Final Project — Student Performance Predictor
student_performance.csv dataset from raw CSV all the way to a trained, evaluated, interpretable machine learning model. Along the way you'll reuse pandas EDA from Section 2 (.isnull().sum(), .describe()), the correlation concepts from Section 4, train_test_split and RandomForestClassifier from Lessons 24–26, and the exact evaluation tools — classification_report and a confusion matrix — from Lesson 27. Nothing here is a new API; it's everything you already know, assembled into one real pipeline.
The Project Brief
Imagine a school wants a rough early-warning tool: given a few easy-to-collect numbers about a student, can we flag who's at risk of failing an upcoming exam before it happens? We'll build exactly that — a binary classifier that predicts pass or fail from three numeric features.
As with Lesson 13's retail dataset, student_performance.csv here is a plausible, illustrative synthetic dataset built to teach the workflow end to end — not a real published study. The pandas and scikit-learn methods are 100% real and correct; the specific numbers are stand-ins for whatever CSV a real project would hand you.
.isnull().sum() and .describe(), then clean what's missing..corr() — which raw features move together with passed, echoing Section 4's correlation lesson.train_test_split, stratified so both splits keep the same pass/fail balance.classification_report and a confusion matrix, exactly like Lesson 27.Load the Data and Take a First Look
Same opening move as every EDA in this course: read the file, then look before touching anything.
import pandas as pd df = pd.read_csv("student_performance.csv") print(df.shape) # (2000, 4) print(df.head())
| study_hours_per_week | attendance_pct | past_exam_avg | passed | |
|---|---|---|---|---|
| 0 | 12.5 | 91.2 | 74.0 | 1 |
| 1 | 4.0 | 62.5 | 48.5 | 0 |
| 2 | 18.0 | NaN | 88.0 | 1 |
| 3 | 7.5 | 70.0 | 55.0 | 0 |
| 4 | 9.0 | 81.0 | 60.0 | 1 |
Row 2 is already missing its attendance_pct — worth remembering for the next step. Next, the dtypes pandas inferred:
print(df.dtypes) # study_hours_per_week float64 # attendance_pct float64 # past_exam_avg float64 # passed int64 # dtype: object
passed has no missing values, so it never got upgraded to float64 the way Lesson 13's quantity column did. It's the target we're trying to predict, so it will be kept separate from X (the features) once we get to the train/test split in Section 5.Check for Missing Values and Describe the Stats
Two quick pandas calls before any modeling happens: .isnull().sum() to find gaps, and .describe() to get a feel for each column's range and center — the same habit from Lesson 13's EDA project.
print(df.isnull().sum()) # study_hours_per_week 0 # attendance_pct 34 # past_exam_avg 0 # passed 0 # dtype: int64 print(df.describe().round(2))
| stat | study_hours_per_week | attendance_pct | past_exam_avg | passed |
|---|---|---|---|---|
| count | 2000.0 | 1966.0 | 2000.0 | 2000.0 |
| mean | 10.42 | 78.35 | 68.91 | 0.59 |
| std | 4.87 | 12.64 | 14.22 | 0.49 |
| min | 0.50 | 40.00 | 30.00 | 0.00 |
| 25% | 6.80 | 70.10 | 58.75 | 0.00 |
| 50% | 10.20 | 79.00 | 69.00 | 1.00 |
| 75% | 13.90 | 87.20 | 79.10 | 1.00 |
| max | 25.00 | 100.00 | 100.00 | 1.00 |
passed is numeric (0 or 1), so .describe() summarizes it right alongside the real-valued features. Its mean of 0.59 is actually the most useful number in that column here — since the values are only 0 and 1, the mean is exactly the proportion of students who passed (59%). That's the class balance we'll carry into the train/test split.34 missing values in attendance_pct is under 2% of 2,000 rows — small enough to fill rather than drop, exactly the same call made in Lesson 13.
# Fill the small number of missing attendance values with the column median df["attendance_pct"] = df["attendance_pct"].fillna(df["attendance_pct"].median()) assert df.isnull().sum().sum() == 0, "Still missing values!"
A Correlation Heatmap
Before training anything, it's worth asking — in plain statistics terms from Section 4 — which raw features actually move together with passed. DataFrame.corr() computes the pairwise Pearson correlation between every numeric column, from -1 (perfectly opposite) through 0 (no linear relationship) to +1 (perfectly together).
corr = df.corr().round(2) print(corr) # A real project would typically visualize this with Seaborn (Section 3): # import seaborn as sns # sns.heatmap(corr, annot=True, cmap="Blues")
| study_hours | attendance | past_exam | passed | |
|---|---|---|---|---|
| study_hours | 1.00 | 0.34 | 0.41 | 0.52 |
| attendance | 0.34 | 1.00 | 0.38 | 0.46 |
| past_exam | 0.41 | 0.38 | 1.00 | 0.61 |
| passed | 0.52 | 0.46 | 0.61 | 1.00 |
past_exam_avg has the highest correlation with passed (0.61), followed by study_hours_per_week (0.52) and attendance_pct (0.46). All three are meaningfully correlated with the target — none is close to 0 — which is a good early sign that a model trained on them has something real to learn from. Keep this ranking in mind; Section 8 checks whether the trained model's own feature importances agree.Splitting Into Features and a Train/Test Set
Same rule from Lesson 24 onward: never evaluate a model on the same rows it was trained on. Separate X (the three feature columns) from y (the passed label), then hold out 20% of the rows for testing only.
from sklearn.model_selection import train_test_split feature_cols = ["study_hours_per_week", "attendance_pct", "past_exam_avg"] X = df[feature_cols] y = df["passed"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) print(f"Train: {X_train.shape}, Test: {X_test.shape}") # Train: (1600, 3), Test: (400, 3)
stratify=y tells train_test_split to preserve that same pass/fail ratio in both y_train and y_test — without it, an unlucky random split could leave the test set with a noticeably different balance, making the evaluation in Section 7 harder to trust.Training a RandomForestClassifier
This is a classification problem — passed is 0 or 1, not a continuous number — so it calls for a classifier, not a regressor. We'll use RandomForestClassifier from Lesson 26: an ensemble of decision trees that tends to handle non-linear relationships between features well and, as a bonus, ships with a built-in feature importance score for Section 8.
from sklearn.ensemble import RandomForestClassifier clf = RandomForestClassifier( n_estimators=200, max_depth=6, random_state=42 ) clf.fit(X_train, y_train)
n_estimators=200 means the forest builds 200 individual decision trees and averages their votes — more trees generally means a more stable prediction, at the cost of more compute. max_depth=6 caps how deep each individual tree can grow, which helps prevent any single tree (and therefore the forest) from memorizing quirks of the training data instead of learning general patterns — the overfitting concern first raised back in Lesson 24.LogisticRegression is just as legitimate a starting point for this pass/fail problem — it's simpler, faster, and its coefficients are directly interpretable. This lesson sticks with RandomForestClassifier throughout for consistency, but comparing the two is exactly Task 1 in this lesson's "Try It Yourself" challenge below.Evaluating the Model Properly
A single accuracy number can hide a lot — exactly the lesson from Lesson 27. Since passed isn't perfectly balanced (59/41), classification_report and a confusion matrix tell a much fuller story than .score() alone.
from sklearn.metrics import classification_report, confusion_matrix, accuracy_score y_pred = clf.predict(X_test) print(f"Accuracy: {accuracy_score(y_test, y_pred):.2f}") print(classification_report(y_test, y_pred, target_names=["Fail", "Pass"])) print(confusion_matrix(y_test, y_pred))
| precision | recall | f1-score | support | |
|---|---|---|---|---|
| Fail | 0.78 | 0.72 | 0.75 | 164 |
| Pass | 0.81 | 0.86 | 0.83 | 236 |
| accuracy | 0.80 | 400 | ||
| macro avg | 0.80 | 0.79 | 0.79 | 400 |
| weighted avg | 0.80 | 0.80 | 0.80 | 400 |
| Pred: Fail | Pred: Pass | |
|---|---|---|
| Actual: Fail | 118 | 46 |
| Actual: Pass | 34 | 202 |
Interpreting Feature Importance
A trained RandomForestClassifier exposes .feature_importances_ — one number per feature, summing to 1.0, measuring roughly how much each feature contributed to the forest's decisions across all its trees.
importances = pd.Series(clf.feature_importances_, index=feature_cols) print(importances.sort_values(ascending=False).round(2)) # past_exam_avg 0.47 # study_hours_per_week 0.31 # attendance_pct 0.22 # dtype: float64
past_exam_avg first, study_hours_per_week second, attendance_pct third. Real datasets don't always line up this cleanly — correlation is linear and pairwise, while feature importance accounts for interactions — but when the two views agree, it's a useful sanity check that the model learned something sensible rather than noise.past_exam_avg being the top feature means it's the most useful for prediction — not proof that raising it would cause a student to pass. A student's past average likely reflects a whole bundle of real-world factors (study habits, support at home, prior teaching quality) that the model never sees directly.The Complete Script, Start to Finish
Every step from this lesson, combined into one runnable pipeline against a CSV shaped like student_performance.csv.
import pandas as pd from sklearn.model_selection import train_test_split from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report, confusion_matrix, accuracy_score # 1. Load and take a first look df = pd.read_csv("student_performance.csv") print("Shape:", df.shape) print(df.head()) print(df.dtypes) # 2. Check for missing values, describe, then clean print(df.isnull().sum()) print(df.describe().round(2)) df["attendance_pct"] = df["attendance_pct"].fillna(df["attendance_pct"].median()) assert df.isnull().sum().sum() == 0 # 3. Correlation heatmap (printed here; visualize with sns.heatmap in a notebook) print(df.corr().round(2)) # 4. Split into features/target, then train/test feature_cols = ["study_hours_per_week", "attendance_pct", "past_exam_avg"] X = df[feature_cols] y = df["passed"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) # 5. Train the model clf = RandomForestClassifier(n_estimators=200, max_depth=6, random_state=42) clf.fit(X_train, y_train) # 6. Evaluate y_pred = clf.predict(X_test) print(f"Accuracy: {accuracy_score(y_test, y_pred):.2f}") print(classification_report(y_test, y_pred, target_names=["Fail", "Pass"])) print(confusion_matrix(y_test, y_pred)) # 7. Interpret feature importance importances = pd.Series(clf.feature_importances_, index=feature_cols) print(importances.sort_values(ascending=False).round(2))
Writing Up Findings
A trained model isn't the deliverable by itself — the point is turning it into a few statements someone at the school could actually act on. Treat these as an example of the kind of takeaway a real project like this would produce, not as claims about any real student population.
past_exam_avg is the strongest single predictor by both correlation and feature importance — an early-warning system built on this data would lean on it most.past_exam_avg who increases study_hours_per_week shows up as more likely to pass in this data.That's the full pipeline — and the full course core curriculum. Every earlier lesson fed into this one: pandas cleaning, correlation from Section 4's statistics, the classifier itself, and the exact same evaluation tools from Lesson 27. Section 6 next turns this project into something you can actually show off.
stratify=y passed to train_test_split for this dataset?clf.feature_importances_, which feature mattered most to the trained RandomForestClassifier?The base pipeline works end to end — now push it further. Use the cleaned df, X_train/X_test/y_train/y_test from Sections 5–9 as your starting point for each task below.
Swap
RandomForestClassifier for Lesson 25's LogisticRegression(max_iter=1000), fit it on the exact same X_train/y_train, and run it through the same classification_report/confusion_matrix code from Section 7. Does accuracy go up, down, or stay about the same? Which class (Fail or Pass) does each model handle better?
Imagine a fourth column,
assignments_submitted_pct, existed in the CSV. Add it to feature_cols, re-run the split and training from Sections 5–6, and re-print clf.feature_importances_. Does adding a feature change the ranking of the original three, or just add a fourth entry?
Try three different values of
max_depth (for example 3, 6, and 12) while keeping everything else fixed, retraining and re-evaluating each time. Does test accuracy keep improving as trees get deeper, or does it plateau or get worse — and what does that tell you about overfitting?
💡 Show hints if you're stuck
- Task 1:
from sklearn.linear_model import LogisticRegression, thenlr = LogisticRegression(max_iter=1000); lr.fit(X_train, y_train)— everything else stays identical. - Task 2:
feature_cols = ["study_hours_per_week", "attendance_pct", "past_exam_avg", "assignments_submitted_pct"], then rebuildX = df[feature_cols]before splitting again. - Task 3: Wrap the training/evaluation code in a small loop over
for depth in [3, 6, 12]:, printingaccuracy_score(y_test, clf.predict(X_test))each time.