ENH(icanclean): threshold='null' — derive the rejection cut from the data - #76
Conversation
Note for review: relationship to #58#58 ( The 2023 paper raises two possibilities in a single sentence:
They sit on different axes:
So this PR's docs page makes two things explicit: that all four modes are batch — each sees the whole recording and none updates its decomposition sample by sample — and that a recursive formulation is a different design that this page does not evaluate. No hard reference to #58 in the page itself, since it is a draft marked "do not merge or mark ready" and a merged docs page should not dangle a pointer at something that may not land. The scoping stands on its own either way. One evidence note while it is relevant: the hybrid claim in this page is deliberately weak. Across 40 ERP CORE subjects hybrid removed more artifact than the best single-pass mode at every preservation floor, but every confidence interval included zero (e.g. +4.61 pp, 95% CI [−1.63, +10.86] at alpha ≥ 0.80). An earlier n=1 result looked much stronger and did not survive the cohort — worth knowing if #58 is assessed against a similar endpoint. |
9d6d422 to
d2fd1df
Compare
Brings back the pseudo-reference method removed in e38e8f5 without a changelog entry (issue #68), reimplemented against the refactored transform path rather than reverting the old patch. pseudo_ref=True derives the CCA reference block from the primary channels themselves: a copy is filtered with filter_ref and appended as the reference, so CCA correlates the EEG against a version of itself that keeps only out-of-band content. This is the method of Downey & Ferris 2023, Sensors 23(19):8214, for recordings without a dual-layer cap. Differences from the original 80b02e0 implementation: - Handles Epochs as well as continuous data. The old version indexed the channel axis as axis 0 unconditionally, which is wrong for the (n_epochs, n_channels, n_times) array Epochs produce. - ref_channels is optional when pseudo_ref=True, instead of being required and then worked around with nested try/except on _resolve_channels. - pseudo_ref without filter_ref now raises. An unfiltered copy of the primary block is perfectly correlated with itself, so every canonical correlation is 1.0 and the entire signal is removed -- silently, in the old version. - filter_ref is validated at construction, and a band edge at or above Nyquist raises instead of failing inside scipy. The regression test asserting these parameters raise TypeError is replaced with behavioural tests: band-stop selectivity, channel-count preservation, and attenuation of a sub-band drift. 55 tests pass (48 pre-existing, 7 new).
…data
`threshold` is an absolute R^2, but the achievable scale is set by the data and
by how the reference block is built. Measured on one recording (120 scalp + 120
outward-facing noise channels), the median R^2 is 0.0013 against a physical
dual-layer reference and 0.642 against a pseudo-reference — a ~500x difference
for the same parameter. Across five subjects of one homogeneous cohort the
achievable maximum still varies 3.4x. A constant tuned on one recording can
therefore become a silent no-op, or remove nearly everything, on the next.
A second failure compounds it. Canonical correlations are upward-biased when a
window is short relative to n_primary + n_reference; as that ratio approaches 1
every correlation approaches 1 whether or not anything is shared. A 2 s window at
250 Hz on a 120 + 120 montage — the shipped default on a common montage — gives a
ratio of 2.08, where a fixed threshold=0.85 removes roughly a quarter of the
components from data with nothing in common.
threshold='null' estimates the largest R^2 attributable to sampling noise for the
current window and channel counts, by recomputing the spectrum against circularly
shifted copies of the reference, and rejects only what exceeds it. Circular shift
rather than sample permutation: EEG is autocorrelated, and shuffling samples
yields surrogates that cannot reach the correlations real data reaches, i.e. an
anticonservative null.
Components falsely removed on independent AR(1) blocks, 40 + 40 channels:
n/(p+q) null thr 'null' fixed 0.65 fixed 0.85
1.2 0.999 0.20 20.4 14.4
2.0 0.992 0.12 16.8 10.6
10.0 0.904 0.00 11.4 2.6
300.0 0.124 0.04 0.0 0.0
The threshold self-adapts across that range with no input, and safety does not
come from timidity: with 0, 1, 3 and 8 injected shared components it recovers
exactly 0, 1, 3 and 8.
Scope: 'null' determines whether a component shares *real* variance with the
reference, not whether that variance is artifact. Against a pseudo-reference —
a band-stopped copy of the primary block — almost everything shares real
variance, so it selects broadly there. It solves the degeneracy problem, not the
selectivity problem, and the docstring says so.
Also in this change:
* threshold and global_threshold are range-checked. Previously any parseable
value was accepted, so threshold=5.0 silently made the estimator a
pass-through, threshold=-1 flagged every component, and the string "0.5" was
accepted and compared against floats.
* New fitted attributes max_r2_, thresholds_ and samples_per_variable_ record
what the data could actually reach, what cut was applied, and the
sample-to-variable ratio. Without them, n_removed_ == 0 is indistinguishable
from "the threshold was unreachable" — the distinction that decides whether a
null result is about the data or about the configuration.
* _reset_qc_attrs did not clear global_n_windows_ or sliding_n_windows_, so
stale hybrid counters survived a re-fit in another mode while every sibling
global_*/sliding_* attribute was correctly cleared.
* The changelog described 'calibrated' as "using a dedicated calibration
period". It has no such concept — it calibrates on the same data it cleans.
* Adds docs/icanclean.md, the module's first narrative documentation page.
Every sibling denoiser had one; this one did not.
The 2023 paper raises two possibilities in one sentence -- using larger windows, and computing CCA recursively. 'hybrid' addresses only the first: both of its passes see the entire recording and neither updates its decomposition as samples arrive. Saying so explicitly keeps this page from being read as an evaluation of recursive/online formulations, which sit on a different axis and are being explored separately.
3d69e57 to
7fb51ca
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #76 +/- ##
==========================================
- Coverage 95.12% 95.09% -0.03%
==========================================
Files 82 82
Lines 10208 10240 +32
Branches 1842 1846 +4
==========================================
+ Hits 9710 9738 +28
- Misses 250 254 +4
Partials 248 248
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Stacked on #74 (
icanclean-pseudo-ref), so the diff here is only the null-threshold work. GitHub will retarget this tomainautomatically once #74 merges.The problem
thresholdis an absolute R², but the achievable scale is set by the data and by how the reference block is built. Measured on one recording (120 scalp + 120 outward-facing noise channels):The same number means entirely different things — the medians differ by ~500×. A grid of
0.25 … 0.95removes exactly zero components in the dual-layer case, because the lowest value tested is already ten times the highest achievable correlation. The scale also moves 3.4× between subjects of a single homogeneous cohort.A second failure compounds it: canonical correlations are upward-biased when a window is short relative to
n_primary + n_reference. A 2 s window at 250 Hz on a 120 + 120 montage gives a ratio of 2.08, where a fixedthreshold=0.85removes roughly a quarter of the components from data with nothing in common. Both failures are silent —n_removed_looks plausible either way.The change
threshold='null'estimates the largest R² attributable to sampling noise for the current window and channel counts — by recomputing the spectrum against circularly shifted copies of the reference — and rejects only what exceeds it.Circular shift rather than sample permutation: EEG is autocorrelated, and shuffling samples produces surrogates that cannot reach the correlations real data reaches, i.e. an anticonservative null.
Components falsely removed on independent AR(1) blocks, 40 + 40 channels:
'null'The threshold self-adapts across that range with no input. Safety does not come from timidity: with 0, 1, 3 and 8 injected shared components it recovers exactly 0, 1, 3 and 8.
Scope.
'null'decides whether a component shares real variance with the reference, not whether that variance is artifact. Against a pseudo-reference almost everything shares real variance, so it selects broadly there. It solves the degeneracy problem, not the selectivity problem — the docstring says so.Also included
threshold/global_thresholdare range-checked. Previously any parseable value was accepted:5.0silently made the estimator a pass-through,-1flagged every component, and the string"0.5"was accepted and compared against floats._reset_qc_attrsleak fixed —global_n_windows_andsliding_n_windows_were not cleared, so stale hybrid counters survived a re-fit in another mode while every siblingglobal_*/sliding_*attribute was correctly cleared.max_r2_,thresholds_,samples_per_variable_. Without these,n_removed_ == 0is indistinguishable from "the threshold was above every achievable R²" — the distinction that decides whether a null result is about the data or the configuration.'calibrated'was described as "using a dedicated calibration period". It has no such concept; it calibrates on the same data it cleans.docs/icanclean.md— the module's first narrative documentation page. Every sibling denoiser (dss,asr,sns,ssa,bss_cca) had one; this did not.Deliberately not included
Testing
5 new tests: null accepts/validates, rejects nothing on independent blocks across three window lengths, recovers injected components exactly, QC records the evidence behind a zero, and the hybrid counter reset. 60 pass locally, ruff clean.
Closes nothing on its own; complements #74.