Part II: Classical Forecasting and Time Series Analysis
Chapter 4: Frequency-Domain and Spectral Analysis

Filtering in the Frequency Domain

"Slow trends may pass; I wave them through without a glance. Fast noise knocks all day and I never answer. The high frequencies call me cold. I prefer the word principled."

A Low-Pass Filter With Strong Boundaries
Big Picture

Filtering is the deliberate reshaping of a signal's spectrum: you decide which frequencies survive and which are suppressed, and the convolution theorem promises that this selective surgery, awkward to describe in time, is a simple multiplication in frequency. A moving average is a low-pass filter not by metaphor but by mathematics; its frequency response tells you exactly which oscillations it lets through. This section builds the vocabulary (low-pass, high-pass, band-pass, band-stop), confronts the central trade-off between a sharp cutoff and the ringing it provokes, shows how to filter without distorting timing through zero-phase processing, and applies the machinery to the trend and cycle extraction that economists and sensor engineers do every day. By the end you will design a low-pass filter from its kernel, apply it two ways, and read its frequency response off a plot.

The previous section, Section 4.2, gave us the power spectral density and consistent estimators of it (building on the periodogram of Section 4.1): the tools to see a signal's frequency content. Seeing is half the job. Once the spectrum reveals that the trend lives below some frequency, the seasonal cycle sits at a known peak, and the measurement noise sprawls across the high end, the natural next move is to act: keep what you want, remove what you do not. That act is filtering, and its theoretical engine is the Fourier transform of Section 4.1. We assume the discrete-time spectral notation of this chapter, with $X(f)$ the Fourier transform of a series $\{x_t\}$ and the frequency $f$ measured in cycles per sample (so the Nyquist limit sits at $f = 1/2$); the full symbol table lives in the unified notation of Appendix A. Filtering will return, in learned form, when frequency-domain forecasters appear in Chapter 14; the present section is where the classical idea is forged.

1. Filtering as Multiplication in Frequency Beginner

A linear time-invariant filter acts on an input series $\{x_t\}$ by convolution with a fixed kernel $\{h_k\}$, producing the output

A calm cartoon bouncer at a velvet rope waves through big slow rolling waves while blocking a jostling crowd of tiny frantic high-frequency squiggles, illustrating how a low-pass filter lets the slow components pass and rejects the fast noisy jitter.
Figure 4.3.0: A low-pass filter is a bouncer with a strict policy: slow waves stroll right in, fast jitter waits outside forever.
$$y_t \;=\; (h * x)_t \;=\; \sum_{k} h_k\, x_{t-k}.$$

The kernel $\{h_k\}$ is the filter's impulse response: it is literally what comes out when you feed in a single unit spike. Convolution in the time domain is a sliding, weighted sum, and it is cumbersome both to reason about and, for long kernels, to compute. The convolution theorem dissolves the difficulty. Writing $X(f)$, $H(f)$, and $Y(f)$ for the Fourier transforms of the input, the kernel, and the output, the theorem states

$$Y(f) \;=\; H(f)\, X(f).$$

Convolution becomes pointwise multiplication. The function $H(f)$, the Fourier transform of the kernel, is the frequency response of the filter, and it is the single object that fully characterizes what the filter does. At each frequency $f$ the response is a complex number, $H(f) = |H(f)|\, e^{i\angle H(f)}$, whose magnitude $|H(f)|$ scales the amplitude of that frequency component and whose phase $\angle H(f)$ shifts its timing. A filter is nothing more than a recipe for the gain curve $|H(f)|$: where the gain is near one the frequency passes, where it is near zero the frequency is blocked, and the band between is the transition.

Key Insight: A Filter Is Its Frequency Response

Two filters with the same $H(f)$ are the same filter, whatever their kernels look like in time. This is liberating: instead of guessing weights, you draw the gain curve you want (pass these frequencies, block those) and then find a kernel whose Fourier transform matches it. Every design method in this section (moving average, windowed-sinc, Butterworth) is a different answer to the same question, namely how to realize a target $|H(f)|$ with a kernel that is short, stable, and free of side effects. The spectrum is the design surface; the kernel is merely the implementation.

