5.1. Tầng và Khối

Khi lần đầu giới thiệu về các mạng nơ-ron, ta tập trung vào các mô hình tuyến tính với một đầu ra duy nhất. Như vậy toàn bộ mô hình chỉ chứa một nơ-ron. Lưu ý rằng một nơ-ron đơn lẻ (i) nhận một vài đầu vào; (ii) tạo một đầu ra (vô hướng) tương ứng; và (iii) có một tập các tham số liên quan có thể được cập nhật để tối ưu một hàm mục tiêu nào đó mà ta quan tâm. Sau đó, khi bắt đầu nghĩ về các mạng có nhiều đầu ra, ta tận dụng các phép tính vector để mô tả nguyên một tầng nơ-ron. Cũng giống như các nơ-ron riêng lẻ, các tầng (i) nhận một số đầu vào, (ii) tạo các đầu ra tương ứng, và (iii) được mô tả bằng một tập các tham số có thể điều chỉnh được. Trong hồi quy softmax, bản thân tầng duy nhất ấy chính là một mô hình. Thậm chí đối với các perceptron đa tầng, ta vẫn có thể nghĩ về chúng theo cấu trúc cơ bản này.

Điều thú vị là đối với các perceptron đa tầng, cả mô hình và các tầng cấu thành đều chia sẻ cấu trúc này. (Toàn bộ) mô hình nhận các đầu vào thô (các đặc trưng), tạo các đầu ra (các dự đoán) và sở hữu các tham số (được tập hợp từ tất cả các tầng cấu thành). Tương tự, mỗi tầng riêng lẻ cũng nhận các đầu vào (được cung cấp bởi tầng trước đó), tính toán các đầu ra (cũng chính là các đầu vào cho tầng tiếp theo), và có một tập các tham số có thể điều chỉnh thông qua việc cập nhật dựa trên tín hiệu được truyền ngược từ tầng kế tiếp.

Dù bạn có thể nghĩ rằng các nơ-ron, các tầng và các mô hình đã cung cấp đủ sự trừu tượng để bắt tay vào làm việc, hóa ra sẽ là thuận tiện hơn khi ta bàn về các thành phần lớn hơn một tầng riêng lẻ nhưng lại nhỏ hơn toàn bộ mô hình. Ví dụ, kiến trúc ResNet-152, rất phổ biến trong thị giác máy tính, sở hữu hàng trăm tầng. Nó bao gồm các khuôn mẫu nhóm tầng được lặp lại nhiều lần. Việc lập trình từng tầng của một mạng như vậy có thể trở nên tẻ nhạt. Mối quan tâm này không chỉ là trên lý thuyết — các khuôn mẫu thiết kế như vậy rất phổ biến trong thực tế. Kiến trúc ResNet được đề cập ở trên đã giành chiến thắng trong hai cuộc thi thị giác máy tính ImageNet và COCO năm 2015, trong cả bài toán nhận dạng và bài toán phát hiện [He et al., 2016a] và vẫn là một kiến trúc được tin dùng cho nhiều bài toán thị giác. Các kiến trúc tương tự, trong đó các tầng được sắp xếp thành những khuôn mẫu lặp lại, hiện đã trở nên thông dụng ở nhiều lĩnh vực khác, bao gồm cả xử lý ngôn ngữ tự nhiên và xử lý tiếng nói.

Để lập trình các mạng phức tạp này, ta sẽ giới thiệu khái niệm khối trong mạng nơ-ron. Một khối có thể mô tả một tầng duy nhất, một mảng đa tầng hoặc toàn bộ một mô hình! Dưới góc nhìn xây dựng phần mềm, một Block (Khối) là một lớp. Bất kỳ một lớp con nào của Block đều phải định nghĩa phương thức forward để chuyển hóa đầu vào thành đầu ra và phải lưu trữ mọi tham số cần thiết. Lưu ý rằng có một vài Block sẽ không yêu cầu chứa bất kỳ tham số nào cả! Ngoài ra, một Block phải sở hữu một phương thức backward cho mục đích tính toán gradient. May mắn thay, nhờ sự trợ giúp đắc lực của gói autograd (được giới thiệu trong Section 2) nên khi định nghĩa Block, ta chỉ cần quan tâm đến các tham số và hàm forward.

Một lợi ích khi làm việc ở mức độ trừu tượng Block đó là ta có thể kết hợp chúng, thường là theo phương pháp đệ quy, để tạo ra các thành phần lớn hơn (xem hình minh họa trong Fig. 5.1.1).

../_images/blocks.svg

Fig. 5.1.1 Nhiều tầng được kết hợp để tạo thành các khối

