3.2. Lập trình Hồi quy Tuyến tính từ đầu

Bây giờ bạn đã hiểu được điểm mấu chốt đằng sau thuật toán hồi quy tuyến tính, chúng ta đã có thể bắt đầu thực hành viết mã. Trong phần này, ta sẽ xây dựng lại toàn bộ kĩ thuật này từ đầu, bao gồm: pipeline dữ liệu, mô hình, hàm mất mát và phương pháp tối ưu hạ gradient. Vì các framework học sâu hiện đại có thể tự động hóa gần như tất cả các công đoạn ở trên, việc lập trình mọi thứ từ đầu chỉ để đảm bảo bạn biết rõ mình đang làm gì. Hơn nữa, việc hiểu rõ cách mọi thứ hoạt động sẽ giúp ta rất nhiều trong những lúc cần tùy chỉnh các mô hình, tự định nghĩa lại các tầng tính toán hay các hàm mất mát, v.v. Trong phần này, chúng ta chỉ dựa vào ndarrayautograd. Sau đó, chúng tôi sẽ giới thiệu một phương pháp triển khai chặt chẽ hơn, tận dụng các tính năng tuyệt vời của Gluon. Để bắt đầu, chúng ta cần khai báo một vài gói thư viện cần thiết.

%matplotlib inline
from d2l import mxnet as d2l
from mxnet import autograd, np, npx
import random
npx.set_np()

3.2.1. Tạo tập dữ liệu

Để giữ cho mọi thứ đơn giản, chúng ta sẽ xây dựng một tập dữ liệu nhân tạo theo một mô hình tuyến tính với nhiễu cộng. Nhiệm vụ của chúng ta là khôi phục các tham số của mô hình này bằng cách sử dụng một tập hợp hữu hạn các mẫu có trong tập dữ liệu đó. Chúng ta sẽ sử dụng dữ liệu ít chiều để thuận tiện cho việc minh họa. Trong đoạn mã sau, chúng ta đã tạo một tập dữ liệu chứa \(1000\) mẫu, mỗi mẫu bao gồm \(2\) đặc trưng được lấy ngẫu nhiên theo phân phối chuẩn. Do đó, tập dữ liệu tổng hợp của chúng ta sẽ là một đối tượng \(\mathbf{X}\in \mathbb{R}^{1000 \times 2}\).

Các tham số đúng để tạo tập dữ liệu sẽ là \(\mathbf{w} = [2, -3.4]^\top\)\(b = 4.2\) và nhãn sẽ được tạo ra dựa theo mô hình tuyến tính với nhiễu \(\epsilon\):

(3.2.1)\[\mathbf{y}= \mathbf{X} \mathbf{w} + b + \mathbf\epsilon.\]

Bạn đọc có thể xem \(\epsilon\) như là sai số tiềm ẩn của phép đo trên các đặc trưng và các nhãn. Chúng ta sẽ mặc định các giả định tiêu chuẩn đều thỏa mãn và vì thế \(\epsilon\) tuân theo phân phối chuẩn với trung bình bằng \(0\). Để đơn giản, ta sẽ thiết lập độ lệch chuẩn của nó bằng \(0.01\). Đoạn mã nguồn sau sẽ tạo ra tập dữ liệu tổng hợp:

# Saved in the d2l package for later use
def synthetic_data(w, b, num_examples):
    """Generate y = X w + b + noise."""
    X = np.random.normal(0, 1, (num_examples, len(w)))
    y = np.dot(X, w) + b
    y += np.random.normal(0, 0.01, y.shape)
    return X, y

true_w = np.array([2, -3.4])
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)

Lưu ý rằng mỗi hàng trong features chứa một điểm dữ liệu hai chiều và mỗi hàng trong labels chứa một giá trị mục tiêu một chiều (một số vô hướng).

print('features:', features[0],'\nlabel:', labels[0])
features: [2.2122064 1.1630787]
label: 4.662078

Bằng cách vẽ đồ thị phân tán với chiều thứ hai features[:, 1]labels, ta có thể quan sát rõ mối tương quan tuyến tính giữa chúng.

