9  Separación de datos

Autor/a

Iraitz Montalbán

Hasta aquí hemos cargado los datos, los hemos limpiado y los hemos explorado. Sabemos que el sexo y la clase del pasajero pesan mucho en la supervivencia. El paso natural sería empezar a construir un modelo, pero antes hay una decisión que condiciona todo lo que viene después.

flowchart LR
  A[Carga] --> B[Exploración]
  B --> C[Preparación]
  C --> D[Modelado]
  D --> E[Evaluación]
  E --> F[Producción]
  F -. reentrenamiento .-> C
  style C fill:#e8d5f2,stroke:#7b3fa0,stroke-width:3px

Estamos entrando en la fase de preparación, y lo primero que hay que hacer en ella, antes de tocar una sola columna, es separar los datos.

9.1 Por qué separar

Un modelo se entrena buscando patrones en los datos que le damos. El problema es que un modelo suficientemente flexible no distingue entre aprender un patrón general y memorizar los casos concretos que ha visto. Si evaluamos ese modelo sobre los mismos datos con los que lo hemos entrenado, no estamos midiendo su capacidad de generalizar: estamos midiendo su memoria.

Lo que de verdad queremos saber es cómo se comportará ante pasajeros que nunca ha visto. Y la única forma honesta de estimarlo es reservar una parte de los datos, no dejar que participen en absoluto en el entrenamiento, y usarlos solo al final para medir.

ImportanteLa regla que ordena esta parte del libro

Cualquier cantidad que se aprenda de los datos (una media para imputar, una desviación típica para escalar, el conjunto de categorías de una variable) tiene que calcularse únicamente con los datos de entrenamiento.

En el capítulo de análisis preliminar imputamos la edad con la media de todo el conjunto. Para explorar era perfectamente razonable. A partir de ahora no lo es, y en este capítulo lo corregimos.

9.2 El punto de partida

Volvemos al fichero original en lugar de al Parquet que guardamos, precisamente porque aquel ya llevaba la edad imputada de forma que ahora sabemos incorrecta.

using CSV, DataFrames

df = CSV.read(joinpath(data_path, "titanic.csv"), DataFrame)

# Descartamos identificadores y las columnas que ya vimos problemáticas
select!(df, Not([:PassengerId, :Name, :Ticket, :Cabin, :Embarked]))

describe(df, :nmissing, :eltype)
7×3 DataFrame
Row variable nmissing eltype
Symbol Int64 Type
1 Survived 0 Int64
2 Pclass 0 Int64
3 Sex 0 String7
4 Age 177 Union{Missing, Float64}
5 SibSp 0 Int64
6 Parch 0 Int64
7 Fare 0 Float64

Fijaos en que la columna Age sigue teniendo valores faltantes. Es deliberado: los vamos a tratar después de separar.

9.3 Tipos científicos

Vamos a apoyarnos en MLJ.jl, el ecosistema de aprendizaje automático de Julia, que hace el papel que en Python hace scikit-learn.

MLJ introduce una distinción que al principio sorprende pero resulta muy útil: separa el tipo de máquina (cómo se almacena el dato: Int64, String, Float64) del tipo científico o scitype (qué representa: una cantidad continua, un conteo, una categoría). Un Int64 puede ser una edad (continua), un número de hermanos (un conteo) o un código de clase (una categoría), y el modelo debe tratarlos de forma muy distinta.

using MLJ

schema(df)
┌──────────┬────────────────────────────┬─────────────────────────┐
│ names     scitypes                    types                   │
├──────────┼────────────────────────────┼─────────────────────────┤
│ Survived │ Count                      │ Int64                   │
│ Pclass   │ Count                      │ Int64                   │
│ Sex      │ Textual                    │ String7                 │
│ Age      │ Union{Missing, Continuous} │ Union{Missing, Float64} │
│ SibSp    │ Count                      │ Int64                   │
│ Parch    │ Count                      │ Int64                   │
│ Fare     │ Continuous                 │ Float64                 │
└──────────┴────────────────────────────┴─────────────────────────┘

La columna Survived se ha leído como Int64, es decir, como un conteo. Pero para un problema de clasificación el objetivo tiene que ser una categoría, así que se lo indicamos explícitamente con coerce.

df.Survived = coerce(df.Survived, Multiclass)

scitype(df.Survived)
AbstractVector{Multiclass{2}} (alias for AbstractArray{Multiclass{2}, 1})

Profundizaremos en los tipos científicos y en el resto de columnas en el capítulo siguiente. Por ahora nos basta con el objetivo bien tipado.

9.4 Separar objetivo y características

Es habitual referirse a X como la matriz de características (features) y a y como el vector objetivo (target). La función unpack nos permite partir el DataFrame indicando qué columna es el objetivo.

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

names(X)
6-element Vector{String}:
 "Pclass"
 "Sex"
 "Age"
 "SibSp"
 "Parch"
 "Fare"

9.5 La partición

Ahora sí, separamos. partition acepta la proporción que queremos destinar a entrenamiento y nos devuelve los dos trozos.

