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

1"""Implement classes to handle statistical tests on likelihood models.""" 

2 

3import numpy as np 

4from nibabel.onetime import auto_attr 

5from scipy.linalg import inv 

6from scipy.stats import t as t_distribution 

7 

8from nilearn.glm._utils import pad_contrast, positive_reciprocal 

9 

10# Inverse t cumulative distribution 

11inv_t_cdf = t_distribution.ppf 

12 

13 

14class LikelihoodModelResults: 

15 """Class to contain results from likelihood models. 

16 

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. 

20 

21 Parameters 

22 ---------- 

23 theta : ndarray 

24 Parameter estimates from estimated model. 

25 

26 Y : ndarray 

27 Data. 

28 

29 model : ``LikelihoodModel`` instance 

30 Model used to generate fit. 

31 

32 cov : None or ndarray, optional 

33 Covariance of thetas. 

34 

35 dispersion : scalar, default=1 

36 Multiplicative factor in front of `cov`. 

37 

38 nuisance : None of ndarray, optional 

39 Parameter estimates needed to compute logL. 

40 

41 Notes 

42 ----- 

43 The covariance of thetas is given by: 

44 

45 dispersion * cov 

46 

47 For (some subset of models) `dispersion` will typically be the mean 

48 square error from the estimated model (sigma^2) 

49 

50 """ 

51 

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 

72 

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 

77 

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) 

84 

85 def t(self, column=None): 

86 """ 

87 Return the (Wald) t-statistic for a given parameter estimate. 

88 

89 Use Tcontrast for more complicated (Wald) t-statistics. 

90 

91 """ 

92 if column is None: 

93 column = range(self.theta.shape[0]) 

94 

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 

102 

103 def vcov(self, matrix=None, column=None, dispersion=None, other=None): 

104 """Return Variance/covariance matrix of linear :term:`contrast`. 

105 

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

113 

114 column : :obj:`int`, default=None 

115 Alternative way of specifying :term:`contrasts<contrast>` 

116 (column index). 

117 

118 dispersion : :obj:`float` or (n_voxels,) array, default=None 

119 Value(s) for the dispersion parameters. 

120 

121 other : (dim, self.theta.shape[0]) array, default=None 

122 Alternative :term:`contrast` specification (?). 

123 

124 Returns 

125 ------- 

126 cov : (dim, dim) or (n_voxels, dim, dim) array 

127 The estimated covariance matrix/matrices. 

128 

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. 

132 

133 The covariance of interest is either specified as a (set of) column(s) 

134 or a matrix. 

135 

136 """ 

137 if self.cov is None: 

138 raise ValueError( 

139 "need covariance of parameters for computing" 

140 "(unnormalized) covariances" 

141 ) 

142 

143 if dispersion is None: 

144 dispersion = self.dispersion 

145 

146 if matrix is None and column is None: 

147 return self.cov * dispersion 

148 

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 

155 

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 

164 

165 def Tcontrast(self, matrix, store=("t", "effect", "sd"), dispersion=None): # noqa: N802 

166 """Compute a Tcontrast for a row vector `matrix`. 

167 

168 To get the t-statistic for a single column, use the 't' method. 

169 

170 Parameters 

171 ---------- 

172 matrix : 1D array-like 

173 Contrast matrix. 

174 

175 store : sequence, default=('t', 'effect', 'sd') 

176 Components of t to store in results output object. 

177 

178 dispersion : None or :obj:`float`, default = None 

179 

180 Returns 

181 ------- 

182 res : ``TContrastResults`` object 

183 

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 ) 

213 

214 def Fcontrast(self, matrix, dispersion=None, invcov=None): # noqa: N802 

215 """Compute an F contrast for a :term:`contrast` matrix ``matrix``. 

216 

217 Here, ``matrix`` M is assumed to be non-singular. More precisely 

218 

219 .. math:: 

220 

221 M pX pX' M' 

222 

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. 

227 

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. 

231 

232 Parameters 

233 ---------- 

234 matrix : 1D array-like 

235 Contrast matrix. 

236 

237 dispersion : None or :obj:`float`, default=None 

238 If None, use ``self.dispersion``. 

239 

240 invcov : None or array, default=None 

241 Known inverse of variance covariance matrix. 

242 If None, calculate this matrix. 

243 

244 Returns 

245 ------- 

246 f_res : ``FContrastResults`` instance 

247 with attributes F, df_den, df_num 

248 

249 Notes 

250 ----- 

251 For F contrasts, we now specify an effect and covariance. 

252 

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 ) 

285 

286 def conf_int(self, alpha=0.05, cols=None, dispersion=None): 

287 """Return the confidence interval of the specified theta estimates. 

288 

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. 

294 

295 

296 cols : :obj:`tuple`, default=None 

297 `cols` specifies which confidence intervals to return. 

298 

299 dispersion : None or scalar, default=None 

300 Scale factor for the variance / covariance 

301 (see class docstring and ``vcov`` method docstring). 

302 

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` 

308 

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

318 

319 Notes 

320 ----- 

321 Confidence intervals are two-tailed. 

322 

323 tails : string, optional 

324 Possible values: 'two' | 'upper' | 'lower' 

325 

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

348 

349 

350class TContrastResults: 

351 """Results from a t :term:`contrast` of coefficients in a parametric model. 

352 

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. 

356 

357 """ 

358 

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 

366 

367 def __array__(self): 

368 return np.asarray(self.t) 

369 

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 ) 

378 

379 

380class FContrastResults: 

381 """Results from an F :term:`contrast` of coefficients \ 

382 in a parametric model. 

383 

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

388 

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 

397 

398 def __array__(self): 

399 return np.asarray(self.F) 

400 

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 )