Repository: nilearn/nilearn

Issue:
compute_contrast in FLM fails when used on multiple runs with different shapes of design matrices 
<!--Provide a brief description of the bug.-->

Context : 
I am trying to obtain activation maps for a contrast between two conditions. I have several runs that I want to input in a FLM. For each run, I have a confounds file generated by [fMRIPrep](https://fmriprep.org/en/stable/). These files include the cosines for drift compensation. Because the length of each run varies a little bit, the number of cosines generated in the confounds files varies too. I first load the confounds file with [load_confounds](https://pypi.org/project/load-confounds/), then I generate the design matrices with [make_first_level_design_matrix](https://nilearn.github.io/modules/generated/nilearn.glm.first_level.make_first_level_design_matrix.html), by passing the confounds in add_regs.

Problem : 
When I use runs with different number of cosines from the confounds files (so, design matrices of the different runs have a different number of columns) the call to compute_contrast fails. It does not fail when I use runs with identical number of cosines.
I've found a [similar problem](https://github.com/nilearn/nilearn/issues/2394) in the issues, but since in my case the two conditions of my contrast are present in all runs, I thought that it might be a related but different issue. 

Or maybe, as some responses to the other issue seemed to suggest, this is a feature and not a bug ? If that's the case, how should I approach this question instead ?

Thanks in advance for your help !

<!--Please fill in the following information, to the best of your ability.-->
Nilearn version:
0.7.0

### Expected behavior
compute_contrast should (?) be able to handle a list of design matrices with varying number of columns.

### Actual behavior
When two design matrices with a different number of columns are passed to compute_contrast, the function fails and returns the following error : 

`
~/anaconda3/envs/hyruuk_shinobi_behav/lib/python3.8/site-packages/nilearn/glm/contrasts.py in compute_contrast(labels, regression_result, con_val, contrast_type)
     92         for label_ in regression_result:
     93             label_mask = labels == label_
---> 94             resl = regression_result[label_].Tcontrast(con_val)
     95             effect_[:, label_mask] = resl.effect.T
     96             var_[label_mask] = (resl.sd ** 2).T

~/anaconda3/envs/hyruuk_shinobi_behav/lib/python3.8/site-packages/nilearn/glm/model.py in Tcontrast(self, matrix, store, dispersion)
    198             raise ValueError("t contrasts should have only one row")
    199         if matrix.shape[1] != self.theta.shape[0]:
--> 200             raise ValueError("t contrasts should be length P=%d, "
    201                              "but this is length %d" % (self.theta.shape[0],
    202                                                         matrix.shape[1]))

ValueError: t contrasts should be length P=49, but this is length 47`

### Steps and code to reproduce bug

I joined an pdf file that shows my code and the outputs. First cell are imports. Second cell shows expected behavior, with two runs that share the same number of confounds. Third cell shows the bug, the code fails to generate the contrast because the two runs have different number of confounds (44 and 46). Fourth cell shows the same runs as in the second cell (the one that works), but I manually removed one of the cosines (to make sure that the number of columns of the design_matrix would differ), and that recreates the issue.

[bug_flm_confounds.pdf](https://github.com/nilearn/nilearn/files/5936005/bug_flm_confounds.pdf)

```python

```



Discussion:
I was able to reproduce the error on the same data. ;) 
With debugging, I was able to understand that calling `model.compute_contrasts(["contrast_str"]*n_runs)` would work but not `model.compute_contrasts("contrast_str")` due to the way contrast string are converted and multiple runs are handled here https://github.com/nilearn/nilearn/blob/e372946bfc91419bc1dbbfadebfed6e6a402956d/nilearn/glm/first_level/first_level.py#L572-L583 

However, when using `model.generate_report(contrasts=['contrast_str']*n_runs)` , the contrasts list are first converted to a dict of `{contrast_val:contrast_val}` here https://github.com/nilearn/nilearn/blob/e372946bfc91419bc1dbbfadebfed6e6a402956d/nilearn/reporting/glm_reporter.py#L196 and thus repetitions of contrasts are eliminated and the above hack cannot work.  


The simpler way would be to modify compute_contrasts by inverting the order of operation with first
https://github.com/nilearn/nilearn/blob/e372946bfc91419bc1dbbfadebfed6e6a402956d/nilearn/glm/first_level/first_level.py#L578-L583
 then https://github.com/nilearn/nilearn/blob/e372946bfc91419bc1dbbfadebfed6e6a402956d/nilearn/glm/first_level/first_level.py#L572-L578 

Unless I am not thinking about any caveat of doing it in that order (the only one that I can think of is performance of calling multiple time `expression_to_contrast_vector()` with the same parameters when runs have the same number of regressors).

Resolving patch:
diff --git a/nilearn/glm/first_level/first_level.py b/nilearn/glm/first_level/first_level.py
--- a/nilearn/glm/first_level/first_level.py
+++ b/nilearn/glm/first_level/first_level.py
@@ -332,7 +332,7 @@ def __init__(self, t_r=None, slice_time_ref=0., hrf_model='glover',
     def fit(self, run_imgs, events=None, confounds=None,
             design_matrices=None):
         """Fit the GLM
-        
+
         For each run:
         1. create design matrix X
         2. do a masker job: fMRI_data -> Y
@@ -568,6 +568,15 @@ def compute_contrast(self, contrast_def, stat_type=None,
             raise ValueError('contrast_def must be an array or str or list of'
                              ' (array or str)')
 
+        n_runs = len(self.labels_)
+        n_contrasts = len(con_vals)
+        if n_contrasts == 1 and n_runs > 1:
+            warn('One contrast given, assuming it for all %d runs' % n_runs)
+            con_vals = con_vals * n_runs
+        elif n_contrasts != n_runs:
+            raise ValueError('%n contrasts given, while there are %n runs' %
+                             (n_contrasts, n_runs))
+
         # Translate formulas to vectors
         for cidx, (con, design_mat) in enumerate(zip(con_vals,
                                                      self.design_matrices_)
@@ -577,11 +586,6 @@ def compute_contrast(self, contrast_def, stat_type=None,
                 con_vals[cidx] = expression_to_contrast_vector(
                     con, design_columns)
 
-        n_runs = len(self.labels_)
-        if len(con_vals) != n_runs:
-            warn('One contrast given, assuming it for all %d runs' % n_runs)
-            con_vals = con_vals * n_runs
-
         valid_types = ['z_score', 'stat', 'p_value', 'effect_size',
                        'effect_variance']
         valid_types.append('all')  # ensuring 'all' is the final entry.


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
@@ -7,6 +7,7 @@
 import numpy as np
 import pandas as pd
 import pytest
+import warnings
 from nibabel import Nifti1Image, load
 from nibabel.tmpdirs import InTemporaryDirectory
 from numpy.testing import (assert_almost_equal, assert_array_almost_equal,
@@ -216,6 +217,45 @@ def test_high_level_glm_different_design_matrices():
                         2 * get_data(z_joint))
 
 
+def test_high_level_glm_different_design_matrices_formulas():
+    # test that one can estimate a contrast when design matrices are different
+    shapes, rk = ((7, 8, 7, 15), (7, 8, 7, 19)), 3
+    mask, fmri_data, design_matrices = generate_fake_fmri_data_and_design(shapes, rk)
+
+    # make column names identical
+    design_matrices[1].columns = design_matrices[0].columns
+    # add a column to the second design matrix
+    design_matrices[1]['new'] = np.ones((19, 1))
+
+    # Fit a glm with two sessions and design matrices
+    multi_session_model = FirstLevelModel(mask_img=mask).fit(
+        fmri_data, design_matrices=design_matrices)
+
+    # Compute contrast with formulas
+    cols_formula = tuple(design_matrices[0].columns[:2])
+    formula = "%s-%s" % cols_formula
+    with pytest.warns(UserWarning, match='One contrast given, assuming it for all 2 runs'):
+        z_joint_formula = multi_session_model.compute_contrast(
+            formula, output_type='effect_size')
+
+
+def test_compute_contrast_num_contrasts():
+
+    shapes, rk = ((7, 8, 7, 15), (7, 8, 7, 19), (7, 8, 7, 13)), 3
+    mask, fmri_data, design_matrices = generate_fake_fmri_data_and_design(shapes, rk)
+
+    # Fit a glm with 3 sessions and design matrices
+    multi_session_model = FirstLevelModel(mask_img=mask).fit(
+        fmri_data, design_matrices=design_matrices)
+
+    # raise when n_contrast != n_runs | 1
+    with pytest.raises(ValueError):
+        multi_session_model.compute_contrast([np.eye(rk)[1]]*2)
+
+    multi_session_model.compute_contrast([np.eye(rk)[1]]*3)
+    with pytest.warns(UserWarning, match='One contrast given, assuming it for all 3 runs'):
+        multi_session_model.compute_contrast([np.eye(rk)[1]])
+
 def test_run_glm():
     rng = np.random.RandomState(42)
     n, p, q = 100, 80, 10
@@ -280,7 +320,7 @@ def test_fmri_inputs():
                 # test with confounds as numpy array
                 FirstLevelModel(mask_img=mask).fit([fi], design_matrices=[d],
                                                    confounds=conf.values)
-                
+
                 FirstLevelModel(mask_img=mask).fit([fi, fi],
                                                    design_matrices=[d, d])
                 FirstLevelModel(mask_img=None).fit((fi, fi),