We closed module 7 with the workshop set up: a src/novamarket/ package with data.py, train.py and predict.py, tests that pass, a reproducible environment and a saved model with its metrics JSON. What was missing, we said, was a method: how to take a use case from the idea to a system that works in production and keeps working months later. This lesson provides it. We pick up the workflow diagram we presented in 04-01 as a map (problem → data → preparation → training → evaluation → deployment → monitoring) and develop it as a project, with the questions to answer in each phase, the roles involved and the mistakes that sink whole projects. And we do it on the case that has been with us since module 4: Marta and Diego take the returns predictor (use case 3) from idea to production inside novamarket_ai/, with a definition document, a decision to deploy in batches with a human review band, a model card generated from train.py, a predict_batch.py that sorts each night's orders into three queues and a drift check that warns when it is time to retrain. It matters because most AI projects do not fail because of the algorithm, but because one of these phases was skipped; and because in 09-04 you will make the same journey with your own project: here you have the method and the worked example.

Contents

  1. From map to method: the life cycle and the CRISP-DM and CRISP-ML(Q) frameworks
  2. Phase 1: define the business problem and success
  3. Phase 2: data (sources, availability, leakage, temporal split, governance)
  4. Phase 3: exploration and analysis
  5. Phase 4: quick prototype (baseline → simple model → iterate)
  6. Phase 5: evaluation with business criteria and with the stakeholders
  7. Phase 6: deployment
  8. Phase 7: monitoring and maintenance
  9. Phase 8: documentation and communicating results
  10. Team roles and typical project mistakes
  11. Worked example: the returns predictor in novamarket_ai/
  12. Common Mistakes and Tips
  13. Exercises
  14. Conclusion

  1. From map to method: the life cycle and the CRISP-DM and CRISP-ML(Q) frameworks

In 04-01 we drew the machine learning workflow as a sequence with loops back. Here we redraw it as the life cycle of a project, adding what we left open then: the business definition at the start, deployment and vigilance at the end, and the fact that the arrows back are not exceptions but the norm.

flowchart LR
    A[1. Business problem<br/>and definition of success] --> B[2. Data]
    B --> C[3. Exploration<br/>and analysis]
    C --> D[4. Prototype:<br/>baseline → model → iterate]
    D --> E[5. Evaluation<br/>with business criteria]
    E -->|not worth it| A
    E -->|data missing| B
    E -->|worth it| F[6. Deployment]
    F --> G[7. Monitoring<br/>and maintenance]
    G -->|drift| B
    G -->|business changes| A
    H[8. Documentation and communication] -.-> A & E & F & G

This cycle is not an invention of the course: it is the shape taken by two frameworks widely used in industry.

  • CRISP-DM (Cross-Industry Standard Process for Data Mining, late 1990s) defines six phases: business understanding, data understanding, data preparation, modelling, evaluation and deployment. Its great insight was to put business understanding before the data and to draw the arrows back.
  • CRISP-ML(Q) is an update designed for modern machine learning: it adds monitoring and maintenance as a phase in its own right (a deployed model degrades) and a cross-cutting quality assurance (Q): in each phase, requirements, risks and checks are defined. Our eight-step cycle is essentially CRISP-ML(Q) with documentation made explicit as phase 8.
Phase in our cycle CRISP-DM CRISP-ML(Q) Course lesson where the technique was covered
1. Problem and success Business understanding Business and data understanding 01-03 (value/feasibility/risk), 02-01 (performance measure), 02-04 (checklist)
2. Data Data understanding Business and data understanding 02-03, 04-03
3. Exploration Data understanding Data engineering 07-02
4. Prototype Preparation + modelling Model engineering 04-04, 04-06, 05-x
5. Evaluation Evaluation Model evaluation 04-05
6. Deployment Deployment Deployment 07-03, 07-04
7. Monitoring (not explicit) Monitoring and maintenance this lesson
8. Documentation cross-cutting Quality assurance (Q) this lesson

One idea worth fixing from now on: the time of an AI project is not split into equal parts. In real projects it is common for definition and data to consume more than half the effort, modelling a small fraction, and deployment and monitoring the rest. Anyone planning "two weeks of data and two months of model" is planning the project backwards.

  1. Phase 1: define the business problem and success

It is the phase that saves the most projects and is skipped the most. It consists of answering these questions in writing, on one page, before touching a single row of data:

  1. What question does the system answer? Not "predict returns", but "what is the probability that this order will be returned, known at the moment it is being prepared?".
  2. What decision changes? If the prediction changes no action, the project is worthless however good the metric. At NovaMarket the decision is: approve the order as usual, pass it to a person, or prepare the reverse logistics in advance (reusable packaging, return label, do not close the stock replenishment).
  3. What is the business metric and what is the technical one? The business metric is expressed in the unit of the person who decides: euros (cost of false positives and false negatives, 04-05), hours of work, customers retained. The technical one (AUC, F1, MAE) is a means. Both must be written down, together with the relationship between them.
  4. What is the baseline? What is done today: nothing, a rule of Diego's ("> €300"), a manual process. Without a baseline there is no way to say whether the model adds anything.
  5. What constraints are there? Legal (GDPR: legal basis, automated decisions; AI Act: risk level, 02-04), ethical (affected groups, proxies), operational (how many orders can one person review per day?), technical (how quickly is the answer needed?).
  6. When will we consider it has worked? A verifiable success criterion: "reduce the cost of unanticipated returns by at least 25 % against the baseline within three months, without exceeding 300 manual reviews a day".

This is where the nine-point ethical checklist of 02-04 applies and, if the system decides about people, the impact assessment. Not as a formality afterwards: its answers condition which data can be used (point 2), which model (explainability, point 4) and what human review is needed (point 5).

The output of the phase is a definition document: a one-page table signed off by business and data. We will see it done for the returns predictor in section 11.

  1. Phase 2: data (sources, availability, leakage, temporal split, governance)

