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

1"""Functions for generating BIDS-compliant GLM outputs.""" 

2 

3import inspect 

4import json 

5import warnings 

6from collections.abc import Iterable 

7from pathlib import Path 

8 

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 

15 

16 

17def _generate_model_metadata(out_file, model): 

18 """Generate a sidecar JSON file containing model metadata. 

19 

20 .. versionadded:: 0.9.2 

21 

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. 

32 

33 model_metadata = { 

34 "Description": "A statistical map generated by Nilearn.", 

35 "ModelParameters": model._attributes_to_dict(), 

36 } 

37 

38 with Path(out_file).open("w") as f_obj: 

39 json.dump(model_metadata, f_obj, indent=4, sort_keys=True) 

40 

41 

42def _generate_dataset_description(out_file, model_level): 

43 """Generate a BIDS dataset_description.json file with relevant metadata. 

44 

45 .. versionadded:: 0.9.2 

46 

47 If the dataset_description already exists only the GeneratedBy section 

48 is extended. 

49 

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" 

58 

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 } 

65 

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 } 

77 

78 with out_file.open("w") as f_obj: 

79 json.dump(dataset_description, f_obj, indent=4, sort_keys=True) 

80 

81 

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. 

93 

94 .. versionadded:: 0.9.2 

95 

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. 

101 

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. 

105 

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. 

109 

110 Arrays may be 1D or 2D, with 1D arrays typically being 

111 t-contrasts and 2D arrays typically being F-contrasts. 

112 

113 %(first_level_contrast)s 

114 

115 .. versionadded:: 0.11.2dev 

116 

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. 

128 

129 out_dir : :obj:`str` or :obj:`pathlib.Path`, default="." 

130 Output directory for files. Default is current working directory. 

131 

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. 

135 

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``. 

141 

142 

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``. 

149 

150 Returns 

151 ------- 

152 model : :obj:`~nilearn.glm.first_level.FirstLevelModel` or \ 

153 :obj:`~nilearn.glm.second_level.SecondLevelModel` 

154 

155 .. versionadded:: 0.11.2dev 

156 

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. 

165 

166 Notes 

167 ----- 

168 This function writes files for the following: 

169 

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``) 

186 

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 ) 

194 

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 ) 

206 

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] 

221 

222 contrasts = coerce_to_dict(contrasts) 

223 

224 out_dir = Path(out_dir) 

225 out_dir.mkdir(exist_ok=True, parents=True) 

226 

227 dset_desc_file = out_dir / "dataset_description.json" 

228 _generate_dataset_description(dset_desc_file, model.__str__()) 

229 

230 model._generate_filenames_output( 

231 prefix, contrasts, contrast_types, out_dir 

232 ) 

233 

234 filenames = model._reporting_data["filenames"] 

235 

236 out_dir = filenames["dir"] 

237 out_dir.mkdir(exist_ok=True, parents=True) 

238 

239 verbose = model.verbose 

240 

241 model.masker_.mask_img_.to_filename(out_dir / filenames["mask"]) 

242 

243 if model.__str__() == "Second Level Model": 

244 design_matrices = [model.design_matrix_] 

245 else: 

246 design_matrices = model.design_matrices_ 

247 

248 if not isinstance(prefix, str): 

249 prefix = "" 

250 if prefix and not prefix.endswith("_"): 

251 prefix += "_" 

252 

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) 

258 

259 logger.log("Generating contrast matrices figures...", verbose=verbose) 

260 generate_contrast_matrices_figures( 

261 design_matrices, 

262 contrasts, 

263 output=filenames, 

264 ) 

265 

266 for i_run, design_matrix in enumerate(design_matrices): 

267 filename = Path( 

268 filenames["design_matrices_dict"][i_run]["design_matrix_tsv"] 

269 ) 

270 

271 # Save design matrix and associated figure 

272 design_matrix.to_csv( 

273 out_dir / filename, 

274 sep="\t", 

275 index=False, 

276 ) 

277 

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 ) 

286 

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) 

291 

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 

303 

304 img = contrast_maps[output_type] 

305 filename = filenames["statistical_maps"][contrast_name][ 

306 output_type 

307 ] 

308 img.to_filename(out_dir / filename) 

309 

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) 

330 

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 ) 

344 

345 logger.log("Saving model level statistical maps...", verbose=verbose) 

346 _write_model_level_statistical_maps(model, out_dir) 

347 

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") 

357 

358 return model 

359 

360 

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)