Course homepage

Data Visualization with R

Chapter 4 – Exploring Distributions, From Data to Predictions

Ewen Gallic

September 14, 2026

Disclaimers

  1. I used Claude Sonnet 5 while creating those slides.

This Chapter: A Hands-On Session

  • Most of what this chapter touches on (distributions, mean vs. median, logistic regression, ROC/AUC) builds on things you already know from your economics/econometrics training.
  • The goal here is to apply those notions on one dataset, in R, and to build judgment about when a single metric used to summarise data is misleading you.
  • This chapter presents two short reference slides for the R syntax you have not used yet, then three long guided labs. Most of the session is yours to work through the 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.

Loading the Data

Again, we use the law dataset introduced in earlier chapters.

library(tidyverse)
library(here)
law <- read_csv(file = here("data", "law_data.csv"))

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)

R Syntax You will Need

Distributions

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)

Models

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

Part 1: Describing Distributions

Lab 1: From Numbers to Shape

Using the law dataset, pick two numeric variables among LSAT, UGPA, ZFYA, and sander_index. For each of them:

  1. Plot a histogram and a density curve (on the same graph). Try at least two different 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.

  1. Plot the ECDF with stat_ecdf(). Use it to read off the proportion of students below the variable’s mean.
  2. Compute the mean and the median. Locate both on your plot from Question 1 (using geom_vline().
  3. Plot a boxplot. Does it flag outliers? Cross-check against the histogram.
  4. Write two or three sentences describing the distribution: center, spread, shape, anything notable.

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?

Debrief: What Did You Notice?

Some questions you can ask yourself:

  • Did any variable surprise you, mean far from median, unexpected outliers?
  • Did changing bins or bw ever change your conclusion about the shape, not just the picture?
  • For the bonus question: did splitting by a group reveal something the pooled distribution hid?

A Solution: Question 1

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")

A Solution: Question 2

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")
prop_below_lsat_mean
[1] 0.4639071

A Solution: Question 3

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))

A Solution: Question 4

p_4 <- ggplot(
  data = law,
  mapping = aes(y = LSAT)
) +
  geom_boxplot() +
  labs(
    title = "Distribution of LSAT scores.",
    subtitle = "Source: law school dataset (Wightman, 1998)",
    y = "LSAT Scores"
  ) +
  scale_x_discrete() +
  theme_minimal(base_size = 14) +
  theme(plot.title.position = "plot")

A Solution: Bonus Question

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")

Part 2: Predictions from a Classifier

Some Context

  • In this part, we want to investigate if LSAT, UGPA, and other variables can predict whether a student passes the bar exam on the first attempt (first_pf)?
  • Two separate questions follow from any such model:
    1. Discrimination: does the model rank observations (here, students) correctly?
    2. Calibration: are the predicted probabilities themselves trustworthy?
  • A model’s predicted probabilities are, themselves, just another distribution, the same tools from Part 1 apply.
  • The next few slides are a quick refresher on four notions you have seen before in your coursework: classifier, confusion matrix, AUC, calibration. Then it is your turn.

The Logistic Regression Equation

  • For a binary outcome such as 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} \]

  • The left-hand side is the logit: the log of the odds of passing vs. not passing.
  • \(\beta_0\) is the log-odds of passing when LSAT and UGPA are both 0 (rarely meaningful on its own, since 0 is outside the observed range of either variable).
  • \(\beta_1\), \(\beta_2\) are the change in log-odds for a one-unit increase in LSAT or UGPA, holding the other fixed.

Estimation with glm()

The glm() (Generalized Linear Models) function from {stats} allows to estimate the coefficients \(\boldsymbol{\beta}\).

m_demo <- glm(first_pf ~ LSAT + UGPA, data = law, family = "binomial")
broom::tidy(m_demo)
# A tibble: 3 × 5
  term        estimate std.error statistic   p.value
  <chr>          <dbl>     <dbl>     <dbl>     <dbl>
1 (Intercept)   -6.37    0.208       -30.6 9.24e-206
2 LSAT           0.158   0.00436      36.3 2.01e-288
3 UGPA           0.933   0.0552       16.9 4.79e- 64

From Log-Odds to a Probability

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:

pred_val <- predict(m_demo, type = "response")
head(pred_val)
        1         2         3         4         5         6 
