Coverage for nilearn/glm/thresholding.py: 9%

105 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-18 13:00 +0200

1"""Utilities for probabilistic error control at voxel- and \ 

2cluster-level in brain imaging: cluster-level thresholding, false \ 

3discovery rate control, false discovery proportion in clusters. 

4""" 

5 

6import warnings 

7 

8import numpy as np 

9from scipy.ndimage import label 

10from scipy.stats import norm 

11 

12from nilearn._utils.helpers import is_matplotlib_installed 

13from nilearn._utils.logger import find_stack_level 

14from nilearn.image import get_data, math_img, threshold_img 

15from nilearn.maskers import NiftiMasker, SurfaceMasker 

16from nilearn.surface import SurfaceImage 

17 

18 

19def _compute_hommel_value(z_vals, alpha, verbose=0): 

20 """Compute the All-Resolution Inference hommel-value.""" 

21 if alpha < 0 or alpha > 1: 

22 raise ValueError("alpha should be between 0 and 1") 

23 z_vals_ = -np.sort(-z_vals) 

24 p_vals = norm.sf(z_vals_) 

25 n_samples = len(p_vals) 

26 

27 if len(p_vals) == 1: 

28 return p_vals[0] > alpha 

29 if p_vals[0] > alpha: 

30 return n_samples 

31 if p_vals[-1] < alpha: 

32 return 0 

33 slopes = (alpha - p_vals[:-1]) / np.arange(n_samples - 1, 0, -1) 

34 slope = np.max(slopes) 

35 hommel_value = np.trunc(alpha / slope) 

36 if verbose > 0: 

37 if not is_matplotlib_installed(): 

38 warnings.warn( 

39 '"verbose" option requires the package Matplotlib.' 

40 "Please install it using `pip install matplotlib`.", 

41 stacklevel=find_stack_level(), 

42 ) 

43 else: 

44 from matplotlib import pyplot as plt 

45 

46 plt.figure() 

47 plt.plot(np.arange(1, 1 + n_samples), p_vals, "o") 

48 plt.plot([n_samples - hommel_value, n_samples], [0, alpha]) 

49 plt.plot([0, n_samples], [0, 0], "k") 

50 plt.show(block=False) 

51 return int(np.minimum(hommel_value, n_samples)) 

52 

53 

54def _true_positive_fraction(z_vals, hommel_value, alpha): 

55 """Given a bunch of z-avalues, return the true positive fraction. 

56 

57 Parameters 

58 ---------- 

59 z_vals : array, 

60 A set of z-variates from which the FDR is computed. 

61 

62 hommel_value : :obj:`int` 

63 The Hommel value, used in the computations. 

64 

65 alpha : :obj:`float` 

66 The desired FDR control. 

67 

68 Returns 

69 ------- 

70 threshold : :obj:`float` 

71 Estimated true positive fraction in the set of values. 

72 

73 """ 

74 z_vals_ = -np.sort(-z_vals) 

75 p_vals = norm.sf(z_vals_) 

76 n_samples = len(p_vals) 

77 c = np.ceil((hommel_value * p_vals) / alpha) 

78 unique_c, counts = np.unique(c, return_counts=True) 

79 criterion = 1 - unique_c + np.cumsum(counts) 

80 proportion_true_discoveries = np.maximum(0, criterion.max() / n_samples) 

81 return proportion_true_discoveries 

82 

83 

84def fdr_threshold(z_vals, alpha): 

85 """Return the Benjamini-Hochberg FDR threshold for the input z_vals. 

86 

87 Parameters 

88 ---------- 

89 z_vals : array 

90 A set of z-variates from which the FDR is computed. 

91 

92 alpha : :obj:`float` 

93 The desired FDR control. 

94 

95 Returns 

96 ------- 

97 threshold : :obj:`float` 

98 FDR-controling threshold from the Benjamini-Hochberg procedure. 

99 

100 """ 

101 if alpha < 0 or alpha > 1: 

102 raise ValueError( 

103 f"alpha should be between 0 and 1. {alpha} was provided" 

104 ) 

105 z_vals_ = -np.sort(-z_vals) 

106 p_vals = norm.sf(z_vals_) 

107 n_samples = len(p_vals) 

108 pos = p_vals < alpha * np.linspace(1 / n_samples, 1, n_samples) 

109 return z_vals_[pos][-1] - 1.0e-12 if pos.any() else np.inf 

110 

111 

112def cluster_level_inference( 

113 stat_img, mask_img=None, threshold=3.0, alpha=0.05, verbose=0 

114): 

115 """Report the proportion of active voxels for all clusters \ 

116 defined by the input threshold. 

117 

118 This implements the method described in :footcite:t:`Rosenblatt2018`. 

119 

120 Parameters 

121 ---------- 

122 stat_img : Niimg-like object 

123 statistical image (presumably in z scale) 

124 

125 mask_img : Niimg-like object, default=None 

126 mask image 

127 

128 threshold : :obj:`list` of :obj:`float`, default=3.0 

129 Cluster-forming threshold in z-scale. 

130 

131 alpha : :obj:`float` or :obj:`list`, default=0.05 

132 Level of control on the true positive rate, aka true discovery 

133 proportion. 

134 

135 verbose : :obj:`int` or :obj:`bool`, default=0 

136 Verbosity mode. 

137 

138 Returns 

139 ------- 

140 proportion_true_discoveries_img : Nifti1Image 

141 The statistical map that gives the true positive. 

142 

143 References 

144 ---------- 

145 .. footbibliography:: 

146 

147 """ 

148 if verbose is False: 

149 verbose = 0 

150 if verbose is True: 

151 verbose = 1 

152 

153 if not isinstance(threshold, list): 

154 threshold = [threshold] 

155 

156 if mask_img is None: 

157 masker = NiftiMasker(mask_strategy="background").fit(stat_img) 

158 else: 

159 masker = NiftiMasker(mask_img=mask_img).fit() 

160 stats = np.ravel(masker.transform(stat_img)) 

161 hommel_value = _compute_hommel_value(stats, alpha, verbose=verbose) 

162 

163 # embed it back to 3D grid 

164 stat_map = get_data(masker.inverse_transform(stats)) 

165 

166 # Extract connected components above threshold 

167 proportion_true_discoveries_img = math_img("0. * img", img=stat_img) 

168 proportion_true_discoveries = masker.transform( 

169 proportion_true_discoveries_img 

170 ).ravel() 

171 

172 for threshold_ in sorted(threshold): 

173 label_map, n_labels = label(stat_map > threshold_) 

174 labels = label_map[get_data(masker.mask_img_) > 0] 

175 

176 for label_ in range(1, n_labels + 1): 

177 # get the z-vals in the cluster 

178 cluster_vals = stats[labels == label_] 

179 proportion = _true_positive_fraction( 

180 cluster_vals, hommel_value, alpha 

181 ) 

182 proportion_true_discoveries[labels == label_] = proportion 

183 

184 proportion_true_discoveries_img = masker.inverse_transform( 

185 proportion_true_discoveries 

186 ) 

187 return proportion_true_discoveries_img 

188 

189 

190def threshold_stats_img( 

191 stat_img=None, 

192 mask_img=None, 

193 alpha=0.001, 

194 threshold=3.0, 

195 height_control="fpr", 

196 cluster_threshold=0, 

197 two_sided=True, 

198): 

