Repository: nilearn/nilearn

Issue:
Using custom response functions for computing GLM leads to SyntaxError when computing contrasts
<!--Provide a brief description of the bug.-->
The bugfix introduced in #3018 derives names of the `design_matrices_` attribute of `FirstLevelModel`. However, computed names contain spaces, which later on raises errors.
In particular, when trying to compute contrasts (or generate reports) using the `"condition_1-condition_2"` string syntax, `pandas` fails to evaluate it and raises a `SyntaxError`.

<!--Please fill in the following information, to the best of your ability.-->
Nilearn version: 0.8.2.dev

### Expected behavior
It should work without error.

### Actual behavior
It fails with a `SyntaxError`:

```
/storage/store2/work/athual/repo/nilearn/nilearn/plotting/matrix_plotting.py:296: UserWarning: Tight layout not applied. The bottom and top margins cannot be made large enough to accommodate all axes decorations. 
  plt.tight_layout()
/storage/store2/work/athual/repo/nilearn/nilearn/reporting/utils.py:9: UserWarning: Tight layout not applied. The bottom and top margins cannot be made large enough to accommodate all axes decorations. 
  io_buffer, format="svg", facecolor="white", edgecolor="white"
Traceback (most recent call last):

  File "/storage/store2/work/athual/anaconda3/envs/singbrain/lib/python3.7/site-packages/IPython/core/interactiveshell.py", line 3444, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)

  File "/tmp/ipykernel_44279/1486762308.py", line 10, in <module>
    bg_img=anatomical_img,

  File "/storage/store2/work/athual/repo/nilearn/nilearn/glm/_base.py", line 117, in generate_report
    report_dims=report_dims)

  File "/storage/store2/work/athual/repo/nilearn/nilearn/reporting/glm_reporter.py", line 202, in make_glm_report
    contrast_plots = _plot_contrasts(contrasts, design_matrices)

  File "/storage/store2/work/athual/repo/nilearn/nilearn/reporting/glm_reporter.py", line 365, in _plot_contrasts
    colorbar=True)

  File "/storage/store2/work/athual/repo/nilearn/nilearn/plotting/matrix_plotting.py", line 273, in plot_contrast_matrix
    contrast_def, design_column_names)

  File "/storage/store2/work/athual/repo/nilearn/nilearn/glm/contrasts.py", line 43, in expression_to_contrast_vector
    contrast_vector = df.eval(expression, engine="python").values

  File "/storage/store2/work/athual/anaconda3/envs/singbrain/lib/python3.7/site-packages/pandas/core/frame.py", line 4191, in eval
    return _eval(expr, inplace=inplace, **kwargs)

  File "/storage/store2/work/athual/anaconda3/envs/singbrain/lib/python3.7/site-packages/pandas/core/computation/eval.py", line 348, in eval
    parsed_expr = Expr(expr, engine=engine, parser=parser, env=env)

  File "/storage/store2/work/athual/anaconda3/envs/singbrain/lib/python3.7/site-packages/pandas/core/computation/expr.py", line 806, in __init__
    self.terms = self.parse()

  File "/storage/store2/work/athual/anaconda3/envs/singbrain/lib/python3.7/site-packages/pandas/core/computation/expr.py", line 825, in parse
    return self._visitor.visit(self.expr)

  File "/storage/store2/work/athual/anaconda3/envs/singbrain/lib/python3.7/site-packages/pandas/core/computation/expr.py", line 407, in visit
    raise e

  File "/storage/store2/work/athual/anaconda3/envs/singbrain/lib/python3.7/site-packages/pandas/core/computation/expr.py", line 403, in visit
    node = ast.fix_missing_locations(ast.parse(clean))

  File "/storage/store2/work/athual/anaconda3/envs/singbrain/lib/python3.7/ast.py", line 35, in parse
    return compile(source, filename, mode, PyCF_ONLY_AST)

  File "<unknown>", line 1
    audio mion_rf -control_visual_audio mion_rf
                ^
SyntaxError: invalid syntax
```

### 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)

