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 xx, 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 yx, 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ả yx. 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àm backward của nó và truy cập kết quả gradient thông qua thuộc tính grad 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

  1. 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?
  2. 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.
  3. Trong ví dụ về luồng điều khiển khi ta tính toán đạo hàm của d theo a, điều gì sẽ xảy ra nếu ta thay đổi biến a 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án f(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?
  4. 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ả.
  5. Cho \(f(x) = \sin(x)\). Vẽ đồ thị của \(f(x)\)\(\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)\).
  6. 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