In 09-01 you went back to the algorithms of module 3 and confirmed that discipline (represent well, define the objective, validate) matters more than the specific algorithm. Now it is time for the same discipline with the machine learning of module 4: five practices with the NovaMarket generators you already know (generate_orders_ml, dirty_orders, prepare_orders, build_preprocessing, generate_weekly_demand, generate_customers_ml), each with a baseline, an honest comparison and a business decision at the end. The reference figures from module 4 (AUC 0.844, cost €2,485 → €1,610 with threshold 0.2, demand MAE 16.8, three customer segments) are your yardstick; in several practices you will discover that "improving" is harder than it looks.

How to work through the lesson: each practice has a statement with specific questions; write your solution before reading ours, run it, and compare figures (they vary by a few hundredths depending on the scikit-learn version). You need the novamarket_ml.py module from 04-03/04-05 (the functions listed above) in the same folder, plus pandas, numpy and scikit-learn. Guideline time: 30-45 minutes per practice.

Contents

  1. Practice 1: exploring and cleaning a dirty batch with a decision report
  2. Practice 2: a new feature in the returns pipeline, compared honestly
  3. Practice 3: demand forecasting with calendar and lags, linear versus gradient boosting
  4. Practice 4: customer segmentation with k-means and one action per segment
  5. Practice 5: cost-based threshold tuning and a fairness check across zones
  6. Common Mistakes and Tips
  7. Conclusion

  1. Practice 1: exploring and cleaning a dirty batch with a decision report

Reminder (04-03, sections 2-3 and 8). dirty_orders produces a "realistic" copy of the orders with nulls, duplicates, an outlier, dates, text and a leak (return_reason, which only exists if the order was returned). Before modelling you have to measure each problem and decide what to do with it, without looking at the label more than strictly necessary.

Statement. Marta receives the September-November orders.csv batch (dirty_orders(generate_orders_ml(3000, 42), 42)). Write a script that produces a quality report with: (a) shape and types; (b) nulls per column, as a count and a percentage, and a check of whether the nulls in delivery_days are spread equally between returned and non-returned orders; (c) duplicated rows (exact and by order_id); (d) amount outliers with the 3 × IQR rule and by eye; (e) category counts and date range; (f) the columns that would be leaks. Then write clean_batch(batch), which returns the clean batch and a dictionary of what it did, and justify each decision in a table.

Hints: isna().sum(), duplicated(keep=False), quantile([0.25, 0.75]), pd.crosstab(batch["return_reason"], batch["returned"]).

Solution

import numpy as np, pandas as pd
from novamarket_ml import generate_orders_ml, dirty_orders, prepare_orders

batch = dirty_orders(generate_orders_ml(3000, 42), 42)
print(batch.shape); print(batch.dtypes.to_dict())
nulls = batch.isna().sum(); nulls = nulls[nulls > 0]
print(pd.DataFrame({"nulls": nulls, "%": (nulls / len(batch) * 100).round(1)}))
print("returned if delivery_days null:", batch.loc[batch["delivery_days"].isna(), "returned"].mean().round(3),
      "| not null:", batch.loc[batch["delivery_days"].notna(), "returned"].mean().round(3))
print("Exact duplicates:", batch.duplicated().sum(), "| repeated ids:", batch["order_id"].duplicated().sum())
print(batch[batch.duplicated(keep=False)].sort_values("order_id")[["order_id", "customer_id", "order_date", "amount", "returned"]])
q1, q3 = batch["amount"].quantile([0.25, 0.75]); ceiling = q3 + 3 * (q3 - q1)
print(f"IQR: q1={q1:.1f} q3={q3:.1f} 3xIQR ceiling={ceiling:.1f} | above: {(batch['amount'] > ceiling).sum()}")
print(batch.loc[batch["amount"] > 1000, ["order_id", "amount", "num_items", "category", "returned"]])
print("Maximum without the outlier:", batch.loc[batch["amount"] < 5000, "amount"].max())
for c in ["category", "postcode_zone", "payment_method", "shipping_type"]:
    print(c, batch[c].value_counts(dropna=False).to_dict())
print("Dates:", batch["order_date"].min().date(), "->", batch["order_date"].max().date())
print(pd.crosstab(batch["return_reason"], batch["returned"]))
print("Return rate:", batch["returned"].mean().round(3), "| by zone:", batch.groupby("postcode_zone")["returned"].mean().round(3).to_dict())

def clean_batch(batch, amount_ceiling=5000):
    report = {}
    clean = batch.drop_duplicates(); report["duplicates_removed"] = len(batch) - len(clean)
    outlier = clean["amount"] > amount_ceiling
    report["amount_outliers_removed"] = int(outlier.sum()); clean = clean[~outlier].copy()
    report["nulls_pipeline_will_impute"] = clean.isna().sum()[lambda s: s > 0].to_dict()
    clean = clean.drop(columns=["return_reason"]); report["leaks_removed"] = ["return_reason"]
    return clean, report

clean, report = clean_batch(batch); print(clean.shape, report)
X, y = prepare_orders(batch); print("prepare_orders ->", X.shape, "rate", y.mean().round(4))
(3003, 13)
                nulls    %
