哈嘍,大家好。
今天跟大家分享一個非常屌的開源項目,用Numpy發展了一個深度學習框架,文法基本上與 Pytorch 一致。
今天以一個簡單的捲積神經網路為例,分析神經網路訓練過程中,涉及的前向傳播、反向傳播、參數優化等核心步驟的源碼。
使用的資料集和程式碼已經打包好,文末有取得方式。
先準備好資料和程式碼。
首先,下載框架原始碼,網址:https://github.com/duma-repo/PyDyNet
git clone https://github.com/duma-repo/PyDyNet.git
建置LeNet卷積神經網絡,訓練三分類模型。
在PyDyNet目錄直接建立程式碼檔案即可。
from pydynet import nn class LeNet(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=2) self.conv2 = nn.Conv2d(6, 16, kernel_size=5) self.avg_pool = nn.AvgPool2d(kernel_size=2, stride=2, padding=0) self.sigmoid = nn.Sigmoid() self.fc1 = nn.Linear(16 * 5 * 5, 120) self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, 3) def forward(self, x): x = self.conv1(x) x = self.sigmoid(x) x = self.avg_pool(x) x = self.conv2(x) x = self.sigmoid(x) x = self.avg_pool(x) x = x.reshape(x.shape[0], -1) x = self.fc1(x) x = self.sigmoid(x) x = self.fc2(x) x = self.sigmoid(x) x = self.fc3(x) return x
可以看到,網路的定義與Pytorch語法完全一樣。
我提供的原始碼裡,提供了 summary 函數可以列印網路結構。
訓練資料使用Fanshion-MNIST資料集,它包含10個類別的圖片,每個類別 6k 張。
為了加快訓練,我只抽了前3個類別,共1.8w張訓練圖片,做一個三分類模型。
import pydynet from pydynet import nn from pydynet import optim lr, num_epochs = 0.9, 10 optimizer = optim.SGD(net.parameters(), lr=lr) loss = nn.CrossEntropyLoss() for epoch in range(num_epochs): net.train() for i, (X, y) in enumerate(train_iter): optimizer.zero_grad() y_hat = net(X) l = loss(y_hat, y) l.backward() optimizer.step() with pydynet.no_grad(): metric.add(l.numpy() * X.shape[0], accuracy(y_hat, y), X.shape[0])
訓練程式碼也跟Pytorch一樣。
下面重點要做的就是深入模型訓練的源碼,來學習模型訓練的原理。
模型開始訓練前,會呼叫net.train。
def train(self, mode: bool = True): set_grad_enabled(mode) self.set_module_state(mode)
可以看到,它會將grad(梯度)設為True,之後創建的Tensor是可以帶梯度的。 Tensor帶上梯度後,便會將其放入計算圖中,等待求導計算梯度。
而下面的with no_grad(): 程式碼
class no_grad: def __enter__(self) -> None: self.prev = is_grad_enable() set_grad_enabled(False)
會將grad(梯度)設為False,這樣之後建立的Tensor不會放到計算圖中,自然也不需要計算梯度,可以加快推理。
我們常在Pytorch中看到net.eval()的用法,我們也順便看一下它的原始碼。
def eval(self): return self.train(False)
可以看到,它直接呼叫train(False)來關閉梯度,效果與no_grad()類似。
所以,一般在訓練前呼叫train開啟梯度。訓練後,呼叫eval關閉梯度,方便快速推理。
前向傳播除了計算類別機率外,最最重要的一件事是按照前傳順序,將網路中的 tensor 組織成計算圖,目的是為了在反向傳播時計算每個tensor的梯度。
tensor在神經網路中,不只用來儲存數據,還用運算梯度、儲存梯度。
以第一層卷積運算為例,來查看如何產生計算圖。
def conv2d(x: tensor.Tensor, kernel: tensor.Tensor, padding: int = 0, stride: int = 1): '''二维卷积函数 ''' N, _, _, _ = x.shape out_channels, _, kernel_size, _ = kernel.shape pad_x = __pad2d(x, padding) col = __im2col2d(pad_x, kernel_size, stride) out_h, out_w = col.shape[-2:] col = col.transpose(0, 4, 5, 1, 2, 3).reshape(N * out_h * out_w, -1) col_filter = kernel.reshape(out_channels, -1).T out = col @ col_filter return out.reshape(N, out_h, out_w, -1).transpose(0, 3, 1, 2)
x是輸入的圖片,不需要記錄梯度。 kernel是卷積核的權重,需要計算梯度。
所以,pad_x = __pad2d(x, padding) 產生的新的tensor也是不帶梯度的,因此也不需要加入計算圖中。
而kernel.reshape(out_channels, -1)產生的tensor則是需要計算梯度,也需要加入計算圖中。
下面看看加入的過程:
def reshape(self, *new_shape): return reshape(self, new_shape) class reshape(UnaryOperator): ''' 张量形状变换算子,在Tensor中进行重载 Parameters ---------- new_shape : tuple 变换后的形状,用法同NumPy ''' def __init__(self, x: Tensor, new_shape: tuple) -> None: self.new_shape = new_shape super().__init__(x) def forward(self, x: Tensor) return x.data.reshape(self.new_shape) def grad_fn(self, x: Tensor, grad: np.ndarray) return grad.reshape(x.shape)
reshape函數會傳回一個reshape類對象,reshape類繼承了UnaryOperator類,並在__init__函數中,呼叫了父類別初始化函數。
class UnaryOperator(Tensor): def __init__(self, x: Tensor) -> None: if not isinstance(x, Tensor): x = Tensor(x) self.device = x.device super().__init__( data=self.forward(x), device=x.device, # 这里 requires_grad 为 True requires_grad=is_grad_enable() and x.requires_grad, )
UnaryOperator類別繼承了Tensor類,所以reshape物件也是一個tensor。
在UnaryOperator的__init__函數中,呼叫Tensor的初始化函數,並且傳入的requires_grad參數是True,代表需要計算梯度。
requires_grad的計算程式碼為is_grad_enable() and x.requires_grad,is_grad_enable()已經被train設定為True,而x是卷積核,它的requires_grad也是True。
class Tensor: def __init__( self, data: Any, dtype=None, device: Union[Device, int, str, None] = None, requires_grad: bool = False, ) -> None: if self.requires_grad: # 不需要求梯度的节点不出现在动态计算图中 Graph.add_node(self)
最終在Tensor類別的初始化方法中,呼叫Graph.add_node(self)將目前tensor加入到計算圖中。
同理,下面使用requires_grad=True的tensor常見出來的新tensor都會放到計算圖中。
經過一次卷積運算,計算圖中會增加 6 個節點。
一次前向傳播完成後,從計算圖中最後一個節點開始,從後往前反向傳播。
l = loss(y_hat, y) l.backward()
經過前向網路一層層傳播,最後傳到了損失張量l。
以l為起點,從前向後傳播,就可計算計算圖中每個節點的梯度。
backward的核心程式碼如下:
def backward(self, retain_graph: bool = False): for node in Graph.node_list[y_id::-1]: grad = node.grad for last in [l for l in node.last if l.requires_grad]: add_grad = node.grad_fn(last, grad) last.grad += add_grad
Graph.node_list[y_id::-1]將計算圖倒序排。
node是前向传播时放入计算图中的每个tensor。
node.last 是生成当前tensor的直接父节点。
调用node.grad_fn计算梯度,并反向传给它的父节点。
grad_fn其实就是Tensor的求导公式,如:
class pow(BinaryOperator): ''' 幂运算算子,在Tensor类中进行重载 See also -------- add : 加法算子 ''' def grad_fn(self, node: Tensor, grad: np.ndarray) if node is self.last[0]: return (self.data * self.last[1].data / node.data) * grad
return后的代码其实就是幂函数求导公式。
假设y=x^2,x的导数为2x。
反向传播计算梯度后,便可以调用优化器,更新模型参数。
l.backward() optimizer.step()
本次训练我们用梯度下降SGD算法优化参数,更新过程如下:
def step(self): for i in range(len(self.params)): grad = self.params[i].grad + self.weight_decay * self.params[i].data self.v[i] *= self.momentum self.v[i] += self.lr * grad self.params[i].data -= self.v[i] if self.nesterov: self.params[i].data -= self.lr * grad
self.params是整个网络的权重,初始化SGD时传进去的。
step函数最核心的两行代码,self.v[i] += self.lr * grad 和 self.params[i].data -= self.v[i],用当前参数 - 学习速率 * 梯度更新当前参数。
这是机器学习的基础内容了,我们应该很熟悉了。
一次模型训练的完整过程大致就串完了,大家可以设置打印语句,或者通过DEBUG的方式跟踪每一行代码的执行过程,这样可以更了解模型的训练过程。
以上是逆天了!以Numpy發展深度學習框架,透視神經網路訓練過程的詳細內容。更多資訊請關注PHP中文網其他相關文章!