SciPy Signal in Python: Filter, Analyze and Find Peaks

An operations lead in Columbus, Ohio sent me a conveyor-motor vibration log buried in 60 Hz hum. SciPy signal cleaned it in four calls: welch(), butter(), sosfiltfilt() and find_peaks().

from scipy import signal

sos = signal.butter(4, 20, btype="low", fs=fs, output="sos")
clean = signal.sosfiltfilt(sos, x)

In this tutorial, I’ll build a small vibration pipeline with the SciPy signal module on Python 3.12.5 with SciPy 1.18.1, step by step.

Here’s what we’re building:

SciPy signal before and after plot with a raw noisy vibration signal on top and the filtered 5 Hz wave with 10 red peak markers below
Top: the raw recording. Bottom: filtered, with every peak marked.

Before You Start: the vibration data for scipy.signal

Install the libraries with pip install scipy matplotlib. New to the library? My SciPy overview explains the subpackages first.

Part of the signalFrequencyAmplitude
Motor vibration (what we want)5 Hz1.0
Power-line hum60 Hz0.5
Sensor noiserandom0.3 std

You’ll get 2 seconds at 500 samples per second: 1,000 samples.

Step 1: Build the noisy test signal with NumPy

You’ll simulate the recording so you can check every result. The seeded generator is covered in NumPy random numbers.

import numpy as np

fs = 500                                # samples per second
t = np.arange(0, 2, 1 / fs)             # 2 seconds, 1,000 samples
rng = np.random.default_rng(2026)
x = (np.sin(2 * np.pi * 5 * t)          # 5 Hz motor vibration
     + 0.5 * np.sin(2 * np.pi * 60 * t) # 60 Hz power-line hum
     + 0.3 * rng.standard_normal(t.size))  # sensor noise
Raw noisy vibration signal plotted with Matplotlib, a 5 Hz wave hidden under 60 Hz hum and noise
The raw signal: the 5 Hz wave is there, but fuzzy.

Checkpoint: you see five slow waves per second, each one blurred by fast wiggles.

Step 2: Find the hidden frequencies with signal.welch()

welch() averages FFTs over segments into a cleaner power spectrum, and find_peaks() picks its spikes. For plain FFTs, see FFT in Python.

# fs, t and x from Step 1
f, pxx = signal.welch(x, fs=fs, nperseg=500)
top, _ = signal.find_peaks(pxx, height=0.01)
print("Strongest frequencies (Hz):", f[top])

Output:

Strongest frequencies (Hz): [ 5. 60.]
Welch power spectrum on a log scale with red markers on the 5 Hz and 60 Hz spikes
Two spikes: the vibration at 5 Hz and the hum at 60 Hz.

Checkpoint: the output lists 5 Hz and 60 Hz, nothing else.

Step 3: Remove the hum with a Butterworth low-pass filter

A 4th-order low-pass at 20 Hz keeps 5 Hz and blocks 60 Hz. Pass fs so the cutoff is in hertz. More filter types are in the SciPy Butterworth filter guide.

# fs, t and x from Step 1
sos = signal.butter(4, 20, btype="low", fs=fs, output="sos")
clean = signal.sosfiltfilt(sos, x)
Raw signal in gray with the smooth Butterworth low-pass filtered signal on top
The filtered line follows the 5 Hz wave without lag.

Checkpoint: the blue line sits on the raw wave, not shifted, because sosfiltfilt() runs forward and backward.

Note: you should keep output="sos"; it stays stable at high orders, where "ba" can blow up.

Step 4: Count the vibration peaks with find_peaks()

height skips small bumps and distance keeps peaks at least 0.1 s apart. All options are in SciPy find_peaks().

# fs, t and x from Step 1, sos and clean from Step 3
peaks, props = signal.find_peaks(clean, height=0.5, distance=fs // 10)
print("Peaks found:", len(peaks))

Output:

Peaks found: 10
Filtered 5 Hz vibration signal with 10 red dots on the detected peaks
find_peaks() marked every crest of the cleaned wave.

Checkpoint: 10 peaks in 2 seconds, which is 5 Hz.

Test the SciPy signal pipeline: does the filter really work?

I run the whole thing without plots and print the numbers (VS Code):

from scipy import signal
# fs, t and x from Step 1
# sos, clean and peaks from Steps 3-4

f, raw_p = signal.welch(x, fs=fs, nperseg=500)
f, new_p = signal.welch(clean, fs=fs, nperseg=500)
true = np.sin(2 * np.pi * 5 * t)
print(f"60 Hz power before: {raw_p[60]:.4f}")
print(f"60 Hz power after:  {new_p[60]:.2e}")
print(f"Error vs true wave: {np.std(x - true):.3f} -> "
      f"{np.std(clean - true):.3f}")
print("Peaks found:", len(peaks))
print("Mean gap (s):", np.diff(t[peaks]).mean().round(3))

Output:

60 Hz power before: 0.0780
60 Hz power after:  8.96e-10
Error vs true wave: 0.459 -> 0.092
Peaks found: 10
Mean gap (s): 0.199
VS Code output showing 60 Hz power dropping to almost zero, error falling from 0.459 to 0.092 and 10 peaks found
The hum is gone and the error shrank about five times.

The 0.199 s mean gap between peaks confirms 5 Hz. To plot these arrays yourself, see plotting NumPy arrays in Matplotlib.

Variant: SciPy iirnotch() and savgol_filter() instead of a low-pass

iirnotch() cuts one frequency and keeps the noise; savgol_filter() fits a polynomial to each window. IIR design is in the SciPy IIR filter tutorial.

from scipy import signal
# fs, t and x from Step 1

b, a = signal.iirnotch(60, Q=30, fs=fs)   # remove 60 Hz only
hum_free = signal.filtfilt(b, a, x)
smooth = signal.savgol_filter(x, 31, 3)   # 31-sample window
true = np.sin(2 * np.pi * 5 * t)
print(f"Notch error:  {np.std(hum_free - true):.3f}")
print(f"Savgol error: {np.std(smooth - true):.3f}")

Output:

Notch error:  0.307
Savgol error: 0.093
First 0.6 seconds of the notch-filtered signal versus the Savitzky-Golay smoothed signal
The notch keeps the noise; Savitzky-Golay smooths it.

The notch leaves the noise in (0.307), while Savitzky-Golay nearly matches the low-pass (0.093 vs 0.092). You’d pick the notch for wide-band audio.

Troubleshooting: why does sosfiltfilt() raise a padlen error?

Very short input can’t hold the padding sosfiltfilt() adds. Old tutorials also call signal.cwt(), which SciPy 1.15 removed (use PyWavelets):

from scipy import signal
# fs, t and x from Step 1, sos from Step 3

try:
    signal.sosfiltfilt(sos, x[:12])
except ValueError as err:
    print("ValueError:", err)
try:
    signal.cwt(x, signal.ricker, [1, 2, 4])
except AttributeError as err:
    print("AttributeError:", err)

Output:

ValueError: The length of the input vector x must be greater than padlen, which is 15.
AttributeError: module 'scipy.signal' has no attribute 'cwt'

Important: check a filter’s response before trusting it; SciPy freqz() plots it.

Where to Take It Next with scipy.signal

Try a moving-average filter with SciPy convolve, or compare methods in SciPy data smoothing.

Other SciPy guides on this site:

Frequently asked questions

What is scipy.signal used for?

Filtering, spectra, peak finding, resampling and convolution of 1-D data.

Should I use filtfilt() or lfilter()?

Use sosfiltfilt() on recorded data (zero phase) and sosfilt() on live streams.

Why pass fs to butter()?

So you can give the cutoff in hertz instead of a fraction of Nyquist.

How do I resample a signal?

Use signal.resample_poly(x, up, down), or signal.resample(x, num) for periodic data.

Is signal.cwt() still in SciPy?