Part II: Classical Signal Processing and Feature Engineering
Chapter 7: Spectral and Time-Frequency Analysis

Power spectral density and coherence

"A single FFT tells you what one window happened to contain. A power spectral density tells you what the process keeps doing."

A Patient AI Agent

The big picture

The raw FFT of Section 7.1 answers a question about one particular record: which sinusoids reconstruct these exact samples. That is the wrong question for most sensor work, because your accelerometer or your EEG electrode is watching a random process, and a single spectrum of a random process is a noisy estimate whose variance never shrinks no matter how long you record. The power spectral density (PSD) reframes the goal: estimate how the average power of the process distributes across frequency, with a variance you can actually drive down. Once you have a stable PSD for one channel, the same machinery extended to two channels gives you the cross-spectral density and its normalized cousin coherence, which measures, frequency by frequency, how linearly two signals are coupled. This section explains what a PSD is and its units, why Welch averaging beats a lone periodogram, how coherence quantifies inter-sensor coupling, and when these second-order tools are the right lens.

This section assumes the sampling, aliasing, and windowing ideas from Chapter 3 and the leakage-versus-resolution tradeoff of Section 7.1. It also leans on the notion of a stationary random process and its autocorrelation from the probability primer in Chapter 4.

What a PSD is, and why the periodogram alone fails

For a wide-sense-stationary process \(x(t)\) with autocorrelation \(r_x(\tau) = \mathbb{E}[x(t)x(t+\tau)]\), the power spectral density is its Fourier transform (the Wiener-Khinchin theorem):

$$S_x(f) = \int_{-\infty}^{\infty} r_x(\tau)\, e^{-j 2\pi f \tau}\, d\tau .$$

The units are power per hertz: \((\text{units of }x)^2/\text{Hz}\), so an accelerometer PSD reads in \(g^2/\text{Hz}\) and integrating \(S_x(f)\) over a band returns the mean-square (the power) the signal carries in that band. That integral property is what makes a PSD a measurement rather than a picture: the area under a resonance peak is the energy that will fatigue a bracket or heat a coil.

The naive estimator is the periodogram, \(\hat{S}(f) = \tfrac{1}{N}\lvert X(f)\rvert^2\), the squared magnitude of the DFT. It is asymptotically unbiased, but it has a fatal defect: its variance does not decrease as the record length \(N\) grows. Doubling your data doubles the number of frequency bins but leaves each bin just as noisy, roughly 100% relative error, because each bin is essentially a single sample of a chi-squared random variable. A periodogram of a smooth process looks like grass: spiky, irreproducible, and misleading.

Welch's method: trading resolution for a variance you can control

The cure is averaging, and the standard recipe is Welch's method. Split the record into \(K\) segments, overlapping (typically 50%), apply a window \(w[n]\) to each to control leakage, compute a periodogram per segment, and average them:

$$\hat{S}_{\text{Welch}}(f) = \frac{1}{K}\sum_{k=1}^{K} \frac{1}{U}\left\lvert \sum_{n} x_k[n]\, w[n]\, e^{-j2\pi f n}\right\rvert^2 ,$$

where \(U = \sum_n w[n]^2\) normalizes for the window's power so the PSD stays correctly scaled. Averaging \(K\) roughly independent periodograms cuts the variance by about \(K\). The catch is the classic bias-variance dial: shorter segments give more of them (lower variance) but coarser frequency resolution \(\Delta f = f_s / L\) for segment length \(L\), and a fatter window main lobe (more bias). Overlap recovers some independence lost to windowing, letting you average more segments from the same data. You are, once again, spending resolution to buy stability, and the right operating point depends on whether you are resolving two close resonances or estimating a broadband floor.

Key insight: a PSD is an estimate with a confidence interval

Because Welch averages \(K\) chi-squared periodograms, each PSD point behaves like a scaled \(\chi^2\) with about \(2K\) degrees of freedom (more when segments overlap and share window taper). That means every peak and every floor in your PSD carries a quantifiable uncertainty, and two PSDs that differ by less than that band are not actually different. Treating a PSD as a deterministic curve is the single most common spectral error in sensor pipelines: people read a 2 dB bump as a fault when the estimator's own noise is 3 dB. The habit that saves you is to always report the number of averages, so a reader can reconstruct the confidence band. This connects directly to the uncertainty discipline of Chapter 4.

Cross-spectra and coherence: coupling between two channels

Sensor systems rarely have one channel. Given two stationary signals \(x\) and \(y\), the cross-spectral density \(S_{xy}(f)\) is the Fourier transform of their cross-correlation, and estimated exactly like a Welch PSD but multiplying one segment's DFT by the complex conjugate of the other's before averaging. From it comes the magnitude-squared coherence:

$$\gamma_{xy}^2(f) = \frac{\lvert S_{xy}(f)\rvert^2}{S_x(f)\, S_y(f)} , \qquad 0 \le \gamma_{xy}^2(f) \le 1 .$$

Coherence is the frequency-domain analogue of a squared correlation coefficient: at each frequency it reports how much of \(y\)'s power is linearly predictable from \(x\). A value near 1 means the two channels are tightly, linearly coupled at that frequency (a common source, or one driving the other through a linear path); a value near 0 means they are unrelated there. Its phase, \(\angle S_{xy}(f)\), gives the frequency-dependent time delay of the coupling, which is how you locate a leak by the lag between two acoustic sensors or estimate a transfer function \(H(f) = S_{xy}(f)/S_x(f)\) for a structure under vibration.

Warning: coherence without averaging is a lie

