Elementos de redes neuronales#

Este ensayo está inspirado en micrograd de Andrej Karpathy, así como su texto sobre redes neuronales [Karpathy, circa 2014]. A continuación, construiremos una red neuronal desde cero, explicando a detalle cada uno de sus elementos.

La derivada demostrada según el orden geométrico#

Las redes neuronales son gigantescas funciones matemáticas. La característica distintiva de estas funciones, que catalizan gran parte de lo que hoy se denomina «inteligencia artificial» (aunque, más estrictamente, deberíamos hablar de «aprendizaje profundo» o deep learning), es que pueden «aprender» a refinar, optimizar y generalizar las «reglas» (parámetros) que las componen. Tal proceso de aprendizaje consiste en la iteración de operaciones matemáticas propias del cálculo diferencial y vectorial. Por ello, para poder formular una red neuronal, procuraremos primero entender intuitivamente a la derivada: esa médula matemática del aprendizaje.

Para tal propósito, construiremos la derivada desde cero, utilizando apenas unas cuantas nociones básicas de geometría.

En geometría, denominamos «pendiente» al valor de la inclinación de una recta. Conceptualmente, la pendiente puede ser interpretada como la proporción del cambio que hay entre las variables \(x\) y \(y\) de la recta. En otras palabras, la pendiente mide qué tanto influye \(x\) en el valor de \(y\), o qué tanto cambia \(y\) cuando avanzamos en \(x\). Supongamos, por ejemplo, la recta de una función que podemos definir como \(f(x, b) = x \cdot b\), donde:

!pip install matplotlib --upgrade
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
x = np.arange(0, 21, 2)
b = 5
def f(x, b): return x*b
y = f(x, b)

d = {'Valores de x': x, 'Valores de b': b, 'Valores de y': y}
tabla = pd.DataFrame(data=d)
tabla = tabla.style.hide_index()
tabla
Valores de x Valores de b Valores de y
0 5 0
2 5 10
4 5 20
6 5 30
8 5 40
10 5 50
12 5 60
14 5 70
16 5 80
18 5 90
20 5 100

En términos más simples, la función es una multiplicación entre \(x\) (números del \(0\) al \(20\) en intervalos de \(2\)) y \(b\) (es decir, \(5\)). Podemos visualizarla de la siguiente manera:

fig, ax = plt.subplots(figsize=(10, 7))
ax.plot(x, y, marker='o', color='blue') 
plt.xticks(ticks=x)
plt.yticks(ticks=y)
ax.set_facecolor('black')
ax.set_xlabel('Valores de x')
ax.set_ylabel('Valores de y')
ax.hlines(y=10, xmin=0, xmax=2, linewidth=1, color='white', linestyles='dashed')
ax.vlines(x=2, ymin=0, ymax=10, linewidth=1, color='white', linestyles='dashed')
plt.show()
../../_images/83de4665974c3cf8aa3acc9da3cad4a38d8e1947ad7b168c5c58deed7c6d5bdc.png

Ahora, ¿cómo puedo saber cuánto está influyendo cada valor de \(x\) en cada valor de \(y\)? Es decir, si paso de \(x=2\) a \(x=4\), ¿qué le pasa a \(y\)? \(y\) avanza desde \(10\) hasta \(20\), es decir, cinco veces \(x\): \(5 \times 2 = 10\). Y si paso de \(x=4\) a \(x=6\), es decir, si avanzo dos «pasos» en \(x\), \(y\) nuevamente avanza cinco veces lo que avancé en \(x\), pasando de \(20\) a \(30\).

En ese sentido, la proporción que hay entre \(x\) y \(y\) es de \(5\), \(5\) es el número que determina cada valor de \(y\) a partir de \(x\) o, dicho de otra forma, \(5\) es la «fuerza» con la que \(x\) influye en \(y\). Esta intuición es clave, pero al mismo tiempo resulta evidente porque la definición de mi función —o sea, cada valor de \(y\)— indica que debo multiplicar \(x\) por \(5\). Al calcular la pendiente, es como si no supiéramos este número y tuviéramos que deducirlo matemáticamente.

La fórmula de la pendiente nos ofrece un método general para calcular esta proporción o fuerza de la que hablamos:

\[ Pendiente = \frac{y_2 - y_1}{x_2 - x_1} = \frac{\Delta y}{\Delta x} \]

Entonces la fórmula solo nos dice que debemos tomar dos puntos cualesquiera de \(x\) y \(y\) para calcular la pendiente. Por ejemplo, si consideramos los valores del punto denotado por las líneas punteadas en la gráfica anterior, de tal manera que \(x_1 = 2, y_1 = 10\), podemos tomar el punto siguiente para calcular la pendiente o inclinación de la recta, de manera que:

\[ Pendiente = \frac{20 - 10}{4 - 2} = \frac{10}{2} = 5 \]

Pero las funciones lineales —es decir, las rectas—, por definición tienen la misma inclinación en todos sus puntos o segmentos. Comprobémoslo cambiando nuestras \(x_2, y_2\):

\[ Pendiente = \frac{y_2 - y_1}{x_2 - x_1} = \frac{60 - 10}{12 - 2} = \frac{50}{10} = 5 \]

Naturalmente, la pendiente sigue siendo la misma. Ahora reiteremos, ¿exactamente qué quiere decir el número cinco? Que por cada paso que damos en \(x\), \(y\) avanza cinco veces más. Dicho de otra forma, \(y\) es el resultado de multiplicar \(x\) por cinco; o \(x\) influye con una «fuerza» de cinco veces sí misma en \(y\); o \(x\) transforma a \(y\) con una fuerza de cinco veces sí misma.

Visualicemos la pendiente en color rojo:

pendiente = (y[6] - y[3]) / (x[6] - x[3])

print(f'Valor de la pendiente: {pendiente}')
fig, ax = plt.subplots(figsize=(10, 7))
ax.plot(x, y, marker='o', color='blue', label='recta de la función') 
plt.xticks(ticks=x)
ax.set_facecolor('black')
ax.set_xlabel('Valores de x')
ax.set_ylabel('Valores de y') 
ax.axline((x[6], y[6]), slope=pendiente, color='red', label='pendiente') 
ax.hlines(y=10, xmin=0, xmax=2, linewidth=1, color='white', linestyles='dashed')
ax.vlines(x=2, ymin=0, ymax=10, linewidth=1, color='white', linestyles='dashed')
ax.legend()
plt.show()
Valor de la pendiente: 5.0
../../_images/b8c65436183dd53125284f131aabecdc2793046d526bc7162d9dd26009045014.png

La pendiente es idéntica a la recta porque, como decíamos, la recta tiene la misma inclinación —pendiente— en todos sus puntos. Ahora, el problema con la pendiente es que esta propiedad es al mismo tiempo una limitación fundamental: la pendiente solo es válida para una recta o función lineal.

Dado que las funciones no lineales no tienen la misma inclinación en todos sus puntos, no podemos hablar de la «inclinación» ni de la «pendiente de una función no lineal». Visualicémoslo con una función cuadrática, donde mis valores de \(x\) ahora van del \(0\) al \(5.99\). Digamos también que me interesa saber la inclinación o «pendiente» de la función cuando \(x=5\):

x = np.arange(0, 6, 0.0001)
def nolineal(x): return x**2
y = nolineal(x)

pendiente = (5**2 - 2**2) / (5 - 2)

