Fitting a trend line with TSLM
Today let's understand how to fit a straight trend line through a time series, using TSLM(), and read exactly what the line's own numbers mean.
Northwind is a small SaaS company that tracks its monthly active users, MAU for short. Here are 48 months of it, January 2022 through December 2025. MAU climbs from 1,225 users to a high of 5,290, but it is not a straight climb, it wobbles up and down along the way.
Look at that shape for a second. It rises overall, but every few months it dips before climbing again. Draw one straight line through that wobble, and Northwind's growth reduces to two numbers: a starting point and a monthly rate.
What TSLM() fits, and what trend() means as a predictor
TSLM() stands for time series linear model. It fits the same ordinary linear regression you would get from lm(), except it is built to work on a tsibble, the tidyverse's structure for a table indexed by time, and it understands a few special time-aware predictors that lm() does not.
trend() is one of those predictors. It is nothing more than the row number of the series: 1 for the first month, 2 for the second, all the way up to 48 for Northwind's last month, December 2025.
Build Northwind's 48 months as a tsibble, then fit TSLM(mau ~ trend()), which reads as "explain mau using trend() as the only predictor."
model() is fabletools' function for fitting any time series model onto a tsibble. It hands back a mable, short for model table, one row holding the whole fitted TSLM object inside a single cell. The print above just confirms which model that cell holds; the next step pulls the real numbers back out of it.
Reading the coefficients in real units
coef() pulls the fitted numbers out of the mable.
Two rows, two coefficients. term names each one and estimate holds its number (the printed 1086. is just rounded for display; the real value is 1085.634). (Intercept) is the fitted mau at trend() = 0, one month before the data starts, so read it as the line's starting point rather than a real observed month.
trend() is the slope: mau rising by 83.9, on average, for every extra month. Across a full year that works out to 83.9 times 12, about 1,007 more users a year.
Put the two together and the line's equation is mau = 1085.6 + 83.9 * trend(). At month 1 that gives 1169.5, against a real January 2022 value of 1,225, a miss of 55.5. At month 48, December 2025, it gives 5112.8, against a real 5,181, a miss of 68.2. augment() confirms both by hand: it adds a .fitted column, the line's value for that row, straight onto the original tsibble, so you never have to compute it by hand again.
Dragging a line through 48 months by hand
Every point in Northwind's series sits a little off any straight line you could draw through it. That gap, for one month, is the residual: the real mau minus the line's prediction for that month. A residual can be positive, the real number beat the line, or negative, the line over-guessed.
To compare two different lines you need one number that scores the whole line at once, built from all 48 residuals. Adding the raw residuals will not work, positive and negative ones cancel out. So square each residual first, then add them up. Squaring drops the sign and punishes a big miss far more than a small one. That total is the sum of squared errors, SSE for short.
Ordinary least squares, the method behind TSLM(), is the rule that picks the one line, the one intercept and slope, that makes SSE as small as it can possibly be.
Drag the line below by hand and watch SSE change with it. It uses the same 48 (month, mau) pairs plotted in the first step, with the month number standing in for trend(). Then press "Snap to least squares" to jump straight to the minimum.
Look at the intercept and slope the snap lands on. They are 1085.6 and 83.9, the exact same numbers coef(fit) printed in the last step. That is not a coincidence, it is the same computation twice: TSLM() solves for least squares algebraically, and the snap button solves the identical problem by search. Both land in the same place because there is only one line that minimises SSE for this data.
Quick check: what the snapped line means
report(): the full regression summary
report() prints the full regression summary fabletools can produce for a fitted model, the same kind of output you would get from summary() on an lm() object in base R.
Residuals summarises the 48 misses: half of them fall between -97.77 and 84.88, the 1st and 3rd quartiles, and the single worst miss reaches 450.30.
Coefficients repeats the intercept and slope from the last step, now with full precision, 1085.634 and 83.898, and their significance. Both t values are large and both p-values print as under 2e-16, about as far from zero as a p-value can get, which says neither coefficient looks like it could plausibly be zero.
Residual standard error, 167.7, is the typical size of a miss, in mau, on 46 degrees of freedom, 48 months minus the 2 parameters the model had to estimate. Multiple R-squared, 0.9804, is the share of mau's variation this one straight line explains, 98%. Adjusted R-squared, 0.98, is the same idea corrected for the number of predictors, which barely moves it here since there is only one.
F-statistic, 2305 on 1 and 46 degrees of freedom, tests whether the slope is really different from zero at all. Its p-value, under 2.22e-16, says yes, decisively.
glance(): the same fit as one row of numbers
report() is built for reading. glance() is built for reuse: it returns the same fit as one row of a tidy table instead of printed text, ready to stack with other models' rows into a single comparison table.
r_squared and adj_r_squared repeat what report() already showed. sigma2, 28136, is the residual variance, the squared version of the residual standard error you just read (167.7 squared lands close to 28136, the small gap is rounding). AIC, AICc and BIC are three different penalised scores for comparing models against each other, lower is better on all three; AICc is AIC with a small-sample correction, and BIC penalises extra parameters more heavily than AIC does. CV is a leave-one-out cross-validation score fabletools computes automatically for a linear model.
None of these numbers mean much read alone, on a single model. They earn their keep once you have two or more models' glance() rows to set side by side and pick the smallest AICc or CV.
Forecasting with new_data()
Reading a fit is one thing. Using it to look ahead is another.
new_data() builds empty future rows that continue a tsibble's own time index. Hand it nw and 6, and it returns 6 new rows that continue trend() to 49 through 54, January through June 2026.
forecast() reads trend() straight off those new rows, 49 through 54, and applies the same line: 1085.6 + 83.9 * trend(). .mean holds that point forecast, the single most likely value for each month. January 2026 lands at 5196.7, and it keeps climbing by 83.9 a month straight through to 5616.2 in June, exactly the slope you already know.
A point forecast alone hides how much uncertainty surrounds it. hilo() turns fc into an interval instead of one number, at whatever confidence level you give it.
January's 80% interval runs 4972.6 to 5420.7, a width of about 448. June's runs 5389.1 to 5843.2, a width of about 454, and every month in between widens a little more than the last. That is not a quirk of this data, it is how a regression forecast always behaves: the further trend() reaches past the 48 months the model actually saw, the less certain the line's own extrapolation gets, and the interval grows to say so honestly.
Watching the forecast extend the series
See the same forecast as a picture instead of a table. Plot Northwind's 48 real months alongside the 6 forecasted ones, on the same axis.
The forecasted months pick up exactly where the real series stops, continuing the same straight climb, the 5196.7 to 5616.2 you just printed. There is no bend where the actual data ends and the forecast begins, because the forecast is just the same line, mau = 1085.6 + 83.9 * trend(), evaluated a little further along the same trend() axis the whole line was fit on.
The two assumptions ordinary regression borrows, and how a time series breaks them
Ordinary regression, the kind TSLM() runs, was built for cross-sectional data: rows with no natural order, like a survey of unrelated customers. To trust everything report() and forecast() just handed you, in particular the standard errors, p-values and interval widths behind them, two assumptions about the errors have to hold.
- Independence. Each residual is unrelated to the ones next to it. Knowing one month's miss should tell you nothing about the next month's miss.
- Constant variance. The residuals spread out by roughly the same amount everywhere along the series, not tighter at one end and wider at the other.
Northwind's own residuals let you check both, directly.
lag1_cor correlates every residual with the one right before it: 0.426. If independence held, that number would sit close to 0; instead each month's miss carries almost half of the previous month's miss forward into it, one residual predicting the next.
dw_stat is a Durbin-Watson-style statistic: the summed squared gap between neighbouring residuals, divided by the summed squared residuals. Independent residuals give a value near 2; Northwind's comes out at 1.144, well under it, the same story the lag-1 correlation just told from a different angle.
sd_first_half and sd_second_half check the second assumption: the residual standard deviation across the first 24 months against the last 24. 130.5 against 195.1, about 50% wider in the second half. The spread is not constant either.
None of this undoes the fitted line itself. 1085.6 and 83.9 are still the least-squares intercept and slope for this data, the best straight line through these 48 points. What it does undo is the machinery built on top of that line: report()'s R-squared and F-statistic and forecast()'s interval widths were all computed on the assumption of independent, constant-variance errors, and Northwind's residuals contradict both. Read a strong-looking fit on a time series with that in mind, rather than taking its p-values and intervals completely at face value.
Quick check: reading the fit and its limits
Your turn: fit and forecast
nw still holds Northwind's 48 months of MAU, built earlier on this page.
Show answer
# Fit TSLM(mau ~ trend()) on nw, forecast 3 more months, and print the point forecasts
fit3 <- nw |> model(TSLM(mau ~ trend()))
fit3 |>
forecast(new_data(nw, 3)) |>
as.data.frame() |>
transmute(month, forecast = round(.mean, 1))
#> month forecast
#> 1 2026 Jan 5196.7
#> 2 2026 Feb 5280.6
#> 3 2026 Mar 5364.5References
- Forecasting: Principles and Practice, section 7.1, The linear model - Hyndman and Athanasopoulos (3rd ed.), the source for TSLM() and ordinary regression on a time series.
- fable package reference documentation for TSLM()
- tsibble package reference documentation for new_data()
- fabletools package reference documentation for report() and glance()
- fabletools package reference documentation for forecast()
What you can do now
You can now fit TSLM(mau ~ trend()) on a tsibble and read its intercept and slope in the real units of your data: 1085.6 and 83.9 here, about 1,007 more users a year.
You can read the same fit two ways, report()'s full printed summary or glance()'s one tidy row, and you can build future rows with new_data() and read forecast()'s point forecast and widening interval off them.
And you can name the two assumptions ordinary regression borrows, independence and constant variance, and check a fit's own residuals against both, the way Northwind's lag-1 correlation and growing spread just failed them.
The next lesson adds a seasonal pattern to this same trend line with season() and dummy variables.