Skip to content

Unedited: a first encounter with titanic

Chapter 42 15 code blocks 3595 words

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

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.

python6 lines
import pandas as pd

df = pd.read_csv(DATA / "titanic.csv")
print("shape:", df.shape)
print()
print(df.dtypes.to_string())
Output
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.

python1 lines
show(df.head(5))
survivedpclasssexagesibspparchfareembarkedclasswhoadult_maledeckembark_townalivealone
03male22107.25SThirdmanTrueNaNSouthamptonnoFalse
11female381071.283CFirstwomanFalseCCherbourgyesFalse
13female26007.925SThirdwomanFalseNaNSouthamptonyesTrue
11female351053.1SFirstwomanFalseCSouthamptonyesFalse
03male35008.05SThirdmanTrueNaNSouthamptonnoTrue

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:

python3 lines
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}")
Output
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.

python37 lines
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}")
Output
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.

python7 lines
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()
Output
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.

python3 lines
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()))
Output
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

python9 lines
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)")
Output
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

python4 lines
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%})")
Output
  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.

python9 lines
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}")
Output
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.

python10 lines
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())
Output
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:

python7 lines
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, ...)"},
]))
ColumnComputed from
classpclass (1→First, 2→Second, 3→Third)
adult_malesex and age
alonesibsp + parch
whosex and age (man/woman/child)
embark_townembarked (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.

python9 lines
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}")
Output
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.

python15 lines
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}")
Output
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

python9 lines
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"},
]))
AttemptAccuracyConclusion
0. Baseline ('died' for everyone)0.6157The pass mark
1. All 14 columns1.0000LEAKAGE: alive = the target
2. alive dropped0.8172The first honest number
3. deck dropped too (77% empty)0.8246Fewer columns, a better result
4. Only 7 'honest' columns0.7985My hypothesis was REJECTED
CV: attempt 30.8182 ±0.0248The real estimate: 13 columns lead
CV: attempt 40.7879 ±0.0198The 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:

python10 lines
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"},
]))
MistakeConsequenceLesson
I gave every column to the model1.0000: a false resultGroup every column by the target
I filled a 77%-empty columnThe result was lowerA mostly empty column isn't filled, it's dropped
I dropped the repeating columnsThe result got worseDrop 'redundant' columns by measuring, not by thinking
I trusted one splitThe gap turned out real, but the ranges overlapCV: 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 alive and deck, 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:

  1. What is the target? (one sentence)
  2. What is my baseline?
  3. Which columns did I drop, and why?
  4. What is my first honest result?
  5. 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.

Write on Telegram Contents