Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

4. Modelado con PySpark

Este capítulo corresponde a la sección 9.10.4.5 del proyecto. Se entrenan los mismos seis modelos con pyspark.ml.classification, con los mismos espacios de búsqueda y el mismo criterio de selección (ROC AUC). Se respetan todas las condiciones del enunciado:

  • Búsqueda de hiperparámetros: exclusivamente con ParamGridBuilder y CrossValidator(numFolds=3), sin bucles manuales sobre combinaciones.

  • Criterio de selección: BinaryClassificationEvaluator(metricName="areaUnderROC").

  • Configuración: la SparkSession obligatoria (spark.sql.shuffle.partitions=400, spark.default.parallelism=400, spark.driver.memory=8g, …).

  • Datos:

    • se lee el CSV completo;

    • se usa la partición común (split.parquet), no randomSplit;

    • los DataFrames se cachean después del VectorAssembler.

  • Transferencia al driver: no se usa .toPandas() ni .collect() sobre el dataset. Solo al terminar el entrenamiento y la predicción se transfieren las columnas id, default y la puntuación del conjunto de prueba, y ese tiempo se reporta por separado.

Dos decisiones de ejecución, justificadas en la sección 4.6:

  1. 12 particiones. Los DataFrames de modelado se reducen con coalesce a 12 particiones, una por núcleo lógico, antes de cachearlos. La configuración de la sesión no cambia.

  2. cacheNodeIds=True en los modelos de árbol.

En una primera ejecución con las 400 particiones heredadas de spark.sql.shuffle.partitions, la regresión logística tardó 40 minutos y el árbol de decisión 31. El bosque aleatorio avanzó 7 de 27 ajustes en 1 h 38 min, hasta que la JVM se quedó sin memoria y se cayó. Además, cada modelo se entrena en una sesión de Spark (JVM) nueva y sus resultados se guardan al terminar, para que un fallo de memoria en un modelo no invalide los demás.

4.1 Sesión de Spark, hardware y datos

Se usa el mismo equipo que en scikit-learn. Spark corre en modo local[*]: un único proceso JVM que actúa como driver y ejecutor y usa todos los núcleos lógicos. Por eso spark.executor.memory no tiene efecto y la memoria disponible la fija spark.driver.memory=8g.

Loading...
Spark 3.5.5 | master: local[*]
{'spark.sql.shuffle.partitions': '400', 'spark.default.parallelism': '400', 'spark.executor.memory': '8g', 'spark.driver.memory': '8g', 'spark.memory.fraction': '0.8', 'spark.memory.storageFraction': '0.3'}
train 1,076,248 | test 269,062 | preprocesamiento 100.6 s | particiones: train 12, test 12 | cacheados: True, True
prevalencia de default en train = 0.1996

4.2 Modelos, espacios de búsqueda y equivalencias

ModeloClase de pyspark.ml.classificationEspacio de búsquedaParámetros fijos
Regresión logísticaLogisticRegressionregParam ∈{10−6,10−5,10−4}\in \{10^{-6}, 10^{-5}, 10^{-4}\}elasticNetParam=0 (L2), standardization=False
Árbol de decisiónDecisionTreeClassifiermaxDepth ∈{5,10,15}\in \{5, 10, 15\}maxBins=32, seed=42, cacheNodeIds=True
Bosque aleatorioRandomForestClassifiernumTrees ∈{10,50,100}\in \{10, 50, 100\} × maxDepth ∈{5,10,15}\in \{5, 10, 15\}maxBins=32, seed=42, cacheNodeIds=True
Gradient boostingGBTClassifiermaxIter ∈{50,100}\in \{50, 100\} × maxDepth ∈{3,5}\in \{3, 5\}stepSize=0.1, maxBins=32, seed=42, cacheNodeIds=True
SVM linealLinearSVCregParam ∈{10−6,10−5,10−4}\in \{10^{-6}, 10^{-5}, 10^{-4}\}standardization=False
Naive BayesNaiveBayessin búsquedamodelType="gaussian"

