Time Series Analysis Basics

โฑ๏ธ 5 min read ๐Ÿ“ˆ Data Analysis

Time series analysis is the study of data recorded at regular time intervals, such as daily sales or hourly website traffic, to separate it into trend, seasonality and noise and use those patterns to forecast future values. The basic workflow is to plot the series, decompose it, check that it is stationary, fit a simple model, and test the forecast on the most recent data you held back.

Quick answer: Time series analysis models data ordered in time. Plot the series, then decompose it into trend, seasonality and residual noise with statsmodels' seasonal_decompose. Test stationarity with the Augmented Dickey-Fuller test (p < 0.05 means you can treat the series as stationary) and difference it if it trends. Start with a moving average or exponential smoothing before ARIMA, and judge every model by its error on held-out recent data.

What are the components of a time series?

A time series has three components: the trend (its long-run direction), seasonality (a pattern that repeats over a fixed period, such as 12 months or 7 days) and residual noise (the random variation left after removing the other two). An additive decomposition assumes the series equals trend + seasonal + residual; use model='multiplicative' instead when the seasonal swings grow as the level of the series rises.

Trend

# Long-term increase or decrease
# Example: Sales growing 10% year-over-year

import pandas as pd
import matplotlib.pyplot as plt

# Calculate moving average to see trend
df['trend'] = df['sales'].rolling(window=12).mean()
plt.plot(df['date'], df['sales'], label='Actual')
plt.plot(df['date'], df['trend'], label='Trend')
plt.legend()

Seasonality

# Repeating patterns at regular intervals
# Example: Retail sales spike every December

# Detect seasonal patterns
from statsmodels.tsa.seasonal import seasonal_decompose

decomposition = seasonal_decompose(df['sales'], model='additive', period=12)
decomposition.plot()

Noise (Residual)

# Random variation not explained by trend or seasonality
# Important for understanding data quality

residual = decomposition.resid
print(f"Residual std dev: {residual.std()}")

How do you check if a time series is stationary?

Run the Augmented Dickey-Fuller (ADF) test with statsmodels' adfuller(): its null hypothesis is that the series has a unit root (is non-stationary), so a p-value below 0.05 lets you reject that and treat the series as stationary. A stationary series has a roughly constant mean, variance and autocorrelation over time, which ARIMA-type models assume, so check this before fitting one.

Augmented Dickey-Fuller Test

from statsmodels.tsa.stattools import adfuller

# Test if series is stationary
result = adfuller(df['sales'])
print(f'ADF Statistic: {result[0]}')
print(f'p-value: {result[1]}')

# If p-value < 0.05, series is stationary
if result[1] < 0.05:
    print("Series is stationary")
else:
    print("Series is non-stationary - needs differencing")

How do you make a time series stationary?

Difference it: replace each value with its change from the previous period using .diff(), which removes a trend. If the variance grows with the level of the series, take the log first; if a seasonal pattern remains, apply a seasonal difference such as .diff(12) for monthly data. Re-run the ADF test after each step and stop once it passes, because over-differencing inflates the variance and adds artificial negative autocorrelation.

Differencing

# Remove trend by subtracting previous value
df['sales_diff'] = df['sales'].diff()

# Second differencing if needed
df['sales_diff2'] = df['sales_diff'].diff()

# Seasonal differencing: change from the same month last year
df['sales_diff12'] = df['sales'].diff(12)

Log Transformation

import numpy as np

# Stabilize variance
df['sales_log'] = np.log(df['sales'])

# Then difference if needed
df['sales_log_diff'] = df['sales_log'].diff()

Simple Forecasting Methods

A moving average forecasts the mean of the last N values, exponential smoothing weights recent values more heavily and can model trend and seasonality, and ARIMA models the series' own past values and past forecast errors after differencing.

Moving Average

# Average of last N observations
window = 3
df['forecast_ma'] = df['sales'].rolling(window=window).mean().shift(1)

Worked example: with monthly sales of 120, 132, 128, 141, 150 and 147 for January to June, the 3-month moving average forecast for July is (141 + 150 + 147) / 3 = 146. The .shift(1) makes each month's forecast use only the three months before it, so a forecast never sees the value it is predicting.

import pandas as pd

sales = pd.Series([120, 132, 128, 141, 150, 147],
                  index=pd.period_range('2026-01', periods=6, freq='M'))

# Forecast for each month = mean of the 3 months before it
forecast = sales.rolling(window=3).mean().shift(1)
print(forecast.round(2))
# 2026-01       NaN
# 2026-02       NaN
# 2026-03       NaN
# 2026-04    126.67
# 2026-05    133.67
# 2026-06    139.67
# Freq: M, dtype: float64

# Forecast for July = mean of April-June
print(sales.tail(3).mean())  # 146.0

Every forecast here comes in below the actual value (126.67 vs 141 in April, 133.67 vs 150 in May), and if July's sales reach 155 the July forecast misses by 9. A moving average systematically under-forecasts a rising series because it averages older, lower values, so use exponential smoothing with a trend term when the data trends.

Exponential Smoothing

from statsmodels.tsa.holtwinters import ExponentialSmoothing

# Accounts for trend and seasonality
model = ExponentialSmoothing(df['sales'],
                             trend='add',
                             seasonal='add',
                             seasonal_periods=12)
fit = model.fit()
forecast = fit.forecast(steps=12)  # Next 12 periods

ARIMA Model

from statsmodels.tsa.arima.model import ARIMA

# Auto-regressive Integrated Moving Average
# (p, d, q) = (autoregressive order, differencing, moving average order)
model = ARIMA(df['sales'], order=(1, 1, 1))
fit = model.fit()
forecast = fit.forecast(steps=12)

How do you evaluate a time series forecast?

Hold back the most recent periods as a test set, fit the model only on the data before them, forecast the held-out window and compare the forecast with what actually happened. Never split a time series randomly, because training on later values leaks information the model would not have when forecasting for real.

import numpy as np
from statsmodels.tsa.holtwinters import ExponentialSmoothing

# Hold out the last 12 periods as the test set
train = df['sales'].iloc[:-12]
test = df['sales'].iloc[-12:]

# Fit on the training data only, then forecast the test window
fit = ExponentialSmoothing(train, trend='add', seasonal='add',
                           seasonal_periods=12).fit()
predictions = fit.forecast(steps=len(test)).to_numpy()
actual = test.to_numpy()

# Calculate errors
mae = np.mean(np.abs(actual - predictions))
rmse = np.sqrt(np.mean((actual - predictions) ** 2))
mape = np.mean(np.abs((actual - predictions) / actual)) * 100

print(f"MAE: {mae:.2f}")
print(f"RMSE: {rmse:.2f}")
print(f"MAPE: {mape:.2f}%")

MAE is the average miss in the data's own units, RMSE penalizes large misses more heavily, and MAPE expresses the error as a percentage of the actual values but is undefined when any actual value is zero and unstable when values are close to zero. Compare every model with a naive baseline, such as repeating the same month's value from last year; a model that cannot beat it is not adding value.

Which forecasting method should you use?

Use a moving average or simple exponential smoothing for a flat series, Holt's linear method for a trend, Holt-Winters for a trend plus a repeating seasonal cycle, and ARIMA when autocorrelation remains after differencing. Keep a more complex model only if it beats the simpler one on the test set.

What the plot showsMethodstatsmodels
No trend or seasonalityMoving average or simple exponential smoothingExponentialSmoothing(y)
Trend, no seasonalityHolt's linear trendExponentialSmoothing(y, trend='add')
Trend and a repeating seasonal cycleHolt-WintersExponentialSmoothing(y, trend='add', seasonal='add', seasonal_periods=12)
Autocorrelation left after differencingARIMA, or SARIMA for seasonal dataARIMA(y, order=(1, 1, 1), seasonal_order=(1, 1, 1, 12))

What are the most common time series analysis mistakes?

Beyond random train/test splits, the most damaging mistakes are scoring a model that was fitted on the test period, decomposing a series that is too short for its seasonal cycle, and setting the wrong seasonal period.

Detecting Anomalies

Flag points that sit far from what the trend and seasonality predict, not far from the overall mean. Apply a 3-standard-deviation rule to the residuals left after decomposition rather than to the raw values: on a trending or seasonal series, the raw values' standard deviation is inflated by the trend and seasonal swings, so real spikes slip through.

# Flag residuals more than 3 standard deviations from their mean
resid = decomposition.resid
z = (resid - resid.mean()) / resid.std()

df['anomaly'] = z.abs() > 3
anomalies = df[df['anomaly']]

print(f"Found {len(anomalies)} anomalies")

With period=12, the first and last six observations have no residual (the centered moving average needs six values on each side), so this check cannot flag them.

Common Applications

Time series methods fit any metric recorded at regular intervals whose past values help predict its future values.

What Are the Key Takeaways for Time Series Analysis?

Plot and decompose the series first, make it stationary before ARIMA-style modeling, and trust only forecasts that were tested on recent data the model never saw.

Pro Tip: Start with simple methods (moving average, exponential smoothing) before jumping to complex models like ARIMA. Plot your data first to visually identify trends and seasonality!

โ† Back to Data Analysis Tips