Chapter 4 – Exploring Distributions, From Data to Predictions
September 14, 2026
law dataset.Warning
Part 2 of this chapter is a bit more technical than the rest of the course. We will go through two important plots used to assess the quality of fit of a binary classifier: the ROC curve and the calibration curve.
Again, we use the law dataset introduced in earlier chapters.
The table below provides a remainder on the content of the dataset.
| Variable | Type | Description |
|---|---|---|
...1 |
numeric | Row identifier / index |
race |
character | Self-reported race (Amerindian, Asian, Black, Hispanic, Mexican, Other, Puertorican, White) |
sex |
numeric | Sex of the student (1 = Female, 2 = Male) |
LSAT |
numeric | LSAT score received by the student (measured on a 10 to 48 scale) |
UGPA |
numeric | Undergraduate GPA of the student (from 0 to 4.33) |
region_first |
character | Geographic region where the student took their first bar examination (Far West, Great Lakes, Midsouth, Midwest, Mountain West, Northeast, New England, Northwest, South Central, South East) |
ZFYA |
numeric | Standardized First-Year Average law school grades (FYA) |
sander_index |
numeric | Composite index weighting LSAT and UGPA (\(200 \times \text{UGPA} + \text{LSAT}\)) |
first_pf |
numeric | Binary indicator for passing the bar exam on the first trial (1 = Pass, 0 = Fail) |
| Function | What it does |
|---|---|
geom_density(bw = ...) |
Smoothed density estimate; bw controls how smooth the curve is |
stat_ecdf() |
Empirical cumulative distribution function, no binning choice needed |
ntile(x, n) |
Splits a numeric vector into n equal-sized groups (used later, in Part 3) |
| Function | What it does |
|---|---|
glm(y ~ x, data = ..., family = "binomial") |
Logistic regression |
broom::tidy(model) |
One row per coefficient |
broom::augment(model, type.predict = "response") |
One row per observation, adds a .fitted column (predicted probability) |
pROC::roc(observed, predicted) |
Builds a ROC curve object |
pROC::auc(roc_obj) |
Area under the ROC curve |
ggroc(list(...)) |
Plots one or several ROC curves at once |
Using the law dataset, pick two numeric variables among LSAT, UGPA, ZFYA, and sander_index. For each of them:
bins/bw values for each.Overlaying Histogram and Density
Set aes(y = after_stat(density)) inside geom_histogram() so both layers share the same vertical scale, then add geom_density() on top to compare the two at once.
stat_ecdf(). Use it to read off the proportion of students below the variable’s mean.geom_vline().Bonus, if you have time: split one of your variables by sex or race (e.g., fill = factor(sex) in geom_density()). Does the shape differ across groups?
Some questions you can ask yourself:
bins or bw ever change your conclusion about the shape, not just the picture?p_1 <- ggplot(
data = law,
mapping = aes(x = LSAT)
) +
geom_histogram(
mapping = aes(y = after_stat(density)),
colour = "white",
binwidth = 2
) +
geom_density(colour = "dodgerblue", bw = .5) +
labs(
title = "Distribution of LSAT scores",
subtitle = "Source: law school dataset (Wightman, 1998)",
x = "LSAT Scores", y = "Count"
) +
theme_minimal(base_size = 14) +
theme(plot.title.position = "plot")
ecdf_lsat <- ecdf(law$LSAT) # ECDF for the LSAT variable
# proportion of students below the variable's mean:
prop_below_lsat_mean <- ecdf_lsat(mean(law$LSAT, na.rm = TRUE))
p_2 <- ggplot(
data = law,
mapping = aes(x = LSAT)
) +
stat_ecdf() +
geom_vline(
xintercept = mean(law$LSAT, na.rm = TRUE),
linetype = "dashed", colour = "dodgerblue"
) +
labs(
title = "Empirical Cumulative Distribution Function for LSAT.\nMean LSAT in blue dashed line.",
subtitle = "Source: law school dataset (Wightman, 1998)",
x = "LSAT Scores", y = "Cumulative probability"
) +
theme_minimal(base_size = 14) +
theme(plot.title.position = "plot")summ_stat_LSAT <- law |> summarise(
mean_LSAT = mean(LSAT),
median_LSAT = median(LSAT)
)
p_3 <- p_1 +
geom_vline(
xintercept = summ_stat_LSAT$mean_LSAT,
colour = "#009E73", linetype = "dashed", size = 1.2
) +
geom_vline(
xintercept = summ_stat_LSAT$median_LSAT,
colour = "#CC79A7", linetype = "dashed", size = 1.2
) +
annotate(
"text",
x = summ_stat_LSAT$mean_LSAT,
y = 0.08,
label = str_c("Mean = ", round(summ_stat_LSAT$mean_LSAT, 2)),
colour = "#009E73",
hjust = 1.1
) +
annotate(
"text",
x = summ_stat_LSAT$median_LSAT,
y = 0.08,
label = str_c("Median = ", round(summ_stat_LSAT$median_LSAT, 2)),
colour = "#CC79A7",
hjust = -0.1
) +
scale_y_continuous(limits = c(0, 0.09))
p_bonus <- ggplot(
data = law |>
mutate(sex = factor(sex, levels = c(1, 2), labels = c("Female", "Male"))),
mapping = aes(x = LSAT)
) +
geom_histogram(
mapping = aes(y = after_stat(density), fill = sex),
colour = "white",
binwidth = 2,
position = "identity",
alpha = .6
) +
geom_density(
mapping = aes(colour = sex),
bw = .5
) +
labs(
title = "Distribution of LSAT scores",
subtitle = "Source: law school dataset (Wightman, 1998)",
x = "LSAT Scores", y = "Count"
) +
scale_colour_manual(
name = "Gender", values = c("Female" = "#D55E00", "Male" = "#009E73")
) +
scale_fill_manual(
name = "Gender", values = c("Female" = "#D55E00", "Male" = "#009E73")
) +
theme_minimal(base_size = 14) +
theme(plot.title.position = "plot")
LSAT, UGPA, and other variables can predict whether a student passes the bar exam on the first attempt (first_pf)?first_pf, logistic regression models the log-odds of passing as a linear function of the predictors:\[ \log\left(\frac{P(\text{first_pf} = 1 \mid \boldsymbol{X})}{1 - P(\text{first_pf} = 1 \mid \boldsymbol{X})}\right) = X\boldsymbol{\beta} = \beta_0 + \beta_1 \cdot \text{LSAT} + \beta_2 \cdot \text{UGPA} \]
LSAT and UGPA are both 0 (rarely meaningful on its own, since 0 is outside the observed range of either variable).LSAT or UGPA, holding the other fixed.glm()The glm() (Generalized Linear Models) function from {stats} allows to estimate the coefficients \(\boldsymbol{\beta}\).
The logit is convenient to estimate, but hard to interpret directly. Inverting it gives back a probability between 0 and 1:
\[ P(\text{first_pf} = 1 \mid \boldsymbol{X}) = \frac{1}{1 + e^{-(\beta_0 + \beta_1 \cdot \text{LSAT} + \beta_2 \cdot \text{UGPA})}} \]
The predict() function allows to estimate the probabilities:
The augment() function from {broom} also allows to estimate those probabilities. It produces a column named .fitted that contains the predicted values. For the predicted values to be the estimated probabilities, the type.predict argument needs to be set to "response".
# A tibble: 21,791 × 9
first_pf LSAT UGPA .fitted .resid .hat .sigma .cooksd .std.resid
<dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 1 39 3.1 0.937 0.362 0.0000670 1.00 0.00000151 0.362
2 1 36 3 0.893 0.475 0.0000655 1.00 0.00000261 0.475
3 1 30 3.1 0.780 0.704 0.000129 1.00 0.0000121 0.704
4 1 39 2.2 0.864 0.540 0.000440 1.00 0.0000230 0.540
5 1 37 3.4 0.934 0.369 0.0000639 1.00 0.00000150 0.369
6 1 30.5 3.6 0.860 0.550 0.000200 1.00 0.0000109 0.550
7 1 36 3.6 0.936 0.364 0.0000862 1.00 0.00000196 0.364
8 0 37 2.7 0.881 -2.06 0.000134 1.00 0.000331 -2.06
9 1 37 2.6 0.871 0.526 0.000175 1.00 0.00000865 0.526
10 1 31 3.6 0.869 0.530 0.000181 1.00 0.00000911 0.530
# ℹ 21,781 more rows
A classifier needs a rule to turn a predicted probability into a class. That rule is a threshold \(\tau \in [0, 1]\):
\[ \widehat{y}_i = \begin{cases} 1 & \text{if } \widehat{P}(\text{first_pf}_i = 1 \mid \boldsymbol{X_i}) > \tau \\ 0 & \text{if } \widehat{P}(\text{first_pf}_i = 1 \mid \boldsymbol{X_i}) \le \tau \end{cases} \]
.fitted value from the model, student \(i\)’s predicted probability of passing.predicted_class in aug_demo.Given a selected threshold, cross-tabulating the predicted class against the actual outcome gives the confusion matrix.
Given a threshold \(\tau\), the simplest summary of the confusion matrix is accuracy: the proportion of students correctly classified.
\[ \text{Accuracy}(\tau) = \frac{TP + TN}{TP+TN+FP+FN} = \frac{\#\{i : \widehat{y}_i(\tau) = y_i\}}{n} \]
At first glance this looks like the natural, single-number way to judge a classifier. The next slide shows why it can be dangerously misleading.
About 88.8% of students pass on their first attempt, first_pf is far from balanced. Now consider a classifier that ignores every predictor and just predicts “pass” for everyone:
# A tibble: 1 × 1
accuracy
<dbl>
1 0.888
This naive rule reaches 88.8% accuracy without ever looking at LSAT or UGPA. Whenever one class dominates, accuracy can look impressive purely by exploiting the imbalance, not by discriminating between the two groups.
Do not Trust Accuracy Alone
Any model should be compared against this naive baseline. Here, a model with 89% accuracy that barely beats always predicting “pass” has learned very little. This gap is filled by the AUC, introduced on the next slides.
Two popular metrics, both functions of \(\tau\):
\[\text{Sensitivity}(\tau) = \frac{TP}{TP+FN} =\\\frac{\#\{i : \widehat{y}_i(t) = 1 \text{ and } y_i = 1\}}{\#\{i : y_i = 1\}}\]
True positive rate: number of true positive (predicted positive when the actual condition is positive) over the overall number of positives.
Here: proportion of actual passers correctly flagged.
\[ \text{Specificity}(t) = \frac{TN}{TN+FP} = \\\frac{\#\{i : \widehat{y}_i(t) = 0 \text{ and } y_i = 0\}}{\#\{i : y_i = 0\}} \] True negative rate: number of true negative (predicted negative when the actual condition is negative) over the overall number of negative.
Here: proportion of actual failers correctly flagged.
1, sensitivity falls, specificity rises.1, sensitivity rises, specificity falls.for (tau in c(0.3, 0.5, 0.7)) {
pred <- if_else(aug_demo$.fitted > tau, 1, 0)
cat(
"tau =", tau,
" | sensitivity =", round(mean(pred[aug_demo$first_pf == 1] == 1), 3),
" | specificity =", round(mean(pred[aug_demo$first_pf == 0] == 0), 3),
"\n"
)
}tau = 0.3 | sensitivity = 0.999 | specificity = 0.017
tau = 0.5 | sensitivity = 0.993 | specificity = 0.093
tau = 0.7 | sensitivity = 0.961 | specificity = 0.252
This Is Exactly What the ROC Curve Plots
Instead of picking one \(\tau\) and reporting one pair of numbers, the ROC curve plots \((1 - \text{Specificity}(\tau), \text{Sensitivity}(\tau))\) for every \(\tau\) between 0 and 1 at once.
Hence, it looks at sensitivity and specificity across every threshold at once.
Area under the curve: 0.7618

AUC condenses the curve into one number: the probability that a randomly chosen passer receives a higher predicted probability than a randomly chosen non-passer. 0.5 is random guessing (see next slides), 1 is perfect separation.
\[ \text{TPR}(\tau) = P(\text{score} > \tau \mid Y=1), \quad \text{FPR}(\tau) = P(\text{score} > \tau \mid Y=0) \]
\[ P(\text{score} > \tau \mid Y=1) = P(\text{score}> \tau) = P(\text{score}>\tau \mid Y=0) \]
In such a case, for every \(\tau\), we expect \(\text{TPR}(\tau)=\text{FPR}(\tau)\).
Sweeping \(\tau\) from 1 down to 0 traces out the full diagonal, continuously, one different point at a time: visually, this corresponds to the diagonal in the ROC curve, i.e., the “no discrimination” reference line on every ROC plot.

With sample size, the curve converges to the diagonal closely at every point along its length.
Small wiggles are sampling noise, not signal.
\[ (\text{FPR}(\tau), \text{TPR}(\tau)) = \begin{cases} (1, 1) & \text{if } \tau < c \quad \text{(everyone classified "pass")} \\ (0, 0) & \text{if } \tau \ge c \quad \text{(no one classified "pass")} \end{cases} \]
law_naive <- law |>
mutate(constant_score = 1)
roc_naive <- roc(law_naive$first_pf, law_naive$constant_score)
auc(roc_naive)Area under the curve: 0.5
Split law into a training set (80% of students), law_train, and a test set (the remaining 20%), law_test. Fix a seed (use 1234) so your split is reproducible.
Fit three logistic regressions on first_pf using only law_train:
m_1 (LSAT only),m_2 (+ UGPA),m_3 (+ race).For each model, use the augment() function to get aug_1, aug_2, aug_3, the tibbles with predictions on students the model never saw while fitting (i.e., those in law_test ; use the newdata= argument).
For aug_3, plot the distribution of .fitted split by first_pf (e.g., fill = factor(first_pf)). What would you want this plot to look like if the model were useful?
Build roc_1, roc_2, roc_3 with pROC::roc() on the test-set predictions, and compare their auc().
Plot all three ROC curves together with ggroc().
Fit a fourth specification m_4 on law_train, adding region_first. Get aug_4 the same way, via newdata = law_test, and add its ROC curve and AUC to the comparison.
Rank the four models by AUC. Is the ranking what you expected?
Discussion: what would happen to each AUC if you had instead evaluated the models on law_train rather than law_test? Would you expect it to be higher, lower, or about the same, and why?
The seed makes the split reproducible, everyone in the room gets the same partition.
For convenience, we can track the students by assigning them an ID:
We can have a look at the number of observation in each resulting dataset:
The models is estimated (trained) on the training set:
m_1 <- glm(first_pf ~ LSAT, data = law_train, family = "binomial")
m_2 <- glm(first_pf ~ LSAT + UGPA, data = law_train, family = "binomial")
m_3 <- glm(first_pf ~ LSAT + UGPA + race, data = law_train, family = "binomial")
tidy(m_3)# A tibble: 10 × 5
term estimate std.error statistic p.value
<chr> <dbl> <dbl> <dbl> <dbl>
1 (Intercept) -6.02 0.353 -17.1 2.49e- 65
2 LSAT 0.137 0.00529 25.9 2.68e-148
3 UGPA 0.809 0.0635 12.7 3.98e- 37
4 raceAsian 0.235 0.282 0.835 4.04e- 1
5 raceBlack 0.195 0.269 0.726 4.68e- 1
6 raceHispanic 0.176 0.289 0.609 5.42e- 1
7 raceMexican 0.393 0.294 1.34 1.81e- 1
8 raceOther 0.427 0.318 1.35 1.79e- 1
9 racePuertorican 0.184 0.363 0.507 6.12e- 1
10 raceWhite 0.959 0.262 3.65 2.59e- 4
All three converge without warnings.
The modelsummary() from {modelsummary} offers nice features to display regression results.
# install.packages("modelsummary")
modelsummary::modelsummary(
list("LSAT only" = m_1, "+ UGPA" = m_2, "+ race" = m_3),
stars = TRUE, # show significance stars
gof_map = c("nobs", "aic", "bic"), # which fit stats to show
coef_omit = "race", # hide the race dummies to keep it short
exponentiate = TRUE # show odds ratios instead of log-odds
)Notice that in m_3, both LSAT and UGPA keep large positive, significant coefficients on the log-odds of passing once race is added. This suggests that race may capture additional variation but does not explain away the other two predictors.
| LSAT only | + UGPA | + race | |
|---|---|---|---|
| + p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001 | |||
| (Intercept) | 0.019*** | 0.002*** | 0.002*** |
| (0.003) | (0.000) | (0.001) | |
| LSAT | 1.189*** | 1.173*** | 1.147*** |
| (0.006) | (0.006) | (0.006) | |
| UGPA | 2.518*** | 2.245*** | |
| (0.156) | (0.143) | ||
| Num.Obs. | 17432 | 17432 | 17432 |
| AIC | 10621.9 | 10401.3 | 10274.2 |
| BIC | 10637.4 | 10424.6 | 10351.9 |
aug_1 <- augment(m_1, newdata = law_test, type.predict = "response")
aug_2 <- augment(m_2, newdata = law_test, type.predict = "response")
aug_3 <- augment(m_3, newdata = law_test, type.predict = "response")
aug_3 |> select(first_pf, LSAT, UGPA, race, .fitted) |> head(3)# A tibble: 3 × 5
first_pf LSAT UGPA race .fitted
<dbl> <dbl> <dbl> <chr> <dbl>
1 1 36 3 White 0.909
2 1 39 2.2 Hispanic 0.783
3 1 37 3.4 White 0.941
Warning
Calling augment(m_3, type.predict = "response") without newdata silently re-predicts on law_train, the data the model was fit on. We used a train/test split approach to avoid measuring quality of fit on data on which the model has learnt. To do so, we pass newdata = law_test explicitly to the augment() function.
A binary classifier that will be able to discriminate (statistically speaking) will produce two densities that will be shifted apart: .fitted concentrated near 1 for students who passed (first_pf = 1), and shifted toward lower values for those who did not.

Here the overlap is still substantial (the base pass rate, mean(aug_3$.fitted) is about 89%). The model, while being able to discriminate a bit, is still far from being perfect (which is the norm when modeling human behavior/outcomes).
We now estimate the ROC curves:
Then, we compute the AUC:
Area under the curve: 0.7345
Area under the curve: 0.7582
Area under the curve: 0.7671
Each added predictor increases AUC, UGPA adds more discrimination than race does on top of UGPA, but neither addition is dramatic: LSAT alone is already carrying most of the discriminative power.
p_roc <- ggroc(
data = list(
"LSAT only" = roc_1,
"LSAT + UGPA" = roc_2,
"LSAT + UGPA + race" = roc_3
),
legacy.axes = TRUE
) +
geom_abline(
intercept = 0, slope = 1, linetype = "dashed", color = "grey60"
) +
labs(
x = "Specificity", y = "Sensitivity", color = "Model",
title = "ROC Curves for the Estimated Logistic Models",
subtitle = "Values estimated on the test set"
) +
theme_minimal(base_size = 14) + theme(plot.title.position = "plot")
The three curves are very close from one another. Since they are all above the diagonal, we can say that each model offers a gain (although modest) from a random guessing.
Also, we can visually confirm what the AUC values hinted: we observe a modest gains from UGPA and race on top of LSAT.
m_4 <- glm(
first_pf ~ LSAT + UGPA + race + region_first,
data = law_train, family = "binomial"
)
aug_4 <- augment(m_4, newdata = law_test, type.predict = "response")
roc_4 <- roc(aug_4$first_pf, aug_4$.fitted)
auc(roc_4)Area under the curve: 0.7871
Adding region_first produces a jump in AUC. Region apparently captures meaningful variation in first-attempt pass rates that LSAT, UGPA, and race together do not.
p_roc <- ggroc(
data = list(
"LSAT only" = roc_1,
"LSAT + UGPA" = roc_2,
"LSAT + UGPA + race" = roc_3,
"LSAT + UGPA + race + region" = roc_4
),
legacy.axes = TRUE
) +
geom_abline(
intercept = 0, slope = 1, linetype = "dashed", color = "grey60"
) +
labs(
x = "Specificity", y = "Sensitivity", color = "Model",
title = "ROC Curves for the Estimated Logistic Models",
subtitle = "Values estimated on the test set"
) +
theme_minimal(base_size = 14) + theme(plot.title.position = "plot")
tibble(
model = c(
"m_1: LSAT", "m_2: + UGPA",
"m_3: + race", "m_4: + region_first"
),
auc = c(auc(roc_1), auc(roc_2), auc(roc_3), auc(roc_4))
) |>
arrange(desc(auc))# A tibble: 4 × 2
model auc
<chr> <dbl>
1 m_4: + region_first 0.787
2 m_3: + race 0.767
3 m_2: + UGPA 0.758
4 m_1: LSAT 0.734
Ranking, best to worst: m_4 \(>\) m_3 \(>\) m_2 \(>\) m_1.
This matches expectations: each specification is nested inside the next (every predictor in m_1 is also in m_4), so AUC can only stay the same or improve on the training data.
On held-out test data an improve in the AUC is not guaranteed as more variables are added, but here it does for every addition.
tb_auc <- NULL
for (m in list(m_1, m_2, m_3)) {
aug_train <- augment(m, newdata = law_train, type.predict = "response")
aug_test <- augment(m, newdata = law_test, type.predict = "response")
roc_train <- roc(aug_train$first_pf, aug_train$.fitted)
roc_test <- roc(aug_test$first_pf, aug_test$.fitted)
tb_auc <- tb_auc |> bind_rows(
tibble(
auc_train = as.numeric(auc(roc_train)),
auc_test = as.numeric(auc(roc_test)))
)
}Train and test AUC come out almost identical here.
# A tibble: 3 × 2
auc_train auc_test
<dbl> <dbl>
1 0.743 0.734
2 0.763 0.758
3 0.771 0.767
That is not a general guarantee, it happens because a 3-predictor logistic regression on over 17,000 training rows has very little room to overfit. With a richer model (many more predictors, interactions, or a flexible non-linear method) or a smaller training set, train AUC would typically look noticeably higher than test AUC, since the model would have more opportunity to fit noise specific to the training sample.
Definition Dawid (1982)
A model is well calibrated if, for every predicted probability \(p\),
\[
\mathbb{E}\big[Y \mid \hat{P}(Y=1 \mid \boldsymbol{X})=p\big]=p
\] In our example: \[
\mathbb{E}\left[\,\text{first_pf} \mid \widehat{P}(\text{first_pf} = 1 \mid X) = p\,\right] = p
\] In words: among students who received a predicted probability of \(p\), the expected (average) value of first_pf, i.e. the actual pass rate, is \(p\) itself.
LSAT and UGPA almost never assigns the exact same \(\widehat{P}(\text{first_pf} = 1 \mid X) = p\) to more than a handful of students, often to just one.\[ \widehat{\mathbb{E}}\big[\text{first_pf} \mid \widehat{P} = p\big] = \frac{1}{n_p}\sum_{i \,:\, \widehat{P}_i = p} \text{first_pf}_i \qquad \text{with } n_p \text{ often } = 1 \]
Instead of conditioning on an exact value of \(p\), we usually condition on an interval of predicted probabilities, chosen so that every bin contains roughly the same number of students:
\[ \widehat{\mathbb{E}}\big[\text{first_pf} \mid \widehat{P} \in \text{Bin}_k\big] \approx \frac{1}{n_k}\sum_{i \,:\, \widehat{P}_i \in \text{Bin}_k} \text{first_pf}_i \]
ntile(.fitted, K) assigns each student to one of \(K\) quantile-based bins, so every \(\text{Bin}_k\) has (approximately) \(n / K\) students.\[ \bar{p}_k = \frac{1}{n_k}\sum_{i \,:\, \widehat{P}_i \in \text{Bin}_k} \widehat{P}_i \qquad \text{vs.} \qquad \bar{y}_k = \frac{1}{n_k}\sum_{i \,:\, \widehat{P}_i \in \text{Bin}_k} \text{first_pf}_i \]
aug_demo |>
mutate(bin = ntile(.fitted, 5)) |>
group_by(bin) |>
summarise(
mean_predicted = mean(.fitted),
mean_observed = mean(first_pf),
.groups = "drop"
)# A tibble: 5 × 3
bin mean_predicted mean_observed
<int> <dbl> <dbl>
1 1 0.714 0.713
2 2 0.874 0.880
3 3 0.923 0.919
4 4 0.953 0.951
5 5 0.978 0.979
A well calibrated model has mean_predicted close to mean_observed in every bin. The next exercise illustrate this!
aug_3, use ntile(.fitted, 10) to bin students into deciles of predicted probability. Within each bin, compute the mean predicted probability and the mean observed first_pf.geom_abline(slope = 1, intercept = 0)).aug_1 (and aug_4 if you have time). Which specification stays closest to the diagonal?ntile(.fitted, 5) and ntile(.fitted, 20) on one model. What changes, and why? What would happen with too many bins?Bin 1 (lowest predicted probabilities, around 0.60) already has an observed pass rate close by (around 0.61). Bin sizes are roughly 430 to 450 students each, plenty to make each bin’s average a stable estimate.
ggplot(
data = calib_3,
mapping = aes(x = mean_predicted, y = mean_observed)
) +
geom_abline(
slope = 1, intercept = 0,
linetype = "dashed", color = "grey50"
) +
geom_point(colour = "dodgerblue") +
geom_line(colour = "dodgerblue") +
labs(
x = "Mean predicted probability",
y = "Observed pass rate",
title = "Calibration plot"
) +
coord_equal(xlim = c(0, 1), ylim = c(0, 1)) +
theme_minimal(base_size = 14) +
theme(plot.title.position = "plot")
All ten points sit close to the 45-degree line across the full range of predicted probabilities, m_3 is reasonably well calibrated on this test set (recall it was also reasonably good at ranking students according to the AUC).
calib_1 <- aug_1 |>
mutate(bin = ntile(.fitted, 10)) |>
group_by(bin) |>
summarise(
mean_predicted = mean(.fitted),
mean_observed = mean(first_pf),
n = n(),
.groups = "drop"
)
p_calib_curve <-
ggplot(
data = calib_1 |> mutate(model = "m_1: LSAT") |>
bind_rows(calib_3 |> mutate(model = "m_3: + race")),
mapping = aes(x = mean_predicted, y = mean_observed, colour = model)
) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
geom_point() + geom_line() +
labs(
x = "Mean predicted probability",
y = "Observed pass rate",
title = "Calibration plot"
) +
coord_equal(xlim = c(0, 1), ylim = c(0, 1)) +
theme_minimal(base_size = 14) + theme(plot.title.position = "plot")
Both models track the diagonal about equally well here. The simlicity of m_1 does not cost it much calibration, even though Lab 2 showed it clearly loses on discrimination (AUC 0.734 vs. 0.767).
m_4 \(>\) m_3 \(>\) m_2 \(>\) m_1.m_1 and m_3 are both close to the diagonal, calibration does not track the same ranking as AUC.# A tibble: 20 × 4
bin mean_predicted mean_observed n
<int> <dbl> <dbl> <int>
1 1 0.482 0.486 218
2 2 0.714 0.743 218
3 3 0.804 0.817 218
4 4 0.843 0.771 218
5 5 0.866 0.862 218
6 6 0.883 0.844 218
7 7 0.895 0.890 218
8 8 0.906 0.890 218
9 9 0.916 0.894 218
10 10 0.924 0.899 218
11 11 0.931 0.922 218
12 12 0.939 0.927 218
13 13 0.945 0.963 218
14 14 0.951 0.931 218
15 15 0.957 0.954 218
16 16 0.962 0.968 218
17 17 0.968 0.936 218
18 18 0.973 0.986 218
19 19 0.979 1 218
20 20 0.986 1 217
With only 5 bins (about 870 students each), the estimates are very stable. However, we cannot tell whether calibration holds up evenly across the full range of predicted probabilities.
With 20 bins (about 210 to 245 students each), individual points start to wander further off the diagonal (e.g., one bin’s observed rate lands nearly 3 points above its predicted rate) purely from sampling noise in smaller groups, not necessarily from real miscalibration.
Too few bins hides structure, too many bins manufactures the appearance of miscalibration out of noise.
plot_calib_bins <- function(n_bins) {
calib_1 <- calib_bins(aug_1, n_bins)
calib_3 <- calib_bins(aug_3, n_bins)
ggplot(
data = calib_1 |> mutate(model = "m_1: LSAT") |>
bind_rows(calib_3 |> mutate(model = "m_3: + race")),
mapping = aes(x = mean_predicted, y = mean_observed, colour = model)
) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
geom_point() + geom_line() +
labs(
x = "Mean predicted probability",
y = "Observed pass rate",
title = str_c("Calibration plot (", n_bins, " bins)")
) +
coord_equal(xlim = c(0, 1), ylim = c(0, 1)) +
theme_minimal(base_size = 14) + theme(plot.title.position = "plot")
}
Data Visualization with R