Filter Structures

Realizing a transfer function: direct, transposed, cascade, parallel, lattice

Chapter 6 ended with a set of coefficients: a transfer function \(H(z)\) that meets a specification. That is the what. This chapter is the how: given \(H(z)\), how do you actually wire up the delays, multipliers, and adders that compute it sample by sample?

The surprise is that there is no single answer. The same \(H(z)\) can be realized by many different block diagrams, all mathematically identical in exact arithmetic, yet very different once you build them. They differ in how much memory they need, how many operations per sample, and (most importantly) how gracefully they degrade when the coefficients and signals are stored in finite-precision numbers. Choosing a structure is the last design decision before the filter becomes code or silicon. It is the real start of the “math to metal” pipeline.

We work with the general difference equation from Chapter 2,

\[y[n] = \sum_{k=0}^{M} b_k\, x[n-k] \;-\; \sum_{k=1}^{N} a_k\, y[n-k],\]

whose transfer function is

\[H(z) = \frac{\sum_{k=0}^{M} b_k\, z^{-k}}{1 + \sum_{k=1}^{N} a_k\, z^{-k}} = \frac{B(z)}{A(z)}.\]

Every structure in this chapter realizes this same \(H(z)\). They are different circuits for one function.


Why structure matters

In exact arithmetic, rearranging a signal-flow graph that computes \(H(z)\) cannot change its output. Real hardware is not exact. Coefficients are rounded to a finite word length, and intermediate sums are quantized. Two effects make the choice of structure consequential:

  • Coefficient sensitivity. The poles of a high-order \(A(z)\) can be extremely sensitive to small changes in the polynomial coefficients. Rounding a single \(a_k\) by one least-significant bit can move a pole enough to ripple the passband or even push it outside the unit circle, making a “stable” design unstable. Breaking the filter into small sections makes each section’s poles far less sensitive.
  • Dynamic range and limit cycles. Different structures produce different intermediate signal magnitudes for the same input. A structure whose internal nodes swing widely is prone to overflow in fixed point; feedback combined with quantization can also trap an IIR filter in a small self-sustaining oscillation called a limit cycle.

So the questions a structure answers are practical: How many delay registers? How many multiply-accumulates per sample? How robust is it to coefficient rounding? Does it overflow? The rest of the chapter introduces the structures in roughly increasing order of numerical robustness.


Direct forms

The most literal realization writes the difference equation out term by term, one multiplier per coefficient and one delay per \(z^{-1}\). This is the direct form. Chapter 2 already drew the generalized FIR transversal structure and the IIR Direct Form I (DF-I), which keeps separate delay lines for the input history and the output history.

DF-I uses \(M + N\) delay elements. You can do better: because the all-zero section \(B(z)\) and the all-pole section \(1/A(z)\) are in series, and series LTI systems commute, you can swap their order and then share one delay line between them. That is Direct Form II (DF-II), the canonical form, which uses only \(\max(M, N)\) delays, the theoretical minimum. The biquad page works through DF-I, DF-II, and the transposed form for the second-order case in detail, with diagrams. Here we focus on what is general about structures, starting with the transformation that turns one structure into another.


The transposition theorem

Every structure has a twin that realizes the same transfer function. You obtain it by transposing the signal-flow graph, following three steps (the transposition theorem):

  1. Reverse the direction of every branch.
  2. Replace every branch (pickoff) node with an adder, and every adder with a branch node.
  3. Interchange the input and the output.

Reversing a flow graph leaves its transfer function unchanged, so the transposed structure computes exactly the same \(H(z)\) with the same coefficients, but it distributes the arithmetic differently and therefore has different numerical behaviour.

Applied to DF-II, this gives the transposed Direct Form II (TDF-II). It is the workhorse of practical IIR filtering: its state update accumulates smaller intermediate values than DF-II, it pipelines naturally, and it is the form that SciPy’s sosfilt and ARM’s CMSIS-DSP arm_biquad_cascade_df2T use internally. The biquad page shows its diagram and state equations.

The point of transposition

Transposition is free: it costs nothing to apply and never changes \(H(z)\). It is purely a tool for trading numerical properties. When someone says “use the transposed form,” they mean “same filter, friendlier arithmetic.”


Cascade form

