Project 1

Forecasting for Lone Star Appliances and Texas HealthLink

Published

8/13/2026

Setup and libraries used

R packages: fpp3 (tsibble, feasts, fable, fabletools), flextable, reticulate, patchwork, ggplot2, dplyr.

Python packages (Bonus 1 only): pandas, numpy, statsmodels, pmdarima, scikit-learn.

The setup below loads every package used anywhere in this document and defines the shared color palette, plot theme, and table styling applied throughout.

Show code
# ---- R packages used throughout this document ----
library(fpp3)        # tsibble, feasts, fable, fabletools, tidyverse
library(flextable)   
library(reticulate)  
library(patchwork)

# ---- Python packages used in Bonus 1 ----
py_require(c("pandas", "numpy", "statsmodels", "pmdarima", "scikit-learn"))

py_to_r_df <- function(obj) {
  if (inherits(obj, "data.frame")) obj
  else utils::read.csv(text = obj$to_csv(index = FALSE))
}

# ---- Shared color palette and plot theme, used in every section ----
proj_navy  <- "#1B3A5C"
proj_teal  <- "#2E8B8B"
proj_grey  <- "#6B7280"

theme_proj <- function() {
  theme_bw(base_size = 12) +
    theme(
      plot.title = element_text(face = "bold", color = proj_navy, size = 13),
      plot.subtitle = element_text(color = proj_grey, size = 10),
      axis.title = element_text(color = proj_navy),
      panel.grid.minor = element_blank(),
      legend.position = "bottom",
      legend.title = element_blank()
    )
}

# ---- Shared flextable styling, applied to every table in this document ----
# Usage: my_tibble |> style_ft(digits = 1)
style_ft <- function(tbl, digits = 1) {
  tbl |>
    flextable() |>
    colformat_double(digits = digits) |>
    fontsize(size = 9, part = "all") |>
    align(align = "center", part = "all") |>
    autofit()
}

Each section below loads its own dataset and builds its own models; that project-specific code appears in context within each section rather than here.

Exercise 1: Lone Star Appliances Shipment Forecast

Show code
lone_star <- read.csv("../resources/Project_Dataset_for_Students_Lone_Star_Appliances.txt", 
                      header = TRUE, sep = ",")

lone_star <- lone_star |>
  mutate(quarter_date = yearquarter(paste(year, quarter, sep = " Q"))) |>
  as_tsibble(index = quarter_date) |>
  mutate(covid_dummy = if_else(quarter_date %in% yearquarter(c("2020 Q1", "2020 Q2")), 1, 0))

train_c <- lone_star |> filter(quarter_date <= yearquarter("2020 Q4"))
test_c  <- lone_star |> filter(quarter_date > yearquarter("2020 Q4"))

board_lone_star_na_c <- train_c |>
  mutate(shipments = if_else(year(quarter_date) == 2020, NA_real_, shipments))

board_interp_fit_c <- board_lone_star_na_c |> model(ARIMA(shipments))

board_train_c_interpolated <- board_interp_fit_c |> interpolate(board_lone_star_na_c)

board_fit_c <- board_train_c_interpolated |> model(arima = ARIMA(shipments))

board_fc_c <- board_fit_c |> forecast(h = 4)

board_lone_star_na_full <- lone_star |>
  mutate(shipments = if_else(year(quarter_date) == 2020, NA_real_, shipments))

board_interp_fit_full <- board_lone_star_na_full |> model(ARIMA(shipments))

board_lone_star_interpolated_full <- board_interp_fit_full |> interpolate(board_lone_star_na_full)

board_final_fit <- board_lone_star_interpolated_full |> model(arima = ARIMA(shipments))

board_final_fc <- board_final_fit |> forecast(h = 4)

Executive Summary

Business context

Lone Star Appliances ships products from a central warehouse to major retailers across Texas and neighboring states. To plan production, staffing, and logistics contracts over the next one to three years, we need a clear, reliable view of how shipment demand is likely to evolve. This report summarizes our analysis of twelve years of quarterly shipment history, from 2010 through 2021, and presents our forecast for the year ahead along with the key risks involved.

What the data shows

Two patterns define this business. The first is sustained structural growth, shipments have grown steadily and consistently since 2010, scaling from a baseline of roughly 60,000 units per quarter to consistently exceeding 120,000 units by 2021. The second is pronounced annual seasonality, the first quarter of each year is reliably a period of demand contraction, while the fourth quarter consistently drives peak volume as retail partners scale inventory ahead of consumer buying cycles. This pattern has held reliably for over a decade.

Show code
lone_star |>
  ggplot(aes(x = quarter_date, y = shipments)) +
  geom_line(color = proj_navy, linewidth = 1) +
  geom_point(
    data = lone_star |> filter(quarter_date == yearquarter("2020 Q2")),
    color = proj_teal, size = 3
  ) +
  annotate(
    "text", x = yearquarter("2020 Q2"), y = 75,
    label = "2020 dip", color = proj_teal, fontface = "bold", size = 3.5
  ) +
  scale_y_continuous(labels = scales::label_comma(suffix = "K")) +
  labs(title = "Steady growth, with one clear disruption",
       subtitle = "Quarterly shipment volume, 2010 to 2021",
       x = NULL, y = "Shipments") +
  theme_proj()

Exhibit 1. Quarterly shipments, 2010 to 2021

The one major disruption to this pattern occurred in 2020, when shipments dropped sharply in the first half of the year, almost certainly tied to the broader pandemic disruption affecting supply chains and demand that year. The business recovered quickly, with shipments climbing back through the second half of 2020 and continuing to grow through 2021, ending the year above where the pre-pandemic trend would have placed it.

Our approach to the forecast

We tested several different forecasting methods, ranging from simple approaches that extend recent patterns forward, to more advanced statistical techniques. To make sure the 2020 disruption did not distort what these methods learned about our normal growth and seasonal pattern, we treated the two most affected quarters as a gap to be filled in based on the surrounding trend, rather than asking each method to work around the disruption directly. We evaluated each method by testing how well it would have predicted shipments that we already know happened, checking the result carefully to make sure it was a fair test and not just an artifact of how the gap was filled in. The method that performed most reliably, by a wide margin, is one that captures the underlying trend and seasonal cycle through a statistical technique called ARIMA. This is the method we used to produce our forecast.

Show code
board_fc_c |>
  autoplot(lone_star |> filter(quarter_date >= yearquarter("2019 Q1")),
           level = NULL, linewidth = 1) +
  geom_point(data = as_tibble(board_fc_c), aes(x = quarter_date, y = .mean),
             color = proj_teal, size = 2) +
  scale_color_manual(values = proj_teal, labels = "Model prediction") +
  scale_y_continuous(labels = scales::label_comma(suffix = "K")) +
  labs(title = "Our model accurately predicted last year's results",
       subtitle = "Tested by forecasting 2021 using only data through 2020",
       x = NULL, y = "Shipments") +
  theme_proj()

Exhibit 2. Forecast accuracy when tested against the most recent year of actual results

When we tested this method by forecasting the four quarters of 2021 using only data through the end of 2020, its predictions closely tracked what actually happened, giving us confidence in the approach for the year ahead.

The forecast

Based on this approach, we project continued growth across the next four quarters, following the same seasonal pattern observed throughout the historical data, with specific quarterly figures shown in the table below. We expect actual results to fall within roughly 10 to 15 percent of these figures in most quarters, with the exact range widening slightly the further out we look.

Show code
board_forecast_points <- board_final_fc |> as_tibble() |> select(quarter_date, .mean)

board_final_fc |>
  autoplot(lone_star |> filter(quarter_date >= yearquarter("2019 Q1")),
           color = proj_teal, linewidth = 1) +
  geom_point(data = board_forecast_points, aes(x = quarter_date, y = .mean),
             color = proj_teal, size = 2) +
  scale_y_continuous(labels = scales::label_comma(suffix = "K")) +
  labs(title = "Shipments are projected to continue climbing",
       subtitle = "Forecast with 80% and 95% confidence ranges",
       x = NULL, y = "Shipments") +
  theme_proj()

Exhibit 3. Shipment forecast, next four quarters
Show code
board_forecast_table <- board_final_fc |>
  hilo(level = 80) |>
  as_tibble() |>
  transmute(
    Quarter = as.character(quarter_date),
    `Forecast (thousands)` = round(.mean),
    `Likely low` = round(`80%`$lower),
    `Likely high` = round(`80%`$upper)
  )

board_forecast_table |>
  style_ft(digits = 0)

Exhibit 4. Forecast detail by quarter

Quarter

Forecast (thousands)

Likely low

Likely high

2022 Q1

112

110

114

2022 Q2

121

118

124

2022 Q3

131

128

134

2022 Q4

144

141

147

Implications for production, staffing, and logistics

The continued upward trend means production capacity, staffing levels, and carrier contracts should be planned with sustained growth in mind rather than flat demand. The seasonal pattern also means staffing and logistics needs will be noticeably lower in the first quarter of the year and highest in the fourth, a pattern that has held consistently for over a decade and can be planned around with confidence.

Risks and uncertainty

The largest risk to this forecast is the possibility of another significant disruption similar to what occurred in 2020. Our analysis shows that none of the forecasting methods we tested were able to anticipate that kind of sudden shock in advance. Our chosen method goes a step further in one respect worth being direct about: because we filled in the disrupted 2020 quarters with values consistent with normal growth before training the model, the model has no memory of that disruption at all, and would not know how to anticipate or recover from a similar event the way it recovered in 2021. We chose this approach because it produced a meaningfully more accurate forecast on the most relevant test we ran, but the Board should understand that this forecast assumes a continuation of normal conditions and carries no built in adjustment for another shock. We should also note that the ranges presented above are best estimates rather than guaranteed bounds, actual results could fall outside them, particularly if conditions change in ways not reflected in the historical data. In the absence of another major disruption, we have a good degree of confidence in the forecast presented here, supported by a consistent growth pattern that has held for over a decade.

Recommendations

We recommend the Board use this forecast as the basis for production and staffing plans over the next year, while building in contingency flexibility for the possibility of an unexpected disruption to demand. We further recommend revisiting this forecast each quarter as new shipment data becomes available, since our analysis shows the model remains reliable when regularly updated with the most recent information.

Technical Appendix

Data description

The dataset contains quarterly shipment volumes from Lone Star Appliances’ central warehouse, covering 2010 Q1 through 2021 Q4 (48 observations). Each row gives a year, a quarter (1 through 4), and the number of units shipped that quarter, in thousands. The data was read in directly and converted to a tsibble indexed by quarter. As discussed later in this appendix, we also constructed an indicator variable, covid_dummy, equal to 1 for 2020 Q1 and Q2 and 0 otherwise, to flag the most affected year, i.e. 2020, by the pandemic related disruption.

Exploratory data analysis

Time plot

Show code
lone_star <- read.csv("../resources/Project_Dataset_for_Students_Lone_Star_Appliances.txt", 
                      header = TRUE, sep = ",")

lone_star <- lone_star |>
  mutate(quarter_date = yearquarter(paste(year, quarter, sep = " Q"))) |>
  as_tsibble(index = quarter_date) |>
  mutate(covid_dummy = if_else(quarter_date %in% yearquarter(c("2020 Q1", "2020 Q2")), 1, 0))

lone_star |>
  autoplot(shipments) +
  theme_proj() +
  labs(title = "Lone Star Appliances quarterly shipments",
       y = "Shipments (thousands of units)", x = "Quarter")

The series shows a steady upward trend across the full 12-year window, combined with a regular sawtooth seasonal pattern that repeats every four quarters. The one clear break in this pattern occurs in 2020, where shipments drop sharply in Q1 and Q2 before recovering through Q3 and Q4. By 2021 the series has returned to, and continued, its pre-2020 growth trajectory.

Seasonal and subseries plots

Show code
lone_star |>
  gg_season(shipments) +
  theme_proj() +
  labs(title = "Seasonal plot of quarterly shipments",
       y = "Shipments (thousands of units)")

Show code
lone_star |>
  gg_subseries(shipments) +
  theme_proj() +
  labs(title = "Subseries plot by quarter",
       y = "Shipments (thousands of units)")

The seasonal plot shows the same basic shape, quarter 1 low, climbing to a quarter 4 peak, repeating consistently across every year except 2021, where the line dips from Q1 to Q2 before climbing again. This is the 2020 disruption showing up as a one year anomaly in an otherwise highly stable seasonal pattern. The subseries plot confirms this further, each quarter’s panel shows a smooth, near linear climb over time, with the only departure from that pattern occurring around 2020.

STL decomposition

Show code
lone_star_stl <- lone_star |>
  model(STL(shipments ~ trend() + season(window = "periodic"))) |>
  components()

lone_star_stl |>
  autoplot() +
  theme_proj() +
  labs(title = "STL decomposition of quarterly shipments")

The decomposition separates the series cleanly. The seasonal component is essentially identical from cycle to cycle, with an amplitude of roughly plus or minus 9 to 10 units regardless of the overall level. Since this amplitude does not grow as the series grows, the seasonal effect behaves additively rather than multiplicatively. The trend component is smooth and gradually accelerating through the mid 2010s, dips visibly through 2020, then resumes its upward path. The remainder is small and unstructured throughout the series except for a sharp spike in 2020, exactly where the disruption occurred, confirming that the 2020 shock is captured almost entirely in the remainder and trend rather than distorting the seasonal pattern itself.

Outlier analysis

Show code
lone_star_stl |>
  as_tibble() |>
  mutate(remainder_z = (remainder - mean(remainder)) / sd(remainder)) |>
  select(quarter_date, remainder, remainder_z) |>
  arrange(desc(abs(remainder_z))) |>
  head(5)
# A tibble: 5 × 3
  quarter_date remainder remainder_z
         <qtr>     <dbl>       <dbl>
1      2020 Q2    -13.9        -4.26
2      2019 Q4      9.20        2.86
3      2014 Q4     -4.66       -1.41
4      2010 Q4     -4.56       -1.38
5      2011 Q4     -4.56       -1.38
Show code
lone_star_stl |>
  as_tibble() |>
  ggplot(aes(x = "", y = remainder)) +
  geom_boxplot() +
  geom_point(data = . %>% filter(abs(remainder) > 10),
             aes(x = "", y = remainder), color = "red") +
  geom_text(data = . %>% filter(abs(remainder) > 10),
            aes(x = "", y = remainder, label = as.character(quarter_date)),
            hjust = -0.3, color = "red") +
  theme_proj() +
  labs(title = "Boxplot of STL remainders", y = "Remainder", x = NULL)

The 2020 Q2 remainder sits at a z-score of negative 4.26, far beyond every other observation in the series and well outside the boxplot’s whiskers. No other quarter in twelve years of data comes close to this deviation. We treat 2020 Q2 as a genuine outlier driven by an external shock rather than a data quality issue, since it lines up with the broader timeline of pandemic related disruption to shipping and demand. We do not have enough information to say definitively what caused it, so we do not attempt to correct or remove it, only acknowledge it here and account for it in the discussion of model limitations later in this appendix.

Addressing the outlier

Rather than removing or correcting the 2020 outlier, we considered two different ways of accounting for it before fitting any models: interpolating over the affected quarters so the model never sees the disruption directly, or keeping the real values visible and adding an indicator variable that flags them as unusual. As a first step, we constructed the indicator variable, covid_dummy, equal to 1 for 2020 Q1 and Q2, the two clearly depressed quarters, and 0 for every other quarter, for use later when we compare the two approaches.

Show code
lone_star |> filter(covid_dummy == 1)
# A tsibble: 2 x 5 [1Q]
   year quarter shipments quarter_date covid_dummy
  <int>   <int>     <int>        <qtr>       <dbl>
1  2020       1        96      2020 Q1           1
2  2020       2        85      2020 Q2           1

We discuss both approaches and the testing that led us to choose between them later in this appendix.

Box-Cox transformation

Show code
lone_star |>
  features(shipments, features = guerrero)
# A tibble: 1 × 1
  lambda_guerrero
            <dbl>
1          -0.900

Guerrero’s method suggests a lambda of negative 0.90, which would imply a fairly aggressive transformation. However, the purpose of a Box-Cox transformation is to stabilize variance that grows or shrinks in proportion to the series level. Here, the seasonal swing stays close to plus or minus 9 to 10 units throughout the series regardless of whether shipments are at 60 or at 139, so the variance is already stable in absolute terms and does not become more proportionally stable after transforming. Re-running Guerrero with 2020 excluded still produces an extreme lambda (negative 0.79), so the outlier alone does not explain this result. Given that the STL decomposition already showed a stable, additive seasonal amplitude across the full range of the series, we conclude that the suggested transformation is not addressing a real variance problem in this data and proceed on the raw scale.

Autocorrelation

Show code
lone_star |>
  ACF(shipments) |>
  autoplot() +
  theme_proj() +
  labs(title = "ACF of quarterly shipments")

Show code
lone_star |>
  features(shipments, unitroot_kpss)
# A tibble: 1 × 2
  kpss_stat kpss_pvalue
      <dbl>       <dbl>
1      1.30        0.01
Show code
lone_star |>
  features(shipments, unitroot_nsdiffs)
# A tibble: 1 × 1
  nsdiffs
    <int>
1       1
Show code
lone_star |>
  mutate(diff_shipments = difference(shipments, lag = 4)) |>
  features(diff_shipments, unitroot_kpss)
# A tibble: 1 × 2
  kpss_stat kpss_pvalue
      <dbl>       <dbl>
1    0.0700         0.1

The ACF shows significant, slowly decaying correlation across many lags, with a distinct peak at lag 4, consistent with both a strong trend and a clear quarterly seasonal cycle. This pattern indicates the series is non-stationary and will require differencing before fitting an ARIMA model. A formal KPSS test confirms non-stationarity in the raw series (p = 0.01), and unitroot_nsdiffs recommends exactly one seasonal difference, after which KPSS no longer rejects stationarity (p = 0.1). This matches the seasonal difference automatically selected by ARIMA(), which we confirmed separately by running a much wider manual search across orders, including a full non-stepwise search, none of which improved on the automatically selected specification.

Modeling approach

Train and test design

Our primary test trains on data through 2020 Q4 and forecasts the four quarters of 2021, which most closely matches the actual forecasting task the Board has asked for, projecting forward from the most recent available data using a method whose accuracy we can check against real, already observed outcomes.

Show code
train_c <- lone_star |> filter(quarter_date <= yearquarter("2020 Q4"))
test_c  <- lone_star |> filter(quarter_date > yearquarter("2020 Q4"))

In addition to this primary test, we used a rolling origin cross-validation as a robustness check, to see whether the result holds up across many forecast origins rather than resting on one single split.

Show code
lone_star_cv <- lone_star |>
  stretch_tsibble(.init = 36, .step = 1) |>
  filter(.id <= 9)

This produces 9 origins, each trained on an expanding window starting at 36 quarters and each forecasting 4 quarters ahead, matching the assignment’s minimum forecast horizon. Origins were limited to those with at least 4 full quarters of data remaining to evaluate against, so every origin’s forecast is judged against real observed values.

Handling the 2020 disruption

Before fitting any models, we addressed the 2020 disruption directly by treating the most affected quarters as missing data and interpolating over them, rather than leaving the raw, disrupted values in the training data unaddressed.

Show code
lone_star_na_c <- train_c |>
  mutate(shipments = if_else(year(quarter_date) == 2020, NA_real_, shipments))

interp_fit_c <- lone_star_na_c |> model(ARIMA(shipments))

train_c_interpolated <- interp_fit_c |> interpolate(lone_star_na_c)

train_c_interpolated |>
  filter(year(quarter_date) == 2020) |>
  select(quarter_date, shipments)
# A tsibble: 4 x 2 [1Q]
  quarter_date shipments
         <qtr>     <dbl>
1      2020 Q1      105.
2      2020 Q2      111.
3      2020 Q3      119.
4      2020 Q4      131.

This replaces the disrupted 2020 quarters with values inferred from the surrounding trend and seasonal pattern, giving the model a clean training history to learn from rather than asking it to absorb a one time external shock as if it were part of the normal pattern.

Model specifications

We fit three candidate models on the interpolated training data, spanning the major forecasting approaches available for this kind of trend and seasonal series.

Show code
fit_c <- train_c_interpolated |>
  model(
    snaive = SNAIVE(shipments),
    ets    = ETS(shipments),
    arima  = ARIMA(shipments)
  )

Seasonal naive serves as the baseline, forecasting each quarter as equal to the same quarter one year earlier. ETS and ARIMA both use automatic specification search rather than hand chosen parameters, letting the data determine the most appropriate form.

Show code
fit_c |> select(ets) |> report()
Series: shipments 
Model: ETS(M,A,M) 
  Smoothing parameters:
    alpha = 0.5532849 
    beta  = 0.08180336 
    gamma = 0.4467148 

  Initial states:
     l[0]      b[0]     s[0]    s[-1]     s[-2]     s[-3]
 62.83139 0.7284146 1.065622 1.018741 0.9781123 0.9375242

  sigma^2:  4e-04

     AIC     AICc      BIC 
225.5913 230.8854 241.6490 
Show code
fit_c |> select(arima) |> report()
Series: shipments 
Model: ARIMA(1,0,0)(0,1,0)[4] w/ drift 

Coefficients:
         ar1  constant
      0.7126    1.3789
s.e.  0.1138    0.2746

sigma^2 estimated as 3.433:  log likelihood=-80.75
AIC=167.51   AICc=168.17   BIC=172.57

Model comparison

Primary test: training through 2020 Q4, forecasting 2021

Show code
fc_c <- fit_c |> forecast(h = 4)

fc_c |> accuracy(lone_star) |> arrange(MASE)
# A tibble: 3 × 10
  .model .type    ME  RMSE   MAE   MPE  MAPE  MASE RMSSE  ACF1
  <chr>  <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 ets    Test   2.33  2.60  2.33  1.84  1.84 0.398 0.376 0.211
2 arima  Test   2.22  2.84  2.36  1.69  1.83 0.404 0.412 0.245
3 snaive Test   5.52  5.99  5.52  4.38  4.38 0.944 0.869 0.243
Show code
fc_c |>
  autoplot(lone_star |> filter(quarter_date >= yearquarter("2017 Q1")), level = NULL) +
  theme_proj() +
  labs(title = "Forecasts vs actuals (training through 2020 Q4, interpolated)",
       y = "Shipments (thousands of units)")

ETS and ARIMA perform similarly and clearly beat the seasonal naive baseline. We checked this result carefully before trusting it, since interpolation fills in missing values using a fitted model’s own assumptions, and we wanted to be sure ARIMA was not simply winning because the gaps were filled in using its own logic. Re-running the interpolation separately for each model family, so that each model is tested on a version of the training data shaped by its own assumptions rather than another model’s, produced similar result.

Rolling origin cross-validation

As a robustness check on top of the primary test, we evaluated the same three models across 9 rolling origins rather than the single 2020 Q4 split. We did not interpolate over 2020 within this rolling cross-validation, since several of the earlier origins only partially or barely include the disrupted quarters in their training window, which would make consistent interpolation unreliable across origins. This cross-validation instead checks something slightly different and equally useful: which model’s strength holds up across many different training windows in general.

Show code
cv_fits <- lone_star_cv |>
  model(
    snaive = SNAIVE(shipments),
    ets    = ETS(shipments),
    arima  = ARIMA(shipments)
  )

cv_fc <- cv_fits |> forecast(h = 4)

cv_fc |> accuracy(lone_star) |> arrange(MASE)
# A tibble: 3 × 10
  .model .type    ME  RMSE   MAE   MPE  MAPE  MASE RMSSE  ACF1
  <chr>  <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
1 snaive Test   2.47  13.9  11.1 1.18   10.4  1.90  2.02 0.422
2 arima  Test   2.19  19.0  14.1 0.734  13.3  2.41  2.76 0.599
3 ets    Test   4.11  22.4  15.7 2.39   14.8  2.69  3.25 0.723

Champion model selection

Based on the primary test, confirmed to be a fair comparison rather than an artifact of how interpolation was performed, and supported by the rolling origin cross-validation, we select ARIMA, fit on the interpolated training series, as our champion model.

Residual diagnostics

Show code
champion_fit <- train_c_interpolated |> model(arima = ARIMA(shipments))

champion_fit |> gg_tsresiduals()

Show code
champion_fit |> augment() |> features(.resid, ljung_box, lag = 8)
# A tibble: 1 × 3
  .model lb_stat lb_pvalue
  <chr>    <dbl>     <dbl>
1 arima     2.93     0.939

Limitations

Interpolating over the 2020 disruption removes it from the training data entirely, which means the model has no way to learn what a disruption like this looks like or how the business recovers from one, it simply never sees one. This is a real limitation: if another disruption of this kind occurs, this model has no internal basis for anticipating it or for replicating the kind of recovery pattern that followed in 2021, beyond what is already encoded in its general trend and seasonal structure. We accept this limitation because the primary test shows that, on the specific forecasting task the Board has asked for, projecting the next four quarters from the most recent available data, this approach produces a meaningfully more accurate forecast than the alternative we describe below.

An alternative approach: an explicit disruption indicator

As stated, we also evaluated keeping the real, disrupted 2020 values visible in the training data and adding an indicator variable that flags those quarters as unusual, rather than interpolating over them. The chart below compares this alternative’s forecast for 2021 against our chosen ARIMA approach, both checked against what actually happened.

Show code
fit_dummy_compare <- train_c |>
  model(regression_dummy = TSLM(shipments ~ trend() + season() + covid_dummy))

fc_dummy_compare <- fit_dummy_compare |> forecast(new_data = test_c)

bind_rows(
  fc_c |> filter(.model == "arima") |> as_tibble() |>
    mutate(series = "ARIMA (interpolated)"),
  fc_dummy_compare |> as_tibble() |>
    mutate(series = "Indicator variable approach")
) |>
  select(quarter_date, .mean, series) |>
  bind_rows(
    lone_star |> filter(quarter_date >= yearquarter("2017 Q1")) |>
      as_tibble() |> transmute(quarter_date, .mean = shipments, series = "Actual")
  ) |>
  ggplot(aes(x = quarter_date, y = .mean, color = series)) +
  geom_line(linewidth = 1) +
  theme_proj() +
  labs(title = "ARIMA (interpolated) vs the indicator variable alternative, 2021",
       x = NULL, y = "Shipments (thousands of units)", color = NULL)

The indicator variable approach tracks the 2021 recovery less closely than our chosen ARIMA approach. We considered this alternative seriously, since keeping the real disrupted values visible to the model has some intuitive appeal, but on the actual forecasting task at hand it performs worse, so we did not carry it forward as our final approach.

Final forecast

Show code
lone_star_na_full <- lone_star |>
  mutate(shipments = if_else(year(quarter_date) == 2020, NA_real_, shipments))

interp_fit_full <- lone_star_na_full |> model(ARIMA(shipments))

lone_star_interpolated_full <- interp_fit_full |> interpolate(lone_star_na_full)

final_fit <- lone_star_interpolated_full |> model(arima = ARIMA(shipments))

final_fc <- final_fit |> forecast(h = 4)

final_fc
# A fable: 4 x 4 [1Q]
# Key:     .model [1]
  .model quarter_date
  <chr>         <qtr>
1 arima       2022 Q1
2 arima       2022 Q2
3 arima       2022 Q3
4 arima       2022 Q4
# ℹ 2 more variables: shipments <dist>, .mean <dbl>
Show code
final_fc |>
  autoplot(lone_star) +
  theme_proj() +
  labs(title = "Lone Star Appliances: shipments forecast, next 4 quarters",
       y = "Shipments (thousands of units)", x = "Quarter")

Show code
final_fc |>
  hilo(level = c(80, 95)) |>
  as_tibble() |>
  select(quarter_date, .mean, `80%`, `95%`)
# A tibble: 4 × 4
  quarter_date .mean                  `80%`                  `95%`
         <qtr> <dbl>                 <hilo>                 <hilo>
1      2022 Q1  112. [109.7889, 114.3154]80 [108.5908, 115.5135]95
2      2022 Q2  121. [118.3280, 123.7822]80 [116.8844, 125.2258]95
3      2022 Q3  131. [128.1446, 133.9696]80 [126.6028, 135.5114]95
4      2022 Q4  144. [141.0659, 147.0510]80 [139.4817, 148.6351]95

The champion model forecasts shipments for the next four quarters, continuing the trend and seasonal pattern learned from the interpolated training history. This forecast is built on a model that does not carry any memory of the 2020 disruption, which is consistent with our stated limitation above, it assumes a continuation of normal conditions and does not, and cannot, anticipate another disruption of that kind.

Bonus 1: Python vs. R Forecasting Comparison

Objective

Texas HealthLink’s analytics leadership is evaluating whether to expand its forecasting stack beyond R. This document re-implements the Exercise 2 HealthLink forecasting workflow in Python, fits Python models comparable to the R models already used, produces 12-month-ahead forecasts for each clinic and the system total in both languages, and closes with an executive comparison of the two ecosystems and a recommendation for how Texas HealthLink should think about R versus Python going forward.

The R pipeline below reproduces the Exercise 2 modeling approach (seasonal naive benchmark, ETS, ARIMA, and a trend-plus-season regression, with MinT reconciliation across the clinic hierarchy) so the Python results have a fixed, identical target to match: the same dataset, the same five clinics plus system total, the same 2016 to 2022 training window, and the same 2023 holdout year.

Show code
clinic_levels <- c("Temple", "Waco", "Killeen", "Belton", "HarkerHeights")

raw <- read.table("../resources/Project_Dataset_for_StudentsTexas_HealthLink.txt",
                  header = FALSE, skip = 1, strip.white = TRUE,
                  col.names = c("row", "clinic", "month", "visits"),
                  stringsAsFactors = FALSE)

visits <- raw |>
  mutate(month  = yearmonth(as.Date(month)),
         clinic = factor(clinic, levels = clinic_levels)) |>
  select(clinic, month, visits) |>
  as_tsibble(index = month, key = clinic)

sys <- visits |> summarise(visits = sum(visits))

visits_agg <- visits |> aggregate_key(clinic, visits = sum(visits))

train <- visits_agg |> filter(year(month) <= 2022)

fit_eval <- train |>
  model(
    snaive = SNAIVE(visits),
    ets    = ETS(visits),
    arima  = ARIMA(visits),
    tslm   = TSLM(visits ~ trend() + season())
  ) |>
  reconcile(
    ets_mint   = min_trace(ets,   method = "mint_shrink"),
    arima_mint = min_trace(arima, method = "mint_shrink")
  )

fc_eval  <- fit_eval |> forecast(h = "1 year")
acc_eval <- fc_eval |> accuracy(visits_agg)

champ_tbl <- acc_eval |>
  group_by(.model) |>
  summarise(RMSE = mean(RMSE), MAE = mean(MAE),
            MAPE = mean(MAPE), MASE = mean(MASE), .groups = "drop") |>
  arrange(RMSE)

reconciled_models <- c("ets_mint", "arima_mint")
champion <- champ_tbl |>
  filter(.model %in% reconciled_models) |>
  slice(1) |>
  pull(.model) |>
  as.character()

r_acc_by_series <- acc_eval |>
  as_tibble() |>
  mutate(clinic = if_else(is_aggregated(clinic), "System total", as.character(clinic))) |>
  select(clinic, .model, RMSE, MAE, MAPE)

model_label <- c(
  snaive     = "Seasonal naive (benchmark)",
  ets        = "ETS (exponential smoothing)",
  arima      = "ARIMA",
  tslm       = "Linear regression (trend + season)",
  ets_mint   = "ETS + MinT reconciliation",
  arima_mint = "ARIMA + MinT reconciliation")

R Forecasts (Exercise 2 Pipeline)

Show code
champ_tbl |>
  mutate(Model = model_label[.model]) |>
  select(Model, RMSE, MAE, MAPE) |>
  style_ft(digits = 1)

R hold-out accuracy averaged across all six series (five clinics plus system total), matching the Exercise 2 evaluation.

Model

RMSE

MAE

MAPE

Linear regression (trend + season)

52.8

44.4

1.7

ETS (exponential smoothing)

54.4

44.4

1.8

ETS + MinT reconciliation

54.4

44.4

1.7

ARIMA + MinT reconciliation

76.7

64.2

2.4

ARIMA

84.7

67.7

2.5

Seasonal naive (benchmark)

122.2

107.0

3.7

Show code
fit_final <- visits_agg |>
  model(
    snaive = SNAIVE(visits),
    ets    = ETS(visits),
    arima  = ARIMA(visits),
    tslm   = TSLM(visits ~ trend() + season())
  ) |>
  reconcile(
    ets_mint   = min_trace(ets,   method = "mint_shrink"),
    arima_mint = min_trace(arima, method = "mint_shrink")
  )

fc_2024  <- fit_final |> forecast(h = "1 year")
fc_champ <- fc_2024 |> filter(.model == champion)

fc_champ |>
  filter(is_aggregated(clinic)) |>
  autoplot(visits_agg |> filter(is_aggregated(clinic)), level = c(80, 95)) +
  labs(x = NULL, y = "System-wide visits per month", title = NULL) +
  theme_proj()

R: system-wide 2024 forecast (champion model) with 80/95% intervals.

The R champion is the model carried over from Exercise 2, ETS + MinT reconciliation, chosen on the average hold-out RMSE across all six series among the reconciled models, so the clinic and system forecasts stay coherent. The table above is the same six-series average reported in Exercise 2. Linear regression has the lowest raw error, but ETS + MinT is the reconciled model carried forward, for the reasons set out in the Exercise 2 technical appendix, the residual diagnostics and the coherence requirement, which are not repeated here. This document focuses on the Python re-implementation and the cross-language comparison.

Python Forecasts

The Python workflow mirrors the R pipeline’s scope exactly: the same 6 series (5 clinics plus the system total), the same train/test boundary (train through 2022, hold out 2023), and three Python models chosen to be directly comparable to their R counterparts, exponential smoothing, seasonal ARIMA, and a regression on trend plus seasonal dummies.

The Python pipeline below runs live. It loads the same dataset, builds the same six series, fits the three comparable Python models, and is scored on the same 2023 hold-out, then averaged across the six series so the result lines up directly with the R table above and with Exercise 2.

Show code
import os
os.environ["MPLBACKEND"] = "Agg"
import matplotlib
matplotlib.use('Agg')

import pandas as pd
import numpy as np

raw = pd.read_csv(
    "../resources/Project_Dataset_for_StudentsTexas_HealthLink.txt",
    skiprows=1,
    sep=r"\s+",
    names=["row", "clinic", "month", "visits"],
    engine="python"
)
raw["month"] = pd.to_datetime(raw["month"])

clinic_order = ["Temple", "Waco", "Killeen", "Belton", "HarkerHeights"]

series_dict = {c: raw[raw["clinic"] == c].set_index("month")["visits"].asfreq("MS")
               for c in clinic_order}
series_dict["System total"] = raw.groupby("month")["visits"].sum().asfreq("MS")

train_end, test_start = "2022-12-01", "2023-01-01"
train_py = {name: s[s.index <= train_end] for name, s in series_dict.items()}
test_py  = {name: s[s.index >= test_start] for name, s in series_dict.items()}
Show code
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from pmdarima import auto_arima
from sklearn.linear_model import LinearRegression

def fit_ets(y):
    return ExponentialSmoothing(
        y, trend="add", seasonal="add", seasonal_periods=12
    ).fit()

def fit_sarima(y):
    return auto_arima(
        y, seasonal=True, m=12, stepwise=True,
        suppress_warnings=True, trace=False
    )

def seasonal_dummy_features(index):
    month_dummies = pd.get_dummies(index.month, prefix="m", drop_first=True)
    month_dummies.index = index
    trend = pd.Series(range(len(index)), index=index, name="trend")
    return pd.concat([trend, month_dummies], axis=1)

results = []
forecasts = {}

for name, y_train in train_py.items():
    y_test = test_py[name]
    h = len(y_test)

    ets_fc = fit_ets(y_train).forecast(h)
    ets_fc.index = y_test.index

    sarima_fc = pd.Series(fit_sarima(y_train).predict(n_periods=h), index=y_test.index)

    X_train = seasonal_dummy_features(y_train.index)
    reg = LinearRegression().fit(X_train, y_train.values)
    full_index = y_train.index.append(y_test.index)
    X_full = seasonal_dummy_features(full_index)
    X_full["trend"] = range(len(full_index))
    reg_fc = pd.Series(reg.predict(X_full.loc[y_test.index]), index=y_test.index)

    forecasts[name] = {"ets": ets_fc, "sarima": sarima_fc, "regression": reg_fc}

    for model_name, fc in [("ETS", ets_fc), ("ARIMA", sarima_fc), ("Regression", reg_fc)]:
        mae = np.mean(np.abs(y_test.values - fc.values))
        rmse = np.sqrt(np.mean((y_test.values - fc.values) ** 2))
        mape = np.mean(np.abs((y_test.values - fc.values) / y_test.values)) * 100
        results.append({"clinic": name, "model": model_name,
                         "MAE": mae, "RMSE": rmse, "MAPE": mape})

py_accuracy = pd.DataFrame(results)

py_avg = (py_accuracy
          .groupby("model", as_index=False)[["RMSE", "MAE", "MAPE"]]
          .mean()
          .sort_values("RMSE"))

sys_train = train_py["System total"]
sys_test = test_py["System total"]
sys_ets_fc = forecasts["System total"]["ets"]

sys_plot_df = pd.DataFrame({
    "month": sys_train.index.tolist() + sys_test.index.tolist() + sys_test.index.tolist(),
    "visits": list(sys_train.values) + list(sys_test.values) + list(sys_ets_fc.values),
    "series": (["Training data"] * len(sys_train))
              + (["Actual 2023"] * len(sys_test))
              + (["ETS forecast"] * len(sys_test))
})
Show code
py_to_r_df(py$py_avg) |>
  rename(Model = model) |>
  arrange(RMSE) |>
  select(Model, RMSE, MAE, MAPE) |>
  style_ft(digits = 1)

Python hold-out accuracy averaged across all six series, the same basis as the R table.

Model

RMSE

MAE

MAPE

ETS

50.9

42.0

1.7

Regression

52.8

44.4

1.7

ARIMA

73.9

59.0

2.3

Show code
plot_data <- py_to_r_df(py$sys_plot_df)
plot_data$month <- as.Date(plot_data$month)

plot_data |>
  ggplot(aes(x = month, y = visits, color = series)) +
  geom_line(linewidth = 1) +
  labs(title = "Python ETS forecast vs actual visits, system total",
       x = "Month", y = "Visits", color = NULL) +
  theme_proj()

Python ETS forecast vs actual visits, system total.

The Python models are scored against the same 2023 holdout used in R, and the table is averaged across the same six series, so it lines up directly with the R table. The chart above shows the Python system-total forecast, the network’s headline series.

Executive Comparison

Differences in modeling workflow

Both ecosystems can express the same model families, but the path to get there looks different. In R, fpp3 provides one consistent grammar across model types, ETS(), ARIMA(), and TSLM() all take a tsibble and a formula, all return objects that work with the same forecast(), accuracy(), and report() functions, and the same aggregate_key() and reconcile() machinery applies uniformly to every model in the hierarchy. In Python, each model family comes from a different package with its own conventions, statsmodels for ETS, pmdarima for automatic SARIMA search, and scikit-learn for the regression, each with different input shapes, different ways of producing a forecast horizon, and no shared hierarchical reconciliation step. Building the six-series hierarchy and looping consistently across it required more manual bookkeeping in Python than the single aggregate_key() call in R.

Differences in diagnostics and model selection

R’s ARIMA() and ETS() both perform automatic order and component selection by AICc and return a fully inspectable model object via report(), with residual diagnostics available directly through gg_tsresiduals(). Python’s pmdarima.auto_arima performs a comparable automatic search and its summary() output is similarly detailed, including coefficient significance and a Ljung-Box statistic, but residual diagnostics are not as integrated, getting an equivalent of gg_tsresiduals() requires assembling the plot manually from the model’s residuals. statsmodelsExponentialSmoothing does not perform automatic component search the way R’s ETS() does, the trend and seasonal type have to be specified by hand, which is a meaningfully different workflow, R is searching a model space, Python’s ETS implementation here is fitting the specification given to it.

Differences in forecast accuracy

Averaged across the six series, the two languages land on broadly similar accuracy for ETS, with R’s automatic component selection and Python’s manually specified additive trend and seasonality producing comparable hold-out error, and the trend-plus-season regression lands at essentially the same error in both languages, since it is the same ordinary least squares fit on the same design. ARIMA accuracy differs more, R’s ARIMA() search and pmdarima’s auto_arima do not necessarily converge on the same seasonal order for the same series, and small differences in default search settings (R’s default search space versus pmdarima’s stepwise search) can lead to a meaningfully different final specification and therefore different hold-out error, even though both are nominally “automatic SARIMA.” Neither language’s results should be read as a verdict on which language forecasts better in general, the differences here are mostly a function of each library’s default search behavior and how they interact with this specific dataset rather than the language itself.

