开发 scikit-learn 估计器#

无论您是提议将估计器纳入 scikit-learn、开发与 scikit-learn 兼容的独立包,还是为自己的项目实现自定义组件,本章都详细介绍了如何开发能与 scikit-learn 流水线(pipelines)和模型选择工具安全交互的对象。

本节详细介绍了您在构建 scikit-learn 兼容估计器时应使用和实现的公共 API。在 scikit-learn 内部,我们会试验并使用一些私有工具,我们的目标始终是在这些工具足够稳定后将其公开,以便您也能在自己的项目中使用它们。

scikit-learn 对象的 API#

主要有两种类型的估计器。您可以将第一组视为简单估计器(simple estimators),大多数估计器都属于此类,例如 LogisticRegressionRandomForestClassifier。第二组是元估计器(meta-estimators),它们是包装了其他估计器的估计器。PipelineGridSearchCV 是元估计器的两个例子。

这里我们先从一些词汇术语开始,然后演示如何实现您自己的估计器。

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

不同的对象#

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

估计器(Estimator):

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

estimator = estimator.fit(data, targets)

或者

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

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

prediction = predictor.predict(data)

分类算法通常也提供量化预测确定性的方法,要么使用 decision_function,要么使用 predict_proba

probability = predictor.predict_proba(data)
转换器(Transformer):

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

new_data = transformer.transform(data)

当拟合和转换一起执行比分开执行效率高得多时,实现

new_data = transformer.fit_transform(data)
模型(Model):

一种可以给出拟合优度(goodness of fit)度量或未见数据似然性的模型,实现(值越高越好)

score = model.score(data)

估计器#

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

estimator.fit(X, y)

在估计器实现的所有方法中,fit 通常是您想要自己实现的方法。其他方法(如 set_paramsget_params 等)在 BaseEstimator 中实现,您应该继承该类。您可能还需要从更多的混合类(mixins)继承,我们稍后会解释。

实例化#

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

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

理想情况下,__init__ 接受的参数都应是带有默认值的关键字参数。换句话说,用户应该能够在不传递任何参数的情况下实例化一个估计器。在某些情况下,如果参数没有合理的默认值,则可以不设置默认值。在 scikit-learn 内部,只有极少数情况(仅在一些元估计器中)会将子估计器参数设为必须参数。

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

此外,__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__ 方法内部设置。关于非 init 参数的属性的更多细节很快就会介绍。

拟合#

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

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

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

参数

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__ 关键字参数。理想情况下,拟合参数应限制为直接依赖于数据的变量。例如,从数据矩阵 X 预计算出的 Gram 矩阵或亲和度矩阵就是依赖于数据的。容差停止准则 tol 并不直接依赖于数据(尽管根据某些评分函数的最优值可能是)。

当调用 fit 时,之前对 fit 的任何调用都应被忽略。通常,调用 estimator.fit(X1) 然后 estimator.fit(X2) 应该与只调用 estimator.fit(X2) 相同。然而,当 fit 依赖于某些随机过程时,这在实践中可能不成立,请参阅 random_state。该规则的另一个例外是当超参数 warm_start 设置为 True 时,对于支持该功能的估计器。warm_start=True 意味着重用估计器可训练参数的前一个状态,而不是使用默认的初始化策略。

估计属性(Estimated Attributes)#

按照 scikit-learn 的约定,您希望作为公共属性向用户公开并已从数据中估计或学习出来的属性,名称必须始终以结尾的下划线结束。例如,某些回归估计器的系数在调用 fit 后将存储在 coef_ 属性中。同样,您在过程中学习到但希望存储而不向用户公开的属性,应以单个下划线开头,例如 _intermediate_coefs。您需要将第一组(以结尾下划线命名)记录为“Attributes(属性)”,而无需记录第二组(以开头下划线命名)。

当您第二次调用 fit 时,预期估计属性会被覆盖。

通用属性(Universal attributes)#

期望表格输入数据的估计器应在 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 -> 0.0
subestimator__max_iter -> 100
subestimator__n_jobs -> None
subestimator__penalty -> deprecated
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 函数例如用于在网格搜索期间设置参数。

克隆(Cloning)#

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

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

负责此行为的函数是 clone

估计器可以通过覆盖 base.BaseEstimator.__sklearn_clone__ 方法来自定义 base.clone 的行为。__sklearn_clone__ 必须返回估计器的一个实例。__sklearn_clone__ 在估计器需要在调用 base.clone 时保持某种状态时非常有用。例如,FrozenEstimator 就利用了这一点。

估计器类型#

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

转换器(Transformers)继承自 TransformerMixin,并实现 transform 方法。这些估计器获取输入并以某种方式对其进行转换。注意,它们永远不应该改变输入样本的数量,并且 transform 的输出应与其输入样本在给定的顺序上保持一致。

回归器(Regressors)继承自 RegressorMixin,并实现 predict 方法。它们在 fit 方法中应接受数值型 y。回归器默认在 score 方法中使用 r2_score

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

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

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 模块包含用于处理多类和多标签问题的有用函数。

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

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

估计器标签(Estimator Tags)#

注意

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

估计器标签是对估计器的注释,允许以编程方式检查它们的能力,例如稀疏矩阵支持、支持的输出类型和支持的方法。估计器标签是由 __sklearn_tags__ 方法返回的 Tags 的实例。这些标签用于不同的地方,例如 is_regressor,或者由 check_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, fields

@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 引入了 set_output API,用于将转换器配置为输出 pandas DataFrames。set_output API 会自动定义,如果转换器定义了 get_feature_names_out 并且子类化了 base.TransformerMixinget_feature_names_out 用于获取 pandas 输出的列名。

base.OneToOneFeatureMixinbase.ClassNamePrefixFeaturesOutMixin 是有助于定义 get_feature_names_out 的有益混合类。base.OneToOneFeatureMixin 在转换器具有输入特征和输出特征之间的一一对应关系(例如 StandardScaler)时很有用。base.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 中返回多个数组的转换器,自动包装将仅包装第一个数组,而不更改其他数组。

有关更多详细信息,请参阅用户指南,并参阅Introducing the set_output API了解如何使用此 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 notebooks)中显示它们自己的 HTML 表示。例如,我们可以显示此 HTML 图

from sklearn.base import BaseEstimator

BaseEstimator()

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

要自定义链接到估计器文档的 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)后换行。

  • 使用绝对导入

  • 单元测试应使用与客户端代码完全相同的导入方式。如果 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 上意味着哈达玛积)。

在其他情况下,请确保对传递给 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

相对公差会自动从提供的数组 dtype 中推断出来(特别是对于 float32 和 float64 dtype),但您可以通过 rtol 进行覆盖。

比较零元素数组时,请通过 atol 为绝对公差提供非零值。

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