Skip to content

Error analysis: what to do when the result is bad

Chapter 11 14 code blocks 3674 words

The code in this chapter uses these filesrun it on your own machine

The scene: there's a number, now what?

This is where students get stuck most. And most books don't have this chapter.

The model is built. F1 = 0.79. Now what?

A student usually does one of three things:

  1. Tries another algorithm (XGBoost, then SVM, then a neural network)
  2. Starts GridSearchCV and waits
  3. Googles "how to improve model accuracy"

All three are guesses. All three eat time.

The right move is different: open the rows the model got wrong and read them. The data itself tells you what to do.

This is steps 11 and 12 of the protocol.

Column names

The data is synthetic Tashkent rental listings, and its column names are Uzbek. tuman district, xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), qurilgan_yil year built. The target ijaraga_berildi_30k means rented within 30 days.

Building the model

python30 lines
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import f1_score, accuracy_score

df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"]
X, y = df[num + ["tuman"]], df["ijaraga_berildi_30k"]
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)

pipe = Pipeline([
    ("prep", ColumnTransformer([
        ("num", Pipeline([("impute", SimpleImputer(strategy="median")),
                          ("scale", StandardScaler())]), num),
        ("cat", OneHotEncoder(handle_unknown="ignore"), ["tuman"]),
    ])),
    ("model", LogisticRegression(max_iter=2000)),
]).fit(X_tr, y_tr)

pred = pipe.predict(X_te)
proba = pipe.predict_proba(X_te)[:, 1]
print(f"Accuracy: {accuracy_score(y_te, pred):.4f}")
print(f"F1      : {f1_score(y_te, pred):.4f}")
print()
print("Now we look NOT AT THESE NUMBERS, BUT AT THE MISTAKES.")
Output
Accuracy: 0.7778
F1      : 0.7938

Now we look NOT AT THESE NUMBERS, BUT AT THE MISTAKES.

Step 1. Read the wrong rows with your own eyes

python12 lines
analysis = X_te.copy()
analysis["truth"] = y_te
analysis["pred"] = pred
analysis["confidence"] = proba.round(3)
analysis["wrong"] = (analysis.truth != analysis.pred).astype(int)

print(f"Total mistakes: {analysis.wrong.sum()} / {len(analysis)} ({analysis.wrong.mean():.1%})")
print()
print("The 6 rows the model got wrong MOST CONFIDENTLY:")
worst = analysis[analysis.wrong == 1].assign(
    error=lambda d: (d.confidence - d.truth).abs()).nlargest(6, "error")
show(worst[["tuman", "xonalar", "maydon_m2", "metro_km", "mebel", "truth", "pred", "confidence"]])
Output
Total mistakes: 80 / 360 (22.2%)

The 6 rows the model got wrong MOST CONFIDENTLY:
tumanxonalarmaydon_m2metro_kmmebeltruthpredconfidence
Shayxontohur1292.780010.855
Sergeli234.74.451010.85
Chilonzor484.51.591100.186
Bektemir125.81.890010.808
Yakkasaroy4751.621100.205
Yunusobod142.15.580010.772

Read this table; don't rush past it. Look at each row and ask yourself: "what would I say about this flat?"

If you'd get it wrong too, the data doesn't hold enough information (you need a new column). If you'd get it right, the model needs a new view of the data to see what you see.

Step 2. Count the mistakes by group

Reading by eye is the first step. Then we check with numbers: are the mistakes concentrated in one place?

python10 lines
def slice_by(col, label=None):
    g = analysis.groupby(col, observed=True).agg(total=("wrong", "size"), wrong=("wrong", "sum"))
    g["error_rate"] = (g.wrong / g.total).round(3)
    g = g[g.total >= 10].sort_values("error_rate", ascending=False)
    print(f"--- by {label or col} ---")
    print(g.to_string())
    print()

