Capítulo 4 de 15 8 secciones 18 min

El problema que una neurona no puede, y la red que sí

El XOR, que paró el campo veinte años. Y la diferencia entre poder representar algo y llegar a encontrarlo.

Una sola neurona traza una recta, y el XOR no se separa con una recta: entrenada 5.000 vueltas devuelve 0,5 en los cuatro casos. Con una capa oculta de cuatro neuronas sí lo resuelve. Pero no siempre: de diez arranques distintos convergen nueve, y el que falla se queda atascado. Con dos neuronas ocultas convergen cinco de diez y con ocho, diez de diez.

Hasta aquí tenemos una neurona (capítulo 2) y sabemos por qué hace falta doblar (capítulo 3). Toca apilar 🧱

Y para que se vea la diferencia hace falta un problema que una sola neurona no pueda. El clásico se llama XOR, tiene cuatro filas y paró la investigación en redes neuronales durante casi veinte años.

Tres problemas de cuatro filas

import numpy as np

def sigmoide(z):
    return 1 / (1 + np.exp(-z))

X = np.array([[0., 0.],
              [0., 1.],
              [1., 0.],
              [1., 1.]])

Y_AND = np.array([0., 0., 0., 1.])      # los dos
Y_OR = np.array([0., 1., 1., 1.])       # alguno de los dos
Y_XOR = np.array([0., 1., 1., 0.])      # uno pero no los dos

for nombre, objetivo in [('AND', Y_AND), ('OR', Y_OR), ('XOR', Y_XOR)]:
    print(nombre, objetivo)
AND [0. 0. 0. 1.]
OR [0. 1. 1. 1.]
XOR [0. 1. 1. 0.]

Llévalo a algo tuyo para que no sea abstracto: imagina que la primera columna es "le hicimos descuento" y la segunda "lo visitó un vendedor".

El AND sería "compra solo si le hacemos las dos cosas". El OR, "compra si le hacemos cualquiera de las dos". Y el XOR sería "compra si le haces una, pero si le haces las dos se siente presionado y no compra" 🤔

Los tres suenan igual de razonables. Y solo dos se pueden aprender con una neurona.

Una neurona, entrenada 5.000 vueltas

def una_neurona(objetivo, vueltas=5000, paso=0.5):
    rng = np.random.default_rng(0)
    w = rng.normal(0, 0.5, 2)
    b = 0.0
    for _ in range(vueltas):
        p = sigmoide(X @ w + b)
        error = p - objetivo
        w -= paso * X.T @ error / len(X)
        b -= paso * error.mean()
    return sigmoide(X @ w + b)

for nombre, objetivo in [('AND', Y_AND), ('OR', Y_OR), ('XOR', Y_XOR)]:
    p = una_neurona(objetivo)
    aciertos = int(((p >= 0.5) == objetivo).sum())
    print(f'{nombre:4} {np.round(p, 4)}  acierta {aciertos} de 4')
AND  [0.     0.0081 0.0081 0.9886]  acierta 4 de 4
OR   [0.0081 0.9968 0.9968 1.    ]  acierta 4 de 4
XOR  [0.5 0.5 0.5 0.5]  acierta 2 de 4

AND y OR salen perfectos. Y el XOR devuelve 0,5 en los cuatro casos, que es la forma que tiene una neurona de decir "no tengo ni idea" 🤷‍♀️

Y no es que le faltaran vueltas. Puedes dejarla un millón y va a seguir diciendo 0,5, porque el problema no es de esfuerzo.

Una neurona traza una recta y decide de qué lado cae cada punto. Coloca los cuatro puntos del XOR en un papel: los que valen 1 están en las esquinas (0,1) y (1,0), o sea en diagonal. No hay ninguna recta que deje esas dos de un lado y las otras dos del otro. Ninguna 📐

Eso lo demostraron Minsky y Papert en 1969, y el campo se quedó helado. La salida ya se conocía, pero faltaba saber cómo entrenarla, que es el capítulo 6.

Dos capas, escritas enteras

Metemos una capa en medio: las entradas van a cuatro neuronas ocultas, y esas cuatro van a la salida.

