深度学习中的正则化 --- 完整知识点与代码示例
一、参数范数惩罚(Parameter Norm Penalty)
1.1 核心知识点
参数范数惩罚通过在损失函数中添加一个与模型参数相关的惩罚项来限制模型复杂度,防止过拟合。通用形式为:
J~(θ;X,y)=J(θ;X,y)+αΩ(θ)\tilde{J}(\theta; X, y) = J(\theta; X, y) + \alpha \Omega(\theta)J~(θ;X,y)=J(θ;X,y)+αΩ(θ)
其中 JJJ 是原始损失函数,Ω(θ)\Omega(\theta)Ω(θ) 是范数惩罚项,α\alphaα 是正则化系数(超参数)。
常见范数惩罚:
| 名称 | 公式 | 效果 |
|---|---|---|
| L1 正则化 | $\Omega(\theta) = |\theta|_1 = \sum_i | \theta_i |
| L2 正则化 | Ω(θ)=12∣θ∣22=12∑iθi2\Omega(\theta) = \frac{1}{2}|\theta|_2^2 = \frac{1}{2}\sum_i \theta_i^2Ω(θ)=21∣θ∣22=21∑iθi2 | 使权重趋向较小值(权重衰减) |
| 弹性网络 | Ω(θ)=α∣θ∣1+β∣θ∣22\Omega(\theta) = \alpha|\theta|_1 + \beta|\theta|_2^2Ω(θ)=α∣θ∣1+β∣θ∣22 | 同时获得稀疏性和权重衰减 |
L2正则化的梯度更新:
∂∂θJ~=∂J∂θ+αθ\frac{\partial}{\partial \theta} \tilde{J} = \frac{\partial J}{\partial \theta} + \alpha \theta∂θ∂J~=∂θ∂J+αθ
更新规则:θ←(1−ϵα)θ−ϵ∂J∂θ\theta \leftarrow (1 - \epsilon\alpha)\theta - \epsilon \frac{\partial J}{\partial \theta}θ←(1−ϵα)θ−ϵ∂θ∂J,其中 (1−ϵα)<1(1-\epsilon\alpha) < 1(1−ϵα)<1 表现为"权重衰减"。
L1正则化的梯度更新:
∂∂θJ~=∂J∂θ+α⋅sign(θ)\frac{\partial}{\partial \theta} \tilde{J} = \frac{\partial J}{\partial \theta} + \alpha \cdot \text{sign}(\theta)∂θ∂J~=∂θ∂J+α⋅sign(θ)
L1正则化会将不重要的特征权重推向精确的0,从而实现特征选择。
注意: 通常不对偏置项(bias)进行正则化,因为偏置项数量少,正则化效果有限且会引入不必要的复杂性。
1.2 L2 正则化(权重衰减)完整代码
python
import torch # 导入PyTorch深度学习框架
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器模块
from torch.utils.data import DataLoader, TensorDataset # 导入数据加载工具
import numpy as np # 导入数值计算库
import matplotlib.pyplot as plt # 导入绘图库
# ==================== 数据准备 ====================
torch.manual_seed(42) # 设置随机种子,保证结果可复现
np.random.seed(42) # 设置numpy随机种子
# 生成模拟数据:1000个样本,20个特征
n_samples = 1000 # 样本数量
n_features = 20 # 特征数量
X = torch.randn(n_samples, n_features) # 生成标准正态分布的随机特征数据
# 真实权重:只有前5个特征是有效的,其余为噪声
true_weights = torch.zeros(n_features) # 初始化真实权重为全零
true_weights[:5] = torch.tensor([1.5, -2.0, 0.8, -1.2, 0.5]) # 只有前5个特征有非零权重
# 生成标签:y = X @ w + noise
y = X @ true_weights + 0.5 * torch.randn(n_samples) # 线性关系加高斯噪声
y = y.unsqueeze(1) # 将标签从[1000]变为[1000, 1]的形状
# 划分训练集和测试集(8:2比例)
train_size = int(0.8 * n_samples) # 训练集大小800
X_train, X_test = X[:train_size], X[train_size:] # 前800个作为训练集,后200个作为测试集
y_train, y_test = y[:train_size], y[train_size:] # 对应标签划分
# 创建TensorDataset和DataLoader
train_dataset = TensorDataset(X_train, y_train) # 将训练数据和标签打包为数据集
test_dataset = TensorDataset(X_test, y_test) # 将测试数据和标签打包为数据集
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) # 训练数据加载器,每批64个样本,随机打乱
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False) # 测试数据加载器,不打乱
# ==================== 模型定义 ====================
class LinearModel(nn.Module): # 定义线性回归模型类
"""简单的线性回归模型,用于展示正则化效果"""
def __init__(self, n_features): # 构造函数,接收特征数量
super(LinearModel, self).__init__() # 调用父类的构造函数
self.linear = nn.Linear(n_features, 1) # 定义线性层:n_features个输入,1个输出
def forward(self, x): # 前向传播方法
return self.linear(x) # 返回线性层的输出
# ==================== L2正则化实现 ====================
def train_with_l2(model, train_loader, test_loader, learning_rate=0.01,
weight_decay=0.1, epochs=100):
"""
使用L2正则化(权重衰减)训练模型
参数:
model: 待训练的模型
train_loader: 训练数据加载器
test_loader: 测试数据加载器
learning_rate: 学习率
weight_decay: L2正则化系数(权重衰减系数)
epochs: 训练轮数
"""
criterion = nn.MSELoss() # 定义均方误差损失函数
# 方法1:在优化器中直接设置weight_decay参数(等价于L2正则化)
optimizer = optim.SGD(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
# SGD优化器的weight_decay参数就是在每次更新时对权重乘以(1 - lr * weight_decay)
train_losses = [] # 记录训练损失
test_losses = [] # 记录测试损失
for epoch in range(epochs): # 遍历每个训练轮次
model.train() # 将模型设置为训练模式(启用Dropout等)
epoch_loss = 0.0 # 累计本轮训练损失
for batch_X, batch_y in train_loader: # 遍历每个mini-batch
predictions = model(batch_X) # 前向传播:计算预测值
loss = criterion(predictions, batch_y) # 计算均方误差损失
optimizer.zero_grad() # 清零梯度(PyTorch默认会累加梯度)
loss.backward() # 反向传播:计算梯度
optimizer.step() # 更新参数
epoch_loss += loss.item() # 累加batch损失(.item()将tensor转为Python数字)
avg_train_loss = epoch_loss / len(train_loader) # 计算平均训练损失
train_losses.append(avg_train_loss) # 记录训练损失
# 测试阶段
model.eval() # 将模型设置为评估模式
test_loss = 0.0 # 累计测试损失
with torch.no_grad(): # 测试时不需要计算梯度(节省内存)
for batch_X, batch_y in test_loader: # 遍历测试集
predictions = model(batch_X) # 前向传播
test_loss += criterion(predictions, batch_y).item() # 累加测试损失
avg_test_loss = test_loss / len(test_loader) # 计算平均测试损失
test_losses.append(avg_test_loss) # 记录测试损失
if (epoch + 1) % 20 == 0: # 每20轮打印一次信息
print(f"Epoch [{epoch+1}/{epochs}], "
f"Train Loss: {avg_train_loss:.4f}, "
f"Test Loss: {avg_test_loss:.4f}")
return train_losses, test_losses # 返回损失记录
# ==================== 训练无正则化模型 ====================
print("=" * 60)
print("训练无正则化的模型:")
model_no_reg = LinearModel(n_features) # 创建无正则化的模型
train_loss_no_reg, test_loss_no_reg = train_with_l2(
model_no_reg, train_loader, test_loader, # 传入模型和数据
learning_rate=0.01, weight_decay=0.0, epochs=100 # weight_decay=0表示无正则化
)
# ==================== 训练有L2正则化模型 ====================
print("\n" + "=" * 60)
print("训练有L2正则化的模型:")
model_l2 = LinearModel(n_features) # 创建有L2正则化的模型
train_loss_l2, test_loss_l2 = train_with_l2(
model_l2, train_loader, test_loader, # 传入模型和数据
learning_rate=0.01, weight_decay=0.1, epochs=100 # weight_decay=0.1表示L2正则化强度
)
# ==================== 对比权重大小 ====================
print("\n" + "=" * 60)
print("权重对比(真实权重 vs 无正则化 vs L2正则化):")
with torch.no_grad(): # 不计算梯度
w_no_reg = model_no_reg.linear.weight.data.numpy().flatten() # 获取无正则化模型的权重
w_l2 = model_l2.linear.weight.data.numpy().flatten() # 获取L2正则化模型的权重
w_true = true_weights.numpy() # 获取真实权重
# 打印每个特征对应的权重值
print(f"{'Feature':<10} {'True':<10} {'No Reg':<12} {'L2 Reg':<10}")
print("-" * 42)
for i in range(n_features): # 遍历每个特征
print(f"Feature {i:<2d} {w_true[i]:<10.4f} {w_no_reg[i]:<12.4f} {w_l2[i]:<10.4f}")
# ==================== 可视化结果 ====================
fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # 创建1行2列的子图
# 子图1:训练/测试损失对比
axes[0].plot(train_loss_no_reg, label='Train (No Reg)', linestyle='--', alpha=0.7) # 无正则化训练损失
axes[0].plot(test_loss_no_reg, label='Test (No Reg)', alpha=0.7) # 无正则化测试损失
axes[0].plot(train_loss_l2, label='Train (L2)', linestyle='--', alpha=0.7) # L2正则化训练损失
axes[0].plot(test_loss_l2, label='Test (L2)', alpha=0.7) # L2正则化测试损失
axes[0].set_xlabel('Epoch') # 设置x轴标签
axes[0].set_ylabel('Loss') # 设置y轴标签
axes[0].set_title('Training and Test Loss') # 设置标题
axes[0].legend() # 显示图例
axes[0].grid(True, alpha=0.3) # 显示网格线
# 子图2:权重对比柱状图
x_pos = np.arange(n_features) # 特征位置
width = 0.25 # 柱状图宽度
axes[1].bar(x_pos - width, w_true, width, label='True', color='green', alpha=0.7) # 真实权重
axes[1].bar(x_pos, w_no_reg, width, label='No Reg', color='red', alpha=0.7) # 无正则化权重
axes[1].bar(x_pos + width, w_l2, width, label='L2 Reg', color='blue', alpha=0.7) # L2正则化权重
axes[1].set_xlabel('Feature Index') # 设置x轴标签
axes[1].set_ylabel('Weight Value') # 设置y轴标签
axes[1].set_title('Weight Comparison') # 设置标题
axes[1].legend() # 显示图例
axes[1].grid(True, alpha=0.3, axis='y') # 显示y轴网格线
plt.tight_layout() # 自动调整子图间距
plt.savefig('l2_regularization.png', dpi=150, bbox_inches='tight') # 保存图片
plt.show() # 显示图片
1.3 L1 正则化完整代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器模块
from torch.utils.data import DataLoader, TensorDataset # 导入数据工具
import numpy as np # 导入数值计算库
import matplotlib.pyplot as plt # 导入绘图库
torch.manual_seed(42) # 设置随机种子
# ==================== 数据准备 ====================
n_samples = 1000 # 样本数量
n_features = 50 # 特征数量(高维,便于展示稀疏性)
X = torch.randn(n_samples, n_features) # 生成随机特征
# 真实权重:只有前5个特征有效
true_weights = torch.zeros(n_features) # 初始化真实权重为零
true_weights[:5] = torch.tensor([3.0, -2.5, 1.8, -1.0, 0.7]) # 设定前5个特征的权重
y = X @ true_weights + 0.3 * torch.randn(n_samples) # 生成带噪声的标签
y = y.unsqueeze(1) # 调整标签维度
# 数据集划分
train_size = 800 # 训练集大小
train_dataset = TensorDataset(X[:train_size], y[:train_size]) # 训练数据集
test_dataset = TensorDataset(X[train_size:], y[train_size:]) # 测试数据集
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) # 训练加载器
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False) # 测试加载器
# ==================== L1正则化训练函数 ====================
def train_with_l1(model, train_loader, test_loader, learning_rate=0.01,
l1_lambda=0.01, epochs=150):
"""
使用L1正则化训练模型
L1正则化不能通过优化器的weight_decay参数实现(那是L2),
需要手动在损失函数中添加L1惩罚项
参数:
model: 待训练的模型
train_loader: 训练数据加载器
test_loader: 测试数据加载器
learning_rate: 学习率
l1_lambda: L1正则化系数
epochs: 训练轮数
"""
criterion = nn.MSELoss() # 均方误差损失函数
optimizer = optim.Adam(model.parameters(), lr=learning_rate) # Adam优化器(不设置weight_decay)
train_losses = [] # 记录训练损失
test_losses = [] # 记录测试损失
l1_losses = [] # 记录L1惩罚项大小
for epoch in range(epochs): # 遍历每个训练轮次
model.train() # 训练模式
epoch_loss = 0.0 # 累计损失
epoch_l1 = 0.0 # 累计L1惩罚
for batch_X, batch_y in train_loader: # 遍历每个batch
predictions = model(batch_X) # 前向传播
mse_loss = criterion(predictions, batch_y) # 计算MSE损失
# ---- 手动计算L1正则化项 ----
l1_norm = 0.0 # 初始化L1范数
for param in model.parameters(): # 遍历模型所有参数
l1_norm += torch.sum(torch.abs(param)) # 累加所有参数的绝对值之和
# 总损失 = MSE损失 + L1惩罚
total_loss = mse_loss + l1_lambda * l1_norm # 组合损失
optimizer.zero_grad() # 清零梯度
total_loss.backward() # 反向传播
optimizer.step() # 更新参数
epoch_loss += mse_loss.item() # 记录MSE损失
epoch_l1 += l1_norm.item() # 记录L1范数
avg_train_loss = epoch_loss / len(train_loader) # 平均训练损失
avg_l1 = epoch_l1 / len(train_loader) # 平均L1范数
train_losses.append(avg_train_loss) # 记录
l1_losses.append(avg_l1)
# 测试阶段
model.eval() # 评估模式
test_loss = 0.0
with torch.no_grad(): # 不计算梯度
for batch_X, batch_y in test_loader:
predictions = model(batch_X)
test_loss += criterion(predictions, batch_y).item()
avg_test_loss = test_loss / len(test_loader)
test_losses.append(avg_test_loss)
if (epoch + 1) % 30 == 0: # 每30轮打印
print(f"Epoch [{epoch+1}/{epochs}], "
f"MSE: {avg_train_loss:.4f}, L1: {avg_l1:.4f}, "
f"Test: {avg_test_loss:.4f}")
return train_losses, test_losses, l1_losses # 返回所有损失记录
# ==================== 模型定义 ====================
class SimpleNet(nn.Module): # 简单全连接网络
def __init__(self, n_features):
super(SimpleNet, self).__init__()
self.fc1 = nn.Linear(n_features, 32) # 第一层:特征数 -> 32
self.relu = nn.ReLU() # ReLU激活函数
self.fc2 = nn.Linear(32, 1) # 输出层:32 -> 1
def forward(self, x):
x = self.relu(self.fc1(x)) # 第一层 + ReLU激活
return self.fc2(x) # 输出层
# ==================== 训练模型 ====================
model_l1 = SimpleNet(n_features) # 创建模型
train_loss_l1, test_loss_l1, l1_norms = train_with_l1(
model_l1, train_loader, test_loader,
learning_rate=0.005, l1_lambda=0.005, epochs=150 # L1正则化系数0.005
)
# ==================== 分析稀疏性 ====================
print("\n" + "=" * 60)
print("L1正则化后的权重稀疏性分析:")
with torch.no_grad(): # 不计算梯度
w_fc1 = model_l1.fc1.weight.data.abs().mean(dim=0).numpy() # 第一层每个特征的平均绝对权重
w_true_np = true_weights.abs().numpy() # 真实权重的绝对值
# 统计接近零的权重数量
threshold = 0.05 # 判断权重是否为零的阈值
n_sparse = np.sum(w_fc1 < threshold) # 统计权重接近零的特征数量
print(f"总特征数: {n_features}") # 打印总特征数
print(f"权重接近0的特征数: {n_sparse} (阈值={threshold})") # 打印稀疏特征数
print(f"有效特征数: {n_features - n_sparse}") # 打印有效特征数
# ==================== 可视化 ====================
fig, axes = plt.subplots(1, 3, figsize=(18, 5)) # 创建1行3列子图
# 子图1:损失曲线
axes[0].plot(train_loss_l1, label='Train MSE', alpha=0.8) # 训练损失
axes[0].plot(test_loss_l1, label='Test MSE', alpha=0.8) # 测试损失
axes[0].set_xlabel('Epoch') # x轴标签
axes[0].set_ylabel('Loss') # y轴标签
axes[0].set_title('Loss Curve with L1 Regularization') # 标题
axes[0].legend() # 图例
axes[0].grid(True, alpha=0.3) # 网格
# 子图2:L1范数变化
axes[1].plot(l1_norms, color='orange', alpha=0.8) # L1范数曲线
axes[1].set_xlabel('Epoch') # x轴标签
axes[1].set_ylabel('L1 Norm') # y轴标签
axes[1].set_title('L1 Norm of Weights Over Training') # 标题
axes[1].grid(True, alpha=0.3) # 网格
# 子图3:权重分布对比
x_pos = np.arange(n_features) # 特征位置
axes[2].bar(x_pos, w_fc1, alpha=0.7, color='steelblue', label='Learned |w|') # 学到的权重
axes[2].bar(x_pos, w_true_np, alpha=0.4, color='red', label='True |w|') # 真实权重
axes[2].axhline(y=threshold, color='green', linestyle='--', label=f'Threshold={threshold}') # 阈值线
axes[2].set_xlabel('Feature Index') # x轴标签
axes[2].set_ylabel('Absolute Weight') # y轴标签
axes[2].set_title('L1 Sparsity: Feature Weights') # 标题
axes[2].legend() # 图例
plt.tight_layout() # 自动调整间距
plt.savefig('l1_regularization.png', dpi=150, bbox_inches='tight') # 保存图片
plt.show() # 显示图片
1.4 L1 vs L2 对比代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import numpy as np # 导入数值计算库
import matplotlib.pyplot as plt # 导入绘图库
# ==================== 可视化L1和L2的等高线和梯度 ====================
def plot_l1_l2_contour():
"""
可视化L1和L2正则化的约束区域和梯度方向
L1约束区域是菱形(|w1| + |w2| <= c)
L2约束区域是圆形(w1^2 + w2^2 <= c^2)
L1的最优解往往在菱形的角上(坐标轴上),因此产生稀疏解
L2的最优解可以是任何位置,通常不会恰好在坐标轴上
"""
fig, axes = plt.subplots(1, 2, figsize=(14, 6)) # 创建1行2列子图
# ---- L2正则化 ----
theta = np.linspace(0, 2*np.pi, 100) # 角度从0到2π
r = 1.0 # 半径为1
x_l2 = r * np.cos(theta) # L2约束区域(圆)的x坐标
y_l2 = r * np.sin(theta) # L2约束区域(圆)的y坐标
axes[0].fill(x_l2, y_l2, alpha=0.2, color='blue', label='L2 Constraint Region') # 填充L2约束区域
axes[0].plot(x_l2, y_l2, 'b-', linewidth=2) # 绘制L2约束边界
# 模拟损失函数等高线(椭圆)
w1 = np.linspace(-2, 2, 200) # w1范围
w2 = np.linspace(-2, 2, 200) # w2范围
W1, W2 = np.meshgrid(w1, w2) # 创建网格
# 假设损失函数的最小值在(1.5, 1.0)处
loss = (W1 - 1.5)**2 + 2*(W2 - 1.0)**2 # 椭圆形损失函数
axes[0].contour(W1, W2, loss, levels=10, colors='red', alpha=0.5) # 绘制损失等高线
# 标注最优点
axes[0].plot(1.5, 1.0, 'r*', markersize=15, label='Loss Minimum (unconstrained)') # 无约束最优点
# L2约束下的最优解(圆与椭圆的切点)
axes[0].plot(0.6, 0.8, 'go', markersize=10, label='L2 Optimal (sparse=False)') # L2最优解
axes[0].set_xlim(-2, 2) # 设置x轴范围
axes[0].set_ylim(-2, 2) # 设置y轴范围
axes[0].set_aspect('equal') # 等比例显示
axes[0].set_title('L2 Regularization (Ridge)', fontsize=14) # 标题
axes[0].legend(loc='upper left') # 图例位置
axes[0].grid(True, alpha=0.3) # 网格
axes[0].axhline(y=0, color='k', linewidth=0.5) # x轴
axes[0].axvline(x=0, color='k', linewidth=0.5) # y轴
# ---- L1正则化 ----
# L1约束区域是菱形
x_l1 = np.array([1, 0, -1, 0, 1]) # 菱形x坐标(顶点在坐标轴上)
y_l1 = np.array([0, 1, 0, -1, 0]) # 菱形y坐标
axes[1].fill(x_l1, y_l1, alpha=0.2, color='green', label='L1 Constraint Region') # 填充L1约束区域
axes[1].plot(x_l1, y_l1, 'g-', linewidth=2) # 绘制L1约束边界
# 损失函数等高线(同样的椭圆)
axes[1].contour(W1, W2, loss, levels=10, colors='red', alpha=0.5) # 损失等高线
# 标注最优点
axes[1].plot(1.5, 1.0, 'r*', markersize=15, label='Loss Minimum (unconstrained)') # 无约束最优点
# L1约束下的最优解(菱形的角上,即坐标轴上 -> w2=0 -> 稀疏)
axes[1].plot(1.0, 0.0, 'ko', markersize=10, label='L1 Optimal (sparse=True)') # L1最优解在角上
axes[1].set_xlim(-2, 2)
axes[1].set_ylim(-2, 2)
axes[1].set_aspect('equal')
axes[1].set_title('L1 Regularization (Lasso)', fontsize=14)
axes[1].legend(loc='upper left')
axes[1].grid(True, alpha=0.3)
axes[1].axhline(y=0, color='k', linewidth=0.5)
axes[1].axvline(x=0, color='k', linewidth=0.5)
plt.suptitle('L1 vs L2 Regularization: Constraint Regions', fontsize=16, y=1.02)
plt.tight_layout()
plt.savefig('l1_vs_l2_contour.png', dpi=150, bbox_inches='tight')
plt.show()
plot_l1_l2_contour() # 执行可视化
二、作为约束的范数惩罚(Norm Penalty as Constraints)
2.1 核心知识点
正则化可以等价地表示为约束优化问题:
minθJ(θ;X,y)s.t.Ω(θ)≤k\min_{\theta} J(\theta; X, y) \quad \text{s.t.} \quad \Omega(\theta) \leq kθminJ(θ;X,y)s.t.Ω(θ)≤k
通过拉格朗日乘子法,约束优化问题与惩罚优化问题等价:
minθmaxα,α≥0J(θ;X,y)+α(Ω(θ)−k)\min_{\theta} \max_{\alpha, \alpha \geq 0} \left J(\\theta; X, y) + \\alpha(\\Omega(\\theta) - k) \\rightθminα,α≥0maxJ(θ;X,y)+α(Ω(θ)−k)
KKT条件(Karush-Kuhn-Tucker条件)给出了约束优化问题的最优性条件:
- 可行性:Ω(θ)≤k\Omega(\theta) \leq kΩ(θ)≤k
- 对偶可行性:α≥0\alpha \geq 0α≥0
- 互补松弛条件:α(Ω(θ)−k)=0\alpha(\Omega(\theta) - k) = 0α(Ω(θ)−k)=0
约束形式的优势: 我们可以精确控制约束的大小 kkk,而惩罚形式需要通过交叉验证搜索 α\alphaα 来间接控制模型复杂度。
2.2 约束优化实现代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import numpy as np # 导入数值计算库
torch.manual_seed(42) # 设置随机种子
# ==================== 带权重约束的线性模型 ====================
class ConstrainedLinearModel(nn.Module):
"""
通过权重裁剪(Weight Clipping)实现范数约束
在每次参数更新后,将参数投影到满足约束的集合中
等价于解决约束优化问题: min J(θ) s.t. ||θ||₂ ≤ k
"""
def __init__(self, n_features, max_norm=1.0):
"""
参数:
n_features: 输入特征维度
max_norm: 权重L2范数的最大允许值(约束上界k)
"""
super(ConstrainedLinearModel, self).__init__() # 调用父类构造函数
self.fc1 = nn.Linear(n_features, 64) # 第一个全连接层
self.relu = nn.ReLU() # ReLU激活函数
self.fc2 = nn.Linear(64, 32) # 第二个全连接层
self.fc3 = nn.Linear(32, 1) # 输出层
self.max_norm = max_norm # 保存最大范数约束
def forward(self, x):
"""前向传播"""
x = self.relu(self.fc1(x)) # 第一层 + ReLU
x = self.relu(self.fc2(x)) # 第二层 + ReLU
return self.fc3(x) # 输出
def constrain_weights(self):
"""
权重约束:在每次参数更新后调用
对每一层的权重执行max_norm约束:
如果||w||₂ > max_norm,则将权重投影到L2球面上:
w = w * (max_norm / ||w||₂)
这保证了所有权重的L2范数不超过max_norm
"""
with torch.no_grad(): # 约束操作不需要计算梯度
for name, param in self.named_parameters(): # 遍历所有参数
if 'weight' in name: # 只约束权重,不约束偏置
norm = torch.norm(param) # 计算权重的L2范数
if norm > self.max_norm: # 如果超过约束
param.mul_(self.max_norm / norm) # 乘以缩放因子进行投影
# param.mul_()是原地操作,直接修改参数值
# ==================== 训练函数 ====================
def train_constrained(model, X_train, y_train, epochs=200, lr=0.01):
"""
带约束的训练过程
关键区别:在optimizer.step()之后调用constrain_weights()
这确保了每次参数更新后,权重都满足约束条件
"""
criterion = nn.MSELoss() # MSE损失函数
optimizer = torch.optim.Adam(model.parameters(), lr=lr) # Adam优化器
losses = [] # 记录损失
norms = [] # 记录权重范数
for epoch in range(epochs): # 遍历每个epoch
# 前向传播
predictions = model(X_train) # 计算预测值
loss = criterion(predictions, y_train) # 计算损失
# 反向传播和参数更新
optimizer.zero_grad() # 清零梯度
loss.backward() # 计算梯度
optimizer.step() # 更新参数
# *** 关键步骤:约束投影 ***
model.constrain_weights() # 更新后立即约束权重
# 记录信息
losses.append(loss.item()) # 记录损失
# 计算所有权重参数的总L2范数
total_norm = sum(torch.norm(p).item()**2 for p in model.parameters() if p.dim() > 1)
norms.append(np.sqrt(total_norm)) # 记录总L2范数
if (epoch + 1) % 50 == 0: # 每50轮打印
print(f"Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}, "
f"Total Weight Norm: {norms[-1]:.4f}, Max Norm Constraint: {model.max_norm}")
return losses, norms # 返回记录
# ==================== 对比实验 ====================
# 生成数据
n_samples, n_features = 500, 10 # 500个样本,10个特征
X = torch.randn(n_samples, n_features) # 随机特征
true_w = torch.randn(n_features, 1) # 真实权重
y = X @ true_w + 0.3 * torch.randn(n_samples, 1) # 带噪声标签
# 训练不同约束强度的模型
for max_norm in [0.5, 2.0, 10.0]: # 三种约束强度
print(f"\n{'='*50}")
print(f"训练模型,max_norm = {max_norm}")
model = ConstrainedLinearModel(n_features, max_norm=max_norm) # 创建约束模型
losses, norms = train_constrained(model, X, y, epochs=200) # 训练
print(f"最终损失: {losses[-1]:.4f}, 最终权重范数: {norms[-1]:.4f}")
三、正则化和欠约束问题(Regularization and Underdetermined Problems)
3.1 核心知识点
欠约束问题是指当模型参数数量远大于数据样本数量时(高维小样本问题),优化问题可能有无穷多解,或数值计算不稳定。
常见场景:
- 线性回归中 XTXX^TXXTX 不可逆(奇异矩阵)
- 神经网络中的过参数化
- 矩阵求逆的数值不稳定
正则化的解决方案:
对于线性回归 w^=(XTX)−1XTy\hat{w} = (X^TX)^{-1}X^Tyw^=(XTX)−1XTy,当 XTXX^TXXTX 不可逆时:
- L2正则化 (岭回归):w^=(XTX+αI)−1XTy\hat{w} = (X^TX + \alpha I)^{-1}X^Tyw^=(XTX+αI)−1XTy
- 添加 αI\alpha IαI 使得矩阵一定可逆且数值稳定
- αI\alpha IαI 的特征值都加上了 α\alphaα,保证正定
3.2 解决欠约束问题代码
python
import numpy as np # 导入数值计算库
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import matplotlib.pyplot as plt # 导入绘图库
np.random.seed(42) # 设置随机种子
# ==================== 场景1:线性回归中的病态矩阵问题 ====================
def demonstrate_ill_conditioned():
"""
演示当X^TX接近奇异时,不加正则化的线性回归会导致数值不稳定
使用Hilbert矩阵(著名的病态矩阵)作为设计矩阵
"""
n = 15 # 矩阵维度(15x15的Hilbert矩阵条件数极大)
# 构造Hilbert矩阵: H[i,j] = 1/(i+j+1)
H = np.zeros((n, n)) # 初始化矩阵
for i in range(n): # 遍历行
for j in range(n): # 遍历列
H[i, j] = 1.0 / (i + j + 1) # 填充Hilbert矩阵元素
# 真实参数
w_true = np.random.randn(n, 1) # 随机生成真实权重
y = H @ w_true + 0.01 * np.random.randn(n, 1) # 生成带微小噪声的标签
# 计算条件数(衡量矩阵的病态程度)
cond_number = np.linalg.cond(H) # Hilbert矩阵的条件数非常大
print(f"Hilbert矩阵的条件数: {cond_number:.2e}") # 打印条件数
# 条件数越大,数值计算越不稳定
# ---- 不加正则化的求解 ----
try:
w_no_reg = np.linalg.solve(H.T @ H, H.T @ y) # 直接求解正规方程
error_no_reg = np.linalg.norm(w_no_reg - w_true) # 计算与真实权重的误差
print(f"无正则化 - 权重误差: {error_no_reg:.4f}") # 打印误差
except np.linalg.LinAlgError: # 如果矩阵不可逆会抛出异常
print("无正则化 - 矩阵奇异,无法求解!")
error_no_reg = float('inf')
# ---- 加L2正则化的求解(岭回归) ----
alpha_values = [1e-6, 1e-4, 1e-2, 1e-1, 1.0] # 不同的正则化强度
print(f"\n{'Alpha':<12} {'权重误差':<15} {'条件数(正则化后)':<20}")
print("-" * 47)
for alpha in alpha_values: # 遍历不同的正则化强度
HTH_reg = H.T @ H + alpha * np.eye(n) # 添加正则化项: H^T*H + alpha*I
w_reg = np.linalg.solve(HTH_reg, H.T @ y) # 求解正则化的正规方程
error_reg = np.linalg.norm(w_reg - w_true) # 计算权重误差
cond_reg = np.linalg.cond(HTH_reg) # 计算正则化后的条件数
print(f"{alpha:<12.1e} {error_reg:<15.4f} {cond_reg:<20.2e}")
demonstrate_ill_conditioned() # 执行演示
# ==================== 场景2:高维小样本分类问题 ====================
def high_dimensional_small_sample():
"""
特征维度 >> 样本数量的分类场景
不加正则化时模型会严重过拟合
加正则化后泛化能力显著提升
"""
n_train = 50 # 训练样本只有50个
n_test = 200 # 测试样本200个
n_features = 200 # 特征维度200(远大于训练样本数)
# 生成数据
X_train = torch.randn(n_train, n_features) # 训练特征
X_test = torch.randn(n_test, n_features) # 测试特征
# 真实决策边界只与前3个特征相关
w_true = torch.zeros(n_features) # 真实权重大部分为0
w_true[:3] = torch.tensor([1.0, -1.5, 0.8]) # 只有前3个特征有效
# 生成二分类标签
y_train = (X_train @ w_true > 0).float().unsqueeze(1) # 训练标签(0或1)
y_test = (X_test @ w_true > 0).float().unsqueeze(1) # 测试标签
# 定义逻辑回归模型
class LogisticRegression(nn.Module): # 逻辑回归模型
def __init__(self, n_features):
super().__init__()
self.linear = nn.Linear(n_features, 1) # 线性层
self.sigmoid = nn.Sigmoid() # Sigmoid激活
def forward(self, x):
return self.sigmoid(self.linear(x)) # 前向传播
criterion = nn.BCELoss() # 二元交叉熵损失
# 训练无正则化模型
model_no_reg = LogisticRegression(n_features) # 创建无正则化模型
optimizer_no_reg = torch.optim.SGD(model_no_reg.parameters(), lr=0.01)
for epoch in range(300): # 训练300轮
pred = model_no_reg(X_train) # 前向传播
loss = criterion(pred, y_train) # 计算损失
optimizer_no_reg.zero_grad() # 清零梯度
loss.backward() # 反向传播
optimizer_no_reg.step() # 更新参数
# 训练有L2正则化模型
model_l2 = LogisticRegression(n_features) # 创建L2正则化模型
optimizer_l2 = torch.optim.SGD(model_l2.parameters(), lr=0.01, weight_decay=0.1)
for epoch in range(300): # 训练300轮
pred = model_l2(X_train) # 前向传播
loss = criterion(pred, y_train) # 计算损失
optimizer_l2.zero_grad() # 清零梯度
loss.backward() # 反向传播
optimizer_l2.step() # 更新参数
# 评估准确率
with torch.no_grad(): # 不计算梯度
# 训练集准确率
train_acc_no_reg = ((model_no_reg(X_train) > 0.5).float() == y_train).float().mean()
train_acc_l2 = ((model_l2(X_train) > 0.5).float() == y_train).float().mean()
# 测试集准确率
test_acc_no_reg = ((model_no_reg(X_test) > 0.5).float() == y_test).float().mean()
test_acc_l2 = ((model_l2(X_test) > 0.5).float() == y_test).float().mean()
print("\n" + "=" * 60)
print("高维小样本分类实验:")
print(f"{'模型':<20} {'训练准确率':<15} {'测试准确率':<15}")
print("-" * 50)
print(f"{'无正则化':<20} {train_acc_no_reg:.4f}{'':<9} {test_acc_no_reg:.4f}")
print(f"{'L2正则化':<20} {train_acc_l2:.4f}{'':<9} {test_acc_l2:.4f}")
print(f"\n结论: 无正则化模型在训练集上完美但测试集上可能很差(过拟合)")
print(f" L2正则化模型虽然训练准确率略低,但泛化能力更强")
high_dimensional_small_sample() # 执行实验
四、数据集增强(Dataset Augmentation)
4.1 核心知识点
数据集增强通过对训练数据施加保持标签不变的变换来人为增加训练集大小。这是解决过拟合最有效的方法之一。
常见增强方法:
| 数据类型 | 增强方法 |
|---|---|
| 图像 | 翻转、旋转、裁剪、颜色抖动、缩放、仿射变换、弹性变形 |
| 文本 | 同义词替换、随机插入、随机删除、回译、文本生成 |
| 音频 | 时间拉伸、音高偏移、添加背景噪声、速度扰动 |
| 表格数据 | 添加噪声、SMOTE过采样、混合样本 |
核心原则: 增强后的数据应保持标签不变(或近似不变)。
4.2 图像数据增强代码
python
import torch # 导入PyTorch
import torchvision # 导入视觉工具库
import torchvision.transforms as transforms # 导入图像变换工具
import numpy as np # 导入数值计算库
import matplotlib.pyplot as plt # 导入绘图库
from PIL import Image # 导入图像处理库
# ==================== 基础图像增强变换 ====================
def create_augmentation_pipeline():
"""
创建不同的数据增强流水线
transforms.Compose: 将多个变换组合成一个流水线
每个变换按顺序依次应用到图像上
"""
# ---- 训练集增强(激进增强) ----
train_transform = transforms.Compose([ # 组合多个变换
transforms.RandomHorizontalFlip(p=0.5), # 以50%概率随机水平翻转
# p=0.5表示每次应用时有50%的概率执行翻转,50%概率保持原样
transforms.RandomVerticalFlip(p=0.2), # 以20%概率随机垂直翻转
transforms.RandomRotation(degrees=15), # 随机旋转±15度
# degrees=15表示从[-15, 15]度均匀分布中随机选择旋转角度
transforms.RandomResizedCrop( # 随机裁剪并缩放到指定大小
size=32, # 输出图像大小32x32
scale=(0.8, 1.0), # 裁剪面积占原图面积的比例范围
ratio=(0.9, 1.1) # 裁剪区域的宽高比范围
),
transforms.ColorJitter( # 颜色抖动
brightness=0.2, # 亮度调整范围:[1-0.2, 1+0.2]
contrast=0.2, # 对比度调整范围
saturation=0.2, # 饱和度调整范围
hue=0.1 # 色调调整范围
),
transforms.RandomAffine( # 随机仿射变换
degrees=0, # 额外旋转角度(已经用RandomRotation处理了)
translate=(0.1, 0.1), # 平移范围:水平和垂直各±10%
scale=(0.9, 1.1), # 缩放范围
shear=5 # 剪切角度范围±5度
),
transforms.RandomErasing( # 随机擦除(Cutout)
p=0.5, # 50%概率执行
scale=(0.02, 0.2), # 擦除区域面积占比范围
ratio=(0.3, 3.3), # 擦除区域宽高比
value=0 # 用0(黑色)填充擦除区域
),
transforms.ToTensor(), # 将PIL图像转换为[0,1]范围的Tensor
transforms.Normalize( # 标准化(使用ImageNet的均值和标准差)
mean=[0.4914, 0.4822, 0.4465], # CIFAR-10数据集各通道的均值
std=[0.2023, 0.1994, 0.2010] # CIFAR-10数据集各通道的标准差
)
])
# ---- 测试集变换(不做数据增强) ----
test_transform = transforms.Compose([ # 测试集只做基础变换
transforms.ToTensor(), # 转换为Tensor
transforms.Normalize( # 标准化(必须与训练集一致)
mean=[0.4914, 0.4822, 0.4465], # 使用相同的均值
std=[0.2023, 0.1994, 0.2010] # 使用相同的标准差
)
])
return train_transform, test_transform # 返回两种变换
# ==================== 使用CIFAR-10数据集 ====================
def demonstrate_augmentation():
"""
演示数据增强效果:展示原始图像和增强后的图像
"""
train_transform, test_transform = create_augmentation_pipeline() # 创建变换
# 下载CIFAR-10数据集
train_dataset = torchvision.datasets.CIFAR10( # CIFAR-10数据集
root='./data', # 数据存储路径
train=True, # 使用训练集
download=True, # 如果没有则自动下载
transform=train_transform # 应用训练集增强
)
# CIFAR-10的10个类别名称
classes = ('plane', 'car', 'bird', 'cat', 'deer', # 10个类别的名称
'dog', 'frog', 'horse', 'ship', 'truck')
# 创建一个不做增强的数据集用于对比
raw_dataset = torchvision.datasets.CIFAR10( # 不做增强的原始数据集
root='./data', train=True, download=True,
transform=transforms.ToTensor() # 只转换为Tensor,不做其他变换
)
# 可视化增强效果
fig, axes = plt.subplots(4, 6, figsize=(15, 10)) # 4行6列的子图
for i in range(6): # 展示6个样本
# 第一行:原始图像
raw_img, label = raw_dataset[i] # 获取原始图像和标签
axes[0, i].imshow(raw_img.permute(1, 2, 0).numpy()) # 从[C,H,W]转为[H,W,C]并显示
axes[0, i].set_title(f'Original: {classes[label]}') # 设置标题
axes[0, i].axis('off') # 隐藏坐标轴
# 第2-4行:对同一图像应用增强3次,展示随机性
for j in range(1, 4): # 行索引1-3
aug_img, _ = train_dataset[i] # 对同一图像应用随机增强
# 反标准化以便显示
aug_img_display = aug_img * torch.tensor([0.2023, 0.1994, 0.2010]).view(3,1,1) # 乘以标准差
aug_img_display = aug_img_display + torch.tensor([0.4914, 0.4822, 0.4465]).view(3,1,1) # 加均值
aug_img_display = torch.clamp(aug_img_display, 0, 1) # 裁剪到[0,1]范围
axes[j, i].imshow(aug_img_display.permute(1, 2, 0).numpy()) # 显示
axes[j, i].set_title(f'Augmented {j}') # 标题
axes[j, i].axis('off') # 隐藏坐标轴
plt.suptitle('Data Augmentation Comparison', fontsize=16) # 总标题
plt.tight_layout() # 调整间距
plt.savefig('augmentation_demo.png', dpi=150, bbox_inches='tight') # 保存
plt.show() # 显示
demonstrate_augmentation() # 执行演示
# ==================== Mixup数据增强 ====================
def mixup_data(x, y, alpha=0.2):
"""
Mixup数据增强:将两个样本线性插值混合
x_mixed = λ * x_i + (1-λ) * x_j
y_mixed = λ * y_i + (1-λ) * y_j
其中 λ ~ Beta(α, α)
参数:
x: 输入特征 [batch_size, ...]
y: 标签 [batch_size, ...]
alpha: Beta分布参数,控制混合程度
alpha越小,混合程度越低(更接近原始数据)
alpha越大,混合程度越高(更多样化但可能引入噪声)
返回:
mixed_x: 混合后的特征
y_a: 第一组标签
y_b: 第二组标签
lam: 混合系数
"""
if alpha > 0: # 如果alpha有效
lam = np.random.beta(alpha, alpha) # 从Beta分布中采样混合系数λ
else:
lam = 1.0 # alpha=0时不做混合
batch_size = x.size(0) # 获取batch大小
index = torch.randperm(batch_size) # 生成随机排列的索引(用于配对)
# torch.randperm(n)生成0到n-1的随机排列,如[3, 0, 2, 1]
mixed_x = lam * x + (1 - lam) * x[index] # 混合输入:线性插值
y_a, y_b = y, y[index] # 保存两组标签
return mixed_x, y_a, y_b, lam # 返回混合数据和标签
def mixup_criterion(criterion, pred, y_a, y_b, lam):
"""
Mixup损失函数:对两个标签分别计算损失,然后加权平均
L = λ * loss(pred, y_a) + (1-λ) * loss(pred, y_b)
参数:
criterion: 原始损失函数
pred: 模型预测值
y_a: 第一组标签
y_b: 第二组标签
lam: 混合系数
"""
return lam * criterion(pred, y_a) + (1 - lam) * criterion(pred, y_b) # 加权损失
# ==================== Mixup训练示例 ====================
def train_with_mixup():
"""使用Mixup增强训练CIFAR-10分类模型"""
import torch.nn as nn
# 定义简单CNN模型
class SimpleCNN(nn.Module): # 简单卷积神经网络
def __init__(self):
super().__init__()
self.features = nn.Sequential( # 特征提取部分
nn.Conv2d(3, 32, 3, padding=1), # 卷积层:3通道->32通道,3x3卷积核
nn.BatchNorm2d(32), # 批量归一化
nn.ReLU(), # ReLU激活
nn.MaxPool2d(2), # 最大池化,尺寸减半
nn.Conv2d(32, 64, 3, padding=1), # 卷积层:32->64通道
nn.BatchNorm2d(64), # 批量归一化
nn.ReLU(), # ReLU激活
nn.MaxPool2d(2), # 最大池化
nn.Conv2d(64, 128, 3, padding=1), # 卷积层:64->128通道
nn.BatchNorm2d(128), # 批量归一化
nn.ReLU(), # ReLU激活
nn.AdaptiveAvgPool2d(1), # 自适应平均池化到1x1
)
self.classifier = nn.Sequential( # 分类器部分
nn.Dropout(0.5), # Dropout正则化
nn.Linear(128, 10) # 全连接层:128->10(10个类别)
)
def forward(self, x):
x = self.features(x) # 提取特征
x = x.view(x.size(0), -1) # 展平为一维向量
return self.classifier(x) # 分类
model = SimpleCNN() # 创建模型
criterion = nn.CrossEntropyLoss() # 交叉熵损失
optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # Adam优化器
# 模拟训练(使用随机数据)
for epoch in range(5): # 模拟5个epoch
# 模拟一个batch的数据
batch_x = torch.randn(32, 3, 32, 32) # 随机图像batch: [32, 3, 32, 32]
batch_y = torch.randint(0, 10, (32,)) # 随机标签: [32]
# 应用Mixup
mixed_x, y_a, y_b, lam = mixup_data(batch_x, batch_y, alpha=0.2) # Mixup增强
# 前向传播
output = model(mixed_x) # 使用混合数据预测
loss = mixup_criterion(criterion, output, y_a, y_b, lam) # 计算Mixup损失
# 反向传播
optimizer.zero_grad() # 清零梯度
loss.backward() # 反向传播
optimizer.step() # 更新参数
print(f"Epoch {epoch+1}, Loss: {loss.item():.4f}, Lambda: {lam:.4f}")
train_with_mixup() # 执行Mixup训练
五、噪声鲁棒性(Noise Robustness)
5.1 核心知识点
向模型的输入或权重添加噪声是一种有效的正则化方法:
1. 输入噪声: 向训练数据添加噪声等价于Tikhonov正则化
- 对于加性高斯噪声 ϵ∼N(0,σ2I)\epsilon \sim N(0, \sigma^2 I)ϵ∼N(0,σ2I),线性模型的最优权重变为带L2正则化的解
2. 权重噪声: 向权重添加噪声
- 训练时向权重添加高斯噪声:θnoisy=θ+ϵ,ϵ∼N(0,η2)\theta_{noisy} = \theta + \epsilon, \epsilon \sim N(0, \eta^2)θnoisy=θ+ϵ,ϵ∼N(0,η2)
- 等价于对损失函数的正则化
3. 输出噪声(标签平滑): 向标签添加噪声
- 标签平滑(Label Smoothing):ysmooth=(1−ϵ)∗y+ϵ/Ky_{smooth} = (1-\epsilon) * y + \epsilon / Kysmooth=(1−ϵ)∗y+ϵ/K
- 防止模型对训练标签过度自信
5.2 噪声鲁棒性完整代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器
import numpy as np # 导入数值计算库
import matplotlib.pyplot as plt # 导入绘图库
torch.manual_seed(42) # 设置随机种子
# ==================== 1. 输入噪声正则化 ====================
class InputNoiseRegularization(nn.Module):
"""
在输入层添加高斯噪声
根据Bishop (1995)的理论,在输入添加高斯噪声等价于
对权重施加特定形式的Tikhonov正则化
"""
def __init__(self, input_dim, noise_std=0.1):
"""
参数:
input_dim: 输入维度
noise_std: 噪声标准差σ
"""
super().__init__()
self.fc1 = nn.Linear(input_dim, 128) # 第一层
self.bn1 = nn.BatchNorm1d(128) # 批量归一化
self.fc2 = nn.Linear(128, 64) # 第二层
self.bn2 = nn.BatchNorm1d(64) # 批量归一化
self.fc3 = nn.Linear(64, 1) # 输出层
self.relu = nn.ReLU() # ReLU激活
self.noise_std = noise_std # 保存噪声标准差
def forward(self, x):
"""
前向传播:在输入和隐藏层都添加噪声
"""
if self.training: # 只在训练时添加噪声
noise = torch.randn_like(x) * self.noise_std # 生成与x同形状的高斯噪声
x = x + noise # 将噪声添加到输入
x = self.relu(self.bn1(self.fc1(x))) # 第一层 + BN + ReLU
if self.training: # 隐藏层也添加噪声
noise = torch.randn_like(x) * self.noise_std * 0.5 # 隐藏层噪声可以小一些
x = x + noise # 添加噪声
x = self.relu(self.bn2(self.fc2(x))) # 第二层 + BN + ReLU
return self.fc3(x) # 输出
# ==================== 2. 权重噪声训练 ====================
def add_weight_noise(model, noise_std=0.01):
"""
在训练时向模型权重添加高斯噪声
原理:每次前向传播前添加噪声,计算损失并反向传播,
然后恢复原始权重并使用计算好的梯度更新
这种方法等价于在损失函数上添加了一个与噪声方差相关的正则项
参数:
model: 待添加噪声的模型
noise_std: 权重噪声的标准差
"""
with torch.no_grad(): # 不需要对噪声操作计算梯度
noise_dict = {} # 存储添加的噪声,用于后续恢复
for name, param in model.named_parameters(): # 遍历所有参数
noise = torch.randn_like(param) * noise_std # 生成与参数同形状的噪声
noise_dict[name] = noise # 保存噪声
param.add_(noise) # 原地添加噪声到参数
return noise_dict # 返回噪声字典(用于恢复)
def remove_weight_noise(model, noise_dict):
"""
恢复原始权重:减去之前添加的噪声
参数:
model: 模型
noise_dict: add_weight_noise返回的噪声字典
"""
with torch.no_grad(): # 不需要计算梯度
for name, param in model.named_parameters(): # 遍历所有参数
if name in noise_dict: # 如果这个参数添加了噪声
param.sub_(noise_dict[name]) # 原地减去噪声,恢复原始值
# ==================== 3. 标签平滑 ====================
class LabelSmoothingLoss(nn.Module):
"""
标签平滑损失函数
将硬标签(one-hot)转为软标签:
y_smooth = (1 - ε) * y_hard + ε / K
其中ε是平滑系数,K是类别数
效果:
- 防止模型输出过于极端的预测(过拟合自信)
- 提高模型的校准性(calibration)
- 通常可以提高泛化性能
"""
def __init__(self, num_classes, smoothing=0.1):
"""
参数:
num_classes: 类别数量K
smoothing: 平滑系数ε(通常取0.1)
"""
super().__init__()
self.num_classes = num_classes # 保存类别数
self.smoothing = smoothing # 保存平滑系数
self.confidence = 1.0 - smoothing # 正确类别的置信度 = 1 - ε
def forward(self, pred, target):
"""
计算标签平滑的交叉熵损失
参数:
pred: 模型的原始输出(logits),形状 [batch_size, num_classes]
target: 真实标签(整数形式),形状 [batch_size]
"""
log_probs = torch.log_softmax(pred, dim=-1) # 计算log softmax
# log_softmax(x) = log(softmax(x)),数值上比先softmax再log更稳定
# 构建软标签的交叉熵
# 对于正确类别:-(1-ε) * log(p_correct)
# 对于错误类别:-(ε/(K-1)) * log(p_wrong)
nll_loss = -log_probs.gather(dim=-1, index=target.unsqueeze(-1)) # 正确类别的负对数似然
# gather从log_probs中取出target对应位置的值
nll_loss = nll_loss.squeeze(-1) # 去掉多余的维度
smooth_loss = -log_probs.mean(dim=-1) # 所有类别的平均负对数
# 对应 -(1/K) * Σ log(p_k),即均匀分布的交叉熵
loss = self.confidence * nll_loss + self.smoothing * smooth_loss # 组合两种损失
return loss.mean() # 返回batch平均损失
# ==================== 综合对比实验 ====================
def noise_robustness_experiment():
"""
对比不同噪声正则化方法的效果
"""
# 生成模拟数据
n_samples = 1000 # 样本数
n_features = 20 # 特征数
n_classes = 5 # 类别数
X = torch.randn(n_samples, n_features) # 随机特征
y = torch.randint(0, n_classes, (n_samples,)) # 随机整数标签
# 划分训练/测试
X_train, X_test = X[:800], X[800:] # 训练/测试划分
y_train, y_test = y[:800], y[800:]
# 定义基础分类模型
class BaseClassifier(nn.Module): # 基础分类器
def __init__(self, in_dim, n_classes):
super().__init__()
self.net = nn.Sequential(
nn.Linear(in_dim, 64), # 第一层
nn.ReLU(), # 激活函数
nn.Linear(64, 32), # 第二层
nn.ReLU(), # 激活函数
nn.Linear(32, n_classes) # 输出层
)
def forward(self, x):
return self.net(x) # 前向传播
results = {} # 存储实验结果
# ---- 实验1:标准训练(基线) ----
print("训练基线模型...")
model_base = BaseClassifier(n_features, n_classes) # 基线模型
optimizer = optim.Adam(model_base.parameters(), lr=0.001) # Adam优化器
criterion = nn.CrossEntropyLoss() # 标准交叉熵损失
for epoch in range(100): # 训练100轮
output = model_base(X_train) # 前向传播
loss = criterion(output, y_train) # 计算损失
optimizer.zero_grad() # 清零梯度
loss.backward() # 反向传播
optimizer.step() # 更新参数
with torch.no_grad(): # 评估
train_acc = (model_base(X_train).argmax(1) == y_train).float().mean()
test_acc = (model_base(X_test).argmax(1) == y_test).float().mean()
results['Baseline'] = (train_acc.item(), test_acc.item())
# ---- 实验2:输入噪声正则化 ----
print("训练输入噪声模型...")
model_noise = InputNoiseRegularization(n_features, noise_std=0.15) # 带输入噪声的模型
optimizer = optim.Adam(model_noise.parameters(), lr=0.001)
for epoch in range(100):
model_noise.train() # 训练模式(启用噪声)
output = model_noise(X_train)
loss = criterion(output, y_train)
optimizer.zero_grad()
loss.backward()
optimizer.step()
model_noise.eval() # 评估模式(关闭噪声)
with torch.no_grad():
train_acc = (model_noise(X_train).argmax(1) == y_train).float().mean()
test_acc = (model_noise(X_test).argmax(1) == y_test).float().mean()
results['Input Noise'] = (train_acc.item(), test_acc.item())
# ---- 实验3:标签平滑 ----
print("训练标签平滑模型...")
model_smooth = BaseClassifier(n_features, n_classes)
optimizer = optim.Adam(model_smooth.parameters(), lr=0.001)
smoothing_criterion = LabelSmoothingLoss(num_classes=n_classes, smoothing=0.1) # 标签平滑损失
for epoch in range(100):
output = model_smooth(X_train)
loss = smoothing_criterion(output, y_train) # 使用标签平滑损失
optimizer.zero_grad()
loss.backward()
optimizer.step()
with torch.no_grad():
train_acc = (model_smooth(X_train).argmax(1) == y_train).float().mean()
test_acc = (model_smooth(X_test).argmax(1) == y_test).float().mean()
results['Label Smoothing'] = (train_acc.item(), test_acc.item())
# ---- 实验4:权重噪声 ----
print("训练权重噪声模型...")
model_w_noise = BaseClassifier(n_features, n_classes)
optimizer = optim.Adam(model_w_noise.parameters(), lr=0.001)
for epoch in range(100):
# 前向传播前添加权重噪声
noise_dict = add_weight_noise(model_w_noise, noise_std=0.02) # 添加权重噪声
output = model_w_noise(X_train) # 带噪声的前向传播
loss = criterion(output, y_train) # 计算损失
optimizer.zero_grad() # 清零梯度
loss.backward() # 反向传播(梯度基于带噪声的权重计算)
remove_weight_noise(model_w_noise, noise_dict) # 恢复原始权重
optimizer.step() # 用恢复后的权重更新
with torch.no_grad():
train_acc = (model_w_noise(X_train).argmax(1) == y_train).float().mean()
test_acc = (model_w_noise(X_test).argmax(1) == y_test).float().mean()
results['Weight Noise'] = (train_acc.item(), test_acc.item())
# 打印结果
print("\n" + "=" * 60)
print("噪声鲁棒性实验结果:")
print(f"{'方法':<20} {'训练准确率':<15} {'测试准确率':<15}")
print("-" * 50)
for method, (train_a, test_a) in results.items():
print(f"{method:<20} {train_a:.4f}{'':<9} {test_a:.4f}")
noise_robustness_experiment() # 执行实验
六、半监督学习(Semi-supervised Learning)
6.1 核心知识点
半监督学习利用大量未标记数据 和少量标记数据进行训练。核心假设:
- 聚类假设:同一聚类中的数据倾向于属于同一类别
- 流形假设:数据分布在低维流形上,相近的点有相同的标签
- 平滑假设:如果输入空间中两个点距离近,则它们的输出也应该相近
常见方法:
- 自训练(Self-training):用标记数据训练模型,对未标记数据预测伪标签,加入训练
- 一致性正则化(Consistency Regularization):同一数据的不同扰动版本应有相同预测
- 虚拟对抗训练(Virtual Adversarial Training):输入最坏情况扰动前后预测应一致
6.2 半监督学习代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器
import numpy as np # 导入数值计算库
from torch.utils.data import DataLoader, TensorDataset # 导入数据工具
torch.manual_seed(42) # 设置随机种子
# ==================== 自训练方法 ====================
class SemiSupervisedSelfTraining:
"""
自训练(Self-Training)半监督学习
算法流程:
1. 使用少量标记数据训练初始模型
2. 用模型对未标记数据进行预测
3. 将高置信度的预测作为伪标签加入训练集
4. 用扩展后的训练集重新训练模型
5. 重复步骤2-4直到收敛
"""
def __init__(self, model, confidence_threshold=0.9, lr=0.001):
"""
参数:
model: 分类模型
confidence_threshold: 伪标签的置信度阈值
lr: 学习率
"""
self.model = model # 保存模型
self.confidence_threshold = confidence_threshold # 置信度阈值
self.optimizer = optim.Adam(model.parameters(), lr=lr) # 优化器
self.criterion = nn.CrossEntropyLoss() # 交叉熵损失
def train_step(self, X_labeled, y_labeled):
"""
在标记数据上训练一步
参数:
X_labeled: 标记数据的特征
y_labeled: 标记数据的标签
"""
self.model.train() # 训练模式
output = self.model(X_labeled) # 前向传播
loss = self.criterion(output, y_labeled) # 计算损失
self.optimizer.zero_grad() # 清零梯度
loss.backward() # 反向传播
self.optimizer.step() # 更新参数
return loss.item() # 返回损失值
def generate_pseudo_labels(self, X_unlabeled):
"""
为未标记数据生成伪标签
只选择模型预测置信度高于阈值的样本
参数:
X_unlabeled: 未标记数据的特征
返回:
pseudo_X: 高置信度样本的特征
pseudo_y: 伪标签
selected_mask: 被选中的样本索引掩码
"""
self.model.eval() # 评估模式
with torch.no_grad(): # 不计算梯度
logits = self.model(X_unlabeled) # 前向传播获取logits
probs = torch.softmax(logits, dim=-1) # 转为概率分布
max_probs, pseudo_labels = probs.max(dim=-1) # 取最大概率和对应类别
# 选择高置信度的样本
selected_mask = max_probs > self.confidence_threshold # 布尔掩码
# max_probs[i] > threshold 表示第i个样本的最高预测概率超过了阈值
if selected_mask.sum() > 0: # 如果有样本被选中
pseudo_X = X_unlabeled[selected_mask] # 选中的特征
pseudo_y = pseudo_labels[selected_mask] # 对应的伪标签
return pseudo_X, pseudo_y, selected_mask # 返回
else:
return None, None, selected_mask # 没有样本被选中
def fit(self, X_labeled, y_labeled, X_unlabeled, n_iterations=10, batch_labeled=32):
"""
完整的自训练流程
参数:
X_labeled: 标记数据特征
y_labeled: 标记数据标签
X_unlabeled: 未标记数据特征
n_iterations: 自训练迭代次数
batch_labeled: 标记数据的batch大小
"""
history = {'labeled_loss': [], 'pseudo_count': []} # 记录训练历史
for iteration in range(n_iterations): # 每次迭代
# 步骤1:在标记数据上训练
epoch_loss = 0.0 # 累计损失
n_batches = 0 # batch计数
# 简单的mini-batch训练
indices = torch.randperm(len(X_labeled)) # 随机打乱索引
for start in range(0, len(X_labeled), batch_labeled): # 遍历batch
end = min(start + batch_labeled, len(X_labeled)) # batch结束位置
batch_idx = indices[start:end] # 当前batch的索引
loss = self.train_step(X_labeled[batch_idx], y_labeled[batch_idx])
epoch_loss += loss # 累加损失
n_batches += 1 # 计数
avg_loss = epoch_loss / n_batches # 平均损失
# 步骤2:生成伪标签
pseudo_X, pseudo_y, mask = self.generate_pseudo_labels(X_unlabeled)
n_pseudo = mask.sum().item() # 被选中的样本数
# 步骤3:将高置信度未标记数据加入训练集
if pseudo_X is not None and len(pseudo_X) > 0:
# 合并标记数据和伪标记数据
X_labeled = torch.cat([X_labeled, pseudo_X], dim=0) # 拼接特征
y_labeled = torch.cat([y_labeled, pseudo_y], dim=0) # 拼接标签
# 从未标记数据中移除已选中的样本
X_unlabeled = X_unlabeled[~mask] # ~mask取反,保留未选中的样本
# 记录历史
history['labeled_loss'].append(avg_loss)
history['pseudo_count'].append(n_pseudo)
print(f"Iteration [{iteration+1}/{n_iterations}], "
f"Loss: {avg_loss:.4f}, "
f"New pseudo labels: {n_pseudo}, "
f"Labeled set size: {len(X_labeled)}, "
f"Unlabeled remaining: {len(X_unlabeled)}")
if len(X_unlabeled) == 0: # 所有未标记数据都已使用
print("所有未标记数据已被利用!")
break
return history # 返回训练历史
# ==================== 一致性正则化(Pi-Model) ====================
class PiModel(nn.Module):
"""
Pi-Model:一致性正则化半监督学习
核心思想:同一样本在不同随机增强下的预测应该一致
总损失 = 分类损失 + λ * 一致性损失
一致性损失 = MSE(f(x + noise1), f(x + noise2))
这个方法利用了未标记数据:即使不知道标签,
也可以通过约束不同扰动版本的预测一致性来利用未标记数据
"""
def __init__(self, input_dim, n_classes, hidden_dim=64):
super().__init__()
self.network = nn.Sequential( # 神经网络
nn.Linear(input_dim, hidden_dim), # 第一层
nn.ReLU(), # 激活
nn.Dropout(0.3), # Dropout(提供随机性)
nn.Linear(hidden_dim, hidden_dim), # 第二层
nn.ReLU(), # 激活
nn.Dropout(0.3), # Dropout
nn.Linear(hidden_dim, n_classes) # 输出层
)
def forward(self, x):
return self.network(x) # 前向传播
def train_pi_model(model, X_labeled, y_labeled, X_unlabeled,
epochs=100, lr=0.001, consistency_weight=1.0):
"""
训练Pi-Model
参数:
model: Pi-Model模型
X_labeled: 标记数据特征
y_labeled: 标记数据标签
X_unlabeled: 未标记数据特征
epochs: 训练轮数
lr: 学习率
consistency_weight: 一致性损失的权重λ
"""
optimizer = optim.Adam(model.parameters(), lr=lr) # Adam优化器
classification_criterion = nn.CrossEntropyLoss() # 分类损失
consistency_criterion = nn.MSELoss() # 一致性损失(均方误差)
for epoch in range(epochs): # 遍历每个epoch
model.train() # 训练模式(启用Dropout)
# ---- 分类损失(在标记数据上) ----
logits_labeled = model(X_labeled) # 标记数据的预测(第一次前向传播)
cls_loss = classification_criterion(logits_labeled, y_labeled) # 分类损失
# ---- 一致性损失(在未标记数据上) ----
# 两次前向传播(Dropout会随机关闭不同神经元,产生不同输出)
logits_unlabeled_1 = model(X_unlabeled) # 第一次预测
logits_unlabeled_2 = model(X_unlabeled) # 第二次预测(Dropout模式下不同)
consistency_loss = consistency_criterion( # 计算两次预测的一致性损失
torch.softmax(logits_unlabeled_1, dim=-1), # 转为概率
torch.softmax(logits_unlabeled_2, dim=-1) # 转为概率
)
# ---- 总损失 ----
# 使用权重调度:一致性权重从0逐渐增大
current_weight = consistency_weight * min(1.0, epoch / (epochs * 0.3))
# 前30%的epoch中,一致性权重线性增大,之后保持不变
# 这是因为一开始模型预测不准确,一致性约束可能引入噪声
total_loss = cls_loss + current_weight * consistency_loss # 组合损失
optimizer.zero_grad() # 清零梯度
total_loss.backward() # 反向传播
optimizer.step() # 更新参数
if (epoch + 1) % 20 == 0: # 每20轮打印
print(f"Epoch [{epoch+1}/{epochs}], "
f"Cls Loss: {cls_loss.item():.4f}, "
f"Consistency Loss: {consistency_loss.item():.4f}, "
f"Total: {total_loss.item():.4f}, "
f"Consistency Weight: {current_weight:.4f}")
# ==================== 运行半监督学习实验 ====================
def semi_supervised_experiment():
"""完整的半监督学习对比实验"""
n_samples = 1000 # 总样本数
n_features = 20 # 特征维度
n_classes = 5 # 类别数
n_labeled = 50 # 标记样本数量(很少)
# 生成数据
X = torch.randn(n_samples, n_features) # 随机特征
y = torch.randint(0, n_classes, (n_samples,)) # 随机标签
# 划分标记/未标记/测试
X_labeled = X[:n_labeled] # 前50个作为标记数据
y_labeled = y[:n_labeled] # 对应标签
X_unlabeled = X[n_labeled:800] # 50-800作为未标记数据(750个)
X_test = X[800:] # 800-1000作为测试数据
y_test = y[800:] # 测试标签
# ---- 基线:仅用标记数据训练 ----
print("=" * 60)
print("实验1: 仅用标记数据训练(基线)")
class SimpleNet(nn.Module): # 简单网络
def __init__(self, in_dim, n_cls):
super().__init__()
self.net = nn.Sequential(
nn.Linear(in_dim, 64), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(64, 32), nn.ReLU(),
nn.Linear(32, n_cls)
)
def forward(self, x):
return self.net(x)
model_baseline = SimpleNet(n_features, n_classes) # 基线模型
optimizer = optim.Adam(model_baseline.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
for epoch in range(200): # 训练200轮
output = model_baseline(X_labeled)
loss = criterion(output, y_labeled)
optimizer.zero_grad()
loss.backward()
optimizer.step()
with torch.no_grad():
acc_baseline = (model_baseline(X_test).argmax(1) == y_test).float().mean()
print(f"测试准确率: {acc_baseline:.4f}")
# ---- 自训练 ----
print("\n" + "=" * 60)
print("实验2: 自训练半监督学习")
model_self = SimpleNet(n_features, n_classes)
self_trainer = SemiSupervisedSelfTraining(model_self, confidence_threshold=0.8)
self_trainer.fit(X_labeled.clone(), y_labeled.clone(), X_unlabeled.clone(), n_iterations=8)
with torch.no_grad():
acc_self = (model_self(X_test).argmax(1) == y_test).float().mean()
print(f"自训练测试准确率: {acc_self:.4f}")
# ---- Pi-Model ----
print("\n" + "=" * 60)
print("实验3: Pi-Model(一致性正则化)")
model_pi = PiModel(n_features, n_classes)
train_pi_model(model_pi, X_labeled, y_labeled, X_unlabeled,
epochs=200, consistency_weight=5.0)
model_pi.eval()
with torch.no_grad():
acc_pi = (model_pi(X_test).argmax(1) == y_test).float().mean()
print(f"Pi-Model测试准确率: {acc_pi:.4f}")
# 总结
print("\n" + "=" * 60)
print("实验总结:")
print(f"仅标记数据: {acc_baseline:.4f}")
print(f"自训练: {acc_self:.4f}")
print(f"Pi-Model: {acc_pi:.4f}")
semi_supervised_experiment() # 执行实验
七、多任务学习(Multi-task Learning)
7.1 核心知识点
多任务学习通过同时学习多个相关任务来提高泛化能力。模型在多个任务上共享表示,从而获得更好的特征学习。
核心思想:
- 共享底层特征表示,每个任务有自己的任务特定层
- 不同任务提供不同的"视角",帮助模型学到更鲁棒的特征
- 充当正则化:任务B防止模型在任务A上过拟合
架构类型:
| 类型 | 描述 |
|---|---|
| 硬参数共享 | 所有任务共享相同的底层网络,每个任务有独立的输出头 |
| 软参数共享 | 每个任务有自己的网络,但通过正则化约束参数相似 |
| 分层共享 | 不同层共享不同的子集 |
损失函数:
Ltotal=∑t=1TλtLtL_{total} = \sum_{t=1}^{T} \lambda_t L_tLtotal=t=1∑TλtLt
其中 λt\lambda_tλt 是任务 ttt 的权重,LtL_tLt 是任务 ttt 的损失。
7.2 多任务学习代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器
import numpy as np # 导入数值计算库
torch.manual_seed(42) # 设置随机种子
# ==================== 多任务学习模型定义 ====================
class MultiTaskNet(nn.Module):
"""
硬参数共享的多任务学习网络
架构:
[输入] -> [共享层] -> [任务A头] -> 输出A
|
+-> [任务B头] -> 输出B
|
+-> [任务C头] -> 输出C
共享层学习通用特征,任务头学习任务特定特征
"""
def __init__(self, input_dim, shared_dim=128, task_dims=None, n_tasks=3):
"""
参数:
input_dim: 输入特征维度
shared_dim: 共享层的隐藏维度
task_dims: 每个任务头的隐藏维度列表
n_tasks: 任务数量
"""
super().__init__()
if task_dims is None: # 如果没有指定任务头维度
task_dims = [64, 64, 64] # 默认每个任务头64维
# ---- 共享层(所有任务共享的特征提取器) ----
self.shared_layers = nn.Sequential( # 共享特征提取网络
nn.Linear(input_dim, shared_dim), # 输入 -> 共享层1
nn.BatchNorm1d(shared_dim), # 批量归一化(稳定训练)
nn.ReLU(), # ReLU激活
nn.Dropout(0.3), # Dropout正则化
nn.Linear(shared_dim, shared_dim // 2), # 共享层1 -> 共享层2
nn.BatchNorm1d(shared_dim // 2), # 批量归一化
nn.ReLU(), # ReLU激活
)
shared_output_dim = shared_dim // 2 # 共享层输出维度
# ---- 任务特定头(每个任务独立的输出层) ----
self.task_heads = nn.ModuleList() # ModuleList可以包含多个子模块
for i in range(n_tasks): # 为每个任务创建独立的头
head = nn.Sequential(
nn.Linear(shared_output_dim, task_dims[i]), # 共享输出 -> 任务隐藏层
nn.ReLU(), # ReLU激活
nn.Dropout(0.2), # Dropout
nn.Linear(task_dims[i], 1) # 任务隐藏层 -> 任务输出(回归值)
)
self.task_heads.append(head) # 添加到ModuleList
def forward(self, x):
"""
前向传播
参数:
x: 输入特征 [batch_size, input_dim]
返回:
outputs: 所有任务的输出列表 [task1_output, task2_output, ...]
"""
shared_features = self.shared_layers(x) # 通过共享层提取特征
outputs = [] # 存储各任务输出
for head in self.task_heads: # 遍历每个任务头
task_output = head(shared_features) # 任务头处理共享特征
outputs.append(task_output) # 添加到输出列表
return outputs # 返回所有任务的输出
# ==================== 多任务损失函数 ====================
class MultiTaskLoss(nn.Module):
"""
多任务损失函数,支持自动权重调节
方法1:固定权重 L = Σ λᵢ Lᵢ
方法2:不确定性加权(Kendall et al., 2018)
L = Σ (1/(2σᵢ²)) Lᵢ + log(σᵢ)
"""
def __init__(self, n_tasks, method='fixed', task_weights=None):
"""
参数:
n_tasks: 任务数量
method: 'fixed'(固定权重)或 'uncertainty'(不确定性加权)
task_weights: 固定权重模式下的各任务权重
"""
super().__init__()
self.n_tasks = n_tasks
self.method = method
if method == 'fixed': # 固定权重模式
if task_weights is None: # 如果没有指定权重
task_weights = [1.0] * n_tasks # 默认等权重
self.task_weights = task_weights # 保存权重
elif method == 'uncertainty': # 不确定性加权模式
# 可学习的log方差参数(每个任务一个)
self.log_vars = nn.Parameter(torch.zeros(n_tasks))
# log_vars初始化为0,即σ²=1,初始时各任务等权重
def forward(self, losses):
"""
计算多任务总损失
参数:
losses: 各任务的损失列表 [loss_1, loss_2, ..., loss_T]
返回:
total_loss: 加权总损失
"""
if self.method == 'fixed': # 固定权重
total_loss = 0.0
for i, loss in enumerate(losses): # 遍历各任务损失
total_loss += self.task_weights[i] * loss # 加权求和
return total_loss
elif self.method == 'uncertainty': # 不确定性加权
total_loss = 0.0
for i, loss in enumerate(losses): # 遍历各任务损失
precision = torch.exp(-self.log_vars[i]) # 精度 = 1/σ² = exp(-log(σ²))
total_loss += precision * loss + self.log_vars[i] # 不确定性加权损失
return total_loss
# ==================== 数据生成 ====================
def generate_multitask_data(n_samples=1000, input_dim=15):
"""
生成多任务学习的模拟数据
三个任务共享部分特征,同时有各自的特定特征:
- 任务A:主要依赖特征0-4
- 任务B:主要依赖特征3-7
- 任务C:主要依赖特征5-9
特征有重叠,体现了多任务学习中共享表示的价值
"""
X = torch.randn(n_samples, input_dim) # 生成随机输入特征
# 任务A的标签:依赖特征0-4
w_a = torch.zeros(input_dim) # 任务A的真实权重
w_a[:5] = torch.tensor([1.0, -0.5, 0.8, -0.3, 0.6]) # 设定前5个特征的权重
y_a = (X @ w_a + 0.1 * torch.randn(n_samples)).unsqueeze(1) # 任务A标签
# 任务B的标签:依赖特征3-7(与任务A重叠特征3-4)
w_b = torch.zeros(input_dim)
w_b[3:8] = torch.tensor([-0.4, 0.7, 1.2, -0.6, 0.3])
y_b = (X @ w_b + 0.1 * torch.randn(n_samples)).unsqueeze(1)
# 任务C的标签:依赖特征5-9(与任务B重叠特征5-7)
w_c = torch.zeros(input_dim)
w_c[5:10] = torch.tensor([0.5, -1.0, 0.3, 0.9, -0.4])
y_c = (X @ w_c + 0.1 * torch.randn(n_samples)).unsqueeze(1)
return X, y_a, y_b, y_c # 返回输入和三个任务的标签
# ==================== 训练和评估 ====================
def train_multitask():
"""完整的多任务学习训练和评估"""
# 生成数据
X, y_a, y_b, y_c = generate_multitask_data(n_samples=1000, input_dim=15)
# 划分训练/测试
train_X, test_X = X[:800], X[800:]
train_y = [y_a[:800], y_b[:800], y_c[:800]] # 训练集三个任务的标签
test_y = [y_a[800:], y_b[800:], y_c[800:]] # 测试集三个任务的标签
# 创建模型
model = MultiTaskNet(input_dim=15, shared_dim=128, n_tasks=3) # 三任务模型
mt_loss = MultiTaskLoss(n_tasks=3, method='uncertainty') # 不确定性加权损失
optimizer = optim.Adam(
list(model.parameters()) + list(mt_loss.parameters()), # 优化模型和损失权重参数
lr=0.001
)
task_criterion = nn.MSELoss() # 每个任务使用MSE损失
# 训练循环
for epoch in range(200): # 训练200轮
model.train() # 训练模式
outputs = model(train_X) # 前向传播,获取所有任务输出
# 计算各任务损失
task_losses = [] # 存储各任务损失
for i, (output, target) in enumerate(zip(outputs, train_y)):
loss_i = task_criterion(output, target) # 任务i的MSE损失
task_losses.append(loss_i) # 保存
# 计算总损失
total_loss = mt_loss(task_losses) # 多任务加权损失
optimizer.zero_grad() # 清零梯度
total_loss.backward() # 反向传播
optimizer.step() # 更新参数
if (epoch + 1) % 50 == 0: # 每50轮打印
model.eval()
with torch.no_grad():
test_outputs = model(test_X)
test_losses = [task_criterion(out, tgt).item()
for out, tgt in zip(test_outputs, test_y)]
total_test = sum(test_losses)
# 获取学习到的任务权重
if mt_loss.method == 'uncertainty':
weights = torch.exp(-mt_loss.log_vars).detach().numpy()
else:
weights = mt_loss.task_weights
print(f"Epoch {epoch+1}: Train Loss={total_loss.item():.4f}, "
f"Test Loss={total_test:.4f}")
print(f" Task Weights: [{', '.join(f'{w:.4f}' for w in weights)}]")
print(f" Task Test Losses: [{', '.join(f'{l:.4f}' for l in test_losses)}]")
# ---- 对比:单任务学习 ----
print("\n" + "=" * 60)
print("对比:单任务学习 vs 多任务学习")
for task_idx in range(3): # 对每个任务单独训练
single_model = nn.Sequential( # 单任务模型(没有共享)
nn.Linear(15, 64), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(64, 32), nn.ReLU(),
nn.Linear(32, 1)
)
optimizer_s = optim.Adam(single_model.parameters(), lr=0.001)
for epoch in range(200): # 训练200轮
pred = single_model(train_X) # 前向传播
loss = task_criterion(pred, train_y[task_idx]) # 该任务的损失
optimizer_s.zero_grad()
loss.backward()
optimizer_s.step()
with torch.no_grad():
single_test_loss = task_criterion(single_model(test_X), test_y[task_idx]).item()
multi_test_loss = task_criterion(model(test_X)[task_idx], test_y[task_idx]).item()
print(f"任务{chr(65+task_idx)}: 单任务={single_test_loss:.4f}, 多任务={multi_test_loss:.4f}")
train_multitask() # 执行实验
八、提前终止(Early Stopping)
8.1 核心知识点
提前终止是最常用且最有效的正则化方法之一。核心思想是:当验证集上的性能不再提升时,停止训练。
工作原理:
- 在每个epoch结束后,在验证集上评估模型性能
- 如果验证集性能相比上一次提升,保存模型参数
- 如果验证集性能连续若干个epoch没有提升(patience),停止训练
- 返回验证集性能最好的模型参数
等价于L2正则化: 对于简单的线性模型,提前终止等价于L2正则化,因为训练时间 τ\tauτ 与正则化系数 λ\lambdaλ 之间存在对应关系:λ∝1/τ\lambda \propto 1/\tauλ∝1/τ。
8.2 提前终止完整代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器
import numpy as np # 导入数值计算库
import copy # 导入copy模块(用于深拷贝模型)
import matplotlib.pyplot as plt # 导入绘图库
torch.manual_seed(42) # 设置随机种子
# ==================== Early Stopping 实现 ====================
class EarlyStopping:
"""
提前终止器
功能:
- 监控验证集指标
- 如果指标连续patience个epoch没有改善,触发终止
- 保存最佳模型参数
参数:
patience: 容忍多少个epoch没有改善(默认10)
min_delta: 最小改善幅度,低于此值不算改善(默认0.0001)
mode: 'min'表示指标越小越好(如损失),'max'表示越大越好(如准确率)
verbose: 是否打印详细信息
"""
def __init__(self, patience=10, min_delta=0.0001, mode='min', verbose=True):
self.patience = patience # 耐心值
self.min_delta = min_delta # 最小改善阈值
self.mode = mode # 模式
self.verbose = verbose # 详细输出标志
self.counter = 0 # 当前连续无改善的计数
self.best_score = None # 最佳分数
self.early_stop = False # 是否触发提前终止
self.best_model_state = None # 最佳模型的状态字典
def __call__(self, score, model):
"""
每个epoch结束后调用,检查是否应该终止
参数:
score: 当前epoch的验证指标
model: 当前模型(用于保存最佳状态)
返回:
是否触发提前终止
"""
if self.best_score is None: # 第一次调用
self.best_score = score # 记录为最佳分数
self.best_model_state = copy.deepcopy(model.state_dict()) # 保存模型状态
# deepcopy进行深拷贝,确保保存的是独立的副本
elif self._is_improvement(score): # 如果有改善
self.best_score = score # 更新最佳分数
self.best_model_state = copy.deepcopy(model.state_dict()) # 更新最佳模型
self.counter = 0 # 重置计数器
if self.verbose:
print(f" Validation improved! Best: {self.best_score:.6f}")
else: # 没有改善
self.counter += 1 # 计数器加1
if self.verbose:
print(f" No improvement for {self.counter}/{self.patience} epochs")
if self.counter >= self.patience: # 超过耐心值
self.early_stop = True # 触发提前终止
if self.verbose:
print(f" *** Early stopping triggered! ***")
return self.early_stop # 返回是否终止
def _is_improvement(self, score):
"""检查当前分数是否比最佳分数有改善"""
if self.mode == 'min': # 越小越好(如损失)
return score < self.best_score - self.min_delta # 小于最佳值减阈值
else: # 越大越好(如准确率)
return score > self.best_score + self.min_delta # 大于最佳值加阈值
def load_best_model(self, model):
"""加载最佳模型参数"""
if self.best_model_state is not None: # 如果有保存的最佳状态
model.load_state_dict(self.best_model_state) # 加载到模型中
if self.verbose:
print(f"Loaded best model with score: {self.best_score:.6f}")
# ==================== 完整的带Early Stopping的训练流程 ====================
class DeepModel(nn.Module):
"""较深的神经网络(容易过拟合,适合展示early stopping效果)"""
def __init__(self, input_dim, hidden_dims=[256, 128, 64], output_dim=1):
super().__init__()
layers = [] # 存储网络层
prev_dim = input_dim # 上一层的维度
for hidden_dim in hidden_dims: # 遍历每个隐藏层维度
layers.append(nn.Linear(prev_dim, hidden_dim)) # 全连接层
layers.append(nn.BatchNorm1d(hidden_dim)) # 批量归一化
layers.append(nn.ReLU()) # ReLU激活
layers.append(nn.Dropout(0.3)) # Dropout正则化
prev_dim = hidden_dim # 更新上一层维度
layers.append(nn.Linear(prev_dim, output_dim)) # 输出层
self.network = nn.Sequential(*layers) # 将所有层组合成序列
def forward(self, x):
return self.network(x) # 前向传播
def train_with_early_stopping():
"""带提前终止的完整训练流程"""
# 生成容易过拟合的数据
n_samples = 800
n_features = 50
X = torch.randn(n_samples, n_features) # 随机特征
# 只有前3个特征有效
true_w = torch.zeros(n_features, 1)
true_w[:3] = torch.tensor([[2.0], [-1.5], [0.8]])
y = X @ true_w + 0.5 * torch.randn(n_samples, 1) # 带噪声标签
# 划分训练/验证/测试
train_X, val_X, test_X = X[:500], X[500:650], X[650:] # 500/150/150
train_y, val_y, test_y = y[:500], y[500:650], y[650:]
# 创建模型
model = DeepModel(input_dim=n_features) # 创建深度模型
optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4) # Adam + L2
criterion = nn.MSELoss() # MSE损失
scheduler = optim.lr_scheduler.ReduceLROnPlateau( # 学习率调度器
optimizer, mode='min', factor=0.5, patience=5, verbose=True
)
# 当验证损失连续5个epoch不下降时,学习率乘以0.5
# 创建Early Stopping
early_stopping = EarlyStopping(patience=15, min_delta=0.001, mode='min', verbose=True)
# 15个epoch没有改善就停止,最小改善幅度0.001
# 记录训练历史
history = {'train_loss': [], 'val_loss': [], 'test_loss': []}
max_epochs = 500 # 最大训练轮数
for epoch in range(max_epochs): # 训练循环
# ---- 训练阶段 ----
model.train() # 训练模式
train_pred = model(train_X) # 前向传播
train_loss = criterion(train_pred, train_y) # 计算训练损失
optimizer.zero_grad() # 清零梯度
train_loss.backward() # 反向传播
optimizer.step() # 更新参数
# ---- 验证阶段 ----
model.eval() # 评估模式
with torch.no_grad(): # 不计算梯度
val_pred = model(val_X) # 验证集预测
val_loss = criterion(val_pred, val_y) # 验证损失
test_pred = model(test_X) # 测试集预测
test_loss = criterion(test_pred, test_y) # 测试损失
# 记录
history['train_loss'].append(train_loss.item())
history['val_loss'].append(val_loss.item())
history['test_loss'].append(test_loss.item())
# 学习率调度
scheduler.step(val_loss) # 根据验证损失调整学习率
# 打印进度
if (epoch + 1) % 10 == 0:
print(f"Epoch [{epoch+1}/{max_epochs}], "
f"Train: {train_loss.item():.4f}, "
f"Val: {val_loss.item():.4f}, "
f"Test: {test_loss.item():.4f}")
# ---- Early Stopping 检查 ----
if early_stopping(val_loss.item(), model): # 如果触发提前终止
print(f"\n提前终止于 Epoch {epoch+1}!")
break # 退出训练循环
# 加载最佳模型
early_stopping.load_best_model(model) # 恢复最佳模型参数
# 最终评估
model.eval()
with torch.no_grad():
final_test_loss = criterion(model(test_X), test_y).item()
print(f"\n最终测试损失: {final_test_loss:.4f}")
# 可视化
fig, ax = plt.subplots(1, 1, figsize=(12, 5))
ax.plot(history['train_loss'], label='Train Loss', alpha=0.8)
ax.plot(history['val_loss'], label='Validation Loss', alpha=0.8)
ax.plot(history['test_loss'], label='Test Loss', alpha=0.8)
ax.axvline(x=len(history['val_loss']) - 1 - early_stopping.counter,
color='red', linestyle='--', label='Best Model')
ax.set_xlabel('Epoch')
ax.set_ylabel('Loss')
ax.set_title('Early Stopping Training History')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('early_stopping.png', dpi=150, bbox_inches='tight')
plt.show()
train_with_early_stopping() # 执行实验
九、参数绑定和参数共享(Parameter Tying and Parameter Sharing)
9.1 核心知识点
参数绑定(Parameter Tying): 两个模型的参数不完全相同,但通过正则化鼓励它们接近:
Ω(θA,θB)=∥θA−θB∥22\Omega(\theta_A, \theta_B) = \|\theta_A - \theta_B\|_2^2Ω(θA,θB)=∥θA−θB∥22
参数共享(Parameter Sharing): 多个模型直接共享同一组参数(完全相同)。这是CNN的核心思想。
| 概念 | 描述 | 优点 |
|---|---|---|
| 参数共享 | 不同位置使用完全相同的参数 | 减少参数量,平移不变性 |
| 参数绑定 | 不同参数通过正则化鼓励接近 | 灵活性更强 |
典型应用:
- CNN中的卷积核共享:同一卷积核在图像不同位置共享
- Siamese网络:两个分支共享参数,用于相似度学习
- 循环神经网络:不同时间步共享权重
9.2 参数共享代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器
import numpy as np # 导入数值计算库
import matplotlib.pyplot as plt # 导入绘图库
torch.manual_seed(42) # 设置随机种子
# ==================== 1. CNN中的参数共享(卷积核共享) ====================
class ConvNet(nn.Module):
"""
卷积神经网络:展示参数共享
核心思想:同一个卷积核在图像的所有位置共享参数
这确保了平移不变性(特征在图像任何位置都能被检测到)
一个3x3卷积核只有9个参数,但在28x28图像上相当于
26x26=676个位置各用了一次,参数效率极高
"""
def __init__(self):
super().__init__()
# ---- 卷积层1:参数共享的体现 ----
self.conv1 = nn.Conv2d(
in_channels=1, # 输入通道数(灰度图为1)
out_channels=32, # 输出通道数(32个不同的卷积核)
kernel_size=3, # 卷积核大小3x3
stride=1, # 步长1
padding=1 # 填充1(保持空间尺寸不变)
)
# 每个卷积核有3*3*1=9个权重参数 + 1个偏置 = 10个参数
# 但这个3x3卷积核在输入图像的所有26x26个位置共享!
# 如果不共享,每个位置需要独立参数: 26*26*10 = 6760个参数
# 共享后只需要: 32*10 = 320个参数
self.bn1 = nn.BatchNorm2d(32) # 批量归一化
self.pool1 = nn.MaxPool2d(2) # 最大池化 2x2,尺寸减半
# ---- 卷积层2 ----
self.conv2 = nn.Conv2d(32, 64, 3, padding=1) # 32通道->64通道
self.bn2 = nn.BatchNorm2d(64)
self.pool2 = nn.MaxPool2d(2)
# ---- 全连接层(无参数共享) ----
self.fc1 = nn.Linear(64 * 7 * 7, 128) # 全连接层
self.fc2 = nn.Linear(128, 10) # 输出层(10个类别)
self.relu = nn.ReLU()
self.dropout = nn.Dropout(0.5)
def forward(self, x):
"""前向传播"""
# 卷积层1
x = self.pool1(self.relu(self.bn1(self.conv1(x)))) # 卷积->BN->ReLU->池化
# 卷积层2
x = self.pool2(self.relu(self.bn2(self.conv2(x)))) # 卷积->BN->ReLU->池化
# 展平
x = x.view(x.size(0), -1) # [batch, 64, 7, 7] -> [batch, 3136]
# 全连接层
x = self.dropout(self.relu(self.fc1(x))) # FC1->ReLU->Dropout
return self.fc2(x) # 输出
# ==================== 2. Siamese网络(孪生网络) ====================
class SiameseNetwork(nn.Module):
"""
孪生网络:两个分支共享完全相同的参数
应用场景:
- 面部验证:判断两张照片是否是同一个人
- 签名验证:判断两个签名是否由同一人书写
- 相似度学习:计算两个输入的相似程度
关键:两个分支使用完全相同的网络(self.shared_encoder),
确保相同类型的不同输入被映射到相同的特征空间
"""
def __init__(self, input_dim, embedding_dim=64):
"""
参数:
input_dim: 输入特征维度
embedding_dim: 嵌入向量维度
"""
super().__init__()
# ---- 共享编码器(两个分支使用同一组参数) ----
self.shared_encoder = nn.Sequential( # 共享的特征提取器
nn.Linear(input_dim, 256), # 第一层
nn.ReLU(), # 激活
nn.Dropout(0.3), # Dropout
nn.Linear(256, 128), # 第二层
nn.ReLU(), # 激活
nn.Dropout(0.3), # Dropout
nn.Linear(128, embedding_dim), # 嵌入层
)
# ---- 距离度量层 ----
self.fc_out = nn.Linear(embedding_dim, 1) # 将距离映射为相似度分数
self.sigmoid = nn.Sigmoid() # Sigmoid输出[0,1]的相似度
def encode(self, x):
"""编码:将输入映射为嵌入向量"""
return self.shared_encoder(x) # 使用共享编码器
def forward(self, x1, x2):
"""
前向传播
参数:
x1: 第一个输入 [batch_size, input_dim]
x2: 第二个输入 [batch_size, input_dim]
返回:
similarity: 相似度分数 [batch_size, 1],范围[0,1]
"""
# 两个输入通过**同一个**编码器
embedding1 = self.encode(x1) # 编码第一个输入
embedding2 = self.encode(x2) # 编码第二个输入(共享参数!)
# 计算L1距离(逐元素绝对差)
diff = torch.abs(embedding1 - embedding2) # |e1 - e2|
# L1距离比L2距离对异常值更鲁棒
# 通过全连接层将距离映射为相似度
similarity = self.sigmoid(self.fc_out(diff)) # 输出[0,1]的相似度
return similarity
# ==================== 3. 参数绑定正则化 ====================
class ParameterTiedNet(nn.Module):
"""
参数绑定网络:通过正则化鼓励两组参数接近
与参数共享不同,参数绑定允许两组参数不完全相同,
但通过损失函数中的正则项 Ω = ||θ_A - θ_B||² 鼓励它们接近
应用场景:
- 编码器-解码器结构中编码和解码参数的绑定
- 语言模型中输入嵌入和输出嵌入的绑定
"""
def __init__(self, input_dim, hidden_dim=64):
super().__init__()
# 两组编码器(参数不共享,但鼓励接近)
self.encoder1 = nn.Sequential( # 编码器1
nn.Linear(input_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim // 2)
)
self.encoder2 = nn.Sequential( # 编码器2
nn.Linear(input_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim // 2)
)
# 输出层
self.classifier = nn.Linear(hidden_dim, 2) # 分类器(拼接两个编码器的输出)
def forward(self, x):
"""前向传播"""
h1 = self.encoder1(x) # 编码器1的输出
h2 = self.encoder2(x) # 编码器2的输出
combined = torch.cat([h1, h2], dim=-1) # 拼接两个编码器的输出
return self.classifier(combined) # 分类
def parameter_tying_loss(self, lambda_tie=0.1):
"""
计算参数绑定正则化损失
遍历两组编码器中名称对应的参数,计算它们之间的L2距离
这鼓励两个编码器学到相似的特征表示
参数:
lambda_tie: 参数绑定的正则化强度
返回:
tie_loss: 参数绑定损失
"""
tie_loss = 0.0
# zip将两个编码器的参数配对
for (name1, p1), (name2, p2) in zip(
self.encoder1.named_parameters(), # 编码器1的参数
self.encoder2.named_parameters() # 编码器2的参数
):
tie_loss += torch.sum((p1 - p2) ** 2) # L2距离 ||p1 - p2||²
return lambda_tie * tie_loss # 乘以正则化强度
# ==================== 训练参数绑定网络 ====================
def train_parameter_tying():
"""训练带参数绑定的网络"""
n_samples = 500
input_dim = 20
X = torch.randn(n_samples, input_dim) # 随机特征
y = torch.randint(0, 2, (n_samples,)) # 二分类标签
model = ParameterTiedNet(input_dim) # 创建参数绑定模型
optimizer = optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss() # 分类损失
for epoch in range(100): # 训练100轮
output = model(X) # 前向传播
cls_loss = criterion(output, y) # 分类损失
tie_loss = model.parameter_tying_loss(lambda_tie=0.01) # 参数绑定损失
total_loss = cls_loss + tie_loss # 总损失
optimizer.zero_grad() # 清零梯度
total_loss.backward() # 反向传播
optimizer.step() # 更新参数
if (epoch + 1) % 25 == 0:
print(f"Epoch {epoch+1}: Cls Loss={cls_loss.item():.4f}, "
f"Tie Loss={tie_loss.item():.4f}, Total={total_loss.item():.4f}")
# 检查两组参数的接近程度
with torch.no_grad():
for (n1, p1), (n2, p2) in zip(
model.encoder1.named_parameters(),
model.encoder2.named_parameters()
):
diff = torch.norm(p1 - p2).item() # 计算参数差异的L2范数
print(f"参数 {n1}: 差异 = {diff:.6f}") # 差异应该很小
# 运行实验
print("=" * 60)
print("参数共享(CNN)示例:")
model_cnn = ConvNet()
total_params = sum(p.numel() for p in model_cnn.parameters()) # 统计总参数量
print(f"CNN总参数量: {total_params:,}")
print(f"conv1参数量: {sum(p.numel() for p in model_cnn.conv1.parameters()):,}")
print()
print("=" * 60)
print("Siamese网络示例:")
model_sia = SiameseNetwork(input_dim=50)
# 验证两个分支共享同一组参数
print(f"shared_encoder参数量: {sum(p.numel() for p in model_sia.shared_encoder.parameters()):,}")
print(f"总参数量: {sum(p.numel() for p in model_sia.parameters()):,}")
print()
print("=" * 60)
print("参数绑定训练:")
train_parameter_tying()
十、稀疏表示(Sparse Representation)
10.1 核心知识点
稀疏表示是让模型的隐藏层激活值尽可能稀疏(大部分为0或接近0)。这与参数稀疏(L1正则化使权重为0)不同,这里是让激活值稀疏。
优势:
- 特征选择能力强:只有少数神经元被激活
- 可解释性好:可以知道哪些特征被使用
- 存储和计算效率高
实现方法:
| 方法 | 公式 | 说明 |
|---|---|---|
| L1惩罚 | $\Omega(h) = \sum_i | h_i |
| KL散度 | Ω=∑jρlogρρ\^j+(1−ρ)log1−ρ1−ρ\^j\Omega = \sum_j \\rho \\log\\frac{\\rho}{\\hat{\\rho}_j} + (1-\\rho)\\log\\frac{1-\\rho}{1-\\hat{\\rho}_j}Ω=∑jρlogρ\^jρ+(1−ρ)log1−ρ\^j1−ρ | 鼓励平均激活值接近目标稀疏度ρ |
| L1/2正则化 | 更强的稀疏促进 | 比L1产生更稀疏的解 |
10.2 稀疏表示代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器
import numpy as np # 导入数值计算库
import matplotlib.pyplot as plt # 导入绘图库
torch.manual_seed(42) # 设置随机种子
# ==================== 稀疏自编码器 ====================
class SparseAutoencoder(nn.Module):
"""
稀疏自编码器
结构:输入 -> 编码器 -> 稀疏隐藏层 -> 解码器 -> 重构
目标:
1. 重构损失:重构后的输出应接近原始输入
2. 稀疏惩罚:隐藏层激活值应稀疏
总损失 = 重构损失 + β * 稀疏惩罚
通过约束隐藏层稀疏性,迫使模型学习数据中最重要的特征
即使隐藏层比输入层更大,也能学到有意义的表示
"""
def __init__(self, input_dim, hidden_dim=128, sparsity_target=0.05):
"""
参数:
input_dim: 输入维度
hidden_dim: 隐藏层维度(可以大于输入维度)
sparsity_target: 目标稀疏度ρ(隐藏层平均激活值的目标值)
ρ=0.05表示每个神经元平均只有5%的时间被激活
"""
super().__init__()
# 编码器
self.encoder = nn.Sequential(
nn.Linear(input_dim, hidden_dim), # 输入 -> 隐藏层
nn.ReLU() # ReLU激活(自然产生稀疏激活)
)
# 解码器
self.decoder = nn.Sequential(
nn.Linear(hidden_dim, input_dim), # 隐藏层 -> 重构
nn.Sigmoid() # Sigmoid(如果输入在[0,1]范围内)
)
self.sparsity_target = sparsity_target # 目标稀疏度ρ
def encode(self, x):
"""编码"""
return self.encoder(x) # 获取隐藏层激活
def forward(self, x):
"""前向传播"""
h = self.encode(x) # 编码
x_recon = self.decoder(h) # 解码/重构
return x_recon, h # 返回重构和隐藏层激活
def kl_divergence_loss(self, hidden_activations, beta=1.0):
"""
计算KL散度稀疏惩罚
KL散度衡量实际激活分布与目标稀疏分布之间的差异:
KL(ρ || ρ̂_j) = ρ * log(ρ/ρ̂_j) + (1-ρ) * log((1-ρ)/(1-ρ̂_j))
其中:
- ρ 是目标稀疏度(如0.05)
- ρ̂_j 是第j个神经元在整个batch上的平均激活值
- 当ρ̂_j = ρ时,KL散度为0(理想情况)
- 当ρ̂_j偏离ρ时,KL散度增大(惩罚增大)
参数:
hidden_activations: 隐藏层激活值 [batch_size, hidden_dim]
beta: 稀疏惩罚的权重
返回:
稀疏惩罚损失(标量)
"""
# 计算每个神经元在batch维度上的平均激活值
rho_hat = torch.mean(hidden_activations, dim=0)
# rho_hat形状: [hidden_dim],每个元素是该神经元在batch上的平均激活
rho = self.sparsity_target # 目标稀疏度
# KL散度计算(添加小常数防止log(0))
eps = 1e-8 # 数值稳定性常数
kl_div = rho * torch.log(rho / (rho_hat + eps)) + \
(1 - rho) * torch.log((1 - rho) / (1 - rho_hat + eps))
# 每个元素衡量一个神经元的KL散度
return beta * kl_div.sum() # 对所有神经元求和并乘以权重
def l1_sparsity_loss(self, hidden_activations, lambda_l1=0.01):
"""
L1稀疏惩罚(更简单的方法)
直接对隐藏层激活值施加L1惩罚:
Ω(h) = λ * Σ|h_i|
鼓励大部分激活值为0
参数:
hidden_activations: 隐藏层激活值
lambda_l1: L1惩罚系数
"""
return lambda_l1 * torch.mean(torch.abs(hidden_activations)) # 平均绝对值
# ==================== 训练稀疏自编码器 ====================
def train_sparse_autoencoder():
"""训练稀疏自编码器并可视化稀疏激活模式"""
# 生成模拟数据(模拟MNIST风格的784维数据)
n_samples = 1000
input_dim = 784 # 28x28=784
# 创建有结构的数据(不是纯随机)
X = torch.zeros(n_samples, input_dim) # 初始化数据
for i in range(n_samples): # 为每个样本创建有意义的模式
pattern = torch.randint(0, input_dim, (50,)) # 随机选择50个像素
X[i, pattern] = torch.randn(50) * 0.5 + 0.5 # 设置这些像素的值
X = torch.clamp(X, 0, 1) # 裁剪到[0,1]
# 创建模型
model = SparseAutoencoder( # 创建稀疏自编码器
input_dim=input_dim,
hidden_dim=256, # 隐藏层256维(大于输入稀疏维度)
sparsity_target=0.05 # 目标稀疏度5%
)
optimizer = optim.Adam(model.parameters(), lr=0.001) # Adam优化器
recon_criterion = nn.MSELoss() # 重构损失用MSE
history = {'recon_loss': [], 'sparse_loss': [], 'total_loss': []}
for epoch in range(100): # 训练100轮
model.train() # 训练模式
x_recon, h = model(X) # 前向传播
# 重构损失
recon_loss = recon_criterion(x_recon, X) # MSE重构损失
# 稀疏损失(KL散度)
sparse_loss = model.kl_divergence_loss(h, beta=3.0) # KL散度惩罚
# 或者使用L1: sparse_loss = model.l1_sparsity_loss(h, lambda_l1=0.01)
# 总损失
total_loss = recon_loss + sparse_loss # 组合损失
optimizer.zero_grad() # 清零梯度
total_loss.backward() # 反向传播
optimizer.step() # 更新参数
history['recon_loss'].append(recon_loss.item())
history['sparse_loss'].append(sparse_loss.item())
history['total_loss'].append(total_loss.item())
if (epoch + 1) % 20 == 0:
print(f"Epoch [{epoch+1}/100], "
f"Recon: {recon_loss.item():.4f}, "
f"Sparse: {sparse_loss.item():.4f}, "
f"Total: {total_loss.item():.4f}")
# 分析稀疏性
model.eval()
with torch.no_grad():
_, h = model(X) # 获取隐藏层激活
h_np = h.numpy() # 转为numpy数组
# 统计激活值
mean_activation = np.mean(h_np, axis=0) # 每个神经元的平均激活值
sparsity = np.mean(h_np < 0.01) # 接近零的激活值比例
active_neurons = np.sum(mean_activation > 0.01) # 平均激活值大于0.01的神经元数
print(f"\n稀疏性分析:")
print(f"隐藏层维度: {h_np.shape[1]}") # 隐藏层总神经元数
print(f"平均激活值接近0(<0.01)的比例: {sparsity:.4f}")
print(f"活跃神经元数(平均激活>0.01): {active_neurons}")
print(f"稀疏度目标: {model.sparsity_target}")
# 可视化
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
# 子图1:损失曲线
axes[0].plot(history['recon_loss'], label='Reconstruction Loss')
axes[0].plot(history['sparse_loss'], label='Sparsity Loss')
axes[0].plot(history['total_loss'], label='Total Loss')
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('Loss')
axes[0].set_title('Training Losses')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# 子图2:隐藏层激活值分布
axes[1].hist(h_np.flatten(), bins=100, alpha=0.7, color='steelblue', density=True)
axes[1].axvline(x=model.sparsity_target, color='red', linestyle='--',
label=f'Target ρ={model.sparsity_target}')
axes[1].set_xlabel('Activation Value')
axes[1].set_ylabel('Density')
axes[1].set_title('Distribution of Hidden Activations')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
# 子图3:每个神经元的平均激活值
axes[2].bar(range(len(mean_activation)), sorted(mean_activation, reverse=True),
alpha=0.7, color='coral')
axes[2].axhline(y=model.sparsity_target, color='red', linestyle='--',
label=f'Target ρ={model.sparsity_target}')
axes[2].set_xlabel('Neuron Index (sorted)')
axes[2].set_ylabel('Mean Activation')
axes[2].set_title('Per-Neuron Mean Activation (sorted)')
axes[2].legend()
axes[2].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('sparse_representation.png', dpi=150, bbox_inches='tight')
plt.show()
train_sparse_autoencoder() # 执行实验
十一、Bagging和其他集成方法(Bagging and Ensemble Methods)
11.1 核心知识点
集成方法通过组合多个模型的预测来减少方差(降低过拟合),提高泛化能力。
主要方法:
| 方法 | 描述 | 特点 |
|---|---|---|
| Bagging | Bootstrap Aggregating:对训练集有放回采样,训练多个模型,预测取平均 | 降低方差 |
| Boosting | 顺序训练模型,每个新模型重点关注前一个模型的错误 | 降低偏差 |
| 随机森林 | Bagging + 随机特征子集 | 降低方差和相关性 |
Bagging原理:
- 从训练集中有放回地抽取n个bootstrap样本
- 在每个bootstrap样本上训练一个独立的模型
- 对于回归:预测取所有模型输出的平均值
- 对于分类:预测取所有模型输出的投票结果
11.2 Bagging集成代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器
import numpy as np # 导入数值计算库
from torch.utils.data import DataLoader, TensorDataset, Subset # 数据工具
import matplotlib.pyplot as plt # 导入绘图库
torch.manual_seed(42) # 设置随机种子
np.random.seed(42) # 设置numpy随机种子
# ==================== Bagging集成学习 ====================
class BaggingEnsemble:
"""
Bagging(Bootstrap Aggregating)集成学习
算法流程:
1. 通过有放回采样生成n_estimators个bootstrap样本集
2. 在每个bootstrap样本集上训练一个独立的模型
3. 预测时取所有模型预测的平均值(回归)或投票(分类)
优势:
- 降低方差:多个模型的平均减少了单一模型的随机性
- 模型之间越独立(差异越大),集成效果越好
- 通过bootstrap采样,每个模型看到约63.2%的不同训练数据
"""
def __init__(self, model_class, n_estimators=10, model_kwargs=None):
"""
参数:
model_class: 模型类(不是实例,是类本身)
n_estimators: 集成模型的数量
model_kwargs: 传给模型构造函数的参数字典
"""
self.n_estimators = n_estimators # 模型数量
self.model_class = model_class # 模型类
self.model_kwargs = model_kwargs or {} # 模型参数
self.models = [] # 存储所有模型
self.bootstrap_indices = [] # 存储每个模型的bootstrap采样索引
def _bootstrap_sample(self, dataset_size):
"""
生成bootstrap样本的索引
Bootstrap采样:有放回地从[0, dataset_size)中随机抽取dataset_size个数
约63.2%的原始样本会被选中(有些会被多次选中)
约36.8%的样本不会被选中(称为Out-of-Bag样本,可用于验证)
参数:
dataset_size: 数据集大小
返回:
indices: bootstrap采样得到的索引数组
"""
indices = np.random.choice( # 有放回随机采样
dataset_size, # 从0到dataset_size-1中采样
size=dataset_size, # 采样数量等于原始数据集大小
replace=True # 有放回采样(关键!)
)
return indices
def fit(self, X, y, epochs=100, lr=0.001, batch_size=32):
"""
训练Bagging集成
参数:
X: 训练特征
y: 训练标签
epochs: 每个模型的训练轮数
lr: 学习率
batch_size: batch大小
"""
n_samples = len(X) # 数据集大小
self.models = [] # 清空模型列表
self.bootstrap_indices = [] # 清空索引列表
for i in range(self.n_estimators): # 训练每个集成成员
print(f"Training estimator {i+1}/{self.n_estimators}...")
# 步骤1:生成bootstrap样本
indices = self._bootstrap_sample(n_samples) # 生成bootstrap索引
self.bootstrap_indices.append(indices) # 保存索引
# 创建bootstrap数据子集
X_boot = X[indices] # 选取bootstrap样本的特征
y_boot = y[indices] # 选取bootstrap样本的标签
# 步骤2:创建并训练模型
model = self.model_class(**self.model_kwargs) # 创建新模型
optimizer = optim.Adam(model.parameters(), lr=lr)
criterion = nn.MSELoss()
for epoch in range(epochs): # 训练
model.train()
# 简单的全量训练(也可以使用mini-batch)
pred = model(X_boot)
loss = criterion(pred, y_boot)
optimizer.zero_grad()
loss.backward()
optimizer.step()
self.models.append(model) # 保存训练好的模型
def predict(self, X):
"""
集成预测:取所有模型预测的平均值
回归任务:y_hat = (1/T) * Σ f_t(x)
分类任务:y_hat = argmax(Σ I(f_t(x) = c))
参数:
X: 待预测的特征
返回:
predictions: 平均预测值
"""
all_predictions = [] # 存储所有模型的预测
for model in self.models: # 遍历每个模型
model.eval() # 评估模式
with torch.no_grad(): # 不计算梯度
pred = model(X) # 获取预测
all_predictions.append(pred) # 保存预测
# 堆叠所有预测并取平均
stacked = torch.stack(all_predictions) # 形状: [n_estimators, n_samples, output_dim]
predictions = stacked.mean(dim=0) # 在n_estimators维度上取平均
return predictions
def predict_with_uncertainty(self, X):
"""
带不确定性的预测:不仅返回平均值,还返回预测方差
方差越大表示模型越不确定,可以用于:
- 主动学习:选择最不确定的样本进行标注
- 风险评估:对不确定的预测进行人工审核
参数:
X: 待预测特征
返回:
mean_pred: 平均预测
std_pred: 预测标准差(不确定性)
"""
all_predictions = []
for model in self.models:
model.eval()
with torch.no_grad():
all_predictions.append(model(X))
stacked = torch.stack(all_predictions) # [n_estimators, n_samples, 1]
mean_pred = stacked.mean(dim=0) # 平均预测
std_pred = stacked.std(dim=0) # 预测标准差
return mean_pred, std_pred
# ==================== 定义基学习器 ====================
class BaseEstimator(nn.Module):
"""基础回归模型"""
def __init__(self, input_dim, hidden_dim=32):
super().__init__()
self.net = nn.Sequential(
nn.Linear(input_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, 1)
)
def forward(self, x):
return self.net(x)
# ==================== Bagging实验 ====================
def bagging_experiment():
"""Bagging集成学习实验"""
# 生成复杂非线性数据
n_samples = 500
n_features = 10
X = torch.randn(n_samples, n_features) # 随机特征
# 复杂非线性目标函数
y = (torch.sin(X[:, 0] * 2) +
0.5 * X[:, 1]**2 -
0.3 * torch.cos(X[:, 2] * 3) +
0.1 * torch.randn(n_samples)).unsqueeze(1) # 带噪声标签
# 划分训练/测试
train_X, test_X = X[:400], X[400:]
train_y, test_y = y[:400], y[400:]
# ---- 单个模型 ----
print("训练单个模型...")
single_model = BaseEstimator(n_features) # 创建单个模型
optimizer = optim.Adam(single_model.parameters(), lr=0.001)
criterion = nn.MSELoss()
for epoch in range(200):
pred = single_model(train_X)
loss = criterion(pred, train_y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
with torch.no_grad():
single_pred = single_model(test_X)
single_mse = criterion(single_pred, test_y).item()
print(f"单模型测试MSE: {single_mse:.4f}")
# ---- Bagging集成 ----
for n_est in [3, 5, 10, 20]: # 不同数量的集成成员
print(f"\n训练Bagging集成 (n_estimators={n_est})...")
ensemble = BaggingEnsemble(
model_class=BaseEstimator, # 基学习器类
n_estimators=n_est, # 集成成员数量
model_kwargs={'input_dim': n_features, 'hidden_dim': 32} # 模型参数
)
ensemble.fit(train_X, train_y, epochs=200, lr=0.001) # 训练
with torch.no_grad():
ensemble_pred = ensemble.predict(test_X) # 集成预测
ensemble_mse = criterion(ensemble_pred, test_y).item() # 集成MSE
# 带不确定性的预测
mean_pred, std_pred = ensemble.predict_with_uncertainty(test_X)
avg_uncertainty = std_pred.mean().item() # 平均不确定性
print(f"集成测试MSE: {ensemble_mse:.4f}")
print(f"平均不确定性: {avg_uncertainty:.4f}")
print(f"MSE降低: {(1 - ensemble_mse / single_mse) * 100:.1f}%")
bagging_experiment() # 执行实验
十二、Dropout
12.1 核心知识点
Dropout是最广泛使用的正则化技术之一。训练时以概率 ppp 随机将隐藏层神经元的输出置为0。
工作原理:
- 训练时:每次前向传播随机关闭 ppp 比例的神经元
- 测试时:使用所有神经元,但输出乘以 (1−p)(1-p)(1−p) 或使用inverted dropout
为什么有效:
- 模型平均:等价于训练了指数级数量的子网络并进行平均
- 减少共适应:防止神经元之间形成固定的依赖关系
- 噪声注入:类似于向隐藏层注入乘性噪声
变体:
| 变体 | 描述 |
|---|---|
| 标准Dropout | 对全连接层的隐藏单元随机置零 |
| Spatial Dropout | 对整个特征通道置零(用于CNN) |
| DropConnect | 随机将权重置零(而非激活值) |
| DropBlock | 随机丢弃特征图的连续区域 |
12.2 Dropout完整代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器
import numpy as np # 导入数值计算库
import matplotlib.pyplot as plt # 导入绘图库
torch.manual_seed(42) # 设置随机种子
# ==================== 手动实现Dropout ====================
class ManualDropout(nn.Module):
"""
手动实现Dropout(用于理解原理)
前向传播时:
1. 生成与输入同形状的Bernoulli随机掩码:mask ~ Bernoulli(1-p)
2. 将输入乘以掩码:output = input * mask
3. 除以(1-p)进行缩放(inverted dropout),使得期望输出不变
为什么除以(1-p)?
- 训练时,平均有p比例的神经元被关闭
- 测试时所有神经元都开启
- 为了使训练和测试时的期望输出一致,训练时需要除以(1-p)
- 这样测试时可以直接使用原始输出,无需额外缩放
"""
def __init__(self, p=0.5):
"""
参数:
p: 每个神经元被置零的概率(dropout rate)
例如p=0.5表示平均有50%的神经元被关闭
"""
super().__init__()
self.p = p # dropout概率
def forward(self, x):
"""
前向传播
参数:
x: 输入张量 [任意形状]
返回:
输出张量(训练时应用dropout,测试时直接返回输入)
"""
if self.training: # 训练模式
# 步骤1:生成Bernoulli掩码
mask = (torch.rand_like(x) > self.p).float()
# torch.rand_like(x)生成[0,1)均匀分布的随机数
# > self.p 返回布尔值:True(1)的概率为(1-p),False(0)的概率为p
# .float()转为浮点数
# 步骤2:应用掩码并缩放(inverted dropout)
return x * mask / (1 - self.p) # 除以(1-p)进行缩放
else: # 测试模式
return x # 直接返回输入(不应用dropout)
# ==================== 带Dropout的深度网络 ====================
class DropoutNet(nn.Module):
"""
带Dropout的深度神经网络
Dropout层的位置:
- 通常放在隐藏层之后(线性层->激活->Dropout)
- 也可以放在输入层之后
- 输出层之前通常不加Dropout
Dropout概率选择:
- 隐藏层:通常p=0.5(最大正则化效果)
- 输入层:通常p=0.2(较低概率,保留更多信息)
- 网络较小时:可以使用较小的p
- 数据量较大时:可以使用较小的p
"""
def __init__(self, input_dim, hidden_dims=[256, 128, 64], output_dim=1, dropout_rate=0.5):
"""
参数:
input_dim: 输入维度
hidden_dims: 各隐藏层维度列表
output_dim: 输出维度
dropout_rate: Dropout概率
"""
super().__init__()
self.dropout_rate = dropout_rate # 保存dropout率
# 构建网络层
layers = []
prev_dim = input_dim
for i, hidden_dim in enumerate(hidden_dims):
layers.append(nn.Linear(prev_dim, hidden_dim)) # 线性层
if i == 0: # 第一层后使用较低的dropout
layers.append(nn.Dropout(p=dropout_rate * 0.4)) # 输入层dropout
else: # 后续层使用标准dropout
layers.append(nn.Dropout(p=dropout_rate))
layers.append(nn.ReLU()) # ReLU激活
prev_dim = hidden_dim
layers.append(nn.Linear(prev_dim, output_dim)) # 输出层(无dropout)
self.network = nn.Sequential(*layers)
def forward(self, x):
return self.network(x)
# ==================== MC Dropout(蒙特卡洛Dropout) ====================
class MCDropoutNet(nn.Module):
"""
蒙特卡洛Dropout(MC Dropout)
核心思想:测试时也保持Dropout开启,多次前向传播得到预测分布
Gal & Ghahramani (2016) 证明:
- 使用Dropout的深度网络的多次前向传播近似于贝叶斯推断
- 预测均值近似于贝叶斯后验均值
- 预测方差近似于模型不确定性
应用:
- 不确定性估计:知道模型对自己的预测有多自信
- 主动学习:选择最不确定的样本进行标注
- 医疗/自动驾驶等安全关键领域
"""
def __init__(self, input_dim, hidden_dim=64, output_dim=1, dropout_rate=0.3):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.dropout1 = nn.Dropout(dropout_rate)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.dropout2 = nn.Dropout(dropout_rate)
self.fc3 = nn.Linear(hidden_dim, output_dim)
self.relu = nn.ReLU()
self.dropout_rate = dropout_rate
def forward(self, x):
"""标准前向传播(Dropout在训练时开启,测试时关闭)"""
x = self.dropout1(self.relu(self.fc1(x)))
x = self.dropout2(self.relu(self.fc2(x)))
return self.fc3(x)
def mc_forward(self, x, n_samples=100):
"""
蒙特卡洛前向传播:保持Dropout开启,进行多次预测
参数:
x: 输入特征 [batch_size, input_dim]
n_samples: 采样次数(越多越准确,但计算量越大)
返回:
mean: 预测均值 [batch_size, output_dim]
std: 预测标准差(不确定性) [batch_size, output_dim]
samples: 所有采样结果 [n_samples, batch_size, output_dim]
"""
self.train() # 强制开启训练模式(启用Dropout)
# 注意:这里故意设置为train模式,即使在测试时
predictions = [] # 存储每次预测
with torch.no_grad(): # 不需要计算梯度
for _ in range(n_samples): # 重复n_samples次
pred = self.forward(x) # 前向传播(Dropout随机开启)
predictions.append(pred) # 保存预测
samples = torch.stack(predictions) # [n_samples, batch_size, output_dim]
mean = samples.mean(dim=0) # 预测均值
std = samples.std(dim=0) # 预测标准差(不确定性)
return mean, std, samples # 返回均值、标准差和原始采样
# ==================== Dropout实验 ====================
def dropout_experiment():
"""Dropout正则化和MC Dropout实验"""
# 生成数据
n_samples = 600
n_features = 20
X = torch.randn(n_samples, n_features)
y = (torch.sin(X[:, 0]) + 0.5 * X[:, 1]**2 +
0.1 * torch.randn(n_samples)).unsqueeze(1)
train_X, val_X, test_X = X[:350], X[350:450], X[450:]
train_y, val_y, test_y = y[:350], y[350:450], y[450:]
criterion = nn.MSELoss()
# ---- 实验1:不同Dropout率的对比 ----
print("=" * 60)
print("实验1: 不同Dropout率对比")
print(f"{'Dropout率':<15} {'训练MSE':<15} {'测试MSE':<15}")
print("-" * 45)
for dropout_rate in [0.0, 0.1, 0.3, 0.5, 0.7, 0.9]:
model = DropoutNet(n_features, dropout_rate=dropout_rate)
optimizer = optim.Adam(model.parameters(), lr=0.001)
for epoch in range(200):
model.train() # 训练模式(启用Dropout)
pred = model(train_X)
loss = criterion(pred, train_y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
model.eval() # 评估模式(关闭Dropout)
with torch.no_grad():
train_mse = criterion(model(train_X), train_y).item()
test_mse = criterion(model(test_X), test_y).item()
print(f"{dropout_rate:<15.1f} {train_mse:<15.4f} {test_mse:<15.4f}")
# ---- 实验2:MC Dropout不确定性估计 ----
print("\n" + "=" * 60)
print("实验2: MC Dropout不确定性估计")
mc_model = MCDropoutNet(n_features, dropout_rate=0.3)
optimizer = optim.Adam(mc_model.parameters(), lr=0.001)
# 训练
for epoch in range(300):
mc_model.train()
pred = mc_model(train_X)
loss = criterion(pred, train_y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
# MC Dropout预测
mc_model.eval() # 先设置为eval(mc_forward内部会改为train)
mean_pred, uncertainty, samples = mc_model.mc_forward(test_X[:10], n_samples=200)
# 只对前10个测试样本进行MC Dropout
# 标准预测(不使用MC Dropout)
mc_model.eval()
with torch.no_grad():
standard_pred = mc_model(test_X[:10])
print(f"\n前10个测试样本的预测:")
print(f"{'样本':<6} {'真实值':<10} {'标准预测':<12} {'MC均值':<12} {'MC不确定性':<12}")
print("-" * 52)
for i in range(10):
print(f"{i:<6} {test_y[i].item():<10.4f} {standard_pred[i].item():<12.4f} "
f"{mean_pred[i].item():<12.4f} {uncertainty[i].item():<12.4f}")
# 可视化不确定性
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# 预测与不确定性
x_range = range(10)
axes[0].errorbar(x_range, mean_pred.numpy().flatten(),
yerr=uncertainty.numpy().flatten(),
fmt='o', capsize=5, label='MC Dropout Prediction', color='steelblue')
axes[0].scatter(x_range, test_y[:10].numpy().flatten(),
marker='x', s=100, c='red', label='True Values', zorder=5)
axes[0].set_xlabel('Sample Index')
axes[0].set_ylabel('Value')
axes[0].set_title('MC Dropout: Prediction with Uncertainty')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# Dropout采样分布(展示第一个样本)
axes[1].hist(samples[:, 0, 0].numpy(), bins=50, alpha=0.7,
color='steelblue', density=True, label='MC Samples')
axes[1].axvline(x=test_y[0].item(), color='red', linestyle='--',
linewidth=2, label=f'True Value = {test_y[0].item():.3f}')
axes[1].axvline(x=mean_pred[0].item(), color='green', linestyle='--',
linewidth=2, label=f'MC Mean = {mean_pred[0].item():.3f}')
axes[1].set_xlabel('Predicted Value')
axes[1].set_ylabel('Density')
axes[1].set_title('MC Dropout: Prediction Distribution (Sample 0)')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('dropout_mc.png', dpi=150, bbox_inches='tight')
plt.show()
dropout_experiment() # 执行实验
十三、对抗训练(Adversarial Training)
13.1 核心知识点
对抗训练通过向训练数据添加对抗样本来提高模型对对抗攻击的鲁棒性。
对抗样本: 对输入施加微小的、人眼不可见的扰动,可以使模型产生错误预测。
常见攻击方法:
| 攻击方法 | 公式 | 说明 |
|---|---|---|
| FGSM | x~=x+ϵ⋅sign(∇xJ)\tilde{x} = x + \epsilon \cdot \text{sign}(\nabla_x J)x~=x+ϵ⋅sign(∇xJ) | 快速梯度符号法,一步攻击 |
| PGD | xt+1=ΠS(xt+α⋅sign(∇xJ))x^{t+1} = \Pi_{S}(x^t + \alpha \cdot \text{sign}(\nabla_x J))xt+1=ΠS(xt+α⋅sign(∇xJ)) | 投影梯度下降,迭代攻击 |
| CW | 优化问题 | Carlini & Wagner攻击,最强攻击之一 |
对抗训练目标:
minθE(x,y)maxδ:∥δ∥≤ϵJ(θ,x+δ,y)\min_{\theta} \mathbb{E}_{(x,y)} \left \\max_{\\delta: \\\|\\delta\\\| \\leq \\epsilon} J(\\theta, x + \\delta, y) \\rightθminE(x,y)δ:∥δ∥≤ϵmaxJ(θ,x+δ,y)
内层最大化:找到最坏情况的扰动
外层最小化:使模型在最坏情况下也能正确预测
13.2 对抗训练代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器
import numpy as np # 导入数值计算库
import matplotlib.pyplot as plt # 导入绘图库
torch.manual_seed(42) # 设置随机种子
# ==================== 对抗攻击方法 ====================
class AdversarialAttacks:
"""
常见对抗攻击方法的实现
对抗攻击的核心思想:
找到一个微小的扰动δ,使得损失J(θ, x+δ, y)最大化
约束条件:||δ|| ≤ ε(扰动足够小,人眼不可察觉)
"""
@staticmethod
def fgsm_attack(model, x, y, criterion, epsilon=0.1):
"""
FGSM (Fast Gradient Sign Method) 攻击
Goodfellow et al., 2014
一步攻击:沿损失函数梯度的符号方向添加扰动
δ = ε * sign(∇_x J(θ, x, y))
优点:计算快速,只需要一次前向+反向传播
缺点:攻击强度相对较弱
参数:
model: 目标模型
x: 原始输入 [batch_size, ...]
y: 真实标签
criterion: 损失函数
epsilon: 扰动大小ε
返回:
x_adv: 对抗样本
"""
# 步骤1:确保输入需要梯度
x_adv = x.clone().detach().requires_grad_(True)
# clone()创建副本
# detach()从计算图中分离
# requires_grad_(True)允许计算梯度
# 步骤2:前向传播
output = model(x_adv) # 计算模型输出
loss = criterion(output, y) # 计算损失
# 步骤3:反向传播,计算输入的梯度
model.zero_grad() # 清零模型梯度
loss.backward() # 反向传播
# 现在x_adv.grad包含了损失对输入的梯度
# 步骤4:生成对抗扰动
perturbation = epsilon * x_adv.grad.sign()
# sign()返回梯度的符号:+1或-1
# 乘以ε控制扰动大小
# 沿梯度符号方向扰动使得损失增大
# 步骤5:添加扰动并裁剪到有效范围
x_adv = x.detach() + perturbation # 原始输入 + 扰动
x_adv = torch.clamp(x_adv, 0, 1) # 裁剪到[0,1](图像的有效范围)
return x_adv.detach() # 返回对抗样本
@staticmethod
def pgd_attack(model, x, y, criterion, epsilon=0.1, alpha=0.01, n_steps=10):
"""
PGD (Projected Gradient Descent) 攻击
Madry et al., 2018
迭代攻击:多次小步迭代,每步都投影回ε-球内
x^{t+1} = Π_{B(x,ε)}(x^t + α * sign(∇_x J))
比FGSM更强,是目前公认的最强一阶攻击
参数:
model: 目标模型
x: 原始输入
y: 真实标签
criterion: 损失函数
epsilon: 最大扰动大小ε
alpha: 每步步长α(通常α < ε)
n_steps: 迭代步数
返回:
x_adv: 对抗样本
"""
# 步骤1:从原始样本的ε邻域内随机初始化
x_adv = x.clone().detach() + torch.FloatTensor(*x.shape).uniform_(-epsilon, epsilon)
# 在[-ε, ε]内均匀随机初始化,增加攻击的随机性
x_adv = torch.clamp(x_adv, 0, 1) # 裁剪到有效范围
x_adv = x_adv.detach().requires_grad_(True)
# 步骤2:迭代攻击
for step in range(n_steps): # 迭代n_steps步
output = model(x_adv) # 前向传播
loss = criterion(output, y) # 计算损失
model.zero_grad() # 清零梯度
loss.backward() # 反向传播
# 沿梯度符号方向走一步
x_adv = x_adv.detach() + alpha * x_adv.grad.sign()
# alpha是步长,sign是梯度符号
# 投影:将扰动裁剪到以原始样本为中心的ε-球内
perturbation = torch.clamp(x_adv - x, -epsilon, epsilon)
# 限制扰动大小不超过ε
x_adv = x + perturbation # 重新投影
x_adv = torch.clamp(x_adv, 0, 1) # 裁剪到[0,1]
x_adv = x_adv.detach().requires_grad_(True) # 继续需要梯度
return x_adv.detach() # 返回对抗样本
# ==================== 对抗训练模型 ====================
class AdversarialTrainingModel(nn.Module):
"""
带对抗训练的分类模型
对抗训练过程:
1. 用原始数据计算正常损失
2. 生成对抗样本
3. 用对抗样本计算对抗损失
4. 总损失 = (1-α) * 正常损失 + α * 对抗损失
这使得模型同时在正常数据和对抗数据上表现良好
"""
def __init__(self, input_dim, n_classes, hidden_dim=128):
super().__init__()
self.network = nn.Sequential(
nn.Linear(input_dim, hidden_dim), # 第一层
nn.ReLU(), # 激活
nn.Dropout(0.3), # Dropout
nn.Linear(hidden_dim, hidden_dim // 2), # 第二层
nn.ReLU(), # 激活
nn.Dropout(0.3), # Dropout
nn.Linear(hidden_dim // 2, n_classes) # 输出层
)
def forward(self, x):
return self.network(x) # 前向传播
# ==================== 对抗训练过程 ====================
def adversarial_training():
"""完整的对抗训练实验"""
# 生成数据
n_samples = 1000
n_features = 30
n_classes = 5
X = torch.randn(n_samples, n_features)
y = torch.randint(0, n_classes, (n_samples,))
# 归一化输入到[0,1]
X = (X - X.min()) / (X.max() - X.min())
# 划分数据
train_X, test_X = X[:800], X[800:]
train_y, test_y = y[:800], y[800:]
criterion = nn.CrossEntropyLoss()
# ---- 对比:标准训练 vs 对抗训练 ----
results = {}
for method in ['standard', 'fgsm_adversarial', 'pgd_adversarial']:
print(f"\n{'='*50}")
print(f"训练方法: {method}")
model = AdversarialTrainingModel(n_features, n_classes)
optimizer = optim.Adam(model.parameters(), lr=0.001)
for epoch in range(100):
model.train()
if method == 'standard':
# 标准训练:只使用原始数据
output = model(train_X)
loss = criterion(output, train_y)
elif method == 'fgsm_adversarial':
# FGSM对抗训练
# 正常损失
output = model(train_X)
loss_normal = criterion(output, train_y)
# 对抗损失
x_adv = AdversarialAttacks.fgsm_attack(
model, train_X, train_y, criterion, epsilon=0.1
)
output_adv = model(x_adv)
loss_adv = criterion(output_adv, train_y)
# 组合损失
loss = 0.5 * loss_normal + 0.5 * loss_adv # 正常和对抗损失各占50%
elif method == 'pgd_adversarial':
# PGD对抗训练
output = model(train_X)
loss_normal = criterion(output, train_y)
x_adv = AdversarialAttacks.pgd_attack(
model, train_X, train_y, criterion,
epsilon=0.1, alpha=0.02, n_steps=5
)
output_adv = model(x_adv)
loss_adv = criterion(output_adv, train_y)
loss = 0.5 * loss_normal + 0.5 * loss_adv
optimizer.zero_grad()
loss.backward()
optimizer.step()
if (epoch + 1) % 50 == 0:
model.eval()
with torch.no_grad():
train_acc = (model(train_X).argmax(1) == train_y).float().mean()
print(f" Epoch {epoch+1}, Loss: {loss.item():.4f}, "
f"Train Acc: {train_acc:.4f}")
# 评估
model.eval()
with torch.no_grad():
# 正常测试准确率
normal_acc = (model(test_X).argmax(1) == test_y).float().mean().item()
# FGSM对抗准确率
x_adv_fgsm = AdversarialAttacks.fgsm_attack(
model, test_X, test_y, criterion, epsilon=0.1
)
fgsm_acc = (model(x_adv_fgsm).argmax(1) == test_y).float().mean().item()
# PGD对抗准确率
x_adv_pgd = AdversarialAttacks.pgd_attack(
model, test_X, test_y, criterion, epsilon=0.1, alpha=0.02, n_steps=10
)
pgd_acc = (model(x_adv_pgd).argmax(1) == test_y).float().mean().item()
results[method] = {
'normal': normal_acc,
'fgsm': fgsm_acc,
'pgd': pgd_acc
}
# 打印结果
print("\n" + "=" * 70)
print("对抗训练实验结果:")
print(f"{'训练方法':<20} {'正常准确率':<15} {'FGSM准确率':<15} {'PGD准确率':<15}")
print("-" * 65)
for method, accs in results.items():
print(f"{method:<20} {accs['normal']:.4f}{'':<9} "
f"{accs['fgsm']:.4f}{'':<9} {accs['pgd']:.4f}")
print("\n结论:")
print("1. 标准训练在正常数据上准确率最高,但在对抗样本上脆弱")
print("2. 对抗训练牺牲了一些正常准确率,但显著提高了对抗鲁棒性")
print("3. PGD对抗训练通常获得最好的鲁棒性")
adversarial_training() # 执行实验
十四、切面距离、正切传播和流形正切分类器
14.1 核心知识点
这些方法基于流形假设:高维数据实际上分布在一个低维流形上。在流形上移动时,数据的标签应保持不变。
1. 切面距离(Tangent Distance):
- 用输入空间中的切平面距离代替欧氏距离
- 对每个训练样本估计其所在流形的切向量
- 分类时,比较测试样本与各类别的切平面距离
2. 正切传播(Tangent Propagation):
- 训练时惩罚模型输出对已知变换的敏感性
- 对于已知的变换(如旋转、缩放),计算输入的切向量
- 约束模型沿着这些切向量方向的输出变化尽可能小
Ω=∑i∥∇xf(x)⋅vi∥2\Omega = \sum_i \left\| \nabla_x f(x) \cdot v_i \right\|^2Ω=i∑∥∇xf(x)⋅vi∥2
其中 viv_ivi 是已知变换的切向量。
3. 流形正切分类器(Manifold Tangent Classifier):
- 使用自编码器学习流形的切向量
- 结合正切传播的思想进行正则化
- 不需要事先知道变换的形式
14.2 切面距离、正切传播和流形正切分类器代码
python
import torch # 导入PyTorch
import torch.nn as nn # 导入神经网络模块
import torch.optim as optim # 导入优化器
import numpy as np # 导入数值计算库
import matplotlib.pyplot as plt # 导入绘图库
from torch.autograd import grad # 导入自动微分梯度计算
torch.manual_seed(42) # 设置随机种子
# ==================== 1. 正切传播(Tangent Propagation) ====================
class TangentPropagationNet(nn.Module):
"""
正切传播网络
核心思想:对于已知的变换(如旋转、平移),模型的输出应该保持不变
通过在损失函数中添加正切传播正则项来实现:
Ω = Σ_i ||∇_x f(x) · v_i||²
其中v_i是已知变换的切向量(tangent vector)
例如:
- 旋转切向量:v_rot = ∂(rotate(x,θ))/∂θ|_{θ=0}
- 平移切向量:v_trans_x = ∂(translate(x,Δx))/∂Δx|_{Δx=0}
这意味着:模型的输出对这些已知变换应该是不变的
"""
def __init__(self, input_dim, n_classes, hidden_dim=128):
super().__init__()
self.network = nn.Sequential(
nn.Linear(input_dim, hidden_dim), # 第一层
nn.ReLU(), # 激活
nn.Linear(hidden_dim, hidden_dim // 2), # 第二层
nn.ReLU(), # 激活
nn.Linear(hidden_dim // 2, n_classes) # 输出层
)
def forward(self, x):
"""前向传播"""
return self.network(x)
def tangent_propagation_loss(self, x, tangent_vectors, lambda_tp=1.0):
"""
计算正切传播正则化损失
核心:约束模型输出在沿切向量方向上的变化率尽可能小
即 Jacobian · tangent_vector ≈ 0
参数:
x: 输入 [batch_size, input_dim]
tangent_vectors: 切向量列表 [v1, v2, ...],每个vi形状为[batch_size, input_dim]
lambda_tp: 正切传播的正则化强度
返回:
正切传播损失(标量)
"""
x_grad = x.clone().detach().requires_grad_(True) # 创建需要梯度的输入副本
output = self.forward(x_grad) # 前向传播
# output形状: [batch_size, n_classes]
tp_loss = 0.0 # 初始化正切传播损失
for v in tangent_vectors: # 对每个切向量
# 计算输出对输入的Jacobian矩阵在切向量方向上的投影
# 我们需要:∂f/∂x · v
jacobian_v = torch.zeros_like(output) # 初始化Jacobian·v的结果
for j in range(output.shape[1]): # 对每个输出维度
if x_grad.grad is not None: # 清零之前的梯度
x_grad.grad.zero_()
output[:, j].backward( # 对第j个输出求梯度
torch.ones(output.shape[0]), # 梯度是全1向量
retain_graph=True # 保留计算图(因为要求多次梯度)
)
if x_grad.grad is not None:
# Jacobian的第j行 · 切向量
jacobian_v[:, j] = (x_grad.grad * v).sum(dim=-1)
# 正切传播损失:Jacobian·v 应该接近0
tp_loss += torch.sum(jacobian_v ** 2) # ||J·v||²
return lambda_tp * tp_loss / len(tangent_vectors) # 平均并乘以正则化强度
def generate_tangent_vectors_2d(x, rotation=True, scale=True):
"""
生成2D数据的切向量
对于2D点(x1, x2):
- 旋转切向量:v_rot = (-x2, x1)(逆时针旋转90°的方向)
- 缩放切向量:v_scale = (x1, x2)(径向方向)
参数:
x: 输入数据 [batch_size, 2]
rotation: 是否生成旋转切向量
scale: 是否生成缩放切向量
返回:
tangent_vectors: 切向量列表
"""
tangent_vectors = []
if rotation:
# 旋转切向量:对于点(x1, x2),旋转的切向量是(-x2, x1)
v_rot = torch.stack([-x[:, 1], x[:, 0]], dim=1) # 旋转切向量
tangent_vectors.append(v_rot)
if scale:
# 缩放切向量:对于点(x1, x2),缩放的切向量就是(x1, x2)本身
v_scale = x.clone() # 缩放切向量就是径向方向
tangent_vectors.append(v_scale)
return tangent_vectors
# ==================== 2. 流形正切分类器(Manifold Tangent Classifier) ====================
class ManifoldTangentClassifier(nn.Module):
"""
流形正切分类器 (Rifai et al., 2011)
方法:
1. 使用收缩自编码器(Contractive Autoencoder)学习数据流形的切向量
2. 在分类器的损失中添加正切传播正则项
与标准正切传播的区别:
- 标准正切传播需要预先知道变换形式
- 流形正切分类器通过自编码器自动学习切向量
- 可以处理更复杂的、未知的数据变换
"""
def __init__(self, input_dim, n_classes, hidden_dim=64, embedding_dim=32):
super().__init__()
# ---- 自编码器部分(用于学习流形切向量) ----
self.encoder = nn.Sequential(
nn.Linear(input_dim, hidden_dim), # 编码器第1层
nn.ReLU(),
nn.Linear(hidden_dim, embedding_dim) # 编码器第2层(瓶颈层)
)
self.decoder = nn.Sequential(
nn.Linear(embedding_dim, hidden_dim), # 解码器第1层
nn.ReLU(),
nn.Linear(hidden_dim, input_dim), # 解码器第2层
nn.Sigmoid() # 输出在[0,1]
)
# ---- 分类器部分 ----
self.classifier = nn.Sequential(
nn.Linear(embedding_dim, hidden_dim), # 从嵌入到分类
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(hidden_dim, n_classes) # 输出类别
)
def encode(self, x):
"""编码:获取低维嵌入"""
return self.encoder(x)
def decode(self, z):
"""解码:重构输入"""
return self.decoder(z)
def classify(self, x):
"""分类"""
z = self.encode(x) # 编码
return self.classifier(z) # 分类
def forward(self, x):
"""前向传播(用于分类)"""
return self.classify(x)
def get_tangent_vectors(self, x):
"""
通过收缩自编码器学习切向量
方法:计算编码器的Jacobian矩阵的奇异向量
- 前几个奇异向量对应流形的主要切方向
- 这些方向是编码器最不敏感的方向(数据变化的方向)
实际计算中,使用编码器输出对输入的Jacobian来近似
"""
x_grad = x.clone().detach().requires_grad_(True)
z = self.encode(x_grad) # 编码
tangent_vecs = [] # 存储切向量
for i in range(z.shape[1]): # 对编码的每个维度
if x_grad.grad is not None:
x_grad.grad.zero_()
z[:, i].backward(torch.ones(z.shape[0]), retain_graph=True)
if x_grad.grad is not None:
tangent_vecs.append(x_grad.grad.clone())
return tangent_vecs # 返回切向量列表
def contractive_loss(self, x, lambda_c=1e-4):
"""
收缩自编码器损失:鼓励编码器对输入的小扰动不敏感
L_contractive = λ * ||∂h/∂x||_F²
这使得编码器学习到的表示在数据流形上平滑变化
"""
x_grad = x.clone().detach().requires_grad_(True)
z = self.encode(x_grad)
# 计算Frobenius范数的平方
contractive = 0.0
for i in range(z.shape[1]):
if x_grad.grad is not None:
x_grad.grad.zero_()
z[:, i].backward(torch.ones(z.shape[0]), retain_graph=True)
if x_grad.grad is not None:
contractive += (x_grad.grad ** 2).sum()
return lambda_c * contractive
# ==================== 3. 切面距离分类器 ====================
class TangentDistanceClassifier:
"""
切面距离分类器 (Simard et al., 1993)
思想:用切面距离代替欧氏距离进行最近邻分类
对于每个类别,估计训练样本所在流形的切平面
分类时,计算测试样本到各类别切平面的距离
切面距离比欧氏距离更鲁棒,因为它允许在流形上滑动
"""
def __init__(self, tangent_generators=None):
"""
参数:
tangent_generators: 切向量生成函数列表
"""
self.tangent_generators = tangent_generators or []
self.train_data = None
self.train_labels = None
self.tangent_spaces = None
def fit(self, X, y):
"""
训练:为每个样本估计切空间
参数:
X: 训练数据 [n_samples, n_features]
y: 训练标签 [n_samples]
"""
self.train_data = X # 保存训练数据
self.train_labels = y # 保存训练标签
# 为每个样本计算切向量
self.tangent_spaces = [] # 存储每个样本的切空间
for i in range(len(X)): # 遍历每个训练样本
tangents = []
for gen in self.tangent_generators: # 对每个切向量生成器
v = gen(X[i:i+1]) # 生成切向量
tangents.append(v) # 保存
if tangents:
self.tangent_spaces.append(torch.cat(tangents, dim=0))
else:
self.tangent_spaces.append(None)
def _tangent_distance(self, x_test, x_train, tangent_basis):
"""
计算测试样本到训练样本切平面的距离
切平面距离 = ||x_test - x_train||² - ||proj_{tangent}(x_test - x_train)||²
即:欧氏距离的平方减去在切平面上的投影分量
参数:
x_test: 测试样本
x_train: 训练样本
tangent_basis: 切空间基向量 [n_tangent, n_features]
返回:
切面距离
"""
diff = x_test - x_train # 差向量
if tangent_basis is None or len(tangent_basis) == 0:
return torch.norm(diff) ** 2 # 无切空间时退化为欧氏距离
# Gram-Schmidt正交化切空间基
Q = tangent_basis.clone() # 复制切向量
for i in range(len(Q)): # Gram-Schmidt过程
for j in range(i):
Q[i] = Q[i] - torch.dot(Q[i], Q[j]) / (torch.dot(Q[j], Q[j]) + 1e-8) * Q[j]
Q[i] = Q[i] / (torch.norm(Q[i]) + 1e-8) # 归一化
# 计算在切平面上的投影
projection = torch.zeros_like(diff) # 初始化投影
for v in Q: # 对每个正交化的切向量
projection = projection + torch.dot(diff, v) * v # 累加投影分量
# 切面距离 = 欧氏距离² - 切平面投影²
td = torch.norm(diff) ** 2 - torch.norm(projection) ** 2
return torch.clamp(td, min=0) # 确保非负
def predict(self, X_test):
"""
预测:使用切面距离进行最近邻分类
"""
predictions = []
for x in X_test: # 遍历每个测试样本
min_dist = float('inf') # 初始化最小距离
pred_label = None # 初始化预测标签
for i in range(len(self.train_data)): # 遍历每个训练样本
td = self._tangent_distance( # 计算切面距离
x, self.train_data[i],
self.tangent_spaces[i]
)
if td < min_dist: # 如果更近
min_dist = td # 更新最小距离
pred_label = self.train_labels[i] # 更新预测标签
predictions.append(pred_label) # 添加预测
return torch.stack(predictions) # 返回所有预测
# ==================== 综合实验 ====================
def tangent_methods_experiment():
"""切面距离和正切传播的综合实验"""
# 生成2D数据(旋转和缩放变换下不变的模式)
n_per_class = 100
angles = np.linspace(0, 2*np.pi, n_per_class)
# 类别0:圆上的点
X0 = torch.stack([
torch.cos(torch.tensor(angles)) * 2 + 0.2 * torch.randn(n_per_class),
torch.sin(torch.tensor(angles)) * 2 + 0.2 * torch.randn(n_per_class)
], dim=1)
# 类别1:更远的圆上的点
X1 = torch.stack([
torch.cos(torch.tensor(angles)) * 4 + 0.2 * torch.randn(n_per_class),
torch.sin(torch.tensor(angles)) * 4 + 0.2 * torch.randn(n_per_class)
], dim=1)
X = torch.cat([X0, X1], dim=0) # 合并数据
y = torch.cat([torch.zeros(n_per_class), # 类别0
torch.ones(n_per_class)]) # 类别1
# 划分训练/测试
train_idx = list(range(0, n_per_class, 2)) + list(range(n_per_class, 2*n_per_class, 2))
test_idx = list(range(1, n_per_class, 2)) + list(range(n_per_class+1, 2*n_per_class, 2))
train_X, train_y = X[train_idx], y[train_idx]
test_X, test_y = X[test_idx], y[test_idx]
# ---- 正切传播实验 ----
print("=" * 60)
print("正切传播实验:")
tp_model = TangentPropagationNet(input_dim=2, n_classes=2, hidden_dim=32)
optimizer = optim.Adam(tp_model.parameters(), lr=0.005)
criterion = nn.CrossEntropyLoss()
for epoch in range(200):
tp_model.train()
output = tp_model(train_X)
cls_loss = criterion(output, train_y.long())
# 生成切向量(旋转和缩放)
tangent_vecs = generate_tangent_vectors_2d(train_X, rotation=True, scale=True)
# 计算正切传播损失(降低频率以加快训练)
if epoch % 5 == 0:
tp_loss = tp_model.tangent_propagation_loss(train_X, tangent_vecs, lambda_tp=0.1)
total_loss = cls_loss + tp_loss
else:
total_loss = cls_loss
optimizer.zero_grad()
total_loss.backward()
optimizer.step()
if (epoch + 1) % 50 == 0:
with torch.no_grad():
train_acc = (tp_model(train_X).argmax(1) == train_y).float().mean()
test_acc = (tp_model(test_X).argmax(1) == test_y).float().mean()
print(f"Epoch {epoch+1}: Train Acc={train_acc:.4f}, Test Acc={test_acc:.4f}")
# ---- 切面距离分类器实验 ----
print("\n" + "=" * 60)
print("切面距离分类器实验:")
# 定义切向量生成器
def rotation_tangent(x):
"""生成旋转切向量"""
return torch.stack([-x[:, 1], x[:, 0]], dim=1)
def scale_tangent(x):
"""生成缩放切向量"""
return x.clone()
td_classifier = TangentDistanceClassifier(
tangent_generators=[rotation_tangent, scale_tangent]
)
td_classifier.fit(train_X, train_y)
# 预测(这里只取前20个测试样本,因为计算量大)
n_test_subset = min(20, len(test_X))
predictions = td_classifier.predict(test_X[:n_test_subset])
td_acc = (predictions == test_y[:n_test_subset]).float().mean()
print(f"切面距离分类器准确率 (前{n_test_subset}个样本): {td_acc:.4f}")
# ---- 可视化 ----
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
# 数据和决策边界
for ax_idx, (model, title) in enumerate([
(tp_model, "Tangent Propagation"),
]):
ax = axes[ax_idx]
# 绘制数据点
ax.scatter(train_X[train_y==0][:, 0], train_X[train_y==0][:, 1],
c='blue', alpha=0.5, label='Class 0')
ax.scatter(train_X[train_y==1][:, 0], train_X[train_y==1][:, 1],
c='red', alpha=0.5, label='Class 1')
# 绘制决策边界
xx, yy = np.meshgrid(
np.linspace(-6, 6, 100),
np.linspace(-6, 6, 100)
)
grid = torch.FloatTensor(np.c_[xx.ravel(), yy.ravel()])
model.eval()
with torch.no_grad():
Z = model(grid).argmax(1).numpy().reshape(xx.shape)
ax.contourf(xx, yy, Z, alpha=0.2, cmap='RdBu')
ax.set_title(title)
ax.legend()
ax.grid(True, alpha=0.3)
ax.set_aspect('equal')
# 切向量可视化
ax = axes[2]
ax.scatter(X[:, 0], X[:, 1], c=y, cmap='coolwarm', alpha=0.3, s=10)
# 绘制一些样本的切向量
sample_indices = [0, 50, 100, 150]
colors = ['green', 'orange', 'purple', 'brown']
for idx, c in zip(sample_indices, colors):
x_point = X[idx]
v_rot = torch.tensor([-x_point[1], x_point[0]]) # 旋转切向量
v_rot = v_rot / torch.norm(v_rot) * 0.5 # 归一化并缩放
ax.annotate('', xy=x_point.numpy() + v_rot.numpy(),
xytext=x_point.numpy(),
arrowprops=dict(arrowstyle='->', color=c, lw=2))
ax.scatter(*x_point.numpy(), c=c, s=100, marker='x', linewidth=3)
ax.set_title('Tangent Vectors Visualization')
ax.grid(True, alpha=0.3)
ax.set_aspect('equal')
plt.tight_layout()
plt.savefig('tangent_methods.png', dpi=150, bbox_inches='tight')
plt.show()
tangent_methods_experiment() # 执行实验
总结对比
| 正则化方法 | 核心思想 | 实现难度 | 效果 |
|---|---|---|---|
| L1正则化 | 惩罚权重绝对值之和,产生稀疏解 | 低 | 特征选择 |
| L2正则化 | 惩罚权重平方和,抑制大权重 | 低 | 防过拟合 |
| 约束优化 | 将正则化转化为约束条件 | 中 | 精确控制复杂度 |
| 数据增强 | 扩充训练数据的多样性 | 低 | 显著提升泛化 |
| 噪声鲁棒性 | 添加输入/权重/标签噪声 | 低-中 | 提高鲁棒性 |
| 半监督学习 | 利用未标记数据 | 高 | 标记数据少时有效 |
| 多任务学习 | 共享表示同时学习多个任务 | 中 | 提升特征质量 |
| 提前终止 | 验证集性能不再提升时停止 | 低 | 简单有效 |
| 参数共享 | 不同位置/时间共享同一参数 | 低-中 | 大幅减少参数 |
| 稀疏表示 | 约束隐藏层激活稀疏 | 中 | 特征可解释 |
| Dropout | 随机关闭神经元 | 低 | 非常有效 |
| 集成方法 | 多模型平均降低方差 | 中 | 稳定提升 |
| 对抗训练 | 用对抗样本增强训练 | 中-高 | 提高鲁棒性 |
| 切面距离/正切传播 | 利用流形几何信息 | 高 | 特定场景有效 |