With the problem defined, it is time to inventory the data. The questions:

  • Sources: which systems hold them? (orders.csv and customers.csv from the ERP, incidents.csv from the CRM, web logs). How often are they updated? Who owns them? Do they need joining (SQL, 07-01) and on which key?
  • Availability at prediction time: the most important and most forgotten question. Every feature must exist when the model is going to be used, not when it is trained. return_reason exists in the history, but only after the return: it is leakage (04-03). The actual delivery_days is not known either when the order is prepared; the promised one is. And a customer's previous_orders must be computed against their history up to that day, not against the whole file.
  • Labels: how is the truth obtained? A return is recorded weeks after the purchase: the model is trained on "mature" orders and in production the label arrives late, which affects how it is measured (phase 7).
  • Temporal split: in a problem with dates, the test set must be later than the training set (TimeSeriesSplit, 04-05); a random split lets the model "see the future" and gives optimistic metrics.
  • Volume and quality: how many positive cases there are (16.4 % of 3,000 orders is 492 returns: enough for a logistic regression, little for a deep network), NaN, duplicates, outliers (04-03).
  • Governance: legal basis and minimisation (GDPR), who accesses what, where the raw data is kept (never in Git, 07-04), what is anonymised, how long it is retained. And a datasheet: origin, period, columns, known biases, restrictions on use.

A rule that saves grief: build the training dataset with the same query you will use in production. If training comes out of a manual Excel and production out of a SQL query, the columns end up meaning different things (the so-called training-serving skew). We will see it with days_since_start in section 11.

  1. Phase 3: exploration and analysis

Before modelling, look. Exploration (07-02) has three goals in a project:

  1. Confirm that the problem exists in the data: does the return rate really vary with amount, category, type of customer? If nothing varies, there is no signal to learn.
  2. Detect quality and leakage problems: columns with too many NaN, impossible values (the €99,999 amount), columns that predict "too well" (suspected leakage).
  3. Feed the definition: exploration often changes the question. If 60 % of returns are electronics, perhaps the first deployment should be limited to that category.

The deliverable is an exploration notebook (Jupyter, 07-04) with charts and a list of findings and of the decisions they trigger. It is the right phase for the notebook; whatever matures moves to src/.

  1. Phase 4: quick prototype (baseline → simple model → iterate)

The classic mistake is to start with the most powerful model. The right order:

  1. Baseline without a model: "never returned" (€3,690 on the 750-order test set), Diego's rule "> €300" (€3,195). Any model has to beat that in euros.
  2. Simple, interpretable model, with the full pipeline: logistic regression inside a Pipeline that imputes, scales and encodes (04-03, 04-04). Make it work end to end before improving anything.
  3. Iterate with a budget: each iteration changes one thing (a new feature, an algorithm, a hyperparameter, 04-06) and is recorded with its metric. Marta gave herself a two-week budget for the prototype: the logistic regression gave AUC 0.844; the tuned forest 0.838; the 21→16→8→1 MLP, 0.834. None improved significantly on the logistic regression, which moreover explains its decisions. She stops: the time budget protects against "just one more try".
Iteration Change Test AUC Cost at threshold 0.2 Decision
0 "Never" baseline €3,690 (no threshold) reference
0b "> €300" rule €3,195 operational reference
1 Logistic + pipeline (21 columns) 0.844 €1,610 candidate
2 Tuned random forest 0.838 similar no improvement
3 MLP 21→16→8→1 0.834 similar no improvement, opaque

Practical rule: the prototype ends when two consecutive iterations do not move the business metric, not when the ideas run out.

  1. Phase 5: evaluation with business criteria and with the stakeholders

Technical evaluation (04-05) is necessary but not sufficient. In a project you evaluate with the people who are going to live with the system:

  • Business metric on the held-out test set: cost €2,485 at threshold 0.5 → €1,610 at threshold 0.2 (35 % less), against €3,690 for doing nothing.
  • Operational capacity: at threshold 0.2, 200 orders out of 750 are flagged (27 %); Diego says his team cannot review that many. Hence the human review band 0.2-0.5: 60 automatic orders, 140 to review, 550 approved.
  • Fairness: parity of flag rates across postcode zones (02-04) on the test set.
  • Concrete cases: show Diego ten orders from each queue with the explanation (the coefficients of the logistic regression) so he can judge whether the model "makes sense". This step uncovers leakage and errors that no metric shows.
  • Risks and plan B: what happens if the model fails (it is switched off and the manual process resumes; see rollback in phase 6).

The output is an explicit decision: proceed to deployment, go back to data, or stop. Stopping is a valid outcome of a well-run project; what is not valid is deploying because a lot has already been invested.

  1. Phase 6: deployment

Deploying is integrating the model into a real flow. The decisions:

Decision Options Criterion NovaMarket (returns)
When to predict In batches (batch: every night, every hour) vs real time (per request) How long can the decision wait? The order is prepared the next day: a nightly batch is enough
How to integrate API (FastAPI, serve.py from 07-03) vs file/table consumed by the existing process Who consumes the prediction and how do they work today? A CSV/table of queues loaded by the warehouse tool; the API is left for the future assistant
Degree of automation Fully automatic vs human-in-the-loop by band Impact on people, cost of errors (02-04) Three queues: approve / review / flag
How it is tested for real Shadow mode (predicts but does not act; compared with what happens) → A/B or gradual rollout (one warehouse first) → full Risk of the change Two weeks in shadow at Getafe, then review band at Getafe, then Zaragoza
Rollback plan Switch to return to the previous process; previous model version kept Always Configuration variable ACTIVE_MODEL=off sends everything back to "approve" and Diego's rule
Packaging Script in cron, Docker container (07-04), service Available infrastructure predict_batch.py script launched by the nightly scheduler

