The three previous lessons consolidated, exercise by exercise, the algorithms (09-01), machine learning (09-02) and neural networks (09-03). What is missing is what 08-01 called "the method": taking one complete case from the business question to a documented, presentable prototype, making every intermediate decision without the lesson making it for you. This lesson is that practice. We solve end to end NovaMarket case 9, diagnosing the cause of incidents, which until now only had the Bayesian network from 06-03 and the rules from 06-04: we will generate a synthetic incidents.csv consistent with those probability tables, measure the network as a baseline, train a classifier with columns the network does not see, combine them with business rules into work queues, write the model card and a drift check, and prepare the presentation to Diego. At the end you will find the equivalent script for case 4 (classifying reviews and prioritising the customer-service response), in case you would rather do the project with text.
How to work through the lesson: it is an 8-12 hour project spread over several sessions. Read the rules of the game, do each step yourself (document, code, tables) and only afterwards compare with our version. When you finish, assess yourself with the rubric. You need the module 7 environment (pandas, scikit-learn, joblib) and, if you do the alternative, reviews_nm.py.
Contents
- Rules of the game: time, deliverables and self-assessment rubric
- Problem definition: the one-page document
- Data: a synthetic
incidents.csvconsistent with the network from 06-03 - Baseline: the Bayesian network by enumeration
- Supervised classifier with the extra columns and an honest comparison
- Neurosymbolic combination: business rules and work queues
- Model card and drift check
- Presenting the results to Diego
- Final rubric and script for case 4
- Common Mistakes and Tips
- Conclusion
- Rules of the game: time, deliverables and self-assessment rubric
Suggested time: 8-12 hours in 4-6 sessions (definition and data, baseline and classifier, rules and queues, documentation and presentation).
Deliverables (a folder novamarket_ai/incidents/ with the structure from 07-04):
DEFINITION.md: the one-page definition document (section 1).data.pywith the reproducible generator anddata/raw/incidents.csv(outside Git if it were real).baseline.py,train.py,decide.py,drift.py: runnable code with outputs.models/model_card.jsonandmodels/drift_reference.json.PRESENTATION.md: the 5-slide script.- This rubric, filled in by you.
| Criterion | 1 (insufficient) | 2 (acceptable) | 3 (good) | 4 (excellent) |
|---|---|---|---|---|
| Definition | Just "predict the cause" | Question and decision written down | + business and technical metric, baseline | + legal/ethical constraints and verifiable success criterion |
| Data | Generator with no seed or consistency | Reproducible | + consistent with the CPTs, with partial labels | + numerical consistency check and leaks identified |
| Baseline | None | Majority | Bayesian network evaluated | + analysis by class and by confidence |
| Model | One model, unvalidated | Cross-validation | + comparison with the baseline on the same rows | + learning curve or analysis of which column contributes |
| Combination | Model only | Review threshold | + business rules with a trace | + queues measured (size and accuracy) and effect of the threshold |
| Documentation | Nothing | Minimal model card | + reference and drift check | + monitoring calendar and honest limitations |
| Communication | Loose figures | Results table | 5-slide script | + a decision requested from Diego and risks |
- Problem definition: the one-page document
Reminder (08-01, sections 2 and 11.1). Before touching a single datum: what question the system answers, what decision it changes, business and technical metric, baseline, constraints, success criterion. A one-page table signed off by business (Diego) and data (Marta).
| Field | Content |
|---|---|
| Question | Given a newly opened incident (warehouse, declared symptoms, days since shipping, carrier, value), what is its most likely cause and with what confidence? |
| Decision it changes | Today the agent reads the ticket and decides by hand. With the system, each incident goes into one of two queues: automatic (cause accepted, actions triggered: reshipment, trace with the carrier, notice to purchasing) or human review (the agent decides with the proposal and the trace in view). All automatic actions are reversible. |
| Business metric | Agent minutes per incident (an automatic incident costs ~2 min; a reviewed one, ~8) and accuracy in the automatic queue ≥ 85 % (a wrong automatic diagnosis generates a second incident). |
| Technical metric | Accuracy and macro F1 over the five causes; log-loss (calibration) because the rules use the probability. |
| Baseline | Majority ("always picking error", 31 %) and the Bayesian network from 06-03 with Diego's CPTs (no labelled data). |
| Success criterion | Automate ≥ 60 % of incidents with ≥ 85 % accuracy in that queue; beat the Bayesian network on accuracy; present a readable trace per incident. |
| Constraints | The cause is not used to sanction anyone (employees or carriers) without human review; GDPR: no customer personal data in the model; AI Act: minimal risk (internal support). Only 50 % of incidents have the cause recorded. |
| Data | incidents.csv (2,000 rows, 3 months), symptoms, warehouse, days, carrier, value; label = cause recorded by the agent on closing. |
| Deployment | Hourly batch over new incidents; output = queue + cause + actions + trace; shadow mode for 2 weeks in Zaragoza. |
| Owner / responsible | Diego (operations), Marta (model), customer service (users), purchasing and logistics (actions). |
| Budget | Prototype: this practice; pilot: 4 weeks. |
- Data: a synthetic
incidents.csv consistent with the network from 06-03
incidents.csv consistent with the network from 06-03Reminder (06-03, sections 6-7; 02-03). The network has warehouse (zaragoza 0.60 / getafe 0.40) → cause (5 values, CPT per warehouse) → four binary symptoms with P(symptom | cause). Real data has the cause in only some of the cases and columns the network does not model; a reproducible generator lets you check that the system recovers what we know is there.
Your task. Write generate_incidents(n=2000, seed=42, labelled_frac=0.5), which samples warehouse, cause and symptoms exactly from the CPTs of 06-03 and adds three columns outside the network: days_since_shipping (Poisson with a mean depending on the cause: picking 3, transport 4, address 8, stock 9, supplier 7, plus 1), carrier (depending on the warehouse: Zaragoza mostly uses TransNova; Getafe, RapidShip) and order_value (gamma, independent). Keep the true cause aside and leave recorded_cause in only half of the rows. Check numerically that the rates match the CPTs.
import numpy as np, pandas as pd, json
from itertools import product
pd.set_option("display.width", 200)
CAUSES = ["picking_error", "transport_damage", "wrong_address", "stale_stock", "supplier_failure"]
SYMPTOMS = ["damaged_package", "wrong_product", "not_delivered", "delay"]
P_WAREHOUSE = {"zaragoza": 0.60, "getafe": 0.40}
P_CAUSE = {"zaragoza": [0.35, 0.30, 0.15, 0.08, 0.12], "getafe": [0.25, 0.20, 0.12, 0.33, 0.10]} # CPT from 06-03
P_SYMPTOM = {"damaged_package": [0.10, 0.80, 0.02, 0.02, 0.15], # P(yes | cause)
"wrong_product": [0.70, 0.02, 0.01, 0.10, 0.30],
"not_delivered": [0.05, 0.10, 0.85, 0.40, 0.30],
"delay": [0.10, 0.30, 0.60, 0.90, 0.70]}
MEAN_DAYS = {"picking_error": 3, "transport_damage": 4, "wrong_address": 8, "stale_stock": 9, "supplier_failure": 7}
P_CARRIER = {"zaragoza": {"TransNova": 0.5, "RapidShip": 0.3, "LogiSur": 0.2},
"getafe": {"RapidShip": 0.5, "LogiSur": 0.3, "TransNova": 0.2}}
def generate_incidents(n=2000, seed=42, labelled_frac=0.5, p_cause=None):
"""Synthetic incidents.csv consistent with the network from 06-03. 'cause' is the truth; 'recorded_cause' only in labelled_frac."""
rng = np.random.default_rng(seed); p_cause = p_cause or P_CAUSE # p_cause: to simulate a different month (drift)
warehouse = rng.choice(list(P_WAREHOUSE), size=n, p=list(P_WAREHOUSE.values()))
cause = np.array([rng.choice(CAUSES, p=p_cause[w]) for w in warehouse])
idx = np.array([CAUSES.index(c) for c in cause])
df = pd.DataFrame({"incident_id": [f"I{7000 + i}" for i in range(n)], "warehouse": warehouse})
for s in SYMPTOMS:
df[s] = (rng.random(n) < np.array(P_SYMPTOM[s])[idx]).astype(int)
df["days_since_shipping"] = np.clip(rng.poisson([MEAN_DAYS[c] for c in cause]) + 1, 1, 30)
df["carrier"] = [rng.choice(list(P_CARRIER[w]), p=list(P_CARRIER[w].values())) for w in warehouse]
df["order_value"] = np.round(rng.gamma(2.0, 60.0, n) + 5, 2)
df["cause"] = cause
df["recorded_cause"] = np.where(rng.random(n) < labelled_frac, cause, None)
return df
inc = generate_incidents(); print(inc.shape, "| labelled:", inc["recorded_cause"].notna().sum())
print(inc.drop(columns=["cause"]).head(3).to_string())
print(inc.groupby("warehouse")["cause"].value_counts(normalize=True).unstack()[CAUSES].round(2).to_string())
print(inc.groupby("cause")[SYMPTOMS].mean().loc[CAUSES].round(2))
print("mean days per cause:", inc.groupby("cause")["days_since_shipping"].mean().round(1).to_dict())
labelled = inc[inc["recorded_cause"].notna()].copy(); y = labelled["recorded_cause"](2000, 11) | labelled: 1003
incident_id warehouse damaged_package wrong_product not_delivered delay days_since_shipping carrier order_value recorded_cause
0 I7000 getafe 0 0 0 1 9 RapidShip 23.26 stale_stock
1 I7001 zaragoza 1 0 0 0 5 TransNova 100.02 transport_damage
2 I7002 getafe 0 0 1 1 7 RapidShip 119.75 NaN
cause picking_error transport_damage wrong_address stale_stock supplier_failure
warehouse
getafe 0.26 0.19 0.12 0.32 0.11
zaragoza 0.37 0.30 0.14 0.08 0.11
damaged_package wrong_product not_delivered delay
cause
picking_error 0.08 0.70 0.06 0.10
transport_damage 0.83 0.01 0.11 0.32
wrong_address 0.02 0.02 0.83 0.58
stale_stock 0.03 0.11 0.40 0.90
supplier_failure 0.13 0.25 0.30 0.68
mean days per cause: {'picking_error': 4.0, 'stale_stock': 10.2, 'supplier_failure': 8.0, 'transport_damage': 5.1, 'wrong_address': 9.0}The sample frequencies reproduce the CPTs to within ±0.03 (2,000 rows): the generator is consistent. Data decisions you must leave in writing: cause (the truth) does not exist in the real file and is used here only to evaluate the unlabelled queue at the end; the model is trained only on the 1,003 labelled rows; incident_id is not a feature; there is no personal data.
- Baseline: the Bayesian network by enumeration
Reminder (06-03, section 7). infer(query, evidence) sums the joint over the hidden variables and normalises. With complete evidence (warehouse and the four symptoms) there are no hidden variables: it is straight Bayes. The network needs no labels: its 29 figures were set by Diego.
Your task. Reuse NETWORK and infer from 06-03 with the tables from section 2, write bayes_diagnosis(df), which returns P(cause | warehouse, symptoms) per row (there are only 2 × 2⁴ = 32 evidence combinations: cache them), evaluate accuracy, macro F1 and log-loss on the 1,003 labelled rows against the majority baseline, and analyse accuracy as a function of the maximum confidence.
from sklearn.metrics import accuracy_score, f1_score, log_loss, classification_report
def binary_cpt(lst): return {(c,): {1: p, 0: round(1 - p, 4)} for c, p in zip(CAUSES, lst)}
NETWORK = {"warehouse": ([], list(P_WAREHOUSE), {(): P_WAREHOUSE}),
"cause": (["warehouse"], CAUSES, {(w,): dict(zip(CAUSES, P_CAUSE[w])) for w in P_WAREHOUSE})}
for s in SYMPTOMS: NETWORK[s] = (["cause"], [1, 0], binary_cpt(P_SYMPTOM[s]))
ORDER = ["warehouse", "cause"] + SYMPTOMS
def joint_prob(assignment):
p = 1.0
for node in ORDER:
parents, _, cpt = NETWORK[node]; p *= cpt[tuple(assignment[q] for q in parents)][assignment[node]]
return p
def infer(query, evidence):
hidden = [n for n in ORDER if n != query and n not in evidence]; res = {}
for v in NETWORK[query][1]:
res[v] = sum(joint_prob(dict(evidence, **{query: v}, **dict(zip(hidden, comb))))
for comb in product(*[NETWORK[n][1] for n in hidden]))
z = sum(res.values()); return {v: p / z for v, p in res.items()}
def bayes_diagnosis(df):
cache, rows = {}, []
for _, r in df.iterrows():
key = (r["warehouse"],) + tuple(int(r[s]) for s in SYMPTOMS)
if key not in cache:
cache[key] = infer("cause", dict({"warehouse": r["warehouse"]}, **{s: int(r[s]) for s in SYMPTOMS}))
rows.append(cache[key])
return pd.DataFrame(rows, index=df.index)[CAUSES]
CLASSES = sorted(CAUSES) # the alphabetical order scikit-learn uses
P_bayes = bayes_diagnosis(labelled); pred_bayes = P_bayes.idxmax(axis=1)
print("Majority (picking_error):", round((y == "picking_error").mean(), 3))
print("Bayesian network: accuracy", round(accuracy_score(y, pred_bayes), 3), "| macro F1", round(f1_score(y, pred_bayes, average="macro"), 3),
"| log-loss", round(log_loss(y, P_bayes[CLASSES].values, labels=CLASSES), 3))
print(classification_report(y, pred_bayes, digits=2))
maxp = P_bayes.max(axis=1)
print(f"Mean max P {maxp.mean():.3f} | rows with P ≥ 0.6: {(maxp >= 0.6).mean():.1%}, accuracy {accuracy_score(y[maxp >= 0.6], pred_bayes[maxp >= 0.6]):.3f} | P < 0.6: accuracy {accuracy_score(y[maxp < 0.6], pred_bayes[maxp < 0.6]):.3f}")Majority (picking_error): 0.314
Bayesian network: accuracy 0.719 | macro F1 0.606 | log-loss 0.789
precision recall f1-score support
picking_error 0.78 0.90 0.83 315
stale_stock 0.54 0.77 0.64 179
supplier_failure 0.50 0.05 0.09 118
transport_damage 0.88 0.82 0.85 257
wrong_address 0.62 0.62 0.62 134
accuracy 0.72 1003
macro avg 0.66 0.63 0.61 1003
Mean max P 0.723 | rows with P ≥ 0.6: 67.3%, accuracy 0.822 | P < 0.6: accuracy 0.506The network gets 71.9 % right (against 31.4 % for the majority baseline) without having seen a single label: that is the value of expert knowledge. Its weak points are clear: supplier_failure is almost never diagnosed (recall 0.05: its symptoms resemble those of stock and picking and its prior is the lowest), and stock/address get confused with each other. And the confidence is informative: when P ≥ 0.6 (two thirds of the cases) it gets 82 % right; below that, half. That is the seed of the review band in section 5. Since the data was generated with these very tables, the network is the best possible classifier with those five variables: any improvement will have to come from the columns the network does not see.
- Supervised classifier with the extra columns and an honest comparison
Reminder (04-04, 04-05, 09-02). Multiclass logistic regression, tree, forest and gradient boosting on a ColumnTransformer; stratified cross-validation; compare on the same rows and per fold; look at the confusion matrix and at which feature contributes.
Your task. Train and compare with StratifiedKFold(5) four models on warehouse, carrier, symptoms, days and value; show that the improvement comes from days_since_shipping (remove columns); compare with the network per fold; draw a learning curve with 100, 250, 500 and 1,003 labels.
from sklearn.model_selection import StratifiedKFold, cross_val_predict
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, HistGradientBoostingClassifier
CATS, NUMS, BINS = ["warehouse", "carrier"], ["days_since_shipping", "order_value"], SYMPTOMS
COLS = CATS + NUMS + BINS
def build_model(classifier=None, cats=CATS, nums=NUMS, bins=BINS):
parts = ([("cat", OneHotEncoder(handle_unknown="ignore"), cats)] if cats else []) + \
([("num", StandardScaler(), nums)] if nums else []) + ([("bin", "passthrough", bins)] if bins else [])
return Pipeline([("prep", ColumnTransformer(parts)), ("model", classifier if classifier is not None else LogisticRegression(max_iter=2000))])
cv = StratifiedKFold(5, shuffle=True, random_state=42)
def evaluate_cv(pipe, Xd, name):
P = cross_val_predict(pipe, Xd, y, cv=cv, method="predict_proba"); pred = np.array(CLASSES)[P.argmax(1)]
print(f"{name:34s} accuracy {accuracy_score(y, pred):.3f} macro F1 {f1_score(y, pred, average='macro'):.3f} log-loss {log_loss(y, P, labels=CLASSES):.3f}")
return P, pred
models = {"Multiclass logistic": LogisticRegression(max_iter=2000), "Tree (depth 5)": DecisionTreeClassifier(max_depth=5, random_state=42),
"Forest (300, leaf 5)": RandomForestClassifier(n_estimators=300, min_samples_leaf=5, random_state=42),
"HGB (depth 3)": HistGradientBoostingClassifier(max_depth=3, learning_rate=0.05, max_iter=200, random_state=42)}
results = {n: evaluate_cv(build_model(m), labelled[COLS], n) for n, m in models.items()}
evaluate_cv(build_model(cats=["warehouse"], nums=[], bins=BINS), labelled[["warehouse"] + BINS], "Logistic ONLY warehouse + symptoms")
evaluate_cv(build_model(cats=[], nums=["days_since_shipping"], bins=[]), labelled[["days_since_shipping"]], "Logistic ONLY days")
P_log, pred_log = results["Multiclass logistic"]
print(pd.crosstab(y, pred_log, rownames=["actual"], colnames=["logistic"]).to_string())
accs = [(accuracy_score(y.iloc[te], pred_bayes.iloc[te]), accuracy_score(y.iloc[te], pred_log[te])) for _, te in cv.split(labelled[COLS], y)]
print("Per fold (bayes, logistic):", [(round(a, 3), round(b, 3)) for a, b in accs], "| mean advantage", round(np.mean([b - a for a, b in accs]), 3))
for n_lab in (100, 250, 500, 1003):
sub = labelled.sample(n_lab, random_state=1) if n_lab < len(labelled) else labelled
P = cross_val_predict(build_model(), sub[COLS], sub["recorded_cause"], cv=cv, method="predict_proba")
print(f"n labelled={n_lab:4d}: logistic {accuracy_score(sub['recorded_cause'], np.array(CLASSES)[P.argmax(1)]):.3f} Bayesian network {accuracy_score(sub['recorded_cause'], bayes_diagnosis(sub).idxmax(axis=1)):.3f}")Multiclass logistic accuracy 0.738 macro F1 0.658 log-loss 0.690 Tree (depth 5) accuracy 0.690 macro F1 0.598 log-loss 1.796 Forest (300, leaf 5) accuracy 0.735 macro F1 0.646 log-loss 0.759 HGB (depth 3) accuracy 0.724 macro F1 0.655 log-loss 0.802 Logistic ONLY warehouse + symptoms accuracy 0.709 macro F1 0.612 log-loss 0.804 Logistic ONLY days accuracy 0.454 macro F1 0.292 log-loss 1.206 logistic picking_error stale_stock supplier_failure transport_damage wrong_address actual picking_error 291 3 9 10 2 stale_stock 5 127 20 3 24 supplier_failure 20 33 26 15 24 transport_damage 26 5 12 211 3 wrong_address 4 28 15 2 85 Per fold (bayes, logistic): [(0.677, 0.716), (0.731, 0.721), (0.751, 0.761), (0.73, 0.77), (0.705, 0.72)] | mean advantage 0.019 n labelled= 100: logistic 0.780 Bayesian network 0.790 n labelled= 250: logistic 0.752 Bayesian network 0.744 n labelled= 500: logistic 0.764 Bayesian network 0.746 n labelled=1003: logistic 0.738 Bayesian network 0.719
Honest reading:
- Multiclass logistic regression is the best (0.738; macro F1 0.658; log-loss 0.690, the best calibrated), followed by the forest; the tree alone is badly calibrated (log-loss 1.8) and HGB adds nothing with 1,000 rows. The advantage over the Bayesian network is +1.9 points, in favour in 4 of 5 folds: small but real.
- Where it comes from: logistic regression with only warehouse and symptoms gives 0.709, slightly below the network (0.719), as it should be (the network is the exact generating model of those variables);
days_since_shippingalone classifies 45 %, and it is the one that adds value when combined: supplier_failure goes from recall 0.05 to 0.22 and stock/address separate somewhat better.carrierandorder_valuecontribute nothing (by construction they do not depend on the cause: checking that is part of the job). - The learning curve tells the story of 06-03: with 100 labels the expert's network wins (0.79 versus 0.78); from about 250 the learned model matches it and with 1,000 it overtakes it. With few labels, knowledge; with many, data; and the ideal (variant) is neurosymbolic here too: estimate the network's CPTs from the labels with Laplace smoothing and add a discretised
daysnode. - Decision: multiclass logistic regression, for metric, calibration and explainability (its coefficients are readable and its probabilities feed the rules of section 5); the network stays as a documented baseline and as a fallback if labelling degrades.
- Neurosymbolic combination: business rules and work queues
Reminder (06-04, sections 9 and 11; 08-01, 11.4). The model is a sensor that returns probabilities; the rules turn those probabilities into facts with a human review band, apply the policy and leave a readable trace. The operational output is a table of queues.
Your task. Write decide(row, probs) with these rules and evaluate the queues with the cross-validated probabilities: R0 orders over €300 always to human review; R1 confidence < 0.6 → human review; R2 confidence ≥ 0.6 → automatic queue with the cause accepted; R3 transport damage → open a trace with the carrier; R4 picking error → reshipment from the warehouse; R5 "not delivered" and more than 10 days → claim against the carrier whatever the cause; R6 stale stock → notice to purchasing. Measure the size and accuracy of each queue, the effect of the threshold, and apply the final model to the 997 unlabelled incidents.
AUTO_THRESHOLD, HIGH_VALUE, MAX_DAYS = 0.60, 300.0, 10
def decide(row, probs):
cause, p = CLASSES[int(np.argmax(probs))], float(np.max(probs)); actions, trace = [], []
if row["order_value"] > HIGH_VALUE:
queue = "human_review"; trace.append(f"R0: value {row['order_value']:.0f} € > {HIGH_VALUE:.0f} € → a person always reviews")
elif p < AUTO_THRESHOLD:
queue = "human_review"; trace.append(f"R1: P({cause})={p:.2f} < {AUTO_THRESHOLD} → grey zone")
else:
queue = "automatic"; trace.append(f"R2: P({cause})={p:.2f} ≥ {AUTO_THRESHOLD} → cause accepted as a fact")
if cause == "transport_damage" and p >= AUTO_THRESHOLD:
actions.append(f"open_trace:{row['carrier']}"); trace.append("R3: transport damage → trace with the carrier")
if cause == "picking_error" and p >= AUTO_THRESHOLD:
actions.append(f"reship_from:{row['warehouse']}"); trace.append("R4: picking error → immediate reshipment and notice to the warehouse")
if row["not_delivered"] == 1 and row["days_since_shipping"] > MAX_DAYS:
actions.append("carrier_claim"); trace.append(f"R5: not delivered and {row['days_since_shipping']} days > {MAX_DAYS} → claim, whatever the cause")
if cause == "stale_stock" and p >= AUTO_THRESHOLD:
actions.append("notify_purchasing"); trace.append("R6: stale stock → notice to purchasing and alternative for the customer")
return {"queue": queue, "proposed_cause": cause, "confidence": round(p, 3), "actions": actions, "trace": trace}
decisions = pd.DataFrame([decide(r, P_log[i]) for i, (_, r) in enumerate(labelled.iterrows())], index=labelled.index)
labelled2 = pd.concat([labelled, decisions], axis=1)
for queue, g in labelled2.groupby("queue"):
print(f"{queue:16s} n={len(g):4d} ({len(g) / len(labelled2):.1%}) accuracy of the proposed cause {accuracy_score(g['recorded_cause'], g['proposed_cause']):.3f}")
print("actions triggered:", pd.Series([a.split(':')[0] for l in labelled2["actions"] for a in l]).value_counts().to_dict())
r = labelled2.iloc[6]; print(r["incident_id"], "| actual:", r["recorded_cause"], "→", r["queue"], r["proposed_cause"], r["confidence"], r["actions"]); [print(" ", t) for t in r["trace"]]
for t in (0.5, 0.6, 0.7, 0.8):
auto = labelled2[(P_log.max(1) >= t) & (labelled2["order_value"] <= HIGH_VALUE)]
print(f"threshold {t}: automatic {len(auto) / len(labelled2):.1%}, accuracy {accuracy_score(auto['recorded_cause'], auto['proposed_cause']):.3f}")
model = build_model().fit(labelled[COLS], y) # final model with the 1,003 labelled rows
unlabelled = inc[inc["recorded_cause"].isna()].copy(); P_unl = model.predict_proba(unlabelled[COLS])
dec_unl = pd.DataFrame([decide(r, P_unl[i]) for i, (_, r) in enumerate(unlabelled.iterrows())], index=unlabelled.index)
auto = dec_unl["queue"] == "automatic"
print("Unlabelled:", dec_unl["queue"].value_counts().to_dict(), "| true (hidden) accuracy automatic:", round(accuracy_score(unlabelled.loc[auto, "cause"], dec_unl.loc[auto, "proposed_cause"]), 3),
"| human:", round(accuracy_score(unlabelled.loc[~auto, "cause"], dec_unl.loc[~auto, "proposed_cause"]), 3))automatic n= 649 (64.7%) accuracy of the proposed cause 0.858
human_review n= 354 (35.3%) accuracy of the proposed cause 0.517
actions triggered: {'reship_from': 290, 'open_trace': 223, 'notify_purchasing': 104, 'carrier_claim': 67}
I7010 | actual: picking_error → human_review picking_error 0.808 ['reship_from:zaragoza']
R0: value 349 € > 300 € → a person always reviews
R4: picking error → immediate reshipment and notice to the warehouse
threshold 0.5: automatic 76.5%, accuracy 0.821
threshold 0.6: automatic 64.7%, accuracy 0.858
threshold 0.7: automatic 53.8%, accuracy 0.906
threshold 0.8: automatic 45.8%, accuracy 0.919
Unlabelled: {'automatic': 697, 'human_review': 300} | true (hidden) accuracy automatic: 0.852 | human: 0.52The success criterion of section 1 is met in the prototype: 64.7 % automated with 85.8 % accuracy in that queue (and 85.2 % on the 997 unlabelled incidents, whose true cause we know only because the data is synthetic: it is the check that cross-validation was not lying). The human queue concentrates the hard cases (51.7 %): the agent receives the proposal, the confidence and the trace, exactly what 06-04 asked for. The threshold is a business dial: 0.7 raises automatic accuracy to 90.6 % while automating 54 %; Diego decides according to the team's capacity. Incident I7010 shows the interaction of rules: the model is confident (0.81) but R0 sends it to review because of the value, and even so R4 triggers the reshipment, because reshipping is reversible and cheap. In agent minutes (2 versus 8): 1,003 incidents cost 8,024 min by hand; now 649 × 2 + 354 × 8 = 4,130, almost half, not counting the second incident caused by a wrong diagnosis (14 % of 649 ≈ 92 cases), which has to be subtracted and watched.
- Model card and drift check
Reminder (08-01, sections 11.3 and 11.5). The model card documents intended and unintended use, data, metrics and limitations from what the code computes; the drift check compares each batch with a reference saved at training time (binned PSI, rates, fraction in each queue).
Your task. Generate model_card.json and drift_reference.json from the code; write check_drift(batch, reference) with the PSI of days_since_shipping, differences in symptom rates, automatic fraction and distribution of proposed causes; test it with a normal month and with a month in which transport damage shoots up.
import joblib
card = {"name": "NovaMarket incident cause diagnosis (case 9)", "version": "0.1.0-prototype", "date": "2026-08-18",
"model": "Multiclass logistic regression: warehouse, carrier, 4 symptoms, days since shipping, order value",
"data": {"incidents": int(len(inc)), "labelled": int(len(labelled)), "classes": CLASSES},
"metrics_cv5": {"accuracy": 0.738, "f1_macro": 0.658, "baseline_bayesian_network": 0.719, "baseline_majority": 0.314},
"queues": {"automatic_threshold": AUTO_THRESHOLD, "high_value_review": HIGH_VALUE, "automatic_fraction": 0.647, "automatic_accuracy": 0.858},
"intended_use": "Propose cause and actions to the agent; decide automatically only in the automatic queue and with reversible actions",
"out_of_scope_use": "Attributing responsibility to a carrier or employee without human review; using the cause to penalise",
"limitations": ["supplier_failure is poorly detected (recall 0.22)", "only 1,003 labels, recorded in half of the cases",
"synthetic data consistent with the CPTs of 06-03: validate with real data before the pilot"]}
reference = {"symptom_rates": labelled[SYMPTOMS].mean().round(3).to_dict(), "automatic_frac": 0.647,
"proposed_cause_dist": labelled2["proposed_cause"].value_counts(normalize=True).round(3).to_dict(),
"days_edges": [0, 2, 4, 6, 8, 10, 14, None], "days_freq": None}
PSI_WARNING, PSI_ALERT, RATE_DIFF = 0.10, 0.25, 0.08
def psi(ref, new, eps=1e-4):
r, n = np.clip(np.asarray(ref, float), eps, None), np.clip(np.asarray(new, float), eps, None)
return float(np.sum((n - r) * np.log(n / r)))
def frequencies(values, edges):
b = [-np.inf if x is None else x for x in edges]; b[-1] = np.inf
c, _ = np.histogram(values, bins=b); return (c / c.sum()).tolist()
reference["days_freq"] = frequencies(labelled["days_since_shipping"], reference["days_edges"])
json.dump(card, open("model_card_incidents.json", "w"), indent=2, ensure_ascii=False)
json.dump(reference, open("drift_reference_incidents.json", "w"), indent=2); joblib.dump(model, "incidents_model.joblib")
def check_drift(batch, reference, model):
P = model.predict_proba(batch[COLS]); dec = pd.DataFrame([decide(r, P[i]) for i, (_, r) in enumerate(batch.iterrows())])
warnings, v = [], psi(reference["days_freq"], frequencies(batch["days_since_shipping"], reference["days_edges"]))
if v > PSI_WARNING: warnings.append(f"PSI days = {v:.3f}")
for s, t in reference["symptom_rates"].items():
if abs(batch[s].mean() - t) > RATE_DIFF: warnings.append(f"rate {s}: {batch[s].mean():.3f} versus {t:.3f}")
fa = (dec["queue"] == "automatic").mean()
if abs(fa - reference["automatic_frac"]) > RATE_DIFF: warnings.append(f"automatic fraction {fa:.3f} versus {reference['automatic_frac']:.3f}")
dc = dec["proposed_cause"].value_counts(normalize=True)
for c, t in reference["proposed_cause_dist"].items():
if abs(dc.get(c, 0) - t) > RATE_DIFF: warnings.append(f"proposed cause {c}: {dc.get(c, 0):.3f} versus {t:.3f}")
level = "ALERT" if v > PSI_ALERT or len(warnings) >= 2 else ("WARNING" if warnings else "OK")
return level, warnings
print("Normal month:", check_drift(generate_incidents(400, seed=7), reference, model))
p_transport = {"zaragoza": [0.20, 0.55, 0.10, 0.06, 0.09], "getafe": [0.15, 0.45, 0.08, 0.25, 0.07]} # transport damage shoots up
print("Month with a transport problem:", check_drift(generate_incidents(400, seed=8, p_cause=p_transport), reference, model))Normal month: ('OK', [])
Month with a transport problem: ('ALERT', ['rate damaged_package: 0.525 versus 0.259', 'rate wrong_product: 0.150 versus 0.282', 'rate not_delivered: 0.177 versus 0.260', 'automatic fraction 0.782 versus 0.647', 'proposed cause picking_error: 0.233 versus 0.345', 'proposed cause transport_damage: 0.497 versus 0.240'])The normal month passes green; the month with a transport problem fires six mutually consistent warnings (more damaged packages, more transport causes proposed, a bigger automatic queue). Interestingly, in that month the model is more accurate (transport damage is the easiest cause), so the alert does not mean "retrain" but "something has changed in the world: logistics should talk to the carrier". It is the difference between data drift and concept drift from 08-01, and that is why a person reads the check. Minimum calendar: every hour, that the batch ran; every week, this check; every month, with the causes recorded by the agents in the human queue (which are now collected systematically), real accuracy per queue and retraining if the automatic queue drops below 80 %.
- Presenting the results to Diego
Reminder (08-01, section 9). Start with the decision and the money, one figure per slide, always compare with the baseline, say what the system does not know and what is being asked for.
| # | Slide | Content (one idea, one figure) |
|---|---|---|
| 1 | The problem and the proposal | Every incident is diagnosed by an agent by hand (~8 min). We propose two queues: automatic (2 min, reversible actions) and human with the suggested cause and its explanation. |
| 2 | What we have built | Model trained on the 1,003 incidents with a recorded cause + business rules (value > €300, confidence < 0.6, trace with carrier, reshipment, notice to purchasing). No personal data. |
| 3 | Results against the baseline | It gets 73.8 % right (the network from Diego's tables, 71.9 %; "always picking", 31.4 %). In the automatic queue, 85.8 % accuracy on 64.7 % of incidents. Estimated saving: from 8,024 to ~4,130 agent minutes per 1,000 incidents. |
| 4 | What it does not know and the risks | Supplier failure is poorly detected (22 %); only half of incidents have a recorded cause (we ask that it always be recorded); prototype data is synthetic: the pilot is needed; no sanctions for carriers or employees without review. |
| 5 | The decision we ask for | 4-week pilot in Zaragoza in shadow mode, threshold 0.6 (or 0.7 if the team prefers 54 % automated with 90.6 % accuracy), weekly drift check and monthly review with the recorded causes. |
- Final rubric and script for case 4
Go back to the rubric in section 0 and score yourself honestly; with our version, the hardest box to justify is "excellent" in Data (consistency was checked, but the leaks were taken for granted: cause never enters the model, and days_since_shipping is known when the incident is opened, but what if in reality it were computed on closing it?). That kind of question is the one you must ask yourself on every row.
Alternative script, case 4: classifying reviews and prioritising the customer-service response. Same structure, different pieces:
| Step | What to do |
|---|---|
| 1 Definition | Question: for each new review, sentiment and urgency (must it be answered today)? Decision: queue "answer today" (negative and urgent: broken product, not delivered, double charge), "answer this week", "thank/automatic". Business metric: urgent reviews handled in < 24 h; technical: sentiment accuracy and urgency recall. Baseline: bag of words + logistic regression from 05-05 (0.767) and keyword rules for urgency. |
| 2 Data | Extend the templates in reviews_nm.py with an urgent label per template (the negatives about "arrived broken", "never arrived", "charged twice", "dangerous" are urgent; "noisy" or "expensive" are not) and generate 400 reviews with generate_reviews_extended from 09-03 returning template; validate by template. |
| 3 Baseline | Keyword rules for urgency (a list of 10 terms) + bag of words for sentiment; measure both by template. |
| 4 Model | Two classifiers (sentiment, urgency) or one 3-queue multiclass; compare bag of words, bigrams and EmbeddingBag from 09-03; urgency F1 above accuracy. |
| 5 Combination | Rules on the probabilities: P(urgent) ≥ 0.7 → "today"; 0.4-0.7 → human review; mention of "charge" or "danger" → always "today" (hard rule); automatic thank-you replies only if P(positive) ≥ 0.9. |
| 6 Documentation | Model card with the warning that the model understands neither negation nor irony; drift: PSI of review length, negative rate, "today" fraction. |
| 7 Presentation | Urgent reviews handled within 24 h before/after; how many would slip through (false negatives) and why; request: pilot with human review of the "today" queue and, if there is budget, a pre-trained language model (09-03). |
Common Mistakes and Tips
- Skipping the definition and starting with the model. Without the table in section 1 you would not know that the metric that matters is accuracy in the automatic queue, not overall accuracy.
- Evaluating the baseline and the model on different rows or with different protocols; here the network needs no training and the model does, but both are measured on the same 1,003 rows and per fold.
- Using the hidden label (
cause) for anything other than the final check of the unlabelled queue: in reality it does not exist. - Rules that contradict the model without leaving a trace. Every decision must be explainable as a list of fired rules with their values; that is what the agent and the auditor will read.
- Drift = retrain. The month with a transport problem teaches that an alert may call for a business action, not a data one.
- Documenting at the end. The model card is generated from the code and the figures come from the same variables you printed; if you write it by hand at the end, it drifts out of sync.
- Tip: save everything as scripts with
main()and a test (assert accuracy > 0.70), as in 07-04, and use the rubric twice: at the start (what am I missing) and at the end (what have I achieved).
Conclusion
With this project you have walked the complete method from 08-01 on a new case: a definition document with the decision (two queues), the business metric (agent minutes and accuracy in the automatic queue) and the constraints; a generator for incidents.csv consistent with the tables of 06-03 and with half the causes unlabelled; the Bayesian network as a baseline (71.9 %, no labels, with supplier_failure as a blind spot); a multiclass logistic regression that beats it by 1.9 points thanks to days_since_shipping (73.8 %, macro F1 0.658), with the learning curve showing when the expert wins and when the data does; the rules from 06-04 turning probabilities into queues (64.7 % automatic with 85.8 % accuracy; the threshold as a business dial); a model card generated from the code and a drift check that tells a normal month from one with a transport problem; and five slides that end by asking for a decision. And you have the script to repeat it with the reviews.
Here module 9 ends and, with it, the practical part of the course. What follows is not more content but how to keep learning on your own: module 10 gathers books and articles (10-01), courses and tutorials (10-02), communities and forums where you can ask and contribute (10-03) and, in 10-04, concrete itineraries depending on where you want to go (data and ML engineering, deep learning, knowledge-based systems, AI project management), with the next steps Marta and Diego would take at NovaMarket as an example.
Fundamentals of Artificial Intelligence (AI)
Module 1: Introduction to Artificial Intelligence
Module 2: Basic Principles of AI
- Fundamental Concepts: Agents, Environments and Rationality
- Types of Artificial Intelligence
- Data as the Raw Material of AI
- Ethics and Considerations in AI
Module 3: Algorithms in AI
- Introduction to Algorithms
- Search Algorithms
- Adversarial Search: Games and Minimax
- Optimization Algorithms
Module 4: Machine Learning
- Basic Concepts of Machine Learning
- Types of Machine Learning
- Data Preparation and Feature Engineering
- Machine Learning Algorithms
- Model Evaluation and Validation
- Overfitting, Regularization and Hyperparameter Tuning
Module 5: Neural Networks and Deep Learning
- Introduction to Neural Networks
- Neural Network Architecture
- How a Network Learns: Gradient Descent and Backpropagation
- Deep Learning and Its Applications
- Transformers, Large Language Models and Generative AI
Module 6: Logic and Expert Systems
- Logic in AI
- Expert Systems
- Reasoning under Uncertainty: Probability and Bayesian Networks
- Applications of Expert Systems
Module 7: Tools and Programming Languages in AI
- Programming Languages for AI
- Scientific Python: NumPy, pandas and Matplotlib
- Popular Tools and Libraries
- Development Environments
Module 8: Projects and Case Studies
Module 9: Exercises and Practice
- Algorithm Exercises
- Machine Learning Practice
- Neural Network Projects
- Capstone Project: from Idea to Prototype