d2l.set_figsize((3.5, 2.5))
d2l.plt.scatter(features[:, 1].asnumpy(), labels.asnumpy(), 1);
../_images/output_linear-regression-scratch_vn_279c44_7_0.svg

3.2.2. Đọc từ Tập dữ liệu

Nhắc lại rằng việc huấn luyện mô hình bao gồm tách tập dữ liệu thành nhiều phần (các mininbatch), lần lượt đọc từng phần của tập dữ liệu mẫu, và sử dụng chúng để cập nhật mô hình của chúng ta. Vì quá trình này rất căn bản để huấn luyện các giải thuật học máy, ta nên định nghĩa một hàm để trộn và truy xuất dữ liệu trong các minibatch một cách tiện lợi.

Ở đoạn mã dưới đây, chúng ta định nghĩa hàm data_iter để minh hoạ cho một cách lập trình chức năng này. Hàm này lấy kích thước một batch, một ma trận đặc trưng và một vector các nhãn rồi sinh ra các minibatch có kích thước batch_size. Mỗi minibatch gồm một tuple các đặc trưng và nhãn.

def data_iter(batch_size, features, labels):
    num_examples = len(features)
    indices = list(range(num_examples))
    # The examples are read at random, in no particular order
    random.shuffle(indices)
    for i in range(0, num_examples, batch_size):
        batch_indices = np.array(
            indices[i: min(i + batch_size, num_examples)])
        yield features[batch_indices], labels[batch_indices]

Lưu ý rằng thông thường chúng ta muốn dùng các minibatch có kích thước phù hợp để tận dụng tài nguyên phần cứng GPU để xử lý song song hiệu quả nhất. Vì mỗi mẫu có thể được mô hình xử lý và tính đạo hàm riêng của hàm mất mát song song với nhau, GPU cho phép xử lý hàng trăm mẫu cùng lúc mà chỉ tốn thời gian hơn một chút so với xử lý một mẫu duy nhất.

Để hiểu hơn, chúng ta hãy chạy đoạn chương trình để đọc và in ra batch đầu tiên của mẫu dữ liệu. Kích thước của các đặc trưng trong mỗi minibatch cho ta biết kích thước của batch lẫn số lượng của các đặc trưng đầu vào. Tương tự, tập minibatch của các nhãn sẽ có kích thước theo batch_size.

batch_size = 10

for X, y in data_iter(batch_size, features, labels):
    print(X, '\n', y)
    break
[[-0.32445922 -0.91774726]
 [ 0.681858    1.3600351 ]
 [ 1.5702168   1.11278   ]
 [-0.05733065 -0.19965324]
 [ 1.2688328  -1.3046491 ]
 [ 0.71321625  0.85190713]
 [-0.21464506  0.6525396 ]
 [-0.02261727  0.41016445]
 [ 0.69248587 -0.26833388]
 [-0.5309896  -0.9410424 ]]
 [ 6.6650786  0.9486723  3.5531938  4.7575097 11.158839   2.7378514
  1.5480783  2.7589145  6.4890523  6.3573055]

Khi chạy iterator, ta lấy từng minibatch riêng biệt cho đến khi lấy hết bộ dữ liệu (bạn hãy xử xem). Mặc dù sử dụng iterator như trên phục vụ tốt cho công tác giảng dạy, nó lại không phải là cách hiệu quả và có thể khiến chúng ta gặp nhiều rắc rối trong thực tế. Chẳng hạn, nó buộc ta phải nạp toàn bộ dữ liệu vào bộ nhớ và tốn rất nhiều thao tác truy cập bộ nhớ ngẫu nhiên. Các iterator trong Apache MXNet lại khá hiệu quả khi chúng có thể xử lý cả dữ liệu được lưu trữ trên tập tin lẫn trong các luồng dữ liệu.

3.2.3. Khởi tạo các Tham số Mô hình

Để tối ưu các tham số của dữ liệu bằng hạ gradient, đầu tiên ta cần khởi tạo chúng. Trong đoạn mã dưới đây, ta khởi tạo các trọng số bằng cách lấy ngẫu nhiên các mẫu từ một phân phối chuẩn với giá trị trung bình bằng 0 và độ lệch chuẩn là \(0.01\), sau đó gán hệ số điều chỉnh \(b\) bằng \(0\).