Two notes. Shadow mode means running the model in production without its outputs affecting anything, only to record them and compare later with reality: it is the cheapest way to discover that production data is not like the training data. And the A/B test (part of the orders with the model, part without, comparing the cost) is the only way to measure causal impact; at a conceptual level it is enough to know that it exists and that it requires assigning the groups at random and waiting until there are enough cases.

  1. Phase 7: monitoring and maintenance

A deployed model starts going stale on day one. Three things must be watched:

  1. Technical health: did the batch run? how many orders? how long did it take? were there errors or missing columns?
  2. Data drift: the distribution of the inputs changes (higher amounts at Christmas, more new customers after a campaign, a new category). It is detected by comparing the recent distribution with the training one: difference of means, an index like the PSI (Population Stability Index) by bins, or simply the model's flag rate (if it suddenly flags 50 % of orders, something has changed).
  3. Concept drift: the relationship between inputs and output changes (a new free-returns policy makes the same orders get returned more). It can only be detected when the actual labels arrive, late: AUC and cost must be recomputed on the orders from a few weeks ago whose returns are already known.

And act: alerts with thresholds (warning / alert), a retraining calendar (periodic or drift-triggered), model versioning (v1.0.0, v1.1.0, each with its metrics JSON) and a decision log (which model scored which batch, how many orders went to each queue, what the reviewer decided): it serves to audit, to explain to a customer and to retrain with human labels. The specific tools of this terrain (MLflow, drift dashboards) were mentioned in 07-03; here it is enough to understand what is measured and to program it by hand.

  1. Phase 8: documentation and communicating results

Two short documents and a habit:

  • Model card: what it is, what it can and cannot be used for, what data it was trained on, how it performs (technical and business metrics, per group where relevant), known limitations, owner, version and date. It was born as an academic proposal and today it is standard practice and, for high-risk systems, part of what regulation demands (02-04). We will generate it automatically from train.py.
  • Datasheet: origin, period, columns, how it was labelled, known biases, legal basis.
  • Communication: you do not bring Diego an AUC; you bring him "€1,610 against €3,690 per 750 orders, 140 reviews per 750, and a list of cases you can look at". To management, three figures and one risk. To the technical team, the repository and the model card.

  1. Team roles and typical project mistakes

Even in a company of 180 people, an AI project needs four hats (sometimes on few heads):

Role Who at NovaMarket Responsibility
Business / owner Diego Defines the decision, the costs, the operational capacity; accepts or rejects
Data / modelling Marta Data, exploration, prototype, evaluation, model card
Engineering systems team Integration, nightly scheduler, environment, rollback, technical alerts
Legal / compliance external advisers GDPR, AI Act, impact assessment, data retention

And the mistakes that sink the most projects:

  1. Starting with the model ("let's do deep learning") with no decision and no business metric.
  2. No baseline: an AUC of 0.84 means nothing if Diego's rule already achieved almost the same.
  3. Wrong metric: optimising accuracy with imbalanced classes (04-05), or a technical metric that does not move in euros.
  4. Data that will not exist in production: leakage and columns computed differently at serving time.
  5. Not closing the loop: deploying without monitoring, without retraining, without a log; or staying in a notebook that never reaches production.
  6. Not involving the decision-maker: Diego finds out about the system the day 641 orders land on him for review.

  1. Worked example: the returns predictor in novamarket_ai/

We walk through the eight phases on the project we already have in novamarket_ai/. All the code in this section has been run in the course environment; the new files are predict_batch.py, drift.py, the model card functions in train.py and a test.

11.1 Definition document (phase 1)

Field Content
Question What is the probability that an order will be returned, known when it is prepared?
Decision that changes Every night, each order of the day goes to one of three queues: approve (normal flow), review (a person decides whether to prepare reverse logistics or contact the customer), flag (reverse logistics is prepared and the replenishment is not closed)
Business metric Cost = €5 per false alarm + €30 per unanticipated return (+ €3 per manual review) on mature orders
Technical metric AUC (model comparison); cost per threshold (threshold choice)
Baseline "Never": €3,690 / 750 orders. Diego's rule "> €300": €3,195
Success criterion ≥ 25 % less cost than the baseline within 3 months; ≤ 300 reviews/day; impact ratio between zones ≥ 0.8
Constraints GDPR: not an automated decision with legal effects (review band, no purchases denied); AI Act: limited/minimal risk; do not use postcode_zone to decide without review; raw data outside Git
Data orders.csv (3 months), customers.csv (history); label = return within 30 days
Deployment Nightly batch; output = queue table; shadow for 2 weeks at Getafe; rollback by configuration
Owner / responsible Diego (business), Marta (model), systems (integration), advisers (legal)
Budget Prototype: 2 weeks; pilot: 6 weeks

11.2 Data, exploration, prototype and evaluation (phases 2-5)

These phases are already done in modules 4 and 7 and we do not repeat them: data.py generates and prepares the orders (leakage out, 21-column Pipeline), and the logistic regression won the prototype with AUC 0.844 and cost €1,610 at threshold 0.2. Only one correction that shows up when preparing the deployment, typical of phase 2: prepare_orders computed days_since_start relative to the first order in the file. In training that is 1 September 2025; in a nightly batch of 1 December it would be... 1 December, and the column would be 0 instead of 91. It is a textbook case of training-serving skew. The fix: a start_date parameter that in production is passed explicitly.

# src/novamarket/data.py (modified fragment)
def prepare_orders(dirty, start_date=None):
    """... start_date: reference date for 'days_since_start'. In training it is taken from the
    first order in the history; in production the SAME date used in training MUST be passed."""
    clean = dirty.drop_duplicates()
    ...
    if start_date is None:
        start_date = clean["order_date"].min()
    clean["days_since_start"] = (clean["order_date"] - pd.Timestamp(start_date)).dt.days
    ...

We also move the business_cost(matrix, cost_fp=5, cost_fn=30) function of 04-05 into data.py, because the model card needs it.

