Nehari and AAK intuition from a finite SISO Hankel matrix

Tutorial goal

Show the finite-Hankel singular-value picture behind Nehari/AAK model-reduction theory within a finite-section scope.

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

The finite-Hankel reducer already available in the package is a practical baseline. This tutorial explains the finite-section Nehari/AAK perspective. It calls finite_nehari_approximate_tail to build a finite Hankel matrix from an anticausal tail and study its singular values.

The goal is to make the intuition precise. A low-rank Hankel structure means that much of the input-output memory is carried by a few modes. In the finite matrix case, the next singular value controls the best unconstrained rank-r spectral-norm error. AAK/Nehari is the rational/Hankel-structured analogue of that statement.

Key idea and equations

Given anticausal coefficients gamma_1, gamma_2, ..., form the finite Hankel matrix

\[\begin{split}\Gamma = \begin{bmatrix} \gamma_1 & \gamma_2 & \gamma_3 & \cdots \\ \gamma_2 & \gamma_3 & \gamma_4 & \cdots \\ \gamma_3 & \gamma_4 & \gamma_5 & \cdots \\ \vdots & \vdots & \vdots & \ddots \end{bmatrix}.\end{split}\]

For a finite matrix, the Eckart–Young theorem gives

\[\min_{\operatorname{rank}(X)\le r} \|\Gamma-X\|_2 = \sigma_{r+1}(\Gamma).\]

The tutorial also Hankelizes the truncated SVD approximation by anti-diagonal averaging to show the difference between unconstrained low-rank approximation and a Hankel-structured approximation.

How to read the result

Check that the finite SVD error matches the next singular value, then compare it with the Hankelized approximation error. This is a finite-section validation bridge and not an exact Nehari/AAK solver.

Run command

python examples/nehari_aak_siso_toy.py

