从梯度到大模型(008):从手写 w、b 到神经网络,讲清 nn.Module、Loss 与 Optimizer
课程:《从梯度到大模型:LLM 系统实践课》
本课预计用时:90~120 分钟,可分两次完成
运行环境:Python 3、PyTorch,CPU 即可
前置课程:第 6 课自动求导、第 7 课 Dataset 与 DataLoader
在上一课,我们已经能把数据分成小批次,再一批一批地训练:
# 摘出一个批次的关键步骤;参数已在循环外创建。w.grad=Noneb.grad=Noneprediction=xb @ w+b loss=((prediction-yb)**2).mean()loss.backward()withtorch.no_grad():w-=learning_rate*w.grad b-=learning_rate*b.grad这段代码里,预测、求导、更新参数的过程都很清楚。
但当模型从两个参数变成几十个、几万个参数时,我们会遇到新的问题:参数放在哪里?怎样找到需要更新的参数?每一层的计算又怎样组织?
本课就从我们熟悉的工时预测开始,把这段代码逐步改写成 PyTorch 常见的训练方式。
学习时先记住三个分工:
| 组件 | 本课中负责什么 | 对应的问题 |
|---|---|---|
nn.Module | 组织模型的参数和前向计算 | 怎样得到预测值? |
| Loss 函数 | 比较预测与目标 | 模型整体错了多少? |
| Optimizer | 使用已有梯度更新参数 | 下一步怎样调整? |
它们共同完成训练,但各自只负责其中一部分。后面我们会用同一组数字,把每个步骤算出来。
这篇文章怎样学?
不必一口气记住所有新名字,可以分两次完成:
- 第一次读第二至十节:把手写的线性模型改成 PyTorch 写法,并手算一次更新。读完应能回答“梯度是谁算的,参数又是谁改的”。
- 第二次读第十一至十五节,再阅读第十六节完整代码,按第十七节运行:看清两层网络的形状、参数数量和训练过程。
- 第十九节是分类预习,完成回归实验后再读;最后独立完成三道课后作业,答案留到下一课。
第一节保留上节作业解析,方便连载读者核对。只想学习本课新内容,可以从第二节开始。
正文中的短代码用于解释局部步骤,不能从第一段到最后一段直接拼接运行。需要执行时,请使用第十六节的完整demo.py。文中X表示整组输入,xb表示一批输入;y、target都表示目标值,yb是一批目标值。
一、上节作业答案与讲解
如果你还没完成第 7 课作业,可以先跳到第二节,做完后再回来核对。
作业 1:11 条样本,batch_size=4,会分成几批?
先不丢弃最后一批:
第 1 批:4 条 第 2 批:4 条 第 3 批:3 条 合计:11 条因此:
drop_last=False# 共3批,本课每批更新一次,所以每轮更新3次。如果设置:
drop_last=True最后不足 4 条的那批会被丢弃:
第 1 批:4 条 第 2 批:4 条 合计:8 条每轮只更新 2 次,另外 3 条不参加这一轮的训练。
注意,“一个 epoch”表示遍历一轮训练数据;是否丢弃尾批会影响实际参与计算的样本数。
作业 2:单样本和批次的形状
已知:
X.shape = [11, 2] y.shape = [11, 1]Dataset 返回:
returnX[index],y[index]整数索引会去掉最前面的样本维度:
一条样本: X[index].shape = [2] y[index].shape = [1]DataLoader 把 4 条样本放在一起后:
第一批: xb.shape = [4, 2] yb.shape = [4, 1]最后只有 3 条:
最后一批: xb.shape = [3, 2] yb.shape = [3, 1]第二个维度没有变:每条任务仍有两个特征,仍对应一个标签。变化的是这一批有多少条任务。
作业 3:自己创建两特征数据集
一种可用的数据是:
importtorch X=torch.tensor([[1.0,0.0],[0.0,2.0],[2.0,2.0],[3.0,1.0],[1.0,4.0],[4.0,0.0],])y=2*X[:,0:1]+0.5*X[:,1:2]+1这里特意使用0:1和1:2,让取出的特征列保持[6,1]。计算后的 y 也是[6,1]。
各条标签为:
[3.0, 2.0, 6.0, 7.5, 5.0, 9.0]索引 2 对应第三条数据[2,2]:
y = 2×2 + 0.5×2 + 1 = 6接着前面的 X、y,实现题目要求的 Dataset,并打印全部批次:
fromtorch.utils.dataimportDataLoader,DatasetclassTaskDataset(Dataset):def__init__(self,features,labels):self.features=features self.labels=labelsdef__len__(self):returnlen(self.features)def__getitem__(self,index):returnself.features[index],self.labels[index]dataset=TaskDataset(X,y)print("样本数:",len(dataset))print("索引2:",dataset[2])loader=DataLoader(dataset,batch_size=2,shuffle=False)forbatch_index,(xb,yb)inenumerate(loader,start=1):print(batch_index,xb.tolist(),yb.tolist())__len__告诉加载器共有多少条数据,__getitem__保证特征和标签按同一个索引配对。这里没有打乱,三批依次来自索引 0、1;2、3;4、5。每批特征[2,2],标签[2,1]。
作业 4:为什么固定种子,每一轮顺序仍可能不同?
种子确定的是随机数生成器的起点。
假设第一次运行从种子 123 开始:
起点123 → 第1轮排列 → 第2轮排列第二次从同一种子开始,并执行相同流程:
起点123 → 对应的第1轮排列 → 对应的第2轮排列我们比较的是“两次运行中的对应轮次”,而不是要求“同一次运行的两轮完全一样”。
正确做法是创建一次 generator 和 loader,在多轮之间复用。不要为了“固定随机数”,在每个 epoch 开头重新初始化成相同种子。
用作业 3 的 dataset 核对,下面每次调用函数只创建一次 loader,然后连续遍历两轮:
defcollect_two_epochs(seed):generator=torch.Generator().manual_seed(seed)loader=DataLoader(dataset,batch_size=2,shuffle=True,generator=generator,num_workers=0,)orders=[]for_inrange(2):order=[]forxb,_inloader:order.extend(xb.tolist())orders.append(order)returnorders first_run=collect_two_epochs(123)second_run=collect_two_epochs(123)forepochinrange(2):print("轮次:",epoch+1)print("第一次:",first_run[epoch])print("第二次:",second_run[epoch])print("对应轮次一致:",first_run[epoch]==second_run[epoch])assertfirst_run==second_run这里每条特征都不同,可以用特征行辨认顺序。在同一环境、相同代码路径下,两次的对应轮次应一致;不要再加“两轮必须不同”的断言,因为不同随机抽取也可能偶然得到相同排列。具体排列以运行结果为准。
作业 5:整体 MSE 应该怎样算?
三批分别有 4、4、1 条样本,批次 MSE 分别为 2、4、10。
先还原每批的平方误差总和:
第1批:4×2 = 8 第2批:4×4 = 16 第3批:1×10 = 10再除以全部样本数:
整体 MSE = (8+16+10)/(4+4+1) = 34/9 ≈ 3.7778如果直接算:
(2+4+10)/3 ≈ 5.3333就把只有一条样本的最后一批,与有四条样本的批次赋予了相同权重。
这里每条样本只有一个输出,所以按样本数加权即可。若要称为“某一组模型参数的整体 MSE”,这三批必须由同一组固定参数计算;如果批次之间更新了参数,这只是训练过程中的误差汇总。第十七节会用程序中的两个指标说明这一区别。
作业 6:参数初始化和清梯度应该放在哪里?
上一课错误代码有两个问题:
第一,把参数放在 epoch 内创建,会每轮重新开始:
forepochinrange(5):w=torch.zeros((2,1),requires_grad=True)第二,只在 epoch 开头清梯度,会让不同批次的梯度累积到一起。
正确结构为:
# 只初始化一次w=torch.zeros((2,1),requires_grad=True)b=torch.zeros(1,requires_grad=True)forepochinrange(5):forxb,ybintrain_loader:# 每批重新计算自己的梯度w.grad=Noneb.grad=