nb-c6-rigor-audit · Capstone C6 — the rigor audit¶
Capstone C6 · Level 6 · Status draft — for expert review; uncertain points carry TODO(confirm).
The capstone brief (spec §6, C6): take a published EEG paper with open data; audit its analysis against the reporting checklist; write a preregistered plan for one key analysis before looking at the data; run it; compare. The rubric is audit is specific and fair; plan was written before analysis; divergences explained.
The paper audited here, by name:
Anjum, M. F., Espinoza, A. I., Cole, R. C., Singh, A., May, P., Uc, E. Y., Dasgupta, S., & Narayanan, N. S. (2024). Resting-state EEG measures cognitive impairment in Parkinson's disease. npj Parkinson's Disease 10, 6. DOI 10.1038/s41531-023-00602-0.
Data: Singh, A., Cole, R., Espinoza, A., Cavanagh, J., & Narayanan, N. Rest eyes open. OpenNeuro ds004584 v1.0.0, DOI 10.18112/openneuro.ds004584.v1.0.0. Licence CC0,
access: open(data/directory.yaml).
§6 names it as the clear candidate because, unlike the other class-A directory entries, it has a published primary result alongside its data. That is what makes an audit possible at all.
What is being audited, and what is not. This is an audit of the reporting: of what the published record — the paper's own data release, its sidecars and the facts the site's catalog records from them — lets a stranger reproduce. It is not a judgement of the authors, of the science, or of the result. Several checklist items below come out better than the field's average and they are marked as such; several cannot be answered from the material this notebook can read, and those say exactly which sentence of the paper a reader must go and find, rather than guessing.
A rule this notebook keeps. Nothing the paper reports is quoted from memory. Where a comparison with the
authors' own numbers belongs, the notebook prints a literal TODO(confirm) naming what to look up. The site's
catalog (data/catalog/datasets/iowapd.md, mirrored into helpers_l6.DATASETS_L6) carries acquisition and
cohort facts; it carries no result values, so none are asserted.
# Setup: dependencies, the shared helpers, non-interactive plotting.
import hashlib
import importlib.util
import json
import subprocess
import sys
import time
import warnings
from datetime import datetime, timezone
from pathlib import Path
_needed = ("mne", "scipy", "matplotlib", "pandas", "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)
_shared = next((d / "_shared" for d in (Path.cwd(), *Path.cwd().parents)
if (d / "_shared" / "helpers_l6.py").exists()), None)
if _shared is None:
raise FileNotFoundError("start the kernel in notebooks/capstones/ (or notebooks/) so that "
"_shared/helpers_l6.py is found")
sys.path.insert(0, str(_shared))
import helpers
import helpers_l1 as L1
import helpers_l6 as L6
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import mne
from scipy import stats
mne.set_log_level("WARNING")
plt.rcParams["figure.dpi"] = 72
# The documented subset and the iteration counts (spec section 11: a capstone runs on a documented
# subset of 10-20 subjects with a FULL_COHORT switch).
FULL_COHORT = False
N_PER_GROUP = 10 if not FULL_COHORT else 49 # 10 + 10 = 20 participants, the upper end of
# spec section 11's "documented subset of 10-20
# subjects"; 49 controls is the cohort's own cap
N_PERM = 10000 if not FULL_COHORT else 50000
SEED = L6.SEED
ALPHA = L6.ALPHA
DATASET = "ds-iowapd"
print(f"MNE {mne.__version__}; helpers_l6 imported from notebooks/_shared")
print(f"FULL_COHORT = {FULL_COHORT}: {N_PER_GROUP} participants per group, {N_PERM} permutations")
print(f"Download: about {N_PER_GROUP * 2 * 36} MB, fetched one participant at a time and deleted before "
f"the next is fetched, so at most one recording is ever on disk")
1 · The material the audit can read¶
An audit has to name its sources. Three kinds of material are available here and they are not equally authoritative:
- The dataset's own files, fetched live below —
dataset_description.json,README,participants.tsvandparticipants.json, and one participant's*_eeg.jsonand*_channels.tsvsidecars. These are primary: they are what the authors released. - The site's catalog (
helpers_l6.DATASETS_L6["ds-iowapd"], mirroringdata/directory.yaml, which was built fromdata/catalog/datasets/iowapd.md). Secondary, but traceable. - The paper's Methods section, which this notebook does not read. Every item that needs it is marked
TODO(confirm)with the question a reader must take to the PDF.
free0 = L6.disk_report("before any download", folders={"course downloads": L1.download_dir()})
S3 = L1.OPENNEURO_S3
DS = "ds004584"
small, absent = {}, []
for name in ("dataset_description.json", "README", "participants.tsv", "participants.json",
"CHANGES", "task-Rest_eeg.json"):
head = helpers.http_head(f"{S3}/{DS}/{name}") # ask before fetching: a 404 costs one request
if not head["ok"]:
absent.append((name, head["reason"]))
continue
small[name] = L1.fetch(f"{S3}/{DS}/{name}", f"{DS}/{name}", verbose=False)
print(f"dataset-level files present: {', '.join(sorted(small))} "
f"({sum(p.stat().st_size for p in small.values()) / 1024:.1f} kB in total)")
for name, why in absent:
print(f" ABSENT: {name} ({why})")
print(" (a top-level task sidecar is optional in BIDS -- the inheritance principle lets the per-subject "
"one carry everything -- so its absence is a note, not a fault)")
dd = json.loads(small["dataset_description.json"].read_text()) if "dataset_description.json" in small else {}
print(f"\ndataset_description.json:")
for k, v in dd.items():
print(f" {k:22s} : {v}")
if "README" in small:
readme = small["README"].read_text().strip()
print(f"\nREADME ({len(readme)} characters):")
print(" " + "\n ".join(readme.splitlines()[:25]))
if len(readme.splitlines()) > 25:
print(f" ... ({len(readme.splitlines()) - 25} more lines)")
parts = pd.read_csv(small["participants.tsv"], sep="\t")
print(f"participants.tsv: {len(parts)} rows x {len(parts.columns)} columns {list(parts.columns)}")
print(parts.head(3).to_string(index=False))
print(f"\ngroups: {parts.GROUP.value_counts().to_dict()}")
for col in ("AGE", "MOCA", "UPDRS"):
if col in parts.columns:
g = parts.groupby("GROUP")[col].agg(["count", "mean", "std", "min", "max"]).round(2)
print(f"\n{col}:")
print(g.to_string())
print(f"\nsex: {parts.groupby(['GROUP', 'GENDER']).size().to_dict()}")
missing = {c: int(parts[c].isna().sum()) for c in parts.columns if parts[c].isna().any()}
print(f"\nmissing values per column: {missing if missing else 'none'}")
if "participants.json" in small:
pj = json.loads(small["participants.json"].read_text())
print(f"\nparticipants.json documents: {list(pj)}")
for k, v in pj.items():
print(f" {k:12s} : {v}")
else:
print("\nparticipants.json: NOT present -- the column meanings are not machine-readable")
# One participant's acquisition sidecars: the recording parameters as the release states them.
sub0 = parts.participant_id.iloc[0]
side = {}
for suffix in ("eeg.json", "channels.tsv", "electrodes.tsv", "coordsystem.json", "events.tsv"):
rel = f"{DS}/{sub0}/eeg/{sub0}_task-Rest_{suffix}"
side[suffix] = (L1.fetch(f"{S3}/{rel}", rel, verbose=False)
if helpers.http_head(f"{S3}/{rel}")["ok"] else None)
print(f"{sub0} sidecars: " + ", ".join(f"{k} {'present' if v else 'ABSENT'}" for k, v in side.items()))
if side["eeg.json"]:
ej = json.loads(side["eeg.json"].read_text())
print(f"\n{sub0}_task-Rest_eeg.json:")
for k, v in ej.items():
print(f" {k:28s} : {v}")
else:
ej = {}
if side["channels.tsv"]:
ch = pd.read_csv(side["channels.tsv"], sep="\t")
print(f"\nchannels.tsv: {len(ch)} rows, columns {list(ch.columns)}")
print(f" types: {ch['type'].value_counts().to_dict() if 'type' in ch else 'no type column'}")
print(f" units: {ch['units'].value_counts().to_dict() if 'units' in ch else 'no units column'}")
if "status" in ch.columns:
print(f" status: {ch['status'].value_counts().to_dict()}")
else:
print(f" status column: ABSENT -- the release does not say which channels the authors treated as bad")
2 · The audit¶
Each checklist item gets a verdict, the source that produced it, and — where the answer is "not from this
material" — the question to take to the paper. helpers_l6.REPORTING_CHECKLIST is the list; its provenance
note is printed first, because a checklist quoted from the wrong place is itself a reporting failure.
print(L6.CHECKLIST_NOTE)
print()
facts = L6.DATASETS_L6["ds-iowapd"]
V = {} # id -> (verdict, source, detail)
def verdict(i, v, source, detail):
V[i] = {"verdict": v, "source": source, "detail": detail}
verdict("participants", "REPORTED",
"participants.tsv + data/directory.yaml",
f"{len(parts)} participants with group, age, sex, MoCA and UPDRS in a machine-readable table "
f"({parts.GROUP.value_counts().to_dict()}). Inclusion and exclusion criteria themselves are in the "
f"paper, not the release: TODO(confirm) the diagnostic criteria and the control recruitment.")
verdict("recording", "REPORTED",
f"{sub0}_task-Rest_eeg.json + channels.tsv",
f"{facts['device']}; {ej.get('SamplingFrequency', facts['sfreq'])} Hz; "
f"{ej.get('EEGChannelCount', facts['n_channels'])} EEG channels; reference "
f"{ej.get('EEGReference', facts['reference'])}; ground {ej.get('EEGGround', 'TODO(confirm)')}.")
verdict("online-filters",
"REPORTED" if any(k in ej for k in ("SoftwareFilters", "HardwareFilters")) else "PARTIAL",
f"{sub0}_task-Rest_eeg.json + data/directory.yaml",
f"sidecar: SoftwareFilters = {ej.get('SoftwareFilters', 'absent')}, "
f"HardwareFilters = {ej.get('HardwareFilters', 'absent')}; catalog records "
f"'{facts['online_filters']}'.")
verdict("task", "REPORTED", "data/directory.yaml + eeg.json",
f"{facts['paradigm']}. TaskName = {ej.get('TaskName', 'absent')}; "
f"RecordingDuration = {ej.get('RecordingDuration', 'absent')} s.")
verdict("offline-filters", "NOT IN THE RELEASE", "the paper's Methods",
"The OpenNeuro release contains raw EEGLAB files and no derivatives, so the filters the analysis "
"used are only in the paper. TODO(confirm): read the filter type, order, cutoffs and direction "
"from the Methods, and check whether the reported band-pass was applied before or after the "
"line-noise removal.")
verdict("line-noise", "NOT IN THE RELEASE", "the paper's Methods + data/directory.yaml",
f"Mains is {facts['mains_hz']} Hz. The catalog records that the authors removed line components "
f"offline as part of their analysis, not in the released files. TODO(confirm): the exact "
f"frequencies and the method, from the Methods.")
verdict("bad-channels",
"NOT IN THE RELEASE" if (side["channels.tsv"] is None or "status" not in ch.columns) else "REPORTED",
"channels.tsv",
"channels.tsv carries no `status` column, so the release does not say which channels were treated "
"as bad or how many there were per participant. TODO(confirm): the paper's channel-rejection rule "
"and the resulting counts.")
verdict("reference", "REPORTED (online) / NOT IN THE RELEASE (offline)",
"eeg.json + data/directory.yaml",
f"Online reference {facts['reference']}, which is why that channel is flat or absent in the files. "
f"The offline reference is a Methods question: TODO(confirm).")
verdict("artifact-correction", "NOT IN THE RELEASE", "the paper's Methods",
"No ICA or regression products are in the release. TODO(confirm): whether ocular correction was "
"applied, by what method, and how many components were removed per participant.")
verdict("rejection", "NOT IN THE RELEASE", "the paper's Methods",
"No per-participant rejection log is released. TODO(confirm): the criterion and how much data it "
"removed per participant.")
verdict("epoching", "N/A (resting design)", "data/directory.yaml",
f"{facts['paradigm']} -- there is no event to lock to. The equivalent question is how the "
f"continuous recording was segmented for the spectral estimate: TODO(confirm).")
verdict("measurement", "NOT IN THE RELEASE", "the paper's Methods",
"TODO(confirm): the frequency bands, the electrodes and whether either was fixed a priori. This is "
"the item the multiverse in section 5 is about.")
verdict("statistics", "NOT IN THE RELEASE", "the paper's Methods",
"TODO(confirm): the test, the multiple-comparison strategy and the size of the space corrected over.")
verdict("effect-size", "NOT IN THE RELEASE", "the paper's Results",
"TODO(confirm): whether effect sizes with confidence intervals are reported alongside p-values.")
verdict("power", "NOT IN THE RELEASE", "the paper's Methods",
"TODO(confirm): how the sample size was arrived at. The released cohort is "
f"{parts.GROUP.value_counts().to_dict()}, which is unusually large for resting EEG and is itself "
f"worth noting in the audit's favour.")
verdict("exclusions", "PARTIAL", "participants.tsv",
f"The released table has {len(parts)} rows and "
f"{sum(parts[c].isna().sum() for c in parts.columns)} missing cells in total, so participants with "
f"incomplete clinical data are visible. Whether any participant was dropped from the analysis, and "
f"why, is a Methods question: TODO(confirm).")
verdict("code", "NOT IN THE RELEASE", "the OpenNeuro release",
"The release contains no analysis code and no derivatives. TODO(confirm): whether code is shared "
"elsewhere (the catalog records a lab mirror for the data).")
verdict("data", "REPORTED, AND WELL", "dataset_description.json + OpenNeuro",
f"BIDS (BIDSVersion {dd.get('BIDSVersion', 'TODO(confirm)')}), licence "
f"{dd.get('License', facts['license'])}, a versioned DOI, per-file HTTP access and DataLad. This is "
f"the item most EEG papers fail and this one passes cleanly.")
verdict("preregistration", "NOT IN THE RELEASE", "the paper",
"TODO(confirm): whether the study was preregistered. The catalog records a separate out-of-sample "
"validation cohort used in the paper but not released, which is the kind of design decision a "
"preregistration would pin down.")
order = [c["id"] for c in L6.REPORTING_CHECKLIST]
by_id = {c["id"]: c for c in L6.REPORTING_CHECKLIST}
print(f"{'#':>2s} {'section':>14s} {'item':<24s} {'verdict':<34s} source")
for n, i in enumerate(order, 1):
c, v = by_id[i], V[i]
print(f"{n:2d} {c['section']:>14s} {i:<24s} {v['verdict']:<34s} {v['source']}")
print("The audit in detail, item by item.\n")
for n, i in enumerate(order, 1):
c, v = by_id[i], V[i]
print(f"{n:2d}. {c['section'].upper()} / {i}")
print(f" asks : {c['item']}")
print(f" verdict : {v['verdict']} (source: {v['source']})")
print(f" detail : {v['detail']}")
print()
counts = {}
for v in V.values():
key = ("reported" if v["verdict"].startswith("REPORTED") else
"partial" if v["verdict"].startswith("PARTIAL") or "/" in v["verdict"] else
"n/a" if v["verdict"].startswith("N/A") else "not in the release")
counts[key] = counts.get(key, 0) + 1
print(f"SUMMARY over {len(V)} items: " + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())))
print()
print("Read that summary carefully before drawing a conclusion from it. 'Not in the release' is NOT the")
print("same as 'not reported': most of those items are in the paper's Methods, which this notebook does not")
print("read. What the count measures is how much of the analysis a stranger can reconstruct from the SHARED")
print("MATERIAL ALONE -- which is the thing that decides whether the work can be reused, and is the reason")
print("sharing derivatives and code alongside raw data matters as much as sharing the raw data.")
What this release does better than most¶
An audit that only lists failures is not an audit, it is a complaint. Four things here are done well and are worth copying:
- The data are genuinely open. CC0, on OpenNeuro, with a versioned DOI, per-file HTTP access and DataLad. No registration, no data-use agreement, no email. Every notebook in this course that touches it could be written because of that decision.
- The cohort is large for resting EEG. 100 patients and 49 controls is several times the median clinical
EEG study, and the size is what makes the dataset reusable as pilot data in
nb-6-3-power-sim. - The clinical variables are in the table. Age, sex, MoCA and UPDRS per participant, machine-readable, so a reuser can model the confounds instead of guessing at them.
- The acquisition sidecars are complete enough to reproduce the recording: amplifier, cap, sampling rate, online reference and online filter are all in the BIDS JSON rather than in prose.
The gap is not in what was collected or shared — it is that no derivatives and no code accompany the raw data, so every preprocessing and measurement decision has to be read out of prose and re-implemented. That is the single change that would move the most items in the table above.
3 · The preregistration, written before the recordings are loaded¶
The rubric asks that the plan be written before the analysis. In a repository the evidence is a timestamped commit; here it is the order of the cells plus a hash. The cell below writes the plan, prints its SHA-256 and records the time; no recording has been downloaded at this point — only the kB-sized sidecars of section 1, which contain no EEG.
Every later cell re-checks the digest before it runs, so a plan edited after seeing a result would announce itself.
PLAN = """
C6 PREREGISTERED ANALYSIS PLAN -- one key analysis of ds-iowapd (OpenNeuro ds004584)
1. QUESTION Does resting-state relative beta power differ between people with Parkinson's disease
(on dopaminergic medication) and healthy controls?
2. DIRECTION Two-sided. The catalog records that recording ON medication attenuates the beta
signatures of Parkinson's disease, so the direction of any difference is not predicted.
3. SAMPLE The first 10 PD and the first 10 Control participants in participants.tsv order.
Order is the file's, not chosen; no recording is inspected before the list is fixed.
4. MEASURE helpers_l6.RESTING_MEASURE, fixed before this dataset was opened: the share of 1-45 Hz
power falling in 13-30 Hz, averaged over C3, Cz and C4, after an average reference,
from seconds 10 to 130 of the recording, by Welch with 2-s Hann segments and 50 % overlap.
5. EXCLUSION A participant is excluded if the recording is shorter than 140 s, if fewer than 8 EEG
channels survive the flat-channel check, or if none of C3/Cz/C4 is usable. Decided now;
every exclusion is reported with its reason.
6. TEST Welch's two-sample t-test (unequal variances), alpha = .05, two-sided.
7. EFFECT SIZE Hedges' g with a 95 % confidence interval. The interval is the result; the p-value is
a footnote to it.
8. ROBUSTNESS A permutation test on the group labels with 10,000 permutations, reported beside the
parametric test. If the two disagree, both are reported and neither is preferred.
9. CONFOUND Age is recorded for every participant and the two groups are not age-matched by design.
The age difference in the analysed sample is reported, and the test is repeated with age
as a covariate. The covariate result is reported whatever it shows.
10. DIVERGENCE Any departure from lines 1-9 is reported in section 6 with the reason and the stage at
which it was made. Nothing here is changed after the recordings are loaded.
"""
PLAN_DIGEST = hashlib.sha256(PLAN.encode("utf-8")).hexdigest()
PLAN_WRITTEN_AT = datetime.now(timezone.utc).isoformat(timespec="seconds")
print(PLAN)
print(f"SHA-256 : {PLAN_DIGEST}")
print(f"written at : {PLAN_WRITTEN_AT} (UTC, this kernel)")
print(f"downloaded so far: {', '.join(sorted(small))} and {sub0}'s sidecars -- "
f"{(sum(p.stat().st_size for p in small.values()) + sum(p.stat().st_size for p in side.values() if p)) / 1024:.1f} kB, "
f"no EEG")
print()
print("In a repository this cell's content would be committed before the data cells were written, and the")
print("commit hash would be the evidence. A digest printed in a notebook proves only that the plan below")
print("matches the plan above -- which is the part a reader of THIS notebook can check.")
4 · Running the plan¶
assert hashlib.sha256(PLAN.encode("utf-8")).hexdigest() == PLAN_DIGEST, "the plan changed after it was written"
pd_ids = parts.loc[parts.GROUP == "PD", "participant_id"].tolist()[:N_PER_GROUP]
hc_ids = parts.loc[parts.GROUP == "Control", "participant_id"].tolist()[:N_PER_GROUP]
print(f"plan digest re-checked: {PLAN_DIGEST[:16]}... OK")
print(f"line 3 sample: {len(pd_ids)} PD ({', '.join(pd_ids)})")
print(f" {len(hc_ids)} Control ({', '.join(hc_ids)})")
print(f"line 4 measure: {L6.RESTING_MEASURE['definition']}, {L6.RESTING_MEASURE['segment']}, "
f"{L6.RESTING_MEASURE['spectrum']}")
t0 = time.time()
L6.disk_report("before the recordings", folders={"course downloads": L1.download_dir()})
res = L6.resting_cohort(DATASET, pd_ids + hc_ids, progress=True)
res["group"] = ["PD"] * len(pd_ids) + ["Control"] * len(hc_ids)
res = res.merge(parts[["participant_id", "AGE", "GENDER", "MOCA", "UPDRS"]],
left_on="subject", right_on="participant_id", how="left")
print(f"\n{int(res.ok.sum())} of {len(res)} recordings measured in {time.time() - t0:.0f} s")
L6.disk_report("after the recordings", folders={"course downloads": L1.download_dir()})
if (~res.ok).any():
print("\nline 5 EXCLUSIONS (reported, as the plan requires):")
print(res.loc[~res.ok, ["subject", "group", "reason"]].to_string(index=False))
else:
print("\nline 5: no participant met an exclusion criterion")
ok = res[res.ok]
a = ok.loc[ok.group == "PD", "value"].to_numpy()
b = ok.loc[ok.group == "Control", "value"].to_numpy()
t_obs, p_par = stats.ttest_ind(a, b, equal_var=False)
g = L6.hedges_g(a, b)
rng = np.random.default_rng(SEED)
pooled = np.r_[a, b]
null = np.empty(N_PERM)
for i in range(N_PERM):
perm = rng.permutation(pooled)
null[i] = stats.ttest_ind(perm[:len(a)], perm[len(a):], equal_var=False).statistic
p_perm = (1 + int((np.abs(null) >= abs(t_obs)).sum())) / (1 + N_PERM)
print(f"line 6 TEST -- {L6.RESTING_MEASURE['name']}, eyes-open rest:")
print(f" PD n = {len(a):2d}, mean {a.mean():.4f}, SD {a.std(ddof=1):.4f}")
print(f" Control n = {len(b):2d}, mean {b.mean():.4f}, SD {b.std(ddof=1):.4f}")
print(f" difference (PD minus Control) {a.mean() - b.mean():+.4f}")
print(f" Welch t({stats.ttest_ind(a, b, equal_var=False).df:.1f}) = {t_obs:.4f}, p = {p_par:.4f}")
print(f"line 7 EFFECT SIZE:")
print(f" Hedges' g = {g['g']:+.4f}, 95 % CI [{g['ci'][0]:+.4f}, {g['ci'][1]:+.4f}] "
f"(Cohen's d {g['d']:+.4f}, correction {g['correction_j']:.4f}, pooled SD {g['pooled_sd']:.4f})")
print(f"line 8 ROBUSTNESS:")
print(f" label permutation, {N_PERM} permutations, seed {SEED}: p = {p_perm:.4f}")
print(f" the two tests {'AGREE' if (p_par < ALPHA) == (p_perm < ALPHA) else 'DISAGREE'} at alpha = {ALPHA}")
print(f" smallest attainable permutation p: {1 / (1 + N_PERM):.5f}")
print(f"line 9 CONFOUND -- age:")
age_pd = ok.loc[ok.group == "PD", "AGE"].to_numpy(float)
age_hc = ok.loc[ok.group == "Control", "AGE"].to_numpy(float)
t_age, p_age = stats.ttest_ind(age_pd, age_hc, equal_var=False)
print(f" PD {np.nanmean(age_pd):.1f} +- {np.nanstd(age_pd, ddof=1):.1f} y")
print(f" Control {np.nanmean(age_hc):.1f} +- {np.nanstd(age_hc, ddof=1):.1f} y")
print(f" Welch t = {t_age:.3f}, p = {p_age:.4f} -> the analysed sample "
f"{'IS' if p_age < ALPHA else 'is not'} significantly age-imbalanced")
X = np.column_stack([np.ones(len(ok)), (ok.group == "PD").to_numpy(float),
ok["AGE"].to_numpy(float) - ok["AGE"].mean()])
yv = ok["value"].to_numpy(float)
beta, *_ = np.linalg.lstsq(X, yv, rcond=None)
resid = yv - X @ beta
dof = len(yv) - X.shape[1]
cov = (resid @ resid / dof) * np.linalg.inv(X.T @ X)
se = np.sqrt(np.diag(cov))
t_cov = beta / se
p_cov = 2 * stats.t.sf(np.abs(t_cov), dof)
print(f" with age as a covariate (ordinary least squares, age centred):")
for name, bi, si, ti, pi in zip(["intercept", "group (PD - Control)", "age (per year)"],
beta, se, t_cov, p_cov):
print(f" {name:22s} {bi:+10.5f} SE {si:.5f} t({dof}) = {ti:+7.3f} p = {pi:.4f}")
print(f" the group estimate moves from {a.mean() - b.mean():+.4f} (raw) to {beta[1]:+.4f} (adjusted), "
f"a change of {beta[1] - (a.mean() - b.mean()):+.4f}")
fig, axes = plt.subplots(1, 3, figsize=(13, 4.1))
for i, (grp, colour) in enumerate((("Control", "tab:blue"), ("PD", "tab:orange"))):
v = ok.loc[ok.group == grp, "value"].to_numpy()
axes[0].scatter(np.full(len(v), i) + rng.normal(0, 0.05, len(v)), v, s=34, color=colour, alpha=0.85,
label=f"{grp} (n = {len(v)})")
axes[0].hlines(v.mean(), i - 0.22, i + 0.22, color="k", lw=2.2)
axes[0].set_xticks([0, 1], ["Control", "PD"])
axes[0].set(ylabel="Relative beta power, 13–30 / 1–45 Hz (dimensionless)",
title=f"The pre-registered comparison\n(g = {g['g']:+.2f} [{g['ci'][0]:+.2f}, {g['ci'][1]:+.2f}])")
axes[0].grid(alpha=0.3)
axes[0].legend(fontsize=8)
axes[1].hist(null, bins=50, color="tab:blue", alpha=0.8, label=f"{N_PERM} label permutations")
axes[1].axvline(t_obs, color="tab:orange", lw=2.2, label=f"observed t = {t_obs:+.2f} (p = {p_perm:.4f})")
axes[1].axvline(-t_obs, color="tab:orange", lw=1.0, ls=":")
axes[1].set(xlabel="Welch t under the null (dimensionless)", ylabel="Permutations",
title="line 8: the permutation null")
axes[1].grid(alpha=0.3)
axes[1].legend(fontsize=8)
for grp, colour in (("Control", "tab:blue"), ("PD", "tab:orange")):
m = ok.group == grp
axes[2].scatter(ok.loc[m, "AGE"], ok.loc[m, "value"], s=34, color=colour, alpha=0.85, label=grp)
axes[2].set(xlabel="Age (years)", ylabel="Relative beta power (dimensionless)",
title=f"line 9: the age confound\n(group difference {p_age:.3f} by age)")
axes[2].grid(alpha=0.3)
axes[2].legend(fontsize=8)
fig.tight_layout()
plt.show() # render the static figure(s) of this cell inline
5 · What the plan bought: the same data through 108 pipelines¶
The plan fixed one measure. Section 5 asks what the other defensible ones would have given — not to choose a better answer, but to size the number the plan protected against.
Five decisions, all of them ones published resting-EEG papers actually make: the beta band's edges (3), the normalising band (2), the electrodes (3), the reference (2) and the segment of the recording used (3). That is 108 analyses of the same recordings, and every one of them could be written into a methods section without a reviewer objecting.
The recordings have to be fetched again for this — one at a time, deleted before the next, as before — because all 108 variants are computed in one pass per participant.
assert hashlib.sha256(PLAN.encode("utf-8")).hexdigest() == PLAN_DIGEST, "the plan changed after it was written"
rpaths = L6.resting_paths()
i_plan = rpaths.index({"band": "13-30", "total": "1-45", "channels": "C3+Cz+C4",
"reference": "average", "segment": "10-130"})
print(f"{len(rpaths)} variants from {len(L6.RESTING_CHOICES)} decisions:")
for c in L6.RESTING_CHOICES:
print(f" {c['id']:10s} ({len(c['options'])}) {', '.join(c['options'])}")
print(f" {c['why']}")
print(f"\nthe plan's own variant is index {i_plan}: {rpaths[i_plan]}")
t0 = time.time()
L6.disk_report("before the multiverse loop", folders={"course downloads": L1.download_dir()})
mv = {}
for sid in list(ok.subject):
try:
mv[sid] = L6.resting_multiverse(DATASET, sid)["values"]
except Exception as exc: # noqa: BLE001
print(f" {sid}: skipped ({type(exc).__name__}: {exc})")
print(f" {sid}: {np.isfinite(mv[sid]).sum() if sid in mv else 0} of {len(rpaths)} variants "
f"(free disk {helpers.free_disk_mb('.') / 1000:.2f} GB)", flush=True)
print(f"{len(mv)} participants x {len(rpaths)} variants in {time.time() - t0:.0f} s")
L6.disk_report("after the multiverse loop", folders={"course downloads": L1.download_dir()})
grp = np.array([1 if s in set(pd_ids) else 0 for s in mv])
M = np.stack([mv[s] for s in mv]) # subjects x variants
gs, ps = np.full(len(rpaths), np.nan), np.full(len(rpaths), np.nan)
for i in range(len(rpaths)):
col = M[:, i]
m = np.isfinite(col)
if m.sum() < 6:
continue
aa, bb = col[m & (grp == 1)], col[m & (grp == 0)]
if len(aa) < 3 or len(bb) < 3:
continue
gs[i] = L6.hedges_g(aa, bb)["g"]
ps[i] = stats.ttest_ind(aa, bb, equal_var=False).pvalue
fin = np.isfinite(gs)
sig = fin & (ps < ALPHA)
print(f"RESTING MULTIVERSE on the same {len(mv)} participants:")
print(f" {int(sig.sum())} of {int(fin.sum())} variants reach p < {ALPHA}")
print(f" Hedges' g spans {np.nanmin(gs):+.4f} to {np.nanmax(gs):+.4f}, median {np.nanmedian(gs):+.4f}")
print(f" sign: {int((gs[fin] > 0).sum())} positive, {int((gs[fin] < 0).sum())} negative"
+ (" <- the variants do not even agree on the direction" if
(gs[fin] > 0).any() and (gs[fin] < 0).any() else " <- every variant points the same way"))
print(f" the plan's own variant: g = {gs[i_plan]:+.4f}, p = {ps[i_plan]:.4f} "
f"({100 * float(np.mean(np.abs(gs[fin]) <= abs(gs[i_plan]))):.0f}th percentile by |g|)")
print()
rows = L6.marginals(rpaths, gs, sig, L6.RESTING_CHOICES)
print(f"{'decision':>11s} {'option':>12s} {'variants':>9s} {'median g':>10s} {'range':>20s} {'p < .05':>9s}")
spread = {}
for r in rows:
print(f"{r['choice']:>11s} {r['option']:>12s} {r['n_paths']:9d} {r['effect_median']:+10.4f} "
f"{r['effect_min']:+9.3f} to {r['effect_max']:+6.3f} {r['n_significant']:4d}/{r['n_paths']:<4d}")
spread.setdefault(r["choice"], []).append(r["effect_median"])
print("\nwhich decision moves the estimate most (spread of the option medians, in g):")
for k, v in sorted(spread.items(), key=lambda kv: -(max(kv[1]) - min(kv[1]))):
print(f" {k:10s} {max(v) - min(v):6.3f} ({min(v):+.3f} to {max(v):+.3f})")
fig, axes = L6.plot_specification_curve(
np.nan_to_num(gs, nan=0.0), np.nan_to_num(ps, nan=1.0), rpaths, alpha=ALPHA,
choices=L6.RESTING_CHOICES, highlight=rpaths[i_plan],
title=f"ds-iowapd PD vs Control, {len(rpaths)} defensible resting analyses "
f"(Hedges' g, dimensionless)")
axes[0].set_ylabel("Hedges' g (dimensionless)")
plt.show() # render the static figure(s) of this cell inline
6 · Divergences, and what the audit concludes¶
print("DIVERGENCES from the plan (line 10):")
divs = []
if (~res.ok).any():
divs.append(f"{int((~res.ok).sum())} participant(s) excluded under line 5; reasons printed in section 4.")
if len(a) != N_PER_GROUP or len(b) != N_PER_GROUP:
divs.append(f"analysed n differs from the planned {N_PER_GROUP} per group: {len(a)} PD, {len(b)} Control.")
if FULL_COHORT:
divs.append("FULL_COHORT is on, so the sample is the whole cohort rather than the planned 12 per group.")
divs.append("Section 5 (the 108-variant multiverse) is NOT in the plan. It was added after the planned "
"analysis had been run and is reported as exploratory, which is what line 10 requires of it. It "
"does not change the planned result and is not offered as a replacement for it.")
for d in divs:
print(f" - {d}")
print()
print("WHAT THE PLANNED ANALYSIS FOUND:")
print(f" {L6.RESTING_MEASURE['name']}, PD {a.mean():.4f} vs Control {b.mean():.4f}; "
f"g = {g['g']:+.4f} [{g['ci'][0]:+.4f}, {g['ci'][1]:+.4f}]; Welch p = {p_par:.4f}; "
f"permutation p = {p_perm:.4f}; age-adjusted group estimate {beta[1]:+.5f} (p = {p_cov[1]:.4f})")
print(f" n = {len(a)} + {len(b)}, which nb-6-3-power-sim shows is a pilot rather than a test: the "
f"confidence interval on g spans {g['ci'][1] - g['ci'][0]:.2f} standardized units.")
print()
print("WHAT IT DOES NOT SHOW:")
print(" - It is not a replication of Anjum et al. (2024). That paper's measure, pipeline and question are")
print(" not this one's, the catalog carries none of its result values, and this notebook read none of its")
print(" Methods. TODO(confirm) before any sentence comparing the two is written.")
print(" - The PD group was recorded ON dopaminergic medication (data/directory.yaml), which the catalog")
print(" records as attenuating the beta signatures of the disease. A null result here is uninformative")
print(" about the unmedicated state.")
print(" - The two groups are not age-matched by design and the analysed sample's age difference is printed")
print(" above; the covariate-adjusted estimate is reported beside the raw one for that reason.")
The rubric, self-assessed¶
| Rubric item (§6, C6) | Where it is met |
|---|---|
| The audit is specific | Every item in section 2 names its source — a file in the release, a field in a sidecar, or the paper's Methods — and says what it found there. Nothing is summarised as "poorly reported". |
| The audit is fair | "Not in the release" is distinguished from "not reported" everywhere, because this notebook does not read the paper. Four things the release does better than most are listed and the reason each matters is given. |
| The plan was written before the analysis | Section 3 precedes every recording download; its SHA-256 is printed there and re-checked at the top of sections 4 and 5. In a repository the timestamped commit is the stronger evidence, and the notebook says so rather than claiming the hash is equivalent. |
| Divergences are explained | Section 6 lists them, including the one that matters most: the multiverse is exploratory and post-hoc, and it says so instead of being presented beside the planned test as if it were part of the plan. |
print("nb-c6-rigor-audit -- C6 numbers (draft; TODO(confirm) at author review)")
print(f"Paper audited: Anjum MF et al. (2024), npj Parkinson's Disease 10, 6, "
f"DOI {L6.DATASETS_L6['ds-iowapd']['paper_doi']}")
print(f"Data: {DATASET} (OpenNeuro ds004584 v1.0.0, DOI {L6.DATASETS_L6['ds-iowapd']['dataset_doi']}), "
f"licence {L6.DATASETS_L6['ds-iowapd']['license']}, access "
f"{L6.DATASETS_L6['ds-iowapd']['access']}")
print()
print(f"1. AUDIT over {len(V)} reporting items: " + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())))
print(f" items answerable from the shared material alone: "
f"{counts.get('reported', 0) + counts.get('partial', 0)} of {len(V)}")
print(f" items needing the paper's Methods: {counts.get('not in the release', 0)}")
print()
print(f"2. PREREGISTRATION: 10 numbered items, SHA-256 {PLAN_DIGEST}, written at {PLAN_WRITTEN_AT} before "
f"any recording was downloaded")
print()
print(f"3. THE PLANNED ANALYSIS ({len(a)} PD, {len(b)} Control, first-in-file-order rule; "
f"{L6.RESTING_MEASURE['name']}):")
print(f" PD {a.mean():.4f} +- {a.std(ddof=1):.4f}; Control {b.mean():.4f} +- {b.std(ddof=1):.4f}; "
f"difference {a.mean() - b.mean():+.4f}")
print(f" Welch t = {t_obs:.4f}, p = {p_par:.4f}; permutation p = {p_perm:.4f} ({N_PERM} permutations)")
print(f" Hedges' g = {g['g']:+.4f}, 95 % CI [{g['ci'][0]:+.4f}, {g['ci'][1]:+.4f}]")
print(f" age: PD {np.nanmean(age_pd):.1f} y vs Control {np.nanmean(age_hc):.1f} y, p = {p_age:.4f}; "
f"age-adjusted group estimate {beta[1]:+.5f}, p = {p_cov[1]:.4f}")
print()
print(f"4. THE EXPLORATORY MULTIVERSE ({len(rpaths)} variants, {len(mv)} participants):")
print(f" {int(sig.sum())} of {int(fin.sum())} reach p < {ALPHA}; g spans {np.nanmin(gs):+.4f} to "
f"{np.nanmax(gs):+.4f}, median {np.nanmedian(gs):+.4f}")
print(f" signs: {int((gs[fin] > 0).sum())} positive / {int((gs[fin] < 0).sum())} negative")
print(f" the plan's variant: g = {gs[i_plan]:+.4f}, p = {ps[i_plan]:.4f}")
print(f" decision with the widest spread of option medians: "
+ max(spread, key=lambda k: max(spread[k]) - min(spread[k]))
+ f" ({max(max(v) - min(v) for v in spread.values()):.3f} in g)")
print()
print("TODO(confirm) for the author, in order of importance:")
print(" 1. Every 'NOT IN THE RELEASE' verdict in section 2 is a statement about the SHARED MATERIAL, not")
print(" about the paper. A reviewer with the PDF should convert each one to a real verdict, and the")
print(" wording should make clear which ones the paper does answer.")
print(" 2. This notebook quotes no published result from Anjum et al. (2024). If the course wants a")
print(" comparison with the paper's own numbers, they must be read from the paper and added to the")
print(" catalog first (section 10.11), not supplied from memory.")
print(" 3. helpers_l6.REPORTING_CHECKLIST is written from the reporting themes spec section 6 L6.6 names.")
print(" It is not a transcription of COBIDAS-MEEG, which the site's reading list has not verified.")
print()
print("Rubric: audit specific and fair (section 2); plan predates the analysis (section 3, digest "
f"{PLAN_DIGEST[:16]}...); divergences explained (section 6).")