Skip to content

Commit 856dfe2

Browse files
ENH: Streamline ICA reporting (#899)
Co-authored-by: Richard Höchenberger <[email protected]>
1 parent cbeeb98 commit 856dfe2

File tree

12 files changed

+96
-78
lines changed

12 files changed

+96
-78
lines changed

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ jobs:
5050
pip install --upgrade --progress-bar off pip
5151
pip install --upgrade --progress-bar off "autoreject @ https://api.github.com/repos/autoreject/autoreject/zipball/master" "mne[hdf5] @ git+https://github.com/mne-tools/mne-python@main" "mne-bids[full] @ https://api.github.com/repos/mne-tools/mne-bids/zipball/main" numba
5252
pip install -ve .[tests]
53-
pip install "PyQt6!=6.6.1,!=6.6.2" "PyQt6-Qt6!=6.6.1,!=6.6.2"
53+
pip install "PyQt6!=6.6.1" "PyQt6-Qt6!=6.6.1,!=6.6.2,!=6.6.3"
5454
- run:
5555
name: Check Qt
5656
command: |

.circleci/remove_examples.sh

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/bin/bash
2+
3+
set -eo pipefail
4+
5+
VER=$1
6+
if [ -z "$VER" ]; then
7+
echo "Usage: $0 <version>"
8+
exit 1
9+
fi
10+
ROOT="$PWD/$VER/examples/"
11+
if [ ! -d ${ROOT} ]; then
12+
echo "Version directory does not exist or appears incorrect:"
13+
echo
14+
echo "$ROOT"
15+
echo
16+
echo "Are you on the gh-pages branch and is the ds000117 directory present?"
17+
exit 1
18+
fi
19+
if [ ! -d ${ROOT}ds000117 ]; then
20+
echo "Directory does not exist:"
21+
echo
22+
echo "${ROOT}ds000117"
23+
echo
24+
echo "Assuming already pruned and exiting."
25+
exit 0
26+
fi
27+
echo "Pruning examples in ${ROOT} ..."
28+
29+
find $ROOT -type d -name "*" | tail -n +2 | xargs rm -Rf
30+
find $ROOT -name "*.html" -exec sed -i /^\<h2\ id=\"generated-output\"\>Generated/,/^\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ $/{d} {} \;
31+
find $ROOT -name "*.html" -exec sed -i '/^ <a href="#generated-output"/,/^ <\/a>$/{d}' {} \;
32+
33+
echo "Done"

.circleci/setup_bash.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ echo 'export MPLBACKEND=Agg' >> "$BASH_ENV"
5555
echo "source ~/python_env/bin/activate" >> "$BASH_ENV"
5656
echo "export MNE_3D_OPTION_MULTI_SAMPLES=1" >> "$BASH_ENV"
5757
echo "export MNE_BIDS_PIPELINE_FORCE_TERMINAL=true" >> "$BASH_ENV"
58+
echo "export FORCE_COLOR=1" >> "$BASH_ENV" # for rich to use color in logs
5859
mkdir -p ~/.local/bin
5960
if [[ ! -f ~/.local/bin/python ]]; then
6061
ln -s ~/python_env/bin/python ~/.local/bin/python

docs/source/examples/gen_examples.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ def _gen_demonstrated_funcs(example_config_path: Path) -> dict:
109109
datasets_without_html.append(dataset_name)
110110
continue
111111

112+
# For ERP_CORE, cut down on what we show otherwise our website is huge
113+
if "ERP_CORE" in test_name:
114+
show = ["015", "average"]
115+
orig_fnames = html_report_fnames
116+
html_report_fnames = [
117+
f
118+
for f in html_report_fnames
119+
if any(f"sub-{s}" in f.parts and f"sub-{s}" in f.name for s in show)
120+
]
121+
assert len(html_report_fnames), orig_fnames
122+
del orig_fnames
123+
112124
fname_iter = tqdm(
113125
html_report_fnames,
114126
desc=f" {test_name}",

docs/source/v1.9.md.inc

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88

99
### :warning: Behavior changes
1010

11-
- Changed default for `source_info_path_update` to `None`. In `_04_make_forward.py`
12-
and `_05_make_inverse.py`, we retrieve the info from the file from which
11+
- All ICA HTML reports have been consolidated in the standard subject `*_report.html`
12+
file instead of producing separate files (#899 by @larsoner).
13+
- Changed default for `source_info_path_update` to `None`. In `_04_make_forward.py`
14+
and `_05_make_inverse.py`, we retrieve the info from the file from which
1315
the `noise_cov` is computed (#919 by @SophieHerbst)
1416
- The [`depth`][mne_bids_pipeline._config.depth] parameter doesn't accept `None`
1517
anymore. Please use `0` instead. (#915 by @hoechenberger)
@@ -31,6 +33,7 @@
3133
bad channels from the first pipeline run. Now, we ensure that the original bad channels would be used and the
3234
related section is removed from the report in this case. (#902 by @larsoner)
3335
- Fixed group-average decoding statistics were not updated in some cases, even if relevant configuration options had been changed. (#902 by @larsoner)
36+
- Fixed a compatibility bug with joblib 1.4.0
3437
3538
### :medical_symbol: Code health and infrastructure
3639

mne_bids_pipeline/_config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,16 @@
11261126
EOG and ECG activity will be omitted during the signal reconstruction step in
11271127
order to remove the artifacts. The ICA procedure can be configured in various
11281128
ways using the configuration options you can find below.
1129+
1130+
!!! warning "ICA requires manual intervention!"
1131+
After the automatic ICA component detection step, review each subject's
1132+
`*_report.html` report file check if the set of ICA components to be removed
1133+
is correct. Adjustments should be made to the `*_proc-ica_components.tsv`
1134+
file, which will then be used in the step that is applied during ICA.
1135+
1136+
ICA component order can be considered arbitrary, so any time the ICA is
1137+
re-fit – i.e., if you change any parameters that affect steps prior to
1138+
ICA fitting – this file will need to be updated!
11291139
"""
11301140

11311141
min_ecg_epochs: Annotated[int, Ge(1)] = 5

mne_bids_pipeline/_run.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,12 @@ def wrapper(*args, **kwargs):
277277

278278
# https://joblib.readthedocs.io/en/latest/memory.html#joblib.memory.MemorizedFunc.call # noqa: E501
279279
if force_run or unknown_inputs or bad_out_files:
280-
out_files, _ = memorized_func.call(*args, **kwargs)
280+
# Joblib 1.4.0 only returns the output, but 1.3.2 returns both.
281+
# Fortunately we can use tuple-ness to tell the difference (we always
282+
# return None or a dict)
283+
out_files = memorized_func.call(*args, **kwargs)
284+
if isinstance(out_files, tuple):
285+
out_files = out_files[0]
281286
else:
282287
out_files = memorized_func(*args, **kwargs)
283288
if self.require_output:

mne_bids_pipeline/steps/preprocessing/_06a1_fit_ica.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,6 @@ def run_ica(
8181
out_files["epochs"] = (
8282
out_files["ica"].copy().update(suffix="epo", processing="icafit")
8383
)
84-
out_files["report"] = bids_basename.copy().update(
85-
processing="icafit", suffix="report", extension=".h5"
86-
)
8784
del bids_basename
8885

8986
# Generate a list of raw data paths (i.e., paths of individual runs)
@@ -247,17 +244,23 @@ def run_ica(
247244
logger.info(**gen_log_kwargs(message=msg))
248245
ica.save(out_files["ica"], overwrite=True)
249246

250-
# Start a report
247+
# Add to report
248+
tags = ("ica", "epochs")
249+
title = "ICA: epochs for fitting"
251250
with _open_report(
252251
cfg=cfg,
253252
exec_params=exec_params,
254253
subject=subject,
255254
session=session,
256255
task=cfg.task,
257-
fname_report=out_files["report"],
258-
name="ICA.fit report",
259256
) as report:
260-
report.title = f"ICA – {report.title}"
257+
report.add_epochs(
258+
epochs=epochs,
259+
title=title,
260+
drop_log_ignore=(),
261+
replace=True,
262+
tags=tags,
263+
)
261264
if cfg.ica_reject == "autoreject_local":
262265
caption = (
263266
f"Autoreject was run to produce cleaner epochs before fitting ICA. "
@@ -274,19 +277,14 @@ def run_ica(
274277
)
275278
report.add_figure(
276279
fig=fig,
277-
title="Epochs: Autoreject cleaning",
280+
title="Autoreject cleaning",
281+
section=title,
278282
caption=caption,
279-
tags=("ica", "epochs", "autoreject"),
283+
tags=tags + ("autoreject",),
280284
replace=True,
281285
)
282286
plt.close(fig)
283287
del caption
284-
report.add_epochs(
285-
epochs=epochs,
286-
title="Epochs used for ICA fitting",
287-
drop_log_ignore=(),
288-
replace=True,
289-
)
290288
return _prep_out_files(exec_params=exec_params, out_files=out_files)
291289

292290

mne_bids_pipeline/steps/preprocessing/_06a2_find_ica_artifacts.py

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
run the apply_ica step.
88
"""
99

10-
import shutil
1110
from types import SimpleNamespace
1211
from typing import Literal
1312

@@ -113,9 +112,6 @@ def get_input_fnames_find_ica_artifacts(
113112
)
114113
_update_for_splits(in_files, key, single=True)
115114
in_files["ica"] = bids_basename.copy().update(processing="icafit", suffix="ica")
116-
in_files["report"] = bids_basename.copy().update(
117-
processing="icafit", suffix="report", extension=".h5"
118-
)
119115
return in_files
120116

121117

@@ -143,9 +139,6 @@ def find_ica_artifacts(
143139
out_files_components = bids_basename.copy().update(
144140
processing="ica", suffix="components", extension=".tsv"
145141
)
146-
out_files["report"] = bids_basename.copy().update(
147-
processing="ica+components", suffix="report", extension=".h5"
148-
)
149142
del bids_basename
150143
msg = "Loading ICA solution"
151144
logger.info(**gen_log_kwargs(message=msg))
@@ -297,41 +290,31 @@ def find_ica_artifacts(
297290
ecg_scores = None if len(ecg_scores) == 0 else ecg_scores
298291
eog_scores = None if len(eog_scores) == 0 else eog_scores
299292

300-
shutil.copyfile(in_files.pop("report"), out_files["report"])
293+
title = "ICA: components"
301294
with _open_report(
302295
cfg=cfg,
303296
exec_params=exec_params,
304297
subject=subject,
305298
session=session,
306299
task=cfg.task,
307-
fname_report=out_files["report"],
308-
name="ICA report",
309300
) as report:
301+
logger.info(**gen_log_kwargs(message=f'Adding "{title}" to report.'))
310302
report.add_ica(
311303
ica=ica,
312-
title="ICA cleaning",
304+
title=title,
313305
inst=epochs,
314306
ecg_evoked=ecg_evoked,
315307
eog_evoked=eog_evoked,
316308
ecg_scores=ecg_scores,
317309
eog_scores=eog_scores,
318310
replace=True,
319311
n_jobs=1, # avoid automatic parallelization
312+
tags=("ica",), # the default but be explicit
320313
)
321314

322-
msg = (
323-
f"ICA completed. Please carefully review the extracted ICs in the "
324-
f"report {out_files['report'].basename}, and mark all components "
325-
f"you wish to reject as 'bad' in "
326-
f"{out_files_components.basename}"
327-
)
328-
logger.info(**gen_log_kwargs(message=msg))
329-
330-
report.save(
331-
out_files["report"],
332-
overwrite=True,
333-
open_browser=exec_params.interactive,
334-
)
315+
msg = 'Carefully review the extracted ICs and mark components "bad" in:'
316+
logger.info(**gen_log_kwargs(message=msg, emoji="🛑"))
317+
logger.info(**gen_log_kwargs(message=str(out_files_components), emoji="🛑"))
335318

336319
assert len(in_files) == 0, in_files.keys()
337320
return _prep_out_files(exec_params=exec_params, out_files=out_files)

mne_bids_pipeline/steps/preprocessing/_08a_apply_ica.py

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import mne
1111
import pandas as pd
1212
from mne.preprocessing import read_ica
13-
from mne.report import Report
1413
from mne_bids import BIDSPath
1514

1615
from ..._config_utils import (
@@ -21,7 +20,7 @@
2120
from ..._import_data import _get_run_rest_noise_path, _import_data_kwargs
2221
from ..._logging import gen_log_kwargs, logger
2322
from ..._parallel import get_parallel_backend, parallel_func
24-
from ..._report import _add_raw, _agg_backend, _open_report
23+
from ..._report import _add_raw, _open_report
2524
from ..._run import _prep_out_files, _update_for_splits, failsafe_run, save_logs
2625

2726

@@ -116,12 +115,8 @@ def apply_ica_epochs(
116115
session: str | None,
117116
in_files: dict,
118117
) -> dict:
119-
bids_basename = in_files["ica"].copy().update(processing=None)
120118
out_files = dict()
121119
out_files["epochs"] = in_files["epochs"].copy().update(processing="ica", split=None)
122-
out_files["report"] = bids_basename.copy().update(
123-
processing="ica", suffix="report", extension=".html"
124-
)
125120

126121
title = f"ICA artifact removal – sub-{subject}"
127122
if session is not None:
@@ -156,32 +151,6 @@ def apply_ica_epochs(
156151
split_size=cfg._epochs_split_size,
157152
)
158153
_update_for_splits(out_files, "epochs")
159-
160-
# Compare ERP/ERF before and after ICA artifact rejection. The evoked
161-
# response is calculated across ALL epochs, just like ICA was run on
162-
# all epochs, regardless of their respective experimental condition.
163-
#
164-
# We apply baseline correction here to (hopefully!) make the effects of
165-
# ICA easier to see. Otherwise, individual channels might just have
166-
# arbitrary DC shifts, and we wouldn't be able to easily decipher what's
167-
# going on!
168-
report = Report(out_files["report"], title=title, verbose=False)
169-
picks = ica.exclude if ica.exclude else None
170-
with _agg_backend():
171-
report.add_ica(
172-
ica=ica,
173-
title="Effects of ICA cleaning",
174-
inst=epochs.copy().apply_baseline(cfg.baseline),
175-
picks=picks,
176-
replace=True,
177-
n_jobs=1, # avoid automatic parallelization
178-
)
179-
report.save(
180-
out_files["report"],
181-
overwrite=True,
182-
open_browser=exec_params.interactive,
183-
)
184-
185154
assert len(in_files) == 0, in_files.keys()
186155

187156
# Report
@@ -198,18 +167,18 @@ def apply_ica_epochs(
198167
exec_params=exec_params,
199168
subject=subject,
200169
session=session,
201-
name="ICA.apply report",
202170
) as report:
203171
report.add_ica(
204172
ica=ica,
205-
title="ICA",
173+
title="ICA: removals",
206174
inst=epochs,
207175
picks=ica.exclude,
208176
# TODO upstream
209177
# captions=f'Evoked response (across all epochs) '
210178
# f'before and after ICA '
211179
# f'({len(ica.exclude)} ICs removed)'
212180
replace=True,
181+
n_jobs=1, # avoid automatic parallelization
213182
)
214183

215184
return _prep_out_files(exec_params=exec_params, out_files=out_files)

0 commit comments

Comments
 (0)