slice_by("tuman")
slice_by("xonalar")
Output
--- by tuman ---
                total  wrong  error_rate
tuman                                   
Shayxontohur       38     12       0.316
Mirzo Ulug'bek     49     13       0.265
Olmazor            46     11       0.239
Yakkasaroy         27      6       0.222
Yunusobod          59     13       0.220
Bektemir           27      5       0.185
Chilonzor          67     12       0.179
Sergeli            47      8       0.170

--- by xonalar ---
         total  wrong  error_rate
xonalar                          
2          134     45       0.336
3           97     25       0.258
4           52      5       0.096
1           62      5       0.081
5           15      0       0.000

This is the heart of error analysis. The overall error rate is one number, but across groups it is not evenly spread.

Let's group the numeric columns too, cutting them into bins first.

python6 lines
analysis["area_bin"] = pd.cut(analysis.maydon_m2, bins=[0, 40, 60, 80, 300],
                              labels=["<40", "40-60", "60-80", "80+"])
analysis["metro_bin"] = pd.cut(analysis.metro_km, bins=[0, 1, 2.5, 5, 20],
                               labels=["<1km", "1-2.5km", "2.5-5km", "5km+"])
slice_by("area_bin", "area")
slice_by("metro_bin", "distance to metro")
Output
--- by area ---
          total  wrong  error_rate
area_bin                          
40-60       149     50       0.336
60-80        91     19       0.209
<40          73      9       0.123
80+          47      2       0.043

--- by distance to metro ---
           total  wrong  error_rate
metro_bin                          
5km+          31      8       0.258
2.5-5km      173     43       0.249
1-2.5km      103     23       0.223
<1km          53      6       0.113

Step 3. Look at the two kinds of mistake separately

FP and FN are two different diseases. Analysing them together is treating two patients with one pill.

python13 lines
fp = analysis[(analysis.truth == 0) & (analysis.pred == 1)]
fn = analysis[(analysis.truth == 1) & (analysis.pred == 0)]

print(f"FP (said yes, was no): {len(fp)}")
print(f"FN (said no, was yes): {len(fn)}")
print()
rows = []
for c in ["maydon_m2", "metro_km", "xonalar", "mebel", "qavat"]:
    rows.append({"Column": c,
                 "FP mean": round(fp[c].mean(), 2),
                 "FN mean": round(fn[c].mean(), 2),
                 "Correct mean": round(analysis[analysis.wrong == 0][c].mean(), 2)})
show(pd.DataFrame(rows))
Output
FP (said yes, was no): 52
FN (said no, was yes): 28
ColumnFP meanFN meanCorrect mean
maydon_m247.8965.6157.67
metro_km3.042.992.75
xonalar2.082.932.55
mebel0.540.680.62
qavat5.214.755.38

Read the table column by column. If the FP and FN means differ, that column explains the kind of mistake.

Step 4. The confidence distribution: where is the model unsure

python20 lines
import matplotlib.pyplot as plt

figure, axes = plt.subplots(1, 2, figsize=(13, 4.5))

axes[0].hist([analysis[analysis.truth == 0].confidence, analysis[analysis.truth == 1].confidence],
             bins=20, label=["actually slow", "actually fast"], color=["#c5221f", "#137333"],
             alpha=.75, stacked=False)
axes[0].axvline(0.5, color="k", ls="--", label="threshold 0.5")
axes[0].set_xlabel("Model confidence"); axes[0].set_ylabel("Number of rows")
axes[0].set_title("Confidence distribution"); axes[0].legend(); axes[0].grid(alpha=.3)

bins = pd.cut(analysis.confidence, bins=np.arange(0, 1.01, 0.1))
error_rate = analysis.groupby(bins, observed=True).wrong.mean()
axes[1].bar(range(len(error_rate)), error_rate.values, color="#1a73e8", alpha=.8)
axes[1].set_xticks(range(len(error_rate)))
axes[1].set_xticklabels([f"{i/10:.1f}" for i in range(len(error_rate))], rotation=45)
axes[1].set_xlabel("Confidence bin"); axes[1].set_ylabel("Error rate")
axes[1].set_title("At which confidence does it go wrong most"); axes[1].grid(alpha=.3, axis="y")

