Repository: nilearn/nilearn

Issue:
Make `cosine` regressors an exception for the variable missing error in `load_confounds`
              Right now the behaviour is the function will just fail, and a check is implemented in `_load_single_confounds_file`, so we don't need to write things in each function

https://github.com/nilearn/nilearn/blob/ec57e566455b3d929480fc2ce19e151e16673c6e/nilearn/interfaces/fmriprep/load_confounds.py#L315

The only variable that could be missing when fMRIPrep can run correctly is `cosineXX`, when the timeseries is too short. 

This deserves it's own issue

_Originally posted by @htwangtw in https://github.com/nilearn/nilearn/pull/3285#discussion_r1117252941_

This came up in a discussion with @tsalo


Discussion:

Resolving patch:
diff --git a/nilearn/interfaces/fmriprep/load_confounds_components.py b/nilearn/interfaces/fmriprep/load_confounds_components.py
--- a/nilearn/interfaces/fmriprep/load_confounds_components.py
+++ b/nilearn/interfaces/fmriprep/load_confounds_components.py
@@ -4,7 +4,12 @@
 
 from .load_confounds_compcor import _find_compcor
 from .load_confounds_scrub import _optimize_scrub
-from .load_confounds_utils import _add_suffix, _check_params, _find_confounds
+from .load_confounds_utils import (
+    MissingConfound,
+    _add_suffix,
+    _check_params,
+    _find_confounds,
+)
 
 
 def _load_motion(confounds_raw, motion):
@@ -13,35 +18,50 @@ def _load_motion(confounds_raw, motion):
         ["trans_x", "trans_y", "trans_z", "rot_x", "rot_y", "rot_z"],
         motion,
     )
-    _check_params(confounds_raw, motion_params)
-    return confounds_raw[motion_params]
+    motion_regressor_check = _check_params(confounds_raw, motion_params)
+    if isinstance(motion_regressor_check, list):
+        raise MissingConfound(params=motion_regressor_check)
+
+    if motion_regressor_check:
+        return confounds_raw[motion_params]
+    else:
+        raise MissingConfound(keywords=["motion"])
 
 
 def _load_high_pass(confounds_raw):
     """Load the high pass filter regressors."""
     high_pass_params = _find_confounds(confounds_raw, ["cosine"])
-    return confounds_raw[high_pass_params]
+    if high_pass_params:
+        return confounds_raw[high_pass_params]
+    else:
+        return pd.DataFrame()
 
 
 def _load_wm_csf(confounds_raw, wm_csf):
     """Load the regressors derived from the white matter and CSF masks."""
     wm_csf_params = _add_suffix(["csf", "white_matter"], wm_csf)
-    _check_params(confounds_raw, wm_csf_params)
-    return confounds_raw[wm_csf_params]
+    if _check_params(confounds_raw, wm_csf_params):
+        return confounds_raw[wm_csf_params]
+    else:
+        raise MissingConfound(keywords=["wm_csf"])
 
 
 def _load_global_signal(confounds_raw, global_signal):
     """Load the regressors derived from the global signal."""
     global_params = _add_suffix(["global_signal"], global_signal)
-    _check_params(confounds_raw, global_params)
-    return confounds_raw[global_params]
+    if _check_params(confounds_raw, global_params):
+        return confounds_raw[global_params]
+    else:
+        raise MissingConfound(keywords=["global_signal"])
 
 
 def _load_compcor(confounds_raw, meta_json, compcor, n_compcor):
     """Load compcor regressors."""
     compcor_cols = _find_compcor(meta_json, compcor, n_compcor)
-    _check_params(confounds_raw, compcor_cols)
-    return confounds_raw[compcor_cols]
+    if _check_params(confounds_raw, compcor_cols):
+        return confounds_raw[compcor_cols]
+    else:
+        raise MissingConfound(keywords=["compcor"])
 
 
 def _load_ica_aroma(confounds_raw, ica_aroma):
