Selecting predictors with cross-validation

Today let's work out which of several candidate predictors actually belong in a regression, instead of throwing every number you have at the model and hoping for the best.

Fairwind Bike Rentals has 48 months of revenue on record, January 2022 through December 2025. Alongside revenue, the shop has tracked four other numbers every month: how much it spent on ads, the average temperature, how many days it rained, and how many new cafes opened downtown that month. That last one, new cafes, has no obvious link to bike rentals at all, and it is one of the four candidates this lesson has to judge.

Revenue climbs overall but bounces around a lot from month to month, from a low of $18,834 in the first month to a high of $35,753 in month 43. Which of the four candidates actually explains that climb, and which ones just happen to be sitting nearby, is the question on the table.

Why R-squared always goes up when you add a predictor

R-squared measures how much of the up-and-down in revenue a regression explains, as a share between 0 and 1. An R-squared of 0.90 means the model accounts for 90% of the variation in revenue, leaving the rest in the residuals, the part the model still misses.

A regression like TSLM() fits by ordinary least squares: it picks coefficients that make the residual sum of squares, the total squared size of the misses, as small as possible. Adding one more predictor can never make that sum bigger. In the worst case the fitting procedure gives the new predictor a coefficient of exactly 0 and reproduces the old fit exactly. So R-squared, which is built directly from that residual sum of squares, can only hold steady or rise as you add predictors. It can never fall.

Watch that happen for real. Fit revenue against ad spend alone, then keep adding the other three candidates one at a time.

RInteractive R
# Build Fairwind's 48 months of revenue and four candidate predictors, then fit four nested regressions library(tsibble) library(fable) library(fabletools) library(dplyr) set.seed(2026) n <- 48 month <- yearmonth("2022 Jan") + 0:(n - 1) idx <- 1:n avg_temp <- 55 + 22 * sin(2 * pi * (idx - 4) / 12) + rnorm(n, 0, 4) rain_days <- pmax(0, 10 - 0.15 * (avg_temp - 55) + rnorm(n, 0, 3)) ad_spend <- round(3500 + 25 * idx + rnorm(n, 0, 500)) new_cafes <- rpois(n, 2) revenue <- round(9000 + 3.2 * ad_spend + 140 * avg_temp - 260 * rain_days + 45 * idx + rnorm(n, 0, 1200)) bikes <- tsibble(month = month, ad_spend = ad_spend, avg_temp = avg_temp, rain_days = rain_days, new_cafes = new_cafes, revenue = revenue, index = month) fits <- bikes |> model( m1 = TSLM(revenue ~ ad_spend), m2 = TSLM(revenue ~ ad_spend + avg_temp), m3 = TSLM(revenue ~ ad_spend + avg_temp + rain_days), m4 = TSLM(revenue ~ ad_spend + avg_temp + rain_days + new_cafes) ) glance(fits) |> select(.model, r_squared) #> # A tibble: 4 x 2 #> .model r_squared #> <chr> <dbl> #> 1 m1 0.454 #> 2 m2 0.905 #> 3 m3 0.917 #> 4 m4 0.921

  

Every added predictor pushes R-squared up: 0.454, then 0.905, then 0.917, then 0.921. The jump from m1 to m2 is large, because average temperature genuinely drives revenue. The jump from m3 to m4 is tiny, 0.917 to 0.921, because new cafes barely helps at all. But it still went up, not down, not even by a hair. R-squared rewards every predictor you add, whether that predictor is real or worthless, so it can never be the thing that tells you to leave one out.