Audit a Model Evaluation for Leakage and Misleading Metrics
Part of AI Engineering: Diagnose leakage and misleading metrics
Audit a Model Evaluation for Leakage and Misleading Metrics
A fictional meal-kit subscription service scores every active subscriber each week with a model that predicts whether the subscriber will cancel within the next 30 days. Its first model looked excellent offline and then disappointed in service, so the team now audits every experiment before anyone trusts its offline numbers. All figures here are synthetic.
In machine learning, leakage means a model is trained or evaluated with information it will not have when it makes real predictions. That is a different problem from data leakage in security, where data reaches someone who should not see it. Leakage enters through features (a column computed after the prediction time, or from the outcome itself) and through procedure (fitting, splitting, and choosing so that evaluation rows shape the model or the number). A metric can also mislead on its own: high accuracy on an imbalanced split may be no better than always predicting the more common class.
Implement auditEvaluation(experiment). It reads one experiment manifest and returns every finding, in a fixed rule order, together with four metrics computed from the reported confusion matrix. It never throws.
Timing convention
A row is scored at the end of its cutoff day. Its label is 1 when the subscriber cancels on any of the next labelWindowDays days, so the label becomes final at the end of day cutoffDay + labelWindowDays. A model can serve only after its last training label is final, so an honest evaluation split starts on a later day.
Input
{
labelWindowDays: 30,
rows: [
{ id: 'tr1', split: 'train', cutoffDay: 12, label: 0 },
{ id: 'va1', split: 'validation', cutoffDay: 50, label: 1 },
{ id: 'te1', split: 'test', cutoffDay: 75, label: 0 },
// ... one entry per manifest row
],
features: [{ name: 'orders_last_8w', windowEndOffsetDays: 0, derivedFromLabel: false }],
preprocessing: [{ name: 'standard_scaler', fitSplits: ['train'] }],
selection: { thresholdSplit: 'validation', modelSplit: 'validation' },
report: { split: 'test', tp: 3, fp: 1, tn: 20, fn: 2 },
}| JavaScript key | Python key | Meaning |
|---|---|---|
labelWindowDays | label_window_days | Days after the cutoff that the label covers |
rows | rows | Manifest rows: id, split ("train", "validation", or "test"), cutoff day, label (0 or 1) |
cutoffDay | cutoff_day | The day the row is scored |
features | features | Feature registry entries: name, window end offset, label lineage |
windowEndOffsetDays | window_end_offset_days | Last day of the feature's data window, relative to the cutoff: 0 is the cutoff day, negative is earlier |
derivedFromLabel | derived_from_label | true when the registry's lineage shows the feature is computed from the outcome |
preprocessing | preprocessing | Fitted steps: name and the splits it was fitted on |
fitSplits | fit_splits | Splits whose rows the step was fitted on; empty for a step that fits nothing |
selection | selection | Where the decision threshold and the model were chosen |
thresholdSplit | threshold_split | "validation" or "test" |
modelSplit | model_split | "validation" or "test" |
report | report | The reported confusion matrix: split ("validation" or "test"), tp, fp, tn, fn |
findings | findings | Output: list of finding strings in rule order (empty when nothing is flagged) |
metrics | metrics | Output: accuracy, baselineAccuracy (Python baseline_accuracy), precision, recall |
These keys are the same in both languages: id, split, label, name, tp, fp, tn, fn, accuracy, precision, recall. Finding strings are identical in both languages.
Guarantees
The input is well-formed, so do not validate it:
- Every split (
train,validation, andtest) has at least one row, and row IDs are unique. - Feature names are unique, and preprocessing step names are unique.
labelWindowDaysis an integer of at least 0. Cutoff days and window end offsets are integers; offsets may be negative.labelis 0 or 1.- Report counts are integers of at least 0, with a total of at least 1.
thresholdSplit,modelSplit, andreport.splitare"validation"or"test".fitSplitsentries are split names. A list may be empty or repeat a name.
Rules, in output order
Apply all seven rules, one pass each, and emit every finding that applies. No rule stops the audit, and each rule's findings come after the findings of the rules before it. In a finding, <name> is the feature or step name exactly as given, case included.
| Rule | Finding | When |
|---|---|---|
| 1 | label-derived:<name> or after-cutoff:<name> | For each feature, in list order: label-derived:<name> when derivedFromLabel is true; otherwise after-cutoff:<name> when windowEndOffsetDays is greater than 0. At most one finding per feature. |
| 2 | fit-beyond-train:<name> | For each preprocessing step, in list order, when any entry of fitSplits is not "train". An empty list is clean, and so is ["train", "train"]. |
| 3 | time-overlap:<split> | Let L = (largest cutoffDay among train rows) + labelWindowDays. For validation, then test (always this order, whatever the row order): when the smallest cutoffDay among that split's rows is less than or equal to L. Each evaluation split is compared with train only. |
| 4 | selected-on-test | Once, when thresholdSplit or modelSplit (or both) is "test". |
| 5 | population-mismatch | Once, when tp + fp + tn + fn differs from the number of rows whose split equals report.split, or when tp + fn differs from that split's number of label-1 rows. |
| 6 | recall-undefined, then precision-undefined | recall-undefined when tp + fn is 0; then precision-undefined when tp + fp is 0. |
| 7 | accuracy-not-above-baseline | When tp + tn is less than or equal to max(tp + fn, tn + fp). Compare these integer counts (equivalent to comparing the two ratios, which share the denominator). |
Metrics
Compute the metrics from the report's counts, with total = tp + fp + tn + fn. Compute them even when the population is flagged; they then describe the reported rows, not the split.
| JavaScript key | Python key | Value |
|---|---|---|
accuracy | accuracy | (tp + tn) / total |
baselineAccuracy | baseline_accuracy | max(tp + fn, tn + fp) / total, the accuracy of always predicting the more common actual class |
precision | precision | tp / (tp + fp), or null (None) when tp + fp is 0 |
recall | recall | tp / (tp + fn), or null (None) when tp + fn is 0 |
Output, rounding, and purity
- Return a new object
{ findings, metrics }.findingsis a new array of strings in rule order, empty when nothing is flagged, andmetricshas exactly the four keys above. - A ratio whose denominator is 0 is
null. A precision or recall that equals 0 is0, nevernull. - Do not round in the function. The tests round each metric with
round4and then compare the whole result:
const round4 = (x) => (typeof x === 'number' ? Math.round(x * 10000) / 10000 : x);- Do not change the input: no in-place sort of
rowsand no added keys.
Example 1
auditEvaluation({
labelWindowDays: 14,
rows: [
{ id: 'tr1', split: 'train', cutoffDay: 3, label: 0 },
{ id: 'tr2', split: 'train', cutoffDay: 9, label: 1 },
{ id: 'va1', split: 'validation', cutoffDay: 20, label: 0 },
{ id: 'va2', split: 'validation', cutoffDay: 24, label: 1 },
{ id: 'te1', split: 'test', cutoffDay: 26, label: 1 },
{ id: 'te2', split: 'test', cutoffDay: 30, label: 0 },
{ id: 'te3', split: 'test', cutoffDay: 28, label: 1 },
{ id: 'te4', split: 'test', cutoffDay: 27, label: 0 },
],
features: [
{ name: 'visits_last_7d', windowEndOffsetDays: 0, derivedFromLabel: false },
{ name: 'refund_issued', windowEndOffsetDays: 3, derivedFromLabel: true },
{ name: 'coupon_views_next_7d', windowEndOffsetDays: 7, derivedFromLabel: false },
],
preprocessing: [{ name: 'scaler', fitSplits: ['train'] }],
selection: { thresholdSplit: 'validation', modelSplit: 'validation' },
report: { split: 'test', tp: 1, fp: 0, tn: 2, fn: 1 },
});
// {
// findings: ['label-derived:refund_issued', 'after-cutoff:coupon_views_next_7d', 'time-overlap:validation'],
// metrics: { accuracy: 0.75, baselineAccuracy: 0.5, precision: 1, recall: 0.5 },
// }refund_issued gets one finding, for its lineage, even though its window also ends after the cutoff. The last training label is final at the end of day 9 + 14 = 23. Validation starts on day 20, so it overlaps; test starts on day 26, so it is clean. The report counts 4 rows with 2 actual positives, like the test split. Accuracy 3/4 beats the 2/4 baseline, precision is 1/1, and recall is 1/2.
Example 2
auditEvaluation({
labelWindowDays: 7,
rows: [
{ id: 'tr1', split: 'train', cutoffDay: 1, label: 1 },
{ id: 'tr2', split: 'train', cutoffDay: 4, label: 0 },
{ id: 'va1', split: 'validation', cutoffDay: 12, label: 0 },
{ id: 'va2', split: 'validation', cutoffDay: 15, label: 0 },
{ id: 'te1', split: 'test', cutoffDay: 13, label: 0 },
{ id: 'te2', split: 'test', cutoffDay: 20, label: 0 },
],
features: [],
preprocessing: [{ name: 'encoder', fitSplits: ['validation'] }],
selection: { thresholdSplit: 'test', modelSplit: 'test' },
report: { split: 'validation', tp: 0, fp: 0, tn: 3, fn: 0 },
});
// {
// findings: ['fit-beyond-train:encoder', 'selected-on-test', 'population-mismatch',
// 'recall-undefined', 'precision-undefined', 'accuracy-not-above-baseline'],
// metrics: { accuracy: 1, baselineAccuracy: 1, precision: null, recall: null },
// }L = 4 + 7 = 11, and both evaluation splits start later. selected-on-test appears once even though both choices used test. The report counts 3 rows, but the validation split has 2. Nothing was flagged and nothing was positive, so both ratios are null, and accuracy only equals the baseline.
Fixtures
The first test runs five experiments by the same team. E1 mirrors the launch configuration on a 22-row sample, and E2 to E5 are later experiments. Every experiment uses labelWindowDays 30. Row IDs are the split prefix plus the position within the split (tr1, tr2, ..., va1, ..., te1, ...), and rows lists the train rows, then validation, then test, each in the order shown.
| Experiment | Split | Cutoff days, in manifest order | Label-1 rows |
|---|---|---|---|
| E1 | train | 64, 12, 97, 35, 118, 50, 7, 83 | tr2, tr6 |
| E1 | validation | 41, 109, 22, 76 | va3 |
| E1 | test | 58, 90, 15, 102, 69, 31, 115, 44, 9, 80 | te2, te5, te9 |
| E2 | train | 95, 20, 120, 61, 8, 104 | tr3 |
| E2 | validation | 158, 150, 165 | va2 |
| E2 | test | 196, 181, 207, 189, 214, 185, 200, 192 | te1, te6 |
| E3 | train | 30, 5, 44, 17 | tr2 |
| E3 | validation | 80, 75 | va1 |
| E3 | test | 110, 96, 103, 120, 99 | none |
| E4 | train | 140, 15, 98, 60, 33, 121, 77, 150 | tr4, tr7 |
| E4 | validation | 190, 181, 199 | va3 |
| E4 | test | 236, 214, 251, 228, 219, 244, 222, 240, 231, 247 | te2, te5, te9 |
| E5 | train | 70, 12, 55, 31, 90 | tr3 |
| E5 | validation | 130, 121, 144, 126, 137, 150 | va2 |
| E5 | test | 175, 160, 168, 181 | te4 |
| Experiment | Story | Features: name (windowEndOffsetDays, derivedFromLabel) | Preprocessing: name (fitSplits) | Selection: thresholdSplit, modelSplit | Report: split; tp, fp, tn, fn |
|---|---|---|---|---|---|
| E1 | Launch configuration, 22-row sample | orders_last_8w (0, false); tickets_30d (14, false); retention_offer_sent (30, true); days_since_signup (0, false) | median_imputer (train); region_target_encoder (train, validation, test); standard_scaler (train) | test, validation | test; 2, 0, 7, 0 |
| E2 | First time-based rebuild | orders_last_8w (0, false); skips_last_4w (0, false); tickets_30d_asof (0, false); payment_failures_prev_cycle (-7, false) | median_imputer (train); standard_scaler (train) | validation, validation | test; 1, 1, 5, 1 |
| E3 | Rebuild scored on a holiday-week test window | orders_last_8w (0, false); cancel_reason_code (0, true); days_since_signup (0, false) | log_transform (none); standard_scaler (train) | validation, test | test; 0, 0, 5, 0 |
| E4 | Second time-based rebuild | orders_last_8w (0, false); skips_last_4w (0, false); tickets_30d_asof (0, false); payment_failures_prev_cycle (-7, false) | median_imputer (train); region_target_encoder (train); standard_scaler (train) | validation, validation | test; 2, 0, 7, 1 |
| E5 | Validation report from the analytics dashboard | orders_last_8w (0, false); payment_failures_prev_cycle (-7, false) | median_imputer (train, validation); log_transform (none) | test, validation | validation; 1, 2, 2, 1 |
"(none)" is an empty fitSplits list: the step fits nothing.
Predict each experiment's findings, in order, and its four metrics before you run the tests. The other tests build small experiments of their own: features and steps in list order with names copied exactly, the time rule at its boundary and with rows in any order, metric arithmetic, selection and population findings together, and clean experiments whose metrics must be unrounded and whose input must stay unchanged.
Constraints
- At most 100,000 rows, 200 features, and 50 preprocessing steps.
- Cutoff days are integers from 0 to 100,000, window end offsets from -365 to 365, and
labelWindowDaysfrom 0 to 365. - Report counts are integers from 0 to 100,000, with a total of at least 1.
Expected Complexity
- Time: O(n + f + p) for n rows, f features, and p preprocessing entries (steps plus their
fitSplitsentries). - Space: O(f + p) for the findings; everything else is a few counters.
Python
Implement audit_evaluation(experiment). The experiment is a dict with the Python keys from the mapping table, and rows, features, steps, selection, and report are dicts. Return a dict with the keys "findings" and "metrics", where findings is a list (not a tuple) and metrics uses baseline_accuracy. Use None where JavaScript uses null. Python's / returns a float, and 1.0 == 1 in the tests. The tests round with:
def round4(x):
return None if x is None else round(x, 4)Example 2 in Python returns {"findings": ["fit-beyond-train:encoder", "selected-on-test", "population-mismatch", "recall-undefined", "precision-undefined", "accuracy-not-above-baseline"], "metrics": {"accuracy": 1.0, "baseline_accuracy": 1.0, "precision": None, "recall": None}}.
0 views
Solution
Hints
