很多人都以为感知机简单到不值得单独写一篇。公式就一行,代码几十行,跑起来也快。但我这些年给人讲机器学习,发现恰恰是这种“觉得简单”的心态,会让人在最基础的地方翻车:有人把标签写成0和1,训练完发现负类永远分不对;有人忽略了bias,结果决策边界整天偏移;还有人把线性不可分的数据扔进去,发现训练永远不结束。这篇笔记我编号为3a,就是想把感知机的训练过程整个拆开——先用手算,再用Python代码复现,最后把我会踩的坑都摊开。
这套内容适合谁看?刚开始学机器学习、正在啃教材的读者,可以顺着文章完整过一遍;已经写过感知机的朋友,可以直接跳到第4节和第5节,那里有一些可能没注意过的边界情况。无论你是哪种,我保证不绕弯子,每一步都能在纸上验算。
1. 为什么我会把感知机单独拆出来讲
1.1 感知机的数学定义与几何直觉
感知机做的事情,用一句话说就是:在特征空间里找一条直线(或者更一般地,一个超平面),把正类样本和负类样本分开。
模型长这样:
- 输入特征:x = (x1, x2, ..., xn)
- 权重:w = (w1, w2, ..., wn)
- 偏置:b
- 预测输出:y_pred = sign(w·x + b)
其中sign是符号函数,输入大于0输出+1,小于0输出-1。决定分类的临界线是:
w·x + b = 0
如果是二维特征,这就是一条直线;如果是三维特征,就是一个平面;更高维就统一叫超平面。感知机的几何直觉非常直白:它拿一根直尺去切平面,把两类点分开。直尺一边是正类,另一边是负类。
我经常用快递分拣来类比:正类包裹是“数码产品”,负类包裹是“普通书籍”,特征就是重量和体积。感知机要学的,就是一根关于“重量、体积”的直线,位于直线一侧算数码,另一侧算书籍。它不关心选的那根线究竟离两侧包裹有多远,只要能把已经见过的包裹分对就行。
1.2 训练规则其实只有一条:错分就纠正
感知机的训练规则看起来是真的简单,但简单不等于容易理解。完整规则就四个字:错分就纠正。
具体来说:
- 初始化w为全0向量,b为0。
- 依次遍历每个训练样本(x, y),其中y只取+1或-1。
- 计算净输入net = w·x + b。
- 判断是否错误:如果 y * net <= 0,说明预测方向和真实方向不一致,触发更新: w ← w + η * y * x b ← b + η * y
- 如果 y * net > 0,说明预测正确,什么也不做。
- 重复遍历整个数据集,直到某一轮完整扫描中没有任何一个样本触发更新,训练才停止。
这里有一个关键点:为什么要用 y * net 来判断?
因为感知机约定正类输出大于0,负类输出小于0。对正类样本(y=+1),我们希望net > 0;对负类样本(y=-1),我们希望net < 0。把y和net乘起来,如果大于0,说明实际值和预测方向一致;如果小于或等于0,说明方向相反,模型分错了。等于0的情况很微妙,它表示样本恰好落在决策边界上,模型对它没有任何判断置信度,所以约定也算“错误”,必须触发更新。
这个“错分就纠正”的规则,本质上是沿着让当前这个样本变正确的方向踩了一小步。正样本被误判时,就把权重往x的正方向拉;负样本被误判时,就把权重往x的反方向拉。每更新一次,都是为了让眼前这一个样本被重新正确分类。
2. 手算拆解:四个样本、两轮迭代、三次更新
光讲规则不够,下面找一个可以完全笔算的数据集,一步一步算给你看。我特意选了一个小体量、数值简单、又能展示关键细节的例子。
2.1 数据集与超参数设置
| 样本 | 特征x1 | 特征x2 | 标签y |
|---|---|---|---|
| P1 | 1 | 2 | +1 |
| P2 | 2 | 1 | +1 |
| N1 | 0 | 0 | -1 |
| N2 | -1 | -1 | -1 |
为什么选这个数据集?有三个考虑:
- 四个点明显线性可分,正例都在右上方,负例在左下方,用一条对角线就能分开;
- N1恰好是原点(0, 0),这个点的特征为零向量,更新时w不会变化,能让我们单独观察到bias的移动;
- 训练过程只需要两轮迭代,每次计算都能在纸上验算,不会把人绕晕。
超参数选学习率η = 1,初始w = (0, 0),b = 0。学习率取1看似随意,其实是感知机教材里最常见的默认值,因为感知机的收敛性与学习率具体大小无关,只要η > 0就能收敛,后面第4节我会专门展开讨论。
2.2 第1轮:从全零权重开始的第一次修正
初始参数:
- w1 = 0,w2 = 0,b = 0
按P1、P2、N1、N2的顺序扫描。
先看样本P1 = (1, 2),标签y = +1。
计算净输入:
net = 0 * 1 + 0 * 2 + 0 = 0
判断:y * net = 1 * 0 = 0,小于等于0,模型认为这个样本“没有把握”,判定为错误,触发更新。
更新权重:
- w1 = 0 + 1 * 1 * 1 = 1
- w2 = 0 + 1 * 1 * 2 = 2
- b = 0 + 1 * 1 = 1
更新之后参数变为:
w = (1, 2),b = 1
为什么w一下子变成(1, 2)?因为正样本P1的特征是(1, 2),更新公式里η * y * x就是1 * 1 * (1, 2),等于把P1的特征向量整体叠加到权重上。这也说明P1这个正样本对模型的影响非常大,它直接把决策边界猛拉向自己一侧。
再看样本P2 = (2, 1),标签y = +1。
用新参数计算:
net = 1 * 2 + 2 * 1 + 1 = 5
5大于0,预测为+1,正确。不更新。
继续看样本N1 = (0, 0),标签y = -1。
net = 1 * 0 + 2 * 0 + 1 = 1
这就有问题了:模型输出是+1,但样本真实标签是-1,预测错误。
更新公式:
- w1 = 1 + 1 * (-1) * 0 = 1
- w2 = 2 + 1 * (-1) * 0 = 2
- b = 1 + 1 * (-1) = 0
注意,因为N1的特征是(0, 0),所以权重向量根本没变,只有b从1降到了0。这是一个特别容易忽略的细节:当错分样本落在原点附近时,w不更新,更新的是bias。bias在这里扮演“整体偏移”的角色,它让决策边界整体平移。
最后看样本N2 = (-1, -1),标签y = -1。
net = 1 * (-1) + 2 * (-1) + 0 = -3
输出-1,与标签一致,正确。
第1轮扫描结束,参数为:
w = (1, 2),b = 0
这里必须停下来,因为“第1轮遍历完了”不代表训练结束。感知机的收敛条件是“一整轮扫描中没有任何更新”,我们现在只知道第1轮里有两次更新,不能确定下一轮是否还会出错。必须继续扫描验证。
2.3 第2轮与收敛验证:为什么net=0也算错
第2轮重新从P1开始,用当前参数w = (1, 2),b = 0来判断。
样本P1 = (1, 2),y = +1:
net = 1 * 1 + 2 * 2 + 0 = 5
5大于0,正确。
样本P2 = (2, 1),y = +1:
net = 1 * 2 + 2 * 1 + 0 = 4
4大于0,正确。
样本N1 = (0, 0),y = -1:
net = 1 * 0 + 2 * 0 + 0 = 0
这一轮的关键出现了。net恰好等于0,模型输出sign(0)无法区分正负类。按上一节说的规则,y * net = -1 * 0 = 0,小于等于0,依然判定为错误,必须更新。
更新公式:
- w1 = 1 + 1 * (-1) * 0 = 1
- w2 = 2 + 1 * (-1) * 0 = 2
- b = 0 + 1 * (-1) = -1
参数变成:
w = (1, 2),b = -1
样本N2 = (-1, -1),y = -1:
net = 1 * (-1) + 2 * (-1) + (-1) = -4
输出-1,正确。
第2轮扫描结束,参数为:
w = (1, 2),b = -1
但还不能急着说收敛。再做第3轮完整验证:
- P1(1, 2):net = 1 * 1 + 2 * 2 - 1 = 4,正确;
- P2(2, 1):net = 1 * 2 + 2 * 1 - 1 = 3,正确;
- N1(0, 0):net = -1,输出-1,正确;
- N2(-1, -1):net = -1 -2 -1 = -4,正确。
第3轮扫描没有发生任何更新,训练正式停止。
最终模型是:
y_pred = sign(x1 + 2*x2 - 1)
它对应的决策边界是:
x1 + 2*x2 - 1 = 0
2.4 权值轨迹与结论:w几乎没动,b一直在变
把整个过程整理成一张参数变化表:
| 时刻 | w1 | w2 | b | 触发样本 |
|---|---|---|---|---|
| 初始 | 0 | 0 | 0 | - |
| 第1轮Step1 | 1 | 2 | 1 | P1错误 |
| 第1轮Step3 | 1 | 2 | 0 | N1错误 |
| 第2轮Step3 | 1 | 2 | -1 | N1再次错误 |
| 最终 | 1 | 2 | -1 | 收敛 |
这张表很值得多看几眼。从头到尾,权重向量只在处理P1时更新过一次,之后两次错误都来自N1,但因为N1的特征是(0, 0),更新时w不变,改变的只有b:1 → 0 → -1。
这说明bias并不是一个可以偷懒不更新的参数。在很多数据集里,错分样本的特征往往不在原点,所以权重和偏置会一起变动;但只要有样本落在原点附近,bias就会单独承担起修正边界的任务。如果你想偷懒把b初始化成0然后不更新,遇到这一类样本就会永远分错。
另一个值得注意的点是:第2轮N1的net=0被判定为错误,才让b从0降到-1,最终边界从原点一侧挪开。很多人写代码时把判断条件写成 y * net < 0,也就是等号不算错,那这个例子在第2轮就会停在b=0,N1依然落在边界上,训练过程反而永远不会触发收敛。所以等号必须算错,这个“零边界”处理是感知机能够稳定收住的关键细节。
3. 代码实现:把刚才的手算过程原样跑一遍
手算完了,下面用Python代码把同一个过程复现出来。代码不追求花哨,只求能看清每一步发生了什么。
3.1 约30行的最小感知机实现
先给出最核心的类实现。我刻意把训练历史记录下来,方便观察每轮错误数。
import numpy as np class Perceptron: def __init__(self, eta=1.0, max_epochs=100): self.eta = eta self.max_epochs = max_epochs self.w = None self.b = 0.0 self.errors_history = [] self.epochs = 0 def fit(self, X, y): self.w = np.zeros(X.shape[1]) for _ in range(self.max_epochs): errors = 0 for xi, target in zip(X, y): net = np.dot(xi, self.w) + self.b if target * net <= 0: self.w += self.eta * target * xi self.b += self.eta * target errors += 1 self.errors_history.append(errors) if errors == 0: break self.epochs = len(self.errors_history) return self def predict(self, X): return np.where(np.dot(X, self.w) + self.b >= 0, 1, -1)这个实现有三个关键点:
- 判断条件是target * net <= 0,也就是net=0时必须触发更新,和手算规则保持一致;
- 更新公式直接用了向量运算,w += eta * target * xi,同时别忘更新b;
- fit函数在一个epoch内统计错误样本数,当某轮errors==0时就停止,这就是收敛。
3.2 带日志的训练版本:让每一步都暴露在眼前
上面的类能跑,但你想和手算逐行对照,最好用下面这个带日志输出的版本。它会在每次错分时打印当前参数和触发更新的样本。
def train_with_log(X, y, eta=1.0): w = np.zeros(X.shape[1]) b = 0.0 epoch = 0 while True: epoch += 1 updated = False print(f"--- Epoch {epoch} ---") for xi, target in zip(X, y): net = np.dot(xi, w) + b if target * net <= 0: print(f" 错分 {xi}, y={target}, net={net:.3f}", end="") w = w + eta * target * xi b = b + eta * target updated = True print(f" -> 更新: w={w}, b={b:.3f}") else: print(f" 正确 {xi}, y={target}, net={net:.3f}") if not updated: print(f"第 {epoch} 轮没有更新,收敛。") break return w, b X = np.array([[1, 2], [2, 1], [0, 0], [-1, -1]], dtype=float) y = np.array([1, 1, -1, -1]) train_with_log(X, y)用这个函数跑我前面的手算数据集,输出会和第2节完全一致:第1轮P1和N1各触发一次更新,第2轮N1再触发一次,第3轮全对,训练结束。
这里我特别建议你在自己的机器上把train_with_log跑一遍,因为看日志和手算是两种学习体验。手算让你理解规则,日志打印让你相信代码没有藏着任何魔法。很多人在感知机实现上出错,其实就是因为直接调库、看不到中间细节,结果某一步逻辑写错了也毫无察觉。
3.3 换一个更真实的数据集观察收敛过程
手算例子是理想的小数据集,真实项目里数据不会只有4个点。下面生成两组随机点团,一组在右上角、一组在左下角,让感知机去分类。
np.random.seed(42) X_pos = np.random.randn(15, 2) + np.array([2, 2]) X_neg = np.random.randn(15, 2) - np.array([2, 2]) X = np.vstack([X_pos, X_neg]) y = np.array([1] * 15 + [-1] * 15) model = Perceptron(eta=0.1, max_epochs=100) model.fit(X, y) print("收敛轮数:", model.epochs) print("各轮错误数:", model.errors_history[:10]) print("最终w:", model.w, "b:", model.b) print("预测准确率:", (model.predict(X) == y).mean())我随手跑的一次输出结果是这样的(不同随机种子略有差异):
- 第1轮错误数在5到10个之间;
- 第2轮、第3轮错误数明显下降;
- 大约第4或第5轮错误数降到0;
- 最终在训练集上的准确率是100%。
这里有个非常实用的观察:感知机训练在错误数序列上往往是“快速下降、然后归零”的模式,而不是线性下降。前一两轮把大的方向修正完,后面几轮就是处理那些恰好卡在边界附近的样本。如果你看到错误数长期不降、反复震荡,大概率是数据本身线性不可分,或者学习率设置有问题,这一块我们马上展开。
4. 训练感知机常踩的四个边界问题
4.1 标签写成0和1:负类永远学不到
感知机的标签y必须取+1和-1,而不是0和1。这是最容易踩、也最容易“看起来一切正常”的坑。
如果你把负类的标签写成0,更新公式w += η * y * x在y=0时永远是0,负样本无论怎么错分都不会产生任何更新。结果就是:正类被一遍遍纠正,负类完全没人管,模型最后干脆把所有样本都判成正类。用准确率看,如果数据里正类占大多数,准确率甚至还挺高,这就伪装成了“训练成功”。
我见过不止一次有人把感知机标签用0/1编码,然后来问“为什么我的模型只输出正类”。解决办法很简单:把所有标签映射成{-1, 1}。代码里可以用一行处理:
y_binary = np.where(y_orig == 1, 1, -1)只要你记住感知机是“符号方向一致”的分类器,标签就必须是带符号的+1/-1,这个坑就不会再踩。
4.2 线性不可分:错误数降不到0,训练永远不结束
感知机有一个致命的假设:数据必须线性可分。换句话说,存在某条直线能把正负类完全分开,训练才可能收敛。
拿经典的XOR(异或)问题来说:
| x1 | x2 | y |
|---|---|---|
| -1 | -1 | -1 |
| -1 | 1 | 1 |
| 1 | -1 | 1 |
| 1 | 1 | -1 |
这四个点的分布是:左上和右下为正类,左下和右上为负类。你画不出任何一条直线能完美切开它们。感知机在这种数据上会反复振荡——每次修正了一个点,另一个点又被带歪,错误数永远不会变成0。
所以我强烈建议:训练感知机时永远设一个max_epochs上限,并且打印每轮的errors_history。如果错误数到后面一直大于0,不要怀疑是自己调参不对,先认真检查数据是不是线性可分。处理线性不可分的数据,感知机本身无能为力,要么做特征变换,要么换用逻辑回归、SVM这类能软化边界的模型。
4.3 学习率的影响:收敛性与数值稳定性要分开看
感知机有个反直觉的理论结论:只要学习率η > 0,收敛性(能否收敛)与η具体取多少无关。这一点和梯度下降完全不同,梯度下降学习率太大是真的会发散,但感知机不会。
不过,不影响收敛不等于不影响过程。我用同一个随机两簇数据试过几种学习率,表现差异在“糊”的程度上:
| 学习率 | 收敛轮数(示例) | 典型现象 |
|---|---|---|
| 0.01 | 较多轮数 | 权重每步挪得小,前期错误数下降得很慢 |
| 0.1 | 适中 | 平衡型,大多数情况推荐 |
| 1.0 | 可能极少轮数收敛 | 有时一步到位,但在边界临界样本上容易反复跳 |
学习率过大的实际风险,不是“发散了”,而是会在最后一个错分样本上产生一次很大的权重跳动,导致已经收敛的边界又坏掉,再花几轮重新修好。我自己在实际项目里比较常用的经验值是0.1,足够稳定,也不至于太慢。
如果你想要一个既稳定又不太慢的策略,可以动态缩小学习率,比如每轮乘一个衰减系数。但我要提醒:感知机的收敛证明依赖固定的任意正学习率,改动学习率只会改变轨迹,不会改变“线性可分条件下必然收敛”这个结论。
4.4 特征尺度差异大:权重会被大特征带偏
感知机对特征尺度比较敏感。如果x1的取值范围是0到1000,x2的取值范围是0到1,那么x1对应的权重w1会常常被更新得更快,因为更新量η * y * x1和x1本身成正比。
这会导致两个问题:
- 训练过程中决策边界在x1方向上摆动很大,收敛变慢;
- 最终得到的边界可能过分依赖某一个特征,泛化能力差。
解决方法和绝大多数机器学习模型一样:训练前对特征做标准化,让每个维度的均值接近0、方差接近1。一个简单的代码示例:
X_mean = X.mean(axis=0) X_std = X.std(axis=0) X_scaled = (X - X_mean) / (X_std + 1e-8)对于感知机这种最朴素的线性分类器,标准化带来的提升往往立竿见影。我在手算例子里故意选了尺度整齐的整数特征,就是为了让权重和偏置的变化看起来清晰;真实数据可没那么温柔,先归一化再训练是常规操作。
5. 从感知机进一步:三个值得接力的方向
5.1 为什么说感知机找的超平面“不唯一也不最优”
从手算例子里能看出一个事实:感知机停在哪条分离线,受初始权重、样本遍历顺序、学习率影响很大。我的例子最终收敛到x1 + 2*x2 - 1 = 0,但如果把样本顺序换一下,完全可能得到另一条同样能把四样本分对的线。
这说明感知机的目标是“找到一个可行解”,不是“找到最好的解”。什么样的解算好?通常我们希望决策边界离两边样本都远一点,这样新样本落在边界附近时更不容易被判错。但感知机对“距离”毫无概念,它只在出错时修正,不出错就停。
真正把“离边界最远”作为优化目标的是SVM。SVM不仅找分正确的线,还找最大化间隔的那条线。从感知机到SVM,本质是同一个“线性分类”问题加上了更严格的优化目标。如果你理解了感知机为什么只会给出一个可行解,就能明白SVM那套间隔和支撑向量的动机。
5.2 从sign到sigmoid:把硬分类变成软估计
感知机输出是sign(w·x + b),结果是+1或-1,只有方向、没有置信度。真实业务里我们往往更想知道“属于正类的概率大概是多少”,而不是一个硬邦邦的类别。
把激活函数从sign换成sigmoid,也就是:
p = 1 / (1 + exp(-(w·x + b)))
再把损失换成交叉熵,就变成了逻辑回归。逻辑回归保留了“线性边界”的骨架,但输出的是一个0到1之间的概率值。它和感知机最大的区别是:感知机只要分对就不更新,逻辑回归即使分对了,只要置信度不够,依然会用梯度下降把边界推得更稳。
这个改动听起来不大,意义却很大。因为sigmoid是连续可导的,逻辑回归可以使用梯度下降,也就能自然而然地扩展到多层神经网络;而sign函数在0点不可导,感知机那套更新规则严格来说不能进行梯度传播。
5.3 单层不够用:XOR与多层网络的动机
我在第4.2节提过XOR问题,感知机永远学不出来,因为它要求数据线性可分。但XOR这种“线性不可分但完全有意义”的模式,在真实数据里太常见了。两个特征交叉组合出类别,用直线切不动,但用一条折线、一个圆圈就能完美分开。
解决思路是堆层。单层感知机没有隐藏层,只能做线性切分;如果加一个隐藏层、再配一个非线性激活函数,就变成了最简单的多层感知机(MLP)。多层网络能表达更复杂的边界,比如异或、圆形分布、任意多边形区域。
这里必须强调一点:从单层感知机到多层网络,不是简单的“多叠几层感知机”,因为感知机的更新规则建立在“单个神经元错分就改”的直觉上,无法直接推导隐藏层权重怎么更新。多层网络需要反向传播算法,它把输出层的误差一层层传回去,依靠梯度而不是简单的符号修正来更新权重。所以感知机通常被看作神经网络的“原型”,但它和现代深度学习在训练机制上是分道扬镳的。
说到最后,我想给一个很个人化的建议。我每次接触一个新模型,都会先找一个3到5个样本的小例子,用手算一遍完整训练过程,再写代码对照。这个习惯看起来很笨,但帮我省掉的debug时间远超过我“浪费”在那几分钟计算上的时间。尤其是感知机这种规则极简的模型,手算能让你把“哪些参数在动、为什么动、不动会怎样”全部看透。如果你正卡在感知机这一步,找张纸,照着我第2节的方式把训练过程推一遍,很多困惑会在笔尖下自动解开。