fig, ax = plt.subplots(figsize=(10, 7))
ax.plot(x, y, color='blue', label='gráfica de la función') 
ax.set_facecolor('black')
ax.set_xticks(np.arange(-5, 6, 1))
ax.set_xlabel('Valores de x')
ax.set_ylabel('Valores de y')
ax.set_ylim(min(y), max(y))
ax.set_xlim(min(x), max(x))
ax.axline((2, 4), slope=pendiente, color='red', label='pendiente?')
ax.hlines(y=4, xmin=0, xmax=2, linewidth=1, color='white', linestyles='dashed')
ax.hlines(y=25, xmin=0, xmax=5, linewidth=1, color='white', linestyles='dashed')
ax.vlines(x=2, ymin=0, ymax=4, linewidth=1, color='white', linestyles='dashed')
ax.vlines(x=5, ymin=0, ymax=25, linewidth=1, color='white', linestyles='dashed')
ax.legend()
plt.show()
../../_images/6e3b35a5c9c6202da51c3362206f7684b9afdb8a71e2b7ec2209bdb39a5bc46b.png

Para calcular la inclinación de la función (es decir, la curva azul) cuando \(x=5\), he utilizado la fórmula de la pendiente tomando como referencia los puntos \(x_1=2, x_2=5\), de tal manera que:

\[ Pendiente = \frac{y_2 - y_1}{x_2 - x_1} = \frac{25 - 4}{5 - 2} = \frac{21}{3} = 7 \]

Pero la realidad es que mi función no cambió en esa proporción con respecto al punto \(x=5\): esa pendiente de \(7\) corresponde a la secante[1] que atraviesa la función, no a la «pendiente» del punto \(x=5\). La gráfica evidencia que el resultado es inexacto: queda claro que la función tiene una inclinación distinta a la de la recta roja.

Además de que la función tiene múltiples «pendientes» —porque su inclinación siempre cambia—, estas «pendientes» son inexactas, puesto que no miden con precisión el impacto (o inclinación) de un solo punto en la función, sino que la fórmula de la pendiente necesariamente nos ofrece la inclinación de una recta trazada entre dos valores de \(x\) y dos valores de \(y\). Puesto que la función con la que lidiamos ahora es curva, los puntos de \(x\) y \(y\) que tomamos no son satisfactorios porque denotan una línea, no una curva. En ese sentido, el resultado está sesgado.

Entonces, ¿cómo puedo hacer para encontrar el verdadero impacto de \(x\) en \(y\)? Podemos pensar en el siguiente truco: si la pendiente de la secante, aunque errónea, se acerca de alguna forma a nuestro valor deseado, ¿no podríamos hacer una secante más pequeña, tal que fuera más similar al punto que nos interesa? Es decir, si tomáramos la pendiente de la distancia entre dos puntos de \(x\) más cercanos entre sí, eso tendría que darnos una mejor aproximación al resultado correcto, ¿no? Veámoslo así:

pendiente = (5**2 - 4**2) / (5 - 4)

print(f'Valor de la pendiente con una secante más pequeña: {pendiente}')
fig, ax = plt.subplots(ncols=2, figsize=(10, 7))
ax[0].plot(x, y, color='blue', label='gráfica de la función') 
ax[0].set_facecolor('black')
ax[0].set_xticks(np.arange(0, 6, 1))
ax[0].set_xlabel('Valores de x')
ax[0].set_ylabel('Valores de y / f(x)')
ax[0].axline((5, 25), slope=pendiente, color='red', label='pendiente?')
ax[0].hlines(y=16, xmin=0, xmax=4, linewidth=1, color='white', linestyles='dashed')
ax[0].hlines(y=25, xmin=0, xmax=5, linewidth=1, color='white', linestyles='dashed')
ax[0].vlines(x=4, ymin=0, ymax=16, linewidth=1, color='white', linestyles='dashed')
ax[0].vlines(x=5, ymin=0, ymax=25, linewidth=1, color='white', linestyles='dashed')
ax[0].legend()
ax[1].plot(x, y, color='blue', label='gráfica de la función') 
ax[1].set_facecolor('black')
ax[1].set_xticks(np.arange(-5, 6, 1))
ax[1].set_xlabel('Valores de x')
ax[1].set_ylabel('Valores de y / f(x)')
ax[1].set_title('Zoom')
ax[1].set_ylim(12, 30)
ax[1].set_xlim(2, 7)
ax[1].axline((5, 25), slope=pendiente, color='red', label='pendiente?')
ax[1].hlines(y=16, xmin=0, xmax=4, linewidth=1, color='white', linestyles='dashed')
ax[1].hlines(y=25, xmin=0, xmax=5, linewidth=1, color='white', linestyles='dashed')
ax[1].vlines(x=4, ymin=0, ymax=16, linewidth=1, color='white', linestyles='dashed')
ax[1].vlines(x=5, ymin=0, ymax=25, linewidth=1, color='white', linestyles='dashed')
ax[1].legend()
plt.show()
Valor de la pendiente con una secante más pequeña: 9.0
../../_images/8a1ba7a697012e7333a878585e69f8ca9cc154d059df4aa43b2839d7446ff6b5.png

Gráficamente podemos comprobar que hemos obtenido un mucho mejor resultado: la secante que trazamos tiene una inclinación similar a la de la curva de la función. Pero las líneas punteadas —las cuales denotan los puntos que hemos tomado como referencia para calcular la pendiente— nos indican que todavía podemos mejorar, tomando como referencia coordenadas más cercanas entre sí.

Bien, pues antes de continuar experimentando, adelantemos ya que esta línea de razonamiento es precisamente la que dio origen a la derivada. El truco es este: podemos calcular la pendiente de líneas secantes cada vez más pequeñas y similares a nuestro punto, obteniendo cada vez mejores resultados. Es más, podemos llegar a calcular la pendiente de una línea infinitamente pequeña, tan pequeña que sería casi idéntica al punto, y la pendiente de esa línea infinitamente pequeña sería igual a la pendiente del punto. En ese mismo sentido, podríamos decir incluso —y de hecho se hace así en el ámbito matemático— que estamos calculando la pendiente de una línea tangente al punto, puesto que su pendiente sería igual a la de una secante infinitamente pequeña que se confunde con el punto.

../../_images/derivada.png

Fig. 2 Como en la imagen, queremos obtener la pendiente de una secante cada vez más pequeña —más cercana al punto que nos interesa—, es decir, con una distancia \(h\) cada vez menor. Podemos hacer esta línea tan infinitamente pequeña que se identifique con el punto; por tanto, su pendiente sería igual a la pendiente de una línea tangente al punto.#

Utilizando la fórmula de la pendiente para poner en práctica nuestra intuición, podemos conseguir un resultado como este:

pendiente = (5**2 - 4.99**2) / (5 - 4.99)

print(f'Valor de la pendiente con una secante más pequeña: {pendiente}')
fig, ax = plt.subplots(ncols=2, figsize=(10, 7))
ax[0].plot(x, y, color='blue', label='gráfica de la función') 
ax[0].set_facecolor('black')
ax[0].set_xticks(np.arange(0, 6, 1))
ax[0].set_xlabel('Valores de x')
ax[0].set_ylabel('Valores de y / f(x)')
ax[0].axline((5, 25), slope=pendiente, color='red', label='pendiente?')
ax[0].hlines(y=4.99**2, xmin=0, xmax=4.99, linewidth=1, color='white', linestyles='dashed')
ax[0].hlines(y=25, xmin=0, xmax=5, linewidth=1, color='white', linestyles='dashed')
ax[0].vlines(x=4.99, ymin=0, ymax=4.99**2, linewidth=1, color='white', linestyles='dashed')
ax[0].vlines(x=5, ymin=0, ymax=25, linewidth=1, color='white', linestyles='dashed')
ax[0].legend()
ax[1].plot(x, y, color='blue', label='gráfica de la función') 
ax[1].set_facecolor('black')
ax[1].set_xticks(np.arange(-5, 6, 1))
ax[1].set_xlabel('Valores de x')
ax[1].set_ylabel('Valores de y / f(x)')
ax[1].set_title('Zoom')
ax[1].set_ylim(22, 28)
ax[1].set_xlim(2, 7)
ax[1].axline((5, 25), slope=pendiente, color='red', label='pendiente?')
ax[1].hlines(y=4.99**2, xmin=0, xmax=4.99, linewidth=1, color='white', linestyles='dashed')
ax[1].hlines(y=25, xmin=0, xmax=5, linewidth=1, color='white', linestyles='dashed')
ax[1].vlines(x=4.99, ymin=0, ymax=4.99**2, linewidth=1, color='white', linestyles='dashed')
ax[1].vlines(x=5, ymin=0, ymax=25, linewidth=1, color='white', linestyles='dashed')
ax[1].legend()
plt.show()
Valor de la pendiente con una secante más pequeña: 9.990000000000023
../../_images/beaf72a4871ea88918f0d1ecd521e2063bdaf30876bfb4d09067f64291797899.png

