Sklearn-源码解析-书-v1-0-十五-

Sklearn 源码解析(书)v1.0(十五)

代码解析

函数根据 samplewise 参数输出两种形状:samplewise=False 时为 (n_labels, 2, 2),每个类别一个 2x2 矩阵;samplewise=True 时为 (n_samples, 2, 2),每个样本一个矩阵。核心统计量 tp_sumpred_sumtrue_sum 通过 _count_nonzero 计算,支持稀疏矩阵乘法和样本权重。对于二分类/多分类(一维标签),它先用 LabelEncoder 编码,再用 _bincount 统计。最后根据 TP/FP/FN 推导 TN,组装成 2x2 矩阵堆叠返回。这种设计为上层的 jaccard_scorehamming_loss 等指标提供了统一的充分统计量基础。

29.5.4 核心分发函数:precision_recall_fscore_support

如果混淆矩阵是分类指标的“原子基石”,那么 precision_recall_fscore_support 就是它们的“核心分发中心”。该函数是一个单一入口,能够同时计算 Precision、Recall、F-beta 分数和 Support,所有上层指标如 f1_score, precision_score, recall_score 等实际上都是对它的轻量封装。

29.5.4.1 _check_zero_division_warn_prf

在核心计算前,我们需要了解零除处理的基础设施。

def _check_zero_division(zero_division):
    if isinstance(zero_division, str) and zero_division == "warn":
        return np.float64(0.0)
    elif isinstance(zero_division, (int, float)) and zero_division in [0, 1]:
        return np.float64(zero_division)
    else:  # np.isnan(zero_division)
        return np.nan
def _warn_prf(average, modifier, msg_start, result_size):
    axis0, axis1 = "sample", "label"
    if average == "samples":
        axis0, axis1 = axis1, axis0
    msg = (
        "{0} ill-defined and being set to 0.0 {{0}} "
        "no {1} {2}s. Use `zero_division` parameter to control"
        " this behavior.".format(msg_start, modifier, axis0)
    )
    if result_size == 1:
        msg = msg.format("due to")
    else:
        msg = msg.format("in {0}s with".format(axis1))
    warnings.warn(msg, UndefinedMetricWarning, stacklevel=2)

代码解析

_check_zero_division 将用户传入的 zero_division 参数标准化为数值:"warn" 对应 0.0 但会触发警告,0/1 直接返回对应浮点数,np.nan 返回 NaN。_warn_prf 根据平均模式和指标类型构造人类可读的警告信息,如 "Precision is ill-defined and being set to 0.0 in labels with no predicted samples"。

29.5.4.2 _check_set_wise_labels

def _check_set_wise_labels(y_true, y_pred, average, labels, pos_label):
    average_options = (None, "micro", "macro", "weighted", "samples")
    if average not in average_options and average != "binary":
        raise ValueError("average has to be one of " + str(average_options))

    y_true, y_pred = attach_unique(y_true, y_pred)
    y_type, y_true, y_pred, _ = _check_targets(y_true, y_pred)
    present_labels = unique_labels(y_true, y_pred)
    if average == "binary":
        if y_type == "binary":
            if pos_label not in present_labels:
                if len(present_labels) >= 2:
                    raise ValueError(
                        f"pos_label={pos_label} is not a valid label. It "
                        f"should be one of {present_labels}"
                    )
            labels = [pos_label]
        else:
            average_options = list(average_options)
            if y_type == "multiclass":
                average_options.remove("samples")
            raise ValueError(
                "Target is %s but average='binary'. Please "
                "choose another average setting, one of %r." % (y_type, average_options)
            )
    elif pos_label not in (None, 1):
        warnings.warn(
            "Note that pos_label (set to %r) is ignored when "
            "average != 'binary' (got %r). You may use "
            "labels=[pos_label] to specify a single positive class."
            % (pos_label, average),
            UserWarning,
        )
    return labels

代码解析

该函数统一处理集合类指标的标签校验与 average/pos_label 参数的合法性检查。它确保 binary 平均模式仅用于二分类任务,并正确推断 labels 列表;对于非二分类任务忽略 pos_label 并发出提示。

29.5.4.3 _prf_divide

def _prf_divide(
    numerator, denominator, metric, modifier, average, warn_for, zero_division="warn"
):
    xp, _ = get_namespace(numerator, denominator)
    dtype_float = _find_matching_floating_dtype(numerator, denominator, xp=xp)
    mask = denominator == 0
    denominator = xp.asarray(denominator, copy=True, dtype=dtype_float)
    denominator[mask] = 1  # avoid infs/nans
    result = xp.asarray(numerator, dtype=dtype_float) / denominator

    if not xp.any(mask):
        return result

    zero_division_value = _check_zero_division(zero_division)
    result[mask] = zero_division_value

    if zero_division != "warn" or metric not in warn_for:
        return result

    if metric in warn_for:
        _warn_prf(average, modifier, f"{metric.capitalize()} is", result.shape[0])

    return result

代码解析

这是统一的除法操作,处理分母为零的情况。它先用掩码标记零分母位置,将分母临时置 1 避免 Inf/NaN,计算完毕后将对应位置替换为 zero_division_value。仅当 zero_division="warn" 且该指标在 warn_for 列表中时才发出警告。使用 _find_matching_floating_dtype 保证跨后端 dtype 一致性。

29.5.4.4 precision_recall_fscore_support

@validate_params(
    {
        "y_true": ["array-like", "sparse matrix"],
        "y_pred": ["array-like", "sparse matrix"],
        "beta": [Interval(Real, 0.0, None, closed="both")],
        "labels": ["array-like", None],
        "pos_label": [Real, str, "boolean", None],
        "average": [
            StrOptions({"micro", "macro", "samples", "weighted", "binary"}),
            None,
        ],
        "warn_for": [list, tuple, set],
        "sample_weight": ["array-like", None],
        "zero_division": [
            Options(Real, {0.0, 1.0}),
            "nan",
            StrOptions({"warn"}),
        ],
    },
    prefer_skip_nested_validation=True,
)
def precision_recall_fscore_support(
    y_true,
    y_pred,
    *,
    beta=1.0,
    labels=None,
    pos_label=1,
    average=None,
    warn_for=("precision", "recall", "f-score"),
    sample_weight=None,
    zero_division="warn",
):
    _check_zero_division(zero_division)
    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,
    )
    tp_sum = MCM[:, 1, 1]
    pred_sum = tp_sum + MCM[:, 0, 1]
    true_sum = tp_sum + MCM[:, 1, 0]

    xp, _, device_ = get_namespace_and_device(y_true, y_pred)
    if average == "micro":
        tp_sum = xp.reshape(xp.sum(tp_sum), (1,))
        pred_sum = xp.reshape(xp.sum(pred_sum), (1,))
        true_sum = xp.reshape(xp.sum(true_sum), (1,))

    beta2 = beta**2

    precision = _prf_divide(
        tp_sum, pred_sum, "precision", "predicted", average, warn_for, zero_division
    )
    recall = _prf_divide(
        tp_sum, true_sum, "recall", "true", average, warn_for, zero_division
    )

    if np.isposinf(beta):
        f_score = recall
    elif beta == 0:
        f_score = precision
    else:
        max_float_type = _max_precision_float_dtype(xp=xp, device=device_)
        denom = beta2 * xp.astype(true_sum, max_float_type) + xp.astype(
            pred_sum, max_float_type
        )
        f_score = _prf_divide(
            (1 + beta2) * xp.astype(tp_sum, max_float_type),
            denom,
            "f-score",
            "true nor predicted",
            average,
            warn_for,
            zero_division,
        )

    if average == "weighted":
        weights = true_sum
    elif average == "samples":
        weights = sample_weight
    else:
        weights = None

    if average is not None:
        precision = float(_nanaverage(precision, weights=weights))
        recall = float(_nanaverage(recall, weights=weights))
        f_score = float(_nanaverage(f_score, weights=weights))
        true_sum = None

    return precision, recall, f_score, true_sum

代码解析

这是分类指标的核心分发函数。流程如下:

  1. 参数校验与标签处理(_check_zero_division_check_set_wise_labels)。

  2. 调用 multilabel_confusion_matrix 获取 MCM,提取三个充分统计量:tp_sum (TP)、pred_sum (TP+FP)、true_sum (TP+FN)。

  3. micro 平均时将三个统计量全局求和。

  4. 使用 _prf_divide 分别计算 Precision = TP/(TP+FP)、Recall = TP/(TP+FN),统一处理零除。

  5. F-beta 计算:当 beta=inf 退化为 Recall,beta=0 退化为 Precision,否则使用公式 (1+β²)*TP / ((1+β²)*TP + β²*FN + FP),通过代数变换避免了显式计算 P 和 R 再调和平均,数值更稳定。

  6. 根据 average 选择权重进行最终聚合:weightedtrue_sum(支持度),samplessample_weight,其余为均匀平均。

这种设计实现了“一次统计,多指标复用”,保证了一致性和性能。

29.5.5 关键指标的数学实现细节

基于上述核心骨架,各上层指标实现简洁明了。

29.5.5.1 accuracy_score

@validate_params(
    {
        "y_true": ["array-like", "sparse matrix"],
        "y_pred": ["array-like", "sparse matrix"],
        "normalize": ["boolean"],
        "sample_weight": ["array-like", None],
    },
    prefer_skip_nested_validation=True,
)
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)
    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.startswith("multilabel"):
        differing_labels = _count_nonzero(y_true - y_pred, xp=xp, device=device, axis=1)
        score = xp.asarray(differing_labels == 0, device=device)
    else:
        score = y_true == y_pred

    return float(_average(score, weights=sample_weight, normalize=normalize, xp=xp))

代码解析

多标签下计算子集准确率:逐样本比较所有标签是否完全匹配(differing_labels == 0),再加权平均。单标签下直接逐元素比较。

29.5.5.2 jaccard_score

@validate_params(
    {
        "y_true": ["array-like", "sparse matrix"],
        "y_pred": ["array-like", "sparse matrix"],
        "labels": ["array-like", None],
        "pos_label": [Real, str, "boolean", None],
        "average": [
            StrOptions({"micro", "macro", "samples", "weighted", "binary"}),
            None,
        ],
        "sample_weight": ["array-like", None],
        "zero_division": [
            Options(Real, {0.0, 1.0}),
            "nan",
            StrOptions({"warn"}),
        ],
    },
    prefer_skip_nested_validation=True,
)
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,
    )
    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]
        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 = TP / (TP + FP + FN)。直接从 MCM 提取分子分母,micro 平均时全局求和,最后用 _prf_divide 处理零除并按权重聚合。

29.5.5.3 matthews_corrcoef

@validate_params(
    {
        "y_true": ["array-like"],
        "y_pred": ["array-like"],
        "sample_weight": ["array-like", None],
    },
    prefer_skip_nested_validation=True,
)
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))

代码解析

MCC 基于混淆矩阵的协方差公式:(TP*TN - FP*FN) / sqrt((TP+FP)(TP+FN)(TN+FP)(TN+FN)) 的多类推广形式。它利用 confusion_matrix 计算 C,再通过行和、列和、迹计算协方差,自然支持多分类,取值范围 [-1, 1]。

29.5.5.4 cohen_kappa_score

@validate_params(
    {
        "y1": ["array-like"],
        "y2": ["array-like"],
        "labels": ["array-like", None],
        "weights": [StrOptions({"linear", "quadratic"}), None],
        "sample_weight": ["array-like", None],
        "replace_undefined_by": [
            Interval(Real, -1.0, 1.0, closed="both"),
            np.nan,
        ],
    },
    prefer_skip_nested_validation=True,
)
def cohen_kappa_score(
    y1,
    y2,
    *,
    labels=None,
    weights=None,
    sample_weight=None,
    replace_undefined_by=np.nan,
):
    try:
        confusion = confusion_matrix(y1, y2, labels=labels, sample_weight=sample_weight)
    except ValueError as e:
        if "At least one label specified must be in y_true" in str(e):
            msg = (
                "At least one label in `labels` must be present in `y1` (even though "
                "`cohen_kappa_score` is otherwise agnostic to the order of `y1` and "
                "`y2`)."
            )
            raise ValueError(msg) from e
        raise

    xp, _, device_ = get_namespace_and_device(y1, y2)
    n_classes = confusion.shape[0]
    max_float_dtype = _max_precision_float_dtype(xp, device=device_)
    confusion = xp.astype(confusion, max_float_dtype, copy=False)
    sum0 = xp.sum(confusion, axis=0)
    sum1 = xp.sum(confusion, axis=1)

    numerator = xp.linalg.outer(sum0, sum1)
    denominator = xp.sum(sum0)
    msg_zero_division = (
        "`y2` contains no labels that are present in both `y1` and `labels`."
        "`cohen_kappa_score` is undefined and set to the value defined by "
        f"the `replace_undefined_by` param, which is set to {replace_undefined_by}."
    )
    if denominator == 0:
        warnings.warn(msg_zero_division, UndefinedMetricWarning, stacklevel=2)
        return replace_undefined_by

    expected = numerator / denominator

    if weights is None:
        w_mat = xp.ones([n_classes, n_classes], dtype=max_float_dtype, device=device_)
        _fill_diagonal(w_mat, 0, xp=xp)
    else:
        w_mat = xp.zeros([n_classes, n_classes], dtype=max_float_dtype, device=device_)
        w_mat += xp.arange(n_classes)
        if weights == "linear":
            w_mat = xp.abs(w_mat - w_mat.T)
        else:
            w_mat = (w_mat - w_mat.T) ** 2

    numerator = xp.sum(w_mat * confusion)
    denominator = xp.sum(w_mat * expected)
    msg_zero_division = (
        "`y1`, `y2` and `labels` have only one label in common. "
        "`cohen_kappa_score` is undefined and set to the value defined by the "
        f"the `replace_undefined_by` param, which is set to {replace_undefined_by}."
    )
    if denominator == 0:
        warnings.warn(msg_zero_division, UndefinedMetricWarning, stacklevel=2)
        return replace_undefined_by

    k = numerator / denominator
    return float(1 - k)

代码解析

Cohen's Kappa = (观测一致性 - 期望一致性) / (1 - 期望一致性)。观测一致性来自混淆矩阵对角线,期望一致性假设两标注者独立(外积归一化)。支持 linear/quadratic 加权处理有序标签,权重矩阵基于类别索引差的绝对值或平方。零分母情况返回 replace_undefined_by 并警告。

29.5.5.5 log_loss_log_loss

@validate_params(
    {
        "y_true": ["array-like", "sparse matrix"],
        "y_pred": ["array-like", "sparse matrix"],
        "normalize": ["boolean"],
        "sample_weight": ["array-like", None],
        "labels": ["array-like", None],
    },
    prefer_skip_nested_validation=True,
)
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)
    transformed_labels = xp.astype(transformed_labels, y_pred.dtype, copy=False)
    loss = -xp.sum(_xlogy(transformed_labels, y_pred, xp=xp), axis=1)
    return float(_average(loss, weights=sample_weight, normalize=normalize))

代码解析

log_loss 先调用 _validate_multiclass_probabilistic_prediction 将标签 One-Hot 编码并验证概率预测合法性(归一化、范围 [0,1]、类别数一致)。_log_loss 核心计算:裁剪概率到 [eps, 1-eps] 避免 log(0),使用 _xlogy 计算 y * log(p)(处理 y=0 时的 0*log(0)=0),逐样本求和后加权平均。

29.5.5.6 _validate_multiclass_probabilistic_prediction_one_hot_encoding_multiclass_target

def _validate_multiclass_probabilistic_prediction(
    y_true, y_prob, sample_weight, labels
):
    xp, _, device_ = get_namespace_and_device(y_prob)

    if xp.max(y_prob) > 1:
        raise ValueError(f"y_prob contains values greater than 1: {xp.max(y_prob)}")
    if xp.min(y_prob) < 0:
        raise ValueError(f"y_prob contains values lower than 0: {xp.min(y_prob)}")

    check_consistent_length(y_prob, y_true, sample_weight)
    if sample_weight is not None:
        _check_sample_weight(sample_weight, y_prob, force_float_dtype=False)

    transformed_labels, lb_classes = _one_hot_encoding_multiclass_target(
        y_true=y_true, labels=labels, target_xp=xp, target_device=device_
    )

    if y_prob.ndim == 1:
        y_prob = y_prob[:, xp.newaxis]
    if y_prob.shape[1] == 1:
        y_prob = xp.concat([1 - y_prob, y_prob], axis=1)

    eps = xp.finfo(y_prob.dtype).eps
    y_prob_sum = xp.sum(y_prob, axis=1)

    if not xp.all(
        xpx.isclose(
            y_prob_sum,
            xp.asarray(1, dtype=y_prob_sum.dtype, device=device_),
            rtol=sqrt(eps),
        )
    ):
        warnings.warn(
            "The y_prob values do not sum to one. Make sure to pass probabilities.",
            UserWarning,
        )

    if lb_classes.shape[0] != y_prob.shape[1]:
        if labels is None:
            raise ValueError(
                "y_true and y_prob contain different number of "
                "classes: {0} vs {1}. Please provide the true "
                "labels explicitly through the labels argument. "
                "Classes found in "
                "y_true: {2}".format(
                    transformed_labels.shape[1], y_prob.shape[1], lb_classes
                )
            )
        else:
            raise ValueError(
                "The number of classes in labels is different "
                "from that in y_prob. Classes found in "
                "labels: {0}".format(lb_classes)
            )

    return transformed_labels, y_prob
def _one_hot_encoding_multiclass_target(y_true, labels, target_xp, target_device):
    xp, _ = get_namespace(y_true)

    lb = LabelBinarizer()
    if labels is not None:
        lb = lb.fit(labels)
        if not xp.all(lb.classes_ == labels):
            warnings.warn(
                f"Labels passed were {labels}. But this function "
                "assumes labels are ordered lexicographically. "
                f"Pass the ordered labels={lb.classes_.tolist()} and ensure that "
                "the columns of y_prob correspond to this ordering.",
                UserWarning,
            )
        if not xp.all(_isin(y_true, labels, xp=xp)):
            undeclared_labels = set(y_true) - set(labels)
            raise ValueError(
                f"y_true contains values {undeclared_labels} not belonging "
                f"to the passed labels {labels}."
            )
    else:
        lb = lb.fit(y_true)

    if lb.classes_.shape[0] == 1:
        if labels is None:
            raise ValueError(
                "y_true contains only one label ({0}). Please "
                "provide the list of all expected class labels explicitly through the "
                "labels argument.".format(lb.classes_[0])
            )
        else:
            raise ValueError(
                "The labels array needs to contain at least two "
                "labels, got {0}.".format(lb.classes_)
            )

    transformed_labels = lb.transform(y_true)
    transformed_labels = target_xp.asarray(transformed_labels, device=target_device)
    if transformed_labels.shape[1] == 1:
        transformed_labels = target_xp.concat(
            (1 - transformed_labels, transformed_labels), axis=1
        )
    return transformed_labels, lb.classes_

代码解析

_one_hot_encoding_multiclass_target 使用 LabelBinarizer 将标签转为 One-Hot,处理单类别报错、标签顺序校验、未声明标签检查。_validate_multiclass_probabilistic_prediction 进一步验证概率预测:维度对齐、概率归一化检查、类别数一致性。二分类概率 (n_samples,) 自动扩展为 (n_samples, 2)。

29.5.5.7 brier_score_loss

@validate_params(
    {
        "y_true": ["array-like"],
        "y_proba": ["array-like"],
        "sample_weight": ["array-like", None],
        "pos_label": [Real, str, "boolean", None],
        "labels": ["array-like", None],
        "scale_by_half": ["boolean", StrOptions({"auto"})],
    },
    prefer_skip_nested_validation=True,
)
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_)

    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
    )

    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)

代码解析

Brier Score = 平均平方差。二分类调用 _validate_binary_probabilistic_prediction,多分类调用多类验证。scale_by_half="auto" 在二分类时缩放 1/2 使范围 [0,1],多分类保持 [0,2]。

29.5.5.8 _validate_binary_probabilistic_prediction_one_hot_encoding_binary_target

def _validate_binary_probabilistic_prediction(y_true, y_prob, sample_weight, pos_label):
    y_true = column_or_1d(y_true)
    y_prob = column_or_1d(y_prob)

    assert_all_finite(y_true)
    assert_all_finite(y_prob)

    check_consistent_length(y_prob, y_true, sample_weight)
    if sample_weight is not None:
        _check_sample_weight(sample_weight, y_prob, force_float_dtype=False)

    y_type = type_of_target(y_true, input_name="y_true")
    if y_type != "binary":
        raise ValueError(
            f"The type of the target inferred from y_true is {y_type} but should be "
            "binary according to the shape of y_prob."
        )

    xp, _, device_ = get_namespace_and_device(y_prob)
    if xp.max(y_prob) > 1:
        raise ValueError(f"y_prob contains values greater than 1: {xp.max(y_prob)}")
    if xp.min(y_prob) < 0:
        raise ValueError(f"y_prob contains values less than 0: {xp.min(y_prob)}")

    try:
        pos_label = _check_pos_label_consistency(pos_label, y_true)
    except ValueError:
        xp_y_true, _ = get_namespace(y_true)
        classes = xp_y_true.unique_values(y_true)
        if not (_is_numpy_namespace(xp_y_true) and classes.dtype.kind in "OUS"):
            pos_label = classes[-1]
        else:
            raise

    transformed_labels = _one_hot_encoding_binary_target(
        y_true=y_true, pos_label=pos_label, target_xp=xp, target_device=device_
    )
    y_prob = xp.stack((1 - y_prob, y_prob), axis=1)

    return transformed_labels, y_prob
def _one_hot_encoding_binary_target(y_true, pos_label, target_xp, target_device):
    xp_y_true, _ = get_namespace(y_true)
    y_true_pos = xp_y_true.asarray(y_true == pos_label, dtype=xp_y_true.int64)
    y_true_pos = target_xp.asarray(y_true_pos, device=target_device)
    return target_xp.stack((1 - y_true_pos, y_true_pos), axis=1)

代码解析

二分类专用验证:将标签按 pos_label 二值化为 One-Hot,概率扩展为 [1-p, p]。自动推断 pos_label:非字符串标签取最大值,字符串需显式指定。

29.5.5.9 hinge_loss

@validate_params(
    {
        "y_true": ["array-like"],
        "pred_decision": ["array-like"],
        "labels": ["array-like", None],
        "sample_weight": ["array-like", None],
    },
    prefer_skip_nested_validation=True,
)
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:
        if pred_decision.ndim <= 1:
            raise ValueError(
                "The shape of pred_decision cannot be 1d array"
                "with a multiclass target. pred_decision shape "
                "must be (n_samples, n_classes), that is "
                f"({y_true.shape[0]}, {y_true_unique.size})."
                f" Got: {pred_decision.shape}"
            )

        if y_true_unique.size != pred_decision.shape[1]:
            if labels is None:
                raise ValueError(
                    "Please include all labels in y_true "
                    "or pass labels as third argument"
                )
            else:
                raise ValueError(
                    "The shape of pred_decision is not "
                    "consistent with the number of classes. "
                    "With a multiclass target, pred_decision "
                    "shape must be "
                    "(n_samples, n_classes), that is "
                    f"({y_true.shape[0]}, {y_true_unique.size}). "
                    f"Got: {pred_decision.shape}"
                )
        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:
        pred_decision = column_or_1d(pred_decision)
        pred_decision = np.ravel(pred_decision)

        lbin = LabelBinarizer(neg_label=-1)
        y_true = lbin.fit_transform(y_true)[:, 0]

        try:
            margin = y_true * pred_decision
        except TypeError:
            raise TypeError("pred_decision should be an array of floats.")

    losses = 1 - margin
    np.clip(losses, 0, None, out=losses)
    return float(np.average(losses, weights=sample_weight))

代码解析

二分类:margin = y_true * pred_decision(标签编码为 ±1),损失 max(0, 1-margin)。多分类:Crammer-Singer 形式,margin = 正确类得分 - max(其他类得分),损失 max(0, 1-margin)。累积损失是错误数的上界。

29.5.5.10 d2_log_loss_scored2_brier_score

@validate_params(
    {
        "y_true": ["array-like"],
        "y_pred": ["array-like"],
        "sample_weight": ["array-like", None],
        "labels": ["array-like", None],
    },
    prefer_skip_nested_validation=True,
)
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:
        msg = "D^2 score is not well-defined with less than two samples."
        warnings.warn(msg, 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
    )
    xp, _ = get_namespace(y_pred, transformed_labels)
    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))
@validate_params(
    {
        "y_true": ["array-like"],
        "y_proba": ["array-like"],
        "sample_weight": ["array-like", None],
        "pos_label": [Real, str, "boolean", None],
        "labels": ["array-like", None],
    },
    prefer_skip_nested_validation=True,
)
def d2_brier_score(
    y_true,
    y_proba,
    *,
    sample_weight=None,
    pos_label=None,
    labels=None,
):
    check_consistent_length(y_proba, y_true, sample_weight)
    if _num_samples(y_proba) < 2:
        msg = "D^2 score is not well-defined with less than two samples."
        warnings.warn(msg, UndefinedMetricWarning)
        return float("nan")

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

    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)

代码解析

D² 分数族:1 - (模型损失 / 零模型损失)。零模型使用经验分布:d2_log_loss_score 用类别先验概率,d2_brier_score 用类别均值。分子分母复用核心损失函数 _log_loss 和 Brier 计算逻辑,样本数<2 返回 NaN 并警告。

29.5.5.11 classification_report

@validate_params(
    {
        "y_true": ["array-like", "sparse matrix"],
        "y_pred": ["array-like", "sparse matrix"],
        "labels": ["array-like", None],
        "target_names": ["array-like", None],
        "sample_weight": ["array-like", None],
        "digits": [Interval(Integral, 0, None, closed="left")],
        "output_dict": ["boolean"],
        "zero_division": [
            Options(Real, {0.0, 1.0}),
            "nan",
            StrOptions({"warn"}),
        ],
    },
    prefer_skip_nested_validation=True,
)
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_is_accuracy = (y_type == "multiclass" or y_type == "binary") and (
        not labels_given or (set(labels) >= set(unique_labels(y_true, y_pred)))
    )

    if target_names is not None and len(labels) != len(target_names):
        if labels_given:
            warnings.warn(
                "labels size, {0}, does not match size of target_names, {1}".format(
                    len(labels), len(target_names)
                )
            )
        else:
            raise ValueError(
                "Number of classes, {0}, does not match size of "
                "target_names, {1}. Try specifying the labels "
                "parameter".format(len(labels), len(target_names))
            )
    if target_names is None:
        target_names = ["%s" % l for l in labels]

    headers = ["precision", "recall", "f1-score", "support"]
    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, p, r, f1, s)

    if y_type.startswith("multilabel"):
        average_options = ("micro", "macro", "weighted", "samples")
    else:
        average_options = ("micro", "macro", "weighted")

    if output_dict:
        report_dict = {label[0]: label[1:] for label in rows}
        for label, scores in report_dict.items():
            report_dict[label] = dict(zip(headers, [float(i) for i in scores]))
    else:
        longest_last_line_heading = "weighted avg"
        name_width = max(len(cn) for cn in target_names)
        width = max(name_width, len(longest_last_line_heading), digits)
        head_fmt = "{:>{width}s} " + " {:>9}" * len(headers)
        report = head_fmt.format("", *headers, width=width)
        report += "\n\n"
        row_fmt = "{:>{width}s} " + " {:>9.{digits}f}" * 3 + " {:>9}\n"
        for row in rows:
            report += row_fmt.format(*row, width=width, digits=digits)
        report += "\n"

    for average in average_options:
        if average.startswith("micro") and micro_is_accuracy:
            line_heading = "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:
            report_dict[line_heading] = dict(zip(headers, [float(i) for i in avg]))
        else:
            if line_heading == "accuracy":
                row_fmt_accuracy = (
                    "{:>{width}s} "
                    + " {:>9.{digits}}" * 2
                    + " {:>9.{digits}f}"
                    + " {:>9}\n"
                )
                report += row_fmt_accuracy.format(
                    line_heading, "", "", *avg[2:], width=width, digits=digits
                )
            else:
                report += row_fmt.format(line_heading, *avg, width=width, digits=digits)

    if output_dict:
        if "accuracy" in report_dict.keys():
            report_dict["accuracy"] = report_dict["accuracy"]["precision"]
        return report_dict
    else:
        return report

代码解析

生成文本/字典报告。先计算逐类别 P/R/F1/support(average=None),再遍历平均策略计算汇总行。多标签额外包含 samples 平均。micro 平均在全类别覆盖时等同于 accuracy,显示为 "accuracy" 行。格式化输出对齐列宽,字典模式便于程序化处理。

29.5.5.12 hamming_loss

@validate_params(
    {
        "y_true": ["array-like", "sparse matrix"],
        "y_pred": ["array-like", "sparse matrix"],
        "sample_weight": ["array-like", None],
    },
    prefer_skip_nested_validation=True,
)
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"]:
        return float(
            _average(y_true != y_pred, weights=sample_weight, normalize=True, xp=xp)
        )
    else:
        raise ValueError("{0} is not supported".format(y_type))

代码解析

多标签:逐标签错误率,分母为 n_samples * n_labels。单标签:等价于 1 - accuracy_score。宽于 zero_one_loss,不要求完全匹配。

29.5.5.13 zero_one_loss

@validate_params(
    {
        "y_true": ["array-like", "sparse matrix"],
        "y_pred": ["array-like", "sparse matrix"],
        "normalize": ["boolean"],
        "sample_weight": ["array-like", None],
    },
    prefer_skip_nested_validation=True,
)
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:
        if sample_weight is not None:
            n_samples = xp.sum(sample_weight)
        else:
            n_samples = _num_samples(y_true)
        return n_samples - score

代码解析

直接复用 accuracy_score:归一化时 1 - accuracy,非归一化时 总样本数 - 正确数。多标签下为子集 0-1 损失。

29.5.5.14 balanced_accuracy_score

@validate_params(
    {
        "y_true": ["array-like"],
        "y_pred": ["array-like"],
        "sample_weight": ["array-like", None],
        "adjusted": ["boolean"],
    },
    prefer_skip_nested_validation=True,
)
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)
    if _is_xp_namespace(xp, "array_api_strict"):
        C = xp.astype(C, _max_precision_float_dtype(xp, device=device_), copy=False)

    context_manager = (
        np.errstate(divide="ignore", invalid="ignore")
        if _is_numpy_namespace(xp)
        else nullcontext()
    )
    with context_manager:
        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)

代码解析

平衡准确率 = 各类别 Recall 宏平均。从混淆矩阵对角线除以行和得到逐类别 Recall。adjusted=True 时扣除随机猜测基线 1/n_classes,映射到 [0,1]。

29.5.5.15 precision_scorerecall_scoref1_scorefbeta_score

def precision_score(...):
    p, _, _, _ = precision_recall_fscore_support(
        y_true,
        y_pred,
        labels=labels,
        pos_label=pos_label,
        average=average,
        warn_for=("precision",),
        sample_weight=sample_weight,
        zero_division=zero_division,
    )
    return p
def recall_score(...):
    _, r, _, _ = precision_recall_fscore_support(
        y_true,
        y_pred,
        labels=labels,
        pos_label=pos_label,
        average=average,
        warn_for=("recall",),
        sample_weight=sample_weight,
        zero_division=zero_division,
    )
    return r
def f1_score(...):
    return fbeta_score(
        y_true,
        y_pred,
        beta=1,
        labels=labels,
        pos_label=pos_label,
        average=average,
        sample_weight=sample_weight,
        zero_division=zero_division,
    )
def fbeta_score(
    y_true,
    y_pred,
    *,
    beta,
    labels=None,
    pos_label=1,
    average="binary",
    sample_weight=None,
    zero_division="warn",
):
    _, _, f, _ = precision_recall_fscore_support(
        y_true,
        y_pred,
        beta=beta,
        labels=labels,
        pos_label=pos_label,
        average=average,
        warn_for=("f-score",),
        sample_weight=sample_weight,
        zero_division=zero_division,
    )
    return f

代码解析

均为 precision_recall_fscore_support 的薄封装,仅提取所需返回值,warn_for 参数控制仅对目标指标发警告。

29.5.5.16 class_likelihood_ratios

@validate_params(
    {
        "y_true": ["array-like", "sparse matrix"],
        "y_pred": ["array-like", "sparse matrix"],
        "labels": ["array-like", None],
        "sample_weight": ["array-like", None],
        "raise_warning": ["boolean", Hidden(StrOptions({"deprecated"}))],
        "replace_undefined_by": [
            Options(Real, {1.0, np.nan}),
            dict,
        ],
    },
    prefer_skip_nested_validation=True,
)
def class_likelihood_ratios(
    y_true,
    y_pred,
    *,
    labels=None,
    sample_weight=None,
    raise_warning="deprecated",
    replace_undefined_by=np.nan,
):
    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 != "binary":
        raise ValueError(
            "class_likelihood_ratios only supports binary classification "
            f"problems, got targets of type: {y_type}"
        )

    if raise_warning != "deprecated":
        warnings.warn(msg_deprecated_param, FutureWarning)
    else:
        raise_warning = True

    if replace_undefined_by == 1.0:
        replace_undefined_by = {"LR+": 1.0, "LR-": 1.0}

    if isinstance(replace_undefined_by, dict):
        # 校验字典格式与取值范围
        ...

    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
    pos_num = tp * support_neg
    pos_denom = fp * support_pos
    neg_num = fn * support_neg
    neg_denom = tn * support_pos

    if support_pos == 0:
        warnings.warn(msg, UndefinedMetricWarning, stacklevel=2)
        positive_likelihood_ratio = np.nan
        negative_likelihood_ratio = np.nan

    if fp == 0:
        if raise_warning:
            warnings.warn(msg, UndefinedMetricWarning, stacklevel=2)
        if isinstance(replace_undefined_by, float) and np.isnan(replace_undefined_by):
            positive_likelihood_ratio = replace_undefined_by
        else:
            positive_likelihood_ratio = desired_lr_pos
    else:
        positive_likelihood_ratio = pos_num / pos_denom

    if tn == 0:
        if raise_warning:
            warnings.warn(msg, UndefinedMetricWarning, stacklevel=2)
        if isinstance(replace_undefined_by, float) and np.isnan(replace_undefined_by):
            negative_likelihood_ratio = replace_undefined_by
        else:
            negative_likelihood_ratio = desired_lr_neg
    else:
        negative_likelihood_ratio = neg_num / neg_denom

    return float(positive_likelihood_ratio), float(negative_likelihood_ratio)

代码解析

计算临床诊断常用的似然比:LR+ = sensitivity / (1 - specificity) = TP/(TP+FN) / (FP/(FP+TN))LR- = (1-sensitivity) / specificity = FN/(TP+FN) / (TN/(FP+TN))。代数化简为 LR+ = (TP * support_neg) / (FP * support_pos)LR- = (FN * support_neg) / (TN * support_pos)。处理零分母情况,支持自定义替代值。

29.6 回归评估核心实现 —— 统一的目标检查与多输出聚合框架

29.6.1 源码路径:sklearn/metrics/_regression.py

29.6.2 统一的目标验证入口:_check_reg_targets_check_reg_targets_with_floating_dtype

回归评估指标同样依赖于统一的输入验证机制。

29.6.2.1 _check_reg_targets

def _check_reg_targets(
    y_true, y_pred, sample_weight, multioutput, dtype="numeric", xp=None
):
    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

代码解析

统一将 1D 输入重塑为 (n_samples, 1),校验输出维度一致性,处理 multioutput 参数(字符串或权重数组)。返回任务类型标识供上层判断是否支持多输出。

29.6.2.2 _check_reg_targets_with_floating_dtype

def _check_reg_targets_with_floating_dtype(
    y_true, y_pred, sample_weight, multioutput, xp=None
):
    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

代码解析

在验证前自动推断最合适的浮点 dtype(通过 _find_matching_floating_dtype),这是 Array API 兼容性的关键,确保 NumPy/CuPy/PyTorch 等后端数值行为一致。

29.6.3 基础误差指标:统一的“计算逐输出误差 -> 按 multioutput 聚合”模式

回归基础指标遵循统一的两级计算模式。

29.6.3.1 mean_absolute_error

@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,
)
def mean_absolute_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(
        xp.abs(y_pred - y_true), 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_error = _average(output_errors, weights=multioutput, xp=xp)

    return float(mean_absolute_error)

代码解析

第一级:_average(..., axis=0) 计算每个输出维度的加权 MAE,得到形状 (n_outputs,)output_errors。第二级:根据 multioutput 聚合——raw_values 直接返回,uniform_average 均匀平均,自定义权重加权平均。其他基础指标(MSE、Pinball、MAPE)完全遵循此模式。

29.6.3.2 mean_pinball_loss

@validate_params(
    {
        "y_true": ["array-like"],
        "y_pred": ["array-like"],
        "sample_weight": ["array-like", None],
        "alpha": [Interval(Real, 0, 1, closed="both")],
        "multioutput": [StrOptions({"raw_values", "uniform_average"}), "array-like"],
    },
    prefer_skip_nested_validation=True,
)
def mean_pinball_loss(
    y_true, y_pred, *, sample_weight=None, alpha=0.5, 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
        )
    )

    diff = y_true - y_pred
    sign = xp.astype(diff >= 0, diff.dtype)
    loss = alpha * sign * diff - (1 - alpha) * (1 - sign) * diff
    output_errors = _average(loss, weights=sample_weight, axis=0, xp=xp)

    if isinstance(multioutput, str) and multioutput == "raw_values":
        return output_errors

    if isinstance(multioutput, str) and multioutput == "uniform_average":
        multioutput = None

    return float(_average(output_errors, weights=multioutput, xp=xp))

代码解析

Pinball 损失公式:α * max(0, diff) + (1-α) * max(0, -diff),向量化实现为 α * sign * diff - (1-α) * (1-sign) * diffalpha=0.5 时等价于 MAE/2。

29.6.3.3 mean_absolute_percentage_error

@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,
)
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 = 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 = |y_pred - y_true| / max(|y_true|, eps)。除零保护用 epsilon(float64 机器精度)替代零值,避免 Inf,此时返回巨大惩罚值而非无穷大。

29.6.4 根误差指标:复用平方误差并开方

29.6.4.1 root_mean_squared_error

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

    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)

代码解析

直接复用 mean_squared_error(multioutput="raw_values") 获取逐输出 MSE,再应用 xp.sqrtroot_mean_squared_log_error 同理复用 mean_squared_log_error。这种“先复用后变换”策略减少代码冗余,保证跨后端一致性。

29.6.5 中位数绝对误差:鲁棒性的体现

29.6.5.1 median_absolute_error

@validate_params(
    {
        "y_true": ["array-like"],
        "y_pred": ["array-like"],
        "multioutput": [StrOptions({"raw_values", "uniform_average"}), "array-like"],
        "sample_weight": ["array-like", None],
    },
    prefer_skip_nested_validation=True,
)
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
    )
    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 计算中位数;有权重时用 _weighted_percentile 计算加权 50th 分位数。对异常值不敏感,适合含离群点的回归问题。

29.6.6 解释性指标:方差解释率与 R² 的统一组装逻辑

29.6.6.1 _assemble_fraction_of_explained_deviance

def _assemble_fraction_of_explained_deviance(
    numerator, denominator, n_outputs, multioutput, force_finite, xp, device
):
    dtype = numerator.dtype

    nonzero_denominator = denominator != 0

    if not force_finite:
        output_scores = 1 - (numerator / denominator)
    else:
        nonzero_numerator = numerator != 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]
        )

        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

代码解析

通用组装器,计算 1 - numerator/denominatorforce_finite=True 时处理常数目标边界:完美预测(分子分母均为 0)设为 1.0,不完美预测(分子>0,分母=0)设为 0.0,避免 NaN/-Inf 污染网格搜索。聚合支持 raw_valuesuniform_averagevariance_weighted(以分母方差为权重)。

29.6.6.2 explained_variance_score

@validate_params(
    {
        "y_true": ["array-like"],
        "y_pred": ["array-like"],
        "sample_weight": ["array-like", None],
        "multioutput": [
            StrOptions({"raw_values", "uniform_average", "variance_weighted"}),
            "array-like",
        ],
        "force_finite": ["boolean"],
    },
    prefer_skip_nested_validation=True,
)
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)。分母:目标方差。不考虑系统性偏移,仅衡量离散程度解释比例。

29.6.6.3 r2_score

@validate_params(
    {
        "y_true": ["array-like"],
        "y_pred": ["array-like"],
        "sample_weight": ["array-like", None],
        "multioutput": [
            StrOptions({"raw_values", "uniform_average", "variance_weighted"}),
            "array-like",
            None,
        ],
        "force_finite": ["boolean"],
    },
    prefer_skip_nested_validation=True,
)
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² 分子:残差平方和(不去中心化)。分母:总平方和(基于均值基线)。考虑了系统性偏移,比 explained_variance 更严格。样本数<2 返回 NaN。

29.6.7 Tweedie 偏差族:统一的指数分布族损失

29.6.7.1 _mean_tweedie_deviance

def _mean_tweedie_deviance(y_true, y_pred, sample_weight, power):
    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:
        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 参数分支覆盖指数分布族:

  • power=0:正态分布 → MSE

  • power=1:泊松分布 → 2 * (y log(y/ŷ) - y + ŷ)

  • power=2:伽马分布 → 2 * (log(ŷ/y) + y/ŷ - 1)

  • power<0:极稳定分布

  • 1<power<2:复合泊松

  • power>2:正稳定分布

统一使用 xp.powxlogy 等 Array API 操作,保证跨后端数值一致。

29.6.7.2 mean_tweedie_deviance

@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.")
    else:
        raise ValueError

    return _mean_tweedie_deviance(
        y_true, y_pred, sample_weight=sample_weight, power=power
    )

代码解析

统一入口,校验 power 参数合法性(<=0>=1),根据分布族校验 y_true/y_pred 定义域(如伽马要求严格正数),调用核心实现。

29.6.7.3 mean_poisson_deviancemean_gamma_deviance

def mean_poisson_deviance(y_true, y_pred, *, sample_weight=None):
    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):
    return mean_tweedie_deviance(y_true, y_pred, sample_weight=sample_weight, power=2)

代码解析

语义化别名,分别固定 power=1power=2,提升可读性。

29.6.8 D² 分数族:泛化的“可解释偏差比例”

29.6.8.1 d2_tweedie_score

@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 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 = 1 - (模型 Tweedie 偏差 / 零模型 Tweedie 偏差)。零模型使用经验均值 y_avg 作为常数预测。样本数<2 返回 NaN。

29.6.8.2 d2_pinball_score

@validate_params(
    {
        "y_true": ["array-like"],
        "y_pred": ["array-like"],
        "sample_weight": ["array-like", None],
        "alpha": [Interval(Real, 0, 1, closed="both")],
        "multioutput": [
            StrOptions({"raw_values", "uniform_average"}),
            "array-like",
        ],
    },
    prefer_skip_nested_validation=True,
)
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")

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

    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 = 1 - (模型 Pinball 损失 / 零模型 Pinball 损失)。零模型使用经验 α-分位数(通过 _weighted_percentile 计算)作为常数预测。复用 mean_pinball_loss 和通用组装器,支持多输出聚合。

29.6.8.3 d2_absolute_error_score

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

代码解析

alpha=0.5 时 Pinball 损失退化为绝对误差,零模型为经验中位数。直接委托 d2_pinball_score

29.7 设计取舍分析

在 metrics 模块的设计中,开发者们在统一性和灵活性之间进行了深思熟虑的权衡。以下以问答形式呈现关键设计决策的考量:

问:为什么在核心指标计算中大量使用向量化操作和底层库函数(如 coo_matrix_average),而不是编写显式的 Python 循环?

答:这是性能与可维护性的权衡。虽然显式 Python 循环在代码逻辑上更直观、更易于调试,但在处理大规模数据集时,其性能劣势是数量级的。scikit-learn 选择利用 NumPy/SciPy 的高度优化向量化操作(如 coo_matrix 构造混淆矩阵、广播机制计算批量误差),哪怕这增加了一定的概念间接性。这种取舍确保了度量计算在真实世界的大规模数据场景下仍能在可接受时间内完成,符合作为基础库的性能责任。同时,将核心计算下推到统一的底层工具函数(_average_prf_divide 等)中,反而通过集中维护提高了整体代码的可靠性。

问:metrics 模块为何强制所有度量函数遵循统一的 API 签名 (y_true, y_pred, **kwargs),并通过通用预处理函数(如 _check_targets_check_reg_targets)统一输入验证?这是否限制了特殊场景下的灵活性?

答:统一 API 是刻意的设计选择,而非妥协。它带来三大收益:一是用户可发现性——学会一套调用模式即可覆盖所有指标;二是行为一致性——样本权重、零除处理、多标签扩展等语义在所有指标中保持完全一致;三是跨后端兼容性——Array API 兼容层只需在统一的预处理和核心计算路径上适配一次,即可惠及所有指标。虽然这意味着极少数高度定制化场景无法直接使用现成函数,但 scikit-learn 通过评分器(make_scorer)、Display 类以及用户自定义函数提供了扩展点,用户可在不修改核心代码的情况下实现特殊需求。统一性带来的生态一致性远超个性化定制的边际收益。

问:引入 Array API 兼容层(get_namespace_and_devicexp 操作)增加了代码抽象层级,要求开发者避免使用 NumPy 特有函数,这值得吗?

答:这是面向未来的必要投资。科学计算生态已从“仅 NumPy”演变为多后端并存(CuPy 用于 GPU、PyTorch/JAX 用于自动微分和硬件加速)。若不引入兼容层,每个后端都需要维护一套指标实现,代价极高且极易导致行为不一致。当前的抽象层虽增加了初期开发复杂度(需改写为标准 Array API),但实现了“写一次,到处运行”的跨后端目标:同一套指标逻辑可无缝运行在 CPU、GPU 甚至 TPU 上,且数值行为由标准保证一致。这符合现代机器学习基础设施演进的必然趋势。

29.8 动手练习

  • 阅读分类指标核心计算流程:从 _check_targetsmultilabel_confusion_matrix 再到 precision_recall_fscore_support,追踪 TP/FP/FN 如何流转。

  • 解析回归指标的统一聚合框架与 Tweedie 偏差族:对比 mean_absolute_errormean_tweedie_deviance 的参数校验与分支逻辑。

  • 探究零除处理与多标签平均策略的边界行为:构造全零预测、单类别数据,观察 zero_division="warn"/0/1/np.nan 下的输出差异。

  • 动手实现一个自定义分类指标:参考 jaccard_score 的结构,实现基于 MCM 的 Dice 系数(2*TP / (2*TP + FP + FN)),支持所有平均策略与零除处理。

29.9 本章小结

在这一章中,我们深入探讨了 scikit-learn metrics 模块的全貌,了解它如何以统一的 API 和模块化的组织方式,为机器学习的各个任务提供标准化的评估工具。首先,我们把 metrics 模块类比为一个“度量衡博物馆”,其中分类指标如同“裁判组”,回归指标似“尺子箱”,聚类指标相当“质检员”,而底层的检查函数和平均策略则构成了“安检门”和“计分规则手册”。其次,我们详细分析了分类评估的核心实现:从混淆矩阵作为所有指标的“原子基石”,到多标签混淆矩阵的维度扩展,再到 precision_recall_fscore_support 作为“核心分发函数”如何统一输出 Precision、Recall、F-beta 和 Support,以及上层指标如 accuracy_scorejaccard_scorematthews_corrcoefcohen_kappa_scorelog_lossbrier_score_losshinge_lossd2_log_loss_scored2_brier_scoreclassification_reporthamming_losszero_one_lossbalanced_accuracy_scoreclass_likelihood_ratios 等如何基于这些统计量进行具体计算。第三,我们考察了回归评估的统一框架:目标验证由 _check_reg_targets_check_reg_targets_with_floating_dtype 完成,基础误差指标遵循“逐输出计算 -> 按 multioutput 聚合”的模式,根误差指标通过复用 MSE 并开方实现,中位数绝对误差提供鲁棒性选择,而解释性指标如 R² 和 Tweedie 偏差族则通过 _assemble_fraction_of_explained_deviance 通用组装器实现可解释的偏差比例。最后,我们简要讨论了设计中的取舍,如为什么选择向量化操作而非自定义循环,以及这种设计在统一性和灵活性之间的平衡。

本章我们一起学习了以下概念:

| 概念 | 解释 |

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

| metrics 模块 | 模型评估的度量衡博物馆,统一 API、模块化组织、支持 Array API 后端 |

| _check_targets | 分类任务的统一安检门:类型推断、标签对齐、稀疏转换、样本权重校验 |

| confusion_matrix | 所有分类指标的原子基石,基于 scipy.sparse.coo_matrix 高效统计加权计数 |

| multilabel_confusion_matrix | 多标签/样本级扩展,输出 (n_labels, 2, 2) 或 (n_samples, 2, 2) 形状 |

| precision_recall_fscore_support | 核心分发函数,基于 MCM 提取 TP/FP/FN 充分统计量,统一支撑上层 P/R/F1 |

| _prf_divide | 统一除零处理:支持 warn/0.0/1.0/nan 四种模式,配合 warn_for 控制警告 |

| _average_binary_score | 二分类指标的多标签/多类平均策略实现 (micro/macro/weighted/samples) |

| _check_reg_targets | 回归任务的统一安检门:1D->2D 重塑、形状一致性、multioutput 参数校验 |

| mean_*_error 族 | 统一‘逐输出计算误差 -> 按 multioutput 聚合’模式 (MAE/MSE/Pinball/MAPE) |

| _assemble_fraction_of_explained_deviance | 解释性指标通用组装器:1 - numerator/denominator,处理常数目标边界情况 |

| mean_tweedie_deviance | Tweedie 偏差族统一入口,power 参数控制分布族 (Normal/Poisson/Gamma/IG/稳定分布) |

| d2_*_score 族 | 泛化的可解释偏差比例:1 - (模型偏差/零模型偏差),基线为经验均值/分位数/中位数 |

| Array API 兼容 | 通过 get_namespace_and_device、xp 操作实现 NumPy/CuPy/PyTorch 等后端无缝切换 |

下一章中,我们将学习分类评估指标 —— 模型“考试评分系统”的核心试卷。我们将深入剖析从基础准确率到高级概率评分的各种分类指标的实现细节,了解它们在多分类和多标签场景下的计算策略,以及如何处理样本权重和零除情况。通过阅读源码,你将掌握这些指标背后的数学原理和工程实现,为后续的模型选择和调参打下坚实的基础。

第 30 章 —— 分类评估指标 —— 模型“考试评分系统”的核心试卷

30.1 学习目标

  • 难度:★★★☆☆(3/5)

  • 预备知识:Python 基础、面向对象编程与 Markdown/代码阅读基础

  • 理解分类评估指标的输入校验与目标类型识别机制

  • 掌握准确率、混淆矩阵、卡帕系数等基础指标的计算逻辑与多任务类型支持

  • 深入精确率、召回率、F分数的统一计算引擎(precision_recall_fscore_support)实现原理

  • 剖析对数损失、Brier分数等概率型指标的数值稳定性处理与多类别扩展

  • 了解Jaccard、Hamming、Matthews、Hinge损失、类似然比等高级指标的适用场景与计算差异

  • 理解分类报告生成的聚合逻辑与多种平均策略(micro/macro/weighted/samples)的实现细节

  • 掌握Array API兼容层如何实现跨NumPy/CuPy/PyTorch/JAX/Dask后端的统一计算

  • 熟悉分类指标测试体系中边界条件、零除处理、警告语义与多后端验证的设计模式

30.2 生活类比

想象分类评估指标是一座“模型考试评分中心”_check_targets = 安检门:统一所有考生(y_true/y_pred)的证件格式,拒绝混合类型(如多标签混入多类)和无效证件(连续值),将二分类/多类压缩为1D准考证,多标签转为CSR稀疏档案袋;confusion_matrix = 答题卡扫描仪:将真实答案与预测答案配对坐标,用COO稀疏矩阵高效累加计数,支持按行/列/全局归一化输出成绩单;precision_recall_fscore_support = 核心阅卷引擎:统一调用multilabel_confusion_matrix获取TP/FP/FN统计量,根据average参数决定是微观全局求和、宏观逐类平均、加权支持度平均还是逐样本平均,F-beta公式直接用统计量避免二次除法;_prf_divide = 安全除法器:分母为零时不报错,按策略填0/1/NaN,warn模式下动态组装警告信息(区分逐标签/逐样本/全局、precision/recall/f-score);log_loss/brier_score_loss = 概率质检员:校验预测概率在[0,1]且行和≈1,裁剪极值避免log(0),二分类扩展为两列,多类One-Hot对齐标签顺序;classification_report = 成绩单生成器:调用PRF引擎获取逐类指标,根据任务类型动态决定显示哪些平均(multilabel含samples avg),micro_is_accuracy判断微观平均是否等同准确率从而改显accuracy行;Array API兼容层 = 跨平台统一运行时:所有指标开头获取xp, device,输入搬运到同一后端/设备,全程用xp.*/xpx.*替代np.*,结果归还原生类型,测试通过yield_namespace_device_dtype_combinations验证多后端一致性;测试体系 = 质检流水线make_prediction生成标准考卷,parametrize覆盖四种零除策略,pytest.warns精确断言警告文案,边界场景(单类别、标签子集、稀疏矩阵、字符串标签、Unicode、长标签名)全覆盖。

30.3 源码地图

sklearn/metrics/_classification.py

├── 入口校验与工具函数

│ ├── _check_targets() # 统一输入格式、识别任务类型、校验一致性

│ ├── _check_zero_division() # 统一零除策略转换

│ ├── _prf_divide() # 安全除法、零除填充、警告构建

│ ├── _warn_prf() # 精确率/召回率/F分数警告信息组装

│ ├── _check_set_wise_labels() # 集合类指标标签校验

│ ├── _one_hot_encoding_multiclass_target() # 多类标签One-Hot编码

│ ├── _validate_multiclass_probabilistic_prediction() # 多类概率预测校验

│ ├── _one_hot_encoding_binary_target() # 二分类标签One-Hot编码

│ ├── _validate_binary_probabilistic_prediction() # 二分类概率预测校验

│ ├── _log_loss() # 对数损失核心计算(裁剪、xlogy)

├── 基础分类指标

│ ├── accuracy_score() # 准确率(支持multilabel子集准确率)

│ ├── confusion_matrix() # 混淆矩阵(COO稀疏累加、归一化、索引转换)

│ ├── multilabel_confusion_matrix() # 多标签/逐样本混淆矩阵(布尔运算、加权计数)

│ ├── cohen_kappa_score() # Cohen's Kappa(加权矩阵、未定义处理)

├── 核心PRF引擎

│ ├── precision_recall_fscore_support() # 统一引擎(MCM统计量、多平均策略、F-beta公式)

│ ├── precision_score() # 精确率(调用PRF引擎)

│ ├── recall_score() # 召回率(调用PRF引擎)

│ ├── f1_score() # F1分数(beta=1调用fbeta_score)

│ ├── fbeta_score() # F-beta分数(beta=0/inf特殊处理)

├── 概率型指标

│ ├── log_loss() # 对数损失(标签二值化、概率校验、裁剪)

│ ├── brier_score_loss() # Brier分数(二分类/多类统一、scale_by_half)

│ ├── d2_log_loss_score() # D²对数损失解释度(空模型基线、比率计算)

│ ├── d2_brier_score() # D² Brier解释度/Brier技能分(复用brier计算)

├── 高级分类指标

│ ├── jaccard_score() # Jaccard相似系数(基于MCM、多平均)

│ ├── hamming_loss() # Hamming损失(逐标签错误率、样本权重)

│ ├── zero_one_loss() # 0-1损失(复用accuracy_score取补)

│ ├── matthews_corrcoef() # Matthews相关系数(混淆矩阵协方差形式)

│ ├── hinge_loss() # Hinge损失(二分类margin、Crammer-Singer多类)

│ ├── class_likelihood_ratios() # 类似然比LR+/LR-(仅binary、混淆矩阵推导)

├── 报告生成

│ ├── classification_report() # 分类报告(逐类指标、多平均聚合、文本/字典输出)

│ ├── balanced_accuracy_score() # 平衡准确率(逐类召回平均、调整随机基线)

sklearn/metrics/_base.py

├── 通用平均工具

│ ├── _average_binary_score() # 二分类指标在多标签/多类上的平均策略

│ ├── _average_multiclass_ovo_score() # 多类两两类别法平均(Hand & Till 2001)

sklearn/metrics/tests/test_classification.py

├── 核心测试模式

│ ├── make_prediction() # 生成真实分类预测数据(binary/multiclass)

│ ├── test_precision_recall_fscore_support() # PRF核心引擎全面测试

│ ├── test_zero_division_*() # 零除策略四种模式全覆盖测试

│ ├── test_classification_report_*() # 报告生成、标签子集/超集、字典输出

├── 边界场景测试

│ ├── test_precision_recall_f_binary_single_class() # 单类别/空预测

│ ├── test_confusion_matrix_multiclass_subset_labels() # 标签子集

│ ├── test_multilabel_confusion_matrix_*() # 稀疏格式、samplewise、sample_weight

│ ├── test_hinge_loss_multiclass_*() # 多类缺失标签、形状校验

│ ├── test_log_loss_*() # 完美预测、非概率警告、pandas输入、标签顺序警告

│ ├── test_brier_score_loss_*() # 二分类/多类、scale_by_half、无效输入

│ ├── test_likelihood_ratios_*() # LR+/LR-警告、错误、replace_undefined_by字典

├── Array API合规性测试

│ ├── test_confusion_matrix_array_api() # need_index_conversion路径验证

│ ├── test_probabilistic_metrics_array_api() # binary/multiclass/multilabel+sample_weight

│ ├── test_pos_label_in_brier_score_metrics_array_api() # 非标准标签pos_label推断

30.4 分类评估入口与目标校验 —— 指标计算的“安检门”

在深入具体指标之前,我们必须先通过分类指标的“安检门” —— _check_targets 函数。它是所有分类指标的统一入口,负责识别任务类型、统一输入格式、校验一致性。如果把分类评估比作考试,_check_targets 就是那个检查准考证、身份证、笔试答题卡格式是否规范的安检员。

30.4.1 核心类型定义与工具函数

首先来看几个核心的校验工具函数,它们构成了后续所有指标计算的基础设施。

源码路径:sklearn/metrics/_classification.py - _check_zero_division()(第107-113行)

def _check_zero_division(zero_division):
    if isinstance(zero_division, str) and zero_division == "warn":
        return np.float64(0.0)
    elif isinstance(zero_division, (int, float)) and zero_division in [0, 1]:
        return np.float64(zero_division)
    else:  # np.isnan(zero_division)
        return np.nan

这段代码定义了零除策略的标准化转换:将用户传入的 "warn"01np.nan 统一转为 float64 标量,供后续 _prf_divide 使用。"warn" 模式下内部按 0.0 处理,但会触发警告。

源码路径:sklearn/metrics/_classification.py - _check_targets()(第115-195行)

def _check_targets(y_true, y_pred, sample_weight=None):
    """Check that y_true and y_pred belong to the same classification task."""
    xp, _ = get_namespace(y_true, y_pred, sample_weight)
    check_consistent_length(y_true, y_pred, sample_weight)
    type_true = type_of_target(y_true, input_name="y_true")
    type_pred = type_of_target(y_pred, input_name="y_pred")
    # ... 省略空数组检查、sample_weight 校验 ...

    y_type = {type_true, type_pred}
    if y_type == {"binary", "multiclass"}:
        y_type = {"multiclass"}  # 二分类视为多类的特例

    if len(y_type) > 1:
        raise ValueError("Classification metrics can't handle a mix of {0} and {1} targets".format(type_true, type_pred))

    y_type = y_type.pop()
    if y_type not in ["binary", "multiclass", "multilabel-indicator"]:
        raise ValueError("{0} is not supported".format(y_type))

    if y_type in ["binary", "multiclass"]:
        # 压缩为 1D 数组
        y_true = column_or_1d(y_true, input_name="y_true")
        y_pred = column_or_1d(y_pred, input_name="y_pred")
        # 如果唯一标签 > 2,自动升级为 multiclass
        if y_type == "binary":
            unique_values = _union1d(y_true, y_pred, xp)
            if unique_values.shape[0] > 2:
                y_type = "multiclass"

    if y_type.startswith("multilabel"):
        # 多标签转为 CSR 稀疏矩阵(仅 NumPy 后端)
        if _is_numpy_namespace(xp):
            y_true = csr_matrix(y_true)
            y_pred = csr_matrix(y_pred)
        y_type = "multilabel-indicator"

    return y_type, y_true, y_pred, sample_weight

这段代码实现了分类指标的“安检门”逻辑:

  1. 统一命名空间:通过 get_namespace 获取数组后端(NumPy/CuPy/PyTorch等),为后续 Array API 兼容铺路。

  2. 长度一致性check_consistent_length 确保 y_truey_predsample_weight 样本数一致。

  3. 任务类型识别:调用 type_of_target 识别 y_true/y_pred 的类型(binary/multiclass/multilabel-indicator/continuous等)。

  4. 类型兼容性校验:拒绝混合类型(如 multilabel 与 multiclass 混用)、不支持的类型(continuous、multioutput)。

  5. 格式标准化

  • binary/multiclass → 压缩为 1D 数组(column_or_1d

  • multilabel → 转为 CSR 稀疏矩阵(仅 NumPy 后端,Array API 暂不支持稀疏)

  1. binary 自动升级:若标签数 > 2,自动将 binary 升级为 multiclass。

源码路径:sklearn/metrics/_classification.py - _prf_divide()(第658-695行)

def _prf_divide(
    numerator, denominator, metric, modifier, average, warn_for, zero_division="warn"
):
    """Performs division and handles divide-by-zero."""
    xp, _ = get_namespace(numerator, denominator)
    dtype_float = _find_matching_floating_dtype(numerator, denominator, xp=xp)
    mask = denominator == 0
    denominator = xp.asarray(denominator, copy=True, dtype=dtype_float)
    denominator[mask] = 1  # 避免 Inf/NaN
    result = xp.asarray(numerator, dtype=dtype_float) / denominator

    if not xp.any(mask):
        return result

    # 零除位置填入 zero_division 值
    zero_division_value = _check_zero_division(zero_division)
    result[mask] = zero_division_value

    # warn 模式且该指标在 warn_for 中才触发警告
    if zero_division != "warn" or metric not in warn_for:
        return result

    # 动态组装警告信息
    if metric in warn_for:
        _warn_prf(average, modifier, f"{metric.capitalize()} is", result.shape[0])

    return result

这是核心的“安全除法器”,设计亮点:

  1. 跨后端兼容:用 xp.* 操作替代 np.*,支持 Array API。

  2. 零除不报错:分母为零处先置 1 避免 inf/nan,再按 zero_division 策略填充(0/1/NaN)。

  3. 精准警告:仅当 zero_division="warn" 且指标在 warn_for 列表中时触发 UndefinedMetricWarning

  4. 上下文感知警告_warn_prf 根据 average 模式(逐标签/逐样本/全局)和指标类型动态生成警告文案。

源码路径:sklearn/metrics/_classification.py - _warn_prf()(第697-715行)

def _warn_prf(average, modifier, msg_start, result_size):
    axis0, axis1 = "sample", "label"
    if average == "samples":
        axis0, axis1 = axis1, axis0
    msg = (
        "{0} ill-defined and being set to 0.0 {{0}} "
        "no {1} {2}s. Use `zero_division` parameter to control"
        " this behavior.".format(msg_start, modifier, axis0)
    )
    if result_size == 1:
        msg = msg.format("due to")
    else:
        msg = msg.format("in {0}s with".format(axis1))
    warnings.warn(msg, UndefinedMetricWarning, stacklevel=2)

警告文案的动态组装逻辑:

  • average="samples" 时交换“sample/label”角色(多标签逐样本模式)。

  • 标量结果用 "due to",数组结果用 "in {labels/samples} with",精确定位问题。

30.4.2 概率型指标的标签编码与校验

概率型指标(log_lossbrier_score_loss 等)需要将标签 One-Hot 编码并校验概率预测的合法性。

源码路径:sklearn/metrics/_classification.py - _one_hot_encoding_multiclass_target()(第1462-1520行)

def _one_hot_encoding_multiclass_target(y_true, labels, target_xp, target_device):
    xp, _ = get_namespace(y_true)
    lb = LabelBinarizer()
    if labels is not None:
        lb = lb.fit(labels)
        # LabelBinarizer 按字典序排序,可能与用户传入 labels 顺序不一致
        if not xp.all(lb.classes_ == labels):
            warnings.warn(
                f"Labels passed were {labels}. But this function "
                "assumes labels are ordered lexicographically. "
                f"Pass the ordered labels={lb.classes_.tolist()} and ensure that "
                "the columns of y_prob correspond to this ordering.",
                UserWarning,
            )
        if not xp.all(_isin(y_true, labels, xp=xp)):
            undeclared_labels = set(y_true) - set(labels)
            raise ValueError(f"y_true contains values {undeclared_labels} not belonging to the passed labels {labels}.")
    else:
        lb = lb.fit(y_true)

    if lb.classes_.shape[0] == 1:
        raise ValueError("y_true contains only one label ... Please provide the list of all expected class labels explicitly through the labels argument.")

    transformed_labels = lb.transform(y_true)
    transformed_labels = target_xp.asarray(transformed_labels, device=target_device)
    if transformed_labels.shape[1] == 1:
        # 二分类扩展为两列 [1-p, p]
        transformed_labels = target_xp.concat((1 - transformed_labels, transformed_labels), axis=1)
    return transformed_labels, lb.classes_

关键点:

  1. LabelBinarizer 字典序陷阱LabelBinarizer 内部按字典序排序 classes_,若用户传入 labels 顺序不同,会发出警告要求对齐。

  2. 缺失标签检查_isin 验证 y_true 所有标签都在 labels 中。

  3. 二分类扩展:单列 One-Hot 自动扩展为两列 [1-p, p],统一后续计算接口。

源码路径:sklearn/metrics/_classification.py - _validate_multiclass_probabilistic_prediction()(第195-260行)

def _validate_multiclass_probabilistic_prediction(y_true, y_prob, sample_weight, labels):
    xp, _, device_ = get_namespace_and_device(y_prob)
    # 1. 概率范围校验
    if xp.max(y_prob) > 1: raise ValueError(...)
    if xp.min(y_prob) < 0: raise ValueError(...)

    check_consistent_length(y_prob, y_true, sample_weight)
    # 2. One-Hot 编码标签
    transformed_labels, lb_classes = _one_hot_encoding_multiclass_target(
        y_true=y_true, labels=labels, target_xp=xp, target_device=device_
    )

    # 3. 单列概率扩展为两列(视为二分类)
    if y_prob.ndim == 1:
        y_prob = y_prob[:, xp.newaxis]
    if y_prob.shape[1] == 1:
        y_prob = xp.concat([1 - y_prob, y_prob], axis=1)

    # 4. 行和归一化校验(允许 sqrt(eps) 容差)
    eps = xp.finfo(y_prob.dtype).eps
    y_prob_sum = xp.sum(y_prob, axis=1)
    if not xp.all(xpx.isclose(y_prob_sum, xp.asarray(1, dtype=y_prob_sum.dtype, device=device_), rtol=sqrt(eps))):
        warnings.warn("The y_prob values do not sum to one. Make sure to pass probabilities.", UserWarning)

    # 5. 类别数一致性校验
    if lb_classes.shape[0] != y_prob.shape[1]:
        raise ValueError("y_true and y_prob contain different number of classes ...")

    return transformed_labels, y_prob

这段代码完成了概率型指标的“质检”全流程:

  • 概率值域检查 [0,1]

  • 标签 One-Hot 编码与类别顺序对齐

  • 单列概率自动补全为二分类两列

  • 行和 ≈ 1 的软校验(容差 sqrt(eps)

  • 标签类别数与概率列数强一致性校验

源码路径:sklearn/metrics/_classification.py - _validate_binary_probabilistic_prediction()(第1700-1748行)

def _validate_binary_probabilistic_prediction(y_true, y_prob, sample_weight, pos_label):
    # ... 维度检查、有限性检查、样本权重校验 ...
    y_type = type_of_target(y_true, input_name="y_true")
    if y_type != "binary":
        raise ValueError(f"The type of the target inferred from y_true is {y_type} but should be binary ...")

    xp, _, device_ = get_namespace_and_device(y_prob)
    # pos_label 推断与一致性校验
    try:
        pos_label = _check_pos_label_consistency(pos_label, y_true)
    except ValueError:
        # 兼容旧行为:非字符串标签取较大值
        if not (_is_numpy_namespace(xp_y_true) and classes.dtype.kind in "OUS"):
            pos_label = classes[-1]
        else:
            raise

    # 转为 (n_samples, 2) 形状
    transformed_labels = _one_hot_encoding_binary_target(
        y_true=y_true, pos_label=pos_label, target_xp=xp, target_device=device_
    )
    y_prob = xp.stack((1 - y_prob, y_prob), axis=1)
    return transformed_labels, y_prob

二分类概率校验的特殊之处在于 pos_label 的推断逻辑:优先用户指定,其次 _check_pos_label_consistency 校验一致性,最后回退到“较大标签”规则(仅非字符串标签)。

30.5 基础分类指标:准确率、混淆矩阵与卡帕系数 —— 分类评估的“入门三件套”

30.5.1 accuracy_score:准确率的多任务统一实现

源码路径:sklearn/metrics/_classification.py - accuracy_score()(第270-340行)

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)
    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.startswith("multilabel"):
        # 多标签:子集准确率(整行完全匹配)
        differing_labels = _count_nonzero(y_true - y_pred, xp=xp, device=device, axis=1)
        score = xp.asarray(differing_labels == 0, device=device)
    else:
        # binary/multiclass:逐元素比较
        score = y_true == y_pred

    return float(_average(score, weights=sample_weight, normalize=normalize, xp=xp))

核心逻辑:

  • multilabel 子集准确率_count_nonzero(y_true - y_pred, axis=1) == 0 判断整行标签完全一致。

  • binary/multiclass 逐元素准确率:直接 y_true == y_pred

  • 统一归还_average 处理加权平均,float() 归还 Python 标量。

对比zero_one_loss 直接复用 accuracy_score 取补:1 - score(normalize=True)或 n_samples - score(normalize=False)。

30.5.2 confusion_matrix:COO 稀疏矩阵的高效累加艺术

源码路径:sklearn/metrics/_classification.py - confusion_matrix()(第342-460行)

def confusion_matrix(y_true, y_pred, *, labels=None, sample_weight=None, normalize=None):
    xp, _, device_ = get_namespace_and_device(y_true, y_pred, labels, sample_weight)
    # 1. 转 NumPy CPU 利用 SciPy coo_matrix 高效累加
    y_true = _convert_to_numpy(y_true, xp)
    y_pred = _convert_to_numpy(y_pred, xp)
    # ... sample_weight 转换 ...

    y_type, y_true, y_pred, sample_weight = _check_targets(y_true, y_pred, sample_weight)
    y_true, y_pred = attach_unique(y_true, y_pred)
    if y_type not in ("binary", "multiclass"):
        raise ValueError("%s is not supported" % y_type)

    if labels is None:
        labels = unique_labels(y_true, y_pred)
    else:
        labels = _convert_to_numpy(labels, xp)
        # ... 校验 labels 非空、与 y_true 有交集 ...

    n_labels = labels.size
    # 2. 标签映射为连续索引
    need_index_conversion = not (
        labels.dtype.kind in {"i", "u", "b"}
        and np.all(labels == np.arange(n_labels))
        and y_true.min() >= 0
        and y_pred.min() >= 0
    )
    if need_index_conversion:
        label_to_ind = {label: index for index, label in enumerate(labels)}
        y_pred = np.array([label_to_ind.get(label, n_labels + 1) for label in y_pred])
        y_true = np.array([label_to_ind.get(label, n_labels + 1) for label in y_true])

    # 3. 过滤掉不在 labels 中的样本
    ind = np.logical_and(y_pred < n_labels, y_true < n_labels)
    if not np.all(ind):
        y_pred = y_pred[ind]
        y_true = y_true[ind]
        sample_weight = sample_weight[ind]

    # 4. 选择累加器 dtype(MPS 设备用 float32 避免精度问题)
    if sample_weight.dtype.kind in {"i", "u", "b"}:
        dtype = np.int64
    else:
        dtype = np.float32 if str(device_).startswith("mps") else np.float64

    # 5. COO 稀疏矩阵高效累加:(row=y_true, col=y_pred, data=sample_weight)
    cm = coo_matrix(
        (sample_weight, (y_true, y_pred)),
        shape=(n_labels, n_labels),
        dtype=dtype,
    ).toarray()

    # 6. 归一化
    with np.errstate(all="ignore"):
        if normalize == "true":
            cm = cm / cm.sum(axis=1, keepdims=True)   # 按真实类别归一化(行和=1)
        elif normalize == "pred":
            cm = cm / cm.sum(axis=0, keepdims=True)   # 按预测类别归一化(列和=1)
        elif normalize == "all":
            cm = cm / cm.sum()                        # 全局归一化
        cm = xpx.nan_to_num(cm)  # 处理 0/0 -> 0

    # 7. 单标签警告
    if cm.shape == (1, 1):
        warnings.warn("A single label was found ... use the 'labels' parameter ...", UserWarning)

    return xp.asarray(cm, device=device_)

设计精髓:

  1. 跨后端策略:输入统一转 NumPy CPU,利用 SciPy coo_matrix 极快累加,结果再转回目标后端/设备。

  2. 索引转换优化:仅当标签非连续整数时才构建映射字典,避免不必要的开销。

  3. MPS 设备 dtype 适配:Apple Metal (MPS) 用 float32 累加器,规避 float64 精度陷阱。

  4. 三种归一化语义

  • normalize='true':行和=1(召回率视角)

  • normalize='pred':列和=1(精确率视角)

  • normalize='all':全矩阵和=1(联合分布视角)

30.5.3 multilabel_confusion_matrix:逐类/逐样本的 2×2 矩阵工厂

源码路径:sklearn/metrics/_classification.py - multilabel_confusion_matrix()(第462-580行)

def multilabel_confusion_matrix(y_true, y_pred, *, sample_weight=None, labels=None, samplewise=False):
    y_true, y_pred = attach_unique(y_true, y_pred)
    xp, _, device_ = get_namespace_and_device(y_true, y_pred, sample_weight)
    y_type, y_true, y_pred, sample_weight = _check_targets(y_true, y_pred, sample_weight)

    present_labels = unique_labels(y_true, y_pred)
    if labels is None:
        labels = present_labels
        n_labels = None
    else:
        labels = xp.asarray(labels, device=device_)
        n_labels = labels.shape[0]
        # 补全 labels 中缺失但数据中存在的标签
        labels = xp.concat([labels, xpx.setdiff1d(present_labels, labels, assume_unique=True, xp=xp)], axis=-1)

    if y_true.ndim == 1:
        # binary/multiclass 路径:One-vs-Rest 二值化后用 _bincount
        if samplewise:
            raise ValueError("Samplewise metrics are not available outside of multilabel classification.")
        le = LabelEncoder()
        le.fit(labels)
        y_true = le.transform(y_true)
        y_pred = le.transform(y_pred)
        sorted_labels = le.classes_

        # _bincount 统计 TP/Pred/True
        tp = y_true == y_pred
        tp_bins = y_true[tp]
        tp_sum = _bincount(tp_bins, weights=sample_weight[tp] if sample_weight is not None else None, minlength=labels.shape[0], xp=xp) if tp_bins.shape[0] else xp.zeros(labels.shape[0])
        pred_sum = _bincount(y_pred, weights=sample_weight, minlength=labels.shape[0], xp=xp)
        true_sum = _bincount(y_true, weights=sample_weight, minlength=labels.shape[0], xp=xp)

        # 仅保留用户请求的 labels
        indices = xp.searchsorted(sorted_labels, labels[:n_labels])
        tp_sum = xp.take(tp_sum, indices, axis=0)
        true_sum = xp.take(true_sum, indices, axis=0)
        pred_sum = xp.take(pred_sum, indices, axis=0)

    else:
        # multilabel-indicator 路径:布尔运算 + _count_nonzero
        sum_axis = 1 if samplewise else 0
        if n_labels is not None:
            y_true = y_true[:, labels[:n_labels]]
            y_pred = y_pred[:, labels[:n_labels]]

        true_and_pred = y_true.multiply(y_pred) if issparse(y_true) or issparse(y_pred) else xp.multiply(y_true, y_pred)

        tp_sum = _count_nonzero(true_and_pred, axis=sum_axis, sample_weight=sample_weight, xp=xp, device=device_)
        pred_sum = _count_nonzero(y_pred, axis=sum_axis, sample_weight=sample_weight, xp=xp, device=device_)
        true_sum = _count_nonzero(y_true, axis=sum_axis, sample_weight=sample_weight, xp=xp, device=device_)

    # 组装 2x2 矩阩: [[tn, fp], [fn, tp]]
    fp = pred_sum - tp_sum
    fn = true_sum - tp_sum
    tp = tp_sum
    if sample_weight is not None and samplewise:
        tn = sample_weight * y_true.shape[1] - tp - fp - fn
    elif sample_weight is not None:
        tn = xp.sum(sample_weight) - tp - fp - fn
    elif samplewise:
        tn = y_true.shape[1] - tp - fp - fn
    else:
        tn = y_true.shape[0] - tp - fp - fn

    return xp.reshape(xp.stack([tn, fp, fn, tp]).T, (-1, 2, 2))

两条计算路径的对比:

| 维度 | y_true.ndim == 1 (binary/multiclass) | y_true.ndim > 1 (multilabel-indicator) |

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

| 核心操作 | LabelEncoder 二值化 + _bincount | 布尔运算 & + _count_nonzero |

| 聚合轴 | 固定按类别 (axis=0) | samplewise 控制:True=axis=1(逐样本), False=axis=0(逐类) |

| 稀疏支持 | 否(已转稠密) | 是(multiply 支持 CSR/CSC) |

| TN 计算 | 总样本数 - TP - FP - FN | 逐样本:sample_weight * n_labels;逐类:sum(sample_weight) |

为什么两条路径算法不同?

  • 1D 路径:标签已是整数编码,_bincount 是最快的计数工具(硬件加速直方图)。
  • ND 路径:多标签是布尔矩阵,_count_nonzero 直接并行计算每行/列的非零计数,天然支持稀疏矩阵与加权。

30.5.4 cohen_kappa_score:加权 Kappa 与未定义处理

源码路径:sklearn/metrics/_classification.py - cohen_kappa_score()(第582-695行)

def cohen_kappa_score(y1, y2, *, labels=None, weights=None, sample_weight=None, replace_undefined_by=np.nan):
    confusion = confusion_matrix(y1, y2, labels=labels, sample_weight=sample_weight)
    xp, _, device_ = get_namespace_and_device(y1, y2)
    n_classes = confusion.shape[0]
    max_float_dtype = _max_precision_float_dtype(xp, device=device_)
    confusion = xp.astype(confusion, max_float_dtype, copy=False)
    sum0 = xp.sum(confusion, axis=0)  # 预测边际分布
    sum1 = xp.sum(confusion, axis=1)  # 真实边际分布

    # 期望混淆矩阵:外积 / 总和
    numerator = xp.linalg.outer(sum0, sum1)
    denominator = xp.sum(sum0)
    if denominator == 0:
        warnings.warn("`y2` contains no labels ...", UndefinedMetricWarning, stacklevel=2)
        return replace_undefined_by
    expected = numerator / denominator

    # 权重矩阵
    if weights is None:
        w_mat = xp.ones([n_classes, n_classes], dtype=max_float_dtype, device=device_)
        _fill_diagonal(w_mat, 0, xp=xp)  # 对角线为 0(一致不惩罚)
    else:
        w_mat = xp.zeros([n_classes, n_classes], dtype=max_float_dtype, device=device_)
        w_mat += xp.arange(n_classes)
        if weights == "linear":
            w_mat = xp.abs(w_mat - w_mat.T)      # 线性:|i-j|
        else:
            w_mat = (w_mat - w_mat.T) ** 2       # 二次:(i-j)^2

    # 加权观测一致性 / 加权期望一致性
    numerator = xp.sum(w_mat * confusion)
    denominator = xp.sum(w_mat * expected)
    if denominator == 0:
        warnings.warn("`y1`, `y2` and `labels` have only one label in common ...", UndefinedMetricWarning, stacklevel=2)
        return replace_undefined_by

    k = numerator / denominator
    return float(1 - k)

公式还原:

\[\kappa = 1 - \frac{\sum_{i,j} w_{ij} C_{ij}}{\sum_{i,j} w_{ij} E_{ij}} \]

其中 \(E_{ij} = \frac{(\sum_k C_{ik})(\sum_k C_{kj})}{N}\) 为期望频数。

设计亮点:

  • 两处零除保护:总样本数为 0、或仅有一个共同标签导致加权期望为 0。

  • replace_undefined_by 参数:允许用户自定义未定义时的返回值(默认 np.nan),比硬编码 0 更灵活。

  • 跨后端 outer/fill_diagonal:用 Array API 标准操作替代 NumPy 专用函数。

30.6 精确率、召回率与 F 分数 —— 核心指标的统一计算引擎

precision_recall_fscore_support 是所有 PRF 指标的统一计算引擎precision_scorerecall_scoref1_scorefbeta_score 均是其薄封装。

30.6.1 核心引擎:precision_recall_fscore_support

源码路径:sklearn/metrics/_classification.py - precision_recall_fscore_support()(第1180-1340行)

def precision_recall_fscore_support(
    y_true, y_pred, *, beta=1.0, labels=None, pos_label=1,
    average=None, warn_for=("precision", "recall", "f-score"),
    sample_weight=None, zero_division="warn"
):
    _check_zero_division(zero_division)
    labels = _check_set_wise_labels(y_true, y_pred, average, labels, pos_label)

    # 1. 统一获取 TP/Pred/True 统计量(复用 MCM)
    samplewise = average == "samples"
    MCM = multilabel_confusion_matrix(
        y_true, y_pred, sample_weight=sample_weight, labels=labels, samplewise=samplewise
    )
    tp_sum = MCM[:, 1, 1]
    pred_sum = tp_sum + MCM[:, 0, 1]   # TP + FP
    true_sum = tp_sum + MCM[:, 1, 0]   # TP + FN

    xp, _, device_ = get_namespace_and_device(y_true, y_pred)

    # 2. micro 平均:全局求和
    if average == "micro":
        tp_sum = xp.reshape(xp.sum(tp_sum), (1,))
        pred_sum = xp.reshape(xp.sum(pred_sum), (1,))
        true_sum = xp.reshape(xp.sum(true_sum), (1,))

    # 3. 安全除法计算 Precision / Recall
    precision = _prf_divide(tp_sum, pred_sum, "precision", "predicted", average, warn_for, zero_division)
    recall = _prf_divide(tp_sum, true_sum, "recall", "true", average, warn_for, zero_division)

    # 4. F-beta 统一公式:直接用混淆矩阵元素避免 P/R 二次除法
    beta2 = beta**2
    if np.isposinf(beta):
        f_score = recall
    elif beta == 0:
        f_score = precision
    else:
        max_float_type = _max_precision_float_dtype(xp=xp, device=device_)
        denom = beta2 * xp.astype(true_sum, max_float_type) + xp.astype(pred_sum, max_float_type)
        f_score = _prf_divide(
            (1 + beta2) * xp.astype(tp_sum, max_float_type),
            denom,
            "f-score", "true nor predicted", average, warn_for, zero_division
        )

    # 5. 平均聚合
    if average == "weighted":
        weights = true_sum          # 支持度 = 真实正样本数
    elif average == "samples":
        weights = sample_weight     # 样本权重
    else:
        weights = None

    if average is not None:
        precision = float(_nanaverage(precision, weights=weights))
        recall = float(_nanaverage(recall, weights=weights))
        f_score = float(_nanaverage(f_score, weights=weights))
        true_sum = None  # 平均后不返回 support

    return precision, recall, f_score, true_sum

核心设计解析:

  1. 统计量复用:调用 multilabel_confusion_matrix 一次性拿到 tp_sumpred_sumtrue_sum,避免重复计算。

  2. Micro 平均的本质:将所有类别的 TP/FP/FN 全局求和,再计算指标 —— 等价于展平多标签矩阵后的二分类指标。

  3. F-beta 统一公式推导

\[F_\beta = \frac{(1+\beta^2) \cdot TP}{(1+\beta^2) \cdot TP + \beta^2 \cdot FN + FP} \]

直接用 tp_sumtrue_sum(=TP+FN)、pred_sum(=TP+FP) 计算,完全避免了先算 P/R 再算调和平均的数值误差与二次除零风险

  1. Beta 边界显式处理
  • beta=infrecall

  • beta=0precision

  1. 加权平均权重来源
  • weightedtrue_sum(每类真实样本数,即 support)

  • samplessample_weight(每样本权重)

  • macro/micro/binary:无权重(等权/全局)

源码路径:sklearn/metrics/_classification.py - fbeta_score()f1_score()(第950-1070行 / 880-950行)

def fbeta_score(y_true, y_pred, *, beta, labels=None, pos_label=1, average="binary", sample_weight=None, zero_division="warn"):
    _, _, f, _ = precision_recall_fscore_support(
        y_true, y_pred, beta=beta, labels=labels, pos_label=pos_label,
        average=average, warn_for=("f-score",), sample_weight=sample_weight, zero_division=zero_division
    )
    return f

def f1_score(y_true, y_pred, *, labels=None, pos_label=1, average="binary", sample_weight=None, zero_division="warn"):
    return fbeta_score(y_true, y_pred, beta=1, labels=labels, pos_label=pos_label, average=average, sample_weight=sample_weight, zero_division=zero_division)

f1_score 只是 fbeta_score(beta=1) 的别名,fbeta_score 只请求 warn_for=("f-score",) 以避免触发 P/R 警告。

30.6.2 平均策略的通用实现

源码路径:sklearn/metrics/_base.py - _average_binary_score()(第15-80行)

def _average_binary_score(binary_metric, y_true, y_score, average, sample_weight=None):
    """Average a binary metric for multilabel classification."""
    xp, _, _device = get_namespace_and_device(y_true, y_score, sample_weight)
    # ... average 合法性校验 ...
    y_type = type_of_target(y_true)
    if y_type == "binary":
        return binary_metric(y_true, y_score, sample_weight=sample_weight)

    # multilabel-indicator 场景
    if average == "micro":
        # 展平矩阵,复制样本权重
        if score_weight is not None:
            score_weight = xp.repeat(score_weight, y_true.shape[1])
        y_true = _ravel(y_true)
        y_score = _ravel(y_score)
    elif average == "weighted":
        # 按每列真实正样本数加权
        if score_weight is not None:
            y_true = xp.asarray(y_true, dtype=score_weight.dtype)
            average_weight = xp.sum(xp.multiply(y_true, xp.reshape(score_weight, (-1, 1))), axis=0)
        else:
            average_weight = xp.sum(y_true, axis=0)
    elif average == "samples":
        # 逐样本平均:交换权重角色,聚合轴变为 0
        average_weight = score_weight
        score_weight = None
        not_average_axis = 0

    # 逐类/逐样本调用 binary_metric,再按 average_weight 加权平均
    # ...
posted @ 2026-09-04 08:54  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报