Power spectral density: periodogram versus Welch, eyes open versus eyes closed, group averaging; windows, leakage and zero-padding

nb-1-3-psd Level 1 · Signal Fundamentals ~3 min Used in L1.3 · Power spectral density

Downloads from ds-eegbci when you run it.

Download the notebook (.ipynb) Outputs below are the ones stored when it was executed — you do not need to run anything to read it.

nb-1-3-psd · Power spectral density (L1.3) and windowing, leakage, zero-padding (L1.4)

Lessons L1.3 (Part 1) and L1.4 (Part 2) · Level 1 · Status draft — for expert review; uncertain points carry TODO(confirm).

What you will do

Part 1 — L1.3. Why the periodogram is noisy and stays noisy; Welch's method and its three knobs (segment length, overlap, window) as a resolution–variance trade-off; PSD units (µV²/Hz), band power (µV²), relative power and log scales; eyes-open versus eyes-closed at O1 and the alpha peak; ten subjects averaged two ways (linear versus log).

Part 2 — L1.4. Leakage from a tone that sits between bins; window shapes and their main lobe / side lobes; zero-padding as interpolation, not resolution; the same effects on real EEG, with three labelled spectra (rectangular, Hann, Hamming) of one segment.

The final cell prints the Welch parameter pair that gives 0.5 Hz resolution with at least 20 segments in 60 s at fs = 160 Hz, and the group-mean alpha peak frequency.

Data ds-eegbci — EEG Motor Movement/Imagery Dataset (EEGMMIDB), Schalk et al. (2004), PhysioNet v1.0.0, DOI 10.13026/C28G6P, ODC-By 1.0. From the catalog: 64 channels (10-10), 160 Hz, no hardware filters, 60 Hz mains; R01 = eyes-open and R02 = eyes-closed baselines of ~1 min. Subjects S001–S010 (none of them among the subjects the catalog documents as defective — S088, S089, S092, S100, and S038/S104 — which helpers.load_spine refuses), channel O1: 20 one-minute EDF files of ~1.2 MB each.

The optional ds-bonn section of the spec is omitted (spec §13 item 19 has not permitted notebook downloads of that dataset); ds-arithmetic is read in nb-1-1 and nb-1-6.

In [1]:
# Setup: dependencies, the shared helpers, non-interactive plotting.
import importlib.util
import subprocess
import sys
import warnings
from pathlib import Path

# 1. Dependencies are pinned in notebooks/requirements.txt.  Nothing is installed
#    when the pinned stack is already present (local runs, CI); a fresh Colab or
#    Binder kernel installs it once.  On Colab, run from a clone of the repository
#    so that notebooks/_shared/ is available (repository URL: TODO(confirm), spec
#    section 13 item 3).
_needed = ("mne", "scipy", "matplotlib", "pooch")
_missing = [p for p in _needed if importlib.util.find_spec(p) is None]
if _missing:
    _req = next((d / "requirements.txt" for d in (Path.cwd(), *Path.cwd().parents)
                 if (d / "requirements.txt").exists()), None)
    _cmd = [sys.executable, "-m", "pip", "install", "-q"]
    _cmd += ["-r", str(_req)] if _req else ["mne==1.10.2", "pooch>=1.8"]
    subprocess.check_call(_cmd)

# 2. Shared helpers (notebooks/_shared/helpers.py and helpers_l1.py), located
#    relative to the working directory -- notebooks/<level>/ or notebooks/ --
#    never through an absolute path.
_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
                if (d / "_shared" / "helpers_l1.py").exists()), None)
