三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

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

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

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)就能让单层感知机解决异或。这是不对的。只要没有隐藏层,无论激活函数多复杂,其最终的决策边界仍然是输入特征的线性组合(尽管激活函数是非线性的,但分类边界由wX+b=阈值决定,这依然是线性的)。问题的核心在于模型的“容量”,即其能够表达的函数的复杂程度,单层结构容量不足。

2.2 隐藏层:构造新特征与空间变换

引入一个隐藏层,游戏规则就完全改变了。我们以最经典的两层结构(一个隐藏层,一个输出层)为例:

  • 输入层:两个神经元,对应输入x1和x2。
  • 隐藏层:假设我们设置两个神经元(这是解决异或问题的最小可行配置之一)。
  • 输出层:一个神经元,给出最终预测结果。

这个过程可以分两步理解:

  1. 特征变换(隐藏层的作用):隐藏层的每个神经元都学习到输入特征的一种线性组合,然后通过一个非线性激活函数(如Sigmoid、ReLU)。这相当于将原始数据点从(x1, x2)坐标系,映射到了一个新的特征空间(h1, h2)。在这个新空间里,数据点的分布被“扭曲”了。
  2. 线性分类(输出层的作用):输出层神经元再对隐藏层输出的新特征(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))

前向传播过程

  1. 隐藏层输入与激活Z1 = X · W1 + b1·表示矩阵乘,这里X是单个样本,扩展为1x2)A1 = sigmoid(Z1)A1就是隐藏层的输出,也是我们构造的新特征。
  2. 输出层输入与激活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)的梯度,然后使用梯度下降法更新参数。这是本项目最核心的数学部分。

链式求导过程

  1. 计算输出层梯度

    • 损失对输出层输入的梯度: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可能来自批量数据)
  2. 计算隐藏层梯度

    • 损失对隐藏层输出的梯度:dA1 = dZ2 · W2.T
    • 损失对隐藏层输入的梯度:dZ1 = dA1 * sigmoid'(Z1) = dA1 * A1 * (1 - A1)
    • 权重W1和偏置b1的梯度:dW1 = X.T · dZ1db1 = sum(dZ1)

实操心得:手动推导一遍反向传播的公式至关重要。即使未来你99%的时间都在用PyTorchTensorFlowautograd,理解底层原理也能让你在模型不收敛、梯度爆炸/消失时,有清晰的调试思路。你可以把这些公式写在一张便签上,对照着代码实现。

3.3 参数更新与学习率选择

得到梯度后,使用梯度下降法更新参数:W = W - learning_rate * dWb = b - learning_rate * db

学习率(Learning Rate)的选择:这是训练神经网络最重要的超参数之一。对于这个小网络,学习率太大(如>1.0)会导致损失震荡甚至发散;学习率太小(如<0.01)则收敛极慢。经过实践,0.10.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, axis=0, keepdims=True) # (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, axis=0, keepdims=True) # (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 parameters

4.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(f"Epoch {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, cmap=plt.cm.Spectral, alpha=0.8) # 绘制原始数据点 plt.scatter(X[:, 0], X[:, 1], c=y.ravel(), s=40, cmap=plt.cm.Spectral, edgecolors='k') 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'])

通过观察W1b1A1,你可能会发现一些模式。例如,一个隐藏神经元可能对输入“都是1”的情况激活值很低(类似NAND门),另一个神经元可能对输入“至少有一个1”的情况激活值很高(类似OR门)。而W2b2则学习如何加权这两个中间特征来得到最终结果。每次训练的具体数值可能不同,但它们实现的功能是等价的。

注意事项:神经网络的权重解通常不是唯一的。不同的随机初始化和训练过程可能得到数值不同但功能等价的权重集。这被称为“权重空间对称性”。所以,如果你的权重和别人的看起来不一样,但只要模型能正确工作,就都是对的。

6. 常见问题、调试技巧与扩展思考

在实际动手实现时,你几乎一定会遇到一些问题。下面是我在多次实现中总结的“避坑指南”。

6.1 训练不收敛或损失震荡

这是最常见的问题。可能的原因和解决方法如下:

  1. 学习率过大或过小:这是首要怀疑对象。解决方法:尝试一个数量级的变化。比如从0.5改为0.1或0.05。可以绘制损失曲线,如果曲线剧烈上下跳动,是学习率太大;如果下降极其缓慢,是学习率太小。
  2. 权重初始化不当:如果初始权重太大,Sigmoid函数的输入会落在饱和区(梯度接近0),导致训练缓慢甚至停滞。解决方法:确保使用我们代码中的“小随机数初始化”(如乘以0.1)。更高级的方法可以使用Xavier或He初始化。
  3. 没有对梯度进行归一化:在我们的backward_propagation函数中,dWdb都除以了样本数m,这是正确的做法,确保了梯度大小与批量大小无关。如果忘了这一步,当使用全批量数据(4个样本)时可能问题不大,但若未来使用更大批量,梯度会非常大,导致不稳定。
  4. 激活函数饱和:Sigmoid函数在输入很大或很小时梯度接近于0,导致“梯度消失”。对于这个简单网络和问题,通常不会发生,但若初始化权重很大或学习率设置导致权重激增,就可能发生。解决方法:除了规范初始化,还可以在代码中加入梯度裁剪(np.clip(grad, -5, 5))或尝试使用ReLU作为隐藏层激活函数(但输出层仍需Sigmoid以保证输出在0-1)。

6.2 模型预测精度达不到100%

在异或问题上,我们的网络容量(2-2-1)是足够的,理论上应该能达到100%准确率。如果达不到,请检查:

  1. 训练轮数(Epochs)是否足够:有时需要更多轮次才能收敛到极小的损失。将epochs增加到20000或50000试试。
  2. 损失函数值是否已接近0:查看最终几轮的损失值。如果损失已经降到1e-5以下,但预测仍有误,可能是阈值问题。我们的预测使用了A2 > 0.5。如果某个输出的A2是0.499,它会被判为0,但实际可能非常接近决策边界。可以尝试输出final_outputs看看原始概率值。
  3. 代码Bug:仔细核对反向传播公式,特别是矩阵的维度。一个快速的检查方法是使用梯度检查(Gradient Checking)。虽然对于这个小网络不是必须,但它是调试复杂网络的神器。其原理是用数值方法(通过微小扰动参数计算损失的变化)估算梯度,与反向传播计算的解析梯度对比,两者应该非常接近。

6.3 项目扩展与思考

完成基础版本后,你可以尝试以下挑战,深化理解:

  1. 增加隐藏层神经元:将隐藏层神经元从2个增加到3个、4个甚至10个。观察训练速度、决策边界形状的变化。你会发现,神经元越多,模型能力越强(但在这个问题上会过拟合),决策边界可能更复杂。
  2. 更换激活函数:将隐藏层的Sigmoid换成Tanh或ReLU。需要修改forward_propagationsigmoid_derivative函数。观察收敛速度和最终效果有何不同。ReLU通常能加速训练。
  3. 实现Mini-Batch梯度下降:当前我们使用的是全批量梯度下降(Batch Gradient Descent)。尝试将4个样本分成2个Mini-Batch,在每个batch后更新权重。你需要修改训练循环和数据加载部分。
  4. 添加L2正则化:在损失函数中加入权重的L2范数作为惩罚项,防止过拟合(虽然在这个小数据集上不明显)。这需要修改compute_costbackward_propagation函数。
  5. 可视化训练动态:创建一个动画,展示随着训练进行,决策边界是如何一步步从一条随机曲线演变到最终能将四个点完美分开的形状的。这非常直观,但需要一些matplotlib.animation的技巧。

手动实现这个简单的两层感知机,其价值远超项目本身。它强迫你理解每一个矩阵乘法的维度、每一次梯度计算的意义、每一个超参数的影响。当你未来使用model.compile()model.fit()时,你会清楚地知道在那些简洁的API调用背后,究竟发生了什么。这才是你从“调包侠”迈向“炼丹师”的第一步。

← 返回列表