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,

\[\hat S_{\mathrm{per}}(\omega)=|\mathrm{DFT}\{w[n]x[n]\}|^2,\]

while an AR spectrum uses

\[\hat S_{\mathrm{AR}}(\omega) \propto \frac{1}{|\hat A(e^{j\omega})|^2}.\]

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

Run status

Return code: 0

Captured stdout

true tone frequencies: [0.18, 0.228]
AR model order: 18
periodogram peak estimates: [0.1799, 0.228]
Levinson AR peak estimates: [0.1794, 0.2283]
Burg AR peak estimates: [0.1797, 0.2288]

Figures

periodogram vs ar signal

periodogram_vs_ar_signal.png

periodogram vs ar spectrum

periodogram_vs_ar_spectrum.png

Generated data files

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()