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 5 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
 
  
 