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
« 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"""
5from warnings import warn
7import numpy as np
8import pandas as pd
9import scipy.stats as sps
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
17DEF_TINY = 1e-50
18DEF_DOFMAX = 1e10
21def expression_to_contrast_vector(expression, design_columns):
22 """Convert a string describing a :term:`contrast` \
23 to a :term:`contrast` vector.
25 Parameters
26 ----------
27 expression : :obj:`str`
28 The expression to convert to a vector.
30 design_columns : :obj:`list` or array of :obj:`str`
31 The column names of the design matrix.
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
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 )
54 return contrast_vector
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.
63 Parameters
64 ----------
65 labels : array of shape (n_voxels,)
66 A map of values on voxels used to identify the corresponding model
68 regression_result : :obj:`dict`
69 With keys corresponding to the different labels
70 values are RegressionResults instances corresponding to the voxels.
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.
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`.
81 contrast_type :
83 .. deprecated:: 0.10.3
85 Use ``stat_type`` instead (see above).
87 Returns
88 -------
89 con : Contrast instance,
90 Yields the statistics of the :term:`contrast`
91 (:term:`effects<Parameter Estimate>`, variance, p-values).
93 """
94 con_val = np.asarray(con_val)
95 dim = 1
96 if con_val.ndim > 1:
97 dim = con_val.shape[0]
99 if stat_type is None:
100 stat_type = "t" if dim == 1 else "F"
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 )
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
118 elif stat_type == "F":
119 from scipy.linalg import sqrtm
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
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 )
152def compute_fixed_effect_contrast(labels, results, con_vals, stat_type=None):
153 """Compute the summary contrast assuming fixed effects.
155 Adds the same contrast applied to all labels and results lists.
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)
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).
180 The important feature is that it supports addition,
181 thus opening the possibility of fixed-effects models.
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).
188 Parameters
189 ----------
190 effect : array of shape (contrast_dim, n_voxels)
191 The effects related to the :term:`contrast`.
193 variance : array of shape (n_voxels)
194 The associated variance estimate.
196 dim : :obj:`int` or None, optional
197 The dimension of the :term:`contrast`.
199 dof : scalar, default=DEF_DOFMAX
200 The degrees of freedom of the residuals.
202 stat_type : {'t', 'F'}, default='t'
203 Specification of the :term:`contrast` type.
205 contrast_type :
207 .. deprecated:: 0.10.3
209 Use ``stat_type`` instead (see above).
211 tiny : :obj:`float`, default=DEF_TINY
212 Small quantity used to avoid numerical underflows.
214 dofmax : scalar, default=DEF_DOFMAX
215 The maximum degrees of freedom of the residuals.
216 """
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")
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
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
261 @property
262 def contrast_type(self):
263 """Return value of stat_type.
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
279 def effect_size(self):
280 """Make access to summary statistics more straightforward \
281 when computing contrasts.
282 """
283 return self.effect
285 def effect_variance(self):
286 """Make access to summary statistics more straightforward \
287 when computing contrasts.
288 """
289 return self.variance
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'.
295 Parameters
296 ----------
297 baseline : :obj:`float`, default=0.0
298 Baseline value for the test statistic.
300 Returns
301 -------
302 stat : 1-d array, shape=(n_voxels,)
303 statistical values, one per voxel.
305 """
306 self.baseline = baseline
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_
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.
330 Parameters
331 ----------
332 baseline : :obj:`float`, default=0.0
333 Baseline value for the test statistic.
336 Returns
337 -------
338 p_values : 1-d array, shape=(n_voxels,)
339 p-values, one per voxel
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
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.
362 Parameters
363 ----------
364 baseline : :obj:`float`, default=0.0
365 Baseline value for the test statistic.
368 Returns
369 -------
370 one_minus_pvalues : 1-d array, shape=(n_voxels,)
371 one_minus_pvalues, one per voxel
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
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'.
393 Parameters
394 ----------
395 baseline : :obj:`float`, default=0.0
396 Baseline value for the test statistic.
399 Returns
400 -------
401 z_score : 1-d array, shape=(n_voxels,)
402 statistical values, one per voxel
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)
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_
416 def __add__(self, other):
417 """Add two contrast, Yields an new Contrast instance.
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 )
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 )
459 __mul__ = __rmul__
461 def __div__(self, scalar):
462 return self.__rmul__(1 / float(scalar))
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.
475 Parameters
476 ----------
477 contrast_imgs : :obj:`list` of Nifti1Images or :obj:`str`\
478 or :obj:`~nilearn.surface.SurfaceImage`
479 The input contrast images.
481 variance_imgs : :obj:`list` of Nifti1Images or :obj:`str` \
482 or :obj:`~nilearn.surface.SurfaceImage`
483 The input variance images.
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``.
490 precision_weighted : :obj:`bool`, default=False
491 Whether fixed effects estimates should be weighted by inverse
492 variance or not.
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.
499 return_z_score : :obj:`bool`, default=False
500 Whether ``fixed_fx_z_score_img`` should be output or not.
502 Returns
503 -------
504 fixed_fx_contrast_img : Nifti1Image or :obj:`~nilearn.surface.SurfaceImage`
505 The fixed effects contrast computed within the mask.
507 fixed_fx_variance_img : Nifti1Image or :obj:`~nilearn.surface.SurfaceImage`
508 The fixed effects variance computed within the mask.
510 fixed_fx_stat_img : Nifti1Image or :obj:`~nilearn.surface.SurfaceImage`
511 The fixed effects stat computed within the mask.
513 fixed_fx_z_score_img : Nifti1Image, optional
514 The fixed effects corresponding z-transform
516 Warns
517 -----
518 DeprecationWarning
519 Starting in version 0.13, fixed_fx_z_score_img will always be returned
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 )
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()
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 )
548 if dofs is None:
549 dofs = [100] * n_runs
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 )
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
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)
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
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_
625 return (
626 fixed_fx_contrasts,
627 fixed_fx_variance,
628 fixed_fx_stat,
629 fixed_fx_z_score,
630 )