开发 scikit-learn 估计器#

无论您是建议将估计器包含在 scikit-learn 中,开发与 scikit-learn 兼容的单独包,还是为自己的项目实现自定义组件,本章都详细介绍了如何开发安全地与 scikit-learn 管道和模型选择工具交互的对象。

本节详细介绍了您应该用于实现与 scikit-learn 兼容的估计器的公共 API。在 scikit-learn 本身内部,我们进行实验并使用一些私有工具,我们的目标始终是在它们足够稳定后公开它们,以便您也可以在自己的项目中使用它们。

scikit-learn 对象的 API#

主要有两种类型的估计器。您可以将第一组视为简单的估计器,它包含大多数估计器,例如 LogisticRegressionRandomForestClassifier。第二组是元估计器,它们是包装其他估计器的估计器。PipelineGridSearchCV 是元估计器的两个示例。

在这里,我们首先介绍一些词汇,然后说明如何实现您自己的估计器。

scikit-learn API 的元素在常用术语和 API 元素词汇表中进行了更明确的描述。

不同的对象#

scikit-learn 中的主要对象是(一个类可以实现多个接口)

估计器:

基本对象,实现 fit 方法以从数据中学习,或者

estimator = estimator.fit(data, targets)

或者

estimator = estimator.fit(data)
预测器:

对于监督学习或某些无监督问题,实现

prediction = predictor.predict(data)

分类算法通常还提供一种量化预测确定性的方法,可以使用 decision_functionpredict_proba

probability = predictor.predict_proba(data)
变换器:

用于以监督或无监督的方式修改数据(例如,通过添加、更改或删除列,但不添加或删除行)。实现

new_data = transformer.transform(data)

当拟合和转换可以一起比单独执行更高效时,实现

new_data = transformer.fit_transform(data)
模型:

可以给出拟合优度度量或未见数据的可能性,实现(越高越好)

score = model.score(data)

估计器#

API 有一个主要对象:估计器。估计器是一个根据一些训练数据拟合模型并能够推断新数据上某些属性的对象。例如,它可以是分类器或回归器。所有估计器都实现 fit 方法

estimator.fit(X, y)

在估计器实现的所有方法中,fit 通常是您想要自己实现的方法。其他方法,例如 set_paramsget_params 等,是在 BaseEstimator 中实现的,您应该从中继承。您可能需要继承更多 mixin,我们将在后面解释。

实例化#

这涉及对象的创建。对象的 __init__ 方法可能会接受常量作为参数,这些参数决定估计器的行为(例如 SGDClassifier 中的 alpha 常量)。但是,它不应将实际的训练数据作为参数,因为这留给了 fit() 方法

clf2 = SGDClassifier(alpha=2.3)
clf3 = SGDClassifier([[1, 2], [2, 3]], [-1, 1]) # WRONG!

理想情况下,__init__ 接受的参数都应该是具有默认值的关键字参数。换句话说,用户应该能够在不向其传递任何参数的情况下实例化估计器。在某些情况下,如果某个参数没有合理的默认值,则可以不设置默认值。在 scikit-learn 本身中,我们只有很少的地方,仅在某些元估计器中,子估计器参数是必需参数。

大多数参数对应于描述模型或估计器尝试解决的优化问题的超参数。其他参数可能会定义估计器的行为,例如定义存储某些数据的缓存的位置。这些初始参数(或超参数)始终由估计器记住。另请注意,它们不应在“属性”部分下记录,而应在该估计器的“参数”部分下记录。

此外,__init__ **接受的每个关键字参数都应该对应于实例上的一个属性**。Scikit-learn 依赖于此来查找在进行模型选择时要在估计器上设置的相关属性。

总而言之,__init__ 应该如下所示

def __init__(self, param1=1, param2=2):
    self.param1 = param1
    self.param2 = param2

不应该存在任何逻辑,甚至包括输入验证,并且参数不应该被更改;理想情况下,它们也不应该是可变对象,例如列表或字典。如果它们是可变的,则应在修改之前进行复制。相应的逻辑应该放在使用参数的地方,通常在fit方法中。以下是错误的示例

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

推迟验证的原因是,如果__init__包含输入验证,那么相同的验证就必须在set_params中执行,而set_params方法在诸如GridSearchCV之类的算法中使用。

