About the tutorial data Every record is simulated with fixed random seeds and contains no real personal health information. The simulation mechanism is solely for teaching; associations in the code must not be interpreted as real-world causal or clinical effects.
This tutorial follows one binary outcome: whether a respiratory
infection occurred. Read the sections on probability, odds, and the
logit in order, then run the model code. Give particular attention to
translating model output back to the probability scale.
Every example uses only R’s built-in stats and
graphics functionality plus knitr, so the
document can be knitted in a clean R session.
After completing this tutorial, you should be able to:
glm(..., family = binomial());Logistic regression applies when each observation has one of two mutually exclusive outcomes, such as:
Putting a 0/1 outcome directly into an ordinary linear regression can produce fitted values below 0 or above 1, and the outcome variance changes with its mean. Logistic regression uses a link function that maps any real-valued linear predictor to a probability between 0 and 1.
Define the event first Before
modeling, state which value represents the event. In this tutorial,
infection_num = 1 means that infection occurred. Factor
levels are explicitly ordered as “No” and “Yes,” but the core models use
the numeric 0/1 outcome to leave no ambiguity about the event
direction.
If the event probability is , then:
Probability is “events divided by all people,” whereas odds are “events divided by non-events.” Their numerical values are generally different.
probability_examples <- c(0.05, 0.20, 0.50, 0.80)
probability_table <- data.frame(
Probability = probability_examples,
Odds = probability_examples / (1 - probability_examples),
Log_odds = qlogis(probability_examples)
)
knitr::kable(
probability_table,
digits = 3,
col.names = c("Probability", "Odds", "Log-odds"),
caption = "Correspondence among probability, odds, and log-odds"
)| Probability | Odds | Log-odds |
|---|---|---|
| 0.05 | 0.053 | -2.94 |
| 0.20 | 0.250 | -1.39 |
| 0.50 | 1.000 | 0.00 |
| 0.80 | 4.000 | 1.39 |
When , the odds are , or approximately 1:4. It would be incorrect to call 0.25 a 25% event probability. The inverse transformation is:
A logistic regression with predictors is:
The model specifies a functional relationship between the predictors and log-odds, not a straight-line relationship between predictors and probability. Coefficients are estimated by maximum likelihood: among candidate parameter values, the algorithm finds those under which the observed sequence of 0s and 1s is most likely.
The example asks:
In these simulated data, is vaccination status associated with respiratory infection within one year? How does the association look after accounting for age, BMI, smoking, and residential area? How well does the model predict probabilities for new observations?
Infection is the outcome, vaccination status is the primary explanatory variable, and the remaining variables may be used to reduce confounding or improve prediction. Adjustment decisions should follow the research question, temporal ordering, and subject-matter knowledge—not univariable p-value screening.
Before any outcome exploration, 30% of the complete simulated dataset
was set aside by stratified sampling as a test set. All descriptions,
exploration, functional-form decisions, and model fitting below use only
train_data. The first summary of test_data
appears in the final evaluation section.
## 'data.frame': 839 obs. of 10 variables:
## $ participant_id: chr "P0001" "P0002" "P0003" "P0004" ...
## $ infection_num : int 0 0 0 0 0 0 0 0 1 0 ...
## $ infection : Factor w/ 2 levels "No","Yes": 1 1 1 1 1 1 1 1 2 1 ...
## $ age : num 26 32 61 33 40 32 59 54 41 41 ...
## $ age10 : num -2.4 -1.8 1.1 -1.7 -1 -1.8 0.9 0.4 -0.9 -0.9 ...
## $ bmi : num 31.5 31.1 28 30.8 30.3 31.9 23.8 27.3 28.8 23 ...
## $ bmi5 : num 1.3 1.22 0.6 1.16 1.06 1.38 -0.24 0.46 0.76 -0.4 ...
## $ smoking : Factor w/ 2 levels "No","Yes": 2 1 1 1 1 1 2 1 1 1 ...
## $ vaccinated : Factor w/ 2 levels "No","Yes": 2 1 2 1 1 1 2 2 1 2 ...
## $ area : Factor w/ 2 levels "Urban","Rural": 2 1 1 1 1 2 1 2 1 1 ...
data_summary <- data.frame(
Sample_size = nrow(train_data),
Infections = sum(train_data$infection_num),
Infection_proportion = mean(train_data$infection_num),
Mean_age = mean(train_data$age),
Mean_BMI = mean(train_data$bmi)
)
knitr::kable(
data_summary,
digits = 3,
col.names = c(
"Sample size", "Infections", "Infection proportion",
"Mean age", "Mean BMI"
),
caption = "Overview of the development/training data"
)| Sample size | Infections | Infection proportion | Mean age | Mean BMI |
|---|---|---|---|---|
| 839 | 169 | 0.201 | 48.6 | 26.4 |
vaccination_table <- with(
train_data,
table(Vaccination = vaccinated, Infection = infection)
)
vaccination_table## Infection
## Vaccination No Yes
## No 310 98
## Yes 360 71
infection_by_vaccination <- aggregate(
infection_num ~ vaccinated,
data = train_data,
FUN = function(x) c(n = length(x), events = sum(x), risk = mean(x))
)
infection_by_vaccination <- data.frame(
Vaccination = infection_by_vaccination$vaccinated,
Sample_size = infection_by_vaccination$infection_num[, "n"],
Infections = infection_by_vaccination$infection_num[, "events"],
Infection_proportion = infection_by_vaccination$infection_num[, "risk"]
)
knitr::kable(
infection_by_vaccination,
digits = 3,
col.names = c(
"Vaccination", "Sample size", "Infections", "Infection proportion"
),
caption = "Infection counts and proportions by vaccination status"
)| Vaccination | Sample size | Infections | Infection proportion |
|---|---|---|---|
| No | 408 | 98 | 0.240 |
| Yes | 431 | 71 | 0.165 |
observed_risk <- tapply(
train_data$infection_num,
train_data$vaccinated,
mean
)
group_n <- table(train_data$vaccinated)
observed_se <- sqrt(observed_risk * (1 - observed_risk) / group_n)
plot(
seq_along(observed_risk), observed_risk,
pch = c(16, 17), cex = 1.3,
col = c(palette_lr["vermillion"], palette_lr["blue"]),
xaxt = "n", ylim = c(0, max(observed_risk + 2 * observed_se) * 1.10),
xlab = "Vaccination status", ylab = "Observed infection proportion",
main = "Begin with absolute risk"
)
axis(1, at = seq_along(observed_risk), labels = names(observed_risk))
arrows(
seq_along(observed_risk), observed_risk - 1.96 * observed_se,
seq_along(observed_risk), observed_risk + 1.96 * observed_se,
angle = 90, code = 3, length = 0.06,
col = c(palette_lr["vermillion"], palette_lr["blue"])
)Observed infection proportions by vaccination group; error bars are approximate 95% confidence intervals.
This descriptive comparison is important, but it does not account for compositional differences between groups and cannot automatically be interpreted as an effect caused by vaccination.
crude_model <- glm(
infection_num ~ vaccinated,
data = train_data,
family = binomial(link = "logit")
)
summary(crude_model)##
## Call:
## glm(formula = infection_num ~ vaccinated, family = binomial(link = "logit"),
## data = train_data)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) -1.152 0.116 -9.94 <2e-16 ***
## vaccinatedYes -0.472 0.174 -2.71 0.0067 **
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 842.99 on 838 degrees of freedom
## Residual deviance: 835.56 on 837 degrees of freedom
## AIC: 839.6
##
## Number of Fisher Scoring iterations: 4
By default, glm() reports coefficients on the log-odds
scale. Exponentiating the vaccination coefficient gives the crude OR for
vaccinated versus unvaccinated participants.
crude_beta <- coef(crude_model)["vaccinatedYes"]
crude_se <- summary(crude_model)$coefficients[
"vaccinatedYes", "Std. Error"
]
crude_or <- exp(crude_beta)
crude_ci <- exp(crude_beta + qnorm(c(0.025, 0.975)) * crude_se)
crude_result <- data.frame(
Contrast = "Vaccinated vs unvaccinated",
OR = crude_or,
CI_lower = crude_ci[1],
CI_upper = crude_ci[2]
)
knitr::kable(
crude_result,
digits = 3,
col.names = c("Contrast", "OR", "95% CI lower", "95% CI upper"),
caption = "Crude odds ratio for vaccination status"
)| Contrast | OR | 95% CI lower | 95% CI upper | |
|---|---|---|---|---|
| vaccinatedYes | Vaccinated vs unvaccinated | 0.624 | 0.444 | 0.877 |
In the training data, the infection odds ratio for vaccinated versus unvaccinated participants is 0.62 (Wald 95% CI: 0.44 to 0.88). This is a ratio of odds, not a ratio of probabilities or a percentage reduction in risk.
Age is expressed per 10 years and centered at 50 years; BMI is expressed per 5 kg/m² and centered at BMI 25. This makes the intercept scientifically plausible and makes continuous-predictor ORs easier to communicate.
adjusted_model <- glm(
infection_num ~ age10 + bmi5 + smoking + vaccinated + area,
data = train_data,
family = binomial()
)
model_coefficient_table <- function(model, labels = NULL) {
coefficient_matrix <- summary(model)$coefficients
output <- data.frame(
Term = rownames(coefficient_matrix),
Coefficient = coefficient_matrix[, "Estimate"],
Standard_error = coefficient_matrix[, "Std. Error"],
OR = exp(coefficient_matrix[, "Estimate"]),
OR_lower = exp(
coefficient_matrix[, "Estimate"] -
qnorm(0.975) * coefficient_matrix[, "Std. Error"]
),
OR_upper = exp(
coefficient_matrix[, "Estimate"] +
qnorm(0.975) * coefficient_matrix[, "Std. Error"]
),
P_value = coefficient_matrix[, "Pr(>|z|)"],
row.names = NULL
)
if (!is.null(labels)) {
replacement <- unname(labels[output$Term])
output$Term[!is.na(replacement)] <- replacement[!is.na(replacement)]
}
output
}
adjusted_labels <- c(
"(Intercept)" = "Intercept: age 50, BMI 25, non-smoker, unvaccinated, urban",
age10 = "Age (per 10-year increase)",
bmi5 = "BMI (per 5 kg/m² increase)",
smokingYes = "Smoking: Yes vs No",
vaccinatedYes = "Vaccination: Yes vs No",
areaRural = "Area: Rural vs Urban"
)
adjusted_results <- model_coefficient_table(adjusted_model, adjusted_labels)
knitr::kable(
adjusted_results,
digits = 3,
col.names = c(
"Term", "Coefficient", "SE", "OR",
"OR 95% lower", "OR 95% upper", "p-value"
),
caption = "Multivariable logistic regression for infection (Wald intervals)"
)| Term | Coefficient | SE | OR | OR 95% lower | OR 95% upper | p-value |
|---|---|---|---|---|---|---|
| Intercept: age 50, BMI 25, non-smoker, unvaccinated, urban | -1.600 | 0.158 | 0.202 | 0.148 | 0.275 | 0.000 |
| Age (per 10-year increase) | 0.424 | 0.065 | 1.527 | 1.344 | 1.735 | 0.000 |
| BMI (per 5 kg/m² increase) | 0.405 | 0.105 | 1.499 | 1.219 | 1.843 | 0.000 |
| Smoking: Yes vs No | 0.748 | 0.208 | 2.112 | 1.406 | 3.173 | 0.000 |
| Vaccination: Yes vs No | -0.652 | 0.188 | 0.521 | 0.361 | 0.753 | 0.001 |
| Area: Rural vs Urban | 0.468 | 0.190 | 1.596 | 1.100 | 2.315 | 0.014 |
The vaccination interpretation must include the condition “holding the other variables in the model constant.” Interpretations of age and BMI must state their units.
Among observations with the same age, BMI, smoking status, and area, the estimated infection odds ratio for vaccinated versus unvaccinated participants is 0.52. The adjusted OR for each 10-year increase in age is 1.53. These are conditional associations; this simulated observational analysis cannot itself establish causality.
The intercept is the log-odds when all numeric predictors equal zero and all categorical predictors are at their reference levels. Because age and BMI were centered, it describes a 50-year-old, BMI-25, non-smoking, unvaccinated urban resident. Exponentiating the intercept gives this reference profile’s baseline odds, not an OR comparing two groups.
If the unexposed-group risk is and the OR is , the corresponding exposed-group risk is:
baseline_risks <- c(0.05, 0.20, 0.50)
example_or <- 2
risk_from_or <- example_or * baseline_risks /
(1 - baseline_risks + example_or * baseline_risks)
or_risk_table <- data.frame(
Baseline_risk = baseline_risks,
OR = example_or,
Corresponding_risk = risk_from_or,
Risk_ratio = risk_from_or / baseline_risks,
Risk_difference = risk_from_or - baseline_risks
)
knitr::kable(
or_risk_table,
digits = 3,
col.names = c(
"Baseline risk", "OR", "Corresponding risk",
"Risk ratio", "Risk difference"
),
caption = "One OR implies different risk ratios and differences at different baseline risks"
)| Baseline risk | OR | Corresponding risk | Risk ratio | Risk difference |
|---|---|---|---|---|
| 0.05 | 2 | 0.095 | 1.91 | 0.045 |
| 0.20 | 2 | 0.333 | 1.67 | 0.133 |
| 0.50 | 2 | 0.667 | 1.33 | 0.167 |
Only when the outcome is rare can an OR and risk ratio be numerically close. Even then, use the correct measure name when reporting results.
profile_data <- expand.grid(
age10 = c(0, 2),
bmi5 = 0,
smoking = factor("No", levels = levels(train_data$smoking)),
vaccinated = factor(
c("No", "Yes"),
levels = levels(train_data$vaccinated)
),
area = factor("Urban", levels = levels(train_data$area))
)
profile_data$Age <- profile_data$age10 * 10 + 50
profile_data$BMI <- profile_data$bmi5 * 5 + 25
link_prediction <- predict(
adjusted_model,
newdata = profile_data,
type = "link",
se.fit = TRUE
)
profile_data$Predicted_probability <- plogis(link_prediction$fit)
profile_data$Probability_lower <- plogis(
link_prediction$fit - qnorm(0.975) * link_prediction$se.fit
)
profile_data$Probability_upper <- plogis(
link_prediction$fit + qnorm(0.975) * link_prediction$se.fit
)
knitr::kable(
profile_data[, c(
"Age", "BMI", "smoking", "vaccinated", "area",
"Predicted_probability", "Probability_lower", "Probability_upper"
)],
digits = 3,
col.names = c(
"Age", "BMI", "Smoking", "Vaccination", "Area",
"Predicted probability", "Mean probability 95% lower",
"Mean probability 95% upper"
),
caption = "Model-predicted probabilities for specific covariate profiles"
)| Age | BMI | Smoking | Vaccination | Area | Predicted probability | Mean probability 95% lower | Mean probability 95% upper |
|---|---|---|---|---|---|---|---|
| 50 | 25 | No | No | Urban | 0.168 | 0.129 | 0.216 |
| 70 | 25 | No | No | Urban | 0.320 | 0.242 | 0.410 |
| 50 | 25 | No | Yes | Urban | 0.095 | 0.070 | 0.129 |
| 70 | 25 | No | Yes | Urban | 0.197 | 0.144 | 0.263 |
Construct the confidence interval on the link scale first, then
transform it back with plogis(). These intervals reflect
uncertainty in the mean probability estimate for each
profile. They are not probability intervals for an individual’s eventual
binary outcome.
ORs are often difficult to interpret. We can copy each person in the training data twice, set everyone to unvaccinated in one copy and vaccinated in the other, and average the two sets of model-predicted probabilities. This procedure is called model standardization or predictive marginalization.
standardized_no <- train_data
standardized_yes <- train_data
standardized_no$vaccinated <- factor(
"No", levels = levels(train_data$vaccinated)
)
standardized_yes$vaccinated <- factor(
"Yes", levels = levels(train_data$vaccinated)
)
standardized_risk_no <- mean(predict(
adjusted_model, newdata = standardized_no, type = "response"
))
standardized_risk_yes <- mean(predict(
adjusted_model, newdata = standardized_yes, type = "response"
))
standardized_results <- data.frame(
Scenario = c(
"Everyone set to unvaccinated",
"Everyone set to vaccinated",
"Vaccinated minus unvaccinated"
),
Estimate = c(
standardized_risk_no,
standardized_risk_yes,
standardized_risk_yes - standardized_risk_no
)
)
knitr::kable(
standardized_results,
digits = 3,
caption = "Standardized probabilities and average risk difference from the adjusted model"
)| Scenario | Estimate |
|---|---|
| Everyone set to unvaccinated | 0.250 |
| Everyone set to vaccinated | 0.158 |
| Vaccinated minus unvaccinated | -0.093 |
Standardization does not automatically create a causal effect Setting a predictor to two levels is a calculation. Interpreting the resulting contrast causally requires additional assumptions, including consistency, exchangeability, positivity, correct model form, and reliable measurement.
R creates one indicator variable for a two-level factor. Its coefficient compares the non-reference level with the reference level. Inspect levels explicitly:
## $smoking
## [1] "No" "Yes"
##
## $vaccinated
## [1] "No" "Yes"
##
## $area
## [1] "Urban" "Rural"
##
## $infection
## [1] "No" "Yes"
## (Intercept) smokingYes vaccinatedYes areaRural
## 1 1 1 1 1
## 2 1 0 0 0
## 3 1 0 1 0
## 4 1 0 0 0
## 5 1 0 0 0
## 10 1 0 0 1
## attr(,"assign")
## [1] 0 1 2 3
## attr(,"contrasts")
## attr(,"contrasts")$smoking
## [1] "contr.treatment"
##
## attr(,"contrasts")$vaccinated
## [1] "contr.treatment"
##
## attr(,"contrasts")$area
## [1] "contr.treatment"
Use relevel() to change a reference group. Releveling
changes the coefficient representation but not the fitted probability
for any observation.
Logistic regression does not require age itself to follow a normal distribution. The basic model does, however, specify a straight-line relationship between age and the logit. Directly comparing unadjusted age-group infection rates with a conditional prediction for one fixed covariate profile would mix different quantities. Instead, the next code uses a component-plus-residual plot to examine the age term on the same adjusted, conditional logit scale as the fitted model.
age_component <-
unname(coef(adjusted_model)["age10"]) * train_data$age10
age_partial_residual <-
age_component + residuals(adjusted_model, type = "working")
age_group <- cut(
train_data$age,
breaks = quantile(train_data$age, probs = seq(0, 1, 0.1)),
include.lowest = TRUE,
ordered_result = TRUE
)
age_partial_data <- data.frame(
age = train_data$age,
partial_residual = age_partial_residual,
age_group = age_group
)
age_partial_summary <- aggregate(
cbind(age, partial_residual) ~ age_group,
data = age_partial_data,
FUN = mean
)
age_sequence <- seq(
min(train_data$age),
max(train_data$age),
length.out = 200
)
linear_age_component <-
unname(coef(adjusted_model)["age10"]) * ((age_sequence - 50) / 10)
age_smooth <- lowess(
train_data$age,
age_partial_residual,
f = 2 / 3
)plot(
train_data$age, age_partial_residual,
pch = 16, cex = 0.45,
col = rgb(107/255, 114/255, 128/255, 0.28),
xlab = "Age (years)", ylab = "Age component + working residual",
main = "Check age form on the adjusted conditional logit scale"
)
points(
age_partial_summary$age,
age_partial_summary$partial_residual,
pch = 16, cex = 1.15, col = palette_lr["blue"]
)
lines(
age_smooth$x, age_smooth$y,
lwd = 3, lty = 2, col = palette_lr["teal"]
)
lines(
age_sequence, linear_age_component,
lwd = 3, col = palette_lr["vermillion"]
)
legend(
"topleft",
legend = c(
"Age-decile means", "LOWESS smooth", "Model-specified linear age term"
),
pch = c(16, NA, NA), lty = c(NA, 2, 1), lwd = c(NA, 3, 3),
col = c(
palette_lr["blue"],
palette_lr["teal"],
palette_lr["vermillion"]
),
bty = "n"
)Component-plus-residual plot for age in the adjusted model. Systematic departure of grouped means and the smooth from the model line suggests that the functional form should be reconsidered.
Working residuals can become large when fitted probabilities approach 0 or 1, so this plot is a diagnostic clue rather than a formal verdict. If the smooth persistently departs from the model line, use subject-matter knowledge to compare prespecified quadratic, piecewise-linear, or spline terms, and use resampling to assess whether predictive performance truly improves.
If scientific knowledge or the diagnostic suggests curvature, a squared term, piecewise-linear term, or restricted cubic spline can be prespecified. The example compares a linear age term with a quadratic age term. It demonstrates functional form; it should not become an open-ended search for the smallest p-value.
quadratic_model <- glm(
infection_num ~ age10 + I(age10^2) + bmi5 + smoking + vaccinated + area,
data = train_data,
family = binomial()
)
anova(adjusted_model, quadratic_model, test = "Chisq")| Resid. Df | Resid. Dev | Df | Deviance | Pr(>Chi) |
|---|---|---|---|---|
| 833 | 748 | NA | NA | NA |
| 832 | 748 | 1 | 0.004 | 0.952 |
| df | AIC | |
|---|---|---|
| adjusted_model | 6 | 760 |
| quadratic_model | 7 | 762 |
If the vaccination association may differ by residential area, include a product term between vaccination and area:
interaction_model <- glm(
infection_num ~ age10 + bmi5 + smoking + vaccinated * area,
data = train_data,
family = binomial()
)
interaction_coefficients <- coef(interaction_model)
interaction_vcov <- vcov(interaction_model)
# In urban residents, the vaccination OR uses only vaccinatedYes.
urban_contrast <- c(
"vaccinatedYes" = 1,
"vaccinatedYes:areaRural" = 0
)
# In rural residents, the vaccination log(OR) is the sum of the main
# vaccination coefficient and the interaction coefficient.
rural_contrast <- c(
"vaccinatedYes" = 1,
"vaccinatedYes:areaRural" = 1
)
contrast_or <- function(model, contrast) {
all_contrast <- setNames(rep(0, length(coef(model))), names(coef(model)))
all_contrast[names(contrast)] <- contrast
estimate <- sum(all_contrast * coef(model))
standard_error <- sqrt(
as.numeric(t(all_contrast) %*% vcov(model) %*% all_contrast)
)
c(
OR = exp(estimate),
lower = exp(estimate - qnorm(0.975) * standard_error),
upper = exp(estimate + qnorm(0.975) * standard_error)
)
}
area_specific_or <- rbind(
Urban = contrast_or(interaction_model, urban_contrast),
Rural = contrast_or(interaction_model, rural_contrast)
)
knitr::kable(
data.frame(
Area = rownames(area_specific_or),
area_specific_or,
row.names = NULL
),
digits = 3,
col.names = c("Area", "Vaccination OR", "95% lower", "95% upper"),
caption = "Area-specific vaccination odds ratios from the interaction model"
)| Area | Vaccination OR | 95% lower | 95% upper |
|---|---|---|---|
| Urban | 0.406 | 0.254 | 0.65 |
| Rural | 0.780 | 0.433 | 1.40 |
With an interaction in the model, vaccinatedYes
describes the vaccination contrast only in the reference area, Urban.
The rural contrast must also include the interaction term. Do not
interpret a so-called “main effect” in isolation.
Lower residual deviance and AIC can help compare candidate models fit to the same outcome and observations. A likelihood-ratio test is available for nested models. None of these replaces external validation or scientific plausibility.
null_model <- glm(
infection_num ~ 1,
data = train_data,
family = binomial()
)
mcfadden_r2 <- 1 - as.numeric(logLik(adjusted_model) / logLik(null_model))
fit_statistics <- data.frame(
Metric = c(
"Null-model deviance",
"Adjusted-model deviance",
"AIC",
"McFadden pseudo-R²"
),
Value = c(
deviance(null_model),
deviance(adjusted_model),
AIC(adjusted_model),
mcfadden_r2
)
)
knitr::kable(
fit_statistics,
digits = 3,
caption = "Fit statistics in the training data"
)| Metric | Value |
|---|---|
| Null-model deviance | 842.992 |
| Adjusted-model deviance | 748.125 |
| AIC | 760.125 |
| McFadden pseudo-R² | 0.113 |
| Resid. Df | Resid. Dev | Df | Deviance | Pr(>Chi) |
|---|---|---|---|---|
| 838 | 843 | NA | NA | NA |
| 833 | 748 | 5 | 94.9 | 0 |
McFadden’s pseudo- is not the proportion of variance explained from linear regression, and values from different definitions are not directly interchangeable. State the definition when reporting it.
The logistic response follows a Bernoulli distribution, whose variance is naturally . Do not import linear regression requirements of residual normality and constant variance. Instead, investigate functional form, unusual residuals, leverage, influence, correlated observations, overdispersion, separation, and model specification.
diagnostic_data <- data.frame(
fitted_probability = fitted(adjusted_model),
deviance_residual = residuals(adjusted_model, type = "deviance"),
pearson_residual = residuals(adjusted_model, type = "pearson"),
leverage = hatvalues(adjusted_model),
cooks_distance = cooks.distance(adjusted_model)
)
diagnostic_summary <- data.frame(
Criterion = c(
"|Deviance residual| > 2",
"Leverage > 2p/n",
"Cook's distance > 4/n"
),
Count = c(
sum(abs(diagnostic_data$deviance_residual) > 2),
sum(
diagnostic_data$leverage >
2 * length(coef(adjusted_model)) / nrow(train_data)
),
sum(diagnostic_data$cooks_distance > 4 / nrow(train_data))
)
)
knitr::kable(
diagnostic_summary,
caption = "Heuristic thresholds for locating observations that require review"
)| Criterion | Count |
|---|---|
| |Deviance residual| > 2 | 25 |
| Leverage > 2p/n | 72 |
| Cook’s distance > 4/n | 59 |
old_par <- par(mfrow = c(1, 3), mar = c(4, 4, 3, 1))
plot(
diagnostic_data$fitted_probability,
diagnostic_data$deviance_residual,
pch = 16, cex = 0.55,
col = rgb(0, 114/255, 178/255, 0.40),
xlab = "Fitted probability", ylab = "Deviance residual",
main = "Residuals"
)
abline(h = c(-2, 0, 2), lty = c(2, 1, 2), col = palette_lr["grey"])
plot(
diagnostic_data$leverage,
type = "h", col = palette_lr["teal"],
xlab = "Training observation index", ylab = "Leverage",
main = "Leverage"
)
abline(
h = 2 * length(coef(adjusted_model)) / nrow(train_data),
lty = 2, col = palette_lr["vermillion"]
)
plot(
diagnostic_data$cooks_distance,
type = "h", col = palette_lr["orange"],
xlab = "Training observation index", ylab = "Cook's distance",
main = "Influence"
)
abline(
h = 4 / nrow(train_data),
lty = 2, col = palette_lr["vermillion"]
)Deviance-residual, leverage, and Cook’s-distance diagnostics for the logistic regression. Heuristic thresholds are screening aids, not automatic deletion rules.
A large residual means that an outcome is discordant with its fitted probability; high leverage indicates an uncommon covariate pattern; and a large Cook’s distance means that deleting an observation might noticeably change the fit. Review data quality, study processes, and sensitivity analyses rather than mechanically deleting observations that cross a cutoff.
If everyone at one predictor level experiences the event—or if no one does—the maximum-likelihood estimate may tend toward positive or negative infinity. This is complete separation. Common signals include:
glm() warning about fitted probabilities of 0 or 1,
or failure to converge;## , , area = Urban
##
## smoking
## infection No Yes
## No 407 76
## Yes 69 30
##
## , , area = Rural
##
## smoking
## infection No Yes
## No 151 36
## Yes 45 25
c(
converged = adjusted_model$converged,
iterations = adjusted_model$iter,
events = sum(train_data$infection_num),
non_events = sum(train_data$infection_num == 0),
parameters = length(coef(adjusted_model))
)## converged iterations events non_events parameters
## 1 5 169 670 6
Possible responses include removing unnecessary parameters, combining categories when scientifically defensible, collecting more informative observations, or using penalized or bias-reduced methods. A fixed “10 events per parameter” rule is an oversimplification: event and non-event counts, effect sizes, predictor distributions, missingness, and validation strategy all affect reliability.
The training set was used to estimate coefficients; the test set is used only for final evaluation. The 70/30 split was stratified by event status, so both sets contain events and non-events.
split_summary <- data.frame(
Dataset = c("Training", "Test"),
Sample_size = c(nrow(train_data), nrow(test_data)),
Events = c(sum(train_data$infection_num), sum(test_data$infection_num)),
Event_proportion = c(
mean(train_data$infection_num),
mean(test_data$infection_num)
)
)
knitr::kable(
split_summary,
digits = 3,
col.names = c("Dataset", "Sample size", "Events", "Event proportion"),
caption = "Composition of the training and test sets"
)| Dataset | Sample size | Events | Event proportion |
|---|---|---|---|
| Training | 839 | 169 | 0.201 |
| Test | 361 | 73 | 0.202 |
The Brier score is the mean squared error between predicted probabilities and 0/1 outcomes; lower is better. It reflects both calibration and discrimination and depends on the outcome’s baseline frequency.
test_brier <- mean((test_data$infection_num - test_probability)^2)
test_log_loss <- -mean(
test_data$infection_num * log(clamp_probability(test_probability)) +
(1 - test_data$infection_num) *
log(1 - clamp_probability(test_probability))
)
calibration_group <- cut(
test_probability,
breaks = unique(quantile(
test_probability,
probs = seq(0, 1, 0.1)
)),
include.lowest = TRUE,
ordered_result = TRUE
)
calibration_data <- aggregate(
cbind(predicted = test_probability, observed = test_data$infection_num) ~
calibration_group,
FUN = mean
)
calibration_metrics <- data.frame(
Metric = c("Test-set Brier score", "Test-set log loss"),
Value = c(test_brier, test_log_loss)
)
knitr::kable(
calibration_metrics,
digits = 3,
caption = "Probability-error metrics in the test set"
)| Metric | Value |
|---|---|
| Test-set Brier score | 0.156 |
| Test-set log loss | 0.486 |
plot(
calibration_data$predicted,
calibration_data$observed,
pch = 16, cex = 1.15, col = palette_lr["blue"],
xlim = c(0, 1), ylim = c(0, 1), asp = 1,
xlab = "Mean predicted probability within group",
ylab = "Observed event proportion within group",
main = "Test-set calibration"
)
abline(0, 1, lty = 2, lwd = 2, col = palette_lr["grey"])Test-set calibration plot grouped by deciles of predicted probability. Ideal calibration lies near the 45-degree line.
A grouped calibration plot depends on the grouping rule and sample size. Do not treat the p-value from a single calibration test as proof that a model “passes.” Examine overall calibration, the probability range relevant to decisions, and important subgroups.
An ROC curve displays the trade-off between sensitivity and 1−specificity over all possible thresholds. The AUC is the probability that the model assigns a higher score to a randomly selected event than to a randomly selected non-event, with ties counting as one-half.
auc_rank <- function(y, probability) {
n_event <- sum(y == 1)
n_nonevent <- sum(y == 0)
rank_sum <- sum(rank(probability, ties.method = "average")[y == 1])
(rank_sum - n_event * (n_event + 1) / 2) /
(n_event * n_nonevent)
}
roc_coordinates <- function(y, probability) {
thresholds <- c(
Inf,
sort(unique(probability), decreasing = TRUE),
-Inf
)
true_positive_rate <- vapply(thresholds, function(threshold) {
predicted <- probability >= threshold
sum(predicted & y == 1) / sum(y == 1)
}, numeric(1))
false_positive_rate <- vapply(thresholds, function(threshold) {
predicted <- probability >= threshold
sum(predicted & y == 0) / sum(y == 0)
}, numeric(1))
data.frame(
threshold = thresholds,
false_positive_rate = false_positive_rate,
true_positive_rate = true_positive_rate
)
}
test_auc <- auc_rank(test_data$infection_num, test_probability)
test_roc <- roc_coordinates(test_data$infection_num, test_probability)plot(
test_roc$false_positive_rate,
test_roc$true_positive_rate,
type = "l", lwd = 3, col = palette_lr["blue"],
xlim = c(0, 1), ylim = c(0, 1), asp = 1,
xlab = "False-positive rate (1 - specificity)",
ylab = "True-positive rate (sensitivity)",
main = paste0("Test-set ROC: AUC = ", round(test_auc, 3))
)
abline(0, 1, lty = 2, col = palette_lr["grey"])ROC curve for the adjusted model in the held-out test set.
The AUC is 0.648. AUC measures ranking, not probability accuracy. A model with a high AUC can still systematically overestimate risk.
Turning probabilities into 0/1 classifications requires a threshold. A value of 0.5 is not inherently optimal. The threshold should reflect the consequences of missed events and false alarms, resource constraints, acceptable workload, and equity considerations.
classification_metrics <- function(y, probability, threshold) {
predicted <- as.integer(probability >= threshold)
tp <- sum(predicted == 1 & y == 1)
fp <- sum(predicted == 1 & y == 0)
tn <- sum(predicted == 0 & y == 0)
fn <- sum(predicted == 0 & y == 1)
data.frame(
Threshold = threshold,
True_positive = tp,
False_positive = fp,
True_negative = tn,
False_negative = fn,
Accuracy = (tp + tn) / (tp + fp + tn + fn),
Sensitivity = tp / (tp + fn),
Specificity = tn / (tn + fp),
Positive_predictive_value = tp / (tp + fp)
)
}
threshold_results <- do.call(
rbind,
lapply(c(0.20, 0.30, 0.50), function(threshold) {
classification_metrics(
test_data$infection_num,
test_probability,
threshold
)
})
)
knitr::kable(
threshold_results,
digits = 3,
col.names = c(
"Threshold", "True positive", "False positive",
"True negative", "False negative", "Accuracy",
"Sensitivity", "Specificity", "Positive predictive value"
),
caption = "Consequences of alternative classification thresholds in the test set"
)| Threshold | True positive | False positive | True negative | False negative | Accuracy | Sensitivity | Specificity | Positive predictive value |
|---|---|---|---|---|---|---|---|---|
| 0.2 | 42 | 107 | 181 | 31 | 0.618 | 0.575 | 0.628 | 0.282 |
| 0.3 | 21 | 43 | 245 | 52 | 0.737 | 0.288 | 0.851 | 0.328 |
| 0.5 | 6 | 6 | 282 | 67 | 0.798 | 0.082 | 0.979 | 0.500 |
Lowering the threshold generally increases sensitivity while reducing specificity. Accuracy can also be dominated by the majority class. With a rare outcome, predicting non-event for everyone may produce deceptively high accuracy.
Model metrics are not permission to deploy Before real use, a model still requires external validation, monitoring for data drift, subgroup performance and calibration checks, an assessment of operational feasibility, and shared decisions about the consequences of false positives and false negatives. Excluding protected characteristics from a model does not guarantee fair results.
By default, glm() excludes rows with missing values in
any model variable. Complete-case analysis is defensible only when the
missingness mechanism and the population represented by complete records
can be justified.
set.seed(20260812)
logistic_data_missing <- train_data
missing_rows <- sample(seq_len(nrow(logistic_data_missing)), 36)
logistic_data_missing$bmi5[missing_rows] <- NA
missing_summary <- data.frame(
Total_rows = nrow(logistic_data_missing),
Missing_BMI = sum(is.na(logistic_data_missing$bmi5)),
Complete_cases = sum(complete.cases(
logistic_data_missing[c(
"infection_num", "age10", "bmi5",
"smoking", "vaccinated", "area"
)]
))
)
knitr::kable(
missing_summary,
col.names = c("Total rows", "Missing BMI", "Complete cases"),
caption = "Sample composition after introducing missing values"
)| Total rows | Missing BMI | Complete cases |
|---|---|---|
| 839 | 36 | 803 |
Report missing counts for every variable, the number of observations actually used by each model, and differences between included and excluded participants. Depending on the missingness mechanism, consider multiple imputation or sensitivity analysis. Do not fill missing predictors with the outcome mean.
For causal interpretation, choose confounders using causal structure, temporal order, and domain knowledge; avoid adjusting for mediators or colliders. For prediction, use prespecified candidate predictors, regularization, and resampling validation to control overfitting. Stepwise regression ignores model-selection uncertainty and can yield overly optimistic p-values and performance estimates.
analysis_record <- list(
outcome_definition = "Respiratory infection within one year: 1=Yes, 0=No",
model_formula = formula(adjusted_model),
training_n = nobs(adjusted_model),
event_count = sum(model.response(model.frame(adjusted_model))),
factor_levels = lapply(
train_data[c("smoking", "vaccinated", "area")],
levels
),
seed_for_split = 20260811,
r_version = R.version.string
)
analysis_record## $outcome_definition
## [1] "Respiratory infection within one year: 1=Yes, 0=No"
##
## $model_formula
## infection_num ~ age10 + bmi5 + smoking + vaccinated + area
## <environment: 0x70731f188>
##
## $training_n
## [1] 839
##
## $event_count
## [1] 169
##
## $factor_levels
## $factor_levels$smoking
## [1] "No" "Yes"
##
## $factor_levels$vaccinated
## [1] "No" "Yes"
##
## $factor_levels$area
## [1] "Urban" "Rural"
##
##
## $seed_for_split
## [1] 20260811
##
## $r_version
## [1] "R version 4.6.1 (2026-06-24)"
A clear report answers at least these questions:
In a simulated cohort, we used multivariable logistic regression with a binomial distribution and logit link to examine infection within one year, prespecifying age (per 10 years), BMI (per 5 kg/m²), smoking, vaccination, and area. Holding the other model variables constant, the infection odds ratio for vaccinated versus unvaccinated participants was 0.52. From the same adjusted model, standardized predicted infection probabilities in the training population were 25% with everyone set to unvaccinated and 15.8% with everyone set to vaccinated, although this contrast is not automatically causal. In the held-out test set, the model had an AUC of 0.65 and a Brier score of 0.156; external validation and evaluation of unmeasured confounding, data drift, and subgroup calibration would still be required.
| Common statement or practice | Problem | Better approach |
|---|---|---|
| “An OR of 0.70 means a 30% risk reduction” | Confuses odds with risk | Say that the odds are 30% lower, and report predicted risks or a risk ratio |
Interpret glm() coefficients directly |
Default coefficients are log-odds | Use exp(coef) for ORs or transform to
probabilities |
| Report a continuous-predictor OR without units | The size of the contrast is unknown | State per 1, 5, or 10 units |
| Treat 0.5 as the default best threshold | Ignores costs and the baseline rate | Choose from decision consequences and report several thresholds |
| Report only accuracy or AUC | Ignores calibration and class imbalance | Also report probability error, calibration, and threshold metrics |
| Evaluate performance in the training data | Performance is generally optimistic | Use resampling, a test set, or external validation |
| Select significant variables stepwise | Estimates and uncertainty are unstable | Prespecify from the question or use validated regularization |
| Delete every influential observation | A threshold is not a deletion rule | Verify data, explain its source, and perform sensitivity analysis |
| Require normally distributed residuals | Imports a linear-model assumption | Check logit functional form, separation, influence, and independence |
A person’s predicted infection probability is 0.25. What are the corresponding odds and log-odds?
Answer: The odds are ; the log-odds are .The OR for age10 is 1.40. How should it be
interpreted?
Why can predict(adjusted_model, newdata = x) not be
treated directly as a probability?
glm is on the link scale, here log-odds. Use
type = "response" or apply plogis() to a
link-scale prediction.
If missing a genuinely high-risk person is far more costly than a false alarm, in which direction should the threshold generally move?
Answer: Usually lower the threshold to increase sensitivity. This creates more false positives and lowers specificity, so the final choice must also consider resources, harms of follow-up interventions, and equity.With vaccinated * area in the model, can
vaccinatedYes be interpreted as the average vaccination OR
across all areas?
vaccinatedYes:areaRural. For a population-average contrast,
calculate standardized predictions in a clearly defined target
population.
All 18 members of a rare exposure group are non-events, and the model returns an extremely large negative coefficient and standard error. What is the likely problem?
Answer: Complete or quasi-complete separation is likely. Check cross-tabulations and data quality first, then consider reducing parameters, obtaining more information, or using penalized or bias-reduced methods. Do not treat an enormous OR as stable evidence.| Goal | Base R expression |
|---|---|
| Fit a model | glm(y ~ x1 + x2, family = binomial(), data = d) |
| Inspect log-odds coefficients | coef(model) |
| Calculate ORs | exp(coef(model)) |
| Wald intervals for ORs | exp(coef(model) + outer(sqrt(diag(vcov(model))), qnorm(c(.025, .975))))
(then arrange dimensions carefully) |
| Predict probabilities | predict(model, newdata = d_new, type = "response") |
| Link-scale prediction and SE | predict(model, newdata = d_new, type = "link", se.fit = TRUE) |
| Deviance residuals | residuals(model, type = "deviance") |
| Leverage | hatvalues(model) |
| Cook’s distance | cooks.distance(model) |
| Compare nested models | anova(model_small, model_large, test = "Chisq") |
| Information criterion | AIC(model) |