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

\[E(e^{j\omega})^H E(e^{j\omega}) = I.\]

The online analysis path realizes the causal convolution

\[y[n] = \sum_{k\ge 0} E_k x[n-k],\]

and full-stream energy preservation is checked after appending a zero-input tail. The finite-record synthesis check applies the adjoint

\[x_{adj}[n] = \sum_{k\ge 0} E_k^H y[n+k].\]

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

Run status

Return code: 0

Captured stdout

channels: 4
order: 3
samples: 4096
tail samples: 1024
max reflection singular value: 0.937736
unitarity error: 2.401e-14
relative reconstruction error: 4.381e-15
relative energy error with streamed tail: 2.114e-15
causal analysis: output at n uses current input and stored lattice states
finite adjoint: synthesis is time-domain but noncausal because it uses the full transformed record
takeaway: matrix lattice all-pass stages act as streaming paraunitary analysis transforms

Figures

paraunitary filter bank channel energy

paraunitary_filter_bank_channel_energy.png

paraunitary filter bank reconstruction error

paraunitary_filter_bank_reconstruction_error.png

paraunitary filter bank singular values

paraunitary_filter_bank_singular_values.png

paraunitary filter bank streaming trace

paraunitary_filter_bank_streaming_trace.png

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)