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))

数值稳定性三重保障:

  1. eps 裁剪xp.clip(y_pred, eps, 1-eps) 将概率限制在机器精度范围内,避免 log(0) 产生 -inf

  2. _xlogy 安全乘积x * log(y)x=0 时定义为 0,避免 0 * (-inf) = NaN

  3. 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==1shape[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² 公式统一形式:

\[D^2 = 1 - \frac{L(\text{model})}{L(\text{null})} \]

  • 空模型 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

动态聚合逻辑核心:

  1. micro_is_accuracy 判断
  • 仅当 y_typemulticlass/binarylabels 覆盖所有真实/预测标签时,micro 平均等同 accuracy

  • 此时报告中显示 accuracy 行而非 micro avg,避免冗余。

  1. Multilabel 特有 samples avg:逐样本平均仅对 multilabel 有意义(此时与 accuracy 不同)。

  2. 字典输出扁平化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))

标准适配三步走:

  1. 获取命名空间与设备xp, _, device = get_namespace_and_device(...) 自动推断输入数组所属后端。

  2. 数据搬运统一move_to(y_true, sample_weight, xp=xp, device=device) 将所有输入移至同一后端/设备。

  3. 全程 Array API 操作:用 xp.sumxp.meanxpx.nan_to_num 等替代 np.*

  4. 结果归还float(...)xp.asarray(result, device=device) 返回原生类型或保持后端一致。

30.10.2 关键兼容工具函数

| 工具函数 | 替代目标 | 作用 |

|----------|----------|------|

| _average | np.average | 跨后端加权平均,处理 weights=Nonenormalize、NaN 传播 |

| _bincount | np.bincount | 硬件加速直方图统计,用于 multilabel_confusion_matrix 1D 路径 |

| _count_nonzero | np.count_nonzero | 并行非零计数,支持 axissample_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_targetsif _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=0FP>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 动手练习

  1. 阅读分类指标核心计算引擎实现

    阅读 sklearn/metrics/_classification.pyprecision_recall_fscore_support 函数(约1180-1340行),理解:

    1. 它如何复用 multilabel_confusion_matrix 获取 tp_sumpred_sumtrue_sum

    2. average='micro' 时如何将三个统计量全局求和?

    3. F-beta 分数的统一公式 f = (1+β²) * tp / ((1+β²)*tp + β²*fn + fp) 如何避免精度/召回再除法?

    4. _prf_divide 如何处理除零、填充零除值、触发警告?

    回答问题:

    • average='weighted'average='samples' 时,_nanaverageweights 参数分别取什么值?

    • beta=0beta=inf 时,fbeta_score 分别退化为哪个基础指标?内部如何显式处理?

  2. 对比混淆矩阵与多标签混淆矩阵的实现差异

    阅读 confusion_matrix(约342-460行)与 multilabel_confusion_matrix(约462-580行),对比:

    1. confusion_matrix 如何通过 need_index_conversion 将任意标签映射为连续索引,并用 scipy.sparse.coo_matrix 高效累加?

    2. multilabel_confusion_matrixy_true.ndim==1(二分类/多类)与 ndim>1(多标签)下的两条计算路径差异?

    3. samplewise=True 时,聚合轴如何切换?sample_weight 在逐样本模式下如何参与 TN 计算?

    回答问题:

    • confusion_matrixnormalize='true'|'pred'|'all' 分别对应哪个轴的归一化?

    • multilabel_confusion_matrix 为何在 y_true.ndim==1 路径下使用 _bincount,而在 ndim>1 路径下使用 _count_nonzero

  3. 探究概率型指标的数值稳定性与多后端兼容

    阅读 _log_loss(约1662-1675行)、brier_score_loss(约1750-1860行)及 _validate_multiclass_probabilistic_prediction(约195-260行),分析:

    1. _log_losseps = xp.finfo(y_pred.dtype).epsxp.clip(y_pred, eps, 1-eps) 如何防止 log(0) 产生 -inf

    2. brier_score_loss 如何在二分类(y_proba.ndim==1)与多类别(ndim==2)间统一计算?scale_by_half='auto' 的自动判断逻辑是什么?

    3. 所有概率型指标开头如何通过 get_namespace_and_devicemove_to 实现跨后端/设备统一?

    回答问题:

    • _validate_multiclass_probabilistic_predictionLabelBinarizerclasses_ 顺序与 y_prob 列顺序的对齐要求是什么?不一致时如何报错?

    • d2_log_loss_scored2_brier_score 如何构造“空模型”基线 y_pred_null?为何取 sample_weight 加权的类别先验概率?

  4. 实战:分类报告生成与零除处理

    阅读 classification_report(约1350-1500行)与 test_classification_report_zero_division_warning 测试用例,分析:

    1. classification_report 如何根据 y_type 决定显示哪些平均行?micro_is_accuracy 判断逻辑是什么?

    2. output_dict=True 时,返回的字典结构如何组织逐类指标与平均指标?

    3. 零除策略如何通过 zero_division 参数传递到底层 PRF 引擎?

    回答问题:

    • labels 参数包含训练数据中不存在的标签时,报告中对应行的 support 为何显示为 0?

    • multilabel-indicator 任务下,为何额外显示 samples avg 而不显示 accuracy 行?

  5. 动手:验证类似然比的边界行为与自定义兜底值

    阅读 class_likelihood_ratios(约2050-2200行)与对应测试 test_likelihood_ratios_*,完成:

    1. 构造 y_true=[1,1,0], y_pred=[1,0,0] 导致 fp=0 的场景,验证 replace_undefined_by={'LR+': np.inf, 'LR-': 0.0} 的效果。

    2. 构造 y_true=[1,0,0], y_pred=[1,1,1] 导致 tn=0 的场景,验证 replace_undefined_by={'LR-': np.nan} 的效果。

    3. 尝试传入非二分类数据(如三分类),观察报错信息。

    回答问题:

    • 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 架构与数据流图

graph TD A[_classification] --> B[_classification] B --> C[_classification]
sequenceDiagram participant U as 调用者 participant E as _classification participant C as _classification U->>E: 调用入口 E->>C: 传递参数 C-->>U: 返回结果
graph LR I[输入] --> P[参数校验] P --> T[核心处理] T --> O[输出]
graph TD L1[用户 API 层] --> L2[算法/服务层] L2 --> L3[数据结构层] L3 --> L4[运行时与依赖层]

上述图分别展示模块依赖、调用时序、数据流和架构分层。

第 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_errormean_squared_error

MAE 计算预测值与真实值的绝对差的平均值,对异常值不敏感;MSE 计算平方差的平均值,放大了大误差的影响;RMSE 是 MSE 的平方根,具有与目标变量相同的单位,便于解释。所有函数通过 _check_reg_targets_with_floating_dtype 进行输入验证和类型统一。支持 sample_weightmultioutput 参数,实现加权和多输出平均。多输出情况下,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_truey_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 数据流图:基础误差指标的计算流程

graph TD A[输入 y_true, y_pred] --> B[_check_reg_targets_with_floating_dtype] B --> C[统一形状 (n_samples, n_outputs)] C --> D[统一浮点类型] D --> E[计算逐样本误差] E --> F{指标类型} F -->|MAE| G[xp.abs(y_pred - y_true)] F -->|MSE| H[(y_true - y_pred) ** 2] F -->|RMSE| I[sqrt(MSE)] G --> J[_average(axis=0, weights=sample_weight)] H --> J I --> J J --> K[output_errors: (n_outputs,)] K --> L{multioutput} L -->|raw_values| M[返回 output_errors] L -->|uniform_average| N[_average(weights=None)] L -->|array-like| O[_average(weights=multioutput)] N --> P[返回标量] O --> P

