在這篇文章里筆者將設計和實現一個、輕量級的(約 200 行)、易于擴展的深度學習框架 tinynn(基于 Python 和 Numpy 實現),希望對大家了解深度學習的基本組件、框架的設計和實現有一定的幫助。

本文首先會從深度學習的流程開始分析,對神經網絡中的關鍵組件抽象,確定基本框架;然后再對框架里各個組件進行代碼實現;最后基于這個框架實現了一個 MNIST 分類的示例,并與 Tensorflow 做了簡單的對比驗證。

組件抽象

首先考慮神經網絡運算的流程,神經網絡運算主要包含訓練 training 和預測 predict (或 inference) 兩個階段,訓練的基本流程是:輸入數據 -> 網絡層前向傳播 -> 計算損失 -> 網絡層反向傳播梯度 -> 更新參數,預測的基本流程是 輸入數據 -> 網絡層前向傳播 -> 輸出結果。從運算的角度看,主要可以分為三種類型的計算:

  1. 數據在網絡層之間的流動:前向傳播和反向傳播可以看做是張量 Tensor(多維數組)在網絡層之間的流動(前向傳播流動的是輸入輸出,反向傳播流動的是梯度),每個網絡層會進行一定的運算,然后將結果輸入給下一層
  2. 計算損失:銜接前向和反向傳播的中間過程,定義了模型的輸出與真實值之間的差異,用來后續提供反向傳播所需的信息
  3. 參數更新:使用計算得到的梯度對網絡參數進行更新的一類計算

基于這個三種類型,我們可以對網絡的基本組件做一個抽象

然后我們還需要一些組件把上面這個 4 種基本組件整合到一起,形成一個 pipeline

基本的框架圖如下圖

組件實現

按照上面的抽象,我們可以寫出整個流程代碼如下。

# define model
net = Net([layer1, layer2, ...])
model = Model(net, loss_fn, optimizer)

# training
pred = model.forward(train_X)
loss, grads = model.backward(pred, train_Y)
model.apply_grad(grads)

# inference
test_pred = model.forward(test_X)

首先定義 net,net 的輸入是多個網絡層,然后將 net、loss、optimizer 一起傳給 model。model 實現了 forward、backward 和 apply_grad 三個接口分別對應前向傳播、反向傳播和參數更新三個功能。接下來我們看這里邊各個部分分別如何實現。

tensor

tensor 張量是神經網絡中基本的數據單位,我們這里直接使用 numpy.ndarray 類作為 tensor 類的實現

numpy.ndarray :https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html

layer

上面流程代碼中 model 進行 forward 和 backward,其實底層都是網絡層在進行實際運算,因此網絡層需要有提供 forward 和 backward 接口進行對應的運算。同時還應該將該層的參數和梯度記錄下來。先實現一個基類如下

# layer.py
class Layer(object):
def __init__(self, name):
self.name = name
self.params, self.grads = None, None

def forward(self, inputs):
raise NotImplementedError

def backward(self, grad):
raise NotImplementedError

最基礎的一種網絡層是全連接網絡層,實現如下。forward 方法接收上層的輸入 inputs,實現 的運算;backward 的方法接收來自上層的梯度,計算關于參數 和輸入的梯度,然后返回關于輸入的梯度。這三個梯度的推導可以見附錄,這里直接給出實現。w_init 和 b_init 分別是參數 和 的初始化器,這個我們在另外的一個實現初始化器中文件 initializer.py 去實現,這部分不是核心部件,所以在這里不展開介紹。

# layer.py
class Dense(Layer):
def __init__(self, num_in, num_out,
w_init=XavierUniformInit(),
b_init=ZerosInit()):
super().__init__("Linear")

self.params = {
"w": w_init([num_in, num_out]),
"b": b_init([1, num_out])}

self.inputs = None

def forward(self, inputs):
self.inputs = inputs
return inputs @ self.params["w"] + self.params["b"]

def backward(self, grad):
self.grads["w"] = self.inputs.T @ grad
self.grads["b"] = np.sum(grad, axis=0)
return grad @ self.params["w"].T

同時神經網絡中的另一個重要的部分是激活函數。激活函數可以看做是一種網絡層,同樣需要實現 forward 和 backward 方法。我們通過繼承 Layer 類實現激活函數類,這里實現了最常用的 ReLU 激活函數。func 和 derivation_func 方法分別實現對應激活函數的正向計算和梯度計算。

# layer.py
class Activation(Layer):
"""Base activation layer"""
def __init__(self, name):
super().__init__(name)
self.inputs = None

def forward(self, inputs):
self.inputs = inputs
return self.func(inputs)

def backward(self, grad):
return self.derivative_func(self.inputs) * grad

def func(self, x):
raise NotImplementedError

def derivative_func(self, x):
raise NotImplementedError

