Why direct-form adaptive IIR updates can become unstable

Tutorial goal

Compare an unconstrained denominator update with bounded reflection-parameterized adaptation.

Note

New to the terminology? See the lattice DSP concept map and the causality/data-use guide for how online, offline, block, and MIMO examples should be read.

Context

FIR LMS already has a real learning-rate problem: the step size controls convergence, misadjustment, and divergence. Adaptive IIR keeps that tuning issue and adds a structural one, because denominator coefficients move the poles. This tutorial intentionally uses a simple aggressive direct-form update to show the failure mode, then compares it with an adaptive lattice model whose reflection coefficients remain bounded.

Key idea and equations

FIR LMS uses

\[w[n+1] = w[n] + \mu x_n e[n],\]

so step-size selection is already part of the design. Direct-form IIR adaptation also requires all poles of A(z) to stay inside the unit circle. In reflection form, the scalar sufficient condition is simply

\[|k_i| < 1-\epsilon.\]

How to read the result

Look at the pole-radius figure. Values above 1 indicate instability in the direct denominator update; the lattice path enforces stability by construction.

Run command

python examples/stability_vs_direct_iir.py

Run status

Return code: 0

Captured stdout

target denominator: [1.0, 0.369, -0.55]
target max pole radius: 0.94873

direct-form max pole radius: 1.01772
direct-form final finite MSE: 5.424478482509337e-23

lattice learned reflection: [0.74967, -0.65528]
lattice learned denominator: [1.0, 0.25842, -0.65528]
lattice max pole radius: 0.94896
lattice final MSE: 0.012732257568221895

Takeaway: the lattice path may still need tuning, but denominator stability
is enforced by the reflection parameterization instead of hoped for after
a direct coefficient update.
A low finite-window MSE is not itself a stability certificate.

Figures

stability vs direct iir

stability_vs_direct_iir.png

Source code

  1"""Why reflection-parameterized adaptive IIR is useful.
  2
  3This example compares two toy adaptive IIR identifiers:
  4
  51. a deliberately simple direct-form denominator update; and
  62. `AdaptiveLatticeLadderNLMS`, whose denominator is represented by bounded
  7   reflection/PARCOR coefficients.
  8
  9The direct-form update is intentionally aggressive so the pole radius can drift
 10outside the unit circle.  This illustrates that the direct denominator
 11coefficients are numerically awkward stability coordinates: stability is a root
 12location condition, not a per-coefficient box constraint.
 13
 14Even FIR LMS requires careful learning-rate selection: `mu` controls convergence
 15speed, misadjustment, and possible divergence.  Adaptive IIR keeps that tuning
 16problem and adds a structural stability problem because denominator updates move
 17poles.
 18
 19The lattice update keeps `|k_i| < 1 - margin` by construction.  The example is
 20therefore about parameterization and stability enforcement, not about claiming
 21that this specific toy adaptation always has the lowest MSE.
 22"""
 23
 24from __future__ import annotations
 25
 26import os
 27from pathlib import Path
 28
 29import numpy as np
 30
 31from lattice_dsp import AdaptiveLatticeLadderNLMS, LatticeIIR, reflection_to_denominator
 32
 33
 34def max_pole_radius(denominator: np.ndarray) -> float:
 35    """Return max absolute pole radius for A(z)=1+a1 z^-1+...+aN z^-N."""
 36
 37    coeffs = np.asarray(denominator, dtype=float)
 38    if coeffs.ndim != 1 or coeffs.size < 2:
 39        return 0.0
 40    roots = np.roots(coeffs)
 41    return float(np.max(np.abs(roots))) if roots.size else 0.0
 42
 43
 44def direct_form_identifier(
 45    x: np.ndarray,
 46    desired: np.ndarray,
 47    *,
 48    order: int = 2,
 49    mu_num: float = 0.03,
 50    mu_den: float = 0.08,
 51    epsilon: float = 1e-8,
 52) -> tuple[np.ndarray, np.ndarray, list[float]]:
 53    """Tiny approximate-gradient adaptive direct-form IIR identifier.
 54
 55    This is not a recommended algorithm.  It exists to show why unconstrained
 56    denominator updates are dangerous in adaptive IIR examples.
 57    """
 58
 59    b = np.zeros(order + 1, dtype=float)
 60    a = np.zeros(order, dtype=float)  # denominator is [1, a1, a2, ...]
 61    x_hist = np.zeros(order + 1, dtype=float)
 62    y_hist = np.zeros(order, dtype=float)
 63    y = np.zeros_like(x, dtype=float)
 64    error = np.zeros_like(x, dtype=float)
 65    pole_radii: list[float] = []
 66
 67    for n, sample in enumerate(x):
 68        x_hist[1:] = x_hist[:-1]
 69        x_hist[0] = sample
 70        y_n = float(np.dot(b, x_hist) - np.dot(a, y_hist))
 71        e_n = float(desired[n] - y_n)
 72
 73        norm = float(np.dot(x_hist, x_hist) + np.dot(y_hist, y_hist) + epsilon)
 74        b += (mu_num * e_n / norm) * x_hist
 75        # Frozen-gradient update for y = b*x - a*y_history.
 76        # This can move the denominator outside the stability region.
 77        a -= (mu_den * e_n / norm) * y_hist
 78
 79        y[n] = y_n
 80        error[n] = e_n
 81        y_hist[1:] = y_hist[:-1]
 82        y_hist[0] = y_n
 83        pole_radii.append(max_pole_radius(np.r_[1.0, a]))
 84
 85        if not np.isfinite(y_n) or pole_radii[-1] > 1.25:
 86            error[n + 1 :] = np.nan
 87            pole_radii.extend([pole_radii[-1]] * (x.size - n - 1))
 88            break
 89
 90    return y, error, pole_radii
 91
 92
 93def artifact_dir() -> Path:
 94    """Return the directory for generated figures/data."""
 95
 96    path = Path(os.environ.get("LATTICE_DSP_ARTIFACT_DIR", "reports/example-artifacts"))
 97    path.mkdir(parents=True, exist_ok=True)
 98    return path
 99
