Python 接口:为什么数据科学家应该关心?
类接口、抽象层、继承,这不是软件开发人员的问题吗?作为数据科学家,你为什么要关心?
接口使几乎所有我们最喜欢的数据科学库成为可能。这是一个足够好的理由,至少对我来说,关心。但是,让我们深入探讨这个主题。在本故事的上下文中,接口是一个面向对象 (OO) 的概念,用于定义其他对象的属性和行为。
当我们要设计一个软件时,接口很方便:
- 依赖于尚不存在但将来会存在的对象(例如,插件或用户定义的对象)
- 允许具有相同核心行为但功能和内部逻辑略有不同的可互换对象
- 将核心逻辑与外部依赖项(例如数据库或外部 API)隔离开来
这些想法听起来很有希望,但老实说,它们听起来更像是软件开发人员的行话(实际上是一个 OO 软件开发人员)。作为数据科学家,你为什么要关心?
- 我们喜欢的大多数库,例如 Keras 或 scikit-learn,都使用接口来定义模型属性。它们中的大多数允许您在模型中编写自定义对象。
- 如果您需要使用与 sklearn 的 API 一样令人愉悦的 API 编写自己的工具,那么您将需要接口。
- 在 Python 中,一切都是对象,因此必须了解 OO 的基础知识。
Why an Interface?
思考以下问题。我们有两个具有拟合和预测方法的模型(每个模型一个类,Layer1 和 Layer2)。我们还有另一个模型(另一个类,LayeredModel),它采用两个 Layer 模型并以某种方式组合它们。此时,我们测试您的代码,一切正常;或者是吗?
尽管 LayeredModel 有效,但它有几个弱点:
- 如果您更改 Layer1 或 Layer2 中的某些内容,您很可能也需要更改 LayerdModel;这是灾难的秘诀。
- 两个模型,Layer1 和 Layer2,具有相同的行为(拟合和预测),但没有明确的方式来说明这一点。
- 如果我们想添加更多层会发生什么?
- 如果 LayerdModel 使用了层,那么让 LayerdModel 本身就是一个层不是很有意义吗?这样做将允许构建更复杂的组合。
我们如何解决这些问题?
Enter the interface.
我们为模型定义了一个接口(BaseLayer)。复合模型,例如 LayeredModel,将依赖于实现此类接口的模型。然后我们对这些层进行编码,使它们都实现(遵守)接口。这样做可以解决我们的大部分问题。
唯一缺少的是让 LayerdModel 也实现接口,我们将创建一种简单而干净的方法来构建可堆叠模型的管道。
在 Python 中定义和使用接口的方法有很多种,它们各有优缺点。在这个故事中,我们将回顾最常用的接口声明方式,并介绍一些示例和常见模式。
Story Structure
- Declaring interfaces
– Informal interface
– 抽象基类(ABC)
– Protocol
– zope.interface
– 优缺点总结 - 使用 ABC 构建复杂模型
- 使用协议构建复杂模型
- ABC: partial implementation
- ABC vs. 协议和多重继承问题
- Final words
声明接口——非正式接口
在 Python 中定义接口的最简单方法是通过常规类;以下示例定义了一个类,用作 sklearn 的 API 样式模型的接口:
您可以看到 fit 和 predict 方法没有实现,只是根据类型(注释)和文档字符串来描述它们应该做什么。
我们将通过从接口继承并覆盖 fit 和 predict 方法来使用接口。我们想假设从接口继承的所有类都实现了接口:
这种方法有一个严重的缺点;如果我们从接口继承但什么都不做(“通过”),那么子类将拥有方法,但它们不会被实现(它们返回 None)。因此,我们不能假设所有子类都实现了该接口。这种行为可能会导致我们的代码出现问题:
解决这个问题的一种方法是定义一个更强大的接口,我们在所有方法中引发“NotImplementedError”异常。这样做不会让我们重复相同的继承反模式,然后什么都不做(“通过”):
这种声明接口的方式(非正式)有一个明显的缺点。除了在名称中包含单词 interface 之外,没有明确的方法可以说明该类是一个接口。请记住,我们使用接口来使代码更加清晰。另一个缺点是这些接口类仍然可以像普通类一样实例化,这不是很好。
声明接口——抽象基类(ABC)
来自 abc 模块的 Python 的 ABC(抽象基类)解决了大多数由非正式接口引起的问题。
为了创建相同的接口,我们创建一个类并从 ABC 继承,然后对未实现的方法使用 abstractmethod 装饰器:
通过这样做,我们实现了:
- 清晰:很明显,这不是一堂普通的课;它显然是一个抽象层,旨在被继承。
- 实现约束:如果我们从接口继承而不实现方法,则会引发错误。
- 实例约束:如果我们尝试实例化接口,将会引发异常。
实施约束示例:
正确实现示例:
声明接口时首选使用抽象基类,但只有一个小警告。它们太笼统了。 ABC 可以用于许多其他事情。什么时候 ABC 只是一个接口或更多的东西并不总是很清楚(我们将在下一节中对此进行研究)。
Declaring Interfaces — Protocol
协议是关于接口的新手。它首先出现在类型模块中用于 Python 3.8 的 PEP 544。它是一种定义接口的隐式方式;但是,它仅在使用类型提示和进行静态类型检查(例如 mypy)时才有用。不过,我发现这种声明接口的方式是我个人最喜欢的。无论如何,如果你现在没有使用类型提示和静态类型检查,你可能应该这样做。
声明接口的方式和ABC非常相似,但是我们继承自Protocol,不使用abatractmethod装饰器。
要实现接口,我们只需遵循协议类型:
您可以看到,没有任何参考(除了文档字符串)这个类与接口有某种关联。
这是另一个例子:
这是一个不遵守协议的类的示例:
如您所见,没有实际引用该协议;事实上,你可以问问自己,协议之类的东西有什么用。
最后,这里是 Protocol 的使用。
现在让我们定义一些使用模型接口(协议)的代码(带有静态类型信息),在这种情况下,是一个函数:
然后使用带有 DummyLayer 和 AnotherDummyLayer 的函数,如果我们进行静态类型检查,一切都很好。但是,如果我们使用 WrongDummyLayer 作为函数参数,则会出现错误;因为我们将函数的参数声明为符合协议,而 WrongDummyLayer 不遵守它。
对 Protocol 的主要批评是它不符合 Python 之禅(PEP 20)[0]
… 显式优于隐式…
— Zen of Python
声明接口——zope.interface
界面之旅的最后一站是 zope.interface。它是一个第三方库。我没有使用过这种声明接口的方式;但是,出于遗留原因,我觉得有必要将其包括在内。它从 Python 2 开始就存在了。
下面是我们声明接口的方式:
我们继承自 zope.interface.Interface。到目前为止,一切都很好。我们使用 zope.interface.implementer 装饰器来声明某个类实现了接口:
@zope.interface.implementer(BaseLayer)
class ProblemDummyLayer:
"""
Concrete implementation of the BaseLayer interface.
The fit method return type checking raised an error in
static type checking (mypy).
"""
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""Do nothing, return self."""
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""Do nothing return all ones."""
return np.ones(X.shape[0])
这是我发现第一个问题的地方。在这种特殊情况下,fit 方法返回类本身的一个实例(“return self”);目的是像 fit().predict() 那样进行链接。在前面的例子中,实现者的 fit 方法的返回对象的类型是接口类型。静态类型检查没有错误。然而,在本示例中,存在错误。我们必须将其修改为:
@zope.interface.implementer(BaseLayer)
class DummyLayer:
"""Concrete implementation of the BaseLayer interface."""
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> DummyLayer:
"""Do nothing, return self."""
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""Do nothing return all ones."""
return np.ones(X.shape[0])
# instance of the DummyLayer class with init
dummy_layer = DummyLayer()
X = np.asarray([[1, 2, 3], [4, 5, 6]])
y = np.asarray([7, 8])
# fit dummy model layer
dummy_layer.fit(X, y)
# predict y
y_hat = dummy_layer.predict(X)
# returns all ones [1. 1.]
这听起来不像是一件大事,但确实如此。就类型而言,说一个类实现了一个接口,就是说实现的和接口是同一个类型。所以一般来说,我们想说 fit 方法返回一个接口类型的对象。
声明接口——优缺点总结
Informal interface pros:
- intuitive
- 不需要依赖
Informal interface cons:
- 声明接口时意图不是很清楚
ABC pros:
- intuitive
- great functionality
ABC cons:
- 很难说基类只是一个接口还是一个更通用的抽象层,因此是另一种设计模式
Protocol pros:
- simple
- intuitive
- elegant
Protocol cons:
- 隐式(与 Python 的禅宗相混淆)
- 没有静态类型检查没用
zope.inteface pros:
- 意图非常明确,名字说的是“界面”
zope.interface cons:
- third-party dependency
- 类型提示不太好
使用 ABC 构建复杂模型
现在我们来看一个使用 ABC 声明的接口的实际示例。让我们定义我们的 BaseLayer 接口;此界面与前面的示例非常相似:
from __future__ import annotations
from abc import ABC, abstractmethod
import numpy as np
import pandas as pd
class BaseLayer(ABC):
"""This the model base layer. All model layers inherit from it."""
@property
@abstractmethod
def name(self) -> str:
"""Returns the name of the model layer."""
@abstractmethod
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""
- X is either a numpy 2D array or pandas DataFrame with columns as
features and rows as sample points.
- y either a numpy 1D array of pandas Series, the size of y
should be the same as the number of rows of X.
Returns the instanced class itself for chaining, i.e "return self"
"""
@abstractmethod
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""
Esimate y.
- X is either a numpy 2D array or pandas DataFrame with columns as
features and rows as sample points.
Returns a numpy 1D array of the same size as the rows of X.
"""
然后我们构建一个模型,该模型使用遵循 BaseLayer 接口的模型(层)。这个模型,LayeredMeanModel,本身就是一个接口实现者:
from typing import Optional
class LayeredMeanModel(BaseLayer):
def __init__(self, layers: list[BaseLayer]) -> None:
self.layers = layers
self._fitted_layers: Optional[list[BaseLayer]] = None
@property
def name(self) -> str:
return "Layered Mean Model"
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
self._fitted_layers = [layer.fit(X, y) for layer in self.layers]
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
if self._fitted_layers is None:
raise RuntimeError("Layered mean model has not been fitted.")
y_hats = np.asarray(
[layer.predict(X) for layer in self._fitted_layers]
).T
return y_hats.mean(axis=1)
然后我们建立两个简单的模型,一个总是预测零,另一个总是预测零:
class LayerZeros(BaseLayer):
@property
def name(self) -> str:
return "Model Layer Zeros"
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""Do nothing, return self."""
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""Do nothing return all zeros."""
return np.zeros(X.shape[0])
class LayerOnes(BaseLayer):
@property
def name(self) -> str:
return "Model Layer Ones"
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""Do nothing, return self."""
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""Do nothing return all ones."""
return np.ones(X.shape[0])
我们使用模型:
# layers instances
zeros_layer = LayerZeros()
ones_layer = LayerOnes()
# layer combiner instance
layered_mean_model = LayeredMeanModel(layers = [zeros_layer, ones_layer])
X = np.asarray([[1, 2, 3], [4, 5, 6]])
y = np.asarray([7, 8])
# fit layered model
layered_mean_model.fit(X, y)
# predict y
y_hat = layered_mean_model.predict(X)
# returns [0.5 0.5], the mean of [0.0 0.0] and [1.0 1.0] as we expected
请注意,我们可以用我们的接口定义做很多事情。我们可以在 LayeredMeanModel 中使用 N 层;事实上,我们可以使用 LayeredMeanModels 作为另一个 LayeredMeanModel 的层。这就是接口的力量。
使用协议构建复杂模型
现在我们重复与前一个相同的示例,但使用协议。代码非常相似,除了 ABC 继承和 abstractmethod 装饰器。
from __future__ import annotations
from typing import Protocol
import numpy as np
import pandas as pd
class BaseLayer(Protocol):
"""This the static typed Protocol for a model base layer."""
@property
def name(self) -> str:
"""Returns the name of the model layer."""
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""
- X is either a numpy 2D array or pandas DataFrame with columns as
features and rows as sample points.
- y either a numpy 1D array of pandas Series, the size of y
should be the same as the number of rows of X.
Returns the instanced class itself for chaining, i.e "return self"
"""
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""
Esimate y.
- X is either a numpy 2D array or pandas DataFrame with columns as
features and rows as sample points.
Returns a numpy 1D array of the same size as the rows of X.
"""
from typing import Optional
class LayeredMeanModel:
"""Implements BaseLayer Protocol."""
def __init__(self, layers: list[BaseLayer]) -> None:
self.layers = layers
self._fitted_layers: Optional[list[BaseLayer]] = None
@property
def name(self) -> str:
return "Layered Mean Model"
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
self._fitted_layers = [layer.fit(X, y) for layer in self.layers]
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
if self._fitted_layers is None:
raise RuntimeError("Layered mean model has not been fitted.")
y_hats = np.asarray(
[layer.predict(X) for layer in self._fitted_layers]
).T
return y_hats.mean(axis=1)
class LayerZeros:
"""Implements BaseLayer Protocol."""
@property
def name(self) -> str:
return "Model Layer Zeros"
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""Do nothing, return self."""
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""Do nothing return all zeros."""
return np.zeros(X.shape[0])
class LayerOnes:
"""Implements BaseLayer Protocol."""
@property
def name(self) -> str:
return "Model Layer Ones"
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""Do nothing, return self."""
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""Do nothing return all ones."""
return np.ones(X.shape[0])
# layers instances
zeros_layer = LayerZeros()
ones_layer = LayerOnes()
# layer combiner instance
layered_mean_model = LayeredMeanModel(layers = [zeros_layer, ones_layer])
X = np.asarray([[1, 2, 3], [4, 5, 6]])
y = np.asarray([7, 8])
# fit layered model
layered_mean_model.fit(X, y)
# predict y
y_hat = layered_mean_model.predict(X)
# returns [0.5 0.5], the mean of [0.0 0.0] and [1.0 1.0] as we expected
再一次,接口(协议)仍然是隐式的,只有在静态类型检查器中出现类型错误时才会出现。
ABC: partial implementation
当我们谈到用 ABC 声明接口时,我们说我们可以将 ABC 用于接口以外的地方。 ABC 的主要用途之一是部分实现。通常使用一些抽象方法(未实现)和一些可能使用抽象方法的实现方法来创建 ABC。
让我们对谈话进行编码,我们使用与以前类似的示例,但现在我们包含一个 fit_predict 方法(已实现),该方法使用 fit 和 predict 方法(未实现):
from __future__ import annotations
from abc import ABC, abstractmethod
import numpy as np
import pandas as pd
class BaseLayer(ABC):
"""This the model base layer. All model layers inherit from it."""
@property
@abstractmethod
def name(self) -> str:
"""Returns the name of the model layer."""
@abstractmethod
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""
- X is either a numpy 2D array or pandas DataFrame with columns as
features and rows as sample points.
- y either a numpy 1D array of pandas Series, the size of y
should be the same as the number of rows of X.
Returns the instanced class itself for chaining, i.e "return self"
"""
@abstractmethod
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""
Esimate y.
- X is either a numpy 2D array or pandas DataFrame with columns as
features and rows as sample points.
Returns a numpy 1D array of the same size as the rows of X.
"""
def fit_predict(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> np.ndarray:
"""
Partial implementation, extends all child classes chaining
fit and predict methods.
"""
return self.fit(X, y).predict(X)
因此,当我们从基类继承并实现 fit 和 predict 方法时, fit_predict 方法通过继承变得可用:
class LayerZeros(BaseLayer):
@property
def name(self) -> str:
return "Model Layer Zeros"
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""Do nothing, return self."""
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""Do nothing return all zeros."""
return np.zeros(X.shape[0])
# instance of the DummyLayer class (no init)
layer = LayerZeros()
X = np.asarray([[1, 2, 3], [4, 5, 6]])
y = np.asarray([7, 8])
# fit dummy model layer
layer.fit(X, y)
# predict y
y_hat = layer.fit_predict(X, y)
# returns all zeros [0. 0.] as expected
这个例子清楚地展示了 ABC 如何比接口更普遍地使用。
ABC vs. 协议和多重继承问题
仅将 ABC 用作接口时可能出现的一个潜在“问题”是多重继承。当我们有两个实现相同接口的对象时:
from __future__ import annotations
from abc import ABC, abstractmethod
import numpy as np
import pandas as pd
class BaseLayer(ABC):
"""This the model base layer. All model layers inherit from it."""
@property
@abstractmethod
def name(self) -> str:
"""Returns the name of the model layer."""
@abstractmethod
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""
- X is either a numpy 2D array or pandas DataFrame with columns as
features and rows as sample points.
- y either a numpy 1D array of pandas Series, the size of y
should be the same as the number of rows of X.
Returns the instanced class itself for chaining, i.e "return self"
"""
@abstractmethod
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""
Esimate y.
- X is either a numpy 2D array or pandas DataFrame with columns as
features and rows as sample points.
Returns a numpy 1D array of the same size as the rows of X.
"""
并且它们之间非常相似:
class LayerZeros1(BaseLayer):
@property
def name(self) -> str:
return "Model Layer Zeros 1"
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""Do nothing, return self."""
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""Do nothing return all zeros."""
return np.zeros(X.shape[0])
class LayerZeros2(BaseLayer):
@property
def name(self) -> str:
return "Model Layer Zeros 2"
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""Do nothing, return self."""
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""Do nothing return all zeros."""
return np.zeros(X.shape[0])
除了一些属性(在本例中为 name 属性),我们希望避免重复代码,而是让两个 LayerZeros 从第四个类继承 fit 和 predict 方法,在本例中为 LayerZerosMixin:
class LayerZerosMixin:
"""Mixin for fit and predict for zeros layer."""
def fit(
self, X: np.ndarray | pd.DataFrame, y: np.ndarray | pd.DataFrame
) -> BaseLayer:
"""Do nothing, return self."""
return self
def predict(self, X: np.ndarray | pd.DataFrame) -> np.ndarray:
"""Do nothing return all zeros."""
return np.zeros(X.shape[0])
class LayerZeros1(LayerZerosMixin, BaseLayer):
@property
def name(self) -> str:
return "Model Layer Zeros 1"
class LayerZeros2(LayerZerosMixin, BaseLayer):
@property
def name(self) -> str:
return "Model Layer Zeros 2"
我们最终得到两个具有多个父级的 LayerZeros。大多数 OO 开发人员会不赞成多重继承。有些人甚至认为它是一种反模式。这就是为什么有许多 OO 语言不允许多重继承的原因。在 Python 中,将单词 Mixin 添加到额外继承类的标题中,神奇地解决了这个问题。这是 OO 社区中正在进行的辩论。
逻辑层面的问题,问题是多重继承有时会打破继承的规范含义,是一种关系。例如,假设我们有员工和收银机来支付他们的业务费用。假设我们有一个 Person 类和一个 CashRegister 类来支付员工工资。如果我们做了类似 Employee(Person) 的事情,即 Employee 类继承自 Person,那么一切都很好,因为 Employee 是一个人(至少现在是这样)。但是我们可能会做一些类似 Employee(Person, CashRegister) 的事情来在 Employee 类中包含支付功能。毕竟,薪酬定义了就业。这很棘手,因为 Employee 不是 CashRegister。在 Python 和其他支持多重继承的语言中,表明 Employee 不是 CashRegister 的一种方法是将类名重写为 CashRegisterMixin。
正如我所说,这是一场持续的辩论。
Final words
正确的选择是一个偏好问题。使用 ABC 或协议取决于您。就个人而言,我使用协议作为接口,使用 ABC 进行部分实现;这是我自己的规则。
数据科学家唯一的工作就是创建 jupyter 笔记本和腌制模型的日子已经一去不复返了。您很有可能会开发成品而不是模型和绘图。这意味着通过数据采集编码您的方式,一直到部署模型的 API。换句话说,您需要将您的数据科学技能与软件开发相结合。那时,接口将使您的生活更轻松,您的代码更简洁。
我希望这个故事对你有用。如果您想要更多这样的故事,请订阅。
喜欢这个故事吗?通过下面的推荐链接成为 Medium 会员,支持我的写作。无限访问我的故事和许多其他故事。
如果我错过了什么,请告诉我。如有任何疑问,批评等,请发表评论。
文章出处登录后可见!