, ,

Model Evaluation in Python: Metrics, Cross Validation and Data Leakage (Data Science Series, Part 11)

A single train test split moved our churn AUC by nine points depending on the seed. Here is how to pick a metric that matches the decision, read a cross validation spread honestly, and catch the leakage that manufactures scores you should not believe.

Data Science Series · Part 11 of 30

I built a dataset of 3,000 columns of pure random numbers, containing no signal of any kind, and cross validated a model on it. Mean AUC came back at 0.8110.

Nothing was wrong with the cross validation code. Five folds, stratified, a clean pipeline inside each fold. One step happened in the wrong place, and that single misplacement was enough to conjure an AUC of 0.81 out of a random number generator. Move that step inside the loop and the same experiment returns 0.5185, which is the honest answer for noise. That gap between 0.81 and 0.52 is the subject of this part.

Who this is for: you have a working classifier from Part 10 and can read a confusion matrix. You know what precision and recall mean as words, even if you have never had to choose between them. No calculus is assumed and no experience with cross validation is assumed. If sampling and confidence intervals feel hazy, Part 8 covers them, and statistics an analyst uses from the Data Analyst Series covers the descriptive groundwork this part assumes rather than repeats.

Key takeaways

  • One train test split is a single draw from a distribution. On our 486 row extract, five different seeds produced AUCs from 0.7824 to 0.8743, a spread of 9.2 points.
  • Cross validation gives you a mean and a spread. Report both, because a mean without a spread hides how much of your result is luck.
  • Metric choice follows the decision, not the model. AUC ranks, average precision measures a scored campaign, and accuracy on 27 percent churn is nearly useless.
  • Preprocessing leakage is real but frequently mild. Selection leakage and target leakage are the ones that produce fantasy numbers.
  • Any step that looks at the target belongs inside the cross validation loop, including feature selection, encoding and threshold tuning.

Where the churn project stands after Part 10

Last part we fitted a logistic regression to the Telco churn data, read its coefficients as odds ratios, and picked a threshold by weighing a 15 dollar retention call against 500 dollars of saved margin. We finished with a scored customer list and one number describing model quality: an AUC of 0.853 on a held out split of about 120 customers. This part asks whether that number deserves the confidence we placed in it, and the answer turns out to be no.

Nothing changes about the model itself here. Same features from Part 6, same pipeline, same 486 row working extract of the public Telco Customer Churn file. What changes is how we measure it. By the end we will have replaced a single fragile number with a mean, a spread, a metric chosen deliberately, and a protocol that resists the three ways evaluation quietly lies to you.

Instability of a single train test split

Start with an experiment that takes four lines and changes how you read every score you have ever reported. Keep the model, the features and the split ratio identical. Change only random_state, which decides which customers land in the test set, and refit five times.

Same model, same data, five random splitsOnly the split seed changed. Test AUC moved 9.2 points.5 fold CV mean 0.83060.8280.7820.8670.8740.865seed 0seed 1seed 42seed 7seed 20240.750.800.850.90Test AUC
Y axis starts at 0.75 to make the movement visible. Part 10 reported 0.853, which sits comfortably inside this range.

Nine points of AUC separate the luckiest split from the unluckiest, and neither is more correct than the other. Both used the same model on the same 486 customers. If you fit a model on Monday with seed 1, tried a competitor on Tuesday with seed 42, and concluded the second architecture was better, you would have been reading the seed rather than the architecture. I have watched that exact comparison drive a genuine build decision, and the giveaway was that the winning margin, around four points, was smaller than the split noise nobody had measured.

Small test sets drive this. Ours holds roughly 121 customers of whom about 33 churn, so moving a handful of hard cases across the boundary shifts the score noticeably. Scale is the cure that most teams do not have. Below roughly 10,000 rows, treat any single split score as provisional, and go straight to cross validation.

Choosing a metric that matches the decision

Before measuring anything more carefully, settle what you are measuring. Metrics are not interchangeable quality scores. Each answers one specific question, and a metric that answers a question nobody asked is worse than no metric at all, because it carries the authority of a number.

Our churn base rate is 27.16 percent. A model that predicts nobody ever leaves scores 72.84 percent accuracy. Our actual model scores 74.50 percent. Framed that way, months of feature engineering bought 1.7 accuracy points, which sounds like failure. Framed by AUC it scores 0.8306 against a coin flip baseline of 0.5, which sounds like success. Same model, same data, opposite stories, and only one of those framings is useful to a retention manager.