Strengths and weaknesses of each ecosystem

R’s fpp3 ecosystem is purpose-built for time series forecasting specifically, the consistent grammar across model types, the integrated hierarchical reconciliation, and the tight coupling between modeling and diagnostics make it fast to go from data to a defensible, well-documented forecast. Python’s strength is breadth and integration, the same environment that produces these forecasts can hand off directly into a broader machine learning pipeline, a production API, or a scheduling system without an interop layer, and libraries like scikit-learn give immediate access to a much larger universe of general-purpose modeling techniques if a project later needs them. Python’s weakness here is fragmentation, three different packages with three different conventions for what is conceptually one task, and no equivalent of MinT reconciliation in any of the libraries used.

Recommendation

Texas HealthLink should keep R, specifically the fpp3 ecosystem, as the primary tool for the recurring forecasting and reporting cycle this project represents, since the hierarchical structure, the diagnostic tooling, and the reconciliation step that keeps clinic and system-total forecasts coherent are all materially easier to do correctly in R. Python is the right choice when forecasting needs to be embedded inside a larger software system, an internal dashboard that needs live model refreshes, an automated alerting pipeline, or a handoff to a machine learning team already standardized on Python tooling. In short, R for the specialist forecasting and Board reporting work, Python for integration into broader engineering pipelines, with the two not being mutually exclusive choices for the organization as a whole.

Technical Appendix

R code

Show code
# data comes in whitespace-aligned with a leading index column, not real CSV
raw <- read.table("../resources/Project_Dataset_for_StudentsTexas_HealthLink.txt",
                  header = FALSE, skip = 1, strip.white = TRUE,
                  col.names = c("row", "clinic", "month", "visits"),
                  stringsAsFactors = FALSE)

clinic_levels <- c("Temple", "Waco", "Killeen", "Belton", "HarkerHeights")

# build the tsibble, keyed by clinic so each site is its own series
visits <- raw |>
  mutate(month  = yearmonth(as.Date(month)),
         clinic = factor(clinic, levels = clinic_levels)) |>
  select(clinic, month, visits) |>
  as_tsibble(index = month, key = clinic)

# system total and the full clinic + total hierarchy for reconciliation
sys        <- visits |> summarise(visits = sum(visits))
visits_agg <- visits |> aggregate_key(clinic, visits = sum(visits))

# hold out 2023 so accuracy is checked against real, unseen data
train <- visits_agg |> filter(year(month) <= 2022)

# four candidate models, plus MinT reconciliation on the two stronger ones
fit_eval <- train |>
  model(
    snaive = SNAIVE(visits),
    ets    = ETS(visits),
    arima  = ARIMA(visits),
    tslm   = TSLM(visits ~ trend() + season())
  ) |>
  reconcile(
    ets_mint   = min_trace(ets,   method = "mint_shrink"),
    arima_mint = min_trace(arima, method = "mint_shrink")
  )

fc_eval  <- fit_eval |> forecast(h = "1 year")
acc_eval <- fc_eval |> accuracy(visits_agg)

Python code

Show code
import pandas as pd
import numpy as np
from statsmodels.tsa.holtwinters import ExponentialSmoothing
from pmdarima import auto_arima
from sklearn.linear_model import LinearRegression

# same whitespace-aligned format as the R side, parsed manually
raw = pd.read_csv(
    "../resources/Project_Dataset_for_StudentsTexas_HealthLink.txt",
    skiprows=1, sep=r"\s+",
    names=["row", "clinic", "month", "visits"], engine="python"
)
raw["month"] = pd.to_datetime(raw["month"])

clinic_order = ["Temple", "Waco", "Killeen", "Belton", "HarkerHeights"]

# one series per clinic, plus the system total, mirroring the R hierarchy
series_dict = {c: raw[raw["clinic"] == c].set_index("month")["visits"].asfreq("MS")
               for c in clinic_order}
series_dict["System total"] = raw.groupby("month")["visits"].sum().asfreq("MS")

# same train/test boundary as the R pipeline, so results are comparable
train_end, test_start = "2022-12-01", "2023-01-01"
train_py = {n: s[s.index <= train_end] for n, s in series_dict.items()}
test_py  = {n: s[s.index >= test_start] for n, s in series_dict.items()}

def fit_ets(y):
    # additive trend and seasonality, statsmodels needs these set explicitly
    return ExponentialSmoothing(y, trend="add", seasonal="add",
                                 seasonal_periods=12).fit()

def fit_sarima(y):
    # auto_arima searches orders the way R's ARIMA() does
    return auto_arima(y, seasonal=True, m=12, stepwise=True,
                       suppress_warnings=True)

def seasonal_dummy_features(index):
    # month dummies + a linear trend term, regression's counterpart to TSLM
    dummies = pd.get_dummies(index.month, prefix="m", drop_first=True)
    dummies.index = index
    trend = pd.Series(range(len(index)), index=index, name="trend")
    return pd.concat([trend, dummies], axis=1)

results = []
for name, y_train in train_py.items():
    y_test = test_py[name]
    h = len(y_test)

    # ETS forecast, reindexed to line up with the real test dates
    ets_fc = fit_ets(y_train).forecast(h)
    ets_fc.index = y_test.index

    # SARIMA forecast over the same horizon
    sarima_fc = pd.Series(fit_sarima(y_train).predict(n_periods=h), index=y_test.index)

    # regression fit on training dummies, then predicted forward into the test period
    X_train = seasonal_dummy_features(y_train.index)
    reg = LinearRegression().fit(X_train, y_train.values)
    full_idx = y_train.index.append(y_test.index)
    X_full = seasonal_dummy_features(full_idx)
    X_full["trend"] = range(len(full_idx))
    reg_fc = pd.Series(reg.predict(X_full.loc[y_test.index]), index=y_test.index)

    # score all three models against the same real holdout values
    for model_name, fc in [("ETS", ets_fc), ("ARIMA", sarima_fc), ("Regression", reg_fc)]:
        mae = np.mean(np.abs(y_test.values - fc.values))
        rmse = np.sqrt(np.mean((y_test.values - fc.values) ** 2))
        mape = np.mean(np.abs((y_test.values - fc.values) / y_test.values)) * 100
        results.append({"clinic": name, "model": model_name,
                        "MAE": mae, "RMSE": rmse, "MAPE": mape})

py_accuracy = pd.DataFrame(results)

# average across all six series, the same basis as the R table above
py_avg = (py_accuracy
          .groupby("model", as_index=False)[["RMSE", "MAE", "MAPE"]]
          .mean()
          .sort_values("RMSE"))

Bonus 2: Interview-Ready Time Series Literacy

Show code
lone_star <- read.csv("../resources/Project_Dataset_for_Students_Lone_Star_Appliances.txt", 
                      header = TRUE, sep = ",")

lone_star <- lone_star |>
  mutate(quarter_date = yearquarter(paste(year, quarter, sep = " Q"))) |>
  as_tsibble(index = quarter_date) |>
  mutate(covid_dummy = if_else(quarter_date %in% yearquarter(c("2020 Q1", "2020 Q2")), 1, 0))

champion_fit <- lone_star |>
  model(regression_dummy = TSLM(shipments ~ trend() + season() + covid_dummy))

air_passengers <- as_tsibble(AirPassengers) |>
  rename(passengers = value)

Interview Transcript

Interviewer: Thanks for making time today. Let’s just talk through how you think about forecasting. Nothing scripted, I just want to hear how you’d explain things to someone on our team who isn’t a statistician. Sound good?

Candidate: Sounds great, happy to walk through it.

Interviewer: Let’s start simple. If I handed you a spreadsheet of quarterly sales numbers and asked you to get familiar with it, what’s the first thing you’d do?

Candidate: Honestly, the very first thing is just plot it. Before I run a single model, I want to see the shape of the data with my own eyes. Here, let me actually show you what I mean, I’ll pull up a series I worked with recently, quarterly shipments for an appliance company.

Show code
lone_star |>
  ggplot(aes(x = quarter_date, y = shipments)) +
  geom_line(color = proj_navy, linewidth = 1.1) +
  geom_point(color = proj_navy, size = 1.5) +
  geom_point(
    data = lone_star |> filter(covid_dummy == 1),
    color = proj_teal, size = 2.5
  ) +
  scale_y_continuous(labels = scales::label_comma(suffix = "K")) +
  labs(title = "Quarterly shipments, 2010 to 2021",
       subtitle = "Teal points mark the two quarters of pandemic related disruption",
       x = NULL, y = "Shipments") +
  theme_proj()

