What makes time series different

Today let's understand what actually makes a time series different from an ordinary dataset, using one real example.

Riverside Bike Share is a city bike-rental program. Here is its daily rental count for three straight years, 1,095 days, plotted in the order the days actually happened.

Look at that line. It climbs slowly over the three years, dips every weekend, and rises every summer. That is the whole difference this lesson is about: for a time series, the order the values arrived in carries real information that the values alone do not.

Meet Riverside Bike Share's three years of daily rentals

Before going any further, build this series yourself so every number that follows traces back to code you can see.

Riverside's daily rentals have four things going on at once: a slow upward trend as the program grows, a weekday versus weekend swing (commuters ride on weekdays, leisure riders take over on weekends), a summer versus winter swing (more riders when it is warm), and ordinary random noise on top of all of it.

RInteractive R
# Build three years of Riverside Bike Share's daily rentals: a slow upward # trend, a weekday/weekend swing, a summer/winter swing, and random noise set.seed(2024) day <- 1:1095 trend <- 150 + 0.05 * day day_of_week <- ((day - 1) %% 7) + 1 weekday_weekend <- ifelse(day_of_week <= 5, 40, -35) summer_winter <- 60 * sin(2 * pi * day / 365 - pi / 2) noise <- rnorm(1095, 0, 15) riverside_rentals <- round(trend + weekday_weekend + summer_winter + noise) riverside_rentals <- pmax(riverside_rentals, 0) round(c(mean = mean(riverside_rentals), sd = sd(riverside_rentals), day_1 = riverside_rentals[1], day_1095 = riverside_rentals[1095]), 1) #> mean sd day_1 day_1095 #> 195.9 58.6 145.0 171.0

  

Across all 1,095 days, the average is 195.9 rentals with a standard deviation of 58.6. The first day (145) and the last day (171) both sit fairly close to that average. None of these four summary numbers say anything about the order the days came in. Plot it and you can see the shape those four ingredients actually produce.

RInteractive R
# Plot all three years of daily rentals, in the order they happened plot(riverside_rentals, type = "l", xlab = "day", ylab = "rentals")

  

Same numbers, shuffled: what changes and what doesn't

Here is a direct test of whether the order matters. Take the exact same 1,095 numbers you just built and shuffle them into a random order, so day 612's rental count might now sit at position 40 and day 3's might sit at position 900.

RInteractive R
# Shuffle the same 1,095 values into a random order, and compare summary stats set.seed(99) shuffled_rentals <- sample(riverside_rentals) round(c(mean_ordered = mean(riverside_rentals), mean_shuffled = mean(shuffled_rentals), sd_ordered = sd(riverside_rentals), sd_shuffled = sd(shuffled_rentals)), 3) #> mean_ordered mean_shuffled sd_ordered sd_shuffled #> 195.947 195.947 58.601 58.601

  

The mean and the standard deviation match to three decimal places. That makes sense: shuffling only rearranges which value sits at which position, it never changes which 1,095 values are in the set, so any summary that only counts values, and ignores their order, comes out identical.

Now plot the two side by side.

RInteractive R
# Plot the ordered series next to the same values shuffled into random order par(mfrow = c(1, 2)) plot(riverside_rentals, type = "l", main = "Ordered", xlab = "day", ylab = "rentals") plot(shuffled_rentals, type = "l", main = "Shuffled", xlab = "day", ylab = "rentals")

  

The ordered plot on the left still shows the climb, the weekend dips, the summer rise. The shuffled plot on the right looks like static: no trend, no seasonal rhythm, nothing to read off it at all. Same 1,095 numbers, same mean, same standard deviation, and a completely different picture. Whatever produces that picture is not sitting in the values themselves. It is sitting in the order.

Autocorrelation: correlating a series with its own past

A mean's standard error, a t-test, and plain linear regression all share one assumption underneath them: each observation is independent, meaning knowing one value tells you nothing about the next one. That assumption is exactly what the shuffle test just put to the test, and for the ordered series, it clearly fails. A busy day is followed by another busy day far more often than chance would produce.

There is a name for that failure, and a way to measure exactly how large it is. Autocorrelation is the correlation between a series and a lagged copy of itself: today's value against yesterday's value, for every day in the series. Line up each day's rentals with the day right before it and run an ordinary correlation.

RInteractive R
# Correlate each day's rentals with the value one day before it: # the lag-1 autocorrelation round(c(ordered = cor(riverside_rentals[-1095], riverside_rentals[-1]), shuffled = cor(shuffled_rentals[-1095], shuffled_rentals[-1])), 3) #> ordered shuffled #> 0.692 -0.026

  

For the ordered series, that correlation is 0.692. For the shuffled series it is -0.026, indistinguishable from zero. Shuffling did not touch a single rental count. It only broke the link between a day and the day before it, and that link is exactly what the 0.692 was measuring.

Key Insight
Autocorrelation at lag 1 is nothing exotic. It is the same Pearson correlation you already know, computed between the series and a copy of itself shifted back by one step. A high lag-1 autocorrelation means today's rentals carry real, usable information about tomorrow's.

See that pairing as a scatter plot: every day's rentals against the day right before it.

Press Run on that chart's code and it computes a Pearson correlation on those 1,094 pairs, and it lands at the same 0.692 the hand calculation gave you a moment ago. High rental days genuinely tend to sit next to other high rental days, and the shuffle you ran earlier destroyed exactly that.

Why the standard error of the mean needs a correction

A mean's ordinary standard error, sd / sqrt(n), assumes every one of the n observations adds a fresh, independent piece of information. With autocorrelation at 0.692, that assumption is badly wrong: a big chunk of what tomorrow's rental count tells you, you already knew from today's. So the 1,095 correlated days do not carry 1,095 days' worth of independent information. They behave like a much smaller number of independent days.

That smaller number has a name, the effective sample size, n_eff, and a formula: n_eff = n * (1 - r) / (1 + r), where r is the lag-1 autocorrelation.

RInteractive R
# Compare the naive standard error of the mean against one corrected # for autocorrelation n <- 1095 r <- cor(riverside_rentals[-1095], riverside_rentals[-1]) n_eff <- n * (1 - r) / (1 + r) naive_se <- sd(riverside_rentals) / sqrt(n) adjusted_se <- sd(riverside_rentals) / sqrt(n_eff) round(c(n_eff = n_eff, naive_se = naive_se, adjusted_se = adjusted_se), 2) #> n_eff naive_se adjusted_se #> 199.19 1.77 4.15

  

Those 1,095 correlated days behave like only about 199 independent ones. Treat them as 1,095 independent days, the way an ordinary standard error does, and you get a naive standard error of 1.77. Correct for the true 199 effective days and it grows to 4.15, more than double. What changed is not the mean itself, still 195.9 exactly as before, but how confident you are allowed to be about it, once the days stop pretending to be independent.

Note
The widget below runs the identical experiment on a different quantity: a regression's trend slope, not a plain mean, correlated errors over time instead of a mean's own days. Drag the severity dial and the SAME failure shape shows up: the interval's coverage collapses toward the low end while the model's fit sits still, exactly like the naive-versus-adjusted gap you just computed.

At severity zero, the errors are independent and the 95% interval covers the truth close to 95% of the time. Drag the dial toward severe, and the interval's actual coverage falls well below that stated 95%, while the fit statistic barely moves, or even rises a little. That gap, a model whose fit still looks fine sitting next to an interval whose real coverage has quietly fallen far below the 95% printed on it, is the entire reason this correction matters.

Quick check: reading a lag-1 correlation

Right. That is what r = 0.692 says about neighbouring days, and it is exactly why a naive standard error understates the true one.
A lag-1 correlation is a statement about how similar neighbouring days are, not about whether the series trends up or down. And it very much affects the standard error: the mean stayed at 195.9, but the true uncertainty around it more than doubled, from 1.77 to 4.15, once the correction accounted for r = 0.692.

The vocabulary: trend, seasonality, cycle, and noise

Riverside's series is built from exactly four ingredients, and every time series you meet from here on can be described using the same four words.

  • Trend: the slow, long-run direction of the series. Riverside's trend climbs from 150 toward roughly 205 over the three years, as the bike-share program grows.
  • Seasonality: a pattern that repeats at a fixed, known length tied to the calendar. Riverside has two: a weekly one (weekdays average 217.5 rentals, weekends average 141.8) and a yearly one (a roughly 120-rental swing between summer and winter).
  • Cycle: a rise and fall with no fixed length, often stretching over years, like a broader business cycle. Riverside's series has none. Three years is simply too short a window for a cycle to ever show up in it.
  • Noise: whatever is left once trend, seasonality, and cycle have been accounted for. It is the day-to-day randomness rnorm() added when you built the series.
RInteractive R
# Plot the full series with its trend line and weekday/weekend averages overlaid weekday_avg <- mean(riverside_rentals[day_of_week <= 5]) weekend_avg <- mean(riverside_rentals[day_of_week > 5]) round(c(weekday_avg = weekday_avg, weekend_avg = weekend_avg), 1) #> weekday_avg weekend_avg #> 217.5 141.8 plot(riverside_rentals, type = "l", col = "grey70", xlab = "day", ylab = "rentals") lines(trend, col = "black", lwd = 2) abline(h = weekday_avg, col = "steelblue", lty = 2) abline(h = weekend_avg, col = "firebrick", lty = 2)

  

The black line is the trend you built the series from. The blue dashed line marks the weekday average, the red dashed line marks the weekend average, and the grey line underneath both is the raw series carrying its own noise.

Lag and horizon: two words used throughout the rest of the course

Two more terms round out the vocabulary you need to read any time series. Lag is how many steps back a comparison looks. Lag 1 compares today against yesterday, the comparison you already computed. Lag 7 compares today against the same day one week earlier.

