Capítulo 2 de 15 7 secciones 11 min

La neurona, escrita a mano en cuatro cuentas

Multiplicar, sumar, aplastar. Y comprobar que la regresión logística que ya conoces es exactamente esto.

Una neurona hace tres cosas: multiplica cada entrada por un peso, suma todo junto con un sesgo, y aplasta el resultado entre 0 y 1 con una función. Eso es todo, y se escribe en una línea de numpy. La prueba de que no hay nada más: tomando los pesos de una regresión logística ya entrenada y haciendo la cuenta a mano, sale exactamente lo mismo que devuelve la librería, con una diferencia de 1,1e-16.

Todo el deep learning sale de repetir una pieza muy simple. Vamos a escribirla 🔧

Y te adelanto el final del capítulo, porque es el que quita el misterio: la regresión logística del libro anterior es una neurona. No se parece, no es análoga: es la misma cuenta, y lo vamos a comprobar hasta la diezmilbillonésima.

Las tres cosas que hace

a=f(b+j=1mwjxj)

una neurona multiplica cada entrada por su peso, lo suma todo, le añade un sesgo y pasa el resultado por una función

Una neurona recibe números y devuelve un número. Por dentro:

  • ✖️ Multiplica cada entrada por su peso. El peso dice cuánto importa esa entrada.
  • Suma todos esos productos, más un sesgo, que es el número que tiene puesto de arranque.
  • 🫓 Aplasta el resultado a algo entre 0 y 1 con una función de activación.
import numpy as np

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

entradas = np.array([2.0, -0.5, 1.0])       # tres cosas que sabemos del cliente
pesos = np.array([0.8, -0.3, 0.2])          # cuánto importa cada una
sesgo = -0.1

z = entradas @ pesos + sesgo
print('la suma da   :', round(z, 4))
print('aplastada da :', round(sigmoide(z), 4))
la suma da   : 1.85
aplastada da : 0.8641

Eso es una neurona. En serio, no hay más 😌

El @ de numpy es la multiplicación de matrices, y aquí hace de golpe lo mismo que 2.0*0.8 + (-0.5)*(-0.3) + 1.0*0.2. Escribirlo así no es por elegancia: es que cuando sean 28 entradas y 3.000 filas, la diferencia de velocidad es de varios órdenes.

Y el resultado, 0,8641, se lee como "86% de que sí". Aunque todavía no significa nada, porque esos pesos me los inventé yo ✋

Muchas filas a la vez

𝐚(l)=f(W(l)𝐚(l1)+𝐛(l))

lo mismo para una capa entera, donde la multiplicación de todas las neuronas por todas las entradas cabe en un solo producto de matrices

La misma línea, sin tocarla, funciona para una tabla entera:

clientes = np.array([
    [2.0, -0.5,  1.0],
    [-1.0,  0.5, -2.0],
    [0.0,  0.0,  0.0],
    [3.0,  1.0,  0.5],
])

print('las sumas   :', np.round(clientes @ pesos + sesgo, 4))
print('aplastadas  :', np.round(sigmoide(clientes @ pesos + sesgo), 4))
las sumas   : [ 1.85 -1.45 -0.1   2.1 ]
aplastadas  : [0.8641 0.19   0.475  0.8909]

Cuatro clientes, cuatro probabilidades, y el código es idéntico. Eso se llama vectorizar y es la razón de que numpy exista.

Fíjate en el tercero: entradas todas cero, así que la suma es solo el sesgo (-0,1) y la probabilidad sale 0,475. El sesgo es lo que la neurona contesta cuando no sabe nada, y por eso hace falta 🎈

Por qué aplastar, y por qué con esa función

La suma puede dar cualquier cosa:

  • -47
  • 0
  • 3
  • 1.200

Y nosotras queremos una probabilidad, o sea algo entre 0 y 1.

La sigmoide hace exactamente eso, y además de una forma con una propiedad que va a ser clave en el capítulo 5:

for z in [-20, -6, -2, 0, 2, 6, 20]:
    s = sigmoide(z)
    print(f'z={z:4d}   sigmoide={s:.6f}   pendiente={s * (1 - s):.6f}')
z= -20   sigmoide=0.000000   pendiente=0.000000
z=  -6   sigmoide=0.002473   pendiente=0.002467
z=  -2   sigmoide=0.119203   pendiente=0.104994
z=   0   sigmoide=0.500000   pendiente=0.250000
z=   2   sigmoide=0.880797   pendiente=0.104994
z=   6   sigmoide=0.997527   pendiente=0.002467
z=  20   sigmoide=1.000000   pendiente=0.000000

Mira la columna de la derecha, que es cuánto cambia la salida si mueves un poquito la entrada.

En el centro (z=0) la pendiente vale 0,25, que es su máximo. En los extremos vale cero: en z=20 la neurona dice 1,000000 y da igual lo que le muevas, va a seguir diciendo 1.

Eso se llama saturación, y es un problema muy serio, porque una neurona saturada deja de aprender. Aparece con nombre propio en el capítulo 7, cuando apilemos capas y esos ceros se multipliquen entre sí 😰

La comprobación que quita el misterio

