Regresión no lineal

Polinomios, escalones, cambio estructural, splines, regresión local y GAMs

Nelson Muriel

Universidad Iberoamericana CDMX

Plan de la sesión

Bloque Tema Min
0 Marco: funciones base 5
1 Regresión polinomial (crudos vs. ortogonales) 30
2 Funciones escalón 10
3 Cambio estructural y codificación de factores 35
4 Splines (de regresión y de suavizado) 35
5 Regresión local (con ejercicio guiado) 30
6 Modelos aditivos generalizados (GAM) 20
7 Cierre: ¿qué método usar? 5

Texto base: ISLR2, Capítulo 7. Los bloques 3 y 5 (parte práctica) van más allá del libro.

Paquetes

library(ISLR2)
library(splines)
library(mgcv)
library(car)
library(strucchange)
library(ggplot2)

data(Wage)
data("mcycle", package = "MASS")

cv_mse <- function(formula, data, K = 10, seed = 1) {
  set.seed(seed)
  folds <- sample(rep(1:K, length.out = nrow(data)))
  y <- eval(formula[[2]], data)
  errs <- sapply(1:K, function(k) {
    fit  <- lm(formula, data = data[folds != k, ])
    pred <- predict(fit, newdata = data[folds == k, ])
    mean((y[folds == k] - pred)^2)
  })
  mean(errs)
}

pred_band <- function(fit, newdata) {
  p <- predict(fit, newdata, se.fit = TRUE)
  data.frame(newdata, fit = p$fit,
             lwr = p$fit - 2 * p$se.fit, upr = p$fit + 2 * p$se.fit)
}

plot_band <- function(df, titulo, color = "darkred") {
  ggplot(df, aes(age, fit)) +
    geom_point(data = Wage, aes(age, wage), inherit.aes = FALSE,
               alpha = 0.15, color = "grey40") +
    geom_ribbon(aes(ymin = lwr, ymax = upr), alpha = 0.25, fill = color) +
    geom_line(color = color, linewidth = 1) +
    labs(title = titulo, x = "age", y = "wage") +
    theme_minimal()
}

age_grid <- data.frame(age = seq(min(Wage$age), max(Wage$age), length.out = 200))

Bloque 0 — Marco

La idea unificadora: funciones base

\[ y = \beta_0 + \beta_1 b_1(x) + \beta_2 b_2(x) + \dots + \beta_K b_K(x) + \varepsilon \]

  • Las \(b_j(\cdot)\) son funciones fijas y conocidas de \(x\)
  • El modelo sigue siendo lineal en \(\beta\) \(\Rightarrow\) lm() y toda la inferencia ya vista (t, F, Wald \(R\beta = q\)) siguen valiendo
Método Funciones base \(b_j(x)\)
Polinomio \(x, x^2, \dots, x^d\)
Escalones \(1\{c_k \le x < c_{k+1}\}\)
Splines potencias truncadas / B-splines

Regresión local y GAMs ya no caben en un único lm(), pero conservan la misma intuición.

Los datos: Wage (ISLR2)

str(Wage[, c("wage", "age", "year", "education", "jobclass")])
'data.frame':   3000 obs. of  5 variables:
 $ wage     : num  75 70.5 131 154.7 75 ...
 $ age      : int  18 24 45 43 50 54 44 30 41 52 ...
 $ year     : int  2006 2004 2003 2003 2005 2008 2009 2008 2006 2004 ...
 $ education: Factor w/ 5 levels "1. < HS Grad",..: 1 4 3 4 2 4 3 3 3 2 ...
 $ jobclass : Factor w/ 2 levels "1. Industrial",..: 1 2 1 2 2 2 1 2 2 2 ...

Salario de 3000 trabajadores del Atlántico medio de EE.UU. Nuestro objetivo inicial: modelar wage en función de age.

Bloque 1 — Regresión polinomial

El modelo

\[ y = \beta_0 + \beta_1 x + \beta_2 x^2 + \dots + \beta_d x^d + \varepsilon \]

Tres formas de escribirlo en R:

fit_raw  <- lm(wage ~ poly(age, 4, raw = TRUE), data = Wage)   # potencias crudas
fit_I    <- lm(wage ~ age + I(age^2) + I(age^3) + I(age^4), data = Wage)
fit_orth <- lm(wage ~ poly(age, 4), data = Wage)               # ortogonales

all.equal(fitted(fit_raw), fitted(fit_I))
[1] TRUE
all.equal(fitted(fit_raw), fitted(fit_orth))
[1] TRUE

Mismo espacio columna \(\Rightarrow\) mismos valores ajustados. Lo que cambia es la parametrización.

Potencias crudas vs. ortogonales

Crudas: \(X = [1, x, x^2, \dots, x^d]\)

  • Columnas casi colineales
  • Coeficientes inestables, difíciles de interpretar
  • Cambian al agregar un grado