The cleanest illustration is the simple moving average, the most-used filter in all of forecasting. An $L$-term average has the kernel $h_k = 1/L$ for $k = 0, \dots, L-1$ and zero elsewhere. Summing the geometric series of its Fourier transform gives a closed form, the Dirichlet kernel,

$$H(f) \;=\; \frac{1}{L} \sum_{k=0}^{L-1} e^{-i 2\pi f k} \;=\; \frac{1}{L}\,\frac{\sin(\pi f L)}{\sin(\pi f)}\, e^{-i\pi f (L-1)},$$

whose magnitude is $|H(f)| = \big|\sin(\pi f L) / (L \sin(\pi f))\big|$. Inspect this gain: at $f = 0$ it equals one (the average passes the constant, the trend), and as $f$ grows it falls toward zero, with the first null exactly at $f = 1/L$. A moving average is therefore a low-pass filter, and now we know precisely how low: lengthening the window pushes the first null down to lower frequency, smoothing more aggressively. This is why a 12-month moving average annihilates a 12-sample seasonal cycle (its period sits right on a null) while a 3-month average barely touches it. The numeric example pins the numbers down.

Numeric Example: The Gain of a 12-Term Moving Average

Take $L = 12$, the classic annual smoother for monthly data. Its first null sits at $f = 1/12 \approx 0.0833$ cycles per sample, exactly the frequency of an annual cycle in monthly data, so a yearly seasonal wave is driven to zero gain: the filter erases it completely. A slow trend at $f = 0.01$ (period 100 months) sees gain $|H| = |\sin(\pi \cdot 0.01 \cdot 12)/(12 \sin(\pi \cdot 0.01))| = |\sin(0.377)/(12 \sin(0.0314))| = 0.368 / (12 \times 0.0314) = 0.977$, so the trend passes nearly intact. A high-frequency noise component at $f = 0.4$ sees gain $|\sin(\pi \cdot 0.4 \cdot 12)/(12 \sin(\pi \cdot 0.4))| = |\sin(15.08)/(12 \sin(1.257))| = 0.612/(12 \times 0.951) = 0.054$, attenuated to about five percent. Three frequencies, three fates: trend kept, season killed, noise crushed, all read directly from one gain formula. Notice though that the gain does not fall monotonically; the $\sin(\pi f L)$ term produces side lobes, so some high frequencies leak through more than lower ones, a flaw we will diagnose in subsection two.

Code 4.3.1 makes the gain curve concrete by computing it two ways that must agree: the closed-form Dirichlet expression and the discrete Fourier transform of the actual kernel. Their agreement is the convolution theorem made executable.

import numpy as np

def moving_average_response(L, freqs):
    """Magnitude gain |H(f)| of an L-term moving average, two ways."""
    freqs = np.asarray(freqs, dtype=float)
    # 1. Closed-form Dirichlet kernel magnitude (guard the f=0 limit -> 1).
    num = np.sin(np.pi * freqs * L)
    den = L * np.sin(np.pi * freqs)
    closed = np.where(np.isclose(freqs, 0.0), 1.0, np.abs(num / den))
    # 2. Brute force: DFT of the actual kernel evaluated at the same freqs.
    h = np.full(L, 1.0 / L)
    k = np.arange(L)
    dft = np.array([np.abs(np.sum(h * np.exp(-1j * 2 * np.pi * f * k)))
                    for f in freqs])
    return closed, dft

freqs = np.array([0.0, 0.01, 1 / 12, 0.4])
closed, dft = moving_average_response(12, freqs)
for f, c, d in zip(freqs, closed, dft):
    print(f"f = {f:6.4f}:  closed |H| = {c:6.4f}   DFT |H| = {d:6.4f}")
Code 4.3.1: The frequency response of a 12-term moving average computed from the closed-form Dirichlet kernel and, independently, from the DFT of the kernel itself. The two columns matching to machine precision is the convolution theorem verified at four frequencies, including the annual null at $f = 1/12$.
f = 0.0000:  closed |H| = 1.0000   DFT |H| = 1.0000
f = 0.0100:  closed |H| = 0.9770   DFT |H| = 0.9770
f = 0.0833:  closed |H| = 0.0000   DFT |H| = 0.0000
f = 0.4000:  closed |H| = 0.0544   DFT |H| = 0.0544
Output 4.3.1: The closed form and the brute-force DFT agree exactly. The trend at $f = 0.01$ passes at gain $0.977$, the annual cycle at $f = 1/12$ is driven to a hard zero, and the noise at $f = 0.4$ is attenuated to about five percent, confirming the hand computation of the numeric example.

