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
Autor
Yuri Martín Biardo
Asignatura
Aprendizaje Automático
Programa
Especialización en Ciencia de Datos e IA
Institución
UTEC · 2026
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ísN% HPPISA Prom.
KOR5,00063.6%527.8
USA4,55245.7%487.7
ESP5,00044.9%486.0
FIN5,00045.7%484.8
FRA5,00041.7%473.1
CHL5,00028.8%451.6
URY5,00020.2%423.6
MEX5,00010.9%407.4
ARG5,00012.6%406.3
BRA5,00012.5%399.9
JOR5,0002.0%358.3
MAR5,0001.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

VariableFaltantes% del totalTratamiento
ICTRES15,99926.9%Imputación por media
HISCED1,9493.3%Imputación por media
ESCS1,5782.6%Imputación por media
ST250Qxx (dispositivos)1,743–2,5272.9–4.2%Imputación por media
Variables macroeconómicas00.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()
Distribución puntajes
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)
ESCS y países
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()
Correlaciones
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()
ROC
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
Matrices de confusión
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
PR curves
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()
F1 vs umbral
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()
Calibración
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%
Feature importance
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

  1. Imputación por media en ICTRES (26.9%): subestima la varianza real. Imputación por KNN o múltiple sería más conservadora.
  2. 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.
  3. GridSearch parcial: se excluyeron hiperparámetros como min_samples_leaf, max_features y subsample que podrían mejorar la generalización.
  4. 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.
  5. Solo PV1: el análisis sobre los 10 plausible values fortalecería la validez estadística siguiendo la metodología OECD.
  6. 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