Plug a single segment (\(K = 1\)) into the coherence formula and it returns exactly 1 at every frequency, always, for any two signals, because \(\lvert S_{xy}\rvert^2 = S_x S_y\) identically when nothing is averaged. Coherence only becomes meaningful after averaging several segments, and its estimate is biased upward: with \(K\) averages, even two independent noise channels show an expected coherence of about \(1/K\). Always average enough segments that the bias floor sits well below the coupling you care about, and compare measured coherence against that \(1/K\) floor before believing it.

Practical example: sourcing a cabin boom in an electric vehicle

An EV NVH team hears a low-frequency boom in the cabin near 55 km/h. They mount an accelerometer on the rear subframe and a microphone at the driver's ear, log both at 8192 Hz during a steady cruise, and compute the coherence. Across most of the spectrum the two channels are unrelated, but at 38 Hz coherence spikes to 0.9: nine-tenths of the acoustic boom is linearly explained by that structural mode. The cross-spectral phase gives the propagation delay, and the transfer function estimate quantifies how many decibels of cabin noise each unit of subframe vibration produces. The fix (a tuned damper on the subframe) is validated the same way: after the change, coherence at 38 Hz collapses. A plain PSD of either channel alone would have shown the 38 Hz peak but could never have proven the structural path was the cause, which is why coherence, not just spectra, drives root-cause work like the interpretability methods of Chapter 67.

Computing it in practice

The code below estimates a PSD, a cross-spectrum, and coherence for two coupled signals: \(y\) is a filtered, delayed copy of a shared band-limited source plus independent noise. Welch handles the segmentation, windowing, overlap, and averaging; coherence peaks in the shared band and stays near the \(1/K\) floor elsewhere.

import numpy as np
from scipy import signal

fs = 8192
n = 200_000
rng = np.random.default_rng(0)
src = signal.lfilter(*signal.butter(4, [30, 45], "bandpass", fs=fs),
                     x=rng.standard_normal(n))          # shared 30-45 Hz source
x = src + 0.5 * rng.standard_normal(n)                  # channel 1 + noise
y = np.roll(src, 12) + 0.5 * rng.standard_normal(n)     # delayed copy + noise

f, Sxx = signal.welch(x, fs, nperseg=4096)              # PSD, units^2/Hz
f, Sxy = signal.csd(x, y, fs, nperseg=4096)             # cross-spectral density
f, Cxy = signal.coherence(x, y, fs, nperseg=4096)       # magnitude-sq coherence

band = (f > 30) & (f < 45)
print(f"mean coherence in 30-45 Hz band: {Cxy[band].mean():.2f}")
print(f"mean coherence outside band:     {Cxy[~band].mean():.2f}")
One shared band-limited source drives two noisy, relatively delayed channels. Welch, csd, and coherence share the same segmentation so their frequency axes align; coherence is high (near 1) in the coupled band and near the small 1/K bias floor everywhere else.

Library shortcut: scipy.signal.welch, csd, coherence

A correct-from-scratch Welch estimator (segmenting, windowing, power normalization by \(\sum w^2\), overlap bookkeeping, one-sided scaling, and the \(f_s\) axis) is roughly 40 to 60 lines, and the coherence version with its cross-spectral conjugation and per-bin normalization adds another 20. SciPy collapses each to a single call with vetted defaults (Hann window, 50% overlap, one-sided PSD). Keep nperseg and the window fixed across a dataset so segment counts and confidence bands stay comparable, in the leakage-safe spirit of Chapter 5. Reach for the from-scratch version only when you need a nonstandard estimator, such as a multitaper PSD.

When to reach for PSD and coherence

Use a PSD whenever you care about how power distributes across frequency for a roughly stationary signal and you want a stable, reproducible estimate: characterizing a sensor's noise floor (as in Chapter 2), specifying a vibration environment, or building band-power features for the classifiers of Section 7.7 and Chapter 8. Use coherence whenever the question is about coupling between two channels: EEG functional connectivity between electrodes (Chapter 31), structural transfer functions, leak localization, or sensor-fusion sanity checks that two supposedly redundant sensors actually agree (Chapter 48). Do not use either when the signal is strongly nonstationary within your averaging window: averaging then blurs distinct regimes into a meaningless mean, and you should segment first or move to the time-frequency tools of Section 7.2. And remember coherence measures only linear coupling; two channels can be perfectly dependent through a nonlinear law yet show low coherence.

Exercise

Generate 60 seconds of a 1000 Hz signal that is a 50 Hz sinusoid buried in white noise at 0 dB SNR. Estimate its PSD three ways: a single periodogram over the whole record, Welch with nperseg=1024, and Welch with nperseg=8192. Overlay them. Quantify the variance of the noise floor in each and relate it to the number of averages \(K\). Then explain why the 8192-point version resolves the 50 Hz line more sharply but has a noisier floor than the 1024-point version. Which would you ship for detecting a narrowband tone, and which for measuring broadband noise power?

Self-check

  1. Why does a periodogram's variance fail to decrease with record length, and what specifically does Welch averaging change to fix it?
  2. What are the units of an accelerometer PSD, and what physical quantity do you get by integrating it over a frequency band?
  3. You compute coherence from a single unaveraged segment and get 1.0 everywhere. Explain the mechanism, and state how many averages you would need so that an independent-noise coherence floor sits below 0.1.

What's Next

In Section 7.7, we step back from individual transforms and ask the design question that governs this whole chapter: given a modality and a task, which representation earns its keep. We will weigh the FFT, spectrogram, wavelet, Hilbert-Huang, cepstral, and PSD tools you have now met against the signal's stationarity, the compute budget, and what a downstream model can actually use, turning a menu of methods into a decision.