11  Pipelines

Autor/a

Iraitz Montalbán

Terminamos el capítulo anterior con dos objetos machine sueltos y una advertencia: hay que acordarse de ajustarlos solo con entrenamiento y de aplicarlos en el orden correcto. Ese “hay que acordarse” es exactamente el tipo de garantía que no aguanta un proyecto real.

11.1 El problema de encadenar a mano

Imaginad el flujo completo: imputar faltantes, escalar, codificar categorías y entrenar. Son cuatro pasos, cada uno con parámetros aprendidos. Y ahora imaginad que queréis hacer validación cruzada con cinco particiones. Los cuatro pasos hay que repetirlos dentro de cada pliegue, ajustándolos solo con las cuatro quintas partes que hacen de entrenamiento en esa vuelta.

Hacerlo a mano son veinte ajustes en el orden correcto. Es fácil escribir un bucle que impute una sola vez fuera del bucle, y ese descuido, silencioso porque no genera ni error ni aviso, infla las métricas. La solución no es tener más cuidado, es que la estructura del código haga imposible el error.

Recuperamos los datos, esta vez sin imputar a mano
using CSV, DataFrames, MLJ, Statistics, CategoricalArrays

df = CSV.read(joinpath(data_path, "titanic.csv"), DataFrame)
select!(df, Not([:PassengerId, :Name, :Ticket, :Cabin, :Embarked]))
df.Survived = coerce(df.Survived, Multiclass)

y, X = unpack(df, ==(:Survived))

# Declarar los tipos científicos NO es aprender de los datos: es describir qué
# significa cada columna. Por eso podemos hacerlo sobre el conjunto completo.
X = coerce(X, :Pclass => OrderedFactor, :Sex => Multiclass)
levels!(X.Pclass, [3, 2, 1])   # de peor a mejor clase, como en el capítulo anterior

(Xtrain, Xtest), (ytrain, ytest) = partition(
    (X, y), 0.7, multi = true, shuffle = true, stratify = y, rng = 1234
)

schema(X)
┌────────┬────────────────────────────┬───────────────────────────────────┐
│ names   scitypes                    types                             │
├────────┼────────────────────────────┼───────────────────────────────────┤
│ Pclass │ OrderedFactor{3}           │ CategoricalValue{Int64, UInt32}   │
│ Sex    │ Multiclass{2}              │ CategoricalValue{String7, UInt32} │
│ Age    │ Union{Missing, Continuous} │ Union{Missing, Float64}           │
│ SibSp  │ Count                      │ Int64                             │
│ Parch  │ Count                      │ Int64                             │
│ Fare   │ Continuous                 │ Float64                           │
└────────┴────────────────────────────┴───────────────────────────────────┘

Fijaos en que Age conserva sus valores faltantes. Esta vez no los vamos a tocar: dejaremos que lo haga el pipeline.

Nota

Declarar tipos con coerce sobre todo el conjunto es seguro porque no calcula nada a partir de los datos, solo dice cómo hay que interpretarlos. Distinto es una media, una desviación o el conjunto de categorías observadas: eso sí se aprende, y va dentro del pipeline.

11.2 Un pipeline en Julia

Necesitamos un modelo con el que terminar la cadena. Usaremos una regresión logística, que es el punto de partida razonable en cualquier problema de clasificación binaria.

using MLJLinearModels

RegresionLogistica = @load LogisticClassifier pkg=MLJLinearModels verbosity=0
LogisticClassifier

Y ahora encadenamos. En Julia el operador |> que ya conocemos del análisis preliminar sirve también para componer modelos de MLJ:

pipe = FillImputer() |> Standardizer() |> ContinuousEncoder() |> RegresionLogistica()
ProbabilisticPipeline(
  fill_imputer = FillImputer(
        features = Symbol[], 
        continuous_fill = MLJTransforms._median, 
        count_fill = MLJTransforms._round_median, 
        finite_fill = MLJTransforms._mode), 
  standardizer = Standardizer(
        features = Symbol[], 
        ignore = false, 
        ordered_factor = false, 
        count = false), 
  continuous_encoder = ContinuousEncoder(
        drop_last = false, 
        one_hot_ordered_factors = false), 
  logistic_classifier = LogisticClassifier(
        lambda = 2.220446049250313e-16, 
        gamma = 0.0, 
        penalty = :l2, 
        fit_intercept = true, 
        penalize_intercept = false, 
        scale_penalty_with_samples = true, 
        solver = nothing), 
  cache = true)

El orden importa y merece justificarse:

  1. FillImputer rellena los faltantes. Va primero porque el resto de pasos no sabría qué hacer con un missing.
  2. Standardizer centra y escala las variables continuas. Va antes de codificar para que solo toque Age y Fare, y no las columnas binarias que creará el siguiente paso.
  3. ContinuousEncoder convierte en números continuos todo lo que queda, es decir, categorías y conteos. A diferencia del OneHotEncoder del capítulo anterior, aquí no descartamos una categoría: la regularización que la regresión logística aplica por defecto se encarga de que la redundancia no genere problemas.
  4. RegresionLogistica es el modelo propiamente dicho.

Lo importante es que el resultado es un único modelo. Se ajusta de una vez, se aplica de una vez, y se comporta ante MLJ igual que cualquier otro.

11.3 Ajustar y predecir

mach = machine(pipe, Xtrain, ytrain)
fit!(mach, verbosity = 0)

ŷ = predict_mode(mach, Xtest)
accuracy(ŷ, ytest)
0.7798507462686567

Con una llamada hemos imputado, escalado, codificado y entrenado; y al predecir sobre Xtest se han aplicado exactamente las mismas transformaciones, con los parámetros aprendidos del entrenamiento.

Ese ŷ es un identificador válido. En la REPL o en un editor con soporte de Julia se escribe y\hat seguido de tabulador. Julia admite Unicode en los nombres de variable, lo que permite que el código se parezca a la notación matemática del artículo que estás implementando: α, σ², . Con moderación, ayuda bastante a la legibilidad en código científico.

11.4 Validación cruzada

Una sola partición nos da una sola estimación, y esa estimación depende de la semilla que elegimos. La validación cruzada reparte los datos en varios pliegues y entrena tantas veces como pliegues, usando cada vez uno distinto para evaluar.

resultado = evaluate(
    pipe, X, y,
    resampling = StratifiedCV(nfolds = 5, rng = 1234),
    measures   = [log_loss, auc],
    verbosity  = 0
)

resultado
PerformanceEvaluation object with these fields:
  model, tag, measure, operation,
  measurement, uncertainty_radius_95, per_fold, per_observation,
  fitted_params_per_fold, report_per_fold,
  train_test_rows, resampling, repeats
Tag: ProbabilisticPipeline-923
Extract:
┌───┬──────────────────────┬───────────┬─────────────┐
│    measure               operation  measurement │
├───┼──────────────────────┼───────────┼─────────────┤
│ A │ LogLoss(             │ predict   │ 0.449       │
│   │   tol = 2.22045e-16) │           │             │
│ B │ AreaUnderCurve()     │ predict   │ 0.854       │
└───┴──────────────────────┴───────────┴─────────────┘
┌───┬─────────────────────────────────────┬─────────┐
│    per_fold                             1.96*SE │
├───┼─────────────────────────────────────┼─────────┤
│ A │ [0.516, 0.476, 0.396, 0.454, 0.404] │ 0.0493  │
│ B │ [0.807, 0.832, 0.902, 0.843, 0.886] │ 0.0384  │
└───┴─────────────────────────────────────┴─────────┘

Usamos StratifiedCV y no CV a secas por el mismo motivo por el que estratificamos la partición: queremos que cada pliegue conserve la proporción de supervivientes.