amount             56  1.9
delivery_days     153  5.1
payment_method    100  3.3
returned if delivery_days null: 0.209 | not null: 0.162
Exact duplicates: 3 | repeated ids: 3
IQR: q1=63.8 q3=163.6 3xIQR ceiling=463.1 | above: 13
    order_id   amount  num_items     category  returned
659  P100017  99999.0          2  electronics         0
Maximum without the outlier: 632.61
payment_method {'card': 1624, 'paypal': 730, 'bizum': 310, 'cash_on_delivery': 239, nan: 100}
Dates: 2025-09-01 -> 2025-11-29
returned           0    1
return_reason
                2509    0
arrived late       0  124
defective          0  126
didn't like it     0  131
size/model         0  113
Return rate: 0.165 | by zone: {'A': 0.162, 'B': 0.168, 'C': 0.164}
(2999, 12) {'duplicates_removed': 3, 'amount_outliers_removed': 1, 'nulls_pipeline_will_impute': {'amount': 56, 'delivery_days': 153, 'payment_method': 100}, 'leaks_removed': ['return_reason']}
prepare_orders -> (2999, 15) rate 0.1644
Finding Decision Justification
3 exact duplicated rows (same order_id, customer, date, amount) Remove An order cannot appear twice; counting it double biases training and evaluation
amount = €99,999 in P100017 (2 electronics items) Remove the row It is a data-entry error, not an order: the next maximum is €632. The remaining 12 above 3 × IQR (up to €632) are legitimate expensive orders and are kept
Nulls: delivery_days 5.1 %, payment_method 3.3 %, amount 1.9 % Impute inside the Pipeline (median / most frequent) They are few and you cannot afford to lose 10 % of the rows; imputing in the pipeline avoids leaks. The return rate when delivery_days is null (0.209 versus 0.162, over 153 rows) is within the margin of chance, but it is the check you have to make: if it were large and persistent, the null would be informative and a delivery_days_missing indicator would be worthwhile (variant)
return_reason only filled in if returned = 1 Drop the column Data leakage: it is known after the return
order_id, customer_id, order_date Not direct features They are used to derive (weekday, per-customer history) and then discarded
Categories with no typos; rate per zone ≈ 16 % in A, B and C Nothing Consistent with how the data is built (the zone has no influence)

Feedback

  • Typical mistake: removing the 13 "outliers" from the IQR rule. Statistical rules point at candidates; the decision comes from context: a €600 order is normal in electronics. Just as bad is imputing the 99,999 with the median instead of removing it (the data-entry error would still be in the statistics of that row's other columns).
  • Another one: imputing with fillna before splitting; doing it in the Pipeline is what protects you from leaks (04-03).
  • Variants: add delivery_days_missing as a binary feature and measure in 09-02.2 whether it contributes; simulate a batch with 30 % of amount null and decide again (with that many nulls, median imputation impoverishes the variable); write the report as a reusable quality_report(df) function in novamarket_ai/.

  1. Practice 2: a new feature in the returns pipeline, compared honestly

Reminder (04-03 section 9, 04-05 sections 5 and 8). The reference pipeline (build_preprocessing() + logistic regression, 21 columns) obtains AUC 0.844 on the 750-order test set and 0.836 ± 0.016 in 5-fold stratified cross-validation; with threshold 0.2 the cost is €1,610 on the test set. An honest comparison uses the same split and, better, cross-validation; and it decides with the business metric, not only with the AUC.

Statement. Logistic regression cannot on its own model the interaction "new customer and expensive order" that the hidden truth of the data contains (remember 04-01). Add three candidates: new_x_amount (= new_customer × amount), risk_category (1 if electronics or computing) and days_x_new (= new_customer × delivery_days). For each one: (a) extend the preprocessing (ColumnTransformer) without touching the rest; (b) measure AUC with StratifiedKFold(5, shuffle=True, random_state=42) and the business cost with cross_val_predict at thresholds 0.2, 0.3 and 0.5; (c) compare with the reference in the same validation and also on the split from 04-05 (train_test_split(..., test_size=0.25, random_state=42, stratify=y)); (d) decide whether Marta should change the production model.

Solution

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder, StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, RepeatedStratifiedKFold, cross_val_score, cross_val_predict, train_test_split
from sklearn.metrics import roc_auc_score, confusion_matrix
from novamarket_ml import build_preprocessing, NUMERIC, BINARY, NOMINAL, ORDINAL

def business_cost(m, cost_fp=5, cost_fn=30):
    tn, fp, fn, tp = m.ravel(); return fp * cost_fp + fn * cost_fn

def add_features(X):
    X = X.copy()
    X["new_x_amount"] = X["new_customer"] * X["amount"].fillna(X["amount"].median())
    X["risk_category"] = X["category"].isin(["electronics", "computing"]).astype(int)
    X["days_x_new"] = X["new_customer"] * X["delivery_days"].fillna(X["delivery_days"].median())
    return X

def build_preprocessing_v2(extra_numeric=(), extra_binary=()):
    return ColumnTransformer([
        ("num", Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())]), NUMERIC + list(extra_numeric)),
        ("bin", "passthrough", BINARY + list(extra_binary)),
        ("nom", Pipeline([("impute", SimpleImputer(strategy="most_frequent")), ("onehot", OneHotEncoder(handle_unknown="ignore"))]), NOMINAL),
        ("ord", OrdinalEncoder(categories=[["standard", "fast", "urgent"]]), ORDINAL)])

X2 = add_features(X)
cv = StratifiedKFold(5, shuffle=True, random_state=42)
def evaluate(name, prep, Xd):
    pipe = Pipeline([("prep", prep), ("model", LogisticRegression(max_iter=2000))])
    aucs = cross_val_score(pipe, Xd, y, cv=cv, scoring="roc_auc")
    prob = cross_val_predict(pipe, Xd, y, cv=cv, method="predict_proba")[:, 1]
    costs = {t: int(business_cost(confusion_matrix(y, (prob >= t).astype(int)))) for t in (0.2, 0.3, 0.5)}
    print(f"{name:22s} AUC CV {aucs.mean():.4f} ± {aucs.std():.3f}  cost CV {costs}")

evaluate("reference", build_preprocessing(), X)
evaluate("+ new_x_amount", build_preprocessing_v2(["new_x_amount"]), X2)
evaluate("+ risk_category", build_preprocessing_v2([], ["risk_category"]), X2)
evaluate("+ days_x_new", build_preprocessing_v2(["days_x_new"]), X2)
evaluate("+ all three", build_preprocessing_v2(["new_x_amount", "days_x_new"], ["risk_category"]), X2)

# repeated cross-validation (5 x 4) with paired differences per fold
rcv = RepeatedStratifiedKFold(n_splits=5, n_repeats=4, random_state=1)
ref = Pipeline([("prep", build_preprocessing()), ("model", LogisticRegression(max_iter=2000))])
new = Pipeline([("prep", build_preprocessing_v2(["new_x_amount"])), ("model", LogisticRegression(max_iter=2000))])
a, b = cross_val_score(ref, X, y, cv=rcv, scoring="roc_auc"), cross_val_score(new, X2, y, cv=rcv, scoring="roc_auc")
print(f"20 folds: ref {a.mean():.4f}  new {b.mean():.4f}  difference {np.mean(b - a):+.4f} ± {np.std(b - a):.4f}  in favour in {(b > a).sum()}/20")

# the split from 04-05
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
for name, prep, tr, te in [("reference", build_preprocessing(), Xtr, Xte),
                           ("+ new_x_amount", build_preprocessing_v2(["new_x_amount"]), add_features(Xtr), add_features(Xte))]:
    p = Pipeline([("prep", prep), ("model", LogisticRegression(max_iter=2000))]).fit(tr, ytr); prob = p.predict_proba(te)[:, 1]
    print(f"{name:22s} AUC test {roc_auc_score(yte, prob):.4f}  cost test", {t: int(business_cost(confusion_matrix(yte, (prob >= t).astype(int)))) for t in (0.2, 0.5)})
p = new.fit(X2, y); names, coef = p["prep"].get_feature_names_out(), p["model"].coef_[0]
print("Largest coefficients:", [(n, round(float(c), 2)) for n, c in sorted(zip(names, coef), key=lambda t: -abs(t[1]))[:5]])
reference              AUC CV 0.8365 ± 0.016  cost CV {0.2: 6635, 0.3: 7220, 0.5: 9455}
+ new_x_amount         AUC CV 0.8409 ± 0.014  cost CV {0.2: 6725, 0.3: 7410, 0.5: 9325}
+ risk_category        AUC CV 0.8365 ± 0.016  cost CV {0.2: 6635, 0.3: 7220, 0.5: 9450}
+ days_x_new           AUC CV 0.8366 ± 0.016  cost CV {0.2: 6630, 0.3: 7200, 0.5: 9490}
+ all three            AUC CV 0.8406 ± 0.014  cost CV {0.2: 6680, 0.3: 7420, 0.5: 9305}
20 folds: ref 0.8333  new 0.8379  difference +0.0046 ± 0.0060  in favour in 16/20
reference              AUC test 0.8444  cost test {0.2: 1610, 0.5: 2485}
+ new_x_amount         AUC test 0.8334  cost test {0.2: 1710, 0.5: 2360}
Largest coefficients: [('num__new_x_amount', 0.83), ('num__amount', 0.76), ('num__delivery_days', 0.66), ('nom__category_electronics', 0.54), ('bin__new_customer', 0.45)]

Reading, in the order in which it pays to reason:

  • risk_category changes nothing (0.8365 and the same costs): it is a linear combination of the one-hot columns of category that already exist; logistic regression gains no information from a redundant column. days_x_new does not contribute either (the hidden truth does not contain that interaction).
  • new_x_amount does improve the AUC in a small but consistent way: +0.0046 on average, in favour in 16 of 20 folds, and it becomes the largest coefficient in the model (0.83), as you would expect from the interaction the hidden truth contains. But the improvement does not translate into euros at the working threshold: at 0.2 the cross-validated cost is slightly worse (€6,725 versus €6,635), and it only improves at 0.5 (where nobody operates).
  • The single split from 04-05 says the opposite of the cross-validation (0.833 versus 0.844): with 750 orders and 123 returns, ±0.01 of AUC is sampling noise. That is why in 04-05 we insisted on cross-validation.
  • Decision: do not change the production model. The gain is marginal in the technical metric and nil or negative in the business one; all else being equal, the simpler one wins (04-06). Marta records the feature as a candidate for the next retraining, when there is more data and it can be evaluated with the real cost.

