Coverage for nilearn/maskers/base_masker.py: 19%

245 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-20 10:58 +0200

1"""Transformer used to apply basic transformations on :term:`fMRI` data.""" 

2 

3import abc 

4import contextlib 

5import itertools 

6import warnings 

7from collections.abc import Iterable 

8from copy import deepcopy 

9from pathlib import Path 

10 

11import numpy as np 

12import pandas as pd 

13from joblib import Memory 

14from sklearn.base import BaseEstimator, TransformerMixin 

15from sklearn.utils.estimator_checks import check_is_fitted 

16from sklearn.utils.validation import check_array 

17 

18from nilearn._utils import logger 

19from nilearn._utils.cache_mixin import CacheMixin, cache 

20from nilearn._utils.docs import fill_doc 

21from nilearn._utils.helpers import ( 

22 rename_parameters, 

23 stringify_path, 

24) 

25from nilearn._utils.logger import find_stack_level, log 

26from nilearn._utils.masker_validation import ( 

27 check_compatibility_mask_and_images, 

28) 

29from nilearn._utils.niimg import repr_niimgs, safe_get_data 

30from nilearn._utils.niimg_conversions import check_niimg 

31from nilearn._utils.numpy_conversions import csv_to_array 

32from nilearn._utils.tags import SKLEARN_LT_1_6 

33from nilearn.image import ( 

34 concat_imgs, 

35 high_variance_confounds, 

36 new_img_like, 

37 resample_img, 

38 smooth_img, 

39) 

40from nilearn.masking import load_mask_img, unmask 

41from nilearn.signal import clean 

42from nilearn.surface.surface import SurfaceImage, at_least_2d, check_surf_img 

43from nilearn.surface.utils import check_polymesh_equal 

44 

45 

46def filter_and_extract( 

47 imgs, 

48 extraction_function, 

49 parameters, 

50 memory_level=0, 

51 memory=None, 

52 verbose=0, 

53 confounds=None, 

54 sample_mask=None, 

55 copy=True, 

56 dtype=None, 

57): 

58 """Extract representative time series using given function. 

59 

60 Parameters 

61 ---------- 

62 imgs : 3D/4D Niimg-like object 

63 Images to be masked. Can be 3-dimensional or 4-dimensional. 

64 

65 extraction_function : function 

66 Function used to extract the time series from 4D data. This function 

67 should take images as argument and returns a tuple containing a 2D 

68 array with masked signals along with a auxiliary value used if 

69 returning a second value is needed. 

70 If any other parameter is needed, a functor or a partial 

71 function must be provided. 

72 

73 For all other parameters refer to NiftiMasker documentation 

74 

75 Returns 

76 ------- 

77 signals : 2D numpy array 

78 Signals extracted using the extraction function. It is a scikit-learn 

79 friendly 2D array with shape n_samples x n_features. 

80 

81 """ 

82 if memory is None: 

83 memory = Memory(location=None) 

84 # If we have a string (filename), we won't need to copy, as 

85 # there will be no side effect 

86 imgs = stringify_path(imgs) 

87 if isinstance(imgs, str): 

88 copy = False 

89 

90 log( 

91 f"Loading data from {repr_niimgs(imgs, shorten=False)}", 

92 verbose=verbose, 

93 ) 

94 

95 # Convert input to niimg to check shape. 

96 # This must be repeated after the shape check because check_niimg will 

97 # coerce 5D data to 4D, which we don't want. 

98 temp_imgs = check_niimg(imgs) 

99 

100 imgs = check_niimg(imgs, atleast_4d=True, ensure_ndim=4, dtype=dtype) 

101 

102 target_shape = parameters.get("target_shape") 

103 target_affine = parameters.get("target_affine") 

104 if target_shape is not None or target_affine is not None: 

105 log("Resampling images") 

106 

107 imgs = cache( 

108 resample_img, 

109 memory, 

110 func_memory_level=2, 

111 memory_level=memory_level, 

112 ignore=["copy"], 

113 )( 

114 imgs, 

115 interpolation="continuous", 

116 target_shape=target_shape, 

117 target_affine=target_affine, 

118 copy=copy, 

119 copy_header=True, 

120 force_resample=False, # set to True in 0.13.0 

121 ) 

