Unedited: a first encounter with titanic
Why this chapter is different
In books, the solution is written after the answer was found. That's why it looks like a straight line: load the data → clean → model → 0.87 → done.
Every wrong turn was deleted in editing.
A student reads that and concludes: "this is how knowledgeable people work." Then their own path zigzags, and they feel guilty.
In this chapter the zigzag stays in. I open titanic.csv and write down every decision, wrong ones included, in the order they happened. There are four attempts. Two are wrong. In one of them my hypothesis is flatly rejected.
Why titanic
Because it's the Kaggle dataset that more people start with than any other. Your student will start with it too. And it contains a real trap that I didn't make up.
Attempt 1: I open the file
Step 1 of the protocol (Chapter 1). Three commands.
import pandas as pd df = pd.read_csv(DATA / "titanic.csv") print("shape:", df.shape) print() print(df.dtypes.to_string())
shape: (891, 15) survived int64 pclass int64 sex object age float64 sibsp int64 parch int64 fare float64 embarked object class object who object adult_male bool deck object embark_town object alive object alone bool
891 rows, 15 columns. My first thought: "15 columns isn't many, I'll give all of them to the model."
That thought was wrong. I found out why 10 minutes later.
show(df.head(5))
| survived | pclass | sex | age | sibsp | parch | fare | embarked | class | who | adult_male | deck | embark_town | alive | alone |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 3 | male | 22 | 1 | 0 | 7.25 | S | Third | man | True | NaN | Southampton | no | False |
| 1 | 1 | female | 38 | 1 | 0 | 71.283 | C | First | woman | False | C | Cherbourg | yes | False |
| 1 | 3 | female | 26 | 0 | 0 | 7.925 | S | Third | woman | False | NaN | Southampton | yes | True |
| 1 | 1 | female | 35 | 1 | 0 | 53.1 | S | First | woman | False | C | Southampton | yes | False |
| 0 | 3 | male | 35 | 0 | 0 | 8.05 | S | Third | man | True | NaN | Southampton | no | True |
The target is clear: survived. The step 2 sentence:
"I predict whether a passenger survived from their class, sex, age and ticket."
Step 3: the target is 0/1, so binary classification.
Step 4, I count the target:
print(df.survived.value_counts(normalize=True).sort_index().to_string()) print() print(f"If the model says 'died' to EVERYONE, accuracy = {1 - df.survived.mean():.4f}")
survived 0 0.616162 1 0.383838 If the model says 'died' to EVERYONE, accuracy = 0.6162
0.6162. That's my pass mark. My model has to beat it.
Attempt 2: I give every column to the model
I split, measure the dummy, then pass every column.
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.dummy import DummyClassifier from sklearn.metrics import accuracy_score X, y = df.drop(columns=["survived"]), df["survived"] X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y) def build_pipe(num, cat): return Pipeline([ ("prep", ColumnTransformer([ ("num", Pipeline([("impute", SimpleImputer(strategy="median")), ("scale", StandardScaler())]), num), ("cat", Pipeline([("impute", SimpleImputer(strategy="most_frequent")), ("encode", OneHotEncoder(handle_unknown="ignore"))]), cat), ])), ("model", LogisticRegression(max_iter=3000)), ]) baseline = accuracy_score(y_te, DummyClassifier(strategy="most_frequent") .fit(X_tr[["pclass"]], y_tr).predict(X_te[["pclass"]])) num = X.select_dtypes(include=[np.number, bool]).columns.tolist() cat = [c for c in X.columns if c not in num] print("numeric columns:", num) print("text columns :", cat) q1 = build_pipe(num, cat).fit(X_tr, y_tr) a1 = accuracy_score(y_te, q1.predict(X_te)) print() print(f"BASELINE : {baseline:.4f}") print(f"Attempt 1 : {a1:.4f}")
numeric columns: ['pclass', 'age', 'sibsp', 'parch', 'fare', 'adult_male', 'alone'] text columns : ['sex', 'embarked', 'class', 'who', 'deck', 'embark_town', 'alive'] BASELINE : 0.6157 Attempt 1 : 1.0000
I saw 1.0000 on the screen.
I'll be honest: for the first half second I was happy. Then my stomach tightened. Because I wrote it myself in Chapter 1: a perfect result is not a celebration, it's a signal.
891 passengers. One hundred percent. That can't be.
I hunt for the trap
Which column is telling it the answer? I group each text column by the target.
for col in cat: table = df.groupby(col, dropna=False).survived.agg(["count", "mean"]) exact = ((table["mean"] == 0) | (table["mean"] == 1)).all() flag = " <-- SEPARATES THE TARGET COMPLETELY" if exact else "" print(f"{col}:{flag}") print(table.round(3).to_string()) print()
sex:
count mean
sex
female 314 0.742
male 577 0.189
embarked:
count mean
embarked
C 168 0.554
Q 77 0.390
S 644 0.337
NaN 2 1.000
class:
count mean
class
First 216 0.630
Second 184 0.473
Third 491 0.242
who:
count mean
who
child 83 0.590
man 537 0.164
woman 271 0.756
deck:
count mean
deck
A 15 0.467
B 47 0.745
C 59 0.593
D 33 0.758
E 32 0.750
F 13 0.615
G 4 0.500
NaN 688 0.299
embark_town:
count mean
embark_town
Cherbourg 168 0.554
Queenstown 77 0.390
Southampton 644 0.337
NaN 2 1.000
alive: <-- SEPARATES THE TARGET COMPLETELY
count mean
alive
no 549 0.0
yes 342 1.0
There it is. The alive column.
alive = "no" → the mean of survived is 0.0. alive = "yes" → 1.0. No mixing at all.
print("values of the alive column:", df.alive.unique().tolist()) print("does 'alive == yes' MATCH 'survived == 1':", bool(((df.alive == "yes") == (df.survived == 1)).all()))
values of the alive column: ['no', 'yes'] does 'alive == yes' MATCH 'survived == 1': True
alive is the text form of survived. The target itself, under another name.
I didn't see it because I glanced at df.head() in a hurry and didn't read all 15 columns. That's my mistake, not the dataset's fault.
In real datasets the target appears twice
This is seaborn's version of titanic, and it wasn't done on purpose: a readable form of survived was simply added. But in practice it happens all the time: status and status_text, is_churned and churn_date, rented and fee.
The rule: group every column by the target. If one of them separates the target cleanly, it isn't a feature.
Attempt 3: I drop alive
X2_tr, X2_te = X_tr.drop(columns=["alive"]), X_te.drop(columns=["alive"]) num2 = [c for c in num if c != "alive"] cat2 = [c for c in cat if c != "alive"] q2 = build_pipe(num2, cat2).fit(X2_tr, y_tr) a2 = accuracy_score(y_te, q2.predict(X2_te)) print(f"BASELINE : {baseline:.4f}") print(f"Attempt 1 : {a1:.4f} (leakage)") print(f"Attempt 2 : {a2:.4f} (alive dropped)")
BASELINE : 0.6157 Attempt 1 : 1.0000 (leakage) Attempt 2 : 0.8172 (alive dropped)
0.8172. Well above the baseline. Now this is a real number.
I look at the missing values
missing = df.isna().sum() missing = missing[missing > 0].sort_values(ascending=False) for col, n in missing.items(): print(f" {col:14s} {n:3d} missing ({n/len(df):.1%})")
deck 688 missing (77.2%) age 177 missing (19.9%) embarked 2 missing (0.2%) embark_town 2 missing (0.2%)
deck: 688 missing, which is 77.2%. Three passengers out of four have no value here.
I had filled it with SimpleImputer(strategy="most_frequent"). In other words, I wrote in a "guessed" deck for 688 passengers. That's made-up data.
My hypothesis: this column adds noise to the model. If I drop it, the result should improve.
X3_tr, X3_te = X2_tr.drop(columns=["deck"]), X2_te.drop(columns=["deck"]) num3 = [c for c in num2 if c != "deck"] cat3 = [c for c in cat2 if c != "deck"] q3 = build_pipe(num3, cat3).fit(X3_tr, y_tr) a3 = accuracy_score(y_te, q3.predict(X3_te)) print(f"Attempt 2 : {a2:.4f} (deck kept, 77% filled in)") print(f"Attempt 3 : {a3:.4f} (deck dropped)") print(f"Change : {a3 - a2:+.4f}")
Attempt 2 : 0.8172 (deck kept, 77% filled in) Attempt 3 : 0.8246 (deck dropped) Change : +0.0075
0.8246. It improved, by removing the column.
Students find this strange: we had less data and got a better result. The reason is simple: 77% made-up values aren't information, they're noise.
Attempt 4: my hypothesis, and its rejection
Now I read the remaining columns carefully. And a suspicion appears: aren't some of them computed from other columns?
I check.
print("class ~ pclass:") print(df.groupby("pclass")["class"].unique().to_string()) print() print("adult_male == (sex=='male' and age>=18):", bool((df.adult_male == ((df.sex == "male") & (df.age >= 18))).all())) print("alone == (sibsp + parch == 0) :", bool((df.alone == ((df.sibsp + df.parch) == 0)).all())) print("who values :", df.who.unique().tolist()) print("embark_town ~ embarked :", df.groupby("embarked")["embark_town"].unique().to_dict())
class ~ pclass:
pclass
1 [First]
2 [Second]
3 [Third]
adult_male == (sex=='male' and age>=18): False
alone == (sibsp + parch == 0) : True
who values : ['man', 'woman', 'child']
embark_town ~ embarked : {'C': array(['Cherbourg'], dtype=object), 'Q': array(['Queenstown'], dtype=object), 'S': array(['Southampton'], dtype=object)}
All confirmed. Five columns are not new information:
show(pd.DataFrame([ {"Column": "class", "Computed from": "pclass (1→First, 2→Second, 3→Third)"}, {"Column": "adult_male", "Computed from": "sex and age"}, {"Column": "alone", "Computed from": "sibsp + parch"}, {"Column": "who", "Computed from": "sex and age (man/woman/child)"}, {"Column": "embark_town", "Computed from": "embarked (S→Southampton, ...)"}, ]))
| Column | Computed from |
|---|---|
| class | pclass (1→First, 2→Second, 3→Third) |
| adult_male | sex and age |
| alone | sibsp + parch |
| who | sex and age (man/woman/child) |
| embark_town | embarked (S→Southampton, ...) |
My hypothesis: these five are repetition. If I drop them, the model gets cleaner and the result improves or stays the same.
I keep only seven "honest, non-repeating" columns: pclass, sex, age, sibsp, parch, fare, embarked.
honest = ["pclass", "sex", "age", "sibsp", "parch", "fare", "embarked"] num4 = ["pclass", "age", "sibsp", "parch", "fare"] cat4 = ["sex", "embarked"] q4 = build_pipe(num4, cat4).fit(X_tr[honest], y_tr) a4 = accuracy_score(y_te, q4.predict(X_te[honest])) print(f"Attempt 3 : {a3:.4f} (13 columns)") print(f"Attempt 4 : {a4:.4f} (7 'honest' columns)") print(f"Change : {a4 - a3:+.4f}")
Attempt 3 : 0.8246 (13 columns) Attempt 4 : 0.7985 (7 'honest' columns) Change : -0.0261
0.7985. It got worse.
My hypothesis was rejected. I thought "repetition hurts", and the measurement didn't confirm it.
Why? Because even though the who column is computed from sex and age, it presents the information differently: the "child" category hands a linear model the rule "age < 16" for free. Logistic regression can't find a cut-off like that by itself: it only sees linear relationships.
A repeating column isn't always harmful
This is my most useful mistake. In statistics, multicollinearity makes coefficients unstable, and that's true. But for prediction accuracy it often doesn't matter, and sometimes it helps: a computed column gives the model a shape it couldn't find on its own.
So: don't drop a column because you think it's redundant. Drop it because you measured it.
The most important part: are these differences real?
I have four numbers: 0.8172, 0.8246, 0.7985. The gap between them reaches 0.026.
But they all come from one train/test split. Change random_state=42 to another number and the numbers change.
Let me check that, with cross-validation.
from sklearn.model_selection import cross_val_score cv3 = cross_val_score(build_pipe(num3, cat3), X.drop(columns=["alive", "deck"]), y, cv=5) cv4 = cross_val_score(build_pipe(num4, cat4), X[honest], y, cv=5) print("Attempt 3 (13 columns):") print(" folds:", cv3.round(4).tolist()) print(f" mean: {cv3.mean():.4f} ±{cv3.std():.4f}") print() print("Attempt 4 (7 columns):") print(" folds:", cv4.round(4).tolist()) print(f" mean: {cv4.mean():.4f} ±{cv4.std():.4f}") print() print(f"Gap between the means: {abs(cv3.mean() - cv4.mean()):.4f}") print(f"Spread of the folds : ±{max(cv3.std(), cv4.std()):.4f}")
Attempt 3 (13 columns): folds: [0.8212, 0.7978, 0.8034, 0.8034, 0.8652] mean: 0.8182 ±0.0248 Attempt 4 (7 columns): folds: [0.7765, 0.7865, 0.7809, 0.7697, 0.8258] mean: 0.7879 ±0.0198 Gap between the means: 0.0303 Spread of the folds : ±0.0248
This is the most important conclusion of the chapter. And here I got it wrong once more.
When I ran cross-validation, I expected "the gap will come out within the noise". In other words I thought the gap between 13 columns and 7 columns would turn out to be chance.
The measurement didn't confirm that either. Look:
- The gap between the means: 0.0303 (0.8182 vs 0.7879)
- The spread of the folds: ±0.0248
The gap is bigger than the spread. So 13 columns really are better: my hypothesis from attempt 4 was wrong twice.
But note that this isn't "reliable proof". Let's put the folds side by side:
- Attempt 3 folds: 0.8212, 0.7978, 0.8034, 0.8034, 0.8652
- Attempt 4 folds: 0.7765, 0.7865, 0.7809, 0.7697, 0.8258
Attempt 4's best fold (0.8258) is higher than attempt 3's worst fold (0.7978). In other words, the ranges overlap.
So if I had picked one unlucky split and measured attempt 4 at 0.8258 and attempt 3 at 0.7978, I would have drawn the opposite conclusion. The mean points in the right direction, but one split isn't enough to reach a verdict.
Don't trust a single split
While writing this chapter I went the wrong way twice, in opposite directions: first I thought the gap mattered, then I thought it was noise. Both were untested assumptions.
The rule: when you compare two models, you use cross-validation, and you look not just at the mean but at the range of the folds. If the ranges overlap, you can't say "clearly better", even if there's a visible gap in the means. Chapter 10 is about this.
The whole path in one table
show(pd.DataFrame([ {"Attempt": "0. Baseline ('died' for everyone)", "Accuracy": f"{baseline:.4f}", "Conclusion": "The pass mark"}, {"Attempt": "1. All 14 columns", "Accuracy": f"{a1:.4f}", "Conclusion": "LEAKAGE: alive = the target"}, {"Attempt": "2. alive dropped", "Accuracy": f"{a2:.4f}", "Conclusion": "The first honest number"}, {"Attempt": "3. deck dropped too (77% empty)", "Accuracy": f"{a3:.4f}", "Conclusion": "Fewer columns, a better result"}, {"Attempt": "4. Only 7 'honest' columns", "Accuracy": f"{a4:.4f}", "Conclusion": "My hypothesis was REJECTED"}, {"Attempt": "CV: attempt 3", "Accuracy": f"{cv3.mean():.4f} ±{cv3.std():.4f}", "Conclusion": "The real estimate: 13 columns lead"}, {"Attempt": "CV: attempt 4", "Accuracy": f"{cv4.mean():.4f} ±{cv4.std():.4f}", "Conclusion": "The ranges overlap: confidence is limited"}, ]))
| Attempt | Accuracy | Conclusion |
|---|---|---|
| 0. Baseline ('died' for everyone) | 0.6157 | The pass mark |
| 1. All 14 columns | 1.0000 | LEAKAGE: alive = the target |
| 2. alive dropped | 0.8172 | The first honest number |
| 3. deck dropped too (77% empty) | 0.8246 | Fewer columns, a better result |
| 4. Only 7 'honest' columns | 0.7985 | My hypothesis was REJECTED |
| CV: attempt 3 | 0.8182 ±0.0248 | The real estimate: 13 columns lead |
| CV: attempt 4 | 0.7879 ±0.0198 | The ranges overlap: confidence is limited |
What we learned
When I opened this dataset I got it wrong twice, and one of my hypotheses was rejected:
show(pd.DataFrame([ {"Mistake": "I gave every column to the model", "Consequence": "1.0000: a false result", "Lesson": "Group every column by the target"}, {"Mistake": "I filled a 77%-empty column", "Consequence": "The result was lower", "Lesson": "A mostly empty column isn't filled, it's dropped"}, {"Mistake": "I dropped the repeating columns", "Consequence": "The result got worse", "Lesson": "Drop 'redundant' columns by measuring, not by thinking"}, {"Mistake": "I trusted one split", "Consequence": "The gap turned out real, but the ranges overlap", "Lesson": "CV: look at the fold range, NOT just the mean"}, ]))
| Mistake | Consequence | Lesson |
|---|---|---|
| I gave every column to the model | 1.0000: a false result | Group every column by the target |
| I filled a 77%-empty column | The result was lower | A mostly empty column isn't filled, it's dropped |
| I dropped the repeating columns | The result got worse | Drop 'redundant' columns by measuring, not by thinking |
| I trusted one split | The gap turned out real, but the ranges overlap | CV: look at the fold range, NOT just the mean |
If I had written this chapter "cleanly", it would look like this:
Load titanic.csv, drop
aliveanddeck, build a Pipeline, accuracy 0.8246. Done.
Four lines. And you'd have learned nothing from it, because you'd have no idea how to find alive.
What to tell a student
If your path zigzags, you're working correctly. The zigzag is the work itself, not a flaw in it.
The difference is that an experienced person spots the zigzag sooner and, after every turn, writes down what they learned.
Homework
Walk this protocol yourself on three datasets (Chapters 43, 44, 45). For each one, write in your notebook:
- What is the target? (one sentence)
- What is my baseline?
- Which columns did I drop, and why?
- What is my first honest result?
- Which of my hypotheses was rejected?
The fifth question matters most. If not a single hypothesis of yours was rejected, you didn't test anything.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.