Applied Linear Models

STAT3530 Fall 2026

The (standardized) cheddar model

Centering and scaling, \(x_j \leftarrow (x_j - \bar{x}_j)/s_j\), makes coefficients comparable.

refit <- lm(taste ~ scale(Acetic) + scale(H2S) + scale(Lactic), data = cheddar)
summary(refit)$coefficients
                Estimate Std. Error    t value     Pr(>|t|)
(Intercept)   24.5333333   1.849605 13.2640910 4.413822e-13
scale(Acetic)  0.1871004   2.545979  0.0734886 9.419798e-01
scale(H2S)     8.3200133   2.655260  3.1334077 4.247081e-03
scale(Lactic)  5.9698141   2.618832  2.2795710 3.107948e-02

A one unit increase is now a one SD increase, so H2S is associated with a relatively larger change in mean taste than lactic acid.

Testing a nonlinear functional

Consider the question: is the H2S “effect” (remember, not causal) the largest?

This is actually about the ranking of coefficients; consider the estimand

\[ \theta(\beta) = \text{argmax}_{j>0}\; |\beta_j| \]

The estimate in the standardized cheddar taste model is \(\hat\theta = 2\)

biggest <- function(f) names(which.max(abs(coef(f)[-1])))
round(coef(fit.s), 3)
(Intercept)      Acetic         H2S      Lactic 
     24.533       0.187       8.320       5.970 
biggest(fit.s)
[1] "H2S"

The corresponding population hypothesis is the model constraint

\[ H_0: \theta(\beta) = 2 \qquad\Longleftrightarrow\qquad \{\beta: |\beta_2| \geq |\beta_k| \text{ for each } k > 0\} \]

But this defines a (nonconvex) cone, not a subspace, so you can’t do F tests.

Inference by model-based simulation

Simulation based inference builds a sampling distribution empirically via computation.

The basic idea:

  1. simulate many datasets under a fitted model
  2. compute a test statistic for each simulated dataset
  3. construct a frequency distribution

This is flexible and largely theory free, but it can be tricky to figure out how to simulate from an appropriate model.

Randomization in regression

One approach: fit a model (possibly under \(H_0\)), shuffle residuals to simulate new data.

Do this many (say \(B = 1000\)) times, then build a sampling distribution for any \(T(Y)\).

F test by randomization

Recall the contrast \(H_0: \beta_2 = \beta_3\) in the cheddar model. Here’s a randomization test:

# constrained fit and the observed F statistic
fit.c <- lm(taste ~ Acetic + I(H2S + Lactic), 
            data = cs)
F.obs <- anova(fit.c, fit.s)$F[2]

# recompute the F stat from new data
sim.F <- function(y) {
  m0 <- lm(y ~ Acetic + I(H2S + Lactic), data = cs)
  m1 <- lm(y ~ Acetic + H2S + Lactic, data = cs)
  anova(m0, m1)$F[2]
}

# shuffle residuals 2000 times
set.seed(1)
F.null <- replicate(2000, {
  yb <- fitted(fit.c) + sample(resid(fit.c))
  sim.F(yb)
})

# randomization p value
mean(F.null >= F.obs)
[1] 0.6065
# compare with the parametric test
anova(fit.c, fit.s)$Pr[2]
[1] 0.6031142

Here we’re just approximating the F distribution, so the tests are almost identical.

Parametric bootstrap

Another approach: draw new residuals from the fitted error model \(\tilde\epsilon_i \stackrel{iid}{\sim} N(0, \hat\sigma^2)\).

As before, set \(B\) large and build a sampling distribution for \(T(Y)\).

This is called a “parametric” bootstrap because we are drawing from an error model.

Bootstrap F test

Testing the same contrast in the cheddar model:

# draw new residuals 2000 times
set.seed(1)
F.boot <- replicate(2000, {
  yb <- fitted(fit.c) + rnorm(30, 0, sigma(fit.c))
  sim.F(yb)
})

# bootstrap p value
mean(F.boot >= F.obs)
[1] 0.607

We are again just approximating the theoretical sampling distribution.

However the bootstrap distribution is a bit closer to theory since it assumes normality.

Nonstandard inference

Where model-based simulation really shines is for unusual estimands.