Ahora lo prometido. Entrenamos una regresión logística con scikit-learn, le sacamos los pesos, y hacemos la cuenta a mano.

import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

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

def prepara(v):
    v = v.sort_values(['cliente_id', 'fecha']).copy()
    v['sin_compra_previa'] = v['fecha_ultima_compra'].isna().astype(int)
    v['sin_descuento'] = v['descuento'].isna().astype(int)
    v['sin_satisfaccion'] = v['satisfaccion'].isna().astype(int)
    v['precio_unitario'] = v['monto'] / v['unidades']
    v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1
    return v

NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario',
             'sin_compra_previa', 'sin_descuento', 'sin_satisfaccion', 'visita_numero']
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']

modelo = Pipeline([
    ('pre', ColumnTransformer([
        ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                          ('e', StandardScaler())]), NUMERICAS),
        ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                          ('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
    ])),
    ('mod', LogisticRegression(max_iter=1000, random_state=42)),
])

datos = prepara(carga_limpia(URL))
X = datos[NUMERICAS + CATEGORICAS]
y = datos['compro']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
                                          random_state=42, stratify=y)
modelo.fit(X_tr, y_tr)

W = modelo.named_steps['mod'].coef_[0]          # los pesos que aprendió
B = modelo.named_steps['mod'].intercept_[0]     # y su sesgo
print('pesos:', W.shape, '  sesgo:', round(B, 4))
pesos: (28,)   sesgo: 0.198

Veintiocho pesos y un sesgo. Exactamente lo que pide una neurona con 28 entradas.

T = modelo.named_steps['pre'].transform(X_te)   # las columnas ya preparadas

a_mano = sigmoide(T @ W + B)
libreria = modelo.predict_proba(X_te)[:, 1]

print('a mano  :', np.round(a_mano[:4], 6))
print('librería:', np.round(libreria[:4], 6))
print('la mayor diferencia entre las dos:', float(np.abs(a_mano - libreria).max()))
a mano  : [0.67472  0.69235  0.722401 0.659543]
librería: [0.67472  0.69235  0.722401 0.659543]
la mayor diferencia entre las dos: 1.1102230246251565e-16

Iguales hasta donde llega el doble de precisión: la mayor diferencia es 1,1e-16, o sea el error de redondeo de la computadora 🎯

Ahí está lo que quería que vieras. No hay una caja negra. Lo que hace predict_proba es esa línea de numpy, ni una operación más.

Y la única diferencia entre esto y una red neuronal de verdad es que la red tiene varias de estas apiladas, cada una comiéndose la salida de la anterior. En el capítulo 4 apilamos la primera.

Lo que la neurona NO hace

Como ya sabes lo que hay dentro, dos cosas que se dicen mucho y son falsas:

  • 🧠 No "entiende" nada. Multiplica y suma. La palabra "neurona" viene de una analogía de 1943 y ha hecho más daño que bien.
  • 🎲 No hay nada aleatorio en predecir. Los mismos pesos y la misma entrada dan siempre el mismo número. Lo aleatorio está en el arranque del entrenamiento, y por eso ponemos random_state.

Ejercicios

1. Qué peso le puso a cada cosa

Mira los cinco pesos más grandes de la neurona que entrenamos, con el nombre de su columna.

nombres = modelo.named_steps['pre'].get_feature_names_out()
for i in np.argsort(-np.abs(W))[:5]:
    print(f'{nombres[i]:32} {W[i]:+.4f}')
cat__segmento_Bodega             -0.6832
cat__segmento_Mayorista          +0.4725
cat__canal_Marketplace           -0.4651
num__satisfaccion                +0.4152
num__sin_compra_previa           -0.3747

Ser Bodega es el peso más negativo y ser Mayorista el más positivo, que es lo mismo que salía en el capítulo 12 del libro de machine learning.

Una neurona entrenada es una lista de números con un nombre al lado. Cuando sean cuatro capas ya no vas a poder leerla así, y esa pérdida es real: es el precio que se paga por la capacidad 🔍

2. Mover un peso a mano y ver qué pasa

Duplica el peso de la satisfacción y mira cuánto se mueven las predicciones.

i = list(nombres).index('num__satisfaccion')
W2 = W.copy()
W2[i] = W[i] * 2

antes = sigmoide(T @ W + B)
despues = sigmoide(T @ W2 + B)
print('peso original :', round(W[i], 4), ' duplicado:', round(W2[i], 4))
print('se movió en promedio:', round(float(np.abs(despues - antes).mean()), 4))
print('el que más se movió :', round(float(np.abs(despues - antes).max()), 4))
peso original : 0.4152  duplicado: 0.8303
se movió en promedio: 0.0671
el que más se movió : 0.1518

Duplicar un peso mueve las predicciones 0,0671 en promedio y hasta 0,1518 en el caso más extremo.

Este ejercicio es el que hace tangible qué es entrenar: buscar la combinación de 28 números que menos se equivoca. Nada más. En el capítulo 5 dejamos de moverlos a mano 🎚️

3. La neurona sin aplastar

Quita la sigmoide y mira qué sale.

