Coverage for nilearn/glm/contrasts.py: 11%

209 statements  

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

1"""Contrast computation and operation on contrast to \ 

2obtain fixed effect results. 

3""" 

4 

5from warnings import warn 

6 

7import numpy as np 

8import pandas as pd 

9import scipy.stats as sps 

10 

11from nilearn._utils import logger, rename_parameters 

12from nilearn._utils.logger import find_stack_level 

13from nilearn.glm._utils import pad_contrast, z_score 

14from nilearn.maskers import NiftiMasker, SurfaceMasker 

15from nilearn.surface import SurfaceImage 

16 

17DEF_TINY = 1e-50 

18DEF_DOFMAX = 1e10 

19 

20 

21def expression_to_contrast_vector(expression, design_columns): 

22 """Convert a string describing a :term:`contrast` \ 

23 to a :term:`contrast` vector. 

24 

25 Parameters 

26 ---------- 

27 expression : :obj:`str` 

28 The expression to convert to a vector. 

29 

30 design_columns : :obj:`list` or array of :obj:`str` 

31 The column names of the design matrix. 

32 

33 """ 

34 if expression in design_columns: 

35 contrast_vector = np.zeros(len(design_columns)) 

36 contrast_vector[list(design_columns).index(expression)] = 1.0 

37 return contrast_vector 

38 

39 eye_design = pd.DataFrame( 

40 np.eye(len(design_columns)), columns=design_columns 

41 ) 

42 try: 

43 contrast_vector = eye_design.eval( 

44 expression, engine="python" 

45 ).to_numpy() 

46 except Exception: 

47 raise ValueError( 

48 f"The expression ({expression}) is not valid. " 

49 "This could be due to " 

50 "defining the contrasts using design matrix columns that are " 

51 "invalid python identifiers." 

52 ) 

53 

54 return contrast_vector 

55 

56 

57@rename_parameters( 

58 replacement_params={"contrast_type": "stat_type"}, end_version="0.13.0" 

59) 

60def compute_contrast(labels, regression_result, con_val, stat_type=None): 

61 """Compute the specified :term:`contrast` given an estimated glm. 

62 

63 Parameters 

64 ---------- 

65 labels : array of shape (n_voxels,) 

66 A map of values on voxels used to identify the corresponding model 

67 

68 regression_result : :obj:`dict` 

69 With keys corresponding to the different labels 

70 values are RegressionResults instances corresponding to the voxels. 

71 

72 con_val : numpy.ndarray of shape (p) or (q, p) 

73 Where q = number of :term:`contrast` vectors 

74 and p = number of regressors. 

75 

76 stat_type : {None, 't', 'F'}, default=None 

77 Type of the :term:`contrast`. 

78 If None, then defaults to 't' for 1D `con_val` 

79 and 'F' for 2D `con_val`. 

80 

81 contrast_type : 

82 

83 .. deprecated:: 0.10.3 

84 

85 Use ``stat_type`` instead (see above). 

86 

87 Returns 

88 ------- 

89 con : Contrast instance, 

90 Yields the statistics of the :term:`contrast` 

91 (:term:`effects<Parameter Estimate>`, variance, p-values). 

92 

93 """ 

94 con_val = np.asarray(con_val) 

95 dim = 1 

96 if con_val.ndim > 1: 

97 dim = con_val.shape[0] 

98 

99 if stat_type is None: 

100 stat_type = "t" if dim == 1 else "F" 

101 

102 acceptable_stat_types = ["t", "F"] 

103 if stat_type not in acceptable_stat_types: 

104 raise ValueError( 

105 f"'{stat_type}' is not a known contrast type. " 

106 f"Allowed types are {acceptable_stat_types}." 

107 ) 

108 

109 if stat_type == "t": 

110 effect_ = np.zeros(labels.size) 

111 var_ = np.zeros(labels.size) 

112 for label_ in regression_result: 

113 label_mask = labels == label_ 

114 reg = regression_result[label_].Tcontrast(con_val) 

115 effect_[label_mask] = reg.effect.T 

116 var_[label_mask] = (reg.sd**2).T 

117 

118 elif stat_type == "F": 

119 from scipy.linalg import sqrtm 

120 

121 effect_ = np.zeros((dim, labels.size)) 

122 var_ = np.zeros(labels.size) 

123 # TODO 

124 # explain why we cannot simply do 

125 # reg = regression_result[label_].Tcontrast(con_val) 

126 # like above or refactor the code so it can be done 

127 for label_ in regression_result: 

128 label_mask = labels == label_ 

129 reg = regression_result[label_] 

