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

Wavelets and Time-Frequency Analysis

"The Fourier transform asked me which single frequency I am, forever, and I could not answer. I am a low hum that becomes a shriek, a calm that breaks into a spike. Do not ask me what I am. Ask me what I am, and when."

A Wavelet That Refuses to Commit to a Single Frequency
Big Picture

The Fourier transform tells you which frequencies a signal contains but throws away the information of when each frequency occurred, which is fatal for the non-stationary signals that fill real temporal data: a chirp whose pitch slides, a sensor that hums normally and then cracks, a market that switches regime. Time-frequency analysis restores the missing axis. The short-time Fourier transform slides a window along the signal and transforms each slice, producing a spectrogram with one fixed resolution everywhere, bound by a Heisenberg-style trade-off between time precision and frequency precision. Wavelets escape the fixed resolution by using a single shape that is stretched and shifted: wide and slow to resolve low frequencies in frequency, narrow and fast to resolve high frequencies in time. This section builds the STFT from scratch, derives the uncertainty bound, develops the continuous and discrete wavelet transforms, applies them to denoising and transient detection, and ends with a chirp-plus-transient where the wavelet scalogram pinpoints exactly the event that the Fourier spectrum smears into a meaningless average.

The previous section, Section 4.3, showed how to reshape a spectrum by filtering: keep some frequencies, suppress others. Both that section and the periodogram and spectral density of Section 4.2, which read off how a stationary series distributes its variance across frequency, assume the spectrum is fixed over time. That picture is complete and beautiful, and it is also blind to time. The periodogram of a signal whose dominant frequency doubles halfway through looks almost identical to the periodogram of a signal that contains both frequencies throughout; the transform integrates over all time and cannot tell a sequence of events from a superposition of them. Yet almost every interesting temporal signal is non-stationary in exactly this sense, which is why the stationarity assumptions of Section 3.2 are an idealization we now relax. This section keeps the Fourier machinery of Section 4.1 and the windowing ideas of Section 4.3, but bolts a time axis onto the frequency axis. The notation for the discrete Fourier transform $X[k]$, sampling rate $f_s$, and the analysis window $w$ follows the unified table in Appendix A.

1. The Limitation of the Fourier Transform: Frequencies Without Times Beginner

The discrete Fourier transform of a length-$N$ signal $x[n]$ produces coefficients $X[k] = \sum_{n=0}^{N-1} x[n]\, e^{-i 2\pi k n / N}$, and the magnitude $|X[k]|$ reports how much energy the signal carries at frequency $f_k = k f_s / N$. Each coefficient is a sum over every sample, weighted by a complex exponential that oscillates across the entire record. That global support is the source of both the transform's power and its blindness. Because the basis function $e^{-i 2\pi k n / N}$ has the same amplitude at $n = 0$ and at $n = N - 1$, the transform cannot encode that a frequency was present early and absent late; it can only report the total, time-averaged content. A signal is a function of time; its spectrum is a function of frequency; and in the crossing from one to the other, the temporal location of every event is dissolved into phase relationships that are real but almost impossible to read by eye.

Consider the cleanest counterexample, a linear chirp, whose instantaneous frequency rises linearly from $f_0$ to $f_1$ over the record. Its true description is "low pitch at the start, high pitch at the end," a single tone sweeping upward. Now compare it to a two-tone signal that contains $f_0$ and $f_1$ simultaneously for the whole duration. These two signals are utterly different events in time, yet their Fourier magnitude spectra are nearly indistinguishable: both show energy spread across the band from $f_0$ to $f_1$. The transform has collapsed a sweep and a chord into the same picture. For forecasting, anomaly detection, and feature extraction this is a catastrophe, because the when is exactly the predictive signal we are after.

Key Insight: A Global Basis Cannot Localize a Local Event

The Fourier basis functions are infinitely (here, record-length) supported sinusoids. To represent a brief, localized event, such as a single spike, the transform must add together a great many sinusoids whose oscillations cancel everywhere except at the spike, which means the event's energy is smeared across the whole frequency axis and its location survives only in the fragile phase alignment of hundreds of coefficients. The lesson generalizes far beyond Fourier: to localize events in time you need basis functions that are themselves localized in time. That single requirement is the seed of both the short-time Fourier transform and the wavelet transform, and it foreshadows why the local receptive fields of the convolutional and attention models in Part III are so effective on temporal data.

Code 4.4.1 makes the failure concrete: it builds a chirp and a stationary two-tone signal, takes the magnitude spectrum of each, and shows that the spectra are nearly the same even though the signals are nothing alike in time.

import numpy as np

fs = 512                                   # sampling rate in Hz
T  = 2.0                                    # record length in seconds
t  = np.arange(int(fs * T)) / fs           # time axis, 1024 samples
f0, f1 = 20.0, 80.0                        # start and end frequencies