122 

123 smoothing_fwhm = parameters.get("smoothing_fwhm") 

124 if smoothing_fwhm is not None: 

125 log("Smoothing images", verbose=verbose) 

126 

127 imgs = cache( 

128 smooth_img, 

129 memory, 

130 func_memory_level=2, 

131 memory_level=memory_level, 

132 )(imgs, parameters["smoothing_fwhm"]) 

133 

134 log("Extracting region signals", verbose=verbose) 

135 

136 region_signals, aux = cache( 

137 extraction_function, 

138 memory, 

139 func_memory_level=2, 

140 memory_level=memory_level, 

141 )(imgs) 

142 

143 # Temporal 

144 # -------- 

145 # Detrending (optional) 

146 # Filtering 

147 # Confounds removing (from csv file or numpy array) 

148 # Normalizing 

149 

150 log("Cleaning extracted signals", verbose=verbose) 

151 

152 runs = parameters.get("runs", None) 

153 region_signals = cache( 

154 clean, 

155 memory=memory, 

156 func_memory_level=2, 

157 memory_level=memory_level, 

158 )( 

159 region_signals, 

160 detrend=parameters["detrend"], 

161 standardize=parameters["standardize"], 

162 standardize_confounds=parameters["standardize_confounds"], 

163 t_r=parameters["t_r"], 

164 low_pass=parameters["low_pass"], 

165 high_pass=parameters["high_pass"], 

166 confounds=confounds, 

167 sample_mask=sample_mask, 

168 runs=runs, 

169 **parameters["clean_kwargs"], 

170 ) 

171 

172 if temp_imgs.ndim == 3: 

173 region_signals = region_signals.squeeze() 

174 

175 return region_signals, aux 

176 

177 

178def prepare_confounds_multimaskers(masker, imgs_list, confounds): 

179 """Check and prepare confounds for multimaskers.""" 

180 if confounds is None: 

181 confounds = list(itertools.repeat(None, len(imgs_list))) 

182 elif len(confounds) != len(imgs_list): 

183 raise ValueError( 

184 f"number of confounds ({len(confounds)}) unequal to " 

185 f"number of images ({len(imgs_list)})." 

186 ) 

187 

188 if masker.high_variance_confounds: 

189 for i, img in enumerate(imgs_list): 

190 hv_confounds = masker._cache(high_variance_confounds)(img) 

191 

192 if confounds[i] is None: 

193 confounds[i] = hv_confounds 

194 elif isinstance(confounds[i], list): 

195 confounds[i] += hv_confounds 

196 elif isinstance(confounds[i], np.ndarray): 

197 confounds[i] = np.hstack([confounds[i], hv_confounds]) 

198 elif isinstance(confounds[i], pd.DataFrame): 

199 confounds[i] = np.hstack( 

200 [confounds[i].to_numpy(), hv_confounds] 

201 ) 

202 elif isinstance(confounds[i], (str, Path)): 

203 c = csv_to_array(confounds[i]) 

204 if np.isnan(c.flat[0]): 

205 # There may be a header 

206 c = csv_to_array(confounds[i], skip_header=1) 

207 confounds[i] = np.hstack([c, hv_confounds]) 

208 else: 

209 confounds[i].append(hv_confounds) 

210 

211 return confounds 

212 

213 

214@fill_doc 

215class BaseMasker(TransformerMixin, CacheMixin, BaseEstimator): 

216 """Base class for NiftiMaskers.""" 

217 

218 @abc.abstractmethod 

219 @fill_doc 

220 def transform_single_imgs( 

221 self, imgs, confounds=None, sample_mask=None, copy=True 

222 ): 

223 """Extract signals from a single niimg. 

224 

225 Parameters 

226 ---------- 

227 imgs : 3D/4D Niimg-like object 

228 See :ref:`extracting_data`. 

229 Images to process. 

230 

231 %(confounds)s 

232 

233 %(sample_mask)s 

234 

235 .. versionadded:: 0.8.0 

236 

237 copy : :obj:`bool`, default=True 

238 Indicates whether a copy is returned or not. 

239 

240 Returns 

241 ------- 

242 %(signals_transform_nifti)s 

243 

244 """ 

