# 勤奋的凯尔森同学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(['中奖'])))
1
2
3
4
5
6
7
8
9
10
11
[[0.25 0.75]]
1

类别顺序为[正常, 垃圾]:正常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))
1
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
1
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,投票结果:判○
1
2
3
4

# 1.2.2 手推小例子:K=3和K=5,同一批邻居投出相反结果

KNN的算法步骤只有三步:

  1. 算待测样本到所有训练样本的距离
  2. 取距离最近的K个
  3. 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)
1
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()
1
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
1
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()
1
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
1
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()
1
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
1

画出来的错分图里,经常是"真实是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()
1
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
1
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']])
1
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)
1
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=['少年', '青年', '中年', '老年'])
1
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))
1
2
3
4
5
[ 119.3   49.2 1180.2  960.0]
1

鸢尾花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)
1
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_)
1
2
3
4
5
6
7
[0.106 0.022 0.436 0.436]
1

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)
1
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}')
1
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
1
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}')
1
2
3
4
5
6
7
8
9
10
11
最优参数: {'clf__alpha': 0.01}, 交叉验证F1: 0.9847
调参后测试集准确率: 0.9819
1
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空间里找最近邻。算法不过时,过时的只有使用场景。

最近更新: 2026/9/30
 |  Copyright © -Present Kelsen All Rights Reserved. | Author KelsenJing