Skip to content

[Parity] Implement Phase 7 long-tail helpers - #150

Merged
suraj-ranganath merged 2 commits into
feature/eeglab-core-parity-completionfrom
phase/7-long-tail-core-helpers
Jun 6, 2026
Merged

[Parity] Implement Phase 7 long-tail helpers#150
suraj-ranganath merged 2 commits into
feature/eeglab-core-parity-completionfrom
phase/7-long-tail-core-helpers

Conversation

@suraj-ranganath

Copy link
Copy Markdown
Member

Add Phase 7 legacy pop wrappers, ICA and rejection helper aliases, help resources, tests, and parity matrix triage for long-tail core EEGLAB coverage. Remaining time-warping, exact correct_mc, PAC, and STUDY long-tail items are tracked in follow-up issues #146-#149.

Fixes #138
Part of #131

@claude

claude Bot commented Jun 6, 2026

Copy link
Copy Markdown

Claude finished @suraj-ranganath's task in 6m 53s —— View job


Code review

  • Overall assessment: Solid, well-scoped parity work. A few EEGLAB numerical-parity deviations should be tightened before merge.
  • Highest-risk area: ICA/statistics helpers (kurt, eeg_pvaf, realproba) where exact EEGLAB numerics matter.
  • Merge recommendation: needs minor changes (parity), then safe to merge.

