Repository: nilearn/nilearn

Issue:
get_clusters_table COM sometimes outside of irregular shaped objects
Hi,

I was using get_clusters_table to exact the summary statistics of a meta-analysis that I'm doing using NiMARE and I came across some behavior which isn't incorrect but it would be good to make it explicit to the user:

- As I'm using cluster-based inference the entire cluster is associated with a probability which means that any sub peaks are not informative. NiMARE makes this explicit by basically making the clusters binary. 
- When using get_clusters_table I therefore get the Center of Mass (COM) of a cluster instead of the location of a peak value
- As the clusters can be irregular shaped the COM of a cluster can actually fall outside of the body. This is expected behavior when calculating the COM but for a meta-analysis where such summary coordinate is usually linked to an anatomical region this might result in associating a cluster with an anatomical label that doesn't actually overlap with the cluster. 

An extreme example would be a cluster in the shape of a donut but the same holds for clusters shaped like a boomerang  (which is more realistic given the curvature of the cortex). At the moment you can infer that this is happening when the peak stat output of get_clusters_table is equal to 0.0

What I would like to request is to have is a flag / warning to the user when using get_clusters_table on binary masks and the COM is outside the actual body of the cluster. This would ensure that the user is at least explicitly aware that the summary coordinate should not be directly linked to an anatomical region.

Cheers
Max


 <!--
Hi! 
If you have: 
 -1 Questions about how to use Nilearn or 
 -2 Need analysis suggestions & recommendations?

