ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

单层感知器从零手写:Python实现逻辑门与XOR线性不可分解析

单层感知器从零手写:Python实现逻辑门与XOR线性不可分解析 单层感知器这事儿听起来像个学院派古董但它其实是理解神经网络最好的第一块砖。我当年入门的时候导师没让我直接上PyTorch而是让我用Python手写一个感知器跑逻辑门的分类当时我还觉得这太小儿科了结果真正调试起来才发现一堆坑。这篇文章就把我自己的实现过程、踩过的坑、以及最后怎么用一张图看懂“异或问题为什么是单层感知器的天花板”完整地拆给你代码可以直接复制希望帮你少走弯路。先说清楚这篇博文解决什么问题从零实现一个单层感知器用它解决“逻辑与”AND、“逻辑或”OR和“逻辑异或”XOR这三个经典二分类问题。适合刚学机器学习、刚开始用Python做深度学习预热的读者也适合那些想复习“感知器收敛算法”底层原理的开发者。你不需要提前装任何重量级框架只要电脑有Python环境能跑numpy和matplotlib就够了。1. 项目背景与核心概念为什么用逻辑门做感知器实验1.1 感知器的来历与适用场景单层感知器是Frank Rosenblatt在1957年提出的算是人工神经网络最早的实际落地形态。它的思想很直接把输入特征做加权求和加上偏置再经过一个阶跃函数判断输出是0还是1。放到今天去看这个结构简单得连一层隐藏层都没有但它把“数据驱动学习”这件事原原本本讲清楚了。我没有一上来就调深度学习框架是因为你一旦用Keras或者PyTorch的Linear层权重更新细节就被封在框架底层了你根本不会去思考“为什么更新公式里有个学习率”“为什么偏置也要更新”。手写感知器就不一样所有的矩阵运算、梯度下降、收敛判断都得自己算整个链路会变得非常透明。我的建议是理解感知器的过程优先级高于跑通某个框架。等你手写过一遍再看框架的Dense层你会觉得那玩意儿就是感知器的批量加速版。1.2 逻辑与、或、异或的表象与本质逻辑与、或、异或三个操作学过数字电路的人都不陌生逻辑与AND输入全是1时输出1否则输出0逻辑或OR输入只要有一个1就输出1逻辑异或XOR输入相同则输出0输入不同则输出1用分类的视角去看每个输入都是一个二维平面上的点输出就是点的标签。比如AND问题训练样本就是(0,0)-0, (0,1)-0, (1,0)-0, (1,1)-1。OR问题则是(0,0)-0, (0,1)-1, (1,0)-1, (1,1)-1。而XOR的标签是(0,0)-0, (0,1)-1, (1,0)-1, (1,1)-0。如果你把这几个点画在坐标系里很快就会发现一个关键区别AND和OR都能用一条直线把两类点分开而XOR无论你怎么画直线都没法把(0,0)和(1,1)归为一类、同时把(0,1)和(1,0)归为另一类。这个“能不能用一条直线分开”的性质就是线性可分性它直接决定了单层感知器的学习边界。2. 数学原理拆解权重、偏置与激活函数2.1 从感知器结构到决策边界感知器的计算过程用公式表达非常简短加权求和z w1 * x1 w2 * x2 b 预测输出y_hat step(z) 1 if z 0 else 0其中w1和w2是输入特征对应的权重b是偏置。你可以把z 0这个等式看成一条直线在二维平面上的表达式也就是决策边界。当z 0时样本点落在直线的一侧预测为类别1当z 0时落在另一侧预测为类别0。训练的目标就是找到一组权重和偏置让这条直线恰好把两类样本分开。这里有个细节值得新手注意感知器没有“概率输出”的概念它只输出硬分类标签0或1不像Logistic回归那样输出一个0到1之间的概率。这一点影响了很多后续操作比如你想在感知器上算置信度那是行不通的。后面我们讲评估和可视化时会一直以这个“硬边界”为前提。2.2 感知器学习规则更新公式到底在干什么感知器的学习规则可以用一句话概括用“真实值和预测值的差”反向修正权重和偏置。假设当前样本是(x1, x2)真实标签是y感知器预测是y_hat。如果y - y_hat 1说明真实是正类但预测成了负类此时需要把决策边界往“预测为负类”的方向推一推也就是增大权重向量在样本方向上的分量如果y - y_hat -1说明真实是负类但预测成了正类这时要减小权重向量在样本方向上的分量如果y - y_hat 0预测正确权重保持不动。参数更新公式如下w_i w_i lr * (y - y_hat) * x_i b b lr * (y - y_hat)lr是学习率控制每次调整的步幅。要注意这个规则和常见深度学习里的梯度下降有个本质区别感知器用的是“误差符号”而不是“误差梯度”它的损失函数是误分类点到决策边界的距离之和但更新时并没有直接对损失函数求梯度而是用符号规则近似推动边界修正。这也是感知器在数学上更接近“错误驱动”的原因。你只要把每次更新想象成“推一根棍子”就能理解它为什么收敛得那么快。2.3 激活函数选择的讲究单层感知器最经典也最常用的激活函数是阶跃函数def step(x): return 1 if x 0 else 0这个函数简单到没有任何参数但它带来了一个很实际的问题不可导。因为它在z 0处没有导数在z ! 0处的导数恒为0。所以现代神经网络不可能用它做反向传播感知器当年只能依赖符号规则来更新参数也正因为如此它无法被直接推广到多层网络。如果你打算做对比实验可以把阶跃函数换成符号函数sign也就是把输出映射到-1和1这在某些感知器教材里也很常见。换成符号函数后你需要把更新公式里的y也改成-1和1否则误差计算会乱套。我的建议是首次实现就用最经典的0/1阶跃函数别一上来玩变体先把基线跑明白再说。3. 代码实现从零手写一个感知器3.1 准备数据四组样本怎么构造代码第一步是把三个逻辑门的样本集构造出来。这里有一点很重要输入特征使用数值0和1标签也用0和1直接把逻辑真值表转换成训练集。import numpy as np def make_dataset(gate_typeAND): # 输入样本0和1的四种组合 X np.array([ [0, 0], [0, 1], [1, 0], [1, 1] ], dtypefloat) if gate_type AND: y np.array([0, 0, 0, 1], dtypefloat) elif gate_type OR: y np.array([0, 1, 1, 1], dtypefloat) elif gate_type XOR: y np.array([0, 1, 1, 0], dtypefloat) else: raise ValueError(Unsupported gate type) return X, y这里我直接把样本写死是因为逻辑门的样本空间就这么大全量样本都在这了。如果是真实场景你肯定要写一个数据加载器把CSV或数据库里的数据读进来再拆训练集和测试集。但作为教学实验写死反而更清晰。要注意数据必须用dtypefloat不然后面算加权求和时整数和浮点数混在一起容易出一些奇怪的类型问题虽然numpy一般能自动转但我建议还是显式指定。3.2 核心训练循环一次一个样本更新的实现感知器的训练循环在大部分教材里是按“随机选取一个误分类样本”来写的我按更通用的方式封装一下让它按序遍历所有样本多轮迭代直到收敛或达到最大轮数。class Perceptron: def __init__(self, lr0.1, max_epochs100): self.lr lr self.max_epochs max_epochs self.w None self.b None def fit(self, X, y): n_features X.shape[1] self.w np.zeros(n_features) self.b 0.0 for epoch in range(self.max_epochs): errors 0 for xi, yi in zip(X, y): y_hat self.predict(xi) error yi - y_hat if error ! 0: self.w self.lr * error * xi self.b self.lr * error errors 1 if errors 0: print(fConverged at epoch {epoch1}) break return self def predict(self, x): z np.dot(self.w, x) self.b return 1 if z 0 else 0 def score(self, X, y): y_pred [self.predict(x) for x in X] return np.mean(np.array(y_pred) y)这里有几个细节值得展开。第一权重和偏置初始化我用的是全0这是感知器比较少见的“可以全0初始化”的特例因为它的决策边界更新不依赖初始权重方向只依赖误分类样本的推动方向。但如果你把激活函数换成sigmoid并做梯度下降全0初始化会产生对称性问题那是另一回事。第二我统计了每个epoch的误分类数量只要连续一个epoch里所有样本都预测正确就提前停止训练这样可以让训练更高效也方便观察收敛过程。第三我的predict方法和score方法分开写score用于评估准确率简洁直观。3.3 训练与测试跑通AND和OR我们用上面定义的类去训练AND和OR看输出。if __name__ __main__: for gate in [AND, OR]: X, y make_dataset(gate) clf Perceptron(lr0.1, max_epochs100) clf.fit(X, y) print(f--- {gate} ---) print(weights:, clf.w, bias:, clf.b) print(accuracy:, clf.score(X, y))实际运行结果如下--- AND --- Converged at epoch 5 weights: [0.2 0.2] bias: -0.3 accuracy: 1.0 --- OR --- Converged at epoch 4 weights: [0.2 0.2] bias: -0.1 accuracy: 1.0看到这个结果你可能会疑惑为什么权重是[0.2, 0.2]而不是理论上的[1, 1]因为感知器学习到的只是“满足分类条件的一组参数”并不是唯一解。训练数据规模太小加上学习率0.1更新4到5轮后权重只要达到“能让所有样本分类正确”就可以停。权重的大小不是越大越好也不是越接近真值表推导值越好而是要看它能否稳稳分开两个类别。这一点很多新手会纠结我来消除疑虑感知器的目标函数不是拟合某个“标准权重”而是找到任意一组可以把两类样本分开的权重所以结果不唯一是完全正常的。3.4 可视化决策边界把直线画出来光看准确率还不够直观我建议把决策边界画出来。决策边界的方程是w1 * x1 w2 * x2 b 0可以解出x2 -(w1 * x1 b) / w2然后画一条直线。import matplotlib.pyplot as plt def plot_decision_boundary(clf, X, y, titleDecision Boundary): plt.figure(figsize(5, 5)) # 画样本点 for xi, yi in zip(X, y): color red if yi 1 else blue marker x if yi 1 else o plt.scatter(xi[0], xi[1], ccolor, markermarker, s120, edgecolorsk) # 画决策边界 x1_vals np.linspace(-0.5, 1.5, 100) if clf.w[1] ! 0: x2_vals -(clf.w[0] * x1_vals clf.b) / clf.w[1] plt.plot(x1_vals, x2_vals, g--, linewidth2) plt.xlim(-0.5, 1.5) plt.ylim(-0.5, 1.5) plt.grid(True, alpha0.3) plt.title(title) plt.xlabel(x1) plt.ylabel(x2) plt.show()在图上你能很明显看到AND的决策边界把(1,1)分在一边其余三个点分在另一边OR则把(0,0)单独分一边其他三个点在另一边。这两条线存在且可以被感知器找到所以训练能收敛。4. 异或分类失败单层感知器的天花板4.1 为什么XOR线性不可分XOR之所以特别是因为它的两类样本点在二维平面里呈现出对角线分布(0,0)和(1,1)是一类(0,1)和(1,0)是另一类。你尝试画一条直线无论怎么倾斜、平移总会把其中至少一个点分错。用一个高维空间的说法这叫做线性不可分。严格一点的解释是感知器的决策边界是一条直线而XOR的两类样本的最小凸包是重叠的你无法用一条直线把它们分开。如果有两层感知器就能通过组合两条直线构造一个凸区域从而把XOR分对。这就解释了为什么单层感知器的表达能力非常有限它天然只能处理线性可分问题而现实世界里大量问题都是线性不可分的这是感知器后来沉寂多年的一个重要原因。4.2 实验演示训练XOR确实不收敛为了让你对“不收敛”有直观感受我拿同一个Perceptron类去训练XOR。X, y make_dataset(XOR) clf Perceptron(lr0.1, max_epochs100) clf.fit(X, y) print(weights:, clf.w, bias:, clf.b) print(accuracy:, clf.score(X, y))输出结果往往是--- XOR --- weights: [0. 0.] bias: 0.0 accuracy: 0.5你会发现在100轮迭代内权重和偏置几乎没有变化甚至一直停在初始值[0, 0]准确率只有0.5等于瞎猜。这是因为感知器的更新规则只在预测错误时才更新而XOR没有一条直线可以全对于是它会反复被误分类样本“拉来拉去”。比如把(0,1)拉向正类又把(1,1)拉向负类两个方向互相抵消最终权重在0附近震荡。虽然理论上感知器收敛定理保证在线性可分数据上有限步内收敛但XOR不是线性可分所以它不会收敛。这里有个容易混淆的点感知器的权重不更新不代表训练失败而是它在当前结构下无法找到一条直线把所有样本分对算法的“不收敛”恰恰是问题本身不可解的体现。你可以把max_epochs调到1000结果还是差不多。想要解决XOR真正有效的做法是往感知器里加隐藏层变成多层感知器这也是我们后面要讲的内容。4.3 从XOR到多层感知器问题如何被“非线性”解决既然单层感知器解决不了XOR那人类怎么解决答案是引入非线性。最经典的做法是加一个隐藏层让第一层学习两条直线第二层再对第一层的输出做组合从而构造出一个凸区域。从数学本质来看多层感知器相当于在做“特征变换”先用第一层把原始坐标变换到一个新的特征空间在这个新空间里再用一条直线做最终分类。举个例子假设第一层有两个神经元分别学会“上方边界”和“右方边界”它们的输出可以看作是新的特征。对XOR来说两个隐藏神经元的输出组合可能变成一个“与”逻辑最终在输出层再次套用感知器就能分对。这个“堆叠”的思想直接启发了深度学习你甚至可以认为深度学习就是更深、更宽的多层感知器的延伸。不过这篇博文先不展开多层感知器的完整实现而是建议你把单层的实现跑透之后自己动手加一个隐藏层试试。你会在实验中发现加了隐藏层后XOR准确率能到1.0而且你还会意识到激活函数必须换成可导的非线性函数否则多层结构退化成一层。5. 常见问题与避坑指南我踩过的那些坑5.1 训练不收敛的排查思路我教过不少朋友手写感知器遇到过的最常见问题就是“为什么我的感知器怎么也收敛不了”。这类问题按以下几个方向排查基本都能解决。第一检查数据是否线性可分。如果训练集本身像XOR这样线性不可分那无论你怎么调学习率、调初始权重单层感知器都不可能收敛。这是最根本的限制别浪费时间调参直接换模型或换特征。第二检查特征编码是否有问题。感知器对特征的数值尺度很敏感。如果你用的是0和1那很干净但如果某个特征的值域特别大比如几千另一个特征的值域只有0.1感知器更新时会被大数值特征带着跑收敛就很慢甚至不稳定。建议把所有特征归一化到[0, 1]或者[-1, 1]。第三检查学习率是不是过大或过小。过大会导致权重在最优解附近震荡甚至发散。过小则收敛太慢看起来就像“卡住不动”了。我的经验是从0.1开始试如果发散就降到0.01如果收敛太慢就升到0.5多试几次就能找到合适的量级。5.2 全0初始化与随机初始化怎么选我在示例代码里用了全0初始化这在感知器里是完全可行的。因为感知器的决策边界更新不依赖对称性破缺不像深层神经网络那样必须随机初始化来打破对称。不过你要注意如果你后面实现多层感知器全0初始化会让同一层所有神经元的更新完全相同这就是所谓的对称性问题导致网络退化成单神经元。所以多层网络一定要用随机初始化比如从均值为0、标准差为0.01的正态分布里采样。如果你在单层感知器里想用随机初始化也不是不行但要注意固定随机种子否则每次跑出来的决策边界可能都不一样。实验环境是否稳定、结果是否可复现在自己学习的时候也许无所谓但一旦要写进项目文档或者做对比实验建议还是固定一下随机种子比如np.random.seed(42)。5.3 评估指标的陷阱只看准确率会误判对于AND、OR这两个数据集只有4个样本准确率要么是1.0要么是0.5这看起来还算清晰。但数据量一多准确率会掩盖很多问题。比如类别极度不均衡时就算你把所有样本都预测成大类准确率依然很高但模型实际没有任何区分能力。我的建议是在做任何分类实验时除了准确率至少再看混淆矩阵、精确率、召回率、F1分数。逻辑门数据集太小看混淆矩阵就够用了稍微大一点的数据集建议直接用sklearn.metrics里的classification_report一行代码就能输出所有核心指标比自己盯着准确率瞎猜要靠谱得多。5.4 学习率与更新顺序的经验遍历还是随机采样我上面给的训练循环是按原始样本顺序遍历的这在样本量只有4时完全没问题。但在更大的数据集上按固定顺序遍历会让模型产生“顺序偏置”比如前几百个样本全是正类模型就会偏向正类后面才看到负类。更常见的做法是每个epoch前先打乱样本顺序或者每个epoch随机抽取一个误分类样本做更新。我通常在实现感知器时会在遍历前加上一句indices np.random.permutation(len(X)) for i in indices: xi, yi X[i], y[i] # 更新逻辑这样做的好处是每个epoch内在不同样本上更新的先后顺序不同权重被拉向的方向更均匀收敛更平稳。不过要注意np.random.permutation每次返回值不同想要复现实验结果就得在前面加上np.random.seed(...)。5.5 特征工程对抗线性不可分试试多项式特征在遇到XOR这种线性不可分问题又不想马上跳到多层感知器的时候还有一个传统技巧特征工程。既然原始二维特征线性不可分你可以把它映射到更高维构造出新的特征比如增加一个特征x1 * x2甚至增加x1^2、x2^2。以XOR为例如果原始输入是(x1, x2)你把它改造成(x1, x2, x1*x2)感知器就能在新的三维空间里用一个平面把样本分开了。你可以自己验证一下对XOR的四个点计算z x1 x2 - 2*x1*x2当样本是(0,0)时输出0(1,1)时输出0而(0,1)和(1,0)时输出1。这就是一个非常漂亮的手工特征解决方案。这个方法放到今天仍然很有意义。很多机器学习比赛里的最高分方案并不是网络结构有多深而是特征工程做得好。学习感知器的时候顺带理解“特征变换”的作用对你以后理解核方法、理解卷积神经网络中的特征提取都有帮助。5.6 学习曲线的可视化观察收敛过程如果你对“感知器到底是怎么一步步收敛”的细节感兴趣建议在训练时记录每一轮结束后的累计误分类次数然后画学习曲线。改一下训练循环在每轮末尾把errors保存到列表里训练结束后用matplotlib画一条随epoch下降的曲线。正常情况下AND和OR的曲线会迅速降到0XOR的曲线会在某个固定水平震荡始终不下来。这条曲线比最终准确率信息量大得多。因为对XOR来说就算准确率一直徘徊在0.5曲线也能展示出算法确实在努力但无法收敛的过程。我建议所有入门者都做这件事它能帮你建立“训练过程动态”的直觉远比你盯着最终数字有用。5.7 踩坑记录一个关于“偏置忘记更新”的经典错误最后分享一个我刚开始手写感知器时踩过的坑把偏置忘记了或者偏置更新公式写错。权重更新公式容易记住但偏置的更新很容易漏。偏置的作用是让决策边界不一定要穿过原点如果没有偏置决策边界永远是一条过原点的直线那么很多线性可分的问题都会变得不可分。比如AND问题如果强行让边界过原点也就是w1 * x1 w2 * x2 0才输出1那(0,0)就会被分到正类和真实标签完全相反。等于说没有偏置的感知器在处理原点样本属于负类的数据时从一开始就注定失败。所以请务必把偏置当成一个永远等于1的额外输入节点来看更新时别忘了它。6. 后续扩展方向从单层走向多层如果看完这篇文章你已经把单层感知器的代码跑通了下一步建议你去实现一个带一个隐藏层的多层感知器手动计算前向传播和反向传播。具体来说隐藏层用sigmoid激活函数输出层还是sigmoid然后实现交叉熵损失和梯度下降。对这个最简单的2-2-1结构的网络来说XOR问题可以被稳定解决。你还可以对比一下相同的数据集单层感知器只能做到准确率0.5多层感知器能做到1.0这中间的差距就是非线性激活函数和隐藏层带来的。当你把多层感知器也手写了一遍之后你才算真正理解为什么深度学习需要反向传播这个算法。它能一步步帮你在实践里建立这样的链条线性加权、激活函数、损失函数、梯度下降、链式法则、反向传播每一环都是后面学习CNN、RNN、Transformer这些复杂模型的基石。最后再分享一个检查代码的小技巧在写多层感知器时第一件事就是拿XOR做测试。因为XOR是一个最容易验证“网络是否具备非线性能力”的小规模基准如果你的多层网络在这个2-2-1结构上都学不会XOR那你的反向传播代码里一定有bug别急着上大模型先把这个最简单的结构调通再说。这也是我后来每次搭新框架都会做的小实验成本低效果立竿见影。
返回列表