此外,预期带有尾部下划线_的参数**不要在**__init__**方法中设置**。关于非初始化参数属性的更多详细信息将在稍后给出。

拟合#

接下来,您可能想要做的是估计模型中的某些参数。这在fit()方法中实现,并且这是训练发生的地方。例如,在这里您可以进行计算来学习或估计线性模型的系数。

fit()方法将训练数据作为参数,在无监督学习的情况下可以是一个数组,在监督学习的情况下可以是两个数组。训练数据附带的其他元数据,例如sample_weight,也可以作为关键字参数传递给fit

请注意,模型使用Xy进行拟合,但对象不保留对Xy的引用。但是,也有一些例外情况,例如在预计算核的情况下,必须存储此数据以供预测方法使用。

参数

X

形状为 (n_samples, n_features) 的类数组

y

形状为 (n_samples,) 的类数组

kwargs

可选的依赖于数据的参数

样本数,即X.shape[0]应该与y.shape[0]相同。如果不满足此要求,则应引发ValueError类型的异常。

在无监督学习的情况下,y可能会被忽略。但是,为了使估计器能够用作可以混合监督和无监督转换器的管道的一部分,即使是无监督估计器也需要在第二个位置接受一个y=None关键字参数,该参数会被估计器忽略。出于同样的原因,如果实现了fit_predictfit_transformscorepartial_fit方法,则它们需要在第二个位置接受一个y参数。

该方法应返回对象(self)。这种模式对于能够在 IPython 会话中实现快速的单行代码非常有用,例如

y_predicted = SGDClassifier(alpha=10).fit(X_train, y_train).predict(X_test)

根据算法的性质,fit有时也可以接受额外的关键字参数。但是,任何可以在访问数据之前分配值的参数都应该是__init__关键字参数。理想情况下,**fit 参数应仅限于直接依赖于数据的变量**。例如,从数据矩阵X预计算的 Gram 矩阵或亲和矩阵是依赖于数据的。容差停止准则tol并不直接依赖于数据(尽管根据某些评分函数得出的最佳值可能是)。

当调用fit时,应忽略之前对fit的任何调用。一般来说,调用estimator.fit(X1)然后调用estimator.fit(X2)应该与仅调用estimator.fit(X2)相同。但是,当fit依赖于某些随机过程时,实际上情况可能并非如此,请参见random_state。此规则的另一个例外是当超参数warm_start对于支持它的估计器设置为True时。warm_start=True表示重用估计器可训练参数的先前状态,而不是使用默认初始化策略。

估计的属性#

根据 scikit-learn 约定,您希望向用户公开为公共属性并已从数据中估计或学习的属性必须始终以尾部下划线结尾,例如,某些回归估计器的系数将在调用fit后存储在coef_属性中。类似地,您在过程中学习并想要存储但不想向用户公开的属性应该有一个前导下划线,例如_intermediate_coefs。您需要将第一组(带尾部下划线)记录为“属性”,而无需记录第二组(带前导下划线)。

当您第二次调用fit时,预计将覆盖估计的属性。

通用属性#

预期表格输入的估计器应在fit时设置n_features_in_属性,以指示估计器在后续调用predicttransform时预期的特征数量。有关详细信息,请参见SLEP010

类似地,如果估计器接收的是 pandas 或 polars 等数据框,则应设置一个 feature_names_in_ 属性来指示输入数据的特征名称,详情见 SLEP007。使用 validate_data 会自动为您设置这些属性。

创建您自己的估计器#

如果您想实现一个与 scikit-learn 兼容的新估计器,除了上面概述的 scikit-learn API 之外,您还应该了解 scikit-learn 的一些内部机制。您可以通过在一个实例上运行 check_estimator 来检查您的估计器是否符合 scikit-learn 接口和标准。 parametrize_with_checks pytest 装饰器也可以使用(有关详细信息以及与 pytest 的可能交互,请参见其文档字符串)。

>>> from sklearn.utils.estimator_checks import check_estimator
>>> from sklearn.tree import DecisionTreeClassifier
>>> check_estimator(DecisionTreeClassifier())  # passes

使一个类与 scikit-learn 估计器接口兼容的主要动机可能是您想将它与模型评估和选择工具一起使用,例如 GridSearchCVPipeline

在详细介绍下面所需的接口之前,我们描述两种更轻松地实现正确接口的方法。

您可以检查上述估计器是否通过所有常用检查。

>>> from sklearn.utils.estimator_checks import check_estimator
>>> check_estimator(TemplateClassifier())  # passes

get_params 和 set_params#

所有 scikit-learn 估计器都具有 get_paramsset_params 函数。

get_params 函数不接受任何参数,并返回估计器的 __init__ 参数及其值的字典。

它接受一个关键字参数 deep,它接收一个布尔值,用于确定该方法是否应该返回子估计器的参数(仅与元估计器相关)。deep 的默认值为 True。例如,考虑以下估计器:

>>> 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

deep 参数控制是否应该报告 subestimator 的参数。因此,当 deep=True 时,输出将为:

>>> 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 -> deprecated
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()

如果元估计器采用多个子估计器,通常这些子估计器具有名称(例如,Pipeline 对象中的命名步骤),在这种情况下,键应该变为 <name>__C<name>__class_weight 等。

deep=False 时,输出将为:

>>> for param, value in my_estimator.get_params(deep=False).items():
...     print(f"{param} -> {value}")
my_extra_param -> random
subestimator -> LogisticRegression()

另一方面,set_params__init__ 的参数作为关键字参数,将其解包到 'parameter': value 形式的字典中,并使用此字典设置估计器的参数。它返回估计器本身。

set_params 函数例如用于在网格搜索期间设置参数。

克隆#

如前所述,当构造函数参数是可变的时,应在修改它们之前复制它们。这也适用于作为估计器的构造函数参数。这就是为什么诸如 GridSearchCV 等元估计器在修改给定估计器之前会创建它的副本。

但是,在 scikit-learn 中,当我们复制一个估计器时,我们会得到一个未拟合的估计器,其中只有构造函数参数被复制(有一些例外,例如与某些内部机制相关的属性,例如元数据路由)。

负责此行为的函数是clone

估计器可以通过重写base.BaseEstimator.__sklearn_clone__方法来自定义base.clone的行为。__sklearn_clone__必须返回估计器的一个实例。__sklearn_clone__在对估计器调用base.clone时需要保留某些状态时非常有用。例如,FrozenEstimator就使用了它。

估计器类型#

在简单的估计器(与元估计器相对)中,最常见的类型是变换器、分类器、回归器和聚类算法。

变换器继承自TransformerMixin,并实现了一个transform方法。这些是接受输入并以某种方式对其进行转换的估计器。请注意,它们不应更改输入样本的数量,并且transform的输出应按相同的顺序对应于其输入样本。

回归器继承自RegressorMixin,并实现了一个predict方法。它们应该在其fit方法中接受数值y。回归器在其score方法中默认使用r2_score

分类器继承自ClassifierMixin。如果适用,分类器可以实现decision_function来返回原始决策值,predict可以根据这些值做出决策。如果支持计算概率,分类器还可以实现predict_probapredict_log_proba

分类器应该接受y(目标)参数到fit,这些参数是字符串或整数的序列(列表、数组)。它们不应该假设类标签是连续的整数范围;相反,它们应该在一个classes_属性或特性中存储一个类列表。此属性中类标签的顺序应与predict_probapredict_log_probadecision_function返回其值的顺序匹配。实现这一点最简单的方法是:

self.classes_, y = np.unique(y, return_inverse=True)

fit中。这将返回一个新的y,其中包含类索引(而不是标签),范围为[0,n_classes)。

分类器的predict方法应该返回包含来自classes_的类标签的数组。在实现decision_function的分类器中,这可以通过以下方式实现:

def predict(self, X):
    D = self.decision_function(X)
    return self.classes_[np.argmax(D, axis=1)]

multiclass模块包含用于处理多类和多标签问题的有用函数。

聚类算法继承自ClusterMixin。理想情况下,它们应该在其fit方法中接受y参数,但应忽略它。聚类算法应该设置一个labels_属性,用于存储分配给每个样本的标签。如果适用,它们还可以实现一个predict方法,返回分配给新给定样本的标签。

如果需要检查给定估计器的类型,例如在元估计器中,可以检查给定对象是否为变换器实现了transform方法,否则可以使用辅助函数,例如is_classifieris_regressor

估计器标签#

注意

Scikit-learn在0.21版本中引入了估计器标签作为私有API,主要用于测试。但是,这些标签随着时间的推移而扩展,许多第三方开发者也需要使用它们。因此,在1.6版本中,标签的API进行了改进并作为公共API公开。

估计器标签是对估计器的注释,允许以编程方式检查其功能,例如稀疏矩阵支持、支持的输出类型和支持的方法。估计器标签是Tags的一个实例,由方法__sklearn_tags__返回。这些标签用于不同的位置,例如is_regressorcheck_estimatorparametrize_with_checks运行的通用检查,其中标签确定要运行哪些检查以及什么输入数据是合适的。标签可能取决于估计器参数甚至系统架构,通常只能在运行时确定,因此是实例属性而不是类属性。有关各个标签的更多信息,请参见Tags

每个标签的默认值不太可能适合您特定估计器的需求。您可以通过定义一个返回估计器标签新值的__sklearn_tags__()方法来更改默认值。例如

class MyMultiOutputEstimator(BaseEstimator):

    def __sklearn_tags__(self):
        tags = super().__sklearn_tags__()
        tags.target_tags.single_output = False
        tags.non_deterministic = True
        return tags

如果您希望向现有集合添加新标签,可以创建Tags的新子类。请注意,您在子类中添加的所有属性都需要具有默认值。它可以采用以下形式

from dataclasses import dataclass, asdict

@dataclass
class MyTags(Tags):
    my_tag: bool = True

class MyEstimator(BaseEstimator):
    def __sklearn_tags__(self):
        tags_orig = super().__sklearn_tags__()
        as_dict = {
            field.name: getattr(tags_orig, field.name)
            for field in fields(tags_orig)
        }
        tags = MyTags(**as_dict)
        tags.my_tag = True
        return tags

用于set_output的开发者API#

使用SLEP018,scikit-learn 引入了用于配置转换器以输出 pandas DataFrame 的set_output API。set_output API 如果转换器定义了get_feature_names_out并是base.TransformerMixin的子类,则会自动定义。get_feature_names_out用于获取 pandas 输出的列名。

base.OneToOneFeatureMixinbase.ClassNamePrefixFeaturesOutMixin是定义get_feature_names_out的有用mixin。base.OneToOneFeatureMixin在转换器在输入特征和输出特征之间存在一对一对应关系时很有用,例如StandardScalerbase.ClassNamePrefixFeaturesOutMixin在转换器需要生成自己的输出特征名时很有用,例如PCA

您可以通过在定义自定义子类时设置auto_wrap_output_keys=None来选择退出set_output API

class MyTransformer(TransformerMixin, BaseEstimator, auto_wrap_output_keys=None):

    def fit(self, X, y=None):
        return self
    def transform(self, X, y=None):
        return X
    def get_feature_names_out(self, input_features=None):
        ...

auto_wrap_output_keys的默认值为("transform",),它会自动包装fit_transformtransformTransformerMixin使用__init_subclass__机制来使用auto_wrap_output_keys并将所有其他关键字参数传递给它的超类。超类的__init_subclass__**不应该**依赖于auto_wrap_output_keys

对于在transform中返回多个数组的转换器,自动包装只会包装第一个数组,而不会更改其他数组。

有关如何使用API的示例,请参见Introducing the set_output API

用于check_is_fitted的开发者API#

默认情况下,check_is_fitted检查实例中是否存在任何以尾部下划线结尾的属性,例如coef_。估计器可以通过实现一个不接受任何输入并返回布尔值的__sklearn_is_fitted__方法来更改行为。如果此方法存在,check_is_fitted只会返回其输出。

有关如何使用API的示例,请参见__sklearn_is_fitted__ as Developer API

用于HTML表示的开发者API#

警告

HTML表示API处于实验阶段,API可能会更改。

继承自BaseEstimator的估计器在Jupyter Notebook等交互式编程环境中显示自身的HTML表示。例如,我们可以显示此HTML图

from sklearn.base import BaseEstimator

BaseEstimator()

