FIR Filters in Python: Design, Apply, and Visualize

FIR filters — Finite Impulse Response filters — are among the most practical tools in digital signal processing. Python's scipy.signal library makes it straightforward to design them, apply them to real data, and inspect their frequency response. This article walks through everything you need, from theory to working code.

Digital signal processing shows up in audio engineering, communications, biomedical devices, financial data smoothing, seismology, radar, and many other domains. FIR filters are a fundamental building block in all of them. Unlike their IIR counterparts, FIR filters are always stable and naturally produce linear phase — meaning every frequency in the signal is delayed by the same amount. That property matters a great deal in audio and communications work, where phase distortion is audible or functionally problematic. It also matters in scientific measurement, where preserving the temporal alignment of features can be the difference between a valid result and a misleading one. In 2012, Widmann and Schröger published a paper in Frontiers in Psychology showing that a poorly designed default FIR filter in EEGLAB introduced ringing artifacts that distorted signal onset latency estimates — a stark reminder that filter design decisions have real consequences (source: Frontiers in Psychology, 2012).

What Is a FIR Filter?

A FIR filter computes each output sample as a weighted sum of a fixed number of past and current input samples. That fixed number is called the filter order, and the weights themselves are the filter coefficients — sometimes called taps. The word "finite" refers to the fact that a single impulse fed into the filter produces a response that eventually dies out completely. There is no feedback path, so past outputs never influence future ones.

The mathematical operation is a discrete convolution. If x[n] is your input signal and h[k] are the filter coefficients, then each output sample y[n] is:

y[n] = h[0]*x[n] + h[1]*x[n-1] + h[2]*x[n-2] + ... + h[N]*x[n-N]

Where N is the filter order. A higher order means more coefficients, a sharper transition between the passband and stopband, and more computation per sample.

Note

FIR filter order and the number of taps are related but not identical. An order-N filter has N+1 taps. The terms are sometimes used interchangeably in libraries like scipy, so always check which convention the documentation uses.

Symmetric FIR filters come in four types, classified by whether the number of coefficients is odd or even and whether the impulse response is symmetric or antisymmetric. Type I (odd length, symmetric) and Type II (even length, symmetric) are the ones produced by firwin. Type II filters always have a zero at the Nyquist frequency, which is why scipy raises a ValueError if you try to design a high-pass or band-stop filter with an even number of taps — such filters need nonzero gain at Nyquist. Types III and IV (antisymmetric) are used for differentiators and Hilbert transformers, but those are outside the scope of standard windowed filter design. Understanding these types prevents a common class of bugs: the mysterious error when someone passes an even tap count to a high-pass design.

Setup and Imports

You need numpy, scipy, and matplotlib. If any of them are missing, install with pip:

pip install numpy scipy matplotlib

Then import at the top of your script:

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

Everything in this article runs on those three libraries. No additional dependencies are required.

Designing FIR Filters with firwin

The primary function for FIR filter design in scipy is scipy.signal.firwin. It uses the window method, which is the most common approach for practical filter design. You specify the number of taps, one or more cutoff frequencies, and optionally a window function.

Cutoff frequencies in firwin are expressed as a fraction of the Nyquist frequency — which is half the sampling rate. So if your signal is sampled at 1000 Hz, the Nyquist frequency is 500 Hz, and a cutoff of 0.2 means 100 Hz. Alternatively, you can pass the fs parameter directly to firwin and specify cutoff frequencies in Hz, which eliminates the normalization step entirely. One important detail from the scipy documentation: the cutoff frequency corresponds to the -6 dB (half-amplitude) point of the filter response, not the -3 dB point used in many other filter design contexts. This distinction matters when comparing filter specifications across tools (source: scipy.signal.firwin documentation).

Low-Pass Filter Design

from scipy import signal

# Sampling rate: 1000 Hz
fs = 1000.0
nyq = fs / 2.0       # Nyquist frequency = 500 Hz
cutoff = 100.0       # Desired cutoff in Hz

# Normalize cutoff to Nyquist
normalized_cutoff = cutoff / nyq   # = 0.2

# Design a 51-tap low-pass FIR filter using a Hamming window
num_taps = 51
coeffs = signal.firwin(num_taps, normalized_cutoff, window='hamming')

print(f"Number of coefficients: {len(coeffs)}")
print(f"Center coefficient (should be largest): {coeffs[num_taps // 2]:.6f}")

The output confirms the filter is symmetric — a hallmark of linear-phase FIR design. The center tap carries the most weight, and the values taper off toward both ends.

Pro Tip

Use an odd number of taps for low-pass and band-stop filters. An even number of taps forces a zero at the Nyquist frequency, which causes problems for those filter types. For high-pass and band-pass filters, even tap counts are fine but odd ones still work — odd is a safe default.

Choosing a Window Function

The window function shapes the tradeoff between how sharp the transition is and how much ripple appears in the passband and stopband. Common choices in scipy include:

  • hamming — good general-purpose choice, ~53 dB minimum stopband attenuation with a moderate transition width
  • hann — narrower main lobe than Hamming but weaker stopband attenuation (~44 dB); good when transition sharpness matters more than sidelobe suppression
  • blackman — wider transition band, but excellent stopband attenuation (~74 dB); best when you need deep suppression of out-of-band content
  • kaiser — parametric; the beta value controls the attenuation-vs-width tradeoff directly, making it the most flexible single window for meeting exact specifications
  • rectangular — no window at all; sharpest transition but worst sidelobe behavior (~21 dB stopband attenuation, Gibbs phenomenon)
# Kaiser window with beta = 8.0 for ~80 dB stopband attenuation
coeffs_kaiser = signal.firwin(num_taps, normalized_cutoff, window=('kaiser', 8.0))

# You can also use the fs parameter to work in Hz directly:
coeffs_hz = signal.firwin(num_taps, cutoff, window='hamming', fs=fs)
# Both approaches produce identical coefficients

The Kaiser window deserves special attention because its beta parameter gives you continuous control over the attenuation-transition width tradeoff. A rough guideline: beta of 5 gives about 50 dB stopband attenuation, beta of 7 gives about 70 dB, and beta of 9 gives about 90 dB. For precise specification-driven design, scipy.signal.kaiserord can calculate both the required number of taps and the beta value from your desired attenuation and transition width — covered in the Automated Filter Order section below.

Applying a Filter to a Signal

Once you have the coefficients, applying the filter is a single function call. The two main options are scipy.signal.lfilter and scipy.signal.filtfilt. They differ in an important way.

lfilter applies the filter in one forward pass. This introduces a time delay equal to half the filter length (the group delay). filtfilt applies the filter twice — once forward and once backward — which cancels the phase delay entirely. The result is zero-phase filtering, which preserves the timing of features in your signal. The cost is that filtfilt requires the full signal to be available at once, so it cannot be used for real-time processing.

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

# Create a test signal: 50 Hz sine + 200 Hz sine, sampled at 1000 Hz
fs = 1000.0
t = np.linspace(0, 1.0, int(fs), endpoint=False)
signal_clean = np.sin(2 * np.pi * 50 * t)    # 50 Hz component
signal_noise = np.sin(2 * np.pi * 200 * t)   # 200 Hz component (to be filtered out)
x = signal_clean + signal_noise

# Design a 101-tap low-pass FIR filter with cutoff at 100 Hz
nyq = fs / 2.0
cutoff = 100.0 / nyq
coeffs = signal.firwin(101, cutoff, window='hamming')

# Apply with lfilter (introduces delay)
y_lfilter = signal.lfilter(coeffs, 1.0, x)

# Apply with filtfilt (zero-phase, no delay)
y_filtfilt = signal.filtfilt(coeffs, 1.0, x)

# Plot the results
fig, axes = plt.subplots(3, 1, figsize=(10, 7), sharex=True)

axes[0].plot(t, x, color='#4b8bbe', linewidth=0.8)
axes[0].set_title('Original Signal (50 Hz + 200 Hz)')
axes[0].set_ylabel('Amplitude')

axes[1].plot(t, y_lfilter, color='#FFD43B', linewidth=0.8)
axes[1].set_title('After lfilter (causal, with delay)')
axes[1].set_ylabel('Amplitude')

axes[2].plot(t, y_filtfilt, color='#98c379', linewidth=0.8)
axes[2].set_title('After filtfilt (zero-phase)')
axes[2].set_ylabel('Amplitude')
axes[2].set_xlabel('Time (seconds)')

plt.tight_layout()
plt.show()
Note

The second argument to lfilter and filtfilt is the denominator polynomial. For a FIR filter this is always 1.0, because FIR filters have no feedback (no denominator terms beyond a constant). If you pass actual denominator coefficients, you would be implementing an IIR filter instead.

Low-Pass, High-Pass, Band-Pass, and Band-Stop

firwin handles all four standard filter types through its pass_zero argument and by accepting one or two cutoff frequencies.

Low-Pass Filter

# Pass frequencies below 100 Hz, attenuate above
lp = signal.firwin(101, 100.0 / nyq, window='hamming')
# pass_zero=True is the default — the DC component (0 Hz) passes through

High-Pass Filter

# Pass frequencies above 100 Hz, attenuate below
hp = signal.firwin(101, 100.0 / nyq, window='hamming', pass_zero=False)
# pass_zero=False reverses the response: DC is now in the stopband

Band-Pass Filter

# Pass frequencies between 80 Hz and 200 Hz
bp = signal.firwin(
    101,
    [80.0 / nyq, 200.0 / nyq],
    window='hamming',
    pass_zero=False   # DC is in the stopband, so pass_zero=False
)

Band-Stop (Notch) Filter

# Attenuate frequencies between 80 Hz and 200 Hz, pass everything else
bs = signal.firwin(
    101,
    [80.0 / nyq, 200.0 / nyq],
    window='hamming',
    pass_zero=True   # DC passes through
)
Pro Tip

In newer versions of scipy, the pass_zero argument also accepts string values: 'lowpass', 'highpass', 'bandpass', and 'bandstop'. This makes the intent explicit and eliminates the mental overhead of tracking boolean logic. Check your scipy version with import scipy; print(scipy.__version__) — string support was added in scipy 1.3.0.

All Four Types: A Complete Comparison

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

fs = 1000.0
nyq = fs / 2.0
num_taps = 101
f1, f2 = 100.0 / nyq, 300.0 / nyq

filters = {
    'Low-Pass':  signal.firwin(num_taps, f1, window='hamming'),
    'High-Pass': signal.firwin(num_taps, f1, window='hamming', pass_zero=False),
    'Band-Pass': signal.firwin(num_taps, [f1, f2], window='hamming', pass_zero=False),
    'Band-Stop': signal.firwin(num_taps, [f1, f2], window='hamming', pass_zero=True),
}

fig, axes = plt.subplots(2, 2, figsize=(12, 8))
axes = axes.flatten()
colors = ['#4b8bbe', '#FFD43B', '#98c379', '#e06c75']

for ax, (name, coeffs), color in zip(axes, filters.items(), colors):
    w, h = signal.freqz(coeffs, worN=8000, fs=fs)
    ax.plot(w, 20 * np.log10(np.abs(h) + 1e-10), color=color, linewidth=1.5)
    ax.set_title(name)
    ax.set_xlabel('Frequency (Hz)')
    ax.set_ylabel('Magnitude (dB)')
    ax.set_ylim(-100, 5)
    ax.grid(True, alpha=0.2)
    ax.axhline(-3, color='white', linestyle='--', alpha=0.3, linewidth=0.8)

plt.suptitle('FIR Filter Frequency Responses', fontweight='bold')
plt.tight_layout()
plt.show()

Inspecting the Frequency Response

Before applying any filter to real data, it is worth verifying that it actually does what you designed it to do. scipy.signal.freqz computes the frequency response of a digital filter and returns both the frequencies and the corresponding complex response values.

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

fs = 1000.0
nyq = fs / 2.0

# Design the filter
coeffs = signal.firwin(101, 150.0 / nyq, window='hamming')

# Compute frequency response
# worN controls how many points to evaluate; higher = smoother curve
# Passing fs returns frequencies in Hz rather than normalized units
w, h = signal.freqz(coeffs, worN=8000, fs=fs)