Feedback

  • Typical mistake: comparing the new pipeline with the AUC "from memory" (0.844) obtained on a different split; the comparison is only valid in the same validation. Another one: creating the interaction with amount without imputing and letting the numeric branch's SimpleImputer impose the median of a product (here we make it explicit with fillna in the function).
  • The AUC going up while the cost does not go down is not a contradiction: the AUC measures the global ordering of all pairs; the cost at 0.2 depends on how a few orders get reordered around that threshold.
  • Variants: try HistGradientBoostingClassifier, which captures interactions without being given them; add delivery_days_missing from practice 1; repeat with n_repeats=10 and watch the deviation of the difference narrow.

  1. Practice 3: demand forecasting with calendar and lags, linear versus gradient boosting

Reminder (04-04 section 2, 04-05 sections 6 and 8). The weekly demand for the NovaClean robot (104 weeks) has a trend (2.5/week), yearly seasonality and a Black Friday spike. Linear regression with week, sin, cos and black_friday gives MAE 16.8 on weeks 79-104 and 18.6 on average with TimeSeriesSplit(4, test_size=13); the "last week" baseline gives 39. Temporal folds train on the past and evaluate the following quarter.

Statement. (a) Write build_features(d, lags=(1, 2, 4), with_black_friday=True), which adds calendar (sin, cos, black_friday), the lags lag_k (units k weeks ago) and mean_4 (mean of the previous 4 weeks), and drops the initial rows without lags. (b) With TimeSeriesSplit(n_splits=4, test_size=13), compute the mean MAE of: the naive baseline (lag_1), the mean_4 baseline, the linear model with calendar only, the linear model with calendar + lags, and HistGradientBoostingRegressor (max_iter=200, learning_rate=0.05, max_depth=3, min_samples_leaf=5) with calendar + lags. (c) Repeat linear and HGB without black_friday and look at the specific error in week 100 (the second Black Friday). (d) Explain the results and choose a model for Marta.

Solution

from novamarket_ml import generate_weekly_demand
from sklearn.linear_model import LinearRegression
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import mean_absolute_error

d = generate_weekly_demand(104, 42)
def build_features(d, lags=(1, 2, 4), with_black_friday=True):
    f = d.copy()
    f["sin"], f["cos"] = np.sin(2 * np.pi * f["week"] / 52), np.cos(2 * np.pi * f["week"] / 52)
    f["black_friday"] = ((f["week"] - 1) % 52 == 47).astype(int)
    for k in lags: f[f"lag_{k}"] = f["units"].shift(k)                    # units k weeks ago
    f["mean_4"] = f["units"].shift(1).rolling(4).mean()                   # mean of the previous 4
    calendar = ["week", "sin", "cos"] + (["black_friday"] if with_black_friday else [])
    lag_cols = [f"lag_{k}" for k in lags] + ["mean_4"]
    return f.dropna().reset_index(drop=True), calendar, lag_cols

tscv = TimeSeriesSplit(n_splits=4, test_size=13)
def mae_cv(f, cols, make_model=None, name=""):
    maes = []
    for tr, te in tscv.split(f):
        pred = f.loc[te, cols[0]] if make_model is None else make_model().fit(f.loc[tr, cols], f.loc[tr, "units"]).predict(f.loc[te, cols])
        maes.append(mean_absolute_error(f.loc[te, "units"], pred))
    print(f"{name:42s} MAE per block {np.round(maes, 1)}  mean {np.mean(maes):.1f}")

lin = LinearRegression
hgb = lambda: HistGradientBoostingRegressor(max_iter=200, learning_rate=0.05, max_depth=3, min_samples_leaf=5, random_state=0)
f, cal, lg = build_features(d)
print("Test blocks:", [(int(f.loc[te, 'week'].min()), int(f.loc[te, 'week'].max())) for _, te in tscv.split(f)])
mae_cv(f, ["lag_1"], None, "Naive baseline (last week)")
mae_cv(f, ["mean_4"], None, "4-week mean baseline")
mae_cv(f, cal, lin, "Linear calendar only (04-05)")
mae_cv(f, cal + lg, lin, "Linear calendar + lags")
mae_cv(f, cal + lg, hgb, "HGB calendar + lags")
f0, cal0, lg0 = build_features(d, with_black_friday=False)
mae_cv(f0, cal0 + lg0, lin, "Linear WITHOUT black_friday")
mae_cv(f0, cal0 + lg0, hgb, "HGB WITHOUT black_friday")
for name, (ff, cols) in {"linear with BF": (f, cal + lg), "linear without BF": (f0, cal0 + lg0), "HGB with BF": (f, cal + lg)}.items():
    tr, te = ff["week"] <= 91, ff["week"] > 91
    m = (lin if "linear" in name else hgb)().fit(ff.loc[tr, cols], ff.loc[tr, "units"])
    err = pd.Series(np.abs(ff.loc[te, "units"].values - m.predict(ff.loc[te, cols])), index=ff.loc[te, "week"])
    print(f"{name:17s} weeks 92-104: MAE {err.mean():.1f} | error week 100 (Black Friday): {err[100]:.1f} | without 100: {err.drop(100).mean():.1f}")
