第四课标准答案
先把上一课最后六个问题收掉,然后直接沿着"参数 \(\theta\) 到底是什么"进入第五课。
1. loss.backward() 和 optimizer.step() 有什么区别?
最关键的区别是:
\ \\boxed{\\text{backward 负责算梯度,step 负责改参数}} \\
执行:
loss.backward()
以后,模型参数本身还没有被更新。PyTorch 只是算出了:
\ \\frac{\\partial L}{\\partial \\theta} \\
并把每个参数对应的梯度保存下来。
随后:
optimizer.step()
Optimizer 才利用这些梯度真正修改:
\ \\theta \\
所以是:
\ L \\rightarrow \\text{backward} \\rightarrow \\nabla_\\theta L \\rightarrow \\text{optimizer.step()} \\rightarrow \\theta_{\\text{new}} \\
2. 为什么每个 Batch 前通常要 optimizer.zero_grad()?
因为 PyTorch 默认会把梯度累加。
假设上一个 Batch 算出来:
\ grad=2 \\
当前 Batch 又得到:
\ grad=3 \\
如果不清零,可能得到:
\ grad=5 \\
普通训练通常希望当前更新只依据当前 Batch,所以先:
optimizer.zero_grad()
把之前残留的梯度清掉。
然后重新:
Forward
→ Loss
→ Backward
→ 当前Batch的Gradient
→ Update
3. 模型真正"学到"的东西存在哪里?
在模型参数:
\ \\boxed{\\theta} \\
里面。
Observation 是输入数据,Loss 是一个训练过程中产生的评价数值。
真正随着训练不断变化并最终被保存到 Checkpoint 的,是模型里的:
\ W,\\quad b,\\quad \\text{以及其他可训练参数} \\
因此:
\ \\theta=\\{W_1,b_1,W_2,b_2,\\ldots\\} \\
所谓"模型学会了",从最底层来看,就是:
模型内部的这些参数被调整到了更合适的数值。
4. Loss 通常是什么 Shape?
假设:
\ obs:\[32,10 \]
模型预测:
\ \\hat A:\[32,7 \]
专家 Action:
\ A:\[32,7 \]
虽然一共有:
\ 32\\times7=224 \\
个预测数值需要比较,但是常见 Loss 最后会把这些误差进行求和或求平均,得到一个:
\ \\boxed{\\text{scalar}} \\
也就是标量。
在 PyTorch 中经常看到:
loss.shape
是:
[]
或者把它打印出来就是:
0.0317
5. 6400 个 Sample,Batch Size = 64,一个 Epoch 大约多少 Step?
\ \\frac{6400}{64}=100 \\
所以:
\ \\boxed{100\\text{ steps}} \\
也就是一个 Epoch 中大约会执行 100 次:
optimizer.step()
6. 为什么 Training 和 Inference 都有 Forward,但只有 Training 需要 Backward?
因为两者都需要模型做预测:
\ o\\rightarrow\\pi_\\theta(o)\\rightarrow\\hat a \\
所以都需要 Forward。
但是 Training 还要问:
我的预测和正确答案差多少?参数应该怎么修改?
因此还需要:
\ \\hat a \\rightarrow Loss \\rightarrow Backward \\rightarrow Parameter Update \\
而 Inference 的参数已经训练好了,只需要:
\ Observation\\rightarrow Predicted\\ Action \\
不需要继续改模型。
VLA 系统学习第 5 课:神经网络的参数到底是什么?------从 nn.Linear 真正理解 \(W\)、\(b\) 和 Gradient
上一课最后,我们留下了一句话:
模型训练,就是不断修改参数 \(\theta\)。
但这句话其实仍然非常抽象。
究竟什么叫"参数"?
一个 nn.Linear(10, 64) 里面到底装了什么?
为什么输入:
\ \[B,10 \]
经过它以后会变成:
\ \[B,64 \]
?
optimizer.step() 到底在改哪个数字?
这一课我们就不再把"参数"当成一个抽象黑箱,而是真正把一层神经网络拆开。
一、先不要想"神经网络",只想一个最简单的公式
假设我们只有一个输入数字:
\ x \\
模型输出:
\ y \\
最简单的关系可以写成:
\ y=wx+b \\
这里真正决定模型行为的是:
\ w \\
和:
\ b \\
例如:
\ w=2,\\qquad b=1 \\
输入:
\ x=3 \\
那么:
\ y=2\\times3+1=7 \\
如果把参数改成:
\ w=5,\\qquad b=-2 \\
相同的输入:
\ x=3 \\
就得到:
\ y=5\\times3-2=13 \\
你看,输入完全没变。
真正改变模型输出的,是:
\ w,b \\
所以这两个东西就是:
Parameters
模型参数。
训练就是不断调整这些参数。
二、为什么需要 \(b\)?
先只考虑:
\ y=wx \\
如果:
\ x=0 \\
那么无论 \(w\) 是多少:
\ y=0 \\
但现实中我们可能希望:
\ x=0 \\
时模型输出:
\ y=3 \\
这时就可以加入:
\ b \\
变成:
\ y=wx+b \\
于是:
\ x=0 \\
时:
\ y=b \\
所以 \(b\) 可以理解成:
在输入经过权重变化之后,再给输出增加一个可以学习的平移量。
\(W\) 通常叫:
Weight,权重
\(b\) 叫:
Bias,偏置
三、从一个输入扩展到多个输入
机器人 Observation 当然不可能只有一个数字。
假设 Observation 有三个维度:
\ x= \[x_1,x_2,x_3 \]
现在我们想输出一个数字。
可以写成:
\ y= x_1w_1+x_2w_2+x_3w_3+b \\
比如:
\ x=\[2,1,3 \]
权重:
\ w=\[0.5,-1,2 \]
偏置:
\ b=0.2 \\
那么:
\ y= 2\\times0.5 + 1\\times(-1) + 3\\times2 + 0.2 \\
所以:
\ y=1-1+6+0.2=6.2 \\
这里其实发生了一件非常重要的事情:
模型让不同输入维度具有不同的重要程度。
因为三个权重:
\ 0.5,-1,2 \\
不同。
第三个输入的影响就比第一个更强。
四、但机器人 Action 也不止一个数字
现在假设:
\ x=\[x_1,x_2,x_3 \]
我们不想只输出一个数,而是输出两个:
\ y=\[y_1,y_2 \]
那么第一份输出可以有自己的一套权重:
\ y_1= x_1w_{11} +x_2w_{12} +x_3w_{13} +b_1 \\
第二份输出也有自己的一套权重:
\ y_2= x_1w_{21} +x_2w_{22} +x_3w_{23} +b_2 \\
现在已经有:
\ 6 \\
个 Weight 和:
\ 2 \\
个 Bias。
这时候一个一个写就很麻烦,所以使用矩阵。
五、Linear Layer 本质上就是矩阵乘法
把权重组织成:
\ W= \\begin{bmatrix} w_{11}\&w_{12}\&w_{13}\\\\ w_{21}\&w_{22}\&w_{23} \\end{bmatrix} \\
那么:
\ W\\in\\mathbb R\^{2\\times3} \\
输入:
\ x\\in\\mathbb R\^3 \\
Bias:
\ b\\in\\mathbb R\^2 \\
就可以写成:
\ y=Wx+b \\
于是:
\ \\mathbb R\^3 \\rightarrow \\mathbb R\^2 \\
这就是 Linear Layer 最核心的东西。
六、对应到 PyTorch 的 nn.Linear
现在看:
layer = nn.Linear(3, 2)
这里:
3 = in_features
2 = out_features
也就是说:
\ 3\\text{维输入} \\rightarrow 2\\text{维输出} \\
PyTorch 内部会创建:
\ W \\
和:
\ b \\
其中:
layer.weight.shape
是:
\ \[2,3 \]
而:
layer.bias.shape
是:
\ \[2 \]
这非常重要。
PyTorch 的 nn.Linear(in_features, out_features) 中:
\ \\boxed{ weight.shape= \[out\\_features,in\\_features } \]
所以:
nn.Linear(10, 64)
里面:
\ W:\[64,10 \]\ b:\[64 \]
七、为什么有时候教材又写 \(Y=XW+b\)?
这个地方以后非常容易把你绕晕,所以现在就把它说明白。
数学教材里可能写:
\ Y=XW+b \\
如果:
\ X:\[B,10 \]
希望输出:
\ Y:\[B,64 \]
那么这里的 \(W\) 应该是:
\ W:\[10,64 \]
因为:
\ \[B,10\times10,64 = B,64 \]
但是 PyTorch 实际存储:
layer.weight
是:
\ \[64,10 \]
所以 PyTorch 计算概念上更接近:
\ Y=XW\^T+b \\
因为:
\ W\^T:\[10,64 \]
于是:
\ \[B,10\times10,64 = B,64 \]
所以以后必须区分两件事:
\ \\boxed{\\text{数学公式中的矩阵写法}} \\
和:
\ \\boxed{\\text{PyTorch实际保存Weight的Shape}} \\
如果代码是:
nn.Linear(10, 64)
你看到:
weight.shape
应该想到:
\ \[64,10 \]
八、现在终于解释 [B,10] → [B,64]
假设:
layer = nn.Linear(10, 64)
输入:
\ X:\[B,10 \]
其中每一行代表一个 Sample:
\ X= \\begin{bmatrix} ---x_1---\\\\ ---x_2---\\\\ \\vdots\\\\ ---x_B--- \\end{bmatrix} \\
每一个:
\ x_i \\
都有 10 个数字。
Linear Layer 有:
\ 64 \\
组输出权重。
所以对于一个 Sample:
\ \[10 \]
会计算出:
\ \[64 \]
个新数字。
于是整个 Batch:
\ \[B,10 \]
变成:
\ \[B,64 \]
注意:
\ B \\
没有变。
因为 Linear 并没有把 32 个 Sample 混在一起。
它只是对这 32 个 Sample 使用同一套参数分别计算。
所以:
\ \[32,10 \rightarrow 32,64 \]
这里真正变化的是:
\ 10\\rightarrow64 \\
而不是:
\ 32 \\
九、这 64 个数字是什么?
这是一个很关键的问题。
假设原 Observation 是:
\ \[10 \]
经过:
nn.Linear(10, 64)
得到:
\ \[64 \]
这 64 个数字通常不再具有:
第 1 个就是关节 1,第 2 个就是关节 2......
这样的直接物理含义。
它们可以理解成:
神经网络根据原始输入学习出来的一组内部特征表示。
通常称为:
Feature / Hidden Representation
所以网络:
Observation
[10]
↓
Linear
↓
Hidden Feature
[64]
实际上是在把原始状态映射到一个新的特征空间。
十、为什么不能直接 10 → 7?
当然可以。
例如:
nn.Linear(10, 7)
直接:
\ Observation \\rightarrow Action \\
完全可以训练。
但它本质上只是:
\ y=Wx+b \\
属于一个线性映射。
而现实中的 Observation → Action 关系往往非常复杂。
例如:
当物体在左边且夹爪打开时这样移动;
当物体在右边且夹爪已经闭合时又是另一种行为。
这种复杂关系通常不能只靠一个简单线性关系表达。
因此神经网络通常会增加隐藏层和:
Nonlinearity
例如:
self.net = nn.Sequential( nn.Linear(10, 64), nn.ReLU(), nn.Linear(64, 7))
于是数据流:
\ \[10 \rightarrow 64 \rightarrow 64 \rightarrow 7 \]
其中:
nn.ReLU()
不会改变这里的 Shape:
\ \[64\rightarrow64 \]
但它引入了非线性。
ReLU 的细节我们现在不岔出去,后面真正需要理解神经网络表达能力时再系统展开。
十一、这个小网络到底有多少 Parameters?
现在来看:
nn.Linear(10, 64)
Weight:
\ 64\\times10=640 \\
个参数。
Bias:
\ 64 \\
个参数。
所以第一层共有:
\ 640+64=704 \\
个参数。
第二层:
nn.Linear(64, 7)
Weight:
\ 7\\times64=448 \\
Bias:
\ 7 \\
所以:
\ 448+7=455 \\
整个网络总参数数:
\ 704+455=1159 \\
ReLU 没有可训练 Weight 和 Bias,所以:
\ 0 \\
个可训练参数。
因此这个极小的 BC Policy 就已经有:
\ \\boxed{1159} \\
个可以被训练修改的数字。
所谓:
\ \\theta \\
实际上就是这 1159 个数字的集合。
十二、model.parameters() 里面究竟是什么?
假设模型:
class BCPolicy(nn.Module): def __init__(self): super().__init__() self.net = nn.Sequential( nn.Linear(10, 64), nn.ReLU(), nn.Linear(64, 7) ) def forward(self, obs): return self.net(obs)
创建:
model = BCPolicy()
然后:
model.parameters()
就是在获取模型中注册的可训练参数。
这里主要就是:
第一层 weight
第一层 bias
第二层 weight
第二层 bias
更直观的方法是:
for name, param in model.named_parameters(): print(name, param.shape)
可能看到类似:
net.0.weight [64, 10]
net.0.bias [64]
net.2.weight [7, 64]
net.2.bias [7]
这就是:
\ \\theta \\
第一次真正从数学符号变成代码里的东西。
十三、Optimizer 为什么需要 model.parameters()?
现在看:
optimizer = torch.optim.Adam( model.parameters(), lr=1e-3)
你现在应该能理解:
model.parameters()
不是一句无意义的固定语法。
它实际上是在告诉 Optimizer:
这些参数是你以后允许修改的对象。
也就是:
W1
b1
W2
b2
训练时:
loss.backward()
计算这些参数的 Gradient。
然后:
optimizer.step()
修改这些参数。
所以链条真正变成:
\ \\boxed{ model.parameters() \\rightarrow Gradient \\rightarrow Optimizer \\rightarrow Updated\\ Parameters } \\
十四、Gradient 和 Weight 是一一对应的
假设:
layer.weight.shape
是:
\ \[64,10 \]
那么训练以后:
layer.weight.grad.shape
也是:
\ \[64,10 \]
为什么?
因为每一个 Weight:
\ w_{ij} \\
都需要知道:
我对最终 Loss 产生了什么影响?
所以每一个:
\ w_{ij} \\
都有对应的:
\ \\frac{\\partial L}{\\partial w_{ij}} \\
因此 Weight Matrix:
\ W \\
对应一个同 Shape 的 Gradient Matrix:
\ \\nabla_WL \\
如果:
\ W:\[64,10 \]
那么:
\ \\nabla_WL:\[64,10 \]
同样:
\ b:\[64 \]
那么:
\ \\nabla_bL:\[64 \]
十五、真正理解 optimizer.step() 在做什么
假设某一个 Weight 当前是:
\ w_{23}=0.41 \\
Backward 算出:
\ \\frac{\\partial L}{\\partial w_{23}}=0.8 \\
假设最简单的梯度下降学习率:
\ \\eta=0.01 \\
那么:
\ w_{23}\^{new} = 0.41-0.01\\times0.8 \\
所以:
\ w_{23}\^{new}=0.402 \\
另一个参数可能是:
\ w_{45}=-0.12 \\
梯度:
\ \\frac{\\partial L}{\\partial w_{45}}=-0.5 \\
更新:
\ w_{45}\^{new} = -0.12-0.01\\times(-0.5) \\
得到:
\ w_{45}\^{new}=-0.115 \\
也就是说:
有的 Weight 变大,有的 Weight 变小。
不是整个模型统一加一个数字。
而是:
\ \\boxed{ 每一个参数都有自己的Gradient } \\
因此一次 optimizer.step() 可能同时修改几十万、几百万甚至几十亿个不同的数字。
十六、为什么一个参数改变会影响最终 Action?
现在把整个网络写出来:
\ O \\rightarrow W_1,b_1 \\rightarrow Hidden \\rightarrow W_2,b_2 \\rightarrow \\hat A \\
如果:
\ W_1 \\
改变了一点,
中间 Hidden Feature 就会改变。
Hidden 改变以后:
\ \\hat A \\
也会改变。
所以训练实际上是在不断寻找:
\ W_1,b_1,W_2,b_2 \\
这些参数的组合,让:
\ \\hat A \\
越来越接近:
\ A \\
于是:
\ \\min_\\theta L(\\pi_\\theta(O),A) \\
现在这条公式应该比上一课具体得多了。
这里:
\ \\theta \\
不是什么神秘符号。
它真的就是:
\ \\theta= \\{W_1,b_1,W_2,b_2,\\ldots\\} \\
十七、顺着前面"魔术方法"再理解一次 model(obs)
前面我们学过:
dataset[10]
会触发:
__getitem__()
现在还有一个很像的情况。
我们定义:
class BCPolicy(nn.Module): def forward(self, obs): ...
但训练时并不通常写:
model.forward(obs)
而是:
model(obs)
这是因为:
BCPolicy
继承了:
nn.Module
而 nn.Module 对:
__call__()
进行了处理。
所以可以先把调用链理解成:
model(obs)
↓
nn.Module 的 __call__()
↓
执行 PyTorch 在前后需要处理的一些逻辑
↓
调用我们定义的 forward(obs)
↓
返回结果
所以:
pred_action = model(obs)
最终确实会进入:
def forward(self, obs):
这和前面的:
dataset[index]
→ __getitem__()
属于同一种 Python 思想:
特定语法触发对象定义好的特殊行为。
因此以后看到:
model(x)
不要理解成:
model 是一个普通函数。
更准确地说:
model是nn.Module对象,这个对象可以被调用,而调用最终会进入forward()。
十八、现在把第五课和前四课彻底接起来
我们的完整链已经从真实数据走到了具体 Weight:
\ \\boxed{ Demonstration \\rightarrow Dataset \\rightarrow Batch \\rightarrow Observation \\rightarrow Linear(W_1,b_1) \\rightarrow Hidden\\ Feature \\rightarrow Linear(W_2,b_2) \\rightarrow Predicted\\ Action } \\
然后:
\ \\hat A \\rightarrow L(\\hat A,A) \\rightarrow Backward \\
得到:
\ \\nabla_{W_1}L, \\nabla_{b_1}L, \\nabla_{W_2}L, \\nabla_{b_2}L \\
再:
\ optimizer.step() \\
于是:
\ W_1,b_1,W_2,b_2 \\
发生一点变化。
下一批数据进来时,模型已经不是原来的模型。
重复成千上万次以后:
\ \\theta_0 \\rightarrow \\theta_1 \\rightarrow \\theta_2 \\rightarrow \\cdots \\rightarrow \\theta\^\* \\
这就是目前最底层意义上的:
模型从 Demonstration 中学到了 Observation → Action 的关系。
第五课自测
-
nn.Linear(10, 64)中,PyTorch 的weight.shape和bias.shape分别是多少? -
为什么:
\ \[B,10 \]
经过 nn.Linear(10,64) 后变成:
\ \[B,64 \]
但 \(B\) 不发生变化?
-
nn.Linear(10,64)一共有多少个可训练参数? -
一个:
nn.Linear(64, 7)
又有多少参数?
- 如果:
\ W:\[64,10 \]
那么:
\ W.grad \\
的 Shape 应该是多少?为什么?
-
model.parameters()为什么必须传给 Optimizer? -
model(obs)为什么最终会调用我们定义的forward()? -
现在请用自己的话解释:
\ \\theta \\
在神经网络里到底是什么。