为什么单层感知机搞不定XOR?用Python手把手教你实现多层感知机(MLP)

如果你刚开始接触神经网络,可能会觉得单层感知机(Perceptron)已经足够强大——它能解决“与”(AND)、“或”(OR)这样的逻辑问题。但当你信心满满地用它去处理“异或”(XOR)问题时,却发现无论怎么调整权重和偏置,模型始终无法收敛,预测结果一塌糊涂。这不是你的代码写错了,而是单层感知机天生的结构缺陷。这个看似简单的XOR问题,在1969年由马文·明斯基(Marvin Minsky)明确提出后,曾一度让整个神经网络研究陷入低谷,被称为“AI的第一次寒冬”。

那么,为什么一个能处理AND和OR的模型,却对XOR束手无策?答案隐藏在“线性可分”这个概念里。单层感知机本质上是一个线性分类器,它只能在特征空间里画一条直线(或超平面)来分割数据。而XOR问题的数据点分布,恰恰无法用一条直线完美分开——它在二维平面上构成了一个经典的“交叉”模式。要解决这个问题,我们需要引入更强大的武器:多层感知机(Multilayer Perceptron, MLP)。MLP通过增加隐藏层和非线性激活函数,让网络具备了学习复杂非线性关系的能力。今天,我们就从最直观的几何视角出发,彻底理解这个瓶颈,并用Python从零开始构建一个能解决XOR问题的MLP。

1. 单层感知机的局限:线性可分的边界

让我们先回顾一下单层感知机的基本结构。它接收输入向量 x,计算加权和 w·x + b,然后通过一个激活函数(如阶跃函数或Sigmoid)产生输出。从几何上看,这个计算过程就是在特征空间中寻找一个线性决策边界。

决策边界 就是方程 w·x + b = 0 所定义的直线(二维)或超平面(高维)。感知机的学习过程,就是不断调整 wb,让这条直线把不同类别的数据点分开。

为了更直观,我们可以用Python快速可视化AND、OR和XOR的数据分布,并观察单层感知机能否画出那条分割线。

import numpy as np
import matplotlib.pyplot as plt

# 定义AND, OR, XOR的真值表 (输入x1, x2, 输出y)
data = {
    '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)]
}

# 可视化函数
def plot_logic_gate(data, title):
    fig, ax = plt.subplots(figsize=(4, 4))
    for (x1, x2, y) in data:
        color = 'red' if y == 1 else 'blue'
        marker = 'o' if y == 1 else 's'
        ax.scatter(x1, x2, c=color, marker=marker, s=200, edgecolors='black')
    ax.set_xlim(-0.5, 1.5)
    ax.set_ylim(-0.5, 1.5)
    ax.grid(True, linestyle='--', alpha=0.6)
    ax.set_title(title, fontsize=14)
    ax.set_xlabel('x1', fontsize=12)
    ax.set_ylabel('x2', fontsize=12)
    return ax

# 绘制三个逻辑运算的散点图
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
for idx, (gate, points) in enumerate(data.items()):
    ax = axes[idx]
    plot_logic_gate(points, gate)
plt.tight_layout()
plt.show()

运行这段代码,你会看到三张图。AND和OR的数据点中,同类别的点(比如输出为1的点)聚集在一起,可以用一条直线把红点和蓝点分开。但XOR的图中,两个红点((0,1)和(1,0))位于对角位置,两个蓝点((0,0)和(1,1))位于另一条对角线上——没有任何一条直线能同时把红点和蓝点划到两侧。这就是“线性不可分”的典型例子。

提示:你可以尝试在XOR的图上手动画线,会发现无论如何画,总有一个红点和一个蓝点落在直线的同一侧。这个直观的困境,就是单层感知机的理论天花板。

从数学上严格证明单层感知机无法解决XOR问题,可以通过反证法。假设存在一组权重 w = (w1, w2) 和偏置 b,使得感知机能够正确分类XOR的四个点。那么我们需要满足以下四个不等式:

  1. 对于 (0,0): w10 + w20 + b <= 0 (输出应为0)
  2. 对于 (0,1): w10 + w21 + b > 0 (输出应为1)
  3. 对于 (1,0): w11 + w20 + b > 0 (输出应为1)
  4. 对于 (1,1): w11 + w21 + b <= 0 (输出应为0)

将不等式(2)和(3)相加,得到 w1 + w2 + 2b > 0。但不等式(1)告诉我们 b <= 0,不等式(4)告诉我们 w1 + w2 + b <= 0。结合这两个条件,可以推导出矛盾。因此,不存在这样的 wb 能满足所有条件。

这个证明揭示了单层感知机的根本限制:它只能表达线性函数。要突破这个限制,我们需要引入非线性变换,而这就是多层感知机的核心思想。

2. 多层感知机(MLP)的破局之道:非线性变换与隐藏层

