Exercise 3: hidden leakage
Why this exercise
In Chapter 1 I showed you the agent_komissiyasi column and said "this is leakage". That was easy: its name and its meaning were both obvious.
In this exercise I tell you nothing.
The file contains leakage. It looks like an ID column, and you know the rule "drop IDs", so you'll drop it. But the question is different: why is it leakage, and how will you find this kind of trap next time?
The file also has a second suspicious column. It is not leakage: it's a real signal. Telling the two apart is the main work of this exercise.
Column names
This synthetic student dataset has Uzbek column names. talaba_id student id, kirish_bali entrance score, davomat_foiz attendance %, uy_vazifa_bali homework score, ishlaydi has a job (0/1), masofa_km distance from home in km, oxirgi_tolov_oyi the last month the fee was paid, diplom_seriya diploma serial number. The target bitirdi means graduated.
Getting to know the data
import pandas as pd td = pd.read_csv(DATA / "talaba_bitirdi.csv", keep_default_na=False, na_values=[""]) print("shape:", td.shape) print() print(td.dtypes.to_string()) print() show(td.head(8))
shape: (900, 9) talaba_id object kirish_bali int64 davomat_foiz int64 uy_vazifa_bali int64 ishlaydi int64 masofa_km float64 oxirgi_tolov_oyi int64 diplom_seriya object bitirdi int64
| talaba_id | kirish_bali | davomat_foiz | uy_vazifa_bali | ishlaydi | masofa_km | oxirgi_tolov_oyi | diplom_seriya | bitirdi |
|---|---|---|---|---|---|---|---|---|
| T-50000 | 62 | 93 | 82 | 0 | 3.6 | 10 | D-200000 | 1 |
| T-50001 | 66 | 100 | 69 | 0 | 7.2 | 10 | D-200001 | 1 |
| T-50002 | 58 | 100 | 42 | 1 | 2.4 | 9 | D-200002 | 1 |
| T-50003 | 50 | 75 | 100 | 1 | 16 | 10 | D-200003 | 1 |
| T-50004 | 56 | 88 | 55 | 0 | 5.7 | 8 | D-200004 | 1 |
| T-50005 | 48 | 80 | 74 | 0 | 7.5 | 12 | D-200005 | 1 |
| T-50006 | 63 | 79 | 74 | 0 | 25.7 | 8 | D-200006 | 1 |
| T-50007 | 81 | 98 | 95 | 1 | 6.6 | 14 | D-200007 | 1 |
print("Target:") print(td.bitirdi.value_counts(normalize=True).sort_index().round(4).to_string()) print() print(f"BASELINE (saying '1' to everyone): {td.bitirdi.mean():.4f}") print() missing = td.isna().sum() print("Missing values:") print(missing[missing > 0].to_string() if (missing > 0).any() else " none")
Target: bitirdi 0 0.1144 1 0.8856 BASELINE (saying '1' to everyone): 0.8856 Missing values: diplom_seriya 103
The task
Walk the 12 steps from Chapter 1 yourself. Nobody shows you the way. Write the following in your notebook:
import pandas as pd show(pd.DataFrame([ {"#": 1, "Answer to write down": "How many rows, how many columns"}, {"#": 2, "Answer to write down": "\"I predict ___ from ___\": one sentence"}, {"#": 3, "Answer to write down": "Task type: regression / binary / multiclass"}, {"#": 4, "Answer to write down": "The target ratio and the BASELINE number"}, {"#": 5, "Answer to write down": "Did you split? Do you need stratify?"}, {"#": 6, "Answer to write down": "The dummy result"}, {"#": 7, "Answer to write down": "Which columns you dropped, and WHY"}, {"#": 8, "Answer to write down": "What you did about missing values and types"}, {"#": 9, "Answer to write down": "Your first honest result"}, {"#": 10, "Answer to write down": "Which metric you chose, and WHY"}, {"#": 11, "Answer to write down": "Which group the mistakes cluster in"}, {"#": 12, "Answer to write down": "Which ONE change you made, and the result"}, ]))
| # | Answer to write down |
|---|---|
| 1 | How many rows, how many columns |
| 2 | "I predict ___ from ___": one sentence |
| 3 | Task type: regression / binary / multiclass |
| 4 | The target ratio and the BASELINE number |
| 5 | Did you split? Do you need stratify? |
| 6 | The dummy result |
| 7 | Which columns you dropped, and WHY |
| 8 | What you did about missing values and types |
| 9 | Your first honest result |
| 10 | Which metric you chose, and WHY |
| 11 | Which group the mistakes cluster in |
| 12 | Which ONE change you made, and the result |
And most important, question 13:
Which of your hypotheses was REJECTED?
If not a single one was rejected, you didn't test anything. Go back.
The one hint
I won't help any further. Just one thing:
Ask this question of every column
"Will I have this column AT PREDICTION TIME?"
A new student has just enrolled. Which columns are known about them right now? Which only appear later?
This question finds leakage without looking at the column's name.
The baseline is 0.8852: accuracy will fool you
The target is 88.6% "graduated". So a model that says "graduated to everyone" gets ~0.885.
If your model scores 0.89, that means almost nothing. You need another metric. Which one? Reread Chapters 7 and 9.
Checking yourself
Work it out yourself first. Really. Then look below.
from sklearn.model_selection import train_test_split from sklearn.linear_model import LogisticRegression from sklearn.dummy import DummyClassifier from sklearn.metrics import accuracy_score, f1_score, recall_score, precision_score honest = ["kirish_bali", "davomat_foiz", "uy_vazifa_bali", "ishlaydi", "masofa_km"] y = td["bitirdi"] tr, te, ytr, yte = train_test_split(td, y, test_size=0.3, random_state=42, stratify=y) baseline = accuracy_score(yte, DummyClassifier(strategy="most_frequent").fit(tr[honest], ytr).predict(te[honest])) m1 = LogisticRegression(max_iter=2000).fit(tr[honest], ytr) b1 = m1.predict(te[honest]) print(f"BASELINE : {baseline:.4f}") print(f"5 honest columns: accuracy : {accuracy_score(yte, b1):.4f} (gap {accuracy_score(yte, b1)-baseline:+.4f})") print(f"5 honest columns: F1 : {f1_score(yte, b1):.4f}") print(f"5 honest columns: recall : {recall_score(yte, b1):.4f}")
BASELINE : 0.8852 5 honest columns: accuracy : 0.8963 (gap +0.0111) 5 honest columns: F1 : 0.9447 5 honest columns: recall : 1.0000
There's the first lesson: the model is only slightly above the baseline. Accuracy is almost useless here.
Now let's open the trap.
td2 = td.copy() td2["has_diploma"] = td2.diplom_seriya.notna().astype(int) tr2, te2 = td2.loc[tr.index], td2.loc[te.index] m2 = LogisticRegression(max_iter=2000).fit(tr2[honest + ["has_diploma"]], ytr) b2 = m2.predict(te2[honest + ["has_diploma"]]) print(f"+ diplom_seriya (present/absent): accuracy {accuracy_score(yte, b2):.4f} <-- PERFECT") print() print("Why? Let's check:") print(pd.crosstab(td2.has_diploma, td2.bitirdi).to_string())
+ diplom_seriya (present/absent): accuracy 1.0000 <-- PERFECT Why? Let's check: bitirdi 0 1 has_diploma 0 103 0 1 0 797
There it is. diplom_seriya exists only for the students who graduated.
Notice: the problem is not that it's an ID. The problem is that whether it's empty or not tells you the target. Whether you feed it to OneHotEncoder or apply notna(), the result is the same lie.
And logically it's obvious too: a diploma serial number is issued AFTER graduating. At prediction time it doesn't exist.
Now the second suspicious column.
m3 = LogisticRegression(max_iter=2000).fit(tr[honest + ["oxirgi_tolov_oyi"]], ytr) b3 = m3.predict(te[honest + ["oxirgi_tolov_oyi"]]) print(f"+ oxirgi_tolov_oyi: accuracy {accuracy_score(yte, b3):.4f} F1 {f1_score(yte, b3):.4f}") print() print("Means by group:") print(td.groupby("bitirdi").oxirgi_tolov_oyi.agg(["mean", "min", "max"]).round(2).to_string())
+ oxirgi_tolov_oyi: accuracy 0.9037 F1 0.9465
Means by group:
mean min max
bitirdi
0 7.71 2 14
1 11.05 6 14
This column is related to the target too: those who graduated paid for more months. But it is not leakage. See the difference:
show(pd.DataFrame([ {"Column": "diplom_seriya", "Related to the target": "Completely (100%)", "Available at prediction time": "NO: issued after graduating", "Verdict": "LEAKAGE: drop it"}, {"Column": "oxirgi_tolov_oyi", "Related to the target": "Partly", "Available at prediction time": "YES: known every month", "Verdict": "A real signal: use it (but carefully)"}, ]))
| Column | Related to the target | Available at prediction time | Verdict |
|---|---|---|---|
| diplom_seriya | Completely (100%) | NO: issued after graduating | LEAKAGE: drop it |
| oxirgi_tolov_oyi | Partly | YES: known every month | A real signal: use it (but carefully) |
"Related to the target" and "leakage" are NOT the same thing
If you adopt the rule "drop every column strongly related to the target", you'll throw away your most useful features. The whole point of building a model is to find columns like that.
The question that separates leakage is not the strength of the relationship. The question is time: does this information exist at prediction time?
oxirgi_tolov_oyi has a point to be careful about: if you predict at the end of the semester, it's honest. If you want to predict on a student's first day, it's leakage too, because there's no payment history yet.
So leakage depends on the question, not on the column.
After the three exercises
show(pd.DataFrame([ {"Exercise": "43. penguins", "What it tests": "A broken pattern: from binary to multiclass", "Main lesson": "average='macro' and a 3x3 matrix"}, {"Exercise": "44. dirty sales", "What it tests": "Cleaning is 80% of the work", "Main lesson": "Cyrillic/Latin, text-as-number, duplicates; and the honest 'no model is possible'"}, {"Exercise": "45. hidden leakage", "What it tests": "Finding the trap yourself", "Main lesson": "TIME separates leakage, not the strength of the relationship"}, ]))
| Exercise | What it tests | Main lesson |
|---|---|---|
| 43. penguins | A broken pattern: from binary to multiclass | average='macro' and a 3x3 matrix |
| 44. dirty sales | Cleaning is 80% of the work | Cyrillic/Latin, text-as-number, duplicates; and the honest 'no model is possible' |
| 45. hidden leakage | Finding the trap yourself | TIME separates leakage, not the strength of the relationship |
If you walked all three exercises yourself, you're now someone who doesn't freeze in front of a new dataset.
Remember the protocol. Measure the baseline first. Be suspicious of a perfect result. Read the mistakes. And write down every rejected hypothesis: they are your real knowledge.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.