9  Logistic Regression: Modelling a Yes/No Outcome

Welcome back

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

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.

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.

What you’ll be able to do by the end

  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

9.1 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(
1    customer_rate_s = as.numeric(scale(customer_rate)),
2    performance_f   = factor(performance)
  )
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.

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

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

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

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.

9.2.2 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

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.

9.2.3 One word changes in brms

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:

set.seed(901)

tibble(logit_intercept = rnorm(4000, mean = 0, sd = 1.5)) |>
1  mutate(prob = plogis(logit_intercept)) |>
  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.

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


9.3 Fit the model

priors <- c(
  prior(normal(0, 1.5), class = Intercept),
1  prior(normal(0, 1),   class = b)
)

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.
 Family: bernoulli 
  Links: mu = logit 
Formula: promoted ~ customer_rate_s + performance_f 
   Data: salespeople (Number of observations: 350) 
  Draws: 4 chains, each with iter = 2000; warmup = 1000; thin = 1;
         total post-warmup draws = 4000

Regression Coefficients:
                Estimate Est.Error l-95% CI u-95% CI Rhat Bulk_ESS Tail_ESS
Intercept          -1.33      0.29    -1.91    -0.78 1.00     2302     2343
customer_rate_s     0.42      0.12     0.19     0.67 1.00     4029     3085
performance_f2     -0.01      0.36    -0.70     0.69 1.00     2517     2751
performance_f3      0.83      0.33     0.21     1.49 1.00     2524     2958
performance_f4      1.36      0.38     0.64     2.10 1.00     2709     2859

Draws were sampled using sampling(NUTS). For each parameter, Bulk_ESS
and Tail_ESS are effective sample size measures, and Rhat is the potential
scale reduction factor on split chains (at convergence, Rhat = 1).
TipWhy 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.


9.4 Interpreting the results

9.4.1 Translation 1 — odds ratios

Exponentiating a log-odds coefficient turns it into an odds ratio: the factor by which the odds multiply.

fixef(fit_logit) |>
  as_tibble(rownames = "term") |>
1  mutate(odds_ratio = exp(Estimate)) |>
  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.
# A tibble: 5 × 3
  term            Estimate odds_ratio
  <chr>              <dbl>      <dbl>
1 Intercept        -1.33        0.265
2 customer_rate_s   0.420       1.52 
3 performance_f2   -0.0129      0.987
4 performance_f3    0.826       2.28 
5 performance_f4    1.36        3.89 
ImportantThe 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.

9.4.2 Translation 2 — predicted probabilities

Odds ratios are exact but abstract. Probabilities are what a manager can act on.

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)
) |>
1  mutate(customer_rate = customer_rate_s * rate_sd + rate_mean)

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) +
2  scale_colour_manual(values = tier_colours) +
  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.

ImportantThis 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:

tiers <- levels(salespeople$performance_f)

headline <- tibble(
1  customer_rate_s = 0,
2  performance_f   = factor(c(last(tiers), tiers[2]),
                           levels = tiers)
) |>
  add_epred_draws(fit_logit) |>
  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.
# A tibble: 2 × 9
  customer_rate_s performance_f  .row .epred .lower .upper .width .point
            <dbl> <fct>         <int>  <dbl>  <dbl>  <dbl>  <dbl> <chr> 
1               0 2                 2  0.210  0.144  0.287   0.95 mean  
2               0 4                 1  0.508  0.379  0.637   0.95 mean  
# ℹ 1 more variable: .interval <chr>
TipThe sentence to go with it

“A top-rated salesperson with average customer scores has about a 51% chance of promotion. A mid-tier one with the same customer scores has about 21%. We’re confident in that gap — 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. That’s the deliverable.

9.4.3 Does it fit?

1pp_check(fit_logit, type = "bars", ndraws = 100) +
  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

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?

# Your code here

On the job

ImportantWhy 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

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

Next chapter

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.