二分类评价、ROC 与 AUC:参考答案#
说明#
以下答案与正文 10 道题逐题对应。前四题给出主要推导与计算;第 5--7 题给出可以运行的程序和检查方法;第 8--10 题说明比较时需要保持哪些条件相同,以及实验结果能够说明什么、不能说明什么。
总样本数为 100,真实正类 60 个、真实负类 40 个、预测正类 60 个。各指标为
\[\begin{split}\begin{aligned} \operatorname{Accuracy}&=\frac{50+30}{100}=0.8,\\ \operatorname{Precision}&=\frac{50}{50+10}=\frac56,\\ \operatorname{Recall}&=\frac{50}{50+10}=\frac56,\\ \operatorname{FPR}&=\frac{10}{10+30}=\frac14,\\ \operatorname{Specificity}&=\frac{30}{30+10}=\frac34,\\ \operatorname{F1}&=2\frac{(5/6)(5/6)}{5/6+5/6}=\frac56. \end{aligned}\end{split}\]Accuracy 的分母是全部样本;Precision 的分母是预测正类;Recall 的分母是实际正类;FPR 和 Specificity 的分母都是实际负类。F1 只使用 Precision 与 Recall,因此没有显式使用 \(\cotn\)。
正类和负类各有两个。得分降序的标签次序为正、负、正、负,所以 ROC 点依次为
\[(0,0),\quad(0,1/2),\quad(1/2,1/2),\quad(1/2,1),\quad(1,1).\]只有横向移动产生面积,梯形积分为
\[\operatorname{AUC} =\left(\frac12-0\right)\frac{\frac12+\frac12}{2} +\left(1-\frac12\right)\frac{1+1}{2} =\frac14+\frac12 =\frac34.\]更直接地,正样本 0.9 高于两个负样本,正样本 0.7 只高于负样本 0.1;四个正负样本对中有三个顺序正确,所以 AUC 为 \(3/4\)。第一项也可写成宽 \(1/2\)、高 \(1/2\) 的矩形,第二项为宽 \(1/2\)、高 1 的矩形。
设正样本数为 \(n_+\)、负样本数为 \(n_-\)。阈值从高到低跨过一个负样本时,FPR 增加 \(1/n_-\);此时 ROC 高度等于得分高于该负样本的正样本比例。因此曲线下面积为
\[\operatorname{AUC} =\frac{1}{n_+n_-} \sum_{i:y_i=1}\sum_{j:y_j=0} \mathbb I\{s_i>s_j\}.\]右端正是均匀随机抽取一对正、负样本后顺序正确的经验概率。若 \(s_i=s_j\),阈值无法把二者按得分区分;给并列对计半分等价于对其任意先后顺序取平均,也与带并列修正的秩和统计量一致。不同软件若采用其他并列约定,必须明确报告。
代入调和平均数并约分:
\[\operatorname{F1} =2\frac{\frac{\cotp}{\cotp+\cofp} \frac{\cotp}{\cotp+\cofn}} {\frac{\cotp}{\cotp+\cofp} +\frac{\cotp}{\cotp+\cofn}} =\frac{2\cotp}{2\cotp+\cofp+\cofn}.\]上述代数推导要求 Precision、Recall 及其调和平均数都有定义。若 \(\cotp=0\) 且 \(\cofp+\cofn>0\),直接使用右侧表达式会得到 0,这是软件常用的延拓约定,而不是从含零分母的左侧表达式直接推导出的结果。
数值例中 Precision 为 \(30/40=3/4\),Recall 为 \(30/50=3/5\),F1 为 \(60/90=2/3\)。没有预测正类时 Precision 分母为 0;没有真实正类时 Recall 分母为 0,ROC 也无法定义 TPR。应返回
NaN、按任务约定返回 0 或明确报错,并在报告中说明,不能用极小正数悄悄改变定义。NumPy 参考实现如下。辅助函数只接受整数或浮点数据类型,并先检查原始数组,再对已经确认只含 0 和 1 的标签进行整数转换。得分保留原始数据类型,避免极大的相邻整数转换成
float64后被错误地视为并列值:import numpy as np def is_integer_or_float(dtype): return np.dtype(dtype).kind in {"i", "u", "f"} def as_real_vector(value, name): raw = np.asarray(value) if raw.ndim != 1 or raw.size == 0: raise ValueError(f"{name} 必须是非空一维数组") if not is_integer_or_float(raw.dtype): raise TypeError(f"{name} 必须是整数或浮点数组") if not np.all(np.isfinite(raw)): raise ValueError(f"{name} 只能包含有限数值") return raw.copy() def validate_binary_inputs(y_true, score): y_raw = as_real_vector(y_true, "y_true") score = as_real_vector(score, "score") if y_raw.shape != score.shape: raise ValueError("y_true 与 score 的维度必须相同") if not np.all((y_raw == 0) | (y_raw == 1)): raise ValueError("标签只能取 0 或 1") return y_raw.astype(np.int8, copy=False), score def as_finite_threshold(threshold): raw = np.asarray(threshold) if raw.ndim != 0: raise ValueError("threshold 必须是实数标量") if not is_integer_or_float(raw.dtype): raise TypeError("threshold 必须是整数或浮点标量") if not bool(np.isfinite(raw)): raise ValueError("threshold 必须是有限数") return raw.copy()[()] def safe_divide(num, den, zero_division="nan"): if zero_division not in {"nan", "zero", "raise"}: raise ValueError("zero_division 必须是 'nan'、'zero' 或 'raise'") if den != 0: return num / den if zero_division == "nan": return np.nan if zero_division == "zero": return 0.0 raise ZeroDivisionError("评价指标分母为 0") def binary_metrics(y_true, score, threshold=0.5, zero_division="nan"): y, score = validate_binary_inputs(y_true, score) threshold = as_finite_threshold(threshold) if zero_division not in {"nan", "zero", "raise"}: raise ValueError("zero_division 必须是 'nan'、'zero' 或 'raise'") pred = score >= threshold positive = y == 1 tp = int(np.sum(pred & positive)) fp = int(np.sum(pred & ~positive)) tn = int(np.sum(~pred & ~positive)) fn = int(np.sum(~pred & positive)) return { "tp": tp, "fp": fp, "tn": tn, "fn": fn, "accuracy": safe_divide(tp + tn, y.size, zero_division), "precision": safe_divide(tp, tp + fp, zero_division), "recall": safe_divide(tp, tp + fn, zero_division), "fpr": safe_divide(fp, fp + tn, zero_division), "specificity": safe_divide(tn, tn + fp, zero_division), "f1": safe_divide(2 * tp, 2 * tp + fp + fn, zero_division), }
np.number还包含时间间隔等不适用于本题的类型,因此程序明确限定为整数或浮点数据类型。zero_division在计算前就进行检查,所以拼写错误不会因为当前数据恰好没有零分母而被忽略。程序也不在分母中加入 epsilon,因为那会把没有定义的指标变成依赖 epsilon 的任意数值。下面的实现复用第 5 题的输入检查,先验证标签确实为 0 或 1,再转换为整数。程序先进行稳定升序排序,再从最高得分组向最低得分组扫描;这样既不会对整数得分取负,也会保留同分样本原有的先后顺序。每个并列得分组只更新一次,并用字典明确标出返回量:
def roc_auc(y_true, score): y, score = validate_binary_inputs(y_true, score) positives = int(np.sum(y == 1)) negatives = int(np.sum(y == 0)) if positives == 0 or negatives == 0: raise ValueError("ROC 需要正、负样本同时存在") order = np.argsort(score, kind="mergesort") y_sorted = y[order] score_sorted = score[order] thresholds = [np.inf] tpr = [0.0] fpr = [0.0] tp = fp = 0 stop = y.size while stop > 0: start = stop - 1 while start > 0 and score_sorted[start - 1] == score_sorted[stop - 1]: start -= 1 group = y_sorted[start:stop] tp += int(np.sum(group == 1)) fp += int(np.sum(group == 0)) thresholds.append(score_sorted[stop - 1].item()) tpr.append(tp / positives) fpr.append(fp / negatives) stop = start tpr = np.asarray(tpr, dtype=np.float64) fpr = np.asarray(fpr, dtype=np.float64) area = float(np.sum( np.diff(fpr) * (tpr[1:] + tpr[:-1]) / 2.0 )) return { "thresholds": tuple(thresholds), "tpr": tpr, "fpr": fpr, "auc": area, } def pairwise_auc(y_true, score): y, score = validate_binary_inputs(y_true, score) positive_scores = score[y == 1] negative_scores = score[y == 0] if positive_scores.size == 0 or negative_scores.size == 0: raise ValueError("AUC 需要正、负样本同时存在") greater = positive_scores[:, None] > negative_scores[None, :] equal = positive_scores[:, None] == negative_scores[None, :] return float(np.mean(greater + 0.5 * equal))
正负样本对版本直接实现式 (44)。直接比较两个得分可避免先做减法时产生数值溢出。该版本的时间和临时内存均为 \(O(n_+n_-)\),只适合小样本核验;排序扫描的时间复杂度为 \(O(n\log n)\),扫描阶段为 \(O(n)\)。例如,可以用
check_y = np.array([1, 0, 1, 0]) check_score = np.array([0.9, 0.8, 0.7, 0.1]) np.testing.assert_allclose( roc_auc(check_y, check_score)["auc"], pairwise_auc(check_y, check_score), )
逐项核对两个实现得到的 AUC。
可以在第 5、6 题代码后运行下面的测试。按照 手算结果与可信实现对照 的原则与 scikit-learn 比较完整 ROC 点时,使用
drop_intermediate=False,避免可信库为了绘图简化而删除共线的中间点:from sklearn.metrics import confusion_matrix, roc_auc_score, roc_curve y = np.array([1, 0, 1, 0, 1, 0]) score = np.array([0.9, 0.9, 0.6, 0.4, 0.4, 0.1]) result = roc_auc(y, score) reference_fpr, reference_tpr, _ = roc_curve( y, score, drop_intermediate=False ) np.testing.assert_allclose(result["fpr"], reference_fpr) np.testing.assert_allclose(result["tpr"], reference_tpr) np.testing.assert_allclose(result["auc"], roc_auc_score(y, score)) np.testing.assert_allclose(result["auc"], pairwise_auc(y, score)) metric_result = binary_metrics(y, score, threshold=0.5) reference_matrix = confusion_matrix(y, score >= 0.5, labels=[0, 1]) np.testing.assert_array_equal( reference_matrix, [[metric_result["tn"], metric_result["fp"]], [metric_result["fn"], metric_result["tp"]]], ) perfect_y = np.array([0, 0, 1, 1]) np.testing.assert_allclose( roc_auc(perfect_y, [0.1, 0.2, 0.8, 0.9])["auc"], 1.0 ) np.testing.assert_allclose( roc_auc(perfect_y, [0.9, 0.8, 0.2, 0.1])["auc"], 0.0 ) np.testing.assert_allclose( roc_auc(perfect_y, [0.5, 0.5, 0.5, 0.5])["auc"], 0.5 ) all_negative = binary_metrics([0, 1], [0.1, 0.2], threshold=0.5) all_positive = binary_metrics([0, 1], [0.1, 0.2], threshold=0.0) np.testing.assert_equal( [all_negative["tp"], all_negative["fp"], all_negative["tn"], all_negative["fn"]], [0, 0, 1, 1], ) np.testing.assert_equal( [all_positive["tp"], all_positive["fp"], all_positive["tn"], all_positive["fn"]], [1, 1, 0, 0], ) np.testing.assert_equal(np.isnan(all_negative["precision"]), True) zero_result = binary_metrics( [0, 1], [0.1, 0.2], threshold=0.5, zero_division="zero" ) np.testing.assert_allclose(zero_result["precision"], 0.0) with np.testing.assert_raises(ZeroDivisionError): binary_metrics( [0, 1], [0.1, 0.2], threshold=0.5, zero_division="raise", ) with np.testing.assert_raises(ValueError): roc_auc([0, 0], [0.1, 0.2]) with np.testing.assert_raises(ValueError): roc_auc([1, 1], [0.1, 0.2]) with np.testing.assert_raises(ValueError): roc_auc([0.2, 1.8], [0.1, 0.9]) with np.testing.assert_raises(ValueError): roc_auc([], []) with np.testing.assert_raises(ValueError): roc_auc([[0, 1]], [[0.1, 0.9]]) with np.testing.assert_raises(ValueError): roc_auc([0, 1], [0.1]) with np.testing.assert_raises(TypeError): roc_auc([0, 1], [0.1 + 0.2j, 0.9]) with np.testing.assert_raises(TypeError): roc_auc( np.array([np.timedelta64(0, "D"), np.timedelta64(1, "D")]), [0.1, 0.9], ) with np.testing.assert_raises(ValueError): roc_auc([0, 1], [0.1, np.inf]) with np.testing.assert_raises(ValueError): roc_auc([0, 1], [0.1, np.nan]) with np.testing.assert_raises(ValueError): binary_metrics([0, 1], [0.1, 0.9], threshold=np.nan) with np.testing.assert_raises(ValueError): binary_metrics([0, 1], [0.1, 0.9], threshold=[0.5]) with np.testing.assert_raises(ValueError): binary_metrics([0, 1], [0.1, 0.9], threshold=np.inf) with np.testing.assert_raises(TypeError): binary_metrics([0, 1], [0.1, 0.9], threshold=0.5 + 0.1j) with np.testing.assert_raises(ValueError): binary_metrics([0, 1], [0.1, 0.9], zero_division="typo") large_scores = np.array([2**53 + 1, 2**53], dtype=np.int64) large_result = roc_auc([1, 0], large_scores) np.testing.assert_allclose(large_result["auc"], 1.0) if large_result["thresholds"][1:] != (2**53 + 1, 2**53): raise AssertionError("极大整数得分的阈值不应因类型转换而失真") transformed = roc_auc(y, 3.0 * score + 7.0)["auc"] np.testing.assert_allclose(transformed, result["auc"]) original_auc = roc_auc([1, 0], [0.51, 0.50])["auc"] tied_auc = roc_auc([1, 0], np.floor([0.51, 0.50]))["auc"] if np.isclose(original_auc, tied_auc): raise AssertionError("非严格变换产生并列值后,AUC 应允许发生变化")
这些测试同时覆盖全负与全正预测、完美与反向排序、常数和并列得分、单一真实类别、维度或长度错误、非法标签、不适用的数据类型、空数组、复数、无穷大、
NaN、极大整数得分以及参数拼写错误。严格单调递增变换保持顺序,所以 AUC 不变;非严格变换可能产生新并列值,因此不具有同样保证。模型只在训练集上训练,超参数只由验证集选择;随后分别生成并缓存一次验证集与测试集得分。候选集合可以取“高于最大得分的阈值”和各个不同得分,分别包含全负类预测端点和其余能够改变预测结果的位置。先确定假正例和假负例的单位代价 \(c_{\mathrm{FP}}\)、\(c_{\mathrm{FN}}\),再在验证集上计算每个候选阈值的总代价
\[\mathcal C(\tau) =c_{\mathrm{FP}}\cofp(\tau) +c_{\mathrm{FN}}\cofn(\tau).\]上式要求验证集的类别比例和抽样方式能够代表实际使用环境。若验证集经过类别重采样,应按照正文 分类阈值没有脱离任务的统一最优值 中的方法,使用实际正类比例对 FPR 与假负例率加权,或采用相应样本权重。加权公式给出单位部署样本的期望误判代价;若预计处理 \(n\) 个样本,再乘以 \(n\) 才是期望总误判代价,但这一固定倍数不会改变所选阈值。该校正还要求验证集在每个类别内部仍能代表实际数据,不能修复类内抽样偏差或其他数据分布变化。
最大 F1 方案也不能忽略重采样。可以先用实际正类比例 \(\pi\) 按正文公式计算 \(\operatorname{Precision}_{\pi}\) 与 \(\operatorname{Recall}_{\pi}\),再计算 \(\operatorname{F1}_{\pi}\);也可以使用与实际类别比例相匹配的样本权重,计算加权后的真正例、假正例和假负例,再由这些量计算 F1。实验开始前还应规定并列规则,例如多个阈值具有相同最大 F1 或相同最小代价时选择其中最大的阈值,以减少预测正类数量。三个方案确定后,只在测试集上评价一次,报告所选阈值、混淆矩阵、Precision、Recall、F1 和总代价,并单独报告验证集阈值搜索时间。模型训练、固定批量预测和峰值内存对三个阈值方案完全相同,只需作为共同成本报告一次。
当缓存得分为正类概率时,固定阈值 0.5 不需要搜索,但未必适合实际类别比例或错误代价;若得分不是概率,必须先说明分值的含义,不能机械地使用 0.5。最大 F1 默认同等重视 Precision 与 Recall,并忽略真负例;最小代价方案直接使用给定代价,但会依赖代价设定与验证集分布。阈值不会改变 ROC-AUC,也不会改变已经缓存得分的模型前向成本。
本题比较的三个模型应明确为:逻辑回归、隐藏层含 16 个神经元的单隐藏层神经网络,以及隐藏层含 128 个神经元的单隐藏层神经网络。后两个模型都只设置一个隐藏层,并使用相同的激活函数、损失函数、参数初始化方法和训练程序,主要改变隐藏层神经元数量。这样,两个神经网络之间的差异可以主要用于分析隐藏层神经元数量的影响。
三个模型应使用相同的数据划分、输入预处理和随机种子集合。训练时采用相同的最大更新次数和早停规则;各模型的超参数只能使用验证集选择,并为三个模型提供相同的候选范围和选择次数。最终阈值也应采用预先规定且相同的方法选择。对每个随机种子报告 ROC-AUC、Average Precision(AP)或已经明确插值规则的 PR-AUC、测试集固定阈值指标、对数损失 或 Brier 分数、训练时间、固定批量预测时间、峰值内存和参数量,再汇总多次实验的平均结果及其变化范围。
若输入特征维度为 \(d\),并且各层都包含偏置,则逻辑回归有 \(d+1\) 个参数;隐藏层含 \(h\) 个神经元的单隐藏层网络的参数量为
\[dh+h+h+1=h(d+2)+1.\]因此,隐藏层分别含 16 个和 128 个神经元时,参数量分别为 \(16(d+2)+1\) 和 \(128(d+2)+1\)。这个计算可以核对程序报告的参数量,也能说明隐藏层神经元增多为什么通常会增加训练时间、预测时间和内存使用量。
ROC-AUC 与 AP 反映模型区分正负样本的能力;固定阈值指标反映最终分类规则;对数损失、Brier 分数 和 校准曲线 反映概率是否可靠;训练与预测时间、内存和参数量反映计算成本。隐藏层含 128 个神经元的网络能够表示更复杂的关系,但参数更多、计算成本通常也更高,而且测试集指标不一定优于隐藏层含 16 个神经元的网络。逻辑回归与两个神经网络的比较可以说明加入非线性隐藏层是否有帮助;两个神经网络之间的比较则主要说明增加隐藏层神经元数量是否值得。
计算概率质量指标前,应确认三个模型输出的都是 \([0,1]\) 内、可以解释为正类概率的数值。Brier 分数按照式 (47) 计算,并与测试集中真实正类所占比例对应的常数概率基准比较。绘制校准曲线时,应事先确定并对所有模型使用同一种分组规则;对每个区间同时报告式 (50) 中的平均概率、实际正类比例和样本数。若需要进一步调整概率,所用方法只能在验证集或单独的校准集上拟合,再用测试集评价一次。
预测计时必须统一设备、数值类型、批量大小、预测状态、预先运行次数和重复计时方法。为避免概率质量计算影响预测计时,应先缓存每个模型的测试集概率,再分别计算评价指标和校准曲线。
三种算法只接收完全相同的缓存标签与得分数组,不包含模型训练和预测时间。三者都采用
score >= threshold,把同分样本作为一组更新,并保留 \((0,0)\) 与 \((1,1)\) 两个端点;可信库设置drop_intermediate=False。统一数据类型、预先运行次数、重复次数与计时范围;按照 手算结果与可信实现对照 的原则,先用小样本逐点比较 ROC 坐标和 AUC,再改变样本量 \(n\) 与并列得分比例,测量评价时间和峰值内存。若有 \(m\) 个不同得分,朴素算法对每个阈值重新扫描 \(n\) 个样本,时间复杂度为 \(O(mn)\),最坏达到 \(O(n^2)\);排序扫描需要一次 \(O(n\log n)\) 排序和一次 \(O(n)\) 扫描。可信库通常采用相近思路,但内部常数和内存分配可能不同。结果表应报告理论复杂度、显式排序调用次数、ROC 点与 AUC 的最大误差、评价时间和峰值内存;不要求猜测库内部无法可靠观测的标签比较次数。
并列得分增多会减少 \(m\),从而可能降低朴素算法需要检查的阈值数量;若朴素程序仍对重复阈值进行扫描,则不会获得这一好处。墙钟时间依赖硬件与库版本,复杂度和随 \(n\) 变化的趋势通常更具有可迁移性。