Repository: nilearn/nilearn

Issue:
[FIX] Fix labels HarvardOxford fetcher
Fixes #2036 

For some atlases, `fetch_atlas_harvard_oxford` does not return the same number of labels as different integer values in the maps:

```python
from nilearn.datasets import fetch_atlas_harvard_oxford

# Do not include probabiltic atlases in the list
atlases = ["cort-maxprob-thr0-1mm", "cort-maxprob-thr0-2mm",
           "cort-maxprob-thr25-1mm", "cort-maxprob-thr25-2mm",
           "cort-maxprob-thr50-1mm", "cort-maxprob-thr50-2mm",
           "cortl-maxprob-thr0-1mm", "cortl-maxprob-thr0-2mm",
           "cortl-maxprob-thr25-1mm", "cortl-maxprob-thr25-2mm",
           "cortl-maxprob-thr50-1mm", "cortl-maxprob-thr50-2mm",
           "sub-maxprob-thr0-1mm", "sub-maxprob-thr0-2mm",
           "sub-maxprob-thr25-1mm", "sub-maxprob-thr25-2mm",
           "sub-maxprob-thr50-1mm", "sub-maxprob-thr50-2mm"]

for name in atlases:
    b = fetch_atlas_harvard_oxford(name, clean_labels=False)
    data_maps = get_data(b.maps)
    try:
        # Same number of labels in list and in image
        assert len(b.labels) == len(np.unique(data_maps))
        # Labels in image are consecutive integers from 0 to Nlabel - 1
        assert np.all(np.unique(data_maps) == np.arange(len(b.labels)))
        # Sets of labels are equal
        assert set(np.unique(data_maps)) == set(range(len(b.labels)))
    except:
        print(name)
        print(set(range(len(b.labels))) - set(np.unique(data_maps)))
```
```
cort-maxprob-thr50-1mm
{10}
cortl-maxprob-thr50-1mm
{19, 20, 93}
cortl-maxprob-thr50-2mm
{93, 94}
```

For now, this PR implements a simple fix which consists in removing labels which corresponding integer value does not appear in the image. For example, the 11th label of `cort-maxprob-thr50-1mm` ('Superior Temporal Gyrus, posterior division') is removed because 10 does not appear in the image:

```python
b = fetch_atlas_harvard_oxford("cort-maxprob-thr50-1mm", clean_labels=True)
data_maps = get_data(b.maps)
assert len(b.labels) == len(np.unique(data_maps))
assert "Superior Temporal Gyrus, posterior division" not in b.labels
```

However, the image will still have non consecutive integer values which means that the following is not verified:

```python
assert set(np.unique(data_maps)) == set(range(len(b.labels)))
```

If this is an issue, I think we will have to re-label the image. WDYT?


Discussion:
👋 @NicolasGensollen Thanks for creating a PR!

Until this PR is ready for review, you can include the [WIP] tag in its title, or leave it as a github draft.

Please make sure it is compliant with our [contributing guidelines](https://nilearn.github.io/development.html#contribution-guidelines). In particular, be sure it checks the boxes listed below.
- [x] PR has an interpretable title.
- [x] PR links to Github issue with mention "Closes #XXXX"
- [x] Code is PEP8-compliant.
- [x] (Bug fixes) There is at least one test that would fail under the original bug conditions.
- [ ] (New features) There is at least one unit test per new function / class.
- [ ] (New features) The new feature is demoed in at least one relevant example.

We will review it as quick as possible, feel free to ping us with questions if needed.
# [Codecov](https://codecov.io/gh/nilearn/nilearn/pull/3080?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) Report
> Merging [#3080](https://codecov.io/gh/nilearn/nilearn/pull/3080?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) (2c399b9) into [main](https://codecov.io/gh/nilearn/nilearn/commit/186c47c34c036c092a8d735baf25541d510078ec?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) (186c47c) will **decrease** coverage by `0.08%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/nilearn/nilearn/pull/3080/graphs/tree.svg?width=650&height=150&src=pr&token=KpYArSdyXv&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn)](https://codecov.io/gh/nilearn/nilearn/pull/3080?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn)

```diff
@@            Coverage Diff             @@
##             main    #3080      +/-   ##
==========================================
- Coverage   90.15%   90.06%   -0.09%     
==========================================
  Files         108      108              
  Lines       14517    14481      -36     
  Branches     2910     2911       +1     
==========================================
- Hits        13088    13043      -45     
- Misses        852      862      +10     
+ Partials      577      576       -1     
```


| [Impacted Files](https://codecov.io/gh/nilearn/nilearn/pull/3080?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn) | Coverage Δ | |
|---|---|---|
| [nilearn/datasets/atlas.py](https://codecov.io/gh/nilearn/nilearn/pull/3080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn#diff-bmlsZWFybi9kYXRhc2V0cy9hdGxhcy5weQ==) | `93.16% <100.00%> (+0.15%)` | :arrow_up: |
| [nilearn/\_utils/testing.py](https://codecov.io/gh/nilearn/nilearn/pull/3080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn#diff-bmlsZWFybi9fdXRpbHMvdGVzdGluZy5weQ==) | `67.34% <0.00%> (-9.19%)` | :arrow_down: |
| [nilearn/reporting/\_get\_clusters\_table.py](https://codecov.io/gh/nilearn/nilearn/pull/3080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn#diff-bmlsZWFybi9yZXBvcnRpbmcvX2dldF9jbHVzdGVyc190YWJsZS5weQ==) | `96.87% <0.00%> (-3.13%)` | :arrow_down: |
| [nilearn/datasets/func.py](https://codecov.io/gh/nilearn/nilearn/pull/3080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn#diff-bmlsZWFybi9kYXRhc2V0cy9mdW5jLnB5) | `76.94% <0.00%> (-1.30%)` | :arrow_down: |
| [nilearn/plotting/\_\_init\_\_.py](https://codecov.io/gh/nilearn/nilearn/pull/3080/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn#diff-bmlsZWFybi9wbG90dGluZy9fX2luaXRfXy5weQ==) | `93.33% <0.00%> (+7.61%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/nilearn/nilearn/pull/3080?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/nilearn/nilearn/pull/3080?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn). Last update [186c47c...2c399b9](https://codecov.io/gh/nilearn/nilearn/pull/3080?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nilearn).

> If this is an issue, I think we will have to re-label the image. WDYT?

I'd rather not. Imagine that you dot it on data from different resolution, then it becomes hard to figure out what is what.
Many atlases do not have consecutive labels, so I don't think it's a big issue.
Best,
but the labels are a sequence, and values in the image are supposed to correspond to indices in that sequence, no? so we cannot just delete entries. of course everything would be easier if labels were provided as an {int value: str label name} mapping
Sorry, I didn't check, but shouldn't we handle the labels as a dictionary instead ? You mean that this would require us to do some more work on this atlas ?
> Sorry, I didn't check, but shouldn't we handle the labels as a dictionary instead ?
> You mean that this would require us to do some more work on this atlas ?

I mean that at the moment, in all atlases, labels are provided as a list
of label names, and integers in the map correspond to indices in this
list so we cannot change this list without changing the image values
(btw the documentation is quite ambiguous):

```python
from nilearn.datasets import fetch_atlas_harvard_oxford

ho = fetch_atlas_harvard_oxford("cortl-maxprob-thr0-2mm")
print(type(ho.labels))
print(ho.labels[:4])
```

prints
```
<class 'list'>
['Background', 'Left Frontal Pole', 'Right Frontal Pole', 'Left Insular Cortex']
```

and
```python
view_img(
    image.new_img_like(ho["maps"], image.get_data(ho["maps"]) == 3),
    title=ho.labels[3],
).open_in_browser()
```
shows the left insular cortex.

> but shouldn't we handle the labels as a dictionary instead ? You mean that this would require us to do some more work on this atlas ?

I completely agree it would be nicer if the labels were a dictionary
instead. However that would break backwards compatibility so we have to
keep the list.

What we can do is add new attributes like `region_id_to_label_name` and
`label_name_to_region_id` (or shorter names if we can find some),
containing the explicit mappings from values in the map to string label
names and vice-versa


> @jeromedockes Yes this is true. The documentation doesn't say clearly
> that integers from the image are supposed to be interpreted as indices
> in the list of labels though. Unless I am missing something, this is
> not the case for all atlases, right?

Which ones? and then how do you find the label corresponding to a value
in the image?

> Some have values that are not even consecutive as @bthirion mentioned earlier.

that could be the case while still having image values correspond to
indices in a label list simply if there empty regions


For instance, with aal, indices go from 2001 to 9170 and are non-consecutive. Do you mean that this is an exception and that we should rather see indices as sequences ? 
> For instance, with aal, indices go from 2001 to 9170 and are non-consecutive. Do you mean that this is an exception and that we should rather see indices as sequences ? 

yes `fetch_atlas_aal` seems to be an exception, and its return value has
an (undocumented) `indices` attribute that is a list of numbers of the
same length as `labels`, giving the numerical (image) values
corresponding to each string label name in `labels`.

Other atlas fetchers I have checked don't have this `indices` attribute,
and it seems that the first item of `labels` (usually "background")
corresponds to voxels with value 0 in the image, the second item of
`labels` corresponds to voxels with value 1 in the image, and so on. --
although this isn't documented clearly.

for probabilistic atlases the first item of labels corresponds to the
first image, the second label to the second image, etc. if we consider
the 4d image as a list of 3d maps

BTW I noticed `fetch_atlas_aal` isn't used in any example so it seems to
be a very good candidate for deprecation

I'll take a closer look tomorrow but it looks like this PR should be closed as for #2036. I might also have to revert the recently merged #3070 which targeted the same issue, but with `fetch_atlas_destrieux_2009`. 
I think we should improve the documentation w.r.t the labels indexing as it seems to be a source of confusion.
> I'll take a closer look tomorrow but it looks like this PR should be closed as for #2036. I might also have to revert the recently merged #3070 which targeted the same issue, but with `fetch_atlas_destrieux_2009`. 

It seems that way, so sorry for missing #3070!

> I think we should improve the documentation w.r.t the labels indexing as it seems to be a source of confusion.

yes I completely agree, and maybe add dictionaries which will be more
helpful for users and keep the lists of labels for backwards
compatibility only

> as for #2036.

indeed most problems reported in #2036 seem to be due to the fact that empty regions cause confusion, and the interface and documentation should be improved. 

but https://github.com/nilearn/nilearn/issues/2036#issuecomment-975007816 points to a bug in `generate_report`, so we may want to open a separate issue about that
>It seems that way, so sorry for missing #3070!

No worries! I merged it too fast without requesting enough reviews. :sweat: 
I'll open a new PR to undo #3070 and will then close this PR as well as #2036 since you opened a more appropriate issue for reports generation.
> I'll open a new PR to undo #3070 and will then close this PR as well as #2036 since you opened a more appropriate issue for reports generation.

sounds good, we can discuss all these things during the office hours today!


Resolving patch:
diff --git a/nilearn/datasets/atlas.py b/nilearn/datasets/atlas.py
--- a/nilearn/datasets/atlas.py
+++ b/nilearn/datasets/atlas.py
@@ -181,23 +181,9 @@ def fetch_atlas_craddock_2012(data_dir=None, url=None, resume=True, verbose=1):
     return Bunch(**params)
 
 
-def _clean_labels(image, labels):
-    """Helper function removing labels that do not appear in the image."""
-    labels_in_img = set(np.unique(get_data(image)))
-    labels_set = set([label.index for label in labels])
-    diff = labels_set.difference(labels_in_img)
-    if len(diff) == 0:
-        return labels
-    cleaned_labels = np.array(
-        [label for label in labels if label.index not in diff],
-        dtype=[('index', '<i8'), ('name', 'S27')]
-    )
-    return cleaned_labels.view(np.recarray)
-
-
 @fill_doc
 def fetch_atlas_destrieux_2009(lateralized=True, data_dir=None, url=None,
-                               resume=True, verbose=1, clean_labels=None):
+                               resume=True, verbose=1):
     """Download and load the Destrieux cortical atlas (dated 2009).
 
     See :footcite:`Fischl2004Automatically`,
@@ -206,9 +192,8 @@ def fetch_atlas_destrieux_2009(lateralized=True, data_dir=None, url=None,
     .. note::
 
         Some labels from the list of labels might not be present in the
-        atlas image, which can be an issue in some specific cases. In order
-        to clean the list of labels and keep only the ones present in the
-        image, use ``clean_labels=True``.
+        atlas image, in which case the integer values in the image might
+        not be consecutive.
 
     Parameters
     ----------
@@ -219,12 +204,6 @@ def fetch_atlas_destrieux_2009(lateralized=True, data_dir=None, url=None,
     %(url)s
     %(resume)s
     %(verbose)s
-    clean_labels : :obj:`bool`, optional
-        If set to ``True``, labels which do not appear in the image will
-        be removed from the list of labels.
-        Default=False.
-
-        .. versionadded:: 0.8.2
 
     Returns
     -------
@@ -239,11 +218,6 @@ def fetch_atlas_destrieux_2009(lateralized=True, data_dir=None, url=None,
     .. footbibliography::
 
     """
-    if clean_labels is None:
-        warnings.warn("Default value for parameter `clean_labels` will "
-                      "change from ``False`` to ``True`` in Nilearn 0.10.",
-                      category=FutureWarning)
-        clean_labels = False
     if url is None:
         url = "https://www.nitrc.org/frs/download.php/11942/"
 
@@ -268,8 +242,6 @@ def fetch_atlas_destrieux_2009(lateralized=True, data_dir=None, url=None,
     with open(files_[2], 'r') as rst_file:
         params['description'] = rst_file.read()
 
-    if clean_labels:
-        params['labels'] = _clean_labels(params['maps'], params['labels'])
     return Bunch(**params)
 
 


Test patch:
diff --git a/nilearn/datasets/tests/test_atlas.py b/nilearn/datasets/tests/test_atlas.py
--- a/nilearn/datasets/tests/test_atlas.py
+++ b/nilearn/datasets/tests/test_atlas.py
@@ -321,15 +321,12 @@ def test_fetch_coords_seitzman_2018(request_mocker):
     assert np.any(bunch.networks != np.sort(bunch.networks))
 
 
-def _destrieux_data(n_labels=10):
-    """Function mocking the download of the destrieux atlas.
-    Parameter `n_labels` controls the number of labels in the
-    CSV file. The number of labels in the image is 10.
-    """
+def _destrieux_data():
+    """Function mocking the download of the destrieux atlas."""
     data = {"destrieux2009.rst": "readme"}
     atlas = np.random.randint(0, 10, (10, 10, 10))
     atlas_img = nibabel.Nifti1Image(atlas, np.eye(4))
-    labels = "\n".join([f"{idx},label {idx}" for idx in range(n_labels)])
+    labels = "\n".join([f"{idx},label {idx}" for idx in range(10)])
     labels = "index,name\n" + labels
     for lat in ["_lateralized", ""]:
         lat_data = {
@@ -340,22 +337,14 @@ def _destrieux_data(n_labels=10):
     return dict_to_archive(data)
 
 
-@pytest.mark.parametrize("n_labels", [10, 11])
 @pytest.mark.parametrize("lateralized", [True, False])
-@pytest.mark.parametrize("clean_labels", [True, False])
-def test_fetch_atlas_destrieux_2009(tmp_path, request_mocker, n_labels,
-                                    lateralized, clean_labels):
+def test_fetch_atlas_destrieux_2009(tmp_path, request_mocker, lateralized):
     """Tests for function `fetch_atlas_destrieux_2009`.
-    The atlas is fetched with different combinations of `lateralized`
-    and `n_labels`. In the case `n_labels=11`, the fetcher should be able
-    to remove the extra label ('10') not present in the atlas image.
+    The atlas is fetched with different values for `lateralized`.
     """
-    request_mocker.url_mapping["*destrieux2009.tgz"] = _destrieux_data(
-        n_labels=n_labels
-    )
+    request_mocker.url_mapping["*destrieux2009.tgz"] = _destrieux_data()
     bunch = atlas.fetch_atlas_destrieux_2009(
-        lateralized=lateralized, data_dir=tmp_path,
-        verbose=0, clean_labels=clean_labels
+        lateralized=lateralized, data_dir=tmp_path, verbose=0
     )
     assert request_mocker.url_count == 1
     name = '_lateralized' if lateralized else ""
@@ -364,23 +353,7 @@ def test_fetch_atlas_destrieux_2009(tmp_path, request_mocker, n_labels,
     )
     labels_img = set(np.unique(get_data(bunch.maps)))
     labels = set([label.index for label in bunch.labels])
-    if clean_labels:
-        assert labels_img == labels
-    else:
-        assert labels_img.issubset(labels)
-
-
-def test_fetch_atlas_destrieux_2009_warning_clean_labels(request_mocker):
-    """Tests that ``fetch_atlas_destrieux_2009`` gives a FutureWarning
-    when ``clean_labels`` isn't specified explicitly.
-    """
-    request_mocker.url_mapping["*destrieux2009.tgz"] = _destrieux_data()
-    with pytest.warns(FutureWarning,
-                      match=("Default value for parameter `clean_labels` "
-                             "will change from ``False`` to ``True`` "
-                             "in Nilearn 0.10.")):
-        atlas.fetch_atlas_destrieux_2009()
-    assert request_mocker.url_count == 1
+    assert labels_img.issubset(labels)
 
 
 def test_fetch_atlas_msdl(tmp_path, request_mocker):