ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Portorch实现线性回归:从零掌握深度学习框架训练全流程

用Portorch实现线性回归:从零掌握深度学习框架训练全流程

要说线性回归这类基础模型,很多人第一反应是“这么简单的东西还需要专门用深度学习框架写一遍吗”?但如果把维度换一下,线性回归恰恰是理解深度学习框架工作流的最佳载体,没有之一。你把一个线性回归跑通了,后面所有复杂的网络结构,本质上都是在同一个骨架上换不同的积木。我最近用Portorch重写了线性回归建模的全流程,这篇文章就把整个过程掰开揉碎讲清楚,包括那些文档里不会写、只有真跑过代码才懂的东西。

1. 为什么用Portorch写线性回归,而不是直接用传统统计库

1.1 从一行公式到完整训练闭环的思维转变

传统统计库做线性回归,你干的事情就是一行代码拟合系数。比如用某个统计库,把数据喂进去,它背后可能用最小二乘法直接解出解析解,整个过程非常快,但对你理解“模型怎么学习”这件事几乎没有帮助。

而Portorch写线性回归,本质是用深度学习的最小化思维去逼近最优解。线性回归的解析解是闭式的,但训练过程的每一步都是开放的:你可以手动控制梯度怎么回传、损失怎么下降、参数怎么更新、什么时候过拟合。这种“可控感”是传统统计库给不了的。我当时第一次在Portorch里把线性回归跑通之后,回头再看神经网络那些概念,什么前向传播、反向传播、优化器更新,全都串起来了。

所以我的建议是:哪怕你以后根本不会用线性回归去做实际业务,也值得用Portorch从头到尾实现一遍。它不是一个“任务”,它是一个“透镜”。

1.2 Portorch框架在这里扮演什么角色

Portorch的核心操作逻辑是:定义计算图、自动计算梯度、按优化器策略更新参数。线性回归模型只需要一个全连接层,甚至可以直接用参数矩阵手动定义。但即使是这么简单的模型,你依然能感受到框架设计的一致性——你在手写线性回归时写的每一行代码,几乎都能被原封不动地迁移到多层感知机、卷积网络甚至Transformer里。

这也是为什么现在很多教学路线都改成“用框架从线性回归开始学起”而不是“先学一堆数学推导再碰框架”。因为计算图的概念太难靠抽象理解,但放到线性回归这个最简单的例子里,一下子就具象了。

2. 数据准备:别小看这一步,训练效果一半由数据决定

2.1 构造一个带噪声的线性数据集

线性回归的人工数据集构造非常简单,核心就是要“带噪声”。如果数据是完全无噪声的完美直线,模型学出来的效果会很好看,但失去了验证意义——你没法判断模型是真正学到了规律,还是只是在背答案。

我用的生成逻辑是:

  • 设定真实权重 weight = 2.8
  • 设定真实偏置 bias = -1.5
  • 生成 2000 个样本点,特征 x 均匀分布在 -3 到 3 之间
  • 加上标准差为 0.5 的高斯噪声

代码如下:

import torch from torch import nn from torch.utils.data import TensorDataset, DataLoader # 设定随机种子,保证可复现 torch.manual_seed(42) # 生成特征和真实标签 x = torch.linspace(-3, 3, 2000).reshape(-1, 1) true_w = 2.8 true_b = -1.5 y = true_w * x + true_b + torch.normal(0, 0.5, size=x.shape)

这里有一个细节我得单独说一下:为什么要用 torch.normal 而不是直接 y = x * true_w + true_b?如果完全不添加噪声,模型会轻松学到完美贴合的结果,你能观测到的损失曲线几乎是一条瞬间掉到零的竖线,整个训练过程没有“渐进感”,也就失去了观察学习过程的意义。加入噪声之后,你才能真实感受到模型的收敛过程、损失曲线的下降趋势,以及最后误差到底能压到多低——那些在实际业务中常见的“损失降不下去”的情况,在人工数据集上也能提前体验到。

2.2 数据集切分与批次处理

数据准备好后,下一步是切分训练集和测试集。我习惯按照 8:2 的比例切分,并且使用 DataLoader 封装数据,方便后续按批次迭代训练。