图解说明:该流程图展示了基础回归指标(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 > -1y_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 > -1y_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_errorlog1p 空间计算。与 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 数据流图:对数空间与鲁棒指标计算流程

graph TD A[输入 y_true, y_pred] --> B[_check_reg_targets_with_floating_dtype] B --> C{指标类型} C -->|MSLE/RMSLE| D[定义域检查: y > -1] D --> E[log1p 变换] E --> F[调用 MSE/RMSE] C -->|Median AE| G[计算绝对残差] G --> H{sample_weight?} H -->|无| I[_median(axis=0)] H -->|有| J[_weighted_percentile] I --> K[output_errors] J --> K F --> K K --> L[multioutput 聚合] L --> M[返回结果]

图解说明:该流程图对比了对数空间指标(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 计算流程

graph TD A[输入 y_true, y_pred] --> B[_check_reg_targets_with_floating_dtype] B --> C{指标类型} C -->|MAPE| D[计算 epsilon = float64 eps] D --> E[分母 = max(|y_true|, epsilon)] E --> F[相对误差 = |diff| / 分母] F --> G[_average(axis=0, weights=sample_weight)] G --> H[multioutput 聚合] C -->|Max Error| I[_check_reg_targets 单输出检查] I --> J[计算最大绝对残差] J --> K[返回标量] H --> K

图解说明: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 解释方差

graph LR A[总离差平方和 SST] --> B[回归平方和 SSR] A --> C[残差平方和 SSE] B --> D[R² = 1 - SSE/SST] C --> D A --> E[解释方差 = 1 - Var(residual)/Var(y)] E --> F[残差去均值] D -.->|残差均值=0时| F

图解说明:该图揭示了 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_deviancemean_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 <= 0power >= 1;输入验证根据 power 区间检查 y_true/y_pred 的符号要求;不支持多输出;样本权重重塑为列向量用于广播。

31.8.4 逐行解析:mean_poisson_deviancemean_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 参数与分布决策树

graph TD A[选择 power 参数] --> B{power < 0?} B -->|是| C[Extreme Stable<br/>y任意, y_pred>0<br/>极值建模] B -->|否| D{power == 0?} D -->|是| E[Normal/Gaussian<br/>y/y_pred任意<br/>等价 MSE] D -->|否| F{power == 1?} F -->|是| G[Poisson<br/>y≥0, y_pred>0<br/>计数数据] F -->|否| H{1 < power < 2?} H -->|是| I[Compound Poisson<br/>y≥0, y_pred>0<br/>零膨胀计数] H -->|否| J{power == 2?} J -->|是| K[Gamma<br/>y>0, y_pred>0<br/>尺度不变/保费] J -->|否| L{power == 3?} L -->|是| M[Inverse Gaussian<br/>y>0, y_pred>0<br/>生存时间] L -->|否| N[Positive Stable<br/>y>0, y_pred>0<br/>重尾正数据]

图解说明:该决策树直观展示了 Tweedie power 参数如何映射到不同的概率分布族。从左到右依次覆盖:极值稳定分布(power<0)、正态分布(power=0)、泊松分布(power=1)、复合泊松(1<power<2)、伽马分布(power=2)、逆高斯分布(power=3)、正稳定分布(power>2)。每个分支标注了对应的 y_true/y_pred 符号约束与典型应用场景。validate_params 装饰器在代码层面强制执行了 power <= 0power >= 1 的数学定义域约束,排除了 (0,1) 区间无对应分布的情况。

31.9 偏差解释度家族:D² Tweedie、Pinball 与绝对误差

31.9.1 核心概念:D² 分数如何将偏差转化为可解释的模型评分?

d2_tweedie_score 以 Tweedie 偏差为基础,分子为模型偏差,分母为常数模型偏差。d2_pinball_score 基于 Pinball 损失,分母使用经验分位数作为基准预测。d2_absolute_error_scored2_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 的计算流程

graph TD A[输入 y_true, y_pred] --> B[_check_reg_targets_with_floating_dtype] B --> C[统一形状与浮点类型] C --> D[计算残差 diff = y_true - y_pred] D --> E[向量化分段函数] E --> F[sign = (diff >= 0).astype(dtype)] F --> G[loss = alpha * sign * diff - (1-alpha) * (1-sign) * diff] G --> H[_average(axis=0, weights=sample_weight)] H --> I[output_errors: (n_outputs,)] I --> J{multioutput} J -->|raw_values| K[返回 output_errors] J -->|uniform_average| L[_average(weights=None)] J -->|array-like| M[_average(weights=multioutput)] L --> N[返回标量] M --> N

图解说明:该流程图详细展示了 mean_pinball_loss 从输入验证到最终聚合的完整数据流向。关键步骤在于向量化分段函数的实现:通过 sign = (diff >= 0).astype(dtype) 将残差符号转化为 0/1 掩码,然后利用 alpha * sign * diff - (1-alpha) * (1-sign) * diff 这一数学技巧,在无分支的情况下同时计算 diff >= 0 时的 alpha * diffdiff < 0 时的 (1-alpha) * (-diff)。这种向量化实现避免了显式条件判断,充分利用了数组后端(NumPy/CuPy/PyTorch)的并行计算能力,是高性能数值计算的典范模式。

31.9.6 D² 分数族统一框架图

graph TD A[D² 分数通用公式: 1 - Model_Deviance / Null_Deviance] --> B[Tweedie 偏差族] A --> C[Pinball 损失族] A --> D[绝对误差族] B --> B1[power=0: R²] B --> B2[power=1: Poisson D²] B --> B3[power=2: Gamma D²] C --> C1[alpha=0.5: MAE D²] C --> C2[alpha=0.95: 95th分位数 D²] D --> D1[等价于 C1]

图解说明:该框架图揭示了 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

这段代码是所有回归指标的统一入口

  1. 长度一致性检查

  2. check_array 标准化数组(允许 1D)

  3. 样本权重验证

  4. 1D → 2D 重塑 (n_samples, 1)

  5. 输出维度匹配检查

  6. multioutput 参数验证与标准化

  7. 返回 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 输入验证流程图

graph TD A[用户调用指标函数] --> B[_check_reg_targets_with_floating_dtype] B --> C[_find_matching_floating_dtype] C --> D[推断统一浮点类型] D --> E[_check_reg_targets] E --> F[check_consistent_length] F --> G[check_array 标准化] G --> H[样本权重验证] H --> I[1D -> 2D 重塑] I --> J[输出维度匹配检查] J --> K[multioutput 参数验证] K --> L[返回 y_type, y_true, y_pred, sample_weight, multioutput] L --> M[具体指标计算逻辑]

图解说明:该流程图完整描绘了回归指标输入验证的两层架构。外层 _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 测试全景流程图

graph TD A[测试入口] --> B[基础正确性 test_regression_metrics] A --> C[多输出聚合 test_multioutput_regression] A --> D[自定义权重 test_regression_custom_weights] A --> E[边界极限 test_regression_metrics_at_limits] A --> F[Tweedie连续性 test_tweedie_deviance_continuity] A --> G[Pinball理论最优性 test_mean_pinball_loss_on_constant_predictions] A --> H[端到端调优 test_dummy_quantile_parameter_tuning] A --> I[Pinball-MAE关系 test_pinball_loss_relation_with_mae] B --> B1[线性数据验证全指标] B --> B2[指标间数学等价关系] C --> C1[raw_values/uniform/variance_weighted] C --> C2[常数目标force_finite行为] D --> D1[输出权重加权平均] E --> E1[单样本/常数目标] E --> E2[MSLE定义域检查] E --> E3[Tweedie power边界验证] F --> F1[power边界数值连续性] G --> G1[多重分布分位数最优性] G --> G2[解析公式交叉验证] H --> H1[GridSearchCV集成] H --> H2[DummyRegressor量化调优] I --> I1[alpha=0.5时PBL=MAE/2]

图解说明:该全景流程图展示了回归指标测试体系的八大核心支柱,覆盖从基础正确性到边界极限、从数学理论验证到端到端集成的完整测试链路。test_regression_metrics 作为基石,验证所有指标在标准线性数据上的数学正确性及指标间等价关系(如 Tweedie power=0 ≡ MSE)。test_multioutput_regressiontest_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 动手练习

  1. 阅读回归指标核心实现

    • 阅读 sklearn/metrics/_regression.py 第80-310行,理解以下指标的实现:

      1. mean_absolute_error() - 平均绝对误差

      2. mean_squared_error() - 均方误差

      3. root_mean_squared_error() - 均方根误差

    • 回答问题:

      • 这些函数如何处理多输出情况?

      • sample_weight 参数在计算中的作用是什么?

      • 为什么需要 _check_reg_targets_with_floating_dtype 进行输入验证?

  2. 探索解释力和偏差解释指标

    • 阅读 sklearn/metrics/_regression.py 第290-500行,理解以下指标的实现:

      1. explained_variance_score() - 解释方差

      2. r2_score() - 决定系数

      3. _assemble_fraction_of_explained_deviance() - 通用聚合函数

    • 回答问题:

      • R²和解释方差在什么情况下等价?何时会出现差异?

      • force_finite 参数如何处理常数目标情况下的非有限值?

      • 多输出情况下,variance_weighted 模式与 uniform_average 有什么区别?

  3. 研究基于偏差的损失函数

    • 阅读 sklearn/metrics/_regression.py 第500-680行,理解以下指标的实现:

      1. mean_tweedie_deviance() - 通用Tweedie偏差

      2. mean_poisson_deviance()mean_gamma_deviance - 特化偏差

      3. d2_tweedie_score() - Tweedie偏差解释度

    • 回答问题:

      • Tweedie偏差如何通过power参数统一正态、泊松、伽马等分布?

      • 不同power值对应的输入验证条件是什么?为什么需要这些限制?

      • D²分数族如何将偏差转化为可解释度量?其数学原理是什么?

  4. 分位数回归与对数空间误差指标

    • 阅读 sklearn/metrics/_regression.py 第140-250行及第310-380行,理解以下指标的实现:

      1. mean_pinball_loss() - 分位数损失

      2. mean_absolute_percentage_error() - 平均绝对百分比误差

      3. mean_squared_log_error()root_mean_squared_log_error() - 对数空间误差

    • 回答问题:

      • Pinball损失如何通过alpha参数控制分位数?当α=0.5时与MAE有何关系?

      • MAPE如何处理y_true=0的情况?引入epsilon的作用是什么?

      • MSLE/RMSLE要求y_true > -1y_pred > -1的数学原因是什么?

  5. 鲁棒指标与输入验证机制

    • 阅读 sklearn/metrics/_regression.py 第380-450行及第1-80行,理解以下内容:

      1. median_absolute_error() - 中位数绝对误差(含加权中位数实现)

      2. max_error() - 最大残差

      3. _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 / NDCGTop‑k 准确率 在信息检索与推荐场景中的细节实现。

  • 了解 Array API 兼容层 如何在排名指标计算中完成跨后端(NumPy / CuPy / JAX …)的分派。


32.2 生活类比

模型的预测分数 想象成搜索引擎为每篇文档打的相关性分,则各类评估曲线就是在这座“信息检索评分大厅”里观察不同仪表盘的变化:

  • ROC 曲线 像一块 “灵敏度‑特异度权衡仪表盘”。当阈值从高到低滑动时,召回率(TPR)与误报率(FPR)会互相增减。

  • AUC 则是这块仪表盘的 单数值总结——随机抽取一正样本和一负样本,正样本分数高于负样本的概率。

  • Precision‑Recall 曲线 更像 “查准‑查全平衡尺”。在正样本稀缺的情形下,它比 ROC 更敏感,曲线下的面积(AP)是对每一次召回提升的精度加权求和。

  • DET 曲线FPRFNR(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_curveprecision_recall_curvedet_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 / Ntpr = 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),
    )
  • 为何 precisionrecall 数组比 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. 召回率),在概率坐标系下直观展示分类器的整体辨别能力。

    • DETFPRFNR = 1‑TPR 投射到 正态分位数(即 norm.ppf 轴),在极低误报/误漏区的细粒度比较更具可读性。

32.4.5 drop_intermediate 参数的实现细节

drop_intermediate 通过 二阶差分 (np.diff(..., 2)) 判断相邻三个点是否共线(在 ROC)或是否形成垂直/水平线段(在 PR/DET),并仅保留拐点与两端点。这样可以 显著减少绘图点数,而不影响 AUCAP 结果,因为这些指标只依赖 曲线的凸包 而非共线点。

32.4.6 Array API 兼容层

  • get_namespace_and_device 根据输入的 array‑like 自动返回对应的命名空间 xp(NumPy、CuPy、JAX 等)以及设备信息。

  • 之后的所有算子(如 xp.argsortxp.cumulative_sumxp.concat)均通过 xp 调用,实现 跨后端无缝切换

  • move_to 用于在需要迁移对象(例如 y_truesample_weight)到目标后端时进行统一搬运,保证所有变量位于同一设备上。

graph TD A[输入 y_true, y_score] --> B[confusion_matrix_at_thresholds] B --> C[tns, fps, fns, tps, thresholds] C --> D[roc_curve: fpr = fps / N, tpr = tps / P] C --> E[precision_recall_curve: precision = tps/(tps+fps), recall = tps / P] C --> F[det_curve: fnr = 1 - tpr, 同 fps]

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,
        )
posted @ 2026-09-04 04:09  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报