Let’s estimate how often the data ranks H2S as most important to taste: \[ P\left(\theta(\hat{\beta}) = 2\right) \qquad\text{where}\qquad \theta(\beta) = \text{argmax}_{j>0}\; |\beta_j| \]

# helper functions
refit <- function(y) lm(y ~ Acetic + H2S + Lactic, data = cs)
biggest <- function(f) names(which.max(abs(coef(f)[-1])))

# 2000 bootstrap samples
set.seed(1)
theta.boot <- replicate(2000, {
  yb <- fitted(fit.s) + rnorm(30, 0, sigma(fit.s))
  refit(yb) |> biggest()
})

# bootstrap distribution of theta
table(theta.boot) |> prop.table()
theta.boot
Acetic    H2S Lactic 
0.0100 0.7005 0.2895 

Bootstrap confidence intervals

Bootstrapping works for any functional as long as you can simulate under the model.

Suppose you want a confidence interval for the “effect size” of H2S relative to lactic acid: \[ \theta(\beta) = \frac{\beta_2}{\beta_3} \]

Build a sampling distribution and take quantiles.

# functional of interest
theta <- function(f) coef(f)[3]/coef(f)[4]

# 2000 bootstrap samples
set.seed(1)
theta.boot <- replicate(2000, {
  yb <- fitted(fit.s) + rnorm(30, 0, sigma(fit.s))
  refit(yb) |> theta()
})

# bootstrap percentile interval
quantile(theta.boot, c(0.025, 0.975))
     2.5%     97.5% 
0.2990749 7.8234811 

With 95% confidence, the “effect” of H2S on taste is estimated to be between 0.3 and 7.8 times larger than that of lactic acid.

Inverse prediction

Average SAT total, educational expenditure, and the share of test takers in 50 states.

\[ \text{SAT}_i = \beta_0 + \beta_1 \text{spending}_i + \beta_2 \log(\text{takers}_i) + \epsilon_i \]

sat.fit <- lm(total ~ expend + log(takers), data = sat)

Question: what level of spending reaches a target of 975 at 30% participation?

\[\hat{x} = \frac{975 - \hat\beta_0 - \hat\beta_2 \log(\text{takers})}{\hat\beta_1}\]

For the actual data this value is

x.star <- function(f, y0, tk) (y0 - coef(f)[1] - coef(f)[3] * log(tk)) / coef(f)[2]

x.star(sat.fit, y0 = 975, tk = 30)
(Intercept) 
   8.434961 

but can we add uncertainty quantification?

Interval for an inverse prediction

Note \(\hat{x}(\beta)\) is a nonlinear functional. So compute a bootstrap interval:

# helper functions
refit <- function(y) lm(y ~ expend + log(takers), data = sat)

# 2000 bootstrap samples
set.seed(5)
x.null <- replicate(2000, {
  y <- fitted(sat.fit) + sample(resid(sat.fit))
  refit(y) |> x.star(y0 = 975, tk = 30)
})

# bootstrap percentile interval
quantile(x.null, c(0.025, 0.975))
     2.5%     97.5% 
 7.571457 11.308079 

With 95% confidence, the level of state spending required (in 1994-1995) to achieve a mean SAT total of 975 is estimated to be between $7571 and $11,308.

Note that the interval is not symmetric about the point estimate:

# point estimate
x.star(sat.fit, y0 = 975, tk = 30)
(Intercept) 
   8.434961 

Methods of interval construction

The symmetry of the usual parametric confidence intervals comes from the normal sampling distribution.

In principle, one could substitute a bootstrap standard error:

Applying \(\pm 1.96\,\hat{\text{SE}}\):

# log scale
ci <- log(theta(fit.s)) + c(-1, 1)*1.96*sd(lt)

# back-transform
exp(ci)
[1] 0.2776893 6.9946720

This works pretty well in this case, but relies on symmetry of the bootstrap distribution.

Symmetric percentile intervals

The bootstrap distribution for the inverse prediction is heavily skewed.

A normal interval is not appropriate.

To enforce symmetry, take the interval half-width directly from the bootstrap:

\[ \hat\theta \pm c \quad \text{where } P\big(|\hat\theta^{(b)} - \hat\theta| \leq c\big) = 0.95 \]