@@ -50,6 +70,8 @@ def _load_ica_aroma(confounds_raw, ica_aroma):
         return pd.DataFrame()
     elif ica_aroma == "basic":
         ica_aroma_params = _find_confounds(confounds_raw, ["aroma"])
+        if not ica_aroma_params:
+            raise MissingConfound(keywords=["ica_aroma"])
         return confounds_raw[ica_aroma_params]
     else:
         raise ValueError(
diff --git a/nilearn/interfaces/fmriprep/load_confounds_utils.py b/nilearn/interfaces/fmriprep/load_confounds_utils.py
--- a/nilearn/interfaces/fmriprep/load_confounds_utils.py
+++ b/nilearn/interfaces/fmriprep/load_confounds_utils.py
@@ -39,22 +39,21 @@ def _check_params(confounds_raw, params):
     not_found_params = [
         par for par in params if par not in confounds_raw.columns
     ]
-    if not_found_params:
-        raise MissingConfound(params=not_found_params)
-    return None
+    if len(not_found_params) == len(params):
+        return False
+    elif not_found_params:
+        return not_found_params
+    else:
+        return True
 
 
 def _find_confounds(confounds_raw, keywords):
     """Find confounds that contain certain keywords."""
-    list_confounds, missing_keys = [], []
+    list_confounds = []
     for key in keywords:
         key_found = [col for col in confounds_raw.columns if key in col]
         if key_found:
             list_confounds.extend(key_found)
-        elif key != "non_steady_state":
-            missing_keys.append(key)
-    if missing_keys:
-        raise MissingConfound(keywords=missing_keys)
     return list_confounds
 
 


Test patch:
diff --git a/nilearn/interfaces/fmriprep/tests/test_load_confounds.py b/nilearn/interfaces/fmriprep/tests/test_load_confounds.py
--- a/nilearn/interfaces/fmriprep/tests/test_load_confounds.py
+++ b/nilearn/interfaces/fmriprep/tests/test_load_confounds.py
@@ -335,21 +335,24 @@ def test_not_found_exception(tmp_path):
         tmp_path, copy_confounds=True, copy_json=False
     )
     missing_params = ["trans_y", "trans_x_derivative1", "rot_z_power2"]
-    missing_keywords = ["cosine"]
+    missing_keywords = ["cosine", "global_signal"]
 
     leagal_confounds = pd.read_csv(bad_conf, delimiter="\t", encoding="utf-8")
-    cosine = [
-        col_name
-        for col_name in leagal_confounds.columns
-        if "cosine" in col_name
-    ]
+    remove_columns = []
+    for missing_kw in missing_keywords:
+        remove_columns += [
+            col_name
+            for col_name in leagal_confounds.columns
+            if missing_kw in col_name
+        ]
+
     aroma = [
         col_name
         for col_name in leagal_confounds.columns
         if "aroma" in col_name
     ]
     missing_confounds = leagal_confounds.drop(
-        columns=missing_params + cosine + aroma
+        columns=missing_params + remove_columns + aroma
     )
     missing_confounds.to_csv(bad_conf, sep="\t", index=False)
 
@@ -361,7 +364,8 @@ def test_not_found_exception(tmp_path):
             motion="full",
         )
     assert f"{missing_params}" in exc_info.value.args[0]
-    assert f"{missing_keywords}" in exc_info.value.args[0]
+    # missing cosine if it's not present in the file it's fine
+    assert f"{missing_keywords[-1:]}" in exc_info.value.args[0]
 
     # loading anat compcor should also raise an error, because the json file is
     # missing for that example dataset
@@ -386,7 +390,7 @@ def test_not_found_exception(tmp_path):
         load_confounds(
             img_missing_confounds, strategy=("ica_aroma", ), ica_aroma="basic"
         )
-    assert "aroma" in exc_info.value.args[0]
+    assert "ica_aroma" in exc_info.value.args[0]
 
     # Aggressive ICA-AROMA strategy requires
     # default nifti
