Sklearn-源码解析-书-v1-0-十六-
Sklearn 源码解析(书)v1.0(十六)
源码路径:sklearn/metrics/_base.py - _average_multiclass_ovo_score()(第82-135行)
def _average_multiclass_ovo_score(binary_metric, y_true, y_score, average="macro"):
"""Hand & Till (2001) 两两类别法平均"""
y_true_unique = np.unique(y_true)
n_pairs = n_classes * (n_classes - 1) // 2
pair_scores = np.empty(n_pairs)
prevalence = np.empty(n_pairs) if average == "weighted" else None
for ix, (a, b) in enumerate(combinations(y_true_unique, 2)):
a_mask = y_true == a
b_mask = y_true == b
ab_mask = np.logical_or(a_mask, b_mask)
if average == "weighted":
prevalence[ix] = np.average(ab_mask)
# a 为正、b 为负 + b 为正、a 为负 取平均
a_true_score = binary_metric(a_mask[ab_mask], y_score[ab_mask, a])
b_true_score = binary_metric(b_mask[ab_mask], y_score[ab_mask, b])
pair_scores[ix] = (a_true_score + b_true_score) / 2
return np.average(pair_scores, weights=prevalence)
Hand & Till (2001) OVO 平均的精髓:
-
将 K 类问题分解为 K(K-1)/2 个二分类子问题
-
每对类别 (a,b) 计算两次二分类指标(a正b负 + b正a负)取平均
-
weighted时按该类别对在数据中出现的样本比例加权
对比表:多种平均策略的适用场景与权重来源
| 平均策略 | 适用任务类型 | 权重来源 | 语义 |
|----------|--------------|----------|------|
| micro | 所有 | 无(全局求和) | 等价于展平后的整体指标,multiclass 下等同 accuracy |
| macro | 所有 | 无(等权) | 关注每个类别表现,不受类别不平衡影响 |
| weighted | 所有 | true_sum (support) | 考虑类别不平衡,大类影响大 |
| samples | multilabel | sample_weight | 关注每个样本的预测质量 |
| binary | binary | N/A | 仅报告 pos_label 类别指标 |
30.7 概率型指标:对数损失与 Brier 分数 —— 评估“置信度质量”的度量衡
30.7.1 log_loss:交叉熵损失的数值稳定实现
源码路径:sklearn/metrics/_classification.py - log_loss() 与 _log_loss()(第1600-1675行)
def log_loss(y_true, y_pred, *, normalize=True, sample_weight=None, labels=None):
xp, _, device_ = get_namespace_and_device(y_pred)
y_pred = check_array(y_pred, ensure_2d=False, dtype=supported_float_dtypes(xp, device=device_))
if sample_weight is not None:
sample_weight = move_to(sample_weight, xp=xp, device=device_)
transformed_labels, y_pred = _validate_multiclass_probabilistic_prediction(
y_true, y_pred, sample_weight, labels
)
return _log_loss(transformed_labels, y_pred, normalize=normalize, sample_weight=sample_weight)
def _log_loss(transformed_labels, y_pred, *, normalize=True, sample_weight=None):
xp, _, device_ = get_namespace_and_device(y_pred)
if sample_weight is not None:
sample_weight = move_to(sample_weight, xp=xp, device=device_)
eps = xp.finfo(y_pred.dtype).eps
y_pred = xp.clip(y_pred, eps, 1 - eps) # 核心:裁剪防止 log(0)
transformed_labels = xp.astype(transformed_labels, y_pred.dtype, copy=False)
loss = -xp.sum(_xlogy(transformed_labels, y_pred, xp=xp), axis=1) # xlogy: x*log(y), 0*log(0)=0
return float(_average(loss, weights=sample_weight, normalize=normalize))
数值稳定性三重保障:
-
eps裁剪:xp.clip(y_pred, eps, 1-eps)将概率限制在机器精度范围内,避免log(0)产生-inf。 -
_xlogy安全乘积:x * log(y)在x=0时定义为 0,避免0 * (-inf) = NaN。 -
dtype 自适应:
xp.finfo(y_pred.dtype).eps自动适配 float16/32/64 的精度下限。
30.7.2 brier_score_loss:均方误差形式的概率校准度量
源码路径:sklearn/metrics/_classification.py - brier_score_loss()(第1750-1860行)
def brier_score_loss(y_true, y_proba, *, sample_weight=None, pos_label=None, labels=None, scale_by_half="auto"):
xp, _, device_ = get_namespace_and_device(y_proba)
y_proba = check_array(y_proba, ensure_2d=False, dtype=supported_float_dtypes(xp, device=device_))
if sample_weight is not None:
sample_weight = move_to(sample_weight, xp=xp, device=device_)
# 统一转为 (n_samples, n_classes) 形状
if y_proba.ndim == 1 or y_proba.shape[1] == 1:
transformed_labels, y_proba = _validate_binary_probabilistic_prediction(
y_true, y_proba, sample_weight, pos_label
)
else:
transformed_labels, y_proba = _validate_multiclass_probabilistic_prediction(
y_true, y_proba, sample_weight, labels
)
transformed_labels = xp.astype(transformed_labels, y_proba.dtype, copy=False)
brier_score = _average(
xp.sum((transformed_labels - y_proba) ** 2, axis=1), weights=sample_weight
)
# scale_by_half 自动判断:二分类默认除以 2 映射到 [0,1]
if scale_by_half == "auto":
scale_by_half = y_proba.ndim == 1 or y_proba.shape[1] < 3
if scale_by_half:
brier_score *= 0.5
return float(brier_score)
设计亮点:
-
二分类/多类统一接口:内部统一转为
(n_samples, n_classes)形状,核心公式mean((y_true - y_proba)^2)完全一致。 -
scale_by_half="auto"智能缩放:-
二分类(
y_proba.ndim==1或shape[1]<3)自动除以 2 → 范围 [0, 1] -
多类保持原始 [0, 2] 范围
-
-
无需裁剪:Brier 分数基于平方差,天然无
log(0)问题。
30.7.3 D² 解释度:相对空模型的可解释性指标
源码路径:sklearn/metrics/_classification.py - d2_log_loss_score()(第1862-1940行)
def d2_log_loss_score(y_true, y_pred, *, sample_weight=None, labels=None):
check_consistent_length(y_pred, y_true, sample_weight)
if _num_samples(y_pred) < 2:
warnings.warn("D^2 score is not well-defined with less than two samples.", UndefinedMetricWarning)
return float("nan")
xp, _, device_ = get_namespace_and_device(y_pred)
y_pred = check_array(y_pred, ensure_2d=False, dtype=supported_float_dtypes(xp, device=device_))
if sample_weight is not None:
sample_weight = move_to(sample_weight, xp=xp, device=device_)
transformed_labels, y_pred = _validate_multiclass_probabilistic_prediction(y_true, y_pred, sample_weight, labels)
# 构造空模型:按 sample_weight 计算类别先验概率
y_pred_null = _average(transformed_labels, axis=0, weights=sample_weight)
y_pred_null = xp.tile(y_pred_null, (y_pred.shape[0], 1))
numerator = _log_loss(transformed_labels, y_pred, normalize=False, sample_weight=sample_weight)
denominator = _log_loss(transformed_labels, y_pred_null, normalize=False, sample_weight=sample_weight)
return float(1 - (numerator / denominator))
D² 公式统一形式:
-
空模型
y_pred_null:每个样本预测相同的类别先验概率分布(按sample_weight加权)。 -
分母为零处理:若空模型损失为 0(如完美预测),返回
NaN并警告。
源码路径:sklearn/metrics/_classification.py - d2_brier_score()(第1942-2050行)
def d2_brier_score(y_true, y_proba, *, sample_weight=None, pos_label=None, labels=None):
# ... 样本数检查、输入转换 ...
if y_proba.ndim == 1 or y_proba.shape[1] == 1:
transformed_labels, y_proba = _validate_binary_probabilistic_prediction(y_true, y_proba, sample_weight, pos_label)
else:
transformed_labels, y_proba = _validate_multiclass_probabilistic_prediction(y_true, y_proba, sample_weight, labels)
transformed_labels = xp.astype(transformed_labels, y_proba.dtype, copy=False)
y_proba_null = _average(transformed_labels, axis=0, weights=sample_weight)
y_proba_null = xp.tile(y_proba_null, (y_proba.shape[0], 1))
brier_score = _average(xp.sum((transformed_labels - y_proba) ** 2, axis=1), weights=sample_weight)
brier_score_null = _average(xp.sum((transformed_labels - y_proba_null) ** 2, axis=1), weights=sample_weight)
return float(1 - brier_score / brier_score_null)
d2_brier_score 完全复用 brier_score_loss 的核心计算,缩放因子在比率中自动抵消,无需关心 scale_by_half。
30.8 高级分类指标:Jaccard、Hamming、Matthews、Hinge、类似然比 —— 多视角的评估工具箱
30.8.1 jaccard_score:基于 MCM 元素的集合相似度
源码路径:sklearn/metrics/_classification.py - jaccard_score()(第750-860行)
def jaccard_score(y_true, y_pred, *, labels=None, pos_label=1, average="binary", sample_weight=None, zero_division="warn"):
labels = _check_set_wise_labels(y_true, y_pred, average, labels, pos_label)
samplewise = average == "samples"
MCM = multilabel_confusion_matrix(y_true, y_pred, sample_weight=sample_weight, labels=labels, samplewise=samplewise)
# Jaccard = TP / (TP + FP + FN)
numerator = MCM[:, 1, 1]
denominator = MCM[:, 1, 1] + MCM[:, 0, 1] + MCM[:, 1, 0]
xp, _, device_ = get_namespace_and_device(y_true, y_pred)
if average == "micro":
numerator = xp.asarray(xp.sum(numerator, keepdims=True), device=device_)
denominator = xp.asarray(xp.sum(denominator, keepdims=True), device=device_)
jaccard = _prf_divide(numerator, denominator, "jaccard", "true or predicted", average, ("jaccard",), zero_division=zero_division)
if average is None:
return jaccard
if average == "weighted":
weights = MCM[:, 1, 0] + MCM[:, 1, 1] # support = TP + FN
if not xp.any(weights): weights = None
elif average == "samples" and sample_weight is not None:
weights = sample_weight
else:
weights = None
return float(_average(jaccard, weights=weights, xp=xp))
Jaccard 与 F1 的关系:
-
二分类单标签:
Jaccard = TP/(TP+FP+FN),F1 = 2TP/(2TP+FP+FN)→F1 = 2*J/(1+J) -
多标签/多类:
jaccard_score直接基于 MCM 元素计算,不依赖 PRF 引擎,但复用_prf_divide处理零除。
30.8.2 hamming_loss 与 zero_one_loss:两种“错误率”的区别
源码路径:sklearn/metrics/_classification.py - hamming_loss()(第1560-1600行)
def hamming_loss(y_true, y_pred, *, sample_weight=None):
y_true, y_pred = attach_unique(y_true, y_pred)
y_type, y_true, y_pred, sample_weight = _check_targets(y_true, y_pred, sample_weight)
xp, _, device = get_namespace_and_device(y_true, y_pred, sample_weight)
if sample_weight is None:
weight_average = 1.0
else:
weight_average = _average(sample_weight, xp=xp)
if y_type.startswith("multilabel"):
# 逐标签错误率:总错标签数 / (样本数 * 标签数)
n_differences = _count_nonzero(y_true - y_pred, xp=xp, device=device, sample_weight=sample_weight)
return float(n_differences) / (y_true.shape[0] * y_true.shape[1] * weight_average)
elif y_type in ["binary", "multiclass"]:
# 逐元素错误率 = 1 - accuracy
return float(_average(y_true != y_pred, weights=sample_weight, normalize=True, xp=xp))
源码路径:sklearn/metrics/_classification.py - zero_one_loss()(第1072-1120行)
def zero_one_loss(y_true, y_pred, *, normalize=True, sample_weight=None):
xp, _ = get_namespace(y_true, y_pred)
score = accuracy_score(y_true, y_pred, normalize=normalize, sample_weight=sample_weight)
if normalize:
return 1 - score
else:
n_samples = xp.sum(sample_weight) if sample_weight is not None else _num_samples(y_true)
return n_samples - score
核心区别:
| 指标 | Multilabel 语义 | Binary/Multiclass 语义 |
|------|-----------------|------------------------|
| hamming_loss | 逐标签错误率:每个标签独立判错,总错标签数 / (n_samples × n_labels) | 等同于 1 - accuracy(逐元素错误率) |
| zero_one_loss | 子集 0-1 损失:整行标签完全匹配才算对,否则记 1 | 等同于 1 - accuracy(逐元素错误率) |
不等式关系:
hamming_loss ≤ zero_one_loss(multilabel 下),因为整行错必然包含至少一个标签错。
30.8.3 matthews_corrcoef:协方差形式的推广相关系数
源码路径:sklearn/metrics/_classification.py - matthews_corrcoef()(第1122-1190行)
def matthews_corrcoef(y_true, y_pred, *, sample_weight=None):
y_true, y_pred = attach_unique(y_true, y_pred)
y_type, y_true, y_pred, sample_weight = _check_targets(y_true, y_pred, sample_weight)
if y_type not in {"binary", "multiclass"}:
raise ValueError("%s is not supported" % y_type)
lb = LabelEncoder()
lb.fit(np.hstack([y_true, y_pred]))
y_true = lb.transform(y_true)
y_pred = lb.transform(y_pred)
C = confusion_matrix(y_true, y_pred, sample_weight=sample_weight)
t_sum = C.sum(axis=1, dtype=np.float64) # 真实边际
p_sum = C.sum(axis=0, dtype=np.float64) # 预测边际
n_correct = np.trace(C, dtype=np.float64) # 对角线和 = 总正确数
n_samples = p_sum.sum()
# 协方差形式公式
cov_ytyp = n_correct * n_samples - np.dot(t_sum, p_sum)
cov_ypyp = n_samples**2 - np.dot(p_sum, p_sum)
cov_ytyt = n_samples**2 - np.dot(t_sum, t_sum)
cov_ypyp_ytyt = cov_ypyp * cov_ytyt
if cov_ypyp_ytyt == 0:
return 0.0
else:
return float(cov_ytyp / np.sqrt(cov_ypyp_ytyt))
数学本质:
-
二分类:等价于 Pearson 相关系数(
phi 系数) -
多类:Gorodkin 推广,基于混淆矩阵的协方差形式
-
优势:单一标量同时考虑 TP/TN/FP/FN,对类别不平衡鲁棒
30.8.4 hinge_loss:从二分类 Margin 到 Crammer-Singer 多类推广
源码路径:sklearn/metrics/_classification.py - hinge_loss()(第1677-1750行)
def hinge_loss(y_true, pred_decision, *, labels=None, sample_weight=None):
check_consistent_length(y_true, pred_decision, sample_weight)
pred_decision = check_array(pred_decision, ensure_2d=False)
y_true = column_or_1d(y_true)
y_true_unique = np.unique(labels if labels is not None else y_true)
if y_true_unique.size > 2:
# 多类:Crammer-Singer Margin
if pred_decision.ndim <= 1:
raise ValueError("multiclass target requires pred_decision shape (n_samples, n_classes)")
if y_true_unique.size != pred_decision.shape[1]:
raise ValueError("pred_decision shape must be (n_samples, n_classes)")
if labels is None:
labels = y_true_unique
le = LabelEncoder()
le.fit(labels)
y_true = le.transform(y_true)
# 正确类得分 - 最大错误类得分
mask = np.ones_like(pred_decision, dtype=bool)
mask[np.arange(y_true.shape[0]), y_true] = False
margin = pred_decision[~mask] # 正确类得分
margin -= np.max(pred_decision[mask].reshape(y_true.shape[0], -1), axis=1) # 减去最大竞争者
else:
# 二分类:标签映射为 ±1
pred_decision = column_or_1d(pred_decision)
lbin = LabelBinarizer(neg_label=-1)
y_true = lbin.fit_transform(y_true)[:, 0]
margin = y_true * pred_decision
losses = 1 - margin
np.clip(losses, 0, None, out=losses) # max(0, 1-margin)
return float(np.average(losses, weights=sample_weight))
两种 Margin 定义:
-
二分类:
margin = y_true * pred_decision(标签 ±1,正确时 margin > 0) -
多类 (Crammer-Singer):
margin = score[true_class] - max_{j≠y} score[j](正确类得分超出最强竞争者多少)
30.8.5 class_likelihood_ratios:医学诊断视角的 LR+/LR-
源码路径:sklearn/metrics/_classification.py - class_likelihood_ratios()(第2050-2200行)
def class_likelihood_ratios(y_true, y_pred, *, labels=None, sample_weight=None, raise_warning="deprecated", replace_undefined_by=np.nan):
# ... 仅支持 binary、弃用 raise_warning 参数 ...
cm = confusion_matrix(y_true, y_pred, sample_weight=sample_weight, labels=labels)
tn, fp, fn, tp = cm.ravel()
support_pos = tp + fn
support_neg = tn + fp
# LR+ = sensitivity / (1 - specificity) = (tp/(tp+fn)) / (fp/(tn+fp))
pos_num = tp * support_neg
pos_denom = fp * support_pos
# LR- = (1 - sensitivity) / specificity = (fn/(tp+fn)) / (tn/(tn+fp))
neg_num = fn * support_neg
neg_denom = tn * support_pos
# 分母为零分情况处理
if support_pos == 0:
warnings.warn("No samples of the positive class ...", UndefinedMetricWarning)
positive_likelihood_ratio = negative_likelihood_ratio = np.nan
if fp == 0:
# ... 警告与 replace_undefined_by 处理 ...
if tn == 0:
# ... 警告与 replace_undefined_by 处理 ...
return float(positive_likelihood_ratio), float(negative_likelihood_ratio)
独特设计:
-
仅支持二分类:避免 Simpson's Paradox(多类合并导致的悖论)。
-
replace_undefined_by字典支持:{"LR+": np.inf, "LR-": 0.0}可分别指定两个比率的兜底值,比标量zero_division更精细。 -
临床解释:
-
LR+ > 1:阳性结果增加患病概率,值越大越强 -
LR- < 1:阴性结果降低患病概率,值越小越强
-
30.9 报告生成与平均策略 —— 从逐类指标到全局摘要的聚合逻辑
30.9.1 classification_report:动态聚合的成绩单生成器
源码路径:sklearn/metrics/_classification.py - classification_report()(第1350-1500行)
def classification_report(y_true, y_pred, *, labels=None, target_names=None, sample_weight=None, digits=2, output_dict=False, zero_division="warn"):
y_true, y_pred = attach_unique(y_true, y_pred)
y_type, y_true, y_pred, sample_weight = _check_targets(y_true, y_pred, sample_weight)
if labels is None:
labels = unique_labels(y_true, y_pred)
labels_given = False
else:
labels = np.asarray(labels)
labels_given = True
# 关键判断:micro 平均是否等同 accuracy
micro_is_accuracy = (y_type == "multiclass" or y_type == "binary") and (
not labels_given or (set(labels) >= set(unique_labels(y_true, y_pred)))
)
# 1. 逐类指标(average=None)
p, r, f1, s = precision_recall_fscore_support(
y_true, y_pred, labels=labels, average=None, sample_weight=sample_weight, zero_division=zero_division
)
rows = zip(target_names or [f"%s" % l for l in labels], p, r, f1, s)
# 2. 根据任务类型决定显示哪些平均行
if y_type.startswith("multilabel"):
average_options = ("micro", "macro", "weighted", "samples")
else:
average_options = ("micro", "macro", "weighted")
# 3. 文本/字典输出格式化
if output_dict:
report_dict = {label: dict(zip(headers, [float(i) for i in scores])) for label, *scores in rows}
else:
# 文本表格格式化(省略细节)
pass
# 4. 计算并追加平均行
for average in average_options:
if average.startswith("micro") and micro_is_accuracy:
line_heading = "accuracy" # 显示为 accuracy 行
else:
line_heading = average + " avg"
avg_p, avg_r, avg_f1, _ = precision_recall_fscore_support(
y_true, y_pred, labels=labels, average=average, sample_weight=sample_weight, zero_division=zero_division
)
avg = [avg_p, avg_r, avg_f1, np.sum(s)]
# ... 格式化输出 ...
if output_dict:
if "accuracy" in report_dict:
report_dict["accuracy"] = report_dict["accuracy"]["precision"] # 扁平化
return report_dict
else:
return report
动态聚合逻辑核心:
micro_is_accuracy判断:
-
仅当
y_type为multiclass/binary且labels覆盖所有真实/预测标签时,micro平均等同accuracy。 -
此时报告中显示
accuracy行而非micro avg,避免冗余。
-
Multilabel 特有
samples avg:逐样本平均仅对 multilabel 有意义(此时与 accuracy 不同)。 -
字典输出扁平化:
accuracy字段直接存浮点数而非子字典。
30.9.2 balanced_accuracy_score:平衡准确率的调整基线
源码路径:sklearn/metrics/_classification.py - balanced_accuracy_score()(第2100-2150行)
def balanced_accuracy_score(y_true, y_pred, *, sample_weight=None, adjusted=False):
C = confusion_matrix(y_true, y_pred, sample_weight=sample_weight)
xp, _, device_ = get_namespace_and_device(y_pred, y_true)
# array_api_strict 需要浮点 dtype 做除法
if _is_xp_namespace(xp, "array_api_strict"):
C = xp.astype(C, _max_precision_float_dtype(xp, device=device_), copy=False)
with (np.errstate(divide="ignore", invalid="ignore") if _is_numpy_namespace(xp) else nullcontext()):
per_class = xp.linalg.diagonal(C) / xp.sum(C, axis=1) # 逐类召回率
if xp.any(xp.isnan(per_class)):
warnings.warn("y_pred contains classes not in y_true")
per_class = per_class[~xp.isnan(per_class)]
score = xp.mean(per_class) # 宏平均召回
if adjusted:
n_classes = per_class.shape[0]
chance = 1 / n_classes
score -= chance
score /= 1 - chance # 调整随机基线
return float(score)
公式:
-
Balanced Accuracy = \(\frac{1}{K}\sum_{k=1}^K \frac{C_{kk}}{\sum_j C_{kj}}\)(逐类召回率宏平均)
-
Adjusted = \(\frac{\text{Balanced} - 1/K}{1 - 1/K}\)(随机猜测得 0,完美得 1)
30.10 Array API 兼容与多后端支持 —— 跨 NumPy/CuPy/PyTorch 的统一计算层
scikit-learn 通过 sklearn.utils._array_api 模块实现了对 Array API 标准的兼容,使指标计算能在 NumPy、CuPy、PyTorch、JAX、Dask 等后端上无缝运行。
30.10.1 核心适配模式
源码路径:sklearn/metrics/_classification.py - accuracy_score 等指标开头(第270行起)
def accuracy_score(y_true, y_pred, *, normalize=True, sample_weight=None):
xp, _, device = get_namespace_and_device(y_pred)
y_true, sample_weight = move_to(y_true, sample_weight, xp=xp, device=device)
# ... 后续全程使用 xp.*, xpx.* ...
return float(_average(score, weights=sample_weight, normalize=normalize, xp=xp))
标准适配三步走:
-
获取命名空间与设备:
xp, _, device = get_namespace_and_device(...)自动推断输入数组所属后端。 -
数据搬运统一:
move_to(y_true, sample_weight, xp=xp, device=device)将所有输入移至同一后端/设备。 -
全程 Array API 操作:用
xp.sum、xp.mean、xpx.nan_to_num等替代np.*。 -
结果归还:
float(...)或xp.asarray(result, device=device)返回原生类型或保持后端一致。
30.10.2 关键兼容工具函数
| 工具函数 | 替代目标 | 作用 |
|----------|----------|------|
| _average | np.average | 跨后端加权平均,处理 weights=None、normalize、NaN 传播 |
| _bincount | np.bincount | 硬件加速直方图统计,用于 multilabel_confusion_matrix 1D 路径 |
| _count_nonzero | np.count_nonzero | 并行非零计数,支持 axis、sample_weight、稀疏矩� |
| _union1d | np.union1d | 唯一值并集,用于 _check_targets binary 标签收集 |
| _max_precision_float_dtype | - | 获取后端支持的最高精度浮点类型(如 float64),用于累加避免溢出 |
| _xlogy | scipy.special.xlogy | 安全计算 x*log(y),x=0 时返回 0 |
| _nanaverage | np.nanmean + 权重 | 忽略 NaN 的加权平均,用于 PRF 平均聚合 |
30.10.3 多后端测试验证
源码路径:sklearn/metrics/tests/test_classification.py - test_confusion_matrix_array_api(第1400-1420行)
@pytest.mark.parametrize("array_namespace, device, _", yield_namespace_device_dtype_combinations())
def test_confusion_matrix_array_api(array_namespace, device, _):
xp = _array_api_for_tests(array_namespace, device)
y_true = xp.asarray([1, 2, 3], device=device)
y_pred = xp.asarray([4, 5, 6], device=device)
labels = xp.asarray([1, 2, 3], device=device)
with config_context(array_api_dispatch=True):
result = confusion_matrix(y_true, y_pred, labels=labels)
assert get_namespace(result)[0] == get_namespace(y_pred)[0]
assert array_api_device(result) == array_api_device(y_pred)
测试策略:
-
yield_namespace_device_dtype_combinations()生成(NumPy/CuPy/PyTorch/JAX/Dask, CPU/GPU, dtype)组合。 -
@pytest.mark.parametrize注入array_namespace, device, dtype。 -
config_context(array_api_dispatch=True)开启派发。 -
断言三要素:数值一致、命名空间一致、设备一致。
30.11 测试体系:边界条件、零除处理、警告语义与多后端验证
30.11.1 核心测试模式
源码路径:sklearn/metrics/tests/test_classification.py - make_prediction()(第20-50行)
def make_prediction(dataset=None, binary=False):
"""用 CalibratedClassifierCV(SVC) 在 Iris 数据集上生成真实预测数据"""
if dataset is None:
dataset = datasets.load_iris()
X, y = dataset.data, dataset.target
if binary:
X, y = X[y < 2], y[y < 2]
# 打乱、添加噪声特征、训练、预测
return y_true, y_pred, y_pred_proba
设计意图:用真实模型生成“真实世界”预测数据(含错误、不确定性),而非手工构造极端案例,覆盖 binary/multiclass 场景。
源码路径:sklearn/metrics/tests/test_classification.py - test_precision_recall_fscore_support()(第50-100行)
def test_precision_recall_fscore_support():
y_true, y_pred, _ = make_prediction(binary=False)
p, r, f, s = precision_recall_fscore_support(y_true, y_pred, average=None)
assert_array_almost_equal(p, [0.83, 0.33, 0.42], 2)
assert_array_almost_equal(r, [0.79, 0.09, 0.90], 2)
assert_array_almost_equal(f, [0.81, 0.15, 0.57], 2)
assert_array_equal(s, [24, 31, 20])
# ... micro/macro/weighted 平均验证 ...
30.11.2 零除策略四模式全覆盖
源码路径:sklearn/metrics/tests/test_classification.py - test_zero_division_nan_no_warning 等(第600-630行)
@pytest.mark.parametrize("zero_division", [0, 1, np.nan])
@pytest.mark.parametrize("y_true, y_pred", [([0], [0])])
@pytest.mark.parametrize("metric", [f1_score, partial(fbeta_score, beta=1), precision_score, recall_score])
def test_zero_division_nan_no_warning(metric, y_true, y_pred, zero_division):
with warnings.catch_warnings():
warnings.simplefilter("error")
result = metric(y_true, y_pred, zero_division=zero_division)
if np.isnan(zero_division):
assert np.isnan(result)
else:
assert result == zero_division
@pytest.mark.parametrize("metric", [...])
def test_zero_division_nan_warning(metric, y_true, y_pred):
with pytest.warns(UndefinedMetricWarning):
result = metric(y_true, y_pred, zero_division="warn")
assert result == 0.0
四模式验证矩阵:
| zero_division | 返回值 | 是否警告 | 测试用例 |
|-----------------|--------|----------|----------|
| 0 | 0.0 | 否 | test_zero_division_nan_no_warning |
| 1 | 1.0 | 否 | test_zero_division_nan_no_warning |
| np.nan | np.nan | 否 | test_zero_division_nan_no_warning |
| "warn" | 0.0 | 是 (UndefinedMetricWarning) | test_zero_division_nan_warning |
30.11.3 典型边界场景测试矩阵
| 测试用例 | 覆盖场景 | 关键断言 |
|----------|----------|----------|
| test_precision_recall_f_binary_single_class | 单类别/全同预测 | precision_score([1,1],[1,1]) == 1.0 |
| test_confusion_matrix_multiclass_subset_labels | 标签子集/超集 | labels=[0,1] 仅输出 2×2 子矩阵 |
| test_multilabel_confusion_matrix_* | 稀疏 CSR/CSC、samplewise、sample_weight | 稀疏/稠密数值等价、TN 计算正确 |
| test_hinge_loss_multiclass_* | 多类缺失标签、形状校验 | labels 参数补全缺失类、形状不匹配报错 |
| test_log_loss_* | 完美预测≈0、非概率警告、pandas输入、标签顺序警告 | log_loss 完美预测接近 0、LabelBinarizer 字典序警告 |
| test_brier_score_loss_* | 二分类/多类、scale_by_half、无效输入 | scale_by_half="auto" 二分类自动除 2 |
| test_likelihood_ratios_* | LR+/LR- 警告、错误、replace_undefined_by字典 | 字典分别指定 LR+/LR- 兜底值 |
30.11.4 Array API 合规性测试组织
源码路径:sklearn/metrics/tests/test_classification.py - test_probabilitic_metrics_array_api(第1422-1480行)
@pytest.mark.parametrize("prob_metric", [brier_score_loss, log_loss, d2_brier_score, d2_log_loss_score])
@pytest.mark.parametrize("str_y_true", [False, True])
@pytest.mark.parametrize("use_sample_weight", [False, True])
@pytest.mark.parametrize("array_namespace, device_, dtype_name", yield_namespace_device_dtype_combinations())
def test_probabilitic_metrics_array_api(prob_metric, str_y_true, use_sample_weight, array_namespace, device_, dtype_name):
xp = _array_api_for_tests(array_namespace, device_)
# binary + multiclass + multilabel + sample_weight 全排列验证
# ...
源码路径:sklearn/metrics/tests/test_classification.py - test_pos_label_in_brier_score_metrics_array_api(第1480-1500行)
@pytest.mark.parametrize("prob_metric", [brier_score_loss, d2_brier_score])
def test_pos_label_in_brier_score_metrics_array_api(prob_metric, array_namespace, device_, dtype_name):
"""检查非标准标签(如 2/0)下 pos_label 推断是否正确"""
xp = _array_api_for_tests(array_namespace, device_)
y_true_pos_1 = xp.asarray([1, 0, 1, 0], device=device_)
y_true_pos_2 = xp.asarray([2, 0, 2, 0], device=device_) # 正类为 2
y_prob = xp.asarray([0.5, 0.2, 0.7, 0.6], dtype=dtype_name, device=device_)
with config_context(array_api_dispatch=True):
metric_pos_1 = prob_metric(y_true_pos_1, y_prob)
metric_pos_2 = prob_metric(y_true_pos_2, y_prob)
assert metric_pos_1 == pytest.approx(metric_pos_2) # 自动推断 pos_label=max(label)
30.12 设计中的取舍
30.12.1 为什么 _check_targets 要把 multilabel 转为 CSR 稀疏矩阵,而 Array API 模式下不转?
回答:
-
历史遗留与性能:NumPy/SciPy 生态中,CSR 矩阵的逐列切片、乘法、计数操作高度优化,
multilabel_confusion_matrix的 ND 路径依赖multiply与_count_nonzero,稀疏格式极大节省内存与计算量。 -
Array API 标准缺口:Array API 标准(及 CuPy/PyTorch/JAX 实现)目前不支持稀疏数组。
_check_targets中if _is_numpy_namespace(xp):守卫确保仅 NumPy 后端转稀疏,其他后端保持稠密布尔矩阵,由_count_nonzero的 Array API 实现处理。 -
Trade-off:NumPy 享受稀疏加速,其他后端接受稠密存储开销,换取统一代码路径与跨后端一致性。
30.12.2 为什么 confusion_matrix 要转 NumPy CPU 用 SciPy coo_matrix,而不是直接用 Array API 实现?
回答:
-
算法复杂度与常数因子:
coo_matrix构造函数内部是高度优化的 C 代码,利用coo格式的(row, col, data)三元组直接累加,无需原子操作或排序,单线程下极快。 -
Array API 缺乏等价原语:标准中无稀疏矩阵构造器,稠密实现需分配
n_classes²矩阵并做原子加或排序归约,大类别数时显存/计算开销大得多。 -
Trade-off:跨设备搬运(GPU→CPU→GPU)有开销,但对于典型分类任务(类别数 < 1000),SciPy 累加快得多,净收益为正。大规模类别场景(如极大规模多标签)可能受限,但属于少数。
30.12.3 为什么 precision_recall_fscore_support 要用统一 F-beta 公式而不先算 P/R 再调和平均?
回答:
-
数值稳定性:当
TP=0且FP>0, FN>0时,P=0, R=0,调和平均2PR/(P+R)面临0/0;统一公式分母(1+β²)TP + β²FN + FP = β²FN + FP > 0,天然避免二次除零。 -
性能:少一次除法、一次加权调和平均计算。
-
语义一致性:直接从充分统计量(TP/FP/FN)导出,
beta=0/inf边界自然退化为precision/recall,无需特判。
30.12.4 为什么 class_likelihood_ratios 仅支持二分类,且 replace_undefined_by 支持字典?
回答:
-
统计学原理限制:LR+/LR- 基于敏感度/特异度定义,天然是二分类概念。多类扩展易陷入 Simpson's Paradox(分层聚合方向相反),scikit-learn 选择显式报错而非给出误导性数值。
-
临床实践需求:医学诊断中,LR+(确诊力)与 LR-(排除力)的临床含义截然不同,用户常需分别设定兜底值(如
LR+ = inf表示完美确诊,LR- = 0表示完美排除),字典格式满足此精细控制需求。
30.13 动手练习
-
阅读分类指标核心计算引擎实现
阅读
sklearn/metrics/_classification.py中precision_recall_fscore_support函数(约1180-1340行),理解:-
它如何复用
multilabel_confusion_matrix获取tp_sum、pred_sum、true_sum? -
average='micro'时如何将三个统计量全局求和? -
F-beta 分数的统一公式
f = (1+β²) * tp / ((1+β²)*tp + β²*fn + fp)如何避免精度/召回再除法? -
_prf_divide如何处理除零、填充零除值、触发警告?
回答问题:
-
average='weighted'和average='samples'时,_nanaverage的weights参数分别取什么值? -
beta=0和beta=inf时,fbeta_score分别退化为哪个基础指标?内部如何显式处理?
-
-
对比混淆矩阵与多标签混淆矩阵的实现差异
阅读
confusion_matrix(约342-460行)与multilabel_confusion_matrix(约462-580行),对比:-
confusion_matrix如何通过need_index_conversion将任意标签映射为连续索引,并用scipy.sparse.coo_matrix高效累加? -
multilabel_confusion_matrix在y_true.ndim==1(二分类/多类)与ndim>1(多标签)下的两条计算路径差异? -
samplewise=True时,聚合轴如何切换?sample_weight在逐样本模式下如何参与 TN 计算?
回答问题:
-
confusion_matrix的normalize='true'|'pred'|'all'分别对应哪个轴的归一化? -
multilabel_confusion_matrix为何在y_true.ndim==1路径下使用_bincount,而在ndim>1路径下使用_count_nonzero?
-
-
探究概率型指标的数值稳定性与多后端兼容
阅读
_log_loss(约1662-1675行)、brier_score_loss(约1750-1860行)及_validate_multiclass_probabilistic_prediction(约195-260行),分析:-
_log_loss中eps = xp.finfo(y_pred.dtype).eps与xp.clip(y_pred, eps, 1-eps)如何防止log(0)产生-inf? -
brier_score_loss如何在二分类(y_proba.ndim==1)与多类别(ndim==2)间统一计算?scale_by_half='auto'的自动判断逻辑是什么? -
所有概率型指标开头如何通过
get_namespace_and_device与move_to实现跨后端/设备统一?
回答问题:
-
_validate_multiclass_probabilistic_prediction中LabelBinarizer的classes_顺序与y_prob列顺序的对齐要求是什么?不一致时如何报错? -
d2_log_loss_score与d2_brier_score如何构造“空模型”基线y_pred_null?为何取sample_weight加权的类别先验概率?
-
-
实战:分类报告生成与零除处理
阅读
classification_report(约1350-1500行)与test_classification_report_zero_division_warning测试用例,分析:-
classification_report如何根据y_type决定显示哪些平均行?micro_is_accuracy判断逻辑是什么? -
output_dict=True时,返回的字典结构如何组织逐类指标与平均指标? -
零除策略如何通过
zero_division参数传递到底层 PRF 引擎?
回答问题:
-
当
labels参数包含训练数据中不存在的标签时,报告中对应行的support为何显示为 0? -
multilabel-indicator任务下,为何额外显示samples avg而不显示accuracy行?
-
-
动手:验证类似然比的边界行为与自定义兜底值
阅读
class_likelihood_ratios(约2050-2200行)与对应测试test_likelihood_ratios_*,完成:-
构造
y_true=[1,1,0], y_pred=[1,0,0]导致fp=0的场景,验证replace_undefined_by={'LR+': np.inf, 'LR-': 0.0}的效果。 -
构造
y_true=[1,0,0], y_pred=[1,1,1]导致tn=0的场景,验证replace_undefined_by={'LR-': np.nan}的效果。 -
尝试传入非二分类数据(如三分类),观察报错信息。
回答问题:
-
当
support_pos == 0(y_true中无正样本)时,LR+ 与 LR- 均为 NaN,这是否合理? -
为何
replace_undefined_by支持字典格式分别指定 LR+/LR- 的兜底值,而不像其他指标仅支持标量?
-
30.14 本章小结
本章我们学习了以下概念:
| 概念 | 解释 |
|------|------|
| _check_targets | 分类指标的安检门,统一输入格式、识别任务类型、校验样本数一致性 |
| accuracy_score | 准确率,multilabel下计算子集准确率(整行完全匹配),其余逐元素比较 |
| confusion_matrix | 混淆矩阵,利用COO稀疏矩阵高效累加计数,支持三种归一化模式 |
| multilabel_confusion_matrix | 逐类/逐样本2x2混淆矩阵,基于布尔运算与加权计数构建TP/FP/FN/TN |
| cohen_kappa_score | Cohen's Kappa一致性系数,支持线性/二次加权,未定义情况可配置替代值 |
| precision_recall_fscore_support | 核心PRF计算引擎,复用MCM统计量,支持micro/macro/weighted/samples多种平均策略 |
| _prf_divide | 安全除法处理零除,支持0/1/NaN/warn四种策略,动态生成上下文相关警告 |
| log_loss | 对数损失/交叉熵,校验概率有效性、裁剪极值、One-Hot编码标签 |
| brier_score_loss | Brier分数,均方误差形式,二分类默认除以2映射到[0,1],支持多类 |
| d2_log_loss_score/d2_brier_score | 可解释性R²指标,1 - 模型损失/空模型损失,空模型预测类别先验概率 |
| jaccard_score | Jaccard相似系数,tp/(tp+fp+fn),基于MCM元素计算,支持多平均策略 |
| hamming_loss | Hamming损失,逐标签错误率,multilabel下区别于子集0-1损失 |
| matthews_corrcoef | Matthews相关系数,基于混淆矩阵协方差形式,二分类等价Pearson,多类Gorodkin推广 |
| hinge_loss | Hinge损失,二分类用margin=y_true*pred_decision,多类用Crammer-Singer方法 |
| class_likelihood_ratios | 医学诊断LR+/LR-,仅binary,基于混淆矩阵tn/fp/fn/tp计算,支持字典分别配置兜底值 |
| classification_report | 文本/字典报告生成,动态聚合micro/macro/weighted/samples平均,micro等同accuracy时改显accuracy行 |
| balanced_accuracy_score | 平衡准确率,逐类召回率平均,可调整随机基线 |
| _average_binary_score | 二分类指标在多标签上的通用平均逻辑,micro展平矩阵复制权重,weighted按真实正样本数加权 |
| _average_multiclass_ovo_score | 多类两两类别法平均,计算所有类别对的二分类指标均值,weighted按类别对出现比例加权 |
| Array API兼容 | 通过get_namespace_and_device/move_to实现跨NumPy/CuPy/PyTorch/JAX/Dask统一计算,全程使用xp.*/xpx.* |
| 测试体系 | 覆盖边界条件(单类别、空预测、标签子集、稀疏、字符串、Unicode)、零除四策略、警告语义、多后端一致性验证 |
下一章中,我们将学习回归评估指标 —— 衡量“预测与真实之间的差距”,包括 MAE/MSE/RMSE、决定系数 R²、Tweedie/Poisson/Gamma 偏差、Pinball 损失与 MAPE 等核心回归度量的实现原理。
30.15 架构与数据流图
上述图分别展示模块依赖、调用时序、数据流和架构分层。
第 31 章 —— 回归评估指标 —— 衡量“预测与真实之间的差距”
31.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解回归指标MAE、MSE、RMSE的计算原理与使用场景
-
掌握决定系数R²和解释方差的数学含义及区别
-
了解基于偏差的损失函数Tweedie、Poisson、Gamma的统一框架
-
熟悉分位数回归指标Pinball损失和MAPE的鲁棒性特点
-
能够阅读并修改sklearn/metrics/_regression.py中的回归评估指标实现
-
理解输入验证函数在回归指标中的作用及其设计原则
-
掌握解释力指标与偏差解释指标的计算机制
-
熟悉多输出情况下的加权平均与方差加权平均策略
31.2 生活类比
想象回归评估指标是一套精密的测量工具箱:MAE/MSE/RMSE = 尺子、卡尺、千分尺(不同精度的长度测量);R²/解释方差 = 百分比刻度尺(衡量解释力度占比);Tweedie偏差家族 = 可调节的万能量规(通过power参数适配不同分布);Pinball损失/MAPE = 特殊场景量具(分位数评估/相对误差测量);中位数绝对误差 = 抗干扰的测量仪(忽略极端异常值);最大误差 = 最坏情况检测器(捕捉单个最大偏差)。就像工程师根据测量对象选择合适的工具(厚度用卡尺、长度用尺子、微小间隙用千分尺),数据科学家也需根据问题特性选择合适的回归指标来量化模型性能。
输入验证函数 _check_reg_targets 和 _check_reg_targets_with_floating_dtype 就像工具箱的“入库检验员”:所有测量工具(指标函数)在开始工作前,都必须先通过检验员的严格把关——检查样本数量是否一致、维度是否对齐、数据类型是否统一为浮点数、多输出权重格式是否合法。只有通过检验的“合格原料”才能进入后续计算流程,保证了整个工具箱输出的一致性与可靠性。
测试机制 则是这套工具箱的“质检部”:不仅要验证每个工具在标准样本(正态分布、线性数据)下读数准确,还要在极端工况(单样本、常数目标、负值输入、零值除法)下进行压力测试。质检部还会设计专门的实验,验证 Pinball 损失的理论最优性(经验分位数最小化损失),以及指标能否无缝集成到 GridSearchCV 等自动化调优流水线中。只有通过全维度质检的工具箱,才能交付给数据科学家投入生产使用。
31.3 源码地图
sklearn/metrics/_regression.py
├── 辅助函数
│ ├── _check_reg_targets # 回归目标基础检查
│ ├── _check_reg_targets_with_floating_dtype # 自动选择浮点类型
│ ├── _assemble_fraction_of_explained_deviance # 解释方差/R²通用实现
│ └── _mean_tweedie_deviance # Tweedie偏差核心计算
├── 基础误差指标
│ ├── mean_absolute_error # L1损失
│ ├── mean_squared_error # L2损失
│ ├── root_mean_squared_error # L2损失平方根
│ ├── mean_squared_log_error # 对数空间L2损失
│ ├── root_mean_squared_log_error # 对数空间L2损失平方根
│ ├── median_absolute_error # 中位数绝对误差
│ ├── mean_absolute_percentage_error # 相对百分比误差
│ ├── mean_pinball_loss # 分位数损失
│ └── max_error # 最大残差
├── 解释力指标
│ ├── r2_score # 决定系数
│ └── explained_variance_score # 解释方差
├── 偏差解释指标
│ ├── d2_tweedie_score # Tweedie偏差解释度
│ ├── d2_pinball_score # Pinball损失解释度
│ └── d2_absolute_error_score # 绝对误差解释度
└── 分布特化偏差
├── mean_tweedie_deviance # 通用Tweedie偏差
├── mean_poisson_deviance # 泊松偏差 (power=1)
└── mean_gamma_deviance # 伽马偏差 (power=2)
31.4 基础回归指标:MAE、MSE 与 RMSE
31.4.1 核心概念:什么是 mean_absolute_error 与 mean_squared_error?
MAE 计算预测值与真实值的绝对差的平均值,对异常值不敏感;MSE 计算平方差的平均值,放大了大误差的影响;RMSE 是 MSE 的平方根,具有与目标变量相同的单位,便于解释。所有函数通过 _check_reg_targets_with_floating_dtype 进行输入验证和类型统一。支持 sample_weight 和 multioutput 参数,实现加权和多输出平均。多输出情况下,uniform_average 对所有输出均匀求平均,raw_values 返回每个输出的独立得分。
31.4.2 类型定义详解:输入验证与聚合策略
在深入具体函数前,我们先看看回归指标通用的输入验证与聚合模式。这是所有指标的“设计图纸”,定义了数据如何被标准化、加权与聚合。
# 第 31 章 —— 统一的参数验证装饰器模式
@validate_params(
{
"y_true": ["array-like"],
"y_pred": ["array-like"],
"sample_weight": ["array-like", None],
"multioutput": [StrOptions({"raw_values", "uniform_average"}), "array-like"],
},
prefer_skip_nested_validation=True,
)
这个装饰器确保所有回归指标接受统一的参数格式:y_true 和 y_pred 为数组,sample_weight 可选,multioutput 控制聚合方式。validate_params 装饰器在运行时自动执行类型检查,prefer_skip_nested_validation=True 避免对嵌套结构重复验证,提升性能。StrOptions 限制字符串参数的合法取值,"array-like" 允许用户传入自定义权重数组。这种声明式验证模式统一了全模块的参数契约,是 scikit-learn 1.0+ 推行的参数验证标准。
31.4.3 逐行解析:mean_absolute_error 核心实现
源码路径:sklearn/metrics/_regression.py - mean_absolute_error()(第80-140行)
def mean_absolute_error(
y_true, y_pred, *, sample_weight=None, multioutput="uniform_average"
):
# ① 获取数组命名空间(支持NumPy/CuPy/PyTorch等后端)
xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput)
# ② 输入验证、类型统一、维度对齐、权重检查
_, y_true, y_pred, sample_weight, multioutput = (
_check_reg_targets_with_floating_dtype(
y_true, y_pred, sample_weight, multioutput, xp=xp
)
)
# ③ 计算每个输出维度的加权平均绝对误差
output_errors = _average(
xp.abs(y_pred - y_true), weights=sample_weight, axis=0, xp=xp
)
# ④ 处理multioutput参数:raw_values直接返回,uniform_average转为None
if isinstance(multioutput, str):
if multioutput == "raw_values":
return output_errors
elif multioutput == "uniform_average":
# pass None as weights to _average: uniform mean
multioutput = None
# ⑤ 跨输出维度加权平均,返回标量
mean_absolute_error = _average(output_errors, weights=multioutput, xp=xp)
return float(mean_absolute_error)
这段代码定义了 MAE 的核心计算流程:输入验证 → 逐样本绝对差 → 样本加权平均 → 输出加权平均。关键在于 _check_reg_targets_with_floating_dtype 统一了数据类型和形状,_average 处理了样本权重与输出权重的两级聚合。
31.4.4 逐行解析:mean_squared_error 核心实现
源码路径:sklearn/metrics/_regression.py - mean_squared_error()(第180-240行)
def mean_squared_error(
y_true,
y_pred,
*,
sample_weight=None,
multioutput="uniform_average",
):
xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput)
_, y_true, y_pred, sample_weight, multioutput = (
_check_reg_targets_with_floating_dtype(
y_true, y_pred, sample_weight, multioutput, xp=xp
)
)
# 核心差异:平方差而非绝对差
output_errors = _average(
(y_true - y_pred) ** 2, axis=0, weights=sample_weight, xp=xp
)
if isinstance(multioutput, str):
if multioutput == "raw_values":
return output_errors
elif multioutput == "uniform_average":
multioutput = None
mean_squared_error = _average(output_errors, weights=multioutput, xp=xp)
return float(mean_squared_error)
这段代码实现了 MSE 的核心逻辑:(y_true - y_pred) ** 2 计算平方差,放大大误差的影响。其余流程与 MAE 完全一致,体现了统一的设计模式。
31.4.5 逐行解析:root_mean_squared_error 核心实现
源码路径:sklearn/metrics/_regression.py - root_mean_squared_error()(第250-310行)
def root_mean_squared_error(
y_true, y_pred, *, sample_weight=None, multioutput="uniform_average"
):
xp, _ = get_namespace(y_true, y_pred, sample_weight, multioutput)
# 复用 MSE 计算,取平方根
output_errors = xp.sqrt(
mean_squared_error(
y_true, y_pred, sample_weight=sample_weight, multioutput="raw_values"
)
)
if isinstance(multioutput, str):
if multioutput == "raw_values":
return output_errors
elif multioutput == "uniform_average":
multioutput = None
root_mean_squared_error = _average(output_errors, weights=multioutput, xp=xp)
return float(root_mean_squared_error)
这段代码展示了 RMSE 的实现策略:直接复用 mean_squared_error(..., multioutput="raw_values") 获取各输出的 MSE,再取平方根,最后聚合。这种组合复用避免了重复代码,保证了数值一致性。
31.4.6 数据流图:基础误差指标的计算流程
图解说明:该流程图展示了基础回归指标(MAE、MSE、RMSE)共享的统一计算管道。所有指标首先经过 _check_reg_targets_with_floating_dtype 进行输入标准化(形状对齐、浮点类型统一、样本权重验证),然后根据指标类型计算逐样本误差(绝对差、平方差或平方根),接着通过 _average 按样本维度加权平均得到各输出的误差向量 output_errors,最后根据 multioutput 参数决定是直接返回向量、均匀平均还是加权平均。这种两级聚合设计(样本级→输出级)保证了多输出场景下的灵活性与一致性。
31.5 对数空间与鲁棒指标:MSLE、RMSLE 与 Median AE
31.5.1 核心概念:何时使用对数空间误差与中位数绝对误差?
MSLE/RMSLE 在对数空间计算误差,适用于目标值呈指数增长或跨越多个数量级的场景。要求 y_true > -1 且 y_pred > -1,因为 log1p 定义域限制。Median AE 使用中位数而非平均值,对极端异常值具有天然鲁棒性。支持样本权重时通过 _weighted_percentile 计算加权中位数。所有指标统一使用 array API 后端支持(如 CuPy、PyTorch),通过 get_namespace 自动适配。
31.5.2 逐行解析:mean_squared_log_error 核心实现
源码路径:sklearn/metrics/_regression.py - mean_squared_log_error()(第310-380行)
def mean_squared_log_error(
y_true,
y_pred,
*,
sample_weight=None,
multioutput="uniform_average",
):
xp, _ = get_namespace(y_true, y_pred)
_, y_true, y_pred, sample_weight, multioutput = (
_check_reg_targets_with_floating_dtype(
y_true, y_pred, sample_weight, multioutput, xp=xp
)
)
# 定义域检查:log1p 要求输入 > -1
if xp.any(y_true <= -1) or xp.any(y_pred <= -1):
raise ValueError(
"Mean Squared Logarithmic Error cannot be used when "
"targets contain values less than or equal to -1."
)
# 核心技巧:在 log1p 空间计算 MSE
return mean_squared_error(
xp.log1p(y_true),
xp.log1p(y_pred),
sample_weight=sample_weight,
multioutput=multioutput,
)
这段代码实现了 MSLE 的核心逻辑:先验证定义域(y_true > -1 且 y_pred > -1),然后在 log1p 空间计算 MSE。log1p(x) = log(1+x) 对小数值更精确,适合处理跨数量级的目标变量。
31.5.3 逐行解析:root_mean_squared_log_error 核心实现
源码路径:sklearn/metrics/_regression.py - root_mean_squared_log_error()(第380-430行)
def root_mean_squared_log_error(
y_true, y_pred, *, sample_weight=None, multioutput="uniform_average"
):
xp, _ = get_namespace(y_true, y_pred)
_, y_true, y_pred, sample_weight, multioutput = (
_check_reg_targets_with_floating_dtype(
y_true, y_pred, sample_weight, multioutput, xp=xp
)
)
if xp.any(y_true <= -1) or xp.any(y_pred <= -1):
raise ValueError(
"Root Mean Squared Logarithmic Error cannot be used when "
"targets contain values less than or equal to -1."
)
return root_mean_squared_error(
xp.log1p(y_true),
xp.log1p(y_pred),
sample_weight=sample_weight,
multioutput=multioutput,
)
这段代码实现了 RMSLE:复用 root_mean_squared_error 在 log1p 空间计算。与 MSLE 同理,定义域检查是关键。
31.5.4 逐行解析:median_absolute_error 核心实现
源码路径:sklearn/metrics/_regression.py - median_absolute_error()(第430-490行)
def median_absolute_error(
y_true, y_pred, *, multioutput="uniform_average", sample_weight=None
):
xp, _ = get_namespace(y_true, y_pred, multioutput, sample_weight)
_, y_true, y_pred, sample_weight, multioutput = _check_reg_targets(
y_true, y_pred, sample_weight, multioutput
)
# 核心差异:中位数 vs 平均数
if sample_weight is None:
output_errors = _median(xp.abs(y_pred - y_true), axis=0)
else:
# 加权中位数实现
output_errors = _weighted_percentile(
xp.abs(y_pred - y_true), sample_weight=sample_weight, average=True
)
if isinstance(multioutput, str):
if multioutput == "raw_values":
return output_errors
elif multioutput == "uniform_average":
multioutput = None
return float(_average(output_errors, weights=multioutput, xp=xp))
这段代码实现了 Median AE:无权重时用 _median,有权重时用 _weighted_percentile 计算加权中位数。中位数对极端异常值不敏感,是鲁棒统计的核心工具。
31.5.5 数据流图:对数空间与鲁棒指标计算流程
图解说明:该流程图对比了对数空间指标(MSLE/RMSLE)与鲁棒指标(Median AE)的计算路径分歧。对数空间指标的核心是定义域检查(y > -1)与 log1p 变换,随后复用标准 MSE/RMSE 逻辑;而 Median AE 的核心是计算绝对残差后的聚合策略差异:无样本权重时使用 _median 直接计算中位数,有样本权重时调用 _weighted_percentile 计算加权中位数。两条路径最终汇聚到统一的 multioutput 聚合层,体现了“验证→变换→计算→聚合”的统一范式。
31.6 相对误差与最大误差:MAPE 与 Max Error
31.6.1 核心概念:MAPE 与 Max Error 如何补充传统误差指标?
MAPE 计算相对绝对误差,直观反映业务层面的预测偏差百分比。引入 epsilon 防止除零,y_true=0 时返回极大值而非 inf。Max Error 仅关注单个最大残差,评估最坏情况表现,不支持多输出。两者均通过 _check_reg_targets_with_floating_dtype 进行输入验证。测试覆盖极端情况:单样本、常数目标、负值输入(对 MSLE/MAPE 的特殊限制)。
31.6.2 逐行解析:mean_absolute_percentage_error 核心实现
源码路径:sklearn/metrics/_regression.py - mean_absolute_percentage_error()(第200-260行)
def mean_absolute_percentage_error(
y_true, y_pred, *, sample_weight=None, multioutput="uniform_average"
):
xp, _, device_ = get_namespace_and_device(
y_true, y_pred, sample_weight, multioutput
)
_, y_true, y_pred, sample_weight, multioutput = (
_check_reg_targets_with_floating_dtype(
y_true, y_pred, sample_weight, multioutput, xp=xp
)
)
# 关键:epsilon 防止除零
epsilon = xp.asarray(xp.finfo(xp.float64).eps, dtype=y_true.dtype, device=device_)
y_true_abs = xp.abs(y_true)
mape = xp.abs(y_pred - y_true) / xp.maximum(y_true_abs, epsilon)
output_errors = _average(mape, weights=sample_weight, axis=0, xp=xp)
if isinstance(multioutput, str):
if multioutput == "raw_values":
return output_errors
elif multioutput == "uniform_average":
multioutput = None
mean_absolute_percentage_error = _average(output_errors, weights=multioutput, xp=xp)
return float(mean_absolute_percentage_error)
这段代码实现了 MAPE:xp.maximum(y_true_abs, epsilon) 将分母下限限制为机器 epsilon,避免除零产生 inf。当 y_true=0 时,误差变为 |y_pred| / epsilon,一个极大但有限的值。
31.6.3 逐行解析:max_error 核心实现
源码路径:sklearn/metrics/_regression.py - max_error()(第490-520行)
def max_error(y_true, y_pred):
xp, _ = get_namespace(y_true, y_pred)
y_type, y_true, y_pred, _, _ = _check_reg_targets(
y_true, y_pred, sample_weight=None, multioutput=None, xp=xp
)
# 仅支持单输出
if y_type == "continuous-multioutput":
raise ValueError("Multioutput not supported in max_error")
return float(xp.max(xp.abs(y_true - y_pred)))
这段代码实现了 Max Error:仅支持单输出回归,返回最大绝对残差。不支持样本权重和多输出,设计意图是作为“最坏情况”诊断工具。
31.6.4 对比表格:MAPE 与 Max Error 的设计差异
以下是 MAPE 与 Max Error 的关键设计差异,二者分别服务于“业务相对误差监控”与“最坏情况诊断”两大截然不同的场景:
| 特性 | MAPE | Max Error |
|------|------|-----------|
| 核心公式 | mean(|y_pred - y_true| / max(|y_true|, eps)) | max(|y_pred - y_true|) |
| 多输出支持 | 支持 | 不支持 |
| 样本权重 | 支持 | 不支持 |
| 除零处理 | epsilon 机制 | 不涉及除法 |
| 适用场景 | 业务相对误差监控 | 最坏情况诊断 |
31.6.5 数据流图:MAPE 与 Max Error 计算流程
图解说明:MAPE 的核心在于 epsilon 机制处理除零风险,xp.maximum(y_true_abs, epsilon) 将分母钳制在机器精度以上,使得 y_true=0 时返回极大有限值而非 inf,保证了数值稳定性与梯度计算的可行性。Max Error 则极度简化:跳过样本权重与多输出逻辑,直接在单输出场景下计算最大绝对残差,定位为“最坏情况诊断工具”而非通用评估指标。
31.7 决定系数 R² 与解释方差
31.7.1 核心概念:R² 和解释方差有何区别与联系?
R² 衡量模型对目标方差的解释比例,取值范围 (-∞, 1],1 为完美拟合。解释方差忽略系统性偏差(如截距),仅衡量方差解释能力。当预测残差均值为零时,两者等价;否则 R² 更全面。通过 _assemble_fraction_of_explained_deviance 实现多输出聚合与非有限值处理。参数 force_finite=True 默认将 NaN/-Inf 替换为 1.0/0.0,避免网格搜索失败。支持 variance_weighted 模式,按每个输出的方差加权平均得分。
31.7.2 类型定义详解:解释力指标的通用聚合器
_assemble_fraction_of_explained_deviance 是 R² 和解释方差的共享“引擎”,处理分数计算、非有限值修正、多输出聚合。
源码路径:sklearn/metrics/_regression.py - _assemble_fraction_of_explained_deviance()(第290-340行)
def _assemble_fraction_of_explained_deviance(
numerator, denominator, n_outputs, multioutput, force_finite, xp, device
):
"""Common part used by explained variance score and R² score."""
dtype = numerator.dtype
nonzero_denominator = denominator != 0
if not force_finite:
# 标准公式,可能产生 NaN 或 -Inf
output_scores = 1 - (numerator / denominator)
else:
nonzero_numerator = numerator != 0
# 默认情况:零分子 = 完美预测,设为 1.0
output_scores = xp.ones([n_outputs], device=device, dtype=dtype)
# 非零分子和非零分母:使用公式
valid_score = nonzero_denominator & nonzero_numerator
output_scores[valid_score] = 1 - (
numerator[valid_score] / denominator[valid_score]
)
# 非零分子但零分母:设为 0.0 避免 -inf
output_scores[nonzero_numerator & ~nonzero_denominator] = 0.0
# 多输出聚合策略
if isinstance(multioutput, str):
if multioutput == "raw_values":
return output_scores
elif multioutput == "uniform_average":
avg_weights = None
elif multioutput == "variance_weighted":
avg_weights = denominator
if not xp.any(nonzero_denominator):
# 所有权重为零(常数目标),回退到均匀权重
avg_weights = None
else:
avg_weights = multioutput
result = _average(output_scores, weights=avg_weights, xp=xp)
if size(result) == 1:
return float(result)
return result
这段代码实现了解释力指标的通用聚合逻辑:force_finite=True 时,完美预测(分子=0)返回 1.0,分母=0但分子≠0 返回 0.0,避免 NaN/-Inf 污染超参数搜索。variance_weighted 使用目标方差作为权重。
31.7.3 逐行解析:explained_variance_score 核心实现
源码路径:sklearn/metrics/_regression.py - explained_variance_score()(第460-520行)
def explained_variance_score(
y_true,
y_pred,
*,
sample_weight=None,
multioutput="uniform_average",
force_finite=True,
):
xp, _, device = get_namespace_and_device(y_true, y_pred, sample_weight, multioutput)
_, y_true, y_pred, sample_weight, multioutput = (
_check_reg_targets_with_floating_dtype(
y_true, y_pred, sample_weight, multioutput, xp=xp
)
)
# 核心:残差方差 / 总方差
y_diff_avg = _average(y_true - y_pred, weights=sample_weight, axis=0, xp=xp)
numerator = _average(
(y_true - y_pred - y_diff_avg) ** 2, weights=sample_weight, axis=0, xp=xp
)
y_true_avg = _average(y_true, weights=sample_weight, axis=0, xp=xp)
denominator = _average(
(y_true - y_true_avg) ** 2, weights=sample_weight, axis=0, xp=xp
)
return _assemble_fraction_of_explained_deviance(
numerator=numerator,
denominator=denominator,
n_outputs=y_true.shape[1],
multioutput=multioutput,
force_finite=force_finite,
xp=xp,
device=device,
)
这段代码实现了解释方差:分子是去均值后的残差方差(减去了 y_diff_avg),分母是目标总方差。忽略了系统性偏移,所以当模型有非零截距偏差时,解释方差可能高于 R²。
31.7.4 逐行解析:r2_score 核心实现
源码路径:sklearn/metrics/_regression.py - r2_score()(第520-620行)
def r2_score(
y_true,
y_pred,
*,
sample_weight=None,
multioutput="uniform_average",
force_finite=True,
):
xp, _, device_ = get_namespace_and_device(
y_true, y_pred, sample_weight, multioutput
)
_, y_true, y_pred, sample_weight, multioutput = (
_check_reg_targets_with_floating_dtype(
y_true, y_pred, sample_weight, multioutput, xp=xp
)
)
if _num_samples(y_pred) < 2:
msg = "R^2 score is not well-defined with less than two samples."
warnings.warn(msg, UndefinedMetricWarning)
return float("nan")
if sample_weight is not None:
sample_weight = column_or_1d(sample_weight)
weight = sample_weight[:, None]
else:
weight = 1.0
# 核心差异:直接用加权平方和,不去均值残差
numerator = xp.sum(weight * (y_true - y_pred) ** 2, axis=0)
denominator = xp.sum(
weight * (y_true - _average(y_true, axis=0, weights=sample_weight, xp=xp)) ** 2,
axis=0,
)
return _assemble_fraction_of_explained_deviance(
numerator=numerator,
denominator=denominator,
n_outputs=y_true.shape[1],
multioutput=multioutput,
force_finite=force_finite,
xp=xp,
device=device_,
)
这段代码实现了 R²:分子是加权残差平方和(不减去残差均值),分母是加权总离差平方和。单样本时发出警告并返回 NaN。当残差均值为 0 时,分子等价于解释方差的分子,两者相等。
31.7.5 数学对比:R² vs 解释方差
图解说明:该图揭示了 R² 与解释方差的数学本质差异。R² 直接使用残差平方和 SSE 作为分子,包含了残差均值的平方项(系统性偏差);而解释方差先将残差去均值,分子仅为残差方差。当残差均值为零(即模型无系统性偏移)时,SSE = n × Var(residual),两者等价;否则 R² 会因系统性偏差而偏低,更严格地反映模型整体拟合质量。
31.8 基于偏差的损失:Tweedie 偏差家族
31.8.1 核心概念:如何用一个函数覆盖泊松、伽马、正态等多种分布?
Tweedie 偏差通过 power 参数统一多种分布:power=0(正态)、1(泊松)、2(伽马)。损失函数基于 Tweedie 分布的对数似然推导,支持极值分布(power < 0 或 > 2)。内部函数 _mean_tweedie_deviance 实现数值稳定的偏差计算,分段处理不同 power 区间。输入验证严格要求:power<0 时 y_pred>0;power∈[1,2) 时 y_true≥0 且 y_pred>0;power≥2 时 y_true>0 且 y_pred>0。通过 mean_poisson_deviance、mean_gamma_deviance 等包装函数提供常用分布的便捷接口。D² 分数族(如 d2_tweedie_score)进一步将偏差解释为可解释度量,类似 R²。
31.8.2 逐行解析:_mean_tweedie_deviance 核心计算
源码路径:sklearn/metrics/_regression.py - _mean_tweedie_deviance()(第600-620行)
def _mean_tweedie_deviance(y_true, y_pred, sample_weight, power):
"""Mean Tweedie deviance regression loss."""
xp, _ = get_namespace(y_true, y_pred)
p = power
if p < 0:
# 'Extreme stable', y any real number, y_pred > 0
dev = 2 * (
xp.pow(
xp.where(y_true > 0, y_true, 0.0),
2 - p,
)
/ ((1 - p) * (2 - p))
- y_true * xp.pow(y_pred, 1 - p) / (1 - p)
+ xp.pow(y_pred, 2 - p) / (2 - p)
)
elif p == 0:
# Normal distribution, y and y_pred any real number
dev = (y_true - y_pred) ** 2
elif p == 1:
# Poisson distribution
dev = 2 * (xlogy(y_true, y_true / y_pred) - y_true + y_pred)
elif p == 2:
# Gamma distribution
dev = 2 * (xp.log(y_pred / y_true) + y_true / y_pred - 1)
else:
# 1 < p < 2 or p > 2
dev = 2 * (
xp.pow(y_true, 2 - p) / ((1 - p) * (2 - p))
- y_true * xp.pow(y_pred, 1 - p) / (1 - p)
+ xp.pow(y_pred, 2 - p) / (2 - p)
)
return float(_average(dev, weights=sample_weight, xp=xp))
这段代码是 Tweedie 偏差的数学核心:根据 power 分段实现不同分布的偏差公式。p=0 退化为 MSE;p=1 使用 xlogy 处理 0*log(0) 边界情况;p=2 为伽马偏差(尺度不变)。分段设计保证了数值稳定性。
31.8.3 逐行解析:mean_tweedie_deviance 公开接口与输入验证
源码路径:sklearn/metrics/_regression.py - mean_tweedie_deviance()(第620-700行)
@validate_params(
{
"y_true": ["array-like"],
"y_pred": ["array-like"],
"sample_weight": ["array-like", None],
"power": [
Interval(Real, None, 0, closed="right"),
Interval(Real, 1, None, closed="left"),
],
},
prefer_skip_nested_validation=True,
)
def mean_tweedie_deviance(y_true, y_pred, *, sample_weight=None, power=0):
xp, _ = get_namespace(y_true, y_pred)
y_type, y_true, y_pred, sample_weight, _ = _check_reg_targets_with_floating_dtype(
y_true, y_pred, sample_weight, multioutput=None, xp=xp
)
if y_type == "continuous-multioutput":
raise ValueError("Multioutput not supported in mean_tweedie_deviance")
if sample_weight is not None:
sample_weight = column_or_1d(sample_weight)
sample_weight = sample_weight[:, np.newaxis]
message = f"Mean Tweedie deviance error with power={power} can only be used on "
if power < 0:
if xp.any(y_pred <= 0):
raise ValueError(message + "strictly positive y_pred.")
elif power == 0:
pass # 正态分布,无限制
elif 1 <= power < 2:
if xp.any(y_true < 0) or xp.any(y_pred <= 0):
raise ValueError(message + "non-negative y and strictly positive y_pred.")
elif power >= 2:
if xp.any(y_true <= 0) or xp.any(y_pred <= 0):
raise ValueError(message + "strictly positive y and y_pred.")
return _mean_tweedie_deviance(
y_true, y_pred, sample_weight=sample_weight, power=power
)
这段代码实现了公开接口:validate_params 限制 power <= 0 或 power >= 1;输入验证根据 power 区间检查 y_true/y_pred 的符号要求;不支持多输出;样本权重重塑为列向量用于广播。
31.8.4 逐行解析:mean_poisson_deviance 与 mean_gamma_deviance 便捷包装
源码路径:sklearn/metrics/_regression.py - mean_poisson_deviance()(第700-730行)、mean_gamma_deviance()(第730-760行)
def mean_poisson_deviance(y_true, y_pred, *, sample_weight=None):
"""Mean Poisson deviance regression loss. power=1"""
return mean_tweedie_deviance(y_true, y_pred, sample_weight=sample_weight, power=1)
def mean_gamma_deviance(y_true, y_pred, *, sample_weight=None):
"""Mean Gamma deviance regression loss. power=2"""
return mean_tweedie_deviance(y_true, y_pred, sample_weight=sample_weight, power=2)
这两段代码提供了语义化的便捷接口:泊松偏差用于计数数据,伽马偏差用于正连续数据且具有尺度不变性。
31.8.5 Tweedie Power 参数与分布对应表
以下是 power 参数对应的分布与输入要求:
| Power 值 | 分布名称 | y_true 要求 | y_pred 要求 | 典型应用场景 |
|----------|----------|-------------|-------------|--------------|
| < 0 | Extreme Stable | 任意实数 | > 0 | 极值建模 |
| 0 | Normal (Gaussian) | 任意实数 | 任意实数 | 一般回归 (等价 MSE) |
| 1 | Poisson | ≥ 0 | > 0 | 计数数据回归 |
| (1, 2) | Compound Poisson | ≥ 0 | > 0 | 零膨胀计数数据 |
| 2 | Gamma | > 0 | > 0 | 正连续数据、保费建模 |
| 3 | Inverse Gaussian | > 0 | > 0 | 生存时间建模 |
| > 2 | Positive Stable | > 0 | > 0 | 重尾正数据 |
31.8.6 Tweedie 偏差 Power 参数与分布决策树
图解说明:该决策树直观展示了 Tweedie power 参数如何映射到不同的概率分布族。从左到右依次覆盖:极值稳定分布(power<0)、正态分布(power=0)、泊松分布(power=1)、复合泊松(1<power<2)、伽马分布(power=2)、逆高斯分布(power=3)、正稳定分布(power>2)。每个分支标注了对应的 y_true/y_pred 符号约束与典型应用场景。validate_params 装饰器在代码层面强制执行了 power <= 0 或 power >= 1 的数学定义域约束,排除了 (0,1) 区间无对应分布的情况。
31.9 偏差解释度家族:D² Tweedie、Pinball 与绝对误差
31.9.1 核心概念:D² 分数如何将偏差转化为可解释的模型评分?
d2_tweedie_score 以 Tweedie 偏差为基础,分子为模型偏差,分母为常数模型偏差。d2_pinball_score 基于 Pinball 损失,分母使用经验分位数作为基准预测。d2_absolute_error_score 是 d2_pinball_score 在 α=0.5 时的特例,对应 MAE 解释度。统一使用 _assemble_fraction_of_explained_deviance 处理多输出聚合与非有限值。均要求样本数 ≥2,否则发出警告并返回 NaN。支持通过 make_scorer 集成到网格搜索等超参数优化流程。
31.9.2 逐行解析:d2_tweedie_score 核心实现
源码路径:sklearn/metrics/_regression.py - d2_tweedie_score()(第760-840行)
def d2_tweedie_score(y_true, y_pred, *, sample_weight=None, power=0):
xp, _ = get_namespace(y_true, y_pred)
y_type, y_true, y_pred, sample_weight, _ = _check_reg_targets_with_floating_dtype(
y_true, y_pred, sample_weight, multioutput=None, xp=xp
)
if y_type == "continuous-multioutput":
raise ValueError("Multioutput not supported in d2_tweedie_score")
if _num_samples(y_pred) < 2:
msg = "D^2 score is not well-defined with less than two samples."
warnings.warn(msg, UndefinedMetricWarning)
return float("nan")
y_true, y_pred = xp.squeeze(y_true, axis=1), xp.squeeze(y_pred, axis=1)
# 分子:模型偏差
numerator = mean_tweedie_deviance(
y_true, y_pred, sample_weight=sample_weight, power=power
)
# 分母:常数模型偏差 (预测为加权均值)
y_avg = _average(y_true, weights=sample_weight, xp=xp)
denominator = _mean_tweedie_deviance(
y_true, y_avg, sample_weight=sample_weight, power=power
)
return 1 - numerator / denominator
这段代码实现了 D² Tweedie:分子是模型的 Tweedie 偏差,分母是“预测加权均值”这一常数模型的 Tweedie 偏差。1 - numerator/denominator 形式与 R² 完全对应,只是把方差换成了偏差。
31.9.3 逐行解析:d2_pinball_score 核心实现
源码路径:sklearn/metrics/_regression.py - d2_pinball_score()(第840-930行)
def d2_pinball_score(
y_true, y_pred, *, sample_weight=None, alpha=0.5, multioutput="uniform_average"
):
xp, _, device_ = get_namespace_and_device(
y_true, y_pred, sample_weight, multioutput
)
_, y_true, y_pred, sample_weight, multioutput = (
_check_reg_targets_with_floating_dtype(
y_true, y_pred, sample_weight, multioutput, xp=xp
)
)
if _num_samples(y_pred) < 2:
msg = "D^2 score is not well-defined with less than two samples."
warnings.warn(msg, UndefinedMetricWarning)
return float("nan")
# 分子:模型 Pinball 损失
numerator = mean_pinball_loss(
y_true,
y_pred,
sample_weight=sample_weight,
alpha=alpha,
multioutput="raw_values",
)
if sample_weight is None:
sample_weight = xp.ones([y_true.shape[0]], dtype=y_true.dtype, device=device_)
# 分母:经验分位数基准的 Pinball 损失
y_quantile = xp.tile(
_weighted_percentile(
y_true,
sample_weight=sample_weight,
percentile_rank=alpha * 100,
average=True,
xp=xp,
),
(y_true.shape[0], 1),
)
denominator = mean_pinball_loss(
y_true,
y_quantile,
sample_weight=sample_weight,
alpha=alpha,
multioutput="raw_values",
)
return _assemble_fraction_of_explained_deviance(
numerator=numerator,
denominator=denominator,
n_outputs=y_true.shape[1],
multioutput=multioutput,
force_finite=True,
xp=xp,
device=device_,
)
这段代码实现了 D² Pinball:分母使用加权经验分位数 y_quantile 作为基准预测(而非均值)。这体现了分位数回归的核心思想:评估模型相对于“盲目预测分位数”有多少改进。
31.9.4 逐行解析:d2_absolute_error_score 核心实现
源码路径:sklearn/metrics/_regression.py - d2_absolute_error_score()(第930-980行)
def d2_absolute_error_score(
y_true, y_pred, *, sample_weight=None, multioutput="uniform_average"
):
return d2_pinball_score(
y_true, y_pred, sample_weight=sample_weight, alpha=0.5, multioutput=multioutput
)
这段代码揭示了本质:d2_absolute_error_score 就是 alpha=0.5 时的 d2_pinball_score,分母基准变为加权中位数。这与 MAE 是中位数的最优估计量在数学上一致。
31.9.5 数据流图:Pinball Loss 的计算流程
图解说明:该流程图详细展示了 mean_pinball_loss 从输入验证到最终聚合的完整数据流向。关键步骤在于向量化分段函数的实现:通过 sign = (diff >= 0).astype(dtype) 将残差符号转化为 0/1 掩码,然后利用 alpha * sign * diff - (1-alpha) * (1-sign) * diff 这一数学技巧,在无分支的情况下同时计算 diff >= 0 时的 alpha * diff 和 diff < 0 时的 (1-alpha) * (-diff)。这种向量化实现避免了显式条件判断,充分利用了数组后端(NumPy/CuPy/PyTorch)的并行计算能力,是高性能数值计算的典范模式。
31.9.6 D² 分数族统一框架图
图解说明:该框架图揭示了 D² 分数族的统一数学结构:所有 D² 指标均遵循 1 - Model_Deviance / Null_Deviance 的通用公式,仅在“偏差度量”的选择上有所不同。Tweedie 偏差族通过 power 参数覆盖从正态到伽马的连续分布谱系(power=0 时退化为标准 R²);Pinball 损失族通过 alpha 参数覆盖从中位数到极端分位数的鲁棒估计谱系(alpha=0.5 时退化为 MAE 解释度);绝对误差族则是 Pinball 族的特例。这种“分子=模型偏差,分母=基准模型偏差”的设计,使得 D² 分数在不同损失函数下都具有相同的可解释性:0 表示与基准模型持平,1 表示完美预测,负值表示比基准模型更差。
31.10 输入验证基石:_check_reg_targets 与浮点类型推断
31.10.1 核心概念:回归指标如何统一处理多样的输入格式?
_check_reg_targets 统一将 1D 输入重塑为 2D,校验样本数一致性、输出维度匹配。处理 multioutput 参数:字符串模式(raw_values/uniform_average/variance_weighted)或自定义权重数组。_check_reg_targets_with_floating_dtype 进一步引入 _find_matching_floating_dtype 自动推断计算精度。支持 array API 标准,兼容 NumPy、CuPy、PyTorch 等后端,通过 get_namespace 获取命名空间。返回标准化的 y_type(continuous/continuous-multioutput)、验证后的数组与参数。所有公开回归指标均依赖此验证层,保证输入合法性与计算一致性。
31.10.2 逐行解析:_check_reg_targets 核心逻辑
源码路径:sklearn/metrics/_regression.py - _check_reg_targets()(第30-80行)
def _check_reg_targets(
y_true, y_pred, sample_weight, multioutput, dtype="numeric", xp=None
):
"""Check that y_true, y_pred and sample_weight belong to the same regression task.
To reduce redundancy when calling `_find_matching_floating_dtype`,
please use `_check_reg_targets_with_floating_dtype` instead.
Parameters
----------
y_true : array-like of shape (n_samples,) or (n_samples, n_outputs)
Ground truth (correct) target values.
y_pred : array-like of shape (n_samples,) or (n_samples, n_outputs)
Estimated target values.
sample_weight : array-like of shape (n_samples,) or None
Sample weights.
multioutput : array-like or string in ['raw_values', uniform_average',
'variance_weighted'] or None
None is accepted due to backward compatibility of r2_score().
dtype : str or list, default="numeric"
the dtype argument passed to check_array.
xp : module, default=None
Precomputed array namespace module. When passed, typically from a caller
that has already performed inspection of its own inputs, skips array
namespace inspection.
Returns
-------
type_true : one of {'continuous', continuous-multioutput'}
The type of the true target data, as output by
'utils.multiclass.type_of_target'.
y_true : array-like of shape (n_samples, n_outputs)
Ground truth (correct) target values.
y_pred : array-like of shape (n_samples, n_outputs)
Estimated target values.
sample_weight : array-like of shape (n_samples,) or None
Sample weights.
multioutput : array-like of shape (n_outputs) or string in ['raw_values',
uniform_average', 'variance_weighted'] or None
Custom output weights if ``multioutput`` is array-like or
just the corresponding argument if ``multioutput`` is a
correct keyword.
"""
xp, _ = get_namespace(y_true, y_pred, multioutput, xp=xp)
check_consistent_length(y_true, y_pred, sample_weight)
y_true = check_array(y_true, ensure_2d=False, dtype=dtype)
y_pred = check_array(y_pred, ensure_2d=False, dtype=dtype)
if sample_weight is not None:
sample_weight = _check_sample_weight(sample_weight, y_true, dtype=dtype)
if y_true.ndim == 1:
y_true = xp.reshape(y_true, (-1, 1))
if y_pred.ndim == 1:
y_pred = xp.reshape(y_pred, (-1, 1))
if y_true.shape[1] != y_pred.shape[1]:
raise ValueError(
"y_true and y_pred have different number of output ({0}!={1})".format(
y_true.shape[1], y_pred.shape[1]
)
)
n_outputs = y_true.shape[1]
allowed_multioutput_str = ("raw_values", "uniform_average", "variance_weighted")
if isinstance(multioutput, str):
if multioutput not in allowed_multioutput_str:
raise ValueError(
"Allowed 'multioutput' string values are {}. "
"You provided multioutput={!r}".format(
allowed_multioutput_str, multioutput
)
)
elif multioutput is not None:
multioutput = check_array(multioutput, ensure_2d=False)
if n_outputs == 1:
raise ValueError("Custom weights are useful only in multi-output cases.")
elif n_outputs != multioutput.shape[0]:
raise ValueError(
"There must be equally many custom weights "
f"({multioutput.shape[0]}) as outputs ({n_outputs})."
)
y_type = "continuous" if n_outputs == 1 else "continuous-multioutput"
return y_type, y_true, y_pred, sample_weight, multioutput
这段代码是所有回归指标的统一入口:
-
长度一致性检查
-
check_array标准化数组(允许 1D) -
样本权重验证
-
1D → 2D 重塑
(n_samples, 1) -
输出维度匹配检查
-
multioutput参数验证与标准化 -
返回
y_type指示单/多输出
31.10.3 逐行解析:_check_reg_targets_with_floating_dtype 浮点类型推断
源码路径:sklearn/metrics/_regression.py - _check_reg_targets_with_floating_dtype()(第80-130行)
def _check_reg_targets_with_floating_dtype(
y_true, y_pred, sample_weight, multioutput, xp=None
):
"""Ensures y_true, y_pred, and sample_weight correspond to same regression task.
Extends `_check_reg_targets` by automatically selecting a suitable floating-point
data type for inputs using `_find_matching_floating_dtype`.
Use this private method only when converting inputs to array API-compatibles.
Parameters
----------
y_true : array-like of shape (n_samples,) or (n_samples, n_outputs)
Ground truth (correct) target values.
y_pred : array-like of shape (n_samples,) or (n_samples, n_outputs)
Estimated target values.
sample_weight : array-like of shape (n_samples,)
multioutput : array-like or string in ['raw_values', 'uniform_average', \
'variance_weighted'] or None
None is accepted due to backward compatibility of r2_score().
xp : module, default=None
Precomputed array namespace module. When passed, typically from a caller
that has already performed inspection of its own inputs, skips array
namespace inspection.
Returns
-------
type_true : one of {'continuous', 'continuous-multioutput'}
The type of the true target data, as output by
'utils.multiclass.type_of_target'.
y_true : array-like of shape (n_samples, n_outputs)
Ground truth (correct) target values.
y_pred : array-like of shape (n_samples, n_outputs)
Estimated target values.
sample_weight : array-like of shape (n_samples,), default=None
Sample weights.
multioutput : array-like of shape (n_outputs) or string in ['raw_values', \
'uniform_average', 'variance_weighted'] or None
Custom output weights if ``multioutput`` is array-like or
just the corresponding argument if ``multioutput`` is a
correct keyword.
"""
dtype_name = _find_matching_floating_dtype(y_true, y_pred, sample_weight, xp=xp)
y_type, y_true, y_pred, sample_weight, multioutput = _check_reg_targets(
y_true, y_pred, sample_weight, multioutput, dtype=dtype_name, xp=xp
)
return y_type, y_true, y_pred, sample_weight, multioutput
这段代码在基础验证之上增加了浮点类型自动推断:_find_matching_floating_dtype 根据输入数组的 dtype 选择合适的计算精度(如 float32/float64),避免精度丢失或不必要的类型提升。这是 Array API 兼容性的关键一步。
31.10.4 输入验证流程图
图解说明:该流程图完整描绘了回归指标输入验证的两层架构。外层 _check_reg_targets_with_floating_dtype 首先调用 _find_matching_floating_dtype 根据输入数组(y_true、y_pred、sample_weight)的 dtype 自动推断统一的浮点计算类型(如 float32/float64),这是 Array API 多后端兼容的关键;内层 _check_reg_targets 执行核心验证逻辑:长度一致性 → 数组标准化 → 样本权重检查 → 1D 转 2D 重塑 → 输出维度匹配 → multioutput 参数合法性校验。最终返回标准化的 y_type(区分单/多输出)、验证后的数组与参数,供下游指标计算直接使用。这种分层设计将“类型推断”与“结构验证”解耦,保证了验证逻辑的复用性与可维护性。
31.11 测试机制与边界场景
31.11.1 核心概念:为什么回归指标需要全面的测试覆盖?
通过参数化测试验证不同数据分布下的指标行为,确保数值稳定性。测试边界条件:单样本、常数目标、极端值输入等异常情况处理。验证多输出场景下的加权计算与聚合策略是否符合预期。检查输入验证函数的异常抛出机制与类型检查逻辑。确保解释力指标在力度有限与非有限模式下的一致性行为。验证分位数回归指标与MAE、中位数绝对误差之间的数学关系。
31.11.2 关键测试用例解析
源码路径:sklearn/metrics/tests/test_regression.py - test_regression_metrics()(第20-80行)
def test_regression_metrics(n_samples=50):
y_true = np.arange(n_samples)
y_pred = y_true + 1
y_pred_2 = y_true - 1
assert_almost_equal(mean_squared_error(y_true, y_pred), 1.0)
assert_almost_equal(
mean_squared_log_error(y_true, y_pred),
mean_squared_error(np.log(1 + y_true), np.log(1 + y_pred)),
)
assert_almost_equal(mean_absolute_error(y_true, y_pred), 1.0)
assert_almost_equal(mean_pinball_loss(y_true, y_pred), 0.5)
assert_almost_equal(mean_pinball_loss(y_true, y_pred_2), 0.5)
assert_almost_equal(mean_pinball_loss(y_true, y_pred, alpha=0.4), 0.6)
assert_almost_equal(mean_pinball_loss(y_true, y_pred_2, alpha=0.4), 0.4)
assert_almost_equal(median_absolute_error(y_true, y_pred), 1.0)
mape = mean_absolute_percentage_error(y_true, y_pred)
assert np.isfinite(mape)
assert mape > 1e6
assert_almost_equal(max_error(y_true, y_pred), 1.0)
assert_almost_equal(r2_score(y_true, y_pred), 0.995, 2)
assert_almost_equal(explained_variance_score(y_true, y_pred), 1.0)
assert_almost_equal(
mean_tweedie_deviance(y_true, y_pred, power=0),
mean_squared_error(y_true, y_pred),
)
assert_almost_equal(
d2_tweedie_score(y_true, y_pred, power=0), r2_score(y_true, y_pred)
)
...
这个测试用简单的线性数据验证了所有基础指标的正确性,并检查了关键数学关系:MSLE 等价于 log 空间 MSE、Pinball loss 在 α=0.5 时等于 MAE/2、Tweedie power=0 等价于 MSE、D² power=0 等价于 R²。
源码路径:sklearn/metrics/tests/test_regression.py - test_multioutput_regression()(第82-150行)
def test_multioutput_regression():
y_true = np.array([[1, 0, 0, 1], [0, 1, 1, 1], [1, 1, 0, 1]])
y_pred = np.array([[0, 0, 0, 1], [1, 0, 1, 1], [0, 0, 0, 1]])
...
error = r2_score(y_true, y_pred, multioutput="variance_weighted")
assert_almost_equal(error, 1.0 - 5.0 / 2)
...
# constant y_true with force_finite=True leads to 1. or 0.
yc = [5.0, 5.0]
error = r2_score(yc, [5.0, 5.0], multioutput="variance_weighted")
assert_almost_equal(error, 1.0)
error = r2_score(yc, [5.0, 5.1], multioutput="variance_weighted")
assert_almost_equal(error, 0.0)
...
这个测试验证了多输出场景下的聚合行为,特别是 variance_weighted 模式和常数目标时的 force_finite 处理。
源码路径:sklearn/metrics/tests/test_regression.py - test_regression_metrics_at_limits()(第382-440行)
def test_regression_metrics_at_limits():
# Single-sample case
assert_almost_equal(mean_squared_error([0.0], [0.0]), 0.0)
...
# Non-finite cases
for s in (r2_score, explained_variance_score):
assert_almost_equal(s([0, 0], [1, -1]), 0.0)
assert_almost_equal(s([0, 0], [1, -1], force_finite=False), -np.inf)
assert_almost_equal(s([1, 1], [1, 1]), 1.0)
assert_almost_equal(s([1, 1], [1, 1], force_finite=False), np.nan)
...
# Tweedie deviance error
power = -1.2
assert_allclose(
mean_tweedie_deviance([0], [1.0], power=power), 2 / (2 - power), rtol=1e-3
)
msg = "can only be used on strictly positive y_pred."
with pytest.raises(ValueError, match=msg):
mean_tweedie_deviance([0.0], [0.0], power=power)
这个测试覆盖了边界情况:单样本、常数目标(触发 NaN/-Inf 处理)、MSLE 定义域错误、Tweedie 不同 power 的输入验证。
源码路径:sklearn/metrics/tests/test_regression.py - test_mean_pinball_loss_on_constant_predictions()(第442-480行)
@pytest.mark.parametrize(
"distribution", ["normal", "lognormal", "exponential", "uniform"]
)
@pytest.mark.parametrize("target_quantile", [0.05, 0.5, 0.75])
def test_mean_pinball_loss_on_constant_predictions(
distribution, target_quantile, global_random_seed
):
# Check that the pinball loss is minimized by the empirical quantile.
n_samples = 3000
rng = np.random.RandomState(global_random_seed)
data = getattr(rng, distribution)(size=n_samples)
best_pred = np.quantile(data, target_quantile)
best_constant_pred = np.full(n_samples, fill_value=best_pred)
best_pbl = mean_pinball_loss(data, best_constant_pred, alpha=target_quantile)
candidate_predictions = np.quantile(data, np.linspace(0, 1, 100))
for pred in candidate_predictions:
constant_pred = np.full(n_samples, fill_value=pred)
pbl = mean_pinball_loss(data, constant_pred, alpha=target_quantile)
assert pbl >= best_pbl - np.finfo(np.float64).eps
这个测试从数学原理验证了 Pinball 损失的核心性质:经验分位数是常数预测器的最优解,在多种分布下都成立。
源码路径:sklearn/metrics/tests/test_regression.py - test_dummy_quantile_parameter_tuning()(第482-510行)
def test_dummy_quantile_parameter_tuning(global_random_seed):
n_samples = 1000
rng = np.random.RandomState(global_random_seed)
X = rng.normal(size=(n_samples, 5))
y = rng.exponential(size=n_samples)
all_quantiles = [0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95]
for alpha in all_quantiles:
neg_mean_pinball_loss = make_scorer(
mean_pinball_loss,
alpha=alpha,
greater_is_better=False,
)
regressor = DummyRegressor(strategy="quantile", quantile=0.25)
grid_search = GridSearchCV(
regressor,
param_grid=dict(quantile=all_quantiles),
scoring=neg_mean_pinball_loss,
).fit(X, y)
assert grid_search.best_params_["quantile"] == pytest.approx(alpha)
这个集成测试验证了 Pinball 损失可以作为 GridSearchCV 的评分器,正确调优 DummyRegressor 的分位数参数。
31.11.3 测试全景流程图
图解说明:该全景流程图展示了回归指标测试体系的八大核心支柱,覆盖从基础正确性到边界极限、从数学理论验证到端到端集成的完整测试链路。test_regression_metrics 作为基石,验证所有指标在标准线性数据上的数学正确性及指标间等价关系(如 Tweedie power=0 ≡ MSE)。test_multioutput_regression 与 test_regression_custom_weights 专注多输出聚合逻辑,覆盖三种字符串模式与自定义权重数组。test_regression_metrics_at_limits 压力测试边界工况:单样本、常数目标触发 NaN/-Inf、MSLE 定义域违规、Tweedie power 边界输入验证。test_tweedie_deviance_continuity 专门验证 Tweedie 偏差在 power 边界点(0、1、2)处的数值连续性。test_mean_pinball_loss_on_constant_predictions 从统计学原理出发,跨四种分布验证 Pinball 损失的分位数最优性。test_dummy_quantile_parameter_tuning 端到端验证 Pinball 损失作为 GridSearchCV 评分器的实战可用性。test_pinball_loss_relation_with_mae 确认 α=0.5 时 Pinball 与 MAE 的精确数学关系。
31.11.4 测试覆盖矩阵
以下是回归指标测试的核心覆盖维度:
| 测试函数 | 覆盖指标 | 核心验证点 |
|----------|----------|------------|
| test_regression_metrics | 全部基础指标 | 数学正确性、指标间等价关系 |
| test_multioutput_regression | 所有支持 multioutput 的指标 | raw_values/uniform/variance_weighted 聚合 |
| test_regression_custom_weights | 支持自定义权重的指标 | 输出权重加权平均 |
| test_regression_metrics_at_limits | 所有指标 | 单样本、常数目标、定义域边界、非有限值 |
| test_tweedie_deviance_continuity | Tweedie 族 | power 边界处的数值连续性 |
| test_mean_pinball_loss_on_constant_predictions | Pinball loss | 分位数最优性理论验证 |
| test_dummy_quantile_parameter_tuning | Pinball loss + GridSearchCV | 端到端超参数调优流程 |
| test_pinball_loss_relation_with_mae | Pinball vs MAE | α=0.5 时的数学关系 |
31.12 设计中的取舍
31.12.1 为什么不用统一的 loss 命名而混用 error/loss/score?
scikit-learn 的约定:*_error/*_loss 返回越小越好的值(需最小化),*_score 返回越大越好的值(需最大化)。这种命名直接反映了优化方向,避免了统一命名带来的认知负担。例如 mean_squared_error 需最小化,r2_score 需最大化。
31.12.2 为什么 max_error 不支持多输出?
max_error 设计为“最坏情况诊断工具”,关注单个最大残差。多输出下“最大残差”定义模糊(是跨输出取最大,还是每个输出取最大再聚合?)。单输出场景下语义最清晰:直接返回标量最大残差。
31.12.3 为什么 Tweedie 偏差限制 power <= 0 或 power >= 1?
这是 Tweedie 分布族的数学定义域:power ∈ (0, 1) 对应的分布不存在(或不具有指数分布族性质)。validate_params 中的 Interval(Real, None, 0, closed="right") 和 Interval(Real, 1, None, closed="left") 精确编码了这一数学约束。
31.12.4 为什么 force_finite=True 是默认值?
超参数搜索(如 GridSearchCV)无法比较 NaN/-Inf。默认将常数目标下的完美预测设为 1.0、不完美预测设为 0.0,保证搜索流程不中断。用户可显式设置 force_finite=False 获得原始数学定义。
31.12.5 为什么 _assemble_fraction_of_explained_deviance 要单独抽象?
R² 和解释方差共享完全相同的聚合逻辑:分数计算、非有限值修正、多输出加权平均。抽象为通用函数消除了代码重复,保证行为一致,也让新增 D² 指标(如 Pinball、Absolute Error)能复用这套成熟逻辑。
31.13 动手练习
-
阅读回归指标核心实现
-
阅读
sklearn/metrics/_regression.py第80-310行,理解以下指标的实现:-
mean_absolute_error()- 平均绝对误差 -
mean_squared_error()- 均方误差 -
root_mean_squared_error()- 均方根误差
-
-
回答问题:
-
这些函数如何处理多输出情况?
-
sample_weight参数在计算中的作用是什么? -
为什么需要
_check_reg_targets_with_floating_dtype进行输入验证?
-
-
-
探索解释力和偏差解释指标
-
阅读
sklearn/metrics/_regression.py第290-500行,理解以下指标的实现:-
explained_variance_score()- 解释方差 -
r2_score()- 决定系数 -
_assemble_fraction_of_explained_deviance()- 通用聚合函数
-
-
回答问题:
-
R²和解释方差在什么情况下等价?何时会出现差异?
-
force_finite参数如何处理常数目标情况下的非有限值? -
多输出情况下,
variance_weighted模式与uniform_average有什么区别?
-
-
-
研究基于偏差的损失函数
-
阅读
sklearn/metrics/_regression.py第500-680行,理解以下指标的实现:-
mean_tweedie_deviance()- 通用Tweedie偏差 -
mean_poisson_deviance()和mean_gamma_deviance- 特化偏差 -
d2_tweedie_score()- Tweedie偏差解释度
-
-
回答问题:
-
Tweedie偏差如何通过
power参数统一正态、泊松、伽马等分布? -
不同power值对应的输入验证条件是什么?为什么需要这些限制?
-
D²分数族如何将偏差转化为可解释度量?其数学原理是什么?
-
-
-
分位数回归与对数空间误差指标
-
阅读
sklearn/metrics/_regression.py第140-250行及第310-380行,理解以下指标的实现:-
mean_pinball_loss()- 分位数损失 -
mean_absolute_percentage_error()- 平均绝对百分比误差 -
mean_squared_log_error()和root_mean_squared_log_error()- 对数空间误差
-
-
回答问题:
-
Pinball损失如何通过
alpha参数控制分位数?当α=0.5时与MAE有何关系? -
MAPE如何处理
y_true=0的情况?引入epsilon的作用是什么? -
MSLE/RMSLE要求
y_true > -1和y_pred > -1的数学原因是什么?
-
-
-
鲁棒指标与输入验证机制
-
阅读
sklearn/metrics/_regression.py第380-450行及第1-80行,理解以下内容:-
median_absolute_error()- 中位数绝对误差(含加权中位数实现) -
max_error()- 最大残差 -
_check_reg_targets()和_check_reg_targets_with_floating_dtype()- 输入验证核心
-
-
回答问题:
-
median_absolute_error如何实现加权中位数?_weighted_percentile起什么作用? -
max_error为何不支持多输出?其设计意图是什么? -
_check_reg_targets如何统一处理1D/2D输入、样本权重与多输出参数?
-
-
31.14 本章小结
这一章中我们学习了 scikit-learn 回归评估指标的完整体系。首先我们探索了基础误差指标 MAE、MSE、RMSE 的核心实现,理解了它们如何通过统一的输入验证层处理多样化输入,并通过两级 _average 实现样本加权与输出聚合。其次我们深入了对数空间指标 MSLE/RMSLE 与鲁棒指标 Median AE,前者通过 log1p 变换适配指数型目标,后者通过中位数实现抗异常值能力。接着我们分析了业务视角的 MAPE(相对误差)与 Max Error(最坏情况),理解了 epsilon 机制防除零的设计。然后我们剖析了解释力指标 R² 与解释方差的数学区别:R² 考虑系统性偏差,解释方差忽略截距偏移,两者通过 _assemble_fraction_of_explained_deviance 共享聚合逻辑。随后我们研究了 Tweedie 偏差家族如何用 power 参数统一正态、泊松、伽马等指数分布族,以及 D² 分数族如何将偏差转化为类 R² 的可解释评分。最后我们验证了 Pinball 损失作为分位数回归最优损失的理论性质,并梳理了输入验证层 _check_reg_targets 与浮点类型推断 _check_reg_targets_with_floating_dtype 的守门人角色,以及测试体系从基础正确性到边界极限、从理论验证到端到端集成的全维度覆盖。
本章我们一起学习了以下概念:
| 概念 | 解释 |
|------|------|
| mean_absolute_error | 平均绝对误差,对异常值不敏感的L1损失 |
| mean_squared_error | 均方误差,放大大误差影响的核心L2损失 |
| root_mean_squared_error | 均方根误差,具有与目标变量相同单位的可解释误差 |
| mean_squared_log_error | 对数空间均方误差,适用于指数型目标变量 |
| root_mean_squared_log_error | 对数空间均方根误差,与目标变量同量纲的对数误差 |
| median_absolute_error | 中位数绝对误差,对极端异常值具有鲁棒性 |
| mean_absolute_percentage_error | 平均绝对百分比误差,相对误差度量(需注意零值问题) |
| mean_pinball_loss | Pinball损失,分位数回归的最优损失函数,α控制分位数 |
| max_error | 最大残差,评估最坏情况下的预测误差 |
| r2_score | 决定系数,衡量模型对目标方差的解释比例,取值范围(-∞,1] |
| explained_variance_score | 解释方差,忽略系统性偏差仅衡量方差解释能力 |
| mean_tweedie_deviance | 通用Tweedie偏差,通过power参数统一多种分布假设 |
| mean_poisson_deviance | 泊松偏差,适用于计数数据建模(power=1) |
| mean_gamma_deviance | 伽马偏差,适用于正连续建模且尺度不变(power=2) |
| d2_tweedie_score | Tweedie偏差解释度,类似R²但基于偏差而非方差 |
| d2_pinball_score | Pinball损失解释度,衡量分位数预测的解释力 |
| d2_absolute_error_score | 绝对误差解释度,对应α=0.5的Pinball解释度 |
| _check_reg_targets | 回归目标输入验证核心函数,统一形状、类型与多输出参数 |
| _check_reg_targets_with_floating_dtype | 带浮点类型推断的输入验证,自动选择合适的计算精度 |
| _assemble_fraction_of_explained_deviance | 解释力指标通用聚合函数,处理多输出平均与非有限值修正 |
| _mean_tweedie_deviance | Tweedie偏差核心计算,分段处理不同power区间的数值稳定实现 |
下一章中,我们将学习排序与曲线评估指标 —— 绘制“模型性能的等高线地图”,包括 ROC 曲线、Precision-Recall 曲线、DET 曲线以及 NDCG 等排序指标的实现原理。
第 32 章 —— 排序与曲线评估指标 —— 绘制“模型性能的等高线地图”
32.1 学习目标
-
难度:★★★☆☆(3/5)
-
预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础
-
理解 ROC 曲线 与 AUC 的计算原理,以及它们在二分类、多分类和多标签场景下的实现差异。
-
掌握 Precision‑Recall 曲线、Average Precision (AP) 与 分位数回归 指标的计算逻辑。
-
熟悉 DET 曲线 与 阈值混淆矩阵 在二分类评估中的工程实现。
-
理解基于 排序的指标(覆盖率误差、标签排名损失、LRAP)的多标签评估机制。
-
掌握 DCG / NDCG 与 Top‑k 准确率 在信息检索与推荐场景中的细节实现。
-
了解 Array API 兼容层 如何在排名指标计算中完成跨后端(NumPy / CuPy / JAX …)的分派。
32.2 生活类比
把 模型的预测分数 想象成搜索引擎为每篇文档打的相关性分,则各类评估曲线就是在这座“信息检索评分大厅”里观察不同仪表盘的变化:
-
ROC 曲线 像一块 “灵敏度‑特异度权衡仪表盘”。当阈值从高到低滑动时,召回率(TPR)与误报率(FPR)会互相增减。
-
AUC 则是这块仪表盘的 单数值总结——随机抽取一正样本和一负样本,正样本分数高于负样本的概率。
-
Precision‑Recall 曲线 更像 “查准‑查全平衡尺”。在正样本稀缺的情形下,它比 ROC 更敏感,曲线下的面积(AP)是对每一次召回提升的精度加权求和。
-
DET 曲线 把 FPR 与 FNR(1‑TPR)映射到正态分位数坐标,使得极低误差区的模型可以更直观地比较。
-
阈值混淆矩阵 记录每个阈值下的 TN / FP / FN / TP,像是全阈值切片的 胶卷,帮助我们看到模型在每一步的细腻变化。
-
在 多标签 场景,覆盖率误差 代表“召回所有真标签所需的平均排名深度”,值越小越好;标签排名损失 则是“错误排序标签对的加权比例”。LRAP 进一步把每个真标签在其前方出现的真标签比例进行平均。
-
DCG / NDCG 为 “带位置折扣的累积增益”。把高相关文档排在前面奖励放大,后面则按对数衰减惩罚。
-
Top‑k 准确率 则是 “前 k 个推荐命中率”,是推荐系统最核心的评估指标之一。
32.3 源码地图
sklearn/metrics/_ranking.py
├─ 核心曲线计算工具
│ ├─ auc() [30-60]
│ ├─ confusion_matrix_at_thresholds() [300-380]
│ ├─ roc_curve() [450-520]
│ ├─ precision_recall_curve() [380-450]
│ ├─ det_curve() [200-300]
├─ AUC 与 AP
│ ├─ roc_auc_score() [150-250]
│ │ ├─ _binary_roc_auc_score() [130-150]
│ │ └─ _multiclass_roc_auc_score() [250-350]
│ └─ average_precision_score() [50-130]
├─ 多标签排名指标
│ ├─ label_ranking_average_precision_score() [550-620]
│ ├─ coverage_error() [620-670]
│ └─ label_ranking_loss() [670-730]
├─ 排序质量指标
│ ├─ dcg_score() [730-800]
│ │ ├─ _dcg_sample_scores() [480-530]
│ │ └─ _tie_averaged_dcg() [530-570]
│ ├─ ndcg_score() [800-870]
│ │ └─ _ndcg_sample_scores() [570-620]
│ └─ top_k_accuracy_score() [870-950]
├─ 测试
│ └─ sklearn/metrics/tests/test_ranking.py
32.4 核心曲线计算:ROC、PR、DET 与阈值混淆矩阵 ― 二分类评估的“三大视角”
32.4.1 confusion_matrix_at_thresholds (300-380) —— 统一基础数据源
def confusion_matrix_at_thresholds(y_true, y_score, pos_label=None, sample_weight=None):
"""Calculate binary confusion matrix terms per classification threshold."""
# Check to make sure y_true is valid
y_type = type_of_target(y_true, input_name="y_true")
if not (y_type == "binary" or (y_type == "multiclass" and pos_label is not None)):
raise ValueError("{0} format is not supported".format(y_type))
xp, _, device = get_namespace_and_device(y_true, y_score, sample_weight)
check_consistent_length(y_true, y_score, sample_weight)
y_true = column_or_1d(y_true)
y_score = column_or_1d(y_score)
assert_all_finite(y_true)
assert_all_finite(y_score)
# Filter out zero-weighted samples, as they should not impact the result
if sample_weight is not None:
sample_weight = column_or_1d(sample_weight)
sample_weight = _check_sample_weight(sample_weight, y_true)
nonzero_weight_mask = sample_weight != 0
y_true = y_true[nonzero_weight_mask]
y_score = y_score[nonzero_weight_mask]
sample_weight = sample_weight[nonzero_weight_mask]
pos_label = _check_pos_label_consistency(pos_label, y_true)
# make y_true a boolean vector
y_true = y_true == pos_label
# sort scores and corresponding truth values
desc_score_indices = xp.argsort(y_score, stable=True, descending=True)
y_score = y_score[desc_score_indices]
y_true = y_true[desc_score_indices]
if sample_weight is not None:
weight = sample_weight[desc_score_indices]
else:
weight = 1.0
# y_score typically has many tied values. Here we extract
# the indices associated with the distinct values. We also
# concatenate a value for the end of the curve.
distinct_value_indices = xp.nonzero(xp.diff(y_score))[0]
threshold_idxs = xp.concat(
[distinct_value_indices, xp.asarray([size(y_true) - 1], device=device)]
)
# accumulate the true positives with decreasing threshold
max_float_dtype = _max_precision_float_dtype(xp, device)
# Perform the weighted cumulative sum using float64 precision when possible
# to avoid numerical stability problem with tens of millions of very noisy
# predictions:
# https://github.com/scikit-learn/scikit-learn/issues/31533#issuecomment-2967062437
y_true = xp.astype(y_true, max_float_dtype)
tps = xp.cumulative_sum(y_true * weight, dtype=max_float_dtype)[threshold_idxs]
if sample_weight is not None:
# express fps as a cumsum to ensure fps is increasing even in
# the presence of floating point errors
fps = xp.cumulative_sum((1 - y_true) * weight, dtype=max_float_dtype)[
threshold_idxs
]
else:
fps = 1 + xp.astype(threshold_idxs, max_float_dtype) - tps
tns = fps[-1] - fps
fns = tps[-1] - tps
return tns, fps, fns, tps, y_score[threshold_idxs]
为什么要在累计和前按分数降序排序?
-
ROC/PR/DET 曲线的阈值是 从高到低 逐渐放宽决策边界。若按降序排列,则在一次累计求和中,TP 与 FP 只会单调递增,从而可以一次性得到所有阈值对应的计数,而不需要逐阈遍历。
-
降序还能让
threshold_idxs正好对应 唯一阈值 的位置,使得后续的roc_curve、precision_recall_curve、det_curve直接复用该结果。
32.4.2 roc_curve (450-520) —— 生成 ROC 坐标与阈值
def roc_curve(
y_true, y_score, *, pos_label=None, sample_weight=None, drop_intermediate=True
):
"""Compute Receiver operating characteristic (ROC)."""
xp, _, device = get_namespace_and_device(y_true, y_score)
_, fps, _, tps, thresholds = confusion_matrix_at_thresholds(
y_true, y_score, pos_label=pos_label, sample_weight=sample_weight
)
# Attempt to drop thresholds corresponding to points in between and
# collinear with other points. These are always suboptimal and do not
# appear on a plotted ROC curve (and thus do not affect the AUC).
# Here np.diff(_, 2) is used as a "second derivative" to tell if there
# is a corner at the point. Both fps and tps must be tested to handle
# thresholds with multiple data points (which are combined in
# confusion_matrix_at_thresholds). This keeps all cases where the point should be
# kept, but does not drop more complicated cases like fps = [1, 3, 7],
# tps = [1, 2, 4]; there is no harm in keeping too many thresholds.
if drop_intermediate and fps.shape[0] > 2:
optimal_idxs = xp.where(
xp.concat(
[
xp.asarray([True], device=device),
xp.logical_or(xp.diff(fps, 2), xp.diff(tps, 2)),
xp.asarray([True], device=device),
]
)
)[0]
fps = fps[optimal_idxs]
tps = tps[optimal_idxs]
thresholds = thresholds[optimal_idxs]
# Add an extra threshold position
# to make sure that the curve starts at (0, 0)
tps = xp.concat([xp.asarray([0.0], device=device), tps])
fps = xp.concat([xp.asarray([0.0], device=device), fps])
# get dtype of `y_score` even if it is an array-like
thresholds = xp.astype(thresholds, _max_precision_float_dtype(xp, device))
thresholds = xp.concat([xp.asarray([xp.inf], device=device), thresholds])
if fps[-1] <= 0:
warnings.warn(
"No negative samples in y_true, false positive value should be meaningless",
UndefinedMetricWarning,
)
fpr = xp.full(fps.shape, xp.nan)
else:
fpr = fps / fps[-1]
if tps[-1] <= 0:
warnings.warn(
"No positive samples in y_true, true positive value should be meaningless",
UndefinedMetricWarning,
)
tpr = xp.full(tps.shape, xp.nan)
else:
tpr = tps / tps[-1]
return fpr, tpr, thresholds
-
坐标含义:
fpr = FP / N、tpr = TP / P,分别衡量误报率和召回率。 -
可视化用途:在 ROC 空间里,曲线越靠左上角表示模型对正负样本区分越好;AUC 通过
auc(fpr, tpr)汇总整体性能。
32.4.3 precision_recall_curve (380-450) —— 生成 PR 坐标
def precision_recall_curve(
y_true,
y_score,
*,
pos_label=None,
sample_weight=None,
drop_intermediate=False,
):
"""Compute precision-recall pairs for different probability thresholds."""
xp, _, device = get_namespace_and_device(y_true, y_score)
_, fps, _, tps, thresholds = confusion_matrix_at_thresholds(
y_true, y_score, pos_label=pos_label, sample_weight=sample_weight
)
if drop_intermediate and fps.shape[0] > 2:
# Drop thresholds corresponding to points where true positives (tps)
# do not change from the previous or subsequent point. This will keep
# only the first and last point for each tps value. All points
# with the same tps value have the same recall and thus x coordinate.
# They appear as a vertical line on the plot.
optimal_idxs = xp.where(
xp.concat(
[
xp.asarray([True], device=device),
xp.logical_or(xp.diff(tps[:-1]), xp.diff(tps[1:])),
xp.asarray([True], device=device),
]
)
)[0]
fps = fps[optimal_idxs]
tps = tps[optimal_idxs]
thresholds = thresholds[optimal_idxs]
ps = tps + fps
# Initialize the result array with zeros to make sure that precision[ps == 0]
# does not contain uninitialized values.
precision = xp.where(ps != 0, xp.divide(tps, ps), 0.0)
# When no positive label in y_true, recall is set to 1 for all thresholds
# tps[-1] == 0 <=> y_true == all negative labels
if tps[-1] == 0:
warnings.warn(
"No positive class found in y_true, "
"recall is set to one for all thresholds."
)
recall = xp.full(tps.shape, 1.0, device=device)
else:
recall = tps / tps[-1]
# reverse the outputs so recall is decreasing
return (
xp.concat((xp.flip(precision), xp.asarray([1.0], device=device))),
xp.concat((xp.flip(recall), xp.asarray([0.0], device=device))),
xp.flip(thresholds),
)
-
为何
precision与recall数组比thresholds多一位?曲线的右端点
(precision=1, recall=0)对应 阈值 0(所有样本都预测为正),但在confusion_matrix_at_thresholds中没有对应的阈值记录。为了保证曲线完整,代码在返回前手动 在precision/recall末尾各加一个点,而thresholds则保持真实的唯一阈值集合。
32.4.4 det_curve (200-300) —— 由 ROC 推导 DET 坐标
def det_curve(
y_true, y_score, pos_label=None, sample_weight=None, drop_intermediate=False
):
"""Compute Detection Error Tradeoff (DET) for different probability thresholds."""
xp, _, device = get_namespace_and_device(y_true, y_score)
_, fps, _, tps, thresholds = confusion_matrix_at_thresholds(
y_true, y_score, pos_label=pos_label, sample_weight=sample_weight
)
# add a threshold at inf where the clf always predicts the negative class
# i.e. tps = fps = 0
tps = xp.concat((xp.asarray([0.0], device=device), tps))
fps = xp.concat((xp.asarray([0.0], device=device), fps))
thresholds = xp.astype(thresholds, _max_precision_float_dtype(xp, device))
thresholds = xp.concat((xp.asarray([xp.inf], device=device), thresholds))
if drop_intermediate and len(fps) > 2:
# Drop thresholds where true positives (tp) do not change from the
# previous or subsequent threshold. As tp + fn, is fixed for a dataset,
# this means the false negative rate (fnr) remains constant while the
# false positive rate (fpr) changes, producing horizontal line segments
# in the transformed (normal deviate) scale. These intermediate points
# can be dropped to create lighter DET curve plots.
optimal_idxs = xp.where(
xp.concat(
[
xp.asarray([True], device=device),
xp.logical_or(xp.diff(tps[:-1]), xp.diff(tps[1:])),
xp.asarray([True], device=device),
]
)
)[0]
fps = fps[optimal_idxs]
tps = tps[optimal_idxs]
thresholds = thresholds[optimal_idxs]
if xp.unique_values(y_true).shape[0] != 2:
raise ValueError(
"Only one class is present in y_true. Detection error "
"tradeoff curve is not defined in that case."
)
fns = tps[-1] - tps
p_count = tps[-1]
n_count = fps[-1]
# start with false positives zero, which may be at a finite threshold
first_ind = (
xp.searchsorted(fps, fps[0], side="right") - 1
if xp.searchsorted(fps, fps[0], side="right") > 0
else None
)
# stop with false negatives zero
last_ind = xp.searchsorted(tps, tps[-1]) + 1
sl = slice(first_ind, last_ind)
# reverse the output such that list of false positives is decreasing
return (
xp.flip(fps[sl]) / n_count,
xp.flip(fns[sl]) / p_count,
xp.flip(thresholds[sl]),
)
-
坐标本质区别:
-
ROC 关注 FPR‑TPR(误报率 vs. 召回率),在概率坐标系下直观展示分类器的整体辨别能力。
-
DET 将 FPR 与 FNR = 1‑TPR 投射到 正态分位数(即
norm.ppf轴),在极低误报/误漏区的细粒度比较更具可读性。
-
32.4.5 drop_intermediate 参数的实现细节
drop_intermediate 通过 二阶差分 (np.diff(..., 2)) 判断相邻三个点是否共线(在 ROC)或是否形成垂直/水平线段(在 PR/DET),并仅保留拐点与两端点。这样可以 显著减少绘图点数,而不影响 AUC 或 AP 结果,因为这些指标只依赖 曲线的凸包 而非共线点。
32.4.6 Array API 兼容层
-
get_namespace_and_device根据输入的 array‑like 自动返回对应的命名空间xp(NumPy、CuPy、JAX 等)以及设备信息。 -
之后的所有算子(如
xp.argsort、xp.cumulative_sum、xp.concat)均通过xp调用,实现 跨后端无缝切换。 -
move_to用于在需要迁移对象(例如y_true、sample_weight)到目标后端时进行统一搬运,保证所有变量位于同一设备上。
32.5 AUC 与 AP 指标:从二分类到多分类/多标签的统一接口
32.5.1 roc_auc_score (150-250) —— 多场景统一入口
def roc_auc_score(
y_true,
y_score,
*,
average="macro",
sample_weight=None,
max_fpr=None,
multi_class="raise",
labels=None,
):
"""Compute Area Under the Receiver Operating Characteristic Curve (ROC AUC) from prediction scores."""
y_type = type_of_target(y_true, input_name="y_true")
y_true = check_array(y_true, ensure_2d=False, dtype=None)
y_score = check_array(y_score, ensure_2d=False)
if sample_weight is not None:
sample_weight = column_or_1d(sample_weight)
if y_type == "multiclass" or (
y_type == "binary" and y_score.ndim == 2 and y_score.shape[1] > 2
):
# do not support partial ROC computation for multiclass
if max_fpr is not None and max_fpr != 1.0:
raise ValueError(
"Partial AUC computation not available in "
"multiclass setting, 'max_fpr' must be"
" set to `None`, received `max_fpr={0}` "
"instead".format(max_fpr)
)
if multi_class == "raise":
raise ValueError("multi_class must be in ('ovo', 'ovr')")
return _multiclass_roc_auc_score(
y_true, y_score, labels, multi_class, average, sample_weight
)
elif y_type == "binary":
labels = np.unique(y_true)
y_true = label_binarize(y_true, classes=labels)[:, 0]
return _average_binary_score(
partial(_binary_roc_auc_score, max_fpr=max_fpr),
y_true,
y_score,
average,
sample_weight=sample_weight,
)
else: # multilabel-indicator
return _average_binary_score(
partial(_binary_roc_auc_score, max_fpr=max_fpr),
y_true,
y_score,
average,
sample_weight=sample_weight,
)
-
关键分派:
type_of_target判断是 binary / multiclass / multilabel‑indicator,进而调用_binary_roc_auc_score、_multiclass_roc_auc_score或直接在多标签上做二分类平均。 -
多分类策略:
multi_class='ovr'(One‑vs‑Rest)或'ovo'(One‑vs‑One),两者的实现分别在_multiclass_roc_auc_score中展开。
32.5.2 _binary_roc_auc_score (130-150) —— 二分类核心实现
def _binary_roc_auc_score(y_true, y_score, sample_weight=None, max_fpr=None):
"""Binary roc auc score."""
if len(np.unique(y_true)) != 2:
warnings.warn(
(
"Only one class is present in y_true. ROC AUC score "
"is not defined in that case."
),
UndefinedMetricWarning,
)
return np.nan
fpr, tpr, _ = roc_curve(y_true, y_score, sample_weight=sample_weight)
if max_fpr is None or max_fpr == 1:
return auc(fpr, tpr)
if max_fpr <= 0 or max_fpr > 1:
raise ValueError("Expected max_fpr in range (0, 1], got: %r" % max_fpr)
# Add a single point at max_fpr by linear interpolation
stop = np.searchsorted(fpr, max_fpr, "right")
x_interp = [fpr[stop - 1], fpr[stop]]
y_interp = [tpr[stop - 1], tpr[stop]]
tpr = np.append(tpr[:stop], np.interp(max_fpr, x_interp, y_interp))
fpr = np.append(fpr[:stop], max_fpr)
partial_auc = auc(fpr, tpr)
# McClish correction: standardize result to be 0.5 if non-discriminant
# and 1 if maximal
min_area = 0.5 * max_fpr**2
max_area = max_fpr
return 0.5 * (1 + (partial_auc - min_area) / (max_area - min_area))
-
完整 AUC:直接调用通用
auc(梯形法则)。 -
部分 AUC:通过
max_fpr截断并线性插值得到限定范围的曲线点,然后再使用auc。 -
McClish 校正:对 部分 AUC 进行标准化,使得随机分类对应 0.5,完美分类对应 1。
32.5.3 _multiclass_roc_auc_score (250-350) —— OvR 与 Ovo 实现差异
def _multiclass_roc_auc_score(
y_true, y_score, labels, multi_class, average, sample_weight
):
"""Multiclass roc auc score."""
if not y_score.ndim == 2:
raise ValueError(
"`y_score` needs to be of shape `(n_samples, n_classes)`, since "
"`y_true` contains multiple classes. Got "
f"`y_score.shape={y_score.shape}`."
)
if not np.allclose(1, y_score.sum(axis=1)):
raise ValueError(
"Target scores need to be probabilities for multiclass "
"roc_auc, i.e. they should sum up to 1.0 over classes"
)
# validation for multiclass parameter specifications
average_options = ("macro", "weighted", None)
if multi_class == "ovr":
average_options = ("micro",) + average_options
if average not in average_options:
raise ValueError(
"average must be one of {0} for multiclass problems".format(average_options)
)
multiclass_options = ("ovo", "ovr")
if multi_class not in multiclass_options:
raise ValueError(
"multi_class='{0}' is not supported "
"for multiclass ROC AUC, multi_class must be "
"in {1}".format(multi_class, multiclass_options)
)
if average is None and multi_class == "ovo":
raise NotImplementedError(
"average=None is not implemented for multi_class='ovo'."
)
if labels is not None:
labels = column_or_1d(labels)
classes = _unique(labels)
if len(classes) != len(labels):
raise ValueError("Parameter 'labels' must be unique")
if not np.array_equal(classes, labels):
raise ValueError("Parameter 'labels' must be ordered")
if len(classes) != y_score.shape[1]:
raise ValueError(
"Number of given labels, {0}, not equal to the number "
"of columns in 'y_score', {1}".format(len(classes), y_score.shape[1])
)
if len(np.setdiff1d(y_true, classes)):
raise ValueError("'y_true' contains labels not in parameter 'labels'")
else:
classes = _unique(y_true)
if len(classes) != y_score.shape[1]:
raise ValueError(
"Number of classes in y_true not equal to the number of "
"columns in 'y_score'"
)
if multi_class == "ovo":
if sample_weight is not None:
raise ValueError(
"sample_weight is not supported "
"for multiclass one-vs-one ROC AUC, "
"'sample_weight' must be None in this case."
)
y_true_encoded = _encode(y_true, uniques=classes)
# Hand & Till (2001) implementation (ovo)
return _average_multiclass_ovo_score(
_binary_roc_auc_score, y_true_encoded, y_score, average=average
)
else:
# ovr is same as multi-label
y_true_multilabel = label_binarize(y_true, classes=classes)
return _average_binary_score(
_binary_roc_auc_score,
y_true_multilabel,
y_score,
average,
sample_weight=sample_weight,
)

浙公网安备 33010602011771号