I reviewed the diff against origin/feature/eeglab-core-parity-completion (single commit ca7ddb1). The vendored src/eegprep/eeglab/*.m references are not present in this checkout, so parity is assessed from EEGLAB behavior + static analysis. I was unable to run the test suite (uv run pytest requires interactive approval in this sandbox), so test results below are from reading only.

I checked imports/symbol resolution (_rejection, _pop_utils, misc.finite_matmul, pop_comperp/pop_chansel/pop_reref/pop_select signatures) — all resolve, and the lazy-export wiring in __init__.py matches the new modules.


Blocking

None.

Important

  • kurt.py:19-22 — wrong moment estimator (parity). Uses np.std(..., ddof=1); EEGLAB kurt.m uses population moments (mean(x.^4)./mean(x.^2).^2 - 3). Result is scaled by ((N-1)/N)**2. Fix posted inline. Fix this →

  • ica_helpers.py _eeg_pv_common (≈166-185) — icachansind ignored (parity/correctness). Channel data uses all nbchan rows while winv/activations follow EEG.icachansind. For subset-ICA datasets (EOG/ref excluded), winv[selected_chans, :] misaligns or raises IndexError. EEGLAB indexes EEG.data(EEG.icachansind,:). Also a test gap — only the full-channel identity case is covered. Details inline.

Nits

  • realproba.py:15-16 — default bins (parity). EEGLAB defaults to 1000, not round(N/5). Only affects direct callers (the entropy_rej path always passes discret). Fix posted inline.

Test gaps

  • eeg_pvaf/eeg_pv: add a case where icachansind is a strict subset of channels (and icawinv has fewer rows than nbchan) to lock in EEGLAB-aligned channel handling.
  • kurt: add a small-N numeric check against the EEGLAB formula N*sum(x^4)/sum(x^2)^2 - 3 so the moment estimator can't silently regress.

EEGLAB parity notes

  • pop_rejchanspec._spectrum computes a single raw periodogram (10*log10(|rfft|**2)) rather than EEGLAB's spectopo (Welch PSD). The relative stdthresh path is fairly robust to this, but absthresh dB thresholds won't line up with EEGLAB's spectopo scaling. Worth a note in the docstring or a follow-up if exact absthresh parity is expected.
  • pop_fusechanrej._common_channel_labels keeps the first dataset's label casing for common, then re-selects every dataset with that list. This relies on eeg_decodechan matching labels case-insensitively across datasets with differing casing — fine for typical consistent-casing datasets, but worth confirming.
  • The remaining wrappers (pop_averef, pop_compareerps, pop_topochansel, pop_findmatchingcomps, pop_icathresh, icaact/icaproj/icavar/compvar, eegthresh) read as faithful ports with correct 1-based↔0-based handling and EEGLAB-style history strings.
    · branch phase/7-long-tail-core-helpers

@suraj-ranganath

Copy link
Copy Markdown
Member Author

🤖 Specification for the Phase 7 parity diff.

Problem
Phase 7 still had audit-approved rows for legacy pop_* entry points and long-tail helpers after Phases 2-6. The gaps were split between likely user-typed aliases, helper calculations used by rejection/ICA workflows, and many MATLAB-only or stale public files that should not become EEGPrep runtime bloat. The parity matrix also needed every remaining port/partial row to have either an implementation/classification or a concrete follow-up issue.

Approach
The branch adds thin wrappers in src/eegprep/functions/popfunc for pop_averef, pop_compareerps, pop_findmatchingcomps, pop_fusechanrej, pop_icathresh, pop_rejchanspec, and pop_topochansel. Shared ICA projection behavior lives in src/eegprep/functions/sigprocfunc/ica_helpers.py, with same-name compatibility modules for EEGLAB-style imports. Rejection helpers live in sigprocfunc and reuse existing EEGPrep rejection helpers where available. The matrix now classifies stale/runtime/external rows explicitly and links follow-up issues #146-#149 for remaining real work.

Key code

def _history_command(options: dict[str, Any], match_maps: np.ndarray) -> str:
    pieces: list[str] = []
    history_options = dict(options)
    if "dataset" in history_options:
        history_options.pop("dataset", None)
        history_options.pop("alleeg", None)
        history_options["matchcomps"] = match_maps
    for key in ("dataset", "matchcomps", "corrthresh", "rejflag", "nomatcherror"):
        if key in history_options:
            value = history_options[key]
            pieces.extend([format_history_value(key), format_history_value(value, cell_for_sequence=None)])
    return "EEG = pop_findmatchingcomps(EEG, " + ", ".join(pieces) + ");"

Tests
Focused tests cover wrapper delegation/history, console auto-store behavior for pop_averef, ICA projection/PVAF helpers, rejection helper outputs, spectral channel rejection, channel selection, and dataset-derived component matching. Broader verification includes the parity matrix validator, ruff, ty, changed-from pre-commit, and the non-slow pytest suite with MATLAB skipped.

Comment on lines +19 to +22
centered = values - np.mean(values, axis=0, keepdims=True)
std = np.std(values, axis=0, ddof=1 if values.shape[0] > 1 else 0)
std[std == 0] = np.inf
return np.sum(centered**4, axis=0) / (std**4) / values.shape[0] - 3.0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: important (EEGLAB parity)

This does not match EEGLAB's kurt.m, which uses population (biased) moments:

data = mean(data.^4) ./ (mean(data.^2).^2) - 3;   % == N*sum(x^4)/sum(x^2)^2 - 3

Here np.std(..., ddof=1) uses the sample variance (/(N-1)), so the result is scaled by ((N-1)/N)**2 relative to EEGLAB — a systematic deviation (small for large N, larger for short windows). Since this is a parity port, the moment estimator should match exactly.

Suggested change
centered = values - np.mean(values, axis=0, keepdims=True)
std = np.std(values, axis=0, ddof=1 if values.shape[0] > 1 else 0)
std[std == 0] = np.inf
return np.sum(centered**4, axis=0) / (std**4) / values.shape[0] - 3.0
centered = values - np.mean(values, axis=0, keepdims=True)
m2 = np.mean(centered**2, axis=0)
m2[m2 == 0] = np.inf
return np.mean(centered**4, axis=0) / m2**2 - 3.0

Comment on lines +15 to +16
bins = max(1, round(flat.size / 5))
if flat.size == 0:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: nit (EEGLAB parity)

EEGLAB's realproba.m defaults bins to 1000, not round(N/5). This only affects direct callers (the entropy_rej path always passes discret, default 1000), but for parity the default should match EEGLAB.

Suggested change
bins = max(1, round(flat.size / 5))
if flat.size == 0:
if bins is None:
bins = 1000

Comment on lines +166 to +175
raw_data = data_3d(EEG)
data = raw_data.reshape(int(EEG.get("nbchan", 0) or raw_data.shape[0]), -1, order="F")
acts = eeg_getica(EEG)
acts_2d = acts.reshape(acts.shape[0], -1, order="F")
winv = np.asarray(EEG.get("icawinv", []), dtype=float)
if winv.ndim != 2 or winv.size == 0:
weights = np.asarray(EEG.get("icaweights", []), dtype=float)
sphere = np.asarray(EEG.get("icasphere", []), dtype=float)
winv = np.linalg.pinv(finite_matmul(weights, sphere))
selected_chans = _selected_channels(data.shape[0], chans=chans, omitchans=omitchans)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: important (EEGLAB parity / correctness)

data is built from all nbchan rows, but winv (from EEG.icawinv) and the component activations correspond to EEG.icachansind. EEGLAB's eeg_pvaf indexes the channel data through EEG.icachansind (EEG.data(EEG.icachansind,:)). When ICA was decomposed on a channel subset (e.g. excluding EOG/ref), winv has fewer rows than nbchan, so winv[selected_chans, :] and winv[np.ix_(selected_chans, art)] will index out of range or misalign with data_selected, producing wrong PVAF or an IndexError.

The current test only covers the full-channel identity case, so this gap isn't caught. Suggest restricting the channel data to EEG.icachansind before computing variance, and mapping selected_chans/omitchans relative to that index set, mirroring EEGLAB.

@suraj-ranganath
suraj-ranganath merged commit 56d8e54 into feature/eeglab-core-parity-completion Jun 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant