前言
以下基于 Pytorch 实践
Python 神经网络框架通过构建计算图来控制数据有序流动(计算图是一种将计算流程化的方法)如前向传播(forward)和反向传播(backward) 从而实现梯度优化。计算图为有向图,由一个个结点组成,每个结点都是某种状态的数据,用变量(variable)表示,可以是标量(scalar),向量(vector),矩阵(matrix),张量(tensor)等各种形式
前向传播很好理解,结点的数据按照计算图上规定好的计算方式逐级向前流动,同时记录梯度优化所必须的相关信息,比如求导过程需要使用的函数。容易忽视的也是最重要的「是反向传播是如何根据神经网络中计算图的结点数据进行梯度优化」,在 Pytorch 中实现了自动微分方法,只要 tensor 结点需要求导,前向传播时 tensor 的隐变量 ctx 就会自动记录结点的运算信息,反向传播时据此计算梯度
张量分析
tensor 定义了以下主要属性
data
:存 tensor 的数据
grad
:存 tensor 的梯度
grad_fn
:存 tensor 的计算路径,无需求梯度且不是由运算产生的 tensor 默认 None,需要求梯度且是运算产生的 tensor 将填充运算过程
is_leaf
:判断结点是否叶子结点,非计算得到的 tensor 默认True
requires_grad
:判断结点是否 backward 时计算梯度,手动声明的 tensor 默认 False 而手动声明的 parameter(封装为默认需要求梯度是叶子结点的 tensor )默认 True
计算图中的结点被区分为普通结点与叶子结点,叶子结点在计算图中又叫终端结点,也就是结点往前连了其它结点,往后没有任何连接,在神经网络中,通常不是计算产生的结点都是叶子结点。例如手动初始化的参数,算子自动初始化的参数等,计算图中的节点根据是否是叶子结点与是否需要计算梯度可以分为四类
1. 无需计算梯度的叶子结点,计算图中手动声明的 Tensor 的 is_leaf = True、requires_grad = False 和 grad_fn 为 None
2. 需要计算梯度的叶子结点,计算图中手动声明的 Parameter 的 is_leaf = True、requires_grad = True 和 grad_fn 为 None
3. 需要计算梯度的普通结点,计算图中运算得到的 Tensor 的 is_leaf = Flase,由 requires_grad = True 的对象产生,此时 requires_grad = True 且 grad_fn 为一个 List 记录了求导链
4. 无需计算梯度的普通结点,计算图中运算得到的 Tensor 的 is_leaf = Flase,由 requires_grad = False 的对象产生,此时 requires_grad = False 且 grad_fn 为 None
其中,需要计算梯度的叶子结点,会根据前驱结点计算梯度存储在其 grad 中,后续优化时使用梯度改变其 data 值,一般 requires_grad = True 且 is_leaf = True 的 tensor 就是模型中可学习的参数,如卷积神经网络的卷积核权重
其中,需要计算梯度的普通结点,支撑起后继结点的梯度运算,是模型的骨架,支撑了计算图的梯度求导链,自身不会保存其梯度也不会优化其数据,可以通过 tensor.retain_grad() 查看其梯度值
梯度分析
声明两个张量 a,b
a = torch.tensor(2.0)
b = torch.tensor(3.0)
c = a * b
此时 a,b 的 requires_grad 默认都是 False,经过计算得到的 c 的 requires_grad 默也是 False,都不需要求梯度
将 a 的 requires_grad 设置为 True
a = torch.tensor(2.0, requires_grad = True)
b = torch.tensor(3.0)
c = a * b
c.backward()
print(a.grad)
"""
tensor(3.)
"""
于是有如下分析
- 因为 tensor a 的属性 requires_grad = True,所以经过计算得到的 tensor c 的属性 requires_grad = True,is_leaf 为 False
- 使用 tensor 的乘法时,调用了隐变量 ctx 的方法 ctx.save_for_backward(),保存了 forward 运算时的参数
- 进行 c.backward 运算时,c 的 grad_fn 保存了 backward 时候对应的计算图,即函数 MulBackward,backward 时,其实就是求函数 c = a * b 分别对 a 与 b 的偏导,需要两 backward 的函数,分别打包存于 MulBackward 的 next_functions 而 next_functions[0] 的 AccumulateGrad 对象会将把相应得到的结果送到 a.grad 中
- 因此 c.backward() 后 a.grad = 3,b.grad = None,c.grad = None
import torch
# 使用梯度优化拟合多项式
x_data = [1.0, 2.0, 3.0]
y_data = [2.0, 4.0, 6.0]
w = torch.Tensor([1.0]) # 初始权值
w.requires_grad = True # 设置为需要计算梯度
def forward(x):
y_pred = x * w
return y_pred
def loss(y_pred, y):
l = (y_pred - y) ** 2
return l
def optim(w):
w.data = w.data - 0.01 * w.grad.data
print('Predict (befortraining)', 4, forward(4).item())
for epoch in range(100):
print('Epoch:', epoch)
for x, y in zip(x_data, y_data):
if w.grad is not None:
w.grad.data.zero_() # 等价于 optimizer.zero_grad()
y_pred = forward(x)
l = loss(y_pred, y)
l.backward()
print(f'x is {x}, y is {y},l is {l.item()}')
print(f'w is {w.item()}, w grad is {w.grad.item()}')
optim(w) # 等价于 optimizer.step() 的作用
print('Predict(after training)', 4, forward(4).item())
"""
Predict (befortraining) 4 4.0
Epoch: 0
x is 1.0, y is 2.0,l is 1.0
w is 1.0, w grad is -2.0
x is 2.0, y is 4.0,l is 3.841600179672241
w is 1.0199999809265137, w grad is -7.840000152587891
x is 3.0, y is 6.0,l is 7.315943717956543
w is 1.0983999967575073, w grad is -16.228801727294922
Epoch: 1
x is 1.0, y is 2.0,l is 0.5465821623802185
w is 1.260688066482544, w grad is -1.478623867034912
x is 2.0, y is 4.0,l is 2.099749803543091
w is 1.2754743099212646, w grad is -5.796205520629883
x is 3.0, y is 6.0,l is 3.9987640380859375
w is 1.333436369895935, w grad is -11.998146057128906
...
Epoch: 99
x is 1.0, y is 2.0,l is 1.2789769243681803e-13
w is 1.9999996423721313, w grad is -7.152557373046875e-07
x is 2.0, y is 4.0,l is 5.115907697472721e-13
w is 1.9999996423721313, w grad is -2.86102294921875e-06
x is 3.0, y is 6.0,l is 9.094947017729282e-13
w is 1.9999996423721313, w grad is -5.7220458984375e-06
Predict(after training) 4 7.999998569488525
"""
自定义函数
如果在模型中引入自定义函数 Function 时,也需要为自定义函数 Function 设计 FunctionBackward ,否则会破坏计算图,当引入一些函数只想改变 tensor 的数据而不想改变计算图时,可以让函数仅操作 tensor 的 data(但是此后再 backward 梯度将出现错误),可以通过如下的方式
import torch
x = torch.ones(1, requires_grad=True)
for i in range(5):
print(x, x.data) # 存储x的数据x.data是两个对象
y = 2 * x
print(y)
reg = x.data * 100
x.data = reg # 只改变数据,不会改变计算图,但是自动微分机制被破坏了,数值优化过程也失去意义
y.backward()
print(x, x.grad)
print(y)
"""
tensor([1.], requires_grad=True) tensor([1.])
tensor([2.], grad_fn=<MulBackward0>)
tensor([100.], requires_grad=True) tensor([2.])
tensor([2.], grad_fn=<MulBackward0>)
...
"""
自定义一个能够反向传播的 Sigmoid 函数
class SelfDefinedSigmoid(torch.autograd.Function):
@staticmethod
def forward(ctx, inp):
result = torch.divide(torch.tensor(1), (1 + torch.exp(-inp)))
ctx.save_for_backward(result)
return result
@staticmethod
def backward(ctx, grad_output):
# ctx.saved_tensors is tuple (tensors, grad_fn)
result, = ctx.saved_tensors
return grad_output * result * (1 - result)
class Sigmoid(nn.Module):
def __init__(self):
super().__init__()
def forward(self, x):
out = SelfDefinedSigmoid.apply(x)
return out
torch.manual_seed(0)
sg = Sigmoid()
inputs = torch.nn.Parameter(torch.tensor(2.))
outputs = sg(inputs)
outputs.backward(torch.ones_like(inputs), retain_graph=True)
print(outputs, inputs.grad)
"""
tensor(0.8808, grad_fn=<SelfDefinedSigmoidBackward>) tensor(0.1050)
"""
打断计算图
Pytorch 虽然采用动态图,但 grad_fn 是在前向计算过程中填充的,填充完整后再去打断计算图,反向传播依旧根据改变前的模式计算,打断将无效果
- detach_() 断开计算图中的叶子结点改变回传梯度
import torch
a = torch.nn.Parameter(torch.tensor([1., 2.]))
b = 2*a
b.detach_()
c = torch.mean(3*a+b)
c.backward()
print("c data:",c)
print("a grad:", a.grad, a.requires_grad)
print("b grad:", b.grad, b.requires_grad)
"""
c data: tensor(7.5000, grad_fn=<MeanBackward0>)
a grad: tensor([1.5000, 1.5000]) True
b grad: None False
"""
求导可知,如果不打断计算图中梯度为 2 的部分,a 的梯度值应该是 [2.5000, 2.5000]
- required_grad_(False) 只能作用于叶子结点,否则会造成反向传播时梯度阻塞
- detach_() 可以作用于非叶子结点,但是必须保证至少有一条完整的 grad_fn
import torch
a = torch.nn.Parameter(torch.tensor(2.))
b = torch.tensor(3.)
c = a * b
d = torch.nn.Parameter(torch.tensor(4.))
e = c * d
e.detach_()
f = c * d
g = torch.nn.Parameter(torch.tensor(5.))
g.requires_grad_(False)
h = f * g
i = e + h
print(f"a data = {a.data,a.requires_grad}, grad = {a.grad}")
print(f"b data = {b.data,b.requires_grad}, grad = {b.grad}")
print(f"c data = {c.data,c.requires_grad}, grad = {c.grad}")
print(f"d data = {d.data,d.requires_grad}, grad = {d.grad}")
print(f"e data = {e.data,e.requires_grad}, grad = {e.grad}")
print(f"f data = {f.data,f.requires_grad}, grad = {f.grad}")
print(f"g data = {g.data,g.requires_grad}, grad = {g.grad}")
print(f"h data = {h.data,h.requires_grad}, grad = {h.grad}")
print(f"i data = {i.data,i.requires_grad}, grad = {i.grad}")
h.retain_grad()
i.backward()
print(f"a data = {a.data,a.requires_grad}, grad = {a.grad}")
print(f"b data = {b.data,b.requires_grad}, grad = {b.grad}")
print(f"c data = {c.data,c.requires_grad}, grad = {c.grad}")
print(f"d data = {d.data,d.requires_grad}, grad = {d.grad}")
print(f"e data = {e.data,e.requires_grad}, grad = {e.grad}")
print(f"f data = {f.data,f.requires_grad}, grad = {f.grad}")
print(f"g data = {g.data,g.requires_grad}, grad = {g.grad}")
print(f"h data = {h.data,h.requires_grad}, grad = {h.grad}")
print(f"i data = {i.data,i.requires_grad}, grad = {i.grad}")
"""
a data = (tensor(2.), True), grad = None
b data = (tensor(3.), False), grad = None
c data = (tensor(6.), True), grad = None
d data = (tensor(4.), True), grad = None
e data = (tensor(24.), False), grad = None
f data = (tensor(24.), True), grad = None
g data = (tensor(5.), False), grad = None
h data = (tensor(120.), True), grad = None
i data = (tensor(144.), True), grad = None
a data = (tensor(2.), True), grad = 60.0
b data = (tensor(3.), False), grad = None
c data = (tensor(6.), True), grad = None
d data = (tensor(4.), True), grad = 30.0
e data = (tensor(24.), False), grad = None
f data = (tensor(24.), True), grad = None
g data = (tensor(5.), False), grad = None
h data = (tensor(120.), True), grad = 1.0
i data = (tensor(144.), True), grad = None
"""
在 i = b ⋅ a d + b ⋅ a d g i=b\cdot ad+b\cdot adg i=b⋅ad+b⋅adg 中, 通过 required_grad_(False) 或 detach_() 可将计算图中叶子结点的梯度相关的属性 required_grad 改为 False,此时 backward 后不再返回梯度,另外,想要查看计算图中非叶子结点的梯度,可使用 retain_grad(),在 backward 后将返回梯度
工作流
当神经网络计算出的 loss 后 Pytorch 依次通过 optimizer.zero_grad()
,loss.backward()
,optimizer.step()
三个方法来实现正确的梯度计算与反向传播
optimizer.zero_grad
- optimizer.zero_grad() 根据 optimizer 封装的方法将每个参数 Tensor 的 grad 赋值 0,避免 grad 的值累在一起,影响反向传播
loss.backward
- loss.backward() 根据 loss 的 grad_fn 描述 backward 的内容为每个 requires_grad = True 的 Tensor 计算出梯度并为 is_leaf = True 的 Tensor 的 grad 返回梯度值
optimizer.step
- optimizer.step() 根据 optimizer 封装的方法将每个参数 Tensor 的 grad 优化其 data
通常会先把模型中的参数全部交给优化器
import model
import config
if opt_name == 'sgd':
return optim.SGD(model.parameters(),
lr=config['learning_rate'], momentum=config['momentum'], weight_decay=config['weight_decay'])
也可以选择将部分参数冻结使其不计算梯度,过滤掉再交给优化器
import model
import config
name_flag = "conv"
for k, v in model.named_parameters():
if name_flag in k:
v.requires_grad = False # v.requires_grad_(False) 或 v.detach_()
optim.SGD(filter(lambda p: p.requires_grad, model.parameters()),
lr=config['learning_rate'], momentum=config['momentum'], weight_decay=config['weight_decay'])
总结
神经网络梯度优化的理论基于多元函数链式求导法则,如何实现取决于所使用的框架(如计算图),万变不离其宗无非是三步:确定链中结点的含义,计算链中结点的差分,结果回溯到链中结点;通过 Python 实现神经网络梯度优化时也一定是从这三个方面设计,因此看源码,查资料,Debug 就有了方向,具体问题具体分析,自然就有的放矢