11.3 Code (a): the model card generated from train.py (phase 8)

We extend train.py so that, besides the model and its versions JSON, it writes the model card in two formats (JSON for machines, Markdown for people) and a drift reference that monitoring will use. train() now returns a third value with the context (splits and test probabilities).

# src/novamarket/train.py (new parts; the rest is the one from 07-04)
from datetime import date
import numpy as np
from sklearn.metrics import confusion_matrix, roc_auc_score
from novamarket.data import business_cost, ...

LOW_THRESHOLD, HIGH_THRESHOLD = 0.2, 0.5      # human review band decided in 04-05
MODEL_VERSION = "1.0.0"

def train(n=3000, seed=42):
    """Generates the data, trains and returns (pipeline, test AUC, context for the card)."""
    X, y = prepare_orders(dirty_orders(generate_orders_ml(n, seed), seed))
    Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=seed, stratify=y)
    pipe = Pipeline([("prep", build_preprocessing()),
                     ("model", LogisticRegression(max_iter=1000))]).fit(Xtr, ytr)
    prob = pipe.predict_proba(Xte)[:, 1]
    auc = roc_auc_score(yte, prob)
    return pipe, auc, {"Xtr": Xtr, "Xte": Xte, "yte": yte, "prob_test": prob}

def write_model_card(auc, context, out_dir, n, seed):
    """Minimal model card: what it is, on what data, how it performs, limits. JSON + Markdown."""
    yte, prob = context["yte"], context["prob_test"]
    costs = {}
    for u in (0.5, LOW_THRESHOLD):                                # business cost at the two thresholds
        m = confusion_matrix(yte, (prob >= u).astype(int))
        costs[f"threshold_{u}"] = {"cost_eur": business_cost(m), "flagged": int(m[:, 1].sum())}
    card = {
        "name": "NovaMarket returns predictor (use case 3)",
        "version": MODEL_VERSION,
        "training_date": date.today().isoformat(),
        "owner": "Marta (data); business owner: Diego (operations)",
        "intended_use": "Score each day's orders every night and sort them into three queues: "
                        "approve (<0.2), review by a person (0.2-0.5) and flag (>=0.5).",
        "out_of_scope_use": "Denying purchases, penalising customers or deciding without human "
                            "review above the band.",
        "algorithm": "scikit-learn Pipeline: imputation + scaling + one-hot (21 columns) "
                     "+ logistic regression",
        "data": {"source": "synthetic orders.csv (generate_orders_ml)", "n_total": n,
                 "n_train": int(len(context["Xtr"])), "n_test": int(len(yte)),
                 "test_return_rate": round(float(yte.mean()), 4), "seed": seed,
                 "features": list(context["Xtr"].columns)},
        "metrics": {"test_auc": round(float(auc), 4), "test_business_cost": costs,
                    "cost_fp_eur": 5, "cost_fn_eur": 30},
        "limitations": [
            "Trained on three months of synthetic data: it has not seen Black Friday or the sales.",
            "'days_since_start' extrapolates in production; it is monitored and the model is retrained periodically.",
            "'previous_orders' and 'previous_return_rate' must be computed against the customer's "
            "full history, not just within the batch.",
            "postcode_zone is not used to decide without review: rate parity between zones "
            "is measured every month (02-04).",
        ],
        "environment": {"python": platform.python_version(), "scikit_learn": sklearn.__version__},
    }
    out_dir = Path(out_dir)
    (out_dir / "model_card.json").write_text(json.dumps(card, indent=2, ensure_ascii=False))
    md = [f"# Model card: {card['name']} v{card['version']}", "",
          f"- **Date**: {card['training_date']}  |  **Owner**: {card['owner']}",
          f"- **Intended use**: {card['intended_use']}", f"- **NOT intended for**: {card['out_of_scope_use']}",
          f"- **Algorithm**: {card['algorithm']}",
          f"- **Data**: {card['data']['n_train']} training orders, "
          f"{card['data']['n_test']} test orders (return rate {card['data']['test_return_rate']:.1%})",
          "", "## Metrics", "", "| Metric | Value |", "|---|---|",
          f"| AUC (test) | {card['metrics']['test_auc']} |"]
    for k, v in costs.items():
        md.append(f"| Business cost {k.replace('_', ' ')} | €{v['cost_eur']} ({v['flagged']} flagged) |")
    md += ["", "## Limitations", ""] + [f"- {l}" for l in card["limitations"]]
    (out_dir / "model_card.md").write_text("\n".join(md) + "\n")
    return card

def write_drift_reference(context, out_dir):
    """Saves what the data and the outputs looked like at training time, to compare every week (drift.py)."""
    amount = context["Xtr"]["amount"].dropna()
    edges = np.quantile(amount, np.linspace(0, 1, 11))            # 10 bins with 10 % of the orders each
    ref = {"amount_mean": round(float(amount.mean()), 2),
           "amount_edges": [None] + [round(float(b), 2) for b in edges[1:-1]] + [None],  # None = infinity
           "amount_frequencies": [0.1] * 10,
           "new_customer_rate": round(float(context["Xtr"]["new_customer"].mean()), 4),
           "flag_rate": round(float((context["prob_test"] >= LOW_THRESHOLD).mean()), 4),
           "flag_rate_high": round(float((context["prob_test"] >= HIGH_THRESHOLD).mean()), 4)}
    (Path(out_dir) / "drift_reference.json").write_text(json.dumps(ref, indent=2))
    return ref

if __name__ == "__main__":
    ...                                                             # argparse as in 07-04
    pipe, auc, ctx = train(args.n, args.seed)
    meta = save(pipe, auc, args.output, args.n, args.seed)
    card = write_model_card(auc, ctx, Path(args.output).parent, args.n, args.seed)
    ref = write_drift_reference(ctx, Path(args.output).parent)
    print(f"Test AUC: {auc:.3f}  ->  {args.output}")
    print("Test business cost:", card["metrics"]["test_business_cost"])

