{"id":"l-ml-5-4","courseId":"ml-crash-course","lang":"en","kind":"lesson","summary":"Audit a model’s errors group by group, pick a fairness criterion that matches the harm, and monitor inputs, predictions and outcomes so you notice when the world drifts away from the training data.","html":"<p>Your champion model reached a test ROC AUC of 0.81. That’s one number for 100 customers from eight countries and three segments. It’s entirely possible for a model to be good on average and poor for one group, and for that group to be the one with the least power to complain. It’s also possible for a model that was good in October to be quietly wrong by March. This lesson covers both: fairness before launch, drift after it.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Where bias comes from</h2>\n<p>Bias in a model is rarely a line of code. It arrives with the data and with how predictions are used:</p>\n<ul>\n<li><strong>Historical bias</strong>: labels record past decisions, not ground truth. A model trained on who got approved learns who was approved, including any prejudice in those approvals.</li>\n<li><strong>Representation</strong>: small groups give the model less to learn from. Cartwheel has 17 Jordanian customers in the whole table; any model will be less reliable for them.</li>\n<li><strong>Proxies</strong>: removing a sensitive column doesn’t remove its information. Shipping fees, delivery times and currency can stand in for country.</li>\n<li><strong>Use</strong>: the same score means different things depending on the action. A churn flag that triggers a discount helps the flagged customer; a flag that triggers a credit freeze hurts them.</li>\n</ul>\n<h2 id=\"sec-2\" tabindex=\"-1\">Auditing a model group by group</h2>\n<p>To audit fairly you need predictions for many customers that the model didn’t train on. <code dir=\"ltr\">cross_val_predict</code> gives every row an out-of-fold prediction: each customer is scored by a model trained on the other folds.</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\"> 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.metrics </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> precision_score, recall_score</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_predict</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\">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:#1F2328;--shiki-dark:#E6EDF3\">prob </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> cross_val_predict(model, churn[features], 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\">method</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"predict_proba\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)[:, </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\">audit </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> churn.assign(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">flagged</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(prob </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">>=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0.5</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).astype(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">int</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">))</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">def</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> group_report</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(g):</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> pd.Series({</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">        \"customers\"</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\">(g),</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">        \"churn_rate\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: g[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churned\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">].mean(),</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">        \"flag_rate\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: g[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"flagged\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">].mean(),</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">        \"recall\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: recall_score(g[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churned\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">], g[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"flagged\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]),</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">        \"precision\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: precision_score(g[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churned\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">], g[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"flagged\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]),</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    })</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">print</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(audit.groupby(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"segment\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)[[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churned\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"flagged\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]].apply(group_report).round(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">))</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">print</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(audit.groupby(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"country\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)[[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churned\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"flagged\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]].apply(group_report).round(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</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>Read across the columns:</p>\n<ul>\n<li><strong>Flag rate</strong> (selection rate): how often each group gets the offer.</li>\n<li><strong>Recall</strong>: of each group’s churners, how many the model catches. Here that’s the share of at-risk customers who get help.</li>\n<li><strong>Precision</strong>: of each group’s flagged customers, how many were really leaving. Low precision means discounts going to people who’d have stayed.</li>\n</ul>\n<p>Recall ranges from 0.71 (Canada, UAE, UK) to 0.82 (Egypt, Jordan). Precision for small businesses is 0.66 against 0.78 for consumers. Some gaps are noise: Jordan’s numbers rest on 17 customers, so a single customer moves them by several points. Others deserve a closer look as data grows. The point of the audit isn’t a pass/fail stamp; it’s knowing where the model is weaker, so you can decide whether that’s acceptable.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">Choosing what “fair” means</h2>\n<p>There are several reasonable definitions, and when groups have different base rates (Egypt churns at 67%, Germany at 45%) they can’t all hold at once:</p>\n<ul>\n<li><strong>Equal selection rates</strong> (demographic parity): every group flagged at the same rate.</li>\n<li><strong>Equal recall</strong> (equal opportunity): every group’s true churners caught at the same rate.</li>\n<li><strong>Equal precision</strong>: a flag means the same risk in every group.</li>\n</ul>\n<p>Pick by asking who is harmed by which error. For win-back offers the harm is a churner who never gets the offer, so equal recall is the natural target. For a model that freezes accounts, the harm is a false positive, and you’d look at false positive rates instead. Write the choice down; it’s a business and ethical decision, not a technical default.</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>Deleting the sensitive column and declaring victory</span></p>\n<p>“We don’t use country, so the model can’t be biased by it” is called fairness through unawareness, and it fails whenever other features correlate with country. Worse, without the column you can’t run the audit above. Keep sensitive attributes out of the model if policy requires, but keep them in the evaluation data.</p>\n</aside>\n<h2 id=\"sec-4\" tabindex=\"-1\">After launch: drift</h2>\n<p>The model learned from customers as they were on 30 September 2025. The world moves. <strong>Data drift</strong> is a change in the inputs: a new mobile app shifts <code dir=\"ltr\">mobile_share</code>, a courier strike stretches <code dir=\"ltr\">days_since_last_order</code>. <strong>Concept drift</strong> is a change in the relationship itself: a competitor’s loyalty scheme makes even recent customers leave. And churn has <strong>label delay</strong>: you only learn whether a prediction was right 90 days later.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 230\" role=\"img\" aria-labelledby=\"l-ml-5-4-t1\"><defs><marker id=\"arrow-l-ml-5-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-5-4-t1\">A loop with four stages. Daily: check input distributions against training data. Daily: track the share of customers flagged. After 90 days: compare predictions with real outcomes. When checks fail: investigate, then retrain and re-audit, which feeds back to the deployed model.</title>\n  <rect class=\"d-box-primary\" x=\"250\" y=\"10\" width=\"180\" height=\"46\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"340\" y=\"38\" text-anchor=\"middle\">deployed model</text>\n  <rect class=\"d-box-accent\" x=\"20\" y=\"92\" width=\"190\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"115\" y=\"116\" text-anchor=\"middle\">input drift checks</text>\n  <text class=\"d-label-muted\" x=\"115\" y=\"137\" text-anchor=\"middle\">daily, no labels needed</text>\n  <rect class=\"d-box-accent\" x=\"245\" y=\"92\" width=\"190\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"340\" y=\"116\" text-anchor=\"middle\">prediction mix</text>\n  <text class=\"d-label-muted\" x=\"340\" y=\"137\" text-anchor=\"middle\">flag rate per group</text>\n  <rect class=\"d-box-warn\" x=\"470\" y=\"92\" width=\"190\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"565\" y=\"116\" text-anchor=\"middle\">real performance</text>\n  <text class=\"d-label-muted\" x=\"565\" y=\"137\" text-anchor=\"middle\">after 90 days</text>\n  <rect class=\"d-box-success\" x=\"250\" y=\"178\" width=\"180\" height=\"46\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"340\" y=\"206\" text-anchor=\"middle\">investigate, retrain, re-audit</text>\n  <path class=\"d-arrow\" d=\"M290 56 L150 90\" marker-end=\"url(#arrow-l-ml-5-4-1)\"/>\n  <path class=\"d-arrow\" d=\"M340 56 L340 90\" marker-end=\"url(#arrow-l-ml-5-4-1)\"/>\n  <path class=\"d-arrow\" d=\"M390 56 L530 90\" marker-end=\"url(#arrow-l-ml-5-4-1)\"/>\n  <path class=\"d-arrow\" d=\"M115 148 L250 195\" marker-end=\"url(#arrow-l-ml-5-4-1)\"/>\n  <path class=\"d-arrow\" d=\"M565 148 L430 195\" marker-end=\"url(#arrow-l-ml-5-4-1)\"/>\n</svg><figcaption>Monitoring a deployed churn model</figcaption></figure>\n<p>Input drift can be checked every day, without labels, by comparing each feature’s recent values with the training data. The two-sample Kolmogorov–Smirnov test from SciPy is a simple start:</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\">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\"> scipy.stats </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> ks_2samp</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\">training </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> churn[features]</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\"># Simulate next quarter: a courier problem delays orders and a new app shifts shopping to mobile</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\">7</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">recent </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> training.sample(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">150</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\">1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).copy()</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">recent[</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:#1F2328;--shiki-dark:#E6EDF3\"> rng.integers(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">20</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">60</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\">(recent))</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">recent[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"mobile_share\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (recent[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"mobile_share\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">+</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0.3</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">).clip(</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\">1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">for</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> col </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">in</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> features:</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    stat, p </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> ks_2samp(training[col], recent[col])</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    flag </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"DRIFT\"</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> p </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">&#x3C;</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0.01</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> else</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"\"</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:#1F2328;--shiki-dark:#E6EDF3\">col</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">:22}</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> KS </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">stat</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">:.2f}</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">  p=</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">p</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">:.3g}</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> {</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">flag</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</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>The two columns that changed light up; the rest don’t. In production you’d run this on a schedule, alert on sustained drift rather than a single bad day, and pair it with the flag rate per group and, once outcomes arrive, recall and precision per group. When real performance drops, retrain on recent data and rerun this lesson’s audit before redeploying.</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>Write a model card</span></p>\n<p>Keep a one-page record next to the model: what it predicts and for whom, the training data dates, metrics overall and per group, the fairness criterion you chose and why, known weaknesses (17 Jordanian customers), and what is monitored. It’s the document someone will need when the model surprises them in a year.</p>\n</aside>\n<p>Monitoring assumes the model is running somewhere. The last lesson packages the champion pipeline, puts it behind an API and closes the loop.</p>\n","toc":[{"id":"sec-1","text":"Where bias comes from","level":2},{"id":"sec-2","text":"Auditing a model group by group","level":2},{"id":"sec-3","text":"Choosing what “fair” means","level":2},{"id":"sec-4","text":"After launch: drift","level":2}],"takeaways":["Bias enters through the data (historical labels, under-represented groups, proxy features) and through how predictions are used; a good overall score can hide a bad score for one group.","Audit with per-group metrics: selection rate, recall and precision, computed on out-of-fold or test predictions.","Fairness definitions such as equal selection rates, equal recall and equal precision usually can’t all hold at once; choose the one that matches who is harmed by which error.","Dropping a sensitive column doesn’t remove bias, because other features act as proxies; keep it available for auditing.","Monitor three things after launch: input distributions (data drift), the distribution of predictions, and real performance once outcomes arrive."],"further":[{"title":"cross_val_predict","url":"https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.cross_val_predict.html"},{"title":"Model evaluation: classification metrics","url":"https://scikit-learn.org/stable/modules/model_evaluation.html#classification-metrics"}],"quiz":[{"q":"<p>The churn model’s recall is 0.82 for Egyptian customers and 0.71 for Canadian customers. Win-back offers go to flagged customers. Who is disadvantaged?</p>","options":[{"text":"Canadian churners, who are less likely to be flagged and offered a deal.","why":"Correct. Lower recall means more Canadian churners are missed, so fewer of them receive the benefit."},{"text":"Egyptian customers, because more of them are flagged.","why":"Being flagged here means receiving an offer, which is a benefit, not a penalty."},{"text":"Nobody, because the overall recall is fine.","why":"An overall average can hide a group that’s consistently served worse."},{"text":"Canadian customers who stayed, because they’re flagged more.","why":"That would be about false positives (precision or false positive rate), not recall."}],"answer":0},{"q":"<p>A teammate removes <code dir=\"ltr\">country</code> from the features ‘to make the model fair’. Why is that not enough?</p>","options":[{"text":"Removing a column always lowers accuracy below the baseline.","why":"It might change accuracy a little or not at all; the fairness problem is separate."},{"text":"Other features, such as shipping fees or delivery times, can carry the same information, and you can no longer audit by country.","why":"Correct. Proxies let the bias back in, and without the column you can’t measure whether it did."},{"text":"scikit-learn requires sensitive attributes to stay in the model.","why":"There’s no such requirement; the issue is about proxies and auditing."}],"answer":1},{"q":"<p>Two months after launch, the share of customers flagged as churners rises from 47% to 60%, but no churn outcomes are known yet. What’s the best first step?</p>","options":[{"text":"Retrain immediately on the latest data.","why":"You don’t have new labels yet, and you don’t know whether the model or the world changed."},{"text":"Lower the threshold so the flag rate returns to 47%.","why":"That hides the signal instead of explaining it."},{"text":"Ignore it until the 90-day outcomes arrive.","why":"Waiting three months for a problem you can investigate today is costly."},{"text":"Compare current input distributions with the training data to find which features shifted, then ask the business why.","why":"Correct. Data drift checks work without labels and usually point to a cause, such as a delivery problem or a new app."}],"answer":3}],"exercise":{"kind":"python","files":["churn.csv"],"starter":"import numpy as np\nimport pandas as pd\nfrom scipy.stats import ks_2samp\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.metrics import precision_score, recall_score\nfrom sklearn.model_selection import StratifiedKFold, cross_val_predict\nfrom sklearn.pipeline import make_pipeline\nfrom sklearn.preprocessing import StandardScaler\n\nchurn = pd.read_csv(\"churn.csv\")\nfeatures = [\"tenure_days\", \"orders\", \"total_spent\", \"avg_order_value\",\n            \"days_since_last_order\", \"used_coupon\", \"support_tickets\", \"mobile_share\"]\ncv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)\nmodel = make_pipeline(StandardScaler(), LogisticRegression())\n\n# Next quarter's customers (simulated): a premium range lifted basket sizes and a returns policy change caused more tickets\nrng = np.random.default_rng(3)\nrecent = churn[features].sample(150, random_state=2).copy()\nrecent[\"avg_order_value\"] *= 1.6\nrecent[\"support_tickets\"] += rng.integers(1, 3, len(recent))\n\n# 1. Out-of-fold churn flags (threshold 0.5) for every customer, using cross_val_predict\nflagged = None\n\n# 2. Recall per segment (a Series indexed by segment), and the segment with the lowest recall\nrecall_by_segment = None\nlowest_recall_segment = None\n\n# 3. Columns of `recent` whose distribution differs from churn[features] (KS test p < 0.01), as a list\ndrifted = []\n","solution":"import numpy as np\nimport pandas as pd\nfrom scipy.stats import ks_2samp\nfrom sklearn.linear_model import LogisticRegression\nfrom sklearn.metrics import precision_score, recall_score\nfrom sklearn.model_selection import StratifiedKFold, cross_val_predict\nfrom sklearn.pipeline import make_pipeline\nfrom sklearn.preprocessing import StandardScaler\n\nchurn = pd.read_csv(\"churn.csv\")\nfeatures = [\"tenure_days\", \"orders\", \"total_spent\", \"avg_order_value\",\n            \"days_since_last_order\", \"used_coupon\", \"support_tickets\", \"mobile_share\"]\ncv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)\nmodel = make_pipeline(StandardScaler(), LogisticRegression())\n\n# Next quarter's customers (simulated): a premium range lifted basket sizes and a returns policy change caused more tickets\nrng = np.random.default_rng(3)\nrecent = churn[features].sample(150, random_state=2).copy()\nrecent[\"avg_order_value\"] *= 1.6\nrecent[\"support_tickets\"] += rng.integers(1, 3, len(recent))\n\nprob = cross_val_predict(model, churn[features], churn[\"churned\"], cv=cv, method=\"predict_proba\")[:, 1]\nflagged = (prob >= 0.5).astype(int)\n\naudit = churn.assign(flagged=flagged)\nrecall_by_segment = audit.groupby(\"segment\")[[\"churned\", \"flagged\"]].apply(\n    lambda g: recall_score(g[\"churned\"], g[\"flagged\"]))\nlowest_recall_segment = recall_by_segment.idxmin()\n\ndrifted = [c for c in features if ks_2samp(churn[c], recent[c]).pvalue < 0.01]\nprint(recall_by_segment.round(2).to_dict(), lowest_recall_segment, drifted)\n","checks":[{"id":"flags","test":"flagged is not None and len(flagged) == 398 and set(np.unique(flagged)) <= {0, 1} and abs(int(np.sum(flagged)) - 218) <= 2"},{"id":"recall","test":"recall_by_segment is not None and set(recall_by_segment.index) == {'consumer', 'enterprise', 'small_business'} and abs(float(recall_by_segment['consumer']) - 0.7582) < 0.01"},{"id":"lowest","test":"lowest_recall_segment == 'enterprise'"},{"id":"drift","test":"sorted(drifted) == ['avg_order_value', 'support_tickets']"}],"title":"Audit by segment, then check for drift","prompt":"<ol>\n<li><code dir=\"ltr\">flagged</code>: an out-of-fold 0/1 churn flag for every customer, from <code dir=\"ltr\">cross_val_predict(model, ..., cv=cv, method=&quot;predict_proba&quot;)</code> and a 0.5 threshold.</li>\n<li><code dir=\"ltr\">recall_by_segment</code>: recall of those flags within each <code dir=\"ltr\">segment</code> (a Series indexed by segment). <code dir=\"ltr\">lowest_recall_segment</code>: the segment whose churners are caught least often.</li>\n<li><code dir=\"ltr\">drifted</code>: the list of feature names where <code dir=\"ltr\">recent</code> differs from <code dir=\"ltr\">churn[features]</code> according to a two-sample KS test with p &lt; 0.01.</li>\n</ol>\n","hints":["<code dir=\"ltr\">cross_val_predict(...)[:, 1]</code> gives the churn probability column; compare it with 0.5 and convert with <code dir=\"ltr\">.astype(int)</code>.","Add the flags as a column, then <code dir=\"ltr\">audit.groupby(&quot;segment&quot;)[[&quot;churned&quot;, &quot;flagged&quot;]].apply(lambda g: recall_score(g[&quot;churned&quot;], g[&quot;flagged&quot;]))</code>.","<code dir=\"ltr\">ks_2samp(churn[c], recent[c]).pvalue</code> for each <code dir=\"ltr\">c</code> in <code dir=\"ltr\">features</code>; keep those below 0.01."],"explanation":"<p>Out-of-fold predictions let you audit all 398 customers without scoring anyone with a model that trained on them. Enterprise churners are caught least often (about 0.71, against 0.76 for consumers), but with only 24 enterprise customers that gap needs more data before anyone acts on it. The KS checks flag exactly the two columns the simulation changed: average order value and support tickets. That is the early warning you’d get months before the churn outcomes confirm a drop in performance.</p>\n","checkLabels":{"flags":"flagged holds out-of-fold 0/1 flags for all 398 customers","recall":"recall_by_segment has the recall of each segment","lowest":"lowest_recall_segment names the weakest segment","drift":"drifted lists exactly the shifted columns"}},"words":818,"readMinutes":4}