def red_dos_capas(semilla, ocultas=4, vueltas=5000, paso=0.5):
    rng = np.random.default_rng(semilla)
    W1 = rng.normal(0, 1, (2, ocultas))
    b1 = np.zeros(ocultas)
    W2 = rng.normal(0, 1, (ocultas, 1))
    b2 = np.zeros(1)
    objetivo = Y_XOR.reshape(-1, 1)

    for _ in range(vueltas):
        # hacia adelante
        h = np.tanh(X @ W1 + b1)
        p = sigmoide(h @ W2 + b2)
        # hacia atrás (el capítulo 6 explica de dónde sale cada línea)
        d2 = (p - objetivo) / len(X)
        dW2 = h.T @ d2
        db2 = d2.sum(axis=0)
        d1 = (d2 @ W2.T) * (1 - h ** 2)
        dW1 = X.T @ d1
        db1 = d1.sum(axis=0)
        # corregir
        W2 -= paso * dW2
        b2 -= paso * db2
        W1 -= paso * dW1
        b1 -= paso * db1

    perdida = -np.mean(objetivo * np.log(p) + (1 - objetivo) * np.log(1 - p))
    return p.ravel(), float(perdida)

p, perdida = red_dos_capas(semilla=1)
print('la red dice :', np.round(p, 4))
print('lo que quería:', Y_XOR)
print('pérdida      :', round(perdida, 6))
la red dice : [1.000e-04 9.989e-01 9.989e-01 1.600e-03]
lo que quería: [0. 1. 1. 0.]
pérdida      : 0.000948

Ahí está 🎉

0,0001 y 0,0016 donde tenía que decir 0, y 0,9989 en los dos que tenían que ser 1. Con cuatro neuronas en medio y veinte líneas de numpy.

Lo que hicieron esas cuatro neuronas es doblar el espacio hasta que los cuatro puntos sí se puedan separar con una recta. Cada una traza su propia raya, y la capa de salida combina las cuatro. Eso es todo lo que hace una capa oculta.

No te preocupes por las líneas del "hacia atrás", que son el capítulo 6 entero. Por ahora quédate con que hay una parte que calcula y otra que corrige 🔄

Y ahora lo que casi nadie cuenta

Ese resultado salió con la semilla 1. Probemos con diez arranques distintos:

for semilla in range(10):
    p, perdida = red_dos_capas(semilla)
    aciertos = int(((p >= 0.5).astype(int) == Y_XOR).sum())
    print(f'semilla {semilla}: {np.round(p, 4)}  pérdida {perdida:.4f}  '
          f'acierta {aciertos}/4')
semilla 0: [7.000e-04 4.995e-01 9.995e-01 5.005e-01]  pérdida 0.3474  acierta 2/4
semilla 1: [1.000e-04 9.989e-01 9.989e-01 1.600e-03]  pérdida 0.0009  acierta 4/4
semilla 2: [3.000e-04 9.994e-01 9.995e-01 7.000e-04]  pérdida 0.0005  acierta 4/4
semilla 3: [1.000e-04 9.991e-01 9.988e-01 1.300e-03]  pérdida 0.0009  acierta 4/4
semilla 4: [2.000e-04 9.994e-01 9.994e-01 7.000e-04]  pérdida 0.0005  acierta 4/4
semilla 5: [1.000e-04 9.988e-01 9.992e-01 1.200e-03]  pérdida 0.0008  acierta 4/4
semilla 6: [1.000e-04 9.989e-01 9.989e-01 1.600e-03]  pérdida 0.0010  acierta 4/4
semilla 7: [1.000e-04 9.991e-01 9.988e-01 1.200e-03]  pérdida 0.0009  acierta 4/4
semilla 8: [1.000e-04 9.989e-01 9.989e-01 1.600e-03]  pérdida 0.0010  acierta 4/4
semilla 9: [1.000e-04 9.989e-01 9.989e-01 1.500e-03]  pérdida 0.0009  acierta 4/4

Nueve de diez lo resuelven. Y la semilla 0 se queda atascada: devuelve 0,4995 y 0,5005 en dos de los cuatro, acierta 2 de 4, y su pérdida se queda en 0,3474 en vez de bajar a 0,0009.

Y no le faltan vueltas ni le falta capacidad. La misma red, el mismo código, las mismas 5.000 vueltas. Lo único distinto son los números con los que arrancó 🎲

Esto tiene nombre: se quedó en un mínimo local. Encontró una combinación de pesos desde la que cualquier cambio pequeño empeora, así que se queda ahí aunque exista una solución mucho mejor en otro sitio.

Guárdate esta distinción porque vale para todo el libro: que una red pueda representar la solución no significa que la vaya a encontrar. Son dos cosas distintas y la segunda es la difícil.

