Desarrollando estimadores de scikit-learn¶
Ya sea que estes proponiendo un estimador para su inclusión en scikit-learn, desarrollando un paquete separado compatible con scikit-learn, o implementando componentes personalizados para tus propios proyectos, este capítulo detalla cómo desarrollar objetos que interactúan de forma segura con los Pipelines de scikit-learn y las herramientas de selección de modelos.
APIs de objetos de scikit-learn¶
Para tener una API uniforme, intentamos tener una API básica común para todos los objetos. Además, para evitar la proliferación de código del framework, intentamos adoptar convenciones sencillas y limitar al mínimo el número de métodos que debe implementar un objeto.
Los elementos de la API de scikit-learn se describen de forma más definitiva en el Glosario de Términos Comunes y Elementos de la API.
Objetos diferentes¶
Los objetos principales en scikit-learn son (una clase puede implementar múltiples interfaces):
- Estimador
El objeto base, implementa un método
fit
para aprender de los datos, o bien:estimator = estimator.fit(data, targets)
o:
estimator = estimator.fit(data)
- Predictor
Para el aprendizaje supervisado, o algunos problemas no supervisados, implementa:
prediction = predictor.predict(data)
Los algoritmos de clasificación generalmente también ofrecen una manera de cuantificar la certidumbre de una predicción, ya sea usando
decision_function
opredict_proba
:probability = predictor.predict_proba(data)
- Transformador
Para filtrar o modificar los datos, de forma supervisada o no supervisada, implementa:
new_data = transformer.transform(data)
Cuando se ajusta y transforma se puede realizar de forma mucho más eficiente juntos que por separado, implementa:
new_data = transformer.fit_transform(data)
- Modelo
Un modelo que puede dar una medida de bondad de ajuste o una probabilidad de los datos no vistos, implementa (mayor es mejor):
score = model.score(data)
Estimadores¶
La API tiene un objeto predominante: el estimador. Un estimador es un objeto que se ajusta a un modelo basado en algunos datos de capacitación y es capaz de inferir algunas propiedades sobre nuevos datos. Puede ser, por ejemplo, un clasificador o un regresor. Todos los estimadores implementan el método de ajuste:
estimator.fit(X, y)
Todos los estimadores incorporados también tienen un método set_params
, que establece parámetros independientes de los datos (anulando los valores de los parámetros anteriores pasados a __init__
).
Todos los estimadores en el código base principal de scikit-learn deben heredar de sklearn.base.BaseEstimator
.
Instanciación¶
Se trata de la creación de un objeto. El método __init__
del objeto puede aceptar constantes como argumentos que determinen el comportamiento del estimador (como la constante C en los SVM). Sin embargo, no debería tomar los datos de entrenamiento como argumento, ya que esto se deja al método fit()
:
clf2 = SVC(C=2.3)
clf3 = SVC([[1, 2], [2, 3]], [-1, 1]) # WRONG!
Los argumentos aceptados por __init__
deberían ser todos argumentos de palabra clave con un valor predeterminado. En otras palabras, un usuario debe ser capaz de instanciar un estimador sin pasarle ningún argumento. Todos los argumentos deberían corresponder a hiperparámetros que describan el modelo o el problema de optimización que el estimador intenta resolver. Estos argumentos (o parámetros) iniciales son siempre recordados por el estimador. También hay que tener en cuenta que no deben documentarse en la sección «Atributos», sino en la sección «Parámetros» de ese estimador.
Además, cada argumento de palabra clave aceptado por __init__
debe corresponder a un atributo en la instancia**. Scikit-learn se basa en esto para encontrar los atributos relevantes para establecer en un estimador al hacer la selección del modelo.
Para resumir, un __init__
debería parecer así:
def __init__(self, param1=1, param2=2):
self.param1 = param1
self.param2 = param2
No debería haber lógica, ni siquiera validación de entrada, y los parámetros no deberían cambiarse. La lógica correspondiente debería ponerse donde se utilizan los parámetros, normalmente en fit
. Lo siguiente es incorrecto:
def __init__(self, param1=1, param2=2, param3=3):
# WRONG: parameters should not be modified
if param1 > 1:
param2 += 1
self.param1 = param1
# WRONG: the object's attributes should have exactly the name of
# the argument in the constructor
self.param3 = param2
La razón para posponer la validación es que la misma validación tendría que realizarse en set_params
, que se utiliza en algoritmos como GridSearchCV
.
Ajuste¶
Lo siguiente que probablemente querras hacer es estimar algunos parámetros en el modelo. Esto está implementado en el método fit()
.
El método fit()
toma los datos de entrenamiento como argumentos, que puede ser una matriz en el caso de aprendizaje no supervisado, o dos matrices en el caso de aprendizaje supervisado.
Ten en cuenta que el modelo se ajusta usando X
y y
, pero el objeto no contiene ninguna referencia a X
y y
. Sin embargo, hay algunas excepciones a esto. como en el caso de los núcleos precomputados donde estos datos deben ser almacenados para su uso por el método predeterminado.
Parámetros |
|
---|---|
X |
array-like de forma (n_samples, n_features) |
y |
array-like de forma (n_samples,) |
kwargs |
parámetros opcionales dependientes de datos |
X.shape[0]
debe ser el mismo que y.shape[0]
. Si no se cumple este requisito, se lanzará una excepción de tipo ValueError
.
y
podría ignorarse en el caso del aprendizaje no supervisado. Sin embargo, para hacer posible el uso del estimador como parte de una cadena que puede mezclar transformadores supervisados y no supervisados, incluso los estimadores no supervisados necesitan aceptar un argumento de palabra clave y=None
en la segunda posición que es simplemente ignorado por el estimador. Por la misma razón, los métodos fit_predict
, fit_transform
, score
y partial_fit
necesitan aceptar un argumento y
en la segunda posición si se implementan.
El método debe devolver el objeto (self
). Este patrón es útil para poder implementar one liners rápidos en una sesión de IPython como:
y_predicted = SVC(C=100).fit(X_train, y_train).predict(X_test)
Dependiendo de la naturaleza del algoritmo, fit
a veces también puede aceptar argumentos de palabras clave adicionales. Sin embargo, cualquier parámetro al que se le pueda asignar un valor antes de tener acceso a los datos debería ser un argumento de palabra clave __init__
. Los parámetros fit deben restringirse a las variables directamente dependientes de los datos. Por ejemplo, una matriz de Gram o una matriz de afinidad que se calculan previamente a partir de la matriz de datos X
son dependientes de los datos. Un criterio de parada de tolerancia tol
no depende directamente de los datos (aunque el valor óptimo según alguna función de puntuación probablemente lo sea).
Cuando se llama a fit
, cualquier llamada anterior a fit
debe ser ignorada. En general, llamar a estimator.fit(X1)
y luego a estimator.fit(X2)
debería ser lo mismo que llamar sólo a estimator.fit(X2)
. Sin embargo, esto puede no ser cierto en la práctica cuando fit
depende de algún proceso aleatorio, ver random_state. Otra excepción a esta regla es cuando el hiperparámetro inicio de calentamiento
se establece en Verdadero
para los estimadores que lo admiten. El parámetro warm_start=True
significa que se reutiliza el estado anterior de los parámetros entrenables del estimador en lugar de utilizar la estrategia de inicialización por defecto.
Atributos estimados¶
Los atributos que se han estimado a partir de los datos deben tener siempre un nombre que termine con subrayado final, por ejemplo, los coeficientes de algún estimador de regresión se almacenarían en un atributo coef_
después de que se haya llamado fit
.
Se espera que los atributos estimados se anulen cuando llame a fit
una segunda vez.
Argumentos opcionales¶
En algoritmos iterativos, el número de iteraciones debe ser especificado por un entero llamado n_iter
.
Atributos en pareja¶
Un estimador que acepta X
de la forma (n_samples, n_samples)
y define una propiedad _pairwise igual a True
permite la validación cruzada del conjunto de datos, por ejemplo, cuando X
es una matriz de núcleo precalculada. Específicamente, la propiedad _pairwise es usada por utils.metaestimators._safe_split
para dividir filas y columnas.
Obsoleto desde la versión 0.24: El atributo _pairwise queda obsoleto en 0.24. A partir de la versión 1.1 (cambio de nombre de la versión 0.26), se utilizará la etiqueta del estimador pairwise
.
Atributos universales¶
Los estimadores que esperan una entrada tabular deben establecer un atributo n_features_in_
en el momento de fit
para indicar el número de características que el estimador espera para las siguientes llamadas a predict
o transform
. Ver SLEP010 para más detalles.
Rodando tu propio estimador¶
Si quieres implementar un nuevo estimador que sea compatible con scikit-learn, ya sea sólo para ti o para contribuir a scikit-learn, hay varios aspectos internos de scikit-learn que debes conocer además de la API de scikit-learn descrita anteriormente. Puede comprobar si tu estimador se adhiere a la interfaz y las normas de scikit-learn ejecutando check_estimator
en una instancia. El decorador parametrize_with_checks
pytest también se puede utilizar (ver su docstring para más detalles y posibles interacciones con pytest
):
>>> from sklearn.utils.estimator_checks import check_estimator
>>> from sklearn.svm import LinearSVC
>>> check_estimator(LinearSVC()) # passes
La motivación principal para hacer que una clase sea compatible con la interfaz de estimadores de scikit-learn puede ser que quieras usarla junto con la evaluación del modelo y herramientas de selección como model_selection.GridSearchCV
y pipeline.Pipeline
.
Antes de detallar la interfaz requerida a continuación, describimos dos maneras de lograr la interfaz correcta más fácilmente.
Project template:
Proveemos una plantilla de proyecto que ayuda en la creación de paquetes Python que contengan estimadores compatibles con scikit-learn. Provee:
un repositorio git inicial con estructura de directorio de paquetes Python
una plantilla de un estimador de scikit-learn
una suite de pruebas inicial incluyendo el uso de
check_estimator
estructuras de directorios y scripts para compilar documentación y galerías de ejemplo
scripts para gestionar la integración continua (pruebas en Linux y Windows)
instrucciones para empezar a publicar en PyPi
BaseEstimator
y mezclas:
Tendemos a utilizar la «duck typing», por lo que la compilación de un estimador que sigue la API es suficiente para la compatibilidad, sin necesidad de heredar o incluso importar cualquier clase de scikit-learn.
Sin embargo, si una dependencia de scikit-learn es aceptable en su código, puedes evitar un montón de código boilerplate derivando una clase de BaseEstimator
y opcionalmente las clases mezcladas en sklearn.base
. Por ejemplo, a continuación se muestra un clasificador personalizado, con más ejemplos incluidos en scikit-learn-contrib en su plantilla del proyecto.
>>> import numpy as np
>>> from sklearn.base import BaseEstimator, ClassifierMixin
>>> from sklearn.utils.validation import check_X_y, check_array, check_is_fitted
>>> from sklearn.utils.multiclass import unique_labels
>>> from sklearn.metrics import euclidean_distances
>>> class TemplateClassifier(BaseEstimator, ClassifierMixin):
...
... def __init__(self, demo_param='demo'):
... self.demo_param = demo_param
...
... def fit(self, X, y):
...
... # Check that X and y have correct shape
... X, y = check_X_y(X, y)
... # Store the classes seen during fit
... self.classes_ = unique_labels(y)
...
... self.X_ = X
... self.y_ = y
... # Return the classifier
... return self
...
... def predict(self, X):
...
... # Check is fit had been called
... check_is_fitted(self)
...
... # Input validation
... X = check_array(X)
...
... closest = np.argmin(euclidean_distances(X, self.X_), axis=1)
... return self.y_[closest]
get_params y set_params¶
Todas las estimaciones de scikit-learn tienen las funciones get_params
y set_params
. La función get_params
no toma argumentos y devuelve un diccionario (dict) de los parámetros __init__
del estimador, junto con sus valores.
Debe tomar un argumento de palabra clave, deep
, que recibe un valor booleano que determina si el método debe devolver los parámetros de subestimadores (para la mayoría de los estimadores, esto puede ser ignorado). El valor predeterminado para deep
debe ser True
. Por ejemplo considerando el siguiente estimador:
>>> from sklearn.base import BaseEstimator
>>> from sklearn.linear_model import LogisticRegression
>>> class MyEstimator(BaseEstimator):
... def __init__(self, subestimator=None, my_extra_param="random"):
... self.subestimator = subestimator
... self.my_extra_param = my_extra_param
El parámetro deep
controlará si los parámetros del subsestimator
deben ser reportados. Así que cuando deep=True
, la salida será:
>>> my_estimator = MyEstimator(subestimator=LogisticRegression())
>>> for param, value in my_estimator.get_params(deep=True).items():
... print(f"{param} -> {value}")
my_extra_param -> random
subestimator__C -> 1.0
subestimator__class_weight -> None
subestimator__dual -> False
subestimator__fit_intercept -> True
subestimator__intercept_scaling -> 1
subestimator__l1_ratio -> None
subestimator__max_iter -> 100
subestimator__multi_class -> auto
subestimator__n_jobs -> None
subestimator__penalty -> l2
subestimator__random_state -> None
subestimator__solver -> lbfgs
subestimator__tol -> 0.0001
subestimator__verbose -> 0
subestimator__warm_start -> False
subestimator -> LogisticRegression()
A menudo, el subestimador
tiene un nombre (como, por ejemplo, los pasos nombrados en un objeto Pipeline
), en cuyo caso la clave debería convertirse en <nombre>__C
, <nombre>__class_weight
, etc.
Mientras que cuando deep=False
, la salida será:
>>> for param, value in my_estimator.get_params(deep=False).items():
... print(f"{param} -> {value}")
my_extra_param -> random
subestimator -> LogisticRegression()
Por otro lado, set_params
toma como entrada un diccionario (dict) de la forma 'parameter': value
y establece el parámetro del estimador utilizando este diccionario. El valor de retorno debe ser el propio estimador.
Mientras que el mecanismo get_params
no es esencial (ver Clonado a continuación), la función set_params
es necesaria ya que se usa para establecer parámetros durante las búsquedas en cuadrícula.
La forma más fácil de implementar estas funciones, y de obtener un método __repr__
sensato, es heredar de sklearn.base.BaseEstimator
. Si no quieres hacer que tu código dependa de scikit-learn, la forma más fácil de implementar la interfaz es:
def get_params(self, deep=True):
# suppose this estimator has parameters "alpha" and "recursive"
return {"alpha": self.alpha, "recursive": self.recursive}
def set_params(self, **parameters):
for parameter, value in parameters.items():
setattr(self, parameter, value)
return self
Parámetros e init¶
Como model_selection.GridSearchCV
utiliza set_params
para aplicar la configuración de parámetros a los estimadores, es esencial que llamar a set_params
tenga el mismo efecto que la configuración de parámetros utilizando el método __init__
. La forma más fácil y recomendada para lograr esto es no hacer ninguna validación de parámetros en __init__
. Toda la lógica detrás de los parámetros del estimador, como la traducción de argumentos de cadena en funciones, debe hacerse en fit
.
También se espera que los parámetros con la terminación _
no se establezcan dentro del método init__
. Todos y sólo los atributos públicos espablecidos por ajuste fit tienen una terminación _
. Como resultado, la existencia de los parámetros con el final _
se utiliza para comprobar si el estimador ha sido ajustado.
Clonado¶
Para su uso con el módulo model_selection
, un estimador debe soportar la base.clone
para replicar un estimador. Esto puede hacerse proporcionando un método get_params
. Si get_params
está presente, entonces clone(estimator)
será una instancia de type(estimator)
en la que set_params
ha sido llamado con clones del resultado de estimator.get_params()
.
Los objetos que no proporcionan este método serán copiados profundamente (usando la función estándar de Python copy.deepcopy
) si safe=False
es pasado a clone
.
Compatibilidad con pipeline¶
Para que un estimador pueda utilizarse junto con pipeline.Pipeline
en cualquier paso, excepto en el último, debe proporcionar una función fit
o fit_transform
. Para poder evaluar el pipeline en cualquier dato que no sea el conjunto de entrenamiento, también necesita proporcionar una función transform
. No hay requisitos especiales para el último paso de un pipeline, excepto que tiene una función fit
. Todas las funciones fit
y fit_transform
deben tomar argumentos X, y
, incluso si no se utiliza y. Del mismo modo, para que score
sea utilizable, el último paso del pipeline debe tener una función score
que acepte un y
opcional.
Tipos de estimador¶
Algunas funcionalidades comunes dependen de la clase de estimador que se pase. Por ejemplo, la validación cruzada en model_selection.GridSearchCV
y model_selection.cross_val_score
está por defecto estratificada cuando se utiliza en un clasificador, pero no en otro caso. Del mismo modo, los calificadores de precisión media que toman una predicción continua necesitan llamar a decision_function
para los clasificadores, pero a predict
para los regresores. Esta distinción entre clasificadores y regresores se implementa mediante el atributo _estimator_type
, que toma un valor de cadena. Debe ser classifier
para los clasificadores y regresor
para los regresores y clusterer
para los métodos de clustering, para que funcione como se espera. Al heredar de ClassifierMixin
, RegressorMixin
o ClusterMixin
se establecerá el atributo automáticamente. Cuando un metaestimador necesita distinguir entre tipos de estimadores, en lugar de comprobar _estimator_type
directamente, se deben utilizar ayudantes como base.is_classifier`
.
Modelos específicos¶
Los clasificadores deben aceptar argumentos y
(objetivo) para fit
que son secuencias (listas, arreglos) de cadenas o enteros. No deben asumir que las etiquetas de clase son un rango contiguo de enteros; en su lugar, deben almacenar una lista de clases en un atributo o propiedad classes_
. El orden de las etiquetas de clase en este atributo debe coincidir con el orden en que predict_proba
, predict_log_proba
y decision_function
devuelven sus valores. La forma más sencilla de conseguirlo es poner:
self.classes_, y = np.unique(y, return_inverse=True)
en fit
. Esto devuelve un nuevo y
que contiene índices de clase, en lugar de etiquetas, en el rango [0, n_classes
).
El método predict
de un clasificador debe devolver arreglos que contengan etiquetas de clase de classes_
. En un clasificador que implementa decision_function
, esto se puede lograr con:
def predict(self, X):
D = self.decision_function(X)
return self.classes_[np.argmax(D, axis=1)]
En modelos lineales, los coeficientes se almacenan en un arreglo llamado coef_
, y el término independiente se almacena en intercept_
. sklearn.linear_model._base
contiene algunas clases base y mezclas que implementan patrones de modelo lineal comunes.
El módulo sklearn.utils.multiclass
contiene funciones útiles para trabajar con problemas multiclase y multietiqueta.
Directrices de codificación¶
A continuación se presentan algunas directrices sobre cómo debe escribirse el nuevo código para su inclusión en scikit-learn, y que puede ser apropiado adoptar en proyectos externos. Por supuesto, hay casos especiales y habrá excepciones a estas reglas. Sin embargo, seguir estas reglas cuando se presenta un nuevo código facilita la revisión, por lo que el nuevo código puede ser integrado en menos tiempo.
Un código con formato uniforme hace más fácil compartir la propiedad del código. El proyecto scikit-learn trata de seguir de cerca las directrices oficiales de Python detalladas en PEP8 que detallan cómo el código debe ser formateado y sangrado. Por favor, Léelo y síguelo.
Además, añadimos las siguientes directrices:
Usa guiones bajos para separar palabras en nombres no de clase:
n_samples
en lugar densamples
.Evita múltiples sentencias en una línea. Prefiere un retorno de línea después de una sentencia de flujo de control (
if
/for
).Utiliza las importaciones relativas para las referencias dentro de scikit-learn.
Las pruebas unitarias son una excepción a la regla anterior; deben usar importaciones absolutas, exactamente como lo haría el código del cliente. Un corolario es que, si
sklearn.foo
exporta una clase o función que está implementada ensklearn.foo.bar.baz
, la prueba debe importarla desklearn.foo
.Por favor, no utilices
import *
en ningún caso. Se considera perjudicial según las recomendaciones oficiales de Python <https://docs.python.org/3.1/howto/doanddont.html#at-module-level>`_. Hace que el código sea más difícil de leer ya que el origen de los símbolos ya no está explícitamente referenciado, pero lo más importante es que impide el uso de una herramienta de análisis estático como pyflakes para encontrar automáticamente errores en scikit-learn.Usa el numpy docstring standard en todos tus docstrings.
Un buen ejemplo de código que nos gusta se puede encontrar aquí.
Validación de entrada¶
El módulo sklearn.utils
contiene varias funciones para realizar la validación y conversión de entradas. A veces, np.asarray
es suficiente para la validación; no utilices np.asanyarray
o np. tleast_2d
, ya que estos permiten pasar np.matrix
de NumPy, que tiene una API diferente (por ejemplo, *
significa producto punto en np.matrix
, pero producto de Hadamard en np.ndarray
).
En otros casos, asegúrate de llamar a check_array
en cualquier argumento array-like pasado a una función API de scikit-learn. Los parámetros exactos a utilizar dependen principalmente de si se deben aceptar las matrices scipy.sparse
y de cuáles.
Para obtener más información, consulta la página Utilidades para Desarrolladores.
Números aleatorios¶
Si su código depende de un generador de números aleatorios, no utilices numpy.random.random()
o rutinas similares. Para asegurar la repetibilidad en la comprobación de errores, la rutina debería aceptar una palabra clave random_state
y usarla para construir un objeto numpy.random.RandomState
. Ver sklearn.utils.check_random_state
en Utilidades para Desarrolladores.
Aquí hay un ejemplo simple de código usando algunas de las directrices anteriores:
from sklearn.utils import check_array, check_random_state
def choose_random_sample(X, random_state=0):
"""Choose a random point from X.
Parameters
----------
X : array-like of shape (n_samples, n_features)
An array representing the data.
random_state : int or RandomState instance, default=0
The seed of the pseudo random number generator that selects a
random sample. Pass an int for reproducible output across multiple
function calls.
See :term:`Glossary <random_state>`.
Returns
-------
x : ndarray of shape (n_features,)
A random point selected from X.
"""
X = check_array(X)
random_state = check_random_state(random_state)
i = random_state.randint(X.shape[0])
return X[i]
Si utiliza la aleatoriedad en un estimador en lugar de una función independiente, se aplican algunas directrices adicionales.
En primer lugar, el estimador debe tomar un argumento estado_aleatorio
en su __init__
con un valor predeterminado de None
. Debería almacenar el valor de ese argumento, sin modificar, en un atributo random_state
. fit
puede llamar a check_random_state
en ese atributo para obtener un generador de números aleatorios real. Si, por alguna razón, la aleatoriedad es necesaria después de fit
, el RNG debe ser almacenado en un atributo random_state_
. El siguiente ejemplo debería aclarar esto:
class GaussianNoise(BaseEstimator, TransformerMixin):
"""This estimator ignores its input and returns random Gaussian noise.
It also does not adhere to all scikit-learn conventions,
but showcases how to handle randomness.
"""
def __init__(self, n_components=100, random_state=None):
self.random_state = random_state
self.n_components = n_components
# the arguments are ignored anyway, so we make them optional
def fit(self, X=None, y=None):
self.random_state_ = check_random_state(self.random_state)
def transform(self, X):
n_samples = X.shape[0]
return self.random_state_.randn(n_samples, self.n_components)
La razón de esta configuración es la reproducibilidad: cuando un estimador es ajustado
dos veces a los mismos datos, debería producir un modelo idéntico ambas veces, de ahí la validación en fit
, no __init__
.