6  工程化实验

“因为现实是唯一真实的东西。”

—— 《头号玩家》 (2018)

主角韦德大部分时间都生活在完美的虚拟世界“绿洲”(Oasis)里。那里想要什么有什么,但这只是虚幻。最终他明白,无论游戏多美好,人终究要回归粗糙、不完美但真实的现实世界。

6.1 构建高效数据管道

在 R 语言的传统统计建模,我们习惯于将所有数据读入内存(Global Environment),清洗成一个巨大的 data.frame,然后传入模型。这种“全量内存”模式在深度学习中是行不通的。

R torch 引入了一套基于 Dataset(数据集) 和 Dataloader(数据加载器) 的标准化范式,旨在实现数据的解耦与惰性加载。

6.1.1 R6 与 Dataset 封装

torch 包提供了一个核心生成器函数 dataset(),它基于 R 的 R6 面向对象系统构建。它的核心思想是将“数据是什么”与“怎么训练”分离开来。

一个标准的 Dataset 类必须实现三个核心方法(“三支柱”):

  1. initialize:初始化构造函数。告诉类数据在哪里(文件路径),或者进行必要的参数配置。通常不在这里读取庞大的数据文件。
  2. .getitem:获取单样本。给定一个索引 \(i\)(index),返回第 \(i\) 个样本(特征 \(x\) 和标签 \(y\))。
  3. .length :返回数据集长度,告诉加载器一共有多少个样本。

让我们定义一个简单的内存数据集来熟悉这个结构:

library(torch)

# 定义一个简单的 Dataset 类
CustomDataset <- dataset(
  name = "SimpleRegressionDataset",
  
  # 1. 初始化:这里我们在内存中生成模拟数据,实际场景通常只存储路径
  initialize = function(n_samples = 100, n_features = 5) {
    self$x <- torch_randn(n_samples, n_features)
    self$y <- torch_randn(n_samples, 1)
  },
  
  # 2. 获取单样本:返回一个 list
  .getitem = function(index) {
    list(x = self$x[index, ], y = self$y[index, ])
  },
  
  # 3. 长度:返回样本总数
  .length = function() {
    self$x$size(1)
  }
)

# 实例化
train_ds <- CustomDataset(n_samples = 10, n_features = 3)

# 测试获取第 5 个样本
sample_5 <- train_ds[5]
print(sample_5)
警告

.getitem 方法返回的通常是一个列表(List),其中包含特征和标签。这个列表的结构决定了后续模型接收数据的格式。

6.1.2 惰性加载

R 用户最常遇到的报错之一是 Error: cannot allocate vector of size...。深度学习中的图片、文本或时间序列数据通常无法一次性塞入 RAM。

惰性加载是指:在 initialize 阶段,我们只记录文件的路径或元数据,只有当程序真正调用 .getitem(index) 时,才从磁盘读取对应的那个文件。

为了演示,我们先在磁盘上创建一些模拟的 CSV 文件:

# 准备模拟数据环境
data_dir <- file.path(tempdir(), "simulated_data")
if (!dir.exists(data_dir)) dir.create(data_dir)

# 创建 50 个小的 CSV 文件,每个代表一个样本
for (i in 1:50) {
  df <- data.frame(
    feature1 = rnorm(1), 
    feature2 = rnorm(1), 
    label = sample(0:1, 1)
  )
  write.csv(df, 
  file.path(data_dir, paste0("sample_", i, ".csv")), row.names = FALSE)
}

现在,我们构建一个支持惰性加载的 Dataset:

CSVFolderDataset <- dataset(
  name = "CSVDataset",
  
  initialize = function(folder_path) {
    # 只扫描文件名,不读取内容!内存占用极低
    self$files <- list.files(folder_path, full.names = TRUE, pattern = "\\.csv$")
  },
  
  .getitem = function(index) {
    # 只有在需要时才读取文件
    file_path <- self$files[index]
    
    # 使用 R 原生函数读取
    raw_data <- read.csv(file_path)
    
    # 转换为 Tensor,注意数据类型
    x <- torch_tensor(as.numeric(raw_data[1, 1:2]), dtype = torch_float())
    y <- torch_tensor(as.numeric(raw_data[1, 3]), dtype = torch_float())
    
    list(x = x, y = y)
  },
  
  .length = function() {
    length(self$files)
  }
)

# 即使只有 4GB 内存,也可以轻松处理 1TB 的文件集
disk_ds <- CSVFolderDataset(data_dir)
print(paste("Dataset size:", length(disk_ds)))

这种模式是处理图像数据(读取 JPEG)和文本数据(读取语料库)的基础。

6.1.3 批次与编排

有了 Dataset,我们依然只能一个一个地获取数据。在训练神经网络时,我们需要:

  • Batching:将 \(B\) 个样本打包成一个张量,利用矩阵运算加速。
  • Shuffling:每个 Epoch 打乱数据顺序,防止模型记忆样本顺序。
  • Parallelism:利用多核 CPU 预取数据。

dataloader() 就是负责这些工作的调度器。

# 创建加载器,batch_size = 4
train_dl <- dataloader(disk_ds, batch_size = 4, shuffle = TRUE)

# 模拟训练循环中的一次迭代
# dataloader 返回的是一个迭代器
iterator <- train_dl$.iter()
batch <- iterator$.next()

# 观察维度变化
# 单个样本 x 的形状是: [2]
# Batch x 的形状变成了: [4, 2] (4个样本,每个2个特征)
print(batch$x$shape)

在实际训练中,我们会使用 coro::loop() 配合 dataloader 进行高效迭代:

coro::loop(for (batch in train_dl) {
  # 这里是训练逻辑
  # input <- batch$x
  # target <- batch$y
  # pred <- model(inputs)
  # loss <- loss_fn(pred, targets)
  # ...
})

6.1.4 处理变长数据

默认情况下,dataloader 假设一个批次内的所有样本形状完全相同,并尝试使用 torch_stack 将它们堆叠起来。