class ReLU(Activation):
"""ReLU activation function"""
def __init__(self):
super().__init__("ReLU")

def func(self, x):
return np.maximum(x, 0.0)

def derivative_func(self, x):
return x > 0.0

net

上文提到 net 類負責管理 tensor 在 layers 之間的前向和反向傳播。forward 方法很簡單,按順序遍歷所有層,每層計算的輸出作為下一層的輸入;backward 則逆序遍歷所有層,將每層的梯度作為下一層的輸入。這里我們還將每個網絡層參數的梯度保存下來返回,后面參數更新需要用到。另外 net 類還實現了獲取參數、設置參數、獲取梯度的接口,也是后面參數更新時需要用到

# net.py
class Net(object):
def __init__(self, layers):
self.layers = layers

def forward(self, inputs):
for layer in self.layers:
inputs = layer.forward(inputs)
return inputs

def backward(self, grad):
all_grads = []
for layer in reversed(self.layers):
grad = layer.backward(grad)
all_grads.append(layer.grads)
return all_grads[::-1]

def get_params_and_grads(self):
for layer in self.layers:
yield layer.params, layer.grads

def get_parameters(self):
return [layer.params for layer in self.layers]

def set_parameters(self, params):
for i, layer in enumerate(self.layers):
for key in layer.params.keys():
layer.params[key] = params[i][key]

losses

上文我們提到 losses 組件需要做兩件事情,給定了預測值和真實值,需要計算損失值和關于預測值的梯度。我們分別實現為 loss 和 grad 兩個方法,這里我們實現多分類回歸常用的 SoftmaxCrossEntropyLoss 損失。這個的損失 loss 和梯度 grad 的計算公式推導進文末附錄,這里直接給出結果:多分類 softmax 交叉熵的損失為

梯度稍微復雜一點,目標類別和非目標類別的計算公式不同。對于目標類別維度,其梯度為對應維度模型輸出概率減一,對于非目標類別維度,其梯度為對應維度輸出概率本身。

代碼實現如下

# loss.py
class BaseLoss(object):
def loss(self, predicted, actual):
raise NotImplementedError

def grad(self, predicted, actual):
raise NotImplementedError

class CrossEntropyLoss(BaseLoss):
def loss(self, predicted, actual):
m = predicted.shape[0]
exps = np.exp(predicted - np.max(predicted, axis=1, keepdims=True))
p = exps / np.sum(exps, axis=1, keepdims=True)
nll = -np.log(np.sum(p * actual, axis=1))
return np.sum(nll) / m

def grad(self, predicted, actual):
m = predicted.shape[0]
grad = np.copy(predicted)
grad -= actual
return grad / m

Softmax 交叉熵損失和梯度推導

多分類下交叉熵損失如下式:

其中 分別是真實值和模型預測值, 是樣本數, 是類別個數。由于真實值一般為一個 one-hot 向量(除了真實類別維度為 1 其他均為 0),因此上式可以化簡為

其中 是代表真實類別, 代表第 個樣本 類的預測概率。即我們需要計算的是每個樣本在真實類別上的預測概率的對數的和,然后再取負就是交叉熵損失。接下來推導如何求解該損失關于模型輸出的梯度,用 表示模型輸出,在多分類中通常最后會使用 Softmax 將網絡的輸出歸一化為一個概率分布,則 Softmax 后的輸出為

代入上面的損失函數

求解 關于輸出向量 的梯度,可以將 分為目標類別所在維度 和非目標類別維度 。首先看目標類別所在維度
再看非目標類別所在維度

可以看到對于目標類別維度,其梯度為對應維度模型輸出概率減一,對于非目標類別維度,其梯度為對應維度輸出概率真身。

optimizer

optimizer 主要實現一個接口 compute_step,這個方法根據當前的梯度,計算返回實際優化時每個參數改變的步長。我們在這里實現常用的 Adam 優化器。

# optimizer.py
class BaseOptimizer(object):
def __init__(self, lr, weight_decay):
self.lr = lr
self.weight_decay = weight_decay

def compute_step(self, grads, params):
step = list()
# flatten all gradients
flatten_grads = np.concatenate(
[np.ravel(v) for grad in grads for v in grad.values()])
# compute step
flatten_step = self._compute_step(flatten_grads)
# reshape gradients
p = 0
for param in params:
layer = dict()
for k, v in param.items():
block = np.prod(v.shape)
_step = flatten_step[p:p+block].reshape(v.shape)
_step -= self.weight_decay * v
layer[k] = _step
p += block
step.append(layer)
return step

def _compute_step(self, grad):
raise NotImplementedError

class Adam(BaseOptimizer):
def __init__(self, lr=0.001, beta1=0.9, beta2=0.999,
eps=1e-8, weight_decay=0.0):
super().__init__(lr, weight_decay)
self._b1, self._b2 = beta1, beta2
self._eps = eps

self._t = 0
self._m, self._v = 0, 0

def _compute_step(self, grad):
self._t += 1
self._m = self._b1 * self._m + (1 - self._b1) * grad
self._v = self._b2 * self._v + (1 - self._b2) * (grad ** 2)
# bias correction
_m = self._m / (1 - self._b1 ** self._t)
_v = self._v / (1 - self._b2 ** self._t)
return -self.lr * _m / (_v ** 0.5 + self._eps)

model

最后 model 類實現了我們一開始設計的三個接口 forward、backward 和 apply_grad ,forward 直接調用 net 的 forward ,backward 中把 net 、loss、optimizer 串起來,先計算損失 loss,然后反向傳播得到梯度,然后 optimizer 計算步長,最后由 apply_grad 對參數進行更新

# model.py
class Model(object):
def __init__(self, net, loss, optimizer):
self.net = net
self.loss = loss
self.optimizer = optimizer

def forward(self, inputs):
return self.net.forward(inputs)

def backward(self, preds, targets):
loss = self.loss.loss(preds, targets)
grad = self.loss.grad(preds, targets)
grads = self.net.backward(grad)
params = self.net.get_parameters()
step = self.optimizer.compute_step(grads, params)
return loss, step

def apply_grad(self, grads):
for grad, (param, _) in zip(grads, self.net.get_params_and_grads()):
for k, v in param.items():
param[k] += grad[k]

整體結構

最后我們實現出來核心代碼部分文件結構如下

tinynn
├── core
│ ├── initializer.py
│ ├── layer.py
│ ├── loss.py
│ ├── model.py
│ ├── net.py
│ └── optimizer.py

其中 initializer.py 這個模塊上面沒有展開講,主要實現了常見的參數初始化方法(零初始化、Xavier 初始化、He 初始化等),用于給網絡層初始化參數。

MNIST 例子

框架基本搭起來后,我們找一個例子來用 tinynn 這個框架 run 起來。這個例子的基本一些配置如下

這里我們忽略數據載入、預處理等一些準備代碼,只把核心的網絡結構定義和訓練的代碼貼出來如下

# example/mnist/run.py
net = Net([
Dense(784, 400),
ReLU(),
Dense(400, 100),
ReLU(),
Dense(100, 10)
])
model = Model(net=net, loss=SoftmaxCrossEntropyLoss(), optimizer=Adam(lr=args.lr))

iterator = BatchIterator(batch_size=args.batch_size)
evaluator = AccEvaluator()
for epoch in range(num_ep):
for batch in iterator(train_x, train_y):
# training
pred = model.forward(batch.inputs)
loss, grads = model.backward(pred, batch.targets)
model.apply_grad(grads)
# evaluate every epoch
test_pred = model.forward(test_x)
test_pred_idx = np.argmax(test_pred, axis=1)
test_y_idx = np.asarray(test_y)
res = evaluator.evaluate(test_pred_idx, test_y_idx)
print(res)

運行結果如下

# tinynn
Epoch 0 {'total_num': 10000, 'hit_num': 9658, 'accuracy': 0.9658}
Epoch 1 {'total_num': 10000, 'hit_num': 9740, 'accuracy': 0.974}
Epoch 2 {'total_num': 10000, 'hit_num': 9783, 'accuracy': 0.9783}
Epoch 3 {'total_num': 10000, 'hit_num': 9799, 'accuracy': 0.9799}
Epoch 4 {'total_num': 10000, 'hit_num': 9805, 'accuracy': 0.9805}
Epoch 5 {'total_num': 10000, 'hit_num': 9826, 'accuracy': 0.9826}
Epoch 6 {'total_num': 10000, 'hit_num': 9823, 'accuracy': 0.9823}
Epoch 7 {'total_num': 10000, 'hit_num': 9819, 'accuracy': 0.9819}
Epoch 8 {'total_num': 10000, 'hit_num': 9820, 'accuracy': 0.982}
Epoch 9 {'total_num': 10000, 'hit_num': 9838, 'accuracy': 0.9838}
Epoch 10 {'total_num': 10000, 'hit_num': 9825, 'accuracy': 0.9825}
Epoch 11 {'total_num': 10000, 'hit_num': 9810, 'accuracy': 0.981}
Epoch 12 {'total_num': 10000, 'hit_num': 9845, 'accuracy': 0.9845}
Epoch 13 {'total_num': 10000, 'hit_num': 9845, 'accuracy': 0.9845}
Epoch 14 {'total_num': 10000, 'hit_num': 9835, 'accuracy': 0.9835}
Epoch 15 {'total_num': 10000, 'hit_num': 9817, 'accuracy': 0.9817}
Epoch 16 {'total_num': 10000, 'hit_num': 9815, 'accuracy': 0.9815}
Epoch 17 {'total_num': 10000, 'hit_num': 9835, 'accuracy': 0.9835}
Epoch 18 {'total_num': 10000, 'hit_num': 9826, 'accuracy': 0.9826}
Epoch 19 {'total_num': 10000, 'hit_num': 9819, 'accuracy': 0.9819}

可以看到測試集 accuracy 隨著訓練進行在慢慢提升,這說明數據在框架中確實按照正確的方式進行流動和計算,參數得到正確的更新。為了對比下效果,我用 Tensorflow 1.13 實現了相同的網絡結構、采用相同的采數初始化方法、優化器配置等等,得到的結果如下

# Tensorflow 1.13.1
Epoch 0 {'total_num': 10000, 'hit_num': 9591, 'accuracy': 0.9591}
Epoch 1 {'total_num': 10000, 'hit_num': 9734, 'accuracy': 0.9734}
Epoch 2 {'total_num': 10000, 'hit_num': 9706, 'accuracy': 0.9706}
Epoch 3 {'total_num': 10000, 'hit_num': 9756, 'accuracy': 0.9756}
Epoch 4 {'total_num': 10000, 'hit_num': 9722, 'accuracy': 0.9722}
Epoch 5 {'total_num': 10000, 'hit_num': 9772, 'accuracy': 0.9772}
Epoch 6 {'total_num': 10000, 'hit_num': 9774, 'accuracy': 0.9774}
Epoch 7 {'total_num': 10000, 'hit_num': 9789, 'accuracy': 0.9789}
Epoch 8 {'total_num': 10000, 'hit_num': 9766, 'accuracy': 0.9766}
Epoch 9 {'total_num': 10000, 'hit_num': 9763, 'accuracy': 0.9763}
Epoch 10 {'total_num': 10000, 'hit_num': 9791, 'accuracy': 0.9791}
Epoch 11 {'total_num': 10000, 'hit_num': 9773, 'accuracy': 0.9773}
Epoch 12 {'total_num': 10000, 'hit_num': 9804, 'accuracy': 0.9804}
Epoch 13 {'total_num': 10000, 'hit_num': 9782, 'accuracy': 0.9782}
Epoch 14 {'total_num': 10000, 'hit_num': 9800, 'accuracy': 0.98}
Epoch 15 {'total_num': 10000, 'hit_num': 9837, 'accuracy': 0.9837}
Epoch 16 {'total_num': 10000, 'hit_num': 9811, 'accuracy': 0.9811}
Epoch 17 {'total_num': 10000, 'hit_num': 9793, 'accuracy': 0.9793}
Epoch 18 {'total_num': 10000, 'hit_num': 9818, 'accuracy': 0.9818}
Epoch 19 {'total_num': 10000, 'hit_num': 9811, 'accuracy': 0.9811}

可以看到兩者效果上大差不差,測試集準確率都收斂到 0.982 左右,就單次的實驗看比 Tensorflow 稍微好一點點。

總結

tinynn 相關的源代碼在這個 repo(https://github.com/borgwang/tinynn) 里。目前支持:

tinynn 還有很多可以繼續完善的地方受限于時間還沒有完成,筆者在空閑時間會進行維護和更新。

當然 tinynn 只是一個「玩具」版本的深度學習框架,一個成熟的深度學習框架至少還需要:支持自動求導、高運算效率(靜態語言加速、支持 GPU 加速)、提供豐富的算法實現、提供易用的接口和詳細的文檔等等。這個小項目的出發點更多地是學習,在設計和實現 tinynn 的過程中筆者個人學習確實到了很多東西,包括如何抽象、如何設計組件接口、如何更效率的實現、算法的具體細節等等。對筆者而言寫這個小框架除了了解深度學習框架的設計與實現之外還有一個好處:后續可以在這個框架上快速地實現一些新的算法,新的參數初始化方法,新的優化算法,新的網絡結構設計,都可以快速地在這個小框架上進行實驗。如果你對自己設計實現一個深度學習框架也感興趣,希望看完這篇文章會對你有所幫助,也歡迎大家提 PR 一起貢獻代碼~

參考

文章轉自微信公眾號@算法進階

上一篇:

大規模神經網絡調節參及優化規律

下一篇:

樹+神經網絡算法強強聯手(Python)
#你可能也喜歡這些API文章!

我們有何不同?

API服務商零注冊

多API并行試用

數據驅動選型,提升決策效率

查看全部API→
??

熱門場景實測,選對API

#AI文本生成大模型API

對比大模型API的內容創意新穎性、情感共鳴力、商業轉化潛力

25個渠道
一鍵對比試用API 限時免費

#AI深度推理大模型API

對比大模型API的邏輯推理準確性、分析深度、可視化建議合理性

10個渠道
一鍵對比試用API 限時免費