Todo junto, de una vez 🌸
Veinte capítulos y ni una sola librería de machine learning. Solo NumPy, pandas y lo que fuimos construyendo.
El problema: predecir cuánto se factura de verdad en una venta, a partir de las unidades, el monto, el descuento y la satisfacción.
Cada paso lleva el capítulo que lo explica. Si alguno no te cuadra, ahí está de dónde salió.
Pasos 1 y 2: mirar antes de tocar
import pandas as pd import numpy as np COLS = ['unidades', 'monto', 'descuento', 'satisfaccion'] df = pd.read_csv('ventas-miss-yera.csv') for c in COLS + ['monto_final_facturado']: df[c] = pd.to_numeric(df[c], errors='coerce') d = df[COLS + ['monto_final_facturado']].dropna() y = d['monto_final_facturado'].to_numpy() F = d[COLS].to_numpy() print('forma:', F.shape, '| objetivo:', y.shape) print('rango:', np.linalg.matrix_rank(F), 'de', F.shape[1])
forma: (1793, 4) | objetivo: (1793,) rango: 4 de 4
Forma primero, siempre, como en el capítulo 1. Y rango completo, que quiere decir que ninguna columna sobra: el chequeo del capítulo 8 pasa.
Paso 3: escalar
Z = (F - F.mean(axis=0)) / F.std(axis=0) print('medias :', np.round(Z.mean(axis=0), 10)) print('desviaciones:', np.round(Z.std(axis=0), 6))
medias : [-0. 0. -0. 0.] desviaciones: [1. 1. 1. 1.]
Media cero y desviación uno. Es el paso que en los capítulos 7, 11 y 18 resultó ser el que arreglaba todo, así que va temprano y no se discute.
Paso 4: la matriz que aparece siempre
A = np.column_stack([np.ones(len(Z)), Z]) n = len(y) G = A.T @ A print('forma :', G.shape, '| simetrica:', np.allclose(G, G.T)) print('condicion :', round(float(np.linalg.cond(G)), 2))
forma : (5, 5) | simetrica: True condicion : 1.11
La columna de unos es el intercepto del capítulo 9. Y condición 1.11 es prácticamente perfecta: no hay nada mal condicionado acá, gracias al paso anterior.
Paso 5: resolver
w_exacto = np.linalg.solve(G, A.T @ y) print('pesos:', np.round(w_exacto, 4))
pesos: [472.3815 2.2614 478.3356 -20.7077 58.9271]
Las ecuaciones normales del capítulo 6, con
solve y no con inv.
Paso 6: comprobar y medir
res = y - A @ w_exacto print('residuos contra columnas:', np.round(A.T @ res, 6)) print('RMSE modelo:', round(float(np.sqrt((res ** 2).mean())), 2)) print('RMSE media :', round(float(np.sqrt(((y - y.mean()) ** 2).mean())), 2))
residuos contra columnas: [-0. 0. 0. 0. -0.] RMSE modelo: 433.8 RMSE media : 648.75
Residuos ortogonales, o sea que la proyección del capítulo 9 está bien hecha. Y esta vez, a diferencia de los ejemplos de los capítulos, el modelo sí sirve: baja el error de 648.75 a 433.80, que es un 33% menos.
Te lo señalo porque a lo largo del libro te enseñé varios modelos que no mejoraban nada, a propósito. Este mejora, y la diferencia es que ahora las columnas sí tienen información sobre lo que quiero predecir 🌟
Paso 7: la curvatura y el límite de la tasa
H = 2 * G / n vals = np.linalg.eigvalsh(H) print('autovalores:', np.round(vals, 4)) print('limite tasa:', round(float(2 / vals.max()), 6))
autovalores: [1.8889 1.9586 2. 2.0571 2.0954] limite tasa: 0.954485
Todos positivos: convexo, hay un solo fondo. Y ya sé qué tasa puedo usar antes de probar ni una. Los dos son los capítulos 16 y 18.
Paso 8: llegar al mismo sitio por el otro camino
def gradiente(w): return -2 * (A.T @ (y - A @ w)) / n w = np.zeros(A.shape[1]) for _ in range(500): w = w - 0.3 * gradiente(w) print('descenso :', np.round(w, 4)) print('distancia:', round(float(np.linalg.norm(w - w_exacto)), 8))
descenso : [472.3815 2.2614 478.3356 -20.7077 58.9271] distancia: 0.0
Distancia cero. El descenso de gradiente del capítulo 17 y la fórmula cerrada llegan exactamente al mismo punto.
Este es el momento del libro que más me gusta. Álgebra por un lado, cálculo por el otro, y el mismo vector 💛
Paso 9: mirar la estructura
C = np.cov(Z, rowvar=False) ev, evec = np.linalg.eigh(C) orden = np.argsort(ev)[::-1] print('varianza explicada:', np.round(ev[orden] / ev.sum(), 4)) print('primer autovector :', np.round(evec[:, orden][:, 0], 4))
varianza explicada: [0.2619 0.2571 0.2448 0.2361] primer autovector : [ 0.4589 0.5758 -0.5253 -0.4265]
26%, 26%, 24%, 24%. Repartido casi perfecto entre las cuatro.
Eso es el capítulo 11 diciéndote que no apliques PCA. No hay redundancia que comprimir: las cuatro columnas aportan cosas distintas. Y eso, para un modelo, es una buena noticia.
Paso 10: regularizar, y comprobar que no hace falta
for al in (0.0, 10.0, 1000.0): wr = np.linalg.solve(G + al * np.eye(A.shape[1]), A.T @ y) print('alpha', str(al).rjust(6), np.round(wr, 3))
alpha 0.0 [472.382 2.261 478.336 -20.708 58.927] alpha 10.0 [469.762 2.237 475.677 -20.702 58.561] alpha 1000.0 [303.251 1.007 306.935 -17.826 36.208]
Con alpha 10 los pesos casi no se mueven. Compáralo con lo que pasaba en el capítulo 19, donde alpha 1 hacía saltar un coeficiente de -565 a 174.
La diferencia es el paso 2: allá había una columna casi duplicada y aquí no. Cuando no hay multicolinealidad, regularizar apenas cambia nada, y con alpha 1000 solo estropea. Que un parámetro no haga nada también es información.
Y ahora la parte que ninguna fórmula hace
Los pesos son 2.26 para unidades, 478.34 para monto, -20.71 para descuento y 58.93 para satisfacción, y como escalamos, todos están medidos en desviaciones y sí se pueden comparar.
El monto manda por goleada, y tiene todo el sentido: lo que se factura sale de lo que se cobra. El descuento resta, que también cuadra.
Y ahora el aviso, que es lo último que quiero que te lleves del libro. Ese -20.71 no dice que dar descuento baje la facturación. Dice que en este archivo, las ventas con más descuento facturaron menos, y eso puede ser porque el descuento baja el monto, o porque los descuentos grandes se dan justamente en las ventas chicas, o por diez cosas más.
El álgebra lineal encuentra relaciones. La causa la pones tú, o no la pone nadie. Toda la matemática de este libro te da coeficientes correctísimos de una pregunta que a lo mejor no era la que querías hacer 🐣
Lo que te llevas
- Forma y rango antes de tocar nada.
- Escalar temprano, porque arregla la distancia, PCA y la convergencia.
solve, nuncainv.- Comparar siempre contra predecir la media.
- Dos caminos independientes que dan el mismo resultado es la mejor comprobación.
- PCA repartido parejo quiere decir que no apliques PCA.
- Que regularizar no cambie nada también es información.
- Los coeficientes son relaciones, no causas.
Comprueba que se entendió
Comprueba que lo tienes
En el proyecto, el descenso de gradiente llega al mismo punto que la fórmula exacta con distancia 0.0. ¿Qué te permite concluir?
- Que el problema estaba bien escalado y bien condicionado, y por eso los dos caminos coinciden
- Que el modelo predice bien
- Que el descenso de gradiente siempre llega al mismo sitio
- Que la fórmula exacta sobra
Ejercicios
1. Quita una columna y mira cuánto costó
Cada columna aporta algo medible.
COLS = ['unidades', 'monto', 'descuento', 'satisfaccion'] for fuera in range(4): quedan = [j for j in range(4) if j != fuera] Zq = Z[:, quedan] Aq = np.column_stack([np.ones(len(Zq)), Zq]) wq = np.linalg.solve(Aq.T @ Aq, Aq.T @ y) rmse = float(np.sqrt(((y - Aq @ wq) ** 2).mean())) print('sin', COLS[fuera].ljust(14), '-> RMSE', round(rmse, 2))
sin unidades -> RMSE 433.81 sin monto -> RMSE 645.43 sin descuento -> RMSE 434.3 sin satisfaccion -> RMSE 437.78
Sin el monto el modelo se va a 645.43, que es casi lo mismo que predecir la media (648.75). Las otras tres, quitándolas, mueven el RMSE entre cero y cuatro puntos.
O sea que el modelo entero es el monto y las otras tres están casi de adorno. Fíjate en que quitar las unidades deja el error en 433.81 cuando con ellas era 433.80: aportan una centésima. Eso no se ve mirando coeficientes, se ve quitando columnas.
2. El chequeo que deberías correr siempre
Cinco líneas que te ahorran tardes enteras.
def revisa(M, nombre='X'): print(nombre, 'forma :', M.shape) print(nombre, 'rango :', np.linalg.matrix_rank(M), 'de', M.shape[1]) print(nombre, 'nan :', bool(np.isnan(M).any())) print(nombre, 'condicion:', round(float(np.linalg.cond(M.T @ M)), 2)) revisa(Z, 'Z')
Z forma : (1793, 4) Z rango : 4 de 4 Z nan : False Z condicion: 1.11
Forma, rango, nan y condición. Cuatro números que resumen los capítulos 1, 8, 18 y 16. Cópialo en tu plantilla de proyectos y córrelo antes de entrenar cualquier cosa.
3. Compruébalo contra scikit-learn
Que lo que escribiste a mano sea lo mismo que usa todo el mundo.
from sklearn.linear_model import LinearRegression modelo = LinearRegression().fit(Z, y) print('sklearn intercepto:', round(float(modelo.intercept_), 4)) print('sklearn pesos :', np.round(modelo.coef_, 4)) print('a mano :', np.round(w_exacto, 4))
sklearn intercepto: 472.3815 sklearn pesos : [ 2.2614 478.3356 -20.7077 58.9271] a mano : [472.3815 2.2614 478.3356 -20.7077 58.9271]
Los mismos números hasta el cuarto decimal. Y eso es lo que quería que
supieras al terminar este libro: LinearRegression() no es una caja
negra. Es np.linalg.solve con manejo de errores y una API bonita, y
tú acabas de escribir lo de adentro 💛
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.