[BUG] `compute_class_weight` coerces class labels to int in 1.9, breaking dict lookup for string labels
Introduce yourself
hi, I am a core developer for the aeon toolkit and an academic at the university of Southampton. I found this bug when trying to raise our upper bound for scikit
https://github.com/aeon-toolkit/aeon/pull/3787
For context, we have a range of algorithms that use scikit components, in this case the ExtraTreeClassifier and RandomForestClassifier. We also maintain a range of data archives including the UCR archive, hosted here
http://timeseriesclassification.com/ Our classification data always loads class values as strings, so for example, "1"/"2" or "Run"/"Jump". The basic problem is that relevant parts of scikit 1.9 now always parse class value strings to integers, leading to dictionary errors.
I had a good look and couldnt see this issue, sorry if its been fixed already.
Describe the bug and give evidence about its user-facing impact
In 1.9, fitting a forest ensemble with class_weight="balanced" raises ValueError when the class labels are strings that happen to parse as integers ("1", "2", ...). The same code works in 1.8 and earlier. Labels that are genuine integers, or strings that do not parse as integers ("a", "b"), are unaffected.
The problem comes from a coercion added to the user-defined-dictionary branch of compute_class_weight in #32644 (commit 9ca766b, "FEA Add array API support for LogisticRegression with LBFGS"):
for i, c in enumerate(classes):
try:
c = int(c)
except ValueError: # `classes` contains strings
c = str(c)
if c in class_weight:
The intent looks like normalising array scalars to Python scalars so that dict lookup by key works under a non-numpy namespace. But int("1") succeeds, so it never reaches the except clause that would have preserved the string.
That branch is reached even though the user asked for "balanced", because BaseForest._validate_y_class_weight does not pass "balanced" straight through. It round-trips (sklearn/ensemble/_forest.py:861):
# Computing class_weight (dict or list) for the "balanced" option.
class_weight_k_vect = compute_class_weight("balanced", classes=self.classes_[k], ...)
class_weight_k = {key: val for (key, val) in zip(self.classes_[k], class_weight_k_vect)}
...
expanded_class_weight = compute_sample_weight(class_weight, y_original)
It first expands "balanced" into an explicit dict keyed by the original labels — {"1": 1.0, "2": 1.0}, string keys — and then feeds that dict back in. The second call goes through the dictionary branch, the keys are coerced to int, every lookup fails, and all classes are reported as unweighted.
Estimators that pass "balanced" directly to compute_class_weight (DecisionTreeClassifier, RidgeClassifierCV, SVC, ...) take the dedicated elif class_weight == "balanced" branch, which uses a LabelEncoder and never coerces, so they are not affected.
Steps/Code to Reproduce
import numpy as np
from sklearn.ensemble import RandomForestClassifier
X = np.random.RandomState(0).rand(20, 5)
y = np.array(["1"] * 10 + ["2"] * 10) # string labels that parse as integers
RandomForestClassifier(n_estimators=5, class_weight="balanced").fit(X, y)
Or against compute_class_weight directly, without the forest:
import numpy as np
from sklearn.utils.class_weight import compute_class_weight
compute_class_weight(
{"1": 1.0, "2": 1.0}, classes=np.array(["1", "2"]), y=np.array(["1", "2", "1"])
)
Expected Results
Expected Results
Fits without error, as in 1.8.0 and earlier. The direct compute_class_weight call returns array([1., 1.]).
Actual Results
Traceback (most recent call last):
File "repro.py", line 7, in <module>
RandomForestClassifier(n_estimators=5, class_weight="balanced").fit(X, y)
File "sklearn/base.py", line 1403, in wrapper
return fit_method(estimator, *args, **kwargs)
File "sklearn/ensemble/_forest.py", line 393, in fit
y, expanded_class_weight = self._validate_y_class_weight(y, sample_weight)
File "sklearn/ensemble/_forest.py", line 879, in _validate_y_class_weight
expanded_class_weight = compute_sample_weight(class_weight, y_original)
File "sklearn/utils/_param_validation.py", line 191, in wrapper
return func(*args, **kwargs)
File "sklearn/utils/class_weight.py", line 236, in compute_sample_weight
weight_k = compute_class_weight(
File "sklearn/utils/_param_validation.py", line 191, in wrapper
return func(*args, **kwargs)
File "sklearn/utils/class_weight.py", line 117, in compute_class_weight
raise ValueError(
ValueError: The classes, [1, 2], are not in class_weight
The message is also misleading: it reports the classes as [1, 2] (post-coercion integers) when the real classes are the strings ["1", "2"], which are the dict keys.
A mixed example shows the failure is per-label and driven purely by whether int() succeeds — with classes ["0", "a"] only "0" is reported:
This effects both ExtraTreesClassifier and RandomForestClassifier
Versions
System:
python: 3.12.9 [MSC v.1942 64 bit (AMD64)]
machine: Windows-11-10.0.26200-SP0
Python dependencies:
sklearn: 1.9.0
pip: 26.1.2
setuptools: 82.0.1
numpy: 2.4.6
scipy: 1.18.0
Cython: None
pandas: 2.2.3
matplotlib: 3.10.1
joblib: 1.4.2
threadpoolctl: 3.5.0
Interest in fixing the bug
yes I would be happy to, I would not convert strings to ints at all, leave them as string and would not use a try ... except block in this context and avoid the round trip, but would need to look more closely, claude suggests this fix (and has helped me pin this down)
Convert the labels to Python scalars without altering their values, rather than parsing
them. move_to is already imported and used earlier in the same function:
for i, c in enumerate(classes):
- try:
- c = int(c)
- except ValueError: # `classes` contains strings
- c = str(c)
+ # convert to python scalars so that lookup by dict key works for any
+ # array namespace, without altering the label values themselves
+ for i, c in enumerate(move_to(classes, xp=np, device="cpu").tolist()):
if c in class_weight:
tolist() maps each element to its natural Python type — str stays str, int stays int — and
both hash-match the user's dict keys, which is what the array-API port needed. Unlike
int(), it never alters a label's value, so it cannot introduce this class of mismatch for
any label type.
A narrower guard such as if not isinstance(c, str) around the existing int() call also fixes it, which is probably what I would do, but I obviously dont have the wider picture.
Source: scikit-learn/scikit-learn