3.3. Cách lập trình súc tích Hồi quy Tuyến tính¶
Sự quan tâm nhiệt thành và rộng khắp với học sâu trong những năm gần đây
đã tạo cảm hứng cho các công ty, học viện và những người đam mê tới học
sâu phát triển nhiều framework mã nguồn mở hoàn thiện, giúp tự động hóa
các công việc lặp đi lặp lại trong quá trình triển khai các thuật toán
học dựa trên gradient. Trong chương trước, chúng ta chỉ dựa vào (i)
ndarray
để lưu dữ liệu và thực hiện tính toán đại số tuyến tính; và
(ii) autograd
để thực hiện tính đạo hàm. Trên thực tế, do các
iterator dữ liệu, các hàm mất mát, các bộ tối ưu và các tầng của mạng
nơ-ron (thậm chí là toàn bộ kiến trúc) rất phổ biển, các thư viện hiện
đại đã cài đặt sẵn những thành phần này cho chúng ta.
Mục này sẽ hướng dẫn bạn cách để xây dựng mô hình hồi quy tuyến tính trong phần Section 3.2 một cách súc tích với Gluon.
3.3.1. Tạo Tập dữ liệu¶
Chúng ta bắt đầu bằng việc tạo một tập dữ liệu như ở mục trước.
from d2l import mxnet as d2l
from mxnet import autograd, gluon, np, npx
npx.set_np()
true_w = np.array([2, -3.4])
true_b = 4.2
features, labels = d2l.synthetic_data(true_w, true_b, 1000)
3.3.2. Đọc tập dữ liệu¶
Thay vì tự viết iterator riêng để đọc dữ liệu thì ta có thể gọi mô-đun
data
của Gluon để xử lý việc này. Bước đầu tiên sẽ là khởi tạo một
ArrayDataset
. Hàm tạo của đối tượng này sẽ lấy một hoặc nhiều
ndarray
làm đối số. Tại đây, ta truyền vào hàm hai đối số là
features
và labels
. Kế tiếp, ta sử dụng ArrayDataset
để khởi
tạo mộtDataLoader
, lớp này yêu cầu ta truyền vào một giá trị
batch_size
và giá trị Boolean shuffle
để cho biết chúng ta có
muốn DataLoader
xáo trộn dữ liệu trên mỗi epoch (một lần duyệt qua
toàn bộ tập dữ liệu) hay không.
# Saved in the d2l package for later use
def load_array(data_arrays, batch_size, is_train=True):
"""Construct a Gluon data loader"""
dataset = gluon.data.ArrayDataset(*data_arrays)
return gluon.data.DataLoader(dataset, batch_size, shuffle=is_train)
batch_size = 10
data_iter = load_array((features, labels), batch_size)
Bây giờ, ta có thể sử dụng data_iter
theo cách tương tự như cách ta
gọi hàm data_iter
trong phần trước. Để biết rằng nó có hoạt động
được hay không, ta có thể thử đọc và in ra minibatch đầu tiên.
for X, y in data_iter:
print(X, '\n', y)
break
[[-0.38151944 -1.3118169 ]
[ 0.02099029 -0.73851013]
[-0.31846237 -0.9492751 ]
[-0.17870884 -0.20225924]
[ 0.0901759 0.27758422]
[-2.2036052 1.2875317 ]
[-0.47063652 -0.59975994]
[ 0.24499686 -1.6080191 ]
[ 0.43582228 0.72849447]
[-0.1742568 1.9691626 ]]
[[ 7.871762 ]
[ 6.737613 ]
[ 6.796183 ]
[ 4.4967184]
[ 3.427059 ]
[-4.5830226]
[ 5.289975 ]
[10.171097 ]
[ 2.5919693]
[-2.8460252]]
3.3.3. Định nghĩa Mô hình¶
Khi ta lập trình hồi quy tuyến tính từ đầu (trong Section 3.2), ta đã định nghĩa rõ ràng các tham số của mô hình và lập trình các tính toán cho giá trị đầu ra sử dụng các phép toán đại số tuyến tính cơ bản. Bạn nên biết cách để làm được điều này. Nhưng một khi mô hình trở nên phức tạp hơn và đồng thời khi bạn phải làm điều này gần như hàng ngày, bạn sẽ thấy vui mừng khi có sự hỗ trợ từ các thư viện. Tình huống này tương tự như việc lập trình blog của riêng bạn lại từ đầu. Làm điều này một hoặc hai lần thì sẽ bổ ích và mang tính hướng dẫn, nhưng bạn sẽ trở thành một nhà phát triển web “khó ở” nếu mỗi khi cần một trang blog bạn lại phải dành ra cả một tháng chỉ để phát triển lại từ đầu.
Đối với những tác vụ tiêu chuẩn, chúng ta có thể sử dụng các tầng đã
được định nghĩa trước trong Gluon, điều này cho phép chúng ta tập trung
vào những tầng được dùng để xây dựng mô hình hơn là việc phải tập trung
vào cách lập trình các tầng đó. Để định nghĩa một mô hình tuyến tính,
đầu tiên chúng ta cần nhập vào mô-đun nn
, giúp ta định nghĩa một
lượng lớn các tầng trong mạng nơ-ron (lưu ý rằng “nn” là chữ viết tắt
của “neural network”). Đầu tiên ta sẽ định nghĩa một biến mô hình là
net
, tham chiếu đến một thực thể của lớp Sequential
. Trong
Gluon, Sequential
định nghĩa một lớp chứa nhiều tầng được liên kết
với nhau. Khi nhận được dữ liệu đầu vào, Sequential
sẽ truyền dữ
liệu vào tầng đầu tiên, kết quả đầu ra từ đó trở thành đầu vào của tầng
thứ hai và cứ tiếp tục như thế ở các tầng kế tiếp. Trong ví dụ tiếp
theo, mô hình chúng ta chỉ có duy nhất một tầng, vì vậy không nhất thiết
phải sử dụng Sequential
. Tuy nhiên vì hầu hết các mô hình chúng ta
gặp phải trong tương lai đều có nhiều tầng, do đó dù sao cũng nên dùng
để làm quen với quy trình làm việc tiêu chuẩn nhất.
from mxnet.gluon import nn
net = nn.Sequential()
Hãy cùng nhớ lại kiến trúc của mạng đơn tầng như đã trình bày tại
Fig. 3.3.1. Tầng đó được gọi là kết nối đầy đủ bởi
vì mỗi đầu vào được kết nối lần lượt với từng đầu ra bằng một phép nhân
ma trận với vector. Trong Gluon, tầng kết nối đầy đủ được định nghĩa
trong lớp Dense
. Bởi vì chúng ta chỉ mong xuất ra một số vô hướng
duy nhất, nên ta gán giá trị là \(1\).
net.add(nn.Dense(1))
Để thuận tiện, điều đáng chú ý là Gluon không yêu cầu chúng ta chỉ định
kích thước đầu vào mỗi tầng. Nên tại đây, chúng ta không cần thiết cho
Gluon biết có bao nhiêu đầu vào cho mỗi tầng tuyến tính. Khi chúng ta cố
gắng truyền dữ liệu qua mô hình lần đầu tiên, ví dụ: khi chúng ta thực
hiện net(X)
sau đó, Gluon sẽ tự động suy ra số lượng đầu vào cho mỗi
tầng. Chúng ta sẽ mô tả cách hoạt động của cơ chế này một cách chi tiết
hơn trong chương “Tính toán trong Học sâu”.
3.3.4. Khởi tạo Tham số Mô hình¶
Trước khi sử dụng net
, chúng ta cần phải khởi tạo tham số cho mô
hình, chẳng hạn như trọng số và hệ số điều chỉnh trong mô hình hồi quy
tuyến tính. Chúng ta sẽ nhập mô-đun initializer
từ MXNet. Mô-đun này
cung cấp nhiều phương thức khác nhau để khởi tạo tham số cho mô hình.
Gluon cho phép dùng init
như một cách ngắn gọn (viết tắt) để truy
cập đến gói initializer
. Bằng cách gọi init.Normal(sigma=0.01)
,
chúng ta sẽ khởi tạo ngẫu nhiên các trọng số từ một phân phối chuẩn
với trung bình bằng \(0\) và độ lệch chuẩn bằng \(0.01\). Mặc
định, tham số hệ số điều chỉnh sẽ được khởi tạo bằng không. Cả hai
vector trọng số và hệ số điều chỉnh sẽ có gradient kèm theo.
from mxnet import init
net.initialize(init.Normal(sigma=0.01))
Đoạn mã nguồn trên trông khá đơn giản nhưng bạn đọc hãy chú ý một vài điểm khác thường ở đây. Chúng ta khởi tạo các tham số cho một mạng mà thậm chí Gluon chưa hề biết số chiều của đầu vào là bao nhiêu! Nó có thể là \(2\) trong trường hợp của chúng ta nhưng cũng có thể là \(2000\). Gluon khiến chúng ta không cần bận tâm về điều này bởi ở hậu trường, quá trình khởi tạo thực sự vẫn đang bị trì hoãn. Quá trình khởi tạo thực sự chỉ bắt đầu khi chúng ta truyền dữ liệu vào mạng lần đầu tiên. Hãy ghi nhớ rằng, do các tham số chưa thực sự được khởi tạo, chúng ta không thể truy cập hoặc thao tác với chúng.
3.3.5. Định nghĩa Hàm mất mát¶
Trong Gluon, mô-đun loss
định nghĩa các hàm mất mát khác nhau. Chúng
ta sẽ sử dụng mô-đun loss
được thêm vào dưới tên gọi là gloss
,
để tránh nhầm lẫn nó với biến đang giữ hàm mất mát mà ta đã chọn. Trong
ví dụ này, chúng ta sẽ sử dụng triển khai trong Gluon của mất mát bình
phương (L2Loss
).
from mxnet.gluon import loss as gloss
loss = gloss.L2Loss() # The squared loss is also known as the L2 norm loss
3.3.6. Định nghĩa Thuật toán Tối ưu¶
Minibatch SGD và các biến thể liên quan đều là các công cụ chuẩn cho
việc tối ưu hóa mạng nơ-ron, vì vậy Gluon có hỗ trợ SGD cùng với một số
biến thể của thuật toán này thông qua lớp Trainer
. Khi khởi tạo lớp
Trainer
, ta cần chỉ định các tham số để tối ưu hóa (có thể lấy từ
mạng thông qua net.collect_params()
), thuật toán tối ưu muốn sử dụng
(sgd
) và một từ điển gồm các siêu tham số cần thiết cho thuật toán
tối ưu. SGD chỉ yêu cầu giá trị của learning_rate
, (ở đây chúng ta
đặt nó bằng 0.03).
from mxnet import gluon
trainer = gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': 0.03})
3.3.7. Huấn luyện¶
Bạn có thể thấy rằng việc biểu diễn mô hình thông qua Gluon đòi hỏi tương đối ít dòng lệnh. Chúng ta không cần phải khởi tạo từng tham số riêng lẻ, định nghĩa hàm mất mát hay lập trình thuật toán hạ gradient ngẫu nhiên. Lợi ích mà Gluon mang lại sẽ rất lớn khi chúng ta bắt đầu làm việc với những mô hình phức tạp hơn. Tuy nhiên, một khi ta có các mảnh ghép cơ bản, vòng lặp huấn luyện lại rất giống với những gì ta đã làm khi lập trình mọi thứ từ đầu.
Nhắc lại rằng: với số lượng epoch nhất định, trong mỗi epoch chúng ta sẽ
duyệt qua toàn bộ tập dữ liệu (train_data
), lần lượt lấy từng
minibatch chứa dữ liệu đầu vào và các nhãn gốc tương ứng. Đối với mỗi
minibatch, chúng ta cần tuân thủ theo trình tự sau:
- Đưa ra dự đoán bằng cách gọi
net(X)
và tính giá trị mất mátl
(lượt truyền xuôi). - Tính gradient bằng cách gọi
l.backward()
(lượt truyền ngược). - Cập nhật các tham số của mô hình bằng cách gọi bộ tối ưu SGD (chú ý
rằng
trainer
đã biết các tham số cần tối ưu, nên ta chỉ cần truyền thêm kích thước của minibatch).
Ngoài ra, ta tính giá trị mất mát sau mỗi epoch và in nó ra màn hình để giám sát tiến trình.
num_epochs = 3
for epoch in range(1, num_epochs + 1):
for X, y in data_iter:
with autograd.record():
l = loss(net(X), y)
l.backward()
trainer.step(batch_size)
l = loss(net(features), labels)
print('epoch %d, loss: %f' % (epoch, l.mean().asnumpy()))
epoch 1, loss: 0.024762
epoch 2, loss: 0.000089
epoch 3, loss: 0.000051
Dưới đây, ta so sánh các tham số của mô hình đã được học thông qua việc
huấn luyện trên tập dữ liệu hữu hạn với các tham số được dùng để tạo ra
tập dữ liệu. Để truy cập các tham số trong Gluon, trước hết ta truy cập
tầng ta quan tâm thông qua biến net
, sau đó truy cập trọng số
(weight
) và hệ số điều chỉnh (bias
) của tầng đó. Để truy cập giá
trị tham số dưới dạng một mảng ndarray
, ta sử dụng phương thức
data
. Giống với phiên bản lập trình từ đầu của chúng ta, các tham số
ước lượng có giá trị gần với giá trị chính xác của chúng.
w = net[0].weight.data()
print('Error in estimating w', true_w.reshape(w.shape) - w)
b = net[0].bias.data()
print('Error in estimating b', true_b - b)
Error in estimating w [[ 0.00061905 -0.00038671]]
Error in estimating b [0.00049639]
3.3.8. Tóm tắt¶
- Sử dụng Gluon giúp việc lập trình các mô hình trở nên ngắn gọn hơn rất nhiều.
- Trong Gluon, mô-đun
data
cung cấp các công cụ để xử lý dữ liệu, mô-đunnn
định nghĩa một lượng lớn các tầng cho mạng nơ-ron, và mô-đunloss
cho phép ta thiết lập nhiều hàm mất mát phổ biến. - Mô-đun
initializer
của MXNet cung cấp nhiều phương thức khác nhau để khởi tạo tham số cho mô hình. - Kích thước và dung lượng lưu trữ của các tham số sẽ được suy ra một cách tự động (nhưng nên cẩn thận tránh truy cập các tham số trước khi chúng được khởi tạo).
3.3.9. Bài tập¶
- Nếu thay thế
l = loss(output, y)
bằngl = loss(output, y).mean()
, chúng ta cần đổitrainer.step(batch_size)
thànhtrainer.step(1)
để phần mã nguồn này hoạt động giống như trước. Tại sao lại thế? - Xem lại tài liệu về MXNet để biết các hàm mất mát và các phương thức
khởi tạo được cung cấp trong hai mô-đun
gluon.loss
vàinit
. Hãy thay thế hàm mất mát đang sử dụng bằng hàm mất mát Huber (Huber loss). - Làm thế nào để truy cập gradient của
dense.weight
?
3.3.10. Thảo luận¶
3.3.11. 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
- Trần Thị Hồng Hạnh
- Phạm Hồng Vinh
- Vũ Hữu Tiệp
- Lý Phi Long
- Phạm Đăng Khoa
- Lê Khắc Hồng Phúc
- Dương Nhật Tân
- Nguyễn Văn Tâm
- Bùi Nhật Quân
- Nguyễn Mai Hoàng Long