Feature Engineering — Building Better Predictors¶
Raw data rarely arrives in a form that a model can use well. The Titanic dataset gives you a passenger's name — which seems useless — but hidden inside it is a title (Mr, Mrs, Miss, Master) that encodes sex, age group, and marital status simultaneously. Feature engineering is the skill of extracting latent signals from raw columns. On this dataset, the difference between a 78% and an 83% accuracy model is almost entirely explained by how well the features are engineered.
Learning Objectives¶
- Drop redundant columns with clear justification for each
- Impute missing values correctly, using training data statistics only
- Create new features that encode domain knowledge (family size, title, age bucket)
- Encode categorical features for sklearn compatibility
- Build a reproducible sklearn Pipeline that combines all preprocessing steps
Step 0: Load and Inspect¶
import seaborn as sns
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, LabelEncoder
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder
df = sns.load_dataset('titanic')
print(f"Starting shape: {df.shape}")
# Output: Starting shape: (891, 15)
Step 1: Drop Redundant Columns¶
# Drop columns that duplicate information or cause leakage
columns_to_drop = [
'class', # duplicate of pclass (string version)
'embark_town', # duplicate of embarked (full name version)
'alive', # target leakage — string version of survived
'who', # derived from sex + age — no independent information
'adult_male', # derived from sex + age — no independent information
'deck', # 77% missing — not imputable reliably
]
df = df.drop(columns=columns_to_drop)
print(f"Shape after dropping redundant columns: {df.shape}")
# Output: Shape after dropping redundant columns: (891, 9)
print(df.columns.tolist())
# Output: ['survived', 'pclass', 'sex', 'age', 'sibsp', 'parch', 'fare', 'embarked', 'alone']
Info
alone is technically redundant (it equals sibsp + parch == 0) but we keep it for now and will replace it with the engineered is_alone feature. Drop the original alone after creating the new version to avoid duplication.
Step 2: Extract Title from Name¶
This step requires accessing the raw seaborn dataset before dropping columns. The name column is present in the seaborn dataset but we need to re-load it to access it.
# Reload to access name column
df_raw = sns.load_dataset('titanic')
# Extract title using regex
# Name format: "Last, Title. First Middle"
df_raw['title'] = df_raw['name'].str.extract(r',\s([A-Za-z]+)\.')
print(df_raw['title'].value_counts())
# Output:
# Mr 517
# Miss 182
# Mrs 125
# Master 40
# Dr 7
# Rev 6
# Col 2
# Mlle 2
# Major 2
# Capt 1
# Lady 1
# Sir 1
# Ms 1
# Don 1
# Jonkheer 1
# Countess 1
# Mme 1
# dtype: int64
# Consolidate rare titles
rare_titles = ['Dr', 'Rev', 'Col', 'Major', 'Mlle', 'Capt', 'Lady',
'Sir', 'Ms', 'Don', 'Jonkheer', 'Countess', 'Mme']
df_raw['title'] = df_raw['title'].replace(rare_titles, 'Rare')
print("\nTitle survival rates:")
title_survival = df_raw.groupby('title')['survived'].agg(['mean', 'count'])
title_survival.columns = ['survival_rate', 'count']
print(title_survival.sort_values('survival_rate', ascending=False))
# Output (approximate):
# survival_rate count
# title
# Mrs 0.793 125
# Miss 0.700 182
# Rare 0.347 22
# Master 0.575 40
# Mr 0.157 517
# 'Master' is the title for male children — their elevated survival rate
# captures the 'children first' signal precisely
Master is the key discovery here. In the early 20th century, "Master" was used exclusively for boys under ~13. The model cannot learn "children survive more" from age alone when 20% of ages are missing — but title == Master flags children even when age is null.
Tip
This is why domain knowledge matters. Without knowing that "Master" is a title for young boys, you would never think to extract it. Always research what your raw columns actually represent before deciding which features to engineer.
Step 3: Create Family Size Features¶
# Create all new features on the raw dataframe, then subset what we need
df_raw['family_size'] = df_raw['sibsp'] + df_raw['parch'] + 1
# Binary flag: is the passenger travelling alone?
df_raw['is_alone'] = (df_raw['family_size'] == 1).astype(int)
# Family size bucket captures the non-linear relationship found in EDA
def family_bucket(size):
if size == 1:
return 'alone'
elif size <= 4:
return 'small'
else:
return 'large'
df_raw['family_bucket'] = df_raw['family_size'].apply(family_bucket)
print("Family size bucket survival rates:")
print(df_raw.groupby('family_bucket')['survived'].mean().sort_values(ascending=False).map('{:.1%}'.format))
# Output:
# family_bucket
# small 56.8%
# large 16.1%
# alone 30.4%
Step 4: Age Bucket¶
df_raw['age_bucket'] = pd.cut(
df_raw['age'],
bins=[0, 12, 18, 35, 60, 120],
labels=['child', 'teen', 'young_adult', 'adult', 'senior']
)
print("Age bucket survival rates:")
print(df_raw.groupby('age_bucket', observed=True)['survived'].mean().map('{:.1%}'.format))
# Output (approximate):
# age_bucket
# child 59.4%
# teen 44.0%
# young_adult 37.5%
# adult 42.4%
# senior 32.1%
Info
The age bucket is particularly useful for passengers where age is missing — after imputation with the median, those passengers will all land in the young_adult or adult bucket. The bucket effectively softens the impact of the imputed values by grouping them with similar passengers rather than treating each imputed value as precise.
Step 5: Fare Per Person¶
# Some tickets were shared — the fare column lists the total ticket price
# Dividing by family size gives a per-person cost that is more comparable
df_raw['fare_per_person'] = df_raw['fare'] / df_raw['family_size']
# Passengers with zero fare need special handling
print(f"Zero-fare passengers: {(df_raw['fare'] == 0).sum()}")
# Output: Zero-fare passengers: 15
# Their fare_per_person will also be 0, which is fine — they form a distinct group
Step 6: Build the Final Feature Matrix¶
# Assemble the working dataframe with all engineered features
feature_cols = [
'survived', # target
'pclass', # ordinal — keep as integer
'sex', # binary after encoding
'age', # float — will impute then scale
'fare', # float — will scale
'embarked', # 3 categories — one-hot encode
'family_size', # integer — scale
'is_alone', # binary integer
'title', # 5 categories — one-hot encode
'age_bucket', # 5 categories — one-hot encode (handles missing age gracefully)
'fare_per_person' # float — scale
]
df_eng = df_raw[feature_cols].copy()
print(f"Feature matrix shape: {df_eng.shape}")
# Output: Feature matrix shape: (891, 11)
print(df_eng.dtypes)
# Output:
# survived int64
# pclass int64
# sex object
# age float64
# fare float64
# embarked object
# family_size int64
# is_alone int64
# title object
# age_bucket category
# fare_per_person float64
Step 7: Train-Test Split (Before Any Fitting)¶
Always split before fitting any imputer or scaler. Fitting on the full dataset reveals test set statistics to your preprocessing steps — that is leakage.
X = df_eng.drop(columns=['survived'])
y = df_eng['survived']
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.2,
random_state=42,
stratify=y # preserve class balance in both splits
)
print(f"Training set: {X_train.shape}")
print(f"Test set: {X_test.shape}")
print(f"Train survival rate: {y_train.mean():.3f}")
print(f"Test survival rate: {y_test.mean():.3f}")
# Output:
# Training set: (712, 10)
# Test set: (179, 10)
# Train survival rate: 0.383
# Test survival rate: 0.385
Step 8: Build the Preprocessing Pipeline¶
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
# Define column groups
numeric_features = ['age', 'fare', 'family_size', 'fare_per_person']
binary_features = ['pclass', 'is_alone'] # already numeric — just scale
categorical_features = ['sex', 'embarked', 'title', 'age_bucket']
# Numeric pipeline: impute missing values, then scale
numeric_pipeline = Pipeline([
('imputer', SimpleImputer(strategy='median')),
('scaler', StandardScaler())
])
# Categorical pipeline: impute missing categories, then one-hot encode
categorical_pipeline = Pipeline([
('imputer', SimpleImputer(strategy='most_frequent')),
('encoder', OneHotEncoder(handle_unknown='ignore', sparse_output=False))
])
# Binary/ordinal columns: only impute (already numeric, no encoding needed)
binary_pipeline = Pipeline([
('imputer', SimpleImputer(strategy='median')),
('scaler', StandardScaler())
])
# Combine with ColumnTransformer
preprocessor = ColumnTransformer([
('num', numeric_pipeline, numeric_features),
('bin', binary_pipeline, binary_features),
('cat', categorical_pipeline, categorical_features),
])
# Fit the preprocessor on TRAINING data only, then transform both sets
X_train_processed = preprocessor.fit_transform(X_train)
X_test_processed = preprocessor.transform(X_test)
print(f"Processed training shape: {X_train_processed.shape}")
# Output: Processed training shape: (712, 20) — exact number depends on one-hot cardinality
Warning
preprocessor.fit_transform(X_train) and then preprocessor.transform(X_test) — not fit_transform(X_test). Calling fit_transform on the test set re-fits the imputer and scaler to test set statistics. This makes your test set performance look better than it actually is. In deployment, you will only ever have transform available, not fit_transform. Use the Pipeline as your contract.
Final Feature Set¶
After preprocessing, the model receives these features:
| Feature Group | Features | Encoding |
|---|---|---|
| Numeric (scaled) | age, fare, family_size, fare_per_person |
StandardScaler after median imputation |
| Binary/Ordinal | pclass, is_alone |
StandardScaler |
| Sex | sex (male/female) |
OneHotEncoder → 2 binary columns |
| Embarkation | embarked (C/Q/S) |
OneHotEncoder → 3 binary columns |
| Title | title (Mr/Mrs/Miss/Master/Rare) |
OneHotEncoder → 5 binary columns |
| Age bucket | age_bucket (child/teen/young_adult/adult/senior) |
OneHotEncoder → 5 binary columns |
Total: approximately 20 features going into the model, all numeric, no missing values.
Success
The full preprocessing Pipeline can be saved and loaded with joblib. When you deploy this model, you pass raw passenger data through the same Pipeline — it handles all imputation and encoding automatically. This is why building a Pipeline from the start is the professional approach, not a convenience.
import joblib
# joblib.dump(preprocessor, 'titanic_preprocessor.pkl')
# loaded_preprocessor = joblib.load('titanic_preprocessor.pkl')