- Trực tiếp triển khai automatic differentiation—cốt lõi của việc huấn luyện mạng nơ-ron—bằng một lớp
Tensor cho scalar, cho thấy việc tính giá trị và tính đạo hàm nối tiếp nhau như thế nào trên cùng một computational graph
- Với biến Python thông thường,
z = x + y chỉ còn lại giá trị kết quả và mối quan hệ bị mất, nên Tensor cần lưu cả giá trị lẫn lịch sử phép toán
- Dùng
Children(a, b, op) và lời gọi đệ quy forward() để tạo computational graph dạng cây nhị phân; bằng cách định nghĩa lại phép cộng và phép nhân, có thể tính lại biểu thức ngay cả khi đưa giá trị vào sau
grad(deriv_to) đặt đạo hàm theo chính nó là 1, theo scalar khác là 0, rồi áp dụng đệ quy các quy tắc đạo hàm của phép toán cơ bản để tạo một computational graph mới
- Bản triển khai chỉ xử lý scalar và có thể chậm; các điểm cần cải thiện còn lại gồm phép toán trên mảng, cắt tỉa nhánh nhân với 0, xử lý node hằng số và cache để giảm tính toán lặp
Với biến Python thông thường, mối quan hệ bị mất
- Khi tính như
x = 3, y = 5, z = x + y, trong z chỉ còn giá trị kết quả 8
- Sau đó dù giá trị của
x hay y thay đổi, z cũng không thể truy vết nó được tạo từ biến nào
- Vì mối quan hệ giữa các biến không được giữ lại, rất khó tự động tính đạo hàm theo một biến cụ thể
Giữ lịch sử phép toán bằng Tensor
- Kiểu mới
Tensor lưu giá trị (value) và định nghĩa lại toán tử để khi tính toán giữa các Tensor thì trả về một Tensor mới
- Bản triển khai ban đầu chỉ định nghĩa lại
__add__, để Tensor(3) + Tensor(5) có thể tạo ra T:8
- Ở bước này, vẫn chưa giữ được lịch sử phép toán rằng
z là kết quả của x + y
Computational graph và forward()
- Để giữ lịch sử phép toán, đưa vào
Children = namedtuple('Children', ['a', 'b', 'op'])
a: tensor đầu vào bên trái
b: tensor đầu vào bên phải
op: phép toán thực tế như np.add, np.multiply
- Mỗi
Tensor có thể có không chỉ giá trị số mà còn cả children, qua đó cấu thành computational graph dạng cây nhị phân
forward() duyệt đệ quy các node con để tính giá trị thực tế
- Với
x = Tensor(3), y = Tensor(5), z1 = x + y, z2 = z1 * y trở thành T:40
- Ngay cả khi tạo graph trước bằng
x = Tensor(None), y = Tensor(None), rồi sau đó gán x.value = 3, y.value = 5 và gọi z2.forward(), nó vẫn tính được T:40
Biến automatic differentiation thành computational graph
- Automatic differentiation được triển khai bằng cách thêm quy tắc đạo hàm cho từng phép toán cơ bản mà
Tensor hỗ trợ
grad(self, deriv_to) duyệt đệ quy computational graph và phân rã một hàm phức tạp thành tổ hợp của các hàm đơn giản
- Các quy tắc cơ bản như sau
- Đạo hàm một tensor theo chính nó là
Tensor(1)
- Đạo hàm một scalar không có node con theo tensor khác là
Tensor(0)
- Phép cộng:
(a + b)' = a' + b'
- Phép nhân:
(ab)' = a'b + ab'
- Khi lấy đạo hàm
z2 = (x + y) * y theo y, kết quả g không phải là một giá trị đơn thuần mà là một computational graph mới biểu diễn đạo hàm riêng
- Viết thành công thức:
g = ∂z2/∂y = x + 2*y
- Khi
x = 3, y = 5, giá trị của g là 13
Mở rộng đến phép trừ, phép chia và hàm mũ
- Để xử lý các biểu thức phức tạp hơn, thêm phép trừ, phép chia, hàm mũ và phép toán số âm vào
Tensor
grad() chứa quy tắc đạo hàm tương ứng với từng phép toán
- Phép trừ:
(a - b)' = a' - b'
- Phép chia:
(a/b)' = (a'b - ab') / b²
- Hàm mũ:
exp(a)' = a' * exp(a)
forward() cũng được thay đổi để xử lý các phép toán chỉ cần một hạng tử
- Ví dụ:
exp(a) không cần hạng tử thứ hai b
-x được xử lý dưới dạng 0 - x
Biểu thức ví dụ và kiểm chứng bằng Sympy
- Viết biểu thức sau bằng
Tensor và tính đạo hàm riêng theo x, y
z = (12 - (x * e^y)) / (45 + x * y * e^-x)
- Trong code, biểu diễn như sau
x = Tensor(3)
y = Tensor(5)
z = (Tensor(12) - (x * y.exp())) / (Tensor(45) + x * y * (-x).exp())
- Các giá trị đạo hàm riêng tính được như sau
z.grad(x) → T:-3.34729777301069
z.grad(y) → T:-9.70176956641438
- Kết quả tính cùng biểu thức bằng
diff() và evalf() của Sympy cũng giống nhau
- Với
xs = 3, ys = 5, giá trị đạo hàm theo x là -3.34729777301069
- Giá trị đạo hàm theo
y là -9.70176956641438
Hạn chế của bản triển khai đơn giản và điểm tối ưu hóa
- Bản triển khai này gần với một hệ thống automatic differentiation đơn giản nhất, đồng thời có thể rất chậm
- Lớp hiện tại chỉ xử lý scalar
- Để trở thành một thư viện hữu ích hơn, cần thêm phép toán trên mảng kích thước tùy ý
- Nhìn vào computational graph, có thể tối ưu hóa ở một số điểm
- Nếu một node phép nhân có một node con là
0, không cần tìm sâu hơn nữa
- Nếu một node và các node con của nó không phụ thuộc vào tensor
x đang lấy đạo hàm, có thể xem node đó là hằng số và dừng duyệt
- Khi cùng một phép toán bị lặp lại, có thể dùng cache để tránh thực hiện cùng một phép tính nhiều lần
1 bình luận
Ý kiến trên Hacker News
Tôi thích những demo mã nhỏ gọn và thanh lịch như thế này. Vì chúng giúp ta hiểu khái niệm bằng cách tự tay làm
Các câu đố GPU và câu đố tensor của Sasha Rush cũng là những ví dụ tương tự
https://github.com/srush/GPU-Puzzles
https://github.com/srush/Tensor-Puzzles
Mã nguồn gốc ở đây: https://github.com/jaymody/picoGPT/blob/main/gpt2.py
Nếu tin rằng chỉ với bài này là đã hiểu hoàn toàn automatic differentiation, thì bạn đang tự lừa mình
Khi đồ thị là cây thì mọi thứ rất đơn giản như trong bài viết này. Nhưng nếu đồ thị là một đồ thị có hướng không chu trình tổng quát hơn, chẳng hạn
x = 5; y = 2x; z = xy, thì dù phần triển khai vẫn rất đơn giản, việc hiểu vì sao triển khai đó đúng lại không hề đơn giản. Nếu nghĩ rằng đó “chỉ là quy tắc dây chuyền thông thường”, thì bạn cũng đang tự lừa mìnhMột trong những giải thích ban đầu là của Paul Werbos; ông gọi quy tắc cần thiết là quy tắc dây chuyền của đạo hàm có thứ tự, và chứng minh nó bằng quy nạp từ quy tắc dây chuyền thông thường. Dù vậy, nó không hiển nhiên ngay lập tức từ quy tắc dây chuyền thông thường. Nếu có ai tin điều ngược lại, tôi mong họ chứng minh rằng tôi sai; như vậy tôi sẽ rất vui
Nếu áp dụng quy tắc dây chuyền thông thường dọc theo các cạnh của đồ thị tính toán, tức đồ thị có hướng không chu trình, thì ở mỗi bước sẽ nhận được giá trị đúng. Quy tắc bổ sung cần thiết chỉ là “nếu một biến được dùng nhiều lần trong phép tính, tức là có nhiều cạnh đi ra từ cùng một nút hoặc, theo chiều ngược, nhiều cạnh đi vào, thì phải cộng các gradient được tính riêng lại với nhau”; điều này theo tôi cũng khá cơ bản và trực quan
Ví dụ, nếu đưa cùng
zvào cảxvàytrongf(x, y), thìd/dz f(z, z) = f_x(z, z) + f_y(z, z), trong đó chỉ số dưới nghĩa là đạo hàm riêng. Với tôi, cách này đơn giản hơn về mặt toán học so với việc trộn hai thứ lại rồi biến nó thành thứ gì đó “vượt ra ngoài quy tắc dây chuyền”, và cũng gần hơn với cách triển khai thực tế, đặc biệt là những gì PyTorch—framework tôi quen nhất—đang làmAutomatic differentiation có cảm giác như ma thuật
Nhiều nhà khoa học máy tính bị cuốn hút bởi nó và đã viết các bài giới thiệu kỹ thuật này từ góc nhìn rộng hơn. Bài của tôi cũng là một trong số đó, và còn bao gồm một “biến thể nhà nghèo” dùng số phức mà không cần operator overloading
https://pizzaseminar.speicherleck.de/automatic-differentiati...
[0]: https://doi.org/10.1145/838250.838251
Tôi có một triển khai automatic differentiation bằng Python trong 26 dòng: https://gist.github.com/sradc/d9d66e3898ffe3a02e0b6b266629b0...
Nó rất giống kỹ thuật dùng trong các hệ thống kỹ thuật dựa trên tri thức, nơi người ta gọi là theo dõi phụ thuộc. Khi dùng cùng caching node hoặc tensor, có thể giảm lượng tính toán, đặc biệt hữu ích cho các mô hình 3D tham số lớn
Khi lấy giá trị, nó gọi đệ quy cây nhị phân/phụ thuộc để kiểm tra biến nào đã thay đổi, và chỉ tính lại những gì cần thiết. Dùng các đối tượng Python tùy chỉnh và thuộc tính có các phương thức
__set__,__get__có thể khiến nó trông như một tính năng tích hợp của mô hình hướng đối tượngx = Tensor(3)y = Tensor(5)z = x + yprint(x, y) # 3, 5print(z) # 8x.value = 4 # khi đặt giá trị thì không tính lại gì cảprint(z) # 9, vì phụ thuộc đã thay đổi được tính lại đúng lúc lấy giá trịAndrej Karpathy có một video thú vị về việc xây dựng autograd engine, và khá giàu insight
https://youtu.be/VMj-3S1tku0?si=wuKhELwOwoYbzpt7
Kho lưu trữ:
https://github.com/karpathy/micrograd
Biến thể tự động vi phân mà tôi biết không tạo đồ thị phép toán. Thay vào đó, nó tính giá trị đó ngay tại chỗ
Cả hai đều hoạt động, nhưng tùy tình huống mà một bên hiệu quả hơn. Trong các trường hợp như “huấn luyện mạng nơ-ron”, thường là tối ưu hóa một đầu ra mất mát đơn lẻ trên nhiều đối tượng, nên thông thường dùng chế độ lùi
Tôi mong tự động vi phân được gọi đơn giản là quy tắc dây chuyền số học, hoặc ít nhất được giải thích như vậy. Theo nghĩa đen thì đó là toàn bộ vấn đề, chỉ kèm thêm vài mẹo để tránh tính tường minh ma trận Jacobi trong một số phép toán cụ thể, nên như thế rõ ràng hơn nhiều
Thực tế, nếu bảo dùng quy tắc dây chuyền để truyền gradient qua đồ thị tính toán, tôi nghĩ phần lớn mọi người sẽ trực giác xem chế độ tiến là mặc định. Tôi cũng vậy
https://en.wikipedia.org/wiki/Automatic_differentiation#Beyo...
Nhìn từ điểm này, có vẻ hữu ích khi dùng thuật ngữ này để chỉ một phương pháp cụ thể nhằm duyệt các công thức do quy tắc dây chuyền cung cấp và tích lũy gradient
Hãy xem mục “khác biệt với các phương pháp khác”: https://en.m.wikipedia.org/wiki/Automatic_differentiation
Như bình luận gần đó, điểm cốt lõi là phần triển khai thực sự quan trọng và đáng để học. Nói tự động vi phân là một nhóm các phương pháp triển khai quy tắc dây chuyền thì được, nhưng nói nó “chỉ là” quy tắc dây chuyền số học thì sai
Tự động vi phân chẳng qua chỉ là lens Descartes của ma trận Jacobi và vi phân toàn phần trong phạm trù các hàm trơn, vậy có vấn đề gì? https://www.youtube.com/watch?v=ne99laPUxN4
Tôi thắc mắc vì sao tên lớp lại được gọi là Tensor. Có cách nào xem biểu thức hoặc đạo hàm của nó như một tensor không? Hay là vì scalar cũng là tensor, và thứ này có thể được mở rộng để hỗ trợ cả các kiểu tensor khác?
Vì thuật toán tự động vi phân được mô tả hoạt động với các đối tượng nhiều chiều tùy ý, nên có vẻ hợp lý khi gọi các đối tượng như vậy là tensor