说到感知机算法原理,我见过很多开发者的第一反应是:这算法不是已经被深度学习淘汰了吗?实际情况恰恰相反。感知机虽然简单到只有一行决策规则,但它几乎是理解一切神经网络的基础原型。我第一次认真手写感知机代码时,最大的收获不是复现了那个经典公式,而是彻底弄清楚了梯度下降、损失函数、决策边界这几件事在真实数据上的运作方式。这篇文章就用感知机作为主线,给出手写 NumPy、scikit-learn、PyTorch 三种 Python 实现方式,并把决策边界、误差曲线、收敛动画全部可视化,放在一起对比。适合两类人:想踏实弄懂感知机原理的初学者,以及会用库但想搞明白底层训练细节的开发者。
1. 感知机的适用边界:线性可分与超平面的几何直觉
1.1 线性可分:感知机的地盘
感知机要处理的任务是二分类,但它能学出的模型形状非常有限:一条直线(二维)、一个平面(三维)、一个超平面(高维)。所以它成立的前提是数据本身能被这种平面形状分开,这个性质叫线性可分。
可以用二维情况感受一下。假如两类点像咖啡和茶一样能倒进两个不同的杯子里,中间留一条清楚的间隙,那它们就是线性可分的;如果两类点互相交错得像调好的混凝土,那无论怎么画直线都会有误分。还要注意,线性可分不是“肉眼能分辨”,而是“存在一个平面把所有点正确分开”。人类眼睛擅长找弧线、找椭圆,这些非线性的边界感知机统统学不出来。
经典的不可分例子是 XOR 问题:四个点分别位于 (0,0)、(1,1) 和 (0,1)、(1,0),前两个是一类,后两个是另一类。你在纸上画个正方形就知道,没有任何一条直线能同时把它们分开。感知机在这个任务上会陷入死循环,不断修正、不断出错。这也是历史上感知机备受质疑的导火索之一,直到多层感知机用非线性激活函数才破解了这个魔咒。
这个概念为什么值得单独讲?因为实操中很多“训练不收敛”根本不是代码问题,而是数据根本线性不可分。你随机生成 200 个点,两个类别中心离得不够远,重叠区域一大片,感知机再怎么调参都不可能收敛。后面可视化部分我会专门说明怎么从图上快速判断这一点。
1.2 数学结构:权重、偏置与符号函数
感知机的决策规则用公式写出来只有一行:
f(x) = sign(w·x + b)
其中 x 是 n 维输入向量,w 是同样维度的权重向量,b 是标量偏置,sign 是符号函数:括号里结果大于等于 0 输出 +1,小于 0 输出 -1。训练要做的就是根据数据不断调整 w 和 b,直到所有样本输出正确。
从几何角度看,w·x + b = 0 就是一个超平面。w 是这个平面的法向量,它决定了平面的朝向;b 是偏置项,它决定平面离原点多远。一个样本 x 代入 w·x + b 后,如果结果为正,它就在法向量指向的那一侧,被分成 +1;结果为负则分到另一侧。整个感知机本质上就是拿一个“有方向的平面”把空间切成两块,然后判定每条数据站在哪边。
用生活类比来解释 b 的作用:w 像你衡量各种线索的信赖程度,比如“是否在下雨”“路况如何”“有没有约到人”各自乘以一个权重;b 则是你心里的总体门槛,比如“今天实在太累,综合分数再高也不想出门”。没有 b,你就只能在所有输入都为零时输出“不出门”——这显然限制了表达能力。
这个公式里还有一个容易被忽略的点:决策边界由 w·x + b = 0 决定,如果对 w 和 b 同时乘以某个正数,决策边界完全不变。这意味着同一个数据集可以被无数个不同参数的感知机正确分类。后面三种实现得到不同结果,绝不是谁写错了,这正是感知机本身的特性。
2. 训练算法推导:误分类点损失、梯度与更新规则
2.1 为什么损失函数只盯误分类点
有监督学习的基本套路是先定义一个损失函数,再用梯度下降去最小化它。感知机也不例外,只不过它的损失函数设计得非常“朴素”。
我们希望的理想状态是:对所有训练样本都满足 y_i(w·x_i + b) > 0。如果某个样本不满足,也就是 y_i(w·x_i + b) ≤ 0,那它就是一个误分类点。注意这里 y_i 取 +1 和 -1,而不是常见的 0 和 1,这是感知机推导特意选的形式:用 y_i(w·x_i + b) 的符号统一表达“对错”,不用分情况讨论。
误分类点到超平面的距离公式是 |w·x_i + b| / ||w||。误分类的时候,这个点一定在错误一侧,所以分子取绝对值后等于 -y_i(w·x_i + b)。如果把所有误分类点的几何距离加起来并求最小值,理论上很优雅——但分母 ||w|| 是个随 w 变化的量,会让优化复杂化。感知机选择的损失函数直接把分母扔掉:
L(w, b) = -∑_{i∈M} y_i(w·x_i + b)
其中 M 是误分类点集合。为什么可以这么做?一个关键直觉是:固定 w 的“方向”时,分子正比于点到平面的距离;而感知机找的并不是那个唯一最优平面,而是任意一个能把数据分开的平面。既然不追求唯一最优解,舍弃分母带来的尺度影响就变得可以接受。
这个损失不是对所有样本求和,而是只对误分类点求和。已经分类正确的点不产生任何损失,也不会驱动更新。这和逻辑回归那种“所有样本都贡献梯度的平滑损失”有本质区别——感知机的损失函数是分段的,只有踩到错误区域的样本才会“咬人”。所以它的训练过程天然带着一种“哪里有错改哪里”的气质。
2.2 更新公式的梯度推导与直观解释
对单个误分类点 (x_i, y_i),这一项的损失是 L_i = -y_i(w·x_i + b)。分别对 w 和 b 求偏导:
∂L_i/∂w = -y_i x_i
∂L_i/∂b = -y_i
梯度下降更新规则是参数 = 参数 - 学习率 × 梯度,代入后得到感知机最著名的更新公式:
w ← w + η y_i x_i
b ← b + η y_i
这个公式看起来简单,背后其实有两层意思。第一,被误分的正样本(y_i=+1)会让 w 朝着 x_i 的方向加一点,相当于把超平面往“应该拉住它”的方向推;被误分的负样本(y_i=-1)则让 w 朝 x_i 的反方向推。第二,因为感知机采用随机梯度下降,它每看到一个误分类点就立即更新一次,而不是把所有误分类点的梯度都算完再统一更新。这样做的好处是单样本迭代极快、实现极简;代价是每次更新都是“头疼医头”,轨迹震荡明显。
很多教材说感知机在线性可分数据下保证收敛,这是 Novikoff 定理的结论。但这个定理只保证“有限步内会收敛”,并没有承诺多少步。实际收敛速度受初始权重、学习率和样本遍历顺序影响很大。如果你用随机顺序训练,同一份数据可能 3 轮就收敛,也可能 30 轮才收敛,这都不算异常。
为了让直觉更具体,我拿一个小例子演算。假设当前 w=(0,0)、b=0,样本是 x=(2,3)、y=-1。代入得到 y(w·x+b)=0,按“≤0 视为误分类”的约定,它也需要修正。更新后得到 w = 0 + η×(-1)×(2,3) = (-2η, -3η),b = -η。现在这个点在新的超平面下会更接近被正确分类的位置。每更新一次,平面就“歪”一下,直到所有点都站在正确一侧。
2.3 学习率和特征尺度:两个最容易失控的变量
学习率 η 在感知机里不像在深度学习中那么敏感,因为只要数据线性可分,任何正值学习率都能最终收敛(只要不触发数值溢出)。但收敛过程的体验差别很大。η 太大时,每次修正的幅度也大,超平面可能在正确位置附近来回跨越,你会看到误差曲线剧烈震荡;η 太小时,每次只挪一点点,收敛轮数显著上升。一般从 0.1 到 1.0 开始试都是比较稳妥的选择。
比学习率更容易翻车的其实是特征尺度。假如 x1 的取值范围是 [0, 1],x2 的取值范围是 [0, 10000],那么更新公式里 x2 方向的更新量天然远大于 x1 方向,权重 w2 的变化幅度会比 w1 大几个数量级。这会让决策边界在空间里被某一方向牵着走,训练过程极度别扭。解决的办法是提前把每个特征标准化到均值为 0、方差为 1。
实操顺序一定是:先标准化,再训练,最后再讨论学习率。很多人在感知机上白费力气调学习率,结果真正的问题只是特征没有缩放。这一点在后面的可视化对比环节会看得非常明显。
3. 三种 Python 实现方式对比:手写、现成库与框架版
3.1 手写 NumPy:算法公式的直接翻译
先统一准备一份线性可分的数据集,后面三个实现都会在这份数据上跑:
from sklearn.datasets import make_classification import numpy as np X, y = make_classification( n_samples=200, n_features=2, n_redundant=0, n_clusters_per_class=1, class_sep=2.0, random_state=42) y = np.where(y == 0, -1, 1) # 转成 +1 / -1 标签make_classification 生成的类别默认是两个团簇,class_sep=2.0 时两个簇离得比较开,线性可分基本没问题。y 转成 -1/1 是为了让后面的公式和代码对应上。
手写版本的完整实现如下:
import numpy as np class Perceptron: def __init__(self, learning_rate=1.0, n_iter=100, random_state=None): self.learning_rate = learning_rate self.n_iter = n_iter self.random_state = random_state self.w_ = None self.b_ = None self.errors_ = [] # 每轮迭代的错误样本数 def fit(self, X, y): rng = np.random.default_rng(self.random_state) n_samples, n_features = X.shape self.w_ = np.zeros(n_features) self.b_ = 0.0 for _ in range(self.n_iter): errors = 0 indices = np.arange(n_samples) if self.random_state is not None: rng.shuffle(indices) for i in indices: xi = X[i] yi = y[i] if yi * (np.dot(xi, self.w_) + self.b_) <= 0.0: self.w_ += self.learning_rate * yi * xi self.b_ += self.learning_rate * yi errors += 1 self.errors_.append(errors) if errors == 0: break return self def predict(self, X): return np.where(np.dot(X, self.w_) + self.b_ >= 0.0, 1, -1)这段代码几乎是算法公式的逐行翻译。fit 开头把 w 初始化为零向量、b 初始化为 0,然后进入多轮迭代。每轮里按顺序遍历所有样本,用yi * (w·xi + b) <= 0判断当前点是否误分类。注意这个条件用的是“小于等于零”,因为约定边界上的点也算分类不明确,需要修正。
一旦发现误分类点,就执行w += η·yi·xi和b += η·yi,这就是上一节更新公式的代码形态。每轮遍历结束后记录本轮累计错误数,如果错误数降为 0 就提前终止,不再空转后续轮次。predict 里把w·x + b的符号映射回 +1 / -1,按“大于等于 0 判为正类”的实现细节与训练时的判断条件保持一致。
一个值得注意的细节是随机顺序。原版感知机教材写作顺序遍历样本,但实际数据往往带某种内在排序——比如正样本全在前面、负样本全在后面。如果顺序固定,早期权重更新会被前几个样本主导。所以我在代码里保留了 random_state 参数,真正训练时强烈建议打开,能显著改善收敛稳定性。
3.2 scikit-learn:封装版本背后的隐藏逻辑
如果你只是要在生产环境快速跑一个感知机,用 scikit-learn 肯定是最高效的:
from sklearn.linear_model import Perceptron as SkPerceptron from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_scaled = scaler.fit_transform(X) clf = SkPerceptron(max_iter=1000, eta0=1.0, tol=1e-3, random_state=42) clf.fit(X_scaled, y)但不要以为这就是“一行代码的事”。scikit-learn 的 Perceptron 类基于其 SGD 分类器的底层实现,和教科书里的原始算法存在几个值得知道的差异。第一,它的学习率并不是简单的固定值,而是带有衰减策略;第二,训练流程里会引入样本洗牌、收敛判定与提前停止等机制,具体行为可以通过参数控制;第三,它的初始化方式也封装在内部,不完全等同于零向量初始化。
几个重要参数需要按场景调整。max_iter 控制最大迭代轮数,别设太小;eta0 是初始学习率,配合内部衰减策略使用;tol 决定收敛阈值,当损失下降小于这个值时停止训练;random_state 固定随机种子,让实验可复现。特征标准化这一步千万别省,scikit-learn 不会替你缩放数据,很多初次上手的人直接拿原始特征训练,然后抱怨“为什么不收敛”。
这些隐藏逻辑的存在并不意味着库实现更好或更差,而是提醒你:同一份数据、同一个模型名,不同实现得到的参数可能完全不同。后面对比结果时你会发现,三种实现各自收敛到了不同的权重,但都能把数据集正确分类。
3.3 PyTorch:用现代自动求导复现感知机
有人可能觉得用 PyTorch 写感知机是“杀鸡用牛刀”,但这件事对理解深度学习很有价值。感知机的原始更新公式依赖符号函数,而符号函数在 0 点不可导,直接套用自动求导框架会断路。所以现代框架实现通常有两条路线。
第一条路线是严格复现原始感知机,直接手动修改权重:
import torch import torch.nn as nn class PerceptronTorch(nn.Module): def __init__(self, n_features): super().__init__() self.linear = nn.Linear(n_features, 1, bias=True) def forward(self, x): return self.linear(x) model = PerceptronTorch(X.shape[1]) learning_rate = 1.0 X_torch = torch.tensor(X, dtype=torch.float32) y_torch = torch.tensor(y, dtype=torch.float32) for epoch in range(100): errors = 0 for xi, yi in zip(X_torch, y_torch): score = model(xi).item() if yi.item() * score <= 0: with torch.no_grad(): model.linear.weight.add_(learning_rate * yi * xi) model.linear.bias.add_(learning_rate * yi) errors += 1 if errors == 0: break这段代码和手写 NumPy 版在数学上完全一致,只不过用 nn.Linear 代替了手动权重数组。为什么值得这么绕一圈?因为你可以借此看到:一个 nn.Module 的 forward 内部本质上就是在做 w·x + b,而“训练”只是不断调整这些参数的过程。
第二条路线是真正发挥框架价值的做法:把不可导的符号函数问题换成合页损失(hinge loss)。合页损失定义为 max(0, 1 - y_i·score),它只在样本分错或分得太勉强时才产生损失,这个思路和感知机非常接近,但又处处可导,能让自动求导引擎正常工作:
import torch import torch.nn as nn import torch.optim as optim model = PerceptronTorch(X.shape[1]) optimizer = optim.SGD(model.parameters(), lr=0.1) for epoch in range(200): total_loss = 0.0 for xi, yi in zip(X_torch, y_torch): optimizer.zero_grad() score = model(xi).squeeze() loss = torch.clamp(1.0 - yi * score, min=0.0) loss.backward() optimizer.step() total_loss += loss.item() if total_loss == 0.0: break严格来说,第二条路线训练的已经不是一个经典感知机,而是“用梯度下降优化的线性分类器”。但它的训练结果通常和感知机高度接近,更重要的是整个流程(定义模型、定义损失、反向传播、优化器 stepping)直接复用了后续所有深度学习模型的训练范式。如果你准备从感知机走向多层神经网络,这套流程就是你的逃生梯。
3.4 三种实现方式差在哪:结果对照与选型建议
下面用一个表格把三种实现的差异列出来:
| 实现方式 | 与经典感知机的一致性 | 依赖库 | 学习率策略 | 主要价值 |
|---|---|---|---|---|
| 手写 NumPy | 高 | NumPy | 固定学习率 | 理解算法本质、便于教学与调试 |
| scikit-learn | 中高 | scikit-learn | 内置衰减与提前停止 | 快速实验、工程集成 |
| PyTorch 手动更新 | 高 | PyTorch | 固定学习率 | 建立框架与经典算法的映射 |
| PyTorch 合页损失 | 中(近似) | PyTorch | 优化器控制 | 衔接深度学习训练范式 |
怎么选?如果目标是“我必须彻底搞懂感知机”,手写 NumPy 版是必须做的功课,别跳过;如果目标是在项目里快速加一个基线分类器,scikit-learn 版是性价比最高的选择;如果你的重点其实是学习现代深度学习框架,或者后续计划扩到多层感知机,直接练 PyTorch 版的第二条路线,它能让你少走一大段弯路。
补充一句我自己反复验证过的经验:手写版本的收敛轮次通常比库版本多,但这个差异不代表实现优劣,而是因为固定学习率没有衰减策略。把学习率从 0.1 提到 1.0 往往就能明显缩短收敛时间。
4. 结果可视化:从静态边界到动态轨迹
4.1 用网格法画出决策边界
感知机学出来的模型可以主观感受,但很难直接“看见”。可视化决策边界最通用的方法是网格法:在数据覆盖的二维区域内生成密密麻麻的点,让模型逐个预测,把预测结果用等值线颜色填充出来,然后叠加原始样本的散点图。核心代码如下:
import matplotlib.pyplot as plt import numpy as np def plot_decision_boundary(model, X, y, ax=None): if ax is None: ax = plt.gca() x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1 y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1 xx, yy = np.meshgrid( np.linspace(x_min, x_max, 300), np.linspace(y_min, y_max, 300)) Z = model.predict(np.c_[xx.ravel(), yy.ravel()]) Z = Z.reshape(xx.shape) ax.contourf(xx, yy, Z, levels=[-1, 0, 1], alpha=0.3, cmap='coolwarm') ax.scatter(X[:, 0], X[:, 1], c=y, cmap='coolwarm', edgecolor='k') ax.set_xlim(x_min, x_max) ax.set_ylim(y_min, y_max)meshgrid 生成横纵坐标的两套网格,ravel 把它们展平成若干个二维坐标点,predict 批量预测后 reshape 回网格形状,最终 contourf 用颜色填充。levels=[-1, 0, 1] 是刻意为之:感知机预测结果只有 -1 和 +1 两个离散值,这样的设置会把“中间地带”的色块压缩掉,只显示两个颜色的分界线。
如果只是想画二维的线性边界,还有更省事的办法:直接从 w 和 b 反解直线方程。二维超平面 w1x + w2y + b = 0 显然是直线,取两个不同的 x 算出对应的 y 连起来即可。但网格法的优势在于通用:它不关心分类器是线性还是非线性,任何带 predict 方法的模型都能套用,所以这也是以后画神经网络决策边界时的标准姿势。
把三个实现放一起对比,你会看到很典型的画面:三条决策边界位置不同、斜率不同,但都恰好把两类样本分开了。这正是感知机解不唯一的直观表现。谁都不算“正确答案”,因为满足分类条件的超平面有一整个家族。
4.2 误差曲线:经典感知机的“震荡后归零”
训练时我记录了每一轮迭代的错误样本数,把它画出来是观察训练过程最直接的方式。正规的误差曲线长下面这样:
第一轮通常错误样本很多,比如 60 个;第二轮可能降到 12 个,然后第三轮又回升到 15 个,第四轮继续震荡,最后在某一个 epoch 突然归零。
很多人第一次看到误差上升会本能地以为模型崩溃了。其实这是随机梯度下降的正常形态。感知机每遇到一个错点就立即更新权重,这轮修正了点 A,可能顺手把点 B 从正确变成错误;点 B 在后续轮次被修正,又可能把点 C 带歪。每个 epoch 结束时的错误数是一个动态快照,它不需要单调递减。对经典感知机来说,只要最终归零,前面的震荡过程无关紧要。
这一点和深度学习中经常看到的平滑下降曲线不同,原因在于损失函数形态。感知机损失是分段线性函数,不可导的位置到处都是;而现代深度模型通常用平滑的交叉熵或均方误差,优化轨迹要温和得多。感知机的误差曲线更像“锯齿波”,而不是“下降斜坡”。
4.3 动态更新动画:把收敛过程做成动画
静态边界图只能看到结果,动态动画则能看到“超平面是怎么一步步被掰弯的”。实现思路不复杂:训练时除了记录每轮错误数,还要记录每次权重更新后的 w 和 b,然后在动画的每一帧画出当时的决策直线。
import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation # 训练前准备 history_w, history_b 两个列表,训练时每次更新后追加 history_w = [] history_b = [] # 训练循环中,每执行一次 w_ += ..., b_ += ... 之后加入: # history_w.append(w_.copy()); history_b.append(b_) fig, ax = plt.subplots() ax.scatter(X[:, 0], X[:, 1], c=y, cmap='coolwarm', edgecolor='k') line, = ax.plot([], [], 'k-', lw=2) def init(): ax.set_xlim(X[:, 0].min() - 1, X[:, 0].max() + 1) ax.set_ylim(X[:, 1].min() - 1, X[:, 1].max() + 1) return line, def update(frame): w = history_w[frame] b = history_b[frame] if w[1] == 0: # 边界垂直的情况,单独处理 x_vals = np.full(2, -b / w[0]) y_vals = np.array([ax.get_ylim()[0], ax.get_ylim()[1]]) else: x_vals = np.array([ax.get_xlim()[0], ax.get_xlim()[1]]) y_vals = -(w[0] * x_vals + b) / w[1] line.set_data(x_vals, y_vals) return line, ani = FuncAnimation(fig, update, frames=len(history_w), init_func=init, blit=True) ani.save('perceptron_boundary.gif', writer='pillow')这段代码里的一个隐藏细节值得注意:决策直线公式 y = -(w[0] * x + b) / w[1] 是从 w1x + w2y + b = 0 解出来的,所以当 w[1] 恰好等于零时,直线是竖直的,必须单独处理,否则会除零报错。真实训练中这种概率不高,但当边界刚好穿过权重空间的特殊位置时确实会出现。
动画播放时你会看到一条直线从初始位置开始不断旋转、平移,偶尔大幅跳跃,最终稳稳停在两类样本的中间。那个“大幅跳跃”通常发生在某个极端误分类点附近,视角上非常直观。
4.4 特征尺度差异:一个可视化就能看懂的坑
最后这个可视化非常值得做。手动构造一份特征尺度差距大的数据,比如把第二个特征放大一万倍:
X_scale = X.copy() X_scale[:, 1] = X_scale[:, 1] * 10000分别用原始特征和标准化特征训练同一个手写感知机,然后对比两个子图。原始特征版本可能在设定的最大迭代轮数内都收敛不了,误差曲线一路高烧不退;标准化版本几轮就归零,边界干净漂亮。原因在前面说过:更新幅度被大尺度特征主导,相当于在山谷里来回奔波,但前进方向极其别扭。
所以规范化流程要刻进肌肉记忆:先 StandardScaler,再 fit,再 predict。哪怕只是做一份玩具数据集,训练前把特征分布的尺度差异拉平均,能省下至少一多半调试时间。这无关感知机本身,所有基于梯度下降的模型都吃这一套,越早养成习惯越受益。
5. 高频问题排查:不收敛、结果不一致从哪里找原因
5.1 三个最容易忽略的原因
我在帮别人排查感知机代码时,超过九成的问题出在下面三个地方。
第一个:数据本身线性不可分。随机生成、真实业务数据,很多都做不到两类完美分离。这时候感知机在训练中会不断产生误分类点,永远无法到达“错误数为 0”的终止条件。排查方法很简单:先画散点图,肉眼看两类是否明显可分;或者把迭代轮数上限设成大值,观察 errors_ 是否始终不归零。
第二个:特征没有做标准化。某些特征数值范围大、某些数值范围小,权重更新会被大尺度特征带跑偏。最典型的表现是误差曲线剧烈震荡、收敛极慢。解决办法就是训练前套一层 StandardScaler。
第三个:标签写成了 0 和 1。感知机的更新公式依赖 y_i 等于 +1 或 -1,如果标签是 0/1,代入公式后类别含义会错乱。很多代码看着没问题、训练却完全乱套,仔细一看是标签转错了。一个简单的自查方法是打印数据集里 y 的唯一取值。
学习率过大导致的发散相对少见,因为线性可分时理论上任何正学习率都能收敛,但学习率大到引发浮点溢出则是另一回事。遇到诡异行为时,先按顺序查:数据可分性、标签取值、特征缩放、学习率量级。
5.2 为什么三种实现结果总是不完全一样
用同一个数据集跑三种实现,得到的 w、b 几乎必然不同。这不代表哪个实现有 bug,而是感知机收敛结果不唯一的属性决定的。只要数据线性可分,满足条件的超平面有无数个,感知机的训练算法只负责找到其中一个,具体找到哪个取决于初始权重、样本遍历顺序、学习率策略。
手写版和 scikit-learn 版差异尤其常见。手写版很可能用零向量初始化,scikit-learn 则有其内部的初始化方式;手写版按固定顺序遍历,库版可能在每轮迭代之间洗牌;手写版固定学习率,库版的学习率会按迭代逐步衰减。这些差异叠加在一起,最终收敛到不同超平面是正常的。
那怎么验证手写实现没错呢?正确做法不是对比权重值,而是对比两个指标:一是测试集上的准确率是否一致,二是是否都满足在训练集上零错误。只要两个条件都满足,代码基本没有问题。如果准确率出现明显差距,再去查数据是否被意外改动、标签是否一致、是不是用了完全不同的训练集。
5.3 从感知机到多层神经网络的下一步
搞完感知机,下一步通常就该上多层感知机了。破解 XOR 这类非线性可分问题的关键,是把单层判断改成“多个感知机叠加 + 非线性激活”,即两层结构。第一层把原始输入映射到隐藏空间,第二层在隐藏空间里做最终的线性分类。只要隐藏层神经元够多,理论上就能逼近任何连续分类边界。
迁移到 PyTorch 实现非常顺滑:把之前的 PerceptronTorch 加一个隐藏层,激活函数换成 ReLU 或 tanh,合页损失可以换成交叉熵损失,优化器继续用 SGD。训练逻辑几乎不变,损失函数和梯度下降的流程都是现成的。如果你按可视化方法画出新模型的决策边界,会看到边界从直线变成了分段的折线,XOR 这四个点终于能被完美分开。
我自己的一点经验是:做这个迁移时,别一上来就用非常复杂的数据集。先用 XOR 这样的小数据验证模型结构有没有问题,再逐步加大数据规模。感知机阶段养成的“先可视化、再调参”的调试习惯,到了多层网络那里同样管用,甚至更重要。