Analysis
Three estimators, and a ratio that moves by itself
Simulate twenty minutes of a signal whose spectrum you know exactly: an aperiodic component falling as f−χ above 1 hertz, plus an alpha rhythm, a resonance at 10 hertz. Estimate alpha's share in 300 windows of 4 seconds three ways: as the demo does, as its Python program does, and with three Slepian tapers. Then make the background steeper with the alpha unchanged, and measure alpha above a fitted background.
import numpy as np
from scipy.signal import welch, lfilter
from scipy.signal.windows import dpss, hann
fs, rng = 500, np.random.default_rng(7)
f = np.linspace(0.5, 60, 20000) # a fine grid for the true spectrum
r, theta = 0.99, 2 * np.pi * 10 / fs # alpha: an AR(2) resonance at 10 Hz
ar = [1, -2 * r * np.cos(theta), r * r]
def simulate(chi, minutes=20):
"""Aperiodic noise falling as f^-chi above 1 Hz, plus alpha, and the true spectra."""
n = minutes * 60 * fs
shape = np.maximum(np.fft.rfftfreq(n, 1 / fs), 1) ** (-chi / 2)
spec = np.fft.rfft(rng.normal(size=n)) * shape
x = 100 * np.fft.irfft(spec, n) + lfilter([1], ar, rng.normal(0, 0.1, n))
z = np.exp(-2j * np.pi * f / fs)
alpha = 0.1 ** 2 * 2 / fs / np.abs(1 + ar[1] * z + ar[2] * z ** 2) ** 2
return x, 100 ** 2 * 2 / fs * np.maximum(f, 1) ** -chi, alpha
def band(freqs, psd, lo, hi, rule=np.trapezoid):
pick = (freqs >= lo) & (freqs < hi)
return rule(psd[pick], freqs[pick])
def share(freqs, psd, rule=np.trapezoid):
return band(freqs, psd, 8, 13, rule) / band(freqs, psd, 4, 30, rule)
def demo(x): # one periodic-Hann periodogram of the first 1,024 samples, bins summed
seg = x[:1024] - x[:1024].mean()
p = np.abs(np.fft.rfft(seg * hann(1024, sym=False))) ** 2
return share(np.fft.rfftfreq(1024, 1 / fs), p, rule=lambda y, x: y.sum())
def program(x): # Welch over 4 s in 2-s segments, as eyes_closed.py does
freqs, p = welch(x - x.mean(), fs=fs, nperseg=2 * fs)
return share(freqs, p)
def multitaper(x): # the same 1,024 samples, 3 Slepian tapers, about 2 Hz of resolution
seg = x[:1024] - x[:1024].mean()
p = (np.abs(np.fft.rfft(dpss(1024, 2, 3) * seg, axis=1)) ** 2).mean(axis=0)
return share(np.fft.rfftfreq(1024, 1 / fs), p)
for chi in (1.5, 2.0):
x, background, alpha = simulate(chi)
true = background + alpha
print(f"exponent {chi}: true share {share(f, true):.3f}, "
f"true alpha power {band(f, alpha, 6, 14):.1f} uV^2")
windows = x.reshape(-1, 4 * fs) # 300 windows of 4 s
for name, estimate in (("demo", demo), ("program", program), ("multitaper", multitaper)):
v = np.array([estimate(w) for w in windows])
bias = v.mean() - share(f, true)
print(f" {name:10s} share {v.mean():.3f} bias {bias:+.3f} sd {v.std():.3f}")
# Fit the background in log-log, away from alpha, and measure alpha above it.
freqs, p = welch(x, fs=fs, nperseg=2 * fs)
freqs, p = freqs[1:], p[1:]
fit = ((freqs >= 2) & (freqs < 6)) | ((freqs >= 15) & (freqs < 40))
slope, offset = np.polyfit(np.log10(freqs[fit]), np.log10(p[fit]), 1)
above = p - 10 ** (offset + slope * np.log10(freqs))
print(f" fitted exponent {-slope:.2f}, "
f"alpha above the fit {band(freqs, above, 6, 14):.1f} uV^2")
You should seeA true share of 0.464. The demo's estimate scatters most, with a standard deviation of 0.13 from one window to the next, the program's least, 0.08, and the multitaper's in between, 0.10, on the same two seconds as the demo. Every estimator is low by 0.01 to 0.03. With the exponent at 2.0, the true share rises to 0.613 though the alpha is the same, and alpha above the fitted background stays near its true 14.3 µV², at 13.4 and 13.7.
Then report: the program's estimator with segments of 1 and 4 seconds, and every estimator with the alpha at 8.5 hertz. Which bias grows, and why?