# 03 · SVM、KNN 与朴素贝叶斯 > AI生成|2026年10月26日|codebuddy **本篇目标**:一口气讲完三种代表不同哲学的模型——**SVM 追求最大间隔、KNN 信仰近邻相似、朴素贝叶斯信奉概率**。它们今天仍活跃在文本分类、小样本、推荐召回等场景里。 **导航**:[栏目首页](./README.md) | 上一篇:[02 · 决策树与集成学习](./02-决策树与集成学习.md) | 下一篇:[04 · 无监督学习:聚类与降维](./04-无监督学习-聚类与降维.md) --- ## 一、核心概念 同一个「画决策边界」的任务,三种模型给出了三种世界观: | 模型 | 哲学 | 边界长什么样 | 训练代价 | | --- | --- | --- | --- | | **SVM** | 找一条**间隔最大**的分界线 | 由少数「支持向量」决定 | 中等/重,依赖核函数 | | **KNN** | 物以类聚,**看邻居投票** | 锯齿状,由数据点直接决定 | **零训练**,预测成本高 | | **朴素贝叶斯** | 贝叶斯定理 + 特征条件独立 | 平滑的曲线(高斯)/ 线性(多项式) | 极轻,基本就是数数 | --- ## 二、模型原理 ### 2.1 支持向量机(SVM) #### 线性可分时的目标:最大化间隔 对线性边界 `wᵀx + b = 0`,样本到边界的几何间隔是 `yᵢ·(wᵀxᵢ+b) / ‖w‖`。把 w、b 整体缩放可以让最近点的函数间隔等于 1,此时几何间隔为 `1/‖w‖`。于是: ```text 最大化 2 / ‖w‖ 等价于 最小化 ½ ‖w‖² 约束条件: yᵢ(wᵀxᵢ + b) ≥ 1 (所有样本都被正确分到间隔之外) ``` 最终决定边界的**只有落在间隔边界上的那几个点**,它们就叫**支持向量**。这是 SVM 最优雅的地方:训练完后绝大多数样本可以从内存里删掉。 #### 现实数据不可分:软间隔 + Hinge Loss ```text min ½‖w‖² + C · Σ ξᵢ 约束: yᵢ(wᵀxᵢ + b) ≥ 1 − ξᵢ, ξᵢ ≥ 0 ``` 等价于最小化 Hinge 损失: ```text L = Σ max(0, 1 − yᵢ·f(xᵢ)) + λ‖w‖² ``` ξ(松弛变量)允许少量样本越界,`C` 控制对越界的惩罚:**C 越大越不容错(易过拟合),C 越小越容忍(边界更平滑)**。 #### 核技巧:在低维价格里办高维的事 样本映到高维后往往就线性可分了(经典例子:二维「月牙」换个坐标系就能一刀切开)。但显式算 Φ(x) 太贵——核函数直接在原始空间算出高维内积: ```text K(x, z) = Φ(x)ᵀΦ(z) ``` | 核 | 公式 | 适用 | | --- | --- | --- | | linear | `xᵀz` | 特征数远大于样本数(文本分类首选) | | RBF(高斯) | `exp(−γ‖x−z‖²)` | 默认首选,能拟合任意形状边界 | | poly | `(γ·xᵀz + r)^d` | 特征间有明显多项式关系时 | **γ 的直觉**(RBF):γ 大 → 单个样本影响范围小 → 边界崎岖 → **过拟合**;γ 小 → 影响范围大 → 边界平滑 → **欠拟合**。γ 与 C 基本就是 SVM 调参的全部。 **用还是不用**: - ✅ 万级样本以内、特征维度高、需要非线性边界、效果优先于速度。 - ❌ 十万级以上数据(训练复杂度 O(n²)~O(n³));需要概率输出时注意 `probability=True` 会做 Platt 校准并让训练更慢。 - 大数据替代方案:`LinearSVC`(线性、快)、`SGDClassifier(loss="hinge")`。 ### 2.2 K 近邻(KNN) **惰性学习(lazy learning)**:没有「训练」这一步,直接把数据存起来;预测时才做计算。 ```text 给定新样本 x: 1. 计算 x 到训练集中(候选)样本的距离 2. 取最近的 K 个邻居 3. 分类:多数投票(可加权);回归:取均值(可加权) ``` **关键要素**: | 要素 | 说明 | | --- | --- | | 距离度量 | 欧氏(`p=2`)、曼哈顿(`p=1`)、闵可夫斯基一般形式;高维稀疏向量常用余弦相似度 | | K 的取值 | **K 小** → 边界崎岖、高方差、对噪声敏感;**K 大** → 边界平滑、高偏差;分类时取**奇数**避免平票 | | 加权方式 | `weights="distance"`:近邻话语权更大,通常略优于等权投票 | | 加速结构 | `algorithm="kd_tree"` / `"ball_tree"`,默认 `auto` 自动选择 | | 适用场景 | 低维、分布均匀、需要即时加入新样本(如推荐的「相似用户」召回) | **最大的敌人:维度灾难**。维度升高后任意两点距离趋于相同,"最近邻"这个概念本身就失效了。所以 KNN 前通常要做**降维或特征选择**(见 04 篇的 PCA)。 ### 2.3 朴素贝叶斯 **贝叶斯定理**: ```text P(y | x) = P(x | y) · P(y) / P(x) ∝ P(y) · P(x | y) ``` **「朴素」在哪**:假设给定类别后**所有特征相互独立**: ```text P(x | y) = Πⱼ P(xⱼ | y) → ŷ = argmax_y [ log P(y) + Σⱼ log P(xⱼ | y) ] ``` 这个假设在现实中几乎不可能成立("免费"和"中奖"显然相关),但即便如此 NB 的**排序能力**依然很强——它给的概率不准,可挑出来的答案往往是对的。 **四种变体**: | 变体 | 假设分布 | 典型数据 | | --- | --- | --- | | `GaussianNB` | 连续特征服从正态分布 | 数值型小数据(如鸢尾花、体征数据) | | `MultinomialNB` | 特征是出现次数 | **文本分类(词频 / TF-IDF)首选** | | `BernoulliNB` | 特征是 0/1 是否出现 | 二值特征、短文本 | | `ComplementNB` | 多项式 NB 的改进版 | 类别不平衡的文本分类 | **两个必须知道的细节**: 1. **取对数**:多个小于 1 的概率连乘会数值下溢,所以实现里都改成对数相加:`log P(y) + Σⱼ log P(xⱼ|y)`。 2. **平滑 alpha**:某个词在训练集的某类中一次没出现,会让整个乘积变 0。拉普拉斯平滑给每个计数加上一个小常数 `alpha`(默认 1.0),避免零概率。 --- ## 三、实例 ### 3.1 SVM:手写数字识别 + 核函数对比 + 网格搜索 ```python import numpy as np from sklearn.datasets import load_digits from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedKFold from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC, LinearSVC from sklearn.metrics import classification_report, confusion_matrix X, y = load_digits(return_X_y=True) # 1797 张 8x8 手写数字,共 10 类 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # 1) 三种核的横向对比(注意:对 SVM 来说 StandardScaler 是必须的) for name, clf in [("LinearSVC", LinearSVC(C=1.0, max_iter=20000)), ("SVC-rbf", SVC(kernel="rbf", C=10, gamma="scale")), ("SVC-poly3", SVC(kernel="poly", degree=3, C=1, gamma="scale"))]: pipe = Pipeline([("scaler", StandardScaler()), ("clf", clf)]) pipe.fit(X_train, y_train) print("{:10s} test accuracy = {:.4f}".format(name, pipe.score(X_test, y_test))) # 2) 对 RBF 核做 C / gamma 的对数网格搜索 pipe = Pipeline([("scaler", StandardScaler()), ("clf", SVC(kernel="rbf"))]) space = {"clf__C": [0.1, 1, 10, 100], "clf__gamma": ["scale", 0.001, 0.01, 0.1]} grid = GridSearchCV(pipe, space, cv=cv, scoring="accuracy", n_jobs=-1) grid.fit(X_train, y_train) print("\n最优:", grid.best_params_, " CV =", round(grid.best_score_, 4)) print("测试集 accuracy = {:.4f}".format(grid.score(X_test, y_test))) # 3) 看看模型最终依赖了多少个支持向量 best_svc = grid.best_estimator_.named_steps["clf"] print("支持向量数 = {}, 占训练集 {:.1%}".format( best_svc.n_sv_.sum(), best_svc.n_sv_.sum() / len(X_train))) print(classification_report(y_test, grid.predict(X_test))) print(confusion_matrix(y_test, grid.predict(X_test))) ``` **怎么读输出**: - RBF 核在这个数据上通常优于线性核(笔画的判别是非线性的),poly 核常常更差且更慢。 - `n_sv_` 告诉你模型实际"记住"了多少样本。如果它接近训练集大小,说明拟合出的边界非常复杂,泛化风险高。 - 混淆矩阵里 "8 vs 9"、"3 vs 5" 最容易互错,这是数字本身的歧义,不算模型的锅。 ### 3.2 KNN:挑 K、加权与缩放的影响 ```python import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import load_wine from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.neighbors import KNeighborsClassifier X, y = load_wine(return_X_y=True) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) ks = list(range(1, 26, 2)) rows = {} for weights in ["uniform", "distance"]: for scaled in [False, True]: scores = [] for k in ks: steps = [("clf", KNeighborsClassifier(n_neighbors=k, weights=weights))] if scaled: steps.insert(0, ("scaler", StandardScaler())) pipe = Pipeline(steps) scores.append(cross_val_score(pipe, X_train, y_train, cv=cv).mean()) rows[(weights, scaled)] = scores for key, scores in rows.items(): plt.plot(ks, scores, "o-", label="weights={}, scaled={}".format(*key)) plt.xlabel("K"); plt.ylabel("CV accuracy"); plt.legend(); plt.show() best_key = max(rows, key=lambda kk: max(rows[kk])) print("最佳配置 {},CV = {:.4f}".format(best_key, max(rows[best_key]))) ``` **怎么读输出**: - **不做缩放的曲线明显更低**——葡萄酒数据集里镁、脯氨酸这类特征的数值量级远大于酒精浓度,欧氏距离会被它们独占。这是 KNN 最常见的翻车原因。 - `weights="distance"` 通常略好且对 K 更鲁棒:K 稍大时它能把远端邻居的影响压下去。 - K 增大后曲线缓慢下降并趋于平稳,说明边界被抹平了(高偏差)。 ### 3.3 朴素贝叶斯:文本分类 先在本地合成一份「垃圾消息 / 正常消息」语料(无需联网下载),再用 `TfidfVectorizer` + `MultinomialNB` 建模。 ```python import numpy as np from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold from sklearn.pipeline import Pipeline from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import classification_report rng = np.random.default_rng(42) spam_words = ["free", "win", "prize", "cash", "urgent", "click", "limited", "offer", "money", "bonus", "instant", "guaranteed"] ham_words = ["meeting", "report", "project", "team", "lunch", "document", "review", "plan", "tomorrow", "notes", "schedule", "design"] def sample_docs(vocab, n, n_words=8): return [" ".join(rng.choice(vocab, size=n_words)) for _ in range(n)] texts = sample_docs(spam_words, 300) + sample_docs(ham_words, 300) labels = np.array([1] * 300 + [0] * 300) # 1 = spam X_train, X_test, y_train, y_test = train_test_split( texts, labels, test_size=0.2, stratify=labels, random_state=42 ) pipe = Pipeline([("tfidf", TfidfVectorizer()), ("nb", MultinomialNB(alpha=1.0))]) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) scores = cross_val_score(pipe, X_train, y_train, cv=cv, scoring="f1") print("CV f1 = {:.4f} +/- {:.4f}".format(scores.mean(), scores.std())) pipe.fit(X_train, y_train) print(classification_report(y_test, pipe.predict(X_test), target_names=["ham", "spam"])) # 模型眼里最能代表每个类的词 vocab = np.array(pipe.named_steps["tfidf"].get_feature_names_out()) log_prob = pipe.named_steps["nb"].feature_log_prob_ for cls, name in [(0, "ham"), (1, "spam")]: top = np.argsort(log_prob[cls])[::-1][:6] print("{:5s} 代表词: {}".format(name, ", ".join(vocab[top]))) # 新样本预测 for msg in ["win huge cash prize urgent", "lets have a meeting tomorrow about the design plan"]: print(msg, "->", "spam" if pipe.predict([msg])[0] == 1 else "ham") ``` **怎么读输出**: - 这类合成数据上 f1 通常接近 1.0,因为两类词表完全不重叠——真实数据会有歧义,那时 alpha 平滑就有了用武之地。 - `feature_log_prob_` 排出来的"代表词"体现了 NB 的核心:它数的是每个类里**哪些词出现的相对频率最高**,预测时用对数相加给文档打分。 --- ## 四、常见坑与调参要点 | 模型 | 坑 | 解决 | | --- | --- | --- | | SVM | 忘记缩放 → 效果极差 | Pipeline 里加 `StandardScaler`(或 `MinMaxScaler`) | | SVM | gamma 与 C 盲目搜索 | 用对数网格:`C = [0.1, 1, 10, 100]`,`gamma = ["scale", 1e-3, 1e-2, 0.1]` | | SVM | 十万样本上卡死 | 换 `LinearSVC` / `SGDClassifier(loss="hinge")`;保留非线性可用 `Nystroem` 近似 | | SVM | 类别不平衡 | `class_weight="balanced"` | | KNN | 特征量纲差异大 | 必须缩放;或改余弦距离(已是单位向量时) | | KNN | 维度太高距离失效 | 先 PCA / 特征选择;大规模用近似检索库(faiss、hnswlib) | | KNN | 预测太慢 | `algorithm="kd_tree"`;或先用少量候选做召回再精排 | | NB | 连续特征不服从正态 | 分箱后用 `MultinomialNB`,或 `KBinsDiscretizer` + `BernoulliNB` | | NB | 概率输出不可信(接近 0 或 1) | 需要真实概率时用 `CalibratedClassifierCV` 校准 | | NB | 未登录词 | `TfidfVectorizer` 默认忽略新词;可用 `char_wb` 的 n-gram 缓解 | **怎么选(速查)**: ```text 样本少(<1000)、特征少、要快 → GaussianNB / LogisticRegression 小中型数据、需要非线性边界、效果优先 → SVM(RBF) + 网格搜索 文本分类基线 / 大规模高维稀疏 → LinearSVC 或 ComplementNB 低维相似检索、冷启动推荐、异常点检测 → KNN 万级以上表格数据 → 回到 02 篇:随机森林 / GBDT ``` --- ## 五、小结 - **SVM**:核心目标是最大化间隔,只有支持向量说了算;靠核技巧在低维空间办高维的事;`C` 与 `gamma` 是它的两个旋钮;必须缩放,大数据要懂得退让。 - **KNN**:没有训练只有推理,K 是偏差-方差的直接旋钮;缩放和维度是它的命门。 - **朴素贝叶斯**:看似离谱的条件独立假设,却在文本分类上又快又稳;对数化与平滑是它实现的两个关键。 - 三者的共同点是**在各自假设成立的范围内极其高效**;一旦假设不成立(非线性且样本大、维度灾难、特征强相关),就该换到 02 篇的模型。 下一篇处理没有标签的世界:[04 · 无监督学习:聚类与降维](./04-无监督学习-聚类与降维.md)。