MetricOur modelBaselineQuestion it answers
Accuracy at 0.50.74500.7284How often is the label right, treating both errors as equal
ROC AUC0.83060.5000Does the model rank a churner above a stayer
Average precision0.61210.2716How clean is the flagged list as you work down it
Recall at 0.50.45410.0000What share of real leavers do we contact
Precision at 0.50.53580.2716What share of retention calls are not wasted
Brier score0.14810.1978Are the probabilities themselves trustworthy, lower is better
Five fold cross validated means on the 486 row extract. Baselines are the always predict No model, except AUC where 0.5 is random ranking.

Average precision deserves more attention than it usually receives, because its baseline moves with your class balance. Random ranking scores an average precision equal to the positive rate, which here is 0.2716. Our 0.6121 therefore represents a genuine lift, but quote 0.6121 in a room without stating that baseline and half of them will privately compare it to AUC and conclude the model got worse. On a rare event problem with a 1 percent positive rate, an average precision of 0.15 can be excellent work. Context is doing all the interpretive labour.

Gotcha

Passing predicted labels into roc_auc_score instead of predicted probabilities is the most common evaluation bug I find in review, and it never raises an error. With labels, the curve collapses to a single operating point and the returned figure is really balanced accuracy wearing a different name. On our model the correct call returns 0.8306 and the mistaken one returns 0.6663. Both look like plausible AUCs, which is exactly what makes it dangerous. Feed roc_auc_score the output of predict_proba column 1, never the output of predict.

Cross validation, and reading the fold spread

Cross validation replaces one arbitrary split with several. Divide the data into k folds, hold each fold out in turn, train on the remaining k minus 1, and collect k scores. Every row gets scored exactly once by a model that never saw it. Stratified folds preserve the class balance in each one, which matters at a 27 percent positive rate because an unlucky ordinary fold could arrive noticeably light on churners.

# pinned environment: Python 3.10, numpy 2.2.6, pandas 2.3.3, scikit-learn 1.7.2
import numpy as np
import pandas as pd
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_validate
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

df = pd.read_csv('Telco-Customer-Churn.csv')
df['TotalCharges'] = pd.to_numeric(df['TotalCharges'], errors='coerce')
df = df.dropna(subset=['TotalCharges'])

y = (df['Churn'] == 'Yes').astype(int)
num = ['tenure', 'MonthlyCharges', 'TotalCharges']
cat = ['Contract', 'InternetService', 'PaymentMethod',
       'PaperlessBilling', 'TechSupport', 'OnlineSecurity']
X = pd.get_dummies(df[num + cat], columns=cat, drop_first=True).astype(float)

# the scaler lives INSIDE the pipeline, so it refits on each training fold
pipe = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

scores = cross_validate(
    pipe, X, y, cv=cv,
    scoring=['roc_auc', 'average_precision', 'recall', 'neg_brier_score'])

for name in ['roc_auc', 'average_precision', 'recall']:
    v = scores['test_' + name]
    print('%-20s mean %.4f  std %.4f' % (name, v.mean(), v.std()))
print('auc per fold', ['%.4f' % s for s in scores['test_roc_auc']])
cross_validate accepts a list of scorer names and returns a dict whose score keys are prefixed with test_.
roc_auc              mean 0.8306  std 0.0253
average_precision    mean 0.6121  std 0.0796
recall               mean 0.4541  std 0.1088
auc per fold ['0.8023', '0.8748', '0.8104', '0.8359', '0.8297']
Actual output on the 486 row extract. Read the standard deviations before the means.

Compare those three standard deviations, because they are telling you something the means cannot. AUC is stable at 0.0253. Recall is wild at 0.1088, swinging from 0.3333 in one fold to 0.6296 in another. Ranking quality holds steady while the number of churners you would actually catch at a fixed threshold nearly doubles between folds. That happens because AUC integrates over every threshold, whereas recall is pinned to 0.5 and the probability distribution shifts slightly from fold to fold. Any metric evaluated at a fixed cut point inherits that instability, so a recall target written into a project plan needs a stated tolerance or it will be missed for reasons unrelated to the model.

In practice: five folds is my default and I move to ten only when a single fold drops below roughly 500 rows or 50 positives. Ten folds trains twice as often for a modest reduction in bias, and on a dataset this size the extra folds mostly add variance to the variance estimate. Set shuffle to True with a fixed random_state, since data arriving sorted by signup date or region turns unshuffled folds into an accidental and usually unflattering time split. Report the mean and the standard deviation together, always, in that order and in the same sentence.

Where leakage actually comes from