Explanation of the new pieces:

  • The card gathers in one place what an auditor, a new colleague or Diego needs: intended and out-of-scope use (the phrase "not for denying purchases" is an ethical decision turned into a document), data, technical and business metrics, honest limitations and version.
  • The drift reference stores what the amount looked like in training (mean and the ten bins that leave 10 % of orders in each), the proportion of new customers and what fraction of orders the model flagged on the test set. It is what monitoring will compare against every week.
  • The outer edges are stored as None (infinity): that way an order more expensive than any in training falls into the last bin instead of getting lost.

Actual output of python -m novamarket.train in the course environment:

Test AUC: 0.844  ->  models/returns_model.joblib
Test business cost: {'threshold_0.5': {'cost_eur': 2485, 'flagged': 60}, 'threshold_0.2': {'cost_eur': 1610, 'flagged': 200}}

And models/model_card.md looks like this (excerpt):

# Model card: NovaMarket returns predictor (use case 3) v1.0.0

- **Date**: 2026-08-18  |  **Owner**: Marta (data); business owner: Diego (operations)
- **Intended use**: Score each day's orders every night and sort them into three queues: approve (<0.2), review by a person (0.2-0.5) and flag (>=0.5).
- **NOT intended for**: Denying purchases, penalising customers or deciding without human review above the band.
- **Data**: 2249 training orders, 750 test orders (return rate 16.4%)

| Metric | Value |
|---|---|
| AUC (test) | 0.8444 |
| Business cost threshold 0.5 | €2485 (60 flagged) |
| Business cost threshold 0.2 | €1610 (200 flagged) |

The figures are exactly those of 04-05 (€2,485 → €1,610, 60 and 200 flagged): the model card invents nothing, it documents what the code computes. models/ now contains returns_model.joblib, returns_model.json, model_card.json, model_card.md and drift_reference.json; the three JSON files and the Markdown do go into Git (07-04).

11.4 Code (b): predict_batch.py, the three queues of every night (phase 6)

The nightly batch receives a CSV with the day's orders already prepared by the same query that feeds training (order_id plus the 15 columns of prepare_orders), scores them with the saved model and produces the queues.

# src/novamarket/predict_batch.py
"""Scores in batch (every night) a CSV of new orders and sorts them into three queues.
Usage:  python -m novamarket.predict_batch --input data/raw/orders_2025-12-01.csv \
          --output data/processed/queues_2025-12-01.csv"""
import argparse, json
from datetime import datetime
from pathlib import Path
import pandas as pd
from novamarket.predict import load

LOW_THRESHOLD, HIGH_THRESHOLD = 0.2, 0.5      # human review band (04-05); same as in train.py

def assign_queue(prob, low=LOW_THRESHOLD, high=HIGH_THRESHOLD):
    """Translates a probability into one of Diego's three operational queues."""
    if prob >= high:
        return "flag"                      # reverse logistics is prepared; nobody reviews it by hand
    if prob >= low:
        return "review"                    # a person decides (human-in-the-loop, 02-04)
    return "approve"                       # follows the normal flow

def score_batch(model, orders):
    """orders: DataFrame with order_id + the 15 columns of prepare_orders. Returns id, prob and queue."""
    columns = [c for c in orders.columns if c != "order_id"]
    prob = model.predict_proba(orders[columns])[:, 1]
    output = pd.DataFrame({"order_id": orders["order_id"].values,
                           "return_prob": prob.round(4)})
    output["queue"] = output["return_prob"].map(assign_queue)
    # From highest to lowest risk: if Diego's team cannot get through the whole "review" queue, it starts with the worst
    return output.sort_values("return_prob", ascending=False).reset_index(drop=True)

def log_decision(summary, log_path="logs/decisions.jsonl"):
    """Decision log: one JSON line per batch (which model, when, how many to each queue)."""
    Path(log_path).parent.mkdir(parents=True, exist_ok=True)
    with open(log_path, "a") as f:
        f.write(json.dumps(summary) + "\n")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Scores a batch of orders and generates the queues")
    parser.add_argument("--input", required=True)
    parser.add_argument("--output", required=True)
    parser.add_argument("--model", default="models/returns_model.joblib")
    args = parser.parse_args()

    model = load(args.model)
    version = json.loads(Path(args.model).with_suffix(".json").read_text()).get("version", "?")
    orders = pd.read_csv(args.input)
    queues = score_batch(model, orders)
    Path(args.output).parent.mkdir(parents=True, exist_ok=True)
    queues.to_csv(args.output, index=False)

    counts = queues["queue"].value_counts().to_dict()
    summary = {"run_at": datetime.now().isoformat(timespec="seconds"), "batch": args.input,
               "model_version": version, "n": int(len(queues)),
               "approve": counts.get("approve", 0), "review": counts.get("review", 0),
               "flag": counts.get("flag", 0), "thresholds": [LOW_THRESHOLD, HIGH_THRESHOLD]}
    log_decision(summary)
    print(f"{len(queues)} orders scored with model v{version} -> {args.output}")
    for queue in ("approve", "review", "flag"):
        print(f"  {queue:8s}: {counts.get(queue, 0):4d}  ({counts.get(queue, 0) / len(queues):.1%})")

Explanation:

  • assign_queue is the human review band of 04-05 and 06-04 turned into code: three zones and two thresholds, defined as named constants so that a change is made in a single place (and stays in Git).
  • score_batch does not touch the model: the saved Pipeline already imputes, scales and encodes (which is why we insisted in 04-03 on putting the preparation inside). It sorts by descending risk: if the team only gets through 300 reviews, it reviews the 300 riskiest.
  • log_decision is the decision log of phase 7: one line per batch with the model version (read from the JSON that save left), the date and the counts. It is the minimum needed to audit and to plot the evolution week by week.
  • The output is a CSV that the warehouse tool already knows how to load: integrate into the existing flow instead of forcing Diego to call an API.

To test it without real data, we simulate the file that the SQL query would produce for 1 December 2025 (3,000 orders, one NovaMarket day), without the returned label, passing the same start_date as in training:

# simulate_day.py (course only; in production this CSV is produced by SQL over the data warehouse)
import numpy as np, pandas as pd
from novamarket.data import generate_orders_ml, dirty_orders, prepare_orders

TRAINING_START_DATE = "2025-09-01"      # the same reference train.py used

def simulate_day(date, n=3000, seed=1, amount_factor=1.0, new_rate=None, output=None):
    orders = generate_orders_ml(n, seed)
    if amount_factor != 1.0:                                    # drift scenario: more expensive orders
        orders["amount"] = (orders["amount"] * amount_factor).round(2)
    if new_rate is not None:                                    # drift scenario: acquisition campaign
        rng = np.random.default_rng(seed)
        orders["new_customer"] = (rng.random(n) < new_rate).astype(int)
    dirty = dirty_orders(orders, seed)
    dirty["order_date"] = pd.Timestamp(date)                    # all orders are from the same day
    X, _ = prepare_orders(dirty, start_date=TRAINING_START_DATE)   # same reference!
    X.insert(0, "order_id", dirty.loc[X.index, "order_id"].values)
    if output:
        X.to_csv(output, index=False)
    return X

simulate_day("2025-12-01", seed=2025, output="data/raw/orders_2025-12-01.csv")   # normal day
simulate_day("2025-12-08", seed=2026, amount_factor=1.4, new_rate=0.45,          # day with drift
             output="data/raw/orders_2025-12-08.csv")

Actual output of the batch for the normal day:

$ python -m novamarket.predict_batch --input data/raw/orders_2025-12-01.csv --output data/processed/queues_2025-12-01.csv
2999 orders scored with model v1.0.0 -> data/processed/queues_2025-12-01.csv
  approve : 2050  (68.4%)
  review  :  641  (21.4%)
  flag    :  308  (10.3%)

And the first rows of queues_2025-12-01.csv (sorted by risk) and the log line:

order_id,return_prob,queue
P101388,0.9978,flag
P102496,0.9818,flag
P102743,0.9817,flag
...
{"run_at": "2026-08-18T01:00:31", "batch": "data/raw/orders_2025-12-01.csv", "model_version": "1.0.0", "n": 2999, "approve": 2050, "review": 641, "flag": 308, "thresholds": [0.2, 0.5]}

Notice the number Diego sees first: 641 orders to review in one day. On the 750-order test set the band gave 140 reviews (19 %); at the scale of 3,000 orders a day that is more than 600, twice what his team can absorb (300, according to the definition document). This does not invalidate the model: it is exactly the kind of finding that shadow mode exists for, and it is resolved in the evaluation with the stakeholders: raise the lower threshold (0.25 or 0.3) at the price of letting more returns slip through, or keep 0.2 and review in order of risk until capacity runs out. You will quantify it in exercise 1.

11.5 Code (c): drift.py, the weekly check (phase 7)

Every Monday the latest batch (or the union of the seven batches of the week) is compared with the reference saved at training time. We measure four things: the PSI of the amount, the mean amount, the new-customer rate and the model's flag rate (proportion of orders that do not go to "approve").

# src/novamarket/drift.py
"""Weekly drift check: compares the recent batch with the reference saved at training time.
Usage:  python -m novamarket.drift --input data/raw/orders_2025-12-01.csv --queues data/processed/queues_2025-12-01.csv"""
import argparse, json
from pathlib import Path
import numpy as np
import pandas as pd

PSI_WARNING, PSI_ALERT = 0.10, 0.25            # usual convention: <0.1 stable, 0.1-0.25 watch, >0.25 act
FLAG_RATE_DIFF = 0.05                          # 5 percentage points of difference in the flag rate

def psi(ref_freq, new_freq, eps=1e-4):
    """Population Stability Index between two binned distributions (lists of frequencies summing to 1)."""
    ref = np.clip(np.asarray(ref_freq, float), eps, None)
    new = np.clip(np.asarray(new_freq, float), eps, None)
    return float(np.sum((new - ref) * np.log(new / ref)))

def bin_frequencies(values, edges):
    """Distributes 'values' into the bins defined by 'edges' (None = infinity) and returns frequencies."""
    b = [-np.inf if x is None else x for x in edges]
    b[-1] = np.inf
    counts, _ = np.histogram(pd.Series(values).dropna(), bins=b)
    return counts / counts.sum()

def check_drift(orders, queues, reference):
    """Returns a list of indicators with their value, the reference and the level: ok / warning / alert."""
    report = []
    freq = bin_frequencies(orders["amount"], reference["amount_edges"])
    v = psi(reference["amount_frequencies"], freq)
    report.append({"indicator": "amount PSI", "value": round(v, 3), "reference": 0.0,
                   "level": "alert" if v > PSI_ALERT else "warning" if v > PSI_WARNING else "ok"})
    mean = float(orders["amount"].mean())
    report.append({"indicator": "mean amount", "value": round(mean, 2),
                   "reference": reference["amount_mean"],
                   "level": "warning" if abs(mean / reference["amount_mean"] - 1) > 0.15 else "ok"})
    new = float(orders["new_customer"].mean())
    report.append({"indicator": "new_customer rate", "value": round(new, 3),
                   "reference": reference["new_customer_rate"],
                   "level": "warning" if abs(new - reference["new_customer_rate"]) > 0.10 else "ok"})
    flagged = float((queues["queue"] != "approve").mean())        # review + flag = prob >= 0.2
    diff = abs(flagged - reference["flag_rate"])
    report.append({"indicator": "flag rate (>=0.2)", "value": round(flagged, 3),
                   "reference": reference["flag_rate"],
                   "level": "alert" if diff > 2 * FLAG_RATE_DIFF else "warning" if diff > FLAG_RATE_DIFF else "ok"})
    return report

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Drift check for the returns predictor")
    parser.add_argument("--input", required=True)
    parser.add_argument("--queues", required=True)
    parser.add_argument("--reference", default="models/drift_reference.json")
    args = parser.parse_args()
    reference = json.loads(Path(args.reference).read_text())
    report = check_drift(pd.read_csv(args.input), pd.read_csv(args.queues), reference)
    print(pd.DataFrame(report).to_string(index=False))
    levels = {row["level"] for row in report}
    if "alert" in levels:
        print("\nALERT: significant drift -> open a retraining task and notify Marta and Diego")
    elif "warning" in levels:
        print("\nWARNING: keep watching next week; if it repeats, retrain")
    else:
        print("\nOK: no appreciable drift")