通过在估计器实例上调用函数estimator_html_repr来获得原始HTML表示。

要自定义链接到估计器文档的 URL(即单击“?”图标时),请覆盖_doc_link_module_doc_link_template属性。此外,您可以提供一个_doc_link_url_param_generator方法。将_doc_link_module设置为包含估计器的(顶级)模块的名称。如果该值与顶级模块名称不匹配,则 HTML 表示形式将不包含指向文档的链接。对于 scikit-learn 估计器,这设置为"sklearn"

_doc_link_template用于构造最终 URL。默认情况下,它可以包含两个变量:estimator_module(包含估计器的模块的全名)和estimator_name(估计器的类名)。如果您需要更多变量,则应实现_doc_link_url_param_generator方法,该方法应返回一个包含变量及其值的字典。此字典将用于呈现_doc_link_template

编码规范#

以下是关于如何编写新代码以包含在 scikit-learn 中的一些指导原则,这些原则也可能适用于外部项目。当然,存在特殊情况,这些规则也会有例外。但是,在提交新代码时遵循这些规则可以使审查更容易,从而可以更快地集成新代码。

统一格式化的代码使共享代码所有权更容易。scikit-learn 项目试图紧密遵循PEP8中详述的官方 Python 指南,这些指南详细说明了代码的格式和缩进方式。请阅读并遵循它。

此外,我们添加以下指导原则

  • 使用下划线分隔非类名中的单词:n_samples而不是nsamples

  • 避免在一行中使用多条语句。在控制流语句(if/for)之后首选换行。

  • 对 scikit-learn 内部的引用使用相对导入。

  • 单元测试是上述规则的例外;它们应该使用绝对导入,就像客户端代码一样。推论是,如果sklearn.foo导出在sklearn.foo.bar.baz中实现的类或函数,则测试应该从sklearn.foo导入它。

  • 请勿使用import *在任何情况下。它被官方 Python 建议认为是有害的。它使代码更难阅读,因为符号的来源不再被明确引用,但更重要的是,它阻止了使用像pyflakes这样的静态分析工具来自动查找 scikit-learn 中的错误。

  • 在所有文档字符串中使用numpy 文档字符串标准

我们喜欢的代码的一个很好的例子可以在这里找到这里

输入验证#

模块sklearn.utils包含各种用于执行输入验证和转换的函数。有时,np.asarray足以进行验证;不要使用np.asanyarraynp.atleast_2d,因为它们允许 NumPy 的np.matrix通过,后者具有不同的 API(例如,*np.matrix上表示点积,但在np.ndarray上表示 Hadamard 积)。

在其他情况下,请务必对传递给 scikit-learn API 函数的任何类似数组的参数调用check_array。要使用的确切参数主要取决于是否以及必须接受哪些scipy.sparse矩阵。

有关更多信息,请参阅开发人员实用程序页面。

随机数#

如果您的代码依赖于随机数生成器,请不要使用numpy.random.random()或类似的例程。为了确保错误检查的可重复性,例程应接受关键字random_state并使用它来构造一个numpy.random.RandomState对象。请参阅sklearn.utils.check_random_state,位于开发人员实用程序

这是一个使用上述一些指导原则的简单代码示例

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]

如果您在估计器而不是独立函数中使用随机性,则适用一些其他指导原则。

首先,估计器应在其__init__中接受一个random_state参数,其默认值为None。它应该存储该参数的值(未修改)在一个属性random_state中。fit可以调用check_random_state来获取实际的随机数生成器。如果由于某种原因在fit之后需要随机性,则 RNG 应存储在属性random_state_中。以下示例应该使这一点清楚

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)

这样设置的原因是为了保证可重复性:当一个估计器fit两次应用于相同的数据时,它应该两次都产生相同的模型,因此验证在fit中进行,而不是在__init__中进行。

测试中的数值断言#

当断言连续值数组的准等值时,请使用sklearn.utils._testing.assert_allclose

相对容差会根据提供的数组数据类型自动推断(特别是对于 float32 和 float64 数据类型),但您可以通过rtol来覆盖。

当比较全零元素数组时,请通过atol提供一个非零的绝对容差值。

更多信息,请参考sklearn.utils._testing.assert_allclose的文档字符串。