Data leakage means using information at training time that will not exist at prediction time. Scores go up, and they go up in a way no validation scheme can detect, because the validation data is contaminated by the same mechanism. Three varieties matter, and they are not equally dangerous, which is a point the standard advice tends to flatten.

First, preprocessing leakage. Fit StandardScaler on all 486 rows before splitting and the test rows contribute their mean and standard deviation to the transform. Every tutorial warns about this. On our data it moved AUC from 0.8127 to 0.8134, which is six ten thousandths, or roughly nothing. Say that plainly, because pretending otherwise trains people to hunt the harmless version and miss the lethal ones. Scaling leakage matters when the transform is aggressive, when the dataset is tiny, or when the transform is target aware, and a mean and variance over 486 rows is none of those. Keep the scaler in the pipeline anyway, since correctness is free here, but do not expect it to be the thing that saves you.

Second, target leakage, where a feature encodes the answer. I added a plausible looking exit_survey_sent flag to the churn features, the kind of column that appears in every real warehouse. AUC went from 0.8127 to 0.9879 and recall at the default threshold jumped to 0.9697. Exit surveys are sent after somebody cancels. That column is the label with a different name and a one day delay.

War story

I once spent the better part of two days admiring an AUC of 0.94 on a subscription churn model that had no business scoring above about 0.85. Every check passed. Folds were stratified, the pipeline was clean, the holdout was untouched. What finally broke it was sorting the feature importances and noticing a column called days_since_last_contact carrying more weight than contract type. Retention agents call customers when they announce they are leaving, so for churners that column was almost always a small number recorded after the decision. Rebuilt with a strict cutoff, contact fields frozen at the prediction date, the model settled at 0.86 and stayed there in production. Two days lost, and the lesson I actually kept was to sort feature importances first rather than last, because leakage nearly always announces itself as a column that is inexplicably good.

Third, and least discussed, selection leakage. This is the one that produced 0.8110 from random noise in the opening paragraph, and it is the variety a pipeline will not protect you from unless you build the pipeline correctly.

flowchart TD A[Full dataset] --> B{Does this step look at y} B -- No, fixed rule --> C[Safe outside the loop] B -- Yes, learns from data --> D[Must sit inside the fold] D --> E[Feature selection] D --> F[Target encoding] D --> G[Scaling and imputation] D --> H[Threshold tuning] E --> I[Fit on train fold only] F --> I G --> I H --> I I --> J[Score the held out fold]
One question decides placement. If a step learns anything from the data, it belongs inside the fold.

Leakage a pipeline cannot catch

Here is the experiment from the opening, written out. Generate 3,000 columns of pure Gaussian noise. Keep the real churn labels. Pick the 20 noise columns most correlated with churn, then cross validate a model on those 20. Selection happens once, on all the rows, before the folds are cut, which is exactly how it is usually written in a notebook.

import numpy as np
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

rng = np.random.default_rng(0)
noise = rng.normal(size=(len(y), 3000))      # zero signal, by construction
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000))

# WRONG: selection sees every row, including the rows it will be scored on
picked = SelectKBest(f_classif, k=20).fit_transform(noise, y)
print('selection outside cv %.4f' % cross_val_score(
    model, picked, y, cv=cv, scoring='roc_auc').mean())

# RIGHT: selection is a pipeline step, refitted on each training fold
honest = make_pipeline(SelectKBest(f_classif, k=20), StandardScaler(),
                       LogisticRegression(max_iter=1000))
print('selection inside cv  %.4f' % cross_val_score(
    honest, noise, y, cv=cv, scoring='roc_auc').mean())
Two blocks differ by where SelectKBest sits. Nothing else changes.
Cross validated AUC on 3,000 columns of noiseThere is no signal in this data. Truth is 0.5.random ranking 0.50.81100.5185Selection outside cvSelection inside cv(wrong)(right)0.00.51.0Mean AUC
Same data, same folds, same model. Only the placement of one step differs.

Mechanically it is simple. With 3,000 random columns and 486 rows, a handful will correlate with churn by chance alone. Selecting on all rows locks in those accidents, and because the held out fold was part of the selection, its accidents are locked in too. Cross validation then dutifully confirms a pattern that selection invented. Moving SelectKBest inside the pipeline forces a fresh choice on each training fold, the accidents no longer transfer, and 0.5185 is what honesty looks like on noise.

Anything that consults the target belongs inside. Feature selection, target encoding of high cardinality categoricals, oversampling, imputation fitted on the target, and threshold tuning all qualify. Threshold tuning is the one most often forgotten, because it happens after modelling and feels like reporting rather than fitting. Pick a threshold by scanning your test set for the best F1 and that threshold is fitted to the test set, so the F1 you quote is optimistic by construction. Sweep thresholds on out of fold predictions instead, which is what the table below does. Distinguishing a real effect from an artefact of how you looked is the same discipline described in A/B testing for analysts, and the failure mode is identical: decide the rule before you see the outcome.

