"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
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.
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")
chirp : band energy fraction = 0.987
two-tone : band energy fraction = 0.972
spectral centroid chirp= 49.71 Hz two-tone= 50.13 Hz
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.
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.
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")
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
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.
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.
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")
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
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}")
noise sigma est = 0.396, threshold lambda = 1.480
RMSE noisy vs clean = 0.398
RMSE denoised vs clean= 0.166
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.
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")
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
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)
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.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.
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.
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.
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.
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.
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.