245 raise NotImplementedError() 

246 

247 def _more_tags(self): 

248 """Return estimator tags. 

249 

250 TODO remove when bumping sklearn_version > 1.5 

251 """ 

252 return self.__sklearn_tags__() 

253 

254 def __sklearn_tags__(self): 

255 """Return estimator tags. 

256 

257 See the sklearn documentation for more details on tags 

258 https://scikit-learn.org/1.6/developers/develop.html#estimator-tags 

259 """ 

260 # TODO 

261 # get rid of if block 

262 # bumping sklearn_version > 1.5 

263 if SKLEARN_LT_1_6: 

264 from nilearn._utils.tags import tags 

265 

266 return tags(masker=True) 

267 

268 from nilearn._utils.tags import InputTags 

269 

270 tags = super().__sklearn_tags__() 

271 tags.input_tags = InputTags(masker=True) 

272 return tags 

273 

274 def fit(self, imgs=None, y=None): 

275 """Present only to comply with sklearn estimators checks.""" 

276 ... 

277 

278 def _load_mask(self, imgs): 

279 """Load and validate mask if one passed at init. 

280 

281 Returns 

282 ------- 

283 mask_img_ : None or 3D binary nifti 

284 """ 

285 if self.mask_img is None: 

286 # in this case 

287 # (Multi)Niftimasker will infer one from imaged to fit 

288 # other nifti maskers are OK with None 

289 return None 

290 

291 repr = repr_niimgs(self.mask_img, shorten=(not self.verbose)) 

292 msg = f"loading mask from {repr}" 

293 log(msg=msg, verbose=self.verbose) 

294 

295 # ensure that the mask_img_ is a 3D binary image 

296 tmp = check_niimg(self.mask_img, atleast_4d=True) 

297 mask = safe_get_data(tmp, ensure_finite=True) 

298 mask = mask.astype(bool).all(axis=3) 

299 mask_img_ = new_img_like(self.mask_img, mask) 

300 

301 # Just check that the mask is valid 

302 load_mask_img(mask_img_) 

303 if imgs is not None: 

304 check_compatibility_mask_and_images(self.mask_img, imgs) 

305 

306 return mask_img_ 

307 

308 @fill_doc 

309 def transform(self, imgs, confounds=None, sample_mask=None): 

310 """Apply mask, spatial and temporal preprocessing. 

311 

312 Parameters 

313 ---------- 

314 imgs : 3D/4D Niimg-like object 

315 See :ref:`extracting_data`. 

316 Images to process. 

317 If a 3D niimg is provided, a 1D array is returned. 

318 

319 %(confounds)s 

320 

321 %(sample_mask)s 

322 

323 .. versionadded:: 0.8.0 

324 

325 Returns 

326 ------- 

327 %(signals_transform_nifti)s 

328 """ 

329 check_is_fitted(self) 

330 

331 if confounds is None and not self.high_variance_confounds: 

332 return self.transform_single_imgs( 

333 imgs, confounds=confounds, sample_mask=sample_mask 

334 ) 

335 

336 # Compute high variance confounds if requested 

337 all_confounds = [] 

338 if self.high_variance_confounds: 

339 hv_confounds = self._cache(high_variance_confounds)(imgs) 

340 all_confounds.append(hv_confounds) 

341 if confounds is not None: 

342 if isinstance(confounds, list): 

343 all_confounds += confounds 

344 else: 

345 all_confounds.append(confounds) 

346 

347 return self.transform_single_imgs( 

348 imgs, confounds=all_confounds, sample_mask=sample_mask 

349 ) 

350 

351 @fill_doc 

352 @rename_parameters(replacement_params={"X": "imgs"}, end_version="0.13.2") 

353 def fit_transform( 

354 self, imgs, y=None, confounds=None, sample_mask=None, **fit_params 

355 ): 

356 """Fit to data, then transform it. 

357 

358 Parameters 

359 ---------- 

360 imgs : Niimg-like object 

361 See :ref:`extracting_data`. 

362 

363 y : numpy array of shape [n_samples], default=None 

364 Target values. 

365 

366 %(confounds)s 

367 

368 %(sample_mask)s 

369 

370 .. versionadded:: 0.8.0 

371 

372 Returns 

373 ------- 

374 %(signals_transform_nifti)s 

375 

376 """ 