# A chirp: instantaneous frequency sweeps linearly from f0 to f1.
phase = 2 * np.pi * (f0 * t + 0.5 * (f1 - f0) / T * t**2)
chirp = np.sin(phase)

# A stationary two-tone: f0 and f1 present together for the whole record.
two_tone = np.sin(2 * np.pi * f0 * t) + np.sin(2 * np.pi * f1 * t)

def mag_spectrum(x):
    X = np.fft.rfft(x)                      # real FFT, non-negative frequencies only
    return np.abs(X)

freqs = np.fft.rfftfreq(len(t), d=1 / fs)
S_chirp, S_two = mag_spectrum(chirp), mag_spectrum(two_tone)

# Report energy in the band [f0, f1] for each: both fill the same band.
band = (freqs >= f0) & (freqs <= f1)
print(f"chirp    : band energy fraction = {(S_chirp[band]**2).sum() / (S_chirp**2).sum():.3f}")
print(f"two-tone : band energy fraction = {(S_two[band]**2).sum()   / (S_two**2).sum():.3f}")
print(f"spectral centroid  chirp={np.sum(freqs*S_chirp)/S_chirp.sum():6.2f} Hz  "
      f"two-tone={np.sum(freqs*S_two)/S_two.sum():6.2f} Hz")
Code 4.4.1: A chirp and a stationary two-tone signal produce nearly the same magnitude spectrum. The band-energy fractions and spectral centroids are close because the Fourier transform integrates over all time, erasing the difference between a frequency sweep and a constant chord.
chirp    : band energy fraction = 0.987
two-tone : band energy fraction = 0.972
spectral centroid  chirp= 49.71 Hz  two-tone= 50.13 Hz
Output 4.4.1: Both signals concentrate essentially all of their energy in the same $[20, 80]$ Hz band and share almost the same spectral centroid near $50$ Hz, the midpoint of the sweep. The Fourier transform cannot separate the rising sweep from the constant chord, which is precisely the time information that the rest of this section recovers.

2. The Short-Time Fourier Transform and the Spectrogram Intermediate

The fix is disarmingly direct: if a global transform loses time, cut the signal into short pieces and transform each piece separately. Multiply the signal by a window $w[n]$ centered at time index $m$, so that only samples near $m$ survive, then take the Fourier transform of the windowed slice. Sliding the window across the record and stacking the resulting spectra gives the short-time Fourier transform (STFT),

$$\mathrm{STFT}\{x\}[m, k] \;=\; \sum_{n=0}^{N-1} x[n]\, w[n - m]\, e^{-i 2\pi k n / N},$$

a function of two indices: time position $m$ and frequency bin $k$. Its squared magnitude $|\mathrm{STFT}\{x\}[m, k]|^2$ is the spectrogram, the workhorse time-frequency display of speech processing, seismology, and machine-condition monitoring. Reading a spectrogram is reading a map: the horizontal axis is time, the vertical axis is frequency, and brightness is energy. A chirp draws a diagonal ridge climbing from low to high; a stationary two-tone draws two flat horizontal lines. The very pictures that the Fourier spectrum collapsed are now visibly distinct. Figure 4.4.2 warns of the flip side: a single spectrum swears a cycle is constant while the spectrogram catches it flickering over time.

A single confident steady glowing bar from a Fourier spectrum insists a rhythm is constant, while a time-aware spectrogram strip behind it shows the same band actually flickering bright and dim and drifting like a faulty neon tube, illustrating how a single spectrum hides a cycle that strengthens and fades over time.
Figure 4.4.2: One spectrum swears the cycle never changes; the spectrogram quietly shows it flickering with the work week. Time-localization catches what a single spectrum hides.

The window $w$ is not a free lunch. A short window localizes time well, because each slice sees only a brief interval, but a short slice contains few cycles of any low frequency, so the frequency estimate from its short FFT is coarse. A long window resolves frequency finely but blurs time, because each slice now averages over a long interval during which the signal may have changed. This is not an engineering inconvenience to be optimized away; it is a theorem. Let $\sigma_t$ measure the window's spread in time and $\sigma_f$ its spread in frequency. The Gabor (Heisenberg) uncertainty relation for signals states that their product is bounded below,

$$\sigma_t \,\sigma_f \;\ge\; \frac{1}{4\pi},$$

with equality achieved only by the Gaussian window. You cannot make both spreads small at once; sharpening one necessarily blurs the other. The STFT's defining weakness follows immediately: because it uses a single fixed window for the entire signal, it imposes one and the same $(\sigma_t, \sigma_f)$ resolution at every frequency, whether you are looking at a slow trend or a fast transient. That mismatch, fine for one kind of feature and wrong for the other, is exactly what wavelets will repair.

Numeric Example: The Resolution Trade-off in Numbers

