Toàn bộ source code nằm ở: https://github.com/lenguyen1807/mnist-cpp

Neural Network là gì ?

Neural Network Architecture
Hình 1: Kiến trúc của một Neural Network (nguồn: Understanding Feed Forward Neural Networks With Maths and Statistics - turing.com)

Dựa vào ảnh trên, có thể thấy một Neural Network như là một đồ thị có hướng không chu trình. Trong đó sẽ gồm nhiều nút, mỗi nút được gọi là một Neuron và được sắp xếp theo từng layer, những layer mà không phải input hoặc output thì được gọi là hidden layer. Mỗi cạnh của Neural Network sẽ có trọng số hay weight.

Lưu ý là khi ta nói Neural Network có 1 layer, ta sẽ hiểu Neural Network ấy sẽ bao gồm 1 layer input và 1 layer output. Bởi vì khi nói đến layer, ta chỉ nói đến các layer có thể tính toán được, còn input layer chỉ có nhiệm vụ nhận giá trị chứ không tính toán giá trị ấy [null].

Neural Network chỉ có 1 layer (gồm input layer và output layer) được gọi là Perceptron. Trước khi đi vào Neural Network nhiều layer hơn, ta sẽ tìm hiểu một chút về Perceptron.

Neural Network mà chúng ta đang nói tới, có hướng, không chu trình được gọi là Feed Forward Neural Network. Ngoài ra mỗi neuron đều có cạnh nối tới nó, do đó có thể nghe đến term Fully-Connected Neural Network.

Perceptron

Perceptron Image
Hình 2: Một ví dụ về perceptron

Ý tưởng mở rộng output neuron được mình lấy thẳng từ [null].

Ở hình 2 là ví dụ về một perceptron gồm NN input và 1 output do đó input layer sẽ gồm NN neuron và output layer chỉ có 1 neuron. Có thể thấy, neuron output yy sẽ được tính bằng tổ hợp tuyến tính của các input neuron (trong đó có các trọng số wiw_i tương ứng với input neuron xix_i), sau đó tổ hợp tuyến tính ấy sẽ đi qua một hàm ϕ\phi:

y=ϕ(i=1Nwixi)y = \phi\left( \sum_{i=1}^N w_ix_i \right)

Ngoài ra còn một thứ mà mình chưa nhắc đến đó là Bias, tức là ta sẽ cộng thêm một hằng số nữa, gọi là bb đi.

y=ϕ(i=1Nwixi+b)y = \phi\left( \sum_{i=1}^N w_ix_i + b \right)

Nếu đặt b=w0b = w_0 thì ta sẽ có:

y=ϕ(w0+x1w1+...+wNxN)y = \phi\left( w_0 + x_1w_1 + ... + w_Nx_N \right)

Vậy vector input x\mathbf{x} lúc này trở thành {1,x1,...,xN}\{1, x_1, ..., x_N\} (thêm 11 vào đầu) và w={w0,...,wN}\mathbf{w} = \{w_0, ..., w_N\}. Nhưng mình chỉ nhắc đến thôi, từ đây về cuối luôn mình sẽ không dùng đến bias (bởi vì nó làm mọi thứ rối hơn 😭).

Ta gọi hàm ϕ\phihàm activation, ngoài ra phần tổ hợp tuyến tính có khi được gọi là pre-activation và kí hiệu là aa, do đó:

a=i=1Nwixiy=ϕ(a)\begin{aligned} a &= \sum_{i=1}^N w_i x_i \\ y &= \phi(a) \end{aligned}

Hiện tại đã có rất nhiều hàm activation, ví dụ như:

  • Hàm sigmoid (mình sẽ kí hiệu là σ\sigma):
ϕ(x)=σ(x)=11+ex\phi(x) = \sigma(x) = \dfrac{1}{1 + e^{-x}}
  • Hàm tanh:
ϕ(x)=tanh(x)=exexex+ex\phi(x) = \tanh(x) = \dfrac{e^x - e^{-x}}{e^x + e^{-x}}
  • Hàm ReLU 1:
ϕ(x)=ReLU(x)=max(0,x)={xneˆˊx>00\phi(x) = \text{ReLU}(x) = \max(0, x) = \begin{cases} x \hspace{10pt} \text{nếu $x > 0$} \\ 0 \hspace{10pt} \end{cases}
  • Hay có thể là hàm Linear 💀:
ϕ(x)=x\phi(x) = x
Perceptron matrix
Hình 3: Perceptron dưới dạng ma trận và vector
Perceptron matrix
Hình 4: Perceptron dưới dạng ma trận và vector (biểu diễn khác)

Nhìn hình 3 và 4 thì ta có thể đưa một Perceptron về dạng ma trận trọng số WW và vector input x\mathbf{x}, vector output y\mathbf{y} (các vector đều là vector cột):

y=ϕ(Wx)\mathbf{y} = \phi(W \cdot \mathbf{x})

trong đó WW sẽ có số dòng tương ứng với chiều của y\mathbf{y} và số cột tương ứng với số chiều của x\mathbf{x}. Nếu mở rộng hơn, số dòng sẽ tương ứng với chiều của layer hiện tại và số cột tương ứng với layer trước đó.

Deep Neural Network

Multi-layer Perceptron Image
Hình 5: Một ví dụ về multi-layer perceptron (hay deep neural network)

Có thể thấy, deep neural network được tạo ra bằng cách thêm nhiều lớp hidden layer ở giữa input layer và output layer. Đây cũng là lý do mà nó còn tên khác là Multi-layer Perceptron (hay MLP) bởi vì đơn giản nó là Perceptron và có thêm nhiều layer ở giữa (Multi-layer).

Multi-layer Perceptron Image
Hình 6: Deep Neural Network biểu diễn dưới dạng ma trận và vector

Dựa vào hình 6, để tính được output của một Neural Network (gọi tắt của Deep Neural Network) ta sẽ thực hiện tính từng hidden layer bằng cách nhân với ma trận trọng số tương ứng. Để tính được output y\mathbf{y} ta phải tính hidden layer hNh_N, tương tự để tính hNh_{N} phải tính được hidden layer hN1h_{N-1}. Vậy tất cả chỉ là nhân ma trận với vector thôi hay sao 😭. Thật ra là còn nhiều hơn nữa nhưng mà đơn giản vậy là được rồi.

Nếu để ý, việc tính được output y\mathbf{y} thì cần bắt đầu từ input x\mathbf{x} sau đó tiến đến hidden layer thứ nhất, thứ hai, ... cho đến hidden layer cuối cùng, do đó nó còn được gọi là bước Forward (tiến về phía trước ấy). Ta sẽ còn một bước nữa gọi là Backward mà mình và các bạn sẽ cùng tìm hiểu ở phần 2 nhé.

Một trong những dòng code đầu tiên khi code Neural Network đó chính là phải code được ma trận và tất cả phép toán của nó ví dụ như phép nhân, cộng, chuyển vị, ...

Những dòng code đầu tiên

Để hiểu rõ và làm cực hơn một tí thì mình với các bạn sẽ cùng code C++ 😈.

Ma trận

Đầu tiên ta phải có 1 class ma trận. Ở đây mình chọn std::vector ví nó đơn giản, dễ sử dụng. Còn tại sao lại dùng size_t mà không dùng int, bởi vì nếu bạn gọi length() của vector thì giá trị trả về là size_t chứ không phải int, nên nếu dùng int tức là bạn đang ép kiểu từ size_t về int.

Kiểu size_t là một kiểu dữ liệu số nguyên không dấu và điều đặc biệt là size_t không có kích thước cố định, trong khi int thì sẽ từ 2 cho đến 4 bytes 2.

Việc dùng const Matrix& mat thay vì Matrix mat sẽ làm được 2 thứ:

  • Thứ nhất là & sẽ chỉ định compiler rằng ta sẽ truyền tham chiếu (hay pass by reference) nếu không có dấu & thì compiler sẽ tạo một copy của mat.
  • Thứ hai là const, bởi vì khi dùng & ta có thể thay đổi được giá trị của mat trong hàm nên dùng const để tránh điều này xảy ra.
matrix.h
class Matrix 
{
public:
    size_t rows;
    size_t cols;
    std::vector<std::vector<double>> values;
 
    // Tạo một ma trận với 
    // số dòng = rows
    // số cột = cols
    Matrix(size_t rows, size_t cols);
 
    // Tạo một ma trận = ma trận mat (truyền vào)
    Matrix(const Matrix& mat);
 
    // Fill tất cả phần tử của ma trận với 1 giá trị nhiều nhất
    // Điều này giúp dễ dàng tạo ma trận 0
    // Fill(0)
    void Fill(double value);
 
    // In ma trận
    void Print();
 
    // Xem hai ma trận có bằng nhau không
    Matrix& operator=(const Matrix& mat);
 
    // Cộng bằng ma trận
    Matrix& operator+=(const Matrix& mat);
    // Trừ bằng ma trận
    Matrix& operator-=(const Matrix& mat);
    // Chuyển vị ma trận
    Matrix T();
}
 
// Cộng ma trận
inline Matrix operator+(Matrix mat1, const Matrix& mat2) 
{
    mat1 += mat2;
    return mat1;
}
 
// Trừ ma trận
inline Matrix operator-(Matrix mat1, const Matrix& mat2) 
{
    mat1 -= mat2;
    return mat1;
}
 
// Nhân ma trận
inline Matrix operator*(const Matrix& mat1, const Matrix& mat2) 
{
    // Implement nhân ma trận ở đây ...
}

Tại sao mình lại dùng inline rồi code thêm operator += chi cho phức tạp vậy, chỉ cần code Matrix operator+(const Matrix& mat1, const Matrix& mat2) là đủ rồi. Việc mình implement += trước sau đó dùng lại +=+ thì sẽ tiết kiệm được việc phải implement 2 cái, ngoài ra dùng inline sẽ tối ưu hơn. Tiếp theo việc truyền Matrix mat1 thay vì const Matrix& mat1 sẽ copy luôn mat1 và dùng += sẽ thay đổi mat1 trực tiếp, cuối cùng là return về copy của mat1 và ta xem nó như là kết quả của mat1 + mat2 3.

Để đơn giản hoá, khi nhân ma trận mình chỉ cần chạy từng dòng, đến từng cột, sau đó tích vô hướng lại, cái này thì dễ làm nhưng độ phức tạp sẽ rơi vào O(n3)O(n^3), rất là lớn ha, nếu các bạn muốn nhanh hơn thì có thể dùng các thuật toán khác và đặc biệt hơn là dùng GPU (đây là cái mà Pytroch, Tensorflow, ... đang làm).

matrix.h
inline Matrix operator*(const Matrix& mat1, const Matrix& mat2)
{
    assert(mat1.cols == mat2.rows);
 
    Matrix res(mat1.rows, mat2.cols);
 
    for (size_t i = 0; i < mat1.rows; i++) {
        for (size_t j = 0; j < mat2.cols; j++) {
            for (size_t k = 0; k < mat1.cols; k++) {
                res.values[i][j] += mat1.values[i][k]*mat2.values[k][j];
            }
        }
    }
 
    return res;
}

Cuối cùng sẽ là implement các hàm còn lại của class hàm ma trận. Lưu ý khi khởi tạo vector lồng vector nhớ khởi tạo vector phía bên trong 4, nếu không nó sẽ dễ bị bug đấy.

Cái mà :rows(rows),cols(cols) ấy được gọi là initializer list 5. Đây là một cách để khởi tạo biến cho class trong C++.

matrix.cpp
Matrix::Matrix(size_t rows, size_t cols)
    : rows(rows)
    , cols(cols)
    , values(rows, std::vector<double>(cols))
{}
 
Matrix::Matrix(const Matrix& mat)
    : rows(mat.rows)
    , cols(mat.cols)
    , values(rows, std::vector<double>(cols))
{
    for (size_t i = 0; i < rows; i++) {
        for (size_t j = 0; j < cols; j++) {
            values[i][j] = mat.values[i][j];
        }
    }
}

Đối với ma trận chuyển vị, thì ta cũng dùng thuật toán đơn giản theo công thức sau:

(AT)[j][i]=A[i][j](A^T)[j][i] = A[i][j]
matrix.cpp
Matrix Matrix::T()
{
    Matrix mat(cols, rows);
    for (size_t i = 0; i < rows; i++) {
        for (size_t j = 0; j < cols; j++) {
            mat.values[j][i] = values[i][j];
        }
    }
    return mat;
}

Ngoài ra còn operator +=, -=, = mà chúng ta cũng phải implement, mình nghĩ code nó khá dễ rồi nên sẽ không giải thích nhiều. Trước khi cộng, trừ hay so sánh hai ma trận, ta phải so sánh hai ma trận xem có cùng chiều với nhau không thông qua một hàm phụ là CheckDimension.

matrix.cpp
void Matrix::CheckDimension(const Matrix& mat1, const Matrix& mat2)
{
    assert(mat1.rows == mat2.rows && mat2.cols == mat2.cols);
}
 
Matrix& Matrix::operator=(const Matrix& mat)
{
    this->cols = mat.cols;
    this->rows = mat.rows;
 
    for (size_t i = 0; i < rows; i++) {
        for (size_t j = 0; j < cols; j++) {
            values[i][j] = mat.values[i][j];
        }
    }
    return *this;
}
 
Matrix& Matrix::operator-=(const Matrix& mat)
{
    CheckDimension((*this), mat);
    for (size_t i = 0; i < rows; i++) {
        for (size_t j = 0; j < cols; j++) {
            this->values[i][j] -= mat.values[i][j];
        }
    }
    return *this;
}
 
Matrix& Matrix::operator+=(const Matrix& mat)
{
    CheckDimension((*this), mat);
    for (size_t i = 0; i < rows; i++) {
        for (size_t j = 0; j < cols; j++) {
            this->values[i][j] += mat.values[i][j];
        }
    }
    return *this;
}

Như vậy là gần xong class ma trận rồi, thế nhưng ta vẫn cần một số hàm phụ trợ nữa. Đầu tiên chính là Flatten, tức là ta sẽ đưa ma trận 2 chiều về thành vector 1 chiều, cái này cực quan trọng nếu dữ liệu là ảnh, ảnh thông thường sẽ ở dạng 2 chiều, nhưng mà input của Neural Network lại là 1 chiều (trừ khi chúng ta dùng dạng neural network khác được gọi là Convolutional Neural Network). Mình implement hàm Flatten như sau:

matrix.h
class Matrix
{
    // ...
    static Matrix Flatten(const Matrix& mat);
    // ...
}

Việc mình chọn static là bởi vì mình không muốn hàm Flatten phải có object mới dùng được, kiểu như ta có một object Matrix mat bất kì thì cứ gọi Matrix::Flatten(mat) là được.

matrix.cpp
Matrix Matrix::Flatten(const Matrix& mat)
{
    // Ở đây vector n chiều được xem như là một ma trận 
    // với 1 cột và n dòng (vector cột)
    Matrix res(mat.cols * mat.rows, 1);
 
    for (size_t i = 0; i < mat.rows; i++) {
        for (size_t j = 0; j < mat.cols; j++) {
            res.values[i*mat.rows + j][0] = mat.values[i][j];
        }
    }
    return res;
}

Tiếp theo là mình phải tìm cách để khởi tạo weight cho Neural Network một cách tối ưu nhất, theo 6 thì khởi tạo trọng số như sau: Với mỗi trọng số ww, ta lấy ww từ một phân phối chuẩn có trung bình là 00 và phương sai là 2/n2/n với nn là số chiều của vector input (hay đúng hơn là chiều của layer phía trước).

matrix.h
class Matrix
{
    // ...
    static Matrix Randomized(size_t rows, size_t cols);
    // ...
}
matrix.cpp
#include <random>
 
// ...
 
Matrix Matrix::Randomized(size_t rows, size_t cols)
{
    // generate random in uniform distribution
    std::random_device rand_dev;
    std::mt19937 generator(rand_dev());
 
    // https://cs231n.github.io/neural-networks-2/#init
    double std = 2.0f / static_cast<double>(cols); 
    std::normal_distribution<double> distr(0, std);
 
    Matrix res(rows, cols);
    for (size_t i = 0; i < rows; i++) {
        for (size_t j = 0; j < cols; j++) {
            res.values[i][j] = distr(generator);
        }
    }
 
    return res;
}

À còn cái cuối cùng, mình cần dùng được một hàm Element-wise trên class Matrix (ví dụ như dùng Activation lên class Matrix). Mình sẽ viết hàm Apply() (giống tương tự như hàm apply trong pandas), std::function<double(double)> nghĩa là một hàm có một tham số kiểu double (cái ngoặc tròn double ý) và kiểu trả về là double (phía ngoài), đây là kiểu dữ liệu của hàm (mình truyền hàm như tham số) của C++11 trở lên.

matrix.h
class Matrix
{
    // ...
    // Apply a function to matrix
    Matrix Apply(const std::function<double(double)>& func);
    // ...
}
matrix.cpp
Matrix Matrix::Apply(const std::function<double(double)>& func)
{
    Matrix mat(rows, cols);
    for (size_t i = 0; i < rows; i++) {
        for (size_t j = 0; j < cols; j++) {
            mat.values[i][j] = func(this->values[i][j]);
        }
    }
    return mat;
}

Các hàm activation

Các hàm activation mà chúng ta đã biết phía trên cũng không quá khó để code. Như đã biết thì mình dùng tham số std::function<double(double)> ở phía trên nên do đó các hàm activation mà áp dụng element-wise cũng phải có kiểu trả về là double và tham số là double luôn.

double Sigmoid(double x) 
{
    if (x > 0) {
        return (1 / (1 + std::exp(-x)));
    } else { // stable sigmoid
        return (std::exp(x) / (1 + std::exp(x)));
    }
}
 
double ReLU(double x) 
{
    return x > 0 ? x : 0;
}
 
double Linear(double x)
{
    return x;
}

Việc mình không làm hàm tanh là bởi vì hiện tại khá là ít người dùng, đa số chỉ dùng Sigmoid hoặc ReLU. Riêng hàm linear thì khá quan trọng, còn tại sao thì dưới đây nhé :>.

Trước khi kết thúc thì còn 1 hàm activation rất quan trọng nữa đó chính là hàm Softmax 7. Softmax cũng là một activation nhưng thay vì truyền vào một số thực như các hàm khác thì ta sẽ truyền 1 vector và output của Softmax cũng là một vector (do đó không còn chuyện element-wise như trên nữa).

Xét một vector bất kì gồm NN phần tử x={x1,x2,...,xN}\mathbf{x} = \{x_1, x_2, ..., x_N\}. Khi đó:

softmax(x)i=exij=1Nexj\text{softmax}(\mathbf{x})_i = \dfrac{e^{x_i}}{\sum_{j=1}^N e^{x_j}}

hay:

softmax(x)={softmax(x)1,...,softmax(x)N}\text{softmax}(\mathbf{x}) =\left\{ \text{softmax}(\mathbf{x})_1, ..., \text{softmax}(\mathbf{x})_N \right\}

Có thể thấy các phần tử của vector softmax\text{softmax} luôn có giá trị nằm trong khoảng (0,1)(0, 1) ngoài ra tổng các phần tử của vector bằng 11 do đó vector softmax\text{softmax} này có thể được người ta xem như vector phân phối vậy, trong đó mỗi phần tử được xem như là xác suất. Ngoài ra hàm activation Softmax thường được áp dụng cho layer cuối, khi đó output cho ra sẽ là xác suất. Ví dụ như output layer cuối cùng gồm 10 phần tử, ta xem mỗi phần tử ấy là 1 lớp, sau khi qua Softmax thì mỗi lớp sẽ có giá trị xác suất tương ứng của mình.

Matrix SoftMax(const Matrix& x)
{
    // suppose x is a column vector
    assert(x.cols == 1);
 
    Matrix result(x.rows, 1);
    double total = 0.0;
 
    for (size_t i = 0; i < x.rows; i++) {
        total += std::exp(x.values[i][0]);        
    }
 
    for (size_t i = 0; i < x.rows; i++) {
        result.values[i][0] = (std::exp(x.values[i][0]) / total);
    }
 
    return result;
}

Giờ đến lý do tại sao lại có hàm Linear nhé, đơn giản là vì mình muốn để Softmax ở layer cuối thế nhưng code mình chỉ hỗ trợ các hàm activation nhận tham số là số thực chứ không là vector do đó mình dùng Linear trước, rồi dùng Softmax lên sau.

Vậy là xong class Ma trận và các hàm phụ trợ rồi ấy, cảm ơn các bạn đã đọc đến đây hehe 🍉. Ở phần sau Cùng mình xây dựng Neural Network đơn giản (Phần 2) ta sẽ đi qua kỹ thuật Backpropagation (hay còn gọi là Backward) và cách train Deep Neural Network.

References

  1. An Introduction to Neural Networks, Neural Networks and Deep Learning: A Textbook,
    Aggarwal, Charu.
    Springer International Publishing, 2023,
    https://doi.org/10.1007/978-3-031-29642-0_1
  2. The Backpropagation Algorithm, Neural Networks and Deep Learning: A Textbook,
    Aggarwal, Charu.
    Springer International Publishing, 2023,
    https://doi.org/10.1007/978-3-031-29642-0_2

Footnotes

  1. https://en.wikipedia.org/wiki/Rectifier_(neural_networks)

  2. https://stackoverflow.com/questions/131803/unsigned-int-vs-size-t

  3. https://stackoverflow.com/questions/4421706/what-are-the-basic-rules-and-idioms-for-operator-overloading

  4. https://stackoverflow.com/questions/13121469/initializing-a-vector-of-vectors-having-a-fixed-size-with-boost-assign

  5. https://en.cppreference.com/w/cpp/language/constructor

  6. https://cs231n.github.io/neural-networks-2/#init

  7. https://en.wikipedia.org/wiki/Softmax_function