Source code

  1"""Tutorial: finite Nehari/AAK intuition from a SISO Hankel matrix.
  2
  3This is not a production Nehari or AAK solver.  It is a deliberately small
  4teaching example that shows the object those theories act on: a Hankel
  5operator built from an anticausal or future impulse-response tail.
  6
  7For a finite Hankel matrix, the best unconstrained rank-r approximation error
  8in spectral norm is the next singular value.  AAK/Nehari theory is the deeper
  9infinite-dimensional rational/Hankel-structured version of this story.  This
 10example makes that bridge visible before implementing exact Nehari/AAK routines.
 11"""
 12
 13from __future__ import annotations
 14
 15import csv
 16import os
 17from pathlib import Path
 18
 19import numpy as np
 20
 21import lattice_dsp as ld
 22
 23
 24def artifact_dir() -> Path:
 25    path = Path(os.environ.get("LATTICE_DSP_ARTIFACT_DIR", "reports/example-artifacts"))
 26    path.mkdir(parents=True, exist_ok=True)
 27    return path
 28
 29
 30def anticausal_tail(n_terms: int) -> np.ndarray:
 31    """Return a synthetic anticausal tail gamma_1, gamma_2, ... .
 32
 33    A sum of damped exponentials gives a low effective Hankel rank with a few
 34    weaker modes.  This keeps the tutorial readable: the singular values decay,
 35    so users can see why a low-order rational approximation may be plausible.
 36    """
 37
 38    n = np.arange(n_terms, dtype=float)
 39    return 1.00 * 0.92**n + 0.30 * 0.63**n - 0.16 * (-0.42) ** n + 0.045 * 0.20**n
 40
 41
 42def hankel_from_tail(gamma: np.ndarray, rows: int, cols: int) -> np.ndarray:
 43    if gamma.size < rows + cols - 1:
 44        raise ValueError("gamma is too short for the requested Hankel matrix")
 45    i = np.arange(rows)[:, None]
 46    j = np.arange(cols)[None, :]
 47    return gamma[i + j]
 48
 49
 50def hankelize(matrix: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
 51    """Project a matrix onto the finite Hankel subspace by anti-diagonal averaging."""
 52
 53    rows, cols = matrix.shape
 54    values = np.zeros(rows + cols - 1, dtype=float)
 55    counts = np.zeros(rows + cols - 1, dtype=float)
 56    for i in range(rows):
 57        for j in range(cols):
 58            values[i + j] += matrix[i, j]
 59            counts[i + j] += 1.0
 60    values /= counts
 61    return values, hankel_from_tail(values, rows, cols)
 62
 63
 64def write_summary(path: Path, rows: list[dict[str, float | int]]) -> None:
 65    with path.open("w", newline="", encoding="utf-8") as f:
 66        writer = csv.DictWriter(f, fieldnames=list(rows[0]))
 67        writer.writeheader()
 68        writer.writerows(rows)
 69
 70
 71def main() -> None:
 72    out_dir = artifact_dir()
 73
 74    rows = cols = 40
 75    gamma = anticausal_tail(rows + cols - 1)
 76    ranks = [1, 2, 3, 4, 6]
 77    summary: list[dict[str, float | int]] = []
 78    tail_approximations: dict[int, np.ndarray] = {}
 79    singular_values: np.ndarray | None = None
 80
 81    for rank in ranks:
 82        result = ld.finite_nehari_approximate_tail(
 83            gamma.tolist(),
 84            rank=rank,
 85            rows=rows,
 86            cols=cols,
 87        )
 88        if singular_values is None:
 89            singular_values = np.asarray(result["hankel_singular_values"], dtype=float)
 90        tail_approximations[rank] = np.asarray(result["approximated_tail"], dtype=float)
 91
 92        summary.append(
 93            {
 94                "rank": rank,
 95                "sigma_next": float(result["sigma_next"]),
 96                "unconstrained_svd_error": float(result["unconstrained_hankel_error"]),
 97                "hankelized_error": float(result["hankelized_hankel_error"]),
 98                "tail_relative_error": float(result["relative_tail_error"]),
 99            }
100        )
101
102    assert singular_values is not None
103
104    csv_path = out_dir / "nehari_aak_siso_toy_summary.csv"
105    write_summary(csv_path, summary)
106
107    print("finite Hankel matrix:", f"{rows} x {cols}")
108    print("leading singular values:", [round(float(v), 6) for v in singular_values[:8]])
109    print("finite Eckart-Young check: ||H-H_r||_2 should equal sigma_{r+1}")
110    for row in summary:
111        print(
112            "rank={rank}: sigma_next={sigma_next:.6e}, svd_error={unconstrained_svd_error:.6e}, "
113            "hankelized_error={hankelized_error:.6e}, tail_rel_error={tail_relative_error:.3e}".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.5))
126    idx = np.arange(1, 21)
127    ax.semilogy(idx, singular_values[:20], marker="o")
128    ax.set_title("Finite Hankel singular values for the Nehari/AAK toy problem")
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 / "nehari_aak_toy_singular_values.png"
134    fig.savefig(fig_path, dpi=160)
135    print(f"wrote {fig_path}")
136
137    fig2, ax2 = plt.subplots(figsize=(8.5, 4.5))
138    rank_axis = [row["rank"] for row in summary]
139    ax2.semilogy(rank_axis, [row["sigma_next"] for row in summary], marker="o", label="sigma_{r+1}")
140    ax2.semilogy(
141        rank_axis,
142        [row["unconstrained_svd_error"] for row in summary],
143        marker="s",
144        label="||H-H_r||_2",
145    )
146    ax2.semilogy(
147        rank_axis,
148        [row["hankelized_error"] for row in summary],
149        marker="^",
150        label="Hankelized error",
151    )
152    ax2.set_title("Finite low-rank error versus Hankelized approximation error")
153    ax2.set_xlabel("rank r")
154    ax2.set_ylabel("spectral-norm error")
155    ax2.grid(True, alpha=0.3)
156    ax2.legend()
157    fig2.tight_layout()
158    fig2_path = out_dir / "nehari_aak_toy_error_curve.png"
159    fig2.savefig(fig2_path, dpi=160)
160    print(f"wrote {fig2_path}")
161
162    fig3, ax3 = plt.subplots(figsize=(8.8, 4.8))
163    n = np.arange(1, gamma.size + 1)
164    ax3.plot(n, gamma, linewidth=2.0, label="target tail")
165    for rank in [1, 2, 4, 6]:
166        ax3.plot(n, tail_approximations[rank], "--", linewidth=1.2, label=f"rank {rank} Hankelized")
167    ax3.set_title("Anticausal tail and finite Hankelized approximations")
168    ax3.set_xlabel("tail coefficient index")
169    ax3.set_ylabel("coefficient value")
170    ax3.grid(True, alpha=0.3)
171    ax3.legend()
172    fig3.tight_layout()
173    fig3_path = out_dir / "nehari_aak_toy_tail_approximations.png"
174    fig3.savefig(fig3_path, dpi=160)
175    print(f"wrote {fig3_path}")
176
177
178if __name__ == "__main__":
179    main()