Winning Kaggle the Reproducible Way

Interactive chapter

This page shows the chapter notebook with its saved results. Constructed examples do not measure competition gains.

Chapter 53: Financial Competition Patterns

Try the decision first

When does removing factor exposure from the features help the score, and when does it cost correlation?

Write your expectation before opening the worked results. Use the chapter activity to compare supported inputs. To change the calculation itself, run the notebook on GitHub.

Your answers stay on this page until you download them. They are not sent to a server.

Open the chapter activity

Worked notebook and saved results

When does removing factor exposure from the features help the score, and when does it cost correlation?

A feature that carries market exposure is legal, but whether it helps depends on the scored target and on whether the factor premium seen in training continues. Neutralizing is a candidate representation to compare under the same metric, not a rule.

Constructed data with a strong factor; the sizes are properties of this generator. Neutralization gains on the factor-neutral target are small here (about 0.02); the large effects are the regime-dependent ones on raw returns.

The experiment

Eight constructed markets of 120 assets. Five features each mix a hidden alpha signal with the asset's market exposure (beta). Returns are a small alpha term, beta times a daily factor return, and noise. Ridge regression is trained on 200 dates while the factor premium averages 0.5, then scored on 200 later dates in which the premium is the control. Features are neutralized by a per-date cross-sectional regression on beta, removing 0%, 50% or 100% of the exposure. Each model is scored by the mean per-date Spearman correlation against the raw returns and against the returns left after removing the day's beta effect.

Calculation code
import numpy as np
from sklearn.linear_model import Ridge

from kaggle_companion.activities._common import clean

SEED = 53
REPLICATES = 8                  # independent constructed markets; every estimate is their mean
ASSETS, TRAIN_DATES, TEST_DATES = 120, 200, 200
LOAD = np.array([0.8, 0.6, 0.5, 0.4, 0.3])      # how strongly each feature carries the market exposure (beta)
SIGNAL = np.array([0.5, 0.4, 0.4, 0.3, 0.3])    # how strongly each feature carries the true alpha signal
TRAIN_PREMIUM = 0.5             # mean daily factor return while the model is trained
FRACTIONS = [0.0, 0.5, 1.0]     # share of the exposure removed from each feature


def make_market(rng, beta, dates, premium):
    """Features, exposures and next-day returns for `dates` dates. A common factor return moves every asset by its beta."""
    alpha = rng.normal(size=(dates, ASSETS))                       # hidden idiosyncratic signal
    exposure = np.tile(beta, (dates, 1))
    X = np.stack([SIGNAL[k] * alpha + LOAD[k] * exposure + rng.normal(size=(dates, ASSETS)) for k in range(len(LOAD))], axis=-1)
    factor = rng.normal(premium, 0.5, size=(dates, 1))             # the factor premium: mean `premium`, daily noise 0.5
    ret = 0.06 * alpha + 0.5 * exposure * factor + 0.5 * rng.normal(size=(dates, ASSETS))
    return X, exposure, ret


def neutralize(X, exposure, fraction):
    """Cross-sectional regression per date: subtract `fraction` of each feature's projection on the exposure."""
    b = exposure - exposure.mean(axis=1, keepdims=True)
    slope = ((X - X.mean(axis=1, keepdims=True)) * b[..., None]).sum(axis=1) / (b ** 2).sum(axis=1, keepdims=True)
    return X - fraction * b[..., None] * slope[:, None, :]


def factor_neutral(ret, exposure):
    """The return left after the day's regression on exposure: what a factor-neutral metric scores."""
    b = exposure - exposure.mean(axis=1, keepdims=True)
    centered = ret - ret.mean(axis=1, keepdims=True)
    return centered - b * ((b * centered).sum(axis=1, keepdims=True) / (b ** 2).sum(axis=1, keepdims=True))


def ranks(a):
    """Per-date ranks scaled to [-0.5, 0.5]."""
    return (a.argsort(axis=1).argsort(axis=1) + 0.5) / a.shape[1] - 0.5


def date_correlations(pred, target):
    """Spearman correlation between prediction and target on each date."""
    p, t = ranks(pred), ranks(target)
    return (p * t).sum(axis=1) / np.sqrt((p ** 2).sum(axis=1) * (t ** 2).sum(axis=1))


