Repository: nilearn/nilearn

Issue:
FirstLevelModel with custom response function fails with TypeError
<!--Provide a brief description of the bug.-->
#2942 added the possibility to use custom response functions when regressing signal from images. However, using a custom response function with `FirstLevelModel` fails.

<!--Please fill in the following information, to the best of your ability.-->
Nilearn version: 0.8.2.dev

### Expected behavior
The GLM should run without error.

### Actual behavior
It currently fails, returning the following stack-trace:

```
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/tmp/ipykernel_58955/2957114893.py in <module>
      3     events_primary_categories_filtered,
      4     subject_label=monkey_subject_name,
----> 5     rf_model=mion_rf,
      6 )

/tmp/ipykernel_58955/4150798340.py in fit_glm(functional_img, events, subject_label, rf_model)
     12     )
     13 
---> 14     fmri_glm.fit(functional_img, events)
     15 
     16     return fmri_glm

/storage/store2/work/athual/repo/nilearn/nilearn/glm/first_level/first_level.py in fit(self, run_imgs, events, confounds, design_matrices, bins)
    549                                                         confounds_matrix,
    550                                                         confounds_names,
--> 551                                                         self.min_onset
    552                                                         )
    553             else:

/storage/store2/work/athual/repo/nilearn/nilearn/glm/first_level/design_matrix.py in make_first_level_design_matrix(frame_times, events, hrf_model, drift_model, high_pass, drift_order, fir_delays, add_regs, add_reg_names, min_onset, oversampling)
    360         matrix, names = _convolve_regressors(
    361             events, hrf_model, frame_times, fir_delays, min_onset,
--> 362             oversampling)
    363 
    364     # step 2: additional regressors

/storage/store2/work/athual/repo/nilearn/nilearn/glm/first_level/design_matrix.py in _convolve_regressors(events, hrf_model, frame_times, fir_delays, min_onset, oversampling)
    237             min_onset=min_onset)
    238 
--> 239         regressor_names += names
    240         if regressor_matrix is None:
    241             regressor_matrix = reg

TypeError: 'NoneType' object is not iterable
```

### Steps and code to reproduce bug
I won't give a fully operational code snippet, but given that one already has functional images, experiment events and a custom `rf_model, the following code will raise the error, `:

```python
fmri_glm = FirstLevelModel(
    t_r=t_r,
    noise_model="ar1",
    standardize=False,
    hrf_model=rf_model,
)

fmri_glm.fit(functional_img, events)
```

### Fix
According to the stack-trace, in `glm/first_level/design_matrix.py`, instruction `regressor_names += names` fails to execute because `names` is `None`.
This variable is computed a few lines above through `compute_regressor()`, which itself calls `_regressor_names()`, which eventually returns a list of names depending on `hrf_model` given in argument. However, it does not explicitly handle the case where `hrf_model` is a custom function (or where `hrf_model` is not a predefined string for that matter), and hence returns nothing.

I think we could:
1. make sure that `_regressor_names()` return an emtpy list by default
2. in case we don't want 1., maybe we could have `_regressor_names()` return an empty list when `hrf_model` is a custom function (or a list of custom functions)
3. modify `compute_regressor()` such that it checks that `names` is iterable before calling the faulty line
4. generally add more tests using custom response functions

I will open a PR soon to fix these things.


Discussion:
The plan LGTM. I think this is simply a corner case that we have neglected.

Thanks for catching this and opening the issue @alexisthual ! :+1: 
I think `_regressor_names()` is the function we should change as it implicitly assumes that there are no possibility to pass custom HRF models and it doesn't have a default case which makes the issue not obvious at first.
I think we should add both:

- a case for custom HRF (use each ``custome_hrf.__name__`` maybe? with a duplicate check, or something simpler like ``[f"custome_hrf_{n}" for n, f in enumerate(hrf_model)]``) 
- a default case (either return an empty list, a list of empty strings, raise an error....)

Resolving patch:
diff --git a/nilearn/glm/first_level/hemodynamic_models.py b/nilearn/glm/first_level/hemodynamic_models.py
--- a/nilearn/glm/first_level/hemodynamic_models.py
+++ b/nilearn/glm/first_level/hemodynamic_models.py
@@ -376,17 +376,17 @@ def _orthogonalize(X):
     return X
 
 
+@fill_doc
 def _regressor_names(con_name, hrf_model, fir_delays=None):
     """ Returns a list of regressor names, computed from con-name and hrf type
+    when this information is explicitly given. If hrf_model is
+    a custom function or a list of custom functions, return their name.
 
     Parameters
     ----------
     con_name : string
         identifier of the condition
-
-    hrf_model : string or None,
-       hrf model chosen
-
+    %(hrf_model)s
     fir_delays : 1D array_like, optional
         Delays (in scans) used in case of an FIR model
 
@@ -396,15 +396,35 @@ def _regressor_names(con_name, hrf_model, fir_delays=None):
         regressor names
 
     """
-    if hrf_model in ['glover', 'spm', None]:
-        return [con_name]
+    # Default value
+    names = [con_name]
+
+    # Handle strings
+    if hrf_model in ['glover', 'spm']:
+        names = [con_name]
     elif hrf_model in ["glover + derivative", 'spm + derivative']:
-        return [con_name, con_name + "_derivative"]
+        names = [con_name, con_name + "_derivative"]
     elif hrf_model in ['spm + derivative + dispersion',
                        'glover + derivative + dispersion']:
-        return [con_name, con_name + "_derivative", con_name + "_dispersion"]
+        names = [con_name, con_name + "_derivative", con_name + "_dispersion"]
     elif hrf_model == 'fir':
-        return [con_name + "_delay_%d" % i for i in fir_delays]
+        names = [con_name + "_delay_%d" % i for i in fir_delays]
+    # Handle callables
+    elif callable(hrf_model):
+        names = [f"{con_name} {hrf_model.__name__}"]
+    elif (isinstance(hrf_model, Iterable)
+          and all([callable(_) for _ in hrf_model])):
+        names = [f"{con_name} {model.__name__}" for model in hrf_model]
+    # Handle some default cases
+    else:
+        if isinstance(hrf_model, Iterable) and not isinstance(hrf_model, str):
+            names = [f"{con_name} {i}" for i in range(len(hrf_model))]
+
+    # Check that all names within the list are different
+    if len(np.unique(names)) != len(names):
+        raise ValueError(f"Computed regressor names are not unique: {names}")
+
+    return names
 
 
 def _hrf_kernel(hrf_model, tr, oversampling=50, fir_delays=None):


Test patch:
diff --git a/nilearn/glm/tests/test_first_level.py b/nilearn/glm/tests/test_first_level.py
--- a/nilearn/glm/tests/test_first_level.py
+++ b/nilearn/glm/tests/test_first_level.py
@@ -825,3 +825,28 @@ def test_first_level_predictions_r_square():
 
     r_square_2d = model.masker_.transform(r_square_3d)
     assert_array_less(0., r_square_2d)
+
+
+@pytest.mark.parametrize("hrf_model", [
+    "spm",
+    "spm + derivative",
+    "glover",
+    lambda tr, ov: np.ones(int(tr * ov))
+])
+def test_first_level_hrf_model(hrf_model):
+    """
+    Test to ensure that FirstLevelModel runs flawlessly
+    with different values of hrf_model. In particular, one checks that it runs
+    without raising errors when given a custom response function.
+    """
+    shapes, rk = [(10, 10, 10, 25)], 3
+    mask, fmri_data, _ =\
+        generate_fake_fmri_data_and_design(shapes, rk)
+
+    events = basic_paradigm()
+
+    model = FirstLevelModel(t_r=2.0,
+                            mask_img=mask,
+                            hrf_model=hrf_model)
+
+    model.fit(fmri_data, events)
diff --git a/nilearn/glm/tests/test_hemodynamic_models.py b/nilearn/glm/tests/test_hemodynamic_models.py
--- a/nilearn/glm/tests/test_hemodynamic_models.py
+++ b/nilearn/glm/tests/test_hemodynamic_models.py
@@ -189,20 +189,45 @@ def test_names():
     """ Test the regressor naming function
     """
     name = 'con'
-    assert _regressor_names(name, 'spm') == ['con']
+    assert _regressor_names(name, 'spm') == [name]
     assert _regressor_names(
-        name, 'spm + derivative') == ['con', 'con_derivative']
+        name, 'spm + derivative') == [name, f'{name}_derivative']
     assert _regressor_names(
-        name, 'spm + derivative + dispersion') == ['con',
-                                                   'con_derivative',
-                                                   'con_dispersion']
-    assert _regressor_names(name, 'glover') == ['con']
+        name, 'spm + derivative + dispersion') == [name,
+                                                   f'{name}_derivative',
+                                                   f'{name}_dispersion']
+    assert _regressor_names(name, 'glover') == [name]
     assert _regressor_names(
-        name, 'glover + derivative') == ['con', 'con_derivative']
+        name, 'glover + derivative') == [name, f'{name}_derivative']
     assert _regressor_names(
-        name, 'glover + derivative + dispersion') == ['con',
-                                                      'con_derivative',
-                                                      'con_dispersion']
+        name, 'glover + derivative + dispersion') == [name,
+                                                      f'{name}_derivative',
+                                                      f'{name}_dispersion']
+
+    assert _regressor_names(name, None) == [name]
+    assert _regressor_names(name, [None, None]) == [f"{name} 0", f"{name} 1"]
+    assert _regressor_names(name, "typo") == [name]
+    assert _regressor_names(name, ["typo", "typo"]) == \
+        [f"{name} 0", f"{name} 1"]
+
+    def custom_rf(tr, ov):
+        return np.ones(int(tr * ov))
+
+    assert _regressor_names(name, custom_rf) == \
+        [f"{name} {custom_rf.__name__}"]
+    assert _regressor_names(name, [custom_rf]) == \
+        [f"{name} {custom_rf.__name__}"]
+    assert _regressor_names(name, lambda tr, ov: np.ones(int(tr * ov))) == \
+        [f"{name} <lambda>"]
+    assert _regressor_names(name, [lambda tr, ov: np.ones(int(tr * ov))]) == \
+        [f"{name} <lambda>"]
+
+    with pytest.raises(ValueError,
+                       match="Computed regressor names are not unique"):
+        _regressor_names(name, [
+            lambda tr, ov: np.ones(int(tr * ov)),
+            lambda tr, ov: np.ones(int(tr * ov))
+        ])
 
 
 def test_hkernel():