import numpy as np
import matplotlib.pyplot as plt
RNG = np.random.default_rng(5)
SIGMA = 0.15
DEGREES = np.arange(1, 11)
def f(x):
return np.sin(2 * x) + 2 * (x - 0.5) ** 3 - 0.5 * x
def sample(n, rng):
x = rng.uniform(0, 1, n)
return x, f(x) + rng.normal(0, SIGMA, n)
def design(x, d):
return np.vander(x, d + 1, increasing=True)
def fit(x, y, d):
coef, *_ = np.linalg.lstsq(design(x, d), y, rcond=None)
return coef
def mse(coef, x, y):
return float(np.mean((design(x, len(coef) - 1) @ coef - y) ** 2))
def cross_val_mse(x, y, d, K, rng):
folds = np.array_split(rng.permutation(len(x)), K)
tot = cnt = 0
for i in range(K):
va = folds[i]
tr = np.concatenate([folds[j] for j in range(K) if j != i])
tot += mse(fit(x[tr], y[tr], d), x[va], y[va]) * len(va)
cnt += len(va)
return tot / cntNested cross-validation
Examples
Choosing a model and estimating its error are two different jobs. If we do both with the same data, the answer is optimistic by an amount we can predict.
On this page we measure that amount.
Setup
Two procedures
Flat cross-validation cross-validates every degree and reports the smallest score.
Nested cross-validation splits off an outer test fold first. Inside the remaining data it cross-validates to pick a degree. It then refits that degree on the inner data and scores it on the outer fold, which took no part in the choice. This is repeated for every outer fold.
def flat_cv(x, y, K=10, rng=None):
"""Select and report with the same data. Optimistic."""
rng = rng or np.random.default_rng()
scores = [cross_val_mse(x, y, d, K, rng) for d in DEGREES]
j = int(np.argmin(scores))
return DEGREES[j], scores[j]
def nested_cv(x, y, K_outer=5, K_inner=10, rng=None):
"""Select inside, score outside."""
rng = rng or np.random.default_rng()
outer = np.array_split(rng.permutation(len(x)), K_outer)
scores, chosen = [], []
for i in range(K_outer):
test = outer[i]
rest = np.concatenate([outer[j] for j in range(K_outer) if j != i])
inner = [cross_val_mse(x[rest], y[rest], d, K_inner, rng)
for d in DEGREES]
d = DEGREES[int(np.argmin(inner))]
scores.append(mse(fit(x[rest], y[rest], d), x[test], y[test]))
chosen.append(d)
return chosen, float(np.mean(scores))- 1
- This fold is put aside. Nothing after this line may look at it.
- 2
-
The whole selection runs on
restonly. - 3
-
We refit the chosen degree on all of
rest, then score once ontest.
How large is the gap
We repeat both procedures on fresh data sets and compare them to the truth. The truth here is the error of the selected model, measured on a large independent test set.
N = 80
REPS = 120
xt = RNG.uniform(0, 1, 20_000)
yt = f(xt) + RNG.normal(0, SIGMA, 20_000)
flat_scores, nested_scores, truth_scores = [], [], []
for _ in range(REPS):
x, y = sample(N, RNG)
d_flat, s_flat = flat_cv(x, y, K=10, rng=RNG)
_, s_nested = nested_cv(x, y, K_outer=5, K_inner=10, rng=RNG)
truth = mse(fit(x, y, d_flat), xt, yt)
flat_scores.append(s_flat)
nested_scores.append(s_nested)
truth_scores.append(truth)
flat_scores = np.array(flat_scores)
nested_scores = np.array(nested_scores)
truth_scores = np.array(truth_scores)
print(f"{'':22s}{'mean':>9s}{'bias vs truth':>16s}")
print(f"{'flat CV (reported)':22s}{flat_scores.mean():9.4f}{flat_scores.mean() - truth_scores.mean():+16.4f}")
print(f"{'nested CV':22s}{nested_scores.mean():9.4f}{nested_scores.mean() - truth_scores.mean():+16.4f}")
print(f"{'true test error':22s}{truth_scores.mean():9.4f}{0.0:+16.4f}")- 1
- The benchmark. The degree that flat CV picked, refitted on all the training data, scored on 20 000 fresh points.
mean bias vs truth
flat CV (reported) 0.0232 -0.0014
nested CV 0.0260 +0.0014
true test error 0.0246 +0.0000
fig, ax = plt.subplots(figsize=(6.4, 3.0))
bins = np.linspace(min(flat_scores.min(), truth_scores.min()) * 0.9,
max(nested_scores.max(), truth_scores.max()) * 1.02, 34)
ax.hist(flat_scores, bins=bins, alpha=0.75, label="flat CV (reported)")
ax.hist(nested_scores, bins=bins, alpha=0.6, label="nested CV")
ax.axvline(truth_scores.mean(), color="#a8452f", ls="--", lw=1.4,
label="mean true test error")
ax.set(xlabel="reported MSE", ylabel="count")
ax.set_title("What each procedure reports")
ax.legend()
plt.show()Nested cross-validation does not land exactly on the truth either. It sits a little above it. Each outer fold trains on only four fifths of the data, so the models it scores are slightly worse than the one we will finally fit on everything. Nested CV is mildly pessimistic and flat CV is optimistic. Of the two, being pessimistic is the safer mistake.
The gap is small here, because the degree of a polynomial is a search over ten options. It grows with the size of the search. A grid over three hyper-parameters with ten values each is a thousand comparisons, and the smallest of a thousand noisy numbers lies well below the truth.
The same thing with scikit-learn
GridSearchCV is the inner loop. Putting it inside cross_val_score gives the outer loop.
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import GridSearchCV, KFold, cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import PolynomialFeatures
x, y = sample(N, RNG)
X = x.reshape(-1, 1)
pipe = Pipeline([("poly", PolynomialFeatures()), ("lin", LinearRegression())])
grid = {"poly__degree": DEGREES}
inner = GridSearchCV(pipe, grid, cv=KFold(10, shuffle=True, random_state=0),
scoring="neg_mean_squared_error")
flat = -inner.fit(X, y).best_score_
outer = -cross_val_score(inner, X, y, cv=KFold(5, shuffle=True, random_state=1),
scoring="neg_mean_squared_error").mean()
print(f"flat (best_score_): {flat:.4f}")
print(f"nested (cross_val): {outer:.4f}")- 1
-
best_score_is the score of the winner. It is not a test error. - 2
- Here the whole grid search is refitted inside every outer fold.
flat (best_score_): 0.0225
nested (cross_val): 0.0208
The rule
Any data we used to make a choice cannot also be used to estimate how good that choice is.
Choices include the model family, the hyper-parameters, the features, which points to drop as outliers, and which of three attempts to write up.
Standardization is also a choice. Fit the scaler inside the fold, on the training part only. A scaler fitted on all the data has seen the mean of the validation fold.
For the project, cross-validate to choose, and report the score from data that was held out before you started choosing. If you have tuned against your test set even once, it is a validation set now, and you need a new one.