Test blocks: [(53, 65), (66, 78), (79, 91), (92, 104)]
Naive baseline (last week)                 MAE per block [31.3 18.7 15.8 52.8]  mean 29.7
4-week mean baseline                       MAE per block [27.4 19.7 12.1 50.8]  mean 27.5
Linear calendar only (04-05)               MAE per block [38.1 19.  13.2 20.1]  mean 22.6
Linear calendar + lags                     MAE per block [66.4 19.  13.7 19.8]  mean 29.7
HGB calendar + lags                        MAE per block [49.8 50.5 12.4 48.9]  mean 40.4
Linear WITHOUT black_friday                MAE per block [168.4  16.5  14.5  40. ]  mean 59.9
HGB WITHOUT black_friday                   MAE per block [49.8 50.5 12.4 48.9]  mean 40.4
linear with BF    weeks 92-104: MAE 19.8 | error week 100 (Black Friday): 26.4 | without 100: 19.2
linear without BF weeks 92-104: MAE 40.0 | error week 100 (Black Friday): 214.5 | without 100: 25.5
HGB with BF       weeks 92-104: MAE 48.9 | error week 100 (Black Friday): 129.9 | without 100: 42.2

Four conclusions Marta must be able to explain:

  • The linear model with calendar still wins (22.6). The lags do not improve it (blocks 2-4 unchanged) and they make it worse in the first block (66.4): with only 48 weeks of training and the Black Friday spike sitting inside lag_1, lag_4 and mean_4 of weeks 49-52, the lag coefficients are poorly estimated. And they cannot help much: the noise in this series is independent from one week to the next (the autocorrelation of the calendar model's residuals is 0.09), so the lags contain no information the calendar does not already have. In real series with runs (promotions that drag on, stock-outs) they would.
  • Gradient boosting loses clearly (40.4): a tree model does not extrapolate the trend (it predicts at most the maximum seen in training, and the series grows by 2.5 units/week) and with 48-87 rows it is starved of data. With min_samples_leaf=1 it drops to 34.8; with the classic fix (linear for trend and seasonality, HGB only for the residuals) to 24.7: it still does not beat the linear model on its own.
  • Black Friday: removing the variable sends the linear model's error in week 100 through the roof (214 units: it predicts ~600 and 825 are sold) and contaminates the fit of the first block (168), because the week-48 spike is fitted as if it were seasonality. For HGB nothing changes: with min_samples_leaf=5, a tree cannot isolate a leaf with a single Black Friday week, so it ignores the variable even when given it. It is an example of why business rules ("week 47 sells ×1.4") are sometimes worth more than the model.
  • Choice: the linear model with calendar, MAE ≈ 20 units on sales of 550-800, versus 27-30 for the baselines. And a maintenance rule: retrain every quarter, because the most recent 13-week block is always outside the training set.

Feedback

  • Typical mistake: building the lags and not dropping the first rows (they are left as NaN), or computing them with shift(-1) (using the future: leakage). Another one: evaluating with a shuffled KFold: the MAE would drop to ~17 and would not hold in production (04-05).
  • Notice that the naive baseline is worse in blocks 1 and 4 (31 and 53): those are the ones containing Black Friday, and "repeat last week" fails twice (it does not anticipate it and then prolongs it).
  • Variants: add lag_52 (same week of the previous year) plus the trend (exercise 3 of 04-05); use generate_weekly_demand(208) (4 years) and check whether HGB gets closer with more data; try Ridge with the lags to stabilise the first block.

  1. Practice 4: customer segmentation with k-means and one action per segment

Reminder (04-02 section 5, 04-04 section 8). k-means groups by distance to k centroids; it requires scaling the variables; inertia always drops as k rises, so you look for the "elbow", and you complement it with the silhouette coefficient (higher, better separation) and with business interpretability. generate_customers_ml(600, 42) has three hidden types (occasional, regular, big spenders).

Statement. (a) Scale annual_spend, num_orders and tenure_months and compute inertia and silhouette for k from 2 to 8. (b) Choose k and justify it. (c) Describe each segment (size, means, mean ticket = spend/orders, percentage of total spend) and propose a business action per segment. (d) Check what happens with k = 4 (which segment splits and why?) and what happens if you do not scale.

Solution

from novamarket_ml import generate_customers_ml
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score

cl = generate_customers_ml(600, 42)
scaler = StandardScaler(); Xc = scaler.fit_transform(cl)
print("| k | inertia | silhouette |")
for k in range(2, 9):
    km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(Xc)
    print(f"| {k} | {km.inertia_:.0f} | {silhouette_score(Xc, km.labels_):.3f} |")
km = KMeans(n_clusters=3, n_init=10, random_state=42).fit(Xc)
cl["segment"] = km.labels_; cl["mean_ticket"] = cl["annual_spend"] / cl["num_orders"]
profile = cl.groupby("segment").agg(n=("annual_spend", "size"), spend=("annual_spend", "mean"), orders=("num_orders", "mean"),
                                    ticket=("mean_ticket", "mean"), tenure=("tenure_months", "mean")).round(1)
profile["% total spend"] = (cl.groupby("segment")["annual_spend"].sum() / cl["annual_spend"].sum() * 100).round(1)
print(profile)
km4 = KMeans(n_clusters=4, n_init=10, random_state=42).fit(Xc)
print(pd.crosstab(cl["segment"], km4.labels_, rownames=["k=3"], colnames=["k=4"]))
print(cl.groupby(km4.labels_)[["annual_spend", "num_orders", "tenure_months"]].mean().round(1))
km_unscaled = KMeans(n_clusters=3, n_init=10, random_state=42).fit(cl[["annual_spend", "num_orders", "tenure_months"]])
print(pd.crosstab(cl["segment"], km_unscaled.labels_, rownames=["scaled"], colnames=["unscaled"]))
k inertia silhouette
2 796 0.554
3 400 0.572
4 299 0.534
5 254 0.524
6 219 0.416
8 174 0.398
           n   spend  orders  ticket  tenure  % total spend
segment
0        351   185.8     2.5    95.0     9.5           20.5
1         52  2206.7    20.0   117.4    38.9           36.1
2        197   699.9     9.2    88.0    28.5           43.4
k=4   0    1   2    3
k=3
0    23  328   0    0
1     0    0  51    1
2    85    0   0  112
   annual_spend  num_orders  tenure_months
0         694.2         9.1           16.7
1         152.9         2.1            9.5
2        2229.8        20.1           38.6
3         699.1         9.0           36.2
unscaled    0   1    2
scaled
0          27   0  324
1           3  49    0
2         187   1    9

Inertia (1,800 with k = 1) falls to 796 with 2 groups and to 400 with 3, and then drops slowly (299, 254, 219): the elbow is at 3, and the silhouette also peaks at 3 (0.572). With another generator seed (generate_customers_ml(600, 7)) the silhouette peaks at 3 again (0.583): the conclusion is stable. The segments and their actions:

Segment Profile Proposed action
0 · Occasional (351 customers, 58 %) 2-3 orders/year, €186 of spend, 9 months of tenure; 20 % of spend Activation: second-purchase coupon and recommendations (case 1); measure conversion to "regular" at 6 months
2 · Regular (197, 33 %) 9 orders/year, €700, ticket €88, 28 months; 43 % of spend Loyalty: free shipping via subscription, replenishment reminders (NovaBrew capsules, NovaClean filters); it is the segment where preventing churn is worth most
1 · Big spenders (52, 9 %) 20 orders/year, €2,200, ticket €117, 39 months; 36 % of spend Personal management: early access to launches (NovaBook, NovaView), priority handling of incidents; watch for churn signals one by one

With k = 4 no new type appears: the regular segment is split by tenure (17 versus 36 months), a division that may be useful for marketing but that the silhouette does not support. Without scaling, the variable with the largest variance (annual_spend, deviation 599 versus 6 and 13) dominates the distance and 36 customers change group: the groups become "spend brackets" and num_orders and tenure barely count.

Feedback

  • Typical mistake: choosing k solely by minimum inertia (it would always be the maximum k) or presenting the centroids on the standardised scale (scaler.inverse_transform(km.cluster_centers_) returns them to euros and orders).
  • Another one: interpreting the segment numbers (0, 1, 2) as an order; they are arbitrary labels that change with the seed. Name them by their profile.
  • Variants: add mean_ticket as a fourth variable and see whether the partition changes; try AgglomerativeClustering and compare; compute the annual value that would be lost if 10 % of the regulars became occasional (that is the argument for Diego).

  1. Practice 5: cost-based threshold tuning and a fairness check across zones

Reminder (04-05 section 5, 02-04 sections 9-10). The threshold is not 0.5 by decree: it is chosen by sweeping thresholds with business_cost (€5 per false alarm, €30 per unanticipated return) and adding a human review band. And every decision about people is checked by group: flag-rate parity with the four-fifths rule (ratio of the least-flagged group to the most-flagged ≥ 0.8). postcode_zone has no influence on the label by construction, so a well-built model should come out at parity.

Statement. With the reference pipeline trained on the split from 04-05: (a) compute the table of FP, FN, flagged and cost for thresholds from 0.05 to 0.70 with three cost structures: (€5, €30), (€8, €20) and (€5, €50); at which threshold is the minimum of each? (b) With threshold 0.2, compute the flag rate per zone on the test set, the impact ratio and a bootstrap interval for the ratio; repeat with the cross-validated predictions over the 2,999 orders. (c) Look at the zone coefficients in the model, and at the false negative and false positive rates per zone. (d) Simulate a scenario in which zone C had many more new customers and see what happens to the ratio: how would you detect it and what would you do if the result were not at parity?

Solution

X, y = prepare_orders(dirty_orders(generate_orders_ml(3000, 42), 42))
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=42, stratify=y)
pipe = Pipeline([("prep", build_preprocessing()), ("model", LogisticRegression(max_iter=1000))]).fit(Xtr, ytr)
prob = pipe.predict_proba(Xte)[:, 1]
rows = []
for t in np.round(np.arange(0.05, 0.71, 0.05), 2):
    m = confusion_matrix(yte, (prob >= t).astype(int))
    rows.append({"threshold": t, "FP": m[0, 1], "FN": m[1, 0], "flagged": int((prob >= t).sum()),
                 "cost 5/30": business_cost(m), "cost 8/20": business_cost(m, 8, 20), "cost 5/50": business_cost(m, 5, 50)})
