D. 三种值得认识的经典方法
遇到一个新任务,不必总从搭建神经网络开始。有时,只问“谁和它最像”已经能给出不错的参照;有时,数一数某些特征在各类里出现得多不多,就能建立一个轻巧的分类器。
这里补上近邻、朴素贝叶斯和支持向量机。三者各有不同的出发点,也各有容易被忽略的假设。我们用能看清计算的人工小例子理解它们,不把这些算例当成真实任务的排行榜。
1. 近邻:先问问附近的样本
假设每条记录有两个特征。新样本来了,先计算它与每条训练记录的距离,再让最近的 条记录投票。这个方法叫 k 近邻,英文是 k-nearest neighbors,常写作 k-NN。
欧几里得距离是:
这里右上方的 表示特征数,左边的 是距离函数。两个写法的角色不同。
当 ,直接使用最近样本的类别;当 ,可以让三位邻居按多数决定。距离也可以成为投票权重。分类遇到平票或等距离时,需要明确规则;回归任务则可以取邻居目标的平均。
“近”取决于用什么尺子
取三个教学坐标,第一列以毫秒记录时间,第二列是一项评分:
| 坐标 | 类别 |
|---|---|
[1000, 1] | 0 |
[2000, 3] | 1 |
[4000, 1] | 0 |
现在查询 [1200, 3]。直接算距离时,第一列的差异远大于第二列,最近的是 [1000,1],预测类别 0。若把毫秒换成秒,再按两列的数值计算欧几里得距离,最近的反而是 [2,3],预测类别 1。
图 D-1:没有新增样本,改变的是度量方式。图中的预测变化不说明哪一种一定正确,而是提醒我们先定义什么叫“相似”。
本例事先指定第一列除以 1000,只用于展示单位影响。在真实任务中,可以根据业务定义距离,也可以用训练集估计标准化参数。若标准化参与选模,每个交叉验证折都应只在该折训练部分拟合,不能先用全部数据算好均值和方差。
较小的 容易追着局部噪声走,较大的 又可能抹平小区域。维度很高时,距离也未必仍能表达任务相关的相似性;保存大量训练样本还会增加查询成本。如何选择尺度、 和搜索结构,仍要通过开发数据与实际资源约束判断。可继续查阅 scikit-learn 近邻说明。
2. 朴素贝叶斯:把条件概率拼起来
回到第三章的贝叶斯公式:
左边是“看到特征以后,属于某类的概率”。右边先看这类原来有多常见,再看这一类出现当前特征有多合理。比较类别时,分母对每一类相同,可以暂时省略。
困难在于,完整的特征联合分布往往不容易估计。朴素贝叶斯作出一个强假设:给定类别后,各特征条件独立,于是用乘积近似联合项:
等号表示这个模型采用的假设,并不是声称真实数据一定如此。“给定类别后独立”也不同于“不考虑类别时独立”。
一个能手算的伯努利版本
设两个特征表示通知中是否出现“优惠”和“预约”,只取 0 或 1。标签 1 代表促销类,0 代表事务类。这里的八条记录全部是教学设定,标签也不承担实际邮件分类结论。
类别 1 有四条:[1,1]、[1,0]、[1,1]、[0,1]。类别 0 有四条:[0,0]、[0,1]、[1,0]、[0,0]。
在类别 1 中,每个词都出现三次;在类别 0 中,每个词都出现一次。为了避免某个未出现过的特征把整个概率乘成零,使用加一平滑。对类别 的第 个二值特征:
是该类中这个特征为 1 的次数, 是该类样本数。分母加 2,是因为分别给“出现”和“不出现”各加了一次。
于是,类别 1 的两个概率都是 2/3,类别 0 都是 1/3。两类先验各为 1/2;对 [1,1],两个未归一化分数为:
归一化后,类别 1 的概率是 。若查询变成 [1,0],第二个特征要用“不出现”的概率,结果两类各为 0.5。
实际程序通常把乘法改写成对数求和,避免许多小概率连乘后下溢。伯努利版本会使用缺席特征的信息;它与按词频计数的多项式朴素贝叶斯不是同一个模型,处理连续特征的高斯版本又有不同假设。参见 scikit-learn 朴素贝叶斯说明。
复制一遍特征,为什么模型更自信了
把两个特征原样复制,输入从 [1,1] 变成 [1,1,1,1]。没有任何新信息,但模型仍按四个条件独立的特征相乘,类别 1 的概率变成约 0.941176。
这个结果暴露了假设:重复特征在给定类别后显然也不独立。模型却把重复的信息当成了额外证据。分类结果可能仍然正确,概率的可信程度却是另一个问题。这也把我们带回第 44~46 章的校准讨论。
3. 支持向量机:给分类边界留一点余地
设二分类标签为 和 ,线性分数为:
分数的正负决定类别。只要能分对,边界往往有很多条;支持向量机(support vector machine,SVM)还关心边界离两侧样本有多远。
对一条样本, 为正表示方向分对了;除以 后,是带符号的几何距离。若数据线性可分,可以把尺度固定成所有样本满足 ,再让 尽量小。此时两条支持边界之间的距离是 。
下面的八个点左右各一类。线性 SVM 得到 、,决策边界是第一维等于 0;最近的两侧点分别在 −1 和 +1。
图 D-2:图中圈出的是这次拟合返回的支持向量。它们参与决定边界;并不是离中心最远的样本最重要。
真实数据常有重叠与异常点,不能要求全部分对。一个常见的软间隔目标是:
叫 hinge loss,常译作合页损失; 决定对间隔违约的惩罚强度。这里采用求和约定。 较大时更重视当前训练样本的违约代价,但并不保证泛化更好,也不保证所有样本都能分对。
当 ,样本虽然分类正确,仍然有合页损失,因为它离边界太近。这个细节说明 SVM 训练的并不只是分类对错计数。
一条直线不够时,核函数能做什么
第十二章的 XOR 四个点没法用一条直线分开。SVM 可以通过核函数表达更丰富的边界,例如径向基函数核(RBF):
控制相似性随距离衰减的速度。核方法让算法通过样本间的内积关系工作,不必总是显式写出变换后的全部特征。这里不展开优化推导;重点是“换一种边界表达”与“把同一条直线训练更久”并不相同。
脚本用固定的 、 拟合 XOR。线性核在四个训练点上准确率为 0.5,RBF 为 1。这是四个训练点上的拟合差异,没有独立测试集,不能解释为新数据上的准确率。 核参数仍需在开发数据上选择,特征尺度也仍然重要。
SVM 分数不是天然的概率;若使用额外概率校准,还要区分校准数据与最终评价。关于优化形式、核与实现差异,参见 scikit-learn SVM 文档和 Cortes、Vapnik 的 Support-Vector Networks(1995)。
4. 运行三个小实验
python src/examples/classic_methods.py
实际输出:
1-NN:原始量纲类别 0,指定尺度后类别 1
朴素贝叶斯的类别 1 概率: [0.8, 0.5]
复制全部特征后,[1,1] 的类别 1 概率:0.941176
朴素贝叶斯与库实现:一致
线性 SVM 权重: [1.0, 0.0]
线性 SVM 最小函数间隔:1.000000
XOR 四个训练点:线性 0.500,RBF 1.000
这些是人工算例和训练点拟合,不是独立测试成绩。
朴素贝叶斯由本书实现,并与库函数逐项核对;近邻与 SVM 使用 scikit-learn。我们没有把三种不同数据上的算例排成优劣榜单。要比较方法,需要给它们同一个任务、相同的划分与合理的调参预算,见模型评估。
展开完整代码
"""近邻、伯努利朴素贝叶斯和 SVM 的人工小例子;没有基准性能主张。"""
import numpy as np
from sklearn.naive_bayes import BernoulliNB
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC
def bernoulli_probabilities(x, labels, queries, alpha=1.0):
"""二分类教学实现:每个特征只能为 0/1,使用经验类先验。"""
x, labels, queries = np.asarray(x), np.asarray(labels), np.asarray(queries)
if alpha <= 0 or set(labels.tolist()) != {0, 1}:
raise ValueError("需要正平滑强度和两个类别")
if not np.isin(x, [0, 1]).all() or not np.isin(queries, [0, 1]).all():
raise ValueError("本例的伯努利特征必须是 0/1")
scores = []
for c in [0, 1]:
subset = x[labels == c]
p = (subset.sum(axis=0) + alpha) / (len(subset) + 2 * alpha)
log_score = (queries * np.log(p) + (1 - queries) * np.log1p(-p)).sum(axis=1)
scores.append(log_score + np.log(len(subset) / len(x)))
scores = np.stack(scores, axis=1)
scores -= scores.max(axis=1, keepdims=True)
probabilities = np.exp(scores)
return probabilities / probabilities.sum(axis=1, keepdims=True)
def experiment():
points = np.array([[1000., 1.], [2000., 3.], [4000., 1.]])
labels, query, scale = [0, 1, 0], np.array([[1200., 3.]]), np.array([1000., 1.])
raw = KNeighborsClassifier(n_neighbors=1).fit(points, labels)
scaled = KNeighborsClassifier(n_neighbors=1).fit(points / scale, labels)
nb_x = np.array([[0, 0], [0, 1], [1, 0], [0, 0],
[1, 1], [1, 0], [1, 1], [0, 1]])
nb_y = np.array([0] * 4 + [1] * 4)
nb_q = np.array([[1, 1], [1, 0]])
own = bernoulli_probabilities(nb_x, nb_y, nb_q)
library = BernoulliNB(alpha=1., binarize=None).fit(nb_x, nb_y).predict_proba(nb_q)
np.testing.assert_allclose(own, library)
duplicated = bernoulli_probabilities(np.tile(nb_x, (1, 2)), nb_y, np.tile(nb_q, (1, 2)))
svm_x = np.array([[-2., -1.], [-2., 1.], [-1., -.5], [-1., .5],
[1., -.5], [1., .5], [2., -1.], [2., 1.]])
svm_y = np.array([-1] * 4 + [1] * 4)
svm = SVC(C=1., kernel="linear", tol=1e-10).fit(svm_x, svm_y)
margins = svm_y * svm.decision_function(svm_x)
xor = np.array([[-1., -1.], [-1., 1.], [1., -1.], [1., 1.]])
xor_y = np.array([0, 1, 1, 0])
xor_linear = SVC(C=10., kernel="linear").fit(xor, xor_y)
xor_rbf = SVC(C=10., kernel="rbf", gamma=1.).fit(xor, xor_y)
return dict(points=points, query=query, scale=scale, labels=labels,
raw=int(raw.predict(query)[0]), scaled=int(scaled.predict(query / scale)[0]),
nb=own, duplicated=duplicated, svm=svm, svm_x=svm_x, svm_y=svm_y,
margins=margins, xor=xor, xor_y=xor_y, xor_linear=xor_linear, xor_rbf=xor_rbf)
def main():
r = experiment()
print(f'1-NN:原始量纲类别 {r["raw"]},指定尺度后类别 {r["scaled"]}')
print("朴素贝叶斯的类别 1 概率:", [round(v, 6) for v in r["nb"][:, 1].tolist()])
print(f'复制全部特征后,[1,1] 的类别 1 概率:{r["duplicated"][0, 1]:.6f}')
print("朴素贝叶斯与库实现:一致")
print("线性 SVM 权重:", np.round(r["svm"].coef_[0], 6).tolist())
print(f'线性 SVM 最小函数间隔:{min(r["margins"]):.6f}')
print(f'XOR 四个训练点:线性 {r["xor_linear"].score(r["xor"], r["xor_y"]):.3f},'
f'RBF {r["xor_rbf"].score(r["xor"], r["xor_y"]):.3f}')
print("这些是人工算例和训练点拟合,不是独立测试成绩。")
if __name__ == "__main__":
main()
练习
- 为什么只把一个特征从毫秒换成秒,就可能改变近邻分类?
- 把上面的近邻数从 1 改成 3,本例的多数类别是什么?这是否说明 3 更好?
- 在伯努利例子中,为什么
[1,0]不能只计算第一个特征? - 复制特征后的 0.941176,是不是比 0.8 多了独立证据?
- SVM 的样本满足 ,分类是否正确?合页损失是多少?
- XOR 的四点全部拟合正确后,还需要什么才能评价新样本?
答案与提示
- 欧几里得距离会平方并合并各维差值。只改变一维的数值尺度,等于改变它在距离中的权重。
- 类别 0 有两票,因此是 0。这只是三个点上的投票,不提供泛化优劣的证据。
- 不出现也是观测结果,应使用该类中第二个特征为 0 的概率。忽略它就改了模型。
- 没有。它是条件独立假设不成立时重复计数的结果。
- 分类正确,因为乘积为正;合页损失为 0.6。正确分类不等于已经满足间隔。
- 先说明任务如何生成新样本、标签规则与目标分布,再准备未用于选参数的评价数据。四个点之外的标签并没有被这张图唯一规定。
本页使用 NumPy 2.5.4、scikit-learn 1.9.1,CPU 运行;接口与来源核查日期 2026-10-20。所有坐标、二值记录和标签均为本书自编。