Skip to content

Commit 60aa673

Browse files
fix: map xgboost Booster labels (#120)
* fix: map xgboost Booster labels * fix: validate class label probability shape --------- Co-authored-by: Nanook <nanookclaw@users.noreply.github.com> Co-authored-by: Shahid Ul Islam <65996596+Khanz9664@users.noreply.github.com>
1 parent 6d1ab99 commit 60aa673

10 files changed

Lines changed: 158 additions & 18 deletions

File tree

‎tests/test_backend_xgboost.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,43 @@ def test_xgboost_integration_analyze():
5151
assert "xgboost" in report.metadata["backend"]["resolver"]
5252

5353

54+
def test_raw_xgboost_booster_maps_ordinal_predictions_to_string_labels():
55+
rng = np.random.default_rng(42)
56+
class_labels = np.array(["mouse", "cat", "dog"])
57+
y_ordinal = np.repeat(np.arange(3), 30)
58+
X = np.column_stack(
59+
[
60+
y_ordinal,
61+
y_ordinal == 1,
62+
y_ordinal == 2,
63+
rng.normal(scale=0.01, size=len(y_ordinal)),
64+
]
65+
).astype(float)
66+
y_true = class_labels[y_ordinal]
67+
68+
dtrain = xgboost.DMatrix(X, label=y_ordinal)
69+
model = xgboost.train(
70+
{
71+
"objective": "multi:softprob",
72+
"num_class": 3,
73+
"max_depth": 2,
74+
"eta": 0.5,
75+
"verbosity": 0,
76+
},
77+
dtrain,
78+
num_boost_round=10,
79+
)
80+
81+
report = analyze(model, X, y_true, class_labels=class_labels, verbose=False)
82+
83+
assert report.metadata["framework"] == "xgboost"
84+
assert report.y_prob.shape == (90, 3)
85+
assert set(np.unique(report.y_pred)).issubset(set(class_labels))
86+
assert report.results["failure"]["misclassification_summary"]["__overall__"][
87+
"total_errors"
88+
] == int(np.sum(y_true != report.y_pred))
89+
90+
5491
def test_xgboost_manual_override():
5592
from sklearn.datasets import make_classification
5693
from xgboost import XGBClassifier

‎tests/test_core_pipeline.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import numpy as np
2+
import pytest
3+
4+
from trustlens.core.pipeline import _encode_labels_for_probability_columns
5+
6+
7+
def test_encode_labels_rejects_class_label_length_mismatch():
8+
y_true = np.array(["mouse", "cat"])
9+
class_labels = np.array(["mouse", "cat"])
10+
11+
with pytest.raises(
12+
ValueError, match=r"class_labels length \(2\).*probability column shape \(3 columns\)"
13+
):
14+
_encode_labels_for_probability_columns(y_true, 3, class_labels)

‎trustlens/api.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def analyze(
8282
sensitive_features: Optional[dict[str, np.ndarray]] = None,
8383
modules: Optional[list[str]] = None,
8484
plugins: Optional[list[str]] = None,
85+
class_labels: Optional[np.ndarray] = None,
8586
verbose: bool = True,
8687
) -> TrustReport:
8788
"""
@@ -113,6 +114,10 @@ def analyze(
113114
Subset of analysis modules to run.
114115
plugins : list[str], optional
115116
Names of registered plugins to activate.
117+
class_labels : np.ndarray, optional
118+
Semantic class labels in the order corresponding to probability columns.
119+
Useful for raw backends such as ``xgboost.Booster`` that return ordinal
120+
probability columns without a ``classes_`` attribute.
116121
verbose : bool
117122
Print progress updates. Default True.
118123
@@ -163,7 +168,14 @@ def analyze(
163168
framework = "manual"
164169

165170
resolver = get_resolver(model, framework=framework)
166-
bundle = resolver(model, X, y_pred=y_pred, y_prob=y_prob)
171+
resolved_class_labels = np.asarray(class_labels) if class_labels is not None else None
172+
bundle = resolver(
173+
model,
174+
X,
175+
y_pred=y_pred,
176+
y_prob=y_prob,
177+
class_labels=resolved_class_labels,
178+
)
167179

168180
# ------------------------------------------------------------------
169181
# 2. Delegate to Core Pipeline
@@ -176,6 +188,7 @@ def analyze(
176188
y_prob=bundle.y_prob,
177189
framework=bundle.framework,
178190
backend_metadata=bundle.metadata,
191+
class_labels=bundle.class_labels,
179192
embeddings=embeddings,
180193
sensitive_features=sensitive_features,
181194
modules=modules,

‎trustlens/backends/registry.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,24 @@ def manual_resolve(
4040
X: np.ndarray,
4141
y_pred: Optional[np.ndarray] = None,
4242
y_prob: Optional[np.ndarray] = None,
43+
class_labels: Optional[np.ndarray] = None,
4344
) -> PredictionBundle:
4445
"""
4546
Passthrough resolver for manual overrides.
4647
"""
4748
if y_pred is None:
4849
if y_prob is not None:
4950
# Derive y_pred from y_prob if missing
50-
y_pred = np.argmax(np.asarray(y_prob), axis=1)
51+
y_prob_arr = np.asarray(y_prob)
52+
y_pred_indices = np.argmax(y_prob_arr, axis=1)
53+
if (
54+
class_labels is not None
55+
and y_prob_arr.ndim == 2
56+
and len(class_labels) == y_prob_arr.shape[1]
57+
):
58+
y_pred = np.asarray(class_labels)[y_pred_indices]
59+
else:
60+
y_pred = y_pred_indices
5161
else:
5262
raise ValueError("Manual override requires either y_pred or y_prob.")
5363

@@ -61,6 +71,7 @@ def manual_resolve(
6171
y_pred=np.asarray(y_pred),
6272
y_prob=np.asarray(y_prob) if y_prob is not None else None,
6373
framework="manual",
74+
class_labels=np.asarray(class_labels) if class_labels is not None else None,
6475
metadata=metadata,
6576
)
6677

‎trustlens/backends/sklearn.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def resolve(
1818
X: np.ndarray,
1919
y_pred: Optional[np.ndarray] = None,
2020
y_prob: Optional[np.ndarray] = None,
21+
class_labels: Optional[np.ndarray] = None,
2122
) -> PredictionBundle:
2223
"""
2324
Resolve predictions and probabilities from a scikit-learn model.
@@ -72,6 +73,8 @@ def resolve(
7273

7374
# Defensive label mapping
7475
classes = getattr(model, "classes_", None)
76+
if classes is None:
77+
classes = class_labels
7578
if classes is not None:
7679
classes_arr = np.asarray(classes)
7780
if len(classes_arr) == y_prob.shape[1]:
@@ -89,9 +92,13 @@ def resolve(
8992
)
9093

9194
# 5. Metadata & Labels
92-
class_labels = getattr(model, "classes_", None)
93-
if class_labels is not None:
94-
class_labels = np.asarray(class_labels)
95+
model_class_labels = getattr(model, "classes_", None)
96+
if model_class_labels is not None:
97+
resolved_class_labels = np.asarray(model_class_labels)
98+
elif class_labels is not None:
99+
resolved_class_labels = np.asarray(class_labels)
100+
else:
101+
resolved_class_labels = None
95102

96103
metadata = {
97104
"resolver": "sklearn",
@@ -103,6 +110,6 @@ def resolve(
103110
y_pred=y_pred,
104111
y_prob=y_prob,
105112
framework="sklearn",
106-
class_labels=class_labels,
113+
class_labels=resolved_class_labels,
107114
metadata=metadata,
108115
)

‎trustlens/backends/types.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def __post_init__(self) -> None:
4242
"Consult docs/internal/prediction_contract.md for details."
4343
)
4444

45-
if np.any(~np.isfinite(self.y_pred)):
45+
if np.issubdtype(self.y_pred.dtype, np.number) and np.any(~np.isfinite(self.y_pred)):
4646
raise ValueError("y_pred contains non-finite values (NaN or Inf).")
4747

4848
if self.y_prob is not None:

‎trustlens/backends/xgboost.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ def resolve(
1515
X: np.ndarray,
1616
y_pred: Optional[np.ndarray] = None,
1717
y_prob: Optional[np.ndarray] = None,
18+
class_labels: Optional[np.ndarray] = None,
1819
) -> PredictionBundle:
1920
"""
2021
Prediction resolver for XGBoost models.
@@ -85,6 +86,14 @@ def resolve(
8586
y_prob_flat = y_prob.flatten()
8687
y_prob = np.column_stack([1 - y_prob_flat, y_prob_flat])
8788

89+
model_class_labels = getattr(model, "classes_", None)
90+
if model_class_labels is not None:
91+
resolved_class_labels = np.asarray(model_class_labels)
92+
elif class_labels is not None:
93+
resolved_class_labels = np.asarray(class_labels)
94+
else:
95+
resolved_class_labels = None
96+
8897
# 4. Resolve Class Predictions
8998
if y_pred is None:
9099
if hasattr(model, "predict") and not isinstance(model, xgb.Booster):
@@ -93,10 +102,9 @@ def resolve(
93102
else:
94103
# Fallback to argmax from probabilities
95104
y_pred_indices = np.argmax(y_prob, axis=1)
96-
if hasattr(model, "classes_"):
97-
classes = np.asarray(model.classes_)
98-
if len(classes) == y_prob.shape[1]:
99-
y_pred = classes[y_pred_indices]
105+
if resolved_class_labels is not None:
106+
if len(resolved_class_labels) == y_prob.shape[1]:
107+
y_pred = resolved_class_labels[y_pred_indices]
100108
else:
101109
y_pred = y_pred_indices
102110
else:
@@ -114,5 +122,6 @@ def resolve(
114122
y_pred=np.asarray(y_pred),
115123
y_prob=y_prob,
116124
framework="xgboost",
125+
class_labels=resolved_class_labels,
117126
metadata=metadata,
118127
)

‎trustlens/core/pipeline.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,41 @@
3535
logger = logging.getLogger(__name__)
3636

3737

38+
def _as_python_label(label: Any) -> Any:
39+
"""Return a hashable Python scalar for NumPy scalar labels."""
40+
return label.item() if hasattr(label, "item") else label
41+
42+
43+
def _encode_labels_for_probability_columns(
44+
y_true: np.ndarray,
45+
n_classes: int,
46+
class_labels: Optional[np.ndarray],
47+
) -> np.ndarray:
48+
"""Encode semantic labels into the class-index order used by y_prob columns."""
49+
if class_labels is not None:
50+
class_labels_array = np.asarray(class_labels)
51+
if len(class_labels_array) != n_classes:
52+
raise ValueError(
53+
"class_labels length "
54+
f"({len(class_labels_array)}) does not match probability column shape "
55+
f"({n_classes} columns)."
56+
)
57+
58+
label_to_index = {
59+
_as_python_label(label): idx for idx, label in enumerate(class_labels_array)
60+
}
61+
try:
62+
encoded_labels: np.ndarray = np.asarray(
63+
[_as_python_label(label_to_index[_as_python_label(label)]) for label in y_true],
64+
dtype=int,
65+
)
66+
return encoded_labels
67+
except KeyError as exc:
68+
raise ValueError("y_true contains labels that are missing from class_labels.") from exc
69+
70+
return y_true.astype(int)
71+
72+
3873
def _run_analysis_pipeline(
3974
model: Any,
4075
X: np.ndarray,
@@ -47,6 +82,7 @@ def _run_analysis_pipeline(
4782
plugins: Optional[list[str]] = None,
4883
framework: Optional[str] = None,
4984
backend_metadata: Optional[dict[str, Any]] = None,
85+
class_labels: Optional[np.ndarray] = None,
5086
verbose: bool = True,
5187
) -> TrustReport:
5288
"""
@@ -98,7 +134,10 @@ def _run_analysis_pipeline(
98134

99135
# Multiclass Brier Score: 1/N * sum(sum((p_ic - o_ic)^2))
100136
# We can compute this efficiently
101-
y_true_one_hot = np.eye(n_classes)[y_true.astype(int)]
137+
y_true_indices = _encode_labels_for_probability_columns(
138+
y_true, n_classes, class_labels
139+
)
140+
y_true_one_hot = np.eye(n_classes)[y_true_indices]
102141
mbrier = np.mean(np.sum((y_prob - y_true_one_hot) ** 2, axis=1))
103142

104143
results["calibration"] = {

‎trustlens/metrics/bias.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
]
2929

3030

31+
def _as_python_label(label):
32+
return label.item() if hasattr(label, "item") else label
33+
34+
3135
def class_imbalance_report(y_true: np.ndarray) -> dict:
3236
"""
3337
Summarize the class distribution in ``y_true``.
@@ -58,13 +62,15 @@ def class_imbalance_report(y_true: np.ndarray) -> dict:
5862
classes, counts = np.unique(y_true, return_counts=True)
5963
n = len(y_true)
6064

61-
class_counts = {int(cls): int(cnt) for cls, cnt in zip(classes, counts)}
62-
class_frequencies = {int(cls): round(float(cnt / n), 4) for cls, cnt in zip(classes, counts)}
65+
class_counts = {_as_python_label(cls): int(cnt) for cls, cnt in zip(classes, counts)}
66+
class_frequencies = {
67+
_as_python_label(cls): round(float(cnt / n), 4) for cls, cnt in zip(classes, counts)
68+
}
6369

6470
min_count = int(counts.min())
6571
max_count = int(counts.max())
66-
minority_class = int(classes[counts.argmin()])
67-
majority_class = int(classes[counts.argmax()])
72+
minority_class = _as_python_label(classes[counts.argmin()])
73+
majority_class = _as_python_label(classes[counts.argmax()])
6874
imbalance_ratio = round(max_count / min_count, 4) if min_count > 0 else float("inf")
6975

7076
return {

‎trustlens/metrics/failure.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
]
2222

2323

24+
def _as_python_label(label):
25+
return label.item() if hasattr(label, "item") else label
26+
27+
2428
def misclassification_summary(
2529
y_true: np.ndarray,
2630
y_pred: np.ndarray,
@@ -71,7 +75,7 @@ def misclassification_summary(
7175

7276
summary: dict = {}
7377
for cls in classes:
74-
cls_mask = y_true == int(cls)
78+
cls_mask = y_true == cls
7579
cls_incorrect = cls_mask & incorrect_mask
7680

7781
n_support = int(cls_mask.sum())
@@ -92,7 +96,7 @@ def misclassification_summary(
9296
else:
9397
top_mistake_indices = []
9498

95-
summary[int(cls)] = {
99+
summary[_as_python_label(cls)] = {
96100
"support": n_support,
97101
"n_misclassified": n_misclassified,
98102
"error_rate": round(error_rate, 4),

0 commit comments

Comments
 (0)