写这篇东西的起因,是群里有个同学在跑一个风控实验,模型用KNN,最后输出结果的时候只贴了一个准确率,我随口问了句“你的ROC和PR曲线呢”,他愣了一下,说“KNN不是没有概率输出吗,也能画曲线?”。这个问题其实问到了点子上。KNN确实不像逻辑回归那样直接吐一个sigmoid概率,但它依然可以画ROC、PR曲线,而且画出来的曲线在评估模型能力时非常有用。这篇就围绕“KNN算法性能度量以及roc,pr曲线的绘制”这条主线,把背后的原理、手写实现的细节、sklearn调包的完整流程、以及实操中容易踩的坑一次讲透。
1. 为什么KNN的性能度量容易“看起来没问题,实际上有问题”
1.1 KNN的分类机制决定了你不能只盯着准确率
KNN的思路本身非常直观:一个样本的类别,由它距离最近的K个邻居投票决定。距离计算通常是欧氏距离,大类投票或者按距离加权投票,K值一般取奇数,避免出现平票。
但这个机制有一个很隐蔽的问题——KNN是纯粹的“局部决策”。它不拟合全局函数,也不给样本打分排序,它对每个测试样本只给一个离散的类别标签。这意味着如果你只用一个准确率来评估,就会丢掉大量关于模型置信度、阈值变化、正负样本区分能力的信息。
举个例子:假设一个二分类数据集,正类占2%,负类占98%。你把全部样本预测为负类,准确率直接到98%,看起来模型“无敌了”。但这样的模型在风控场景里一点用都没有——所有坏客户全漏过去了。所以单独看准确率,在类别不平衡的数据集上几乎就是自欺欺人。
这时候性能度量体系就派上用场了。真正需要关注的不只是“猜对了多少”,而是“正类抓到了多少”“抓出来的正类里有多少是真的”以及“在不同阈值下,模型的能力曲线长什么样”。
1.2 性能度量的核心指标:混淆矩阵是地基
不管是ROC、PR还是AUC、AP,本质上都是从混淆矩阵衍生出来的。混淆矩阵的四格,在二分类里是:
| 预测为正 | 预测为负 |
|---|---|
| 真实为正 | TP(真正例) |
| 真实为负 | FP(假正例) |
围绕这张表,衍生出几个常用指标:
- 准确率(Accuracy):(TP + TN) / (TP + FN + FP + TN),就是整体猜对的比例。
- 精确率(Precision):TP / (TP + FP),预测为正的样本里,真正为正的比例。直接回答“模型说这是个正类,它有多可靠”。
- 召回率(Recall):TP / (TP + FN),真实为正的样本里,模型抓到了多少。直接回答“正类样本漏网了多少”。
- F1 Score:Precision和Recall的调和平均数,2 * P * R / (P + R),在两者之间取平衡。
- 真正例率(TPR):TP / (TP + FN),和Recall公式一样。
- 假正例率(FPR):FP / (FP + TN),真实为负的样本里,被误判成正类的比例。
这里有一个容易混乱的点:TPR就是Recall,两者是同一个东西的两种叫法。ROC曲线的纵轴是TPR,PR曲线的横轴也是Recall,但横纵轴搭配不同,导致两条曲线关注的能力维度完全不同。
1.3 KNN为什么也能画ROC和PR:分数从哪来
很多人卡在这个坎上:KNN不是只输出类别标签吗,画曲线需要给每个样本一个分数啊,分数从哪里来?
答案是:把硬投票变成软投票。K近邻中,每个邻居都会给一个类别标签,统计K个邻居里有多少个属于正类。如果K=5,4个邻居投票给正类,那这个样本的正类比例就是4/5=0.8。这个0.8就可以当作“预测为正的概率”或“置信度分数”。
sklearn的KNeighborsClassifier里有个方法叫predict_proba(),返回的正是每个样本在每个类别上的投票比例。如果是5个邻居,3个投正类,2个投负类,那这个样本属于正类的概率就被估计为0.6。是不是真实的概率?不完全是,但它提供了一个可以排序的分数,而有了分数就能画曲线。
这也是很多初学者没想通的点:画ROC不一定需要“真概率”,只需要一个能让样本从高到低排序的置信度分数。KNN的投票比例恰好满足这个需求。
2. ROC曲线绘制:从原理到实战
2.1 ROC曲线的核心逻辑和AUC含义
ROC曲线的两个轴,横轴是FPR(假正例率),纵轴是TPR(真正例率,也就是Recall)。它表达的其实是:随着分类阈值从高往低移动,模型在“多抓正类”和“多误伤负类”之间的权衡变化。
可以这样想:比如有一个信用评分模型,分数从0到100。阈值定在90,只有评分特别高的样本才被判定为正类,这时TPR可能很低(很多真坏客户漏掉了),但FPR也很低(好人基本不会被误判)。阈值往下降,比如降到60,抓到的真坏客户变多了,TPR上升,但误伤好人的比例也跟着上升,FPR变大。ROC曲线就是把不同阈值下的FPR和TPR连成一条线。
AUC就是ROC曲线下的面积,取值在0到1之间。它的统计含义是:随机抽一个正样本,随机抽一个负样本,模型给正样本打的分比负样本高的概率。AUC=0.5说明模型和随机猜差不多,AUC=0.9说明模型能把正负样本分得很开,当然AUC也有小于0.5的情况,那基本等于你的标签反了或者模型逻辑有问题。
2.2 手写ROC计算过程:理解每个点是怎么来的
在调sklearn之前,强烈建议手写一遍ROC计算过程。这样你才能真正理解阈值这个概念,以后遇到曲线画出来的形状怪异,也知道是哪里出了问题。
手写的思路是这样:把所有样本按预测分数从高到低排序,然后从最高分开始,依次把阈值往下滑,每滑动一次,算一次当前的TPR和FPR,连起来就是ROC曲线。
import numpy as np def calc_roc(y_true, scores): # 按分数从高到低排序 thresholds = np.unique(scores)[::-1] fpr_list = [] tpr_list = [] # 统计真实正负样本数 P = np.sum(y_true == 1) N = np.sum(y_true == 0) for th in thresholds: # 分数大于等于阈值的判为正类 preds = (scores >= th).astype(int) TP = np.sum((preds == 1) & (y_true == 1)) FP = np.sum((preds == 1) & (y_true == 0)) TPR = TP / P FPR = FP / N tpr_list.append(TPR) fpr_list.append(FPR) # 补上原点 fpr_list.insert(0, 0.0) tpr_list.insert(0, 0.0) return np.array(fpr_list), np.array(tpr_list) # 造一个简单例子验证 y_true = np.array([1, 0, 1, 1, 0]) scores = np.array([0.9, 0.8, 0.7, 0.3, 0.2]) fpr, tpr = calc_roc(y_true, scores) # 再用sklearn对比 from sklearn.metrics import roc_curve fpr_sk, tpr_sk, _ = roc_curve(y_true, scores) print("手写FPR:", fpr) print("sklearn FPR:", fpr_sk)运行之后你会发现,手写版本和sklearn版本在大多数情况下结果一致,唯一的差异在边界点的处理上。比如sklearn会在最前面和最后面补端点,让曲线闭合到(0,0)和(1,1)。这就是为什么它返回的数组长度通常比你自己算的多。
理解了这一点,再看sklearn的返回值就顺了:roc_curve返回fpr、tpr、thresholds三组数组。稍不注意忽略的是thresholds的数量比fpr、tpr少一个,因为sklearn把“所有样本都判为负类”那个端点单独处理了。
2.3 用sklearn和matplotlib完成ROC实战
接下去进入可以直接抄作业的阶段。拿一个常见的数据集做演示,比如乳腺癌数据集或一个造出来的二分类数据集。这里用某开源数据集做演示,核心代码通用。
import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import load_breast_cancer from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import roc_curve, auc # 加载数据,乳腺癌数据集正类为1,负类为0 data = load_breast_cancer() X, y = data.data, data.target X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) # KNN之前必须做标准化,否则距离计算会被大数值特征主导 scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) # 训练KNN模型 model = KNeighborsClassifier(n_neighbors=5) model.fit(X_train, y_train) # 获取正类概率,注意predict_proba返回两列,要取正类那一列 y_score = model.predict_proba(X_test)[:, 1] # 计算ROC fpr, tpr, _ = roc_curve(y_test, y_score) roc_auc = auc(fpr, tpr) # 绘制ROC曲线 plt.figure(figsize=(6, 6)) plt.plot(fpr, tpr, color="darkorange", lw=2, label="KNN ROC (AUC = %0.3f)" % roc_auc) plt.plot([0, 1], [0, 1], color="navy", lw=1, linestyle="--") plt.xlim([0.0, 1.0]) plt.ylim([0.0, 1.05]) plt.xlabel("False Positive Rate") plt.ylabel("True Positive Rate") plt.title("ROC Curve - KNN") plt.legend(loc="lower right") plt.grid(alpha=0.3) plt.show()几个需要注意的细节:
第一,KNN对特征尺度极其敏感。欧氏距离的计算里,如果特征A的取值范围是0到1,特征B的取值范围是0到1000,那特征A的贡献几乎被完全淹没。标准化不是可选项,是必选项。而且注意,fit_transform只能用在训练集上,测试集必须用同一个scaler的transform。这是一个极其常见的错误:先对全量数据做标准化再切分,这样测试集的信息就会泄漏进训练过程,ROC曲线会偏乐观。
第二,predict_proba的返回值。它是二维数组,行对应样本,列对应类别。在二分类里,第0列是负类概率,第1列是正类概率。如果代码里写[:, 0],画出来的曲线就是反的,AUC可能小于0.5。这个坑我见过很多次,一查原因,全是索引方向搞反了。
第三,绘制时最好加一条对角线。这条线表示随机猜测的基准线,AUC越大于0.5,说明模型越能区分正负样本。
如果想要更严谨一点的评估,可以结合交叉验证画多条ROC曲线,算出均值和置信区间。大致思路是用StratifiedKFold保证每一折正负类比例一致,每一折都训练模型并记录ROC,然后对插值后的TFR求平均。这是比较标准的做法,代码会比单次复杂一点,但结果更有说服力。
from sklearn.model_selection import StratifiedKFold cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) tprs = [] mean_fpr = np.linspace(0, 1, 100) aucs = [] plt.figure(figsize=(6, 6)) for train_idx, test_idx in cv.split(X, y): X_cv_train, X_cv_test = X[train_idx], X[test_idx] y_cv_train, y_cv_test = y[train_idx], y[test_idx] scaler_cv = StandardScaler().fit(X_cv_train) X_cv_train = scaler_cv.transform(X_cv_train) X_cv_test = scaler_cv.transform(X_cv_test) model_cv = KNeighborsClassifier(n_neighbors=5).fit(X_cv_train, y_cv_train) y_cv_score = model_cv.predict_proba(X_cv_test)[:, 1] fpr_fold, tpr_fold, _ = roc_curve(y_cv_test, y_cv_score) roc_auc_fold = auc(fpr_fold, tpr_fold) aucs.append(roc_auc_fold) # 插值到统一的fpr网格上,方便画均值和方差带 tpr_interp = np.interp(mean_fpr, fpr_fold, tpr_fold) tprs.append(tpr_interp) plt.plot(fpr_fold, tpr_fold, lw=0.5, alpha=0.6, color="gray") mean_tpr = np.mean(tprs, axis=0) mean_tpr[-1] = 1.0 std_tpr = np.std(tprs, axis=0) plt.plot(mean_fpr, mean_tpr, color="darkorange", lw=2, label=r"Mean ROC (AUC = %0.3f $\pm$ %0.3f)" % (np.mean(aucs), np.std(aucs))) plt.fill_between(mean_fpr, mean_tpr - std_tpr, mean_tpr + std_tpr, color="orange", alpha=0.2) plt.plot([0, 1], [0, 1], "k--", lw=1) plt.xlim([0, 1]) plt.ylim([0, 1.05]) plt.xlabel("False Positive Rate") plt.ylabel("True Positive Rate") plt.title("KNN ROC with 5-Fold CV") plt.legend(loc="lower right") plt.show()这个做法的意义在于,单次划分训练测试集的结果带有随机性,可能运气好也可能运气差,交叉验证画出的ROC均值和方差,能更可靠地反映模型能力的平均值。
3. PR曲线绘制:小样本不均衡时的王牌
3.1 PR曲线的原理和什么时候用
PR曲线的横轴是Recall(召回率),纵轴是Precision(精确率)。它同样通过扫描阈值来生成,每个点对应一个阈值下的Precision和Recall组合。
什么时候该用PR而不是ROC?关键看负样本的数量。ROC曲线的两个轴都由负样本参与计算——FPR的分母是TN+FP,也就是所有真实负样本。当负样本数量极其庞大时,FPR会被稀释到很小的数值,ROC曲线看起来“很完美”,但实际上模型可能误判了大量负样本。PR曲线的Precision直接将FP放进分子分母,负样本一多,Precision马上被打回原形。
典型的应用场景比如:电信欺诈检测、罕见病诊断、机器故障预测、信贷违约预测。这些场景共同特点是正类非常稀少,通常占比只有百分之几甚至千分之几。在这种情况下,PR曲线比ROC曲线更能暴露模型的真实水平。
有一个非常尖锐的例子可以说明问题:AUC都是0.9的两个模型,在正类占比1%的数据上,PR曲线下的面积(AP)可能一个是0.5,另一个是0.05。模型A的实际表现远好于模型B,但只看ROC曲线根本发现不了这个差距。
3.2 手写PR计算与直接调库对比
PR曲线的计算方法和ROC类似,也是遍历阈值,但关心的是Precision和Recall。用前面那个小型示例,手写一遍:
def calc_pr(y_true, scores): P = np.sum(y_true == 1) thresholds = np.unique(scores)[::-1] precisions = [] recalls = [] for th in thresholds: preds = (scores >= th).astype(int) TP = np.sum((preds == 1) & (y_true == 1)) FP = np.sum((preds == 1) & (y_true == 0)) Precision = TP / (TP + FP) if (TP + FP) > 0 else 0 Recall = TP / P if P > 0 else 0 precisions.append(Precision) recalls.append(Recall) # 补上Recall=0, Precision=1的起点 precisions.append(1.0) recalls.append(0.0) return np.array(recalls), np.array(precisions) # 对比sklearn from sklearn.metrics import precision_recall_curve precision_sk, recall_sk, _ = precision_recall_curve(y_true, scores)sklearn的functional接口很简单,实际的library实现还帮忙处理了正类预测为零这种除零风险(返回0而不是报错)。但你是否真正明白了threshold从高到低滑动时,Precision不断下降、Recall不断上升的趋势变化?多看几次手写过程,理解会更扎实。
调用库的实战版:
from sklearn.metrics import precision_recall_curve, average_precision_score precision, recall, _ = precision_recall_curve(y_test, y_score) ap = average_precision_score(y_test, y_score) plt.figure(figsize=(6, 6)) plt.plot(recall, precision, color="blue", lw=2, label="KNN PR (AP = %0.3f)" % ap) plt.xlabel("Recall") plt.ylabel("Precision") plt.title("PR Curve - KNN") plt.legend(loc="upper right") plt.grid(alpha=0.3) plt.show()注意precision_recall_curve的返回顺序是(precision, recall, thresholds),不要和roc_curve的(fpr, tpr, thresholds)搞混,虽然都是三个返回值,位置含义完全不同。画图时横纵轴一定不能填反,否则一条好好的PR曲线会被画成“反PR”。
3.3 如何解读PR曲线的形状和AP值
PR曲线的右上角意味着Precision和Recall都很高,这是模型追求的理想状态。但真实情况是,阈值越高,模型越谨慎,Precision越高,但Recall越低;阈值越低,模型越激进,Recall越高,但会掺入大量False Positive,Precision下降。
PR曲线下的面积常被称为Average Precision(AP),它的计算方式和AUC不太一样:AP是每个阈值点上的Precision对Recall变化做加权平均,严格来说更接近“在召回率上升的过程中,精确率的平均表现”。sklearn里的average_precision_score实现了这一点。
AP值的baseline不是0.5,而是正类样本占比。比如正类占2%,那一个完全随机的模型AP大约等于0.02。这意味着:如果你的模型AP=0.3,看起来不高,但在正类仅占2%的任务里已经是很强的效果了。很多新手拿AP和AUC比较,觉得怎么AUC都0.9了AP才0.4,是不是代码写错了。没有写错,而是两者的baseline和尺度根本不同。
4. 实战:一个二分类项目的完整度量流程
4.1 场景设定与数据准备
为了把前面的内容串起来,这里用一个模拟的信贷违约预测场景来做演示。场景是:银行有一批历史借款记录,特征包括年龄、收入、负债率、信用账户数、逾期次数等,目标字段是“是否违约”,1表示违约,0表示正常还款。正类(违约)占比大约3%,典型的类别不平衡数据。
因为真实业务数据不方便直接用,这里用模拟数据来代替,但流程完全一致。生成一份带正负类的数据集,然后跑完整流程。
from sklearn.datasets import make_classification X, y = make_classification( n_samples=3000, n_features=15, n_informative=8, n_redundant=3, n_classes=2, weights=[0.97, 0.03], # 正类只占3% flip_y=0.03, # 轻微标签噪声 random_state=42 )这段代码会生成3000个样本,其中大约90个是正类。正类数量很少,这正好考验PR曲线的价值。
4.2 从训练到绘制双曲线完整流程
流程按顺序走:切分数据、标准化、训练KNN、预测概率、计算ROC和PR、画图。这里把全部核心步骤整合到一起,方便整体理解。
import numpy as np import matplotlib.pyplot as plt from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import ( roc_curve, auc, precision_recall_curve, average_precision_score, confusion_matrix, classification_report ) # 切分数据,stratify保证训练测试集中正类比例几乎一致 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) # 标准化,只fit训练集 scaler = StandardScaler() X_train = scaler.fit_transform(X_train) X_test = scaler.transform(X_test) # 训练KNN knn = KNeighborsClassifier(n_neighbors=5) knn.fit(X_train, y_train) # 预测正类概率 y_score = knn.predict_proba(X_test)[:, 1] # 计算ROC相关 fpr, tpr, _ = roc_curve(y_test, y_score) roc_auc = auc(fpr, tpr) # 计算PR相关 precision, recall, _ = precision_recall_curve(y_test, y_score) ap = average_precision_score(y_test, y_score) # 画两个子图 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5)) # 左图:ROC ax1.plot(fpr, tpr, color="darkorange", lw=2, label="ROC (AUC = %0.3f)" % roc_auc) ax1.plot([0, 1], [0, 1], color="navy", lw=1, linestyle="--") ax1.set_xlim([0.0, 1.0]) ax1.set_ylim([0.0, 1.05]) ax1.set_xlabel("False Positive Rate") ax1.set_ylabel("True Positive Rate") ax1.set_title("ROC Curve") ax1.legend(loc="lower right") ax1.grid(alpha=0.2) # 右图:PR ax2.plot(recall, precision, color="blue", lw=2, label="PR (AP = %0.3f)" % ap) ax2.set_xlabel("Recall") ax2.set_ylabel("Precision") ax2.set_title("PR Curve") ax2.legend(loc="upper right") ax2.grid(alpha=0.2) plt.tight_layout() plt.show() # 选0.5阈值看混淆矩阵和分类报告 y_pred = (y_score >= 0.5).astype(int) print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred, target_names=["normal", "default"]))跑完这份代码之后,试着打印混淆矩阵。我几乎可以打赌,如果你的数据确实只有3%正类,那么在默认0.5阈值下,Precision和Recall会非常难看。不是KNN算法不行,而是默认阈值没有针对数据分布做调整。
4.3 结果解读与模型决策建议
日常项目里画完曲线不算结束,还要从曲线推导出“用哪个阈值上线”。这一步其实是被很多人忽略的。
还是以信贷风控为例。如果模型的目标是尽量不放过任何一个违约客户(宁可错杀一部分好人),那应该选择Recall较高的阈值,比如0.3甚至0.2。如果模型的目标是在精准找出违约客户的同时,不要打扰太多正常客户(比如营销场景,给高精准客户推送优惠),那应该选择Precision较高的阈值,比如0.7以上。
可以动态打印不同阈值下的Precision和Recall,结合业务成本做选择:
thresholds = np.arange(0.2, 0.8, 0.05) for th in thresholds: preds = (y_score >= th).astype(int) tn, fp, fn, tp = confusion_matrix(y_test, preds).ravel() precision_cur = tp / (tp + fp) if (tp + fp) > 0 else 0 recall_cur = tp / (tp + fn) if (tp + fn) > 0 else 0 print("Threshold %0.2f -> Precision: %0.3f, Recall: %0.3f" % (th, precision_cur, recall_cur))ROC和PR曲线帮你看到“阈值光谱”上模型的全部可能性,而到底选哪个阈值,是一个结合业务成本、收益、风险偏好的决策。说得直白一点,AUC和AP告诉你模型有多强,Threshold告诉你业务怎么用这个模型。
5. 常见问题与排查技巧实录
5.1 KNN画曲线之前最常踩的五个坑
整理一下我实际见别人踩过、自己踩过的一些坑,直接做成表格,比对诊断:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| AUC接近0或小于0.5 | predict_proba取错了列,取到负类概率 | 确认正类索引,通常用[:, 1] |
| 曲线呈锯齿状,不平滑 | 测试样本量太少,导致每个阈值段样本数极不均匀 | 增加测试集样本量,或使用交叉验证合并曲线 |
| ROC完美但PR极差 | 数据集严重不平衡,负样本主导了FPR计算结果 | 改用PR曲线,关注AP值 |
| 图像诡异,完全不对 | 标准化处理不当,或对全量数据fit后再切分,造成数据泄漏 | 标准化必须先fit训练集,再transform测试集 |
| 报错ValueError: multiclass format is not supported | 标签是字符串或多分类 | 二分类场景先把标签映射为0/1,多分类用OvR处理 |
其中“AUC低于0.5”是最常见也最容易被当成模型训练失败的。实际上KNN大概率已经学到了什么,问题只是你把概率列取反了。赶紧检查predict_proba的输出和列顺序。
关于标签的问题也说一句:sklearn的roc_curve和precision_recall_curve要求二分类标签最好是0和1的整数形式,字符串标签(比如"yes"/"no")会直接报错或产生模糊的正类定义。先用LabelEncoder或者简单手写map做转换。
5.2 ROC和PR到底应该怎么选
这个选择问题在不少团队里甚至能吵一架,给一个比较实用的判别标准:
| 比较维度 | ROC曲线 | PR曲线 |
|---|---|---|
| 最适用场景 | 正负样本相对均衡 | 正类极度稀少 |
| 对类别不平衡的敏感度 | 低,容易被“大量负样本”稀释误判 | 高,能直接反映正类预测质量 |
| Basline参考 | AUC=0.5(与类别分布无关) | AP=正类占比(随分布变化) |
| 业务解释成本 | 较高,面向非技术决策者时要解释FPR | 直观,Precision和Recall易于业务理解 |
| 正面回答的问题 | 排序能力:能否把正类排在负类前面 | 抓取能力:抓出来的正类有多少是准的 |
如果你做的是一个通用分类任务,正负类比例接近1:1,ROC和AUC足够。如果你做的是反欺诈、异常检测、精准营销这种正类极其稀缺的任务,PR曲线和AP是必须放进去的。推荐两个都画,但看结论时以PR为主。
还有一个容易被忽视的点:当正类占比发生变化时,ROC曲线基本稳定而PR曲线会明显变化。这意味着,AUC比较适合衡量模型本身的排序能力,与业务分布解耦,AP则和业务中的真实正类比例强相关。上线前的最终评估,一定是在真实分布上算AP,不要拿一个删除过负样本的测试集来骗自己。
5.3 K值、距离权重与曲线的小技巧
既然主角是KNN,最后聊几个KNN独有的细节。
K值影响什么?K越小,模型对局部结构越敏感,决策边界越复杂,曲线往往更跳跃,方差更大。K越大,模型越平滑,偏差增加但方差下降。需要选K的话,别靠感觉,用GridSearchCV配合ROC_AUC或AP作为评分指标跑一遍,这样是数学上比较舒适的做法。示例如下:
from sklearn.model_selection import GridSearchCV param_grid = {"n_neighbors": [3, 5, 7, 9, 11], "weights": ["uniform", "distance"]} grid = GridSearchCV( KNeighborsClassifier(), param_grid, scoring="roc_auc", cv=5, n_jobs=-1 ) grid.fit(X_train, y_train) print(grid.best_params_)weights="distance"是一个值得试的选项。它让距离更近的邻居拥有更大投票权,相当于在投票时加了软注意力机制。在我做过的几个实际项目里,距离权重通常比等权投票的AP高一点,尤其是在特征噪声较大的数据集上,效果更明显。
PR曲线画出来“毛刺多”怎么办?除了增加样本量,也可以尝试对曲线做平滑插值。但要清楚一点:平滑只是视觉效果,真正要解决锯齿问题还是得靠更多样本或降低K值。曲线本身包含的信息才是关键,不要用平滑掩盖质量问题。
关于多分类怎么画曲线:KNN天然支持多分类,但在画ROC/PR之前必须把任务转化为二分类。常见方式是OvR(One vs Rest),比如有A、B、C三类,画“A vs 非A”的曲线时,把标签变成1(A)和0(B和C),重复做三轮。sklearn里把y用LabelBinarizer处理后就支持这个思路。不过每个类都画曲线会导致信息量很大,实际项目里一般只对关注的目标类单独画。
还有一个实操习惯:把pos_label明确传进去。在多数情况下不传也能正确工作,因为sklearn会默认把较大的值或第一个遇到的类当正类。但这种“隐形默认”是最容易出事的,尤其在类别标签是0/1还是一个什么奇怪编码时。明确写下roc_curve(y_test, y_score, pos_label=1)和average_precision_score(y_test, y_score, pos_label=1),所有人都能一眼看懂你的正类定义。
最后分享一个我个人的实操心得。之前在某数据竞赛项目上,KNN的ROC AUC跑到了0.93,看起来特别漂亮,模型文档里一度准备写“模型表现优秀”。后来顺手算了PR曲线的AP,只有0.11。当时正类占比大概是1.5%,AP=0.11意味着比随机提升了7倍多,但这和AUC 0.93带来的直觉冲击力完全不同。最后跟业务方确认时,用PR曲线和阈值选择方案去沟通,对方反而更快理解了模型的价值。这个经历让我形成习惯:凡是正类占比低于10%的任务,PR曲线一定是评估报告的主角,ROC只作为排序能力参考。这个习惯,也推荐给你。