A single high-order \((b, a)\) pair is the most fragile way to implement a filter, precisely because of the coefficient sensitivity described above. The standard fix is to factor the transfer function into a product of low-order sections:

\[H(z) = g \prod_{k=1}^{L} H_k(z), \qquad H_k(z) = \frac{b_{0k} + b_{1k}\,z^{-1} + b_{2k}\,z^{-2}}{1 + a_{1k}\,z^{-1} + a_{2k}\,z^{-2}}.\]

Each \(H_k(z)\) is a second-order section (a biquad), and the sections run in series, the output of one feeding the next.

x x[n] h1 H 1 (z) x--h1 h2 H 2 (z) h1--h2 dd ··· h2--dd hL H L (z) dd--hL y y[n] hL--y
Figure 1: Cascade form. The transfer function is factored into a series of low-order sections; in practice each H_k(z) is a biquad.

An order-\(N\) filter needs \(\lceil N/2 \rceil\) sections. The win is robustness: a biquad’s two poles are easy to place accurately, and rounding its handful of coefficients barely moves them, whereas rounding the coefficients of one big polynomial can move its poles dramatically. Cascading also makes stability trivial to check section by section.

In Python, scipy.signal.tf2sos performs the factorization and pole-zero pairing for you, and sosfilt runs the cascade (each section in transposed Direct Form II):

# A 4th-order lowpass, expressed two ways
b, a = butter(4, 0.25)                 # single (b, a) pair
sos = tf2sos(b, a)                     # cascade of second-order sections
print("sections:", sos.shape[0])
print(np.round(sos, 4))

# The two realizations agree to machine precision (at this modest order)
rng = np.random.default_rng(0)
x = rng.standard_normal(2000)
err = np.max(np.abs(sosfilt(sos, x) - lfilter(b, a, x)))
print(f"max |sosfilt - lfilter| = {err:.2e}")
sections: 2
[[ 0.0102  0.0204  0.0102  1.     -0.8554  0.2097]
 [ 1.      2.      1.      1.     -1.113   0.5741]]
max |sosfilt - lfilter| = 5.77e-15

Each row of the sos matrix is one section, laid out as \([\,b_0, b_1, b_2, 1, a_1, a_2\,]\). At fourth order both realizations agree, but as the order grows the single-polynomial path loses accuracy fast while the cascade stays clean. The filter design chapter and the biquad page show that divergence directly. This is why every serious IIR implementation ships as second-order sections.

Section ordering matters

The factorization is not unique: you can pair any pole with any zero and order the sections however you like. These choices do not change \(H(z)\) but they do change the internal signal levels, which matters for overflow in fixed point. tf2sos uses a sensible default (pair each pole with its nearest zero, order to keep intermediate gains moderate). For most work the default is fine.


Parallel form

Instead of factoring \(H(z)\) into a product, you can expand it into a sum. A partial-fraction expansion writes the transfer function as

\[H(z) = \sum_{k} H_k(z) \;+\; (\text{optional direct term}),\]

where each \(H_k(z)\) is again a low-order (typically second-order) section, but now the sections run in parallel: the input feeds all of them at once and their outputs are summed.

x x[n] bm x--bm bt bt--bm h0 H 1 (z) bt--h0 bd bm--bd h1 H 2 (z) bm--h1 bb bd--bb hL H L (z) bb--hL c0 + h0--c0 c1 + h1--c1 hd cL hL--cL y y[n] c0--y c1--c0 cd cd--c1 cL--cd
Figure 2: Parallel form. The input drives every section simultaneously and the section outputs are summed.

scipy.signal.residuez computes the expansion (residues \(r_k\), poles \(p_k\), and any direct terms). Grouping each complex-conjugate pole pair into one real second-order section gives a realizable parallel structure:

def parallel_sections(b, a):
    """Partial-fraction expansion grouped into real second-order sections."""
    r, p, k = residuez(b, a)
    sections, used = [], np.zeros(len(p), bool)
    for i in range(len(p)):
        if used[i]:
            continue
        # find the conjugate partner of a complex pole
        j = next((jj for jj in range(len(p)) if jj != i and not used[jj]
                  and np.isclose(p[jj], np.conj(p[i])) and abs(p[i].imag) > 1e-9), -1)
        if j >= 0:                                   # conjugate pair -> real biquad
            used[i] = used[j] = True
            bsec = np.real([r[i] + r[j], -(r[i]*p[j] + r[j]*p[i]), 0.0])
            asec = np.real([1.0, -(p[i] + p[j]), p[i]*p[j]])
        else:                                        # lone real pole -> first order
            used[i] = True
            bsec, asec = np.real([r[i], 0.0, 0.0]), np.real([1.0, -p[i], 0.0])
        sections.append((bsec, asec))
    return sections, np.real(k)

b, a = butter(4, 0.25)
sections, direct = parallel_sections(b, a)
y_par = sum(lfilter(bs, as_, x) for bs, as_ in sections)
if len(direct):
    y_par = y_par + lfilter(direct, [1.0], x)
print(f"parallel sections: {len(sections)}")
print(f"max |parallel - lfilter| = {np.max(np.abs(y_par - lfilter(b, a, x))):.2e}")
parallel sections: 2
max |parallel - lfilter| = 4.44e-15

The cascade and parallel forms realize the same filter and share the same robustness advantage over a single high-order section. They differ in detail: the cascade preserves the exact zeros of \(H(z)\) in each section (good when the zeros matter, as in notch filters), while the parallel form keeps the sections fully independent (good for parallel hardware, and the natural form when you think of a filter as a sum of resonances).


Lattice structures

The lattice is a different shape entirely. Instead of a delay line tapped by coefficients, it is a ladder of identical two-input, two-output stages, each parametrized by a single reflection coefficient \(k_m\). Lattices are prized in speech coding (linear predictive coding), adaptive filtering, and anywhere coefficient robustness and a cheap stability test matter.

Each stage carries a forward signal \(f_m[n]\) and a backward signal \(g_m[n]\). For an all-zero (FIR) filter the stages run from \(m = 1\) to \(M\):

\[f_0[n] = g_0[n] = x[n],\] \[f_m[n] = f_{m-1}[n] + k_m\, g_{m-1}[n-1],\] \[g_m[n] = k_m\, f_{m-1}[n] + g_{m-1}[n-1],\]

and the output is \(y[n] = f_M[n]\). A single stage is the cross-coupled “butterfly” below: the forward signal passes straight through and also feeds the backward adder through \(k_m\), while the delayed backward signal passes straight through and feeds the forward adder through \(k_m\).

fin f m−1 fb fin--fb At + fb--At kd k m fb--kd fo f m At--fo gin g m−1 z z⁻¹ gin--z gb z--gb Bb + gb--Bb ku k m gb--ku go g m Bb--go kd--Bb ku--At
Figure 3: One stage of an FIR (all-zero) lattice. The forward path f and backward path g are cross-coupled through the reflection coefficient k_m; the delay sits on the backward path.

The reflection coefficients are not the filter coefficients \(b_k\); you obtain them from the polynomial by a step-down (Levinson) recursion. Starting from the monic polynomial \(A(z) = 1 + a_1 z^{-1} + \cdots + a_M z^{-M}\), peel off one stage at a time:

\[k_m = a_m^{(m)}, \qquad a_i^{(m-1)} = \frac{a_i^{(m)} - k_m\, a_{m-i}^{(m)}}{1 - k_m^2}.\]

The same lattice stages, rewired so the forward recursion runs backward, realize the all-pole filter \(1/A(z)\):

\[f_M[n] = x[n], \qquad f_{m-1}[n] = f_m[n] - k_m\, g_{m-1}[n-1], \qquad g_m[n] = k_m\, f_{m-1}[n] + g_{m-1}[n-1],\]

with output \(y[n] = f_0[n]\). The code below builds both lattices from their reflection coefficients and confirms they reproduce the ordinary direct-form output to machine precision (the near-zero errors are the verification; this chapter introduces lattices, which scipy.signal does not provide ready-made):

def poly_to_reflection(a):
    """Step-down (Levinson) recursion: monic polynomial -> reflection coefficients."""
    a = np.asarray(a, float)
    a = a / a[0]
    M = len(a) - 1
    k = np.zeros(M)
    for m in range(M, 0, -1):
        k[m - 1] = a[m]
        a = (a[:m + 1] - a[m] * a[:m + 1][::-1])[:m] / (1 - a[m] ** 2)
    return k

def fir_lattice(k, x):
    """All-zero FIR lattice; returns f_M[n]."""
    M = len(k)
    g_delayed = np.zeros(M)                       # g_{m-1}[n-1]
    y = np.zeros_like(x, float)
    for n in range(len(x)):
        f, g = x[n], np.empty(M + 1)
        g[0] = x[n]
        for m in range(1, M + 1):
            f_next = f + k[m - 1] * g_delayed[m - 1]
            g[m] = k[m - 1] * f + g_delayed[m - 1]
            f = f_next
        y[n] = f
        g_delayed = g[:M].copy()
    return y

def allpole_lattice(k, x):
    """All-pole IIR lattice realizing 1/A(z); returns f_0[n]."""
    M = len(k)
    g_delayed = np.zeros(M)
    y = np.zeros_like(x, float)
    for n in range(len(x)):
        f, g = x[n], np.empty(M + 1)
        for m in range(M, 0, -1):
            f = f - k[m - 1] * g_delayed[m - 1]
            g[m] = k[m - 1] * f + g_delayed[m - 1]
        g[0] = f
        y[n] = f
        g_delayed = g[:M].copy()
    return y

# FIR lattice vs direct-form FIR
b_fir = np.array([1.0, -0.6, 0.3, 0.2])
k_fir = poly_to_reflection(b_fir)
err_fir = np.max(np.abs(b_fir[0] * fir_lattice(k_fir, x) - lfilter(b_fir, [1.0], x)))
print(f"FIR  reflection coeffs = {np.round(k_fir, 4)}")
print(f"FIR  max |lattice - lfilter| = {err_fir:.2e}")

# All-pole lattice vs direct-form IIR
a_iir = np.array([1.0, -0.5, 0.25])
k_iir = poly_to_reflection(a_iir)
err_iir = np.max(np.abs(allpole_lattice(k_iir, x) - lfilter([1.0], a_iir, x)))
print(f"IIR  reflection coeffs = {np.round(k_iir, 4)}")
print(f"IIR  max |lattice - lfilter| = {err_iir:.2e}")
FIR  reflection coeffs = [-0.4783  0.4375  0.2   ]
FIR  max |lattice - lfilter| = 8.88e-16
IIR  reflection coeffs = [-0.4   0.25]
IIR  max |lattice - lfilter| = 4.44e-16

Two properties make the lattice attractive on hardware. First, stability is trivial to test: an all-pole lattice is stable if and only if every reflection coefficient satisfies \(|k_m| < 1\), a single comparison per stage rather than a root-finding problem. Second, the lattice is modular and well-conditioned: increasing the order just appends a stage without recomputing the earlier ones, and the structure tolerates coefficient rounding gracefully, which is why it dominates speech LPC.

From math to metal

ARM’s CMSIS-DSP library ships lattice filters ready to drop onto a Cortex-M: arm_fir_lattice_f32 and arm_iir_lattice_f32 (with q15/q31 fixed-point variants). The FIR function takes exactly the reflection coefficients computed above; the IIR function realizes a general pole-zero response, so it needs the reflection coefficients plus a set of ladder (tap) coefficients for the numerator, with the all-pole case here as its special instance. The transposed Direct Form II biquad cascade from earlier is arm_biquad_cascade_df2T_f32. Picking a structure here is picking the library function you will call on the metal.


Choosing a structure

All of these realize the same \(H(z)\). The differences are practical:

Structure Delays Robustness to coefficient rounding Typical use
Direct Form I \(M + N\) Poor at high order; good per low-order section Fixed-point biquads (separate delay lines resist overflow)
Direct Form II \(\max(M,N)\) Poor at high order; wide internal range Rarely used directly at high order
Transposed DF-II \(\max(M,N)\) Good per section; small internal values Default for floating-point (SciPy sosfilt, CMSIS df2T)
Cascade (SOS) \(2\lceil N/2\rceil\) Very good (low-order sections, exact zeros) The standard production IIR form
Parallel \(2\lceil N/2\rceil\) Very good (independent sections) Parallel hardware; sum-of-resonances designs
Lattice \(M\) or \(N\) Very good; trivial \(|k_m|<1\) stability test Speech LPC, adaptive filters

