Hướng dẫn hiểu và viết một Custom MLP model đơn giản bằng Numpy - Forward Propagation to Backward Propagation
Bài đăng này đã không được cập nhật trong 4 năm
Bài này mình hướng dẫn cách hiểu và viết một custom MLP model cho việc train data (mnist). Có đi sâu vào backprop một chút.
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline
import tensorflow as tf
tf.__version__
'2.1.0'
Mục tiêu
- Hiểu cách vận hành của một mạng fully connected nhiều lớp.
- Code bằng Numpy với custom layer (build init weights, bias, loss function,...).
- Thực hành trên MNIST dataset của thư viện TensorFlow.
Phần 1 - Sơ bộ về Foward trong mạng Neural
Không trình bày chi tiết về neural net
Về cơ bản, chẳng hạn ta có một sample , hidden layer
gồm có node như hình trên, và thêm vào đó là bias
. Ouput layer
là một layer có node (ứng với 2 giá trị ta muốn phân loại). Mỗi có một kết nối nào đó với một node .
Bước 1: Tính toán các tổ hợp tuyến tính giữa và , ở đây là phép nhân vô hướng giữa hai véc tơ (tích trong của và ) cộng với bias
, kết quả ký hiệu là .
# tạo véc tơ x là một tensor shape (1,2), giá trị lấy trong khoảng (-10, 10)
# tạo bias = 1
# seed ddể cố định việc tạo ra x, W_1 và W_2, phục vụ cho việc test ở sau.
np.random.seed(1)
x = np.random.randint(-10,10,(1,2))
b = 1
print(x)
print(b)
[[-5 1]]
1
Véc tơ có hai phần tử, thực hiện kết nối với node , như vậy cần tạo ma trận kích thước . Một cách tương tự, có kích thước .
np.random.seed(2)
W_1 = np.random.random((2,4))
print(W_1)
[[0.4359949 0.02592623 0.54966248 0.43532239]
[0.4203678 0.33033482 0.20464863 0.61927097]]
np.random.seed(3)
W_2 = np.random.random((4,2))
print(W_2)
[[0.5507979 0.70814782]
[0.29090474 0.51082761]
[0.89294695 0.89629309]
[0.12558531 0.20724288]]
là kết quả được tính bởi và node thứ .
z_1 = np.dot(x, W_1[:,0]) + b
z_1
array([-0.75960671])
z_2 = np.dot(x, W_1[:,1]) + b
z_2
array([1.20070366])
Như vậy là một tensor có shape , ứng với các ouput tại các node .
z = np.dot(x, W_1) + b
z
array([[-0.75960671, 1.20070366, -1.54366376, -0.557341 ]])
Bước 2: Thực hiện kích hoạt giá trị từ tuyến tính lên phi tuyến bằng cách áp lên nó một hàm kích hoạt (activation function
), giá trị sau khi kích hoạt ký hiệu là .
Có rất nhiều hàm kích hoạt trong deep learning
, trong bài này, Mình dùng hàm sigmoid
.
def sigmoid(x):
return np.exp(-x)/(1+np.exp(-x))
a_1 = sigmoid(z_1)
a_1
array([0.68126834])
a = sigmoid(z)
a
array([[0.68126834, 0.23135006, 0.8239967 , 0.63583708]])
Bước 3: Tính toán tương tự với output layer (lặp lại 2 bước 1 và 2). Bias b được dùng lại.
Do output layer có node, ta sẽ dùng activation softmax
như sau
def softmax(x):
return np.exp(x)/np.sum(np.exp(x), axis=1)
Việc dùng softmax có thể sinh ra các giá trị NaN
(Not a Number), khắc phục bằng việc dùng Softmax stable
.
def softmax_stable(Z):
"""
Compute softmax values for each sets of scores in Z.
each column of Z is a set of score.
"""
e_Z = np.exp(Z - np.max(Z,keepdims = True))
A = e_Z / e_Z.sum(axis = 1)
return A
zz = np.dot(a, W_2) + b
print('Giá trị của 2 node output trước khi kích hoạt:', zz)
aa = softmax_stable(zz)
print('Giá trị sau khi kích hoatj:', aa)
Giá trị của 2 node output trước khi kích hoạt: [[2.25817914 2.47093394]]
Giá trị sau khi kích hoạt: [[0.44701103 0.55298897]]
Dễ dàng kiểm tra được tổng của các phần tử của là , có thể hiểu đơn giản là node của output layer đóng vai trò như là 2 class, và giá trị sau khi kích hoạt chính là các xác suất mà thuộc vào 2 class đó. Lẽ dĩ nhiên, lớp nào có xác suất lớn hơn thì khả năng thuộc vào lớp đó cao hơn.
np.sum(aa)
1.0
Thực hiện code custom layer
class FC_layer(object):
"""A simple fully-connected NN layer.
Args:
num_inputs (int): Kích thước của véc tơ input (số phần tử của vecto x)
layer_size (int): Kích thước của layer (số node)
activation_fn (callable): Hàm kích hoạt
Attributes:
W (ndarray): Giá trị weights khởi tạo
b (ndarray): Giá trị bias khởi tạo
activation_fn (callable): Như trên
"""
def __init__(self, num_inputs, layer_size, activation_fn):
super().__init__()
self.W = np.random.random((num_inputs, layer_size))
self.b = 1
self.activation_fn = activation_fn
def forward(self,x):
z = np.dot(x, self.W) + self.b
a = self.activation_fn(z)
return a
np.random.seed(2)
layer1 = FC_layer(2,4,sigmoid)
np.random.seed(1)
x = np.random.randint(-10,10,(1,2))
x
array([[-5, 1]])
layer1.forward(x)
array([[0.68126834, 0.23135006, 0.8239967 , 0.63583708]])
print(a == layer1.forward(x))
[[ True True True True]]
Kết quả này giống với kết quả của , lúc này ta lập trình cho fully connected layer với nhiều lớp.
class Network(object):
"""A simple fully-connected NN layer.
Args:
num_inputs (int): Kích thước của véc tơ input (số phần tử của vecto x)
num_outputs (int): Kích thước của véc tơ output (số class cần phân loại)
hidden_layers_sizes (list): Danh sách các kích thước của các hidden layer
Attributes:
sizes: Danh sách kích thước của các layer
layers: Danh sách các layer
"""
def __init__(self, num_inputs, num_outputs, hidden_layers_sizes):
self.num_inputs = num_inputs
self.num_outputs = num_outputs
self.hidden_layers_sizes = hidden_layers_sizes
# list các node ứng với các layer
sizes = [num_inputs, *hidden_layers_sizes, num_outputs]
# tạo ra list các layer tiện cho việc tính toán, không có layer cuối (softmax layer)
self.layers = [FC_layer(sizes[i], sizes[i+1], sigmoid) for i in range(len(sizes)-2)]
self.sizes = sizes
def forward(self, x):
for layer in self.layers:
x = layer.forward(x)
# thực hiện softmax ở layer cuối
out = FC_layer(self.sizes[-2], self.sizes[-1], softmax_stable).forward(x)
return out
np.random.seed(2)
net = Network(2,2,[4])
for layer in net.layers:
print(layer.W == W_1)
[[ True True True True]
[ True True True True]]
np.random.seed(3)
result = net.forward(x)
result
array([[0.44701103, 0.55298897]])
Kết quả này giống với như ở phần trên.
print(result==aa)
[[ True True]]
Phần 2: Sơ bộ về Backward trong mạng Neural
Có hai hàm loss được nhắc đến trong phần này
MSE (Mean Square Error):
trong đó là giá trị đúng (ground truth hay label), là giá trị có được sau một loạt các tính toán (logits)
BCE (Binary Cross Entropy):
BCE lợi thế hơn hẳn MSE vì nó là độ đo sai lệch giữa 2 phân bố, trong khi MSE chỉ đo về khoảng cách là chủ yếu.
Chain rule
Đơn giản là đạo hàm của hàm hợp, thực hiện tính toán cập nhật trọng số trong giải thuật gradient descent.
Một lần nữa, bài viết không tập trung giải thích lý thuyết về gradient descent, nó khá dễ hiểu và cũng có nhiều tài liệu đã viết.
Trong giải thuật này, ta cần cập nhật 2 loại trọng số , đó là và .
Xét tại layer thứ , ta có input, output, weights, bias của layer đó lần lượt là . Lẽ dĩ nhiên, output của layer thứ là input của layer , do đó theo chain rule
Trong đó
- là đạo hàm được tính cho layer ứng với input .
- là đạo hàm của activation function.
- là chuyển vị của
Ký hiệu là tích hadamard
Một cách tương tự, tính toán đạo hàm của hàm loss ứng với biến số bias
Thêm vào đó
Thực hiện code code custom model
Khi viết Backward, cần chú ý về các hàm sau đây
- : Phép nhân trên python, nhân kiểu hadamard, có tính đối xứng.
np.multiply: Phép nhân trên numpy, nhân kiểu hadamard, có tính đối xứng.
np.matmul: Phép nhân ma trận.
np.dot: Phép nhân véc tơ và ma trận.
Các hàm cần thiết
def sigmoid(x): # sigmoid function
return 1 / (1 + np.exp(-x))
def derivated_sigmoid(y): # sigmoid derivative function: y' = y(1-y)
return y * (1 - y)
def softmax(x):
return np.exp(x)/np.sum(np.exp(x), axis=1)
def softmax_stable(z):
e = np.exp(z-np.max(z))
s = np.sum(e, axis=1, keepdims=True)
return e/s
def mse_loss(label, pred):
return np.sum((label-pred)**2)/pred.shape[0]
def derivative_mse(label, pred):
return 2*(label-pred) # biến là pred, không chia cho N bởi vì không ảnh hưởng nhiều đến kết quả.
def binary_cross_entropy(label, pred): # cross-entropy loss function
return -np.mean(np.multiply(np.log(pred), label) + np.multiply(np.log(1 - pred), (1 - label)))
def derivated_binary_cross_entropy(label, pred): # cross-entropy derivative function
return (pred - label) / (pred * (1 - pred))
Bây giờ ta tiến hành code một layer fully connected đầy đủ forward và backward để có thể optimize W và b, bằng SGD qua từng batch (khi thực hành trên MNIST dataset) trên layer đó. Ở đây ta dùng sigmoid là activation cho mọi layer). Phần optimize đơn giản là áp dụng giải thuật Gradient Descent.
class FC_layer_complete(object):
def __init__(self, num_inputs, layer_size, activation_fn=sigmoid, d_activation_fn=derivated_sigmoid):
super().__init__()
self.W = np.random.standard_normal((num_inputs, layer_size))
self.b = np.random.standard_normal(layer_size)
self.activation_fn = activation_fn
self.d_activation_fn = d_activation_fn
self.x, self.a, self.dL_dW, self.dL_db = None, None, None, None
def forward(self,x):
z = np.dot(x, self.W) + self.b
self.a = self.activation_fn(z)
self.x = x # giữ giá trị x lại để dùng cho backward
return self.a
def backward(self, dL_da):
# print(dL_da.shape)
da_dz = self.d_activation_fn(self.a) # f'
# print(da_dz.shape)
dL_dz = dL_da * da_dz # l'* f'
dz_dw = np.transpose(self.x) # x^T
dz_dx = np.transpose(self.W) # W^T
dz_db = np.ones(dL_da.shape[0])
self.dL_dW = np.dot(dz_dw, dL_dz)
self.dL_db = np.dot(dz_db, dL_dz)
dL_dx = np.dot(dL_dz, dz_dx)
return dL_dx
def optimize(self, ep):
self.W -= ep*self.dL_dW
self.b -= ep*self.dL_db
Ta tạo một class Network_complete
, thêm phương thức evaluate_accuracy
để đánh giá accuracy trên tập test sau mỗi epoch.
Phương thức train
được tiến hành như sau:
- Lặp qua từng epoch, ở mỗi epoch lại lặp qua từng batch, mỗi batch có 32 samples.
- Ta shuffle và tạo batch cho data train (32 samples), sau đó tiến hành train và cập nhật trọng số trên
từng layer ứng với batch đó, đây gọi là mini batch GD.
class Network_complete(object):
def __init__(self,num_inputs,num_outputs,hidden_layers_sizes,loss_fn=binary_cross_entropy,
d_loss_fn=derivated_binary_cross_entropy):
self.num_inputs = num_inputs
self.num_outputs = num_outputs
self.hidden_layers_sizes = hidden_layers_sizes
self.loss_fn = loss_fn
self.d_loss_fn = d_loss_fn
# list các node ứng với các layer
sizes = [num_inputs, *hidden_layers_sizes, num_outputs]
self.layers = [FC_layer_complete(sizes[i], sizes[i+1]) for i in range(len(sizes)-1)]
# self.sizes = sizes
def forward_net(self, x):
for layer in self.layers:
x = layer.forward(x)
return x
def backward_net(self,dL_da):
for layer in reversed(self.layers):
dL_da = layer.backward(dL_da)
return dL_da
def optimize_net(self, ep):
for layer in self.layers:
layer.optimize(ep)
def evaluate_accuracy(self, x_val, y_val):
num_corrects = 0
for i in range(len(x_val)):
pred = np.argmax(self.forward_net(x_val[i]))
# print(pred)
# print(y_val[i])
if pred == y_val[i]:
num_corrects+=1
return num_corrects/len(x_val)
def train(self, x_train, y_train, x_val, y_val, batch_size = 32, n_epochs=10,learning_rate=5e-3):
n_batches_per_epoch = int(len(x_train)/batch_size)
losses, val_accuracies = [], []
for i in range(n_epochs):
epoch_loss = 0
# shuffle data
indexes = np.random.permutation(len(x_train))
for b in range(n_batches_per_epoch):
# tạo batch:
b_idx_begin = b*batch_size
b_idx_end = b_idx_begin + batch_size
indexes_container = indexes[b_idx_begin:b_idx_end]
x, y = x_train[indexes_container], y_train[indexes_container]
# optimize
a = self.forward_net(x)
dL_da = self.d_loss_fn(label=y, pred=a)
self.backward_net(dL_da)
self.optimize_net(ep=learning_rate)
epoch_loss += self.loss_fn(label=y,pred=a)
epoch_loss/=n_batches_per_epoch
losses.append(epoch_loss)
val_acc = self.evaluate_accuracy(x_val, y_val)
val_accuracies.append(val_acc)
print("epoch: {} -- training loss={:.6f} -- val accuracy={:.2f}".format(i+1, epoch_loss, val_acc))
return losses, val_accuracies
Phần 3: Train dữ liệu MNIST dataset và minh họa
Tiền xử lí dữ liệu
from tensorflow.keras.datasets.mnist import load_data
from tensorflow.keras.utils import to_categorical
def preprocessing_data():
(x_train, y_train), (x_test, y_test) = load_data()
x_train, x_test = x_train.astype('float32')/255., x_test.astype('float32')/255.
x_train, x_test = x_train.reshape((-1, 28*28)), x_test.reshape((-1, 28*28))
y_train = to_categorical(y_train)
return (x_train, y_train), (x_test, y_test)
(x_train, y_train), (x_test, y_test) = preprocessing_data()
x_train.shape
(60000, 784)
Mình chỉ sử dụng 2 hidden layer có kích thước lần lượt 64 và 32 để train cho nhanh. Vì có 10 classes cần phân loại nên num_outputs = 10 = n_classes.
n_classes = 10
mnist_net = Network_complete(x_train.shape[1], n_classes, [64, 32])
losses, accuracies = mnist_net.train(x_train, y_train, x_test, y_test)
epoch: 1 -- training loss=0.161560 -- val accuracy=0.85
epoch: 2 -- training loss=0.085164 -- val accuracy=0.89
epoch: 3 -- training loss=0.067998 -- val accuracy=0.90
epoch: 4 -- training loss=0.059162 -- val accuracy=0.91
epoch: 5 -- training loss=0.053312 -- val accuracy=0.92
epoch: 6 -- training loss=0.048876 -- val accuracy=0.92
epoch: 7 -- training loss=0.045440 -- val accuracy=0.93
epoch: 8 -- training loss=0.042690 -- val accuracy=0.93
epoch: 9 -- training loss=0.040216 -- val accuracy=0.93
epoch: 10 -- training loss=0.038245 -- val accuracy=0.93
Có thể thấy, từ epoch 7 trở đi, val_accuracy không tăng, mô hình lúc này đã bị underfitting, hiểu qua biểu đồ sau
Vẽ biểu đồ minh họa quá trình train thông qua losses và accuracies
def plot(data):
plt.figure(figsize=(10,5))
if data == 'losses':
plt.title('Training Loss')
plt.ylabel('Loss')
plt.plot(range(10), [losses[i] for i in range(10)])
if data == 'accuracies':
plt.title('Validation Accuracy')
plt.ylabel('Accuracy')
plt.plot(range(10), [accuracies[i] for i in range(10)])
plt.xlabel('Epoch')
plt.show()
plot('losses')
plot('accuracies')
Kết
Bài này mình đã trình bày một cách để tạo custom model với layer fully connected đơn giản, nếu thấy hay thì bạn nhớ cho mình một vote nhé. Bài này mình có chia sẻ lại trên github page, thỉnh thoảng mình có viết bài trên blog này của mình.
TÀI LIỆU THAM KHẢO
All rights reserved