既然一条直线不够,那我们就用多条直线来组合。多层感知机通过在输入层和输出层之间插入一个或多个隐藏层,让网络能够学习更复杂的决策边界。每个隐藏层由多个神经元(或称单元)组成,每个神经元都会对前一层的输出进行线性变换,然后通过一个非线性激活函数

关键洞察:隐藏层的作用是对原始输入特征进行非线性变换,将数据映射到一个新的特征空间。在这个新空间中,原本线性不可分的数据可能变得线性可分。

让我们用XOR问题来具体说明这个思想。考虑一个最简单的MLP结构:输入层(2个神经元,对应x1和x2)、一个隐藏层(2个神经元)、输出层(1个神经元)。这个网络的计算过程可以分解为两步:

  1. 隐藏层变换:将原始输入 (x1, x2) 映射到中间表示 (h1, h2)
  2. 输出层决策:基于 (h1, h2) 做出最终分类

神奇的是,通过精心设计(或学习到)的权重,隐藏层可以将XOR的四个点映射到三维空间,使它们变得线性可分。下面这个表格展示了一种可能的映射:

原始输入 (x1, x2) 隐藏层输出 (h1, h2) 最终输出 (y)
(0, 0) (0, 0) 0
(0, 1) (1, 0) 1
(1, 0) (1, 0) 1
(1, 1) (0, 1) 0

在这个例子中,隐藏层实际上学习了两个简单的逻辑函数:

  • h1 = x1 AND (NOT x2) OR x2 AND (NOT x1) 的某种组合
  • h2 = x1 AND x2

在 (h1, h2) 这个新的二维空间中,原本的XOR问题变成了一个简单的线性可分问题:所有输出为1的点都落在h1轴上,输出为0的点落在原点或h2轴上。

为什么需要非线性激活函数? 如果隐藏层只进行线性变换(即不使用激活函数,或者使用线性激活函数),那么整个多层网络仍然等价于一个单层线性模型。数学上可以证明:多个线性变换的复合仍然是线性变换。假设我们有一个两层的线性网络:

h = W1·x + b1
y = W2·h + b2

将h代入y的表达式:

y = W2·(W1·x + b1) + b2 = (W2·W1)·x + (W2·b1 + b2)

这可以简化为 y = W'·x + b' 的形式,其中 W' = W2·W1,b' = W2·b1 + b2。看,它又变回了一个单层线性模型!因此,非线性激活函数是MLP获得强大表达能力的必要条件

常用的非线性激活函数包括:

激活函数 公式 特点与适用场景
Sigmoid σ(x) = 1 / (1 + e^{-x}) 将输入压缩到(0,1),适合二分类输出层,但容易导致梯度消失
Tanh tanh(x) = (e^x - e^{-x}) / (e^x + e^{-x}) 输出范围(-1,1),零中心化,比Sigmoid梯度特性稍好
ReLU ReLU(x) = max(0, x) 计算简单,缓解梯度消失,是现代深度网络最常用的激活函数
Leaky ReLU LReLU(x) = max(αx, x), α≈0.01 ReLU的改进,解决“神经元死亡”问题

在接下来的实现中,我们将主要使用ReLU,因为它的计算效率高且在实践中表现良好。

3. 从零实现:用NumPy构建MLP解决XOR问题

现在,让我们动手实现一个能够解决XOR问题的多层感知机。我们将完全使用NumPy,不依赖任何深度学习框架,以便深入理解MLP的每个细节。我们的网络结构如下:

  • 输入层:2个神经元(对应x1, x2)
  • 隐藏层:4个神经元(使用ReLU激活)
  • 输出层:1个神经元(使用Sigmoid激活,输出0-1之间的概率值)

首先,我们需要实现前向传播和反向传播(反向传播算法是训练神经网络的核心,它通过链式法则计算损失函数对每个参数的梯度)。

import numpy as np

class MLP:
    def __init__(self, input_size=2, hidden_size=4, output_size=1):
        # 初始化权重和偏置
        # 使用He初始化,适合ReLU激活函数
        self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size)
        self.b1 = np.zeros((1, hidden_size))
        self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size)
        self.b2 = np.zeros((1, output_size))
        
    def relu(self, x):
        """ReLU激活函数"""
        return np.maximum(0, x)
    
    def relu_derivative(self, x):
        """ReLU的导数"""
        return (x > 0).astype(float)
    
    def sigmoid(self, x):
        """Sigmoid激活函数"""
        # 数值稳定版本,防止溢出
        x = np.clip(x, -500, 500)  # 防止exp溢出
        return 1 / (1 + np.exp(-x))
    
    def sigmoid_derivative(self, x):
        """Sigmoid的导数"""
        s = self.sigmoid(x)
        return s * (1 - s)
    
    def forward(self, X):
        """前向传播"""
        # 隐藏层
        self.z1 = np.dot(X, self.W1) + self.b1  # 线性变换
        self.a1 = self.relu(self.z1)            # 非线性激活
        
        # 输出层
        self.z2 = np.dot(self.a1, self.W2) + self.b2
        self.a2 = self.sigmoid(self.z2)         # 输出概率
        
        return self.a2
    
    def backward(self, X, y, output, learning_rate=0.1):
        """反向传播,更新参数"""
        m = X.shape[0]  # 样本数量
        
        # 输出层的误差和梯度
        dZ2 = output - y  # 交叉熵损失对z2的导数
        dW2 = (1/m) * np.dot(self.a1.T, dZ2)
        db2 = (1/m) * np.sum(dZ2, axis=0, keepdims=True)
        
        # 隐藏层的误差和梯度
        dA1 = np.dot(dZ2, self.W2.T)
        dZ1 = dA1 * self.relu_derivative(self.z1)
        dW1 = (1/m) * np.dot(X.T, dZ1)
        db1 = (1/m) * np.sum(dZ1, axis=0, keepdims=True)
        
        # 更新参数
        self.W2 -= learning_rate * dW2
        self.b2 -= learning_rate * db2
        self.W1 -= learning_rate * dW1
        self.b1 -= learning_rate * db1
    
    def compute_loss(self, y_true, y_pred):
        """计算二元交叉熵损失"""
        # 添加小常数防止log(0)
        epsilon = 1e-15
        y_pred = np.clip(y_pred, epsilon, 1 - epsilon)
        return -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))
    
    def predict(self, X, threshold=0.5):
        """预测函数"""
        prob = self.forward(X)
        return (prob > threshold).astype(int)
    
    def train(self, X, y, epochs=10000, learning_rate=0.1, verbose=1000):
        """训练函数"""
        losses = []
        for epoch in range(epochs):
            # 前向传播
            output = self.forward(X)
            
            # 计算损失
            loss = self.compute_loss(y, output)
            losses.append(loss)
            
            # 反向传播
            self.backward(X, y, output, learning_rate)
            
            # 打印训练进度
            if verbose and (epoch + 1) % verbose == 0:
                predictions = self.predict(X)
                accuracy = np.mean(predictions == y)
                print(f"Epoch {epoch+1}/{epochs}, Loss: {loss:.6f}, Accuracy: {accuracy:.4f}")
        
        return losses

# 准备XOR数据
X_xor = np.array([[0, 0], [0, 1], [1, 0], [1, 1]], dtype=np.float32)
y_xor = np.array([[0], [1], [1], [0]], dtype=np.float32)

# 创建并训练MLP
mlp = MLP(input_size=2, hidden_size=4, output_size=1)
print("开始训练MLP解决XOR问题...")
losses = mlp.train(X_xor, y_xor, epochs=10000, learning_rate=0.1, verbose=2500)

# 测试训练结果
print("\n训练完成!测试结果:")
predictions = mlp.predict(X_xor)
for i in range(len(X_xor)):
    print(f"输入: {X_xor[i]}, 预测: {predictions[i][0]}, 真实: {y_xor[i][0]}")

这段代码实现了一个完整的两层MLP。让我们拆解几个关键部分:

前向传播流程

  1. 输入X(形状:4×2,4个样本,每个样本2个特征)
  2. 计算隐藏层:z1 = X·W1 + b1,然后应用ReLU:a1 = relu(z1)
  3. 计算输出层:z2 = a1·W2 + b2,然后应用Sigmoid:a2 = sigmoid(z2)

反向传播的核心公式(使用交叉熵损失): 对于输出层(Sigmoid激活):

  • 误差项:dZ2 = A2 - Y
  • 权重梯度:dW2 = (1/m) * A1^T · dZ2
  • 偏置梯度:db2 = (1/m) * sum(dZ2)

对于隐藏层(ReLU激活):

  • 误差项:dZ1 = dA1 ⊙ relu'(Z1),其中 dA1 = dZ2 · W2^T
  • 权重梯度:dW1 = (1/m) * X^T · dZ1
  • 偏置梯度:db1 = (1/m) * sum(dZ1)

这里 表示逐元素乘法,relu' 是ReLU的导数(输入>0时为1,否则为0)。

运行这段代码,你会看到MLP经过几千次迭代后,能够完美地学习XOR函数,准确率达到100%。这验证了多层结构配合非线性激活函数确实能解决单层感知机无法处理的问题。

4. 可视化决策边界:看MLP如何“画”出非线性分割

理解MLP如何解决XOR问题的最直观方式,就是可视化它的决策边界。我们可以生成一个密集的网格点,用训练好的MLP预测每个点的类别,然后绘制出分类区域。

import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap

def plot_decision_boundary(model, X, y, title="MLP决策边界"):
    """绘制MLP的决策边界"""
    # 创建网格点
    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
    xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.02),
                         np.arange(y_min, y_max, 0.02))
    
    # 预测网格上每个点的类别
    Z = model.predict(np.c_[xx.ravel(), yy.ravel()])
    Z = Z.reshape(xx.shape)
    
    # 绘制决策区域
    plt.figure(figsize=(10, 8))
    cmap_background = ListedColormap(['#FFAAAA', '#AAAAFF'])
    plt.contourf(xx, yy, Z, alpha=0.3, cmap=cmap_background)
    
    # 绘制原始数据点
    colors = ['blue' if label == 0 else 'red' for label in y.flatten()]
    plt.scatter(X[:, 0], X[:, 1], c=colors, edgecolors='black', 
                s=200, alpha=0.9, marker='o' if y.flatten()[0] == 0 else 's')
    
    # 标注每个点
    for i, (x, y_val) in enumerate(zip(X, y.flatten())):
        plt.text(x[0]+0.05, x[1]+0.05, f'({int(x[0])},{int(x[1])})->{int(y_val)}', 
                 fontsize=12, fontweight='bold')
    
    plt.xlim(x_min, x_max)
    plt.ylim(y_min, y_max)
    plt.xlabel('x1', fontsize=14)
    plt.ylabel('x2', fontsize=14)
    plt.title(title, fontsize=16)
    plt.grid(True, alpha=0.3)
    plt.show()

# 绘制MLP的决策边界
print("绘制MLP的决策边界...")
plot_decision_boundary(mlp, X_xor, y_xor, "MLP对XOR问题的决策边界")

# 对比:尝试用逻辑回归(单层)解决XOR问题
from sklearn.linear_model import LogisticRegression

# 训练逻辑回归模型
lr = LogisticRegression()
lr.fit(X_xor, y_xor.flatten())

# 自定义预测函数以匹配我们的接口
class LogisticRegressionWrapper:
    def __init__(self, model):
        self.model = model
    
    def predict(self, X):
        return self.model.predict(X).reshape(-1, 1)

lr_wrapper = LogisticRegressionWrapper(lr)
plot_decision_boundary(lr_wrapper, X_xor, y_xor, "逻辑回归对XOR问题的决策边界")

运行这段可视化代码,你会看到两幅对比图。MLP的决策边界是非线性的——它可能是一条曲线或者由多个直线段组成的复杂形状,能够完美地将四个点分开。而逻辑回归(本质上是单层感知机)的决策边界永远是一条直线,无论怎么调整,都无法正确分类所有四个点。

这种可视化清楚地展示了为什么MLP能解决XOR问题:它通过隐藏层的非线性变换,将原始二维空间扭曲变形,在新的表示空间中,数据变得线性可分。输出层只需要在这个扭曲后的空间里画一条简单的直线(或超平面)就能完成任务。

5. 深入MLP内部:权重分析与网络容量讨论

为了更深入理解MLP如何工作,让我们检查训练后网络的权重,并讨论网络容量(即模型复杂度)对学习能力的影响。

def analyze_network_weights(mlp_model):
    """分析网络权重"""
    print("=== 网络权重分析 ===")
    print(f"W1形状: {mlp_model.W1.shape}")
    print(f"b1形状: {mlp_model.b1.shape}")
    print(f"W2形状: {mlp_model.W2.shape}")
    print(f"b2形状: {mlp_model.b2.shape}")
    print("\n隐藏层权重W1:")
    print(mlp_model.W1)
    print("\n隐藏层偏置b1:")
    print(mlp_model.b1)
    print("\n输出层权重W2:")
    print(mlp_model.W2)
    print("\n输出层偏置b2:")
    print(mlp_model.b2)
    
    # 计算权重绝对值之和,作为网络复杂度的粗略度量
    total_weight_magnitude = np.sum(np.abs(mlp_model.W1)) + np.sum(np.abs(mlp_model.b1)) + \
                            np.sum(np.abs(mlp_model.W2)) + np.sum(np.abs(mlp_model.b2))
    print(f"\n权重总绝对值: {total_weight_magnitude:.4f}")
    
    # 检查隐藏层激活模式
    print("\n=== 隐藏层激活分析 ===")
    for i, (x, y_true) in enumerate(zip(X_xor, y_xor)):
        # 前向传播到隐藏层
        z1 = np.dot(x.reshape(1, -1), mlp_model.W1) + mlp_model.b1
        a1 = mlp_model.relu(z1)
        print(f"输入 {x}: 隐藏层激活值 = {a1.flatten()}, 激活神经元数 = {np.sum(a1 > 0)}/4")

analyze_network_weights(mlp)

