Finite Hankel SISO model reduction

Tutorial goal

Build a finite Hankel matrix from a stable IIR impulse response and construct lower-order rational models with the C++ backend.

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

This tutorial is the first executable bridge between the model-reduction theory page and the package API. It is deliberately finite-dimensional: a truncated Hankel matrix is built from the impulse response, its singular values are inspected, and a lower-order Ho–Kalman realization is recovered from the leading Hankel factors.

Exact AAK/Nehari theory is an infinite-dimensional optimal rational-approximation theory. The implementation here is a practical finite-section reducer inspired by the same Hankel-operator viewpoint, but it does not claim exact Nehari or AAK optimality.

Key idea and equations

Given an impulse response h[n], the finite Hankel matrix is

\[H_{ij}=h[i+j+1].\]

Its singular values measure input-output memory. A reduced order r keeps the leading singular directions and forms a balanced finite realization

\[H_0 \approx U_r \Sigma_r V_r^T, \qquad A_r = \Sigma_r^{-1/2}U_r^T H_1 V_r\Sigma_r^{-1/2}.\]

How to read the result

Look for Hankel singular-value decay, retained Hankel energy, impulse-response error, and whether the reduced denominator remains stable.

Run command

python examples/finite_hankel_model_reduction.py

Run status

Return code: 0

Captured stdout

full order: 8
finite Hankel matrix: 48 x 48
leading Hankel singular values: [7.372965, 0.174286, 0.135451, 0.113361, 0.015519, 0.01479, 0.009059, 0.002232]
order=2: stable=True, retained_energy=0.999417, rel_impulse_error=4.282e-03, max_mag_error=1.933 dB
order=4: stable=True, retained_energy=0.999990, rel_impulse_error=3.880e-05, max_mag_error=0.175 dB
order=6: stable=True, retained_energy=0.999998, rel_impulse_error=1.834e-05, max_mag_error=0.129 dB
order=8: stable=True, retained_energy=1.000000, rel_impulse_error=1.115e-23, max_mag_error=0.000 dB

Figures

finite hankel reduced responses

finite_hankel_reduced_responses.png

finite hankel singular values

finite_hankel_singular_values.png

Generated data files

