学习D2L深度学习课程3:矩阵计算与自动求导

矩阵计算

标量导数

 y \space y \space  a \space a \space  xn \space x^n \space  ex \space e^x \space  logx \space \log{x} \space  sinx \space \sin{x} \space  u+v \space u + v \space  uv \space uv \space  y=f(u),u=g(x) \space y = f(u), u = g(x) \space
 dydx \space \frac{dy}{dx} \space 00 nxn1nx^{n-1}  ex \space e^x \space  1x \space \frac{1}{x} \space  cosx \space \cos{x} \space  dudx+dvdx\space \frac{du}{dx} + \frac{dv}{dx}  dudxv+dvdxu \space \frac{du}{dx}v + \frac{dv}{dx}u \space  dydududx \space \frac{dy}{du}\frac{du}{dx} \space

站在几何上的角度来说,导数的几何意义就是原函数切线的斜率:

亚导数

其功能是将导数拓展到不可微的函数上。例如绝对值函数的0点。在绝对值函数0点处,函数的切线斜率不存在或不唯一,此时可以以亚导数形式进行定义:


另外一个例子是与0的取大函数max(x,0)\max(x,0),此函数在x<0x < 0时恒等于0,而在x0x \geq 0时等于xx,因此其在x=0x = 0时函数不可微,需要用亚导数形式进行表示:

xmax(x,0)={1 , if x>00 , if x<0a , if x=0, a[0,1]\frac{\partial}{\partial x} \max (x, 0) = \begin{cases} 1 \space , \space if \space x > 0 \\ 0 \space , \space if \space x < 0 \\ a \space , \space if \space x = 0 , \space a \in [0, 1] \end{cases}

梯度

梯度是将标量求导引申至向量求导所得的概念。在将导数拓展到向量时,最重要的是理清求出结果的形状,是标量、向量、亦或是矩阵。具体结果形状如下图:

具体分情况讨论如下:

  • 当标量对向量求导时(yx\frac {\partial y}{\partial \textbf{x}}

当标量对于一个向量求导时,向量为一个列向量,但最后求得的结果向量却是一个行向量:

x=[x1x2xn]     yx=[yx1,yx2,,yxn]\textbf{x} = \begin{bmatrix} x_1 \\ x_2 \\ \vdots \\ x_n \end{bmatrix} \space \space \space \space \space \frac {\partial y}{\partial \textbf{x}} = \begin{bmatrix} \frac{\partial y}{\partial x_1} , & \frac{\partial y}{\partial x_2}, & \dots , & \frac{\partial y}{\partial x_n} \end{bmatrix}

或者可以理解为当标量对向量求导,就是标量对向量组中每一个元素求偏导,再将结果组成一个新的行向量。一个例子如下:

xx12+2x22=[2x1,4x2]\frac {\partial}{\partial \textbf{x}} x^2_1 + 2x^2_2 = \begin{bmatrix} 2x_1, & 4x_2 \end{bmatrix}

此求法也可以从几何角度理解:考虑下面这张“等高图”,其中每条线由当yy取得同一值时输入的(x1,x2)(x_1, x_2)的取值集合组成。而某一点的梯度,可以看作与该点等高线方向正交的向量方向。也可以说这个方向是会令输出yy的值发生最大幅度改变的(x1,x2)(x_1, x_2)改变方向:

不同形式的标量对向量求导汇总如下:

yy aa auau sum(x)sum(\textbf{x}) x2|\textbf{x}|^2 u+vu+v uv(逐元素相乘)uv(逐元素相乘) <u,v>(内积)<\textbf{u}, \textbf{v}>(内积)
yx\frac {\partial y}{\partial \textbf{x}} 0\textbf{0}^\top auxa\frac{\partial u}{\partial \textbf{x}} 1\textbf{1}^\top 2x2\textbf{x}^\top ux+vx\frac{\partial u}{\partial \textbf{x}} + \frac{\partial v}{\partial \textbf{x}} uxv+vxu\frac{\partial u}{\partial \textbf{x}}v + \frac{\partial v}{\partial \textbf{x}}u uvx+vux\textbf{u}^\top \frac {\partial \textbf{v}}{\partial \textbf{x}} + \textbf{v}^\top \frac {\partial \textbf{u}}{\partial \textbf{x}}
  • 当向量对标量求导时(yx\frac{\partial \textbf{y}}{\partial x}

当向量对于一个标量求导时,向量和结果向量都为一个列向量,形状不变(此处和上一节的向量形状规定被称为“分子布局符号”,还可以全部反过来,被称为“分母布局符号”):

y=[y1y2ym]     yx=[y1xy2xymx]\textbf{y} = \begin{bmatrix} y_1 \\ y_2 \\ \vdots \\ y_m \end{bmatrix} \space \space \space \space \space \frac {\partial \textbf{y}}{\partial x} = \begin{bmatrix} \frac{\partial y_1}{\partial x} \\ \frac{\partial y_2}{\partial x} \\ \vdots \\ \frac{\partial y_m}{\partial x} \end{bmatrix}

这种求导方法可以被理解为对向量y\textbf{y}中的每一个分量yiy_i,关于标量xx求导,然后再合并为一个新同形列向量。

  • 当向量对向量求导时(yx\frac{\partial \textbf{y}}{\partial \textbf{x}}

当向量对一个向量求导时,结果为一个矩阵,可把这种计算当成上两节所说求导方式的联合运算:

yx=[y1xy2xymx]=[y1x1,y1x2,,y1xny2x1,y2x2,,y2xn,,,ymx1,ymx2,,ymxn]\frac{\partial \textbf{y}}{\partial \textbf{x}} = \begin{bmatrix} \frac{\partial y_1}{\partial \textbf{x}} \\ \frac{\partial y_2}{\partial \textbf{x}} \\ \vdots \\ \frac{\partial y_m}{\partial \textbf{x}} \end{bmatrix} = \begin{bmatrix} \frac {\partial y_1}{\partial x_1}, & \frac {\partial y_1}{\partial x_2}, & \dots , & \frac {\partial y_1}{\partial x_n} \\ \frac {\partial y_2}{\partial x_1}, & \frac {\partial y_2}{\partial x_2}, & \dots , & \frac {\partial y_2}{\partial x_n} \\ \vdots, & \vdots, & \dots, & \vdots \\ \frac {\partial y_m}{\partial x_1}, & \frac {\partial y_m}{\partial x_2}, & \dots , & \frac {\partial y_m}{\partial x_n} \\ \end{bmatrix}

不同形式的矩阵对矩阵求导汇总如下:

yy aa x\textbf{x} Ax\textbf{A}\textbf{x} xA\textbf{x}^\top \textbf{A} aua\textbf{u} Au\textbf{A}\textbf{u} u+v\textbf{u} + \textbf{v}
yx\frac {\partial y}{\partial \textbf{x}} 0\textbf{0} I\textbf{I} A\textbf{A} A\textbf{A} ^\top auxa \frac{\partial \textbf{u}}{\partial \textbf{x}} Aux\textbf{A} \frac{\partial \textbf{u}}{\partial \textbf{x}} ux+vx\frac{\partial \textbf{u}}{\partial \textbf{x}} + \frac{\partial \textbf{v}}{\partial \textbf{x}}
  • 拓展:如果将矩阵也作为输入进行考虑时求导结果形状列举如下:


我认为其结果形状的规律是:被求导的对象(yy)维度在前,求导的对象(xx)维度在后,结果需要合并这两个对象的维度形状,但是对于求导对象xx需要先进行转置操作颠倒维度。还要注意对形状为1的维度有时要消除。

自动求导

链式法则

最简单的标量链式法则在矩阵计算中已经提到,格式如下:

y=f(u), u=g(x)  yx=yuuxy = f(u), \space u = g(x) \space \space \frac{\partial y}{\partial x} = \frac{\partial y}{\partial u} \frac{\partial u}{\partial x}

但是也正如矩阵计算中提到,向量也是可以作为参与者加入导数运算的。那么如果有向量参与了这种复合函数的链式求导,就需要明白求导过程中各个对象的形状,示意如下(x\textbf{x}为n维、y\textbf{y}为m维、u\textbf{u}为k维向量):

以下为几个计算的例子:

  • 例1(线性回归):

x, wRn\textbf{x}, \space \textbf{w} \in R^n为两个向量,yRy \in R为标量,z=(<x,w>y)2z = (<\textbf{x}, \textbf{w}> - y)^2,需要计算zw\frac{\partial z}{\partial \textbf{w}}

一个简单的方法是可以先进行分解,将复合函数的不同层次用不同参数代替:

a=<x, w>b=ayz=b2\begin{split} & a = <\textbf{x}, \space \textbf{w}> \\ & b = a - y \\ & z = b^2 \end{split}

那么此时,这个复合函数求导就可以被看作:zw=zbbaaw\frac{\partial z}{\partial \textbf{w}} = \frac{\partial z}{\partial b} \frac {\partial b}{\partial a} \frac{\partial a}{\partial \textbf{w}}。然后就可以分层次求导:

zw=zbbaaw=b2baya<x,w>w=2b  1  x=2(<x,w>y)x\begin{split} \frac{\partial z}{\partial \textbf{w}} & = \frac{\partial z}{\partial b} \frac {\partial b}{\partial a} \frac{\partial a}{\partial \textbf{w}} \\ & = \frac {\partial b^2} {\partial b} \frac{\partial a - y}{\partial a} \frac{\partial <\textbf{x}, \textbf{w}>}{\partial \textbf{w}} \\ & = 2b \space \cdot \space 1 \space \cdot \space \textbf{x}^\top \\ & = 2 (<\textbf{x}, \textbf{w}> - y) \textbf{x}^\top \end{split}

注意:<x,w>w\frac{\partial <\textbf{x}, \textbf{w}>}{\partial \textbf{w}}的计算依照了矩阵计算中标量对向量求导时内积的情况。展开如下(以下为我的理解):

<x,w>w=xww+wxw=xI+w0=x\begin{split} \frac{\partial <\textbf{x}, \textbf{w}>}{\partial \textbf{w}} & = \textbf{x}^\top \frac {\partial \textbf{w}}{\partial \textbf{w}} + \textbf{w}^\top \frac {\partial \textbf{x}}{\partial \textbf{w}} \\ & = \textbf{x}^\top \textbf{I} + \textbf{w}^\top \cdot 0 \\ & = \textbf{x}^\top \end{split}

  • 例2(矩阵相关):

XRm×n\textbf{X} \in R^{m \times n}为m行n列矩阵,wRn\textbf{w} \in R^n为长度为n向量,yRm\textbf{y} \in R^m为长度为m向量,z=Xwy2z = \|\textbf{X}\textbf{w} - \textbf{y}\|^2为一个标量,求zw\frac{\partial z}{\partial \textbf{w}}

和例1一样,首先可以进行复合函数各部分的分解:

a=Xwb=ayz=b2\begin{split} & \textbf{a} = \textbf{X}\textbf{w} \\ & \textbf{b} = \textbf{a} - \textbf{y} \\ & z = \|\textbf{b}\|^2 \end{split}

那么此时这个复合函数也可以进行分层次求导的链式法则了(一些相关导数的展开同样见矩阵计算中的表格):

zw=zbbaaw=b2bayaXww=2b×I×X=2(Xwy)X\begin{split} \frac{\partial z}{\partial \textbf{w}} & = \frac {\partial z}{\partial \textbf{b}} \frac {\partial \textbf{b}}{\partial \textbf{a}} \frac {\partial \textbf{a}}{\partial \textbf{w}} \\ & = \frac{\partial \|\textbf{b}\|^2}{\partial \textbf{b}} \frac {\partial \textbf{a} - \textbf{y}}{\partial \textbf{a}} \frac {\partial \textbf{X}\textbf{w}}{\partial \textbf{w}} \\ & = 2 \textbf{b}^\top \times \textbf{I} \times \textbf{X} \\ & = 2 (\textbf{X}\textbf{w} - \textbf{y})^\top \textbf{X} \end{split}

自动求导

自动求导是计算一个函数在指定值上的导数,区别于:

  • 符号求导:也就是说给出显式的f(x)f(x)xx,最终利用求导法则显式的求出一个f(x)x\frac{\partial f(x)}{\partial x}的导函数公式
  • 数值求导:不知道具体f(x)f(x),但可以通过给定的xx与微小的hh,利用函数值算出某一点的导数值:f(x)x=limh0f(x+h)f(x)h\frac{\partial f(x)}{\partial x} = \lim_{h\to0} \frac {f(x+h) - f(x)}{h}

在pytorch等框架中,实现自动求导使用的是计算图的概念。其操作包括:

  • 将代码分解为操作子(类似于之前进行的复合函数部分分解)
  • 将计算表示为一个无环图

示意图如下:

计算图可以进行显示建造,也就是上图中的a,b,ca, b, c全部都使用代码显示的定义,也可以隐式构造,也就是在计算时不具体重新说明a,b,ca, b, c的定义(此处理解不是很透彻)

当进行自动求导时,假设使用的链式法则为:yx=yununun1u2u1u1x\frac{\partial y}{\partial x} = \frac{\partial y}{\partial u_n}\frac{\partial u_n}{\partial u_{n-1}} \dots \frac{\partial u_2}{\partial u_1}\frac{\partial u_1}{\partial x},那么此时有两种方式进行求导:

  • 正向累积(计算图自底向上):yx=yun(unun1((u2u1u1x)))\frac{\partial y}{\partial x} = \frac{\partial y}{\partial u_n}(\frac{\partial u_n}{\partial u_{n-1}} (\dots (\frac{\partial u_2}{\partial u_1}\frac{\partial u_1}{\partial x})))
  • 反向累积、又称反向传递(计算图自顶向下):yx=(((yununun1))u2u1)u1x\frac{\partial y}{\partial x} = (((\frac{\partial y}{\partial u_n}\frac{\partial u_n}{\partial u_{n-1}}) \dots )\frac{\partial u_2}{\partial u_1})\frac{\partial u_1}{\partial x}

反向累积过程示意如下:

所以一个反向累积的计算过程为:

  • 构造计算图
  • 进行前向执行:对于每一个节点,存储其相对子节点中间变量进行求导的中间结果
  • 进行反向执行:从反方向执行图,同时去除不需要的枝(如果此枝代表的求导计算与结果无关则不进行计算)

反向累积的复杂度分析:

  • 计算复杂度为O(n)O(n)nn代表操作子个数(正向过程所有操作子都要计算一次,反向过程的整个链式法则上的点也要计算一次)
  • 内存复杂度为O(n)O(n),因为正向过程中所有节点计算的中间结果都需要保存

与之对比,正向累积的计算复杂度同样为O(n)O(n)(同样需要计算整个链条上的导数),然而内存复杂度为O(1)O(1),因为没有反向累积中的正向过程,不需要保存每个节点的中间结果。然而,正向累积的问题是每次进行计算,即使求导内容不变,也需要重新扫过整个计算图执行正向累积操作。

具体实现见自动求导实现。

自动求导实现

目标:想对函数y=2xxy = 2 \textbf{x}^\top \textbf{x}关于列向量x\textbf{x}求导。

  • 首先初始化一个列向量:
1
2
3
import torch
x = torch.arange(4.0)
print(x) # 输出:tensor([0., 1., 2., 3.])
  • 在计算yyx\textbf{x}的梯度之前需要一个地方存储梯度(存储属性为grad,默认为None):
1
2
x.requires_grad_(True) # 等价于x = torch.arange(4.0, requires_grad=True)
print(x.grad) # 输出:None
  • 然后可以使用此向量计算yy(由于y由隐式构造,因此其会保存和x相关的求导结果):
1
2
y = 2 * torch.dot(x, x)
print(y) # 输出:tensor(28., grad_fn=<MulBackward0>)
  • 后续可以通过反向传播函数来自动计算yy关于x\textbf{x}每个分量的梯度:
1
2
y.backward()
print(x.grad) # 输出:tensor([ 0., 4., 8., 12.])
  • 如果直接考虑这个函数:2xxx=4x\frac {\partial 2 \textbf{x}^\top \textbf{x}}{\textbf{x}} = 4 \textbf{x}^\top,可以验证在[0, 1, 2, 3]位置的梯度值计算是否正确:
1
print(x.grad == 4 * x) # 输出:tensor([True, True, True, True])
  • 现在重新计算x\textbf{x}的另一个函数。由于在默认情况下,Pytorch会累积梯度(结果累加),因此在重新计算新梯度之前需要清除之前的梯度计算值后再计算:
1
2
3
4
x.grad.zero_()
y = x.sum() # 要对x求导的新函数
y.backward()
print(x.grad) # 输出:tensor([1., 1., 1., 1.])

(此处可以理解为:[1,1,1,1]xx\frac {\partial \begin{bmatrix} 1, 1, 1, 1 \end{bmatrix} \textbf{x}} {\partial \textbf{x}},其结果自然是[1,1,1,1]\begin{bmatrix} 1, 1, 1, 1 \end{bmatrix}

  • 在深度学习中,求导目的一般不是计算一个微分矩阵,而是批量中每个样本单独计算的偏导数之和(也就是说很少使用向量对向量的求导):
1
2
3
4
5
# 对非标量调用backward需要传入一个gradient参数,该参数指定微分函数关于self的梯度
x.grad.zero_()
y = x * x
y.sum().backward() # 如果对y求导相当于对非标量求微分方程,比较少见,更多的是先求和再求导
print(x.grad) # 输出:tensor([0., 2., 4., 6.])
  • 可以使用detach()将一些计算移动到记录的计算图之外(视为常数):
1
2
3
4
5
6
7
x.grad.zero_()
y = x * x
u = y.detach() # 相当于将u定为一个常量,其值由x确定,但是不视为x的函数
z = u * x

z.sum().backward() # z对x求导是[1, 1, 1, 1]
print(x.grad == u) # 输出:tensor([True, True, True, True])
  • 也可知道,即使是上述情况,y本身依然是关于x的函数(未被detach),那么其同样可以正确执行对x的求导:
1
2
3
x.grad.zero_()
y.sum().backward()
print(x.grad == 2 * x) # 输出:tensor([True, True, True, True])
  • 即使构建函数的计算图需要经过Python控制流,也可以计算得到变量的梯度(也就是说,在Python感应到计算时就已经将对应的计算图建构完成,后续可以直接求对应梯度),举例如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def f(a): # 利用控制流构造复杂的公式
b = 2 * a
while b.norm() < 1000:
b = b * 2
if b.sum() > 0:
c = b
else:
c = 100 * b
return c

a = torch.randn(size=(), requires_grad=True)
d = f(a)
d.backward() # 对于复杂公式同样可以求任意节点导数

print(a.grad == d / a) # 输出:tensor(True)