但如果我们处理的是文本(句子长度不一)或不同尺寸的图像,默认行为就会报错。这时我们需要自定义 collate_fn。collate_fn 接收一个列表(包含 batch_size 个样本),你需要手动决定如何将它们拼在一起。

假设我们的 Dataset 返回长度不一的向量(为循环网络章节做铺垫):

VarLenDataset <- dataset(
  name = "VarLen",
  initialize = function() {},
  .getitem = function(index) {
    # 返回随机长度 1 到 5 的向量
    len <- sample(1:5, 1)
    torch_randn(len) 
  },
  .length = function() 10
)

# 定义 collate 函数:进行填充 (Padding)
pad_collate <- function(batch) {
  # batch 是一个 list,包含 dataloader 取出的 batch_size 个元素
  # 这里的元素就是 Dataset .getitem 返回的 tensor
  
  # 使用 pad_sequence 进行填充,使得 batch 内所有向量长度一致
  # batch_first = TRUE 表示输出形状为 (Batch, Max_Len)
  nn_utils_rnn_pad_sequence(batch, batch_first = TRUE, padding_value = 0)
}

vl_ds <- VarLenDataset()
# 传入自定义的 collate_fn
vl_dl <- dataloader(vl_ds, batch_size = 3, collate_fn = pad_collate)

iter <- vl_dl$.iter()
print(iter$.next())

通过自定义 collate_fn,我们可以灵活地处理任何非结构化数据。

6.1.5 多进程加速

数据加载往往是训练的瓶颈。当 GPU 极速计算矩阵乘法时,CPU 还在缓慢地从硬盘读取 CSV 或解压 JPEG,导致 GPU 显存空闲(Volatile GPU-Util 低)。

dataloader(..., num_workers = N) 参数允许开启 \(N\) 个后台 R 进程并行读取数据。

\[ \text{Total Time} \approx \max(\text{GPU Compute Time}, \frac{\text{Data Loading Time}}{N}) \]

在 Windows 上,R 的并行机制(基于 PSOCK)开销较大,通常设为 0 或 2;在 Linux/macOS 上(基于 Fork),可以设置为 CPU 核心数的一半。

警告

使用多进程时,确保 Dataset 的逻辑是线程安全的,且不要在 initialize 中建立数据库连接,而应在 .getitem 中建立。 另外,有大量图片文件加载的场景,因为调度之间的损耗,设置多线程加速可能还会拖慢整体的加载速度。

6.2 偏差和方差的博弈

深度学习的本质目标不是在已有的数据上做“记忆”,而是在未见的数据上做“推断”。为了衡量这种能力,我们需要一套严谨的评估体系,以及能够透视模型内部状态的诊断工具。

6.2.1 数据划分原则

在统计学习中,我们通常假设数据是独立同分布(i.i.d.)的。为了模拟模型上线后的真实表现,我们必须人为地制造“未知”。最基础的原则是将数据集划分为三个互斥的部分:

  • 训练集 (Training Set):用于计算梯度和更新参数。这是模型“学习”的材料。
  • 验证集 (Validation Set):用于调整超参数(如学习率、网络层数、Batch Size)和进行模型选择。这是模型的“模拟考”。
  • 测试集 (Test Set):仅在所有开发工作结束后使用一次,用于评估模型的最终性能。这是“期末考”。

如果你根据测试集的表现来调整模型结构,那么测试集实际上就变成了训练过程的一部分,其评估结果将不再具有统计效力。

注记

这个是数据集划分原则,有些实际情况可能不太一样:

  • 模型训练是“一步到位”的(closed-form 或固定迭代次数)比如线性回归、朴素贝叶斯、SVM(非核)、决策树,可以不需要验证集。
  • 模型训练是“逐步优化”的(iterative optimization),比如 XGBoost、LightGBM、深度学习,需要验证集来监控训练过程。

出于重点和认知负担的考虑,本章节的案例不做复杂设计,默认验证集等同于测试集。

在 R 生态中,可以使用随机抽样 sample 这类函数做手动切片。但更规范的是使用 tidymodels 生态的 rsample 包来优雅地管理这种划分。第 4 章我们有简要提及如何切分训练集和测试集,如果严格执行标准划分准则的话,需要这样操作:

# 1. 先切 test
split1 <- initial_split(iris, prop = 0.8, strata = 'Species')
train_valid <- training(split1)
test        <- testing(split1)

# 2. 再切 valid
split2 <- initial_split(train_valid, prop = 0.75, strata = 'Species')
train <- training(split2)
valid <- testing(split2)

设置分层抽样(非随机抽样),保证分布的一致性,避免某类在 valid/test 里太少。分层变量使用 strata = target 来指定。

注记

strata 参数既可以用于分类变量,也可以用于数值变量。数值型变量会被自动分箱(通常为 5 个区间),再在每个区间内按比例抽样,从而保证训练集、验证集和测试集在该变量上的分布尽可能一致。由于分箱本身具有一定的任意性,因此对数值变量进行分层抽样需要谨慎使用,通常只在希望保持目标变量分布一致的场景下采用。

6.2.2 偏差-方差权衡

为了直观理解偏差与方差,我们构造一个经典的非线性回归任务:目标函数为 \(y = \sin(2\pi x)\),并加入高斯噪声。我们将分别使用线性模型(过于简单)和高阶多项式模型(过于复杂)来拟合它。

generate_data <- function(n = 100) {
  x <- torch_rand(n, 1)
  y <- torch_sin(2 * pi * x) + 0.1 * torch_randn(n, 1)
  list(x = x, y = y)
}
train_ds <- generate_data(20) # 小样本更容易观察过拟合
valid_ds <- generate_data(100)

先构建一个能够动态调整多项式次数的模型。这里我们利用 torch_cat 手动构造多项式特征 \(x^1, x^2, \dots, x^n\)