fig("xato_tahlili.png", caption="Left: how well the two classes separate by confidence. Right: in which confidence bin the mistakes gather.")

Left: how well the two classes separate by confidence. Right: in which confidence bin the mistakes gather.

Left: how well the two classes separate by confidence. Right: in which confidence bin the mistakes gather.

The chart on the right matters: the mistakes gather around 0.5. That's normal: the model goes wrong where it is unsure.

But if there are many mistakes around 0.9 too, that's a serious signal. If the model is confidently wrong, the data either has wrong labels or is missing an important column.

Step 5. Do we need more data? Measure it

You can't answer this question by guessing. You have to measure it: a learning curve.

python13 lines
from sklearn.model_selection import learning_curve

sizes, train_scores, val_scores = learning_curve(
    pipe, X, y, train_sizes=np.linspace(0.15, 1.0, 8), cv=5,
    scoring="f1", shuffle=True, random_state=42)

table = pd.DataFrame({
    "train size": sizes,
    "train F1": train_scores.mean(axis=1).round(4),
    "val F1": val_scores.mean(axis=1).round(4),
    "GAP": (train_scores.mean(axis=1) - val_scores.mean(axis=1)).round(4),
})
show(table)
train sizetrain F1val F1GAP
1440.7410.6950.046
2600.740.7180.021
3770.7430.720.024
4930.7480.7310.017
6100.7450.7320.013
7260.7320.7310.001
8430.7310.730.001
9600.7280.7270
python8 lines
figure2, ax = plt.subplots(figsize=(9, 5))
ax.plot(sizes, train_scores.mean(axis=1), "b-o", lw=2, label="Train F1")
ax.plot(sizes, val_scores.mean(axis=1), "r-o", lw=2, label="Validation F1")
ax.fill_between(sizes, train_scores.mean(axis=1), val_scores.mean(axis=1), alpha=.15, color="red")
ax.set_xlabel("Number of training rows"); ax.set_ylabel("F1")
ax.set_title("Learning curve: would more data help?")
ax.legend(); ax.grid(alpha=.3)
fig("learning_curve.png", caption="When the two lines have come together and flattened, more data won't help.")

When the two lines have come together and flattened, more data won't help.

When the two lines have come together and flattened, more data won't help.

How to read the chart:

python10 lines
show(pd.DataFrame([
    {"What you see": "The two lines are CLOSE and both LOW", "Diagnosis": "Underfitting (high bias)",
     "Action": "A stronger model, more features. More DATA WON'T HELP"},
    {"What you see": "Train HIGH, validation LOW, a WIDE gap", "Diagnosis": "Overfitting (high variance)",
     "Action": "More data, regularisation, a simpler model"},
    {"What you see": "Validation is still RISING", "Diagnosis": "Too little data",
     "Action": "Collecting more data HELPS"},
    {"What you see": "Validation has FLATTENED", "Diagnosis": "Enough data",
     "Action": "You need a new column or another model"},
]))
What you seeDiagnosisAction
The two lines are CLOSE and both LOWUnderfitting (high bias)A stronger model, more features. More DATA WON'T HELP
Train HIGH, validation LOW, a WIDE gapOverfitting (high variance)More data, regularisation, a simpler model
Validation is still RISINGToo little dataCollecting more data HELPS
Validation has FLATTENEDEnough dataYou need a new column or another model
python11 lines
last_gap = train_scores.mean(axis=1)[-1] - val_scores.mean(axis=1)[-1]
last_rise = val_scores.mean(axis=1)[-1] - val_scores.mean(axis=1)[-3]
print(f"GAP at the last point         : {last_gap:.4f}")
print(f"Latest rise in validation     : {last_rise:+.4f}")
print()
if last_gap > 0.08:
    print("DIAGNOSIS: signs of overfitting -> more data or regularisation")