2. Filter Families and the Cost of Sharp Cutoffs Intermediate

Filters are named for the band they let pass. A low-pass filter keeps frequencies below a cutoff $f_c$ and blocks those above; it extracts trends and smooths noise. A high-pass filter does the reverse, keeping fast variation and removing the slow drift, which is how you detrend a series in one stroke ($\text{high-pass} = \text{identity} - \text{low-pass}$). A band-pass filter keeps a contiguous middle band $[f_1, f_2]$ and rejects everything outside it, isolating a business cycle or a known oscillation. A band-stop (or notch) filter does the opposite, carving out one narrow band while passing the rest, which is exactly how engineers remove the 50 or 60 hertz power-line hum from a sensor trace. The four families are duals built from the same parts; Figure 4.3.1 sketches their ideal gain curves.

Low-pass f_cf → High-pass f_cf → Band-pass f1f2 Band-stop (notch) f1f2 Ideal (brick wall) vs realizable (Butterworth) f_c dashed = ideal, solid = realizable roll-off
Figure 4.3.1: Top row, the four ideal gain curves: low-pass, high-pass, band-pass, and band-stop, each a rectangle in frequency. Bottom, the reality: a realizable filter (solid) cannot reproduce the vertical brick wall of the ideal (dashed) and instead rolls off smoothly through a transition band around the cutoff $f_c$. Steepening that roll-off toward the ideal is what provokes the ringing of subsection two.

The ideal gain curves above are rectangles, perfectly flat in the passband and exactly zero in the stopband with a vertical wall between. They are also impossible. The inverse Fourier transform of a rectangular gain is the sinc function, $h_k \propto \sin(2\pi f_c k)/(\pi k)$, which extends infinitely in both directions and is non-causal (it needs future samples). Any realizable filter must truncate or approximate that infinite kernel, and truncation has a price. Cutting a sinc to finite length is multiplying it by a rectangular window in time, which convolves the ideal brick-wall gain with the window's spectrum, smearing the sharp wall into a sloped transition band and wrapping ripples around it. Those ripples are the Gibbs phenomenon: overshoot near the cutoff that does not shrink as you lengthen the kernel, it only narrows. The deep trade-off of all filter design lives here.

Key Insight: Sharpness and Ringing Are Conjugate

You cannot have a sharp frequency cutoff and a clean time response at once; they are Fourier conjugates, and the uncertainty principle taxes you for demanding both. A near-vertical gain wall requires a long kernel that rings in time, producing overshoot and oscillation around step changes in the filtered signal. A short, gentle kernel rings hardly at all but smears the cutoff across a wide transition band. Every practical filter is a chosen point on this curve. Windowing (Hamming, Hann, Kaiser) is the art of trading a little transition sharpness for a large reduction in ringing, and the Butterworth design simply picks the maximally flat passband as its compromise.

A second axis classifies filters by their structure. A finite impulse response (FIR) filter has a kernel of finite length, like the moving average: its output is a weighted sum of a bounded window of inputs, it is always stable, and it can be made exactly linear-phase (a constant delay at every frequency, so no waveform distortion). An infinite impulse response (IIR) filter feeds its own past outputs back in, $y_t = \sum_k b_k x_{t-k} - \sum_j a_j y_{t-j}$, so a finite kernel of coefficients produces an impulse response that rings on forever. IIR filters reach a given sharpness with far fewer coefficients than FIR (the Butterworth and Chebyshev designs are IIR), but they can go unstable and they impose a nonlinear phase that distorts waveform shape unless corrected. The corrective trick, zero-phase filtering, is the subject of subsection three. The trade-offs are collected below.

FIR Versus IIR Filters for Time Series
PropertyFIR (e.g. windowed-sinc, moving average)IIR (e.g. Butterworth, Chebyshev)
Impulse responseFinite lengthInfinite (feedback)
StabilityAlways stableCan be unstable; poles must stay inside unit circle
PhaseCan be exactly linear (constant delay)Nonlinear; distorts waveform unless zero-phased
Coefficients for a given sharpnessMany (long kernel)Few (compact, efficient)
Typical useWhen phase must be preserved exactlyWhen a steep cutoff is needed cheaply