w = np.random.normal(0, 0.01, (2, 1))
b = np.zeros(1)

Sau khi khởi tạo các tham số, bước tiếp theo là cập nhật chúng cho đến khi chúng ăn khớp với dữ liệu của ta đủ tốt. Mỗi lần cập nhật, ta tính gradient (đạo hàm nhiều biến) của hàm mất mát theo các tham số. Với gradient này, chúng ta có thể cập nhật mỗi tham số theo hướng giảm dần giá trị mất mát.

Vì không ai muốn tính gradient bằng tay (một việc rất nhàm chán và dễ sai sót), ta dùng chương trình để tính tự động gradient (autograd). Xem Section 2.5 để biết thêm chi tiết. Nhắc lại từ mục tính vi phân tự động, để chỉ định hàm autograd lưu gradient của các biến số, ta cần gọi hàm attach_grad, cấp phát bộ nhớ để lưu giá trị gradient mong muốn.

w.attach_grad()
b.attach_grad()

3.2.4. Định nghĩa Mô hình

Tiếp theo, chúng ta cần định nghĩa mô hình dựa trên đầu vào và tham số liên quan tới đầu ra. Nhắc lại rằng để tính đầu ra của một mô hình tuyến tính, ta có thể đơn giản tính tích vô hướng ma trận-vector của các mẫu \(\mathbf{X}\) và trọng số mô hình \(w\), sau đó thêm vào hệ số điều chỉnh \(b\) cho từng mẫu. Ở đây, np.dot(X, w) là một vector trong khi b là một số vô hướng. Nhắc lại rằng khi tính tổng vector và số vô hướng, thì số vô hướng sẽ được cộng vào từng phần tử của vector.

# Saved in the d2l package for later use
def linreg(X, w, b):
    return np.dot(X, w) + b

3.2.5. Định nghĩa Hàm Mất mát

Để cập nhật mô hình ta phải tính gradient của hàm mất mát, vậy nên ta cần định nghĩa hàm mất mát trước tiên. Chúng ta sẽ sử dụng hàm mất mát bình phương (SE) như đã trình bày ở phần trước đó. Trên thực tế, chúng ta cần chuyển đổi giá trị nhãn thật y sang kích thước của giá trị dự đoán y_hat. Hàm dưới đây sẽ trả về kết quả có kích thước tương đương với kích thước của y_hat.

# Saved in the d2l package for later use
def squared_loss(y_hat, y):
    return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

3.2.6. Định nghĩa Thuật toán Tối ưu

Như đã thảo luận ở mục trước, hồi quy tuyến tính có một nghiệm dạng đóng. Tuy nhiên, đây không phải là một cuốn sách về hồi quy tuyến tính, mà là về Học sâu. Vì không một mô hình nào khác được trình bày trong cuốn sách này có thể giải được bằng phương pháp phân tích, chúng tôi sẽ nhân cơ hội này để giới thiệu với các bạn ví dụ đầu tiên về hạ gradient ngẫu nhiên (stochastic gradient descent – SGD).

Sử dụng một batch được lấy ngẫu nhiên từ tập dữ liệu tại mỗi bước, chúng ta sẽ ước tính được gradient của mất mát theo các tham số. Tiếp đó, ta sẽ cập nhật các tham số (với một lượng nhỏ) theo chiều hướng làm giảm sự mất mát. Nhớ lại từ Section 2.5 rằng sau khi chúng ta gọi backward, mỗi tham số (param) sẽ có gradient của nó lưu ở param.grad. Đoạn mã sau áp dụng cho việc cập nhật SGD, đưa ra một bộ các tham số, tốc độ học và kích thước batch. Kích thước của bước cập nhật được xác định bởi tốc độ học lr. Bởi vì các mất mát được tính dựa trên tổng các mẫu của batch, ta chuẩn hóa kích thước bước cập nhật theo kích thước batch (batch_size), sao cho độ lớn của một bước cập nhật thông thường không phụ thuộc nhiều vào kích thước batch.