# symmetric percentile interval
cc <- quantile(abs(x.null - x.hat), 0.95)
c(x.hat - cc, x.hat + cc)
(Intercept) (Intercept) 
   6.226979   10.642943 

But note it forces the lower bound outside the observed range.

A nonlinear relationship

Recall the tree girth data.

# fit a simple linear model
m0 <- lm(Volume ~ Girth, data = trees)

Girth is a circumference. By geometry: \[\text{volume} \propto \text{girth}^2\]

So simply add a quadratic term and fit \[ \text{volume}_i = \beta_0 + \beta_1\, \underbrace{\text{girth}_i}_{f_1(x_i)} + \beta_2\,\underbrace{\text{girth}_i^2}_{f_2(x_i)} + \epsilon_i \]

# fit a polynomial model
m1 <- lm(Volume ~ Girth + I(Girth^2), 
         data = trees)

And we can test for a quadratic relationship.

# test for quadratic relationship
anova(m0, m1)$`Pr(>F)`[2]
[1] 0.000152389

Polynomial models

More broadly, we can fit a \(k^\text{th}\) order polynomial via \[ Y_i = \beta_0 + \beta_1\,x_i + \beta_2\,x_i^2 + \cdots + \beta_k\,x_i^k + \epsilon_i \]

For example, we could try a cubic model for the girth data.

# cubic model
m2 <- lm(Volume ~ poly(Girth, degree = 3), data = trees)

# test comparing cubic and quadratic models
anova(m1, m2)
Analysis of Variance Table

Model 1: Volume ~ Girth + I(Girth^2)
Model 2: Volume ~ poly(Girth, degree = 3)
  Res.Df    RSS Df Sum of Sq      F Pr(>F)
1     28 311.38                           
2     27 302.18  1    9.2022 0.8222 0.3726

No evidence of a cubic relationship.

Aside: poly() in R

The poly() function generates polynomials.

head(trees$Girth, 3)
[1] 8.3 8.6 8.8
# "raw" quadratic terms
poly(trees$Girth, degree = 2, raw = T) |> head(3)
       1     2
[1,] 8.3 68.89
[2,] 8.6 73.96
[3,] 8.8 77.44
# orthogonalized quadratic terms
poly(trees$Girth, degree = 2) |> head(3)
              1         2
[1,] -0.2878929 0.4011468
[2,] -0.2704391 0.3424225
[3,] -0.2588033 0.3050173

By default, these are orthogonalized via Gram-Schmidt. Guesses as to why?

Why orthogonalize?

Notice the partial tests are not helpful because raw polynomials are correlated.

# fit with raw polynomials
fit.r <- lm(Volume ~ poly(Girth, degree = 4, raw = T), 
            data = trees) 
summary(fit.r)$coef[, c(1, 2, 4)]
                                       Estimate   Std. Error  Pr(>|t|)
(Intercept)                       -1.401833e+02 178.34785925 0.4389665
poly(Girth, degree = 4, raw = T)1  4.156852e+01  54.06757554 0.4489249
poly(Girth, degree = 4, raw = T)2 -4.306252e+00   5.96658281 0.4769025
poly(Girth, degree = 4, raw = T)3  2.042494e-01   0.28427175 0.4788565
poly(Girth, degree = 4, raw = T)4 -3.317801e-03   0.00494356 0.5080531

Orthogonalization fixes that.

# fit with orthogonal polynomials
fit.o <- lm(Volume ~ poly(Girth, degree = 4), data = trees) 
summary(fit.o)$coef[, c(1, 2, 4)]
                          Estimate Std. Error     Pr(>|t|)
(Intercept)              30.170968  0.6070656 2.646861e-27
poly(Girth, degree = 4)1 87.073425  3.3799983 4.903516e-20
poly(Girth, degree = 4)2 14.591793  3.3799983 2.036754e-04
poly(Girth, degree = 4)3  3.033512  3.3799983 3.776938e-01
poly(Girth, degree = 4)4 -2.268438  3.3799983 5.080531e-01

Equivalent model specifications

Gram Schmidt orthogonalization gives \(X \to XT\) where \(T\) is an invertible upper-triangular matrix, so \(\text{col}(XT) = \text{col}(X)\) and the least squares projections are identical.

# identical fitted values
max(abs(fitted(q.r) - fitted(q.o)))
[1] 1.421085e-14

