多层感知机全解析:从理论到手写数字识别

4 阅读1分钟

在上一节中,我们学习了感知机的基本原理和实现方法。虽然感知机在解决线性可分问题上表现出色,但它无法处理线性不可分问题,如XOR运算。为了解决这一局限性,研究人员提出了多层感知机(Multi-Layer Perceptron, MLP),它通过增加网络层数和使用非线性激活函数,能够解决更复杂的非线性问题。

本节将深入探讨多层感知机的结构、工作原理以及在手写数字识别任务中的应用,让你全面掌握这一重要的神经网络模型。

什么是多层感知机?

多层感知机是一种前馈神经网络,由至少三层神经元组成:输入层、一个或多个隐藏层以及输出层。与单层感知机不同,MLP使用非线性激活函数,使其能够学习和表示复杂的非线性关系。

MLP的基本结构

graph LR
    A[输入层] --> B[隐藏层1]
    B --> C[隐藏层2]
    C --> D[输出层]
    
    style A fill:#a8dadc
    style B fill:#457b9d
    style C fill:#457b9d
    style D fill:#e63946
    
    classDef inputLayer fill:#a8dadc,stroke:#333;
    classDef hiddenLayer fill:#457b9d,stroke:#333;
    classDef outputLayer fill:#e63946,stroke:#333;

MLP与单层感知机的区别

特性单层感知机多层感知机
网络层数仅输入层和输出层包含一个或多个隐藏层
激活函数阶跃函数非线性激活函数(如Sigmoid、ReLU)
可解决问题线性可分问题线性和非线性可分问题
学习算法感知机学习规则反向传播算法

MLP的工作原理

MLP通过前向传播和反向传播两个过程来学习数据中的模式:

前向传播

在前向传播过程中,输入数据从输入层经过隐藏层传递到输出层,每一层的输出作为下一层的输入。

对于一个具有一个隐藏层的MLP,前向传播过程如下:

  1. 隐藏层计算: z[1]=W[1]x+b[1]z^{[1]} = W^{[1]}x + b^{[1]} a[1]=g[1](z[1])a^{[1]} = g^{[1]}(z^{[1]})

  2. 输出层计算: z[2]=W[2]a[1]+b[2]z^{[2]} = W^{[2]}a^{[1]} + b^{[2]} a[2]=g[2](z[2])=y^a^{[2]} = g^{[2]}(z^{[2]}) = \hat{y}

其中:

  • W[l]W^{[l]}b[l]b^{[l]} 分别是第 ll 层的权重和偏置
  • g[l]g^{[l]} 是第 ll 层的激活函数
  • a[l]a^{[l]} 是第 ll 层的激活输出

反向传播