RInteractive R
# Compare the lag-7 correlation (today vs the same day last week) # with the lag-1 correlation you already know n2 <- length(riverside_rentals) lag7 <- cor(riverside_rentals[1:(n2 - 7)], riverside_rentals[8:n2]) lag1 <- cor(riverside_rentals[-n2], riverside_rentals[-1]) round(c(lag_1 = lag1, lag_7 = lag7), 3) #> lag_1 lag_7 #> 0.692 0.933

  

Lag 7's correlation, 0.933, is even higher than lag 1's 0.692. That makes sense once you remember the weekly pattern: a Monday is more like the Monday one week earlier than it is like the Sunday right before it.

Horizon is a different idea: how many steps ahead a forecast reaches. Forecasting tomorrow's rentals is horizon 1. Forecasting a month from now is horizon 30. Lag looks backward to measure a relationship already in the data. Horizon looks forward to say how far a forecast is trying to reach.

A forecast is a distribution, not one number

Riverside's manager wants tomorrow's rental count, day 1,096. The tempting answer is a single number, probably today's value, 171. But that hides something worth stating plainly: how much that guess could plausibly be off by.

RInteractive R
# Measure how much rentals typically change from one day to the next, # then build a plausible range around tomorrow's guess using that spread diffs <- diff(riverside_rentals) day_to_day_spread <- sd(diffs) last_value <- riverside_rentals[1095] round(c(spread = day_to_day_spread, low = last_value - day_to_day_spread, guess = last_value, high = last_value + day_to_day_spread), 1) #> spread low guess high #> 46 125 171 217

  

Day-to-day changes in Riverside's rentals typically run about 46 rentals in either direction. So the honest statement for tomorrow is not "171." It is "somewhere around 125 to 217," built directly from that spread. A forecast is really a distribution of plausible outcomes, and a single number is just its centre.

That spread would also widen the further out the forecast reaches, if day-to-day changes kept behaving the same independent way.

RInteractive R
# See how that spread would widen at longer horizons, if day-to-day # changes kept behaving the same independent way horizon <- c(1, 7, 30) data.frame(horizon_days = horizon, spread = round(day_to_day_spread * sqrt(horizon))) #> horizon_days spread #> 1 1 46 #> 2 7 122 #> 3 30 252

  

One day out, the spread is 46. A week out, it grows to 122. A month out, 252. The spread grows with the square root of the horizon, not the horizon itself, but it still grows a lot: forecasting further ahead means committing to a wider honest range, never a sharper single number.

Closing quiz: putting the vocabulary together

Suppose a new series repeats a fixed shape every 12 months and rises steadily underneath that repeating shape.

Exactly. A fixed, calendar-tied repeating length is seasonality, and the steady underlying rise is trend, the same two ingredients Riverside's own series carries.
A fixed 12-month repeat is seasonality, not a cycle, since a cycle has no fixed length. And the steady underlying rise is a trend, not leftover noise. Autocorrelation would be present here too, but naming it alone skips the two actual patterns, seasonality and trend, that are producing it.

Your turn: compute a lag correlation and a forecast range

riverside_rentals still holds all 1,095 days you built earlier in this lesson.

First, compute the lag-2 autocorrelation: correlate the series with the value two days before it, the same way lag-1 used one day before and lag-7 used seven days before.

Right: about 0.454. That sits between lag-1's 0.692 and lag-7's 0.933, which makes sense: two days back is a weaker link than one day back, but still stronger than an unrelated pair of days.Pair riverside_rentals[1:1093] against riverside_rentals[3:1095] inside cor(), the same slicing pattern lag-1 used with a gap of one position instead of two.
Show answer
# Correlate riverside_rentals with the value two days before it
round(cor(riverside_rentals[1:1093], riverside_rentals[3:1095]), 3)
#> [1] 0.454

Now, last_value (171) and day_to_day_spread (46) are both still available from earlier in this lesson. Compute day 1,096's forecast range from them, low and high, instead of just stating it.

RInteractive R
# last_value holds day 1,095's rentals (171) and day_to_day_spread # holds the spread you computed earlier (46). # Build day 1,096's forecast range: the low end and the high end. # Two lines. Press Check when you have them.

  

References

Quick recap

Order carries information: the same 1,095 numbers gave 0.692 ordered against -0.026 shuffled, and that gap is the whole reason time series get treated differently from an ordinary sample.

Autocorrelation shrinks the effective sample size: 1,095 correlated days behaved like only about 199 independent ones, so the honest standard error came out more than double the naive one, even though the mean never moved.

Trend, seasonality, cycle, and noise describe the parts a series is built from. Lag and horizon frame every comparison and every forecast you will make from here on. And a forecast itself is a range, not a single number, one that widens the further out it reaches.

Next up: putting this exact series into the tidy, rectangular shape the rest of this course builds on.