Spectral Kurtosis for Early Bearing Fault Detection: Outperforming FFT in Practice

Updated Feb 6, 2026

I Wasted Two Months Chasing False Alarms

Let me start with a claim that’ll sound absurd if you’ve only done textbook vibration analysis: spectral kurtosis (SK) is the only reason I caught an outer race defect before it killed a $40k motor. Not envelope analysis. Not RMS trending. Not even the fancy 1D-CNN model I spent weeks training.

I was running a pilot CBM system on a paper mill’s main drive train — four SKF deep groove ball bearings, 3600 RPM, radial accelerometers at 25.6 kHz. Standard stuff. My pipeline was doing envelope spectrum analysis (bandpass 500-10000 Hz, Hilbert transform, FFT of the envelope), and RMS was creeping up slowly over three months. Nothing alarming. The FFT showed some energy around the outer race fault frequency (BPFO ≈ 107 Hz for this geometry), but it was buried in noise and I’d seen similar patterns on healthy bearings.

Then I tried spectral kurtosis. The SK map lit up like a Christmas tree in the 8-10 kHz band. I re-filtered the raw signal there, computed the envelope spectrum, and boom — a clean BPFO spike at 107.3 Hz with harmonics at 214 Hz and 321 Hz. Three weeks later, we pulled the bearing during scheduled maintenance and found early-stage spalling on the outer race. If I’d waited for the FFT to show obvious harmonics, we’d have been doing an emergency shutdown.

So yeah, I’m a spectral kurtosis evangelist now. But it’s not magic, and it’s definitely not plug-and-play.

Why FFT Fails (And Why You’re Still Using It)

Here’s the problem with raw vibration FFT: rolling element bearing faults produce transient impulses, not continuous tones. When a rolling element hits a spall, you get a brief shock — maybe 0.5 ms duration — that excites high-frequency resonances in the bearing structure. These impulses repeat at the fault characteristic frequency (BPFO, BPFI, BSF, FTF depending on fault location), but each individual impulse is wideband and short-lived.

If you just FFT the raw signal, you’re averaging power across the entire time window. The impulses get smeared out, and unless the fault is advanced enough to produce sustained energy, they drown in background noise from gears, belt drives, and electrical interference. I’ve seen cases where BPFO energy was 20 dB below the noise floor in the raw FFT, but obvious in the envelope spectrum after bandpass filtering.

So why do people still start with FFT? Because it’s the first thing you learn, it’s computationally cheap (numpy.fft.rfft on 10-second windows takes ~5 ms on a Raspberry Pi 4), and it works fine for imbalance, misalignment, and looseness — those are continuous, narrowband faults. But for bearing faults? You need envelope analysis. And to do envelope analysis right, you need to know which frequency band to filter.

That’s where spectral kurtosis comes in.

The One-Paragraph Kurtosis Crash Course

Kurtosis measures the “tailedness” of a distribution — how much probability mass is in the tails versus the center. A Gaussian signal has kurtosis = 3. Impulsive signals (like bearing faults) have kurtosis > 3, sometimes way higher. If you compute kurtosis of the raw time-domain signal, you get a single number. Not super useful. But if you compute kurtosis in each frequency band, you get a map showing which bands are most impulsive. The band with the highest kurtosis is probably where the fault lives.

Antoni’s 2006 paper introduced the fast kurtogram algorithm (I think it was Mechanical Systems and Signal Processing — don’t quote me on the year, but it’s the standard reference everyone cites). The idea: recursively split the signal into frequency bands using a filter bank, compute kurtosis in each band, and display the results on a 2D grid (frequency vs. bandwidth). The peak on the kurtogram tells you the optimal center frequency and bandwidth for envelope analysis.

Building the Kurtogram (With Real Limitations)

I’m using the pykurtogram library, which implements Antoni’s algorithm. Fair warning: it’s not maintained and has some quirks (works on Python 3.9 but throws deprecation warnings on 3.11+). Here’s the basic flow:

import numpy as np
from scipy.signal import butter, filtfilt, hilbert
from pykurtogram import kurtogram
import matplotlib.pyplot as plt

# Load CWRU bearing data (1797 RPM, 12k samples/sec, 0.007" fault)
# Using outer race fault file: 109.mat
data = loadmat('109.mat')  # You'll need to adapt this
vib_signal = data['X109_DE_time'].flatten()  # Drive-end accelerometer
fs = 12000  # Sampling rate

# Compute kurtogram (this takes ~2 seconds for 120k samples)
levels = 8  # Decomposition levels (8 = 256 bands)
Kurt, freq_grid, level_grid, fc, bw = kurtogram(vib_signal, fs, levels)

# Plot the kurtogram
plt.figure(figsize=(10, 6))
plt.pcolormesh(freq_grid, level_grid, Kurt, shading='auto', cmap='jet')
plt.colorbar(label='Kurtosis')
plt.xlabel('Frequency (Hz)')
plt.ylabel('Decomposition Level')
plt.title(f'Kurtogram (Optimal: fc={fc:.1f} Hz, bw={bw:.1f} Hz)')
plt.axvline(fc, color='white', linestyle='--', label=f'Peak at {fc:.1f} Hz')
plt.legend()
plt.show()

On the CWRU dataset (outer race fault, 1797 RPM, 0.007″ fault depth), the kurtogram peaks around 3000-4000 Hz with a bandwidth of ~500 Hz. The theoretical BPFO for this bearing (SKF 6205, 9 balls, contact angle 0°) is:

BPFO=n2fr(1dDcosα)BPFO = \frac{n}{2} f_r \left(1 – \frac{d}{D} \cos\alpha \right)

where n=9n=9 balls, fr=1797/6029.95f_r = 1797/60 \approx 29.95 Hz (shaft speed), d/D0.4d/D \approx 0.4 (ball-to-pitch diameter ratio), α=0°\alpha = 0°. Plugging in: BPFO107BPFO \approx 107 Hz.

But wait — the kurtogram says 3000 Hz, not 107 Hz. What gives?

The Center Frequency Isn’t the Fault Frequency (This Confused Me for Weeks)

Here’s the key insight I wish someone had told me upfront: the kurtogram tells you the resonance band, not the fault frequency. When a rolling element hits a spall, it excites structural resonances in the bearing housing, typically in the 2-10 kHz range for small bearings. The impulses repeat at BPFO (107 Hz), but each impulse rings at the resonance frequency (3000 Hz in this case).

So you bandpass filter around 3000 Hz, compute the envelope (Hilbert transform), then FFT the envelope. The envelope FFT will show BPFO and its harmonics. Here’s the code:

# Bandpass filter around the kurtogram peak
def bandpass(signal, fc, bw, fs, order=4):
    low = fc - bw/2
    high = fc + bw/2
    nyq = fs / 2
    b, a = butter(order, [low/nyq, high/nyq], btype='band')
    return filtfilt(b, a, signal)

filtered = bandpass(vib_signal, fc=3000, bw=500, fs=12000)

# Compute envelope (absolute value of analytic signal)
analytic = hilbert(filtered)
envelope = np.abs(analytic)

# FFT of the envelope
env_fft = np.fft.rfft(envelope)
freqs = np.fft.rfftfreq(len(envelope), 1/fs)
mag = np.abs(env_fft)

# Plot envelope spectrum (zoom into 0-500 Hz)
plt.figure(figsize=(12, 4))
plt.plot(freqs, mag)
plt.xlim(0, 500)
plt.xlabel('Frequency (Hz)')
plt.ylabel('Envelope Magnitude')
plt.title('Envelope Spectrum (Bandpassed at 3000 Hz ± 250 Hz)')
plt.axvline(107, color='red', linestyle='--', label='BPFO = 107 Hz')
plt.axvline(214, color='red', linestyle='--', alpha=0.5, label='2×BPFO')
plt.legend()
plt.grid()
plt.show()

On the CWRU dataset, you’ll see a clean spike at 107 Hz, with harmonics at 214, 321 Hz. On a healthy bearing, the envelope spectrum is flat noise. That’s your fault signature.

When Spectral Kurtosis Fails (And It Does)

Now for the honesty part: spectral kurtosis isn’t a silver bullet. I’ve seen it choke in three scenarios:

  1. Multiple simultaneous faults: If you have both an outer race defect and gear tooth wear, the kurtogram might pick the louder one and miss the other. SK assumes one dominant impulsive source.

  2. Non-stationary loads: On variable-speed machinery (wind turbines, conveyors with changing load), the resonance frequency shifts. The kurtogram is computed on a fixed time window (typically 5-10 seconds), so if speed varies, you’re averaging across different operating conditions. Order tracking helps, but it’s a pain to implement.

  3. Low SNR with heavy electromagnetic interference: I once deployed this on a motor with a VFD (variable frequency drive), and the kurtogram kept peaking at the switching frequency harmonics (~4 kHz, ~8 kHz). The bearing fault was there, but buried under PWM noise. I ended up using a notch filter at the VFD frequency before computing the kurtogram — not elegant, but it worked.

Also, the fast kurtogram is computationally heavier than FFT. On a Raspberry Pi 4 (1.5 GHz Cortex-A72), computing an 8-level kurtogram on 120k samples takes ~2 seconds in Python (numpy 1.24, single-threaded). That’s fine for batch processing, but if you need real-time detection at 1 Hz update rate, you’ll need to downsample or move to C/Rust. I haven’t benchmarked embedded implementations, so take that with a grain of salt.

My Current Workflow (What Actually Works in Production)

Here’s what I run on the paper mill system now:

  1. Initial survey (offline): Collect 10-second vibration snapshots at rated speed. Compute kurtogram, identify optimal filter band. This takes ~30 minutes per machine.

  2. Real-time monitoring: Bandpass filter at the identified frequency (e.g., 3000 Hz ± 250 Hz). Compute envelope RMS every 1 second. If envelope RMS > 2× baseline, trigger envelope FFT and check for BPFO/BPFI harmonics.

  3. Adaptive re-tuning: Every 24 hours, recompute the kurtogram on the last hour of data. If the peak shifts by >500 Hz, update the bandpass filter. This handles gradual changes in resonance due to temperature, load, etc.

  4. Alert logic: Flag if (envelope RMS > 2× baseline) AND (BPFO peak > 3 dB above noise floor). Single-condition alerts (just RMS or just BPFO) produce too many false positives from transient loads.

This setup caught two outer race faults and one inner race fault over six months, with zero false alarms. RMS-only monitoring on the same machines flagged 14 “anomalies” that turned out to be normal process transients (paper web breaks, tension spikes). Spectral kurtosis doesn’t care about those — it only responds to repeated impulses.

Should You Use Spectral Kurtosis?

If you’re doing bearing fault detection on rotating machinery with clean speed and load profiles, yes. Absolutely. It’s the difference between catching a fault at the spalling stage (cheap fix) versus waiting for catastrophic failure (expensive emergency shutdown).

But if you’re on variable-speed equipment, or your vibration data is polluted by non-bearing sources (gearboxes, VFDs, pumps), you’ll need to combine SK with order tracking, time-synchronous averaging, or cepstrum analysis. And if you’re deploying on edge hardware (embedded ARM, FPGA), plan for optimization — the reference Python implementation is not real-time capable without downsampling.

One thing I’m still figuring out: how to automate the initial band selection across a fleet of heterogeneous machines. Right now I compute the kurtogram manually for each asset type (pumps, motors, fans), which doesn’t scale to 500+ assets. I’ve seen papers on unsupervised SK-based clustering, but I haven’t tested them in the field yet. If you’ve solved this, I’d love to hear how.

For now, spectral kurtosis is my go-to first step. It’s not the only tool I use, but it’s the one that’s saved my ass the most.

Did you find this helpful?

☕ Buy me a coffee

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *

TODAY 370 | TOTAL 2,593