Capítulo 1. Fundamentos Fundamentos

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

No memorices estas fórmulas. Si entiendes los conceptos, puedes inventar tu propia notación.

John Cochrane, Notas sobre inversiones 2006

El objetivo de este capítulo en es explicar algunos modelos mentales fundamentales que son esenciales para comprender cómo funcionan las redes neuronales. En concreto, trataremos las funciones matemáticas anidadas y sus derivadas. Partiremos de los bloques de construcción más sencillos posibles para demostrar que podemos construir funciones complicadas formadas por una "cadena" de funciones constituyentes e, incluso cuando una de estas funciones sea una multiplicación de matrices que toma múltiples entradas, calcular la derivada de las salidas de las funciones con respecto a sus entradas. Entender cómo funciona este proceso será esencial para comprender las redes neuronales, que técnicamente no empezaremos a tratar hasta el Capítulo 2.

Mientras nos orientamos en torno a estos elementos fundamentales de las redes neuronales, describiremos sistemáticamente cada concepto que introduzcamos desde tres perspectivas:

  • Matemáticas, en forma de ecuación o ecuaciones

  • Código, con la menor sintaxis extra posible (lo que hace de Python una opción ideal)

  • Un diagrama que explique lo que ocurre, del tipo que dibujarías en una pizarra durante una entrevista de codificación

Como menciona en el prefacio, uno de los retos de la comprensión de las redes neuronales es que requiere múltiples modelos mentales. Tendremos una idea de ello en este capítulo: cada una de estas tres perspectivas excluye ciertas características esenciales de los conceptos que trataremos, y sólo cuando se toman en conjunto proporcionan una imagen completa tanto de cómo como de por qué las funciones matemáticas anidadas funcionan del modo en que lo hacen. De hecho, tengo la firme opinión de que cualquier intento de explicar los componentes básicos de las redes neuronales que excluya una de estas tres perspectivas es incompleto.

Una vez aclarado esto, es hora de dar los primeros pasos. Vamos a empezar con algunos bloques de construcción extremadamente sencillos para ilustrar cómo podemos entender diferentes conceptos en términos de estas tres perspectivas. Nuestro primer bloque de construcción será un concepto sencillo pero fundamental: la función.

Funciones

¿Qué es una función y cómo la describimos? Al igual que ocurre con las redes neuronales, hay varias formas de describir las funciones, ninguna de las cuales, individualmente, pinta un cuadro completo. En lugar de intentar dar una descripción concisa de una sola frase, vamos a recorrer simplemente los tres modelos mentales uno a uno, interpretando el papel de los ciegos que palpan distintas partes del elefante.

Matemáticas

Aquí hay dos ejemplos de funciones, descritas en notación matemática:

  • f1(x) = x2

  • f2(x) = max(x, 0)

Esta notación dice que las funciones, que arbitrariamente llamamos f1 y f2, toman como entrada un número x y lo transforman en x2 (en el primer caso) o en max(x, 0) (en el segundo caso).

Diagramas

Una forma de representar las funciones es:

  1. Dibuja un plano x-y (donde x se refiere al eje horizontal e y al eje vertical).

  2. Traza un puñado de puntos, donde las coordenadas x de los puntos son entradas (normalmente espaciadas uniformemente) de la función sobre un cierto intervalo, y las coordenadas y son las salidas de la función sobre ese intervalo.

  3. Une estos puntos trazados.

Esto lo hizo por primera vez el filósofo francés René Descartes, y es extremadamente útil en muchas áreas de las matemáticas, en particular en el cálculo. La figura 1-1 muestra el diagrama de estas dos funciones.

Two continuous, mostly differentiable functions
Figura 1-1. Dos funciones continuas, mayoritariamente diferenciables

Sin embargo, en hay otra forma de representar las funciones que no es tan útil cuando aprendemos cálculo, pero que nos será muy útil cuando pensemos en modelos de aprendizaje profundo. Podemos pensar en las funciones como cajas que reciben números como entrada y producen números como salida, como minifábricas que tienen sus propias reglas internas sobre lo que ocurre con la entrada. La Figura 1-2 muestra tanto estas funciones descritas como reglas generales como la forma en que operan con entradas específicas.

Another way of looking at functions
Figura 1-2. Otra forma de ver estas funciones

Código

Por último, en podemos describir estas funciones mediante código. Antes de hacerlo, deberíamos hablar un poco de la biblioteca de Python sobre la que escribiremos nuestras funciones: NumPy.

Advertencia sobre el código nº 1: NumPy

NumPy es una biblioteca de Python muy utilizada para el cálculo numérico rápido, cuyo funcionamiento interno está escrito en su mayor parte en C. En pocas palabras: los datos con los que tratamos en las redes neuronales siempre se almacenan en en una matriz multidimensional que casi siempre es unidimensional, bidimensional, tridimensional o cuatridimensional, pero especialmente bidimensional o tridimensional. La clase ndarray de la biblioteca NumPy nos permite operar con estas matrices de forma (a) intuitiva y (b) rápida. Por poner el ejemplo más sencillo posible: si almacenáramos nuestros datos en listas (o listas de listas) de Python, sumar o multiplicar las listas por elementos utilizando la sintaxis normal no funcionaría, mientras que sí funciona para ndarrays:

print("Python list operations:")
a = [1,2,3]
b = [4,5,6]
print("a+b:", a+b)
try:
    print(a*b)
except TypeError:
    print("a*b has no meaning for Python lists")
print()
print("numpy array operations:")
a = np.array([1,2,3])
b = np.array([4,5,6])
print("a+b:", a+b)
print("a*b:", a*b)
Python list operations:
a+b: [1, 2, 3, 4, 5, 6]
a*b has no meaning for Python lists

numpy array operations:
a+b: [5 7 9]
a*b: [ 4 10 18]

ndarrays también tienen varias características que esperarías de una matriz n-dimensional; cada ndarray tiene n ejes, indexados desde 0, de modo que el primer eje es 0, el segundo es 1, y así sucesivamente. En concreto, como a menudo tratamos con ndarrays en 2D, podemos pensar en axis = 0 como las filas y en axis = 1 como las columnas -ver Figura 1-3.

Simple NumPy array example
Figura 1-3. Una matriz NumPy 2D, con eje = 0 como filas y eje = 1 como columnas

Los ndarrayde NumPy también permiten aplicar funciones a lo largo de estos ejes de forma intuitiva. Por ejemplo, sumar a lo largo del eje 0 (las filas de una matriz 2D) básicamente "colapsa la matriz" a lo largo de ese eje, devolviendo una matriz con una dimensión menos que la matriz original; para una matriz 2D, esto equivale a sumar cada columna:

print('a:')
print(a)
print('a.sum(axis=0):', a.sum(axis=0))
print('a.sum(axis=1):', a.sum(axis=1))
a:
[[1 2]
 [3 4]]
a.sum(axis=0): [4 6]
a.sum(axis=1): [3 7]

Por último, NumPy ndarrays admite añadir una matriz 1D al último eje; para una matriz 2D a con R filas y C columnas, esto significa que podemos añadir una matriz 1D b de longitud C y NumPy hará la suma de forma intuitiva, añadiendo los elementos a cada fila de a:1

a = np.array([[1,2,3],
              [4,5,6]])

b = np.array([10,20,30])

print("a+b:\n", a+b)
a+b:
[[11 22 33]
 [14 25 36]]

Advertencia de código nº 2: Funciones con comprobación de tipo

Como he mencionado en, el objetivo principal del código que escribimos en este libro es que los conceptos que explico sean precisos y claros. Esto será más difícil a medida que avance el libro, ya que escribiremos funciones con muchos argumentos como parte de clases complicadas. Para combatirlo, utilizaremos funciones con firmas de tipo en todo el libro; por ejemplo, en el capítulo 3, inicializaremos nuestras redes neuronales de la siguiente manera:

def __init__(self,
             layers: List[Layer],
             loss: Loss,
             learning_rate: float = 0.01) -> None:

Esta firma de tipo por sí sola te da una idea de para qué se utiliza la clase. Por el contrario, considera la siguiente firma de tipo que podríamos utilizar para definir una operación:

def operation(x1, x2):