En esencia, hemos minimizado las distancias entre \(x_2\) y \(x_1\) al utilizar los valores \(x_2=5, x_1=4.99\) para obtener un resultado más riguroso. Gráficamente, corroboramos que nuestro resultado es más exacto: la línea que podemos trazar con la pendiente obtenida es casi idéntica a la función, al punto que la opaca cuando la miramos desde cerca. Suponiendo, como en nuestro dibujo anterior, que llamemos \(h\) a la distancia entre las \(x\), nuestra fórmula actual para la pendiente de esta tangente o secante infinitamente pequeña luce así:

\[ Pendiente = \frac{y_2 - y_1}{x_2 - x_1} = \frac{f(x+h) - f(x)}{(x+h) - x} = \frac{f(x+h) - f(x)}{h} \]

Como decía, idealmente queremos que la distancia \(h\) sea lo más pequeña posible, es decir, lo más cercana a \(0\) posible (sin ser \(0\), puesto que necesitamos una distancia entre dos \(x\) para tener una línea y así ser capaces de obtener su pendiente, puesto que un punto no tiene pendiente). Para expresar esta idea, emplearemos la expresión \(\lim _{h \rightarrow 0}\), es decir, «cuando \(h\) es tan pequeña que se acerca a \(0\)» o, más estrictamente, «el límite de la función cuando \(h\) tiende a \(0\)». De tal manera que:

\[ \lim _{h \rightarrow 0} \frac{f(x+h) - f(x)}{h} \]

Pero ahora ya no hablamos de una pendiente exactamente, sino que la estamos alterando para conseguir un resultado distinto. A este nuevo concepto lo denominaremos «derivada»:

\[ Derivada = \lim _{h \rightarrow 0} \frac{f(x+h) - f(x)}{h} \]