In other words, different coordinates, same column space.

Ozone in LA

Ozone concentration against temperature, 330 days in the Los Angeles basin.

The data seem to show a change point around 60\(^\circ\)F.

Could fit two models But better if continuous

A piecewise linear model \[ Y_i = \begin{cases} \beta_0 + \beta_1 x_i + \epsilon_i &\text{if } x_i \leq t \\ \alpha_0 + \alpha_1 x_i + \epsilon_i &\text{if } x_i > t \end{cases} \] with the usual assumptions on \(\epsilon_i\).

Knots

Cut the range at a knot \(t\) via indicator functions:

\[\begin{align*} I_1(x) &= \begin{cases} 1, &x \leq t \\ 0, &x > t \end{cases} \qquad I_2(x) = \begin{cases} 1, &x > t \\ 0, &x \leq t \end{cases} \end{align*}\]

# indicators
I1 <- function(x) as.numeric(x <= 60)
I2 <- function(x) as.numeric(x > 60)

Now write the piecewise model in terms of “new” predictors \[ Y_i = \beta_0 \underbrace{I_1(x_i)}_{\tilde x_{1i}} + \beta_1 \underbrace{x_i I_1(x_i)}_{\tilde x_{2i}} + \alpha_0 \underbrace{I_2(x_i)}_{\tilde x_{3i}} + \alpha_1 \underbrace{x_i I_2(x_i)}_{\tilde x_{4i}} + \epsilon_i \]

# compute "new" predictors
x <- ozone$temp
x1 <- I1(x); x2 <- x*I1(x); x3 <- I2(x); x4 <- x*I2(x)

Fitting the piecewise model

\[ Y_i = \beta_0 I_1(x_i) + \beta_1 x_i I_1(x_i) + \alpha_0 I_2(x_i) + \alpha_1 x_i I_2(x_i) + \epsilon_i \]

# fit
free <- lm(O3 ~ x1 + x2 + x3 + x4 - 1, data = ozone)
summary(free)$coefficients
      Estimate Std. Error    t value     Pr(>|t|)
x1  -1.6879648 2.41912947 -0.6977571 4.858265e-01
x2   0.1603439 0.04875170  3.2889914 1.115329e-03
x3 -27.0046348 3.00187958 -8.9959088 1.967514e-17
x4   0.5994737 0.04096317 14.6344551 1.248113e-37

So the estimates are \[ \hat\beta_0 = -1.69 \qquad \hat\beta_1 = 0.16 \qquad \hat\alpha_0 = -27.00 \qquad \hat\alpha_1 = 0.60 \]

Making ends meet

Let’s add a constraint that the mean has to be continuous in \(x\)

\[ \underbrace{\beta_0 + \beta_1 t}_{\text{left piece at } t} \;=\; \underbrace{\alpha_0 + \alpha_1 t}_{\text{right piece at } t} \quad\Longleftrightarrow\quad \beta_0 + \beta_1 t - \alpha_0 - \alpha_1 t = 0 \]

This is a contrast! \[ c^T\left[\begin{array}{c} \beta \\ \alpha \end{array}\right] = 0 \quad\text{where}\quad c^T = [\,1\;\;t\;\;-1\;\;-t\,] \]

We can reparametrize under the contrast to obtain the “broken stick” model \[ Y_i = \gamma_0 + \gamma_1\,x_i + \gamma_2\, (x_i - t)_+ + \epsilon_i \quad\text{where}\quad (x_i - t)_+ = \max(x_i - t, 0) \]

A bit of algebra and you can show \[ \gamma_0 = \beta_0 \quad \gamma_1 = \beta_1 \quad \underbrace{\gamma_2 = \alpha_1 - \beta_1}_\text{change in slope at knot} \]

Fitting the broken stick

\[ Y_i = \gamma_0 + \gamma_1\,x_i + \gamma_2\, (x_i - t)_+ + \epsilon_i \]

stick <- lm(O3 ~ temp + pmax(temp - 60, 0), data = ozone)

round(coef(stick), 3)
       (Intercept)               temp pmax(temp - 60, 0) 
            -3.100              0.193              0.433 

And since continuity is just a contrast, we can test it!

anova(stick, free)
Analysis of Variance Table

