1. 为什么深度学习的第一课永远从linear开始
李哥这个深度学习班第二节课讲linear代码,我本来以为内容会很基础,毕竟线性回归嘛,搞过机器学习的人谁没见过。但真听他讲完再自己动手复现一遍,才发现这块内容承载的东西比表面看起来多得多:它是你理解神经网络如何做决策、如何学习、如何反馈的第一块基石。说句实在话,后面你接触的任何模型,从卷积神经网络到Transformer,本质上的计算单元都还是"线性变换加激活函数",只不过规模更大、堆叠更深。所以你现在把linear代码吃透,后面就是在同一个骨架上来回换皮。
这节课的内容主线其实很清晰:用Python写一个完整的线性回归训练流程,包含数据准备、模型定义、损失计算、梯度回传、参数更新、结果可视化。代码本身不复杂,二三十行就能跑通,难点在于把每一行的存在理由讲清楚。李哥在课上反复强调一句话:先别管模型多高级,你自己能把一条直线拟合明白,神经网络的原理就懂了一半。我觉得这句话是这节课的灵魂。
对于目标读者,我的建议是:如果你是第一次接触深度学习,或者之前只看了理论视频但没亲手敲过训练代码,这节课的小项目就是最好的切入点。不过动手之前,你至少需要会基础的Python语法,知道什么是numpy数组,这些前置要求不过分。另外,你需要一个能跑PyTorch的环境,CPU就能跑,完全用不着显卡。
我自己的复现环境如下:
| 项目 | 版本/方案 |
|---|---|
| 操作系统 | Windows 11 |
| Python | 3.9.18 |
| 深度学习框架 | PyTorch 2.1.2(CPU版) |
| 数值计算 | numpy 1.24.3 |
| 可视化 | matplotlib 3.7.2 |
如果你的环境还没装好,建议直接用Anaconda创建虚拟环境,然后执行pip install torch torchvision matplotlib numpy。选CPU版就行,这个项目的数据量规模用不上CUDA。李哥课上也顺嘴提了:CPU版安装包小,不折腾驱动,对入门期最友好。我认同,先把流程跑通,再考虑性能问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 准备一份"看起来真实"的合成数据:为什么要自己造数据而不是用现成的
2.1 合成数据集的价值与随机种子细节
正式写模型之前,得先解决吃饭的家伙:数据。这节课用的数据不是从网上下载的公开数据集,而是李哥带着大家手写代码模拟出来的一组带噪声的线性数据。你可能会问:为什么不直接用现成的波士顿房价、糖尿病数据集之类的?原因其实很务实——真实数据集有太多跟当前学习目标无关的干扰因素,比如特征维度不统一、缺失值、量纲差异、数据分布不理想。这些干扰放到后面处理起来当然有意义,但作为第一次接触训练循环的学员,你需要的是一条只有明确线性关系、叠加可控噪声的数据,这样你能一眼判断模型是否学会了,而不是被数据预处理拖着走。
造数据的逻辑很简单:先确定真实权重和真实偏置,然后生成一批横坐标值,算出一批纵坐标,最后加上高斯噪声。这样我们就"预知"了正确答案,这是验证模型学习效果的前提。这里有个细节值得专门说一下:生成随机数时一定要设置随机种子。没有种子的话,每次运行代码拿到的噪声都不一样,你今天训练的基线和明天完全无法对比,出了bug都很难复现。设置随机种子是个职业习惯,几乎所有深度学习项目的第一行都会做这件事,为的就是结果可复现。
2.2 数据生成代码与张量形状
下面这组代码就是李哥课上带着大家写的完整数据生成段,我在复现时加了一些形状打印和可视化,方便确认数据结构。
python复制import torch
import numpy as np
import matplotlib.pyplot as plt
torch.manual_seed(42)
np.random.seed(42)
# 设定真实的线性关系:y = 2.5x + 1.3
true_w = 2.5
true_b = 1.3
num_samples = 200
# 生成均匀分布的横坐标,范围[-2.0, 2.0]
x = torch.linspace(-2.0, 2.0, num_samples).reshape(-1, 1)
# 加入标准正态分布的噪声,模拟真实世界的随机干扰
noise = torch.randn(x.shape) * 0.3
y = true_w * x + true_b + noise
print("x shape:", x.shape)
print("y shape:", y.shape)
print("前5个样本的x值:", x[:5].flatten())
print("前5个样本的y值:", y[:5].flatten())
# 可视化合成数据
plt.scatter(x.numpy(), y.numpy(), alpha=0.6)
plt.xlabel("x")
plt.ylabel("y")
plt.title("Synthetic Linear Data with Noise")
plt.show()
这里面的每一步都值得你花时间弄明白。torch.linspace(-2.0, 2.0, num_samples)在[-2, 2]范围内均匀取200个点,这样数据分布是均匀的,不会出现一侧样本密集、另一侧稀疏的不平衡情况。.reshape(-1, 1)把形状从[200]变成[200, 1],变成一个二维列向量。为什么非要二维?这可能是初学者最容易忽略的一个问题:PyTorch中的线性层nn.Linear期望的输入形状是[batch_size, input_dim],即使input_dim是1,也需要保留这个维度。很多第一次写代码的人就是在这里报维度错误,报错信息还看不懂。
至于噪声,torch.randn生成的是标准正态分布随机数,乘以0.3就是把噪声的标准差控制在0.3这个尺度。这个值不是随便拍的,它决定了数据拟合的难度。噪声太小,模型学得太轻松,看不到训练过程的反复挣扎;噪声太大,真实线性关系被淹没,模型怎么调都拟合不干净,容易误导初学者怀疑自己的模型写错了。0.3在这个数据范围内是一个合适的折中:肉眼能看出线性趋势,但又不至于一条直线完美穿过所有点。
3. 先别急着上框架:手写梯度下降,把反向传播的每一步掰开
3.1 为什么建议先写一个"不高效但透明"的实现
李哥这节课的安排很有特点,并没有直接让大家用nn.Linear一键定义模型,而是先带着全场手写了整个训练过程。我当时觉得有点多此一举,但后来才意识到这一步的价值:框架帮你把梯度算好了,你不会真正理解梯度是从哪来的;手写一遍之后,你再去看框架封装好的API,脑子里会自动对号入座。
我们用的模型形式是最简单的单变量线性回归:
y_pred = w * x + b
定义损失函数为均方误差(Mean Squared Error, MSE):
loss = (1 / n) * sum((y_pred - y_true)^2)
为什么是均方误差而不是绝对误差?因为MSE处处可导,而且对大误差的惩罚是平方级的,这就让模型在预测偏差大的时候,梯度信号也足够大,从而强迫模型快速向正确的方向修正。绝对误差在误差为零处不可导,在实际优化中会带来不少麻烦。
3.2 手写训练完整代码
下面这段代码是这节课的核心资产,每一行的设计都有讲究。建议你运行的时候把中间过程的数值变化打印出来,亲眼看一次loss是怎么下降的,比看十篇文章都管用。
python复制# 定义线性回归模型的参数,w为权重,b为偏置
w = torch.tensor(0.0, requires_grad=False)
b = torch.tensor(0.0, requires_grad=False)
# 超参数设置
lr = 0.05
epochs = 300
# 记录训练过程中的损失值
loss_history = []
for epoch in range(epochs):
# 前向传播:计算预测值
y_pred = w * x + b
# 计算损失:均方误差
loss = ((y_pred - y) ** 2).mean()
loss_history.append(loss.item())
# 手动计算梯度
grad_w = (2.0 * x * (y_pred - y)).mean()
grad_b = (2.0 * (y_pred - y)).mean()
# 更新参数
w -= lr * grad_w
b -= lr * grad_b
if (epoch + 1) % 50 == 0:
print(f"Epoch [{epoch + 1}/{epochs}], w: {w.item():.4f}, b: {b.item():.4f}, loss: {loss.item():.4f}")
print(f"训练结束,学到的权重 w = {w.item():.4f},偏置 b = {b.item():.4f}")
print(f"真实权重 w = {true_w},真实偏置 b = {true_b}")
这里有个比较有意思的点:梯度公式是怎么推出来的?手推一下也很简单。对于只有一个样本的情况,loss对w的偏导是2 * (w*x + b - y) * x,对所有样本取平均后,就得到了代码里的grad_w = (2.0 * x * (y_pred - y)).mean()。对b的偏导也一样,只是把乘x这一项去掉,得到grad_b = (2.0 * (y_pred - y)).mean()。为什么求梯度时有的带x有的不带x?答案藏在链式法则里:y_pred对w求偏导结果是x,对b求偏导结果是1。
你可能注意到这里我先用的requires_grad=False,也就是完全没有用框架的自动求导,梯度是我们手工算好的。李哥讲这段的时候打了个比方,我觉得特别贴切:你知道方向盘的转向逻辑,才知道什么时候该打方向、打多少;如果只会上车踩油门,遇到弯道就只能听天由命。手写梯度就是让你看清方向盘背后的机械结构。
3.3 手动梯度下降的核心观察
跑完上面这段代码,你会看到类似这样的输出:
code复制Epoch [50/300], w: 2.2215, b: 1.3097, loss: 0.1287
Epoch [100/300], w: 2.4132, b: 1.3050, loss: 0.0901
Epoch [150/300], w: 2.4856, b: 1.2980, loss: 0.0854
Epoch [200/300], w: 2.5007, b: 1.2920, loss: 0.0843
Epoch [250/300], w: 2.5039, b: 1.2895, loss: 0.0841
Epoch [300/300], w: 2.5011, b: 1.2889, loss: 0.0840
真实权重是2.5,真实偏置是1.3,模型学到的结果是w约等于2.50、b约等于1.29。b跟真实值之间的差距是噪声带来的正常偏差,因为噪声均值不完全为零,模型只能逼近无法完全还原真实参数。这一条值得记住:训练出来的参数逼近但不等于真实参数,不代表代码写错了。
4. 用PyTorch的nn.Linear重写模型:感受框架封装的威力与代价
4.1 为什么同样的逻辑,框架版更接近工业实践
手写版本弄明白原理之后,就可以上升到框架封装版了。工业界和学术界的深度学习项目几乎都不会手动求梯度,因为当模型复杂到几十层、几百层的时候,手动推导梯度公式根本是不可能完成的工程。PyTorch提供的nn.Linear和自动求导机制(Autograd)把低层次的数学计算全部隐藏在底层,你只需要关心模型结构、前向计算和训练循环。
但这里有个建议:用手写版本和框架版本做一次结果对比。如果两边学到的w和b在误差范围内一致,说明你对两种实现方式的理解都在线;如果不一致,优先检查框架版的损失计算和优化器配置,尤其是学习率参数是否跟手写版一致。
4.2 框架版完整代码
python复制import torch
import torch.nn as nn
import torch.optim as optim
# 重新生成数据,保持随机种子一致
torch.manual_seed(42)
x = torch.linspace(-2.0, 2.0, 200).reshape(-1, 1)
noise = torch.randn(x.shape) * 0.3
y = 2.5 * x + 1.3 + noise
# 定义模型:一个线性层,输入维度1,输出维度1
model = nn.Linear(in_features=1, out_features=1)
# 定义损失函数和优化器
criterion = nn.MSELoss() # 均方误差损失
optimizer = optim.SGD(model.parameters(), lr=0.05) # 随机梯度下降
# 打印初始化后的参数,PyTorch默认随机初始化
print(f"初始权重: {model.weight.item():.4f}, 初始偏置: {model.bias.item():.4f}")
epochs = 300
for epoch in range(epochs):
# 梯度清零,非常重要,否则梯度会累加
optimizer.zero_grad()
# 前向传播
y_pred = model(x)
# 计算损失
loss = criterion(y_pred, y)
# 反向传播,自动计算所有参数的梯度
loss.backward()
# 更新参数
optimizer.step()
if (epoch + 1) % 50 == 0:
print(f"Epoch [{epoch + 1}/{epochs}], loss: {loss.item():.4f}")
w_final = model.weight.item()
b_final = model.bias.item()
print(f"训练结束,学到的权重 w = {w_final:.4f},偏置 b = {b_final:.4f}")
这段代码虽然短,但每一个API的调用位置和顺序都有讲究。optimizer.zero_grad()是初学者最容易漏掉的一行。PyTorch的设计哲学是梯度默认会累加,如果你不清零,第二个batch的梯度会加到第一个batch上,导致参数更新方向被污染,损失函数可能出现不降反升的情况。每轮训练前先清零,这是肌肉记忆级的操作,没有例外。
loss.backward()执行的是自动反向传播,框架会根据计算图自动求出每个参数的梯度,然后optimizer.step()拿到梯度去更新参数。整个过程中的计算图构建和释放都是自动完成的。
这里插一个经常遇到的问题:如果你对参数梯度做了调试,会发现第一次backward()之后,model.weight.grad变量里存的就是梯度值。如果我们把optimizer.zero_grad()漏掉,下一次backward()之后这个梯度就变成两次梯度之和。所以当你发现损失曲线呈现周期性锯齿状波动时,第一个要去排查的就是梯度累加。
4.3 模型初始化参数对收敛的影响
PyTorch的nn.Linear默认采用Kaiming均匀初始化,也就是权重会被初始化为[-1, 1]区间内的随机值(严格来说是跟输入输出维度相关的一个范围)。跟手写版本初始化为0不同,这个初始值通常无所谓,因为无论从哪里出发,线性回归的损失函数是一个凸函数,最终都会收敛到同一个全局最优解附近。
不过有一点需要注意:由于随机初始化,框架版学到的最终权重跟手写版可能在小数点后第三位开始有差异。这不是bug,是浮点计算路径不同带来的正常偏差。只要两个版本的loss都收敛到了相近水平,w和b在误差范围内一致,就说明实现没问题。
5. 可视化训练过程:从损失曲线和拟合直线判断模型是否真的在学
5.1 损失曲线的解读方法
训练完成后,直接看一个数字(比如最终loss是0.084)是感受不到训练过程的,你需要把损失曲线画出来,直观看到loss的下降趋势。这也是李哥课上反复强调的一个工作习惯:做深度学习实验,可视化不是可选项,而是必选项。没有可视化,你只是在盲调参数。
绘制损失曲线的代码很简单:
python复制plt.figure(figsize=(8, 4))
plt.plot(range(epochs), loss_history, lw=2)
plt.xlabel("Epoch")
plt.ylabel("MSE Loss")
plt.title("Training Loss Curve")
plt.grid(True, alpha=0.3)
plt.show()
一条健康的损失曲线应该有以下几个特征:前期快速下降,中期逐渐放缓,后期趋于平缓并伴随小幅波动。如果曲线前期下降非常剧烈、后期几乎贴着横轴走,说明模型已经收敛;如果曲线呈现锯齿状上下震荡,很可能是学习率太大;如果曲线下降得跟蜗牛一样慢,训练完300轮还没接近最优值,多半是学习率太小。
5.2 拟合直线与真实分布的叠加对比
光看损失曲线还不够,把学到的直线跟原始散点画在同一张图上,一眼就能判断拟合效果,直观程度远胜任何数字指标。
python复制with torch.no_grad():
y_pred_final = model(x)
plt.figure(figsize=(8, 5))
plt.scatter(x.numpy(), y.numpy(), alpha=0.6, label="Original Data")
plt.plot(x.numpy(), y_pred_final.numpy(), color="red", lw=2, label="Fitted Line")
plt.xlabel("x")
plt.ylabel("y")
plt.title("Linear Regression Fitting Result")
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()
运行这段代码后,你应该看到一条红色直线从散点带中间穿过,且直线的斜率跟散点带的整体走向一致。如果直线明显偏离,比如横在山脚或者陡得离谱,就说明训练过程出了问题,优先去检查学习率和数据形状。
我以前带过不少新人,发现一个很普遍的问题是:训练结束只看loss数字,学生就觉得完事了,完全不去看拟合直线长得什么样。其实数字有时候会骗人,尤其当你换了一个量级的损失函数,0.1和0.01看起来都很小,但放在图上可能一条线歪得没法看。可视化对比永远是最可靠的验收标准。
5.3 手动计算收敛后的效果验证
想进一步验证模型学到的参数是否合理,可以手动对比预测值和真实关系。比如用训练好的模型去预测x=1.0时的y值:
python复制x_test = torch.tensor([[1.0]])
y_test_pred = model(x_test)
print(f"模型预测 x=1.0 时 y = {y_test_pred.item():.4f}")
print(f"按真实关系计算 y = {true_w * 1.0 + true_b:.4f}")
如果模型学得好,两者差距应该在一个合理的噪声范围之内。这里有个容易踩的坑:模型的输入必须是[batch_size, feature_dim]的二维张量,不能直接传一个标量torch.tensor(1.0)。很多人训练的时候顺利,一到预测就报RuntimeError: mat1 and mat2 shapes cannot be multiplied,原因就是忘了保持输入是二维形状。这个习惯一定要养成。
6. linear只是起点:从单层线性到激活函数的能力跃迁
6.1 当数据不是直线时,linear模型会遭遇什么
把上面的基础知识都过一遍之后,李哥在课程末尾抛了一个很耐人寻味的问题:如果数据不是线性的,比如是一条二次曲线,你还能用nn.Linear直接拟合并得到满意结果吗?
我当时想当然回答"多堆几个Linear层",这其实是一个很经典的误解。多个线性层堆叠在一起,在数学上仍然等价于一个线性层,因为线性变换的复合还是线性变换。不管你有多少层nn.Linear,中间不加激活函数,整个网络能表达的函数空间并没有扩大,跟单层没区别。
这个例子可能更直观:如果你把一条直线转45度再平移,它仍然是一条直线,不可能变成曲线。要让模型能拟合曲线,必须引入非线性变换,也就是激活函数。
6.2 ReLU、Sigmoid的直觉理解与对比
深度学习里最常用的激活函数有三个:ReLU(线性整流单元)、Sigmoid、Tanh。李哥在课上没有深入数学推导,而是用直觉帮大家建立了印象,我觉得这个方式特别适合作入门理解:
- ReLU:对输入做
max(0, x),负的部分直接压成0,正的保持不变。这个操作就像一个阀门,只允许正向信号通过。它的计算非常快,且能有效缓解梯度消失,是现代深度网络默认首选。 - Sigmoid:把任意实数压缩到(0, 1)区间,适合做概率输出。但它的两端梯度几乎为零,深层网络中使用容易导致梯度消失,现在主要用在二分类输出层。
- Tanh:输出范围是(-1, 1),均值为0,比Sigmoid的收敛效果稍好,但同样存在两端饱和的问题。
三者的核心区别,用一句话概括:ReLU适合当隐藏层主力,Sigmoid适合当输出层做概率表达。下一节课李哥应该就会讲如何用nn.Sequential把nn.Linear和激活函数组合成第一代真正意义上的神经网络。
6.3 从linear到第一个神经网络的代码预览
如果你已经完成了上面的linear回归代码,其实下一节课的第一步你已经会了大半。提前看一眼这段代码,不会吃亏,反而能帮你建立"线性层加激活函数组网"的初步感觉:
python复制import torch.nn as nn
# 一个简单的多层感知机:线性层 + ReLU + 线性层
model = nn.Sequential(
nn.Linear(1, 16), # 把输入从1维映射到16维
nn.ReLU(), # 非线性激活
nn.Linear(16, 1) # 把16维映射回1维输出
)
print(model)
这个结构的本质,就是在你学过的linear模块之间穿插非线性变换,让模型具备拟合曲线、曲面乃至任意复杂函数的能力。所以现在回头看,linear代码练得越顺手,后续理解网络结构就越轻松。它像一个最简单但最标准的积木原型,后续所有积木的插槽设计和它保持一致。
7. linear实操中的常见报错与排查清单
代码写到这一步,该跑通的都跑通了。但我还是要专门整理一份排查清单,因为李哥在课上答疑环节遇到的问题,几乎全部集中在这几个点上。你提前看到这些,能省下不少自己瞎折腾的时间。
| 报错/现象 | 根本原因 | 解决方案 |
|---|---|---|
RuntimeError: mat1 and mat2 shapes cannot be multiplied (1x200 and 1x1) |
输入张量形状不对,x没有变成二维列向量 | 用x = x.reshape(-1, 1)确保形状是[样本数, 特征数] |
loss值稳步下降但不收敛到预期水平 |
学习率过小,训练轮数不足 | 适当调大lr到0.01-0.1区间,或增加epochs |
loss曲线剧烈震荡,不降反升 |
学习率过大,参数更新步子太猛 | 把lr降到0.001-0.01再试 |
| 每轮loss几乎不变 | 忘了optimizer.zero_grad(),梯度累加互相抵消 |
在backward()之前调用optimizer.zero_grad() |
| 模型预测报维度错误 | 预测时输入了标量或一维张量 | 预测输入保持二维,如torch.tensor([[1.0]]) |
w学到2.0左右再也上不去 |
数据噪声过大,或模型尚未收敛 | 增大训练轮数,或降低噪声标准差重新生成数据 |
这些坑我基本都踩过,其中梯度忘记清零是出现频率最高的。很多初学者把代码从网上拷下来,能跑就交差了,不知道背后每一行存在的意义,一旦报错就手足无措。我建议你自己动手把上面这些错误都故意制造一遍,观察报错信息和训练曲线的变化,这种"主动踩坑"式的学习方式,比光看正确代码印象深刻得多。
还有个容易忽略的细节:用nn.MSELoss计算损失时,它对预测值和真实值有一个形状匹配要求。如果你在手动扩维时不小心多了一个空维度,比如形状变成了[200, 1, 1],虽然代码可能不报错也能算,但损失会沿着多出来的维度广播,导致数值翻倍。排查这类问题可以直接打印中间变量的shape属性进行比对。
最后再多提一句:训练过程可视化不能省。每次训练结束后把损失曲线和拟合直线都画出来看一眼,比盯着终端里打印的loss数字有信息量得多。判断模型好坏的第一性原理,永远是你对数据分布的直觉,而不是某个指标值本身。
