Make model and policy claims honestly: define the decision moment, split access-review episodes by time and user, expose feature and preprocessing leakage, and audit LLM evaluation contamination.
Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
The reinforcement-learning routing policy for temporary admin-access requests earned more expected reward for human_review once abandonment risk was included. That's not enough to ship a policy. A scientific claim needs evidence from requests that did not shape the decision rule.
Validation asks whether a fitted rule works on new examples. Leakage occurs when evaluation lets the rule see information it couldn't have at decision time, or lets test data influence choices. Both mistakes make weak systems look strong.[1]
Suppose you're fitting a guardrail model for the access-review router. When a request opens, it predicts whether the request must go to human review. The label comes later from an audit, but the prediction must be made before manager approvals, reviewer actions, and access outcomes arrive.
For request REQ-10234, the decision moment is 2026-07-01 09:00.
| Field | When it exists | Valid model feature at opening? | Why |
|---|---|---|---|
ambiguity_score | Before opening decision | Yes | Extracted from submitted request text |
prior_policy_exceptions_90d | Before opening decision | Yes | User history already recorded |
manager_approval_received_at | After evidence request | No | The router's action can create this event |
access_revoked_after_audit | After resolution | No | It reveals a later outcome |
needs_review | After audit | Label only | It's what the guardrail predicts |
Write this feature contract before training a model. If a field doesn't exist when the action is chosen, it can't enter features, prompt context, retrieval results, or preprocessing statistics.
1from datetime import datetime
2
3decision_at = datetime.fromisoformat("2026-07-01T09:00")
4fields = [
5 ("ambiguity_score", "2026-07-01T08:59"),
6 ("prior_policy_exceptions_90d", "2026-07-01T08:59"),
7 ("manager_approval_received_at", "2026-07-01T10:22"),
8 ("access_revoked_after_audit", "2026-07-15T12:00"),
9]
10
11for field, observed_at in fields:
12 known_in_time = datetime.fromisoformat(observed_at) <= decision_at
13 decision = "ALLOW" if known_in_time else "BLOCK"
14 print(f"{field:18} {decision}")1ambiguity_score ALLOW
2prior_policy_exceptions_90d ALLOW
3manager_approval_received_at BLOCK
4access_revoked_after_audit BLOCKThe two blocked fields may be useful for later outcome analysis. They aren't legal inputs for the opening decision.
A timestamp audit is necessary, but it's not sufficient. Rebuild derived features from an as-of snapshot and inspect their source windows too. A bad offline join can stamp a feature before the decision while still aggregating records that arrived later.
Every dataset split controls a different kind of influence:
| Split | Allowed use | Must not do |
|---|---|---|
| Train | Fit coefficients, trees, encoders, scalers, or learned features | Claim this score estimates deployment quality |
| Validation | Choose features, thresholds, model families, prompts, or reward settings | Reuse it as an untouched final estimate |
| Test | Estimate final performance after choices are frozen | Inspect errors, revise system, then report same test score as final |
Access requests arrive over time, so the first honest train, validation, and test split is chronological:
1episodes = [
2 {"request": f"{month}-{index}", "month": month}
3 for month in range(1, 9)
4 for index in range(2)
5]
6
7train = [row for row in episodes if row["month"] <= 6]
8validation = [row for row in episodes if row["month"] == 7]
9test = [row for row in episodes if row["month"] == 8]
10
11print("train requests =", len(train), "months = 1..6")
12print("valid requests =", len(validation), "months = 7")
13print("test requests =", len(test), "months = 8 (locked)")1train requests = 12 months = 1..6
2valid requests = 2 months = 7
3test requests = 2 months = 8 (locked)
If you inspect August failures and revise the pipeline, that work may be excellent engineering. It also spends the August test set. You then need a later untouched window for a final estimate.
One validation month can be unusual. Cross-validation (CV) rotates the held-out role through earlier data: fit on some folds, score on one unseen fold, repeat, and average the scores. It's a tool for model development, not permission to open the final test set repeatedly.[1]
Five earlier-history folds give these F1 scores for the review guardrail: 0.67, 0.75, 0.58, 0.83, and 0.71. The F1 score (harmonic mean of precision and recall) is useful here because review-required requests may be less frequent than routine ones, and both missed reviews and excessive reviews matter.
1import numpy as np
2
3fold_f1 = np.array([0.67, 0.75, 0.58, 0.83, 0.71])
4
5print("fold F1 =", fold_f1.tolist())
6print(f"mean F1 = {fold_f1.mean():.2f}")
7print(f"std F1 = {fold_f1.std(ddof=1):.2f}")1fold F1 = [0.67, 0.75, 0.58, 0.83, 0.71]
2mean F1 = 0.71
3std F1 = 0.09The mean of 0.71 summarizes performance across folds. The standard deviation of 0.09 says the estimate isn't equally reliable everywhere. Investigate the weak fold before automating high-cost actions.
Ordinary shuffled CV assumes rows are exchangeable: any row could plausibly have arrived in any fold. That assumption breaks when the future differs from the past or when several rows come from one user, repository, service, document, or conversation.
Suppose access_revoked_after_audit is set after a request closes. It almost reveals whether review was required. Adding it to an opening-time guardrail can produce a spectacular metric and an unusable model.
This experiment creates eight months of access requests. It fits on months 1 through 6 and scores months 7 and 8. The only difference between the two feature sets is a post-close audit field that copies the target.
1import numpy as np
2from sklearn.linear_model import LogisticRegression
3from sklearn.metrics import accuracy_score
4
5rng = np.random.default_rng(9)
6month = np.repeat(np.arange(1, 9), 50)
7ambiguity = rng.normal(0, 1, len(month))
8policy_support = rng.integers(0, 2, len(month))
9needs_review = (
10 ambiguity
11 - 0.9 * policy_support
12 + rng.normal(0, 1.1, len(month))
13 > 0
14).astype(int)
15
16# This field is recorded after resolution, so it is forbidden at request opening.
17post_close_audit = needs_review.copy()
18train_mask = month <= 6
19future_mask = month >= 7
20
21decision_time_X = np.column_stack([ambiguity, policy_support])
22future_field_X = np.column_stack([ambiguity, policy_support, post_close_audit])
23
24for name, features in [
25 ("decision-time", decision_time_X),
26 ("future-field", future_field_X),
27]:
28 model = LogisticRegression().fit(features[train_mask], needs_review[train_mask])
29 predictions = model.predict(features[future_mask])
30 score = accuracy_score(needs_review[future_mask], predictions)
31 print(f"{name:13} accuracy={score:.2f}")1decision-time accuracy=0.73
2future-field accuracy=1.001.00 isn't evidence of a brilliant guardrail. Here it proves that the evaluation admitted its answer key. The 0.73 result asks a real question: how well can opening-time information identify later review decisions?
A random row split can be correct when production will repeatedly serve known users. It's wrong when you claim the model generalizes to new users while each user's earlier rows appear in training.
The next dataset is intentionally stark. A user-specific pattern determines every label. A model with user_id memorizes users perfectly when their rows are scattered through folds, then fails on user IDs it never encountered.
1import numpy as np
2import pandas as pd
3from sklearn.linear_model import LogisticRegression
4from sklearn.model_selection import GroupKFold, StratifiedKFold, cross_val_score
5from sklearn.pipeline import make_pipeline
6from sklearn.preprocessing import OneHotEncoder
7
8users = np.repeat([f"c{i:02}" for i in range(60)], 4)
9needs_review = np.repeat([i % 2 for i in range(60)], 4)
10features = pd.DataFrame({"user_id": users})
11model = make_pipeline(
12 OneHotEncoder(handle_unknown="ignore"),
13 LogisticRegression(max_iter=1000),
14)
15
16row_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=3)
17group_cv = GroupKFold(n_splits=5)
18
19row_score = cross_val_score(
20 model, features, needs_review, cv=row_cv, scoring="accuracy"
21).mean()
22group_score = cross_val_score(
23 model,
24 features,
25 needs_review,
26 groups=users,
27 cv=group_cv,
28 scoring="accuracy",
29).mean()
30
31print(f"random rows mean accuracy = {row_score:.2f}")
32print(f"new users mean accuracy = {group_score:.2f}")1random rows mean accuracy = 1.00
2new users mean accuracy = 0.50Neither metric is universally correct. 1.00 answers, "Can I recognize users already represented in history?" 0.50 answers, "Can user identity alone help on unseen users?" Write the intended deployment promise beside the score.
Leakage doesn't require a suspicious column. A scaler, imputer, vocabulary, principal component analysis (PCA) transform, or feature selector learns parameters during fit. If it sees all rows before a split, the validation data already affected the trained pipeline.
The experiment below uses scikit-learn's Pipeline with random labels.[2] If a feature selector sees all labels before splitting, it can select chance correlations that happen to fit the test rows. Fitting selection inside the pipeline removes that advantage.
1import numpy as np
2from sklearn.feature_selection import SelectKBest
3from sklearn.metrics import accuracy_score
4from sklearn.model_selection import train_test_split
5from sklearn.pipeline import make_pipeline
6from sklearn.tree import DecisionTreeClassifier
7
8rng = np.random.RandomState(42)
9features = rng.standard_normal((200, 2_000))
10random_labels = rng.choice(2, 200)
11
12selected = SelectKBest(k=25).fit_transform(features, random_labels)
13leaky_train_X, leaky_test_X, leaky_train_y, leaky_test_y = train_test_split(
14 selected, random_labels, random_state=42
15)
16leaky_model = DecisionTreeClassifier(max_depth=4, random_state=1).fit(
17 leaky_train_X, leaky_train_y
18)
19
20train_X, test_X, train_y, test_y = train_test_split(
21 features, random_labels, random_state=42
22)
23safe_model = make_pipeline(
24 SelectKBest(k=25),
25 DecisionTreeClassifier(max_depth=4, random_state=1),
26).fit(train_X, train_y)
27
28print(
29 "fit selector before split accuracy =",
30 f"{accuracy_score(leaky_test_y, leaky_model.predict(leaky_test_X)):.2f}",
31)
32print(
33 "pipeline after split accuracy =",
34 f"{accuracy_score(test_y, safe_model.predict(test_X)):.2f}",
35)1fit selector before split accuracy = 0.74
2pipeline after split accuracy = 0.52Because the labels are random, the true attainable accuracy is around chance. The inflated 0.74 score comes entirely from leakage. In a real access model, you often won't know the true attainable score, which is why pipeline discipline matters.
Access routing faces seasonal traffic, policy changes, and changing user behavior. A model fitted on August shouldn't be used to predict June in an experiment that claims future performance. TimeSeriesSplit creates expanding history: each validation window comes after its training window.
1import numpy as np
2from sklearn.model_selection import TimeSeriesSplit
3
4months = np.repeat(np.arange(1, 9), 3)
5splitter = TimeSeriesSplit(n_splits=3)
6
7for fold, (train_index, validation_index) in enumerate(splitter.split(months), start=1):
8 train_months = months[train_index]
9 validation_months = months[validation_index]
10 print(
11 f"fold {fold}: train <= {train_months.max()} "
12 f"validate = {validation_months.min()}..{validation_months.max()}"
13 )1fold 1: train <= 2 validate = 3..4
2fold 2: train <= 4 validate = 5..6
3fold 3: train <= 6 validate = 7..8If one user can appear over many months and you need a promise about unseen users, time ordering alone is insufficient. Hold out future months and keep the relevant user boundary intact inside the evaluation design.
Production access-review often needs both constraints: evaluate on future months and on users the model hasn't trained on. A pure TimeSeriesSplit can still leak through repeated users who appear before and after the cut. A pure GroupKFold can still score earlier months after later ones. Sketch the joint rule before you code it:
1Promise: score future months on users unseen in training.
21. Sort requests by decision time.
32. Pick a future validation window (e.g. July).
43. Training rows: decision_time < July AND user_id not in July's user set
5 (or: train only on users who never appear in the validation window).
64. Report metrics on the July window for those held-out users.
75. If you also need returning-user quality, define a second contract
8 that allows prior user history but still freezes the time cut.Don't stop at "I used a time split" if the product claim is about new users, or at "I used GroupKFold" if the product claim is about next-month traffic.
Validation may choose thresholds, features, and model families, but unlimited search has a cost. Trying many models, many feature subsets, and many thresholds on the same validation window, then reporting the best validation score as if it were a locked estimate, is another leak class: selection bias / validation overuse. The cure is nested evaluation or a final locked window:
If you lack nested CV compute, still keep one untouched final window and treat heavy validation search as exploratory, not as the published number.
A probability model still needs an action threshold. Choosing that threshold from the test set is leakage, because the test labels then influence the deployed decision rule.
Below, July validation data chooses among three review thresholds. Only after selection is frozen does August report one test score.
1import numpy as np
2from sklearn.metrics import f1_score
3
4thresholds = [0.30, 0.50, 0.70]
5validation_probability = np.array([0.82, 0.62, 0.58, 0.42, 0.31, 0.12])
6validation_label = np.array([1, 1, 1, 0, 0, 0])
7
8test_probability = np.array([0.77, 0.49, 0.39, 0.52, 0.66, 0.14])
9test_label = np.array([1, 1, 0, 0, 1, 0])
10
11validation_f1 = {
12 threshold: f1_score(validation_label, validation_probability >= threshold)
13 for threshold in thresholds
14}
15chosen = max(validation_f1, key=validation_f1.get)
16
17for threshold, score in validation_f1.items():
18 print(f"threshold={threshold:.2f} valid_f1={score:.2f}")
19print(
20 f"chosen threshold={chosen:.2f} "
21 f"test_f1={f1_score(test_label, test_probability >= chosen):.2f}"
22)1threshold=0.30 valid_f1=0.75
2threshold=0.50 valid_f1=1.00
3threshold=0.70 valid_f1=0.50
4chosen threshold=0.50 test_f1=0.67The test score may be lower than validation. That's not a reason to retune on test; it's information about how uncertain your claimed performance is. Log the threshold, split definition, feature contract, and final metric together.
Language-model systems add three common ways to leak information:
| System | Leakage path | Honest boundary |
|---|---|---|
| Retrieval-augmented generation (RAG) | Chunks from one source document appear in both tuning and test corpora | Split documents before chunking |
| Fine-tuning or prompting | Eval questions, answers, or worked traces enter training examples or prompt demonstrations | Maintain provenance and exclude eval material |
| Public benchmark evaluation | A benchmark item may be present in model training data | Prefer fresh or contamination-limited test material and report uncertainty |
For an access-policy assistant, splitting chunks after document creation is too late if chunks from the same policy page reach both sides. The retriever then appears to generalize while it's retrieving near-copies of documents seen during development.
1documents = {
2 "policy_access": [
3 "temporary admin roles need review",
4 "manager approval may support access grant",
5 ],
6 "policy_incidents": [
7 "sev2 incidents need incident commander approval",
8 "service-owner approval precedes production access",
9 ],
10 "policy_admins": [
11 "privileged role appeal needs audit",
12 "audit stores decision reason",
13 ],
14}
15chunks = [(document, chunk) for document, texts in documents.items() for chunk in texts]
16
17bad_train = chunks[::2]
18bad_test = chunks[1::2]
19bad_overlap = sorted(
20 {document for document, _ in bad_train}
21 & {document for document, _ in bad_test}
22)
23
24safe_train_documents = {"policy_access", "policy_incidents"}
25safe_test_documents = {"policy_admins"}
26safe_overlap = sorted(safe_train_documents & safe_test_documents)
27
28print("split chunks first shared documents =", bad_overlap)
29print("split documents first shared documents =", safe_overlap)1split chunks first shared documents = ['policy_access', 'policy_admins', 'policy_incidents']
2split documents first shared documents = []Public LLM benchmarks have a harder version of this problem: training corpora can include published test material. LiveBench was designed around frequently updated questions from recent information sources with objective scoring to reduce contamination risk, not to make contamination impossible for all future uses.[3]
A cheap local smoke test searches for exact phrase overlap between your own training and evaluation artifacts:
1training_prompts = [
2 "route privileged access request with required review to human queue",
3 "approve access grant after clear manager approval arrives",
4]
5evaluation_prompts = [
6 "route privileged access request with required review to human queue",
7 "send an ambiguous admin request to a reviewer",
8]
9
10def ngrams(text, size=5):
11 words = text.lower().split()
12 return {
13 " ".join(words[start : start + size])
14 for start in range(len(words) - size + 1)
15 }
16
17training_grams = set().union(*(ngrams(prompt) for prompt in training_prompts))
18for prompt in evaluation_prompts:
19 overlaps = ngrams(prompt) & training_grams
20 status = "FLAG" if overlaps else "clear"
21 print(f"{status}: {prompt}")1FLAG: route privileged access request with required review to human queue
2clear: send an ambiguous admin request to a reviewerThis flags copied wording. It doesn't prove the second prompt is safe: paraphrases, translated items, and memorized solution patterns can evade exact matching. Treat overlap checks as a reason to investigate, not as a certificate of clean evaluation.
These labs evaluate a supervised guardrail: given information available when a request opens, predict whether human review is required. They don't by themselves estimate the reward of a brand-new sequential policy.
Historical trajectories reveal the outcome of the action that was taken. They usually don't reveal what would have happened if the router had selected a different action for the same user. A time split prevents future leakage, but it doesn't fill in those missing counterfactual outcomes. For an RL policy, pair split discipline with a validated simulator, carefully logged exploration or propensities for off-policy evaluation, human review, or a staged online experiment before automation.[4]
Before publishing a score, create a short, reviewable artifact:
1Decision moment: request opening, before evidence request or reviewer action
2Allowed features: ambiguity_score, policy_support, prior_policy_exceptions_90d
3Forbidden features: manager_approval_received_at, reviewer_action, access_revoked_after_audit
4Train: January-June requests
5Validation: July requests; choose threshold and features here
6Test: August requests; open once after choices are frozen
7Entity promise: new-user quality, so no user crosses split boundary
8Preprocessing: every fit step is inside training fold Pipeline
9Feature snapshot: as of request opening; aggregation windows stop before decision time
10Metrics: review-required F1 plus unsupported-access countThe metric is only the last line of reasoning. The contract states what the metric means.
reviewer_action to the feature-audit lab and assign an observation time. Explain why it's label-adjacent leakage.group-leakage-from-repeat-users.py so prediction is for returning users rather than new users. State whether row-wise splitting now answers a relevant product question and what time boundary is still needed.SelectKBest with StandardScaler or PCA inside a Pipeline. Identify which learned values must come only from training rows.reviewer_action exists only after routing and review work begin. It belongs in outcome analysis, not opening-time features.StandardScaler must learn means and variances from training rows. PCA must learn its centering values and components from training rows. A Pipeline keeps those fit calls inside the split.Answer every question, then check your score. Score 75% or higher to mark this lesson complete.
9 questions remaining.
The Elements of Statistical Learning.
Hastie, T., Tibshirani, R., Friedman, J. · 2009
Common Pitfalls and Recommended Practices: Data Leakage
Scikit-learn Developers · 2026 · Official documentation
LiveBench: A Challenging, Contamination-Limited LLM Benchmark
White, C., et al. · 2024
Reinforcement Learning: An Introduction
Sutton, R. S. and Barto, A. G. · 2018 · MIT Press
Questions and insights from fellow learners.