Periodogram versus AR spectral estimates¶
Tutorial goal
Compare a windowed periodogram with Levinson and Burg AR spectra on a noisy two-tone signal.
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 introduces the visual diagnostics that make the AR tools easier to interpret. The periodogram is a direct Fourier estimate; AR spectra are model-based and can sharpen peaks, but they also depend on the chosen model order.
Key idea and equations¶
The periodogram estimates power directly from the DFT,
while an AR spectrum uses
How to read the result¶
The vertical dotted lines mark the true tones. Compare how broad the periodogram peaks are with the sharper AR curves, and check the CSV for numeric values.
Run command¶
python examples/periodogram_vs_ar_spectrum.py
Source code¶
1"""Tutorial: compare a periodogram with AR spectral estimates.
2
3The signal contains two nearby sinusoidal components plus white noise. A
4periodogram is a direct Fourier-domain power estimate, while AR spectra fit an
5all-pole model and then evaluate its frequency response. The example writes a
6comparison plot and a CSV file to the configured artifact directory.
7"""
8
9from __future__ import annotations
10
11import csv
12import os
13from pathlib import Path
14
15import numpy as np
16
17from lattice_dsp import autocorrelation, burg_denominator, levinson_durbin_denominator
18
19
20def artifact_dir() -> Path:
21 path = Path(os.environ.get("LATTICE_DSP_ARTIFACT_DIR", "reports/example-artifacts"))
22 path.mkdir(parents=True, exist_ok=True)
23 return path
24
25
26def db_normalized(power: np.ndarray) -> np.ndarray:
27 power = np.maximum(np.asarray(power, dtype=float), 1e-18)
28 return 10.0 * np.log10(power / np.max(power))
29
30
31def periodogram(x: np.ndarray, n_fft: int) -> tuple[np.ndarray, np.ndarray]:
32 window = np.hanning(x.size)
33 spectrum = np.fft.rfft(window * x, n=n_fft)
34 scale = np.sum(window**2)
35 power = np.abs(spectrum) ** 2 / max(scale, 1e-12)
36 freq = np.fft.rfftfreq(n_fft, d=1.0)
37 return freq, power
38
39
40def ar_spectrum(denominator: np.ndarray, n_fft: int) -> tuple[np.ndarray, np.ndarray]:
41 freq = np.linspace(0.0, 0.5, n_fft // 2 + 1)
42 z = np.exp(-2j * np.pi * freq)
43 a = np.zeros_like(z, dtype=complex)
44 for i, coef in enumerate(denominator):
45 a += float(coef) * z**i
46 power = 1.0 / np.maximum(np.abs(a) ** 2, 1e-18)
47 return freq, power
48
49
50def top_peaks(freq: np.ndarray, db: np.ndarray, count: int = 3, guard_bins: int = 8) -> list[float]:
51 candidates = db.copy()
52 peaks: list[float] = []
53 for _ in range(count):
54 idx = int(np.argmax(candidates))
55 peaks.append(float(freq[idx]))
56 lo = max(0, idx - guard_bins)
57 hi = min(candidates.size, idx + guard_bins + 1)
58 candidates[lo:hi] = -np.inf
59 return peaks
60
61
62def write_csv(path: Path, freq: np.ndarray, columns: dict[str, np.ndarray]) -> None:
63 with path.open("w", newline="", encoding="utf-8") as f:
64 writer = csv.writer(f)
65 writer.writerow(["frequency_cycles_per_sample", *columns])
66 for i, value in enumerate(freq):
67 writer.writerow([value, *(columns[name][i] for name in columns)])
68
69
70def main() -> None:
71 rng = np.random.default_rng(2026)
72 samples = 512
73 n = np.arange(samples)
74 tones = [0.180, 0.228]
75 x = (
76 1.0 * np.sin(2.0 * np.pi * tones[0] * n)
77 + 0.75 * np.sin(2.0 * np.pi * tones[1] * n + 0.4)
78 + 0.45 * rng.normal(size=samples)
79 )
80 x -= np.mean(x)
81
82 n_fft = 4096
83 order = 18
84 freq, p_periodogram = periodogram(x, n_fft)
85
86 r = autocorrelation(x, order)
87 den_levinson = np.asarray(levinson_durbin_denominator(r, order), dtype=float)
88 den_burg = np.asarray(burg_denominator(x, order), dtype=float)
89
90 _, p_levinson = ar_spectrum(den_levinson, n_fft)
91 _, p_burg = ar_spectrum(den_burg, n_fft)
92
93 y_periodogram = db_normalized(p_periodogram)
94 y_levinson = db_normalized(p_levinson)
95 y_burg = db_normalized(p_burg)
96
97 out_dir = artifact_dir()
98 csv_path = out_dir / "periodogram_vs_ar_spectrum.csv"
99 write_csv(
100 csv_path,
101 freq,
102 {
103 "periodogram_db": y_periodogram,
104 "levinson_ar_db": y_levinson,
105 "burg_ar_db": y_burg,
106 },
107 )
108
109 print("true tone frequencies:", tones)
110 print("AR model order:", order)
111 print("periodogram peak estimates:", [round(v, 4) for v in top_peaks(freq, y_periodogram, 2)])
112 print("Levinson AR peak estimates:", [round(v, 4) for v in top_peaks(freq, y_levinson, 2)])
113 print("Burg AR peak estimates:", [round(v, 4) for v in top_peaks(freq, y_burg, 2)])
114 print(f"wrote {csv_path}")
115
116 try:
117 import matplotlib.pyplot as plt
118 except Exception:
119 print("matplotlib is not installed; skipped figures")
120 return
121
122 fig, ax = plt.subplots(figsize=(9, 4.8))
123 ax.plot(freq, y_periodogram, label="periodogram")
124 ax.plot(freq, y_levinson, label="Levinson AR")
125 ax.plot(freq, y_burg, linestyle="--", label="Burg AR")
126 for tone in tones:
127 ax.axvline(tone, linestyle=":", linewidth=1.0)
128 ax.set_xlim(0.12, 0.28)
129 ax.set_ylim(-55, 3)
130 ax.set_title("Periodogram vs. AR spectral estimates")
131 ax.set_xlabel("frequency (cycles/sample)")
132 ax.set_ylabel("normalized power (dB)")
133 ax.legend()
134 fig.tight_layout()
135 fig_path = out_dir / "periodogram_vs_ar_spectrum.png"
136 fig.savefig(fig_path, dpi=160)
137 print(f"wrote {fig_path}")
138
139 fig2, ax2 = plt.subplots(figsize=(9, 3.2))
140 ax2.plot(n[:160], x[:160])
141 ax2.set_title("Noisy two-tone input excerpt")
142 ax2.set_xlabel("sample")
143 ax2.set_ylabel("amplitude")
144 fig2.tight_layout()
145 fig2_path = out_dir / "periodogram_vs_ar_signal.png"
146 fig2.savefig(fig2_path, dpi=160)
147 print(f"wrote {fig2_path}")
148
149
150if __name__ == "__main__":
151 main()