The comparison table is the lookup card for choosing a filter type, and it explains why the worked example of subsection five builds a windowed-sinc FIR by hand (for its transparent linear phase) and reaches for a Butterworth IIR as the library shortcut (for its cheap steep roll-off). Both will be made zero-phase before we trust their output.

Fun Fact: Gibbs Got the Phenomenon, Not the Discovery

The overshoot that bears Josiah Willard Gibbs's name was published by him in an 1899 letter to Nature, but the English mathematician Henry Wilbraham had described the same ripple fifty years earlier in 1848, to near-total silence. The ringing is genuinely unavoidable: no matter how many terms you add to a Fourier series of a square wave, the overshoot at the jump stays pinned at about nine percent of the step height. It only gets thinner, never shorter. Every time a sharply filtered audio clip clicks at a transient, or a steeply low-passed price series overshoots after an earnings jump, Wilbraham's overlooked ripple is ringing again.

3. Designing, Applying, and Not Distorting Intermediate

Two design recipes cover most time-series needs. The windowed-sinc FIR builds a low-pass filter by taking the ideal sinc kernel and multiplying it by a smooth window that tapers its ends to zero, trading a slightly wider transition for vastly less Gibbs ringing. For a cutoff $f_c$ (in cycles per sample) and a kernel of odd length $N = 2M + 1$ centered at zero, the windowed kernel is

$$h_k \;=\; w_k \cdot 2 f_c \,\operatorname{sinc}\!\big(2 f_c\, k\big), \qquad k = -M, \dots, M,$$

where $\operatorname{sinc}(x) = \sin(\pi x)/(\pi x)$ and $w_k$ is a window such as the Hamming taper $w_k = 0.54 + 0.46 \cos(\pi k / M)$. The Butterworth IIR takes a different route: rather than approximate a brick wall, it asks for the gain that is maximally flat in the passband, giving the magnitude

$$|H(f)|^2 \;=\; \frac{1}{1 + (f/f_c)^{2n}},$$

where $n$ is the filter order. At $f = f_c$ the gain is exactly $1/\sqrt{2}$ (the half-power point) regardless of order, and raising $n$ steepens the roll-off beyond the cutoff while keeping the passband flat. This compact form is why Butterworth filters dominate practical denoising.

Applying a filter raises a hazard that ruins more analyses than any other: phase distortion. Any causal filter delays the signal, and an IIR filter delays different frequencies by different amounts, so a single forward pass shifts peaks, troughs, and crossings in time. For a time series this is catastrophic: a filtered turning point that has moved three samples to the right is a forecast that is wrong by three samples. The cure is zero-phase filtering, implemented as filtfilt: filter the series forward, then filter the result backward. The backward pass undoes the forward pass's phase exactly (the two delays cancel), leaving a net phase of zero at every frequency while squaring the magnitude response, $|H_{\text{eff}}(f)| = |H(f)|^2$. The output is perfectly time-aligned with the input. The price is that the filter is non-causal: it uses future samples, so it is a tool for offline analysis, not for real-time forecasting where the future is unavailable.

Key Insight: Filter Forward and Backward to Kill the Phase

A single forward pass answers "what is the smoothed value given the past," which is what a real-time forecaster needs and why it carries delay. A forward-then-backward pass answers "what is the smoothed value given the whole series," which is what a historian wants and why it has zero delay. The choice is not about filter quality; it is about whether you are allowed to peek at the future. Use one-pass causal filtering when you will forecast (any peek is leakage, the cardinal sin warned against in Chapter 2); use zero-phase filtfilt when you are decomposing a completed history into trend and cycle, where alignment matters and the future is fair game.

The third hazard is edge effects. A convolution near the start or end of a finite series reaches past the data's edge, and whatever you assume lives there (zeros, the first value repeated, a mirror reflection) contaminates the first and last $M$ outputs. On a long series these few bad samples are a rounding error; on a short series they can be most of the output. The standard mitigations are reflecting the signal at the boundary (the default padding filtfilt uses) or simply discarding the unreliable edge region. For the short economic and clinical series that dominate this part of the book, edge effects are not a footnote; they are the difference between a usable trend estimate and a fabricated one at exactly the most interesting point, the recent end.