# Saved in the d2l package for later use
def sgd(params, lr, batch_size):
    for param in params:
        param[:] = param - lr * param.grad / batch_size

3.2.7. Huấn luyện

Bây giờ, sau khi đã có tất cả các thành phần, chúng ta đã sẵn sàng để viết vòng lặp huấn luyện. Quan trọng nhất là bạn phải hiểu được rõ đoạn mã này bởi vì việc huấn luyện gần như tương tự thế này sẽ được lặp lại nhiều lần trong suốt qua trình chúng ta tìm hiểu và lập trình các thuật toán học sâu.

Trong mỗi vòng lặp, đầu tiên chúng ta sẽ lấy ra các minibatch dữ liệu và chạy nó qua mô hình để lấy ra tập kết quả dự đoán. Sau khi tính toán sự mất mát, chúng ta dùng hàm backward để bắt đầu lan truyền ngược qua mạng lưới, lưu trữ các gradient tương ứng với mỗi tham số trong từng thuộc tính .grad của chúng. Cuối cùng, chúng ta sẽ dùng thuật toán tối ưu sgd để cập nhật các tham số của mô hình. Từ đầu chúng ta đã đặt kích thước batch batch_size\(10\), vậy nên mất mát l cho mỗi minibatch có kích thước là (\(10\), \(1\)).

Tóm lại, chúng ta sẽ thực thi vòng lặp sau:

  • Khởi tạo bộ tham số \((\mathbf{w}, b)\)
  • Lặp lại cho tới khi hoàn thành
    • Tính gradient \(\mathbf{g} \leftarrow \partial_{(\mathbf{w},b)} \frac{1}{\mathcal{B}} \sum_{i \in \mathcal{B}} l(\mathbf{x}^i, y^i, \mathbf{w}, b)\)
    • Cập nhật bộ tham số \((\mathbf{w}, b) \leftarrow (\mathbf{w}, b) - \eta \mathbf{g}\)

Trong đoạn mã dưới đây, l là một vector của các mất mát của từng mẫu trong minibatch. Vì l không phải là biến vô hướng, chạy l.backward() sẽ cộng các phần tử trong l để tạo ra một biến mới và sau đó mới tính gradient.

Với mỗi epoch, chúng ta sẽ lặp qua toàn bộ tập dữ liệu (sử dụng hàm data_iter) cho đến khi đi qua toàn bộ mọi mẫu trong tập huấn luyện (giả định rằng số mẫu chia hết cho kích thước batch). Số epoch num_epochs và tốc độ học lr đều là siêu tham số, mà chúng ta đặt ở đây là tương ứng \(3\)\(0.03\). Không may thay, việc lựa chọn siêu tham số thường không đơn giản và đòi hỏi sự điều chỉnh qua nhiều lần thử và sai. Hiện tại chúng ta sẽ bỏ qua những chi tiết này nhưng sẽ bàn lại về chúng tại chương Section 11.

lr = 0.03  # Learning rate
num_epochs = 3  # Number of iterations
net = linreg  # Our fancy linear model
loss = squared_loss  # 0.5 (y-y')^2

for epoch in range(num_epochs):
    # Assuming the number of examples can be divided by the batch size, all
    # the examples in the training dataset are used once in one epoch
    # iteration. The features and tags of minibatch examples are given by X
    # and y respectively
    for X, y in data_iter(batch_size, features, labels):
        with autograd.record():
            l = loss(net(X, w, b), y)  # Minibatch loss in X and y
        l.backward()  # Compute gradient on l with respect to [w, b]
        sgd([w, b], lr, batch_size)  # Update parameters using their gradient
    train_l = loss(net(features, w, b), labels)
    print('epoch %d, loss %f' % (epoch + 1, train_l.mean().asnumpy()))
epoch 1, loss 0.025013
epoch 2, loss 0.000090
epoch 3, loss 0.000051