# Magnitude response in dB
magnitude_db = 20 * np.log10(np.abs(h) + 1e-10)  # small offset avoids log(0)

# Phase response in degrees
phase_deg = np.angle(h, deg=True)

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6))

ax1.plot(w, magnitude_db, color='#4b8bbe', linewidth=1.5)
ax1.axhline(-3, color='#FFD43B', linestyle='--', linewidth=1, label='-3 dB')
ax1.axhline(-40, color='#e06c75', linestyle='--', linewidth=1, label='-40 dB')
ax1.set_title('FIR Filter Magnitude Response')
ax1.set_xlabel('Frequency (Hz)')
ax1.set_ylabel('Magnitude (dB)')
ax1.set_ylim(-100, 5)
ax1.legend()
ax1.grid(True, alpha=0.2)

ax2.plot(w, phase_deg, color='#98c379', linewidth=1.5)
ax2.set_title('FIR Filter Phase Response')
ax2.set_xlabel('Frequency (Hz)')
ax2.set_ylabel('Phase (degrees)')
ax2.grid(True, alpha=0.2)

plt.tight_layout()
plt.show()

The magnitude plot shows how much each frequency is amplified or attenuated. The passband is the region where the filter passes signal through with minimal loss (near 0 dB). The transition band is the region between the passband and stopband. The stopband is where unwanted frequencies are attenuated — ideally to the point of being negligible.

The phase plot for a FIR filter designed with the window method will be a straight line across the passband — confirming linear phase behavior. This is one of the key advantages over IIR filters, which produce nonlinear phase that warps the relative timing of frequency components.

Phase Delay and Linear Phase

Every causal FIR filter delays the output relative to the input. The amount of delay is called the group delay. For a symmetric FIR filter designed with the window method, the group delay is constant across all frequencies and equals exactly (N - 1) / 2 samples, where N is the number of taps. This constant group delay is what "linear phase" means — the phase shift is a linear function of frequency.

from scipy import signal
import numpy as np

fs = 1000.0
nyq = fs / 2.0
num_taps = 101

coeffs = signal.firwin(num_taps, 150.0 / nyq, window='hamming')

# Group delay: should be constant at (N-1)/2 samples across the passband
w, gd = signal.group_delay((coeffs, 1.0), fs=fs)

expected_delay_samples = (num_taps - 1) / 2   # = 50.0
expected_delay_ms = expected_delay_samples / fs * 1000

print(f"Expected group delay: {expected_delay_samples} samples ({expected_delay_ms:.1f} ms)")
print(f"Measured at 50 Hz: {gd[np.argmin(np.abs(w - 50))]:.2f} samples")
print(f"Measured at 100 Hz: {gd[np.argmin(np.abs(w - 100))]:.2f} samples")

Running this confirms that the group delay is uniform — 50 samples (50 ms at 1000 Hz) at every frequency in the passband. When you need to account for this delay in your application, shift the output array by (num_taps - 1) // 2 samples, or use filtfilt to eliminate the delay entirely.

Warning

Do not use filtfilt when your data arrives in chunks or in real time. Because filtfilt processes the signal in both directions, it requires access to future samples — which do not exist in streaming applications. Use lfilter instead and compensate for the group delay separately if needed.

Compensating for Group Delay Manually

import numpy as np
from scipy import signal

fs = 1000.0
t = np.linspace(0, 1.0, int(fs), endpoint=False)
x = np.sin(2 * np.pi * 50 * t) + np.sin(2 * np.pi * 200 * t)

num_taps = 101
coeffs = signal.firwin(num_taps, 100.0 / (fs / 2.0), window='hamming')

y = signal.lfilter(coeffs, 1.0, x)

# Shift output left to remove the group delay
delay = (num_taps - 1) // 2
y_corrected = y[delay:]
t_corrected = t[:len(y_corrected)]

# y_corrected now aligns with the original signal in time

Handling Edge Effects and Transients

Every FIR filter produces transient artifacts at the beginning and end of the output signal. The first N-1 samples of the output (where N is the number of taps) are corrupted because the filter does not yet have a full window of input samples to work with. This is not a bug — it is a mathematical consequence of convolution with a finite signal.