table = pd.DataFrame(rows); print(table.to_string(index=False))
for c in ["cost 5/30", "cost 8/20", "cost 5/50"]:
    print(c, "-> minimum", table[c].min(), "€ at threshold", table.loc[table[c].idxmin(), "threshold"])

def flag_rates(zones, flagged):
    t = pd.Series(flagged).groupby(np.asarray(zones)).mean()
    return t.round(3).to_dict(), round(float(t.min() / t.max()), 3)
te = Xte.assign(prob=prob, y=yte.values, flagged=(prob >= 0.2).astype(int))
print("Test 750, threshold 0.2:", flag_rates(te["postcode_zone"], te["flagged"]), "| n per zone:", te.groupby("postcode_zone").size().to_dict())
print("Actual return rate per zone (test):", te.groupby("postcode_zone")["y"].mean().round(3).to_dict())
rng = np.random.default_rng(0)
ratios = [flag_rates(*te.iloc[rng.integers(0, len(te), len(te))][["postcode_zone", "flagged"]].T.values)[1] for _ in range(300)]
print("Bootstrap ratio 2.5-97.5 %:", np.percentile(ratios, [2.5, 97.5]).round(3))
prob_cv = cross_val_predict(Pipeline([("prep", build_preprocessing()), ("model", LogisticRegression(max_iter=1000))]), X, y, cv=cv, method="predict_proba")[:, 1]
print("CV 2999, threshold 0.2:", flag_rates(X["postcode_zone"], (prob_cv >= 0.2).astype(int)))
print("FNR per zone:", te[te.y == 1].groupby("postcode_zone")["flagged"].apply(lambda s: round(1 - s.mean(), 3)).to_dict(),
      "| FPR per zone:", te[te.y == 0].groupby("postcode_zone")["flagged"].mean().round(3).to_dict())