PolyNet <- nn_module(
  "PolyNet",
  
  initialize = function(degree) {
    self$degree <- degree
    self$linear <- nn_linear(in_features = degree, out_features = 1)
  },
  
  forward = function(x) {
    # 生成多项式特征矩阵
    x_poly <- torch_cat(lapply(1:self$degree, function(d) x^d), dim = 2)
    self$linear(x_poly)
  }
)

接着,我们分别训练一个 degree=1 (线性) 的模型和一个 degree=15 (高阶) 的模型,并记录每个 Epoch 的训练 Loss 和验证 Loss。

当 degree=1 时:

  • 欠拟合 (Underfitting) ,模型试图用一条直线去拟合正弦波,效果不可能好。
  • Training Loss 很高且无法继续下降;Validation Loss 也很高。

当 degree=15 且训练数据量较少时:

  • 过拟合 (Overfitting) ,模型有足够的能力“死记硬背”每一个训练样本。
  • Training Loss 持续下降,甚至接近于 0;但 Validation Loss 在下降一段时间后,开始反向飙升。

上述实验揭示了机器学习中著名的误差分解公式。对于一个给定的输入 \(x\),模型的期望预测误差可以分解为:

\[ E[(y - \hat{f}(x))^2] = \text{Bias}[\hat{f}(x)]^2 + \text{Var}[\hat{f}(x)] + \sigma^2 \]

  • 偏差 (Bias):来源于错误的模型假设(如用直线拟合曲线)。高偏差意味着模型“太笨”。
  • 方差 (Variance):来源于模型对训练数据微小波动的敏感性。高方差意味着模型“太敏感”。
  • 不可约误差 (\(\sigma^2\)):数据本身的噪声,任何模型都无法消除。

在工程实践中,我们很少直接计算数学上的偏差和方差,而是通过 Loss 曲线的行为来定性判断:

现象 诊断 工程对策
Train Loss 高, Val Loss 高 欠拟合 (高偏差) 1. 增加模型复杂度(加深网络、增加神经元)
2. 减少正则化
3. 增加特征工程
Train Loss 低, Val Loss 高 过拟合 (高方差) 1. 收集更多数据(最有效但最贵)
2. 正则化(Weight Decay, Dropout)
3. 降低模型复杂度
4. 早停法 (Early Stopping)
Train Loss 低, Val Loss 低 理想状态 保持现状,尝试微调或部署

在深度学习时代,我们通常倾向于:先构建一个稍微过拟合的模型(保证偏差足够低,能学会数据),然后通过正则化手段来控制方差(压低验证误差)。

6.3 正则化策略

前面我们见识了“过拟合”的可怕:模型在训练集上表现神勇,但在验证集上却一塌糊涂。要解决这个问题,我们需要引入正则化 (Regularization)。正则化的本质是给模型人为地制造困难,限制其“胡乱记忆”的能力,强迫它学习数据背后真正的规律。

本节我们将探讨三种最核心的正则化手段:权重衰减 (Weight Decay)、Dropout (随机失活) 和 Batch Normalization (批归一化),并演示如何在 luz 框架中优雅地组合使用它们。

6.3.1 权重衰减

权重衰减 (Weight Decay / L2 Regularization/ ridge penalty) 几乎是最古老也最常用的正则化方法。在损失上增加了一项:

\[ L_{total} = L_{original} + \frac{\lambda}{2} \sum_{j} w_j^2 \]

公式符号说明:

  • \(L_{original}\): 原始的损失函数(例如 MSE 或 Cross Entropy)。
  • \(\lambda\): 正则化强度超参数。在 Luz 中设置的 weight_decay 参数。
  • \(w\): 神经网络中的权重参数(一般不对偏置项 \(b\) 进行正则化)。

当我们对 \(L_{total}\) 求导并使用梯度下降更新权重时,你会看到“衰减”是如何发生的。权重的梯度:

\[ \frac{\partial L_{total}}{\partial w} = \frac{\partial L_{original}}{\partial w} + \lambda w \]

参数更新规则 (其中 \(\eta\) 是学习率):

\[ w_{new} = w - \eta \left( \frac{\partial L_{original}}{\partial w} + \lambda w \right) \]

整理后:

\[ w_{new} = \underbrace{(1 - \eta \lambda) w}_{\text{权重衰减}} - \eta \frac{\partial L_{original}}{\partial w} \]

也就是说每次更新时,权重 \(w\) 会先乘以一个小于 1 的系数 \((1 - \eta \lambda)\) 进行收缩(衰减),然后再沿着原始梯度的方向移动。这就是它被称为 Weight Decay 的原因。

注记

无论回归还是深度学习,从贝叶斯统计的角度看,L2 正则化都等价于为参数 \(w\) 引入了一个均值为 0 的高斯分布(正态分布)先验。

  • 我们假设权重 \(w\) 不应该太大,大部分应该聚集在 0 附近。
  • \(\lambda\) 越大,对应的高斯分布方差越小,对大权重的惩罚越重。

R 用户初学 Torch 时经常找不到 L2 Regularization,是因为权重衰减放到了优化器 (Optimizer) 里。

optimizer <- optim_adam(
  model$parameters,
  lr = 1e-3,
  weight_decay = 1e-5
)

weight_decay 越大,正则越强,参数被压得更小。一般取值范围:1e-5 到 1e-3。

6.3.2 Dropout

Dropout (随机失活) 是深度学习中最天才的正则化发明之一。在每次训练迭代中,随机地将一部分神经元的输出置为 0(比如丢弃 50%)。这强迫网络不能依赖任何某一个特定的神经元(因为它随时可能罢工)。网络必须学会利用多个神经元的组合特征来做判断。这相当于在训练过程中训练了成千上万个不同的“子网络”并进行了集成(Ensemble)1

利用 nn_dropout 函数就可以加进去:

self$net <- nn_sequential(
  nn_linear(100, 50),
  nn_relu(),
  
  # p = 0.5 表示有 50% 的概率丢弃神经元
  nn_dropout(p = 0.5), 
  
  nn_linear(50, 1)
)

注意:Dropout 只能在训练时开启,预测时必须关闭。

  • 调用 model$train():开启 Dropout。
  • 调用 model$eval():关闭 Dropout,且 Torch 会自动缩放权重以保持期望值一致。

6.3.3 批归一化

在数据预处理阶段,我们通常会对输入数据进行标准化(Center & Scale),使其符合 \(\mu=0, \sigma=1\) 的分布。这能让模型在训练初期走得更稳。

然而,随着训练的进行,网络参数不断更新。对于深层网络来说,第 2 层的输入分布完全取决于第 1 层的输出。如果第 1 层的参数变了,第 2 层的输入分布就会发生剧烈抖动。每一层都在试图适应上一层不断变化的分布,这种现象被称为内部协变量偏移 (Internal Covariate Shift)。

批归一化 (Batch Normalization) 的作用就是强行将每一层的输入拉回到一个标准的正态分布,把这个偏移的趋势固定住。

Batch Normalization 的计算可以分为两步:

第一步,标准化 (Normalization):利用当前 Batch 的均值 \(\mu_{\mathcal{B}}\) 和方差 \(\sigma_{\mathcal{B}}^2\),将输入 \(x\) 拉回到标准正态分布。为了数值稳定性,我们在分母中加入了一个极小的常数 \(\epsilon\)(通常为 \(1e-5\)):

\[ \hat{x} = \frac{x - \mu_{\mathcal{B}}}{\sqrt{\sigma_{\mathcal{B}}^2 + \epsilon}} \]

此时,\(\hat{x}\) 的均值为 0,方差为 1。

第二步,仿射变换 (Affine Transformation):为了防止标准化破坏了特征原本的表达能力(比如某些特征可能需要非零均值才能激活后续的 ReLU),BN 引入了两个可学习的参数:缩放因子 \(\gamma\) 和 平移因子 \(\beta\)

\[ x_{out} = \gamma \cdot \hat{x} + \beta \]

  • \(x_{out}\):这是 BN 层最终处理完传给下一层的输出。
  • \(\gamma\) :对应为 weight 参数。
  • \(\beta\) :对应为 bias 参数。

怎么理解这两个可学习的参数呢?我们可以做一个很简单的实验:

  • x <- torch_randn( 1000, 1) * 2 + 10,均值为 10,标准差为 2 的 1000 个数
  • 输出 y 等于这个输入 x
  • 构建一个 net 结构,只包含一个批归一化层
  • 求解批归一化层的 \(\gamma\)\(\beta\) 参数

因为 x 整体被强行 Normalization 了,所以均值为 0,标准差为 1。 但又要求 y = x,所以被 Normalization 的 x 会被强行“仿射变换”回去,也就是放大 \(\gamma=2\) 倍,再做 \(\beta=10\) 的平移。

\(\gamma\)\(\beta\) 两个参数不是随机初始化的,默认为 1 和 0,即恒等变化。

通过上述机制,BN 带来了两个立竿见影的效果:

  • 平滑损失地形:它让损失函数曲面变得更加平滑,不再坑坑洼洼。
  • 允许更大的学习率:优化器可以放心大胆地大步往前走,而不用担心梯度爆炸或消失。

正如我们在下图中看到的(蓝色线 vs 红色线),加入 BN 后,Loss 就像从悬崖跳水一样迅速下降,而没有 BN 的模型则在初期挣扎了很久。

有无 BN 在一个 3 层全连接网络中对 loss 的影响

由于 \(\mu_{\mathcal{B}}\)\(\sigma_{\mathcal{B}}\) 是基于当前 Batch 计算的(是真实分布的有噪声估计),这给网络引入了随机性。这种随机噪声迫使模型不过分依赖某个特定的神经元,从而在一定程度上起到了类似 Dropout 的正则化效果,防止过拟合。

在全连接网络(MLP)中,我们使用 nn_batch_norm1d

注意:虽然名字里带有 1d,但它不仅适用于 1D 卷积,也标准用于处理形状为 \((N, \text{Features})\) 的全连接层输出。

通常建议将 BN 层放在线性层之后、激活函数之前(虽然放在激活函数后有时也有效,但经典论文推荐前者):

net <- nn_sequential(
  nn_linear(100, 50),
  
  # 插入 BN 层,参数为特征数量 (50)
  # 此时数据的形状是 (Batch, 50)
  nn_batch_norm1d(num_features = 50), 
  
  nn_relu(),     # 激活函数
  nn_dropout(0.5),
  nn_linear(50, 1)
)

预告:当我们在下一章处理图像数据(2D 结构)时,BN 的计算方式会有细微但重要的变化,届时我们将介绍 nn_batch_norm2d

6.4 拥抱 luz 框架

还记得你依旧“手写循环”的痛处吗?

  • 不仅要管理 optimizer$zero_grad()backward(),还要手动计算每个 epoch 的平均 Loss。
  • 忘记把数据 $to(device) 移动到 GPU,忘记在验证阶段调用 model$eval(),每个问题发生都会带来无穷无尽的 debug。
  • 不停的在 model$train()model$eval() 中切换。
  • 想要实现 Early Stopping(早停),想保存验证集表现最好的模型,想动态调整学习率,这些都需要你自己写大量的辅助代码。

我们希望能像 dplyr 处理数据一样,将前面提到的优化方法,都能够优雅地放进训练流程。

6.4.1 什么是 luz

luz(Falbel 2025) 是一个用于 torch 的高级 API,它的名字来源于西班牙语的“光(Light)”,寓意着照亮深度学习的黑盒。它将训练过程分解成一系列可重用的代码片段,减少了使用 torch 训练模型所需的冗长代码,有效规避了在调用

zero_grad() - backward() - step()

序列时产生的错误倾向,并简化了在 CPU 和 GPU 之间迁移数据和模型的过程。luz 的设计非常灵活,它提供了一个分层 API,无论需要对训练循环进行何种级别的控制,它都能满足需求。 它就像 dplyr 之于数据处理,ggplot2 之于绘图,能让你用极其优雅的“管道”语法,完成标准化的深度学习训练。

luz 的设计灵感来源于其他一些高级的深度学习框架,例如:FastAI、Keras、PyTorch Lightning、HuggingFace Accelerate 等,包括 R 的 tidymodels

6.4.2 优雅的工作流

细心的读者可能已经发现,在第四章 ames 房价预测的手写循环中,我们虽然划分了测试(验证)集,但在训练过程中我们并没有实时监控它。我们是像“盲跑”一样跑完了 1000 个 Epoch,最后才看了一眼测试(验证)集分数。

按照本章第二节的理论讨论,我们需要同时监测测试(验证)集的 loss 变化,来判断模型拟合状态。

基于这个考虑,我们对原有建模逻辑做 luz 改造。首先要对两个数据集做一个小小的升级。在手写循环训练逻辑中,我们是直接把整个 x_train 塞进了模型。luz 强制要求我们使用 datasetdataloader 来管理数据。

ames_dataset <- dataset(
  name = "ames_ds",
  initialize = function(x, y) {
    self$x <- torch_tensor(x, dtype = torch_float32())
    self$y <- torch_tensor(y, dtype = torch_float32())
  },
  .getitem = function(i) {
    list(x = self$x[i, ], y = self$y[i, ])
  },
  .length = function() {
    self$x$size(1)
  }
)

train_ds <- ames_dataset(x_train, y_train)
test_ds  <- ames_dataset(x_test, y_test)

train_dl <- dataloader(train_ds, batch_size = 128, shuffle = TRUE)
test_dl  <- dataloader(test_ds, batch_size = 128)
注记

对于已经存在内存里的张量,可以使用 tensor_dataset 函数一键封装。

train_ds <- tensor_dataset(
  torch_tensor(x_train, dtype = torch_float()), 
  torch_tensor(y_train, dtype = torch_float())
)
train_dl <- dataloader(train_ds, batch_size = 128, shuffle = TRUE)
# 同理测试集的构建

模型依然使用原有模型结构

输入层 ncol(x_train) -> 隐藏层 (128, ReLU) -> 隐藏层 (64, ReLU) -> 输出层 (1)

但改造成 nn_module 形态,方便 luz 框架调用

AmesNet <- nn_module(
  "AmesNet",
  
  initialize = function(input_dim) {
    self$net <- nn_sequential(
      nn_linear(input_dim, 128),
      nn_relu(),
      nn_linear(128, 64),
      nn_relu(),
      nn_linear(64, 1)
    )
  },
  
  forward = function(x) {
    self$net(x)
  }
)

接下来使用 luz 框架来训练模型:这个框架下,原本复杂的训练循环、梯度清零、反向传播、状态切换,全部被压缩在以下三个标准化的管道中,管道通过 tidyverse 体系的 %>% 或者 R 原生管道符 |> 进行传递。

  1. 组装 setup()
  2. 配置 set_hparams()set_opt_hparams
  3. 训练 fit()
AmesNet_fitted <- AmesNet %>%
  # 1. 建立 (Setup): 指定损失函数和优化器
  setup(
    loss = nn_mse_loss(),
    optimizer = optim_adamw, # AdamW 通常比 Adam 更稳健
    metrics = list(
      luz_metric_rmse() # 监控 RMSE
    )
  ) %>%
  # 2. 设置超参数 (Set Hparams): 传递给 initialize 的参数
  set_hparams(input_dim = ncol(x_train)) %>%
  set_opt_hparams(lr = 0.002) %>% # 学习率与权重衰减
  # 3. 训练 (Fit)
  fit(
    train_dl,
    epochs = 50,
    valid_data = test_dl,
    verbose = TRUE # 显示进度条
  )

因为在 metrics 声明了要追踪 RMSE,所以在执行过程控制台会出现一个动态更新的进度条。它不仅显示当前的 Training Loss(rmse),还会自动在一个 Epoch 结束后计算 Validation Loss(rmse)。

参数说明:

  1. setup() 可以配置 loss function,训练模型的优化器 optimizer(任意在 torch 中存在的,或者通过 optimizer() 函数创建的),或者传递一个训练过程中跟踪的指标列表,luz 会自动在训练和验证过程中计算它们,无需手动编写数学公式。

  2. set_hparams() 将模型的超参数传递给预先定义 nn_module 的方法 initialize(),模型定义与具体参数分离。在本例中 h1h2 两个超参数传到了前面定义的 net。

  3. set_opt_hparams()用来传递优化器函数使用的超参数。例如,函数 optim_adam() 可以接受参数 lr 来指定学习率。

  4. fit()luz 最强大的地方。当你运行这段代码时,luz 在后台默默完成了以下工作:

    • 接受 setup() 提供的模型规范,并使用指定的训练和验证 Dataloader 进行训练和验证。在训练循环中自动开启 train() 模式,在验证循环中自动切换到 eval() 模式并关闭梯度计算。
    • 通过 accelerator 自动检测是否有 GPU,并将数据和模型移动到正确的设备上,默认无需特殊声明。
    • 提供了一个实时的、带有预计剩余时间的进度条。
    • 如果在训练中途出错(比如内存溢出),它会尝试安全退出并保存当前状态。

训练结束后,fit 函数返回一个对象,这里命名为 AmesNet_fitted,它保存了所有的训练历史。

在 R 语言中,我们要看历史数据,第一反应自然是 plot(),你的直觉是对的:

plot(AmesNet_fitted)

获得优化目标和 epochs 的关系图:

从这张图上我们可以看到非常典型的过拟合现象:

  • 训练集 loss 持续在下降,验证集的 loss 在 10 epoch 之后几乎不怎么变化。
  • 训练集 在 50 个 epoch 以后 loss 已经接近 0 , 但验证集的 Loss 还有 0.3+。

要观察模型最后一个 epoch 的 loss 和 RMSE 值,我们可以使用 evaluate()get_metrics()

AmesNet_fitted |> evaluate(test_dl) |> get_metrics()
# # A tibble: 2 × 2
#   metric value
#   <chr>  <dbl>
# 1 loss   0.116
# 2 rmse   0.330

细心的读者肯定发现了一个有趣的现象。虽然这个版本存在过拟合的现象,但是 RMSE (0.3297) 比第 4 章手写循环的版本 (0.3632) 低很多!

模型结构、优化器和数据都是完全相同的,仅因为训练方式不同,luz 版本的预测误差降低了约 10%。这并非偶然,而是“训练动力学”直观体现:

  1. 手写循环实际执行的是 Full Batch 梯度下降,它将几千条数据一次性喂给模型。计算出的梯度非常精确,但也非常“平滑”,泛化能力差。luz 版本执行的是 Mini-batch 随机梯度下降,每个 batch 128 个样本,容易跳出糟糕的局部最优解。
  2. 手写循环的数据顺序不变,但 luz 版增加了 shuffle,这强迫模型必须关注特征本身的规律,而非数据的排列顺序。
  3. 手写循环的训练了 1000 轮,迭代次数太多了,loss 几乎降到了 0。luz 仅训练 50 轮,模型学会了通用规律,但还没有来得及死记硬背。“恰到好处的停止”本质上就是一种隐式的正则化。

6.4.3 引入正则化

既然模型存在过拟合情况,原始的模型定义就需要大刀阔斧的改造一下2。使用 nn_module 做一个封装,同时定义一些关键参数。比如

  • 第一个隐藏层神经元数量 h1
  • 第二个隐藏层神经元数量是 h1 的一半(体现漏斗结构)。
  • 两个隐藏层均使用 batch norm。
  • 在两个隐藏层的 relu 后各增加一个随机失活层,共享参数为 dr
FlexibleMLP <- nn_module(
  "FlexibleMLP",
  initialize = function(in_features, h1 = 128, dr = 0.3) {
    self$fc1 <- nn_linear(in_features, h1)
    self$bn1 <- nn_batch_norm1d(h1)
    self$fc2 <- nn_linear(h1, floor(h1 / 2)) 
    self$bn2 <- nn_batch_norm1d(floor(h1 / 2))
    self$fc3 <- nn_linear(floor(h1 / 2), 1)
    
    self$act <- nn_relu()
    self$dropout <- nn_dropout(dr)
  },
  forward = function(x) {
    x %>% 
      self$fc1() %>% self$bn1() %>% self$act() %>% self$dropout() %>%
      self$fc2() %>% self$bn2() %>% self$act() %>% self$dropout() %>%
      self$fc3()
  }
)

接下来是训练,和前面的内容基本一样 (weight_decay 通过 set_opt_hparams 传递到了模型里):

input_dim <- ncol(x_train)
FlexibleMLP_fitted <- FlexibleMLP %>%
  setup(
    loss = nn_mse_loss(),
    optimizer = optim_adamw, # AdamW 通常比 Adam 更稳健
    metrics = list(
      luz_metric_rmse() # 监控 RMSE
    )
  ) %>%
  set_hparams(in_features = input_dim, h1 = 128, dr = 0.3) %>%
  set_opt_hparams(lr = 0.002, weight_decay = 1e-5) %>% # 学习率与权重衰减
  fit(
    train_dl,
    epochs = 50,
    valid_data = test_ds,
    verbose = TRUE # 显示进度条
  )

再观察模型训练的过程:

有几个有趣的现象:

  • 之前的训练集 Loss 是 0.00,现在增加到了 0.10。通过正则化的方式,模型不能通过强行记忆的方式来降低 loss,而只能困难地学习规律。虽然训练集的 loss 不那么好看了,但更加真实。
  • 曲线的锯齿状比之前要厉害很多,这正是 dropout 的副作用:每个 batch 随机扔掉的神经元不同,所以会有波动。
  • 剪刀差闭合了!训练集和验证集的表现一致,模型的泛化能力变得更强。

但我们也发现了一个问题,验证集的 loss 在 20 个 epoch 之后就不怎么变了,再训练下去没有意义。是否有什么自动机制提前停止训练呢?

接下来引入的回调函数就是处理类似情况的 luz 模块。

6.4.4 回调函数

luz 的强大之处在于其回调系统(Callbacks)。回调函数允许我们在训练周期的特定时间点(如每个 Batch 结束、每个 Epoch 结束、训练开始前等)插入自定义逻辑。

最常用的两个工程化回调是:早停法和模型检查点。

一、早停法 (Early Stopping)

如果模型在第 20 轮就已经收敛,强行训练到 100 轮不仅浪费计算资源,还可能导致过拟合(验证集 Loss 反升)。luz_callback_early_stopping 就像一个监工,一旦发现验证集指标不再改善,就强制停止训练。

early_stop_cb <- luz_callback_early_stopping(
  monitor = "valid_loss", # 监控验证集损失
  patience = 5,           # 如果连续 5 轮 Loss 都没有改善
  min_delta = 0.001,      # 且改善幅度小于 0.001
  mode = "min"            # 对于 Loss,我们要越小越好
)

二、模型检查点

早停法有一个小缺陷:它是在第 N 次变差后才停止的。这意味着训练结束时的那个模型,其实并不是表现最好的模型(通常是 N 个 Epoch 之前的那个最好)。

模型检查点的作用就是:时刻盯着验证集 Loss,只要发现了目前的“历史最佳”成绩,就立刻把这一刻的模型参数保存到硬盘上(覆盖旧的存档)。这样,无论训练在哪里停止,你硬盘里存的那个文件永远是验证集分数最高的那个。

checkpoint_cb <- luz_callback_model_checkpoint(
  path = "checkpoints/ames_model_{epoch:02d}.pt", # 保存路径模板
  save_best_only = TRUE, # 只保存表现最好的模型
  monitor = "valid_loss",
  mode = "min"
)

最后,将定义好的回调加入训练管道:

final_fit <- FlexibleMLP |>
  setup(
    loss = nn_mse_loss(),
    optimizer = optim_adamw, # AdamW 通常比 Adam 更稳健
    metrics = list(
      luz_metric_rmse() # 监控 RMSE
    )
  ) |>
  set_hparams(in_features = input_dim, h1 = 128, dr = 0.3) |>
  set_opt_hparams(lr = 0.002, weight_decay = 1e-5) |>
  fit(
    data = train_dl,
    valid_data = test_dl,
    epochs = 100,
    callbacks = list(
      early_stop_cb,
      checkpoint_cb
    )
  )

当你运行这段代码时,请留意控制台的输出

  • 虽然我们设置要跑 100 个 Epoch,但第 26 个 Epoch 就自动结束了。最后一行会提示 Early stopping at epoch 26 of 100
  • checkpoints 目录下多了若干个 .pt 文件3

观察训练过程:

6.5 超参数调优

在前面的章节中,我们构建了多层感知机,并使用 luz 简化了训练循环。然而,无论是隐藏层的神经元数量(128 还是 256?)、Dropout 的比例(0.1 还是 0.5?),还是优化器的学习率(Learning Rate),我们似乎都在凭“直觉”或“默认值”行事。

在深度学习社区,这常被戏称为“炼丹”。但工程实践不能依赖运气。本节我们将介绍一套科学的超参数调优(Hyperparameter Tuning)方法论,并利用 R 语言强大的数据处理能力实现自动化搜索。

6.5.1 完美的学习率

学习率(Learning Rate, \(\eta\))是深度学习中最重要的超参数,没有之一。它决定了模型参数更新的步伐大小:

  1. \(\eta\) 太小:模型收敛如蜗牛爬行,甚至陷入局部极小值无法自拔。
  2. \(\eta\) 太大:损失函数会在极小值附近剧烈震荡,甚至直接飞出“峡谷”,导致模型发散(Loss 变为 NaN)。

在 Cyclical Learning Rates (Smith 2017) 提出之前,人们通常通过“盲猜”来设定学习率(如 0.1, 0.01, 0.001)。这种方法既耗时又依赖运气。luz 内置了更先进的 LR Finder 工具,它不再依赖猜谜,而是通过一次科学的实验来探测模型的“底线”。

其原理是对模型进行“压力测试”,它的工作流程如下:

  1. 从一个极小的学习率(如 \(10^{-7}\))开始。
  2. 在每一个 Batch(而非每一个 Epoch)迭代结束后,指数级增加学习率。
  3. 记录下每一步的学习率与对应的 Loss 值。
  4. 当 Loss 不再下降反而开始剧烈上升(发散)时,立即停止测试。

在 R 中,我们可以直接对定义好的模型管道调用 lr_finder()。注意,这里不需要调用 fit(),因为 lr_finder 内部已经封装了一个特殊的训练循环。

trained_lr <- FlexibleMLP %>%
  setup(
    loss = nn_mse_loss(),
    optimizer = optim_adamw
  ) %>%
  set_hparams(in_features = input_dim) %>%
  lr_finder(data = train_dl, verbose = FALSE)

plot(trained_lr)

初学者最容易犯的错误是直接选择图中的“Loss 最低点”。然而,正确的读图逻辑应该关注变化率而非绝对值。

请注意那条平滑的青色实线,它是剧烈波动 loss 的指数加权移动平均结果:

  1. 从左边直到 \(10^{-4}\) 附近,Loss 都在 1.0 到 1.5 之间震荡,没有明显的下降趋势。这说明在这个区间内,学习率太小,模型还没“醒”过来。
  2. \(10^{-4}\) 开始,曲线出现了一个明显的拐点,然后开始了一路顺畅的下坡。这说明梯度更新开始起作用了。
  3. \(10^{-2}\)\(3 \times 10^{-2}\) 之间,Loss 达到了最低点(大约 0.3 左右)。
  4. 图最右端,黑色虚线开始再次剧烈跳动,且青色线没有进一步下降的趋势,甚至有抬头的迹象。这说明学习率已经过大,模型开始不稳定了。
提示使用 lr finder 寻找 lr 的最佳实践法则

寻找平滑曲线(青色线)下降最陡峭的中点,或者找到 Loss 的最低点,然后除以 10。

因此,我们建议为 lr 为 0.003。

6.5.2 搜索策略

确定了学习率的范围后,我们还需要确定网络结构参数(如 hidden_units)和正则化参数(如 dropout_rate, weight_decay)。在高维空间中搜索最优组合,主要有两种策略:

一、网格搜索 (Grid Search):

  • 设定每个参数的候选列表(如 Dropout: [0.1, 0.3, 0.5]),尝试所有排列组合。
  • 存在“维数灾难”。如果有 4 个参数,每个取 5 个值,就需要训练 \(5^4 = 625\) 次。且对于不重要的参数,网格搜索会浪费大量计算资源在重复的有效维度上。

二、随机搜索 (Random Search):

  • 为每个参数定义一个统计分布(如 Dropout 服从 \(U(0.1, 0.5)\)),随机采样 \(N\) 组配置进行训练。
  • 有研究表明 (Bergstra 和 Bengio 2012),在相同的计算预算下,随机搜索通常比网格搜索更有效。因为在多维空间中,有效的超参数只有 1-2 个,随机采样能探索出每个参数更多的独特取值。

先在大范围内进行随机搜索(Coarse),通过可视化找到 Loss 较低的参数聚集区域,然后在该区域内进行精细的网格搜索(Fine)。这种策略是相对靠谱的逻辑。

注记

随机搜索比网格搜索的效果会更好?有点反直觉,但看完这个例子你就明白了:

set.seed(123)
# 假设模型性能只依赖 x,最佳点在 x = 0.7
true_score <- function(x, y) {
  -(x - 0.7)^2 + rnorm(1, sd = 0.01)  # y 完全无关
}

# Grid Search(10x10 网格)
grid_x <- seq(0, 1, length.out = 10)
grid_y <- seq(0, 1, length.out = 10)

grid_points <- expand.grid(x = grid_x, y = grid_y)
grid_points$score <- mapply(true_score, grid_points$x, grid_points$y)
best_grid <- grid_points[which.max(grid_points$score), ] # grid 最佳值

# 2. Random Search(100 次)
rand_points <- data.frame(
  x = runif(100),
  y = runif(100)
)
rand_points$score <- mapply(true_score, rand_points$x, rand_points$y)
best_rand <- rand_points[which.max(rand_points$score), ] # random 最佳值

Grid Search 找到的最佳 x = 0.667;Random Search 找到的最佳 x = 0.721

6.5.3 基于 luz 的调优

我们将使用原生的 for 循环来驱动 luz 进行随机搜索,读者也可以考虑 tidyverse 生态的 purrr

第一步:定义单次实验函数

我们需要一个函数,它接受一组超参数,训练模型,并返回验证集上的最佳评估指标。

fit_one_trial <- function(hparams, train_dl, valid_dl) {
  
  # 打印当前尝试的参数(可选)
  message(sprintf("Testing: Units=%d, Drop=%.2f, LR=%.4f, Decay=%.4f", 
                  hparams$units, hparams$drop, hparams$lr, hparams$decay))
  
  fitted <- FlexibleMLP %>%
    setup(
      loss = nn_mse_loss(),
      optimizer = optim_adamw,
      metrics = list(luz_metric_rmse())
    ) %>%
    set_hparams(
      in_features = input_dim,
      h1 = hparams$units,
      dr = hparams$drop
    ) %>%
    set_opt_hparams(
      lr = hparams$lr,
      weight_decay = hparams$decay
    ) %>%
    fit(
      train_dl,
      epochs = 10, # 搜索阶段 Epoch 可以设少一点以节省时间
      valid_data = test_dl,
      verbose = FALSE, # 关闭进度条,避免刷屏
      callbacks = list(
        luz_callback_early_stopping(patience = 3) # 表现不好尽早掐断
      )
    )
  
  valid_rmse <- get_metrics(fitted) |> filter(
    metric == 'rmse' & set == 'valid'
  ) # 返回验证集上最好的 RMSE
  best_metric <- min(valid_rmse$value)
  return(best_metric)
}

第二步:生成参数空间

我们使用随机搜索策略,生成 24 组候选参数,并增加一列放置 valid 数据集的 RMSE。

提示

LR Finder 为我们指明了 learning rate 的大致方位,0.003 附近。在随机搜索时,我们不再需要满世界乱跑,只需要在 0.001 到 0.006 这个高概率区间内进行地毯式搜索,就能以最小的代价找到全局最优解。

n_trials <- 24
search_grid <- tibble(
  trial_id = 1:n_trials,
  # 随机采样
  units = sample(c(64, 128, 256, 512), n_trials, replace = TRUE),
  drop  = runif(n_trials, min = 0.0, max = 0.5),
  lr    = runif(n_trials, min = 0.001, max = 0.006),
  decay = 10^runif(n_trials, min = -5, max = -3)
)
search_grid$val_rmse <- numeric(n_trials)

第三步:执行搜索

这里我们使用简单的 for 循环,以便我们可以实时看到进度并在每一轮后保存结果(防止程序中途崩溃导致前功尽弃)。

for (i in 1:nrow(search_grid)) {
  
  current_params <- list(
    units = search_grid$units[i],
    drop  = search_grid$drop[i],
    lr    = search_grid$lr[i],
    decay = search_grid$decay[i]
  )
  try({
    score <- fit_one_trial(current_params, train_dl, test_dl)
    search_grid$val_rmse[i] <- score
  })
  gc() 
}

接着利用 search_grid 训练一颗决策树来拟合超参数与最终 Loss 的关系:

library(rpart)
library(rpart.plot)
fit_tree <- rpart(
  val_rmse ~ units + drop + lr + decay,
  data = search_grid,
  control = rpart.control(cp = 0.05, minsplit = 8)
)
rpart.plot(fit_tree, type = 2, extra = 101)

解读分析结果:

  • 第一刀 (Units >= 96):决策树告诉我们,只要隐藏层单元数太小(比如 64),验证集误差就会高达 0.36(最右边的分支)。这意味着模型容量不足(Underfitting)。要想获得好成绩,第一步必须加大模型容量。
  • 第二刀 (Decay >= 20e-6):在模型够大的前提下,如果没有施加足够的正则化(Weight Decay < 20e-6),误差是 0.35(中间分支)。这意味着模型虽然大了,但开始过拟合(Overfitting)。
  • 最佳区域 (Units >= 384):当我们既保证了模型足够大(Units >= 384),又施加了正则化,我们不仅获得了验证集上的最佳成绩(RMSE 0.32),而且这个结果非常稳定(占所有试验的 21%)。

而且有个现象很有意思:

理论上 lr 应该是最重要的因素。但我们前置使用了 lr_finder 确定了学习率的范围。果然在后续的参数空间内找规律,学习率本身没有成为主要的分叉点,侧面说明 lr_finder 策略非常成功,已经消除了最大的不确定性。

lr 大于 0.0045 就不用看了,dropout rate 主要搜索 0.37 以下,

fine_grid <- expand.grid(
  # 既然 units 没出现,说明不敏感,直接选一个适中的
  units = c(256), 
  # lr 必须小于 0.0045
  lr = c(0.001, 0.002, 0.004), 
  # drop 必须小于 0.37
  drop = c(0.1, 0.2, 0.3),
  # Decay 有用,但左分支没用到它,可以设个默认小值
  decay = 1e-4 
)

执行网格搜索结果,最小的参数空间为:

units lr drop decay val_rmse
256 0.001 0.3 1e-04 0.3190549

放大迭代次数,在第 16 步提前停止,RMSE 优化到最优,为 0.3169

注记

xgboost 可以轻松做到 0.3062

虽然 MLP 很强,但面对图像(如 1024x1024 像素),它的参数量会爆炸(100万输入节点),且忽略了像素之间的空间关系。

在第三部分:进阶篇,我们将开启深度学习最辉煌的篇章。