Section 1 of 9

1 Before you start

Before you begin, take a few minutes to check what you already know and how confident you feel. You will see the same questions again at the end of the module — this helps both you and us measure what you have learned. Click an option for every question and confidence rating, then click Next to continue.

Pre-test

You want to model whether a patient had a post-operative complication (0 = no, 1 = yes). Why is lm() the wrong tool for this outcome?

Pre-test

Averaging a 0/1 complication column with mean() gives you what quantity?

Pre-test

Your outcome is whether a patient had a stroke (0 = no, 1 = yes). Which line fits the right model?

Pre-test

In the formula complication ~ age + smoker passed to glm(), what does the + do?

Pre-test

A logistic model reports a raw coefficient of 0 for a predictor. What does that mean on the odds-ratio scale?

Pre-test

A logistic model gives a smoking coefficient of 0.8 on the log-odds scale. Which call converts it to an odds ratio?

Pre-test

Which call turns a fitted logistic model into a table of adjusted odds ratios with 95% confidence intervals?

Pre-test

An adjusted odds ratio for smoker is 2.1 with a 95% confidence interval of 1.2 to 3.7. Which interpretation is correct?

Pre-confidence

I can recognise a binary 0/1 outcome and explain why it needs glm(family = binomial) rather than lm(), which can predict impossible probabilities outside 0 and 1.

Not at all confident
Fully confident
Pre-confidence

I can fit a logistic regression with glm(family = binomial), convert its log-odds coefficients to adjusted odds ratios with exp() or broom, and read one against the line of no effect at 1.

Not at all confident
Fully confident
Pre-confidence

I can produce a tidy, publication-ready table of adjusted odds ratios with 95% confidence intervals using broom::tidy() or gtsummary::tbl_regression(), and interpret one odds ratio in plain clinical language.

Not at all confident
Fully confident
Section 2 of 9

2 Introduction

In Part I you fit a linear model with lm() to predict a number — a patient's blood pressure, their HbA1c. But much of clinical research asks a yes/no question instead: did the patient develop a complication, respond to treatment, survive the admission? This part teaches you to model that kind of outcome and report it in the language a clinician actually reads: the odds ratio.

This part of the module covers five skills, built one rung at a time:

  • Binary outcomes — recognise a 0/1 outcome, see why a straight line predicts impossible probabilities, and reach for a model built for proportions instead.
  • Logistic regression — fit a model for a yes/no outcome with glm(family = binomial) and read its log-odds coefficients.
  • Odds ratios — turn the model's coefficients into adjusted odds ratios with exp(), and read one against the line of no effect at 1.
  • Publication tables — produce tidy, reportable output with broom::tidy() and gtsummary::tbl_regression().
  • Clinical interpretation — put it together: fit the model, exponentiate, and state one adjusted odds ratio and its confidence interval in plain clinical words.

By the end of this part you will be able to fit a logistic regression, explain why a 0/1 outcome needs glm(family = binomial) rather than lm(), convert its coefficients to adjusted odds ratios and interpret one for a clinician, and produce a tidy, publication-ready table of odds ratios with 95% confidence intervals.

Try every snippet in the R Scratchpad on the right. This part needs no data file — the examples build a small simulated cohort of 300 surgical patients with set.seed(), so everyone who runs the code gets the exact same cohort.

Section 3 of 9

3 From a number to a yes/no outcome

Part I predicted a continuous number, so lm() was the right tool. Now the outcome is binary — it takes exactly two values, coded 0 and 1, such as 0 for no complication and 1 for a complication. Averaging a 0/1 column gives you the proportion with the event, which is a probability between 0 and 1.

The classic novice trap is to fit lm() to that 0/1 column as if it were a number. A straight line is not bounded between 0 and 1, so it happily predicts a probability of 1.3 or minus 0.2 — impossible values. The fix is a model built for proportions.

That model is logistic regression. You fit it with glm() — the generalised linear model function — and you tell it the outcome is binary by setting family = binomial. The glm part is base R, from the stats package that is always loaded.

A binary 0/1 outcome must be modelled with logistic regression because a straight line is not bounded to [0, 1] and so predicts impossible probabilities, whereas the S-shaped logistic curve always stays between 0 and 1.
A binary 0/1 outcome must be modelled with logistic regression because a straight line is not bounded to [0, 1] and so predicts impossible probabilities, whereas the S-shaped logistic curve always stays between 0 and 1.

