Skip to content

ENH(icanclean): threshold='null' — derive the rejection cut from the data - #76

Merged
BabaSanfour merged 5 commits into
mainfrom
feat/icanclean-null-threshold
Aug 25, 2026
Merged

ENH(icanclean): threshold='null' — derive the rejection cut from the data#76
BabaSanfour merged 5 commits into
mainfrom
feat/icanclean-null-threshold

Conversation

@snesmaeili

Copy link
Copy Markdown
Contributor

Stacked on #74 (icanclean-pseudo-ref), so the diff here is only the null-threshold work. GitHub will retarget this to main automatically once #74 merges.

The problem

threshold is 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):

reference construction max R² median R² components ≥ 0.25
dual-layer (physical noise electrodes) 0.024 0.0013 0 of 120
pseudo-reference (band-stopped copy) 0.825 0.642 119 of 120

The same number means entirely different things — the medians differ by ~500×. A grid of 0.25 … 0.95 removes 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 fixed threshold=0.85 removes 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:

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. 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_threshold are range-checked. Previously any parseable value was accepted: 5.0 silently made the estimator a pass-through, -1 flagged every component, and the string "0.5" was accepted and compared against floats.
  • _reset_qc_attrs leak fixedglobal_n_windows_ and sliding_n_windows_ were not cleared, so stale hybrid counters survived a re-fit in another mode while every sibling global_*/sliding_* attribute was correctly cleared.
  • New QC: max_r2_, thresholds_, samples_per_variable_. Without these, n_removed_ == 0 is indistinguishable from "the threshold was above every achievable R²" — the distinction that decides whether a null result is about the data or the configuration.
  • Changelog correction'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

  • Band-content component selection. Prototyped following the preprint's suggestion of "examining the frequency content of the candidate noise components". It selects 1 of 30 components and is dominated by simpler options — a preservation-biased operating point, not a Pareto improvement. Recorded as a negative result rather than shipped.
  • Presets / recommended thresholds. The operating points measured are dataset-specific; shipping them as defaults would repeat the mistake this PR exists to fix.

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.

@snesmaeili
snesmaeili requested a review from BabaSanfour August 16, 2026 23:50
@snesmaeili

Copy link
Copy Markdown
Contributor Author

Note for review: relationship to #58

#58 (RecursiveICanClean, draft) and mode='hybrid' — which this PR documents — are often read as the same idea. They are not, and docs/icanclean.md now says so explicitly.

The 2023 paper raises two possibilities in a single sentence:

"Further research is needed to determine if performance can be improved by incorporating larger windows of data. If there proves to be a benefit to using more data, then it is possible we could implement CCA recursively in the future."

They sit on different axes:

axis which half of the sentence
mode='hybrid' (existing) two-pass batch — global subspace, then sliding refinement larger windows
RecursiveICanClean (#58) online/causal streaming, incrementally updated moments recursive CCA

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.

@BabaSanfour
BabaSanfour force-pushed the icanclean-pseudo-ref branch 2 times, most recently from 9d6d422 to d2fd1df Compare August 24, 2026 13:33
Base automatically changed from icanclean-pseudo-ref to main August 24, 2026 13:50
snesmaeili and others added 3 commits August 25, 2026 12:07
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.
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.58333% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.09%. Comparing base (c8486a9) to head (bc5ada4).

Files with missing lines Patch % Lines
mne_denoise/icanclean/core.py 89.58% 4 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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              
Flag Coverage Δ
unittests 95.09% <89.58%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
mne_denoise/icanclean/core.py 93.46% <89.58%> (-0.42%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@BabaSanfour
BabaSanfour merged commit 092bfea into main Aug 25, 2026
16 checks passed
@BabaSanfour
BabaSanfour deleted the feat/icanclean-null-threshold branch August 25, 2026 12:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants