# Logistic Regression: Modelling a Yes/No Outcome
## Welcome back {.unnumbered}
Every model so far has predicted a **quantity** — a sales figure, an
average, a rate. Today the outcome changes shape: it becomes a
**yes or no**.
That's a bigger share of People Analytics than the continuous case.
Did they accept the offer? Did they pass probation? Were they
promoted? Did they leave? Each is a coin that landed one way or the
other, and each needs a model that respects the fact.
### Back to the question we started with {.unnumbered}
Chapter 1 opened on *what predicts who gets promoted?* and we've been
circling it ever since. Your VP of Sales has the pieces now — the
promotion rate (Chapter 4), what a customer-rating point is worth
(Chapter 6), whether the performance ratings track anything real
(Chapter 7) — and she wants them assembled into one answer:
> **Given what we know about a salesperson, how likely are they to be
> promoted — and which factors actually move that number?**
The outcome `promoted` is **0 or 1**. Everything else you know still
applies: same `brm()`, same priors, same posteriors, same credible
intervals. Only the family changes, and one new idea comes with it.
::: callout-note
This is the last new model family in Part III. Chapter 10 then takes
everything from Chapter 1 to here and turns it into a repeatable
workflow you can apply to your own questions.
:::
### Analysis strategy {.unnumbered}
**What we have.** One row per salesperson — a customer rating, a performance
tier, and a flag saying whether they were promoted. The flag is the outcome
and it is the awkward part: it takes exactly two values, so the tools from
Chapters 6 and 7, which predict a quantity that can sit anywhere on a line,
will not do as they stand.
**What would answer the question.** Not a coefficient. What the VP of Sales
can act on is a **probability**: for a salesperson with these
characteristics, how likely is promotion, and how much does that change if one
characteristic changes? So the target is a probability with an interval on it,
and everything we build should be built to produce one.
**What could go wrong.** Promotion is relatively uncommon, so apparent
differences between groups may rest on a handful of people — Chapter 3's
warning, and the reason we want intervals rather than point estimates. And the
two predictors move together: better-rated salespeople tend to sit in higher
tiers, so a model with one will not say what a model with both says.
**So the plan.** Get the outcome onto a scale a regression can work on — the
one genuinely new idea here. Fit the same `brm()` as before, with the family
changed. Translate back to probabilities, because that is the scale the
question was asked in. Then check that the model reproduces how many people
were actually promoted.
**What would make us stop.** If that last check fails, nothing from the three
steps before it is worth reporting, and we go back to the specification rather
than to the interpretation.
## What you'll be able to do by the end {.unnumbered}
1. Explain why a straight line can't model a yes/no outcome, and what
**log-odds** do about it
2. Fit a **logistic regression** in `brms`
3. Interpret coefficients as **odds ratios** — and avoid the mistake
almost everyone makes with them
4. Report results as **predicted probabilities** a stakeholder can act
on
5. Match a **posterior predictive check** to a binary outcome
------------------------------------------------------------------------
## Setup
```{r setup}
library(tidyverse)
library(peopleanalyticsdata)
library(brms)
library(tidybayes)
library(ggdist)
theme_set(theme_minimal(base_size = 13))
set.seed(2026)
data("salespeople", package = "peopleanalyticsdata")
# Chapter 1 found a single missing value in each of `sales`,
# `customer_rate` and `performance`; dropping those rows here keeps
# the rest of this chapter's code simple.
salespeople <- salespeople |>
drop_na(sales, customer_rate, performance, promoted) |>
mutate(
customer_rate_s = as.numeric(scale(customer_rate)), # <1>
performance_f = factor(performance) # <2>
)
```
1. `scale()` **standardises**: it subtracts the mean and divides by the
standard deviation, so `customer_rate_s` is measured in standard
deviations away from average rather than in rating points. Chapter 6
only centred; the reason for the extra step is in the box below the
model.
2. Four labels, not a number, exactly as in Chapter 7.
------------------------------------------------------------------------
## Back to our opening question
*What predicts who gets promoted?* We asked this in Chapter 1 and have
been building toward it ever since.
The outcome `promoted` is **0 or 1** — and that needs a different kind
of model.
### Why a straight line fails
If we fit an ordinary regression to a 0/1 outcome, the line keeps
going — because that's what lines do.
```{r why-not-linear}
#| code-fold: true
#| code-summary: "Show the plotting code"
ggplot(salespeople, aes(customer_rate, promoted)) +
geom_jitter(height = 0.03, alpha = 0.20, colour = "#122a52") +
geom_hline(yintercept = c(0, 1), linetype = "dotted", colour = "grey40") +
geom_smooth(method = "lm", formula = y ~ x, se = FALSE,
fullrange = TRUE, colour = "#d32f2f") +
geom_smooth(method = "glm", formula = y ~ x, se = FALSE,
method.args = list(family = binomial),
fullrange = TRUE, colour = "#3d68a8") +
expand_limits(x = c(-1, 10)) +
labs(title = "A straight line runs off the end of the world",
subtitle = "Red = ordinary linear fit; blue = logistic fit; dotted = the 0 and 1 boundaries",
x = "Customer rating", y = "Promoted (0/1)")
```
::: callout-important
Follow the red line far enough and it predicts a **−0.3 probability**
of promotion, or a **1.4**. Both are nonsense — probabilities live
between 0 and 1.
The blue line bends. It approaches 0 and 1 without ever reaching them,
which is exactly the behaviour a probability should have. The rest of
this section is how it does that.
:::
### Odds, and then log-odds
The trick is to stop modelling the probability directly and model
something unbounded instead. It takes two steps.
**Step one: odds.** Instead of "30% chance of promotion", say the
**odds** — the chance it happens divided by the chance it doesn't:
$$\text{odds} = \frac{p}{1-p}$$
A 30% chance is odds of 0.3 / 0.7 ≈ **0.43**, or a bit under one to
two. A 50% chance is odds of exactly 1. A 90% chance is odds of 9.
That's progress: probabilities were trapped between 0 and 1, but odds
can be anything from 0 upwards. Still bounded below, though.
**Step two: take the logarithm.** Log-odds — the **logit** — run from
minus infinity to plus infinity:
$$\text{logit}(p) = \log\!\left(\frac{p}{1-p}\right)$$
| Probability | Odds | Log-odds |
|-------------|-------------|-------------|
| 0.10 | 0.11 | −2.20 |
| 0.30 | 0.43 | −0.85 |
| 0.50 | 1.00 | **0.00** |
| 0.70 | 2.33 | 0.85 |
| 0.90 | 9.00 | 2.20 |
Two things worth memorising from that table: **log-odds of 0 means a
50/50 chance**, and the scale is **symmetric** — 0.3 and 0.7 sit the
same distance either side of zero.
Now a straight line is safe. We fit the line on the log-odds scale,
where it can run off in either direction without embarrassment, then
translate back to a probability at the end. That translation is what
bends the blue curve.
$$
\text{promoted}_i \sim \text{Bernoulli}(p_i), \qquad
\text{logit}(p_i) = \alpha + \beta_1 \text{customer\_rate}_i + \beta_2 \text{performance}_i
$$
::: callout-note
The cost of this trick is that your coefficients are now in log-odds,
which nobody has intuition for. That's a presentation problem, not a
modelling one, and the two sections after the fit are entirely about
solving it.
:::
### One word changes in `brms`
```{r}
#| eval: false
brm(sales ~ customer_rate_c, family = gaussian()) # continuous outcome
brm(promoted ~ customer_rate_s + performance_f, family = bernoulli()) # yes/no outcome
```
Priors, posteriors, credible intervals, checks — all identical to what
you already know.
A prior on the log-odds scale is hard to judge by eye — `1.5`
doesn't mean much until you translate it. So this is the chapter where a
**prior predictive check** earns its keep, exactly as Chapter 5
described: simulate draws from the intercept prior and translate them to a
probability:
```{r plot-prior-logit}
set.seed(901)
tibble(logit_intercept = rnorm(4000, mean = 0, sd = 1.5)) |>
mutate(prob = plogis(logit_intercept)) |> # <1>
ggplot(aes(prob)) +
geom_histogram(bins = 40, fill = "#8fabd0", colour = "white") +
labs(title = "What Normal(0, 1.5) on the log-odds scale implies",
subtitle = "Simulated draws from the intercept prior, translated to a baseline promotion probability",
x = "Implied baseline promotion probability", y = "Simulated draws")
```
1. `plogis()` is the inverse logit — it converts log-odds back to a
probability, undoing the two-step transformation above. Its
partner `qlogis()` goes the other way. You will use `plogis()`
constantly from here on.
::: callout-note
Spread fairly evenly across plausible promotion rates, with a little
extra weight near the extremes — reasonable for "we don't know the
baseline rate yet," and nowhere near committing to, say, "everyone
gets promoted."
:::
------------------------------------------------------------------------
## Fit the model
```{r fit-logit}
priors <- c(
prior(normal(0, 1.5), class = Intercept),
prior(normal(0, 1), class = b) # <1>
)
fit_logit <- brm(
promoted ~ customer_rate_s + performance_f,
data = salespeople, family = bernoulli(),
prior = priors,
chains = 4, iter = 2000, seed = 9, refresh = 0
)
summary(fit_logit)
```
1. No `sigma` this time. A Bernoulli outcome has no separate spread
parameter to estimate — once you know the probability, the
variability is fully determined. One fewer thing to think about.
::: callout-tip
## Why standardised, not just centred?
Chapter 6 centred `customer_rate`. Here we **standardised** it instead —
that is the `scale()` call in the setup chunk at the top of the chapter,
which centres the variable *and* divides by its standard deviation, so
one unit is now one SD rather than one rating point.
The reason is the prior. On the log-odds scale, `normal(0, 1)` is a
sensible weakly-informative prior for a coefficient — it says a
one-unit change is unlikely to swing the odds by more than a factor of
about seven. But "one unit" only means something once you've fixed the
scale of the predictor. Standardising makes that prior mean the same
thing regardless of whether the variable is measured in points,
pounds or years.
The cost is that your coefficients are now "per standard deviation",
which is not a unit anyone thinks in — so we translate back before
showing anyone.
:::
------------------------------------------------------------------------
## Interpreting the results
### Translation 1 — odds ratios
Exponentiating a log-odds coefficient turns it into an **odds ratio**:
the factor by which the odds multiply.
```{r odds-ratios}
fixef(fit_logit) |>
as_tibble(rownames = "term") |>
mutate(odds_ratio = exp(Estimate)) |> # <1>
select(term, Estimate, odds_ratio)
```
1. `exp()` undoes the logarithm, taking us from log-odds back to
odds. An odds ratio above 1 means more likely to be promoted;
below 1, less likely; exactly 1, no effect.
::: callout-important
## The mistake almost everyone makes with odds ratios
An odds ratio of 3 does **not** mean "three times as likely".
It means the *odds* triple, and odds are not probabilities. Work it
through at a 30% baseline promotion rate:
- Odds of 0.3 / 0.7 = **0.43**
- Triple them: **1.29**
- Convert back: 1.29 / (1 + 1.29) = **56%**
So an odds ratio of 3 took the probability from 30% to 56% — not quite
**double**, nowhere near triple. Report it as "three times as likely"
and you have overstated your finding by a factor of about 1.6.
The gap depends entirely on the baseline. When an outcome is **rare**
— a 2% chance of something — odds and probabilities are close, and an
odds ratio of 3 really does mean roughly three times as likely. When
the outcome is **common**, which is nearly everything in People
Analytics, the two diverge badly.
This matters beyond pedantry. Odds ratios turn up constantly in pay
equity, promotion equity and adverse impact analysis — exactly the
places where overstating an effect by 60% is least acceptable. If you
are going to quote one to a non-technical audience, **convert it to
probabilities first**, which is the next section.
:::
### Translation 2 — predicted probabilities
Odds ratios are exact but abstract. **Probabilities** are what a
manager can act on.
```{r predicted-prob}
rate_mean <- mean(salespeople$customer_rate)
rate_sd <- sd(salespeople$customer_rate)
tier_colours <- c("#9db4d4", "#5a83b8", "#2f5389", "#122a52")
promo_grid <- expand_grid(
customer_rate_s = seq(-2, 2, by = 0.1),
performance_f = levels(salespeople$performance_f)
) |>
mutate(customer_rate = customer_rate_s * rate_sd + rate_mean) # <1>
promo_grid |>
add_epred_draws(fit_logit) |>
mean_qi(.epred, .width = 0.95) |>
ggplot(aes(customer_rate, .epred,
colour = performance_f, fill = performance_f)) +
geom_ribbon(aes(ymin = .lower, ymax = .upper), alpha = 0.15, colour = NA) +
geom_line(linewidth = 1) +
scale_colour_manual(values = tier_colours) + # <2>
scale_fill_manual(values = tier_colours) +
scale_y_continuous(labels = scales::percent) +
labs(title = "Predicted promotion probability",
subtitle = "Rises with customer rating, and sharply with performance tier",
x = "Customer rating", y = "P(promoted)",
colour = "Performance tier", fill = "Performance tier")
```
1. Undoing the standardisation for the axis. The model was fitted on
standardised units because that made the priors sensible — but
"customer rating of 4.2" is a thing your audience recognises and
"0.7 standard deviations" is not. **Fit on whatever scale suits
the model; present on the scale the business thinks in.**
2. A sequential ramp: light for tier 1, darkest for tier 4. Performance
tier is **ordered**, so the colours should be ordered too — a reader
can then rank the lines without consulting the legend. The four
shades are spaced far enough apart in lightness to stay separable
when printed or projected.
::: callout-important
## This chart is the deliverable
Everything else in this chapter is scaffolding. This is what goes on
the slide.
Promotion likelihood **climbs with customer rating** and is
**dramatically higher for top performers** — with honest uncertainty
bands around both, on a scale anyone in a meeting can read. A talent team
could act on it tomorrow: check whether the promotion process is
weighting these factors the way leadership intends.
Note what isn't on it. No coefficients, no log-odds, no odds ratios,
no p-values, no mention of `brms`. The rigour is in how the number was
produced, not in how much of the machinery you show.
:::
To say that out loud you need the two numbers themselves, not the
chart. Pull them straight off the same posterior:
```{r headline-numbers}
tiers <- levels(salespeople$performance_f)
headline_draws <- tibble(
customer_rate_s = 0, # <1>
performance_f = factor(c(last(tiers), tiers[2]), # <2>
levels = tiers)
) |>
add_epred_draws(fit_logit)
headline <- headline_draws |> mean_qi(.epred, .width = 0.95)
p_top <- headline |> filter(performance_f == last(tiers)) |> pull(.epred)
p_mid <- headline |> filter(performance_f == tiers[2]) |> pull(.epred)
headline
```
1. Standardised units, so zero **is** the average customer rating —
the same centring habit from Chapter 6, doing the same job.
2. The top tier and a mid tier, holding customer rating fixed so the
comparison isolates performance.
### The gap needs its own posterior
Those are two intervals, and it is tempting to look at them, see that
they do not overlap, and call the gap established. Chapter 7 was careful
not to do that, and the reason applies here unchanged: both estimates
come out of the **same** model, so their draws are correlated, and
summarising each first and then subtracting throws that correlation
away.
So take the difference draw by draw, exactly as Chapter 7 did:
```{r headline-gap}
gap <- headline_draws |>
compare_levels(.epred, by = performance_f, # <1>
comparison = list(c(last(tiers), tiers[2]))) |>
ungroup() |>
median_qi(.epred, .width = 0.95)
gap
```
1. The same `compare_levels()` from Chapter 7, now on the probability
scale rather than the sales scale. The result is a posterior for
*the gap itself*, in percentage points of promotion probability —
which is the quantity the sentence below is about, and therefore the
quantity that should carry the interval.
::: callout-tip
## The sentence to go with it
*"A top-rated salesperson with average customer scores has about a
`r round(100 * p_top)`% chance of promotion. A mid-tier one with the
same customer scores has about `r round(100 * p_mid)`%. The gap between
them is about `r round(100 * gap$.epred)` percentage points, and we can
put it between `r round(100 * gap$.lower)` and
`r round(100 * gap$.upper)` — and customer rating matters too, but much
less than the performance tier does."*
One chart, one sentence, no jargon, and a number attached to every
claim — including to the comparison, which is the claim the room will
actually repeat. That's the deliverable.
:::
::: callout-note
## Why not just say the intervals don't overlap?
Because it answers a slightly different question, and it happens to be
safe here rather than being safe in general.
Two intervals that don't overlap do imply a difference. But two
intervals that *do* overlap can still sit either side of a gap the model
is confident about — the correlation between the draws can make the
difference far better determined than either estimate is on its own.
Reading confidence off an overlap is therefore right sometimes and
conservative the rest of the time, in a way you can't tell apart by
looking.
The rule from Chapter 7 stands, and it is short: **if the claim is about
a difference, state the posterior of the difference.** It costs one line
of code, and it is the number the stakeholder is being asked to rely on.
:::
::: callout-warning
## Closing the loop on the rating itself
Chapter 7 flagged that these performance ratings are assigned by
different managers to different standards, and pooled them anyway for
teaching. That assumption is still in force here, and it matters more
now than it did there, because the rating has stopped being the thing
being described and become a **predictor of who gets promoted**.
So state it in the deliverable, not in a footnote: this model takes the
ratings as given, and reads "top-rated" as *"rated 4 by their own
manager"*. If rating standards vary across managers — and Chapter 8
argued they almost certainly do — then part of what looks like a
promotion advantage for top performers is a promotion advantage for
people with generous managers, and this model cannot tell you how much.
The more rigorous version is a multilevel one, and by this point in the
book it is a formula rather than a project: add `(1 | manager_id)` so
each manager gets their own baseline, and Chapter 8's partial pooling
handles the managers with only a handful of reports. If your HRIS
carries the rater, it is worth the extra term. If it doesn't — which is
common — then say the sentence above and let the reader discount
accordingly.
:::
### Does it fit?
```{r ppc}
pp_check(fit_logit, type = "bars", ndraws = 100) + # <1>
scale_x_continuous(breaks = c(0, 1),
labels = c("Not promoted", "Promoted")) +
labs(title = "Posterior predictive check (binary outcome)",
subtitle = "Bars = what the model predicts; points = what actually happened",
x = NULL, y = "Number of salespeople")
```
1. `type = "bars"` instead of the default density overlay. With a 0/1
outcome there are only two possible values, so a smooth density
curve would be meaningless — we want the observed *count* of 0s
and 1s compared against the counts the model predicts. Match the
check to the shape of the outcome.
You want the observed points to sit inside the predicted intervals for
both bars. If the model systematically predicts too many promotions,
you'll see it here and nowhere else.
### Your turn {.unnumbered}
**1.** Add `sales` to the logistic model. Does higher sales change
promotion odds, holding customer rating and performance tier constant?
Report its odds ratio — and then convert it into a change in
probability at a 30% baseline, which is the version you'd actually
present.
**2.** Compare the model with and without `sales` using LOO from
Chapter 7. Does it earn its place?
```{r your-turn}
# Your code here
```
------------------------------------------------------------------------
## On the job {.unnumbered}
::: callout-important
## Why this matters day to day
Yes/no outcomes are most of what People Analytics is asked about —
accept or decline, pass or fail probation, promoted or not, stayed or
left. Logistic regression is the workhorse for all of them, and it's
the same `brm()` call you already know with `family = bernoulli()`.
The part that separates a good analyst here is **translation**. The
model speaks log-odds, the software offers you odds ratios, and your
stakeholder needs probabilities. Doing that conversion carefully — and
resisting the temptation to say "three times as likely" when the odds
ratio is 3 — is what keeps your findings both useful and honest.
:::
------------------------------------------------------------------------
## Summary {.unnumbered}
::: callout-note
## Today you learned
1. A straight line can't model a probability, because lines don't
stop at 0 and 1. **Log-odds** are unbounded, so we fit the line
there and translate back.
2. **Logistic regression** (`family = bernoulli()`) does exactly
that, and has no `sigma` to estimate.
3. **Log-odds of 0 is a 50/50 chance**, and the scale is symmetric
around it.
4. An **odds ratio** is not a ratio of probabilities. An OR of 3 at a
30% baseline is a move to 56% — not "three times as likely".
Convert to probabilities before presenting.
5. **Fit on whatever scale suits the model; present on the scale the
business thinks in.**
6. Match your **posterior predictive check** to the shape of the
outcome — `type = "bars"` for binary.
7. When the claim is about a **gap between two groups**, compute the
gap's own posterior with `compare_levels()`. Two non-overlapping
intervals are not the same statement, and the difference is the
number the stakeholder will quote.
:::
## Next chapter {.unnumbered}
**The complete workflow** — you now have every technique in Part III.
Next we assemble them into a single repeatable recipe, and a map of
which model to reach for whatever your outcome looks like.