Unit 4 · Tutorial 8 of 8
Worked Example: Student Grade Prediction
A second end-to-end build — this time on data about people. The modelling is familiar by now; what is new is the weight that interpretation carries. When a regression model is used to make decisions about individuals, a coefficient is no longer a curiosity. It is a claim about someone's future, and it has to be defended.
1 Learning objectives
By the end of this tutorial you will be able to
- Run the complete eight-stage regression workflow from Tutorial 7 on a different dataset, without prompting.
- Distinguish a feature you are permitted to use from a feature that produces a fair model, and explain why the two questions are different.
- Identify proxy variables — features that are not themselves protected attributes but encode membership in a protected group — and describe their consequences.
- Perform a subgroup analysis: compute performance metrics separately for groups defined by a sensitive attribute, and interpret the differences.
- Explain why a model can be accurate on average and harmful for a subgroup, with reference to the specific metrics involved.
- Read a coefficient table and identify which coefficients are stable enough to report and which are not.
- Write a model card — a structured document recording a model's purpose, data, performance, limitations and intended use.
- Decide, with justification, whether a given predictive model should be deployed, as distinct from whether it can be.
Estimated time
40 minutes of reading, plus 60–90 minutes to work through the notebook and the problem set. This is the last tutorial in Unit 4 and it is deliberately the most reflective — budget time for the discussion prompts, not just the code.
2 Motivating example
A university's teaching and learning office has a proposal.
"We lose too many students in first year. By the time we see them failing, it is too late to help. Build us a model that predicts each student's final mark at the end of week four, so we can reach out to the ones who are going to struggle."
The motivation is good. Early intervention works, and the university genuinely wants to help students who are at risk. But the request contains a decision that the requester has not noticed they are making: the model will be used to decide which students get support.
That changes everything about how the model must be evaluated. In the house price tutorial, a bad prediction cost money and could be corrected with a valuation. Here, a bad prediction costs a student their access to help — and if the model is wrong systematically for a particular group of students, it will withhold support from exactly the students who need it most, while appearing to work perfectly well on average.
This tutorial does the modelling properly, because that is the baseline standard. But it spends most of its effort on the questions that follow: is this model fair? what can its coefficients honestly be said to mean? and should it be used at all?
The two-question test
Before building any model about people, ask both of these questions. They are distinct and you need answers to both.
- Can we? Is the data available, are the features legal, is the model technically sound?
- Should we? Would using this model make the situation better, or would it entrench a disadvantage that already exists?
The house price model in Tutorial 7 raised the first question and answered it comfortably. This one raises the second, and the answer is genuinely not obvious.
3 Concept explanation
3.1 A reminder of the workflow
The eight stages from Tutorial 7 apply unchanged. What changes is the weight carried by stages 2, 4 and 8.
-
Frame the problem Target, units, horizon, cost of error — and here, crucially, who will be affected by a mistake.
-
Audit the features Not just for leakage, but for proxy variables and for features the subjects would object to being used.
-
Split the data Same as before. Train, validation, test.
-
Explore the training set Including the distribution of sensitive attributes, and whether the training data represents the population the model will serve.
-
Build the pipeline Same as before. Correctness is not optional just because the problem is sensitive.
-
Fit and evaluate on validation Now with subgroup metrics, not just overall metrics.
-
Diagnose Residual plots overall, and residual plots per subgroup.
-
Report — and decide A model card, a fairness analysis, and an explicit recommendation about deployment.
3.2 Permitted features and fair models
The first instinct when told to be fair is to remove the protected attribute from the feature set. Do not use gender. Do not use ethnicity. Do not use disability status. This is called fairness through unawareness, and it does not work.
It does not work because the other features carry the same information. If a student's postcode, secondary school, or the subject they study correlates with a protected attribute, then a model trained on those features can reconstruct the protected attribute's effect without ever being shown it. The protected attribute is still influencing the prediction — just indirectly, through a proxy variable.
Proxy variable
A feature that is not itself a protected attribute, but which correlates with one strongly enough that including it in a model reproduces much of the effect that including the protected attribute would have had.
Common examples in an educational setting:
| Feature | What it proxies | Why it is a problem |
|---|---|---|
| Home postcode | Socioeconomic status, and often ethnicity | Encodes disadvantage that the student did not choose and cannot change. Predictions become partly a function of where they grew up. |
| Secondary school name | Prior educational advantage or disadvantage | Students from under-resourced schools are penalised for the resourcing of their school, not their ability. |
| Term-time employment hours | Socioeconomic status | Students who must work to support themselves are flagged as at risk precisely because they are supporting themselves. |
| Caring responsibilities | Gender, socioeconomic status | Caring roles are distributed unequally and correlate with gender. |
| English as an additional language | Nationality, ethnicity, migration status | Can encode language background as a deficit rather than a difference. |
| Age at entry | Maturity, and indirectly socioeconomic background | Older students are more likely to be mature learners with caring or work responsibilities. |
None of these features is inherently illegitimate. Term-time employment is genuinely predictive of academic difficulty, and a model that ignored it would be less accurate. The problem is not that the features are wrong — it is that using them means the model is partly predicting disadvantage rather than need, and the intervention it triggers may then reinforce the disadvantage.
The proxy variable trade-off
Removing a proxy variable reduces the model's accuracy, which means fewer students get help. Keeping it means the help is distributed inequitably, reaching some students more reliably than others. There is no answer that is free of cost. The only wrong response is to make the choice without noticing that a choice was made.
3.3 Subgroup analysis
Fairness in a regression model is not one thing, and there is no single metric that captures it. What you can do is measure performance separately for each group you are concerned about, and look at the differences.
The standard approach computes the same metrics overall and per subgroup:
| Metric | What a difference means |
|---|---|
| RMSE per group | How large the typical error is for each group. A large gap means the model is less precise for one group. |
| MAE per group | The same, less influenced by outliers. |
| Mean residual per group (bias) | Whether the model systematically over- or under-predicts for a group. A non-zero mean residual is systematic error, which is far more concerning than random error. |
| R² per group | How much of the group's variance the model explains. A low R² means the model understands that group less well. |
| Coverage of the intervention | If the model triggers an intervention for the bottom 10% of predictions, what fraction of each group receives it? Compare with the fraction that would receive it under a perfect model. |
The last row is the one that matters most in practice. A model can have equal RMSE across groups and still distribute support unequally, because the threshold that triggers support interacts with the distribution of predictions in each group.
There is no fairness metric that satisfies everyone
Several mathematically precise definitions of fairness exist, and a well-known result shows that they cannot all hold at once except in degenerate cases. Equal error rates across groups and equal selection rates across groups, for example, generally conflict — you can have one or the other, not both, unless the base rates are identical.
This is not a reason to give up. It is a reason to decide which notion of fairness matters for this specific deployment, state the choice explicitly, and accept the trade-off knowingly. Module 6 covers this in detail; the purpose here is to make you suspicious of anyone who claims a model is simply "fair".
3.4 What a coefficient means when the data is about people
Tutorial 2 established that a coefficient is a partial association, not a causal effect, and Tutorial 7 warned against reading it causally. In an educational context the stakes are higher, and three specific misreadings are common.
| Misreading | Why it is wrong |
|---|---|
| "Term-time work reduces marks by 4 points per 10 hours." | The coefficient describes students who work and students who do not. Those two groups differ in many ways the model does not measure. The coefficient is not evidence that reducing a student's hours would raise their mark — and in fact a student who cut their hours might simply have less money and more stress. |
| "Attendance is the biggest factor — it has the largest coefficient." | A larger coefficient may simply reflect a feature with a wider range or smaller standard deviation. Standardised coefficients are comparable; raw ones are not. And even a standardised coefficient is not a claim about what would happen if you intervened on that feature. |
| "We control for prior attainment, so the remaining effect of school type is real." | Controlling for a variable that is itself a consequence of disadvantage removes part of the very effect you are trying to measure. Prior attainment is not a neutral baseline — it is a summary of everything that came before, including the disadvantage. |
The safe framing is always the same: in this dataset, among students with these measured characteristics, this feature was associated with a difference of this size in the predicted outcome. That statement is defensible. Anything stronger is not.
3.5 The model card
A model card is a short structured document that accompanies a model and records what it is for, how it was built, how well it works, and where it should not be used. It is the deliverable that turns a notebook into something an organisation can govern.
For a regression model of this kind, a model card should contain at least:
- Purpose — what decision the model supports, and for whom.
- Intended use — the specific context in which it may be used.
- Out-of-scope use — contexts in which it must not be used.
- Data — the population it was built from, and how representative that is of the population it will serve.
- Features — what was included, and what was excluded and why.
- Performance — overall metrics on held-out data, and the baseline comparison.
- Subgroup performance — the same metrics computed for each group of concern.
- Known limitations — the failure modes the developer has identified.
- Ethical considerations — the proxy variable decisions made, and the reasoning behind them.
- Monitoring plan — what would indicate that the model has stopped working, and what would trigger a review.
Two of these are routinely omitted and both are consequential. Out-of-scope use prevents a model built for one purpose being quietly repurposed for another. And the monitoring plan is what turns a one-off analysis into a system that can be held accountable over time.
3.6 Deciding whether to deploy
A model can be technically sound, well evaluated, honestly reported and still not worth deploying. The final judgement is not statistical.
A workable decision procedure:
-
Is it accurate enough to help? If the errors are larger than the intervention's benefit, the model cannot improve on doing nothing.
-
Does it beat the alternative? Compare against what the university does now, not against a perfect model. If the current process is "wait until they fail", a moderate model may be a clear improvement.
-
Is it fair enough? Are the subgroup differences small enough that the intervention reaches each group in proportion to need? If not, can the threshold be adjusted to compensate?
-
What happens when it is wrong? If a student is wrongly flagged, do they receive an unwelcome label? If a student is wrongly missed, do they lose access to help they would otherwise have received?
-
Can it be explained? If a student asks why they were flagged, is there an answer that respects them as a person rather than as a data point?
-
Can it be monitored and withdrawn? Is there a plan for detecting degradation, and a route to switching it off if it causes harm?
The honest answer to a well-built model in a sensitive domain is often "yes, but with these specific safeguards and a review in six months". That is a legitimate professional answer and it belongs in the report.
4 Visual illustration
5 Worked example
The dataset is synthetic: 900 first-year undergraduate records, with a mixture of prior attainment, engagement and demographic features. It has been constructed to contain a genuine proxy variable, so that the fairness analysis has something real to find. The workflow follows Tutorial 7 exactly, with the additional steps marked.
Step 1 — Frame the problem, with the affected party named
Target: the student's final module mark, on a 0–100 scale.
Horizon: predicted at the end of week four, using only information available by then.
Decision supported: which students are invited to an optional study-skills programme. The invitation is not compulsory and the programme is not recorded on a transcript.
Cost of error: asymmetric in a specific way. A false positive (a student flagged who would have been fine) costs a wasted invitation and a small amount of the student's time. A false negative (a student not flagged who then struggles) costs the student access to support at the moment it would have helped. Both matter, but the second is worse.
Who is affected: every first-year student in the cohort. The students are not customers; they are the subjects of a decision made about them by an institution that holds power over their education.
The framing changes the standard of evidence
In Tutorial 7, the affected party was a seller who could ignore the estimate. Here they cannot. A student who is wrongly not invited does not know the model exists, does not know they were missed, and cannot appeal. That asymmetry is what raises the standard of evidence the model has to meet.
Step 2 — Audit the features, with proxy variables in view
available_columns = [
# prior attainment
"prior_qualification_points", # UCAS-style points
"entry_maths_grade",
# engagement (weeks 1–4)
"vle_logins_weeks_1_4",
"assignment_1_mark",
"attendance_pct_weeks_1_4",
# context
"term_time_work_hours",
"caring_responsibilities",
"commute_minutes",
"home_postcode_area", # reduced to outward code
"secondary_school_type", # state / independent / FE college
# demographics
"age_at_entry",
"gender",
"ethnicity",
"disability_status",
"eal_flag", # English as an additional language
# NOT available at week 4
"final_mark", # the target
"exam_board_flag", # set at the end of the year
]
Applying the leakage test from Tutorial 7 removes final_mark (the
target) and exam_board_flag (set at the end of the year). That part is
straightforward.
The harder decisions concern the context features. None of them is a protected
attribute. All of them are available at week four. But three of them —
term_time_work_hours, home_postcode_area and
secondary_school_type — are strongly associated with socioeconomic
background, and one of them, caring_responsibilities, is associated
with gender. They are proxy variables.
| Feature | Decision | Reasoning |
|---|---|---|
gender, ethnicity, disability_status, eal_flag |
Exclude from model, retain for analysis | Using a protected attribute directly as a predictor is not appropriate. But these columns must be kept in the dataset so that subgroup analysis is possible — otherwise the model's fairness cannot be measured at all. |
term_time_work_hours |
Include, but flag | Genuinely predictive of academic difficulty, and the student can report it. But flagging a student as "at risk" partly because they work is close to penalising them for needing an income. Retained, and noted in the model card. |
home_postcode_area |
Exclude | A direct proxy for socioeconomic status that adds little predictive value once prior attainment is included. Removing it costs a small amount of accuracy and removes a substantial source of inequity. |
secondary_school_type |
Include, but flag | Predictive, but encodes the resourcing of the school the student attended rather than the student's own preparation. Retained for now; to be tested in the fairness analysis. |
caring_responsibilities |
Include, but flag | Strongly predictive and highly relevant to whether the student can attend an optional programme. Retained, with a note that the intervention design must account for it. |
| Prior attainment and engagement features | Include | The core of the model. These describe what the student has done, not who they are. |
Two features are excluded on fairness grounds, and four protected attributes are retained for analysis but excluded from the model. Those four retained columns are essential — a model whose fairness cannot be measured is a model whose fairness cannot be claimed.
Step 3 — Split, and check representativeness
X_train, X_temp, y_train, y_temp = train_test_split(
X, y, test_size=0.40, random_state=42, stratify=df["ethnicity"]
)
X_val, X_test, y_val, y_test = train_test_split(
X_temp, y_temp, test_size=0.50, random_state=42,
stratify=X_temp["ethnicity"]
)
# Keep the sensitive attributes aligned with the splits for later analysis
sensitive_train = X_train[PROTECTED].copy()
sensitive_val = X_val[PROTECTED].copy()
# Then drop them from the model features
X_train_model = X_train.drop(columns=PROTECTED)
X_val_model = X_val.drop(columns=PROTECTED)
Two details here are new.
- Stratified splitting. With a smaller dataset, an unstratified random split can produce a validation set with almost no students from a minority group, which makes subgroup metrics meaningless. Stratifying on the sensitive attribute preserves the group proportions across splits.
- Sensitive attributes are separated before dropping. They are removed from the model's feature set, but kept alongside so that the analysis in Step 7 is possible. If you delete them from the dataframe entirely at the start, you cannot check the model afterwards.
It is also worth checking whether the training data resembles the population the model will serve. If the cohort is 62% female and the training data is 71%, the model will be less well calibrated for male students — not because of any modelling error, but because there is less data to learn from.
Step 4 — Explore, and look for the structure that will drive the fairness result
print(y_train.describe())
print(f"skewness: {y_train.skew():.2f}")
# Distribution of the sensitive attributes in the training set
for col in PROTECTED:
print(f"\n{col}:")
print(X_train[col].value_counts(normalize=True).round(3))
# Do the proxy features differ across groups?
print("\nmean term_time_work_hours by ethnicity:")
print(X_train.groupby("ethnicity")["term_time_work_hours"].mean().round(1))
print("\nmean secondary_school_type == 'independent' by ethnicity:")
print((X_train.groupby("ethnicity")["secondary_school_type"]
.apply(lambda s: (s == "independent").mean())).round(3))
final mark: mean 63.4 std 13.8 min 21 max 96
skewness: -0.21
ethnicity:
group_a 0.612
group_b 0.198
group_c 0.112
group_d 0.078
mean term_time_work_hours by ethnicity:
group_a 8.2
group_b 9.1
group_c 13.4
group_d 15.7
mean secondary_school_type == 'independent' by ethnicity:
group_a 0.241
group_b 0.152
group_c 0.071
group_d 0.042
This is the structure that will produce a fairness problem. Students in group D work nearly twice as many hours as students in group A, and are six times less likely to have attended an independent school. Both features are in the model. Both are predictive of a lower mark. Neither is a protected attribute.
The model has no idea that group D exists. It is about to learn that working long hours and attending a state school predict lower marks — and it will then apply that learning uniformly, which means it will flag group D students more often than group A students, for reasons that have nothing to do with their individual ability.
Step 5 — Build the pipeline and fit
numeric_cols = [
"prior_qualification_points", "entry_maths_grade",
"vle_logins_weeks_1_4", "assignment_1_mark",
"attendance_pct_weeks_1_4", "term_time_work_hours",
"commute_minutes", "age_at_entry",
]
categorical_cols = [
"secondary_school_type", "caring_responsibilities",
]
numeric_pipe = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
])
categorical_pipe = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore", drop="first")),
])
preprocess = ColumnTransformer([
("num", numeric_pipe, numeric_cols),
("cat", categorical_pipe, categorical_cols),
])
model = Pipeline([
("preprocess", preprocess),
("regressor", LinearRegression()),
])
model.fit(X_train_model, y_train)
Nothing here is new. The pipeline is structurally identical to Tutorial 7's, with a smaller feature set.
Step 6 — Evaluate overall, then per subgroup
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
def group_metrics(y_true, y_pred, label):
rmse = np.sqrt(mean_squared_error(y_true, y_pred))
mae = mean_absolute_error(y_true, y_pred)
r2 = r2_score(y_true, y_pred)
bias = (y_true - y_pred).mean() # mean residual — systematic error
n = len(y_true)
print(f"{label:<18} n={n:>4} RMSE {rmse:>5.2f} MAE {mae:>5.2f} "
f"R² {r2:>6.3f} bias {bias:>+6.2f}")
y_pred_val = model.predict(X_val_model)
print("=== OVERALL ===")
group_metrics(y_val, y_pred_val, "all students")
print("\n=== BY ETHNICITY ===")
for group in sorted(sensitive_val["ethnicity"].unique()):
mask = sensitive_val["ethnicity"] == group
group_metrics(y_val[mask], y_pred_val[mask], f" {group}")
print("\n=== BY DISABILITY STATUS ===")
for group in sorted(sensitive_val["disability_status"].unique()):
mask = sensitive_val["disability_status"] == group
group_metrics(y_val[mask], y_pred_val[mask], f" {group}")
=== OVERALL ===
all students n= 180 RMSE 9.42 MAE 7.51 R² 0.714 bias +0.00
=== BY ETHNICITY ===
group_a n= 110 RMSE 8.88 MAE 7.10 R² 0.742 bias +0.62
group_b n= 36 RMSE 9.71 MAE 7.68 R² 0.702 bias −0.51
group_c n= 20 RMSE 10.94 MAE 8.72 R² 0.618 bias −1.84
group_d n= 14 RMSE 12.31 MAE 9.96 R² 0.412 bias −6.21
=== BY DISABILITY STATUS ===
no n= 152 RMSE 9.28 MAE 7.42 R² 0.722 bias +0.18
yes n= 28 RMSE 10.02 MAE 7.98 R² 0.668 bias −0.94
The overall model looks reasonable: RMSE 9.42 marks, R² 0.71, no overall bias. A report that stopped here would be misleading in three specific ways.
- R² ranges from 0.74 to 0.41 across ethnic groups. The model explains less than half the variance for group D. It understands that group substantially less well than the others.
- Group D has a mean residual of −6.21 marks. The model systematically under-predicts group D — it predicts marks that are on average six marks lower than the students actually achieve. This is not random error; it is a consistent directional error, and it is the most serious finding in the table.
- Group D has the smallest sample. Fourteen students in the validation set. The estimates for this group are themselves uncertain, and a responsible analysis would report confidence intervals and note the small n rather than treating the numbers as precise.
What the mean residual means for a student
The model under-predicts group D by six marks on average. In a system where the bottom 10% of predictions are flagged for support, an under-prediction of six marks moves a student closer to that threshold. Group D students will be flagged for support more often than their actual performance warrants — and while being offered support may sound benign, the flagging is also the mechanism by which the institution forms an impression of who is struggling. The model is not neutral about group D. It is making a claim about them that is, on average, too low.
Step 7 — Trace the bias to its source
A subgroup difference is a symptom. To act on it, the cause has to be identified. Two checks:
# 1. How does the model distribute support across groups?
threshold = np.percentile(y_pred_val, 10) # bottom 10% of predictions
flagged = y_pred_val <= threshold
print(f"threshold: {threshold:.1f} marks\n")
print("group n flagged rate actual_bottom10 over_flag")
for group in sorted(sensitive_val["ethnicity"].unique()):
mask = (sensitive_val["ethnicity"] == group).values
n_grp = mask.sum()
n_flag = (flagged & mask).sum()
# what fraction of this group is genuinely in the bottom 10% of actual marks?
actual = (y_val <= np.percentile(y_val, 10)).values
actual_rate = (actual & mask).sum() / n_grp
print(f"{group:<7} {n_grp:>3} {n_flag:>4} "
f"{n_flag/n_grp:>5.1%} {actual_rate:>5.1%} "
f"{n_flag/n_grp - actual_rate:>+6.1%}")
# 2. Refit without the flagged proxy features and compare subgroup bias
proxy_cols = ["term_time_work_hours", "secondary_school_type"]
model_noproxy = Pipeline([
("preprocess", ColumnTransformer([
("num", numeric_pipe,
[c for c in numeric_cols if c not in proxy_cols]),
("cat", categorical_pipe,
[c for c in categorical_cols if c not in proxy_cols]),
])),
("regressor", LinearRegression()),
]).fit(X_train_model, y_train)
y_pred_np = model_noproxy.predict(X_val_model)
print("\n=== group D bias, with and without proxy features ===")
for label, preds in [("with proxies", y_pred_val), ("without proxies", y_pred_np)]:
mask = (sensitive_val["ethnicity"] == "group_d").values
bias = (y_val[mask] - preds[mask]).mean()
r2 = r2_score(y_val[mask], preds[mask])
print(f"{label:<18} bias {bias:>+6.2f} R² {r2:>6.3f}")
threshold: 50.5 marks
group n flagged rate actual_bottom10 over_flag
group_a 110 6 5.5% 4.5% +1.0%
group_b 36 4 11.1% 8.3% +2.8%
group_c 20 4 20.0% 15.0% +5.0%
group_d 14 4 28.6% 21.4% +7.2%
=== group D bias, with and without proxy features ===
with proxies bias -6.21 R² 0.412
without proxies bias -2.08 R² 0.298
Two findings, and they point in opposite directions.
- The support is distributed unequally. Group D students are flagged at 28.6% versus 5.5% for group A. Part of that is genuine need — 21.4% of group D are in the actual bottom 10%, compared with 4.5% of group A. But the over-flagging is also unequal: +7.2 percentage points for group D, +1.0 for group A.
- Removing the proxy features reduces the bias but also the accuracy. Group D's mean residual shrinks from −6.21 to −2.08 — a substantial improvement. But R² for group D falls from 0.41 to 0.30, and the overall model's R² falls as well. The model is now fairer and less useful.
This is the trade-off from Section 3.2, made concrete with numbers. There is no version of this model that is both as accurate and as fair as the alternatives. Which one to deploy depends on which cost is judged more serious — and that is not a statistical question.
Step 8 — Read the coefficients, with the correlation structure in mind
regressor = model.named_steps["regressor"]
features = model.named_steps["preprocess"].get_feature_names_out()
coef_df = pd.DataFrame({
"feature": features,
"coef": regressor.coef_,
})
# Standardised features: coefficients are per standard deviation
# Convert to per-original-unit by dividing by the feature std dev
stds = X_train_model[numeric_cols].std()
for name, sd in stds.items():
key = f"num__{name}"
coef_df.loc[coef_df["feature"] == key, "coef"] /= sd
print(coef_df.sort_values("coef", key=abs, ascending=False).to_string(index=False))
# How stable are these? Refit on 20 bootstrap samples and look at the spread.
boots = []
for seed in range(20):
idx = np.random.RandomState(seed).choice(len(X_train_model), len(X_train_model), replace=True)
m = clone(model).fit(X_train_model.iloc[idx], y_train.iloc[idx])
boots.append(m.named_steps["regressor"].coef_)
boots = np.array(boots)
coef_df["std_over_boot"] = boots.std(axis=0)
coef_df["stability"] = coef_df["coef"].abs() / coef_df["std_over_boot"].replace(0, np.nan)
print("\ncoefficient stability (|coef| / bootstrap sd):")
print(coef_df[["feature", "coef", "std_over_boot", "stability"]]
.sort_values("stability").to_string(index=False))
feature coef
num__assignment_1_mark 0.412
num__attendance_pct_weeks_1_4 0.287
num__prior_qualification_points 0.196
num__vle_logins_weeks_1_4 0.121
num__term_time_work_hours -0.108
cat__secondary_school_type_independent 0.094
num__entry_maths_grade 0.063
num__commute_minutes -0.041
num__age_at_entry 0.019
coefficient stability (|coef| / bootstrap sd):
feature coef sd_over_boot stability
num__age_at_entry 0.019 0.031 0.61
num__commute_minutes -0.041 0.038 1.08
num__entry_maths_grade 0.063 0.041 1.54
cat__secondary_school_type_independent 0.094 0.052 1.81
num__term_time_work_hours -0.108 0.044 2.45
num__vle_logins_weeks_1_4 0.121 0.039 3.10
num__prior_qualification_points 0.196 0.036 5.44
num__attendance_pct_weeks_1_4 0.287 0.029 9.90
num__assignment_1_mark 0.412 0.022 18.73
Three coefficients are stable enough to report with confidence: assignment 1 mark, attendance, and prior qualification points. They are the core of the model and their signs and magnitudes are robust.
Two coefficients are not stable: age_at_entry and
commute_minutes. Their bootstrap standard deviations are comparable to
their point estimates, meaning that a different sample of the same population could
easily produce a coefficient of the opposite sign. They should not appear in any
report, and they should not be used to justify a decision. If the module leader asks
"does commuting hurt marks?", the honest answer is that this dataset cannot say.
Stability is not the same as causality
The attendance coefficient is stable at 0.287 marks per percentage point of attendance, and it is the second-largest in the model. That is a robust association in this dataset. It is not evidence that attending more lectures would raise a student's mark by that amount. Students who attend more may differ in motivation, prior preparation, health, or caring responsibilities in ways the model does not measure. A stable coefficient is one you can report; it is not one you can act on causally.
Step 9 — Evaluate on the test set, once
y_pred_test = model.predict(X_test_model)
print("=== TEST SET ===")
group_metrics(y_test, y_pred_test, "all students")
print("\nby ethnicity:")
for group in sorted(X_test["ethnicity"].unique()):
mask = (X_test["ethnicity"] == group).values
if mask.sum() >= 5:
group_metrics(y_test[mask], y_pred_test[mask], f" {group}")
=== TEST SET ===
all students n= 180 RMSE 9.58 MAE 7.63 R² 0.701 bias +0.11
by ethnicity:
group_a n= 110 RMSE 8.95 MAE 7.18 R² 0.729 bias +0.44
group_b n= 36 RMSE 9.88 MAE 7.79 R² 0.688 bias −0.62
group_c n= 20 RMSE 11.20 MAE 8.91 R² 0.601 bias −2.01
group_d n= 14 RMSE 12.64 MAE 10.12 R² 0.398 bias −6.48
The test results confirm the validation findings, including the group D bias. The pattern is not an artefact of the validation split — it is a genuine property of the model.
Step 10 — Write the model card
Model card — first-year module mark prediction
Purpose
To identify first-year students who would benefit from an optional study-skills programme, at the end of week four, so that invitations can be issued before the first assessment period.
Intended use
As one input to a human decision about which students to invite. The invitation is optional, unrecorded on the transcript, and does not affect assessment.
Out-of-scope use
- Must not be used to grade, rank, or make progression decisions.
- Must not be shared with individual students as a forecast of their result.
- Must not be applied to students outside the first-year cohort it was built on.
- Must not be used beyond week eight — its features are all from weeks 1–4.
Data
900 first-year undergraduate records from a single institution, 2022–2024 entry. The cohort is 61% group A, 20% group B, 11% group C and 8% group D by ethnicity. The dataset is small for subgroup analysis: group D has 14 students in each of the validation and test sets, so the subgroup metrics carry substantial uncertainty.
Features
Prior attainment, week 1–4 engagement and a small number of context features.
home_postcode_area was excluded as a socioeconomic proxy that added
little predictive value. term_time_work_hours,
caring_responsibilities and secondary_school_type were
retained despite being proxies, because they are strongly predictive and directly
relevant to whether a student can attend an optional programme.
gender, ethnicity, disability_status and
eal_flag were excluded from the model and retained for analysis only.
Performance (held-out test set, n = 180)
| Group | n | RMSE | MAE | R² | Mean residual |
|---|---|---|---|---|---|
| All students | 180 | 9.58 | 7.63 | 0.701 | +0.11 |
| Group A | 110 | 8.95 | 7.18 | 0.729 | +0.44 |
| Group B | 36 | 9.88 | 7.79 | 0.688 | −0.62 |
| Group C | 20 | 11.20 | 8.91 | 0.601 | −2.01 |
| Group D | 14 | 12.64 | 10.12 | 0.398 | −6.48 |
Mean baseline RMSE: 13.82 marks. The model reduces error by about 31%.
Known limitations
- Systematic under-prediction for group D. The model predicts marks approximately 6.5 marks lower than students in group D actually achieve. This is the most serious limitation and the reason for the deployment conditions below.
- Lower R² for groups C and D, indicating the model understands these groups less well.
- Small subgroup sample sizes make the per-group estimates uncertain.
- Built on a single institution's data and may not transfer.
- The two largest coefficients — assignment 1 mark and attendance — are robust associations, not causal effects. Attending more lectures will not mechanically raise a mark by 0.29 per percentage point.
Ethical considerations
Three features in the model are socioeconomic or gender proxies. Removing them reduces group D's bias from −6.21 to −2.08 marks but reduces overall R² from 0.714 to approximately 0.60. The team judged that the accuracy cost was worth paying if the deployment conditions below are met, and recommends re-testing without the proxies after one academic year of operation.
Deployment conditions
- Support must be offered, not withheld. The model may be used to invite students to an optional programme. It must not be used to exclude anyone from any provision.
- Group D students flagged by the model must be reviewed individually before any action is taken, given the systematic bias identified.
- No prediction is shown to a student. The output is an invitation, not a forecast.
- Monitoring plan: subgroup metrics to be recomputed each semester. A group D mean residual below −4 marks, or an R² gap above 0.25 between any two groups, triggers a review and possible withdrawal.
- Review date: end of the current academic year, with a decision on whether to refit without the proxy features.
Recommendation
Deploy in a limited pilot with the conditions above, on the basis that the current process — waiting until students fail — is worse for every group. The pilot must include an independent review of the group D findings before any expansion.
The complete code
import numpy as np
import pandas as pd
from sklearn.base import clone
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
RANDOM_STATE = 42
TARGET = "final_mark"
PROTECTED = ["gender", "ethnicity", "disability_status", "eal_flag"]
NUMERIC = [
"prior_qualification_points", "entry_maths_grade",
"vle_logins_weeks_1_4", "assignment_1_mark",
"attendance_pct_weeks_1_4", "term_time_work_hours",
"commute_minutes", "age_at_entry",
]
CATEGORICAL = ["secondary_school_type", "caring_responsibilities"]
EXCLUDE_PROXY = ["home_postcode_area"] # dropped on fairness grounds
# ------------------------------------------------------------------
# Load
# ------------------------------------------------------------------
df = pd.read_csv("student_cohort.csv")
y = df[TARGET]
sensitive = df[PROTECTED].copy()
feature_cols = NUMERIC + CATEGORICAL + PROTECTED # keep protected for splitting
X_raw = df[feature_cols + EXCLUDE_PROXY]
X_raw = X_raw.drop(columns=EXCLUDE_PROXY)
# ------------------------------------------------------------------
# Split (stratified on ethnicity so subgroups survive the split)
# ------------------------------------------------------------------
X_train, X_temp, y_train, y_temp = train_test_split(
X_raw, y, test_size=0.40, random_state=RANDOM_STATE,
stratify=df["ethnicity"],
)
X_val, X_test, y_val, y_test = train_test_split(
X_temp, y_temp, test_size=0.50, random_state=RANDOM_STATE,
stratify=X_temp["ethnicity"],
)
# Keep sensitive columns aligned, then drop them from the model features
sens_train = X_train[PROTECTED].reset_index(drop=True)
sens_val = X_val[PROTECTED].reset_index(drop=True)
sens_test = X_test[PROTECTED].reset_index(drop=True)
X_train_m = X_train.drop(columns=PROTECTED)
X_val_m = X_val.drop(columns=PROTECTED)
X_test_m = X_test.drop(columns=PROTECTED)
# ------------------------------------------------------------------
# Pipeline
# ------------------------------------------------------------------
numeric_pipe = Pipeline([
("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler()),
])
categorical_pipe = Pipeline([
("impute", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore", drop="first")),
])
preprocess = ColumnTransformer([
("num", numeric_pipe, NUMERIC),
("cat", categorical_pipe, CATEGORICAL),
])
model = Pipeline([
("preprocess", preprocess),
("regressor", LinearRegression()),
]).fit(X_train_m, y_train)
# ------------------------------------------------------------------
# Metrics, overall and per subgroup
# ------------------------------------------------------------------
def group_metrics(y_true, y_pred, label):
rmse = np.sqrt(mean_squared_error(y_true, y_pred))
mae = mean_absolute_error(y_true, y_pred)
r2 = r2_score(y_true, y_pred)
bias = (y_true - y_pred).mean()
print(f"{label:<22} n={len(y_true):>4} RMSE {rmse:>5.2f} "
f"MAE {mae:>5.2f} R² {r2:>6.3f} bias {bias:>+6.2f}")
y_pred_val = model.predict(X_val_m)
group_metrics(y_val, y_pred_val, "all students")
for col in ["ethnicity", "disability_status", "gender"]:
print(f"\n--- by {col} ---")
for grp in sorted(sens_val[col].unique()):
mask = (sens_val[col] == grp).values
if mask.sum() >= 5:
group_metrics(y_val[mask], y_pred_val[mask], f" {grp}")
# ------------------------------------------------------------------
# Coefficients with bootstrap stability
# ------------------------------------------------------------------
regressor = model.named_steps["regressor"]
features = model.named_steps["preprocess"].get_feature_names_out()
coef_df = pd.DataFrame({"feature": features, "coef": regressor.coef_})
# Convert numeric coefficients to per-original-unit
for name in NUMERIC:
key = f"num__{name}"
coef_df.loc[coef_df["feature"] == key, "coef"] /= X_train_m[name].std()
# Bootstrap stability
boots = []
for seed in range(20):
idx = np.random.RandomState(seed).choice(
len(X_train_m), len(X_train_m), replace=True
)
m = clone(model).fit(X_train_m.iloc[idx], y_train.iloc[idx])
boots.append(m.named_steps["regressor"].coef_)
coef_df["boot_sd"] = np.array(boots).std(axis=0)
coef_df["stability"] = coef_df["coef"].abs() / coef_df["boot_sd"].replace(0, np.nan)
print("\ncoefficients, ordered by stability:")
print(coef_df.sort_values("stability")[
["feature", "coef", "boot_sd", "stability"]
].to_string(index=False))
# ------------------------------------------------------------------
# Final evaluation on the test set
# ------------------------------------------------------------------
y_pred_test = model.predict(X_test_m)
print("\n=== TEST SET ===")
group_metrics(y_test, y_pred_test, "all students")
for grp in sorted(sens_test["ethnicity"].unique()):
mask = (sens_test["ethnicity"] == grp).values
if mask.sum() >= 5:
group_metrics(y_test[mask], y_pred_test[mask], f" {grp}")
6 Interactive activity
Make the trade-off yourself
In the Tutorial 8 notebook, the fairness–accuracy trade-off is not abstract. You will fit the model with four different feature sets and measure both the overall accuracy and the group D bias in each case. Your task is to decide which one to recommend, and to defend the decision in writing.
- Model A — full feature set. All features except the protected attributes. This is what the worked example used.
-
Model B — no socioeconomic proxies. Remove
term_time_work_hours,secondary_school_typeandcaring_responsibilities. - Model C — proxy-free and threshold-adjusted. Use Model B's features, but choose the support threshold so that the selection rate for each group matches the group's rate in the actual bottom 10%.
- Model D — accuracy-focused. Model A's features plus the postcode area that was excluded on fairness grounds. This is the highest-accuracy version available.
For each model, record:
- Overall RMSE, MAE, R² on the validation set.
- R² and mean residual per ethnic group.
- The percentage of each group flagged for support, and the corresponding actual rate.
- The number of students who would be flagged under each model and would not be flagged under the others.
Then write 200 words recommending one of the four, on the assumption that the pilot runs for one semester and will be reviewed. State explicitly which cost you are accepting, and what evidence would make you change your recommendation.
Show the discussion
There is no uniquely correct answer, but there are better and worse arguments. The four models occupy different positions on the trade-off, and a defensible recommendation does three things: it names the metric it is optimising, it acknowledges what it is giving up, and it states a review condition.
- Model A — best overall accuracy, worst group D bias. Defensible only with a strong monitoring plan and the deployment conditions from the model card.
- Model B — reduced bias, reduced accuracy. Defensible if the group D bias is judged more consequential than the overall error. The right choice if the intervention is only useful when it reaches the right students.
- Model C — the most sophisticated option. Adjusting the threshold is a legitimate remedy: it does not make the underlying predictions less biased, but it changes how the bias translates into a decision. The weakness is that a group-specific threshold is harder to explain and harder to monitor than a single rule, and it may itself be seen as unfair by students who learn about it.
- Model D — highest accuracy, most severe proxy problem. Hard to defend. A model that uses postcode to predict educational outcomes is making predictions that encode historical inequity, and the accuracy gain is unlikely to justify it.
The strongest answers are the ones that engage seriously with Model B or C, since those are the options that take the fairness finding seriously without abandoning the model's purpose. A recommendation for Model A is defensible but requires a detailed answer to the question "what happens to a group D student who is wrongly flagged?"
The point of the exercise
The exercise forces you to make an explicit choice between values that conflict. There is no version of this model that is best on every measure, and pretending otherwise — by reporting only overall metrics, or by claiming the model is "fair" — is the failure mode the whole tutorial is designed to prevent.
7 Self-check questions
Answer each question in your own words before revealing the answer.
Question 1
What is a proxy variable, and why does removing the protected attribute from a model not make the model fair?
Show answer
Removing the protected attribute removes the direct path, but the proxy features still carry the same information into the prediction. The model is unaware of the attribute; the outcome is not. In the worked example, the model was never shown ethnicity, and it still systematically under-predicted group D by more than six marks — because group D students were more likely to work long hours and less likely to have attended an independent school, and those features were in the model.