{"id":"l-ml-4-4","courseId":"ml-crash-course","lang":"en","kind":"lesson","summary":"Recognise the three common kinds of data leakage (target leakage, preprocessing before the split, and overlapping rows or time) and prevent them with snapshots, pipelines and the right splitter.","html":"<p>Every experienced data scientist has a story about the model that was too good. Accuracy of 99% in the notebook, coin-flip performance in production. The cause is almost always the same: during training or evaluation the model had access to information it wouldn’t have in real life. That’s <strong>data leakage</strong>, and it’s dangerous because it doesn’t look like a bug. It looks like success.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Target leakage: features from the future</h2>\n<p>Cartwheel’s churn table is a <strong>snapshot</strong>: every feature describes a customer as of 30 September 2025, and <code dir=\"ltr\">churned</code> records what happened in the 90 days after. That timeline is the contract the model lives by. At prediction time, you only know what happened before the snapshot.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 220\" role=\"img\" aria-labelledby=\"l-ml-4-4-t1\"><defs><marker id=\"arrow-l-ml-4-4-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-ml-4-4-t1\">A timeline. Customer history up to the 30 September snapshot is where features may come from. The 90 days after the snapshot are the outcome window that defines churned. A feature computed from events inside the outcome window, such as recency measured at year end, is a leak.</title>\n  <line class=\"d-line\" x1=\"30\" y1=\"110\" x2=\"650\" y2=\"110\"/>\n  <rect class=\"d-box-success\" x=\"30\" y=\"70\" width=\"360\" height=\"40\" rx=\"6\"/>\n  <text class=\"d-label\" x=\"210\" y=\"95\" text-anchor=\"middle\">history: features come from here</text>\n  <rect class=\"d-box-warn\" x=\"390\" y=\"70\" width=\"260\" height=\"40\" rx=\"6\"/>\n  <text class=\"d-label\" x=\"520\" y=\"95\" text-anchor=\"middle\">outcome window (90 days)</text>\n  <line class=\"d-line\" x1=\"390\" y1=\"50\" x2=\"390\" y2=\"140\"/>\n  <text class=\"d-label-strong\" x=\"390\" y=\"40\" text-anchor=\"middle\">30 Sep: predict</text>\n  <text class=\"d-label-muted\" x=\"650\" y=\"160\" text-anchor=\"end\">29 Dec: churned known</text>\n  <path class=\"d-arrow\" d=\"M560 180 L470 125\" marker-end=\"url(#arrow-l-ml-4-4-1)\"/>\n  <text class=\"d-label\" x=\"560\" y=\"200\" text-anchor=\"middle\">\"recency at year end\" leaks</text>\n</svg><figcaption>Features must come from before the prediction date</figcaption></figure>\n<p>Now imagine an analyst rebuilds the table in January and computes <code dir=\"ltr\">days_since_last_order</code> as of the export date instead of the snapshot date. For churners, who didn’t order in the window, the value is 92 days larger. For customers who stayed, it’s small, because they ordered recently. The column now encodes the answer:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"python\" data-run=\"python\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">Python</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"python\" data-index=\"0\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> numpy </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">as</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> np</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> pandas </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">as</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> pd</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> sklearn.linear_model </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> LogisticRegression</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> sklearn.model_selection </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> StratifiedKFold, cross_val_score</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> sklearn.pipeline </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> make_pipeline</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> sklearn.preprocessing </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> StandardScaler</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">churn </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> pd.read_csv(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churn.csv\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">features </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"tenure_days\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"orders\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"total_spent\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"avg_order_value\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">            \"days_since_last_order\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"used_coupon\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"support_tickets\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"mobile_share\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">rng </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> np.random.default_rng(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\"># Recency measured at export time (year end), not at the 30 September snapshot</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">churn[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"recency_at_export\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> np.where(churn[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churned\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">==</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">                                      churn[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"days_since_last_order\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">+</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 92</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">                                      rng.integers(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">92</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">len</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(churn)))</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">cv </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> StratifiedKFold(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">n_splits</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">5</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">shuffle</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">True</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">random_state</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">42</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">model </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> make_pipeline(StandardScaler(), LogisticRegression())</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">for</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> cols </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">in</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [features, features </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">+</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"recency_at_export\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]]:</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    auc </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> cross_val_score(model, churn[cols], churn[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churned\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">], </span><span style=\"color:#953800;--shiki-dark:#FFA657\">cv</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">cv, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">scoring</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"roc_auc\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).mean()</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">    print</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">f</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">len</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(cols)</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> features: ROC AUC </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">auc</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">:.3f}</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span></code></pre><div class=\"run-output\" data-run-output=\"0\" hidden></div></div>\n<p>ROC AUC jumps from 0.81 to 0.999. Nothing in the code is wrong; cross-validation is done properly. The leak lives in how the data was built, which is why you have to look for it in the data’s history, not the model.</p>\n<p>Target leaks hide in innocent names: <code dir=\"ltr\">account_status</code>, <code dir=\"ltr\">last_contact_reason</code>, <code dir=\"ltr\">refund_amount</code>, <code dir=\"ltr\">total_orders</code> (counted at export, including future orders). The test for every column is one question: <strong>would I know this value, exactly as stored, at the moment I make the prediction?</strong></p>\n<h2 id=\"sec-2\" tabindex=\"-1\">Preprocessing before the split</h2>\n<p>The second kind of leak happens in code. Any step that learns from data (scaling, imputing, encoding, selecting features) must learn from training rows only. Do it on the whole dataset first and the validation rows have shaped the model.</p>\n<p>With scaling, the damage is usually small. With feature selection it can be enormous. Here’s a dataset of pure noise: 200 rows, 2,000 random columns, random labels. No model should beat 50%.</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"python\" data-run=\"python\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">Python</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"python\" data-index=\"1\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> numpy </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">as</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> np</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> sklearn.feature_selection </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> SelectKBest, f_classif</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> sklearn.linear_model </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> LogisticRegression</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> sklearn.model_selection </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> StratifiedKFold, cross_val_score</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> sklearn.pipeline </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> make_pipeline</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">rng </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> np.random.default_rng(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">X </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> rng.normal(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">size</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">200</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2000</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">))</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">y </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> rng.integers(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">size</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">200</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">cv </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> StratifiedKFold(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">n_splits</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">5</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">shuffle</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">True</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">random_state</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">42</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">X_selected </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> SelectKBest(f_classif, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">k</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">20</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).fit_transform(X, y)   </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\"># selection saw every label</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">leaky </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> cross_val_score(LogisticRegression(), X_selected, y, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">cv</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">cv).mean()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">pipe </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> make_pipeline(SelectKBest(f_classif, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">k</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">20</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">), LogisticRegression())</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">honest </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> cross_val_score(pipe, X, y, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">cv</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">cv).mean()</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">print</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">f</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"select then CV: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">leaky</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">:.3f}</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">   select inside CV: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">honest</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">:.3f}</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span></code></pre><div class=\"run-output\" data-run-output=\"1\" hidden></div></div>\n<p>Selecting on all 200 rows finds 20 columns that happen to line up with the labels of every row, including the ones later used for validation. Cross-validation then confirms a pattern that only exists because it was chosen using those very rows: 0.75 accuracy on noise. Inside a pipeline, selection sees only training folds and the score falls back to chance.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>“I only scaled the whole dataset, that’s harmless”</span></p>\n<p>Sometimes it nearly is. But the habit is what matters: the same code shape with an imputer, a target encoder or a feature selector produces leaks that inflate scores by 10 or 20 points. Make “every learned step lives in the pipeline” a rule with no exceptions, and the question never comes up.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">Rows that know each other</h2>\n<p>The third kind comes from how rows relate. Random splits assume rows are independent. When they aren’t, the test set contains near-copies of training rows.</p>\n<ul>\n<li><strong>Repeated entities.</strong> If a table has several rows per customer, patient or device, a random split puts the same customer on both sides, and the model gets graded partly on people it has memorised. Use <code dir=\"ltr\">GroupKFold</code> (or <code dir=\"ltr\">GroupShuffleSplit</code>) with the customer id as <code dir=\"ltr\">groups</code>.</li>\n<li><strong>Time.</strong> If you’re predicting the future, a random split lets the model learn from rows after the ones it’s tested on. Use <code dir=\"ltr\">TimeSeriesSplit</code>, or a simple cut-off date: train on the past, validate on later periods.</li>\n<li><strong>Duplicates.</strong> Exact duplicate rows split across train and test are free answers. Drop them before splitting.</li>\n</ul>\n<p>Cartwheel’s churn table has one row per customer and a single snapshot date, so a stratified random split is fine. Change either of those and it isn’t.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>A leak-hunting checklist</span></p>\n<p>Be suspicious when a score jumps after adding one column, when a model beats the business’s best experts by a mile, or when permutation importance puts nearly everything on one feature with a vague name. Then trace that column back to the query that created it and check its timestamp against the prediction date.</p>\n</aside>\n<p>With leaks closed off, you can trust cross-validation to judge new ideas, and the most productive new ideas are usually better features. Next lesson builds some from domain knowledge and tests whether they earn their place.</p>\n","toc":[{"id":"sec-1","text":"Target leakage: features from the future","level":2},{"id":"sec-2","text":"Preprocessing before the split","level":2},{"id":"sec-3","text":"Rows that know each other","level":2}],"takeaways":["Leakage is any information available during training or evaluation that won’t be available when the model makes real predictions.","Target leakage comes from features recorded after the moment of prediction; ask of every column, would I know this value at prediction time?","Fitting a scaler, imputer or feature selector before splitting lets validation rows shape the model; put every learned step inside a pipeline.","When rows belong to the same customer or follow each other in time, use <code dir=\"ltr\">GroupKFold</code> or <code dir=\"ltr\">TimeSeriesSplit</code> instead of a random split.","A score that looks too good, or a single feature that dominates permutation importance, is a reason to hunt for leaks before celebrating."],"further":[{"title":"Common pitfalls: data leakage","url":"https://scikit-learn.org/stable/common_pitfalls.html#data-leakage"},{"title":"Cross-validation iterators for grouped data","url":"https://scikit-learn.org/stable/modules/cross_validation.html#group-k-fold"},{"title":"TimeSeriesSplit","url":"https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html"}],"quiz":[{"q":"<p>A teammate adds <code dir=\"ltr\">refund_issued_in_q4</code> to the churn features and ROC AUC jumps from 0.81 to 0.97. The prediction is made on 30 September. What’s the problem?</p>","options":[{"text":"The feature describes events after the prediction date, so it won’t exist when the model runs; it’s target leakage.","why":"Correct. Q4 refunds happen during the outcome window, so the model is partly reading the answer."},{"text":"The feature is binary, and binary features always inflate AUC.","why":"Many legitimate features are binary (<code dir=\"ltr\">used_coupon</code>). The problem is timing, not type."},{"text":"Nothing; refunds are a known driver of churn.","why":"They may well be, but a model can only use what’s known at prediction time."},{"text":"The model is overfitting and needs a smaller <code dir=\"ltr\">C</code>.","why":"A stronger penalty wouldn’t stop the model using a feature that encodes the outcome."}],"answer":0},{"q":"<p>On 2,000 columns of pure noise, selecting the 20 ‘best’ columns on all the data and then cross-validating gives 0.75 accuracy. Inside a pipeline it gives 0.48. Why?</p>","options":[{"text":"The pipeline version uses fewer training rows, so it’s weaker.","why":"Both use the same folds; the difference is where selection happens."},{"text":"Selecting on all rows picked columns that happen to correlate with the labels of the validation rows too, so those rows were effectively seen during training.","why":"Correct. In the pipeline, selection only sees each fold’s training rows, so the noise can’t line up with validation labels."},{"text":"SelectKBest is random, so the two runs chose different columns.","why":"SelectKBest is deterministic; the difference is which rows it looked at."}],"answer":1},{"q":"<p>The order history has several rows per customer, and you want to predict at order level. Which splitter avoids leakage between folds?</p>","options":[{"text":"<code dir=\"ltr\">StratifiedKFold(shuffle=True)</code>, because it balances the classes.","why":"Class balance doesn’t stop the same customer’s orders landing in both training and validation folds."},{"text":"<code dir=\"ltr\">KFold(shuffle=False)</code>, so rows keep their order.","why":"Unshuffled folds still split a customer’s rows across folds when they aren’t contiguous."},{"text":"<code dir=\"ltr\">GroupKFold</code> with the customer id as the group, so each customer’s rows stay in one fold.","why":"Correct. The model is then always validated on customers it never saw."}],"answer":2},{"q":"<p>Which signal most strongly suggests a leak?</p>","options":[{"text":"Cross-validated accuracy is a few points above the rule baseline.","why":"That’s what a modest, honest model looks like."},{"text":"The fold scores vary by five points.","why":"Variation reflects small folds and noise, not leakage."},{"text":"Training accuracy is higher than test accuracy.","why":"That’s normal for almost every model."},{"text":"One unfamiliar feature carries almost all the permutation importance and the score is near perfect.","why":"Correct. Real-world problems rarely have a single near-perfect predictor; when one appears, check when and how it was recorded."}],"answer":3}],"exercise":{"kind":"spot-bug","language":"python","code":"import pandas as pd\nfrom sklearn.impute import SimpleImputer\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.model_selection import cross_val_score, train_test_split\nfrom sklearn.pipeline import make_pipeline\nfrom sklearn.preprocessing import StandardScaler\n\nchurn = pd.read_csv(\"churn_export_january.csv\")\nchurn[\"lifetime_orders\"] = churn[\"orders\"] + churn[\"orders_oct_to_dec\"]\nX = churn.drop(columns=[\"customer_id\", \"churned\", \"country\", \"segment\", \"orders_oct_to_dec\"])\nX = SimpleImputer(strategy=\"median\").fit_transform(X)\ny = churn[\"churned\"]\nX_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)\nmodel = make_pipeline(StandardScaler(), LogisticRegression())\nprint(cross_val_score(model, X_train, y_train, cv=5, scoring=\"roc_auc\").mean())","bugLines":[9,11],"title":"Find the leaks","prompt":"<p>A colleague’s churn notebook reports a cross-validated ROC AUC of 0.98. The model predicts on 30 September; the January export also includes <code dir=\"ltr\">orders_oct_to_dec</code>, the orders placed between October and December. Select every line that leaks information the model won’t have at prediction time, or lets validation rows influence training.</p>\n","hints":["Check every feature against the timeline: what would you know on 30 September?","Look for anything that calls <code dir=\"ltr\">fit</code> or <code dir=\"ltr\">fit_transform</code> on data before the split.","There are two leaky lines: one in the data, one in the code."],"explanation":"<p><strong>Line 9</strong> builds a feature from <code dir=\"ltr\">orders_oct_to_dec</code>, which covers the 90-day outcome window: customers who churned have zero orders there by definition, so <code dir=\"ltr\">lifetime_orders</code> partly encodes the label. Line 10 drops the raw column, but too late: its values already live on inside <code dir=\"ltr\">lifetime_orders</code>. Drop the derived feature too, or compute any order counts strictly up to the snapshot. <strong>Line 11</strong> fits the imputer on all rows before splitting, so medians are computed with test and validation rows included. Move it into the pipeline instead:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"python\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">Python</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">model </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> make_pipeline(SimpleImputer(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">strategy</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"median\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">), StandardScaler(), LogisticRegression())</span></span></code></pre></div>\n<p>The rest is fine: the scaler is already inside the pipeline, the split is stratified, and cross-validation runs on the training part only.</p>\n","checkLabels":{}},"words":710,"readMinutes":3}