fmri_glm.generate_report(["condition_1 hrf_model-condition_2 hrf_model"])
```

### Fix

A quick fix would be to simple replace spaces with underscores. I'll write a PR for that.


Discussion:

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
@@ -6,6 +6,7 @@
 Author: Bertrand Thirion, 2011--2018
 """
 
+import re
 import warnings
 
 import numpy as np
@@ -411,19 +412,29 @@ def _regressor_names(con_name, hrf_model, fir_delays=None):
         names = [con_name + "_delay_%d" % i for i in fir_delays]
     # Handle callables
     elif callable(hrf_model):
-        names = [f"{con_name} {hrf_model.__name__}"]
+        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]
+        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))]
+            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}")
 
+    # Replace spaces with underscores
+    names = [re.sub(" +", "_", str(name)) for name in names]
+    # Remove any non-word character
+    names = [re.sub("[^a-zA-Z0-9_]+", "", str(name)) for name in names]
+
+    # Check that all names look like proper pandas.DataFrame column names
+    if not all([name.isidentifier() for name in names]):
+        raise ValueError("At least one regressor name can't be used "
+                         f"as a column identifier: {names}")
+
     return names
 
 


Test patch:
diff --git a/nilearn/glm/tests/test_dmtx.py b/nilearn/glm/tests/test_dmtx.py
--- a/nilearn/glm/tests/test_dmtx.py
+++ b/nilearn/glm/tests/test_dmtx.py
@@ -256,7 +256,12 @@ def test_design_matrix6():
 
 
 def test_design_matrix7():
-    # idem test_design_matrix1, but odd experimental paradigm
+    """
+    idem test_design_matrix1, but odd experimental paradigm;
+    code should raise an exception as condition names are not valid
+    pandas.DataFrame column names (and hence the computed design matrix
+    won't be able to call pd.eval when computing contrasts for instance).
+    """
     tr = 1.0
     frame_times = np.linspace(0, 127 * tr, 128)
     conditions = [0, 0, 0, 1, 1, 1, 3, 3, 3]
@@ -267,9 +272,13 @@ def test_design_matrix7():
                            'onset': onsets,
                            'duration': durations})
     hrf_model = 'glover'
-    X, names = design_matrix_light(frame_times, events, hrf_model=hrf_model,
-                                   drift_model='polynomial', drift_order=3)
-    assert len(names) == 7
+
+    with pytest.raises(
+        ValueError,
+        match="At least one regressor name can't be used"
+    ):
+        design_matrix_light(frame_times, events, hrf_model=hrf_model,
+                            drift_model='polynomial', drift_order=3)
 
 
 def test_design_matrix8():
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
@@ -470,8 +470,12 @@ def test_fmri_inputs():
         del fi, func_img, mask, d, des, FUNCFILE, _
 
 
-def basic_paradigm():
-    conditions = ['c0', 'c0', 'c0', 'c1', 'c1', 'c1', 'c2', 'c2', 'c2']
+def basic_paradigm(condition_names_have_spaces=False):
+    if condition_names_have_spaces:
+        conditions = ['c 0', 'c 0', 'c 0', 'c 1', 'c 1', 'c 1',
+                      'c 2', 'c 2', 'c 2']
+    else:
+        conditions = ['c0', 'c0', 'c0', 'c1', 'c1', 'c1', 'c2', 'c2', 'c2']
     onsets = [30, 70, 100, 10, 30, 90, 30, 40, 60]
     durations = 1 * np.ones(9)
     events = pd.DataFrame({'trial_type': conditions,
@@ -833,20 +837,29 @@ def test_first_level_predictions_r_square():
     "glover",
     lambda tr, ov: np.ones(int(tr * ov))
 ])
-def test_first_level_hrf_model(hrf_model):
+@pytest.mark.parametrize("spaces", [
+    False,
+    True
+])
+def test_first_level_hrf_model(hrf_model, spaces):
     """
-    Test to ensure that FirstLevelModel runs flawlessly
-    with different values of hrf_model. In particular, one checks that it runs
+    Ensure that FirstLevelModel runs without raising errors
+    for different values of hrf_model. In particular, one checks that it runs
     without raising errors when given a custom response function.
+    Also ensure that it computes contrasts without raising errors,
+    even when event (ie condition) names have spaces.
     """
     shapes, rk = [(10, 10, 10, 25)], 3
     mask, fmri_data, _ =\
         generate_fake_fmri_data_and_design(shapes, rk)
 
-    events = basic_paradigm()
+    events = basic_paradigm(condition_names_have_spaces=spaces)
 
     model = FirstLevelModel(t_r=2.0,
                             mask_img=mask,
                             hrf_model=hrf_model)
 
     model.fit(fmri_data, events)
+
+    columns = model.design_matrices_[0].columns
+    model.compute_contrast(f"{columns[0]}-{columns[1]}")
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
@@ -205,22 +205,22 @@ def test_names():
                                                       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, [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"]
+        [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__}"]
+        [f"{name}_{custom_rf.__name__}"]
     assert _regressor_names(name, [custom_rf]) == \
-        [f"{name} {custom_rf.__name__}"]
+        [f"{name}_{custom_rf.__name__}"]
     assert _regressor_names(name, lambda tr, ov: np.ones(int(tr * ov))) == \
-        [f"{name} <lambda>"]
+        [f"{name}_lambda"]
     assert _regressor_names(name, [lambda tr, ov: np.ones(int(tr * ov))]) == \
-        [f"{name} <lambda>"]
+        [f"{name}_lambda"]
 
     with pytest.raises(ValueError,
                        match="Computed regressor names are not unique"):