Skip to content

Commit 4264eb9

Browse files
authored
[REF] CBMR internal and public API refactor (#1005)
* Optimize CBMR fit, inference, and device handling Profile and refactor the CBMR hot paths while preserving numerical behavior within tolerance. - speed up summary table construction and inference result copying - cache tensorized inputs, log-intensity values, and covariance terms - vectorize multi-contrast GLH paths and add analytic Poisson Fisher info - trim the synthetic CBMR fixture and expand regression coverage - remove stale CBMR/model code, fix lint issues, and support CUDA devices * update how splines are computed * redesign the CBMR interface * respond to review * add useful helpers/abstractions * fix documentation drift * fix line length/style * fix the documentation for cbmr
1 parent eec15b4 commit 4264eb9

13 files changed

Lines changed: 2259 additions & 919 deletions

File tree

examples/02_meta-analyses/11_plot_cbmr.py

Lines changed: 44 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
"""
1+
"""Coordinate-based meta-regression algorithms.
22
33
.. _metas_cbmr:
44
55
===========================================
66
Coordinate-based meta-regression algorithms
77
===========================================
88
9-
A tour of Coordinate-based meta-regression (CBMR) algorithms in NiMARE
9+
A tour of Coordinate-based meta-regression (CBMR) algorithms in NiMARE.
1010
1111
CBMR is a generative framework to approximate smooth activation intensity function
1212
and investigate the effect of study-level moderators (e.g., year of pubilication,
@@ -24,6 +24,7 @@
2424
`online course <https://www.coursera.org/lecture/functional-mri-2/module-3-meta-analysis-Vd4zz>`_
2525
or a `brief overview <https://libguides.princeton.edu/neuroimaging_meta>`_.
2626
"""
27+
2728
import numpy as np
2829
import scipy
2930
from nilearn.plotting import plot_stat_map
@@ -75,7 +76,7 @@
7576
###############################################################################
7677
# Estimation of group-specific spatial intensity functions
7778
# -----------------------------------------------------------------------------
78-
# CBMR can generate estimation of group-specific spatial internsity
79+
# CBMR can generate estimates of group-specific spatial intensity
7980
# functions for multiple groups simultaneously, with different group-specific
8081
# spatial regression coefficients.
8182
#
@@ -84,14 +85,14 @@
8485
# of moderators (shared by all groups).
8586
#
8687
# Note that study-level moderators can only have global effects instead of localized
87-
# effects within CBMR framework. In the scenario that there're multiple subgroups
88+
# effects within CBMR framework. In the scenario that there are multiple subgroups
8889
# within a group (e.g., indexed as subgroup-1 to subgroup-n, but one or more of them
8990
# don't have enough number of studies to be inferred as a separate group). Using
9091
# categorical encoding, CBMR can interpret the subgroups as categorical moderators
9192
# for each study (either 0 or 1), and estimate the global activation intensity
9293
# associated with each subgroup (comparing to the average).
9394

94-
from nimare.meta.cbmr import CBMREstimator
95+
from nimare.meta import CBMREstimator
9596

9697
studyset = StandardizeField(fields=["sample_sizes", "avg_age"]).transform(studyset)
9798

@@ -106,7 +107,7 @@
106107
model=models.PoissonEstimator,
107108
penalty=False,
108109
lr=1e-1,
109-
tol=1e3, # a reasonable choice is 1e-2, 1e3 is for speed
110+
tol=1e3, # a reasonable choice is 1e-2, 1e3 is for speed
110111
device="cpu", # "cuda" if you have GPU
111112
)
112113
results = cbmr.fit(dataset=studyset)
@@ -163,16 +164,10 @@
163164
###############################################################################
164165
# Generalized Linear Hypothesis (GLH) testing for spatial homogeneity
165166
# -----------------------------------------------------------------------------
166-
# In the most basic scenario of spatial homogeneity test, contrast matrix `t_con_groups`
167-
# can be generated by `create_contrast` function, with group names specified.
168-
from nimare.meta.cbmr import CBMRInference
169-
170-
inference = CBMRInference(device="cuda")
171-
inference.fit(result=results)
172-
t_con_groups = inference.create_contrast(
173-
["SchizophreniaYes", "SchizophreniaNo", "DepressionYes", "DepressionNo"], source="groups"
174-
)
175-
contrast_result = inference.transform(t_con_groups=t_con_groups)
167+
# In the most basic scenario of spatial homogeneity testing, the fitted CBMR result can run
168+
# inference directly. The available groups and moderators are discoverable from the result.
169+
print(results.describe_inference_inputs())
170+
contrast_result = results.test_groups()
176171

177172
###############################################################################
178173
# Now that we have done spatial homogeneity tests, we can plot the z-score maps.
@@ -231,7 +226,7 @@
231226
# Areas with significant p-values are highlighted (under significance level :math:`0.05`).
232227

233228
###############################################################################
234-
# Perform fasle discovery rate (FDR) correction on spatial homogeneity test
229+
# Perform false discovery rate (FDR) correction on spatial homogeneity test
235230
# -----------------------------------------------------------------------------
236231
# The default FDR correction method is "indep", using Benjamini-Hochberg(BH) procedure.
237232
from nimare.correct import FDRCorrector
@@ -295,18 +290,14 @@
295290
###############################################################################
296291
# GLH testing for group comparisons among any two groups
297292
# -----------------------------------------------------------------------------
298-
# In the most basic scenario of group comparison test, contrast matrix `t_con_groups`
299-
# can be generated by `create_contrast` function, with `contrast_name` specified as
300-
# "group1-group2".
301-
t_con_groups = inference.create_contrast(
293+
# Pairwise group comparisons can also be expressed more directly with tuples.
294+
contrast_result = results.compare_groups(
302295
[
303-
"SchizophreniaYes-SchizophreniaNo",
304-
"SchizophreniaNo-DepressionNo",
305-
"DepressionYes-DepressionNo",
306-
],
307-
source="groups",
296+
("SchizophreniaYes", "SchizophreniaNo"),
297+
("SchizophreniaNo", "DepressionNo"),
298+
("DepressionYes", "DepressionNo"),
299+
]
308300
)
309-
contrast_result = inference.transform(t_con_groups=t_con_groups, t_con_moderators=False)
310301

311302
###############################################################################
312303
# Now that we have done group comparison tests,
@@ -358,22 +349,24 @@
358349
###############################################################################
359350
# GLH testing with contrast matrix specified
360351
# -----------------------------------------------------------------------------
361-
# CBMR supports more flexible GLH test by specifying a contrast matrix.
362-
# For example, group comparison test `2xgroup_0-1xgroup_1-1xgroup_2` can be
363-
# represented as `t_con_group=[2, -1, -1, 0]`, as an input in `compute_contrast`
364-
# function. Multiple independent GLH tests can be conducted simultaneously by
365-
# including multiple contrast vectors/matrices in `t_con_group`.
352+
# CBMR supports more flexible GLH tests by specifying contrast vectors or matrices
353+
# directly through the result-level `infer` API. For example, the group comparison
354+
# `2xgroup_0-1xgroup_1-1xgroup_2` can be represented as
355+
# `group_contrasts=[[2, -1, -1, 0]]`. Multiple independent GLH tests can be
356+
# conducted simultaneously by including multiple contrast vectors or matrices in
357+
# `group_contrasts`.
366358
#
367-
# CBMR also allows simultaneous GLH tests (consisting of multiple contrast vectors)
368-
# when it's represented as one of elements in `t_con_group` (datatype: list).
359+
# CBMR also allows simultaneous GLH tests consisting of multiple contrast vectors,
360+
# represented as one element of `group_contrasts`.
369361
# Only if all of null hypotheses are rejected at voxel level, p-values are significant.
370-
# For example, `t_con_group=[[1,-1,0,0], [1,0,-1,0], [0,0,1,-1]]` is used for testing
371-
# the equality of spatial intensity estimation among all of four groups (finding the
372-
# consistent activation regions). Note that only :math:`n-1` contrast vectors are necessary
373-
# for testing the equality of :math:`n` groups.
374-
375-
contrast_result = inference.transform(
376-
t_con_groups=[[[1, -1, 0, 0], [1, 0, -1, 0], [0, 0, 1, -1]]], t_con_moderators=False
362+
# For example, `[[1, -1, 0, 0], [1, 0, -1, 0], [0, 0, 1, -1]]` tests the equality
363+
# of spatial intensity estimates across all four groups (finding consistent activation
364+
# regions). Note that only :math:`n-1` contrast vectors are necessary for testing the
365+
# equality of :math:`n` groups.
366+
367+
contrast_result = results.infer(
368+
group_contrasts=[[[1, -1, 0, 0], [1, 0, -1, 0], [0, 0, 1, -1]]],
369+
moderator_contrasts=False,
377370
)
378371

379372
###############################################################################
@@ -395,11 +388,9 @@
395388
###############################################################################
396389
# GLH testing for study-level moderators
397390
# -----------------------------------------------------------------------------
398-
# CBMR framework can estimate global study-level moderator effects,
399-
# and allows inference on the existence of m.
400-
contrast_name = results.estimator.moderators
401-
t_con_moderators = inference.create_contrast(contrast_name, source="moderators")
402-
contrast_result = inference.transform(t_con_moderators=t_con_moderators)
391+
# The CBMR framework can estimate global study-level moderator effects and allows
392+
# inference on whether those moderator effects differ from zero.
393+
contrast_result = results.test_moderators()
403394
print(contrast_result.tables["moderators_regression_coef"])
404395
print(
405396
"P-values of moderator effects `sample_sizes` is {}".format(
@@ -420,18 +411,18 @@
420411
# a chosen subtype, spatial intensity estimations of the other :math:`4` subtypes of
421412
# schizophrenia are moderatored globally.
422413

423-
t_con_moderators = inference.create_contrast(
424-
["standardized_sample_sizes-standardized_avg_age"], source="moderators"
414+
contrast_result = results.compare_moderators(
415+
[("standardized_sample_sizes", "standardized_avg_age")]
425416
)
426-
contrast_result = inference.transform(t_con_moderators=t_con_moderators)
427417
print(
428-
"P-values of difference in two moderator effectors (`sample_size-avg_age`) is {}".format(
418+
"P-values of the difference between two moderator effects (`sample_size-avg_age`) is {}".format(
429419
contrast_result.tables["p_standardized_sample_sizes-standardized_avg_age"]
430420
)
431421
)
432422

433423
###############################################################################
434424
# CBMR also allows flexible contrasts between study-level covariates.
435-
# For example, we can write `contrast_name` (an input to `create_contrast`
436-
# function) as `standardized_sample_sizes-standardized_avg_age` when exploring
437-
# if the moderator effects of `sample_sizes` and `avg_age` are equivalent.
425+
# For example, we can express the comparison
426+
# `standardized_sample_sizes-standardized_avg_age` directly through
427+
# `results.compare_moderators(...)` when exploring whether the moderator effects
428+
# of `sample_sizes` and `avg_age` are equivalent.

nimare/estimator.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@
1212

1313

1414
class Estimator(NiMAREBase):
15-
"""Estimators take in Datasets and return MetaResults.
15+
"""Estimators take in collections and return fitted result objects.
1616
1717
All Estimators must have a ``_fit`` method implemented, which applies algorithm-specific
18-
methods to a Dataset and returns a dictionary of arrays to be converted into a MetaResult.
18+
methods to a collection and returns a dictionary of arrays to be converted into a fitted
19+
result object.
1920
2021
Users will interact with the ``_fit`` method by calling the user-facing ``fit`` method.
2122
``fit`` takes in a ``Dataset``, calls ``_collect_inputs``, then ``_preprocess_input``,
22-
then ``_fit``, and finally converts the dictionary returned by ``_fit`` into a ``MetaResult``.
23+
then ``_fit``, and finally converts the dictionary returned by ``_fit`` into a result
24+
object via ``_make_result``.
2325
2426
.. warning::
2527
Support for :class:`~nimare.dataset.Dataset` inputs is deprecated and will be removed in
@@ -51,7 +53,7 @@ def _collect_inputs(self, dataset, drop_invalid=True):
5153
5254
Parameters
5355
----------
54-
dataset : :obj:`~nimare.dataset.Dataset`
56+
dataset : :obj:`~nimare.nimads.Studyset` or :obj:`~nimare.dataset.Dataset`
5557
drop_invalid : :obj:`bool`, default=True
5658
Whether to automatically drop any studies in the Dataset without valid data or not.
5759
Default is True.
@@ -111,8 +113,8 @@ def _preprocess_input(self, dataset):
111113
112114
Parameters
113115
----------
114-
dataset : :obj:`~nimare.dataset.Dataset`
115-
The Dataset
116+
dataset : :obj:`~nimare.nimads.Studyset` or :obj:`~nimare.dataset.Dataset`
117+
The collection to preprocess.
116118
117119
.. warning::
118120
Support for :class:`~nimare.dataset.Dataset` inputs is deprecated and will be removed
@@ -129,21 +131,29 @@ def _fit(self, dataset):
129131
"""
130132
pass
131133

134+
def _make_result(self, dataset, maps=None, tables=None, description=""):
135+
"""Construct the fitted result object for this estimator.
136+
137+
Subclasses may override this to return a specialized ``MetaResult`` subclass.
138+
"""
139+
masker = getattr(self, "masker", None) or dataset.masker
140+
return MetaResult(self, mask=masker, maps=maps, tables=tables, description=description)
141+
132142
def fit(self, dataset, drop_invalid=True):
133-
"""Fit Estimator to a Studyset-backed collection.
143+
"""Fit Estimator to a collection.
134144
135145
Parameters
136146
----------
137147
dataset : :obj:`~nimare.nimads.Studyset` or :obj:`~nimare.dataset.Dataset`
138148
Collection object to analyze.
139149
drop_invalid : :obj:`bool`, optional
140150
Whether to automatically ignore any studies without the required data or not.
141-
Default is False.
151+
Default is True.
142152
143153
Returns
144154
-------
145155
:obj:`~nimare.results.MetaResult`
146-
Results of Estimator fitting.
156+
Result of Estimator fitting. Subclasses may return a ``MetaResult`` subclass.
147157
148158
Attributes
149159
----------
@@ -166,9 +176,4 @@ def fit(self, dataset, drop_invalid=True):
166176
if not self.generate_description:
167177
description = ""
168178

169-
if hasattr(self, "masker") and self.masker is not None:
170-
masker = self.masker
171-
else:
172-
masker = dataset.masker
173-
174-
return MetaResult(self, mask=masker, maps=maps, tables=tables, description=description)
179+
return self._make_result(dataset, maps=maps, tables=tables, description=description)

nimare/generate.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@
2222
CONTRAST_OF_INTEREST = {"animal": "as-Animal"}
2323

2424

25+
def _studyset_from_dataset(dataset):
26+
"""Convert a Dataset into a Studyset."""
27+
from nimare.nimads import Studyset
28+
29+
return Studyset.from_dataset(dataset)
30+
31+
2532
def create_coordinate_dataset(
2633
foci=1,
2734
foci_percentage="100%",
@@ -145,8 +152,6 @@ def create_coordinate_studyset(
145152
Generated foci in xyz (mm) coordinates.
146153
studyset : :class:`~nimare.nimads.Studyset`
147154
"""
148-
from nimare.nimads import Studyset
149-
150155
ground_truth_foci, dataset = create_coordinate_dataset(
151156
foci=foci,
152157
foci_percentage=foci_percentage,
@@ -157,8 +162,7 @@ def create_coordinate_studyset(
157162
seed=seed,
158163
space=space,
159164
)
160-
studyset = Studyset.from_dataset(dataset)
161-
return ground_truth_foci, studyset
165+
return ground_truth_foci, _studyset_from_dataset(dataset)
162166

163167

164168
def create_neurovault_dataset(
@@ -241,16 +245,14 @@ def create_neurovault_studyset(
241245
:obj:`~nimare.nimads.Studyset`
242246
Studyset object containing experiment information from NeuroVault.
243247
"""
244-
from nimare.nimads import Studyset
245-
246248
dataset = create_neurovault_dataset(
247249
collection_ids=collection_ids,
248250
contrasts=contrasts,
249251
img_dir=img_dir,
250252
map_type_conversion=map_type_conversion,
251253
**dset_kwargs,
252254
)
253-
return Studyset.from_dataset(dataset)
255+
return _studyset_from_dataset(dataset)
254256

255257

256258
def _create_source(foci, sample_sizes, space="MNI"):

nimare/io.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1927,7 +1927,13 @@ def convert_neurovault_to_dataset(
19271927
dataset_dict = {}
19281928
for coll_name, nv_coll in collection_ids.items():
19291929
nv_url = f"https://neurovault.org/api/collections/{nv_coll}/images/?format=json"
1930-
images = requests.get(nv_url).json()
1930+
images = _get_json_with_retries(
1931+
nv_url,
1932+
error_message=(
1933+
f"Failed to retrieve NeuroVault collection {nv_coll}. "
1934+
"The NeuroVault API returned an invalid or incomplete response."
1935+
),
1936+
)
19311937
if "Not found" in images.get("detail", ""):
19321938
raise ValueError(
19331939
f"Collection {nv_coll} not found. "
@@ -1963,7 +1969,8 @@ def convert_neurovault_to_dataset(
19631969
)
19641970

19651971
if not filename.exists():
1966-
r = requests.get(img_dict["file"])
1972+
r = requests.get(img_dict["file"], timeout=30)
1973+
r.raise_for_status()
19671974
with open(filename, "wb") as f:
19681975
f.write(r.content)
19691976

@@ -2004,6 +2011,31 @@ def convert_neurovault_to_dataset(
20042011
return dataset
20052012

20062013

2014+
def _get_json_with_retries(url, *, error_message, max_attempts=3, timeout=30):
2015+
"""Fetch JSON from a remote endpoint with limited retries for transient failures."""
2016+
last_error = None
2017+
for _ in range(max_attempts):
2018+
try:
2019+
response = requests.get(url, timeout=timeout)
2020+
try:
2021+
payload = response.json()
2022+
except json.JSONDecodeError as exc:
2023+
if response.status_code == 404:
2024+
return {"detail": "Not found"}
2025+
last_error = exc
2026+
continue
2027+
2028+
if response.status_code == 404:
2029+
return payload if isinstance(payload, dict) else {"detail": "Not found"}
2030+
2031+
response.raise_for_status()
2032+
return payload
2033+
except requests.RequestException as exc:
2034+
last_error = exc
2035+
2036+
raise ValueError(f"{error_message} Last error: {last_error}") from last_error
2037+
2038+
20072039
def _resolve_sample_size(sample_sizes):
20082040
"""Choose modal sample_size if there are multiple sample_sizes to choose from."""
20092041
sample_size_counts = Counter(sample_sizes)

0 commit comments

Comments
 (0)