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
For a finite matrix, the Eckart–Young theorem gives
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()