names, coef = pipe["prep"].get_feature_names_out(), pipe["model"].coef_[0]
print("Zone coefficients:", {n.split("_")[-1]: round(float(c), 3) for n, c in zip(names, coef) if "zone" in n})
X_sim = Xte.copy(); in_c = X_sim["postcode_zone"] == "C"              # scenario: 60 % of C becomes new customers
X_sim.loc[in_c, "new_customer"] = np.where(rng.random(in_c.sum()) < 0.6, 1, X_sim.loc[in_c, "new_customer"])
print("Proxy scenario (more new customers in C):", flag_rates(X_sim["postcode_zone"], (pipe.predict_proba(X_sim)[:, 1] >= 0.2).astype(int)))
 threshold   FP   FN  flagged  cost 5/30  cost 8/20  cost 5/50
      0.05  347    8      462       1975       2936       2135
      0.10  218   18      323       1630       2104       1990
      0.15  149   25      247       1495       1692       1995
      0.20  112   35      200       1610       1596       2310
      0.30   60   56      127       1980       1600       3100
      0.50   17   80       60       2485       1736       4085
      0.70    3  107       19       3225       2164       5365
cost 5/30 -> minimum 1495 € at threshold 0.15
cost 8/20 -> minimum 1596 € at threshold 0.2
cost 5/50 -> minimum 1990 € at threshold 0.1
Test 750, threshold 0.2: ({'A': 0.235, 'B': 0.288, 'C': 0.29}, 0.813) | n per zone: {'A': 310, 'B': 257, 'C': 183}
Actual return rate per zone (test): {'A': 0.129, 'B': 0.175, 'C': 0.208}
Bootstrap ratio 2.5-97.5 %: [0.579 0.93 ]
CV 2999, threshold 0.2: ({'A': 0.269, 'B': 0.268, 'C': 0.269}, 0.994)
FNR per zone: {'A': 0.225, 'B': 0.311, 'C': 0.316} | FPR per zone: {'A': 0.156, 'B': 0.203, 'C': 0.186}
Zone coefficients: {'A': 0.017, 'B': 0.073, 'C': -0.094}
Proxy scenario (more new customers in C): ({'A': 0.235, 'B': 0.288, 'C': 0.475}, 0.495)