ThresholdFlaggedPrecisionRecallF1Accuracy
0.202480.4840.9090.6320.712
0.302050.5220.8110.6350.747
0.401600.5310.6440.5820.749
0.501120.5360.4550.4920.745
0.60570.6320.2730.3810.759
Swept on out of fold predictions across all 486 customers, 132 of whom churned. Every row here was scored by a model that had not seen it.

Notice how much steadier this table is than the same sweep on 121 test rows in Part 10. Four times the customers, and each one scored out of fold, so a single borderline case no longer swings a whole column. Accuracy also does its usual disappearing act, sitting between 0.712 and 0.759 across a range where recall falls from 0.909 to 0.273. Anyone optimising accuracy here would land on 0.60 and miss three quarters of the leavers.

Confusion matrix at threshold 0.30Out of fold predictions, all 486 customersPredicted stayPredicted churnStayedChurned2569825107correctly left alonewasted callsleavers we missedsaved, if the call works
At 15 dollars a call and 500 dollars of margin, 98 wasted calls cost 1,470 dollars against 107 chances at 53,500 dollars.

One error deserves showing because it catches people who have done everything else correctly. Cross validate on a small or heavily imbalanced dataset with too many folds and a fold eventually arrives with no positives in it. Scoring by AUC on that fold cannot work, and scikit-learn says so rather than guessing:

>>> from sklearn.metrics import roc_auc_score
>>> roc_auc_score([0, 0, 0, 0], [0.1, 0.4, 0.35, 0.8])
Traceback (most recent call last):
  File "sklearn/metrics/_ranking.py", line 640, in _binary_roc_auc_score
    raise ValueError(
ValueError: Only one class present in y_true. ROC AUC score is not defined in that case.
Fix is StratifiedKFold rather than KFold, and fewer folds when positives are scarce.

Two structural cases break plain stratified folds entirely and no amount of care with pipelines will save them. Where rows are grouped, several transactions per customer for instance, splitting rows at random puts the same customer in train and test, so use GroupKFold and split on the customer identifier. Where data is ordered in time, random folds train on the future to predict the past, which is a guaranteed and completely unrealistic score. Part 19 handles the time series case properly, since it deserves a full treatment rather than a paragraph.

Evaluation protocol I would use before shipping a churn model

My recommendation is deliberately narrow. Hold out 20 percent as a final test set, touch it once, and do everything else with five fold stratified cross validation on the remaining 80 percent. Put every fitted step inside the pipeline, including selection and encoding, not just the scaler. Report AUC with its standard deviation as the model quality number and average precision beside the base rate as the campaign number. Sweep thresholds on out of fold predictions and never on the test set. Sort feature importances before celebrating any score that pleases you.

Between AUC and average precision, I would pick average precision as the number to optimise for a retention campaign and AUC as the number to report for model comparison, and I would avoid F1 as a headline metric entirely. F1 buries the precision and recall balance that somebody with a budget needs to see, and it treats a wasted 15 dollar call as equivalent to a lost 500 dollar customer, which is a claim nobody in the business would sign. Accuracy stays off slides altogether on any imbalanced problem. Where a stakeholder insists on a single number, give them expected value in currency, since that is the only single number that encodes the asymmetry honestly.

Our churn project now has a defensible number attached to it: AUC 0.8306 with a standard deviation of 0.0253, average precision 0.6121 against a 0.2716 base rate, and a threshold of 0.30 chosen on out of fold predictions that flags 205 customers to catch 107 of the 132 leavers. Part 10 quoted 0.853 from one split, which we can now see was a slightly lucky draw rather than a lie. Part 12 brings in decision trees, random forests and gradient boosting, which will comfortably beat this logistic baseline on ranking quality. Evaluate them with exactly the protocol above, because a boosted ensemble with hundreds of trees is far better at memorising a leaked column than logistic regression ever was, and a model that fits harder also fools you harder.

Run the noise experiment yourself before moving on. Ten lines, two minutes, and watching an AUC of 0.81 materialise out of a random number generator on your own screen is worth more than any warning I can write here.

Data Science Series · Part 11 of 30
« Previous: Part 10  |  Guide  |  Next: Part 12 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure – By Dr Pranay Jha

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure - By Dr Pranay Jha

Subscribe now to keep reading and get access to the full archive.

Continue reading