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
nimare/meta/cbmr.py ADDED
@@ -0,0 +1,970 @@
1
+ """Coordinate Based Meta Regression Methods."""
2
+
3
+ import logging
4
+ import re
5
+ from functools import wraps
6
+
7
+ import nibabel as nib
8
+ import numpy as np
9
+ import pandas as pd
10
+ import scipy
11
+
12
+ try:
13
+ import torch
14
+ except ImportError as e:
15
+ raise ImportError(
16
+ "Torch is required to use `CBMR` classes. Install with `pip install 'nimare[cbmr]'`."
17
+ ) from e
18
+
19
+ from nimare import _version
20
+ from nimare.diagnostics import FocusFilter
21
+ from nimare.estimator import Estimator
22
+ from nimare.meta import models
23
+ from nimare.utils import b_spline_bases, dummy_encoding_moderators, get_masker, mm2vox
24
+
25
+ LGR = logging.getLogger(__name__)
26
+ __version__ = _version.get_versions()["version"]
27
+
28
+
29
+ class CBMREstimator(Estimator):
30
+ """Coordinate-based meta-regression with a spatial model.
31
+
32
+ .. versionadded:: 0.1.0
33
+
34
+ Parameters
35
+ ----------
36
+ group_categories : :obj:`~str` or obj:`~list` or obj:`~None`, optional
37
+ CBMR allows dataset to be categorized into mutiple groups, according to group categories.
38
+ Default is one-group CBMR.
39
+ moderators : :obj:`~str` or obj:`~list` or obj:`~None`, optional
40
+ CBMR can accommodate study-level moderators (e.g. sample size, year of publication).
41
+ Default is CBMR without study-level moderators.
42
+ model : : :obj:`~nimare.meta.models.GeneralLinearModel`, optional
43
+ Stochastic models in CBMR. The available options are
44
+
45
+ ======================= ==================================================================
46
+ Poisson (default) This is the most efficient and widely used method, but slightly
47
+ less accurate, because Poisson model is an approximation for
48
+ low-rate Binomial data, but cannot account over-dispersion in
49
+ foci counts and may underestimate the standard error.
50
+
51
+ NegativeBinomial This method might be slower and less stable, but slightly more
52
+ accurate. Negative Binomial (NB) model asserts foci counts follow
53
+ a NB distribution, and allows for anticipated excess variance
54
+ relative to Poisson (there's an group-wise overdispersion parameter
55
+ shared by all studies and all voxels to index excess variance).
56
+
57
+ ClusteredNegativeBinomial This method is also an efficient but less accurate approach.
58
+ Clustered NB model is "random effect" Poisson model, which asserts
59
+ that the random effects are latent characteristics of each study,
60
+ and represent a shared effect over the entire brain for a given
61
+ study.
62
+ ======================= =================================================================
63
+ penalty: :obj:`~bool`, optional
64
+ Currently, the only available option is Firth-type penalty, which penalizes likelihood function
65
+ by Jeffrey's invariant prior and guarantees convergent estimates.
66
+ spline_spacing: :obj:`~int`, optional
67
+ Spatial structure of foci counts is parameterized by coefficient of cubic B-spline bases
68
+ in CBMR. Spatial smoothness in CBMR is determined by spline spacing, which is shared across
69
+ x,y,z dimension.
70
+ Default is 10 (20mm with 2mm brain atlas template).
71
+ n_iters: :obj:`int`, optional
72
+ Number of iterations limit in optimisation of log-likelihood function.
73
+ Default is 10000.
74
+ lr: :obj:`float`, optional
75
+ Learning rate in optimization of log-likelihood function.
76
+ Default is 1e-2 for Poisson and clustered NB model, and 1e-3 for NB model.
77
+ lr_decay: :obj:`float`, optional
78
+ Multiplicative factor of learning rate decay.
79
+ Default is 0.999.
80
+ tol: :obj:`float`, optional
81
+ Stopping criteria w.r.t difference of log-likelihood function in two consecutive
82
+ iterations.
83
+ Default is 1e-2
84
+ device: :obj:`string`, optional
85
+ Device type ('cpu' or 'cuda') represents the device on which operations will be allocated
86
+ Default is 'cpu'
87
+ **kwargs
88
+ Keyword arguments. Arguments for the Estimator can be assigned here,
89
+ Another optional argument is ``mask``.
90
+
91
+ Attributes
92
+ ----------
93
+ masker : :class:`~nilearn.input_data.NiftiMasker` or similar
94
+ Masker object.
95
+ inputs_ : :obj:`dict`
96
+ Inputs to the Estimator. For CBMR estimators, there is only multiple keys:
97
+ coordinates,
98
+ mask_img (Niftiimage of brain mask),
99
+ id (study id),
100
+ studies_by_groups (study id categorized by groups),
101
+ all_group_moderators (study-level moderators categorized by groups if exist),
102
+ coef_spline_bases (spatial matrix of coefficient of cubic B-spline
103
+ bases in x,y,z dimension),
104
+ foci_per_voxel (voxelwise sum of foci count across studies, categorized by groups),
105
+ foci_per_study (study-wise sum of foci count across space, categorized by groups).
106
+
107
+ Notes
108
+ -----
109
+ Available correction methods: :meth:`~nimare.meta.cbmr.CBMRInference`.
110
+ """
111
+
112
+ _required_inputs = {"coordinates": ("coordinates", None)}
113
+
114
+ def __init__(
115
+ self,
116
+ group_categories=None,
117
+ moderators=None,
118
+ mask=None,
119
+ spline_spacing=10,
120
+ model=models.PoissonEstimator,
121
+ penalty=False,
122
+ n_iter=2000,
123
+ lr=1,
124
+ lr_decay=0.999,
125
+ tol=1e-9,
126
+ device="cpu",
127
+ **kwargs,
128
+ ):
129
+ super().__init__(**kwargs)
130
+ if mask is not None:
131
+ mask = get_masker(mask)
132
+ self.masker = mask
133
+
134
+ self.group_categories = group_categories
135
+ self.moderators = moderators
136
+
137
+ self.spline_spacing = spline_spacing
138
+ self.model = model(
139
+ penalty=penalty, lr=lr, lr_decay=lr_decay, n_iter=n_iter, tol=tol, device=device
140
+ )
141
+ self.penalty = penalty
142
+ self.n_iter = n_iter
143
+ self.lr = lr
144
+ self.lr_decay = lr_decay
145
+ self.tol = tol
146
+ self.device = device
147
+ if self.device == "cuda" and not torch.cuda.is_available():
148
+ LGR.debug("cuda not found, use device cpu")
149
+ self.device = "cpu"
150
+
151
+ # Initialize optimisation parameters
152
+ self.iter = 0
153
+
154
+ def _generate_description(self):
155
+ """Generate a description of the Estimator instance.
156
+
157
+ Returns
158
+ -------
159
+ description : :obj:`str`
160
+ Description of the Estimator instance.
161
+ """
162
+ description = """CBMR is a meta-regression framework that can explicitly model
163
+ group-wise spatial intensity function, and consider the effect of
164
+ study-level moderators. It consists of two components: (1) a spatial
165
+ model that makes use of a spline parameterization to induce a smooth
166
+ response; (2) a generalized linear model (Poisson, Negative Binomial
167
+ (NB), Clustered NB) to model group-wise spatial intensity function).
168
+ CBMR is fitted via maximizing the log-likelihood function with L-BFGS
169
+ algorithm."""
170
+ if self.moderators:
171
+ moderators_str = f"""and accommodate the following study-level moderators:
172
+ {', '.join(self.moderators)}"""
173
+ else:
174
+ moderators_str = ""
175
+ if self.model.penalty:
176
+ penalty_str = " Firth-type penalty is applied to ensure convergence."
177
+ else:
178
+ penalty_str = ""
179
+
180
+ if type(self.model).__name__ == "PoissonEstimator":
181
+ model_str = (
182
+ " Here, Poisson model \\citep{eisenberg1966general} is the most basic CBMR model. "
183
+ "It's based on the assumption that foci arise from a realisation of a (continues) "
184
+ "inhomogeneous Poisson process, so that the (discrete) voxel-wise foci counts will"
185
+ " be independently distributed as Poisson random variables, with rate equal to the"
186
+ " integral of (true, unobserved, continous) intensity function over each voxels"
187
+ )
188
+ elif type(self.model).__name__ == "NegativeBinomialEstimator":
189
+ model_str = (
190
+ " Negative Binomial (NB) model \\citep{barndorff1969negative} is a generalized "
191
+ "Poisson model with over-dispersion. "
192
+ "It's a more flexible model, but more difficult to estimate. In practice, foci"
193
+ "counts often display over-dispersion (the variance of response variable"
194
+ "substantially exceeeds the mean), which is not captured by Poisson model."
195
+ )
196
+ elif type(self.model).__name__ == "ClusteredNegativeBinomialEstimator":
197
+ model_str = (
198
+ " Clustered NB model \\citep{geoffroy2001poisson} can also accommodate "
199
+ "over-dispersion in foci counts. "
200
+ "In NB model, the latent random variable introduces indepdentent variation"
201
+ "at each voxel. While in Clustered NB model, we assert the random effects are not "
202
+ "independent voxelwise effects, but rather latent characteristics of each study, "
203
+ "and represent a shared effect over the entire brain for a given study."
204
+ )
205
+
206
+ model_description = (
207
+ f"CBMR is a meta-regression framework that was performed with NiMARE {__version__}. "
208
+ f"{type(self.model).__name__} model was used to model group-wise spatial intensity "
209
+ f"functions {moderators_str}." + model_str
210
+ )
211
+
212
+ optimization_description = (
213
+ "CBMR is fitted via maximizing the log-likelihood function with L-BFGS algorithm, with"
214
+ f" learning rate {self.lr}, learning rate decay {self.lr_decay} and "
215
+ + "tolerance {self.tol}."
216
+ + penalty_str
217
+ + f" The optimization is run on {self.device}."
218
+ f" The input dataset included {self.inputs_['coordinates'].shape[0]} foci from "
219
+ f"{len(self.inputs_['id'])} experiments."
220
+ )
221
+
222
+ description = model_description + "\n" + optimization_description
223
+ return description
224
+
225
+ def _preprocess_input(self, dataset):
226
+ """Mask required input images using either the Dataset's mask or the Estimator's.
227
+
228
+ Also, categorize study id, voxelwise sum of foci counts across studies, study-wise sum of
229
+ foci counts across space into multiple groups. And summarize study-level moderators into
230
+ multiple groups (if exist).
231
+
232
+ Parameters
233
+ ----------
234
+ dataset : :obj:`~nimare.dataset.Dataset`
235
+ In this method, the Dataset is used to (1) select the appropriate mask image,
236
+ (2) categorize studies into multiple groups according to group categories in
237
+ annotations,
238
+ (3) summarize group-wise study id, moderators (if exist), foci per voxel, foci
239
+ per study,
240
+ (4) extract sample size metadata and use it as one of study-level moderators.
241
+
242
+ Attributes
243
+ ----------
244
+ inputs_ : :obj:`dict`
245
+ Specifically, (1) a “mask_img” key will be added (Niftiimage of brain mask),
246
+ (2) an 'id' key will be added (id of all studies in the dataset),
247
+ (3) a 'coef_spline_bases' key will be added (spatial matrix of coefficient of cubic
248
+ B-spline bases in x,y,z dimension),
249
+ (4) an 'studies_by_group' key will be added (study id categorized by groups),
250
+ (5) an 'moderators_by_group' key will be added (study-level moderators categorized
251
+ by groups) if study-level moderators are considered,
252
+ (6) an 'foci_per_voxel' key will be added (voxelwise sum of foci count across
253
+ studies, categorized by groups),
254
+ (7) an 'foci_per_study' key will be added (study-wise sum of foci count across
255
+ space, categorized by groups).
256
+ """
257
+ masker = self.masker or dataset.masker
258
+
259
+ mask_img = masker.mask_img or masker.labels_img
260
+ if isinstance(mask_img, str):
261
+ mask_img = nib.load(mask_img)
262
+ self.inputs_["mask_img"] = mask_img
263
+
264
+ # generate spatial matrix of coefficient of cubic B-spline bases in x,y,z dimension
265
+ coef_spline_bases = b_spline_bases(
266
+ masker_voxels=mask_img._dataobj, spacing=self.spline_spacing
267
+ )
268
+ self.inputs_["coef_spline_bases"] = coef_spline_bases
269
+
270
+ for name, (type_, _) in self._required_inputs.items():
271
+ if type_ == "coordinates":
272
+ # remove dataset coordinates outside of mask
273
+ focus_filter = FocusFilter(mask=masker)
274
+ dataset = focus_filter.transform(dataset)
275
+ valid_dset_annotations = dataset.annotations[
276
+ dataset.annotations["id"].isin(self.inputs_["id"])
277
+ ]
278
+ studies_by_group = dict()
279
+ if self.group_categories is None:
280
+ studies_by_group["Default"] = (
281
+ valid_dset_annotations["study_id"].unique().tolist()
282
+ )
283
+ unique_groups = ["Default"]
284
+ elif isinstance(self.group_categories, str):
285
+ if self.group_categories not in valid_dset_annotations.columns:
286
+ raise ValueError(
287
+ f"""Category_names: {self.group_categories} does not exist
288
+ in the dataset"""
289
+ )
290
+ else:
291
+ unique_groups = list(
292
+ valid_dset_annotations[self.group_categories].unique()
293
+ )
294
+ for group in unique_groups:
295
+ group_study_id_bool = (
296
+ valid_dset_annotations[self.group_categories] == group
297
+ )
298
+ group_study_id = valid_dset_annotations.loc[group_study_id_bool][
299
+ "study_id"
300
+ ]
301
+ studies_by_group[group.capitalize()] = group_study_id.unique().tolist()
302
+ elif isinstance(self.group_categories, list):
303
+ missing_categories = set(self.group_categories) - set(
304
+ dataset.annotations.columns
305
+ )
306
+ if missing_categories:
307
+ raise ValueError(
308
+ f"""Category_names: {missing_categories} do/does not exist in
309
+ the dataset."""
310
+ )
311
+ unique_groups = (
312
+ valid_dset_annotations[self.group_categories]
313
+ .drop_duplicates()
314
+ .values.tolist()
315
+ )
316
+ for group in unique_groups:
317
+ group_study_id_bool = (
318
+ valid_dset_annotations[self.group_categories] == group
319
+ ).all(axis=1)
320
+ group_study_id = valid_dset_annotations.loc[group_study_id_bool][
321
+ "study_id"
322
+ ]
323
+ camelcase_group = "".join([g.capitalize() for g in group])
324
+ studies_by_group[camelcase_group] = group_study_id.unique().tolist()
325
+ self.inputs_["studies_by_group"] = studies_by_group
326
+ self.groups = list(self.inputs_["studies_by_group"].keys())
327
+ # collect studywise moderators if specficed
328
+ if self.moderators:
329
+ valid_dset_annotations, self.moderators = dummy_encoding_moderators(
330
+ valid_dset_annotations, self.moderators
331
+ )
332
+ if isinstance(self.moderators, str):
333
+ self.moderators = [
334
+ self.moderators
335
+ ] # convert moderators to a single-element list if it's a string
336
+ moderators_by_group = dict()
337
+ for group in self.groups:
338
+ df_group = valid_dset_annotations.loc[
339
+ valid_dset_annotations["study_id"].isin(studies_by_group[group])
340
+ ]
341
+ group_moderators = np.stack(
342
+ [df_group[moderator_name] for moderator_name in self.moderators],
343
+ axis=1,
344
+ )
345
+ moderators_by_group[group] = group_moderators
346
+ self.inputs_["moderators_by_group"] = moderators_by_group
347
+
348
+ foci_per_voxel, foci_per_study = dict(), dict()
349
+ for group in self.groups:
350
+ group_study_id = studies_by_group[group]
351
+ group_coordinates = dataset.coordinates.loc[
352
+ dataset.coordinates["study_id"].isin(group_study_id)
353
+ ]
354
+ # Group-wise foci coordinates
355
+ # Calculate IJK matrix indices for target mask
356
+ # Mask space is assumed to be the same as the Dataset's space
357
+ group_xyz = group_coordinates[["x", "y", "z"]].values
358
+ group_ijk = mm2vox(group_xyz, mask_img.affine)
359
+ group_foci_per_voxel = np.zeros(mask_img.shape, dtype=np.int32)
360
+ for ijk in group_ijk:
361
+ group_foci_per_voxel[ijk[0], ijk[1], ijk[2]] += 1
362
+ # will not work with maskers that aren't NiftiMaskers
363
+ group_foci_per_voxel = nib.Nifti1Image(
364
+ group_foci_per_voxel, mask_img.affine, mask_img.header
365
+ )
366
+ group_foci_per_voxel = masker.transform(group_foci_per_voxel).transpose()
367
+ # number of foci per voxel/study
368
+ # n_group_study = len(group_study_id)
369
+ group_foci_per_study = group_coordinates.groupby(["study_id"]).size()
370
+ group_foci_per_study = group_foci_per_study.to_numpy()
371
+ group_foci_per_study = group_foci_per_study.reshape((-1, 1))
372
+
373
+ foci_per_voxel[group] = group_foci_per_voxel
374
+ foci_per_study[group] = group_foci_per_study
375
+
376
+ self.inputs_["foci_per_voxel"] = foci_per_voxel
377
+ self.inputs_["foci_per_study"] = foci_per_study
378
+
379
+ def _fit(self, dataset):
380
+ """Perform coordinate-based meta-regression (CBMR) on dataset.
381
+
382
+ (1) Estimate group-wise spatial regression coefficients and its standard error via
383
+ inverse of Fisher Information matrix; Similarly, estimate regression coefficient of
384
+ study-level moderators (if exist), as well as its standard error via inverse of
385
+ Fisher Information matrix;
386
+ (2) Estimate standard error of group-wise log intensity, group-wise intensity via delta
387
+ method;
388
+ (3) For NegativeBinomial or ClusteredNegativeBinomial model, estimate regression
389
+ coefficient of overdispersion.s
390
+
391
+ Parameters
392
+ ----------
393
+ dataset : :obj:`~nimare.dataset.Dataset`
394
+ Dataset to analyze.
395
+ """
396
+ init_weight_kwargs = {
397
+ "groups": self.groups,
398
+ "moderators": self.moderators,
399
+ "spatial_coef_dim": self.inputs_["coef_spline_bases"].shape[1],
400
+ "moderators_coef_dim": len(self.moderators) if self.moderators else None,
401
+ }
402
+ self.model.init_weights(**init_weight_kwargs)
403
+
404
+ moderators_by_group = self.inputs_["moderators_by_group"] if self.moderators else None
405
+ self.model.fit(
406
+ self.inputs_["coef_spline_bases"],
407
+ moderators_by_group,
408
+ self.inputs_["foci_per_voxel"],
409
+ self.inputs_["foci_per_study"],
410
+ )
411
+
412
+ maps, tables = self.model.summary()
413
+
414
+ return maps, tables, self._generate_description()
415
+
416
+
417
+ class CBMRInference(object):
418
+ """Statistical inference on outcomes of CBMR.
419
+
420
+ .. versionadded:: 0.1.0
421
+
422
+ (intensity estimation and study-level moderator regressors)
423
+
424
+ Parameters
425
+ ----------
426
+ result : :obj:`~nimare.cbmr.CBMREstimator`
427
+ Results of optimized regression coefficients of CBMR, as well as their
428
+ standard error in `tables`. Results of estimated spatial intensity function
429
+ (per study) in `maps`.
430
+ t_con_groups : :obj:`~bool` or obj:`~list` or obj:`~None`, optional
431
+ Contrast matrix for homogeneity test or group comparison on estimated spatial
432
+ intensity function.
433
+ For boolean inputs, no statistical inference will be conducted for spatial intensity
434
+ if `t_con_groups` is False, and spatial homogeneity test for groupwise intensity
435
+ function will be conducted if `t_con_groups` is True.
436
+ For list inputs, generialized linear hypothesis (GLH) testing will be conducted for
437
+ each element independently. We also allow any element of `t_con_groups` in list type,
438
+ which represents GLH is conducted for all contrasts in this element simultaneously.
439
+ Default is homogeneity test on group-wise estimated intensity function.
440
+ t_con_moderators : :obj:`~bool` or obj:`~list` or obj:`~None`, optional
441
+ Contrast matrix for testing the existence of one or more study-level moderator effects.
442
+ For boolean inputs, no statistical inference will be conducted for study-level moderators
443
+ if `t_con_moderatorss` is False, and statistical inference on the effect of each
444
+ study-level moderators will be conducted if `t_con_groups` is True.
445
+ For list inputs, generialized linear hypothesis (GLH) testing will be conducted for
446
+ each element independently. We also allow any element of `t_con_moderatorss` in list type,
447
+ which represents GLH is conducted for all contrasts in this element simultaneously.
448
+ Default is statistical inference on the effect of each study-level moderators
449
+ device: :obj:`string`, optional
450
+ Device type ('cpu' or 'cuda') represents the device on which operations will be allocated.
451
+ Default is 'cpu'.
452
+ """
453
+
454
+ def __init__(self, device="cpu"):
455
+ self.device = device
456
+ # device check
457
+ if self.device == "cuda" and not torch.cuda.is_available():
458
+ LGR.debug("cuda not found, use device 'cpu'")
459
+ self.device = "cpu"
460
+ self.result = None
461
+ self.groups = None
462
+ self.moderators = None
463
+
464
+ def _check_fit(fn):
465
+ """Check if CBMRInference instance has been fit."""
466
+
467
+ @wraps(fn)
468
+ def wrapper(self, *args, **kwargs):
469
+ if self.result is None:
470
+ raise ValueError("CBMRInference instance has not been fit.")
471
+ return fn(self, *args, **kwargs)
472
+
473
+ return wrapper
474
+
475
+ def fit(self, result):
476
+ """Fit CBMRInference instance.
477
+
478
+ Parameters
479
+ ----------
480
+ result : :obj:`~nimare.cbmr.CBMREstimator`
481
+ Results of optimized regression coefficients of CBMR, as well as their
482
+ standard error in `tables`. Results of estimated spatial intensity function
483
+ (per study) in `maps`.
484
+ """
485
+ self.result = result.copy()
486
+ self.estimator = self.result.estimator
487
+ self.groups = self.result.estimator.groups
488
+ self.moderators = self.result.estimator.moderators
489
+
490
+ self.create_regular_expressions()
491
+
492
+ self.group_reference_dict, self.moderator_reference_dict = dict(), dict()
493
+ for i in range(len(self.groups)):
494
+ self.group_reference_dict[self.groups[i]] = i
495
+ if self.moderators:
496
+ for j in range(len(self.moderators)):
497
+ self.moderator_reference_dict[self.moderators[j]] = j
498
+ LGR.info(f"{self.moderators[j]} = index_{j}")
499
+
500
+ @_check_fit
501
+ def display(self):
502
+ """Display Groups and Moderator names and order."""
503
+ # visialize group/moderator names and their indices in contrast array
504
+ LGR.info("Group Reference in contrast array")
505
+ for group, index in self.group_reference_dict.items():
506
+ LGR.info(f"{group} = index_{index}")
507
+ if self.moderators:
508
+ LGR.info("Moderator Reference in contrast array")
509
+ for moderator, index in self.moderator_reference_dict.items():
510
+ LGR.info(f"{moderator} = index_{index}")
511
+
512
+ def create_regular_expressions(self):
513
+ """
514
+ Create regular expressions for parsing contrast names.
515
+
516
+ creates the following attributes:
517
+ self.groups_regular_expression: regular expression for parsing group names
518
+ self.moderators_regular_expression: regular expression for parsing moderator names
519
+
520
+ usage:
521
+ >>> self.groups_regular_expression.match("group1 - group2").groupdict()
522
+ """
523
+ operator = "(\\ ?(?P<operator>[+-]?)\\ ??)"
524
+ for attr in ["groups", "moderators"]:
525
+ groups = getattr(self, attr)
526
+ if groups:
527
+ first_group, second_group = [
528
+ f"(?P<{order}>{'|'.join([re.escape(g) for g in groups])})"
529
+ for order in ["first", "second"]
530
+ ]
531
+ reg_expr = re.compile(first_group + "(" + operator + second_group + "?)")
532
+ else:
533
+ reg_expr = None
534
+
535
+ setattr(self, "{}_regular_expression".format(attr), reg_expr)
536
+
537
+ @_check_fit
538
+ def create_contrast(self, contrast_name, source="groups"):
539
+ """Create contrast matrix for generalized hypothesis testing (GLH).
540
+
541
+ (1) if `source` is "group", create contrast matrix for GLH on spatial intensity;
542
+ if `contrast_name` begins with 'homo_test_', followed by a valid group name,
543
+ create a contrast matrix for one-group homogeneity test on spatial intensity;
544
+ if `contrast_name` comes in the form of "group1VSgroup2", with valid group names
545
+ "group1" and "group2", create a contrast matrix for group comparison on estimated
546
+ group spatial intensity;
547
+ (2) if `source` is "moderator", create contrast matrix for GLH on study-level moderators;
548
+ if `contrast_name` begins with 'moderator_', followed by a valid moderator name,
549
+ we create a contrast matrix for testing if the effect of this moderator exists;
550
+ if `contrast_name` comes in the form of "moderator1VSmoderator2", with valid moderator
551
+ names "modeator1" and "moderator2", we create a contrast matrix for testing if the
552
+ effect of these two moderators are different.
553
+
554
+ Parameters
555
+ ----------
556
+ contrast_name : :obj:`~string`
557
+ Name of contrast in GLH.
558
+ """
559
+ if isinstance(contrast_name, str):
560
+ contrast_name = [contrast_name]
561
+ contrast_matrix = {}
562
+ if source == "groups": # contrast matrix for spatial intensity
563
+ for contrast in contrast_name:
564
+ contrast_vector = np.zeros(len(self.groups))
565
+ contrast_match = self.groups_regular_expression.match(contrast)
566
+ # check validity of contrast name
567
+ if contrast_match is None:
568
+ raise ValueError(f"{contrast} is not a valid contrast.")
569
+ groups_contrast = contrast_match.groupdict()
570
+ # create contrast matrix
571
+ if all(groups_contrast.values()): # group comparison
572
+ contrast_vector[self.group_reference_dict[groups_contrast["first"]]] = 1
573
+ contrast_vector[self.group_reference_dict[groups_contrast["second"]]] = int(
574
+ contrast_match["operator"] + "1"
575
+ )
576
+ else: # homogeneity test
577
+ contrast_vector[self.group_reference_dict[contrast]] = 1
578
+ contrast_matrix[contrast] = contrast_vector
579
+
580
+ elif source == "moderators": # contrast matrix for moderator effect
581
+ for contrast in contrast_name:
582
+ contrast_vector = np.zeros(len(self.moderators))
583
+ contrast_match = self.moderators_regular_expression.match(contrast)
584
+ if contrast_match is None:
585
+ raise ValueError(f"{contrast} is not a valid contrast.")
586
+ moderators_contrast = contrast_match.groupdict()
587
+ if all(moderators_contrast.values()): # moderator comparison
588
+ _ = list(map(moderators_contrast.get, ["first", "second"]))
589
+ contrast_vector[
590
+ self.moderator_reference_dict[moderators_contrast["first"]]
591
+ ] = 1
592
+ contrast_vector[
593
+ self.moderator_reference_dict[moderators_contrast["second"]]
594
+ ] = int(moderators_contrast["operator"] + "1")
595
+ else: # moderator effect
596
+ contrast_vector[self.moderator_reference_dict[contrast]] = 1
597
+ contrast_matrix[contrast] = contrast_vector
598
+
599
+ return contrast_matrix
600
+
601
+ @_check_fit
602
+ def transform(self, t_con_groups=None, t_con_moderators=None):
603
+ """Conduct generalized linear hypothesis (GLH) testing on CBMR estimates.
604
+
605
+ Estimate group-wise spatial regression coefficients and its standard error via inverse
606
+ Fisher Information matrix, estimate standard error of group-wise log intensity,
607
+ group-wise intensity via delta method. For NB or clustered model, estimate regression
608
+ coefficient of overdispersion. Similarly, estimate regression coefficient of study-level
609
+ moderators (if exist), as well as its standard error via Fisher Information matrix.
610
+ Save these outcomes in `tables`. Also, estimate group-wise spatial intensity (per study)
611
+ and save the results in `maps`.
612
+
613
+ Parameters
614
+ ----------
615
+ t_con_groups : :obj:`~list`, optional
616
+ Contrast matrix for GLH on group-wise spatial intensity estimation.
617
+ Default is None (group-wise homogeneity test for all groups).
618
+ t_con_moderators : :obj:`~list`, optional
619
+ Contrast matrix for GLH on moderator effects.
620
+ Default is None (tests if moderator effects exist for all moderators).
621
+ """
622
+ self.t_con_groups = t_con_groups
623
+ self.t_con_moderators = t_con_moderators
624
+
625
+ if self.t_con_groups:
626
+ # preprocess and standardize group contrast
627
+ self.t_con_groups, self.t_con_groups_name = self._preprocess_t_con_regressor(
628
+ source="groups"
629
+ )
630
+ # GLH test for group contrast
631
+ self._glh_con_group()
632
+ if self.t_con_moderators:
633
+ # preprocess and standardize moderator contrast
634
+ self.t_con_moderators, self.t_con_moderators_name = self._preprocess_t_con_regressor(
635
+ source="moderators"
636
+ )
637
+ # GLH test for moderator contrast
638
+ self._glh_con_moderator()
639
+
640
+ return self.result
641
+
642
+ def fit_transform(self, result, t_con_groups=None, t_con_moderators=None):
643
+ """Fit and transform."""
644
+ self.fit(result)
645
+ return self.transform(t_con_groups, t_con_moderators)
646
+
647
+ @_check_fit
648
+ def _preprocess_t_con_regressor(self, source):
649
+ """Preprocess contrast vector/matrix for GLH testing.
650
+
651
+ Follow the steps below:
652
+ (1) Remove groups not involved in contrast;
653
+ (2) Standardize contrast matrix (row sum to 1);
654
+ (3) Remove duplicate rows in contrast matrix.
655
+
656
+ Parameters
657
+ ----------
658
+ source : :obj:`~string`
659
+ Source of contrast matrix, either "groups" or "moderators".
660
+
661
+ Returns
662
+ -------
663
+ t_con_regressor : :obj:`~list`
664
+ Preprocessed contrast vector/matrix for inference on
665
+ spatial intensity or study-level moderators.
666
+ t_con_regressor_name : :obj:`~list`
667
+ Name of contrast vector/matrix for spatial intensity
668
+ """
669
+ # regressor can be either groups or moderators
670
+ t_con_regressor = getattr(self, f"t_con_{source}")
671
+ n_regressors = len(getattr(self, f"{source}"))
672
+ # if contrast matrix is a dictionary, convert it to list
673
+ if isinstance(t_con_regressor, dict):
674
+ t_con_regressor_name = list(t_con_regressor.keys())
675
+ t_con_regressor = list(t_con_regressor.values())
676
+ elif isinstance(t_con_regressor, (list, np.ndarray)):
677
+ for i in range(len(t_con_regressor)):
678
+ self.result.metadata[f"GLH_{source}_{i}"] = t_con_regressor[i]
679
+ t_con_regressor_name = None
680
+ # Conduct group-wise spatial homogeneity test by default
681
+ t_con_regressor = (
682
+ [np.eye(n_regressors)]
683
+ if t_con_regressor is None
684
+ else [np.array(con_regressor) for con_regressor in t_con_regressor]
685
+ )
686
+ # make sure contrast matrix/vector is 2D
687
+ t_con_regressor = [
688
+ con_regressor.reshape((1, -1)) if len(con_regressor.shape) == 1 else con_regressor
689
+ for con_regressor in t_con_regressor
690
+ ]
691
+ # raise error if dimension of contrast matrix/vector doesn't match with number of groups
692
+ if np.any([con_regressor.shape[1] != n_regressors for con_regressor in t_con_regressor]):
693
+ wrong_con_regressor_idx = np.where(
694
+ [con_regressor.shape[1] != n_regressors for con_regressor in t_con_regressor]
695
+ )[0].tolist()
696
+ raise ValueError(
697
+ f"""The shape of {str(wrong_con_regressor_idx)}th contrast vector(s) in contrast
698
+ matrix doesn't match with {source}."""
699
+ )
700
+ # remove zero rows in contrast matrix (if exist)
701
+ con_regressor_zero_row = [
702
+ np.where(np.sum(np.abs(con_regressor), axis=1) == 0)[0]
703
+ for con_regressor in t_con_regressor
704
+ ]
705
+ if np.any([len(zero_row) > 0 for zero_row in con_regressor_zero_row]):
706
+ t_con_regressor = [
707
+ np.delete(t_con_regressor[i], con_regressor_zero_row[i], axis=0)
708
+ for i in range(len(t_con_regressor))
709
+ ]
710
+ if np.any([con_regressor.shape[0] == 0 for con_regressor in t_con_regressor]):
711
+ raise ValueError(
712
+ f"""One or more of contrast vector(s) in {source} contrast matrix are
713
+ all zeros."""
714
+ )
715
+ # standardization (row sum 1)
716
+ t_con_regressor = [
717
+ con_regressor / np.sum(np.abs(con_regressor), axis=1).reshape((-1, 1))
718
+ for con_regressor in t_con_regressor
719
+ ]
720
+ # remove duplicate rows in contrast matrix (after standardization)
721
+ uniq_con_regressor_idx = np.unique(t_con_regressor, axis=0, return_index=True)[1].tolist()
722
+ t_con_regressor = [t_con_regressor[i] for i in uniq_con_regressor_idx[::-1]]
723
+
724
+ return t_con_regressor, t_con_regressor_name
725
+
726
+ @_check_fit
727
+ def _glh_con_group(self):
728
+ """Conduct GLH testing for group-wise spatial intensity estimation.
729
+
730
+ GLH testing allows flexible hypothesis testings on spatial
731
+ intensity, including group-wise spatial homogeneity test and
732
+ group comparison test.
733
+ """
734
+ X = self.estimator.inputs_["coef_spline_bases"]
735
+ n_brain_voxel, spatial_coef_dim = X.shape
736
+ con_group_count = 0
737
+ for con_group in self.t_con_groups:
738
+ con_group_involved_index = np.where(np.any(con_group != 0, axis=0))[0].tolist()
739
+ con_group_involved = [self.groups[i] for i in con_group_involved_index]
740
+ n_con_group_involved = len(con_group_involved)
741
+ # Simplify contrast matrix by removing irrelevant columns
742
+ simp_con_group = con_group[:, ~np.all(con_group == 0, axis=0)]
743
+ # Covariance of involved group-wise spatial coef (either one or multiple groups)
744
+ moderators_by_group = (
745
+ self.estimator.inputs_["moderators_by_group"] if self.moderators else None
746
+ )
747
+ f_spatial_coef = self.estimator.model.fisher_info_multiple_group_spatial(
748
+ con_group_involved,
749
+ self.estimator.inputs_["coef_spline_bases"],
750
+ moderators_by_group,
751
+ self.estimator.inputs_["foci_per_voxel"],
752
+ self.estimator.inputs_["foci_per_study"],
753
+ )
754
+ cov_spatial_coef = np.linalg.inv(f_spatial_coef)
755
+ # compute numerator: contrast vector * group-wise log spatial intensity
756
+ involved_log_intensity_per_voxel = list()
757
+ for group in con_group_involved:
758
+ group_log_intensity_per_voxel = np.log(
759
+ self.result.maps["spatialIntensity_group-" + group]
760
+ )
761
+ if np.all(np.count_nonzero(con_group, axis=1) == 1): # GLH: homogeneity test
762
+ group_foci_per_voxel = self.estimator.inputs_["foci_per_voxel"][group]
763
+ group_foci_per_study = self.estimator.inputs_["foci_per_study"][group]
764
+ n_voxels, n_study = (
765
+ group_foci_per_voxel.shape[0],
766
+ group_foci_per_study.shape[0],
767
+ )
768
+ group_null_log_spatial_intensity = np.log(
769
+ np.sum(group_foci_per_voxel) / (n_voxels * n_study)
770
+ )
771
+ group_log_intensity_per_voxel -= group_null_log_spatial_intensity
772
+ involved_log_intensity_per_voxel.append(group_log_intensity_per_voxel)
773
+ involved_log_intensity_per_voxel = np.stack(involved_log_intensity_per_voxel, axis=0)
774
+ contrast_log_intensity = np.matmul(simp_con_group, involved_log_intensity_per_voxel)
775
+
776
+ # check if a single hypothesis is tested or GLH tests
777
+ # (with multiple contrasts) are conducted
778
+ m, _ = con_group.shape
779
+ if m == 1: # a single contrast vector, use Wald test
780
+ var_log_intensity = []
781
+ for k in range(n_con_group_involved):
782
+ cov_spatial_coef_k = cov_spatial_coef[
783
+ k * spatial_coef_dim : (k + 1) * spatial_coef_dim,
784
+ k * spatial_coef_dim : (k + 1) * spatial_coef_dim,
785
+ ]
786
+ var_log_intensity_k = np.sum(np.multiply(X @ cov_spatial_coef_k, X), axis=1)
787
+ var_log_intensity.append(var_log_intensity_k)
788
+ var_log_intensity = np.stack(var_log_intensity, axis=0)
789
+ involved_var_log_intensity = simp_con_group**2 @ var_log_intensity
790
+ involved_std_log_intensity = np.sqrt(involved_var_log_intensity)
791
+ # Conduct Wald test (Z test)
792
+ z_stats_spatial = contrast_log_intensity / involved_std_log_intensity
793
+ if n_con_group_involved == 1: # one-tailed test
794
+ p_vals_spatial = scipy.stats.norm.sf(z_stats_spatial) # shape: (1, n_voxels)
795
+ else: # two-tailed test
796
+ p_vals_spatial = (
797
+ scipy.stats.norm.sf(abs(z_stats_spatial)) * 2
798
+ ) # shape: (1, n_voxels)
799
+ else: # GLH tests (with multiple contrasts)
800
+ cov_log_intensity = np.empty(shape=(0, n_brain_voxel))
801
+ for k in range(n_con_group_involved):
802
+ for s in range(n_con_group_involved):
803
+ cov_beta_ks = cov_spatial_coef[
804
+ k * spatial_coef_dim : (k + 1) * spatial_coef_dim,
805
+ s * spatial_coef_dim : (s + 1) * spatial_coef_dim,
806
+ ]
807
+ cov_group_log_intensity = (
808
+ (X.dot(cov_beta_ks) * X).sum(axis=1).reshape((1, -1))
809
+ )
810
+ cov_log_intensity = np.concatenate(
811
+ (cov_log_intensity, cov_group_log_intensity), axis=0
812
+ ) # (m^2, n_voxels)
813
+ # GLH on log_intensity (eta)
814
+ chi_sq_spatial = self._chi_square_log_intensity(
815
+ m,
816
+ n_brain_voxel,
817
+ n_con_group_involved,
818
+ simp_con_group,
819
+ cov_log_intensity,
820
+ contrast_log_intensity,
821
+ )
822
+ p_vals_spatial = 1 - scipy.stats.chi2.cdf(chi_sq_spatial, df=m)
823
+ # convert p-values to z-scores for visualization
824
+ if np.all(np.count_nonzero(con_group, axis=1) == 1): # GLH: homogeneity test
825
+ z_stats_spatial = scipy.stats.norm.isf(p_vals_spatial)
826
+ z_stats_spatial[z_stats_spatial < 0] = 0
827
+ else:
828
+ z_stats_spatial = scipy.stats.norm.isf(p_vals_spatial / 2)
829
+ if con_group.shape[0] == 1: # GLH one test: Z statistics are signed
830
+ z_stats_spatial *= np.sign(contrast_log_intensity.flatten())
831
+ z_stats_spatial = np.clip(z_stats_spatial, a_min=-10, a_max=10)
832
+ # save results
833
+ if self.t_con_groups_name:
834
+ if m > 1: # GLH tests (with multiple contrasts)
835
+ self.result.maps[
836
+ f"chiSquare_group-{self.t_con_groups_name[con_group_count]}"
837
+ ] = chi_sq_spatial
838
+ self.result.maps[f"p_group-{self.t_con_groups_name[con_group_count]}"] = (
839
+ p_vals_spatial
840
+ )
841
+ self.result.maps[f"z_group-{self.t_con_groups_name[con_group_count]}"] = (
842
+ z_stats_spatial
843
+ )
844
+ else:
845
+ if m > 1: # GLH tests (with multiple contrasts)
846
+ self.result.maps[f"chiSquare_GLH_groups_{con_group_count}"] = chi_sq_spatial
847
+ self.result.maps[f"p_GLH_groups_{con_group_count}"] = p_vals_spatial
848
+ self.result.maps[f"z_GLH_groups_{con_group_count}"] = z_stats_spatial
849
+ con_group_count += 1
850
+
851
+ def _chi_square_log_intensity(
852
+ self,
853
+ m,
854
+ n_brain_voxel,
855
+ n_con_group_involved,
856
+ simp_con_group,
857
+ cov_log_intensity,
858
+ contrast_log_intensity,
859
+ ):
860
+ """
861
+ Calculate chi-square statistics for GLH on group-wise log intensity function.
862
+
863
+ It is an intermediate steps for GLH testings.
864
+
865
+ Parameters
866
+ ----------
867
+ m : :obj:`int`
868
+ Number of independent GLH tests.
869
+ n_brain_voxel : :obj:`int`
870
+ Number of voxels within the brain mask.
871
+ n_con_group_involved : :obj:`int`
872
+ Number of groups involved in the GLH test.
873
+ simp_con_group : :obj:`numpy.ndarray`
874
+ Simplified contrast matrix for the GLH test.
875
+ cov_log_intensity : :obj:`numpy.ndarray`
876
+ Covariance matrix of log intensity estimation.
877
+ contrast_log_intensity : :obj:`numpy.ndarray`
878
+ The product of contrast matrix and log intensity estimation.
879
+
880
+ Returns
881
+ -------
882
+ chi_sq_spatial : :obj:`numpy.ndarray`
883
+ Voxel-wise chi-square statistics for GLH tests on group-wise spatial
884
+ intensity estimations.
885
+ """
886
+ chi_sq_spatial = np.empty(shape=(0,))
887
+ for j in range(n_brain_voxel):
888
+ contrast_log_intensity_j = contrast_log_intensity[:, j].reshape(m, 1)
889
+ v_j = cov_log_intensity[:, j].reshape((n_con_group_involved, n_con_group_involved))
890
+ cv_jc = simp_con_group @ v_j @ simp_con_group.T
891
+ cv_jc_inv = np.linalg.inv(cv_jc)
892
+ chi_sq_spatial_j = contrast_log_intensity_j.T @ cv_jc_inv @ contrast_log_intensity_j
893
+ chi_sq_spatial = np.concatenate(
894
+ (
895
+ chi_sq_spatial,
896
+ chi_sq_spatial_j.reshape(
897
+ 1,
898
+ ),
899
+ ),
900
+ axis=0,
901
+ )
902
+ return chi_sq_spatial
903
+
904
+ @_check_fit
905
+ def _glh_con_moderator(self):
906
+ """Conduct Generalized linear hypothesis (GLH) testing for study-level moderators.
907
+
908
+ GLH testing allows flexible hypothesis testings on regression
909
+ coefficients of study-level moderators, including testing for
910
+ the existence of moderator effects and difference in moderator
911
+ effects across multiple moderator effects.
912
+ """
913
+ con_moderator_count = 0
914
+ for con_moderator in self.t_con_moderators:
915
+ m_con_moderator, _ = con_moderator.shape
916
+ moderator_coef = self.result.tables["moderators_regression_coef"].to_numpy().T
917
+ contrast_moderator_coef = np.matmul(con_moderator, moderator_coef)
918
+
919
+ moderators_by_group = (
920
+ self.estimator.inputs_["moderators_by_group"] if self.moderators else None
921
+ )
922
+ f_moderator_coef = self.estimator.model.fisher_info_multiple_group_moderator(
923
+ self.estimator.inputs_["coef_spline_bases"],
924
+ moderators_by_group,
925
+ self.estimator.inputs_["foci_per_voxel"],
926
+ self.estimator.inputs_["foci_per_study"],
927
+ )
928
+
929
+ cov_moderator_coef = np.linalg.inv(f_moderator_coef)
930
+ if m_con_moderator == 1: # a single contrast vector, use Wald test
931
+ var_moderator_coef = np.diag(cov_moderator_coef)
932
+ involved_var_moderator_coef = con_moderator**2 @ var_moderator_coef
933
+ involved_std_moderator_coef = np.sqrt(involved_var_moderator_coef)
934
+ # Conduct Wald test (Z test)
935
+ z_stats_moderator = contrast_moderator_coef / involved_std_moderator_coef
936
+ p_vals_moderator = (
937
+ scipy.stats.norm.sf(abs(z_stats_moderator)) * 2
938
+ ) # two-tailed test
939
+ else: # GLH test (multiple contrast vectors)
940
+ chi_sq_moderator = (
941
+ contrast_moderator_coef.T
942
+ @ np.linalg.inv(con_moderator @ cov_moderator_coef @ con_moderator.T)
943
+ @ contrast_moderator_coef
944
+ )
945
+ p_vals_moderator = 1 - scipy.stats.chi2.cdf(chi_sq_moderator, df=m_con_moderator)
946
+ z_stats_moderator = scipy.stats.norm.isf(p_vals_moderator / 2)
947
+
948
+ if self.t_con_moderators_name: # None?
949
+ if m_con_moderator > 1:
950
+ self.result.tables[
951
+ f"chi_square_{self.t_con_moderators_name[con_moderator_count]}"
952
+ ] = pd.DataFrame(data=np.array(chi_sq_moderator), columns=["chi_square"])
953
+ self.result.tables[f"p_{self.t_con_moderators_name[con_moderator_count]}"] = (
954
+ pd.DataFrame(data=np.array(p_vals_moderator), columns=["p"])
955
+ )
956
+ self.result.tables[f"z_{self.t_con_moderators_name[con_moderator_count]}"] = (
957
+ pd.DataFrame(data=np.array(z_stats_moderator), columns=["z"])
958
+ )
959
+ else:
960
+ if m_con_moderator > 1:
961
+ self.result.tables[f"chi_square_GLH_moderators_{con_moderator_count}"] = (
962
+ pd.DataFrame(data=np.array(chi_sq_moderator), columns=["chi_square"])
963
+ )
964
+ self.result.tables[f"p_GLH_moderators_{con_moderator_count}"] = pd.DataFrame(
965
+ data=np.array(p_vals_moderator), columns=["p"]
966
+ )
967
+ self.result.tables[f"z_GLH_moderators_{con_moderator_count}"] = pd.DataFrame(
968
+ data=np.array(z_stats_moderator), columns=["z"]
969
+ )
970
+ con_moderator_count += 1