Explanation:

  • The PSI compares two histograms with the same bins: for each bin, the difference in frequencies multiplied by the logarithm of their ratio, summed. It is 0 if they are identical and grows with the difference; the industry convention (inherited from credit scoring) is that below 0.1 nothing is happening, between 0.1 and 0.25 you should watch, and above 0.25 you should act. The bins are the training deciles (10 % of orders in each), so the reference is simply ten times 0.1.
  • The flag rate is the cheapest and most useful indicator: it needs no labels and reflects any change in the inputs that affects the model. If it suddenly flags 51 % instead of 27 %, the review team is swamped and something has changed.
  • The warning and alert thresholds are decisions, not laws: they are agreed with Diego and written into the document.

Actual output of the two runs:

$ python -m novamarket.drift --input data/raw/orders_2025-12-01.csv --queues data/processed/queues_2025-12-01.csv
        indicator   value reference level
       amount PSI   0.007    0.0000    ok
      mean amount 124.800  126.1500    ok
new_customer rate   0.308    0.3024    ok
flag rate (>=0.2)   0.316    0.2667    ok

OK: no appreciable drift

$ python -m novamarket.drift --input data/raw/orders_2025-12-08.csv --queues data/processed/queues_2025-12-08.csv
        indicator   value reference   level
       amount PSI   0.224    0.0000 warning
      mean amount 175.130  126.1500 warning
new_customer rate   0.453    0.3024 warning
flag rate (>=0.2)   0.510    0.2667   alert

ALERT: significant drift -> open a retraining task and notify Marta and Diego

The first batch looks like the training data and everything stays green. In the second (we simulate a Christmas campaign: orders 40 % more expensive and 45 % new customers), the mean amount has risen to €175, the PSI is in the watch zone and the model flags 51 % of the orders: 1,530 orders in the review and flag queues in a single day. The alert fires before any actual return is known; that is data drift. If a month later, with the labels mature, the AUC on those orders had dropped, it would also be concept drift, and the response would be to retrain with the campaign data included (python -m novamarket.train on the extended history, version 1.1.0, new model card, compare in shadow, replace).

The project tests are extended with two for the batch (tests/test_predict_batch.py: the boundaries of assign_queue with 0.19/0.20/0.49/0.50, and that score_batch returns one row per order with probabilities between 0 and 1) and pytest passes 7 tests.

11.6 Monitoring calendar

Frequency What is checked Who Action threshold
Every night The batch ran; n orders, time, errors; count per queue in logs/decisions.jsonl Systems (automatic alert) Missing batch or n outside ±30 % of the usual → immediate warning
Every week drift.py: PSI and mean of amount, new-customer rate, flag rate Marta Warning → watch; alert → retraining task
Every week "Review" queue: orders reviewed, reviewer's decision, mean time Diego Queue > 300/day for 3 days → revisit the lower threshold
Every month With mature labels (orders ≥ 30 days old): AUC, actual cost per threshold, cost against baseline; rate parity between zones (02-04) Marta + Diego AUC < 0.80 or cost ≥ baseline → retrain or stop; impact ratio < 0.8 → review fairness
Every quarter Review of the model card, the definition document and the impact assessment; scheduled retraining even without an alert Marta, Diego, legal Always (preventive maintenance)

With this the project has closed the loop: definition, data, prototype, evaluation, batch deployment with a review band and rollback, monitoring with alerts, and documentation. In 09-04 you will walk this same path with a case of your own.

Common Mistakes and Tips

  • Starting with the model. Write the definition document (one table) before opening the notebook. If you cannot fill in "decision that changes" and "baseline", the project does not exist yet.
  • Technical metric without translation. An AUC on its own convinces no one; always accompany it with the cost in euros and the number of cases that reach people.
  • Data that will not exist or will change at serving time. Check column by column that it exists at prediction time and is computed the same way (days_since_start was a silent example). Use the same query for training and serving.
  • Random split in temporal problems. Test set later than training; otherwise, optimistic metrics.
  • Deploying all at once. Shadow → pilot in one warehouse → everything, always with a rollback switch.
  • Fully automating decisions about people. Human review band proportional to the impact (02-04) and a log of what the person decides.
  • Not monitoring because "the model already works". Program the flag rate and a PSI from day one; it is fifteen lines.
  • Alert thresholds taken from a manual. 0.1/0.25 for the PSI and ±5 points of flag rate are starting points; adjust them with the real variability of your weeks and write them down.
  • Documenting at the end. The model card generated from the code costs nothing to maintain; the one written by hand at the end never gets written.
  • Tip: save every model version with its JSON and its card, and never overwrite the previous one until the new one has been through shadow.

Exercises

Exercise 1: review capacity

Diego can only review 300 orders a day. With the file queues_2025-12-01.csv (or generating it with simulate_day), compute how many orders would go to "review" if the lower threshold were 0.25 and 0.30 (keeping 0.5 as the upper one). Propose a reasoned decision. Hint: score_batch already returns return_prob; it is enough to count ((p >= u) & (p < 0.5)).sum().

Exercise 2: concept drift without data drift

