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à featureslabels. 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\).

../_images/singleneuron.svg

Fig. 3.3.1 Hồi quy tuyến tính là một mạng nơ-ron đơn tầng.

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át l (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ô-đun nn định nghĩa một lượng lớn các tầng cho mạng nơ-ron, và mô-đun loss 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

  1. Nếu thay thế l = loss(output, y) bằng l = loss(output, y).mean(), chúng ta cần đổi trainer.step(batch_size) thành trainer.step(1) để phần mã nguồn này hoạt động giống như trước. Tại sao lại thế?
  2. 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.lossinit. 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).
  3. 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