Más neuronas no es más capacidad: es más suerte

En teoría con dos neuronas ocultas basta para el XOR. Vamos a ver qué pasa en la práctica:

for ocultas in [2, 4, 8, 16]:
    convergen = sum(
        1 for s in range(10)
        if ((red_dos_capas(s, ocultas=ocultas)[0] >= 0.5).astype(int) == Y_XOR).sum() == 4
    )
    print(f'{ocultas:2d} neuronas ocultas: convergen {convergen} de 10 arranques')
 2 neuronas ocultas: convergen 5 de 10 arranques
 4 neuronas ocultas: convergen 9 de 10 arranques
 8 neuronas ocultas: convergen 10 de 10 arranques
16 neuronas ocultas: convergen 10 de 10 arranques

Y aquí está el resultado que a mí me parece el más bonito del capítulo 💡

Con dos neuronas convergen 5 de 10, con cuatro 9 de 10, y con ocho o dieciséis las diez.

Con dos ya alcanza para representar el XOR. Lo que cambia al poner más no es lo que la red puede: es la probabilidad de que el entrenamiento encuentre una solución en vez de atascarse. Más neuronas son más caminos hacia abajo.

Por eso en la práctica las redes se hacen más grandes de lo que la teoría pide. No es derroche: es comprar probabilidad de converger 🛣️

¿Y hay algún XOR en los datos de la distribuidora?

Buena pregunta, porque si no lo hay, este capítulo es una clase de teoría y ya está. Vamos a mirarlo 🔎

El equivalente sería: que hacer descuento ayude cuando el cliente está poco satisfecho y estorbe cuando está contento. O sea que el efecto del descuento cambie de signo según la otra columna.

import pandas as pd

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'

def carga_limpia(url):
    v = pd.read_csv(url).drop_duplicates()
    v['ciudad'] = (v['ciudad'].str.strip().str.lower()
                   .str.normalize('NFKD')
                   .str.encode('ascii', 'ignore').str.decode('utf-8'))
    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
    for col in ['fecha', 'fecha_ultima_compra']:
        f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
        falta = f.isna() & v[col].notna()
        f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y', errors='coerce')
        v[col] = f
    return v

ventas = carga_limpia(URL)
ventas['con_descuento'] = (ventas['descuento'].fillna(0) > 0).astype(int)
ventas['satisfecho'] = (ventas['satisfaccion']
                        .fillna(ventas['satisfaccion'].median()) >= 4).astype(int)

print(ventas.groupby(['con_descuento', 'satisfecho'])['compro']
      .agg(['size', 'mean']).round(4).to_string())
                          size    mean
con_descuento satisfecho              
0             0            392  0.4031
              1            208  0.6250
1             0           1487  0.5535
              1            913  0.6813

Ahí están las cuatro casillas, igual que el XOR pero con 3.000 filas detrás.

Y no hay XOR. Mira los dos saltos: sin satisfacción, el descuento sube la compra de 0,4031 a 0,5535, o sea quince puntos. Con satisfacción, la sube de 0,6250 a 0,6813, o sea cinco puntos y medio.

El descuento ayuda siempre. Ayuda más cuando el cliente está poco contento, que tiene todo el sentido comercial, pero nunca cambia de signo. Eso no es un XOR: es un efecto que suma con distinta fuerza.

Y ahí tienes explicado, por fin, todo lo que veníamos midiendo. En el capítulo 1 la red perdía y en el 3 ganaba identity porque en estos datos no hay ninguna esquina cruzada que doblar. La capacidad de la red está ahí y no hay dónde gastarla 💡

Esta comprobación de cuatro casillas es la que yo haría antes de proponer una red para datos en tabla. Si las cuatro casillas suman, la logística te vale.

Ejercicios

1. Rescatar la semilla atascada

Dale a la semilla 0 más vueltas, más paso y más neuronas, y mira cuál la salva.

print('base           ', np.round(red_dos_capas(0)[0], 4))
print('4 veces vueltas', np.round(red_dos_capas(0, vueltas=20000)[0], 4))
print('paso más grande', np.round(red_dos_capas(0, paso=2.0)[0], 4))
print('8 neuronas     ', np.round(red_dos_capas(0, ocultas=8)[0], 4))
base            [7.000e-04 4.995e-01 9.995e-01 5.005e-01]
4 veces vueltas [1.000e-04 4.999e-01 9.999e-01 5.001e-01]
paso más grande [2.000e-04 4.999e-01 9.999e-01 5.001e-01]
8 neuronas      [2.000e-04 9.994e-01 9.996e-01 6.000e-04]

