Structural Break Detection in Economic Time Series

The Problem

Why does a structural break cause the CUSUM of OLS residuals to deviate from zero?

The OLS residuals are always zero after fitting.
Wrong: OLS minimises the sum of squared residuals, but residuals are generally non-zero for individual observations.
Residuals on one side of the break have a consistent sign, so their cumulative sum
drifts systematically rather than fluctuating around zero.
Correct: the OLS fit uses a single set of parameters for the whole series; where the true mean differs from the fitted mean, residuals are biased in one direction.
The CUSUM is defined as the sum of squared residuals, which grows with the break size.
Wrong: CUSUM is the cumulative sum of residuals (not squared), so it can go negative as well as positive.
A break always makes the residuals larger in absolute value, raising the CUSUM.
Wrong: absolute size alone does not cause drift; it is the consistent sign that produces the characteristic V-shape.

The CUSUM Statistic

$$C_t = \sum_{s=0}^{t} \hat{e}_s$$

$$\hat{\tau} = \mathop{\arg\max}_{t} |C_t|$$

A noise-free series has value 0 for steps 0-99 and value 3 for steps 100-199. The global mean is 1.5. At which step index (0-based) does $|C_t|$ reach its maximum?

Generating Synthetic Data

def make_breakpoint_data(
    n_steps=N_STEPS,
    break_step=BREAK_STEP,
    mean_before=MEAN_BEFORE,
    mean_after=MEAN_AFTER,
    noise_std=NOISE_STD,
    seed=SEED,
):
    """Return a Polars DataFrame with columns 'step' and 'value'.

    The series has a single structural break at break_step:
      - value[t] ~ N(mean_before, noise_std^2)  for t < break_step
      - value[t] ~ N(mean_after,  noise_std^2)  for t >= break_step

    The break is abrupt: there is no gradual transition.
    """
    rng = np.random.default_rng(seed)
    steps = np.arange(n_steps)
    means = np.where(steps < break_step, mean_before, mean_after)
    values = means + rng.normal(0.0, noise_std, n_steps)
    return pl.DataFrame({"step": steps, "value": values})

Mean-Only Residuals and CUSUM


def cusum(residuals):
    """Cumulative sum (CUSUM) of residuals.

    C_t = sum_{s=0}^{t} e_s  (0-indexed).

    A structural break shifts the expected sign of residuals, so C_t drifts
    away from zero before the break and then reverses afterwards.  The index
    of maximum |C_t| estimates the location of the break.
    """
    return np.cumsum(residuals)


def detect_break(cusum_values):
    """Return the index where |CUSUM| is largest.

    This index is the last step before the detected break: the series
    properties are estimated to shift at index detect_break(...) + 1.
    """
    return int(np.argmax(np.abs(cusum_values)))

Linear-Trend Residuals and CUSUM


A time series has GDP values that grow at roughly 2% per year with no sudden shift. Which CUSUM model is more appropriate?

Mean-only, because GDP is always measured as a level.
Wrong: a mean-only model applied to a trending series will produce a large CUSUM signal driven by the trend, not any break.
Linear-trend, because removing the trend first isolates departures from the trend.
Correct: the OLS detrending step removes the 2% annual growth so the CUSUM responds only to unexpected deviations.
Neither; CUSUM requires a stationary series before any fitting.
Wrong: CUSUM can be applied after any OLS fit; the choice of fit determines what kind of departure the CUSUM detects.
Mean-only, but only if the trend is very small.
Wrong: even a modest trend, cumulated over many steps, will dominate the CUSUM and make break detection unreliable.

Plotting

def plot_series_with_break(values, detected_break, filename):
    """Save a time-series plot with a vertical rule at the detected break."""
    n = len(values)
    df = pl.DataFrame({"step": np.arange(n, dtype=float), "value": values})
    series = (
        alt.Chart(df)
        .mark_line(color="steelblue", strokeWidth=1.5)
        .encode(
            x=alt.X("step:Q", title="Step"),
            y=alt.Y("value:Q", title="Value"),
        )
    )
    break_df = pl.DataFrame({"step": [float(detected_break + 1)]})
    break_rule = (
        alt.Chart(break_df)
        .mark_rule(color="firebrick", strokeWidth=2, strokeDash=[6, 3])
        .encode(x="step:Q")
    )
    chart = alt.layer(series, break_rule).properties(
        width=450,
        height=250,
        title=f"Time series with detected break at step {detected_break + 1}",
    )
    chart.save(filename)


def plot_cusum_comparison(cusum_mean, cusum_trend, true_break, filename):
    """Save CUSUM trajectories for both OLS models on the same axes."""
    n = len(cusum_mean)
    steps = np.arange(n, dtype=float)
    df = pl.DataFrame(
        {
            "step": np.concatenate([steps, steps]),
            "cusum": np.concatenate([cusum_mean, cusum_trend]),
            "model": ["mean-only"] * n + ["linear trend"] * n,
        }
    )
    lines = (
        alt.Chart(df)
        .mark_line(strokeWidth=2)
        .encode(
            x=alt.X("step:Q", title="Step"),
            y=alt.Y("cusum:Q", title="CUSUM"),
            color=alt.Color("model:N", legend=alt.Legend(title="OLS model")),
        )
    )
    break_df = pl.DataFrame({"step": [float(true_break)]})
    break_rule = (
        alt.Chart(break_df)
        .mark_rule(color="gray", strokeDash=[4, 4], strokeWidth=1.5)
        .encode(x="step:Q")
    )
    chart = alt.layer(lines, break_rule).properties(
        width=450,
        height=300,
        title="CUSUM comparison: mean-only vs. linear-trend residuals",
    )
    chart.save(filename)