crudo = T @ W + B
print('sin aplastar, va de', round(float(crudo.min()), 4),
      'a', round(float(crudo.max()), 4))
print('aplastado, va de', round(float(sigmoide(crudo).min()), 4),
      'a', round(float(sigmoide(crudo).max()), 4))
print('¿ordenan igual?', bool((np.argsort(crudo) == np.argsort(sigmoide(crudo))).all()))
sin aplastar, va de -2.4445 a 2.8774
aplastado, va de 0.0798 a 0.9467
¿ordenan igual? True

Van de -2,4445 a 2,8774 sin aplastar, y de 0,0798 a 0,9467 aplastado. Y ordenan exactamente igual.

Eso es útil de saber: si solo te importa el ranking, como en el capítulo 15 del libro anterior, la sigmoide no cambia nada. Importa cuando quieres leer el número como probabilidad, y cuando entrenas 📏

4. Un umbral es una raya en la suma

Comprueba que cortar en 0,5 de probabilidad es lo mismo que cortar en 0 de la suma.

print('sigmoide(0) =', sigmoide(0))
print('¿coinciden los dos cortes?',
      bool(((sigmoide(crudo) >= 0.5) == (crudo >= 0)).all()))
print('¿y 0,14 con -1,8153?',
      bool(((sigmoide(crudo) >= 0.14) == (crudo >= -1.8153)).all()))
sigmoide(0) = 0.5
¿coinciden los dos cortes? True
¿y 0,14 con -1,8153? True

El famoso 0,5 del capítulo 9 del libro anterior es, por dentro, cortar la suma en cero. Y el 0,14 que salía óptimo es cortar en -1,8153.

No cambia nada práctico y sí cambia cómo lo piensas: el umbral no es una propiedad del modelo, es dónde pones la raya después ✂️

5. Cuántas cuentas hace de verdad

Cuenta las multiplicaciones que hace para predecir las 750 filas de prueba.

filas, columnas = T.shape
print(f'{filas} filas x {columnas} pesos = {filas * columnas:,} multiplicaciones')
print(f'y {filas:,} sumas del sesgo, y {filas:,} exponenciales')
750 filas x 28 pesos = 21,000 multiplicaciones
y 750 sumas del sesgo, y 750 exponenciales

Veintiún mil multiplicaciones para predecir 750 clientes, y tarda menos de un parpadeo.

Guárdate ese número, porque en el capítulo 11 vamos a ver que un modelo de lenguaje hace del orden de miles de millones de estas por cada palabra que escribe. La cuenta es la misma; lo que cambia es cuántas 🔢

6. La neurona con los pesos al revés

Ponle los pesos con el signo cambiado y mira qué predice.

alreves = sigmoide(T @ (-W) + (-B))
from sklearn.metrics import roc_auc_score
print('normal  :', round(roc_auc_score(y_te, sigmoide(T @ W + B)), 4))
print('al revés:', round(roc_auc_score(y_te, alreves), 4))
normal  : 0.7214
al revés: 0.2786

0,7214 y 0,2786, que suman exactamente 1.

Un modelo con AUC de 0,28 no es un modelo malo: es un modelo bueno leído al revés. Si alguna vez te sale un AUC muy por debajo de 0,5, casi siempre hay una etiqueta invertida en alguna parte, y es de los errores más fáciles de arreglar 🔄

7. El error que vas a ver mil veces

Dale a la neurona menos pesos que columnas.

T @ W[:5]
ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 5 is different from 28)

Aquí está el error más común de todo el deep learning, y no exagero. Una matriz de 750 por 28 no se puede multiplicar por un vector de 5.

El mensaje es feo pero dice todo lo que necesitas: "size 5 is different from 28". Cuando te salga, no leas el resto: busca esos dos números y mira qué forma esperabas tú 📐

Mi truco de toda la vida es imprimir .shape de todo lo que entra en un @ antes de ejecutarlo. Suena de principiante y lo sigo haciendo.

Comprueba que lo tienes

Una neurona multiplica, suma y aplasta. ¿Qué pasa si le quitas el aplastar?

  • Que deja de poder aprender cualquier cosa que no sea una recta
  • Que va más rápido pero aprende igual
  • Que hay que usar más neuronas para compensar
  • Nada, es un detalle de implementación

Lo que te llevas

  • ✖️ Una neurona multiplica, suma un sesgo y aplasta. Se escribe en una línea de numpy.
  • 🎯 La regresión logística es una neurona: haciendo la cuenta a mano sale lo mismo que la librería con 1,1e-16 de diferencia.
  • 🎈 El sesgo es lo que contesta cuando todas las entradas son cero.
  • 😰 La sigmoide se satura: en z=20 su pendiente es cero y la neurona deja de aprender. Vuelve en el capítulo 7.
  • ✂️ Un umbral de probabilidad es una raya en la suma: 0,5 es cortar en cero.
  • 🔄 Un AUC de 0,28 es un 0,72 con la etiqueta invertida.
  • 📐 El error que más vas a ver es de formas, y su mensaje trae los dos números que necesitas.

En el capítulo 3 vemos por qué la sigmoide no es la única función de activación, y por qué casi nadie la usa ya en las capas del medio.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?