nimare 0.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. benchmarks/__init__.py +0 -0
  2. benchmarks/bench_cbma.py +57 -0
  3. nimare/__init__.py +45 -0
  4. nimare/_version.py +21 -0
  5. nimare/annotate/__init__.py +21 -0
  6. nimare/annotate/cogat.py +213 -0
  7. nimare/annotate/gclda.py +924 -0
  8. nimare/annotate/lda.py +147 -0
  9. nimare/annotate/text.py +75 -0
  10. nimare/annotate/utils.py +87 -0
  11. nimare/base.py +217 -0
  12. nimare/cli.py +124 -0
  13. nimare/correct.py +462 -0
  14. nimare/dataset.py +685 -0
  15. nimare/decode/__init__.py +33 -0
  16. nimare/decode/base.py +115 -0
  17. nimare/decode/continuous.py +462 -0
  18. nimare/decode/discrete.py +753 -0
  19. nimare/decode/encode.py +110 -0
  20. nimare/decode/utils.py +44 -0
  21. nimare/diagnostics.py +510 -0
  22. nimare/estimator.py +139 -0
  23. nimare/extract/__init__.py +19 -0
  24. nimare/extract/extract.py +466 -0
  25. nimare/extract/utils.py +295 -0
  26. nimare/generate.py +331 -0
  27. nimare/io.py +667 -0
  28. nimare/meta/__init__.py +39 -0
  29. nimare/meta/cbma/__init__.py +6 -0
  30. nimare/meta/cbma/ale.py +951 -0
  31. nimare/meta/cbma/base.py +947 -0
  32. nimare/meta/cbma/mkda.py +1361 -0
  33. nimare/meta/cbmr.py +970 -0
  34. nimare/meta/ibma.py +1683 -0
  35. nimare/meta/kernel.py +501 -0
  36. nimare/meta/models.py +1199 -0
  37. nimare/meta/utils.py +494 -0
  38. nimare/nimads.py +492 -0
  39. nimare/reports/__init__.py +24 -0
  40. nimare/reports/base.py +664 -0
  41. nimare/reports/default.yml +123 -0
  42. nimare/reports/figures.py +651 -0
  43. nimare/reports/report.tpl +160 -0
  44. nimare/resources/__init__.py +1 -0
  45. nimare/resources/atlases/Harvard-Oxford-LICENSE +93 -0
  46. nimare/resources/atlases/HarvardOxford-cort-maxprob-thr25-2mm.nii.gz +0 -0
  47. nimare/resources/database_file_manifest.json +142 -0
  48. nimare/resources/english_spellings.csv +1738 -0
  49. nimare/resources/filenames.json +32 -0
  50. nimare/resources/neurosynth_laird_studies.json +58773 -0
  51. nimare/resources/neurosynth_stoplist.txt +396 -0
  52. nimare/resources/nidm_pain_dset.json +1349 -0
  53. nimare/resources/references.bib +541 -0
  54. nimare/resources/semantic_knowledge_children.txt +325 -0
  55. nimare/resources/semantic_relatedness_children.txt +249 -0
  56. nimare/resources/templates/MNI152_2x2x2_brainmask.nii.gz +0 -0
  57. nimare/resources/templates/tpl-MNI152NLin6Asym_res-01_T1w.nii.gz +0 -0
  58. nimare/resources/templates/tpl-MNI152NLin6Asym_res-01_desc-brain_mask.nii.gz +0 -0
  59. nimare/resources/templates/tpl-MNI152NLin6Asym_res-02_T1w.nii.gz +0 -0
  60. nimare/resources/templates/tpl-MNI152NLin6Asym_res-02_desc-brain_mask.nii.gz +0 -0
  61. nimare/results.py +225 -0
  62. nimare/stats.py +276 -0
  63. nimare/tests/__init__.py +1 -0
  64. nimare/tests/conftest.py +229 -0
  65. nimare/tests/data/amygdala_roi.nii.gz +0 -0
  66. nimare/tests/data/data-neurosynth_version-7_coordinates.tsv.gz +0 -0
  67. nimare/tests/data/data-neurosynth_version-7_metadata.tsv.gz +0 -0
  68. nimare/tests/data/data-neurosynth_version-7_vocab-terms_source-abstract_type-tfidf_features.npz +0 -0
  69. nimare/tests/data/data-neurosynth_version-7_vocab-terms_vocabulary.txt +100 -0
  70. nimare/tests/data/neurosynth_dset.json +2868 -0
  71. nimare/tests/data/neurosynth_laird_studies.json +58773 -0
  72. nimare/tests/data/nidm_pain_dset.json +1349 -0
  73. nimare/tests/data/nimads_annotation.json +1 -0
  74. nimare/tests/data/nimads_studyset.json +1 -0
  75. nimare/tests/data/test_baseline.txt +2 -0
  76. nimare/tests/data/test_pain_dataset.json +1278 -0
  77. nimare/tests/data/test_pain_dataset_multiple_contrasts.json +1242 -0
  78. nimare/tests/data/test_sleuth_file.txt +18 -0
  79. nimare/tests/data/test_sleuth_file2.txt +10 -0
  80. nimare/tests/data/test_sleuth_file3.txt +5 -0
  81. nimare/tests/data/test_sleuth_file4.txt +5 -0
  82. nimare/tests/data/test_sleuth_file5.txt +5 -0
  83. nimare/tests/test_annotate_cogat.py +32 -0
  84. nimare/tests/test_annotate_gclda.py +86 -0
  85. nimare/tests/test_annotate_lda.py +27 -0
  86. nimare/tests/test_dataset.py +99 -0
  87. nimare/tests/test_decode_continuous.py +132 -0
  88. nimare/tests/test_decode_discrete.py +92 -0
  89. nimare/tests/test_diagnostics.py +168 -0
  90. nimare/tests/test_estimator_performance.py +385 -0
  91. nimare/tests/test_extract.py +46 -0
  92. nimare/tests/test_generate.py +247 -0
  93. nimare/tests/test_io.py +294 -0
  94. nimare/tests/test_meta_ale.py +298 -0
  95. nimare/tests/test_meta_cbmr.py +295 -0
  96. nimare/tests/test_meta_ibma.py +240 -0
  97. nimare/tests/test_meta_kernel.py +209 -0
  98. nimare/tests/test_meta_mkda.py +234 -0
  99. nimare/tests/test_nimads.py +21 -0
  100. nimare/tests/test_reports.py +110 -0
  101. nimare/tests/test_stats.py +101 -0
  102. nimare/tests/test_transforms.py +272 -0
  103. nimare/tests/test_utils.py +200 -0
  104. nimare/tests/test_workflows.py +221 -0
  105. nimare/tests/utils.py +126 -0
  106. nimare/transforms.py +907 -0
  107. nimare/utils.py +1367 -0
  108. nimare/workflows/__init__.py +14 -0
  109. nimare/workflows/base.py +189 -0
  110. nimare/workflows/cbma.py +165 -0
  111. nimare/workflows/ibma.py +108 -0
  112. nimare/workflows/macm.py +77 -0
  113. nimare/workflows/misc.py +65 -0
  114. nimare-0.4.2.dist-info/LICENSE +21 -0
  115. nimare-0.4.2.dist-info/METADATA +124 -0
  116. nimare-0.4.2.dist-info/RECORD +119 -0
  117. nimare-0.4.2.dist-info/WHEEL +5 -0
  118. nimare-0.4.2.dist-info/entry_points.txt +2 -0
  119. nimare-0.4.2.dist-info/top_level.txt +2 -0