100
101def main() -> None:
102    rng = np.random.default_rng(11)
103    x = rng.normal(size=10_000)
104
105    target_reflection = [0.82, -0.55]
106    target_numerator = [0.35, -0.05, 0.6]
107    desired = np.asarray(LatticeIIR(target_reflection, target_numerator).process(x), dtype=float)
108
109    target_den = np.asarray(reflection_to_denominator(target_reflection), dtype=float)
110    target_radius = max_pole_radius(target_den)
111
112    _, direct_error, direct_radii = direct_form_identifier(x, desired)
113
114    lattice = AdaptiveLatticeLadderNLMS(
115        initial_reflection=[0.0, 0.0],
116        initial_taps=[0.0, 0.0, 0.0],
117        mu_taps=0.06,
118        mu_reflection=0.0015,
119        margin=1e-4,
120    )
121    lattice_error = np.asarray(lattice.adapt_block(x, desired), dtype=float)
122    learned_den = np.asarray(lattice.denominator, dtype=float)
123
124    print("target denominator:", np.round(target_den, 5).tolist())
125    print("target max pole radius:", round(target_radius, 5))
126    print()
127    print("direct-form max pole radius:", round(float(np.nanmax(direct_radii)), 5))
128    print("direct-form final finite MSE:", float(np.nanmean(direct_error[-1000:] ** 2)))
129    print()
130    print("lattice learned reflection:", np.round(lattice.reflection, 5).tolist())
131    print("lattice learned denominator:", np.round(learned_den, 5).tolist())
132    print("lattice max pole radius:", round(max_pole_radius(learned_den), 5))
133    print("lattice final MSE:", float(np.mean(lattice_error[-1000:] ** 2)))
134    print()
135    print("Takeaway: the lattice path may still need tuning, but denominator stability")
136    print("is enforced by the reflection parameterization instead of hoped for after")
137    print("a direct coefficient update.")
138    print("A low finite-window MSE is not itself a stability certificate.")
139
140    try:
141        import matplotlib.pyplot as plt
142    except Exception:
143        return
144
145    fig, ax = plt.subplots(figsize=(8, 4))
146    ax.plot(direct_radii, label="direct-form adaptive denominator")
147    ax.axhline(1.0, color="black", linestyle="--", linewidth=1.0, label="unit circle")
148    ax.set_title("Direct denominator update can cross the stability boundary")
149    ax.set_xlabel("sample")
150    ax.set_ylabel("max pole radius")
151    ax.legend()
152    fig.tight_layout()
153    out = artifact_dir() / "stability_vs_direct_iir.png"
154    fig.savefig(out, dpi=150)
155    print(f"wrote {out}")
156
157
158if __name__ == "__main__":
159    main()