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 44: Loss Functions for CV

Try the decision first

Does focal loss beat cross-entropy on the metric that is scored, and what does it cost elsewhere?

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

Does focal loss beat cross-entropy on the metric that is scored, and what does it cost elsewhere?

Focal loss is a popular answer to class imbalance, and the chapter asks that it win against an unchanged cross-entropy reference on the scored metric before it is kept. Sixteen paired trainings show where the gain stops and what changes silently, such as the scale of the predicted probabilities.

Constructed data with a hard minority subgroup; the sizes of these effects are properties of this generator, not a competition result.

The experiment

A constructed binary task with 5% positives, six numeric features and a hard subgroup (30% of positives look like negatives on the first feature). A one-hidden-layer network, 16 tanh units, is trained full-batch with Adam on the focal loss with the control's gamma and, from the same start and the same rows, on cross-entropy (gamma = 0). The 2,000 training rows hold about 100 positives. Both models are scored on 12,000 fresh rows by average precision (the ranking metric), by F1 at the default cutoff of 0.5, by F1 at a cutoff tuned on a separate 2,000 development rows, by log loss and by the mean predicted probability, averaged over 16 draws.

Calculation code
import numpy as np
from sklearn.metrics import average_precision_score, log_loss

from kaggle_companion.activities._common import clean

SEED = 44
DRAWS = 16        # independent training sets; every estimate is a mean over draws
N_TRAIN, N_DEV, N_ASSESS = 2000, 2000, 12000
PREVALENCE = 0.05


def generate(n, rng):
    """Rare positives (5%). Two features separate them, and 30% of positives form a hard subgroup that looks different."""
    y = (rng.random(n) < PREVALENCE).astype(int)
    X = rng.normal(size=(n, 6))
    X[:, 0] += 1.4 * y
    X[:, 1] += 0.9 * y
    hard = (y == 1) & (rng.random(n) < 0.3)
    X[hard, 0] -= 1.4          # the hard positives hide on feature 0 ...
    X[hard, 2] += 2.0          # ... and show up on feature 2 instead
    return X, y


def focal_loss(z, y, gamma):
    """Mean binary focal loss on logits z: -(1 - p_t)^gamma * log(p_t). gamma = 0 is ordinary cross-entropy."""
    p = np.clip(1 / (1 + np.exp(-z)), 1e-7, 1 - 1e-7)
    p_t = np.where(y == 1, p, 1 - p)
    return float(np.mean(-((1 - p_t) ** gamma) * np.log(p_t)))


def focal_grad(z, y, gamma):
    """Derivative of the focal loss with respect to each logit (checked against finite differences in verify)."""
    p = np.clip(1 / (1 + np.exp(-z)), 1e-7, 1 - 1e-7)
    q = 1 - p
    pos = gamma * p * q ** gamma * np.log(p) - q ** (gamma + 1)
    neg = -(gamma * q * p ** gamma * np.log(q) - p ** (gamma + 1))
    return np.where(y == 1, pos, neg)


def fit_mlp(X, y, gamma, seed, hidden=16, epochs=200, lr=0.03, decay=1e-3):
    """One-hidden-layer network trained full-batch with Adam on the focal loss. Returns a predict-probability function."""
    rng = np.random.default_rng(seed)
    n, d = X.shape
    params = [rng.normal(0, 1 / np.sqrt(d), (d, hidden)), np.zeros(hidden), rng.normal(0, 1 / np.sqrt(hidden), hidden), np.array([-2.0])]
    m1 = [np.zeros_like(p) for p in params]
    m2 = [np.zeros_like(p) for p in params]
    for t in range(1, epochs + 1):
        h = np.tanh(X @ params[0] + params[1])
        dz = focal_grad(h @ params[2] + params[3][0], y, gamma) / n
        dh = np.outer(dz, params[2]) * (1 - h ** 2)
        grads = [X.T @ dh + decay * params[0], dh.sum(0), h.T @ dz + decay * params[2], np.array([dz.sum()])]
        for i, g in enumerate(grads):                       # Adam update
            m1[i] = 0.9 * m1[i] + 0.1 * g
            m2[i] = 0.999 * m2[i] + 0.001 * g * g
            params[i] -= lr * (m1[i] / (1 - 0.9 ** t)) / (np.sqrt(m2[i] / (1 - 0.999 ** t)) + 1e-8)

    def logit(X_new):
        return np.tanh(X_new @ params[0] + params[1]) @ params[2] + params[3][0]
    return logit


def f1(y, flagged):
    """F1 of the positive class: 2TP / (2TP + FP + FN)."""
    tp = np.sum(flagged & (y == 1))
    return 2 * tp / max(2 * tp + np.sum(flagged & (y == 0)) + np.sum(~flagged & (y == 1)), 1)


def tuned_threshold(y, p):
    """The probability cutoff that maximises F1 on development rows."""
    cuts = np.linspace(0.02, 0.9, 60)
    return cuts[np.argmax([f1(y, p >= c) for c in cuts])]


def score(logit, X_dev, y_dev, X_a, y_a):
    z = logit(X_a)
    p = np.clip(1 / (1 + np.exp(-z)), 1e-6, 1 - 1e-6)
    cut = tuned_threshold(y_dev, 1 / (1 + np.exp(-logit(X_dev))))
    return {"ap": average_precision_score(y_a, p), "f1_default": f1(y_a, p >= 0.5), "f1_tuned": f1(y_a, p >= cut),
            "log_loss": log_loss(y_a, p), "mean_probability": p.mean(), "flagged_default": (p >= 0.5).mean()}


def run(gamma):
    rng = np.random.default_rng(SEED)
    X_a, y_a = generate(N_ASSESS, rng)                         # assessment rows, used only for scoring
    rows = {"ce": [], "focal": []}
    for draw in range(DRAWS):
        X, y = generate(N_TRAIN, rng)
        X_d, y_d = generate(N_DEV, rng)
        for name, g in (("ce", 0.0), ("focal", float(gamma))):
            if name == "focal" and gamma == 0:             # gamma 0 is the comparator itself
                rows["focal"].append(rows["ce"][-1])
                continue
            rows[name].append(score(fit_mlp(X, y, g, seed=draw), X_d, y_d, X_a, y_a))
    keys = list(rows["ce"][0])
    mean = {name: {k: float(np.mean([r[k] for r in rs])) for k in keys} for name, rs in rows.items()}
    paired = {k: np.array([f[k] - c[k] for f, c in zip(rows["focal"], rows["ce"])]) for k in keys}
    # Sanity test from the chapter: gamma = 0 must reduce to ordinary cross-entropy.
    z_test = np.random.default_rng(0).normal(size=500)
    y_test = (np.random.default_rng(1).random(500) < 0.3).astype(int)
    p_test = 1 / (1 + np.exp(-z_test))
    return clean({
        "gamma": gamma,
        "ce": mean["ce"], "focal": mean["focal"],
        "ap_gain": paired["ap"].mean(), "ap_gain_se": paired["ap"].std(ddof=1) / np.sqrt(DRAWS) if gamma else 0.0,
        "ap_win_rate": float((paired["ap"] > 0).mean()) if gamma else 0.0,
        "f1_tuned_gain": paired["f1_tuned"].mean(), "f1_default_gain": paired["f1_default"].mean(),
        "gamma0_vs_log_loss": abs(focal_loss(z_test, y_test, 0.0) - log_loss(y_test, p_test)),
        "prevalence": PREVALENCE, "draws": DRAWS, "assessment_rows": N_ASSESS,
    })

Measure it across the control

The control is focusing parameter gamma (0 is ordinary cross-entropy). Each value below runs the whole experiment from the generator up.

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

VALUES = [0, 1, 2, 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               1               2               5
Average precision, cross-entropy           0.301           0.301           0.301           0.301
Average precision, focal                   0.301           0.309           0.322           0.291
Paired gain (standard error)      +0.000 (0.000)  +0.008 (0.002)  +0.021 (0.003)  -0.010 (0.004)
F1 at cutoff 0.5, focal                    0.218           0.224           0.198           0.080
Log loss, cross-entropy                    0.155           0.155           0.155           0.155
Log loss, focal                            0.155           0.187           0.256           0.426

Look at it

The figure shows the default setting, focusing parameter gamma (0 is ordinary cross-entropy) = 2.

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

DEFAULT = 2
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

At gamma 2, average precision is 0.322 against 0.301 for cross-entropy: 0.322 - 0.301 = 0.021 (standard error 0.003, better in 94% of the draws). The ranking improves. At the default cutoff F1 moves from 0.218 to 0.198; with a cutoff tuned on development rows it moves from 0.341 to 0.350. Log loss goes from 0.155 to 0.256 because the mean predicted probability is 0.191 on a 0.050 positive rate.

Apply this to your competition

Book location: Chapter 44, Focal Loss for Imbalanced Classes.

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.

Use focal loss for sparse attribute prediction

Conditions

  • instance masks also require imbalanced attribute labels

Action

  • Proposed conditional application: Add an attribute head and compare focal loss with the previous attribute loss.

Check

  • Proposed check: Compare previous loss/focal variants at fixed detector on independent attribute F1, mask score and rare labels.

Limits

  • Full detector took ~69 TPU hours; attribute thresholds and base model confound overall scoring.

Find source links and value grades in the lesson atlas

Keep standard cross-entropy as a comparator for specialized image losses

Conditions

  • specialized losses or sampling are proposed for noisy fine-grained images

Action

  • Proposed adaptation of source observation: Compare each alternative with the same cross-entropy baseline and record its actual retention status.

Check

  • Proposed verification: Retune optimizer-sensitive alternatives and evaluate minority and noisy examples separately.

Limits

  • Local negative evidence lacks settings and does not establish universal ineffectiveness.

Find source links and value grades in the lesson atlas

Align training and selection with a nonstandard metric

Conditions

  • a weighted competition loss differs materially from ordinary logloss

Action

  • Proposed conditional application: Implement the actual metric and compare an aligned training objective with ordinary logloss.

Check

  • Proposed check: Implement metric test fixtures, compare aligned and standard training losses under identical patient folds, and select models by the actual target metric.

Limits

  • A metric-aligned surrogate may have optimization difficulties; the source comparison is specific to small STRIP AI folds.

Find source links and value grades in the lesson atlas