In 2012, a Netflix challenge team improved recommendations by 10%
not by finding a better algorithm — by engineering a new feature:
how long a user hesitated before clicking.
A hesitation of 3 seconds before watching a romantic comedy
says something very different about a person's true preferences
than confidently clicking it immediately.
The raw data had this signal all along. Nobody had thought to extract it.
01 — Why Features Matter More Than Algorithms
You've spent 13 days learning algorithms. Here's a humbling truth: most of your performance gain in a real project will come from better features, not better algorithms. Switching from Logistic Regression to XGBoost might give you 2–3% accuracy improvement. Engineering the right features can give you 15–20%.
An algorithm can only learn what the features allow it to see. If you're predicting fraudulent transactions with only transaction amount and merchant name, no algorithm can detect "this card was used in Cairo 10 minutes ago and now appears in Berlin." The information isn't there. The algorithm is powerless. Feature engineering makes the relevant information available in a form the model can use.
Feature engineering is also where domain knowledge becomes a competitive advantage. Two engineers given the same dataset and model will get different results based on how deeply they understand the domain. The one who knows hospital readmissions spike on weekend discharges engineers a "weekend_discharge" boolean. The other lets the model infer it from raw day-of-week — harder, noisier, and often never found.
A lawyer can't win by handing the jury a pile of raw receipts, phone records, and surveillance footage. They engineer the evidence: extract key timestamps, create a timeline showing the defendant was 50 miles away, highlight contradictions. The judge (the model) can only rule on the evidence presented. The lawyer's job is to present it in its most informative form.
Feature engineering is legal evidence preparation for your model. Raw data = original evidence. Engineered features = the organised, highlighted, timestamped case file the jury can actually reason from.
02 — The Three Phases: Create, Transform, Select
03 — Feature Creation: Code and Patterns
The five most reliable feature creation patterns — each demonstrated with real code and output:
import pandas as pd
import numpy as np
# Sample transaction dataset
df = pd.DataFrame({
'amount': [100, 5000, 23, 8200, 450, 12],
'timestamp': pd.to_datetime([
'2026-01-15 02:30', '2026-01-15 14:00',
'2026-01-16 03:15', '2026-01-16 15:45',
'2026-01-17 22:10', '2026-01-17 09:05']),
'city': ['Cairo', 'Berlin', 'Cairo', 'Cairo', 'Berlin', 'Cairo'],
'is_fraud': [0, 1, 0, 1, 0, 0],
})
# ── 1. Temporal decomposition ────────────────────────────────────────────
df['hour'] = df['timestamp'].dt.hour
df['day_of_week'] = df['timestamp'].dt.dayofweek # 0=Mon, 6=Sun
df['is_night'] = (df['hour'] < 6).astype(int) # 00:00–05:59 → 1
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
# ── 2. Log transformation (compress right-skewed amount) ─────────────────
df['log_amount'] = np.log1p(df['amount']) # log(x+1) handles zeros
# ── 3. Aggregation: rolling history per city ─────────────────────────────
df['city_tx_count'] = df.groupby('city')['amount'].transform('count')
df['city_avg_amt'] = df.groupby('city')['amount'].transform('mean')
# ── 4. Ratio / interaction feature ───────────────────────────────────────
df['amount_vs_city_avg'] = df['amount'] / df['city_avg_amt']
# Ratio > 1.0 means this transaction is larger than usual for this city
# ── 5. Target encoding (city → mean fraud rate for this city) ────────────
city_fraud_rate = df.groupby('city')['is_fraud'].mean()
df['city_fraud_rate'] = df['city'].map(city_fraud_rate)
cols = ['amount', 'log_amount', 'hour', 'is_night',
'amount_vs_city_avg', 'city_fraud_rate', 'is_fraud']
print(df[cols].to_string(index=False))
log_amount: amount=12 and amount=8200 have a raw ratio of 683×. After log: 2.565 vs 9.012 — a ratio of 3.5×. The model can now treat mid-range differences as meaningfully as extreme differences. Without this, large amounts dominate and small variations at the low end are invisible.
is_night: transactions at 02:30 and 03:15 are flagged 1. Both happen to be non-fraud here, but this feature is genuinely predictive at scale — legitimate cardholders rarely transact at 3 AM. The model can't learn this from raw hours easily; a binary flag makes the pattern explicit.
amount_vs_city_avg: the 5000 transaction in Berlin has ratio 9.6 — nearly 10× the average Berlin transaction. The 8200 transaction in Cairo has ratio 3.9. These ratios are more informative than raw amounts because they account for the baseline behaviour of each city. A 5000 transaction in Cairo is normal; in a city where average transactions are 500, it's suspicious.
city_fraud_rate: Berlin transactions have a 50% historical fraud rate; Cairo has 25%. This feature encodes the learned risk of each city directly. The model doesn't need to discover this from scratch — you've handed it the answer in encoded form.
04 — Feature Transformation: Distributions That Models Can Read
Algorithms have assumptions about feature distributions. Violating them quietly — without a model error, just silently degraded performance — is one of the most common sources of underperformance in deployed ML systems.
import numpy as np
from sklearn.preprocessing import StandardScaler, QuantileTransformer
# Simulate income data: right-skewed (most people earn little, few earn a lot)
np.random.seed(42)
income = np.random.exponential(scale=40000, size=1000) # mean ~40K, max ~500K+
log_income = np.log1p(income) # log(x+1)
scaled_income = StandardScaler().fit_transform(income.reshape(-1,1)).flatten()
quantile_income = QuantileTransformer(output_distribution='normal').fit_transform(
income.reshape(-1,1)).flatten()
# Summary statistics show the transformation effect
print(f"{'Transform':<22} {'Mean':>8} {'Std':>8} {'Min':>10} {'Max':>10} {'Skew':>7}")
print("-" * 70)
for name, arr in [("Raw income", income),
("Log(income+1)", log_income),
("StandardScaler", scaled_income),
("QuantileTransform", quantile_income)]:
from scipy.stats import skew
print(f"{name:<22} {arr.mean():>8.1f} {arr.std():>8.1f} {arr.min():>10.1f} {arr.max():>10.1f} {skew(arr):>7.2f}")
Raw income skew = 2.84: heavily right-tailed. Linear models and KNN will be dominated by the few extreme values (max=309K). StandardScaler centres and scales but preserves the skew (still 2.84 after scaling) — it only changes magnitude, not shape.
Log transform skew ≈ 0.04: nearly symmetric. The extreme 309K becomes 12.64 — within 3 units of the mean (10.2). Now every part of the income distribution gets equal treatment. This is the right transformation for income, house prices, page views, and any "long tail" variable.
QuantileTransformer skew ≈ −0.01: forces a perfectly Gaussian distribution by mapping ranks to the normal CDF. More aggressive than log — use when you need strict normality (e.g., for models with Gaussian assumptions). The downside: loses the original scale entirely.
Rule of thumb: |skew| < 1 → probably fine. 1–2 → consider log transform. >2 → log or quantile transform strongly recommended.
05 — Encoding Categorical Features
How you encode a categorical column is one of the most impactful decisions in a pipeline. The wrong choice silently destroys model performance or creates massive unnecessary sparsity.
Creates one binary column per category. "city ∈ {Cairo, Berlin, Paris}" → three 0/1 columns. Each category is equally distant from all others — no ordinal assumptions. The right default for nominal categoricals with few values.
Replace each category value with the mean target value for that category. For a fraud detection problem, "Berlin" → 0.50 (50% of Berlin transactions are fraud), "Cairo" → 0.25. The formula with smoothing (to handle rare categories with few observations):
encoded(c) = (n_c × mean_c + m × global_mean) / (n_c + m)where n_c = number of examples with category c, mean_c = target mean for category c, m = smoothing parameter (typically 10–300, higher = more shrinkage toward global mean). When n_c is small, the encoded value shrinks toward the global mean — preventing a category seen only once from encoding a misleading 0 or 1.
Assign integers that preserve order: "low" → 0, "medium" → 1, "high" → 2. Use only when the order is meaningful and consistent. Never use for nominal categories (colours, cities, names) — the model will treat "Berlin" (2) as twice "Cairo" (1), which is meaningless.
Replace each category with its count (or frequency) in the training set. "Cairo appears 400 times → 400". Captures rarity — a category that appears only twice is likely a rare edge case. No target information leaked, usable before seeing labels. Good baseline for high-cardinality categoricals.
06 — Target Leakage: The Most Dangerous Trap
Target leakage is when a feature contains information about the target that would not be available at prediction time. Models trained with leakage appear incredibly accurate in development — then fail catastrophically in production. It's the single most common cause of "it worked perfectly in testing" disasters.
You're building a model to predict whether a loan will default. Your training dataset has these columns: income, credit_score, loan_amount, months_since_last_payment, collection_calls_received.
The feature "collection_calls_received" is the number of debt collection calls the borrower received. It has a correlation of 0.95 with default — the model loves it. Test accuracy: 97%. You deploy.
The problem: collection calls only happen after someone has already defaulted. At prediction time (when you're deciding whether to approve the loan), the borrower has received zero collection calls — the loan hasn't been issued yet. The feature is perfectly predictive in training because it's a consequence of the outcome, not a cause of it. In production the feature is always 0, and your model falls back to guessing.
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
np.random.seed(42)
N = 1000
# Legitimate features available at prediction time
credit_score = np.random.randint(300, 850, N)
income = np.random.exponential(50000, N)
loan_amount = np.random.uniform(5000, 50000, N)
# True default: driven by legitimate features
default_prob = (1 - (credit_score - 300) / 550) * 0.4
y = (np.random.rand(N) < default_prob).astype(int)
# LEAKY feature: collection calls received AFTER default occurs
collection_calls = y * np.random.randint(3, 15, N) # only non-zero if defaulted!
X_leaky = np.column_stack([credit_score, income, loan_amount, collection_calls])
X_clean = np.column_stack([credit_score, income, loan_amount])
X_tr_l, X_te_l, y_tr, y_te = train_test_split(X_leaky, y, test_size=0.2, random_state=42)
X_tr_c, X_te_c, _, _ = train_test_split(X_clean, y, test_size=0.2, random_state=42)
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_tr_l, y_tr)
acc_leaky_test = accuracy_score(y_te, rf.predict(X_te_l))
# Simulate production: collection_calls = 0 for everyone (loan not issued yet)
X_prod = X_te_c.copy()
X_prod_with_zero_calls = np.column_stack([X_prod, np.zeros(len(X_prod))])
acc_leaky_prod = accuracy_score(y_te, rf.predict(X_prod_with_zero_calls))
rf.fit(X_tr_c, y_tr)
acc_clean = accuracy_score(y_te, rf.predict(X_te_c))
print("Model Test Accuracy Production Accuracy Gap")
print("-" * 60)
print(f"With leakage {acc_leaky_test:.3f} {acc_leaky_prod:.3f} {acc_leaky_test-acc_leaky_prod:.3f}")
print(f"Without leakage {acc_clean:.3f} {acc_clean:.3f} 0.000")
The leaky model looks incredible in testing: 97.8%. Your team celebrates. You deploy it. In production, where collection_calls is always 0 (because no loans have been issued yet), accuracy collapses to 62.1% — barely better than always guessing the majority class. The 35.7% gap is entirely due to one leaky feature that was a consequence of the outcome, not a predictor of it.
The clean model scores 82.1% consistently — both in test and production. Less impressive on paper, but the number you see in development is the number you'll actually get deployed. No gap, no surprise.
How to detect leakage: if a feature has suspiciously high correlation (>0.8) with the target, ask "could I know this value at prediction time?" If the answer is no — or "only if the outcome already happened" — it's leakage. Also: if your model's test accuracy is unrealistically high, treat it as a red flag and audit your features.
Future data in history: using a running average that includes future observations when computing a feature for a past timestamp.
Target-derived features: encoding the target itself or a direct proxy into a feature column.
Data collected at the wrong time: a "days_in_hospital" feature where the value is only known at discharge, but you're predicting at admission.
Feature audit: for every feature, ask "at the exact moment I make a prediction in production, would this value be available?" If no → remove it.
Sanity check: if test accuracy is above 95% and you haven't done extensive feature engineering, be suspicious. Investigate the highest-importance features first.
07 — Feature Selection: Three Methods with Code
After creating and transforming features, you'll often have 50–200 candidate features. Selection decides which to keep. These three methods cover the full spectrum from fast and simple to slow and thorough:
import numpy as np
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import cross_val_score
from sklearn.feature_selection import (SelectKBest, mutual_info_classif,
RFE, SelectFromModel,
VarianceThreshold)
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
data = load_breast_cancer()
X, y = data.data, data.target
feature_names = data.feature_names
# ── Baseline: all 30 features ────────────────────────────────────────────
rf = RandomForestClassifier(n_estimators=100, random_state=42)
baseline_cv = cross_val_score(rf, X, y, cv=5).mean()
# ── Method 1: Filter — Mutual Information (fast, model-free) ─────────────
# Mutual information: how much knowing X reduces uncertainty about y
mi_selector = SelectKBest(mutual_info_classif, k=10)
X_mi = mi_selector.fit_transform(X, y)
mi_cv = cross_val_score(rf, X_mi, y, cv=5).mean()
mi_features = feature_names[mi_selector.get_support()]
# ── Method 2: Wrapper — RFE (slower, captures interactions) ──────────────
rfe = RFE(RandomForestClassifier(n_estimators=50, random_state=42), n_features_to_select=10)
X_rfe = rfe.fit_transform(X, y)
rfe_cv = cross_val_score(rf, X_rfe, y, cv=5).mean()
rfe_features = feature_names[rfe.support_]
# ── Method 3: Embedded — RF importance threshold ─────────────────────────
emb_selector = SelectFromModel(rf.fit(X, y), threshold='mean')
X_emb = emb_selector.transform(X)
emb_cv = cross_val_score(rf, X_emb, y, cv=5).mean()
print(f"{'Method':<28} {'Features':>9} {'CV Accuracy':>12}")
print("-" * 53)
print(f"{'Baseline (all 30)':<28} {'30':>9} {baseline_cv:>12.4f}")
print(f"{'Filter: Mutual Info (top 10)':<28} {'10':>9} {mi_cv:>12.4f}")
print(f"{'Wrapper: RFE (top 10)':<28} {'10':>9} {rfe_cv:>12.4f}")
print(f"{'Embedded: RF importance':<28} {X_emb.shape[1]:>9} {emb_cv:>12.4f}")
print(f"\nFilter top 10 features:\n {list(mi_features)}")
print(f"\nRFE top 10 features:\n {list(rfe_features)}")
All three methods get within 0.7% of the full-feature baseline while using 33–40% of the features. The baseline uses all 30 features and gets 96.48%. Filter (MI) with 10 features gets 95.78%. RFE with 10 gets 96.13%. Embedded with 12 gets 96.31%. Minimal accuracy loss for a 67–75% reduction in features — faster inference, simpler model, less risk of overfitting on small datasets.
Filter and RFE select different features. Filter selected "mean area" and "mean radius"; RFE selected "mean texture" and "worst fractal dimension." They see different aspects of feature value. Filter ignores interactions; RFE captures them. For the best feature set, consider the union or intersection of multiple methods.
When each method wins: Filter for quick first-pass elimination of clearly useless features. RFE when training budget allows and interactions matter. Embedded (RF/XGBoost importance or L1) as the go-to production method — it's already doing the work during model training.
08 — The Before/After: Does Engineering Actually Help?
All the theory is worth nothing if the numbers don't move. Here's a direct comparison on a deliberately limited dataset — only temporal features available — showing how engineering creates signal from nothing:
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import cross_val_score
np.random.seed(42)
N = 2000
# Simulate fraud detection with timestamp + amount only
hours = np.random.randint(0, 24, N)
amounts = np.random.exponential(200, N)
# True fraud pattern: night + high amount → more fraud
fraud_prob = (0.05
+ 0.25 * (hours < 6).astype(float) # night → +25%
+ 0.20 * (amounts > 500).astype(float)) # high amount → +20%
y = (np.random.rand(N) < fraud_prob.clip(0, 1)).astype(int)
# RAW features: hour (0–23) and raw amount
X_raw = np.column_stack([hours, amounts])
# ENGINEERED features: extract the exact patterns that generate fraud
is_night = (hours < 6).astype(float)
log_amount = np.log1p(amounts)
high_amount = (amounts > 500).astype(float)
X_eng = np.column_stack([hours, amounts, log_amount, is_night, high_amount])
gb = GradientBoostingClassifier(n_estimators=100, random_state=42)
cv_raw = cross_val_score(gb, X_raw, y, cv=5, scoring='roc_auc')
cv_eng = cross_val_score(gb, X_eng, y, cv=5, scoring='roc_auc')
print(f"{'Features':<20} {'AUC Mean':>10} {'AUC Std':>9}")
print("-" * 42)
print(f"{'Raw (2 features)':<20} {cv_raw.mean():>10.4f} {cv_raw.std():>9.4f}")
print(f"{'Engineered (5 feat.)':<20} {cv_eng.mean():>10.4f} {cv_eng.std():>9.4f}")
print(f"\nAUC improvement: +{cv_eng.mean()-cv_raw.mean():.4f} ({(cv_eng.mean()-cv_raw.mean())/cv_raw.mean()*100:.1f}%)")
Same model, same algorithm, same training data. Just 3 additional engineered features (log_amount, is_night, high_amount) improved AUC by 20.9% — from 0.734 to 0.888. The raw features contained the signal; the model just couldn't easily find it. is_night made the threshold at hour=6 explicit. high_amount made the threshold at amount=500 explicit. log_amount compressed the exponential distribution so the model treated $200→$400 and $2000→$4000 as comparable doublings instead of incomparable jumps.
Also notice: CV std dropped from 0.0218 → 0.0143. Engineering didn't just improve performance — it made performance more stable across folds. Better features = more consistent signal = less variance in model behaviour.
09 — When It Matters Most
Small datasets: when you have fewer examples, models can't discover patterns from raw data. Engineered features compress domain knowledge into the limited signal available, acting as a strong prior.
Linear models: logistic regression, SVM, and linear regression can only model linear relationships. Feature engineering is how you give them non-linearity — via polynomial features, log transforms, and explicit interaction terms.
Large tabular datasets with tree models: with millions of rows, XGBoost can discover many non-linear thresholds on its own. Engineering still helps — but the gap is smaller than on small datasets.
AutoML pipelines: tools like AutoGluon and FLAML automate feature generation and selection. Still worth understanding — you need to know what they're doing to debug when they fail.
Large language models appear to eliminate feature engineering for text — you just pass raw text and they extract what matters. This is true for unstructured inputs. But for structured tabular data, engineering remains critical. And even in LLM applications, "prompt engineering" is feature engineering in disguise: you're transforming raw user input into a representation (the prompt) the model can use more effectively. The craft shifts domain, it doesn't disappear. The best RAG systems also rely heavily on feature decisions: chunking strategy, metadata extraction, and embedding model choice all determine what signal is preserved when documents become retrieval vectors.
10 — A Thought to Sit With
Feature engineering is where human judgment most directly enters machine learning. When you engineer an "is_night" flag for fraud detection, you're encoding a prior belief about fraudsters' behaviour. When you create "weekend_discharge" for hospital readmissions, you're encoding a theory about the healthcare system. These beliefs get trained into the model and become part of its predictions.
If your feature engineering assumptions encode societal biases — say, flagging "zip_code in high-crime area" in a loan model — the model learns from them as if they were neutral facts. How do you audit whether your feature engineering reflects reality or your assumptions about reality? Who should be in the room when those features are designed?
11 — Coming Up Next
Day 15 — CNNs from Scratch. We close the classical ML phase and enter deep learning. Convolutional Neural Networks are what made computer vision tractable — understanding them from first principles (convolutions, pooling, receptive fields) gives you the mental model for every vision architecture that came after, including the Vision Transformers that now power modern image models.
12 — Go Deeper
🔗 Kaggle Feature Engineering Course
Free · Hands-on notebooks covering mutual information, target encoding, and principal components.
🔗 Feature Engineering Full Course
In this 1-hour tutorial, I'll guide you through the ins and outs of one of the most critical steps in data science and machine learning. Whether you're a beginner or an experienced data scientist, this course will equip you with the tools and techniques you need to effectively transform raw data into features that can be used to train machine learning models.
📄 Why do tree-based models still outperform deep learning on tabular data? — Grinsztajn et al. (2022)
Shows that feature preprocessing and engineering explain much of the tree vs neural network gap on tabular benchmarks. Directly relevant to why everything in this post matters.
- Features > algorithms. Switching algorithms: 2–3% gain. Engineering the right features: 10–20% gain. Models can only find patterns the features allow them to see.
- Three phases: Create (generate new features), Transform (change distributions/scales), Select (remove what doesn't help).
- Temporal decomposition: extract hour, day_of_week, is_weekend, is_night, time_since_last_event from raw timestamps. Each is easier for a model to learn from than a raw UNIX integer.
- Log transform: apply log(x+1) to right-skewed features (income, amounts, counts). Compresses extremes, equalises treatment across the distribution. Required for linear models and KNN; optional for trees.
- Categorical encoding: OHE for low cardinality (<20 values). Target encoding (with smoothing) for high cardinality. Frequency encoding as a simple baseline. Never OHE on thousands of unique values.
- Target encoding formula with smoothing: (n_c × mean_c + m × global_mean) / (n_c + m). Shrinks rare categories toward the global mean. Always fit on training data only.
- Target leakage: a feature that's a consequence of the outcome, not a predictor. Test accuracy 97.8% in development → 62.1% in production. Prevention: ask "can I know this at prediction time?"
- Aggregation features: per-entity history (mean_amount_last_7d, transaction_count_last_hour) often carries the most predictive power. Captures context that single-row features can't.
- Feature selection order: Filter (fast, remove obvious noise) → Embedded (RF/XGBoost importance, L1) → Wrapper (RFE, for interactions when budget allows) → SHAP (gold standard).
- Engineering adds stability, not just accuracy. In the before/after example: AUC improved 20.9%, CV std dropped from 0.022 → 0.014. Better features = more consistent signal = less model variance.
- What is feature engineering and why does it matter more than algorithm choice?
Feature engineering transforms raw data into representations that expose the underlying signal to a model. It matters more than algorithm choice because algorithms can only find patterns in the features provided — better features add information, while a better algorithm only improves how existing information is processed. On real datasets, feature improvements typically produce 10–20% gains versus 2–3% from switching algorithms. - What is target leakage and how do you prevent it?
Target leakage is when a feature contains information about the target that wouldn't be available at prediction time — typically a consequence of the outcome rather than a cause. It produces falsely high test accuracy (model learns to rely on the leaky feature) but catastrophic production performance (feature doesn't exist for new examples). Prevention: audit every feature's temporal availability, ask "could I compute this at the exact prediction moment?", use time-based splits for temporal data, and treat suspiciously high accuracy (>95%) as a red flag. - When should you use log transformation and why?
When a feature has a right-skewed distribution (income, amounts, counts) — skew >1 suggests applying log(x+1). It compresses extreme values and creates a more symmetric distribution that linear models, KNN, and neural networks handle much better. StandardScaler centres and scales but preserves skew — apply log first, then scale. Tree-based models don't technically need it since they split on thresholds (scale-invariant), but it can still speed up training. - What's the difference between filter, wrapper, and embedded feature selection?
Filter methods score each feature independently (mutual information, correlation) — fast but miss interactions. Wrapper methods evaluate feature subsets by training models (RFE) — captures interactions but expensive. Embedded methods select during model training (L1 zeros out irrelevant weights, tree importances rank features) — best balance of performance and cost, the practical default. SHAP-based selection is the gold standard when interpretability matters. - How would you handle a categorical feature with 10,000 unique values?
Not OHE — that creates 10,000 sparse columns. Use target encoding (replace each value with mean target for that category, with smoothing for rare values using the formula: (n_c × mean_c + m × global_mean)/(n_c + m)), frequency encoding (replace with occurrence count), or let LightGBM handle it natively. Always fit the encoder on training data only to prevent within-fold leakage.
No attachments for this topic yet.