if _shared is None:
    raise FileNotFoundError("start the kernel in notebooks/L1/ (or notebooks/) so that _shared/helpers_l1.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l1

# 3. Plotting: Jupyter's default inline backend renders static PNGs through Agg
#    (no windows, nothing blocks); outside Jupyter the helpers select Agg.  Every
#    MNE figure is requested with show=False, and plt.show() renders each cell's
#    figures in place.
import matplotlib.pyplot as plt
import numpy as np
import mne
import pooch

mne.viz.set_browser_backend("matplotlib", verbose=False)
mne.set_log_level("WARNING")
pooch.get_logger().setLevel("WARNING")   # no download chatter (it would print local paths)
plt.rcParams["figure.dpi"] = 72
print(f"MNE {mne.__version__}; helpers and helpers_l1 imported from notebooks/_shared; downloads go to the course "
      "data directory (EEG_COURSE_DOWNLOADS or EEG_COURSE_DATA if set, else MNE's data directory under eeg-course/)")
MNE 1.10.2; helpers and helpers_l1 imported from notebooks/_shared; downloads go to the course data directory (EEG_COURSE_DOWNLOADS or EEG_COURSE_DATA if set, else MNE's data directory under eeg-course/)

Part 1 — Power spectral density (L1.3)

1. The periodogram is noisy, and a longer record does not help

The periodogram is |FFT|² / (N · fs) of one windowed record (units µV²/Hz). Each of its bins is an estimate with a standard deviation as large as its mean — a longer record adds bins (finer resolution) but does not average anything, so the scatter stays. Welch's method cuts the record into overlapping segments, windows each, averages their periodograms and pays with resolution (1 / segment length). The scatter is measured below as the standard deviation of the dB values in 20–30 Hz after removing a straight line in log-frequency.

In [2]:
from scipy import signal

DATASET, SUBJECT, CH = "ds-eegbci", "S001", "O1"
raw_eo = helpers.load_spine(DATASET, SUBJECT, "R01")      # eyes open
raw_ec = helpers.load_spine(DATASET, SUBJECT, "R02")      # eyes closed
SF = raw_ec.info["sfreq"]
x_ec = raw_ec.get_data(picks=CH)[0] * 1e6                 # uV
x_eo = raw_eo.get_data(picks=CH)[0] * 1e6
DUR = raw_ec.times[-1] + 1 / SF


def scatter_db(f, p, band=(20.0, 30.0)):
    """SD (dB) of the PSD in `band` around a straight line fitted in log-frequency: a noisiness measure."""
    m = (f >= band[0]) & (f <= band[1])
    db = 10 * np.log10(p[m])
    fit = np.polyval(np.polyfit(np.log10(f[m]), db, 1), np.log10(f[m]))
    return float(np.std(db - fit))


estimates = {}
for label, seg in ((f"periodogram, first 10 s", x_ec[: int(10 * SF)]), (f"periodogram, all {DUR:.0f} s", x_ec)):
    f, p = signal.periodogram(seg, fs=SF, window="hann", detrend="constant", scaling="density")
    estimates[label] = (f[1:], p[1:])
f, p = helpers_l1.welch_psd(x_ec, SF, seg_s=2.0)
estimates[f"Welch, 2-s Hann segments, 50 % overlap, all {DUR:.0f} s"] = (f, p)
for label, (f, p) in estimates.items():
    print(f"{label:52s} bins {f[1] - f[0]:.4f} Hz apart; scatter in 20-30 Hz: {scatter_db(f, p):.1f} dB")
ax = helpers_l1.plot_spectra(estimates, xlim=(0, 80), title=f"{SUBJECT} R02 (eyes closed) {CH}: periodogram vs Welch", lw=0.7)
plt.show()   # render the static figure(s) of this cell inline
periodogram, first 10 s                              bins 0.1000 Hz apart; scatter in 20-30 Hz: 5.5 dB
periodogram, all 61 s                                bins 0.0164 Hz apart; scatter in 20-30 Hz: 5.1 dB
Welch, 2-s Hann segments, 50 % overlap, all 61 s     bins 0.5000 Hz apart; scatter in 20-30 Hz: 1.0 dB
Figure 1 of notebook nb-1-3-psd, an output plot. The text around it states what it shows and the units of every axis.

2. Segment length, overlap and window: the trade-off, live

Longer segments buy resolution (Δf = 1 / seg_s) with fewer segments to average; overlap adds segments, but overlapping segments share data, so the variance reduction saturates (50 % is the common compromise for a Hann window). The number of segments is (N − nperseg) // (nperseg − noverlap) + 1, exactly what SciPy averages.

In [3]:
print(f"{'seg_s':>6s} {'nperseg':>8s} {'noverlap':>9s} {'df (Hz)':>8s} {'segments':>9s} {'scatter 20-30 Hz':>17s}")
trade = {}
for seg_s in (0.5, 1.0, 2.0, 4.0, 8.0):
    nperseg, noverlap = int(seg_s * SF), int(seg_s * SF) // 2
    f, p = helpers_l1.welch_psd(x_ec, SF, seg_s=seg_s, overlap=0.5)
    trade[f"{seg_s:g}-s segments ({helpers_l1.n_welch_segments(len(x_ec), nperseg, noverlap)} of them)"] = (f, p)
    print(f"{seg_s:6.1f} {nperseg:8d} {noverlap:9d} {1 / seg_s:8.3f} {helpers_l1.n_welch_segments(len(x_ec), nperseg, noverlap):9d} {scatter_db(f, p):17.2f}")
print()
print("2-s segments, three overlaps:")
for ov in (0.0, 0.5, 0.75):
    nperseg = int(2 * SF)
    f, p = helpers_l1.welch_psd(x_ec, SF, seg_s=2.0, overlap=ov)
    print(f"  overlap {100 * ov:3.0f} % (noverlap {int(ov * nperseg):3d}): {helpers_l1.n_welch_segments(len(x_ec), nperseg, int(ov * nperseg)):3d} segments; scatter 20-30 Hz {scatter_db(f, p):.2f} dB")
ax = helpers_l1.plot_spectra(trade, xlim=(0, 40), title=f"{SUBJECT} R02 {CH}: Welch with five segment lengths (Hann, 50 % overlap)", lw=0.8)
plt.show()   # render the static figure(s) of this cell inline
 seg_s  nperseg  noverlap  df (Hz)  segments  scatter 20-30 Hz
   0.5       80        40    2.000       243              0.59
   1.0      160        80    1.000       121              0.91
   2.0      320       160    0.500        60              1.02
   4.0      640       320    0.250        29              1.17
   8.0     1280       640    0.125        14              1.44

2-s segments, three overlaps:
  overlap   0 % (noverlap   0):  30 segments; scatter 20-30 Hz 1.19 dB
  overlap  50 % (noverlap 160):  60 segments; scatter 20-30 Hz 1.02 dB
  overlap  75 % (noverlap 240): 119 segments; scatter 20-30 Hz 0.98 dB
Figure 2 of notebook nb-1-3-psd, an output plot. The text around it states what it shows and the units of every axis.

3. Units: µV²/Hz, band power in µV², relative power, log axes

A PSD is power per hertz: integrate it over a band (sum of bins × Δf) to get the band's power in µV². SciPy's scaling='spectrum' returns something else: the power a sinusoid would show at its own bin (µV²) — right for reading the power of a line, wrong for summing over a band with a tapered window, because each windowed bin spans more than one bin's width (the window's equivalent noise bandwidth, computed below: 1.5 bins for Hann), so the sum double-counts. Relative power divides by the power in a reference range (here 1–40 Hz) and removes overall gain differences between subjects, at the price of coupling every band to every other band. Log axes exist because the PSD spans four orders of magnitude between 1 and 80 Hz.

In [4]:
f, p_density = helpers_l1.welch_psd(x_ec, SF, seg_s=2.0)
_, p_spectrum = signal.welch(x_ec, fs=SF, window="hann", nperseg=int(2 * SF), noverlap=int(SF), scaling="spectrum")
df = f[1] - f[0]
alpha = (f >= 8) & (f <= 12)
broad = (f >= 1) & (f <= 40)
bp_density = p_density[alpha].sum() * df
bp_spectrum = p_spectrum[alpha].sum()
w_hann = signal.get_window("hann", int(2 * SF))
enbw_bins = len(w_hann) * (w_hann ** 2).sum() / w_hann.sum() ** 2          # equivalent noise bandwidth of the window, in bins
print(f"8-12 Hz band power at {CH}, eyes closed: {bp_density:.1f} uV^2 = sum of the density over the band x df ({df:g} Hz)")
print(f"summing scaling='spectrum' over the same band gives {bp_spectrum:.1f} uV^2, {bp_spectrum / bp_density:.2f}x more: a Hann window spreads each bin over "
      f"{enbw_bins:.2f} bins (its equivalent noise bandwidth), so the sum double-counts. Integrate the density; read peaks off the spectrum.")
print(f"relative alpha power (8-12 Hz / 1-40 Hz): {100 * bp_density / (p_density[broad].sum() * df):.1f} %")
print(f"mean PSD in 8-12 Hz: {p_density[alpha].mean():.1f} uV^2/Hz; RMS of the 8-12 Hz band: {np.sqrt(bp_density):.1f} uV")
fig, axes = plt.subplots(1, 3, figsize=(14, 3.6))
for ax, (db, log_y, ttl) in zip(axes, ((False, False, "linear axes"), (False, True, "log power axis"), (True, False, "decibels"))):
    helpers_l1.plot_spectra({f"{CH} eyes closed": (f, p_density)}, ax=ax, db=db, log_y=log_y, xlim=(0, 80), title=ttl, legend=False)
fig.suptitle(f"{SUBJECT} R02 {CH}: the same Welch PSD on three scales", y=1.03)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
8-12 Hz band power at O1, eyes closed: 3727.4 uV^2 = sum of the density over the band x df (0.5 Hz)
summing scaling='spectrum' over the same band gives 5591.0 uV^2, 1.50x more: a Hann window spreads each bin over 1.50 bins (its equivalent noise bandwidth), so the sum double-counts. Integrate the density; read peaks off the spectrum.
relative alpha power (8-12 Hz / 1-40 Hz): 66.1 %
mean PSD in 8-12 Hz: 828.3 uV^2/Hz; RMS of the 8-12 Hz band: 61.1 uV
Figure 3 of notebook nb-1-3-psd, an output plot. The text around it states what it shows and the units of every axis.

4. Eyes open versus eyes closed: the alpha peak

The same estimator on both runs of S001. The eyes-closed spectrum carries a peak in the alpha range; its frequency is read here as the largest PSD value between 7 and 13 Hz (a model-free definition; nb-1-7 uses a parametric one).

In [5]:
f, p_eo = helpers_l1.welch_psd(x_eo, SF, seg_s=2.0)
_, p_ec = helpers_l1.welch_psd(x_ec, SF, seg_s=2.0)
peak_ec = helpers_l1.alpha_peak_frequency(f, p_ec)
peak_eo = helpers_l1.alpha_peak_frequency(f, p_eo)
ratio = (p_ec[alpha].sum() / p_eo[alpha].sum())
print(f"{SUBJECT} {CH}: alpha peak (largest PSD value in 7-13 Hz) at {peak_ec:g} Hz eyes closed, {peak_eo:g} Hz eyes open; "
      f"8-12 Hz power ratio closed/open = {ratio:.1f} ({10 * np.log10(ratio):.1f} dB)")
fig, axes = plt.subplots(1, 2, figsize=(13, 3.8))
helpers_l1.plot_spectra({"eyes open (R01)": (f, p_eo), "eyes closed (R02)": (f, p_ec)}, ax=axes[0], xlim=(0, 80), lines=[peak_ec],
                        title=f"{SUBJECT} {CH}: Welch 2-s Hann, 50 % overlap")
helpers_l1.plot_spectra({"eyes open (R01)": (f, p_eo), "eyes closed (R02)": (f, p_ec)}, ax=axes[1], db=False, xlim=(4, 16), lines=[peak_ec],
                        title="Zoom on the alpha range, linear power")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
S001 O1: alpha peak (largest PSD value in 7-13 Hz) at 10 Hz eyes closed, 12.5 Hz eyes open; 8-12 Hz power ratio closed/open = 15.2 (11.8 dB)
Figure 4 of notebook nb-1-3-psd, an output plot. The text around it states what it shows and the units of every axis.

5. Ten subjects: how to average PSDs

Per-subject PSDs are skewed — a few subjects with a large alpha rhythm dominate a linear average, and at every frequency the mean lies above most of the individual curves. Averaging log power (the geometric mean) treats a doubling and a halving symmetrically and keeps the group curve representative of a typical subject. The group's alpha peak can be stated two ways too: the mean (or median) of the individual peak frequencies, or the peak of the averaged spectrum — they differ when individual peaks are spread out.

In [6]:
SUBJECTS = helpers_l1.eegbci_subjects(1, 10)          # S001-S010; the catalog's exclusions are not in this range
group = {"eo": [], "ec": []}
peaks = []
for s in SUBJECTS:
    r_eo = helpers.load_spine(DATASET, s, "R01")
    r_ec = helpers.load_spine(DATASET, s, "R02")
    assert r_eo.info["sfreq"] == SF and r_ec.info["sfreq"] == SF
    fg, p1 = helpers_l1.welch_psd(r_eo.get_data(picks=CH)[0] * 1e6, SF, seg_s=2.0)
    _, p2 = helpers_l1.welch_psd(r_ec.get_data(picks=CH)[0] * 1e6, SF, seg_s=2.0)
    group["eo"].append(p1)
    group["ec"].append(p2)
    peaks.append(helpers_l1.alpha_peak_frequency(fg, p2))
group = {k: np.array(v) for k, v in group.items()}
peaks = np.array(peaks)
alpha_g = (fg >= 8) & (fg <= 12)
print(f"{'subject':8s} {'EC peak (Hz)':>12s} {'EC 8-12 Hz (uV^2)':>18s} {'EO 8-12 Hz (uV^2)':>18s} {'ratio EC/EO':>11s}")
for s, pk, p2, p1 in zip(SUBJECTS, peaks, group["ec"], group["eo"]):
    print(f"{s:8s} {pk:12.1f} {p2[alpha_g].sum() * df:18.1f} {p1[alpha_g].sum() * df:18.1f} {p2[alpha_g].sum() / p1[alpha_g].sum():11.1f}")
lin_mean = group["ec"].mean(axis=0)
log_mean = 10 ** np.log10(group["ec"]).mean(axis=0)
print(f"\nEC alpha peak frequency: mean of the {len(SUBJECTS)} individual peaks {peaks.mean():.2f} Hz (SD {peaks.std(ddof=1):.2f}), median {np.median(peaks):.2f} Hz; "
      f"peak of the linear-mean spectrum {helpers_l1.alpha_peak_frequency(fg, lin_mean):g} Hz; peak of the log-mean spectrum {helpers_l1.alpha_peak_frequency(fg, log_mean):g} Hz")
m_alpha = (fg >= 9.5) & (fg <= 10.5)
print(f"at 9.5-10.5 Hz the linear mean is {10 * np.log10(lin_mean[m_alpha].mean() / log_mean[m_alpha].mean()):.1f} dB above the log mean "
      f"(the largest single subject sits {10 * np.log10(group['ec'][:, m_alpha].mean(axis=1).max() / log_mean[m_alpha].mean()):.1f} dB above it)")

fig, axes = plt.subplots(1, 2, figsize=(13, 4), sharey=True)
for ax, cond, ttl in zip(axes, ("ec", "eo"), ("eyes closed (R02)", "eyes open (R01)")):
    spectra = {f"{s}": (fg, pp, dict(lw=0.5, color="0.6")) for s, pp in zip(SUBJECTS, group[cond])}
    spectra["linear mean"] = (fg, group[cond].mean(axis=0), dict(lw=1.8, color="tab:orange"))
    spectra["log (geometric) mean"] = (fg, 10 ** np.log10(group[cond]).mean(axis=0), dict(lw=1.8, color="tab:blue"))
    helpers_l1.plot_spectra(spectra, ax=ax, xlim=(1, 40), title=f"{len(SUBJECTS)} subjects, {CH}, {ttl}", legend=False)
    ax.legend(handles=ax.get_lines()[-2:], fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
subject  EC peak (Hz)  EC 8-12 Hz (uV^2)  EO 8-12 Hz (uV^2) ratio EC/EO
S001             10.0             3727.4              244.9        15.2
S002             11.0             1225.8               87.8        14.0
S003             10.5             4116.9              216.1        19.1
S004             10.5              538.7               20.8        25.8
S005             11.0               46.5               30.2         1.5
S006              7.0               21.8               17.7         1.2
S007             11.5             1956.3              609.9         3.2
S008              9.5              336.0               42.4         7.9
S009             10.0              409.0              102.9         4.0
S010              9.0             2348.2              186.9        12.6

EC alpha peak frequency: mean of the 10 individual peaks 10.00 Hz (SD 1.29), median 10.25 Hz; peak of the linear-mean spectrum 10 Hz; peak of the log-mean spectrum 10.5 Hz
at 9.5-10.5 Hz the linear mean is 4.9 dB above the log mean (the largest single subject sits 10.2 dB above it)
Figure 5 of notebook nb-1-3-psd, an output plot. The text around it states what it shows and the units of every axis.

Part 2 — Windowing, leakage and zero-padding (L1.4)

6. Leakage: a tone between two bins

The DFT assumes the epoch repeats forever. A tone that completes a whole number of cycles in the epoch repeats seamlessly and occupies one bin; a tone that does not (10.25 Hz in a 2-s epoch, bins 0.5 Hz apart) has a jump at the seam, and that jump spreads power into every bin — leakage. A window that tapers the epoch to zero at both ends removes the jump: the side lobes fall by tens of dB, the price being a wider main lobe. Spectra below are zero-padded eight-fold so that the lobes are drawn smoothly (section 8 says what that does and does not do); for the on-bin tone the unpadded bins fall exactly on the zeros between the side lobes, which is why it "occupies one bin" — the lobes are there all the same, as the padded curve and the printed levels show.

In [7]:
T, N = 2.0, int(2.0 * SF)
tt = np.arange(N) / SF
PAD = 8


def amp_spectrum_db(y, window, pad=PAD):
    """Amplitude spectrum (dB re 1 uV) of y with a window, zero-padded `pad`-fold; window gain compensated."""
    w = signal.get_window(window, len(y))
    Y = np.fft.rfft(y * w, n=pad * len(y))
    fr = np.fft.rfftfreq(pad * len(y), 1 / SF)
    return fr, 20 * np.log10(np.maximum(2 * np.abs(Y) / w.sum(), 1e-9))


fig, axes = plt.subplots(1, 2, figsize=(13, 3.8), sharey=True)
for ax, f0 in zip(axes, (10.0, 10.25)):
    tone = 10.0 * np.sin(2 * np.pi * f0 * tt)                       # 10 uV amplitude
    for window in ("boxcar", "hann"):
        fr, a = amp_spectrum_db(tone, window)
        ax.plot(fr, a, lw=0.9, label=f"{window} window")
        far = a[(fr >= f0 + 2) & (fr <= f0 + 4)].max()
        print(f"{f0:5.2f} Hz tone, {window:6s}: peak {a.max():5.1f} dB re 1 uV (true 20.0), highest level 2-4 Hz away {far:6.1f} dB")
    ax.set(xlim=(4, 16), ylim=(-70, 25), xlabel="Frequency (Hz)", ylabel="Amplitude (dB re 1 uV)",
           title=f"{f0:g} Hz tone in a {T:g}-s epoch ({'on' if f0 == 10.0 else 'between'} bins, {1 / T:g} Hz apart) (dB re 1 uV)")
    ax.grid(alpha=0.3); ax.legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
10.00 Hz tone, boxcar: peak  20.0 dB re 1 uV (true 20.0), highest level 2-4 Hz away   -3.9 dB
10.00 Hz tone, hann  : peak  20.0 dB re 1 uV (true 20.0), highest level 2-4 Hz away  -28.6 dB
10.25 Hz tone, boxcar: peak  20.0 dB re 1 uV (true 20.0), highest level 2-4 Hz away   -3.9 dB
10.25 Hz tone, hann  : peak  20.0 dB re 1 uV (true 20.0), highest level 2-4 Hz away  -28.6 dB
Figure 6 of notebook nb-1-3-psd, an output plot. The text around it states what it shows and the units of every axis.

7. Window shapes and their spectra

Every window is a compromise between main-lobe width (how close two components can be and still be told apart) and side-lobe level (how far a strong component leaks). The rectangular window has the narrowest main lobe and the worst side lobes; Hann and Hamming widen the lobe to about twice the width and drop the side lobes; Tukey windows taper only the ends and sit in between. Numbers below are measured from the spectra (main lobe = full width at −3 dB, in bins of 1/T; highest side lobe relative to the peak).

In [8]:
windows = ["boxcar", "hann", "hamming", ("tukey", 0.25), "blackman"]
fig, axes = plt.subplots(1, 2, figsize=(13, 3.8))
print(f"{'window':14s} {'-3 dB main-lobe width (bins)':>28s} {'highest side lobe (dB)':>22s}")
for window in windows:
    name = window if isinstance(window, str) else f"{window[0]}({window[1]})"
    w = signal.get_window(window, N)
    W = np.abs(np.fft.rfft(w, n=64 * N))
    W_db = 20 * np.log10(np.maximum(W / W.max(), 1e-12))
    bins = np.fft.rfftfreq(64 * N, 1 / SF) * T                      # frequency in units of bins (1/T)
    width = 2 * bins[np.argmax(W_db < -3)]                          # symmetric main lobe
    pk, _ = signal.find_peaks(W_db)
    side = W_db[pk].max() if len(pk) else np.nan
    print(f"{name:14s} {width:28.2f} {side:22.1f}")
    axes[0].plot(tt, w, lw=1, label=name)
    axes[1].plot(bins, W_db, lw=0.9, label=name)
axes[0].set(xlabel="Time (s)", ylabel="Window value (a.u.)", title=f"Window shapes over a {T:g}-s epoch (a.u.)")
axes[1].set(xlim=(0, 8), ylim=(-100, 3), xlabel="Frequency (bins of 1/T)", ylabel="Gain (dB re peak)", title="Window spectra: main lobe and side lobes (dB)")
for ax in axes:
    ax.grid(alpha=0.3); ax.legend(fontsize=8)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
window         -3 dB main-lobe width (bins) highest side lobe (dB)
boxcar                                 0.91                  -13.3
hann                                   1.47                  -31.5
hamming                                1.31                  -42.7
tukey(0.25)                            1.03                  -13.6
blackman                               1.66                  -58.1
Figure 7 of notebook nb-1-3-psd, an output plot. The text around it states what it shows and the units of every axis.

8. Zero-padding interpolates; it does not resolve

Appending zeros to an epoch before the FFT evaluates the same spectrum at more frequencies — a smoother curve, nothing more. Two tones 0.6 Hz apart in a 1-s epoch (bins 1 Hz apart) remain one lump however many zeros are appended; they separate only when the epoch itself is long enough (4 s: bins 0.25 Hz apart).

In [9]:
F1, F2 = 10.0, 10.6
fig, axes = plt.subplots(1, 3, figsize=(14, 3.6), sharey=True)
for ax, (Tn, pad) in zip(axes, ((1.0, 1), (1.0, 16), (4.0, 1))):
    n = int(Tn * SF)
    tn = np.arange(n) / SF
    two = 10 * np.sin(2 * np.pi * F1 * tn) + 10 * np.sin(2 * np.pi * F2 * tn)
    w = signal.get_window("hann", n)
    Y = np.fft.rfft(two * w, n=pad * n)
    fr = np.fft.rfftfreq(pad * n, 1 / SF)
    a = 2 * np.abs(Y) / w.sum()
    ax.plot(fr, a, "k", lw=0.9)
    ax.plot(fr, a, ".", color="tab:orange", ms=3)
    pk, _ = signal.find_peaks(a[(fr > 7) & (fr < 14)], height=a.max() * 0.3)
    print(f"T = {Tn:g} s, {pad:2d}x zero-padding: {len(fr)} points, spacing {fr[1]:.4f} Hz, distinct peaks between 7 and 14 Hz: {len(pk)}")
    ax.set(xlim=(7, 14), xlabel="Frequency (Hz)", title=f"T = {Tn:g} s, {pad}x zero-padding: {len(pk)} peak{'s' if len(pk) != 1 else ''}")
    ax.grid(alpha=0.3)
axes[0].set_ylabel("Amplitude (uV)")
fig.suptitle(f"Two tones at {F1:g} and {F2:g} Hz (10 uV each), Hann window: padding smooths, epoch length resolves (uV)", y=1.03)
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
T = 1 s,  1x zero-padding: 81 points, spacing 1.0000 Hz, distinct peaks between 7 and 14 Hz: 1
T = 1 s, 16x zero-padding: 1281 points, spacing 0.0625 Hz, distinct peaks between 7 and 14 Hz: 1
T = 4 s,  1x zero-padding: 321 points, spacing 0.2500 Hz, distinct peaks between 7 and 14 Hz: 2
Figure 8 of notebook nb-1-3-psd, an output plot. The text around it states what it shows and the units of every axis.

9. On real EEG: three spectra of one segment

Welch with 2-s segments of S001's eyes-closed O1 record, three windows. The rectangular window's side lobes lift the whole spectrum between the peaks (the skirt around the alpha peak and around the 60 Hz line); Hann and Hamming differ little from each other. The level between alpha and beta (14–16 Hz) and next to the line (62–65 Hz) is printed for each — this is the giveaway the L1.4 exercise asks you to spot.

In [10]:
three = {}
for window in ("boxcar", "hann", "hamming"):
    fw, pw = helpers_l1.welch_psd(x_ec, SF, seg_s=2.0, window=window)
    three[f"{'rectangular' if window == 'boxcar' else window} window"] = (fw, pw)
    m1 = (fw >= 14) & (fw <= 16)
    m2 = (fw >= 62) & (fw <= 65)
    print(f"{window:8s}: PSD 14-16 Hz {10 * np.log10(pw[m1].mean()):5.1f} dB, 62-65 Hz {10 * np.log10(pw[m2].mean()):5.1f} dB re 1 uV^2/Hz")
fig, axes = plt.subplots(1, 2, figsize=(13, 3.8))
helpers_l1.plot_spectra(three, ax=axes[0], xlim=(0, 80), title=f"{SUBJECT} R02 {CH}: Welch 2-s segments, three windows")
helpers_l1.plot_spectra(three, ax=axes[1], xlim=(50, 70), title="Zoom on the 60 Hz line: the rectangular skirt")
fig.tight_layout()
plt.show()   # render the static figure(s) of this cell inline
boxcar  : PSD 14-16 Hz  17.9 dB, 62-65 Hz  -6.8 dB re 1 uV^2/Hz
hann    : PSD 14-16 Hz  17.3 dB, 62-65 Hz -13.4 dB re 1 uV^2/Hz
hamming : PSD 14-16 Hz  17.3 dB, 62-65 Hz -13.1 dB re 1 uV^2/Hz
Figure 9 of notebook nb-1-3-psd, an output plot. The text around it states what it shows and the units of every axis.

10. The numbers

The L1.3 exercise: Welch parameters that give 0.5 Hz resolution with at least 20 segments in 60 s at fs = 160 Hz. nperseg = fs / Δf; the segment count follows from noverlap through the formula of section 2 (checked against SciPy's own count). The L1.4 exercise is answered in section 9 (the rectangular window is the one with the raised skirt).

In [11]:
FS_EX, DUR_EX, DF_EX, MIN_SEG = 160.0, 60.0, 0.5, 20
n_ex = int(DUR_EX * FS_EX)
nperseg = int(round(FS_EX / DF_EX))
print("nb-1-3-psd -- L1.3 / L1.4 exercise numbers (draft; TODO(confirm) at author review)")
print(f"resolution {DF_EX:g} Hz at fs = {FS_EX:g} Hz -> nperseg = fs / df = {nperseg} samples ({nperseg / FS_EX:g} s)")
for noverlap in (0, nperseg // 2, 3 * nperseg // 4):
    n_seg = helpers_l1.n_welch_segments(n_ex, nperseg, noverlap)
    print(f"  noverlap = {noverlap:3d} ({100 * noverlap / nperseg:2.0f} %): {n_seg:3d} segments in {DUR_EX:g} s -> {'meets' if n_seg >= MIN_SEG else 'fails'} the >= {MIN_SEG} requirement")
print(f"Answer pair (the conventional choice): nperseg = {nperseg}, noverlap = {nperseg // 2} "
      f"({helpers_l1.n_welch_segments(n_ex, nperseg, nperseg // 2)} segments); the minimal pair nperseg = {nperseg}, noverlap = 0 gives "
      f"{helpers_l1.n_welch_segments(n_ex, nperseg, 0)} segments and also satisfies the constraint")
# check the count against SciPy by asking it for the individual segment periodograms
_, _, stft = signal.stft(np.zeros(n_ex), fs=FS_EX, window="hann", nperseg=nperseg, noverlap=nperseg // 2, boundary=None, padded=False)
print(f"SciPy check: stft with the same parameters yields {stft.shape[1]} segments")
print(f"Group-mean alpha peak frequency, eyes closed, {CH}, {len(SUBJECTS)} subjects ({SUBJECTS[0]}-{SUBJECTS[-1]}), Welch 2-s Hann 50 %, largest PSD in 7-13 Hz: "
      f"mean {peaks.mean():.2f} Hz, median {np.median(peaks):.2f} Hz, SD {peaks.std(ddof=1):.2f} Hz; peak of the log-mean spectrum {helpers_l1.alpha_peak_frequency(fg, log_mean):g} Hz")
print(f"Individual peaks: " + ", ".join(f"{s} {pk:g}" for s, pk in zip(SUBJECTS, peaks)))
nb-1-3-psd -- L1.3 / L1.4 exercise numbers (draft; TODO(confirm) at author review)
resolution 0.5 Hz at fs = 160 Hz -> nperseg = fs / df = 320 samples (2 s)
  noverlap =   0 ( 0 %):  30 segments in 60 s -> meets the >= 20 requirement
  noverlap = 160 (50 %):  59 segments in 60 s -> meets the >= 20 requirement
  noverlap = 240 (75 %): 117 segments in 60 s -> meets the >= 20 requirement
Answer pair (the conventional choice): nperseg = 320, noverlap = 160 (59 segments); the minimal pair nperseg = 320, noverlap = 0 gives 30 segments and also satisfies the constraint
SciPy check: stft with the same parameters yields 59 segments
Group-mean alpha peak frequency, eyes closed, O1, 10 subjects (S001-S010), Welch 2-s Hann 50 %, largest PSD in 7-13 Hz: mean 10.00 Hz, median 10.25 Hz, SD 1.29 Hz; peak of the log-mean spectrum 10.5 Hz
Individual peaks: S001 10, S002 11, S003 10.5, S004 10.5, S005 11, S006 7, S007 11.5, S008 9.5, S009 10, S010 9