Skip to content

[ENH] ibma support - #59

Merged
jdkent merged 14 commits into
mainfrom
enh/ibma_support
Aug 21, 2026
Merged

[ENH] ibma support#59
jdkent merged 14 commits into
mainfrom
enh/ibma_support

Conversation

@jdkent

@jdkent jdkent commented Aug 20, 2026

Copy link
Copy Markdown
Member

this is the attempt to officially support image based meta-analysis.

there is a lot of code in this pull request, it may be merged due to time constraints, but should be combed over soon.

jdkent and others added 8 commits August 19, 2026 21:14
Point at NiMARE main rather than a released version. 0.21.0 is where the
`groupby` estimator argument lands, where Stouffers loses
`normalize_contrast_weights`, and where `aggressive_mask` starts defaulting to
False; PyPI still only has 0.20.0, which has none of it.

A direct reference needs hatchling's opt-in to build, so that goes in too. Both
carry a note to swap back to a version specifier once 0.21.0 ships, because the
PyPI publish step rejects direct references even though the build accepts them.

`requests` becomes explicit because staging an image-based meta-analysis's maps
needs it directly rather than through a transitive dependency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NiMARE resolves an image to a path and hands it to nibabel, which cannot read
over HTTP, so the maps have to be local before a studyset can be analysed.

Two things make this less mechanical than it sounds.

Which of an image's `url` and `filename` holds the downloadable NIfTI depends on
how it reached Neurostore, and both shapes are live in production: images
ingested from NeuroVault put the NIfTI in `url` and a bare basename in
`filename`, while images uploaded by compose put the landing page in `url` and
the NIfTI in `filename`. So neither field can be trusted by position, and the
choice is made on content instead -- the candidate that is both fetchable and
named like a NIfTI. Choosing the bare basename would have dropped every map in
an ingested studyset, which is most of what an image-based meta-analysis would
want to run over.

A landing page also returns HTTP 200 with HTML, so `raise_for_status` does not
catch it. Downloaded bytes are checked for a gzip or NIfTI header before being
written, to keep a file nibabel cannot open out of the cache, where it would
fail every later run too.

`value_type` arrives as a NeuroVault label rather than a NiMARE image type, and
maps NiMARE has no use for are dropped -- along with a record, per analysis, of
what was dropped and why, so a caller can say which maps are absent rather than
only how many.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`FixedEffectsHedges` and `SampleSizeBasedLikelihood` require a `sample_sizes`
entry on each analysis, `Stouffers` and `Fishers` can weight by it, and NiMARE
needs it to convert a t-map into a z-map -- which is the common case, since most
NeuroVault uploads are t-maps.

Compose records it on the annotation note or on the study's metadata rather than
on the analysis, and as a free-form string, so it has to be found, coerced and
copied across before the studyset becomes a Dataset. Precedence is annotation
note, then analysis metadata, then study metadata: the annotation is the most
specific and the most deliberately curated.

Analyses with no sample size are counted and warned about rather than failing
the run, since an estimator that does not need one is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Compose stores whatever the frontend collected and hands it straight to the
estimator. Two things have to happen in between.

`n_cores` is the load-bearing one. The runner injects it for every estimator and
no image-based estimator accepts it, so passing it through would raise now that
NiMARE rejects unrecognized keywords. `PermutedOLS` calls its equivalent
`n_jobs`; the rest are serial.

The rest is a better error, earlier. Validating against the estimator's own
signature -- walked over the MRO, because `aggressive_mask` and `groupby` live on
the base class and reach subclasses through `**kwargs` -- lets a stale
specification be rejected before a studyset's worth of maps is downloaded. The
message names what replaced a retired argument rather than only reporting it as
unexpected.

There is nothing to be backwards compatible with: compose cannot create an
image-based meta-analysis yet, so no stored specification names an argument an
older NiMARE had. This targets one API, the installed one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Real studysets are uneven. A base-study search for image data on "language"
returns 27 studies carrying nine distinct value_type labels between them -- z
maps, t maps, beta maps, F maps, ROI masks, anatomicals.

NiMARE closes the gap on its own: `IBMAWorkflow` converts what it can and
`drop_invalid` discards the rest. Both steps are silent, and that is the only
problem -- a meta-analysis can rest on a third of the studies the user selected
and say nothing about it. On that studyset, `Stouffers` uses 39 of 53 analyses
when sample sizes are recorded and 22 of 53 when they are not, because the
difference decides whether 17 t-map analyses can be converted.

This owns no transform rules and repeats none of NiMARE's work. It introspects
the fitted estimator, which already holds both answers: `inputs_["id"]` is what
survived the dropping, and `estimator.dataset.images` is the table after the
transform, so diffing it against the table that was submitted says which maps
were converted rather than supplied.

The per-analysis verdict goes to the log and to a TSV beside the results.

Note for anyone extending this: NiMARE's image table has a column per type
across the whole studyset, and an absent path reads as NaN, which is truthy.
Testing truthiness reports every analysis as holding every type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dispatch a specification whose type is IBMA to `IBMAWorkflow`. Group comparisons
are rejected up front, because no image-based estimator is pairwise.

Analyses are no longer merged. `combine_analyses` is right for CBMA, where the
point is to pool a study's coordinates, but for IBMA it concatenates a study's
images into one analysis and the conversion to a Dataset keeps only one map per
type, so every extra contrast is silently discarded -- along with the study
grouping the dependence correction depends on.