Las dos medidas dicen cosas distintas. log_loss penaliza las probabilidades mal calibradas, castigando sobre todo equivocarse con mucha confianza, mientras que auc mide la capacidad de ordenar correctamente los casos sin depender de dónde pongamos el umbral de decisión. Volveremos sobre la elección de métricas cuando lleguemos a la evaluación.

Además de la medida agregada, la salida trae dos columnas que conviene mirar siempre. per_fold muestra el valor obtenido en cada uno de los cinco pliegues, y 1.96*SE es el radio del intervalo de confianza al 95 % alrededor de la media. Si ese radio es del mismo orden que la diferencia entre dos modelos que estás comparando, la comparación no distingue nada: estás mirando ruido.

11.5 Por qué esto elimina la fuga

Aquí está el pago de todo el capítulo. Cuando evaluate procesa el tercer pliegue:

  1. Toma las cuatro quintas partes que hacen de entrenamiento en esa vuelta.
  2. Ajusta FillImputer solo con ellas: la mediana que usará sale de ahí.
  3. Ajusta Standardizer solo con ellas: media y desviación salen de ahí.
  4. Ajusta ContinuousEncoder y el modelo, otra vez solo con ellas.
  5. Aplica esa cadena al pliegue reservado y mide.

Y repite el proceso completo en cada vuelta. No hay forma de que una media calculada en el pliegue cinco contamine el entrenamiento del pliegue uno, porque cada vuelta reconstruye la cadena desde cero. La corrección deja de depender de la disciplina de quien escribe el código y pasa a estar garantizada por la estructura.

Comparadlo con lo que hicimos en el primer capítulo de esta parte: allí calculamos la media a mano y tuvimos que razonar con cuidado sobre de dónde salía. Con una transformación era llevadero; aquí son tres, multiplicadas por cinco pliegues.

11.6 Inspeccionar el pipeline

Que sea un único objeto no significa que sea opaco. Podemos abrirlo y ver qué aprendió cada etapa.

params = fitted_params(mach)

keys(params)
(:logistic_classifier, :continuous_encoder, :standardizer, :fill_imputer)
params.standardizer
(features_fit = [:Age, :Fare], means = (29.51338683788122, 31.347484430176568), stds = (13.09415483803606, 44.27287942095778))

Ahí están las medias y desviaciones que aprendió el escalador dentro del pipeline, las mismas que en el capítulo anterior calculábamos por separado.

Y los coeficientes del modelo:

coefs = params.logistic_classifier.coefs

for (nombre, valor) in coefs
    println(rpad(nombre, 16), lpad(round(valor, digits = 4), 10))
end
Pclass              1.0809
Sex__female         0.8007
Sex__male          -2.0413
Age                -0.4622
SibSp              -0.2601
Parch              -0.2144
Fare               -0.0037

Un coeficiente positivo empuja hacia la supervivencia y uno negativo en contra. Como escalamos las variables continuas antes de entrenar, las magnitudes son comparables entre sí, algo que no podríamos hacer si Fare siguiera en libras y Age en años.

Advertencia

Interpretar coeficientes de una regresión logística tiene más matices de los que caben aquí: signo y magnitud dependen de la codificación elegida y las variables correlacionadas se reparten el peso de formas poco intuitivas. Lo usamos como comprobación de coherencia, no como explicación causal.

11.7 Lo que hemos conseguido

Cerramos la parte de preparación con un objeto único que encapsula todo el tratamiento de los datos y el modelo, que se puede validar de forma honesta y que se puede guardar y volver a cargar tal cual.

Eso es justo lo que hace falta para la siguiente fase. Con el pipeline como unidad de trabajo, comparar modelos deja de ser el ejercicio de fontanería que supone repetir el preprocesado para cada candidato, y se convierte en sustituir la última etapa de la cadena. En la próxima parte cambiaremos esa última etapa por árboles, conjuntos de árboles y otros modelos, y aprenderemos a elegir entre ellos con criterio.