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).
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:
- Phương thức
forward
nhận đối số là dữ liệu đầu vào. - 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. - 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. - 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
. - 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__
và 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, net1
và net2
, 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.hidden
và
self.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¶
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¶
- Vấn đề gì sẽ xảy ra nếu ta bỏ hàm
asscalar
trong lớpFixedHiddenMLP
? - Vấn đề gì sẽ xảy ra nếu
self.net
được định nghĩa trong thực thểSequential
ở lớpNestMLP
được đổi thànhself.net = [nn.Dense(64, activation='relu'), nn. Dense(32, activation='relu')]
? - Hãy lập trình một khối nhận đối số là hai khối khác, ví dụ như
net1
vànet2
, 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. - 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