Bandpass Filter Design: Theory and Python Implementation

Learn bandpass filter theory (passband, stopband, bandwidth, center frequency) through Z-transform and frequency response, with IIR/FIR design using scipy.signal and Python visualization.

What Is a Bandpass Filter?

A bandpass filter (BPF) passes signals within a specified frequency band (passband) and attenuates signals outside that band (stopband).

Applications include radio channel selection, speech processing, and biomedical signal filtering (ECG, EEG noise removal).

Key Parameters

ParameterDescription
\(f_l\)Lower cutoff frequency (-3 dB point)
\(f_h\)Upper cutoff frequency (-3 dB point)
\(f_0 = \sqrt{f_l f_h}\)Center frequency (geometric mean)
\(BW = f_h - f_l\)Bandwidth
\(Q = f_0 / BW\)Q factor (selectivity)

A higher Q value means a narrower, more selective passband.

Frequency Response Derivation

Lowpass-to-Bandpass Transformation

A bandpass filter can be designed from a lowpass prototype (LPF) via a frequency transformation. In the analog domain, replace the complex variable \(s\) as:

\[ s \rightarrow \frac{s^2 + \omega_l \omega_h}{s(\omega_h - \omega_l)} \tag{1}\]

where \(\omega_l = 2\pi f_l\) and \(\omega_h = 2\pi f_h\).

Applying transformation \((1)\) to a 1st-order Butterworth LPF \(H_{LP}(s) = \frac{\omega_c}{s + \omega_c}\) yields a 2nd-order bandpass filter:

\[ H\_{BP}(s) = \frac{BW \cdot s}{s^2 + BW \cdot s + \omega_0^2} \tag{2}\]

where \(BW = \omega_h - \omega_l\) and \(\omega_0 = \sqrt{\omega_l \omega_h}\).

Discrete-Time Bandpass Filter (Bilinear Transform)

Equation \((2)\) is converted to the digital domain via the bilinear transform:

\[s = \frac{2}{T} \cdot \frac{1 - z^{-1}}{1 + z^{-1}} \tag{3}\]

Substituting \((3)\) into \((2)\) yields the digital BPF transfer function:

\[ H(z) = \frac{b_0 + b_1 z^{-1} + b_2 z^{-2}}{1 + a_1 z^{-1} + a_2 z^{-2}} \tag{4}\]

In practice, scipy.signal handles this computation automatically.

Python Implementation

IIR Bandpass Filter with scipy.signal

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

# --- Parameters ---
fs = 1000.0          # Sampling frequency [Hz]
f_low = 50.0         # Lower cutoff [Hz]
f_high = 150.0       # Upper cutoff [Hz]
order = 4            # Filter order

# --- Design Butterworth bandpass filter ---
nyq = fs / 2.0
low = f_low / nyq
high = f_high / nyq

b, a = signal.butter(order, [low, high], btype='bandpass')

# --- Compute frequency response ---
w, h = signal.freqz(b, a, worN=8000, fs=fs)

# --- Plot ---
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))

# Gain (dB)
ax1.semilogx(w, 20 * np.log10(np.abs(h) + 1e-10))
ax1.axvline(f_low, color='r', linestyle='--', label=f'$f_l$ = {f_low} Hz')
ax1.axvline(f_high, color='g', linestyle='--', label=f'$f_h$ = {f_high} Hz')
ax1.axhline(-3, color='gray', linestyle=':', label='-3 dB')
ax1.set_xlabel('Frequency [Hz]')
ax1.set_ylabel('Gain [dB]')
ax1.set_title(f'Butterworth Bandpass Filter (order={order})')
ax1.legend()
ax1.grid(True, which='both', alpha=0.3)
ax1.set_xlim([1, nyq])
ax1.set_ylim([-80, 5])

# Phase
ax2.semilogx(w, np.angle(h, deg=True))
ax2.axvline(f_low, color='r', linestyle='--')
ax2.axvline(f_high, color='g', linestyle='--')
ax2.set_xlabel('Frequency [Hz]')
ax2.set_ylabel('Phase [degrees]')
ax2.set_title('Phase Response')
ax2.grid(True, which='both', alpha=0.3)
ax2.set_xlim([1, nyq])

plt.tight_layout()
plt.savefig('bandpass_response.png', dpi=150, bbox_inches='tight')
plt.show()

FIR Bandpass Filter Design

IIR filters have nonlinear phase, but FIR filters can achieve linear phase. This is important in audio processing where phase distortion is audible.

from scipy.signal import firwin, freqz

# --- FIR bandpass filter ---
numtaps = 101        # Number of taps (odd number recommended)
fir_bpf = firwin(
    numtaps,
    [f_low, f_high],
    pass_zero=False,  # Bandpass specification
    fs=fs,
    window='hamming'
)

w_fir, h_fir = freqz(fir_bpf, worN=8000, fs=fs)

# IIR vs FIR comparison
plt.figure(figsize=(10, 5))
plt.semilogx(w, 20 * np.log10(np.abs(h) + 1e-10), label='IIR Butterworth (order=4)', linewidth=2)
plt.semilogx(w_fir, 20 * np.log10(np.abs(h_fir) + 1e-10), label=f'FIR Hamming (taps={numtaps})', linewidth=2, linestyle='--')
plt.axvline(f_low, color='r', linestyle=':', alpha=0.7, label=f'$f_l$ = {f_low} Hz')
plt.axvline(f_high, color='g', linestyle=':', alpha=0.7, label=f'$f_h$ = {f_high} Hz')
plt.axhline(-3, color='gray', linestyle=':', alpha=0.7)
plt.xlabel('Frequency [Hz]')
plt.ylabel('Gain [dB]')
plt.title('Bandpass Filter Comparison: IIR vs FIR')
plt.legend()
plt.grid(True, which='both', alpha=0.3)
plt.xlim([1, nyq])
plt.ylim([-80, 5])
plt.tight_layout()
plt.show()

Applying the Filter to a Signal

# --- Generate test signal ---
t = np.linspace(0, 1.0, int(fs), endpoint=False)

# Target signal: 100 Hz sine wave
signal_pure = np.sin(2 * np.pi * 100 * t)

# Noise: 10 Hz low-freq + 300 Hz high-freq + white noise
noise = (
    0.5 * np.sin(2 * np.pi * 10 * t) +
    0.5 * np.sin(2 * np.pi * 300 * t) +
    0.3 * np.random.randn(len(t))
)

x_noisy = signal_pure + noise

# --- Apply filter ---
# lfilter: causal filter (real-time, has phase delay)
y_lfilter = signal.lfilter(b, a, x_noisy)

# filtfilt: zero-phase filter (offline, no phase delay)
y_filtfilt = signal.filtfilt(b, a, x_noisy)

# --- Comparison plot ---
fig, axes = plt.subplots(3, 1, figsize=(12, 8), sharex=True)

axes[0].plot(t[:200], x_noisy[:200], alpha=0.8, label='Noisy signal')
axes[0].set_ylabel('Amplitude')
axes[0].set_title('Input Signal (noisy)')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

axes[1].plot(t[:200], y_lfilter[:200], color='orange', label='lfilter (causal)')
axes[1].plot(t[:200], signal_pure[:200], color='gray', linestyle='--', alpha=0.6, label='True signal')
axes[1].set_ylabel('Amplitude')
axes[1].set_title('After lfilter (with phase delay)')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

axes[2].plot(t[:200], y_filtfilt[:200], color='green', label='filtfilt (zero-phase)')
axes[2].plot(t[:200], signal_pure[:200], color='gray', linestyle='--', alpha=0.6, label='True signal')
axes[2].set_ylabel('Amplitude')
axes[2].set_xlabel('Time [s]')
axes[2].set_title('After filtfilt (zero-phase)')
axes[2].legend()
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('bandpass_filtering_result.png', dpi=150, bbox_inches='tight')
plt.show()

IIR vs FIR Design Comparison

PropertyIIR (Butterworth, etc.)FIR
Phase responseNonlinear (phase distortion)Linear phase achievable
Number of coefficientsSmall (\(2N+1\) approx.)Large (tens to hundreds)
Computational costLowHigh (depends on tap count)
StabilityRisk with high ordersAlways stable
Stopband roll-offSteep at low orderImproves with tap count
Group delayNon-constantConstant (\((N-1)/2\) samples)
Typical useReal-time processingAudio, medical signals

Design guidelines:

  • Low computational cost in real-time → IIR (Butterworth / Chebyshev)
  • Linear phase required (audio, medical) → FIR (Hamming / Kaiser window)
  • Offline processing, zero phase delay → filtfilt (works with both IIR and FIR)

Effect of Filter Order

fig, ax = plt.subplots(figsize=(10, 6))

for ord_n in [2, 4, 6, 8]:
    b_n, a_n = signal.butter(ord_n, [low, high], btype='bandpass')
    w_n, h_n = signal.freqz(b_n, a_n, worN=8000, fs=fs)
    ax.semilogx(w_n, 20 * np.log10(np.abs(h_n) + 1e-10), label=f'order={ord_n}')

ax.axhline(-3, color='gray', linestyle=':', label='-3 dB')
ax.axvline(f_low, color='r', linestyle='--', alpha=0.5)
ax.axvline(f_high, color='g', linestyle='--', alpha=0.5)
ax.set_xlabel('Frequency [Hz]')
ax.set_ylabel('Gain [dB]')
ax.set_title('Effect of Filter Order on Bandpass Response')
ax.legend()
ax.grid(True, which='both', alpha=0.3)
ax.set_xlim([1, nyq])
ax.set_ylim([-80, 5])
plt.tight_layout()
plt.show()

Higher order yields steeper roll-off, but IIR filters become numerically unstable at very high orders. Order ≤ 8 is a practical guideline.

References