130 con_val = pad_contrast( 

131 con_val=con_val, theta=reg.theta, stat_type=stat_type 

132 ) 

133 cbeta = np.atleast_2d(np.dot(con_val, reg.theta)) 

134 invcov = np.linalg.inv( 

135 np.atleast_2d(reg.vcov(matrix=con_val, dispersion=1.0)) 

136 ) 

137 wcbeta = np.dot(sqrtm(invcov), cbeta) 

138 rss = reg.dispersion 

139 effect_[:, label_mask] = wcbeta 

140 var_[label_mask] = rss 

141 

142 dof_ = regression_result[label_].df_residuals 

143 return Contrast( 

144 effect=effect_, 

145 variance=var_, 

146 dim=dim, 

147 dof=dof_, 

148 stat_type=stat_type, 

149 ) 

150 

151 

152def compute_fixed_effect_contrast(labels, results, con_vals, stat_type=None): 

153 """Compute the summary contrast assuming fixed effects. 

154 

155 Adds the same contrast applied to all labels and results lists. 

156 

157 """ 

158 contrast = None 

159 n_contrasts = 0 

160 for i, (lab, res, con_val) in enumerate(zip(labels, results, con_vals)): 

161 if np.all(con_val == 0): 

162 warn( 

163 f"Contrast for run {int(i)} is null.", 

164 stacklevel=find_stack_level(), 

165 ) 

166 continue 

167 contrast_ = compute_contrast(lab, res, con_val, stat_type) 

168 contrast = contrast_ if contrast is None else contrast + contrast_ 

169 n_contrasts += 1 

170 if contrast is None: 

171 raise ValueError("All contrasts provided were null contrasts.") 

172 return contrast * (1.0 / n_contrasts) 

173 

174 

175class Contrast: 

176 """The contrast class handles the estimation \ 

177 of statistical :term:`contrasts<contrast>` \ 

178 on a given model: student (t) or Fisher (F). 

179 

180 The important feature is that it supports addition, 

181 thus opening the possibility of fixed-effects models. 

182 

183 The current implementation is meant to be simple, 

184 and could be enhanced in the future on the computational side 

185 (high-dimensional F :term:`contrasts<contrast>` 

186 may lead to memory breakage). 

187 

188 Parameters 

189 ---------- 

190 effect : array of shape (contrast_dim, n_voxels) 

191 The effects related to the :term:`contrast`. 

192 

193 variance : array of shape (n_voxels) 

194 The associated variance estimate. 

195 

196 dim : :obj:`int` or None, optional 

197 The dimension of the :term:`contrast`. 

198 

199 dof : scalar, default=DEF_DOFMAX 

200 The degrees of freedom of the residuals. 

201 

202 stat_type : {'t', 'F'}, default='t' 

203 Specification of the :term:`contrast` type. 

204 

205 contrast_type : 

206 

207 .. deprecated:: 0.10.3 

208 

209 Use ``stat_type`` instead (see above). 

210 

211 tiny : :obj:`float`, default=DEF_TINY 

212 Small quantity used to avoid numerical underflows. 

213 

214 dofmax : scalar, default=DEF_DOFMAX 

215 The maximum degrees of freedom of the residuals. 

216 """ 

217 

218 @rename_parameters( 

219 replacement_params={"contrast_type": "stat_type"}, end_version="0.13.0" 

220 ) 

221 def __init__( 

222 self, 

223 effect, 

224 variance, 

225 dim=None, 

226 dof=DEF_DOFMAX, 

227 stat_type="t", 

228 tiny=DEF_TINY, 

229 dofmax=DEF_DOFMAX, 

230 ): 

231 if variance.ndim != 1: 

232 raise ValueError("Variance array should have 1 dimension") 

233 if effect.ndim > 2: 

234 raise ValueError("Effect array should have 1 or 2 dimensions") 

235 

236 self.effect = effect 

237 self.variance = variance 

238 self.dof = float(dof) 

239 if dim is None: 

240 self.dim = effect.shape[0] if effect.ndim == 2 else 1 

241 else: 

242 self.dim = dim 

243 

244 if self.dim > 1 and stat_type == "t": 

245 logger.log( 

246 "Automatically converted multi-dimensional t to F contrast" 

247 ) 

248 stat_type = "F" 

249 if stat_type not in ["t", "F"]: 

250 raise ValueError( 

251 f"{stat_type} is not a valid stat_type. Should be t or F" 

252 ) 

253 self.stat_type = stat_type 

254 self.stat_ = None 

255 self.p_value_ = None 

256 self.one_minus_pvalue_ = None 

257 self.baseline = 0 

258 self.tiny = tiny 

259 self.dofmax = dofmax 

260 

261 @property 