cacheNodeIds=True no cambia el modelo. En cada nivel del árbol, Spark tiene que saber en qué nodo cae cada fila. Por defecto lo recalcula recorriendo el árbol parcial para cada fila; con cacheNodeIds guarda ese identificador en un RDD cacheado y solo lo actualiza. El ahorro crece con la profundidad.

Consideraciones de equivalencia con scikit-learn

  • Regularización. Ambos entornos usan L2 (elasticNetParam=0 en Spark). Spark minimiza 1n∑ℓi+λ2∥w∥2\tfrac1n\sum\ell_i + \tfrac{\lambda}{2}\lVert w\rVert^2 y scikit-learn C∑ℓi+12∥w∥2C\sum\ell_i + \tfrac12\lVert w\rVert^2. La relación C=1/(λn)C = 1/(\lambda n) es aproximada: CC pondera la suma de las pérdidas y regParam su promedio, y en validación cruzada cada pliegue tiene 2n/32n/3 filas.

  • standardization=False. Por defecto, Spark estandariza internamente todas las columnas, dummies incluidas, antes de aplicar la penalización. scikit-learn penaliza los coeficientes en la escala en que recibe los datos. Como las numéricas ya llegan estandarizadas (capítulo 2), se desactiva la estandarización interna para que la penalización actúe sobre la misma escala en ambos entornos.

  • Intercepto. Ninguno de los dos entornos lo regulariza en la regresión logística. En LinearSVC, liblinear (scikit-learn) sí lo regulariza y Spark no (ver sección 3.6).

  • LinearSVC. Spark minimiza la pérdida hinge en el primal con OWL-QN. scikit-learn usa loss="hinge", que solo admite el dual de liblinear.

  • Árboles. Spark discretiza cada variable continua en a lo sumo maxBins=32 intervalos (cuantiles aproximados) y solo evalúa cortes en esos bordes; scikit-learn evalúa todos los cortes exactos. Además, featureSubsetStrategy="auto" (raíz cuadrada del número de variables) y el bootstrap equivalen a los valores por defecto de RandomForestClassifier en scikit-learn.

  • Naive Bayes. Se usa la variante gaussiana, porque la multinomial y la de Bernoulli no admiten los valores negativos que produce el StandardScaler.

  • Pliegues. CrossValidator(seed=42) asigna los pliegues al azar. Los pliegues no coinciden con los de StratifiedKFold de scikit-learn, pero el conjunto de prueba sí es el mismo.

Loading...

4.3 Entrenamiento con CrossValidator

Para cada modelo, en una sesión de Spark nueva:

  1. Preprocesamiento. Se lee el CSV y se aplica el Pipeline del capítulo 2, ajustado con train. Su tiempo se registra aparte y no se suma al entrenamiento.

  2. Validación cruzada. CrossValidator(numFolds=3, seed=42) evalúa cada combinación con BinaryClassificationEvaluator(areaUnderROC) y reajusta la mejor con todo el conjunto de entrenamiento. El tiempo de entrenamiento incluye la validación cruzada completa.

  3. Puntuación continua. Es el segundo elemento de la columna probability o, en LinearSVC, de rawPrediction, extraído con vector_to_array. El BinaryClassificationEvaluator se configura sobre esa misma columna.

  4. Predicción. Se transforma el conjunto de prueba y se materializa en caché con count(). Por la evaluación perezosa de Spark, esto es lo que obliga a calcular todas las predicciones, y define el tiempo de predicción.

  5. Umbral. Se aplica el mismo criterio que en scikit-learn: el cuantil 1−π1-\pi de las puntuaciones de train, calculado con approxQuantile (error relativo 10-4) sin salir de Spark.

  6. Métricas. Accuracy, precision, recall y F1 se obtienen de la matriz de confusión, calculada con una agregación distribuida. ROC AUC y AUC-PR se calculan con BinaryClassificationEvaluator.

  7. Transferencia. Se transfieren al driver solo id, default y la puntuación del conjunto de prueba (269.062 × 3 valores), con un tiempo medido aparte.

  8. Guardado. Los resultados del modelo se guardan en data/pyspark_by_model/. Si el notebook se vuelve a ejecutar y el archivo de un modelo ya existe, se carga en lugar de reentrenarlo, con sus tiempos originales. La salida indica en cada caso si el modelo se entrenó o se cargó.