Bằng cách định nghĩa các khối với độ phức tạp tùy ý, các mạng nơ-ron phức tạp có thể được lập trình với mã nguồn ngắn gọn một cách đáng ngạc nhiên.

Để bắt đầu, ta sẽ xem lại các khối mà ta đã sử dụng để lập trình perceptron đa tầng (Section 4.3). Đoạn mã nguồn sau tạo ra một mạng gồm một tầng ẩn kết nối đầy đủ với 256 nút sử dụng hàm kích hoạt ReLU, theo sau là một tầng đầu ra kết nối đầy đủ với 10 nút (không có hàm kích hoạt).

from mxnet import np, npx
from mxnet.gluon import nn
npx.set_np()

x = np.random.uniform(size=(2, 20))

net = nn.Sequential()
net.add(nn.Dense(256, activation='relu'))
net.add(nn.Dense(10))
net.initialize()
net(x)
array([[ 0.06240272, -0.03268593,  0.02582653,  0.02254182, -0.03728798,
        -0.04253786,  0.00540613, -0.01364186, -0.09915452, -0.02272738],
       [ 0.02816677, -0.03341204,  0.03565666,  0.02506382, -0.04136416,
        -0.04941845,  0.01738528,  0.01081961, -0.09932579, -0.01176298]])

Trong ví dụ này, ta đã xây dựng mô hình bằng cách khởi tạo một đối tượng nn.Sequential và gán vào biến net. Sau đó, ta gọi phương thức add nhiều lần để nối các tầng theo thứ tự mà chúng sẽ được thực thi. Nói một cách ngắn gọn, nn.Sequential định nghĩa một loại Block đặc biệt có nhiệm vụ duy trì một danh sách chứa các Block cấu thành được sắp xếp theo thứ tự nhất định. Phương thức add chỉ đơn giản hỗ trợ việc thêm liên tiếp từng Block vào trong danh sách đó. Lưu ý rằng mỗi tầng là một thực thể của lớp Dense, và bản thân lớp Dense lại là một lớp con của Block. Hàm forward cũng rất đơn giản: nó xâu chuỗi từng Block trong danh sách lại với nhau, chuyển đầu ra của từng khối thành đầu vào cho khối tiếp theo. Lưu ý rằng cho đến giờ, ta đã gọi mô hình thông qua net(X) để thu được đầu ra. Thực ra đây chỉ là một cách viết tắt của net.forward(X), một thủ thuật Python khéo léo đạt được thông qua hàm __call__ của lớp Block.

5.1.1. Một Khối Tùy chỉnh

Có lẽ cách dễ nhất để hiểu rõ hơn nn.Block hoạt động như thế nào là tự lập trình nó. Trước khi tự lập trình một Block tùy chỉnh, hãy cùng tóm tắt ngắn gọn các chức năng cơ bản mà một Block phải cung cấp:

  1. Phương thức forward nhận đối số là dữ liệu đầu vào.
  2. Phương thức forward trả về một giá trị đầu ra. Lưu ý rằng đầu ra có thể có kích thước khác với đầu vào. Ví dụ, tầng Dense đầu tiên trong mô hình phía trên nhận đầu vào có kích thước tùy ý nhưng trả về đầu ra có kích thước 256.
  3. Tính gradient của đầu ra theo đầu vào bằng phương thức backward, thường thì việc này được thực hiện tự động.
  4. Lưu trữ và cung cấp quyền truy cập tới các tham số cần thiết để tiến hành phương thức tính toán forward.
  5. Khởi tạo các tham số này khi cần thiết.

Trong đoạn mã dưới đây, chúng ta lập trình từ đầu một Block (Khối) tương đương với một perceptron đa tầng chỉ có một tầng ẩn và 256 nút ẩn, cùng một tầng đầu ra 10 chiều. Lưu ý rằng lớp MLP bên dưới đây kế thừa từ lớp Block. Ta sẽ phụ thuộc nhiều vào các phương thức của lớp cha, và chỉ tự viết phương thức __init__forward.

from mxnet.gluon import nn

class MLP(nn.Block):
    # Declare a layer with model parameters. Here, we declare two fully
    # connected layers
    def __init__(self, **kwargs):
        # Call the constructor of the MLP parent class Block to perform the
        # necessary initialization. In this way, other function parameters can
        # also be specified when constructing an instance, such as the model
        # parameter, params, described in the following sections
        super(MLP, self).__init__(**kwargs)
        self.hidden = nn.Dense(256, activation='relu')  # Hidden layer
        self.output = nn.Dense(10)  # Output layer

    # Define the forward computation of the model, that is, how to return the
    # required model output based on the input x
    def forward(self, x):
        return self.output(self.hidden(x))