A bunch of fMRI researchers hang out at Neurostars (http://neurostars.org/). 
Post those questions there. 
Add the tag `nilearn` (we get an email from Neurostars if you do).

Posting them here makes life more complicated for the Nilearn developers.  
-->

<!-- 
For the Feature Request,
Include the following:
------------------------
What would you like changed/added and why?
What would be the benefit? Does the change make something easier to use?
Clarifies something?
If it is a new feature, what is the benefit? 
-->



Discussion:
Dear Max, 
Thank you for posting. Can you let me know which Nilearn function you are referring to ?
Indeed, the `nilearn.reporting.get_clusters_table` does not return the COM 
https://nilearn.github.io/auto_examples/plot_single_subject_single_run.html#detecting-voxels-with-significant-effects
Best,
Bertrand 
@bthirion peak identification does use the center of mass (see [`nilearn.reporting._get_clusters_table._identify_subpeaks()`](https://github.com/nilearn/nilearn/blob/2a4111f87ea7458c4e5aec9db814135bc3cf5451/nilearn/reporting/_get_clusters_table.py#L49). This was originally added back in `nistats` in https://github.com/nilearn/nistats/pull/167, although I haven't kept track of the `get_clusters_table` code lately, so if something has changed I may be mistaken.

EDIT: Here's the original rationale for using center of mass: https://github.com/nilearn/nistats/pull/167#discussion_r170573707:

> This is only for neighboring peak voxels with the same value. One thing I noticed with the tests for this function were that some of them used constant blocks of values and the old function would just return the first index, which generally corresponded so a voxel in the corner of the "cluster".
Indeed, sorry.
@mckeuken can you implement your suggestion, ie detecting when the COM is outside of the brain mask and raising a warning ?
Best,
Hi @bthirion and @tsalo 

As I understand the nilearn.reporting._get_clusters_table code it always outputs the COM right?

In line 169 of _get_clusters_table.py (regardless of whether you are looking at the peak or sub peak) it calls:

> subpeak_ijk, subpeak_vals = _local_max(masked_data, stat_img.affine,
>                                                min_distance=min_distance)

Where _local_max (line 37) calls:
> ijk, vals = _identify_subpeaks(data) 

And _identify_subpeaks only looks at the center of mass (line 53 in original code, line 10 in quote) to get the initial 'ijk' coordinates:

> def _identify_subpeaks(data):
>>    Initial identification of subpeaks with minimal minimum distance
>>    data_max = ndimage.filters.maximum_filter(data, 3)
>>    maxima = (data == data_max)
>>    data_min = ndimage.filters.minimum_filter(data, 3)
>>    diff = ((data_max - data_min) > 0)
>>    maxima[diff == 0] = 0
>>    labeled, n_subpeaks = ndimage.label(maxima)
>>    labels_index = range(1, n_subpeaks + 1)
>>    ijk = np.array(ndimage.center_of_mass(data, labeled, labels_index))
>>    ijk = np.round(ijk).astype(int)
>>    vals = np.apply_along_axis(arr=ijk, axis=1, func1d=_get_val, input_arr=data)
>>    return ijk, vals

In cases of a single peak voxel the COM and the coordinate of that voxel are the same. 
In cases of symmetrically shaped cluster of neighbouring voxels with the same peak value the coordinate that you get back will be in the middle of the cluster. 
In cases of irregular shaped clusters of neighbouring voxels with the same peak value (the cluster level output of NiMARE is an example where this usually is the case) you might be getting a COM that is outside of the cluster. 

Would adding the last three lines to _identify_subpeaks be enough for such a flag?
> def _identify_subpeaks(data):
>>    Initial identification of subpeaks with minimal minimum distance
>>    data_max = ndimage.filters.maximum_filter(data, 3)
>>    maxima = (data == data_max)
>>    data_min = ndimage.filters.minimum_filter(data, 3)
>>    diff = ((data_max - data_min) > 0)
>>    maxima[diff == 0] = 0
>>    labeled, n_subpeaks = ndimage.label(maxima)
>>    labels_index = range(1, n_subpeaks + 1)
>>    ijk = np.array(ndimage.center_of_mass(data, labeled, labels_index))
>>    ijk = np.round(ijk).astype(int)
>>   vals = np.apply_along_axis(arr=ijk, axis=1, func1d=_get_val, input_arr=data)
>>   statValuesCluster = np.array(ndimage.maximum(data, labels=labeled, index=labels_index))
>>   if (statValuesCluster > vals).any():
>>       warnings.warn('Attention: At least one of the reported coordinates falls outside of the cluster body.')
>>   return ijk, vals

Yes this sounds right. You will also have to test that the warning is raised when it should. Thx !
Hi @bthirion and @tsalo

So I created a nifti file with 5 different clusters:
- a sphere, intensity 1
- a hollow sphere, intensity 1
- a 3D crescent (basically a hollow sphere with a chunk out of it), intensity 1
- a cube, intensity 2 
- and a rectangle, intensity 2  

And I modified my local copy of get_clusters_table by including the warning and ran it on the nifti file with two different thresholds (0 and 1). The resulting table corresponds to the COM of all the 5 different clusters and raises a warning for the hollow spheres. If you threshold it at 1 it only finds the COM of the square and rectangle and does not raise a warning as the COM for all reported clusters are inside of the cluster body. 

In addition to the simulation I also checked with a cluster output from NiMare to see if it also works with actual data and that seems to be the case. I've added the modified code in a notebook with the example nifti's in the attachment so you can have a look. If you don't see anything weird I'll make a push request.
[CodeAndExampleNiftis.zip](https://github.com/nilearn/nilearn/files/5771152/CodeAndExampleNiftis.zip)

Cheers,
Max

Nice job with the notebook! I think that your proposed changes would work for most NiMARE-like cases, although I'm not sure how it would work for a standard image without running nilearn's existing tests. However, your ideas got me thinking of an extreme case where there's something like a donut with a smaller sphere inside the hole. Basically, a case where the value of the COM is non-zero, but comes from outside the cluster. I am not sure that your proposed code would catch the problem there, unless the values in the sphere were lower than the values in the donut (if I'm understanding the code correctly).

Here's an approach that should, I think, just check if the COM is within the cluster based on the array indices:

```python
cluster_idx = np.vstack(np.where(labeled)).T.tolist()
bad_idx = [i for i, peak_idx in enumerate(ijk.tolist()) if peak_idx not in cluster_idx]
vals[bad_idx] = np.nan
if np.any(np.isnan(vals)):
    warnings.warn('Attention: At least one of the reported coordinates falls outside of the cluster body.')
```

It would go right where your proposed change is (between lines 62 and 63):

https://github.com/nilearn/nilearn/blob/2a4111f87ea7458c4e5aec9db814135bc3cf5451/nilearn/reporting/_get_clusters_table.py#L57-L63

The additional benefit here is that NaN is more explicit than 0 when you want to let users know something is wonky.

WDYT?
Sounds like . a better solution indeed.

I think the solution of Taylor is way more elegant!

Resolving patch:
diff --git a/nilearn/reporting/_get_clusters_table.py b/nilearn/reporting/_get_clusters_table.py
--- a/nilearn/reporting/_get_clusters_table.py
+++ b/nilearn/reporting/_get_clusters_table.py
@@ -47,19 +47,55 @@ def _local_max(data, affine, min_distance):
 
 
 def _identify_subpeaks(data):
+    """Identify cluster peak and subpeaks based on minimum distance.
+
+    Parameters
+    ----------
+    data : `numpy.ndarray`
+        3D array of with masked values for cluster.
+
+    Returns
+    -------
+    ijk : `numpy.ndarray`
+        (n_foci, 3) array of local maxima indices for cluster.
+    vals : `numpy.ndarray`
+        (n_foci,) array of values from data at ijk.
+
+    Notes
+    -----
+    When a cluster's local maxima correspond to contiguous voxels with the
+    same values (as in a binary cluster), this function determines the center
+    of mass for those voxels.
+    """
     # Initial identification of subpeaks with minimal minimum distance
     data_max = ndimage.filters.maximum_filter(data, 3)
-    maxima = (data == data_max)
+    maxima = data == data_max
     data_min = ndimage.filters.minimum_filter(data, 3)
-    diff = ((data_max - data_min) > 0)
+    diff = (data_max - data_min) > 0
     maxima[diff == 0] = 0
 
     labeled, n_subpeaks = ndimage.label(maxima)
     labels_index = range(1, n_subpeaks + 1)
     ijk = np.array(ndimage.center_of_mass(data, labeled, labels_index))
     ijk = np.round(ijk).astype(int)
-    vals = np.apply_along_axis(arr=ijk, axis=1, func1d=_get_val,
-                               input_arr=data)
+    vals = np.apply_along_axis(
+        arr=ijk, axis=1, func1d=_get_val, input_arr=data
+    )
+    # Determine if all subpeaks are within the cluster
+    # They may not be if the cluster is binary and has a shape where the COM is
+    # outside the cluster, like a donut.
+    cluster_idx = np.vstack(np.where(labeled)).T.tolist()
+    subpeaks_outside_cluster = [
+        i
+        for i, peak_idx in enumerate(ijk.tolist())
+        if peak_idx not in cluster_idx
+    ]
+    vals[subpeaks_outside_cluster] = np.nan
+    if subpeaks_outside_cluster:
+        warnings.warn(
+            "Attention: At least one of the (sub)peaks falls outside of the "
+            "cluster body."
+        )
     return ijk, vals
 
 


Test patch:
diff --git a/nilearn/reporting/tests/test_reporting.py b/nilearn/reporting/tests/test_reporting.py
--- a/nilearn/reporting/tests/test_reporting.py
+++ b/nilearn/reporting/tests/test_reporting.py
@@ -25,9 +25,10 @@
 @pytest.mark.skipif(not have_mpl,
                     reason='Matplotlib not installed; required for this test')
 def test_local_max():
+    """Basic test of nilearn.reporting._get_clusters_table._local_max()"""
     shape = (9, 10, 11)
-    data = np.zeros(shape)
     # Two maxima (one global, one local), 10 voxels apart.
+    data = np.zeros(shape)
     data[4, 5, :] = [4, 3, 2, 1, 1, 1, 1, 1, 2, 3, 4]
     data[5, 5, :] = [5, 4, 3, 2, 1, 1, 1, 2, 3, 4, 6]
     data[6, 5, :] = [4, 3, 2, 1, 1, 1, 1, 1, 2, 3, 4]
@@ -41,6 +42,32 @@ def test_local_max():
     assert np.array_equal(ijk, np.array([[5., 5., 10.]]))
     assert np.array_equal(vals, np.array([6]))
 
+    # Two global (equal) maxima, 10 voxels apart.
+    data = np.zeros(shape)
+    data[4, 5, :] = [4, 3, 2, 1, 1, 1, 1, 1, 2, 3, 4]
+    data[5, 5, :] = [5, 4, 3, 2, 1, 1, 1, 2, 3, 4, 5]
+    data[6, 5, :] = [4, 3, 2, 1, 1, 1, 1, 1, 2, 3, 4]
+    affine = np.eye(4)
+
+    ijk, vals = _local_max(data, affine, min_distance=9)
+    assert np.array_equal(ijk, np.array([[5., 5., 0.], [5., 5., 10.]]))
+    assert np.array_equal(vals, np.array([5, 5]))
+
+    ijk, vals = _local_max(data, affine, min_distance=11)
+    assert np.array_equal(ijk, np.array([[5., 5., 0.]]))
+    assert np.array_equal(vals, np.array([5]))
+
+    # A donut.
+    data = np.zeros(shape)
+    data[4, 5, :] = [0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0]
+    data[5, 5, :] = [0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0]
+    data[6, 5, :] = [0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0]
+    affine = np.eye(4)
+
+    ijk, vals = _local_max(data, affine, min_distance=9)
+    assert np.array_equal(ijk, np.array([[5., 5., 5.]]))
+    assert np.all(np.isnan(vals))
+
 
 def test_get_clusters_table():
     shape = (9, 10, 11)