Time series of 200 values with a clear upward jump around step 100 and a dashed vertical rule at that step.
Figure 1: Synthetic time series with a mean shift from 0.0 to 3.0 at step 100. The dashed red rule shows the break detected by the mean-only CUSUM at step 100 — exact agreement with the true break.
Two overlapping CUSUM curves both reaching their minimum near step 99 and returning toward zero by step 199; a dashed gray vertical line marks step 100.
Figure 2: CUSUM trajectories for mean-only (blue) and linear-trend (orange) OLS residuals. Both methods reach their maximum absolute value near step 99 and detect the break at step 100. With no trend in the data the two CUSUMs are nearly identical; the gray dashed line marks the true break.

Testing

CUSUM of zero residuals

CUSUM matches np.cumsum

Residuals sum to zero

Clean-signal break detection

Noisy detection within ten steps

import numpy as np
import pytest
from generate_breakpoint import make_breakpoint_data, BREAK_STEP, N_STEPS
from breakpoint import residuals_mean, residuals_trend, cusum, detect_break


def test_cusum_all_zero_residuals():
    # The CUSUM of a zero-residual sequence is identically zero.
    assert np.all(cusum(np.zeros(50)) == 0.0)


def test_cusum_matches_cumsum():
    # By definition CUSUM must equal numpy's cumsum on the same array.
    residuals = np.array([1.0, -2.0, 3.0, -0.5, 0.5])
    np.testing.assert_array_equal(cusum(residuals), np.cumsum(residuals))


def test_mean_residuals_sum_to_zero():
    # OLS residuals from a mean-only fit sum to zero because the least-squares
    # intercept equals the sample mean, centering the residuals exactly.
    df = make_breakpoint_data()
    values = df["value"].to_numpy()
    assert np.sum(residuals_mean(values)) == pytest.approx(0.0, abs=1e-10)


def test_trend_residuals_sum_to_zero():
    # OLS with an intercept always centers the residuals, so they also sum to
    # zero for the linear-trend fit.
    df = make_breakpoint_data()
    values = df["value"].to_numpy()
    assert np.sum(residuals_trend(values)) == pytest.approx(0.0, abs=1e-10)


def test_detect_break_clean_signal():
    # With no noise the CUSUM of mean-only residuals reaches its maximum
    # absolute value at index BREAK_STEP - 1 (the last step before the break),
    # because the cumulative deficit reaches its deepest point exactly there.
    values = np.concatenate(
        [
            np.zeros(BREAK_STEP),
            3.0 * np.ones(N_STEPS - BREAK_STEP),
        ]
    )
    detected = detect_break(cusum(residuals_mean(values)))
    assert detected == BREAK_STEP - 1


def test_detect_break_noisy_within_ten_steps():
    # With seed 7493418 and a signal-to-noise ratio of 3 (mean shift 3.0,
    # noise std 1.0), the detected break must be within 10 steps of the
    # true break.  detect_break returns the last index before the break,
    # so the estimated break location is detected + 1.
    df = make_breakpoint_data()
    values = df["value"].to_numpy()
    detected = detect_break(cusum(residuals_mean(values)))
    assert abs((detected + 1) - BREAK_STEP) <= 10

Structural break detection key terms

Structural break
A point in time at which the statistical properties of a time series (mean, variance, trend) change abruptly and permanently; also called a regime change or change point
CUSUM statistic $C_t$
$\sum_{s=0}^{t} \hat{e}_s$; cumulative sum of residuals; drifts systematically when the fitted model is wrong on one side of a break
Mean-only residuals
$\hat{e}_t = y_t - \bar{y}$; appropriate when the series is stationary around a constant mean; confounds trend and break if a trend is present
Linear-trend residuals
$\hat{e}_t = y_t - (a + bt)$ where $a$ and $b$ are found by np.polyfit; removes a fitted linear drift before forming the CUSUM; appropriate when the series has a deterministic trend
Break detection rule
$\hat{\tau} = \arg\max_t |C_t|$; the last index before the estimated break; the estimated break location is $\hat{\tau} + 1$

Exercises

Two break points

Modify make_breakpoint_data to introduce a second break: the mean shifts from 0.0 to 3.0 at step 60 and then from 3.0 to 1.0 at step 140. Plot the CUSUM of the mean-only residuals and explain why the statistic now shows two local extrema. Does detect_break find the larger or smaller break?

Effect of signal-to-noise ratio

Run the detection algorithm for five signal-to-noise ratios ($\Delta\mu / \sigma = $ 0.5, 1, 2, 3, 5) while keeping all other parameters fixed. For each ratio, report the detected break location and its distance from the true break. At what SNR does reliable detection (within 5 steps) become consistent across different random seeds?

Trend with a break

Generate a series with a linear trend ($b = 0.05$ per step) and a mean break at step 100. Apply both the mean-only and linear-trend CUSUM and compare the detected break locations. Show that the mean-only CUSUM gives a biased estimate when a trend is present.

Scaled CUSUM and critical values

The raw CUSUM is not scale-invariant: a larger noise standard deviation produces larger CUSUM values even with no break. Normalise the CUSUM by dividing by $\hat{\sigma}\sqrt{T}$ where $\hat{\sigma}$ is the residual standard error and $T$ is the series length. The scaled statistic exceeds 1.36 with probability 5% under the null hypothesis of no break (Brown-Durbin-Evans critical value). Apply this threshold to the synthetic data and report whether the break is detected at the 5% level.