So just from this, in about five seconds, you’ve got a clear upward climb over a decade, a repeating up and down pattern every single year, and then these two teal points, one stretch that clearly breaks the pattern before it recovers. None of that would have jumped out from a table of numbers. A good time series plot is like a quick physical exam before you order any lab tests, and I always look before I touch a model.

Interviewer: What were you actually looking for in that plot?

Candidate: Three things, really. Trend, seasonality, and noise. Trend is just the long term direction, is the series generally climbing, falling, or flat. Seasonality is the repeating pattern tied to the calendar, in that shipment data it was a quarterly cycle, low in the first quarter, building to a peak in the fourth. And noise is everything left over once you’ve accounted for trend and seasonality, the random bounce that doesn’t follow any pattern. The skill is learning to separate those three things in your head just from looking at a line.

Interviewer: How do you formally separate them, rather than just eyeballing it?

Candidate: That’s where decomposition comes in. Classical decomposition is the older, simpler approach, it estimates the trend with a moving average and then averages out the seasonal effect for each period. It works fine for a quick look, but it has some real limitations, it struggles at the edges of the series and it assumes the seasonal pattern never changes shape over time.

Interviewer: And what would you reach for instead?

Candidate: In practice I almost always reach for STL instead, seasonal and trend decomposition using Loess. It’s more flexible, it lets the seasonal component evolve gradually if it needs to, and it handles the ends of the series much better. Let me show you what that actually looks like on this same data.

Show code
lone_star |>
  model(STL(shipments ~ trend() + season(window = "periodic"))) |>
  components() |>
  autoplot() +
  theme_proj() +
  labs(title = "STL decomposition of quarterly shipments")

You can see the seasonal panel here is almost perfectly stable cycle to cycle, the same shape repeating every year, around plus or minus nine or ten units, no matter how high the overall trend climbs. That told me something important, the seasonal effect was additive, not multiplicative, it wasn’t growing proportionally with the level of the series. And the remainder panel at the bottom is flat and unremarkable except for that one sharp spike, exactly where the disruption hit, which tells you the shock lived in the remainder, not in the seasonal pattern itself.

Interviewer: Why does that distinction matter?

Candidate: Because it changes how you model it, and whether you need to transform the data first. If the seasonal swings were getting proportionally larger as the series grew, that’s a sign you might need a transformation, something like a Box-Cox transform, to stabilize the variance before fitting a model. On this project, the textbook automated method actually suggested a fairly aggressive transformation, but when I looked at what it was actually doing, it wasn’t fixing a real variance problem, it was just rescaling the numbers. The seasonal swing was already stable in absolute terms, you just saw that in the chart. So I made the judgment call to skip the transformation, even though the automated suggestion said otherwise. That’s a good example of why you don’t just trust an algorithm’s output blindly, you check whether the recommendation actually makes sense given what you’re seeing.

For contrast, the classic example where you would want that transform is something like monthly airline passenger volume. Let me put them side by side, it makes the difference obvious in about two seconds.

Show code
p_lone_star <- lone_star |>
  ggplot(aes(x = quarter_date, y = shipments)) +
  geom_line(color = proj_navy, linewidth = 1) +
  labs(title = "Lone Star shipments",
       subtitle = "Seasonal swing stays roughly the same size",
       x = NULL, y = "Shipments") +
  theme_proj()

p_air <- air_passengers |>
  ggplot(aes(x = index, y = passengers)) +
  geom_line(color = proj_teal, linewidth = 1) +
  labs(title = "Airline passengers",
       subtitle = "Seasonal swing grows along with the trend",
       x = NULL, y = "Passengers") +
  theme_proj()

p_lone_star + p_air

On the left, our series, the peaks and troughs stay roughly the same distance apart the whole way through, regardless of how high the trend climbs. On the right, classic airline passenger data, the summer peaks get visibly taller relative to the troughs as the years go on, that’s seasonality growing in proportion to the level. The left chart is why I skipped the transform on this project. The right chart is what would have made me reach for one, I’d take logs first, or let an automated search fit on a log scale, so the early years and the later years sit on comparable footing.

Interviewer: Let’s talk about autocorrelation. How would you explain that to someone non-technical?

Candidate: I’d say autocorrelation is just asking, does knowing today’s value help me guess tomorrow’s value? If a series is highly autocorrelated, today looks a lot like yesterday, or like the same point last year. The ACF plot is basically a bar chart of that relationship at different lags, one quarter ago, two quarters ago, and so on. On the shipment series, the ACF showed strong, slowly fading correlation across many lags, with a noticeable bump exactly at lag four. That bump at lag four is the seasonal fingerprint, it’s telling you the value four quarters ago, same quarter last year, is still strongly related to today’s value.

Interviewer: And partial autocorrelation, how’s that different?

Candidate: PACF asks a slightly sharper question. It strips out the indirect relationships and asks, what’s the direct relationship between today and three quarters ago, after you’ve already accounted for what one and two quarters ago already explained? It’s a subtle distinction, but it’s genuinely useful for picking model orders. Here’s what I mean, this is the ACF and PACF together on that same series.

Show code
lone_star |>
  gg_tsdisplay(shipments, plot_type = "partial") +
  theme_proj()

You can see the ACF on the left decays slowly with a clear bump at lag four, that’s the seasonal fingerprint I mentioned. The PACF on the right is sharper, it cuts off fast after just one or two lags. When you see that pairing, ACF tailing off slowly, PACF cutting off quickly, that’s a classic signature pointing you toward an autoregressive structure rather than a moving average one. I use this pair to form a hypothesis about model order before I let any automated search confirm or correct it.

Interviewer: Can you explain AR, MA, and ARIMA in plain language?

Candidate: Sure. An AR model, autoregressive, says today’s value is a weighted combination of recent past values, plus some randomness. It’s modeling momentum, in a sense. An MA model, moving average, despite the confusing name, says today’s value depends on recent past forecast errors, not past values themselves, it’s correcting for recent surprises. ARIMA combines both of those ideas, autoregressive and moving average terms, with a differencing step in between to handle trend. The “I” in ARIMA stands for integrated, which is just a formal word for differencing. On the shipment project, the automated ARIMA search landed on a fairly simple specification, one autoregressive term, one seasonal difference, and a drift term to capture the underlying growth. I didn’t have to hand pick that, the algorithm searched a range of specifications and picked the best one by an information criterion, but I always check that selection against what the data is actually doing, rather than treating it as a black box.

Interviewer: You mentioned differencing. What is stationarity, and why does it matter so much in this field?

Candidate: Stationarity means the statistical properties of a series, its average level and its variability, don’t change over time. A lot of classical time series theory, ARIMA in particular, assumes the series is stationary, or can be made stationary, before the math works properly. Most real business data isn’t stationary on its own, it has trend and seasonality baked in. Differencing is how you fix that, you look at the change from one period to the next instead of the raw level. On the shipment data, a formal stationarity test confirmed what the ACF plot already suggested, the raw series wasn’t stationary, but taking one seasonal difference, comparing each quarter to the same quarter a year prior, resolved it. That matched exactly what the automated ARIMA search had already chosen on its own, which was a nice independent confirmation that the model was doing something sensible.

Interviewer: Once you’ve got a few candidate models, how do you actually decide which one is best?

Candidate: This is where I think people sometimes cut corners, and it’s worth doing carefully. You can’t just fit a model and look at how well it fits the data it was trained on, that’ll always look good and tells you nothing about how it’ll perform on new data. You need a proper holdout, a train and test split, or better yet, several of them, plus ideally a rolling cross-validation that tests the model across many different starting points in time, not just one lucky or unlucky split.

For accuracy metrics, I lean on a few standard ones. RMSE, root mean squared error, punishes big misses more heavily, which is useful if large errors are especially costly to the business. MAPE expresses error as a percentage, which is intuitive for non-technical audiences, though it can behave oddly near zero. And MASE, mean absolute scaled error, compares your model’s error against a naive benchmark, which is genuinely my favorite, because it tells you immediately whether your fancy model is actually earning its complexity, or whether a simple “repeat last year” approach would have done just as well.