Ortogonales: \(Z = [z_0, z_1, \dots, z_d]\) con \(Z'Z = I\)

  • \(\widehat\gamma_j = z_j' y\), cada uno con varianza \(\sigma^2\) y sin correlación
  • Los \(\widehat\gamma_0,\dots,\widehat\gamma_d\) no cambian al pasar a grado \(d+1\)
  • Las pruebas t por término son independientes

Colinealidad de las potencias crudas

X_raw  <- poly(Wage$age, 4, raw = TRUE)
round(cor(X_raw), 3)
      1     2     3     4
1 1.000 0.987 0.950 0.898
2 0.987 1.000 0.988 0.955
3 0.950 0.988 1.000 0.989
4 0.898 0.955 0.989 1.000
c(crudo = kappa(model.matrix(fit_raw), exact = TRUE),
  ortogonal = kappa(model.matrix(fit_orth), exact = TRUE))
       crudo    ortogonal 
5.674673e+08 5.477226e+01 
vif(fit_I)
      age  I(age^2)  I(age^3)  I(age^4) 
 8690.654 81946.588 92502.872 12349.025 

Ortogonalidad verificada

X_orth <- poly(Wage$age, 4)
round(crossprod(X_orth), 10)      # Z'Z = I
  1 2 3 4
1 1 0 0 0
2 0 1 0 0
3 0 0 1 0
4 0 0 0 1
round(colMeans(X_orth), 10)       # columnas centradas
1 2 3 4 
0 0 0 0 
X_orth_m <- matrix(X_orth, nrow = nrow(X_orth)); colnames(X_orth_m) <- paste0("p", 1:4)
vif(lm(wage ~ ., data = data.frame(wage = Wage$wage, X_orth_m)))
p1 p2 p3 p4 
 1  1  1  1 

¿Cómo se construyen? QR de la Vandermonde centrada

\[ V = \big[\,\mathbf 1,\; (x-\bar x),\; (x-\bar x)^2,\; \dots,\; (x-\bar x)^d\,\big] = Q R \]

Las columnas de \(Q\) son ortonormales (Gram–Schmidt). La columna \(j\) es un polinomio de grado \(j\) en \(x\) (a esto se refiere la recurrencia de tres términos).

x <- Wage$age; y <- Wage$wage; n <- length(x); d <- 4
V   <- outer(x - mean(x), 0:d, `^`)
qrV <- qr(V)
Q   <- qr.Q(qrV) %*% diag(sign(diag(qr.R(qrV))))

# Las columnas 2..(d+1) de Q son poly(x, d) (hasta signo)
all.equal(abs(Q[, -1]), abs(unclass(X_orth)[, 1:d]), check.attributes = FALSE)
[1] TRUE

Sin invertir nada: \(\widehat\gamma_j = z_j' y\)

gamma_hat <- drop(crossprod(Q, y))
cbind(gamma = gamma_hat[-1], lm = coef(fit_orth)[-1])
                   gamma         lm
poly(age, 4)1  447.06785  447.06785
poly(age, 4)2 -478.31581 -478.31581
poly(age, 4)3  125.52169  125.52169
poly(age, 4)4  -77.91118  -77.91118

Y los coeficientes en potencias de \((x-\bar x)\) se recuperan con el factor triangular: \(\beta = R^{-1} Q' y\).

beta_c <- backsolve(qr.R(qrV), crossprod(qr.Q(qrV), y))
cbind(backsolve = drop(beta_c), lm = coef(lm(y ~ V - 1)))
       backsolve            lm
V1  1.185769e+02  1.185769e+02
V2  3.924507e-01  3.924507e-01
V3 -4.306237e-02 -4.306237e-02
V4  1.375112e-03  1.375112e-03
V5 -3.203830e-05 -3.203830e-05

Estabilidad al cambiar el grado

for (g in 2:4) {
  cat("\n--- Grado", g, "---\n")
  cat("Ortogonales:", round(coef(lm(wage ~ poly(age, g), data = Wage)), 2), "\n")
  cat("Crudos:     ", round(coef(lm(wage ~ poly(age, g, raw = TRUE), data = Wage)), 6), "\n")
}

--- Grado 2 ---
Ortogonales: 111.7 447.07 -478.32 
Crudos:      -10.42522 5.29403 -0.053005 

--- Grado 3 ---
Ortogonales: 111.7 447.07 -478.32 125.52 
Crudos:      -75.24391 10.18999 -0.168029 0.000849 

--- Grado 4 ---
Ortogonales: 111.7 447.07 -478.32 125.52 -77.91 
Crudos:      -184.1542 21.24552 -0.563859 0.006811 -3.2e-05 

Con ortogonales, los primeros coeficientes no se mueven. Con crudos, cambian por completo.

Elegir el grado: anova() secuencial

fits <- lapply(1:5, function(g) lm(wage ~ poly(age, g), data = Wage))
do.call(anova, fits)
Analysis of Variance Table

Model 1: wage ~ poly(age, g)
Model 2: wage ~ poly(age, g)
Model 3: wage ~ poly(age, g)
Model 4: wage ~ poly(age, g)
Model 5: wage ~ poly(age, g)
  Res.Df     RSS Df Sum of Sq        F    Pr(>F)    
1   2998 5022216                                    
2   2997 4793430  1    228786 143.5931 < 2.2e-16 ***
3   2996 4777674  1     15756   9.8888  0.001679 ** 
4   2995 4771604  1      6070   3.8098  0.051046 .  
5   2994 4770322  1      1283   0.8050  0.369682    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Con ortogonales, \(t^2\) de cada término en el modelo de grado 5 coincide con el \(F\) de la tabla:

round(summary(fits[[5]])$coefficients[-1, "t value"]^2, 4)
poly(age, g)1 poly(age, g)2 poly(age, g)3 poly(age, g)4 poly(age, g)5 
     125.4443      143.5931        9.8888        3.8098        0.8050 

Elegir el grado: validación cruzada

cv_grado <- sapply(1:8, function(g)
  cv_mse(as.formula(paste0("wage ~ poly(age, ", g, ")")), Wage))
data.frame(grado = 1:8, cv_mse = round(cv_grado, 1))
  grado cv_mse
1     1 1676.8
2     2 1601.0
3     3 1596.0
4     4 1594.2
5     5 1594.5
6     6 1593.3
7     7 1593.1
8     8 1594.0

El mínimo está en grado 7, pero la mejora se aplana rápido: parsimonia.

Ajuste con banda de confianza

plot_band(pred_band(fits[[4]], age_grid), "Polinomio de grado 4")

Callback: el punto máximo con el método delta

\(\text{edad}^* = -\beta_1 / (2\beta_2)\) en el modelo cuadrático:

fit_q <- lm(wage ~ age + I(age^2), data = Wage)
deltaMethod(fit_q, "-age / (2*`I(age^2)`)")
                      Estimate       SE    2.5 % 97.5 %
-age/(2 * `I(age^2)`) 49.93890  0.81693 48.33774  51.54

Cuidado: colas y estructura local

fit_m10 <- lm(accel ~ poly(times, 10), data = mcycle)
tt <- data.frame(times = seq(min(mcycle$times), max(mcycle$times), length.out = 300))

plot(mcycle$times, mcycle$accel, pch = 20, col = "grey40", xlab = "times (ms)", ylab = "accel")
lines(tt$times, predict(fit_m10, tt), col = "red", lwd = 2)
lines(tt$times, predict(loess(accel ~ times, mcycle, span = 0.3), tt), col = "blue", lwd = 2)

legend("bottomright", c("Polinomio gr. 10", "loess"), col = c("red", "blue"), lwd = 2)

Los polinomios son globales: un dato en un extremo mueve toda la curva y las colas oscilan.

Bloque 2 — Funciones escalón

El modelo

\[ \begin{split} f(x) =& \beta_0 + \beta_1 C_1(x) + \dots + \beta_K C_K(x), \\ C_k(x) =& 1\{c_k \le x < c_{k+1}\} \end{split} \]

fit_step <- lm(wage ~ cut(age, 4), data = Wage)
round(summary(fit_step)$coefficients, 3)
                       Estimate Std. Error t value Pr(>|t|)
(Intercept)              94.158      1.476  63.790    0.000
cut(age, 4)(33.5,49]     24.053      1.829  13.148    0.000
cut(age, 4)(49,64.5]     23.665      2.068  11.443    0.000
cut(age, 4)(64.5,80.1]    7.641      4.987   1.532    0.126
tapply(Wage$wage, cut(Wage$age, 4), mean)
(17.9,33.5]   (33.5,49]   (49,64.5] (64.5,80.1] 
   94.15839   118.21188   117.82295   101.79898 

El intercepto es la media del primer tramo; los demás coeficientes son diferencias respecto a él. Esto es la codificación treatment que veremos en el Bloque 3.

Cortes elegidos por el analista

cortes <- c(18, 30, 45, 60, 80)
fit_step2 <- lm(wage ~ cut(age, breaks = cortes, include.lowest = TRUE), data = Wage)
head(model.matrix(fit_step2), 3)
       (Intercept) cut(age, breaks = cortes, include.lowest = TRUE)(30,45]
231655           1                                                       0
86582            1                                                       0
161300           1                                                       1
       cut(age, breaks = cortes, include.lowest = TRUE)(45,60]
231655                                                       0
86582                                                        0
161300                                                       0
       cut(age, breaks = cortes, include.lowest = TRUE)(60,80]
231655                                                       0
86582                                                        0
161300                                                       0
plot_band(pred_band(fit_step2, age_grid), "Función escalón (cortes en 30, 45, 60)", color = "darkgreen")

¿Escalones o polinomio?

c(escalon = cv_mse(wage ~ cut(age,
                              breaks = c(18, 30, 45, 60, 80),
                              include.lowest = TRUE),
                   Wage),
  poly4   = cv_mse(wage ~ poly(age, 4), Wage))
 escalon    poly4 
1639.565 1594.229 
  • Ventaja: interpretación directa por tramos, sin supuestos de forma
  • Desventaja: saltos artificiales, sensibles a la elección de los cortes; si hay tendencia, la pierden

Bloque 3 — Cambio estructural y codificación de factores

Cambio estructural con una variable binaria

\[ y_t = \beta_0 + \beta_1 t + \gamma_0 D_t + \gamma_1\, t D_t + \varepsilon_t, \qquad D_t = 1\{t > T_0\} \]

  • \(\gamma_0\): salto en el intercepto en \(T_0\) (si centramos \(t\) en \(T_0\))
  • \(\gamma_1\): cambio en la pendiente (la “deriva” de la tendencia)

Datos: caudal anual del Nilo (1871–1970), presa de Asuán en 1898.

nile <- data.frame(year = as.numeric(time(Nile)), flow = as.numeric(Nile))
T0   <- 1898
nile$t <- nile$year - T0
nile$D <- as.numeric(nile$year > T0)

plot(nile$year, nile$flow, type = "l", xlab = "año", ylab = "caudal")
abline(v = T0, lty = 2)

Ajuste con quiebre

fit_r <- lm(flow ~ t, data = nile)                 # restringido: sin cambio
fit_u <- lm(flow ~ t + D + t:D, data = nile)       # no restringido
round(summary(fit_u)$coefficients, 3)
            Estimate Std. Error t value Pr(>|t|)
(Intercept) 1113.404     47.221  23.579     0.00
t              1.160      3.002   0.386     0.70
D           -288.634     56.246  -5.132     0.00
t:D           -0.469      3.088  -0.152     0.88

El contraste de Chow es un caso de \(R\beta = q\)

\(H_0: \gamma_0 = \gamma_1 = 0\) con orden de coeficientes (Intercept), t, D, t:D:

R <- rbind(c(0, 0, 1, 0),
           c(0, 0, 0, 1))
linearHypothesis(fit_u, hypothesis.matrix = R, rhs = c(0, 0))

Linear hypothesis test:
D = 0
t:D = 0

Model 1: restricted model
Model 2: flow ~ t + D + t:D

  Res.Df     RSS Df Sum of Sq      F    Pr(>F)    
1     98 2221264                                  
2     96 1580175  2    641089 19.474 7.962e-08 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Chow “a mano” con sumas de cuadrados

\[ F = \frac{(RSS_R - (RSS_1 + RSS_2))/k}{(RSS_1 + RSS_2)/(n - 2k)} \]

rss_r <- deviance(fit_r)
rss_1 <- deviance(lm(flow ~ t, data = nile, subset = D == 0))
rss_2 <- deviance(lm(flow ~ t, data = nile, subset = D == 1))
k <- 2; n <- nrow(nile)
F_chow <- ((rss_r - (rss_1 + rss_2)) / k) / ((rss_1 + rss_2) / (n - 2 * k))
c(F = F_chow, p = pf(F_chow, k, n - 2 * k, lower.tail = FALSE))
           F            p 
1.947395e+01 7.962413e-08 
c(deviance(fit_u), rss_1 + rss_2)   # interacciones completas = dos regresiones separadas
[1] 1580175 1580175

¿Y si no conocemos la fecha del quiebre?

Se calcula el estadístico \(F\) para cada fecha posible y se toma el supremo (sup-F). Su distribución no es F: usar valores críticos de strucchange.

fs <- Fstats(Nile ~ 1)
sctest(fs)

    supF test

data:  fs
sup.F = 75.93, p-value = 2.22e-16
plot(fs)
breakdates(breakpoints(Nile ~ 1, breaks = 1))
[1] 1898

Quiebre continuo = spline lineal con un nudo

\[ y_t = \beta_0 + \beta_1 t + \delta\,(t - T_0)_+ + \varepsilon_t \]

nile$hinge <- pmax(0, nile$t)
fit_h  <- lm(flow ~ t + hinge, data = nile)
fit_b1 <- lm(flow ~ bs(year, degree = 1, knots = T0), data = nile)
all.equal(as.numeric(fitted(fit_h)), as.numeric(fitted(fit_b1)))
[1] TRUE

Puente al Bloque 4: la función “bisagra” es el ladrillo de los splines.

Factores con varios niveles: estacionalidad

Muertes y lesiones graves de conductores en el Reino Unido (mensual). Ley del cinturón de seguridad desde febrero de 1983.

sb <- as.data.frame(Seatbelts)
sb$time  <- as.numeric(time(Seatbelts))
sb$month <- factor(as.numeric(cycle(Seatbelts)), levels = 1:12, labels = month.abb)

fit_sb <- lm(log(drivers) ~ time + law + month, data = sb)
round(summary(fit_sb)$coefficients[1:4, ], 4)
            Estimate Std. Error t value Pr(>|t|)
(Intercept)  31.3339     3.3022  9.4889    0e+00
time         -0.0121     0.0017 -7.2346    0e+00
law          -0.1640     0.0238 -6.9046    0e+00
monthFeb     -0.1162     0.0312 -3.7195    3e-04

Advertencia

Son series de tiempo: los errores están autocorrelacionados y los errores estándar de OLS no son confiables.

Box.test(resid(fit_sb), lag = 12, type = "Ljung-Box")

    Box-Ljung test

data:  resid(fit_sb)
X-squared = 202.06, df = 12, p-value < 2.2e-16

Marco general: matriz de contrastes

Un factor con \(k\) niveles se codifica con una matriz \(C\) de \(k \times (k-1)\):

\[ \mu = \beta_0 \mathbf 1 + C \beta, \qquad \mu = (\mu_1, \dots, \mu_k)' \text{ medias de grupo} \]

Codificación Función \(\beta_j\) significa
Treatment contr.treatment \(\mu_{j+1} - \mu_1\)
Suma contr.sum \(\mu_j - \bar\mu\)
Helmert contr.helmert \(\mu_{j+1}\) contra el promedio de los anteriores (escalado)
Polinomial contr.poly tendencia lineal, cuadrática, cúbica… en los niveles

Todas generan el mismo espacio columna: mismo ajuste, mismo RSS, mismo \(F\) global. Sólo cambia la interpretación (y las pruebas t individuales).

Las matrices, con \(k = 4\)

contr.treatment(4)
  2 3 4
1 0 0 0
2 1 0 0
3 0 1 0
4 0 0 1
contr.sum(4)
  [,1] [,2] [,3]
1    1    0    0
2    0    1    0
3    0    0    1
4   -1   -1   -1
contr.helmert(4)
  [,1] [,2] [,3]
1   -1   -1   -1
2    1   -1   -1
3    0    2   -1
4    0    0    3
round(contr.poly(4), 3)
         .L   .Q     .C
[1,] -0.671  0.5 -0.224
[2,] -0.224 -0.5  0.671
[3,]  0.224 -0.5 -0.671
[4,]  0.671  0.5  0.224

Mismo ajuste, distinta interpretación

fit_tr <- lm(log(drivers) ~ time + law + month, sb, contrasts = list(month = "contr.treatment"))
fit_su <- lm(log(drivers) ~ time + law + month, sb, contrasts = list(month = "contr.sum"))
fit_he <- lm(log(drivers) ~ time + law + month, sb, contrasts = list(month = "contr.helmert"))

c(all.equal(fitted(fit_tr), fitted(fit_su)), all.equal(fitted(fit_tr), fitted(fit_he)))
[1] TRUE TRUE
rbind(treatment = anova(fit_tr)["month", ], suma = anova(fit_su)["month", ])
Analysis of Variance Table

Response: log(drivers)
          Df Sum Sq Mean Sq F value    Pr(>F)    
treatment 11 2.5423 0.23112  29.685 < 2.2e-16 ***
suma      11 2.5423 0.23112  29.685 < 2.2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
round(head(coef(fit_tr)[grep("month", names(coef(fit_tr)))], 3), 3)
monthFeb monthMar monthApr 
  -0.116   -0.077   -0.153 
round(head(coef(fit_su)[grep("month", names(coef(fit_su)))], 3), 3)
month1 month2 month3 
 0.007 -0.109 -0.070 

Para meses (cíclicos) contr.poly no tiene sentido. Sí lo tiene para categorías ordenadas.

Factores ordenados: contrastes polinomiales

contr.poly(k) son polinomios ortonormales evaluados en los puntajes \(1, 2, \dots, k\) (los mismos de poly(1:k, k-1)):

is.ordered(Wage$education)
[1] FALSE
options("contrasts")
$contrasts
        unordered           ordered 
"contr.treatment"      "contr.poly" 
fit_ed_poly <- lm(wage ~ education, data = Wage,
                  contrasts = list(education = 'contr.poly'))
round(summary(fit_ed_poly)$coefficients, 3)
            Estimate Std. Error t value Pr(>|t|)
(Intercept)  112.598      0.734 153.317    0.000
education.L   51.315      1.891  27.135    0.000
education.Q    9.173      1.772   5.176    0.000
education.C    3.012      1.463   2.059    0.040
education^4    0.085      1.389   0.061    0.951

Los términos .L, .Q, .C, ^4 miden la tendencia lineal, cuadrática, cúbica y de cuarto grado del salario medio en el nivel educativo.

Nota

Así aparecieron gleason.L, gleason.Q, gleason.C en la actividad de cáncer de próstata.

Treatment vs. polinomial: mismo ajuste

fit_ed_tr <- lm(wage ~ education, data = Wage, contrasts = list(education = "contr.treatment"))
all.equal(fitted(fit_ed_poly), fitted(fit_ed_tr))
[1] TRUE
rbind(poly = anova(fit_ed_poly)["education", ], treatment = anova(fit_ed_tr)["education", ])
Analysis of Variance Table

Response: wage
          Df  Sum Sq Mean Sq F value    Pr(>F)    
poly       4 1226364  306591  229.81 < 2.2e-16 ***
treatment  4 1226364  306591  229.81 < 2.2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

¿Qué significa cada coeficiente?

Con una sola variable, los valores ajustados son las medias de grupo \(\mu\).

mu <- tapply(Wage$wage, Wage$education, mean)

# treatment:  b_j = mu_j - mu_1
cbind(coef = coef(fit_ed_tr)[-1], mu_j_menos_mu1 = mu[-1] - mu[1])
                                coef mu_j_menos_mu1
education2. HS Grad         11.67894       11.67894
education3. Some College    23.65115       23.65115
education4. College Grad    40.32349       40.32349
education5. Advanced Degree 66.81336       66.81336
# poly:  mu = b0 1 + C b  y  C'C = I   =>   b = C' mu
cbind(coef = coef(fit_ed_poly)[-1], Ctmu = drop(crossprod(contr.poly(5), mu)))
                  coef       Ctmu
education.L 51.3146842 51.3146842
education.Q  9.1729381  9.1729381
education.C  3.0118317  3.0118317
education^4  0.0849271  0.0849271
round(crossprod(contr.poly(5)), 10)     # columnas ortonormales
   .L .Q .C ^4
.L  1  0  0  0
.Q  0  1  0  0
.C  0  0  1  0
^4  0  0  0  1

Advertencia sobre contr.poly

  • Supone niveles equiespaciados. Para otros espaciamientos: contr.poly(5, scores = c(0, 8, 12, 16, 20))
  • Útil para contrastar tendencia (¿el efecto es lineal en el nivel?), no para estimar el efecto de cada nivel
  • Si el interés es comparar cada nivel con un nivel de referencia: contr.treatment

Bloque 4 — Splines

Splines de regresión: base de potencias truncadas

Spline cúbico con \(K\) nudos \(\xi_1, \dots, \xi_K\):

\[ f(x) = \beta_0 + \beta_1 x + \beta_2 x^2 + \beta_3 x^3 + \sum_{k=1}^K \beta_{3+k}\,(x - \xi_k)_+^3 \]

  • Continuo, con primera y segunda derivadas continuas en los nudos
  • \(K + 4\) grados de libertad (incluyendo el intercepto)
tp <- function(x, xi) pmax(0, x - xi)^3

fit_tp <- lm(wage ~ age + I(age^2) + I(age^3) + tp(age, 25) + tp(age, 40) + tp(age, 60), data = Wage)
fit_bs <- lm(wage ~ bs(age, knots = c(25, 40, 60)), data = Wage)

all.equal(fitted(fit_tp), fitted(fit_bs), tolerance = 1e-6)
[1] TRUE

bs() usa la base B-spline (numéricamente estable); genera el mismo espacio.

bs() vs. ns()

fit_ns <- lm(wage ~ ns(age, df = 4), data = Wage)

dim(bs(Wage$age, knots = c(25, 40, 60)))     # K + 3 columnas (+ intercepto)
[1] 3000    6
dim(ns(Wage$age, df = 4))                    # K + 1 columnas (sin intercepto)
[1] 3000    4
attr(ns(Wage$age, df = 4), "knots")          # cuantiles 25, 50, 75
[1] 33.75 42.00 51.00
  • Spline natural: lineal fuera de los nudos frontera \(\Rightarrow\) menos varianza en las colas
  • Nudos por defecto: cuantiles de \(x\)

Varianza en los bordes

bordes <- data.frame(age = c(18, 80))
sapply(list(poly4 = fits[[4]], bs = fit_bs, ns = fit_ns),
       function(m) predict(m, bordes, se.fit = TRUE)$se.fit)
      poly4        bs       ns
1  5.298268  9.460394 4.235067
2 13.629642 16.786914 8.395179

Bandas de confianza

plot_band(pred_band(fit_bs, age_grid), "B-spline (nudos 25, 40, 60)")
plot_band(pred_band(fit_ns, age_grid), "Spline natural (4 gl)", color = "steelblue")

Elegir los grados de libertad

cv_bs <- sapply(3:12, function(g) cv_mse(as.formula(paste0("wage ~ bs(age, df = ", g, ")")), Wage))
cv_ns <- sapply(2:12, function(g) cv_mse(as.formula(paste0("wage ~ ns(age, df = ", g, ")")), Wage))
data.frame(df = 3:12, bs = round(cv_bs, 1))
   df     bs
1   3 1596.0
2   4 1594.8
3   5 1593.3
4   6 1593.9
5   7 1594.9
6   8 1594.8
7   9 1594.6
8  10 1593.3
9  11 1594.1
10 12 1595.6
data.frame(df = 2:12, ns = round(cv_ns, 1))
   df     ns
1   2 1602.4
2   3 1595.4
3   4 1595.2
4   5 1595.1
5   6 1594.0
6   7 1593.9
7   8 1593.6
8   9 1594.8
9  10 1596.2
10 11 1594.7
11 12 1597.7

Splines de suavizado

Entre todas las funciones \(g\) con segunda derivada, se minimiza

\[ \sum_{i=1}^n (y_i - g(x_i))^2 + \lambda \int g''(t)^2\,dt \]

  • \(\lambda \to 0\): interpola los datos
  • \(\lambda \to \infty\): recta de mínimos cuadrados
  • La solución es un spline cúbico natural con nudos en cada \(x_i\) distinto, encogido
  • Complejidad medida con los grados de libertad efectivos \(df_\lambda = \mathrm{tr}(S_\lambda)\), con \(\hat y = S_\lambda y\)

Spline de suavizado en R

ss_gcv <- smooth.spline(Wage$age, Wage$wage)       # lambda por GCV
c(df = ss_gcv$df, lambda = ss_gcv$lambda)
       df    lambda 
6.4682992 0.0348627 
dfs <- c(2, 5, 10, 30)
ss_list <- lapply(dfs, function(g) smooth.spline(Wage$age, Wage$wage, df = g))
plot(Wage$age, Wage$wage, pch = 20, col = gray(0.6, 0.25), xlab = "age", ylab = "wage")
for (i in seq_along(dfs)) lines(ss_list[[i]], col = i + 1, lwd = 2)
legend("topright", paste("gl =", dfs), col = 2:5, lwd = 2)

Atajo de validación cruzada leave-one-out

Para un suavizador lineal \(\hat y = S y\):

\[ \mathrm{LOOCV} = \frac{1}{n}\sum_{i=1}^n\left(\frac{y_i - \hat y_i}{1 - S_{ii}}\right)^2 \]

Un solo ajuste en lugar de \(n\). Verificación con una base fija:

kn <- attr(ns(mcycle$times, df = 6), "knots")
bk <- attr(ns(mcycle$times, df = 6), "Boundary.knots")
fit_m <- lm(accel ~ ns(times, knots = kn, Boundary.knots = bk), data = mcycle)

loocv_atajo <- mean((resid(fit_m) / (1 - hatvalues(fit_m)))^2)
loocv_bruto <- mean(sapply(seq_len(nrow(mcycle)), function(i) {
  f <- lm(accel ~ ns(times, knots = kn, Boundary.knots = bk), data = mcycle[-i, ])
  (mcycle$accel[i] - predict(f, mcycle[i, ]))^2
}))
c(atajo = loocv_atajo, bruto = loocv_bruto)
 atajo  bruto 
582.97 582.97 

Todos los métodos sobre Wage

modelos <- list("Polinomio grado 4" = fits[[4]],
                "B-spline (3 nudos)" = fit_bs,
                "Spline natural (4 gl)" = fit_ns)
curvas <- do.call(rbind, lapply(names(modelos), function(nm)
  data.frame(modelo = nm, age = age_grid$age, fit = predict(modelos[[nm]], age_grid))))
curvas <- rbind(curvas,
                data.frame(modelo = "Spline de suavizado (GCV)", age = age_grid$age,
                           fit = predict(ss_gcv, age_grid$age)$y))

ggplot(curvas, aes(age, fit, color = modelo)) +
  geom_line(linewidth = 1) +
  labs(x = "age", y = "wage", color = NULL) +
  theme_minimal() + theme(legend.position = "bottom")

Bloque 5 — Regresión local

El algoritmo

Para cada punto objetivo \(x_0\):

  1. Tomar la fracción span \(= k/n\) de observaciones más cercanas a \(x_0\)
  2. Asignar pesos tricúbicos: \(K(d) = \left(1 - (d/h)^3\right)^3\) si \(d < h\), y \(0\) si no; \(h\) es la distancia al \(k\)-ésimo vecino
  3. Ajustar mínimos cuadrados ponderados con un polinomio en \((x - x_0)\) de grado 1 o 2
  4. \(\hat g(x_0) = \hat\beta_0\) (el intercepto de ese ajuste)

Un modelo distinto en cada \(x_0\); la curva final es la unión de esos valores.

loess() en R

lo_default <- loess(accel ~ times, data = mcycle)             # span = 0.75, grado 2
lo_03      <- loess(accel ~ times, data = mcycle, span = 0.3)

plot(mcycle$times, mcycle$accel, pch = 20, col = "grey40", xlab = "times", ylab = "accel")
lines(tt$times, predict(lo_default, tt), col = "red",  lwd = 2)
lines(tt$times, predict(lo_03, tt),      col = "blue", lwd = 2)
legend("bottomright", c("span = 0.75", "span = 0.3"), col = c("red", "blue"), lwd = 2)

Ejercicio guiado: programa tu propia regresión local

Completa los ___ y evalúa tu función en una malla de puntos:

local_fit <- function(x0, x, y, span = 0.5, degree = 1) {
  n    <- length(x)
  k    <- floor(span * n + 1e-5)       # tamaño de la ventana
  dist <- abs(x - x0)                  # distancias a x0
  h    <- ___                          # ancho de banda: distancia al k-ésimo vecino
  w    <- ___                          # pesos tricúbicos (0 si dist >= h)
  keep <- w > 0
  dx   <- x[keep] - x0
  fit  <- lm(___ ~ poly(dx, degree, raw = TRUE), weights = ___)
  unname(coef(fit)[___])               # ¿qué coeficiente es g_hat(x0)?
}

Pistas: sort(), ifelse(). ¿Por qué el intercepto es el valor ajustado en \(x_0\)?

Solución

local_fit <- function(x0, x, y, span = 0.5, degree = 1) {
  n    <- length(x)
  k    <- floor(span * n + 1e-5)
  dist <- abs(x - x0)
  h    <- sort(dist)[k]
  w    <- ifelse(dist < h, (1 - (dist / h)^3)^3, 0)
  keep <- w > 0
  dx   <- x[keep] - x0
  fit  <- lm(y[keep] ~ poly(dx, degree, raw = TRUE), weights = w[keep])
  unname(coef(fit)[1])      # intercepto = valor ajustado en x0 (dx = 0)
}

Porque el polinomio está escrito en potencias de \((x - x_0)\), y en \(x = x_0\) todas las potencias se anulan salvo la constante.

Comparación con loess()

x <- mcycle$times; y <- mcycle$accel
grid <- seq(min(x), max(x), length.out = 200)

mi_loc  <- sapply(grid, local_fit, x = x, y = y, span = 0.3, degree = 2)
lo_dir  <- loess(accel ~ times, data = mcycle, span = 0.3, degree = 2,
                 control = loess.control(surface = "direct"))
lo_pred <- predict(lo_dir, data.frame(times = grid))

max(abs(mi_loc - lo_pred))
[1] 1.847411e-13
plot(x, y, pch = 20, col = "grey40", xlab = "times", ylab = "accel")
lines(grid, mi_loc, col = "red", lwd = 3)
lines(grid, lo_pred, col = "blue", lwd = 1, lty = 2)
legend("bottomright", c("Mi implementación", "loess"), col = c("red", "blue"), lwd = c(3, 1), lty = c(1, 2))

Debe ser prácticamente cero. Si queda una diferencia mínima, se debe a detalles de implementación en la elección del \(k\)-ésimo vecino. Con surface = "interpolate" (el valor por defecto de loess) se interpola sobre una malla y la diferencia es mayor.

Efecto del span y del grado

par(mfrow = c(1, 2))
for (gr in 1:2) {
  plot(x, y, pch = 20, col = "grey60", main = paste("Grado", gr), xlab = "times", ylab = "accel")
  for (i in 1:3) {
    sp <- c(0.1, 0.3, 0.6)[i]
    lines(grid, sapply(grid, local_fit, x = x, y = y, span = sp, degree = gr), col = i + 1, lwd = 2)
  }
  legend("bottomright", paste("span =", c(0.1, 0.3, 0.6)), col = 2:4, lwd = 2, cex = 0.8)
}
par(mfrow = c(1, 1))

span chico: poco sesgo, mucha varianza. span grande: lo contrario. Es el mismo dilema que df en splines.

Elegir el span por LOOCV

loocv_span <- function(span, degree = 2) {
  mean(sapply(seq_along(y), function(i)
    (y[i] - local_fit(x[i], x[-i], y[-i], span, degree))^2))
}
spans   <- seq(0.15, 0.8, by = 0.05)
cv_span <- sapply(spans, loocv_span)

plot(spans, cv_span, type = "b", xlab = "span", ylab = "LOOCV")
spans[which.min(cv_span)]
[1] 0.35

Grados de libertad efectivos de loess: traza de la matriz suavizadora.

lo_03$trace.hat
[1] 12.43856

Bloque 6 — GAM

Modelos aditivos generalizados

\[ y = \beta_0 + f_1(x_1) + f_2(x_2) + \dots + f_p(x_p) + \varepsilon \]

  • Cada \(f_j\) es una función suave estimada (splines, loess, …)
  • Aditivo: el efecto de cada variable es interpretable, manteniendo las demás fijas
  • Se estima por backfitting o, en mgcv, por máxima verosimilitud penalizada, eligiendo la suavidad con REML
  • Se extiende a respuestas binomial, Poisson, etc. (family = ...)

Un GAM con lm(): splines naturales como bloques

gam_lm <- lm(wage ~ ns(year, 4) + ns(age, 5) + education, data = Wage)
summary(gam_lm)$r.squared
[1] 0.2930184

Cada término es lineal en sus funciones base, así que lm() basta cuando los grados de libertad son fijos.

GAM con mgcv

year sólo toma 7 valores distintos, así que limitamos k.

gam1 <- gam(wage ~ s(year, k = 4) + s(age) + education, data = Wage, method = "REML")
summary(gam1)

Family: gaussian 
Link function: identity 

Formula:
wage ~ s(year, k = 4) + s(age) + education

Parametric coefficients:
                            Estimate Std. Error t value Pr(>|t|)    
(Intercept)                   85.441      2.152  39.696  < 2e-16 ***
education2. HS Grad           10.988      2.428   4.525 6.27e-06 ***
education3. Some College      23.534      2.558   9.202  < 2e-16 ***
education4. College Grad      38.197      2.543  15.023  < 2e-16 ***
education5. Advanced Degree   62.572      2.759  22.682  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Approximate significance of smooth terms:
          edf Ref.df     F  p-value    
s(year) 1.153  1.287 10.36 0.000433 ***
s(age)  5.067  6.156 36.84  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

R-sq.(adj) =   0.29   Deviance explained = 29.3%
-REML =  14930  Scale est. = 1235.6    n = 3000

Los efectos estimados

plot(gam1, pages = 1, all.terms = TRUE, shade = TRUE)

Comparación de modelos anidados

gam_m1 <- gam(wage ~ s(age) + education, data = Wage, method = "REML")           # sin year
gam_m2 <- gam(wage ~ year + s(age) + education, data = Wage, method = "REML")    # year lineal
gam_m3 <- gam1                                                                   # year suave
anova(gam_m1, gam_m2, gam_m3, test = "F")
Analysis of Deviance Table

Model 1: wage ~ s(age) + education
Model 2: wage ~ year + s(age) + education
Model 3: wage ~ s(year, k = 4) + s(age) + education
  Resid. Df Resid. Dev     Df Deviance       F    Pr(>F)    
1    2988.6    3711635                                      
2    2987.4    3693397 1.1416  18237.6 12.9293 0.0001644 ***
3    2986.3    3692988 1.0943    409.1  0.3026 0.6028675    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Misma lógica que las pruebas F de la sesión anterior; ahora con grados de libertad efectivos.

GAM logístico: \(P(\text{wage} > 250)\)

Quitamos “< HS Grad”: ahí no hay observaciones con wage > 250 (separación).

wage_hs <- droplevels(subset(Wage, education != "1. < HS Grad"))
gam_log <- gam(I(wage > 250) ~ year + s(age) + education, data = wage_hs,
               family = binomial, method = "REML")
summary(gam_log)$s.table
            edf   Ref.df   Chi.sq   p-value
s(age) 3.407801 4.269413 9.891976 0.0449851

Caso actuarial: frecuencia de siniestros vs. edad

Datos simulados con frecuencia verdadera en forma de U. Poisson con exposición como offset:

\[ \log E[N_i] = \log(\text{expo}_i) + f(\text{edad}_i) \]

set.seed(2026)
n_p  <- 5000
edad <- runif(n_p, 18, 80)
expo <- runif(n_p, 0.3, 1)
freq_verdadera <- function(a) exp(-2.3 + 0.0015 * (a - 45)^2)
sim <- data.frame(edad, expo, siniestros = rpois(n_p, expo * freq_verdadera(edad)))

gam_p <- gam(siniestros ~ s(edad) + offset(log(expo)), family = poisson,
             data = sim, method = "REML")

¿Recupera el GAM la forma verdadera?

malla <- data.frame(edad = seq(18, 80, length.out = 200), expo = 1)
malla$estimada  <- predict(gam_p, malla, type = "response")
malla$verdadera <- freq_verdadera(malla$edad)

ggplot(malla, aes(edad)) +
  geom_line(aes(y = estimada, color = "GAM estimado"), linewidth = 1) +
  geom_line(aes(y = verdadera, color = "Verdadera"), linewidth = 1, linetype = 2) +
  labs(y = "Frecuencia esperada (exposición = 1)", color = NULL) +
  theme_minimal()

Sin imponer una forma cuadrática, el GAM aprende la U. Es el análogo flexible del turning point que probamos con el método delta.

Bloque 7 — Cierre

¿Qué método usar?

Método Qué ajusta Complejidad Inferencia
Polinomio potencias globales grado lm exacta
Escalones constantes por tramos cortes lm exacta
Spline de regresión polinomios por tramos suaves nudos / gl lm exacta
Spline de suavizado spline con penalización \(\lambda\) (gl efectivos) aproximada
Regresión local ajuste ponderado alrededor de \(x_0\) span, grado aproximada
GAM suma de funciones suaves suavidad de cada \(f_j\) aproximada (mgcv)

Mensajes clave

  • Casi todo es funciones base + lm(): la inferencia de las sesiones anteriores se hereda
  • Ortogonal vs. crudo y treatment vs. poly son lo mismo: misma bondad de ajuste, distinta parametrización
  • La complejidad (grado, nudos, \(\lambda\), span) se elige por validación cruzada, no a ojo
  • Regresión local y splines de suavizado: sin forma global asumida, a cambio de inferencia aproximada
  • GAM: flexibilidad por variable sin perder interpretabilidad

Referencias

  • James, G., Witten, D., Hastie, T., Tibshirani, R. (2021). An Introduction to Statistical Learning with Applications in R (2.ª ed.), Cap. 7. Springer.
  • Chow, G. C. (1960). Tests of equality between sets of coefficients in two linear regressions. Econometrica, 28(3), 591–605.
  • Zeileis, A., Leisch, F., Hornik, K., Kleiber, C. (2002). strucchange: An R package for testing for structural change in linear regression models. Journal of Statistical Software, 7(2).
  • Cleveland, W. S. (1979). Robust locally weighted regression and smoothing scatterplots. JASA, 74(368), 829–836.
  • Wood, S. N. (2017). Generalized Additive Models: An Introduction with R (2.ª ed.). CRC Press.