377 # non-optimized default implementation; override when a better 

378 # method is possible for a given clustering algorithm 

379 if y is None: 

380 # fit method of arity 1 (unsupervised transformation) 

381 if self.mask_img is None: 

382 return self.fit(imgs, **fit_params).transform( 

383 imgs, confounds=confounds, sample_mask=sample_mask 

384 ) 

385 

386 return self.fit(**fit_params).transform( 

387 imgs, confounds=confounds, sample_mask=sample_mask 

388 ) 

389 

390 # fit method of arity 2 (supervised transformation) 

391 if self.mask_img is None: 

392 return self.fit(imgs, y, **fit_params).transform( 

393 imgs, confounds=confounds, sample_mask=sample_mask 

394 ) 

395 

396 warnings.warn( 

397 f"[{self.__class__.__name__}.fit] " 

398 "Generation of a mask has been" 

399 " requested (y != None) while a mask was" 

400 " given at masker creation. Given mask" 

401 " will be used.", 

402 stacklevel=find_stack_level(), 

403 ) 

404 return self.fit(**fit_params).transform( 

405 imgs, confounds=confounds, sample_mask=sample_mask 

406 ) 

407 

408 @fill_doc 

409 def inverse_transform(self, X): 

410 """Transform the data matrix back to an image in brain space. 

411 

412 This step only performs spatial unmasking, 

413 without inverting any additional processing performed by ``transform``, 

414 such as temporal filtering or smoothing. 

415 

416 Parameters 

417 ---------- 

418 %(x_inv_transform)s 

419 

420 Returns 

421 ------- 

422 %(img_inv_transform_nifti)s 

423 

424 """ 

425 check_is_fitted(self) 

426 

427 # do not run sklearn_check as they may cause some failure 

428 # with some GLM inputs 

429 X = self._check_array(X, sklearn_check=False) 

430 

431 img = self._cache(unmask)(X, self.mask_img_) 

432 # Be robust again memmapping that will create read-only arrays in 

433 # internal structures of the header: remove the memmaped array 

434 with contextlib.suppress(Exception): 

435 img._header._structarr = np.array(img._header._structarr).copy() 

436 return img 

437 

438 def _check_array( 

439 self, signals: np.ndarray, sklearn_check: bool = True 

440 ) -> np.ndarray: 

441 """Check array to inverse transform. 

442 

443 Parameters 

444 ---------- 

445 signals : :obj:`numpy.ndarray` 

446 

447 sklearn_check : :obj:`bool` 

448 Run scikit learn check on input 

449 """ 

450 signals = np.atleast_1d(signals) 

451 

452 if sklearn_check: 

453 signals = check_array(signals, ensure_2d=False) 

454 

455 assert signals.ndim <= 2 

456 

457 expected_shape = ( 

458 (self.n_elements_,) 

459 if signals.ndim == 1 

460 else (signals.shape[0], self.n_elements_) 

461 ) 

462 

463 if signals.shape != expected_shape: 

464 raise ValueError( 

465 "Input to 'inverse_transform' has wrong shape.\n" 

466 f"Expected {expected_shape}.\n" 

467 f"Got {signals.shape}." 

468 ) 

469 

470 return signals 

471 

472 def set_output(self, *, transform=None): 

473 """Set the output container when ``"transform"`` is called. 

474 

475 .. warning:: 

476 

477 This has not been implemented yet. 

478 """ 

479 raise NotImplementedError() 

480 

481 def _sanitize_cleaning_parameters(self): 

482 """Make sure that cleaning parameters are passed via clean_args. 

483 

484 TODO remove when bumping to nilearn >0.13 

485 """ 

486 if hasattr(self, "clean_kwargs"): 

487 if self.clean_kwargs: 

488 tmp = [", ".join(list(self.clean_kwargs))] 

489 warnings.warn( 

490 f"You passed some kwargs to {self.__class__.__name__}: " 

491 f"{tmp}. " 

492 "This behavior is deprecated " 

493 "and will be removed in version >0.13.", 

494 DeprecationWarning, 

495 stacklevel=find_stack_level(), 

496 ) 

