MACHINE LEARNING
Fundamentos, Teoría y Práctica
Desde los conceptos esenciales hasta las técnicas modernas
Incluye ejemplos prácticos con Scikit-Learn, PyTorch y Keras
Capítulo 1: ¿Qué es el Machine Learning?
El Machine Learning (ML), o Aprendizaje Automático, es una rama de la Inteligencia Artificial que
permite a los sistemas informáticos aprender y mejorar a partir de la experiencia sin ser explícitamente
programados para cada tarea. En lugar de seguir reglas escritas a mano por un programador, un
algoritmo de ML extrae patrones estadísticos de los datos y los usa para tomar decisiones o
predicciones.
1.1 La diferencia con la programación clásica
Programación Clásica
Machine Learning
El programador escribe reglas explícitas
El algoritmo aprende las reglas de los datos
Entrada + Reglas → Salida
Entrada + Salida esperada → Reglas (modelo)
Cambiar el comportamiento requiere reescribir
código
Se re-entrena con nuevos datos
Ideal para lógica determinista
Ideal para problemas con patrones complejos o
difusos
1.2 Tipos de aprendizaje
Aprendizaje Supervisado
El modelo aprende a partir de un conjunto de datos etiquetados: pares (entrada x, salida y). El objetivo
es aprender una función f tal que f(x) ≈ y para nuevas entradas.
•
•
Regresión: la salida y es un valor continuo. Ejemplo: predecir el precio de una casa.
Clasificación: la salida y es una categoría discreta. Ejemplo: detectar spam.
Aprendizaje No Supervisado
El modelo aprende estructura de datos sin etiquetas. No hay una 'respuesta correcta' definida; el
algoritmo descubre patrones por sí mismo.
•
•
•
Clustering: agrupa datos similares (K-Means, DBSCAN).
Reducción de dimensionalidad: comprime representaciones (PCA, t-SNE, UMAP).
Modelos generativos: aprenden la distribución de los datos (VAE, GAN).
Aprendizaje por Refuerzo
Un agente toma acciones en un entorno y recibe recompensas o penalizaciones. Su objetivo es
aprender una política que maximice la recompensa acumulada a lo largo del tiempo. Es la base de
sistemas como AlphaGo, ChatGPT (RLHF) y robots autónomos.
Aprendizaje Semi-supervisado y Auto-supervisado
El aprendizaje semi-supervisado combina una pequeña cantidad de datos etiquetados con grandes
volúmenes de datos sin etiquetar. El aprendizaje auto-supervisado genera automáticamente señales
de supervisión a partir de los propios datos (por ejemplo, predecir la siguiente palabra en un texto),
siendo la base de los grandes modelos de lenguaje.
1.3 El flujo de trabajo en ML
Pipeline típico de un proyecto de Machine Learning
1. Definición del problema y métricas de éxito
2. Recopilación y limpieza de datos
3. Análisis exploratorio (EDA)
4. Ingeniería de características (feature engineering)
5. Selección y entrenamiento del modelo
6. Evaluación y validación cruzada
7. Ajuste de hiperparámetros
8. Despliegue y monitorización en producción
Capítulo 2: Fundamentos Matemáticos
El Machine Learning descansa sobre tres pilares matemáticos: álgebra lineal, cálculo diferencial y
probabilidad/estadística. Comprender estos fundamentos es esencial para entender por qué y cómo
funcionan los algoritmos.
2.1 Álgebra Lineal
Vectores y Matrices
Un vector x ∈ ℝⁿ es una lista de n números reales. Una matriz A ∈ ℝᵐˣⁿ es un arreglo rectangular de m
filas y n columnas. En ML, los datos se representan como matrices donde cada fila es una muestra y
cada columna una característica (feature).
Producto punto (dot product): Mide la similitud o proyección de dos vectores:
x · y = Σᵢ xᵢ yᵢ = x₁y₁ + x₂y₂ + ... + xₙ yₙ
Si los vectores son ortogonales (perpendiculares), su producto punto es 0. Si apuntan en la misma
dirección, su producto punto es máximo.
Multiplicación matricial: Si A ∈ ℝᵐˣᵏ y B ∈ ℝᵏˣⁿ, entonces C = AB ∈ ℝᵐˣⁿ donde:
Cᵢⱼ = Σₖ Aᵢₖ · Bₖ ⱼ
La multiplicación matricial es el corazón de las redes neuronales: cada capa aplica una transformación
lineal W·x + b donde W es la matriz de pesos.
Normas y Distancias
Norma L1 (Manhattan): ||x||₁ = Σᵢ |xᵢ| — usada en regularización Lasso
||x||₁ = |x₁| + |x₂| + ... + |xₙ |
Norma L2 (Euclidiana): ||x||₂ = √(Σᵢ xᵢ²) — usada en regularización Ridge
||x||₂ = √(x₁² + x₂² + ... + xₙ ²)
Valores propios (Eigenvalues)
Un vector v es un eigenvector de la matriz A si A·v = λ·v, donde λ es el eigenvalor correspondiente.
Los eigenvalores indican cuánto 'estira' la transformación en cada dirección. Son fundamentales en
PCA (Análisis de Componentes Principales).
2.2 Cálculo Diferencial y Gradientes
Derivadas parciales
Si una función f depende de múltiples variables (x₁ , x₂ , ..., xₙ), la derivada parcial ∂f/∂xᵢ mide cómo
cambia f cuando solo varía xᵢ, manteniendo el resto constante.
Gradiente
El gradiente ∇ f es el vector de todas las derivadas parciales. Apunta en la dirección de máximo
crecimiento de f:
∇f(x) = [∂f/∂x₁, ∂f/∂x₂, ..., ∂f/∂xₙ ]ᵀ
En ML usamos el gradiente para encontrar el mínimo de la función de pérdida: nos movemos en la
dirección opuesta al gradiente (descenso de gradiente).
Regla de la cadena
Si y = f(g(x)), entonces dy/dx = (df/dg) · (dg/dx). Esta regla es el fundamento del algoritmo de
backpropagation en redes neuronales, donde calculamos el gradiente de la pérdida respecto a cada
parámetro aplicando la regla de la cadena capa a capa.
2.3 Probabilidad y Estadística
Distribuciones de probabilidad
Una distribución de probabilidad describe cómo se distribuyen los valores posibles de una variable
aleatoria.
•
•
•
Distribución Normal (Gaussiana): N(μ, σ²) — la más común en ML, con forma de campana.
Distribución de Bernoulli: modela eventos binarios (0 o 1), base de la clasificación binaria.
Distribución Categórica: generaliza Bernoulli a múltiples clases, base de softmax.
Máxima Verosimilitud (MLE)
El principio de Máxima Verosimilitud (Maximum Likelihood Estimation) dice que los mejores
parámetros θ de un modelo son aquellos que maximizan la probabilidad de haber observado los datos:
θ* = argmax_θ Π P(xᵢ | θ) = argmax_θ Σ log P(xᵢ | θ)
Minimizar la entropía cruzada (cross-entropy loss) en clasificación es equivalente a maximizar la
verosimilitud bajo un modelo de distribución categórica.
Teorema de Bayes
El Teorema de Bayes es la base del razonamiento probabilístico en ML:
P(θ | datos) = P(datos | θ) · P(θ) / P(datos)
donde P(θ) es la distribución a priori (conocimiento previo sobre los parámetros), P(datos | θ) es la
verosimilitud (qué tan probable es observar los datos dado θ), y P(θ | datos) es la distribución a
posteriori (lo que sabemos de θ después de ver los datos).
Capítulo 3: Regresión
Los modelos de regresión predicen un valor numérico continuo a partir de variables de entrada. Son
los modelos más básicos y didácticos del ML, y sus conceptos subyacentes se extienden a todo el
campo.
3.1 Regresión Lineal
La regresión lineal simple asume que la variable de salida y es una combinación lineal de la entrada x
más un término de error ε:
y = β₀ + β₁x + ε
En el caso multivariado, con p características x₁ , x₂ , ..., xₙ:
y = β₀ + β₁x₁ + β₂x₂ + ... + βₚ xₚ + ε = xᵀβ + ε
Interpretación: β₁ representa el cambio esperado en y por cada unidad de cambio en x₁ ,
manteniendo el resto constante. β₀ es la intersección con el eje y.
Función de Pérdida: Error Cuadrático Medio (MSE)
Para entrenar el modelo, buscamos los parámetros β que minimicen el Error Cuadrático Medio (MSE)
sobre los n ejemplos de entrenamiento:
MSE = (1/n) Σᵢ (yᵢ - ŷᵢ)² = (1/n) Σᵢ (yᵢ - xᵢᵀβ)²
Se elige el cuadrado del error (y no el valor absoluto) porque es diferenciable en todo punto, lo que
facilita la optimización. Penaliza errores grandes mucho más que errores pequeños, lo que puede ser
un inconveniente si hay valores atípicos.
Solución Analítica: Ecuación Normal
Cuando el número de características no es demasiado grande, existe una solución de forma cerrada
que minimiza el MSE exactamente:
β* = (XᵀX)⁻¹ Xᵀy
Derivación: La función de pérdida L(β) = ||y - Xβ||² es convexa. Igualando su gradiente a cero: ∇ L = 2Xᵀ(y - Xβ) = 0, lo que da Xᵀy = XᵀXβ, y despejando β obtenemos la ecuación normal. El
inconveniente es que invertir XᵀX tiene complejidad O(p³), lo que es inviable para p grande (millones
de features).
Implementación práctica
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error, r2_score
import matplotlib.pyplot as plt
# Generamos datos sintéticos: y = 3 + 2x + ruido
np.random.seed(42)
X = 2 * np.random.rand(100, 1)
y = 3 + 2 * X + np.random.randn(100, 1) * 0.5
# División en train/test (80% / 20%)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2,
random_state=42)
# Entrenamiento
model = LinearRegression()
model.fit(X_train, y_train)
# Predicciones y métricas
y_pred = model.predict(X_test)
print(f'Intercepto (β₀ ): {model.intercept_[0]:.3f}')
# debería ≈ 3
print(f'Coeficiente (β₁ ): {model.coef_[0][0]:.3f}')
# debería ≈ 2
print(f'RMSE: {np.sqrt(mean_squared_error(y_test, y_pred)):.3f}')
print(f'R²:
{r2_score(y_test, y_pred):.3f}')
# 1.0 = perfecto
3.2 Regularización
Cuando un modelo es demasiado complejo (muchos parámetros), tiende a memorizar el conjunto de
entrenamiento en lugar de generalizar. Esto se llama sobreajuste (overfitting). La regularización añade
un término de penalización a la función de pérdida para desincentivar coeficientes grandes.
Ridge Regression (L2)
Añade la norma L2 al cuadrado de β a la función de pérdida:
L_Ridge(β) = Σᵢ (yᵢ - xᵢᵀβ)² + λ ||β||₂²
El hiperparámetro λ ≥ 0 controla la fuerza de la regularización. Con λ = 0 recuperamos la regresión
lineal ordinaria. Ridge encoge todos los coeficientes hacia cero pero nunca los hace exactamente cero
(no hace selección de variables). Su solución analítica es: β* = (XᵀX + λI)⁻ ¹ Xᵀy.
Lasso Regression (L1)
Añade la norma L1 de β a la función de pérdida:
L_Lasso(β) = Σᵢ (yᵢ - xᵢᵀβ)² + λ ||β||₁
La diferencia clave con Ridge es que la norma L1 puede llevar coeficientes exactamente a cero,
actuando como selector automático de variables. Esto hace a Lasso ideal cuando se sospecha que
solo unas pocas características son realmente relevantes (sparsidad).
Comparativa Ridge vs Lasso
Ridge (L2): penaliza β², encoge todo uniformemente, no lleva a cero. Mejor cuando muchos
features son relevantes.
Lasso (L1): penaliza |β|, puede hacer coeficientes exactamente 0. Mejor cuando hay pocos
features relevantes.
ElasticNet: combinación lineal de L1 y L2. Captura lo mejor de ambos mundos.
Regla práctica: empieza con Ridge, usa Lasso si quieres interpretabilidad/sparsidad.
Capítulo 4: Clasificación
En problemas de clasificación, la variable objetivo y toma valores discretos (clases o categorías). El
modelo aprende a asignar cada entrada a una de esas clases.
4.1 Regresión Logística
A pesar de su nombre, la regresión logística es un modelo de clasificación binaria. Modela la
probabilidad de que una muestra pertenezca a la clase positiva (y = 1).
La función sigmoide
Para que la salida del modelo sea una probabilidad (entre 0 y 1), se aplica la función sigmoide σ a la
combinación lineal:
P(y=1 | x) = σ(xᵀβ) = 1 / (1 + e^(-xᵀβ))
La función sigmoide 'aplasta' cualquier valor real en el rango (0, 1). Cuando xᵀβ = 0, la probabilidad es
exactamente 0.5. Valores grandes positivos se acercan a 1, y valores negativos se acercan a 0.
Función de Pérdida: Entropía Cruzada Binaria
No podemos usar MSE para clasificación porque la función no sería convexa. En cambio, usamos la
entropía cruzada (cross-entropy), que tiene la propiedad de ser convexa y derivarse del principio de
máxima verosimilitud:
L(β) = -(1/n) Σᵢ [yᵢ log(ŷᵢ) + (1-yᵢ) log(1-ŷᵢ)]
Cuando yᵢ = 1 y ŷᵢ → 1, el término -log(ŷᵢ) → 0 (sin pérdida). Cuando yᵢ = 1 y ŷᵢ → 0, el término -log(ŷᵢ)
→ ∞ (pérdida infinita). Esto penaliza fuertemente las predicciones muy incorrectas.
Implementación y métricas de evaluación
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (classification_report, confusion_matrix,
roc_auc_score, RocCurveDisplay)
from sklearn.datasets import make_classification
# Dataset sintético: 2 clases, 20 características
X, y = make_classification(n_samples=1000, n_features=20, n_informative=10,
random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
clf = LogisticRegression(C=1.0, max_iter=1000)
clf.fit(X_train, y_train)
y_pred = clf.predict(X_test)
y_prob = clf.predict_proba(X_test)[:, 1]
# probabilidad de clase positiva
print(classification_report(y_test, y_pred))
# classification_report muestra: precision, recall, F1-score, soporte
print(f'AUC-ROC: {roc_auc_score(y_test, y_prob):.3f}')
Métricas de clasificación explicadas
Métrica
Definición y Cuándo Usarla
Accuracy
Fracción de predicciones correctas. Engañosa con clases desbalanceadas.
Precision
TP / (TP+FP). 'De los que predije positivo, ¿cuántos lo eran?' Alta prioridad
cuando los falsos positivos son costosos (ej: spam).
Recall
(Sensibilidad)
TP / (TP+FN). 'De todos los positivos reales, ¿cuántos encontré?' Alta prioridad
cuando los falsos negativos son costosos (ej: diagnóstico médico).
F1-Score
Media armónica de Precision y Recall: 2·P·R/(P+R). Equilibrio entre ambas.
AUC-ROC
Área bajo la curva ROC. Mide la capacidad discriminativa en todos los
umbrales. 0.5 = aleatorio, 1.0 = perfecto.
4.2 Support Vector Machines (SVM)
Las Máquinas de Vectores Soporte buscan el hiperplano que maximiza el margen (distancia) entre las
dos clases. Es un enfoque elegante y con sólida base teórica.
Hiperplano de máximo margen
Un hiperplano en ℝⁿ se define como {x : wᵀx + b = 0}. Las SVM buscan w y b que maximicen el
margen 2/||w|| sujeto a que todos los ejemplos estén correctamente clasificados con margen ≥ 1:
min (1/2)||w||² s.a. yᵢ(wᵀxᵢ + b) ≥ 1 ∀i
Los puntos que quedan exactamente sobre los límites del margen se llaman vectores soporte y son los
únicos que determinan el hiperplano (el modelo es robusto a los demás puntos).
El truco del kernel
Cuando los datos no son linealmente separables, SVM aplica una transformación φ(x) que lleva los
datos a un espacio de mayor dimensión donde sí lo son. El 'truco del kernel' evita calcular φ(x)
explícitamente, usando solo el producto K(x,z) = φ(x)ᵀφ(z):
•
•
•
Kernel lineal: K(x,z) = xᵀz
Kernel polinomial: K(x,z) = (γ·xᵀz + r)ᵈ
Kernel RBF (Gaussiano): K(x,z) = exp(-γ||x-z||²) — el más popular
4.3 Árboles de Decisión
Un árbol de decisión divide recursivamente el espacio de características mediante preguntas de tipo
'umbral' (¿x₁ > 3.5?) construyendo un árbol de nodos. Es altamente interpretable.
Criterios de división
Índice de Gini: Mide la impureza de un nodo. Un nodo con una sola clase tiene Gini = 0 (puro):
Gini(t) = 1 - Σₖ p(k|t)²
Entropía de Información: Alternativa al Gini basada en teoría de información:
Entropy(t) = -Σₖ p(k|t) · log₂ p(k|t)
En cada nodo, se prueba cada feature y cada umbral posible, y se elige la división que maximiza la
reducción de impureza (Information Gain = Gini o Entropía del nodo padre - promedio ponderado de
los hijos).
Capítulo 5: Métodos Ensemble
Los métodos ensemble combinan múltiples modelos para obtener mejores predicciones que cualquier
modelo individual. Se basan en el principio de 'sabiduría de la multitud': un conjunto de modelos
débiles, suficientemente diversos, puede superar a un único modelo fuerte.
5.1 Bagging y Random Forest
Bootstrap Aggregating (Bagging)
Bagging entrena múltiples versiones del mismo algoritmo en subconjuntos aleatorios del dataset
(muestras con reemplazamiento, o 'bootstrap'). Las predicciones finales se combinan por votación
(clasificación) o promedio (regresión). La diversidad entre modelos reduce la varianza.
Random Forest
Random Forest extiende Bagging aplicado a árboles de decisión con un truco adicional: en cada nodo,
solo se considera un subconjunto aleatorio de m características (tipicamente m = √p) para la división.
Esto descorrelaciona los árboles y reduce la varianza aún más.
¿Por qué Random Forest funciona tan bien?
Sesgo bajo: los árboles profundos tienen bajo sesgo (se ajustan bien al train).
Varianza reducida: el promedio de muchos árboles decorrelacionados tiene varianza ≈ σ²/B
(donde B = número de árboles).
Robustez: los datos bootstrap y la submuestra de features hacen al modelo resistente a outliers
y ruido.
Out-of-Bag error: el ~37% de datos que no entran en cada bootstrap sirven como validación
gratis.
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
rf = RandomForestClassifier(
n_estimators=200,
# número de árboles
max_depth=None,
# árboles sin límite de profundidad (se regulariza via
min_samples)
max_features='sqrt',
# sqrt(p) features por nodo
min_samples_leaf=2,
# al menos 2 muestras en cada hoja (regularización)
oob_score=True,
# calcula error out-of-bag
n_jobs=-1,
# usa todos los núcleos disponibles
random_state=42
)
rf.fit(X_train, y_train)
print(f'OOB Score: {rf.oob_score_:.3f}')
usar test set
# estimación del error de test sin
# Importancia de características (basada en reducción de Gini)
importances = rf.feature_importances_
indices = np.argsort(importances)[::-1]
print('Top 5 features:', [f'feature_{indices[i]}' for i in range(5)])
5.2 Gradient Boosting
Gradient Boosting construye modelos de forma secuencial: cada nuevo árbol aprende a corregir los
errores del ensemble anterior. En lugar de promediar (Bagging), los modelos se suman con un peso de
aprendizaje:
F₀(x) = constante; Fₘ (x) = Fₘ ₋₁(x) + η · hₘ (x)
donde hₙ es el m-ésimo árbol entrenado sobre los residuos (o pseudoresiduos) de Fₙ₋ ₁ , y η es la
tasa de aprendizaje (learning rate). El nombre 'Gradient' viene de que los pseudoresiduos son el
gradiente negativo de la función de pérdida respecto a las predicciones.
XGBoost, LightGBM y CatBoost
Estas tres librerías son implementaciones altamente optimizadas de Gradient Boosting que dominan
las competiciones de ML en datos tabulares:
Librería
Características Clave
XGBoost
Regularización L1/L2 incorporada, manejo nativo de NaN, paralelización por
columnas.
LightGBM
Crecimiento leaf-wise (más rápido), histogramas para features continuos,
GOSS y EFB para datasets grandes.
CatBoost
Manejo nativo de variables categóricas, ordered boosting para evitar target
leakage, interfaz simple.
# XGBoost - instalación: pip install xgboost
import xgboost as xgb
model_xgb = xgb.XGBClassifier(
n_estimators=500,
learning_rate=0.05,
# eta: pasos pequeños, más árboles => mejor
generalización
max_depth=6,
# profundidad máxima de cada árbol
subsample=0.8,
# fracción de filas por árbol (estocástico)
colsample_bytree=0.8, # fracción de columnas por árbol
reg_alpha=0.1,
# L1 en pesos de hojas
reg_lambda=1.0,
# L2 en pesos de hojas
eval_metric='auc',
early_stopping_rounds=30, # detiene si no mejora en 30 rondas
random_state=42
)
model_xgb.fit(X_train, y_train,
eval_set=[(X_test, y_test)],
verbose=50)
Capítulo 6: Redes Neuronales Artificiales
Las redes neuronales artificiales son modelos computacionales inspirados vagamente en el cerebro
biológico. Su verdadero poder es que son aproximadores universales de funciones: con suficientes
neuronas y capas, pueden representar cualquier función continua.
6.1 La Neurona Artificial (Perceptrón)
Una neurona artificial recibe n entradas x₁ , ..., xₙ, cada una multiplicada por un peso w₁ , ..., wₙ.
Suma los productos y añade un sesgo b. Luego aplica una función de activación f no lineal para
producir la salida:
salida = f(w₁x₁ + w₂x₂ + ... + wₙ xₙ + b) = f(wᵀx + b)
Sin la función de activación no lineal, apilar capas sería equivalente a una sola transformación lineal
(composición de lineales es lineal). La no linealidad es lo que da a las redes neuronales su poder
expresivo.
6.2 Funciones de Activación
Función
Definición, Rango y Uso
Sigmoide
σ(z) = 1/(1+e⁻ ᶻ). Rango (0,1). Antigua, sufre vanishing gradient en capas
profundas. Usada en salida de clasificación binaria.
Tanh
tanh(z) = (eᶻ-e⁻ ᶻ)/(eᶻ+e⁻ ᶻ). Rango (-1,1). Similar a sigmoide pero centrada en
0. Mejora la convergencia vs sigmoide.
ReLU
max(0, z). Rango [0, ∞). Rápida, evita vanishing gradient. Puede 'morir' (dying
ReLU). El estándar en capas ocultas.
Leaky ReLU / ELU
Leaky: max(0.01z, z). ELU: z si z>0, α(eᶻ-1) si z≤0. Soluciona dying ReLU,
salida negativa posible.
GELU
z · Φ(z) donde Φ es la CDF normal. Usada en Transformers (BERT, GPT).
Suaviza ReLU.
Softmax
σ(z)ₙ = eᶻₙ / Σⱼ eᶻʲ. Convierte logits en distribución de probabilidad. Usada en
capa de salida de clasificación multiclase.
6.3 Backpropagation y Descenso de Gradiente
Forward Pass
En el paso hacia adelante, los datos fluyen de la capa de entrada a la de salida. En cada capa l, se
calcula: z⁽ ˡ⁾ = W⁽ ˡ⁾ a⁽ ˡ⁻ ¹⁾ + b⁽ ˡ⁾ y a⁽ ˡ⁾ = f(z⁽ ˡ⁾ ). Al final, se calcula la pérdida L comparando la
predicción con el valor real.
Backward Pass (Backpropagation)
Backpropagation aplica la regla de la cadena para calcular el gradiente de la pérdida respecto a todos
los parámetros. Se propaga el error hacia atrás desde la capa de salida:
∂L/∂W⁽ˡ⁾ = δ⁽ˡ⁾ (a⁽ˡ⁻¹⁾)ᵀ donde δ⁽ˡ⁾ = (W⁽ˡ⁺¹⁾)ᵀ δ⁽ˡ⁺¹⁾ ⊙ f'(z⁽ˡ⁾)
donde ⊙ denota el producto elemento a elemento (Hadamard). δ⁽ ˡ⁾ es el 'error' de la capa l, que
cuantifica cuánto contribuyó cada neurona a la pérdida final.
Algoritmos de Optimización
SGD con Momentum: Añade un término de inercia para acelerar convergencia y escapar mínimos
locales:
v ← γv + η∇L(θ), θ ← θ - v
Adam (Adaptive Moment Estimation): El optimizador más popular. Adapta la tasa de aprendizaje por
parámetro usando medias móviles del gradiente y su cuadrado:
m ← β₁m + (1-β₁)g, v ← β₂v + (1-β₂)g², θ ← θ - η · m̂/√(v̂ +ε)
donde g = ₙL(θ), m̂ y v̂ son las medias corregidas por sesgo (bias correction), y los hiperparámetros
estándar son β₁ =0.9, β₂ =0.999, ε=1e-8. Adam converge más rápido que SGD en la mayoría de
problemas.
6.4 Redes Neuronales con Keras
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
# Red totalmente conectada (MLP) para clasificación multiclase
model = keras.Sequential([
layers.Input(shape=(20,)),
layers.Dense(128, activation='relu'),
# capa oculta 1
layers.BatchNormalization(),
# normaliza activaciones
layers.Dropout(0.3),
# regularización: apaga 30% de
neuronas
layers.Dense(64, activation='relu'),
# capa oculta 2
layers.Dropout(0.3),
layers.Dense(10, activation='softmax')
# 10 clases de salida
])
model.compile(
optimizer=keras.optimizers.Adam(learning_rate=1e-3),
loss='sparse_categorical_crossentropy',
metrics=['accuracy']
)
# Callbacks: early stopping y reducción de LR
callbacks = [
keras.callbacks.EarlyStopping(patience=10, restore_best_weights=True),
keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=5),
]
history = model.fit(
X_train, y_train,
epochs=100,
batch_size=32,
validation_split=0.2,
callbacks=callbacks
)
Capítulo 7: Deep Learning
El Deep Learning usa redes neuronales con múltiples capas (profundas) que aprenden
representaciones jerárquicas de los datos. Cada capa extrae características de nivel creciente de
abstracción: píxeles → bordes → formas → objetos.
7.1 Redes Neuronales Convolucionales (CNN)
Las CNN están diseñadas para datos con estructura espacial (imágenes, series temporales, audio).
Explotan la invariancia translacional: un detector de bordes útil en la esquina superior izquierda
también es útil en el centro.
La operación de convolución
Una capa convolucional aplica un filtro (kernel) K de tamaño k×k sobre una imagen I deslizándolo en
todas las posiciones. Cada posición produce un escalar (la suma de productos elemento a elemento):
(I * K)[i,j] = Σₘ Σₙ I[i+m, j+n] · K[m, n]
El resultado es un mapa de activación (feature map). Una capa convolucional tiene múltiples filtros que
aprenden diferentes detectores de características. Los parámetros del filtro se aprenden durante el
entrenamiento, no son fijos.
Pooling, Padding y Stride
•
•
•
•
Max Pooling: reduce el tamaño espacial tomando el máximo en cada ventana. Aporta
invariancia a pequeñas traslaciones.
Average Pooling: toma el promedio. Más suave que max pooling.
Padding: añade ceros en los bordes para controlar el tamaño del output.
Stride: distancia entre posiciones consecutivas del filtro. Stride 2 reduce el tamaño a la mitad.
Arquitecturas CNN clásicas
Arquitectura
Contribución
LeNet-5 (1998)
Primera CNN exitosa. Reconocimiento de dígitos escritos a mano.
AlexNet (2012)
Popularizó Deep Learning. Ganó ImageNet con ~15% de error vs ~26%
del runner-up. Introdujo ReLU y Dropout.
VGGNet (2014)
Demostró que la profundidad importa. Bloques de 3×3 convoluciones. 1619 capas.
ResNet (2015)
Introduce conexiones residuales (skip connections). Entrena redes de
100-1000 capas. Soluciona vanishing gradient.
EfficientNet (2019)
Escala anchura, profundidad y resolución simultáneamente. Estado del
arte en eficiencia.
CNN con PyTorch
import torch
import torch.nn as nn
import torch.nn.functional as F
class CNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
# Bloque 1: 1 canal entrada, 32 filtros 3x3
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
self.bn1
= nn.BatchNorm2d(32)
self.pool = nn.MaxPool2d(2, 2)
# reduce 28x28 -> 14x14
# Bloque 2: 32 -> 64 filtros
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.bn2
= nn.BatchNorm2d(64)
# Bloque clasificador
self.fc1
= nn.Linear(64 * 7 * 7, 512)
self.drop = nn.Dropout(0.5)
self.fc2
= nn.Linear(512, num_classes)
def forward(self, x):
x = self.pool(F.relu(self.bn1(self.conv1(x))))
x = self.pool(F.relu(self.bn2(self.conv2(x))))
x = x.view(x.size(0), -1)
x = F.relu(self.fc1(x))
x = self.drop(x)
return self.fc2(x)
# 1x28x28 -> 32x14x14
# 32x14x14 -> 64x7x7
# aplanar: 64*7*7
# logits (sin softmax)
model = CNN(num_classes=10).to('cuda' if torch.cuda.is_available() else 'cpu')
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss() # incluye softmax internamente
7.2 Redes Recurrentes (RNN / LSTM / GRU)
Las redes recurrentes están diseñadas para secuencias: texto, series temporales, audio. Mantienen un
estado oculto h que se actualiza en cada paso temporal t:
hₜ = f(W_hh · hₜ ₋₁ + W_xh · xₜ + b)
El problema de las RNN simples es el vanishing gradient: el gradiente se hace exponencialmente
pequeño al propagarse hacia atrás en el tiempo, haciendo difícil aprender dependencias largas.
LSTM (Long Short-Term Memory)
Las LSTM resuelven el vanishing gradient con un estado de celda c y tres compuertas (gates)
aprendibles que controlan el flujo de información:
• Forget gate: fₙ = σ(Wf·[hₙ₋ ₁ , xₙ] + bf) — ¿qué olvidar del estado de celda anterior?
• Input gate: iₙ = σ(Wi·[hₙ₋ ₁ , xₙ] + bi) — ¿qué nueva información escribir?
• Output gate: oₙ = σ(Wo·[hₙ₋ ₁ , xₙ] + bo) — ¿qué leer del estado de celda?
Actualización: cₙ = fₙ ⊙ cₙ₋ ₁ + iₙ ⊙tanh(Wc·[hₙ₋ ₁ , xₙ]) ; hₙ = oₙ ⊙tanh(cₙ)
La clave es el estado de celda c: la información puede fluir por él con poca modificación (cuando fₙ ≈
1), permitiendo recordar eventos de hace cientos de pasos temporales.
Capítulo 8: Transformers y NLP Moderno
Los Transformers, introducidos en 2017 en el artículo 'Attention Is All You Need', revolucionaron el
procesamiento del lenguaje natural y luego se extendieron a visión, audio y bioinformática. Son la base
de GPT, BERT, T5, LLaMA y la mayoría de modelos de IA actuales.
8.1 El mecanismo de Atención
La atención permite al modelo ponderar la relevancia de cada token de la secuencia cuando procesa
un token dado. No hay recurrencia: todos los tokens se procesan en paralelo.
Scaled Dot-Product Attention
Cada token es proyectado en tres vectores: Query (Q), Key (K) y Value (V). La atención se computa
como:
Attention(Q, K, V) = softmax(QKᵀ / √d_k) · V
Interpretación: QKᵀ calcula la similitud entre queries y keys (puntuación de atención). Se divide por
√d_k para evitar que el producto punto crezca demasiado en dimensiones altas (lo que saturaría
softmax). softmax convierte las puntuaciones en pesos que suman 1. Finalmente, se toma una suma
ponderada de los Values.
Multi-Head Attention
En lugar de una sola operación de atención, se aplican h operaciones en paralelo con proyecciones
aprendidas diferentes, y se concatenan sus salidas:
MultiHead(Q,K,V) = Concat(head₁,...,headₕ ) · W^O
Cada cabeza puede especializarse en diferentes tipos de relaciones: sintácticas, semánticas, de
correferencia, etc.
8.2 Arquitectura del Transformer
Encoder
El encoder transforma una secuencia de tokens en representaciones contextualizadas. Cada bloque
encoder tiene: Multi-Head Self-Attention (cada token atiende a todos) → Add & Norm (conexión
residual + Layer Norm) → Feed-Forward Network (dos capas densas con ReLU) → Add & Norm.
Positional Encoding
Como el Transformer no tiene recurrencia ni convolución, no sabe el orden de los tokens. Se añade un
encoding posicional sinusoidal sumado a los embeddings:
PE(pos, 2i) = sin(pos / 10000^(2i/d)) ; PE(pos, 2i+1) = cos(pos / 10000^(2i/d))
Esto permite al modelo inferir la posición relativa y absoluta de cada token.
8.3 Modelos Pre-entrenados y Fine-tuning
El paradigma moderno es: pre-entrenar un modelo enorme en texto o imágenes sin etiquetas (selfsupervised learning), luego ajustarlo (fine-tuning) para la tarea específica con datos etiquetados
pequeños.
Modelo
Arquitectura y Uso
BERT (2018)
Encoder bidireccional. Pre-entrenado con Masked Language Modeling. Ideal
para tareas de comprensión (clasificación, NER, QA).
GPT-2/3/4 (20182023)
Decoder autoregressivo. Genera texto token a token. Base de los chatbots
modernos.
T5 (2019)
Encoder-Decoder. Reformula todo como seq2seq. Muy versátil.
LLaMA 2/3 (202324)
Decoder open-source de Meta. Base para fine-tuning eficiente con
LoRA/QLoRA.
Fine-tuning con Hugging Face Transformers
# pip install transformers datasets torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from transformers import TrainingArguments, Trainer
from datasets import load_dataset
import numpy as np
# Cargamos un modelo pre-entrenado y su tokenizador
model_name = 'distilbert-base-uncased'
tokenizer = AutoTokenizer.from_pretrained(model_name)
model
= AutoModelForSequenceClassification.from_pretrained(model_name,
num_labels=2)
# Dataset de ejemplo: análisis de sentimiento en reseñas de cine
dataset = load_dataset('imdb')
def tokenize(batch):
return tokenizer(batch['text'], padding=True, truncation=True, max_length=512)
tokenized = dataset.map(tokenize, batched=True)
args = TrainingArguments(
output_dir='./results',
num_train_epochs=3,
per_device_train_batch_size=16,
evaluation_strategy='epoch',
learning_rate=2e-5,
# fine-tuning con LR pequeño para no olvidar el preentrenamiento
weight_decay=0.01,
)
trainer = Trainer(model=model, args=args,
train_dataset=tokenized['train'].select(range(2000)),
eval_dataset=tokenized['test'].select(range(500)))
trainer.train()
Capítulo 9: Evaluación, Validación y Selección de Modelos
Evaluar un modelo correctamente es tan importante como entrenarlo. El objetivo del ML no es
minimizar el error en los datos de entrenamiento sino obtener buena generalización a nuevos datos.
9.1 El Dilema Sesgo-Varianza
El error esperado de un modelo puede descomponerse en tres términos:
Error = Sesgo² + Varianza + Ruido irreducible
•
•
•
Sesgo (Bias): error por suposiciones incorrectas del modelo (underfitting). Modelos demasiado
simples (ej: regresión lineal para datos no lineales) tienen sesgo alto.
Varianza: sensibilidad del modelo a fluctuaciones del conjunto de entrenamiento (overfitting).
Modelos complejos (ej: árboles muy profundos) tienen varianza alta.
Ruido irreducible: variabilidad inherente en los datos que ningún modelo puede capturar.
Diagnóstico práctico
Error train alto + Error test alto → Underfitting (sesgo alto). Solución: modelo más complejo.
Error train bajo + Error test alto → Overfitting (varianza alta). Solución: regularización, más
datos, modelo más simple.
Error train bajo + Error test bajo → Buen ajuste. Buscar optimizaciones adicionales.
Curvas de aprendizaje (learning curves) permiten diagnosticar visualmente el problema.
9.2 Validación Cruzada (Cross-Validation)
La validación cruzada k-fold divide el dataset en k partes (folds). Se entrena k veces, usando k-1 folds
para entrenamiento y 1 fold como validación, rotando el fold de validación. La métrica final es el
promedio de las k evaluaciones.
from sklearn.model_selection import cross_val_score, StratifiedKFold, GridSearchCV
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
# Pipeline: escalar + modelo (evita data leakage)
pipe = Pipeline([
('scaler', StandardScaler()),
('clf',
RandomForestClassifier(n_estimators=100, random_state=42))
])
# Stratified KFold: preserva la proporción de clases en cada fold
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(pipe, X, y, cv=cv, scoring='roc_auc', n_jobs=-1)
print(f'AUC: {scores.mean():.3f} ± {scores.std():.3f}')
# Búsqueda de hiperparámetros con Grid Search + CV
param_grid = {
'clf__n_estimators': [100, 200],
'clf__max_depth':
[None, 10, 20],
'clf__min_samples_leaf': [1, 5]
}
gs = GridSearchCV(pipe, param_grid, cv=cv, scoring='roc_auc',
n_jobs=-1, verbose=1)
gs.fit(X_train, y_train)
print('Mejores parámetros:', gs.best_params_)
print('Mejor AUC CV:',
gs.best_score_.round(4))
9.3 Data Leakage y Pipelines
El data leakage (fuga de datos) ocurre cuando información del conjunto de test o de validación
contamina el entrenamiento, dando métricas artificialmente optimistas que no se reproducen en
producción.
Causas comunes de data leakage
Escalar con estadísticas del test set (fit_transform en todo el dataset antes de dividir).
Imputar valores faltantes con la media de todo el dataset (incluido test).
Feature engineering que usa información futura en series temporales.
Target encoding sin validación cruzada (target leakage).
Solución: usar sklearn Pipelines siempre. El Pipeline aplica fit() solo en train y transform() en
test.
Capítulo 10: Aprendizaje No Supervisado
10.1 Clustering
K-Means
K-Means divide los datos en K clusters asignando cada punto al centroide más cercano y actualizando
los centroides iterativamente. El objetivo es minimizar la inercia (suma de distancias cuadradas al
centroide del cluster):
J = Σₖ Σᵢ∈Cₖ ||xᵢ - μₖ ||²
Algoritmo: (1) Inicializar K centroides aleatoriamente. (2) Asignar cada punto al centroide más cercano.
(3) Recalcular centroides como media de los puntos del cluster. (4) Repetir hasta convergencia. KMeans++ mejora la inicialización eligiendo centroides distantes entre sí, reduciendo el número de
iteraciones.
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
# Elegir K usando el método del codo y silhouette score
inertias
= []
silhouettes = []
K_range = range(2, 11)
for k in K_range:
km = KMeans(n_clusters=k, init='k-means++', n_init=10, random_state=42)
labels = km.fit_predict(X)
inertias.append(km.inertia_)
silhouettes.append(silhouette_score(X, labels))
# El silhouette score mide cohesión intracluster vs separación intercluster
# Rango: [-1, 1]; valores cercanos a 1 indican clusters bien separados
best_k = K_range[np.argmax(silhouettes)]
print(f'Mejor K (silhouette): {best_k}')
DBSCAN
DBSCAN (Density-Based Spatial Clustering of Applications with Noise) descubre clusters de forma
arbitraria y detecta outliers automáticamente. No requiere especificar K. Un punto es 'core' si tiene al
menos min_samples vecinos en un radio ε. Los puntos que no pertenecen a ningún core son
clasificados como ruido.
10.2 Reducción de Dimensionalidad
PCA (Análisis de Componentes Principales)
PCA encuentra las direcciones de máxima varianza en los datos (componentes principales). Es una
transformación lineal ortogonal que proyecta los datos en un nuevo espacio donde las variables están
descorrelacionadas.
Matemáticamente: PCA calcula los eigenvectores de la matriz de covarianza Σ = (1/n)XᵀX. Los
eigenvectores son las componentes principales; los eigenvalores indican cuánta varianza explica cada
componente. Elegimos las d componentes con mayor eigenvalor.
z = Wᵀ(x - μ) donde W = [v₁, v₂, ..., vₐ] (d eigenvectores top)
t-SNE y UMAP
Para visualización en 2D/3D de datos de alta dimensionalidad, t-SNE y UMAP son superiores a PCA
porque capturan estructura no lineal. t-SNE (t-distributed Stochastic Neighbor Embedding) modela
similitudes locales minimizando la KL divergence entre distribuciones en alta y baja dimensión. UMAP
es más rápido y preserva mejor la estructura global.
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
import umap # pip install umap-learn
# PCA: reducir de 50 a 2 dimensiones linealmente
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_scaled)
print(f'Varianza explicada: {pca.explained_variance_ratio_.sum():.1%}')
# t-SNE: no lineal, mejor para visualización
tsne = TSNE(n_components=2, perplexity=30, n_iter=1000, random_state=42)
X_tsne = tsne.fit_transform(X_scaled)
# solo para visualización, no para
modelos
# UMAP: más rápido que t-SNE, preserva estructura global
reducer = umap.UMAP(n_components=2, n_neighbors=15, min_dist=0.1)
X_umap = reducer.fit_transform(X_scaled)
Capítulo 11: Tendencias Actuales en Machine Learning
11.1 Large Language Models (LLMs)
Los Grandes Modelos de Lenguaje (GPT-4, Claude, LLaMA, Gemini) son Transformers decoder
entrenados sobre trillones de tokens de texto mediante next-token prediction. Su escala emergente
(parámetros de orden 7B-400B+) les confiere capacidades de razonamiento, programación y
generalización sin precedentes.
Paradigma de los LLMs modernos
Pre-entrenamiento: next-token prediction en texto masivo (auto-supervisado).
Instruction Tuning (SFT): fine-tuning con pares (instrucción, respuesta ideal).
RLHF (Reinforcement Learning from Human Feedback): alinear el modelo con preferencias
humanas.
PEFT (Parameter-Efficient Fine-Tuning): LoRA, QLoRA — adaptar con <1% de parámetros
entrenables.
RAG (Retrieval-Augmented Generation): conectar el LLM con fuentes externas de
conocimiento.
11.2 Modelos Generativos
GANs (Generative Adversarial Networks)
Las GANs consisten en dos redes que compiten: un Generator G que intenta generar muestras
realistas a partir de ruido, y un Discriminator D que intenta distinguir muestras reales de generadas. El
equilibrio de Nash de este juego minimax produce imágenes de alta calidad:
min_G max_D E[log D(x)] + E[log(1 - D(G(z)))]
Diffusion Models
Los modelos de difusión (DALL-E 3, Stable Diffusion, Midjourney) han superado a las GANs en
generación de imágenes. Aprenden a revertir un proceso de adición gradual de ruido gaussiano. En el
proceso forward, se añade ruido en T pasos hasta destruir la imagen. En el proceso reverse, una red
neuronal aprende a predecir y quitar el ruido paso a paso.
11.3 ML Responsable e Interpretabilidad
SHAP (SHapley Additive exPlanations)
SHAP, basado en valores de Shapley de la teoría de juegos, atribuye la predicción de un modelo a sus
características de forma justa y consistente. Para una predicción f(x), el SHAP value φᵢ de la feature i
es su contribución marginal promediada sobre todas las permutaciones posibles de features:
φᵢ = Σ_{S ⊆ F\{i}} [|S|!(|F|-|S|-1)!/|F|!] · [f(S∪{i}) - f(S)]
import shap
# SHAP con cualquier modelo (TreeExplainer es óptimo para ensembles)
explainer = shap.TreeExplainer(rf)
shap_values = explainer.shap_values(X_test)
# Resumen: importancia global de features
shap.summary_plot(shap_values[1], X_test, feature_names=[f'feat_{i}' for i in
range(X.shape[1])])
# Explicación local: por qué el modelo predijo lo que predijo para una muestra
shap.waterfall_plot(shap.Explanation(values=shap_values[1][0],
base_values=explainer.expected_value[1],
data=X_test[0]))
Fairness y Sesgos
Los modelos de ML pueden aprender y amplificar sesgos presentes en los datos históricos, generando
discriminación injusta. Es fundamental auditar los modelos con métricas de equidad (fairness) como
equalized odds, demographic parity y calibration by group, especialmente en aplicaciones de alto
impacto como crédito, sanidad o justicia penal.
11.4 MLOps: Machine Learning en Producción
MLOps aplica principios DevOps al ciclo de vida del ML. Los sistemas en producción requieren
monitorización continua (data drift, concept drift), versionado de datos y modelos, y pipelines
reproducibles.
Herramienta
Propósito
MLflow
Tracking de experimentos, registro de modelos, servidor de modelos.
DVC
Versionado de datos y pipelines, similar a Git para datasets.
Weights & Biases
Visualización de experimentos, sweeps de hiperparámetros.
Evidently AI
Monitorización de data drift y calidad de modelos en producción.
BentoML / FastAPI
Servir modelos como APIs REST escalables.
Capítulo 12: Proyecto Integrador — Pipeline Completo
En este capítulo ponemos en práctica todos los conceptos anteriores construyendo un pipeline
completo de ML para predecir la supervivencia en el Titanic (clasificación binaria con datos mixtos).
12.1 EDA y Preprocesamiento
import pandas as pd
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.model_selection import StratifiedKFold, cross_validate
from sklearn.metrics import classification_report
# Carga de datos
df = pd.read_csv('titanic.csv')
Fare, Embarked
X = df.drop('Survived', axis=1)
y = df['Survived']
# Survived, Pclass, Sex, Age, SibSp, Parch,
# Identificar columnas numéricas y categóricas
num_cols = ['Age', 'Fare', 'SibSp', 'Parch']
cat_cols = ['Sex', 'Embarked', 'Pclass']
# Preprocesamiento con ColumnTransformer
num_pipe = Pipeline([
('imputer', SimpleImputer(strategy='median')),
('scaler', StandardScaler())
])
# NaN → mediana
# normalizar
cat_pipe = Pipeline([
('imputer', SimpleImputer(strategy='most_frequent')), # NaN → moda
('ohe',
OneHotEncoder(drop='first', handle_unknown='ignore'))
])
preprocessor = ColumnTransformer([
('num', num_pipe, num_cols),
('cat', cat_pipe, cat_cols)
])
12.2 Modelado y Evaluación
# Pipeline completo: preprocesamiento + modelo
full_pipeline = Pipeline([
('prep', preprocessor),
('model', GradientBoostingClassifier(
n_estimators=300, learning_rate=0.05,
max_depth=4, subsample=0.8, random_state=42))
])
# Validación cruzada estratificada con múltiples métricas
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
results = cross_validate(full_pipeline, X, y, cv=cv,
scoring=['accuracy', 'f1', 'roc_auc'],
return_train_score=True)
for metric in ['accuracy', 'f1', 'roc_auc']:
train_m = results[f'train_{metric}'].mean()
test_m = results[f'test_{metric}'].mean()
print(f'{metric:10s}: Train={train_m:.3f} | Test={test_m:.3f}')
# Entrenamiento final y guardar modelo
import joblib
full_pipeline.fit(X, y)
joblib.dump(full_pipeline, 'titanic_model.pkl')
# Predicción en nuevos datos
new_passenger = pd.DataFrame({'Age':[29], 'Fare':[25.0], 'SibSp':[0],
'Parch':[0], 'Sex':['female'],
'Embarked':['S'], 'Pclass':[2]})
prob = full_pipeline.predict_proba(new_passenger)[0, 1]
print(f'Probabilidad de supervivencia: {prob:.1%}')
12.3 Checklist del Proyecto de ML
Lista de verificación antes de poner un modelo en producción
✓ Datos: sin leakage, distribución train/test comparable, manejo de NaN y outliers.
✓ Baseline: comparar siempre contra un modelo simple (ej: predecir la clase mayoritaria).
✓ Validación: usar CV estratificado, no un único split train/test.
✓ Métricas: elegir métricas alineadas con el objetivo de negocio.
✓ Interpretabilidad: usar SHAP u otras técnicas para validar que el modelo usa features
sensatos.
✓ Fairness: auditar el modelo por subgrupos relevantes.
✓ Monitorización: implementar alertas de data drift y degradación de performance.
✓ Documentación: versionar datos, código y modelo con MLflow/DVC.
Capítulo 13: Ejemplos Prácticos Explicados Paso a Paso
Este capítulo desarrolla ejemplos completos y detallados para los algoritmos más importantes,
explicando cada decisión, cada número y el razonamiento detrás de cada paso.
Ejemplo 1 — Regresión Lineal calculada a mano
� La intuición del problema
Imagina que tienes una pizzería y quieres predecir cuántas pizzas venderás según la
temperatura exterior. Con más calor, la gente sale más y pide más pizza. ¿Podemos encontrar
una línea recta que capture esa relación?
Tenemos 5 observaciones: temperatura exterior (x) en °C y pizzas vendidas (y):
Temperatura x (°C)
Pizzas vendidas y
20
50
22
55
25
65
28
70
30
80
✏ Calcular β₀ y β₁ de la regresión lineal
Paso 1: Calculamos las medias: x̄ = (20+22+25+28+30)/5 = 25, ȳ = (50+55+65+70+80)/5 = 64
Paso 2: Calculamos β₁ = Σ(xᵢ-x̄)(yᵢ-ȳ) / Σ(xᵢ-x̄)² = [(−5)(−14)+(−3)(−9)+(0)(1)+(3)(6)+(5)(16)] /
[25+9+0+9+25] = [70+27+0+18+80] / 68 = 195/68 ≈ 2.87
Paso 3: Calculamos β₀ = ȳ − βₙ·x̄ = 64 − 2.87·25 = 64 − 71.75 = −7.75
Paso 4: Ecuación final: ŷ = −7.75 + 2.87·x
Paso 5: Interpretación: por cada grado Celsius más de temperatura, se venden 2.87 pizzas
adicionales en promedio.
Paso 6: Predicción: si mañana hace 27°C → ŷ = −7.75 + 2.87·27 = −7.75 + 77.49 ≈ 70 pizzas
Verificamos el ajuste calculando el error para cada punto:
x
y real
ŷ = −7.75 + 2.87x
Error (y − ŷ)
Error²
20
50
49.65
+0.35
0.12
22
55
55.39
−0.39
0.15
25
65
64.00
+1.00
1.00
28
70
72.61
−2.61
6.81
30
80
78.35
+1.65
2.72
MSE = (0.12 + 0.15 + 1.00 + 6.81 + 2.72) / 5 = 10.80 / 5 = 2.16 → RMSE ≈ 1.47 pizzas de error
promedio
import numpy as np
from sklearn.linear_model import LinearRegression
import matplotlib.pyplot as plt
x = np.array([20, 22, 25, 28, 30]).reshape(-1, 1)
y = np.array([50, 55, 65, 70, 80])
# reshape para sklearn
model = LinearRegression()
model.fit(x, y)
print(f'β₀ (intercepto) = {model.intercept_:.2f}')
print(f'β₁ (pendiente) = {model.coef_[0]:.2f}')
# → -7.75
# → 2.87
# Predecir para 27°C
print(f'Predicción 27°C: {model.predict([[27]])[0]:.0f} pizzas')
# → 70
# Visualización
x_line = np.linspace(18, 32, 100).reshape(-1, 1)
plt.scatter(x, y, color='blue', label='Datos reales')
plt.plot(x_line, model.predict(x_line), color='red', label='Línea de regresión')
plt.xlabel('Temperatura (°C)')
plt.ylabel('Pizzas vendidas')
plt.title('Regresión Lineal: Temperatura → Pizzas')
plt.legend()
plt.show()
Ejemplo 2 — Descenso de Gradiente paso a paso
� La intuición del gradiente descendente
Imagina que estás en una montaña con los ojos vendados y quieres llegar al valle (el mínimo).
En cada paso, tocas el suelo con el pie para sentir la pendiente y te mueves en la dirección que
desciende más. El tamaño de cada paso es la tasa de aprendizaje (η). Pasos muy grandes:
puedes saltar al otro lado del valle. Pasos muy pequeños: tardarás mucho en llegar.
Trabajamos con una función cuadrática simple: L(w) = (w − 3)². Su mínimo está en w = 3. Partimos de
w₀ = 0.
∂L/∂w = 2(w − 3) → actualización: w ← w − η · 2(w − 3)
Ejecutamos 6 iteraciones con tasa de aprendizaje η = 0.3:
Iteración
w actual
Gradiente
2(w−3)
Actualización
−η·grad
w nuevo
Pérdida L(w)
0 (inicio)
0.000
−6.000
+1.800
1.800
9.000
1
1.800
−2.400
+0.720
2.520
1.440
2
2.520
−0.960
+0.288
2.808
0.230
3
2.808
−0.384
+0.115
2.923
0.037
4
2.923
−0.154
+0.046
2.969
0.006
5
2.969
−0.062
+0.019
2.988
0.001
Conclusión: En solo 5 iteraciones, w convergió de 0 a ≈ 2.99, muy cerca del mínimo real w=3. La
pérdida se redujo de 9.0 a 0.001 (99.99% de reducción).
# Visualizar la convergencia del gradiente descendente
import numpy as np
import matplotlib.pyplot as plt
def L(w):
def grad_L(w):
return (w - 3)**2
return 2 * (w - 3)
eta = 0.3
w
= 0.0
historia = [w]
# tasa de aprendizaje
# punto de inicio
for _ in range(20):
w = w - eta * grad_L(w)
historia.append(w)
# función de pérdida
# gradiente
# actualización
# Gráfico
w_vals = np.linspace(-1, 5, 300)
plt.figure(figsize=(10, 5))
plt.subplot(1, 2, 1)
plt.plot(w_vals, L(w_vals), 'b-', lw=2, label='L(w) = (w-3)²')
for i, wi in enumerate(historia[:8]):
plt.plot(wi, L(wi), 'ro', markersize=8)
if i < 7: plt.annotate(f'iter {i}', (wi, L(wi)), textcoords='offset points',
xytext=(5,5))
plt.xlabel('w'); plt.ylabel('L(w)'); plt.title('Trayectoria del gradiente');
plt.legend()
plt.subplot(1, 2, 2)
plt.plot([L(w) for w in historia], 'g-o', markersize=5)
plt.xlabel('Iteración'); plt.ylabel('Pérdida'); plt.title('Curva de aprendizaje');
plt.yscale('log')
plt.tight_layout(); plt.show()
¿Qué pasa si η es demasiado grande o demasiado pequeño?
η = 0.01 (demasiado pequeño): converge pero muy despacio, necesita miles de iteraciones.
η = 0.30 (adecuado): converge en ~20 iteraciones.
η = 0.99 (muy grande): oscila alrededor del mínimo sin llegar a él.
η = 1.10 (demasiado grande): diverge, la pérdida AUMENTA en lugar de disminuir.
En la práctica se usa learning rate scheduling: empezar con η grande e ir reduciéndolo.
Ejemplo 3 — Regresión Logística: Predecir si un email es spam
� El problema
Recibes 10 emails. Sabes cuántas veces aparece la palabra 'oferta' (x₁ ) y si tiene más de 3
signos de exclamación (x₂ ). Quieres predecir si cada email es spam (y=1) o no (y=0). La
regresión logística nos dará la PROBABILIDAD de que sea spam.
Dataset de entrenamiento (simplificado):
Email
Veces 'oferta' (x₁ )
¿Más de 3 '!' (x₂ )
¿Spam? (y)
1
0
0
0 (no spam)
2
1
0
0
3
2
1
1 (spam)
4
3
0
1
5
4
1
1
6
0
1
0
7
5
1
1
8
1
1
0
Supongamos que tras el entrenamiento el modelo aprendió: β₀ = −2.1, β₁ = 1.5, β₂ = 0.8
✏ Calcular la probabilidad de spam para el email: x₁ =3, x₂ =1
Paso 1: Calculamos la combinación lineal: z = β₀ + β₁ ·x₁ + β₂ ·x₂ = −2.1 + 1.5·3 + 0.8·1 =
−2.1 + 4.5 + 0.8 = 3.2
Paso 2: Aplicamos la función sigmoide: P(spam) = 1/(1 + e^(−3.2)) = 1/(1 + 0.041) = 1/1.041 ≈
0.961
Paso 3: Conclusión: el modelo predice un 96.1% de probabilidad de que este email sea spam.
Paso 4: Con umbral estándar de 0.5, clasificamos como SPAM (0.961 > 0.5). ✓ Correcto (era
y=1 en datos similares).
✏ Calcular la pérdida de entropía cruzada para este ejemplo
Paso 1: La fórmula es: L = −[y·log(ŷ) + (1−y)·log(1−ŷ)]
Paso 2: Con y=1 (es spam real) y ŷ=0.961: L = −[1·log(0.961) + 0·log(0.039)] = −log(0.961) =
0.040
Paso 3: Si el modelo se hubiera equivocado (ŷ=0.05 siendo spam): L = −log(0.05) = 3.00
Paso 4: La entropía cruzada penaliza MUCHO las predicciones muy equivocadas (3.00 >>
0.04). Esto fuerza al modelo a ser bien calibrado.
from sklearn.linear_model import LogisticRegression
import numpy as np
# Dataset del ejemplo
X = np.array([[0,0],[1,0],[2,1],[3,0],[4,1],[0,1],[5,1],[1,1]])
y = np.array([0, 0, 1, 1, 1, 0, 1, 0])
model = LogisticRegression()
model.fit(X, y)
print(f'β₀ = {model.intercept_[0]:.2f}')
print(f'β₁ (coef. oferta) = {model.coef_[0][0]:.2f}')
print(f'β₂ (coef. !) = {model.coef_[0][1]:.2f}')
# Predecir para varios emails nuevos
emails_nuevos = np.array([[0,0],[3,1],[5,1],[1,0]])
probabilidades = model.predict_proba(emails_nuevos)[:, 1]
for i, (email, prob) in enumerate(zip(emails_nuevos, probabilidades)):
verdict = 'SPAM' if prob > 0.5 else 'OK'
print(f'Email {i+1}: oferta={email[0]}x, !={email[1]} → P(spam)={prob:.1%} →
{verdict}')
# Interpretación de coeficientes:
# Cada aparición de 'oferta' multiplica los odds por e^β₁
print(f'Multiplicador de odds por cada aparición de oferta:
{np.exp(model.coef_[0][0]):.2f}x')
Ejemplo 4 — Árbol de Decisión: ¿Jugar al tenis hoy?
� El problema clásico
Tenemos registros históricos de días en que se jugó (o no) al tenis según las condiciones
meteorológicas. Queremos construir el árbol de decisión que capture estas reglas de forma
automática, y entender exactamente por qué el algoritmo elige cada división.
Cielo
Temperatura
Humedad
Viento
¿Jugar? (y)
Soleado
Alta
Alta
Débil
No
Soleado
Alta
Alta
Fuerte
No
Nublado
Alta
Alta
Débil
Sí
Lluvia
Media
Alta
Débil
Sí
Lluvia
Baja
Normal
Débil
Sí
Lluvia
Baja
Normal
Fuerte
No
Nublado
Baja
Normal
Fuerte
Sí
Soleado
Media
Alta
Débil
No
Soleado
Baja
Normal
Débil
Sí
Lluvia
Media
Normal
Débil
Sí
Soleado
Media
Normal
Fuerte
Sí
Nublado
Media
Alta
Fuerte
Sí
Nublado
Alta
Normal
Débil
Sí
Lluvia
Media
Alta
Fuerte
No
✏ ¿Por qué el algoritmo elige 'Cielo' como primera división?
Paso 1: Total: 14 ejemplos, 9 Sí, 5 No. Gini raíz = 1 − [(9/14)² + (5/14)²] = 1 − [0.413 + 0.127] =
0.459
Paso 2: Si dividimos por CIELO: Soleado(5 ej: 2Sí,3No), Nublado(4 ej: 4Sí,0No), Lluvia(5 ej:
3Sí,2No)
Paso 3: Gini(Soleado)=1−[(2/5)²+(3/5)²]=0.480; Gini(Nublado)=0 (¡puro!); Gini(Lluvia)=0.480
Paso 4: Gini ponderado = (5/14)·0.480 + (4/14)·0 + (5/14)·0.480 = 0.171 + 0 + 0.171 = 0.343
Paso 5: Reducción de Gini = 0.459 − 0.343 = 0.116. El algoritmo escoge la variable con
MAYOR reducción.
Paso 6: Nublado siempre lleva a Sí → rama pura, no se divide más. Soleado y Lluvia se siguen
dividiendo.
El árbol resultante tiene esta estructura:
Árbol de Decisión: ¿Jugar al tenis?
Raíz: ¿Cielo?
├── Nublado → JUGAR (siempre, rama pura, 4/4 Sí)
├── Soleado → ¿Humedad?
│ ├── Alta → NO JUGAR (3/3 No, rama pura)
│ └── Normal → JUGAR (2/2 Sí, rama pura)
└── Lluvia → ¿Viento?
├── Débil → JUGAR (3/3 Sí, rama pura)
└── Fuerte → NO JUGAR (2/2 No, rama pura)
Accuracy en entrenamiento: 14/14 = 100% (el árbol memoriza perfectamente con profundidad
suficiente)
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.preprocessing import LabelEncoder
import numpy as np
# Codificamos las variables categóricas como números
datos = [
['Soleado','Alta','Alta','Débil',0], ['Soleado','Alta','Alta','Fuerte',0],
['Nublado','Alta','Alta','Débil',1], ['Lluvia','Media','Alta','Débil',1],
['Lluvia','Baja','Normal','Débil',1], ['Lluvia','Baja','Normal','Fuerte',0],
['Nublado','Baja','Normal','Fuerte',1],['Soleado','Media','Alta','Débil',0],
['Soleado','Baja','Normal','Débil',1],['Lluvia','Media','Normal','Débil',1],
['Soleado','Media','Normal','Fuerte',1],['Nublado','Media','Alta','Fuerte',1],
['Nublado','Alta','Normal','Débil',1],['Lluvia','Media','Alta','Fuerte',0],
]
# Codificación manual: Cielo: Soleado=2,Nublado=0,Lluvia=1
#
Temp: Alta=0,Baja=1,Media=2
#
Hum:
Alta=0,Normal=1 Viento: Débil=0,Fuerte=1
cielo = {'Soleado':2,'Nublado':0,'Lluvia':1}
temp = {'Alta':0,'Baja':1,'Media':2}
hum
= {'Alta':0,'Normal':1}
viento= {'Débil':0,'Fuerte':1}
X = np.array([[cielo[d[0]],temp[d[1]],hum[d[2]],viento[d[3]]] for d in datos])
y = np.array([d[4] for d in datos])
tree = DecisionTreeClassifier(criterion='gini', max_depth=3)
tree.fit(X, y)
print(export_text(tree, feature_names=['Cielo','Temp','Humedad','Viento']))
# ¿Jugar el día de hoy? Nublado, Baja, Normal, Débil
nuevo_dia = np.array([[0, 1, 1, 0]]) # Nublado=0, Baja=1, Normal=1, Débil=0
print('Decisión:', 'Jugar' if tree.predict(nuevo_dia)[0] == 1 else 'No jugar')
Ejemplo 5 — K-Means: Segmentar clientes de una tienda
� El problema
Una tienda tiene 8 clientes caracterizados por su gasto anual (x₁ , en miles de €) y su
frecuencia de compra (x₂ , visitas/mes). Queremos agruparlos en K=2 segmentos para diseñar
estrategias de marketing diferenciadas, sin saber a priori qué clientes pertenecen a cada grupo.
Cliente
Gasto (x₁ )
Frecuencia (x₂ )
Grupo real (aprox)
A
1.0
2
Ocasional
B
1.5
1
Ocasional
C
2.0
3
Ocasional
D
8.0
9
VIP
E
9.0
8
VIP
F
8.5
10
VIP
G
1.0
4
Ocasional
H
9.5
7
VIP
✏ Iteración 1 de K-Means (inicialización aleatoria: μ₁ =A(1,2), μ₂ =D(8,9))
Paso 1: Calcular distancias de cada punto a cada centroide (distancia euclídea):
Paso 2: Cliente C(2,3): dist(C,μ₁ )=√((2-1)²+(3-2)²)=√2≈1.41 vs dist(C,μ₂ )=√((2-8)²+(39)²)=√72≈8.49 → asignar a μ₁
Paso 3: Cliente E(9,8): dist(E,μ₁ )=√((9-1)²+(8-2)²)=√100=10 vs dist(E,μ₂ )=√((9-8)²+(89)²)=√2≈1.41 → asignar a μ₂
Paso 4: Resultado: Cluster 1 = {A,B,C,G} (ocasionales). Cluster 2 = {D,E,F,H} (VIP).
Paso 5: Actualizar centroides: μ₁ = media({A,B,C,G}) = ((1+1.5+2+1)/4, (2+1+3+4)/4) = (1.375,
2.5)
Paso 6: μ₂ = media({D,E,F,H}) = ((8+9+8.5+9.5)/4, (9+8+10+7)/4) = (8.75, 8.5)
Iteración 2: Con los nuevos centroides, las asignaciones no cambian → el algoritmo converge. Los
grupos son estables.
import numpy as np
from sklearn.cluster import KMeans
import matplotlib.pyplot as plt
# Datos de clientes
X = np.array([[1,2],[1.5,1],[2,3],[8,9],[9,8],[8.5,10],[1,4],[9.5,7]])
nombres = ['A','B','C','D','E','F','G','H']
# Ajustamos K-Means con K=2
km = KMeans(n_clusters=2, init='k-means++', n_init=10, random_state=42)
labels = km.fit_predict(X)
centroides = km.cluster_centers_
print('Asignaciones:')
for nombre, label in zip(nombres, labels):
print(f' Cliente {nombre}: Cluster {label}')
print(f'Centroides: {centroides.round(2)}')
print(f'Inercia (suma dist² a centroides): {km.inertia_:.2f}')
# Visualización
colores = ['#2196F3' if l == 0 else '#FF5722' for l in labels]
plt.figure(figsize=(8,6))
plt.scatter(X[:,0], X[:,1], c=colores, s=150, edgecolors='black', zorder=3)
plt.scatter(centroides[:,0], centroides[:,1], c='black', marker='X', s=300,
label='Centroides')
for i, nombre in enumerate(nombres):
plt.annotate(nombre, (X[i,0]+0.1, X[i,1]+0.1))
plt.xlabel('Gasto anual (miles €)'); plt.ylabel('Visitas/mes')
plt.title('Segmentación de clientes con K-Means')
plt.legend(['Cluster 1 (Ocasionales)', 'Cluster 2 (VIP)', 'Centroides'])
plt.show()
Ejemplo 6 — Red Neuronal: Forward y Backward pass a mano
� El problema
Construimos la red neuronal MÁS SIMPLE posible: 2 neuronas de entrada, 1 neurona oculta, 1
neurona de salida. Queremos aprender la función XOR (1 XOR 0 = 1; 1 XOR 1 = 0). Con esto
entenderemos exactamente cómo fluye la información y cómo se actualizan los pesos.
Arquitectura: x₁ , x₂ → [neurona oculta h con ReLU] → [neurona salida ŷ con sigmoide]
Pesos iniciales (elegidos manualmente para el ejemplo): w₁ =0.5, w₂ =−0.3, b₁ =0.1 (oculta); v=0.8,
b₂ =−0.2 (salida)
✏ Forward Pass para la muestra (x₁ =1, x₂ =0, y=1)
Paso 1: Capa oculta (pre-activación): z₁ = w₁ ·x₁ + w₂ ·x₂ + b₁ = 0.5·1 + (−0.3)·0 + 0.1 =
0.60
Paso 2: Activación ReLU: h = max(0, z₁ ) = max(0, 0.60) = 0.60
Paso 3: Capa de salida (pre-activación): z₂ = v·h + b₂ = 0.8·0.60 + (−0.2) = 0.48 − 0.20 = 0.28
Paso 4: Activación sigmoide: ŷ = σ(z₂ ) = 1/(1+e^(−0.28)) = 1/(1+0.756) = 1/1.756 ≈ 0.569
Paso 5: Pérdida (entropía cruzada, y=1): L = −log(ŷ) = −log(0.569) ≈ 0.564
✏ Backward Pass: calcular gradientes con la regla de la cadena
Paso 1: ∂L/∂ŷ = −1/ŷ = −1/0.569 = −1.758 (derivada de la pérdida respecto a la predicción)
Paso 2: ∂ŷ/∂z₂ = ŷ·(1−ŷ) = 0.569·0.431 = 0.245 (derivada de la sigmoide)
Paso 3: δ₂ = ∂L/∂z₂ = (∂L/∂ŷ)·(∂ŷ/∂z₂ ) = −1.758·0.245 = −0.431 (error de la capa salida)
Paso 4: ∂L/∂v = δ₂ ·h = −0.431·0.60 = −0.259 ∂L/∂b₂ = δ₂ = −0.431
Paso 5: δ₁ = δ₂ ·v·1{z₁ >0} = −0.431·0.8·1 = −0.345 (ReLU: derivada=1 si z₁ >0, =0 si z₁ ≤0)
Paso 6: ∂L/∂w₁ = δ₁ ·x₁ = −0.345·1 = −0.345 ∂L/∂w₂ = δ₁ ·x₂ = −0.345·0 = 0
✏ Actualización de pesos con η=0.1
Paso 1: v_nuevo = v − η·(∂L/∂v) = 0.800 − 0.1·(−0.259) = 0.800 + 0.026 = 0.826
Paso 2: b₂ _nuevo = b₂ − η·(∂L/∂b₂ ) = −0.200 − 0.1·(−0.431) = −0.200 + 0.043 = −0.157
Paso 3: w₁ _nuevo = w₁ − η·(∂L/∂w₁ ) = 0.500 − 0.1·(−0.345) = 0.500 + 0.035 = 0.535
Paso 4: w₂ _nuevo = w₂ − η·(∂L/∂w₂ ) = −0.300 − 0.1·0 = −0.300 (sin cambio: x₂ =0 no aportó
señal)
Paso 5: Tras actualizar: realizamos otro forward pass → la pérdida disminuye ligeramente.
Repetimos miles de veces.
import torch
import torch.nn as nn
# XOR dataset
X = torch.tensor([[0.,0.],[0.,1.],[1.,0.],[1.,1.]])
y = torch.tensor([[0.],[1.],[1.],[0.]])
# XOR labels
class XORNet(nn.Module):
def __init__(self):
super().__init__()
self.hidden = nn.Linear(2, 4)
self.output = nn.Linear(4, 1)
# 2 entradas, 4 neuronas ocultas
# 4 -> 1 salida
def forward(self, x):
h = torch.relu(self.hidden(x)) # ReLU en capa oculta
return torch.sigmoid(self.output(h)) # sigmoid en salida
model = XORNet()
optimizer = torch.optim.Adam(model.parameters(), lr=0.1)
criterion = nn.BCELoss() # Binary Cross-Entropy
# Entrenamiento
for epoch in range(2000):
pred = model(X)
loss = criterion(pred, y)
optimizer.zero_grad()
# limpiar gradientes del paso anterior
loss.backward()
# backpropagation: calcula ∂L/∂θ para todos los
parámetros
optimizer.step()
# actualiza θ ← θ − η·∇L
if epoch % 500 == 0:
acc = ((pred > 0.5).float() == y).float().mean()
print(f'Epoch {epoch:4d}: Loss={loss.item():.4f}, Acc={acc:.2f}')
# Verificar resultados
with torch.no_grad():
preds = model(X).squeeze()
print('\nResultados finales:')
for xi, yi, pi in zip(X, y, preds):
print(f' XOR({int(xi[0])},{int(xi[1])}) = {int(yi[0])} → predicción:
{pi:.3f} ({"✓" if (pi>0.5)==(yi>0.5) else "✗"})')
Ejemplo 7 — El Dilema Sesgo-Varianza con Regresión Polinomial
� La analogía del arquero
Imagina tres arqueros: (1) Uno que siempre falla al mismo lado (sesgo alto, varianza baja). (2)
Uno que dispara disperso sin patrón (sesgo bajo, varianza alta). (3) Uno que siempre da en el
centro (sesgo bajo, varianza baja). El ML busca el tercer arquero. Un modelo demasiado simple
es el arquero 1. Un modelo demasiado complejo es el arquero 2.
Generamos datos de una función seno con ruido: y = sin(x) + ε, ε ~ N(0, 0.3). Entrenamos modelos
polinomiales de grado 1, 4 y 15:
Configuración del modelo
Comportamiento observado
Polinomio grado 1 (línea recta)
Underfitting: no puede capturar la curvatura del seno. Error train =
0.45, Error test = 0.47. Sesgo alto.
Polinomio grado 4
Buen ajuste: captura las curvas principales. Error train = 0.09, Error
test = 0.11. Balance óptimo.
Polinomio grado 15
Overfitting: sigue el ruido exactamente. Error train = 0.001, Error test
= 0.89. Varianza alta.
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import make_pipeline
from sklearn.metrics import mean_squared_error
# Generamos datos
np.random.seed(0)
X_train = np.sort(np.random.uniform(0, 2*np.pi, 20))
y_train = np.sin(X_train) + np.random.normal(0, 0.3, 20)
X_test = np.sort(np.random.uniform(0, 2*np.pi, 100))
y_test = np.sin(X_test) + np.random.normal(0, 0.3, 100)
grados = [1, 4, 15]
etiquetas = ['Underfitting (grado 1)', 'Balance óptimo (grado 4)', 'Overfitting
(grado 15)']
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
x_plot = np.linspace(0, 2*np.pi, 300)
for ax, grado, etiqueta in zip(axes, grados, etiquetas):
model = make_pipeline(PolynomialFeatures(grado), LinearRegression())
model.fit(X_train.reshape(-1,1), y_train)
y_pred_train = model.predict(X_train.reshape(-1,1))
y_pred_test = model.predict(X_test.reshape(-1,1))
mse_train = mean_squared_error(y_train, y_pred_train)
mse_test = mean_squared_error(y_test, y_pred_test)
ax.scatter(X_train, y_train, color='blue', alpha=0.6, label='Datos train')
ax.plot(x_plot, np.sin(x_plot), 'g--', label='Función real')
ax.plot(x_plot, model.predict(x_plot.reshape(-1,1)), 'r-', label=f'Modelo
g={grado}')
ax.set_title(f'{etiqueta}\nTrain MSE={mse_train:.3f}, Test
MSE={mse_test:.3f}')
ax.legend(fontsize=8); ax.set_ylim(-2.5, 2.5)
plt.tight_layout()
plt.savefig('sesgo_varianza.png', dpi=150)
plt.show()
# Curva de aprendizaje: MSE vs complejidad del modelo
grados_rango = range(1, 20)
mse_trains = []; mse_tests = []
for g in grados_rango:
m = make_pipeline(PolynomialFeatures(g), LinearRegression())
m.fit(X_train.reshape(-1,1), y_train)
mse_trains.append(mean_squared_error(y_train, m.predict(X_train.reshape(1,1))))
mse_tests.append(mean_squared_error(y_test, m.predict(X_test.reshape(-1,1))))
plt.figure(figsize=(8,5))
plt.plot(grados_rango, mse_trains, 'b-o', label='MSE Train')
plt.plot(grados_rango, mse_tests, 'r-o', label='MSE Test')
plt.axvline(x=4, color='green', linestyle='--', label='Complejidad óptima')
plt.xlabel('Grado del polinomio'); plt.ylabel('MSE'); plt.yscale('log')
plt.title('Dilema Sesgo-Varianza: Train vs Test MSE'); plt.legend()
plt.show()
¿Cómo detectar y corregir cada problema?
UNDERFITTING: MSE train alto y MSE test alto y similares.
→ Aumentar complejidad (más capas, más neuronas, mayor grado).
→ Añadir más features o features más expresivos.
→ Reducir la regularización (λ más pequeño).
OVERFITTING: MSE train muy bajo pero MSE test mucho mayor.
→ Regularización L1/L2, Dropout, BatchNorm.
→ Recoger más datos de entrenamiento.
→ Reducir complejidad del modelo.
→ Early stopping (detener el entrenamiento antes de memorizar).
Ejemplo 8 — PCA: Reducción de Dimensionalidad en Iris
� La intuición de PCA
Imagina que tienes datos en 3D (longitud, anchura de pétalo, y longitud de sépalo) y quieres
proyectarlos en 2D para visualizarlos. PCA encuentra el 'eje' en el que los datos varían más
(PC1), luego el eje perpendicular con segunda mayor varianza (PC2). Es como encontrar el
mejor ángulo desde el que fotografiar una escultura 3D para que se vea mejor en una foto 2D.
✏ PCA en 2D con datos sintéticos (4 variables → 2 componentes)
Paso 1: Dataset Iris: 150 flores, 4 features (longitud/anchura sépalo y pétalo), 3 clases.
Paso 2: Paso 1 — Estandarizar: cada feature con media=0 y std=1 (obligatorio para PCA).
Paso 3: Paso 2 — Calcular la matriz de covarianza 4×4 de las features estandarizadas.
Paso 4: Paso 3 — Calcular eigenvectores y eigenvalores de la matriz de covarianza.
Paso 5: Paso 4 — Ordenar eigenvectores por eigenvalor descendente. Los 2 primeros son
PC1 y PC2.
Paso 6: Paso 5 — Proyectar: X_pca = X_estandarizado · [PC1, PC2]. Reducimos de 4D a 2D.
Paso 7: Resultado: PC1 explica el 72.9% de la varianza. PC2 explica el 22.9%. Total: 95.8%
de la información.
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
from sklearn.datasets import load_iris
import matplotlib.pyplot as plt
import numpy as np
# Cargamos Iris: 150 muestras, 4 features, 3 clases
iris = load_iris()
X, y = iris.data, iris.target
nombres_clases = iris.target_names
# setosa, versicolor, virginica
nombres_features = iris.feature_names
# 1) Estandarizar (SIEMPRE antes de PCA)
scaler = StandardScaler()
X_std = scaler.fit_transform(X)
# 2) PCA: de 4D a 2D
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X_std)
print('Varianza explicada por componente:')
for i, var in enumerate(pca.explained_variance_ratio_):
print(f' PC{i+1}: {var:.1%}')
print(f' Total: {pca.explained_variance_ratio_.sum():.1%}')
print('\nContribución de cada feature a PC1 (loadings):')
for feat, loading in zip(nombres_features, pca.components_[0]):
print(f' {feat:30s}: {loading:+.3f}')
# 3) Visualización
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Plot 1: proyección 2D con colores por clase
colores = ['#E53935', '#43A047', '#1E88E5']
for i, clase in enumerate(nombres_clases):
idx = y == i
axes[0].scatter(X_pca[idx,0], X_pca[idx,1], c=colores[i], label=clase,
alpha=0.7, s=60)
axes[0].set_xlabel(f'PC1 ({pca.explained_variance_ratio_[0]:.1%} varianza)')
axes[0].set_ylabel(f'PC2 ({pca.explained_variance_ratio_[1]:.1%} varianza)')
axes[0].set_title('Iris proyectado en 2D con PCA')
axes[0].legend()
# Plot 2: varianza acumulada (scree plot)
pca_full = PCA().fit(X_std)
var_acum = np.cumsum(pca_full.explained_variance_ratio_)
axes[1].bar(range(1,5), pca_full.explained_variance_ratio_, color='#1E88E5',
alpha=0.7, label='Varianza por PC')
axes[1].plot(range(1,5), var_acum, 'ro-', label='Varianza acumulada')
axes[1].axhline(0.95, color='green', linestyle='--', label='Umbral 95%')
axes[1].set_xlabel('Componente principal'); axes[1].set_ylabel('Fracción de
varianza')
axes[1].set_title('Scree Plot: varianza explicada'); axes[1].legend()
plt.tight_layout(); plt.show()
Ejemplo 9 — Mecanismo de Atención: Ejemplo numérico detallado
� La intuición de la atención
Al leer '¿A quién vio Juan? Juan vio a María.', para entender qué hace Juan, necesitamos
conectar 'Juan' (la Query) con 'vio a María' (las Keys/Values relevantes). El mecanismo de
atención hace exactamente esto: calcula cuánto debe 'atender' cada palabra a cada otra
palabra, y pondera sus representaciones en consecuencia.
Ejemplo simplificado con d_k=2 (2 dimensiones). Frase: ['el', 'perro', 'ladra']. Matrices Q, K, V
(inventadas para el ejemplo):
Token
Query Q (dim=2)
Key K (dim=2)
Value V (dim=2)
'el'
[1.0, 0.0]
[1.0, 0.0]
[0.5, 0.1]
'perro'
[0.0, 1.0]
[0.0, 1.0]
[0.8, 0.9]
'ladra'
[0.5, 0.5]
[0.5, 0.5]
[0.2, 0.7]
✏ Calcular la atención del token 'perro' hacia todos los tokens
Paso 1: Q_perro = [0, 1]. Calculamos QKᵀ: similitudes = [Q·K_el, Q·K_perro, Q·K_ladra]
Paso 2: sim(perro, el) = [0,1]·[1,0] = 0·1 + 1·0 = 0.0
Paso 3: sim(perro, perro) = [0,1]·[0,1] = 0·0 + 1·1 = 1.0
Paso 4: sim(perro, ladra) = [0,1]·[0.5,0.5] = 0·0.5 + 1·0.5 = 0.5
Paso 5: Escalar por √d_k = √2 ≈ 1.414: [0/1.414, 1/1.414, 0.5/1.414] = [0.0, 0.707, 0.354]
Paso 6: Aplicar softmax: e^0=1, e^0.707=2.028, e^0.354=1.425. Suma=4.453. Pesos: [0.225,
0.455, 0.320]
Paso 7: Salida = 0.225·V_el + 0.455·V_perro + 0.320·V_ladra = 0.225·[0.5,0.1] +
0.455·[0.8,0.9] + 0.320·[0.2,0.7]
Paso 8: = [0.113,0.023] + [0.364,0.410] + [0.064,0.224] = [0.541, 0.657]
Interpretación: El token 'perro' (al ser Query) presta mayor atención a sí mismo (45.5%) y en segundo
lugar a 'ladra' (32%), lo cual tiene sentido sintáctico: el sujeto está conectado con el verbo. La
representación resultante [0.541, 0.657] es una mezcla de todos los tokens ponderada por relevancia.
import torch
import torch.nn.functional as F
# Matrices Q, K, V para la frase ['el', 'perro', 'ladra']
Q = torch.tensor([[1.0, 0.0], [0.0, 1.0], [0.5, 0.5]]) # (seq_len=3, d_k=2)
K = torch.tensor([[1.0, 0.0], [0.0, 1.0], [0.5, 0.5]])
V = torch.tensor([[0.5, 0.1], [0.8, 0.9], [0.2, 0.7]])
d_k = Q.shape[-1]
# dimensión de los keys
# Paso 1: puntuaciones de similitud QKᵀ escaladas
scores = torch.matmul(Q, K.T) / (d_k ** 0.5)
print('Puntuaciones de atención (antes softmax):')
print(scores.numpy().round(3))
# Paso 2: pesos de atención con softmax (normalizar por filas)
attn_weights = F.softmax(scores, dim=-1)
print('\nPesos de atención (después softmax):')
tokens = ['el', 'perro', 'ladra']
for i, tok in enumerate(tokens):
w = attn_weights[i].numpy()
print(f' {tok:8s} atiende a: el={w[0]:.3f}, perro={w[1]:.3f},
ladra={w[2]:.3f}')
# Paso 3: salida = suma ponderada de Values
output = torch.matmul(attn_weights, V)
print('\nRepresentaciones de salida (contextualizadas):')
for tok, vec in zip(tokens, output):
print(f' {tok:8s}: {vec.detach().numpy().round(3)}')
Ejemplo 10 — Interpretabilidad con SHAP: ¿Por qué el modelo predijo eso?
� La motivación de la explicabilidad
Un banco usa un modelo de ML para aprobar o rechazar préstamos. Un cliente es rechazado y
pregunta: ¿por qué? La respuesta 'el modelo dijo que no' no es aceptable ni legal (GDPR).
SHAP nos permite decir: 'Tu solicitud fue rechazada principalmente porque tu historial de pagos
tiene 3 demoras (−35 puntos) y tu ratio deuda/ingreso es 0.8 (−28 puntos), aunque tu
antigüedad laboral ayuda (+15 puntos).'
Vamos a entrenar un Random Forest sobre el dataset 'breast cancer' (diagnóstico de tumor:
benigno/maligno) y explicar sus predicciones con SHAP:
import shap
import numpy as np
import pandas as pd
from sklearn.datasets import load_breast_cancer
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# Cargamos datos
cancer = load_breast_cancer()
X = pd.DataFrame(cancer.data, columns=cancer.feature_names)
y = cancer.target # 0=maligno, 1=benigno
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2,
random_state=42)
# Entrenamos
rf = RandomForestClassifier(n_estimators=200, random_state=42)
rf.fit(X_train, y_train)
print(f'Accuracy: {rf.score(X_test, y_test):.3f}')
# ── Explicación Global con SHAP ──────────────────────────────────
explainer
= shap.TreeExplainer(rf)
shap_values = explainer.shap_values(X_test)
# shap_values es una lista de 2: [clase_maligno, clase_benigno]
# Summary plot: importancia global de features
# Cada punto es una muestra; color = valor del feature; posición x = impacto en la
predicción
shap.summary_plot(shap_values[1], X_test, plot_type='dot')
# ── Explicación Local: UNA predicción concreta ───────────────────
muestra_idx = 5 # elegimos la muestra 5 del test set
prediccion = rf.predict(X_test.iloc[[muestra_idx]])[0]
probabilidad= rf.predict_proba(X_test.iloc[[muestra_idx]])[0]
print(f'\nMuestra {muestra_idx}: Predicción={"Benigno" if prediccion==1 else
"Maligno"}')
print(f'P(benigno)={probabilidad[1]:.3f}, P(maligno)={probabilidad[0]:.3f}')
# Waterfall plot: descompone la predicción en contribuciones de cada feature
shap.waterfall_plot(shap.Explanation(
values=shap_values[1][muestra_idx],
base_values=explainer.expected_value[1], # valor de predicción 'base' sin
info del paciente
data=X_test.iloc[muestra_idx],
feature_names=X_test.columns.tolist()
))
# Force plot: alternativa visual compacta
shap.force_plot(
explainer.expected_value[1],
shap_values[1][muestra_idx],
X_test.iloc[muestra_idx]
)
# Los valores SHAP positivos (en rojo) empujan hacia predicción benigno
# Los valores SHAP negativos (en azul) empujan hacia predicción maligno
# La suma de todos los SHAP values + base_value = probabilidad predicha
Cómo leer un gráfico SHAP waterfall
base_value: la predicción promedio del modelo sobre todos los datos de entrenamiento (línea
base).
Barras rojas: features que AUMENTAN la predicción (empujan hacia clase positiva).
Barras azules: features que DISMINUYEN la predicción (empujan hacia clase negativa).
Anchura de la barra: magnitud del impacto del feature en ESTA predicción concreta.
La suma de todas las barras + base_value = predicción final del modelo.
Regla práctica: los 5 features con barras más anchas son los más determinantes para esta
predicción.
0
Puede agregar este documento a su colección de estudio (s)
Iniciar sesión Disponible sólo para usuarios autorizadosPuede agregar este documento a su lista guardada
Iniciar sesión Disponible sólo para usuarios autorizados(Para quejas, use otra forma )