diff --git a/nilearn/interfaces/fmriprep/tests/test_load_confounds_components.py b/nilearn/interfaces/fmriprep/tests/test_load_confounds_components.py
new file mode 100644
--- /dev/null
+++ b/nilearn/interfaces/fmriprep/tests/test_load_confounds_components.py
@@ -0,0 +1,91 @@
+import pandas as pd
+import pytest
+
+from nilearn.interfaces.fmriprep import load_confounds
+from nilearn.interfaces.fmriprep.load_confounds import _load_noise_component
+from nilearn.interfaces.fmriprep.load_confounds_utils import (
+    _load_confounds_json,
+)
+from nilearn.interfaces.fmriprep.tests.utils import create_tmp_filepath
+
+
+@pytest.fixture
+def expected_parameters(strategy_keywords):
+    expectation = {
+        "compcor": {"compcor": "anat_combined", "n_compcor": 6},
+        "ica_aroma": {"ica_aroma": "basic"},
+    }
+    if strategy_keywords in expectation:
+        return expectation[strategy_keywords]
+    elif strategy_keywords != "high_pass":
+        return {strategy_keywords: "full"}
+    else:
+        return False
+
+
+@pytest.mark.parametrize(
+    "strategy_keywords",
+    ["motion", "wm_csf", "global_signal", "compcor", "ica_aroma", "high_pass"],
+)
+def test_missing_keywords(tmp_path, strategy_keywords, expected_parameters):
+    """
+    Check the strategy keywords are raising errors correctly in low and
+    high level functions with the exception of `high_pass`.
+    """
+    img, bad_conf = create_tmp_filepath(
+        tmp_path, copy_confounds=True, copy_json=True
+    )
+    legal_confounds = pd.read_csv(bad_conf, delimiter="\t", encoding="utf-8")
+    meta_json = bad_conf.parent / bad_conf.name.replace("tsv", "json")
+    meta_json = _load_confounds_json(meta_json, flag_acompcor=True)
+
+    # remove confound strategy keywords in the test data
+    if expected_parameters:
+        remove_columns, missing = _load_noise_component(
+            legal_confounds,
+            component=strategy_keywords,
+            missing={"confounds": [], "keywords": []},
+            meta_json=meta_json,
+            **expected_parameters,
+        )
+    else:
+        remove_columns, missing = _load_noise_component(
+            legal_confounds,
+            component=strategy_keywords,
+            missing={"confounds": [], "keywords": []},
+        )
+    confounds_raw = legal_confounds.drop(columns=remove_columns)
+    confounds_raw.to_csv(bad_conf, sep="\t", index=False)  # save in tmp_path
+
+    # unit test of module `load_confounds_components`
+    # and higher level wrapper `load_confounds`
+    # The two should behave the same
+    missing = {"confounds": [], "keywords": []}
+    if strategy_keywords == "high_pass":
+        # when no cosine regressors are present in the confound file,
+        # it will not raise error
+        confounds_unit_test, missing = _load_noise_component(
+            confounds_raw, component=strategy_keywords, missing=missing
+        )
+        assert confounds_unit_test.empty is True
+        assert len(missing["keywords"]) == 0
+
+        confounds_lc, _ = load_confounds(img, strategy=[strategy_keywords])
+        assert confounds_lc.empty is True
+
+    else:
+        confounds_unit_test, missing = _load_noise_component(
+            confounds_raw,
+            component=strategy_keywords,
+            missing=missing,
+            meta_json=meta_json,
+            **expected_parameters,
+        )
+        assert confounds_unit_test.empty is True
+        assert missing["keywords"] == [strategy_keywords]
+
+        with pytest.raises(ValueError) as exc_info:
+            load_confounds(
+                img, strategy=[strategy_keywords], **expected_parameters
+            )
+        assert strategy_keywords in exc_info.value.args[0]