Let us build a small cohort to model. We simulate 300 patients whose risk of a post-operative complication rises with age and is higher in smokers. Using set.seed() first means everyone who runs this code gets the exact same cohort.

Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
set.seed(2025)
n <- 300
age <- round(rnorm(n, mean = 60, sd = 10))
smoker <- rbinom(n, 1, 0.35)
lp <- -6 + 0.07 * age + 0.8 * smoker
prob <- 1 / (1 + exp(-lp))
complication <- rbinom(n, 1, prob)
cohort <- data.frame(age, smoker, complication)
mean(cohort$complication)
Each patient's complication is not read off a table but generated: their risk factors are added into a log-odds, the logistic function turns that into a probability, and a single random draw against that probability decides the 0 or 1, so the model's real output is a probability, not a certainty.
Each patient's complication is not read off a table but generated: their risk factors are added into a log-odds, the logistic function turns that into a probability, and a single random draw against that probability decides the 0 or 1, so the model's real output is a probability, not a certainty.

Notice the last line: mean() of a 0/1 column is the event rate — the proportion of patients who had a complication. That single number is what a logistic model explains in terms of age and smoking.

Section 4 of 9

4 Fitting a logistic model

With the cohort in hand, the model fits in one line. The formula reads outcome on the left, predictors on the right, joined by a tilde: complication ~ age + smoker. The + means you adjust for both predictors at once.

Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
set.seed(2025)
n <- 300
age <- round(rnorm(n, mean = 60, sd = 10))
smoker <- rbinom(n, 1, 0.35)
lp <- -6 + 0.07 * age + 0.8 * smoker
complication <- rbinom(n, 1, 1 / (1 + exp(-lp)))
cohort <- data.frame(age, smoker, complication)
fit <- glm(complication ~ age + smoker, family = binomial, data = cohort)
summary(fit)
An R model formula reads as "predict the outcome from these predictors", where the tilde means "depends on" and the plus puts every predictor in one model so each effect is adjusted for the others.
An R model formula reads as "predict the outcome from these predictors", where the tilde means "depends on" and the plus puts every predictor in one model so each effect is adjusted for the others.

Read the Estimate column of the summary. Each estimate is a log-odds coefficient: the change in the natural log of the odds of a complication for a one-unit rise in that predictor, holding the others fixed. Log-odds are correct but unreadable — nobody thinks in log-odds at the bedside.

Here is the trap that catches every beginner. On the log-odds scale, a coefficient of 0 means no effect — not 1. A positive coefficient raises the odds; a negative one lowers them. To make the numbers speak, you exponentiate them, which is the next rung.

Let’s try and fit a model built for a yes/no outcome.

Worked example · Fit a logistic model

Work through this example in three stages. You unlock each stage only after the tutor confirms the previous one. Each stage removes more of the scaffolding — by the end you are writing it yourself.

Problem: Build a small cohort and fit a logistic model of complication on age, then read the coefficient summary.

Stage 1 · Study the solved example
Fully solved solution
age <- c(45, 52, 61, 58, 70, 49, 65, 55)
complication <- c(0, 0, 1, 0, 1, 0, 1, 0)
fit <- glm(complication ~ age, family = binomial)
summary(fit)
Walk-through
  1. glm() with family = binomial fits logistic regression
  2. the outcome is the 0/1 column
  3. summary() shows the log-odds coefficients
A logistic coefficient lives on the log-odds scale where no effect is 0, so you exponentiate to read an odds ratio, where no effect is 1 (below 1 means less likely, above 1 means more likely).
A logistic coefficient lives on the log-odds scale where no effect is 0, so you exponentiate to read an odds ratio, where no effect is 1 (below 1 means less likely, above 1 means more likely).
Section 5 of 9

5 From coefficients to adjusted odds ratios

The fix for unreadable log-odds is the odds ratio, written OR. You get it by exponentiating a coefficient with exp(). The odds ratio compares the odds of the event in one group to the odds in another, after adjusting for every other predictor in the model.

Read an odds ratio against 1, not 0. An OR above 1 means higher odds of the event; below 1 means lower odds; exactly 1 means no effect. The reference point is 1 because exponentiating the no-effect coefficient of 0 gives exp(0), which is 1.

So forgetting to exponentiate is the cardinal sin of logistic regression. The raw glm coefficient is a log-odds; an OR of 1 — not 0 — is the line of no effect. Always ask whether

Exponentiating a logistic-regression coefficient converts a log-odds into an odds ratio, which is why the line of no effect sits at 1 (since exp(0)=1) rather than at 0.
Exponentiating a logistic-regression coefficient converts a log-odds into an odds ratio, which is why the line of no effect sits at 1 (since exp(0)=1) rather than at 0.