497 if self.clean_args: 

498 raise ValueError( 

499 "Passing arguments via 'kwargs' " 

500 "is mutually exclusive with using 'clean_args'" 

501 ) 

502 self.clean_kwargs_ = { 

503 k[7:]: v 

504 for k, v in self.clean_kwargs.items() 

505 if k.startswith("clean__") 

506 } 

507 

508 

509class _BaseSurfaceMasker(TransformerMixin, CacheMixin, BaseEstimator): 

510 """Class from which all surface maskers should inherit.""" 

511 

512 def _more_tags(self): 

513 """Return estimator tags. 

514 

515 TODO remove when bumping sklearn_version > 1.5 

516 """ 

517 return self.__sklearn_tags__() 

518 

519 def __sklearn_tags__(self): 

520 """Return estimator tags. 

521 

522 See the sklearn documentation for more details on tags 

523 https://scikit-learn.org/1.6/developers/develop.html#estimator-tags 

524 """ 

525 # TODO 

526 # get rid of if block 

527 if SKLEARN_LT_1_6: 

528 from nilearn._utils.tags import tags 

529 

530 return tags(surf_img=True, niimg_like=False, masker=True) 

531 

532 from nilearn._utils.tags import InputTags 

533 

534 tags = super().__sklearn_tags__() 

535 tags.input_tags = InputTags( 

536 surf_img=True, niimg_like=False, masker=True 

537 ) 

538 return tags 

539 

540 def _check_imgs(self, imgs) -> None: 

541 if not ( 

542 isinstance(imgs, SurfaceImage) 

543 or ( 

544 hasattr(imgs, "__iter__") 

545 and all(isinstance(x, SurfaceImage) for x in imgs) 

546 ) 

547 ): 

548 raise TypeError( 

549 "'imgs' should be a SurfaceImage or " 

550 "an iterable of SurfaceImage." 

551 f"Got: {imgs.__class__.__name__}" 

552 ) 

553 

554 def _load_mask(self, imgs): 

555 """Load and validate mask if one passed at init. 

556 

557 Returns 

558 ------- 

559 mask_img_ : None or 1D binary SurfaceImage 

560 """ 

561 if self.mask_img is None: 

562 return None 

563 

564 mask_img_ = deepcopy(self.mask_img) 

565 

566 logger.log( 

567 msg=f"loading mask from {mask_img_.__repr__()}", 

568 verbose=self.verbose, 

569 ) 

570 

571 mask_img_ = at_least_2d(mask_img_) 

572 mask = {} 

573 for part, v in mask_img_.data.parts.items(): 

574 mask[part] = v 

575 non_finite_mask = np.logical_not(np.isfinite(mask[part])) 

576 if non_finite_mask.any(): 

577 warnings.warn( 

578 "Non-finite values detected. " 

579 "These values will be replaced with zeros.", 

580 stacklevel=find_stack_level(), 

581 ) 

582 mask[part][non_finite_mask] = 0 

583 mask[part] = mask[part].astype(bool).all(axis=1) 

584 

585 mask_img_ = new_img_like(self.mask_img, mask) 

586 

587 # Just check that the mask is valid 

588 load_mask_img(mask_img_) 

589 if imgs is not None: 

590 check_compatibility_mask_and_images(mask_img_, imgs) 

591 if not isinstance(imgs, Iterable): 

592 imgs = [imgs] 

593 for x in imgs: 

594 check_surf_img(x) 

595 check_polymesh_equal(mask_img_.mesh, x.mesh) 

596 

597 return mask_img_ 

598 

599 @rename_parameters( 

600 replacement_params={"img": "imgs"}, end_version="0.13.2" 

601 ) 

602 @fill_doc 

603 def transform(self, imgs, confounds=None, sample_mask=None): 

604 """Apply mask, spatial and temporal preprocessing. 

605 

606 Parameters 

607 ---------- 

608 imgs : :obj:`~nilearn.surface.SurfaceImage` object or \ 

609 iterable of :obj:`~nilearn.surface.SurfaceImage` 

610 Images to process. 

611 

612 %(confounds)s 

613 

614 %(sample_mask)s 

615 

616 Returns 

617 ------- 

618 %(signals_transform_surface)s 

619 """ 