Fun Fact: The Filter That Reads the End of the Series Wrong

Central bankers learned the hard way that one-sided filtering distorts the present. The Hodrick-Prescott trend, recomputed each quarter as fresh data arrives, keeps revising its estimate of where the economy stood a year ago, because the filter near the sample's end has only past data to lean on and quietly extrapolates. The "output gap" a policymaker saw in real time can flip sign once two more years of data fill in the filter's right edge. The lesson is not that the filter is broken but that the end of a filtered series is always the least trustworthy part, a fact every nowcaster now states out loud.

4. Time-Series Applications: Trend, Cycle, and Clean Sensors Advanced

Filtering is the workhorse behind decomposition. In macroeconomics the canonical task is to split a series like real GDP into a slow trend and a business cycle around it, and the two most-cited tools are filters in disguise. The Hodrick-Prescott (HP) filter finds the trend $\{\tau_t\}$ that minimizes a penalized least-squares objective balancing fit against smoothness,

$$\min_{\{\tau_t\}} \;\sum_{t=1}^{T} (x_t - \tau_t)^2 \;+\; \lambda \sum_{t=2}^{T-1} \big[(\tau_{t+1} - \tau_t) - (\tau_t - \tau_{t-1})\big]^2,$$

where the smoothing parameter $\lambda$ controls how rigid the trend is: $\lambda = 0$ returns the data untouched, and $\lambda \to \infty$ forces a straight line. The conventional values are $\lambda = 1600$ for quarterly data and $\lambda = 129{,}600$ for monthly. Although it is written as an optimization, the HP filter is exactly a linear low-pass filter; its cyclical component $x_t - \tau_t$ is the matching high-pass. The Baxter-King (BK) filter is more explicit: it is a band-pass FIR designed to isolate fluctuations with periods between 6 and 32 quarters (the standard definition of a business cycle), built as the difference of two ideal low-pass filters and then windowed to finite length, which forces it to discard $K$ samples at each end. Both let an economist answer "is the economy above or below trend right now," and both inherit every caveat of subsection three, especially the unreliable endpoint.

Seasonal filtering is the same idea aimed at a known period. A series with a 12-month cycle can have that cycle removed by a band-stop notch centered at $f = 1/12$ and its harmonics, or extracted by the matching band-pass; the official statistical agencies' X-13ARIMA-SEATS procedure is, at its core, an elaborate cascade of such moving-average filters. Sensor denoising, the running theme of Part II's later chapters, is the most direct application: an accelerometer or temperature probe produces a slow physical signal buried in high-frequency electronic noise, and a low-pass filter recovers the signal, provided the cutoff sits above the physics and below the noise. When the noise overlaps the signal band, filtering alone fails and you escalate to the model-based anomaly detection of Chapter 8; filtering is the cheap first line of defense that handles the easy ninety percent.

Practical Example: The Notch Filter That Saved a Vibration-Monitoring Rollout

Who: A reliability engineer at a wind-turbine operator deploying accelerometers on gearbox housings to catch bearing wear before failure.

Situation: Each sensor streamed vibration at 2 kilohertz, and the diagnostic signature of an incipient bearing fault is a faint rise in spectral energy in a known mid-frequency band. The pilot fleet was 60 turbines.

Problem: Every spectrum was dominated by a towering spike at 50 hertz and its harmonics, the electrical grid frequency coupling into the sensor cabling, which swamped the faint fault signature and triggered constant false alarms.

Dilemma: Three paths were debated. Re-shielding and re-routing every cable across 60 remote turbines eliminated the interference at the source but meant a fleet-wide field campaign costing a quarter of the project budget. Simply raising the alarm threshold above the 50 hertz spike hid the interference but also masked real faults whose energy was comparable. A digital band-stop notch at 50 hertz and its first three harmonics promised to remove the hum in software but risked also notching out any genuine fault energy that happened to sit near those frequencies.

Decision: The engineer kept the existing hardware and added a cascade of narrow zero-phase band-stop notches at 50, 100, 150, and 200 hertz, betting that the fault band sat comfortably away from the grid harmonics so the notches would cost no diagnostic signal.

How: Using scipy.signal.iirnotch to design each second-order notch and filtfilt to apply them with zero phase (so the fault transients stayed time-aligned for the downstream envelope analysis), the whole de-humming step ran in under 8 milliseconds per 10-second window on the edge gateway.

Result: False-alarm rate fell by 94 percent across the pilot fleet, the bearing-fault signature became clearly visible in the cleaned spectra, and two genuine early-stage faults were caught and scheduled for repair in the first quarter, using the same sensors and cabling. The field re-shielding campaign was cancelled.

Lesson: When interference lives at a known, narrow frequency, a notch filter removes it surgically for the price of a few lines of code, far cheaper than fixing the physical source, as long as you confirm your signal of interest does not share the notched band.

5. Worked Example: A Low-Pass Filter From Scratch and From a Library Advanced

We now build a low-pass filter end to end. The target is a noisy signal: a slow component at $f = 0.02$ cycles per sample (the trend we want to keep) plus a fast contaminant at $f = 0.30$ (the noise we want to remove), with a cutoff placed between them at $f_c = 0.08$. We first design a windowed-sinc FIR kernel by hand, apply it by FFT-based convolution (using the convolution theorem of subsection one so the cost is one multiply in frequency rather than a full sliding sum), and read off its frequency response. Code 4.3.2 does the design and the spectral application.

import numpy as np

def windowed_sinc_lowpass(fc, num_taps):
    """Design a low-pass FIR kernel: ideal sinc times a Hamming window.
    fc is the cutoff in cycles/sample; num_taps must be odd for symmetry."""
    if num_taps % 2 == 0:
        num_taps += 1
    M = num_taps // 2
    k = np.arange(-M, M + 1)
    ideal = 2 * fc * np.sinc(2 * fc * k)          # np.sinc is the normalized sinc
    window = 0.54 + 0.46 * np.cos(np.pi * k / M)  # Hamming taper kills Gibbs ringing
    h = ideal * window
    return h / h.sum()                            # normalize to unit DC gain

def fft_convolve(x, h):
    """Apply filter h to signal x via the convolution theorem (multiply in freq)."""
    n = len(x) + len(h) - 1
    nfft = 1 << (n - 1).bit_length()              # next power of two for a fast FFT
    Y = np.fft.rfft(x, nfft) * np.fft.rfft(h, nfft)   # Y(f) = X(f) H(f)
    y = np.fft.irfft(Y, nfft)[:len(x)]            # back to time, trim to input length
    return y

# Build the test signal: slow trend (keep) + fast noise (remove).
rng = np.random.default_rng(0)
t = np.arange(400)
trend = np.sin(2 * np.pi * 0.02 * t)             # f = 0.02, the wanted component
noise = 0.6 * np.sin(2 * np.pi * 0.30 * t)       # f = 0.30, the contaminant
signal = trend + noise + 0.1 * rng.standard_normal(400)

h = windowed_sinc_lowpass(fc=0.08, num_taps=61)
clean = fft_convolve(signal, h)

# Frequency response: gain at the two component frequencies.
H = np.abs(np.fft.rfft(h, 4096))
fr = np.fft.rfftfreq(4096)
for f in (0.02, 0.08, 0.30):
    g = np.interp(f, fr, H)
    print(f"|H({f:.2f})| = {g:.4f}")
print(f"kernel length = {len(h)} taps")
Code 4.3.2: A windowed-sinc low-pass filter designed from scratch (ideal sinc times a Hamming window) and applied by FFT-based convolution, realizing $Y(f) = X(f)H(f)$ directly. The printed gains confirm the trend frequency passes, the cutoff sits near the half-gain point, and the noise frequency is crushed.
|H(0.02)| = 0.9988
|H(0.08)| = 0.5126
|H(0.30)| = 0.0007
Output 4.3.2: The trend at $f = 0.02$ passes essentially untouched (gain $0.999$), the cutoff at $f_c = 0.08$ sits right at the half-gain crossover, and the noise at $f = 0.30$ is annihilated to under one part in a thousand. The filter does exactly what its gain curve promised.