LogReg        entrenamiento   662.9 s | predicción   1.4 s | transferencia  2.7 s | AUC CV 0.7133 | AUC test 0.7135 | {'regParam': 0.0001} | cargado de data/pyspark_by_model (tiempos de su ejecución original)
DecisionTree  entrenamiento   219.3 s | predicción   1.3 s | transferencia  2.3 s | AUC CV 0.7002 | AUC test 0.7002 | {'maxDepth': 10} | cargado de data/pyspark_by_model (tiempos de su ejecución original)
RandomForest  entrenamiento  6306.3 s | predicción 100.5 s | transferencia 28.2 s | AUC CV 0.7139 | AUC test 0.7137 | {'numTrees': 100, 'maxDepth': 15} | cargado de data/pyspark_by_model (tiempos de su ejecución original)
GradBoost     entrenamiento  4845.5 s | predicción   3.7 s | transferencia  4.4 s | AUC CV 0.7187 | AUC test 0.7195 | {'maxIter': 100, 'maxDepth': 5} | entrenado en esta ejecución
LinearSVC     entrenamiento  1268.9 s | predicción   1.3 s | transferencia  2.0 s | AUC CV 0.6381 | AUC test 0.6300 | {'regParam': 1e-06} | entrenado en esta ejecución
NaiveBayes    entrenamiento   174.2 s | predicción   1.3 s | transferencia  2.0 s | AUC CV 0.6400 | AUC test 0.6395 | {} | entrenado en esta ejecución

4.4 Resultados de la validación cruzada

Loading...
Loading...

4.5 Métricas en entrenamiento y prueba

Las métricas de esta tabla se calculan íntegramente en Spark. BinaryClassificationEvaluator estima el área bajo la curva con un máximo de 1000 umbrales (numBins), así que su AUC es una aproximación muy cercana al exacto. Las pruebas estadísticas del capítulo 5 usan el AUC exacto, calculado con las puntuaciones transferidas. Por eso, al final de esta sección se comparan ambos.

Loading...
Métricas en prueba con el umbral por defecto (0,5 para probabilidades; 0 para LinearSVC):
Loading...
Loading...
<Figure size 1600x950 with 6 Axes>
<Figure size 800x700 with 1 Axes>
<Figure size 1400x450 with 2 Axes>
Loading...

4.6 Efecto de las condiciones de ejecución en el rendimiento

Este experimento justifica las dos decisiones de ejecución del capítulo y responde a la pregunta de la reflexión sobre el efecto de las condiciones obligatorias. Se mide con dos modelos de referencia, entrenados sobre el conjunto de entrenamiento completo:

Particiones y cacheNodeIds. Un RandomForestClassifier(numTrees=10, maxDepth=10) en cuatro variantes:

  • 400 particiones, que son las que hereda la unión con la partición común por spark.sql.shuffle.partitions=400;

  • 12 particiones, una por núcleo;

  • cada una de las dos anteriores, con y sin cacheNodeIds.

Caché. Una LogisticRegression(regParam=1e-5, maxIter=10) con el DataFrame cacheado frente a sin cachear. Sin caché, cada una de las 10 iteraciones del optimizador vuelve a leer el CSV y a recalcular el preprocesamiento.

Loading...
Loading...

Interpretación del experimento

