Worked examples — scipy.signal — filtering, convolution, FFT-based analysis
The scenario matrix
Before any example, let's enumerate every kind of situation this topic can throw at you. Each row is a cell. Every cell gets covered by at least one worked example below.
| # | Cell (case class) | What is tricky here | Example |
|---|---|---|---|
| C1 | Plain convolution, both arrays length ≥ 2 | flip-and-slide bookkeeping, output length | Ex 1 |
| C2 | Degenerate kernel: length-1 (a scalar) | convolution collapses to scaling | Ex 2 |
| C3 | Kernel longer than signal | commutativity — who slides over whom? | Ex 3 |
| C4 | Correlation, positive lag (template after start) | no flip; where is the peak? | Ex 4 |
| C5 | Correlation, negative lag (template before centre) | sign of the lag, how to read it | Ex 4 |
| C6 | FFT of a pure tone, on-bin frequency | one clean spike, amplitude scaling | Ex 5 |
| C7 | FFT frequency exactly at Nyquist | the DC/Nyquist bins do NOT get the ×2 | Ex 6 |
| C8 | Off-bin frequency (spectral leakage) | energy smears across bins | Ex 7 |
| C9 | Resolution limit: two close peaks | need longer record, not faster sampling | Ex 8 |
| C10 | Real-world word problem: 50 Hz mains hum removal | pick filter type + cutoff, zero-phase | Ex 9 |
| C11 | Exam twist: normalised cutoff illegal (>1) | catch the API trap | Ex 10 |
Notation we will use everywhere
Example 1 — Plain convolution (cell C1)
Step 1 — Output length. Why this step? Getting the length wrong means the whole array is misaligned. Full convolution length is .
Step 2 — Flip . Why? Convolution is defined with ; the means we read backwards. Flipping gives — symmetric, so it looks the same, but the concept is "flip then slide".
Step 3 — Slide and sum each overlap position. Why? Each output sample is the sum of products of samples that currently line up.
So .

The figure shows the blue circles as the input and the orange squares as the output shifted slightly to the right so you can compare them. Notice the output has 5 stems where the input had 3 — that visual "spreading" is exactly the extra length, and each orange value is labelled so you can trace it back to a step above.
Verify: A moving sum of a signal must have total (sum of all outputs) equal to . Here and , so the product is . Indeed . ✓ In code: convolve([2,0,-1],[1,1,1]).
Example 2 — Degenerate kernel of length 1 (cell C2)
Step 1 — Output length. . Why? The kernel occupies zero width beyond a single sample, so nothing spreads out.
Step 2 — There is only one overlap term at every position. Why? With , the sum has exactly one non-zero term, .
Verify: convolve([4,7,-2],[3]) == [12,21,-6]. Also and . ✓
Example 3 — Kernel LONGER than the signal (cell C3)
Step 1 — Output length, from counting overlaps. Why this step? Instead of memorising the formula, count how many slide positions produce at least one overlapping pair. Start with just touching on the left (1 pair overlaps) and end when it just leaves on the right (1 pair overlaps). As you slide by one each time, the number of distinct positions is . The "" is because the two end positions where a single sample overlaps are the boundaries you count once each. The formula is symmetric, so it doesn't matter which array is longer.
Step 2 — Use commutativity. Why this step? — convolution does not care which one you flip. So slide the shorter one () across the longer one; less bookkeeping.
So .
Verify: , and . ✓
Example 4 — Correlation, positive AND negative lag (cells C4, C5)
Step 1 — No flip. Why this step? Convolution flips the kernel (it models a system). Correlation asks "how similar is the template to each window?" — similarity needs the template kept in its true orientation, exactly as the definition above shows.
Step 2 — Compute the sliding dot products where fully overlaps . The bump in sits at indices 2,3,4 as — an exact copy of .
That value is the maximum possible (), a perfect match.

In the figure, the blue stems are the signal (a bump centred on index 3) and the orange curve is the full correlation. Watch where the orange curve reaches its peak (marked red): that peak sits over index 3, telling you the template best matches when centred there.
Step 3 — Map output index to lag. Why this step? scipy.signal.correlate(x, h, mode='full') returns an array of length . Its index maps to a lag by
The peak sits at output index , so . A positive lag of 2 means the template, whose left edge starts at 0, best fits when its window starts 2 samples in — i.e. the bump is centred at index 3 (cell C4).
Step 4 — Negative-lag case (C5). Now let — bump right at the start. Correlate with the same template but treat the template as a centred reference by using correlate(x2, h) and reading lag relative to the signal centre. The best overlap now occurs at output index , giving relative to the start, which is earlier than the signal's mid-point (index 2–3). Measured against that centre, the reported shift is negative: the pattern appears earlier than the reference. The sign of the lag literally tells you "template is ahead of (−) / behind (+) the reference".
Verify: peak value of correlate([0,0,1,2,1,0],[1,2,1]) equals , equals , and its output index is 4 → lag . ✓
Example 5 — FFT of an on-bin pure tone (cell C6)
Step 1 — Bin spacing. Why this step? Each bin sits at (from the DFT definition above). Here Hz. A 2 Hz tone lands exactly on bin — this is why it's called "on-bin". No leakage.
Step 2 — Where the rule comes from. Why this step? From the notation box: a real sine of amplitude deposits into bin (its energy is split with the mirror bin ). Invert that: . Since , the mirror partner exists, so the is correct here. Predicted amplitude .

Left panel: the 8 time samples of the sine. Right panel: the one-sided (rfft) magnitude, already scaled by . A single orange stem rises to exactly 3 at 2 Hz (marked red), and every other bin is flat at zero — the textbook picture of an on-bin tone.
Verify: the scaled one-sided magnitude at equals (to floating-point tolerance) and all other bins are ≈0. ✓
Example 6 — Frequency exactly at Nyquist (cell C7)
Step 1 — Locate the bin. Hz bin . Why care? The Nyquist bin has no distinct mirror partner — it is its own mirror ().
Step 2 — Compute directly from the DFT sum. Why this step? Rather than trust a half-remembered rule, plug into with : So (here , ). Note this is , not : at Nyquist all the energy piles into the single self-mirrored bin instead of being split into two.
Step 3 — Correct scaling at Nyquist. Why? Because there is no separate mirror bin, the right recovery is ✓. The naive one-sided rule double-counts and reports twice the true amplitude.
Verify: for , : ; correct amplitude , while the naive (wrong). ✓
Example 7 — Off-bin frequency: spectral leakage (cell C8)
Step 1 — Is it on a bin? Hz gives bins at 0,1,2,3,4 Hz. 2.5 Hz matches none. Why this matters: the DFT assumes the signal repeats every samples; a 2.5 Hz tone does not complete a whole number of cycles in 8 samples, so the periodic assumption is violated.
Step 2 — Energy leaks. Why? The tone's energy spreads into neighbouring bins (2 and 3 get the most, others get some). No single bin equals 1. This is spectral leakage.

Left panel: the 8 time samples — note they do not line up into a whole number of cycles. Right panel: instead of one clean stem you see a smeared hump; bins 2 and 3 (either side of 2.5 Hz) are the tallest but neither reaches the true amplitude 1. That smear is leakage.
Step 3 — The peak underestimates amplitude. The largest bin will read less than the true 1, because the energy is split. Windowing (Hann, etc.) reshapes but never fully removes leakage for off-bin tones.
Verify: the max scaled magnitude for the 2.5 Hz tone is strictly less than 1 (leakage), and the energy in bins 2 and 3 exceeds any other neighbouring pair. ✓
Example 8 — Resolving two close peaks (cell C9)
Step 1 — Current bin spacing. Hz. Why this step? Two peaks only appear separate if they fall in different bins, i.e. their gap must exceed . The gap here is Hz Hz → they blur into one bump.
Step 2 — Faster sampling doesn't help. Why? Increasing raises the highest visible frequency (Nyquist), but — if you double while keeping the time fixed you also double , so is unchanged. Resolution is set by record length in seconds , since .
Step 3 — Record longer. Why? . To get Hz we need s, i.e. samples. With , Hz — just enough; go a bit longer (say , Hz) for a clean split.
Recall The one-line rule
Question ::: To separate two frequencies apart, you need record time ; sampling faster does nothing for resolution.
Verify: with , (unresolved). With , (just resolved). And required s → . ✓
Example 9 — Real-world: remove 50 Hz mains hum (cell C10)
Step 1 — Choose cutoff. Why this step? We must pass the ECG (<40 Hz) and block 50 Hz. Put the cutoff between: Hz is a safe choice.
Step 2 — Normalised cutoff (old API). Why? Butterworth's classic signature takes cutoff as a fraction of Nyquist. . (Or pass fs=1000 and 45 directly in modern scipy.)
Step 3 — Choose filtfilt, not lfilter, and write the code. Why filtfilt? A causal filter (lfilter) delays the signal — an ECG peak would move in time, ruining timing measurements. filtfilt runs the filter forward then backward; the two equal-and-opposite delays cancel → zero phase, so peaks stay put. Since we have the full recording offline, causality isn't needed. The full call:
from scipy.signal import butter, filtfilt
b, a = butter(4, 45, btype='low', fs=1000) # or Wn=0.09 with the old API
clean = filtfilt(b, a, ecg) # zero-phase; peaks stay putThis connects to Digital Filter Design (why Butterworth) and Linear Time-Invariant Systems (why a filter is convolution).
Verify: , and so it is a legal digital cutoff. ✓
Example 10 — Exam twist: the illegal cutoff (cell C11)
Step 1 — What 80 means without fs=. Why this step? In the old signature, the second argument is a fraction of Nyquist, i.e. a number in . So 80 is read as "80× Nyquist" — nonsense; scipy rejects any .
Step 2 — Fix A: normalise. . Then butter(4, 0.16, btype='low') is valid because .
Step 3 — Fix B: pass real Hz. Supply fs and let scipy normalise internally:
from scipy.signal import butter
b, a = butter(4, 80, btype='low', fs=1000) # Hz allowed once fs is given
# equivalently: butter(4, 0.16, btype='low') # old-style normalised cutoffVerify: , which lies in — legal. The raw 80 does not. ✓
Connections
- Fourier Transform — leakage and on-bin/off-bin theory behind Ex 5–7.
- Aliasing and Sampling Theorem — the Nyquist edge case of Ex 6.
- Linear Time-Invariant Systems — why convolution (Ex 1–3) is filtering.
- Cross-correlation and Template Matching — the lag reading of Ex 4.
- Digital Filter Design — Butterworth choice in Ex 9–10.
- numpy.fft — the FFT engine under Ex 5–8.