反向传播算法通过计算损失函数相对于网络参数的梯度来更新权重和偏置:

  1. 计算输出层误差: d[2]=a[2]yd^{[2]} = a^{[2]} - y

  2. 计算隐藏层误差: d[1]=(W[2])Td[2]g[1](z[1])d^{[1]} = (W^{[2]})^T d^{[2]} \cdot g^{[1]'}(z^{[1]})

  3. 更新参数: W[2]=W[2]αd[2](a[1])TW^{[2]} = W^{[2]} - \alpha d^{[2]} (a^{[1]})^T b[2]=b[2]αd[2]b^{[2]} = b^{[2]} - \alpha d^{[2]} W[1]=W[1]αd[1]xTW^{[1]} = W^{[1]} - \alpha d^{[1]} x^T b[1]=b[1]αd[1]b^{[1]} = b^{[1]} - \alpha d^{[1]}

其中 α\alpha 是学习率。

常用激活函数

激活函数为神经网络引入非线性,使其能够学习复杂的模式。常用的激活函数包括:

Sigmoid函数

σ(z)=11+ez\sigma(z) = \frac{1}{1 + e^{-z}}

Tanh函数

tanh(z)=ezezez+ez\tanh(z) = \frac{e^z - e^{-z}}{e^z + e^{-z}}

ReLU函数

ReLU(z)=max(0,z)\text{ReLU}(z) = \max(0, z)

动手实现MLP

让我们用Python从零开始实现一个MLP,并在MNIST手写数字数据集上进行训练和测试。

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import fetch_openml
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import train_test_split

# 激活函数及其导数
def sigmoid(z):
    # 防止溢出
    z = np.clip(z, -500, 500)
    return 1 / (1 + np.exp(-z))

def sigmoid_derivative(z):
    return sigmoid(z) * (1 - sigmoid(z))

def relu(z):
    return np.maximum(0, z)

def relu_derivative(z):
    return (z > 0).astype(float)

class MLP:
    def __init__(self, input_size, hidden_sizes, output_size, learning_rate=0.001):
        self.learning_rate = learning_rate
        
        # 初始化网络结构
        self.layer_sizes = [input_size] + hidden_sizes + [output_size]
        self.num_layers = len(self.layer_sizes)
        
        # 初始化权重和偏置
        self.weights = []
        self.biases = []
        
        for i in range(self.num_layers - 1):
            # 使用Xavier初始化
            w = np.random.randn(self.layer_sizes[i], self.layer_sizes[i+1]) * \
                np.sqrt(2.0 / self.layer_sizes[i])
            b = np.zeros((1, self.layer_sizes[i+1]))
            self.weights.append(w)
            self.biases.append(b)
    
    def forward(self, X):
        """前向传播"""
        self.activations = [X]
        self.z_values = []
        
        # 逐层计算
        a = X
        for i in range(self.num_layers - 1):
            z = np.dot(a, self.weights[i]) + self.biases[i]
            self.z_values.append(z)
            
            if i < self.num_layers - 2:  # 隐藏层使用ReLU
                a = relu(z)
            else:  # 输出层使用Sigmoid
                a = sigmoid(z)
            
            self.activations.append(a)
        
        return a
    
    def backward(self, X, y):
        """反向传播"""
        m = X.shape[0]
        
        # 计算输出层误差
        dz = self.activations[-1] - y.reshape(-1, 1)
        
        # 反向传播
        for i in range(self.num_layers - 2, -1, -1):
            # 计算梯度
            dw = (1/m) * np.dot(self.activations[i].T, dz)
            db = (1/m) * np.sum(dz, axis=0, keepdims=True)
            
            # 更新参数
            self.weights[i] -= self.learning_rate * dw
            self.biases[i] -= self.learning_rate * db
            
            # 如果不是第一层,计算前一层的误差
            if i > 0:
                dz = np.dot(dz, self.weights[i].T) * relu_derivative(self.z_values[i-1])
    
    def compute_cost(self, y_true, y_pred):
        """计算交叉熵损失"""
        m = y_true.shape[0]
        # 防止log(0)
        y_pred = np.clip(y_pred, 1e-15, 1 - 1e-15)
        cost = -(1/m) * np.sum(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))
        return cost
    
    def train(self, X, y, epochs=1000, print_cost=True):
        """训练模型"""
        costs = []
        
        for i in range(epochs):
            # 前向传播
            y_pred = self.forward(X)
            
            # 计算损失
            cost = self.compute_cost(y, y_pred)
            costs.append(cost)
            
            # 反向传播
            self.backward(X, y)
            
            # 打印损失
            if print_cost and i % 100 == 0:
                print(f"Cost after epoch {i}: {cost}")
        
        return costs
    
    def predict(self, X):
        """预测"""
        y_pred = self.forward(X)
        return (y_pred > 0.5).astype(int)

# 加载MNIST数据集(简化版,只使用0和1两个数字)
def load_mnist_binary():
    # 获取MNIST数据
    mnist = fetch_openml('mnist_784', version=1, as_frame=False)
    X, y = mnist.data, mnist.target.astype(int)
    
    # 只选择数字0和1
    indices = np.where((y == 0) | (y == 1))[0]
    X = X[indices]
    y = y[indices]
    
    # 数据标准化
    scaler = StandardScaler()
    X = scaler.fit_transform(X)
    
    return X, y

# 加载数据
print("Loading data...")
X, y = load_mnist_binary()
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

print(f"Training set size: {X_train.shape[0]}")
print(f"Test set size: {X_test.shape[0]}")
print(f"Input features: {X_train.shape[1]}")

# 创建并训练MLP
# 输入层784个神经元(28*28像素),隐藏层64个神经元,输出层1个神经元
mlp = MLP(input_size=784, hidden_sizes=[64], output_size=1, learning_rate=0.01)

print("\nTraining MLP...")
costs = mlp.train(X_train, y_train, epochs=1000, print_cost=True)

# 绘制损失曲线
plt.figure(figsize=(10, 6))
plt.plot(costs)
plt.title('Training Cost')
plt.xlabel('Epochs')
plt.ylabel('Cost')
plt.grid(True)
plt.show()

# 预测
predictions = mlp.predict(X_test)
accuracy = np.mean(predictions.flatten() == y_test)
print(f"\nTest Accuracy: {accuracy:.4f}")

# 可视化一些预测结果
fig, axes = plt.subplots(2, 5, figsize=(12, 6))
axes = axes.ravel()

for i in range(10):
    # 重塑图像
    img = X_test[i].reshape(28, 28)
    axes[i].imshow(img, cmap='gray')
    axes[i].set_title(f'True: {y_test[i]}, Pred: {predictions[i][0]}')
    axes[i].axis('off')

plt.tight_layout()
plt.show()

使用PyTorch实现更复杂的MLP

为了处理完整的MNIST数据集(0-9所有数字),我们可以使用PyTorch构建更强大的MLP:

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision
import torchvision.transforms as transforms
import matplotlib.pyplot as plt
import numpy as np

# 检查CUDA是否可用
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")

# 数据预处理
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,))
])

# 加载MNIST数据集
train_dataset = torchvision.datasets.MNIST(root='./data', train=True,
                                         download=True, transform=transform)
test_dataset = torchvision.datasets.MNIST(root='./data', train=False,
                                        download=True, transform=transform)

train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=1000, shuffle=False)

# 定义MLP模型
class MLP(nn.Module):
    def __init__(self, input_size=784, hidden_sizes=[128, 64], num_classes=10):
        super(MLP, self).__init__()
        self.layers = nn.Sequential(
            nn.Linear(input_size, hidden_sizes[0]),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(hidden_sizes[0], hidden_sizes[1]),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(hidden_sizes[1], num_classes)
        )
    
    def forward(self, x):
        x = x.view(x.size(0), -1)  # 展平图像
        x = self.layers(x)
        return x

# 创建模型
model = MLP().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

# 训练函数
def train(model, device, train_loader, optimizer, epoch):
    model.train()
    for batch_idx, (data, target) in enumerate(train_loader):
        data, target = data.to(device), target.to(device)
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)
        loss.backward()
        optimizer.step()
        if batch_idx % 100 == 0:
            print(f'Train Epoch: {epoch} [{batch_idx * len(data)}/{len(train_loader.dataset)} '
                  f'({100. * batch_idx / len(train_loader):.0f}%)]\tLoss: {loss.item():.6f}')

# 测试函数
def test(model, device, test_loader):
    model.eval()
    test_loss = 0
    correct = 0
    with torch.no_grad():
        for data, target in test_loader:
            data, target = data.to(device), target.to(device)
            output = model(data)
            test_loss += criterion(output, target).item()
            pred = output.argmax(dim=1, keepdim=True)
            correct += pred.eq(target.view_as(pred)).sum().item()

    test_loss /= len(test_loader.dataset)
    accuracy = 100. * correct / len(test_loader.dataset)
    print(f'\nTest set: Average loss: {test_loss:.4f}, '
          f'Accuracy: {correct}/{len(test_loader.dataset)} ({accuracy:.2f}%)\n')
    return accuracy