El número de particiones es el factor dominante del rendimiento en modo local. Con las 400 particiones que hereda la unión con la partición común, a través de spark.sql.shuffle.partitions=400, el bosque de referencia tarda ocho veces más que con 12 particiones, y la regresión logística seis veces más. En un único equipo con 12 núcleos lógicos, 400 particiones de unas 2700 filas obligan a lanzar 400 tareas en cada pasada sobre los datos y, en el caso de los árboles, a crear y combinar 400 agregadores de estadísticas mediante treeAggregate. El costo de coordinación supera así al cómputo útil. Ese valor es razonable en un clúster con cientos de núcleos, pero no en una sola máquina. Con 12 particiones, cacheNodeIds reduce el tiempo en un 23 % adicional, hasta una aceleración total de 10,4 veces; con 400 particiones no aporta ninguna mejora, porque el costo por tarea oculta el ahorro. El caché, por último, resulta imprescindible: sin él, cada una de las diez iteraciones de la regresión logística vuelve a leer el CSV de 1,7 GB y a repetir el preprocesamiento, y el ajuste se vuelve catorce veces más lento. En una validación cruzada con cien iteraciones por ajuste, esa penalización se multiplicaría.

4.7 Resumen del capítulo

En PySpark, el mejor modelo vuelve a ser gradient boosting (GBTClassifier, 100 iteraciones y profundidad 5), con un AUC de 0,7195, un AUC-PR de 0,387 y un F1 de 0,403. Coincide con scikit-learn tanto en el modelo ganador como en sus hiperparámetros. Le siguen el bosque aleatorio (0,7137) y la regresión logística (0,7135). La concordancia entre entornos es notable. La regresión logística obtiene el mismo AUC con cuatro decimales, aunque la validación cruzada eligió un regParam distinto en cada entorno (10-4 frente a 10-6), lo que indica que el AUC de validación es prácticamente plano en esa región de regularización. En los modelos de árboles, las diferencias son de 0,002 a 0,003 puntos de AUC, atribuibles a la discretización en maxBins=32 y a la aleatoriedad del bootstrap; su significancia se evalúa en el capítulo 5. El AUC que calcula BinaryClassificationEvaluator con 1000 umbrales coincide con el exacto hasta la cuarta cifra decimal en los seis modelos, y el bosque de profundidad 15 vuelve a ser el único con una brecha apreciable entre entrenamiento y prueba (0,774 frente a 0,714).

LinearSVC constituye la gran excepción: alcanza un AUC de 0,630 en Spark, frente a 0,461 en scikit-learn. Spark minimiza la pérdida hinge en el primal con OWL-QN y sin regularizar el intercepto, y reproduce casi exactamente la referencia primal de la sección 3.6 (0,627). Su función de decisión, sin embargo, también es negativa para todos los préstamos, de modo que con el umbral nulo no predice ningún default. La pérdida hinge sigue siendo inadecuada para este problema desbalanceado; la formulación de Spark únicamente preserva mejor el ordenamiento.

El entrenamiento completo con validación cruzada tomó 3 h 45 min, frente a 2 h 57 min en scikit-learn. PySpark solo fue más rápido en gradient boosting (81 frente a 125 minutos) y, marginalmente, en LinearSVC. En la regresión logística fue 26 veces más lento (11 minutos frente a 25 segundos) y en Naive Bayes 14 veces más lento. Con un único equipo y datos que caben en memoria, cada pasada de Spark paga costos fijos de planificación, serialización y coordinación. En los algoritmos que realizan muchas pasadas baratas, como las cien iteraciones de L-BFGS de la regresión logística, ese costo domina; en los que realizan pocas pasadas costosas, como la construcción de árboles del boosting, el paralelismo interno de Spark lo compensa, mientras que GradientBoostingClassifier trabaja en un solo hilo.

Debe advertirse que los tiempos de la regresión logística, el árbol y el bosque se midieron en una ejecución en la que las JVM de los modelos anteriores no se cerraban por completo y retenían memoria sin consumir CPU. El error se corrigió en U.spark_shutdown antes de entrenar los tres modelos restantes, cuyo primer intento, en el caso de GBT, había fallado precisamente por agotamiento de la memoria del sistema. Los tiempos de esos tres modelos podrían estar ligeramente sobrestimados por la presión de memoria, en especial el del bosque.