Tutorials

Finding Peaks with SciPy

Peak detection is the task of finding local maxima — points that are higher than their immediate neighbors — in a sequence of numbers. It's useful in many fields: finding spikes in sensor readings, identifying price peaks in financial data, locating spectral peaks in chemistry, or detecting heartbeat peaks in ECG signals. SciPy's `find_peaks` function handles all of this with a simple, filter-based API.

### Basic Peak Detection

At its simplest, `find_peaks` returns the indices of all local maxima in a 1D array. A point is a local maximum if it is strictly greater than both its immediate neighbors.

from scipy.signal import find_peaks
import numpy as np
import matplotlib.pyplot as plt

signal = np.array([0, 1, 0, 2, 0, 3, 0, 2, 0, 1, 0])
peaks, _ = find_peaks(signal)

plt.figure(figsize=(7, 3))
plt.plot(signal, color="steelblue", linewidth=2, label="Signal")
plt.plot(peaks, signal[peaks], "x", color="crimson", markersize=10, markeredgewidth=2, label="Peaks")
plt.xticks(range(len(signal)))
plt.legend()
plt.title("Basic Peak Detection")
plt.tight_layout()
plt.show()
- `find_peaks` returns a tuple: the first element is an array of indices where peaks occur, and the second is a dictionary of properties (empty here since no filters were applied).
- `signal[peaks]` uses NumPy fancy indexing to retrieve the values at peak positions, used here to place the red markers.
- The red `x` markers land exactly on the detected peaks, making it easy to verify the result visually.

### Filtering by Minimum Height

Often you only care about peaks above a certain threshold — for example, ignoring noise spikes. The `height` parameter sets the minimum value a peak must have to be included.

from scipy.signal import find_peaks
import numpy as np
import matplotlib.pyplot as plt

signal = np.array([0, 1, 0, 2, 0, 3, 0, 2, 0, 1, 0], dtype=float)
all_peaks, _ = find_peaks(signal)
tall_peaks, _ = find_peaks(signal, height=2)

plt.figure(figsize=(7, 3))
plt.plot(signal, color="steelblue", linewidth=2)
plt.axhline(2, color="orange", linestyle="--", linewidth=1.2, label="height threshold = 2")
plt.plot(all_peaks, signal[all_peaks], "o", color="gray", markersize=8, label="All peaks")
plt.plot(tall_peaks, signal[tall_peaks], "x", color="crimson", markersize=10, markeredgewidth=2, label="Peaks ≥ 2")
plt.xticks(range(len(signal)))
plt.legend()
plt.title("Height Filter")
plt.tight_layout()
plt.show()
- The dashed orange line marks the `height=2` threshold — peaks below it (gray circles) are excluded.
- `height=2` keeps only peaks whose value is at least 2; the retained peaks are shown as red crosses.
- You can also pass a 2-element tuple like `height=(2, 4)` to set both a minimum and maximum height.

### Enforcing Minimum Distance Between Peaks

In dense signals, nearby points can all qualify as peaks. The `distance` parameter enforces a minimum number of samples between consecutive peaks, keeping only the tallest peak in each window.

from scipy.signal import find_peaks
import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(0, 4 * np.pi, 200)
signal = np.sin(x) + 0.3 * np.sin(8 * x)

peaks_all, _ = find_peaks(signal)
peaks_dist, _ = find_peaks(signal, distance=40)

fig, axes = plt.subplots(2, 1, figsize=(8, 5), sharex=True)
for ax, peaks, label, color in zip(
    axes,
    [peaks_all, peaks_dist],
    [f"No distance filter ({len(peaks_all)} peaks)", f"distance=40 ({len(peaks_dist)} peaks)"],
    ["gray", "crimson"],
):
    ax.plot(x, signal, color="steelblue", linewidth=1.5)
    ax.plot(x[peaks], signal[peaks], "x", color=color, markersize=8, markeredgewidth=2)
    ax.set_title(label)
    ax.set_ylabel("Amplitude")
plt.xlabel("x")
plt.tight_layout()
plt.show()
- The top panel shows every local maximum — the high-frequency ripple creates many spurious peaks.
- The bottom panel with `distance=40` retains only 4 well-spaced peaks corresponding to the dominant sine wave.
- `sharex=True` links the x-axes so both panels share the same scale for easy comparison.

### Filtering by Prominence

Prominence measures how much a peak "stands out" from the surrounding signal — it's the vertical drop you'd need to reach any higher terrain when descending from the peak. Unlike height, it's relative: a peak at y=5 in a flat baseline of y=4 has low prominence, while the same peak rising from y=0 has high prominence.

from scipy.signal import find_peaks
import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(0, 6 * np.pi, 300)
signal = np.sin(x) + np.array([0.5 if 100 < i < 150 else 0 for i in range(300)])

all_peaks, _ = find_peaks(signal)
prom_peaks, props = find_peaks(signal, prominence=0.8)

plt.figure(figsize=(8, 3.5))
plt.plot(x, signal, color="steelblue", linewidth=1.5, label="Signal")
plt.plot(x[all_peaks], signal[all_peaks], "o", color="gray", markersize=7, label="All peaks")
plt.plot(x[prom_peaks], signal[prom_peaks], "x", color="crimson", markersize=10,
         markeredgewidth=2, label=f"Prominence ≥ 0.8 ({len(prom_peaks)} peaks)")
plt.legend()
plt.title("Prominence Filter")
plt.xlabel("x")
plt.tight_layout()
plt.show()
- Gray circles show all local maxima; red crosses show only the prominent ones.
- The elevated region (100 < i < 150) raises the baseline, causing nearby peaks to lose prominence — they appear as gray circles only.
- Prominence is often more robust than height for real-world signals where the baseline shifts over time.

### Filtering by Width

The `width` parameter selects peaks based on how wide they are at half their prominence height. This is useful for identifying broad features like protein bands in gel electrophoresis or wide absorption peaks in spectroscopy.

from scipy.signal import find_peaks
import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(0, 10, 500)
signal = (
    np.exp(-((x - 2) ** 2) / 0.05)        # narrow peak
    + 2 * np.exp(-((x - 7) ** 2) / 1.5)   # wide peak
)

all_peaks, _ = find_peaks(signal)
wide_peaks, props = find_peaks(signal, width=20)

plt.figure(figsize=(8, 3.5))
plt.plot(x, signal, color="steelblue", linewidth=2)
plt.plot(x[all_peaks], signal[all_peaks], "o", color="gray", markersize=8, label="All peaks")
plt.plot(x[wide_peaks], signal[wide_peaks], "x", color="crimson", markersize=12,
         markeredgewidth=2.5, label=f"Width ≥ 20 samples")
for i, pk in enumerate(wide_peaks):
    w = props["widths"][i]
    plt.annotate(f"width ≈ {w:.0f} samples", xy=(x[pk], signal[pk]),
                 xytext=(0, 15), textcoords="offset points", ha="center", fontsize=9)
plt.legend()
plt.title("Width Filter")
plt.xlabel("x")
plt.tight_layout()
plt.show()
- The narrow Gaussian near x=2 (gray circle) is filtered out; only the wide Gaussian near x=7 passes.
- `"widths"` in the properties dictionary gives each retained peak's measured width in samples.
- The annotation shows the computed width directly on the chart so you can calibrate the threshold intuitively.

### Combining Filters on a Noisy Signal

In practice you'll use multiple filters together. Here's a realistic noisy signal with all three filters applied at once, and the result plotted.

from scipy.signal import find_peaks
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
x = np.linspace(0, 8 * np.pi, 800)
signal = np.sin(x) + 0.5 * np.sin(2.5 * x) + 0.2 * rng.standard_normal(800)

peaks, _ = find_peaks(signal, height=0.5, distance=50, prominence=0.4)

plt.figure(figsize=(10, 4))
plt.plot(x, signal, color="steelblue", linewidth=1.2, alpha=0.8, label="Noisy signal")
plt.plot(x[peaks], signal[peaks], "x", color="crimson", markersize=10,
         markeredgewidth=2, label=f"Detected peaks ({len(peaks)})")
plt.axhline(0.5, color="orange", linestyle="--", linewidth=1, label="height = 0.5")
plt.legend()
plt.title("Combined Filters: height=0.5, distance=50, prominence=0.4")
plt.xlabel("x")
plt.tight_layout()
plt.show()
- Combining `height`, `distance`, and `prominence` eliminates noise spikes, closely packed minor bumps, and low-prominence wiggles in one call.
- The dashed orange line shows the height threshold — only peaks above it and passing the other filters are marked.
- Start with loose filters and tighten them until the result matches your domain knowledge of what a "real" peak looks like.

`find_peaks` is one of SciPy's most practical utilities. Once you know your signal's characteristics — its baseline, typical peak spacing, and peak shape — the right combination of these four filters will reliably isolate the features you care about. Next, learn about [kernel density estimation with SciPy](/tutorials/kernel-density-estimation-with-scipy) to model the underlying distribution of your data.