0.9365380 0.8931803 0.7802871 0.8643677 0.9343255 0.8597209 

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".

aug_demo <- broom::augment(m_demo, type.predict = "response")
aug_demo
# 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

From Probability to Class, With a Threshold

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} \]

  • \(\widehat{P}(\text{first_pf}_i = 1 \mid \boldsymbol{X_i})\) is the .fitted value from the model, student \(i\)’s predicted probability of passing.
  • \(\widehat{y}_i\) is the resulting predicted class, predicted_class in aug_demo.
  • \(\tau = 0.5\) is a common default, but it is a choice, not a requirement: nothing about the model forces it.
tau <- 0.5
aug_demo <- aug_demo |>
  mutate(predicted_class = if_else(.fitted > tau, 1, 0))

The Confusion Matrix

Given a selected threshold, cross-tabulating the predicted class against the actual outcome gives the confusion matrix.

Observed 0 Observed 1
Predicted 0 True Negative False Negative
Predicted 1 False Positive True Positive
table(
  predicted = aug_demo$predicted_class,
  observed  = aug_demo$first_pf
)
         observed
predicted     0     1
        0   225   145
        1  2206 19215

Accuracy: The Obvious Metric

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} \]

mean(aug_demo$predicted_class == aug_demo$first_pf)
[1] 0.8921114

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.

Accuracy Can Be Misleading with an Unbalanced Outcome

mean(law$first_pf)
[1] 0.8884402

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:

law |>
  mutate(predicted_naive = 1) |>
  summarise(accuracy = mean(predicted_naive == first_pf))
# 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.

The Threshold Matters for Some Metrics

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.

  • Raising \(\tau\): fewer students get classified as 1, sensitivity falls, specificity rises.
  • Lowering \(\tau\): more students get classified as 1, sensitivity rises, specificity falls.
  • There is no single \(\tau\) that maximizes both at once: this is the sensitivity/specificity trade-off.

Varying the Threshold

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.

ROC Curve and AUC

library(pROC)
roc_demo <- roc(aug_demo$first_pf, aug_demo$.fitted)
auc(roc_demo)
Area under the curve: 0.7618
p_roc <- ggroc(roc_demo, legacy.axes = TRUE) +
  labs(
    x = "False Positive Rate (1-specificity)", 
    y = "True Positive Rate (sensitivity)"
  ) +
  geom_abline(
    intercept = 0, slope = 1, linetype = "dashed", 
    color = "grey60"
  ) +
  theme_minimal(base_size = 14) +
  coord_equal()

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.

Baseline 1, Random Guessing Traces the Diagonal

  • Suppose a “model” assigns each student a score with no relationship to whether they actually pass, pure random noise.
  • Note that:

\[ \text{TPR}(\tau) = P(\text{score} > \tau \mid Y=1), \quad \text{FPR}(\tau) = P(\text{score} > \tau \mid Y=0) \]

  • If the score is independent of \(Y\) (which is the case when assigning a score at random), then conditioning on \(Y=1\) or \(Y=0\) does not change the distribution of the score at all:

\[ 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.

Baseline 1, Random Guessing Traces the Diagonal

set.seed(42)
law_random <- law |>
  mutate(random_score = runif(n()))

roc_random <- roc(law_random$first_pf, law_random$random_score)
auc(roc_random)
Area under the curve: 0.4971
ggroc(roc_random, legacy.axes = TRUE) +
  labs(x = "False Positive Rate (1-specificity)", y = "True Positive Rate (sensitivity)") +
  geom_abline(intercept = 0, slope = 1, linetype = "dashed", color = "grey60") +
  theme_minimal(base_size = 14) +
  coord_equal()

  • With sample size, the curve converges to the diagonal closely at every point along its length.

  • Small wiggles are sampling noise, not signal.

Baseline 2, “Always Pass” Only Ever Visits Two Points

  • Consider now the case where every student gets the exact same score \(c\), so there is only one meaningful cutoff behaviour, not a fixed point for every \(\tau\):

\[ (\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} \]

  • As \(\tau\) sweeps from 1 down to 0, the point sits at \((0,0)\), then jumps straight to \((1,1)\) the instant \(\tau\) crosses \(c\), and stays there. No intermediate point is ever reached, because there is no intermediate score to threshold on.

Baseline 2, “Always Pass” Only Ever Visits Two Points

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
ggroc(roc_naive, legacy.axes = TRUE) +
  labs(x = "False Positive Rate (1-specificity)", y = "True Positive Rate (sensitivity)") +
  geom_abline(intercept = 0, slope = 1, linetype = "dashed", color = "grey60") +
  theme_minimal(base_size = 14) +
  coord_equal()

Baselines 1 and 2: Wrap up

  • The two previous special cases landed an AUC equal to 0.5.
  • Baseline 1: random guessing visits a different point on the diagonal at every \(\tau\), because it is equally uninformative everywhere.
  • Baseline 2: the naive classifier only ever visits two points: the trivial corners \((0,0)\) and \((1,1)\), and connecting those two endpoints happens to reproduce the same diagonal line, not because it behaves like a coin flip at each threshold, but because it has no usable threshold to vary at all.
  • Note that the AUC does not inflate itself just because one class is rare (remember we face a the 88.8%/11.2% class split in our example), which is good news!

Lab 2: Fit, Predict, Discriminate

  1. 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.

  2. Fit three logistic regressions on first_pf using only law_train:

    1. m_1 (LSAT only),
    2. m_2 (+ UGPA),
    3. m_3 (+ race).
  3. 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).

  4. 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?

  5. Build roc_1, roc_2, roc_3 with pROC::roc() on the test-set predictions, and compare their auc().

  6. Plot all three ROC curves together with ggroc().

  7. 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.

  8. Rank the four models by AUC. Is the ranking what you expected?

  9. 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?

Solution 1: Train/Test Split

The seed makes the split reproducible, everyone in the room gets the same partition.

library(broom)
set.seed(1234)

For convenience, we can track the students by assigning them an ID:

law_id    <- law |> mutate(row_id = row_number())
law_train <- law_id |> slice_sample(prop = 0.8)
law_test  <- law_id |> anti_join(law_train, by = "row_id")

We can have a look at the number of observation in each resulting dataset:

nrow(law_train) # 80% of the student
[1] 17432
nrow(law_test)  # 20% remaining students
[1] 4359

Solution 2: Fit Three Models

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.

Solution 2: Fit Three Models

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

Solution 3: Predict on the Test Set

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.

Solution 4: Distribution of Predicted Probabilities

ggplot(
  data = aug_3, 
  mapping = aes(x = .fitted, fill = factor(first_pf))
) +
  geom_density(alpha = 0.4) +
  labs(fill = "first_pf", x = "Predicted probability of passing")

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).

Solution 5: ROC Curves and AUC

We now estimate the ROC curves:

roc_1 <- roc(aug_1$first_pf, aug_1$.fitted)
roc_2 <- roc(aug_2$first_pf, aug_2$.fitted)
roc_3 <- roc(aug_3$first_pf, aug_3$.fitted)

Then, we compute the AUC:

auc(roc_1)  # LSAT only
Area under the curve: 0.7345
auc(roc_2)  # + UGPA
Area under the curve: 0.7582
auc(roc_3)  # + race
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.

Solution 6: Plotting the ROC Curves Together

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.

Solution 7: A Fourth Specification

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")

Solution 8: Ranking by AUC

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.

Solution 9: Train vs. Test AUC (Discussion)

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.

Part 3: Calibration

What about Calibration?

  • The AUC only asks whether the model ranks students correctly.
  • Calibration asks a different question: are the predicted probabilities themselves believable as probabilities?

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.

Calibration in Practice

  • The previous definition of calibration conditions on students who share the exact predicted probability \(p\).
  • In practice, a logistic regression with continuous predictors like 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.
  • With one student per value of \(p\), the empirical pass rate at that \(p\) is either \(0\) or \(1\): it tells us nothing about calibration, it just reflects a single outcome. Empirically:

\[ \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 \]

  • We need groups large enough for the average to be a stable estimate, but grouping strictly by predicted probability does not guarantee that.

Calibration in Practice: Quantile-Based Bins

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 \]

  • In R: ntile(.fitted, K) assigns each student to one of \(K\) quantile-based bins, so every \(\text{Bin}_k\) has (approximately) \(n / K\) students.
  • Within each bin \(\text{Bin}_k\), we compare the mean predicted probability to the mean observed outcome:

\[ \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 \]

Computing Calibration

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!

