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.
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?
Averaging a 0/1 complication column with mean() gives you what quantity?
Your outcome is whether a patient had a stroke (0 = no, 1 = yes). Which line fits the right model?
In the formula complication ~ age + smoker passed to glm(), what does the + do?
A logistic model reports a raw coefficient of 0 for a predictor. What does that mean on the odds-ratio scale?
A logistic model gives a smoking coefficient of 0.8 on the log-odds scale. Which call converts it to an odds ratio?
Which call turns a fitted logistic model into a table of adjusted odds ratios with 95% confidence intervals?
An adjusted odds ratio for smoker is 2.1 with a 95% confidence interval of 1.2 to 3.7. Which interpretation is correct?
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.
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.
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.
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.
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.](GIF_logistic_regression.gif)
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 this snippet in the R Scratchpad on the right.
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)

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.
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 this snippet in the R Scratchpad on the right.
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)

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.
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.
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)
- glm() with family = binomial fits logistic regression
- the outcome is the 0/1 column
- summary() shows the log-odds coefficients
The same solution with key parts replaced by ???.
Fill in every ??? so the code matches the reference,
then ask the tutor to check it.
Your turn: On the log-odds scale a coefficient of 0 means no effect. What odds ratio does that correspond to? Report round(exp(0), 2).

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

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 this snippet in the R Scratchpad on the right.
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 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.

Let’s practise turn a log-odds into a readable odds ratio.
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.
coef_smoker <- 0.8
round(exp(coef_smoker), 2)
- a raw glm coefficient is a log-odds
- exp() converts it to an odds ratio
- an OR above 1 means higher odds of the event
The same solution with key parts replaced by ???.
Fill in every ??? so the code matches the reference,
then ask the tutor to check it.
Your turn: A coefficient of 1.1 corresponds to what odds ratio? Report round(exp(1.1), 2).

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 this snippet in the R Scratchpad on the right.
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)

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 this snippet in the R Scratchpad on the right.
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)

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.
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.
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.
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)
- Build the cohort with set.seed(2025) and load broom
- glm() with family = binomial fits the logistic model
- exponentiate = TRUE converts log-odds coefficients into odds ratios
- conf.int = TRUE adds the 95% confidence interval for each odds ratio
The same solution with key parts replaced by ???.
Fill in every ??? so the code matches the reference,
then ask the tutor to check it.
Your turn: Now fit a model with age as the only predictor and report its adjusted odds ratio with confidence interval. Build cohort first with set.seed(2025) as shown earlier, and load broom.

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.
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?
Averaging a 0/1 complication column with mean() gives you what quantity?
Your outcome is whether a patient had a stroke (0 = no, 1 = yes). Which line fits the right model?
In the formula complication ~ age + smoker passed to glm(), what does the + do?
A logistic model reports a raw coefficient of 0 for a predictor. What does that mean on the odds-ratio scale?
A logistic model gives a smoking coefficient of 0.8 on the log-odds scale. Which call converts it to an odds ratio?
Which call turns a fitted logistic model into a table of adjusted odds ratios with 95% confidence intervals?
An adjusted odds ratio for smoker is 2.1 with a 95% confidence interval of 1.2 to 3.7. Which interpretation is correct?
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.
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.
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.
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.
Submit the post-test to see your results.
What is the one thing from this module that is still unclear to you?