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:

(3.6.1)\[\mathrm{softmax}(\mathbf{X})_{ij} = \frac{\exp(X_{ij})}{\sum_k \exp(X_{ik})}.\]

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_haty đã đượ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)
../_images/output_softmax-regression-scratch_vn_6ac99c_37_0.svg

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)
../_images/output_softmax-regression-scratch_vn_6ac99c_39_0.svg

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

  1. 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)\))?
  2. 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)?
  3. Bạn có thể tìm ra giải pháp cho hai vấn đề trên không?
  4. 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?
  5. 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