Esta firma de tipo por sí sola no te da ninguna pista sobre lo que está pasando; sólo imprimiendo el tipo de cada objeto, viendo qué operaciones se realizan en cada objeto o adivinando basándonos en los nombres x1 y x2 podríamos entender lo que está pasando en esta función. En cambio, puedo definir una función con una firma de tipo como la siguiente

def operation(x1: ndarray, x2: ndarray) -> ndarray:

Enseguida sabrás que se trata de una función que recibe dos ndarrays, probablemente los combina de algún modo y devuelve el resultado de esa combinación. Debido a la mayor claridad que proporcionan, utilizaremos funciones con comprobación de tipo a lo largo de este libro.

Funciones básicas en NumPy

Con estos preliminares en mente, vamos a escribir en NumPy las funciones que definimos anteriormente:

def square(x: ndarray) -> ndarray:
    '''
    Square each element in the input ndarray.
    '''
    return np.power(x, 2)

def leaky_relu(x: ndarray) -> ndarray:
    '''
    Apply "Leaky ReLU" function to each element in ndarray.
    '''
    return np.maximum(0.2 * x, x)
Nota

Una de las peculiaridades de NumPy es que muchas funciones pueden aplicarse a ndarrays escribiendo np.function_name(ndarray) o escribiendo ndarray.function_name. Por ejemplo, la función relu anterior podría escribirse como : x.clip(min=0). Intentaremos ser coherentes y utilizar la convención np.function_name(ndarray) en particular, evitaremos trucos como ndarray.T para transponer un ndarray bidimensional, en lugar de escribir np.transpose(ndarray, (1, 0)).

Si puedes comprender el hecho de que las matemáticas, un diagrama y el código son tres formas diferentes de representar el mismo concepto subyacente, entonces estás en el buen camino para mostrar el tipo de pensamiento flexible que necesitarás para comprender realmente el aprendizaje profundo.

Derivados

Las derivadas, como las funciones, son un concepto muy importante para comprender el aprendizaje profundo con el que probablemente muchos de vosotros estéis familiarizados. También como las funciones, pueden representarse de múltiples formas. Empezaremos diciendo simplemente a alto nivel que la derivada de una función en un punto es la "tasa de cambio" de la salida de la función con respecto a su entrada en ese punto. Recorramos ahora las mismas tres perspectivas de las derivadas que cubrimos para las funciones, a fin de obtener un mejor modelo mental de cómo funcionan las derivadas.

Matemáticas

En primer lugar, nos pondremos matemáticamente precisos: podemos describir este número -cuánto cambia la salida de f cuando cambiamos su entrada en un valor concreto a de la entrada- como un límite:

df du ( a ) = lim Δ0 fa+Δ-fa-Δ 2×Δ

Este límite puede aproximarse numéricamente fijando un valor muy pequeño para Δ, como 0,001, de modo que podamos calcular la derivada como:

df du ( a ) = f(a+0.001)-f(a-0.001) 0.002

Aunque exacto, esto es sólo una parte de un modelo mental completo de los derivados. Veámoslos desde otra perspectiva: un diagrama.

Diagramas

En primer lugar, la forma conocida de: si simplemente trazamos una recta tangente a la representación cartesiana de la función f, la derivada de f en un punto a no es más que la pendiente de esta recta en a. Al igual que con las descripciones matemáticas del subapartado anterior, hay dos formas de calcular realmente la pendiente de esta recta. La primera sería utilizar el cálculo para calcular realmente el límite. La segunda sería simplemente tomar la pendiente de la recta que une f en a - 0,001 y a + 0,001. Este último método se representa en la Figura 1-4 y debería resultar familiar a cualquiera que haya estudiado cálculo.

dlfs 0104
Figura 1-4. Derivadas como pendientes

Como vimos en la sección anterior, otra forma de pensar en las funciones es como minifábricas. Ahora piensa que las entradas a esas fábricas están conectadas a las salidas por una cuerda. La derivada es igual a la respuesta a esta pregunta: si subimos la entrada de la función a en una cantidad muy pequeña -o, para tener en cuenta el hecho de que la función puede ser asimétrica en a, bajamos a en una cantidad pequeña-, ¿en qué múltiplo de esta pequeña cantidad cambiará la salida, dado el funcionamiento interno de la fábrica? Esto se representa en la Figura 1-5.

dlfs 0105
Figura 1-5. Otra forma de visualizar las derivadas

Esta segunda representación resultará ser más importante que la primera para comprender el aprendizaje profundo.

Código

Por último, en podemos codificar la aproximación a la derivada que vimos anteriormente:

from typing import Callable

def deriv(func: Callable[[ndarray], ndarray],
          input_: ndarray,
          delta: float = 0.001) -> ndarray:
    '''
    Evaluates the derivative of a function "func" at every element in the
    "input_" array.
    '''
    return (func(input_ + delta) - func(input_ - delta)) / (2 * delta)
Nota

Cuando decimos que "algo es función de otra cosa" -por ejemplo, que P es función de E (letras elegidas al azar a propósito)-, lo que queremos decir es que existe alguna función f tal que f(E) = P -o, equivalentemente, que existe una función f que toma objetos E y produce objetos P-. También podemos pensar que esto significa que P se define como lo que resulta cuando aplicamos la función f a E:

Another way of visualizing functions

Y lo codificaríamos como:

def f(input_: ndarray) -> ndarray:
    # Some transformation(s)
    return output

P = f(E)

Funciones anidadas

Ahora trataremos un concepto que resultará fundamental para comprender las redes neuronales: las funciones pueden "anidarse" para formar funciones "compuestas". ¿Qué quiero decir exactamente con "anidadas"? Quiero decir que si tenemos dos funciones que por convención matemática llamamos f1 y f2, la salida de una de las funciones se convierte en la entrada de la siguiente, de modo que podemos "encadenarlas".

Diagrama

La forma más natural de representar una función anidada es con la representación "minifábrica" o "caja" (la segunda representación de "Funciones").

Como muestra la Figura 1-6, una entrada entra en la primera función, se transforma y sale; luego entra en la segunda función y se transforma de nuevo, y obtenemos nuestra salida final.

f1 and f2 as a chain
Figura 1-6. Funciones anidadas, naturalmente

Matemáticas

Nosotros también deberíamos incluir la representación matemática menos intuitiva:

f 2 ( f 1 ( x ) ) = y

Esto es menos intuitivo debido a la peculiaridad de que las funciones anidadas se leen "de fuera hacia dentro", pero las operaciones se realizan en realidad "de dentro hacia fuera". Por ejemplo, aunque f 2 ( f 1 ( x ) ) = y se lee "f 2 de f 1 de x", lo que realmente significa es "aplicar primero f1 a x, y luego aplicar f2 al resultado de aplicar f1 a x".

Código

Por último, en cumpliendo mi promesa de explicar cada concepto desde tres perspectivas, vamos a codificarlo. En primer lugar, definiremos un tipo de datos para las funciones anidadas:

from typing import List

# A Function takes in an ndarray as an argument and produces an ndarray
Array_Function = Callable[[ndarray], ndarray]

# A Chain is a list of functions
Chain = List[Array_Function]

Luego definiremos cómo pasan los datos por una cadena, primero de longitud 2:

def chain_length_2(chain: Chain,
                   a: ndarray) -> ndarray:
    '''
    Evaluates two functions in a row, in a "Chain".
    '''
    assert len(chain) == 2, \
    "Length of input 'chain' should be 2"

    f1 = chain[0]
    f2 = chain[1]

    return f2(f1(x))

Otro esquema

Si representamos la función anidada mediante la representación de caja, veremos que esta función compuesta es en realidad una única función. Así, podemos representar esta función simplemente como f1 f2, como se muestra en la Figura 1-7.

f1f2 nested
Figura 1-7. Otra forma de ver las funciones anidadas

Además, un teorema del cálculo nos dice que una función compuesta formada por funciones "mayoritariamente diferenciables" ¡es a su vez mayoritariamente diferenciable! Por tanto, podemos pensar en f1f2 como otra función de la que podemos calcular derivadas, y calcular derivadas de funciones compuestas resultará esencial para entrenar modelos de aprendizaje profundo.

Sin embargo, necesitamos una fórmula para poder calcular la derivada de esta función compuesta en función de las derivadas de sus funciones constituyentes. Eso es lo que veremos a continuación.

La regla de la cadena

La regla de la cadena es un teorema matemático que nos permite calcular derivadas de funciones compuestas. Los modelos de aprendizaje profundo son, matemáticamente, funciones compuestas, y razonar sobre sus derivadas es esencial para entrenarlos, como veremos en los próximos capítulos.

Matemáticas

Matemáticamente, el teorema afirma -de forma poco intuitiva- que, para un valor dado x,

df 2 du ( x ) = df 2 du ( f 1 ( x ) ) × df 1 du ( x )

donde u es simplemente una variable ficticia que representa la entrada de una función.

Nota

Cuando describimos la derivada de una función f con una entrada y una salida, podemos denotar la función que representa la derivada de esta función como df du . Podríamos utilizar una variable ficticia distinta en lugar de u;no importa, igual que f(x) = x2 y f(y) = y2 significan lo mismo.

Por otra parte, más adelante trataremos con funciones que reciben varias entradas, por ejemplo, x e y. Una vez allí, tendrá sentido escribir df dx y que signifique algo distinto de df dy .

Por eso en la fórmula anterior denotamos todas las derivadas con una u en la parte inferior: tanto f1 como f2 son funciones que toman una entrada y producen una salida, y en tales casos (de funciones con una entrada y una salida) utilizaremos u en la notación de la derivada.

Diagrama

La fórmula anterior de no da mucha intuición sobre la regla de la cadena. Para ello, la representación de la caja es mucho más útil. Vamos a razonar cuál "debería" ser la derivada en el caso simple de f1 f2.

f1f2 nested
Figura 1-8. Ilustración de la regla de la cadena

Intuitivamente, utilizando el diagrama de la Figura 1-8, la derivada de la función compuesta debería ser una especie de producto de las derivadas de sus funciones constituyentes. Digamos que introducimos el valor 5 en la primera función, y digamos además que el cálculo de la derivada de la primera función en u = 5 nos da un valor de 3, es decir df 1 du ( 5 ) = 3 .

Digamos que tomamos entonces el valor de la función que sale de la primera casilla -supongamos que es 1, de modo que f1(5) = 1- y calculamos la derivada de la segunda función f2 en este valor: es decir df 2 du ( 1 ) . Comprobamos que este valor es -2.

Si pensamos en estas funciones como si estuvieran literalmente encadenadas, entonces si cambiar la entrada de la casilla dos en 1 unidad produce un cambio de -2 unidades en la salida de la casilla dos, cambiar la entrada de la casilla dos en 3 unidades debería cambiar la salida de la casilla dos en -2 × 3 = -6 unidades. Por eso, en la fórmula de la regla de la cadena, el resultado final es, en última instancia, un producto: df 2 du ( f 1 ( x ) ) veces df 1 du ( x ) .

Así que, teniendo en cuenta el diagrama y las matemáticas, podemos razonar cuál debe ser la derivada de la salida de una función anidada respecto a su entrada, utilizando la regla de la cadena. ¿Qué aspecto podrían tener las instrucciones del código para el cálculo de esta derivada?

Código

Vamos a codificar para mostrar que calcular las derivadas de este modo produce resultados que "parecen correctos". Utilizaremos la función square de "Funciones básicas en NumPy" junto con sigmoid, otra función que acaba siendo importante en el aprendizaje profundo:

def sigmoid(x: ndarray) -> ndarray:
    '''
    Apply the sigmoid function to each element in the input ndarray.
    '''
    return 1 / (1 + np.exp(-x))

Y ahora codificamos la regla de la cadena:

def chain_deriv_2(chain: Chain,
                  input_range: ndarray) -> ndarray:
    '''
    Uses the chain rule to compute the derivative of two nested functions:
    (f2(f1(x))' = f2'(f1(x)) * f1'(x)
    '''

    assert len(chain) == 2, \
    "This function requires 'Chain' objects of length 2"

    assert input_range.ndim == 1, \
    "Function requires a 1 dimensional ndarray as input_range"

    f1 = chain[0]
    f2 = chain[1]

    # df1/dx
    f1_of_x = f1(input_range)

    # df1/du
    df1dx = deriv(f1, input_range)

    # df2/du(f1(x))
    df2du = deriv(f2, f1(input_range))

    # Multiplying these quantities together at each point
    return df1dx * df2du

La Figura 1-9 representa los resultados y muestra que la regla de la cadena funciona:

PLOT_RANGE = np.arange(-3, 3, 0.01)

chain_1 = [square, sigmoid]
chain_2 = [sigmoid, square]

plot_chain(chain_1, PLOT_RANGE)
plot_chain_deriv(chain_1, PLOT_RANGE)

plot_chain(chain_2, PLOT_RANGE)
plot_chain_deriv(chain_2, PLOT_RANGE)
Chain rule illustration
Figura 1-9. La regla de la cadena funciona, parte 1

La regla de la cadena parece funcionar. Cuando las funciones tienen pendiente ascendente, la derivada es positiva; cuando son planas, la derivada es cero; y cuando tienen pendiente descendente, la derivada es negativa.

Así que, de hecho, podemos calcular, tanto matemáticamente como mediante código, las derivadas de funciones anidadas o "compuestas", como f1 f2, siempre que las funciones individuales sean a su vez mayoritariamente diferenciables.

Resultará que los modelos de aprendizaje profundo son, matemáticamente, largas cadenas de estas funciones, en su mayoría diferenciables; dedicar tiempo a repasar manualmente y en detalle un ejemplo un poco más largo te ayudará a construir tu intuición sobre lo que ocurre y cómo puede generalizarse a modelos más complejos.

Un ejemplo un poco más largo

Examinemos detenidamente en una cadena un poco más larga: si tenemos tres funciones mayoritariamente diferenciables -f1, f2 y f3- , ¿cómo podríamos calcular la derivada de f1 f2 f3? Deberíamos" poder hacerlo, ya que por el teorema de cálculo mencionado anteriormente, sabemos que el compuesto de cualquier número finito de funciones "mayoritariamente diferenciables" es diferenciable.

Matemáticas

Matemáticamente, el resultado resulta ser la siguiente expresión:

df 3 du ( x ) = df 3 du ( f 2 ( f 1 ( x ) ) ) × df 2 du ( f 1 ( x ) ) × df 1 du ( x ) )

La lógica subyacente de por qué la fórmula funciona para cadenas de longitud 2, df 2 du ( x ) = df 2 du ( f 1 ( x ) ) × df 1 du ( x ) también se aplica en este caso, ¡al igual que la falta de intuición que se desprende de la mera observación de la fórmula!

Diagrama

La mejor forma de ver (literalmente) por qué esta fórmula tiene sentido es mediante otro diagrama de caja, como el que se muestra en la Figura 1-10.

dlfs 0110
Figura 1-10. El "modelo de caja" para calcular la derivada de tres funciones anidadas

Utilizando un razonamiento similar al de la sección anterior: si imaginamos que la entrada a f1 f2 f3 (llamémosla a) está conectada a la salida (llamémosla b) por una cadena, entonces cambiar a en una pequeña cantidad Δ producirá un cambio en f1(a) de df 1 du ( x ) ) por Δ, lo que dará lugar a un cambio en f 2 ( f 1 ( x ) ) (el siguiente paso en la cadena) de df 2 du ( f 1 ( x ) ) × df 1 du ( x ) ) multiplicado por Δ, y así sucesivamente en el tercer paso, cuando lleguemos al cambio final igual a la fórmula completa de la regla en cadena anterior multiplicada por Δ. Dedica un poco de tiempo a repasar esta explicación y el diagrama anterior, pero no demasiado, ya que desarrollaremos aún más intuición al respecto cuando lo codifiquemos.

Código

¿Cómo podríamos traducir dicha fórmula en instrucciones de código para calcular la derivada, dadas las funciones constituyentes? Curiosamente, ya en este sencillo ejemplo vemos los comienzos de lo que serán los pases hacia delante y hacia atrás de una red neuronal:

def chain_deriv_3(chain: Chain,
                  input_range: ndarray) -> ndarray:
    '''
    Uses the chain rule to compute the derivative of three nested functions:
    (f3(f2(f1)))' = f3'(f2(f1(x))) * f2'(f1(x)) * f1'(x)
    '''

    assert len(chain) == 3, \
    "This function requires 'Chain' objects to have length 3"

    f1 = chain[0]
    f2 = chain[1]
    f3 = chain[2]

    # f1(x)
    f1_of_x = f1(input_range)

    # f2(f1(x))
    f2_of_x = f2(f1_of_x)

    # df3du
    df3du = deriv(f3, f2_of_x)

    # df2du
    df2du = deriv(f2, f1_of_x)

    # df1dx
    df1dx = deriv(f1, input_range)

    # Multiplying these quantities together at each point
    return df1dx * df2du * df3du

Aquí ocurrió algo interesante: para calcular la regla de la cadena de esta función anidada, hicimos dos "pasadas" sobre ella:

  1. En primer lugar, pasamos "hacia delante" a través de él, calculando las cantidades f1_of_x y f2_of_x por el camino. Podemos llamar a esto (y pensar en ello como) "el paso adelante".

  2. A continuación, "retrocedimos" en la función, utilizando las cantidades que calculamos en la pasada hacia delante para calcular las cantidades que componen la derivada.

Por último, multiplicamos tres de estas cantidades para obtener nuestra derivada.

Ahora, demostremos que esto funciona, utilizando las tres funciones sencillas que hemos definido hasta ahora: sigmoid, square, y leaky_relu.

PLOT_RANGE = np.range(-3, 3, 0.01)
plot_chain([leaky_relu, sigmoid, square], PLOT_RANGE)
plot_chain_deriv([leaky_relu, sigmoid, square], PLOT_RANGE)

La Figura 1-11 muestra el resultado.

dlfs 0111
Figura 1-11. La regla de la cadena funciona, incluso con funciones triplemente anidadas

De nuevo, comparando los gráficos de las derivadas con las pendientes de las funciones originales, vemos que la regla de la cadena está calculando correctamente las derivadas.

Apliquemos ahora nuestros conocimientos a las funciones compuestas con múltiples entradas, una clase de funciones que sigue los mismos principios que ya hemos establecido y que, en última instancia, es más aplicable al aprendizaje profundo.

Funciones con múltiples entradas

Con este punto, tenemos una comprensión conceptual de cómo las funciones pueden encadenarse para formar funciones compuestas. También sabemos cómo representar estas funciones como series de cajas en las que entran entradas y salen salidas. Por último, hemos visto cómo calcular las derivadas de estas funciones, de modo que entendemos estas derivadas tanto matemáticamente como cantidades calculadas mediante un proceso paso a paso con un componente "hacia delante" y otro "hacia atrás".

A menudo, las funciones que tratamos en el aprendizaje profundo no tienen una sola entrada. En su lugar, tienen varias entradas que en determinados pasos se suman, multiplican o combinan de alguna otra forma. Como veremos, calcular las derivadas de las salidas de estas funciones con respecto a sus entradas sigue sin ser un problema: consideremos un escenario muy sencillo con varias entradas, en el que dos entradas se suman y luego se alimentan a través de otra función.

Matemáticas

Para este ejemplo, en realidad es útil empezar mirando las matemáticas. Si nuestras entradas son x e y, podríamos pensar que la función se produce en dos pasos. En el paso 1, x e y pasan por una función que las suma. Denotaremos esa función como α (utilizaremos letras griegas para referirnos a los nombres de las funciones en todo momento) y la salida de la función como a. Formalmente, esto es simplemente:

a = α ( x , y ) = x + y

El paso 2 sería alimentar a a través de alguna función σ puede ser cualquier función continua, como sigmoid, o la función square, o incluso una función cuyo nombre no empiece por s). Denotaremos la salida de esta función como s:

s = σ ( a )

Podríamos, equivalentemente, denotar la función entera como f y escribir:

f ( x , y ) = σ ( x + y )

Esto es más conciso matemáticamente, pero oculta el hecho de que en realidad se trata de dos operaciones que suceden secuencialmente. Para ilustrarlo, necesitamos el diagrama de la sección siguiente.

Diagrama

Ahora que nos encontramos en la fase en la que examinamos funciones con múltiples entradas, hagamos una pausa para definir un concepto sobre el que hemos estado danzando: los diagramas con círculos y flechas que los conectan y que representan el "orden de las operaciones" matemáticas pueden considerarse gráficos computacionales. Por ejemplo, la Figura 1-12 muestra un gráfico computacional de la función f que acabamos de describir.

dlfs 0112
Figura 1-12. Función con múltiples entradas

Aquí vemos las dos entradas que entran en α y salen como a y luego se alimentan a través de σ.

Código

La codificación es muy sencilla, pero hay que añadir una afirmación más:

def multiple_inputs_add(x: ndarray,
                        y: ndarray,
                        sigma: Array_Function) -> float:
    '''
    Function with multiple inputs and addition, forward pass.
    '''
    assert x.shape == y.shape

    a = x + y
    return sigma(a)

A diferencia de las funciones que vimos anteriormente en este capítulo, esta función no opera simplemente "elemento a elemento" en cada elemento de su entrada ndarrays. Siempre que tratemos con una operación que tome múltiples ndarrays como entradas, tenemos que comprobar sus formas para asegurarnos de que cumplen las condiciones que requiera esa operación. En este caso, para una operación sencilla como la suma, todo lo que tenemos que comprobar es que las formas sean idénticas para que la suma pueda producirse en el sentido de los elementos.

Derivadas de funciones con múltiples entradas

En no debería sorprender que podamos calcular la derivada de la salida de dicha función con respecto a sus dos entradas.

Diagrama

Conceptualmente, simplemente hacemos lo mismo que hicimos en el caso de las funciones con una entrada: calcular la derivada de cada función constituyente "yendo hacia atrás" a través del gráfico computacional y luego multiplicar los resultados para obtener la derivada total. Esto se muestra en la Figura 1-13.

dlfs 0113
Figura 1-13. Retroceder por el gráfico computacional de una función con múltiples entradas

Matemáticas

La regla de la cadena se aplica a estas funciones del mismo modo que a las funciones de las secciones anteriores. Como se trata de una función anidada, con f ( x , y ) = σ ( α ( x , y ) ) tenemos:

f x = σ u ( α ( x , y ) ) × α x ( ( x , y ) ) = σ u ( x + y ) × α x ( ( x , y ) )

Y por supuesto f y serían idénticos.

Ahora ten en cuenta que:

α x ( ( x , y ) ) = 1

ya que por cada aumento unitario de x, a aumenta una unidad, sea cual sea el valor de x (lo mismo vale para y).

Teniendo esto en cuenta, podemos codificar cómo calcular la derivada de dicha función.

Código

def multiple_inputs_add_backward(x: ndarray,
                                 y: ndarray,
                                 sigma: Array_Function) -> float:
    '''
    Computes the derivative of this simple function with respect to
    both inputs.
    '''
    # Compute "forward pass"
    a = x + y

    # Compute derivatives
    dsda = deriv(sigma, a)

    dadx, dady = 1, 1

    return dsda * dadx, dsda * dady

Un ejercicio sencillo para el lector es modificar esto para el caso en que x y y se multipliquen en lugar de sumarse.

A continuación, examinaremos un ejemplo más complicado que imita más de cerca lo que ocurre en el aprendizaje profundo: una función similar al ejemplo anterior, pero con dos entradas vectoriales.

Funciones con múltiples entradas vectoriales

En el aprendizaje profundo , tratamos con funciones cuyas entradas son vectores o matrices. Estos objetos no sólo se pueden sumar, multiplicar, etc., sino que también se pueden combinar mediante un producto punto o una multiplicación matricial. En el resto de este capítulo, mostraré cómo pueden seguir aplicándose las matemáticas de la regla de la cadena y la lógica del cálculo de las derivadas de estas funciones mediante un paso hacia delante y hacia atrás.

Estas técnicas de acabarán siendo fundamentales para comprender por qué funciona el aprendizaje profundo. En el aprendizaje profundo, nuestro objetivo será ajustar un modelo a unos datos. Más concretamente, esto significa que queremos encontrar una función matemática que asigne las observaciones de los datos -que serán las entradas de la función- a algunas predicciones deseadas de los datos -que serán las salidas de la función- de la forma más óptima posible. Resulta que estas observaciones se codificarán en matrices, normalmente con una fila como observación y cada columna como característica numérica de esa observación. Trataremos esto con más detalle en el próximo capítulo; por ahora, será esencial poder razonar sobre las derivadas de funciones complejas que impliquen productos punto y multiplicaciones matriciales.

Empecemos por definir con precisión lo que quiero decir, matemáticamente.

Matemáticas

Una forma típica de representar un único punto de datos, u "observación", en una red neuronal es como una fila con n características, donde cada característica es simplemente un número x1, x2, y así sucesivamente, hasta xn:

X = x 1 x 2 ... x n

Un ejemplo canónico a tener en cuenta aquí es la predicción de los precios de la vivienda, para lo que construiremos una red neuronal desde cero en el próximo capítulo; en este ejemplo, x1, x2, etc. son características numéricas de una casa, como sus metros cuadrados o su proximidad a las escuelas.

Crear nuevas funciones a partir de funciones existentes

Tal vez la operación más común en las redes neuronales sea formar una "suma ponderada" de estas características, donde la suma ponderada podría enfatizar ciertas características y restar énfasis a otras y, por tanto, considerarse como una nueva característica que en sí misma no es más que una combinación de características antiguas. Una forma concisa de expresar esto matemáticamente es como un producto punto de esta observación, con algún conjunto de "pesos" de la misma longitud que las características, w1, w2, y así sucesivamente, hasta wn. Exploremos este concepto desde las tres perspectivas que hemos utilizado hasta ahora en este capítulo.

Matemáticas

Para ser matemáticamente precisos, si

W = w 1 w 2 w n

entonces podríamos definir la salida de esta operación como:

N = ν ( X , W ) = X × W = x 1 × w 1 + x 2 × w 2 + ... + x n × w n

Observa que esta operación es un caso especial de multiplicación de matrices que resulta ser un producto punto porque X tiene una fila y W una sola columna.

A continuación, veamos algunas formas de representar esto con un diagrama.

Diagrama

En la Figura 1-14 se muestra una forma sencilla de representar esta operación.

dlfs 0114
Figura 1-14. Diagrama de un producto punto vectorial

Este diagrama representa una operación que toma dos entradas, ambas pueden ser ndarrays, y produce una salida ndarray.

Pero en realidad se trata de una enorme abreviatura de muchas operaciones que ocurren en muchas entradas. En su lugar, podríamos destacar las operaciones y entradas individuales, como se muestra en las Figuras 1-15 y 1-16.

dlfs 0115
Figura 1-15. Otro diagrama de una multiplicación de matrices
dlfs 0116
Figura 1-16. Tercer diagrama de una multiplicación de matrices

El punto clave es que el producto punto (o multiplicación de matrices) es una forma concisa de representar muchas operaciones individuales; además, como empezaremos a ver en la siguiente sección, utilizar esta operación hace que nuestros cálculos de derivadas en el paso hacia atrás sean también extremadamente concisos.

Código

Por último, en código esta operación es simplemente:

def matmul_forward(X: ndarray,
                   W: ndarray) -> ndarray:
    '''
    Computes the forward pass of a matrix multiplication.
    '''

    assert X.shape[1] == W.shape[0], \
    '''
    For matrix multiplication, the number of columns in the first array should
    match the number of rows in the second; instead the number of columns in the
    first array is {0} and the number of rows in the second array is {1}.
    '''.format(X.shape[1], W.shape[0])

    # matrix multiplication
    N = np.dot(X, W)

    return N

donde tenemos una nueva afirmación que garantiza que la multiplicación de matrices funcionará. (Esto es necesario porque es nuestra primera operación que no se limita a tratar con ndarrayque tienen el mismo tamaño y realiza una operación de forma elemental: ahora nuestra salida tiene un tamaño distinto al de la entrada).

Derivadas de funciones con varias entradas vectoriales

Para las funciones que simplemente toman una entrada como número y producen una salida, como f(x) = x2 o f(x) = sigmoide(x), calcular la derivada es sencillo: simplemente aplicamos las reglas del cálculo. En el caso de las funciones vectoriales, no es evidente cuál es la derivada: si escribimos un producto punto como ν ( X , W ) = N como en la sección anterior, surge naturalmente la pregunta: ¿cuál sería N X y N W ¿ser?

Diagrama

Conceptualmente, sólo queremos hacer algo como lo de la Figura 1-17.

dlfs 0117
Figura 1-17. Paso hacia atrás de una multiplicación de matrices, conceptualmente

Calcular estas derivadas era fácil cuando sólo tratábamos con la suma y la multiplicación, como en los ejemplos anteriores. Pero, ¿cómo podemos hacer algo análogo con la multiplicación de matrices? Para definirlo con precisión, tendremos que recurrir a las matemáticas.

Matemáticas

En primer lugar, ¿cómo definiríamos siquiera "la derivada respecto a una matriz"? Recordando que la sintaxis matricial no es más que la abreviatura de un montón de números dispuestos de una forma determinada, "la derivada respecto a una matriz" significa en realidad "la derivada respecto a cada elemento de la matriz". Como X es una fila, una forma natural de definirla es

ν X = ν x 1 ν x 2 ν x 3

Sin embargo, la salida de ν es sólo un número: N = x 1 × w 1 + x 2 × w 2 + x 3 × w 3 . Y observando esto, podemos ver que si, por ejemplo x 1 cambia en ϵ unidades, entonces N cambiará en w 1 × ϵ unidades, y la misma lógica se aplica a los demás elementos xi. Así pues:

ν x 1 = w 1
ν x 2 = w 2
ν x 3 = w 3

Y así:

ν X = w 1 w 2 w 3 = W T

Se trata de un resultado sorprendente y elegante que resulta ser una pieza clave del rompecabezas para comprender tanto por qué funciona el aprendizaje profundo como cómo puede implementarse de forma tan limpia.

Utilizando un razonamiento similar, podemos ver que:

ν W = x 1 x 2 x 3 = X T

Código

Aquí, razonar matemáticamente sobre cuál "debería" ser la respuesta era la parte difícil. La parte fácil es codificar el resultado:

def matmul_backward_first(X: ndarray,
                          W: ndarray) -> ndarray:
    '''
    Computes the backward pass of a matrix multiplication with respect to the
    first argument.
    '''

    # backward pass
    dNdX = np.transpose(W, (1, 0))

    return dNdX

La cantidad dNdX calculada aquí representa la derivada parcial de cada elemento de X con respecto a la suma de la salida N. Hay un nombre especial para esta cantidad que utilizaremos a lo largo del libro: la llamaremos el gradiente de X con respecto a X. La idea es que para un elemento individual de X -digamos, x3- el elemento correspondiente en dNdx (dNdX[2], para ser específicos) es la derivada parcial de la salida del producto punto vectorial N con respecto a x3. El término "gradiente", tal y como lo utilizaremos en este libro, se refiere simplemente a un análogo multidimensional de la derivada parcial; en concreto, es una matriz de derivadas parciales de la salida de una función con respecto a cada elemento de la entrada de dicha función.

Funciones vectoriales y sus derivadas: Un paso más allá

Los modelos de aprendizaje profundo, por supuesto, implican más de una operación: incluyen largas cadenas de operaciones, algunas de las cuales son funciones vectoriales como la tratada en la última sección, y otras simplemente aplican una función elemento a elemento a la ndarray que reciben como entrada. Por tanto, ahora veremos cómo calcular la derivada de una función compuesta que incluya ambos tipos de funciones. Supongamos que nuestra función toma los vectores X y W, realiza el producto punto descrito en el apartado anterior -que denotaremos como ν ( X , W ) -y luego alimenta los vectores a través de una función σ. Expresaremos el mismo objetivo que antes, pero en un nuevo lenguaje: queremos calcular los gradientes de la salida de esta nueva función con respecto a X y W. De nuevo, a partir del próximo capítulo, veremos con detalle preciso cómo se conecta esto con lo que hacen las redes neuronales, pero por ahora sólo queremos construir la idea de que podemos calcular gradientes para grafos computacionales de complejidad arbitraria.