(Xtrain, Xtest), (ytrain, ytest) = partition(
    (X, y), 0.7,
    multi   = true,   # partimos X e y a la vez, manteniendo la correspondencia
    shuffle = true,   # barajamos: el fichero podría venir ordenado
    stratify = y,     # conservamos la proporción de supervivientes
    rng     = 1234    # semilla: hace la partición reproducible
)

nrow(Xtrain), nrow(Xtest)
(623, 268)

Hay tres argumentos que merecen comentario.

9.5.1 shuffle: nunca confíes en el orden

Los ficheros llegan ordenados más veces de las que uno espera: por fecha, por identificador, por la clave con la que se exportaron. Si el conjunto estuviera ordenado por clase, un corte sin barajar dejaría toda la tercera clase en test. Barajar elimina ese riesgo.

9.5.2 rng: reproducibilidad

Sin fijar la semilla, cada ejecución produce una partición distinta y por tanto métricas distintas. Cuando alguien te diga que su modelo acierta un 82 %, la primera pregunta debería ser con qué partición. Fijar rng hace que tú y quien lea este libro obtengáis exactamente los mismos números.

Fijar la semilla sirve para reproducir un resultado, no para elegirlo. Probar semillas hasta dar con la que mejores métricas arroja es una forma sutil de engañarse: se está ajustando al conjunto de test. Si las métricas varían mucho entre semillas, eso es en sí mismo un resultado, porque significa que el conjunto es pequeño o inestable, y la respuesta es validación cruzada, que veremos en el capítulo de pipelines.

9.5.3 stratify: mantener las proporciones

Ya vimos que el conjunto está desbalanceado: alrededor de un 38 % sobrevivió. Si partimos al azar sin más, esa proporción puede desviarse en cada trozo por pura suerte. stratify fuerza a que ambos conjuntos reproduzcan la proporción del original.

using Statistics

for (nombre, conjunto) in [("completo", y), ("entrenamiento", ytrain), ("test", ytest)]
    pct = round(100 * mean(conjunto .== 1), digits = 2)
    println(rpad(nombre, 16), lpad(pct, 6), " % sobrevivió   (", length(conjunto), " filas)")
end
completo         38.38 % sobrevivió   (891 filas)
entrenamiento    38.36 % sobrevivió   (623 filas)
test             38.43 % sobrevivió   (268 filas)

Las tres proporciones coinciden dentro de una décima de punto. Sin estratificar, las desviaciones serían pequeñas en un conjunto como este, pero se vuelven un problema serio cuando la clase minoritaria es rara (detección de fraude, diagnóstico de enfermedades poco frecuentes) y un corte desafortunado puede dejar apenas un puñado de casos positivos en test.

9.6 Volviendo sobre la imputación

Ahora ya podemos tratar los valores faltantes de Age como corresponde. Comparemos primero la media que usaríamos en cada caso.

media_global = mean(skipmissing(X.Age))
media_train  = mean(skipmissing(Xtrain.Age))

println("Media de Age con TODO el conjunto  : ", round(media_global, digits = 4))
println("Media de Age solo con entrenamiento: ", round(media_train,  digits = 4))
println("Diferencia                         : ", round(abs(media_global - media_train), digits = 4))
Media de Age con TODO el conjunto  : 29.6991
Media de Age solo con entrenamiento: 29.9163
Diferencia                         : 0.2172

La diferencia es pequeña, y precisamente por eso conviene entender el argumento bien. El problema no es el tamaño del error, es de dónde viene la información. La media global incorpora las edades del conjunto de test; si la usamos para rellenar huecos en entrenamiento, el modelo recibe, de forma indirecta y difícil de detectar, información que se suponía que no debía ver. Esto es lo que se conoce como fuga de información o data leakage, y su consecuencia es que las métricas salen mejores de lo que el modelo realmente vale.

Con una media es casi inocuo. Con un escalado, una codificación de categorías o una selección de variables hecha sobre el conjunto completo, la fuga puede ser grande. La costumbre correcta es la misma en todos los casos: calcular con entrenamiento, aplicar a ambos.

Xtrain[!, :Age] = coalesce.(Xtrain.Age, media_train)
Xtest[!,  :Age] = coalesce.(Xtest.Age,  media_train)

sum(ismissing.(Xtrain.Age)), sum(ismissing.(Xtest.Age))
(0, 0)

Ya no quedan valores faltantes, y el valor con el que hemos rellenado el conjunto de test procede exclusivamente del de entrenamiento.

Nota

Fijaos en que hemos usado Xtrain[!, :Age] y no Xtrain[:, :Age]. Como vimos en el análisis preliminar, ! nos da una referencia a la columna y modifica el DataFrame; : devolvería una copia y nuestro cambio se perdería.

9.7 Lo que hemos conseguido

Tenemos cuatro objetos (Xtrain, ytrain, Xtest, ytest) con la proporción de clases preservada, la partición reproducible y la imputación hecha sin mirar el conjunto de test.

Hacer esto a mano funciona con una transformación, pero se vuelve frágil en cuanto encadenamos varias: basta olvidar un fit sobre el conjunto equivocado. En el capítulo de pipelines veremos cómo encadenarlas de forma que la separación quede garantizada por construcción y no por disciplina. Antes, en el capítulo siguiente, toca preparar las columnas para que un modelo pueda digerirlas.