elif last_rise > 0.01:
    print("DIAGNOSIS: validation is still rising -> more data HELPS")
else:
    print("DIAGNOSIS: enough data, the lines are close -> you need a NEW COLUMN or another model")
Output
GAP at the last point         : 0.0002
Latest rise in validation     : -0.0034

DIAGNOSIS: enough data, the lines are close -> you need a NEW COLUMN or another model

Step 6. A decision and one change

Now you have evidence. From the table you pick one action.

python9 lines
show(pd.DataFrame([
    {"Evidence": "The mistakes cluster in one group", "Action": "A new column for that group, or a separate model"},
    {"Evidence": "The FP and FN means differ", "Action": "Move the threshold (Chapter 9)"},
    {"Evidence": "The model is confidently wrong", "Action": "Check the labels, add the important column"},
    {"Evidence": "Learning curve: validation is rising", "Action": "Collect more data"},
    {"Evidence": "Learning curve: the GAP is wide", "Action": "Regularisation or a simpler model"},
    {"Evidence": "Learning curve: both low and flat", "Action": "A stronger model (tree, boosting)"},
    {"Evidence": "No evidence, everything is normal", "Action": "Stop. The model is good enough (Chapter 40)"},
]))
EvidenceAction
The mistakes cluster in one groupA new column for that group, or a separate model
The FP and FN means differMove the threshold (Chapter 9)
The model is confidently wrongCheck the labels, add the important column
Learning curve: validation is risingCollect more data
Learning curve: the GAP is wideRegularisation or a simpler model
Learning curve: both low and flatA stronger model (tree, boosting)
No evidence, everything is normalStop. The model is good enough (Chapter 40)

What does the evidence say in our case? The learning curve gave its diagnosis. Let's try exactly that action: check whether a linear model isn't enough.

python22 lines
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_score

def with_pipe(model):
    return Pipeline([
        ("prep", ColumnTransformer([
            ("num", Pipeline([("impute", SimpleImputer(strategy="median")),
                              ("scale", StandardScaler())]), num),
            ("cat", OneHotEncoder(handle_unknown="ignore"), ["tuman"]),
        ])),
        ("model", model),
    ])

lr = cross_val_score(with_pipe(LogisticRegression(max_iter=2000)), X, y, cv=5, scoring="f1")
rf = cross_val_score(with_pipe(RandomForestClassifier(n_estimators=200, random_state=42)),
                     X, y, cv=5, scoring="f1")
print(f"Logistic Regression: {lr.mean():.4f} ±{lr.std():.4f}")
print(f"Random Forest      : {rf.mean():.4f} ±{rf.std():.4f}")
print(f"Gap                : {rf.mean() - lr.mean():+.4f}  (spread ±{max(lr.std(), rf.std()):.4f})")
print()
print("Verdict:", "Random Forest is reliably better" if rf.mean() - lr.mean() > max(lr.std(), rf.std())
      else "The gap is within the spread: keep the simpler model")
Output
Logistic Regression: 0.7273 ±0.0118
Random Forest      : 0.6998 ±0.0138
Gap                : -0.0275  (spread ±0.0138)

Verdict: The gap is within the spread: keep the simpler model

ONE change at a time

If you add a new column, change the model and move the threshold all at once, and the result improves, you don't know which one helped.

Next time you apply that "good recipe" on another project and it doesn't work, because you never knew what worked.

One change -> measure -> write it down -> the next one.

The error-analysis notebook

Write down every loop. This notebook is your most valuable document.

