從零實現(xiàn)深度學(xué)習(xí)框架(三)計算圖運算補充

引言
本著“凡我不能創(chuàng)造的,我就不能理解”的思想,本系列文章會基于純Python以及NumPy從零創(chuàng)建自己的深度學(xué)習(xí)框架,該框架類似PyTorch能實現(xiàn)自動求導(dǎo)。
要深入理解深度學(xué)習(xí),從零開始創(chuàng)建的經(jīng)驗非常重要,從自己可以理解的角度出發(fā),盡量不適用外部完備的框架前提下,實現(xiàn)我們想要的模型。本系列文章的宗旨就是通過這樣的過程,讓大家切實掌握深度學(xué)習(xí)底層實現(xiàn),而不是僅做一個調(diào)包俠。
本文額外介紹一些操作的計算圖,像求最大值(Max)、切片(Slice)、變形(Reshape)和轉(zhuǎn)置(Transpose)。
Max
Max操作要復(fù)雜一些,我們先來看一下當(dāng)輸入是一個1D數(shù)組時:

取1D數(shù)組中,假設(shè)第三個元素為最大值,那么經(jīng)過Max操作后,返回的就是這個元素。當(dāng)反向傳播時,只有第三個元素有梯度,其他元素的梯度為。此時只有在第三個元素處才會將上游的梯度原封不動的傳給下游,其他元素處將不會有梯度往下游傳遞。就像電路中的開關(guān)一樣,只有第三個元素處開關(guān)是打開的,有電流通過;其他元素的位置的開關(guān)是關(guān)閉的,沒有電流通過。下面通過代碼演示一下:
>?import?torch
#?隨機生成一個(8,)的一維數(shù)組
>?x?=?torch.randint(10,?(8,),?dtype=torch.float,?requires_grad=True)
>?print(x)?#?第三個元素為最大值
tensor([3.,?1.,?8.,?0.,?0.,?3.,?6.,?4.],?requires_grad=True)
>?y?=?torch.max(x)
>?print(y)
tensor(8.,?grad_fn=)
>?y.backward()
>?print(x.grad)?#?只有第三個元素才有梯度
tensor([0.,?0.,?1.,?0.,?0.,?0.,?0.,?0.])
我們再來看輸入是2D的情況:

在2D數(shù)組中,如果是沿著行的方向,即axis=0時,取每列的最大值,如果保持維度的話,就變成了一個的數(shù)組。就是上圖藍色背景對應(yīng)的元素。在反向傳播時,只有這些元素才會將上游的梯度傳遞到下游,其他元素的位置不會有梯度往下游傳遞。
通過代碼來演示一下:
>?D,?N?=?5,?4
>?x?=?torch.randint(10,(N,D),?dtype=torch.float,?requires_grad=True)
>?print(x)
tensor([[1.,?1.,?5.,?9.,?1.],
????????[4.,?5.,?9.,?8.,?7.],
????????[1.,?1.,?7.,?7.,?9.],
????????[8.,?1.,?1.,?0.,?7.]],?requires_grad=True)
>?y?=?torch.max(x,?dim=0,?keepdim=True).values
>?print(y)
tensor([[8.,?5.,?9.,?9.,?9.]],?grad_fn=)
>?y.sum().backward()?#?或者y.backward([[1.,?1.,?1.,?1.,?1.]])
>?print(x.grad)
tensor([[0.,?0.,?0.,?1.,?0.],
????????[0.,?1.,?1.,?0.,?0.],
????????[0.,?0.,?0.,?0.,?1.],
????????[1.,?0.,?0.,?0.,?0.]])
有一點值得注意的是,假設(shè)我們?nèi)∽畲笾禃r,包含重復(fù)元素,會怎樣呢?
給個極端的例子:
#?極端的例子
>?x?=?torch.ones((2,5),?dtype=torch.float,?requires_grad=True)
>?x
tensor([[1.,?1.,?1.,?1.,?1.],
????????[1.,?1.,?1.,?1.,?1.]],?requires_grad=True)
>?y?=?torch.max(x)
>?y.backward()
>?x.grad
tensor([[0.1000,?0.1000,?0.1000,?0.1000,?0.1000],
????????[0.1000,?0.1000,?0.1000,?0.1000,?0.1000]])
在這個例子中,共有10個元素,形狀是。元素值都是相等的,如果直接調(diào)用torch.max,在反向傳播后,梯度被這些元素給均分了,其實也很好理解。畢竟又不是復(fù)制操作。
當(dāng)我們指定維度的時候,還會這樣嗎?
#?極端的例子
>?x?=?torch.ones((2,5),?dtype=torch.float,?requires_grad=True)
>?x
tensor([[1.,?1.,?1.,?1.,?1.],
????????[1.,?1.,?1.,?1.,?1.]],?requires_grad=True)
>?y?=?torch.max(x,?axis=0)?#?指定axis=0,取每列的最大值
>?y
torch.return_types.max(values=tensor([1.,?1.,?1.,?1.,?1.],?grad_fn=),?indices=tensor([0,?0,?0,?0,?0]))?#?每列的最大值都是1,但是僅記錄了遇到的第一個元素索引
>?y.values.sum().backward()
>?print(x.grad)
tensor([[1.,?1.,?1.,?1.,?1.],
????????[0.,?0.,?0.,?0.,?0.]])
此時,PyTorch中的表現(xiàn)是這樣的。取每列的最大值,每列都有一個梯度,但是遇到重復(fù)元素的時候,并沒有把上游傳過來的梯度進行平分。博主更傾向于會均分梯度,因此在我們實現(xiàn)的時候,會考慮這一點。
Slice
切片(Slice)也是一種常見的操作,比如我們從數(shù)組中取出某個元素、某一列、某一行等。我們已經(jīng)了解了Max操作反向傳播的原理。那么理解切片應(yīng)該也不難。只有選中的元素才有資格傳遞梯度到下游。

在上面這個的數(shù)組中,假設(shè)通過切片選擇了第三行,那么反向傳播時,只有第三行的元素上才有梯度往下游傳遞。通過代碼描述如下:
>?D,?N?=?5,?4
>?x?=?torch.randint(10,?(N,D),?dtype=torch.float,?requires_grad=True)
>?x
tensor([[3.,?8.,?8.,?4.,?0.],
????????[0.,?5.,?6.,?9.,?6.],
????????[6.,?8.,?8.,?1.,?8.],
????????[2.,?1.,?8.,?7.,?5.]],?requires_grad=True)
>?y?=?x[2,:]?#?取第2行
>?y
tensor([6.,?8.,?8.,?1.,?8.],?grad_fn=)
>?y.sum().backward()
>?x.grad
tensor([[0.,?0.,?0.,?0.,?0.],
????????[0.,?0.,?0.,?0.,?0.],
????????[1.,?1.,?1.,?1.,?1.],
????????[0.,?0.,?0.,?0.,?0.]])
Reshape
變形(Reshape)操作的反向傳播其實是最簡單的。假設(shè)經(jīng)過y = x.reshape(..),在反向傳播時,只要保證梯度的形狀和x保持一致即可。

我們通過代碼來驗證一下:
>?D,?N?=?6,?4
>?x?=?torch.randint(10,?(N,D),?dtype=torch.float,?requires_grad=True)
>?x?#?(4,6)的數(shù)組
tensor([[9.,?4.,?8.,?7.,?6.,?0.],
????????[7.,?4.,?2.,?9.,?4.,?4.],
????????[7.,?1.,?8.,?2.,?4.,?7.],
????????[8.,?8.,?9.,?2.,?6.,?6.]],?requires_grad=True)
>?y?=?x.reshape(3,?8)?#?(4,6)?->?(3,8)
>?y
tensor([[9.,?4.,?8.,?7.,?6.,?0.,?7.,?4.],
????????[2.,?9.,?4.,?4.,?7.,?1.,?8.,?2.],
????????[4.,?7.,?8.,?8.,?9.,?2.,?6.,?6.]],?grad_fn=)
>?y.sum().backward()
>?x.grad
tensor([[1.,?1.,?1.,?1.,?1.,?1.],
????????[1.,?1.,?1.,?1.,?1.,?1.],
????????[1.,?1.,?1.,?1.,?1.,?1.],
????????[1.,?1.,?1.,?1.,?1.,?1.]])
Transpose
轉(zhuǎn)置(Transpose,我司CV大佬稱為旋轉(zhuǎn))和Reshape類似,所有元素的梯度都會往下游傳遞。但是轉(zhuǎn)置和Reshape操作本身又是有很大不同的。我們先來看一下它們的區(qū)別。
>?import?matplotlib.pyplot?as?plt
>?import?matplotlib.image?as?mpimg
>?import?numpy?as?np
>?img_array?=?mpimg.imread('https://gitee.com/nlp-greyfoss/images/raw/master/data/20211217174850.png')
>?plt.imshow(img_array)?#?顯示圖片
>?plt.axis("off")
>?img_array.shape
(157,?210,?3)

該圖片的形狀為:
寬: 210 像素 高: 157 像素 RGB: 3
假設(shè)我們想對圖片進行旋轉(zhuǎn),先通過Reshape進行,保持最后一個維度不變,以能展示出圖片。
> reshaped = img_array.reshape((210,157,3))
> plt.imshow(reshaped)
> plt.axis("off")
> reshaped.shape
(210, 157, 3)

雖然圖片是可以顯示出來,但是圖片變成了很多條狀的東西。我們可以通過下圖理解Reshape做了什么事情:

Reshape改變矩陣形狀后,里面的元素還是根據(jù)原來的順序依次排列的。這會導(dǎo)致這些像素的相對位置會發(fā)生變化。
我們再進行Transpose操作。
>?transposed?=?img_array.transpose((1,0,2))?#?交換第0和第1個維度:?(0,1,2)?->?(1,0,2)?
>?plt.imshow(transposed)
>?plt.axis("off")

可以看到,Transpose并不會改變元素的相對位置。具體如下:

Transpose對于矩陣來說,就是轉(zhuǎn)置,也可以理解為對圖像進行旋轉(zhuǎn)。
轉(zhuǎn)置的計算圖就不畫了,經(jīng)過上面的探討應(yīng)該能很好地理解。我們來看一下轉(zhuǎn)置操作如何進行反向傳播。
>?x?=?torch.Tensor(np.arange(24).reshape(2,3,4))
>?x.requires_grad?=?True
>?print(x.shape)
>?print(x)
torch.Size([2,?3,?4])
tensor([[[?0.,??1.,??2.,??3.],
?????????[?4.,??5.,??6.,??7.],
?????????[?8.,??9.,?10.,?11.]],
????????[[12.,?13.,?14.,?15.],
?????????[16.,?17.,?18.,?19.],
?????????[20.,?21.,?22.,?23.]]],?requires_grad=True)
>?axis?=?(0,1,2)?#?和原來的軸保持一致,演示不轉(zhuǎn)置的結(jié)果
>?y?=?torch.permute(x,axis)?#?torch中的轉(zhuǎn)置
>?y.sum().backward()
>?x.grad
tensor([[[1.,?1.,?1.,?1.],
?????????[1.,?1.,?1.,?1.],
?????????[1.,?1.,?1.,?1.]],
????????[[1.,?1.,?1.,?1.],
?????????[1.,?1.,?1.,?1.],
?????????[1.,?1.,?1.,?1.]]])
>?x.grad.shape
torch.Size([2,?3,?4])
可以看到,果然和Reshape一樣,哪怕做了個假轉(zhuǎn)置,也會有梯度。而且梯度的維度和x一致。
所以,在反向傳播的時候,我們要將上游傳遞過來的梯度,進行逆Reshape操作,保證和x的維度一致。
我們創(chuàng)建一個維度的向量。
>?a?=?np.arange(24).reshape(2,3,4)
>?a
array([[[?0,??1,??2,??3],
????????[?4,??5,??6,??7],
????????[?8,??9,?10,?11]],
???????[[12,?13,?14,?15],
????????[16,?17,?18,?19],
????????[20,?21,?22,?23]]])
下面我們先對其進行轉(zhuǎn)置,然后探討一下如何把轉(zhuǎn)置后的結(jié)果,轉(zhuǎn)置回來。
>?b?=?a.transpose(2,0,1)
>?print(b.shape)?#(0,1,2)?->?(2,0,1)?:?得到(4,2,3)?即第0軸到了中間,第1軸到了最后,第2軸到了最前面。
>?print(b)
(4,?2,?3)
[[[?0??4??8]
??[12?16?20]]
?[[?1??5??9]
??[13?17?21]]
?[[?2??6?10]
??[14?18?22]]
?[[?3??7?11]
??[15?19?23]]]
由。所以要轉(zhuǎn)置回來,我們需要把進行一個怎么樣的轉(zhuǎn)置,才會變回來?中括號里面的數(shù)字表示現(xiàn)在對應(yīng)的軸。所以我們應(yīng)該把對應(yīng)的軸交換到最后(2軸),把對應(yīng)的軸交換到中間(1)軸,把軸交換到最前(0軸)。我們要對當(dāng)前的進行一個這樣的轉(zhuǎn)置操作:b.reshape(1,2,0)。下面來驗證看:
>?b.transpose(1,2,0)
array([[[?0,??1,??2,??3],
????????[?4,??5,??6,??7],
????????[?8,??9,?10,?11]],
???????[[12,?13,?14,?15],
????????[16,?17,?18,?19],
????????[20,?21,?22,?23]]])
看起來不錯,但是每次這么分析,很耗時間啊。這里這有3個維度,如果有5個維度怎么辦,有什么規(guī)律嗎?
嘿,還確實有規(guī)律,就是對a轉(zhuǎn)置時的元組(或者說是軸列表)進行argsort(對元素按從小到大進行排序,但返回的是排序后的索引)
我們來試一下:
>?b.transpose(np.argsort((2,0,1)))
array([[[?0,??1,??2,??3],
????????[?4,??5,??6,??7],
????????[?8,??9,?10,?11]],
???????[[12,?13,?14,?15],
????????[16,?17,?18,?19],
????????[20,?21,?22,?23]]])
總結(jié)
至此,我們經(jīng)常用到操作的計算圖都了解完畢了,下篇文章開始通過Python實現(xiàn)這些計算圖來創(chuàng)造一個我們自己的自動求導(dǎo)工具。
最后一句:BUG,走你!


Markdown筆記神器Typora配置Gitee圖床
不會真有人覺得聊天機器人難吧(一)
Spring Cloud學(xué)習(xí)筆記(一)
沒有人比我更懂Spring Boot(一)
入門人工智能必備的線性代數(shù)基礎(chǔ)
1.看到這里了就點個在看支持下吧,你的在看是我創(chuàng)作的動力。
2.關(guān)注公眾號,每天為您分享原創(chuàng)或精選文章!
3.特殊階段,帶好口罩,做好個人防護。
