06 · Phase 1 Projects¶
Projects are the only thing that proves you learned something. Notes are cheap. A working, deployed model with a documented methodology is evidence that cannot be faked by memorization. Each project below is designed to demonstrate a specific cluster of Phase 1 competencies to a technical interviewer, a hiring manager, or your future self in six months. They progress in difficulty and portfolio impact: Titanic/House Prices gets you started, the Random Forest from scratch proves your depth, and the deployed churn model proves you can ship.
Complete all three. Put them on GitHub. Write a proper README for each one. These are your Phase 1 artifacts.
Project 1 · Kaggle Competition Progression: Titanic → House Prices¶
What This Proves¶
You can run a complete ML pipeline on real, messy data — from raw CSV to a submitted prediction. Titanic is classification (binary, imbalanced, lots of NaN). House Prices is regression with heavy feature engineering. Together they span 80% of tabular ML use cases.
Project Spec¶
Repository name: kaggle-classical-ml
Part A: Titanic Survival Prediction
The Titanic dataset has ~900 rows, 12 raw features, and requires feature engineering to score above baseline. The naive “everyone dies” baseline achieves ~61.6% accuracy. The goal: beat 80% accuracy on the leaderboard (public LB), which requires real feature engineering.
Tasks:
1. EDA (1 hr)
- Survival rate by Pclass, Sex, Age bucket, Fare
- Missing values: Age (177 NaN), Cabin (687 NaN), Embarked (2 NaN)
- Plot: survival heatmap by Sex × Pclass
2. Feature Engineering (1.5 hrs)
- Title extraction from Name: "Mr", "Mrs", "Miss", "Master", "Rare"
- Family size: SibSp + Parch + 1
- IsAlone: 1 if FamilySize == 1
- Age × Pclass interaction
- Fare per person: Fare / FamilySize
- Deck from Cabin (A-G, U for unknown)
- AgeBucket: binned into 5 groups
3. Preprocessing Pipeline (0.5 hrs)
- Median imputation for Age, mode for Embarked
- OrdinalEncoder for Sex, OneHotEncoder for Embarked, Title
- StandardScaler (for LogReg/SVM only)
4. Model Comparison (1 hr)
- Logistic Regression (baseline)
- Decision Tree (benchmark)
- Random Forest (n_estimators=200, cv=5)
- XGBoost (n_estimators=200, max_depth=4, learning_rate=0.05)
- Compare: 5-fold CV accuracy ± std
5. Hyperparameter Tuning (0.5 hrs)
- RandomizedSearchCV on best model (50 iterations)
- Report: best params + val accuracy
6. Submission (0.5 hrs)
- Predict test.csv, generate submission.csv
- Submit to Kaggle, screenshot leaderboard score
Acceptance Criteria (Titanic):
Leaderboard accuracy ≥ 0.800 (top ~5% historically achievable with good feature engineering, no leakage)
CV accuracy reported as
mean ± stdacross 5 foldsAt least 5 engineered features documented with rationale
Model comparison table: ≥4 algorithms, sorted by CV accuracy
No test data used in any preprocessing fit step (verified by code review)
EDA section with ≥3 visualizations
Part B: House Prices — Advanced Regression
House Prices has 81 features, heavy missingness, high skewness in the target (SalePrice), and requires understanding feature interactions in a domain context.
Tasks:
1. Target analysis (0.5 hrs)
- Plot SalePrice distribution → right-skewed
- Apply log1p transform: log(1 + SalePrice)
- Verify: log-transformed target is near-Gaussian (shapiro test)
2. Missing data audit (1 hr)
- Plot missing fraction per feature
- Separate: structural missing (PoolQC=NaN means "no pool") vs. random
- Impute structural NaN as "None" (new category)
- Impute random NaN: median/mode
3. Feature engineering (1.5 hrs)
- TotalSF = TotalBsmtSF + 1stFlrSF + 2ndFlrSF
- TotalBathrooms = FullBath + 0.5*HalfBath + BsmtFullBath + 0.5*BsmtHalfBath
- HouseAge = YrSold - YearBuilt
- RemodAge = YrSold - YearRemodAdd
- HasPool, HasGarage, HasBsmt, HasFireplace (binary flags)
4. Encoding + scaling (0.5 hrs)
- Ordinal features (quality ratings: Po < Fa < TA < Gd < Ex): OrdinalEncoder
- Nominal categoricals: OneHotEncoder
- Skewed numeric features (skew > 0.75): log1p transform
5. Model (0.5 hrs)
- LightGBM or XGBoost with 5-fold CV
- Hyperparameter: n_estimators, max_depth, learning_rate, subsample
- Optional: Lasso + Ridge blend
6. Submission (0.5 hrs)
- Predict, expm1 to reverse log transform, submit
Acceptance Criteria (House Prices):
Kaggle RMSE on log-transformed target ≤ 0.130 (top 30% as of 2024)
Target log-transform applied and justified with distribution plots
Structural vs. random missingness distinguished in code + comments
≥5 engineered features with domain rationale documented
Feature importance plot for final model
Portfolio Output¶
GitHub repo with clean
notebooks/titanic.ipynbandnotebooks/house_prices.ipynbBoth notebooks: EDA → engineering → pipeline → CV table → leaderboard screenshot
One-paragraph summary in repo README for each project: what you did, what worked, what surprised you
Time estimate: 10-12 hours total across both
Project 2 · Random Forest from Scratch¶
What This Proves¶
You understand decision trees well enough to build them, and you understand bootstrap aggregation well enough to extend them into an ensemble. No sklearn internals. You can explain the algorithm to anyone. This is the most technically demanding project in Phase 1 and the most powerful interview differentiator.
Project Spec¶
Repository: ml-foundations-from-scratch (same repo as Phase 0 PCA project — add to it)
File: random_forest_scratch.py
# Implement the following classes with the signatures below.
# Do NOT import sklearn's DecisionTreeClassifier or RandomForestClassifier.
# numpy only.
class Node:
"""A node in the decision tree."""
def __init__(self):
self.feature_index: int = None # which feature to split on
self.threshold: float = None # split threshold
self.left: 'Node' = None # left child
self.right: 'Node' = None # right child
self.value: float = None # leaf node class prediction
class DecisionTreeScratch:
"""
Binary CART decision tree for classification.
Splitting criterion: Gini impurity or information gain (selectable).
"""
def __init__(self, max_depth: int = 5, min_samples_split: int = 2,
criterion: str = 'gini', max_features: int = None):
pass
def fit(self, X: np.ndarray, y: np.ndarray) -> 'DecisionTreeScratch':
"""Build tree recursively. Returns self."""
pass
def predict(self, X: np.ndarray) -> np.ndarray:
"""Predict class labels for X."""
pass
def _gini(self, y: np.ndarray) -> float:
"""Gini impurity: 1 - sum(p_k²)"""
pass
def _entropy(self, y: np.ndarray) -> float:
"""Entropy: -sum(p_k * log2(p_k))"""
pass
def _best_split(self, X: np.ndarray, y: np.ndarray) -> tuple:
"""
Find the best (feature_index, threshold) pair.
- If max_features is set, randomly sample that many features (for RF).
- Returns (best_feature, best_threshold, best_gain)
"""
pass
def _build(self, X: np.ndarray, y: np.ndarray, depth: int) -> Node:
"""Recursive tree construction."""
pass
class RandomForestScratch:
"""
Random Forest classifier.
- Bootstrap sampling of training data for each tree
- Random feature subsampling at each split (max_features='sqrt' default)
- Prediction via majority vote
"""
def __init__(self, n_estimators: int = 100, max_depth: int = 5,
min_samples_split: int = 2, max_features: str = 'sqrt',
random_state: int = None):
pass
def fit(self, X: np.ndarray, y: np.ndarray) -> 'RandomForestScratch':
"""
Train n_estimators trees on bootstrap samples.
Each tree uses a different bootstrap sample and random feature subsets.
"""
pass
def predict(self, X: np.ndarray) -> np.ndarray:
"""Majority vote across all trees."""
pass
def predict_proba(self, X: np.ndarray) -> np.ndarray:
"""
Fraction of trees voting for each class.
Returns shape (n_samples, n_classes).
"""
pass
@property
def feature_importances_(self) -> np.ndarray:
"""
Mean decrease in impurity across all trees and all splits.
Normalize to sum to 1.
"""
pass
Test Suite (must pass all):
# tests/test_random_forest_scratch.py
import numpy as np
from sklearn.datasets import load_breast_cancer, load_iris
from sklearn.metrics import accuracy_score, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier as SklearnRF
from random_forest_scratch import RandomForestScratch
def test_binary_classification_accuracy():
"""Your RF must achieve >= 90% accuracy on breast cancer."""
X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
rf = RandomForestScratch(n_estimators=100, max_depth=10, random_state=42)
rf.fit(X_train, y_train)
acc = accuracy_score(y_test, rf.predict(X_test))
print(f"Binary classification accuracy: {acc:.4f}")
assert acc >= 0.90, f"Expected >= 0.90, got {acc:.4f}"
def test_within_5pct_of_sklearn():
"""Your RF AUC must be within 5% of sklearn's RF AUC."""
X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
your_rf = RandomForestScratch(n_estimators=100, max_depth=10, random_state=42)
your_rf.fit(X_train, y_train)
your_auc = roc_auc_score(y_test, your_rf.predict_proba(X_test)[:, 1])
sklearn_rf = SklearnRF(n_estimators=100, max_depth=10, random_state=42)
sklearn_rf.fit(X_train, y_train)
sklearn_auc = roc_auc_score(y_test, sklearn_rf.predict_proba(X_test)[:, 1])
diff = abs(your_auc - sklearn_auc)
print(f"Your AUC: {your_auc:.4f}, Sklearn AUC: {sklearn_auc:.4f}, Diff: {diff:.4f}")
assert diff <= 0.05, f"AUC gap too large: {diff:.4f} > 0.05"
def test_feature_importances_sum_to_one():
"""Feature importances must be non-negative and sum to 1."""
X, y = load_iris(return_X_y=True)
rf = RandomForestScratch(n_estimators=50, max_depth=5, random_state=42)
rf.fit(X, y)
importances = rf.feature_importances_
assert np.all(importances >= 0), "Importances must be non-negative"
assert abs(importances.sum() - 1.0) < 1e-6, f"Importances sum to {importances.sum()}"
def test_n_estimators_effect():
"""More trees should not hurt (or minimally hurt) performance."""
X, y = load_breast_cancer(return_X_y=True)
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)
accs = {}
for n in [10, 50, 100, 200]:
rf = RandomForestScratch(n_estimators=n, max_depth=8, random_state=42)
rf.fit(X_tr, y_tr)
accs[n] = accuracy_score(y_te, rf.predict(X_te))
print("Accuracy by n_estimators:", accs)
# 200 trees should not be worse than 10 trees
assert accs[200] >= accs[10] - 0.02, "More trees should not significantly hurt"
Documentation Required:
Docstring in each method explaining the mathematical operation
A 300-word writeup in the repo README: “What I learned implementing Random Forest from scratch that sklearn hides from you” — this is the most valuable part
Acceptance Criteria:
All 4 tests pass
DecisionTreeScratchandRandomForestScratchuse only numpy (no sklearn algorithm classes)Binary classification AUC within 5% of sklearn’s implementation
Feature importances sum to 1.0 (within 1e-6)
Code has docstrings on every method explaining the math
300-word writeup in README
Time estimate: 8-10 hours. This is the hardest project. The _best_split function alone takes most people 2-3 hours to get right. That’s fine. The confusion is the point.
Project 3 · Customer Churn Model — Full Pipeline, Deployed as API¶
What This Proves¶
You can take a business problem, engineer features from raw data, build and tune a model, package it properly, and deploy it as a working API with input validation and drift monitoring. This is the project you demo in interviews. It maps directly to what ML engineers do on their first week at a new job.
Project Spec¶
Repository: churn-prediction-api
Dataset: IBM Telco Customer Churn (free on Kaggle)
7,043 customers, 21 features, ~26.5% churn rate
Mix of numeric, categorical, binary features
Real business context: telecom churn prediction
Project Structure:
churn-prediction-api/
├── data/
│ └── telco_churn.csv
├── notebooks/
│ └── 01_eda_and_modeling.ipynb ← full development notebook
├── src/
│ ├── features.py ← feature engineering functions
│ ├── train.py ← training script (saves model to models/)
│ ├── serve.py ← FastAPI application
│ └── monitor.py ← drift monitoring
├── models/ ← saved .joblib + metadata.json
├── tests/
│ ├── test_features.py
│ └── test_api.py
├── requirements.txt
├── Dockerfile
└── README.md
Phase 1: EDA + Feature Engineering (2 hrs)
Required EDA findings (document all in notebook):
- Churn rate by Contract type (month-to-month vs. annual)
- Churn rate by tenure buckets
- Correlation between TotalCharges and tenure (near-perfect: r > 0.95)
→ Decision: drop TotalCharges or keep it? Document your reasoning.
- Distribution of MonthlyCharges for churned vs. retained customers
Required engineered features:
- tenure_bucket: binned into 5 groups (0-12, 12-24, 24-36, 36-48, 48-60, 60+)
- avg_monthly_charge: TotalCharges / (tenure + 1) — captures spend rate
- has_multiple_services: sum of PhoneService, InternetService, OnlineSecurity,
OnlineBackup, DeviceProtection, TechSupport (binary flags)
- is_senior_with_partner: SeniorCitizen == 1 AND Partner == "Yes"
Phase 2: Modeling (2 hrs)
Requirements:
1. Baseline: LogisticRegression (establishes minimum bar)
2. Production model: XGBoost or LightGBM with RandomizedSearchCV (50 iters)
3. CV: StratifiedKFold(5), scoring='roc_auc'
4. Class imbalance handling: try scale_pos_weight in XGBoost OR
class_weight='balanced' in sklearn models. Document effect on PR-AUC.
5. Calibration: apply isotonic regression calibration. Report Brier score
before/after. Calibration matters here — the API outputs probabilities.
6. Final evaluation (on holdout test set, touched once):
- ROC-AUC
- PR-AUC
- Brier Score
- Calibration curve plot
- Classification report at threshold=0.5 and threshold=0.35
(lower threshold because FN = lost revenue opportunity)
Required Results:
Test ROC-AUC ≥ 0.82 (achievable without feature leakage on this dataset)
Brier score ≤ 0.16 (after calibration)
Report shows PR curve and ROC curve side-by-side
Phase 3: API (2 hrs)
Implement the FastAPI server from 05_classical_ml_in_production.md, adapted for Telco churn:
Required endpoints:
GET /health → {"status": "ok", "model_version": "..."}
GET /model/info → metadata JSON
POST /predict → single customer prediction
POST /predict/batch → batch up to 200 customers
Required input validation (Pydantic):
- tenure: int, >= 0
- MonthlyCharges: float, >= 0
- Contract: str, must be one of ["Month-to-month", "One year", "Two year"]
- All other fields: type-checked with appropriate ranges
Required response:
- churn_probability: float [0, 1]
- churn_risk: "HIGH" (>0.6) | "MEDIUM" (0.3-0.6) | "LOW" (<0.3)
- model_version: str
- inference_time_ms: float
Phase 4: Testing + Monitoring (1 hr)
# tests/test_api.py (use pytest + httpx or requests)
def test_health_endpoint():
response = requests.get("http://localhost:8000/health")
assert response.status_code == 200
assert response.json()["status"] == "ok"
def test_predict_valid_input():
payload = {
"tenure": 12,
"MonthlyCharges": 65.0,
"Contract": "Month-to-month",
# ... all required fields
}
response = requests.post("http://localhost:8000/predict", json=payload)
assert response.status_code == 200
data = response.json()
assert 0.0 <= data["churn_probability"] <= 1.0
assert data["churn_risk"] in ["HIGH", "MEDIUM", "LOW"]
def test_invalid_contract_type_returns_422():
payload = {"tenure": 12, "MonthlyCharges": 65.0, "Contract": "invalid_type"}
response = requests.post("http://localhost:8000/predict", json=payload)
assert response.status_code == 422 # FastAPI validation error, not 500
def test_batch_predict_limit():
"""Batch size > 200 should return 400."""
payload = [{"tenure": 12, "MonthlyCharges": 65.0, "Contract": "Month-to-month"}
for _ in range(201)]
response = requests.post("http://localhost:8000/predict/batch", json=payload)
assert response.status_code == 400
def test_inference_latency():
"""Single prediction must complete in < 50ms."""
import time
payload = { ... } # valid payload
start = time.time()
response = requests.post("http://localhost:8000/predict", json=payload)
latency_ms = (time.time() - start) * 1000
assert latency_ms < 50, f"Latency {latency_ms:.1f}ms exceeds 50ms SLA"
Phase 5: Dockerfile (0.5 hrs)
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
COPY models/ ./models/
EXPOSE 8000
CMD ["uvicorn", "src.serve:app", "--host", "0.0.0.0", "--port", "8000"]
# Build and run
docker build -t churn-api:latest .
docker run -p 8000:8000 churn-api:latest
# Verify
curl http://localhost:8000/health
Acceptance Criteria:
Test ROC-AUC ≥ 0.82 on held-out test set (documented in notebook)
Calibration curve plotted + Brier score improved by isotonic regression
FastAPI server runs locally: all 4 required endpoints return correct responses
All 5 test cases pass (
pytest tests/)Invalid input returns 422, not 500
Single prediction latency < 50ms (measured in tests)
Docker image builds and runs cleanly
README includes: dataset, problem statement, model approach (2 paragraphs), how to run locally, API usage example with curl
Time estimate: 7-9 hours
Phase 1 Portfolio Summary¶
Project |
Core Skills Demonstrated |
Deliverable |
Est. Hours |
|---|---|---|---|
Kaggle Titanic + House Prices |
EDA, feature engineering, model comparison, competition workflow |
Leaderboard score + clean notebooks |
10-12 hrs |
Random Forest from Scratch |
Algorithm internals, tree construction, ensemble theory, numpy |
Passing test suite + technical writeup |
8-10 hrs |
Churn API |
End-to-end pipeline, calibration, FastAPI, Docker, testing |
Deployed API + passing test suite |
7-9 hrs |
Total estimated time: 25-31 hours across Month 4 (roughly 7-8 hrs/week on top of study time)
Where to publish:
All three projects: public GitHub repos with descriptive READMEs
Titanic/House Prices: link your Kaggle profile showing leaderboard position
Churn API: record a 2-minute Loom walkthrough of the live API — one video is worth fifty bullet points on a resume
Return to README.md · Previous: 05_classical_ml_in_production.md