Cuadruplicar las vueltas no la salva: sigue en 0,5. El paso más grande tampoco. Lo que la salva es darle más neuronas.

Es la lección de arriba en forma de ejercicio: cuando una red se atasca, la respuesta casi nunca es entrenar más rato 🕐

2. Qué aprendió cada neurona oculta

Mira lo que sale de la capa del medio para los cuatro casos.

rng = np.random.default_rng(1)
W1 = rng.normal(0, 1, (2, 4)); b1 = np.zeros(4)
W2 = rng.normal(0, 1, (4, 1)); b2 = np.zeros(1)
objetivo = Y_XOR.reshape(-1, 1)
for _ in range(5000):
    h = np.tanh(X @ W1 + b1)
    p = sigmoide(h @ W2 + b2)
    d2 = (p - objetivo) / len(X)
    d1 = (d2 @ W2.T) * (1 - h ** 2)
    W2 -= 0.5 * (h.T @ d2); b2 -= 0.5 * d2.sum(axis=0)
    W1 -= 0.5 * (X.T @ d1); b1 -= 0.5 * d1.sum(axis=0)

print('capa oculta, una fila por caso:')
print(np.round(np.tanh(X @ W1 + b1), 3))
capa oculta, una fila por caso:
[[ 0.012 -0.06  -0.684 -0.777]
 [ 0.972  0.972 -1.     0.955]
 [ 0.975  0.977  0.949 -1.   ]
 [ 1.     1.    -0.98  -0.981]]

Cada columna es una neurona y cada fila uno de los cuatro casos. Fíjate en que las filas del medio (las que tienen que dar 1) se parecen entre sí y se diferencian de las otras dos.

Eso es exactamente el trabajo de la capa oculta: convertir un problema que no se separa con una recta en uno que sí. La capa de salida, que es una sola neurona, ya solo tiene que trazar esa recta 📏

3. Una neurona con la pista dada a mano

Añade tú la columna que hace falta y usa una sola neurona.

X_ayudada = np.column_stack([X, X[:, 0] * X[:, 1]])   # el producto de las dos

rng = np.random.default_rng(0)
w = rng.normal(0, 0.5, 3); b = 0.0
for _ in range(5000):
    p = sigmoide(X_ayudada @ w + b)
    error = p - Y_XOR
    w -= 0.5 * X_ayudada.T @ error / len(X)
    b -= 0.5 * error.mean()

print('con la columna extra:', np.round(sigmoide(X_ayudada @ w + b), 4))
print('acierta', int(((sigmoide(X_ayudada @ w + b) >= 0.5) == Y_XOR).sum()), 'de 4')
con la columna extra: [0.0148 0.9901 0.9901 0.0066]
acierta 4 de 4

Cuatro de cuatro con una sola neurona, porque le di masticado lo que la capa oculta habría tenido que descubrir sola.

Y este es el mismo experimento del capítulo 1 con los cuadrados del círculo, por si no había quedado claro: la capa oculta es feature engineering automático. Cuando sabes qué columna falta, no necesitas la capa 🧩

4. Cuántos pesos hay en juego

Cuenta los parámetros de cada versión.

for ocultas in [2, 4, 8, 16]:
    pesos = 2 * ocultas + ocultas + ocultas * 1 + 1
    print(f'{ocultas:2d} ocultas: {pesos:3d} pesos para aprender 4 filas')
 2 ocultas:   9 pesos para aprender 4 filas
 4 ocultas:  17 pesos para aprender 4 filas
 8 ocultas:  33 pesos para aprender 4 filas
16 ocultas:  65 pesos para aprender 4 filas

Con dieciséis neuronas hay 65 parámetros para aprender cuatro filas. O sea dieciséis pesos por fila 😅

En cualquier otro contexto eso sería sobreajuste puro. Aquí no importa porque las cuatro filas son el universo entero: no hay ningún caso nuevo que pueda llegar. Con datos de verdad esto se paga, y es el capítulo 8.

5. La red que se rinde por el paso

Prueba pasos muy chicos y muy grandes.

for paso in [0.001, 0.05, 0.5, 5.0, 50.0]:
    p, perdida = red_dos_capas(1, paso=paso)
    print(f'paso {paso:6}: pérdida {perdida:.4f}  {np.round(p, 3)}')
paso  0.001: pérdida 0.6382  [0.438 0.631 0.451 0.513]
paso   0.05: pérdida 0.0114  [0.002 0.988 0.987 0.018]
paso    0.5: pérdida 0.0009  [0.    0.999 0.999 0.002]
paso    5.0: pérdida 0.0000  [0. 1. 1. 0.]
paso   50.0: pérdida 55.8202  [0. 0. 0. 0.]

Los tres del medio funcionan y los dos extremos fallan, cada uno a su manera.

Con 0,001 la pérdida se queda en 0,6382 y las salidas rondan el 0,5: no le dio tiempo a moverse del punto de partida en 5.000 vueltas. Ese punto de partida vale 0,6931, que no es un número cualquiera: es el logaritmo natural de 2, lo que vale la pérdida cuando el modelo dice 0,5 a todo. Cuando lo veas en un entrenamiento de verdad, sabes que no ha aprendido nada todavía 🎯

Con 50 pasa lo contrario y es peor: la pérdida sale 55,82 y las cuatro salidas se van a 0. El paso era tan grande que en vez de bajar por la pendiente se pasó de largo, rebotó y se disparó. Eso se llama divergir, y una pérdida que crece en vez de bajar es su síntoma 💥

Entre 0,05 y 5 hay dos órdenes de magnitud que funcionan, y fuera de ahí no funciona nada. Elegir el paso es el ajuste que más importa de una red, y es el capítulo 5.

6. Tres capas en vez de dos

Mete una capa más y mira si ayuda.

def red_tres_capas(semilla, vueltas=5000, paso=0.5):
    rng = np.random.default_rng(semilla)
    W1 = rng.normal(0, 1, (2, 4)); b1 = np.zeros(4)
    W2 = rng.normal(0, 1, (4, 4)); b2 = np.zeros(4)
    W3 = rng.normal(0, 1, (4, 1)); b3 = np.zeros(1)
    objetivo = Y_XOR.reshape(-1, 1)
    for _ in range(vueltas):
        h1 = np.tanh(X @ W1 + b1)
        h2 = np.tanh(h1 @ W2 + b2)
        p = sigmoide(h2 @ W3 + b3)
        d3 = (p - objetivo) / len(X)
        d2 = (d3 @ W3.T) * (1 - h2 ** 2)
        d1 = (d2 @ W2.T) * (1 - h1 ** 2)
        W3 -= paso * (h2.T @ d3); b3 -= paso * d3.sum(axis=0)
        W2 -= paso * (h1.T @ d2); b2 -= paso * d2.sum(axis=0)
        W1 -= paso * (X.T @ d1); b1 -= paso * d1.sum(axis=0)
    return p.ravel()

convergen = sum(1 for s in range(10)
                if ((red_tres_capas(s) >= 0.5).astype(int) == Y_XOR).sum() == 4)
print('con tres capas convergen', convergen, 'de 10')
con tres capas convergen 10 de 10

Diez de diez, mejor que las nueve de dos capas. Aunque el XOR no necesita profundidad para nada: se resuelve con una capa oculta y punto.

Lo que ganó aquí la capa extra es lo mismo que ganaban las neuronas de más: caminos. Y fíjate cómo crece el código hacia atrás, que es una línea más por capa. Ese patrón es literalmente todo el deep learning 🔁

7. El error de olvidar el reshape

Pásale el objetivo plano en vez de en columna.

rng = np.random.default_rng(1)
W1 = rng.normal(0, 1, (2, 4)); b1 = np.zeros(4)
W2 = rng.normal(0, 1, (4, 1)); b2 = np.zeros(1)

h = np.tanh(X @ W1 + b1)
p = sigmoide(h @ W2 + b2)
print('p tiene forma  :', p.shape)
print('Y_XOR tiene    :', Y_XOR.shape)
print('la resta sale  :', (p - Y_XOR).shape)
p tiene forma  : (4, 1)
Y_XOR tiene    : (4,)
la resta sale  : (4, 4)

Y aquí está el peligro, que no da error: restar algo de (4, 1) menos algo de (4,) devuelve (4, 4). Numpy estira las dos formas hasta que encajan, y en vez de cuatro errores tienes dieciséis.

El entrenamiento sigue corriendo, la pérdida baja, todo parece bien y el resultado está mal. Es de los errores más caros que existen porque no avisa 😨

Por eso el reshape(-1, 1) del código de arriba no era manía. Mi regla: imprime .shape de las dos cosas antes de cada resta, sobre todo si una viene de pandas.

8. Buscar la esquina cruzada en tres pares de columnas

Repite la comprobación de las cuatro casillas con otros pares y mira si alguno cruza.

ventas['visita_repetida'] = (ventas.sort_values(['cliente_id', 'fecha'])
                             .groupby('cliente_id').cumcount() > 0).astype(int)
ventas['mayorista'] = (ventas['segmento'] == 'Mayorista').astype(int)

for otra in ['visita_repetida', 'mayorista']:
    tabla = ventas.groupby(['con_descuento', otra])['compro'].mean().unstack()
    salto_sin = tabla.loc[1, 0] - tabla.loc[0, 0]
    salto_con = tabla.loc[1, 1] - tabla.loc[0, 1]
    print(f'{otra:18} el descuento suma {salto_sin:+.4f} cuando es 0 '
          f'y {salto_con:+.4f} cuando es 1')
visita_repetida    el descuento suma +0.1175 cuando es 0 y +0.1235 cuando es 1
mayorista          el descuento suma +0.1368 cuando es 0 y +0.0682 cuando es 1

Los dos saltos salen positivos en los dos pares. Ni uno cruza.

Con mayorista el descuento suma 0,1368 a los que no lo son y 0,0682 a los que sí, o sea el doble en unos que en otros. Otra vez intensidad y no signo.

Y con visita_repetida los dos saltos son casi idénticos (0,1175 y 0,1235), o sea que ahí ni siquiera hay interacción: son dos efectos que van cada uno por su lado y se suman.

Tres pares mirados y ninguna esquina cruzada. En un negocio de distribución tiene sentido: rebajar el precio no hace que nadie deje de comprar 🧾

9. El error de cambiar el tamaño en un solo sitio

Pon ocho neuronas en la primera capa y déjate las cuatro de la segunda, que es lo que pasa cuando editas rápido.

rng = np.random.default_rng(1)
W1 = rng.normal(0, 1, (2, 8)); b1 = np.zeros(8)     # ocho ahora
W2 = rng.normal(0, 1, (4, 1)); b2 = np.zeros(1)     # y aquí seguían cuatro

h = np.tanh(X @ W1 + b1)
h @ W2
ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 4 is different from 8)

"size 4 is different from 8", que es el mismo error de formas del capítulo 2 y te va a acompañar toda la vida.

Y menos mal que revienta. La capa oculta tiene que salir con la misma forma con la que entra en la siguiente, y ese encadenado es lo único que hay que cuidar al montar una red a mano 🔗

Cuando uses una librería, esto se lo lleva ella: le dices hidden_layer_sizes=(8, 4) y las formas las cuadra sola. Es de las pocas cosas que agradezco de verdad de las librerías 😅

Comprueba que lo tienes

El XOR converge en 9 de 10 arranques con 4 neuronas, en 5 de 10 con 2 y en 10 de 10 con 8. ¿Qué dan las neuronas de más?

  • Caminos: más arranques distintos acaban encontrando la solución
  • Capacidad: pueden aprender funciones más complicadas
  • Velocidad de entrenamiento
  • Precisión en la respuesta

Lo que te llevas

  • 📐 Una neurona traza una recta. El XOR no se separa con una recta, así que devuelve 0,5 en los cuatro casos por muchas vueltas que le des.
  • 🧱 Una capa oculta de cuatro neuronas lo resuelve, y se escribe en veinte líneas de numpy.
  • 🎲 Pero no siempre: de diez arranques convergen nueve, y el que falla se queda en un mínimo local con pérdida 0,3468.
  • 🛣️ Con dos neuronas convergen 5 de 10, con cuatro 9 y con ocho las diez. Más neuronas no dan más capacidad, dan más caminos.
  • 🧩 La capa oculta es feature engineering automático: si le das a mano la columna que falta, una sola neurona resuelve el XOR.
  • 🎯 Una pérdida de 0,6931 es ln(2), o sea el modelo diciendo 0,5 a todo.
  • 😨 Restar un (4, 1) menos un (4,) devuelve un (4, 4) sin avisar.

En el capítulo 5 dejamos de mover los pesos a mano: descenso de gradiente, que es cómo la red decide hacia dónde corregir.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?