开发 scikit-learn 估计器#

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

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

scikit-learn 对象的 API#

估计器主要有两种类型。您可以将第一组视为简单估计器,它们包含大多数估计器,例如 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_functionpredict_proba

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

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

new_data = transformer.transform(data)

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

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

一个能够提供拟合优度度量或未见数据似然度的模型,实现(越高越好)

score = model.score(data)

估计器(Estimators)#

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_paramsGridSearchCV 等算法中使用。

此外,带有末尾下划线 _ 的参数**不应在 __init__ 方法内部设置**。有关非初始化参数属性的更多详细信息将很快提供。

拟合(Fitting)#

接下来您可能想做的就是估计模型中的一些参数。这在 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 可能会被忽略。然而,为了能够将估计器作为可混合有监督和无监督转换器(transformer)的流水线(pipeline)的一部分使用,即使是无监督估计器也需要在第二个位置接受一个 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 的估计器,当超参数 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 -> 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 函数用于在网格搜索期间设置参数。

克隆(Cloning)#

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

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

负责此行为的函数是 clone

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

估计器类型#

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

转换器(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 返回其值的顺序一致。实现此目的最简单的方法是放置:

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 方法,返回分配给新给定样本的标签。

如果需要检查给定估计器的类型,例如在元估计器中,可以检查给定对象是否实现了转换器(transformers)的 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, 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 引入了 set_output API,用于配置转换器(transformer)以输出 pandas DataFrame。set_output API 在转换器定义了 get_feature_names_out 并继承自 base.TransformerMixin 时自动定义。get_feature_names_out 用于获取 pandas 输出的列名。

base.OneToOneFeatureMixinbase.ClassNamePrefixFeaturesOutMixin 是定义 get_feature_names_out 的有用混合类(mixins)。当转换器(transformer)的输入特征和输出特征之间存在一对一对应关系时,例如 StandardScalerbase.OneToOneFeatureMixin 会很有用。当转换器需要生成自己的输出特征名称时,例如 PCAbase.ClassNamePrefixFeaturesOutMixin 会很有用。

您可以通过在定义自定义子类时设置 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 的示例,请参阅set_output API 简介

check_is_fitted 的开发者 API#

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

有关如何使用该 API 的示例,请参阅将 __sklearn_is_fitted__ 作为开发者 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)后优先换行。

  • 在 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 通过,而 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 的文档字符串。