There are several strategies for dealing with this, and the best choice depends on your application:

import numpy as np
from scipy import signal

fs = 1000.0
t = np.linspace(0, 1.0, int(fs), endpoint=False)
x = np.sin(2 * np.pi * 50 * t)

num_taps = 101
coeffs = signal.firwin(num_taps, 100.0 / (fs / 2.0), window='hamming')

# Strategy 1: Discard the transient region entirely
y = signal.lfilter(coeffs, 1.0, x)
valid_start = num_taps - 1
y_clean = y[valid_start:]

# Strategy 2: Use filtfilt to avoid the problem altogether (zero-phase)
y_zero_phase = signal.filtfilt(coeffs, 1.0, x)

# Strategy 3: Use lfilter_zi for steady-state initial conditions
# This minimizes the startup transient by initializing the filter
# as if the first sample had been repeating indefinitely
zi = signal.lfilter_zi(coeffs, 1.0) * x[0]
y_warm, _ = signal.lfilter(coeffs, 1.0, x, zi=zi)

Strategy 3 deserves attention because it is the least commonly discussed. The lfilter_zi function computes the initial filter state that corresponds to a constant input equal to the signal's first sample. This dramatically reduces the startup transient — the output immediately tracks the signal rather than ramping up from zero. It is especially useful for streaming or chunk-based processing where filtfilt is not an option.

Pro Tip

When filtering data in chunks (real-time or memory-constrained scenarios), pass the final filter state from one chunk as the initial state for the next. The lfilter function returns this state as a second output when you provide zi: y_chunk, zf = signal.lfilter(coeffs, 1.0, chunk, zi=zi). Use zf as zi for the next chunk to maintain continuity across boundaries.

Automated Filter Order with kaiserord

Choosing the number of taps manually is fine for prototyping, but production code should derive the filter order from specifications. The scipy.signal.kaiserord function does exactly this — you give it a desired stopband attenuation (in dB) and a transition width, and it returns the minimum number of taps and the Kaiser window beta parameter needed to meet those requirements.

from scipy import signal

fs = 1000.0
nyq = fs / 2.0

# Specification: at least 60 dB stopband attenuation
# with a 40 Hz transition band between 100 Hz and 140 Hz
ripple_db = 60.0
transition_width = 40.0 / nyq  # Normalized

# kaiserord returns the number of taps and beta
num_taps, beta = signal.kaiserord(ripple_db, transition_width)

# Ensure odd number of taps for a Type I filter
if num_taps % 2 == 0:
    num_taps += 1

# Design the filter using the computed parameters
cutoff = 120.0 / nyq  # Center of the transition band
coeffs = signal.firwin(num_taps, cutoff, window=('kaiser', beta))

print(f"Required taps: {num_taps}")
print(f"Kaiser beta: {beta:.2f}")
print(f"Filter length: {num_taps / fs * 1000:.1f} ms")

This approach eliminates guesswork. Rather than adjusting tap counts through trial and error, you state what you need — 60 dB of rejection with a 40 Hz transition — and the math produces the minimum filter that satisfies both constraints. The cutoff frequency is placed at the center of the transition band, which aligns with firwin's -6 dB convention.

Note

The kaiserord estimates are conservative. The actual filter will often exceed the requested attenuation by a few dB. Always verify with freqz after design, but expect the result to meet or exceed your specification.

Beyond firwin: remez, firls, and firwin2

The window method via firwin handles the majority of standard filter design tasks. But scipy provides three additional FIR design functions that solve problems firwin cannot:

scipy.signal.remez — the Parks-McClellan algorithm. It designs equiripple filters that minimize the maximum deviation from the desired response across all frequency bands. Equiripple filters achieve a given stopband attenuation with fewer taps than windowed designs, making them more computationally efficient in production. The tradeoff is a more complex API — you specify band edges and desired gains for each band separately.

from scipy import signal
import numpy as np

fs = 1000.0

# Design a 50-tap equiripple low-pass filter
# Band edges: passband 0-100 Hz, stopband 150-500 Hz
# The 100-150 Hz gap is the transition band (a "don't care" region)
taps_remez = signal.remez(
    51,
    bands=[0, 100, 150, fs / 2],
    desired=[1, 0],
    fs=fs
)

# Compare with a windowed design of the same length
taps_firwin = signal.firwin(51, 125.0, window='hamming', fs=fs)

# Plot both to see the difference in stopband behavior
w1, h1 = signal.freqz(taps_remez, worN=4096, fs=fs)
w2, h2 = signal.freqz(taps_firwin, worN=4096, fs=fs)

print(f"Remez max stopband: {20 * np.log10(np.abs(h1[w1 > 150]).max() + 1e-10):.1f} dB")
print(f"Firwin max stopband: {20 * np.log10(np.abs(h2[w2 > 150]).max() + 1e-10):.1f} dB")

scipy.signal.firls — least-squares FIR design. Instead of minimizing the maximum error (equiripple), it minimizes the total squared error across all frequency bands. This produces filters with smaller average error but potentially larger worst-case error. It also supports frequency-dependent weighting, which lets you prioritize accuracy in specific bands.

scipy.signal.firwin2 — windowed FIR design with arbitrary frequency response. Unlike firwin which only handles standard filter shapes, firwin2 accepts a list of frequency-gain pairs that define any piecewise-linear magnitude response. Use it when you need a custom gain profile, such as a filter that passes 0-100 Hz at full gain, rolls off linearly to half gain between 100-200 Hz, and then blocks everything above.

Pro Tip

As a general decision framework: start with firwin for standard low-pass, high-pass, band-pass, or band-stop designs. Switch to remez when you need the same attenuation with fewer taps (or more attenuation with the same taps). Use firls when you have "don't care" frequency regions that should not influence the design. Use firwin2 when the desired response is not one of the four standard shapes.

Computational Performance at Scale

For short filters applied to short signals, the choice of filtering function barely matters. But when the filter length or the signal length grows large, the computational cost of convolution becomes significant. A direct convolution of an N-sample signal with an M-tap filter costs O(N*M) operations. For a 101-tap filter on a 1-million-sample signal, that is roughly 100 million multiply-accumulates.

The scipy.signal.fftconvolve function offers O(N log N) performance by computing the convolution in the frequency domain. For large filters or long signals, this can be dramatically faster:

import numpy as np
from scipy import signal
import time

fs = 44100.0
duration = 60.0  # One minute of audio
x = np.random.randn(int(fs * duration))

num_taps = 501
coeffs = signal.firwin(num_taps, 4000.0, fs=fs, window='blackman')

# Direct convolution via lfilter
start = time.perf_counter()
y_direct = signal.lfilter(coeffs, 1.0, x)
t_direct = time.perf_counter() - start

# FFT-based convolution
start = time.perf_counter()
y_fft = signal.fftconvolve(x, coeffs, mode='full')[:len(x)]
t_fft = time.perf_counter() - start

print(f"lfilter: {t_direct:.3f}s")
print(f"fftconvolve: {t_fft:.3f}s")
print(f"Speedup: {t_direct / t_fft:.1f}x")

The crossover point depends on your hardware, but as a rule of thumb: lfilter is faster for filters under about 50 taps, and fftconvolve wins for longer filters. For real-time or streaming applications where you cannot buffer the entire signal, lfilter with state management (see the edge effects section) remains the right tool regardless of performance.

Note

The scipy documentation for lfilter notes that sosfilt (second-order sections) is generally preferred for IIR filtering due to numerical stability. For FIR filters this is not a concern — FIR filters are inherently stable regardless of representation — so lfilter is perfectly appropriate (source: scipy.signal.lfilter documentation).

Real-World Application: Cleaning Sensor Data

Theory becomes concrete with a practical example. Suppose you have accelerometer data sampled at 500 Hz that contains both the signal of interest (human motion below 20 Hz) and high-frequency sensor noise. Here is the complete workflow from specification to clean output:

import numpy as np
from scipy import signal

# Simulate noisy accelerometer data
fs = 500.0
duration = 10.0
t = np.linspace(0, duration, int(fs * duration), endpoint=False)

