Skip to content

Commit 798d4c9

Browse files
committed
automatically use the base z stat image when the target image is the cluster corrected map
1 parent 4df92ba commit 798d4c9

2 files changed

Lines changed: 236 additions & 24 deletions

File tree

nimare/diagnostics.py

Lines changed: 132 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import copy
44
import logging
5+
import warnings
56
from abc import abstractmethod
67

78
import nibabel as nib
@@ -69,6 +70,124 @@ def _get_target_value_map(result):
6970
)
7071

7172

73+
def _resolve_target_threshold(target_threshold, voxel_thresh):
74+
"""Resolve diagnostics threshold aliases."""
75+
if target_threshold is not None and voxel_thresh is not None:
76+
raise ValueError(
77+
"Only one of 'target_threshold' and deprecated 'voxel_thresh' may be provided."
78+
)
79+
80+
if voxel_thresh is not None:
81+
warnings.warn(
82+
"'voxel_thresh' is deprecated for diagnostics and will be removed in a future "
83+
"release. Use 'target_threshold' to threshold the selected target image before "
84+
"diagnostics table/support generation.",
85+
FutureWarning,
86+
stacklevel=3,
87+
)
88+
return voxel_thresh
89+
90+
return target_threshold
91+
92+
93+
def _is_cluster_corrected_target(target_image):
94+
"""Determine whether a target image is a corrected cluster-level map."""
95+
return "_level-cluster" in target_image and "_corr-" in target_image
96+
97+
98+
def _remove_cluster_stat_suffix(description):
99+
"""Remove size/mass cluster-stat suffixes from a map description."""
100+
if description in {"size", "mass"}:
101+
return None
102+
103+
for suffix in ("Size", "Mass", "size", "mass"):
104+
if description.endswith(suffix):
105+
description = description[: -len(suffix)]
106+
break
107+
108+
return description or None
109+
110+
111+
def _candidate_peak_value_maps(target_image):
112+
"""Generate candidate original statistic maps for a corrected cluster target."""
113+
uncorrected_target = target_image.split("_corr-", 1)[0]
114+
candidate_maps = []
115+
116+
if "_desc-" in uncorrected_target:
117+
description = uncorrected_target.split("_desc-", 1)[1].split("_", 1)[0]
118+
description = _remove_cluster_stat_suffix(description)
119+
if description is not None:
120+
candidate_maps.extend([f"z_desc-{description}", f"stat_desc-{description}"])
121+
122+
candidate_maps.extend(
123+
["z", "stat", "est", "stat_desc-group1MinusGroup2", "z_desc-association"]
124+
)
125+
return candidate_maps
126+
127+
128+
def _get_peak_value_map_for_cluster_table(result, target_image):
129+
"""Select the original map used for peak statistics in corrected-cluster tables."""
130+
for candidate_map in _candidate_peak_value_maps(target_image):
131+
if "_level-cluster" not in candidate_map and candidate_map in result.maps:
132+
return candidate_map
133+
134+
available_maps = ", ".join(sorted(result.maps.keys()))
135+
raise ValueError(
136+
"No supported original z/statistic map found for corrected-cluster table peaks. "
137+
f"Target image was '{target_image}'. Available maps are: {available_maps}."
138+
)
139+
140+
141+
def _get_cluster_support_data(label_maps, shape):
142+
"""Convert one or more cluster label maps to a binary support array."""
143+
support = np.zeros(shape, dtype=bool)
144+
for label_map in label_maps:
145+
support |= np.asanyarray(label_map.dataobj) > 0
146+
return support
147+
148+
149+
def _get_clusters_table_and_label_maps(
150+
result,
151+
target_img,
152+
target_image,
153+
threshold,
154+
cluster_threshold,
155+
):
156+
"""Create diagnostics clusters from target image and peaks from original statistics."""
157+
target_data = target_img.get_fdata(dtype=DEFAULT_FLOAT_DTYPE)
158+
if hasattr(result.estimator, "two_sided"):
159+
# Only present in Fisher's and Stouffer's estimators
160+
two_sided = getattr(result.estimator, "two_sided")
161+
else:
162+
two_sided = (target_data < 0).any()
163+
164+
clusters_table, label_maps = get_clusters_table(
165+
target_img,
166+
0 if threshold is None else threshold,
167+
cluster_threshold,
168+
two_sided=two_sided,
169+
return_label_maps=True,
170+
)
171+
172+
if clusters_table.empty or not label_maps or not _is_cluster_corrected_target(target_image):
173+
return clusters_table, label_maps
174+
175+
peak_value_map = _get_peak_value_map_for_cluster_table(result, target_image)
176+
peak_img = result.get_map(peak_value_map, return_type="image")
177+
peak_data = peak_img.get_fdata(dtype=DEFAULT_FLOAT_DTYPE)
178+
support_data = _get_cluster_support_data(label_maps, peak_data.shape)
179+
masked_peak_data = np.where(support_data, peak_data, 0).astype(DEFAULT_FLOAT_DTYPE, copy=False)
180+
masked_peak_img = nib.Nifti1Image(masked_peak_data, peak_img.affine, peak_img.header)
181+
182+
peak_clusters_table = get_clusters_table(
183+
masked_peak_img,
184+
0,
185+
0,
186+
two_sided=two_sided or (masked_peak_data < 0).any(),
187+
)
188+
return peak_clusters_table, label_maps
189+
190+
72191
def _cluster_masker_kwargs():
73192
"""Return standardized kwargs for label-based cluster summaries."""
74193
return _filter_kwargs(
@@ -210,17 +329,20 @@ class Diagnostics(NiMAREBase):
210329
The meta-analytic map for which clusters will be characterized.
211330
The default is z because log-p will not always have value of zero for non-cluster voxels.
212331
voxel_thresh : :obj:`float` or None, optional
213-
An optional voxel-level threshold that may be applied to the ``target_image`` to define
214-
clusters. This can be None if the ``target_image`` is already thresholded
215-
(e.g., a cluster-level corrected map).
216-
Default is None.
332+
Deprecated alias for ``target_threshold``. Prefer ``target_threshold`` for new code.
217333
cluster_threshold : :obj:`int` or None, optional
218334
Cluster size threshold, in :term:`voxels<voxel>`.
219335
If None, then no cluster size threshold will be applied. Default=None.
220336
n_cores : :obj:`int`, optional
221337
Number of cores to use for parallelization.
222338
If <=0, defaults to using all available cores.
223339
Default is 1.
340+
target_threshold : :obj:`float` or None, optional
341+
Threshold applied to ``target_image`` before defining diagnostics clusters and tables.
342+
For unthresholded Monte Carlo cluster-corrected maps, this should generally be the
343+
corrected significance threshold in the target map's units. This is distinct from
344+
:class:`~nimare.correct.FWECorrector` ``voxel_thresh``, which is the cluster-forming
345+
threshold used during correction. Default=None.
224346
225347
"""
226348

@@ -231,9 +353,11 @@ def __init__(
231353
cluster_threshold=None,
232354
display_second_group=False,
233355
n_cores=1,
356+
target_threshold=None,
234357
):
235358
self.target_image = target_image
236359
self.voxel_thresh = voxel_thresh
360+
self.target_threshold = _resolve_target_threshold(target_threshold, voxel_thresh)
237361
self.cluster_threshold = cluster_threshold
238362
self.display_second_group = display_second_group
239363
self.n_cores = _check_ncores(n_cores)
@@ -312,21 +436,14 @@ def transform(self, result):
312436
)
313437

314438
# Get clusters table and label maps
315-
stat_threshold = self.voxel_thresh or 0
316439
cluster_threshold = 0 if self.cluster_threshold is None else self.cluster_threshold
317440

318-
if hasattr(result.estimator, "two_sided"):
319-
# Only present in Fisher's and Stouffer's estimators
320-
two_sided = getattr(result.estimator, "two_sided")
321-
else:
322-
two_sided = (target_img.get_fdata(dtype=DEFAULT_FLOAT_DTYPE) < 0).any()
323-
324-
clusters_table, label_maps = get_clusters_table(
441+
clusters_table, label_maps = _get_clusters_table_and_label_maps(
442+
result,
325443
target_img,
326-
stat_threshold,
444+
self.target_image,
445+
self.target_threshold,
327446
cluster_threshold,
328-
two_sided=two_sided,
329-
return_label_maps=True,
330447
)
331448

332449
n_clusters = clusters_table.shape[0]

nimare/tests/test_diagnostics.py

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import nibabel as nib
88
import numpy as np
9+
import pandas as pd
910
import pytest
1011
from nilearn.maskers import NiftiLabelsMasker
1112

@@ -92,6 +93,100 @@ def test_get_target_value_map_raises_for_unsupported_maps():
9293
diagnostics._get_target_value_map(result)
9394

9495

96+
def test_diagnostics_voxel_thresh_deprecated_alias():
97+
"""voxel_thresh should remain a deprecated alias for target_threshold."""
98+
with pytest.warns(FutureWarning, match="voxel_thresh"):
99+
counter = diagnostics.FocusCounter(target_image="z", voxel_thresh=1.0)
100+
101+
assert counter.target_threshold == 1.0
102+
103+
104+
def test_diagnostics_target_and_voxel_threshold_error():
105+
"""Supplying both threshold names should fail explicitly."""
106+
with pytest.raises(ValueError, match="target_threshold"):
107+
diagnostics.FocusCounter(target_image="z", target_threshold=1.0, voxel_thresh=1.0)
108+
109+
110+
def test_corrected_cluster_table_uses_thresholded_support_and_original_z():
111+
"""Corrected cluster tables should report original-z peaks inside thresholded support."""
112+
target_image = "z_desc-size_level-cluster_corr-FWE_method-montecarlo"
113+
mask_data = np.ones((7, 7, 7), dtype=bool)
114+
mask_img = nib.Nifti1Image(mask_data.astype(np.int8), affine=np.eye(4))
115+
116+
class DummyMasker:
117+
def __init__(self, mask_img):
118+
self.mask_img_ = mask_img
119+
120+
def transform(self, img):
121+
return np.asanyarray(img.dataobj)[mask_data].reshape(1, -1)
122+
123+
def inverse_transform(self, data):
124+
arr = np.asarray(data)
125+
if arr.ndim > 1:
126+
arr = np.squeeze(arr, axis=0)
127+
out = np.zeros(mask_data.shape, dtype=arr.dtype)
128+
out[mask_data] = arr
129+
return nib.Nifti1Image(out, affine=mask_img.affine)
130+
131+
class DummyResult:
132+
def __init__(self, maps, masker):
133+
self.maps = maps
134+
self.tables = {}
135+
self.diagnostics = []
136+
self.masker = masker
137+
self.estimator = SimpleNamespace(
138+
masker=masker,
139+
inputs_={
140+
"id": ["study1"],
141+
"coordinates": pd.DataFrame(
142+
{"id": ["study1"], "x": [1.0], "y": [1.0], "z": [1.0]}
143+
),
144+
},
145+
)
146+
147+
def get_map(self, name, return_type="image"):
148+
values = self.maps[name]
149+
if return_type == "array":
150+
return values
151+
return self.masker.inverse_transform(values)
152+
153+
corrected = np.zeros(mask_data.shape, dtype=float)
154+
corrected[(1, 1, 1)] = 6.0
155+
corrected[(1, 1, 2)] = 2.0
156+
corrected[(1, 2, 1)] = 2.0
157+
corrected[(5, 5, 5)] = 1.2
158+
corrected[(5, 5, 4)] = 1.2
159+
160+
original_z = np.zeros(mask_data.shape, dtype=float)
161+
original_z[(1, 1, 1)] = 3.0
162+
original_z[(1, 1, 2)] = 9.0
163+
original_z[(1, 2, 1)] = 2.0
164+
original_z[(5, 5, 5)] = 8.0
165+
original_z[(5, 5, 4)] = 8.0
166+
original_z[(3, 3, 3)] = 99.0
167+
168+
masker = DummyMasker(mask_img)
169+
result = DummyResult(
170+
{
171+
target_image: masker.transform(nib.Nifti1Image(corrected, affine=mask_img.affine))[0],
172+
"z": masker.transform(nib.Nifti1Image(original_z, affine=mask_img.affine))[0],
173+
},
174+
masker,
175+
)
176+
177+
counter = diagnostics.FocusCounter(target_image=target_image, target_threshold=1.64)
178+
result = counter.transform(result)
179+
180+
clusters_table = result.tables[f"{target_image}_tab-clust"]
181+
peak_row = clusters_table.loc[clusters_table["Peak Stat"].idxmax()]
182+
183+
assert clusters_table.shape[0] == 1
184+
assert peak_row["Peak Stat"] == pytest.approx(9.0)
185+
assert peak_row[["X", "Y", "Z"]].to_numpy().tolist() == [1.0, 1.0, 2.0]
186+
assert not np.any(np.isclose(clusters_table["Peak Stat"], 8.0))
187+
assert not np.any(np.isclose(clusters_table["Peak Stat"], 99.0))
188+
189+
95190
def test_is_voxelwise_masker_uses_round_trip_when_mask_count_mismatches():
96191
"""Voxelwise detection should fall back to a round-trip feature-shape check."""
97192
mask_data = np.array([[[1], [0]], [[1], [1]]], dtype=np.int8)
@@ -150,7 +245,7 @@ def test_jackknife_smoke(
150245
testdata = testdata_ibma if meta_type == "ibma" else testdata_cbma_full
151246
res = meta.fit(dset1, dset2) if n_samples == "twosample" else meta.fit(testdata)
152247

153-
jackknife = diagnostics.Jackknife(target_image=target_image, voxel_thresh=voxel_thresh)
248+
jackknife = diagnostics.Jackknife(target_image=target_image, target_threshold=voxel_thresh)
154249
results = jackknife.transform(res)
155250

156251
image_name = "_".join(target_image.split("_")[1:])
@@ -177,7 +272,7 @@ def test_jackknife_with_zero_clusters(testdata_cbma_full):
177272
meta = cbma.ALE()
178273
res = meta.fit(testdata_cbma_full)
179274

180-
jackknife = diagnostics.Jackknife(target_image="z", voxel_thresh=10)
275+
jackknife = diagnostics.Jackknife(target_image="z", target_threshold=10)
181276
results = jackknife.transform(res)
182277

183278
contribution_table = results.tables["z_diag-Jackknife_tab-counts"]
@@ -200,14 +295,14 @@ def test_jackknife_with_custom_masker_smoke(testdata_ibma):
200295
meta = ibma.SampleSizeBasedLikelihood(mask=masker)
201296
res = meta.fit(testdata_ibma)
202297

203-
jackknife = diagnostics.Jackknife(target_image="z", voxel_thresh=0.5)
298+
jackknife = diagnostics.Jackknife(target_image="z", target_threshold=0.5)
204299
results = jackknife.transform(res)
205300
contribution_table = results.tables["z_diag-Jackknife_tab-counts_tail-positive"]
206301
assert contribution_table.shape[0] == len(meta.inputs_["id"])
207302

208303
# A Jackknife with a target_image that isn't present in the MetaResult raises a ValueError.
209304
with pytest.raises(ValueError):
210-
jackknife = diagnostics.Jackknife(target_image="doggy", voxel_thresh=0.5)
305+
jackknife = diagnostics.Jackknife(target_image="doggy", target_threshold=0.5)
211306
jackknife.transform(res)
212307

213308

@@ -227,7 +322,7 @@ def test_focuscounter_negative_tail_label_map_naming(testdata_cbma_full):
227322
neg_img = nib.Nifti1Image(neg_data, mask_img.affine)
228323
res.maps["z"] = np.squeeze(masker.transform(neg_img))
229324

230-
counter = diagnostics.FocusCounter(target_image="z", voxel_thresh=1.0)
325+
counter = diagnostics.FocusCounter(target_image="z", target_threshold=1.0)
231326
results = counter.transform(res)
232327

233328
assert "label_tail-negative" in results.maps
@@ -252,7 +347,7 @@ def test_focuscounter_positive_tail_label_map_naming(testdata_cbma_full):
252347
pos_img = nib.Nifti1Image(pos_data, mask_img.affine)
253348
res.maps["z"] = np.squeeze(masker.transform(pos_img))
254349

255-
counter = diagnostics.FocusCounter(target_image="z", voxel_thresh=1.0)
350+
counter = diagnostics.FocusCounter(target_image="z", target_threshold=1.0)
256351
results = counter.transform(res)
257352

258353
assert "label_tail-positive" in results.maps
@@ -283,7 +378,7 @@ def _fake_infer(_label_maps, _clusters_table, _n_clusters):
283378
monkeypatch.setattr(diagnostics, "_infer_label_map_tails", _fake_infer)
284379
caplog.set_level(logging.WARNING, logger="nimare.diagnostics")
285380

286-
counter = diagnostics.FocusCounter(target_image="z", voxel_thresh=1.0)
381+
counter = diagnostics.FocusCounter(target_image="z", target_threshold=1.0)
287382
results = counter.transform(res)
288383

289384
assert any("Mixed-sign clusters detected" in r.message for r in caplog.records)
@@ -308,7 +403,7 @@ def test_focuscounter_pairwise_negative_tail_uses_group2(testdata_cbma_full):
308403
neg_img = nib.Nifti1Image(neg_data, mask_img.affine)
309404
res.maps["z_desc-uniformity"] = np.squeeze(masker.transform(neg_img))
310405

311-
counter = diagnostics.FocusCounter(target_image="z_desc-uniformity", voxel_thresh=1.0)
406+
counter = diagnostics.FocusCounter(target_image="z_desc-uniformity", target_threshold=1.0)
312407
results = counter.transform(res)
313408

314409
table_key = "z_desc-uniformity_diag-FocusCounter_tab-counts_tail-negative"
@@ -342,7 +437,7 @@ def test_focuscounter_smoke(
342437
testdata = testdata_ibma if meta_type == "ibma" else testdata_cbma_full
343438
res = meta.fit(dset1, dset2) if n_samples == "twosample" else meta.fit(testdata)
344439

345-
counter = diagnostics.FocusCounter(target_image=target_image, voxel_thresh=1.65)
440+
counter = diagnostics.FocusCounter(target_image=target_image, target_threshold=1.65)
346441
if meta_type == "ibma":
347442
with pytest.raises(ValueError):
348443
counter.transform(res)

0 commit comments

Comments
 (0)