{"id":"l-ml-5-5","courseId":"ml-crash-course","lang":"en","kind":"lesson","summary":"Save the champion pipeline with joblib, wrap it in a validated prediction function, serve it as a nightly batch job or an API, and measure whether it changes business outcomes.","html":"<p>A model in a notebook helps nobody. To change what Cartwheel does, the champion pipeline has to run on fresh customer data, on a schedule or on request, and its scores have to reach the people who send the win-back offers. This lesson covers the last mile: saving the model, guarding its inputs, choosing between a batch job and an API, and proving it works.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Save the pipeline, not just the model</h2>\n<p>The thing to ship is the whole fitted pipeline. The scaler’s means and standard deviations are as much a part of the model as the coefficients; without them, production would scale new customers differently and every score would be off.</p>\n<p>scikit-learn’s model persistence guide covers several formats (pickle, joblib, skops, ONNX); <code dir=\"ltr\">joblib</code> is the most common when Python both saves and loads the model. Save a small bundle with what someone will need later: the pipeline, the exact feature list, a version label and the library version it was trained with.</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\"> os</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> tempfile</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> joblib</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\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> sklearn</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.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\">champion </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> make_pipeline(StandardScaler(), LogisticRegression()).fit(churn[features], churn[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churned\"</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\">bundle </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"model\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: champion, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"features\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: features,</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">          \"version\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churn-2025-09-30.1\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"sklearn_version\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: sklearn.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">__version__</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\">with</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> tempfile.TemporaryDirectory() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">as</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> folder:</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    path </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> os.path.join(folder, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churn_model.joblib\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    joblib.dump(bundle, path)</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:#0A3069;--shiki-dark:#A5D6FF\">\"saved\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, os.path.getsize(path), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"bytes\"</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\">    loaded </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> joblib.load(path)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    same </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (loaded[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"model\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">].predict_proba(churn[features]) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">==</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> champion.predict_proba(churn[features])).all()</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:#0A3069;--shiki-dark:#A5D6FF\">\"identical predictions after reload:\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, same, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"| trained with scikit-learn\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, loaded[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"sklearn_version\"</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>Notice the final model is fitted on all 398 labelled customers. The test set’s job was to measure the modelling process; once you’ve measured it, retraining on every labelled row gives the deployed model a little more to learn from. Record that you did it, and keep the reported metric from the test-set evaluation.</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>Loading a model file you didn’t make</span></p>\n<p><code dir=\"ltr\">joblib</code> files are pickles, and loading a pickle can execute arbitrary code. Treat a model file like a program: only load ones you produced or that come from a trusted, access-controlled store. Also load with the same scikit-learn version you trained with; a different version may warn with <code dir=\"ltr\">InconsistentVersionWarning</code> or behave differently. Pin the version in your requirements.</p>\n</aside>\n<h2 id=\"sec-2\" tabindex=\"-1\">Guard the inputs</h2>\n<p>A model will happily score nonsense. A customer with <code dir=\"ltr\">orders = 0</code>, a <code dir=\"ltr\">mobile_share</code> of 7, or a missing column produces a probability that looks as confident as any other. Put a thin layer in front of <code dir=\"ltr\">predict_proba</code> that checks the input and fails loudly:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"python\" data-index=\"1\"><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:#6E7781;--shiki-dark:#8B949E\"># assumes `import pandas as pd` and a loaded `bundle` as above</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">def</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> predict_churn</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(record: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">dict</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, bundle: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">dict</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) -> </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">float</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">:</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    missing </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [f </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">for</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> f </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">in</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> bundle[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"features\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> f </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">not</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> in</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> record]</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> missing:</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">        raise</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> ValueError</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\">\"missing fields: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">missing</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>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    if</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> not</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 0</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> &#x3C;=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> record[</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\">&#x3C;=</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:#CF222E;--shiki-dark:#FF7B72\">        raise</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> ValueError</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"mobile_share must be between 0 and 1\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    row </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> pd.DataFrame([record])[bundle[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"features\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]]</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> float</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(bundle[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"model\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">].predict_proba(row)[</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></code></pre></div>\n<p>Selecting <code dir=\"ltr\">[bundle[&quot;features&quot;]]</code> fixes the column order, which, as section 1 showed, scikit-learn insists on.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">Batch job or API?</h2>\n<p>There are two common ways to put a model to work:</p>\n<ul>\n<li><strong>Batch scoring</strong>: a scheduled job (nightly or weekly) loads the model, scores every active customer from the warehouse and writes the probabilities to a table. Marketing’s tools read the table. Simple, cheap, easy to monitor, and right for churn, where a decision a day later costs nothing.</li>\n<li><strong>Online API</strong>: a web service scores one customer per request, in milliseconds. Needed when the decision happens during a user’s session, such as showing a retention offer on the cancellation page.</li>\n</ul>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 700 250\" role=\"img\" aria-labelledby=\"l-ml-5-5-t1\"><defs><marker id=\"arrow-l-ml-5-5-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-5-t1\">A training pipeline produces a versioned model file. The model file feeds either a nightly batch job that writes scores to a table for marketing, or an online API that answers single requests. Both log inputs and predictions to monitoring, which feeds back into retraining.</title>\n  <rect class=\"d-box\" x=\"10\" y=\"95\" width=\"130\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"75\" y=\"120\" text-anchor=\"middle\">training</text>\n  <text class=\"d-label-muted\" x=\"75\" y=\"140\" text-anchor=\"middle\">pipeline + CV</text>\n  <path class=\"d-arrow\" d=\"M140 123 L178 123\" marker-end=\"url(#arrow-l-ml-5-5-1)\"/>\n  <rect class=\"d-box-primary\" x=\"182\" y=\"95\" width=\"130\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-code\" x=\"247\" y=\"120\" text-anchor=\"middle\">model.joblib</text>\n  <text class=\"d-label-muted\" x=\"247\" y=\"140\" text-anchor=\"middle\">versioned</text>\n  <path class=\"d-arrow\" d=\"M312 110 L360 62\" marker-end=\"url(#arrow-l-ml-5-5-1)\"/>\n  <path class=\"d-arrow\" d=\"M312 136 L360 184\" marker-end=\"url(#arrow-l-ml-5-5-1)\"/>\n  <rect class=\"d-box-accent\" x=\"364\" y=\"30\" width=\"160\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"444\" y=\"55\" text-anchor=\"middle\">nightly batch job</text>\n  <text class=\"d-label-muted\" x=\"444\" y=\"75\" text-anchor=\"middle\">scores → table</text>\n  <rect class=\"d-box-accent\" x=\"364\" y=\"160\" width=\"160\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"444\" y=\"185\" text-anchor=\"middle\">online API</text>\n  <text class=\"d-label-muted\" x=\"444\" y=\"205\" text-anchor=\"middle\">one request, ms</text>\n  <path class=\"d-arrow\" d=\"M524 58 L568 110\" marker-end=\"url(#arrow-l-ml-5-5-1)\"/>\n  <path class=\"d-arrow\" d=\"M524 188 L568 136\" marker-end=\"url(#arrow-l-ml-5-5-1)\"/>\n  <rect class=\"d-box-warn\" x=\"572\" y=\"95\" width=\"118\" height=\"56\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"631\" y=\"120\" text-anchor=\"middle\">monitoring</text>\n  <text class=\"d-label-muted\" x=\"631\" y=\"140\" text-anchor=\"middle\">logs, drift</text>\n  <path class=\"d-dashed d-line\" d=\"M631 151 C631 240 75 240 75 151\" fill=\"none\"/>\n  <text class=\"d-label-muted\" x=\"350\" y=\"244\" text-anchor=\"middle\">retrain when monitoring says so</text>\n</svg><figcaption>From training to decisions</figcaption></figure>\n<p>If you do need an API, a small FastAPI service is a common choice in Python. This sketch isn’t runnable in the browser, but it’s complete enough to run locally with <code dir=\"ltr\">uvicorn app:app</code>:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"python\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">app.py</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:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> joblib</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\"> fastapi </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> FastAPI</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> pydantic </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> BaseModel, Field</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\"># joblib uses pickle: load only model files from your own trusted, access-controlled store</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">bundle </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> joblib.load(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churn_model.joblib\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">app </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> FastAPI()</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">class</span><span style=\"color:#953800;--shiki-dark:#FFA657\"> Customer</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">BaseModel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">):</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    tenure_days: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">int</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Field(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">ge</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</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\">    orders: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">int</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Field(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">ge</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\">    total_spent: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">float</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Field(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">ge</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</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\">    avg_order_value: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">float</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Field(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">ge</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</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\">    days_since_last_order: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">int</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Field(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">ge</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</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\">    used_coupon: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">int</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Field(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">ge</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">le</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\">    support_tickets: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">int</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Field(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">ge</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</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\">    mobile_share: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">float</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> Field(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">ge</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">le</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>\n<span class=\"line\"><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">@app.post</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"/predict\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">def</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> predict</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(customer: Customer):</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    row </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> pd.DataFrame([customer.model_dump()])[bundle[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"features\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]]</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:#0550AE;--shiki-dark:#79C0FF\"> float</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(bundle[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"model\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">].predict_proba(row)[</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 style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"churn_probability\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">round</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(prob, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">3</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">), </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"model_version\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: bundle[</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"version\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]}</span></span></code></pre></div>\n<p>The Pydantic model does the input validation declaratively: a request with <code dir=\"ltr\">orders: 0</code> or a missing field gets a 422 error before the model ever sees it. Returning the model version with every answer makes it possible to trace any prediction back to the exact model that made it. Log every request and response; those logs are what the drift checks from the previous lesson run on.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">Did it work? Run an experiment</h2>\n<p>A good ROC AUC means the model ranks customers well. It doesn’t mean the win-back offer changes anyone’s mind. To measure that, hold out a random slice of flagged customers (say 20%) who don’t get the offer. After 90 days, compare churn between the contacted and held-out groups. The difference is the effect of the offer on the customers the model picks, and multiplied by customer value it’s the number the business actually cares about. Without the held-out group, any drop in churn could be the season, a competitor’s stumble or luck.</p>\n<aside class=\"callout callout-why\" 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\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><circle cx=\"12\" cy=\"12\" r=\"6\"/><circle cx=\"12\" cy=\"12\" r=\"2\"/></svg><span><span class=\"callout-kind\">Why this matters</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Why the held-out group is worth the lost revenue</span></p>\n<p>Withholding offers from some at-risk customers feels expensive. But without it, you can’t tell a system that saves customers from one that only spends money on discounts, and you’ll never know when it stops working.</p>\n</aside>\n<h2 id=\"sec-5\" tabindex=\"-1\">Where you are now</h2>\n<p>You started with a question and a table, and you now have a process that holds up: frame the problem, split before you look, beat a majority-class dummy and a rule, choose metrics by the cost of each mistake, compare models with cross-validation inside leak-free pipelines, check who the model serves worse, and ship something you can monitor and test. The champion turned out to be the simplest serious model, and you know exactly why. That habit of asking “compared to what?” is the most valuable thing this course had to teach, and it applies unchanged to the next model you build, whatever it is.</p>\n","toc":[{"id":"sec-1","text":"Save the pipeline, not just the model","level":2},{"id":"sec-2","text":"Guard the inputs","level":2},{"id":"sec-3","text":"Batch job or API?","level":2},{"id":"sec-4","text":"Did it work? Run an experiment","level":2},{"id":"sec-5","text":"Where you are now","level":2}],"takeaways":["Save the whole fitted pipeline, not only the model, so preprocessing travels with it; store the feature list, a version and the scikit-learn version alongside.","<code dir=\"ltr\">joblib</code> and <code dir=\"ltr\">pickle</code> files can run code when loaded, so only load model files you created or trust, and load them with the same scikit-learn version.","[object Object]","Many business models, churn included, are best shipped as a scheduled batch job; an online API is for decisions that must happen during a request.","The real test of a model is a controlled experiment: compare outcomes for flagged customers who got the offer with a randomly held-out group who didn’t."],"further":[{"title":"Model persistence","url":"https://scikit-learn.org/stable/model_persistence.html"},{"title":"pickle: security warning","url":"https://docs.python.org/3/library/pickle.html"}],"quiz":[{"q":"<p>Why save the fitted pipeline rather than only the <code dir=\"ltr\">LogisticRegression</code> step?</p>","options":[{"text":"The pipeline file is smaller.","why":"It’s slightly larger, if anything; size isn’t the reason."},{"text":"The scaler’s learned means and standard deviations are part of the model; without them, new data would be scaled differently.","why":"Correct. The pipeline guarantees production applies exactly the preprocessing the model was trained with."},{"text":"joblib can’t save a LogisticRegression on its own.","why":"It can save any picklable object; the issue is consistency, not capability."},{"text":"Pipelines load faster than single estimators.","why":"Load time is negligible either way."}],"answer":1},{"q":"<p>A colleague emails you <code dir=\"ltr\">model.joblib</code> from an unknown source and asks you to evaluate it. What’s the risk?</p>","options":[{"text":"Loading it can execute arbitrary code on your machine.","why":"Correct. joblib uses pickle, which can run code during loading. Only load files from sources you trust."},{"text":"It might use too much memory to open.","why":"Possible but minor; the serious risk is code execution."},{"text":"It will silently retrain on your data.","why":"Loading doesn’t train anything. The danger is what the file can run while being loaded."}],"answer":0},{"q":"<p>Marketing contacts every customer the model flags, and churn among them drops from 79% to 60%. Can you credit the model and the offer?</p>","options":[{"text":"Yes; churn fell after launch, so the system works.","why":"Churn might have fallen anyway (season, a competitor’s problem). Without a comparison group you can’t tell."},{"text":"Yes, as long as test-set ROC AUC was above 0.8.","why":"AUC shows the model ranks well; it says nothing about whether the offer changes behaviour."},{"text":"No, because churn should be measured over 30 days, not 90.","why":"The window is a business definition; the problem is the missing comparison."},{"text":"Not yet; you need a randomly held-out group of flagged customers who got no offer to compare against.","why":"Correct. The difference between the two groups is the effect of the offer."}],"answer":3}],"exercise":{"kind":"python","files":["churn.csv"],"starter":"import io\n\nimport joblib\nimport pandas as pd\nimport sklearn\nfrom sklearn.linear_model import LogisticRegression\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\"]\nchampion = make_pipeline(StandardScaler(), LogisticRegression()).fit(churn[features], churn[\"churned\"])\n\ngood = {\"tenure_days\": 400, \"orders\": 2, \"total_spent\": 180.0, \"avg_order_value\": 90.0,\n        \"days_since_last_order\": 150, \"used_coupon\": 0, \"support_tickets\": 1, \"mobile_share\": 0.5}\n\n# 1. A bundle with the fitted pipeline, the feature list, a version string and sklearn's version\nbundle = {\"model\": champion}\n\n# 2. Save it with joblib to an in-memory buffer, then load it back into `reloaded`\nbuffer = io.BytesIO()\nreloaded = None\n\n# 3. Validate, then predict. Raise ValueError if any feature is missing,\n#    or if orders < 1, or if mobile_share is outside 0..1.\ndef predict_churn(record, bundle):\n    row = pd.DataFrame([record])\n    return bundle[\"model\"].predict_proba(row)[0, 1]\n","solution":"import io\n\nimport joblib\nimport pandas as pd\nimport sklearn\nfrom sklearn.linear_model import LogisticRegression\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\"]\nchampion = make_pipeline(StandardScaler(), LogisticRegression()).fit(churn[features], churn[\"churned\"])\n\ngood = {\"tenure_days\": 400, \"orders\": 2, \"total_spent\": 180.0, \"avg_order_value\": 90.0,\n        \"days_since_last_order\": 150, \"used_coupon\": 0, \"support_tickets\": 1, \"mobile_share\": 0.5}\n\nbundle = {\"model\": champion, \"features\": features,\n          \"version\": \"churn-2025-09-30.1\", \"sklearn_version\": sklearn.__version__}\n\nbuffer = io.BytesIO()\njoblib.dump(bundle, buffer)\nbuffer.seek(0)\nreloaded = joblib.load(buffer)\n\ndef predict_churn(record, bundle):\n    missing = [f for f in bundle[\"features\"] if f not in record]\n    if missing:\n        raise ValueError(f\"missing fields: {missing}\")\n    if record[\"orders\"] < 1:\n        raise ValueError(\"orders must be at least 1\")\n    if not 0 <= record[\"mobile_share\"] <= 1:\n        raise ValueError(\"mobile_share must be between 0 and 1\")\n    row = pd.DataFrame([record])[bundle[\"features\"]]\n    return float(bundle[\"model\"].predict_proba(row)[0, 1])\n\nprint(round(predict_churn(good, reloaded), 3))\n","checks":[{"id":"bundle","test":"set(bundle) >= {'model', 'features', 'version', 'sklearn_version'} and list(bundle['features']) == features and bundle['sklearn_version'] == sklearn.__version__"},{"id":"reloaded","test":"reloaded is not None and (reloaded['model'].predict_proba(churn[features]) == champion.predict_proba(churn[features])).all()"},{"id":"predicts","test":"isinstance(predict_churn(good, reloaded), float) and abs(predict_churn(good, reloaded) - float(champion.predict_proba(pd.DataFrame([good])[features])[0, 1])) < 1e-12 and abs(predict_churn(dict(reversed(list(good.items()))), reloaded) - predict_churn(good, reloaded)) < 1e-12"},{"id":"missing","test":"exec('try:\\n    predict_churn({k: v for k, v in good.items() if k != \"orders\"}, reloaded)\\n    _raised = False\\nexcept ValueError:\\n    _raised = True', globals()) or _raised"},{"id":"ranges","test":"exec('try:\\n    predict_churn({**good, \"mobile_share\": 7}, reloaded)\\n    _raised = False\\nexcept ValueError:\\n    _raised = True', globals()) or _raised and exec('try:\\n    predict_churn({**good, \"orders\": 0}, reloaded)\\n    _raised = False\\nexcept ValueError:\\n    _raised = True', globals()) or _raised"}],"title":"Package the champion","prompt":"<p>Get the champion ready to ship.</p>\n<ol>\n<li><code dir=\"ltr\">bundle</code>: a dict with <code dir=\"ltr\">&quot;model&quot;</code> (the fitted pipeline), <code dir=\"ltr\">&quot;features&quot;</code> (the feature list), <code dir=\"ltr\">&quot;version&quot;</code> (any string) and <code dir=\"ltr\">&quot;sklearn_version&quot;</code> (<code dir=\"ltr\">sklearn.__version__</code>).</li>\n<li>Save <code dir=\"ltr\">bundle</code> with <code dir=\"ltr\">joblib.dump</code> into the in-memory <code dir=\"ltr\">buffer</code>, rewind it, and load it back into <code dir=\"ltr\">reloaded</code>.</li>\n<li>Finish <code dir=\"ltr\">predict_churn(record, bundle)</code>: raise <code dir=\"ltr\">ValueError</code> if any feature is missing, if <code dir=\"ltr\">orders</code> is below 1, or if <code dir=\"ltr\">mobile_share</code> is outside 0–1. Otherwise return the churn probability as a Python <code dir=\"ltr\">float</code>, with columns in the bundle’s feature order (the record’s keys may come in any order).</li>\n</ol>\n","hints":["<code dir=\"ltr\">joblib.dump(bundle, buffer)</code>, then <code dir=\"ltr\">buffer.seek(0)</code> before <code dir=\"ltr\">joblib.load(buffer)</code>: loading reads from the current position.","Build <code dir=\"ltr\">missing = [f for f in bundle[&quot;features&quot;] if f not in record]</code> and raise if it isn’t empty.","<code dir=\"ltr\">pd.DataFrame([record])[bundle[&quot;features&quot;]]</code> fixes the column order; wrap the result of <code dir=\"ltr\">predict_proba(...)[0, 1]</code> in <code dir=\"ltr\">float()</code>."],"explanation":"<p>The bundle carries everything a production job needs: the pipeline with its fitted scaler, the column order, a version to log with every prediction, and the library version to pin. Reloading gives identical predictions. The guard turns bad input into an explicit error instead of a plausible-looking probability, and selecting <code dir=\"ltr\">bundle[&quot;features&quot;]</code> makes the column order independent of how the caller built the record. In a real service the buffer would be a file in a trusted model store.</p>\n","checkLabels":{"bundle":"bundle holds model, features, version and sklearn_version","reloaded":"reloaded predicts exactly like the original","predicts":"predict_churn returns the right float, whatever the key order","missing":"A missing feature raises ValueError","ranges":"orders below 1 or mobile_share outside 0–1 raise ValueError"}},"words":804,"readMinutes":4}