Trong trường hợp này, bởi vì chúng ta tự tổng hợp dữ liệu nên đã biết chính xác giá trị đúng của các tham số. Vì vậy, chúng ta có thể đánh giá sự thành công của việc huấn luyện bằng cách so sánh giá trị đúng của các tham số với những giá trị học được thông qua quá trình huấn luyện. Quả thật giá trị các tham số học được và giá trị chính xác của các tham số rất gần với nhau.

print('Error in estimating w', true_w - w.reshape(true_w.shape))
print('Error in estimating b', true_b - b)
Error in estimating w [ 0.0001483  -0.00020313]
Error in estimating b [0.00082684]

Lưu ý là chúng ta không nên ngộ nhận rằng giá trị của các tham số có thể được khôi phục một cách chính xác. Điều này chỉ xảy ra với một số bài toán đặc biệt: các bài toán tối ưu lồi chặt với lượng dữ liệu “đủ” để đảm bảo rằng các mẫu nhiễu vẫn cho phép chúng ta khôi phục được các tham số. Trong hầu hết các trường hợp, điều này không xảy ra. Trên thực tế, các tham số của một mạng học sâu hiếm khi nào giống nhau (hoặc thậm chí là gần nhau) giữa hai lần chạy riêng biệt, trừ khi tất cả các điều kiện đều giống hệt nhau, bao gồm cả thứ tự mà dữ liệu được duyệt qua. Tuy nhiên, trong học máy, chúng ta thường ít quan tâm đến việc khôi phục chính xác giá trị của các tham số, mà quan tâm nhiều hơn đến bộ tham số nào sẽ dẫn tới việc dự đoán chính xác hơn. May mắn thay, thậm chí với những bài toán tối ưu khó, giải thuật hạ gradient ngẫu nhiên thường có thể tìm ra các lời giải đủ tốt, do một thực tế là đối với các mạng học sâu, có thể tồn tại nhiều bộ tham số dẫn đến việc dự đoán chính xác.

3.2.8. Tóm tắt

Chúng ta đã thấy cách một mạng sâu được thực thi và tối ưu hóa từ đầu chỉ với ndarrayautograd mà không cần định nghĩa các tầng, các thuật toán tối ưu đặc biệt, v.v. Điều này chỉ mới chạm đến bề mặt của những gì mà ta có thể làm. Trong các phần sau, chúng tôi sẽ mô tả các mô hình khác dựa trên những khái niệm vừa được giới thiệu cũng như cách thực thi chúng một cách chính xác hơn.

3.2.9. Bài tập

  1. Điều gì sẽ xảy ra nếu chúng ta khởi tạo các trọng số \(\mathbf{w} = 0\). Liệu thuật toán sẽ vẫn hoạt động chứ?
  2. Giả sử rằng bạn là Georg Simon Ohm và bạn đang cố gắng tìm ra một mô hình giữa điện áp và dòng điện. Bạn có thể sử dụng autograd để học các tham số cho mô hình của bạn không?
  3. Bạn có thể sử dụng Luật Planck để xác định nhiệt độ của một vật thể sử dụng mật độ năng lượng quang phổ không?
  4. Những vấn đề gặp phải nếu muốn mở rộng autograd đến các đạo hàm bậc hai? Cần sửa lại như thế nào?
  5. Tại sao hàm reshape lại cần thiết trong hàm squared_loss?
  6. Thử nghiệm các tốc độ học khác nhau để kiểm tra mức độ giảm nhanh của giá trị hàm mất mát giảm.
  7. Nếu số lượng mẫu không thể chia hết cho kích thước batch, thì hàm data_iter sẽ xử lý như thế nào?

3.2.10. Thảo luận

3.2.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
  • Lý Phi Long
  • Vũ Hữu Tiệp
  • Phạm Hồng Vinh
  • Nguyễn Văn Tâm
  • Nguyễn Cảnh Thướng
  • Nguyễn Lê Quang Nhật
  • Dương Nhật Tân
  • Nguyễn Minh Thư
  • Nguyễn Trường Phát
  • Đinh Minh Tân
  • Trần Thị Hồng Hạnh
  • Lê Khắc Hồng Phúc
  • Phạm Minh Đức
  • Nguyễn Mai Hoàng Long