33 Regression Models for Trend and Seasonality

33.1 Regression Models for Trend and Seasonality

In the previous lecture, a simple seasonal indicator was enough for the ski-sales data. In other situations, the pattern across the year is smoother and more complicated. We want a model that is more flexible than a single winter indicator, but more parsimonious than fitting a separate effect for every month.

By the end of this lecture, students should be able to:

  1. Use factor indicators to represent seasonality.
  2. Build sine and cosine predictors for periodic behaviour.
  3. Explain why sine-plus-cosine allows an unknown phase shift.
  4. Recognise Fourier terms as a systematic version of the same idea.

33.2 Electricity Bills: Month Effects and Seasonal Shape

We consider monthly electricity bills for a household from May 1994 to May 1998. During some months there were paying guests staying in part of the house, so the householder wanted to estimate the extra business-related electricity cost.

After allowing for the number of days in the bill, whether the bill was estimated, the guest weeks, and a time trend, we then examine what seasonal structure remains.

Download Electricity.csv

Electricity <- read.csv("../data/Electricity.csv", header = TRUE)
Electricity |>
    head() |>
    kable()
Bill Days Estimate Guestwks Month Monthlab Time Year
70.18 29 0 0 5 M 5 94
79.05 30 1 0 6 J 6 94
78.60 27 0 0 7 J 7 94
81.45 29 1 0 8 A 8 94
174.25 28 0 4 9 S 9 94
115.20 28 1 0 10 O 10 94

33.2.1 Residual Seasonality After Basic Adjustment

Electricity_lm1 <- lm(Bill ~ Days + Estimate + Guestwks + Time, data = Electricity)
resids <- resid(Electricity_lm1)

plot(resids ~ Time, data = Electricity, ylab = "Residuals")
lines(resids ~ Time, data = Electricity, col = 4)
abline(h = 0, lty = 2)

unlabelled

The plot suggests that some months tend to lie above zero and others below zero, so there is still seasonal structure after adjusting for the non-weather-related variables.

33.2.2 A Separate Effect for Each Month

Electricity_lm2 <- lm(resids ~ factor(Month), data = Electricity)
summary(Electricity_lm2)

Call:
lm(formula = resids ~ factor(Month), data = Electricity)

Residuals:
    Min      1Q  Median      3Q     Max 
-40.822 -11.236   0.350   9.064  31.986 

Coefficients:
                Estimate Std. Error t value Pr(>|t|)    
(Intercept)      -23.929      8.953  -2.673 0.011125 *  
factor(Month)2    19.410     12.661   1.533 0.133785    
factor(Month)3    14.437     12.661   1.140 0.261517    
factor(Month)4     7.877     12.661   0.622 0.537669    
factor(Month)5    21.401     12.012   1.782 0.083015 .  
factor(Month)6    18.597     12.661   1.469 0.150341    
factor(Month)7    47.215     12.661   3.729 0.000642 ***
factor(Month)8    51.110     12.661   4.037 0.000262 ***
factor(Month)9    55.853     12.661   4.411 8.54e-05 ***
factor(Month)10   38.446     12.661   3.036 0.004368 ** 
factor(Month)11    5.300     12.661   0.419 0.677957    
factor(Month)12    8.133     12.661   0.642 0.524586    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 17.91 on 37 degrees of freedom
Multiple R-squared:  0.5794,    Adjusted R-squared:  0.4543 
F-statistic: 4.633 on 11 and 37 DF,  p-value: 0.0002005

This shows that the average residual differs by month. That is useful diagnostically, but using eleven indicator variables for a short series is not very parsimonious.

33.3 Sinusoidal Terms

A smoother alternative is to represent seasonality by sine and cosine waves. Suppose we try \[\sin\left(\frac{2\pi (t - c)}{12}\right)\] for monthly data, where the shift constant \(c\) says where the cycle starts.

An initial guess is to place the zero point near April, because that is close to autumn and might correspond to an “average” month for electricity use.

