Zero-Phase Filtering

Filtering without phase distortion

A causal digital filter delays different frequency components by different amounts. This is not just a time shift. It distorts the waveform shape. Peaks spread, sharp edges soften asymmetrically, and the temporal relationships between signal components change. For many applications (biomedical signal analysis, vibration monitoring, feature extraction) this phase distortion is unacceptable.

Forward-backward filtering eliminates phase distortion entirely by filtering the signal twice: once forward in time, once backward. The result has the squared magnitude response of the original filter and exactly zero phase shift at all frequencies. The price: it requires the entire signal in advance. You cannot do this in real time.

Prerequisites

This topic assumes familiarity with filter design (IIR filters, group delay) and frequency-domain analysis. The group delay discussion provides the theoretical background on why linear-phase FIR filters are sometimes preferred over IIR filters.


Why phase matters

Consider a signal composed of two frequency components with a specific temporal relationship, say, a narrow pulse built from harmonics. A filter that shifts these harmonics by different amounts reassembles them into a different shape.

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

fs = 1000
t = np.arange(2000) / fs
# Pulse-like signal from harmonics
x = np.zeros_like(t)
for k in range(1, 20, 2):
    x += (1.0 / k) * np.sin(2 * np.pi * k * 5 * t)

# Lowpass IIR filter
sos = sig.butter(4, 50, fs=fs, output='sos')

# Causal filtering (forward only)
y_causal = sig.sosfilt(sos, x)

# Zero-phase filtering (forward-backward)
y_zerophase = sig.sosfiltfilt(sos, x)

fig, axes = plt.subplots(3, 1, figsize=(10, 6), sharex=True)
axes[0].plot(t, x, 'C0', linewidth=0.8)
axes[0].set_title('Original signal')
axes[0].set_ylabel('Amplitude')

axes[1].plot(t, x, 'C7', linewidth=0.5, alpha=0.5)
axes[1].plot(t, y_causal, 'C3', linewidth=1.2)
axes[1].set_title('Causal filter — phase distortion visible')
axes[1].set_ylabel('Amplitude')

axes[2].plot(t, x, 'C7', linewidth=0.5, alpha=0.5)
axes[2].plot(t, y_zerophase, 'C2', linewidth=1.2)
axes[2].set_title('Zero-phase filter — shape preserved, no delay')
axes[2].set_ylabel('Amplitude')
axes[2].set_xlabel('Time [s]')

for ax in axes:
    ax.set_xlim(0.2, 0.8)
    ax.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
Figure 1: Causal filtering introduces phase distortion (time shift and waveform asymmetry). Zero-phase filtering preserves the original waveform shape.

The causal filter shifts and distorts the waveform. The zero-phase filter preserves the shape and timing of every feature.


How forward-backward filtering works

The algorithm is straightforward:

  1. Forward pass: filter \(x[n]\) with \(H(z)\) to produce \(y_f[n]\)
  2. Reverse: time-reverse \(y_f[n]\) to get \(y_f[-n]\)
  3. Backward pass: filter \(y_f[-n]\) with \(H(z)\) again to produce \(y_b[n]\)
  4. Reverse again: time-reverse \(y_b[n]\) to get the output \(y[n]\)

The effective transfer function is:

\[H_{\text{eff}}(e^{j\omega}) = H(e^{j\omega}) \cdot H(e^{-j\omega}) = |H(e^{j\omega})|^2\]

The phase of \(H(e^{j\omega})\) is exactly cancelled by the conjugate phase of the backward pass. What remains is the squared magnitude, always real and non-negative, hence zero phase.

Tip

The squared magnitude means the effective filter is “sharper” than you might expect. A 4th-order Butterworth filtered forward-backward has an 8th-order magnitude rolloff. Design for the single-pass order, knowing the effective order will be doubled.


SciPy implementation

SciPy provides two functions for zero-phase filtering:

  • scipy.signal.filtfilt(b, a, x): for transfer function form
  • scipy.signal.sosfiltfilt(sos, x): for second-order sections (preferred for numerical stability)
# Simulate ECG-like signal
rng = np.random.default_rng(123)
t_ecg = np.arange(3000) / fs
ecg = np.zeros_like(t_ecg)
for beat in np.arange(0.3, 2.7, 0.8):
    ecg += 1.5 * np.exp(-((t_ecg - beat) / 0.01)**2)  # R peak
    ecg -= 0.3 * np.exp(-((t_ecg - beat - 0.05) / 0.03)**2)  # T wave
ecg += 0.4 * rng.standard_normal(len(t_ecg))

# 30 Hz lowpass
sos_lp = sig.butter(4, 30, fs=fs, output='sos')
ecg_causal = sig.sosfilt(sos_lp, ecg)
ecg_zp = sig.sosfiltfilt(sos_lp, ecg)

fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(t_ecg, ecg, 'C7', linewidth=0.5, label='Noisy')
ax.plot(t_ecg, ecg_causal, 'C0', linewidth=1.2, label='Causal (sosfilt)')
ax.plot(t_ecg, ecg_zp, 'C3', linewidth=1.2, label='Zero-phase (sosfiltfilt)')
ax.set_xlim(0, 2.5)
ax.set_xlabel('Time [s]')
ax.set_ylabel('Amplitude')
ax.legend(fontsize=8)
ax.grid(True, alpha=0.3)
ax.set_title('Causal vs zero-phase filtering of ECG-like signal')
fig.tight_layout()
plt.show()
Figure 2: Comparison of causal vs zero-phase IIR lowpass on noisy ECG-like data. The zero-phase output has no delay and preserves peak timing, critical for biomedical applications.

Magnitude response comparison

The forward-backward pass squares the magnitude response. This has practical consequences for filter specification:

w, H_single = sig.sosfreqz(sos_lp, worN=4096, fs=fs)
H_fb = np.abs(H_single)**2  # Forward-backward = squared magnitude

fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(w, 20*np.log10(np.maximum(np.abs(H_single), 1e-10)),
        'C0', linewidth=1.5, label='Single pass (4th order)')
ax.plot(w, 20*np.log10(np.maximum(H_fb, 1e-10)),
        'C3', linewidth=1.5, label='Forward-backward (effective 8th order)')
ax.axhline(-3, color='C0', linestyle=':', alpha=0.5)
ax.axhline(-6, color='C3', linestyle=':', alpha=0.5)
ax.set_xlabel('Frequency [Hz]')
ax.set_ylabel('Magnitude [dB]')
ax.set_xlim(0, 100)
ax.set_ylim(-60, 5)
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
ax.set_title('Magnitude response: single pass vs forward-backward')
fig.tight_layout()
plt.show()
Figure 3: Single-pass vs forward-backward magnitude response. The -3 dB point of the single-pass filter becomes the -6 dB point after forward-backward filtering. The effective rolloff is twice as steep.

If you want the -3 dB point of the forward-backward filter at a specific frequency \(f_c\), design the single-pass filter with a slightly higher cutoff. SciPy’s sosfiltfilt does not adjust for this automatically. You need to compensate yourself. For a Butterworth filter of order \(N\), the correction factor is:

\[f_{c,\text{design}} = f_c \cdot \left(\sqrt{2} - 1\right)^{-1/(2N)}\]

This comes from requiring the forward–backward magnitude \(|H(\omega_c)|^2 = 1/\sqrt{2}\) (its \(-3\) dB point); for a Butterworth response \(|H(\omega)|^2 = 1/\!\left(1 + (\omega/\omega_\text{design})^{2N}\right)\) this gives \((\omega_c/\omega_\text{design})^{2N} = \sqrt{2} - 1\). The correction is modest but not negligible, about 12% at \(N = 4\), falling to ~6% at \(N = 8\), and is often tuned empirically.


FIR alternative: linear phase

Forward-backward filtering is not the only way to avoid phase distortion. A symmetric FIR filter has exactly linear phase, which corresponds to a pure time delay. The waveform shape is preserved, just shifted.

The trade-off:

Property Forward-backward IIR Linear-phase FIR
Phase distortion Zero Zero (constant delay)
Time delay None \((N-1)/2\) samples
Real-time capable No Yes
Filter order for sharp cutoff Low (effective 2N) High (hundreds of taps)
Edge effects Yes (startup/end transients) Yes (group delay at edges)

For offline analysis, forward-backward IIR is usually preferable: lower order, no delay, sharp cutoff. For real-time applications, a linear-phase FIR is the standard choice: you accept a fixed delay but get causal operation.


Edge effects and padding

Forward-backward filtering requires handling signal boundaries. When the forward pass reaches the end of the signal, the filter’s internal state contains transient energy that has nowhere to go. The backward pass then starts from this corrupted state, and the transient propagates back into the signal.

SciPy’s filtfilt and sosfiltfilt mitigate this by padding the signal before filtering and trimming afterwards. The padlen parameter controls how many samples of padding are added (default is 3 * max(len(a), len(b))). The padding method reflects the signal at the boundaries.

For short signals or aggressive filters (low cutoff, high order), the default padding may not be sufficient. Increasing padlen or manually padding the signal can help, but there is no perfect solution. Some edge corruption is inherent to the method.


Open questions

Minimum-phase alternative. Rather than achieving zero phase via forward-backward filtering, an alternative is to design a minimum-phase filter that concentrates its group delay at low frequencies and has the fastest possible impulse response. Minimum-phase filters are causal and have less delay than linear-phase FIR filters of comparable performance, but they still introduce some phase distortion. Whether this distortion is acceptable depends on the application.

Non-causal filtering in real-time. Forward-backward filtering is fundamentally non-causal, but some real-time systems approximate it using a look-ahead buffer: delay the output by \(D\) samples and use future input samples to approximate the backward pass. This gives “nearly zero” phase within the passband at the cost of a fixed latency. How much latency is needed for a given approximation quality is application-dependent and not well characterised in the literature for arbitrary filter designs.