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:

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 signal | Frequency | Amplitude |
|---|---|---|
| Motor vibration (what we want) | 5 Hz | 1.0 |
| Power-line hum | 60 Hz | 0.5 |
| Sensor noise | random | 0.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

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.]

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)

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

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

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

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:
- Machine learning for signal processing
- SciPy convolve2d for images
- SciPy interpolate
- SciPy curve_fit
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.