Coverage for nilearn/glm/model.py: 13%
126 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"""Implement classes to handle statistical tests on likelihood models."""
3import numpy as np
4from nibabel.onetime import auto_attr
5from scipy.linalg import inv
6from scipy.stats import t as t_distribution
8from nilearn.glm._utils import pad_contrast, positive_reciprocal
10# Inverse t cumulative distribution
11inv_t_cdf = t_distribution.ppf
14class LikelihoodModelResults:
15 """Class to contain results from likelihood models.
17 This is the class in which things like AIC, BIC, llf
18 can be implemented as methods, not computed in, say,
19 the fit method of OLSModel.
21 Parameters
22 ----------
23 theta : ndarray
24 Parameter estimates from estimated model.
26 Y : ndarray
27 Data.
29 model : ``LikelihoodModel`` instance
30 Model used to generate fit.
32 cov : None or ndarray, optional
33 Covariance of thetas.
35 dispersion : scalar, default=1
36 Multiplicative factor in front of `cov`.
38 nuisance : None of ndarray, optional
39 Parameter estimates needed to compute logL.
41 Notes
42 -----
43 The covariance of thetas is given by:
45 dispersion * cov
47 For (some subset of models) `dispersion` will typically be the mean
48 square error from the estimated model (sigma^2)
50 """
52 def __init__(
53 self,
54 theta,
55 Y,
56 model,
57 cov=None,
58 dispersion=1.0,
59 nuisance=None,
60 ):
61 self.theta = theta
62 self.Y = Y
63 self.model = model
64 if cov is None:
65 self.cov = self.model.information(
66 self.theta, nuisance=self.nuisance
67 )
68 else:
69 self.cov = cov
70 self.dispersion = dispersion
71 self.nuisance = nuisance
73 self.df_total = Y.shape[0]
74 self.df_model = model.df_model
75 # put this as a parameter of LikelihoodModel
76 self.df_residuals = self.df_total - self.df_model
78 # @auto_attr store the value as an object attribute after initial call
79 # better performance than @property
80 @auto_attr
81 def logL(self): # noqa: N802
82 """Return the maximized log-likelihood."""
83 return self.model.logL(self.theta, self.Y, nuisance=self.nuisance)
85 def t(self, column=None):
86 """
87 Return the (Wald) t-statistic for a given parameter estimate.
89 Use Tcontrast for more complicated (Wald) t-statistics.
91 """
92 if column is None:
93 column = range(self.theta.shape[0])
95 column = np.asarray(column)
96 _theta = self.theta[column]
97 _cov = self.vcov(column=column)
98 if _cov.ndim == 2:
99 _cov = np.diag(_cov)
100 _t = _theta * positive_reciprocal(np.sqrt(_cov))
101 return _t
103 def vcov(self, matrix=None, column=None, dispersion=None, other=None):
104 """Return Variance/covariance matrix of linear :term:`contrast`.
106 Parameters
107 ----------
108 matrix : (dim, self.theta.shape[0]) array, default=None
109 Numerical :term:`contrast` specification,
110 where ``dim`` refers to the 'dimension' of the contrast
111 i.e. 1 for t contrasts, 1
112 or more for F :term:`contrasts<contrast>`.
114 column : :obj:`int`, default=None
115 Alternative way of specifying :term:`contrasts<contrast>`
116 (column index).
118 dispersion : :obj:`float` or (n_voxels,) array, default=None
119 Value(s) for the dispersion parameters.
121 other : (dim, self.theta.shape[0]) array, default=None
122 Alternative :term:`contrast` specification (?).
124 Returns
125 -------
126 cov : (dim, dim) or (n_voxels, dim, dim) array
127 The estimated covariance matrix/matrices.
129 Returns the variance/covariance matrix of a linear contrast of the
130 estimates of theta, multiplied by `dispersion` which will often be an
131 estimate of `dispersion`, like, sigma^2.
133 The covariance of interest is either specified as a (set of) column(s)
134 or a matrix.
136 """
137 if self.cov is None:
138 raise ValueError(
139 "need covariance of parameters for computing"
140 "(unnormalized) covariances"
141 )
143 if dispersion is None:
144 dispersion = self.dispersion
146 if matrix is None and column is None:
147 return self.cov * dispersion
149 if column is not None:
150 column = np.asarray(column)
151 if column.shape == ():
152 return self.cov[column, column] * dispersion
153 else:
154 return self.cov[column][:, column] * dispersion
156 else:
157 if other is None:
158 other = matrix
159 tmp = np.dot(matrix, np.dot(self.cov, np.transpose(other)))
160 if np.isscalar(dispersion):
161 return tmp * dispersion
162 else:
163 return tmp[:, :, np.newaxis] * dispersion
165 def Tcontrast(self, matrix, store=("t", "effect", "sd"), dispersion=None): # noqa: N802
166 """Compute a Tcontrast for a row vector `matrix`.
168 To get the t-statistic for a single column, use the 't' method.
170 Parameters
171 ----------
172 matrix : 1D array-like
173 Contrast matrix.
175 store : sequence, default=('t', 'effect', 'sd')
176 Components of t to store in results output object.
178 dispersion : None or :obj:`float`, default = None
180 Returns
181 -------
182 res : ``TContrastResults`` object
184 """
185 matrix = np.asarray(matrix)
186 # 1D vectors assumed to be row vector
187 if matrix.ndim == 1:
188 matrix = matrix[None]
189 if matrix.size == 0:
190 raise ValueError(f"t contrasts cannot be empty: got {matrix}")
191 if matrix.shape[0] != 1:
192 raise ValueError(
193 f"t contrasts should have only one row: got {matrix}."
194 )
195 matrix = pad_contrast(con_val=matrix, theta=self.theta, stat_type="t")
196 store = set(store)
197 if not store.issubset(("t", "effect", "sd")):
198 raise ValueError(f"Unexpected store request in {store}")
199 st_t = st_effect = st_sd = effect = sd = None
200 if "t" in store or "effect" in store:
201 effect = np.dot(matrix, self.theta)
202 if "effect" in store:
203 st_effect = np.squeeze(effect)
204 if "t" in store or "sd" in store:
205 sd = np.sqrt(self.vcov(matrix=matrix, dispersion=dispersion))
206 if "sd" in store:
207 st_sd = np.squeeze(sd)
208 if "t" in store:
209 st_t = np.squeeze(effect * positive_reciprocal(sd))
210 return TContrastResults(
211 effect=st_effect, t=st_t, sd=st_sd, df_den=self.df_residuals
212 )
214 def Fcontrast(self, matrix, dispersion=None, invcov=None): # noqa: N802
215 """Compute an F contrast for a :term:`contrast` matrix ``matrix``.
217 Here, ``matrix`` M is assumed to be non-singular. More precisely
219 .. math::
221 M pX pX' M'
223 is assumed invertible. Here, :math:`pX` is the generalized inverse of
224 the design matrix of the model.
225 There can be problems in non-OLS models where
226 the rank of the covariance of the noise is not full.
228 See the contrasts module to see how to specify contrasts.
229 In particular, the matrices from these contrasts will always be
230 non-singular in the sense above.
232 Parameters
233 ----------
234 matrix : 1D array-like
235 Contrast matrix.
237 dispersion : None or :obj:`float`, default=None
238 If None, use ``self.dispersion``.
240 invcov : None or array, default=None
241 Known inverse of variance covariance matrix.
242 If None, calculate this matrix.
244 Returns
245 -------
246 f_res : ``FContrastResults`` instance
247 with attributes F, df_den, df_num
249 Notes
250 -----
251 For F contrasts, we now specify an effect and covariance.
253 """
254 matrix = np.asarray(matrix)
255 # 1D vectors assumed to be row vector
256 if matrix.ndim == 1:
257 matrix = matrix[None]
258 if matrix.shape[1] != self.theta.shape[0]:
259 raise ValueError(
260 f"F contrasts should have shape[1]={self.theta.shape[0]}, "
261 f"but this has shape[1]={matrix.shape[1]}"
262 )
263 matrix = pad_contrast(con_val=matrix, theta=self.theta, stat_type="F")
264 ctheta = np.dot(matrix, self.theta)
265 if matrix.ndim == 1:
266 matrix = matrix.reshape((1, matrix.shape[0]))
267 if dispersion is None:
268 dispersion = self.dispersion
269 q = matrix.shape[0]
270 if invcov is None:
271 invcov = inv(self.vcov(matrix=matrix, dispersion=1.0))
272 F = np.add.reduce(
273 np.dot(invcov, ctheta) * ctheta, 0
274 ) * positive_reciprocal(q * dispersion)
275 F = np.squeeze(F)
276 return FContrastResults(
277 effect=ctheta,
278 covariance=self.vcov(
279 matrix=matrix, dispersion=dispersion[np.newaxis]
280 ),
281 F=F,
282 df_den=self.df_residuals,
283 df_num=invcov.shape[0],
284 )
286 def conf_int(self, alpha=0.05, cols=None, dispersion=None):
287 """Return the confidence interval of the specified theta estimates.
289 Parameters
290 ----------
291 alpha : :obj:`float`, default=0.05
292 The `alpha` level for the confidence interval.
293 ie., `alpha` = .05 returns a 95% confidence interval.
296 cols : :obj:`tuple`, default=None
297 `cols` specifies which confidence intervals to return.
299 dispersion : None or scalar, default=None
300 Scale factor for the variance / covariance
301 (see class docstring and ``vcov`` method docstring).
303 Returns
304 -------
305 cis : ndarray
306 `cis` is shape ``(len(cols), 2)`` where each row contains [lower,
307 upper] for the given entry in `cols`
309 Examples
310 --------
311 >>> from numpy.random import standard_normal as stan
312 >>> from nilearn.glm import OLSModel
313 >>> x = np.hstack((stan((30, 1)), stan((30, 1)), stan((30, 1))))
314 >>> beta = np.array([3.25, 1.5, 7.0])
315 >>> y = np.dot(x, beta) + stan((30))
316 >>> model = OLSModel(x).fit(y)
317 >>> confidence_intervals = model.conf_int(cols=(1, 2))
319 Notes
320 -----
321 Confidence intervals are two-tailed.
323 tails : string, optional
324 Possible values: 'two' | 'upper' | 'lower'
326 """
327 if cols is None:
328 lower = self.theta - inv_t_cdf(
329 1 - alpha / 2, self.df_residuals
330 ) * np.sqrt(np.diag(self.vcov(dispersion=dispersion)))
331 upper = self.theta + inv_t_cdf(
332 1 - alpha / 2, self.df_residuals
333 ) * np.sqrt(np.diag(self.vcov(dispersion=dispersion)))
334 else:
335 lower, upper = [], []
336 for i in cols:
337 lower.append(
338 self.theta[i]
339 - inv_t_cdf(1 - alpha / 2, self.df_residuals)
340 * np.sqrt(self.vcov(column=i, dispersion=dispersion))
341 )
342 upper.append(
343 self.theta[i]
344 + inv_t_cdf(1 - alpha / 2, self.df_residuals)
345 * np.sqrt(self.vcov(column=i, dispersion=dispersion))
346 )
347 return np.asarray(list(zip(lower, upper)))
350class TContrastResults:
351 """Results from a t :term:`contrast` of coefficients in a parametric model.
353 The class does nothing.
354 It is a container for the results from T :term:`contrasts<contrast>`,
355 and returns the T-statistics when np.asarray is called.
357 """
359 def __init__(self, t, sd, effect, df_den=None):
360 if df_den is None:
361 df_den = np.inf
362 self.t = t
363 self.sd = sd
364 self.effect = effect
365 self.df_den = df_den
367 def __array__(self):
368 return np.asarray(self.t)
370 def __str__(self):
371 return (
372 "<T contrast: "
373 f"effect={self.effect}, "
374 f"sd={self.sd}, "
375 f"t={self.t}, "
376 f"df_den={self.df_den}>"
377 )
380class FContrastResults:
381 """Results from an F :term:`contrast` of coefficients \
382 in a parametric model.
384 The class does nothing.
385 It is a container for the results from F :term:`contrasts<contrast>`,
386 and returns the F-statistics when np.asarray is called.
387 """
389 def __init__(self, effect, covariance, F, df_num, df_den=None):
390 if df_den is None:
391 df_den = np.inf
392 self.effect = effect
393 self.covariance = covariance
394 self.F = F
395 self.df_den = df_den
396 self.df_num = df_num
398 def __array__(self):
399 return np.asarray(self.F)
401 def __str__(self):
402 return (
403 "<F contrast: "
404 f"F={self.F!r}, "
405 f"df_den={self.df_den}, "
406 f"df_num={self.df_num}>"
407 )