10. Fallas comunes y prácticas recomendadas¶
El propósito de este capítulo es ilustrar algunas fallas comunes y antipatrones que ocurren cuando se utiliza scikit-learn. Proporciona ejemplos de lo que no hacer, junto con un ejemplo correcto correspondiente.
10.1. Preprocesamiento inconsistente¶
scikit-learn proporciona una biblioteca de Transformaciones de conjuntos de datos, que puede limpiar (ver Preprocesamiento de los datos), reducir (ver Reducción de dimensionalidad no supervisada), expandir (ver Aproximación de núcleo) o generar (ver las representaciones de características de Extracción de características). Si estas transformaciones de datos se utilizan cuando se capacita un modelo, también deben ser utilizadas en conjuntos de datos subsiguientes, si se trata de datos de prueba o datos en un sistema en producción. De lo contrario, el espacio de características cambiará, y el modelo no podrá funcionar eficazmente.
Para el siguiente ejemplo, vamos a crear un conjunto de datos sintéticos con una única característica:
>>> from sklearn.datasets import make_regression
>>> from sklearn.model_selection import train_test_split
>>> random_state = 42
>>> X, y = make_regression(random_state=random_state, n_features=1, noise=1)
>>> X_train, X_test, y_train, y_test = train_test_split(
... X, y, test_size=0.4, random_state=random_state)
Incorrecto
El conjunto de datos de entrenamiento es escalado, pero no el conjunto de datos de prueba, por lo que el rendimiento del modelo en el conjunto de datos de prueba es peor de lo esperado:
>>> from sklearn.metrics import mean_squared_error
>>> from sklearn.linear_model import LinearRegression
>>> from sklearn.preprocessing import StandardScaler
>>> scaler = StandardScaler()
>>> X_train_transformed = scaler.fit_transform(X_train)
>>> model = LinearRegression().fit(X_train_transformed, y_train)
>>> mean_squared_error(y_test, model.predict(X_test))
62.80...
Correcto
En lugar de pasar el X_test
no transformado a predic
, debemos transformar los datos de prueba, de la misma manera que transformamos los datos de entrenamiento:
>>> X_test_transformed = scaler.transform(X_test)
>>> mean_squared_error(y_test, model.predict(X_test_transformed))
0.90...
Alternativamente, recomendamos usar un Pipeline
, lo que hace más fácil encadenar las transformaciones con estimadores y reduce la posibilidad de olvidar una transformación:
>>> from sklearn.pipeline import make_pipeline
>>> model = make_pipeline(StandardScaler(), LinearRegression())
>>> model.fit(X_train, y_train)
Pipeline(steps=[('standardscaler', StandardScaler()),
('linearregression', LinearRegression())])
>>> mean_squared_error(y_test, model.predict(X_test))
0.90...
Los pipelines también ayudan a evitar otra falla común: filtrar los datos de prueba en los datos de entrenamiento.
10.2. Fuga de datos¶
La fuga de datos se produce cuando al construir el modelo se utiliza información que no estaría disponible en el momento de la predicción. Esto resulta en estimaciones de rendimiento demasiado optimistas, por ejemplo de validación cruzada, y por lo tanto un rendimiento empobrecido cuando el modelo se utiliza en datos realmente nuevos, por ejemplo durante producción.
Una causa común es no mantener separados los subconjuntos de datos de prueba y entrenamiento. Los datos de prueba nunca deben utilizarse para tomar decisiones acerca del modelo. La regla general es nunca llamar fit
en los datos de prueba. Aunque esto puede parecer obvio, esto es fácil de perder en algunos casos, por ejemplo cuando se aplican ciertos pasos de preprocesamiento.
Aunque tanto los subconjuntos de datos de entrenamiento como de prueba deben recibir la misma transformación de preprocesamiento (como se describe en la sección anterior), es importante que estas transformaciones sólo se aprendan de los datos de entrenamiento. Por ejemplo, si se tiene un paso de normalización donde se divide entre el valor promedio, el promedio debe ser el promedio del subconjunto de entrenamiento, no el promedio de todos los datos. Si el subconjunto de pruebas está incluido en el cálculo promedio, la información del subconjunto de pruebas está influyendo en el modelo.
A continuación se detalla un ejemplo de fuga de datos durante el preprocesamiento.
10.2.1. Fuga de datos durante el preprocesamiento¶
Nota
Aquí elegimos ilustrar la fuga de datos con un paso de selección de características. Este riesgo de fuga es sin embargo relevante con casi todas las transformaciones en scikit-learn, incluyendo (pero no limitado a) StandardScaler
, SimpleImputer
, y PCA
.
Un número de funciones Selección de características están disponibles en scikit-learn. Pueden ayudar a eliminar características irrelevantes, redundantes y ruidosas, así como a mejorar el tiempo y el rendimiento de su modelo. Como en cualquier otro tipo de preprocesamiento, la selección de características debe sólo usar los datos de entrenamiento. Incluir los datos de prueba en la selección de características optimizará el sesgo de su modelo.
Para demostrar crearemos este problema de clasificación binaria con 10.000 características generadas aleatoriamente:
>>> import numpy as np
>>> n_samples, n_features, n_classes = 200, 10000, 2
>>> rng = np.random.RandomState(42)
>>> X = rng.standard_normal((n_samples, n_features))
>>> y = rng.choice(n_classes, n_samples)
Incorrecto
El uso de todos los datos para realizar la selección de características da como resultado una puntuación de precisión muy superior al azar, a pesar de que nuestros objetivos son completamente aleatorios. Esta aleatoriedad significa que nuestros X
y y
son independientes y por lo tanto esperamos que la precisión sea de alrededor de 0.5. Sin embargo, dado que el paso de selección de características «ve» los datos de prueba, el modelo tiene una ventaja injusta. En el ejemplo incorrecto de abajo utilizamos primero todos los datos para la selección de características y luego dividimos los datos en subconjuntos de entrenamiento y de pruebas para el ajuste del modelo. El resultado es una puntuación de precisión muy superior a la esperada:
>>> from sklearn.model_selection import train_test_split
>>> from sklearn.feature_selection import SelectKBest
>>> from sklearn.ensemble import GradientBoostingClassifier
>>> from sklearn.metrics import accuracy_score
>>> # Incorrect preprocessing: the entire data is transformed
>>> X_selected = SelectKBest(k=25).fit_transform(X, y)
>>> X_train, X_test, y_train, y_test = train_test_split(
... X_selected, y, random_state=42)
>>> gbc = GradientBoostingClassifier(random_state=1)
>>> gbc.fit(X_train, y_train)
GradientBoostingClassifier(random_state=1)
>>> y_pred = gbc.predict(X_test)
>>> accuracy_score(y_test, y_pred)
0.76
Correcto
Para evitar la fuga de datos, es una buena práctica dividir los datos en subconjuntos de entrenamiento y de prueba primero. La selección de características se puede formar utilizando sólo el conjunto de datos del entrenamiento. Ten en cuenta que cada vez que usamos fit
o fit_transform
, sólo usamos el conjunto de datos de entrenamiento. La puntuación es ahora la que cabría esperar para los datos, cercana al azar:
>>> X_train, X_test, y_train, y_test = train_test_split(
... X, y, random_state=42)
>>> select = SelectKBest(k=25)
>>> X_train_selected = select.fit_transform(X_train, y_train)
>>> gbc = GradientBoostingClassifier(random_state=1)
>>> gbc.fit(X_train_selected, y_train)
GradientBoostingClassifier(random_state=1)
>>> X_test_selected = select.transform(X_test)
>>> y_pred = gbc.predict(X_test_selected)
>>> accuracy_score(y_test, y_pred)
0.46
De nuevo, recomendamos utilizar una Pipeline
para encadenar la selección de características y los estimadores del modelo. El pipeline garantiza que sólo se utilicen los datos de entrenamiento al realizar el fit
y que los datos de prueba se utilicen únicamente para calcular la puntuación de precisión:
>>> from sklearn.pipeline import make_pipeline
>>> X_train, X_test, y_train, y_test = train_test_split(
... X, y, random_state=42)
>>> pipeline = make_pipeline(SelectKBest(k=25),
... GradientBoostingClassifier(random_state=1))
>>> pipeline.fit(X_train, y_train)
Pipeline(steps=[('selectkbest', SelectKBest(k=25)),
('gradientboostingclassifier',
GradientBoostingClassifier(random_state=1))])
>>> y_pred = pipeline.predict(X_test)
>>> accuracy_score(y_test, y_pred)
0.46
El pipeline también puede ser alimentado en una función de validación cruzada como cross_val_score
. De nuevo, el pipeline asegura que el subconjunto de datos correcto y el método de estimación se utilicen durante el ajuste y la predicción:
>>> from sklearn.model_selection import cross_val_score
>>> scores = cross_val_score(pipeline, X, y)
>>> print(f"Mean accuracy: {scores.mean():.2f}+/-{scores.std():.2f}")
Mean accuracy: 0.45+/-0.07
10.2.2. Cómo evitar fuga de datos¶
A continuación hay algunos consejos para evitar fugas de datos:
Siempre dividir los datos en subconjuntos de entrenamiento y de pruebas primero, especialmente antes de cualquier paso de preprocesamiento.
Nunca incluya datos de prueba cuando utilice los métodos
fit
yfit_transform
. Usando todos los datos, por ejemplo,fit(X)
, puede resultar en puntuaciones demasiado optimistas.Por el contrario, el método
transform
debe utilizarse tanto en el subconjunto de entrenamiento como en el de prueba, ya que debe aplicarse el mismo preprocesamiento a todos los datos. Esto puede lograrse utilizandofit_transform
en el subconjunto de entrenamiento ytransform
en el subconjunto de prueba.El scikit-learn pipeline es una gran manera de evitar la fuga de datos, ya que asegura que el método apropiado se realiza en el subconjunto de datos correcto. El pipeline es ideal para su uso en funciones de validación cruzada y de ajuste de hiperparámetros.
10.3. Control de aleatoriedad¶
Algunos objetos de scikit-learn son inherentemente aleatorios. Estos suelen ser estimadores (por ejemplo, RandomForestClassifier
) y separadores de validación cruzada (por ejemplo, KFold
). La aleatoriedad de estos objetos se controla a través de su parámetro random_state
, como se describe en el Glosario. Esta sección se expande en la entrada del glosario, y describe buenas prácticas y fallas comunes con respecto a este parámetro sutil.
Nota
Resumen de recomendaciones
Para una robustez óptima de los resultados de la validación cruzada (CV), pasa las instancias de RandomState
al crear estimadores, o deja random_state
a None
. Pasar enteros a separadores CV es generalmente la opción más segura y es preferible; pasar las instancias de RandomState
a los separadores puede ser útil a veces para lograr casos de uso muy específicos. Tanto para los estimadores como para los separadores, pasar un entero vs pasar una instancia (o None
) conduce a diferencias sutiles pero significativas, especialmente para los procedimientos de CV. Estas diferencias son importantes para entender cuando se reportan resultados.
Para resultados reproducibles a través de ejecuciones, elimine cualquier uso de random_state=None
.
10.3.1. Usando instancias de None
o RandomState
, y llamadas repetidas a fit
y split
¶
El parámetro random_state
determina si varias llamadas a fit (para estimadores) o a split (para separadores CV) producirán los mismos resultados. de acuerdo a estas reglas:
Si se pasa un entero, llamar a
fit
osplit
varias veces siempre produce los mismos resultados.Si se pasa
None
o una instanciaRandomState
:fit
ysplit
producirán diferentes resultados cada vez que se llamen, y la sucesión de llamadas explora todas las fuentes de entropía.None
es el valor predeterminado para todos los parámetrosrandom_state
.
Aquí se ilustra estas normas tanto para estimadores como para separadores CV.
Nota
Dado que pasar random_state=None
es equivalente a pasar la instancia global de RandomState
desde numpy
(random_state=np.random. trand._rand
), no mencionaremos explícitamente None
aquí. Todo lo que aplica a las instancias también se aplica al uso de None
.
10.3.1.1. Estimadores¶
Pasar instancias significa que llamar fit
varias veces no producirá los mismos resultados, incluso si el estimador se ajusta a los mismos datos y con los mismos hiperparámetros:
>>> from sklearn.linear_model import SGDClassifier
>>> from sklearn.datasets import make_classification
>>> import numpy as np
>>> rng = np.random.RandomState(0)
>>> X, y = make_classification(n_features=5, random_state=rng)
>>> sgd = SGDClassifier(random_state=rng)
>>> sgd.fit(X, y).coef_
array([[ 8.85418642, 4.79084103, -3.13077794, 8.11915045, -0.56479934]])
>>> sgd.fit(X, y).coef_
array([[ 6.70814003, 5.25291366, -7.55212743, 5.18197458, 1.37845099]])
Podemos ver desde el fragmento de código de arriba que la llamada repetidamente sgd.fit
ha producido modelos diferentes, incluso si los datos eran los mismos. Esto se debe a que el Generador de Números Aleatorios (RNG) del estimador es consumido (i.e. mutado) cuando fit
es llamado, y este RNG mutado será usado en las llamadas subsiguientes a fit
. Además, el objeto rng
se comparte a través de todos los objetos que lo usan, y como consecuencia, estos objetos se vuelven algo interdependientes. Por ejemplo, dos estimadores que comparten la misma instancia de RandomState
influirán entre sí, como veremos más adelante cuando discutamos la clonación. Este punto es importante tener en cuenta a la hora de depurar.
Si hubiéramos pasado un entero al parámetro random_state
del andomForestClassifier
, habríamos obtenido los mismos modelos, y por lo tanto los mismos puntajes cada vez. Cuando pasamos un entero, el mismo RNG se usa a través de todas las llamadas a fit
. Lo que ocurre internamente es que aunque el RNG se consume cuando se llama fit
, siempre se reinicia a su estado original al principio de fit
.
10.3.1.2. Separadores de CV (Validación cruzada)¶
Los separadores aleatorios CV tienen un comportamiento similar cuando se pasa una instancia de RandomState
; llamar a split
varias veces produce diferentes divisiones de datos:
>>> from sklearn.model_selection import KFold
>>> import numpy as np
>>> X = y = np.arange(10)
>>> rng = np.random.RandomState(0)
>>> cv = KFold(n_splits=2, shuffle=True, random_state=rng)
>>> for train, test in cv.split(X, y):
... print(train, test)
[0 3 5 6 7] [1 2 4 8 9]
[1 2 4 8 9] [0 3 5 6 7]
>>> for train, test in cv.split(X, y):
... print(train, test)
[0 4 6 7 8] [1 2 3 5 9]
[1 2 3 5 9] [0 4 6 7 8]
Podemos ver que las divisiones son diferentes de la segunda vez que se llama split
. Esto puede llevar a resultados inesperados si comparas el rendimiento de múltiples estimadores llamando a split
muchas veces, como veremos en la siguiente sección.
10.3.2. Fallas comunes y sutilezas¶
Aunque las reglas que rigen el parámetro random_state
son aparentemente sencillas, sin embargo tienen algunas implicaciones sutiles. En algunos casos, esto puede incluso conducir a conclusiones equivocadas.
10.3.2.1. Estimadores¶
Diferentes tipos de `random_state` conducen a diferentes procedimientos de validación cruzada
Dependiendo del tipo del parámetro random_state
, los estimadores se comportarán de forma diferente, especialmente en los procedimientos de validación cruzada. Considere el siguiente fragmento de código:
>>> from sklearn.ensemble import RandomForestClassifier
>>> from sklearn.datasets import make_classification
>>> from sklearn.model_selection import cross_val_score
>>> import numpy as np
>>> X, y = make_classification(random_state=0)
>>> rf_123 = RandomForestClassifier(random_state=123)
>>> cross_val_score(rf_123, X, y)
array([0.85, 0.95, 0.95, 0.9 , 0.9 ])
>>> rf_inst = RandomForestClassifier(random_state=np.random.RandomState(0))
>>> cross_val_score(rf_inst, X, y)
array([0.9 , 0.95, 0.95, 0.9 , 0.9 ])
Vemos que las puntuaciones de validación cruzada de rf_123
y rf_inst
son diferentes, como era de esperar ya que no pasamos el mismo parámetro random_state
. Sin embargo, la diferencia entre estas puntuaciones es más sutil de lo que parece, y los procedimientos de validación cruzada que fueron realizados por cross_val_score
difieren significativamente en cada caso:
Dado que a
rf_123
se le pasó un número entero, cada llamada afit
utiliza el mismo RNG: esto significa que todas las características aleatorias del estimador de bosque aleatorio serán las mismas para cada uno de los 5 pliegues del procedimiento CV. En particular, el subconjunto (elegido al azar) de características del estimador será el mismo en todos los pliegues.Como a
rf_inst
se le pasó una instancia deRandomState
, cada llamada afit
parte de un RNG diferente. Como resultado, el subconjunto aleatorio de características será diferente para cada pliegue.
Aunque tener un estimador RNG constante en todos los pliegues no es intrínsecamente incorrecto, normalmente queremos resultados de CV que sean robustos con respecto a la aleatoriedad del estimador. Como resultado, pasar una instancia en lugar de un entero puede ser preferible, ya que permitirá que el RNG del estimador varíe para cada pliegue.
Nota
Aquí, cross_val_score
usará un separador de CV no aleatoriado (como es el predeterminado), así que ambos estimadores serán evaluados en los mismos separadores. Esta sección no trata de variabilidad en los separadores. También, si pasamos un entero o una instancia a ake_classification
no es relevante para nuestro propósito de ilustración: lo que importa es lo que pasamos al estimador RandomForestClassifier
.
Clonado
Otro sutil efecto secundario de pasar instancias de RandomState
es cómo funcionará clone
:
>>> from sklearn import clone
>>> from sklearn.ensemble import RandomForestClassifier
>>> import numpy as np
>>> rng = np.random.RandomState(0)
>>> a = RandomForestClassifier(random_state=rng)
>>> b = clone(a)
Dado que se ha pasado una instancia de RandomState
a a
, a
y b
no son clones en sentido estricto, sino clones en sentido estadístico: a
y b
seguirán siendo modelos diferentes, incluso cuando se llame a fit(X, y)
con los mismos datos. Además, a
y b
se influirán mutuamente ya que comparten el mismo RNG interno: llamar a a.fit
consumirá el RNG de b
, y llamar a b.fit
consumirá el RNG de a
, ya que son el mismo. Esto es válido para cualquier estimador que comparta el parámetro random_state
; no es específico de los clones.
Si se pasara un entero, a
y b
serían clones exactos y no influirían entre sí.
Advertencia
Aunque clone
se usa raramente en el código de usuario, se llama de forma generalizada en todo el código base de scikit-learn: en particular, la mayoría de los metaestimadores que aceptan estimadores no ajustados llaman internamente a clone
(GridSearchCV
, StackingClassifier
, CalibratedClassifierCV
, etc.).
10.3.2.2. Separadores de CV (Validación cruzada)¶
Cuando se pasa una instancia de RandomState
, los separadores de CV producen diferentes divisiones cada vez que se llama a split
. Al comparar diferentes estimadores, esto puede llevar a sobreestimar la varianza de la diferencia de rendimiento entre los estimadores:
>>> from sklearn.naive_bayes import GaussianNB
>>> from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
>>> from sklearn.datasets import make_classification
>>> from sklearn.model_selection import KFold
>>> from sklearn.model_selection import cross_val_score
>>> import numpy as np
>>> rng = np.random.RandomState(0)
>>> X, y = make_classification(random_state=rng)
>>> cv = KFold(shuffle=True, random_state=rng)
>>> lda = LinearDiscriminantAnalysis()
>>> nb = GaussianNB()
>>> for est in (lda, nb):
... print(cross_val_score(est, X, y, cv=cv))
[0.8 0.75 0.75 0.7 0.85]
[0.85 0.95 0.95 0.85 0.95]
Comparar directamente el rendimiento del estimador LinearDiscriminantAnalysis
frente al estimador GaussianNB
en cada pliegue sería un error: las divisiones en las que se evalúan los estimadores son diferentes. De hecho, cross_val_score
llamará internamente a cv.split
en la misma instancia KFold
, pero las divisiones serán diferentes cada vez. Esto también es cierto para cualquier herramienta que realice la selección del modelo a través de la validación cruzada, por ejemplo, GridSearchCV
y RandomizedSearchCV
: las puntuaciones no son comparables pliegue a pliegue a través de diferentes llamadas a search.fit
, ya que cv.split
habría sido llamado varias veces. Sin embargo, dentro de una sola llamada a search.fit
, la comparación entre pliegues es posible ya que el estimador de búsqueda sólo llama a cv.split
una vez.
Para obtener resultados comparables entre pliegues en todos los escenarios, se debe pasar un número entero al separador de CV: cv = KFold(shuffle=True, random_state=0)
.
Nota
Aunque la comparación entre pliegues no es aconsejable con las instancias de RandomState
, se puede esperar que las puntuaciones medias permitan concluir si un estimador es mejor que otro, siempre que se utilicen suficientes pliegues y datos.
Nota
Lo que importa en este ejemplo es lo que se pasó a KFold
. Si pasamos una instancia de RandomState
o un entero a make_classification
no es relevante para nuestro propósito de ilustración. Además, ni LinearDiscriminantAnalysis
ni GaussianNB
son estimadores aleatorios.
10.3.3. Recomendaciones generales¶
10.3.3.1. Obteniendo resultados reproducibles a través de múltiples ejecuciones¶
Para obtener resultados reproducibles (es decir, constantes) a través de múltiples ejecuciones del programa, necesitamos eliminar todos los usos de random_state=None
, que es el valor por defecto. La forma recomendada es declarar una variable rng
en la parte superior del programa, y pasarla a cualquier objeto que acepte un parámetro random_state
:
>>> from sklearn.ensemble import RandomForestClassifier
>>> from sklearn.datasets import make_classification
>>> from sklearn.model_selection import train_test_split
>>> import numpy as np
>>> rng = np.random.RandomState(0)
>>> X, y = make_classification(random_state=rng)
>>> rf = RandomForestClassifier(random_state=rng)
>>> X_train, X_test, y_train, y_test = train_test_split(X, y,
... random_state=rng)
>>> rf.fit(X_train, y_train).score(X_test, y_test)
0.84
Ahora tenemos la garantía que el resultado de este script será siempre 0.84, sin importar cuántas veces lo ejecutemos. Cambiar la variable global rng
a un valor diferente debería afectar los resultados, como se esperaba.
También es posible declarar la variable rng
como un entero. Sin embargo, esto puede llevar a resultados de validación cruzada menos robustos, como veremos en la siguiente sección.
Nota
No recomendamos establecer la semilla global numpy
llamando np.random.seed(0)
. Ver aquí para una discusión.
10.3.3.2. Robustez de los resultados de validación cruzada¶
Cuando evaluamos el rendimiento de un estimador aleatorizado mediante validación cruzada, queremos asegurarnos de que el estimador puede producir predicciones precisas para los nuevos datos, pero también queremos asegurarnos de que el estimador es robusto con respecto a su inicialización aleatoria. Por ejemplo, nos gustaría que la inicialización de las ponderaciones aleatorias de un SGDCLassifier
fuera consistentemente buena en todos los pliegues: de lo contrario, cuando entrenemos ese estimador con nuevos datos, podríamos tener mala suerte y la inicialización aleatoria podría conducir a un mal rendimiento. Del mismo modo, queremos que un bosque aleatorio sea robusto con respecto al conjunto de características seleccionadas aleatoriamente que utilizará cada árbol.
Por estas razones, es preferible evaluar el rendimiento de la validación cruzada dejando que el estimador utilice un RNG diferente en cada pliegue. Esto se hace pasando una instancia de RandomState
(o None
) a la inicialización del estimador.
Cuando pasamos un número entero, el estimador utilizará el mismo RNG en cada pliegue: si el estimador funciona bien (o mal), según la evaluación de CV, puede ser sólo porque tuvimos suerte (o mala suerte) con esa semilla específica. Pasar instancias conduce a resultados de CV más robustos, y hace que la comparación entre varios algoritmos sea más justa. También ayuda a limitar la tentación de tratar el RNG del estimador como un hiperparámetro que se puede ajustar.
El hecho de que pasemos instancias de RandomState
o enteros a los divisores de CV no tiene ningún impacto en la robustez, siempre y cuando se llame a split
sólo una vez. Cuando se llama a split
varias veces, la comparación entre pliegues ya no es posible. Como resultado, pasar enteros a los separadores CV es normalmente más seguro y cubre la mayoría de los casos de uso.