Source code

  1"""Tutorial: finite Hankel model reduction for a stable SISO IIR.
  2
  3This example is a practical bridge between the theory page and executable code.
  4It builds a stable high-order lattice IIR, forms a finite Hankel matrix from its
  5impulse response, inspects the Hankel singular values, and constructs lower-order
  6finite-Hankel reduced models with the C++ backend.
  7
  8The implementation is intentionally labeled "finite-Hankel/Ho-Kalman": it uses a
  9truncated Hankel matrix and a Ho-Kalman realization, so it is a useful numerical
 10approximation and diagnostic rather than a claim of exact infinite-dimensional
 11AAK or Nehari optimality.
 12"""
 13
 14from __future__ import annotations
 15
 16import csv
 17import os
 18from pathlib import Path
 19
 20import numpy as np
 21
 22import lattice_dsp as ld
 23
 24
 25def artifact_dir() -> Path:
 26    path = Path(os.environ.get("LATTICE_DSP_ARTIFACT_DIR", "reports/example-artifacts"))
 27    path.mkdir(parents=True, exist_ok=True)
 28    return path
 29
 30
 31def freq_response(
 32    denominator: np.ndarray, numerator: np.ndarray, n_fft: int = 1024
 33) -> tuple[np.ndarray, np.ndarray]:
 34    freq = np.linspace(0.0, 0.5, n_fft // 2 + 1)
 35    z = np.exp(-2j * np.pi * freq)
 36    num = np.zeros_like(z, dtype=complex)
 37    den = np.zeros_like(z, dtype=complex)
 38    for i, coef in enumerate(numerator):
 39        num += float(coef) * z**i
 40    for i, coef in enumerate(denominator):
 41        den += float(coef) * z**i
 42    mag_db = 20.0 * np.log10(np.maximum(np.abs(num / den), 1e-12))
 43    return freq, mag_db
 44
 45
 46def write_summary(path: Path, rows: list[dict[str, float | int | str | bool]]) -> None:
 47    with path.open("w", newline="", encoding="utf-8") as f:
 48        fieldnames = [
 49            "order",
 50            "stable",
 51            "retained_hankel_energy",
 52            "relative_impulse_error",
 53            "max_magnitude_error_db",
 54        ]
 55        writer = csv.DictWriter(f, fieldnames=fieldnames)
 56        writer.writeheader()
 57        writer.writerows(rows)
 58
 59
 60def main() -> None:
 61    out_dir = artifact_dir()
 62
 63    # A stable eighth-order all-pole denominator.  In a lattice representation,
 64    # stability is controlled by |k_i| < 1.
 65    reflection = np.array([0.62, -0.48, 0.36, -0.28, 0.20, -0.14, 0.09, -0.05], dtype=float)
 66    numerator = np.array([1.0, -0.22, 0.15, 0.08, -0.05, 0.03, 0.0, 0.0, 0.0], dtype=float)
 67    denominator = np.asarray(ld.reflection_to_denominator(reflection), dtype=float)
 68
 69    n_impulse = 360
 70    rows = cols = 48
 71    impulse = np.asarray(ld.iir_impulse_response(denominator, numerator, n_impulse), dtype=float)
 72    hsv = np.asarray(ld.hankel_singular_values(impulse, rows, cols), dtype=float)
 73
 74    freq, full_mag = freq_response(denominator, numerator)
 75
 76    orders = [2, 4, 6, 8]
 77    summary: list[dict[str, float | int | str | bool]] = []
 78    reduced_curves: dict[int, np.ndarray] = {}
 79
 80    for order in orders:
 81        result = ld.finite_hankel_reduce_iir(
 82            reflection.tolist(),
 83            numerator.tolist(),
 84            reduced_order=order,
 85            n_impulse=n_impulse,
 86            rows=rows,
 87            cols=cols,
 88        )
 89        red_den = np.asarray(result["denominator"], dtype=float)
 90        red_num = np.asarray(result["numerator"], dtype=float)
 91        _, red_mag = freq_response(red_den, red_num)
 92        reduced_curves[order] = red_mag
 93        max_mag_err = float(np.max(np.abs(full_mag - red_mag)))
 94        summary.append(
 95            {
 96                "order": order,
 97                "stable": bool(result["stable"]),
 98                "retained_hankel_energy": float(result["retained_hankel_energy"]),
 99                "relative_impulse_error": float(result["relative_impulse_error"]),
100                "max_magnitude_error_db": max_mag_err,
101            }
102        )
103
104    csv_path = out_dir / "finite_hankel_model_reduction_summary.csv"
105    write_summary(csv_path, summary)
106
107    print("full order:", len(reflection))
108    print("finite Hankel matrix:", f"{rows} x {cols}")
109    print("leading Hankel singular values:", [round(float(v), 6) for v in hsv[:8]])
110    for row in summary:
111        print(
112            "order={order}: stable={stable}, retained_energy={retained_hankel_energy:.6f}, "
113            "rel_impulse_error={relative_impulse_error:.3e}, max_mag_error={max_magnitude_error_db:.3f} dB".format(
114                **row
115            )
116        )
117    print(f"wrote {csv_path}")
118
119    try:
120        import matplotlib.pyplot as plt
121    except Exception:
122        print("matplotlib is not installed; skipped figures")
123        return
124
125    fig, ax = plt.subplots(figsize=(8.5, 4.6))
126    idx = np.arange(1, min(24, hsv.size) + 1)
127    ax.semilogy(idx, hsv[: idx.size], marker="o")
128    ax.set_title("Finite Hankel singular-value decay")
129    ax.set_xlabel("index")
130    ax.set_ylabel("singular value")
131    ax.grid(True, alpha=0.3)
132    fig.tight_layout()
133    fig_path = out_dir / "finite_hankel_singular_values.png"
134    fig.savefig(fig_path, dpi=160)
135    print(f"wrote {fig_path}")
136
137    fig2, ax2 = plt.subplots(figsize=(9, 4.8))
138    ax2.plot(freq, full_mag, linewidth=2.0, label="full order 8")
139    for order in orders:
140        ax2.plot(freq, reduced_curves[order], label=f"reduced order {order}")
141    ax2.set_title("Full IIR response versus finite-Hankel reduced models")
142    ax2.set_xlabel("frequency (cycles/sample)")
143    ax2.set_ylabel("magnitude (dB)")
144    ax2.set_xlim(0.0, 0.5)
145    ax2.grid(True, alpha=0.3)
146    ax2.legend()
147    fig2.tight_layout()
148    fig2_path = out_dir / "finite_hankel_reduced_responses.png"
149    fig2.savefig(fig2_path, dpi=160)
150    print(f"wrote {fig2_path}")
151
152
153if __name__ == "__main__":
154    main()