Capstone — Full ML Project, from Data to API
joblib (Lesson 21), and wrap it in a working FastAPI endpoint (Lesson 22) — a real, complete data-to-API pipeline. It closes with a full recap of all 5 sections and honest suggestions for what to build next.
The Capstone Brief
Every earlier project in this course (Lesson 12's house price model, Lesson 16's model comparison report, Lesson 20's customer segmentation) practiced one slice of the workflow at a time. This capstone runs the FULL pipeline end to end, on one dataset, finishing with something a real user could actually call over HTTP.
student_scores.csv dataset (study hours, attendance, previous score, assignments completed → final score) introduced in Lesson 21: preprocess the data, train and compare several regression models, evaluate them with proper metrics, pick the best one, save it with joblib, and serve it through a FastAPI /predict endpoint — exactly the same schema Lesson 22's API already used.joblib.dump(), exactly as covered in Lesson 21./predict pattern from Lesson 22.The Dataset
The same four features this course has used since Lesson 21 and Lesson 22's worked examples — kept consistent on purpose, so this capstone is a genuine continuation rather than a brand-new unrelated exercise. Illustrative synthetic data, deliberately similar in spirit to the "500 real student records" a real capstone would collect and clean.
| StudentID | StudyHours | Attendance% | PrevScore | AssignmentsDone | FinalScore |
|---|---|---|---|---|---|
| 1 | 4.5 | 92 | 78 | 9 | 84 |
| 2 | 1.2 | 65 | 52 | 4 | 49 |
| 3 | 6.0 | 98 | 88 | 10 | 93 |
| 4 | 2.8 | 74 | 61 | 6 | 60 |
| 5 | 3.9 | 80 | 70 | 7 | 72 |
import pandas as pd df = pd.read_csv("student_scores.csv") print(df.shape) print(df.describe()) print(df.isnull().sum()) # confirm no missing values before training
Preprocessing
A proper train/test split (Lesson 4) BEFORE any scaling happens — fitting a scaler on the full dataset first would leak information from the test set into training, exactly the mistake Lesson 4 and Lesson 21 both warned against.
from sklearn.model_selection import train_test_split features = ["StudyHours", "Attendance", "PrevScore", "AssignmentsDone"] X = df[features] y = df["FinalScore"] X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) print(f"Train: {X_train.shape[0]} rows · Test: {X_test.shape[0]} rows")
Scaling isn't applied here directly — instead, each model below gets wrapped in its own Pipeline with a StandardScaler step (Lesson 21's Section 5 pattern), so scaling is always fit ONLY on training data, correctly, no matter which model is being tried.
Train & Compare Multiple Models
Five regression approaches from Section 2 of this course, each wrapped in the same StandardScaler pipeline, trained on the exact same split, and compared fairly with 5-fold cross-validation (Lesson 4) on the training set.
from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LinearRegression, Ridge from sklearn.tree import DecisionTreeRegressor from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor from sklearn.model_selection import cross_val_score candidates = { "Linear Regression": LinearRegression(), "Ridge": Ridge(alpha=1.0), "Decision Tree": DecisionTreeRegressor(max_depth=5, random_state=42), "Random Forest": RandomForestRegressor(n_estimators=200, random_state=42), "Gradient Boosting": GradientBoostingRegressor(n_estimators=200, random_state=42), } results = {} for name, estimator in candidates.items(): pipe = Pipeline([("scaler", StandardScaler()), ("model", estimator)]) scores = cross_val_score(pipe, X_train, y_train, cv=5, scoring="r2") results[name] = scores.mean() print(f"{name:<20} CV R²: {scores.mean():.3f} (+/- {scores.std():.3f})")
Consistent with Section 2's general lessons (Lesson 10 especially), the ensemble methods — Random Forest and Gradient Boosting — outperform the plain linear models on this illustrative data, and Gradient Boosting edges out Random Forest slightly. That makes it this capstone's leading candidate, pending a proper held-out test-set check in Section 5.
Evaluate & Pick the Best Model
Cross-validation picked a leading candidate — now it gets a final, honest check against the held-out X_test/y_test set it has never seen, using the full regression metric set from Lesson 15: MAE, RMSE, and R².
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import numpy as np best_pipeline = Pipeline([ ("scaler", StandardScaler()), ("model", GradientBoostingRegressor(n_estimators=200, random_state=42)) ]) best_pipeline.fit(X_train, y_train) y_pred = best_pipeline.predict(X_test) mae = mean_absolute_error(y_test, y_pred) rmse = np.sqrt(mean_squared_error(y_test, y_pred)) r2 = r2_score(y_test, y_pred) print(f"Test MAE: {mae:.2f}") print(f"Test RMSE: {rmse:.2f}") print(f"Test R²: {r2:.3f}")
| Model | Test MAE | Test RMSE | Test R² |
|---|---|---|---|
| Linear Regression | 6.8 | 8.4 | 0.70 |
| Random Forest | 4.1 | 5.3 | 0.83 |
| Gradient Boosting | 3.6 | 4.9 | 0.86 |
Gradient Boosting wins on all three metrics on the held-out test set — lowest error, highest R². It's the model this capstone carries forward into Section 6.
GridSearchCV or RandomizedSearchCV over Gradient Boosting's n_estimators, max_depth, and learning_rate (Lesson 11) to squeeze out any further improvement — skipped here for brevity, but a genuinely worthwhile step before shipping a real model.Save the Winning Pipeline with Joblib
Exactly Lesson 21's pattern: the WHOLE pipeline (scaler + model), saved as a single joblib file, so nothing can get out of sync when it's loaded later.
import joblib joblib.dump(best_pipeline, "student_score_model_final.joblib") print("Final capstone pipeline saved to student_score_model_final.joblib")
Wrap It in a FastAPI Endpoint
The exact StudentInput / PredictionOutput / /predict pattern from Lesson 22, pointed at this capstone's newly saved student_score_model_final.joblib.
from fastapi import FastAPI from pydantic import BaseModel import joblib import pandas as pd app = FastAPI(title="Student Score Predictor API — Capstone", version="1.0") model = joblib.load("student_score_model_final.joblib") class StudentInput(BaseModel): study_hours: float attendance: float prev_score: float assignments_done: int class PredictionOutput(BaseModel): predicted_score: float confidence: str @app.get("/health") def health(): return {"status": "ok", "model_loaded": model is not None} @app.post("/predict", response_model=PredictionOutput) def predict(student: StudentInput): features = pd.DataFrame([{ "StudyHours": student.study_hours, "Attendance": student.attendance, "PrevScore": student.prev_score, "AssignmentsDone": student.assignments_done }]) pred = model.predict(features)[0] confidence = "high" if pred > 80 else "medium" if pred > 60 else "low" return PredictionOutput(predicted_score=round(float(pred), 1), confidence=confidence) # Run with: uvicorn api:app --reload
Starting the server with uvicorn api:app --reload and visiting /docs gives a fully working, testable prediction API — trained, evaluated, saved, and served, all from this one lesson.
The Complete Pipeline, Start to Finish
Every step from Sections 2–6, combined into one runnable training script.
import pandas as pd import numpy as np import joblib from sklearn.model_selection import train_test_split, cross_val_score from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline from sklearn.linear_model import LinearRegression, Ridge from sklearn.tree import DecisionTreeRegressor from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score # 1. Load df = pd.read_csv("student_scores.csv") features = ["StudyHours", "Attendance", "PrevScore", "AssignmentsDone"] X, y = df[features], df["FinalScore"] # 2. Split X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) # 3. Compare candidates with cross-validation candidates = { "Linear Regression": LinearRegression(), "Ridge": Ridge(alpha=1.0), "Decision Tree": DecisionTreeRegressor(max_depth=5, random_state=42), "Random Forest": RandomForestRegressor(n_estimators=200, random_state=42), "Gradient Boosting": GradientBoostingRegressor(n_estimators=200, random_state=42), } best_name, best_score = None, -np.inf for name, estimator in candidates.items(): pipe = Pipeline([("scaler", StandardScaler()), ("model", estimator)]) score = cross_val_score(pipe, X_train, y_train, cv=5, scoring="r2").mean() print(f"{name:<20} CV R²: {score:.3f}") if score > best_score: best_name, best_score = name, score print(f"\nBest candidate: {best_name} (CV R² = {best_score:.3f})") # 4. Fit the winner on the full training set, evaluate on the held-out test set best_pipeline = Pipeline([("scaler", StandardScaler()), ("model", candidates[best_name])]) best_pipeline.fit(X_train, y_train) y_pred = best_pipeline.predict(X_test) print(f"Test MAE: {mean_absolute_error(y_test, y_pred):.2f}") print(f"Test RMSE: {np.sqrt(mean_squared_error(y_test, y_pred)):.2f}") print(f"Test R²: {r2_score(y_test, y_pred):.3f}") # 5. Save with joblib — ready for Section 7's FastAPI app joblib.dump(best_pipeline, "student_score_model_final.joblib") print("Saved to student_score_model_final.joblib")
Everything You Covered — a Five-Section Recap
24 lessons is real ground covered. Before the wrap-up, here's the whole course compressed into five lines — one per section. If any of these feel unfamiliar, that's a completely normal reason to go back and revisit — nothing about finishing this course means every detail has to be perfectly memorized.
What to Build Next
Finishing 24 lessons is a real milestone — but the skill itself keeps building through what happens after. A few honest, concrete next steps:
api.py running on an actual server or hosting platform, not just localhost — that's the difference between a working demo and a genuinely deployed project.Use the candidates, best_pipeline, and api.py objects from Sections 4–8 as your starting point for each task below.
Using Lesson 11's
GridSearchCV, search over Gradient Boosting's n_estimators (e.g. [100, 200, 300]), max_depth (e.g. [2, 3, 4]), and learning_rate (e.g. [0.01, 0.1, 0.2]). Does the tuned version beat this lesson's default-parameter Test R² from Section 5?
Following Lesson 22's Task 3 pattern, add a Pydantic model wrapping
list[StudentInput] and a POST /predict/batch endpoint that returns a list of predictions in one request.
Find a real, public regression dataset (a Kaggle dataset is a common starting point) and re-run Section 8's complete script against it: same structure, different data. Write a short paragraph comparing the winning model and its metrics to this lesson's illustrative results.
💡 Show hints if you're stuck
- Task 1:
GridSearchCV(pipeline, param_grid, cv=5, scoring="r2")— remember the parameter names need themodel__prefix when tuning a step inside a Pipeline, e.g.model__n_estimators. - Task 2:
class BatchInput(BaseModel): students: list[StudentInput], then build one combined DataFrame from all of them before callingmodel.predict()once. - Task 3: Keep the exact same
features/targetvariable structure — only the CSV and column names need to change, the rest of Section 8's script can stay the same shape.