Audit a Model Evaluation for 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

JavaScript
{
    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 keyPython keyMeaning
labelWindowDayslabel_window_daysDays after the cutoff that the label covers
rowsrowsManifest rows: id, split ("train", "validation", or "test"), cutoff day, label (0 or 1)
cutoffDaycutoff_dayThe day the row is scored
featuresfeaturesFeature registry entries: name, window end offset, label lineage
windowEndOffsetDayswindow_end_offset_daysLast day of the feature's data window, relative to the cutoff: 0 is the cutoff day, negative is earlier
derivedFromLabelderived_from_labeltrue when the registry's lineage shows the feature is computed from the outcome
preprocessingpreprocessingFitted steps: name and the splits it was fitted on
fitSplitsfit_splitsSplits whose rows the step was fitted on; empty for a step that fits nothing
selectionselectionWhere the decision threshold and the model were chosen
thresholdSplitthreshold_split"validation" or "test"
modelSplitmodel_split"validation" or "test"
reportreportThe reported confusion matrix: split ("validation" or "test"), tp, fp, tn, fn
findingsfindingsOutput: list of finding strings in rule order (empty when nothing is flagged)
metricsmetricsOutput: 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, and test) has at least one row, and row IDs are unique.
  • Feature names are unique, and preprocessing step names are unique.
  • labelWindowDays is an integer of at least 0. Cutoff days and window end offsets are integers; offsets may be negative.
  • label is 0 or 1.
  • Report counts are integers of at least 0, with a total of at least 1.
  • thresholdSplit, modelSplit, and report.split are "validation" or "test".
  • fitSplits entries 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.

RuleFindingWhen
1label-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.
2fit-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"].
3time-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.
4selected-on-testOnce, when thresholdSplit or modelSplit (or both) is "test".
5population-mismatchOnce, 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.
6recall-undefined, then precision-undefinedrecall-undefined when tp + fn is 0; then precision-undefined when tp + fp is 0.
7accuracy-not-above-baselineWhen 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 keyPython keyValue
accuracyaccuracy(tp + tn) / total
baselineAccuracybaseline_accuracymax(tp + fn, tn + fp) / total, the accuracy of always predicting the more common actual class
precisionprecisiontp / (tp + fp), or null (None) when tp + fp is 0
recallrecalltp / (tp + fn), or null (None) when tp + fn is 0

Output, rounding, and purity

  • Return a new object { findings, metrics }. findings is a new array of strings in rule order, empty when nothing is flagged, and metrics has exactly the four keys above.
  • A ratio whose denominator is 0 is null. A precision or recall that equals 0 is 0, never null.
  • Do not round in the function. The tests round each metric with round4 and then compare the whole result:
JavaScript
const round4 = (x) => (typeof x === 'number' ? Math.round(x * 10000) / 10000 : x);
  • Do not change the input: no in-place sort of rows and no added keys.

Example 1

JavaScript
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

JavaScript
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.

ExperimentSplitCutoff days, in manifest orderLabel-1 rows
E1train64, 12, 97, 35, 118, 50, 7, 83tr2, tr6
E1validation41, 109, 22, 76va3
E1test58, 90, 15, 102, 69, 31, 115, 44, 9, 80te2, te5, te9
E2train95, 20, 120, 61, 8, 104tr3
E2validation158, 150, 165va2
E2test196, 181, 207, 189, 214, 185, 200, 192te1, te6
E3train30, 5, 44, 17tr2
E3validation80, 75va1
E3test110, 96, 103, 120, 99none
E4train140, 15, 98, 60, 33, 121, 77, 150tr4, tr7
E4validation190, 181, 199va3
E4test236, 214, 251, 228, 219, 244, 222, 240, 231, 247te2, te5, te9
E5train70, 12, 55, 31, 90tr3
E5validation130, 121, 144, 126, 137, 150va2
E5test175, 160, 168, 181te4
ExperimentStoryFeatures: name (windowEndOffsetDays, derivedFromLabel)Preprocessing: name (fitSplits)Selection: thresholdSplit, modelSplitReport: split; tp, fp, tn, fn
E1Launch configuration, 22-row sampleorders_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, validationtest; 2, 0, 7, 0
E2First time-based rebuildorders_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, validationtest; 1, 1, 5, 1
E3Rebuild scored on a holiday-week test windoworders_last_8w (0, false); cancel_reason_code (0, true); days_since_signup (0, false)log_transform (none); standard_scaler (train)validation, testtest; 0, 0, 5, 0
E4Second time-based rebuildorders_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, validationtest; 2, 0, 7, 1
E5Validation report from the analytics dashboardorders_last_8w (0, false); payment_failures_prev_cycle (-7, false)median_imputer (train, validation); log_transform (none)test, validationvalidation; 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 labelWindowDays from 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 fitSplits entries).
  • 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:

Python
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}}.

MEDIUM
AI Engineering
Machine Learning
Data Leakage
Debugging
Intermediate

0 views

Solution

Hints

Hint 1
Hint 2
Premium
Hint 3
Premium
Hint 4
Premium