Lab 3: Build and Compare Calibration Plots

  1. On 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.
  2. Plot mean observed against mean predicted, one point per bin, with a 45-degree reference line (geom_abline(slope = 1, intercept = 0)).
  3. Repeat steps 1 to 2 for aug_1 (and aug_4 if you have time). Which specification stays closest to the diagonal?
  4. Compare this ranking to your AUC ranking from Lab 2. Is the model with the highest AUC also the best calibrated? Is that necessarily true in general?
  5. Try ntile(.fitted, 5) and ntile(.fitted, 20) on one model. What changes, and why? What would happen with too many bins?

Solution 1: Binning into Deciles

calib_3 <- aug_3 |>
  mutate(bin = ntile(.fitted, 10)) |>
  group_by(bin) |>
  summarise(
    mean_predicted = mean(.fitted),
    mean_observed  = mean(first_pf),
    n              = n(),
    .groups        = "drop"
  )
calib_3
# A tibble: 10 × 4
     bin mean_predicted mean_observed     n
   <int>          <dbl>         <dbl> <int>
 1     1          0.598         0.615   436
 2     2          0.823         0.794   436
 3     3          0.875         0.853   436
 4     4          0.901         0.890   436
 5     5          0.920         0.897   436
 6     6          0.935         0.924   436
 7     7          0.948         0.947   436
 8     8          0.959         0.961   436
 9     9          0.970         0.961   436
10    10          0.982         1       435

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.

Solution 2: The Calibration Plot

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).

Solution 3: Comparing Specifications

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).

Solution 4: AUC Ranking vs. Calibration Ranking

  • AUC ranking (Lab 2, Solution 8): m_4 \(>\) m_3 \(>\) m_2 \(>\) m_1.
  • Calibration (Solution 3): m_1 and m_3 are both close to the diagonal, calibration does not track the same ranking as AUC.
  • In general the two rankings need not agree. Logistic regression fit by maximum likelihood tends to be well calibrated on data similar to its training data almost regardless of which predictors it has. Consequently, a high-AUC model is not automatically the best-calibrated one. Both metrics need to be checked.
  • Actually, if the model is well specified, the logistic model will be well calibrated.
  • When using a machine learning model instead of a logistic regression, a random forest for example, calibration may be harder to achieve.
  • Note that calibration will matter if ranking is not enough (if you want to estimate a probability, calibration matters).

Solution 5: Changing the Number of Bins

calib_bins <- function(aug, n_bins) {
  aug |>
    mutate(bin = ntile(.fitted, n_bins)) |>
    group_by(bin) |>
    summarise(
      mean_predicted = mean(.fitted), 
      mean_observed = mean(first_pf), 
      n = n(), 
      .groups = "drop"
    )
}
calib_bins(aug_3, 5)
# A tibble: 5 × 4
    bin mean_predicted mean_observed     n
  <int>          <dbl>         <dbl> <int>
1     1          0.711         0.704   872
2     2          0.888         0.872   872
3     3          0.927         0.911   872
4     4          0.954         0.954   872
5     5          0.976         0.980   871
calib_bins(aug_3, 10)
# A tibble: 10 × 4
     bin mean_predicted mean_observed     n
   <int>          <dbl>         <dbl> <int>
 1     1          0.598         0.615   436
 2     2          0.823         0.794   436
 3     3          0.875         0.853   436
 4     4          0.901         0.890   436
 5     5          0.920         0.897   436
 6     6          0.935         0.924   436
 7     7          0.948         0.947   436
 8     8          0.959         0.961   436
 9     9          0.970         0.961   436
10    10          0.982         1       435
calib_bins(aug_3, 20)
# 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.

Solution 5: Changing the Number of Bins

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")
}
plot_calib_bins(5)

plot_calib_bins(10)

plot_calib_bins(20)

Wrap-Up

Bringing It Together

  • A single summary number compresses a distribution, and that compression can hide real structure: extra modes, skew, outliers.
  • A model’s predicted probabilities are a distribution too, and the same diagnostic habits apply to them.
  • Discrimination (ROC/AUC) and calibration answer two different questions. A model can do well on one while failing the other.

References

Brier, Glenn W. 1950. “Verification of Forecasts Expressed in Terms of Probability.” Monthly Weather Review 78 (1): 1–3.
Dawid, A Philip. 1982. “The Well-Calibrated Bayesian.” Journal of the American Statistical Association 77 (379): 605–10.