资讯动态

从零实现两层感知机:用Python解决经典异或问题

发布时间:2026/8/4 14:25:10 来源:尧图企业网站定制
1. 项目概述从“不可能”到“可能”的经典一跃在机器学习入门领域有一个绕不开的“里程碑式”问题那就是异或XOR问题。它之所以经典是因为它曾一度让最基础的感知机模型束手无策直接推动了神经网络从单层结构向多层结构的进化。今天我们就来亲手实现这个“两层感知机解决异或问题”的项目这不仅是理解神经网络发展史的关键一步更是掌握其核心工作原理——非线性可分与权重学习——的绝佳实践。无论你是刚学完线性回归和单层感知机对更复杂的模型感到好奇的新手还是想重温神经网络基础、夯实理论的老手这个项目都能让你获得从理论到代码的透彻理解。简单来说异或是一个逻辑运算当两个输入相同时都是0或都是1输出为0当两个输入不同时一个0一个1输出为1。它的数据点在二维平面上无法用一条直线分开这就是所谓的“线性不可分”。单层感知机一个神经元本质上只能画一条直线或超平面所以它搞不定异或。而两层感知机通过引入一个隐藏层相当于先画两条直线或进行空间变换再将结果组合就能巧妙地构造出一个非线性决策边界完美解决这个问题。这个项目我们将用Python从零开始不依赖高级的深度学习框架手动实现前向传播、反向传播误差反向传播算法并可视化整个学习过程让你看清每一个权重和偏置是如何被调整的最终让网络“学会”异或逻辑。2. 核心原理为什么单层不行两层就行要理解两层感知机如何解决异或问题我们必须先深入理解单层感知机的局限性以及隐藏层带来的“空间变换”魔力。2.1 单层感知机的本质与局限单层感知机或者说一个简单的神经元其数学表达是y f(w1*x1 w2*x2 b)。其中f是激活函数最初是阶跃函数w是权重b是偏置。这个公式定义的实际上是一个线性分类器。在二维平面上决策边界就是一条直线w1*x1 w2*x2 b 0。所有落在这条直线一侧的点被分为一类例如输出1另一侧的点被分为另一类输出0。现在我们把异或的四个数据点画在图上(0,0)-0, (0,1)-1, (1,0)-1, (1,1)-0。你会发现无论你怎么画一条直线都无法把输出为1的点(0,1)和(1,0)和输出为0的点(0,0)和(1,1)完全分开。总有一类点会被分错。这就是马文·明斯基在《感知机》一书中指出的致命弱点它直接导致了神经网络研究的第一次寒冬。注意这里常有一个误解认为换一个复杂的激活函数如Sigmoid就能让单层感知机解决异或。这是不对的。只要没有隐藏层无论激活函数多复杂其最终的决策边界仍然是输入特征的线性组合尽管激活函数是非线性的但分类边界由wXb阈值决定这依然是线性的。问题的核心在于模型的“容量”即其能够表达的函数的复杂程度单层结构容量不足。2.2 隐藏层构造新特征与空间变换引入一个隐藏层游戏规则就完全改变了。我们以最经典的两层结构一个隐藏层一个输出层为例输入层两个神经元对应输入x1和x2。隐藏层假设我们设置两个神经元这是解决异或问题的最小可行配置之一。输出层一个神经元给出最终预测结果。这个过程可以分两步理解特征变换隐藏层的作用隐藏层的每个神经元都学习到输入特征的一种线性组合然后通过一个非线性激活函数如Sigmoid、ReLU。这相当于将原始数据点从(x1, x2)坐标系映射到了一个新的特征空间(h1, h2)。在这个新空间里数据点的分布被“扭曲”了。线性分类输出层的作用输出层神经元再对隐藏层输出的新特征(h1, h2)进行线性组合。关键在于经过隐藏层的非线性变换后原本在原始空间中线性不可分的数据点在新构造的特征空间中变得线性可分了。一个直观的类比想象你要区分一片土地上两种颜色的石头但它们混杂在一起。单层感知机就像让你直接画一条直线来分开你做不到。两层感知机的策略是先派两个人隐藏神经元去对石头进行两种不同的“测量”比如一个人按重量标记一个人按形状标记。经过他们的测量每块石头有了两个新标签重量等级、形状等级。然后你输出神经元再看这些新标签发现很容易就能画一条直线根据“重量和形状的组合”把两种颜色的石头完美分开。隐藏层做的就是这种“创造新视角”的工作。对于异或问题隐藏层可以学习到类似“与门(NAND)”和“或门(OR)”的中间表示。输出层再学习一个“与门(AND)”来组合这两个中间结果最终实现异或逻辑XOR(x1, x2) AND( NAND(x1, x2), OR(x1, x2) )。我们的网络将通过训练自动发现这种或功能等价的表示。3. 网络设计与实现细节我们将设计一个结构为2-2-1的两层感知机。即2个输入隐藏层2个神经元输出层1个神经元。激活函数选择Sigmoid因为它处处可导便于我们手动实现反向传播。3.1 网络结构与前向传播公式让我们定义清晰的符号和计算步骤网络参数输入:X [x1, x2]隐藏层权重:W1(形状 2x2) 隐藏层偏置:b1(形状 1x2)输出层权重:W2(形状 2x1) 输出层偏置:b2(形状 1x1)激活函数:sigmoid(z) 1 / (1 exp(-z))前向传播过程隐藏层输入与激活Z1 X · W1 b1·表示矩阵乘这里X是单个样本扩展为1x2A1 sigmoid(Z1)A1就是隐藏层的输出也是我们构造的新特征。输出层输入与激活Z2 A1 · W2 b2A2 sigmoid(Z2)A2就是网络的最终输出一个介于0和1之间的值我们可以设定阈值如0.5将其转换为0/1分类。初始化策略权重不能初始化为0否则所有神经元将同步更新失去意义。我们采用简单的“小随机数”初始化例如从均值为0、标准差为0.1的正态分布中采样。3.2 损失函数与反向传播推导我们使用均方误差MSE作为损失函数虽然对于分类问题交叉熵更常见但MSE推导更直观且对于这个简单问题完全有效。 损失函数:L 1/2 * (A2 - Y)^2其中Y是真实标签。反向传播的目标是计算损失函数对各个参数W1, b1, W2, b2的梯度然后使用梯度下降法更新参数。这是本项目最核心的数学部分。链式求导过程计算输出层梯度损失对输出层输入的梯度dZ2 dL/dZ2 (A2 - Y) * sigmoid(Z2)。其中sigmoid(z) sigmoid(z) * (1 - sigmoid(z)) A2 * (1 - A2)。因此dZ2 (A2 - Y) * A2 * (1 - A2)。权重W2和偏置b2的梯度dW2 A1.T · dZ2db2 sum(dZ2)注意求和是因为b2是标量但dZ2可能来自批量数据计算隐藏层梯度损失对隐藏层输出的梯度dA1 dZ2 · W2.T损失对隐藏层输入的梯度dZ1 dA1 * sigmoid(Z1) dA1 * A1 * (1 - A1)权重W1和偏置b1的梯度dW1 X.T · dZ1db1 sum(dZ1)实操心得手动推导一遍反向传播的公式至关重要。即使未来你99%的时间都在用PyTorch或TensorFlow的autograd理解底层原理也能让你在模型不收敛、梯度爆炸/消失时有清晰的调试思路。你可以把这些公式写在一张便签上对照着代码实现。3.3 参数更新与学习率选择得到梯度后使用梯度下降法更新参数W W - learning_rate * dWb b - learning_rate * db学习率Learning Rate的选择这是训练神经网络最重要的超参数之一。对于这个小网络学习率太大如1.0会导致损失震荡甚至发散学习率太小如0.01则收敛极慢。经过实践0.1到0.5是一个不错的起点。我们可以在代码中实现一个简单的逻辑如果连续多次迭代损失不下降则适当减小学习率。4. 从零开始的Python代码实现我们不使用任何深度学习框架仅依赖numpy进行数值计算和matplotlib进行可视化。让我们一步步构建整个项目。4.1 数据准备与网络初始化import numpy as np import matplotlib.pyplot as plt # 1. 定义异或问题的输入和输出 X np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) Y np.array([[0], [1], [1], [0]]) # 注意保持维度一致 (4,1) # 2. 网络参数初始化函数 def initialize_parameters(input_size, hidden_size, output_size): np.random.seed(42) # 固定随机种子确保结果可复现 W1 np.random.randn(input_size, hidden_size) * 0.1 b1 np.zeros((1, hidden_size)) W2 np.random.randn(hidden_size, output_size) * 0.1 b2 np.zeros((1, output_size)) parameters {W1: W1, b1: b1, W2: W2, b2: b2} return parameters # 初始化一个2-2-1网络 params initialize_parameters(2, 2, 1) print(初始权重W1:\n, params[W1]) print(初始权重W2:\n, params[W2])4.2 前向传播与反向传播实现# 3. 激活函数及其导数 def sigmoid(x): return 1 / (1 np.exp(-x)) def sigmoid_derivative(x): s sigmoid(x) return s * (1 - s) # 4. 前向传播 def forward_propagation(X, parameters): W1, b1, W2, b2 parameters[W1], parameters[b1], parameters[W2], parameters[b2] Z1 np.dot(X, W1) b1 A1 sigmoid(Z1) Z2 np.dot(A1, W2) b2 A2 sigmoid(Z2) cache {Z1: Z1, A1: A1, Z2: Z2, A2: A2} return A2, cache # 5. 计算损失 def compute_cost(A2, Y): m Y.shape[0] # 样本数量 cost (1/(2*m)) * np.sum(np.square(A2 - Y)) return cost # 6. 反向传播核心 def backward_propagation(parameters, cache, X, Y): m X.shape[0] W1, W2 parameters[W1], parameters[W2] A1, A2, Z1, Z2 cache[A1], cache[A2], cache[Z1], cache[Z2] # 输出层梯度 dZ2 (A2 - Y) * sigmoid_derivative(Z2) # (4,1) dW2 (1/m) * np.dot(A1.T, dZ2) # (2,1) db2 (1/m) * np.sum(dZ2, axis0, keepdimsTrue) # (1,1) # 隐藏层梯度 dA1 np.dot(dZ2, W2.T) # (4,2) dZ1 dA1 * sigmoid_derivative(Z1) # (4,2) dW1 (1/m) * np.dot(X.T, dZ1) # (2,2) db1 (1/m) * np.sum(dZ1, axis0, keepdimsTrue) # (1,2) grads {dW1: dW1, db1: db1, dW2: dW2, db2: db2} return grads # 7. 参数更新 def update_parameters(parameters, grads, learning_rate): parameters[W1] - learning_rate * grads[dW1] parameters[b1] - learning_rate * grads[db1] parameters[W2] - learning_rate * grads[dW2] parameters[b2] - learning_rate * grads[db2] return parameters4.3 训练循环与可视化# 8. 整合训练过程 def train_model(X, Y, hidden_size, learning_rate, epochs): np.random.seed(42) input_size X.shape[1] output_size Y.shape[1] parameters initialize_parameters(input_size, hidden_size, output_size) costs [] # 记录损失历史 for i in range(epochs): # 前向传播 A2, cache forward_propagation(X, parameters) # 计算损失 cost compute_cost(A2, Y) costs.append(cost) # 反向传播 grads backward_propagation(parameters, cache, X, Y) # 更新参数 parameters update_parameters(parameters, grads, learning_rate) # 每1000轮打印一次损失 if i % 1000 0: print(fEpoch {i}: Cost {cost:.6f}) return parameters, costs # 9. 开始训练 hidden_size 2 learning_rate 0.5 epochs 10000 trained_params, cost_history train_model(X, Y, hidden_size, learning_rate, epochs) # 10. 绘制损失下降曲线 plt.plot(cost_history) plt.xlabel(Epoch) plt.ylabel(Cost (MSE)) plt.title(Training Loss over Epochs) plt.grid(True) plt.show() # 11. 测试训练好的模型 def predict(X, parameters): A2, _ forward_propagation(X, parameters) predictions (A2 0.5).astype(int) # 以0.5为阈值进行二分类 return predictions, A2 final_predictions, final_outputs predict(X, trained_params) print(\n 最终预测结果 ) print(输入数据) print(X) print(\n网络原始输出Sigmoid后) print(final_outputs) print(\n阈值化预测0.5为1) print(final_predictions) print(\n真实标签) print(Y)运行这段代码你应该能看到损失从某个初始值如0.1左右迅速下降并趋近于0。最终模型的预测输出会与真实标签Y完全一致。恭喜你你的两层感知机已经成功学会了异或逻辑5. 深入分析网络究竟学到了什么模型能预测正确只是第一步。作为一个有追求的学习者我们更应该打开这个“黑箱”看看里面的权重究竟变成了什么隐藏层到底构造了什么样的新特征。5.1 可视化决策边界与隐藏层激活我们可以通过绘制决策边界来直观理解网络是如何划分二维输入空间的。# 12. 绘制决策边界 def plot_decision_boundary(X, y, parameters): # 设置网格范围 x_min, x_max X[:, 0].min() - 0.5, X[:, 0].max() 0.5 y_min, y_max X[:, 1].min() - 0.5, X[:, 1].max() 0.5 h 0.01 # 网格步长 xx, yy np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h)) # 将网格点作为输入进行预测 grid_points np.c_[xx.ravel(), yy.ravel()] Z, _ predict(grid_points, parameters) Z Z.reshape(xx.shape) # 绘制等高线图决策边界 plt.contourf(xx, yy, Z, cmapplt.cm.Spectral, alpha0.8) # 绘制原始数据点 plt.scatter(X[:, 0], X[:, 1], cy.ravel(), s40, cmapplt.cm.Spectral, edgecolorsk) plt.xlabel(Input x1) plt.ylabel(Input x2) plt.title(Decision Boundary of 2-Layer Perceptron for XOR) plt.show() plot_decision_boundary(X, Y, trained_params)你会看到一幅图其中背景被两种颜色填充分别代表网络预测为0和1的区域。两个预测为1的点(0,1)和(1,0)位于一个颜色的区域两个预测为0的点位于另一个颜色的区域而边界是一条复杂的曲线由于Sigmoid的平滑性它不是一条折线。这清晰地展示了非线性决策边界。5.2 解读训练后的权重打印出训练后的参数尝试理解其逻辑。print(训练后的权重与偏置) print(W1 (输入层-隐藏层):\n, trained_params[W1]) print(b1 (隐藏层偏置):\n, trained_params[b1]) print(W2 (隐藏层-输出层):\n, trained_params[W2]) print(b2 (输出层偏置):\n, trained_params[b2]) # 让我们看看隐藏层对四个输入点的输出即构造的新特征 A2, cache forward_propagation(X, trained_params) print(\n隐藏层激活值 A1 (构造的新特征空间):) print(cache[A1])通过观察W1、b1和A1你可能会发现一些模式。例如一个隐藏神经元可能对输入“都是1”的情况激活值很低类似NAND门另一个神经元可能对输入“至少有一个1”的情况激活值很高类似OR门。而W2和b2则学习如何加权这两个中间特征来得到最终结果。每次训练的具体数值可能不同但它们实现的功能是等价的。注意事项神经网络的权重解通常不是唯一的。不同的随机初始化和训练过程可能得到数值不同但功能等价的权重集。这被称为“权重空间对称性”。所以如果你的权重和别人的看起来不一样但只要模型能正确工作就都是对的。6. 常见问题、调试技巧与扩展思考在实际动手实现时你几乎一定会遇到一些问题。下面是我在多次实现中总结的“避坑指南”。6.1 训练不收敛或损失震荡这是最常见的问题。可能的原因和解决方法如下学习率过大或过小这是首要怀疑对象。解决方法尝试一个数量级的变化。比如从0.5改为0.1或0.05。可以绘制损失曲线如果曲线剧烈上下跳动是学习率太大如果下降极其缓慢是学习率太小。权重初始化不当如果初始权重太大Sigmoid函数的输入会落在饱和区梯度接近0导致训练缓慢甚至停滞。解决方法确保使用我们代码中的“小随机数初始化”如乘以0.1。更高级的方法可以使用Xavier或He初始化。没有对梯度进行归一化在我们的backward_propagation函数中dW和db都除以了样本数m这是正确的做法确保了梯度大小与批量大小无关。如果忘了这一步当使用全批量数据4个样本时可能问题不大但若未来使用更大批量梯度会非常大导致不稳定。激活函数饱和Sigmoid函数在输入很大或很小时梯度接近于0导致“梯度消失”。对于这个简单网络和问题通常不会发生但若初始化权重很大或学习率设置导致权重激增就可能发生。解决方法除了规范初始化还可以在代码中加入梯度裁剪np.clip(grad, -5, 5)或尝试使用ReLU作为隐藏层激活函数但输出层仍需Sigmoid以保证输出在0-1。6.2 模型预测精度达不到100%在异或问题上我们的网络容量2-2-1是足够的理论上应该能达到100%准确率。如果达不到请检查训练轮数Epochs是否足够有时需要更多轮次才能收敛到极小的损失。将epochs增加到20000或50000试试。损失函数值是否已接近0查看最终几轮的损失值。如果损失已经降到1e-5以下但预测仍有误可能是阈值问题。我们的预测使用了A2 0.5。如果某个输出的A2是0.499它会被判为0但实际可能非常接近决策边界。可以尝试输出final_outputs看看原始概率值。代码Bug仔细核对反向传播公式特别是矩阵的维度。一个快速的检查方法是使用梯度检查Gradient Checking。虽然对于这个小网络不是必须但它是调试复杂网络的神器。其原理是用数值方法通过微小扰动参数计算损失的变化估算梯度与反向传播计算的解析梯度对比两者应该非常接近。6.3 项目扩展与思考完成基础版本后你可以尝试以下挑战深化理解增加隐藏层神经元将隐藏层神经元从2个增加到3个、4个甚至10个。观察训练速度、决策边界形状的变化。你会发现神经元越多模型能力越强但在这个问题上会过拟合决策边界可能更复杂。更换激活函数将隐藏层的Sigmoid换成Tanh或ReLU。需要修改forward_propagation和sigmoid_derivative函数。观察收敛速度和最终效果有何不同。ReLU通常能加速训练。实现Mini-Batch梯度下降当前我们使用的是全批量梯度下降Batch Gradient Descent。尝试将4个样本分成2个Mini-Batch在每个batch后更新权重。你需要修改训练循环和数据加载部分。添加L2正则化在损失函数中加入权重的L2范数作为惩罚项防止过拟合虽然在这个小数据集上不明显。这需要修改compute_cost和backward_propagation函数。可视化训练动态创建一个动画展示随着训练进行决策边界是如何一步步从一条随机曲线演变到最终能将四个点完美分开的形状的。这非常直观但需要一些matplotlib.animation的技巧。手动实现这个简单的两层感知机其价值远超项目本身。它强迫你理解每一个矩阵乘法的维度、每一次梯度计算的意义、每一个超参数的影响。当你未来使用model.compile()和model.fit()时你会清楚地知道在那些简洁的API调用背后究竟发生了什么。这才是你从“调包侠”迈向“炼丹师”的第一步。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价