# 训练模型
accuracies = []
for epoch in range(1, 6):
    train(model, device, train_loader, optimizer, epoch)
    accuracy = test(model, device, test_loader)
    accuracies.append(accuracy)

# 绘制准确率曲线
plt.figure(figsize=(10, 6))
plt.plot(range(1, 6), accuracies, marker='o')
plt.title('MLP Accuracy on MNIST')
plt.xlabel('Epoch')
plt.ylabel('Accuracy (%)')
plt.grid(True)
plt.show()

# 可视化一些预测结果
def visualize_predictions(model, test_loader, device, num_images=10):
    model.eval()
    images, labels = next(iter(test_loader))
    images, labels = images.to(device), labels.to(device)
    
    with torch.no_grad():
        outputs = model(images[:num_images])
        _, predicted = torch.max(outputs, 1)
    
    fig, axes = plt.subplots(2, 5, figsize=(12, 6))
    axes = axes.ravel()
    
    for i in range(num_images):
        img = images[i].cpu().numpy().squeeze()
        axes[i].imshow(img, cmap='gray')
        axes[i].set_title(f'True: {labels[i].item()}, Pred: {predicted[i].item()}')
        axes[i].axis('off')
    
    plt.tight_layout()
    plt.show()

# 显示预测结果
visualize_predictions(model, test_loader, device)

MLP的正则化技术

为了防止过拟合,MLP通常会使用以下正则化技术:

Dropout

Dropout是一种在训练过程中随机将一部分神经元输出设置为0的技术:

# 在PyTorch中使用Dropout
class MLPWithDropout(nn.Module):
    def __init__(self, input_size=784, hidden_sizes=[128, 64], num_classes=10, dropout_rate=0.2):
        super(MLPWithDropout, self).__init__()
        self.layers = nn.Sequential(
            nn.Linear(input_size, hidden_sizes[0]),
            nn.ReLU(),
            nn.Dropout(dropout_rate),  # Dropout层
            nn.Linear(hidden_sizes[0], hidden_sizes[1]),
            nn.ReLU(),
            nn.Dropout(dropout_rate),  # Dropout层
            nn.Linear(hidden_sizes[1], num_classes)
        )
    
    def forward(self, x):
        x = x.view(x.size(0), -1)
        x = self.layers(x)
        return x

L2正则化

L2正则化通过在损失函数中添加权重的平方和来惩罚过大的权重:

# 在PyTorch中使用L2正则化
optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)  # weight_decay是L2正则化系数

MLP的应用场景

MLP在许多领域都有广泛应用:

  1. 图像分类:虽然CNN在图像任务上表现更好,但MLP可以作为基础模型
  2. 文本分类:处理词袋模型或TF-IDF特征
  3. 表格数据建模:处理结构化数据,如金融、医疗等领域
  4. 推荐系统:作为协同过滤的一部分

总结

多层感知机通过引入隐藏层和非线性激活函数,克服了单层感知机只能解决线性可分问题的限制。本节我们:

  1. 深入理解了MLP的结构和工作原理
  2. 学习了前向传播和反向传播算法
  3. 动手实现了MLP并应用于手写数字识别任务
  4. 了解了Dropout和L2正则化等防止过拟合的技术

MLP虽然结构简单,但它是现代深度学习的基础。在下一节中,我们将深入探讨反向传播算法的详细原理,这是训练深度神经网络的核心技术。

练习题

  1. 尝试调整MLP的网络结构(隐藏层数量、每层神经元数量),观察对性能的影响
  2. 实现不同的激活函数(如tanh)并比较效果
  3. 在其他数据集上测试MLP的性能,如Fashion-MNIST
  4. 研究早停(Early Stopping)技术并实现