@@ -0,0 +1,947 @@
1
+ """CBMA methods from the ALE and MKDA families."""
2
+
3
+ import logging
4
+ from abc import abstractmethod
5
+
6
+ import nibabel as nib
7
+ import numpy as np
8
+ import pandas as pd
9
+ import sparse
10
+ from joblib import Memory, Parallel, delayed
11
+ from nilearn.input_data import NiftiMasker
12
+ from scipy import ndimage
13
+ from tqdm.auto import tqdm
14
+
15
+ from nimare.estimator import Estimator
16
+ from nimare.meta.kernel import KernelTransformer
17
+ from nimare.meta.utils import _calculate_cluster_measures, _get_last_bin
18
+ from nimare.results import MetaResult
19
+ from nimare.stats import null_to_p, nullhist_to_p
20
+ from nimare.transforms import p_to_z
21
+ from nimare.utils import (
22
+ _add_metadata_to_dataframe,
23
+ _check_ncores,
24
+ _check_type,
25
+ get_masker,
26
+ mm2vox,
27
+ vox2mm,
28
+ )
29
+
30
+ LGR = logging.getLogger(__name__)
31
+
32
+
33
+ class CBMAEstimator(Estimator):
34
+ """Base class for coordinate-based meta-analysis methods.
35
+
36
+ .. versionchanged:: 0.0.12
37
+
38
+ * Remove *low_memory* option
39
+ * CBMA-specific elements of ``Estimator`` excised and moved into ``CBMAEstimator``.
40
+ * Generic kwargs and args converted to named kwargs. All remaining kwargs are for kernels.
41
+ * Use a 4D sparse array for modeled activation maps.
42
+
43
+ .. versionchanged:: 0.0.8
44
+
45
+ * [REF] Use saved MA maps, when available.
46
+ * [REF] Add *low_memory* option.
47
+
48
+ .. versionadded:: 0.0.3
49
+
50
+ Parameters
51
+ ----------
52
+ kernel_transformer : :obj:`~nimare.meta.kernel.KernelTransformer`, optional
53
+ Kernel with which to convolve coordinates from dataset. Default is
54
+ ALEKernel.
55
+ memory : instance of :class:`joblib.Memory`, :obj:`str`, or :class:`pathlib.Path`
56
+ Used to cache the output of a function. By default, no caching is done.
57
+ If a :obj:`str` is given, it is the path to the caching directory.
58
+ memory_level : :obj:`int`, default=0
59
+ Rough estimator of the amount of memory used by caching.
60
+ Higher value means more memory for caching. Zero means no caching.
61
+ *args
62
+ Optional arguments to the :obj:`~nimare.base.Estimator` __init__
63
+ (called automatically).
64
+ **kwargs
65
+ Optional keyword arguments to the :obj:`~nimare.base.Estimator`
66
+ __init__ (called automatically).
67
+ """
68
+
69
+ # The standard required inputs are just coordinates.
70
+ # An individual CBMAEstimator may override this.
71
+ _required_inputs = {"coordinates": ("coordinates", None)}
72
+
73
+ def __init__(
74
+ self,
75
+ kernel_transformer,
76
+ memory=Memory(location=None, verbose=0),
77
+ memory_level=0,
78
+ *,
79
+ mask=None,
80
+ **kwargs,
81
+ ):
82
+ if mask is not None:
83
+ mask = get_masker(mask, memory=memory, memory_level=memory_level)
84
+ self.masker = mask
85
+
86
+ # Identify any kwargs
87
+ kernel_args = {k: v for k, v in kwargs.items() if k.startswith("kernel__")}
88
+
89
+ # Flag any extraneous kwargs
90
+ other_kwargs = dict(set(kwargs.items()) - set(kernel_args.items()))
91
+ if other_kwargs:
92
+ LGR.warning(f"Unused keyword arguments found: {tuple(other_kwargs.items())}")
93
+
94
+ # Get kernel transformer
95
+ kernel_args = {k.split("kernel__")[1]: v for k, v in kernel_args.items()}
96
+ if "memory" not in kernel_args.keys() and "memory_level" not in kernel_args.keys():
97
+ kernel_args.update(memory=memory, memory_level=memory_level)
98
+ kernel_transformer = _check_type(kernel_transformer, KernelTransformer, **kernel_args)
99
+ self.kernel_transformer = kernel_transformer
100
+
101
+ super().__init__(memory=memory, memory_level=memory_level)
102
+
103
+ def _preprocess_input(self, dataset):
104
+ """Mask required input images using either the Dataset's mask or the Estimator's.
105
+
106
+ Also, insert required metadata into coordinates DataFrame.
107
+
108
+ Parameters
109
+ ----------
110
+ dataset : :obj:`~nimare.dataset.Dataset`
111
+ In this method, the Dataset is used to (1) select the appropriate mask image,
112
+ and (2) extract sample size metadata and place it into the coordinates input.
113
+
114
+ Attributes
115
+ ----------
116
+ inputs_ : :obj:`dict`
117
+ This attribute (created by ``_collect_inputs()``) is updated in this method.
118
+ Specifically, (1) an "ma_maps" key may be added if pre-generated MA maps are available,
119
+ (2) IJK coordinates will be added based on the mask image's affine,
120
+ and (3) sample sizes may be added to the "coordinates" key, as needed.
121
+ """
122
+ masker = self.masker or dataset.masker
123
+
124
+ mask_img = masker.mask_img or masker.labels_img
125
+ if isinstance(mask_img, str):
126
+ mask_img = nib.load(mask_img)
127
+
128
+ for name, (type_, _) in self._required_inputs.items():
129
+ if type_ == "coordinates":
130
+ # Calculate IJK matrix indices for target mask
131
+ # Mask space is assumed to be the same as the Dataset's space
132
+ # These indices are used directly by any KernelTransformer
133
+ xyz = self.inputs_["coordinates"][["x", "y", "z"]].values
134
+ ijk = mm2vox(xyz, mask_img.affine)
135
+ self.inputs_["coordinates"][["i", "j", "k"]] = ijk
136
+
137
+ # All extra (non-ijk) parameters for a kernel should be overrideable as
138
+ # parameters to __init__, so we can access them with get_params()
139
+ kt_args = list(self.kernel_transformer.get_params().keys())
140
+
141
+ # Integrate "sample_size" from metadata into DataFrame so that
142
+ # kernel_transformer can access it.
143
+ if "sample_size" in kt_args:
144
+ self.inputs_["coordinates"] = _add_metadata_to_dataframe(
145
+ dataset,
146
+ self.inputs_["coordinates"],
147
+ metadata_field="sample_sizes",
148
+ target_column="sample_size",
149
+ filter_func=np.mean,
150
+ )
151
+
152
+ def _fit(self, dataset):
153
+ """Perform coordinate-based meta-analysis on dataset.
154
+
155
+ Parameters
156
+ ----------
157
+ dataset : :obj:`~nimare.dataset.Dataset`
158
+ Dataset to analyze.
159
+ """
160
+ self.dataset = dataset
161
+ self.masker = self.masker or dataset.masker
162
+
163
+ if not isinstance(self.masker, NiftiMasker):
164
+ raise ValueError(
165
+ f"A {type(self.masker)} mask has been detected. "
166
+ "Only NiftiMaskers are allowed for this Estimator."
167
+ )
168
+
169
+ self.null_distributions_ = {}
170
+
171
+ ma_values = self._collect_ma_maps(
172
+ coords_key="coordinates",
173
+ maps_key="ma_maps",
174
+ )
175
+
176
+ # Infer a weight vector, when applicable. Primarily used only for MKDADensity.
177
+ self.weight_vec_ = self._compute_weights(ma_values)
178
+
179
+ stat_values = self._compute_summarystat(ma_values)
180
+
181
+ # Determine null distributions for summary stat (OF) to p conversion
182
+ self._determine_histogram_bins(ma_values)
183
+ if self.null_method.startswith("approximate"):
184
+ self._compute_null_approximate(ma_values)
185
+
186
+ elif self.null_method == "montecarlo":
187
+ self._compute_null_montecarlo(n_iters=self.n_iters, n_cores=self.n_cores)
188
+
189
+ else:
190
+ # A hidden option only used for internal validation/testing
191
+ self._compute_null_reduced_montecarlo(ma_values, n_iters=self.n_iters)
192
+
193
+ p_values, z_values = self._summarystat_to_p(stat_values, null_method=self.null_method)
194
+
195
+ maps = {"stat": stat_values, "p": p_values, "z": z_values}
196
+ description = self._generate_description()
197
+ return maps, {}, description
198
+
199
+ def _compute_weights(self, ma_values):
200
+ """Perform optional weight computation routine.
201
+
202
+ Takes an array of meta-analysis values as input and returns an array
203
+ of the same shape, weighted as desired.
204
+ Can be ignored by algorithms that don't support weighting.
205
+ """
206
+ return None
207
+
208
+ def _collect_ma_maps(self, coords_key="coordinates", maps_key="ma_maps", return_type="sparse"):
209
+ """Collect modeled activation maps from Estimator inputs.
210
+
211
+ Parameters
212
+ ----------
213
+ coords_key : :obj:`str`, optional
214
+ Key to ``Estimator.inputs_`` dictionary containing coordinates DataFrame.
215
+ This key should **always** be present.
216
+ Default is "coordinates".
217
+ maps_key : :obj:`str`, optional
218
+ Key to ``Estimator.inputs_`` dictionary containing list of MA map files.
219
+ This key should only be present if the kernel transformer was already fitted to the
220
+ input Dataset.
221
+ Default is "ma_maps".
222
+
223
+ Returns
224
+ -------
225
+ ma_maps : :obj:`sparse._coo.core.COO`
226
+ Return a 4D sparse array of shape
227
+ (n_studies, mask.shape) with MA maps.
228
+ """
229
+ LGR.debug(f"Generating MA maps from coordinates ({coords_key}).")
230
+
231
+ ma_maps = self.kernel_transformer.transform(
232
+ self.inputs_[coords_key],
233
+ masker=self.masker,
234
+ return_type=return_type,
235
+ )
236
+
237
+ return ma_maps
238
+
239
+ def _compute_summarystat(self, data):
240
+ """Compute summary statistics from data.
241
+
242
+ The actual summary statistic varies across Estimators, and is implemented in
243
+ ``_compute_summarystat_est``.
244
+ For ALE and SCALE, the values are known as ALE values.
245
+ For (M)KDA, they are "OF" scores.
246
+
247
+ Parameters
248
+ ----------
249
+ data : array, sparse._coo.core.COO, pandas.DataFrame, or list of img_like
250
+ Data from which to estimate summary statistics.
251
+ The data can be:
252
+ (1) a 1d contrast-len or 2d contrast-by-voxel array of MA values,
253
+ (2) a 4d sparse array of MA maps,
254
+ (3) a DataFrame containing coordinates to produce MA values,
255
+ or (4) a list of imgs containing MA values.
256
+
257
+ Returns
258
+ -------
259
+ stat_values : 1d array
260
+ Summary statistic values. One value per voxel.
261
+ """
262
+ if isinstance(data, pd.DataFrame):
263
+ ma_values = self.kernel_transformer.transform(
264
+ data, masker=self.masker, return_type="sparse"
265
+ )
266
+ elif isinstance(data, list):
267
+ ma_values = self.masker.transform(data)
268
+ elif isinstance(data, (np.ndarray, sparse._coo.core.COO)):
269
+ ma_values = data
270
+ else:
271
+ raise ValueError(f"Unsupported data type '{type(data)}'")
272
+
273
+ # Apply weights before returning
274
+ return self._compute_summarystat_est(ma_values)
275
+
276
+ @abstractmethod
277
+ def _compute_summarystat_est(self, ma_values):
278
+ """Compute summary statistic according to estimator-specific method.
279
+
280
+ Must be overriden by subclasses.
281
+ Input and output are both numpy arrays; the output must
282
+ aggregate over the 0th dimension of the input.
283
+ (i.e., if the input has K dimensions, the output has K - 1 dimensions.)
284
+ """
285
+ pass
286
+
287
+ def _summarystat_to_p(self, stat_values, null_method="approximate"):
288
+ """Compute p- and z-values from summary statistics (e.g., ALE scores).
289
+
290
+ Uses either histograms from "approximate" null or null distribution from "montecarlo" null.
291
+
292
+ Parameters
293
+ ----------
294
+ stat_values : 1D array_like
295
+ Array of summary statistic values from estimator.
296
+ null_method : {"approximate", "montecarlo"}, optional
297
+ Whether to use approximate null or montecarlo null.
298
+ Default is "approximate".
299
+
300
+ Returns
301
+ -------
302
+ p_values, z_values : 1D array
303
+ P- and Z-values for statistic values.
304
+ Same shape as stat_values.
305
+ """
306
+ if null_method.startswith("approximate"):
307
+ assert "histogram_bins" in self.null_distributions_.keys()
308
+ assert "histweights_corr-none_method-approximate" in self.null_distributions_.keys()
309
+
310
+ p_values = nullhist_to_p(
311
+ stat_values,
312
+ self.null_distributions_["histweights_corr-none_method-approximate"],
313
+ self.null_distributions_["histogram_bins"],
314
+ )
315
+
316
+ elif null_method == "montecarlo":
317
+ assert "histogram_bins" in self.null_distributions_.keys()
318
+ assert "histweights_corr-none_method-montecarlo" in self.null_distributions_.keys()
319
+
320
+ p_values = nullhist_to_p(
321
+ stat_values,
322
+ self.null_distributions_["histweights_corr-none_method-montecarlo"],
323
+ self.null_distributions_["histogram_bins"],
324
+ )
325
+
326
+ elif null_method == "reduced_montecarlo":
327
+ assert "values_corr-none_method-reducedMontecarlo" in self.null_distributions_.keys()
328
+
329
+ p_values = null_to_p(
330
+ stat_values,
331
+ self.null_distributions_["values_corr-none_method-reducedMontecarlo"],
332
+ tail="upper",
333
+ )
334
+
335
+ else:
336
+ raise ValueError("Argument 'null_method' must be one of: 'approximate', 'montecarlo'.")
337
+
338
+ z_values = p_to_z(p_values, tail="one")
339
+ return p_values, z_values
340
+
341
+ def _p_to_summarystat(self, p, null_method=None):
342
+ """Compute a summary statistic threshold that corresponds to the provided p-value.
343
+
344
+ Uses either histograms from approximate null or null distribution from montecarlo null.
345
+
346
+ Parameters
347
+ ----------
348
+ p : :obj:`float`
349
+ The p-value that corresponds to the summary statistic threshold.
350
+ null_method : {None, "approximate", "montecarlo"}, optional
351
+ Whether to use approximate null or montecarlo null. If None, defaults to using
352
+ whichever method was set at initialization.
353
+
354
+ Returns
355
+ -------
356
+ ss : float
357
+ A float giving the summary statistic value corresponding to the passed p.
358
+ """
359
+ if null_method is None:
360
+ null_method = self.null_method
361
+
362
+ if null_method.startswith("approximate"):
363
+ assert "histogram_bins" in self.null_distributions_.keys()
364
+ assert "histweights_corr-none_method-approximate" in self.null_distributions_.keys()
365
+
366
+ # Convert unnormalized histogram weights to null distribution
367
+ histogram_weights = self.null_distributions_[
368
+ "histweights_corr-none_method-approximate"
369
+ ]
370
+ null_distribution = histogram_weights / np.sum(histogram_weights)
371
+ null_distribution = np.cumsum(null_distribution[::-1])[::-1]
372
+ null_distribution /= np.max(null_distribution)
373
+ null_distribution = np.squeeze(null_distribution)
374
+
375
+ # Desired bin is the first one _before_ the target p-value (for uniformity
376
+ # with the montecarlo null).
377
+ ss_idx = np.maximum(0, np.where(null_distribution <= p)[0][0] - 1)
378
+ ss = self.null_distributions_["histogram_bins"][ss_idx]
379
+
380
+ elif null_method == "montecarlo":
381
+ assert "histogram_bins" in self.null_distributions_.keys()
382
+ assert "histweights_corr-none_method-montecarlo" in self.null_distributions_.keys()
383
+
384
+ hist_weights = self.null_distributions_["histweights_corr-none_method-montecarlo"]
385
+ # Desired bin is the first one _before_ the target p-value (for uniformity
386
+ # with the montecarlo null).
387
+ ss_idx = np.maximum(0, np.where(hist_weights <= p)[0][0] - 1)
388
+ ss = self.null_distributions_["histogram_bins"][ss_idx]
389
+
390
+ elif null_method == "reduced_montecarlo":
391
+ assert "values_corr-none_method-reducedMontecarlo" in self.null_distributions_.keys()
392
+
393
+ null_dist = np.sort(
394
+ self.null_distributions_["values_corr-none_method-reducedMontecarlo"]
395
+ )
396
+ n_vals = len(null_dist)
397
+ ss_idx = np.floor(p * n_vals).astype(int)
398
+ ss = null_dist[-ss_idx]
399
+
400
+ else:
401
+ raise ValueError("Argument 'null_method' must be one of: 'approximate', 'montecarlo'.")
402
+
403
+ return ss
404
+
405
+ def _compute_null_reduced_montecarlo(self, ma_maps, n_iters=5000):
406
+ """Compute uncorrected null distribution using the reduced montecarlo method.
407
+
408
+ This method is much faster than the full montecarlo approach, but is still slower than the
409
+ approximate method. Given that its resolution is roughly the same as the approximate
410
+ method, we recommend against using this method.
411
+
412
+ Parameters
413
+ ----------
414
+ ma_maps : (C x V) array
415
+ Contrast by voxel array of MA values, after weighting with weight_vec.
416
+
417
+ Notes
418
+ -----
419
+ This method adds one entry to the null_distributions_ dict attribute:
420
+ "values_corr-none_method-reducedMontecarlo".
421
+
422
+ Warnings
423
+ --------
424
+ This method is only retained for testing and algorithm development.
425
+ """
426
+ if isinstance(ma_maps, sparse._coo.core.COO):
427
+ masker = self.dataset.masker if not self.masker else self.masker
428
+ mask = masker.mask_img
429
+ mask_data = mask.get_fdata().astype(bool)
430
+
431
+ ma_maps = ma_maps.todense()
432
+ ma_maps = ma_maps[:, mask_data]
433
+
434
+ n_studies, n_voxels = ma_maps.shape
435
+ null_ijk = np.random.choice(np.arange(n_voxels), (n_iters, n_studies))
436
+ iter_ma_values = ma_maps[np.arange(n_studies), tuple(null_ijk)].T
437
+ null_dist = self._compute_summarystat(iter_ma_values)
438
+ self.null_distributions_["values_corr-none_method-reducedMontecarlo"] = null_dist
439
+
440
+ def _compute_null_montecarlo_permutation(self, iter_xyz, iter_df):
441
+ """Run a single Monte Carlo permutation of a dataset.
442
+
443
+ Does the shared work between uncorrected stat-to-p conversion and vFWE.
444
+
445
+ Parameters
446
+ ----------
447
+ params : tuple
448
+ A tuple containing 2 elements, respectively providing (1) the permuted
449
+ coordinates and (2) the original coordinate DataFrame.
450
+
451
+ Returns
452
+ -------
453
+ counts : 1D array_like
454
+ Weights associated with the attribute `null_distributions_["histogram_bins"]`.
455
+ """
456
+ # Not sure if joblib will automatically use a copy of the object, but I'll make a copy to
457
+ # be safe.
458
+ iter_df = iter_df.copy()
459
+
460
+ iter_xyz = np.squeeze(iter_xyz)
461
+ iter_df[["x", "y", "z"]] = iter_xyz
462
+
463
+ iter_ma_maps = self.kernel_transformer.transform(
464
+ iter_df, masker=self.masker, return_type="sparse"
465
+ )
466
+ iter_ss_map = self._compute_summarystat(iter_ma_maps)
467
+
468
+ del iter_ma_maps
469
+
470
+ # Get bin edges for histogram
471
+ bin_centers = self.null_distributions_["histogram_bins"]
472
+ step_size = bin_centers[1] - bin_centers[0]
473
+ bin_edges = bin_centers - (step_size / 2)
474
+ bin_edges = np.append(bin_centers, bin_centers[-1] + step_size)
475
+
476
+ counts, _ = np.histogram(iter_ss_map, bins=bin_edges, density=False)
477
+ return counts
478
+
479
+ def _compute_null_montecarlo(self, n_iters, n_cores):
480
+ """Compute uncorrected null distribution using Monte Carlo method.
481
+
482
+ Parameters
483
+ ----------
484
+ n_iters : int
485
+ Number of permutations.
486
+ n_cores : int
487
+ Number of cores to use.
488
+
489
+ Notes
490
+ -----
491
+ This method adds two entries to the null_distributions_ dict attribute:
492
+ "histweights_corr-none_method-montecarlo" and
493
+ "histweights_level-voxel_corr-fwe_method-montecarlo".
494
+ """
495
+ null_ijk = np.vstack(np.where(self.masker.mask_img.get_fdata())).T
496
+
497
+ n_cores = _check_ncores(n_cores)
498
+
499
+ rand_idx = np.random.choice(
500
+ null_ijk.shape[0],
501
+ size=(self.inputs_["coordinates"].shape[0], n_iters),
502
+ )
503
+ rand_ijk = null_ijk[rand_idx, :]
504
+ rand_xyz = vox2mm(rand_ijk, self.masker.mask_img.affine)
505
+ iter_xyzs = np.split(rand_xyz, rand_xyz.shape[1], axis=1)
506
+ iter_df = self.inputs_["coordinates"].copy()
507
+
508
+ perm_histograms = [
509
+ r
510
+ for r in tqdm(
511
+ Parallel(return_as="generator", n_jobs=n_cores)(
512
+ delayed(self._compute_null_montecarlo_permutation)(
513
+ iter_xyzs[i_iter], iter_df=iter_df
514
+ )
515
+ for i_iter in range(n_iters)
516
+ ),
517
+ total=n_iters,
518
+ )
519
+ ]
520
+
521
+ perm_histograms = np.vstack(perm_histograms)
522
+ self.null_distributions_["histweights_corr-none_method-montecarlo"] = np.sum(
523
+ perm_histograms, axis=0
524
+ )
525
+
526
+ fwe_voxel_max = np.apply_along_axis(_get_last_bin, 1, perm_histograms)
527
+ histweights = np.zeros(perm_histograms.shape[1], dtype=perm_histograms.dtype)
528
+ for perm in fwe_voxel_max:
529
+ histweights[perm] += 1
530
+
531
+ self.null_distributions_["histweights_level-voxel_corr-fwe_method-montecarlo"] = (
532
+ histweights
533
+ )
534
+
535
+ def _correct_fwe_montecarlo_permutation(
536
+ self,
537
+ iter_xyz,
538
+ iter_df,
539
+ conn,
540
+ voxel_thresh,
541
+ vfwe_only,
542
+ ):
543
+ """Run a single Monte Carlo permutation of a dataset.
544
+
545
+ Does the shared work between vFWE and cFWE.
546
+
547
+ Parameters
548
+ ----------
549
+ iter_xyz : :obj:`numpy.ndarray` of shape (C, 3)
550
+ The permuted coordinates. One row for each peak.
551
+ Columns correspond to x, y, and z coordinates.
552
+ iter_df : :obj:`pandas.DataFrame`
553
+ The coordinates DataFrame, to be filled with the permuted coordinates in ``iter_xyz``
554
+ before permutation MA maps are generated.
555
+ conn : :obj:`numpy.ndarray` of shape (3, 3, 3)
556
+ The 3D structuring array for labeling clusters.
557
+ voxel_thresh : :obj:`float`
558
+ Uncorrected summary statistic threshold for defining clusters.
559
+ vfwe_only : :obj:`bool`
560
+ If True, only calculate the voxel-level FWE-corrected maps.
561
+
562
+ Returns
563
+ -------
564
+ (iter_max value, iter_max_cluster, iter_max_mass)
565
+ A 3-tuple of floats giving the maximum voxel-wise value, maximum cluster size,
566
+ and maximum cluster mass for the permuted dataset.
567
+ If ``vfwe_only`` is True, the latter two values will be None.
568
+ """
569
+ iter_df = iter_df.copy()
570
+
571
+ iter_xyz = np.squeeze(iter_xyz)
572
+ iter_df[["x", "y", "z"]] = iter_xyz
573
+
574
+ iter_ma_maps = self.kernel_transformer.transform(
575
+ iter_df, masker=self.masker, return_type="sparse"
576
+ )
577
+ iter_ss_map = self._compute_summarystat(iter_ma_maps)
578
+
579
+ del iter_ma_maps
580
+
581
+ # Voxel-level inference
582
+ iter_max_value = np.max(iter_ss_map)
583
+
584
+ if vfwe_only:
585
+ iter_max_size, iter_max_mass = None, None
586
+ else:
587
+ # Cluster-level inference
588
+ iter_ss_map = self.masker.inverse_transform(iter_ss_map).get_fdata()
589
+ iter_max_size, iter_max_mass = _calculate_cluster_measures(
590
+ iter_ss_map, voxel_thresh, conn, tail="upper"
591
+ )
592
+ return iter_max_value, iter_max_size, iter_max_mass
593
+
594
+ def correct_fwe_montecarlo(
595
+ self,
596
+ result,
597
+ voxel_thresh=0.001,
598
+ n_iters=5000,
599
+ n_cores=1,
600
+ vfwe_only=False,
601
+ ):
602
+ """Perform FWE correction using the max-value permutation method.
603
+
604
+ Only call this method from within a Corrector.
605
+
606
+ .. versionchanged:: 0.0.13
607
+
608
+ Change cluster neighborhood from faces+edges to faces, to match Nilearn.
609
+
610
+ .. versionchanged:: 0.0.12
611
+
612
+ * Fix the ``vfwe_only`` option.
613
+
614
+ .. versionchanged:: 0.0.11
615
+
616
+ * Rename ``*_level-cluster`` maps to ``*_desc-size_level-cluster``.
617
+ * Add new ``*_desc-mass_level-cluster`` maps that use cluster mass-based inference.
618
+
619
+ Parameters
620
+ ----------
621
+ result : :obj:`~nimare.results.MetaResult`
622
+ Result object from a CBMA meta-analysis.
623
+ voxel_thresh : :obj:`float`, default=0.001
624
+ Cluster-defining p-value threshold. Default is 0.001.
625
+ n_iters : :obj:`int`, default=5000
626
+ Number of iterations to build the voxel-level, cluster-size, and cluster-mass FWE
627
+ null distributions. Default is 5000.
628
+ n_cores : :obj:`int`, default=1
629
+ Number of cores to use for parallelization.
630
+ If <=0, defaults to using all available cores. Default is 1.
631
+ vfwe_only : :obj:`bool`, default=False
632
+ If True, only calculate the voxel-level FWE-corrected maps. Voxel-level correction
633
+ can be performed very quickly if the Estimator's ``null_method`` was "montecarlo".
634
+ Default is False.
635
+
636
+ Returns
637
+ -------
638
+ images : :obj:`dict`
639
+ Dictionary of 1D arrays corresponding to masked images generated by
640
+ the correction procedure. The following arrays are generated by
641
+ this method:
642
+
643
+ - ``logp_desc-size_level-cluster``: Cluster-level FWE-corrected ``-log10(p)`` map
644
+ based on cluster size. This was previously simply called "logp_level-cluster".
645
+ This array is **not** generated if ``vfwe_only`` is ``True``.
646
+ - ``logp_desc-mass_level-cluster``: Cluster-level FWE-corrected ``-log10(p)`` map
647
+ based on cluster mass. According to :footcite:t:`bullmore1999global` and
648
+ :footcite:t:`zhang2009cluster`, cluster mass-based inference is more powerful than
649
+ cluster size.
650
+ This array is **not** generated if ``vfwe_only`` is ``True``.
651
+ - ``logp_level-voxel``: Voxel-level FWE-corrected ``-log10(p)`` map.
652
+ Voxel-level correction is generally more conservative than cluster-level
653
+ correction, so it is only recommended for very large meta-analyses
654
+ (i.e., hundreds of studies), per :footcite:t:`eickhoff2016behavior`.
655
+ description_ : :obj:`str`
656
+ A text description of the correction procedure.
657
+
658
+ Notes
659
+ -----
660
+ If ``vfwe_only`` is ``False``, this method adds three new keys to the
661
+ ``null_distributions_`` attribute:
662
+
663
+ - ``values_level-voxel_corr-fwe_method-montecarlo``: The maximum summary statistic
664
+ value from each Monte Carlo iteration. An array of shape (n_iters,).
665
+ - ``values_desc-size_level-cluster_corr-fwe_method-montecarlo``: The maximum cluster
666
+ size from each Monte Carlo iteration. An array of shape (n_iters,).
667
+ - ``values_desc-mass_level-cluster_corr-fwe_method-montecarlo``: The maximum cluster
668
+ mass from each Monte Carlo iteration. An array of shape (n_iters,).
669
+
670
+ See Also
671
+ --------
672
+ nimare.correct.FWECorrector : The Corrector from which to call this method.
673
+
674
+ References
675
+ ----------
676
+ .. footbibliography::
677
+
678
+ Examples
679
+ --------
680
+ >>> meta = MKDADensity()
681
+ >>> result = meta.fit(dset)
682
+ >>> corrector = FWECorrector(method='montecarlo', voxel_thresh=0.01,
683
+ n_iters=5, n_cores=1)
684
+ >>> cresult = corrector.transform(result)
685
+ """
686
+ stat_values = result.get_map("stat", return_type="array")
687
+
688
+ if vfwe_only and (self.null_method == "montecarlo"):
689
+ LGR.info("Using precalculated histogram for voxel-level FWE correction.")
690
+
691
+ # Determine p- and z-values from stat values and null distribution.
692
+ p_vfwe_values = nullhist_to_p(
693
+ stat_values,
694
+ self.null_distributions_["histweights_level-voxel_corr-fwe_method-montecarlo"],
695
+ self.null_distributions_["histogram_bins"],
696
+ )
697
+
698
+ else:
699
+ if vfwe_only:
700
+ LGR.warn(
701
+ "In order to run this method with the 'vfwe_only' option, "
702
+ "the Estimator must use the 'montecarlo' null_method. "
703
+ "Running permutations from scratch."
704
+ )
705
+
706
+ null_xyz = vox2mm(
707
+ np.vstack(np.where(self.masker.mask_img.get_fdata())).T,
708
+ self.masker.mask_img.affine,
709
+ )
710
+
711
+ n_cores = _check_ncores(n_cores)
712
+
713
+ # Identify summary statistic corresponding to intensity threshold
714
+ ss_thresh = self._p_to_summarystat(voxel_thresh)
715
+
716
+ rand_idx = np.random.choice(
717
+ null_xyz.shape[0],
718
+ size=(self.inputs_["coordinates"].shape[0], n_iters),
719
+ )
720
+ rand_xyz = null_xyz[rand_idx, :]
721
+ iter_xyzs = np.split(rand_xyz, rand_xyz.shape[1], axis=1)
722
+ iter_df = self.inputs_["coordinates"].copy()
723
+
724
+ # Define connectivity matrix for cluster labeling
725
+ conn = ndimage.generate_binary_structure(rank=3, connectivity=1)
726
+
727
+ perm_results = [
728
+ r
729
+ for r in tqdm(
730
+ Parallel(return_as="generator", n_jobs=n_cores)(
731
+ delayed(self._correct_fwe_montecarlo_permutation)(
732
+ iter_xyzs[i_iter],
733
+ iter_df=iter_df,
734
+ conn=conn,
735
+ voxel_thresh=ss_thresh,
736
+ vfwe_only=vfwe_only,
737
+ )
738
+ for i_iter in range(n_iters)
739
+ ),
740
+ total=n_iters,
741
+ )
742
+ ]
743
+
744
+ fwe_voxel_max, fwe_cluster_size_max, fwe_cluster_mass_max = zip(*perm_results)
745
+
746
+ if not vfwe_only:
747
+ # Cluster-level FWE
748
+ # Extract the summary statistics in voxel-wise (3D) form, threshold, and
749
+ # cluster-label
750
+ thresh_stat_values = self.masker.inverse_transform(stat_values).get_fdata()
751
+ thresh_stat_values[thresh_stat_values <= ss_thresh] = 0
752
+ labeled_matrix, _ = ndimage.label(thresh_stat_values, conn)
753
+
754
+ cluster_labels, idx, cluster_sizes = np.unique(
755
+ labeled_matrix,
756
+ return_inverse=True,
757
+ return_counts=True,
758
+ )
759
+ assert cluster_labels[0] == 0
760
+
761
+ # Cluster mass-based inference
762
+ cluster_masses = np.zeros(cluster_labels.shape)
763
+ for i_val in cluster_labels:
764
+ if i_val == 0:
765
+ cluster_masses[i_val] = 0
766
+
767
+ cluster_mass = np.sum(thresh_stat_values[labeled_matrix == i_val] - ss_thresh)
768
+ cluster_masses[i_val] = cluster_mass
769
+
770
+ p_cmfwe_vals = null_to_p(cluster_masses, fwe_cluster_mass_max, "upper")
771
+ p_cmfwe_map = p_cmfwe_vals[np.reshape(idx, labeled_matrix.shape)]
772
+
773
+ p_cmfwe_values = np.squeeze(
774
+ self.masker.transform(
775
+ nib.Nifti1Image(p_cmfwe_map, self.masker.mask_img.affine)
776
+ )
777
+ )
778
+ logp_cmfwe_values = -np.log10(p_cmfwe_values)
779
+ logp_cmfwe_values[np.isinf(logp_cmfwe_values)] = -np.log10(np.finfo(float).eps)
780
+ z_cmfwe_values = p_to_z(p_cmfwe_values, tail="one")
781
+
782
+ # Cluster size-based inference
783
+ cluster_sizes[0] = 0 # replace background's "cluster size" with zeros
784
+ p_csfwe_vals = null_to_p(cluster_sizes, fwe_cluster_size_max, "upper")
785
+ p_csfwe_map = p_csfwe_vals[np.reshape(idx, labeled_matrix.shape)]
786
+
787
+ p_csfwe_values = np.squeeze(
788
+ self.masker.transform(
789
+ nib.Nifti1Image(p_csfwe_map, self.masker.mask_img.affine)
790
+ )
791
+ )
792
+ logp_csfwe_values = -np.log10(p_csfwe_values)
793
+ logp_csfwe_values[np.isinf(logp_csfwe_values)] = -np.log10(np.finfo(float).eps)
794
+ z_csfwe_values = p_to_z(p_csfwe_values, tail="one")
795
+
796
+ self.null_distributions_[
797
+ "values_desc-size_level-cluster_corr-fwe_method-montecarlo"
798
+ ] = fwe_cluster_size_max
799
+ self.null_distributions_[
800
+ "values_desc-mass_level-cluster_corr-fwe_method-montecarlo"
801
+ ] = fwe_cluster_mass_max
802
+
803
+ # Voxel-level FWE
804
+ LGR.info("Using null distribution for voxel-level FWE correction.")
805
+ p_vfwe_values = null_to_p(stat_values, fwe_voxel_max, tail="upper")
806
+ self.null_distributions_["values_level-voxel_corr-fwe_method-montecarlo"] = (
807
+ fwe_voxel_max
808
+ )
809
+
810
+ z_vfwe_values = p_to_z(p_vfwe_values, tail="one")
811
+ logp_vfwe_values = -np.log10(p_vfwe_values)
812
+ logp_vfwe_values[np.isinf(logp_vfwe_values)] = -np.log10(np.finfo(float).eps)
813
+
814
+ if vfwe_only:
815
+ # Return unthresholded value images
816
+ maps = {
817
+ "logp_level-voxel": logp_vfwe_values,
818
+ "z_level-voxel": z_vfwe_values,
819
+ }
820
+
821
+ else:
822
+ # Return unthresholded value images
823
+ maps = {
824
+ "logp_level-voxel": logp_vfwe_values,
825
+ "z_level-voxel": z_vfwe_values,
826
+ "logp_desc-size_level-cluster": logp_csfwe_values,
827
+ "z_desc-size_level-cluster": z_csfwe_values,
828
+ "logp_desc-mass_level-cluster": logp_cmfwe_values,
829
+ "z_desc-mass_level-cluster": z_cmfwe_values,
830
+ }
831
+
832
+ if vfwe_only:
833
+ description = (
834
+ "Family-wise error correction was performed using a voxel-level Monte Carlo "
835
+ "procedure. "
836
+ "In this procedure, null datasets are generated in which dataset coordinates are "
837
+ "substituted with coordinates randomly drawn from the meta-analysis mask, and "
838
+ "the maximum summary statistic is retained. "
839
+ f"This procedure was repeated {n_iters} times to build a null distribution of "
840
+ "summary statistics."
841
+ )
842
+ else:
843
+ description = (
844
+ "Family-wise error rate correction was performed using a Monte Carlo procedure. "
845
+ "In this procedure, null datasets are generated in which dataset coordinates are "
846
+ "substituted with coordinates randomly drawn from the meta-analysis mask, and "
847
+ "maximum values are retained. "
848
+ f"This procedure was repeated {n_iters} times to build null distributions of "
849
+ "summary statistics, cluster sizes, and cluster masses. "
850
+ "Clusters for cluster-level correction were defined using edge-wise connectivity "
851
+ f"and a voxel-level threshold of p < {voxel_thresh} from the uncorrected null "
852
+ "distribution."
853
+ )
854
+
855
+ return maps, {}, description
856
+
857
+
858
+ class PairwiseCBMAEstimator(CBMAEstimator):
859
+ """Base class for pairwise coordinate-based meta-analysis methods.
860
+
861
+ .. versionchanged:: 0.0.12
862
+
863
+ - Use a 4D sparse array for modeled activation maps.
864
+
865
+ .. versionchanged:: 0.0.8
866
+
867
+ * [REF] Use saved MA maps, when available.
868
+
869
+ .. versionadded:: 0.0.3
870
+
871
+ Parameters
872
+ ----------
873
+ kernel_transformer : :obj:`~nimare.meta.kernel.KernelTransformer`, optional
874
+ Kernel with which to convolve coordinates from dataset. Default is
875
+ ALEKernel.
876
+ memory : instance of :class:`joblib.Memory`, :obj:`str`, or :class:`pathlib.Path`
877
+ Used to cache the output of a function. By default, no caching is done.
878
+ If a :obj:`str` is given, it is the path to the caching directory.
879
+ memory_level : :obj:`int`, default=0
880
+ Rough estimator of the amount of memory used by caching.
881
+ Higher value means more memory for caching. Zero means no caching.
882
+ *args
883
+ Optional arguments to the :obj:`~nimare.base.Estimator` __init__
884
+ (called automatically).
885
+ **kwargs
886
+ Optional keyword arguments to the :obj:`~nimare.base.Estimator`
887
+ __init__ (called automatically).
888
+ """
889
+
890
+ def _compute_summarystat_est(self, ma_values):
891
+ """Calculate the Estimator's summary statistic.
892
+
893
+ This method is only included because CBMAEstimator has it as an abstract method.
894
+ PairwiseCBMAEstimators are not constructed uniformly enough for this structure to work
895
+ consistently.
896
+ """
897
+ raise NotImplementedError
898
+
899
+ def fit(self, dataset1, dataset2, drop_invalid=True):
900
+ """Fit Estimator to two Datasets.
901
+
902
+ Parameters
903
+ ----------
904
+ dataset1/dataset2 : :obj:`~nimare.dataset.Dataset`
905
+ Dataset objects to analyze.
906
+
907
+ Returns
908
+ -------
909
+ :obj:`~nimare.results.MetaResult`
910
+ Results of Estimator fitting.
911
+
912
+ Notes
913
+ -----
914
+ The `fit` method is a light wrapper that runs input validation and
915
+ preprocessing before fitting the actual model. Estimators' individual
916
+ "fitting" methods are implemented as `_fit`, although users should
917
+ call `fit`.
918
+ """
919
+ # Reproduce fit() for dataset1 to collect and process inputs.
920
+ self._collect_inputs(dataset1, drop_invalid=drop_invalid)
921
+ self._preprocess_input(dataset1)
922
+ if "ma_maps" in self.inputs_.keys():
923
+ # Grab pre-generated MA maps
924
+ self.inputs_["ma_maps1"] = self.inputs_.pop("ma_maps")
925
+
926
+ self.inputs_["id1"] = self.inputs_.pop("id")
927
+ self.inputs_["coordinates1"] = self.inputs_.pop("coordinates")
928
+
929
+ # Reproduce fit() for dataset2 to collect and process inputs.
930
+ self._collect_inputs(dataset2, drop_invalid=drop_invalid)
931
+ self._preprocess_input(dataset2)
932
+ if "ma_maps" in self.inputs_.keys():
933
+ # Grab pre-generated MA maps
934
+ self.inputs_["ma_maps2"] = self.inputs_.pop("ma_maps")
935
+
936
+ self.inputs_["id2"] = self.inputs_.pop("id")
937
+ self.inputs_["coordinates2"] = self.inputs_.pop("coordinates")
938
+
939
+ # Now run the Estimator-specific _fit() method.
940
+ maps, tables, description = self._cache(self._fit, func_memory_level=1)(dataset1, dataset2)
941
+
942
+ if hasattr(self, "masker") and self.masker is not None:
943
+ masker = self.masker
944
+ else:
945
+ masker = dataset1.masker
946
+
947
+ return MetaResult(self, mask=masker, maps=maps, tables=tables, description=description)