python30 lines
notebook = """
+----------------------------------------------------------------------+
|  ERROR-ANALYSIS NOTEBOOK                                             |
+----------------------------------------------------------------------+
|  Date        :                                                       |
|  Model       :                                                       |
|  Metric      : F1 = ______  (CV: ______ +- ______)                   |
|  Baseline    : ______                                                |
+----------------------------------------------------------------------+
|  1. Which group do the mistakes cluster in?                          |
|     ................................................................ |
|  2. How do FP and FN differ?                                         |
|     ................................................................ |
|  3. Is the model confidently wrong? (mistakes at 0.9+)               |
|     ................................................................ |
|  4. Learning curve diagnosis?                                        |
|     [ ] underfitting  [ ] overfitting  [ ] too little data  [ ] ok   |
+----------------------------------------------------------------------+
|  DECISION (ONLY ONE):                                                |
|     [ ] new feature: ..............................................  |
|     [ ] another model: ............................................  |
|     [ ] more data                                                    |
|     [ ] move the threshold: .......................................  |
|     [ ] stop                                                         |
+----------------------------------------------------------------------+
|  Result: ______  (previous: ______)   Gap: ______                    |
|  Was my hypothesis right?  [ ] yes   [ ] NO <- the most valuable     |
+----------------------------------------------------------------------+
"""
print(notebook)
Output
+----------------------------------------------------------------------+
|  ERROR-ANALYSIS NOTEBOOK                                             |
+----------------------------------------------------------------------+
|  Date        :                                                       |
|  Model       :                                                       |
|  Metric      : F1 = ______  (CV: ______ +- ______)                   |
|  Baseline    : ______                                                |
+----------------------------------------------------------------------+
|  1. Which group do the mistakes cluster in?                          |
|     ................................................................ |
|  2. How do FP and FN differ?                                         |
|     ................................................................ |
|  3. Is the model confidently wrong? (mistakes at 0.9+)               |
|     ................................................................ |
|  4. Learning curve diagnosis?                                        |
|     [ ] underfitting  [ ] overfitting  [ ] too little data  [ ] ok   |
+----------------------------------------------------------------------+
|  DECISION (ONLY ONE):                                                |
|     [ ] new feature: ..............................................  |
|     [ ] another model: ............................................  |
|     [ ] more data                                                    |
|     [ ] move the threshold: .......................................  |
|     [ ] stop                                                         |
+----------------------------------------------------------------------+
|  Result: ______  (previous: ______)   Gap: ______                    |
|  Was my hypothesis right?  [ ] yes   [ ] NO <- the most valuable     |
+----------------------------------------------------------------------+

Pay special attention to the last line. A rejected hypothesis is the most valuable entry. It tells you that your assumptions about your data were wrong, and no book can give you that knowledge.

What we learned today

python8 lines
show(pd.DataFrame([
    {"Step": "1. Read the mistakes", "Tool": "nlargest(confidence), with your own eyes"},
    {"Step": "2. Count by group", "Tool": "groupby(column).wrong.mean()"},
    {"Step": "3. Separate FP and FN", "Tool": "Two kinds of mistake, two different causes"},
    {"Step": "4. Confidence distribution", "Tool": "Where the model is unsure, where it is confidently wrong"},
    {"Step": "5. Learning curve", "Tool": "Do you need more data? A MEASUREMENT, not a guess"},
    {"Step": "6. ONE change", "Tool": "An evidence-based decision, then back to step 9"},
]))
StepTool
1. Read the mistakesnlargest(confidence), with your own eyes
2. Count by groupgroupby(column).wrong.mean()
3. Separate FP and FNTwo kinds of mistake, two different causes
4. Confidence distributionWhere the model is unsure, where it is confidently wrong
5. Learning curveDo you need more data? A MEASUREMENT, not a guess
6. ONE changeAn evidence-based decision, then back to step 9

That completes Part 2. You can now split the data, know your baseline, choose the metric, move the threshold, get a reliable score with CV, and find the next step with error analysis.

In the three exercises of Chapters 43–45 you do all of this yourself.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents