OneVsRestClassifier when all estimators predict a sample belongs to the other classes

### Describe the bug

Hello, I stumbled upon quite a funny case by accident.

In OneVsRestClassifier, each classifier predicts whether a sample belongs to a specific class, or to any of the other class. For instance, if you have 3 classes, you will have 3  binary classifiers:

- the first one says if the sample belongs to class 1 or to one of the two other classes.
- the second one says if the sample belongs to class 2 or to one of the two other classes.
- the third one says if the sample belongs to class 3 or to one of the two other classes.

However, it creates an edge case where all of the estimators of OneVsRestClassifier predict a specific sample belongs to the other classes:

- calling .predict() will mark the sample as belonging to the last class (which is of course wrong since in our example, the 3rd estimator said the sample did not belong in that class).
- calling .predict_proba() will return NaNs values.

### Steps/Code to Reproduce

```python
from sklearn.multiclass import OneVsRestClassifier
from sklearn.base import BaseEstimator, ClassifierMixin
from sklearn.datasets import make_classification

import numpy as np


class MyDumbDumbBinaryClassifier(BaseEstimator, ClassifierMixin):
    def fit(self, X, y):
        self.classes_ = set(y)
        return self

    def predict(self, X):
        return np.array([0 for _ in range(len(X))])

    def predict_proba(self, X):
        ones = np.ones((len(X), len(self.classes_)))
        # the proba of being the positive class is always 0
        ones[:, 1] = 0

        return ones
    

clf = OneVsRestClassifier(MyDumbDumbBinaryClassifier())

X, y = make_classification(n_classes=3, n_informative=5)
clf.fit(X, y)
clf.predict_proba(X)
```

### Expected Results

I guess .predict() should return NaNs, and .predict_proba() should return a vector of 0s for that sample.

### Actual Results

```python
>>> clf.predict(X)

array([2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
       2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
       2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
       2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,
       2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2])
```

```python
>>> clf.predict_proba(X)

array([[nan, nan, nan],
       [nan, nan, nan],
       [nan, nan, nan],
       [nan, nan, nan],
       ...
       [nan, nan, nan],
       [nan, nan, nan],
       [nan, nan, nan]])
```


### Versions

```shell
System:
    python: 3.13.2 (v3.13.2:4f8bb3947cf, Feb  4 2025, 11:51:10) [Clang 15.0.0 (clang-1500.3.9.4)]
executable: /Library/Frameworks/Python.framework/Versions/3.13/bin/python3
   machine: macOS-14.4.1-arm64-arm-64bit-Mach-O

Python dependencies:
      sklearn: 1.6.1
          pip: 24.3.1
   setuptools: None
        numpy: 2.2.3
        scipy: 1.15.2
       Cython: None
       pandas: 2.2.3
   matplotlib: 3.10.1
       joblib: 1.4.2
threadpoolctl: 3.6.0

Built with OpenMP: True

threadpoolctl info:
       user_api: openmp
   internal_api: openmp
    num_threads: 8
         prefix: libomp
       filepath: /Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/site-packages/sklearn/.dylibs/libomp.dylib
        version: None
```

COMMENT:
The issue comes from the probability normalization in the `predict_proba` method of `OneVsRestClassifier.` Look at lines 520-558 of `sklearn/multiclass.py`:
```
    @available_if(_estimators_has("predict_proba"))
    def predict_proba(self, X):
        """
        ... (docstring stuff)
        """
        check_is_fitted(self)
        # Y[i, j] gives the probability that sample i has the label j.
        # In the multi-label case, these are not disjoint.
        Y = np.array([e.predict_proba(X)[:, 1] for e in self.estimators_]).T

        if len(self.estimators_) == 1:
            # Only one estimator, but we still want to return probabilities
            # for two classes.
            Y = np.concatenate(((1 - Y), Y), axis=1)

        if not self.multilabel_:
            # Then, probabilities should be normalized to 1.
            Y /= np.sum(Y, axis=1)[:, np.newaxis]
        return Y
```

Then you do this:

```
# %% Original code
from sklearn.multiclass import OneVsRestClassifier
from sklearn.base import BaseEstimator, ClassifierMixin
from sklearn.datasets import make_classification

import numpy as np


class MyDumbDumbBinaryClassifier(BaseEstimator, ClassifierMixin):
    def fit(self, X, y):
        self.classes_ = set(y)
        return self

    def predict(self, X):
        return np.array([0 for _ in range(len(X))])

    def predict_proba(self, X):
        ones = np.ones((len(X), len(self.classes_)))
        # the proba of being the positive class is always 0
        ones[:, 1] = 0

        return ones
    

clf = OneVsRestClassifier(MyDumbDumbBinaryClassifier())

X, y = make_classification(n_classes=3, n_informative=5)
clf.fit(X, y)
# Y = clf.predict_proba(X)


# %% What's going under the hood when we call `Y = clf.predict_proba(X)`
estimators = clf.estimators_
Y = np.array([e.predict_proba(X)[:, 1] for e in estimators]).T
row_sums = np.sum(Y, axis=1)[:, np.newaxis] # Array of all 0's
Y /= row_sums # Division by zero leads to NaN
```

Since our Y matrix has a zero row and `OneVsRestClassifier.predict_proba` wants to normalize the probabilities, it ends up dividing by zero and producing undefined behavior. We might want to change the original source code from

```
if not self.multilabel_:
            # Then, probabilities should be normalized to 1.
            Y /= np.sum(Y, axis=1)[:, np.newaxis]
```

to

```
if not self.multilabel_:
            # Then, (nonzero) probabilities should be normalized to 1.
            row_sums = np.sum(Y, axis=1)[:, np.newaxis]
            row_sums[row_sums == 0] = 1
            Y /= row_sums
```

Maybe worth a PR?

Labels: Bug