Clojure 机器学习(二)

原文:annas-archive.org/md5/1b2ecfd03b995ad3ca86b0a07ad56e70

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章. 选择和评估数据

在上一章中,我们学习了人工神经网络ANNs)以及它们如何有效地对非线性样本数据进行建模。到目前为止,我们已经讨论了几种可以用来对给定的训练数据集进行建模的机器学习技术。在本章中,我们将探讨以下主题,重点关注如何从样本数据中选择合适的特征:

  • 我们将研究评估或量化制定模型与提供的训练数据拟合准确性的方法。当我们需要扩展或调试现有模型时,这些技术将非常有用。

  • 我们还将探索如何使用clj-ml库在给定的机器学习模型上执行此过程。

  • 在本章的末尾,我们将实现一个包含模型评估技术的有效垃圾邮件分类器。

机器学习诊断”这个术语通常用来描述一种可以运行的测试,以获得关于机器学习模型中哪些工作得好,哪些工作得不好的洞察。诊断生成的信息可以用来提高给定模型的表现。一般来说,在设计机器学习模型时,建议并行地为模型制定一个诊断。为给定模型实现诊断可能需要与制定模型本身相同的时间,但实现诊断是值得投入时间的,因为它有助于快速确定模型中需要改变什么才能改进它。因此,机器学习诊断有助于在调试或改进制定的学习模型方面节省时间。

机器学习的另一个有趣方面是,如果我们不知道我们试图拟合的数据的性质,我们就无法对可以使用哪种机器学习模型来拟合样本数据做出任何假设。这个公理被称为没有免费午餐定理,可以总结如下:

“如果没有关于学习算法性质的前置假设,没有任何学习算法比其他任何(甚至随机猜测)更优越或更劣。”

理解欠拟合和过拟合

在之前的章节中,我们讨论了最小化一个机器学习模型的误差或损失函数。估计模型的总体误差低是合适的,但低误差通常不足以确定模型与提供的训练数据拟合得有多好。在本节中,我们将重新审视过拟合欠拟合的概念。

如果估计模型在预测中表现出较大的误差,则称其为欠拟合。理想情况下,我们应该努力最小化模型中的这个误差。然而,具有低误差或成本函数的公式化模型也可能表明模型不理解模型给定特征之间的潜在关系。相反,模型是记忆提供的数据,这甚至可能导致对随机噪声的建模。在这种情况下,该模型被称为过拟合。过拟合模型的一般症状是未能从未见过的数据中正确预测输出变量。欠拟合模型也被称为表现出高偏差,而过拟合模型则被认为具有高方差

假设我们在模型中建模一个单一的自变量和因变量。理想情况下,模型应该拟合训练数据,同时在尚未观察到的训练数据上泛化。

在欠拟合模型中,可以使用以下图表表示因变量与自变量之间的方差:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_01.jpg

在前图中,红色交叉表示样本数据中的数据点。如图所示,欠拟合模型将表现出较大的总体误差,我们必须通过适当选择模型的特征和使用正则化来尝试减少这个误差。

另一方面,模型也可能过拟合,在这种情况下,模型的总体误差值很低,但估计的模型无法从先前未见过的数据中正确预测因变量。可以使用以下图表来表示过拟合模型:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_03.jpg

如前图所示,估计的模型图紧密但不适当地拟合了训练数据,因此总体误差较低。但是,模型无法对新数据做出正确响应。

描述样本数据的良好拟合模型的总体误差将很低,并且可以从模型中独立变量的先前未见过的值正确预测因变量。一个适当拟合的模型应该有一个类似于以下图表的图形:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_05.jpg

神经网络也可能在提供的样本数据上欠拟合或过拟合。例如,具有少量隐藏节点和层的神经网络可能是一个欠拟合模型,而具有大量隐藏节点和层的神经网络可能表现出过拟合。

评估模型

我们可以绘制模型的因变量和自变量的方差图,以确定模型是欠拟合还是过拟合。然而,随着特征数量的增加,我们需要更好的方法来可视化模型在训练数据上对模型因变量和自变量关系的泛化程度。

我们可以通过确定模型在某些不同数据上的成本函数来评估训练好的机器学习模型。因此,我们需要将可用的样本数据分成两个子集——一个用于训练模型,另一个用于测试模型。后者也被称为我们模型的测试集

然后计算测试集中https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_06.jpg样本的成本函数。这为我们提供了一个度量,即当模型用于之前未见过的数据时,模型的整体误差。这个值由估计模型https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_08.jpg的术语https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_07.jpg表示,也被称为该公式的测试误差。训练数据中的整体误差被称为模型的训练误差,由术语https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_09.jpg表示。线性回归模型的测试误差可以按以下方式计算:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_10.jpg

同样,二元分类模型中的测试误差可以正式表示如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_11.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_12.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_13.jpg

确定模型特征以使测试误差低的问题被称为模型选择特征选择。此外,为了避免过拟合,我们必须测量模型在训练数据上的泛化程度。测试误差本身是对模型在训练数据上泛化误差的乐观估计。然而,我们还必须测量模型在尚未被模型看到的数据上的泛化误差。如果模型在未见过的数据上也有低误差,我们可以确信该模型没有过拟合数据。这个过程被称为交叉验证

因此,为了确保模型能够在未见过的数据上表现良好,我们需要额外的一组数据,称为交叉验证集。交叉验证集中样本的数量由术语https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_14.jpg表示。通常,样本数据被划分为训练集、测试集和交叉验证集,使得训练数据中的样本数量显著多于测试集和交叉验证集。因此,泛化误差,或者说交叉验证误差https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_15.jpg,表明估计模型与未见数据拟合得有多好。请注意,当我们使用交叉验证和测试集对估计模型进行修改时,我们不会修改估计模型。我们将在本章的后续部分更详细地研究交叉验证。正如我们稍后将会看到的,我们还可以使用交叉验证来确定从某些样本数据中模型的特征。

例如,假设我们的训练数据中有 100 个样本。我们将这些样本数据分成三个集合。前 60 个样本将用于估计一个适合数据的模型。在剩下的 40 个样本中,20 个将用于交叉验证估计的模型,其余的 20 个将用于最终测试交叉验证后的模型。

在分类的背景下,一个给定分类器准确性的良好表示是混淆矩阵。这种表示通常用于根据监督机器学习算法可视化给定分类器的性能。这个矩阵中的每一列代表由给定分类器预测属于特定类别的样本数量。混淆矩阵的行代表样本的实际类别。混淆矩阵也称为训练分类器的列联表误差矩阵

例如,假设在给定的分类模型中有两个类别。这个模型的混淆矩阵可能如下所示:

预测类别
AB
实际类别A
B30

在混淆矩阵中,我们模型中的预测类别由垂直列表示,实际类别由水平行表示。在先前的混淆矩阵示例中,总共有 100 个样本。在这些样本中,来自类别 A 的 45 个样本和来自类别 B 的 10 个样本被预测为正确的类别。然而,15 个类别 A 的样本被错误地分类为类别 B,同样,30 个类别 B 的样本被预测为类别 A。

让我们考虑一个使用与上一个示例相同数据的不同分类器的混淆矩阵:

预测类别
AB
实际类别A
B0

在先前的混淆矩阵中,分类器正确地将类别 B 的所有样本分类。此外,只有 5 个类别 A 的样本被错误分类。因此,与上一个示例中使用的分类器相比,这个分类器更好地理解了两种数据类别的区别。在实践中,我们必须努力训练一个分类器,使其混淆矩阵中除对角线元素外的所有元素值都接近于0

理解特征选择

如我们之前提到的,我们需要从样本数据中确定一个合适的特征集,这是我们建立模型的基础。我们可以使用交叉验证来确定从训练数据中应使用哪个特征集,这可以解释如下。

对于每个特征变量集或组合,我们根据所选特征集确定模型的训练和交叉验证错误。例如,我们可能想要添加由模型独立变量导出的多项式特征。我们根据用于建模训练数据的最高多项式度数评估每个特征集的训练和交叉验证错误。我们可以绘制这些错误函数的方差随多项式度数的变化,类似于以下图表:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_16.jpg

从前面的图表中,我们可以确定哪个特征集会产生欠拟合或过拟合的估计模型。如果所选模型在图表左侧具有高训练和交叉验证错误值,则表示模型对提供的训练数据欠拟合。另一方面,如图表右侧所示,低训练错误和高交叉验证错误表明模型过拟合。理想情况下,我们必须选择具有最低可能训练和交叉验证错误值的特征集。

调整正则化参数

为了产生更好的训练数据拟合,我们可以使用正则化来避免过度拟合数据的问题。给定模型的https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_17.jpg值必须根据模型的行为适当选择。请注意,高正则化参数可能导致高训练错误,这是不希望看到的效果。我们可以在公式化的机器学习模型中调整正则化参数,以产生以下图表,显示错误值随模型中正则化参数值的变化:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_18.jpg

因此,如图所示,我们也可以通过改变正则化参数来最小化模型中的训练和交叉验证错误。如果模型在这两个错误值上都有高值,我们必须考虑降低正则化参数的值,直到对于提供的样本数据,这两个错误值都显著降低。

理解学习曲线

另一种可视化机器学习模型性能的有用方法是使用学习曲线。学习曲线本质上是在模型训练和交叉验证的样本数量上绘制错误值的图表。例如,一个模型可能具有以下训练和交叉验证错误的学习曲线:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_19.jpg

学习曲线可以用来诊断欠拟合和过拟合模型。例如,训练误差可能会观察到随着提供给模型的样本数量的增加而迅速增加,并收敛到一个接近交叉验证的值。此外,我们模型中的误差值也有显著的高值。表现出这种误差随样本数量变化的变异性模型是欠拟合的,其学习曲线类似于以下图表:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_20.jpg

另一方面,模型的训练误差可能会随着提供给模型的样本数量的增加而缓慢增加,并且模型中训练误差和交叉验证误差之间可能存在很大的差异。这种模型被称为过拟合,其学习曲线类似于以下图表:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_21.jpg

因此,学习曲线是确定给定机器学习模型中哪些地方不起作用以及需要改变的好辅助工具。

改进模型

一旦我们确定了模型在给定的样本数据上是欠拟合还是过拟合,我们必须决定如何改进模型对独立变量和因变量之间关系的理解。以下简要讨论几种这些技术:

使用交叉验证

如我们之前简要提到的,交叉验证是一种常见的验证技术,可以用来评估机器学习模型。交叉验证本质上衡量的是估计模型将如何泛化一些给定数据。这些数据与提供给我们的模型训练数据不同,被称为模型的交叉验证集,或简单地称为验证集。给定模型的交叉验证也称为旋转估计

如果估计模型在交叉验证期间表现良好,我们可以假设该模型可以理解其各种独立和依赖变量之间的关系。交叉验证的目的是提供一个测试,以确定所提出的模型是否在训练数据上过度拟合。从实施的角度来看,交叉验证是机器学习系统的一种单元测试。

单轮交叉验证通常涉及将所有可用的样本数据划分为两个子集,然后在其中一个子集上进行训练,在另一个子集上进行验证和/或测试。必须使用不同的数据集进行多个这样的交叉验证轮次,或称为“折”,以减少给定模型的整体交叉验证误差的方差。任何特定的交叉验证误差度量都应该计算为不同折在交叉验证中的平均误差。

对于给定的机器学习模型或系统,我们可以实现多种类型的交叉验证作为诊断。以下简要探讨其中几种:

  • 一种常见类型是k-折交叉验证,其中我们将交叉验证数据划分为k个相等的子集。然后,在数据的一个子集上执行模型的训练,在另一个子集上进行交叉验证。

  • k-折交叉验证的一种简单变体是2-折交叉验证,也称为留出法。在*2-折交叉验证中,训练和交叉验证数据子集的比例几乎相等。

  • 重复随机子采样是交叉验证的另一种简单变体,其中首先对样本数据进行随机化或洗牌,然后将其用作训练和交叉验证数据。这种方法特别不依赖于交叉验证中使用的折数。

  • k-折交叉验证的另一种形式是**留一法交叉验证*,其中仅使用可用样本数据中的一个记录进行交叉验证。留一法交叉验证本质上等同于k-折交叉验证,其中k等于样本数据中的样本或观察数量。

交叉验证基本上将估计模型视为一个黑盒,即它不对模型的实现做出任何假设。我们还可以使用交叉验证来通过确定在给定样本数据上产生最佳拟合模型的特征集来选择给定模型中的特征。当然,分类有一些局限性,可以总结如下:

  • 如果需要给定的模型进行内部特征选择,我们必须对给定模型中每个选定的特征集进行交叉验证。这可能会根据可用样本数据的数量而变得计算成本高昂。

  • 如果样本数据恰好或几乎完全相等,交叉验证就不是很有效。

总结来说,对于任何我们构建的机器学习系统,实现交叉验证都是一个好的实践。此外,我们可以根据我们试图建模的问题以及收集到的样本数据的性质来选择合适的交叉验证技术。

注意

对于接下来的示例,命名空间声明应类似于以下声明:

(ns my-namespace
  (:use [clj-ml classifiers data]))

我们可以使用 clj-ml 库来交叉验证我们在第三章,分类数据中为鱼包装厂构建的分类器。本质上,我们使用 clj-ml 库构建了一个分类器,用于确定一条鱼是鲑鱼还是海鲈鱼。为了回顾,一条鱼被表示为一个包含鱼类别和鱼的各种特征的值的向量。鱼的属性是它的长度、宽度和皮肤的光泽。我们还描述了一个样本鱼的模板,其定义如下:

(def fish-template
  [{:category [:salmon :sea-bass]}
   :length :width :lightness])

在前面的代码中定义的 fish-template 向量可以用来使用一些样本数据训练一个分类器。现在,我们暂时不必担心我们使用了哪种分类算法来建模给定的训练数据。我们只能假设这个分类器是使用 clj-ml 库中的 make-classifier 函数创建的。这个分类器存储在 *classifier* 变量中,如下所示:

(def *classifier* (make-classifier ...))

假设分类器已经使用一些样本数据进行训练。我们现在必须评估这个训练好的分类模型。为此,我们必须首先创建一些用于交叉验证的样本数据。为了简化,在这个例子中我们将使用随机生成的数据。我们可以使用我们定义在第三章,分类数据中的 make-sample-fish 函数来生成这些数据。这个函数简单地创建一个包含一些随机值的新的向量,代表一条鱼。当然,我们不应该忘记 make-sample-fish 函数有一个内置的偏置,因此我们使用这个函数创建的多个样本中创建一个有意义的模式,如下所示:

(def fish-cv-data
  (for [i (range 3000)] (make-sample-fish)))

我们需要使用来自 clj-ml 库的数据集,并且我们可以使用 make-dataset 函数来创建一个,如下面的代码所示:

(def fish-cv-dataset
  (make-dataset "fish-cv" fish-template fish-cv-data))

为了交叉验证分类器,我们必须使用来自 clj-ml.classifiers 命名空间的 classifier-evaluate 函数。这个函数本质上在给定数据上执行 k-fold 交叉验证。除了分类器和交叉验证数据集之外,这个函数还需要指定作为最后一个参数的数据的折数。此外,我们首先需要使用 dataset-set-class 函数设置 fish-cv-dataset 记录的类字段。我们可以定义一个单独的函数来执行这些操作,如下所示:

(defn cv-classifier [folds]
  (dataset-set-class fish-cv-dataset 0)
  (classifier-evaluate *classifier* :cross-validation
                       fish-cv-dataset folds))

我们将在分类器上使用 10 折交叉验证。由于classifier-evaluate函数返回一个映射,我们将此返回值绑定到一个变量以供进一步使用,如下所示:

user> (def cv (cv-classifier 10))
#'user/cv

我们可以使用:summary关键字获取并打印前面交叉验证的摘要,如下所示:

user> (print (:summary cv))

Correctly Classified Instances        2986              99.5333 %
Incorrectly Classified Instances        14               0.4667 %
Kappa statistic                          0.9888
Mean absolute error                      0.0093
Root mean squared error                  0.0681
Relative absolute error                  2.2248 %
Root relative squared error             14.9238 %
Total Number of Instances             3000     
nil

如前述代码所示,我们可以查看我们训练好的分类器的多个性能统计指标。除了正确和错误分类的记录外,此摘要还描述了分类器中的均方根误差RMSE)和其他几个误差度量。为了更详细地查看分类器中正确和错误分类的实例,我们可以使用:confusion-matrix关键字打印交叉验证的混淆矩阵,如下所示:

user> (print (:confusion-matrix cv))
=== Confusion Matrix ===

    a    b   <-- classified as
 2129    0 |    a = salmon
    9  862 |    b = sea-bass
nil

如前例所示,我们可以使用clj-ml库的classifier-evaluate函数对任何给定的分类器执行k折交叉验证。尽管在使用classifier-evaluate函数时我们被限制只能使用clj-ml库中的分类器,但我们必须努力在我们构建的任何机器学习系统中实现类似的诊断。

构建垃圾邮件分类器

现在我们已经熟悉了交叉验证,我们将构建一个包含交叉验证的工作机器学习系统。当前的问题将是垃圾邮件分类,其中我们必须确定一封给定邮件是否为垃圾邮件的可能性。本质上,这个问题归结为二元分类,并做了一些调整以使机器学习系统对垃圾邮件更加敏感(更多信息,请参阅垃圾邮件计划)。请注意,我们不会实现一个与电子邮件服务器集成的分类引擎,而是将专注于使用一些数据训练引擎和分类给定邮件的方面。

这种在实际中的使用方法可以简要说明如下。用户将接收并阅读一封新邮件,并决定是否将该邮件标记为垃圾邮件。根据用户的决定,我们必须使用新邮件作为数据来训练邮件服务的垃圾邮件引擎。

为了以更自动化的方式训练我们的垃圾邮件分类器,我们只需简单地收集数据以供分类器使用。我们需要大量的数据才能有效地用英语训练一个分类器。幸运的是,垃圾邮件分类的样本数据在互联网上很容易找到。对于这个实现,我们将使用来自Apache SpamAssassin项目的数据。

备注

Apache SpamAssassin 项目是一个用 Perl 实现的垃圾邮件分类引擎的开源实现。对于我们的实现,我们将使用该项目中的样本数据。您可以从 spamassassin.apache.org/publiccorpus/ 下载这些数据。在我们的示例中,我们使用了 spam_2easy_ham_2 数据集。一个容纳我们的垃圾邮件分类器实现的 Clojure Leiningen 项目将要求这些数据集被提取并放置在 corpus/ 文件夹的 ham/spam/ 子目录中。corpus/ 文件夹应放置在 Leiningen 项目的根目录中,与 project.clj 文件相同的文件夹。

我们垃圾邮件分类器的特征将是所有之前遇到的单词在垃圾邮件和正常邮件中的出现次数。术语 ham 指的是“非垃圾邮件”。因此,在我们的模型中实际上有两个独立的变量。此外,每个单词都有一个与电子邮件中出现的概率相关联,这可以通过它在垃圾邮件和正常邮件中出现的次数以及分类器处理的电子邮件总数来计算。新电子邮件将通过找到电子邮件标题和正文中所有已知单词,然后以某种方式结合这些单词在垃圾邮件和正常邮件中的出现概率来进行分类。

对于我们分类器中的给定单词特征,我们必须通过考虑分类器分析的电子邮件总数来计算单词出现的总概率(更多信息,请参阅 更好的贝叶斯过滤)。此外,一个未见过的术语在意义上是中性的,即它既不是垃圾邮件也不是正常邮件。因此,未经训练的分类器中任何单词出现的初始概率是 0.5。因此,我们使用 贝叶斯概率 函数来模拟特定单词的出现。

为了分类新电子邮件,我们还需要结合其中找到的所有已知单词的出现概率。对于这个实现,我们将使用 费舍尔方法,或 费舍尔组合概率测试,来结合计算出的概率。尽管这个测试的数学证明超出了本书的范围,但重要的是要知道这种方法本质上是在给定模型中将几个独立概率估计为 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_28.jpg(发音为 卡方)分布(更多信息,请参阅 研究工作者统计方法)。这种分布有一个相关的自由度数。可以证明,具有等于组合概率数 k 两次的自由度的 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_28.jpg 分布可以正式表示如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_29.jpg

这意味着使用具有https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_28.jpg自由度的https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_30.jpg分布,电子邮件是垃圾邮件或正常邮件的概率的累积分布函数CDF)可以结合起来反映一个总概率,当有大量值接近 1.0 的概率时,这个总概率会很高。因此,只有当电子邮件中的大多数单词之前都曾在垃圾邮件中找到时,电子邮件才会被分类为垃圾邮件。同样,大量正常邮件的关键词也会表明该电子邮件实际上是一封正常邮件。另一方面,电子邮件中垃圾邮件关键词出现的次数较少时,其概率会更接近 0.5,在这种情况下,分类器将不确定该电子邮件是垃圾邮件还是正常邮件。

注意

对于接下来的示例,我们需要从clojure.java.ioIncanter库中分别获取filecdf-chisq函数。示例的命名空间声明应类似于以下声明:

(ns my-namespace
  (:use [clojure.java.io :only [file]]
        [incanter.stats :only [cdf-chisq]])

使用前面描述的 Fisher 方法训练的分类器将非常敏感于新的垃圾邮件。我们用给定电子邮件是垃圾邮件的概率来表示我们模型的因变量。这个概率也被称为电子邮件的垃圾邮件分数。低分数表示电子邮件是正常邮件,而高分数表示电子邮件是垃圾邮件。当然,我们还需要在我们的模型中包含一个第三类来表示未知值。我们可以为这些类别的分数定义一些合理的限制,如下所示:

(def min-spam-score 0.7)
(def max-ham-score 0.4)

(defn classify-score [score]
  [(cond
    (<= score max-ham-score) :ham
    (>= score min-spam-score) :spam
    :else :unsure)
   score])

如前所述,如果电子邮件的分数为 0.7 或更高,则它是垃圾邮件。分数为 0.5 或更低的电子邮件表示它是正常邮件。此外,如果分数介于这两个值之间,我们无法有效地决定电子邮件是垃圾邮件还是正常邮件。我们使用关键词:ham:spam:unsure来表示这三个类别。

垃圾邮件分类器必须读取几封电子邮件,确定电子邮件文本和标题中的所有单词或标记,并将这些信息作为经验知识存储起来以供以后使用。我们需要存储特定单词在垃圾邮件和正常邮件中出现的次数。因此,分类器遇到的每个单词都代表一个特征。为了表示单个单词的信息,我们将使用具有三个字段的记录,如下面的代码所示:

(defrecord TokenFeature [token spam ham])

(defn new-token [token]
  (TokenFeature. token 0 0))

(defn inc-count [token-feature type]
  (update-in token-feature [type] inc))

在前面的代码中定义的 TokenFeature 记录可以用来存储我们垃圾邮件分类器所需的信息。new-token 函数简单地通过调用记录构造函数为给定标记创建一个新的记录。显然,一个单词在垃圾邮件和非垃圾邮件中最初都被看到零次。我们还需要更新这些值,因此我们定义了 inc-count 函数来使用 update-in 函数对记录进行更新。请注意,update-in 函数期望一个函数作为最后一个参数应用于记录中的特定字段。我们已经在实现中处理了一小部分可变状态,因此让我们通过代理来委托对这个状态的访问。我们还想跟踪垃圾邮件和非垃圾邮件的总数;因此,我们将这些值也用代理包装起来,如下面的代码所示:

(def feature-db
  (agent {} :error-handler #(println "Error: " %2)))

(def total-ham (agent 0))
(def total-spam (agent 0))

在前面的代码中定义的 feature-db 代理将用于存储所有单词特征。我们使用 :error-handler 关键字参数为这个代理定义了一个简单的错误处理器。代理的 total-hamtotal-spam 函数将分别跟踪垃圾邮件和非垃圾邮件的总数。现在,我们将定义几个函数来访问这些代理,如下所示:

(defn clear-db []
  (send feature-db (constantly {}))
  (send total-ham  (constantly 0))
  (send total-spam (constantly 0)))

(defn update-feature!
  "Looks up a TokenFeature record in the database and
  creates it if it doesn't exist, or updates it."
  [token f & args]
  (send feature-db update-in [token]
        #(apply f (if %1 %1 (new-token token))
                args)))

如果你对 Clojure 中的代理不熟悉,我们可以使用 send 函数来改变代理中包含的值。这个函数期望一个参数,即应用于其封装值的函数。代理在其包含的值上应用这个函数,如果没有错误,则更新它。clear-db 函数简单地使用初始值初始化我们定义的所有代理。这是通过使用 constantly 函数来完成的,该函数将一个值包装在一个返回相同值的函数中。update-feature! 函数修改 feature-db 映射中给定标记的值,并在提供的标记不在 feature-db 映射中时创建一个新的标记。由于我们只将增加给定标记的出现次数,因此我们将 inc-count 函数作为参数传递给 update-feature! 函数。

现在,让我们定义分类器如何从给定的电子邮件中提取单词。我们将使用正则表达式来完成这项工作。如果我们想从给定的字符串中提取所有单词,我们可以使用正则表达式 [a-zA-Z]{3,}。我们可以在 Clojure 中使用字面量语法来定义这个正则表达式,如下面的代码所示。请注意,我们也可以使用 re-pattern 函数来创建正则表达式。我们还将定义所有应该从中提取标记的 MIME 头字段。我们将使用以下代码来完成所有这些:

(def token-regex #"[a-zA-Z]{3,}")

(def header-fields
  ["To:"
   "From:"
   "Subject:"
   "Return-Path:"])

为了匹配与 token-regex 定义的正则表达式相匹配的标记,我们将使用 re-seq 函数,该函数返回给定字符串中所有匹配标记的字符串序列。对于电子邮件的 MIME 头部,我们需要使用不同的正则表达式来提取标记。例如,我们可以按照以下方式从 "From" MIME 头部提取标记:

user> (re-seq #"From:(.*)\n"
              "From: someone@host.org\n")
(["From: someone@host.org\n" " someone@host.org"])

注意

注意正则表达式末尾的换行符,它用于指示电子邮件中 MIME 头部的结束。

我们可以继续提取由前述代码中定义的正则表达式匹配得到的值中的单词。让我们定义以下几个函数,使用这种逻辑从给定电子邮件的头和正文中提取标记:

(defn header-token-regex [f]
  (re-pattern (str f "(.*)\n")))

(defn extract-tokens-from-headers [text]
  (for [field header-fields]
    (map #(str field %1)  ; prepends field to each word from line
         (mapcat (fn [x] (->> x second (re-seq token-regex)))
                 (re-seq (header-token-regex field)
                         text)))))

(defn extract-tokens [text]
  (apply concat
         (re-seq token-regex text)
         (extract-tokens-from-headers text)))

在前述代码中定义的 header-token-regex 函数返回一个用于给定头部的正则表达式,例如 "From:(.*)\n" 用于 "From" 头部。extract-tokens-from-headers 函数使用这个正则表达式来确定电子邮件各种头部字段中的所有单词,并将头部名称附加到头部文本中找到的所有标记上。extract-tokens 函数将正则表达式应用于电子邮件的文本和头部,然后使用 applyconcat 函数将结果列表展平成一个单一的列表。注意,extract-tokens-from-headers 函数对于在 header-fields 中定义但不在提供的电子邮件头部中出现的头部返回空列表。让我们通过以下代码在 REPL 中尝试这个函数:

user> (def sample-text
        "From: 12a1mailbot1@web.de
         Return-Path: <12a1mailbot1@web.de>
         MIME-Version: 1.0")

user> (extract-tokens-from-headers sample-text)
(() ("From:mailbot" "From:web")
 () ("Return-Path:mailbot" "Return-Path:web"))

使用 extract-tokens-from-headers 函数和 token-regex 定义的正则表达式,我们可以从电子邮件的头和文本中提取所有由三个或更多字符组成的单词。现在,让我们定义一个函数,将 extract-tokens 函数应用于给定的电子邮件,并使用 update-feature! 函数更新特征映射,其中包括电子邮件中找到的所有单词。我们将借助以下代码来完成所有这些工作:

(defn update-features!
  "Updates or creates a TokenFeature in database
  for each token in text."
  [text f & args]
  (doseq [token (extract-tokens text)]
    (apply update-feature! token f args)))

使用前述代码中的 update-features! 函数,我们可以使用给定的电子邮件来训练我们的垃圾邮件分类器。为了跟踪垃圾邮件和正常邮件的总数,我们必须根据给定的电子邮件是垃圾邮件还是正常邮件,将 inc 函数发送到 total-spamtotal-ham 代理。我们将借助以下代码来完成这项工作:

(defn inc-total-count! [type]
  (send (case type
          :spam total-spam
          :ham total-ham)
        inc))

(defn train! [text type]
  (update-features! text inc-count type)
  (inc-total-count! type))

在前一段代码中定义的 inc-total-count! 函数更新了我们特征数据库中垃圾邮件和正常邮件的总数。train! 函数简单地调用 update-features!inc-total-count! 函数,使用给定的邮件及其类型来训练我们的垃圾邮件分类器。注意,我们将 inc-count 函数传递给 update-features! 函数。现在,为了将新邮件分类为垃圾邮件或正常邮件,我们首先必须定义如何使用我们的训练特征数据库从给定的邮件中提取已知特征。我们将借助以下代码来完成这项工作:

(defn extract-features
  "Extracts all known tokens from text"
  [text]
  (keep identity (map #(@feature-db %1) (extract-tokens text))))

在前面的代码中定义的extract-features函数通过解除引用存储在feature-db中的映射,并将其作为函数应用于extract-tokens函数返回的所有值来查找给定电子邮件中的所有已知特征。由于映射闭包#(@feature-db %1)可以为不在feature-db代理中的所有标记返回()nil,因此我们需要从提取的特征列表中删除所有空值。为此,我们将使用keep函数,该函数期望一个应用于集合中非 nil 值的函数以及从其中过滤出所有 nil 值的集合。由于我们不想转换电子邮件中的已知特征,我们将传递identity函数,该函数将返回其参数本身作为keep函数的第一个参数。

现在我们已经从一个给定的电子邮件中提取了所有已知特征,我们必须计算这些特征在垃圾邮件中出现的所有概率。然后,我们必须使用我们之前描述的费舍尔方法将这些概率结合起来,以确定新电子邮件的垃圾邮件分数。让我们定义以下函数来实现贝叶斯概率和费舍尔方法:

(defn spam-probability [feature]
  (let [s (/ (:spam feature) (max 1 @total-spam))
        h (/ (:ham feature) (max 1 @total-ham))]
      (/ s (+ s h))))

(defn bayesian-spam-probability
  "Calculates probability a feature is spam on a prior
  probability assumed-probability for each feature,
  and weight is the weight to be given to the prior
  assumed (i.e. the number of data points)."
  [feature & {:keys [assumed-probability weight]
              :or   {assumed-probability 1/2 weight 1}}]
  (let [basic-prob (spam-probability feature)
        total-count (+ (:spam feature) (:ham feature))]
    (/ (+ (* weight assumed-probability)
          (* total-count basic-prob))
       (+ weight total-count))))

在前面的代码中定义的spam-probability函数使用垃圾邮件和非垃圾邮件中单词出现的次数以及分类器处理的垃圾邮件和非垃圾邮件的总数来计算给定单词特征在垃圾邮件中出现的概率。为了避免除以零错误,我们在执行除法之前确保垃圾邮件和非垃圾邮件的数量至少为 1。bayesian-spam-probability函数使用spam-probability函数返回的这个概率来计算一个加权平均值,初始概率为 0.5 或1/2

我们现在将实现费舍尔方法,该方法用于结合由bayesian-spam-probability函数返回的所有已知特征的概率。我们将借助以下代码来完成这项工作:

(defn fisher
  "Combines several probabilities with Fisher's method."
  [probs]
  (- 1 (cdf-chisq
         (* -2 (reduce + (map #(Math/log %1) probs)))
         :df (* 2 (count probs)))))

在前面的代码中定义的fisher函数使用Incanter库中的cdf-chisq函数来计算由表达式https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_05_31.jpg转换的几个概率的 CDF。我们使用:df可选参数指定此函数的自由度。我们现在需要将fisher函数应用于电子邮件是垃圾邮件或非垃圾邮件的贝叶斯概率组合,并将这些值组合成一个最终的垃圾邮件分数。这两个概率必须结合,使得只有高概率的高频次出现才表明垃圾邮件或非垃圾邮件的可能性很高。已经证明,这样做最简单的方法是平均垃圾邮件的概率和垃圾邮件的负概率(或 1 减去垃圾邮件的概率)。我们将借助以下代码来完成这项工作:

(defn score [features]
  (let [spam-probs (map bayesian-spam-probability features)
        ham-probs (map #(- 1 %1) spam-probs)
        h (- 1 (fisher spam-probs))
        s (- 1 (fisher ham-probs))]
     (/ (+ (- 1 h) s) 2)))

因此,score 函数将返回给定电子邮件的最终垃圾邮件分数。让我们定义一个函数来从给定的电子邮件中提取已知单词特征,将这些特征的出现的概率结合起来产生电子邮件的垃圾邮件分数,并最终将这个垃圾邮件分数分类为正常邮件或垃圾邮件,分别用关键词 :ham:spam 表示,如下面的代码所示:

(defn classify
  "Returns a vector of the form [classification score]"
  [text]
   (-> text
       extract-features
       score
       classify-score))

到目前为止,我们已经实现了如何训练我们的垃圾邮件分类器以及如何使用它来分类一封新的电子邮件。现在,让我们定义一些函数来从项目的 corpus/ 文件夹中加载样本数据,并使用这些数据来训练和交叉验证我们的分类器,如下所示:

(defn populate-emails
  "Returns a sequence of vectors of the form [filename type]"
  []
  (letfn [(get-email-files [type]
            (map (fn [f] [(.toString f) (keyword type)])
                 (rest (file-seq (file (str "corpus/" type))))))]
    (mapcat get-email-files ["ham" "spam"])))

在前面的代码中定义的 populate-emails 函数返回一个向量序列,代表我们样本数据中来自 ham/ 文件夹的所有正常邮件和来自 spam/ 文件夹的所有垃圾邮件。这个返回序列中的每个向量都有两个元素。这个向量中的第一个元素是给定电子邮件的相对文件路径,第二个元素是 :spam:ham,这取决于电子邮件是否为垃圾邮件。请注意,使用 file-seq 函数将目录中的文件作为序列读取。

现在,我们将使用 train! 函数将所有电子邮件的内容输入到我们的垃圾邮件分类器中。为此,我们可以使用 slurp 函数将文件内容读取为字符串。对于交叉验证,我们将使用 classify 函数对提供的交叉验证数据中的每封电子邮件进行分类,并返回一个表示交叉验证测试结果的映射列表。我们将通过以下代码来完成这项工作:

(defn train-from-corpus! [corpus]
  (doseq [v corpus]
    (let [[filename type] v]
      (train! (slurp filename) type))))

(defn cv-from-corpus [corpus]
  (for [v corpus]
    (let [[filename type] v
          [classification score] (classify (slurp filename))]
      {:filename filename
       :type type
       :classification classification
       :score score})))

在前面的代码中定义的 train-from-corpus! 函数将使用 corpus/ 文件夹中找到的所有电子邮件来训练我们的垃圾邮件分类器。cv-from-corpus 函数使用训练好的分类器将提供的电子邮件分类为垃圾邮件或正常邮件,并返回一个表示交叉验证过程结果的映射序列。cv-from-corpus 函数返回的序列中的每个映射包含电子邮件的文件、电子邮件的实际类型(垃圾邮件或正常邮件)、电子邮件的预测类型和电子邮件的垃圾邮件分数。现在,我们需要在样本数据的两个适当划分的子集上调用这两个函数,如下所示:

(defn test-classifier! [corpus cv-fraction]
  "Trains and cross-validates the classifier with the sample
  data in corpus, using cv-fraction for cross-validation.
  Returns a sequence of maps representing the results
  of the cross-validation."
    (clear-db)
    (let [shuffled (shuffle corpus)
          size (count corpus)
          training-num (* size (- 1 cv-fraction))
          training-set (take training-num shuffled)
          cv-set (nthrest shuffled training-num)]
      (train-from-corpus! training-set)
      (await feature-db)
      (cv-from-corpus cv-set)))

在前面的代码中定义的 test-classifier! 函数将随机打乱样本数据,并选择指定比例的随机数据作为我们的分类器的交叉验证集。然后,test-classifier! 函数调用 train-from-corpus!cv-from-corpus 函数来训练和交叉验证数据。请注意,使用 await 函数是为了等待 feature-db 代理完成通过 send 函数发送给它的所有函数的应用。

现在我们需要分析交叉验证的结果。我们必须首先确定由 cv-from-corpus 函数返回的给定电子邮件的实际和预期类别中的错误分类和遗漏的电子邮件数量。我们将使用以下代码来完成这项工作:

(defn result-type [{:keys [filename type classification score]}]
  (case type
    :ham  (case classification
            :ham :correct
            :spam :false-positive
            :unsure :missed-ham)
    :spam (case classification
            :spam :correct
            :ham :false-negative
            :unsure :missed-spam)))

result-type 函数将确定交叉验证过程中错误分类和遗漏的电子邮件数量。现在,我们可以将 result-type 函数应用于 cv-from-corpus 函数返回的结果中的所有映射,并使用以下代码帮助打印交叉验证结果的摘要:

(defn analyze-results [results]
  (reduce (fn [map result]
            (let [type (result-type result)]
              (update-in map [type] inc)))
          {:total (count results) :correct 0 :false-positive 0
           :false-negative 0 :missed-ham 0 :missed-spam 0}
          results))

(defn print-result [result]
  (let [total (:total result)]
    (doseq [[key num] result]
      (printf "%15s : %-6d%6.2f %%%n"
              (name key) num (float (* 100 (/ num total)))))))

在前面的代码中定义的 analyze-results 函数简单地将 result-type 函数应用于 cv-from-corpus 函数返回的序列中的所有映射值,同时保持错误分类和遗漏的电子邮件总数。print-result 函数简单地将分析结果打印为字符串。最后,让我们定义一个函数,使用 populate-emails 函数加载所有电子邮件,然后使用这些数据来训练和交叉验证我们的垃圾邮件分类器。由于 populate-emails 函数在没有电子邮件时将返回一个空列表或 nil,我们将检查这个条件以避免在程序后续阶段失败:

(defn train-and-cv-classifier [cv-frac]
  (if-let [emails (seq (populate-emails))]
    (-> emails
        (test-classifier! cv-frac)
        analyze-results
        print-result)
    (throw (Error. "No mails found!"))))

在前面代码中显示的 train-and-cv-classifier 函数中,我们首先调用 populate-emails 函数,并使用 seq 函数将结果转换为序列。如果序列有任何元素,我们训练并交叉验证分类器。如果没有找到电子邮件,我们简单地抛出一个错误。请注意,if-let 函数用于检查 seq 函数返回的序列是否有任何元素。

我们已经拥有了创建和训练垃圾邮件分类器所需的所有部分。最初,由于分类器尚未看到任何电子邮件,任何电子邮件或文本被分类为垃圾邮件的概率是 0.5。这可以通过以下代码验证,该代码最初将任何文本分类为 :unsure 类型:

user> (classify "Make money fast")
[:unsure 0.5]
user> (classify "Job interview today! Programmer job position for GNU project")
[:unsure 0.5]

我们现在使用 train-and-cv-classifier 函数训练分类器并交叉验证它。我们将使用所有可用样本数据的一分之一作为我们的交叉验证集。这如下面的代码所示:

user> (train-and-cv-classifier 1/5)
          total : 600   100.00 %
        correct : 585    97.50 %
 false-positive : 1       0.17 %
 false-negative : 1       0.17 %
     missed-ham : 9       1.50 %
    missed-spam : 4       0.67 %
nil

交叉验证我们的垃圾邮件分类器断言它适当地分类了电子邮件。当然,仍然存在一小部分错误,这可以通过使用更多的训练数据来纠正。现在,让我们尝试使用我们的训练好的垃圾邮件分类器对一些文本进行分类,如下所示:

user> (classify "Make money fast")
[:spam 0.9720416490829515]
user> (classify "Job interview today! Programmer job position for GNU project")
[:ham 0.19095646757667556]

有趣的是,文本"Make money fast"被归类为垃圾邮件,而文本“Job interview … GNU project”被归类为正常邮件,如前面的代码所示。让我们看看训练好的分类器是如何使用extract-features函数从某些文本中提取特征的。由于分类器最初没有读取任何标记,因此当分类器未训练时,此函数显然会返回一个空列表或nil,如下所示:

user> (extract-features "some text to extract")
(#clj_ml5.spam.TokenFeature{:token "some", :spam 91, :ham 837}
 #clj_ml5.spam.TokenFeature{:token "text", :spam 907, :ham 1975}
 #clj_ml5.spam.TokenFeature{:token "extract", :spam 3, :ham 5})

如前面的代码所示,每个TokenFeature记录将包含给定单词在垃圾邮件和正常邮件中出现的次数。此外,单词"to"不被识别为特征,因为我们只考虑由三个或更多字符组成的单词。

现在,让我们检查我们的垃圾邮件分类器对垃圾邮件的敏感性。我们首先需要选择一些文本或特定的术语,这些文本或术语既不被归类为垃圾邮件,也不被归类为正常邮件。对于本例中选定的训练数据,单词"Job"符合这一要求,如下面的代码所示。让我们使用train!函数用单词"Job"训练分类器,同时指定文本类型为正常邮件。我们可以这样做,如下所示:

user> (classify "Job")
[:unsure 0.6871002132196162]
user> (train! "Job" :ham)
#<Agent@1f7817e: 1993>
user> (classify "Job")
[:unsure 0.6592140921409213]

在用给定的文本作为正常邮件训练分类器后,观察到该术语被归类为垃圾邮件的概率略有下降。如果术语"Job"出现在更多正常邮件中,分类器最终会将该单词归类为正常邮件。因此,分类器对新的正常邮件的反应并不明显。相反,如以下代码所示,分类器对垃圾邮件的敏感性很高:

user> (train! "Job" :spam)
#<Agent@1f7817e: 1994>
user> (classify "Job")
[:spam 0.7445135045480734]

在单个垃圾邮件中观察到特定单词的出现会显著增加分类器预测该术语属于垃圾邮件的概率。术语"Job"随后将被我们的分类器归类为垃圾邮件,至少直到它在足够多的正常邮件中出现。这是由于我们正在建模的卡方分布的性质。

我们还可以通过向分类器提供更多训练数据来提高我们垃圾邮件分类器的整体错误率。为了演示这一点,让我们只用样本数据中的一分之一来交叉验证分类器。因此,分类器将实际上用可用的九分之八的数据进行训练,如下所示:

user> (train-and-cv-classifier 1/10)
          total : 300   100.00 %
        correct : 294    98.00 %
 false-positive : 0       0.00 %
 false-negative : 1       0.33 %
     missed-ham : 3       1.00 %
    missed-spam : 2       0.67 %
nil

如前面的代码所示,当我们使用更多训练数据时,漏检和错误分类的邮件数量有所减少。当然,这只是一个示例,我们应收集更多邮件作为训练数据输入到分类器中。使用样本数据的一部分进行交叉验证是一种良好的实践。

总结来说,我们有效地构建了一个使用费舍尔方法训练的垃圾邮件分类器。我们还实现了一个交叉验证诊断,这相当于对我们分类器的一种单元测试。

注意

注意,train-and-cv-classifier函数产生的确切值将取决于用作训练数据的垃圾邮件和正常邮件。

摘要

在本章中,我们探讨了可以用来诊断和改进给定机器学习模型的技巧。以下是我们已经涵盖的一些其他要点:

  • 我们重新审视了样本数据欠拟合和过拟合的问题,并讨论了如何评估一个已制定模型以诊断它是否欠拟合或过拟合。

  • 我们已经探讨了交叉验证及其如何被用来确定一个已制定模型对之前未见过的数据的响应效果。我们还看到,我们可以使用交叉验证来选择模型的特征和正则化参数。我们还研究了几种可以针对给定模型实现的交叉验证方法。

  • 我们简要探讨了学习曲线及其如何被用来诊断欠拟合和过拟合模型。

  • 我们已经探讨了clj-ml库提供的工具,用于对给定分类器进行交叉验证。

  • 最后,我们构建了一个操作性的垃圾邮件分类器,该分类器结合交叉验证来确定分类器是否适当地将电子邮件分类为垃圾邮件。

在接下来的章节中,我们将继续探索更多的机器学习模型,并且我们还将详细研究支持向量机SVMs)。

第六章。构建支持向量机

在本章中,我们将探讨支持向量机SVMs)。我们将研究 Clojure 中的一些 SVM 实现,这些实现可以用来使用一些给定的训练数据构建和训练 SVM。

SVMs 是用于回归和分类的监督学习模型。然而,在本章中,我们将专注于 SVMs 中的分类问题。SVMs 在文本挖掘、化学分类、图像和手写识别中都有应用。当然,我们不应忽视这样一个事实,即机器学习模型的整体性能主要取决于训练数据量和性质,并且也受我们用于建模可用数据的机器学习模型的影响。

在最简单的情况下,SVM 通过估计在向量空间中表示的两个类别的最佳向量平面或超平面来分离和预测两个类别的数据。一个超平面可以简单地定义为比环境空间少一个维度的平面。对于三维空间,我们会得到一个二维超平面。

基本的支持向量机(SVM)是一种非概率的二分类器,它使用线性分类。除了线性分类之外,SVMs 还可以用于对多个类别进行非线性分类。SVMs 的一个有趣方面是,估计的向量平面将在输入值的类别之间具有相当大且独特的间隙。正因为如此,SVMs 通常具有很好的泛化性能,并且实现了一种自动复杂度控制来避免过拟合。因此,SVMs 也被称为大间隔分类器。在本章中,我们还将研究 SVMs 如何与其他分类器相比,在输入数据的类别之间实现这种大间隔。关于 SVMs 的另一个有趣的事实是,它们与被建模的特征数量非常匹配,因此 SVMs 通常用于处理大量特征的机器学习问题。

理解大间隔分类

正如我们之前提到的,SVMs 通过大间隔对输入数据进行分类。让我们来看看这是如何实现的。我们使用我们之前在第三章中描述的逻辑分类模型定义,作为对 SVMs 进行推理的基础。

我们可以使用逻辑或sigmoid函数来分离两个类别的输入值,正如我们在第三章中描述的,数据分类。这个函数可以正式定义为输入变量X的函数,如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_01.jpg

在前一个方程中,输出变量 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_02.jpg 不仅依赖于变量 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_03.jpg,还依赖于系数 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_04.jpg。变量 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_03.jpg 类似于我们模型中的输入值向量,而项 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_04.jpg 是模型的参数向量。对于二元分类,Y 的值必须在 0 和 1 的范围内。此外,一组输入值的类别由输出变量 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_02.jpg 是更接近 0 还是 1 来决定。对于这些 Y 的值,项 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_05.jpg 要么远大于 0,要么远小于 0。这可以形式化地表达如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_06.jpg

对于具有输入值 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_08.jpg 和输出值 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_09.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_07.jpg 个样本,我们定义成本函数 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_10.jpg 如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_12.jpg

注意

注意,项 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_11.jpg 代表从估计模型计算得到的输出变量。

对于逻辑回归分类模型,https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_11.jpg 是将逻辑函数应用于一组输入值 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_08.jpg 时的值。我们可以简化并展开前面方程定义的成本函数中的求和项 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_13.jpg,如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_14.jpg

很明显,前面表达式中显示的成本函数取决于表达式中的两个对数项。因此,我们可以将成本函数表示为这两个对数项的函数,分别用项 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_15.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_16.jpg 表示。现在,让我们假设以下方程中的两个项:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_17.jpg

函数https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_15.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_16.jpg都是使用逻辑函数组成的。一个模拟逻辑函数的分类器必须经过训练,使得这两个函数在参数向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_04.jpg的所有可能值上都被最小化。我们可以使用铰链损失函数来近似使用逻辑函数的线性分类器的期望行为(更多信息,请参阅“损失函数都一样吗?”)。现在,我们将通过将其与逻辑函数进行比较来研究铰链损失函数。以下图表描述了https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_15.jpg函数必须如何随https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_18.jpg项变化,以及它如何可以使用逻辑函数和铰链损失函数来建模:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/image1.jpg

在前一张图中所示的图中,逻辑函数被表示为一条平滑的曲线。可以看到,在某个给定点之前,该函数迅速下降,然后以更低的速率下降。在这个例子中,逻辑函数速率变化发生的点是x = 0。铰链损失函数通过使用两个在x = 0点交汇的线段来近似这一点。有趣的是,这两个函数都模拟了一种随输入值x成反比变化的速率的行为。同样,我们可以使用铰链损失函数来近似https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_16.jpg函数的效果,如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/image2.jpg

注意,https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_16.jpg函数与https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_18.jpg项成正比。因此,我们可以通过模拟铰链损失函数来实现逻辑函数的分类能力,而使用铰链损失函数构建的分类器将表现得与使用逻辑函数的分类器一样好。

如前图所示,hinge 损失函数仅在https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_21.jpg这一点上改变其值。这适用于https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_15.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_16.jpg这两个函数。因此,我们可以使用 hinge 损失函数根据https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_23.jpg的值是大于还是小于 0 来分离两类数据。在这种情况下,这两类数据之间几乎没有分离间隔。为了提高分类间隔,我们可以修改 hinge 损失函数,使其仅在https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_24.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_25.jpg时值为大于 0。

修改后的 hinge 损失函数可以如下绘制两类数据。以下图表描述了https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_25.jpg的情况:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/image3.jpg

同样,对于https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_24.jpg情况修改后的 hinge 损失函数可以通过以下图表进行说明:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/image4.jpg

注意,在https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_24.jpg的情况下,hinge发生在*-1*处。

如果我们将https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_15.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_16.jpg函数替换为 hinge 损失函数,我们就会得到 SVMs(支持向量机)的优化问题(更多信息,请参阅“支持向量网络”),其形式化表达如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_28.jpg

在前述方程中,项https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_29.jpg是正则化参数。此外,当https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_30.jpg时,SVM 的行为更多地受到https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_15.jpg函数的影响,反之亦然当https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_31.jpg。在某些情况下,模型的正则化参数https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_29.jpg作为常数C添加到优化问题中,其中C类似于https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_32.jpg。这种优化问题的表示可以形式化地表达如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_33.jpg

由于我们只处理两类数据,其中https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_09.jpg要么是 0 要么是 1,我们可以将之前描述的优化问题重写如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_34.jpg

让我们尝试可视化 SVM 在训练数据上的行为。假设我们在训练数据中有两个输入变量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_35.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_36.jpg。输入值及其类别可以用以下图示表示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/image5.jpg

在前述图示中,训练数据中的两类被表示为圆圈和正方形。线性分类器将尝试将这些样本值划分为两个不同的类别,并产生一个决策边界,该边界可以由前述图示中的任意一条线表示。当然,分类器应努力最小化所构建模型的总体误差,同时找到一个很好地泛化数据的模型。SVM 也会像其他分类模型一样尝试将样本数据划分为两个类别。然而,SVM 设法确定了一个分离超平面,该超平面在输入数据的两个类别之间观察到具有最大的可能间隔。

SVM 的这种行为可以用以下图示来展示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_38_a.jpg

如前述图示所示,SVM 将确定一个最优的超平面,该超平面在两类数据之间具有最大的可能间隔来分离这两类数据。从我们之前描述的 SVM 优化问题中,我们可以证明 SVM 估计的分离超平面的方程如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_40.jpg

注意

注意,在前述方程中,常数https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_39.jpg仅仅是超平面的 y 截距。

要了解 SVM 如何实现这种大的间隔分离,我们需要使用一些基本的向量代数。首先,我们可以定义一个给定向量的长度如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_41.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_42.jpg

另一个常用来描述支持向量机(SVMs)的操作是两个向量的内积。两个给定向量的内积可以形式化定义为如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_43.jpg

注意

注意,只有当两个向量长度相同时,两个向量的内积才存在。

如前述方程所示,两个向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_44.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_45.jpg的内积等于https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_45.jpg的转置与向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_46.jpg的点积。另一种表示两个向量内积的方法是利用一个向量在另一个向量上的投影,如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_47.jpg

注意,项https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_48.jpg等同于向量 V 与向量 U 转置的向量积https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_49.jpg。由于表达式https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_50.jpg等同于向量的乘积https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_51.jpg,我们可以将我们之前用输入变量投影到输出变量描述的优化问题重新写为以下形式:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_52.jpg

因此,SVM 试图最小化参数向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_04.jpg中元素的平方和,同时确保将两个数据类别分开的最佳超平面位于两个平面之间以及https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_53.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_54.jpg。这两个平面被称为 SVM 的支持向量。由于我们必须最小化参数向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_04.jpg中元素的值,因此投影https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_55.jpg必须足够大,以确保https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_56.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_57.jpg

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_58.jpg

因此,SVM 将确保输入变量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_08.jpg投影到输出变量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_09.jpg的投影尽可能大。这意味着 SVM 将在训练数据中找到两个输入值类别之间可能的最大间隔。

SVM 的替代形式

现在我们将描述几种替代形式来表示 SVM。本节的其余部分可以安全地跳过,但建议读者了解这些形式,因为它们也是 SVM 广泛使用的符号。

如果https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_59.jpg是 SVM 估计的超平面的法线,我们可以用以下方程表示这个分离超平面:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_61.jpg

注意

注意,在前面的方程中,项https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_60.jpg是超平面的 y 截距,与我们之前描述的超平面方程中的项https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_39.jpg类似。

这个超平面的两个外围支持向量具有以下方程:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_62.jpg

我们可以使用表达式https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_63.jpg来确定给定输入值集的类别。如果这个表达式的值小于或等于-1,那么我们可以说输入值属于两个数据类别之一。同样,如果表达式https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_63.jpg的值大于或等于 1,预测输入值属于第二个类别。这可以正式表示如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_64.jpg

前面方程中描述的两个不等式可以合并成一个不等式,如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_65.jpg

因此,我们可以简洁地重写 SVMs 的优化问题如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_66.jpg

在前面方程定义的受约束问题中,我们使用法线https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_59.jpg而不是参数向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_04.jpg来参数化优化问题。通过使用拉格朗日乘数https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_67.jpg,我们可以将优化问题表示如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_68.jpg

这种 SVM 优化问题的形式被称为原始形式。请注意,在实践中,只有少数拉格朗日乘数将具有大于 0 的值。此外,这个解可以表示为输入向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_08.jpg和输出变量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_09.jpg的线性组合,如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_69.jpg

我们也可以将 SVM 的优化问题表示为对偶形式,这是一种受约束的表示,可以描述如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_70.jpg

在前面方程中描述的受约束问题中,函数https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_71.jpg被称为核函数,我们将在本章后面的部分讨论这个函数在 SVMs 中的作用。

使用 SVM 进行线性分类

正如我们之前所描述的,SVMs 可以用于在两个不同的类别上执行线性分类。SVM 将尝试找到一个超平面来分隔这两个类别,使得估计的超平面描述了我们在模型中两个类别之间可达到的最大分离间隔。

例如,可以使用以下图表来可视化两个数据类别的估计超平面:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_72.jpg

如前述图表所示,圆圈和交叉用于表示样本数据中的两个类别的输入值。线代表 SVM 的估计超平面。

在实践中,使用已实现的 SVM 而不是自己实现 SVM 通常更有效。有几个库实现了 SVM,并且已经移植到多种编程语言中。其中一个这样的库是 LibLinear (www.csie.ntu.edu.tw/~cjlin/liblinear/),它使用 SVM 实现了一个线性分类器。LibLinear 的 Clojure 封装是 clj-liblinear (github.com/lynaghk/clj-liblinear),我们现在将探讨如何使用这个库轻松构建一个线性分类器。

注意

可以通过在 project.clj 文件中添加以下依赖项将 clj-liblinear 库添加到 Leiningen 项目中:

[clj-liblinear "0.1.0"]

对于接下来的示例,命名空间声明应类似于以下声明:

(ns my-namespace
  (:use [clj-liblinear.core :only [train predict]]))

首先,让我们生成一些训练数据,以便我们有两个类别的输入值。在这个例子中,我们将模拟两个输入变量,如下所示:

(def training-data
  (concat
   (repeatedly
    500 #(hash-map :class 0
                   :data {:x (rand)
                          :y (rand)}))
   (repeatedly
    500 #(hash-map :class 1
                   :data {:x (- (rand))
                          :y (- (rand))}))))

使用前面代码中显示的 repeatedly 函数,我们生成了两个映射序列。这两个序列中的每个映射都包含键 :class:data:class 键的值表示输入值的类别,而 :data 键的值是另一个包含键 :x:y 的映射。:x:y 键的值代表我们训练数据中的两个输入变量。这些输入变量的值是通过使用 rand 函数随机生成的。训练数据是生成的,使得一组输入值的类别为 0,如果两个输入值都是正数,而如果两个输入值都是负数,则一组输入值的类别为 1。如前所述的代码所示,使用 repeatedly 函数生成了总共 1,000 个样本,分为两个类别,作为两个序列,然后使用 concat 函数合并成一个序列。我们可以在 REPL 中检查这些输入值,如下所示:

user> (first training-data)
{:class 0,
 :data {:x 0.054125811753944264, :y 0.23575052637986382}}
user> (last training-data)
{:class 1,
 :data {:x -0.8067872409710037, :y -0.6395480020409928}}

我们可以使用我们生成的训练数据创建和训练一个 SVM。为此,我们使用train函数。train函数接受两个参数,包括输入值序列和输出值序列。这两个序列都假定是相同顺序的。对于分类的目的,输出变量可以设置为给定一组输入值的类别,如下所示:

(defn train-svm []
  (train
   (map :data training-data)
   (map :class training-data)))

前述代码中定义的train-svm函数将使用training-data序列实例化和训练一个 SVM。现在,我们可以使用训练好的 SVM 通过predict函数进行分类,如下所示:

user> (def svm (train-svm))
#'user/svm
user> (predict svm {:x 0.5 :y 0.5})
0.0
user> (predict svm {:x -0.5 :y 0.5})
0.0
user> (predict svm {:x -0.4 :y 0.4})
0.0
user> (predict svm {:x -0.4 :y -0.4})
1.0
user> (predict svm {:x 0.5 :y -0.5})
1.0

predict函数需要两个参数,一个是 SVM 的实例,以及一组输入值。

如前述代码所示,我们使用svm变量来表示一个训练好的 SVM。然后我们将svm变量传递给predict函数,同时传递一组新的输入值,这些输入值的类别是我们想要预测的。观察到predict函数的输出与训练数据一致。有趣的是,只要输入值:y为正,分类器就会预测任何一组输入值的类别为0;相反,如果一组输入值的:y特征为负,则预测为1

在前一个例子中,我们使用 SVM 进行分类。然而,训练好的 SVM 的输出变量始终是一个数字。因此,我们也可以像前述代码中描述的那样使用clj-liblinear库来训练一个回归模型。

clj-liblinear库也支持更复杂的 SVM 特征类型,如向量、映射和集合。现在,我们将演示如何训练一个使用集合作为输入变量的分类器,而不是像前一个例子中那样使用纯数字。假设我们有一个来自特定用户 Twitter 动态的推文流。假设用户将手动将这些推文分类到预定义类别中的一个。这个处理过的推文序列可以表示如下:

(def tweets
  [{:class 0 :text "new lisp project released"}
   {:class 0 :text "try out this emacs package for common lisp"}
   {:class 0 :text "a tutorial on guile scheme"}

   {:class 1 :text "update in javascript library"}
   {:class 1 :text "node.js packages are now supported"}
   {:class 1 :text "check out this jquery plugin"}

   {:class 2 :text "linux kernel news"}
   {:class 2 :text "unix man pages"}
   {:class 2 :text "more about linux software"}])

前述代码中定义的推文向量包含几个映射,每个映射都有:class:text键。:text键包含推文文本,我们将使用:text键中的值来训练 SVM。但是我们不能直接使用文本,因为推文中可能会有重复的单词。此外,我们还需要处理这个文本中字母的情况。让我们定义一个函数将这个文本转换为集合,如下所示:

(defn extract-words [text]
  (->> #" "
       (split text)
       (map lower-case)
       (into #{})))

前述代码中定义的extract-words函数会将任何字符串(由参数text表示)转换为一系列单词,这些单词全部为小写。为了创建一个集合,我们使用(into #{})形式。根据定义,这个集合将不包含任何重复的值。注意在extract-words函数定义中使用了->>线程宏。

注意

extract-words函数中,->>形式可以等价地写成(into #{} (map lower-case (split text #" ")))

我们可以在 REPL 中检查extract-words函数的行为,如下所示:

user> (extract-words "Some text to extract some words")
#{"extract" "words" "text" "some" "to"}

使用extract-words函数,我们可以有效地使用一组字符串作为特征变量来训练 SVM。如我们之前提到的,这可以通过train函数来完成,如下所示:

(defn train-svm []
  (train (->> tweets
              (map :text)
              (map extract-words))
         (map :class tweets)))

在前面的代码中定义的train-svm函数将使用trainextract-words 函数创建并训练一个 SVM,该 SVM 使用推文变量中的处理后的训练数据。我们现在需要在以下代码中组合predictextract-words函数,以便我们可以预测给定推文的类别:

(defn predict-svm [svm text]
  (predict
    svm (extract-words text)))

在前面的代码中定义的predict-svm函数可以用来对给定的推文进行分类。我们可以在 REPL 中验证 SVM 对一些任意推文的预测类别,如下所示:

user> (def svm (train-svm))
#'user/svm
user> (predict-svm svm "a common lisp tutorial")
0.0
user> (predict-svm svm "new javascript library")
1.0
user> (predict-svm svm "new linux kernel update")
2.0

总之,clj-liblinear库允许我们轻松地使用大多数 Clojure 数据类型构建和训练 SVM。该库施加的唯一限制是训练数据必须能够线性分离成我们模型中的类别。我们将在本章的后续部分研究如何构建更复杂的分类器。

使用核 SVM

在某些情况下,可用的训练数据不是线性可分的,我们无法使用线性分类来建模数据。因此,我们需要使用不同的模型来拟合非线性数据。如第四章《构建神经网络》中所述,人工神经网络(ANNs)可以用来建模这类数据。在本节中,我们将描述如何使用核函数将 SVM 拟合到非线性数据上。包含核函数的 SVM 被称为核支持向量机。请注意,在本节中,术语 SVM 和核 SVM 是互换使用的。核 SVM 将根据非线性决策边界对数据进行分类,决策边界的性质取决于 SVM 使用的核函数。为了说明这种行为,核 SVM 将按照以下图示将训练数据分为两类:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_73.jpg

在支持向量机(SVMs)中使用核函数的概念实际上是基于数学变换的。核函数在 SVM 中的作用是将训练数据中的输入变量进行变换,使得变换后的特征是线性可分的。由于 SVM 基于大间隔线性划分输入数据,因此两个数据类别之间的这种大间隔分离在非线性空间中也将是可观察的。

核函数表示为https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_71.jpg,其中https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_08.jpg是从训练数据中得到的输入值向量,https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_74.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_75.jpg的转换向量。函数https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_71.jpg表示这两个向量的相似性,并且等同于转换空间中这两个向量的内积。如果输入向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_75.jpg具有给定的类别,那么当这两个向量的核函数值接近 1 时,即https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_76.jpg时,向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_74.jpg的类别与向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_75.jpg的类别相同。核函数可以用以下数学表达式表示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_77.jpg

在前一个方程中,函数https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_78.jpg执行从非线性空间https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_79.jpg到线性空间https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_46.jpg的转换。请注意,https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_78.jpg的显式表示不是必需的,只需知道https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_46.jpg是一个内积空间即可。虽然我们可以自由选择任何任意的核函数来建模给定的训练数据,但我们必须努力减少最小化所构建 SVM 模型成本函数的问题。因此,核函数通常被选择,使得计算 SVM 的决策边界只需要确定转换特征空间https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_46.jpg中向量的点积。

SVM 的核函数的一个常见选择是多项式核函数,也称为多项式核函数,它将训练数据建模为原始特征变量的多项式。读者可能还记得第五章中关于选择和评估数据的讨论,我们讨论了多项式特征如何极大地提高给定机器学习模型的性能。多项式核函数可以被视为这一概念的扩展,适用于 SVM。该函数可以形式化地表示如下。

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_80.jpg

在前一个方程中,术语https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_81.jpg代表多项式特征的最高次数。此外,当(常数)https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_82.jpg时,核被称作同质的

另一个广泛使用的核函数是高斯核函数。大多数熟悉线性代数的读者对高斯函数都不陌生。重要的是要知道,这个函数表示数据点的正态分布,其中数据点更接近数据的均值。

在 SVM 的背景下,高斯核函数可以用来表示一个模型,其中训练数据中的两个类别之一在输入变量上的值接近任意均值。高斯核函数可以形式地表示如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_83.jpg

在前面方程定义的高斯核函数中,术语https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_06_84.jpg表示训练数据的方差,并代表高斯核的宽度

核函数的另一个流行选择是字符串核函数,它作用于字符串值。术语“字符串”指的是符号的有限序列。字符串核函数本质上衡量两个给定字符串之间的相似度。如果传递给字符串核函数的两个字符串相同,该函数返回的值将是1。因此,字符串核函数在将特征表示为字符串的数据建模中非常有用。

序列最小优化

SVM 的优化问题可以使用序列最小优化SMO)来解决。SVM 的优化问题是跨多个维度的成本函数的数值优化,以减少训练 SVM 的整体误差。在实践中,这必须通过数值优化技术来完成。SMO 算法的完整讨论超出了本书的范围。然而,我们必须注意,该算法通过一种分而治之的技术来解决优化问题。本质上,SMO 将多个维度的优化问题分解为几个可以解析解决的较小的二维问题(更多信息,请参阅序列最小优化:训练支持向量机的一种快速算法)。

LibSVM是一个流行的库,它实现了 SMO 来训练 SVM。svm-clj库是 LibSVM 的 Clojure 包装器,我们将现在探讨如何使用这个库来构建 SVM 模型。

注意

可以通过在project.clj文件中添加以下依赖项将svm-clj库添加到 Leiningen 项目中:

[svm-clj "0.1.3"]

对于接下来的示例,命名空间声明应类似于以下声明:

(ns my-namespace
  (:use svm.core))

此示例将使用SPECT Heart数据集的简化版本(archive.ics.uci.edu/ml/datasets/SPECT+Heart)。此数据集描述了使用单光子发射计算机断层扫描SPECT)图像对几位心脏病患者的诊断。原始数据集包含总共 267 个样本,其中每个样本有 23 个特征。数据集的输出变量描述了给定患者的阳性或阴性诊断,分别用+1 或-1 表示。

对于此示例,训练数据存储在一个名为features.dat的文件中。此文件必须放置在 Leiningen 项目的resources/目录中,以便可供使用。此文件包含几个输入特征和这些输入值的类别。让我们看一下文件中的以下样本值之一:

+1 2:1 3:1 4:-0.132075 5:-0.648402 6:1 7:1 8:0.282443 9:1 10:0.5 11:1 12:-1 13:1

如前述代码行所示,第一个值+1表示样本的类别,其他值表示输入变量。请注意,输入变量的索引也给出了。此外,前述样本中第一个特征的值是0,因为它没有使用1:键提及。从前述行中可以清楚地看出,每个样本将最多有 12 个特征。所有样本值都必须符合 LibSVM 规定的此格式。

我们可以使用这些样本数据训练一个 SVM。为此,我们使用svm-clj库中的train-model函数。此外,由于我们必须首先从文件中加载样本数据,我们还需要首先使用以下代码调用read-dataset函数:

(def dataset (read-dataset "resources/features.dat"))

(def model (train-model dataset))

如前述代码中定义的模型变量所表示的训练好的 SVM 现在可以用来预测一组输入值的类别。predict函数可用于此目的。为了简单起见,我们将使用数据集变量本身的样本值如下:

user> (def feature (last (first dataset)))
#'user/feature
user> feature
{1 0.708333, 2 1.0, 3 1.0, 4 -0.320755, 5 -0.105023,
 6 -1.0, 7 1.0, 8 -0.4198, 9 -1.0, 10 -0.2258, 12 1.0, 13 -1.0}
user> (feature 1)
0.708333
user> (predict model feature)
1.0

如前述代码中的 REPL 输出所示,dataset可以被视为一系列的映射。每个映射包含一个代表样本中输出变量值的单个键。dataset映射中此键的值是另一个映射,它表示给定样本的输入变量。由于feature变量代表一个映射,我们可以将其作为函数调用,如前述代码中的(feature 1)调用所示。

预测值与给定一组输入值的输出变量实际值或类别相一致。总之,svm-clj库为我们提供了一个简单且简洁的 SVM 实现。

使用核函数

如我们之前提到的,当我们需要拟合一些非线性数据时,我们可以为 SVM 选择一个核函数。现在,我们将通过使用clj-ml库来展示如何在实践中实现这一点。由于这个库已经在之前的章节中讨论过,我们将不会关注 SVM 的完整训练过程,而是关注如何创建使用核函数的 SVM。

注意

对于接下来的示例,命名空间声明应类似于以下声明:

(ns my-namespace
  (:use [clj-ml classifiers kernel-functions]))

来自clj-ml.kernel-functions命名空间的make-kernel-function函数用于创建可用于 SVMs 的核函数。例如,我们可以通过将:polynomic关键字传递给此函数来创建一个多项式核函数,如下所示:

(def K (make-kernel-function :polynomic {:exponent 3}))

如前一行所示,由变量K定义的多项式核函数具有多项式次数3。同样,我们也可以使用:string关键字创建一个字符串核函数,如下所示:

(def K (make-kernel-function :string))

clj-ml库中存在几种这样的核函数,鼓励读者探索这个库中更多的核函数。该命名空间文档可在antoniogarrote.github.io/clj-ml/clj-ml.kernel-functions-api.html找到。我们可以通过指定:support-vector-machine:smo关键字以及使用:kernel-function关键字选项来创建一个 SVM,如下所示:

(def classifier
  (make-classifier :support-vector-machine :smo
                   :kernel-function K))

现在我们可以像之前章节中做的那样,训练由变量 classifier 表示的 SVM。因此,clj-ml库允许我们创建具有给定核函数的 SVM。

摘要

在本章中,我们探讨了 SVMs 及其如何用于拟合线性和非线性数据。以下是我们已经涵盖的其他主题:

  • 我们已经探讨了支持向量机(SVMs)如何实现大间隔分类以及 SVMs 的优化问题各种形式

  • 我们已经讨论了如何使用核函数和 SMO 来训练非线性样本数据的 SVM

  • 我们还展示了如何使用几个 Clojure 库来构建和训练 SVMs

在下一章中,我们将把重点转向无监督学习,并探讨聚类技术来模拟这些类型的机器学习问题。

第七章:聚类数据

我们现在将关注点转向无监督学习。在本章中,我们将研究几种聚类算法,或称为聚类器,以及如何在 Clojure 中实现它们。我们还将演示几个提供聚类算法实现的 Clojure 库。在章节的末尾,我们将探讨降维及其如何被用来提供对提供的样本数据的可理解的可视化。

聚类或聚类分析基本上是一种将数据或样本分组的方法。作为一种无监督学习形式,聚类模型使用未标记数据进行训练,这意味着训练数据中的样本将不包含输入值的类别或类别。相反,训练数据不描述给定输入集的输出变量的值。聚类模型必须确定几个输入值之间的相似性,并自行推断这些输入值的类别。因此,可以使用这种模型将样本值划分为多个簇。

聚类在现实世界问题中有几种实际应用。聚类常用于图像分析、图像分割、软件演化系统和社交网络分析。在计算机科学领域之外,聚类算法被用于生物分类、基因分析和犯罪分析。

到目前为止,已经发表了多种聚类算法。每种算法都有其独特的关于如何定义簇以及如何将输入值组合成新簇的概念。不幸的是,对于任何聚类问题都没有给出解决方案,每个算法都必须通过试错法来评估,以确定哪个模型最适合提供的训练数据。当然,这是无监督学习的一个方面,即没有明确的方法可以说一个给定的解决方案是任何给定数据的最佳匹配。

这是因为输入数据未标记,并且无法从输出变量或输入值的类别未知的数据中推断出一个简单的基于奖励的 yes/no 训练系统。

在本章中,我们将描述一些可以应用于未标记数据的聚类技术。

使用 K-means 聚类

K-means 聚类算法是一种基于矢量量化的聚类技术(更多信息,请参阅“算法 AS 136:K-Means 聚类算法”)。该算法将多个样本向量划分为K个簇,因此得名。在本节中,我们将研究 K-means 算法的性质和实现。

量化,在信号处理中,是将一组大量值映射到一组较小值的过程。例如,一个模拟信号可以被量化为 8 位,信号可以用 256 个量化级别来表示。假设这些位代表 0 到 5 伏特的值,8 位量化允许每位的分辨率为 5/256 伏特。在聚类的上下文中,输入或输出的量化可以出于以下原因进行:

  • 为了将聚类限制在有限的聚类集合中。

  • 为了适应样本数据中的值范围,在聚类执行时需要有一定的容差。这种灵活性对于将未知或意外的样本值分组在一起至关重要。

算法的精髓可以简洁地描述如下。首先随机初始化 K 个均值值,或称为质心。然后计算每个样本值与每个质心的距离。根据哪个质心与给定样本的距离最小,将样本值分组到给定质心的聚类中。在多维空间中,对于多个特征或输入值,样本输入向量的距离是通过输入向量与给定质心之间的欧几里得距离来衡量的。这个算法阶段被称为分配步骤

K 均值算法的下一阶段是更新步骤。根据前一步生成的分割输入值调整质心的值。然后,这两个步骤重复进行,直到连续两次迭代中质心值之间的差异变得可以忽略不计。因此,算法的最终结果是给定训练数据中每组输入值的聚类或类别。

可以使用以下图表来展示 K 均值算法的迭代过程:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_01.jpg

每个图描绘了算法针对一组输入值在每次迭代中产生的质心和分割样本值。在每张图中,给定迭代的聚类以不同的颜色显示。最后一张图代表了 K 均值算法产生的最终分割输入值集。

K 均值聚类算法的优化目标可以正式定义为如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_02.jpg

在前面方程定义的优化问题中,https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_03.jpg这些项代表围绕输入值聚类的 K 均值。K 均值算法最小化聚类的尺寸,并确定可以最小化这些聚类尺寸的均值。

此算法需要https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_04.jpg样本值和https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_05.jpg初始均值作为输入。在分配步骤中,输入值被分配到算法提供的初始均值周围的聚类中。在后续的更新步骤中,从输入值计算新的均值。在大多数实现中,新的均值被计算为属于给定聚类的所有输入值的平均值。

大多数实现将https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_05.jpg初始均值设置为一些随机选择的输入值。这种技术被称为Forgy 方法的随机初始化。

当聚类数K或输入数据的维度d未定义时,K-means 算法是 NP 难的。当这两个值都固定时,K-means 算法的时间复杂度为https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_06.jpg。该算法有几种变体,这些变体在计算新均值的方式上有所不同。

现在,我们将演示如何在纯 Clojure 中实现K-means 算法,而不使用任何外部库。我们首先定义算法的各个部分,然后稍后将其组合以提供K-means 算法的基本可视化。

我们可以说两个数字之间的距离是它们值之间的绝对差,这可以通过以下代码中的distance函数实现:

(defn distance [a b]
  (if (< a b) (- b a) (- a b)))

如果我们给定一些均值,我们可以通过使用distancesort-by函数的组合来计算给定数字的最近均值,如下面的代码所示:

(defn closest [point means distance]
  (first (sort-by #(distance % point) means)))

为了演示前面代码中定义的closest函数,我们首先需要定义一些数据,即一系列数字和一些均值,如下面的代码所示:

(def data '(2 3 5 6 10 11 100 101 102))
(def guessed-means '(0 10))

现在,我们可以使用dataguessed-means变量与closest函数以及任意数字一起使用,如下面的 REPL 输出所示:

user> (closest 2 guessed-means distance)
0
user> (closest 9 guessed-means distance)
10
user> (closest 100 guessed-means distance)
10

给定均值010closest函数返回0作为2最近的均值,以及10作为9100的均值。因此,可以通过与它们最近的均值来对数据点进行分组。我们可以通过使用closestgroup-by函数来实现以下分组操作:

(defn point-groups [means data distance]
  (group-by #(closest % means distance) data))

前面代码中定义的point-groups函数需要三个参数,即初始均值、要分组的点集合,以及最后是一个返回点与给定均值距离的函数。请注意,group-by函数将一个函数(作为第一个参数传递)应用于一个集合,然后将该集合作为第二个参数传递。

我们可以在 data 变量表示的数字列表上应用 point-groups 函数,根据它们与猜测的均值(由 guessed-means 表示)的距离将给定的值分组,如下面的代码所示:

user> (point-groups guessed-means data distance)
{0 [2 3 5], 10 [6 10 11 100 101 102]}

如前述代码所示,point-groups 函数将序列 data 分成两个组。为了从这些输入值的组中计算新的均值集,我们必须计算它们的平均值,这可以通过使用 reducecount 函数来实现,如下面的代码所示:

(defn average [& list]
  (/ (reduce + list)
     (count list)))

我们实现了一个函数,将前面代码中定义的 average 函数应用于前一个平均值和 point-groups 函数返回的组映射。我们将通过以下代码来完成这项工作:

(defn new-means [average point-groups old-means]
  (for [m old-means]
    (if (contains? point-groups m)
      (apply average (get point-groups m)) 
      m)))

在前面代码中定义的 new-means 函数中,对于前一个平均值中的每个值,我们应用 average 函数到按平均值分组的点。当然,只有当平均值有按其分组的一些点时,才需要对给定均值的点应用 average 函数。这是通过在 new-means 函数中使用 contains? 函数来检查的。我们可以在 REPL 中检查 new-means 函数返回的值,如下面的输出所示:

user> (new-means average
        (point-groups guessed-means data distance)
                 guessed-means)
(10/3 55)

如前一个输出所示,新的平均值是根据初始平均值 (0 10) 计算得出的 (10/3 55)。为了实现 K-means 算法,我们必须迭代地应用 new-means 函数到它返回的新平均值上。这个迭代可以通过 iterate 函数来完成,该函数需要一个接受单个参数的函数作为输入。

我们可以通过将 new-means 函数对传递给它的旧均值进行柯里化来定义一个与 iterate 函数一起使用的函数,如下面的代码所示:

(defn iterate-means [data distance average]
  (fn [means]
    (new-means average
               (point-groups means data distance)
               means)))

前面代码中定义的 iterate-means 函数返回一个函数,该函数从给定的一组初始平均值计算新的平均值,如下面的输出所示:

user> ((iterate-means data distance average) '(0 10))
(10/3 55)
user> ((iterate-means data distance average) '(10/3 55))
(37/6 101)

如前一个输出所示,观察到在应用 iterate-means 函数返回的函数几次后,平均值发生了变化。这个返回的函数可以传递给 iterate 函数,我们可以使用 take 函数检查迭代的平均值,如下面的代码所示:

user> (take 4 (iterate (iterate-means data distance average)
                       '(0 10)))
((0 10) (10/3 55) (37/6 101) (37/6 101))

观察到均值值仅在第一次迭代中的前三次发生变化,并收敛到我们定义的样本数据的值(37/6 10)。K-means 算法的终止条件是均值值的收敛,因此我们必须迭代iterate-means函数返回的值,直到返回的均值值与之前返回的均值值不再不同。由于iterate函数惰性地返回一个无限序列,我们必须实现一个函数来通过序列中元素的收敛来限制这个序列。这种行为可以通过使用lazy-seqseq函数的惰性实现来实现,如下所示:

(defn take-while-unstable
  ([sq] (lazy-seq (if-let [sq (seq sq)]
                    (cons (first sq)
                          (take-while-unstable 
                           (rest sq) (first sq))))))
  ([sq last] (lazy-seq (if-let [sq (seq sq)]
                         (if (= (first sq) last)
                           nil
                           (take-while-unstable sq))))))

前面代码中定义的take-while-unstable函数将惰性序列分割成其头部和尾部项,然后比较序列的第一个元素与序列尾部的第一个元素,如果两个元素相等则返回一个空列表,或nil。然而,如果它们不相等,则take-while-unstable函数会在序列的尾部再次被调用。注意if-let宏的使用,它只是一个带有if表达式的let形式,用于检查序列sq是否为空。我们可以在以下输出中检查take-while-unstable函数返回的值:

user> (take-while-unstable
       '(1 2 3 4 5 6 7 7 7 7))
(1 2 3 4 5 6 7)
user> (take-while-unstable 
       (iterate (iterate-means data distance average)
                '(0 10)))
((0 10) (10/3 55) (37/6 101))

使用我们计算出的最终均值值,我们可以使用point-groups函数返回的映射上的vals函数来确定输入值的聚类,如下所示:

(defn k-cluster [data distance means]
  (vals (point-groups means data distance)))

注意,vals函数返回给定映射中的所有值作为一个序列。

前面代码中定义的k-cluster函数生成了由 K-means 算法返回的输入值的最终聚类。我们可以将k-cluster函数应用于最终均值值(37/6 101),以返回输入值的最终聚类,如下所示:

user> (k-cluster data distance '(37/6 101))
([2 3 5 6 10 11] [100 101 102])

为了可视化输入值聚类的变化,我们可以将k-cluster函数应用于由组合iterateiterate-means函数返回的值序列。我们必须通过所有聚类中值的收敛来限制这个序列,这可以通过使用take-while-unstable函数来实现,如下所示:

user> (take-while-unstable
       (map #(k-cluster data distance %)
            (iterate (iterate-means data distance average)
             '(0 10))))
(([2 3 5] [6 10 11 100 101 102])
 ([2 3 5 6 10 11] [100 101 102]))

我们可以将前面的表达式重构为一个函数,该函数只需要初始猜测的均值值集合,通过将iterate-means函数绑定到样本数据来实现。用于计算给定输入值与均值值距离以及从输入值集合中计算平均均值值的函数如下所示:

(defn k-groups [data distance average]
  (fn [guesses]
    (take-while-unstable
     (map #(k-cluster data distance %)
          (iterate (iterate-means data distance average)
                   guesses)))))

我们可以将前面代码中定义的k-groups函数与我们的样本数据和distance以及average函数绑定,这些函数在以下代码中展示了它们对数值的操作:

(def grouper
  (k-groups data distance average))

现在,我们可以对任何任意集合的均值应用grouper函数,以可视化在 K-均值算法的各个迭代过程中聚类的变化,如下面的代码所示:

user> (grouper '(0 10))
(([2 3 5] [6 10 11 100 101 102])
 ([2 3 5 6 10 11] [100 101 102]))
user> (grouper '(1 2 3))
(([2] [3 5 6 10 11 100 101 102])
 ([2 3 5 6 10 11] [100 101 102])
 ([2 3] [5 6 10 11] [100 101 102])
 ([2 3 5] [6 10 11] [100 101 102])
 ([2 3 5 6] [10 11] [100 101 102]))
user> (grouper '(0 1 2 3 4))
(([2] [3] [5 6 10 11 100 101 102])
 ([2] [3 5 6 10 11] [100 101 102])
 ([2 3] [5 6 10 11] [100 101 102])
 ([2 3 5] [6 10 11] [100 101 102])
 ([2] [3 5 6] [10 11] [100 101 102])
 ([2 3] [5 6] [10 11] [100 101 102]))

如我们之前提到的,如果平均值数量大于输入数量,我们最终会得到与输入值数量相等的聚类数量,其中每个聚类包含一个单独的输入值。这可以通过使用grouper函数在 REPL 中进行验证,如下面的代码所示:

user> (grouper (range 200))
(([2] [3] [100] [5] [101] [6] [102] [10] [11]))

我们可以通过更改k-groups函数的参数distanceaverage距离来扩展前面的实现,使其适用于向量值而不是仅限于数值。我们可以如下实现这两个函数:

(defn vec-distance [a b]
  (reduce + (map #(* % %) (map - a b))))

(defn vec-average [& list]
  (map #(/ % (count list)) (apply map + list)))

在前面的代码中定义的vec-distance函数实现了两个向量值之间的平方欧几里得距离,即两个向量中对应元素平方差的和。我们还可以通过将它们相加并除以相加的向量数量来计算一些向量值的平均值,如前面代码中定义的vec-average函数所示。我们可以在 REPL 中检查这些函数的返回值,如下面的输出所示:

user> (vec-distance [1 2 3] [5 6 7])
48
user> (vec-average  [1 2 3] [5 6 7])
(3 4 5)

现在,我们可以定义一些以下向量值作为我们的聚类算法的样本数据:

(def vector-data
  '([1 2 3] [3 2 1] [100 200 300] [300 200 100] [50 50 50]))

现在,我们可以使用k-groups函数以及vector-datavec-distancevec-average变量来打印出迭代产生的各种聚类,从而得到最终的聚类集合,如下面的代码所示:

user> ((k-groups vector-data vec-distance vec-average)
       '([1 1 1] [2 2 2] [3 3 3]))
(([[1 2 3] [3 2 1]] [[100 200 300] [300 200 100] [50 50 50]])

 ([[1 2 3] [3 2 1] [50 50 50]]
  [[100 200 300] [300 200 100]])

 ([[1 2 3] [3 2 1]]
  [[100 200 300] [300 200 100]]
  [[50 50 50]]))

我们可以添加到这个实现中的另一个改进是使用new-means函数更新相同的均值值。如果我们向new-means函数传递一个相同的均值值的列表,两个均值值都将得到更新。然而,在经典的 K-均值算法中,只有两个相同均值值中的一个会被更新。这种行为可以通过在 REPL 中传递一个如'(0 0)'的相同均值值的列表到new-means函数来验证,如下面的代码所示:

user> (new-means average 
                 (point-groups '(0 0) '(0 1 2 3 4) distance) 
                 '(0 0))
(2 2)

我们可以通过检查给定平均值在平均值集合中的出现次数来避免这个问题,并且只有在发现多个出现时才更新单个平均值。我们可以使用frequencies函数来实现这一点,该函数返回一个映射,其键是传递给frequencies函数的原始集合中的元素,其值是这些元素出现的频率。因此,我们可以重新定义new-means函数,如下面的代码所示:

(defn update-seq [sq f]
  (let [freqs (frequencies sq)]
    (apply concat
     (for [[k v] freqs]
       (if (= v 1) 
         (list (f k))
         (cons (f k) (repeat (dec v) k)))))))
(defn new-means [average point-groups old-means]
  (update-seq
   old-means
   (fn [o]
     (if (contains? point-groups o)
       (apply average (get point-groups o)) o))))

前述代码中定义的update-seq函数将函数f应用于序列sq中的元素。只有当元素在序列中重复时,才会对单个元素应用函数f。现在我们可以观察到,当我们对相同的均值序列'(0 0)应用重新定义的new-means函数时,只有一个均值值发生变化,如下面的输出所示:

user> (new-means average
                 (point-groups '(0 0) '(0 1 2 3 4) distance)
                 '(0 0))
(2 0)

new-means函数先前重新定义的结果是,k-groups函数现在在应用于不同的和相同的初始均值时,如'(0 1)'(0 0),会产生相同的聚类,如下面的代码所示:

user> ((k-groups '(0 1 2 3 4) distance average)
       '(0 1))
(([0] [1 2 3 4]) ([0 1] [2 3 4]))
user> ((k-groups '(0 1 2 3 4) distance average)
       '(0 0))
(([0 1 2 3 4]) ([0] [1 2 3 4]) ([0 1] [2 3 4]))

关于new-means函数在相同初始均值方面的这种新行为也扩展到向量值,如下面的输出所示:

user> ((k-groups vector-data vec-distance vec-average)
       '([1 1 1] [1 1 1] [1 1 1]))
(([[1 2 3] [3 2 1] [100 200 300] [300 200 100] [50 50 50]])
 ([[1 2 3] [3 2 1]] [[100 200 300] [300 200 100] [50 50 50]])
 ([[1 2 3] [3 2 1] [50 50 50]] [[100 200 300] [300 200 100]])
 ([[1 2 3] [3 2 1]] [[100 200 300] [300 200 100]] [[50 50 50]]))

总之,前例中定义的k-clusterk-groups函数描述了如何在 Clojure 中实现K-means 聚类。

使用 clj-ml 进行聚类数据

clj-ml库提供了从 Java Weka 库派生出的几个聚类算法的实现。现在我们将演示如何使用clj-ml库构建一个K-means 聚类器。

注意

可以通过在project.clj文件中添加以下依赖项将clj-ml和 Incanter 库添加到 Leiningen 项目中:

[cc.artifice/clj-ml "0.4.0"]
[incanter "1.5.4"]

对于接下来的示例,命名空间声明应类似于以下声明:

(ns my-namespace
 (:use [incanter core datasets]
 [clj-ml data clusterers]))

对于本章中使用的clj-ml库的示例,我们将使用 Incanter 库中的Iris数据集作为我们的训练数据。这个数据集本质上是一个 150 朵花的样本,以及为这些样本测量的四个特征变量。在 Iris 数据集中测量的花的特征是花瓣和花萼的宽度和长度。样本值分布在三个物种或类别中,即 Virginica、Setosa 和 Versicolor。数据以https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_07.jpg大小的矩阵形式提供,其中给定花的物种在该矩阵的最后一列中表示。

我们可以使用 Incanter 库中的get-datasetselto-vector函数从 Iris 数据集中选择特征作为向量,如下面的代码所示。然后我们可以使用clj-ml库中的make-dataset函数将此向量转换为clj-ml数据集。这是通过将特征值的键名作为模板传递给make-dataset函数来完成的,如下面的代码所示:

(def features [:Sepal.Length
               :Sepal.Width
               :Petal.Length
               :Petal.Width])

(def iris-data (to-vect (sel (get-dataset :iris)
                             :cols features)))

(def iris-dataset
  (make-dataset "iris" features iris-data))

我们可以在 REPL 中打印前述代码中定义的iris-dataset变量,以获取有关其包含内容的一些信息,如下面的代码和输出所示:

user> iris-dataset
#<ClojureInstances @relation iris

@attribute Sepal.Length numeric
@attribute Sepal.Width numeric
@attribute Petal.Length numeric
@attribute Petal.Width numeric

@data
5.1,3.5,1.4,0.2
4.9,3,1.4,0.2
4.7,3.2,1.3,0.2
...
4.7,3.2,1.3,0.2
6.2,3.4,5.4,2.3
5.9,3,5.1,1.8>

我们可以使用clj-ml.clusterers命名空间中的make-clusterer函数来创建一个聚类器。我们可以将创建的聚类器类型作为make-cluster函数的第一个参数。第二个可选参数是一个选项映射,用于创建指定的聚类器。我们可以使用clj-ml库中的cluster-build函数来训练一个给定的聚类器。在下面的代码中,我们使用make-clusterer函数和:k-means关键字创建一个新的K-means 聚类器,并定义一个简单的辅助函数来帮助使用任何给定的数据集训练这个聚类器:

(def k-means-clusterer
  (make-clusterer :k-means
                  {:number-clusters 3}))

(defn train-clusterer [clusterer dataset]
  (clusterer-build clusterer dataset)
  clusterer)

train-clusterer函数可以应用于由k-means-clusterer变量定义的聚类器实例和由iris-dataset变量表示的样本数据,如下面的代码和输出所示:

user> (train-clusterer k-means-clusterer iris-dataset)
#<SimpleKMeans
kMeans
======

Number of iterations: 6
Within cluster sum of squared errors: 6.982216473785234
Missing values globally replaced with mean/mode

Cluster centroids:
                            Cluster#
Attribute       Full Data          0          1          2
                    (150)       (61)       (50)       (39)
==========================================================
Sepal.Length       5.8433     5.8885      5.006     6.8462
Sepal.Width        3.0573     2.7377      3.428     3.0821
Petal.Length        3.758     4.3967      1.462     5.7026
Petal.Width        1.1993      1.418      0.246     2.0795

如前一个输出所示,训练好的聚类器在第一个聚类(聚类0)中包含61个值,在第二个聚类(聚类1)中包含50个值,在第三个聚类(聚类2)中包含39个值。前一个输出还提供了关于训练数据中各个特征平均值的一些信息。现在我们可以使用训练好的聚类器和clusterer-cluster函数来预测输入数据的类别,如下面的代码所示:

user> (clusterer-cluster k-means-clusterer iris-dataset)
#<ClojureInstances @relation 'clustered iris'

@attribute Sepal.Length numeric
@attribute Sepal.Width numeric
@attribute Petal.Length numeric
@attribute Petal.Width numeric
@attribute class {0,1,2}

@data
5.1,3.5,1.4,0.2,1
4.9,3,1.4,0.2,1
4.7,3.2,1.3,0.2,1
...
6.5,3,5.2,2,2
6.2,3.4,5.4,2.3,2
5.9,3,5.1,1.8,0>

clusterer-cluster函数使用训练好的聚类器返回一个新的数据集,该数据集包含一个额外的第五个属性,表示给定样本值的类别。如前述代码所示,这个新属性的值为012,样本值也包含这个新特征的合法值。总之,clj-ml库提供了一个良好的框架来处理聚类算法。在前面的例子中,我们使用clj-ml库创建了一个K-means 聚类器。

使用层次聚类

层次聚类是另一种聚类分析方法,其中训练数据的输入值被分组到一个层次结构中。创建层次结构的过程可以采用自上而下的方法,其中所有观测值最初都是单个聚类的部分,然后被划分为更小的聚类。或者,我们可以使用自下而上的方法来分组输入值,其中每个聚类最初都是训练数据中的一个样本值,然后这些聚类被合并在一起。前者自上而下的方法被称为划分聚类,后者自下而上的方法被称为聚合聚类

因此,在聚合聚类中,我们将聚类合并成更大的聚类,而在划分聚类中,我们将聚类划分为更小的聚类。在性能方面,现代聚合聚类算法的实现具有https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_08.jpg的时间复杂度,而划分聚类的时间复杂度则要高得多。

假设我们在训练数据中有六个输入值。在下图说明中,假设这些输入值是根据某种二维度量来衡量给定输入值的整体值的位置:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_10.jpg

我们可以对这些输入值应用凝聚聚类,以产生以下聚类层次:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_11.jpg

观察到值 bc 在空间分布上彼此最接近,因此被分组到一个聚类中。同样,节点 de 也被分组到另一个聚类中。层次聚类输入值的最终结果是单个二叉树或样本值的树状图。实际上,如 bcdef 这样的聚类作为值的二叉子树或其他聚类的子树被添加到层次中。尽管这个过程在二维空间中看起来非常简单,但当应用于多个维度的特征时,确定输入值之间距离和层次的问题的解决方案就不再那么简单了。

在凝聚和分裂聚类技术中,必须计算样本数据中输入值之间的相似性。这可以通过测量两组输入值之间的距离,使用计算出的距离将它们分组到聚类中,然后确定输入值聚类之间的连接或相似性来完成。

在层次聚类算法中,距离度量的选择将决定算法产生的聚类形状。两个常用的衡量两个输入向量 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_12.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_13.jpg 之间距离的度量是欧几里得距离 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_14.jpg 和平方欧几里得距离 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_15.jpg,其形式可以表示如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_16.jpg

另一个常用于衡量输入值之间距离的度量标准是最大距离 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_17.jpg,它计算两个给定向量中对应元素的绝对最大差值。此函数可以表示如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_18.jpg

层次聚类算法的第二个方面是连接标准,它是衡量两个输入值聚类之间相似性或差异性的有效度量。确定两个输入值之间连接的两种常用方法是完全连接聚类单连接聚类。这两种方法都是凝聚聚类的形式。

在聚合聚类中,两个具有最短距离度量的输入值或聚类被合并成一个新的聚类。当然,“最短距离”的定义是任何聚合聚类技术中独特的地方。在完全链接聚类中,使用彼此最远的输入值来确定分组。因此,这种方法也被称为最远邻聚类。两个值之间的距离https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_19.jpg的度量可以如下正式表达:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_20.jpg

在前面的方程中,函数https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_21.jpg是两个输入向量之间选择的距离度量。完全链接聚类将本质上将具有最大距离度量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_22.jpg的值或聚类分组在一起。这种将聚类分组在一起的操作会重复进行,直到产生单个聚类。

在单链接聚类中,彼此最近的值被分组在一起。因此,单链接聚类也称为最近邻聚类。这可以用以下表达式正式表述:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_23.jpg

另一种流行的层次聚类技术是蜘蛛网算法。该算法是一种概念聚类,其中为聚类方法产生的每个聚类创建一个概念。术语“概念”指的是聚在一起的数据的简洁形式化描述。有趣的是,概念聚类与决策树学习密切相关,我们已经在第三章中讨论过,即数据分类。蜘蛛网算法将所有聚类分组到一个分类树中,其中每个节点包含其子节点(即值或聚类)的正式摘要。然后可以使用这些信息来确定和预测具有一些缺失特征的输入值的类别。在这种情况下,当测试数据中的某些样本具有缺失或未知特征时,可以使用这种技术。

现在我们演示层次聚类的简单实现。在这个实现中,我们采取了一种略有不同的方法,将部分所需功能嵌入到 Clojure 语言提供的标准向量数据结构中。

注意

对于即将到来的示例,我们需要clojure.math.numeric-tower库,可以通过在project.clj文件中添加以下依赖项将此库添加到 Leiningen 项目中:

[org.clojure/math.numeric-tower "0.0.4"]

示例中的命名空间声明应类似于以下声明:

(ns my-namespace
  (:use [clojure.math.numeric-tower :only [sqrt]]))

对于这个实现,我们将使用两点之间的欧几里得距离作为距离度量。我们可以通过输入向量中元素的平方和来计算这个距离,这可以通过reducemap函数的组合来计算,如下所示:

(defn sum-of-squares [coll]
  (reduce + (map * coll coll)))

在前面的代码中定义的sum-of-squares函数将用于确定距离度量。我们将定义两个协议来抽象我们对特定数据类型执行的操作。从工程角度来看,这两个协议可以合并为一个单一协议,因为这两个协议都将组合使用。

然而,为了清晰起见,我们在这个例子中使用以下两个协议:

(defprotocol Each
  (each [v op w]))

(defprotocol Distance
  (distance [v w]))

Each协议中定义的each函数将给定的操作op应用于两个集合vw中的对应元素。each函数与标准map函数非常相似,但each允许v的数据类型决定如何应用函数op。在Distance协议中定义的distance函数计算任何两个集合vw之间的距离。请注意,我们使用通用术语“集合”,因为我们处理的是抽象协议,而不是这些协议函数的具体实现。对于这个例子,我们将实现前面的协议作为向量数据类型的一部分。当然,这些协议也可以扩展到其他数据类型,如集合和映射。

在这个例子中,我们将实现单链接聚类作为链接标准。首先,我们必须定义一个函数来确定从一组向量值中距离最近的两个向量。为此,我们可以对一个向量应用min-key函数,该函数返回集合中与最少关联值的键。有趣的是,在 Clojure 中这是可能的,因为我们可以将向量视为一个映射,其中向量中各种元素的索引值作为其键。我们将借助以下代码来实现这一点:

(defn closest-vectors [vs]
  (let [index-range (range (count vs))]
    (apply min-key
           (fn [[x y]] (distance (vs x) (vs y)))
           (for [i index-range
                 j (filter #(not= i %) index-range)]
             [i j]))))

在前面的代码中定义的closest-vectors函数使用for形式确定向量vs的所有可能的索引组合。请注意,向量vs是一个向量向量的向量。然后,将distance函数应用于可能的索引组合的值,并使用min-key函数比较这些距离。该函数最终返回两个内部向量值的最小距离的索引值,从而实现单链接聚类。

我们还需要计算必须聚在一起的两个向量的平均值。我们可以使用在Each协议中先前定义的each函数和reduce函数来实现这一点,如下所示:

(defn centroid [& xs]
  (each
   (reduce #(each %1 + %2) xs)
   *
   (double (/ 1 (count xs)))))

在前一段代码中定义的 centroid 函数将计算一系列向量值的平均值。请注意,使用 double 函数以确保 centroid 函数返回的值是一个双精度浮点数。

我们现在将 EachDistance 协议作为向量数据类型的一部分实现,该类型完全限定为 clojure.lang.PersistentVector。这是通过使用 extend-type 函数实现的,如下所示:

(extend-type clojure.lang.PersistentVector
  Each
  (each [v op w]
    (vec
     (cond
      (number? w) (map op v (repeat w))
      (vector? w) (if (>= (count v) (count w))
                    (map op v (lazy-cat w (repeat 0)))
                    (map op (lazy-cat v (repeat 0)) w)))))
  Distance 
  ;; implemented as Euclidean distance
  (distance [v w] (-> (each v - w)
                      sum-of-squares
                      sqrt)))

each 函数的实现方式是,将 op 操作应用于 v 向量中的每个元素和第二个参数 ww 参数可以是向量或数字。如果 w 是一个数字,我们只需将函数 op 映射到 v 和数字 w 的重复值上。如果 w 是一个向量,我们使用 lazy-cat 函数用 0 值填充较短的向量,并将 op 映射到两个向量上。此外,我们用 vec 函数包装整个表达式,以确保返回的值始终是向量。

distance 函数是通过使用我们之前定义的 sum-of-squares 函数和来自 clojure.math.numeric-tower 命名空间的 sqrt 函数来计算两个向量值 vw 之间的欧几里得距离。

我们已经拥有了实现一个对向量值进行层次聚类功能的所有组件。我们可以主要使用之前定义的 centroidclosest-vectors 函数来实现层次聚类,如下所示:

(defn h-cluster
  "Performs hierarchical clustering on a
  sequence of maps of the form { :vec [1 2 3] } ."
  [nodes]
  (loop [nodes nodes]
    (if (< (count nodes) 2)
      nodes
      (let [vectors    (vec (map :vec nodes))
            [l r]      (closest-vectors vectors)
            node-range (range (count nodes))
            new-nodes  (vec
                        (for [i node-range
                              :when (and (not= i l)
                                         (not= i r))]
                          (nodes i)))]
        (recur (conj new-nodes
                     {:left (nodes l) :right (nodes r)
                      :vec (centroid
                            (:vec (nodes l))
                            (:vec (nodes r)))}))))))

我们可以将映射到前一段代码中定义的 h-cluster 函数的向量传递给。这个向量中的每个映射都包含一个向量作为 :vec 关键字的值。h-cluster 函数结合了这些映射中 :vec 关键字的所有向量值,并使用 closest-vectors 函数确定两个最近的向量。由于 closest-vectors 函数返回的是一个包含两个索引值的向量,我们确定除了 closest-vectors 函数返回的两个索引值之外的所有向量。这是通过一个特殊的 for 宏形式实现的,该宏允许使用 :when 关键字参数指定条件子句。然后使用 centroid 函数计算两个最近向量值的平均值。使用平均值创建一个新的映射,并将其添加到原始向量中以替换两个最近的向量值。使用 loop 形式重复此过程,直到向量中只剩下一个簇。我们可以在以下代码中检查 h-cluster 函数的行为:

user> (h-cluster [{:vec [1 2 3]} {:vec [3 4 5]} {:vec [7 9 9]}])
[{:left {:vec [7 9 9]},
  :right {:left {:vec [1 2 3]},
          :right {:vec [3 4 5]},
          :vec [2.0 3.0 4.0]},
  :vec [4.5 6.0 6.5] }]

当应用于三个向量值 [1 2 3][3 4 5][7 9 9],如前述代码所示时,h-cluster 函数将向量 [1 2 3][3 4 5] 分组到一个单独的簇中。这个簇的平均值为 [2.0 3.0 4.0],这是从向量 [1 2 3][3 4 5] 计算得出的。然后,这个新的簇在下一轮迭代中与向量 [7 9 9] 分组,从而产生一个平均值为 [4.5 6.0 6.5] 的单个簇。总之,h-cluster 函数可以用来将向量值分层聚类到一个单独的层次结构中。

clj-ml 库提供了一个 Cobweb 层次聚类算法的实现。我们可以使用带有 :cobweb 参数的 make-clusterer 函数实例化这样的聚类器。

(def h-clusterer (make-clusterer :cobweb))

由前述代码中显示的 h-clusterer 变量定义的聚类器可以使用 train-clusterer 函数和之前定义的 iris-dataset 数据集进行训练,如下所示:train-clusterer 函数和 iris-dataset 可以按照以下代码实现:

user> (train-clusterer h-clusterer iris-dataset)
#<Cobweb Number of merges: 0
Number of splits: 0
Number of clusters: 3

node 0 [150]
|   leaf 1 [96]
node 0 [150]
|   leaf 2 [54]

如前述 REPL 输出所示,Cobweb 聚类算法将输入数据划分为两个簇。一个簇包含 96 个样本,另一个簇包含 54 个样本,这与我们之前使用的 K-means 聚类器得到的结果相当不同。总之,clj-ml 库提供了一个易于使用的 Cobweb 聚类算法的实现。

使用期望最大化

期望最大化EM)算法是一种概率方法,用于确定适合提供的训练数据的聚类模型。此算法确定了一个公式化聚类模型参数的最大似然估计MLE)(有关更多信息,请参阅观察指数族变量函数时的分布的最大似然理论和应用)。

假设我们想要确定抛硬币得到正面或反面的概率。如果我们抛硬币 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_24.jpg 次,我们将得到 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_25.jpg 次正面和 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_26.jpg 次反面的出现。我们可以通过以下方程估计出现正面的实际概率 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_27.jpg,即正面出现次数与抛硬币总次数的比值:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_28.jpg

在前一个方程中定义的概率 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_29.jpg 是概率 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_30.jpg 的最大似然估计。在机器学习的背景下,最大似然估计可以被最大化以确定给定类别或类别的发生概率。然而,这个估计的概率可能不会在可用的训练数据上以良好的定义方式统计分布,这使得难以有效地确定最大似然估计。通过引入一组隐藏值来解释训练数据中的未观察值,问题得到了简化。隐藏值不是直接从数据中测量的,而是从影响数据的因素中确定的。对于给定的一组观察值 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_12.jpg 和一组隐藏值 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_32.jpg,参数 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_31.jpg 的似然函数定义为 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_12.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_32.jpg 发生的概率,对于给定的一组参数 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_31.jpg。似然函数可以用 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_33.jpg 的数学表达式表示,可以表示如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_34.jpg

EM 算法包括两个步骤——期望步骤和最大化步骤。在期望步骤中,我们计算 对数似然 函数的期望值。这一步确定了一个必须在下一步中最大化的度量 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_35.jpg,即算法的最大化步骤。这两个步骤可以正式总结如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_36.jpg

在前一个方程中,通过迭代计算最大化函数 Q 值的 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_31.jpg 值,直到它收敛到一个特定的值。术语 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_37.jpg 代表算法 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_38.jpg 迭代中的估计参数。此外,术语 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_39.jpg 是对数似然函数的期望值。

clj-ml 库还提供了一个 EM 聚类器。我们可以使用 make-clusterer 函数和 :expectation-maximization 关键字作为其参数来创建一个 EM 聚类器,如下面的代码所示:

(def em-clusterer (make-clusterer :expectation-maximization
                                  {:number-clusters 3}))

注意,我们还必须指定要生成的聚类数量作为 make-clusterer 函数的选项。

我们可以使用 train-clusterer 函数和之前定义的 iris-dataset 数据集来训练由 em-clusterer 变量定义的聚类器,如下所示:

user> (train-clusterer em-clusterer iris-dataset)
#<EM
EM
==

Number of clusters: 3

               Cluster
Attribute            0       1       2
                (0.41)  (0.25)  (0.33)
=======================================
Sepal.Length
  mean           5.9275  6.8085   5.006
  std. dev.      0.4817  0.5339  0.3489

Sepal.Width
  mean           2.7503  3.0709   3.428
  std. dev.      0.2956  0.2867  0.3753

Petal.Length
  mean           4.4057  5.7233   1.462
  std. dev.      0.5254  0.4991  0.1719

Petal.Width
  mean           1.4131  2.1055   0.246
  std. dev.      0.2627  0.2456  0.1043

如前述输出所示,EM 聚类器将给定的数据集划分为三个簇,其中簇的分布大约为训练数据样本的 41%、25% 和 35%。

使用 SOMs

如我们之前在第四章中提到的,构建神经网络,SOM 可以用于建模无监督机器学习问题,例如聚类(更多信息,请参阅 自组织映射作为 K-Means 聚类的替代方案)。为了快速回顾,SOM 是一种将高维输入值映射到低维输出空间的 ANN 类型。这种映射保留了输入值之间的模式和拓扑关系。SOM 的输出空间中的神经元对于空间上彼此接近的输入值将具有更高的激活值。因此,SOM 是聚类具有大量维度的输入数据的好方法。

Incanter 库提供了一个简洁的 SOM 实现,我们可以使用它来聚类 Iris 数据集中的输入变量。我们将在接下来的示例中演示如何使用这个 SOM 实现进行聚类。

注意

可以通过在 project.clj 文件中添加以下依赖项将 Incanter 库添加到 Leiningen 项目中:

[incanter "1.5.4"]

对于即将到来的示例,命名空间声明应类似于以下声明:

(ns my-namespace
  (:use [incanter core som stats charts datasets]))

我们首先使用 Incanter 库中的 get-datasetselto-matrix 函数定义用于聚类的样本数据,如下所示:

(def iris-features (to-matrix (sel (get-dataset :iris)
                                   :cols [:Sepal.Length
                                          :Sepal.Width
                                          :Petal.Length
                                          :Petal.Width])))

在前面的代码中定义的 iris-features 变量实际上是一个 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_40.jpg 大小的矩阵,它表示我们从 Iris 数据集中选出的四个输入变量的值。现在,我们可以使用 incanter.som 命名空间中的 som-batch-train 函数创建并训练一个 SOM,如下所示:

(def som (som-batch-train
          iris-features :cycles 10))

定义为 som 变量的实际上是一个包含多个键值对的映射。该映射中的 :dims 键包含一个向量,它表示训练好的 SOM 中神经元格子的维度,如下面的代码和输出所示:

user> (:dims som)
[10.0 2.0]

因此,我们可以说训练好的 SOM 的神经网络是一个 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_41.jpg 矩阵。som 变量表示的映射中的 :sets 键给出了输入值在 SOM 神经元格子中的各种索引的位置分组,如下面的输出所示:

user> (:sets som)
{[4 1] (144 143 141 ... 102 100),
 [8 1] (149 148 147 ... 50),
 [9 0] (49 48 47 46 ... 0)}

如前述 REPL 输出所示,输入数据被划分为三个簇。我们可以使用 incanter.stats 命名空间中的 mean 函数计算每个特征的均值,如下所示:

(def feature-mean
  (map #(map mean (trans
                   (sel iris-features :rows ((:sets som) %))))
       (keys (:sets som))))

我们可以使用 Incanter 库中的 xy-plotadd-linesview 函数来实现一个函数来绘制这些均值,如下所示:

(defn plot-means []
  (let [x (range (ncol iris-features))
        cluster-name #(str "Cluster " %)]
    (-> (xy-plot x (nth feature-mean 0)
                 :x-label "Feature"
                 :y-label "Mean value of feature"
                 :legend true
                 :series-label (cluster-name 0))
        (add-lines x (nth feature-mean 1)
                   :series-label (cluster-name 1))
        (add-lines x (nth feature-mean 2)
                   :series-label (cluster-name 2))
        view)))

调用前面代码中定义的 plot-means 函数时,产生了以下线性图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_42.jpg

上述图表展示了 SOM 确定的三个聚类中各种特征的均值。图表显示,其中两个聚类(Cluster 0Cluster 1)具有相似的特征。然而,第三个聚类在这些特征集合上的均值有显著差异,因此在图表中显示为不同的形状。当然,这个图表并没有给我们关于这些均值周围输入值分布或方差的信息。为了可视化这些特征,我们需要以某种方式将输入数据的维度数转换为两个或三个维度,这样就可以轻松地可视化。我们将在本章下一节中进一步讨论在训练数据中减少特征数量的概念。

我们也可以使用frequenciessel函数来打印聚类和输入值的实际类别,如下所示:

(defn print-clusters []
  (doseq [[pos rws] (:sets som)]
    (println pos \:
             (frequencies
              (sel (get-dataset :iris) 
                   :cols :Species :rows rws)))))

我们可以调用前面代码中定义的print-clusters函数来生成以下 REPL 输出:

user> (print-clusters)
[4 1] : {virginica 23}
[8 1] : {virginica 27, versicolor 50}
[9 0] : {setosa 50}
nil

如前所述的输出所示,virginicasetosa物种似乎被适当地分类到两个聚类中。然而,包含versicolor物种输入值的聚类也包含了 27 个virginica物种的样本。这个问题可以通过使用更多的样本数据来训练 SOM 或通过建模更多的特征来解决。

总之,Incanter 库为我们提供了一个简洁的 SOM 实现,我们可以使用前面示例中的 Iris 数据集进行训练。

在数据中降低维度

为了轻松可视化某些未标记数据的分布,其中输入值具有多个维度,我们必须将特征维度数降低到两个或三个。一旦我们将输入数据的维度数降低到两个或三个维度,我们就可以简单地绘制数据,以提供更易于理解的可视化。在输入数据中减少维度数的过程被称为降维。由于这个过程减少了表示样本数据所使用的总维度数,因此它也有助于数据压缩。

主成分分析PCA)是一种降维方法,其中样本数据中的输入变量被转换为线性不相关的变量(更多信息,请参阅主成分分析)。这些转换后的特征被称为样本数据的主成分

PCA 使用协方差矩阵和称为 奇异值分解SVD)的矩阵运算来计算给定输入值的特征值。表示为 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_43.jpg 的协方差矩阵,可以从具有 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_12.jpg 个样本的输入向量 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_44.jpg 中确定如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_45.jpg

协方差矩阵通常在均值归一化后的输入值上计算,这仅仅是确保每个特征具有零均值值。此外,在确定协方差矩阵之前,特征可以被缩放。接下来,协方差矩阵的 SVD 如下确定:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_46.jpg

可以将 SVD 视为将大小为 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_48.jpg 的矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_47.jpg 分解为三个矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_49.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_50.jpg,和 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_51.jpg。矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_49.jpg 的大小为 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_52.jpg,矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_50.jpg 的大小为 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_48.jpg,矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_51.jpg 的大小为 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_53.jpg。矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_47.jpg 实际上代表了具有 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_55.jpg 维度的 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_54.jpg 输入向量。矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_50.jpg 是一个对角矩阵,被称为矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_47.jpg奇异值,而矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_49.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_51.jpg 分别被称为 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_47.jpg左奇异向量右奇异向量。在 PCA 的上下文中,矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_50.jpg 被称为 降维成分,而矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_49.jpg 被称为样本数据的 旋转成分

将输入向量的 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_55.jpg 维度降低到 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_56.jpg 维度的 PCA 算法可以总结如下:

  1. 从输入向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_12.jpg计算协方差矩阵https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_43.jpg

  2. 通过对协方差矩阵https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_43.jpg应用奇异值分解(SVD),计算矩阵https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_49.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_50.jpg,和https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_51.jpg

  3. https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_52.jpg矩阵https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_49.jpg中,选择前https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_56.jpg列以生成矩阵https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_57.jpg,该矩阵被称为矩阵https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_43.jpg降维左奇异向量降维旋转矩阵。此矩阵代表了样本数据的https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_56.jpg个主成分,并将具有https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_58.jpg的大小。

  4. 计算具有https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_56.jpg维度的向量,用https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_32.jpg表示,如下所示:https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_59.jpg

注意,PCA 算法的输入是经过均值归一化和特征缩放后的样本数据集的输入向量集https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_12.jpg

由于在前面步骤中计算的矩阵https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_57.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_56.jpg列,矩阵https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_32.jpg将具有https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_60.jpg的大小,它代表了https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_56.jpg维度的https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_54.jpg个输入向量。我们应该注意,维度数https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_56.jpg的降低可能会导致数据方差损失增加。因此,我们应该选择https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_56.jpg,使得方差损失尽可能小。

原始输入向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_12.jpg可以通过矩阵https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_32.jpg和降维后的左奇异向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_57.jpg如下重建:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_61.jpg

Incanter 库包含一些执行 PCA 的函数。在接下来的示例中,我们将使用 PCA 来提供 Iris 数据集的更好可视化。

注意

下一个示例中的命名空间声明应类似于以下声明:

(ns my-namespace
  (:use [incanter core stats charts datasets]))

我们首先使用 get-datasetto-matrixsel 函数定义训练数据,如下面的代码所示:

(def iris-matrix (to-matrix (get-dataset :iris)))
(def iris-features (sel iris-matrix :cols (range 4)))
(def iris-species (sel iris-matrix :cols 4))

与前面的例子类似,我们将使用 Iris 数据集的前四列作为训练数据的输入变量样本数据。

PCA 通过 incanter.stats 命名空间中的 principal-components 函数执行。此函数返回一个包含旋转矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_49.jpg 和降低矩阵 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_50.jpg 的映射,这些矩阵是我们之前描述的。我们可以使用 sel 函数从输入数据的降低矩阵中选择列,如下面的代码所示:

(def pca (principal-components iris-features))

(def U (:rotation pca))
(def U-reduced (sel U :cols (range 2)))

如前面代码所示,可以通过 principal-components 函数返回的值上的 :rotation 关键字获取输入数据的 PCA 旋转矩阵。现在我们可以使用降低旋转矩阵和由 iris-features 变量表示的特征原始矩阵来计算降低特征 Z,如下面的代码所示:

(def reduced-features (mmult iris-features U-reduced))

通过选择 reduced-features 矩阵的前两列并使用 scatter-plot 函数进行绘图,可以可视化降低特征,如下面的代码所示:

(defn plot-reduced-features []
  (view (scatter-plot (sel reduced-features :cols 0)
                      (sel reduced-features :cols 1)
                      :group-by iris-species
                      :x-label "PC1"
                      :y-label "PC2")))

下面的图表是在调用前面代码中定义的 plot-reduced-features 函数时生成的:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_07_62.jpg

前面图表中展示的散点图为我们提供了输入数据分布的良好可视化。前面图表中的蓝色和绿色簇显示,对于给定的特征集,这些簇具有相似的价值。总之,Incanter 库支持主成分分析(PCA),这使得可视化一些样本数据变得简单。

摘要

在本章中,我们探讨了可以用于建模一些未标记数据的几种聚类算法。以下是我们已经涵盖的一些其他要点:

  • 我们探讨了 K-均值算法和层次聚类技术,同时提供了这些方法在纯 Clojure 中的示例实现。我们还描述了如何通过 clj-ml 库利用这些技术。

  • 我们讨论了 EM 算法,这是一种概率聚类技术,并描述了如何使用 clj-ml 库构建一个 EM 聚类器。

  • 我们还探讨了如何使用自组织映射(SOMs)来拟合具有高维度的聚类问题。我们还演示了如何使用 Incanter 库构建一个用于聚类的 SOM。

  • 最后,我们研究了降维和 PCA,以及如何使用 Incanter 库通过 PCA 提供更好的 Iris 数据集可视化。

在下一章中,我们将探讨使用机器学习技术来探索异常检测和推荐系统概念。

第八章. 异常检测与推荐

在本章中,我们将研究一些现代应用机器学习的形式。我们首先将探讨异常检测的问题,我们将在本章后面讨论推荐系统

异常检测是一种机器学习技术,其中我们确定代表系统的某些选定特征的给定值集是否与给定特征的正常观察值意外不同。异常检测有几个应用,例如在制造业检测结构性和操作缺陷、网络入侵检测系统、系统监控和医疗诊断。

推荐系统本质上是信息系统,旨在预测特定用户对特定项目的喜好或偏好。近年来,已经建立了大量推荐系统,或称为推荐器系统,用于多个商业和社会应用,以提供更好的用户体验。这些系统可以根据用户之前评分或喜欢的项目向用户提供有用的推荐。今天,大多数现有的推荐系统都向用户提供有关在线产品、音乐和社交媒体的推荐。在网络上,也有大量使用推荐系统的金融和商业应用。

有趣的是,异常检测和推荐系统都是机器学习问题的应用形式,我们在这本书中之前已经遇到过。实际上,异常检测是二元分类的扩展,而推荐实际上是线性回归的扩展形式。我们将在本章中进一步研究这些相似之处。

检测异常

异常检测本质上是识别不符合预期模式的项目或观察值(更多信息,请参阅“异常检测方法综述”)。模式可以由先前观察到的值确定,也可以由输入值可以变化的某些限制确定。在机器学习的背景下,异常检测可以在监督和非监督环境中进行。无论哪种方式,异常检测的问题都是找到与其他输入值显著不同的输入值。这项技术有几种应用,从广义上讲,我们可以使用异常检测进行以下原因:

  • 为了检测问题

  • 为了检测新现象

  • 为了监控异常行为

被发现与其他值不同的观察值被称为异常值、异常或例外。更正式地说,我们定义异常值为位于分布整体模式之外的观察值。通过“之外”,我们指的是与数据中其他部分有较高数值或统计距离的观察值。

以下图表可以描述一些异常的例子,其中红色十字表示正常观测值,绿色十字表示异常观测值:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_01.jpg

异常检测的一个可能方法是使用一个概率分布模型,该模型由训练数据构建,用于检测异常。使用这种方法的技术被称为异常检测的统计方法。在这种情况下,异常相对于其余样本数据的整体概率分布将具有较低的几率。因此,我们尝试将模型拟合到可用的样本数据上,并使用这个构建的模型来检测异常。这种方法的主要问题是很难为随机数据找到一个标准的分布模型。

另一种可以用来检测异常的方法是基于邻近度的方法。在这种方法中,我们确定一组观测值相对于样本数据中其余值的邻近度或接近度。例如,我们可以使用K-最近邻KNN)算法来确定给定观测值与其 k 个最近值之间的距离。这种技术比在样本数据上估计统计模型要简单得多。这是因为确定一个单一度量,即观测值的邻近度,比在可用的训练数据上拟合标准模型要容易。然而,对于大型数据集,确定一组输入值的邻近度可能效率低下。例如,KNN 算法的时间复杂度为https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_02.jpg,对于较大的 k 值,计算给定值与其 k 个最近值之间的邻近度可能效率低下。此外,KNN 算法可能对邻居 k 的值敏感。如果 k 的值太大,包含少于 k 个输入值集的值簇可能会被错误地分类为异常。另一方面,如果 k 的值太小,一些具有少量低邻近度邻居的异常可能不会被检测到。

我们还可以根据围绕给定观测值的数据密度来判断该观测值是否为异常。这种方法被称为异常检测的基于密度的方法。如果给定值周围的数据密度低,则给定的一组输入值可以被视为异常。在异常检测中,基于密度和基于邻近度的方法密切相关。实际上,数据的密度通常是根据给定值相对于其余数据的邻近度或距离来定义的。例如,如果我们使用 KNN 算法来确定给定值相对于其余数据的邻近度或距离,我们可以将密度定义为到最近的 k 个值的平均距离的倒数,如下所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_03.jpg

基于聚类的技术也可以用于检测异常。本质上,聚类可以用来确定样本数据中的值组或簇。簇中的项目可以假设是紧密相关的,而异常是那些无法与样本数据中先前遇到的簇中的值相关联的值。因此,我们可以确定样本数据中的所有簇,并将最小的簇标记为异常。或者,我们可以从样本数据中形成簇,并确定给定的一组先前未见过的值的簇,如果有的话。

如果一组输入值不属于任何簇,那么它肯定是一个异常观测值。聚类技术的优点是它们可以与其他我们之前讨论过的机器学习技术结合使用。另一方面,这种方法的问题在于大多数聚类技术对选择的簇的数量很敏感。此外,聚类技术的算法参数,如簇中平均项目数和簇数,很难确定。例如,如果我们使用 KNN 算法对一些未标记的数据进行建模,簇数K可能需要通过试错法或通过仔细检查样本数据中的明显簇来确定。然而,这两种技术都不保证在未见过的数据上表现良好。

在那些样本值都应符合某个平均值并允许一定容忍度的模型中,通常使用高斯正态分布作为分布模型来训练异常检测器。此模型有两个参数——均值https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_04.jpg和方差https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_05.jpg。这种分布模型常用于异常检测的统计方法中,其中输入变量通常在统计上接近某个预定的平均值。

概率密度函数PDF)常被密度基异常检测方法使用。这个函数本质上描述了输入变量取某个给定值的可能性。对于随机变量x,我们可以正式定义 PDF 如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_06.jpg

PDF 也可以与正态分布模型结合用于异常检测的目的。正态分布的 PDF 由分布的均值https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_04.jpg和方差https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_05.jpg参数化,并且可以正式表示如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_07.jpg

我们现在将展示一个基于先前讨论的正态分布 PDF 的简单异常检测器在 Clojure 中的实现。对于这个例子,我们将使用 Clojure 原子来维护模型中的所有状态。原子用于在 Clojure 中表示原子状态。通过“原子”,我们指的是底层状态要么完全改变,要么完全不改变——因此状态的变化是“原子”的。

我们现在定义一些函数来帮助我们操作模型的特性。本质上,我们打算将这些特性和它们的值表示为一个地图。为了管理这个地图的状态,我们使用一个原子。每当异常检测器被提供一组特征值时,它必须首先检查新值集中关于特征的前置信息,然后它应该开始维护任何新特征的状态,当需要时。由于在 Clojure 中,一个函数本身不能包含任何外部状态,我们将使用闭包将状态和函数绑定在一起。在这个实现中,几乎所有的函数都返回其他函数,并且生成的异常检测器也将像函数一样使用。总之,我们将使用原子来模拟异常检测器的状态,然后使用闭包将这个原子绑定到一个函数上。

我们首先定义一个函数,该函数使用一些状态初始化我们的模型。这个状态本质上是一个通过使用atom函数包裹的地图,如下所示:

(defn update-totals [n]
  (comp #(update-in % [:count] inc)
        #(update-in % [:total] + n)
        #(update-in % [:sq-total] + (Math/pow n 2))))

(defn accumulator []
  (let [totals (atom {:total 0, :count 0, :sq-total 0})]
    (fn [n]
      (let [result (swap! totals (update-totals n))
            cnt (result :count)
            avg (/ (result :total) cnt)]
        {:average avg
         :variance (- (/ (result :sq-total) cnt)
                      (Math/pow avg 2))}))))

在前面的代码中定义的accumulator函数初始化一个原子,并返回一个将update-totals函数应用于值n的函数。值n代表我们模型中输入变量的一个值。update-totals函数也返回一个接受单个参数的函数,然后它通过使用update-in函数更新原子中的状态。由accumulator函数返回的函数将使用update-totals函数来更新模型均值和方差的州。

我们现在实现以下用于正态分布的 PDF 函数,可用于监控模型特征值的突然变化:

(defn density [x average variance]
  (let [sigma (Math/sqrt variance)
        divisor (* sigma (Math/sqrt (* 2 Math/PI)))
        exponent (/ (Math/pow (- x average) 2)
                    (if (zero? variance) 1
                        (* 2 variance)))]
    (/ (Math/exp (- exponent))
       (if (zero? divisor) 1
           divisor))))

在前面的代码中定义的density函数是正态分布 PDF 的直接翻译。它使用来自Math命名空间的功能和常数,如sqrtexpPI,通过使用模型的累积均值和方差来找到模型的 PDF。我们将定义density-detector函数,如下面的代码所示:

 (defn density-detector []
  (let [acc (accumulator)]
    (fn [x]
      (let [state (acc x)]
        (density x (state :average) (state :variance))))))

在前面的代码中定义的density-detector函数使用accumulator函数初始化我们的异常检测器的状态,并使用累加器维护的状态上的density函数来确定模型的 PDF。

由于我们处理的是包裹在原子中的地图,我们可以通过使用contains?assoc-inswap!函数来实现几个函数来执行此检查,如下面的代码所示:

 (defn get-or-add-key [a key create-fn]
  (if (contains? @a key)
    (@a key)
    ((swap! a #(assoc-in % [key] (create-fn))) key)))

在前面的代码中定义的 get-or-add-key 函数通过使用 contains? 函数在包含映射的原子中查找给定的键。注意使用 @ 操作符来取消引用原子到其包装的值。如果在映射中找到键,我们只需将映射作为函数调用,即 (@a key)。如果没有找到键,我们使用 swap!assoc-in 函数将新的键值对添加到原子中的映射中。此键值对的值是从传递给 get-or-add-key 函数的 create-fn 参数生成的。

使用我们定义的 get-or-add-keydensity-detector 函数,我们可以实现以下函数,这些函数在检测样本数据中的异常时返回函数,从而在这些函数本身中创建维护模型 PDF 分布状态的效果:

(defn atom-hash-map [create-fn]
  (let [a (atom {})]
    (fn [x]
      (get-or-add-key a x create-fn))))

(defn get-var-density [detector]
  (fn [kv]
    (let [[k v] kv]
      ((detector k) v))))

(defn detector []
  (let [detector (atom-hash-map density-detector)]
    (fn [x]
      (reduce * (map (get-var-density detector) x)))))

在前面的代码中定义的 atom-hash-map 函数使用 get-key 函数和一个任意的初始化函数 create-fn 来在原子中维护映射的状态。检测函数使用我们之前定义的 density-detector 函数来初始化输入值中每个新特征的初始状态。请注意,此函数返回一个函数,该函数将接受带有键值参数的映射作为特征。我们可以在以下代码和输出中检查实现的异常检测器的行为:

user> (def d (detector))
#'user/d
user> (d {:x 10 :y 10 :z 10})
1.0
user> (d {:x 10 :y 10 :z 10})
1.0

如前述代码和输出所示,我们通过使用 detector 函数创建了一个新的异常检测器实例。detector 函数返回一个函数,该函数接受特征键值对的映射。当我们用 {:x 10 :y 10 :z 10} 向映射提供数据时,异常检测器返回 PDF 为 1.0,因为到目前为止数据中的所有样本都具有相同的特征值。只要所有样本输入中的特征数量和这些特征值保持不变,异常检测器将始终返回此值。

当我们向异常检测器提供具有不同值的特征集时,PDF 观察到变为有限数值,如下面的代码和输出所示:

user> (d {:x 11 :y 9 :z 15})
0.0060352535208831985
user> (d {:x 10 :y 10 :z 14})
0.07930301229115849

当特征显示出很大的变化时,检测器在其分布模型的 PDF 中会有突然和大幅度的下降,如下面的代码和输出所示:

user> (d {:x 100 :y 10 :z 14})
1.9851385000301642E-4
user> (d {:x 101 :y 9 :z 12})
5.589934974999084E-4

总结来说,当之前描述的异常检测器返回的正常分布模型的 PDF 与其先前值有较大差异时,可以检测到异常样本值。我们可以扩展此实现以检查某种阈值值,以便结果被量化。因此,系统仅在 PDF 的此阈值值被越过时检测到异常。在处理现实世界数据时,我们只需以某种方式表示我们正在建模的特征值作为映射,并通过试错法确定要使用的阈值值。

异常检测可以用于监督学习和无监督机器学习环境。在监督学习中,样本数据将被标记。有趣的是,我们还可以使用二元分类等监督学习技术来模拟这类数据。我们可以通过以下指南在异常检测和分类之间选择来模拟标记数据:

  • 当样本数据中正例和负例的数量几乎相等时,选择二元分类。相反,如果训练数据中正例或负例的数量非常少,则选择异常检测。

  • 选择异常检测,当训练数据中有许多稀疏类别和少数密集类别时。

  • 当训练模型可能遇到的正样本将与模型已经看到的正样本相似时,选择监督学习技术,如分类。

构建推荐系统

推荐系统是信息过滤系统,其目标是向用户提供有用的推荐。为了确定这些推荐,推荐系统可以使用关于用户活动的历史数据,或者它可以使用其他用户喜欢的推荐(更多信息,请参阅“互联网上推荐代理的分类”)。这两种方法构成了推荐系统使用的两种类型算法的基础——基于内容的过滤协同过滤。有趣的是,一些推荐系统甚至使用这两种技术的组合来向用户提供推荐。这两种技术都旨在向用户推荐项目,或由以用户为中心的应用程序管理或交换的领域对象。这类应用包括提供在线内容和信息的多个网站,例如在线购物和媒体。

基于内容的过滤中,推荐是通过使用特定用户的评分来找到相似项目来确定的。每个项目都表示为一组离散的特征或特性,每个项目也被几个用户评分。因此,对于每个用户,我们都有几组输入变量来表示每个项目的特征,以及一组输出变量来表示用户对该项目的评分。这些信息可以用来推荐具有与之前评分的项目相似特征或特性的项目。

协同过滤方法基于收集关于特定用户的行为、活动或偏好的数据,并使用这些信息向用户推荐项目。推荐基于用户的行为与其他用户行为相似的程度。实际上,用户的推荐基于她的过去行为以及系统中其他用户所做的决策。协同过滤技术将使用类似用户的偏好来确定系统中所有可用项目的特征,然后它会推荐具有相似特征的项目,这些特征是观察到的特定用户群体所喜欢的。

基于内容的过滤

如我们之前提到的,基于内容的过滤系统为用户提供基于他们过去行为以及特定用户积极评价或喜欢的项目特征的推荐。我们还可以考虑特定用户不喜欢的项目。一个项目通常由几个离散属性表示。这些属性类似于分类或基于线性回归的机器学习模型的输入变量或特征。

例如,假设我们想要构建一个推荐系统,该系统使用基于内容的过滤来向其用户推荐在线产品。每个产品都可以通过几个已知特征来表征和识别,并且用户可以为每个产品的每个特征提供评分。产品的特征值可以在 0 到 10 之间,用户为产品提供的评分将在 0 到 5 的范围内。我们可以通过以下表格表示来可视化此推荐系统的样本数据:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_08.jpg

在前一个表中,系统有https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_09.jpg个产品以及https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_10.jpg个用户。每个产品由https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_11.jpg个特征定义,每个特征都将有一个在 0 到 10 之间的值,并且每个产品也会被用户评分。设用户https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_13.jpg对产品https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_12.jpg的评分表示为https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_14.jpg。使用输入值https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_15.jpg,或者说输入向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_16.jpg,以及用户https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_13.jpg的评分https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_14.jpg,我们可以估计一个参数向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_17.jpg,我们可以使用它来预测用户的评分。因此,基于内容的过滤实际上将线性回归的副本应用于每个用户的评分和每个产品的特征值,以估计一个回归模型,该模型反过来可以用来估计一些未评分产品的用户评分。实际上,我们使用独立变量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_16.jpg和因变量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_14.jpg以及系统中所有用户来学习参数https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_17.jpg。使用估计的参数https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_17.jpg和一些独立变量的给定值,我们可以预测任何给定用户的因变量值。因此,基于内容的过滤的优化问题可以表示如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_18.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_19.jpg

在前一个方程中定义的优化问题可以应用于系统的所有用户,以产生以下针对 Uusers 的优化问题:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_21.jpg

简而言之,参数向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_20.jpg试图缩放或转换输入变量以匹配模型的输出变量。添加的第二项是为了正则化。有趣的是,在递减方程中定义的优化问题与线性回归类似,因此基于内容的过滤可以被视为线性回归的扩展。

基于内容的过滤的关键问题是给定的推荐系统是否能够从用户的偏好或评分中学习。可以通过要求用户对系统中他们喜欢的项目进行评分来使用直接反馈,尽管这些评分也可以从用户的历史行为中隐含得出。此外,为特定用户和特定项目类别训练的基于内容的过滤系统不能用来预测同一用户对其他类别项目的评分。例如,使用用户对新闻的偏好来预测用户对在线购物产品的喜好是一个难题。

协同过滤

另一种主要的推荐形式是协同过滤,在这种形式中,分析具有相似兴趣的多个用户的行为数据,并用于预测这些用户的推荐。这种技术的主要优势是系统不依赖于其项目特征变量的值,因此这样的系统不需要了解它提供的项目的特征。实际上,项目的特征是通过使用用户对项目的评分和系统用户的行怍动态确定的。我们将在本节的后面部分更详细地探讨这种优势。

协同过滤模型使用的关键部分依赖于其用户的行为。为了构建模型的一部分,我们可以使用以下方法以显式方式确定模型中项目的用户评分:

  • 要求用户对项目进行特定尺度的评分

  • 要求用户将项目标记为收藏夹

  • 向用户展示少量项目,并要求他们根据对项目的喜好或厌恶程度进行排序

  • 要求用户创建他们喜欢的项目或项目类型的列表。

或者,这些信息也可以通过用户的活动以隐式的方式收集。以下是一些使用给定项目或产品集建模系统用户行为的示例方法:

  • 观察用户查看的项目

  • 分析特定用户查看特定项目的次数

  • 分析用户的社会网络,并发现具有相似兴趣的用户

例如,考虑一个在线购物推荐系统,这是我们之前章节讨论过的。我们可以使用协同过滤动态确定产品的特征值,并预测用户可能感兴趣的产品。使用以下表格可以可视化此类使用协同过滤的系统样本数据:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_22.jpg

在前表中显示的数据中,产品的特征是未知的。唯一可用的数据是用户的评分和用户的行为模型。

协同过滤和产品用户的优化问题可以表达如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_23.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_24.jpg

前面的方程式被视为我们为基于内容的过滤定义的优化问题的逆。不是估计参数向量https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_17.jpg,协同过滤试图确定产品的特征值https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_16.jpg。同样,我们可以如下定义多个用户的优化问题:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_25.jpg

使用协同过滤,我们可以估计产品的特征https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_26.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_27.jpg,然后使用这些特征值来改进用户的行为模型https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_28.jpg。改进的用户行为模型可以再次用于生成物品的更好的特征值。这个过程然后重复进行,直到特征值和行为模型收敛到某些适当的值。

注意

注意,在这个过程中,算法从未需要知道其物品的初始特征值,它只需要最初估计用户的行为模型,以便为用户提供有用的推荐。

在某些特殊情况下,协同过滤也可以与基于内容的过滤相结合。这种方法被称为推荐系统的混合方法。我们可以以几种方式结合或混合两种推荐模型,具体如下:

  • 两个模型的结果可以通过加权方式在数值上进行组合

  • 在给定时间可以选择这两个模型中的任何一个

  • 向用户展示两个模型推荐结果的组合

使用 Slope One 算法

我们现在将研究协同过滤的 Slope One 算法。此外,我们还将展示如何在 Clojure 中简洁地实现它。

Slope One 算法是基于物品的协同过滤最简单形式之一,它本质上是一种协同过滤技术,其中用户明确地对每个他们喜欢的物品进行评分(更多信息,请参阅基于在线评分的协同过滤的 Slope One 预测器)。通常,基于物品的协同过滤技术将使用用户的评分和过去用户的行为来估计每个用户的简单回归模型。因此,我们为系统中的所有用户https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_29.jpghttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_13.jpg估计一个函数。

Slope One 算法使用一个更简单的预测器 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_30.jpg 来模拟用户的回归行为模式,因此计算成本更低。参数 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_31.jpg 可以通过计算两个项目之间用户评分的差异来估计。由于 Slope One 算法的定义简单,它可以轻松高效地实现。有趣的是,这个算法比其他协同过滤技术更不容易过拟合。

考虑一个简单的推荐系统,包含两个项目和两个用户。我们可以用以下表格来可视化这些样本数据:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_32.jpg

在前面表格中显示的数据中,可以通过使用 用户 1 提供的评分来找到 项目 A项目 B 之间的评分差异。这个差异是 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_33.jpg。因此,我们可以将这个差异加到 用户 2项目 A 的评分上,以预测他对 项目 B 的评分,这个评分等于 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_34.jpg

让我们将前面的例子扩展到三个项目和三个用户。这个数据对应的表格可以可视化如下:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_35.jpg

在这个例子中,项目 A项目 B 之间对于 用户 2 (-1) 和 用户 1 (+2) 的评分差异的平均值是 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_36.jpg。因此,平均而言,项目 A 的评分比 项目 Bhttps://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_37.jpg。同样,项目 A项目 C 之间的平均评分差异是 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_38.jpg。我们可以使用 用户 3项目 B 的评分以及 项目 A项目 B 的评分差异的平均值来预测 用户 3项目 A 的评分。这个值是 https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/clj-ml/img/4351OS_08_39.jpg

现在我们将描述一个简洁的 Slope One 算法在 Clojure 中的实现。首先,我们需要定义我们的样本数据。这可以通过使用嵌套映射来完成,如下面的代码所示:

(def ? nil)
(def data
  {"User 1" {"Item A" 5 "Item B" 3 "Item C" 2 "Item D" ?}
   "User 2" {"Item A" 3 "Item B" 4 "Item C" ? "Item D" 4}
   "User 3" {"Item A" ? "Item B" 2 "Item C" 5 "Item D" 3}
   "User 4" {"Item A" 4 "Item B" ? "Item C" 3 "Item D" ?}})

在前面显示的代码中,我们将值 nil 绑定到 ? 符号上,并使用它来定义嵌套映射 data,其中每个键代表一个用户,其值代表一个映射,该映射以项目名称作为键,包含用户的评分。我们将定义以下一些实用方法来帮助我们操作由 data 表示的嵌套映射:

(defn flatten-to-vec [coll]
  (reduce #(apply conj %1 %2)
          []
          coll))

前面代码中定义的 flatten-to-vec 函数简单地将映射转换为平面向量,使用 reduceconj 函数。我们也可以通过使用标准 vecflattenseq 函数的功能组合来定义 flatten-to-vec,即 (def flatten-to-vec (comp vec flatten seq))。由于我们处理的是映射,我们可以定义以下一些函数,将任何函数映射到这些映射的值:

(defn map-vals [f m]
  (persistent!
    (reduce (fn [m [k v]]
              (assoc! m k (f k v)))
            (transient m) m)))

(defn map-nested-vals [f m]
  (map-vals
   (fn [k1 inner-map]
     (map-vals
      (fn [k2 val] (f [k1 k2] val)) inner-map)) m))

前面代码中定义的 map-vals 函数可以用来修改给定映射的值。此函数使用 assoc! 函数替换映射中给定键存储的值,并使用 reduce 函数组合并应用 assoc! 函数到映射中的所有键值对。在 Clojure 中,大多数集合,包括映射,都是持久和不可变的。注意使用 transient 函数将持久和不可变的映射转换为可变的,以及使用 persistent! 函数将瞬态可变集合转换为持久的。通过隔离修改,该函数的性能得到提高,同时保留了使用此函数的代码不可变性的保证。前面代码中定义的 map-nested-vals 函数简单地将 map-vals 函数应用于嵌套映射的第二级值。

我们可以在 REPL 中检查 map-valsmap-nested-vals 函数的行为,如下所示:

user> (map-vals #(inc %2) {:foo 1 :bar 2})
{:foo 2, :bar 3}

user> (map-nested-vals (fn [keys v] (inc v)) {:foo {:bar 2}})
{:foo {:bar 3}}

如前一个 REPL 输出所示,inc 函数被应用于映射 {:foo 1 :bar 2}{:foo {:bar 3}} 的值。我们现在定义一个函数,通过使用 Slope One 算法从样本数据生成一个训练模型,如下所示:

(defn train [data]
  (let [diff-map      (for [[user preferences] data]
                        (for [[i u-i] preferences
                              [j u-j] preferences
                              :when (and (not= i j)
                                         u-i u-j)]
                          [[i j] (- u-i u-j)]))
        diff-vec      (flatten-to-vec diff-map)
        update-fn     (fn [[freqs-so-far diffs-so-far]
                           [item-pair diff]]
                        [(update-in freqs-so-far
                                    item-pair (fnil inc 0))
                         (update-in diffs-so-far
                                    item-pair (fnil + 0) diff)])
        [freqs
         total-diffs] (reduce update-fn
                              [{} {}] diff-vec)
        differences   (map-nested-vals
                       (fn [item-pair diff]
                         (/ diff (get-in freqs item-pair)))
                       total-diffs)]
    {:freqs freqs
     :differences differences}))

前面代码中定义的 train 函数首先使用 for 宏找出模型中所有项目的评分差异,然后使用 update-fn 封闭调用添加项目的评分频率及其评分差异。

注意

函数和宏之间的主要区别在于,宏在执行时不会评估其参数。此外,宏在编译时解析和展开,而函数在运行时调用。

update-fn函数使用update-in函数来替换映射中的一个键的值。注意fnil函数的使用,它本质上返回一个检查值nil并将其替换为第二个参数的函数。这用于处理在嵌套映射数据中表示的由?符号表示的值,该符号在嵌套映射中的值为nil。最后,train函数将map-nested-valsget-in函数应用于前一步返回的评分差异映射。最后,它返回一个包含:freqs:differences键的映射,这些键包含表示项目频率和相对于模型中其他项目的评分差异的映射。现在我们可以使用这个训练好的模型来预测不同用户对给定项目的评分。为此,我们将在以下代码中实现一个函数,该函数使用前一个代码中定义的train函数返回的值:

(defn predict [{:keys [differences freqs]
                :as model}
               preferences
               item]
  (let [get-rating-fn (fn [[num-acc denom-acc]
                           [i rating]]
                        (let [freqs-ji (get-in freqs [item i])]
                          [(+ num-acc
                              (* (+ (get-in differences [item i])
                                    rating)
                                 freqs-ji))
                           (+ denom-acc freqs-ji)]))]
    (->> preferences
         (filter #(not= (first %) item))
         (reduce get-rating-fn [0 0])
         (apply /))))

前一个代码中定义的predict函数使用get-in函数检索由train函数返回的映射中每个项目的频率和差异的总和。然后,该函数通过使用reduce/(除法)函数的组合来平均这些评分差异。predict函数的行为可以在 REPL 中检查,如下面的代码所示:

user> (def trained-model (train data))
#'user/trained-model
user> (predict trained-model {"Item A" 2} "Item B")
3/2

如前一个 REPL 输出所示,predict函数使用了train函数返回的值来预测一个给过Item A评分为2的用户对Item B的评分。predict函数估计Item B的评分为3/2。现在我们可以在以下代码中实现一个函数,该函数封装predict函数以找到模型中所有项目的评分:

(defn mapmap
  ([vf s]
     (mapmap identity vf s))
  ([kf vf s]
     (zipmap (map kf s)
             (map vf s))))

(defn known-items [model]
  (-> model :differences keys))

(defn predictions
  ([model preferences]
     (predictions
      model
      preferences
      (filter #(not (contains? preferences %))
              (known-items model))))
  ([model preferences items]
     (mapmap (partial predict model preferences)
             items)))

前一个代码中定义的mapmap函数简单地将两个函数应用于给定的序列,并返回一个映射,其键由第一个函数kf创建,其值由第二个函数vf生成。如果将单个函数传递给mapmap函数,它将使用identity函数生成映射返回的键。前一个代码中定义的known-items函数将使用映射的键函数确定模型中的所有项目,该映射由train函数返回的值中的:differences键表示。最后,predictions函数使用trainknown-items函数返回的值来确定模型中的所有项目,然后预测特定用户的所有未评分项目。该函数还接受一个可选的第三个参数,即要预测评分的项目名称的向量,以便返回向量items中存在的所有项目的预测。

现在,我们可以在 REPL 中检查前面函数的行为,如下所示:

user> (known-items trained-model)
("Item D" "Item C" "Item B" "Item A")

如前述输出所示,known-items函数返回模型中所有项目的名称。现在我们可以尝试使用 predictions 函数,如下所示:

user> (predictions trained-model {"Item A" 2} ["Item C" "Item D"])
{"Item D" 3, "Item C" 0}
user> (predictions trained-model {"Item A" 2})
{"Item B" 3/2, "Item C" 0, "Item D" 3}

注意,当我们跳过predictions函数的最后一个可选参数时,该函数返回的映射将包含所有尚未被特定用户评价的项目。这可以通过在 REPL 中使用keys函数来验证,如下所示:

user> (keys  (predictions trained-model {"Item A" 2}))
("Item B" "Item C" "Item D")

总结来说,我们展示了如何使用嵌套映射和标准 Clojure 函数实现 Slope One 算法。

摘要

在本章中,我们讨论了异常检测和推荐。我们还实现了一个简单的异常检测器和推荐引擎。本章涵盖的主题可以总结如下:

  • 我们探讨了异常检测以及如何使用 Clojure 中的 PDF 实现异常检测器。

  • 我们研究了使用基于内容和协同过滤技术的推荐系统。我们还研究了这些技术中的各种优化问题。

  • 我们还研究了 Slope One 算法,这是一种协同过滤的形式,并描述了该算法的简洁实现。

在下一章中,我们将讨论更多可以应用于大型和复杂以数据为中心的应用的机器学习技术的应用。

根据原作 https://pan.quark.cn/s/459657bcfd45 的源码改编 Classic-ML-Methods-Algo 引言 建立这个项目,是为了梳理和总结传统机器学习(Machine Learning)方法(methods)或者算法(algo),和各位同仁相互学习交流. 现在的深度学习本质上来自于传统的神经网络模型,很大程度上是传统机器学习的延续,同时也在不少时候需要结合传统方法来实现. 任何机器学习方法基本的流程结构都是通用的;使用的评价方法也基本通用;使用的一些数学知识也是通用的. 本文在梳理传统机器学习方法算法的同时也会顺便补充这些流程,数学上的知识以供参考. 机器学习 机器学习是人工智能(Artificial Intelligence)的一个分支,也是实现人工智能最重要的手段.区别于传统的基于规则(rule-based)的算法,机器学习可以从数据中获取知识,从而实现规定的任务[Ian Goodfellow and Yoshua Bengio and Aaron Courville的Deep Learning].这些知识可以分为四种: 总结(summarization) 预测(prediction) 估计(estimation) 假想验证(hypothesis testing) 机器学习主要关心的是预测[Varian在Big Data : New Tricks for Econometrics],预测的可以是连续性的输出变量,分类,聚类或者物品之间的有趣关联. 机器学习分类 根据数据配置(setting,是否有标签,可以是连续的也可以是离散的)和任务目标,我们可以将机器学习方法分为四种: 无监督(unsupervised) 训练数据没有给定...
本系统采用微信小程序作为前端交互界面,结合Spring Boot与Vue.js框架实现后端服务及管理后台的构建,形成一套完整的电子商务解决方案。该系统架构支持单一商户独立运营,亦兼容多商户入驻的平台模式,具备高度的灵活性与扩展性。 在技术实现上,后端以Java语言为核心,依托Spring Boot框架提供稳定的业务逻辑处理与数据接口服务;管理后台采用Vue.js进行开发,实现了直观高效的操作界面;前端微信小程序则为用户提供了便捷的移动端购物体验。整套系统各模块间紧密协作,功能链路完整闭环,已通过严格测试与优化,符合商业应用的标准要求。 系统设计注重业务场景的全面覆盖,不仅包含商品展示、交易流程、订单处理等核心电商功能,还集成了会员管理、营销工具、数据统计等辅助模块,能够满足不同规模商户的日常运营需求。其多店铺支持机制允许平台方对入驻商户进行统一管理,同时保障各店铺在品牌展示、商品销售及客户服务方面的独立运作空间。 该解决方案强调代码结构的规范性与可维护性,遵循企业级开发标准,确保了系统的长期稳定运行与后续功能迭代的可行性。整体而言,这是一套技术选型成熟、架构清晰、功能完备且可直接投入商用的电商平台系统。 资源来源于网络分享,仅用于学习交流使用,请勿用于商业,如有侵权请联系我删除!
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值