About the tutorial data Every record is simulated with a fixed random seed and contains no real personal health information. The data deliberately include mild nonlinearity, heteroskedasticity, and missingness for diagnostic practice. Simulated relationships are teaching devices, not estimates for a real population.
Work through the sequence question → data check → fit → diagnose → interpret → validate → report. Read each explanation before running its code, and attempt the exercises before opening the answers. Code is shown by default and can be folded with the page controls.
By the end of this tutorial, you should be able to:
Linear regression primarily describes how the conditional mean of a continuous outcome changes with one or more predictors. Examples include:
A general model is:
“Linear” first means linear in the unknown parameters . Predictors may be squared, segmented, or transformed in prespecified ways; linear regression does not require every fitted relationship to be a straight line.
Identify the outcome before choosing a
function Ordinary linear regression is usually not the first
choice for a binary event, count, proportion, or survival time. Match
the method to the outcome distribution, estimand, and sampling design
rather than using it simply because lm() is convenient.
The same formula can serve different goals, but its evaluation criteria change:
| Task | Main question | Primary emphasis |
|---|---|---|
| Describe an association | How does the conditional mean vary in the observed sample? | Coefficients, intervals, functional form, and transparent reporting |
| Predict | How accurately can the model predict a new, unobserved person? | Out-of-sample error, calibration, and target population |
| Estimate a causal effect | How would the outcome change under an intervention on the exposure? | Design, temporality, confounding control, and identification assumptions |
A regression that “adjusts for several covariates” does not automatically estimate a causal effect. Unless a defensible design and causal assumptions support stronger language, all interpretations below concern conditional associations.
For participant , the model gives fitted value and residual . Ordinary least squares (OLS) chooses coefficients that minimize the sum of squared residuals:
Squaring prevents positive and negative residuals from cancelling and penalizes large deviations more strongly. OLS point estimates do not require the outcome itself to be normally distributed; normal residuals matter more directly for the exactness of classical small-sample t tests and intervals.
Each row represents one simulated participant. Systolic blood
pressure in mmHg is the primary outcome; candidate predictors include
age, BMI, current smoking status, sex, neighborhood, and weekly physical
activity. Before any outcome exploration, 25% of the complete simulated
data was randomly held out. From this point, ph_data
contains development data only; the holdout is not summarized until the
out-of-sample section.
## 'data.frame': 420 obs. of 11 variables:
## $ participant_id : chr "P001" "P004" "P005" "P006" ...
## $ age : num 23 31 38 67 44 28 33 30 48 53 ...
## $ sex : Factor w/ 2 levels "Female","Male": 1 1 2 1 1 2 1 2 2 1 ...
## $ neighborhood : Factor w/ 4 levels "Central","North",..: 1 3 4 1 1 4 2 1 4 4 ...
## $ smoking_status : Factor w/ 2 levels "Not current",..: 1 1 1 1 1 2 1 1 1 1 ...
## $ bmi : num 27.8 20.4 26.5 18.8 27.6 29 18.6 26.3 27.9 16.8 ...
## $ physical_activity_min_week: num 113 69 116 105 32 361 NA 93 121 84 ...
## $ systolic_bp : num 117 109 120 151 111 ...
## $ age_c10 : num -2.7 -1.9 -1.2 1.7 -0.6 -2.2 -1.7 -2 -0.2 0.3 ...
## $ bmi_c5 : num 0.56 -0.92 0.3 -1.24 0.52 0.8 -1.28 0.26 0.58 -1.64 ...
## $ physical_activity_30 : num 3.77 2.3 3.87 3.5 1.07 ...
Record variable names, roles, and units in the analysis plan before examining results.
| Variable | Type | Role or unit in this tutorial |
|---|---|---|
systolic_bp |
Continuous | Outcome, mmHg |
age |
Continuous | Years |
bmi |
Continuous | kg/m² |
smoking_status |
Binary | Current versus not current |
sex |
Binary | Male versus female |
neighborhood |
Multicategory | North, South, or West versus Central |
physical_activity_min_week |
Continuous | Minutes/week; contains missing values |
data_check <- data.frame(
"Variable" = names(ph_data),
"Class" = vapply(ph_data, function(x) class(x)[1], character(1)),
"Missing" = vapply(ph_data, function(x) sum(is.na(x)), integer(1)),
"Unique values" = vapply(
ph_data,
function(x) length(unique(x[!is.na(x)])),
integer(1)
),
check.names = FALSE
)
knitr::kable(data_check, caption = "Variable types, missingness, and unique values")| Variable | Class | Missing | Unique values | |
|---|---|---|---|---|
| participant_id | participant_id | character | 0 | 420 |
| age | age | numeric | 0 | 67 |
| sex | sex | factor | 0 | 2 |
| neighborhood | neighborhood | factor | 0 | 4 |
| smoking_status | smoking_status | factor | 0 | 2 |
| bmi | bmi | numeric | 0 | 152 |
| physical_activity_min_week | physical_activity_min_week | numeric | 29 | 201 |
| systolic_bp | systolic_bp | numeric | 0 | 288 |
| age_c10 | age_c10 | numeric | 0 | 67 |
| bmi_c5 | bmi_c5 | numeric | 0 | 152 |
| physical_activity_30 | physical_activity_30 | numeric | 29 | 201 |
Also check impossible values, duplicate identifiers, incorrect units,
coding changes, and eligibility criteria. An automatic
summary() is useful but cannot replace a data dictionary
and subject-matter knowledge.
summary(ph_data[c(
"age", "bmi", "physical_activity_min_week", "systolic_bp",
"smoking_status", "sex", "neighborhood"
)])## age bmi physical_activity_min_week systolic_bp
## Min. :18.0 Min. :16.0 Min. : 0 Min. : 91.7
## 1st Qu.:35.0 1st Qu.:22.2 1st Qu.: 47 1st Qu.:117.6
## Median :47.0 Median :24.9 Median : 94 Median :127.0
## Mean :46.6 Mean :25.0 Mean :110 Mean :128.0
## 3rd Qu.:58.0 3rd Qu.:27.7 3rd Qu.:154 3rd Qu.:136.7
## Max. :85.0 Max. :38.5 Max. :535 Max. :170.7
## NAs :29
## smoking_status sex neighborhood
## Not current:353 Female:201 Central:136
## Current : 67 Male :219 North : 85
## South :110
## West : 89
##
##
##
plot(
ph_data$age, ph_data$systolic_bp,
pch = 16, cex = 0.65,
col = rgb(0, 114 / 255, 178 / 255, 0.38),
xlab = "Age (years)", ylab = "Systolic blood pressure (mmHg)",
main = "Check whether a linear relationship is plausible"
)
abline(
lm(systolic_bp ~ age, data = ph_data),
col = palette_ph["vermillion"], lwd = 2.5
)
lines(
lowess(ph_data$age, ph_data$systolic_bp, f = 2 / 3),
col = palette_ph["teal"], lwd = 2.5, lty = 2
)
legend(
"topleft",
legend = c("OLS line", "LOWESS smoother"),
col = c(palette_ph["vermillion"], palette_ph["teal"]),
lwd = 2.5, lty = c(1, 2), bty = "n"
)Systolic blood pressure versus age. The straight line is a simple linear fit; the curve is a LOWESS smoother.
The smoother is a diagnostic for functional form, not the final model. Systematic separation between the smoother and straight line suggests considering scientifically meaningful nonlinear terms rather than blindly adding high-degree polynomials.
boxplot(
systolic_bp ~ smoking_status,
data = ph_data,
names = c("Not current", "Current"),
col = c("#D9EAF3", "#F4C6A6"),
border = palette_ph["navy"],
ylab = "Systolic blood pressure (mmHg)", xlab = "",
main = "Distribution across a categorical predictor"
)
stripchart(
systolic_bp ~ smoking_status,
data = ph_data,
vertical = TRUE, method = "jitter", pch = 16,
col = rgb(23 / 255, 50 / 255, 77 / 255, 0.22),
add = TRUE
)Systolic blood pressure by smoking status; jittered observations are overlaid on the boxplots.
The crude group difference may reflect differences in age, BMI, or neighborhood composition. Multiple regression can describe a conditional mean difference at the same modeled covariate values, but its credibility still depends on model specification and study design.
Begin by relating mean systolic blood pressure to age:
##
## Call:
## lm(formula = systolic_bp ~ age, data = ph_data)
##
## Residuals:
## Min 1Q Median 3Q Max
## -35.45 -7.84 0.70 7.41 32.12
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 101.6771 1.7177 59.2 <2e-16 ***
## age 0.5660 0.0349 16.2 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 11.3 on 418 degrees of freedom
## Multiple R-squared: 0.386, Adjusted R-squared: 0.384
## F-statistic: 263 on 1 and 418 DF, p-value: <2e-16
knitr::kable(
coefficient_table(simple_model),
digits = 3,
caption = "Simple linear regression of systolic blood pressure on age"
)| Term | Estimate | Standard error | 95% CI lower | 95% CI upper | p-value |
|---|---|---|---|---|---|
| Intercept | 101.677 | 1.718 | 98.301 | 105.054 | 0 |
| Age (per 1 year) | 0.566 | 0.035 | 0.497 | 0.635 | 0 |
The age coefficient is 0.57 mmHg per year (95% CI 0.5 to 0.63). It describes the mean blood-pressure difference between sampled people one year apart in age; it does not prove that changing age itself causes that difference.
The intercept extrapolates the mean to age zero, outside this adult sample. It mainly positions the line and usually has no substantive interpretation here. Centering age can make the intercept correspond to a meaningful reference age.
simple_components <- data.frame(
"Participant ID" = ph_data$participant_id[1:8],
"Observed" = ph_data$systolic_bp[1:8],
"Fitted" = fitted(simple_model)[1:8],
"Residual" = residuals(simple_model)[1:8],
check.names = FALSE
)
knitr::kable(
simple_components,
digits = 2,
caption = "Observed values, fitted values, and residuals for the first 8 participants"
)| Participant ID | Observed | Fitted | Residual | |
|---|---|---|---|---|
| 1 | P001 | 117 | 115 | 2.20 |
| 4 | P004 | 109 | 119 | -10.52 |
| 5 | P005 | 120 | 123 | -3.59 |
| 6 | P006 | 151 | 140 | 11.70 |
| 7 | P007 | 111 | 127 | -15.18 |
| 8 | P008 | 138 | 118 | 20.07 |
| 9 | P009 | 111 | 120 | -9.46 |
| 10 | P010 | 122 | 119 | 3.34 |
In an OLS model with an intercept, residuals sum to zero up to numerical error. This does not mean the model predicts every person accurately.
c(
residual_sum = sum(residuals(simple_model)),
residual_mean = mean(residuals(simple_model)),
residual_rmse = sqrt(mean(residuals(simple_model)^2))
)## residual_sum residual_mean residual_rmse
## 3.55e-13 1.27e-15 1.13e+01
multiple_model <- lm(
systolic_bp ~ age + bmi + smoking_status + sex + neighborhood,
data = ph_data
)
knitr::kable(
coefficient_table(multiple_model),
digits = 3,
caption = "Multiple linear regression of systolic blood pressure"
)| Term | Estimate | Standard error | 95% CI lower | 95% CI upper | p-value |
|---|---|---|---|---|---|
| Intercept | 84.046 | 3.500 | 77.165 | 90.926 | 0.000 |
| Age (per 1 year) | 0.557 | 0.033 | 0.491 | 0.622 | 0.000 |
| BMI (per 1 unit) | 0.640 | 0.126 | 0.393 | 0.886 | 0.000 |
| Current smoking (reference: not current) | 4.209 | 1.433 | 1.392 | 7.025 | 0.003 |
| Male (reference: female) | 2.083 | 1.058 | 0.003 | 4.162 | 0.050 |
| North (reference: Central) | 2.840 | 1.482 | -0.074 | 5.754 | 0.056 |
| South (reference: Central) | -2.728 | 1.369 | -5.419 | -0.037 | 0.047 |
| West (reference: Central) | 2.262 | 1.460 | -0.609 | 5.132 | 0.122 |
A numeric coefficient is the conditional mean difference for a one-unit increase holding the other modeled variables fixed. The age coefficient, for example, compares participants one year apart who have the same modeled BMI, smoking status, sex, and neighborhood.
“Holding fixed” describes a conditional comparison within the model. It does not guarantee that perfectly matched people exist in the sample, nor does it make the comparison causal.
R uses the first factor level as the reference by default. Set it deliberately before fitting rather than changing it in response to whichever comparison looks most significant.
list(
smoking_status = levels(ph_data$smoking_status),
sex = levels(ph_data$sex),
neighborhood = levels(ph_data$neighborhood)
)## $smoking_status
## [1] "Not current" "Current"
##
## $sex
## [1] "Female" "Male"
##
## $neighborhood
## [1] "Central" "North" "South" "West"
Thus, smoking_statusCurrent compares current with
non-current smoking. The three neighborhood coefficients compare North,
South, and West with Central. An unordered factor with
levels generally produces
coefficients.
To use North as the reference when that comparison matches the question:
ph_reference_demo <- ph_data
ph_reference_demo$neighborhood <- relevel(
ph_reference_demo$neighborhood,
ref = "North"
)
reference_demo_model <- lm(
systolic_bp ~ age + bmi + smoking_status + sex + neighborhood,
data = ph_reference_demo
)
coef(reference_demo_model)[grep("neighborhood", names(coef(reference_demo_model)))]## neighborhoodCentral neighborhoodSouth neighborhoodWest
## -2.840 -5.568 -0.578
Changing the reference level changes how selected coefficients are expressed, but not participant-level fitted values, residuals, or overall fit.
Effects per one year or one BMI unit can be too granular. This tutorial defines:
age_c10 = (age - 50) / 10: per 10 years, with zero at
age 50;bmi_c5 = (bmi - 25) / 5: per 5 BMI units, with zero at
BMI 25.scaled_model <- lm(
systolic_bp ~ age_c10 + bmi_c5 + smoking_status + sex + neighborhood,
data = ph_data
)
knitr::kable(
coefficient_table(scaled_model),
digits = 3,
caption = "Multiple model expressed in meaningful units"
)| Term | Estimate | Standard error | 95% CI lower | 95% CI upper | p-value |
|---|---|---|---|---|---|
| Intercept | 127.86 | 1.085 | 125.730 | 129.997 | 0.000 |
| Age (per 10 years; centered at 50) | 5.57 | 0.335 | 4.907 | 6.224 | 0.000 |
| BMI (per 5 units; centered at 25) | 3.20 | 0.628 | 1.963 | 4.432 | 0.000 |
| Current smoking (reference: not current) | 4.21 | 1.433 | 1.392 | 7.025 | 0.003 |
| Male (reference: female) | 2.08 | 1.058 | 0.003 | 4.162 | 0.050 |
| North (reference: Central) | 2.84 | 1.482 | -0.074 | 5.754 | 0.056 |
| South (reference: Central) | -2.73 | 1.369 | -5.419 | -0.037 | 0.047 |
| West (reference: Central) | 2.26 | 1.460 | -0.609 | 5.132 | 0.122 |
After adjustment for the other modeled variables, a 10-year age difference corresponds to a mean systolic blood-pressure difference of 5.57 mmHg. A 5-unit BMI difference corresponds to 3.2 mmHg. Centering leaves model fit unchanged but makes the intercept correspond to a 50-year-old, BMI-25, non-current-smoking female participant in Central.
A point estimate is the value that best fits this sample; its confidence interval expresses repeated-sampling uncertainty. Interval width depends on sample size, residual variability, the predictor distribution, and collinearity.
scaled_ci <- confint(scaled_model)
keep_coef <- rownames(scaled_ci) != "(Intercept)"
coef_values <- coef(scaled_model)[keep_coef]
ci_values <- scaled_ci[keep_coef, , drop = FALSE]
plot(
coef_values, seq_along(coef_values),
xlim = range(ci_values), yaxt = "n",
pch = 19, col = palette_ph["blue"],
xlab = "Mean systolic blood-pressure difference (mmHg)", ylab = "",
main = "Coefficients and 95% confidence intervals"
)
segments(
ci_values[, 1], seq_along(coef_values),
ci_values[, 2], seq_along(coef_values),
col = palette_ph["navy"], lwd = 2
)
abline(v = 0, lty = 2, col = "grey45")
axis(
2, at = seq_along(coef_values),
labels = label_terms(names(coef_values)), las = 1, cex.axis = 0.72
)Multiple-regression coefficients and 95% confidence intervals; the intercept is omitted for readability.
Do not equate “the interval includes zero” with “there is no association,” or “the interval excludes zero” with “the association matters for public health.” Interpret the magnitude, compatible range, units, and context together.
is the proportion of outcome variability explained by the model in the current sample. Adding a predictor cannot lower training-sample , even when that predictor has no practical value. Adjusted penalizes complexity and can decline, but it still does not replace out-of-sample evaluation.
fit_statistics <- function(model, model_name) {
s <- summary(model)
data.frame(
"Model" = model_name,
"Sample size" = nobs(model),
"Parameters" = length(coef(model)),
"R-squared" = s$r.squared,
"Adjusted R-squared" = s$adj.r.squared,
"Residual standard error" = s$sigma,
check.names = FALSE
)
}
fit_comparison <- rbind(
fit_statistics(simple_model, "Age only"),
fit_statistics(scaled_model, "Age + BMI + smoking + sex + neighborhood")
)
knitr::kable(fit_comparison, digits = 3, caption = "In-sample fit measures")| Model | Sample size | Parameters | R-squared | Adjusted R-squared | Residual standard error |
|---|---|---|---|---|---|
| Age only | 420 | 2 | 0.386 | 0.384 | 11.3 |
| Age + BMI + smoking + sex + neighborhood | 420 | 8 | 0.461 | 0.452 | 10.6 |
A low does not invalidate a well-estimated association, and a high does not prove that a model is unbiased, transportable, or causal.
A no-interaction model assumes the same age slope in both smoking groups. An interaction permits a different slope among current smokers:
interaction_model <- lm(
systolic_bp ~ age_c10 * smoking_status +
bmi_c5 + sex + neighborhood,
data = ph_data
)
knitr::kable(
coefficient_table(interaction_model),
digits = 3,
caption = "Model with an age-by-smoking-status interaction"
)| Term | Estimate | Standard error | 95% CI lower | 95% CI upper | p-value |
|---|---|---|---|---|---|
| Intercept | 127.89 | 1.082 | 125.760 | 130.012 | 0.000 |
| Age (per 10 years; centered at 50) | 5.30 | 0.359 | 4.599 | 6.010 | 0.000 |
| Current smoking (reference: not current) | 4.30 | 1.429 | 1.491 | 7.107 | 0.003 |
| BMI (per 5 units; centered at 25) | 3.21 | 0.626 | 1.983 | 4.444 | 0.000 |
| Male (reference: female) | 2.07 | 1.054 | 0.000 | 4.145 | 0.050 |
| North (reference: Central) | 2.61 | 1.482 | -0.301 | 5.524 | 0.079 |
| South (reference: Central) | -2.84 | 1.365 | -5.525 | -0.157 | 0.038 |
| West (reference: Central) | 2.05 | 1.459 | -0.823 | 4.914 | 0.162 |
| Age × current smoking | 1.93 | 0.976 | 0.010 | 3.847 | 0.049 |
In this model:
age_c10 is the 10-year age slope among
non-current smokers;smoking_statusCurrent is the group difference when
age_c10 = 0, or at age 50;linear_combination_ci <- function(model, weights, level = 0.95) {
beta <- coef(model)
V <- vcov(model)
L <- setNames(rep(0, length(beta)), names(beta))
L[names(weights)] <- weights
estimate <- sum(L * beta)
se <- sqrt(drop(t(L) %*% V %*% L))
critical <- qt(1 - (1 - level) / 2, df.residual(model))
c(
estimate = estimate,
standard_error = se,
lower = estimate - critical * se,
upper = estimate + critical * se
)
}
slope_not_current <- linear_combination_ci(
interaction_model,
c(age_c10 = 1)
)
slope_current <- linear_combination_ci(
interaction_model,
c(age_c10 = 1, "age_c10:smoking_statusCurrent" = 1)
)
interaction_slopes <- rbind(
"Not current" = slope_not_current,
"Current" = slope_current
)
knitr::kable(
interaction_slopes,
digits = 2,
caption = "Ten-year systolic blood-pressure slopes by smoking status (mmHg)"
)| estimate | standard_error | lower | upper | |
|---|---|---|---|---|
| Not current | 5.30 | 0.36 | 4.60 | 6.01 |
| Current | 7.23 | 0.91 | 5.45 | 9.02 |
age_grid <- seq(min(ph_data$age), max(ph_data$age), length.out = 120)
interaction_grid <- rbind(
data.frame(
age = age_grid,
age_c10 = (age_grid - 50) / 10,
bmi_c5 = 0,
smoking_status = factor("Not current", levels = levels(ph_data$smoking_status)),
sex = factor("Female", levels = levels(ph_data$sex)),
neighborhood = factor("Central", levels = levels(ph_data$neighborhood))
),
data.frame(
age = age_grid,
age_c10 = (age_grid - 50) / 10,
bmi_c5 = 0,
smoking_status = factor("Current", levels = levels(ph_data$smoking_status)),
sex = factor("Female", levels = levels(ph_data$sex)),
neighborhood = factor("Central", levels = levels(ph_data$neighborhood))
)
)
interaction_grid$prediction <- predict(interaction_model, newdata = interaction_grid)
plot(
systolic_bp ~ age, data = ph_data,
pch = 16, cex = 0.45, col = "grey80",
xlab = "Age (years)", ylab = "Predicted mean systolic BP (mmHg)",
main = "Interpret an interaction graphically"
)
for (group_index in seq_along(levels(ph_data$smoking_status))) {
rows <- interaction_grid$smoking_status ==
levels(ph_data$smoking_status)[group_index]
lines(
interaction_grid$age[rows], interaction_grid$prediction[rows],
col = c(palette_ph["blue"], palette_ph["vermillion"])[group_index],
lwd = 3
)
}
legend(
"topleft", legend = c("Not current", "Current"),
col = c(palette_ph["blue"], palette_ph["vermillion"]),
lwd = 3, bty = "n"
)Model-predicted age–blood-pressure relationships by smoking status, with BMI fixed at 25, sex at female, and neighborhood at Central.
The hierarchy principle When a model contains , retain the and main effects in most settings, even if one has a large p-value. Let a prespecified scientific question motivate an interaction, and explain it through stratified predictions or marginal effects over a meaningful covariate range.
If the age slope is not constant, add the square of centered age:
linear_age_model <- lm(
systolic_bp ~ age_c10 + bmi_c5 + smoking_status + sex + neighborhood,
data = ph_data
)
quadratic_model <- lm(
systolic_bp ~ age_c10 + I(age_c10^2) +
bmi_c5 + smoking_status + sex + neighborhood,
data = ph_data
)
knitr::kable(
coefficient_table(quadratic_model),
digits = 3,
caption = "Model with a centered quadratic age term"
)| Term | Estimate | Standard error | 95% CI lower | 95% CI upper | p-value |
|---|---|---|---|---|---|
| Intercept | 125.556 | 1.154 | 123.286 | 127.825 | 0.000 |
| Age (per 10 years; centered at 50) | 6.152 | 0.347 | 5.471 | 6.834 | 0.000 |
| Age squared | 0.889 | 0.180 | 0.536 | 1.243 | 0.000 |
| BMI (per 5 units; centered at 25) | 3.225 | 0.611 | 2.023 | 4.426 | 0.000 |
| Current smoking (reference: not current) | 4.566 | 1.396 | 1.823 | 7.310 | 0.001 |
| Male (reference: female) | 2.630 | 1.035 | 0.595 | 4.665 | 0.011 |
| North (reference: Central) | 3.198 | 1.444 | 0.360 | 6.036 | 0.027 |
| South (reference: Central) | -3.307 | 1.337 | -5.935 | -0.679 | 0.014 |
| West (reference: Central) | 1.940 | 1.422 | -0.855 | 4.735 | 0.173 |
With a quadratic term present, age_c10 is the local
slope near age 50 rather than one slope shared across all ages. Do not
interpret the squared term by itself; graph predictions using both age
terms.
knitr::kable(
as.data.frame(anova(linear_age_model, quadratic_model)),
digits = 3,
caption = "Comparison of nested linear-age and quadratic-age models"
)| Res.Df | RSS | Df | Sum of Sq | F | Pr(>F) |
|---|---|---|---|---|---|
| 412 | 46714 | NA | NA | NA | NA |
| 411 | 44094 | 1 | 2620 | 24.4 | 0 |
This F test compares two nested models; it cannot establish that a quadratic curve is the true mechanism. Judge functional form using graphs, prediction goals, prior knowledge, and external validation as well.
quadratic_grid <- data.frame(
age_c10 = (age_grid - 50) / 10,
bmi_c5 = 0,
smoking_status = factor("Not current", levels = levels(ph_data$smoking_status)),
sex = factor("Female", levels = levels(ph_data$sex)),
neighborhood = factor("Central", levels = levels(ph_data$neighborhood))
)
quadratic_prediction <- predict(
quadratic_model,
newdata = quadratic_grid,
interval = "confidence"
)
plot(
age_grid, quadratic_prediction[, "fit"], type = "n",
ylim = range(quadratic_prediction),
xlab = "Age (years)", ylab = "Predicted mean systolic BP (mmHg)",
main = "Graph the curve rather than guessing from one coefficient"
)
polygon(
c(age_grid, rev(age_grid)),
c(quadratic_prediction[, "lwr"], rev(quadratic_prediction[, "upr"])),
col = rgb(86 / 255, 180 / 255, 233 / 255, 0.28), border = NA
)
lines(
age_grid, quadratic_prediction[, "fit"],
col = palette_ph["blue"], lwd = 3
)Adjusted quadratic relationship between age and predicted mean systolic blood pressure with a 95% confidence band.
High-degree polynomials can oscillate sharply near data boundaries, making extrapolation especially dangerous. More complex relationships may call for piecewise lines, splines, or subject-matter transformations, with complexity matched to sample size and purpose.
Classical linear-regression inference commonly considers:
Diagnostics are not a one-time pass/fail examination. They help identify disagreement between the model and data, assess sensitivity, and improve reporting.
This working model allows both age curvature and an age-by-smoking interaction:
working_model <- lm(
systolic_bp ~ age_c10 * smoking_status + I(age_c10^2) +
bmi_c5 + sex + neighborhood,
data = ph_data
)
summary(working_model)##
## Call:
## lm(formula = systolic_bp ~ age_c10 * smoking_status + I(age_c10^2) +
## bmi_c5 + sex + neighborhood, data = ph_data)
##
## Residuals:
## Min 1Q Median 3Q Max
## -30.272 -6.761 0.021 6.692 26.530
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 125.623 1.152 109.02 < 2e-16 ***
## age_c10 5.916 0.372 15.92 < 2e-16 ***
## smoking_statusCurrent 4.636 1.393 3.33 0.00095 ***
## I(age_c10^2) 0.871 0.180 4.84 1.8e-06 ***
## bmi_c5 3.238 0.610 5.31 1.8e-07 ***
## sexMale 2.610 1.033 2.53 0.01187 *
## neighborhoodNorth 2.994 1.445 2.07 0.03889 *
## neighborhoodSouth -3.392 1.334 -2.54 0.01140 *
## neighborhoodWest 1.762 1.422 1.24 0.21618
## age_c10:smoking_statusCurrent 1.657 0.952 1.74 0.08253 .
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 10.3 on 410 degrees of freedom
## Multiple R-squared: 0.495, Adjusted R-squared: 0.484
## F-statistic: 44.6 on 9 and 410 DF, p-value: <2e-16
old_par <- par(mfrow = c(2, 2), mar = c(4, 4, 2, 1))
plot(working_model, which = 1:4, caption = rep("", 4))Four standard diagnostics for the working model. Look for systematic shape, tail departures, a funnel pattern, and influential observations.
First divide fitted values into five groups and compare residual standard deviations:
fitted_groups <- cut(
fitted(working_model),
breaks = quantile(fitted(working_model), probs = seq(0, 1, 0.2)),
include.lowest = TRUE
)
variance_by_fit <- data.frame(
"Fitted-value group" = levels(fitted_groups),
"Sample size" = as.vector(table(fitted_groups)),
"Residual standard deviation" = as.vector(
tapply(residuals(working_model), fitted_groups, sd)
),
check.names = FALSE
)
knitr::kable(
variance_by_fit,
digits = 2,
caption = "Residual variability across fitted-value quintiles"
)| Fitted-value group | Sample size | Residual standard deviation |
|---|---|---|
| [107,119] | 84 | 10.65 |
| (119,124] | 84 | 9.55 |
| (124,130] | 84 | 9.55 |
| (130,137] | 84 | 9.63 |
| (137,166] | 84 | 11.73 |
The following one-predictor Breusch–Pagan-style screen regresses squared residuals on fitted values. Its statistic is compared approximately with a chi-squared distribution with one degree of freedom.
hetero_auxiliary <- lm(I(residuals(working_model)^2) ~ fitted(working_model))
hetero_statistic <- nobs(working_model) * summary(hetero_auxiliary)$r.squared
hetero_p_value <- pchisq(hetero_statistic, df = 1, lower.tail = FALSE)
c(statistic = hetero_statistic, df = 1, p_value = hetero_p_value)## statistic df p_value
## 1.319 1.000 0.251
This simplified screen is not a final verdict. A significant result may arise from a misspecified mean; a non-significant result does not prove constant variance. Combine it with residual plots, knowledge of the data-generating process, and robust sensitivity analyses.
influence_summary <- data.frame(
"Participant ID" = ph_data$participant_id,
"Leverage" = hatvalues(working_model),
"Studentized residual" = rstudent(working_model),
"Cook's distance" = cooks.distance(working_model),
check.names = FALSE
)
top_influence <- order(
influence_summary[["Cook's distance"]],
decreasing = TRUE
)[1:8]
knitr::kable(
influence_summary[top_influence, ],
digits = 3,
caption = "Eight observations with the largest Cook's distances"
)| Participant ID | Leverage | Studentized residual | Cook’s distance | |
|---|---|---|---|---|
| 486 | P486 | 0.108 | -1.91 | 0.044 |
| 399 | P399 | 0.035 | -3.01 | 0.032 |
| 235 | P235 | 0.026 | -2.91 | 0.022 |
| 543 | P543 | 0.037 | -2.35 | 0.021 |
| 71 | P071 | 0.034 | -2.40 | 0.020 |
| 375 | P375 | 0.135 | -1.10 | 0.019 |
| 367 | P367 | 0.056 | 1.76 | 0.018 |
| 213 | P213 | 0.035 | -2.23 | 0.018 |
Common heuristics include leverage above , Cook’s distance above , and an absolute studentized residual above 2 or 3. These are not universal deletion thresholds.
p_working <- ncol(model.matrix(working_model))
n_working <- nobs(working_model)
c(
leverage_2p_over_n = 2 * p_working / n_working,
cooks_4_over_n = 4 / n_working,
count_abs_studentized_over_2 = sum(abs(rstudent(working_model)) > 2)
)## leverage_2p_over_n cooks_4_over_n
## 0.04762 0.00952
## count_abs_studentized_over_2
## 18.00000
For a high-influence observation, verify the source record, units, and eligibility, then compare analyses with and without it. Exclusion requires a defensible data-quality or target-population reason; making a p-value smaller is not one.
Collinearity does not necessarily bias predictions, but it can inflate coefficient standard errors and make coefficients sensitive to small data changes. The function below calculates a VIF for each design-matrix column:
vif_columns <- function(model) {
X <- model.matrix(model)
X <- X[, colnames(X) != "(Intercept)", drop = FALSE]
values <- vapply(seq_len(ncol(X)), function(j) {
outcome_column <- X[, j]
other_columns <- X[, -j, drop = FALSE]
r_squared_j <- summary(lm(outcome_column ~ other_columns))$r.squared
if (1 - r_squared_j < .Machine$double.eps^0.5) {
Inf
} else {
1 / (1 - r_squared_j)
}
}, numeric(1))
data.frame(
"Design-matrix column" = label_terms(colnames(X)),
"VIF" = values,
row.names = NULL,
check.names = FALSE
)
}
knitr::kable(
vif_columns(working_model),
digits = 2,
caption = "Column-level VIFs for the working model design matrix"
)| Design-matrix column | VIF |
|---|---|
| Age (per 10 years; centered at 50) | 1.35 |
| Current smoking (reference: not current) | 1.02 |
| Age squared | 1.17 |
| BMI (per 5 units; centered at 25) | 1.03 |
| Male (reference: female) | 1.05 |
| North (reference: Central) | 1.33 |
| South (reference: Central) | 1.35 |
| West (reference: Central) | 1.33 |
| Age × current smoking | 1.17 |
Treat VIF cutoffs such as 5 or 10 as heuristics. Column-level VIFs need particular care with multilevel factors, interactions, and polynomials. Centering can reduce unnecessary numerical collinearity, but it cannot repair two distinct variables that nearly measure the same construct.
If the conditional-mean model is reasonable but error variance is not constant, OLS coefficients can still describe conditional-mean associations while classical standard errors may be unreliable. A heteroskedasticity-consistent covariance matrix replaces the common-variance assumption with observation-specific residual information.
HC3 strongly adjusts high-leverage observations. Its “meat” matrix uses:
hc3_table <- function(model, level = 0.95) {
if (anyNA(coef(model))) {
stop("The model has non-estimable coefficients; resolve perfect collinearity first.")
}
X <- model.matrix(model)
residual <- residuals(model)
leverage <- hatvalues(model)
omega_hc3 <- residual^2 / (1 - leverage)^2
bread <- solve(crossprod(X))
meat <- crossprod(X, X * omega_hc3)
covariance_hc3 <- bread %*% meat %*% bread
standard_error <- sqrt(diag(covariance_hc3))
estimate <- coef(model)
statistic <- estimate / standard_error
degrees_freedom <- df.residual(model)
critical <- qt(1 - (1 - level) / 2, degrees_freedom)
data.frame(
term = names(estimate),
estimate = unname(estimate),
robust_se_hc3 = standard_error,
lower = estimate - critical * standard_error,
upper = estimate + critical * standard_error,
p_value = 2 * pt(
abs(statistic),
df = degrees_freedom,
lower.tail = FALSE
),
row.names = NULL
)
}working_hc3 <- hc3_table(working_model)
classic_se <- summary(working_model)$coefficients[, "Std. Error"]
hc3_comparison <- data.frame(
"Term" = label_terms(working_hc3$term),
"Estimate" = working_hc3$estimate,
"Classical SE" = classic_se[working_hc3$term],
"HC3 SE" = working_hc3$robust_se_hc3,
"HC3 lower" = working_hc3$lower,
"HC3 upper" = working_hc3$upper,
"HC3 p-value" = working_hc3$p_value,
row.names = NULL,
check.names = FALSE
)
knitr::kable(
hc3_comparison,
digits = 3,
caption = "Classical and HC3 robust standard errors"
)| Term | Estimate | Classical SE | HC3 SE | HC3 lower | HC3 upper | HC3 p-value |
|---|---|---|---|---|---|---|
| Intercept | 125.623 | 1.152 | 1.150 | 123.363 | 127.883 | 0.000 |
| Age (per 10 years; centered at 50) | 5.916 | 0.372 | 0.384 | 5.160 | 6.672 | 0.000 |
| Current smoking (reference: not current) | 4.636 | 1.393 | 1.549 | 1.592 | 7.680 | 0.003 |
| Age squared | 0.871 | 0.180 | 0.188 | 0.500 | 1.241 | 0.000 |
| BMI (per 5 units; centered at 25) | 3.238 | 0.610 | 0.602 | 2.054 | 4.422 | 0.000 |
| Male (reference: female) | 2.610 | 1.033 | 1.051 | 0.544 | 4.676 | 0.013 |
| North (reference: Central) | 2.994 | 1.445 | 1.528 | -0.010 | 5.998 | 0.051 |
| South (reference: Central) | -3.392 | 1.334 | 1.373 | -6.091 | -0.693 | 0.014 |
| West (reference: Central) | 1.762 | 1.422 | 1.377 | -0.946 | 4.469 | 0.202 |
| Age × current smoking | 1.657 | 0.952 | 0.990 | -0.290 | 3.603 | 0.095 |
Robust standard errors do not repair every problem HC3 changes uncertainty estimates, not OLS coefficients. It cannot fix a misspecified conditional mean, serious measurement error, uncontrolled confounding, selection bias, clustered dependence, or unsupported extrapolation. Clustered data require standard errors or models matched to that dependence structure.
A prediction interval also includes individual residual variation around the conditional mean, so it is usually wider.
new_people <- data.frame(
age = c(35, 50, 70),
bmi = c(23, 25, 30),
smoking_status = factor(
c("Not current", "Current", "Not current"),
levels = levels(ph_data$smoking_status)
),
sex = factor(
c("Female", "Male", "Female"),
levels = levels(ph_data$sex)
),
neighborhood = factor(
c("Central", "North", "West"),
levels = levels(ph_data$neighborhood)
)
)
new_people$age_c10 <- (new_people$age - 50) / 10
new_people$bmi_c5 <- (new_people$bmi - 25) / 5
mean_intervals <- predict(
working_model, newdata = new_people,
interval = "confidence", level = 0.95
)
individual_intervals <- predict(
working_model, newdata = new_people,
interval = "prediction", level = 0.95
)
interval_table <- cbind(
new_people[c("age", "bmi", "smoking_status", "sex", "neighborhood")],
"Mean fitted value" = mean_intervals[, "fit"],
"Mean CI lower" = mean_intervals[, "lwr"],
"Mean CI upper" = mean_intervals[, "upr"],
"Individual PI lower" = individual_intervals[, "lwr"],
"Individual PI upper" = individual_intervals[, "upr"]
)
knitr::kable(
interval_table,
digits = 1,
caption = "Confidence intervals for means and prediction intervals for individuals"
)| age | bmi | smoking_status | sex | neighborhood | Mean fitted value | Mean CI lower | Mean CI upper | Individual PI lower | Individual PI upper |
|---|---|---|---|---|---|---|---|---|---|
| 35 | 23 | Not current | Female | Central | 117 | 115 | 120 | 97 | 138 |
| 50 | 25 | Current | Male | North | 136 | 132 | 139 | 115 | 156 |
| 70 | 30 | Not current | Female | West | 146 | 142 | 149 | 125 | 166 |
predict.lm(..., interval = "prediction") uses the
classical homoskedastic model. Under substantial heteroskedasticity,
individual variability may change across predictor values, so classical
prediction intervals may be miscalibrated. Robust coefficient standard
errors alone do not automatically yield valid individual prediction
intervals.
simple_grid <- data.frame(
age = seq(min(ph_data$age), max(ph_data$age), length.out = 160)
)
simple_confidence <- predict(simple_model, simple_grid, interval = "confidence")
simple_prediction <- predict(simple_model, simple_grid, interval = "prediction")
plot(
ph_data$age, ph_data$systolic_bp,
pch = 16, cex = 0.42, col = "grey75",
xlab = "Age (years)", ylab = "Systolic blood pressure (mmHg)",
main = "A confidence band is not a prediction band"
)
polygon(
c(simple_grid$age, rev(simple_grid$age)),
c(simple_prediction[, "lwr"], rev(simple_prediction[, "upr"])),
col = rgb(230 / 255, 159 / 255, 0, 0.16), border = NA
)
polygon(
c(simple_grid$age, rev(simple_grid$age)),
c(simple_confidence[, "lwr"], rev(simple_confidence[, "upr"])),
col = rgb(0, 114 / 255, 178 / 255, 0.28), border = NA
)
lines(
simple_grid$age, simple_confidence[, "fit"],
col = palette_ph["navy"], lwd = 2.5
)
legend(
"topleft",
legend = c("Fitted mean", "Mean 95% CI", "Individual 95% PI"),
col = c(palette_ph["navy"], palette_ph["blue"], palette_ph["orange"]),
lwd = c(2.5, 8, 8), bty = "n"
)In simple regression, the 95% confidence band for the mean response and the 95% prediction band for a new individual.
Adding variables cannot increase the residual sum of squares in training data, yet performance on new data may deteriorate. When prediction is a goal, set aside test data before modeling, and perform all variable selection, transformations, and tuning within the training data.
train_data <- ph_data
prediction_formula <- systolic_bp ~
age_c10 * smoking_status + I(age_c10^2) +
bmi_c5 + sex + neighborhood
train_model <- lm(prediction_formula, data = train_data)
test_prediction <- predict(train_model, newdata = test_data)This is the first point at which the untouched holdout outcome is opened and summarized.
split_composition <- data.frame(
"Dataset" = c("Development/training", "Untouched holdout test"),
"Sample size" = c(nrow(train_data), nrow(test_data)),
"Mean systolic BP" = c(
mean(train_data$systolic_bp),
mean(test_data$systolic_bp)
),
check.names = FALSE
)
knitr::kable(
split_composition,
digits = 1,
caption = "Prespecified training data and the newly opened holdout test data"
)| Dataset | Sample size | Mean systolic BP |
|---|---|---|
| Development/training | 420 | 128 |
| Untouched holdout test | 140 | 131 |
prediction_metrics <- function(observed, predicted) {
c(
RMSE = sqrt(mean((observed - predicted)^2)),
MAE = mean(abs(observed - predicted)),
R2_test = 1 - sum((observed - predicted)^2) /
sum((observed - mean(observed))^2)
)
}
model_metrics <- prediction_metrics(test_data$systolic_bp, test_prediction)
baseline_prediction <- rep(mean(train_data$systolic_bp), nrow(test_data))
baseline_metrics <- prediction_metrics(test_data$systolic_bp, baseline_prediction)
performance_table <- rbind(
"Regression model" = model_metrics,
"Training-mean baseline" = baseline_metrics
)
knitr::kable(
performance_table,
digits = 2,
caption = "Predictive performance in the untouched holdout test data"
)| RMSE | MAE | R2_test | |
|---|---|---|---|
| Regression model | 10.7 | 8.73 | 0.51 |
| Training-mean baseline | 15.7 | 11.87 | -0.04 |
RMSE is more sensitive to large errors, whereas MAE averages absolute errors. Test-set can be negative, meaning the model performs worse than a simple mean-based reference. A single random split has sampling uncertainty; a formal prediction study generally needs cross-validation and external validation.
Prevent data leakage If the analyst uses the full dataset to select variables, handle unusual observations, or choose transformations before splitting, the test data have already influenced training. Estimate imputation, standardization, and feature-selection rules within each training fold and then apply those rules to validation data.
By default, lm() drops a row when any variable in the
formula is missing. Adding physical activity therefore reduces the
analysis sample:
activity_model <- lm(
systolic_bp ~ age_c10 * smoking_status + I(age_c10^2) +
bmi_c5 + sex + neighborhood + physical_activity_30,
data = ph_data,
na.action = na.exclude
)
missing_model_comparison <- rbind(
fit_statistics(working_model, "Without physical activity"),
fit_statistics(activity_model, "Physical activity added; complete cases")
)
knitr::kable(
missing_model_comparison,
digits = 3,
caption = "Change in the analysis sample after adding a variable with missingness"
)| Model | Sample size | Parameters | R-squared | Adjusted R-squared | Residual standard error |
|---|---|---|---|---|---|
| Without physical activity | 420 | 10 | 0.495 | 0.484 | 10.3 |
| Physical activity added; complete cases | 391 | 11 | 0.497 | 0.483 | 10.4 |
Compare coefficients from the two models cautiously: a difference may reflect adjustment for physical activity, a change in the analyzed sample, or both. Report variable-level missingness, the model sample size, assumptions about missingness, and the handling method.
Complete-case analysis is easiest to justify under strong conditions such as missing completely at random. When missingness relates to observed information, multiple imputation may be appropriate; dependence on unobserved values also calls for sensitivity analysis. This tutorial does not use single mean imputation because it understates variability and distorts relationships.
Choose variables from the estimand and causal structure:
lm() independence-based inference is
inappropriate for complex samples, clusters, or repeated measures unless
the design is handled explicitly.Causal language requires additional support “The association remained significant after controlling for age and BMI” does not establish causation. A causal interpretation requires clear temporality, a well-defined intervention, confounding assumptions, comparability, justified missingness and selection mechanisms, and an estimator appropriate to the study design.
A reproducible report should state:
Among 420 simulated participants, we used OLS to describe conditional associations of systolic blood pressure with age, BMI, smoking status, sex, and neighborhood, allowing a prespecified quadratic age term and age-by-smoking interaction. Among non-current smokers at the same modeled values of other covariates, a 10-year age difference near age 50 corresponded to 5.9 mmHg in mean systolic blood pressure (HC3 95% CI 5.2 to 6.7). The in-sample adjusted was 0.48; residual checks suggested non-constant variance, so we report HC3 robust standard errors. These simulated cross-sectional-style data do not support a causal effect of aging, and the result should not be extrapolated beyond the observed covariate ranges.
A full report should also interpret the interaction explicitly, noting that the displayed age coefficient belongs to the non-current-smoking reference group and graphing predictions for both groups.
Which question is most directly suited to ordinary linear regression?
How should the bmi_c5 coefficient in
scaled_model be interpreted? Can it be called a causal
effect of BMI?
What comparison does neighborhoodSouth represent in
multiple_model? Would model predictions change if South
became the reference?
Fit systolic blood pressure as a function of age, BMI, and smoking status, and report 95% CIs. The answer uses only base R and the helper defined above.
exercise_model <- lm(
systolic_bp ~ age_c10 + bmi_c5 + smoking_status,
data = ph_data
)
knitr::kable(
coefficient_table(exercise_model),
digits = 3,
caption = "Exercise model coefficients and 95% confidence intervals"
)| Term | Estimate | Standard error | 95% CI lower | 95% CI upper | p-value |
|---|---|---|---|---|---|
| Intercept | 129.24 | 0.595 | 128.07 | 130.41 | 0.000 |
| Age (per 10 years; centered at 50) | 5.39 | 0.340 | 4.72 | 6.06 | 0.000 |
| BMI (per 5 units; centered at 25) | 3.13 | 0.637 | 1.87 | 4.38 | 0.000 |
| Current smoking (reference: not current) | 4.17 | 1.458 | 1.30 | 7.03 | 0.004 |
Suppose the age main effect is 5.0 mmHg per 10 years and the
age-by-current-smoking interaction is 1.8 mmHg per 10 years. What is the
age slope among current smokers? At what age does the
smoking_statusCurrent main effect compare groups?
What would you do if the Residuals vs Fitted plot showed residual spread increasing with fitted values?
A health department wants uncertainty for the mean systolic blood pressure of 60-year-old, BMI-25, non-current-smoking women. A clinician wants a plausible range for the next individual with those characteristics. Which interval serves each question?
A model with many variables and interactions has training . Is that enough to claim excellent predictive performance?
| Purpose | Code pattern |
|---|---|
| Fit a linear model | lm(y ~ x1 + x2, data = d) |
| View a full summary | summary(model) |
| Extract coefficients | coef(model) |
| Classical covariance matrix | vcov(model) |
| 95% coefficient intervals | confint(model) |
| Fitted values and residuals | fitted(model); residuals(model) |
| Standard diagnostic plots | plot(model) |
| Leverage and Cook’s distance | hatvalues(model);
cooks.distance(model) |
| Studentized residuals | rstudent(model) |
| Confidence interval for a mean | predict(model, newdata, interval = "confidence") |
| Prediction interval for an individual | predict(model, newdata, interval = "prediction") |
| Compare nested models | anova(smaller, larger) |
| Inspect the design matrix | model.matrix(model) |
When reading model output, proceed in this order:
Question and estimand → analysis sample → units and reference groups → functional form → coefficients and intervals → interaction or nonlinearity → diagnostics → out-of-sample performance → limitations and domain of use
| Concept | Formula or meaning |
|---|---|
| Conditional mean | |
| Residual | |
| OLS objective | Minimize |
| OLS solution | |
| RMSE | |
| HC3 weight | |
| Age slope among current smokers | Age main effect + age × smoking interaction |
Before publishing results, confirm that:
## R version 4.6.1 (2026-06-24)
## Platform: aarch64-apple-darwin23
## Running under: macOS Tahoe 26.5.1
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
##
## locale:
## [1] C.UTF-8/C.UTF-8/C.UTF-8/C/C.UTF-8/C.UTF-8
##
## time zone: America/Edmonton
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## loaded via a namespace (and not attached):
## [1] digest_0.6.39 R6_2.6.1 fastmap_1.2.0 xfun_0.60
## [5] cachem_1.1.0 knitr_1.51 htmltools_0.5.9 rmarkdown_2.31
## [9] lifecycle_1.0.5 cli_3.6.6 sass_0.4.10 jquerylib_0.1.4
## [13] compiler_4.6.1 tools_4.6.1 evaluate_1.0.5 bslib_0.12.0
## [17] yaml_2.3.12 rlang_1.3.0 jsonlite_2.0.0