The estimator is built before the images are fetched. NiMARE raises on an
argument it does not recognize and so does the resolver, so a stale specification
should not first cost a studyset's worth of downloads.

The diagnostic is named rather than constructed, matching the coordinate-based
branches, so the workflow's `voxel_thresh` and `cluster_threshold` are what
define clusters. `n_cores` is threaded through to the workflow, which is what
parallelizes the diagnostics.

Two outcomes are refused rather than reported as success. A studyset with nothing
usable gets NiMARE's own message with the per-analysis account appended, since
there is no fitted estimator to introspect at that point. And a result that is
NaN at every voxel is rejected naming the input maps responsible: NiMARE counts a
voxel as valid only where it is finite and non-zero, so a single empty upload
empties an aggressive mask however good the rest are. That run looks successful
and the maps it would upload hold no values.

Uploads now skip a map whose value is None, mirroring `save_maps`, which never
writes a file for one. Reading unconditionally failed at the very end of a run,
after all the work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An IBMA is heavy because of its inputs, not its correction: it downloads a
full-resolution map per contrast, holds every one of them in memory as a single
masked array, and under aggressive_mask=False fits a separate model per group of
voxels sharing a validity pattern. None of that applies to a coordinate-based
run, which only ever handles foci. So the specification type escalates the task
size on its own, whatever the corrector says.

The fallbacks to the standard task now log why. `_fetch_meta_analysis` is an
unauthenticated GET with a ten second timeout, and `?nested=true` is what makes
the specification a dict rather than an id string; either failing silently sized
an image-based run as though it were coordinate-based, which shows up afterwards
as an unexplained slowdown or OOM with nothing recording the cause.

Note that this buys CPU and memory but not disk: both task definitions set
`ephemeral_storage_gib` only when the context value exceeds 20, and nothing
overrides it. Measured, that is not currently tight -- a 27-study run used
156 MB -- so it is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Compose cannot create an image-based meta-analysis yet, so there is no bundle to
download and no way to find out what breaks by running one.
`simulate_ibma_bundle.py` assembles the bundle the frontend would produce -- real
Neurostore studies, real NeuroVault maps, and a specification generated from the
frontend's own meta_analysis_params.json -- and pushes it through the runner.

Taking the specification from the committed config rather than writing it by hand
is the point: it is what surfaced the places where the config, NiMARE and this
runner disagreed. `--search` goes further and builds the studyset from a
base-study query, using whatever comes back rather than selecting for studies the
estimator can already use.

The document records the bundle's shape, what the runner does differently for
IBMA, what Neurostore still needs, and what was found and fixed in NiMARE along
the way, with the measurements behind each claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
number = float(value)
except (TypeError, ValueError):
return None
if number <= 0 or number != number: # reject non-positive and NaN
Comment thread compose_runner/run.py Fixed
jdkent and others added 6 commits August 19, 2026 21:41
The previous commit escalated every IBMA to the large task on the assumption
that its inputs were what made it expensive. Measured, they are not. Only the
FWE path is heavy, and for an image-based specification that means PermutedOLS's
permutation test -- the one image-based estimator with FWE_enabled -- so that is
the only case that escalates now.

On the "language" studyset with aggressive_mask=False, pinned to four vCPU to
emulate the standard task:

    studies  images  bags  cores  wall time  peak RSS
          9      14    13      4       44 s   2.25 GiB
         18      26    46      4       80 s   2.56 GiB
         27      39   263      4      232 s   3.10 GiB
         27      39   263      8      222 s   4.99 GiB

Ten percent of a 30 GiB limit at worst, and memory is mostly fixed overhead:
2.8x the images buys 1.36x the memory, roughly a 2 GiB baseline plus ~30 MB per
image, so the standard task holds around 900 images. The array that sounded
expensive -- every map held as one masked array -- is 36 MB; the 3 GiB is the 4D
concatenation during resampling plus one estimator copy per joblib worker.

Cores do not help. Doubling to eight took 4% off the wall time and added 61% to
peak memory, since each worker copies the estimator. Stouffers fits serially, the
voxel-bag loop is sequential, and Jackknife is evidently bound by something other
than CPU. Escalating would have paid four times the task cost for nothing.

`_is_fwe` and `_corrector_method` come out as helpers because both branches now
need the FWE check, and the method lookup was buried in nested early returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ef561d4 removed the branch that logged `specification_not_nested`, leaving this
test asserting a log line nothing emits. The premise it was defending is not
worth defending: `_fetch_meta_analysis` always passes `?nested=true`, so the
specification is reliably a dict rather than an id string.

The `specification_unavailable` case is different and keeps its test -- a
transient fetch failure is real, and the size chosen after one is a guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two rows described code that no longer exists. `_report_coverage` ran NiMARE's
transform to work out what was usable before fitting; that was replaced by
introspecting the fitted estimator, so the row is now `_fit_image_based` and
`_describe_coverage`, neither of which transforms anything. And the sizing row
still claimed every IBMA goes to the large task, which 8440240 narrowed to FWE
only.

The prose sections were updated when those changes were made; the table was not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread compose_runner/run.py
self.nsc_key = meta_analysis.get("run_key")

def apply_filter(self, studyset, annotation):
def apply_filter(self, studyset, combine=True):

@jdkent jdkent left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, some of the IBMA simulation/testing will be deleted once the workflow actually exists on our server.

@jdkent
jdkent merged commit 9b37918 into main Aug 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant