3 điểm bởi GN⁺ 2023-08-27 | 1 bình luận | Chia sẻ qua WhatsApp
  • 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 g13

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()evalf() của Sympy cũng giống nhau
    • Với xs = 3, ys = 5, giá trị đạo hàm theo x-3.34729777301069
    • Giá trị đạo hàm theo y-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

 
GN⁺ 2023-08-27
Ý 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

  • 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ình
    Mộ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

    • Vậy nên đọc thêm ở đâu? Những người tạo ra các framework như autograd, PyTorch, mxnet chắc hẳn đã học kỹ ở đâu đó, và tôi tò mò nguồn đó là gì. Theo tôi biết, mxnet xuất phát từ giới học thuật, có lẽ là CMU
    • Thành thật mà nói, tôi không rõ mọi người muốn gì trong cuộc thảo luận kiểu này, và có lẽ là vì phép trừu tượng ngầm định, đạo hàm có thứ tự, không lý tưởng
      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 z vào cả xy trong f(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àm
    • Quy tắc dây chuyền được định nghĩa cho đạo hàm riêng, nên về mặt kỹ thuật vẫn có thể xem nó đơn giản là quy tắc dây chuyền
  • Automatic 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...

    • Khi tôi làm machine learning vào năm 1994–1995, tôi không biết đến automatic differentiation, và vị giáo sư tạo ra hàm mục tiêu cũng tự tìm đạo hàm giải tích. Mãi vài năm trước tôi mới biết, và điều đó khiến tôi kinh ngạc khi nghĩ lại khoảng thời gian cuối thập niên 90 mình học Mathematica đủ nhiều để tự tạo các đạo hàm giải tích
    • Có vẻ điều này bắt nguồn từ xấp xỉ đạo hàm bằng bước phức của J. Martins, P. Sturdza, J. Alonso năm 2003. Bài báo đó đáng đọc
      [0]: https://doi.org/10.1145/838250.838251
    • Thật sự có cảm giác như ma thuật. Nếu có tài liệu nhập môn về backpropagation được viết theo cách tương tự thì tôi muốn biết
  • Tôi có một triển khai automatic differentiation bằng Python trong 26 dòng: https://gist.github.com/sradc/d9d66e3898ffe3a02e0b6b266629b0...

    • Ngắn gọn thì tốt, nhưng có vẻ đầu tôi hoạt động tốt hơn nhiều khi có lượng khoảng trắng vừa phải. Tôi nên luyện thêm những phong cách khác như thế này
  • 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ượng
    x = Tensor(3)
    y = Tensor(5)
    z = x + y
    print(x, y) # 3, 5
    print(z) # 8
    x.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ó lẽ bạn đang nghĩ đến tự động vi phân chế độ tiến. Nó hữu ích hơn khi chiều đầu ra của hàm tương đối lớn, và khác với tự động vi phân chế độ lùi, vốn hữu ích hơn khi chiều đầu ra tương đối nhỏ
      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

    • “autodiff” được giải thích ở đây và được dùng thường xuyên nhất trong triển khai lan truyền ngược là tự động vi phân chế độ ngược, nhưng cũng có chế độ tiến và các chiến lược nằm giữa hai thái cực này. Cuối cùng tất cả đều quy về quy tắc dây chuyền, nhưng ở cấp độ thuật toán, việc chọn cách nào hoàn toàn không hề tầm thường
      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
    • Về mặt kỹ thuật thì sai. Quy tắc dây chuyền số học dùng phương pháp sai phân hữu hạn, và sai số tích lũy qua quá trình tính toán
      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
    • Có thể chính xác hơn, nhưng tôi sẽ không gọi là rõ ràng hơn
  • 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?

    • Có thể tôi sai, nhưng về mặt toán học, tôi nghĩ đối tượng 2 chiều được gọi là ma trận, còn đối tượng từ 3 chiều trở lên được gọi là tensor
      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