2.5. Tính vi phân Tự động¶
Như đã giải thích trong Section 2.4, vi phân là phép tính thiết yếu trong hầu như tất cả mọi thuật toán học sâu. Mặc dù các phép toán trong việc tính đạo hàm khá trực quan và chỉ yêu cầu một chút kiến thức giải tích, nhưng với các mô hình phức tạp, việc tự tính rõ ràng từng bước khá là mệt (và thường rất dễ sai).
Gói thư viện autograd
giải quyết vấn đề này một cách nhanh chóng và
hiệu quả bằng cách tự động hoá các phép tính đạo hàm (automatic
differentiation). Trong khi nhiều thư viện yêu cầu ta phải biên dịch
một đồ thị biểu tượng (symbolic graph) để có thể tự động tính đạo
hàm, autograd
cho phép ta tính đạo hàm ngay lập tức thông qua các
dòng lệnh thông thường. Mỗi khi đưa dữ liệu chạy qua mô hình,
autograd
xây dựng một đồ thị và theo dõi xem dữ liệu nào kết hợp với
các phép tính nào để tạo ra kết quả. Với đồ thị này autograd
sau đó
có thể lan truyền ngược gradient lại theo ý muốn. Lan truyền ngược ở
đây chỉ đơn thuần là truy ngược lại đồ thị tính toán và điền vào đó
các giá trị đạo hàm riêng theo từng tham số.
from mxnet import autograd, np, npx
npx.set_np()
2.5.1. Một ví dụ đơn giản¶
Lấy ví dụ đơn giản, giả sử chúng ta muốn tính vi phân của hàm số
\(y = 2\mathbf{x}^{\top}\mathbf{x}\) theo vector cột
\(\mathbf{x}\). Để bắt đầu, ta sẽ tạo biến x
và gán cho nó một
giá trị ban đầu.
x = np.arange(4)
x
array([0., 1., 2., 3.])
Lưu ý rằng trước khi có thể tính gradient của \(y\) theo \(\mathbf{x}\), chúng ta cần một nơi để lưu giữ nó. Điều quan trọng là ta không được cấp phát thêm bộ nhớ mới mỗi khi tính đạo hàm theo một biến xác định, vì ta thường cập nhật cùng một tham số hàng ngàn vạn lần và sẽ nhanh chóng dùng hết bộ nhớ.
Cũng lưu ý rằng, bản thân giá trị gradient của hàm số đơn trị theo một
vector \(\mathbf{x}\) cũng là một vector với cùng kích thước. Do vậy
trong mã nguồn sẽ trực quan hơn nếu chúng ta lưu giá trị gradient tính
theo x
dưới dạng một thuộc tính của chính ndarray
x
. Chúng
ta cấp bộ nhớ cho gradient của một ndarray
bằng cách gọi phương thức
attach_grad
.
x.attach_grad()
Sau khi đã tính toán gradient theo biến x
, ta có thể truy cập nó
thông qua thuộc tính grad
. Để an toàn, x.grad
được khởi tạo là
một mảng chứa các giá trị không. Điều này hợp lý vì trong học sâu, việc
lấy gradient thường là để cập nhật các tham số bằng cách cộng (hoặc trừ)
gradient của một hàm để cực đại (hoặc cực tiểu) hóa hàm đó. Bằng cách
khởi tạo gradient bằng mảng chứa giá trị không, ta đảm bảo rằng bất kỳ
cập nhật vô tình nào trước khi gradient được tính toán sẽ không làm thay
đổi giá trị các tham số.
x.grad
array([0., 0., 0., 0.])
Giờ hãy tính \(y\). Bởi vì mục đích sau cùng là tính gradient, ta muốn MXNet tạo đồ thị tính toán một cách nhanh chóng. Ta có thể tưởng tượng rằng MXNet sẽ bật một thiết bị ghi hình để thu lại chính xác đường đi mà mỗi biến được tạo.
Chú ý rằng ta cần một số lượng phép tính không hề nhỏ để xây dựng đồ thị
tính toán. Vậy nên MXNet sẽ chỉ dựng đồ thị khi được ra lệnh rõ ràng. Ta
có thể thực hiện việc này bằng cách đặt đoạn mã trong phạm vi
autograd.record
.
with autograd.record():
y = 2 * np.dot(x, x)
y
array(28.)
Bởi vì x
là một ndarray
có độ dài bằng 4, np.dot
sẽ tính
toán tích vô hướng của x
và x
, trả về một số vô hướng mà sẽ được
gán cho y
. Tiếp theo, ta có thể tính toán gradient của y
theo
mỗi thành phần của x
một cách tự động bằng cách gọi hàm backward
của y
.
y.backward()
Nếu kiểm tra lại giá trị của x.grad
, ta sẽ thấy nó đã được ghi đè
bằng gradient mới được tính toán.
x.grad
array([ 0., 4., 8., 12.])
Gradient của hàm \(y = 2\mathbf{x}^{\top}\mathbf{x}\) theo
\(\mathbf{x}\) phải là \(4\mathbf{x}\). Hãy kiểm tra một cách
nhanh chóng rằng giá trị gradient mong muốn được tính toán đúng. Nếu hai
ndarray
là giống nhau, thì mọi cặp phần tử tương ứng cũng bằng nhau.
x.grad == 4 * x
array([ True, True, True, True])
Nếu ta tiếp tục tính gradient của một biến khác mà giá trị của nó là kết
quả của một hàm theo biến x
, thì nội dung trong x.grad
sẽ bị ghi
đè.
with autograd.record():
y = x.sum()
y.backward()
x.grad
array([1., 1., 1., 1.])
2.5.2. Truyền ngược cho các biến không phải Số vô hướng¶
Về mặt kỹ thuật, khi y
không phải một số vô hướng, cách diễn giải tự
nhiên nhất cho vi phân của một vector y
theo vector x
đó là một
ma trận. Với các bậc và chiều cao hơn của y
và x
, kết quả của
phép vi phân có thể là một tensor bậc cao.
Tuy nhiên, trong khi những đối tượng như trên xuất hiện trong học máy nâng cao (bao gồm học sâu), thường thì khi ta gọi lan truyền ngược trên một vector, ta đang cố tính toán đạo hàm của hàm mất mát theo mỗi batch bao gồm một vài mẫu huấn luyện. Ở đây, ý định của ta không phải là tính toán ma trận vi phân mà là tổng của các đạo hàm riêng được tính toán một cách độc lập cho mỗi mẫu trong batch.
Vậy nên khi ta gọi backward
lên một biến vector y
– là một hàm
của x
, MXNet sẽ cho rằng ta muốn tính tổng của các gradient. Nói
ngắn gọn, MXNet sẽ tạo một biến mới có giá trị là số vô hướng bằng cách
cộng lại các phần tử trong y
và tính gradient theo x
của biến
mới này.
with autograd.record():
y = x * x # y is a vector
y.backward()
u = x.copy()
u.attach_grad()
with autograd.record():
v = (u * u).sum() # v is a scalar
v.backward()
x.grad == u.grad
array([ True, True, True, True])
2.5.3. Tách rời Tính toán¶
Đôi khi chúng ta muốn chuyển một số phép tính ra khỏi đồ thị tính toán.
Ví dụ, giả sử y
đã được tính như một hàm của x
, rồi sau đó z
được tính như một hàm của cả y
và x
. Bây giờ, giả sử ta muốn
tính gradient của z
theo x
, nhưng vì lý do nào đó ta lại muốn
xem y
như là một hằng số và chỉ xét đến vai trò của x
như là
biến số của z
sau khi giá trị của y
đã được tính.
Trong trường hợp này, ta có thể gọi u = y.detach()
để trả về một
biến u
mới có cùng giá trị như y
nhưng không còn chứa các thông
tin về cách mà y
đã được tính trong đồ thị tính toán. Nói cách khác,
gradient sẽ không thể chảy ngược qua u
về x
được. Bằng cách này,
ta đã tính u
như một hàm của x
ở ngoài phạm vi của
autograd.record
, dẫn đến việc biến u
sẽ được xem như là một hằng
số mỗi khi ta gọi backward
. Chính vì vậy, hàm backward
sau đây
sẽ tính đạo hàm riêng của z = u * x
theo x
khi xem u
như là
một hằng số, thay vì đạo hàm riêng của z = x * x * x
theo x
.
with autograd.record():
y = x * x
u = y.detach()
z = u * x
z.backward()
x.grad == u
array([ True, True, True, True])
Bởi vì sự tính toán của y
đã được ghi lại, chúng ta có thể gọi
y.backward()
sau đó để lấy đạo hàm của y = x * x
theo x
, tức
là 2 * x
.
y.backward()
x.grad == 2 * x
array([ True, True, True, True])
Lưu ý rằng khi ta gắn gradient vào một biến x
, x = x.detach()
sẽ
được gọi ngầm. Nếu x
được tính dựa trên các biến khác, phần tính
toán này sẽ không được sử dụng trong hàm backward
.
y = np.ones(4) * 2
y.attach_grad()
with autograd.record():
u = x * y
u.attach_grad() # Implicitly run u = u.detach()
z = 5 * u - x
z.backward()
x.grad, u.grad, y.grad
(array([-1., -1., -1., -1.]), array([5., 5., 5., 5.]), array([0., 0., 0., 0.]))
2.5.4. Tính gradient của Luồng điều khiển Python¶
Một lợi thế của việc sử dụng vi phân tự động là khi việc xây dựng đồ thị
tính toán đòi hỏi trải qua một loạt các câu lệnh điều khiển luồng
Python, (ví dụ như câu lệnh điều kiện, vòng lặp và các lệnh gọi hàm tùy
ý), ta vẫn có thể tính gradient của biến kết quả. Trong đoạn mã sau, hãy
lưu ý rằng số lần lặp của vòng lặp while
và kết quả của câu lệnh
if
đều phụ thuộc vào giá trị của đầu vào a
.
def f(a):
b = a * 2
while np.linalg.norm(b) < 1000:
b = b * 2
if b.sum() > 0:
c = b
else:
c = 100 * b
return c
Một lần nữa, để tính gradient ta chỉ cần “ghi lại” các phép tính (bằng
cách gọi hàm record
) và sau đó gọi hàm backward
.
a = np.random.normal()
a.attach_grad()
with autograd.record():
d = f(a)
d.backward()
Giờ ta có thể phân tích hàm f
được định nghĩa ở phía trên. Hãy để ý
rằng hàm này tuyến tính từng khúc theo đầu vào a
. Nói cách khác, với
mọi giá trị của a
tồn tại một hằng số k
sao cho
f(a) = k * a
, ở đó giá trị của k
phụ thuộc vào đầu vào a
. Do
đó, ta có thể kiểm tra giá trị của gradient bằng cách tính d / a
.
a.grad == d / a
array(True)
2.5.5. Chế độ huấn luyện và Chế độ dự đoán¶
Như đã thấy, sau khi gọi autograd.record
, MXNet sẽ ghi lại những
tính toán xảy ra trong khối mã nguồn theo sau. Có một chi tiết tinh tế
nữa mà ta cần để ý. autograd.record
sẽ thay đổi chế độ chạy từ chế
độ dự đoán sang chế độ huấn luyện. Ta có thể kiểm chứng hành vi này
bằng cách gọi hàm is_training
.
print(autograd.is_training())
with autograd.record():
print(autograd.is_training())
False
True
Khi ta tìm hiểu tới các mô hình học sâu phức tạp, ta sẽ gặp một vài thuật toán mà mô hình hoạt động khác nhau khi huấn luyện và khi được sử dụng sau đó để dự đoán. Những khác biệt này sẽ được đề cập chi tiết trong các chương sau.
2.5.6. Tóm tắt¶
- MXNet cung cấp gói
autograd
để tự động hóa việc tính toán đạo hàm. Để sử dụng nó, đầu tiên ta gắn gradient cho các biến mà ta muốn lấy đạo hàm riêng theo nó. Sau đó ta ghi lại tính toán của giá trị mục tiêu, thực thi hàmbackward
của nó và truy cập kết quả gradient thông qua thuộc tínhgrad
của các biến. - Ta có thể tách rời gradient để kiểm soát những phần tính toán được sử
dụng trong hàm
backward
. - Các chế độ chạy của MXNet bao gồm chế độ huấn luyện và chế độ dự
đoán. Ta có thể kiểm tra chế độ đang chạy bằng cách gọi hàm
is_training
.
2.5.7. Bài tập¶
- Tại sao đạo hàm bậc hai lại mất thêm rất nhiều tài nguyên để tính toán hơn đạo hàm bậc một?
- Sau khi chạy
y.backward()
, lập tức chạy lại lần nữa và xem chuyện gì sẽ xảy ra. - Trong ví dụ về luồng điều khiển khi ta tính toán đạo hàm của
d
theoa
, điều gì sẽ xảy ra nếu ta thay đổi biếna
thành một vector hay ma trận ngẫu nhiên. Lúc này, kết quả của tính toánf(a)
sẽ không còn là số vô hướng nữa. Điều gì sẽ xảy ra với kết quả? Ta có thể phân tích nó như thế nào? - Hãy tái thiết kế một ví dụ về việc tìm gradient của luồng điều khiển. Chạy ví dụ và phân tích kết quả.
- Cho \(f(x) = \sin(x)\). Vẽ đồ thị của \(f(x)\) và \(\frac{df(x)}{dx}\) với điều kiện không được tính trực tiếp đạo hàm \(f'(x) = \cos(x)\).
- Trong một cuộc đấu giá kín theo giá thứ hai (ví dụ như trong eBay hay
trong quảng cáo điện toán), người thắng cuộc đấu giá chỉ trả mức giá
cao thứ hai. Hãy tính gradient của mức giá cuối cùng theo mức đặt của
người thắng cuộc bằng cách sử dụng
autograd
. Kết quả cho bạn biết điều gì về cơ chế đấu giá này? Nếu bạn tò mò muốn tìm hiểu thêm về các cuộc đấu giá kín theo giá thứ hai, hãy đọc bài báo nghiên cứu của Edelman et al. [Edelman et al., 2007].
2.5.8. Thảo luận¶
2.5.9. 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ê Khắc Hồng Phúc
- Nguyễn Cảnh Thướng
- Phạm Hồng Vinh
- Vũ Hữu Tiệp
- Tạ H. Duy Nguyên
- Phạm Minh Đức