Sklearn、TensorFlow 与 Keras 机器学习实用指南第三版(一)(3)https://developer.aliyun.com/article/1482407
处理文本和分类属性
到目前为止,我们只处理了数值属性,但是您的数据可能还包含文本属性。在这个数据集中,只有一个:ocean_proximity
属性。让我们看一下前几个实例的值:
>>> housing_cat = housing[["ocean_proximity"]] >>> housing_cat.head(8) ocean_proximity 13096 NEAR BAY 14973 <1H OCEAN 3785 INLAND 14689 INLAND 20507 NEAR OCEAN 1286 INLAND 18078 <1H OCEAN 4396 NEAR BAY
这不是任意的文本:可能的值有限,每个值代表一个类别。因此,这个属性是一个分类属性。大多数机器学习算法更喜欢使用数字,所以让我们将这些类别从文本转换为数字。为此,我们可以使用 Scikit-Learn 的OrdinalEncoder
类:
from sklearn.preprocessing import OrdinalEncoder ordinal_encoder = OrdinalEncoder() housing_cat_encoded = ordinal_encoder.fit_transform(housing_cat)
这是housing_cat_encoded
中前几个编码值的样子:
>>> housing_cat_encoded[:8] array([[3.], [0.], [1.], [1.], [4.], [1.], [0.], [3.]])
您可以使用categories_
实例变量获取类别列表。它是一个包含每个分类属性的类别的 1D 数组的列表(在这种情况下,由于只有一个分类属性,因此包含一个单一数组的列表):
>>> ordinal_encoder.categories_ [array(['<1H OCEAN', 'INLAND', 'ISLAND', 'NEAR BAY', 'NEAR OCEAN'], dtype=object)]
这种表示的一个问题是,机器学习算法会假设两个相邻的值比两个远离的值更相似。在某些情况下这可能没问题(例如,对于有序类别如“bad”、“average”、“good”和“excellent”),但显然不适用于ocean_proximity
列(例如,类别 0 和 4 明显比类别 0 和 1 更相似)。为了解决这个问题,一个常见的解决方案是为每个类别创建一个二进制属性:当类别是"<1H OCEAN"
时一个属性等于 1(否则为 0),当类别是"INLAND"
时另一个属性等于 1(否则为 0),依此类推。这被称为独热编码,因为只有一个属性将等于 1(热),而其他属性将等于 0(冷)。新属性有时被称为虚拟属性。Scikit-Learn 提供了一个OneHotEncoder
类来将分类值转换为独热向量:
from sklearn.preprocessing import OneHotEncoder cat_encoder = OneHotEncoder() housing_cat_1hot = cat_encoder.fit_transform(housing_cat)
默认情况下,OneHotEncoder
的输出是 SciPy 的稀疏矩阵,而不是 NumPy 数组:
>>> housing_cat_1hot <16512x5 sparse matrix of type '<class 'numpy.float64'>' with 16512 stored elements in Compressed Sparse Row format>
稀疏矩阵是包含大部分零的矩阵的非常高效的表示。实际上,它内部只存储非零值及其位置。当一个分类属性有数百或数千个类别时,对其进行独热编码会导致一个非常大的矩阵,除了每行一个单独的 1 之外,其他都是 0。在这种情况下,稀疏矩阵正是你需要的:它将节省大量内存并加快计算速度。你可以像使用普通的 2D 数组一样使用稀疏矩阵,但如果你想将其转换为(密集的)NumPy 数组,只需调用toarray()
方法:
>>> housing_cat_1hot.toarray() array([[0., 0., 0., 1., 0.], [1., 0., 0., 0., 0.], [0., 1., 0., 0., 0.], ..., [0., 0., 0., 0., 1.], [1., 0., 0., 0., 0.], [0., 0., 0., 0., 1.]])
或者,当创建OneHotEncoder
时设置sparse=False
,在这种情况下,transform()
方法将直接返回一个常规(密集的)NumPy 数组。
与OrdinalEncoder
一样,你可以使用编码器的categories_
实例变量获取类别列表:
>>> cat_encoder.categories_ [array(['<1H OCEAN', 'INLAND', 'ISLAND', 'NEAR BAY', 'NEAR OCEAN'], dtype=object)]
Pandas 有一个名为get_dummies()
的函数,它也将每个分类特征转换为一种独热表示,每个类别一个二进制特征:
>>> df_test = pd.DataFrame({"ocean_proximity": ["INLAND", "NEAR BAY"]}) >>> pd.get_dummies(df_test) ocean_proximity_INLAND ocean_proximity_NEAR BAY 0 1 0 1 0 1
看起来很简单漂亮,那为什么不使用它而不是OneHotEncoder
呢?嗯,OneHotEncoder
的优势在于它记住了它训练过的类别。这非常重要,因为一旦你的模型投入生产,它应该被喂入与训练期间完全相同的特征:不多,也不少。看看我们训练过的cat_encoder
在我们让它转换相同的df_test
时输出了什么(使用transform()
,而不是fit_transform()
):
>>> cat_encoder.transform(df_test) array([[0., 1., 0., 0., 0.], [0., 0., 0., 1., 0.]])
看到区别了吗?get_dummies()
只看到了两个类别,所以输出了两列,而OneHotEncoder
按正确顺序输出了每个学习类别的一列。此外,如果你给get_dummies()
一个包含未知类别(例如"<2H OCEAN"
)的 DataFrame,它会高兴地为其生成一列:
>>> df_test_unknown = pd.DataFrame({"ocean_proximity": ["<2H OCEAN", "ISLAND"]}) >>> pd.get_dummies(df_test_unknown) ocean_proximity_<2H OCEAN ocean_proximity_ISLAND 0 1 0 1 0 1
但是OneHotEncoder
更聪明:它会检测到未知类别并引发异常。如果你愿意,你可以将handle_unknown
超参数设置为"ignore"
,在这种情况下,它将只用零表示未知类别:
>>> cat_encoder.handle_unknown = "ignore" >>> cat_encoder.transform(df_test_unknown) array([[0., 0., 0., 0., 0.], [0., 0., 1., 0., 0.]])
提示
如果一个分类属性有大量可能的类别(例如,国家代码,职业,物种),那么独热编码将导致大量的输入特征。这可能会减慢训练速度并降低性能。如果发生这种情况,您可能希望用与类别相关的有用的数值特征替换分类输入:例如,您可以用到海洋的距离替换ocean_proximity
特征(类似地,国家代码可以用国家的人口和人均 GDP 替换)。或者,您可以使用category_encoders
包在GitHub上提供的编码器之一。或者,在处理神经网络时,您可以用一个可学习的低维向量替换每个类别,称为嵌入。这是表示学习的一个例子(有关更多详细信息,请参见第十三章和第十七章)。
当您使用 DataFrame 拟合任何 Scikit-Learn 估计器时,估计器会将列名存储在feature_names_in_
属性中。然后,Scikit-Learn 确保在此之后将任何 DataFrame 提供给该估计器(例如,用于transform()
或predict()
)具有相同的列名。转换器还提供了一个get_feature_names_out()
方法,您可以使用它来构建围绕转换器输出的 DataFrame:
>>> cat_encoder.feature_names_in_ array(['ocean_proximity'], dtype=object) >>> cat_encoder.get_feature_names_out() array(['ocean_proximity_<1H OCEAN', 'ocean_proximity_INLAND', 'ocean_proximity_ISLAND', 'ocean_proximity_NEAR BAY', 'ocean_proximity_NEAR OCEAN'], dtype=object) >>> df_output = pd.DataFrame(cat_encoder.transform(df_test_unknown), ... columns=cat_encoder.get_feature_names_out(), ... index=df_test_unknown.index) ...
特征缩放和转换
您需要对数据应用的最重要的转换之一是特征缩放。除了少数例外,当输入的数值属性具有非常不同的比例时,机器学习算法的表现不佳。这适用于房屋数据:房间总数的范围大约从 6 到 39,320,而中位收入的范围仅从 0 到 15。如果没有进行任何缩放,大多数模型将倾向于忽略中位收入,更多地关注房间数量。
有两种常见的方法可以使所有属性具有相同的比例:最小-最大缩放和标准化。
警告
与所有估计器一样,将缩放器仅适配于训练数据非常重要:永远不要对除训练集以外的任何内容使用fit()
或fit_transform()
。一旦您有了一个经过训练的缩放器,您就可以使用它来transform()
任何其他集合,包括验证集,测试集和新数据。请注意,虽然训练集的值将始终缩放到指定的范围内,但如果新数据包含异常值,则这些值可能会缩放到范围之外。如果要避免这种情况,只需将clip
超参数设置为True
。
最小-最大缩放(许多人称之为归一化)是最简单的:对于每个属性,值被移动和重新缩放,以便最终范围为 0 到 1。这是通过减去最小值并除以最小值和最大值之间的差异来执行的。Scikit-Learn 提供了一个名为MinMaxScaler
的转换器。它有一个feature_range
超参数,让您更改范围,如果出于某种原因,您不想要 0-1(例如,神经网络最适合具有零均值输入,因此-1 到 1 的范围更可取)。使用起来非常简单:
from sklearn.preprocessing import MinMaxScaler min_max_scaler = MinMaxScaler(feature_range=(-1, 1)) housing_num_min_max_scaled = min_max_scaler.fit_transform(housing_num)
标准化是不同的:首先它减去均值(因此标准化值具有零均值),然后将结果除以标准差(因此标准化值的标准差等于 1)。与最小-最大缩放不同,标准化不限制值到特定范围。然而,标准化受异常值的影响要小得多。例如,假设一个地区的中位收入等于 100(错误),而不是通常的 0-15。将最小-最大缩放到 0-1 范围会将这个异常值映射到 1,并将所有其他值压缩到 0-0.15,而标准化不会受到太大影响。Scikit-Learn 提供了一个名为StandardScaler
的标准化器:
from sklearn.preprocessing import StandardScaler std_scaler = StandardScaler() housing_num_std_scaled = std_scaler.fit_transform(housing_num)
提示
如果要对稀疏矩阵进行缩放而不先将其转换为密集矩阵,可以使用StandardScaler
,并将其with_mean
超参数设置为False
:它只会通过标准差除以数据,而不会减去均值(因为这样会破坏稀疏性)。
当一个特征的分布具有重尾(即远离均值的值并不呈指数稀有)时,最小-最大缩放和标准化会将大多数值压缩到一个小范围内。机器学习模型通常不喜欢这种情况,正如您将在第四章中看到的那样。因此,在对特征进行缩放之前,您应该首先对其进行转换以缩小重尾,并尽可能使分布大致对称。例如,对于具有右侧重尾的正特征,常见的做法是用其平方根替换特征(或将特征提升到 0 到 1 之间的幂)。如果特征具有非常长且重尾的分布,例如幂律分布,那么用其对数替换特征可能有所帮助。例如,population
特征大致遵循幂律:拥有 1 万居民的地区只比拥有 1,000 居民的地区少 10 倍,而不是指数级别少。图 2-17 显示了当计算其对数时,这个特征看起来更好多少:它非常接近高斯分布(即钟形)。
图 2-17。将特征转换为更接近高斯分布
处理重尾特征的另一种方法是将特征分桶。这意味着将其分布分成大致相等大小的桶,并用桶的索引替换每个特征值,就像我们创建income_cat
特征时所做的那样(尽管我们只用它进行分层抽样)。例如,您可以用百分位数替换每个值。使用等大小的桶进行分桶会产生一个几乎均匀分布的特征,因此不需要进一步缩放,或者您可以只除以桶的数量以将值强制为 0-1 范围。
当一个特征具有多峰分布(即有两个或更多明显峰值,称为模式)时,例如housing_median_age
特征,将其分桶也可能有所帮助,但这次将桶 ID 视为类别而不是数值值。这意味着桶索引必须进行编码,例如使用OneHotEncoder
(因此通常不希望使用太多桶)。这种方法将使回归模型更容易学习不同范围的特征值的不同规则。例如,也许大约 35 年前建造的房屋有一种不合时宜的风格,因此它们比其年龄单独表明的要便宜。
将多模态分布转换的另一种方法是为每个模式(至少是主要的模式)添加一个特征,表示房屋年龄中位数与该特定模式之间的相似性。相似性度量通常使用径向基函数(RBF)计算 - 任何仅取决于输入值与固定点之间距离的函数。最常用的 RBF 是高斯 RBF,其输出值随着输入值远离固定点而指数衰减。例如,房龄x与 35 之间的高斯 RBF 相似性由方程 exp(–γ(x – 35)²)给出。超参数γ(gamma)确定随着x远离 35,相似性度量衰减的速度。使用 Scikit-Learn 的rbf_kernel()
函数,您可以创建一个新的高斯 RBF 特征,衡量房屋年龄中位数与 35 之间的相似性:
from sklearn.metrics.pairwise import rbf_kernel age_simil_35 = rbf_kernel(housing[["housing_median_age"]], [[35]], gamma=0.1)
图 2-18 显示了这个新特征作为房屋年龄中位数的函数(实线)。它还显示了如果使用较小的gamma
值,该特征会是什么样子。正如图表所示,新的年龄相似性特征在 35 岁时达到峰值,正好在房屋年龄中位数分布的峰值附近:如果这个特定年龄组与较低价格有很好的相关性,那么这个新特征很可能会有所帮助。
图 2-18. 高斯 RBF 特征,衡量房屋年龄中位数与 35 之间的相似性
到目前为止,我们只看了输入特征,但目标值可能也需要转换。例如,如果目标分布具有重尾,您可能选择用其对数替换目标。但是,如果这样做,回归模型现在将预测中位数房价的对数,而不是中位数房价本身。如果您希望预测的中位数房价,您需要计算模型预测的指数。
幸运的是,大多数 Scikit-Learn 的转换器都有一个inverse_transform()
方法,使得计算其转换的逆变换变得容易。例如,以下代码示例显示如何使用StandardScaler
缩放标签(就像我们为输入做的那样),然后在生成的缩放标签上训练一个简单的线性回归模型,并使用它对一些新数据进行预测,然后使用训练好的缩放器的inverse_transform()
方法将其转换回原始比例。请注意,我们将标签从 Pandas Series 转换为 DataFrame,因为StandardScaler
需要 2D 输入。此外,在此示例中,我们只对单个原始输入特征(中位收入)进行了模型训练,以简化问题:
from sklearn.linear_model import LinearRegression target_scaler = StandardScaler() scaled_labels = target_scaler.fit_transform(housing_labels.to_frame()) model = LinearRegression() model.fit(housing[["median_income"]], scaled_labels) some_new_data = housing[["median_income"]].iloc[:5] # pretend this is new data scaled_predictions = model.predict(some_new_data) predictions = target_scaler.inverse_transform(scaled_predictions)
这样做效果很好,但更简单的选择是使用TransformedTargetRegressor
。我们只需要构建它,给定回归模型和标签转换器,然后在训练集上拟合它,使用原始未缩放的标签。它将自动使用转换器来缩放标签,并在生成的缩放标签上训练回归模型,就像我们之前做的那样。然后,当我们想要进行预测时,它将调用回归模型的predict()
方法,并使用缩放器的inverse_transform()
方法来生成预测:
from sklearn.compose import TransformedTargetRegressor model = TransformedTargetRegressor(LinearRegression(), transformer=StandardScaler()) model.fit(housing[["median_income"]], housing_labels) predictions = model.predict(some_new_data)
自定义转换器
尽管 Scikit-Learn 提供了许多有用的转换器,但对于自定义转换、清理操作或组合特定属性等任务,您可能需要编写自己的转换器。
对于不需要任何训练的转换,您可以编写一个函数,该函数以 NumPy 数组作为输入,并输出转换后的数组。例如,如前一节所讨论的,通常最好通过用其对数替换具有重尾分布的特征(假设特征为正且尾部在右侧)来转换特征。让我们创建一个对数转换器,并将其应用于population
特征:
from sklearn.preprocessing import FunctionTransformer log_transformer = FunctionTransformer(np.log, inverse_func=np.exp) log_pop = log_transformer.transform(housing[["population"]])
inverse_func
参数是可选的。它允许您指定一个逆转换函数,例如,如果您计划在TransformedTargetRegressor
中使用您的转换器。
您的转换函数可以将超参数作为额外参数。例如,以下是如何创建一个与之前相同的高斯 RBF 相似性度量的转换器:
rbf_transformer = FunctionTransformer(rbf_kernel, kw_args=dict(Y=[[35.]], gamma=0.1)) age_simil_35 = rbf_transformer.transform(housing[["housing_median_age"]])
请注意,RBF 核函数没有逆函数,因为在固定点的给定距离处始终存在两个值(除了距离为 0 的情况)。还要注意,rbf_kernel()
不会单独处理特征。如果您传递一个具有两个特征的数组,它将测量 2D 距离(欧几里得距离)以衡量相似性。例如,以下是如何添加一个特征,用于衡量每个地区与旧金山之间的地理相似性:
sf_coords = 37.7749, -122.41 sf_transformer = FunctionTransformer(rbf_kernel, kw_args=dict(Y=[sf_coords], gamma=0.1)) sf_simil = sf_transformer.transform(housing[["latitude", "longitude"]])
自定义转换器也可用于组合特征。例如,这里有一个FunctionTransformer
,计算输入特征 0 和 1 之间的比率:
>>> ratio_transformer = FunctionTransformer(lambda X: X[:, [0]] / X[:, [1]]) >>> ratio_transformer.transform(np.array([[1., 2.], [3., 4.]])) array([[0.5 ], [0.75]])
FunctionTransformer
非常方便,但如果您希望您的转换器是可训练的,在fit()
方法中学习一些参数,并在transform()
方法中稍后使用它们,该怎么办?为此,您需要编写一个自定义类。Scikit-Learn 依赖于鸭子类型,因此这个类不必继承自任何特定的基类。它只需要三个方法:fit()
(必须返回self
)、transform()
和fit_transform()
。
只需将TransformerMixin
作为基类添加进去,就可以免费获得fit_transform()
:默认实现只会调用fit()
,然后调用transform()
。如果将BaseEstimator
作为基类(并且避免在构造函数中使用*args
和**kwargs
),还会获得两个额外的方法:get_params()
和set_params()
。这些对于自动超参数调整会很有用。
例如,这里有一个自定义的转换器,它的功能类似于StandardScaler
:
from sklearn.base import BaseEstimator, TransformerMixin from sklearn.utils.validation import check_array, check_is_fitted class StandardScalerClone(BaseEstimator, TransformerMixin): def __init__(self, with_mean=True): # no *args or **kwargs! self.with_mean = with_mean def fit(self, X, y=None): # y is required even though we don't use it X = check_array(X) # checks that X is an array with finite float values self.mean_ = X.mean(axis=0) self.scale_ = X.std(axis=0) self.n_features_in_ = X.shape[1] # every estimator stores this in fit() return self # always return self! def transform(self, X): check_is_fitted(self) # looks for learned attributes (with trailing _) X = check_array(X) assert self.n_features_in_ == X.shape[1] if self.with_mean: X = X - self.mean_ return X / self.scale_
以下是一些需要注意的事项:
sklearn.utils.validation
包含了几个我们可以用来验证输入的函数。为简单起见,我们将在本书的其余部分跳过这些测试,但生产代码应该包含它们。- Scikit-Learn 管道要求
fit()
方法有两个参数X
和y
,这就是为什么我们需要y=None
参数,即使我们不使用y
。 - 所有 Scikit-Learn 估计器在
fit()
方法中设置n_features_in_
,并确保传递给transform()
或predict()
的数据具有这个特征数量。 fit()
方法必须返回self
。- 这个实现并不完全:所有的估计器在传入 DataFrame 时应该在
fit()
方法中设置feature_names_in_
。此外,所有的转换器应该提供一个get_feature_names_out()
方法,以及一个inverse_transform()
方法,当它们的转换可以被逆转时。更多细节请参考本章末尾的最后一个练习。
一个自定义转换器可以(并经常)在其实现中使用其他估计器。例如,以下代码演示了一个自定义转换器,在fit()
方法中使用KMeans
聚类器来识别训练数据中的主要聚类,然后在transform()
方法中使用rbf_kernel()
来衡量每个样本与每个聚类中心的相似程度:
from sklearn.cluster import KMeans class ClusterSimilarity(BaseEstimator, TransformerMixin): def __init__(self, n_clusters=10, gamma=1.0, random_state=None): self.n_clusters = n_clusters self.gamma = gamma self.random_state = random_state def fit(self, X, y=None, sample_weight=None): self.kmeans_ = KMeans(self.n_clusters, random_state=self.random_state) self.kmeans_.fit(X, sample_weight=sample_weight) return self # always return self! def transform(self, X): return rbf_kernel(X, self.kmeans_.cluster_centers_, gamma=self.gamma) def get_feature_names_out(self, names=None): return [f"Cluster {i} similarity" for i in range(self.n_clusters)]
提示
您可以通过将一个实例传递给sklearn.utils.estimator_checks
包中的check_estimator()
来检查您的自定义估计器是否符合 Scikit-Learn 的 API。有关完整的 API,请查看https://scikit-learn.org/stable/developers。
正如您将在第九章中看到的,k-means 是一种在数据中定位聚类的算法。它搜索的聚类数量由n_clusters
超参数控制。训练后,聚类中心可以通过cluster_centers_
属性获得。KMeans
的fit()
方法支持一个可选参数sample_weight
,让用户指定样本的相对权重。k-means 是一种随机算法,意味着它依赖于随机性来定位聚类,因此如果您想要可重现的结果,必须设置random_state
参数。正如您所看到的,尽管任务复杂,代码还是相当简单的。现在让我们使用这个自定义转换器:
cluster_simil = ClusterSimilarity(n_clusters=10, gamma=1., random_state=42) similarities = cluster_simil.fit_transform(housing[["latitude", "longitude"]], sample_weight=housing_labels)
这段代码创建了一个ClusterSimilarity
转换器,将聚类数设置为 10。然后它使用训练集中每个区域的纬度和经度调用fit_transform()
,通过每个区域的中位房价加权。转换器使用k-means 来定位聚类,然后测量每个区域与所有 10 个聚类中心之间的高斯 RBF 相似性。结果是一个矩阵,每个区域一行,每个聚类一列。让我们看一下前三行,四舍五入到两位小数:
>>> similarities[:3].round(2) array([[0\. , 0.14, 0\. , 0\. , 0\. , 0.08, 0\. , 0.99, 0\. , 0.6 ], [0.63, 0\. , 0.99, 0\. , 0\. , 0\. , 0.04, 0\. , 0.11, 0\. ], [0\. , 0.29, 0\. , 0\. , 0.01, 0.44, 0\. , 0.7 , 0\. , 0.3 ]])
图 2-19 显示了k-means 找到的 10 个聚类中心。根据它们与最近聚类中心的地理相似性,地区被着色。如您所见,大多数聚类位于人口稠密和昂贵的地区。
图 2-19. 高斯 RBF 相似度到最近的聚类中心
转换管道
如您所见,有许多数据转换步骤需要按正确顺序执行。幸运的是,Scikit-Learn 提供了Pipeline
类来帮助处理这样的转换序列。这是一个用于数值属性的小管道,它将首先填充然后缩放输入特征:
from sklearn.pipeline import Pipeline num_pipeline = Pipeline([ ("impute", SimpleImputer(strategy="median")), ("standardize", StandardScaler()), ])
Pipeline
构造函数接受一系列步骤定义的名称/估计器对(2 元组)列表。名称可以是任何您喜欢的内容,只要它们是唯一的且不包含双下划线(__
)。稍后在我们讨论超参数调整时,它们将很有用。所有估计器必须是转换器(即,它们必须具有fit_transform()
方法),除了最后一个,它可以是任何东西:一个转换器,一个预测器或任何其他类型的估计器。
提示
在 Jupyter 笔记本中,如果import
sklearn
并运行sklearn.set_config(display="diagram")
,则所有 Scikit-Learn 估计器都将呈现为交互式图表。这对于可视化管道特别有用。要可视化num_pipeline
,请在最后一行中运行一个包含num_pipeline
的单元格。单击估计器将显示更多详细信息。
如果您不想为转换器命名,可以使用make_pipeline()
函数;它将转换器作为位置参数,并使用转换器类的名称创建一个Pipeline
,名称为小写且没有下划线(例如,"simpleimputer"
):
from sklearn.pipeline import make_pipeline num_pipeline = make_pipeline(SimpleImputer(strategy="median"), StandardScaler())
如果多个转换器具有相同的名称,将在它们的名称后附加索引(例如,"foo-1"
,"foo-2"
等)。
当您调用管道的fit()
方法时,它会按顺序在所有转换器上调用fit_transform()
,将每次调用的输出作为下一次调用的参数,直到达到最终的估计器,对于最终的估计器,它只调用fit()
方法。
管道公开与最终估计器相同的方法。在这个例子中,最后一个估计器是StandardScaler
,它是一个转换器,因此管道也像一个转换器。如果调用管道的transform()
方法,它将顺序应用所有转换到数据。如果最后一个估计器是预测器而不是转换器,则管道将具有predict()
方法而不是transform()
方法。调用它将顺序应用所有转换到数据,并将结果传递给预测器的predict()
方法。
让我们调用管道的fit_transform()
方法,并查看输出的前两行,保留两位小数:
>>> housing_num_prepared = num_pipeline.fit_transform(housing_num) >>> housing_num_prepared[:2].round(2) array([[-1.42, 1.01, 1.86, 0.31, 1.37, 0.14, 1.39, -0.94], [ 0.6 , -0.7 , 0.91, -0.31, -0.44, -0.69, -0.37, 1.17]])
如您之前所见,如果要恢复一个漂亮的 DataFrame,可以使用管道的get_feature_names_out()
方法:
df_housing_num_prepared = pd.DataFrame( housing_num_prepared, columns=num_pipeline.get_feature_names_out(), index=housing_num.index)
管道支持索引;例如,pipeline[1]
返回管道中的第二个估计器,pipeline[:-1]
返回一个包含除最后一个估计器之外的所有估计器的Pipeline
对象。您还可以通过steps
属性访问估计器,该属性是名称/估计器对的列表,或者通过named_steps
字典属性访问估计器,该属性将名称映射到估计器。例如,num_pipeline["simpleimputer"]
返回名为"simpleimputer"
的估计器。
到目前为止,我们已经分别处理了分类列和数值列。有一个单一的转换器可以处理所有列,对每一列应用适当的转换会更方便。为此,您可以使用ColumnTransformer
。例如,以下ColumnTransformer
将num_pipeline
(我们刚刚定义的)应用于数值属性,将cat_pipeline
应用于分类属性:
from sklearn.compose import ColumnTransformer num_attribs = ["longitude", "latitude", "housing_median_age", "total_rooms", "total_bedrooms", "population", "households", "median_income"] cat_attribs = ["ocean_proximity"] cat_pipeline = make_pipeline( SimpleImputer(strategy="most_frequent"), OneHotEncoder(handle_unknown="ignore")) preprocessing = ColumnTransformer([ ("num", num_pipeline, num_attribs), ("cat", cat_pipeline, cat_attribs), ])
首先我们导入 ColumnTransformer
类,然后定义数值和分类列名的列表,并为分类属性构建一个简单的管道。最后,我们构建一个 ColumnTransformer
。它的构造函数需要一个三元组(3 元组)的列表,每个三元组包含一个名称(必须是唯一的,不包含双下划线)、一个转换器和一个应用转换器的列名(或索引)列表。
提示
如果你想要删除列,可以指定字符串 "drop"
,如果你想要保留列不变,可以指定 "passthrough"
。默认情况下,剩余的列(即未列出的列)将被删除,但是如果你想要这些列被处理方式不同,可以将 remainder
超参数设置为任何转换器(或 "passthrough"
)。
由于列名的列举并不是很方便,Scikit-Learn 提供了一个 make_column_selector()
函数,返回一个选择器函数,你可以用它自动选择给定类型的所有特征,比如数值或分类。你可以将这个选择器函数传递给 ColumnTransformer
,而不是列名或索引。此外,如果你不关心命名转换器,你可以使用 make_column_transformer()
,它会为你选择名称,就像 make_pipeline()
一样。例如,以下代码创建了与之前相同的 ColumnTransformer
,只是转换器自动命名为 "pipeline-1"
和 "pipeline-2"
,而不是 "num"
和 "cat"
:
from sklearn.compose import make_column_selector, make_column_transformer preprocessing = make_column_transformer( (num_pipeline, make_column_selector(dtype_include=np.number)), (cat_pipeline, make_column_selector(dtype_include=object)), )
现在我们准备将这个 ColumnTransformer
应用到房屋数据中:
housing_prepared = preprocessing.fit_transform(housing)
太棒了!我们有一个预处理管道,它接受整个训练数据集,并将每个转换器应用于适当的列,然后水平连接转换后的列(转换器绝不能改变行数)。再次返回一个 NumPy 数组,但你可以使用 preprocessing.get_feature_names_out()
获取列名,并像之前一样将数据包装在一个漂亮的 DataFrame 中。
注意
OneHotEncoder
返回一个稀疏矩阵,而 num_pipeline
返回一个密集矩阵。当存在稀疏和密集矩阵混合时,ColumnTransformer
会估计最终矩阵的密度(即非零单元格的比例),如果密度低于给定阈值(默认为 sparse_threshold=0.3
),则返回稀疏矩阵。在这个例子中,返回一个密集矩阵。
你的项目进展得很顺利,你几乎可以开始训练一些模型了!现在你想创建一个单一的管道,执行到目前为止你已经尝试过的所有转换。让我们回顾一下管道将做什么以及为什么:
- 数值特征中的缺失值将被中位数替换,因为大多数 ML 算法不希望有缺失值。在分类特征中,缺失值将被最频繁的类别替换。
- 分类特征将被独热编码,因为大多数 ML 算法只接受数值输入。
- 将计算并添加一些比率特征:
bedrooms_ratio
、rooms_per_house
和people_per_house
。希望这些特征与房屋价值中位数更好地相关,并帮助 ML 模型。 - 还将添加一些集群相似性特征。这些特征可能对模型比纬度和经度更有用。
- 具有长尾的特征将被其对数替换,因为大多数模型更喜欢具有大致均匀或高斯分布的特征。
- 所有数值特征将被标准化,因为大多数 ML 算法更喜欢所有特征具有大致相同的尺度。
构建执行所有这些操作的管道的代码现在应该对你来说很熟悉:
def column_ratio(X): return X[:, [0]] / X[:, [1]] def ratio_name(function_transformer, feature_names_in): return ["ratio"] # feature names out def ratio_pipeline(): return make_pipeline( SimpleImputer(strategy="median"), FunctionTransformer(column_ratio, feature_names_out=ratio_name), StandardScaler()) log_pipeline = make_pipeline( SimpleImputer(strategy="median"), FunctionTransformer(np.log, feature_names_out="one-to-one"), StandardScaler()) cluster_simil = ClusterSimilarity(n_clusters=10, gamma=1., random_state=42) default_num_pipeline = make_pipeline(SimpleImputer(strategy="median"), StandardScaler()) preprocessing = ColumnTransformer([ ("bedrooms", ratio_pipeline(), ["total_bedrooms", "total_rooms"]), ("rooms_per_house", ratio_pipeline(), ["total_rooms", "households"]), ("people_per_house", ratio_pipeline(), ["population", "households"]), ("log", log_pipeline, ["total_bedrooms", "total_rooms", "population", "households", "median_income"]), ("geo", cluster_simil, ["latitude", "longitude"]), ("cat", cat_pipeline, make_column_selector(dtype_include=object)), ], remainder=default_num_pipeline) # one column remaining: housing_median_age
如果你运行这个 ColumnTransformer
,它将执行所有转换并输出一个具有 24 个特征的 NumPy 数组:
>>> housing_prepared = preprocessing.fit_transform(housing) >>> housing_prepared.shape (16512, 24) >>> preprocessing.get_feature_names_out() array(['bedrooms__ratio', 'rooms_per_house__ratio', 'people_per_house__ratio', 'log__total_bedrooms', 'log__total_rooms', 'log__population', 'log__households', 'log__median_income', 'geo__Cluster 0 similarity', [...], 'geo__Cluster 9 similarity', 'cat__ocean_proximity_<1H OCEAN', 'cat__ocean_proximity_INLAND', 'cat__ocean_proximity_ISLAND', 'cat__ocean_proximity_NEAR BAY', 'cat__ocean_proximity_NEAR OCEAN', 'remainder__housing_median_age'], dtype=object)
选择并训练模型
终于!你确定了问题,获取了数据并对其进行了探索,对训练集和测试集进行了抽样,并编写了一个预处理管道来自动清理和准备数据以供机器学习算法使用。现在你准备好选择和训练一个机器学习模型了。
在训练集上训练和评估
好消息是,由于之前的所有步骤,现在事情将变得容易!你决定训练一个非常基本的线性回归模型来开始:
from sklearn.linear_model import LinearRegression lin_reg = make_pipeline(preprocessing, LinearRegression()) lin_reg.fit(housing, housing_labels)
完成了!你现在有一个可用的线性回归模型。你可以在训练集上尝试一下,查看前五个预测值,并将其与标签进行比较:
>>> housing_predictions = lin_reg.predict(housing) >>> housing_predictions[:5].round(-2) # -2 = rounded to the nearest hundred array([243700., 372400., 128800., 94400., 328300.]) >>> housing_labels.iloc[:5].values array([458300., 483800., 101700., 96100., 361800.])
好吧,它起作用了,但并不总是:第一个预测结果相差太远(超过 20 万美元!),而其他预测结果更好:两个相差约 25%,两个相差不到 10%。记住你选择使用 RMSE 作为性能指标,所以你想使用 Scikit-Learn 的mean_squared_error()
函数在整个训练集上测量这个回归模型的 RMSE,将squared
参数设置为False
:
>>> from sklearn.metrics import mean_squared_error >>> lin_rmse = mean_squared_error(housing_labels, housing_predictions, ... squared=False) ... >>> lin_rmse 68687.89176589991
这比没有好,但显然不是一个很好的分数:大多数地区的median_housing_values
在 12 万美元到 26.5 万美元之间,因此典型的预测误差 68,628 美元确实令人不满意。这是一个模型欠拟合训练数据的例子。当这种情况发生时,可能意味着特征提供的信息不足以做出良好的预测,或者模型不够强大。正如我们在前一章中看到的,修复欠拟合的主要方法是选择更强大的模型,用更好的特征来训练算法,或者减少模型的约束。这个模型没有正则化,这排除了最后一个选项。你可以尝试添加更多特征,但首先你想尝试一个更复杂的模型看看它的表现如何。
你决定尝试一个DecisionTreeRegressor
,因为这是一个相当强大的模型,能够在数据中找到复杂的非线性关系(决策树在第六章中有更详细的介绍):
from sklearn.tree import DecisionTreeRegressor tree_reg = make_pipeline(preprocessing, DecisionTreeRegressor(random_state=42)) tree_reg.fit(housing, housing_labels)
现在模型已经训练好了,你可以在训练集上评估它:
>>> housing_predictions = tree_reg.predict(housing) >>> tree_rmse = mean_squared_error(housing_labels, housing_predictions, ... squared=False) ... >>> tree_rmse 0.0
等等,什么!?一点错误都没有?这个模型真的完全完美吗?当然,更有可能的是模型严重过拟合了数据。你怎么确定?正如你之前看到的,你不想在准备启动一个你有信心的模型之前触摸测试集,所以你需要使用部分训练集进行训练和部分进行模型验证。
使用交叉验证进行更好的评估
评估决策树模型的一种方法是使用train_test_split()
函数将训练集分成一个较小的训练集和一个验证集,然后针对较小的训练集训练模型,并针对验证集评估模型。这需要一些努力,但并不太困难,而且效果还不错。
一个很好的选择是使用 Scikit-Learn 的k_-fold 交叉验证功能。以下代码将训练集随机分成 10 个不重叠的子集,称为folds,然后对决策树模型进行 10 次训练和评估,每次选择一个不同的 fold 进行评估,并使用其他 9 个 folds 进行训练。结果是一个包含 10 个评估分数的数组:
from sklearn.model_selection import cross_val_score tree_rmses = -cross_val_score(tree_reg, housing, housing_labels, scoring="neg_root_mean_squared_error", cv=10)
警告
Scikit-Learn 的交叉验证功能期望一个效用函数(值越大越好)而不是成本函数(值越小越好),因此评分函数实际上与 RMSE 相反。它是一个负值,因此您需要改变输出的符号以获得 RMSE 分数。
让我们看看结果:
>>> pd.Series(tree_rmses).describe() count 10.000000 mean 66868.027288 std 2060.966425 min 63649.536493 25% 65338.078316 50% 66801.953094 75% 68229.934454 max 70094.778246 dtype: float64
现在决策树看起来不像之前那么好。事实上,它似乎表现几乎和线性回归模型一样糟糕!请注意,交叉验证允许您不仅获得模型性能的估计,还可以获得这个估计的精确度(即其标准偏差)。决策树的 RMSE 约为 66,868,标准偏差约为 2,061。如果您只使用一个验证集,您将无法获得这些信息。但是交叉验证的代价是多次训练模型,因此并非总是可行。
如果您对线性回归模型计算相同的度量,您会发现均值 RMSE 为 69,858,标准偏差为 4,182。因此,决策树模型似乎比线性模型表现稍微好一些,但由于严重过拟合,差异很小。我们知道存在过拟合问题,因为训练误差很低(实际上为零),而验证误差很高。
现在让我们尝试最后一个模型:RandomForestRegressor
。正如你将在第七章中看到的,随机森林通过在特征的随机子集上训练许多决策树,然后平均它们的预测来工作。这种由许多其他模型组成的模型被称为集成:它们能够提升底层模型的性能(在本例中是决策树)。代码与之前基本相同:
from sklearn.ensemble import RandomForestRegressor forest_reg = make_pipeline(preprocessing, RandomForestRegressor(random_state=42)) forest_rmses = -cross_val_score(forest_reg, housing, housing_labels, scoring="neg_root_mean_squared_error", cv=10)
让我们看一下分数:
>>> pd.Series(forest_rmses).describe() count 10.000000 mean 47019.561281 std 1033.957120 min 45458.112527 25% 46464.031184 50% 46967.596354 75% 47325.694987 max 49243.765795 dtype: float64
哇,这好多了:随机森林看起来对这个任务非常有前途!然而,如果你训练一个RandomForest
并在训练集上测量 RMSE,你会发现大约为 17,474:这个值要低得多,这意味着仍然存在相当多的过拟合。可能的解决方案是简化模型,约束它(即对其进行正则化),或者获得更多的训练数据。然而,在深入研究随机森林之前,你应该尝试许多其他来自各种机器学习算法类别的模型(例如,几个具有不同核的支持向量机,可能还有一个神经网络),而不要花费太多时间调整超参数。目标是列出几个(两到五个)有前途的模型。
调整模型
假设您现在有了几个有前途的模型的候选名单。现在您需要对它们进行微调。让我们看看您可以这样做的几种方法。
网格搜索
一种选择是手动调整超参数,直到找到一组很好的超参数值的组合。这将是非常繁琐的工作,你可能没有时间去探索很多组合。
相反,您可以使用 Scikit-Learn 的GridSearchCV
类来为您搜索。您只需要告诉它您想要尝试哪些超参数以及要尝试的值,它将使用交叉验证来评估所有可能的超参数值组合。例如,以下代码搜索RandomForestRegressor
的最佳超参数值组合:
from sklearn.model_selection import GridSearchCV full_pipeline = Pipeline([ ("preprocessing", preprocessing), ("random_forest", RandomForestRegressor(random_state=42)), ]) param_grid = [ {'preprocessing__geo__n_clusters': [5, 8, 10], 'random_forest__max_features': [4, 6, 8]}, {'preprocessing__geo__n_clusters': [10, 15], 'random_forest__max_features': [6, 8, 10]}, ] grid_search = GridSearchCV(full_pipeline, param_grid, cv=3, scoring='neg_root_mean_squared_error') grid_search.fit(housing, housing_labels)
请注意,您可以引用管道中任何估计器的任何超参数,即使这个估计器深度嵌套在多个管道和列转换器中。例如,当 Scikit-Learn 看到"preprocessing__geo__n_clusters"
时,它会在双下划线处拆分这个字符串,然后在管道中查找名为"preprocessing"
的估计器,并找到预处理ColumnTransformer
。接下来,它在这个ColumnTransformer
中查找名为"geo"
的转换器,并找到我们在纬度和经度属性上使用的ClusterSimilarity
转换器。然后找到这个转换器的n_clusters
超参数。类似地,random_forest__max_features
指的是名为"random_forest"
的估计器的max_features
超参数,这当然是RandomForest
模型(max_features
超参数将在第七章中解释)。
提示
将预处理步骤包装在 Scikit-Learn 管道中允许您调整预处理超参数以及模型超参数。这是一个好事,因为它们经常互动。例如,也许增加n_clusters
需要增加max_features
。如果适合管道转换器计算成本很高,您可以将管道的memory
超参数设置为缓存目录的路径:当您首次适合管道时,Scikit-Learn 将保存适合的转换器到此目录。然后,如果您再次使用相同的超参数适合管道,Scikit-Learn 将只加载缓存的转换器。
在这个param_grid
中有两个字典,所以GridSearchCV
将首先评估第一个dict
中指定的n_clusters
和max_features
超参数值的所有 3×3=9 个组合,然后它将尝试第二个dict
中超参数值的所有 2×3=6 个组合。因此,总共网格搜索将探索 9+6=15 个超参数值的组合,并且它将对每个组合进行 3 次管道训练,因为我们使用 3 折交叉验证。这意味着将有总共 15×3=45 轮训练!可能需要一段时间,但完成后,您可以像这样获得最佳参数组合:
>>> grid_search.best_params_ {'preprocessing__geo__n_clusters': 15, 'random_forest__max_features': 6}
在这个示例中,通过将n_clusters
设置为 15 并将max_features
设置为 8 获得了最佳模型。
提示
由于 15 是为n_clusters
评估的最大值,您可能应该尝试使用更高的值进行搜索;分数可能会继续提高。
您可以使用grid_search.best_estimator_
访问最佳估计器。如果GridSearchCV
初始化为refit=True
(这是默认值),那么一旦它使用交叉验证找到最佳估计器,它将在整个训练集上重新训练。这通常是一个好主意,因为提供更多数据可能会提高其性能。
评估分数可使用grid_search.cv_results_
获得。这是一个字典,但如果将其包装在 DataFrame 中,您将获得所有测试分数的一个很好的列表,每个超参数组合和每个交叉验证拆分的平均测试分数:
>>> cv_res = pd.DataFrame(grid_search.cv_results_) >>> cv_res.sort_values(by="mean_test_score", ascending=False, inplace=True) >>> [...] # change column names to fit on this page, and show rmse = -score >>> cv_res.head() # note: the 1st column is the row ID n_clusters max_features split0 split1 split2 mean_test_rmse 12 15 6 43460 43919 44748 44042 13 15 8 44132 44075 45010 44406 14 15 10 44374 44286 45316 44659 7 10 6 44683 44655 45657 44999 9 10 6 44683 44655 45657 44999
最佳模型的平均测试 RMSE 分数为 44,042,比使用默认超参数值(47,019)获得的分数更好。恭喜,您已成功微调了最佳模型!
随机搜索
当您探索相对较少的组合时,像在先前的示例中一样,网格搜索方法是可以接受的,但是RandomizedSearchCV
通常更可取,特别是当超参数搜索空间很大时。这个类可以像GridSearchCV
类一样使用,但是它不是尝试所有可能的组合,而是评估固定数量的组合,在每次迭代中为每个超参数选择一个随机值。这可能听起来令人惊讶,但这种方法有几个好处:
- 如果您的一些超参数是连续的(或离散的但具有许多可能的值),并且让随机搜索运行,比如说,1,000 次迭代,那么它将探索每个超参数的 1,000 个不同值,而网格搜索只会探索您为每个超参数列出的少量值。
- 假设一个超参数实际上并没有太大的差异,但您还不知道。如果它有 10 个可能的值,并且您将其添加到网格搜索中,那么训练将需要更长时间。但如果将其添加到随机搜索中,将不会有任何区别。
- 如果有 6 个要探索的超参数,每个超参数有 10 个可能的值,那么网格搜索除了训练模型一百万次之外别无选择,而随机搜索可以根据您选择的任意迭代次数运行。
对于每个超参数,您必须提供可能值的列表或概率分布:
from sklearn.model_selection import RandomizedSearchCV from scipy.stats import randint param_distribs = {'preprocessing__geo__n_clusters': randint(low=3, high=50), 'random_forest__max_features': randint(low=2, high=20)} rnd_search = RandomizedSearchCV( full_pipeline, param_distributions=param_distribs, n_iter=10, cv=3, scoring='neg_root_mean_squared_error', random_state=42) rnd_search.fit(housing, housing_labels)
Scikit-Learn 还有HalvingRandomSearchCV
和HalvingGridSearchCV
超参数搜索类。它们的目标是更有效地利用计算资源,要么训练更快,要么探索更大的超参数空间。它们的工作原理如下:在第一轮中,使用网格方法或随机方法生成许多超参数组合(称为“候选”)。然后使用这些候选来训练模型,并像往常一样使用交叉验证进行评估。然而,训练使用有限资源,这显著加快了第一轮的速度。默认情况下,“有限资源”意味着模型在训练集的一小部分上进行训练。然而,也可能存在其他限制,比如如果模型有一个超参数来设置它,则减少训练迭代次数。一旦每个候选都被评估过,只有最好的候选才能进入第二轮,他们被允许使用更多资源进行竞争。经过几轮后,最终的候选将使用全部资源进行评估。这可能会节省一些调整超参数的时间。
集成方法
微调系统的另一种方法是尝试组合表现最佳的模型。该组(或“集成”)通常比最佳单个模型表现更好——就像随机森林比它们依赖的单个决策树表现更好一样——特别是如果单个模型产生非常不同类型的错误。例如,您可以训练和微调一个k最近邻模型,然后创建一个集成模型,该模型只预测随机森林预测和该模型的预测的平均值。我们将在第七章中更详细地介绍这个主题。
分析最佳模型及其错误
通过检查最佳模型,您通常会对问题有很好的洞察。例如,RandomForestRegressor
可以指示每个属性对于进行准确预测的相对重要性:
>>> final_model = rnd_search.best_estimator_ # includes preprocessing >>> feature_importances = final_model["random_forest"].feature_importances_ >>> feature_importances.round(2) array([0.07, 0.05, 0.05, 0.01, 0.01, 0.01, 0.01, 0.19, [...], 0.01])
让我们按降序对这些重要性分数进行排序,并将它们显示在其对应的属性名称旁边:
>>> sorted(zip(feature_importances, ... final_model["preprocessing"].get_feature_names_out()), ... reverse=True) ... [(0.18694559869103852, 'log__median_income'), (0.0748194905715524, 'cat__ocean_proximity_INLAND'), (0.06926417748515576, 'bedrooms__ratio'), (0.05446998753775219, 'rooms_per_house__ratio'), (0.05262301809680712, 'people_per_house__ratio'), (0.03819415873915732, 'geo__Cluster 0 similarity'), [...] (0.00015061247730531558, 'cat__ocean_proximity_NEAR BAY'), (7.301686597099842e-05, 'cat__ocean_proximity_ISLAND')]
有了这些信息,您可能想尝试删除一些不太有用的特征(例如,显然只有一个ocean_proximity
类别是真正有用的,因此您可以尝试删除其他类别)。
提示
sklearn.feature_selection.SelectFromModel
转换器可以自动为您删除最不重要的特征:当您拟合它时,它会训练一个模型(通常是随机森林),查看其feature_importances_
属性,并选择最有用的特征。然后当您调用transform()
时,它会删除其他特征。
您还应该查看系统产生的具体错误,然后尝试理解为什么会出错以及如何解决问题:添加额外特征或去除无信息的特征,清理异常值等。
现在也是一个好时机确保您的模型不仅在平均情况下表现良好,而且在所有类型的地区(无论是农村还是城市,富裕还是贫穷,北方还是南方,少数民族还是非少数民族等)中都表现良好。为每个类别创建验证集的子集需要一些工作,但这很重要:如果您的模型在某个地区类别上表现不佳,那么在解决问题之前可能不应部署该模型,或者至少不应用于为该类别做出预测,因为这可能会带来更多伤害而不是好处。
在测试集上评估您的系统
调整模型一段时间后,最终你有了一个表现足够好的系统。你准备在测试集上评估最终模型。这个过程没有什么特别的;只需从测试集中获取预测变量和标签,运行你的final_model
来转换数据并进行预测,然后评估这些预测结果:
X_test = strat_test_set.drop("median_house_value", axis=1) y_test = strat_test_set["median_house_value"].copy() final_predictions = final_model.predict(X_test) final_rmse = mean_squared_error(y_test, final_predictions, squared=False) print(final_rmse) # prints 41424.40026462184
在某些情况下,这种泛化误差的点估计可能不足以说服您启动:如果它比当前生产中的模型仅好 0.1%怎么办?您可能想要知道这个估计有多精确。为此,您可以使用scipy.stats.t.interval()
计算泛化误差的 95%置信区间。您得到一个相当大的区间,从 39,275 到 43,467,而您之前的点估计为 41,424 大致位于其中间:
>>> from scipy import stats >>> confidence = 0.95 >>> squared_errors = (final_predictions - y_test) ** 2 >>> np.sqrt(stats.t.interval(confidence, len(squared_errors) - 1, ... loc=squared_errors.mean(), ... scale=stats.sem(squared_errors))) ... array([39275.40861216, 43467.27680583])
如果您进行了大量的超参数调整,性能通常会略低于使用交叉验证测量的性能。这是因为您的系统最终被微调以在验证数据上表现良好,可能不会在未知数据集上表现得那么好。在这个例子中并非如此,因为测试 RMSE 低于验证 RMSE,但当发生这种情况时,您必须抵制调整超参数以使测试集上的数字看起来好的诱惑;这种改进不太可能推广到新数据。
现在是项目预启动阶段:您需要展示您的解决方案(突出显示您学到了什么,什么有效,什么无效,做出了什么假设以及您的系统有什么限制),记录一切,并创建具有清晰可视化和易于记忆的陈述的漂亮演示文稿(例如,“收入中位数是房价的头号预测因子”)。在这个加利福尼亚州住房的例子中,系统的最终性能并不比专家的价格估计好多少,这些估计通常偏离 30%,但如果这样做可以为专家节省一些时间,让他们可以从事更有趣和更有成效的任务,那么启动它可能仍然是一个好主意。
启动、监控和维护您的系统
完美,您已获得启动批准!现在您需要准备好将解决方案投入生产(例如,完善代码,编写文档和测试等)。然后,您可以将模型部署到生产环境。最基本的方法就是保存您训练的最佳模型,将文件传输到生产环境,并加载它。要保存模型,您可以像这样使用joblib
库:
import joblib joblib.dump(final_model, "my_california_housing_model.pkl")
提示
通常最好保存您尝试的每个模型,这样您就可以轻松地返回到您想要的任何模型。您还可以保存交叉验证分数,也许是验证集上的实际预测结果。这将使您能够轻松比较不同模型类型之间的分数,并比较它们所产生的错误类型。
一旦您的模型转移到生产环境,您就可以加载并使用它。为此,您必须首先导入模型依赖的任何自定义类和函数(这意味着将代码转移到生产环境),然后使用joblib
加载模型并用它进行预测:
import joblib [...] # import KMeans, BaseEstimator, TransformerMixin, rbf_kernel, etc. def column_ratio(X): [...] def ratio_name(function_transformer, feature_names_in): [...] class ClusterSimilarity(BaseEstimator, TransformerMixin): [...] final_model_reloaded = joblib.load("my_california_housing_model.pkl") new_data = [...] # some new districts to make predictions for predictions = final_model_reloaded.predict(new_data)
例如,也许该模型将在网站内使用:用户将输入有关新区域的一些数据,然后点击“估算价格”按钮。这将向包含数据的查询发送到 Web 服务器,Web 服务器将其转发到您的 Web 应用程序,最后您的代码将简单地调用模型的predict()
方法(您希望在服务器启动时加载模型,而不是每次使用模型时都加载)。或者,您可以将模型封装在专用的 Web 服务中,您的 Web 应用程序可以通过 REST API 查询该服务¹³(请参见图 2-20)。这样可以更容易地将模型升级到新版本,而不会中断主要应用程序。它还简化了扩展,因为您可以启动所需数量的 Web 服务,并将来自 Web 应用程序的请求负载均衡到这些 Web 服务中。此外,它允许您的 Web 应用程序使用任何编程语言,而不仅仅是 Python。
图 2-20. 作为 Web 服务部署并由 Web 应用程序使用的模型
另一种流行的策略是将模型部署到云端,例如在 Google 的 Vertex AI 上(以前称为 Google Cloud AI 平台和 Google Cloud ML 引擎):只需使用joblib
保存您的模型并将其上传到 Google Cloud Storage(GCS),然后转到 Vertex AI 并创建一个新的模型版本,将其指向 GCS 文件。就是这样!这为您提供了一个简单的网络服务,可以为您处理负载平衡和扩展。它接受包含输入数据(例如一个地区的数据)的 JSON 请求,并返回包含预测的 JSON 响应。然后,您可以在您的网站(或您正在使用的任何生产环境)中使用此网络服务。正如您将在第十九章中看到的那样,将 TensorFlow 模型部署到 Vertex AI 与部署 Scikit-Learn 模型并没有太大不同。
但部署并不是故事的终点。您还需要编写监控代码,定期检查系统的实时性能,并在性能下降时触发警报。它可能会非常快速地下降,例如如果您的基础设施中的某个组件出现故障,但请注意,它也可能会非常缓慢地衰减,这很容易在很长一段时间内不被注意到。这是很常见的,因为模型腐烂:如果模型是使用去年的数据训练的,那么它可能不适应今天的数据。
因此,您需要监控模型的实时性能。但如何做到这一点呢?嗯,这取决于情况。在某些情况下,可以从下游指标推断模型的性能。例如,如果您的模型是推荐系统的一部分,并且建议用户可能感兴趣的产品,那么每天销售的推荐产品数量很容易监控。如果这个数字下降(与非推荐产品相比),那么主要嫌疑人就是模型。这可能是因为数据管道出现问题,或者可能是模型需要重新训练以适应新数据(我们将很快讨论)。
但是,您可能还需要人工分析来评估模型的性能。例如,假设您训练了一个图像分类模型(我们将在第三章中讨论这些内容)来检测生产线上各种产品缺陷。在数千个有缺陷的产品运送给客户之前,如何在模型性能下降时获得警报?一个解决方案是向人工评分员发送模型分类的所有图片样本(特别是模型不太确定的图片)。根据任务的不同,评分员可能需要是专家,也可能是非专业人员,例如众包平台上的工人(例如,亚马逊 Mechanical Turk)。在某些应用中,甚至可以是用户自己,例如通过调查或重新利用的验证码进行回应。
无论如何,您都需要建立一个监控系统(无论是否有人工评分员来评估实时模型),以及定义在发生故障时应该采取的所有相关流程以及如何为其做好准备。不幸的是,这可能是一项很多工作。事实上,通常比构建和训练模型要多得多。
如果数据不断发展,您将需要定期更新数据集并重新训练模型。您应该尽可能自动化整个过程。以下是一些您可以自动化的事项:
- 定期收集新数据并对其进行标记(例如,使用人工评分员)。
- 编写一个脚本来自动训练模型并微调超参数。该脚本可以自动运行,例如每天或每周一次,具体取决于您的需求。
- 编写另一个脚本,将在更新的测试集上评估新模型和先前模型,并在性能没有下降时将模型部署到生产环境(如果性能下降了,请确保调查原因)。该脚本可能会测试模型在测试集的各种子集上的性能,例如贫困或富裕地区,农村或城市地区等。
您还应该确保评估模型的输入数据质量。有时性能会因为信号质量不佳(例如,故障传感器发送随机值,或其他团队的输出变得陈旧)而略微下降,但可能需要一段时间,直到系统性能下降到足以触发警报。如果监控模型的输入,您可能会更早地发现这一点。例如,如果越来越多的输入缺少某个特征,或者均值或标准差与训练集相差太远,或者分类特征开始包含新类别,您可以触发警报。
最后,确保您保留您创建的每个模型的备份,并具备回滚到先前模型的过程和工具,以防新模型因某种原因开始严重失败。备份还使得可以轻松地将新模型与先前模型进行比较。同样,您应该保留每个数据集版本的备份,以便在新数据集损坏时(例如,如果添加到其中的新数据被证明充满异常值),可以回滚到先前数据集。备份数据集还可以让您评估任何模型与任何先前数据集的关系。
正如您所看到的,机器学习涉及相当多的基础设施。第十九章讨论了其中的一些方面,但这是一个名为ML Operations(MLOps)的非常广泛的主题,值得有一本专门的书来讨论。因此,如果您的第一个机器学习项目需要大量的工作和时间来构建和部署到生产环境,也不要感到惊讶。幸运的是,一旦所有基础设施就绪,从构思到生产将会更快。
试一试!
希望本章让您对机器学习项目的外观有了一个很好的了解,并向您展示了一些可以用来训练出色系统的工具。正如您所看到的,大部分工作在数据准备阶段:构建监控工具,设置人工评估流水线,以及自动化常规模型训练。当然,机器学习算法很重要,但可能更好的是熟悉整个过程,并且对三四种算法很熟悉,而不是花费所有时间探索高级算法。
因此,如果您还没有这样做,现在是时候拿起笔记本电脑,选择您感兴趣的数据集,并尝试从头到尾完成整个过程。一个很好的开始地方是在竞赛网站上,比如Kaggle:您将有一个数据集可以使用,一个明确的目标,以及可以分享经验的人。玩得开心!
练习
以下练习基于本章的房屋数据集:
- 尝试使用各种超参数的支持向量机回归器(
sklearn.svm.SVR
),例如kernel="linear"
(对于C
超参数的各种值)或kernel="rbf"
(对于C
和gamma
超参数的各种值)。请注意,支持向量机在大型数据集上不容易扩展,因此您可能应该仅在训练集的前 5,000 个实例上训练模型,并仅使用 3 折交叉验证,否则将需要数小时。现在不用担心超参数的含义;我们将在第五章中讨论它们。最佳SVR
预测器的表现如何? - 尝试用
RandomizedSearchCV
替换GridSearchCV
。 - 尝试在准备流水线中添加
SelectFromModel
转换器,仅选择最重要的属性。 - 尝试创建一个自定义转换器,在其
fit()
方法中训练一个k最近邻回归器(sklearn.neighbors.KNeighborsRegressor
),并在其transform()
方法中输出模型的预测。然后将此功能添加到预处理流水线中,使用纬度和经度作为该转换器的输入。这将在模型中添加一个特征,该特征对应于最近地区的房屋中位数价格。 - 使用
GridSearchCV
自动探索一些准备选项。 - 尝试从头开始再次实现
StandardScalerClone
类,然后添加对inverse_transform()
方法的支持:执行scaler.inverse_transform(scaler.fit_transform(X))
应该返回一个非常接近X
的数组。然后添加特征名称的支持:如果输入是 DataFrame,则在fit()
方法中设置feature_names_in_
。该属性应该是一个列名的 NumPy 数组。最后,实现get_feature_names_out()
方法:它应该有一个可选的input_features=None
参数。如果传递了,该方法应该检查其长度是否与n_features_in_
匹配,并且如果定义了feature_names_in_
,则应该匹配;然后返回input_features
。如果input_features
是None
,那么该方法应该返回feature_names_in_
(如果定义了)或者长度为n_features_in_
的np.array(["x0", "x1", ...])
。
这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3。
原始数据集出现在 R. Kelley Pace 和 Ronald Barry 的“稀疏空间自回归”中,统计与概率信件 33,第 3 期(1997 年):291-297。
馈送给机器学习系统的信息片段通常称为信号,这是指克劳德·香农在贝尔实验室开发的信息论,他的理论是:您希望有高的信噪比。
记住,转置运算符将列向量翻转为行向量(反之亦然)。
您可能还需要检查法律约束,例如不应将私有字段复制到不安全的数据存储中。
标准差通常用σ(希腊字母 sigma)表示,它是方差的平方根,即与均值的平方偏差的平均值。当一个特征具有钟形的正态分布(也称为高斯分布)时,这是非常常见的,“68-95-99.7”规则适用:大约 68%的值落在均值的 1σ范围内,95%在 2σ范围内,99.7%在 3σ范围内。
经常会看到人们将随机种子设置为 42。这个数字没有特殊的属性,除了它是生命、宇宙和一切终极问题的答案。
位置信息实际上相当粗糙,因此许多地区将具有相同的 ID,因此它们最终会进入相同的集合(测试或训练)。这引入了一些不幸的抽样偏差。
如果您是在灰度模式下阅读本文,请拿一支红笔,在从旧金山湾到圣迭戈的大部分海岸线上涂鸦(正如您所期望的那样)。您也可以在萨克拉门托周围添加一块黄色的区域。
有关设计原则的更多详细信息,请参阅 Lars Buitinck 等人的“机器学习软件的 API 设计经验:来自 Scikit-Learn 项目的经验”,arXiv 预印本 arXiv:1309.0238(2013)。
一些预测器还提供测量其预测置信度的方法。
当您阅读这些文字时,可能会有可能使所有的转换器在接收到 DataFrame 作为输入时输出 Pandas DataFrames:Pandas 输入,Pandas 输出。可能会有一个全局配置选项:sklearn.set_config(pandas_in_out=True)
。
查看 SciPy 的文档以获取更多详细信息。
¹³ 简而言之,REST(或 RESTful)API 是基于 HTTP 的 API,遵循一些约定,例如使用标准的 HTTP 动词来读取、更新、创建或删除资源(GET、POST、PUT 和 DELETE),并使用 JSON 作为输入和输出。
¹⁴ 验证码是一种测试,用于确保用户不是机器人。这些测试经常被用作标记训练数据的廉价方式。