Để bắt đầu, ta sẽ tập trung vào phương thức forward. Lưu ý rằng nó nhận giá trị đầu vào x, tính toán tầng biểu diễn ẩn (self.hidden(x)) và trả về các giá trị logit (self.output( ... )). Ở cách lập trình MLP này, cả hai tầng trên đều là biến thực thể (instance variables). Để thấy tại sao điều này có lý, tưởng tượng ta khởi tạo hai MLP, net1net2, và huấn luyện chúng với dữ liệu khác nhau. Dĩ nhiên là ta mong đợi chúng đại diện cho hai mô hình học khác nhau.

Ta khởi tạo các tầng của MLP trong phương thức __init__ (hàm khởi tạo) và sau đó gọi các tầng này mỗi khi ta gọi phương thức forward. Hãy chú ý một vài chi tiết quan trọng. Đầu tiên, phương thức __init__ tùy chỉnh của ta gọi phương thức __init__ của lớp cha thông qua super(MLP, self).__init__(**kwargs) để tránh việc viết lại cùng một phần mã nguồn áp dụng cho hầu hết các khối. Chúng ta sau đó khởi tạo hai tầng Dense, gán chúng lần lượt là self.hiddenself.output. Chú ý rằng trừ khi đang phát triển một toán tử mới, chúng ta không cần lo lắng về lan truyền ngược (phương thức backward) hoặc khởi tạo tham số (phương thức initialize). Gluon sẽ tự động khởi tạo các phương thức đó. Hãy cùng thử nghiệm điều này:

net = MLP()
net.initialize()
net(x)
array([[-0.03989594, -0.1041471 ,  0.06799038,  0.05245074,  0.02526059,
        -0.00640342,  0.04182098, -0.01665319, -0.02067346, -0.07863817],
       [-0.03612847, -0.07210436,  0.09159479,  0.07890771,  0.02494172,
        -0.01028665,  0.01732428, -0.02843242,  0.03772651, -0.06671704]])

Một ưu điểm chính của phép trừu tượng hóa Block là tính linh hoạt của nó. Ta có thể kế thừa từ lớp Block để tạo các tầng (chẳng hạn như lớp Dense được cung cấp bởi Gluon), toàn bộ cả mô hình (như MLP ở phía trên) hoặc các thành phần đa dạng với độ phức tạp vừa phải. Ta sẽ tận dụng tính linh hoạt này xuyên suốt ở các chương sau, đặc biệt khi làm việc với các mạng nơ-ron tích chập.

5.1.2. Khối Tuần tự

Bây giờ ta có thể có cái nhìn rõ hơn về cách mà lớp Sequential (Tuần tự) hoạt động. Nhắc lại rằng Sequential được thiết kế để xâu chuỗi các Khối lại với nhau. Để tự xây dựng một lớp MySequential đơn giản, ta chỉ cần định nghĩa hai phương thức chính sau: 1. Phương thức add nhằm đẩy từng Block một vào trong danh sách. 2. Phương thức forward nhằm truyền một đầu vào qua chuỗi các Blocks (theo thứ tự mà chúng được nối).

Lớp MySequential dưới đây cung cấp tính năng giống như lớp Sequential mặc định của Gluon:

class MySequential(nn.Block):
    def add(self, block):
        # Here, block is an instance of a Block subclass, and we assume it has
        # a unique name. We save it in the member variable _children of the
        # Block class, and its type is OrderedDict. When the MySequential
        # instance calls the initialize function, the system automatically
        # initializes all members of _children
        self._children[block.name] = block

    def forward(self, x):
        # OrderedDict guarantees that members will be traversed in the order
        # they were added
        for block in self._children.values():
            x = block(x)
        return x

Phương thức add thêm một Block đơn vào từ điển có thứ tự _children. Bạn có thể thắc mắc tại sao mỗi Block của Gluon sở hữu một thuộc tính _children và tại sao ta sử dụng nó thay vì tự tạo một danh sách Python. Thật ra, ưu điểm chính của _children là trong quá trình khởi tạo trọng số ban đầu của các khối, Gluon sẽ tự động tìm các khối con có trọng số cần được khởi tạo trong từ điển này.

Khi phương thức forward của khối MySequential được gọi, các Block sẽ được thực thi theo thứ tự mà chúng được thêm vào. Bây giờ ta có thể lập trình lại một MLP sử dụng lớp MySequential.

net = MySequential()
net.add(nn.Dense(256, activation='relu'))
net.add(nn.Dense(10))
net.initialize()
net(x)
array([[-0.07645682, -0.01130233,  0.04952145, -0.04651389, -0.04131573,
        -0.05884133, -0.0621381 ,  0.01311472, -0.01379425, -0.02514282],
       [-0.05124625,  0.00711231, -0.00155935, -0.07555379, -0.06675334,
        -0.01762914,  0.00589084,  0.01447191, -0.04330775,  0.03317726]])

Chú ý rằng việc sử dụng MySequential giống hệt với đoạn mã mà ta đã viết trước đó cho lớp Sequential của Gluon (được mô tả trong Section 4.3).

5.1.3. Thực thi Mã trong Phương thức forward

Lớp nn.Sequential giúp việc xây dựng mô hình trở nên dễ hơn, cho phép ta xây dựng các kiến trúc mới mà không cần phải tự định nghĩa một lớp riêng. Tuy nhiên, không phải tất cả mô hình đều có cấu trúc chuỗi xích đơn giản. Khi cần phải linh hoạt hơn, ta vẫn sẽ muốn định nghĩa từng Block theo cách của mình, ví dụ như khi muốn sử dụng luồng điều khiển Python trong lượt truyền xuôi. Hơn nữa, ta cũng có thể muốn thực hiện các phép toán tùy ý thay vì chỉ dựa vào các tầng mạng nơ-ron được định nghĩa từ trước.

Độc giả có thể nhận ra rằng tất cả phép toán trong mạng cho tới giờ đều thao tác trên các giá trị kích hoạt và tham số của mạng. Tuy nhiên, trong một vài trường hợp, ta có thể muốn kết hợp thêm các hằng số. Chúng không phải là kết quả của tầng trước mà cũng không phải là tham số có thể cập nhật được. Trong Gluon, ta gọi chúng là tham số không đổi (constant parameter). Ví dụ ta muốn một tầng tính hàm \(f(\mathbf{x},\mathbf{w}) = c \cdot \mathbf{w}^\top \mathbf{x}\), trong đó \(\mathbf{x}\), \(\mathbf{w}\) là tham số, và \(c\) là một hằng số cho trước được giữ nguyên giá trị trong suốt quá trình tối ưu hóa.

Khai báo các hằng số một cách tường minh (bằng get_constant) giúp Gluon tăng tốc độ thực thi. Trong đoạn mã sau, ta lập trình một mô hình mà không hề dễ lắp ráp nếu sử dụng Sequential và các tầng được định nghĩa trước.

class FixedHiddenMLP(nn.Block):
    def __init__(self, **kwargs):
        super(FixedHiddenMLP, self).__init__(**kwargs)
        # Random weight parameters created with the get_constant are not
        # iterated during training (i.e., constant parameters)
        self.rand_weight = self.params.get_constant(
            'rand_weight', np.random.uniform(size=(20, 20)))
        self.dense = nn.Dense(20, activation='relu')

    def forward(self, x):
        x = self.dense(x)
        # Use the constant parameters created, as well as the relu
        # and dot functions
        x = npx.relu(np.dot(x, self.rand_weight.data()) + 1)
        # Reuse the fully connected layer. This is equivalent to sharing
        # parameters with two fully connected layers
        x = self.dense(x)
        # Here in Control flow, we need to call asscalar to return the scalar
        # for comparison
        while np.abs(x).sum() > 1:
            x /= 2
        return x.sum()

Trong mô hình FixedHiddenMLP, ta lập trình một tầng ẩn có trọng số (self.rand_ weight) được khởi tạo ngẫu nhiên và giữ nguyên giá trị về sau. Trọng số này không phải là một tham số mô hình, vì vậy nó không được cập nhật khi sử dụng lan truyền ngược. Sau đó, đầu ra của tầng cố định này được đưa vào tầng Dense.

Lưu ý rằng trước khi trả về giá trị đầu ra, mô hình của ta đã làm điều gì đó bất thường. Ta đã chạy một vòng lặp while, lấy vector đầu ra chia cho \(2\) cho đến khi nó thỏa mãn điều kiện np.abs(x).sum() > 1. Cuối cùng, ta gán giá trị đầu ra bằng tổng các phần tử trong x. Theo sự hiểu biết của chúng tôi, không có mạng nơ-ron tiêu chuẩn nào thực hiện phép toán này. Lưu ý rằng phép toán đặc biệt này có thể không hữu ích gì trong các công việc ngoài thực tế. Mục đích của chúng tôi ở đây là chỉ cho độc giả thấy được cách tích hợp một đoạn mã tùy ý vào luồng tính toán của mạng nơ-ron.

net = FixedHiddenMLP()
net.initialize()
net(x)
array(0.52637565)

Với Gluon, ta có thể kết hợp nhiều cách khác nhau để lắp ráp các Block lại. Trong ví dụ dưới đây, ta lồng các Block với nhau theo nhiều cách sáng tạo.

class NestMLP(nn.Block):
    def __init__(self, **kwargs):
        super(NestMLP, self).__init__(**kwargs)
        self.net = nn.Sequential()
        self.net.add(nn.Dense(64, activation='relu'),
                     nn.Dense(32, activation='relu'))
        self.dense = nn.Dense(16, activation='relu')

    def forward(self, x):
        return self.dense(self.net(x))

chimera = nn.Sequential()
chimera.add(NestMLP(), nn.Dense(20), FixedHiddenMLP())

chimera.initialize()
chimera(x)
array(0.97720534)

5.1.4. Biên dịch Mã nguồn

Những người đọc có tâm có thể sẽ bắt đầu lo lắng về hiệu năng của một vài đoạn mã trên. Sau cùng thì, chúng ta có rất nhiều thao tác truy cập từ điển, thực thi mã lập trình và rất nhiều thứ “đậm chất Python” khác xuất hiện trong thứ mà lẽ ra nên là một thư viện học sâu hiệu năng cao. Vấn đề của Khóa Trình thông dịch Toàn cục (Global Interpreter Lock) trong Python khá phổ biến. Trong bối cảnh học sâu, ta lo sợ rằng GPU cực kỳ nhanh của ta có thể sẽ phải đợi CPU “rùa bò” chạy xong những dòng lệnh Python trước khi nó có thể nhận tác vụ chạy tiếp theo. Cách tốt nhất để tăng tốc Python là tránh không sử dụng nó. Gluon làm việc này bằng cách cho phép việc Hybrid hóa (Section 12.1). Ở đây, trình thông dịch của Python sẽ thực thi một Khối trong lần chạy đầu tiên.
Môi trường chạy của Gluon sẽ ghi lại những gì đang diễn ra và trong lần chạy tiếp theo, nó sẽ thực hiện các tác vụ gọi trong Python một cách vắn tắt hơn. Điều này có thể giúp tăng tốc độ chạy đáng kể trong một vài trường hợp, tuy nhiên, ta cần quan tâm tới việc luồng điều khiển (như trên) sẽ dẫn đến những nhánh khác nhau với mỗi lần truyền qua mạng. Chúng tôi khuyến khích những độc giả có hứng thú sau khi hoàn tất chương này hãy đọc thêm mục hybrid hóa (Section 12.1) để tìm hiểu về quá trình biên dịch.

5.1.5. Tóm tắt

  • Các tầng trong mạng nơ-ron là các Khối.
  • Nhiều tầng có thể cấu thành một Khối.
  • Nhiều Khối có thể cấu thành một Khối.
  • Một Khối có thể chứa các đoạn mã nguồn.
  • Các Khối đảm nhiệm nhiều tác vụ bao gồm khởi tạo tham số và lan truyền ngược.
  • Việc gắn kết các tầng và khối một cách tuần tự được đảm nhiệm bởi Khối Sequential.

5.1.6. Bài tập

  1. Vấn đề gì sẽ xảy ra nếu ta bỏ hàm asscalar trong lớp FixedHiddenMLP?
  2. Vấn đề gì sẽ xảy ra nếuself.net được định nghĩa trong thực thể Sequential ở lớp NestMLP được đổi thành self.net = [nn.Dense(64, activation='relu'), nn. Dense(32, activation='relu')]?
  3. Hãy lập trình một khối nhận đối số là hai khối khác, ví dụ như net1net2, và trả về kết quả là phép nối các giá trị đầu ra của cả hai mạng đó khi thực hiện lượt truyền xuôi.
  4. Giả sử bạn muốn nối nhiều thực thể của cùng một mạng với nhau. Hãy lập trình một hàm để tạo ra nhiều thực thể của cùng một mạng và dùng chúng để tạo thành một mạng lớn hơn (các hàm này trong thiết kế phần mềm được gọi là Factory Function).

5.1.7. Thảo luận

5.1.8. Những người thực hiện

Bản dịch trong trang này được thực hiện bởi:

  • Đoàn Võ Duy Thanh
  • Nguyễn Duy Du
  • Phạm Minh Đức
  • Nguyễn Lê Quang Nhật
  • Lê Khắc Hồng Phúc
  • Trần Yến Thy
  • Phạm Hồng Vinh
  • Lý Phi Long