๐Ÿ” Cross-Validation & Model Selection

One split is a sample, not a measurement - and choosing between models spends the folds you chose on
Validation & Model Selection Beginner
Contents

    Cross-Validation and Model Selection

    Every other walkthrough in this section answers how does this algorithm work. This one answers a different question: how do you know whether it works, and why the obvious way of checking is wrong often enough to matter.

    The short version: a single train/test split gives you one number, and that number has a standard error nobody prints. Cross-validation gives you a distribution instead. And the moment you start choosing between models using that distribution, you introduce a new bias that needs a second layer of validation to remove.

    Everything here is worked on 12 EEG recordings so the folds can be written out by hand, then checked against scikit-learn.

    Display helpers ready: banner, step, check, expand, show_matrix, show_matmul, show_elementwise.
    12 recordings x 3 features
    
        id      region   line_length    spike_rate           age   y_class    y_reg
    -------------------------------------------------------------------------------
     rec00     frontal          12.0           0.5          24.0         0      0.2
     rec01    temporal          15.0           1.2          31.0         0      0.5
     rec02    temporal          42.0           6.8          58.0         1      6.4
     rec03   occipital          38.0           5.1          47.0         1      4.8
     rec04     frontal          11.0           0.3          19.0         0      0.1
     rec05    temporal          55.0           8.2          63.0         1      9.1
     rec06   occipital          40.0           1.9          35.0         1      3.5
     rec07    temporal          47.0           7.4          52.0         1      7.2
     rec08     frontal          14.0           0.8          28.0         0      0.3
     rec09   occipital          51.0           6.1          60.0         1      6.9
     rec10     frontal          45.0           2.4          41.0         0      1.6
     rec11    temporal          35.0           4.7          30.0         1      4.1
    -------------------------------------------------------------------------------
    
    class balance: 5 negative, 7 positive
    y_reg mean = 3.7250, std = 3.0518

    1. The problem: one split gives one number, and the number moves

    Hold out 25% of the data, score the model, report it. The trouble is that "25% of the data" is not one thing โ€” it is a choice, and different choices give different answers.

    Below, the same model on the same data, with nothing changed but the random seed that decides who lands in the test set.

    ==============================================================================
      THE SAME MODEL, THE SAME DATA, DIFFERENT SPLITS
    ==============================================================================
    
    Ten random 75/25 splits
    ==============================================================================
       seed               test recordings   accuracy
      ------------------------------------------------
          0           rec10, rec11, rec06      0.667
          1           rec06, rec08, rec03      0.667
          2           rec00, rec11, rec06      0.667
          3           rec11, rec04, rec02      1.000
          4           rec11, rec04, rec02      1.000
          5           rec11, rec02, rec08      1.000
          6           rec09, rec05, rec04      1.000
          7           rec07, rec10, rec06      0.667
          8           rec09, rec05, rec08      1.000
          9           rec06, rec02, rec10      0.667
      ------------------------------------------------
       mean                                    0.833
      range                                0.667 - 1.000
    
      Ten defensible answers between 67% and 100%.
      Reporting any single one of them is reporting a coin flip as a fact.
    
    Worse: the split can change WHICH MODEL WINS
    ==============================================================================
       seed      tree    logistic  winner
      ----------------------------------------
          0     0.750       0.750  tie
          1     0.750       0.750  tie
          2     0.750       0.500  tree
          3     1.000       1.000  tie
          4     0.750       0.750  tie
          5     1.000       1.000  tie
          6     1.000       1.000  tie
          7     0.750       0.750  tie
          8     1.000       1.000  tie
          9     0.750       0.750  tie
      ----------------------------------------
      tree wins 1x, logistic wins 0x, tied 9x
    
      If you had run seed 0 and stopped, you would have shipped one model.
      Seed 1 would have shipped the other. Nothing about the models changed.

    2. k-fold, by hand

    The fix is to stop throwing data away. Split the data into $k$ equal folds; train $k$ times, each time holding out a different fold; every observation is tested on exactly once, and every observation trains the model $k-1$ times.

    $$\text{CV}_k = \frac{1}{k}\sum_{i=1}^{k} \text{score}\big(f^{(-i)},\ \text{fold}_i\big)$$

    where $f^{(-i)}$ is the model trained on everything except fold $i$.

    With 12 recordings and $k = 4$, each fold is exactly 3 recordings โ€” small enough to write every fold out and check the arithmetic.

    ==============================================================================
      4-FOLD CROSS-VALIDATION, WRITTEN OUT
    ==============================================================================
    
    The folds
    ==============================================================================
      fold 0: ['rec00', 'rec04', 'rec08']   classes [0, 0, 0]
      fold 1: ['rec01', 'rec05', 'rec09']   classes [0, 1, 1]
      fold 2: ['rec02', 'rec06', 'rec10']   classes [1, 1, 0]
      fold 3: ['rec03', 'rec07', 'rec11']   classes [1, 1, 1]
    
    Train k times, hold out one fold each time
    ==============================================================================
       fold   train on                     test on    correct      acc
      ----------------------------------------------------------------
          0          9         rec00, rec04, rec08     3/3       1.000
          1          9         rec01, rec05, rec09     3/3       1.000
          2          9         rec02, rec06, rec10     2/3       0.667
          3          9         rec03, rec07, rec11     1/3       0.333
      ----------------------------------------------------------------
      CV_4 = (1.000 + 1.000 + 0.667 + 0.333) / 4 = 0.7500
    
      Every recording was tested exactly once: 9/12 correct overall = 0.7500
      (equal to the fold mean here only because the folds are the same size)
    
    Check against scikit-learn
    ==============================================================================
      by hand      : [1.     1.     0.6667 0.3333]   mean 0.7500
      scikit-learn : [1.     1.     0.6667 0.3333]   mean 0.7500
      max|diff| = 0.00e+00   MATCH: True

    3. The number nobody prints: the standard error

    A CV score is a mean of $k$ numbers, so like any mean it has a spread. The usual estimate is

    $$\text{SE} = \frac{s}{\sqrt{k}}, \qquad s = \text{standard deviation of the fold scores}$$

    Reporting CV = 0.83 without it invites a comparison that the data cannot support. Two models differing by 0.02 with a standard error of 0.09 are not distinguishable โ€” and this is the single most common way model comparisons go wrong.

    A caution the formula hides: the $k$ fold scores are not independent, because their training sets overlap heavily (any two share $k-2$ folds). So $s/\sqrt{k}$ understates the true uncertainty. It is a floor, not an estimate โ€” treat a difference smaller than one SE as no difference at all.

    ==============================================================================
      HOW UNCERTAIN IS A CV SCORE?
    ==============================================================================
    
    Fold-to-fold spread on our 12 recordings
    ==============================================================================
      fold scores  : [1.     1.     0.6667 0.3333]
      mean         : 0.7500
      std (ddof=1) : 0.3191
      SE = s/sqrt(k) = 0.3191/sqrt(4) = 0.1596
    
      report: 0.750 +/- 0.160
    
    Why that matters when comparing models
    ==============================================================================
               model   CV mean       SE  fold scores
      ------------------------------------------------------------
        tree depth 1    0.9167   0.0833  [1.    1.    0.667 1.   ]
        tree depth 2    0.7500   0.1596  [1.    1.    0.667 0.333]
        tree depth 4    0.9167   0.0833  [1.    1.    0.667 1.   ]
            logistic    0.9167   0.0833  [1.    1.    0.667 1.   ]
         naive bayes    0.9167   0.0833  [1.    1.    0.667 1.   ]
      ------------------------------------------------------------
      best mean: 'tree depth 1' at 0.9167 (SE 0.0833)
    
      Within one SE of the best -- i.e. NOT distinguishable from it:
          tree depth 1  0.9167
          tree depth 4  0.9167
              logistic  0.9167
           naive bayes  0.9167
    
      With 12 recordings the data simply cannot separate these. Picking the
      top row and claiming it is 'the best model' overstates what you know.
    
    The 1-SE rule
    ==============================================================================
      A standard response: among models within one SE of the best, choose
      the SIMPLEST rather than the highest-scoring. It costs nothing in
      measurable accuracy and buys robustness, because the ranking inside
      that band is mostly noise.

    4. Choosing the right splitter

    Plain k-fold assumes every row is an independent draw from the same distribution. When that assumption fails โ€” and it usually does โ€” plain k-fold gives an answer that is confidently wrong. Each variant below fixes one specific violation.

    Splitter Use when What breaks without it
    Stratified k-fold classification, especially imbalanced a fold with no positives makes some metrics undefined and all of them unstable
    Leave-one-out very small $n$ nothing breaks, but the estimate has high variance and costs $n$ fits
    Group k-fold repeated measures โ€” several rows per patient/subject/session the same subject appears in train and test: you measure memorisation, not generalisation
    TimeSeriesSplit anything ordered in time training on the future to predict the past

    4.1 Stratified

    Plain k-fold on 12 recordings with 7 positives can easily produce a fold that is all one class. Stratified k-fold keeps each fold's class ratio close to the whole dataset's.

    ==============================================================================
      STRATIFIED K-FOLD
    ==============================================================================
    
    Plain k-fold: what the class balance does
    ==============================================================================
      overall: 5 negative, 7 positive  (58% positive)
    
       fold      test classes   % positive
      --------------------------------------
          0         [0, 0, 1]          33%
          1         [1, 1, 1]         100%
          2         [0, 1, 1]          67%
          3         [0, 1, 0]          33%
      --------------------------------------
    
    Stratified: each fold mirrors the whole
    ==============================================================================
       fold      test classes   % positive
      --------------------------------------
          0         [0, 0, 1]          33%
          1         [1, 1, 0]          67%
          2         [0, 1, 1]          67%
          3         [0, 1, 1]          67%
      --------------------------------------
      spread of fold positive-rate: plain vs stratified
        plain      : [0.333, 1.0, 0.667, 0.333]   range 0.667
        stratified : [0.333, 0.667, 0.667, 0.667]   range 0.333
    
      With rarer positives this stops being cosmetic: a fold with zero
      positives makes recall undefined and AUC uncomputable.
    
    Leave-one-out: k = n
    ==============================================================================
      12 fits, each testing one recording
      per-recording correct: [1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1]
      LOO accuracy = 10/12 = 0.8333
    
      compare 4-fold: 0.7500
      LOO uses the most training data (n-1 rows) so it is nearly unbiased,
      but each test set is a single row, so the estimate is high-variance
      and it costs n fits. For n=12 that is fine; for n=100k it is not.

    4.2 Group k-fold โ€” the one that bites EEG work

    Suppose the 12 recordings come from 6 patients, two recordings each. Two recordings from the same patient are not independent: they share anatomy, electrode placement, baseline rhythms.

    Plain k-fold will happily put patient 3's first recording in the training set and their second in the test set. The model can then score well by recognising the patient rather than the pathology โ€” and the reported accuracy is measuring something you will never have at deployment, when every patient is new.

    Group k-fold keeps all rows from a group on the same side of every split.

    ==============================================================================
      GROUP K-FOLD: WHEN ROWS ARE NOT INDEPENDENT
    ==============================================================================
    
    The data has hidden structure
    ==============================================================================
          id   patient    y
       rec00        P0    0
       rec01        P0    0
       rec02        P1    1
       rec03        P1    1
       rec04        P2    0
       rec05        P2    1
       rec06        P3    1
       rec07        P3    1
       rec08        P4    0
       rec09        P4    1
       rec10        P5    0
       rec11        P5    1
    
    Plain k-fold splits patients across train and test
    ==============================================================================
      fold 0: test patients [1, 2, 5]  |  ALSO IN TRAIN: [2, 5]
      fold 1: test patients [0, 3]  |  ALSO IN TRAIN: []
      fold 2: test patients [2, 4, 5]  |  ALSO IN TRAIN: [2, 5]
    
      4 patient-level leaks across 3 folds.
    
    GroupKFold keeps each patient whole
    ==============================================================================
      fold 0: test patients [2, 5]  |  also in train: none
      fold 1: test patients [1, 4]  |  also in train: none
      fold 2: test patients [0, 3]  |  also in train: none
    
    What the difference is worth, in accuracy
    ==============================================================================
      plain  3-fold : [0.75 0.75 1.  ]  mean 0.8333
      group  3-fold : [0.75 1.   0.75]  mean 0.8333
      optimism from ignoring groups: +0.0000
    
      The group number is the one that predicts performance on a NEW
      patient, which is the only question anyone actually asked.

    4.3 Time series โ€” shuffling is cheating

    If rows are ordered in time, a random split trains on the future to predict the past. The model can learn from information that would not exist at prediction time, and the score is meaningless.

    TimeSeriesSplit produces expanding windows: always train on a prefix, always test on the block immediately after it. Note the consequence โ€” the training set size differs between folds, so the fold scores are not identically distributed, and averaging them is a rougher approximation than in plain k-fold.

    ==============================================================================
      TIME-SERIES SPLITTING
    ==============================================================================
    
    Expanding-window folds
    ==============================================================================
       fold               train (in time order)            test
      ----------------------------------------------------------
          0                        [0, 1, 2, 3]          [4, 5]
          1                  [0, 1, 2, 3, 4, 5]          [6, 7]
          2            [0, 1, 2, 3, 4, 5, 6, 7]          [8, 9]
          3      [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]        [10, 11]
      ----------------------------------------------------------
      Train indices are always BEFORE test indices. No fold ever sees the
      future. Note the training set grows: fold 0 trains on 4 rows, fold 3
      on 10 -- so the folds are not measuring quite the same thing.
    
    What a random split would do here
    ==============================================================================
      fold 0: test [4, 6, 11]  -> 2 test rows precede some training row
      fold 1: test [2, 8, 10]  -> 3 test rows precede some training row
      fold 2: test [1, 7, 9]  -> 3 test rows precede some training row
      fold 3: test [0, 3, 5]  -> 3 test rows precede some training row
    
      11 causality violations. Every one is the model being handed
      information that will not exist when it runs for real.

    5. Nested CV โ€” the bias you create by choosing

    Here is the subtle one, and the reason this page exists.

    Section 3 used cross-validation to compare five models and pick the best. But the winning score was the maximum over five noisy estimates โ€” and the maximum of noisy numbers is biased upward. Some of that model's advantage is real skill, and some is simply that it got the friendlier folds.

    Report that maximum as your expected performance and you are reporting a number that includes the luck. The more candidates you try, the worse it gets.

    Nested cross-validation separates the two jobs:

    • an inner loop searches hyperparameters, using only the training part of each outer fold
    • an outer loop scores the whole procedure โ€” "tune, then fit" โ€” on data the inner loop never saw

    The outer score answers the honest question: if I run my entire model-selection process on new data, how well will the result do?

    The demonstration below is the important one on this page. It uses pure noise โ€” features with no relationship to the target whatsoever, where the true accuracy of any model is chance. A flat (non-nested) search still reports well above chance. Nested CV does not.

    ==============================================================================
      SELECTION BIAS: FLAT SEARCH vs NESTED CV, ON PURE NOISE
    ==============================================================================
      60 samples, 200 features, all random.
      class balance: [30, 30] -- deliberately exact, so
      the majority-class baseline is 0.50 and cannot be mistaken for skill.
      There is no relationship to learn. Honest accuracy = 0.50.
    
    Flat search: score all 40 candidates on the same folds, report the best
    ==============================================================================
      candidate CV scores: min 0.2833  mean 0.4800  max 0.6167  (std 0.0789)
      best candidate     : #24
      best CV            : 0.6167   <- what a flat search reports
    
      Note the mean of all candidates is 0.4800 -- close to the
      truth. It is only the MAXIMUM that is inflated.
    
    Nested CV: pick inside, score outside
    ==============================================================================
      outer fold 0: inner picked candidate # 8 (inner CV 0.6644)  ->  outer score 0.4167
      outer fold 1: inner picked candidate #25 (inner CV 0.6467)  ->  outer score 0.3333
      outer fold 2: inner picked candidate # 5 (inner CV 0.6622)  ->  outer score 0.5000
      outer fold 3: inner picked candidate #24 (inner CV 0.7133)  ->  outer score 0.4167
      outer fold 4: inner picked candidate #21 (inner CV 0.6689)  ->  outer score 0.3333
      nested CV = 0.4000   <- the honest number
    
    The gap
    ==============================================================================
         flat (chose and reported on the same folds)   0.6167
          nested (choice kept inside the inner loop)   0.4000
         truth (labels are random, classes balanced)   0.5000
      --------------------------------------------------------
                         optimism of the flat search  +0.1167
                               optimism of nested CV  -0.1000
    
      The flat search reports 62% on data containing no signal.
      The arithmetic is not wrong -- that really is the best of 40
      scores. But it is the MAXIMUM of 40 noisy numbers, and the
      maximum of noise is not zero.
    
    The bias grows with the number of candidates
    ==============================================================================
      Given the 40 candidate scores above: if you had tried only n of
      them, picked at random, what would your reported best be?
      Averaged over 3000 random draws:
    
       n tried   E[best CV]   optimism
      --------------------------------
             1       0.4801    -0.0199
             2       0.5239    +0.0239
             5       0.5668    +0.0668
            10       0.5863    +0.0863
            20       0.6032    +0.1032
            40       0.6167    +0.1167
      --------------------------------
      monotonically increasing: True
    
      Trying one candidate is roughly honest. Trying forty and reporting
      the winner is not. Nothing about the data changed between those
      rows -- only how many chances you gave yourself.
    
      This is why 'we tried 500 configurations and got 0.94' should raise
      an eyebrow, not confidence.
    
    Being fair to nested CV: unbiased is not the same as exact
    ==============================================================================
      Nested CV came out at 0.4000 just now -- BELOW the true 0.50.
      That is not a failure, it is variance: 5 outer folds of 12 rows each.
      The claim is that nested CV has no systematic upward pull, not that
      it hits 0.50 on one run. Repeating the whole experiment on 12 fresh
      noise datasets:
    
       dataset      flat    nested
      ----------------------------
             0    0.6167    0.5000
             1    0.6667    0.5000
             2    0.6500    0.3833
             3    0.6500    0.5667
             4    0.6833    0.4500
             5    0.6833    0.5000
             6    0.7333    0.5667
             7    0.6500    0.5000
             8    0.7000    0.3667
             9    0.6333    0.4500
            10    0.6500    0.5500
            11    0.6333    0.3333
      ----------------------------
          mean    0.6625    0.4722
         truth    0.5000    0.5000
    
      flat search   : 0.6625  (+0.1625)  above 0.50 on 12/12 datasets
      nested CV     : 0.4722  (-0.0278)  above 0.50 on 3/12 datasets
    
      Averaged over repetitions the flat search sits clearly above chance
      and nested CV sits close to it. That is the difference between a
      biased estimator and a noisy one -- more data fixes noise, but no
      amount of data fixes the bias in a flat search.

    6. What to take away

    A single split is a sample, not a measurement. It has a standard error, and on small data that error is large enough to change which model appears to win.

    Cross-validation gives you a distribution. Use the spread. A difference smaller than one standard error is not a difference โ€” and because the folds share training data, that standard error is itself an optimistic floor.

    Match the splitter to the dependence in your data. Independent rows: k-fold. Classification: stratify. Repeated measures per subject: group. Ordered in time: expanding window. Getting this wrong does not produce a noisy answer, it produces a confident wrong one โ€” the group-vs-plain gap on the EEG data was entirely manufactured by patients appearing on both sides of the split.

    Selecting is measuring. Every hyperparameter you compare on a set of folds spends some of those folds' independence. Tune inside an inner loop, report from an outer one. The noise experiment is the proof: 200 random features, random labels, and a flat grid search still reported well above chance.

    Verified in this notebook

    • Hand-computed 4-fold scores match cross_val_score exactly
    • Every recording tested exactly once; fold mean equals pooled accuracy for equal folds
    • Stratified folds hold the class ratio far tighter than plain folds
    • GroupKFold removes the patient overlap that plain k-fold creates, and the accuracy gap that overlap caused
    • TimeSeriesSplit never places a training row after a test row
    • On data with no signal at all, flat search reports well above 0.50 while nested CV does not

    Where this connects

    [[data-leakage]] is the other half of this story โ€” cross-validation only protects you if the folds are honest, and leakage is what makes them dishonest. [[hyperparameter-search]] is the inner loop of section 5. [[probability-calibration]] needs out-of-fold predictions, which is the machinery built here.

    References

    1. A Study of Cross-Validation and Bootstrap for Accuracy Estimation and Model Selection โ€” Kohavi, 1995 (IJCAI)
    2. On Over-fitting in Model Selection and Subsequent Selection Bias in Performance Evaluation โ€” Cawley & Talbot, 2010 (JMLR)