A Grammar of Machine Learning Workflows: Rejecting Data Leakage at Call Time
Abstract
Data leakage has been identified in 648 published papers across 30 scientific fields. The knowledge to prevent it has existed for over a decade; the problem persists because the tools do not enforce what the textbooks teach. This paper presents a grammar (eight typed primitives connected by a directed acyclic graph with four hard constraints) that makes the most damaging leakage types structurally unrepresentable within the grammar's scope. The core mechanism is a terminal assessment gate: the first call-time-enforced evaluate/assess boundary documented in the peer-reviewed ML methodology literature (to my knowledge, as of May 2026), backed by a specification precise enough for independent reimplementation. A companion landscape study across 2,047 datasets grounds the constraints in measured effect sizes. Two reference implementations (Python, R) are available.
Figures & tables
| # | Primitive | Type signature | What it does |
|---|---|---|---|
| 1 | split | DataFrame Partition | Create train/valid/test splits with provenance |
| 2 | cv | DataFrame g CVResult | Create -fold rotation from training data |
| 3 | prepare | DataFrame g PreparedData | Normalize, encode, impute features |
| 4 | fit | (DataFrame g CVResult PreparedData) target Model | Train a model |
| 5 | predict | Model DataFrame Predictions | Apply model to new data |
| 6 | evaluate | Model DataFrame g Metrics | Measure on validation data (repeatable) |
| # | Constraint | What it prevents | Leakage class | AUC |
|---|---|---|---|---|
| 1 | Assess once per holdout | Repeated test-set peeking | Class II ( = 0.929) | +0.040 |
| 2 | Prepare after split, per fold | Global preprocessing leakage | Class I ( ) | |
| 3 | Type-safe transitions | Fitting on test/untagged data; evaluating without a fitted model | Class II/III ( = 0.372–1.112) | +0.004–0.024 |
| 4 | No unregistered data into fit | Feature selection using test labels | Class II ( = 0.929) | +0.040 |
| Family | Question | Strategies |
|---|---|---|
| Selection | Which algorithm? | screen, compare, pick |
| Optimization | Which hyperparameters? | tune (grid, random, bayesian) |
| Evaluation | How to rotate data? | kfold, grouped, temporal, nested_cv |
| Ensemble | How to combine models? | blend, bag, boost, stack |
| Prediction | Source | Result |
|---|---|---|
| Screen inflation ( ): selecting best-of- algorithms inflates performance | Selection family; consistent with Cawley and Talbot ( [ ref-cawley2010overfitting ] ) | Confirmed: = +0.27, -invariant |
| Stack leakage ( ): out-of-fold meta-learner leaks through fold labels | Ensemble family | Falsified : = -0.22; stack() is empirically safe |
| Seed inflation ( ): reporting best-of- seeds inflates performance | Assess-once constraint | Confirmed: = +0.89, prevalence 92% |
| State | split | cv | prep. | fit | eval. | expl. | pred. | assess |
|---|---|---|---|---|---|---|---|---|
| Untagged | Partit. | rej. | rej. | rej. | rej. | rej. | rej. | rej. |
| Partitioned | - | CVRes. | PrepData | Fitted | rej. | rej. | rej. | rej. |
| Fitted | - | - | - | - | Metrics | Explan. | Predict. | Evidence |
| Assessed | - | - | - | - | Metrics | Explan. | Predict. | rej. |
| Invalid workflow | Why rejected | Leakage class |
|---|---|---|
| assess assess (same holdout) | Terminal: once per holdout | Class II |
| prepare(all) split fit | Prepare must follow split | Class I |
| select_features(X, y) split fit | No unregistered data into fit | Class II |
| evaluate(data) without prior fit | Type error: no Model | Class III |
| prepare(test) | Guard: test tag not in {train, valid, dev} | Class I |
| fit(test) | Guard: test tag not in {train, valid, dev} | Class III |
| Framework | Per-fold prep | Terminal assess | Provenance | Reject at call time |
|---|---|---|---|---|
| sklearn | via Pipeline p | — | — | — |
| tidymodels | via recipes p | convention l | — | — |
| mlr3 | via PipeOps p | — | — | — |
| Class | Mechanism | Measured effect | Grammar response |
|---|---|---|---|
| I | Estimation (normalize before split) | ; AUC | Constraint 2 (principled, costless) |
| II | Selection (peek, seed, screen) | – ; AUC – | Constraints 1, 3, 4 + terminal assess |
| III | Memorization (train on eval data) | – ; AUC – | Constraint 4 (provenance gate) |
| IV | Boundary (partition deployment) | domain-dependent; AUC where drift exists | split_temporal , split_group |
| Valid in the grammar but poor in practice | Why the grammar allows it |
|---|---|
| Logistic regression on 1M rows when XGBoost dominates | Algorithm choice is not structural |
| Accuracy on a 99/1 imbalanced dataset | Metric selection is not structural |
| -fold CV on time-series data | Temporal awareness requires domain knowledge 2 2 2 Configuring boundary-aware CV involves methodological trade-offs (block size, environmental gradients); Roberts et al. ( [ ref-roberts2017cross ] ) survey these for spatial, temporal, hierarchical, and phylogenetic dependence structures and document that aggressive blocking can over-estimate interpolation error. |
| Normalizing binary features | Feature-level decisions are semantic |
| Grammar mode | Recommendation | |
|---|---|---|
| Does not apply | Exact tests, Bayesian, case studies | |
| – | LOO-CV, one model | Report massive uncertainty |
| – | -fold CV, 1–3 models | No screening, no stacking |
| – | Full grammar feasible | Screen, tune, holdout |
| Grammar in its element | All strategies available |
| Type | Structure | Terminal |
|---|---|---|
| DataFrame | Tabular data with named columns and partition_tag: {None, train, valid, test, dev} . Freshly loaded data carries tag None (untagged). split assigns tags train , valid , test to the output partitions. The dev tag is assigned to the union of train and valid ( s.dev ). | No |
| Partition | {train, valid, test, dev: DataFrame} where dev = train valid | No |
| CVResult | {folds: [(train_idx, valid_idx)] ˆ k, k: N, target: str} . Produced by cv from a training or dev DataFrame; blocks .train , .valid , .test , .dev access. Test partition stays on the originating Partition. | No |
| PreparedData | {data: DataFrame_numeric, state: Transformer, target: str, task: {clf, reg}} | No |
| Model | {algorithm: str, task: {clf, reg}, fitted: bool} ‡ | No |
| Predictions | One-column numeric DataFrame | Yes † |
| Primitive | Input | Output | Guard | Effect |
|---|---|---|---|---|
| split | DataFrame, params | Partition | — | assigns partition_tag in {train, valid, test, dev} to output partitions; registers content fingerprints of all partitions (including dev = train valid) in the provenance registry with their tags and split lineage |
| cv | DataFrame, params | CVResult | data registered by split ; data.partition_tag in {train, dev} | blocks .train , .valid , .test , .dev on result; test stays on originating Partition |
| prepare | DataFrame, target | PreparedData | data registered by split ; data.partition_tag in {train, valid, dev} | fits Transformer on data; stores in PreparedData.state |
| fit | DataFrame CVResult PreparedData, target | Model | data registered by split ; if DataFrame, data.partition_tag in {train, valid, dev} ; if CVResult, produced by cv ; if PreparedData, inherits provenance from its source DataFrame | model.fitted ← true ; prepare applied per fold (skipped if input is PreparedData) |
| predict | Model, DataFrame | Predictions | model.fitted = true | — |
| evaluate | Model, DataFrame | Metrics | model.fitted = true ; data registered by split ; data.partition_tag != test | — |
| Workflow | Failing condition | Mechanism |
|---|---|---|
| assess( ) → assess( ) | : test partition not previously assessed | First call marks test fingerprint as assessed in registry; second call on same holdout fails |
| assess( , test) → assess( , test) | : test partition not previously assessed | First call marks test fingerprint as assessed in registry; second call on same holdout fails regardless of model |
| prepare( ) → split → fit | : data not registered by split | prepare rejects unregistered data ( partition_tag = None ); failure occurs at prepare , before split is called |
| select_features( , ) → split → fit | fit requires split provenance | Feature selection belongs inside prepare , per fold (§ Feature engineering); unregistered data rejected |
| evaluate without prior fit | Type continuity | No Model in scope; out(split) = Partition != in(evaluate) = Model |
| fit( .test) | : data.partition_tag not in {train, valid, dev} | Test tag is test , not in {train, valid, dev} ; guard fails |