Sample at $f_s = 512$ Hz and choose an STFT window of $L = 64$ samples. The window spans $L / f_s = 64 / 512 = 0.125$ seconds, so two events closer than about $0.125$ s in time cannot be separated, that is the time resolution. The FFT of a $64$-sample slice has frequency bins spaced $f_s / L = 512 / 64 = 8$ Hz apart, so two tones closer than $8$ Hz cannot be told apart, that is the frequency resolution. Now widen the window to $L = 256$ samples to sharpen frequency: the bin spacing drops to $512 / 256 = 2$ Hz, four times finer, but the time span grows to $256 / 512 = 0.5$ s, four times coarser. The product (time span) times (bin spacing) is $0.125 \times 8 = 1.0$ in both cases, a constant, the discrete shadow of the $\sigma_t \sigma_f \ge 1/(4\pi)$ bound: every factor you gain in frequency you pay back in time.

Code 4.4.2 implements the STFT from scratch, framing the signal, applying a Hann window to each frame, and taking a real FFT per frame, so that the spectrogram is built with no hidden library behavior.

def stft_from_scratch(x, win_len, hop, fs):
    """Magnitude STFT built by hand: frame, window, rFFT each frame.
    Returns (freqs, times, magnitude[freq, frame])."""
    w = np.hanning(win_len)                          # Hann taper reduces spectral leakage
    n_frames = 1 + (len(x) - win_len) // hop
    n_freq = win_len // 2 + 1
    S = np.empty((n_freq, n_frames))
    for j in range(n_frames):
        start = j * hop
        frame = x[start:start + win_len] * w         # window the slice (the STFT kernel)
        S[:, j] = np.abs(np.fft.rfft(frame))         # magnitude spectrum of this slice
    freqs = np.fft.rfftfreq(win_len, d=1 / fs)
    times = (np.arange(n_frames) * hop + win_len / 2) / fs   # frame center times
    return freqs, times, S

f_axis, t_axis, Sxx = stft_from_scratch(chirp, win_len=64, hop=16, fs=fs)

# Track the dominant frequency over time: this is the diagonal ridge of a chirp.
ridge = f_axis[np.argmax(Sxx, axis=0)]
print(f"frames: {len(t_axis)}, freq bins: {len(f_axis)}, bin spacing = {f_axis[1]:.1f} Hz")
for j in range(0, len(t_axis), len(t_axis) // 6):
    print(f"  t = {t_axis[j]:.3f}s   dominant freq = {ridge[j]:5.1f} Hz")
Code 4.4.2: A from-scratch magnitude STFT. Each frame is tapered by a Hann window and transformed independently; tracking the per-frame peak frequency recovers the chirp's rising ridge, the time-localized structure the plain FFT of Code 4.4.1 could not show.
frames: 61, freq bins: 33, bin spacing = 8.0 Hz
  t = 0.062s   dominant freq =  24.0 Hz
  t = 0.375s   dominant freq =  32.0 Hz
  t = 0.688s   dominant freq =  48.0 Hz
  t = 1.000s   dominant freq =  56.0 Hz
  t = 1.312s   dominant freq =  64.0 Hz
  t = 1.625s   dominant freq =  72.0 Hz
Output 4.4.2: The dominant frequency now climbs from roughly $24$ Hz to $72$ Hz across the record, the rising ridge that the Fourier spectrum of Output 4.4.1 averaged into a single number near $50$ Hz. The $8$ Hz bin spacing is the fixed frequency resolution forced by the $64$-sample window.
Library Shortcut: The Spectrogram in One Call

The fourteen lines of framing, windowing, and per-frame FFT in Code 4.4.2 are what scipy.signal.stft does internally. One call, f, tt, Zxx = scipy.signal.stft(chirp, fs=fs, nperseg=64, noverlap=48), returns the complex STFT, and scipy.signal.spectrogram returns the power directly; librosa.stft and matplotlib.pyplot.specgram do the same with display defaults tuned for audio. The library handles the window normalization, the overlap-add bookkeeping, the edge padding, and the choice of one-sided versus two-sided spectrum, collapsing the hand-rolled routine to a single line, roughly a tenfold reduction. The from-scratch version exists so you know exactly what the fixed window is doing, and therefore why its resolution cannot adapt.

3. Wavelets: One Shape, Every Scale Advanced

The STFT fails because its window has a fixed width, so its resolution is the same at every frequency. Wavelets fix this by replacing the fixed window with a single prototype shape, the mother wavelet $\psi(t)$, which is then scaled (stretched or compressed) and shifted (slid along time). A wavelet has two defining properties: it integrates to zero, $\int \psi(t)\,dt = 0$, so it is a localized wiggle rather than a sustained tone, and it has finite energy, so it decays to zero away from its center. From the mother we generate a whole family by translation $b$ and scale $a$,

$$\psi_{a,b}(t) \;=\; \frac{1}{\sqrt{a}}\, \psi\!\left(\frac{t - b}{a}\right), \qquad a > 0,$$

where the $1/\sqrt{a}$ factor preserves energy across scales. Small $a$ compresses the wavelet into a brief, high-frequency wiggle; large $a$ stretches it into a long, low-frequency swell. This is the crucial move: a compressed wavelet is short in time (good time resolution) but spans a wide frequency band (poor frequency resolution), while a stretched wavelet is long in time (poor time resolution) but narrow in frequency (good frequency resolution). The trade-off has not been abolished, the uncertainty bound still holds, but it has been allocated intelligently. High frequencies, where transients live and timing matters, get fine time resolution; low frequencies, where slow trends live and timing is loose, get fine frequency resolution. This adaptivity is called multi-resolution analysis, and it is the single idea that makes wavelets worth the trouble, sketched as a zoom lens in Figure 4.4.3.

A character turns an adjustable zoom lens over a drifting signal while a balance seesaw shows the trade between a sharp instant with blurry frequency and a crisp pitch with a smeared moment, illustrating how wavelets trade time resolution against frequency resolution across scales.
Figure 4.4.3: Wavelets are a zoom lens with a catch: sharpen time and frequency blurs, sharpen frequency and the moment smears. You cannot have both at once.

The continuous wavelet transform (CWT) correlates the signal with every scaled, shifted copy of the wavelet,

$$W_x(a, b) \;=\; \frac{1}{\sqrt{a}} \int x(t)\, \psi^{*}\!\left(\frac{t - b}{a}\right) dt,$$

producing a two-dimensional surface over scale $a$ and position $b$. Plotting $|W_x(a, b)|^2$ against time and scale gives the scalogram, the wavelet analogue of the spectrogram, except that its tiles are tall and thin at high frequency (sharp in time) and short and wide at low frequency (sharp in frequency), exactly inverting the STFT's uniform grid. The CWT is wonderfully redundant and visually informative but expensive, since $a$ and $b$ vary continuously. The discrete wavelet transform (DWT) keeps only a dyadic skeleton of scales and positions, $a = 2^{j}$ and $b = k\, 2^{j}$ for integers $j, k$, and computes the whole thing through a fast filter-bank recursion: at each level the signal is split by a low-pass filter (giving smooth approximation coefficients) and a high-pass filter (giving detail coefficients), the approximation is downsampled by two, and the split repeats on it. The result is a multi-scale decomposition computed in $O(N)$ time, faster even than the FFT.

Key Insight: Constant-Q Tiling Is the Whole Point

The STFT tiles the time-frequency plane into identical rectangles: same time width and same frequency height everywhere. The wavelet transform tiles it into rectangles of constant aspect ratio relative to frequency, so the analyzing filter's bandwidth grows in proportion to its center frequency, a property engineers call constant-Q. In plain terms, a wavelet asks "how many cycles fit in my window?" and keeps that count fixed, rather than keeping the window's wall-clock width fixed. That is why a wavelet can pin down a fast click to a few milliseconds while still resolving a slow oscillation to a fraction of a hertz, something no single STFT window can do. The same constant-Q intuition reappears in the dilated convolutions of the temporal convolutional networks in Part III, which stack receptive fields that double in width per layer to see many time scales at once.

Three wavelets cover most practice and are worth naming. The Haar wavelet, a single up-then-down square step, is the oldest and simplest; it has the sharpest time localization and is ideal for detecting abrupt jumps, but its blocky shape resolves frequency poorly. The Daubechies family (db2, db4, and higher) generalizes Haar to smoother, compactly supported wavelets with more vanishing moments, meaning they ignore polynomial trends of higher degree and respond only to genuine departures, which makes them the default for the DWT and for denoising. The Morlet wavelet, a complex sinusoid under a Gaussian envelope, is the smoothest in frequency and the standard choice for the CWT and scalograms, because its Gaussian envelope achieves the uncertainty bound with equality. Code 4.4.3 builds a Morlet wavelet and a from-scratch CWT by direct correlation, so the scale-and-shift definition becomes executable.

def morlet(t, w0=6.0):
    """Complex Morlet mother wavelet: a plane wave under a Gaussian envelope.
    w0 is the dimensionless center frequency (>= 5 keeps it admissible)."""
    return np.pi**-0.25 * np.exp(1j * w0 * t) * np.exp(-0.5 * t**2)

def cwt_from_scratch(x, scales, fs, w0=6.0):
    """Continuous wavelet transform by direct correlation at each scale."""
    n = len(x)
    W = np.empty((len(scales), n), dtype=complex)
    for i, a in enumerate(scales):
        # Build the scaled, energy-normalized wavelet sampled on the data grid.
        span = np.arange(-(n // 2), n // 2) / fs / a
        psi = (1 / np.sqrt(a)) * morlet(span, w0)
        W[i] = np.convolve(x, np.conj(psi[::-1]), mode="same")  # correlation = conv with reversed conj
    return W

# Convert scales to approximate center frequencies for labeling.
scales = np.geomspace(0.004, 0.05, 40)                 # seconds-ish scales, low freq -> high freq
freq_of_scale = w0_to_freq = (6.0) / (2 * np.pi * scales)
Wc = cwt_from_scratch(chirp, scales, fs)
ridge_scale = scales[np.argmax(np.abs(Wc), axis=0)]    # dominant scale per time sample
print(f"scales: {len(scales)}, freq range {freq_of_scale.min():.1f}-{freq_of_scale.max():.1f} Hz")
for idx in np.linspace(64, len(chirp) - 64, 5).astype(int):
    print(f"  t = {idx/fs:.3f}s   dominant freq = {6.0/(2*np.pi*ridge_scale[idx]):5.1f} Hz")
Code 4.4.3: A from-scratch continuous wavelet transform using a complex Morlet mother. Each row correlates the signal with one scaled, energy-normalized copy of the wavelet; the per-time dominant scale, mapped to frequency, traces the chirp's rising ridge with finer time resolution at high frequency than the fixed STFT window allowed.
scales: 40, freq range 19.1-238.7 Hz
  t = 0.125s   dominant freq =  26.4 Hz
  t = 0.547s   dominant freq =  38.9 Hz
  t = 0.969s   dominant freq =  51.7 Hz
  t = 1.391s   dominant freq =  64.5 Hz
  t = 1.812s   dominant freq =  77.2 Hz
Output 4.4.3: The Morlet CWT recovers the same rising sweep as the STFT, but the scale axis is logarithmic in frequency, so the high end of the chirp is resolved in time more finely than the low end, the adaptive resolution that a single STFT window cannot provide.
Fun Fact: Wavelets Were Invented to Find Oil

The mother wavelet was not born in a mathematics department. In the early 1980s the geophysicist Jean Morlet, processing seismic echoes for an oil company, grew frustrated that the STFT's fixed window could not simultaneously resolve the sharp shallow reflections and the smeared deep ones. He invented the scale-and-shift trick by hand and named the little wave an "ondelette." The physicist Alex Grossmann gave it a rigorous footing, and Yves Meyer, Ingrid Daubechies, and Stephane Mallat then built the multi-resolution theory and the fast filter bank that turned it into the JPEG 2000 image standard and the FBI fingerprint compression format. A practical complaint about smeared echoes became a Fields-Medal-adjacent corner of harmonic analysis.

4. Applications: Denoising, Transient Detection, and Multi-Scale Features Advanced

The multi-resolution decomposition is not just a prettier picture; it is a representation in which several hard problems become easy. The first is denoising. A smooth signal contaminated by white noise has, in the wavelet domain, a few large detail coefficients (the real edges and features) buried among many small ones (the noise, which spreads itself thinly across all coefficients). Donoho and Johnstone's insight was that you can separate them by a simple threshold: keep the large detail coefficients, zero out the small ones, and invert the transform. The universal threshold is

$$\lambda \;=\; \sigma \sqrt{2 \ln N},$$

where $N$ is the signal length and $\sigma$ is the noise standard deviation, robustly estimated from the finest-scale detail coefficients as $\hat\sigma = \mathrm{median}(|d_1|) / 0.6745$. Hard thresholding sets every coefficient below $\lambda$ to zero and leaves the rest untouched; soft thresholding additionally shrinks the survivors toward zero by $\lambda$, which trades a little bias for a smoother result. Because the wavelet basis concentrates a piecewise-smooth signal into few coefficients (it is sparse in the wavelet domain), thresholding removes most of the noise while preserving sharp features, where a Fourier-domain low-pass filter would blur every edge.

The second application is transient and anomaly detection. A localized event, a spike, a step, a brief burst of high frequency, lights up the fine-scale detail coefficients at exactly the time it occurs, because those coefficients are computed from short, high-frequency wavelets that respond only to rapid change. Scanning the finest detail level for coefficients that exceed a threshold gives a time-resolved anomaly detector that ignores slow drift entirely, which is why wavelet detail energy is a standard front end for the change-point methods of Chapter 8. The third application is feature extraction for downstream models: the energy in each wavelet level forms a compact multi-scale descriptor of a window, and these descriptors feed classifiers and the learned temporal representations of Part IV. Code 4.4.4 implements wavelet denoising from scratch on a Haar transform.

def haar_dwt(x, levels):
    """Single-channel Haar DWT: returns [cA_L, cD_L, ..., cD_1] coefficient blocks."""
    coeffs, a = [], x.astype(float).copy()
    for _ in range(levels):
        even, odd = a[0::2], a[1::2]
        m = min(len(even), len(odd))
        cA = (even[:m] + odd[:m]) / np.sqrt(2)       # approximation (low-pass, scaled)
        cD = (even[:m] - odd[:m]) / np.sqrt(2)       # detail (high-pass, scaled)
        coeffs.append(cD); a = cA
    coeffs.append(a)
    return coeffs[::-1]                               # [cA_L, cD_L, ..., cD_1]

def haar_idwt(coeffs):
    """Inverse Haar DWT from [cA_L, cD_L, ..., cD_1]."""
    a = coeffs[0]
    for cD in coeffs[1:]:
        m = len(cD)
        even = (a[:m] + cD) / np.sqrt(2)
        odd  = (a[:m] - cD) / np.sqrt(2)
        a = np.empty(2 * m); a[0::2] = even; a[1::2] = odd
    return a

rng = np.random.default_rng(0)
clean = np.where(t < 1.0, 1.0, -0.5)                  # a step signal: piecewise constant
clean[(t > 1.4) & (t < 1.42)] += 3.0                  # plus a sharp transient spike
noisy = clean + 0.4 * rng.standard_normal(len(t))

coeffs = haar_dwt(noisy, levels=4)
sigma  = np.median(np.abs(coeffs[-1])) / 0.6745       # noise est. from finest detail
lam    = sigma * np.sqrt(2 * np.log(len(t)))          # universal threshold
den    = [coeffs[0]] + [np.sign(c) * np.maximum(np.abs(c) - lam, 0) for c in coeffs[1:]]  # soft
recon  = haar_idwt(den)[:len(t)]
print(f"noise sigma est = {sigma:.3f}, threshold lambda = {lam:.3f}")
print(f"RMSE noisy vs clean   = {np.sqrt(np.mean((noisy - clean)**2)):.3f}")
print(f"RMSE denoised vs clean= {np.sqrt(np.mean((recon - clean)**2)):.3f}")
Code 4.4.4: From-scratch Haar wavelet denoising by soft thresholding. The noise level is estimated from the finest detail coefficients, the universal threshold $\sigma\sqrt{2\ln N}$ is applied to every detail block, and the inverse transform reconstructs a signal that keeps the step and spike while discarding the noise.
noise sigma est = 0.396, threshold lambda = 1.480
RMSE noisy vs clean   = 0.398
RMSE denoised vs clean= 0.166
Output 4.4.4: The estimated noise level $0.396$ closely matches the injected $0.4$, and soft thresholding more than halves the reconstruction error from $0.398$ to $0.166$ while preserving the step edge and the spike, the features a Fourier low-pass filter would have smeared.
Library Shortcut: PyWavelets Replaces the Whole Pipeline

The thirty-odd lines of hand-rolled Haar transform, inverse, noise estimation, and thresholding in Code 4.4.4 collapse to about five lines with pywt (PyWavelets): coeffs = pywt.wavedec(noisy, "db4", level=4), estimate sigma from coeffs[-1], threshold with pywt.threshold(c, lam, "soft"), and invert with pywt.waverec. That is roughly a sixfold reduction, and you also gain a hundred wavelet families (the smooth Daubechies db4 instead of the blocky Haar), correct boundary handling, and the continuous transform pywt.cwt for scalograms. The library manages the filter-bank convolutions, the dyadic downsampling, and the edge-extension mode, so in production you reach for pywt and spend your attention on choosing the wavelet and the threshold rule, not on reimplementing the recursion.

5. Worked Example: A Chirp With a Hidden Transient Advanced

We now stage the decisive comparison. Build a signal that is a rising chirp with one brief, high-frequency click buried in the middle, the kind of transient a bearing emits just before it fails or a heart emits as a premature beat. The Fourier spectrum of this signal, integrating over all time, buries the click: its energy is a tiny fraction of the total and spreads across a wide band, so it barely lifts the spectrum above the chirp's own broadband content. The STFT spectrogram shows the diagonal chirp ridge clearly and reveals the click as a faint vertical smear, but the fixed window forces a compromise resolution. The wavelet scalogram, with its fine time resolution at high frequency, pins the click to a narrow vertical stripe at exactly its time of occurrence while still tracing the chirp ridge. Figure 4.4.1 schematizes the three views side by side.

Fourier spectrum: transient buried frequency → energy STFT: ridge + faint smear time → freq Wavelet scalogram: ridge + sharp stripe time → freq The signal in time: a rising chirp with one hidden click ↑ the transient click time → chirp ridge / signal smeared transient sharply localized transient
Figure 4.4.1: Three views of a rising chirp carrying one brief transient click (bottom panel). The Fourier spectrum (top left) shows the chirp's broadband hump with the transient buried as a faint vertical smear that barely lifts the curve. The STFT spectrogram (top center) recovers the diagonal chirp ridge and shows the transient as a wide, faint vertical smear, blurred by the fixed window. The wavelet scalogram (top right) traces the same ridge but renders the transient as a sharp narrow stripe at exactly its time of occurrence, the payoff of fine time resolution at high frequency.

Code 4.4.5 builds the chirp-plus-transient, computes the from-scratch STFT of Code 4.4.2 and the from-scratch CWT of Code 4.4.3, and measures how sharply each one localizes the transient in time by reporting the width of the high-frequency energy burst. The scalogram's burst is several times narrower, the quantitative version of Figure 4.4.1.

t_click = 1.0                                          # the transient occurs at 1.0 s
sig = chirp.copy()
mask = (t >= t_click) & (t < t_click + 0.02)           # a 20 ms high-frequency click
sig[mask] += 2.5 * np.sin(2 * np.pi * 200 * t[mask])  # 200 Hz burst, far above the chirp band

# (a) Fourier: the transient is a negligible bump in the global spectrum.
Sf = np.abs(np.fft.rfft(sig)); ff = np.fft.rfftfreq(len(t), 1 / fs)
print(f"Fourier: energy above 150 Hz = {(Sf[ff > 150]**2).sum() / (Sf**2).sum():.4f} of total")

# (b) STFT high-band energy over time: locate and measure the transient burst width.
f_ax, t_ax, Sxx = stft_from_scratch(sig, win_len=64, hop=8, fs=fs)
stft_hi = Sxx[f_ax > 150].sum(axis=0)
def burst_width(energy, axis):                         # full width at half maximum, in seconds
    half = energy.max() / 2
    over = axis[energy >= half]
    return over.max() - over.min()
print(f"STFT  : transient at t = {t_ax[np.argmax(stft_hi)]:.3f}s, "
      f"burst width = {burst_width(stft_hi, t_ax)*1000:.0f} ms")

# (c) CWT high-frequency (small-scale) energy over time: the same measurement.
scales = np.geomspace(0.002, 0.05, 50)
Wc = cwt_from_scratch(sig, scales, fs)
# scales were redefined just above (50 values), so recompute any freq mapping locally
cwt_hi = np.abs(Wc[0:8]).sum(axis=0)  # smallest scales = highest frequencies
tt = np.arange(len(sig)) / fs
print(f"CWT   : transient at t = {tt[np.argmax(cwt_hi)]:.3f}s, "
      f"burst width = {burst_width(cwt_hi, tt)*1000:.0f} ms")
Code 4.4.5: The decisive comparison on a chirp with a hidden $200$ Hz transient. The Fourier spectrum sees the burst only as a negligible fraction of total energy, while both time-frequency methods locate it near $t = 1.0$ s; the burst width quantifies how sharply each method pins the event, with the wavelet's small-scale resolution giving the narrower burst.
Fourier: energy above 150 Hz = 0.0073 of total
STFT  : transient at t = 1.008s, burst width = 125 ms
CWT   : transient at t = 1.004s, burst width = 39 ms
Output 4.4.5: The Fourier transform attributes under one percent of total energy to the transient band, effectively burying it. The STFT localizes the click to within $125$ ms (the fixed $64$-sample window's resolution), while the wavelet's fine high-frequency scales localize it to $39$ ms, roughly three times sharper, the numeric counterpart of the narrow stripe in Figure 4.4.1.

The same analysis through production libraries takes a handful of lines, and in practice this is what you would run.

from scipy.signal import stft as scipy_stft
import pywt

# STFT via scipy: complex time-frequency array in one call.
f_s, t_s, Z = scipy_stft(sig, fs=fs, nperseg=64, noverlap=56)
spectrogram = np.abs(Z)**2

# CWT scalogram via pywt: choose a complex Morlet and a band of scales.
widths = np.geomspace(2, 64, 50)
cwt_coeffs, cwt_freqs = pywt.cwt(sig, widths, "cmor1.5-1.0", sampling_period=1 / fs)
scalogram = np.abs(cwt_coeffs)**2
print("scipy STFT shape :", spectrogram.shape, "  pywt scalogram shape:", scalogram.shape)
Code 4.4.6: The same spectrogram and scalogram through scipy.signal.stft and pywt.cwt. The roughly fifty lines of from-scratch STFT, Morlet CWT, and burst measurement across Codes 4.4.2, 4.4.3, and 4.4.5 reduce to these few calls, about a tenfold reduction, with the libraries handling window normalization, the complex Morlet construction, the scale-to-frequency mapping, and the edge padding.
Practical Example: The Heartbeat That Skipped Out of Turn

Who: A clinical data scientist building an arrhythmia screen over ambulatory ECG, each patient wearing a single-lead monitor that streams the heart's electrical signal at a few hundred samples per second for days at a time.

Situation: Heart rate wanders with activity and sleep, so the dominant rhythm frequency drifts continuously through the recording, and the team had been watching the FFT spectrum of short windows for signs of ectopy.

Problem: The patient threw occasional premature ventricular contractions, each a brief, sharply shaped early beat with energy at higher frequencies than the normal QRS complex, but the FFT spectrum barely moved: the contraction's energy was a fraction of a percent of the total and smeared across a wide band, exactly the burial seen in Output 4.4.5.

Dilemma: Lowering the FFT alarm threshold to catch the early beat would have drowned the team in false alarms from ordinary heart-rate drift, while leaving it high missed the ectopy. The frequency-only view could not separate "a new transient beat appeared" from "the resting rate shifted."

Decision: They replaced the windowed FFT with a continuous wavelet scalogram and an alarm on the energy in the finest scales, the band where the sharp premature beat lives and slow rate drift does not.

How: Using pywt.cwt with a complex Morlet wavelet (the library shortcut above), they computed the small-scale detail energy beat by beat and flagged windows where it exceeded a robust threshold, the same finest-scale logic as the denoiser of Code 4.4.4 run as a detector rather than a filter.

Result: The scalogram lit up a sharp vertical stripe at each premature contraction that the FFT smeared into the baseline, surfacing ectopic beats the frequency-only screen had missed, and the slow rate drift, being a low-frequency phenomenon, never touched the fine-scale alarm.

Lesson: When the event is a brief, localized, high-frequency departure riding on a slowly changing background, a frequency-only view buries it and a fixed-window spectrogram blurs it. The wavelet's fine time resolution at high frequency is what turns an invisible early beat into an actionable flag, which is why biosignal monitoring leans on scalograms feeding the change-point detectors of Chapter 8.

Research Frontier: Time-Frequency Thinking in the Foundation-Model Era (2024 to 2026)

Wavelets and time-frequency representations are enjoying a deep-learning revival rather than a retirement. Three current threads stand out. First, frequency-domain forecasters: the FEDformer line and 2024 to 2025 successors compute attention in a frequency or wavelet basis to capture long-horizon periodicity cheaply, and several models feed the discrete wavelet decomposition of a window directly as multi-scale tokens, realizing the feature-extraction idea of subsection four inside a transformer. Second, learnable and scattering transforms: the wavelet scattering network (Mallat and successors) and learnable filter banks such as those in recent audio and biosignal models keep the constant-Q multi-resolution structure but train the filters, blurring the line between a fixed wavelet and a learned convolution; the SpaceTime and TimesNet families explicitly decompose a series into multiple periods, a data-driven cousin of the scalogram. Third, temporal foundation models (Chronos, TimesFM, Moirai, MOMENT) are increasingly probed with time-frequency diagnostics to check whether they capture non-stationary structure, and wavelet-domain losses are used to penalize a model that gets the spectrum right but the timing wrong. The forty-year-old scale-and-shift idea now lives inside the newest architectures it anticipated, a thread this book follows into the frequency-domain neural models of Part III and the representation learners of Part IV.

Exercise 4.4.1: Why Two Different Signals Share a Spectrum Conceptual

Explain, in terms of the support of the Fourier basis functions, why the chirp and the stationary two-tone of Code 4.4.1 have nearly identical magnitude spectra despite being completely different events in time. Then state what is preserved that does differ between them (consider the phase), and argue why phase, though it carries the timing, is almost impossible to read by eye. Finally, describe the one change to the analysis (windowing) that turns the indistinguishable spectra into the clearly distinct spectrogram of Output 4.4.2.

Exercise 4.4.2: Walk the Uncertainty Trade-off Coding

Using stft_from_scratch from Code 4.4.2, compute the spectrogram of the chirp for window lengths $L \in \{32, 64, 128, 256\}$ at a fixed overlap fraction. For each $L$, report the frequency-bin spacing $f_s / L$ and the time span $L / f_s$, and verify numerically that their product is constant, the discrete shadow of $\sigma_t \sigma_f \ge 1/(4\pi)$. Then add the $200$ Hz transient of Code 4.4.5 and measure, for each $L$, how sharply the spectrogram localizes the transient in time; explain why the shortest window localizes the click best but resolves the chirp ridge worst.

Exercise 4.4.3: Denoise and Compare to a Low-Pass Filter Analysis

Take the step-plus-spike signal of Code 4.4.4. Denoise it two ways: with the from-scratch wavelet soft-thresholding of that code, and with a Fourier-domain low-pass filter that zeros all frequencies above a cutoff you choose to match the wavelet's noise reduction. Report the reconstruction RMSE of each and, crucially, plot or describe what each does to the step edge and the spike. Explain why the wavelet preserves the discontinuities while the low-pass filter introduces ringing (the Gibbs phenomenon) around them, connecting back to the sparsity-in-the-wavelet-basis argument of subsection four.

Exercise 4.4.4: Choose a Wavelet for a Fault Open-Ended

You are designing a transient detector for a signal whose anomalies are sometimes abrupt steps and sometimes smooth localized bumps. Using pywt, compare the Haar wavelet and a smoother Daubechies wavelet (say db4) on synthetic versions of both anomaly types, and discuss the trade-off: Haar's sharper time localization for steps versus db4's vanishing moments that ignore polynomial trends and respond more cleanly to smooth bumps. There is no single right answer; argue from the shape of the anomaly, the number of vanishing moments, and what each wavelet's detail coefficients do at the event, and say how you would pick in a real deployment with both fault types present.