On the shipment project, that last point turned out to matter a lot. When I tested everything across a window that included an unexpected disruption, a sudden drop tied to a real world shock, every single model, including the more sophisticated ones, had a MASE above one. None of them beat the naive benchmark. That’s not a flattering result, but it’s an honest and useful one, it told the business plainly that no statistical method could have seen that disruption coming.

Interviewer: How do you do model diagnostics once you’ve picked a candidate?

Candidate: The main thing I check is the residuals, the leftover errors after the model has done its best. If a model is doing its job well, the residuals should look like noise, no pattern, no leftover trend, no leftover seasonality, and ideally no autocorrelation among themselves. I check that visually with a residual plot and a residual ACF, and I back it up with a formal test like Ljung-Box. Actually, let me show you, because this is a good story. My first version of the regression model failed that test, there was a leftover spike in the residual ACF at lag one. So I dug into it, added an indicator variable flagging the disrupted quarters explicitly, and re-ran the diagnostics. Here’s what that final version looks like.

Show code
champion_fit |> gg_tsresiduals()

This is how I know the model is actually sound, not just accurate on paper. The residuals bounce around zero with no leftover spike, the ACF down there has nothing poking through the significance bounds, and the histogram looks roughly bell shaped. I’d be honest in an interview that this is the cleaned up version, the first attempt had that lag one problem until I explicitly flagged the disruption, and I think being upfront about that iteration, rather than only showing the final clean result, is exactly the kind of thing that builds trust. If your residuals are talking to you, listen to them, don’t just report the headline accuracy number and move on.

Interviewer: Last technical one. When would you reach for ETS versus ARIMA versus a regression approach?

Candidate: It really depends on the shape of the problem. ETS, exponential smoothing, is great when you want something that adapts smoothly to recent changes in level, trend, and seasonality, and you want the model to pick its own structure automatically. It’s intuitive and usually a strong, fast baseline. ARIMA is a good fit when there’s meaningful autocorrelation structure in the data itself, momentum or mean reversion patterns, and you want a model built around that. Regression is my choice when I want maximum interpretability, especially for a business audience, and when I have a clear, explainable structure I want to impose, like a steady growth trend plus a seasonal pattern, or when I want to bring in an external factor, like flagging a known disruption with an indicator variable. On the shipment project, regression actually won out, not because it was fundamentally more sophisticated, but because its trend estimate was anchored across the entire training history, so a single disrupted year didn’t throw it off course the way it derailed the more reactive methods. The lesson for me wasn’t “regression beats ARIMA,” it was “match the model’s behavior to what the business actually needs it to be robust to.”

Interviewer: That actually connects to something I wanted to ask. A lot of forecasting work, especially anything touching public services or government programs, has to be explainable to people outside the room, auditors, oversight bodies, the public. How do you think about that kind of transparency requirement?

Candidate: This matters a lot to me, and it’s one of the first things I weigh when a forecast is going to touch a real decision about people or public money. I think of it as white-box versus black-box. A white-box model is one I can open up and explain, with something like the regression model we just talked about, I can point to the trend, the seasonal coefficients, the indicator variable, and tell you in plain words exactly why the forecast came out the way it did. A black-box model, something like a deep neural network or a large ensemble, might squeeze out a bit more accuracy, but it’s hard to trace why it landed on a specific number. In a government setting that’s not a technicality. If a forecast is shaping how staff, budget, or services get allocated, someone has the right to ask why, and “the model said so” isn’t an acceptable answer to an auditor or a court.

Interviewer: So is there ever a place for a black-box model in that kind of environment?

Candidate: Sure, but in a supporting role rather than the model that actually makes the call. I’ll often fit a more complex model on the side just to see how much accuracy it could realistically buy me. On the shipment project, for example, every method we tried struggled with the same disruption, so a black-box model probably wouldn’t have done meaningfully better there either, the problem wasn’t model complexity, it was that the event was genuinely unpredictable from the data alone. If a complex model only edges out the interpretable one by a little, which is common for clean operational series, that small gain isn’t worth losing the ability to explain the result. If the gaps were large, that would tell me the simple model is missing something real, and I’d go back and add it transparently, a new seasonal term, an external driver, rather than just swapping in the opaque model. There are lower stakes places where a black-box is fine, quick internal exploration, a what-if scenario nobody is directly affected by. But for anything that carries real weight, I treat explainability as a requirement, not a nice to have, and I pick the simplest model that actually meets the need.

Interviewer: That’s a great way to put it. Before we wrap up, is there anything you’d want to leave me with, something that sums up how you’d actually deliver this to a business?

Candidate: Yeah, actually, let me just show you where all of this ends up, since that’s really the point of the exercise. And thank you for the opportunity. Looking forward to the next steps.

Show code
future_quarters <- tibble(
  quarter_date = yearquarter("2022 Q1") + 0:3,
  covid_dummy = 0
) |>
  as_tsibble(index = quarter_date)

champion_fit |>
  forecast(new_data = future_quarters) |>
  autoplot(lone_star |> filter(quarter_date >= yearquarter("2018 Q1"))) +
  scale_y_continuous(labels = scales::label_comma(suffix = "K")) +
  labs(title = "Forecast for the next four quarters",
       subtitle = "Shaded bands show 80 and 95 percent prediction intervals",
       x = NULL, y = "Shipments") +
  theme_proj()

That’s the forecast for the next four quarters, with the shaded bands showing 80 and 95 percent ranges. I’d never hand someone just the dark line in the middle without those bands around it. Notice how they widen the further out you go, that’s just an honest reflection of reality, the further into the future you forecast, the more can change that the model hasn’t seen yet. Everything we just talked through, the decomposition, the diagnostics, the model comparison, all of it exists to earn the right to put a chart like this in front of someone who’s going to make a real decision based on it.

Interviewer: I think that covers everything I wanted to ask. Anything else you want to add?

Candidate: Just that I try to treat every modeling choice as something I need to be able to defend in plain language to a business stakeholder, not just something that scored well on a metric. That habit has made me a better forecaster, not just a better fitter of models.

Interviewer: Thank you! We’ll be in touch.

Executive Summary of Forecasting Literacy

What I’ve learned about forecasting. Good forecasting is less about finding the most sophisticated algorithm and more about deeply understanding the shape of the problem before reaching for any tool. The most valuable habit I’ve built is starting with a visual, honest look at the data, trend, seasonality, and anything unusual, before any model touches it. Time and again, that first look has told me more than any single accuracy metric.

How I approach a new time series problem. I start with visualization and decomposition to understand structure, check whether the data needs a transformation by asking whether variability is genuinely tied to the level of the series, and formally test for stationarity before committing to any differencing. Only once I understand the shape of the problem do I begin fitting candidate models, and I always include a simple benchmark, like a naive seasonal forecast, so I have an honest baseline to measure everything else against.

How I compare and select models. I never trust in-sample fit alone. I rely on proper holdout testing, ideally across multiple time windows or a rolling cross-validation, and I weigh metrics like RMSE and MASE depending on the audience and the cost of different kinds of errors. Just as important as the accuracy number is the residual diagnostics step, a model that looks accurate but leaves structured, autocorrelated errors behind hasn’t actually captured everything it should have, and I treat that as a signal to dig deeper rather than a footnote to skip past.

How I communicate uncertainty. I present every forecast with a range, not just a point estimate, and I explain in plain language why that range exists and why it widens further into the future. When a model has a known blind spot, for instance, an inability to anticipate a sudden, unprecedented disruption, I say so directly rather than letting a confident looking chart imply more certainty than the model actually has.

Tools I prefer, and why. I’m comfortable in both R and Python, but for time series work specifically I lean toward R’s tidyverts ecosystem, the combination of tsibble, fable, and feasts gives me a consistent, tidy framework for decomposition, modeling, and forecasting side by side, which keeps the analysis readable and reproducible. For broader data engineering, general machine learning, or when integrating into a larger Python based pipeline, I’m equally comfortable working there. I pick the tool based on what the team and the problem need, not out of habit.

How I’d add value to a business using forecasting. Beyond producing accurate numbers, I see my role as translating uncertainty into something decision makers can actually act on, telling a VP not just what the forecast is, but how much to trust it and where the real risks sit. I aim to build forecasting processes that are transparent enough that a non-technical stakeholder can understand the key drivers and limitations, because a forecast that nobody trusts or understands doesn’t get used, no matter how accurate it is.