def one_market(test_premium, replicate):
    rng = np.random.default_rng(SEED * 100 + replicate)
    beta = rng.normal(size=ASSETS)
    X_train, e_train, r_train = make_market(rng, beta, TRAIN_DATES, TRAIN_PREMIUM)
    X_test, e_test, r_test = make_market(rng, beta, TEST_DATES, test_premium)
    targets = {"raw": r_test, "neutral": factor_neutral(r_test, e_test)}
    out = {}
    for fraction in FRACTIONS:
        # Features are neutralized with the same rule in training and in the later period; the target is never altered.
        model = Ridge(alpha=100).fit(neutralize(X_train, e_train, fraction).reshape(-1, len(LOAD)), ranks(r_train).reshape(-1))
        pred = model.predict(neutralize(X_test, e_test, fraction).reshape(-1, len(LOAD))).reshape(TEST_DATES, ASSETS)
        for name, target in targets.items():
            c = date_correlations(pred, target)
            out[(fraction, name)] = (float(c.mean()), float(c.std()))
    return out


def run(test_premium):
    markets = [one_market(test_premium, i) for i in range(REPLICATES)]
    score = {}
    for name in ("raw", "neutral"):
        score[name] = {str(f): {"mean": float(np.mean([m[(f, name)][0] for m in markets])),
                               "daily_sd": float(np.mean([m[(f, name)][1] for m in markets])),
                               "per_market": [m[(f, name)][0] for m in markets]} for f in FRACTIONS}
    return clean({"test_premium": test_premium, "score": score, "train_premium": TRAIN_PREMIUM,
                  "replicates": REPLICATES, "assets": ASSETS, "test_dates": TEST_DATES})

Measure it across the control

The control is mean factor return in the scored period (training period: 0.5). Each value below runs the whole experiment from the generator up.

Calculation code
import pandas as pd
from kaggle_companion.activities.ch53 import explain

VALUES = [0.5, 0.25, 0.0, -0.5]
results = {value: run(value) for value in VALUES}
table = pd.DataFrame({str(v): explain(results[v], v)['metrics'] for v in VALUES})
print(table.to_string())
                                       0.5    0.25     0.0    -0.5
Raw features, raw returns            0.274   0.165   0.045  -0.186
Neutralized, raw returns             0.063   0.065   0.066   0.061
Raw features, neutral returns        0.055   0.055   0.055   0.055
Neutralized, neutral returns         0.072   0.072   0.072   0.072
Cost of neutralizing (raw returns)  +0.211  +0.100  -0.021  -0.247

Look at it

The figure shows the default setting, mean factor return in the scored period (training period: 0.5) = -0.5.

Calculation code
import matplotlib.pyplot as plt
from kaggle_companion.activities.ch53 import draw, NCOLS, HEIGHT

DEFAULT = -0.5
fig, axes = plt.subplots(1, NCOLS, figsize=(6.6 if NCOLS == 1 else 10.4, HEIGHT), layout='constrained')
draw(axes, results[DEFAULT], DEFAULT)
plt.show()
Saved figure from this chapter calculation

What the result says

With a premium of -0.50 in the scored period (0.5 in training), raw features score -0.186 against raw returns and fully neutralized features 0.061, so 0.061 - (-0.186) = 0.247 is what neutralizing gains there. Against factor-neutral returns the scores are 0.055 and 0.072. The daily correlation on raw returns has a standard deviation of 0.230 with raw features and 0.082 with neutralized ones.

Apply this to your competition

Book location: Chapter 53, Feature Neutralization: Removing Market Exposure.

Technical corrections

Apply a lesson from this chapter

Select a lesson whose conditions match your project. State how you would test it and what would make you reject it.

Stress-test training-sample sensitivity in volatile markets

Conditions

  • training data mixes changing market conditions

Action

  • Proposed adaptation of source observation: As a proposed adaptation, compare fitted behavior and error across temporal training windows rather than trusting one sample.

Check

  • Proposed verification: Check predictions and errors on successive held-out regimes.

Limits

  • The source reports instability without documenting a time-split protocol; no model superiority follows.

Find source links and value grades in the lesson atlas