Experimentation And Applied Statistics
Time Series Analysis Basics
Every dataset examined so far has treated rows as independent, interchangeable observations — shuffling them wouldn't change any statistic. Time series data bre
Jr Codex Data Science Notes
Level: Intermediate–Advanced Prerequisites: Chapter 3: Common Experiment Design Pitfalls Time to complete: ~25 minutes
Table of Contents
- What Makes Time Series Data Different
- The Components of a Time Series
- Visualizing a Time Series
- Moving Averages — Smoothing Out Noise
- Decomposition
- A Simple Forecast — Naive & Moving Average Methods
- Why Standard Correlation Can Mislead on Time Series
- Summary & Next Steps
1. What Makes Time Series Data Different
Every dataset examined so far has treated rows as independent, interchangeable observations — shuffling them wouldn't change any statistic. Time series data breaks that assumption: the order matters, and today's value is often directly influenced by yesterday's.
Regular Tabular Data Time Series Data
───────────────────────────── ─────────────────────────────
Each row = one customer Each row = one TIME POINT
Shuffling rows changes NOTHING Shuffling rows DESTROYS the
about the analysis entire meaning of the data
───────────────────────────── ─────────────────────────────
2. The Components of a Time Series
A Time Series Is Typically Decomposed Into
─────────────────────────────────────────
Trend: the long-term overall direction (growing, shrinking, flat)
Seasonality: a REPEATING pattern at a fixed, known frequency
(daily, weekly, yearly — e.g. retail spikes every December)
Noise/Residual: whatever's left after removing trend and seasonality —
the unpredictable, random component
─────────────────────────────────────────
import numpy as np
import pandas as pd
np.random.seed(0)
dates = pd.date_range("2022-01-01", periods=365 * 2, freq="D")
trend = np.linspace(100, 200, len(dates)) # steadily increasing
seasonality = 20 * np.sin(2 * np.pi * dates.dayofyear / 365) # yearly cycle
noise = np.random.normal(0, 5, len(dates)) # random day-to-day variation
values = trend + seasonality + noise
df = pd.DataFrame({"date": dates, "value": values}).set_index("date")3. Visualizing a Time Series
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(df.index, df["value"])
ax.set_title("Raw Time Series")
ax.set_xlabel("Date")
ax.set_ylabel("Value")Directly applying Module 4's chart-selection principle — a line chart is the correct default for time series, since it visually preserves the sense of continuity and order that a bar chart would obscure.
4. Moving Averages — Smoothing Out Noise
A moving average (or "rolling average") replaces each point with the average of itself and its nearby neighbors — smoothing out short-term noise to reveal the underlying trend more clearly.
import pandas as pd
df["ma_7"] = df["value"].rolling(window=7).mean() # 7-day moving average
df["ma_30"] = df["value"].rolling(window=30).mean() # 30-day moving average
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(df.index, df["value"], alpha=0.3, label="Raw data")
ax.plot(df.index, df["ma_7"], label="7-day moving average")
ax.plot(df.index, df["ma_30"], label="30-day moving average", linewidth=2)
ax.legend()
ax.set_title("Raw Data vs Moving Averages")Choosing a Window Size
─────────────────────────────────────────
Shorter window (e.g. 7-day) → tracks the data more closely,
still shows some noise
Longer window (e.g. 30-day) → smoother, reveals the broader
trend, but reacts SLOWER to
genuine recent changes
─────────────────────────────────────────
Practical use: moving averages are the standard way to answer "is this metric actually trending up, or is that just noisy day-to-day variation?" — a version of this question comes up constantly when monitoring business metrics or, in Chapter 3's A/B testing context, tracking a metric across a test's duration.
5. Decomposition
Rather than eyeballing trend/seasonality/noise separately, statsmodels can split them apart programmatically:
from statsmodels.tsa.seasonal import seasonal_decompose
import matplotlib.pyplot as plt
result = seasonal_decompose(df["value"], model="additive", period=365)
fig, axes = plt.subplots(4, 1, figsize=(12, 10))
result.observed.plot(ax=axes[0], title="Observed")
result.trend.plot(ax=axes[1], title="Trend")
result.seasonal.plot(ax=axes[2], title="Seasonality")
result.resid.plot(ax=axes[3], title="Residual (Noise)")
plt.tight_layout()Additive vs Multiplicative Decomposition
─────────────────────────────────────────
Additive: value = trend + seasonality + noise
use when seasonal swings stay roughly the SAME
SIZE regardless of the trend's level
Multiplicative: value = trend × seasonality × noise
use when seasonal swings GROW proportionally
as the trend grows (e.g. holiday sales spikes
that get bigger as the business itself grows)
─────────────────────────────────────────
6. A Simple Forecast — Naive & Moving Average Methods
Full forecasting (ARIMA, Prophet, and beyond) is a deep topic of its own — this section covers the simplest baseline methods, useful both on their own for short horizons and as a sanity-check baseline against any fancier model.
import pandas as pd
# Naive forecast: "tomorrow will look like today" — surprisingly hard to beat for short horizons
last_value = df["value"].iloc[-1]
naive_forecast = last_value # forecast for the next period
# Moving average forecast: "tomorrow will look like the recent average"
ma_forecast = df["value"].tail(30).mean()
print(f"Naive forecast: {naive_forecast:.1f}")
print(f"Moving average forecast: {ma_forecast:.1f}")# Evaluating a forecast method — held-out validation, the time-series version of a train/test split
train = df.iloc[:-30] # everything except the last 30 days
test = df.iloc[-30:] # the last 30 days, held out to check forecast accuracy
naive_predictions = [train["value"].iloc[-1]] * len(test) # repeat the last known value
mae = (test["value"] - naive_predictions).abs().mean()
print(f"Naive forecast MAE: {mae:.2f}") # Mean Absolute Error — average forecast missWhy the Split Isn't Random (Unlike Module 1's Sampling)
─────────────────────────────────────────
A time series train/test split must be CHRONOLOGICAL —
train on the PAST, test on the FUTURE.
Randomly shuffling rows before splitting (as you might for
ordinary tabular data) would let the model "see the future"
during training — an invalid, overly optimistic evaluation.
This exact distinction reappears in the Machine Learning
notes' model evaluation chapter.
─────────────────────────────────────────
7. Why Standard Correlation Can Mislead on Time Series
Module 1, Chapter 5's correlation warning gets an extra twist with time series: two variables can appear strongly correlated purely because they both trend over time, with no real relationship between them at all — a phenomenon called spurious correlation.
import numpy as np
import pandas as pd
np.random.seed(1)
# Two COMPLETELY UNRELATED series, that both happen to trend upward over time
dates = pd.date_range("2020-01-01", periods=100, freq="M")
series_a = np.linspace(10, 50, 100) + np.random.normal(0, 2, 100) # e.g. "ice cream sales"
series_b = np.linspace(5, 200, 100) + np.random.normal(0, 10, 100) # e.g. "global CO2 levels"
correlation = np.corrcoef(series_a, series_b)[0, 1]
print(f"Correlation: {correlation:.3f}") # very high, e.g. 0.95+ — despite NO real relationship!The Fix — Compare CHANGES, Not Levels
─────────────────────────────────────────
Instead of correlating the raw trending series, correlate their
period-over-period CHANGES (differences) — this removes the
shared trend and reveals whether they actually move together.
─────────────────────────────────────────
import pandas as pd
diff_a = pd.Series(series_a).diff().dropna()
diff_b = pd.Series(series_b).diff().dropna()
correlation_diff = diff_a.corr(diff_b)
print(f"Correlation of differences: {correlation_diff:.3f}") # much closer to 0 — the honest answerThis is a direct, practical extension of Module 1, Chapter 5's "correlation is not causation" — with time series, even correlation itself can be an illusion created by a shared trend, not just a confound.
8. Summary & Next Steps
Key Takeaways
- Time series data breaks the "rows are independent and interchangeable" assumption every prior module relied on — order carries meaning, and shuffling destroys it.
- Every time series can be thought of as trend + seasonality + noise;
seasonal_decomposesplits these apart explicitly. - Moving averages smooth short-term noise to reveal the underlying trend — shorter windows track closely but stay noisy, longer windows smooth more but react slower.
- Time series train/test splits must be chronological (train on the past, test on the future) — never randomly shuffled, unlike ordinary tabular splits.
- Two trending series can show high correlation purely from sharing a trend, with no real relationship — correlating their period-over-period changes instead is the standard fix.
Module 5 Complete (through Chapter 4) — Continuing to Chapter 5
You now have the applied statistical toolkit for experimentation and time-ordered data. Chapter 5 closes this module with a different but essential skill: translating everything you've learned across this curriculum into a narrative a non-technical stakeholder will actually act on.
Concept Check
- Why can't you randomly shuffle rows before doing a train/test split on time series data, the way you might for ordinary tabular data?
- What's the difference between additive and multiplicative decomposition, and when would you choose each?
- Why might two completely unrelated metrics show a very high correlation if both happen to be trending upward over time?
Next Chapter
→ Chapter 5: Storytelling with Data
Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index