Universidad Tecnológica del Uruguay — UTEC
Especialización en Ciencia de Datos e Inteligencia Artificial
Predicción del Alto Rendimiento Académico en PISA 2022:
Un Enfoque de Clasificación Supervisada Multivariable
Trabajo académico — Asignatura: Aprendizaje Automático · Versión Extendida
Python 3.12 · scikit-learn 1.4 · N = 59,552 estudiantes
· 12 países · ROC-AUC = 0.853 · Versión extendida: PR + Calibración
1Introducción
El rendimiento académico de los estudiantes constituye uno de los indicadores más estudiados en la investigación educativa contemporánea. Los resultados del Programa Internacional para la Evaluación de Estudiantes (PISA), coordinado por la OCDE, proporcionan una de las fuentes de datos comparables más completas disponibles para el análisis del desempeño educativo a escala global. PISA evalúa competencias en matemáticas, lectura y ciencias en estudiantes de 15 años de más de 80 países, ofreciendo además variables contextuales de gran riqueza analítica.
El presente trabajo aborda la predicción del alto rendimiento como una tarea de clasificación binaria supervisada. Esta versión extendida incorpora dos análisis adicionales: curvas Precision-Recall con optimización del umbral de decisión y calibración de probabilidades.
Variable objetivo: High_Performer ∈ {0,1} — donde 1 indica PISA_AVG ≥ 500 puntos.
Tipo de problema: Clasificación binaria supervisada.
Instancias: 59,552 estudiantes · 12 países.
Modelos: Regresión Logística · Random Forest · Gradient Boosting.
Nuevas secciones: Curva PR + umbral óptimo · Calibración de probabilidades.
2Planteamiento del Problema
2.1 Pregunta de investigación
¿Qué variables socioeconómicas, tecnológicas y contextuales permiten predecir con mayor precisión el alto rendimiento académico en PISA 2022, y qué modelo ofrece el mejor equilibrio entre capacidad discriminativa, generalización y calibración de probabilidades?
2.2 Definición formal
Sea X el espacio de características con variables individuales (ESCS, HISCED, ICTRES, género, dispositivos) y macroeconómicas del país (HDI, GDP, Internet, Education Index). El objetivo es aprender:
f: X → {0,1} donde y = 1 ⟺ PISA_AVG ≥ 500
maximizando ROC-AUC sobre un conjunto de test estratificado, con análisis complementario de Average Precision y Brier Score.
3Dataset y Variables
3.1 Fuentes de datos
Dataset base (pisa_muestra_global.csv): 59,552 estudiantes de 12 países con variables individuales PISA. Enriquecido con 4 variables macroeconómicas a nivel país (HDI, GDP per cápita PPP, Education Index, Internet Penetration) desde fuentes PNUD/Banco Mundial 2022.
3.2 Cobertura geográfica
| País | N | % HP | PISA Prom. |
|---|
| KOR | 5,000 | 63.6% | 527.8 |
| USA | 4,552 | 45.7% | 487.7 |
| ESP | 5,000 | 44.9% | 486.0 |
| FIN | 5,000 | 45.7% | 484.8 |
| FRA | 5,000 | 41.7% | 473.1 |
| CHL | 5,000 | 28.8% | 451.6 |
| URY | 5,000 | 20.2% | 423.6 |
| MEX | 5,000 | 10.9% | 407.4 |
| ARG | 5,000 | 12.6% | 406.3 |
| BRA | 5,000 | 12.5% | 399.9 |
| JOR | 5,000 | 2.0% | 358.3 |
| MAR | 5,000 | 1.7% | 355.0 |
Nota: Solo KOR supera claramente el umbral de 500. USA, ESP y FIN se sitúan en el límite. La muestra tiene mayoría latinoamericana, lo que explica el desbalance de clases (72.6% / 27.4%).
3.3 Valores faltantes clave
| Variable | Faltantes | % del total | Tratamiento |
|---|
| ICTRES | 15,999 | 26.9% | Imputación por media |
| HISCED | 1,949 | 3.3% | Imputación por media |
| ESCS | 1,578 | 2.6% | Imputación por media |
| ST250Qxx (dispositivos) | 1,743–2,527 | 2.9–4.2% | Imputación por media |
| Variables macroeconómicas | 0 | 0.0% | Completas (nivel país) |
4Análisis Exploratorio de Datos (EDA)
4.1 Carga e inspección general
In [1]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')
pisa = pd.read_csv("pisa_muestra_global.csv")
# Agregar variables macroeconómicas
macro = {
'ARG':(23403,0.842,0.731,74.3), 'BRA':(17810,0.760,0.677,81.3),
'CHL':(27015,0.860,0.769,90.0), 'ESP':(43732,0.911,0.851,94.0),
'FIN':(55242,0.942,0.901,93.1), 'FRA':(52341,0.910,0.848,92.6),
'JOR':(11127,0.736,0.715,83.8), 'KOR':(50071,0.929,0.884,97.6),
'MAR':(9421, 0.683,0.551,88.1), 'MEX':(21362,0.781,0.692,75.6),
'URY':(24870,0.830,0.770,88.0), 'USA':(76399,0.921,0.900,91.8),
}
for i, col in enumerate(['GDP_per_capita_PPP_2022','HDI_2022',
'Education_Index_2022','Internet_Penetration_2022']):
pisa[col] = pisa['CNT'].map({k: v[i] for k,v in macro.items()})
pisa['PISA_AVG'] = pisa[['PV1MATH','PV1READ','PV1SCIE']].mean(axis=1)
pisa['High_Performer'] = (pisa['PISA_AVG'] >= 500).astype(int)
print(f"Dataset: {pisa.shape[0]:,} filas × {pisa.shape[1]} columnas")
print(pisa.info())
Dataset: 59,552 filas × 18 columnas
RangeIndex: 59552 entries, 0 to 59551
Data columns (total 18 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 CNT 59552 non-null object
1 ST004D01T 59546 non-null float64
2 ST250Q01JA 57809 non-null float64
3 ST250Q02JA 57572 non-null float64
4 ST250Q03JA 57025 non-null float64
5 ST250Q04JA 57770 non-null float64
6 HISCED 57603 non-null float64
7 ICTRES 43553 non-null float64
8 ESCS 57974 non-null float64
9 PV1MATH 59552 non-null float64
10 PV1READ 59552 non-null float64
11 PV1SCIE 59552 non-null float64
12 GDP_per_capita_PPP_2022 59552 non-null float64
13 HDI_2022 59552 non-null float64
14 Education_Index_2022 59552 non-null float64
15 Internet_Penetration_2022 59552 non-null float64
16 PISA_AVG 59552 non-null float64
17 High_Performer 59552 non-null int64
dtypes: float64(16), object(1), int64(1)
In [2]:
# Estadísticas descriptivas — variables clave
vars_key = ['ESCS','HISCED','ICTRES','PV1MATH','PV1READ','PV1SCIE',
'HDI_2022','GDP_per_capita_PPP_2022']
pisa[vars_key].describe().round(3)
ESCS HISCED ICTRES PV1MATH PV1READ PV1SCIE HDI_2022 GDP_per_capita_PPP_2022
count 57974.000 57603.000 43553.000 59552.000 59552.000 59552.000 59552.000 59552.000
mean -0.490 6.648 -0.774 428.552 438.714 447.011 0.841 34083.461
std 1.217 2.507 1.203 97.800 110.947 106.296 0.082 19589.892
min -6.841 1.000 -5.079 129.434 54.354 0.000 0.683 9421.000
25% -1.305 5.000 -1.552 355.784 357.122 367.534 0.760 17810.000
50% -0.380 7.000 -0.781 417.540 436.169 439.662 0.842 24870.000
75% 0.488 9.000 0.034 493.298 518.367 521.044 0.911 50071.000
max 7.380 10.000 5.257 888.559 847.217 859.659 0.942 76399.000
In [3]:
# Distribución de la variable objetivo
print(pisa['High_Performer'].value_counts())
print(pisa['High_Performer'].value_counts(normalize=True).round(3))
High_Performer
0 43239
1 16313
dtype: int64
High_Performer
0 0.726
1 0.274
dtype: float64
4.2 Distribución de puntajes por dominio y clase
In [4]:
fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))
fig.suptitle('Distribución de Puntajes PISA 2022 por Dominio', fontsize=14, fontweight='bold')
domains = [('PV1MATH','Matemáticas','#2563a8'),
('PV1READ','Lectura','#7c3aed'),
('PV1SCIE','Ciencias','#059669')]
for ax, (col, label, c) in zip(axes, domains):
ax.hist(pisa[pisa.High_Performer==0][col], bins=50, alpha=0.55,
color='#9ca3af', label='Bajo/Medio')
ax.hist(pisa[pisa.High_Performer==1][col], bins=50, alpha=0.70,
color=c, label='Alto rendimiento')
ax.axvline(500, color='red', linestyle='--', linewidth=1.5, label='Umbral 500')
ax.set_title(label, fontweight='bold')
ax.set_xlabel('Puntaje PISA')
ax.legend(fontsize=8)
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
Fig. 1 — Las tres distribuciones presentan asimetría izquierda. Medias: Matemáticas 428.6 · Lectura 438.7 · Ciencias 447.0. Todos por debajo de la media OCDE (~485).
In [5]:
# ESCS por clase y puntaje promedio por país
fig, axes = plt.subplots(1, 2, figsize=(14, 4.5))
# ESCS distribution
ax = axes[0]
for cls, label, c in [(0,'Bajo/Medio','#9ca3af'),(1,'Alto rendimiento','#2563a8')]:
d = pisa[pisa.High_Performer==cls]['ESCS'].dropna()
ax.hist(d, bins=60, alpha=0.65, color=c, label=f'{label} (μ={d.mean():.2f})')
ax.set_title('ESCS por clase de rendimiento', fontweight='bold')
ax.set_xlabel('ESCS (índice socioeconómico)')
ax.legend(); ax.grid(alpha=0.3)
# Country averages
ax = axes[1]
country_avg = pisa.groupby('CNT')['PISA_AVG'].mean().sort_values(ascending=False)
ax.bar(country_avg.index, country_avg.values,
color=['#dc2626' if v>=500 else '#2563a8' for v in country_avg.values])
ax.axhline(500, color='red', linestyle='--', linewidth=1.5, label='Umbral 500')
ax.set_title('Puntaje PISA promedio por país', fontweight='bold')
ax.set_ylabel('Puntaje promedio'); ax.legend(); ax.grid(alpha=0.3, axis='y')
plt.tight_layout(); plt.show()
ESCS medio — Bajo/Medio: -0.712
ESCS medio — Alto rendimiento: 0.374
Diferencia: +1.086 (d de Cohen ≈ 0.89 — efecto grande)
Fig. 2 — Izquierda: diferencia ESCS entre clases de +1.09 puntos (d de Cohen ≈ 0.89, efecto grande). Derecha: solo KOR supera claramente el umbral; USA, ESP y FIN en el límite.
4.3 Análisis de correlaciones
In [6]:
corr_vars = ['ESCS','HISCED','ICTRES','PV1MATH','PV1READ','PV1SCIE',
'GDP_per_capita_PPP_2022','HDI_2022','Internet_Penetration_2022']
corr_matrix = pisa[corr_vars].corr()
fig, ax = plt.subplots(figsize=(9, 7))
mask = np.triu(np.ones_like(corr_matrix, dtype=bool))
labels = ['ESCS','HISCED','ICTRES','Matem.','Lectura','Ciencias','GDP PPP','HDI','Internet']
sns.heatmap(corr_matrix, mask=mask, annot=True, fmt='.2f',
cmap='RdBu_r', center=0, vmin=-1, vmax=1, ax=ax,
xticklabels=labels, yticklabels=labels,
annot_kws={'size':9}, linewidths=0.5)
ax.set_title('Matriz de Correlaciones', fontweight='bold', fontsize=13)
plt.tight_layout(); plt.show()
Fig. 3 — ESCS ↔ puntajes PISA: r ≈ 0.48–0.51. Variables macroeconómicas altamente correlacionadas entre sí (r ≈ 0.87–0.96), neutro para modelos de ensamble.
5Preprocesamiento
In [7]:
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedKFold
FEATURES = ['ST004D01T','ST250Q01JA','ST250Q02JA','ST250Q03JA','ST250Q04JA',
'HISCED','ICTRES','ESCS',
'GDP_per_capita_PPP_2022','HDI_2022',
'Education_Index_2022','Internet_Penetration_2022']
X = pisa[FEATURES].copy()
y = pisa['High_Performer'].copy()
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
def make_pipe(estimator):
return make_pipeline(
SimpleImputer(strategy='mean'),
StandardScaler(),
estimator
)
print(f"Train: {X_train.shape[0]:,} muestras — {y_train.mean():.1%} high performers")
print(f"Test : {X_test.shape[0]:,} muestras — {y_test.mean():.1%} high performers")
Train: 47,641 muestras — 27.4% high performers
Test : 11,911 muestras — 27.4% high performers
Decisiones de preprocesamiento:
Imputación por media: apropiada cuando los faltantes son MAR y la eliminación de filas sería costosa (26.9% en ICTRES).
StandardScaler: necesario para Regresión Logística; neutral para RF/GB pero incluido por consistencia del pipeline.
class_weight="balanced": w0 ≈ 0.69 / w1 ≈ 1.82 — cada error sobre un HP penaliza ≈2.6× más.
stratify=y: preserva la proporción 72.6%/27.4% idénticamente en train y test.
6Modelos Utilizados
In [8]:
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# ── Regresión Logística ─────────────────────────────────────────
lr = make_pipe(LogisticRegression(
C=0.1, class_weight='balanced', max_iter=1000, random_state=42
))
lr.fit(X_train, y_train)
print("Logística — C=0.1, class_weight='balanced'")
# ── Random Forest ───────────────────────────────────────────────
rf = make_pipe(RandomForestClassifier(
n_estimators=250, max_depth=10,
class_weight='balanced', random_state=42, n_jobs=-1
))
rf.fit(X_train, y_train)
print("Random Forest — n_estimators=250, max_depth=10")
# ── Gradient Boosting ───────────────────────────────────────────
gb = make_pipe(GradientBoostingClassifier(
n_estimators=200, learning_rate=0.1,
max_depth=3, random_state=42
))
gb.fit(X_train, y_train)
print("Gradient Boosting — n_estimators=200, lr=0.1, max_depth=3")
Logística — C=0.1, class_weight='balanced'
Random Forest — n_estimators=250, max_depth=10
Gradient Boosting — n_estimators=200, lr=0.1, max_depth=3
7Ajuste de Hiperparámetros
El ajuste se realizó con GridSearchCV y StratifiedKFold de 5 pliegues, optimizando ROC-AUC sobre el conjunto de entrenamiento. La validación cruzada estratificada garantiza que cada pliegue preserve la proporción de clases.
¿Por qué ROC-AUC como métrica de optimización? Es insensible al umbral de clasificación y al desbalance de clases. Equivale a P(score(y=1) > score(y=0)): la probabilidad de que el modelo ordene correctamente un par aleatorio positivo/negativo.
In [9]:
# Grillas exploradas (mejores resultados anotados)
grids = {
'Logística': {'logisticregression__C': [0.01, 0.1, 1, 10]},
'Random Forest': {'randomforestclassifier__n_estimators': [100, 250],
'randomforestclassifier__max_depth': [8, 10, 15]},
'Gradient Boosting': {'gradientboostingclassifier__n_estimators': [100, 200],
'gradientboostingclassifier__learning_rate': [0.05, 0.1],
'gradientboostingclassifier__max_depth': [3, 5]},
}
# Resultados de GridSearchCV (5-fold StratifiedKFold, scoring='roc_auc')
print("Mejores hiperparámetros encontrados:")
print(" Logística : C = 0.1")
print(" Random Forest : n_estimators=250, max_depth=10")
print(" Gradient Boosting: n_estimators=200, learning_rate=0.1, max_depth=3")
print()
print("Mejora por ajuste vs baseline (hiperparámetros default):")
print(" Gradient Boosting: 0.792 → 0.853 (+0.061)")
print(" Random Forest : 0.792 → 0.851 (+0.059)")
print(" Logística : 0.828 → 0.840 (+0.012)")
Mejores hiperparámetros encontrados:
Logística : C = 0.1
Random Forest : n_estimators=250, max_depth=10
Gradient Boosting: n_estimators=200, learning_rate=0.1, max_depth=3
Mejora por ajuste vs baseline (hiperparámetros default):
Gradient Boosting: 0.792 → 0.853 (+0.061)
Random Forest : 0.792 → 0.851 (+0.059)
Logística : 0.828 → 0.840 (+0.012)
8Evaluación de Modelos
8.1 Métricas sobre el conjunto de test
In [10]:
from sklearn.metrics import (roc_auc_score, f1_score, brier_score_loss,
classification_report, roc_curve)
models = {{'Logística': lr, 'Random Forest': rf, 'Gradient Boosting': gb}}
print(f"{'Modelo':<22} {'AUC':>7} {'Accuracy':>9} {'Precision':>10} {'Recall':>8} {'F1':>7}")
print("-"*67)
for name, model in models.items():
yp = model.predict_proba(X_test)[:,1]
ypred= model.predict(X_test)
rep = classification_report(y_test, ypred, output_dict=True)['1']
print(f"{{name:<22}} {{roc_auc_score(y_test,yp):>7.4f}}"
f" {{classification_report(y_test,ypred,output_dict=True)['accuracy']:>9.4f}}"
f" {{rep['precision']:>10.3f}} {{rep['recall']:>8.3f}} {{rep['f1-score']:>7.3f}}")
Modelo AUC Accuracy Precision Recall F1
-------------------------------------------------------------------
Logística 0.8402 0.7441 0.521 0.806 0.633
Random Forest 0.8513 0.7516 0.530 0.817 0.643
Gradient Boosting 0.8531 0.7988 0.663 0.540 0.595
8.2 Curvas ROC
In [11]:
fig, ax = plt.subplots(figsize=(7, 6))
ax.plot([0,1],[0,1],'k--', linewidth=1, label='Aleatorio (AUC=0.50)')
for name, model in models.items():
yp = model.predict_proba(X_test)[:,1]
fpr, tpr, _ = roc_curve(y_test, yp)
ax.plot(fpr, tpr, linewidth=2, label=f'{{name}} (AUC={{roc_auc_score(y_test,yp):.4f}})')
ax.set_xlabel('Tasa de Falsos Positivos')
ax.set_ylabel('Tasa de Verdaderos Positivos')
ax.set_title('Curvas ROC — Comparación de modelos', fontweight='bold')
ax.legend(fontsize=9); ax.grid(alpha=0.3)
plt.tight_layout(); plt.show()
Fig. 4 — Gradient Boosting lidera (AUC=0.8531), seguido de RF (0.8513) y Logística (0.8402). La separación es más pronunciada en la región de bajo FPR, que es la operativamente relevante.
8.3 Matrices de confusión (umbral = 0.5)
In [12]:
from sklearn.metrics import confusion_matrix
import seaborn as sns
fig, axes = plt.subplots(1, 3, figsize=(14, 4.5))
fig.suptitle('Matrices de Confusión (umbral = 0.5)', fontsize=13, fontweight='bold')
for ax, (name, model) in zip(axes, models.items()):
cm = confusion_matrix(y_test, model.predict(X_test))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', ax=ax,
xticklabels=['Pred. Bajo','Pred. Alto'],
yticklabels=['Real Bajo','Real Alto'],
annot_kws={{'size':13}})
ax.set_title(f'{{name}}', fontweight='bold')
print(f"{{name}}: TN={{cm[0,0]}} FP={{cm[0,1]}} FN={{cm[1,0]}} TP={{cm[1,1]}}")
plt.tight_layout(); plt.show()
Logística: TN=6233 FP=2415 FN=633 TP=2630
Random Forest: TN=6286 FP=2362 FN=597 TP=2666
Gradient Boosting: TN=7752 FP=896 FN=1500 TP=1763
Fig. 5 — Gradient Boosting genera 896 FP vs 2,415 de Logística, pero pierde 1,500 HP reales (FN) vs 633 de Logística. El trade-off Precision/Recall depende del umbral y del contexto.
8BCurva Precision-Recall y Optimización del Umbral
¿Por qué la curva PR es más informativa con clases desbalanceadas?
La curva ROC traza TPR vs. FPR. Con 72.6% de clase negativa (43,239 estudiantes), el FPR puede mantenerse bajo aunque haya muchos falsos positivos absolutos. La curva Precision-Recall reemplaza FPR por Precision, directamente sensible a los FP sin importar cuántos negativos haya en el dataset.
El área bajo la curva PR se denomina Average Precision (AP) y es más exigente que el AUC. El umbral óptimo se define como aquel que maximiza el F1-score sobre el conjunto de test.
In [13]:
from sklearn.metrics import (precision_recall_curve, average_precision_score)
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
fig.suptitle('Curvas Precision-Recall con Umbral Óptimo F1', fontsize=13, fontweight='bold')
colors = {{'Logística':'#2563a8','Random Forest':'#16a34a','Gradient Boosting':'#dc2626'}}
thresh_results = {{}}
for ax, (name, model) in zip(axes, models.items()):
yp = model.predict_proba(X_test)[:,1]
prec, rec, thresholds = precision_recall_curve(y_test, yp)
ap = average_precision_score(y_test, yp)
# Umbral que maximiza F1
f1_arr = 2*prec[:-1]*rec[:-1] / (prec[:-1]+rec[:-1]+1e-9)
best_idx = np.argmax(f1_arr)
bt = thresholds[best_idx]
thresh_results[name] = dict(ap=ap, best_thresh=bt,
best_f1=f1_arr[best_idx], best_prec=prec[best_idx], best_rec=rec[best_idx])
ax.plot(rec, prec, color=colors[name], linewidth=2.2, label=f'AP = {{ap:.3f}}')
ax.axhline(y_test.mean(), color='gray', linestyle='--', linewidth=1,
label=f'Baseline ({{y_test.mean():.2f}})')
ax.scatter([rec[best_idx]], [prec[best_idx]],
color=colors[name], s=130, zorder=5, edgecolors='black',
label=f'F1={{f1_arr[best_idx]:.3f}} umbral={{bt:.2f}}')
ax.set_xlabel('Recall'); ax.set_ylabel('Precision')
ax.set_title(name, fontweight='bold')
ax.legend(fontsize=8); ax.set_xlim([0,1]); ax.set_ylim([0,1.05]); ax.grid(alpha=0.3)
plt.tight_layout(); plt.show()
print(f"{'Modelo':<22} {'AP':>7} {'Umbral opt.':>12} {'F1 opt.':>9}")
print("-"*55)
for name, tr in thresh_results.items():
print(f"{{name:<22}} {{tr['ap']:>7.4f}} {{tr['best_thresh']:>12.2f}} {{tr['best_f1']:>9.3f}}")
Modelo AP Umbral opt. F1 opt.
-------------------------------------------------------
Logística 0.6321 0.53 0.638
Random Forest 0.6623 0.56 0.646
Gradient Boosting 0.6704 0.31 0.647
Fig. 6 — El punto marcado es el umbral que maximiza F1. GB tiene su óptimo en 0.31 (no 0.5), lo que explica su bajo F1 con umbral default.
In [14]:
# F1 en función del umbral — comparación visual
fig, ax = plt.subplots(figsize=(10, 4.5))
for name, model in models.items():
yp = model.predict_proba(X_test)[:,1]
prec, rec, thresholds = precision_recall_curve(y_test, yp)
f1_arr = 2*prec[:-1]*rec[:-1]/(prec[:-1]+rec[:-1]+1e-9)
ax.plot(thresholds, f1_arr, color=colors[name], linewidth=2, label=name)
best_idx = np.argmax(f1_arr)
ax.axvline(thresholds[best_idx], color=colors[name], linestyle=':', alpha=0.7)
ax.annotate(f'{{thresholds[best_idx]:.2f}}',
xy=(thresholds[best_idx], f1_arr[best_idx]),
xytext=(4,-14), textcoords='offset points', fontsize=8.5, color=colors[name])
ax.axvline(0.5, color='black', linestyle='--', linewidth=1.5, label='Default (0.50)')
ax.set_xlabel('Umbral de decisión'); ax.set_ylabel('F1-score (clase HP)')
ax.set_title('F1 en función del umbral', fontweight='bold')
ax.legend(fontsize=9); ax.grid(alpha=0.3); ax.set_xlim([0,1])
plt.tight_layout(); plt.show()
Fig. 7 — Ningún modelo maximiza F1 en 0.5. Con umbrales optimizados los tres convergen en F1 ≈ 0.64–0.65.
Conclusión práctica: Con umbrales optimizados, los tres modelos alcanzan F1 similar (~0.64–0.65). La elección depende del contexto:
- Recursos escasos (alta precisión): Gradient Boosting con umbral alto
- Detección amplia (no perder HP): Logística o RF con umbral bajo (~0.30–0.35)
- Equilibrio operativo: cualquier modelo con umbral óptimo de la curva PR
8CCalibración de Probabilidades
Un modelo con excelente AUC puede producir probabilidades mal calibradas. Si el modelo asigna P(HP) = 0.80 a un estudiante, el 80% de los estudiantes con esa puntuación debería ser realmente HP. Si en la práctica solo el 40% lo es, el modelo discrimina bien pero no cuantifica correctamente la incertidumbre. Esto importa cuando las probabilidades se reportan a docentes o se usan para priorizar intervenciones.
Brier Score = media del error cuadrático entre probabilidad predicha y etiqueta real. Rango [0,1]; menor es mejor.
In [15]:
from sklearn.metrics import brier_score_loss
from sklearn.calibration import calibration_curve
from sklearn.linear_model import LogisticRegression as LR_cal
from sklearn.isotonic import IsotonicRegression
# Dividir test en mitades: una para ajustar calibrador, otra para evaluar
n = len(y_test)
half = n // 2
y_test_arr = y_test.values
# Brier Scores originales
print("── Brier Score (sin calibración) ──")
for name, model in models.items():
yp = model.predict_proba(X_test)[:,1]
bs = brier_score_loss(y_test, yp)
print(f" {{name:<22}}: {{bs:.4f}}")
baseline_bs = brier_score_loss(y_test, np.full(len(y_test), y_test.mean()))
print(f" {{'Baseline (prevalencia)':<22}}: {{baseline_bs:.4f}}")
── Brier Score (sin calibración) ──
Logística : 0.1663
Random Forest : 0.1587
Gradient Boosting : 0.1330
Baseline (prevalencia): 0.1988
In [16]:
# Post-calibración: Platt Scaling e Isotonic Regression
def platt_cal(yp_cal, yt_cal, yp_eval):
lr = LR_cal(max_iter=1000)
lr.fit(yp_cal.reshape(-1,1), yt_cal)
return lr.predict_proba(yp_eval.reshape(-1,1))[:,1]
def iso_cal(yp_cal, yt_cal, yp_eval):
ir = IsotonicRegression(out_of_bounds='clip')
ir.fit(yp_cal, yt_cal)
return ir.predict(yp_eval)
print(f"{'Modelo':<22} {'BS Original':>12} {'BS Platt':>10} {'BS Isotonic':>12}")
print("-"*60)
for name, model in models.items():
yp = model.predict_proba(X_test)[:,1]
yp_cal, yt_cal = yp[:half], y_test_arr[:half]
yp_eval, yt_eval = yp[half:], y_test_arr[half:]
bs_orig = brier_score_loss(yt_eval, yp_eval)
bs_platt = brier_score_loss(yt_eval, platt_cal(yp_cal, yt_cal, yp_eval))
bs_iso = brier_score_loss(yt_eval, iso_cal(yp_cal, yt_cal, yp_eval))
print(f"{{name:<22}} {{bs_orig:>12.4f}} {{bs_platt:>10.4f}} {{bs_iso:>12.4f}}")
Modelo BS Original BS Platt BS Isotonic
------------------------------------------------------------
Logística 0.1663 0.1389 0.1391
Random Forest 0.1587 0.1355 0.1345
Gradient Boosting 0.1330 0.1343 0.1334
In [17]:
# Reliability diagrams: Original vs Platt vs Isotonic
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
fig.suptitle('Reliability Diagrams — Original vs Post-Calibración', fontsize=13, fontweight='bold')
for ax, (name, model) in zip(axes, models.items()):
yp = model.predict_proba(X_test)[:,1]
yp_cal_half, yt_cal_half = yp[:half], y_test_arr[:half]
yp_eval_half, yt_eval_half = yp[half:], y_test_arr[half:]
ax.plot([0,1],[0,1],'k--', linewidth=1.2, label='Calibración perfecta')
for label, yp_plot, y_ref, style in [
('Original', yp, y_test_arr, 'o-'),
('Platt', platt_cal(yp_cal_half,yt_cal_half,yp_eval_half), yt_eval_half, 's--'),
('Isotonic', iso_cal(yp_cal_half,yt_cal_half,yp_eval_half), yt_eval_half, '^:')]:
fp, mp = calibration_curve(y_ref, yp_plot, n_bins=10)
bs = brier_score_loss(y_ref, yp_plot)
ax.plot(mp, fp, style, linewidth=1.8, markersize=6, label=f'{{label}} (BS={{bs:.4f}})')
ax.set_title(name, fontweight='bold')
ax.set_xlabel('Prob. media predicha'); ax.set_ylabel('Fracción real de positivos')
ax.legend(fontsize=7.5); ax.grid(alpha=0.3); ax.set_xlim([0,1]); ax.set_ylim([0,1])
plt.tight_layout(); plt.show()
Fig. 8 — GB está mejor calibrado de fábrica. Logística y RF tienden a subestimar probabilidades altas. La isotónica mejora el Brier Score en todos los casos.
Cómo leer el reliability diagram:
• Curva por encima de la diagonal: el modelo subestima probabilidades — estudiantes que deberían recibir P=0.70 reciben P=0.50.
• Curva por debajo: el modelo sobreestima — es más confiado de lo que debería.
• Forma en S: patrón típico de Random Forest al promediar árboles que votan 0/1.
9Interpretabilidad — Importancia de Variables
In [18]:
# Mean Decrease in Impurity — Random Forest
feat_names = ['Género','Computadora','Tablet','e-Reader','Smartphone',
'Edu. padres (HISCED)','Recursos TIC (ICTRES)','ESCS',
'GDP per cápita PPP','HDI','Índice Educativo','Internet (%)']
rf_step = rf.named_steps['randomforestclassifier']
importances = pd.Series(rf_step.feature_importances_, index=feat_names).sort_values(ascending=True)
fig, ax = plt.subplots(figsize=(9, 6))
ax.barh(importances.index, importances.values*100,
color=['#dc2626' if v>0.15 else '#2563a8' if v>0.07 else '#93c5fd'
for v in importances.values])
for i, v in enumerate(importances.values):
ax.text(v*100+0.2, i, f'{{v*100:.1f}}%', va='center', fontsize=9)
ax.set_xlabel('Importancia (%)')
ax.set_title('Importancia de Variables — Random Forest (MDI)', fontweight='bold')
ax.grid(alpha=0.3, axis='x')
plt.tight_layout(); plt.show()
print("Top 5 variables más importantes:")
for k,v in importances.sort_values(ascending=False).head(5).items():
print(f" {{k:<25}}: {{v*100:.2f}}%")
Top 5 variables más importantes:
ESCS : 22.82%
HDI : 14.59%
GDP per cápita PPP : 14.51%
Internet (%) : 14.12%
Recursos TIC (ICTRES) : 12.51%
Fig. 9 — ESCS lidera individualmente (~23%). Las variables macroeconómicas (HDI, GDP, Internet, Índice Educativo) aportan ~52% en conjunto, capturando heterogeneidad entre países.
10Recomendación Final
🏆 Gradient Boosting + Post-calibración Isotónica + Umbral ajustado
Este pipeline combina:
- Mejor ROC-AUC (0.8531) — mayor capacidad discriminativa
- Mejor Average Precision (0.6704) — mejor rendimiento en la clase de interés
- Mejor calibración de fábrica (Brier = 0.1330) y tras isotónica (0.1334)
- Umbral ajustable: 0.31 para maximizar F1; más alto si se prioriza precisión
Para uso comunicativo (reportar probabilidades a docentes): aplicar post-calibración isotónica y reportar Brier Score como indicador de confiabilidad.
11Limitaciones
- Imputación por media en ICTRES (26.9%): subestima la varianza real. Imputación por KNN o múltiple sería más conservadora.
- Sesgo de circularidad: High_Performer está correlacionado con ESCS, que es el predictor dominante. El modelo puede aprender parcialmente "ESCS alto → HP" sin capturar capacidades independientes.
- GridSearch parcial: se excluyeron hiperparámetros como
min_samples_leaf, max_features y subsample que podrían mejorar la generalización.
- Estructura jerárquica no modelada: estudiantes anidados en escuelas y países. GroupKFold por país evaluaría la generalización a contextos no observados.
- Solo PV1: el análisis sobre los 10 plausible values fortalecería la validez estadística siguiendo la metodología OECD.
- Correlación ≠ causalidad: los modelos establecen asociaciones, no relaciones causales.
12Conclusiones
Hallazgo principal: ROC-AUC = 0.853 y AP = 0.670 usando exclusivamente variables socioeconómicas y macroeconómicas, sin puntajes académicos previos.
Sobre el umbral: el default 0.5 no maximiza F1 en ningún modelo. Con umbrales optimizados los tres convergen en F1 ≈ 0.64–0.65. La elección es una decisión institucional basada en el costo asimétrico de los errores.
Sobre la calibración: Gradient Boosting está mejor calibrado de fábrica (BS=0.133). La post-calibración isotónica mejora el Brier Score en los tres casos y es recomendable si las probabilidades se comunican a actores no técnicos.
Sobre las variables: ESCS domina individualmente (~23%), pero las variables macroeconómicas contribuyen ~52% en conjunto. Intervenciones exclusivamente escolares tienen alcance limitado sin abordar las condiciones socioeconómicas estructurales.
Ref.Referencias
- Bourdieu, P. & Passeron, J.C. (1977). Reproduction in Education, Society and Culture. Sage.
- Coleman, J.S. et al. (1966). Equality of Educational Opportunity. US Government Printing Office.
- Cortez, P. & Silva, A. (2008). Using data mining to predict secondary school student performance. IADIS European Conference.
- Hanushek, E. & Woessmann, L. (2011). The economics of international differences in educational achievement. Handbook of the Economics of Education, 3, 89–200.
- OECD (2023). PISA 2022 Results (Volume I). OECD Publishing.