# Human motion: a mix of low-frequency components
motion = (0.8 * np.sin(2 * np.pi * 2.0 * t)
        + 0.3 * np.sin(2 * np.pi * 8.5 * t)
        + 0.1 * np.sin(2 * np.pi * 15.0 * t))

# High-frequency noise: vibration and electrical interference
noise = 0.4 * np.random.randn(len(t)) + 0.2 * np.sin(2 * np.pi * 120 * t)
raw = motion + noise

# Step 1: Define specifications
passband_edge = 20.0     # Hz — everything below this is signal
stopband_edge = 30.0     # Hz — everything above this must be suppressed
stopband_atten = 60.0    # dB — how much to suppress

# Step 2: Use kaiserord to determine filter parameters
transition_width = (stopband_edge - passband_edge) / (fs / 2.0)
num_taps, beta = signal.kaiserord(stopband_atten, transition_width)
if num_taps % 2 == 0:
    num_taps += 1

# Step 3: Design and verify
cutoff = (passband_edge + stopband_edge) / 2.0
coeffs = signal.firwin(num_taps, cutoff, window=('kaiser', beta), fs=fs)

# Verify the design meets spec
w, h = signal.freqz(coeffs, worN=8000, fs=fs)
stopband_mask = w >= stopband_edge
actual_atten = -20 * np.log10(np.abs(h[stopband_mask]).max() + 1e-10)
print(f"Filter order: {num_taps - 1}")
print(f"Achieved stopband attenuation: {actual_atten:.1f} dB")

# Step 4: Apply with zero-phase filtering
clean = signal.filtfilt(coeffs, 1.0, raw)

# The 'clean' signal now contains only the motion components

This workflow demonstrates specification-driven design in practice. Rather than guessing at tap counts, the filter parameters are derived from measurable requirements — what frequencies to keep, what frequencies to reject, and by how much. The verification step confirms the design meets spec before it touches real data. This is the kind of disciplined approach that separates production-grade filtering from trial-and-error prototyping.

Key Takeaways

  1. FIR filters are inherently stable because they have no feedback. You never need to check pole locations or worry about instability caused by coefficient rounding.
  2. Linear phase is a built-in property of symmetric FIR filters designed with the window method. All frequencies experience the same time delay, preserving the shape of signal features. This is why FIR filters are the default choice in fields like electrophysiology and audio engineering where temporal accuracy is critical.
  3. Use firwin for standard filter shapes. Specify cutoff frequencies (using the fs parameter for convenience), pick a window function based on your stopband attenuation requirements, and use an odd number of taps as a safe default. Remember that the cutoff corresponds to the -6 dB point, not -3 dB.
  4. Choose between lfilter and filtfilt deliberately. filtfilt gives zero-phase output but requires the full signal. lfilter works in real time but introduces a predictable, compensatable delay. Use lfilter_zi to minimize startup transients when streaming.
  5. Always verify your design with freqz. Plotting the magnitude response before applying a filter to real data catches misconfigured cutoffs and wrong filter types before they corrupt results.
  6. Derive filter order from specifications using kaiserord rather than guessing. State your attenuation and transition width requirements, and let the math produce the minimum filter that meets them.
  7. Know when to reach beyond firwin. Use remez for equiripple designs that achieve the same performance with fewer taps. Use firls for weighted least-squares optimization. Use firwin2 for arbitrary frequency response shapes.
  8. Handle edge effects explicitly. The first N-1 samples of any causal filter output are transient artifacts. Discard them, use filtfilt, or initialize with lfilter_zi — but never silently pass corrupted samples into downstream analysis.
  9. Consider computational cost at scale. Use fftconvolve for long filters on long signals, and lfilter with state management for streaming applications.

FIR filters are a reliable tool once the core concepts — taps, cutoff normalization, window choice, group delay, edge effects, and design automation — are clear. The scipy API keeps the implementation concise, which leaves more room to focus on what actually matters: choosing the right filter for the problem at hand, deriving the parameters from your requirements rather than intuition, and verifying the result before it touches real data.

back to articles