A pesar de trabajar e investigar un poco en el ecosistema de IA durante algún tiempo, hasta hace poco no me detuve a pensar en la retropropagación y las actualizaciones de gradiente dentro de las redes neuronales. Este artículo busca rectificar esto y, con suerte, brindará una inmersión exhaustiva pero fácil de seguir en el tema mediante la implementación de un marco de red neuronal simple (pero algo poderoso) desde cero.
Básicamente, una red neuronal es solo una función matemática desde nuestro espacio de entrada hasta nuestro espacio de salida deseado. De hecho, podemos “desenvolver” eficazmente cualquier red neuronal en una función. Considere, por ejemplo, la siguiente red neuronal simple con dos capas y una entrada:
Ahora podemos construir una función equivalente avanzando capa por capa, comenzando desde la entrada. Sigamos nuestra función final capa por capa:
- En la entrada, comenzamos con la función de identidad. pred(x) = x
- En la primera capa lineal, obtenemos pred(x) = w₁x+b₁
- El ReLU nos conecta pred(x) = máx(0, w₁x+b₁)
- En la capa final, obtenemos pred(x) = w₂(máx(0, w₁x+b₁)) + b₂
Con redes más complicadas, estas funciones, por supuesto, se vuelven difíciles de manejar, pero la cuestión es que podemos construir tales representaciones de redes neuronales.
Sin embargo, podemos ir un paso más allá: las funciones de esta forma no son extremadamente convenientes para el cálculo, pero podemos analizarlas en una forma más útil, es decir, un árbol de sintaxis. Para nuestra red simple, el árbol se vería así:
En esta forma de árbol, nuestras hojas son parámetros, constantes y entradas, y los otros nodos son operaciones elementales cuyos argumentos son sus hijos. Por supuesto, estas operaciones elementales no tienen que ser binarias: la operación sigmoidea, por ejemplo, es unaria (y también lo es ReLU si no la representamos como un máximo de 0 y x), y podemos optar por admitir multiplicación y suma de más de una entrada.
Al pensar en nuestra red como un árbol de estas operaciones elementales, ahora podemos hacer muchas cosas muy fácilmente con la recursividad, que formará la base de nuestros algoritmos de propagación hacia atrás y hacia adelante. En el código, podemos definir una clase de red neuronal recursiva similar a esta:
from dataclasses import dataclass, field
from typing import List@dataclass
class NeuralNetNode:
"""A node in our neural network tree"""
children: List['NeuralNetNode'] = field(default_factory=list)
def op(self, x: List[float]) -> float:
"""The operation that this node performs"""
raise NotImplementedError
def forward(self) -> float:
"""Evaluate this node on the given input"""
return self.op([child.forward() for child in self.children])
# This is just for convenience
def __call__(self) -> List[float]:
return self.forward()
def __repr__(self):
return f'{self.__class__.__name__}({self.children})'
Supongamos ahora que tenemos una función de pérdida diferenciable para nuestra red neuronal, digamos MSE. Recuerde que MSE (para una muestra) se define de la siguiente manera:
Ahora deseamos actualizar nuestros parámetros (los círculos verdes en nuestra representación de árbol) dado el valor de nuestra pérdida. Para hacer esto, necesitamos la derivada de nuestra función de pérdida con respecto a cada parámetro. Sin embargo, calcular esto directamente a partir de la pérdida es extremadamente difícil; después de todo, nuestro MSE se calcula en términos del valor predicho por nuestra red neuronal, lo que puede ser una función extraordinariamente complicada.
Aquí es donde entra en juego una pieza muy útil de las matemáticas: la regla de la cadena. En lugar de vernos obligados a calcular nuestras derivadas altamente complejas desde el principio, podemos calcular una serie de derivadas más simples.
Resulta que la regla de la cadena encaja muy bien con nuestra estructura de árbol recursivo. La idea básicamente funciona de la siguiente manera: suponiendo que tenemos operaciones elementales suficientemente simples, cada operación elemental conoce su derivada con respecto a todos sus argumentos. Dada la derivada de la operación principal, podemos calcular la derivada de cada operación secundaria con respecto a la función de pérdida mediante una simple multiplicación. Para un modelo de regresión lineal simple que utiliza MSE, podemos diagramarlo de la siguiente manera:
Por supuesto, algunos de nuestros nodos no hacen nada con sus derivados, es decir, solo a nuestros nodos hoja les importa. Pero ahora cada nodo puede obtener la derivada de su salida con respecto a la función de pérdida mediante este proceso recursivo. Así podemos agregar los siguientes métodos a nuestra clase NeuralNetNode:
def grad(self) -> List[float]:
"""The gradient of this node with respect to its inputs"""
raise NotImplementedErrordef backward(self, derivative_from_parent: float):
"""Propagate the derivative from the parent to the children"""
self.on_backward(derivative_from_parent)
deriv_wrt_children = self.grad()
for child, derivative_wrt_child in zip(self.children, deriv_wrt_children):
child.backward(derivative_from_parent * derivative_wrt_child)
def on_backward(self, derivative_from_parent: float):
"""Hook for subclasses to override. Things like updating parameters"""
pass
Ejercicio 1: Intente crear uno de estos árboles para un modelo de regresión lineal simple y realice las actualizaciones de gradiente recursivas a mano durante un par de pasos.
Nota: En aras de la simplicidad, requerimos que nuestros nodos tengan solo un padre (o ninguno). Si a cada nodo se le permite tener varios padres, nuestro algoritmo de retroceso() se vuelve algo más complicado ya que cada hijo necesita sumar la derivada de sus padres para calcular la suya propia. Podemos hacer esto de forma iterativa con una clasificación topológica (por ejemplo, ver aquí) o aún de forma recursiva, es decir, con acumulación inversa (aunque en este caso necesitaríamos hacer una segunda pasada para actualizar todos los parámetros). Esto no es extraordinariamente difícil, así que lo dejaré como ejercicio para el lector (y hablaré más sobre ello en la parte 2, estad atentos).
La construcción de modelos
El resto de nuestro código en realidad solo implica implementar parámetros, entradas y operaciones y, por supuesto, ejecutar nuestra capacitación. Los parámetros y las entradas son construcciones bastante simples:
import random@dataclass
class Input(NeuralNetNode):
"""A leaf node that represents an input to the network"""
value: float=0.0
def op(self, x):
return self.value
def grad(self) -> List[float]:
return [1.0]
def __repr__(self):
return f'{self.__class__.__name__}({self.value})'
@dataclass
class Parameter(NeuralNetNode):
"""A leaf node that represents a parameter to the network"""
value: float=field(default_factory=lambda: random.uniform(-1, 1))
learning_rate: float=0.01
def op(self, x):
return self.value
def grad(self):
return [1.0]
def on_backward(self, derivative_from_parent: float):
self.value -= derivative_from_parent * self.learning_rate
def __repr__(self):
return f'{self.__class__.__name__}({self.value})'
Las operaciones son un poco más complicadas, aunque no demasiado: sólo necesitamos calcular sus gradientes correctamente. A continuación se muestran implementaciones de algunas operaciones útiles:
import math@dataclass
class Operation(NeuralNetNode):
"""A node that performs an operation on its inputs"""
pass
@dataclass
class Add(Operation):
"""A node that adds its inputs"""
def op(self, x):
return sum(x)
def grad(self):
return [1.0] * len(self.children)
@dataclass
class Multiply(Operation):
"""A node that multiplies its inputs"""
def op(self, x):
return math.prod(x)
def grad(self):
grads = []
for i in range(len(self.children)):
cur_grad = 1
for j in range(len(self.children)):
if i == j:
continue
cur_grad *= self.children[j].forward()
grads.append(cur_grad)
return grads
@dataclass
class ReLU(Operation):
"""
A node that applies the ReLU function to its input.
Note that this should only have one child.
"""
def op(self, x):
return max(0, x[0])
def grad(self):
return [1.0 if self.children[0].forward() > 0 else 0.0]
@dataclass
class Sigmoid(Operation):
"""
A node that applies the sigmoid function to its input.
Note that this should only have one child.
"""
def op(self, x):
return 1 / (1 + math.exp(-x[0]))
def grad(self):
return [self.forward() * (1 - self.forward())]
La superclase de operación aquí no es útil todavía, aunque la necesitaremos para encontrar más fácilmente las entradas de nuestro modelo más adelante.
Observe con qué frecuencia los gradientes de las funciones requieren los valores de sus hijos, por lo que necesitamos llamar al método forward() del hijo. Hablaremos más de esto en un momento.
Definir una red neuronal en nuestro marco es un poco detallado pero es muy similar a construir un árbol. Aquí, por ejemplo, hay un código para un clasificador lineal simple en nuestro marco:
linear_classifier = Add([
Multiply([
Parameter(),
Input()
]),
Parameter()
])
Usando nuestros modelos
Para ejecutar una predicción con nuestro modelo, primero debemos completar las entradas en nuestro árbol y luego llamar a forward() en el padre. Sin embargo, para completar las entradas, primero debemos encontrarlas, por lo que agregamos el siguiente método a nuestro Operación clase (no agregamos esto a nuestra clase NeuralNetNode ya que el tipo de entrada aún no está definido allí):
def find_input_nodes(self) -> List[Input]:
"""Find all of the input nodes in the subtree rooted at this node"""
input_nodes = []
for child in self.children:
if isinstance(child, Input):
input_nodes.append(child)
elif isinstance(child, Operation):
input_nodes.extend(child.find_input_nodes())
return input_nodes
Ahora podemos agregar el método predict() a la clase Operación:
def predict(self, inputs: List[float]) -> float:
"""Evaluate the network on the given inputs"""
input_nodes = self.find_input_nodes()
assert len(input_nodes) == len(inputs)
for input_node, value in zip(input_nodes, inputs):
input_node.value = value
return self.forward()
Ejercicio 2: La forma actual en que implementamos predict() es algo ineficiente ya que necesitamos recorrer el árbol para encontrar todas las entradas cada vez que ejecutamos predict(). Escriba un método compile() que almacene en caché las entradas de la operación cuando se ejecute.
Entrenar nuestros modelos ahora es muy sencillo:
from typing import Callable, Tupledef train_model(
model: Operation,
loss_fn: Callable[[float, float], float],
loss_grad_fn: Callable[[float, float], float],
data: List[Tuple[List[float], float]],
epochs: int=1000,
print_every: int=100
):
"""Train the given model on the given data"""
for epoch in range(epochs):
total_loss = 0.0
for x, y in data:
prediction = model.predict(x)
total_loss += loss_fn(y, prediction)
model.backward(loss_grad_fn(y, prediction))
if epoch % print_every == 0:
print(f'Epoch {epoch}: loss={total_loss/len(data)}')
Así es, por ejemplo, cómo entrenaríamos un clasificador lineal de Fahrenheit a Celsius usando nuestro marco:
def mse_loss(y_true: float, y_pred: float) -> float:
return (y_true - y_pred) ** 2def mse_loss_grad(y_true: float, y_pred: float) -> float:
return -2 * (y_true - y_pred)
def fahrenheit_to_celsius(x: float) -> float:
return (x - 32) * 5 / 9
def generate_f_to_c_data() -> List[List[float]]:
data = []
for _ in range(1000):
f = random.uniform(-1, 1)
data.append([[f], fahrenheit_to_celsius(f)])
return data
linear_classifier = Add([
Multiply([
Parameter(),
Input()
]),
Parameter()
])
train_model(linear_classifier, mse_loss, mse_loss_grad, generate_f_to_c_data())
Después de ejecutar esto, obtenemos
print(linear_classifier)
print(linear_classifier.predict([32]))>> Add(children=[Multiply(children=[Parameter(0.5555555555555556), Input(0.8930639016107234)]), Parameter(-17.777777777777782)])
>> -1.7763568394002505e-14
Lo que corresponde correctamente a un clasificador lineal con peso 0,56, sesgo -17,78 (que es la fórmula de Fahrenheit a Celsius)
Por supuesto, también podemos entrenar modelos mucho más complejos, por ejemplo, aquí hay uno para predecir si un punto (x, y) está por encima o por debajo de la línea y = x:
def bce_loss(y_true: float, y_pred: float, eps: float=0.00000001) -> float:
y_pred = min(max(y_pred, eps), 1 - eps)
return -y_true * math.log(y_pred) - (1 - y_true) * math.log(1 - y_pred)def bce_loss_grad(y_true: float, y_pred: float, eps: float=0.00000001) -> float:
y_pred = min(max(y_pred, eps), 1 - eps)
return (y_pred - y_true) / (y_pred * (1 - y_pred))
def generate_binary_data():
data = []
for _ in range(1000):
x = random.uniform(-1, 1)
y = random.uniform(-1, 1)
data.append([(x, y), 1 if y > x else 0])
return data
model_binary = Sigmoid(
[
Add(
[
Multiply(
[
Parameter(),
ReLU(
[
Add(
[
Multiply(
[
Parameter(),
Input()
]
),
Multiply(
[
Parameter(),
Input()
]
),
Parameter()
]
)
]
)
]
),
Parameter()
]
)
]
)
train_model(model_binary, bce_loss, bce_loss_grad, generate_binary_data())
Entonces obtenemos razonablemente
print(model_binary.predict([1, 0]))
print(model_binary.predict([0, 1]))
print(model_binary.predict([0, 1000]))
print(model_binary.predict([-5, 3]))
print(model_binary.predict([0, 0]))>> 3.7310797619230176e-66
>> 0.9997781079343139
>> 0.9997781079343139
>> 0.9997781079343139
>> 0.23791579184662365
Aunque tiene un tiempo de ejecución razonable, es algo más lento de lo que esperaríamos. Esto se debe a que tenemos que llamar a forward() y volver a calcular las entradas del modelo. mucho en la llamada al revés(). Como tal, realice el siguiente ejercicio:
Ejercicio 3: Agregar almacenamiento en caché a nuestra red. Es decir, en la llamada a forward(), el modelo debe devolver el valor almacenado en caché de la llamada anterior a forward() si y sólo si las entradas no han cambiado desde la última llamada. Asegúrese de ejecutar forward() nuevamente si las entradas han cambiado.
¡Y eso es todo! Ahora tenemos un marco de red neuronal funcional en el que podemos entrenar muchos modelos interesantes (aunque no redes con nodos que alimentan a muchos otros nodos. Esto no es demasiado difícil de agregar; consulte la nota en la discusión de la cadena). regla), aunque es un poco detallado. Si desea mejorarlo, pruebe algunos de los siguientes:
Ejercicio 4: Si lo piensas bien, los nodos más “complejos” de nuestra red (por ejemplo, capas lineales) son en realidad simplemente “macros” en cierto sentido, es decir, si tuviéramos un árbol de red neuronal que tuviera, por ejemplo, el siguiente aspecto:
lo que realmente estás haciendo es esto:
En otras palabras, Lineal(entrada) es en realidad sólo una macro para un árbol que contiene |entrada| + 1 parámetros, el primero de los cuales son pesos en la multiplicación y el último es un sesgo. Cada vez que vemos Lineal(entrada)podemos sustituirlo por un árbol equivalente compuesto únicamente de operaciones elementales.
Para este ejercicio, su trabajo es implementar el Macro clase. La clase debe ser una Operación que recursivamente se reemplaza con operaciones elementales
Nota: este paso se puede realizar en cualquier momento, aunque probablemente sea más fácil agregar un método compile() a la clase Operación que debe llamar antes del entrenamiento (o agregarlo a su método existente del Ejercicio 2). Por supuesto, también podemos implementar nodos más complejos de otras formas (quizás más eficientes), pero sigue siendo un buen ejercicio.
Ejercicio 5: Aunque en realidad nunca necesitamos que los nodos internos produzcan nada más que un número como salida, a veces es bueno que la raíz de nuestro árbol (es decir, nuestra capa de salida) produzca algo más (por ejemplo, una lista de números en el caso de una Softmax). Implementar el Producción clase y permitirle producir un Listof[float] en lugar de simplemente un flotador. Como beneficio adicional, intente implementar la salida de SoftMax.
Nota: hay algunas formas de hacer esto. Puede hacer que la salida extienda la operación y luego modificar el método op() de la clase NeuralNetNode para devolver una lista.[float] en lugar de simplemente un flotador. Alternativamente, puede crear una nueva superclase de Nodo que extienda tanto la Salida como la Operación. Probablemente esto sea más fácil.
Tenga en cuenta además que, aunque estas salidas pueden producir listas, solo obtendrán una derivada de la función de pérdida: la función de pérdida simplemente tomará una lista de flotantes en lugar de un flotante (por ejemplo, la pérdida de entropía cruzada categórica).
Ejercicio 6: ¿Recuerda que anteriormente en el artículo dijimos que las redes neuronales son solo funciones matemáticas compuestas de operaciones elementales? Añade el funcificar() método a la clase NeuralNetNode que la convierte en una función escrita en notación legible por humanos (agregue paréntesis como desee). Por ejemplo, la red neuronal Agregar([Parameter(0.1), Parameter(0.2)]) debería colapsar a “0,1 + 0,2” (o “(0,1 + 0,2)”).
Nota: Para que esto funcione, las entradas deben tener un nombre. Si realizó el ejercicio 2, asigne un nombre a sus entradas en la función compilar(). De lo contrario, tendrá que encontrar una manera de nombrar sus entradas; escribir una función compile() sigue siendo probablemente la forma más fácil.
Ejercicio 7: Modifique nuestro marco para permitir que los nodos tengan varios padres. Esto lo resolveré en la parte 2.
¡Eso es todo por ahora! Si desea consultar el código, puede consultar esta colaboración de google eso lo tiene todo (excepto las soluciones para todos los ejercicios excepto el 6, aunque puedo agregarlas en la parte 2).
Contactame en mchak@calpoly.edu para cualquier consulta.
A menos que se especifique lo contrario, todas las imágenes son del autor.