Coverage for nilearn/interfaces/bids/glm.py: 12%
101 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-20 10:58 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-20 10:58 +0200
1"""Functions for generating BIDS-compliant GLM outputs."""
3import inspect
4import json
5import warnings
6from collections.abc import Iterable
7from pathlib import Path
9from nilearn import __version__
10from nilearn._utils import logger
11from nilearn._utils.docs import fill_doc
12from nilearn._utils.glm import coerce_to_dict, make_stat_maps
13from nilearn._utils.helpers import is_matplotlib_installed
14from nilearn._utils.logger import find_stack_level
17def _generate_model_metadata(out_file, model):
18 """Generate a sidecar JSON file containing model metadata.
20 .. versionadded:: 0.9.2
22 Parameters
23 ----------
24 out_file : :obj:`str`
25 Output JSON filename, to be created by the function.
26 model : :obj:`~nilearn.glm.first_level.FirstLevelModel` or
27 :obj:`~nilearn.glm.second_level.SecondLevelModel`
28 First- or second-level model from which to save outputs.
29 """
30 # Define which FirstLevelModel attributes are BIDS compliant and which
31 # should be bundled in a new "ModelParameters" field.
33 model_metadata = {
34 "Description": "A statistical map generated by Nilearn.",
35 "ModelParameters": model._attributes_to_dict(),
36 }
38 with Path(out_file).open("w") as f_obj:
39 json.dump(model_metadata, f_obj, indent=4, sort_keys=True)
42def _generate_dataset_description(out_file, model_level):
43 """Generate a BIDS dataset_description.json file with relevant metadata.
45 .. versionadded:: 0.9.2
47 If the dataset_description already exists only the GeneratedBy section
48 is extended.
50 Parameters
51 ----------
52 out_file : :obj:`pathlib.Path`
53 Output JSON filename, to be created by the function.
54 model_level : str
55 The level of the model.
56 """
57 repo_url = "https://github.com/nilearn/nilearn"
59 GeneratedBy = {
60 "Name": "nilearn",
61 "Version": __version__,
62 "Description": (f"A Nilearn {model_level}-level GLM."),
63 "CodeURL": (f"{repo_url}/releases/tag/{__version__}"),
64 }
66 if out_file.exists():
67 with out_file.open() as f_obj:
68 dataset_description = json.load(f_obj)
69 if dataset_description.get("GeneratedBy"):
70 dataset_description["GeneratedBy"].append(GeneratedBy)
71 else:
72 dataset_description = {
73 "BIDSVersion": "1.9.0",
74 "DatasetType": "derivative",
75 "GeneratedBy": [GeneratedBy],
76 }
78 with out_file.open("w") as f_obj:
79 json.dump(dataset_description, f_obj, indent=4, sort_keys=True)
82@fill_doc
83def save_glm_to_bids(
84 model,
85 contrasts,
86 first_level_contrast=None,
87 contrast_types=None,
88 out_dir=".",
89 prefix=None,
90 **kwargs,
91):
92 """Save :term:`GLM` results to :term:`BIDS`-like files.
94 .. versionadded:: 0.9.2
96 Parameters
97 ----------
98 model : :obj:`~nilearn.glm.first_level.FirstLevelModel` or \
99 :obj:`~nilearn.glm.second_level.SecondLevelModel`
100 First- or second-level model from which to save outputs.
102 contrasts : :obj:`str` or array of shape (n_col) or :obj:`list` \
103 of (:obj:`str` or array of shape (n_col)) or :obj:`dict`
104 Contrast definitions.
106 If a dictionary is passed then it must be a dictionary of
107 'contrast name': 'contrast weight' key-value pairs.
108 The contrast weights may be strings, lists, or arrays.
110 Arrays may be 1D or 2D, with 1D arrays typically being
111 t-contrasts and 2D arrays typically being F-contrasts.
113 %(first_level_contrast)s
115 .. versionadded:: 0.11.2dev
117 contrast_types : None or :obj:`dict` of :obj:`str`, default=None
118 An optional dictionary mapping some
119 or all of the :term:`contrast` names to
120 specific contrast types ('t' or 'F').
121 If None, all :term:`contrast` types will
122 be automatically inferred based on the :term:`contrast` arrays
123 (1D arrays are t-contrasts, 2D arrays are F-contrasts).
124 Keys in this dictionary must match the keys in the ``contrasts``
125 dictionary, but only those contrasts
126 for which :term:`contrast` type must be
127 explicitly set need to be included.
129 out_dir : :obj:`str` or :obj:`pathlib.Path`, default="."
130 Output directory for files. Default is current working directory.
132 prefix : :obj:`str` or None, default=None
133 String to prepend to generated filenames.
134 If a string is provided, '_' will be added to the end.
136 For FirstLevelModel that used files as inputs at fit time,
137 and if ``prefix`` is ``None``,
138 the name of the output will be inferred from the input filenames
139 by trying to parse them as BIDS files.
140 This behavior can prevented by passing ``""`` as ``prefix``.
143 kwargs : extra keywords arguments to pass to ``model.generate_report``
144 See :func:`nilearn.reporting.make_glm_report` for more details.
145 Can be any of the following: ``title``, ``bg_img``, ``threshold``,
146 ``alpha``, ``cluster_threshold``, ``height_control``,
147 ``min_distance``, ``plot_type``, ``display_mode``,
148 ``two_sided``, ``cut_coords``.
150 Returns
151 -------
152 model : :obj:`~nilearn.glm.first_level.FirstLevelModel` or \
153 :obj:`~nilearn.glm.second_level.SecondLevelModel`
155 .. versionadded:: 0.11.2dev
157 Warnings
158 --------
159 The files generated by this function are a best approximation of
160 appropriate names for GLM-based BIDS derivatives.
161 However, BIDS does not currently have GLM-based derivatives supported in
162 the specification, and there is no guarantee that the files created by
163 this function will be BIDS-compatible if and when the specification
164 supports model derivatives.
166 Notes
167 -----
168 This function writes files for the following:
170 - Modeling software information (``dataset_description.json``)
171 - Model-level metadata (``statmap.json``)
172 - Model design matrix (``design.tsv``)
173 - Model design metadata (``design.json``)
174 - Model design matrix figure (``design.svg``)
175 - Model error (``stat-errorts_statmap.nii.gz``)
176 - Model r-squared (``stat-rsquared_statmap.nii.gz``)
177 - Contrast :term:`'parameter estimates'<Parameter Estimate>`
178 (``contrast-[name]_stat-effect_statmap.nii.gz``)
179 - Variance of the contrast parameter estimates
180 (``contrast-[name]_stat-variance_statmap.nii.gz``)
181 - Contrast test statistics
182 (``contrast-[name]_stat-[F|t]_statmap.nii.gz``)
183 - Contrast p- and z-values
184 (``contrast-[name]_stat-[p|z]_statmap.nii.gz``)
185 - Contrast weights figure (``contrast-[name]_design.svg``)
187 """
188 # Import here to avoid circular imports
189 from nilearn.glm import threshold_stats_img
190 from nilearn.reporting.get_clusters_table import (
191 clustering_params_to_dataframe,
192 get_clusters_table,
193 )
195 if is_matplotlib_installed():
196 from nilearn._utils.plotting import (
197 generate_contrast_matrices_figures,
198 generate_design_matrices_figures,
199 )
200 else:
201 warnings.warn(
202 ("No plotting backend detected. Output will be missing figures."),
203 UserWarning,
204 stacklevel=find_stack_level(),
205 )
207 # grab the default from generate_report()
208 # fail early if invalid parameters to pass to generate_report()
209 tmp = dict(**inspect.signature(model.generate_report).parameters)
210 tmp.pop("contrasts")
211 report_kwargs = {k: v.default for k, v in tmp.items()}
212 for key in kwargs:
213 if key not in report_kwargs:
214 raise ValueError(
215 f"Extra key-word arguments must be one of: "
216 f"{report_kwargs}\n"
217 f"Got: {key}"
218 )
219 else:
220 report_kwargs[key] = kwargs[key]
222 contrasts = coerce_to_dict(contrasts)
224 out_dir = Path(out_dir)
225 out_dir.mkdir(exist_ok=True, parents=True)
227 dset_desc_file = out_dir / "dataset_description.json"
228 _generate_dataset_description(dset_desc_file, model.__str__())
230 model._generate_filenames_output(
231 prefix, contrasts, contrast_types, out_dir
232 )
234 filenames = model._reporting_data["filenames"]
236 out_dir = filenames["dir"]
237 out_dir.mkdir(exist_ok=True, parents=True)
239 verbose = model.verbose
241 model.masker_.mask_img_.to_filename(out_dir / filenames["mask"])
243 if model.__str__() == "Second Level Model":
244 design_matrices = [model.design_matrix_]
245 else:
246 design_matrices = model.design_matrices_
248 if not isinstance(prefix, str):
249 prefix = ""
250 if prefix and not prefix.endswith("_"):
251 prefix += "_"
253 if is_matplotlib_installed():
254 logger.log("Generating design matrices figures...", verbose=verbose)
255 # TODO: Assuming that cases of multiple design matrices correspond to
256 # different runs. Not sure if this is correct. Need to check.
257 generate_design_matrices_figures(design_matrices, output=filenames)
259 logger.log("Generating contrast matrices figures...", verbose=verbose)
260 generate_contrast_matrices_figures(
261 design_matrices,
262 contrasts,
263 output=filenames,
264 )
266 for i_run, design_matrix in enumerate(design_matrices):
267 filename = Path(
268 filenames["design_matrices_dict"][i_run]["design_matrix_tsv"]
269 )
271 # Save design matrix and associated figure
272 design_matrix.to_csv(
273 out_dir / filename,
274 sep="\t",
275 index=False,
276 )
278 if model.__str__() == "First Level Model":
279 with (out_dir / filename.with_suffix(".json")).open("w") as f_obj:
280 json.dump(
281 {"RepetitionTime": model.t_r},
282 f_obj,
283 indent=4,
284 sort_keys=True,
285 )
287 # Model metadata
288 # TODO: Determine optimal mapping of model metadata to BIDS fields.
289 metadata_file = out_dir / f"{prefix}statmap.json"
290 _generate_model_metadata(metadata_file, model)
292 logger.log("Saving contrast-level statistical maps...", verbose=verbose)
293 statistical_maps = make_stat_maps(
294 model,
295 contrasts,
296 output_type="all",
297 first_level_contrast=first_level_contrast,
298 )
299 for contrast_name, contrast_maps in statistical_maps.items():
300 for output_type in contrast_maps:
301 if output_type in ["metadata", "results"]:
302 continue
304 img = contrast_maps[output_type]
305 filename = filenames["statistical_maps"][contrast_name][
306 output_type
307 ]
308 img.to_filename(out_dir / filename)
310 thresholded_img, threshold = threshold_stats_img(
311 stat_img=img,
312 threshold=report_kwargs["threshold"],
313 alpha=report_kwargs["alpha"],
314 cluster_threshold=report_kwargs["cluster_threshold"],
315 height_control=report_kwargs["height_control"],
316 )
317 table_details = clustering_params_to_dataframe(
318 report_kwargs["threshold"],
319 report_kwargs["cluster_threshold"],
320 report_kwargs["min_distance"],
321 report_kwargs["height_control"],
322 report_kwargs["alpha"],
323 is_volume_glm=model._is_volume_glm,
324 )
325 table_details = table_details.to_dict()
326 with (
327 out_dir / filenames["statistical_maps"][contrast_name]["metadata"]
328 ).open("w") as f:
329 json.dump(table_details[0], f)
331 cluster_table = get_clusters_table(
332 thresholded_img,
333 stat_threshold=threshold,
334 cluster_threshold=report_kwargs["cluster_threshold"],
335 min_distance=report_kwargs["min_distance"],
336 two_sided=report_kwargs["two_sided"],
337 )
338 cluster_table.to_csv(
339 out_dir
340 / filenames["statistical_maps"][contrast_name]["clusters_tsv"],
341 sep="\t",
342 index=False,
343 )
345 logger.log("Saving model level statistical maps...", verbose=verbose)
346 _write_model_level_statistical_maps(model, out_dir)
348 logger.log("Generating HTML...", verbose=verbose)
349 # generate_report can just rely on the name of the files
350 # stored in the model instance.
351 # temporarily drop verbosity to avoid generate_report
352 # logging the same thing
353 model.verbose -= 1
354 glm_report = model.generate_report(**kwargs)
355 model.verbose += 1
356 glm_report.save_as_html(out_dir / f"{prefix}report.html")
358 return model
361def _write_model_level_statistical_maps(model, out_dir):
362 for i_run, model_level_mapping in model._reporting_data["filenames"][
363 "model_level_mapping"
364 ].items():
365 for attr, map_name in model_level_mapping.items():
366 img = getattr(model, attr)
367 stat_map_to_save = img[i_run] if isinstance(img, Iterable) else img
368 stat_map_to_save.to_filename(out_dir / map_name)