# 切分数据 train_ratio = 0.8 split_idx = int(train_ratio * len(x)) train_x, test_x = x[:split_idx], x[split_idx:] train_y, test_y = y[:split_idx], y[split_idx:] # 封装为 Dataset train_dataset = TensorDataset(train_x, train_y) test_dataset = TensorDataset(test_x, test_y) # 创建 DataLoader,batch_size 设为 64 train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)

很多人第一次写的时候会忽略 shuffle=True 这个参数。我当时也干过这事儿,结果训练过程出现了一种特别诡异的“阶梯式下降”:损失每走一个台阶才降一次,整体收敛速度慢得离谱。原因是数据本身是从小到大排列的,每个 batch 里的样本分布非常不均匀,梯度方向一直在被顺序性的偏差带着走。设置 shuffle=True 之后,每个 batch 都近似于从全集里随机抽样,梯度方向稳定了很多,收敛速度肉眼可见地变快了。

Batch size 的选择也有讲究。64 是一个很稳妥的起点:太小的话梯度噪声太大,收敛不稳定;太大的话模型学得慢,而且在小数据集上没必要。线性回归这种简单模型,实际跑起来你会发现 batch size 的影响相对有限,但养成“先设 64 再根据情况调”的习惯对后续复杂模型是很有用的。

2.3 数据归一化到底是必需还是可选

线性回归对数据尺度其实是比较敏感的,尤其是在特征量纲差异很大的时候。比如一个特征范围是 0 到 1,另一个特征范围是 0 到 10000,模型会天然认为第二个特征更重要,因为它的数值波动对输出影响更大——但这是错误的学习方向,本质上是数值尺度带来的偏见,不是真实的关系强度。

不过如果特征就一维,或者所有特征都在同一个数量级,归一化这一步确实可以省掉。在这个例子里,x 的范围是 -3 到 3,y 的范围大概是 -10 到 8,量级差异不大,不归一化也完全能收敛。但如果后续你做多特征回归,我强烈建议在数据进模型之前做一次标准化:

from sklearn.preprocessing import StandardScaler scaler_x = StandardScaler() scaler_y = StandardScaler() x_scaled = scaler_x.fit_transform(x.numpy()) y_scaled = scaler_y.fit_transform(y.numpy()) # 转换回 torch.Tensor 继续使用

注意一个关键细节:scaler 必须在训练集上 fit,然后用同一个 scaler 去 transform 测试集。不能在整个数据集上 fit 之后再切分,那样测试集的信息已经泄露出去了,评估结果会虚高。这是机器学习里一个非常经典的数据泄漏陷阱,在框架代码里反而容易被忽略。

3. 模型搭建与训练流程:从零到一的核心步骤

3.1 用 nn.Module 组织模型结构

Portorch 的模型基类是 nn.Module,所有自定义模型都要继承它,并实现 forward 方法。线性回归模型在 Portorch 里有两种写法,我分别说说它们的区别。

第一种是用 nn.Linear 封装好的层,最省事:

class LinearRegression(nn.Module): def __init__(self, in_features, out_features): super().__init__() self.linear = nn.Linear(in_features, out_features) def forward(self, x): return self.linear(x)

第二种是手动定义权重和偏置,你能更清楚地看到每个参数的结构:

class LinearRegressionManual(nn.Module): def __init__(self, in_features, out_features): super().__init__() self.weight = nn.Parameter(torch.randn(in_features, out_features) * 0.01) self.bias = nn.Parameter(torch.zeros(out_features)) def forward(self, x): return x @ self.weight + self.bias

我建议你两种写法都试一遍。第一种是日常开发最常用的写法,特别是在模型规模变大之后,手写参数初始化很容易出错;第二种则能帮你建立“什么是参数、什么是计算图”的直觉。两个模型结构完全相同,训练代码一行都不用改。

关于初始化,nn.Linear 内部默认的初始化方式其实已经考虑了神经网络的激活函数特性,效果很稳定。而手动初始化时,如果直接使用 torch.randn,初始权重的绝对值可能偏大,在你的优化器学习率设置不合理时容易出现训练初期损失波动的现象。我后来习惯了初始化时乘一个 0.01 的缩放因子,让参数从较小的值开始。对于线性回归这种只有一个线性层的模型,初始化影响不大,但这个习惯到了深层网络里能帮你省很多事。