The headline advice is simple. Never implement a high-order IIR filter as a single \((b, a)\) pair. Factor it into second-order sections and run them in transposed Direct Form II, which is exactly what scipy.signal.sosfilt and CMSIS-DSP give you by default. Reach for lattice structures when you need a cheap stability guarantee or you are doing linear prediction.


Exercises

A first-order structure is described by the difference equation \[y[n] = b_0\, x[n] + b_1\, x[n-1] - a_1\, y[n-1].\] Write its transfer function \(H(z) = Y(z)/X(z)\). Is this Direct Form I or Direct Form II if the input and output histories use separate delay registers?

Taking the \(z\)-transform of both sides and collecting terms, \[H(z) = \frac{b_0 + b_1 z^{-1}}{1 + a_1 z^{-1}}.\] Separate delay registers for the input (\(x[n-1]\)) and the output (\(y[n-1]\)) make this Direct Form I. Sharing a single register between the feedforward and feedback paths would make it Direct Form II.

A biquad section has \[H(z) = \frac{1 + 0.1\,z^{-1} - 0.7199\,z^{-2}}{1 - 1.1786\,z^{-1} + 0.7246\,z^{-2}}.\] (a) Write its Direct Form II difference equations. (b) With \(f_s = 100\) kHz, find the resonance frequency from the pole angle.

(a) With the shared state \(w[n]\) and the denominator coefficients \(a_1 = -1.1786\), \(a_2 = 0.7246\): \[w[n] = x[n] + 1.1786\,w[n-1] - 0.7246\,w[n-2],\] \[y[n] = w[n] + 0.1\,w[n-1] - 0.7199\,w[n-2].\]

(b) Write the denominator as \(A(z) = 1 - 2R\cos\theta\,z^{-1} + R^2 z^{-2}\). Then \(R = \sqrt{0.7246} = 0.851\) and \(\cos\theta = 1.1786 / (2R) = 0.692\), so \(\theta = 0.806\) rad. The resonance frequency is \[f_c = \frac{\theta}{2\pi}\, f_s = \frac{0.806}{2\pi}\,(100\ \text{kHz}) \approx 12.8\ \text{kHz}.\] The poles have magnitude \(R = 0.851 < 1\), so the section is stable.

Take the 6th-order lowpass b, a = butter(6, 0.2). Decompose it into second-order sections with tf2sos, and compare sosfilt against lfilter on a noise input. Which one do you trust at this order, and why?

b6, a6 = butter(6, 0.2)
sos6 = tf2sos(b6, a6)
xt = rng.standard_normal(4000)
diff = np.max(np.abs(sosfilt(sos6, xt) - lfilter(b6, a6, xt)))
print(f"{sos6.shape[0]} sections, max |sosfilt - lfilter| = {diff:.2e}")
3 sections, max |sosfilt - lfilter| = 1.14e-13

The two agree here because the input is well-scaled and double precision is generous, but the single \((b, a)\) path is the fragile one: its 6th-order denominator has tightly clustered poles whose locations are hypersensitive to coefficient rounding. In fixed point, or at higher order, lfilter on the flat polynomial degrades while the three-section sosfilt cascade stays accurate. Trust the SOS form.

The transposed Direct Form II second-order section has the state equations \[y[n] = b_0\,x[n] + s_1[n-1], \quad s_1[n] = b_1\,x[n] - a_1\,y[n] + s_2[n-1], \quad s_2[n] = b_2\,x[n] - a_2\,y[n]\] (this is the structure you derive by hand in Exercise 5). Show by direct calculation, taking \(z\)-transforms and eliminating the internal states \(S_1(z)\) and \(S_2(z)\), that it has the same transfer function as the direct form it was transposed from.