Describe (without code) a change at NovaMarket that would make drift.py raise no alert and yet make the model genuinely worse. Which indicator of the monitoring calendar would detect it, and with how much delay? What would you add to the weekly check to reduce that delay?

Exercise 3: definition document for the recommender

Fill in the definition table of section 11.1 for NovaMarket's product recommender (use case 1). Pay special attention to "decision that changes", "business metric", "baseline" and "deployment" (batch or real time? shadow mode or A/B?).

Solutions

Solution 1. Run on queues_2025-12-01.csv (2,999 orders), counting ((p >= u) & (p < 0.5)).sum() for each u: with lower threshold 0.2 there are 641 orders in "review"; with 0.25, 484 remain; with 0.3, 355 remain (the "flag" queue does not change: 308). None of the three fits in 300, but with 0.3 the excess is small (55 orders). The price shows on the 04-05 test set (750 orders, review at €3 and a reviewer who gets it right): with the 0.2-0.5 band the cost was €1,555 and 35 returns slipped through; with 0.25-0.5 it rises to €1,795 (47 slip through) and with 0.3-0.5 to €1,966 (56 slip through). That is, raising the lower threshold to 0.3 saves about 290 reviews a day but costs about €400 more per 750 orders: capacity versus cost, the kind of trade-off Diego decides, not the model. A reasonable decision: lower threshold 0.3 in the pilot, queue sorted by risk (the 55 that do not get reviewed are the lowest-probability ones, between 0.30 and 0.32, and are approved), measure the actual cost after a month with mature labels and, if it pays off, request extra review capacity to go back to 0.2. An equally valid alternative: keep 0.2, review the 300 riskiest and approve the rest; since the queue is sorted, the result is practically the same as raising the threshold, but it leaves a record of how many "medium-risk" orders go unreviewed each day. What matters is not the number but the process: the band is an operational decision taken with the people who bear it, not a model parameter.

Solution 2. Example: NovaMarket launches "free, no-questions-asked returns for 60 days". The incoming orders are the same (same amounts, same customers, same model flag rate), but now they get returned more and for different reasons: the relationship between inputs and output has changed (concept drift). drift.py would see nothing. It would be detected by the monthly review with mature labels (AUC and actual cost per threshold), with a delay of 30 to 60 days (the time returns take to be recorded). To shorten it: (a) add to the weekly check the actual return rate of orders from 3-4 weeks ago compared with the training one (16.4 %), even with partial labels; (b) use the review queue as a sensor: the proportion of orders the reviewer confirms as risky changes before the overall figures do; (c) subscribe the project to changes in commercial policy (a row in the decision log: "policy change on day X"), because concept drift almost always has a cause known to the business.

Solution 3. One proposal: Question: which products are most likely to interest this customer now. Decision that changes: which 6 products are shown in the "You may also like" block on the product page and in the weekly email (today: the category's best sellers). Business metric: additional revenue per session and click/purchase rate of the block; technical: precision in the top 6 (proportion of recommendations that end in a click or a purchase) measured on past sessions. Baseline: "category best sellers" (what exists today) and "the same products the customer bought last time". Success criterion: +X % of block revenue in a 4-week A/B without raising the return rate. Constraints: GDPR (profiling: information and the possibility to object), no recommending by postcode, no pushing products with high returns (link with use case 3), minimal risk under the AI Act. Data: orders.csv, customers.csv, products.csv, web browsing; typical leakage: using purchases made after the session. Deployment: the list of recommendations per customer can be computed in a nightly batch (enough for the email and for most product pages) and served from a table; the website reads it through an API; A/B is mandatory because the effect can only be measured causally. Monitoring: coverage (how many customers get a recommendation?), diversity, clicks, and that the same five products do not always appear. Owners: marketing (business), Marta, systems.

Conclusion

This lesson has turned the map of 04-01 into a method. The life cycle of an AI project (definition of the problem and of success, data, exploration, prototype, evaluation, deployment, monitoring and documentation) is the shape taken by CRISP-DM and CRISP-ML(Q), with the arrows back as the norm and quality as the cross-cutting thread. We have seen which questions each phase answers (the decision that changes, the metric in euros versus the technical one, the baseline, the availability of each piece of data at prediction time, the temporal split, the prototype budget, the evaluation with the affected people, batch versus real time, shadow and A/B, rollback, data and concept drift, model card and datasheet), who takes part (business, data, engineering, legal) and which mistakes sink projects (starting with the model, having no baseline, wrong metric, data that will not exist in production, not closing the loop). And we have actually done it with the returns predictor inside novamarket_ai/: a one-page definition document, the fix of a training-serving skew in days_since_start, a model card in JSON and Markdown generated from train.py with the usual figures (AUC 0.844; €2,485 → €1,610), a predict_batch.py that sorts one night's 2,999 orders into 2,050 approved, 641 to review and 308 flagged and leaves a record in a log, a drift.py that gives green on a normal day and an alert on a campaign day (PSI 0.224, flag rate of 51 %), and a monitoring calendar with owners and thresholds.

Marta and Diego now have a method and a project that follows it. But a method is also learned by looking at how others have applied it, well or badly: what the recommendation platforms did, the medical image diagnosis systems, AlphaGo and AlphaFold, the recruitment tools that discriminated, the assistants that made up policies and the celebrated projects that failed. That is the next lesson, 08-02, AI Case Studies, where each case ends with the same question: what does NovaMarket learn from this.

Fundamentals of Artificial Intelligence (AI)

Module 1: Introduction to Artificial Intelligence

Module 2: Basic Principles of AI

Module 3: Algorithms in AI

Module 4: Machine Learning

Module 5: Neural Networks and Deep Learning

Module 6: Logic and Expert Systems

Module 7: Tools and Programming Languages in AI

Module 8: Projects and Case Studies

Module 9: Exercises and Practice

Module 10: Additional Resources

© Copyright 2026. All rights reserved