Do the exponentiation yourself on the whole model. exp(coef(fit)) turns every log-odds coefficient into an odds ratio at once — the line you reach for after almost every logistic fit.

Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
set.seed(2025)
n <- 300
age <- round(rnorm(n, mean = 60, sd = 10))
smoker <- rbinom(n, 1, 0.35)
complication <- rbinom(n, 1, 1 / (1 + exp(-(-6 + 0.07 * age + 0.8 * smoker))))
cohort <- data.frame(age, smoker, complication)
fit <- glm(complication ~ age + smoker, family = binomial, data = cohort)
exp(coef(fit))
One exp() call rescales the entire vector of logistic coefficients from the additive log-odds scale (centred on 0) to the multiplicative odds-ratio scale (centred on 1), so each slope becomes a per-unit multiplier on the odds.
One exp() call rescales the entire vector of logistic coefficients from the additive log-odds scale (centred on 0) to the multiplicative odds-ratio scale (centred on 1), so each slope becomes a per-unit multiplier on the odds.

One more distinction students blur. An odds ratio is not a risk ratio. A risk ratio compares probabilities directly; an odds ratio compares odds, where odds are probability divided by one-minus-probability. For a rare outcome the two are close, but for a common outcome an odds ratio is further from 1 than the risk ratio — never report an OR as if it were a relative risk.

An odds ratio and a risk ratio coincide only when the outcome is rare; when the outcome is common the odds ratio sits further from 1 than the risk ratio, so the same doubling of risk can be made to look like a much larger effect.
An odds ratio and a risk ratio coincide only when the outcome is rare; when the outcome is common the odds ratio sits further from 1 than the risk ratio, so the same doubling of risk can be made to look like a much larger effect.

Let’s practise turn a log-odds into a readable odds ratio.

Worked example · Coefficients to odds ratios

Work through this example in three stages. You unlock each stage only after the tutor confirms the previous one. Each stage removes more of the scaffolding — by the end you are writing it yourself.

Problem: A logistic model gives a smoking log-odds coefficient of 0.8. Convert it to an odds ratio, rounded to two decimals.

Stage 1 · Study the solved example
Fully solved solution
coef_smoker <- 0.8
round(exp(coef_smoker), 2)
Walk-through
  1. a raw glm coefficient is a log-odds
  2. exp() converts it to an odds ratio
  3. an OR above 1 means higher odds of the event
A logistic regression coefficient is a log-odds, and applying exp() converts it into an odds ratio, where 1 means no effect and a value above 1 (here 2.23) means higher odds of the event.
A logistic regression coefficient is a log-odds, and applying exp() converts it into an odds ratio, where 1 means no effect and a value above 1 (here 2.23) means higher odds of the event.
Section 6 of 9

6 Tidy, publication-ready tables

Reading odds ratios off a summary() by hand is slow and error-prone. Two packages turn the model into a clean table. First, broom tidies any model into a data frame you can sort, filter, and reuse.

Call broom::tidy() with exponentiate = TRUE to report odds ratios instead of log-odds, and conf.int = TRUE to add a 95% confidence interval. The :: writes the package name before the function, so you can see exactly where it comes from.

Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
library(broom)
set.seed(2025)
n <- 300
age <- round(rnorm(n, mean = 60, sd = 10))
smoker <- rbinom(n, 1, 0.35)
complication <- rbinom(n, 1, 1 / (1 + exp(-(-6 + 0.07 * age + 0.8 * smoker))))
cohort <- data.frame(age, smoker, complication)
fit <- glm(complication ~ age + smoker, family = binomial, data = cohort)
tidy(fit, exponentiate = TRUE, conf.int = TRUE)
broom::tidy() with exponentiate = TRUE and conf.int = TRUE turns a logistic model's log-odds summary into a clean, sortable table of odds ratios, each with a 95% confidence interval, so you read effect sizes directly instead of mentally exponentiating by hand.
broom::tidy() with exponentiate = TRUE and conf.int = TRUE turns a logistic model's log-odds summary into a clean, sortable table of odds ratios, each with a 95% confidence interval, so you read effect sizes directly instead of mentally exponentiating by hand.

For a table you can paste straight into a manuscript, reach for gtsummary. Its tbl_regression() function takes the fitted model and, with exponentiate = TRUE, prints adjusted odds ratios with confidence intervals and p-values, already formatted with proper variable labels.

Try it out

Try this snippet in the R Scratchpad on the right.