262 def contrast_type(self): 

263 """Return value of stat_type. 

264 

265 .. deprecated:: 0.10.3 

266 """ 

267 attrib_deprecation_msg = ( 

268 'The attribute "contrast_type" ' 

269 "will be removed in 0.13.0 release of Nilearn. " 

270 'Please use the attribute "stat_type" instead.' 

271 ) 

272 warn( 

273 category=DeprecationWarning, 

274 message=attrib_deprecation_msg, 

275 stacklevel=find_stack_level(), 

276 ) 

277 return self.stat_type 

278 

279 def effect_size(self): 

280 """Make access to summary statistics more straightforward \ 

281 when computing contrasts. 

282 """ 

283 return self.effect 

284 

285 def effect_variance(self): 

286 """Make access to summary statistics more straightforward \ 

287 when computing contrasts. 

288 """ 

289 return self.variance 

290 

291 def stat(self, baseline=0.0): 

292 """Return the decision statistic associated with the test of the \ 

293 null hypothesis: (H0) 'contrast equals baseline'. 

294 

295 Parameters 

296 ---------- 

297 baseline : :obj:`float`, default=0.0 

298 Baseline value for the test statistic. 

299 

300 Returns 

301 ------- 

302 stat : 1-d array, shape=(n_voxels,) 

303 statistical values, one per voxel. 

304 

305 """ 

306 self.baseline = baseline 

307 

308 # Case: one-dimensional contrast ==> t or t**2 

309 if self.stat_type == "F": 

310 stat = ( 

311 np.sum((self.effect - baseline) ** 2, 0) 

312 / self.dim 

313 / np.maximum(self.variance, self.tiny) 

314 ) 

315 elif self.stat_type == "t": 

316 # avoids division by zero 

317 stat = (self.effect - baseline) / np.sqrt( 

318 np.maximum(self.variance, self.tiny) 

319 ) 

320 else: 

321 raise ValueError("Unknown statistic type") 

322 self.stat_ = stat.ravel() 

323 return self.stat_ 

324 

325 def p_value(self, baseline=0.0): 

326 """Return a parametric estimate of the p-value associated with \ 

327 the null hypothesis (H0): 'contrast equals baseline', \ 

328 using the survival function. 

329 

330 Parameters 

331 ---------- 

332 baseline : :obj:`float`, default=0.0 

333 Baseline value for the test statistic. 

334 

335 

336 Returns 

337 ------- 

338 p_values : 1-d array, shape=(n_voxels,) 

339 p-values, one per voxel 

340 

341 """ 

342 if self.stat_ is None or self.baseline != baseline: 

343 self.stat_ = self.stat(baseline) 

344 # Valid conjunction as in Nichols et al, Neuroimage 25, 2005. 

345 if self.stat_type == "t": 

346 p_values = sps.t.sf(self.stat_, np.minimum(self.dof, self.dofmax)) 

347 elif self.stat_type == "F": 

348 p_values = sps.f.sf( 

349 self.stat_, self.dim, np.minimum(self.dof, self.dofmax) 

350 ) 

351 else: 

352 raise ValueError("Unknown statistic type") 

353 self.p_value_ = p_values 

354 return p_values 

355 

356 def one_minus_pvalue(self, baseline=0.0): 

357 """Return a parametric estimate of the 1 - p-value associated \ 

358 with the null hypothesis (H0): 'contrast equals baseline', \ 

359 using the cumulative distribution function, \ 

360 to ensure numerical stability. 

361 

362 Parameters 

363 ---------- 

364 baseline : :obj:`float`, default=0.0 

365 Baseline value for the test statistic. 

366 

367 

368 Returns 

369 ------- 

370 one_minus_pvalues : 1-d array, shape=(n_voxels,) 

371 one_minus_pvalues, one per voxel 

372 

373 """ 

374 if self.stat_ is None or self.baseline != baseline: 

375 self.stat_ = self.stat(baseline) 

376 # Valid conjunction as in Nichols et al, Neuroimage 25, 2005. 

377 if self.stat_type == "t": 

378 one_minus_pvalues = sps.t.cdf( 

379 self.stat_, np.minimum(self.dof, self.dofmax) 

380 ) 

381 else: 

382 assert self.stat_type == "F" 

383 one_minus_pvalues = sps.f.cdf( 

384 self.stat_, self.dim, np.minimum(self.dof, self.dofmax) 

385 ) 

386 self.one_minus_pvalue_ = one_minus_pvalues 

387 return one_minus_pvalues 

388 

389 def z_score(self, baseline=0.0): 