En la práctica también suele escribirse como \(\frac{dy}{dx}\), o \(f'(x)\).

Aplicada a nuestro problema, podemos utilizarla así:

\[ \frac{dy}{dx} =\lim _{h \rightarrow 0} \frac{f(x+h) - f(x)}{h} = \frac{(5+0.001)^2 - 5^2}{0.001} = 10.00 \]

Programáticamente:

def derivada(f, x):
  h = 0.001
  return (f(x+h) - f(x)) / h

derivada(nolineal, 5)
10.001000000002591

Finalmente hemos descubierto que cuando \(x=5\), \(x\) «influye» en \(y\) con una «fuerza» de \(10\). Analíticamente, podemos comprobarlo de esta forma:

\[\begin{split} \begin{gather*} \frac{dy}{dx} =\lim _{h \rightarrow 0} \frac{(x+h)^2 - x^2}{h} = \lim _{h \rightarrow 0} \frac{(x+h)(x+h) - x^2}{h} = \lim _{h \rightarrow 0} \frac{x^2 + xh + hx + h^2 - x^2}{h} \\ = \lim _{h \rightarrow 0} \frac{2xh + h^2}{h} = \lim _{h \rightarrow 0} \frac{h(2x + h)}{h} = \lim _{h \rightarrow 0} (2x + h) = 2x + 0 = 2x \end{gather*} \end{split}\]

Ahora, podemos generalizar esta expresión bajo la fórmula \(nx^{n-1}\), es decir, cuando \(f(x) = x^2\) y \(x=5\), entonces:

\[\begin{split} \begin{gather*} 2x = 2(5) = 10 \\ nx^{n-1} = 2(5)^{2-1} = 10 \end{gather*} \end{split}\]

En suma, \(10\) es efectivamente la proporción o fuerza con la que \(x\) influye en \(y\), \(10\) es la derivada de \(y\) con respecto a \(x\) cuando \(x=5\). Y, según hemos visto, podemos obtener este resultado de formas distintas: aplicando la fórmula, programáticamente, geométricamente y analítica o algebraicamente. En general, toda fórmula o expresión matemática tiene un trasfondo racional como los que hemos llevado a cabo, aunque las más de las veces se requieren algunos desarrollos lógico-matemáticos ulteriores para generalizarlas.

Volviendo a lo nuestro, podemos concluir que, conceptualmente, la derivada —igual que la pendiente— mide la proporción o magnitud del cambio que una variable provoca en el resultado de una función. Digamos que la derivada mide la fuerza con la que una variable influye —en un determinado punto— en el resultado de una función.

Pongamos otro ejemplo: si los valores de la función nunca cambiaran, entonces su derivada sería \(0\). Geométricamente, no habría nada de inclinación en la tangente a cualquier punto de la función; conceptualmente, la definición de la función indicaría que su resultado nunca cambia: ninguna variable tendría influencia en la función, de manera que el «cambio» siempre sería nulo, cero.

Supongamos que quiero medir la distancia que recorro en \(20\) segundos: si con el paso del tiempo no avanzo nada o, lo que es lo mismo, si la variable tiempo no influye en la función distancia, entonces la función, la pendiente y la tangente a cualquier punto son \(0\), por lo que lucen así:

tiempo = np.arange(0, 21, 1)
def f(t): return t*0
distancia = f(tiempo)

fig, ax = plt.subplots(figsize=(10, 7))
ax.plot(tiempo, distancia, marker='o', color='blue', label='recta de la función') 
ax.set_facecolor('black')
ax.set_xlabel('Tiempo (segundos)')
ax.set_ylabel('Distancia (metros)')
ax.axline((tiempo[0], distancia[0]), slope=0, color='red', label='derivada / pendiente')
ax.legend()
plt.show()
../../_images/6957750a89c2e84e1c7e10fc4bbfc8a58d81833e9d4a0fa9d2c62c51b7860e0c.png

Veamos un último ejemplo, ahora con la función suma: mi variable \(x\) tendrá valores del \(0\) al \(12\) con intervalos de \(2\), mientras que mi función es \(x+6\). Mi tabla de valores entonces debería quedar así:

x = np.arange(0, 13, 2)
def f(x): return x+6
y = f(x)

z = {'Valores de x': x, 'Valores de y': y}
tabla = pd.DataFrame(data=z)
tabla = tabla.style.hide_index()
tabla
Valores de x Valores de y
0 6
2 8
4 10
6 12
8 14
10 16
12 18

Ahora preguntémonos: ¿en qué proporción cambia \(y\) cuando aumentamos \(x\)? Si paso de mi primer valor de \(x\) al segundo, es decir, si paso de \(0\) a \(2\), ¿cuánto estoy aumentando a mi primer valor de \(y\)? ¡También dos! Es decir, que la proporción de los cambios entre \(x\) y \(y\) es 1: si aumento dos a \(x\), también aumento \(2\) a \(y\). En nuestro primer ejemplo, si agregábamos \(2\) a \(x\), nuestro valor de \(y\) aumentaba cinco veces \(x\).

Esto tiene sentido porque la multiplicación aumenta nuestros valores con mayor velocidad y en diferente proporción que la suma: por ejemplo, \(2 \times 4 = 8\), pero \(2 + 4 = 6\), y conforme tomamos números más grandes, nuestra multiplicación continúa creciendo a mayor velocidad que la suma: \(2 \times 10 = 20\), pero \(2 + 10 = 12\).

Grafiquemos nuestra nueva función de suma:

derivada = (y[2] - y[1]) / (x[2] - x[1])

fig, ax = plt.subplots(figsize=(10, 7))
ax.plot(x, y, marker='o', color='blue', label='recta de la función') 
ax.set_facecolor('black')
ax.set_xlabel('Valores de x')
ax.set_ylabel('Valores de y')
ax.axline((x[1], y[1]), slope=derivada, color='red', label='derivada / pendiente')
ax.hlines(y=10, xmin=0, xmax=4, linewidth=1, color='white', linestyles='dashed')
ax.vlines(x=4, ymin=0, ymax=10, linewidth=1, color='white', linestyles='dashed')
ax.legend()
plt.show()
../../_images/497f99c4848de769f76752ec759b7db960d6366cff8fab48a4bbb3b64dbd6ca8.png

La inclinación de la recta es menor porque \(y\) aumenta lentamente en proporción a \(x\), de \(2\) en \(2\). Una vez más: únicamente estamos averiguando la proporción que hay entre los valores de \(x\) y de \(y\). Si cada valor de \(x\) aumenta significativamente cada valor correspondiente de la función, entonces nuestra derivada será alta y la inclinación de la recta será empinada.

Por otra parte, cuando cada valor de la función aumenta poco conforme hacemos más grande nuestra variable de entrada, entonces la derivada será pequeña y la inclinación será poca.

Pero ¿para qué sirve la derivada?#

Imaginemos que tenemos dos variables: \(x = -2\) y \(y = 3\). La función \(f(x, y)\) las multiplica y su resultado es \(-6\). Sin embargo, queremos alterar ese resultado para que sea \(0\) y tenemos una restricción: la única manera de hacerlo es a través de las variables de entrada. Para ello, podríamos pensar en aumentar o disminuir las entradas en pasos pequeños y probar aleatoriamente hasta conseguir el resultado.

Supongamos que nuestro cambio aplicado será \(0.01\): disminuiremos \(y\) en esa cantidad y aumentaremos \(x\) de la misma forma: \(x = -1.99 \times y = 2.99 = -5.95\). Bien, \(-5.95\) es más cercano a \(0\) que \(-6\). Podríamos continuar este proceso hasta llegar a \(0\), pero nuestro método no parece muy efectivo.

Si recordamos la lección aprendida en las derivadas, se nos puede ocurrir utilizarlas para resolver este problema: dado que la derivada me dice el impacto que tiene una variable de entrada en la función, ¿será posible utilizarla para alterar el resultado de la función? Es decir, si la derivada me dice la magnitud del impacto que una varible tiene en el resultado, entonces debería poder utilizar esa información para influir en el resultado de manera más eficiente. Por ejemplo, si la derivada me indica que una variable influye positivamente en la función, pero yo quiero disminuirla, entonces sabré más o menos la magnitud en que debo disminuir esa variable, y así sucesivamente.

Pequegrad#

Para entender mejor la posible solución planteada, crearemos programáticamente la clase Numero, es decir, formularemos una estructura que nos permita definir, modificar y operar con números. De momento, cada Numero tendrá las siguientes propiedades: un valor, un par de valores previos para el caso de que el valor haya sido generado mediante una operación (por ejemplo, \(2\) y \(2\) en el caso de que multiplicados hayan generado el número \(4\)), la operación que generó dicho valor (en nuestro ejemplo, la multiplicación) y una etiqueta en caso de que queramos asociar nuestro valor a una variable. Por ahora, únicamente tendremos las operaciones de suma, multiplcación, resta y división:

class Numero:
  def __init__(self, valor, _previos=(), _op='', etiqueta=''):
    self.valor = valor
    self._previos = set(_previos)
    self._op = _op
    self.etiqueta = etiqueta

  def __add__(self, otro): # adición
    otro = otro if isinstance(otro, Numero) else Numero(otro) # nos cercioramos de que el otro valor sea un Número
    resultado = Numero(self.valor + otro.valor, (self, otro), '+')
    return resultado

  def __radd__(self, otro): 
    return self + otro

  def __mul__(self, otro): # multiplicación
    otro = otro if isinstance(otro, Numero) else Numero(otro)
    resultado = Numero(self.valor * otro.valor, (self, otro), '*')
    return resultado

  def __rmul__(self, otro):
    return self * otro

  def __sub__(self, otro): # resta, substracción
    return self + (-otro)

  def __truediv__(self, otro): # división
    return self * otro**-1 #dividir es lo mismo que multiplicar por el dividendo elevado a la menos 1
  
  def __rtruediv__(self, otro):
    return otro * self**-1

  def __neg__(self): # volver negativo un número
    return self * -1

  def __repr__(self):
    return f'Valor={self.valor}' # esta función determina cómo se representa nuestro número

Utilizaremos un problema más desafiante que una simple multiplicación:

# Definimos la función L con los siguientes números y operaciones:

a = Numero(-2.0, etiqueta='a')
b = Numero(3.0, etiqueta='b')
c = a*b; c.etiqueta = 'c' # definimos c, que es el resultado de multiplicar a y b
d = Numero(10.0, etiqueta='d')
e = c + d; e.etiqueta = 'e'
f = Numero(-3.0); f.etiqueta = 'f'
L = f * e; L.etiqueta= 'L'

print(f'Ejemplo: Valor de c: {c} | Valores previos: {c._previos} | Operación realizada para generar c: {c._op}')
Ejemplo: Valor de c: Valor=-6.0 | Valores previos: {Valor=3.0, Valor=-2.0} | Operación realizada para generar c: *

También crearemos una función para graficar nuestras operaciones:

from graphviz import Digraph

def rastreo(origen):
  # construye un conjunto de todos los nodos en un gráfico
  nodos, lineas = set(), set()
  def construir(v):
    if v not in nodos:
      nodos.add(v)
      for parte in v._previos:
        lineas.add((parte, v))
        construir(parte)
  construir(origen)
  return nodos, lineas

def graficar(origen):
  grafica = Digraph(format='svg', graph_attr={'rankdir': 'LR'}) # left to right, izquierda a derecha

  nodos, lineas = rastreo(origen)
  for n in nodos:
    uid = str(id(n))
    # por cada valor en la gráfica, crea un nodo rectangular ('record') para él
    grafica.node(name=uid, label='{ %s | valor %.4f}' % (n.etiqueta, n.valor), shape='record')
    if n._op:
      # si el valor es resultado de una operación, crea un nodo para la operación
      grafica.node(name = uid + n._op, label = n._op)
      # conecta los nodos
      grafica.edge(uid + n._op, uid)

  for n1, n2 in lineas:
    # conecta n1 al nodo operación de n2
    grafica.edge(str(id(n1)), str(id(n2)) + n2._op)

  return grafica

Finalmente, visualizamos la función[2]. El problema a resolver consiste en influir en los valores de la función \(L\) para llevarla a \(0\).

graficar(L)
../../_images/ffb0554f4cdbb82d6eec858d6db576dbe80c979f9b42e1ebd103a57ea46cac67.svg

Como decíamos, intuitivamente creemos que la derivada puede ayudarnos: con la derivada sabemos qué impacto tiene cada variable en el resultado final \(L\). En ese sentido, lo que tendremos que hacer primero es calcular la derivada de \(L\) con respecto a cada variable[3].

En principio, la derivada de \(L\) con respecto a sí misma es \(1\): aunque suene absurdo y evidente a la vez, el cambio en \(L\) es proporcional (idéntico) a sí mismo. Ahora, la derivada de \(L\) con respecto a \(f\) y \(e\) la podemos averiguar con la fórmula que veníamos utilizando:

def derivada():
  h = 0.000001

  # Función original
  a = Numero(-2.0, etiqueta='a')
  b = Numero(3.0, etiqueta='b')
  c = a*b; c.etiqueta = 'c'
  d = Numero(10.0, etiqueta='d')
  e = c + d; e.etiqueta = 'e'
  f = Numero(-3.0); f.etiqueta = 'f'
  L1 = f * e; L.etiqueta= 'L1'

  # Función con incremento h
  a = Numero(-2.0, etiqueta='a')
  b = Numero(3.0, etiqueta='b')
  c = a*b; c.etiqueta = 'c'
  d = Numero(10.0, etiqueta='d')
  e = c + d; e.etiqueta = 'e'
  e.valor += h # aumento de h para obtener la derivada de L con respecto a e
  f = Numero(-3.0); f.etiqueta = 'f'
  L2 = f * e; L.etiqueta= 'L2'

  print((L2 - L1) / h) # fórmula de la derivada

derivada()
Valor=-2.9999999995311555

La derivada de la función \(L\) con respecto a \(e\) es aproximadamente \(-3\). A estas alturas, ya habremos notado un patrón: cuando derivamos una multiplicación, la derivada parcial con respecto al multiplicando es el multiplicador. En este caso, \(L\) es el resultado de multiplicar \(e\) con \(f\). Vimos que la derivada con respecto a \(e\) es \(-3\), o sea \(f\). Podemos inducir, empíricamente y por simetría, que la derivada con respecto a \(f\) es \(e\), o sea 4.

Quizá también sea una buena oportunidad para demostrar analíticamente lo que venimos diciendo. Utilizando nuestra fórmula matemática, tenemos que:

\[ \frac{\partial L(e, f)}{\partial e}=\lim _{h \rightarrow 0}{\frac{L(e + h, f)-L(e, f)}{h}} = {\frac{(e + h) \cdot f - e \cdot f}{h}} = {\frac{ef + hf - ef}{h}} = {\frac{hf}{h}} = f \]

Y aunque la notación aparente complejidad, en realidad solo estamos sumando, multiplicando, restando y dividiendo.

Ahora que ya tenemos esta información, queremos continuar al nodo anterior: las derivadas con respecto a \(c\) y \(d\). Pero aquí hay una sutileza que, bien entendida, nos dará la clave de las redes neuronales: debemos obtener la derivada de \(L\) con respecto a \(c\) y \(d\), no la derivada de \(e\) con respecto a ellas. Entonces, ¿cómo podemos averiguar el impacto que \(c\) y \(d\) tienen en \(L\) a través de \(e\)? Para hacer este cálculo solo hace falta una intuición bastante simple.

La regla de la cadena: propagación hacia atrás#

Tomemos prestada la analogía de George Simmons: si una bicicleta es dos veces más rápida que una persona corriendo, y un automóvil cuatro veces más rápido que una bicicleta, entonces el automóvil es \(2 \times 4 = 8\) veces más rápido que una persona corriendo.

De igual forma, si queremos saber la influencia que \(c\) tiene en \(L\), solo debemos obtener la derivada de \(e\) con respecto a \(c\), y multiplicarla por la derivada de \(L\) con respecto a \(e\). Es decir, debemos multiplicar la fuerza de \(c\) en \(e\) y la de \(e\) en \(L\) para saber con cuánta fuerza influye \(c\) en \(L\). Lo mismo vale para \(d\) y todas las demás; a esta regla se le llama «regla de la cadena» (y mediante ella se realiza la «propagación hacia atrás» en el campo de la inteligencia artificial).

Ahora, para obtener la derivada de \(e\) con respecto a \(c\), recordemos otro patrón que vimos: en una suma, la derivada nos daba como resultado \(1\) porque la función avanzaba en la misma proporción que avanzaba la variable. Verifiquémoslo:

def deriv_e(c, d):
  return (((c+0.00001)+d) - (c+d)) / 0.00001

deriv_e(-6, 10)
0.9999999999621422

En efecto, el resultado es aproximadamente \(1\). Por simetría nuevamente, entendemos que la derivada de \(e\) con respecto a \(d\) también es \(1\). Y para el lector exigente, analíticamente:

\[ \frac{\partial e(c, d)}{\partial d}=\lim _{h \rightarrow 0}{\frac{e(c, d+h)-e(c, d)}{h}} = {\frac{(c + d + h) - (c + d)}{h}} = {\frac{(c+d) + h - (c+d)}{h}} = {\frac{h}{h}} = 1 \]

Y ahora que ya tenemos ambas derivadas parciales, podemos multiplicarlas siguiendo la regla de la cadena: \(1 \times -3 = -3\). Así pues, la derivada de \(L\) con respecto a \(c\) y \(d\) es \(-3\). En términos matemáticos, hemos hecho algo equivalente a:

\[ \frac{dz}{dx} = \frac{dz}{dy} \cdot \frac{dy}{dx} \]

Donde \(z\) depende de \(y\) y, a su vez, \(y\) depende de \(x\).

Comprobémoslo programáticamente:

def derivada():
  h = 0.000001

  # Función original
  a = Numero(-2.0, etiqueta='a')
  b = Numero(3.0, etiqueta='b')
  c = a*b; c.etiqueta = 'c'
  d = Numero(10.0, etiqueta='d')
  e = c + d; e.etiqueta = 'e'
  f = Numero(-3.0); f.etiqueta = 'f'
  L1 = f * e; L.etiqueta= 'L1'

  # Función con incremento h
  a = Numero(-2.0, etiqueta='a')
  b = Numero(3.0, etiqueta='b')
  c = a*b; c.etiqueta = 'c'
  c.valor += h # aumento de h para obtener la derivada de L con respecto a c
  d = Numero(10.0, etiqueta='d')
  e = c + d; e.etiqueta = 'e'
  f = Numero(-3.0); f.etiqueta = 'f'
  L2 = f * e; L.etiqueta= 'L2'

  print((L2 - L1) / h) # fórmula de la derivada

derivada()
Valor=-2.9999999995311555

Finalmente, debemos hacer lo mismo para obtener las derivadas con respecto a \(a\) y \(b\). Entonces, dado el patrón que habíamos descubierto, la derivada de \(c\) con respecto a \(a\) es \(b\), o sea \(3\), y viceversa: la derivada con respecto a \(b\) es \(a\), o sea \(-2\). Pero recordemos: estas derivadas «locales» son de la función \(c\), y a nosotros nos interesa la derivada de \(L\), es decir, la magnitud de la influencia que \(a\) y \(b\) tienen en \(L\). Para saberlo, nuevamente debemos aplicar la regla de la cadena y multiplicar las derivadas que tenemos por la derivada de \(L\) con respecto a \(c\). Entonces, la derivada parcial de \(L\) con respecto a \(a\) es \(3 \times -3 = -9\), mientras que la derivada parcial con respecto a \(b\) es \(-2 \times -3 = 6\).

Ahora que ya tenemos estos valores, podemos optimizar nuestro código para contemplarlos y visualizarlos de mejor manera. Añadiremos también algunas funciones que después detallaremos. En la práctica, nadie calcula las derivadas manualmente como lo hicimos, puesto que sería una labor eterna; pero ya hemos aprendido los patrones para calcularlas, así que podemos implementarlos en nuestro código para que se calculen automáticamente:

import math

class Numero:
  def __init__(self, valor, _previos=(), _op='', etiqueta=''):
    self.valor = valor
    self.grad = 0.0 # gradiente, comienza en 0
    self._propagar = lambda: None # el valor predeterminado de la propagación hacia atrás es nulo
    self._previos = set(_previos)
    self._op = _op
    self.etiqueta = etiqueta

  def __add__(self, otro): 
    otro = otro if isinstance(otro, Numero) else Numero(otro) 
    resultado = Numero(self.valor + otro.valor, (self, otro), '+')

    def _propagar():
      self.grad += 1.0 * resultado.grad # la derivada de una suma es 1; después, se multiplica por la «derivada global»
      otro.grad += 1.0 * resultado.grad
    resultado._propagar = _propagar

    return resultado

  def __radd__(self, otro): 
    return self + otro

  def __mul__(self, otro):
    otro = otro if isinstance(otro, Numero) else Numero(otro)
    resultado = Numero(self.valor * otro.valor, (self, otro), '*')

    def _propagar():
      self.grad += otro.valor * resultado.grad # la derivada de la multiplicación es igual al multiplicando por la derivada global
      otro.grad += self.valor * resultado.grad
    resultado._propagar = _propagar

    return resultado

  def __rmul__(self, otro):
    return self * otro

  def __pow__(self, otro): # potenciación
    assert isinstance(otro, (int, float))
    resultado = Numero(self.valor**otro, (self,), f'**{otro}')

    def _propagar():
      self.grad += otro * (self.valor ** (otro - 1)) * resultado.grad # recordemos la fórmula: yx**y-1
    resultado._propagar = _propagar

    return resultado

  def __sub__(self, otro): 
    return self + (-otro)

  def __rsub__(self, otro):
    return otro + (-self)

  def __neg__(self): 
    return self * -1

  def __truediv__(self, otro): 
    return self * otro**-1 
  
  def __rtruediv__(self, otro):
    return otro * self**-1

  def ReLU(self): 
    resultado = Numero((0 if self.valor < 0 else self.valor), (self,), 'ReLU')

    def _propagar():
      self.grad += (1 if resultado.valor > 0 else 0) * resultado.grad # la derivada es 1 si x > 0, 0 si x < 0
    resultado._propagar = _propagar
    
    return resultado

  def tanh(self): # tangente hiperbólica
    t = (math.exp(2*self.valor) - 1) / (math.exp(2*self.valor) + 1)
    resultado = Numero(t, (self,), 'tanh')

    def _propagar():
      self.grad += (1 - t**2) * resultado.grad # según la fórmula 1-tanh**2x
    resultado._propagar = _propagar

    return resultado

  def sigmoide(self):
    s = 1/(1 + math.exp(self.valor))
    resultado = Numero(s, (self,), 'sigmoide')

    def _propagar():
      self.grad += (s * (1 - s)) * resultado.grad # la derviada es s(x)(1-s(x))
    resultado._propagar = _propagar

    return resultado

  def exp(self): # exponenciación
    resultado = Numero(math.exp(self.valor), (self,), 'exp')

    def _propagar():
      self.grad += self.valor * resultado.grad
    resultado._propagar = _propagar

    return resultado

  def log(self): # logaritmo natural
    resultado = Numero(math.log(self.valor), (self,), 'log')

    def _propagar():
      self.grad += 1/self.valor * resultado.grad
    resultado._propagar = _propagar

    return resultado

  def propagar(self):
    # ordenamiento topológico
    topo = []
    visitados = set()
    def construir_topo(v):
      if v not in visitados:
        visitados.add(v)
        for previo in v._previos:
          construir_topo(previo)
        topo.append(v)
    construir_topo(self)

    self.grad = 1 # asignamos la derivada del valor final con respecto a sí mismo: 1
    for nodo in reversed(topo): # comenzamos desde adelante hacia atrás
      nodo._propagar()

  def __repr__(self):
    return f'Valor={self.valor}'
from graphviz import Digraph

def rastreo(origen):
  nodos, lineas = set(), set()
  def construir(v):
    if v not in nodos:
      nodos.add(v)
      for parte in v._previos:
        lineas.add((parte, v))
        construir(parte)
  construir(origen)
  return nodos, lineas

def graficar(origen):
  grafica = Digraph(format='svg', graph_attr={'rankdir': 'LR'})

  nodos, lineas = rastreo(origen)
  for n in nodos:
    uid = str(id(n))
    grafica.node(name=uid, label='{ %s | valor %.4f | grad %.4f}' % (n.etiqueta, n.valor, n.grad), shape='record')
    if n._op:
      grafica.node(name = uid + n._op, label = n._op)
      grafica.edge(uid + n._op, uid)

  for n1, n2 in lineas:
    grafica.edge(str(id(n1)), str(id(n2)) + n2._op)

  return grafica
a = Numero(-2.0, etiqueta='a')
b = Numero(3.0, etiqueta='b')
c = a*b; c.etiqueta = 'c'
d = Numero(10.0, etiqueta='d')
e = c + d; e.etiqueta = 'e'
f = Numero(-3.0); f.etiqueta = 'f'
L = f * e; L.etiqueta= 'L'

graficar(L)
../../_images/b7494524cd3f98bfe1df8b762be73bf71162fa09561d354292f3c869b3f3e762.svg

Ahora obtendremos las derivadas, pero programática en vez de manualmente:

L.propagar()
graficar(L)
../../_images/5e5c7077e2c62a693ab0673a20e12c2f6f0bfb88ac1342d7f9f5ed866e230c18.svg

Bien. Ahora pongamos en práctica nuestra intuición: dado que el gradiente (o derivada global, o la colección de derivadas parciales de \(L\) con respecto a cada variable) nos dice la «fuerza» con la que cada número aumenta el resultado final de la función, podemos utilizar esa misma fuerza para alterar nuestros valores y, con ello, aumentar el valor de \(L\). La bondad de utilizar el gradiente es que nos indica la «dirección» y la magnitud en la que debemos modificar cada valor para aumentar el resultado final, es decir: aumentar el valor de cada variable en la misma dirección del gradiente, contribuye a elevar el valor de la función global. Incluso si el gradiente tiene un valor negativo, eso indicaría que el valor de la variable debe disminuir para incrementar la función. De igual forma, si lo que queremos es disminuir el valor de una función, solo debemos ir en la dirección opuesta —i. e., negativa— al gradiente.

Las siguientes imágenes ilustran con flechas azules la dirección del gradiente, y en blanco y negro a los valores de la función (el negro indica los valores más altos). Como digo, el gradiente indica la dirección en la que la función aumenta su valor:

Solo hace falta un detalle: al emplear esta estrategia, debemos ser cuidadosos de no excedernos en el aumento del resultado, puesto que queremos llegar a \(0\) sin superarlo. Para ello, podemos atenuar la fuerza de los gradientes, multiplicándolos por un número pequeño como \(0.01\) o \(0.1\) para luego sumar (en caso de querer aumentar el resultado final de la función) o restar ese valor a la variable. Probemos un ejemplo con la variable \(a\):

na = a + 0.1 * a.grad # nuevo valor de a: su derivada multiplicada por 0.1 + el valor anterior de a

a = Numero(na.valor, etiqueta='a') # asignamos el nuevo valor a la variable
b = Numero(3.0, etiqueta='b')
c = a*b; c.etiqueta = 'c'
d = Numero(10.0, etiqueta='d')
e = c + d; e.etiqueta = 'e'
f = Numero(-3.0); f.etiqueta = 'f'
L = f * e; L.etiqueta= 'L'

L.propagar()

graficar(L)
../../_images/4109df9dc37a3ef874d2e9c525927d1351130bccbd856542311d3c8e6407a038.svg

Bien, parece que nuestro método funciona. Sin embargo, comprobamos que quizá nuestra fuerza es elevada, puesto que disminuyó mucho el resultado. Probablemente tengamos un mejor resultado si hacemos esto con todas nuestras variables, pero con una fuerza más atenuada (\(0.01\)):

na = a + 0.01 * a.grad
nb = b + 0.01 * b.grad
nd = d + 0.01 * d.grad
nf = f + 0.01 * f.grad

a = Numero(na.valor, etiqueta='a')
b = Numero(nb.valor, etiqueta='b')
c = a*b; c.etiqueta = 'c'
d = Numero(nd.valor, etiqueta='d')
e = c + d; e.etiqueta = 'e'
f = Numero(nf.valor); f.etiqueta = 'f'
L = f * e; L.etiqueta= 'L'

L.propagar()

graficar(L)
../../_images/292aa95acad81ef9c0792575a873b6fe0ca47d36e906cebd1a2a9e364d371c6c.svg

Aunque no hayamos conseguido un resultado de exactamente 0, nos hemos aproximado significativamente con nuestro nuevo método programático. Pronto explotaremos el verdadero poder detrás de esta nueva herramienta nuestra, aunque desde ya podamos proclamar —con Françoise Hardy— ¡voilà! Esta es la esencia del aprendizaje en una red neuronal.


Perceptrón multicapa#

Anteriormente, construimos una especie de neurona; sin embargo, el poder de una red neuronal consiste en que tiene millones de neuronas, todas optimizando sus valores para darnos el resultado que deseamos (en nuestro ejemplo: \(0\)).

A continuación, construiremos una red neuronal llamada «perceptrón multicapa» (Multilayer Perceptron o MLP en inglés) que nos permitirá resolver problemas prácticos. Como ejemplo, la entrenaremos para que identifique sarcasmo en reseñas de películas.

Para detectar sarcasmo en las reseñas que los usuarios dan sobre una película, podríamos tomar como base dos variables: el sentimiento (con valores del \(1\) al \(5\), donde \(5\) es un sentimiento positivo y \(1\) uno negativo) y la calificación asignada (también con valores del \(1\) al \(5\)). El resultado tendría que ser \(1\) para sarcasmo y \(0\) para ausencia de sarcasmo. Por ejemplo:

  • Genial, me encanta cuando veo una película de terror con la típica trama de siempre. Calificación: \(2/5\).

En este caso, el sentimiento es positivo (utiliza expresiones como «genial», «me encanta»), pero la reseña es negativa, de manera que podemos establecer una relación de proporcionalidad inversa entre ambas variables para detectar el sarcasmo: si la calificación es baja pero el sentimiento «alto», entonces hay sarcasmo (es decir, sarcasmo \(=1\)).

#entradas
sentimiento = Numero(5.0, etiqueta='sentimiento')
calificacion = Numero(5.0, etiqueta='calificacion')

# pesos (weights)
w1 = Numero(-3.0, etiqueta='w1')
w2 = Numero(1.0, etiqueta='w2')

# sesgo (bias)
b = Numero(12, etiqueta='b')

# x1*w1 + x2*w2 + b
x1w1 = sentimiento*w1; x1w1.etiqueta='x1w1'
x2w2 = calificacion*w2; x2w2.etiqueta='x2w2'
sumatoria = x1w1 + x2w2; sumatoria.etiqueta='s'
n = sumatoria + b; n.etiqueta='n'

# activación
sarcasmo = n.tanh(); sarcasmo.etiqueta= 'sarcasmo'

sarcasmo.propagar()

graficar(sarcasmo)
../../_images/d28664f133e78cf9f5ac292e99d79ebe596b4a69e26ad471ea620ba0eee9d909.svg

Por el momento, nuestros valores son aleatorios y todo funciona mal. La calificación y el sentimiento que asignamos son \(5\), pero la neurona indica que la reseña es sarcástica porque da \(\approx1\) como resultado. Al contrario, queremos que el sarcasmo sea \(0\) en este caso. Podemos optimizarla programáticamente ahora que ya conocemos la propagación hacia atrás.

Primero, crearemos la clase Neurona para crear una estructura como la anterior. Al pasar nuestras variables por la Neurona, obtendremos un peso y un sesgo por cada una. Hecho esto, realizaremos operaciones con el peso, el sesgo y las entradas para transformar las entradas y, con ello, obtengamos al final el resultado deseado. Por ejemplo: si mis entradas son \(5\) y \(5\) —como en nuestro ejemplo—, nosotros queremos encontrar unos pesos y sesgos tales que, multiplicados y sumados por \(5\) y \(5\), nos den como resultado un \(0\), puesto que \(5\) y \(5\) significa ausencia de sarcasmo en este caso. Al mismo tiempo, queremos que esos mismos pesos y sesgos, por ejemplo al ser multiplicados y sumados por un sentimiento de \(5\) y una calificación de \(1\), nos arrojen \(1\) como resultado, puesto que eso indicaría sarcasmo.

Para obtener como resultado una probabilidad de sarcasmo del \(0\) al \(1\), utilizaremos la función tangente hiperbólica, la cual es muy similar a las que vimos con anterioridad (sigmoide, ReLU, softmax): cuando nuestros valores la atraviesen, ella nos dará como resultado un valor del \(-1\) al \(1\)[4].

import random

class Neurona:
  def __init__(self, nentradas):
    self.peso = [Numero(random.uniform(-1,1)) for i in range(nentradas)] # cada entrada tendrá un peso, que será un número aleatorio
    self.sesgo = Numero(random.uniform(-1,1)) # un sesgo con valor aleatorio del -1 al 1

  def __call__(self, x):
    # peso * x + sesgo
    activacion = sum((peso_i*x_i for peso_i, x_i in zip(self.peso, x)), self.sesgo) #multiplicamos, sumamos
    resultado = activacion.tanh() # aplicamos la función tangente hiperbólica
    return resultado

  def parametros(self):
    return self.peso + [self.sesgo]
#entradas
sentimiento = Numero(5.0, etiqueta='sentimiento')
calificacion = Numero(5.0, etiqueta='calificacion')

N = Neurona(2) # creamos una neurona

neuron = N([sentimiento, calificacion]) # damos a las variables como valores de entrada

neuron.propagar()

graficar(neuron)
../../_images/98b71e1ccb2b4a31e95c4f377bc09c7dbd2bdf163d862d00e644b8e2445cbc17.svg

Nuestros pesos y sesgos tienen valores aleatorios, de manera que esto sigue sin funcionar. Para optimizarlos apropiadamente, será mejor crear una arquitectura más robusta. Entendámonos: será difícil que encontremos un par de pesos tales que, multiplicados por nuestras dos entradas, reflejen apropiadamente el sarcasmo como queremos. Si tenemos más pesos, probablemente sea más fácil encontrar una combinación de números que arroje los resultados que queremos.

Para ello, crearemos una red neuronal. Lo único que nos falta es una forma de conectar varias neuronas entre sí, así podremos tener más pesos y sesgos que entrenar, lo cual se traduce en mejor desempeño.

Construiremos, pues, una «capa», que no es más que una forma programática de definir cuántas neuronas queremos para nuestras entradas, es decir, de generar una serie de neuronas; y finalmente conectaremos todo con un «perceptrón multicapa», que consiste en unir varias capas entre sí:

class Capa: # Layer
  def __init__(self, nentradas, nsalidas):
    self.neuronas = [Neurona(nentradas) for _ in range(nsalidas)]

  def __call__(self, x):
    resultado = [n(x) for n in self.neuronas]
    return resultado[0] if len(resultado) == 1 else resultado

  def parametros(self):
    return [parametro for n in self.neuronas for parametro in n.parametros()]

class MLP: # Perceptrón multicapa
  def __init__(self, nentrada, nsalidas):
    tamaño = [nentrada] + nsalidas
    self.capas = [Capa(tamaño[i], tamaño[i+1]) for i in range(len(nsalidas))]

  def __call__(self, x):
    for capa in self.capas:
      x = capa(x)
    return x

  def parametros(self):
    return [parametro for capa in self.capas for parametro in capa.parametros()]

Veamos una capa como ejemplo:

x = [1.0, 2.0, 3.0]
C = Capa(4, 1)
graficar(C(x))
../../_images/7a37908d780ca547927215c442f15836ecfa58c4dddcdf13ec1ab7f991544f27.svg

Ahora veamos una red neuronal de la especie perceptrón multicapa:

x = [3.0, 2.0]
RN = MLP(4, [4, 4, 1])

graficar(RN(x))
../../_images/4d7d0ac151612c02c57e37c6b191fa14dd832d6d8fe6bf2ae12728e5879c559d.svg

Como vemos, únicamente estamos interconectando y creando más neuronas para que nuestro modelo tenga más potencia. Al mismo tiempo, vemos que una neurona interconecta las entradas entre sí a través de pesos y sesgos. Los pesos y sesgos, además de realizar operaciones diferenciables (es decir, que podemos derivar), nos ayudarán a ir midiendo la relevancia de cada entrada para, con base en el gradiente, ajustarse en proporción a dicha relevancia. De igual forma, los pesos y sesgos articularán el patrón de números que en este caso necesitamos para convertir cada combinación de entradas en una probabilidad de sarcasmo.

Ahora manos a la obra. Daremos a nuestro modelo cuatro entradas de entrenamiento (es decir, serán ejemplos para que la red neuronal aprenda): cada entrada tendrá el sentimiento y la calificación asignada, así como el valor de sarcasmo objetivo que deseamos:

entradas = [
    [5.0, 5.0], # no sarcasmo
    [5.0, 1.0,], # sarcasmo
    [5.0, 2.0], # sarcasmo
    [4.0, 5.0], # no sarcasmo
]

objetivos = [0.0, 1.0, 1.0, 0.0]
predicciones = [RN(x) for x in entradas]
predicciones
[Valor=0.3669362864564352,
 Valor=0.06860246608538911,
 Valor=0.3213103120229461,
 Valor=0.3486194765977301]

Nuestras predicciones de sarcasmo fueron generadas con valores aleatorios, de manera que todas son erróneas. Ahora, debemos cuantificar qué tan alejadas están de su objetivo. Para ello, crearemos una función que mida la diferencia entre el objetivo y la predicción. Se trata de una simple resta, pero la elevaremos al cuadrado para obtener solo números positivos:

fn_perdida = [(pred - obj)**2 for pred, obj in zip(predicciones, objetivos)] #Mean Squared Error
fn_perdida
[Valor=0.13464223831843908,
 Valor=0.8675013661822187,
 Valor=0.46061969256639074,
 Valor=0.1215355394632753]

Ahora, sumaremos los valores entre sí para obtener una sola cifra que nos permita cuantificar el desempeño general de nuestro modelo:

perdida = sum(fn_perdida)
perdida
Valor=1.5842988365303239

Luego, con base en la función de pérdida, haremos propagación hacia atrás: obtendremos los gradientes y, con ello, sabremos en qué dirección modificar nuestros pesos y sesgos para que la pérdida (o el error, o la distancia) sea \(0\), es decir, que nuestras predicciones sean idénticas a nuestros objetivos:

perdida.propagar()
graficar(perdida)
../../_images/f27eabbc4321f0980a05e2d61a50c5907741f075aa57e66d9b422d535777a25e.svg

Esto ya luce mucho más como una red neuronal. Calcular manualmente todos los gradientes sería engorroso; pero la programación nos permite apalancarnos de los algoritmos, las matemáticas y las computadoras para explotar su potencial a gran escala.

Echemos un vistazo a nuestros parámetros (o sea, pesos y sesgos):

RN.parametros()
[Valor=0.26185647542075774,
 Valor=-0.3897146431515013,
 Valor=-0.042130217416122884,
 Valor=0.716233520043825,
 Valor=0.34035823314645985,
 Valor=-0.38262186196333725,
 Valor=0.016423299964780647,
 Valor=-0.9183633316805504,
 Valor=-0.5763110561373717,
 Valor=0.019367922187966347,
 Valor=0.12454783116525658,
 Valor=0.6707556894941378,
 Valor=0.834698024121612,
 Valor=0.254042516410647,
 Valor=-0.6730022053225764,
 Valor=-0.8928621154187195,
 Valor=0.7161297325281775,
 Valor=0.09323426117937772,
 Valor=0.7985780052593621,
 Valor=-0.9972537117506883,
 Valor=-0.0037082550344191834,
 Valor=0.7422268948402582,
 Valor=-0.7202178129671104,
 Valor=-0.49309665896689103,
 Valor=0.48794442651441616,
 Valor=0.6622683039011021,
 Valor=-0.9732767794233275,
 Valor=0.7338007914809526,
 Valor=-0.12433377269499779,
 Valor=0.6796016177396522,
 Valor=-0.2252461785794555,
 Valor=-0.25469641100646667,
 Valor=0.25222074387643856,
 Valor=-0.636707051411459,
 Valor=-0.08679161950639269,
 Valor=0.26446327705831196,
 Valor=0.21401883070848138,
 Valor=0.3809439666539909,
 Valor=-0.010349121884670964,
 Valor=-0.47937169114088274,
 Valor=-0.7563496531288552,
 Valor=-0.690391901144549,
 Valor=0.9515007803442808,
 Valor=0.6590463877459278,
 Valor=0.21212402329507696]

Aunque para nosotros puedan no significar nada, lo cierto es que con base en el gradiente, nuestro modelo aprenderá a configurarlos de tal manera que representen un patrón cuya función será arrojar el resultado que nosotros queremos.

Ahora, dado que nuestra función de pérdida es positiva, queremos disminuirla. Si los gradientes nos indican la dirección en la que podemos aumentar el resultado de una función, entonces en este caso queremos ir en dirección opuesta al gradiente, pues eso tendría como resultado una disminución del resultado de la función. Para ello, sumaremos valor a las variables en el sentido inverso al gradiente (o, visto de otra forma, les restaremos valor según nos indique el gradiente).

Para optimizar de mejor manera, haremos este mismo paso \(30\) veces, puesto que los cambios serán diminutos:

for k in range(30):

  # paso hacia delante
  preds = [RN(x) for x in entradas]
  perdida = sum([(pred - obj)**2 for pred, obj in zip(preds, objetivos)])

  # propagación hacia atrás, Stochastic Gradient Descent
  for p in RN.parametros():
    p.grad = 0.0
  perdida.propagar()

  # actualizar
  for p in RN.parametros():
    p.valor += -0.07 * p.grad

  print(k, perdida.valor)
0 1.5842988365303239
1 1.2529558664951095
2 0.587216087017959
3 0.4191023147078547
4 0.5752410954325351
5 1.1191852453113773
6 1.784366523080085
7 1.652128457073363
8 1.224657076741451
9 0.26771058028648226
10 0.41855267707767796
11 0.707877061951523
12 0.15361704203665727
13 0.12954214337233716
14 0.12316318950192393
15 0.12964905806896287
16 0.1261090038316882
17 0.1336944727568814
18 0.11078544833502019
19 0.102441253791397
20 0.08312278356688153
21 0.0723339169867922
22 0.06107490601393822
23 0.053290880990198526
24 0.04659821460517871
25 0.04138965501323435
26 0.03716069258091919
27 0.03363478739730862
28 0.030798690241934046
29 0.02833061619126015

Al parecer, nuestra función de pérdida disminuyó casi a cero. Comprobemos que nuestras predicciones ahora sean más similares a nuestros objetivos:

preds
[Valor=0.1016585786227638,
 Valor=0.9171470012496584,
 Valor=0.8984522443847082,
 Valor=0.02862836899357634]
objetivos
[0.0, 1.0, 1.0, 0.0]

En efecto, nuestro modelo ya es mucho más apto ahora que antes para detectar sarcasmo. ¿Qué tal si hacemos un test con una nueva calificación? El sentimiento será de \(5\) y la calificación \(1.5\):

test = [5.0, 1.5]

prediccion = RN(test)
prediccion
Valor=0.9022088768249822

Nuestra red neuronal considera que existe un \(\approx90 \%\) de probabilidad de que sea sarcasmo. Nada mal.