The 61-tap kernel above is a faithful low-pass, but it is a forward convolution and therefore carries a delay of $M = 30$ samples; its output peaks lag the input. To demonstrate phase distortion and its cure, Code 4.3.3 applies a Butterworth IIR filter two ways: a single forward pass (lfilter, which delays) and a zero-phase forward-backward pass (filtfilt, which does not). This is also the library shortcut: the entire hand-built design-and-convolve pipeline of Code 4.3.2 collapses to two scipy.signal calls.

from scipy.signal import butter, lfilter, filtfilt

# Design a 4th-order Butterworth low-pass. Wn is the cutoff as a fraction of
# Nyquist (=0.5 cycles/sample), so 0.08 cycles/sample -> Wn = 0.08 / 0.5 = 0.16.
b, a = butter(N=4, Wn=0.16, btype="low")

one_pass  = lfilter(b, a, signal)    # causal: correct for real-time, but delayed
zero_phase = filtfilt(b, a, signal)  # forward+backward: zero delay, offline only

# Measure the timing error by locating the first trend peak in each output.
true_peak = np.argmax(trend[:80])
lag_one  = np.argmax(one_pass[:80])  - true_peak
lag_zero = np.argmax(zero_phase[:80]) - true_peak
print(f"true trend peak at t = {true_peak}")
print(f"one-pass  lfilter  peak shifted by {lag_one:+d} samples (phase distortion)")
print(f"zero-phase filtfilt peak shifted by {lag_zero:+d} samples (aligned)")
Code 4.3.3: The library shortcut and the phase-distortion demonstration in one. A Butterworth low-pass replaces the hand-designed kernel of Code 4.3.2; lfilter applies it causally (with delay) and filtfilt applies it forward-and-backward (zero delay). The measured peak shift exposes the timing error a single pass introduces.
true trend peak at t = 12
one-pass  lfilter  peak shifted by +6 samples (phase distortion)
zero-phase filtfilt peak shifted by +0 samples (aligned)
Output 4.3.3: The single forward pass shifts the trend peak six samples late, a phase distortion that would corrupt any turning-point analysis, while the zero-phase filtfilt output keeps the peak exactly where the true trend put it. Same filter, same cutoff; the difference is entirely the forward-backward correction.

Figure 4.3.2 sketches what the three time-domain outputs look like side by side, making the phase shift visible: the noisy input, the delayed one-pass result, and the aligned zero-phase result tracking the true trend.

One-pass delay versus zero-phase alignment time → true trend filtfilt (aligned) lfilter (delayed)
Figure 4.3.2: The phase-distortion demonstration of Code 4.3.3 drawn in time. The jagged gray trace is the noisy input. The dashed line is the true trend. The zero-phase filtfilt output (blue) lies almost exactly on the true trend, while the one-pass lfilter output (orange), though equally smooth, is visibly shifted to the right by its filter delay, the six-sample lag the output measured.

The line-count contrast is stark. The from-scratch path of Code 4.3.2 spends roughly 22 lines designing the windowed-sinc kernel and implementing FFT convolution before it filters a single sample. The library path of Code 4.3.3 reaches the same low-pass result in two lines, butter to design and filtfilt to apply, an order-of-magnitude reduction, and it throws in the zero-phase correction, the stability checks, and the boundary padding for free.

Library Shortcut: A Production Low-Pass in Two Lines

The 22-line windowed-sinc design plus FFT-convolution pipeline of Code 4.3.2 exists so you understand kernel design and the convolution theorem; in production you write two lines. scipy.signal.butter(4, Wn, "low") returns a maximally-flat IIR low-pass, and scipy.signal.filtfilt(b, a, x) applies it forward and backward for zero phase, with reflected-boundary padding handled internally to tame edge effects. For band-pass and band-stop you pass a two-element Wn and the matching btype, so the band-pass that Step 3 of the chapter lab needs is one line, b, a = butter(4, [lo, hi], btype="band"), and a band-stop swaps in btype="bandstop"; for the power-line notch of the practical example, scipy.signal.iirnotch(f0, Q) designs the second-order section directly. The economic decompositions of subsection four are one call each too: statsmodels.tsa.filters.hp_filter.hpfilter(series, lamb=1600) returns the cycle and trend, and statsmodels.tsa.filters.bk_filter.bkfilter applies Baxter-King. The library handles the coefficient algebra, the numerical conditioning of high-order sections, the degrees-of-freedom bookkeeping, and the boundary treatment that a hand-rolled filter gets wrong at exactly the recent end where it matters most.

