Capitulo 08
Aprendizaje Supervisado profundo: regresion, metricas, cross-validation
El modulo mas aplicado de la carrera: del laboratorio al diagnostico riguroso
Regresion Lineal (supuestos, Ridge/Lasso, diagnostico de residuos), Arboles + Random Forest (max_depth, feature importance), el ABC de las metricas (accuracy, F1, ROC-AUC, PR-AUC, MAE, RMSE, R²) y como elegir el threshold optimo por costo de negocio.
8.1 Regresion Lineal: el modelo mas viejo sigue siendo util
La regresion lineal no es “anticuada”. Es el modelo mas interpretable que existe. Cuando un CFO te pregunta “¿que feature importa mas?”, un Random Forest te da una distribucion de importancias. Una regresion lineal te da: “cada metro cuadrado adicional aumenta el precio en $1.800, manteniendo todo lo demas constante”. Esa es la diferencia entre interpretar y explicar.
Los 4 supuestos que importan
La regresion lineal NO es solo “ajustar una recta”. Tiene 4 supuestos que DEBES verificar antes de confiar en sus resultados. Si no se cumplen, los p-valores, los intervalos de confianza y las predicciones son invalidas.
| Concepto | Descripcion |
|---|---|
| Linealidad | La relacion entre X e y es aproximadamente lineal. Verificar con scatter plot. Si no, polynomial features o modelo no lineal. |
| Homocedast | Varianza de residuos CONSTANTE en todo el rango de prediccion. Verificar con plot de residuos vs predicciones. Si forma embudo, log(y) o modelo robusto. |
| Normalidad | Los residuos siguen una distribucion normal. Importa para p-valores e ICs. Q-Q plot o test de Shapiro-Wilk. Para predicciones puntuales, no es critico. |
| No multicol | Features no estan altamente correlacionadas entre si (|r| < 0.9). VIF > 10 es senal de problema. Solucion: PCA, eliminar features, regularizar. |
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.preprocessing import StandardScaler, PolynomialFeatures
from sklearn.pipeline import Pipeline
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from scipy import stats
# 1) Dataset sintetico: precio de viviendas
np.random.seed(42)
N = 300
superficie = np.random.normal(80, 25, N).clip(30, 200)
antiguedad = np.random.uniform(0, 50, N)
distancia_centro = np.random.exponential(8, N)
precio = (50000 + superficie*1800 - antiguedad*800 - distancia_centro*1200
+ np.random.normal(0, 15000, N))
df = pd.DataFrame({'superficie': superficie, 'antiguedad': antiguedad,
'distancia_centro': distancia_centro, 'precio': precio})
X = df.drop('precio', axis=1)
y = df['precio']
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# 2) Modelo: Pipeline con escalado + regresion
pipe = Pipeline([('scaler', StandardScaler()), ('model', LinearRegression())])
pipe.fit(X_train, y_train)
y_pred = pipe.predict(X_test)
print(f"R² en test: {r2_score(y_test, y_pred):.3f}")
print(f"MAE en test: {mean_absolute_error(y_test, y_pred):.0f}")
# 3) DIAGNOSTICO de los 4 supuestos
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
# a) Linealidad: y_pred vs y_test
axes[0,0].scatter(y_test, y_pred, alpha=0.6, color='#06B6D4')
axes[0,0].plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], 'r--', lw=2)
axes[0,0].set_xlabel('Real')
axes[0,0].set_ylabel('Predicho')
axes[0,0].set_title('Linealidad: nube deberia estar en la diagonal')
# b) Homocedasticidad: residuos vs predicciones
residuos = y_test - y_pred
axes[0,1].scatter(y_pred, residuos, alpha=0.6, color='#8B5CF6')
axes[0,1].axhline(0, color='red', linestyle='--')
axes[0,1].set_xlabel('Predicciones')
axes[0,1].set_ylabel('Residuos')
axes[0,1].set_title('Homocedasticidad: dispersion CONSTANTE alrededor de 0')
# c) Normalidad: Q-Q plot
stats.probplot(residuos, dist='norm', plot=axes[1,0])
axes[1,0].set_title('Normalidad: puntos deberian caer en la linea')
# d) Multicolinealidad: heatmap de correlaciones
corr = X.corr()
sns.heatmap(corr, annot=True, fmt='.2f', cmap='RdBu_r', center=0, ax=axes[1,1], square=True)
axes[1,1].set_title('Multicolinealidad: |r| > 0.9 es senal de problema')
for ax in axes.flatten():
sns.despine(ax=ax)
if ax.get_xlabel() and 'Correlacion' not in ax.get_title():
ax.grid(alpha=0.3, linestyle='--')
plt.tight_layout()
plt.show() Regularizacion: Ridge y Lasso
Ridge (L2)
M8Agrega una penalidad lambda * sum(w^2) a la funcion de costo. Encoge los coeficientes hacia cero sin llegar a cero. Mejora el modelo en presencia de multicolinealidad. Util cuando TODOS los features son relevantes.
Lasso (L1)
M8Agrega una penalidad lambda * sum(|w|) a la funcion de costo. Puede llevar coeficientes EXACTAMENTE a cero, haciendo feature selection automatica. Util cuando sospechas que solo algunos features son relevantes.
from sklearn.linear_model import Ridge, Lasso
import numpy as np
# Supongamos 50 features, pero solo 5 son realmente predictivos
np.random.seed(42)
N, n_features = 200, 50
X = np.random.randn(N, n_features)
w_real = np.zeros(n_features)
w_real[:5] = [3, -2, 1.5, 0.8, -1] # solo 5 features importan
y = X @ w_real + np.random.normal(0, 0.5, N)
# Ridge: los 5 features se mantienen, los otros se acercan a 0
ridge = Ridge(alpha=1.0).fit(X, y)
print("Ridge - features 'realmente' importantes:", np.argsort(np.abs(ridge.coef_))[-5:][::-1].tolist())
print("Ridge - coeficientes en los 5 reales:", ridge.coef_[:5].round(2).tolist())
print("Ridge - coeficientes en ruido:", ridge.coef_[10:].mean().round(3))
# Lasso: lleva los irrelevantes EXACTAMENTE a 0 (feature selection)
lasso = Lasso(alpha=0.1).fit(X, y)
n_zeros = (lasso.coef_ == 0).sum()
print(f"\nLasso - {n_zeros}/{n_features} coeficientes en cero (feature selection)")
print("Lasso - coeficientes no-cero:", lasso.coef_[lasso.coef_ != 0].round(2).tolist()) - Ridge (L2): cuando todos los features aportan algo. No hace feature selection.
- Lasso (L1): cuando sospechas que solo algunos features importan. Hace feature selection.
- ElasticNet (L1 + L2): combinacion. Util cuando hay features correlacionados (Lasso tiende a elegir uno solo).
La eleccion de alpha (fuerza de regularizacion) se hace con cross-validation.
8.2 Arboles: el algoritmo que mejor se interpreta
El arbol particiona el espacio de features usando preguntas binarias. Cada nodo interno pregunta “¿feature X >= umbral?”. Las hojas son las predicciones. Para clasificar una instancia nueva: arrancas en la raiz y bajas por las ramas segun las respuestas. El camino es la “explicacion” de la prediccion.
| Concepto | Descripcion |
|---|---|
| max_depth | Profundidad maxima del arbol. Default=None (crece hasta que las hojas sean puras) = overfit casi seguro. Tipico: 3-10. |
| min_samples_leaf | Minimo de muestras en cada hoja. Mas alto = mas regularizacion. Tipico: 1-50. Es el hiperparametro mas efectivo. |
| min_samples_split | Minimo de muestras para dividir un nodo. Si < esto, no divide. Tipico: 2-20. |
| max_features | Cantidad de features a considerar en cada split. "sqrt" o "log2" para Random Forest. None para Decision Tree. |
| criterion | Funcion de impureza: "gini" (rapido) o "entropy" / "log_loss" (mas preciso). |
import numpy as np
import matplotlib.pyplot as plt
from sklearn.tree import DecisionTreeRegressor
from sklearn.model_selection import cross_val_score
import seaborn as sns
# Comparar train R² vs CV R² para distintos max_depth
depths = range(1, 21)
train_r2, cv_r2 = [], []
for d in depths:
tree = DecisionTreeRegressor(max_depth=d, random_state=42)
# Train R² (riesgo de overfitting se ve aqui)
tree.fit(X_train, y_train)
train_r2.append(tree.score(X_train, y_train))
# CV R² (honesto)
cv_r2.append(cross_val_score(tree, X_train, y_train, cv=5, scoring='r2').mean())
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(depths, train_r2, 'o-', label='Train R²', color='#06B6D4', linewidth=2)
ax.plot(depths, cv_r2, 'o-', label='CV R² (5-fold)', color='#8B5CF6', linewidth=2)
ax.axvline(x=4, color='red', linestyle='--', alpha=0.5, label='Punto optimo (depth=4)')
ax.fill_between(depths, cv_r2, train_r2, alpha=0.15, color='red', label='Gap (overfitting)')
ax.set_xlabel('max_depth')
ax.set_ylabel('R²')
ax.set_title('Tuning de max_depth: train vs CV')
ax.legend()
ax.grid(alpha=0.3, linestyle='--')
sns.despine(ax=ax)
plt.tight_layout()
plt.show()
# Lectura:
# - Train R² siempre crece (mas profundidad = mas capacidad de memorizar)
# - CV R² crece hasta un punto, luego BAJA (overfitting)
# - El gap train-test es la "varianza" del modelo
# - El optimo es donde CV R² es maximo: depth=4 aca 8.3 Random Forest: el ensamblador que siempre anda bien
Cuando no sabes que modelo usar, usa Random Forest. No es el mas fancy, no es el mas exacto en todos los datasets, pero es:
- Raramente overfittea (gracias al bagging + feature subsampling)
- Robusto a outliers y features irrelevantes
- No requiere escalado
- Da feature importance gratis
- Tiene pocos hiperparametros que tuning
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestRegressor
import seaborn as sns
# Random Forest nos da feature importance por "reduccion de impureza"
rf = RandomForestRegressor(n_estimators=200, max_depth=8, random_state=42, n_jobs=-1)
rf.fit(X_train, y_train)
importancias = pd.Series(rf.feature_importances_, index=X.columns).sort_values()
fig, ax = plt.subplots(figsize=(8, 4))
importancias.plot(kind='barh', color='#8B5CF6', ax=ax)
ax.set_title('Feature Importance - Random Forest')
ax.set_xlabel('Importancia (reduccion de impureza)')
sns.despine(ax=ax)
plt.tight_layout()
plt.show()
# ⚠️ CUIDADO: feature importance de RF es SESGADA hacia features con
# alta cardinalidad (muchas categorias) o muchos splits posibles.
# Para interpretacion seria, usar SHAP (lo vemos en M10). 8.4 Metricas: el idioma para hablar de modelos
Clasificacion
| Concepto | Descripcion |
|---|---|
| Accuracy | Proporcion de predicciones correctas. Enganosa en clases desbalanceadas. Ej: 99% accuracy prediciendo siempre 0 en dataset 99/1. |
| Precision | De los que predije positivos, cuantos eran. Util cuando FP es caro (spam: no marcar mail bueno como spam). |
| Recall | De los positivos reales, cuantos encontre. Util cuando FN es caro (cancer: no dejar pasar un caso). |
| F1 | Media armonica de P y R. Balanceada. Util en desbalance. Variante F2 pesa mas el recall. |
| ROC-AUC | Area bajo la curva ROC. Independiente del threshold. Bueno en clases balanceadas. NO recomendado en desbalance extremo. |
| PR-AUC | Area bajo la curva Precision-Recall. MEJOR que ROC-AUC en desbalance extremo (fraude, enfermedades raras). |
Regresion
| Concepto | Descripcion |
|---|---|
| MAE | Promedio de |error|. En la misma unidad que y. Robusto a outliers. Error "promedio" que veras en produccion. |
| RMSE | Raiz del MSE. Penaliza errores grandes al cuadrado. Si un error grande es inaceptable, usar RMSE. |
| R² | Proporcion de varianza en y explicada por el modelo. Rango (-inf, 1]. 1 = perfecto, 0 = tan bueno como la media, < 0 = peor que la media. |
| MAPE | MAE en porcentaje. Util para presentar a negocio: "el modelo se equivoca en promedio 5%". Indefinido si y=0. |
8.5 Umbral optimo: cuando el negocio importa
sklearn por defecto usa 0.5 para predecir clases. Pero ese numero es arbitrario: no tiene nada que ver con tu problema. La eleccion optima del threshold depende del COSTO de cada tipo de error.
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import precision_recall_curve, f1_score
import seaborn as sns
# Asumimos: modelo ya entrenado, tenemos y_proba para test
# y_proba = modelo.predict_proba(X_test)[:, 1]
# Costos del negocio
COSTO_FP = 50 # contactamos un cliente que NO se va
COSTO_FN = 10000 # NO contactamos un cliente que SI se va
thresholds = np.linspace(0.0, 1.0, 101)
ganancias = []
for t in thresholds:
y_pred_t = (y_proba >= t).astype(int)
fp = ((y_pred_t == 1) & (y_test == 0)).sum()
fn = ((y_pred_t == 0) & (y_test == 1)).sum()
# Ganancia negativa (costos)
ganancia = -(fp * COSTO_FP + fn * COSTO_FN)
ganancias.append(ganancia)
ganancias = np.array(ganancias)
t_optimo = thresholds[np.argmax(ganancias)]
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(thresholds, ganancias, color='#06B6D4', linewidth=2)
ax.axvline(t_optimo, color='red', linestyle='--', label=f'Threshold optimo = {t_optimo:.2f}')
ax.set_xlabel('Threshold')
ax.set_ylabel('Costo total (menor = mejor)')
ax.set_title(f'Costo de negocio vs threshold (FP=${COSTO_FP}, FN=${COSTO_FN:,})')
ax.legend()
ax.grid(alpha=0.3, linestyle='--')
sns.despine(ax=ax)
plt.tight_layout()
plt.show()
print(f"Threshold optimo: {t_optimo:.2f}")
print(f"Ahorro vs threshold=0.5: ${(ganancias[50] - ganancias.max()):,.0f}") - Si FN es MUCHO mas caro que FP (fraude, cancer): threshold BAJO (0.1-0.3). Maximiza recall.
- Si FP es mas caro que FN (spam, marketing): threshold ALTO (0.7-0.9). Maximiza precision.
- Si son similares: threshold que maximiza F1 (~0.5 si el modelo esta bien calibrado).
- NUNCA elegir threshold mirando el test. Usar CV o un set de validacion separado.
8.6 Errores comunes
- Cargá un dataset de regresion (House Prices, California Housing, Bike Sharing). Documenta el problema en 3 lineas.
- Entrena regresion lineal + Ridge + Lasso + Random Forest con Pipeline (todo el preprocessing adentro). Evalua con CV, reportando MAE, RMSE y R².
- Para el modelo de regresion lineal, hace el diagnostico de 4 plots (linealidad, homocedasticidad, normalidad, multicolinealidad). Comenta que supuestos se cumplen y cuales no.
- Tunea max_depth y n_estimators de Random Forest. Grafica train vs CV para detectar el punto optimo.
- Bonus: convertí el problema a clasificacion (precio > mediana = 1, sino 0). Compara accuracy vs F1. Si el dataset esta desbalanceado, reporta PR-AUC.
- Bonus 2: elegí un threshold optimo segun un costo de negocio ficticio (ej: FP = $100, FN = $5000). Compara con threshold=0.5.