# 实验:不同隐藏层大小对学习XOR的影响
def experiment_hidden_sizes():
    """实验不同隐藏层大小对MLP性能的影响"""
    hidden_sizes = [1, 2, 3, 4, 8, 16]
    results = []
    
    for hidden_size in hidden_sizes:
        print(f"\n{'='*50}")
        print(f"测试隐藏层大小: {hidden_size}")
        
        # 训练多个随机初始化的模型,取平均表现
        accuracies = []
        losses = []
        
        for trial in range(5):  # 5次随机初始化
            model = MLP(input_size=2, hidden_size=hidden_size, output_size=1)
            # 训练更多轮次确保收敛
            model.train(X_xor, y_xor, epochs=15000, learning_rate=0.1, verbose=0)
            
            # 计算准确率
            preds = model.predict(X_xor)
            acc = np.mean(preds == y_xor)
            accuracies.append(acc)
            
            # 计算最终损失
            final_output = model.forward(X_xor)
            final_loss = model.compute_loss(y_xor, final_output)
            losses.append(final_loss)
        
        avg_acc = np.mean(accuracies)
        avg_loss = np.mean(losses)
        results.append((hidden_size, avg_acc, avg_loss))
        
        print(f"平均准确率: {avg_acc:.4f}, 平均损失: {avg_loss:.6f}")
        print(f"最佳准确率: {max(accuracies):.4f}, 最差准确率: {min(accuracies):.4f}")
    
    # 可视化结果
    sizes, accs, losses = zip(*results)
    
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
    
    ax1.plot(sizes, accs, 'o-', linewidth=2, markersize=8)
    ax1.set_xlabel('隐藏层神经元数量', fontsize=12)
    ax1.set_ylabel('平均准确率', fontsize=12)
    ax1.set_title('隐藏层大小 vs 准确率', fontsize=14)
    ax1.grid(True, alpha=0.3)
    ax1.set_ylim(0, 1.05)
    
    ax2.plot(sizes, losses, 's-', linewidth=2, markersize=8, color='red')
    ax2.set_xlabel('隐藏层神经元数量', fontsize=12)
    ax2.set_ylabel('平均损失', fontsize=12)
    ax2.set_title('隐藏层大小 vs 损失', fontsize=14)
    ax2.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    return results

# 运行实验
print("\n开始隐藏层大小实验...")
experiment_results = experiment_hidden_sizes()

这个分析揭示了几个重要见解:

  1. 权重模式:训练后的权重会形成特定的模式。对于XOR问题,你可能会发现某些隐藏神经元专门响应特定的输入组合(如"x1 AND NOT x2"或"NOT x1 AND x2")。

  2. 网络容量与过拟合:当隐藏层神经元太少(如1-2个),网络可能没有足够的表达能力来学习XOR函数。当神经元数量适中(如4个),网络通常能很好地学习。当神经元过多时,虽然仍能解决XOR问题,但权重可能变得复杂且难以解释。

  3. 随机初始化的影响:由于梯度下降可能陷入局部最优,不同的随机初始化可能导致不同的最终权重。这就是为什么我们在实验中多次运行并取平均值。

  4. 激活模式分析:通过观察隐藏层的激活值,我们可以看到网络内部如何表示不同的输入。例如,对于输入(0,1)和(1,0),虽然它们属于同一类(输出1),但可能激活不同的隐藏神经元组合。

注意:在实际项目中,选择适当的网络容量(层数和每层神经元数)是一个重要的超参数调优问题。太小的网络可能欠拟合(无法学习复杂模式),太大的网络可能过拟合(记忆训练数据但泛化能力差)。

6. 超越XOR:MLP在更复杂问题中的应用

虽然XOR是一个简单的教学示例,但MLP的能力远不止于此。通过增加层数和神经元,MLP可以逼近任意复杂的连续函数(这是通用近似定理的核心内容)。让我们看一个更复杂的例子:学习圆形分类问题。

def create_circle_data(n_samples=1000, noise=0.1):
    """创建圆形分类数据集"""
    # 生成两个同心圆
    n_samples_per_class = n_samples // 2
    
    # 内圈(类别0)
    radius_inner = 0.5
    theta = np.random.rand(n_samples_per_class) * 2 * np.pi
    r = radius_inner + np.random.randn(n_samples_per_class) * noise
    X_inner = np.column_stack([r * np.cos(theta), r * np.sin(theta)])
    y_inner = np.zeros(n_samples_per_class)
    
    # 外圈(类别1)
    radius_outer = 1.0
    theta = np.random.rand(n_samples_per_class) * 2 * np.pi
    r = radius_outer + np.random.randn(n_samples_per_class) * noise
    X_outer = np.column_stack([r * np.cos(theta), r * np.sin(theta)])
    y_outer = np.ones(n_samples_per_class)
    
    # 合并
    X = np.vstack([X_inner, X_outer])
    y = np.hstack([y_inner, y_outer]).reshape(-1, 1)
    
    # 打乱顺序
    indices = np.random.permutation(n_samples)
    return X[indices], y[indices]