So we create \[\mathrm{SinTime} = \sin\left(\frac{2\pi (Time - 4)}{12}\right), \qquad \mathrm{CosTime} = \cos\left(\frac{2\pi (Time - 4)}{12}\right).\]

The cosine term matters because we may not know the best phase shift in advance. If the true seasonal pattern is \[A\sin(x + \phi),\] then trigonometry gives \[A\sin(x + \phi) = A\sin(x)\cos(\phi) + A\cos(x)\sin(\phi) = A_1 \sin(x) + A_2 \cos(x).\]

So a regression on both sine and cosine can estimate the amplitude and the phase shift automatically.

33.3.1 First Guess: Shift by Four Months

Electricity <- Electricity |>
    mutate(SinTime = sin((Time - 4) * 2 * pi/12), CosTime = cos((Time - 4) * 2 *
        pi/12))

Electricity_lm3 <- lm(resids ~ SinTime + CosTime, data = Electricity)
summary(Electricity_lm3)

Call:
lm(formula = resids ~ SinTime + CosTime, data = Electricity)

Residuals:
    Min      1Q  Median      3Q     Max 
-43.135 -10.729  -2.141  11.561  37.675 

Coefficients:
              Estimate Std. Error t value Pr(>|t|)    
(Intercept)  -0.003126   2.756424  -0.001  0.99910    
SinTime      18.692200   3.917615   4.771 1.89e-05 ***
CosTime     -10.615094   3.878633  -2.737  0.00879 ** 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 19.29 on 46 degrees of freedom
Multiple R-squared:  0.3932,    Adjusted R-squared:  0.3669 
F-statistic: 14.91 on 2 and 46 DF,  p-value: 1.022e-05
plot(resids ~ Time, data = Electricity, ylab = "Residuals")
lines(resids ~ Time, data = Electricity, col = 4)
lines(Electricity_lm3$fitted.values ~ Time, data = Electricity, col = 2, lwd = 2,
    lty = 2)
abline(h = 0, lty = 2)

unlabelled

The coefficient of CosTime is clearly not zero, which tells us our guessed phase shift is not quite right.

33.3.2 Second Guess: Shift by Five Months

The fitted curve crosses zero closer to Time = 5, so we try that shift instead.

Electricity <- Electricity |>
    mutate(SinTime = sin((Time - 5) * 2 * pi/12), CosTime = cos((Time - 5) * 2 *
        pi/12))

Electricity_lm4 <- lm(resids ~ SinTime + CosTime, data = Electricity)
summary(Electricity_lm4)

Call:
lm(formula = resids ~ SinTime + CosTime, data = Electricity)

Residuals:
    Min      1Q  Median      3Q     Max 
-43.135 -10.729  -2.141  11.561  37.675 

Coefficients:
             Estimate Std. Error t value Pr(>|t|)    
(Intercept) -0.003126   2.756424  -0.001    0.999    
SinTime     21.495467   3.936961   5.460 1.85e-06 ***
CosTime      0.153159   3.858994   0.040    0.969    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 19.29 on 46 degrees of freedom
Multiple R-squared:  0.3932,    Adjusted R-squared:  0.3669 
F-statistic: 14.91 on 2 and 46 DF,  p-value: 1.022e-05

Now the cosine term is no longer doing much work, which suggests the revised phase shift is much more sensible.

33.3.3 A Sharper Seasonal Shape: Add Second Harmonics

The fitted curve still looks a little too smooth. In particular, the rise toward winter is steeper than a simple sine wave suggests, so we add terms with twice the frequency: \[\sin\left(\frac{4\pi (Time - 5)}{12}\right), \qquad \cos\left(\frac{4\pi (Time - 5)}{12}\right).\]

Electricity <- Electricity |>
    mutate(Sin2Time = sin((Time - 5) * 4 * pi/12), Cos2Time = cos((Time - 5) * 4 *
        pi/12))