Take the \(z\)-transform of each equation, where a one-sample delay becomes a factor \(z^{-1}\): \[Y(z) = b_0 X(z) + z^{-1} S_1(z), \quad S_1(z) = b_1 X(z) - a_1 Y(z) + z^{-1} S_2(z), \quad S_2(z) = b_2 X(z) - a_2 Y(z).\] Substitute \(S_2(z)\) into the expression for \(S_1(z)\): \[S_1(z) = (b_1 + b_2 z^{-1})\,X(z) - (a_1 + a_2 z^{-1})\,Y(z),\] then substitute that into the equation for \(Y(z)\): \[Y(z) = b_0 X(z) + z^{-1}\bigl[(b_1 + b_2 z^{-1})\,X(z) - (a_1 + a_2 z^{-1})\,Y(z)\bigr].\] Collecting the \(Y\) terms on the left, \[Y(z)\,\bigl(1 + a_1 z^{-1} + a_2 z^{-2}\bigr) = \bigl(b_0 + b_1 z^{-1} + b_2 z^{-2}\bigr)\,X(z),\] so \[H(z) = \frac{Y(z)}{X(z)} = \frac{b_0 + b_1 z^{-1} + b_2 z^{-2}}{1 + a_1 z^{-1} + a_2 z^{-2}},\] which is exactly the transfer function of the Direct Form II section it was transposed from. The transpose rearranges where the additions happen, not what is computed: same \(H(z)\), different numerical behaviour. That is why the transposition theorem is “free.”

Starting from a second-order Direct Form II section, apply the three transposition rules (reverse every branch, swap branch nodes with adders, exchange input and output) and describe the resulting transposed Direct Form II. Where do the delays and the multiplies by \(b_k\) and \(-a_k\) end up?

Reversing the graph turns the input fan-out node (where \(x[n]\) splits into the \(b_k\) taps) into the output summing node, and turns the output adder into the input fan-out. The two delay elements stay, but now each one sits between an adder and the next, accumulating a partial sum rather than holding a raw sample. The result is the structure \[y[n] = b_0\,x[n] + s_1[n-1], \quad s_1[n] = b_1\,x[n] - a_1\,y[n] + s_2[n-1], \quad s_2[n] = b_2\,x[n] - a_2\,y[n].\] Each \(b_k\) multiplies the input and each \(-a_k\) multiplies the output, with the products injected into the delay chain. This is transposed Direct Form II, the form sosfilt and CMSIS df2T use, and it is preferred because the intermediate states \(s_1, s_2\) stay smaller than the raw accumulator of plain DF-II.


Summary

  • The same \(H(z)\) has many structures (realizations). In exact arithmetic they are identical; in finite precision they differ in memory, operation count, and robustness.
  • Direct forms implement the difference equation literally. DF-II uses the minimum number of delays; the transposed DF-II has the best numerical behaviour and is the floating-point default.
  • Transposition turns any structure into a twin with the same \(H(z)\) but different arithmetic, for free.
  • Cascade (product of biquads) and parallel (sum of sections) forms break a fragile high-order polynomial into robust low-order sections. Cascade SOS is the production standard.
  • Lattice structures realize the filter as a ladder of reflection-coefficient stages, with a trivial \(|k_m| < 1\) stability test, favoured in speech and adaptive filtering.
  • The practical rule: factor into second-order sections and run them in transposed Direct Form II.

This is the last chapter of the Basics path. You now have the full toolchain: sampling and quantization, discrete-time systems, the z-domain and frequency domain, filter design, and the structures that turn a design into something a processor can run. The natural next step is to put it to work on real signals.

Going deeper

The Topics section applies these structures to concrete problems: the biquad filters topic builds cascaded second-order sections for audio equalizers and shows them on real microcontrollers; multirate systems uses polyphase and CIC structures for efficient sample-rate conversion; adaptive filtering lets a filter’s coefficients adjust themselves in real time. The capstone exercise ties the whole Basics path together in one end-to-end pipeline.

References

For the transposition theorem, the cascade and parallel forms, and the lattice (including the step-down recursion), see Oppenheim and Schafer (Oppenheim and Schafer 2010, ch. 6), Proakis and Manolakis (Proakis and Manolakis 2007), and Mitra (Mitra 2006). The cascade-of-biquads argument is developed further on the biquad page, and the numerical-stability motivation appears in the filter design chapter.

Mitra, Sanjit K. 2006. Digital Signal Processing: A Computer-Based Approach. 3rd ed. McGraw-Hill.
Oppenheim, Alan V., and Ronald W. Schafer. 2010. Discrete-Time Signal Processing. 3rd ed. Pearson.
Proakis, John G., and Dimitris G. Manolakis. 2007. Digital Signal Processing: Principles, Algorithms, and Applications. 4th ed. Pearson.