3.2 损失函数与优化器的选择逻辑

线性回归的默认损失函数是均方误差 MSELoss,这个选择没有任何悬念。它的公式是预测值与真实值差的平方再取平均,平方操作带来的效果是:误差越大,惩罚越重;误差小的时候,梯度也小,模型能精准地微调参数。

优化器我选了 SGD(随机梯度下降),学习率设为 0.01。有人可能会问,为什么不直接用 Adam?在这个例子里如果用 Adam,损失下降确实会更快更平滑,但 Adam 的自适应学习率机制会把梯度的细节“抹平”,你观察不到手动调学习率时那种因为学习率设置不当而损失震荡甚至爆炸的典型现象。用 SGD 去观察学习率变化对训练的影响,是理解优化器工作原理最直观的路径之一。

model = LinearRegression(in_features=1, out_features=1) loss_fn = nn.MSELoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

3.3 训练循环:有条不紊地推进

训练循环是整个流程里最关键的部分,我用代码拆开来说:

epochs = 200 train_losses = [] test_losses = [] for epoch in range(epochs): # 训练状态 model.train() epoch_train_loss = 0.0 for batch_x, batch_y in train_loader: # 1. 梯度清零 optimizer.zero_grad() # 2. 前向传播 y_pred = model(batch_x) # 3. 计算损失 loss = loss_fn(y_pred, batch_y) # 4. 反向传播 loss.backward() # 5. 参数更新 optimizer.step() # 累计损失 epoch_train_loss += loss.item() * batch_x.size(0) epoch_train_loss /= len(train_dataset) # 评估状态 model.eval() with torch.no_grad(): test_pred = model(test_x) test_loss = loss_fn(test_pred, test_y).item() train_losses.append(epoch_train_loss) test_losses.append(test_loss) if (epoch + 1) % 20 == 0: print(f"Epoch [{epoch+1}/{epochs}] - Train Loss: {epoch_train_loss:.4f}, Test Loss: {test_loss:.4f}")

这里有几个操作,第一次写代码的人经常忽略,但我逐个踩过坑之后才知道它们的意义。

optimizer.zero_grad() 必须放在每次前向传播之前。如果漏掉这一步,梯度会在不同 batch 之间不断累积,梯度数值会越来越大,参数更新幅度也随之越来越离谱,最后模型可能直接发散。新手最常见的症状就是:训练没几步,loss 突然变成 nan 或者一个巨大的数字。

model.eval() 加 torch.no_grad() 的组合在评估阶段是标配。没有 model.eval(),某些层的行为会不一致(虽然线性回归没有 Dropout 和 BatchNorm,但养成这个习惯很重要);没有 torch.no_grad(),测试阶段也会构建计算图并计算梯度,白白增加内存消耗和计算时间。这个组合本质上是在告诉框架:“这里只需要前向传播算结果,不需要反向传播的梯度链路。”

3.4 训练结果的可视化与模型评估

训练完之后,验证模型好坏的方式不只是看数字。我习惯分三步验证:

看训练损失曲线。正常情况下,loss 应该在前期快速下降,然后逐渐趋于平缓。如果曲线持续大幅震荡,大概率是学习率太大;如果曲线下降得像蜗牛爬,那就是学习率太小。

对比学习到的参数与真实参数。线性回归最直接的优势在这里体现出来了,你能直接看模型学出来的 weight 和 bias 怎么逼近真实值:

print(f"Learn weight: {model.linear.weight.item():.4f}, True weight: {true_w}") print(f"Learn bias: {model.linear.bias.item():.4f}, True bias: {true_b}")

我当时跑出来的结果大概是:weight 学到的值是 2.797,真实值是 2.8;bias 学到的值是 -1.52,真实值是 -1.5。这种“肉眼可见的正确”给了我很大的信心——如果连线性回归的参数都学不准,那一定是前面哪个环节出了问题,而不是模型本身的问题。

可视化预测效果。把拟合直线画出来,跟真实数据散点图叠在一起看。这一步是最终验证,也是最有成就感的一步。拟合线穿过数据云团中间,斜率、截距都与真实直线高度吻合,噪声点分布两侧,整个图看起来就像教科书上的示例图。

4. 训练过程中的隐蔽问题与排查心得

4.1 损失不降反升的排查清单

这个问题我在实验过程中遇到过一次。所有代码都看起来正常,但 train loss 不仅没降,还在缓慢上升。我当时排查的顺序是这样的,按照可能性从高到低排列:

  • 学习率太大。SGD 的学习率一旦超过模型适应范围,损失就会震荡甚至发散发散。把 0.01 改成 0.001 重新跑一遍,立刻恢复正常。
  • 梯度累积导致步长失控。这个前面说过是 zero_grad 的问题。
  • 模型训练和评估模式搞混。eval 状态下有 no_grad 保护,但如果在训练过程中调用了 eval,梯度就断了,参数也停更了。

这三条排查完之后,90% 的“损失异常”问题都能解决。

4.2 损失剧烈震荡,但没有发散

有次我为了测试学习率的影响,把 lr 调到了 0.1,在训练集上跑出了锯齿形的损失曲线——整体在降,但每一步之间都在上下跳动。这是因为学习率太大,SGD 的参数更新幅度已经超过了损失函数的“安全区间”,每一步都可能跳过最优点。

这个现象本身其实很有教学价值:你亲手把学习率往大了调,亲眼看到损失从平滑下降变成锯齿跳动,再把它调回来恢复平滑,这比看任何文档都来得直观。后来我跟别人讲学习率的时候,总是喜欢推荐这个实验套路——用同一个模型,分别用 0.1、0.01、0.001 三档学习率去训练,对比三条损失曲线,一次就能理解透彻。

4.3 把梯度裁剪当成备选项

在小规模线性回归上,梯度裁剪通常不需要。但如果在你的实际项目中遇到了梯度异常大的情况,Portorch 提供了非常方便的裁剪接口:

# 在 loss.backward() 之后、optimizer.step() 之前调用 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

这相当于给梯度设了一个“上限绝对值”,超过就按比例缩放回去。这个操作在深层网络和 RNN 中极其常用,提前在简单模型上掌握它的使用位置和调用时机,后面会少走很多弯路。

5. 扩展:从一维线性回归走向真实场景

线性回归的建模流程跑通之后,你完全可以自己继续往下推演:把输入特征从 1 维变成多维,只需要修改模型的 in_features,数据侧生成多维特征就行,训练代码一行都不用变。可以自己试试把特征变成 3 维,真实权重设置为一定的向量,生成数据做多变量回归,你会发现模型照样能学到每个维度的系数——这时候你就真正理解了线性回归的本质:它不过是一个加权求和的线性组合,外加一个偏置项。

更进一步,如果想在这个骨架上加入非线性能力,把线性层后面接一个激活函数,就成了单层神经网络;多叠加几个带激活函数的层,就是多层感知机。而这一切的开始,就是从线性回归模型里理解了前向传播、反向传播、参数更新这几个核心步骤的排布方式。

顺带提一句,Portorch 里还有一个值得关注的特性,就是可以在 Dataset 的getitem里做实时数据增强。对于线性回归任务来说不需要,但以后做图像分类、序列建模时,你会发现数据管线这个环节和模型结构本身一样重要。

6. 谈一点实操体会

整套流程跑完一遍之后,我最大的感受是:与其说是“用 Portorch 实现线性回归”,不如说是“用线性回归来理解 Portorch”。模型只是载体,真正宝贵的是你对框架运行机制形成的肌肉记忆——什么时候调用 zero_grad、什么时候切 eval、为什么评估要包 no_grad、batch size 怎么影响训练动态。这些知识从任何一本书里读到的效果,都远不如亲自跑完一遍代码、并看到损失曲线真实变化来得深刻。

如果这篇文章你只记住一件事,那我希望是:调试模型别死盯着损失函数那一行,把训练循环里每个操作的目的都搞清楚,问题往往都出在你看似“理所当然”的代码里。把线性回归这个例子完完整整地跑通,你会发现自己对深度学习框架的掌控力在短时间里有了一次明显的提升。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进