Reading:

  • Threshold. With a step of 0.05 the minimum for (5, 30) is at 0.15 (€1,495), not at 0.2 (the table in 04-05 only looked at tenths): 247 flagged out of 750, a third. With (8, 20) the minimum moves to 0.2 (false alarms cost more and returns less, so fewer are flagged), and with (5, 50) it drops to 0.1 (flagging is cheap and missing is very expensive). The threshold is a business decision parameterised by the costs; the model does not change. Given Diego's review capacity (≤ 300/day out of 3,000 orders, 10 %), 0.15 would flag too many and the 0.2-0.5 band from 08-01 remains the operational one.
  • Parity on the test set: 0.235 / 0.288 / 0.290 → ratio 0.813, just above 0.8, and with a bootstrap interval from 0.58 to 0.93: 750 orders are not enough to claim either parity or disparity. With the 2,999 cross-validated orders the rates are 0.269 / 0.268 / 0.269 (ratio 0.994): the model is at parity, as it had to be by construction, and the deviation on the test set is sampling noise (in that test set, zone C happened by chance to have more actual returns, 0.208 versus 0.129 in A). The zone coefficients are ≈ 0 (0.017 / 0.073 / −0.094): the model does not use the zone.
  • How you would detect it if it were not, in order of urgency: (1) a flag-rate ratio < 0.8 persistently in large samples (which is why 08-01 measures it every month on accumulated data, not on each batch); (2) FNR/FPR differences between zones (a zone where more returns slip through or that is bothered more), even if the flag rates match; (3) non-zero coefficients or importances for the zone, or for its proxies. The simulated scenario shows that last point: if zone C had many more new customers, the flag rate would rise to 0.475 and the ratio would fall to 0.50 even though the model still does not look at the zone, because new_customer acts as a proxy. Then the question is no longer technical but the one from 02-04: is it legitimate for new customers to be reviewed more (yes: they really do return more, 33 % versus 9 %) and are we sure the concentration in C is not down to something else (income, origin)? The possible answers: a wider human review band for the affected group, per-group thresholds (with legal advice), removing the proxy if its effect is not justified, and always documenting it in the model card.

Feedback

  • Typical mistake: concluding "there is bias" (or "there is none") from a single small sample; the impact ratio has variance and needs an interval and a sample size. Another one: comparing flag rates without looking at the actual return rates per group (flag parity and error parity are not the same thing; which one is required depends on the case).
  • The zone having a coefficient ≈ 0 does not guarantee parity if there are proxies; parity holding does not guarantee that decisions are fair one by one. Group metrics are necessary, not sufficient.
  • Variants: compute the cost with a review band (€3 per review and 100 % reviewer accuracy) for bands 0.15-0.5 and 0.2-0.5; repeat the analysis by category and by payment_method (cash on delivery is a candidate socio-economic proxy); write parity_ok(rates, minimum=0.8) from 02-04 and add it as a test in novamarket_ai/tests/.

Common Mistakes and Tips

  • Comparing figures from different validations. Everything you compare must be measured on the same partition or the same cross-validation; and small differences are compared fold by fold (paired), not by loose means.
  • Confusing technical and business metrics. The AUC can go up without the cost going down (practice 2) and the optimal threshold changes with the costs (practice 5). Always report both.
  • Shuffled time series and lags from the future. TimeSeriesSplit and shift(k) with k > 0; check by hand that the row for week t contains only data from weeks < t.
  • Scaling (or not) without thinking. k-means and logistic regression need scaling; trees do not. And the scaler is fitted on training data only (inside the Pipeline).
  • Small samples and big conclusions. 750 orders are too few to measure parity; 48 weeks are too few to fit lags; one split is too little to choose features. When the conclusion matters, ask for more data or more repetitions.
  • Tip: every practice should end in a sentence for Diego: "the current model stays; the new feature does not save euros", "the linear model with calendar, MAE 20, retrain every quarter", "three segments and one action per segment", "threshold 0.2 with a band, at parity across zones with n = 2,999". If you cannot write it, the practice is not finished.

Conclusion

Five practices and five decisions: a batch of 3,003 rows ends up at 2,999 after removing 3 duplicates and a €99,999 amount, with the nulls imputed in the pipeline and the leak out; an interaction feature raises the AUC from 0.8365 to 0.8409 consistently but does not save euros at threshold 0.2, so the production model does not change; in demand, the linear model with calendar (MAE 22.6 over four quarters) beats the baselines (27-30), the lags (which add nothing with independent noise and destabilise the first block) and gradient boosting (40.4, which neither extrapolates the trend nor isolates Black Friday), and removing the Black Friday variable costs 214 units of error in a single week; k-means finds three segments (silhouette 0.572) with a business action for each; and the optimal threshold depends on the costs (0.15 with 5/30, 0.2 with 8/20, 0.1 with 5/50) while parity across zones, ambiguous with 750 orders (ratio 0.81, interval 0.58-0.93), is clear with 2,999 (0.99), although a proxy could break it without the model looking at the zone.

In 09-03, Neural Network Projects, you will go up a level of complexity with PyTorch: the returns MLP with an architecture and learning-rate search, the photo CNN with a third class and data augmentation, a GRU on the demand series that you will compare with this lesson's linear model, and a review classifier with embeddings trained from scratch versus the bag of words. The underlying question will be the one from 05-03: when does the network pay off against a well-built classical model?

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