Repository: nilearn/nilearn

Issue:
[BUG]: missing values in `Decoder.cv_params_` when `param_grid` is a list of dicts with different keys
If `param_grid` is a list of dicts and the dicts have different keys, the fitted Decoder's `cv_params_` output can contain lists that have less elements than the number of CV folds. For example, if `param_grid` is
```python
param_grid = [
    {
        "penalty": ["l2"],
        "C": [100, 1000],
    },
    {
        "penalty": ["l1"],
        "dual": [False],    # "dual" is not in the first dict
        "C": [100, 1000],
    },
]
```

Then it is possible that `decoder.cv_params_["shoe"]` has something like
```python
{
    'C': [1000, 1000, 100, 100, 1000], 
    'dual': [False, False, False],    # less than 5, not aligned with the other hyperparameter values
    'penalty': ['l1', 'l2', 'l1', 'l1', 'l2'],
}
```

_Originally posted by @michellewang in https://github.com/nilearn/nilearn/pull/3626#discussion_r1172699079_
            


Discussion:
Proposed fix:
1. In [`nilearn.decoding.decoder._parallel_fit`](https://github.com/nilearn/nilearn/blob/main/nilearn/decoding/decoder.py#L163), before looping over the hyperparameters, collect all the unique keys in `param_grid`
2. Inside the loop, check if `best_param` is missing any keys, and add them if necessary

Resolving patch:
diff --git a/nilearn/decoding/decoder.py b/nilearn/decoding/decoder.py
--- a/nilearn/decoding/decoder.py
+++ b/nilearn/decoding/decoder.py
@@ -304,10 +304,18 @@ def _parallel_fit(
         X_test = selector.transform(X_test)
 
     # If there is no parameter grid, then we use a suitable grid (by default)
-    param_grid = _check_param_grid(estimator, X_train, y_train, param_grid)
+    param_grid = ParameterGrid(
+        _check_param_grid(estimator, X_train, y_train, param_grid)
+    )
+
+    # collect all parameter names from the grid
+    all_params = set()
+    for params in param_grid:
+        all_params.update(params.keys())
+
     best_score = None
-    for param in ParameterGrid(param_grid):
-        estimator = clone(estimator).set_params(**param)
+    for params in param_grid:
+        estimator = clone(estimator).set_params(**params)
         estimator.fit(X_train, y_train)
 
         if is_classification:
@@ -332,8 +340,13 @@ def _parallel_fit(
                     dummy_output = estimator.constant_
 
             if isinstance(estimator, (RidgeCV, RidgeClassifierCV)):
-                param["best_alpha"] = estimator.alpha_
-            best_param = param
+                params["best_alpha"] = estimator.alpha_
+            best_params = params
+
+            # fill in any missing param from param_grid
+            for param in all_params:
+                if param not in best_params:
+                    best_params[param] = getattr(estimator, param)
 
     if best_coef is not None:
         if do_screening:
@@ -346,7 +359,7 @@ def _parallel_fit(
         class_index,
         best_coef,
         best_intercept,
-        best_param,
+        best_params,
         best_score,
         dummy_output,
     )


Test patch:
diff --git a/nilearn/decoding/tests/test_decoder.py b/nilearn/decoding/tests/test_decoder.py
--- a/nilearn/decoding/tests/test_decoder.py
+++ b/nilearn/decoding/tests/test_decoder.py
@@ -441,6 +441,31 @@ def test_parallel_fit_builtin_cv(
     assert isinstance(best_param[fitted_param_name], (float, int))
 
 
+def test_decoder_param_grid_sequence(binary_classification_data):
+    X, y, _ = binary_classification_data
+    n_cv_folds = 10
+    param_grid = [
+        {
+            "penalty": ["l2"],
+            "C": [100, 1000],
+            "random_state": [42],  # fix the seed for consistent behaviour
+        },
+        {
+            "penalty": ["l1"],
+            "dual": [False],  # "dual" is not in the first dict
+            "C": [100, 10],
+            "random_state": [42],  # fix the seed for consistent behaviour
+        },
+    ]
+
+    model = Decoder(param_grid=param_grid, cv=n_cv_folds)
+    model.fit(X, y)
+
+    for best_params in model.cv_params_.values():
+        for param_list in best_params.values():
+            assert len(param_list) == n_cv_folds
+
+
 def test_decoder_binary_classification_with_masker_object(
     binary_classification_data,
 ):