Paraunitary filter-bank behavior¶
Tutorial goal
Demonstrate streaming analysis and finite-record adjoint reconstruction for a paraunitary-style transform.
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¶
Paraunitary systems preserve energy and enable perfect reconstruction in multirate/filter-bank contexts. Matrix lattice structures are a natural way to parameterize such systems. Here the forward analysis transform is run by the causal online lattice runtime, while synthesis is a finite-record time-domain adjoint diagnostic.
Key idea and equations¶
A paraunitary filter bank is most naturally described by its polyphase or frequency-response matrix \(E(z)\). On the unit circle, the ideal condition is
The online analysis path realizes the causal convolution
and full-stream energy preservation is checked after appending a zero-input tail. The finite-record synthesis check applies the adjoint
The adjoint is time-domain but noncausal because it needs future analysis samples. This is the correct distinction between streaming analysis and block/transductive reconstruction.
Causality and data use¶
The forward analysis transform is causal and streaming. The synthesis/reconstruction check is a time-domain finite-block adjoint; it is noncausal because a stable causal all-pass generally has a noncausal stable inverse.
What this example verifies¶
This verifies the split between streaming analysis and finite-record synthesis. The forward paraunitary-style analysis is causal and norm-preserving after the tail is included; the adjoint reconstruction check is time-domain but noncausal/transductive.
How to read the result¶
The channel-energy, reconstruction-error, singular-value, and streaming-trace figures should show causal norm-preserving analysis plus near-perfect finite-adjoint reconstruction.
Run command¶
python examples/paraunitary_filter_bank_demo.py
Source code¶
1"""Paraunitary / perfect-reconstruction matrix-lattice demo.
2
3A matrix lattice all-pass filter is unitary at every frequency. This example
4uses the causal online runtime for the forward analysis transform and a
5finite-record time-domain adjoint for synthesis. The adjoint check is a block
6operation because it needs future transformed samples; the forward analysis path
7is genuinely streaming.
8"""
9
10from __future__ import annotations
11
12import os
13from pathlib import Path
14
15import numpy as np
16
17from lattice_dsp import (
18 MatrixLatticeAllPass,
19 contractive_matrix_from_raw,
20 matrix_lattice_finite_adjoint,
21 unitary_polar_factor,
22)
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 _make_filter(rng: np.random.Generator, channels: int, order: int) -> MatrixLatticeAllPass:
32 reflections = [
33 contractive_matrix_from_raw(
34 0.32
35 * (rng.normal(size=(channels, channels)) + 1j * rng.normal(size=(channels, channels)))
36 )
37 for _ in range(order)
38 ]
39 residue = unitary_polar_factor(
40 rng.normal(size=(channels, channels)) + 1j * rng.normal(size=(channels, channels))
41 )
42 return MatrixLatticeAllPass(reflections, residue=residue)
43
44
45def _analysis_streaming(x: np.ndarray, filt: MatrixLatticeAllPass, *, tail: int) -> np.ndarray:
46 runtime = filt.to_online_filter()
47 return runtime.process(x, drain=tail)
48
49
50def _synthesis_finite_adjoint(
51 y: np.ndarray, filt: MatrixLatticeAllPass, *, tail: int, output_length: int
52) -> np.ndarray:
53 h = filt.impulse_response(tail)
54 return matrix_lattice_finite_adjoint(y, h, output_length=output_length)
55
56
57def _save_figures(
58 x: np.ndarray, y: np.ndarray, x_hat: np.ndarray, filt: MatrixLatticeAllPass
59) -> None:
60 try:
61 import matplotlib.pyplot as plt
62 except ImportError: # pragma: no cover - optional plotting dependency
63 print("matplotlib is not installed; skipped figures")
64 return
65
66 out_dir = _artifact_dir()
67 n_samples = x.shape[0]
68 output_energy = np.sum(np.abs(y[:n_samples]) ** 2, axis=0)
69 input_energy = np.sum(np.abs(x) ** 2, axis=0)
70 reconstruction_error = np.abs(x_hat - x)
71 omega_probe = np.linspace(0.0, np.pi, 96)
72 singular_values = np.linalg.svd(filt.frequency_response(omega_probe), compute_uv=False)
73
74 fig, ax = plt.subplots(figsize=(7.2, 4.0))
75 idx = np.arange(x.shape[1])
76 width = 0.36
77 ax.bar(idx - width / 2, input_energy, width=width, label="analysis input")
78 ax.bar(idx + width / 2, output_energy, width=width, label="first output prefix")
79 ax.set_xlabel("channel")
80 ax.set_ylabel("energy")
81 ax.set_title("Streaming analysis moves energy across channels")
82 ax.legend(loc="best")
83 fig.tight_layout()
84 path = out_dir / "paraunitary_filter_bank_channel_energy.png"
85 fig.savefig(path, dpi=160)
86 plt.close(fig)
87 print(f"wrote {path}")
88
89 fig, ax = plt.subplots(figsize=(7.2, 4.0))
90 ax.semilogy(np.maximum(np.mean(reconstruction_error, axis=1), 1e-18))
91 ax.set_xlabel("sample")
92 ax.set_ylabel("mean absolute reconstruction error")
93 ax.set_title("Finite-block time-domain adjoint reconstructs the input")
94 fig.tight_layout()
95 path = out_dir / "paraunitary_filter_bank_reconstruction_error.png"
96 fig.savefig(path, dpi=160)
97 plt.close(fig)
98 print(f"wrote {path}")
99
100 fig, ax = plt.subplots(figsize=(7.2, 4.0))
101 for sv in range(singular_values.shape[1]):
102 ax.plot(omega_probe, singular_values[:, sv], label=f"σ{sv + 1}")
103 ax.set_xlabel("rad/sample")
104 ax.set_ylabel("singular value")
105 ax.set_title("Analysis response is unitary at each frequency")
106 ax.legend(loc="best")
107 fig.tight_layout()
108 path = out_dir / "paraunitary_filter_bank_singular_values.png"
109 fig.savefig(path, dpi=160)
110 plt.close(fig)
111 print(f"wrote {path}")
112
113 fig, ax = plt.subplots(figsize=(7.2, 4.0))
114 span = min(400, n_samples)
115 ax.plot(np.real(x[:span, 0]), label="input ch0 real")
116 ax.plot(np.real(y[:span, 0]), label="streaming analysis ch0 real", alpha=0.8)
117 ax.set_xlabel("sample")
118 ax.set_ylabel("amplitude")
119 ax.set_title("Causal paraunitary analysis trace")
120 ax.legend(loc="best")
121 fig.tight_layout()
122 path = out_dir / "paraunitary_filter_bank_streaming_trace.png"
123 fig.savefig(path, dpi=160)
124 plt.close(fig)
125 print(f"wrote {path}")
126
127
128rng = np.random.default_rng(2025)
129channels = 4
130order = 3
131n_samples = 4096
132tail = 1024
133
134filt = _make_filter(rng, channels, order)
135x = rng.normal(size=(n_samples, channels)) + 1j * rng.normal(size=(n_samples, channels))
136y = _analysis_streaming(x, filt, tail=tail)
137x_hat = _synthesis_finite_adjoint(y, filt, tail=tail, output_length=n_samples)
138
139energy_in = float(np.vdot(x, x).real)
140energy_out = float(np.vdot(y, y).real)
141relative_reconstruction_error = float(np.linalg.norm(x_hat - x) / np.linalg.norm(x))
142relative_energy_error = abs(energy_out - energy_in) / energy_in
143
144print("channels:", channels)
145print("order:", order)
146print("samples:", n_samples)
147print("tail samples:", tail)
148print("max reflection singular value:", round(filt.max_reflection_singular_value(), 6))
149print("unitarity error:", f"{filt.unitarity_error(np.linspace(0.0, np.pi, 128)):.3e}")
150print("relative reconstruction error:", f"{relative_reconstruction_error:.3e}")
151print("relative energy error with streamed tail:", f"{relative_energy_error:.3e}")
152print("causal analysis: output at n uses current input and stored lattice states")
153print(
154 "finite adjoint: synthesis is time-domain but noncausal because it uses the full transformed record"
155)
156print("takeaway: matrix lattice all-pass stages act as streaming paraunitary analysis transforms")
157
158_save_figures(x, y, x_hat, filt)