Electricity_lm5 <- lm(resids ~ SinTime + CosTime + Sin2Time + Cos2Time, data = Electricity)
summary(Electricity_lm5)

Call:
lm(formula = resids ~ SinTime + CosTime + Sin2Time + Cos2Time, 
    data = Electricity)

Residuals:
    Min      1Q  Median      3Q     Max 
-46.043 -13.531   0.651  11.336  34.987 

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept)   0.2067     2.4900   0.083  0.93423    
SinTime      21.4955     3.5550   6.046 2.88e-07 ***
CosTime       0.5727     3.4873   0.164  0.87030    
Sin2Time     -6.1586     3.5550  -1.732  0.09022 .  
Cos2Time    -10.6995     3.4873  -3.068  0.00368 ** 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 17.42 on 44 degrees of freedom
Multiple R-squared:  0.5268,    Adjusted R-squared:  0.4837 
F-statistic: 12.24 on 4 and 44 DF,  p-value: 8.949e-07
anova(Electricity_lm4, Electricity_lm5)
Analysis of Variance Table

Model 1: resids ~ SinTime + CosTime
Model 2: resids ~ SinTime + CosTime + Sin2Time + Cos2Time
  Res.Df   RSS Df Sum of Sq      F   Pr(>F)   
1     46 17112                                
2     44 13346  2    3765.5 6.2072 0.004221 **
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
plot(resids ~ Time, data = Electricity, ylab = "Residuals")
lines(resids ~ Time, data = Electricity, col = 4)
lines(Electricity_lm5$fitted.values ~ Time, data = Electricity, col = 2, lwd = 2,
    lty = 2)
abline(h = 0, lty = 2)

unlabelled

The extra sine and cosine pair gives a noticeably more flexible shape. It captures a steeper winter peak and a flatter period through part of the year without requiring a separate coefficient for each month.

33.3.4 Putting the Model Together

Electricity_lm6 <- lm(Bill ~ Days + Estimate + Guestwks + Time + SinTime + CosTime +
    Sin2Time + Cos2Time, data = Electricity)
summary(Electricity_lm6)

Call:
lm(formula = Bill ~ Days + Estimate + Guestwks + Time + SinTime + 
    CosTime + Sin2Time + Cos2Time, data = Electricity)

Residuals:
    Min      1Q  Median      3Q     Max 
-37.718  -9.495  -0.918   9.350  34.942 

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept)  24.2994    35.3845   0.687   0.4962    
Days          1.6721     1.2801   1.306   0.1989    
Estimate     10.6697     7.0672   1.510   0.1390    
Guestwks     11.0988     1.5241   7.282 7.62e-09 ***
Time          0.1432     0.1984   0.722   0.4745    
SinTime      27.0035     4.0213   6.715 4.68e-08 ***
CosTime      -1.5594     3.6114  -0.432   0.6682    
Sin2Time     -6.1630     3.4560  -1.783   0.0821 .  
Cos2Time     -7.5589     3.6172  -2.090   0.0430 *  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 16.84 on 40 degrees of freedom
Multiple R-squared:  0.7882,    Adjusted R-squared:  0.7458 
F-statistic:  18.6 on 8 and 40 DF,  p-value: 2.985e-11
plot(Bill ~ Time, data = Electricity)
lines(Bill ~ Time, data = Electricity, col = 4)
lines(Electricity_lm6$fitted.values ~ Time, data = Electricity, col = 2, lwd = 2,
    lty = 2)

unlabelled

This final model is still a regression model. The main difference is that the seasonal predictors are now smooth waves rather than month indicators.

33.4 Fourier Terms in a Modern Example

The Seoul bike data use the same idea, but on a daily time scale. The sine and cosine terms are usually written more systematically as Fourier terms.

Download seoul_bike_daily.csv

