# 勤奋的凯尔森同学10000h刻意练习Day11:朴素贝叶斯与K近邻 —— 两个"懒到家"的算法为什么至今不过时?从贝叶斯概率到特征工程
所属专栏: 凯尔森的10000h刻意练习
Slogan: 一个36岁程序员, 研究生(学硕),前世界500强top20软件开发Java工程师,用10000小时深耕AI, 目标Top10 AI资深工程师, 全程日更记录。
先看两条短信:一封写着"中奖 点击链接领取",一封写着"明天下午三点开会"。不用查任何规则,你扫一眼就知道前者是诈骗、后者是正常通知。问题来了——你脑子里既没人逐条教过规则,也没有"点击链接"和"诈骗"之间的因果模型,这个判断到底是怎么做出来的?
今天的两位主角,恰好对应两种最"省事"的答案。**K近邻(KNN)**是真懒:它根本不训练,把数据存下来就下班,预测时才算距离、找邻居、多数票表决——所谓"近朱者赤,近墨者黑"。朴素贝叶斯也懒,但懒在另一个地方:它懒得建模特征之间的关系,直接假设所有特征条件独立,训练说白了就是数数——数每个类别出现多少次、每个词在各类别里出现多少次。一个懒得训练,一个懒得建模,看着都不靠谱,可垃圾邮件过滤、新闻分类这类任务上,朴素贝叶斯至今是工业界首选基线之一;KNN的找最近邻思想,更是推荐系统和向量检索的底层逻辑。
昨天(Day10)在没有标签的世界里做了聚类和降维,今天回到有标签的监督学习。从Day6的线性回归到Day9的SVM,前面的模型都在费心拟合一条决策边界,今天换两种思路:贝叶斯派算后验概率,最近邻派直接投票。讲完两个算法,再补机器学习里最吃功夫、也最值钱的环节——特征工程:缺失值怎么填、类别变量怎么编码、特征怎么缩放和挑选、类别不平衡怎么办。
# 1.1 朴素贝叶斯
# 1.1.1 贝叶斯定理
贝叶斯定理做的事,是把"事先的经验"和"眼前看到的证据"拼成"事后的判断":
- :先验概率。没看到证据前,类别本身占多大比例(所有短信里垃圾短信占多少)
- :似然。假设类别就是,看到证据的概率有多大(垃圾短信里出现"中奖"这个词的频率)
- :后验概率。看到证据后,这条短信属于的概率——这才是分类真正要的东西
- :证据因子。不管哪个类别,出现的总概率,对所有类别都是同一个数;分类只比大小,可以直接扔掉
# 1.1.2 "朴素"在哪
现实中一封邮件有成百上千个词,联合概率没法直接统计——词的组合是天文数字,训练集里根本见不全。朴素贝叶斯的办法是拍一个强假设:特征之间条件独立,已知类别后,每个词出现与否互不影响:
于是分类只需比较各类别的"先验×各特征似然的连乘":
"买啤酒"和"买尿布"明明经常一起出现,这个假设现实中几乎不成立,为什么还敢用?两个原因:第一,分类只比哪个类别的分数高,不要求绝对概率算得准,相对大小对就行;第二,文本数据特别稀疏,上万维词表里每封邮件只命中几十个词,特征间共现本来就少,独立假设的误差被稀释了。1.4节的实测会看到,在新闻分类上它和逻辑回归基本打平。
# 1.1.3 手推小例子:4封短信判断"中奖"
训练集4封已分词短信,2封垃圾、2封正常:
| 编号 | 分词结果 | 类别 |
|---|---|---|
| 1 | 中奖 | 垃圾 |
| 2 | 中奖 链接 | 垃圾 |
| 3 | 通知 开会 | 正常 |
| 4 | 通知 | 正常 |
词表 = {中奖, 链接, 通知, 开会},共个词。垃圾类的词共出现3次(中奖×2、链接×1),正常类也是3次(通知×2、开会×1)。
现在来了一条只含"中奖"的短信,判断它是什么类别。
第一步,先验概率(各类别占训练集的比例):
第二步,算似然。垃圾类很直观:。但算到正常类卡住了:"中奖"在正常类从没出现过,计数是0,于是。后验一乘0,不管别的证据多充分都归零——一个没见过的词直接一票否决,这显然不合理。
解决办法是给每个计数留一点底,叫拉普拉斯平滑(1.1.4节给通用公式):分子加1、分母加上词表大小,让没出现过的词拿到一个很小但非零的概率:
第三步,比后验。分母两边相同,只比分子:
,判定为垃圾。顺手归一化验证:,,相加为1,自洽。
用sklearn复现上面的手算,预测概率分毫不差:
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB
docs = ['中奖', '中奖 链接', '通知 开会', '通知']
y = [1, 1, 0, 0] # 1=垃圾, 0=正常
vec = CountVectorizer(token_pattern=r'(?u)\b\w+\b') # 中文按空格切好的词逐个计数
X = vec.fit_transform(docs)
clf = MultinomialNB(alpha=1.0) # alpha=1就是拉普拉斯平滑
clf.fit(X, y)
print(clf.predict_proba(vec.transform(['中奖'])))
2
3
4
5
6
7
8
9
10
11
[[0.25 0.75]]
类别顺序为[正常, 垃圾]:正常0.25、垃圾0.75,和手算完全一致。
# 1.1.4 拉普拉斯平滑
把上面的做法写成通用公式,引入平滑参数:
- :词在类别中出现的次数
- :类别中所有词的出现总次数
- :词表大小
- 叫拉普拉斯平滑;叫Lidstone平滑,平滑力度更弱
为什么分母要加,而不是只加?因为分子给词表里每个词都加了,全部词的概率凭空多出;分母同步补上这个量,该类别下所有词的概率之和才能仍然等于1,概率归一化不被破坏。sklearn的MultinomialNB(alpha=1.0)默认就是拉普拉斯平滑。
# 1.1.5 三种朴素贝叶斯
平滑解决"没见过的词概率为0",而不同数据类型对应不同的似然分布假设:
| 变体 | 适用数据 | 似然假设 |
|---|---|---|
| GaussianNB | 连续特征 | 假设服从高斯(正态)分布 |
| MultinomialNB | 离散计数(词频) | 多项式分布 |
| BernoulliNB | 二值特征(词是否出现) | 伯努利分布 |
文本词频用MultinomialNB,只关心"词出不出现"的二值表示用BernoulliNB,连续数值特征用GaussianNB。
# 1.1.6 代码实战:新闻分类
把手算的方法搬到真实语料上:从20 Newsgroups取棒球和太空两个版块,TF-IDF向量化后接多项式朴素贝叶斯,一条流水线搞定:
from sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.pipeline import Pipeline
from sklearn.metrics import classification_report
# fetch_20newsgroups拉取著名的 20 Newsgroups 数据集(约 2 万篇 Usenet 新闻组帖子,分 20 个类别)。这里只挑了两个差异很大的类别做二分类(选棒球vs航天),所以任务简单、跑得快
categories = ['rec.sport.baseball', 'sci.space']
train = fetch_20newsgroups(subset='train', categories=categories)
test = fetch_20newsgroups(subset='test', categories=categories)
print(f'训练集{len(train.data)}封, 测试集{len(test.data)}封')
# TfidfVectorizer——把任意长度的文本变成定长的数值向量。它内部做了三件事:分词、建词表(默认会过滤英文停用词)、计算TF-IDF权重。TF-IDF的核心思想是:一个词在这篇文档里出现得多(TF高),但在所有文档里出现得少(IDF高),它就越能代表这篇文档。像"the"、"write"这种满屏都是的词权重会被压低,而"asteroid"、"strikeout"这类词会被凸显出来。
# MultinomialNB——多项式朴素贝叶斯。它假设各个特征(词)在给定类别下条件独立(这个假设现实中并不成立,所以叫"朴素"),用贝叶斯定理算P(类别|文档),取概率最大的那个。它对高维稀疏的文本特征效果出奇地好,而且训练极快。
# 为什么必须用Pipeline?因为fit时只在训练集上建词表和算IDF,预测时直接用同一套规则转换测试集。手写的话很容易不小心在测试集上重新fit_transform一次,造成数据泄露,指标虚高。Pipeline从机制上杜绝了这个问题。
model = Pipeline([
('tfidf', TfidfVectorizer()),
('clf', MultinomialNB())
])
model.fit(train.data, train.target)
pred = model.predict(test.data)
# 输出的是四个指标,理解它们的区别是看懂报告的关键:
# precision(精确率):预测为棒球的帖子里,真的是棒球的比例——"宁缺毋滥"
# recall(召回率):所有真棒球帖子里,被找出来的比例——"宁错杀不放过"
# f1-score:上面两者的调和平均,综合打分
# support:该类别在测试集中的真实样本数,用来判断指标可不可信
print(classification_report(test.target, pred, target_names=train.target_names))
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
训练集1190封, 测试集791封
precision recall f1-score support
rec.sport.baseball 0.99 0.99 0.99 397
sci.space 0.99 0.99 0.99 394
accuracy 0.99 791
macro avg 0.99 0.99 0.99 791
weighted avg 0.99 0.99 0.99 791
2
3
4
5
6
7
8
9
两类近800封测试邮件,朴素贝叶斯只错了个位数。
# 1.2 K近邻(KNN)
# 1.2.1 思想:近朱者赤
KNN的假设一句话说尽:一个样本的类别,由离它最近的K个邻居投票决定。它没有显式的训练过程——fit做的事就是把训练集存起来,所有计算都推迟到预测时,所以KNN又叫"懒惰学习"(lazy learning):训练零成本,代价全在预测时。
○ ○ ○
★ ★ 是待测样本
● ● 最近的5个邻居:3个○、2个●
K=5,投票结果:判○
2
3
4
# 1.2.2 手推小例子:K=3和K=5,同一批邻居投出相反结果
KNN的算法步骤只有三步:
- 算待测样本到所有训练样本的距离
- 取距离最近的K个
- K个邻居中票数最多的类别就是预测结果
设待测样本周围5个最近邻,按距离由近到远排列:
| 邻居 | 距离 | 类别 |
|---|---|---|
| 1 | 1.2 | A |
| 2 | 1.5 | A |
| 3 | 2.0 | B |
| 4 | 2.8 | B |
| 5 | 3.5 | B |
- K=3:圈子里2票A、1票B → 判A
- K=5:圈子一扩大,变成2票A、3票B → 判B
同一批数据,K一变答案就变——这就是KNN唯一、但要命的超参数。万一平票(比如K=2时1票A、1票B),sklearn会优先取距离更近的那个邻居;也可以设weights='distance'让近邻的票权更大。拿K=5这组算:距离加权后A的票权为,B为,A又反超回来。
基本用法:
from sklearn.neighbors import KNeighborsClassifier
from sklearn.datasets import load_iris
X, y = load_iris(return_X_y=True)
knn = KNeighborsClassifier(n_neighbors=5) # 一人一票
knn_w = KNeighborsClassifier(n_neighbors=5, weights='distance') # 越近票权越大
knn.fit(X, y)
2
3
4
5
6
7
# 1.2.3 K值怎么选
| K值 | 偏差 | 方差 | 现象 |
|---|---|---|---|
| 太小(如K=1) | 低 | 高 | 只听最近几个人的,决策边界犬牙交错,过拟合 |
| 太大(如K=100) | 高 | 低 | 远处不相干的样本也来投票,边界糊成一片,欠拟合 |
实践中用交叉验证扫描K值,经验起点取附近(为训练样本数),比如120个样本就从K≈11附近开始试。1.2.6节在鸢尾花上实测,交叉验证选出的最优K=6。
# 1.2.4 距离度量
| 距离 | 公式 | 适用 |
|---|---|---|
| 欧氏距离 | $$\sqrt{\sum (x_i - y_i)^2}$$ | 默认,连续特征 |
| 曼哈顿距离 | $$\sum | x_i - y_i |
| 余弦距离 | $$1 - \frac{x \cdot y}{ | x |
# 1.2.5 KNN的特点
- 优点:原理简单、无需训练、天然支持多分类
- 缺点:预测慢(每个待测点要和全部训练样本算距离)、存储开销大、对特征尺度敏感、高维下距离集体失效(维度灾难)
加速方面,中小数据规模sklearn会用KD-Tree或Ball Tree(algorithm='kd_tree'/'ball_tree')避免蛮力穷举;百万级以上的数据通常上近似最近邻索引(如HNSW、FAISS),用一点精度换大幅提速——这正是今天推荐系统和RAG向量检索的标配。
# 1.2.6 完整实战:鸢尾花分类
把流程串起来:交叉验证选K → 训练预测 → 混淆矩阵 → 决策边界可视化。
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import accuracy_score, confusion_matrix, ConfusionMatrixDisplay
iris = load_iris()
X, y = iris.data, iris.target
target_names = iris.target_names
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
# 1. 交叉验证扫描K=1~30,选平均准确率最高的
k_range = range(1, 31)
cv_scores = [
cross_val_score(KNeighborsClassifier(n_neighbors=k),
X_train, y_train, cv=5, scoring='accuracy').mean()
for k in k_range
]
best_k = k_range[np.argmax(cv_scores)]
print(f'最优K值: {best_k}, 交叉验证准确率: {max(cv_scores):.4f}')
plt.plot(k_range, cv_scores, 'o-')
plt.xlabel('K')
plt.ylabel('交叉验证准确率')
plt.title('不同K值下的性能')
plt.grid(True)
plt.show()
# 2. 用最优K训练并预测
knn = KNeighborsClassifier(n_neighbors=best_k)
knn.fit(X_train, y_train)
y_pred = knn.predict(X_test)
print(f'测试集准确率: {accuracy_score(y_test, y_pred):.4f}')
# 3. 混淆矩阵:看错在哪些类别之间
cm = confusion_matrix(y_test, y_pred)
ConfusionMatrixDisplay(cm, display_labels=target_names).plot(cmap='Blues')
plt.title('混淆矩阵')
plt.show()
# 4. 决策边界:只用前两个特征训练,方便在二维纸上画出来
knn_2d = KNeighborsClassifier(n_neighbors=best_k).fit(X_train[:, :2], y_train)
x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5
y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5
# meshgrid在二维平面上撒出一张步长0.02的密集网格点
xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.02),
np.arange(y_min, y_max, 0.02))
# np.c_[xx.ravel(), yy.ravel()] 把两个二维网格拉平成一列列坐标点,凑成模型能吃进的 (N, 2) 形状
# 对这上万个点逐一 predict,再 reshape 回网格形状
Z = knn_2d.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
# contourf 按预测类别给网格上色,就得到了彩色区域;散点是真实样本
plt.contourf(xx, yy, Z, alpha=0.3, cmap='viridis')
plt.scatter(X[:, 0], X[:, 1], c=y, cmap='viridis', edgecolor='k')
plt.xlabel(iris.feature_names[0]); plt.ylabel(iris.feature_names[1])
plt.title(f'KNN决策边界(K={best_k}, 仅前两个特征)')
plt.show()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
最优K值: 6, 交叉验证准确率: 0.9833
测试集准确率: 0.9667
2
两点说明:鸢尾花4个特征单位都是厘米、尺度接近,不标准化效果就很好;但如果特征量纲悬殊(比如年龄20-80岁、收入5000-50000元),收入会凭数值幅度主导距离,这种数据务必先StandardScaler(见1.3.3)。另外决策边界图只用前两个特征训练,纯粹为了可视化,用来感受K大小对边界平滑度的影响;真实模型用的是全部4个特征,别拿这张图当评估依据。
# 1.2.7 错误分析:看错在哪
准确率只给出对错比例,把错分样本挑出来,才能看到模型的盲区。
# 接上面鸢尾花实战的 y_test / y_pred / X
misclassified = np.where(y_pred != y_test)[0]
print(f'错分样本数: {len(misclassified)}/{len(y_test)}')
for idx in misclassified:
print(f'样本{idx}: 真实={target_names[y_test[idx]]}, 预测={target_names[y_pred[idx]]}')
# 用PCA把4维投到2维,标出错分点的位置(PCA只负责可视化,分类器始终用4维)
from sklearn.decomposition import PCA
# 创建一个降维到2维的PCA模型,并用全部样本数据X训练拟合这个模型
# .fit(X):用完整的150个4维样本X来学习投影规则 ——PCA会自动计算出数据中方差最大的两个方向(称为“主成分”),作为新的坐标轴
pca = PCA(n_components=2).fit(X)
# 把原始4维的全部样本X,投影到刚才学到的2维新坐标系上
# 得到形状为(150, 2)的数组X_all_pca,每一行对应一个样本在2维PCA空间的(x, y)坐标
X_all_pca = pca.transform(X)
# 单独把测试集样本投影到同一个PCA空间中
# 后面要专门标记“测试集中的错分样本”,所以需要单独拿到测试集的2维坐标
# 必须复用已经拟合好的pca对象,不能用测试集重新fit,否则两个数据集的坐标系不一样,位置就对不上了
X_test_pca = pca.transform(X_test)
plt.figure(figsize=(8, 5))
# enumerate(target_names):同时拿到类别编号i(0、1、2)和类别名称name(setosa、versicolor、virginica). 为什么要循环:每个类别用不同颜色区分,并且单独添加图例标签,让图更易读
for i, name in enumerate(target_names):
# 在图上画出第i类所有样本的散点
# X_all_pca[y == i, 0]:布尔索引,只选出真实标签为i的样本,取它们的第0主成分作为x轴坐标
# X_all_pca[y == i, 1]:取同一批样本的第1主成分作为y轴坐标
# label=name:给这组点加上类别名称标签,用于后续生成图例
plt.scatter(X_all_pca[y == i, 0], X_all_pca[y == i, 1], label=name, alpha=0.5)
# 专门把测试集中被分错的样本,用醒目的红色大叉号标出来
# X_test_pca[misclassified, 0/1]:用之前得到的错分样本索引misclassified,从测试集PCA坐标中挑出错分样本的x、y坐标
# marker='X':用叉号形状,和普通圆形样本点区分开
# s=150:放大标记尺寸,让错分点更醒目
# c='red':用红色填充,突出错误点
# edgecolor='k':给叉号加黑色边框,即使在深色背景上也清晰可见
# label='错分样本':添加到图例中
plt.scatter(X_test_pca[misclassified, 0], X_test_pca[misclassified, 1],
marker='X', s=150, c='red', label='错分样本', edgecolor='k')
plt.legend()
plt.title('错分样本在PCA空间的位置')
plt.show()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
错分样本数: 1/30
样本19: 真实=virginica, 预测=versicolor
2
唯一被错分的样本19,是virginica被判成versicolor——这两类在花瓣尺寸上本来就有大片重叠,交界处的样本人眼也容易犹豫,模型错得并不冤枉。一般来说,错分点扎堆在类别交界处是正常现象;如果错分总是集中在某一个类,那往往是该类样本不足或特征区分度不够,该回去补数据,而不是继续调参。
手写数字识别里同样的分析更直观:把错分的8×8图片画出来,看模型到底在哪些数字上犯糊涂。
from sklearn.datasets import load_digits
digits = load_digits()
# 测试集和验证集划分
Xd_tr, Xd_te, yd_tr, yd_te = train_test_split(
digits.data, digits.target, test_size=0.2, random_state=42, stratify=digits.target
)
knn_d = KNeighborsClassifier(n_neighbors=best_k).fit(Xd_tr, yd_tr)
yd_pred = knn_d.predict(Xd_te)
mis = np.where(yd_pred != yd_te)[0]
print(f'数字错分数: {len(mis)}/{len(yd_te)}, 准确率: {accuracy_score(yd_te, yd_pred):.4f}')
fig, axes = plt.subplots(2, 5, figsize=(12, 6))
for i, idx in enumerate(mis[:10]):
# xes是2×5的二维数组,axes.ravel()把它拉平成长度10的一维数组。[i]取第i个子图
ax = axes.ravel()[i]
ax.imshow(Xd_te[idx].reshape(8, 8), cmap='gray') # idx是测试集内位置,要从Xd_te取图
ax.set_title(f'真:{yd_te[idx]} 预:{yd_pred[idx]}')
# 隐藏该子图的坐标轴刻度和边框
ax.axis('off')
# plt.suptitle(...) → 给整张画布加总标题。注意它和ax.set_title的区别:后者是每张小图各自的标题,前者是全局的大标题
plt.suptitle('错分数字示例')
# plt.tight_layout() → 自动调整子图间距,防止标题和刻度互相重叠。凡是画多子图,这行几乎必加
plt.tight_layout()
plt.show()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
数字错分数: 7/360, 准确率: 0.9806
画出来的错分图里,经常是"真实是4、预测成9""真实是8、预测成3"这类连人都会犹豫的潦草字迹——模型不是在乱猜,而是学到了真实的混淆模式。
# 1.3 特征工程
"数据和特征决定了模型的上限,算法只是逼近这个上限。"特征工程就是把原始数据加工成模型能吃、爱吃的输入:缺失的补上、类别的数字化、量级的统一、新信息的构造、无用特征的剔除。
# 1.3.1 缺失值处理
先造一个带缺失值的DataFrame。数值列用中位数填(比均值抗异常值),类别列用众数填:
import pandas as pd
import numpy as np
df = pd.DataFrame({
'age': [25, 30, np.nan, 35, 28],
'city': ['北京', '上海', '北京', np.nan, '深圳'],
'income': [5000, 6000, 5500, np.nan, 7000]
})
df['age'] = df['age'].fillna(df['age'].median()) # 数值:中位数
df['city'] = df['city'].fillna(df['city'].mode()[0]) # 类别:众数
# sklearn流水线里用SimpleImputer,可以和scaler、模型串成一个Pipeline
from sklearn.impute import SimpleImputer
df['income'] = SimpleImputer(strategy='median').fit_transform(df[['income']]).ravel()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
填充结果:age填29、city填"北京"、income填5750。
比"怎么填"更重要的是先问"为什么缺":传感器故障丢的值和用户故意不填的值,信息量完全不同;缺失本身有时就是一个强信号,可以额外加一列is_missing把"缺失过"这件事单独记下来。
# 1.3.2 类别特征编码
模型只吃数字,文本类别要先编码。
有序类别(小学<中学<大学)要按真实顺序编码,用Categorical显式指定顺序,别用LabelEncoder——它只按字符排序,不懂学历高低:
df['edu'] = ['小学', '硕士', '大学', '中学', '大学']
df['edu'] = pd.Categorical(
df['edu'], categories=['小学', '中学', '大学', '硕士'], ordered=True
)
df['edu_label'] = df['edu'].cat.codes # 小学→0, 中学→1, 大学→2, 硕士→3
2
3
4
5
无序类别(城市、颜色)用One-Hot:每个类别一列,是它为1、否则为0。直接给城市编整数会让模型误以为"深圳(2)比北京(0)大",凭空造出不存在的顺序:
# pandas一行搞定
df_onehot = pd.get_dummies(df, columns=['city'])
# sklearn方式
from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(sparse_output=False, drop='first')
city_encoded = ohe.fit_transform(df[['city']])
2
3
4
5
6
7
drop='first'丢掉第一列:3个城市只需2列就能区分,两列全是0就代表被丢掉的那个城市,同时避免多重共线性。决策树类模型按阈值切分、对虚假顺序不敏感,通常不需要One-Hot。(LabelEncoder的正经用途是给标签y编码。)
# 1.3.3 数值特征缩放
标准化(Standardization):变成均值0、标准差1:
保留异常值的相对信息,适合基于距离的模型(KNN、SVM、KMeans)和基于梯度下降的模型(线性回归、逻辑回归、神经网络)。
归一化(Min-Max Normalization):压到[0, 1]:
对异常值敏感,适合取值范围明确的数据,如图像像素(0-255 → 0-1)。
from sklearn.preprocessing import StandardScaler, MinMaxScaler
X_std = StandardScaler().fit_transform(X)
X_mm = MinMaxScaler().fit_transform(X)
2
3
一条经验法则:用距离的、用梯度的模型必须缩放;树模型(决策树、随机森林、XGBoost)按阈值切分,对尺度免疫,不用缩放。
# 1.3.4 特征构造
从现有特征里"算"出新信息,往往比调参更有效。
# 日期拆出周期信号
df['date'] = pd.to_datetime(
['2024-05-01', '2024-05-02', '2024-05-03', '2024-05-04', '2024-05-05']
)
df['month'] = df['date'].dt.month
df['dayofweek'] = df['date'].dt.dayofweek
df['is_weekend'] = (df['dayofweek'] >= 5).astype(int)
# 有业务含义的比值与变换:人均收入、对数压缩长尾
df['income_per_age'] = df['income'] / df['age']
df['income_log'] = np.log1p(df['income'])
# 连续值分箱:抗噪声,顺便引入非线性
df['age_group'] = pd.cut(df['age'], bins=[0, 18, 30, 50, 100],
labels=['少年', '青年', '中年', '老年'])
2
3
4
5
6
7
8
9
10
11
12
13
14
15
构造特征拼的是对业务的理解:电商场景里"最近一次购买距今天数"比"累计消费金额"更能预测流失,风控里"夜间交易占比"比单笔金额更能说明问题。这部分是算法替代不了的。
# 1.3.5 特征选择
特征不是越多越好:无关特征拖慢训练、加剧过拟合,还会稀释重要特征。三条路线:
过滤法(Filter):不依赖具体模型,先按统计指标筛。比如ANOVA的F值衡量"特征在不同类别之间差异大不大":
from sklearn.feature_selection import SelectKBest, f_classif
selector = SelectKBest(score_func=f_classif, k=2)
X_selected = selector.fit_transform(X, y)
print(np.round(selector.scores_, 1))
2
3
4
5
[ 119.3 49.2 1180.2 960.0]
鸢尾花4个特征的F值如上,选出的前2名是花瓣长度和花瓣宽度——和植物学常识一致。
包裹法(Wrapper):把模型当考官,反复训练、每轮淘汰最没用的特征,效果好但计算量大。代表方法是递归特征消除RFE:
from sklearn.feature_selection import RFE
from sklearn.linear_model import LogisticRegression
rfe = RFE(LogisticRegression(max_iter=1000), n_features_to_select=2)
X_rfe = rfe.fit_transform(X, y)
2
3
4
5
嵌入法(Embedded):训练过程中顺手完成选择,不花额外轮次。两种主流做法:L1正则化把没用特征的系数压成0;树模型训练完直接给出特征重要性:
from sklearn.linear_model import Lasso
lasso = Lasso(alpha=0.1).fit(X, y)
X_l1 = X[:, lasso.coef_ != 0] # 只剩花瓣长度1个特征非零
from sklearn.ensemble import RandomForestClassifier
rf = RandomForestClassifier(n_estimators=100, random_state=42).fit(X, y)
print(rf.feature_importances_)
2
3
4
5
6
7
[0.106 0.022 0.436 0.436]
Lasso只留下花瓣长度,随机森林则把约87%的重要性分给花瓣长度和花瓣宽度,三种方法指向同一个结论:区分鸢尾花品种,看花瓣就够了。
# 1.3.6 处理类别不平衡
正负样本1:99时,模型闭眼全猜多数类也有99%的准确率,但真正要抓的欺诈、患病样本全被漏掉。三种应对:
from sklearn.linear_model import LogisticRegression
# 1. 类别权重:让少数类的错分罚得更重,一行参数搞定,首选
clf = LogisticRegression(class_weight='balanced')
# 2. 上采样:有放回复制少数类,补齐到和多数类一样多
from sklearn.utils import resample
X_minority_up = resample(X[y == 1], n_samples=(y == 0).sum(), random_state=42)
X_balanced = np.vstack([X[y == 0], X_minority_up])
y_balanced = np.array([0] * (y == 0).sum() + [1] * (y == 0).sum())
# 3. SMOTE:在少数类相邻样本之间插值造新样本,比简单复制更自然
# 第三方库,需先 pip install imbalanced-learn
from imblearn.over_sampling import SMOTE
X_smote, y_smote = SMOTE(random_state=42).fit_resample(X, y)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
配套地,评估时别只盯准确率,要看混淆矩阵、少数类的召回率和F1——这些指标Day13会系统讲。
# 1.4 综合实战:三种模型同台比文本分类
把朴素贝叶斯、KNN和Day7的逻辑回归放进同一条TF-IDF流水线(完全相同的5000个词特征),在棒球、太空、政治三个版块上公平比试:
from sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.naive_bayes import MultinomialNB
from sklearn.neighbors import KNeighborsClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.metrics import accuracy_score, f1_score
categories = ['rec.sport.baseball', 'sci.space', 'talk.politics.misc']
data = fetch_20newsgroups(subset='all', categories=categories)
X_train, X_test, y_train, y_test = train_test_split(
data.data, data.target, test_size=0.2, random_state=42, stratify=data.target
)
print(f'总样本{len(data.data)}, 训练集{len(X_train)}/测试集{len(X_test)}')
models = {
'NaiveBayes': MultinomialNB(),
'KNN': KNeighborsClassifier(n_neighbors=5),
'LogisticRegression': LogisticRegression(max_iter=1000)
}
for name, clf in models.items():
pipe = Pipeline([
('tfidf', TfidfVectorizer(max_features=5000, stop_words='english')),
('clf', clf)
])
pipe.fit(X_train, y_train)
pred = pipe.predict(X_test)
print(f'{name:<20}准确率={accuracy_score(y_test, pred):.4f} '
f'F1(macro)={f1_score(y_test, pred, average="macro"):.4f}')
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
总样本2756, 训练集2204/测试集552
NaiveBayes 准确率=0.9837 F1(macro)=0.9834
KNN 准确率=0.9565 F1(macro)=0.9564
LogisticRegression 准确率=0.9873 F1(macro)=0.9868
2
3
4
# 1.4.1 结果解读:朴素贝叶斯凭什么打平逻辑回归
- 高维稀疏正合口味:5000维特征里每封邮件只命中几十个词,特征共现少,条件独立假设的误差被稀释
- 只比相对大小:后验的绝对值不准无所谓,大小顺序对就能分对
- 训练就是数数:不用梯度迭代,上万维也是秒级训完;逻辑回归要多轮迭代,KNN每预测一封都要和2204封训练邮件算距离,都比它重
KNN垫底也不意外:在5000维稀疏空间里算距离,正中维度灾难。好在TF-IDF向量默认做了L2归一化,向量归一化后欧氏距离的远近排序等价于余弦相似度,否则它会输得更多。
# 1.4.2 调参:平滑力度alpha
朴素贝叶斯主要调一个平滑参数:太小,模型对没见过的词过度自信;太大,所有词的概率被拉平、区分度下降。网格搜索扫一遍:
from sklearn.model_selection import GridSearchCV
pipe = Pipeline([
('tfidf', TfidfVectorizer(max_features=5000, stop_words='english')),
('clf', MultinomialNB())
])
grid = GridSearchCV(pipe, {'clf__alpha': [0.01, 0.1, 0.5, 1.0, 2.0, 5.0]},
cv=5, scoring='f1_macro')
grid.fit(X_train, y_train)
print(f'最优参数: {grid.best_params_}, 交叉验证F1: {grid.best_score_:.4f}')
print(f'调参后测试集准确率: {accuracy_score(y_test, grid.predict(X_test)):.4f}')
2
3
4
5
6
7
8
9
10
11
最优参数: {'clf__alpha': 0.01}, 交叉验证F1: 0.9847
调参后测试集准确率: 0.9819
2
有意思的是,交叉验证选出的最优alpha=0.01,拿到测试集上准确率0.9819,和默认alpha=1.0的0.9837基本持平。简单任务上默认参数常常已经够用,调参有没有收益要靠交叉验证确认,不能想当然;模型之间零点几个百分点的差距,很多时候也在随机波动范围内,真正重要的是理解差距背后的原因。
# 1.5 小结
# 选型速查
| 场景 | 选择 |
|---|---|
| 垃圾邮件、新闻文本分类,要快、要一个靠谱基线 | MultinomialNB + TF-IDF |
| 数据量不大、边界不规则、不想训练模型 | KNN(先标准化,交叉验证选K) |
| 连续数值特征、近似正态分布 | GaussianNB |
| 特征里有缺失、类别变量、量纲不一 | 先做特征工程,再谈选模型 |
| 正负样本1:99 | class_weight='balanced'或SMOTE,评估看召回率和F1 |
# 今天真正值得记住的
两个"懒"算法给出的启发相反相成:朴素贝叶斯的假设最武断(所有特征条件独立),却在高维稀疏的文本场景异常好用——模型不必逼真,假设契合数据就行;KNN最没有"模型"(训练集本身就是模型),却逼着人正视距离和尺度的问题——喂给模型的特征,往往比模型本身更决定成败。这正好解释了特征工程的地位:缺失值、编码、缩放、构造、选择、不平衡处理,每一步都在抬高模型能够逼近的那个上限。
放到今天看,这两个老算法一点不过时:朴素贝叶斯"数数+算概率"的思路仍是很多文本系统的第一道防线;KNN更是换了个名字活在大模型时代——RAG里的向量检索,本质就是在embedding空间里找最近邻。算法不过时,过时的只有使用场景。