Try this snippet
library(gtsummary)
set.seed(2025)
n <- 300
age <- round(rnorm(n, mean = 60, sd = 10))
smoker <- rbinom(n, 1, 0.35)
complication <- rbinom(n, 1, 1 / (1 + exp(-(-6 + 0.07 * age + 0.8 * smoker))))
cohort <- data.frame(age, smoker, complication)
fit <- glm(complication ~ age + smoker, family = binomial, data = cohort)
tbl_regression(fit, exponentiate = TRUE)
Exponentiating a logistic-regression coefficient places it on the odds-ratio scale, where OR = 1 means no effect and the further an odds ratio sits from 1, the stronger the association.
Exponentiating a logistic-regression coefficient places it on the odds-ratio scale, where OR = 1 means no effect and the further an odds ratio sits from 1, the stronger the association.

Now interpret one odds ratio in plain clinical language. Suppose the adjusted OR for smoker is 2.1, with a 95% confidence interval of 1.2 to 3.7. You would write: after adjusting for age, smokers have about 2.1 times the odds of a post-operative complication compared with non-smokers, and the interval excludes 1, so the association is statistically significant.

Section 7 of 9

7 Put it together: fit, exponentiate, interpret

The worked example below fades the support as you go: first you study a full solution, then you fill the gap, then you solve a fresh one on your own. The core skill is fitting a logistic model and reporting an adjusted odds ratio.

Worked example · An adjusted odds ratio from a logistic model

Work through this example in three stages. You unlock each stage only after the tutor confirms the previous one. Each stage removes more of the scaffolding — by the end you are writing it yourself.

Problem: Build the simulated cohort, fit a logistic model of complication on age and smoker, then report the adjusted odds ratio and 95% confidence interval for smoker.

Stage 1 · Study the solved example
Fully solved solution
library(broom)
set.seed(2025)
n <- 300
age <- round(rnorm(n, mean = 60, sd = 10))
smoker <- rbinom(n, 1, 0.35)
complication <- rbinom(n, 1, 1 / (1 + exp(-(-6 + 0.07 * age + 0.8 * smoker))))
cohort <- data.frame(age, smoker, complication)
fit <- glm(complication ~ age + smoker, family = binomial, data = cohort)
tidy(fit, exponentiate = TRUE, conf.int = TRUE)
Walk-through
  1. Build the cohort with set.seed(2025) and load broom
  2. glm() with family = binomial fits the logistic model
  3. exponentiate = TRUE converts log-odds coefficients into odds ratios
  4. conf.int = TRUE adds the 95% confidence interval for each odds ratio
An adjusted odds ratio is just exp() of a logistic regression coefficient, and you judge it by whether its 95% confidence interval crosses the no-effect line at 1.
An adjusted odds ratio is just exp() of a logistic regression coefficient, and you judge it by whether its 95% confidence interval crosses the no-effect line at 1.
Section 8 of 9

8 Check your understanding

You have reached the end of the module. Try the same questions again — your answers here, paired with your pre-test answers, are how we measure what the module taught you. Answer every question and confidence rating, then click Submit and see results to view your score.

Post-test

You want to model whether a patient had a post-operative complication (0 = no, 1 = yes). Why is lm() the wrong tool for this outcome?

Post-test

Averaging a 0/1 complication column with mean() gives you what quantity?

Post-test

Your outcome is whether a patient had a stroke (0 = no, 1 = yes). Which line fits the right model?

Post-test

In the formula complication ~ age + smoker passed to glm(), what does the + do?

Post-test

A logistic model reports a raw coefficient of 0 for a predictor. What does that mean on the odds-ratio scale?

Post-test

A logistic model gives a smoking coefficient of 0.8 on the log-odds scale. Which call converts it to an odds ratio?

Post-test

Which call turns a fitted logistic model into a table of adjusted odds ratios with 95% confidence intervals?

Post-test

An adjusted odds ratio for smoker is 2.1 with a 95% confidence interval of 1.2 to 3.7. Which interpretation is correct?

Post-confidence

I can recognise a binary 0/1 outcome and explain why it needs glm(family = binomial) rather than lm(), which can predict impossible probabilities outside 0 and 1.

Not at all confident
Fully confident
Post-confidence

I can fit a logistic regression with glm(family = binomial), convert its log-odds coefficients to adjusted odds ratios with exp() or broom, and read one against the line of no effect at 1.

Not at all confident
Fully confident
Post-confidence

I can produce a tidy, publication-ready table of adjusted odds ratios with 95% confidence intervals using broom::tidy() or gtsummary::tbl_regression(), and interpret one odds ratio in plain clinical language.

Not at all confident
Fully confident
Section 9 of 9

9 Your results

Here is how your post-test answers compare with your pre-test answers. The pre/post pairing is the most reliable way to see what this module actually taught you.

Your score

Submit the post-test to see your results.

Muddiest point

What is the one thing from this module that is still unclear to you?

Rate this module

Overall, how would you rate this module?

How likely are you to recommend this module to a peer? (0 = not at all, 10 = extremely likely)