Model 1: O3 ~ temp + pmax(temp - 60, 0)
Model 2: O3 ~ x1 + x2 + x3 + x4 - 1
  Res.Df    RSS Df Sum of Sq      F Pr(>F)
1    327 7217.7                           
2    326 7189.5  1    28.257 1.2813 0.2585

No evidence of a jump.

More knots

Consider now derived variables based on \(K\) knots

\[ 1, \quad x, \quad (x - t_1)_+, \quad \ldots, \quad (x - t_K)_+ \]

Each \((x - t_k)_+\) is flat to the left of its knot, so only changes the slope past \(t_k\).

For example, a broken stick model with \(K = 2\) knots at 50 and 60

two <- lm(O3 ~ temp + 
            pmax(temp - 50, 0) + 
            pmax(temp - 60, 0), 
          data = ozone)

Change of coordinates

Consider \(Y_i = f(x_i) + \epsilon_i\) where \(f\) is piecewise linear with knots at \(t_1, \dots, t_K\).

We parametrize via the linear model \(Y = X\gamma + \epsilon\) where \(X\) has rows \[ p(x_i)^T = \left[\begin{array}{ccccc} 1 &x_i &(x_i - t_1)_+ &\cdots &(x_i - t_K)_+ \end{array}\right] \]

Now let \(\eta_k = f(t_k)\) be the mean at the \(k\)th knot so that, adding boundary knots, \[ \eta = E\gamma \quad\text{where}\quad E = \left[\begin{array}{ccccc} p(t_0) &p(t_1) &\cdots &p(t_{K + 1}) \end{array}\right]^T \]

Now \(E\) is invertible so \(\gamma = E^{-1} \eta\) and the model becomes \[ Y = H\eta + \epsilon \] where \(H\) has rows \[ \left[\begin{array}{cccc} h_0(x_i) &h_1(x_i) &\cdots &h_{K + 1}(x_i)\end{array}\right] \quad\text{with}\quad h_k(z) = p(z)^TE^{-1} \]

Local basis

The functions \(h_k(z) = p(z)^TE^{-1}\) define a local basis for piecewise linear functions.

For any piecewise linear \(f\) with knots at \(t_0, \dots, t_{K + 1}\) \[ f(x) = \sum_{k = 0}^{K + 1} c_k h_k(x) \] for some coefficients \(c_0, \dots, c_{K + 1}\).

  • \(c_k = f(t_k)\) by construction
  • the slope on \([t_{k - 1}, t_k]\) is \[\frac{c_k - c_{k-1}}{t_k - t_{k-1}}\]
Drag a peak to change ck, or a knot sideways.
c =
t =

Revisiting ozone in LA

So instead of using pmax(...), specify a piecewise linear basis:

stick <- lm(O3 ~ bs(temp, knots = c(50, 60), degree = 1, intercept = TRUE) - 1,
            data = ozone)
summary(stick)$coef[, 1:2]
                                                            Estimate Std. Error
bs(temp, knots = c(50, 60), degree = 1, intercept = TRUE)1  2.740269  1.5842119
bs(temp, knots = c(50, 60), degree = 1, intercept = TRUE)2  6.036252  0.6508552
bs(temp, knots = c(50, 60), degree = 1, intercept = TRUE)3  8.712008  0.5291268
bs(temp, knots = c(50, 60), degree = 1, intercept = TRUE)4 28.941656  0.8606079

Level shifts via bases

Why not change the shape of \(h_k(x)\)’s? If we make them indicators, i.e.,

\[ h_k^{(0)}(x) = I(t_{k} < x \leq t_{k+1}) = \begin{cases} 1, &x\in (t_{k}, t_{k + 1}] \\ 0, &\text{otherwise} \end{cases} \]

we obtain a level shift model. Annual flow of the Nile, with one level change at 1898:

era   <- cut(nile$year, c(1871, 1898, 1970), include.lowest = TRUE, dig.lab = 4)
level <- lm(flow ~ era - 1, data = nile)
summary(level)$coef[, 1:2]
                Estimate Std. Error
era[1871,1898] 1097.7500   24.12807
era(1898,1970]  849.9722   15.04649

B-spline basis

We can recover the piecewise linear basis by linearly blending the indicator basis:

\[ h_k^{(1)}(x) = \underbrace{\frac{x - t_k}{t_{k + 1} - t_k}}_{\text{ramp up}} h_k^{(0)}(x) \;+\; \underbrace{\frac{t_{k + 2} - x}{t_{k + 2} - t_{k + 1}}}_{\text{ramp down}} h_{k + 1}^{(0)}(x) \]

Now iterate to obtain increasingly smooth bases:

\[ h_k^{(d)}(x) = \frac{x - t_k}{t_{k + d} - t_k}\, h_k^{(d - 1)}(x) \;+\; \frac{t_{k + d + 1} - x}{t_{k + d + 1} - t_{k + 1}}\, h_{k + 1}^{(d - 1)}(x) \]

Repeating boundary knots to “clamp” bases gives \(k = 0, \dots, K + d\).

Each iteration defines a b-spline basis of degree \(d\).

General nonlinear relationships

Consider a general nonlinear regression model in one predictor:

\[ Y_i = f(x_i) + \epsilon_i \]

Often one can approximate \(f\) by \[ f(x) \approx \sum_{k = 0}^{K + d} c_k h_k^{(d)}(x) \] where \(h_0^{(d)}, \dots, h_{K + d}^{(d)}\) is a b-spline basis.

Drag a peak to change ck, or a knot sideways.
degree  

The bs() function

Just as poly() generates polynomial terms, bs() generates basis expansions.

# downsample and sort for display purposes
temp.sorted <- sample(ozone$temp, 20) |> sort()

# compute basis expansion
temp.bs <- bs(temp.sorted, knots = c(50, 60), 
              degree = 3, intercept = T)

# see structure
temp.bs[1:3, 1:5]
         1       2         3          4 5
[1,] 1.000 0.00000 0.0000000 0.00000000 0
[2,] 0.216 0.55744 0.2152659 0.01129412 0
[3,] 0.064 0.49536 0.4025224 0.03811765 0

Fitting with b-splines

Nonlinear relationships can be estimated using a linear model for the basis expansion: \[ Y_i = \beta_0 h_0^{(d)}(x_i) + \beta_1 h_1^{(d)}(x_i) + \cdots + \beta_K h_{K+d}^{(d)}(x_i) + \epsilon_i \]

# fit a cubic spline model
H   <- bs(ozone$temp, knots = c(50, 60), degree = 3, intercept = TRUE)
cub <- lm(O3 ~ H - 1, data = ozone)
summary(cub)$coef[, 1:2]
    Estimate Std. Error
H1  2.081659   3.811630
H2  4.746948   2.669437
H3  4.499714   1.465026
H4 10.220868   1.511883
H5 26.083061   1.953848
H6 26.157815   1.670010

Motorcycle crash test

Accelerometer readings from a motorcycle crash test. Four knots at degree 3:

Hm   <- bs(mcycle$times, knots = c(15, 20, 30, 40), degree = 3, intercept = TRUE)
mfit <- lm(accel ~ Hm - 1, data = mcycle)

Knots are placed where the function seems to bend.

“Automatic” knot placement

An alternative is to place knots at data quantiles.

bs(mcycle$times, df = 8, intercept = TRUE) |> 
  attr('knots')
[1] 14.68 18.44 26.52 36.20
quantile(mcycle$times, c(0.2, 0.4, 0.6, 0.8))
  20%   40%   60%   80% 
14.68 18.44 26.52 36.20 

Here df is just the number of basis functions.

auto <- lm(accel ~ bs(times, df = 8, intercept = TRUE) - 1, 
           data = mcycle)

How many knots?

Models are nested, so one can compare with an F test.

anova(lm(accel ~ bs(times, df = 5), data = mcycle),
      lm(accel ~ bs(times, df = 8), data = mcycle),
      lm(accel ~ bs(times, df = 12), data = mcycle))
Analysis of Variance Table

Model 1: accel ~ bs(times, df = 5)
Model 2: accel ~ bs(times, df = 8)
Model 3: accel ~ bs(times, df = 12)
  Res.Df    RSS Df Sum of Sq       F  Pr(>F)    
1    127 134906                                 
2    124  65319  3     69587 45.7593 < 2e-16 ***
3    120  60829  4      4490  2.2142 0.07145 .  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1