Add a progress bar to the randomized and grid search

### Describe the workflow you want to enable

When working on a large hyper-parameter set, setting the verbosity of `{Randomized, Grid}SearchCV` doesn't make the CV more informative. The display should help users estimate their waiting time and take a look at their scores. This issue is particularly salient in realistic searches that can take e.g. several hours to complete.
cc @glemaitre @jeromedockes @GaelVaroquaux

### Describe your proposed solution

Using joblib `return_as = 'generator'` instead of the default `'list'` in `BaseSearchCV.fit`, we could easily update a progress bar.

https://github.com/scikit-learn/scikit-learn/blob/main/sklearn/model_selection/_search.py
```diff   
+ def progress_bar(
+     idx,
+     total,
+    score_name,
+    best_score,
+    best_parameters,
+    bar_length=30,
+):
+    fraction = idx / total
+    filled_length = int(round(bar_length * fraction))
+
+    # Construct the bar with an orange filled part and a default unfilled part
+    orange = "\033[38;5;208m"  # ANSI code for orange
+    reset = "\033[0m"  # Reset color
+    bar = f"{orange}{'█' * filled_length}{reset}{'-' * (bar_length - filled_length)}"
+
+    # Print the progress bar on the same line using carriage return (\r)
+    text = " | ".join(
+        [
+            f"\r[ {bar} ] {int(fraction * 100)}%",
+            f"Best test {score_name}: {best_score}",
+            f"Best params: {best_parameters}",
+        ]
+    )
+    print(text, end="", flush=True)
+
+
+ def _get_score_name(scorers):
+     scorers = getattr(scorers, "_scorer", scorers)
+ 
+     if hasattr(scorers, "_score_func"):
+         return scorers._score_func.__name__
+ 
+    if isinstance(est := getattr(scorers, "_estimator", None), BaseEstimator):
+        return {"regressor": "r2", "classifier": "accuracy"}[est._estimator_type]
+
+    return "score"
+    

...

class BaseSearchCV(MetaEstimatorMixin, BaseEstimator, metaclass=ABCMeta):

... 

    def fit(self, X, y=None, **params):
        """Run fit with all sets of parameters.
        
...
        """

        estimator = self.estimator
        scorers, refit_metric = self._get_scorers()

        X, y = indexable(X, y)
        params = _check_method_params(X, params=params)

        routed_params = self._get_routed_params_for_fit(params)

        cv_orig = check_cv(self.cv, y, classifier=is_classifier(estimator))
        n_splits = cv_orig.get_n_splits(X, y, **routed_params.splitter.split)

        base_estimator = clone(self.estimator)

-        parallel = Parallel(n_jobs=self.n_jobs, pre_dispatch=self.pre_dispatch)
+        parallel = Parallel(
+            n_jobs=self.n_jobs,
+            pre_dispatch=self.pre_dispatch,
+            return_as="generator",
+        )

        fit_and_score_kwargs = dict(
            scorer=scorers,
            fit_params=routed_params.estimator.fit,
            score_params=routed_params.scorer.score,
            return_train_score=self.return_train_score,
            return_n_test_samples=True,
            return_times=True,
            return_parameters=False,
            error_score=self.error_score,
            verbose=self.verbose,
        )
        results = {}
        with parallel:
            all_candidate_params = []
            all_out = []
            all_more_results = defaultdict(list)

            def evaluate_candidates(candidate_params, cv=None, more_results=None):
                cv = cv or cv_orig
                candidate_params = list(candidate_params)
                n_candidates = len(candidate_params)

                if self.verbose > 0:
                    print(
                        "Fitting {0} folds for each of {1} candidates,"
                        " totalling {2} fits".format(
                            n_splits, n_candidates, n_candidates * n_splits
                        )
                    )

-               out = parallel(
+               out_gen = parallel(
                    delayed(_fit_and_score)(
                        clone(base_estimator),
                        X,
                        y,
                        train=train,
                        test=test,
                        parameters=parameters,
                        split_progress=(split_idx, n_splits),
                        candidate_progress=(cand_idx, n_candidates),
                        **fit_and_score_kwargs,
                    )
                    for (cand_idx, parameters), (split_idx, (train, test)) in product(
                        enumerate(candidate_params),
                        enumerate(cv.split(X, y, **routed_params.splitter.split)),
                    )
                )

+               score_name = _get_score_name(scorers)
+
+               best_score = -float("inf")
+               best_score_str = ""
+               best_parameters = {}
+               score_buffer = []
+               parameters_buffer = []
+               out = []
+
+               progress_bar(
+                   0,
+                   n_candidates,
+                   score_name,
+                   best_score_str,
+                   best_parameters,
+               )
+
+               for idx, result in enumerate(out_gen, 1):
+                   out.append(result)
+                   parameters_buffer.append(result["parameters"])
+                   score_buffer.append(result["test_scores"])
+
+                   if idx % n_splits == 0:
+                       # We evaluated all folds of a single parameters combination.
+                       # Parallel(return_as = generator) sequence is ordered.
+                       assert all([parameters_buffer[0] == p for p in parameters_buffer])
+                       mean_score = np.mean(score_buffer)
+
+                       # Higher is better by sklearn convention
+                       if mean_score > best_score:
+                           best_score = mean_score
+                           best_parameters = parameters_buffer[0]
+
+                           std_score = np.std(score_buffer)
+                           best_score_str = f"{mean_score:.4f} ± {std_score:.4f}"
+
+                       progress_bar(
+                           idx // n_splits,
+                           n_candidates,
+                           score_name,
+                           best_score_str,
+                           best_parameters,
+                       )
+                       parameters_buffer = []
+                       score_buffer = []
+
+               print()  # Move to the next line when done

                if len(out) < 1:
                    raise ValueError(
                        "No fits were performed. "
                        "Was the CV iterator empty? "
                        "Were there no candidates?"
                    )

```

The output looks like:

![Image](https://github.com/user-attachments/assets/6f75fd5c-8830-4510-b697-f296a89f4e17)


### Describe alternatives you've considered, if relevant

Using the callback mechanisms, but I don't know their ETA.

### Additional context

In skrub, we're developing a mechanism to broaden the scope of hp search, which leads to bigger hp spaces and longer waiting time. Having a better display will help us make this search less cumbersome.

COMMENT:
So the way forward is implemented in https://github.com/scikit-learn/scikit-learn/pull/28760 that has the callback infrastructure and the progress bar callback.

Labels: New Feature