原文:
annas-archive.org/md5/ec14cdde5f82b4b7e0113bdbb2bbe4c7
译者:飞龙
第九章:Y 与 X 同样重要
我们给予输入特征很多关注,即我们的x
。我们使用算法对它们进行缩放、从中选择,并工程化地添加新的特征。尽管如此,我们也应当同样关注目标变量,即y
。有时,缩放你的目标可以帮助你使用更简单的模型。而有时候,你可能需要一次预测多个目标。那时,了解你的目标的分布及其相互依赖关系是至关重要的。在本章中,我们将重点讨论目标以及如何处理它们。
在本章中,我们将涵盖以下主题:
-
缩放你的回归目标
-
估计多个回归目标
-
处理复合分类目标
-
校准分类器的概率
-
计算 K 的精确度
缩放你的回归目标
在回归问题中,有时对目标进行缩放可以节省时间,并允许我们为当前问题使用更简单的模型。在本节中,我们将看到如何通过改变目标的尺度来简化估计器的工作。
在以下示例中,目标与输入之间的关系是非线性的。因此,线性模型不能提供最佳结果。我们可以使用非线性算法、转换特征或转换目标。在这三种选择中,转换目标有时可能是最简单的。请注意,我们这里只有一个特征,但在处理多个特征时,首先考虑转换目标是有意义的。
以下图表显示了单一特征x
与因变量y
之间的关系:
在你我之间,以下代码用于生成数据,但为了学习的目的,我们可以假装目前不知道y
和x
之间的关系:
x = np.random.uniform(low=5, high=20, size=100)
e = np.random.normal(loc=0, scale=0.5, size=100)
y = (x + e) ** 3
一维输入(x
)在5和20之间均匀分布。y
与x
之间的关系是立方的,并且向x
添加了少量正态分布的噪声。
在拆分数据之前,我们需要将x从向量转换为矩阵,如下所示:
from sklearn.model_selection import train_test_split
x = x.reshape((x.shape[0],1))
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.25)
现在,如果我们将数据拆分为训练集和测试集,并运行岭回归,我们将得到一个平均绝对误差(MAE)为559
。由于数据是随机生成的,你的结果可能有所不同。我们能做得更好吗?
请记住,在本章中提到的大多数示例中,你最终得到的结果可能与我的有所不同。我在生成和拆分数据时选择不使用随机状态,因为我的主要目标是解释概念,而不是关注最终的结果和运行代码时的准确度分数。
让我们创建一个简单的变换器,根据给定的 power
来转换目标。当 power
设置为 1
时,不对目标进行任何变换;否则,目标会被提升到给定的幂次。我们的变换器有一个互补的 inverse_transform()
方法,将目标重新转换回原始尺度:
class YTransformer:
def __init__(self, power=1):
self.power = power
def fit(self, x, y):
pass
def transform(self, x, y):
return x, np.power(y, self.power)
def inverse_transform(self, x, y):
return x, np.power(y, 1/self.power)
def fit_transform(self, x, y):
return self.transform(x, y)
现在,我们可以尝试不同的幂次设置,并循环遍历不同的变换,直到找到给出最佳结果的变换:
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error
from sklearn.metrics import r2_score
for power in [1, 1/2, 1/3, 1/4, 1/5]:
yt = YTransformer(power)
_, y_train_t = yt.fit_transform(None, y_train)
_, y_test_t = yt.transform(None, y_test)
rgs = Ridge()
rgs.fit(x_train, y_train_t)
y_pred_t = rgs.predict(x_test)
_, y_pred = yt.inverse_transform(None, y_pred_t)
print(
'Transformed y^{:.2f}: MAE={:.0f}, R2={:.2f}'.format(
power,
mean_absolute_error(y_test, y_pred),
r2_score(y_test, y_pred),
)
)
将预测值转换回原始值是至关重要的。否则,计算的误差指标将无法进行比较,因为不同的幂次设置会导致不同的数据尺度。
因此,inverse_transform()
方法在预测步骤后使用。在我的随机生成的数据上运行代码得到了以下结果:
Transformed y¹.00: MAE=559, R2=0.89
Transformed y⁰.50: MAE=214, R2=0.98
Transformed y⁰.33: MAE=210, R2=0.97
Transformed y⁰.25: MAE=243, R2=0.96
Transformed y⁰.20: MAE=276, R2=0.95
如预期的那样,当使用正确的变换时,最低的误差和最高的 R²
被实现,这正是当幂次设置为 https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/01bbc660-8e6f-4a20-8edb-4b814412761f.png 时。
对数变换、指数变换和平方根变换是统计学家最常用的变换。当执行预测任务时,特别是在使用线性模型时,使用这些变换是有意义的。
对数变换仅对正值有效。Log(0)
是未定义的,对负数取对数会得到虚数值。因此,对数变换通常应用于处理非负目标。为了确保我们不会遇到 log(0)
,一个技巧是,在转换目标之前,先给所有目标值加 1,然后在将预测结果反向转换后再减去 1。同样,对于平方根变换,我们也需要确保一开始没有负目标值。
与其一次处理一个目标,我们有时可能希望一次预测多个目标。当多个回归任务使用相同特征时,将它们合并为一个模型可以简化代码。如果你的目标是相互依赖的,推荐使用这种方法。在下一部分,我们将看到如何一次性估计多个回归目标。
估算多个回归目标
在你的在线业务中,你可能想估算用户在下个月、下个季度和明年的生命周期价值。你可以为这三个单独的估算构建三个不同的回归模型。然而,当这三个估算使用完全相同的特征时,构建一个具有三个输出的回归器会更为实用。在下一部分,我们将看到如何构建一个多输出回归器,然后我们将学习如何使用回归链在这些估算之间注入相互依赖关系。
构建一个多输出回归器
一些回归器允许我们一次预测多个目标。例如,岭回归器允许给定二维目标。换句话说,y
不再是单维数组,而是可以作为矩阵给定,其中每列代表一个不同的目标。对于只允许单一目标的其他回归器,我们可能需要使用多输出回归器元估算器。
为了演示这个元估算器,我将使用make_regression
辅助函数来创建一个我们可以调整的数据集:
from sklearn.datasets import make_regression
x, y = make_regression(
n_samples=500, n_features=8, n_informative=8, n_targets=3, noise=30.0
)
在这里,我们创建500
个样本,具有 8 个特征和 3 个目标;即返回的x
和y
的形状分别为(500
,8
)和(500
,3
)。我们还可以为特征和目标指定不同的名称,然后按如下方式将数据拆分为训练集和测试集:
feature_names = [f'Feature # {i}' for i in range(x.shape[1])]
target_names = [f'Target # {i}' for i in range(y.shape[1])]
from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.25)
由于SGDRegressor
不支持多目标,因此以下代码将抛出一个值错误,抱怨输入的形状不正确:
from sklearn.linear_model import SGDRegressor
rgr = SGDRegressor()
rgr.fit(x_train, y_train)
因此,我们必须将MultiOutputRegressor
包裹在SGDRegressor
周围才能使其工作:
from sklearn.multioutput import MultiOutputRegressor
from sklearn.linear_model import SGDRegressor
rgr = MultiOutputRegressor(
estimator=SGDRegressor(),
n_jobs=-1
)
rgr.fit(x_train, y_train)
y_pred = rgr.predict(x_test)
我们现在可以将预测结果输出到数据框中:
df_pred = pd.DataFrame(y_pred, columns=target_names)
同时,检查每个目标的前几次预测。以下是我在这里得到的预测示例。请记住,您可能会得到不同的结果:
我们还可以分别打印每个目标的模型表现:
from sklearn.metrics import mean_absolute_error
from sklearn.metrics import r2_score
for t in range(y_train.shape[1]):
print(
'Target # {}: MAE={:.2f}, R2={:.2f}'.format(
t,
mean_absolute_error(y_test[t], y_pred[t]),
r2_score(y_test[t], y_pred[t]),
)
)
在某些情况下,知道一个目标可能有助于了解其他目标。在前面提到的生命周期价值估算示例中,预测下一个月的结果对季度和年度预测非常有帮助。为了将一个目标的预测作为输入传递给连续的回归器,我们需要使用回归器链元估算器。
链接多个回归器
在前一节的数据集中,我们无法确定生成的目标是否相互依赖。现在,假设第二个目标依赖于第一个目标,第三个目标依赖于前两个目标。我们稍后将验证这些假设。为了引入这些相互依赖关系,我们将使用RegressorChain
并指定假设的依赖关系顺序。order
列表中的 ID 顺序指定列表中的每个 ID 依赖于前面的 ID。使用正则化回归器是有意义的。正则化是必要的,用于忽略目标之间不存在的任何假定依赖关系。
以下是创建回归器链的代码:
from sklearn.multioutput import RegressorChain
from sklearn.linear_model import Ridge
rgr = RegressorChain(
base_estimator=Ridge(
alpha=1
),
order=[0,1,2],
)
rgr.fit(x_train, y_train)
y_pred = rgr.predict(x_test)
测试集的表现几乎与使用MultiOutputRegressor
时的表现相同。看起来链式方法并没有帮助当前的数据集。我们可以显示每个Ridge
回归器在训练后的系数。第一个估算器只使用输入特征,而后面的估算器则为输入特征以及之前的目标分配系数。以下是如何显示链中第三个估算器的系数:
pd.DataFrame(
zip(
rgr.estimators_[-1].coef_,
feature_names + target_names
),
columns=['Coeff', 'Feature']
)[
['Feature', 'Coeff']
].style.bar(
subset=['Coeff'], align='mid', color='#AAAAAA'
)
从计算出的系数来看,我们可以看到链中的第三个估算器几乎忽略了前两个目标。由于这些目标是独立的,链中的每个估算器仅使用输入特征。尽管你在运行代码时得到的系数可能有所不同,但由于目标的独立性,分配给前两个目标的系数仍然是微不足道的:
在目标之间存在依赖关系的情况下,我们期望目标会分配更大的系数。在实际应用中,我们可能会尝试不同的order
超参数组合,直到找到最佳性能为止。
与回归问题一样,分类器也可以处理多个目标。然而,单个目标可以是二元的,或者具有两个以上的值。这为分类问题增添了更多细节。在下一部分,我们将学习如何构建分类器来满足复合目标的需求。
处理复合分类目标
与回归器类似,分类器也可以有多个目标。此外,由于目标是离散的,单个目标可以具有两个或更多的值。为了区分不同的情况,机器学习实践者提出了以下术语:
-
多类
-
多标签(和多输出)
以下矩阵总结了上述术语。我将通过一个示例进一步说明,并在本章后续内容中也会详细阐述多标签和多输出术语之间的细微区别:
想象一个场景,你需要根据图片中是否包含猫来进行分类。在这种情况下,需要一个二元分类器,也就是说,目标值要么是零,要么是一个。当问题涉及判断图片中是否有猫、狗或人类时,目标的种类数就超出了二个,此时问题就被表述为多类分类问题。
图片中也可能包含多个对象。一个图片可能只包含一只猫,而另一个图片则同时包含人类和猫。在多标签设置中,我们将构建一组二元分类器:一个用于判断图片中是否有猫,另一个用于判断是否有狗,再有一个用于判断是否有人类。为了在不同目标之间注入相互依赖关系,你可能希望一次性预测所有同时出现的标签。在这种情况下,通常会使用“多输出”这一术语。
此外,你可以使用一组二分类器来解决多类问题。与其判断图片里是否有猫、狗或人类,不如设置一个分类器判断是否有猫,一个分类器判断是否有狗,另一个判断是否有人类。这对于模型的可解释性很有用,因为每个分类器的系数可以映射到单一类别。在接下来的章节中,我们将使用一对多策略将多类问题转换为一组二分类问题。
将多类问题转换为一组二分类器
我们不必局限于多类问题。我们可以简单地将手头的多类问题转换为一组二分类问题。
在这里,我们构建了一个包含 5000 个样本、15 个特征和 1 个标签(具有 4 个可能值)的数据集:
from sklearn.datasets import make_classification
x, y = make_classification(
n_samples=5000, n_features=15, n_informative=8, n_redundant=2,
n_classes=4, class_sep=0.5,
)
在通常的方式划分数据后,并保留 25%用于测试,我们可以在LogisticRegression
之上应用一对多策略。顾名思义,它是一个元估计器,构建多个分类器来判断每个样本是否属于某个类别,最终将所有的决策结合起来:
from sklearn.linear_model import LogisticRegression
from sklearn.multiclass import OneVsRestClassifier
from sklearn.metrics import accuracy_score
clf = OneVsRestClassifier(
estimator=LogisticRegression(solver='saga')
)
clf.fit(x_train, y_train)
y_pred = clf.predict(x_test)
我使用了 saga 求解器,因为它对较大数据集收敛更快。一对多策略给我带来了0.43
的准确率。我们可以通过estimators
方法访问元估计器使用的底层二分类器,然后可以揭示每个底层二分类器为每个特征学习的系数。**
**另一种策略是一对一。它为每一对类别构建独立的分类器,使用方法如下:
from sklearn.linear_model import LogisticRegression
from sklearn.multiclass import OneVsOneClassifier
clf = OneVsOneClassifier(
estimator=LogisticRegression(solver='saga')
)
clf.fit(x_train, y_train)
y_pred = clf.predict(x_test)
accuracy_score(y_test, y_pred)
一对一策略给我带来了0.44
的可比准确率。我们可以看到,当处理大量类别时,之前的两种策略可能无法很好地扩展。OutputCodeClassifier
是一种更具可扩展性的解决方案。通过将其code_size
超参数设置为小于 1 的值,它可以将标签编码为更密集的表示。较低的code_size
将提高其计算性能,但会以牺牲准确性和可解释性为代价。**
**通常,一对多是最常用的策略,如果你的目标是为每个类别分离系数,它是一个很好的起点。
为了确保所有类别的返回概率加起来为 1,一对多策略通过将概率除以其总和来规范化这些概率。另一种概率规范化的方法是Softmax()
函数。它将每个概率的指数除以所有概率指数的总和。Softmax()
函数也用于多项式逻辑回归,而不是Logistic()
函数,使其作为多类分类器运作,而无需使用一对多或一对一策略。
估计多个分类目标
与 MultiOutputRegressor
一样,MultiOutputClassifier
是一个元估算器,允许底层估算器处理多个输出。
让我们创建一个新的数据集,看看如何使用 MultiOutputClassifier
:
from sklearn.datasets import make_multilabel_classification
x, y = make_multilabel_classification(
n_samples=500, n_features=8, n_classes=3, n_labels=2
)
这里首先需要注意的是,n_classes
和 n_labels
这两个术语在 make_multilabel_classification
辅助函数中具有误导性。前面的设置创建了 500 个样本,包含 3 个二元目标。我们可以通过打印返回的 x
和 y
的形状,以及 y
的基数来确认这一点:
x.shape, y.shape # ((500, 8), (500, 3))
np.unique(y) # array([0, 1])
然后,我们强制第三个标签完全依赖于第一个标签。我们稍后会利用这个事实:
y[:,-1] = y[:,0]
在像往常一样划分数据集,并将 25% 用于测试之后,我们会注意到 GradientBoostingClassifier
无法处理我们所拥有的三个目标。一些分类器能够在没有外部帮助的情况下处理多个目标。然而,MultiOutputClassifier
估算器是我们这次决定使用的分类器所必需的:
from sklearn.multioutput import MultiOutputClassifier
from sklearn.ensemble import GradientBoostingClassifier
clf = MultiOutputClassifier(
estimator=GradientBoostingClassifier(
n_estimators=500,
learning_rate=0.01,
subsample=0.8,
),
n_jobs=-1
)
clf.fit(x_train, y_train)
y_pred_multioutput = clf.predict(x_test)
我们已经知道,第一个和第三个目标是相关的。因此,ClassifierChain
可能是一个很好的替代选择,可以尝试代替 MultiOutputClassifier
估算器。然后,我们可以使用它的 order
超参数来指定目标的依赖关系,如下所示:
from sklearn.multioutput import ClassifierChain
from sklearn.ensemble import GradientBoostingClassifier
clf = ClassifierChain(
base_estimator=GradientBoostingClassifier(
n_estimators=500,
learning_rate=0.01,
subsample=0.8,
),
order=[0,1,2]
)
clf.fit(x_train, y_train)
y_pred_chain = clf.predict(x_test)
现在,如果我们像之前对 RegressorChain
所做的那样,显示第三个估算器的系数,我们可以看到它只是复制了对第一个目标所做的预测,并直接使用这些预测。因此,除了分配给第一个目标的系数外,所有系数都被设置为零,如下所示:
如你所见,每当我们希望使用的估算器不支持多个目标时,我们都能得到覆盖。我们还可以告诉我们的估算器在预测下一个目标时应使用哪些目标。
在许多现实生活中的场景中,我们更关心分类器预测的概率,而不是它的二元决策。一个良好校准的分类器会产生可靠的概率,这在风险计算中至关重要,并有助于实现更高的精度。
在接下来的部分中,我们将看到如何校准我们的分类器,特别是当它们的估计概率默认情况下不可靠时。
校准分类器的概率
“每个企业和每个产品都有风险。你无法回避它。”
– 李·艾科卡
假设我们想要预测某人是否会感染病毒性疾病。然后我们可以构建一个分类器来预测他们是否会感染该病毒。然而,当可能感染的人群比例过低时,分类器的二分类预测可能不够精确。因此,在这种不确定性和有限资源的情况下,我们可能只想将那些感染概率超过 90%的人隔离起来。分类器的预测概率听起来是一个很好的估算来源。然而,只有当我们预测为某一类别且其概率超过 90%的样本中,90%(9 个中有 9 个)最终确实属于该类别时,这个概率才能被认为是可靠的。同样,对于 80%以上的概率,最终 80%的样本也应该属于该类别。换句话说,对于一个完美校准的模型,我们在绘制目标类别样本百分比与分类器预测概率之间的关系时,应该得到一条 45°的直线:
一些模型通常已经经过良好的校准,例如逻辑回归分类器。另一些模型则需要我们在使用之前对其概率进行校准。为了演示这一点,我们将创建一个以下的二分类数据集,包含 50,000 个样本和15
个特征。我使用了较低的class_sep
值,以确保这两个类别不容易分开:
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
x, y = make_classification(
n_samples=50000, n_features=15, n_informative=5, n_redundant=10,
n_classes=2, class_sep=0.001
)
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.25)
然后我训练了一个高斯朴素贝叶斯分类器,并存储了正类的预测概率。由于其天真的假设,朴素贝叶斯分类器通常会返回不可靠的概率,正如我们在第六章《使用朴素贝叶斯分类文本》中讨论的那样。由于我们处理的是连续特征,因此这里使用GaussianNB
分类器:
from sklearn.naive_bayes import GaussianNB
clf = GaussianNB()
clf.fit(x_train, y_train)
y_pred_proba = clf.predict_proba(x_test)[:,-1]
Scikit-learn 提供了绘制分类器校准曲线的工具。它将估计的概率划分为多个区间,并计算每个区间中属于正类的样本比例。在以下代码片段中,我们将区间数设置为10
,并使用计算出的概率来创建校准曲线:
from sklearn.calibration import calibration_curve
fraction_of_positives, mean_predicted_value = calibration_curve(
y_test, y_pred_proba, n_bins=10
)
fig, ax = plt.subplots(1, 1, figsize=(10, 8))
ax.plot(
mean_predicted_value, fraction_of_positives, "--",
label='Uncalibrated GaussianNB', color='k'
)
fig.show()
我为了简洁起见,省略了负责图形格式化的代码部分。运行代码后,我得到了以下的曲线:
如你所见,模型远未经过校准。因此,我们可以使用CalibratedClassifierCV
来调整其概率:
from sklearn.calibration import CalibratedClassifierCV
from sklearn.naive_bayes import GaussianNB
clf_calib = CalibratedClassifierCV(GaussianNB(), cv=3, method='isotonic')
clf_calib.fit(x_train, y_train)
y_pred_calib = clf_calib.predict(x_test)
y_pred_proba_calib = clf_calib.predict_proba(x_test)[:,-1]
在下图中,我们可以看到CalibratedClassifierCV
对模型的影响,其中新的概率估算更加可靠:**
CalibratedClassifierCV
使用两种校准方法:sigmoid()
和isotonic()
方法。推荐在小数据集上使用sigmoid()
方法,因为isotonic()
方法容易过拟合。此外,校准应在与模型拟合时使用的不同数据上进行。CalibratedClassifierCV
允许我们进行交叉验证,将用于拟合基础估计器的数据与用于校准的数据分开。在之前的代码中使用了三折交叉验证。
如果线性回归旨在最小化平方误差,并假设目标y与特征x之间的关系为由y = f(x)表示的线性方程,那么等距回归则有不同的假设,旨在最小化平方误差。它假设f(x)是一个非线性但单调的函数。换句话说,它随着x的增大要么持续增加,要么持续减少。等距回归的这种单调性特征使其适用于概率校准。**
除了校准图,Brier 得分是检查模型是否校准的好方法。它基本上计算了预测概率与实际目标之间的均方误差(MSE)。因此,较低的 Brier 得分反映出更可靠的概率。
在下一节中,我们将学习如何使用分类器对预测结果进行排序,然后如何评估这个排序。
计算 k 时的精度
在上一节中关于病毒感染的示例中,您的隔离能力可能仅限于例如 500 名患者。在这种情况下,您会希望根据预测概率,尽可能多的阳性病例出现在前 500 名患者中。换句话说,我们不太关心模型的整体精度,因为我们只关心它在前k
样本中的精度。
我们可以使用以下代码计算前k
样本的精度:
def precision_at_k_score(y_true, y_pred_proba, k=1000, pos_label=1):
topk = [
y_true_ == pos_label
for y_true_, y_pred_proba_
in sorted(
zip(y_true, y_pred_proba),
key=lambda y: y[1],
reverse=True
)[:k]
]
return sum(topk) / len(topk)
如果您不太喜欢函数式编程范式,那么让我详细解释一下代码。zip()
方法将两个列表合并,并返回一个元组列表。列表中的第一个元组将包含y_true
的第一个项以及y_pred_proba
的第一个项。第二个元组将包含它们的第二个项,以此类推。然后,我根据元组的第二个元素,即y_pred_proba
,对元组列表按降序进行排序(reverse=True
)。接着,我取排序后的前k
个元组,并将它们的y_true
部分与pos_label
参数进行比较。pos_label
参数允许我决定基于哪个标签进行精度计算。最后,我计算了在topk
中实际属于pos_label
类的元素所占的比例。
现在,我们可以计算未校准的GaussianNB
分类器在前 500 个预测中的精度:
precision_at_k_score(y_test, y_pred_proba, k=500)
这为我们提供了前500
个样本的82%
精度,相比之下,所有正类样本的总体精度为62%
。再次提醒,你的结果可能与我的不同。
k
精度指标是处理不平衡数据或难以分离的类别时非常有用的工具,尤其是当你只关心模型在前几个预测中的准确度时。它允许你调整模型,以捕捉最重要的样本。我敢打赌,谷歌比起你在第 80 页看到的搜索结果,更关心你在第一页看到的结果。而如果我只有足够的钱购买 20 只股票,我希望模型能正确预测前 20 只股票的走势,对于第 100 只股票的准确性我倒不太关心。
总结
在处理分类或回归问题时,我们通常会首先考虑我们应该在模型中包含哪些特征。然而,解决方案的关键往往在于目标值。正如我们在本章所看到的,重新缩放我们的回归目标可以帮助我们使用更简单的模型。此外,校准我们分类器给出的概率可以迅速提高我们的准确度,并帮助我们量化不确定性。我们还学会了通过编写一个单一的估计器来同时预测多个输出,从而处理多个目标。这有助于简化我们的代码,并使得估计器可以利用从一个标签中学到的知识来预测其他标签。
在现实生活中的分类问题中,类别不平衡是常见的。当检测欺诈事件时,你的数据中大多数通常是非欺诈案例。同样,对于诸如谁会点击你的广告,谁会订阅你的新闻通讯等问题,通常是少数类对你来说更为重要。
在下一章,我们将看到如何通过修改训练数据来让分类器更容易处理不平衡的数据集。**********
第十章:不平衡学习 - 连 1% 的人都未能中彩票
在你的类别平衡的情况下,更多的是例外而不是规则。在我们将遇到的大多数有趣的问题中,类别极不平衡。幸运的是,网络支付的一小部分是欺诈的,就像人口中少数人感染罕见疾病一样。相反,少数竞争者中彩票,你的熟人中少数成为你的密友。这就是为什么我们通常对捕捉这些罕见案例感兴趣的原因。
在本章中,我们将学习如何处理不平衡的类别。我们将从给训练样本分配不同权重开始,以减轻类别不平衡问题。此后,我们将学习其他技术,如欠采样和过采样。我们将看到这些技术在实践中的效果。我们还将学习如何将集成学习与重采样等概念结合起来,并引入新的评分来验证我们的学习器是否符合我们的需求。
本章将涵盖以下主题:
-
重新加权训练样本
-
随机过采样
-
随机欠采样
-
将采样与集成结合使用
-
平等机会分数
让我们开始吧!
获取点击预测数据集
通常,看到广告并点击的人只占很小一部分。换句话说,在这种情况下,正类样本的百分比可能只有 1%甚至更少。这使得预测点击率(CTR)变得困难,因为训练数据极度不平衡。在本节中,我们将使用来自知识发现数据库(KDD)杯赛的高度不平衡数据集。
KDD 杯是由 ACM 知识发现与数据挖掘特别兴趣小组每年组织的比赛。2012 年,他们发布了一个数据集,用于预测搜索引擎中显示的广告是否会被用户点击。经修改后的数据已发布在 OpenML 平台上(www.openml.org/d/1220
)。修改后数据集的 CTR 为 16.8%。这是我们的正类。我们也可以称之为少数类,因为大多数情况下广告未被点击。
在这里,我们将下载数据并将其放入 DataFrame 中,如下所示:
from sklearn.datasets import fetch_openml
data = fetch_openml(data_id=1220)
df = pd.DataFrame(
data['data'],
columns=data['feature_names']
).astype(float)
df['target'] = pd.Series(data['target']).astype(int)
我们可以使用以下代码显示数据集的5
个随机行:
df.sample(n=5, random_state=42)
如果我们将random_state
设为相同的值,我们可以确保得到相同的随机行。在《银河系漫游指南》中,道格拉斯·亚当斯认为数字42
是生命、宇宙和一切的终极问题的答案。因此,我们将在本章节中始终将random_state
设为42
。这是我们的五行样本:
关于这些数据,我们需要记住两件事:
-
如前所述,类别不平衡。你可以通过运行
df['target'].mean()
来检查这一点,这将返回16.8%
。 -
尽管所有特征都是数值型的,但显然,所有以
id
后缀结尾的特征应该作为分类特征来处理。例如,ad_id
与 CTR 之间的关系并不预期是线性的,因此在使用线性模型时,我们可能需要使用one-hot 编码器对这些特征进行编码。然而,由于这些特征具有高基数,one-hot 编码策略会导致产生过多的特征,使我们的分类器难以处理。因此,我们需要想出另一种可扩展的解决方案。现在,让我们学习如何检查每个特征的基数:
for feature in data['feature_names']:
print(
'Cardinality of {}: {:,}'.format(
feature, df[feature].value_counts().shape[0]
)
)
这将给我们以下结果:
Cardinality of impression: 99
Cardinality of ad_id: 19,228
Cardinality of advertiser_id: 6,064
Cardinality of depth: 3
Cardinality of position: 3
Cardinality of keyword_id: 19,803
Cardinality of title_id: 25,321
Cardinality of description_id: 22,381
Cardinality of user_id: 30,114
最后,我们将把数据转换成x_train
、x_test
、y_train
和y_test
数据集,如下所示:
from sklearn.model_selection import train_test_split
x, y = df[data['feature_names']], df['target']
x_train, x_test, y_train, y_test = train_test_split(
x, y, test_size=0.25, random_state=42
)
在本节中,我们下载了必要的数据并将其添加到 DataFrame 中。在下一节中,我们将安装imbalanced-learn
库。
安装 imbalanced-learn 库
由于类别不平衡,我们需要重新采样训练数据或应用不同的技术以获得更好的分类结果。因此,我们将在这里依赖imbalanced-learn
库。该项目由Fernando Nogueira于 2014 年启动,现提供多种重采样数据技术,以及用于评估不平衡分类问题的度量标准。该库的接口与 scikit-learn 兼容。
你可以通过在终端中运行以下命令来使用pip
下载该库:
pip install -U imbalanced-learn
现在,你可以在代码中导入并使用它的不同模块,正如我们在接下来的章节中所看到的。该库提供的度量标准之一是几何均值分数。在第八章,集成方法 – 当一个模型不足够时,我们了解了真正例率(TPR),即灵敏度,以及假正例率(FPR),并用它们绘制了曲线下面积。我们还学习了真负例率(TNR),即特异度,它基本上是 1 减去 FPR。几何均值分数,对于二分类问题来说,是灵敏度(TPR)和特异度(TNR)乘积的平方根。通过结合这两个度量标准,我们试图在考虑类别不平衡的情况下,最大化每个类别的准确性。geometric_mean_score
的接口与其他 scikit-learn 度量标准类似。它接受真实值和预测值,并返回计算出的分数,如下所示:****
****```py
from imblearn.metrics import geometric_mean_score
geometric_mean_score(y_true, y_pred)
在本章中,我们将使用这个度量标准,除了精确度和召回率分数外。
在下一节中,我们将调整训练样本的权重,看看这是否有助于处理类别不平衡问题。
# 预测 CTR
我们已经准备好了数据并安装了`imbalanced-learn`库。现在,我们可以开始构建我们的分类器了。正如我们之前提到的,由于类别特征的高基数,传统的独热编码技术并不适合大规模应用。在[第八章](https://cdp.packtpub.com/hands_on_machine_learning_with_scikit_learn/wp-admin/post.php?post=30&action=edit),《集成方法——当一个模型不足以应对时》,我们简要介绍了**随机树嵌入**作为一种特征转换技术。它是完全随机树的集成,每个数据样本将根据它落在每棵树的叶子节点来表示。这里,我们将构建一个管道,将数据转换为随机树嵌入并进行缩放。最后,使用**逻辑回归**分类器来预测是否发生了点击:
```py
from sklearn.preprocessing import MaxAbsScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomTreesEmbedding
from sklearn.pipeline import Pipeline
from sklearn.metrics import precision_score, recall_score
from imblearn.metrics import geometric_mean_score
def predict_and_evalutate(x_train, y_train, x_test, y_test, sample_weight=None, title='Unweighted'):
clf = Pipeline(
[
('Embedder', RandomTreesEmbedding(n_estimators=10, max_leaf_nodes=20, random_state=42)),
('Scaler', MaxAbsScaler()),
('Classifier', LogisticRegression(solver='saga', max_iter=1000, random_state=42))
]
)
clf.fit(x_train, y_train, Classifier__sample_weight=sample_weight)
y_test_pred = clf.predict(x_test)
print(
'Precision: {:.02%}, Recall: {:.02%}; G-mean: {:.02%} @ {}'.format(
precision_score(y_test, y_test_pred),
recall_score(y_test, y_test_pred),
geometric_mean_score(y_test, y_test_pred),
title
)
)
return clf
我们将整个过程封装到一个函数中,这样我们就可以在本章后面重复使用它。predict_and_evalutate()
函数接受 x 和 y,以及样本权重。我们稍后会使用样本权重,但现在可以忽略它们。一旦预测完成,函数还会打印不同的得分,并返回使用的管道实例。
我们可以像下面这样使用我们刚刚创建的函数:
clf = predict_and_evalutate(x_train, y_train, x_test, y_test)
默认情况下,计算出的精确度和召回率是针对正类的。前面的代码给出了0.3%
的召回率,62.5%
的精确度,以及5.45%
的几何均值得分。召回率低于1%
,这意味着分类器将无法捕捉到绝大多数正类/少数类样本。这是处理不平衡数据时常见的情况。解决方法之一是为少数类样本分配更多的权重。这就像是要求分类器更加关注这些样本,因为我们关心的是捕捉到它们,尽管它们相对稀少。在下一节中,我们将看到样本加权对分类器的影响。
对训练样本进行不同的加权
多数类样本的数量大约是少数类样本的五倍。你可以通过运行以下代码来验证这一点:
(1 - y_train.mean()) / y_train.mean()
因此,将少数类样本的权重设置为其他样本的五倍是有意义的。我们可以使用前一节中的predict_and_evalutate()
函数,并调整样本权重,具体如下:
sample_weight = (1 * (y_train == 0)) + (5 * (y_train == 1))
clf = predict_and_evalutate(
x_train, y_train, x_test, y_test,
sample_weight=sample_weight
)
现在,召回率跳升到13.4%
,但精确度下降至24.8%
。几何均值得分从5.5%
降至34%
,这得益于新的权重设置。
predict_and_evalutate()
函数返回使用的管道实例。我们可以通过clf[-1]
获取管道的最后一个组件,即逻辑回归分类器。然后,我们可以访问分配给每个特征的分类器系数。在嵌入步骤中,我们可能最终会得到多达 200 个特征;10 个估算器×最多 20 个叶节点。以下函数打印出最后九个特征及其系数以及截距:
def calculate_feature_coeff(clf):
return pd.DataFrame(
{
'Features': [
f'EmbFeature{e}'
for e in range(len(clf[-1].coef_[0]))
] + ['Intercept'],
'Coeff': list(
clf[-1].coef_[0]
) + [clf[-1].intercept_[0]]
}
).set_index('Features').tail(10)
calculate_feature_coeff(clf).round(2)
的输出也可以四舍五入到两位小数,如下所示:
现在,让我们并排比较三种加权策略。权重为 1 时,少数类和多数类的权重相同。然后,我们将少数类的权重设置为多数类的两倍,再设置为五倍,如下所示:
df_coef_list = []
weight_options = [1, 2, 5]
for w in weight_options:
print(f'\nMinority Class (Positive Class) Weight = Weight x {w}')
sample_weight = (1 * (y_train == 0)) + (w * (y_train == 1))
clf = predict_and_evalutate(
x_train, y_train, x_test, y_test,
sample_weight=sample_weight
)
df_coef = calculate_feature_coeff(clf)
df_coef = df_coef.rename(columns={'Coeff': f'Coeff [w={w}]'})
df_coef_list.append(df_coef)
这给我们带来了以下结果:
很容易看到加权如何影响精度和召回率。就好像其中一个总是在牺牲另一个的情况下改进。这种行为是由于移动了分类器的边界。如我们所知,类别边界是由不同特征的系数以及截距定义的。我敢打赌你很想看到这三种先前模型的系数并排显示。幸运的是,我们已经将系数保存在df_coef_list
中,以便我们可以使用以下代码片段显示它们:
pd.concat(df_coef_list, axis=1).round(2).style.bar(
subset=[f'Coeff [w={w}]' for w in weight_options],
color='#999',
align='zero'
)
这给我们带来了三种分类器之间的以下视觉比较:
特征的系数确实发生了轻微变化,但截距的变化更为显著。总之,加权最影响截距,并因此移动了类别边界。
如果预测的概率超过50%
,则将样本分类为正类成员。截距的变化(在其他系数不变的情况下)相当于改变概率阈值,使其高于或低于50%
。如果加权只影响截距,我们可能会建议尝试不同的概率阈值,直到获得期望的精度-召回平衡。为了检查加权是否提供了超出仅改变截距的额外好处,我们必须检查接收者操作特征(ROC)曲线下面积。
加权对 ROC 的影响
加权是否改善了 ROC 曲线下面积?为了回答这个问题,让我们从创建一个显示 ROC 曲线并打印曲线下面积(AUC)的函数开始:
from sklearn.metrics import roc_curve, auc
def plot_roc_curve(y, y_proba, ax, label):
fpr, tpr, thr = roc_curve(y, y_proba)
auc_value = auc(fpr, tpr)
pd.DataFrame(
{
'FPR': fpr,
'TPR': tpr
}
).set_index('FPR')['TPR'].plot(
label=label + f'; AUC = {auc_value:.3f}',
kind='line',
xlim=(0,1),
ylim=(0,1),
color='k',
ax=ax
)
return (fpr, tpr, auc_value)
现在,我们可以循环遍历三种加权选项,并渲染它们相应的曲线,如下所示:
from sklearn.metrics import roc_curve, auc
fig, ax = plt.subplots(1, 1, figsize=(15, 8), sharey=False)
ax.plot(
[0, 1], [0, 1],
linestyle='--',
lw=2, color='k',
label='Chance', alpha=.8
)
for w in weight_options:
sample_weight = (1 * (y_train == 0)) + (w * (y_train == 1))
clf = Pipeline(
[
('Embedder', RandomTreesEmbedding(n_estimators=20, max_leaf_nodes=20, random_state=42)),
('Scaler', MaxAbsScaler()),
('Classifier', LogisticRegression(solver='lbfgs', max_iter=2000, random_state=42))
]
)
clf.fit(x_train, y_train, Classifier__sample_weight=sample_weight)
y_test_pred_proba = clf.predict_proba(x_test)[:,1]
plot_roc_curve(
y_test, y_test_pred_proba,
label=f'\nMinority Class Weight = Weight x {w}',
ax=ax
)
ax.set_title('Receiver Operating Characteristic (ROC)')
ax.set_xlabel('False Positive Rate')
ax.set_ylabel('True Positive Rate')
ax.legend(ncol=1, fontsize='large', shadow=True)
fig.show()
这三条曲线在这里展示:
ROC 曲线旨在显示不同概率阈值下的真正率(TPR)与假正率(FPR)之间的权衡。如果 ROC 曲线下的面积对于三种加权策略大致相同,那么加权除了改变分类器的截距外,并没有提供太多价值。因此,是否要以牺牲精度为代价来提高召回率,就取决于我们是否想重新加权训练样本,或者尝试不同的分类决策概率阈值。
除了样本加权之外,我们还可以重新采样训练数据,以便在一个更加平衡的数据集上进行训练。在下一节中,我们将看到imbalanced-learn
库提供的不同采样技术。
训练数据的采样
“这不是否认。我只是对我接受的现实有选择性。”
- 比尔·沃特森
如果机器学习模型是人类,它们可能会认为目的证明手段是合理的。当 99%的训练数据属于同一类时,它们的目标是优化目标函数。如果它们专注于正确处理那一类,也不能怪它们,因为它为解决方案贡献了 99%的数据。在上一节中,我们通过给少数类或多类更多的权重来尝试改变这种行为。另一种策略可能是从多数类中移除一些样本,或向少数类中添加新样本,直到两个类达到平衡。
对多数类进行下采样
“真理,就像黄金,不是通过它的增长得到的,而是通过洗净其中所有非黄金的部分来获得的。”
- 列夫·托尔斯泰
我们可以随机移除多数类的样本,直到它与少数类的大小相同。在处理非二分类任务时,我们可以从所有类别中移除样本,直到它们都变成与少数类相同的大小。这个技术被称为随机下采样。以下代码展示了如何使用RandomUnderSampler()
来对多数类进行下采样:
from imblearn.under_sampling import RandomUnderSampler
rus = RandomUnderSampler()
x_train_resampled, y_train_resampled = rus.fit_resample(x_train, y_train)
与其保持类别平衡,你可以通过设置sampling_strategy
超参数来减少类别的不平衡。其值决定了少数类与多数类的最终比例。在下面的示例中,我们保持了多数类的最终大小,使其是少数类的两倍:
from imblearn.under_sampling import RandomUnderSampler
rus = RandomUnderSampler(sampling_strategy=0.5)
x_train_resampled, y_train_resampled = rus.fit_resample(x_train, y_train)
下采样过程不一定是随机的。例如,我们可以使用最近邻算法来移除那些与邻居不一致的样本。EditedNearestNeighbours
模块允许你通过其n_neighbors
超参数来设置检查邻居的数量,代码如下:
from imblearn.under_sampling import EditedNearestNeighbours
enn = EditedNearestNeighbours(n_neighbors=5)
x_train_resampled, y_train_resampled = enn.fit_resample(x_train, y_train)
之前的技术属于原型选择。在这种情况下,我们从已经存在的样本中选择样本。与原型选择不同,原型生成方法生成新的样本来概括现有样本。ClusterCentroids算法将多数类样本放入聚类中,并使用聚类中心代替原始样本。有关聚类和聚类中心的更多内容,将在第十一章*《聚类——理解无标签数据》*中提供。
为了比较前述算法,让我们创建一个函数,该函数接收 x 和 y 以及采样器实例,然后训练它们并返回测试集的预测值:
from sklearn.preprocessing import MaxAbsScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomTreesEmbedding
from sklearn.pipeline import Pipeline
def sample_and_predict(x_train, y_train, x_test, y_test, sampler=None):
if sampler:
x_train, y_train = sampler.fit_resample(x_train, y_train)
clf = Pipeline(
[
('Embedder', RandomTreesEmbedding(n_estimators=10, max_leaf_nodes=20, random_state=42)),
('Scaler', MaxAbsScaler()),
('Classifier', LogisticRegression(solver='saga', max_iter=1000, random_state=42))
]
)
clf.fit(x_train, y_train)
y_test_pred_proba = clf.predict_proba(x_test)[:,1]
return y_test, y_test_pred_proba
现在,我们可以使用刚刚创建的sample_and_predict()
函数,并为以下两种采样技术绘制结果的 ROC 曲线:
from sklearn.metrics import roc_curve, auc
from imblearn.under_sampling import RandomUnderSampler
from imblearn.under_sampling import EditedNearestNeighbours
fig, ax = plt.subplots(1, 1, figsize=(15, 8), sharey=False)
# Original Data
y_test, y_test_pred_proba = sample_and_predict(x_train, y_train, x_test, y_test, sampler=None)
plot_roc_curve(
y_test, y_test_pred_proba,
label='Original Data',
ax=ax
)
# RandomUnderSampler
rus = RandomUnderSampler(random_state=42)
y_test, y_test_pred_proba = sample_and_predict(x_train, y_train, x_test, y_test, sampler=rus)
plot_roc_curve(
y_test, y_test_pred_proba,
label='RandomUnderSampler',
ax=ax
)
# EditedNearestNeighbours
nc = EditedNearestNeighbours(n_neighbors=5)
y_test, y_test_pred_proba = sample_and_predict(x_train, y_train, x_test, y_test, sampler=nc)
plot_roc_curve(
y_test, y_test_pred_proba,
label='EditedNearestNeighbours',
ax=ax
)
ax.legend(ncol=1, fontsize='large', shadow=True)
fig.show()
结果的 ROC 曲线将如下所示:
在这里,我们可以看到与训练原始未采样数据集相比,采样技术对 ROC 曲线下面积的影响。三张图可能太过接近,导致我们难以区分它们,就像这里一样,因此检查最终的 AUC 值更为有意义。
对少数类进行过采样
除了欠采样,我们还可以增加少数类的数据点。RandomOverSampler
简单地复制少数类的随机样本,直到它的大小与多数类相同。而SMOTE
和ADASYN
则通过插值生成新的合成样本。
在这里,我们将RandomOverSampler
与SMOTE
过采样算法进行比较:
from sklearn.metrics import roc_curve, auc
from imblearn.over_sampling import RandomOverSampler
from imblearn.over_sampling import SMOTE
fig, ax = plt.subplots(1, 1, figsize=(15, 8), sharey=False)
# RandomOverSampler
ros = RandomOverSampler(random_state=42)
y_test, y_test_pred_proba = sample_and_predict(x_train, y_train, x_test, y_test, sampler=ros)
plot_roc_curve(
y_test, y_test_pred_proba,
label='RandomOverSampler',
ax=ax
)
# SMOTE
smote = SMOTE(random_state=42)
y_test, y_test_pred_proba = sample_and_predict(x_train, y_train, x_test, y_test, sampler=smote)
plot_roc_curve(
y_test, y_test_pred_proba,
label='SMOTE',
ax=ax
)
ax.legend(ncol=1, fontsize='large', shadow=True)
fig.show()
结果的 ROC 曲线帮助我们比较当前数据集上两种技术的性能:
正如我们所看到的,SMOTE
算法在当前数据集上的表现不好,而RandomOverSampler
则使曲线向上移动。到目前为止,我们使用的分类器对于我们应用的采样技术是无关的。我们可以简单地移除逻辑回归分类器,并在不更改数据采样代码的情况下插入任何其他分类器。与我们使用的算法不同,数据采样过程是一些集成算法的核心组成部分。在下一节中,我们将学习如何利用这一点,做到两者兼得。
将数据采样与集成方法结合
在第八章中,集成方法——当一个模型不足以解决问题时,我们学习了包外算法。它们基本上允许多个估计器从数据集的不同子集进行学习,期望这些多样化的训练子集能够帮助不同的估计器在结合时做出更好的决策。现在我们已经对多数类进行了欠采样,以保持训练数据的平衡,结合这两个思路是很自然的;也就是说,结合包外和欠采样技术。
BalancedBaggingClassifier
在不同的随机选择的数据子集上构建多个估计器,在采样过程中,类别是平衡的。同样,BalancedRandomForestClassifier
在平衡样本上构建其决策树。以下代码绘制了这两个集成方法的 ROC 曲线:
from imblearn.ensemble import BalancedRandomForestClassifier
from imblearn.ensemble import BalancedBaggingClassifier
fig, ax = plt.subplots(1, 1, figsize=(15, 8), sharey=False)
# BalancedBaggingClassifier
clf = BalancedBaggingClassifier(n_estimators=500, n_jobs=-1, random_state=42)
clf.fit(x_train, y_train)
y_test_pred_proba = clf.predict_proba(x_test)[:,1]
plot_roc_curve(
y_test, y_test_pred_proba,
label='Balanced Bagging Classifier',
ax=ax
)
# BalancedRandomForestClassifier
clf = BalancedRandomForestClassifier(n_estimators=500, n_jobs=-1, random_state=42)
clf.fit(x_train, y_train)
y_test_pred_proba = clf.predict_proba(x_test)[:,1]
plot_roc_curve(
y_test, y_test_pred_proba,
label='Balanced Random Forest Classifier',
ax=ax
)
fig.show()
出于简洁考虑,某些格式化行已被省略。运行前面的代码会给我们以下图表:
从这一点可以看出,欠采样和集成方法的结合比我们之前的模型取得了更好的效果。
除了包外算法,RUSBoostClassifier
将随机欠采样技术与adaBoost
分类器相结合。
平等机会得分
到目前为止,我们只关注了类别标签的不平衡。在某些情况下,某个特征的不平衡也可能是一个问题。假设历史上,公司大多数工程师是男性。如果现在你基于现有数据构建一个算法来筛选新申请人,那么这个算法是否会对女性候选人产生歧视?
平等机会得分试图评估模型在某个特征上的依赖程度。简单来说,如果模型的预测和实际目标之间的关系无论该特征的值如何都相同,则认为模型对该特征的不同值给予了平等的机会。形式上,这意味着在实际目标和申请人性别条件下,预测目标的条件概率应该是相同的,无论性别如何。以下方程显示了这些条件概率:
之前的方程只给出了二元结果。因此,我们可以将其转化为一个比率,值在 0 和 1 之间。由于我们不知道哪个性别获得更好的机会,我们使用以下方程取两种可能分数中的最小值:
为了展示这一指标,假设我们有一个基于申请人的IQ
和Gender
训练的模型。以下代码展示了该模型在测试集上的预测结果,其中真实标签和预测值并排列出:
df_engineers = pd.DataFrame(
{
'IQ': [110, 120, 124, 123, 112, 114],
'Gender': ['M', 'F', 'M', 'F', 'M', 'F'],
'Is Hired? (True Label)': [0, 1, 1, 1, 1, 0],
'Is Hired? (Predicted Label)': [1, 0, 1, 1, 1, 0],
}
)
现在,我们可以创建一个函数来计算等机会得分,代码如下:
def equal_opportunity_score(df, true_label, predicted_label, feature_name, feature_value):
opportunity_to_value = df[
(df[true_label] == 1) & (df[feature_name] == feature_value)
][predicted_label].mean() / df[
(df[true_label] == 1) & (df[feature_name] != feature_value)
][predicted_label].mean()
opportunity_to_other_values = 1 / opportunity_to_value
better_opportunity_to_value = opportunity_to_value > opportunity_to_other_values
return {
'Score': min(opportunity_to_value, opportunity_to_other_values),
f'Better Opportunity to {feature_value}': better_opportunity_to_value
}
当使用我们的df_engineers
数据框时,它将给我们0.5
。一个小于 1 的值告诉我们,女性申请者在我们的模型中获得聘用的机会较少:
equal_opportunity_score(
df=df_engineers,
true_label='Is Hired? (True Label)',
predicted_label='Is Hired? (Predicted Label)',
feature_name='Gender',
feature_value='F'
)
显然,我们可以完全从模型中排除性别特征,但如果有任何剩余的特征依赖于申请者的性别,那么这个得分仍然很有用。此外,在处理非二元分类器和/或非二元特征时,我们需要调整这个得分。你可以在Moritz Hardt等人的原始论文中更详细地阅读有关此得分的内容。
摘要
在这一章中,我们学习了如何处理类别不平衡问题。这是机器学习中的一个常见问题,其中大部分价值都集中在少数类中。这种现象足够常见,以至于黑天鹅隐喻被用来解释它。当机器学习算法试图盲目地优化其开箱即用的目标函数时,它们通常会忽略这些黑天鹅。因此,我们必须使用诸如样本加权、样本删除和样本生成等技术,迫使算法实现我们的目标。
这是本书关于监督学习算法的最后一章。有一个粗略估计,大约 80%的商业和学术环境中的机器学习问题是监督学习问题,这也是本书约 80%的内容聚焦于这一范式的原因。从下一章开始,我们将开始介绍其他机器学习范式,这是现实生活中大约 20%价值所在。我们将从聚类算法开始,然后继续探讨其他数据也是未标记的情况下的问题。****
第三部分:无监督学习及更多
如果您的数据没有标签,那么本节将帮助您找到理解数据的方法。您将学习如何组织海量数据集,识别其中的异常值,并根据用户的历史行为推断其偏好。
本节包括以下章节:
第十一章:聚类 – 理解无标签数据
聚类是无监督学习方法的代表。它通常是我们在需要为无标签数据添加意义时的首选。在一个电子商务网站中,营销团队可能会要求你将用户划分为几个类别,以便他们能够为每个群体定制信息。如果没有人给这些数百万用户打标签,那么聚类就是你将这些用户分组的唯一方法。当处理大量文档、视频或网页,并且这些内容没有被分配类别,而且你又不愿意求助于Marie Kondo,那么聚类就是你整理这一堆混乱数据的唯一途径。
由于这是我们关于监督学习算法的第一章,我们将首先介绍一些关于聚类的理论背景。然后,我们将研究三种常用的聚类算法,并介绍用于评估这些算法的方法。
在本章中,我们将讨论以下主题:
-
理解聚类
-
K 均值聚类
-
聚合聚类
-
DBSCAN
让我们开始吧!
理解聚类
机器学习算法可以看作是优化问题。它们获取数据样本和目标函数,并尝试优化该函数。在监督学习的情况下,目标函数基于所提供的标签。我们试图最小化预测值和实际标签之间的差异。在无监督学习的情况下,由于缺乏标签,情况有所不同。聚类算法本质上是试图将数据样本划分到不同的聚类中,从而最小化聚类内的距离并最大化聚类间的距离。换句话说,我们希望同一聚类中的样本尽可能相似,而来自不同聚类的样本则尽可能不同。
然而,解决这个优化问题有一个显而易见的解决方案。如果我们将每个样本视为其自身的聚类,那么聚类内的距离都为零,而聚类间的距离则为最大值。显然,这不是我们希望从聚类算法中得到的结果。因此,为了避免这个显而易见的解决方案,我们通常会在优化函数中添加约束。例如,我们可能会预定义需要的聚类数量,以确保避免上述显而易见的解决方案。另一个可能的约束是设置每个聚类的最小样本数。在本章讨论不同的聚类算法时,我们将看到这些约束在实际中的应用。
标签的缺失还决定了评估结果聚类好坏的不同度量标准。这就是为什么我决定在这里强调聚类算法的目标函数,因为理解算法的目标有助于更容易理解其评估度量标准。在本章中,我们将遇到几个评估度量标准。
衡量簇内距离的一种方式是计算簇中每个点与簇中心的距离。簇中心的概念你应该已经很熟悉,因为我们在第五章中讨论过最近邻中心算法,图像处理与最近邻。簇中心基本上是簇中所有样本的均值。此外,某些样本与其均值之间的平均欧几里得距离还有一个名字,这是我们在小学时学过的——标准差。相同的距离度量可以用于衡量簇中心之间的差异。
目前,我们准备好探索第一个算法——K 均值。然而,我们需要先创建一些样本数据,这样才能用来演示算法。在接下来的部分,解释完算法之后,我们将创建所需的数据,并使用 K 均值算法进行聚类。
K 均值聚类
“我们都知道自己是独一无二的个体,但我们往往把他人看作是群体的代表。”
- Deborah Tannen
在上一节中,我们讨论了通过指定所需簇的数量来对目标函数进行约束。这就是K的含义:簇的数量。我们还讨论了簇的中心,因此“均值”这个词也可以理解。算法的工作方式如下:
-
它首先随机选择K个点,并将其设置为簇中心。
-
然后,它将每个数据点分配给最近的簇中心,形成K个簇。
-
然后,它会为新形成的簇计算新的簇中心。
-
由于簇中心已经更新,我们需要回到步骤 2,根据更新后的簇中心重新分配样本到新的簇中。然而,如果簇中心没有太大变化,我们就知道算法已经收敛,可以停止。
如你所见,这是一个迭代算法。它会不断迭代直到收敛,但我们可以通过设置其max_iter
超参数来限制迭代次数。此外,我们可以通过将tol
*超参数设置为更大的值来容忍更大的中心移动,从而提前停止。关于初始簇中心的不同选择可能会导致不同的结果。将算法的init
超参数设置为k-means++
可以确保初始簇中心彼此远离。这通常比随机初始化能得到更好的结果。K的选择也是通过n_clusters
超参数来指定的。为了演示该算法及其超参数的使用,我们先从创建一个示例数据集开始。
*## 创建一个球形数据集
我们通常将聚类数据可视化为圆形的散点数据点。这种形状也称为凸聚类,是算法最容易处理的形状之一。稍后我们将生成更难以聚类的数据集,但现在我们先从简单的 blob 开始。
make_blobs
函数帮助我们创建一个 blob 形状的数据集。在这里,我们将样本数量设置为100
,并将它们分成四个聚类。每个数据点只有两个特征。这将使我们后续更容易可视化数据。这些聚类有不同的标准差;也就是说,有些聚类比其他聚类更分散。该函数还返回标签。我们将标签放在一边,稍后用于验证我们的算法。最后,我们将x
和y
放入一个 DataFrame,并将其命名为df_blobs
:
from sklearn.datasets import make_blobs
x, y = make_blobs(n_samples=100, centers=4, n_features=2, cluster_std=[1, 1.5, 2, 2], random_state=7)
df_blobs = pd.DataFrame(
{
'x1': x[:,0],
'x2': x[:,1],
'y': y
}
)
为了确保你得到和我一样的数据,请将数据生成函数的random_state
参数设置为一个特定的随机种子。数据准备好后,我们需要创建一个函数来可视化这些数据。
可视化我们的示例数据
在本章中,我们将使用以下函数。它接受二维的 x 和 y 标签,并将它们绘制到给定的 Matplotlib 轴 ax 上。在实际场景中,通常不会给出标签,但我们仍然可以将聚类算法预测的标签传递给这个函数。生成的图形会带上一个标题,并显示从给定 y 的基数推断出的聚类数量:
def plot_2d_clusters(x, y, ax):
y_uniques = pd.Series(y).unique()
for y_unique_item in y_uniques:
x[
y == y_unique_item
].plot(
title=f'{len(y_uniques)} Clusters',
kind='scatter',
x='x1', y='x2',
marker=f'${y_unique_item}$',
ax=ax,
)
我们可以按如下方式使用新的plot_2d_clusters()
函数:
fig, ax = plt.subplots(1, 1, figsize=(10, 6))
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
plot_2d_clusters(x, y, ax)
这将给我们以下图示:
每个数据点都会根据其给定的标签进行标记。现在,我们将假设这些标签没有被提供,并观察 K-means 算法是否能够预测这些标签。
使用 K-means 进行聚类
现在我们假装没有给定标签,我们该如何确定用于K的值,也就是n_clusters
超参数的值呢?我们无法确定。现在我们只好随便选一个数值,稍后我们将学习如何找到n_clusters
的最佳值。暂时我们将其设为五。其余的超参数将保持默认值。一旦算法初始化完成,我们可以使用它的fit_predict
方法,如下所示:
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=2, random_state=7)
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
y_pred = kmeans.fit_predict(x)
请注意,在训练集上进行拟合并在测试集上进行预测的概念在这里通常没有意义。我们通常在同一数据集上进行拟合和预测。我们也不会向fit
或fit_predict
方法传递任何标签。
现在我们已经预测了新的标签,我们可以使用plot_2d_clusters()
函数来将我们的预测与原始标签进行比较,如下所示:
fig, axs = plt.subplots(1, 2, figsize=(14, 6))
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
plot_2d_clusters(x, y, axs[0])
plot_2d_clusters(x, y_pred, axs[1])
axs[0].set_title(f'Actuals: {axs[0].get_title()}')
axs[1].set_title(f'KMeans: {axs[1].get_title()}')
我在对应的图形标题前加上了Actuals
和KMeans
两个词。生成的聚类如下截图所示:
由于我们将K设置为五,原来的四个聚类中的一个被拆分成了两个。除此之外,其他聚类的预测结果是合理的。给聚类分配的标签是随意的。原来标签为一的聚类在算法中被称为三。只要聚类的成员完全相同,这一点应该不会让我们困扰。这一点对于聚类评估指标也没有影响。它们通常会考虑到这一事实,并在评估聚类算法时忽略标签名称。
然而,我们如何确定K的值呢?我们别无选择,只能多次运行算法,使用不同数量的聚类并选择最佳结果。在以下的代码片段中,我们正在遍历三个不同的n_clusters
值。我们还可以访问最终的质心,这些质心是在算法收敛后为每个聚类计算得出的。查看这些质心有助于理解算法如何将每个数据点分配到它自己的聚类中。代码片段的最后一行使用三角形标记在三个图形中绘制了质心:
from sklearn.cluster import KMeans
n_clusters_options = [2, 4, 6]
fig, axs = plt.subplots(1, len(n_clusters_options), figsize=(16, 6))
for i, n_clusters in enumerate(n_clusters_options):
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
kmeans = KMeans(n_clusters=n_clusters, random_state=7)
y_pred = kmeans.fit_predict(x)
plot_2d_clusters(x, y_pred, axs[i])
axs[i].plot(
kmeans.cluster_centers_[:,0], kmeans.cluster_centers_[:,1],
'k^', ms=12, alpha=0.75
)
这是三个选择的结果,横向排列:
对三个图形进行视觉检查告诉我们,选择四个聚类是正确的选择。不过,我们必须记住,我们这里处理的是二维数据点。如果我们的数据样本包含两个以上的特征,同样的视觉检查就会变得更加困难。在接下来的部分中,我们将学习轮廓系数,并利用它来选择最佳的聚类数,而不依赖于视觉辅助。
轮廓系数
轮廓系数是衡量一个样本与其自身聚类中其他样本相比的相似度的指标。对于每个样本,我们将计算该样本与同一聚类中所有其他样本之间的平均距离。我们称这个平均距离为A。然后,我们计算该样本与最近聚类中所有其他样本之间的平均距离。我们称这个平均距离为B。现在,我们可以定义轮廓系数,如下所示:
现在,我们不再通过视觉检查聚类,而是将遍历多个n_clusters
的值,并在每次迭代后存储轮廓系数。如你所见,silhouette_score
接受两个参数——数据点(x
)和预测的聚类标签(y_pred
):
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
n_clusters_options = [2, 3, 4, 5, 6, 7, 8]
silhouette_scores = []
for i, n_clusters in enumerate(n_clusters_options):
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
kmeans = KMeans(n_clusters=n_clusters, random_state=7)
y_pred = kmeans.fit_predict(x)
silhouette_scores.append(silhouette_score(x, y_pred))
我们可以直接选择提供最佳得分的n_clusters
值。在这里,我们将计算出的得分放入一个 DataFrame 中,并使用柱状图进行比较:
fig, ax = plt.subplots(1, 1, figsize=(12, 6), sharey=False)
pd.DataFrame(
{
'n_clusters': n_clusters_options,
'silhouette_score': silhouette_scores,
}
).set_index('n_clusters').plot(
title='KMeans: Silhouette Score vs # Clusters chosen',
kind='bar',
ax=ax
)
结果得分确认了我们最初的决定,四是最适合的聚类数:
除了选择聚类的数量外,算法初始质心的选择也会影响其准确性。错误的选择可能会导致 K-means 算法收敛到一个不理想的局部最小值。在下一节中,我们将看到初始质心如何影响算法的最终决策。
选择初始质心
默认情况下,scikit-learn 的 K-means 实现会选择相互之间距离较远的随机初始质心。它还会尝试多个初始质心,并选择产生最佳结果的那个。话虽如此,我们也可以手动设置初始质心。在以下代码片段中,我们将比较两种初始设置,看看它们对最终结果的影响。然后我们将并排打印这两种结果:
from sklearn.cluster import KMeans
initial_centroid_options = np.array([
[(-10,5), (0, 5), (10, 0), (-10, 0)],
[(0,0), (0.1, 0.1), (0, 0), (0.1, 0.1)],
])
fig, axs = plt.subplots(1, 2, figsize=(16, 6))
for i, initial_centroids in enumerate(initial_centroid_options):
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
kmeans = KMeans(
init=initial_centroids, max_iter=500, n_clusters=4, random_state=7
)
y_pred = kmeans.fit_predict(x)
plot_2d_clusters(x, y_pred, axs[i])
axs[i].plot(
kmeans.cluster_centers_[:,0], kmeans.cluster_centers_[:,1], 'k^'
)
以下图表展示了算法收敛后的聚类结果。部分样式代码为了简洁被省略:
显然,第一个初始设置帮助了算法,而第二个设置则导致了不好的结果。因此,我们必须注意算法的初始化,因为它的结果是非确定性的。
在机器学习领域,迁移学习指的是我们需要重新利用在解决一个问题时获得的知识,并将其应用于稍微不同的另一个问题。人类也需要迁移学习。K-means 算法有一个 fit_transform
方法。如果我们的数据(x)由 N 个样本和 M 个特征组成,方法将把它转换成 N 个样本和 K 列。列中的值基于预测的聚类。通常,K 要远小于 N。因此,您可以重新利用 K-means 聚类算法,使其可以作为降维步骤,在将其转换后的输出传递给简单分类器或回归器之前。类似地,在多分类问题中,可以使用聚类算法来减少目标的基数。**
**与 K-means 算法相对,层次聚类是另一种结果是确定性的算法。它不依赖任何初始选择,因为它从不同的角度来解决聚类问题。层次聚类是下一节的主题。
层次聚类
“人口最多的城市不过是荒野的集合。”
- 奥尔杜斯·赫胥黎
在 K-means 聚类算法中,我们从一开始就有了我们的K个聚类。在每次迭代中,某些样本可能会改变其归属,某些聚类的质心可能会改变,但最终,聚类从一开始就已定义。相反,在凝聚层次聚类中,开始时并不存在聚类。最初,每个样本都属于它自己的聚类。开始时的聚类数量与数据样本的数量相同。然后,我们找到两个最接近的样本,并将它们合并为一个聚类。之后,我们继续迭代,通过合并下一个最接近的两个样本、两个聚类,或者下一个最接近的样本和一个聚类。正如你所看到的,每次迭代时,聚类数量都会减少一个,直到所有样本都加入一个聚类。将所有样本放入一个聚类听起来不太直观。因此,我们可以选择在任何迭代中停止算法,具体取决于我们需要的最终聚类数量。
那么,让我们来学习如何使用凝聚层次聚类算法。要让算法提前终止其聚合任务,你需要通过它的n_clusters
超参数告知它我们需要的最终聚类数量。显然,既然我提到算法会合并已关闭的聚类,我们需要深入了解聚类间的距离是如何计算的,但暂时我们可以忽略这一点——稍后我们会讲到。以下是当聚类数量设置为4
时,算法的使用方法:
from sklearn.cluster import AgglomerativeClustering
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
agglo = AgglomerativeClustering(n_clusters=4)
y_pred = agglo.fit_predict(x)
由于我们将聚类数量设置为4
,预测的y_pred
将会有从零到三的值。
事实上,凝聚层次聚类算法并没有在聚类数量为四时停止。它继续合并聚类,并使用内部树结构跟踪哪些聚类是哪些更大聚类的成员。当我们指定只需要四个聚类时,它重新访问这个内部树,并相应地推断聚类的标签。在下一节中,我们将学习如何访问算法的内部层级,并追踪它构建的树。
跟踪凝聚层次聚类的子节点
如前所述,每个样本或聚类都会成为另一个聚类的成员,而这个聚类又成为更大聚类的成员,依此类推。这个层次结构被存储在算法的children_
属性中。这个属性的形式是一个列表的列表。外部列表的成员数量等于数据样本数量减去一。每个成员列表由两个数字组成。我们可以列出children_
属性的最后五个成员,如下所示:
agglo.children_[-5:]
这将给我们以下列表:
array([[182, 193],
[188, 192],
[189, 191],
[194, 195],
[196, 197]])
列表中的最后一个元素是树的根节点。它有两个子节点,196
和197
。这些是根节点的子节点的 ID。大于或等于数据样本数量的 ID 是聚类 ID,而较小的 ID 则表示单个样本。如果你从聚类 ID 中减去数据样本的数量,就可以得到子节点列表中的位置,从而获得该聚类的成员。根据这些信息,我们可以构建以下递归函数,它接受一个子节点列表和数据样本的数量,并返回所有聚类及其成员的嵌套树,如下所示:
def get_children(node, n_samples):
if node[0] >= n_samples:
child_cluster_id = node[0] - n_samples
left = get_children(
agglo.children_[child_cluster_id],
n_samples
)
else:
left = node[0]
if node[1] >= n_samples:
child_cluster_id = node[1] - n_samples
right = get_children(
agglo.children_[child_cluster_id],
n_samples
)
else:
right = node[1]
return [left, right]
我们可以像这样调用我们刚刚创建的函数:
root = agglo.children_[-1]
n_samples = df_blobs.shape[0]
tree = get_children(root, n_samples)
此时,tree[0]
和tree[1]
包含树的左右两侧样本的 ID——这些是两个最大聚类的成员。如果我们的目标是将样本分成四个聚类,而不是两个,我们可以使用tree[0][0]
、tree[0][1]
、tree[1][0]
和tree[1][1]
。以下是tree[0][0]
的样子:
[[[46, [[25, 73], [21, 66]]], [87, 88]],
[[[22, 64], [4, [49, 98]]],
[[19, [55, 72]], [[37, 70], [[[47, 82], [13, [39, 92]]], [2, [8, 35]]]]]]]
这种嵌套性使我们能够设置我们希望聚类的深度,并相应地获取其成员。尽管如此,我们可以使用以下代码将这个列表展平:
def flatten(sub_tree, flat_list):
if type(sub_tree) is not list:
flat_list.append(sub_tree)
else:
r, l = sub_tree
flatten(r, flat_list)
flatten(l, flat_list)
现在,我们可以获取tree[0][0]
的成员,如下所示:
flat_list = []
flatten(tree[0][0], flat_list)
print(flat_list)
我们还可以模拟fit_predict
的输出,并使用以下代码片段构建我们自己的预测标签。它将为我们构建的树的不同分支中的成员分配从零到三的标签。我们将我们的预测标签命名为y_pred_dash
:
n_samples = x.shape[0]
y_pred_dash = np.zeros(n_samples)
for i, j, label in [(0,0,0), (0,1,1), (1,0,2), (1,1,3)]:
flat_list = []
flatten(tree[i][j], flat_list)
for sample_index in flat_list:
y_pred_dash[sample_index] = label
为了确保我们的代码按预期工作,y_pred_dash
中的值应该与上一节中的y_pred
匹配。然而,tree[0][0]
部分的树是否应被分配标签0
、1
、2
或3
并没有明确的规定。我们选择标签是任意的。因此,我们需要一个评分函数来比较这两个预测,同时考虑到标签名称可能会有所不同。这就是调整后的兰德指数(adjusted Rand index)的作用,接下来我们将讨论它。
调整后的兰德指数
调整后的兰德指数在分类中的计算方式与准确率评分非常相似。它计算两个标签列表之间的一致性,但它考虑了准确率评分无法处理的以下问题:
-
调整后的兰德指数并不关心实际的标签,只要这里的一个聚类的成员和那里聚类的成员是相同的。
-
与分类不同,我们可能会得到太多的聚类。在极端情况下,如果每个样本都是一个独立的聚类,忽略标签名称的情况下,任何两个聚类列表都会一致。因此,调整后的兰德指数会减小两个聚类偶然一致的可能性。
当两个预测结果匹配时,最佳调整的兰德指数为1
。因此,我们可以用它来比较y_pred
和我们的y_pred_dash
。该得分是对称的,因此在调用评分函数时,参数的顺序并不重要,如下所示:
from sklearn.metrics import adjusted_rand_score
adjusted_rand_score(y_pred, y_pred_dash)
由于我们得到了1
的调整兰德指数,我们可以放心,推断子树中簇的成员资格的代码是正确的。
我之前简要提到过,在每次迭代中,算法会合并两个最接近的簇。很容易想象,如何计算两个样本之间的距离。它们基本上是两个点,我们之前已经使用了不同的距离度量,例如欧氏距离和曼哈顿距离。然而,簇并不是一个点。我们应该从哪里开始测量距离呢?是使用簇的质心吗?还是在每个簇中选择一个特定的数据点来计算距离?所有这些选择都可以通过连接超参数来指定。在下一节中,我们将看到它的不同选项。
选择聚类连接
默认情况下,使用欧氏距离来决定哪些簇对彼此最接近。这个默认度量可以通过亲和度超参数进行更改。如果你想了解更多不同的距离度量,例如余弦和曼哈顿距离,请参考第五章*,最近邻的图像处理*。在计算两个簇之间的距离时,连接准则决定了如何测量这些距离,因为簇通常包含不止一个数据点。在完全连接中,使用两个簇中所有数据点之间的最大距离。相反,在单一连接中,使用最小距离。显然,平均连接取所有样本对之间所有距离的平均值。在沃德连接中,如果两个簇的每个数据点与合并簇的质心之间的平均欧氏距离最小,则这两个簇会合并。沃德连接仅能使用欧氏距离。
为了能够比较上述连接方法,我们需要创建一个新的数据集。数据点将以两个同心圆的形式排列。较小的圆嵌套在较大的圆内,就像莱索托和南非一样。make_circles
函数指定了生成样本的数量(n_samples
)、两个圆之间的距离(factor
)以及数据的噪声大小(noise
):
from sklearn.datasets import make_circles
x, y = make_circles(n_samples=150, factor=0.5, noise=0.05, random_state=7)
df_circles = pd.DataFrame({'x1': x[:,0], 'x2': x[:,1], 'y': y})
我稍后会显示生成的数据集,但首先,我们先使用凝聚算法对新的数据样本进行聚类。我将运行两次算法:第一次使用完全连接,第二次使用单一连接。这次我将使用曼哈顿距离:
from sklearn.cluster import AgglomerativeClustering
linkage_options = ['complete', 'single']
fig, axs = plt.subplots(1, len(linkage_options) + 1, figsize=(14, 6))
x, y = df_circles[['x1', 'x2']], df_circles['y']
plot_2d_clusters(x, y, axs[0])
axs[0].set_title(f'{axs[0].get_title()}\nActuals')
for i, linkage in enumerate(linkage_options, 1):
y_pred = AgglomerativeClustering(
n_clusters=2, affinity='manhattan', linkage=linkage
).fit_predict(x)
plot_2d_clusters(x, y_pred, axs[i])
axs[i].set_title(f'{axs[i].get_title()}\nAgglomerative\nLinkage= {linkage}')
这是两种连接方法并排显示的结果:
当使用单链聚合时,考虑每对聚类之间的最短距离。这使得它能够识别出数据点排列成的圆形带状区域。完全链聚合考虑聚类之间的最长距离,导致结果偏差较大。显然,单链聚合在这里获得了最佳结果。然而,由于其方差,它容易受到噪声的影响。为了证明这一点,我们可以在将噪声从0.05
增加到0.08
后,重新生成圆形样本,如下所示:
from sklearn.datasets import make_circles
x, y = make_circles(n_samples=150, factor=0.5, noise=0.08, random_state=7)
df_circles = pd.DataFrame({'x1': x[:,0], 'x2': x[:,1], 'y': y})
在新样本上运行相同的聚类算法将给我们以下结果:
这次噪声数据干扰了我们的单链聚合,而完全链聚合的结果变化不大。在单链聚合中,一个落在两个聚类之间的噪声点可能会导致它们合并。平均链聚合可以看作是单链和完全链聚合标准之间的中间地带。由于这些算法的迭代特性,三种链聚合方法会导致较大的聚类变得更大。这可能导致聚类大小不均。如果必须避免不平衡的聚类,那么应该优先选择沃德链聚合,而不是其他三种链聚合方法。
到目前为止,K-means 和层次聚类算法需要预先定义期望的聚类数量。与 K-means 算法相比,层次聚类计算量大,而 K-means 算法无法处理非凸数据。在下一节中,我们将看到第三种不需要预先定义聚类数量的算法。****
****# DBSCAN
“你永远无法真正理解一个人,除非你从他的角度考虑问题。”
- 哈珀·李
缩写DBSCAN代表基于密度的噪声应用空间聚类。它将聚类视为高密度区域,之间由低密度区域分隔。这使得它能够处理任何形状的聚类。这与假设聚类为凸形的 K-means 算法不同;即数据簇与质心。DBSCAN算法首先通过识别核心样本来开始。这些是周围至少有 min_samples
点,且距离在 eps
(ε) 内的点。最初,一个聚类由其核心样本组成。一旦识别出核心样本,它的邻居也会被检查,并且如果符合核心样本标准,就将其添加到聚类中。接着,聚类将被扩展,以便我们可以将非核心样本添加到其中。这些样本是可以直接从核心样本通过 eps
距离到达的点,但它们本身不是核心样本。一旦所有的聚类被识别出来,包括核心样本和非核心样本,剩余的样本将被视为噪声。
很明显,min_samples
和eps
超参数在最终预测中起着重要作用。这里,我们将min_samples
设置为3
并尝试不同的eps
设置*😗**
from sklearn.cluster import DBSCAN
eps_options = [0.1, 1.0, 2.0, 5.0]
fig, axs = plt.subplots(1, len(eps_options) + 1, figsize=(14, 6))
x, y = df_blobs[['x1', 'x2']], df_blobs['y']
plot_2d_clusters(x, y, axs[0])
axs[0].set_title(f'{axs[0].get_title()}\nActuals')
for i, eps in enumerate(eps_options, 1):
y_pred = DBSCAN(eps=eps, min_samples=3, metric='euclidean').fit_predict(x)
plot_2d_clusters(x, y_pred, axs[i])
axs[i].set_title(f'{axs[i].get_title()}\nDBSCAN\neps = {eps}')
对于 blobs 数据集,结果聚类帮助我们识别eps
超参数的影响:
一个非常小的eps
值不允许任何核心样本形成。当eps
被设置为0.1
时,几乎所有的点都被当作噪声处理。当我们增加eps
值时,核心点开始形成。然而,在某个时刻,当eps
设置为0.5
时,两个簇错误地合并在一起。
同样,min_samples
的值可以决定我们的聚类算法是否成功。这里,我们将尝试不同的min_samples
值来对我们的同心数据点进行处理:**
**```py
from sklearn.cluster import DBSCAN
min_samples_options = [3, 5, 10]
fig, axs = plt.subplots(1, len(min_samples_options) + 1, figsize=(14, 6))
x, y = df_circles[[‘x1’, ‘x2’]], df_circles[‘y’]
plot_2d_clusters(x, y, axs[0])
axs[0].set_title(f’{axs[0].get_title()}\nActuals’)
for i, min_samples in enumerate(min_samples_options, 1):
y_pred = DBSCAN(
eps=0.25, min_samples=min_samples, metric='euclidean', n_jobs=-1
).fit_predict(x)
plot_2d_clusters(x, y_pred, axs[i])
axs[i].set_title(f'{axs[i].get_title()}\nDBSCAN\nmin_samples = {min_samples}')
在这里,我们可以看到`min_samples`对聚类结果的影响:
<https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/00022b95-11a4-44a4-9c8e-4de20b8a90a9.png>
再次强调,仔细选择`min_samples`给出了最好的结果。与`eps`不同,`min_samples`值越大,核心样本越难以形成。**
除了上述超参数外,我们还可以更改算法使用的距离度量。通常,`min_samples`取值大于三。将`min_samples`设置为一意味着每个样本将成为自己的簇,而将其设置为二将给出类似于层次聚类算法的结果,但采用单链接法。你可以从将`min_samples`值设置为数据维度的两倍开始;也就是说,设置为特征数量的两倍。然后,如果数据已知是噪声较多的,你可以增加该值,否则可以减少它。至于`eps`,我们可以使用以下**k 距离图**。
在同心数据集上,我们将`min_samples`设置为三。现在,对于每个样本,我们想要查看它的两个邻居有多远。以下代码片段计算每个点与其最接近的两个邻居之间的距离:
```py
from sklearn.neighbors import NearestNeighbors
x = df_circles[['x1', 'x2']]
distances, _ = NearestNeighbors(n_neighbors=2).fit(x).kneighbors()
如果min_samples
设置为其他任何数值,我们希望得到与该数值相等的邻居数量,减去一。现在,我们可以关注每个样本的两个邻居中最远的一个,并绘制所有结果的距离,如下所示:
pd.Series(distances[:,-1]).sort_values().reset_index(drop=True).plot()
结果图形将如下所示:
图表剧烈改变斜率的点为我们提供了eps
值的大致估计。在这里,当min_samples
设置为三时,eps
值为0.2
似乎非常合适。此外,我们可以尝试这些两个数值的不同组合,并使用轮廓系数或其他聚类度量来微调我们的超参数。
总结
英国历史学家阿诺德·汤因比曾说过,“没有哪种工具是全能的。” 在本章中,我们使用了三种工具来进行聚类。我们在这里讨论的三种算法从不同的角度处理问题。K-means 聚类算法试图找到总结簇和质心的点,并围绕它们构建聚类。凝聚层次聚类方法则更倾向于自下而上的方式,而 DBSCAN 聚类算法则引入了核心点和密度等新概念。本章是三章关于无监督学习问题的第一章。由于缺乏标签,我们被迫学习了新的评估指标,如调整兰德指数和轮廓系数。
在下一章,我们将处理第二个无监督学习问题:异常检测。幸运的是,本章中讨论的概念,以及第五章中关于最近邻和最近质心算法的内容,将帮助我们在下一章中进一步理解。再一次,我们将得到未标记的数据样本,我们的任务是挑出其中的异常样本。
第十二章:异常检测 – 找出数据中的异常值
检测数据中的异常是机器学习中的一个重复性主题。在第十章,Imbalanced Learning – Not Even 1% Win the Lottery,我们学习了如何在数据中发现这些有趣的少数群体。那时,数据是有标签的,并且之前章节中的分类算法适用于该问题。除了有标签异常检测问题外,还有一些情况下数据是无标签的。
在本章中,我们将学习如何在没有标签的情况下识别数据中的异常值。我们将使用三种不同的算法,并学习无标签异常检测的两个分支。本章将涵盖以下主题:
-
无标签异常检测
-
使用基本统计方法检测异常
-
使用
EllipticEnvelope
检测异常值 -
使用局部异常因子(LOF)进行异常值和新颖性检测
-
使用隔离森林检测异常值
无标签异常检测
在本章中,我们将从一些无标签数据开始,我们需要在其中找到异常样本。我们可能只会得到正常数据(inliers),并希望从中学习正常数据的特征。然后,在我们对正常数据拟合一个模型后,给定新的数据,我们需要找出与已知数据不符的异常值(outliers)。这类问题被称为新颖性检测。另一方面,如果我们在一个包含正常数据和异常值的数据集上拟合我们的模型,那么这个问题被称为异常值检测问题。
与其他无标签算法一样,fit
方法会忽略任何给定的标签。该方法的接口允许你传入* x * 和 * y *,为了保持一致性,但 * y * 会被简单忽略。在新颖性检测的情况下,首先在没有异常值的数据集上使用fit
方法,然后在包含正常数据和异常值的数据上使用算法的predict
方法是合乎逻辑的。相反,对于异常值检测问题,通常会同时使用fit
方法进行拟合,并通过fit_predict
方法进行预测。
在使用任何算法之前,我们需要创建一个样本数据集,以便在本章中使用。我们的数据将包括 1,000 个样本,其中 98%的样本来自特定分布,剩余的 2%来自不同的分布。在下一节中,我们将详细介绍如何创建这个样本数据。
生成样本数据
make_classification
函数允许我们指定样本数量和特征数量。我们可以限制信息性特征的数量,并使一些特征冗余——即依赖于信息性特征。我们也可以将一些特征设置为任何信息性或冗余特征的副本。在我们当前的使用案例中,我们将确保所有特征都是信息性的,因为我们将仅限于使用两个特征。由于make_classification
函数是用于生成分类问题的数据,它同时返回x和y。
在构建模型时,我们将忽略y,并只在后续评估中使用它。我们将确保每个类别来自两个不同的分布,通过将n_clusters_per_class
设置为2
。我们将通过将scale
设置为一个单一值,确保两个特征保持相同的尺度。我们还将确保数据是随机洗牌的(shuffle=True
),并且没有任何一个类别的样本被标记为另一个类别的成员(flip_y=0
)。最后,我们将random_state
设置为0
,确保在我们的计算机上运行以下代码时获得完全相同的随机数据:
from sklearn.datasets import make_classification
x, y = make_classification(
n_samples=1000, n_features=2, n_informative=2, n_redundant=0, n_repeated=0,
n_classes=2, n_clusters_per_class=2, weights=[0.98, ], class_sep=0.5,
scale=1.0, shuffle=True, flip_y=0, random_state=0
)
现在样本数据已经准备好,是时候考虑如何检测其中的离群点了。
使用基本统计学检测异常值
在直接进入 scikit-learn 中现有的算法之前,让我们先思考一些方法来检测异常样本。假设每小时测量你网站的流量,这样你会得到以下数字:
hourly_traffic = [
120, 123, 124, 119, 196,
121, 118, 117, 500, 132
]
看这些数字,500
相比其他数值看起来相当高。正式来说,如果假设每小时的流量数据符合正态分布,那么500
就更远离其均值或期望值。我们可以通过计算这些数字的均值,并检查那些距离均值超过 2 或 3 个标准差的数值来衡量这一点。类似地,我们也可以计算一个高分位数,并检查哪些数值超过了这个分位数。这里,我们找到了高于 95^(th)百分位数的值:
pd.Series(hourly_traffic) > pd.Series(hourly_traffic).quantile(0.95)
这段代码将给出一个False
值的数组,除了倒数第二个值,它对应于500
。在打印结果之前,让我们将前面的代码转化为一个估算器,并包含它的fit
和predict
方法。fit
方法计算阈值并保存,而predict
方法将新数据与保存的阈值进行比较。我还添加了一个fit_predict
方法,它按顺序执行这两个操作。以下是估算器的代码:
class PercentileDetection:
def __init__(self, percentile=0.9):
self.percentile = percentile
def fit(self, x, y=None):
self.threshold = pd.Series(x).quantile(self.percentile)
def predict(self, x, y=None):
return (pd.Series(x) > self.threshold).values
def fit_predict(self, x, y=None):
self.fit(x)
return self.predict(x)
我们现在可以使用我们新创建的估算器。在以下代码片段中,我们使用 95^(th)百分位数作为我们的估算器。然后,我们将得到的预测结果与原始数据一起放入数据框中。最后,我添加了一些样式逻辑,将离群点所在的行标记为粗体:
outlierd = PercentileDetection(percentile=0.95)
pd.DataFrame(
{
'hourly_traffic': hourly_traffic,
'is_outlier': outlierd.fit_predict(hourly_traffic)
}
).style.apply(
lambda row: ['font-weight: bold'] * len(row)
if row['is_outlier'] == True
else ['font-weight: normal'] * len(row),
axis=1
)
这是得到的数据框:
我们能将相同的逻辑应用于前一部分的 dataset 吗?当然可以,但我们首先需要弄清楚如何将其应用于多维数据。
使用百分位数处理多维数据
与hourly_traffic
数据不同,我们使用make_classification
函数生成的数据是多维的。这次我们有多个特征需要检查。显然,我们可以分别检查每个特征。以下是检查第一个特征的离群点的代码:
outlierd = PercentileDetection(percentile=0.98)
y_pred = outlierd.fit_predict(x[:,0])
我们也可以对其他特征做同样的事情:
outlierd = PercentileDetection(percentile=0.98)
y_pred = outlierd.fit_predict(x[:,1])
现在,我们得出了两个预测结果。我们可以以一种方式将它们结合起来,如果某个样本相对于任何一个特征是离群点,那么它就被标记为离群点。在下面的代码片段中,我们将调整PercentileDetection
估算器来实现这一点:
**```py
class PercentileDetection:
def __init__(self, percentile=0.9):
self.percentile = percentile
def fit(self, x, y=None):
self.thresholds = [
pd.Series(x[:,i]).quantile(self.percentile)
for i in range(x.shape[1])
]
def predict(self, x, y=None):
return (x > self.thresholds).max(axis=1)
def fit_predict(self, x, y=None):
self.fit(x)
return self.predict(x)
现在,我们可以按如下方式使用调整后的估算器:
```py
outlierd = PercentileDetection(percentile=0.98)
y_pred = outlierd.fit_predict(x)
我们还可以使用之前忽略的标签来计算我们新估算器的精度和召回率。因为我们关心的是标签为1
的少数类,所以在以下代码片段中,我们将pos_label
设置为1
:
from sklearn.metrics import precision_score, recall_score
print(
'Precision: {:.02%}, Recall: {:.02%} [Percentile Detection]'.format(
precision_score(y, y_pred, pos_label=1),
recall_score(y, y_pred, pos_label=1),
)
)
这给出了4%
的精度和5%
的召回率。你期望更好的结果吗?我也希望如此。也许我们需要绘制数据来理解我们的方法可能存在哪些问题。以下是数据集,其中每个样本根据其标签进行标记:
我们的方法检查每个点,看看它是否在两个轴中的一个上极端。尽管离群点距离内点较远,但仍然有一些内点与离群点的每个点共享相同的水平或垂直位置。换句话说,如果你将点投影到任意一个轴上,你将无法再将离群点与内点区分开来。因此,我们需要一种方法来同时考虑这两个轴。如果我们找到这两个轴的平均点——即我们的数据的中心,然后围绕它绘制一个圆或椭圆?然后,我们可以将任何位于椭圆外的点视为离群点。这个新策略会有效吗?幸运的是,这正是EllipticEnvelope
算法的作用。
使用 EllipticEnvelope 检测离群点
“我害怕变得平庸。”
– 泰勒·斯威夫特
EllipticEnvelope
算法通过找到数据样本的中心,然后在该中心周围绘制一个椭圆体。椭圆体在每个轴上的半径是通过马哈拉诺比斯距离来衡量的。你可以将马哈拉诺比斯距离视为一种欧氏距离,其单位是每个方向上标准差的数量。绘制椭圆体后,位于椭圆体外的点可以被视为离群点。
多元高斯分布是EllipticEnvelope
算法的一个关键概念。它是单维高斯分布的推广。如果高斯分布通过单一的均值和方差来定义,那么多元高斯分布则通过均值和协方差的矩阵来定义。然后,多元高斯分布用于绘制一个椭球体,定义什么是正常的,什么是异常值。
下面是我们如何使用EllipticEnvelope
算法来检测数据中的异常值,使用该算法的默认设置。请记住,本章所有异常值检测算法的predict
方法会返回-1
表示异常值,返回1
表示内点:
from sklearn.covariance import EllipticEnvelope
ee = EllipticEnvelope(random_state=0)
y_pred = ee.fit_predict(x) == -1
我们可以使用前一节中的完全相同代码来计算预测的精确度和召回率:
from sklearn.metrics import precision_score, recall_score
print(
'Precision: {:.02%}, Recall: {:.02%} [EllipticEnvelope]'.format(
precision_score(y, y_pred, pos_label=1),
recall_score(y, y_pred, pos_label=1),
)
)
这一次,我们得到了9%
的精确度和45%
的召回率。这已经比之前的分数更好了,但我们能做得更好吗?嗯,如果你再看一下数据,你会注意到它是非凸的。我们已经知道每个类别中的样本来自多个分布,因此这些点的形状似乎无法完美地拟合一个椭圆。这意味着我们应该使用一种基于局部距离和密度的算法,而不是将所有东西与一个固定的中心点进行比较。局部异常因子(LOF)为我们提供了这种特性。如果上一章的k 均值聚类算法属于椭圆包络算法的同一类,那么 LOF 就是 DBSCAN 算法的对应物。
使用 LOF 进行异常值和新颖性检测
“疯狂在个体中是罕见的——但在群体、党派、国家和时代中,它是常态。”
– 弗里德里希·尼采
LOF 与尼采的方式正好相反——它将样本的密度与其邻居的局部密度进行比较。与邻居相比,处于低密度区域的样本被视为异常值。像其他基于邻居的算法一样,我们可以设置参数来指定要考虑的邻居数量(n_neighbors
)以及用于查找邻居的距离度量(metric
和 p
)。默认情况下,使用的是欧几里得距离——即,metric='minkowski'
和 p=2
。有关可用距离度量的更多信息,您可以参考第五章,最近邻图像处理。下面是我们如何使用LocalOutlierFactor
进行异常值检测,使用 50 个邻居及其默认的距离度量:
from sklearn.neighbors import LocalOutlierFactor
lof = LocalOutlierFactor(n_neighbors=50)
y_pred = lof.fit_predict(x) == -1
精确度和召回率得分现在已经进一步改善了结果。我们得到了26%
的精确度和65%
的召回率。
就像分类器拥有predict
方法以及predict_proba
方法一样,离群点检测算法不仅会给出二分类预测,还可以告诉我们它们对于某个样本是否为离群点的置信度。一旦 LOF 算法被拟合,它会将其离群点因子分数存储在negative_outlier_factor_
中。如果分数接近-1
,则该样本更有可能是离群点。因此,我们可以使用这个分数,将最低的 1%、2%或 10%作为离群点,其余部分视为正常点。以下是不同阈值下的性能指标比较:
from sklearn.metrics import precision_score, recall_score
lof = LocalOutlierFactor(n_neighbors=50)
lof.fit(x)
for quantile in [0.01, 0.02, 0.1]:
y_pred = lof.negative_outlier_factor_ < np.quantile(
lof.negative_outlier_factor_, quantile
)
print(
'LOF: Precision: {:.02%}, Recall: {:.02%} [Quantile={:.0%}]'.format(
precision_score(y, y_pred, pos_label=1),
recall_score(y, y_pred, pos_label=1),
quantile
)
)
以下是不同的精确度和召回率分数:
# LOF: Precision: 80.00%, Recall: 40.00% [Quantile=1%]
# LOF: Precision: 50.00%, Recall: 50.00% [Quantile=2%]
# LOF: Precision: 14.00%, Recall: 70.00% [Quantile=10%]
就像分类器的概率一样,在不同阈值下,精确度和召回率之间存在权衡。这就是你如何微调预测结果以满足需求的方法。如果已知真实标签,你还可以使用negative_outlier_factor_
绘制接收器操作特性(ROC)曲线或精确度-召回率(PR)曲线。
除了用于离群点检测,LOF 算法还可以用于新颖性检测。
**## 使用 LOF 进行新颖性检测
当用于离群点检测时,算法必须在包含正常点和离群点的数据集上进行拟合。而在新颖性检测的情况下,我们需要只在正常点(inliers)上拟合该算法,然后在后续预测中使用被污染的数据集。此外,为了用于新颖性检测,在算法初始化时需要将novelty=True
。在这里,我们从数据中去除离群点,并使用得到的子样本x_inliers
与fit
函数进行拟合。然后,我们按照正常流程对原始数据集进行预测:
from sklearn.neighbors import LocalOutlierFactor
x_inliers = x[y==0]
lof = LocalOutlierFactor(n_neighbors=50, novelty=True)
lof.fit(x_inliers)
y_pred = lof.predict(x) == -1
得到的精确度(26.53%
)和召回率(65.00%
)与我们使用该算法进行离群点检测时差异不大。最终,关于新颖性检测和离群点检测方法的选择是一个策略性的问题。它取决于模型建立时可用的数据,以及这些数据是否包含离群点。
你可能已经知道,我喜欢使用集成方法,所以我很难在没有介绍一个集成算法来进行离群点检测的情况下结束这一章。在下一节中,我们将讨论隔离森林(isolation forest)算法。
使用隔离森林检测离群点
在之前的方法中,我们首先定义什么是正常的,然后将任何不符合此标准的样本视为离群点。隔离森林算法采用了不同的方法。由于离群点数量较少且与其他样本差异较大,因此它们更容易从其余样本中隔离出来。因此,当构建随机树森林时,在树的叶节点较早结束的样本——也就是说,它不需要太多分支就能被隔离——更可能是离群点。
作为一种基于树的集成算法,这个算法与其对手共享许多超参数,比如构建随机树的数量(n_estimators
)、构建每棵树时使用的样本比例(max_samples
)、构建每棵树时考虑的特征比例(max_features
)以及是否进行有放回抽样(bootstrap
)。你还可以通过将 n_jobs
设置为 -1
,利用机器上所有可用的 CPU 并行构建树。在这里,我们将构建一个包含 200 棵树的隔离森林算法,然后用它来预测数据集中的异常值。像本章中的所有其他算法一样,-1
的预测结果表示该样本被视为异常值:
from sklearn.ensemble import IsolationForest
iforest = IsolationForest(n_estimators=200, n_jobs=-1, random_state=10)
y_pred = iforest.fit_predict(x) == -1
得到的精度(6.5%
)和召回率(60.0%
)值不如之前的方法。显然,LOF 是最适合我们手头数据的算法。由于原始标签可用,我们能够对比这三种算法。实际上,标签通常是不可用的,决定使用哪种算法也变得困难。无标签异常检测评估的领域正在积极研究中,我希望在未来能够看到 scikit-learn 实现可靠的评估指标。
在监督学习的情况下,你可以使用真实标签通过 PR 曲线来评估模型。对于无标签数据,最近的研究者们正在尝试量身定制评估标准,比如超质量(Excess-Mass,EM)和质量体积(Mass-Volume,MV)曲线。
总结
到目前为止,在本书中我们使用了监督学习算法来识别异常样本。本章提供了当没有标签时的额外解决方案。这里解释的解决方案源自机器学习的不同领域,如统计学习、最近邻和基于树的集成方法。每种方法都可以表现出色,但也有缺点。我们还学到了,当没有标签时,评估机器学习算法是很棘手的。
本章将处理无标签数据。在上一章中,我们学习了如何聚类数据,接着在这一章我们学习了如何检测其中的异常值。然而,这本书里我们还有一个无监督学习的话题要讨论。下一章我们将讨论与电子商务相关的重要话题——推荐引擎。因为这是本书的最后一章,我还想讨论机器学习模型部署的可能方法。我们将学习如何保存和加载我们的模型,并如何将其部署到应用程序接口(APIs)上。
第十三章:推荐系统 – 了解用户的口味
一个外行可能不知道那些控制股票交易所高频交易的复杂机器学习算法。他们也许不了解那些检测在线犯罪和控制外太空任务的算法。然而,他们每天都会与推荐引擎互动。他们是推荐引擎每天为他们挑选亚马逊上的书籍、在 Netflix 上选择他们下一部应该观看的电影、以及影响他们每天阅读新闻文章的见证者。推荐引擎在许多行业中的普及要求采用不同版本的推荐算法。
在本章中,我们将学习推荐系统使用的不同方法。我们将主要使用一个与 scikit-learn 相关的库——Surprise。Surprise 是一个实现不同协同过滤算法的工具包。因此,我们将从学习协同过滤算法和基于内容的过滤算法在推荐引擎中的区别开始。我们还将学习如何将训练好的模型打包,以便其他软件使用而无需重新训练。以下是本章将讨论的主题:
-
不同的推荐范式
-
下载 Surprise 和数据集
-
使用基于 KNN 的算法
-
使用基准算法
-
使用奇异值分解
-
在生产环境中部署机器学习模型
不同的推荐范式
在推荐任务中,你有一组用户与一组物品互动,你的任务是弄清楚哪些物品适合哪些用户。你可能了解每个用户的一些信息:他们住在哪里、他们赚多少钱、他们是通过手机还是平板登录的,等等。类似地,对于一个物品——比如说一部电影——你知道它的类型、制作年份以及它获得了多少项奥斯卡奖。显然,这看起来像一个分类问题。你可以将用户特征与物品特征结合起来,为每个用户-物品对构建一个分类器,然后尝试预测用户是否会喜欢该物品。这种方法被称为基于内容的过滤。顾名思义,它的效果取决于从每个用户和每个物品中提取的内容或特征。在实践中,你可能只知道每个用户的一些基本信息。用户的位置信息或性别可能足以揭示他们的口味。这种方法也很难推广。例如,如果我们决定扩展推荐引擎,推荐电视连续剧。那时奥斯卡奖的数量可能就不相关了,我们可能需要将此特征替换为金球奖提名的数量。如果我们后来将其扩展到音乐呢?因此,考虑采用一种与内容无关的不同方法似乎更为合理。
协同过滤则不太关注用户或物品特征。相反,它假设那些已经对某些物品感兴趣的用户,将来可能对相同的物品也有兴趣。为了给您推荐物品,它基本上是通过招募与您相似的其他用户,并利用他们的决策来向您推荐物品。这里一个明显的问题是冷启动问题。当新用户加入时,很难立刻知道哪些用户与他们相似。此外,对于一个新物品,可能需要一段时间,直到某些用户发现它,系统才有可能将其推荐给其他用户。
由于每种方法都有其局限性,因此可以使用两者的混合方法。在最简单的形式中,我们可以向新用户推荐平台上最受欢迎的物品。一旦这些新用户消费了足够的物品,以便我们了解他们的兴趣,我们就可以开始结合更具协同过滤的方法,为他们量身定制推荐。
在本章中,我们将重点关注协同过滤范式。它是更常见的方法,我们在前面的章节中已经学会了如何构建为基于内容的过滤方法所需的分类器。我们将使用一个名为 Surprise 的库来演示不同的协同过滤算法。在接下来的部分,我们将安装 Surprise 并下载本章其余部分所需的数据。
下载 surprise 库和数据集
Nicolas Hug 创建了 Surprise [surpriselib.com
],它实现了我们在这里将使用的一些协同过滤算法。我正在使用该库的 1.1.0 版本。要通过 pip
下载相同版本的库,您可以在终端中运行以下命令:
*```py
pip install -U scikit-surprise==1.1.0
在使用该库之前,我们还需要下载本章使用的数据集。
## 下载 KDD Cup 2012 数据集
我们将使用与[第十章](https://cdp.packtpub.com/hands_on_machine_learning_with_scikit_learn/wp-admin/post.php?post=32&action=edit)*不平衡学习 – 甚至不到 1%的人赢得彩票*中使用的相同数据集。数据发布在 **OpenML** 平台上。它包含了一系列记录。在每条记录中,一个用户看到了在线广告,并且有一列额外的信息说明该用户是否点击了广告。在前面提到的章节中,我们构建了一个分类器来预测用户是否会点击广告。我们在分类器中使用了广告和访问用户提供的特征。在本章中,我们将把这个问题框架化为协同过滤问题。因此,我们将只使用用户和广告的 ID,其他所有特征将被忽略,这次的目标标签将是用户评分。在这里,我们将下载数据并将其放入数据框中:
```py
from sklearn.datasets import fetch_openml
data = fetch_openml(data_id=1220)
df = pd.DataFrame(
data['data'],
columns=data['feature_names']
)[['user_id', 'ad_id']].astype(int)
df['user_rating'] = pd.Series(data['target']).astype(int)
我们将所有的列转换为整数。评级列采用二进制值,1
表示点击或正向评分。我们可以看到,只有16.8%
的记录导致了正向评分。我们可以通过打印user_rating
列的均值来验证这一点,如下所示:
df['user_rating'].mean()
我们还可以显示数据集的前四行。在这里,您可以看到用户和广告的 ID 以及给出的评分:
Surprise 库期望数据列的顺序完全按照这个格式。所以,目前不需要进行更多的数据处理。在接下来的章节中,我们将看到如何将这个数据框加载到库中,并将其分割成训练集和测试集。
处理和拆分数据集
从协同过滤的角度来看,两个用户如果对相同的物品给出相同的评分,则认为他们是相似的。在当前的数据格式中很难看到这一点。将数据转换为用户-物品评分矩阵会更好。这个矩阵的每一行表示一个用户,每一列表示一个物品,每个单元格中的值表示该用户给对应物品的评分。我们可以使用 pandas
的 pivot
方法来创建这个矩阵。在这里,我为数据集的前 10 条记录创建了矩阵:
df.head(10).groupby(
['user_id', 'ad_id']
).max().reset_index().pivot(
'user_id', 'ad_id', 'user_rating'
).fillna(0).astype(int)
这是得到的 10
个用户与 10
个物品的矩阵:
使用数据框自己实现这一点并不是最高效的做法。Surprise 库以更高效的方式存储数据。所以,我们将改用该库的 Dataset
模块。在加载数据之前,我们需要指定评分的尺度。在这里,我们将使用 Reader
模块来指定我们的评分是二进制值。然后,我们将使用数据集的 load_from_df
方法加载数据框。该方法需要我们的数据框以及前述的读取器实例:
from surprise.dataset import Dataset
from surprise import Reader
reader = Reader(rating_scale=(0, 1))
dataset = Dataset.load_from_df(df, reader)
协同过滤算法不被认为是监督学习算法,因为缺乏特征和目标等概念。尽管如此,用户会对物品进行评分,我们尝试预测这些评分。这意味着我们仍然可以通过比较实际评分和预测评分来评估我们的算法。这就是为什么通常将数据分割为训练集和测试集,并使用评估指标来检验我们的预测。Surprise 提供了一个类似于 scikit-learn 中 train_test_split
函数的功能。我们将在这里使用它,将数据分割为 75% 的训练集和 25% 的测试集:
from surprise.model_selection import train_test_split
trainset, testset = train_test_split(dataset, test_size=0.25)
除了训练-测试划分外,我们还可以进行K 折交叉验证。我们将使用平均绝对误差(MAE)和均方根误差(RMSE)来比较预测评分和实际评分。以下代码使用 4 折交叉验证,并打印四个折叠的平均 MAE 和 RMSE。为了方便不同算法的应用,我创建了一个predict_evaluate
函数,它接受我们想使用的算法的实例。它还接受整个数据集,并且算法的名称会与结果一起打印出来。然后它使用surprise
的cross_validate
模块来计算期望误差并打印它们的平均值:
**```py
from surprise.model_selection import cross_validate
def predict_evaluate(recsys, dataset, name=‘Algorithm’):
scores = cross_validate(
recsys, dataset, measures=[‘RMSE’, ‘MAE’], cv=4
)
print(
‘Testset Avg. MAE: {:.2f} & Avg. RMSE: {:.2f} [{}]’.format(
scores[‘test_mae’].mean(),
scores[‘test_rmse’].mean(),
name
)
)
我们将在接下来的章节中使用这个函数。在了解不同算法之前,我们需要创建一个参考算法—一条用来与其他算法进行比较的标准。在下一节中,我们将创建一个给出随机结果的推荐系统。这个系统将是我们之后的参考算法。
## 创建一个随机推荐系统
我们知道 16.8%的记录会导致正向评分。因此,一个随机给 16.8%情况赋予正向评分的推荐系统,似乎是一个很好的参考,能够用来与其他算法进行比较。顺便说一句,我特意避免在这里使用*基准*这个术语,而是使用*参考*这样的词汇,因为这里使用的算法之一被称为*基准*。无论如何,我们可以通过创建一个继承自 Surprise 库中`AlgoBase`类的`RandomRating`类来创建我们的参考算法。库中的所有算法都继承自`AlgoBase`基础类,预计它们都需要实现一个估算方法。
这个方法会针对每一对用户-项目对进行调用,并期望返回该特定用户-项目对的预测评分。由于我们这里返回的是随机评分,我们将使用 NumPy 的`random`模块。在这里,我们将二项分布方法中的`n`设置为 1,这使得它变成了伯努利分布。在类初始化时,赋值给`p`的值指定了返回 1 的概率。默认情况下,50%的用户-项目对会得到`1`的评分,而 50%的用户-项目对会得到`0`的评分。我们将覆盖这个默认值,并在稍后的使用中将其设置为 16.8%。以下是新创建方法的代码:
```py
from surprise import AlgoBase
class RandomRating(AlgoBase):
def __init__(self, p=0.5):
self.p = p
AlgoBase.__init__(self)
def estimate(self, u, i):
return np.random.binomial(n=1, p=self.p, size=1)[0]
我们需要将p
的默认值更改为16.8%
。然后,我们可以将RandomRating
实例传递给predict_evaluate
,以获得预测误差:
recsys = RandomRating(p=0.168)
predict_evaluate(recsys, dataset, 'RandomRating')
上述代码给出了0.28
的平均 MAE 和0.53
的平均 RMSE。请记住,我们使用的是 K 折交叉验证。因此,我们计算每一折返回的平均误差的平均值。记住这些误差数字,因为我们预计更先进的算法会给出更低的误差。在接下来的章节中,我们将介绍最基础的协同过滤算法系列,其灵感来源于K-近邻(KNN)算法。
使用基于 KNN 的算法
我们已经遇到过足够多的 KNN算法变种,因此它是我们解决推荐问题时的首选算法。在上一节中的用户-项评分矩阵中,每一行代表一个用户,每一列代表一个项。因此,相似的行代表口味相似的用户,相同的列代表喜欢相同项的用户。因此,如果我们想估算用户(u)对项(i)的评分(r[u,i]),我们可以获取与用户(u)最相似的 KNN,找到他们对项(i)的评分,然后计算他们评分的平均值,作为对(r[u,i])的估算。然而,由于某些邻居比其他邻居与用户(u)更相似,我们可能需要使用加权平均值。与用户(u)更相似的邻居给出的评分应该比其他邻居的评分权重大。以下是一个公式,其中相似度得分用于加权用户邻居给出的评分:
我们用术语v来表示u的邻居。因此,r[v,i]是每个邻居给项(i)的评分。相反,我们也可以根据项相似度而不是用户相似度来进行估算。然后,预期的评分(r[u,i])将是用户(u)对其最相似项(i)评分的加权平均值。
你可能在想,我们现在是否可以设置邻居的数量,是否有多个相似度度量可以选择。两个问题的答案都是肯定的。我们稍后会深入探讨算法的超参数,但现在先使用它的默认值。一旦KNNBasic
初始化完成,我们可以像在上一节中将RandomRating
估算器传递给predict_evaluate
函数那样,将其传递给predict_evaluate
函数。运行以下代码之前,请确保计算机有足够的内存。
from surprise.prediction_algorithms.knns import KNNBasic
recsys = KNNBasic()
predict_evaluate(recsys, dataset, 'KNNBasic')
这一次我们得到了0.28
的平均 MAE 和0.38
的平均 RMSE。考虑到RandomRating
估算器是在盲目地做随机预测,而KNNBasic
是基于用户相似性做决策的,平方误差的改善是预期之中的。
此处使用的数据集中的评分是二进制值。在其他一些场景中,用户可能允许给出 5 星评分,甚至给出从 0 到 100 的分数。在这些场景中,一个用户可能比另一个用户更慷慨地给出分数。我们两个人可能有相同的口味,但对我来说,5 星评分意味着电影非常好,而你自己从不给 5 星评分,你最喜欢的电影顶多获得 4 星评分。KNNWithMeans
算法解决了这个问题。它是与KNNBasic
几乎相同的算法,不同之处在于它最初会对每个用户给出的评分进行归一化,使得评分可以进行比较。
如前所述,我们可以选择K
的数值以及使用的相似度得分。此外,我们还可以决定是否基于用户相似性或物品相似性来进行估计。在这里,我们将邻居数量设置为20
,使用余弦相似度,并基于物品相似性来进行估计:
from surprise.prediction_algorithms.knns import KNNBasic
sim_options = {
'name': 'cosine', 'user_based': False
}
recsys = KNNBasic(k=20, sim_options=sim_options, verbose=False)
predict_evaluate(recsys, dataset, 'KNNBasic')
结果错误比之前更严重。我们得到的平均 MAE 为0.29
,平均 RMSE 为0.39
。显然,我们需要尝试不同的超参数,直到得到最佳结果。幸运的是,Surprise 提供了一个GridSearchCV
助手来调节算法的超参数。我们基本上提供一个超参数值的列表,并指定我们需要用来评估算法的衡量标准。在下面的代码片段中,我们将衡量标准设置为rmse
和mae
。我们使用 4 折交叉验证,并在运行网格搜索时使用机器上的所有可用处理器。你现在可能已经知道,KNN 算法的预测时间较慢。因此,为了加速这一过程,我只在我们的数据集的一个子集上运行了搜索,如下所示:
from surprise.model_selection import GridSearchCV
from surprise.prediction_algorithms.knns import KNNBasic
param_grid = {
'sim_options': {
'name':['cosine', 'pearson'],
},
'k': [5, 10, 20, 40],
'verbose': [True],
}
dataset_subset = Dataset.load_from_df(
df.sample(frac=0.25, random_state=0), reader
)
gscv = GridSearchCV(
KNNBasic, param_grid, measures=['rmse', 'mae'],
cv=4, n_jobs=-1
)
gscv.fit(dataset_subset)
print('Best MAE:', gscv.best_score['mae'].round(2))
print('Best RMSE:', gscv.best_score['rmse'].round(2))
print('Best Params', gscv.best_params['rmse'])
我们得到的平均 MAE 为0.28
,平均 RMSE 为0.38
。这些结果与使用默认超参数时相同。然而,GridSearchCV
选择了K
值为20
,而默认值为40
。它还选择了皮尔逊相关系数作为相似度衡量标准。
KNN 算法较慢,并没有为我们的数据集提供最佳的性能。因此,在下一部分中,我们将尝试使用非实例基础的学习器。
使用基准算法
最近邻算法的简单性是一把双刃剑。一方面,它更容易掌握,但另一方面,它缺乏一个可以在训练过程中优化的目标函数。这也意味着它的大部分计算是在预测时进行的。为了解决这些问题,Yehuda Koren 将推荐问题表述为一个优化任务。然而,对于每个用户-物品对,我们仍然需要估计一个评分(r[u,i])。这次期望的评分是以下三元组的总和:
-
https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/a8e42ccd-0aab-45e7-9757-acf0bea746cb.png:所有用户对所有物品的总体平均评分
-
b[u]:表示用户(u)与总体平均评分的偏差
-
b[i]:表示项目(i)偏离平均评分的术语
这是期望评分的公式:
对于训练集中的每一对用户-项目,我们知道其实际评分(r[u,i]),现在我们要做的就是找出最优的b[u]和b[i]值。我们的目标是最小化实际评分(r[u,i])与上述公式中期望评分(r[u,i])之间的差异。换句话说,我们需要一个求解器在给定训练数据时学习这些项的值。实际上,基准算法尝试最小化实际评分与期望评分之间的平均平方差。它还添加了一个正则化项,用来惩罚(b[u])和(b[i]),以避免过拟合。更多关于正则化概念的理解,请参见第三章,用线性方程做决策。
学到的系数(b[u]和b[i])是描述每个用户和每个项目的向量。在预测时,如果遇到新用户,*b[u]*将设置为0
。类似地,如果遇到在训练集中未出现过的新项目,*b[i]*将设置为0
。
有两个求解器可用于解决此优化问题:随机梯度下降 (SGD) 和 交替最小二乘法 (ALS)。默认使用 ALS。每个求解器都有自己的设置,如最大迭代次数和学习率。此外,您还可以调整正则化参数。
这是使用其默认超参数的模型:
from surprise.prediction_algorithms.baseline_only import BaselineOnly
recsys = BaselineOnly(verbose=False)
predict_evaluate(recsys, dataset, 'BaselineOnly')
这次,我们得到了平均 MAE 为0.27
,平均 RMSE 为0.37
。同样,GridSearchCV
可以用来调整模型的超参数。参数调优部分留给你去尝试。现在,我们进入第三个算法:奇异值分解 (SVD)。
使用奇异值分解
用户-项目评分矩阵通常是一个巨大的矩阵。我们从数据集中得到的矩阵包含 30,114 行和 19,228 列,其中大多数值(99.999%)都是零。这是预期中的情况。假设你拥有一个包含数千部电影的流媒体服务库。用户观看的电影数量很少,因此矩阵中的零值非常多。这种稀疏性带来了另一个问题。如果一个用户观看了电影宿醉:第一部,而另一个用户观看了宿醉:第二部,从矩阵的角度来看,他们看的是两部不同的电影。我们已经知道,协同过滤算法不会使用用户或项目的特征。因此,它无法意识到宿醉的两部作品属于同一系列,更不用说它们都是喜剧片了。为了解决这个问题,我们需要转换我们的用户-项目评分矩阵。我们希望新的矩阵,或者多个矩阵,能够更小并更好地捕捉用户和项目之间的相似性。
SVD是一种用于降维的矩阵分解算法,它与我们在第五章*《使用最近邻的图像处理》*中讨论的主成分分析(PCA)非常相似。与 PCA 中的主成分不同,得到的奇异值捕捉了用户-项目评分矩阵中用户和项目的潜在信息。如果之前的句子不太清楚也不用担心。在接下来的章节中,我们将通过一个例子更好地理解这个算法。
通过 SVD 提取潜在信息
没有什么能比音乐更能体现品味了。让我们来看一下下面这个数据集。在这里,我们有六个用户,每个用户都投票选出了自己喜欢的音乐人:
music_ratings = [('U1', 'Metallica'), ('U1', 'Rammstein'), ('U2', 'Rammstein'), ('U3', 'Tiesto'), ('U3', 'Paul van Dyk'), ('U2', 'Metallica'), ('U4', 'Tiesto'), ('U4', 'Paul van Dyk'), ('U5', 'Metallica'), ('U5', 'Slipknot'), ('U6', 'Tiesto'), ('U6', 'Aly & Fila'), ('U3', 'Aly & Fila')]
我们可以将这些评分放入数据框,并使用数据框的pivot
方法将其转换为用户-项目评分矩阵,具体方法如下:
df_music_ratings = pd.DataFrame(music_ratings, columns=['User', 'Artist'])
df_music_ratings['Rating'] = 1
df_music_ratings_pivoted = df_music_ratings.pivot(
'User', 'Artist', 'Rating'
).fillna(0)
这是得到的矩阵。我使用了pandas
样式,将不同的评分用不同的颜色表示,以便清晰区分:
很明显,用户 1、2 和 5 喜欢金属音乐,而用户 3、4 和 6 喜欢迷幻音乐。尽管用户 5 只与用户 1 和 2 共享一个乐队,但我们仍然可以看出这一点。也许我们之所以能够看到这一点,是因为我们了解这些音乐人,并且我们从整体的角度来看待矩阵,而不是专注于单独的用户对。我们可以使用 scikit-learn 的TruncatedSVD
函数来降低矩阵的维度,并通过N个组件(单一向量)来表示每个用户和音乐人。以下代码片段计算了带有两个单一向量的TruncatedSVD
。然后,transform
函数返回一个新的矩阵,其中每一行代表六个用户中的一个,每一列对应两个单一向量之一:
from sklearn.decomposition import TruncatedSVD
svd = TruncatedSVD(n_components=2)
svd.fit_transform(df_music_ratings_pivoted).round(2)
再次,我将结果矩阵放入数据框,并使用其样式根据值给单元格上色。以下是实现这一点的代码:
pd.DataFrame(
svd.fit_transform(df_music_ratings_pivoted),
index=df_music_ratings_pivoted.index,
columns=['SV1', 'SV2'],
).round(2).style.bar(
subset=['SV1', 'SV2'], align='mid', color='#AAA'
)
这是结果数据框:
你可以将这两个组件视为一种音乐风格。很明显,较小的矩阵能够捕捉到用户在风格上的偏好。用户 1、2 和 5 现在更加接近彼此,用户 3、4 和 6 也是如此,他们彼此之间比原始矩阵中的更为接近。我们将在下一节中使用余弦相似度得分来更清楚地展示这一点。
这里使用的概念也适用于文本数据。诸如search
、find
和forage
等词汇具有相似的意义。因此,TruncatedSVD
变换器可以用来将向量空间模型(VSM)压缩到一个较低的空间,然后再将其用于有监督或无监督的学习算法。在这种背景下,它被称为潜在语义分析(LSA)。
*这种压缩不仅捕捉到了较大矩阵中不明显的潜在信息,还帮助了距离计算。我们已经知道,像 KNN 这样的算法在低维度下效果最好。不要仅仅相信我的话,在下一节中,我们将比较基于原始用户-物品评分矩阵与二维矩阵计算的余弦距离。
比较两个矩阵的相似度度量
我们可以计算所有用户之间的余弦相似度。我们将从原始的用户-物品评分矩阵开始。在计算用户 1、2、3 和 5 的配对余弦相似度后,我们将结果放入数据框并应用一些样式以便于查看:
from sklearn.metrics.pairwise import cosine_similarity
user_ids = ['U1', 'U2', 'U3', 'U5']
pd.DataFrame(
cosine_similarity(
df_music_ratings_pivoted.loc[user_ids, :].values
),
index=user_ids,
columns=user_ids
).round(2).style.bar(
subset=user_ids, align='mid', color='#AAA'
)
以下是四个用户之间的配对相似度结果:
的确,用户 5 比用户 3 更像用户 1 和用户 2。然而,他们之间的相似度并没有我们预期的那么高。现在我们来通过使用TruncatedSVD
来计算相同的相似度:
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.decomposition import TruncatedSVD
user_ids = ['U1', 'U2', 'U3', 'U5']
svd = TruncatedSVD(n_components=2)
df_user_svd = pd.DataFrame(
svd.fit_transform(df_music_ratings_pivoted),
index=df_music_ratings_pivoted.index,
columns=['SV1', 'SV2'],
)
pd.DataFrame(
cosine_similarity(
df_user_svd.loc[user_ids, :].values
),
index=user_ids,
columns=user_ids
).round(2).style.bar(
subset=user_ids, align='mid', color='#AAA'
)
新的计算方法这次捕捉了音乐家之间的潜在相似性,并在比较用户时考虑了这一点。以下是新的相似度矩阵:
显然,用户 5 比以前更像用户 1 和用户 2。忽略这里某些零前的负号,这是因为 Python 实现了IEEE(电气和电子工程师协会)标准的浮点运算。
自然,我们也可以根据音乐家的风格(单一向量)来表示他们。这种矩阵可以通过svd.components_
检索。然后,我们可以计算不同音乐家之间的相似度。这种转换也建议作为稀疏数据聚类的初步步骤。
现在,这个版本的SVD
已经清晰了,在实践中,当处理大数据集时,通常会使用更具可扩展性的矩阵分解算法。概率矩阵分解 (**P*MF)*与观测数量成线性比例,并且在稀疏和不平衡数据集上表现良好。在下一节中,我们将使用 Surprise 的 PMF 实现。
使用 SVD 进行点击预测
我们现在可以使用 Surprise 的SVD
算法来预测我们数据集中的点击。让我们从算法的默认参数开始,然后稍后再解释:
from surprise.prediction_algorithms.matrix_factorization import SVD
recsys = SVD()
predict_evaluate(recsys, dataset, 'SVD')
这次,我们得到的平均 MAE 为0.27
,平均 RMSE 为0.37
。这些结果与之前使用的基准算法类似。事实上,Surprise 的SVD
实现是基准算法和SVD
的结合。它使用以下公式表示用户-项目评分:
方程的前三项((https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/a8e42ccd-0aab-45e7-9757-acf0bea746cb.png,b[u] 和 b[i])与基准算法相同。第四项表示两个相似矩阵的乘积,这些矩阵与我们从TruncatedSVD
得到的矩阵类似。*q[i]*矩阵将每个项目表示为多个单一向量。类似地,p[u]矩阵将每个用户表示为多个单一向量。项目矩阵被转置,因此上面有一个T字母。然后,算法使用SGD来最小化预期评分与实际评分之间的平方差。与基准模型类似,它还对预期评分的系数(b[u], b[i], q[i], 和 p[u])进行正则化,以避免过拟合。
我们可以忽略方程的基准部分——即通过设置biased=False
来移除前面三个系数((https://github.com/OpenDocCN/freelearn-ml-pt2-zh/raw/master/docs/hsn-ml-skl-scipy-tk/img/a8e42ccd-0aab-45e7-9757-acf0bea746cb.png,b[u] 和 b[i])。使用的单一向量数量由n_factors
超参数设置。我们还可以通过n_epochs
控制SGD
的迭代次数。此外,还有其他超参数用于设置算法的学习率、正则化以及系数的初始值。你可以使用surprise
提供的参数调优助手来找到这些参数的最佳组合——即GridSearchCV
或RandomizedSearchCV
。
我们对推荐系统及其各种算法的讨论标志着本书中机器学习话题的结束。与这里讨论的其他所有算法一样,只有在将其投入生产环境并供他人使用时,它们才有意义。在下一节中,我们将看到如何部署一个训练好的算法并让其他人使用它。
在生产环境中部署机器学习模型
使用机器学习模型有两种主要模式:
-
批量预测:在这种模式下,你在一段时间后加载一批数据记录——例如,每晚或每月一次。然后,你对这些数据进行预测。通常,在这里延迟不是问题,你可以将训练和预测代码放入单个批处理作业中。一个例外情况是,如果你需要过于频繁地运行作业,以至于每次作业运行时都没有足够的时间重新训练模型。那么,训练一次模型,将其存储在某处,并在每次进行新的批量预测时加载它是有意义的。
-
在线 预测:在这个模型中,你的模型通常被部署在应用程序编程接口(API)后面。每次调用 API 时,通常会传入一条数据记录,API 需要为这条记录做出预测并返回结果。这里低延迟是至关重要的,通常建议训练模型一次,将其存储在某处,并在每次新的 API 调用时使用预训练模型。
如你所见,在这两种情况下,我们可能需要将模型训练过程中使用的代码与预测时使用的代码分开。无论是监督学习算法还是无监督学习算法,除了编写代码的行数外,拟合的模型还依赖于从数据中学习到的系数和参数。因此,我们需要一种方法来将代码和学习到的参数作为一个单元存储。这个单元可以在训练后保存,并在预测时使用。为了能够将函数或对象存储在文件中或通过互联网共享,我们需要将它们转换为标准格式或协议。这个过程被称为序列化。pickle
是 Python 中最常用的序列化协议之一。Python 标准库提供了序列化对象的工具;然而,joblib
在处理 NumPy 数组时是一个更高效的选择。为了能够使用这个库,你需要通过pip
在终端中运行以下命令来安装它:
pip
install
joblib
安装完成后,你可以使用joblib
将任何东西保存到磁盘文件中。例如,在拟合基线算法后,我们可以使用joblib
函数的dump
方法来存储拟合的对象。该方法除了模型的对象外,还需要提供一个保存对象的文件名。我们通常使用.pkl
扩展名来表示pickle
文件:
import joblib
from surprise.prediction_algorithms.baseline_only import BaselineOnly
recsys = BaselineOnly()
recsys.fit(trainset)
joblib.dump(recsys, 'recsys.pkl')
一旦保存到磁盘,任何其他 Python 代码都可以再次加载相同的模型,并立即使用它,而无需重新拟合。在这里,我们加载已序列化的算法,并使用它对测试集进行预测:
from surprise import accuracy
recsys = joblib.load('recsys.pkl')
predictions = recsys.test(testset)
这里使用了一个surprise
估算器,因为这是我们在本章中一直使用的库。然而,任何 Python 对象都可以以相同的方式被序列化并加载。前几章中使用的任何估算器也可以以相同的方式使用。此外,你还可以编写自己的类,实例化它们,并序列化生成的对象。
若要将你的模型部署为 API,你可能需要使用如Flask或CherryPy之类的 Web 框架。开发 Web 应用超出了本书的范围,但一旦你学会如何构建它们,加载已保存的模型应该会很简单。建议在 Web 应用启动时加载已保存的对象。这样,如果每次收到新请求时都重新加载对象,你就不会引入额外的延迟。
摘要
本章标志着本书的结束。我希望到现在为止,这里讨论的所有概念都已经清晰明了。我也希望每个算法的理论背景与实际应用的结合,能够为你提供解决实际生活中不同问题的途径。显然,没有任何一本书能够得出最终结论,未来你会接触到新的算法和工具。不过,Pedro Domingos 将机器学习算法分为五个类别。除了进化算法外,我们已经学习了属于 Domingos 五个类别中的四个类别的算法。因此,我希望这里讨论的各种算法,每种都有其独特的方法,在未来处理任何新的机器学习问题时,能够为你提供坚实的基础。
所有的书籍都是一个不断完善的过程。它们的价值不仅体现在内容上,还包括它们激发的未来讨论所带来的价值。每次你分享基于书籍中获得的知识所构建的内容时,作者都会感到高兴。每次你引用他们的观点,分享更好、更清晰的解释方式,甚至纠正他们的错误时,他们也会同样高兴。我也期待着你为此做出的宝贵贡献。******