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
« 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"""
6import warnings
8import numpy as np
9from scipy.ndimage import label
10from scipy.stats import norm
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
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)
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
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))
54def _true_positive_fraction(z_vals, hommel_value, alpha):
55 """Given a bunch of z-avalues, return the true positive fraction.
57 Parameters
58 ----------
59 z_vals : array,
60 A set of z-variates from which the FDR is computed.
62 hommel_value : :obj:`int`
63 The Hommel value, used in the computations.
65 alpha : :obj:`float`
66 The desired FDR control.
68 Returns
69 -------
70 threshold : :obj:`float`
71 Estimated true positive fraction in the set of values.
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
84def fdr_threshold(z_vals, alpha):
85 """Return the Benjamini-Hochberg FDR threshold for the input z_vals.
87 Parameters
88 ----------
89 z_vals : array
90 A set of z-variates from which the FDR is computed.
92 alpha : :obj:`float`
93 The desired FDR control.
95 Returns
96 -------
97 threshold : :obj:`float`
98 FDR-controling threshold from the Benjamini-Hochberg procedure.
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
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.
118 This implements the method described in :footcite:t:`Rosenblatt2018`.
120 Parameters
121 ----------
122 stat_img : Niimg-like object
123 statistical image (presumably in z scale)
125 mask_img : Niimg-like object, default=None
126 mask image
128 threshold : :obj:`list` of :obj:`float`, default=3.0
129 Cluster-forming threshold in z-scale.
131 alpha : :obj:`float` or :obj:`list`, default=0.05
132 Level of control on the true positive rate, aka true discovery
133 proportion.
135 verbose : :obj:`int` or :obj:`bool`, default=0
136 Verbosity mode.
138 Returns
139 -------
140 proportion_true_discoveries_img : Nifti1Image
141 The statistical map that gives the true positive.
143 References
144 ----------
145 .. footbibliography::
147 """
148 if verbose is False:
149 verbose = 0
150 if verbose is True:
151 verbose = 1
153 if not isinstance(threshold, list):
154 threshold = [threshold]
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)
163 # embed it back to 3D grid
164 stat_map = get_data(masker.inverse_transform(stats))
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()
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]
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
184 proportion_true_discoveries_img = masker.inverse_transform(
185 proportion_true_discoveries
186 )
187 return proportion_true_discoveries_img
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.
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.
209 mask_img : Niimg-like object, default=None
210 Mask image
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.
217 threshold : :obj:`float`, default=3.0
218 Desired threshold in z-scale.
219 This is used only if height_control is None.
221 height_control : :obj:`str`, or None, default='fpr'
222 False positive control meaning of cluster forming
223 threshold: None|'fpr'|'fdr'|'bonferroni'
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.
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.
235 Returns
236 -------
237 thresholded_map : Nifti1Image,
238 The stat_map thresholded at the prescribed voxel- and cluster-level.
240 threshold : :obj:`float`
241 The voxel-level threshold used actually.
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
248 See Also
249 --------
250 nilearn.image.threshold_img :
251 Apply an explicit voxel-level (and optionally cluster-level) threshold
252 without correction.
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 )
267 # if two-sided, correct alpha by a factor of 2
268 alpha_ = alpha / 2 if two_sided else alpha
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_)
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 )
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()
297 stats = np.ravel(masker.transform(stat_img))
298 n_elements = np.size(stats)
300 # Thresholding
301 if two_sided:
302 # replace stats by their absolute value
303 stats = np.abs(stats)
305 if height_control == "fdr":
306 threshold = fdr_threshold(stats, alpha_)
307 elif height_control == "bonferroni":
308 threshold = norm.isf(alpha_ / n_elements)
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 )
321 return stat_img, threshold