seoul_daily <- read_csv("../data/seoul_bike_daily.csv", show_col_types = FALSE) |>
    mutate(S1 = sin(2 * pi * day_index/365), C1 = cos(2 * pi * day_index/365), S2 = sin(4 *
        pi * day_index/365), C2 = cos(4 * pi * day_index/365))

seoul_daily |>
    head() |>
    kable()
date day_index rented_bike_count mean_temperature mean_humidity mean_wind_speed mean_visibility mean_dew_point_temperature total_solar_radiation total_rainfall total_snowfall season holiday_any functioning_day_all S1 C1 S2 C2
2017-12-01 1 9539 -2.4541667 45.87500 1.5375000 1870.750 -13.545833 5.97 0.0 0.0 Winter no yes 0.0172134 0.9998518 0.0344216 0.9994074
2017-12-02 2 8523 1.3250000 61.95833 1.7125000 1471.083 -5.716667 6.33 0.0 0.0 Winter no yes 0.0344216 0.9994074 0.0688024 0.9976303
2017-12-03 3 7222 4.8750000 81.54167 1.6125000 455.750 1.883333 3.01 4.0 0.0 Winter no yes 0.0516197 0.9986668 0.1031017 0.9946708
2017-12-04 4 8729 -0.3041667 52.50000 3.4500000 1362.833 -9.925000 6.79 0.1 0.0 Winter no yes 0.0688024 0.9976303 0.1372788 0.9905325
2017-12-05 5 8307 -4.4583333 36.41667 1.1083333 1959.458 -17.425000 0.86 0.0 0.0 Winter no yes 0.0859648 0.9962982 0.1712931 0.9852201
2017-12-06 6 6669 0.0458333 70.79167 0.6958333 1186.875 -5.187500 6.14 1.3 8.6 Winter no yes 0.1031017 0.9946708 0.2051045 0.9787401
seoul_fourier <- lm(rented_bike_count ~ day_index + S1 + C1 + S2 + C2 + mean_temperature +
    total_rainfall + total_solar_radiation, data = seoul_daily)
summary(seoul_fourier)

Call:
lm(formula = rented_bike_count ~ day_index + S1 + C1 + S2 + C2 + 
    mean_temperature + total_rainfall + total_solar_radiation, 
    data = seoul_daily)

Residuals:
     Min       1Q   Median       3Q      Max 
-27475.5  -1526.5    474.7   2918.6  10393.4 

Coefficients:
                       Estimate Std. Error t value Pr(>|t|)    
(Intercept)            7852.642   1635.166   4.802 2.31e-06 ***
day_index                14.427      5.591   2.581  0.01026 *  
S1                    -3429.607   1166.534  -2.940  0.00350 ** 
C1                    -3713.282   1069.412  -3.472  0.00058 ***
S2                    -1009.192    516.924  -1.952  0.05169 .  
C2                     2766.476    421.357   6.566 1.84e-10 ***
mean_temperature        157.521     84.559   1.863  0.06330 .  
total_rainfall         -201.256     29.827  -6.747 6.11e-11 ***
total_solar_radiation   373.863     60.035   6.227 1.34e-09 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 5410 on 356 degrees of freedom
Multiple R-squared:  0.728, Adjusted R-squared:  0.7219 
F-statistic: 119.1 on 8 and 356 DF,  p-value: < 2.2e-16
seoul_daily |>
    mutate(fitted = fitted(seoul_fourier)) |>
    ggplot(aes(x = day_index, y = rented_bike_count)) + geom_line(alpha = 0.6) +
    geom_line(aes(y = fitted), colour = "red", linewidth = 1) + labs(x = "Day index",
    y = "Daily rented bike count", title = "Fourier terms give a smooth seasonal fit for bike demand")

unlabelled

The electricity example introduced the ideas in a small, interpretable setting. The Seoul bike model shows the same method on a larger dataset, where the sine and cosine pairs become a compact way to represent annual seasonality.

33.5 Looking Ahead

So far we have assumed that once the mean structure is well chosen, the residuals behave independently. The next lecture asks whether that is actually true.