620 check_is_fitted(self) 

621 self._check_imgs(imgs) 

622 

623 return_1D = isinstance(imgs, SurfaceImage) and len(imgs.shape) < 2 

624 

625 if not isinstance(imgs, list): 

626 imgs = [imgs] 

627 imgs = concat_imgs(imgs) 

628 check_surf_img(imgs) 

629 

630 check_compatibility_mask_and_images(self.mask_img_, imgs) 

631 

632 if self.smoothing_fwhm is not None: 

633 warnings.warn( 

634 "Parameter smoothing_fwhm " 

635 "is not yet supported for surface data", 

636 UserWarning, 

637 stacklevel=find_stack_level(), 

638 ) 

639 self.smoothing_fwhm = None 

640 

641 if self.reports: 

642 self._reporting_data["images"] = imgs 

643 

644 if confounds is None and not self.high_variance_confounds: 

645 signals = self.transform_single_imgs( 

646 imgs, confounds=confounds, sample_mask=sample_mask 

647 ) 

648 return signals.squeeze() if return_1D else signals 

649 

650 # Compute high variance confounds if requested 

651 all_confounds = [] 

652 

653 if self.high_variance_confounds: 

654 hv_confounds = self._cache(high_variance_confounds)(imgs) 

655 all_confounds.append(hv_confounds) 

656 

657 if confounds is not None: 

658 if isinstance(confounds, list): 

659 all_confounds += confounds 

660 else: 

661 all_confounds.append(confounds) 

662 

663 signals = self.transform_single_imgs( 

664 imgs, confounds=all_confounds, sample_mask=sample_mask 

665 ) 

666 

667 return signals.squeeze() if return_1D else signals 

668 

669 @abc.abstractmethod 

670 def transform_single_imgs(self, imgs, confounds=None, sample_mask=None): 

671 """Extract signals from a single surface image.""" 

672 # implemented in children classes 

673 raise NotImplementedError() 

674 

675 @rename_parameters( 

676 replacement_params={"img": "imgs"}, end_version="0.13.2" 

677 ) 

678 @fill_doc 

679 def fit_transform(self, imgs, y=None, confounds=None, sample_mask=None): 

680 """Prepare and perform signal extraction from regions. 

681 

682 Parameters 

683 ---------- 

684 imgs : :obj:`~nilearn.surface.SurfaceImage` object or \ 

685 :obj:`list` of :obj:`~nilearn.surface.SurfaceImage` or \ 

686 :obj:`tuple` of :obj:`~nilearn.surface.SurfaceImage` 

687 Mesh and data for both hemispheres. The data for each hemisphere \ 

688 is of shape (n_vertices_per_hemisphere, n_timepoints). 

689 

690 y : None 

691 This parameter is unused. 

692 It is solely included for scikit-learn compatibility. 

693 

694 %(confounds)s 

695 

696 %(sample_mask)s 

697 

698 

699 Returns 

700 ------- 

701 %(signals_transform_surface)s 

702 """ 

703 del y 

704 return self.fit(imgs).transform(imgs, confounds, sample_mask) 

705 

706 def _check_array( 

707 self, signals: np.ndarray, sklearn_check: bool = True 

708 ) -> np.ndarray: 

709 """Check array to inverse transform. 

710 

711 Parameters 

712 ---------- 

713 signals : :obj:`numpy.ndarray` 

714 

715 sklearn_check : :obj:`bool` 

716 Run scikit learn check on input 

717 """ 

718 signals = np.atleast_2d(signals) 

719 

720 if sklearn_check: 

721 signals = check_array(signals, ensure_2d=False) 

722 

723 if signals.shape[-1] != self.n_elements_: 

724 raise ValueError( 

725 "Input to 'inverse_transform' has wrong shape.\n" 

726 f"Last dimension should be {self.n_elements_}.\n" 

727 f"Got {signals.shape[-1]}." 

728 ) 

729 

730 return signals 

731 

732 def set_output(self, *, transform=None): 

733 """Set the output container when ``"transform"`` is called. 

734 

735 .. warning:: 

736 

737 This has not been implemented yet. 

738 """ 

739 raise NotImplementedError()