- Muestra cómo implementar directamente la diferenciación automática, clave en el entrenamiento de redes neuronales, con una clase escalar
Tensor, y cómo el cálculo de valores y derivadas se encadena sobre el mismo grafo computacional
- Con variables normales de Python, en
z = x + y solo queda el valor resultante y se pierde la relación, así que Tensor debe guardar tanto el valor como el historial de operaciones
- Con
Children(a, b, op) y llamadas recursivas a forward() se construye un grafo computacional en árbol binario; al redefinir suma y multiplicación, la expresión puede recalcularse aunque los valores se asignen después
grad(deriv_to) toma la derivada respecto de sí mismo como 1 y respecto de otro escalar como 0, y aplica recursivamente las reglas de derivación de las operaciones básicas para crear un nuevo grafo computacional
- La implementación solo maneja escalares y puede ser lenta; quedan como mejoras pendientes las operaciones con arreglos, la poda de ramas multiplicadas por 0, el manejo de nodos constantes y un caché para reducir cálculos repetidos
Con variables normales de Python la relación se pierde
- Si se calcula
x = 3, y = 5, z = x + y, en z solo queda el valor resultante 8
- Aunque luego cambien los valores de
x o y, z ya no puede rastrear de qué variables fue creado
- Como no se conserva la relación entre variables, resulta difícil calcular automáticamente la derivada respecto de una variable concreta
Preservar el historial de operaciones con Tensor
- El nuevo tipo
Tensor guarda un valor (value) y redefine operadores para que, al operar entre Tensor, devuelva un nuevo Tensor
- La implementación inicial solo redefine
__add__, así que Tensor(3) + Tensor(5) puede producir T:8
- En esta etapa,
z todavía no puede conservar el historial de operaciones de que es el resultado de x + y
Grafo computacional y forward()
- Para conservar el historial de operaciones se introduce
Children = namedtuple('Children', ['a', 'b', 'op'])
a: tensor de entrada izquierdo
b: tensor de entrada derecho
op: operación real como np.add o np.multiply
- Cada
Tensor puede tener no solo un valor numérico sino también children, y con eso construir un grafo computacional en forma de árbol binario
forward() visita recursivamente los nodos hijos para calcular el valor real
- Con
x = Tensor(3), y = Tensor(5), si z1 = x + y y z2 = z1 * y, el resultado es T:40
- Incluso si primero se arma el grafo con
x = Tensor(None), y = Tensor(None) y después se asigna x.value = 3, y.value = 5, al llamar z2.forward() también se obtiene T:40
Construir la diferenciación automática como un grafo computacional
- La diferenciación automática se implementa agregando reglas de derivación para cada operación básica que soporte
Tensor
grad(self, deriv_to) recorre recursivamente el grafo computacional y descompone funciones complejas en combinaciones de funciones simples
- Las reglas básicas son estas
- Si se deriva un tensor respecto de sí mismo, se obtiene
Tensor(1)
- Si se deriva un escalar sin hijos respecto de otro tensor, se obtiene
Tensor(0)
- Suma:
(a + b)' = a' + b'
- Multiplicación:
(ab)' = a'b + ab'
- Si se deriva
z2 = (x + y) * y respecto de y, el resultado g no es un valor simple, sino un nuevo grafo computacional que representa la derivada parcial
- Como fórmula,
g = ∂z2/∂y = x + 2*y
- Cuando
x = 3 y y = 5, el valor de g es 13
Extensión a resta, división y función exponencial
- Para manejar expresiones más complejas, se agregan a
Tensor la resta, la división, la función exponencial y la negación
grad() incorpora las reglas de derivación correspondientes a cada operación
- Resta:
(a - b)' = a' - b'
- División:
(a/b)' = (a'b - ab') / b²
- Función exponencial:
exp(a)' = a' * exp(a)
forward() también cambia para manejar operaciones que solo requieren un operando
- Por ejemplo,
exp(a) no necesita un segundo operando b
-x se maneja como 0 - x
Fórmula de ejemplo y validación con Sympy
- Se escribe la siguiente expresión con
Tensor y se calculan las derivadas parciales respecto de x y y
z = (12 - (x * e^y)) / (45 + x * y * e^-x)
- En el código se expresa así
x = Tensor(3)
y = Tensor(5)
z = (Tensor(12) - (x * y.exp())) / (Tensor(45) + x * y * (-x).exp())
- Los valores calculados para las derivadas parciales son los siguientes
z.grad(x) → T:-3.34729777301069
z.grad(y) → T:-9.70176956641438
- El resultado de calcular la misma expresión con
Sympy usando diff() y evalf() también coincide
- Con
xs = 3, ys = 5, el valor de la derivada respecto de x es -3.34729777301069
- El valor de la derivada respecto de
y es -9.70176956641438
Limitaciones de esta implementación simple y puntos de optimización
- Esta implementación se parece a un sistema de diferenciación automática de la forma más simple posible y, al mismo tiempo, puede ser muy lenta
- La clase actual solo maneja escalares
- Para convertirse en una biblioteca más útil, tendría que agregar operaciones con arreglos de tamaño arbitrario
- Al observar el grafo computacional, se ven varias optimizaciones posibles
- En un nodo de multiplicación, si uno de los hijos es
0, no hace falta seguir explorando más profundo
- Si un nodo y sus hijos no dependen del tensor
x respecto del cual se deriva, ese nodo puede tratarse como una constante y detener el recorrido
- Cuando se repite la misma operación, se puede usar un caché para evitar hacer el mismo cálculo varias veces
1 comentarios
Opiniones en Hacker News
Me gustan estas demos de código pequeñas y elegantes, porque te permiten entender el concepto ensuciándote las manos
Los puzzles de GPU y de tensores de Sasha Rush son ejemplos parecidos
https://github.com/srush/GPU-Puzzles
https://github.com/srush/Tensor-Puzzles
El código original está aquí: https://github.com/jaymody/picoGPT/blob/main/gpt2.py
Si crees que con esto ya entendiste por completo la diferenciación automática, te estás engañando
Cuando el grafo es un árbol, todo es muy simple, como en este artículo. Pero si el grafo es un grafo acíclico dirigido más general, por ejemplo
x = 5; y = 2x; z = xy, la implementación sigue siendo muy simple, pero entender por qué esa implementación es correcta no lo es. Si piensas que es “solo la regla de la cadena normal”, también te estás engañandoUna de las primeras explicaciones fue de Paul Werbos, quien llamó a la regla necesaria regla de la cadena para derivadas ordenadas, y la demostró por inducción a partir de la regla de la cadena normal. Aun así, no se desprende de forma inmediata y evidente de la regla de la cadena normal. Si alguien cree lo contrario, me gustaría que lo demostrara; me alegraría mucho
Si aplicas la regla de la cadena normal a lo largo de las aristas del grafo computacional, es decir, de un grafo acíclico dirigido, en cada paso obtienes el valor correcto. La regla adicional necesaria es algo como: “si una variable se usa varias veces en el cálculo, es decir, si salen varias aristas del mismo nodo o, en sentido inverso, entran varias aristas, hay que sumar los gradientes calculados para cada una”; pero eso también me parece bastante básico e intuitivo
Por ejemplo, si pasas
ztanto comoxcomoyaf(x, y), entoncesd/dz f(z, z) = f_x(z, z) + f_y(z, z), donde el subíndice indica una derivada parcial. Para mí, esta forma es más simple matemáticamente que mezclar ambas cosas y hacer que parezca “algo más allá de la regla de la cadena”; además, parece más cercana a la implementación real, en especial a lo que hace PyTorch, que es con lo que estoy más familiarizadoLa diferenciación automática se siente como magia
Muchos científicos de la computación se han fascinado con esto y han escrito artículos que presentan la técnica desde una perspectiva más amplia. Mi artículo es uno de ellos e incluye una “variante para pobres” que usa números complejos sin sobrecarga de operadores
https://pizzaseminar.speicherleck.de/automatic-differentiati...
[0]: https://doi.org/10.1145/838250.838251
Tengo una implementación de diferenciación automática en Python en 26 líneas: https://gist.github.com/sradc/d9d66e3898ffe3a02e0b6b266629b0...
Es muy parecido a una técnica usada en sistemas de ingeniería basados en conocimiento, donde la llaman seguimiento de dependencias. Usada junto con caché de nodos o tensores, puede reducir la cantidad de cálculo, algo especialmente útil en modelos 3D paramétricos grandes
Al obtener un valor, se llama recursivamente al árbol binario/de dependencias para verificar qué variables cambiaron y se recalcula solo lo necesario. Si usas objetos y atributos personalizados de Python con métodos
__set__y__get__, puedes hacer que se sienta como una funcionalidad incorporada de un modelo orientado a objetosx = Tensor(3)y = Tensor(5)z = x + yprint(x, y) # 3, 5print(z) # 8x.value = 4 # al establecer el valor no se recalcula nadaprint(z) # 9, porque la dependencia modificada se recalcula en el momento de obtener el valorAndrej Karpathy tiene un video interesante donde construye un motor de autograd, y es bastante revelador
https://youtu.be/VMj-3S1tku0?si=wuKhELwOwoYbzpt7
Repositorio:
https://github.com/karpathy/micrograd
La variante de diferenciación automática que conozco no construye un grafo de operaciones. En cambio, calcula el valor al vuelo.
Ambas funcionan, pero una u otra es más eficiente según el caso. En cosas como el “entrenamiento de redes neuronales”, a menudo se optimiza una única salida de pérdida sobre muchos objetivos, así que normalmente se usa el modo inverso.
Ojalá a la diferenciación automática simplemente se le llamara regla de la cadena numérica, o al menos se explicara así. Literalmente eso es todo, con algunos trucos añadidos para no tener que calcular explícitamente la matriz jacobiana en ciertas operaciones, así que sería mucho más claro.
De hecho, si le pides a alguien que use la regla de la cadena para propagar gradientes a través de un grafo computacional, creo que la mayoría pensaría intuitivamente en el modo directo como opción predeterminada. Yo también.
https://en.wikipedia.org/wiki/Automatic_differentiation#Beyo...
Visto así, parece útil usar el término para referirse a un método específico de acumular gradientes al recorrer las expresiones que proporciona la regla de la cadena.
Mira la sección “Comparación con otros métodos”: https://en.m.wikipedia.org/wiki/Automatic_differentiation
Como dicen comentarios cercanos, la clave es que la implementación realmente importa y vale la pena estudiarla. Está bien decir que la diferenciación automática es un conjunto de métodos para implementar la regla de la cadena, pero decir que es “simplemente” la regla de la cadena numérica es incorrecto.
¿Cuál es el problema? La diferenciación automática no es más que una lente cartesiana de la matriz jacobiana y la diferencial total en la categoría de funciones suaves: https://www.youtube.com/watch?v=ne99laPUxN4
Me da curiosidad por qué la clase se llama Tensor. ¿Hay alguna forma de pensar una expresión o su derivada como un tensor? ¿O es porque un escalar también es un tensor, y esto se puede extender para admitir otros tipos de tensores?
Como el algoritmo de diferenciación automática descrito funciona con objetos arbitrarios de alta dimensión, parece tener sentido llamar tensores a estos objetos.