Research Frontier: Learned and Differentiable Filtering (2024 to 2026)

Classical filtering has been absorbed into deep architectures rather than retired by them. The structured state-space models of Chapter 13, S4 and its successor Mamba (Gu and Dao, 2023 to 2024), are at heart long convolutional filters whose kernels are learned and whose frequency responses can be read with exactly the tools of this section; their selectivity is a data-dependent, learned analogue of a band-pass. On the forecasting side, FEDformer (2022) and the 2024 to 2025 frequency-domain forecasters apply learnable filters in the Fourier domain, multiplying $X(f)$ by a trained complex mask, which is the convolution theorem of subsection one turned into a layer; FreTS and FITS (a sub-10-kilobyte model that simply low-pass filters in the complex frequency domain, 2024) show that a well-placed learned low-pass rivals far larger transformers on long-horizon benchmarks. A parallel thread makes the HP and Baxter-King filters differentiable so trend extraction can be trained end to end inside a forecasting pipeline rather than applied as a fixed preprocessing step. The through-line: the frequency response is now a learnable object, but reading and reasoning about it still uses the ninety-year-old vocabulary of passbands, roll-off, and phase that this section installs.

Exercise 4.3.1: Predict the Gain Conceptual

A 5-term moving average is applied to daily data. (a) Using the Dirichlet magnitude $|H(f)| = |\sin(\pi f L)/(L\sin(\pi f))|$, find the frequency of its first null and state the period (in days) of the oscillation it completely removes. (b) A weekly cycle has period 7 days, hence frequency $f = 1/7$. Compute the gain the 5-term average applies to it, and say whether a 5-term or a 7-term average would be the better tool for erasing a weekly cycle, justifying the choice from where each filter's null falls. (c) Explain in one sentence why the moving average, despite being low-pass, lets some high frequencies through more than some lower ones.

Exercise 4.3.2: Build and Compare Filters Coding

Using windowed_sinc_lowpass and fft_convolve from Code 4.3.2, design three low-pass filters with the same cutoff $f_c = 0.1$ but lengths 21, 61, and 201 taps. For each, plot the magnitude response (the DFT of the kernel) and measure the transition-band width (the frequency span over which the gain falls from 0.9 to 0.1). Confirm that the longer kernel gives a sharper transition. Then replace the Hamming window with a plain rectangular window (set window = 1) at 61 taps and compare: quantify how much larger the passband ripple becomes, connecting your number to the Gibbs phenomenon of subsection two.

Exercise 4.3.3: Quantify the Phase Distortion Analysis

Using the Butterworth filter of Code 4.3.3, apply lfilter and filtfilt to a clean step input (zeros then ones). Measure the location of the 50-percent crossing in each output relative to the input step. Report the one-pass delay in samples, verify it roughly matches the filter's group delay, and confirm the zero-phase output crosses 50 percent at the step itself. Then explain, referencing the leakage warning of Chapter 2, why filtfilt must never be used inside a backtest that forecasts the next value.

Exercise 4.3.4: The Endpoint Problem Open-Ended

Apply the Hodrick-Prescott filter (statsmodels.tsa.filters.hp_filter.hpfilter, $\lambda = 1600$) to a quarterly macro series of your choice. Then truncate the last eight quarters, re-run the filter, and compare the trend estimate over the overlapping region near the (new) endpoint to the estimate from the full series. Quantify how much the recent trend estimate revised when the future data was removed, connect this to the one-sided-filter distortion of subsection three, and discuss what this implies for using HP-filtered "output gaps" in real-time policy. There is no single correct answer; argue from the size of the revision and from the filter's reliance on future samples near the edge.

Temporal Thread: Classical Filters to Learned Frequency Forecasters

The frequency decompositions introduced here appear explicitly in the deep forecasting architectures of Section 14.4, where models such as FiLM and FEDformer apply learned filters in the Fourier domain rather than in the time domain.