390 """Return a parametric estimation of the z-score associated \ 

391 with the null hypothesis: (H0) 'contrast equals baseline'. 

392 

393 Parameters 

394 ---------- 

395 baseline : :obj:`float`, default=0.0 

396 Baseline value for the test statistic. 

397 

398 

399 Returns 

400 ------- 

401 z_score : 1-d array, shape=(n_voxels,) 

402 statistical values, one per voxel 

403 

404 """ 

405 if self.p_value_ is None or self.baseline != baseline: 

406 self.p_value_ = self.p_value(baseline) 

407 if self.one_minus_pvalue_ is None: 

408 self.one_minus_pvalue_ = self.one_minus_pvalue(baseline) 

409 

410 # Avoid inf values kindly supplied by scipy. 

411 self.z_score_ = z_score( 

412 self.p_value_, one_minus_pvalue=self.one_minus_pvalue_ 

413 ) 

414 return self.z_score_ 

415 

416 def __add__(self, other): 

417 """Add two contrast, Yields an new Contrast instance. 

418 

419 This should be used only on independent contrasts. 

420 """ 

421 if self.stat_type != other.stat_type: 

422 raise ValueError( 

423 "The two contrasts do not have consistent type dimensions" 

424 ) 

425 if self.dim != other.dim: 

426 raise ValueError( 

427 "The two contrasts do not have compatible dimensions" 

428 ) 

429 dof_ = self.dof + other.dof 

430 if self.stat_type == "F": 

431 warn( 

432 "Running approximate fixed effects on F statistics.", 

433 category=UserWarning, 

434 stacklevel=find_stack_level(), 

435 ) 

436 effect_ = self.effect + other.effect 

437 variance_ = self.variance + other.variance 

438 return Contrast( 

439 effect=effect_, 

440 variance=variance_, 

441 dim=self.dim, 

442 dof=dof_, 

443 stat_type=self.stat_type, 

444 ) 

445 

446 def __rmul__(self, scalar): 

447 """Multiply a contrast by a scalar.""" 

448 scalar = float(scalar) 

449 effect_ = self.effect * scalar 

450 variance_ = self.variance * scalar**2 

451 dof_ = self.dof 

452 return Contrast( 

453 effect=effect_, 

454 variance=variance_, 

455 dof=dof_, 

456 stat_type=self.stat_type, 

457 ) 

458 

459 __mul__ = __rmul__ 

460 

461 def __div__(self, scalar): 

462 return self.__rmul__(1 / float(scalar)) 

463 

464 

465def compute_fixed_effects( 

466 contrast_imgs, 

467 variance_imgs, 

468 mask=None, 

469 precision_weighted=False, 

470 dofs=None, 

471 return_z_score=False, 

472): 

473 """Compute the fixed effects, given images of effects and variance. 

474 

475 Parameters 

476 ---------- 

477 contrast_imgs : :obj:`list` of Nifti1Images or :obj:`str`\ 

478 or :obj:`~nilearn.surface.SurfaceImage` 

479 The input contrast images. 

480 

481 variance_imgs : :obj:`list` of Nifti1Images or :obj:`str` \ 

482 or :obj:`~nilearn.surface.SurfaceImage` 

483 The input variance images. 

484 

485 mask : Nifti1Image or NiftiMasker instance or \ 

486 :obj:`~nilearn.maskers.SurfaceMasker` instance \ 

487 or None, default=None 

488 Mask image. If ``None``, it is recomputed from ``contrast_imgs``. 

489 

490 precision_weighted : :obj:`bool`, default=False 

491 Whether fixed effects estimates should be weighted by inverse 

492 variance or not. 

493 

494 dofs : array-like or None, default=None 

495 the degrees of freedom of the models with ``len = len(variance_imgs)`` 

496 when ``None``, 

497 it is assumed that the degrees of freedom are 100 per input. 

498 

499 return_z_score : :obj:`bool`, default=False 

500 Whether ``fixed_fx_z_score_img`` should be output or not. 

501 

502 Returns 

503 ------- 

504 fixed_fx_contrast_img : Nifti1Image or :obj:`~nilearn.surface.SurfaceImage` 

505 The fixed effects contrast computed within the mask. 

506 

507 fixed_fx_variance_img : Nifti1Image or :obj:`~nilearn.surface.SurfaceImage` 

508 The fixed effects variance computed within the mask. 

509 

510 fixed_fx_stat_img : Nifti1Image or :obj:`~nilearn.surface.SurfaceImage` 

511 The fixed effects stat computed within the mask. 

512 

513 fixed_fx_z_score_img : Nifti1Image, optional 

514 The fixed effects corresponding z-transform 

515 

516 Warns 

517 ----- 

518 DeprecationWarning 

519 Starting in version 0.13, fixed_fx_z_score_img will always be returned 

520 

521 """ 