def train_mlp_on_circle():
    """在圆形数据上训练MLP"""
    # 创建数据
    X_circle, y_circle = create_circle_data(n_samples=1000, noise=0.1)
    
    # 分割训练集和测试集
    split_idx = int(0.8 * len(X_circle))
    X_train, X_test = X_circle[:split_idx], X_circle[split_idx:]
    y_train, y_test = y_circle[:split_idx], y_circle[split_idx:]
    
    # 创建更大的MLP(处理更复杂的问题需要更多容量)
    mlp_circle = MLP(input_size=2, hidden_size=16, output_size=1)
    
    # 训练
    print("训练MLP解决圆形分类问题...")
    losses = mlp_circle.train(X_train, y_train, epochs=5000, learning_rate=0.1, verbose=1000)
    
    # 评估
    train_preds = mlp_circle.predict(X_train)
    test_preds = mlp_circle.predict(X_test)
    train_acc = np.mean(train_preds == y_train)
    test_acc = np.mean(test_preds == y_test)
    
    print(f"\n训练准确率: {train_acc:.4f}")
    print(f"测试准确率: {test_acc:.4f}")
    
    # 可视化决策边界
    plt.figure(figsize=(12, 5))
    
    # 左图:原始数据
    plt.subplot(1, 2, 1)
    plt.scatter(X_circle[y_circle.flatten()==0, 0], X_circle[y_circle.flatten()==0, 1], 
                c='blue', alpha=0.6, label='类别 0', edgecolors='black')
    plt.scatter(X_circle[y_circle.flatten()==1, 0], X_circle[y_circle.flatten()==1, 1], 
                c='red', alpha=0.6, label='类别 1', edgecolors='black')
    plt.xlabel('x1', fontsize=12)
    plt.ylabel('x2', fontsize=12)
    plt.title('圆形分类数据集', fontsize=14)
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    # 右图:决策边界
    plt.subplot(1, 2, 2)
    
    # 创建网格
    x_min, x_max = X_circle[:, 0].min() - 0.5, X_circle[:, 0].max() + 0.5
    y_min, y_max = X_circle[:, 1].min() - 0.5, X_circle[:, 1].max() + 0.5
    xx, yy = np.meshgrid(np.linspace(x_min, x_max, 200),
                         np.linspace(y_min, y_max, 200))
    
    # 预测网格点
    Z = mlp_circle.predict(np.c_[xx.ravel(), yy.ravel()])
    Z = Z.reshape(xx.shape)
    
    # 绘制决策边界
    from matplotlib.colors import ListedColormap
    cmap_background = ListedColormap(['#AAAAFF', '#FFAAAA'])
    plt.contourf(xx, yy, Z, alpha=0.3, cmap=cmap_background)
    
    # 绘制测试集数据点
    plt.scatter(X_test[y_test.flatten()==0, 0], X_test[y_test.flatten()==0, 1], 
                c='blue', alpha=0.8, label='测试集 类别 0', edgecolors='black', s=50)
    plt.scatter(X_test[y_test.flatten()==1, 0], X_test[y_test.flatten()==1, 1], 
                c='red', alpha=0.8, label='测试集 类别 1', edgecolors='black', s=50)
    
    plt.xlabel('x1', fontsize=12)
    plt.ylabel('x2', fontsize=12)
    plt.title('MLP决策边界 (测试集)', fontsize=14)
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    # 绘制训练损失曲线
    plt.figure(figsize=(8, 5))
    plt.plot(losses, linewidth=2)
    plt.xlabel('训练轮次', fontsize=12)
    plt.ylabel('损失', fontsize=12)
    plt.title('MLP训练损失曲线 (圆形分类)', fontsize=14)
    plt.grid(True, alpha=0.3)
    plt.yscale('log')  # 对数尺度,更容易观察损失下降
    plt.show()
    
    return mlp_circle, train_acc, test_acc

# 运行圆形分类实验
mlp_circle_model, train_acc, test_acc = train_mlp_on_circle()

圆形分类问题比XOR更复杂,因为决策边界需要是一个闭合曲线(圆形)。单层感知机完全无法处理这种问题,因为它只能产生直线边界。但我们的MLP(带有16个隐藏神经元)能够学习到一个近似圆形的决策边界。

这个实验展示了MLP的几个重要特性:

  1. 非线性决策边界:MLP可以学习复杂的非线性决策边界,如圆形、椭圆形或更复杂的形状。

  2. 从数据中学习:我们不需要手动设计特征或指定边界形状。MLP直接从数据中学习适当的表示和决策边界。

  3. 泛化能力:通过在测试集上评估,我们可以看到MLP不仅记忆了训练数据,还学会了泛化到未见过的数据点。

  4. 损失曲线:观察训练损失曲线可以帮助我们诊断训练过程。理想情况下,损失应该平稳下降并最终收敛。如果损失剧烈波动或停止下降,可能需要调整学习率或网络结构。

7. 实用技巧与常见陷阱

在实际使用MLP时,有几个关键因素需要考虑。以下是一些实用技巧和常见陷阱:

权重初始化:合适的初始化对训练成功至关重要。我们之前使用了He初始化(适合ReLU),其他常见方法包括:

  • Xavier/Glorot初始化:适合Sigmoid和Tanh
  • 随机初始化:简单但可能效果不佳
  • 预训练初始化:使用其他任务上训练好的权重

学习率选择:学习率太大可能导致震荡或不收敛,太小则训练缓慢。策略包括:

  • 学习率衰减:随着训练进行逐渐减小学习率
  • 自适应学习率算法:如Adam、RMSprop
  • 学习率网格搜索:尝试多个值选择最佳

过拟合与正则化:复杂MLP容易过拟合。应对方法:

  • L1/L2正则化:在损失函数中添加权重惩罚项
  • Dropout:训练时随机丢弃部分神经元
  • 早停:监控验证集性能,在过拟合前停止训练
  • 数据增强:增加训练数据多样性

梯度问题:深度网络可能面临梯度消失或爆炸:

  • 梯度消失:使用ReLU等激活函数缓解
  • 梯度爆炸:梯度裁剪、权重正则化
  • 批归一化:稳定训练过程

下面是一个增强版的MLP实现,加入了Dropout和L2正则化:

class EnhancedMLP:
    """增强版MLP,支持Dropout和L2正则化"""
    def __init__(self, input_size=2, hidden_sizes=[4, 4], output_size=1, 
                 dropout_rate=0.0, l2_lambda=0.0):
        """
        参数:
            input_size: 输入维度
            hidden_sizes: 隐藏层大小列表,如[4, 4]表示两个隐藏层,每层4个神经元
            output_size: 输出维度
            dropout_rate: Dropout概率(0表示不使用)
            l2_lambda: L2正则化强度
        """
        self.hidden_sizes = hidden_sizes
        self.dropout_rate = dropout_rate
        self.l2_lambda = l2_lambda
        
        # 初始化所有权重
        self.weights = []
        self.biases = []
        
        # 输入层到第一个隐藏层
        prev_size = input_size
        for hidden_size in hidden_sizes:
            # He初始化
            W = np.random.randn(prev_size, hidden_size) * np.sqrt(2.0 / prev_size)
            b = np.zeros((1, hidden_size))
            self.weights.append(W)
            self.biases.append(b)
            prev_size = hidden_size
        
        # 最后一个隐藏层到输出层
        W_out = np.random.randn(prev_size, output_size) * np.sqrt(2.0 / prev_size)
        b_out = np.zeros((1, output_size))
        self.weights.append(W_out)
        self.biases.append(b_out)
        
    def relu(self, x):
        return np.maximum(0, x)
    
    def relu_derivative(self, x):
        return (x > 0).astype(float)
    
    def sigmoid(self, x):
        x = np.clip(x, -500, 500)
        return 1 / (1 + np.exp(-x))
    
    def sigmoid_derivative(self, x):
        s = self.sigmoid(x)
        return s * (1 - s)
    
    def dropout(self, x, training=True):
        """Dropout层"""
        if not training or self.dropout_rate == 0:
            return x
        
        # 生成掩码
        mask = np.random.binomial(1, 1-self.dropout_rate, size=x.shape) / (1-self.dropout_rate)
        return x * mask, mask
    
    def forward(self, X, training=True):
        """前向传播,支持Dropout"""
        self.activations = [X]  # 存储每层激活值,用于反向传播
        self.z_values = []      # 存储每层线性输出
        self.dropout_masks = [] # 存储Dropout掩码
        
        # 隐藏层前向传播
        current_input = X
        for i in range(len(self.hidden_sizes)):
            z = np.dot(current_input, self.weights[i]) + self.biases[i]
            self.z_values.append(z)
            
            a = self.relu(z)
            
            # 应用Dropout(仅训练时)
            if training and self.dropout_rate > 0:
                a, mask = self.dropout(a, training)
                self.dropout_masks.append(mask)
            else:
                self.dropout_masks.append(None)
            
            self.activations.append(a)
            current_input = a
        
        # 输出层前向传播
        z_out = np.dot(current_input, self.weights[-1]) + self.biases[-1]
        self.z_values.append(z_out)
        
        a_out = self.sigmoid(z_out)
        self.activations.append(a_out)
        
        return a_out
    
    def backward(self, X, y, output, learning_rate=0.1):
        """反向传播,包含L2正则化"""
        m = X.shape[0]
        
        # 输出层误差
        dZ_out = output - y
        dW_out = (1/m) * np.dot(self.activations[-2].T, dZ_out)
        db_out = (1/m) * np.sum(dZ_out, axis=0, keepdims=True)
        
        # 添加L2正则化梯度
        if self.l2_lambda > 0:
            dW_out += (self.l2_lambda / m) * self.weights[-1]
        
        # 反向传播经过隐藏层
        dA_next = np.dot(dZ_out, self.weights[-1].T)
        
        # 存储梯度
        dW_list = [dW_out]
        db_list = [db_out]
        
        # 从后往前遍历隐藏层
        for l in range(len(self.hidden_sizes)-1, -1, -1):
            # 应用Dropout掩码(如果有)
            if self.dropout_masks[l] is not None:
                dA_next = dA_next * self.dropout_masks[l]
            
            # ReLU导数
            dZ = dA_next * self.relu_derivative(self.z_values[l])
            
            # 计算梯度
            dW = (1/m) * np.dot(self.activations[l].T, dZ)
            db = (1/m) * np.sum(dZ, axis=0, keepdims=True)
            
            # 添加L2正则化梯度
            if self.l2_lambda > 0:
                dW += (self.l2_lambda / m) * self.weights[l]
            
            dW_list.insert(0, dW)
            db_list.insert(0, db)
            
            # 传播到前一层
            if l > 0:
                dA_next = np.dot(dZ, self.weights[l].T)
        
        # 更新权重
        for i in range(len(self.weights)):
            self.weights[i] -= learning_rate * dW_list[i]
            self.biases[i] -= learning_rate * db_list[i]
    
    def compute_loss(self, y_true, y_pred):
        """计算损失,包含L2正则化项"""
        epsilon = 1e-15
        y_pred = np.clip(y_pred, epsilon, 1 - epsilon)
        
        # 交叉熵损失
        cross_entropy = -np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))
        
        # L2正则化项
        l2_penalty = 0
        if self.l2_lambda > 0:
            for W in self.weights:
                l2_penalty += np.sum(W ** 2)
            l2_penalty = (self.l2_lambda / (2 * y_true.shape[0])) * l2_penalty
        
        return cross_entropy + l2_penalty
    
    def predict(self, X, threshold=0.5):
        """预测时关闭Dropout"""
        prob = self.forward(X, training=False)
        return (prob > threshold).astype(int)
    
    def train(self, X, y, epochs=10000, learning_rate=0.1, verbose=1000):
        """训练函数"""
        losses = []
        for epoch in range(epochs):
            # 前向传播(训练模式,启用Dropout)
            output = self.forward(X, training=True)
            
            # 计算损失
            loss = self.compute_loss(y, output)
            losses.append(loss)
            
            # 反向传播
            self.backward(X, y, output, learning_rate)
            
            # 打印进度
            if verbose and (epoch + 1) % verbose == 0:
                predictions = self.predict(X)
                accuracy = np.mean(predictions == y)
                print(f"Epoch {epoch+1}/{epochs}, Loss: {loss:.6f}, Accuracy: {accuracy:.4f}")
        
        return losses

# 使用增强版MLP测试XOR问题
print("使用增强版MLP(带Dropout和L2正则化)测试XOR问题...")
enhanced_mlp = EnhancedMLP(input_size=2, hidden_sizes=[8, 4], output_size=1, 
                          dropout_rate=0.2, l2_lambda=0.001)
enhanced_losses = enhanced_mlp.train(X_xor, y_xor, epochs=10000, learning_rate=0.1, verbose=2500)

enhanced_preds = enhanced_mlp.predict(X_xor)
enhanced_acc = np.mean(enhanced_preds == y_xor)
print(f"\n增强版MLP在XOR问题上的准确率: {enhanced_acc:.4f}")

这个增强版MLP引入了几个重要改进:

  1. 支持任意层数:通过hidden_sizes列表可以指定任意数量的隐藏层和每层大小。

  2. Dropout:在训练时随机丢弃部分神经元,防止过拟合。注意在预测时需要关闭Dropout。

  3. L2正则化:在损失函数中添加权重平方和作为惩罚项,鼓励较小的权重值,提高泛化能力。

  4. 模块化设计:将前向传播和反向传播组织得更清晰,便于扩展。

在实际项目中,你可能会遇到更复杂的情况,比如梯度消失/爆炸、训练不稳定等。这时可以考虑以下高级技巧:

  • 批归一化:在每层激活前归一化输入,加速训练并提高稳定性
  • 残差连接:让信息更容易跨层传播,缓解梯度消失
  • 自适应优化器:使用Adam、RMSprop等替代普通梯度下降
  • 学习率调度:根据训练进度动态调整学习率

MLP虽然结构简单,但它是理解更复杂神经网络的基础。从单层感知机到多层感知机的跨越,不仅仅是增加了层数,更是引入了非线性变换和层次化特征学习的能力。这种能力使得神经网络能够处理现实世界中复杂的非线性问题,为后来的卷积神经网络、循环神经网络等更高级架构奠定了基础。

在实际应用中,MLP仍然有广泛用途,特别是在处理结构化数据(表格数据)时。虽然对于图像、文本等数据,CNN和Transformer等架构通常表现更好,但MLP因其简单性和可解释性,在许多场景下仍然是首选方案。理解MLP的工作原理,特别是它如何通过隐藏层和非线性激活函数解决线性不可分问题,是深入学习现代深度学习的关键第一步。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