199 """Compute the required threshold level and return the thresholded map. 

200 

201 Parameters 

202 ---------- 

203 stat_img : Niimg-like object, or a :obj:`~nilearn.surface.SurfaceImage` \ 

204 or None, default=None 

205 Statistical image (presumably in z scale) whenever height_control 

206 is 'fpr' or None, stat_img=None is acceptable. 

207 If it is 'fdr' or 'bonferroni', an error is raised if stat_img is None. 

208 

209 mask_img : Niimg-like object, default=None 

210 Mask image 

211 

212 alpha : :obj:`float` or :obj:`list`, default=0.001 

213 Number controlling the thresholding (either a p-value or q-value). 

214 Its actual meaning depends on the height_control parameter. 

215 This function translates alpha to a z-scale threshold. 

216 

217 threshold : :obj:`float`, default=3.0 

218 Desired threshold in z-scale. 

219 This is used only if height_control is None. 

220 

221 height_control : :obj:`str`, or None, default='fpr' 

222 False positive control meaning of cluster forming 

223 threshold: None|'fpr'|'fdr'|'bonferroni' 

224 

225 cluster_threshold : :obj:`float`, default=0 

226 cluster size threshold. In the returned thresholded map, 

227 sets of connected voxels (`clusters`) with size smaller 

228 than this number will be removed. 

229 

230 two_sided : :obj:`bool`, default=True 

231 Whether the thresholding should yield both positive and negative 

232 part of the maps. 

233 In that case, alpha is corrected by a factor of 2. 

234 

235 Returns 

236 ------- 

237 thresholded_map : Nifti1Image, 

238 The stat_map thresholded at the prescribed voxel- and cluster-level. 

239 

240 threshold : :obj:`float` 

241 The voxel-level threshold used actually. 

242 

243 Notes 

244 ----- 

245 If the input image is not z-scaled (i.e. some z-transformed statistic) 

246 the computed threshold is not rigorous and likely meaningless 

247 

248 See Also 

249 -------- 

250 nilearn.image.threshold_img : 

251 Apply an explicit voxel-level (and optionally cluster-level) threshold 

252 without correction. 

253 

254 """ 

255 height_control_methods = [ 

256 "fpr", 

257 "fdr", 

258 "bonferroni", 

259 None, 

260 ] 

261 if height_control not in height_control_methods: 

262 raise ValueError( 

263 f"'height_control' should be one of {height_control_methods}. \n" 

264 f"Got: '{height_control_methods}'" 

265 ) 

266 

267 # if two-sided, correct alpha by a factor of 2 

268 alpha_ = alpha / 2 if two_sided else alpha 

269 

270 # if height_control is 'fpr' or None, we don't need to look at the data 

271 # to compute the threshold 

272 if height_control == "fpr": 

273 threshold = norm.isf(alpha_) 

274 

275 # In this case, and if stat_img is None, we return 

276 if stat_img is None: 

277 if height_control in ["fpr", None]: 

278 return None, threshold 

279 else: 

280 raise ValueError( 

281 f"'stat_img' cannot be None for {height_control=}" 

282 ) 

283 

284 if mask_img is None: 

285 if isinstance(stat_img, SurfaceImage): 

286 masker = SurfaceMasker() 

287 else: 

288 masker = NiftiMasker(mask_strategy="background") 

289 masker.fit(stat_img) 

290 else: 

291 if isinstance(stat_img, SurfaceImage): 

292 masker = SurfaceMasker(mask_img=mask_img) 

293 else: 

294 masker = NiftiMasker(mask_img=mask_img) 

295 masker.fit() 

296 

297 stats = np.ravel(masker.transform(stat_img)) 

298 n_elements = np.size(stats) 

299 

300 # Thresholding 

301 if two_sided: 

302 # replace stats by their absolute value 

303 stats = np.abs(stats) 

304 

305 if height_control == "fdr": 

306 threshold = fdr_threshold(stats, alpha_) 

307 elif height_control == "bonferroni": 

308 threshold = norm.isf(alpha_ / n_elements) 

309 

310 # Apply cluster-extent thresholding with new cluster-defining threshold 

311 stat_img = threshold_img( 

312 img=stat_img, 

313 threshold=threshold, 

314 cluster_threshold=cluster_threshold, 

315 two_sided=two_sided, 

316 mask_img=mask_img, 

317 copy=True, 

318 copy_header=True, 

319 ) 

320 

321 return stat_img, threshold