33. ML End-to-End Worked Project - Titanic
ML End-to-End Worked Project - Titanic
This is the project that ties everything together. We will take a real (but small) dataset and build a complete ML pipeline from scratch, using almost every concept from the previous tutorials.
By the end you will have:
- Explored a real dataset.
- Cleaned messy data.
- Engineered useful features.
- Compared multiple models.
- Tuned the best one.
- Evaluated honestly on a held-out test set.
This is the workflow you will use again and again in real ML work.
The problem
The Titanic dataset contains passenger information from the 1912 disaster. For each passenger we know things like age, sex, ticket class, fare, and whether they survived.
Goal: Build a model that predicts whether a passenger survived.
This is a binary classification problem. Survived = 1, did not survive = 0.
Step 1. Load and explore the data
We use the version built into seaborn so you do not need to download anything.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
df = sns.load_dataset("titanic")
print(df.shape) # (891, 15)
print(df.head())
print(df.info())
print(df.describe())
You will see columns like:
survived(0 or 1, the target)pclass(1, 2, or 3 - ticket class)sex,age,sibsp,parch,fare,embarked,class,who,adult_male,alone, etc.
Some are redundant (e.g. pclass and class). Some have missing values (age, deck, embarked).
Check class balance
print(df["survived"].value_counts(normalize=True))
# 0 ~61%
# 1 ~39%
Slightly imbalanced but not dramatically. Accuracy will be a reasonable starting metric.
Look at missing values
print(df.isnull().sum().sort_values(ascending=False))
You will see deck has many missing values (drop it), age has some missing values (impute it), embarked has two missing (impute or drop).
Step 2. Clean and engineer features
Pick the columns we want
df = df[["survived", "pclass", "sex", "age", "sibsp", "parch", "fare", "embarked"]].copy()
We dropped redundant columns (class, who, adult_male, alone, embark_town, alive, adult_male) and the mostly-missing deck.
Engineer some new features
# Family size = siblings/spouses + parents/children + self
df["family_size"] = df["sibsp"] + df["parch"] + 1
# Is the passenger travelling alone?
df["is_alone"] = (df["family_size"] == 1).astype(int)
Feature engineering like this often beats fancy models. Always look for combinations that have real-world meaning.
Handle missing values
# Age: fill with median
df["age"].fillna(df["age"].median(), inplace=True)
# Embarked: fill with most common value
df["embarked"].fillna(df["embarked"].mode()[0], inplace=True)
# Fare: just in case
df["fare"].fillna(df["fare"].median(), inplace=True)
For a more rigorous project, you would impute inside a Pipeline so it does not leak from test data. We will do that with SimpleImputer below.
Encode categorical variables
df = pd.get_dummies(df, columns=["sex", "embarked"], drop_first=True)
print(df.columns)
sex and embarked are categorical. pd.get_dummies turns them into 0/1 columns.
Step 3. Split into train and test
This must happen before any further preprocessing fitting.
from sklearn.model_selection import train_test_split
X = df.drop(columns=["survived"])
y = df["survived"]
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.2,
random_state=42,
stratify=y, # keep class balance the same in train and test
)
print(X_train.shape, X_test.shape)
Notice stratify=y. With imbalanced data, this ensures both train and test have similar class proportions.
Step 4. Build a baseline pipeline
Always start with the simplest reasonable model. A Logistic Regression baseline tells us how hard the problem is.
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score
baseline = Pipeline([
("scaler", StandardScaler()),
("model", LogisticRegression(max_iter=1000)),
])
cv_scores = cross_val_score(baseline, X_train, y_train, cv=5, scoring="accuracy")
print(f"Baseline CV accuracy: {cv_scores.mean():.3f} (+/- {cv_scores.std():.3f})")
You should see somewhere around 0.79-0.81. That is the bar to beat.
Step 5. Compare several models
Now try a few different families and see which looks promising.
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier
models = {
"Logistic Regression": LogisticRegression(max_iter=1000),
"Decision Tree": DecisionTreeClassifier(max_depth=5, random_state=42),
"Random Forest": RandomForestClassifier(n_estimators=200, random_state=42),
"Gradient Boosting": GradientBoostingClassifier(random_state=42),
"KNN (k=5)": KNeighborsClassifier(n_neighbors=5),
}
results = {}
for name, model in models.items():
pipe = Pipeline([("scaler", StandardScaler()), ("model", model)])
scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring="accuracy")
results[name] = scores.mean()
print(f"{name:25s} {scores.mean():.3f} (+/- {scores.std():.3f})")
You will typically see Gradient Boosting and Random Forest leading the pack, around 0.83-0.84.
Step 6. Tune the best model
Once you pick a winning algorithm, tune its hyperparameters.
from sklearn.model_selection import GridSearchCV
pipe = Pipeline([
("scaler", StandardScaler()),
("model", GradientBoostingClassifier(random_state=42)),
])
param_grid = {
"model__n_estimators": [100, 200, 300],
"model__learning_rate": [0.05, 0.1, 0.2],
"model__max_depth": [2, 3, 4],
}
grid = GridSearchCV(
pipe,
param_grid,
cv=5,
scoring="accuracy",
n_jobs=-1,
verbose=1,
)
grid.fit(X_train, y_train)
print("Best params:", grid.best_params_)
print("Best CV accuracy:", grid.best_score_)
This fits 27 candidates x 5 folds = 135 models. With n_jobs=-1 it runs in parallel.
Step 7. Final evaluation on the held-out test set
This is the moment of truth. We have not touched X_test and y_test yet.
from sklearn.metrics import (
accuracy_score,
classification_report,
confusion_matrix,
roc_auc_score,
)
best_model = grid.best_estimator_
y_pred = best_model.predict(X_test)
y_proba = best_model.predict_proba(X_test)[:, 1]
print(f"Test accuracy: {accuracy_score(y_test, y_pred):.3f}")
print(f"Test ROC-AUC: {roc_auc_score(y_test, y_proba):.3f}")
print("\nConfusion matrix:")
print(confusion_matrix(y_test, y_pred))
print("\nClassification report:")
print(classification_report(y_test, y_pred, target_names=["Died", "Survived"]))
Typical result is around 0.82-0.85 accuracy with ROC-AUC around 0.86-0.88. The confusion matrix tells you exactly what kinds of mistakes the model makes.
Step 8. Look at feature importance
Gradient Boosting gives you free feature importance scores. Use them to understand what drove the predictions.
import pandas as pd
model = best_model.named_steps["model"]
importance = pd.Series(model.feature_importances_, index=X_train.columns).sort_values(ascending=False)
plt.figure(figsize=(8, 5))
sns.barplot(x=importance.values, y=importance.index, color="gray")
plt.title("Feature Importance (Gradient Boosting)")
plt.xlabel("Importance")
plt.tight_layout()
plt.show()
You will almost certainly see sex_male and fare at the top, followed by age, pclass, and family_size. The model learned what we already knew historically: women, the rich, and children had higher survival rates.
This is what good ML looks like: numbers that align with reality.
Step 9. Sanity check with predictions
It is always a good idea to spot-check actual predictions.
sample = X_test.head(5)
print(sample)
print("\nPredicted probabilities of survival:")
print(best_model.predict_proba(sample)[:, 1].round(2))
print("\nActual:")
print(y_test.head(5).values)
If the model says “5% survival” for an actual non-survivor, great. If it says “5% survival” for an actual survivor, look at the row and see why the model was wrong. Was that person old? Travelling third class? Now you understand both your data and your model.
The full pipeline as one block
Here is the entire project in one paste-ready snippet.
import numpy as np
import pandas as pd
import seaborn as sns
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score, classification_report, roc_auc_score
# 1. Load
df = sns.load_dataset("titanic")
df = df[["survived","pclass","sex","age","sibsp","parch","fare","embarked"]].copy()
# 2. Feature engineering
df["family_size"] = df["sibsp"] + df["parch"] + 1
df["is_alone"] = (df["family_size"] == 1).astype(int)
# 3. Missing values
df["age"].fillna(df["age"].median(), inplace=True)
df["embarked"].fillna(df["embarked"].mode()[0], inplace=True)
df["fare"].fillna(df["fare"].median(), inplace=True)
# 4. Encode
df = pd.get_dummies(df, columns=["sex","embarked"], drop_first=True)
# 5. Split
X, y = df.drop(columns="survived"), df["survived"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
# 6. Pipeline + grid search
pipe = Pipeline([
("scaler", StandardScaler()),
("model", GradientBoostingClassifier(random_state=42)),
])
param_grid = {
"model__n_estimators": [100, 200, 300],
"model__learning_rate": [0.05, 0.1, 0.2],
"model__max_depth": [2, 3, 4],
}
grid = GridSearchCV(pipe, param_grid, cv=5, scoring="accuracy", n_jobs=-1)
grid.fit(X_train, y_train)
print("Best:", grid.best_params_, grid.best_score_)
# 7. Final evaluation
best = grid.best_estimator_
y_pred = best.predict(X_test)
y_proba = best.predict_proba(X_test)[:, 1]
print(f"Test acc: {accuracy_score(y_test, y_pred):.3f}")
print(f"Test ROC-AUC: {roc_auc_score(y_test, y_proba):.3f}")
print(classification_report(y_test, y_pred, target_names=["Died","Survived"]))
What you just did
You ran the entire classical ML pipeline:
- Loaded and explored real data (with missing values and categorical features).
- Engineered features from your domain understanding.
- Cleaned the data and handled missing values.
- Split into train and test, stratified.
- Built a Pipeline with scaling and a classifier to prevent leakage.
- Compared several models with cross validation.
- Tuned the best one with GridSearchCV.
- Evaluated on a held-out test set with multiple metrics.
- Inspected feature importance to make sure the model learned sensible things.
This workflow generalizes to almost any tabular ML problem. Take any new dataset, drop in your own columns, and 80% of the steps stay the same.
Common improvements you can try
- Stratified K-fold instead of plain K-fold for imbalanced data.
- Smarter imputation. Use
SimpleImputerinside the Pipeline so test data is never leaked. - Extract title from name (Mr, Mrs, Miss, Master, Dr) - often a strong predictor.
- Try XGBoost / LightGBM instead of GradientBoostingClassifier. They are usually faster and often more accurate.
- Try CatBoost if you have many categorical features.
- Try a simple neural net with
MLPClassifier. On this problem it will not help much, but it is good practice. - Calibrate probabilities with
CalibratedClassifierCVif you need actual probability estimates. - Save your model with
joblib.dump(best, "model.pkl")to use in production.
What this taught you
Every concept from the previous tutorials showed up here. Linear regression’s foundation. Decision trees’ splits. Random forests’ voting. Gradient boosting’s sequential corrections. Cross validation’s robustness. Grid search’s tuning. Pipeline’s safety. Metric selection’s importance.
This is what ML really looks like. Not 95% writing models from scratch. It is 80% data wrangling, 10% picking algorithms, 5% tuning, and 5% interpreting results.
If you can do this project comfortably, you can do most real ML jobs. That is the whole point. 🚢