Multiple Regression Exercises in R: 17 Real-World Practice Problems
Seventeen practice problems that walk you through fitting multiple regression models in R, reading partial slopes, comparing nested models with anova(), checking VIF for multicollinearity, diagnosing residuals, adding interactions and polynomial terms, and predicting on a held-out slice. Every problem ships with a runnable scaffold, the expected printed output, and a hidden solution with an explanation you can self-check against.
Section 1. Fit and interpret a multiple regression (4 problems)
Exercise 1.1: Fit a two-predictor model and pull both coefficients
Task: Use mtcars to fit a multiple regression of mpg on wt and hp. Save the fitted model object to ex_1_1 so subsequent problems can reuse it, then print the named coefficient vector with coef().
Expected result:
#> (Intercept) wt hp
#> 37.22727012 -3.87783074 -0.03177295
Difficulty: Beginner
A multiple regression model takes one outcome and several predictors joined in the formula; once it is fitted, the named slopes can be read straight back out.
Fit with lm(mpg ~ wt + hp, data = mtcars) and pass the result to coef().
Click to reveal solution
Explanation: The formula mpg ~ wt + hp tells lm() to estimate one intercept and two partial slopes. coef() returns a named numeric vector, which is the cleanest way to grab a single slope by name later (e.g. coef(ex_1_1)["wt"]). Each slope is conditional: the wt slope is the effect of weight after holding horsepower fixed, not the marginal effect of weight on its own.
Exercise 1.2: Interpret a partial slope as a one-unit change
Task: Using the ex_1_1 model from the previous exercise, compute the expected change in mpg when wt increases by 0.5 (half a thousand pounds) while hp stays constant. Save the scalar to ex_1_2 (it should be negative).
Expected result:
#> [1] -1.938915
Difficulty: Beginner
A partial slope already gives the change per one unit of the predictor, so scaling it by the size of the move gives the answer directly.
Pull the wt slope with coef(ex_1_1)["wt"], multiply by 0.5, and strip the carried name with unname().
Click to reveal solution
Explanation: A partial slope is the predicted change in the outcome per unit change in that predictor, holding the others fixed. Multiplying the slope by the size of the move (0.5) gives the predicted change directly. unname() strips the carried-over wt name so the result is a clean scalar, which matters when you feed numbers into reporting tables.
Exercise 1.3: Build a 95 percent confidence interval for one coefficient
Task: From ex_1_1, use confint() to extract the 95 percent confidence interval for the hp coefficient only. Save the resulting two-element named numeric vector (the lower and upper bound) to ex_1_3.
Expected result:
#> 2.5 % 97.5 %
#> -0.05024078 -0.01330512
Difficulty: Intermediate
Each coefficient comes with an interval of plausible values, and you only need the row for one predictor.
Call confint() with parm = "hp" and level = 0.95, then index [1, ] to collapse the single row to a vector.
Click to reveal solution
Explanation: confint() returns a matrix with one row per coefficient. Passing parm = "hp" limits the rows it computes, and [1, ] collapses the single row to a named vector. The interval excludes zero, which is the geometric statement of "p < 0.05" for that coefficient. Pair the slope with its interval whenever you report a regression result, because a slope without a width is incomplete information.
Exercise 1.4: Extract Adjusted R-squared programmatically
Task: Fit a three-predictor model of mpg on wt, hp, and cyl using mtcars. Pull the adjusted R-squared from the summary() object (do not retype the number from the printout). Save the scalar to ex_1_4.
Expected result:
#> [1] 0.8327
Difficulty: Intermediate
Goodness-of-fit numbers live inside the model summary object, not just in its printout.
Fit lm(mpg ~ wt + hp + cyl, data = mtcars) and read $adj.r.squared off summary().
Click to reveal solution
Explanation: Always read summary values programmatically, never from the printout. summary(fit) returns a list with named elements like adj.r.squared, r.squared, coefficients, fstatistic, and sigma. The adjusted version penalises model size, so it goes up only when an added predictor pulls its statistical weight. Compare adj-R² across nested models to decide whether a new variable is worth keeping.
Section 2. Test predictor significance and compare models (3 problems)
Exercise 2.1: Read a single t-statistic from the coefficient table
Task: Refit the three-predictor model mpg ~ wt + hp + cyl on mtcars. Pull the t-statistic of the cyl coefficient out of the summary() coefficient matrix without retyping it. Save the scalar to ex_2_1.
Expected result:
#> [1] -1.7086
Difficulty: Intermediate
The summary stores every coefficient's estimate, standard error, and test statistic in one indexable table.
Index summary(fit)$coefficients by the row "cyl" and the column "t value".
Click to reveal solution
Explanation: summary(fit)$coefficients is a 4-column matrix (Estimate, Std. Error, t value, Pr(>|t|)) indexed by predictor name. Indexing by name in both dimensions is robust to predictor reordering, which is why this pattern is safer than [3, 3]. The t-value here is borderline, which is exactly the kind of "should I keep this predictor?" signal you want to make a deliberate call on rather than eyeballing stars.
Exercise 2.2: Compare two nested models with anova()
Task: On mtcars, fit a small model mpg ~ wt and a larger model mpg ~ wt + hp + cyl. Use anova() to test whether the two extra predictors jointly improve fit. Save the p-value of the F-test (the Pr(>F) value on the second row) to ex_2_2.
Expected result:
#> [1] 1.331e-05
Difficulty: Intermediate
To judge whether a block of extra predictors earns its place, compare the small and large models as a pair rather than predictor by predictor.
Pass both fitted models to anova() and take the Pr(>F) value from the second row.
Click to reveal solution
Explanation: anova() on two nested lm objects runs a partial F-test: does the extra block of predictors explain a statistically meaningful chunk of residual variance beyond the smaller model? The p-value is on the second row because the first row is the "smaller model" baseline. Use this test, not a stack of individual t-tests, whenever you want to add or drop a group of predictors together.
Exercise 2.3: Stepwise selection by AIC with step()
Task: Start from the full model mpg ~ wt + hp + cyl + drat + qsec on mtcars. Run backward stepwise selection by AIC using step() with trace = 0. Save the formula of the final selected model (as a formula object via formula()) to ex_2_3.
Expected result:
#> mpg ~ wt + qsec + hp
#> <environment: 0x...>
Difficulty: Advanced
Backward selection starts from the full model and prunes predictors until removing any more would only hurt the fit criterion.
Run step() with direction = "backward" and trace = 0, then wrap the chosen model in formula().
Click to reveal solution
Explanation: step() greedily drops one predictor at a time, recomputes AIC, and stops when no single drop lowers AIC further. direction = "backward" starts from the full model and prunes; "both" lets it add variables back. AIC trades fit against complexity (AIC = -2 log L + 2 k), so unlike adjusted R² it imposes a hard penalty per parameter. Stepwise selection is a screening tool, not a final answer; always cross-check the chosen model on a holdout slice.
Section 3. Diagnose residuals and check assumptions (4 problems)
Exercise 3.1: Build the residuals-vs-fitted plot manually
Task: From the model ex_1_1 (mpg ~ wt + hp), build a tibble with columns fitted and resid using fitted() and resid(). Save the two-column tibble (32 rows) to ex_3_1 so you can pipe it into ggplot().
Expected result:
#> # A tibble: 32 x 2
#> fitted resid
#> <dbl> <dbl>
#> 1 23.6 -2.57
#> 2 22.6 -1.59
#> 3 25.3 -2.51
#> 4 21.3 -0.13
#> 5 18.2 0.50
#> # 27 more rows hidden
Difficulty: Intermediate
Every residual diagnostic plot starts from two aligned vectors: what the model predicted and how far each point missed.
Build a tibble() with fitted(ex_1_1) and resid(ex_1_1) as its two columns.
Click to reveal solution
Explanation: fitted() returns the predicted values at each training row and resid() returns observed minus predicted. Stacking them into a tibble is the gateway to every regression diagnostic plot: residuals-vs-fitted (heteroscedasticity), QQ plot (normality), scale-location (variance trend), residuals-vs-leverage (influence). If the residuals fan out as the fitted value grows, your variance is not constant and a log transform or robust standard errors are on the table.
Exercise 3.2: Shapiro-Wilk test for residual normality
Task: Run a Shapiro-Wilk normality test on the residuals of ex_1_1 using shapiro.test(). Save the resulting p.value (a scalar) to ex_3_2. The expected p-value is around 0.13, meaning we do not reject normality at the 5 percent level.
Expected result:
#> [1] 0.1295
Difficulty: Intermediate
A normality check applies to the residuals the model leaves behind, not to the raw outcome variable.
Feed resid(ex_1_1) to shapiro.test() and read its $p.value.
Click to reveal solution
Explanation: Shapiro-Wilk is a formal test of "the data are drawn from a normal distribution." With n = 32 it has limited power, so failing to reject (p > 0.05) does not mean "the residuals are perfectly normal," it means you do not have strong evidence against normality. Always pair the test with a QQ plot; the eyeball test catches tail issues that small-sample tests miss.
Exercise 3.3: Breusch-Pagan test for heteroscedasticity
Task: Use lmtest::bptest() to run a Breusch-Pagan test for non-constant residual variance on ex_1_1. The null is constant variance (homoscedasticity). Save the p-value to ex_3_3. A small p-value means heteroscedasticity is present.
Expected result:
#> [1] 0.0892
Difficulty: Intermediate
To check whether residual spread stays constant across the fit, run the test on the model object itself.
Call bptest() on ex_1_1 and pull its $p.value.
Click to reveal solution
Explanation: Breusch-Pagan regresses the squared residuals on the original predictors and tests whether any predictor has explanatory power over the variance. A p of 0.09 sits in the "smoke but not flame" zone: there is mild evidence the variance is not flat. Common fixes are to log-transform the outcome, use weighted least squares, or compute heteroscedasticity-consistent (HC3) standard errors via sandwich::vcovHC().
Exercise 3.4: Identify high-leverage observations with Cook's distance
Task: Compute Cook's distance for every row in ex_1_1 with cooks.distance(). Save the names of all observations whose Cook's distance exceeds the conventional cutoff of 4 / n (where n is nrow(mtcars)) to ex_3_4 as a character vector.
Expected result:
#> [1] "Chrysler Imperial" "Toyota Corolla" "Maserati Bora"
Difficulty: Advanced
An influence score per row flags the points that move the fit the most, judged against a cutoff that shrinks with sample size.
Get the scores with cooks.distance(), compare them against 4 / nrow(mtcars), and pull the names() of the ones above it.
Click to reveal solution
Explanation: Cook's distance measures how much every fitted value would shift if you deleted one row. The 4/n rule of thumb (0.125 here) flags candidates to inspect, not rows to delete blindly. Always look at flagged rows in context: the Maserati Bora has unusually high horsepower for its weight, so it is an honest outlier, not a data error. Removing it would bias the slope estimate toward the bulk of the sample.
Section 4. Detect multicollinearity (3 problems)
Exercise 4.1: Build the predictor correlation matrix
Task: Compute the Pearson correlation matrix of the five potential predictors wt, hp, cyl, disp, drat from mtcars. Save the 5x5 numeric matrix to ex_4_1 so you can spot pairs with absolute correlation above 0.8.
Expected result:
#> wt hp cyl disp drat
#> wt 1.0000000 0.6587479 0.7824958 0.8879799 -0.7124406
#> hp 0.6587479 1.0000000 0.8324475 0.7909486 -0.4487591
#> cyl 0.7824958 0.8324475 1.0000000 0.9020329 -0.6999381
#> disp 0.8879799 0.7909486 0.9020329 1.0000000 -0.7102139
#> drat -0.7124406 -0.4487591 -0.6999381 -0.7102139 1.0000000
Difficulty: Intermediate
Pairwise relationships among the predictors are the first screen for redundancy before any model is fitted.
Subset mtcars to the five predictor columns and pass that to cor().
Click to reveal solution
Explanation: A correlation matrix is the first multicollinearity check: anything above 0.8 in absolute value is a warning sign. Here disp is strongly correlated with wt, cyl, and hp, which means putting all four in a model is likely to inflate standard errors and flip coefficient signs. The matrix is necessary but not sufficient; even with all pairwise correlations below 0.8, three predictors can still co-move enough to inflate VIF.
Exercise 4.2: Compute VIF for a multi-predictor model
Task: Fit mpg ~ wt + hp + cyl + disp on mtcars and use vif() to compute the variance inflation factor for each predictor. Save the named numeric vector of four VIFs to ex_4_2. Any VIF above 5 (some authors use 10) is the conventional red flag.
Expected result:
#> wt hp cyl disp
#> 4.844618 3.258481 8.819479 7.324517
Difficulty: Advanced
Multicollinearity is measured per predictor by how well the rest of the model can already explain that predictor.
Fit lm(mpg ~ wt + hp + cyl + disp, data = mtcars) and pass the model to vif().
Click to reveal solution
Explanation: VIF for predictor j is 1 / (1 - R_j^2), where R_j^2 is the R-squared of regressing predictor j on every other predictor. A VIF of 8.8 for cyl means its standard error is sqrt(8.8) = 2.97 times larger than it would be if cyl were orthogonal to the rest. The fix is usually to drop one of the redundant predictors, combine them into a single derived variable, or shrink coefficients with ridge or lasso.
Exercise 4.3: Drop the worst collinear predictor and re-check VIF
Task: From the model in Exercise 4.2, drop the predictor with the highest VIF (it should be cyl). Refit the three-predictor model, recompute VIF on the smaller model, and save the new named VIF vector to ex_4_3. All three VIFs should now be below 5.
Expected result:
#> wt hp disp
#> 4.510914 2.736633 4.711599
Difficulty: Advanced
Removing the single most redundant predictor should pull the remaining inflation factors back under the threshold.
Refit without cyl as lm(mpg ~ wt + hp + disp, data = mtcars) and rerun vif() on the smaller model.
Click to reveal solution
Explanation: Dropping cyl removes the most redundant predictor; the remaining three are pairwise correlated but no longer flagged. This is the typical VIF workflow: identify the worst offender, drop it (or merge with a sibling), and confirm the rest fall under the threshold. If multiple predictors had VIF above 5, drop them one at a time and re-check, because dropping one often pulls the others below threshold on its own.
Section 5. Add interactions and transformations (4 problems)
Exercise 5.1: Fit a model with one interaction term
Task: On mtcars, fit a model where mpg depends on wt, hp, and their interaction wt:hp. Use the * shortcut so the formula reads mpg ~ wt * hp. Save the named coefficient vector (4 elements: intercept, wt, hp, wt:hp) to ex_5_1.
Expected result:
#> (Intercept) wt hp wt:hp
#> 49.80842369 -8.21662430 -0.12010209 0.02784815
Difficulty: Intermediate
The multiplication shorthand in a formula expands to both main effects plus their product term in one stroke.
Fit lm(mpg ~ wt * hp, data = mtcars) and read the four slopes with coef().
Click to reveal solution
Explanation: wt * hp expands to wt + hp + wt:hp, so you get both main effects and the interaction in one stroke. The positive interaction coefficient means the negative effect of weight on mpg shrinks as horsepower grows, which makes physical sense: heavy cars with weak engines are penalised most. Always include main effects when including an interaction; dropping a main effect forces it through zero and silently biases the interaction.
Exercise 5.2: Log-transform the outcome and compare R-squared
Task: On mtcars, fit two models: mpg ~ wt + hp and log(mpg) ~ wt + hp. Pull the multiple R-squared from each summary(). Save a length-2 named numeric vector c(linear = ..., log = ...) of the two R-squared values to ex_5_2.
Expected result:
#> linear log
#> 0.8267855 0.8782068
Difficulty: Advanced
Logging the outcome changes its scale, so you fit a separate model for each version and read the fit number off each.
Fit mpg ~ wt + hp and log(mpg) ~ wt + hp, then collect each summary()$r.squared into a named c().
Click to reveal solution
Explanation: A log transform on the outcome turns additive effects into multiplicative ones; each coefficient becomes "approximate percentage change in mpg per unit predictor." The fit improves here because mpg has a long right tail and shrinks when logged, which evens out the residual variance. Watch out: R-squared on log(mpg) is not directly comparable to R-squared on mpg, because they live on different scales. Compare on the same metric (e.g. residual standard error on the original scale after back-transforming predictions).
Exercise 5.3: Add a quadratic term with poly()
Task: On mtcars, fit a model where mpg depends on wt, hp, and a quadratic term in hp. Use the orthogonal polynomial form poly(hp, 2) to avoid collinearity between hp and hp^2. Save the adjusted R-squared scalar to ex_5_3.
Expected result:
#> [1] 0.844
Difficulty: Advanced
A curved relationship needs a squared term, added in an orthogonal form so it does not collide with the linear term.
Put poly(hp, 2) in the formula alongside wt and read $adj.r.squared from summary().
Click to reveal solution
Explanation: poly(hp, 2) builds two columns: a linear term and a quadratic term, both orthogonal to each other. That orthogonality matters because raw hp and hp^2 are correlated by construction (VIF in the dozens), which destabilises the coefficient estimates even when the fit is fine. The adj-R² climbs from 0.815 to 0.844, suggesting the mpg-vs-hp relationship is genuinely curved, not linear.
Exercise 5.4: Compare nested models with and without the interaction
Task: Build two nested models on mtcars: m_main is mpg ~ wt + hp and m_intx adds the interaction (mpg ~ wt * hp). Use anova() to test whether the interaction adds explanatory power. Save the F-statistic of the comparison (row 2) to ex_5_4.
Expected result:
#> [1] 14.821
Difficulty: Advanced
To decide whether the interaction is worth its one degree of freedom, weigh the model with it against the model without it.
Pass m_main and m_intx to anova() and take the F value from the second row.
Click to reveal solution
Explanation: Comparing nested models with anova() is the principled way to decide whether the interaction is worth its degree of freedom. The F-statistic of 14.8 with p well under 0.001 says the interaction does pay rent; the residual sum of squares drops sharply when you allow the wt slope to flex with hp. Without this test you would be guessing from the t-stat of the interaction alone, which works here but breaks for multi-degree-of-freedom factor interactions.
Section 6. Predict on new data (3 problems)
Exercise 6.1: Predict mpg for a new car with a confidence interval
Task: Using ex_1_1 (mpg ~ wt + hp), predict the mean mpg for a hypothetical car with wt = 3.0 and hp = 110. Ask for interval = "confidence". Save the 1x3 numeric matrix (fit, lwr, upr) to ex_6_1.
Expected result:
#> fit lwr upr
#> 1 22.10593 20.79722 23.41464
Difficulty: Intermediate
Scoring a hypothetical car means handing the model a new one-row dataset and asking for the uncertainty band around the mean.
Call predict() with newdata = new_car and interval = "confidence".
Click to reveal solution
Explanation: interval = "confidence" returns the interval for the mean prediction at that x. interval = "prediction" would be wider because it also accounts for the residual noise of a single new observation. Use the confidence interval when reporting "what does the model say the expected mpg is for cars like this?" and the prediction interval when you need to bound an actual future observation, like the next car off the line.
Exercise 6.2: Compute hold-out RMSE on a train/test split
Task: Set set.seed(42), sample 24 rows of mtcars for training and the remaining 8 as the test set. Fit mpg ~ wt + hp + cyl on the training rows, predict on the test rows, and save the scalar RMSE on the test set to ex_6_2.
Expected result:
#> [1] 2.7847
Difficulty: Advanced
An honest error score is fitted on one slice of rows and measured on rows the model never saw during fitting.
Fit on train, get predict(fit, newdata = test), then compute sqrt(mean((test$mpg - preds)^2)).
Click to reveal solution
Explanation: Holdout RMSE is the honest scorecard for a regression model; in-sample R-squared always overstates performance because the model has already seen those rows. With only 8 test rows the estimate is noisy, so in practice repeat the split many times or use k-fold cross-validation via caret::trainControl() or rsample::vfold_cv(). The takeaway here is the pattern: fit on train, predict on test, score with a metric that lives on the outcome's scale.
Exercise 6.3: Build a tidy coefficient table with broom
Task: Refit mpg ~ wt + hp + cyl on mtcars and use broom::tidy() with conf.int = TRUE to produce a tidy coefficient table. Save the 4-row tibble (intercept plus three predictors) with columns term, estimate, std.error, statistic, p.value, conf.low, conf.high to ex_6_3.
Expected result:
#> # A tibble: 4 x 7
#> term estimate std.error statistic p.value conf.low conf.high
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 (Intercept) 38.8 1.79 21.7 4.02e-19 35.1 42.4
#> 2 wt -3.17 0.741 -4.28 2.00e- 4 -4.69 -1.65
#> 3 hp -0.0180 0.0119 -1.52 1.40e- 1 -0.0424 0.00641
#> 4 cyl -0.942 0.551 -1.71 9.85e- 2 -2.07 0.187
Difficulty: Intermediate
A fitted model can be reshaped into a flat one-row-per-term table with its confidence bounds attached.
Fit the three-predictor model and pass it to tidy() with conf.int = TRUE.
Click to reveal solution
Explanation: broom::tidy() turns a model object into a flat tibble that plays nicely with dplyr, ggplot2, and report generation. conf.int = TRUE adds confidence bounds in two extra columns. This format is the right shape for forest plots, regression tables in Quarto reports, and bulk comparison across many models (purrr::map(fits, tidy) |> bind_rows(.id = "model")).
What to do next
You have practiced every step of the multiple regression workflow: fitting, interpreting partial slopes, testing, diagnosing, fixing collinearity, adding interactions, and predicting. Pick a follow-up depending on what you want to deepen next.
- Multiple Regression in R: the parent tutorial that walks through the same workflow with longer explanations.
- Linear Regression in R: back to one predictor, useful if you want a refresher on the foundation.
- Logistic Regression in R: when the outcome is binary instead of continuous.
- R for Statistics: Hypothesis Testing Exercises: practice the testing logic that underlies anova(), F-tests, and confidence intervals.
r-statistics.co · Verifiable credential · Public URL
This document certifies mastery of
Multiple Regression Mastery
Every certificate has a public verification URL that proves the holder passed the assessment. Anyone with the link can confirm the recipient and date.
110 learners have earned this certificate