522 n_runs = len(contrast_imgs) 

523 if n_runs != len(variance_imgs): 

524 raise ValueError( 

525 f"The number of contrast images ({len(contrast_imgs)}) differs " 

526 f"from the number of variance images ({len(variance_imgs)})." 

527 ) 

528 

529 if isinstance(mask, (NiftiMasker, SurfaceMasker)): 

530 masker = mask.fit() 

531 elif mask is None: 

532 if isinstance(contrast_imgs[0], SurfaceImage): 

533 masker = SurfaceMasker().fit(contrast_imgs[0]) 

534 else: 

535 masker = NiftiMasker().fit(contrast_imgs) 

536 elif isinstance(mask, SurfaceImage): 

537 masker = SurfaceMasker(mask_img=mask).fit(contrast_imgs[0]) 

538 else: 

539 masker = NiftiMasker(mask_img=mask).fit() 

540 

541 variances = np.array( 

542 [masker.transform(vi).squeeze() for vi in variance_imgs] 

543 ) 

544 contrasts = np.array( 

545 [masker.transform(ci).squeeze() for ci in contrast_imgs] 

546 ) 

547 

548 if dofs is None: 

549 dofs = [100] * n_runs 

550 

551 elif len(dofs) != n_runs: 

552 raise ValueError( 

553 f"The number of degrees of freedom ({len(dofs)}) " 

554 f"differs from the number of contrast images ({n_runs})." 

555 ) 

556 ( 

557 fixed_fx_contrast, 

558 fixed_fx_variance, 

559 fixed_fx_stat, 

560 fixed_fx_z_score, 

561 ) = _compute_fixed_effects_params( 

562 contrasts, variances, precision_weighted, dofs 

563 ) 

564 

565 fixed_fx_contrast_img = masker.inverse_transform(fixed_fx_contrast) 

566 fixed_fx_variance_img = masker.inverse_transform(fixed_fx_variance) 

567 fixed_fx_stat_img = masker.inverse_transform(fixed_fx_stat) 

568 fixed_fx_z_score_img = masker.inverse_transform(fixed_fx_z_score) 

569 warn( 

570 category=DeprecationWarning, 

571 message="The behavior of this function will be " 

572 "changed in release 0.13 to have an additional " 

573 "return value 'fixed_fx_z_score_img' by default. " 

574 "Please set return_z_score to True.", 

575 stacklevel=find_stack_level(), 

576 ) 

577 if return_z_score: 

578 return ( 

579 fixed_fx_contrast_img, 

580 fixed_fx_variance_img, 

581 fixed_fx_stat_img, 

582 fixed_fx_z_score_img, 

583 ) 

584 else: 

585 return fixed_fx_contrast_img, fixed_fx_variance_img, fixed_fx_stat_img 

586 

587 

588def _compute_fixed_effects_params( 

589 contrasts, variances, precision_weighted, dofs 

590): 

591 """Compute the fixed effects t/F-statistic, contrast, variance, \ 

592 given arrays of effects and variance. 

593 """ 

594 tiny = 1.0e-16 

595 contrasts, variances = np.asarray(contrasts), np.asarray(variances) 

596 variances = np.maximum(variances, tiny) 

597 

598 if precision_weighted: 

599 weights = 1.0 / variances 

600 fixed_fx_variance = 1.0 / np.sum(weights, 0) 

601 fixed_fx_contrasts = np.sum(contrasts * weights, 0) * fixed_fx_variance 

602 else: 

603 fixed_fx_variance = np.mean(variances, 0) / len(variances) 

604 fixed_fx_contrasts = np.mean(contrasts, 0) 

605 dim = 1 

606 stat_type = "t" 

607 fixed_fx_contrasts_ = fixed_fx_contrasts 

608 if len(fixed_fx_contrasts.shape) == 2: 

609 dim = fixed_fx_contrasts.shape[0] 

610 if dim > 1: 

611 stat_type = "F" 

612 else: 

613 fixed_fx_contrasts_ = fixed_fx_contrasts 

614 

615 con = Contrast( 

616 effect=fixed_fx_contrasts_, 

617 variance=fixed_fx_variance, 

618 dim=dim, 

619 dof=np.sum(dofs), 

620 stat_type=stat_type, 

621 ) 

622 fixed_fx_z_score = con.z_score() 

623 fixed_fx_stat = con.stat_ 

624 

625 return ( 

626 fixed_fx_contrasts, 

627 fixed_fx_variance, 

628 fixed_fx_stat, 

629 fixed_fx_z_score, 

630 )