Diagrama

El diagrama de esta función, mostrado en la Figura 1-18, es el mismo que el de la Figura 1-17, con la función σ simplemente añadida al final.

dlfs 0118
Figura 1-18. El mismo gráfico anterior, pero con otra función añadida al final

Matemáticas

Matemáticamente, esto también es sencillo:

s = f ( X , W ) = σ ( ν ( X , W ) ) = σ ( x 1 × w 1 + x 2 × w 2 + x 3 × w 3 )

Código

Por último, podemos codificar esta función como

def matrix_forward_extra(X: ndarray,
                         W: ndarray,
                         sigma: Array_Function) -> ndarray:
    '''
    Computes the forward pass of a function involving matrix multiplication,
    one extra function.
    '''
    assert X.shape[1] == W.shape[0]

    # matrix multiplication
    N = np.dot(X, W)

    # feeding the output of the matrix multiplication through sigma
    S = sigma(N)

    return S

Funciones vectoriales y sus derivadas: El paso atrás

Del mismo modo, el paso atrás no es más que una extensión directa del ejemplo anterior.

Matemáticas

Puesto que f(X, W) es una función anidada -específicamente, f(X, W) = σ(X, W))- su derivada con respecto a, por ejemplo, X debería ser conceptualmente:

f X = σ u ( ν ( X , W ) ) × ν X ( X , W )

Pero la primera parte de esto es simplemente:

σ u ( ν ( X , W ) ) = σ u ( x 1 × w 1 + x 2 × w 2 + x 3 × w 3 )

que está bien definida, ya que σ no es más que una función continua cuya derivada podemos evaluar en cualquier punto, y aquí sólo la estamos evaluando en x 1 × w 1 + x 2 × w 2 + x 3 × w 3 .

Además, en el ejemplo anterior razonamos que ν X ( X , W ) = W T . Por tanto:

f X = σ u ( ν ( X , W ) ) × ν X ( X , W ) = σ u ( x 1 × w 1 + x 2 × w 2 + x 3 × w 3 ) × W T

que, como en el ejemplo anterior, da como resultado un vector de la misma forma que X, ya que la respuesta final es un número, σ u ( x 1 × w 1 + x 2 × w 2 + x 3 × w 3 ) veces un vector de la misma forma que X en WT.

Diagrama

El diagrama para el paso hacia atrás de esta función, que se muestra en la Figura 1-19, es similar al del ejemplo anterior e incluso de nivel superior al matemático; sólo tenemos que añadir una multiplicación más basada en la derivada de la función σ evaluada en el resultado de la multiplicación matricial.

dlfs 0119
Figura 1-19. Gráfico con una multiplicación matricial: el paso hacia atrás

Código

Por último, la codificación del paso hacia atrás también es sencilla:

def matrix_function_backward_1(X: ndarray,
                               W: ndarray,
                               sigma: Array_Function) -> ndarray:
    '''
    Computes the derivative of our matrix function with respect to
    the first element.
    '''
    assert X.shape[1] == W.shape[0]

    # matrix multiplication
    N = np.dot(X, W)

    # feeding the output of the matrix multiplication through sigma
    S = sigma(N)

    # backward calculation
    dSdN = deriv(sigma, N)

    # dNdX
    dNdX = np.transpose(W, (1, 0))

    # multiply them together; since dNdX is 1x1 here, order doesn't matter
    return np.dot(dSdN, dNdX)

Observa que aquí vemos la misma dinámica que en el ejemplo anterior con las tres funciones anidadas: calculamos cantidades en el paso hacia delante (aquí, sólo N) que luego utilizamos durante el paso hacia atrás.

¿Es así?

¿Cómo podemos saber si las derivadas que estamos calculando son correctas? Una prueba sencilla es perturbar un poco la entrada y observar el cambio resultante en la salida. Por ejemplo, X en este caso es

print(X)
[[ 0.4723  0.6151 -1.7262]]

Si aumentamos x3 en 0,01, de -1.726 a -1.716, deberíamos ver un aumento en el valor producido por la función hacia delante del gradiente de la salida con respecto a x3 × 0,01. La figura 1-20 lo muestra.

dlfs 0120
Figura 1-20. Comprobación del gradiente: una ilustración

Utilizando la función matrix_function_backward_1, podemos ver que el gradiente es -0.1121:

print(matrix_function_backward_1(X, W, sigmoid))
[[ 0.0852 -0.0557 -0.1121]]

Para comprobar si este gradiente es correcto, deberíamos ver, tras incrementar x3 en 0,01, una disminución correspondiente en la salida de la función de aproximadamente 0.01 × -0.1121 = -0.001121; si viéramos una disminución mayor o menor que esta cantidad, o un aumento, por ejemplo, sabríamos que nuestro razonamiento sobre la regla de la cadena estaba equivocado. Lo que vemos cuando hacemos este cálculo2 sin embargo, es que aumentar x3 en una pequeña cantidad sí disminuye el valor de la salida de la función en 0.01 × -0.1121-¡lo que significa que las derivadas que estamos calculando son correctas!

Para terminar este capítulo, veremos un ejemplo que se basa en todo lo que hemos hecho hasta ahora y que se aplica directamente a los modelos que construiremos en el próximo capítulo: un gráfico computacional que comienza multiplicando juntas un par de matrices bidimensionales.

Gráfico computacional con dos entradas de matriz 2D

En el aprendizaje profundo de, y en el aprendizaje automático en general, tratamos con operaciones que toman como entrada dos matrices 2D, una de las cuales representa un lote de datos X y la otra representa los pesos W. En el próximo capítulo, profundizaremos en por qué esto tiene sentido en un contexto de modelado, pero en este capítulo nos centraremos sólo en la mecánica y las matemáticas que hay detrás de esta operación. En concreto, recorreremos en detalle un ejemplo sencillo y demostraremos que, incluso cuando se trata de multiplicaciones de matrices 2D, en lugar de simples productos punto de vectores 1D, el razonamiento que hemos estado utilizando a lo largo de este capítulo sigue teniendo sentido matemático y, de hecho, es extremadamente fácil de codificar.

Como antes, las matemáticas necesarias para obtener estos resultados se vuelven... no difíciles, pero sí confusas. Sin embargo, el resultado es bastante limpio. Y, por supuesto, lo desglosaremos paso a paso y lo relacionaremos siempre tanto con el código como con los diagramas.

Matemáticas

Supongamos que

X = x 11 x 12 x 13 x 21 x 22 x 23 x 31 x 32 x 33

y:

W = w 11 w 12 w 21 w 22 w 31 w 32

Esto podría corresponder a un conjunto de datos en el que cada observación tiene tres características, y las tres filas podrían corresponder a tres observaciones diferentes para las que queremos hacer predicciones.

Ahora definiremos las siguientes operaciones sencillas con estas matrices:

  1. Multiplica estas matrices. Como antes, denotaremos la función que lo hace como ν(X, W) y el resultado como N, de modo que N = ν(X, W).

  2. Alimenta N a través de alguna función diferenciable σ, y define(S = σ(N).

Como antes, la pregunta ahora es: ¿cuáles son los gradientes de la salida S con respecto a X y W? ¿Podemos volver a utilizar simplemente la regla de la cadena? ¿Por qué sí o por qué no?

Si piensas un poco en esto, te darás cuenta de que algo es diferente de los ejemplos anteriores que hemos visto: S es ahora una matriz, no simplemente un número. Y, después de todo, ¿qué significa el gradiente de una matriz respecto a otra matriz?

Esto nos lleva a una idea sutil pero importante: podemos realizar cualquier serie de operaciones en matrices multidimensionales que queramos, pero para que la noción de "gradiente" con respecto a algún resultado esté bien definida, necesitamos sumar (o agregar de otro modo en un único número) la matriz final de la secuencia para que la noción de "cuánto afectará al resultado cambiar cada elemento de X " tenga siquiera sentido.

Así que añadiremos al final una tercera función, Lambda, que simplemente toma los elementos de S y los suma.

Concretemos esto matemáticamente. Primero, multipliquemos X y W:

X × W = x 11 × w 11 + x 12 × w 21 + x 13 × w 31 x 11 × w 12 + x 12 × w 22 + x 13 × w 32 x 21 × w 11 + x 22 × w 21 + x 23 × w 31 x 21 × w 12 + x 22 × w 22 + x 23 × w 32 x 31 × w 11 + x 32 × w 21 + x 33 × w 31 x 31 × w 12 + x 32 × w 22 + x 33 × w 32 = X W 11 X W 12 X W 21 X W 22 X W 31 X W 32

donde denotamos la fila i y la columna j de la matriz resultante como X W ij por comodidad.

A continuación, pasaremos este resultado por σ, lo que sólo significa aplicar σ a cada elemento de la matriz X × W :

σ ( X × W ) = σ ( x 11 × w 11 + x 12 × w 21 + x 13 × w 31 ) σ ( x 11 × w 12 + x 12 × w 22 + x 13 × w 32 ) σ ( x 21 × w 11 + x 22 × w 21 + x 23 × w 31 ) σ ( x 21 × w 12 + x 22 × w 22 + x 23 × w 32 ) σ ( x 31 × w 11 + x 32 × w 21 + x 33 × w 31 ) σ ( x 31 × w 12 + x 32 × w 22 + x 33 × w 32 ) = σ ( X W 11 ) σ ( X W 12 ) σ ( X W 21 ) σ ( X W 22 ) σ ( X W 31 ) σ ( X W 32 )

Por último, podemos resumir simplemente estos elementos:

L = Λ ( σ ( X × W ) ) = Λ ( σ ( X W 11 ) σ ( X W 12 ) σ ( X W 21 ) σ ( X W 22 ) σ ( X W 31 ) σ ( X W 32 ) ) = σ ( X W 11 ) + σ ( X W 12 ) + σ ( X W 21 ) + σ ( X W 22 ) + σ ( X W 31 ) + σ ( X W 32 )

Ahora estamos de nuevo en un entorno de cálculo puro: tenemos un número, L, y queremos averiguar el gradiente de L con respecto a X y W; es decir, queremos saber cuánto cambiaría L al cambiar cada elemento de estas matrices de entrada(x11, w21, etc.). Podemos escribirlo como

Λ u ( X ) = Λ u ( x 11 ) Λ u ( x 12 ) Λ u ( x 13 ) Λ u ( x 21 ) Λ u ( x 22 ) Λ u ( x 23 ) Λ u ( x 31 ) Λ u ( x 32 ) Λ u ( x 33 )

Y ahora entendemos matemáticamente el problema al que nos enfrentamos. Hagamos una pausa en las matemáticas durante un segundo y pongámonos al día con nuestro diagrama y código.

Diagrama

Conceptualmente, lo que estamos haciendo aquí es similar a lo que hemos hecho en los ejemplos anteriores con un grafo computacional con múltiples entradas; por tanto, la Figura 1-21 debería resultarte familiar.

dlfs 0121
Figura 1-21. Gráfica de una función con un paso adelante complicado

Simplemente estamos enviando entradas hacia adelante como antes. Afirmamos que incluso en este escenario más complicado, deberíamos ser capaces de calcular los gradientes que necesitamos utilizando la regla de la cadena.

Código

Nosotros podemos codificar esto como:

def matrix_function_forward_sum(X: ndarray,
                                W: ndarray,
                                sigma: Array_Function) -> float:
    '''
    Computing the result of the forward pass of this function with
    input ndarrays X and W and function sigma.
    '''
    assert X.shape[1] == W.shape[0]

    # matrix multiplication
    N = np.dot(X, W)

    # feeding the output of the matrix multiplication through sigma
    S = sigma(N)

    # sum all the elements
    L = np.sum(S)

    return L

La parte divertida: El pase hacia atrás

Ahora queremos "realizar el paso hacia atrás" de esta función, mostrando cómo, incluso cuando interviene una multiplicación matricial, podemos acabar calculando el gradiente de N con respecto a cada uno de los elementos de nuestra entrada ndarrays.3 Con este último paso resuelto, empezar a entrenar modelos reales de aprendizaje automático en el Capítulo 2 será sencillo. En primer lugar, recordemos lo que estamos haciendo, conceptualmente.

Diagrama

De nuevo, lo que estamos haciendo en es similar a lo que hemos hecho en los ejemplos anteriores de este capítulo; la Figura 1-22 debería resultarte tan familiar como lo fue la Figura 1-21.

dlfs 0122
Figura 1-22. Paso atrás a través de nuestra complicada función

Sólo tenemos que calcular la derivada parcial de cada función constitutiva y evaluarla en su entrada, multiplicando los resultados para obtener la derivada final. Consideremos cada una de estas derivadas parciales por separado.

Matemáticas

Observemos primero en que podríamos calcularlo directamente. En efecto, el valor L es una función de x11, x12, etc., hasta x33.

Sin embargo, eso parece complicado. ¿No era el objetivo de la regla de la cadena descomponer las derivadas de funciones complicadas en partes sencillas, calcular cada una de esas partes y luego multiplicar los resultados? De hecho, ese hecho fue lo que hizo tan fácil codificar estas cosas: simplemente pasamos paso a paso por el paso hacia delante, guardando los resultados a medida que avanzamos, y luego utilizamos esos resultados para evaluar todas las derivadas necesarias para el paso hacia atrás.

Demostraré que este enfoque sólo funciona cuando hay matrices de por medio. Vamos a ello.

Podemos escribir L como Λ ( σ ( ν ( X , W ) ) ) . Si se tratara de una función regular, escribiríamos simplemente la regla de la cadena:

Λ X ( X ) = ν X ( X , W ) × σ u ( N ) × Λ u ( S )

Entonces calcularíamos sucesivamente cada una de las tres derivadas parciales. Esto es exactamente lo que hicimos antes en la función de tres funciones anidadas, para la que calculamos la derivada utilizando la regla de la cadena, y la Figura 1-22 sugiere que ese enfoque debería funcionar también para esta función.

La primera derivada es la más sencilla y, por tanto, la que mejor calienta. Queremos saber cuánto aumentará L (la salida de Λ) si aumenta cada elemento de S. Como L es la suma de todos los elementos de S, esta derivada es simplemente:

Λ u ( S ) = 1 1 1 1 1 1

ya que aumentar cualquier elemento de S en, digamos, 0,46 unidades, aumentaría Λ en 0,46 unidades.

A continuación, tenemos σ u ( N ) . Se trata simplemente de la derivada de la función σ, evaluada en los elementos de N. En la sintaxis"XW" que hemos utilizado anteriormente, también es fácil de calcular:

σ u ( X W 11 ) σ u ( X W 12 ) σ u ( X W 21 ) σ u ( X W 22 ) σ u ( X W 31 ) σ u ( X W 32 )

Observa que en este punto podemos decir con certeza que podemos multiplicar estas dos derivadas elementalmente y calcular L u ( N ) :

Λ u ( N ) = Λ u ( S ) × σ u ( N ) = σ u ( X W 11 ) σ u ( X W 12 ) σ u ( X W 21 ) σ u ( X W 22 ) σ u ( X W 31 ) σ u ( X W 32 ) × 1 1 1 1 1 1 = σ u ( X W 11 ) σ u ( X W 12 ) σ u ( X W 21 ) σ u ( X W 22 ) σ u ( X W 31 ) σ u ( X W 32 )

Ahora, sin embargo, estamos atascados. Lo siguiente que queremos, basándonos en el diagrama y aplicando la regla de la cadena, es ν u ( X ) . Recuerda, sin embargo, que N, la salida de ν, no era más que el resultado de una multiplicación matricial de X por W. Por tanto, queremos alguna noción de cuánto aumentará cada elemento de X (una matriz de 3 × 3) cada elemento de N (una matriz de 3 × 2). Si te cuesta hacerte a la idea de ese concepto, esa es la cuestión: no está nada claro cómo lo definiríamos, ni siquiera si sería útil hacerlo.

¿Por qué es un problema ahora? Antes, nos encontrábamos en la afortunada situación de que X y W eran transposiciones entre sí en cuanto a la forma. En ese caso, podíamos demostrar que ν u ( X ) = W T y ν u ( W ) = X T . ¿Hay algo análogo que podamos decir aquí?

El "?"

Más concretamente, aquí es donde estamos atascados. Tenemos que averiguar qué va en el "?

Λ u ( X ) = Λ u ( σ ( N ) ) × ? = σ u ( X W 11 ) σ u ( X W 12 ) σ u ( X W 21 ) σ u ( X W 22 ) σ u ( X W 31 ) σ u ( X W 32 ) × ?

La respuesta

Resulta que, por la forma en que funciona la multiplicación, lo que rellena el "?" es simplemente WT, ¡como en el ejemplo más sencillo con el producto punto vectorial que acabamos de ver! La forma de comprobarlo es calcular directamente la derivada parcial de L con respecto a cada elemento de X; al hacerlo4 la matriz resultante se factoriza en:

Λ u ( X ) = Λ u ( S ) × σ u ( N ) × W T

donde la primera multiplicación es elemental, y la segunda es una multiplicación matricial.

Esto significa que , incluso si las operaciones de nuestro grafo computacional implican multiplicar matrices con múltiples filas y columnas, e incluso si las formas de las salidas de esas operaciones son diferentes de las de las entradas, aún podemos incluir esas operaciones en nuestro grafo computacional y retropropagarnos a través de ellas utilizando la lógica de la "regla en cadena". Se trata de un resultado crítico, sin el cual entrenar modelos de aprendizaje profundo sería mucho más engorroso, como podrás apreciar más adelante en el próximo capítulo.

Código

Vamos a encapsular lo que acabamos de derivar utilizando código, y esperamos solidificar nuestra comprensión en el proceso:

def matrix_function_backward_sum_1(X: ndarray,
                                   W: ndarray,
                                   sigma: Array_Function) -> ndarray:
    '''
    Compute derivative of matrix function with a sum with respect to the
    first matrix input.
    '''
    assert X.shape[1] == W.shape[0]

    # matrix multiplication
    N = np.dot(X, W)

    # feeding the output of the matrix multiplication through sigma
    S = sigma(N)

    # sum all the elements
    L = np.sum(S)

    # note: I'll refer to the derivatives by their quantities here,
    # unlike the math, where we referred to their function names

    # dLdS - just 1s
    dLdS = np.ones_like(S)

    # dSdN
    dSdN = deriv(sigma, N)

    # dLdN
    dLdN = dLdS * dSdN

    # dNdX
    dNdX = np.transpose(W, (1, 0))

    # dLdX
    dLdX = np.dot(dSdN, dNdX)

    return dLdX

Ahora vamos a comprobar que todo ha funcionado:

np.random.seed(190204)
X = np.random.randn(3, 3)
W = np.random.randn(3, 2)

print("X:")
print(X)

print("L:")
print(round(matrix_function_forward_sum(X, W, sigmoid), 4))
print()
print("dLdX:")
print(matrix_function_backward_sum_1(X, W , sigmoid))
X:
[[-1.5775 -0.6664  0.6391]
 [-0.5615  0.7373 -1.4231]
 [-1.4435 -0.3913  0.1539]]
L:
2.3755

dLdX:
[[ 0.2489 -0.3748  0.0112]
 [ 0.126  -0.2781 -0.1395]
 [ 0.2299 -0.3662 -0.0225]]

Como en el ejemplo anterior, puesto que dLdX representa el gradiente de X con respecto a L, esto significa que, por ejemplo, el elemento superior izquierdo indica que Λ x 11 ( X , W ) = 0.2489 . Por tanto, si la matemática matricial de este ejemplo fuera correcta, el aumento de x11 en 0,001 debería aumentar L en 0.01 × 0.2489. Efectivamente, vemos que esto es lo que ocurre:

X1 = X.copy()
X1[0, 0] += 0.001

print(round(
        (matrix_function_forward_sum(X1, W, sigmoid) - \
         matrix_function_forward_sum(X, W, sigmoid)) / 0.001, 4))
0.2489

¡Parece que los gradientes se han calculado correctamente!

Describir visualmente estos gradientes

En volvemos a lo que señalamos al principio del capítulo, alimentamos el elemento en cuestión, x11, a través de una función con muchas operaciones: había una multiplicación matricial -que en realidad era una forma abreviada de combinar las nueve entradas de la matriz X con las seis entradas de la matriz W para crear seis salidas-, la función sigmoid, y luego la suma. Sin embargo, también podemos pensar en esto como una única función llamada, digamos, " W N S L ", como se muestra en la Figura 1-23.

dlfs 0123
Figura 1-23. Otra forma de describir la función anidada: como una función, "WNSL"

Como cada función es diferenciable, el conjunto no es más que una única función diferenciable, con x11 como entrada; por tanto, el gradiente no es más que la respuesta a la pregunta, ¿cuál es dL dx 11 ? Para visualizarlo, podemos trazar simplemente cómo cambia L a medida que cambia x11. Observando el valor inicial de x11, vemos que es -1.5775:

print("X:")
print(X)
X:
[[-1.5775 -0.6664  0.6391]
 [-0.5615  0.7373 -1.4231]
 [-1.4435 -0.3913  0.1539]]

Si trazamos el valor de L que resulta de introducir X y W en el gráfico computacional definido anteriormente -o, para representarlo de otro modo, de introducir X y W en la función llamada en el código anterior- sin cambiar nada excepto el valor de x11 (o X[0, 0]), el gráfico resultante se parece a la Figura 1-24.5

dlfs 0124
Figura 1-24. L frente a x11, manteniendo constantes otros valores de X y W

De hecho, observando esta relación en el caso de x11, parece que la distancia que aumenta esta función a lo largo del eje L es aproximadamente 0,5 (de algo más de 2,1 a algo más de 2,6), y sabemos que estamos mostrando un cambio de 2 a lo largo del eje x11, lo que haría que la pendiente fuera aproximadamente 0.5 2 = 0.25 -¡que es exactamente lo que acabamos de calcular!

Así que nuestra complicada matemática matricial parece que nos ha permitido calcular correctamente la derivada parcial L con respecto a cada elemento de X. Además, el gradiente de L con respecto a W podría calcularse de forma similar.

Nota

La expresión del gradiente de L respecto a W sería XT. Sin embargo, debido al orden en que la expresión XT factoriza la derivada de L, XT estaría en el lado izquierdo de la expresión del gradiente de L respecto a W:

Λ u ( W ) = X T × Λ u ( S ) × σ u ( N )

En código, por tanto, mientras tendríamos dNdW = np.transpose(X, (1, 0)), el siguiente paso sería:

dLdW = np.dot(dNdW, dSdN)

en lugar de dLdX = np.dot(dSdN, dNdX) como antes.

Conclusión

Después de este capítulo, deberías tener la seguridad de que puedes entender funciones matemáticas anidadas complicadas y razonar su funcionamiento conceptualizándolas como una serie de cajas, cada una de las cuales representa una única función constituyente, conectadas por cadenas. Concretamente, puedes escribir código para calcular las derivadas de las salidas de dichas funciones con respecto a cualquiera de las entradas, incluso cuando haya multiplicaciones de matrices que impliquen ndarrays bidimensionales, y comprender las matemáticas que hay detrás de por qué estos cálculos de derivadas son correctos. Estos conceptos básicos son exactamente lo que necesitaremos para empezar a construir y entrenar redes neuronales en el próximo capítulo, y para construir y entrenar modelos de aprendizaje profundo desde cero en los capítulos siguientes. ¡Adelante!

1 Esto nos permitirá añadir fácilmente un sesgo a nuestra multiplicación matricial más adelante.

2 En todo momento proporcionaré enlaces a material complementario relevante en un repositorio de GitHub que contiene el código del libro, incluido el de este capítulo.

3 En el apartado siguiente nos centraremos en calcular el gradiente de N con respecto a X, pero el gradiente con respecto a W podría razonarse de forma similar.

4 Lo hacemos en "Regla de la cadena matricial".

5 La función completa se puede encontrar en la página web del libro; es simplemente un subconjunto de la función matrix function backward sum mostrada en la página anterior.

Get Aprendizaje profundo desde cero now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.