3.6. Lập trình Hồi quy Sofmax từ đầu¶
Giống như khi ta lập trình hồi quy tuyến tính từ đầu, hồi quy (softmax) logistic đa lớp cũng là một kĩ thuật căn bản mà bạn nên hiểu biết tường tận các chi tiết để có thể tự xây dựng lại. Sau khi tự lập trình lại mọi thứ thì ta cũng sẽ dùng Gluon để so sánh như ở phần hồi quy tuyến tính. Để bắt đầu, chúng ta nhập các thư viện quen thuộc vào.
from d2l import mxnet as d2l
from mxnet import autograd, np, npx, gluon
from IPython import display
npx.set_np()
Ta sẽ làm việc trên tập dữ liệu Fashion-MNIST, vừa được giới thiệu trong Section 3.5, thiết lập một iterator với kích thước batch là \(256\).
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
3.6.1. Khởi tạo các Tham số của Mô hình¶
Giống như ví dụ về hồi quy tuyến tính, mỗi mẫu sẽ được biểu diễn bằng một vector có chiều dài cố định. Mỗi mẫu trong tập dữ liệu thô là một ảnh \(28 \times 28\). Trong phần này, chúng ta sẽ trải phẳng mỗi tấm ảnh thành một vector có kích thước là \(784\). Sau này ta sẽ bàn về các chiến lược công phu hơn có khả năng khai thác cấu trúc không gian của ảnh, còn bây giờ ta hãy xem mỗi điểm ảnh là một đặc trưng.
Nhắc lại trong hồi quy softmax, mỗi lớp sẽ có một đầu ra. Vì tập dữ liệu của chúng ta có \(10\) lớp, mạng của chúng ta sẽ có \(10\) đầu ra. Do đó, các trọng số sẽ tạo thành một ma trận \(784 \times 10\) và các hệ số điều chỉnh sẽ tạo thành một vector \(1 \times 10\). Cũng như hồi quy tuyến tính, ta sẽ khởi tạo các trọng số \(W\) bằng nhiễu Gauss và các hệ số điều chỉnh sẽ được khởi tạo bằng \(0\).
num_inputs = 784
num_outputs = 10
W = np.random.normal(0, 0.01, (num_inputs, num_outputs))
b = np.zeros(num_outputs)
Hãy nhớ rằng ta cần đính kèm gradient vào các tham số của mô hình. Cụ thể hơn, ta cho MXNet biết rằng ta sẽ muốn tính các gradient theo các tham số này và cần phân bổ bộ nhớ để lưu trữ chúng trong tương lai.
W.attach_grad()
b.attach_grad()
3.6.2. Softmax¶
Trước khi xây dựng mô hình hồi quy softmax, hãy ôn nhanh tác dụng của
các toán tử như sum
trên những chiều cụ thể của một ndarray
. Cho
một ma trận X
, chúng ta có thể tính tổng tất cả các phần tử (mặc
định) hoặc chỉ trên các phần tử trong cùng một trục, ví dụ, cột
(axis=0
) hoặc cùng một hàng (axis=1
). Lưu ý rằng nếu X
là
một mảng có kích thước (2, 3)
, tính tổng các cột
(X.sum (axis=0)
) sẽ trả về một vector (một chiều) có kích thước là
(3,)
. Nếu chúng ta muốn giữ số lượng trục giống với mảng ban đầu
thay vì thu gọn kích thước trên các trục đã tính toán (dẫn đến một mảng
2 chiều có kích thước (1, 3)
), ta có thể chỉ định keepdims=True
khi gọi hàm sum
.
X = np.array([[1, 2, 3], [4, 5, 6]])
print(X.sum(axis=0, keepdims=True), '\n', X.sum(axis=1, keepdims=True))
[[5. 7. 9.]]
[[ 6.]
[15.]]
Bây giờ chúng ta đã có thể bắt đầu xây dựng hàm softmax. Lưu ý rằng việc
thực thi hàm softmax bao gồm hai bước: Đầu tiên, chúng ta lũy thừa từng
giá trị (sử dụng exp
). Sau đó tính tổng trên mỗi hàng để lấy các
hằng số chuẩn hóa cho mỗi mẫu (vì mỗi mẫu là một hàng). Cuối cùng, chia
mỗi hàng theo hằng số chuẩn hóa của nó để đảm bảo rằng kết quả có tổng
bằng \(1\). Trước khi xem đoạn mã, chúng ta hãy nhớ lại các bước này
được thể hiện trong phương trình sau:
Mẫu số hoặc hằng số chuẩn hóa đôi khi cũng được gọi là hàm phân hoạch (partition function) và logarit của nó được gọi là hàm log phân hoạch (log-partition function). Tên gốc của hàm được định nghĩa trong cơ học thống kê với phương trình liên quan đến mô hình hóa phân phối trên một tập hợp các phần tử.
def softmax(X):
X_exp = np.exp(X)
partition = X_exp.sum(axis=1, keepdims=True)
return X_exp / partition # The broadcast mechanism is applied here
Có thể thấy rằng với bất kỳ đầu vào ngẫu nhiên nào thì mỗi phần tử đều được biến đổi thành một số không âm. Hơn nữa, mỗi hàng đều có tổng là 1 nên thoả mãn yêu cầu làm một giá trị xác suất. Chú ý rằng đoạn mã trên tuy đúng về mặt toán học nhưng chúng tôi đã hơi cẩu thả về mặt lập trình vì đã không kiểm tra vấn đề tràn số trên và dưới gây ra bởi các giá trị vô cùng lớn hoặc vô cùng nhỏ trong ma trận, không giống với cách đã thực hiện tại Section 18.9.
X = np.random.normal(size=(2, 5))
X_prob = softmax(X)
X_prob, X_prob.sum(axis=1)
(array([[0.22376052, 0.06659239, 0.06583703, 0.29964197, 0.3441681 ],
[0.63209665, 0.03179282, 0.194987 , 0.09209415, 0.04902935]]),
array([1. , 0.99999994]))
3.6.3. Mô hình¶
Sau khi đã định nghĩa hàm softmax, chúng ta có thể bắt đầu lập trình mô
hình hồi quy softmax. Đoạn mã sau định nghĩa lượt truyền xuôi qua mạng.
Chú ý rằng chúng ta sẽ làm phẳng mỗi ảnh gốc trên batch thành một vector
có độ dài num_inputs
bằng hàm reshape
, trước khi truyền dữ liệu
sang mô hình đã khởi tạo.
def net(X):
return softmax(np.dot(X.reshape(-1, num_inputs), W) + b)
3.6.4. Hàm mất mát¶
Tiếp đến chúng ta cần lập trình hàm mất mát entropy chéo đã được giới thiệu ở Section 3.4. Đây có lẽ là hàm mất mát thông dụng nhất trong phần lớn nghiên cứu về học sâu vì hiện nay số lượng bài toán phân loại đã vượt xa hơn số lượng bài toán hồi quy.
Nhắc lại rằng hàm entropy chéo lấy đầu vào là đối log hợp lý của xác
suất dự đoán được gán cho nhãn thật \(-\log P(y \mid x)\). Thay vì
lặp qua các dự đoán của mô hình bằng vòng lặp for
trong Python (có
xu hướng kém hiệu quả), chúng ta có thể sử dụng hàm pick
để dễ dàng
chọn các phần tử thích hợp từ ma trận của các giá trị softmax. Dưới đây,
ta minh hoạ cách sử dụng hàm pick
trong một ví dụ đơn giản với ma
trận có \(3\) lớp và \(2\) mẫu.
y_hat = np.array([[0.1, 0.3, 0.6], [0.3, 0.2, 0.5]])
y_hat[[0, 1], [0, 2]]
array([0.1, 0.5])
Bây giờ chúng ta có thể lập trình hàm mất mát entropy chéo hiệu quả hơn chỉ với một dòng lệnh.
def cross_entropy(y_hat, y):
return - np.log(y_hat[range(len(y_hat)), y])
3.6.5. Độ chính xác cho bài toán Phân loại¶
Với phân phối xác suất dự đoán y_hat
, ta thường chọn lớp có xác suất
dự đoán cao nhất khi phải đưa ra một dự đoán cụ thể vì nhiều ứng dụng
trong thực tế có yêu cầu như vậy. Ví dụ, Gmail cần phải phân loại một
email vào trong các danh mục sau: Email chính (Primary), Mạng xã hội
(Social), Nội dung cập nhật (Updates) hoặc Diễn đàn (Forums). Có thể các
xác suất được tính toán bên trong nội bộ hệ thống, nhưng cuối cùng kết
quả vẫn chỉ là một trong các danh mục.
Các dự đoán được coi là chính xác khi chúng khớp với lớp thực tế y
.
Độ chính xác phân loại được tính bởi tỉ lệ các dự đoán chính xác trên
tất cả các dự đoán đã đưa ra. Dù ta có thể gặp khó khăn khi tối ưu hóa
trực tiếp độ chính xác (chúng không khả vi), đây thường là phép đo chất
lượng được quan tâm nhiều nhất và sẽ luôn được tính khi huấn luyện các
bộ phân loại.
Độ chính xác được tính toán như sau: Đầu tiên, dùng lệnh
y_hat.argmax(axis=1)
nhằm lấy ra các lớp được dự đoán (được cho bởi
chỉ số của phần tử lớn nhất trên mỗi hàng). Kết quả trả về sẽ có cùng
kích thước với biến y
và bây giờ ta chỉ cần so sánh hai vector này.
Vì toán tử ==
so khớp cả kiểu dữ liệu của biến (ví dụ một biến
int
và một biến float32
không thể bằng nhau), ta cần đưa chúng
về cùng một kiểu dữ liệu (ở đây ta chọn kiểu float32
). Kết quả sẽ là
một ndarray
chứa các giá trị 0 (false) và 1 (true), giá trị trung
bình này mang lại kết quả mà ta mong muốn.
# Saved in the d2l package for later use
def accuracy(y_hat, y):
if y_hat.shape[1] > 1:
return float((y_hat.argmax(axis=1) == y.astype('float32')).sum())
else:
return float((y_hat.astype('int32') == y.astype('int32')).sum())
Ta sẽ tiếp tục sử dụng biến y_hat
và y
đã được định nghĩa trong
hàm pick
, lần lượt tương ứng với phân phối xác suất được dự đoán và
nhãn. Có thể thấy rằng kết quả dự đoán lớp từ ví dụ đầu tiên là
\(2\) (phần từ lớn nhất trong hàng là \(0.6\) với chỉ số tương
ứng là \(2\)) không khớp với nhãn thực tế là \(0\). Dự đoán lớp
ở ví dụ thứ hai là \(2\) (phần tử lớn nhất hàng là \(0.5\) với
chỉ số tương ứng là \(2\)) khớp với nhãn thực tế là \(2\). Do
đó, ta có độ chính xác phân loại cho hai ví dụ này là \(0.5\).
y = np.array([0, 2])
accuracy(y_hat, y) / len(y)
0.5
Tương tự như trên, ta có thể đánh giá độ chính xác của mô hình net
trên tập dữ liệu (được truy xuất thông qua data_iter
).
# Saved in the d2l package for later use
def evaluate_accuracy(net, data_iter):
metric = Accumulator(2) # num_corrected_examples, num_examples
for X, y in data_iter:
metric.add(accuracy(net(X), y), y.size)
return metric[0] / metric[1]
Accumulator
ở đây là một lớp đa tiện ích, có tác dụng tính tổng tích
lũy của nhiều số.
# Saved in the d2l package for later use
class Accumulator(object):
"""Sum a list of numbers over time."""
def __init__(self, n):
self.data = [0.0] * n
def add(self, *args):
self.data = [a+float(b) for a, b in zip(self.data, args)]
def reset(self):
self.data = [0] * len(self.data)
def __getitem__(self, i):
return self.data[i]
Vì ta đã khởi tạo mô hình net
với trọng số ngẫu nhiên nên độ chính
xác của mô hình lúc này sẽ gần với việc phỏng đoán ngẫu nhiên, tức độ
chính xác bằng \(0.1\) với \(10\) lớp.
evaluate_accuracy(net, test_iter)
0.0811
3.6.6. Huấn luyện Mô hình¶
Vòng lặp huấn luyện cho hồi quy softmax trông khá quen thuộc nếu bạn đã
xem cách lập trình cho hồi quy tuyến tính tại
Section 3.2. Ở đây, chúng ta tái cấu trúc
(refactor) lại đoạn mã để sau này có thể tái sử dụng. Đầu tiên, chúng
ta định nghĩa một hàm để huấn luyện với một epoch dữ liệu. Lưu ý rằng
updater
là một hàm tổng quát để cập nhật các tham số của mô hình và
sẽ nhận kích thước batch làm đối số. Nó có thể là một wrapper của
d2l.sgd
hoặc là một đối tượng huấn luyện Gluon.
# Saved in the d2l package for later use
def train_epoch_ch3(net, train_iter, loss, updater):
metric = Accumulator(3) # train_loss_sum, train_acc_sum, num_examples
if isinstance(updater, gluon.Trainer):
updater = updater.step
for X, y in train_iter:
# Compute gradients and update parameters
with autograd.record():
y_hat = net(X)
l = loss(y_hat, y)
l.backward()
updater(X.shape[0])
metric.add(float(l.sum()), accuracy(y_hat, y), y.size)
# Return training loss and training accuracy
return metric[0]/metric[2], metric[1]/metric[2]
Trước khi xem đoạn mã thực hiện hàm huấn luyện, ta định nghĩa một lớp phụ trợ để minh họa dữ liệu. Mục đích của nó là đơn giản hoá các đoạn mã sẽ xuất hiện trong những chương sau.
# Saved in the d2l package for later use
class Animator(object):
def __init__(self, xlabel=None, ylabel=None, legend=[], xlim=None,
ylim=None, xscale='linear', yscale='linear', fmts=None,
nrows=1, ncols=1, figsize=(3.5, 2.5)):
"""Incrementally plot multiple lines."""
d2l.use_svg_display()
self.fig, self.axes = d2l.plt.subplots(nrows, ncols, figsize=figsize)
if nrows * ncols == 1:
self.axes = [self.axes, ]
# Use a lambda to capture arguments
self.config_axes = lambda: d2l.set_axes(
self.axes[0], xlabel, ylabel, xlim, ylim, xscale, yscale, legend)
self.X, self.Y, self.fmts = None, None, fmts
def add(self, x, y):
"""Add multiple data points into the figure."""
if not hasattr(y, "__len__"):
y = [y]
n = len(y)
if not hasattr(x, "__len__"):
x = [x] * n
if not self.X:
self.X = [[] for _ in range(n)]
if not self.Y:
self.Y = [[] for _ in range(n)]
if not self.fmts:
self.fmts = ['-'] * n
for i, (a, b) in enumerate(zip(x, y)):
if a is not None and b is not None:
self.X[i].append(a)
self.Y[i].append(b)
self.axes[0].cla()
for x, y, fmt in zip(self.X, self.Y, self.fmts):
self.axes[0].plot(x, y, fmt)
self.config_axes()
display.display(self.fig)
display.clear_output(wait=True)
Hàm huấn luyện sau đó sẽ chạy qua nhiều epoch và trực quan hoá quá trình huấn luyện.
# Saved in the d2l package for later use
def train_ch3(net, train_iter, test_iter, loss, num_epochs, updater):
animator = Animator(xlabel='epoch', xlim=[1, num_epochs],
ylim=[0.3, 0.9],
legend=['train loss', 'train acc', 'test acc'])
for epoch in range(num_epochs):
train_metrics = train_epoch_ch3(net, train_iter, loss, updater)
test_acc = evaluate_accuracy(net, test_iter)
animator.add(epoch+1, train_metrics+(test_acc,))
Nhắc lại, chúng ta sử dụng giải thuật hạ gradient ngẫu nhiên theo
minibatch để tối ưu hàm mất mát của mô hình. Lưu ý rằng số lượng epoch
(num_epochs
), và hệ số học (lr
) là hai siêu tham số được hiệu
chỉnh. Bằng cách thay đổi các giá trị này, chúng ta có thể tăng độ chính
xác khi phân loại của mô hình. Trong thực tế, chúng ta sẽ chia dữ liệu
của mình theo ba hướng tiếp cận khác nhau gồm: dữ liệu huấn luyện, dữ
liệu kiểm định và dữ liệu kiểm tra; sử dụng dữ liệu kiểm định để chọn ra
những giá trị tốt nhất cho các siêu tham số.
num_epochs, lr = 10, 0.1
def updater(batch_size):
return d2l.sgd([W, b], lr, batch_size)
train_ch3(net, train_iter, test_iter, cross_entropy, num_epochs, updater)
3.6.7. Dự đoán¶
Giờ thì việc huấn luyện đã hoàn thành, mô hình của chúng ta đã sẵn sàng để phân loại ảnh. Cho một loạt các ảnh, chúng ta sẽ so sánh các nhãn thật của chúng (dòng đầu tiên của văn bản đầu ra) với những dự đoán của mô hình (dòng thứ hai của văn bản đầu ra).
# Saved in the d2l package for later use
def predict_ch3(net, test_iter, n=6):
for X, y in test_iter:
break
trues = d2l.get_fashion_mnist_labels(y)
preds = d2l.get_fashion_mnist_labels(net(X).argmax(axis=1))
titles = [true+'\n' + pred for true, pred in zip(trues, preds)]
d2l.show_images(X[0:n].reshape(n, 28, 28), 1, n, titles=titles[0:n])
predict_ch3(net, test_iter)
3.6.8. Tóm tắt¶
Với hồi quy softmax, chúng ta có thể huấn luyện các mô hình cho bài toán phân loại đa lớp. Vòng lặp huấn luyện rất giống với vòng lặp huấn luyện của hồi quy tuyến tính: truy xuất và đọc dữ liệu, định nghĩa mô hình và hàm mất mát, và rồi huấn luyện mô hình sử dụng các giải thuật tối ưu. Rồi bạn sẽ sớm thấy rằng hầu hết các mô hình học sâu phổ biến đều có quy trình huấn luyện tương tự như vậy.
3.6.9. Bài tập¶
- Trong mục này, chúng ta đã lập trình hàm softmax dựa vào định nghĩa toán học của phép toán softmax. Điều này có thể gây ra những vấn đề gì (gợi ý: thử tính \(\exp(50)\))?
- Hàm
cross_entropy
trong mục này được lập trình dựa vào định nghĩa của hàm mất mát entropy chéo. Vấn đề gì có thể xảy ra với cách lập trình như vậy (gợi ý: xem xét miền của hàm log)? - Bạn có thể tìm ra giải pháp cho hai vấn đề trên không?
- Việc trả về nhãn có khả năng nhất có phải lúc nào cũng là ý tưởng tốt không? Ví dụ, bạn có dùng phương pháp này cho chẩn đoán bệnh hay không?
- Giả sử rằng chúng ta muốn sử dụng hồi quy softmax để dự đoán từ tiếp theo dựa vào một số đặc trưng. Những vấn đề gì có thể xảy ra nếu dùng một tập từ vựng lớn?
3.6.10. Thảo luận¶
3.6.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
- Bùi Nhật Quân
- Lý Phi Long
- Phạm Hồng Vinh
- Lâm Ngọc Tâm
- Vũ Hữu Tiệp
- Lê Khắc Hồng Phúc
- Nguyễn Cảnh Thướng
- Phạm Minh Đức
- Nguyễn Quang Hải
- Đinh Minh Tân
- Lê Cao Thăng