El borde filoso del capítulo anterior 🌸
En el capítulo 17 la tasa 0.9 funcionaba y la 1.01 mandaba el error a veintinueve mil millones. Ese borde no es aleatorio: tiene un número exacto y se puede calcular.
Volvemos a abrir el archivo
import pandas as pd import numpy as np df = pd.read_csv('ventas-miss-yera.csv') for c in ('unidades', 'monto', 'satisfaccion'): df[c] = pd.to_numeric(df[c], errors='coerce') X = df[['unidades', 'monto', 'satisfaccion']].dropna().to_numpy() y = X[:, 1] F = X[:, [0, 2]] Zf = (F - F.mean(axis=0)) / F.std(axis=0) A = np.column_stack([np.ones(len(y)), Zf]) n = len(y) print('A.shape:', A.shape)
A.shape: (2238, 3)
Motivo uno: la tasa se pasó del límite
H = 2 * (A.T @ A) / n vals = np.linalg.eigvalsh(H) print('autovalores de la hessiana:', np.round(vals, 4)) print('limite de la tasa :', round(float(2 / vals.max()), 6))
autovalores de la hessiana: [1.9722 2. 2.0278] limite de la tasa : 0.986287
la tasa tiene que ser menor que dos partido por el autovalor mayor de la hessiana, y si te pasas de ahí el error crece en vez de bajar
0.9863. Ahí está el borde. La tasa 0.9 estaba justo debajo y la 1.01 justo encima, y por eso una funcionaba y la otra no.
La intuición es la de una pelota en un tazón: si el paso es más del doble de lo que hace falta para llegar al fondo, acabas más arriba que donde empezaste. Y como el gradiente ahí es mayor, el siguiente paso es peor todavía. Se realimenta 🙃
Motivo dos, y es el que te va a pasar: no escalaste
An = np.column_stack([np.ones(len(y)), F]) Hn = 2 * (An.T @ An) / n vn = np.linalg.eigvalsh(Hn) print('sin escalar, autovalores:', np.round(vn, 4)) print('limite de la tasa :', float(2 / vn.max())) print('numero de condicion :', round(float(vn.max() / vn.min()), 2))
sin escalar, autovalores: [2.013000e-01 7.575100e+00 3.560967e+02] limite de la tasa : 0.005616452068299692 numero de condicion : 1769.22
El límite pasó de 0.9863 a 0.0056. Ciento setenta y seis veces más chico.
Y ahora míralo pasar:
def error_n(w): return float(((y - An @ w) ** 2).mean()) for tasa in (0.1, 0.01, 0.001): w = np.zeros(3) roto = False for _ in range(200): w = w - tasa * (-2 * (An.T @ (y - An @ w)) / n) if not np.all(np.isfinite(w)): roto = True break print(' tasa', tasa, '->', 'exploto' if roto else round(error_n(w), 2))
tasa 0.1 -> exploto tasa 0.01 -> 1.2207480061226205e+169 tasa 0.001 -> 633065.28
La tasa 0.1, que escalado iba perfecta, acá revienta. La 0.01 devuelve diez elevado a 169, que técnicamente no es infinito y para efectos prácticos lo es. Y la 0.001, que sí está por debajo del límite, llega a 633.065 cuando el óptimo es 567.723.
Ninguna de las tres sirve. Los mismos datos, el mismo modelo, la misma fórmula, y lo único que cambia es en qué unidades están medidas las columnas.
Escalar no es limpieza de datos. Es lo que convierte un cañón estrecho en un tazón redondo, y esa es la diferencia entre poder entrenar y no poder. Es la cuarta vez en este libro que escalar arregla algo y ya no es coincidencia: es que la mitad de los problemas de este oficio son problemas de escala 🌟
Motivo tres: la superficie no tiene un solo fondo
Los dos motivos de arriba son de configuración y se arreglan. El tercero es de la forma del problema.
print('autovalores escalado:', np.round(vals, 4)) print('todos positivos :', bool(np.all(vals > 0)))
autovalores escalado: [1.9722 2. 2.0278] todos positivos : True
Todos positivos en todo el espacio quiere decir convexo: un solo fondo, y llegas siempre, vengas de donde vengas. Eso lo comprobaste en el ejercicio 2 del capítulo 17.
Toda regresión lineal con error cuadrático es convexa. La logística también. Y por eso con esos modelos el entrenamiento o converge o está mal configurado, no hay tercera opción.
Una red neuronal no es convexa, y ahí aparecen dos cosas:
- Mínimos locales: hoyos que no son el hoyo más hondo. Tienen mucha peor fama de la que merecen, porque en dimensiones altas son raros.
- Puntos de silla: sube en unas direcciones y baja en otras, como el ejercicio 3 del capítulo 16. El gradiente ahí vale cero y el entrenamiento se queda quieto sin haber terminado. Estos sí son el problema de verdad, y son muchísimo más frecuentes.
La lista para cuando no converja
- ¿Escalaste? Nueve de cada diez veces es esto.
- Baja la tasa entre diez. Si con eso baja el error, era la tasa.
- Mira el largo del gradiente. Si es cero pero el error sigue alto, es un punto de silla, no el fondo.
- Comprueba el gradiente contra el numérico, como en el capítulo 13. Si no coinciden, la derivada está mal programada y no hay tasa que lo arregle.
- Mira si hay nan. Un solo nan en los datos se propaga a todos los pesos en una iteración.
Lo que te llevas
- La tasa tiene un límite exacto: dos partido por el autovalor mayor de la hessiana.
- Sin escalar ese límite se hace diminuto, y ninguna tasa razonable funciona.
- Escalar cambia la forma del valle, no los datos.
- Los modelos lineales son convexos: convergen o están mal configurados.
- En redes, los puntos de silla dan más problemas que los mínimos locales.
- Cuando no converja: escalar, bajar la tasa, mirar el gradiente, buscar nan.
Comprueba que se entendió
Comprueba que lo tienes
Con las columnas escaladas tu entrenamiento converge con tasa 0.1. Sin escalar, la misma tasa explota. ¿Por qué?
- Porque sin escalar el autovalor mayor de la hessiana es enorme, y el límite de la tasa se hace diminuto
- Porque sin escalar hay más ruido en los datos
- Porque escalar quita los valores extremos
- Porque escalar hace el modelo más simple
Ejercicios
1. Cruza el límite y míralo explotar
Justo debajo y justo encima del número exacto.
y = X[:, 1] F = X[:, [0, 2]] Zf = (F - F.mean(axis=0)) / F.std(axis=0) A = np.column_stack([np.ones(len(y)), Zf]) n = len(y) def error(w): return float(((y - A @ w) ** 2).mean()) limite = float(2 / np.linalg.eigvalsh(2 * (A.T @ A) / n).max()) print('limite:', round(limite, 6)) for tasa in (limite * 0.99, limite * 1.01): w = np.zeros(3) for _ in range(300): w = w - tasa * (-2 * (A.T @ (y - A @ w)) / n) print(' tasa', round(tasa, 6), '-> error', error(w))
limite: 0.986287 tasa 0.976424 -> error 567723.3915457682 tasa 0.99615 -> error 28471165.52758426
Un 2% de diferencia en la tasa, y el error pasa de 567.723 a veintiocho millones. No es una zona gris: es un acantilado, y ahora sabes en qué coordenada está. Y ojo con que del lado malo el número todavía se puede imprimir: si dejas correr más iteraciones se va a infinito, pero en el paso 300 aún parece un número.
2. Un nan se come el modelo entero
Mete un hueco y mira cuánto tarda en contaminarlo todo.
y = X[:, 1] F = X[:, [0, 2]] Zf = (F - F.mean(axis=0)) / F.std(axis=0) A = np.column_stack([np.ones(len(y)), Zf]) n = len(y) Aroto = A.copy() Aroto[7, 1] = np.nan w = np.zeros(3) for i in range(1, 4): w = w - 0.1 * (-2 * (Aroto.T @ (y - Aroto @ w)) / n) print('iter', i, '| pesos', w, '| cuantos nan:', int(np.isnan(w).sum()))
iter 1 | pesos [nan nan nan] | cuantos nan: 3 iter 2 | pesos [nan nan nan] | cuantos nan: 3 iter 3 | pesos [nan nan nan] | cuantos nan: 3
Una sola casilla mala, y en una iteración los tres pesos son nan. Ni siquiera hizo falta la segunda: el producto punto del capítulo 4 suma toda la columna, así que un nan en cualquier fila contamina el resultado entero, y de ahí pasa a los tres pesos.
Y encima no da error: sigue corriendo tan tranquilo hasta el final. Por eso
np.isnan(X).any() va antes de entrenar y no después de que algo
salga raro.
3. El número de condición predice cuántas iteraciones
Compara escalado contra a medio escalar.
y = X[:, 1] F = X[:, [0, 2]] n = len(y) Zf = (F - F.mean(axis=0)) / F.std(axis=0) for nombre, M in (('escalado ', Zf), ('a medias ', Zf * np.array([1.0, 50.0]))): A_ = np.column_stack([np.ones(n), M]) H = 2 * (A_.T @ A_) / n v = np.linalg.eigvalsh(H) opt = np.linalg.solve(A_.T @ A_, A_.T @ y) w = np.zeros(3) tasa = 0.9 * 2 / v.max() iters = 0 while np.linalg.norm(w - opt) > 1e-3 and iters < 200000: w = w - tasa * (-2 * (A_.T @ (y - A_ @ w)) / n) iters += 1 print(nombre, '| condicion', round(float(v.max() / v.min()), 1), '| iteraciones', iters)
escalado | condicion 1.0 | iteraciones 54 a medias | condicion 2500.5 | iteraciones 18878
Multipliqué una columna por cincuenta y el entrenamiento pasó de 54 iteraciones a 18.878. Trescientas cincuenta veces más lento por una decisión de unidades que nadie tomó a propósito. Ese es el precio real de no escalar, y no sale en ningún mensaje de error.
Practica este capítulo 📓
Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.
¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.