Clojure-机器学习-全-

Clojure 机器学习(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

机器学习在计算机科学中有广泛的应用。使用机器学习技术的软件系统往往能为用户提供更好的用户体验。随着云数据的日益相关,开发者最终将构建更多智能系统,为用户简化并优化任何常规任务。

本书将介绍几种机器学习技术,并描述我们如何在 Clojure 编程语言中利用这些技术。

Clojure 是一种基于 Java 虚拟机(JVM)的动态和函数式编程语言。需要注意的是,Clojure 是 Lisp 语言家族的一员。Lisp 在 70 年代和 80 年代的人工智能革命中发挥了关键作用。不幸的是,人工智能在 80 年代末失去了其活力。然而,Lisp 却继续发展,并在历史上产生了多种 Lisp 方言。Clojure 是一种简单而强大的 Lisp 方言,首次发布于 2007 年。在撰写本书时,Clojure 是 JVM 上增长最快的编程语言之一。它目前支持一些最先进的语言特性和编程方法,如可选类型、软件事务内存、异步编程和逻辑编程。Clojure 社区以其优雅而强大的库而闻名,这也是使用 Clojure 的另一个令人信服的理由。

机器学习技术基于统计学和基于逻辑的推理。在这本书中,我们将关注机器学习的统计方面。这些技术中的大多数都基于人工智能革命的原则。机器学习仍然是一个活跃的研究和开发领域。软件世界的巨头,如谷歌和微软,也为机器学习做出了重大贡献。现在越来越多的软件公司意识到,使用机器学习技术的应用程序能为用户提供更好的体验。

尽管机器学习中涉及大量的数学知识,但我们将更多地关注这些技术的思想和实际应用,而不是集中在其理论及所使用的数学符号上。本书旨在为机器学习技术提供一个温和的介绍,以及它们如何在 Clojure 语言中应用。

本书涵盖内容

第一章, 处理矩阵,解释了矩阵及其在实现机器学习算法中有用的基本操作。

第二章, 理解线性回归,介绍了线性回归作为一种监督学习形式。我们还将讨论梯度下降算法和普通最小二乘法(OLS)用于拟合线性回归模型。

第三章, 数据分类,涵盖了分类,这是另一种监督学习形式。我们将研究分类的贝叶斯方法、决策树和 k 近邻算法。

第四章, 构建神经网络,解释了在非线性数据分类中有用的人工神经网络(ANNs),并描述了一些 ANN 模型。我们还将研究和实现用于训练 ANN 的反向传播算法,并描述自组织映射(SOMs)。

第五章, 选择和评估数据,涵盖了机器学习模型的评估。在本章中,我们将讨论几种可以用来提高特定机器学习模型有效性的方法。我们还将实现一个工作垃圾邮件分类器,作为如何构建包含评估的机器学习系统的示例。

第六章, 构建支持向量机,涵盖了支持向量机(SVMs)。我们还将描述如何使用 SVMs 来对线性和非线性样本数据进行分类。

第七章, 聚类数据,解释了聚类技术作为一种无监督学习形式,以及我们如何使用它们来发现未标记样本数据中的模式。在本章中,我们将讨论 K-means 和期望最大化(EM)算法。我们还将探讨降维技术。

第八章, 异常检测与推荐,解释了异常检测,这是另一种有用的无监督学习形式。我们还将讨论推荐系统和几种推荐算法。

第九章, 大规模机器学习,涵盖了处理大量数据的技术。在这里,我们解释了 MapReduce 的概念,这是一种并行数据处理技术。我们还将演示如何将数据存储在 MongoDB 中,以及如何使用 BigML 云服务构建机器学习模型。

附录, 参考文献,列出了本书各章节中使用的所有参考文献。

你需要这本书的内容

本书所需的软件之一是 Java 开发工具包(JDK),您可以从www.oracle.com/technetwork/java/javase/downloads/获取。JDK 是运行和开发 Java 平台上的应用程序所必需的。

您还需要的其他主要软件是 Leiningen,您可以从github.com/technomancy/leiningen下载并安装。Leiningen 是一个用于管理 Clojure 项目和它们依赖关系的工具。我们将在第一章使用矩阵中解释如何使用 Leiningen。

在本书的整个过程中,我们将使用包括 Clojure 本身在内的多个其他 Clojure 和 Java 库。Leiningen 将为我们下载这些库。您还需要一个文本编辑器或集成开发环境(IDE)。如果您已经有一个喜欢的文本编辑器,您可能可以使用它。导航到dev.clojure.org/display/doc/Getting+Started以检查使用您特定首选环境所需的提示和插件。如果您没有偏好,我建议您考虑使用 Eclipse 与 Counterclockwise。有关如何设置此环境的说明可在dev.clojure.org/display/doc/Getting+Started+with+Eclipse+and+Counterclockwise找到。

在第九章大规模机器学习中,我们也使用了 MongoDB,您可以从www.mongodb.org/下载并安装。

本书的目标读者

这本书是为熟悉 Clojure 并希望用它来构建机器学习系统的程序员或软件架构师而写的。本书不介绍 Clojure 语言的语法和功能(您应熟悉该语言,但不需要成为 Clojure 专家)。

同样,尽管您不需要成为统计学和坐标几何的专家,但您应该熟悉这些概念,以便理解我们将讨论的几种机器学习技术背后的理论。如有疑问,不要犹豫,查阅并学习本书中使用的数学概念。

约定

在本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词如下所示:“之前定义的probability函数需要一个参数来表示我们希望计算其发生概率的属性或条件。”

代码块设置如下:

(defn predict [coefs X]
  {:pre [(= (count coefs)
            (+ 1 (count X)))]}
  (let [X-with-1 (conj X 1)
        products (map * coefs X-with-1)]
    (reduce + products)))

当我们希望将您的注意力引向代码块中的特定部分时,相关的行或项目将以粗体显示:

:dependencies [[org.clojure/clojure "1.5.1"]
 [incanter "1.5.2"]
        [clatrix "0.3.0"]
        [net.mikera/core.matrix "0.10.0"]]

任何命令行输入或输出都按照以下方式编写:

$ lein deps

我们还使用的一个简单约定是始终显示以user>提示符开始的 Clojure 代码,该代码是在 REPL(读取-评估-打印循环)中输入的。在实践中,这个提示符将根据我们当前使用的 Clojure 命名空间而变化。然而,为了简单起见,REPL 代码以user>提示符开始,如下所示:

user> (every? #(< % 0.0001)
              (map - ols-linear-model-coefs
              (:coefs iris-linear-model))
true

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“点击下一个按钮将您带到下一屏幕”。

注意

警告或重要注意事项以如下框的形式出现。

小贴士

小贴士和技巧看起来像这样。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正从中获得最大收益的标题非常重要。

要发送给我们一般反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在您的邮件主题中提及书名。

如果您在某个主题上具有专业知识,并且您对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从您在www.packtpub.com的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/4351OS_Graphics.pdf下载此文件。

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。这样做可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。任何现有勘误都可以通过从 www.packtpub.com/support 选择您的标题来查看。

盗版

在互联网上,版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们作者方面的帮助,以及我们为您提供有价值内容的能力。

询问

如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章. 矩阵操作

在本章中,我们将探讨一个基本而优雅的数学数据结构——矩阵。大多数计算机科学和数学毕业生已经熟悉矩阵及其应用。在机器学习的背景下,矩阵用于实现多种机器学习技术,如线性回归和分类。我们将在后面的章节中更深入地研究这些技术。

虽然这个章节一开始可能看起来主要是理论性的,但很快我们会看到矩阵是快速组织和索引多维度数据的非常有用的抽象。机器学习技术使用的数据包含多个维度的大量样本值。因此,矩阵可以用来存储和处理这些样本数据。

使用矩阵的一个有趣应用是谷歌搜索,它建立在PageRank算法之上。尽管这个算法的详细解释超出了本书的范围,但值得知道的是,谷歌搜索本质上是在寻找一个极其庞大的数据矩阵的特征向量(更多信息,请参阅《大规模超文本搜索引擎的解剖结构》)。矩阵在计算机科学中有各种应用。尽管我们在这本书中不讨论谷歌搜索使用的特征向量矩阵运算,但在实现机器学习算法的过程中,我们会遇到各种矩阵运算。在本章中,我们将描述我们可以对矩阵执行的有用操作。

介绍 Leiningen

在本书的整个过程中,我们将使用 Leiningen(leiningen.org/)来管理第三方库和依赖项。Leiningen,或lein,是标准的 Clojure 包管理和自动化工具,具有用于管理 Clojure 项目的几个强大功能。

要获取如何安装 Leiningen 的说明,请访问项目网站leiningen.org/lein程序的第一次运行可能需要一段时间,因为它在第一次运行时会下载和安装 Leiningen 的二进制文件。我们可以使用leinnew子命令创建一个新的 Leiningen 项目,如下所示:

$ lein new default my-project

之前的命令创建了一个新的目录,my-project,它将包含 Clojure 项目的所有源文件和配置文件。这个文件夹包含src子目录中的源文件和一个单独的project.clj文件。在这个命令中,default是新项目要使用的项目模板的类型。本书中的所有示例都使用上述default项目模板。

project.clj文件包含与项目相关的所有配置,并将具有以下结构:

(defproject my-project "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license 
  {:name "Eclipse Public License"
   :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.5.1"]])

小贴士

下载示例代码

您可以从您在www.packtpub.com的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

第三方 Clojure 库可以通过向带有:dependencies键的向量中添加声明来包含在项目中。例如,Clojars 上的 core.matrix Clojure 库包(clojars.org/net.mikera/core.matrix)为我们提供了包声明[net.mikera/core.matrix "0.20.0"]。我们只需将此声明粘贴到:dependencies向量中,即可将 core.matrix 库包作为依赖项添加到我们的 Clojure 项目中,如下面的代码所示:

  :dependencies [[org.clojure/clojure "1.5.1"]
                 [net.mikera/core.matrix "0.20.0"]])

要下载project.clj文件中声明的所有依赖项,只需运行以下deps子命令:

$ lein deps

Leiningen 还提供了一个REPL读取-评估-打印循环),它简单来说是一个包含在project.clj文件中声明的所有依赖项的交互式解释器。此 REPL 还将引用我们在项目中定义的所有 Clojure 命名空间。我们可以使用以下leinrepl子命令来启动 REPL。这将启动一个新的 REPL 会话:

$ lein repl

矩阵表示

矩阵简单来说就是按行和列排列的数据矩形数组。大多数编程语言,如 C#和 Java,都直接支持矩形数组,而其他语言,如 Clojure,则使用异构的数组-数组表示法来表示矩形数组。请注意,Clojure 没有直接支持处理数组,并且惯用的 Clojure 代码使用向量来存储和索引元素数组。正如我们稍后将会看到的,矩阵在 Clojure 中表示为一个向量,其元素是其他向量。

矩阵支持多种算术运算,如加法和乘法,这些构成了数学中一个重要的领域,称为线性代数。几乎每种流行的编程语言至少有一个线性代数库。Clojure 通过让我们从多个此类库中选择,并且所有这些库都有一个与矩阵一起工作的单一标准化 API 接口,从而更进一步。

core.matrix库是一个多功能的 Clojure 库,用于处理矩阵。Core.matrix 还包含处理矩阵的规范。关于 core.matrix 的一个有趣的事实是,虽然它提供了此规范的默认实现,但它还支持多个实现。core.matrix 库托管和开发在 GitHub 上github.com/mikera/core.matrix

注意

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

[net.mikera/core.matrix "0.20.0"]

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

(ns my-namespace
  (:use clojure.core.matrix))

注意,在 Clojure 中使用 :import 来包含库命名空间通常是不推荐的。相反,使用 :require 形式进行别名命名空间是首选的。然而,对于下一节中的示例,我们将使用前面的命名空间声明。

在 Clojure 中,一个矩阵简单地说就是一个向量的向量。这意味着矩阵被表示为一个其元素是其他向量的向量。向量是一个元素数组,检索元素的时间几乎恒定,与具有线性查找时间的列表不同。然而,在矩阵的数学上下文中,向量仅仅是具有单行或单列的矩阵。

要从一个向量的向量创建一个矩阵,我们使用以下 matrix 函数,并将一个向量的向量或一个引用列表传递给它。请注意,矩阵的所有元素都内部表示为 double 数据类型(java.lang.Double),以增加精度。

user> (matrix [[0 1 2] [3 4 5]])    ;; using a vector
[[0 1 2] [3 4 5]]
user> (matrix '((0 1 2) (3 4 5)))   ;; using a quoted list
[[0 1 2] [3 4 5]]

在前面的示例中,矩阵有两行三列,或者更简洁地说是一个 2 x 3 矩阵。应注意,当矩阵由向量的向量表示时,表示矩阵各个行的所有向量应该具有相同的长度。

创建的矩阵以向量的形式打印出来,这不是最佳的可视化表示方法。我们可以使用 pm 函数如下打印矩阵:

user> (def A (matrix [[0 1 2] [3 4 5]]))
#'user/A
user> (pm A)
[[0.000 1.000 2.000]
 [3.000 4.000 5.000]]

这里,我们定义了一个矩阵 A,它用以下方式在数学上表示。请注意,使用大写变量名只是为了说明,因为所有 Clojure 变量都按照惯例以小写形式书写。

表示矩阵

矩阵 A 由元素 a[i,j] 组成,其中 i 是矩阵的行索引,j 是列索引。我们可以用以下方式用括号表示数学上的矩阵 A

表示矩阵

我们可以使用 matrix? 函数来检查一个符号或变量是否实际上是一个矩阵。matrix? 函数将对实现 core.matrix 规范的所有矩阵返回 true。有趣的是,matrix? 函数也会对普通向量的向量返回 true

core.matrix 的默认实现是用纯 Clojure 编写的,这会影响处理大型矩阵时的性能。core.matrix 规范有两个流行的贡献实现,即使用纯 Java 实现的 vectorz-clj (github.com/mikera/vectorz-clj) 和通过本地库实现的 clatrix (github.com/tel/clatrix)。虽然还有其他几个库实现了 core.matrix 规范,但这两个库被视为最成熟的。

注意

Clojure 有三种类型的库,即 core、contrib 和第三方库。core 和 contrib 库是标准 Clojure 库的一部分。core 和 contrib 库的文档可以在 clojure.github.io/ 找到。core 和 contrib 库之间的唯一区别是,contrib 库不是与 Clojure 语言一起分发的,必须单独下载。

任何人都可开发第三方库,并通过 Clojars (clojars.org/) 提供这些库。Leiningen 支持所有这些库,并且在这些库之间没有太大的区别。

contrib 库通常最初是作为第三方库开发的。有趣的是,core.matrix 首先作为一个第三方库开发,后来被提升为 contrib 库。

clatrix 库使用 基本线性代数子程序BLAS)规范来接口它所使用的本地库。BLAS 也是矩阵和向量线性代数操作的稳定规范,这些操作主要被本地语言使用。在实践中,clatrix 的性能显著优于 core.matrix 的其他实现,并定义了几个用于处理矩阵的实用函数。你应该注意,clatrix 库将矩阵视为可变对象,而 core.matrix 规范的其他实现则习惯上将矩阵视为不可变类型。

在本章的大部分内容中,我们将使用 clatrix 来表示和操作矩阵。然而,我们可以有效地重用 core.matrix 中的函数,这些函数在通过 clatrix 创建的矩阵上执行矩阵操作(如加法和乘法)。唯一的区别是,我们不应该使用 core.matrix 命名空间中的 matrix 函数来创建矩阵,而应该使用 clatrix 库中定义的函数。

注意

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

[clatrix "0.3.0"]

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

(ns my-namespace
  (:use clojure.core.matrix)
  (:require [clatrix.core :as cl]))

请记住,我们可以在同一个源文件中使用 clatrix.coreclojure.core.matrix 命名空间,但一个好的做法是将这两个命名空间导入到别名命名空间中,以防止命名冲突。

我们可以使用以下 cl/matrix 函数从 clatrix 库创建矩阵。请注意,clatrix 产生的矩阵表示与 core.matrix 略有不同,但更具有信息量。如前所述,可以使用 pm 函数将矩阵打印为向量向量:

user> (def A (cl/matrix [[0 1 2] [3 4 5]]))
#'user/A
user> A
 A 2x3 matrix
 -------------
 0.00e+00  1.00e+00  2.00e+00 
 3.00e+00  4.00e+00  5.00e+00 
user> (pm A)
[[0.000 1.000 2.000]
 [3.000 4.000 5.000]]
nil

我们还可以使用matrix函数的重载版本,它将矩阵实现名称作为第一个参数,后面跟着矩阵作为向量的常规定义,来创建矩阵。实现名称指定为一个关键字。例如,默认的持久向量实现指定为:persistent-vector,而 clatrix 实现指定为:clatrix。我们可以通过指定这个关键字参数来调用matrix函数,创建不同实现的矩阵,如下述代码所示。在第一次调用中,我们使用:persistent-vector关键字调用matrix函数来指定默认的持久向量实现。同样,我们使用:clatrix关键字调用matrix函数来创建 clatrix 实现。

user> (matrix :persistent-vector [[1 2] [2 1]])
[[1 2] [2 1]]
user> (matrix :clatrix [[1 2] [2 1]])
 A 2x2 matrix
 -------------
 1.00e+00  2.00e+00 
 2.00e+00  1.00e+00

一个有趣的观点是,clatrix 将向量数组和数字的向量都视为matrix函数的有效参数,这与 core.matrix 的处理方式不同。例如,[0 1]生成一个 2 x 1 的矩阵,而[[0 1]]生成一个 1 x 2 的矩阵。core.matrix 的matrix函数没有这个功能,并且始终期望传递一个向量数组的向量。然而,使用[0 1][[0 1]]调用cl/matrix函数将创建以下矩阵而不会出现任何错误:

user> (cl/matrix [0 1])
 A 2x1 matrix
 -------------
 0.00e+00 
 1.00e+00 
user> (cl/matrix [[0 1]])
 A 1x2 matrix
 -------------
 0.00e+00  1.00e+00 

matrix?函数类似,我们可以使用cl/clatrix?函数来检查一个符号或变量是否来自 clatrix 库的矩阵。实际上,matrix?函数检查的是核心矩阵规范或协议的实现,而cl/clatrix?函数检查的是特定类型。如果cl/clatrix?函数对一个特定变量返回true,则matrix?也应该返回true;然而,这个公理的反面并不成立。如果我们使用matrix函数而不是cl/matrix函数创建一个矩阵并调用cl/clatrix?,它将返回false;这在下述代码中显示:

user> (def A (cl/matrix [[0 1]]))
#'user/A
user> (matrix? A)
true
user> (cl/clatrix? A)
true
user> (def B (matrix [[0 1]]))
#'user/B
user> (matrix? B)
true
user> (cl/clatrix? B)
false

矩阵的大小是一个重要的属性,通常需要计算。我们可以使用row-count函数来找到矩阵中的行数。实际上,这仅仅是组成矩阵的向量的长度,因此,我们也可以使用标准的count函数来确定矩阵的行数。同样,column-count函数返回矩阵中的列数。考虑到矩阵由等长的向量组成,列数应该是任何内部向量,或者说任何矩阵行的长度。我们可以在 REPL 中对以下示例矩阵的countrow-countcolumn-count函数的返回值进行检查:

user> (count (cl/matrix [0 1 2]))
3
user> (row-count (cl/matrix [0 1 2]))
3
user> (column-count (cl/matrix [0 1 2]))
1

要使用矩阵的行和列索引检索元素,请使用以下 cl/get 函数。除了执行操作的矩阵外,此函数还接受两个参数作为矩阵的索引。请注意,在 Clojure 代码中,所有元素都是相对于 0 进行索引的,这与矩阵的数学表示法不同,后者将 1 视为矩阵中第一个元素的位置。

user> (def A (cl/matrix [[0 1 2] [3 4 5]]))
#'user/A
user> (cl/get A 1 1)
4.0
user> (cl/get A 3)
4.0

如前例所示,cl/get 函数还有一个接受单个索引值作为函数参数的替代形式。在这种情况下,元素通过行优先遍历进行索引。例如,(cl/get A 1) 返回 3.0,而 (cl/get A 3) 返回 4.0。我们可以使用以下 cl/set 函数来更改矩阵的元素。此函数接受与 cl/get 相似的参数——一个矩阵、一个行索引、一个列索引,以及最后要设置在矩阵指定位置的新元素。实际上,cl/set 函数会修改或修改它提供的矩阵。

user> (pm A)
[[0.000 1.0002.000]
 [3.000 4.0005.000]]
nil
user> (cl/set A 1 2 0)
#<DoubleMatrix [0.000000, 1.000000, … , 0.000000]>
user> (pm A)
[[0.000 1.000 2.000]
 [3.000 4.000 0.000]]
nil

Clatrix 库还提供了两个用于函数组合的便捷函数:cl/mapcl/map-indexed。这两个函数都接受一个函数和一个矩阵作为参数,并将传递的函数应用于矩阵中的每个元素,其方式类似于标准的 map 函数。此外,这两个函数都返回新的矩阵,并且不会修改它们作为参数提供的矩阵。请注意,传递给 cl/map-indexed 的函数应该接受三个参数——行索引、列索引以及元素本身:

user> (cl/map-indexed 
      (fn [i j m] (* m 2)) A)
 A 2x3 matrix
 -------------
 0.00e+00  2.00e+00  4.00e+00 
 6.00e+00  8.00e+00  1.00e+01 
user> (pm (cl/map-indexed (fn [i j m] i) A))
[[0.000 0.000 0.000]
 [1.000 1.000 1.000]]
nil
user> (pm (cl/map-indexed (fn [i j m] j) A))
[[0.000 1.000 2.000]
 [0.000 1.000 2.000]]
nil

生成矩阵

如果矩阵的行数和列数相等,则我们称该矩阵为 方阵。我们可以通过使用 repeat 函数重复单个元素来轻松生成一个简单的方阵,如下所示:

(defn square-mat
  "Creates a square matrix of size n x n 
  whose elements are all e"
  [n e]
  (let [repeater #(repeat n %)]
    (matrix (-> e repeater repeater))))

在前例中,我们定义了一个闭包来重复值 n 次,这显示为 repeater。然后我们使用 thread 宏 (->) 将元素 e 通过闭包传递两次,最后将 matrix 函数应用于 thread 宏的结果。我们可以扩展此定义,以便我们可以指定用于生成的矩阵的矩阵实现;这是如下完成的:

(defn square-mat
  "Creates a square matrix of size n x n whose 
  elements are all e. Accepts an option argument 
  for the matrix implementation."
  [n e & {:keys [implementation] 
          :or {implementation :persistent-vector}}]
  (let [repeater #(repeat n %)]
    (matrix implementation (-> e repeater repeater))))

square-mat 函数被定义为接受可选的关键字参数,这些参数指定了生成的矩阵的矩阵实现。我们将 core.matrix 的默认 :persistent-vector 实现指定为 :implementation 关键字的默认值。

现在,我们可以使用这个函数来创建方阵,并在需要时指定矩阵实现:

user> (square-mat 2 1)
[[1 1] [1 1]]
user> (square-mat 2 1 :implementation :clatrix)
 A 2x2 matrix
 -------------
 1.00e+00  1.00e+00
 1.00e+00  1.00e+00

经常使用的一种特殊类型的矩阵是单位矩阵。单位矩阵是一个对角线元素为 1 而其他所有元素为 0 的方阵。我们正式定义单位矩阵 生成矩阵 如下:

生成矩阵

我们可以使用之前提到的 cl/map-indexed 函数来实现一个创建单位矩阵的函数,如下代码片段所示。我们首先使用之前定义的 square-mat 函数创建一个 生成矩阵 大小的正方形矩阵 init,然后使用 cl/map-indexed 将所有对角线元素映射为 1

(defn id-mat
  "Creates an identity matrix of n x n size"
  [n]
  (let [init (square-mat :clatrix n 0)
       identity-f (fn [i j n]
                     (if (= i j) 1 n))]
    (cl/map-indexed identity-f init)))

core.matrix 库也有自己的这个函数版本,命名为 identity-matrix

user> (id-mat 5)
 A 5x5 matrix
 -------------
 1.00e+00  0.00e+00 0.00e+00 0.00e+00 0.00e+00
 0.00e+00  1.00e+00 0.00e+00 0.00e+00 0.00e+00
 0.00e+00  0.00e+00 1.00e+00 0.00e+00 0.00e+00
 0.00e+00  0.00e+00 0.00e+00 1.00e+00 0.00e+00 
 0.00e+00  0.00e+00 0.00e+00 0.00e+00 1.00e+00 
user> (pm (identity-matrix 5))
[[1.000 0.000 0.000 0.000 0.000]
 [0.000 1.000 0.000 0.000 0.000]
 [0.000 0.000 1.000 0.000 0.000]
 [0.000 0.000 0.000 1.000 0.000]
 [0.000 0.000 0.000 0.000 1.000]]
nil

我们还会遇到另一个常见场景,即需要生成一个包含随机数据的矩阵。让我们实现以下函数来生成随机矩阵,就像之前定义的 square-mat 函数一样,使用 rand-int 函数。请注意,rand-int 函数接受一个参数 n,并返回一个介于 0n 之间的随机整数:

(defn rand-square-mat 
  "Generates a random matrix of size n x n"
  [n]
  ;; this won't work
  (matrix (repeat n (repeat n (rand-int 100))))) 

但这个函数生成的矩阵的所有元素都是单个随机数,这并不太有用。例如,如果我们用任何整数作为参数调用 rand-square-mat 函数,它将返回一个包含单个不同随机数的矩阵,如下代码片段所示:

user> (rand-square-mat 4)
[[94 94] [94 94] [94 94] [94 94]]

相反,我们应该使用 rand-int 函数映射 square-mat 函数生成的正方形矩阵的每个元素,为每个元素生成一个随机数。不幸的是,cl/map 只能与由 clatrix 库创建的矩阵一起使用,但我们可以通过使用 repeatedly 函数返回的惰性序列轻松地复制这种行为。请注意,repeatedly 函数接受一个惰性生成序列的长度和一个用作该序列生成器的函数作为参数。因此,我们可以实现使用 clatrix 和 core.matrix 库生成随机矩阵的函数如下:

(defn rand-square-clmat
  "Generates a random clatrix matrix of size n x n"
  [n]
  (cl/map rand-int (square-mat :clatrix n 100)))

(defn rand-square-mat
  "Generates a random matrix of size n x n"
  [n]
  (matrix
   (repeatedly n #(map rand-int (repeat n 100)))))

这个实现按预期工作,新矩阵的每个元素现在都是一个独立生成的随机数。我们可以在 REPL 中通过调用以下修改后的 rand-square-mat 函数来验证这一点:

user> (pm (rand-square-mat 4))
[[97.000 35.000 69.000 69.000]
 [50.000 93.000 26.000  4.000]
 [27.000 14.000 69.000 30.000]
 [68.000 73.000 0.0007 3.000]]
nil
user> (rand-square-clmat 4)
 A 4x4 matrix
 -------------
 5.30e+01  5.00e+00  3.00e+00  6.40e+01 
 6.20e+01  1.10e+01  4.10e+01  4.20e+01 
 4.30e+01  1.00e+00  3.80e+01  4.70e+01 
 3.00e+00  8.10e+01  1.00e+01  2.00e+01

我们还可以使用 clatrix 库中的 cl/rnorm 函数生成随机元素矩阵。这个函数生成一个具有可选指定均值和标准差的正态分布随机元素矩阵。矩阵是正态分布的,意味着所有元素都均匀分布在指定的均值周围,其分布由标准差指定。因此,低标准差会产生一组几乎等于均值的值。

cl/rnorm 函数有几个重载版本。让我们在 REPL 中检查其中几个:

user> (cl/rnorm 10 25 10 10)
 A 10x10 matrix
 ---------------
-1.25e-01  5.02e+01 -5.20e+01  .  5.07e+01  2.92e+01  2.18e+01 
-2.13e+01  3.13e+01 -2.05e+01  . -8.84e+00  2.58e+01  8.61e+00 
 4.32e+01  3.35e+00  2.78e+01  . -8.48e+00  4.18e+01  3.94e+01 
 ... 
 1.43e+01 -6.74e+00  2.62e+01  . -2.06e+01  8.14e+00 -2.69e+01 
user> (cl/rnorm 5)
 A 5x1 matrix
 -------------
 1.18e+00 
 3.46e-01 
-1.32e-01 
 3.13e-01 
-8.26e-02 
user> (cl/rnorm 3 4)
 A 3x4 matrix
 -------------
-4.61e-01 -1.81e+00 -6.68e-01  7.46e-01 
 1.87e+00 -7.76e-01 -1.33e+00  5.85e-01 
 1.06e+00 -3.54e-01  3.73e-01 -2.72e-02 

在前面的例子中,第一次调用指定了均值、标准差以及矩阵的行数和列数。第二次调用指定了一个单个参数 n 并生成一个 生成矩阵 大小的矩阵。最后,第三次调用指定了矩阵的行数和列数。

core.matrix库还提供了一个compute-matrix函数来生成矩阵,这对 Clojure 程序员来说会感觉非常自然。此函数需要一个表示矩阵大小的向量,以及一个接受与矩阵维度数量相等的参数的函数。实际上,compute-matrix足够灵活,可以用来生成单位矩阵,以及随机元素矩阵。

我们可以使用compute-matrix函数实现以下函数来创建单位矩阵以及随机元素矩阵:

(defn id-computed-mat
  "Creates an identity matrix of size n x n 
  using compute-matrix"
  [n]
  (compute-matrix [n n] #(if (= %1 %2) 1 0)))

(defn rand-computed-mat
  "Creates an n x m matrix of random elements 
  using compute-matrix"
  [n m]
  (compute-matrix [n m] 
   (fn [i j] (rand-int 100))))

添加矩阵

Clojure 语言不直接支持矩阵操作,但通过core.matrix规范实现。在 REPL 中尝试添加两个矩阵,如下面的代码片段所示,只会抛出一个错误,指出在期望整数的地方找到了向量:

user> (+ (matrix [[0 1]]) (matrix [[0 1]]))
ClassCastException clojure.lang.PersistentVector cannot be cast to java.lang.Number  clojure.lang.Numbers.add (Numbers.java:126)

这是因为+函数作用于数字而不是矩阵。要添加矩阵,我们应该使用core.matrix.operators命名空间中的函数。在包含core.matrix.operators之后,命名空间声明应该看起来像以下代码片段:

(ns my-namespace
  (:use clojure.core.matrix)
  (:require [clojure.core.matrix.operators :as M]))

注意,这些函数实际上被导入到一个别名命名空间中,因为像+*这样的函数名与默认 Clojure 命名空间中的函数名冲突。在实践中,我们应该始终尝试通过:require:as过滤器使用别名命名空间,并避免使用:use过滤器。或者,我们可以在命名空间声明中使用:refer-clojure过滤器简单地不引用冲突的函数名,如下面的代码所示。然而,这应该谨慎使用,并且仅作为最后的手段。

对于本节中的代码示例,我们将使用之前的声明以提高清晰度:

(ns my-namespace
  (:use clojure.core.matrix)
  (:require clojure.core.matrix.operators)
  (:refer-clojure :exclude [+ - *])) 

我们可以使用M/+函数对两个或多个矩阵执行矩阵加法。要检查任意数量矩阵的相等性,我们使用M/==函数:

user> (def A (matrix [[0 1 2] [3 4 5]]))
#'user/A
user> (def B (matrix [[0 0 0] [0 0 0]]))
#'user/B
user> (M/== B A)
false
user> (def C (M/+ A B))
#'user/C
user> C
[[0 1 2] [3 4 5]]
user> (M/== C A)
true

如果两个矩阵 AB 满足以下等式,则称它们是相等的:

添加矩阵

因此,前面的等式解释了两个或多个矩阵相等当且仅当满足以下条件:

  • 每个矩阵都有相同数量的行和列

  • 所有具有相同行和列索引的元素都相等

以下是一个简单而优雅的矩阵相等实现。它基本上是使用标准的reducemap函数比较向量相等性:

(defn mat-eq
  "Checks if two matrices are equal"
  [A B]
  (and (= (count A) (count B))
       (reduce #(and %1 %2) (map = A B))))

我们首先使用count=函数比较两个矩阵的行长度,然后使用reduce函数比较内部向量元素。本质上,reduce函数反复将一个接受两个参数的函数应用于序列中的连续元素,并在序列中的所有元素都被应用函数“减少”后返回最终结果。

或者,我们也可以使用类似的组合使用 every?true? Clojure 函数。使用表达式 (every? true? (map = A B)),我们可以检查两个矩阵的相等性。请记住,true? 函数在传入 true 时返回 true(否则返回 false),而 every? 函数在给定的谓词函数对给定序列中的所有值返回 true 时返回 true

要加两个矩阵,它们必须有相等数量的行和列,和本质上是一个由具有相同行和列索引的元素之和组成的矩阵。两个矩阵 AB 的和已经正式定义为以下:

矩阵加法

使用标准的 mapv 函数实现矩阵加法几乎是显而易见的,它只是 map 函数的一个变体,返回一个向量。我们将 mapv 应用到矩阵的每一行以及整个矩阵上。请注意,此实现旨在用于向量(向量中的向量),尽管它可以很容易地与 core.matrix 中的 matrixas-vec 函数一起使用来操作矩阵。我们可以实现以下函数,使用标准 mapv 函数执行矩阵加法:

(defn mat-add
  "Add two matrices"
  [A B]
  (mapv #(mapv + %1 %2) A B))

我们同样可以很容易地将 mat-add 函数推广到任意数量的矩阵,使用 reduce 函数。如下面的代码所示,我们可以扩展 mat-add 的先前定义,使其使用 reduce 函数适用于任意数量的矩阵:

(defn mat-add
  "Add two or more matrices"
  ([A B]
     (mapv #(mapv + %1 %2) A B))
  ([A B & more]
     (let [M (concat [A B] more)]
       (reduce mat-add M))))

在一个 矩阵加法 矩阵 A 上有一个有趣的单一运算,即矩阵的迹,表示为 矩阵加法。矩阵的迹本质上是其对角元素的求和:

矩阵加法

使用前面描述的 cl/map-indexedrepeatedly 函数实现矩阵的迹函数相当简单。我们在这里省略了它,以便作为你的练习。

矩阵乘法

乘法是矩阵上的另一个重要二元运算。在更广泛的意义上,矩阵乘法这一术语指的是几种将矩阵相乘以产生新矩阵的技术。

让我们在 REPL 中定义三个矩阵 ABC 以及一个单一值 N。这些矩阵具有以下值:

矩阵乘法

我们可以使用 core.matrix 库中的 M/* 函数来乘以矩阵。除了用于乘以两个矩阵外,此函数还可以用于乘以任意数量的矩阵以及标量值。我们可以在 REPL 中尝试以下 M/* 函数来乘以两个给定的矩阵:

user> (pm (M/* A B))
[[140.000 200.000]
 [320.000 470.000]]
nil
user> (pm (M/* A C))
RuntimeException Mismatched vector sizes  clojure.core.matrix.impl.persistent-vector/... 
user> (def N 10)
#'user/N
user> (pm (M/* A N))
[[10.000 20.000 30.000]
 [40.000 50.000 60.000]]
nil

首先,我们计算了两个矩阵的乘积。这个操作被称为矩阵-矩阵乘法。然而,矩阵 AC 的乘法是不行的,因为这两个矩阵的大小不兼容。这把我们带到了矩阵乘法的第一个规则:要乘以两个矩阵 ABA 中的列数必须等于 B 中的行数。结果矩阵具有与 A 相同的行数和与 B 相同的列数。这就是为什么 REPL 不同意乘以 AC,而是简单地抛出一个异常。

对于大小为矩阵乘法的矩阵 A 和大小为矩阵乘法的矩阵 B,这两个矩阵的乘积只有在矩阵乘法的情况下才存在,而 AB 的乘积是一个大小为矩阵乘法的新矩阵。

矩阵 AB 的乘积是通过将 A 中行的元素与 B 中相应的列相乘,然后将结果值相加,为 A 的每一行和 B 的每一列产生一个单一值来计算的。因此,结果乘积具有与 A 相同的行数和与 B 相同的列数。

我们可以这样定义两个兼容大小的矩阵的乘积:

矩阵乘法矩阵乘法

以下是如何使用 AB 中的元素来计算两个矩阵的乘积的说明:

矩阵乘法

这看起来稍微有些复杂,所以让我们用一个例子来演示前面的定义,使用我们之前定义的矩阵 AB。以下计算实际上与 REPL 产生的值一致:

矩阵乘法

注意,矩阵乘法不是交换运算。然而,这个操作确实表现出函数的关联性质。对于乘积兼容大小的矩阵 ABC,以下性质始终成立,只有一个例外,我们稍后会揭露:

矩阵乘法

一个明显的推论是,一个方阵与另一个相同大小的方阵相乘会产生一个结果矩阵,其大小与两个原始矩阵相同。同样,方阵的平方、立方以及其他幂次运算会产生相同大小的矩阵。

另一个有趣的性质是,方阵在乘法中有一个单位元素,即与乘积兼容大小的单位矩阵。但是,单位矩阵本身也是一个方阵,这使我们得出结论,方阵与单位矩阵的乘法是一个交换操作。因此,矩阵乘法不是交换的规则实际上在其中一个矩阵是单位矩阵而另一个是方阵时并不成立。这可以由以下等式形式化总结:

矩阵乘法

矩阵乘法的简单实现的时间复杂度为矩阵乘法,对于一个矩阵乘法矩阵需要八个乘法操作。时间复杂度指的是特定算法运行到完成所需的时间。因此,线性代数库使用更有效的算法,如Strassen 算法,来实现矩阵乘法,该算法只需要七个乘法操作,并将复杂度降低到矩阵乘法

Clatrix 库对矩阵乘法的实现性能显著优于默认的持久向量实现,因为它与本地库进行了接口。在实践中,我们可以使用像 criterium 这样的基准测试库来对 Clojure 进行此比较(github.com/hugoduncan/criterium)。或者,我们也可以通过定义一个简单的函数来乘以两个矩阵,然后使用我们之前定义的rand-square-matrand-square-clmat函数将不同实现的大矩阵传递给它,简要比较这两种实现的性能。我们可以定义一个函数来测量乘以两个矩阵所需的时间。

此外,我们还可以定义两个函数来乘以使用我们之前定义的rand-square-matrand-square-clmat函数创建的矩阵,如下所示:

(defn time-mat-mul
  "Measures the time for multiplication of two matrices A and B"
  [A B]
  (time (M/* A B)))

(defn core-matrix-mul-time []
  (let [A (rand-square-mat 100)
        B (rand-square-mat 100)]
    (time-mat-mul A B)))

(defn clatrix-mul-time []
  (let [A (rand-square-clmat 100)
        B (rand-square-clmat 100)]
    (time-mat-mul A B)))

我们可以看到,core.matrix 实现平均需要一秒钟来计算两个随机生成矩阵的乘积。然而,clatrix 实现平均不到一毫秒,尽管第一次调用的通常需要 35 到 40 毫秒来加载本地 BLAS 库。当然,这个值可能会根据计算它的硬件略有不同。尽管如此,除非有有效的理由,例如硬件不兼容或避免额外的依赖,否则在处理大型矩阵时,clatrix 是首选。

接下来,让我们看看 标量乘法,它涉及将单个值 N 或标量与矩阵相乘。结果矩阵的大小与原始矩阵相同。对于一个 2 x 2 矩阵,我们可以定义标量乘法如下:

矩阵乘法

对于矩阵 矩阵乘法矩阵乘法,其乘积如下:

矩阵乘法

注意,我们还可以使用 core.matrix 库中的 scale 函数来执行标量乘法:

user> (pm (scale A 10))
[[10.000 20.000 30.000]
 [40.000 50.000 60.000]]
nil
user> (M/== (scale A 10) (M/* A 10))
true

最后,我们将简要地看一下矩阵乘法的一种特殊形式,称为 矩阵-向量乘法。向量可以简单地看作是一个只有一行的矩阵,它与大小与乘积兼容的方阵相乘,产生一个与原始向量大小相同的新向量。将大小为 矩阵乘法 的矩阵 A 和向量 V 的转置 V'(大小为 矩阵乘法)相乘,产生一个大小为 矩阵乘法 的新向量 V"。如果 A 是一个方阵,那么 V" 的大小与转置 V' 相同。

矩阵乘法矩阵乘法

矩阵转置和求逆

另一个常用的基本矩阵操作是矩阵的 转置。矩阵 A 的转置表示为 矩阵转置和求逆矩阵转置和求逆。定义矩阵转置的一个简单方法是通过反射矩阵到其 主对角线。主对角线是指由行和列索引相等的元素组成的对角线。我们还可以通过交换矩阵的行和列来描述矩阵的转置。我们可以使用 core.matrix 中的以下 transpose 函数来执行此操作:

user> (def A (matrix [[1 2 3] [4 5 6]]))
#'user/A
user> (pm (transpose A))
[[1.000 4.000]
 [2.000 5.000]
 [3.000 6.000]]
nil

我们可以定义以下三种获取矩阵转置的可能方法:

  • 原始矩阵沿主对角线进行反射

  • 矩阵的行变成其转置的列

  • 矩阵的列变成其转置的行

因此,矩阵中的每个元素在其转置中行和列都交换了,反之亦然。这可以用以下方程正式表示:

矩阵转置和求逆

这引出了可逆矩阵的概念。如果一个方阵存在另一个方阵作为其逆矩阵,并且与原矩阵相乘时产生单位矩阵,则称该方阵是可逆的。一个大小为 矩阵转置和求逆 的矩阵 A,如果以下等式成立,则称其有一个逆矩阵 B

矩阵转置和求逆

让我们使用 core.matrix 的 inverse 函数来测试这个等式。请注意,core.matrix 库的默认持久实现没有实现逆操作,所以我们使用 clatrix 库中的矩阵。在以下示例中,我们使用 cl/matrix 函数从 clatrix 库创建一个矩阵,使用 inverse 函数确定其逆,然后使用 M/* 函数将这两个矩阵相乘:

user> (def A (cl/matrix [[2 0] [0 2]]))
#'user/A
user> (M/* (inverse A) A)
 A 2x2 matrix
 -------------
 1.00e+00  0.00e+00 
 0.00e+00  1.00e+00

在前面的例子中,我们首先定义了一个矩阵 A,然后将其与其逆矩阵相乘以产生相应的单位矩阵。当我们使用双精度数值类型作为矩阵的元素时,一个有趣的观察是,并非所有矩阵与它们的逆矩阵相乘都会产生单位矩阵。

对于某些矩阵,可以观察到一些小的误差,这是由于使用 32 位表示浮点数的限制造成的;如下所示:

user> (def A (cl/matrix [[1 2] [3 4]]))
#'user/A
user> (inverse A)
 A 2x2 matrix
 -------------
-2.00e+00  1.00e+00 
 1.50e+00 -5.00e-01

为了找到一个矩阵的逆,我们首先必须定义该矩阵的 行列式,这仅仅是从给定矩阵中确定的一个值。首先,行列式仅存在于方阵中,因此,逆矩阵仅存在于行数和列数相等的矩阵中。矩阵的行列式表示为 矩阵的转置和求逆矩阵的转置和求逆。行列式为零的矩阵被称为 奇异矩阵。对于一个矩阵 A,我们定义其行列式如下:

矩阵的转置和求逆

我们可以使用前面的定义来表示任何大小的矩阵的行列式。一个有趣的观察是,单位矩阵的行列式始终是 1。作为一个例子,我们将如下找到给定矩阵的行列式:

矩阵的转置和求逆

对于一个 矩阵的转置和求逆 矩阵,我们可以使用 萨鲁斯规则 作为计算矩阵行列式的另一种方法。要使用此方案计算矩阵的行列式,我们首先将矩阵的前两列写在第三列的右侧,使得一行中有五列。接下来,我们加上从上到下对角线的乘积,并减去从下到上对角线的乘积。这个过程可以用以下图表说明:

矩阵的转置和求逆

通过使用萨鲁斯规则,我们正式地将矩阵 A 的行列式表达如下:

矩阵的转置和求逆

我们可以使用 core.matrix 的以下 det 函数在 REPL 中计算矩阵的行列式。请注意,此操作不是由 core.matrix 的默认持久向量实现实现的。

user> (def A (cl/matrix [[-2 2 3] [-1 1 3] [2 0 -1]]))
#'user/A
user> (det A)
6.0

现在我们已经定义了矩阵的行列式,让我们用它来定义矩阵的逆。我们已经讨论了可逆矩阵的概念;找到矩阵的逆就是确定一个矩阵,当它与原矩阵相乘时会产生一个单位矩阵。

对于矩阵的逆存在,其行列式必须不为零。接下来,对于原矩阵中的每个元素,我们找到去掉所选元素的行和列的矩阵的行列式。这会产生一个与原矩阵大小相同的矩阵(称为原矩阵的余子式矩阵)。余子式矩阵的转置称为原矩阵的伴随矩阵。通过将伴随矩阵除以原矩阵的行列式,可以得到逆矩阵。现在,让我们正式定义一个 矩阵转置和求逆 矩阵 A 的逆。我们用 矩阵转置和求逆 表示矩阵 A 的逆,它可以正式表示如下:

矩阵转置和求逆

以一个例子来说明,让我们找到一个样本 矩阵转置和求逆 矩阵的逆。实际上,我们可以验证,当与原矩阵相乘时,逆矩阵会产生一个单位矩阵,如下面的例子所示:

矩阵转置和求逆

同样,我们按照以下方式定义一个 矩阵转置和求逆 矩阵的逆:

矩阵转置和求逆矩阵转置和求逆

现在,让我们计算一个 矩阵转置和求逆 矩阵的逆:

矩阵转置和求逆矩阵转置和求逆

我们提到奇异和非方阵没有逆,我们可以看到当inverse函数接收到这样的矩阵时,会抛出错误。如下面的 REPL 输出所示,如果给定的矩阵不是方阵,或者给定的矩阵是奇异的,inverse函数将抛出错误:

user> (def A (cl/matrix [[1 2 3] [4 5 6]]))
#'user/A
user> (inverse A)
ExceptionInfo throw+: {:exception "Cannot invert a non-square matrix."}  clatrix.core/i (core.clj:1033)
user> (def A (cl/matrix [[2 0] [2 0]]))
#'user/A
user> (M/* (inverse A) A)
LapackException LAPACK DGESV: Linear equation cannot be solved because the matrix was singular.  org.jblas.SimpleBlas.gesv (SimpleBlas.java:274)

使用矩阵进行插值

让我们尝试一个示例来演示我们如何使用矩阵。这个例子使用矩阵在给定的一组点之间插值曲线。假设我们有一个代表某些数据的给定点集。目标是追踪点之间的平滑线,以产生一个估计数据形状的曲线。尽管这个例子中的数学公式可能看起来很复杂,但我们应该知道,这种技术实际上只是线性回归模型正则化的一种形式,被称为 Tichonov 正则化。现在,我们将专注于如何在这个技术中使用矩阵,我们将在第二章 理解线性回归 中深入探讨正则化。

我们首先定义一个插值矩阵 L,它可以用来确定给定数据点的估计曲线。它本质上是一个向量 [-1, 2, -1] 在矩阵的列中斜向移动。这种矩阵被称为 带宽矩阵

使用矩阵进行插值

我们可以使用以下 compute-matrix 函数简洁地定义矩阵 L。注意,对于给定的大小 n,我们生成一个大小如 使用矩阵进行插值 的矩阵:

(defn lmatrix [n]
  (compute-matrix :clatrix [n (+ n 2)]
                  (fn [i j] ({0 -1, 1 2, 2 -1} (- j i) 0))))

在前面的例子中,匿名闭包使用一个映射来决定指定行和列索引处的元素值。例如,行索引 2 和列索引 3 的元素是 2,因为 (- j i)1,映射中的键 1 的值是 2。我们可以通过以下方式在 REPL 中验证生成的矩阵与 lmatrix 矩阵具有相似的结构:

user> (pm (lmatrix 4))
[[-1.000 2.000 -1.000  0.000  0.000  0.000]
[ 0.000 -1.000  2.000 -1.000  0.000  0.000]
[ 0.000  0.000 -1.000  2.000 -1.000  0.000]
[ 0.000  0.000  0.000 -1.000  2.000 -1.000]]
nil

接下来,我们定义如何表示我们打算进行插值的数值点。每个点都有一个观测值 x,它被传递给某个函数以产生另一个观测值 y。在这个例子中,我们简单地为一个 x 选择一个随机值,并为 y 选择另一个随机值。我们重复执行此操作以产生数据点。

为了表示与兼容大小的 L 矩阵一起的数据点,我们定义了一个名为 problem 的简单函数,该函数返回问题定义的映射。这包括 L 矩阵、x 的观测值、x 的隐藏值(对于这些值,我们必须估计 y 的值以创建曲线),以及 y 的观测值。

(defn problem
  "Return a map of the problem setup for a
  given matrix size, number of observed values 
  and regularization parameter"
  [n n-observed lambda]
  (let [i (shuffle (range n))]
    {:L (M/* (lmatrix n) lambda)
     :observed (take n-observed i)
     :hidden (drop n-observed i)
     :observed-values (matrix :clatrix
                              (repeatedly n-observed rand))}))

函数的前两个参数是 L 矩阵中的行数 n 和观测的 x 值数 n-observed。函数接受第三个参数 lambda,这实际上是我们的模型的正则化参数。此参数决定了估计曲线的准确性,我们将在后面的章节中更详细地研究它与此模型的相关性。在前面的函数返回的映射中,xy 的观测值具有键 :observed:observed-values,而 x 的隐藏值具有键 :hidden。同样,键 :L 映射到兼容大小的 L 矩阵。

现在我们已经定义了我们的问题(或模型),我们可以在给定的点上绘制一条平滑的曲线。这里的“平滑”是指曲线上的每个点都是其直接邻居的平均值,以及一些高斯噪声。因此,所有这些噪声曲线上的点都服从高斯分布,其中所有值都围绕某个平均值散布,并带有由某个标准差指定的范围。

如果我们将矩阵 L 分别在观测点和隐藏点上进行 使用矩阵进行插值使用矩阵进行插值 的划分,我们可以定义一个公式来确定曲线如下。以下方程可能看起来有些令人畏惧,但如前所述,我们将在接下来的章节中研究这个方程背后的推理。曲线可以用一个矩阵来表示,该矩阵可以通过以下方式计算,使用矩阵 L

使用矩阵进行插值

我们使用原始观测的 y 值,即 使用矩阵进行插值,来估计隐藏的 x 值,使用从插值矩阵 L 计算出的两个矩阵。这两个矩阵仅使用矩阵的转置和逆函数来计算。由于此方程右侧的所有值要么是矩阵要么是向量,我们使用矩阵乘法来找到这些值的乘积。

之前的方程可以使用我们之前探索过的函数来实现。实际上,代码只包含这个方程,以作为我们之前定义的 problem 函数返回的映射的前缀表达式。我们现在定义以下函数来解决 problem 函数返回的问题:

(defn solve
  "Return a map containing the approximated value 
y of each hidden point x"
  [{:keys [L observed hidden observed-values] :as problem}]
  (let [nc  (column-count L)
        nr  (row-count L)
        L1  (cl/get L (range nr) hidden)
        L2  (cl/get L (range nr) observed)
        l11 (M/* (transpose L1) L1)
        l12 (M/* (transpose L1) L2)]
    (assoc problem :hidden-values
      (M/* -1 (inverse l11) l12 observed-values))))

前面的函数计算了 y 的估计值,并简单地使用 assoc 函数将它们添加到原始映射中,键为 :hidden-values

要在心理上可视化曲线的计算值相当困难,因此我们现在将使用 Incanter 库(github.com/liebke/incanter)来绘制估计曲线和原始点。这个库本质上提供了一个简单且符合习惯的 API 来创建和查看各种类型的图表和图表。

注意

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

[incanter "1.5.4"]

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

(ns my-namespace
  (:use [incanter.charts :only [xy-plot add-points]]
        [incanter.core   :only [view]])
  (:require [clojure.core.matrix.operators :as M]
            [clatrix.core :as cl]))

现在,我们定义一个简单的函数,它将使用 Incanter 库中的函数,如 xy-plotview,来绘制给定数据的图表:

(defn plot-points
  "Plots sample points of a solution s"
  [s]
  (let [X (concat (:hidden s) (:observed s))
        Y (concat (:hidden-values s) (:observed-values s))]
    (view
     (add-points
      (xy-plot X Y) (:observed s) (:observed-values s)))))

由于这是我们第一次接触 Incanter 库,让我们讨论一些用于实现 plot-points 的函数。我们首先将所有 x 轴上的值绑定到 X,将所有 y 轴上的值绑定到 Y。然后,我们使用 xy-plot 函数将点绘制为曲线,该函数接受两个参数,用于在 xy 轴上绘制,并返回一个图表或图表。接下来,我们使用 add-points 函数将原始观察到的点添加到图表中。add-points 函数需要三个参数:原始图表、x 轴组件的所有值的向量,以及 y 轴组件的所有值的向量。此函数也返回一个类似于 xy-plot 函数的图表,我们可以使用 view 函数查看此图表。请注意,我们也可以等效地使用线程宏(->)来组合 xy-plotadd-pointsview 函数。

现在,我们可以直观地使用 plot-points 函数在随机数据上可视化估计曲线,如下面的函数所示:

(defn plot-rand-sample []
  (plot-points (solve (problem 150 10 30))))

当我们执行 plot-rand-sample 函数时,会显示以下值的图表:

使用矩阵进行插值

摘要

在本章中,我们通过 core.matrix 和 clatrix 库介绍了矩阵。以下是我们所涵盖的要点:

  • 我们已经讨论了如何通过 core.matrix 和 clatrix 来表示、打印和从矩阵中获取信息。我们还讨论了如何使用一些随机数据生成矩阵。

  • 我们已经讨论了一些矩阵的基本操作,例如相等、加法、乘法、转置和逆运算。

  • 我们还介绍了一个多功能的 Incanter 库,它用于通过矩阵使用示例来可视化数据图表。

接下来,我们将研究一些使用线性回归进行预测的基本技术。正如我们将看到的,其中一些技术实际上是基于简单的矩阵运算。线性回归实际上是一种监督学习类型,我们将在下一章中讨论。

第二章:理解线性回归

在本章中,我们开始探索机器学习模型和技术。机器学习的最终目标是泛化从某些经验样本数据中得出的事实。这被称为泛化,本质上是指使用这些推断事实以准确率在新的、未见过的数据上准确执行的能力。机器学习的两大类别是监督学习和无监督学习。监督学习这个术语用来描述机器学习中的任务,即从某些标记数据中制定理解或模型。通过标记,我们指的是样本数据与某些观察到的值相关联。在基本意义上,模型是数据的统计描述以及数据如何在不同参数上变化。监督机器学习技术用来创建模型的初始数据被称为模型的训练数据。另一方面,无监督学习技术通过在未标记数据中寻找模式来估计模型。由于无监督学习技术使用的数据是未标记的,通常没有基于是或否的明确奖励系统来确定估计的模型是否准确和正确。

现在,我们将考察线性回归,这是一个有趣的预测模型。作为一种监督学习,回归模型是从某些数据中创建的,其中一些参数以某种方式组合产生几个目标值。该模型实际上描述了目标值与模型参数之间的关系,并在提供模型参数的值时可以用来预测目标值。

我们首先将研究单变量和多变量线性回归,然后描述可以用来从一些给定数据中制定机器学习模型的算法。我们将研究这些模型背后的推理,并同时展示如何在 Clojure 中实现这些算法来创建这些模型。

理解单变量线性回归

我们经常遇到需要从一些样本数据中创建近似模型的情况。然后,可以使用该模型在提供所需参数时预测更多此类数据。例如,我们可能想研究特定城市某一天降雨的频率,我们假设这取决于那一天的湿度。一个制定好的模型可以用来预测某一天降雨的可能性,如果我们知道那一天的湿度。我们从一些数据开始制定模型,首先通过一些参数和系数在这个数据上拟合一条直线(即方程)。这种类型的模型被称为线性回归模型。如果我们假设样本数据只有一个维度,我们可以将线性回归视为在样本数据上拟合一条直线,理解单变量线性回归

线性回归模型简单地说是一个线性方程,它表示模型的回归量因变量。根据可用数据,建立的回归模型可以有一个或多个参数,这些模型的参数也被称为回归变量特征独立变量。我们将首先探讨具有单个独立变量的线性回归模型。

使用单变量线性回归的一个示例问题可能是预测特定一天降雨的概率,这取决于那一天的湿度。这些训练数据可以用以下表格形式表示:

理解单变量线性回归

对于单变量线性模型,因变量必须相对于单个参数变化。因此,我们的样本数据本质上由两个向量组成,即一个用于依赖参数 Y 的值,另一个用于独立变量 X 的值。这两个向量长度相同。这些数据可以用以下两种形式正式表示为两个向量,或单列矩阵:

理解单变量线性回归

让我们快速定义以下两个矩阵,Clojure 中的 XY,以表示一些样本数据:

(def X (cl/matrix [8.401 14.475 13.396 12.127 5.044
                      8.339 15.692 17.108 9.253 12.029]))

(def Y (cl/matrix [-1.57 2.32  0.424  0.814 -2.3
           0.01 1.954 2.296 -0.635 0.328]))

在这里,我们定义了 10 个数据点;这些点可以用以下 Incanter scatter-plot函数轻松地绘制在散点图上:

(def linear-samp-scatter
  (scatter-plot X Y))

(defn plot-scatter []
  (view linear-samp-scatter))

(plot-scatter)

上述代码显示了以下数据点的散点图:

理解单变量线性回归

之前的散点图是我们定义在 XY 中的 10 个数据点的简单表示。

注意

scatter-plot 函数可以在 Incanter 库的 charts 命名空间中找到。使用此函数的文件命名空间声明应类似于以下声明:

(ns my-namespace
  (:use [incanter.charts :only [scatter-plot]]))

现在我们已经对我们的数据有了可视化,让我们在给定的数据点上估计一个线性模型。我们可以使用 Incanter 库中的linear-model函数生成任何数据的线性模型。这个函数返回一个映射,描述了构建的模型,以及关于这个模型的大量有用数据。首先,我们可以使用这个映射中的:fitted键值对在我们的先前散点图上绘制线性模型。我们首先从返回的映射中获取:fitted键的值,并使用add-lines函数将其添加到散点图中;这在上面的代码中有所展示:

(def samp-linear-model
  (linear-model Y X))
(defn plot-model []
  (view (add-lines samp-scatter-plot 
          X (:fitted linear-samp-scatter))))

(plot-model)

这段代码生成了以下自解释的线性模型图,该图覆盖了我们之前定义的散点图:

理解单变量线性回归

之前的图表描绘了线性模型samp-linear-model,作为在XY中定义的 10 个数据点上绘制的直线。

注意

linear-model函数可以在 Incanter 库的stats命名空间中找到。使用linear-model的文件命名空间声明应类似于以下声明:

(ns my-namespace
  (:use [incanter.stats :only [linear-model]]))

好吧,看起来 Incanter 的linear-model函数为我们做了大部分工作。本质上,这个函数通过使用普通最小二乘法OLS)曲线拟合算法来创建我们数据的线性模型。我们很快就会深入了解这个算法的细节,但首先让我们了解曲线是如何精确地拟合到一些给定数据上的。

让我们先定义一条直线如何表示。在坐标几何学中,一条直线简单地是一个独立变量x的函数,它有一个给定的斜率m和截距c。直线的函数y可以正式写成理解单变量线性回归。直线的斜率表示当x的值变化时,y的值变化了多少。这个方程的截距就是直线与图表的y轴相交的地方。请注意,方程yY不同,Y实际上代表了我们提供的方程的值。

类似于从坐标几何学中定义的直线,我们使用我们对矩阵XY的定义,正式地使用单变量线性回归模型进行定义,如下所示:

理解单变量线性回归

由于我们可以使用相同的方程来定义具有多个变量的线性模型,因此这个单变量线性模型的定义实际上非常灵活;我们将在本章后面看到这一点。在前面的定义中,术语理解单变量线性回归是一个系数,它表示y相对于x的线性尺度。从几何学的角度来看,它就是拟合给定数据矩阵XY的直线的斜率。由于X是一个矩阵或向量,理解单变量线性回归也可以被视为矩阵X的缩放因子。

此外,术语理解单变量线性回归是另一个系数,它解释了当x为零时y的值。换句话说,它是方程的y截距。所构建模型的系数理解单变量线性回归被称为线性模型的回归系数效应,而系数理解单变量线性回归被称为模型的误差项偏差。一个模型甚至可能有多个回归系数,正如我们将在本章后面看到的。结果证明,误差理解单变量线性回归实际上只是另一个回归系数,并且可以传统地与其他模型的效应一起提及。有趣的是,这个误差决定了数据的一般散点或方差。

使用我们之前示例中linear-model函数返回的映射,我们可以轻松地检查生成的模型的系数。返回的映射有一个:coefs键,它映射到一个包含模型系数的向量。按照惯例,误差项也包含在这个向量中,简单地作为另一个系数:

user> (:coefs samp-linear-model)
[-4.1707801647266045 0.39139682427040384]

现在我们已经定义了数据上的线性模型。很明显,并非所有点都会落在表示所构建模型的线条上。每个数据点在y轴上与线性模型的绘图都有一定的偏差,这种偏差可以是正的也可以是负的。为了表示模型与给定数据之间的整体偏差,我们使用残差平方和均方误差均方根误差函数。这三个函数的值代表了对所构建模型中误差量的标量度量。

术语 误差残差 之间的区别在于,误差是衡量观察值与其预期值差异的量度,而残差是对不可观察的统计误差的估计,这简单地没有被我们使用的统计模型所建模或理解。我们可以这样说,在观察值集中,一个观察值与所有值的平均值之间的差异是一个残差。构建模型中的残差数量必须等于样本数据中依赖变量的观察值数量。

我们可以使用 :residuals 关键字从由 linear-model 函数生成的线性模型中获取残差,如下面的代码所示:

user> (:residuals samp-linear-model)
[-0.6873445559690581 0.8253111334125092 -0.6483716931997257 0.2383108767994172 -0.10342541689331242 0.9169220471357067 -0.01701880172457293 -0.22923670489146497 -0.08581465024744239 -0.20933223442208365]

预测均方误差SSE)简单地是构建模型中误差的总和。注意,在以下方程中,误差项 理解单变量线性回归 的符号并不重要,因为我们平方了这个差异值;因此,它总是产生一个正值。SSE 也被称为 残差平方和RSS)。

理解单变量线性回归

linear-model 函数还计算构建模型的 SSE,并且可以使用 :sse 关键字检索此值;以下代码行展示了这一点:

user> (:sse samp-linear-model)
2.5862250345284887

均方误差MSE)衡量了在构建的模型中误差的平均幅度,不考虑误差的方向。我们可以通过平方所有给定依赖变量的值与其在构建的线性模型中的对应预测值的差异,并计算这些平方误差的平均值来计算这个值。MSE 也被称为模型的 均方预测误差。如果一个构建的模型的 MSE 为零,那么我们可以说该模型完美地拟合了给定的数据。当然,这在实际数据中是几乎不可能的,尽管在理论上我们可以找到一组产生零 MSE 的值。

对于依赖变量的给定的一组 N 个值 理解单变量线性回归 和从构建的模型中计算出的估计值 理解单变量线性回归,我们可以正式表示构建模型的 MSE 函数 理解单变量线性回归 如下:

理解单变量线性回归

均方根误差RMSE)或 均方根偏差简单地是 MSE 的平方根,常用于衡量构建的线性模型的偏差。RMSE 对较大误差有偏,因此是尺度相关的。这意味着当不希望有较大误差时,RMSE 特别有用。

我们可以如下正式定义一个公式的模型的均方根误差(RMSE):

理解单变量线性回归

另一个衡量公式的线性模型准确度的指标是确定系数,表示为理解单变量线性回归。确定系数表示公式的模型与给定样本数据拟合得有多好,其定义如下。该系数是根据样本数据中观察值的平均值理解单变量线性回归、SSE 和总误差和理解单变量线性回归来定义的。

理解单变量线性回归

我们可以通过使用linear-model函数生成的模型,并使用:r-square关键字来检索理解单变量线性回归的计算值,如下所示:

user> (:r-square samp-linear-model)
0.8837893226172282

为了制定一个最适合样本数据的模型,我们应该努力最小化之前描述的值。对于某些给定数据,我们可以制定几个模型,并计算每个模型的总体误差。然后,可以使用这个计算出的误差来确定哪个公式的模型最适合数据,从而选择给定数据的最佳线性模型。

根据公式的均方误差(MSE),模型被认为有一个成本函数。在数据上拟合线性模型的问题等价于最小化公式的线性模型成本函数的问题。成本函数,表示为理解单变量线性回归,可以简单地看作是公式模型参数的函数。通常,这个成本函数转化为模型的均方误差。由于 RMSE 随模型的公式参数变化,以下模型的成本函数是这些参数的函数:

理解单变量线性回归

这将我们引向以下关于在数据上拟合线性回归模型问题的正式定义,对于线性模型的估计效应理解单变量线性回归理解单变量线性回归

理解单变量线性回归

该定义指出,我们可以通过确定这些参数的值来估计一个线性模型,这些参数由理解单变量线性回归理解单变量线性回归表示,在这些参数下,成本函数理解单变量线性回归取最小可能值,理想情况下为零。

注意

在前面的公式中,理解单变量线性回归表达式代表成本函数的 N 维欧几里得空间的标准范数。通过“范数”一词,我们指的是在 N 维空间中只有正值的功能。

让我们可视化构建的模型成本函数的欧几里得空间如何随模型参数的变化而变化。为此,我们假设理解单变量线性回归参数,它代表常数误差为零。线性模型在参数理解单变量线性回归上的成本函数理解单变量线性回归的图将理想地呈现为抛物线形状,类似于以下图表:

理解单变量线性回归

对于单个参数理解单变量线性回归,我们可以绘制前面的二维图表。同样,对于两个参数理解单变量线性回归理解单变量线性回归,构建的模型,将产生三维图表。此图表呈现为碗形或具有凸表面的形状,如图所示。此外,我们可以将此推广到构建模型的 N 个参数,并生成理解单变量线性回归维度的图表。

理解单变量线性回归

理解梯度下降

梯度下降算法是构建线性模型的最简单技术之一,尽管它不是最有效的方法,可以使得成本函数或模型误差尽可能小。该算法本质上寻找构建的线性模型成本函数的局部最小值。

如我们之前所述,单变量线性回归模型成本函数的三维图将呈现为凸或碗形的表面,具有全局最小值。通过“最小值”,我们指的是成本函数在图表表面的这一点上具有可能的最小值。梯度下降算法本质上从表面的任何一点开始,执行一系列步骤来接近表面的局部最小值。

这个过程可以想象成把一个球扔进山谷或两个相邻的山丘之间,结果球会慢慢滚向海平面以上最低的点。算法会重复进行,直到从表面当前点的明显成本函数值收敛到零,这比喻地意味着滚下山的球停止了,正如我们之前描述的那样。

当然,如果图表的表面上存在多个局部最小值,梯度下降可能根本不起作用。然而,对于适当缩放的单一变量线性回归模型,图表的表面总是有一个唯一的全局最小值,正如我们之前所展示的。因此,在这种情况下,我们仍然可以使用梯度下降算法来找到图表表面的全局最小值。

这个算法的精髓是从表面上某个点开始,然后朝着最低点迈出几步。我们可以用以下等式正式表示这一点:

理解梯度下降

在这里,我们从成本函数 J 的图表上表示为 理解梯度下降 的点开始,并逐步减去成本函数一阶偏导数 理解梯度下降 的乘积,该偏导数是根据公式的模型参数导出的。这意味着我们缓慢地向表面下方移动,朝着局部最小值前进,直到我们无法在表面上找到更低的点。术语 理解梯度下降 决定了我们朝着局部最小值迈出的步子有多大,被称为梯度下降算法的 步长。我们重复这个迭代过程,直到 理解梯度下降理解梯度下降 之间的差异收敛到零,或者至少减少到接近零的阈值值。

下图展示了向成本函数图表表面局部最小值下降的过程:

理解梯度下降

前面的插图是图表表面的等高线图,其中圆形线条连接了等高点的位置。我们从点 理解梯度下降 开始,执行一次梯度下降算法的迭代,将表面下降到点 理解梯度下降。我们重复这个过程,直到达到相对于初始起始点 理解梯度下降 的表面局部最小值。请注意,通过每次迭代,步长的大小都会减小,因为当接近局部最小值时,该表面的切线斜率也趋于零。

对于误差常数为零的单变量线性回归模型理解梯度下降,我们可以简化梯度下降算法的偏导数组件理解梯度下降。当模型只有一个参数理解梯度下降时,一阶偏导数简单地是该点在图表表面切线的斜率。因此,我们计算这条切线的斜率,并沿着这个斜率方向迈出一步,以便到达一个高于y轴的点。这如下公式所示:

理解梯度下降

我们可以将这个简化的梯度下降算法实现如下:

(def gradient-descent-precision 0.001)

(defn gradient-descent
  "Find the local minimum of the cost function's plot"
  [F' x-start step]
  (loop [x-old x-start]
    (let [x-new (- x-old
                   (* step (F' x-old)))
          dx (- x-new x-old)]
      (if (< dx gradient-descent-precision)
        x-new
        (recur x-new)))))

在前面的函数中,我们从x-start点开始,递归地应用梯度下降算法,直到x-new值收敛。请注意,这个过程是通过使用loop形式实现的尾递归函数。

使用偏导数,我们可以正式表达如何使用梯度下降算法计算参数理解梯度下降理解梯度下降

理解梯度下降

理解多元线性回归

多元线性回归模型可以包含多个变量或特征,这与我们之前研究过的单变量线性回归模型不同。有趣的是,单变量线性模型的定义本身可以通过矩阵扩展来应用于多个变量。

我们可以将我们之前用于预测特定一天降雨概率的例子扩展到包含更多独立变量的多元变量模型中,例如最小和最大温度。因此,多元线性回归模型的训练数据将类似于以下插图:

理解多元线性回归

对于多元线性回归模型,训练数据由两个矩阵定义,XY。在这里,X是一个理解多元线性回归矩阵,其中P是模型中的独立变量数量。矩阵Y是一个长度为N的向量,就像在单变量线性模型中一样。该模型如下所示:

理解多元线性回归

对于以下 Clojure 中的多元线性回归示例,我们不会通过代码生成样本数据,而是使用 Incanter 库中的样本数据。我们可以使用 Incanter 库的get-dataset函数获取任何数据集。

注意

在即将到来的示例中,可以从 Incanter 库中导入selto-matrixget-dataset函数到我们的命名空间中,如下所示:

(ns my-namespace
  (:use [incanter.datasets :only [get-dataset]]
        [incanter.core :only [sel to-matrix]]))

我们可以通过使用带有:iris关键字参数的get-dataset函数来获取Iris数据集;如下所示:

(def iris
  (to-matrix (get-dataset :iris)))

(def X (sel iris :cols (range 1 5)))
(def Y (sel iris :cols 0))

我们首先使用to-matrixget-dataset函数将变量iris定义为矩阵,然后定义两个矩阵XY。在这里,Y实际上是一个包含 150 个值的向量,或者是一个大小为理解多元线性回归的矩阵,而X是一个大小为理解多元线性回归的矩阵。因此,X可以用来表示四个独立变量的值,而Y表示因变量的值。请注意,sel函数用于从iris矩阵中选择一组列。实际上,我们可以从iris数据矩阵中选择更多的此类列,但为了简化起见,在下面的例子中我们只使用四个。

注意

在前面的代码示例中,我们使用的数据集是Iris数据集,该数据集可在 Incanter 库中找到。这个数据集具有相当大的历史意义,因为它被罗纳德·费舍尔爵士首次用于开发线性判别分析LDA)方法进行分类(更多信息请参阅“Iris 中的物种问题”)。该数据集包含三种不同的 Iris 植物物种的 50 个样本,即SetosaVersicolorVirginica。在每个样本中测量这些物种的花朵的四个特征,即花瓣宽度、花瓣长度、萼片宽度和萼片长度。请注意,在本书的后续内容中,我们将多次遇到这个数据集。

有趣的是,linear-model函数接受一个多列矩阵,因此我们可以使用此函数来拟合单变量和多变量数据的线性回归模型,如下所示:

(def iris-linear-model
  (linear-model Y X))
(defn plot-iris-linear-model []
  (let [x (range -100 100)
        y (:fitted iris-linear-model)]
    (view (xy-plot x y :x-label "X" :y-label "Y"))))

(plot-iris-linear-model)

在前面的代码示例中,我们使用xy-plot函数绘制线性模型,同时提供可选参数来指定定义的图中轴的标签。我们还通过使用range函数生成一个向量来指定x轴的范围。plot-iris-linear-model函数生成了以下图形:

理解多元线性回归

尽管从前面示例生成的曲线看起来没有明确的形状,我们仍然可以使用这个生成的模型通过为模型提供独立变量的值来估计或预测因变量的值。为了做到这一点,我们必须首先定义具有多个特征的线性回归模型中因变量和自变量之间的关系。

具有 P 个独立变量的线性回归模型产生理解多元线性回归个回归系数,因为我们除了包括模型的其他系数外,还定义了一个额外的变量理解多元线性回归,该变量总是1

linear-model函数与命题一致,即所构建模型中的系数数量P总是比样本数据中的自变量总数N多一个;这在下述代码中显示:

user> (= (count (:coefs iris-linear-model)) 
         (+ 1 (column-count X)))
true

我们正式表达多元回归模型中因变量和自变量之间的关系如下:

理解多元线性回归

由于变量理解多元线性回归在先前的方程中总是1,因此值理解多元线性回归与单变量线性模型定义中的误差常数理解多元线性回归类似。

我们可以定义一个向量来表示前述方程中所有的系数,即理解多元线性回归。这个向量被称为所构建回归模型的参数向量。此外,模型的独立变量也可以用一个向量表示。因此,我们可以定义回归变量Y为参数向量的转置与模型独立变量向量的乘积:

理解多元线性回归

多项式函数也可以通过将多项式方程中的每个高阶变量替换为一个单一变量来简化为标准形式。例如,考虑以下多项式方程:

理解多元线性回归

我们可以用理解多元线性回归替换理解多元线性回归,将方程简化为多元线性回归模型的标准形式。

这将我们引向以下具有多个变量的线性模型的成本函数的正式定义,这仅仅是单个变量线性模型成本函数定义的扩展:

理解多元线性回归

注意,在先前的定义中,我们可以将模型的各个系数与参数向量理解多元线性回归互换使用。

类似于我们定义的问题,即对给定数据拟合单变量模型,我们可以将构建多变量线性模型的问题定义为最小化先前成本函数的问题:

理解多变量线性回归

多变量梯度下降

我们可以将梯度下降算法应用于寻找具有多个变量的模型局部最小值。当然,由于模型中有多个系数,我们必须对所有的这些系数应用算法,而不是像单变量回归模型中只对两个系数应用算法。

因此,梯度下降算法可以用来找到多变量线性回归模型参数向量多变量梯度下降中所有系数的值,并且形式上定义为以下内容:

多变量梯度下降

在前面的定义中,术语多变量梯度下降简单地指的是构建模型中独立变量的样本值多变量梯度下降。此外,变量多变量梯度下降始终为1。因此,这个定义可以应用于与之前定义的单变量线性回归模型的梯度下降算法相对应的两个系数。

如我们之前所见,梯度下降算法可以应用于具有单变量和多变量的线性回归模型。然而,对于某些模型,梯度下降算法实际上可能需要很多迭代,或者说很多时间,才能收敛到模型系数的估计值。有时,算法也可能发散,因此在这种情况下我们无法计算出模型的系数。让我们来考察一些影响该算法行为和性能的因素:

  • 所有样本数据的特征都必须相互缩放。通过缩放,我们指的是样本数据中独立变量的所有值都取相似的范围。理想情况下,所有独立变量必须观察到介于-11之间的值。这可以形式上表达如下:多变量梯度下降

  • 我们可以将独立变量的观测值相对于这些值的平均值进行归一化。我们还可以通过使用观测值的标准差进一步归一化这些数据。总之,我们用减去这些值的平均值,多个变量的梯度下降,并将结果表达式除以标准差多个变量的梯度下降得到的值来替换这些值。这可以通过以下公式表示:多个变量的梯度下降

  • 步长或学习率,多个变量的梯度下降,是决定算法收敛到模型参数值速度的另一个重要因素。理想情况下,步长率应该选择得使得模型参数的旧迭代值和新迭代值之间的差异在每次迭代中都有最佳的变化量。一方面,如果这个值太大,算法在每次迭代后甚至可能产生模型参数的发散值。因此,在这种情况下,算法将永远找不到全局最小值。另一方面,这个率太小可能会导致算法通过不必要的迭代次数减慢。

理解普通最小二乘法

估计线性回归模型参数向量的另一种技术是普通最小二乘法OLS)。OLS 方法本质上是通过最小化线性回归模型中的平方误差和来工作的。

线性回归模型的预测平方误差和(SSE)可以用模型的实际值和期望值来定义如下:

理解普通最小二乘法

前面的 SSE 定义可以用矩阵乘法进行因式分解如下:

理解普通最小二乘法

我们可以通过使用全局最小值的定义来解前面的方程,以求解估计的参数向量理解普通最小二乘法。由于这个方程是二次方程的形式,并且项理解普通最小二乘法总是大于零,因此成本函数表面的全局最小值可以定义为在该点切线斜率变化率为零的点。此外,该图是线性模型参数的函数,因此表面图的方程应该对估计的参数向量理解普通最小二乘法进行微分。因此,我们可以如下求解所构建模型的最佳参数向量理解普通最小二乘法

理解普通最小二乘法

前面推导中的最后一个等式给出了最优参数向量理解普通最小二乘法的定义,它正式表达如下:

理解普通最小二乘法

我们可以通过使用 core.matrix 库的transposeinverse函数以及 Incanter 库的bind-columns函数来实现先前的参数向量定义的 OLS 方法:

(defn linear-model-ols
  "Estimates the coefficients of a multi-var linear
  regression model using Ordinary Least Squares (OLS) method"
  [MX MY]
  (let [X (bind-columns (repeat (row-count MX) 1) MX)
        Xt (cl/matrix (transpose X))
        Xt-X (cl/* Xt X)]
    (cl/* (inverse Xt-X) Xt MY)))

(def ols-linear-model
  (linear-model-ols X Y))

(def ols-linear-model-coefs
  (cl/as-vec ols-linear-model))

在这里,我们首先添加一个列,其中每个元素都是1,因为矩阵MX的第一列使用bind-columns函数。我们添加的额外列代表独立变量理解普通最小二乘法,其值始终为1。然后我们使用transposeinverse函数计算矩阵MXMY中数据的线性回归模型的估计系数。

注意

对于当前示例,可以将 Incanter 库中的bind-columns函数导入我们的命名空间,如下所示:

(ns my-namespace
  (:use [incanter.core :only [bind-columns]]))

可以将先前定义的函数应用于我们先前定义的矩阵(XY),如下所示:

(def ols-linear-model
  (linear-model-ols X Y))

(def ols-linear-model-coefs
  (cl/as-vec ols-linear-model))

在前面的代码中,ols-linear-model-coefs只是一个变量,而ols-linear-model是一个单列矩阵,它被表示为一个向量。我们使用 clatrix 库中的as-vec函数执行此转换。

实际上,我们可以验证由ols-linear-model函数估计的系数实际上与 Incanter 库的linear-model函数生成的系数相等,如下所示:

user> (cl/as-vec (ols-linear-model X Y))
[1.851198344985435 0.6252788163253274 0.7429244752213087 -0.4044785456588674 -0.22635635488532463]
user> (:coefs iris-linear-model)
[1.851198344985515 0.6252788163253129 0.7429244752213329 -0.40447854565877606 -0.22635635488543926]
user> (every? #(< % 0.0001) 
                      (map - 
                         ols-linear-model-coefs 
                         (:coefs iris-linear-model)))
true

在前面代码示例的最后表达式中,我们找到了由ols-linear-model函数产生的系数之间的差异,由linear-model函数产生的差异,并检查这些差异中的每一个是否小于0.0001

使用线性回归进行预测

一旦我们确定了线性回归模型的系数,我们可以使用这些系数来预测模型因变量的值。预测值由线性回归模型定义为每个系数与其对应自变量值的乘积之和。

我们可以轻松定义以下通用函数,当提供系数和自变量的值时,它预测给定公式的线性回归模型中因变量的值:

(defn predict [coefs X]
  {:pre [(= (count coefs)
            (+ 1 (count X)))]}
  (let [X-with-1 (conj X 1)
        products (map * coefs X-with-1)]
    (reduce + products)))

在前面的函数中,我们使用一个先决条件来断言系数的数量和自变量的值。这个函数期望自变量的值数量比模型的系数数量少一个,因为我们添加了一个额外的参数来表示一个值始终为1的自变量。然后,该函数使用map函数计算相应的系数和自变量值的乘积,然后使用reduce函数计算这些乘积项的总和。

理解正则化

线性回归使用线性方程估计一些给定的训练数据;这种解决方案可能并不总是给定数据的最佳拟合。当然,这很大程度上取决于我们试图建模的问题。正则化是一种常用的技术,用于提供更好的数据拟合。通常,一个给定的模型通过减少模型中一些自变量的影响来进行正则化。或者,我们也可以将其建模为更高阶的多项式。正则化并不局限于线性回归,大多数机器学习算法都使用某种形式的正则化,以便从给定的训练数据中创建更精确的模型。

当一个模型未能估计出与训练数据中依赖变量的观察值接近的值时,我们称其为欠拟合高偏差。另一方面,当一个估计模型完美地拟合数据,但不够通用以至于不能用于预测时,我们也可以称之为过拟合高方差。过拟合模型通常描述的是训练数据中的随机误差或噪声,而不是模型中依赖变量和自变量之间的基本关系。最佳拟合回归模型通常位于欠拟合和过拟合模型之间,可以通过正则化过程获得。

对于欠拟合或过拟合模型的正则化,常用的方法是Tikhonov 正则化。在统计学中,这种方法也称为岭回归。我们可以将 Tikhonov 正则化的通用形式描述如下:

理解正则化

假设A代表从自变量向量x到依赖变量y的映射。值A类似于回归模型的参数向量。向量x与依赖变量的观察值之间的关系,用b表示,可以表达如下。

一个欠拟合模型与实际数据存在显著的误差,或者说偏差。我们应该努力最小化这个误差。这可以形式化地表达如下,并且基于估计模型的残差之和:

理解正则化

Tikhonov 正则化向先前的方程添加了一个惩罚最小二乘项,以防止过拟合,其形式如下:

理解正则化

先前的方程中的术语理解正则化被称为正则化矩阵。在 Tikhonov 正则化的最简单形式中,这个矩阵取值为理解正则化,其中理解正则化是一个常数。尽管将此方程应用于回归模型超出了本书的范围,但我们可以使用 Tikhonov 正则化来生成具有以下成本函数的线性回归模型:

理解正则化

在先前的方程中,术语理解正则化被称为模型的正则化参数。此值必须选择适当,因为此参数的较大值可能会产生欠拟合模型。

使用先前定义的成本函数,我们可以应用梯度下降来确定参数向量,如下所示:

理解正则化

我们也可以将正则化应用于确定参数向量的 OLS 方法,如下所示:

理解正则化

在先前的方程中,L被称为平滑矩阵,可以采用以下形式。请注意,我们在第一章中使用了L定义的后一种形式,即矩阵操作

理解正则化

有趣的是,当先前的方程中的正则化参数理解正则化0时,正则化解简化为使用 OLS 方法得到的原始解。

概述

在本章中,我们学习了线性回归以及一些可以用来从样本数据中构建最优线性回归模型的算法。以下是我们所涵盖的一些其他要点:

  • 我们讨论了单变量和多变量的线性回归

  • 我们实现了梯度下降算法来构建一个单变量线性回归模型

  • 我们实现了普通最小二乘法OLS)来找到最优线性回归模型的系数

  • 我们介绍了正则化及其在线性回归中的应用

在下一章中,我们将研究机器学习的另一个领域,即分类。分类也是一种回归形式,用于将数据分类到不同的类别或组中。

第三章. 数据分类

在本章中,我们将探讨分类,这是监督学习中的另一个有趣问题。我们将检查一些用于分类数据的技术,并研究我们如何在 Clojure 中利用这些技术。

分类可以定义为根据一些经验训练数据识别观测数据的类别或类别的问题。训练数据将包含可观测特征或独立变量的值。它还将包含这些观测值的已知类别。在某种程度上,分类与回归相似,因为我们根据另一组值预测一个值。然而,对于分类,我们感兴趣的不仅仅是观测值的类别,而是基于给定的值集预测一个值。例如,如果我们从一个输出值范围在05的集合中训练一个线性回归模型,训练好的分类器可以预测一组输入值的输出值为10-1。然而,在分类中,输出变量的预测值始终属于一组离散的值。

分类模型的独立变量也被称为模型的解释变量,而因变量也被称为观测值的结果类别类别。分类模型的结果始终是离散值,即来自一组预定的值。这是分类与回归之间的一项主要区别,因为在回归建模中,我们预测的变量可以有一个连续的范围。请注意,在分类的上下文中,“类别”和“类别”这两个术语可以互换使用。

实现分类技术的算法被称为分类器。一个分类器可以正式定义为将一组值映射到类别或类别的函数。分类仍然是计算机科学中的一个活跃研究领域,今天在软件中使用了几个突出的分类器算法。分类有几个实际应用,例如数据挖掘、机器视觉、语音和手写识别、生物分类和地理统计学。

理解二分类和多分类

我们首先将研究一些关于数据分类的理论方面。与其他监督机器学习技术一样,目标是根据样本数据估计一个模型或分类器,并使用它来预测一组给定的结果。分类可以被视为确定一个函数,该函数将样本数据的特征映射到特定的类别。预测的类别是从一组预定的类别中选择出来的。因此,与回归类似,对给定独立变量的观测值进行分类的问题与确定给定训练数据的最优拟合函数是相似的。

在某些情况下,我们可能只对单个类别感兴趣,即观测值是否属于特定类别。这种分类形式被称为二进制分类,模型的输出变量可以是01的值。因此,我们可以说理解二进制和多类分类,其中y是分类模型的输出或因变量。当理解二进制和多类分类时,结果被认为是负的,反之,当理解二进制和多类分类时,结果被认为是正的。

从这个角度来看,当提供了模型独立变量的某些观测值时,我们必须能够确定正结果的概率。因此,给定样本数据的估计模型具有理解二进制和多类分类的概率,可以表示如下:

理解二进制和多类分类

在前面的方程中,参数理解二进制和多类分类代表估计分类模型理解二进制和多类分类的独立变量,而项理解二进制和多类分类代表该模型的估计参数向量。

这种分类的一个例子是决定一封新邮件是否为垃圾邮件,这取决于发件人或邮件中的内容。另一个简单的二进制分类例子是根据某一天的观测湿度以及当天的最低和最高温度来确定该日降雨的可能性。这个例子中的训练数据可能类似于以下表格中的数据:

理解二进制和多类分类

我们可以使用来建模二进制分类的数学函数是sigmoidlogistic函数。如果特征X的输出有一个估计的参数向量理解二进制和多类分类,我们可以定义正结果的估计概率Y(作为 sigmoid 函数)如下:

理解二进制和多类分类

为了可视化前面的方程,我们可以通过将Z替换为理解二进制和多类分类来简化它,如下所示:

理解二进制和多类分类

我们还可以使用其他几个函数来模拟数据。然而,二分类器的样本数据可以很容易地转换,使其可以用 S 型函数进行模拟。使用逻辑函数对分类问题进行建模的过程称为逻辑回归。前面方程中定义的简化 S 型函数产生了以下图表:

理解二分类和多分类

注意,如果术语Z具有负值,则图表会反转,并且是前一个图表的镜像。我们可以通过以下图表可视化 S 型函数相对于术语Z的变化:

理解二分类和多分类

在前面的图表中,展示了不同值下的 S 型函数理解二分类和多分类;其范围从-55。请注意,对于二维情况,术语理解二分类和多分类是独立变量x的线性函数。有趣的是,对于理解二分类和多分类理解二分类和多分类,S 型函数看起来或多或少像一条直线。当理解二分类和多分类时,该函数简化为一条直线,可以用常数y值(在这种情况下,方程理解二分类和多分类)表示。

我们观察到,估计的输出Y始终在01之间,因为它代表给定观察值的正结果概率。此外,输出Y的范围不受术语理解二分类和多分类符号的影响。因此,回顾起来,S 型函数是二分类的有效表示。

要使用逻辑函数从训练数据估计分类模型,我们可以将逻辑回归模型的成本函数定义为以下:

理解二分类和多分类

前面的方程本质上总结了模型输出变量实际值和预测值之间的差异,就像线性回归一样。然而,由于我们处理的是介于01之间的概率值,我们使用前面的对数函数来有效地衡量实际值和预测输出值之间的差异。请注意,术语N表示训练数据中的样本数量。我们可以将梯度下降应用于此成本函数,以确定一组观察值的局部最小值或预测类别。此方程可以正则化,产生以下成本函数:

理解二分类和多类分类

注意,在这个公式中,第二个求和项被添加为一个正则化项,就像我们在第二章中讨论的那样,理解线性回归。这个项基本上防止了估计模型在样本数据上的欠拟合和过拟合。注意,项 理解二分类和多类分类 是正则化参数,必须根据我们希望模型有多准确来适当选择。

多类分类,这是分类的另一种形式,将分类结果预测为特定预定义值集中的值。因此,结果是从 k 个离散值中选择,即 理解二分类和多类分类。该模型为每个可能的观察值类别产生 k 个概率。这使我们得到了多类分类的以下正式定义:

理解二分类和多类分类

因此,在多类分类中,我们预测 k 个不同的值,其中每个值表示输入值属于特定类别的概率。有趣的是,二分类可以被视为多类分类的一种特殊情况,其中只有两个可能的类别,即 理解二分类和多类分类理解二分类和多类分类

作为多类分类的一个特殊情况,我们可以说,具有最大概率的类别是结果,或者简单地说,给定观察值集合的预测类别。这种多类分类的特殊化称为一对多分类。在这里,从给定的观察值集合中确定具有最大(或最小)发生概率的单个类别,而不是找到我们模型中所有可能类别的发生概率。因此,如果我们打算从特定类别集合中预测单个类别,我们可以定义结果 C 如下:

理解二分类和多类分类

例如,假设我们想要确定一个鱼包装厂的分类模型。在这种情况下,鱼被分为两个不同的类别。比如说,我们可以将鱼分类为海鲈鱼或三文鱼。我们可以通过选择足够大的鱼样本并分析它们在某些选定特征上的分布来为我们的模型创建一些训练数据。比如说,我们已经确定了两个特征来分类数据,即鱼的长度和皮肤的亮度。

第一特征,即鱼的长度的分布可以如下可视化:

理解二分类和多分类

同样,样本数据中鱼皮光亮度的分布可以通过以下图来可视化:

理解二分类和多分类

从前面的图中,我们可以看出,仅指定鱼的长度的信息不足以确定其类型。因此,这个特征在分类模型中的系数较小。相反,由于鱼皮的光亮度在确定鱼类型方面起着更大的作用,这个特征将在估计的分类模型的参数向量中具有更大的系数。

一旦我们建模了一个给定的分类问题,我们可以将训练数据划分为两个(或更多)集合。在向量空间中分割这两个集合的表面被称为所制定分类模型的决策边界。决策边界一侧的所有点属于一个类别,而另一侧的点属于另一个类别。一个明显的推论是,根据不同类别的数量,一个给定的分类模型可以有几个这样的决策边界。

我们现在可以将这两个特征结合起来训练我们的模型,这会产生两个鱼类别之间的估计决策边界。这个边界可以在训练数据的散点图上如下可视化:

理解二分类和多分类

在前面的图中,我们通过使用直线来近似分类模型,因此,我们有效地将分类建模为线性函数。我们也可以将数据建模为多项式函数,因为它会产生更准确的分类模型。这样的模型产生的决策边界可以如下可视化:

理解二分类和多分类

决策边界将样本数据划分为两个维度,如图中所示。当样本数据具有更多特征或维度时,决策边界将变得更加复杂,难以可视化。例如,对于三个特征,决策边界将是一个三维表面,如图中所示。请注意,为了清晰起见,未显示样本数据点。此外,假设图中绘制的两个特征在理解二分类和多分类范围内变化,第三个特征在理解二分类和多分类范围内变化。

理解二分类和多分类

理解贝叶斯分类

我们现在将探讨用于分类数据的贝叶斯技术。贝叶斯 分类器本质上是一种基于贝叶斯条件概率定理构建的概率分类器。基于贝叶斯分类的模型假设样本数据具有高度独立的特征。这里的“独立”意味着模型中的每个特征可以独立于模型中的其他特征变化。换句话说,模型的特征是互斥的。因此,贝叶斯分类器假设特定特征的呈现与否完全独立于分类模型中其他特征的呈现与否。

术语 理解贝叶斯分类 用于表示条件或特征 A 发生的概率。其值始终是介于 01 之间的分数值,包括两端。它也可以表示为百分比值。例如,概率 0.5 也可以写作 50%50 percent。假设我们想要找到从给定样本中发生特征 A理解贝叶斯分类 的概率。因此,理解贝叶斯分类 的值越高,特征 A 发生的可能性就越高。我们可以将概率 理解贝叶斯分类 正式表示如下:

理解贝叶斯分类

如果 AB 是我们分类模型中的两个条件或特征,那么我们使用术语 理解贝叶斯分类 来表示当已知 B 已经发生时 A 的发生。这个值被称为 AB 条件下的 条件概率,术语 理解贝叶斯分类 也可以读作 AB 条件下的概率。在术语 理解贝叶斯分类 中,B 也被称为 A 的证据。在条件概率中,两个事件 AB 可能相互独立,也可能不独立。然而,如果 AB 确实是相互独立的条件,那么概率 理解贝叶斯分类 等于 AB 单独发生概率的乘积。我们可以将这个公理表达如下:

理解贝叶斯分类

贝叶斯定理描述了条件概率理解贝叶斯分类理解贝叶斯分类与概率理解贝叶斯分类理解贝叶斯分类之间的关系。它使用以下等式正式表达:

理解贝叶斯分类

当然,为了使前面的关系成立,概率理解贝叶斯分类理解贝叶斯分类都必须大于0

让我们重新回顾一下我们之前描述的鱼包装厂的分类示例。问题是,我们需要根据鱼的外部特征来确定它是不是海鲈鱼还是三文鱼。现在,我们将使用贝叶斯分类器来实现这个问题的解决方案。然后,我们将使用贝叶斯定理来建模我们的数据。

假设每种鱼类都有三个独立且不同的特征,即皮肤的光泽度、长度和宽度。因此,我们的训练数据将类似于以下表格:

理解贝叶斯分类

为了简化实现,让我们使用 Clojure 符号来表示这些特征。我们需要首先生成以下数据:

(defn make-sea-bass []
  ;; sea bass are mostly long and light in color
  #{:sea-bass
    (if (< (rand) 0.2) :fat :thin)
    (if (< (rand) 0.7) :long :short)
    (if (< (rand) 0.8) :light :dark)})

(defn make-salmon []
  ;; salmon are mostly fat and dark
  #{:salmon
    (if (< (rand) 0.8) :fat :thin)
    (if (< (rand) 0.5) :long :short)
    (if (< (rand) 0.3) :light :dark)})

(defn make-sample-fish []
  (if (< (rand) 0.3) (make-sea-bass) (make-salmon)))

(def fish-training-data
  (for [i (range 10000)] (make-sample-fish)))

在这里,我们定义了两个函数,make-sea-bassmake-salmon,以创建一组符号来表示两种鱼类的类别。我们方便地使用:salmon:sea-bass关键字来表示这两个类别。同样,我们也可以使用 Clojure 关键字来枚举鱼的特征。在这个例子中,皮肤的光泽度是:light:dark,长度是:long:short,宽度是:fat:thin。此外,我们定义了make-sample-fish函数来随机创建一个由之前定义的特征集表示的鱼。

注意,我们定义这两种鱼类的类别,使得海鲈鱼通常较长且皮肤颜色较浅,而三文鱼通常较肥且颜色较深。此外,我们在make-sample-fish函数中生成了更多的三文鱼而不是海鲈鱼。我们只在数据中添加这种偏差,以提供更具说明性的结果,并鼓励读者尝试更真实的数据分布。在第二章中介绍的 Incanter 库中可用的Iris数据集,是可用于研究分类的真实世界数据集的例子。

现在,我们将实现以下函数来计算特定条件的概率:

(defn probability
  "Calculates the probability of a specific category
   given some attributes, depending on the training data."
  [attribute & {:keys
                [category prior-positive prior-negative data]
                :or {category nil
                     data fish-training-data}}]
  (let [by-category (if category
                      (filter category data)
                      data)
        positive (count (filter attribute by-category))
        negative (- (count by-category) positive)
        total (+ positive negative)]
    (/ positive negative)))

我们实际上通过出现次数的基本定义来实现概率。

在前一段代码中定义的probability函数需要一个参数来表示我们想要计算其发生概率的属性或条件。此外,该函数还接受几个可选参数,例如用于计算此值的fish-training-data序列,默认为我们之前定义的,以及一个类别,这可以简单地理解为另一个条件。参数categoryattribute实际上与理解贝叶斯分类中的条件AB相对应。probability函数通过使用filter函数过滤训练数据来确定条件的总积极发生次数。然后,它通过计算样本数据中由(count by-category)表示的积极值和总值的差来确定消极发生次数。最后,该函数返回条件积极发生次数与给定数据中总发生次数的比率。

让我们使用probability函数来了解我们的训练数据如下:

user> (probability :dark :category :salmon)
1204/1733
user> (probability :dark :category :sea-bass)
621/3068
user> (probability :light :category :salmon)
529/1733
user> (probability :light :category :sea-bass)
2447/3068

如前述代码所示,三文鱼外观为暗色的概率很高,具体为1204/1733。与海鲈鱼为暗色和三文鱼为亮色的概率相比,海鲈鱼为亮色和三文鱼为暗色的概率也较低。

假设我们对鱼的特征的观察值是它皮肤暗,长,且胖。在这些条件下,我们需要将鱼分类为海鲈鱼或三文鱼。从概率的角度来看,我们需要确定在鱼是暗、长、胖的情况下,鱼是三文鱼或海鲈鱼的概率。正式来说,这个概率由理解贝叶斯分类理解贝叶斯分类这两个术语表示,针对鱼的任何一个类别。如果我们计算这两个概率,我们可以选择这两个概率中较高的类别来确定鱼的类别。

使用贝叶斯定理,我们定义术语理解贝叶斯分类理解贝叶斯分类如下:

理解贝叶斯分类理解贝叶斯分类

术语理解贝叶斯分类理解贝叶斯分类可能有点令人困惑,但这两个术语之间的区别在于指定条件的出现顺序。术语理解贝叶斯分类表示深色、长而胖的鱼是三文鱼的概率,而术语理解贝叶斯分类表示三文鱼是深色、长而胖的鱼的概率。

理解贝叶斯分类概率可以从给定的训练数据中计算如下。由于假设鱼的三个特征是相互独立的,因此术语理解贝叶斯分类仅仅是每个单个特征发生概率的乘积。相互独立意味着这些特征的方差或分布不依赖于分类模型中的任何其他特征。

术语理解贝叶斯分类也称为给定类别的证据,在本例中是类别“三文鱼”。我们可以将理解贝叶斯分类概率表示为模型独立特征的概率乘积;如下所示:

理解贝叶斯分类

有趣的是,术语理解贝叶斯分类理解贝叶斯分类理解贝叶斯分类可以很容易地从训练数据和之前实现的probability函数中计算出来。同样,我们可以找到鱼是三文鱼或理解贝叶斯分类的概率。因此,在理解贝叶斯分类的定义中,唯一没有考虑到的术语是理解贝叶斯分类。实际上,我们可以使用概率中的一个简单技巧来完全避免计算这个术语。

由于鱼是深色、长而胖的,它可能是三文鱼或海鲈鱼。这两种鱼类的发生概率都是互补的,也就是说,它们共同解释了我们模型中可能出现的所有条件。换句话说,这两个概率的总和为1。因此,我们可以正式地表达术语,理解贝叶斯分类,如下所示:

理解贝叶斯分类

上述等式右侧的两个术语都可以从训练数据中确定,这些术语类似于 理解贝叶斯分类理解贝叶斯分类 以及等等。因此,我们可以直接从我们的训练数据中计算出 理解贝叶斯分类 概率。我们通过以下等式来表示这个概率:

理解贝叶斯分类

现在,让我们使用训练数据和之前定义的 probability 函数来实现前面的等式。首先,给定鱼的外观是深色、长和胖,鱼是鲑鱼的证据可以表示如下:

(defn evidence-of-salmon [& attrs]
  (let [attr-probs (map #(probability % :category :salmon) attrs)
        class-and-attr-prob (conj attr-probs
                                  (probability :salmon))]
    (float (apply * class-and-attr-prob))))

为了明确起见,我们实现一个函数来计算从给定训练数据中术语 理解贝叶斯分类 的概率。术语 理解贝叶斯分类理解贝叶斯分类理解贝叶斯分类理解贝叶斯分类 的等式将作为此实现的基线。

在前面的代码中,我们使用 probability 函数确定 i 的所有属性或条件的术语 理解贝叶斯分类理解贝叶斯分类。然后,我们使用 apply* 函数的组合乘以所有这些术语。由于所有计算出的概率都是 probability 函数返回的比率,我们使用 float 函数将最终比率转换为浮点值。我们可以在 REPL 中尝试此函数如下:

user> (evidence-of-salmon :dark)
0.4816
user> (evidence-of-salmon :dark :long)
0.2396884
user> (evidence-of-salmon)
0.6932

如 REPL 输出所示,训练数据中所有鱼中有 48.16% 的鱼是皮肤较暗的鲑鱼。同样,所有鱼中有 23.96% 的鱼是深色长鲑鱼,而所有鱼中有 69.32% 的鱼是鲑鱼。(evidence-of-salmon :dark :long) 调用返回的值可以表示为 理解贝叶斯分类,同样,(evidence-of-salmon) 也返回 理解贝叶斯分类

类似地,我们可以定义一个evidence-of-sea-bass函数,该函数根据鱼的一些观察到的特征来确定海鲈鱼出现的证据。由于我们只处理两个类别,理解贝叶斯分类,我们可以在 REPL 中轻松验证这个等式。有趣的是,观察到一个小错误,但这个错误与训练数据无关。实际上,这个小错误是一个浮点数舍入错误,这是由于浮点数的限制造成的。在实践中,我们可以使用十进制或BigDecimal(来自java.lang)数据类型来避免这种情况,而不是使用浮点数。我们可以使用 REPL 中的evidence-of-sea-bassevidence-of-salmon函数来验证这一点,如下所示:

user> (+ (evidence-of-sea-bass) (evidence-of-salmon))
1.0000000298023224

我们可以将evidence-of-salmonevidence-of-sea-bass函数泛化,以便我们能够根据一些观察特征确定任何类别的概率;以下代码展示了这一点:

(defn evidence-of-category-with-attrs
  [category & attrs]
  (let [attr-probs (map #(probability % :category category) attrs)
        class-and-attr-prob (conj attr-probs
                                  (probability category))]
    (float (apply * class-and-attr-prob))))

前面代码中定义的函数返回的值与以下evidence-of-salmonevidence-of-sea-bass函数返回的值一致:

user> (evidence-of-salmon :dark :fat)
0.38502988
user> (evidence-of-category-with-attrs :salmon :dark :fat)
0.38502988

使用evidence-of-salmonevidence-of-sea-bass函数,我们可以按照以下方式计算以probability-dark-long-fat-is-salmon为单位的概率:

(def probability-dark-long-fat-is-salmon
  (let [attrs [:dark :long :fat]
        sea-bass? (apply evidence-of-sea-bass attrs)
        salmon? (apply evidence-of-salmon attrs)]
    (/ salmon?
       (+ sea-bass? salmon?))))

我们可以在 REPL 中检查probability-dark-long-fat-is-salmon值,如下所示:

user> probability-dark-long-fat-is-salmon
0.957091799207812

probability-dark-long-fat-is-salmon值表明,一条深色、长而胖的鱼有 95.7%的概率是鲑鱼。

使用前面定义的probability-dark-long-fat-is-salmon函数作为模板,我们可以泛化它所执行的计算。让我们首先定义一个简单的数据结构,它可以被传递。在 Clojure 的惯用风格中,我们方便地使用映射来完成这个目的。使用映射,我们可以在我们的模型中表示一个类别及其出现的证据和概率。此外,给定几个类别的证据,我们可以计算特定类别出现的总概率,如下面的代码所示:

(defn make-category-probability-pair
  [category attrs]
  (let [evidence-of-category (apply
  evidence-of-category-with-attrs
                              category attrs)]
    {:category category
     :evidence evidence-of-category}))

(defn calculate-probability-of-category
  [sum-of-evidences pair]
  (let [probability-of-category (/ (:evidence pair)
                                   sum-of-evidences)]
    (assoc pair :probability probability-of-category)))

make-category-probability-pair函数使用我们在前面代码中定义的evidence-category-with-attrs函数来计算类别的证据及其条件或属性。然后,它以映射的形式返回这个值,以及类别本身。此外,我们还定义了calculate-probability-of-category函数,该函数使用sum-of-evidences参数和make-category-probability-pair函数返回的值来计算类别及其条件的总概率。

我们可以将前面两个函数组合起来,确定给定一些观察值的所有类别的总概率,然后选择概率最高的类别,如下所示:

(defn classify-by-attrs
  "Performs Bayesian classification of the attributes,
   given some categories.
   Returns a map containing the predicted category and
   the category's
   probability of occurrence."
  [categories & attrs]
  (let [pairs (map #(make-category-probability-pair % attrs)
                   categories)
        sum-of-evidences (reduce + (map :evidence pairs))
        probabilities (map #(calculate-probability-of-category
                              sum-of-evidences %)
                           pairs)
        sorted-probabilities (sort-by :probability probabilities)
        predicted-category (last sorted-probabilities)]
    predicted-category))

前述代码中定义的classify-by-attrs函数将所有可能的类别映射到make-category-probability-pair函数,给定一些条件或我们模型特征的观察值。由于我们处理的是make-category-probability-pair返回的序列对,我们可以使用reducemap+函数的简单组合来计算此序列中所有证据的总和。然后,我们将calculate-probability-of-category函数映射到类别-证据对的序列,并选择概率最高的类别-证据对。我们通过按概率升序排序序列来实现这一点,并选择排序序列中的最后一个元素。

现在,我们可以使用classify-by-attrs函数来确定一个观察到的鱼(外观为暗、长、胖)是鲑鱼的概率。它也由我们之前定义的probability-dark-long-fat-is-salmon值表示。这两个表达式都产生了相同的概率,即 95.7%,表示外观为暗、长、胖的鱼是鲑鱼。我们将在以下代码中实现classify-by-attrs函数:

user> (classify-by-attrs [:salmon :sea-bass] :dark :long :fat)
{:probability 0.957091799207812, :category :salmon, :evidence 0.1949689}
user> probability-dark-long-fat-is-salmon
0.957091799207812

classify-by-attrs函数还返回给定观察条件:dark:long:fat的预测类别(即:salmon)。我们可以使用此函数来了解更多关于训练数据的信息,如下所示:

user> (classify-by-attrs [:salmon :sea-bass] :dark)
{:probability 0.8857825967670728, :category :salmon, :evidence 0.4816}
user> (classify-by-attrs [:salmon :sea-bass] :light)
{:probability 0.5362699908806723, :category :sea-bass, :evidence 0.2447}
user> (classify-by-attrs [:salmon :sea-bass] :thin)
{:probability 0.6369809383442954, :category :sea-bass, :evidence 0.2439}

如前述代码所示,外观为暗的鱼主要是鲑鱼,外观为亮的鱼主要是海鲈鱼。此外,体型瘦的鱼很可能是海鲈鱼。以下值实际上与我们之前定义的训练数据相符:

user> (classify-by-attrs [:salmon] :dark)
{:probability 1.0, :category :salmon, :evidence 0.4816}
user> (classify-by-attrs [:salmon])
{:probability 1.0, :category :salmon, :evidence 0.6932}

注意,仅使用[:salmon]作为参数调用classify-by-attrs函数会返回任何给定鱼是鲑鱼的概率。一个明显的推论是,给定一个单一类别,classify-by-attrs函数总是以完全的确定性预测提供的类别,即概率为1.0。然而,该函数返回的证据取决于传递给它的观察特征以及我们用来训练模型的样本数据。

简而言之,前面的实现描述了一个可以使用一些样本数据进行训练的贝叶斯分类器。它还分类了我们的模型特征的某些观察值。

我们可以通过构建我们之前示例中理解贝叶斯分类概率的定义来描述一个通用的贝叶斯分类器。为了快速回顾,理解贝叶斯分类这个术语可以正式表达如下:

理解贝叶斯分类

在前一个等式中,我们处理一个单一类别,即鲑鱼,以及三个相互独立特征,即鱼皮的长度、宽度和亮度。我们可以将这个等式推广到 N 个特征,如下所示:

理解贝叶斯分类

在这里,术语 Z 是分类模型的证据,我们在前一个方程中进行了描述。我们可以使用求和和乘积符号来更简洁地描述前一个等式,如下所示:

理解贝叶斯分类

前一个等式描述了单个类别 C 的发生概率。如果我们给定多个类别可供选择,我们必须选择发生概率最高的类别。这引出了贝叶斯分类器的基本定义,其形式表达如下:

理解贝叶斯分类

在前一个方程中,函数 理解贝叶斯分类 描述了一个贝叶斯分类器,它选择发生概率最高的类别。请注意,术语 理解贝叶斯分类 代表我们分类模型的各个特征,而术语 理解贝叶斯分类 代表这些特征的观测值集合。此外,方程右侧的变量 c 可以取分类模型中所有不同类别的值。

我们可以通过 最大后验概率 (MAP) 估计进一步简化贝叶斯分类器的先前的方程,这可以被视为贝叶斯统计中特征的正规化。简化的贝叶斯分类器可以形式表达如下:

理解贝叶斯分类

这个定义本质上意味着 classify 函数确定给定特征的最高发生概率的类别。因此,前一个方程描述了一个可以使用一些样本数据进行训练,然后用于预测给定观测值集合类别的贝叶斯分类器。我们现在将专注于使用现有的贝叶斯分类器实现来建模给定的分类问题。

clj-ml库(github.com/joshuaeckroth/clj-ml)包含几个实现算法,我们可以从中选择来模拟给定的分类问题。实际上,这个库只是流行的Weka库(www.cs.waikato.ac.nz/ml/weka/)的 Clojure 包装器,Weka 是一个包含多个机器学习算法实现的 Java 库。它还有几个用于评估和验证生成的分类模型的方法。然而,我们将专注于本章中clj-ml库的分类器实现。

备注

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

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

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

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

现在,我们将通过一个贝叶斯分类器的实现来介绍clj-ml库,以模拟我们之前涉及鱼包装厂的问题。首先,让我们精炼我们的训练数据,使用数值而不是我们之前描述的关键字来表示模型的各种特征。当然,我们将在训练数据中保持部分性,使得鲑鱼大多是肥的和深色的,而海鲈鱼大多是长的和浅色的。以下代码实现了这一点:

(defn rand-in-range
  "Generates a random integer within the given range"
  [min max]
  (let [len      (- max min)
        rand-len (rand-int len)]
    (+ min rand-len)))

;; sea bass are mostly long and light in color
(defn make-sea-bass []
  (vector :sea-bass
          (rand-in-range 6 10)          ; length
          (rand-in-range 0 5)           ; width
          (rand-in-range 4 10)))        ; lightness of skin

;; salmon are mostly fat and dark
(defn make-salmon []
  (vector :salmon
          (rand-in-range 0 7)           ; length
          (rand-in-range 4 10)          ; width
          (rand-in-range 0 6)))         ; lightness of skin

在这里,我们定义了rand-in-range函数,该函数简单地生成给定值范围内的随机整数。然后我们重新定义了make-sea-bassmake-salmon函数,使用rand-in-range函数来生成鱼的三个特征(长度、宽度和皮肤颜色深浅)的值,这些值在010之间。皮肤颜色较浅的鱼,这个特征的值会更高。请注意,我们重用了make-sample-fish函数的定义和fish-dataset变量的定义来生成我们的训练数据。此外,鱼是由一个向量而不是一个集合来表示的,正如在make-sea-bassmake-salmon函数的早期定义中所述。

我们可以使用clj-ml库中的make-classifier函数创建一个分类器,该函数位于clj-ml.classifiers命名空间中。我们可以通过将两个关键字作为参数传递给函数来指定要使用的分类器类型。由于我们打算使用贝叶斯分类器,我们将关键字:bayes:naive传递给make-classifier函数。简而言之,我们可以使用以下声明来创建一个贝叶斯分类器。请注意,以下代码中使用的关键字:naive表示一个朴素贝叶斯分类器,它假设我们模型中的特征是独立的:

(def bayes-classifier (make-classifier :bayes :naive))

clj-ml 库的分类器实现使用通过 clj-ml.data 命名空间中的函数定义或生成的数据集。我们可以使用 make-dataset 函数将 fish-dataset 序列(一个向量序列)转换为这样的数据集。此函数需要一个数据集的任意字符串名称、每个集合项的模板以及要添加到数据集中的项目集合。提供给 make-dataset 函数的模板很容易用映射表示,如下所示:

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

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

在前面的代码中定义的 fish-template 映射只是简单地说明,作为一个向量表示的鱼,由鱼的类别、长度、宽度和皮肤的亮度组成,顺序如下。请注意,鱼的类别是用 :salmon:sea-bass 描述的。现在我们可以使用 fish-dataset 来训练由 bayes-classifier 变量表示的分类器。

虽然该 fish-template 映射定义了鱼的全部属性,但它仍然缺少一个重要的细节。它没有指定这些属性中哪一个代表鱼的类别或分类。为了指定向量中的一个特定属性以代表整个观察值集合的类别,我们使用 dataset-set-class 函数。此函数接受一个参数,指定属性的索引,并用于在向量中代表观察值集合的类别。请注意,此函数实际上会修改或修改它提供的数据集。然后我们可以使用 classifier-train 函数来训练我们的分类器,该函数接受一个分类器和数据集作为参数;如下代码所示:

(defn train-bayes-classifier []
  (dataset-set-class fish-dataset 0)
  (classifier-train bayes-classifier fish-dataset))

前面的 train-bayes-classifier 函数只是调用了 dataset-set-classclassifier-train 函数来训练我们的分类器。当我们调用 train-bayes-classifier 函数时,分类器会使用以下提供的数据进行训练,然后打印到 REPL 输出:

user> (train-bayes-classifier)
#<NaiveBayes Naive Bayes Classifier

                     Class
Attribute        salmon  sea-bass
                  (0.7)    (0.3)
=================================
length
  mean            2.9791   7.5007
  std. dev.       1.9897   1.1264
  weight sum        7032     2968
  precision            1        1

width
  mean            6.4822   1.9747
  std. dev.        1.706    1.405
  weight sum        7032     2968
  precision            1        1

lightness
  mean            2.5146   6.4643
  std. dev.       1.7047   1.7204
  weight sum        7032     2968
  precision            1        1

>

此输出为我们提供了关于训练数据的一些基本信息,例如我们模型中各种特征的均值和标准差。现在我们可以使用这个训练好的分类器来预测我们模型特征的观察值的类别。

让我们先定义我们打算分类的观察值。为此,我们使用以下 make-instance 函数,该函数需要一个数据集和一个与提供的数据集数据模板相匹配的观察值向量:

(def sample-fish
  (make-instance fish-dataset [:salmon 5.0 6.0 3.0]))

在这里,我们只是使用 make-instance 函数定义了一个样本鱼。现在我们可以如下预测由 sample-fish 表示的鱼的类别:

user> (classifier-classify bayes-classifier sample-fish)
:salmon

如前述代码所示,鱼被分类为salmon。请注意,尽管我们在定义sample-fish时提供了鱼的类别为:salmon,但这只是为了符合fish-dataset定义的数据模板。实际上,我们可以将sample-fish的类别指定为:sea-bass或第三个值,例如:unknown,以表示一个未定义的值,分类器仍然会将sample-fish分类为salmon

当处理给定分类模型的各种特征的连续值时,我们可以指定一个贝叶斯分类器来使用连续特征的离散化。这意味着模型的各种特征的值将通过概率密度估计转换为离散值。我们可以通过简单地向make-classifier函数传递一个额外的参数{:supervised-discretization true}来指定此选项。这个映射实际上描述了可以提供给指定分类器的所有可能选项。

总之,clj-ml库提供了一个完全可操作的贝叶斯分类器,我们可以用它来对任意数据进行分类。尽管我们在前面的例子中自己生成了训练数据,但这些数据也可以从网络或数据库中获取。

使用 k-最近邻算法

一种可以用来对一组观测值进行分类的简单技术是k-最近邻(简称k-NN)算法。这是一种懒惰学习的形式,其中所有计算都推迟到分类阶段。在分类阶段,k-NN 算法仅使用训练数据中的少数几个值来近似观测值的类别,其他值的读取则推迟到实际需要时。

虽然我们现在在分类的背景下探索 k-NN 算法,但它也可以通过简单地选择预测值作为一组观测特征值的依赖变量的最近值的平均值来应用于回归。有趣的是,这种建模回归的技术实际上是对线性插值(更多信息,请参阅An introduction to kernel and nearest-neighbor nonparametric regression)的推广。

k-NN 算法读取一些训练数据,并懒惰地分析这些数据,也就是说,只有在需要时才会分析。除了训练数据外,该算法还需要一组观测值和一个常数k作为参数来对观测值集进行分类。为了对这些观测值进行分类,算法预测与观测值集最近的k个训练样本中最频繁的类别。这里的“最近”是指,在训练数据的欧几里得空间中,代表观测值集的点与具有最小欧几里得距离的点。

一个明显的推论是,当使用 k 最近邻算法时,预测的类别是观察值集合最近的单个邻居的类别。k-NN 算法的这个特殊情况被称为最近邻算法。

我们可以使用clj-ml库的make-classifier函数创建一个使用 k-NN 算法的分类器。这样的分类器通过将:lazy:ibk作为make-classifier函数的参数来指定。我们现在将使用这样的分类器来模拟我们之前的鱼包装厂示例,如下所示:

(def K1-classifier (make-classifier :lazy :ibk))

(defn train-K1-classifier []
  (dataset-set-class fish-dataset 0)
  (classifier-train K1-classifier fish-dataset))

前述代码定义了一个 k-NN 分类器为K1-classifier,以及一个train-K1-classifier函数,使用fish-dataset(我们在前述代码中定义)来训练分类器。

注意,make-classifier函数默认将常数k或更确切地说,邻居的数量设置为1,这意味着单个最近邻。我们可以选择性地通过将:num-neighbors键作为键值对传递给make-classifier函数来指定常数k,如下述代码所示:

(def K10-classifier (make-classifier
                     :lazy :ibk {:num-neighbors 10}))

我们现在可以调用train-K1-classifier函数来按照以下方式训练分类器:

user> (train-K1-classifier)
#<IBk IB1 instance-based classifier
using 1 nearest neighbour(s) for classification
>

我们现在可以使用classifier-classify函数来对之前定义的sample-fish表示的鱼进行分类,使用的是由K1-classifier变量表示的分类器:

user> (classifier-classify K1-classifier sample-fish)
:salmon

如前述代码所示,k-NN 分类器预测鱼类为鲑鱼,这与我们之前使用贝叶斯分类器做出的预测一致。总之,clj-ml库提供了一个简洁的实现,使用 k-NN 算法来预测一组观察值的类别。

clj-ml库提供的 k-NN 分类器默认使用这些特征的值均值和标准差对分类模型的特征进行归一化。我们可以通过传递一个包含:no-normalization键的映射条目到make-classifier函数的选项映射中,来指定一个选项给make-classifier函数以跳过这个归一化阶段。

使用决策树

我们还可以使用决策树来建模给定的分类问题。实际上,决策树是从给定的训练数据构建的,我们可以使用这个决策树来预测一组给定观察值的类别。构建决策树的过程大致基于信息论中的信息熵和信息增益的概念(更多信息,请参阅决策树归纳)。它也常被称为决策树学习。不幸的是,对信息论进行详细研究超出了本书的范围。然而,在本节中,我们将探讨一些将在机器学习环境中使用的信息论概念。

决策树是一种树或图,描述了决策及其可能后果的模型。决策树中的一个内部节点代表一个决策,或者更确切地说,在分类的上下文中,代表特定特征的某种条件。它有两个可能的结果,分别由节点的左右子树表示。当然,决策树中的节点也可能有超过两个的子树。决策树中的每个叶节点代表我们分类模型中的一个特定类别或后果。

例如,我们之前的涉及鱼包装工厂的分类问题可能有以下决策树:

使用决策树

之前展示的决策树使用两个条件来分类鱼是鲑鱼还是海鲢。内部节点代表基于我们分类模型特征的两种条件。请注意,决策树只使用了我们分类模型的三个特征中的两个。因此,我们可以说这棵树是剪枝的。我们将在本节中简要探讨这项技术。

要使用决策树对一组观测值进行分类,我们从根节点开始遍历树,直到达到代表观测值集合预测类别的叶节点。从决策树预测一组观测值类别的这种技术始终相同,无论决策树是如何构建的。对于之前描述的决策树,我们可以通过首先比较鱼的长度,然后比较其皮肤的亮度来对鱼进行分类。第二次比较只有在鱼的长度大于6(如决策树中带有表达式Length < 6的内部节点所指定)时才需要。如果鱼的长度确实大于6,我们使用鱼的皮肤亮度来决定它是鲑鱼还是海鲢。

实际上,有几种算法用于从一些训练数据中构建决策树。通常,树是通过根据属性值测试将训练数据中的样本值集合分割成更小的子集来构建的。这个过程在每个子集上重复进行,直到分割给定样本值子集不再向决策树添加内部节点。正如我们之前提到的,决策树中的内部节点可能有多于两个的子树。

现在我们将探讨C4.5算法来构建决策树(更多信息,请参阅《C4.5:机器学习程序》)。这个算法使用信息熵的概念来决定必须对样本值集合进行分割的特征和相应的值。信息熵被定义为给定特征或随机变量的不确定性度量(更多信息,请参阅“通信的数学理论”)。

对于给定特征或属性 f,其值在 1m 的范围内,我们可以定义该使用决策树特征的信息熵如下:

使用决策树

在前面的方程中,术语使用决策树表示特征 f 相对于值 i 的出现次数。基于特征信息熵的定义,我们定义归一化信息增益使用决策树。在以下等式中,术语 T 指的是提供给算法的样本值或训练数据集:

使用决策树

从信息熵的角度来看,给定属性的先前的信息增益定义是当从模型中给定的特征集中移除属性 f 时,整个值集的信息熵的变化。

算法从训练数据中选择的特征 A,使得特征 A 在特征集中具有最大的可能信息增益。我们可以借助以下等式来表示这一点:

使用决策树

在前面的方程中,使用决策树表示特征 a 所知可能具有的所有可能值集。使用决策树集表示特征 a 具有值 v 的观察值集,而术语使用决策树表示该值集的信息熵。

使用前面的方程从训练数据中选择具有最大信息增益的特征,我们可以通过以下步骤描述 C4.5 算法:

  1. 对于每个特征 a,找到在特征 a 上划分样本数据时的归一化信息增益。

  2. 选择具有最大归一化信息增益的特征 A

  3. 根据所选特征 A 创建一个内部决策节点。从这个步骤创建的两个子树要么是叶节点,要么是进一步划分的新样本值集。

  4. 在上一步产生的每个样本值分区集上重复此过程。我们重复前面的步骤,直到样本值子集中的所有特征都具有相同的信息熵。

一旦创建了一个决策树,我们可以选择性地对该树进行剪枝。剪枝简单地说就是从树中移除任何多余的决策节点。这可以被视为通过正则化决策树来防止估计的决策树模型欠拟合或过拟合的一种形式。

J48是 C4.5 算法在 Java 中的开源实现,clj-ml库包含一个有效的 J48 决策树分类器。我们可以使用make-classifier函数创建一个决策树分类器,并向此函数提供:decision-tree:c45关键字作为参数来创建一个 J48 分类器,如下述代码所示:

(def DT-classifier (make-classifier :decision-tree :c45))

(defn train-DT-classifier []
  (dataset-set-class fish-dataset 0)
  (classifier-train DT-classifier fish-dataset))

前述代码中定义的train-DT-classifier函数简单地将DT-classifier分类器用我们之前鱼包装厂示例中的训练数据训练。classifier-train函数还会打印以下训练好的分类器:

user> (train-DT-classifier)
#<J48 J48 pruned tree
------------------
width <= 3: sea-bass (2320.0)
width > 3
|   length <= 6
|   |   lightness <= 5: salmon (7147.0/51.0)
|   |   lightness > 5: sea-bass (95.0)
|   length > 6: sea-bass (438.0)

Number of Leaves  : 4

Size of the tree : 7
>

上述输出很好地说明了训练好的决策树的外观,以及决策树的大小和叶节点数量。显然,决策树有三个不同的内部节点。树的根节点基于鱼的宽度,后续节点基于鱼的长度,最后一个决策节点基于鱼皮肤的亮度。

现在,我们可以使用决策树分类器来预测鱼的类别,我们使用以下classifier-classify函数来进行这个分类:

user> (classifier-classify DT-classifier sample-fish)
:salmon

如前述代码所示,分类器将代表sample-fish的鱼的类别预测为:salmon关键字,就像之前示例中使用的其他分类器一样。

clj-ml库提供的 J48 决策树分类器实现,在训练分类器时将剪枝作为最后一步。我们可以通过在传递给make-classifier函数的选项映射中指定:unpruned键来生成未经修剪的树,如下述代码所示:

(def UDT-classifier (make-classifier
                     :decision-tree :c45 {:unpruned true}))

之前定义的分类器在用给定的训练数据训练决策树时不会进行剪枝。我们可以通过定义和调用train-UDT-classifier函数来检查未经修剪的树的外观,该函数简单地使用classifier-train函数和fish-dataset训练数据来训练分类器。此函数可以定义为与train-UDT-classifier函数类似,并在调用时产生以下输出:

user> (train-UDT-classifier)
#<J48 J48 unpruned tree
------------------
width <= 3: sea-bass (2320.0)
width > 3
|   length <= 6
|   |   lightness <= 5
|   |   |   length <= 5: salmon (6073.0)
|   |   |   length > 5
|   |   |   |   width <= 4
|   |   |   |   |   lightness <= 3: salmon (121.0)
|   |   |   |   |   lightness > 3
|   |   |   |   |   |   lightness <= 4: salmon (52.0/25.0)
|   |   |   |   |   |   lightness > 4: sea-bass (50.0/24.0)
|   |   |   |   width > 4: salmon (851.0)
|   |   lightness > 5: sea-bass (95.0)
|   length > 6: sea-bass (438.0)

Number of Leaves  : 8

Size of the tree : 15

如前述代码所示,未经修剪的决策树与修剪后的决策树相比,拥有更多的内部决策节点。现在我们可以使用以下classifier-classify函数来预测一条鱼的类别,使用的是训练好的分类器:

user> (classifier-classify UDT-classifier sample-fish)
:salmon

有趣的是,未经修剪的树也预测了代表sample-fish的鱼的类别为:salmon,因此与之前描述的修剪决策树预测的类别一致。总之,clj-ml库为我们提供了一个基于 C4.5 算法的决策树分类器的有效实现。

make-classifier函数支持 J48 决策树分类器的一些有趣选项。我们已经探讨了:unpruned选项,它表示决策树没有被剪枝。我们可以将:reduced-error-pruning选项指定给make-classifier函数,以强制使用减少误差剪枝(更多信息,请参阅“基于树大小的悲观决策树剪枝”),这是一种基于减少模型整体误差的剪枝形式。我们还可以指定给make-classifier函数的另一个有趣选项是剪枝决策树时可以移除的最大内部节点或折叠数。我们可以使用:pruning-number-of-folds选项来指定此选项,并且默认情况下,make-classifier函数在剪枝决策树时不会施加此类限制。此外,我们还可以通过指定:only-binary-splits选项给make-classifier函数,来指定决策树中的每个内部决策节点只有两个子树。

摘要

在本章中,我们探讨了分类以及可以用来对给定分类问题进行建模的各种算法。尽管分类技术非常有用,但当样本数据具有大量维度时,它们的性能并不太好。此外,特征可能以非线性方式变化,正如我们将在第四章“构建神经网络”中描述的那样。我们将在接下来的章节中进一步探讨这些方面以及监督学习的替代方法。以下是本章中我们关注的一些要点:

  • 我们描述了两种广泛的分类类型,即二分类和多分类。我们还简要研究了逻辑函数以及如何通过逻辑回归来使用它来建模分类问题。

  • 我们研究和实现了贝叶斯分类器,它使用用于建模分类的概率模型。我们还描述了如何使用clj-ml库的贝叶斯分类器实现来对给定的分类问题进行建模。

  • 我们还探讨了简单的 k-最近邻算法以及我们如何利用clj-ml库来利用它。

  • 我们研究了决策树和 C4.5 算法。clj-ml库为我们提供了一个基于 C4.5 算法的可配置分类器实现,我们描述了如何使用此实现。

在下一章中,我们将探讨人工神经网络。有趣的是,我们可以使用人工神经网络来建模回归和分类问题,我们也将研究这些神经网络的方面。

第四章:建立神经网络

在本章中,我们将介绍人工神经网络ANNs)。我们将研究 ANNs 的基本表示,然后讨论可用于监督和未监督机器学习问题的多个 ANN 模型。我们还介绍了Enclog Clojure 库来构建 ANNs。

神经网络非常适合在给定的数据中寻找模式,并在计算领域有多个实际应用,例如手写识别和机器视觉。人工神经网络(ANNs)通常被组合或相互连接来模拟给定的问题。有趣的是,它们可以应用于多个机器学习问题,如回归和分类。ANNs 在计算领域的多个领域都有应用,并不局限于机器学习的范围。

无监督学习是一种机器学习方法,其中给定的训练数据不包含任何关于给定输入样本属于哪个类别的信息。由于训练数据是未标记的,无监督学习算法必须完全依靠自己确定给定数据中的各种类别。通常,这是通过寻找不同数据之间的相似性,然后将数据分组到几个类别中实现的。这种技术称为聚类分析,我们将在以下章节中更详细地研究这种方法。ANNs 在无监督机器学习技术中的应用主要是由于它们能够快速识别某些未标记数据中的模式。这种 ANNs 表现出的特殊形式的无监督学习被称为竞争学习

关于 ANNs 的一个有趣的事实是,它们是从具有学习能力的更高阶动物的中枢神经系统的结构和行为中建模的。

理解非线性回归

到目前为止,读者必须意识到梯度下降算法可以用来估计回归和分类问题的线性回归和逻辑回归模型。一个明显的问题会是:当我们可以使用梯度下降从训练数据中估计线性回归和逻辑回归模型时,为什么还需要神经网络?为了理解 ANNs 的必要性,我们首先必须理解非线性回归

假设我们有一个单个特征变量X和一个随X变化的因变量Y,如下面的图所示:

理解非线性回归

如前图所示,将因变量 Y 模型化为自变量 X 的线性方程是困难的,甚至是不可能的。我们可以将因变量 Y 模型为自变量 X 的高阶多项式方程,从而将问题转化为线性回归的标准形式。因此,说因变量 Y 非线性地随自变量 X 变化。当然,也有可能数据无法使用多项式函数进行建模。

还可以证明,使用梯度下降法计算多项式函数中所有项的权重或系数的时间复杂度为 理解非线性回归,其中 n 是训练数据中的特征数量。同样,计算三次多项式方程中所有项的系数的算法复杂度为 理解非线性回归。很明显,梯度下降的时间复杂度随着模型特征数量的增加而呈几何级数增长。因此,仅使用梯度下降本身不足以对具有大量特征的非线性回归模型进行建模。

另一方面,神经网络在建模具有大量特征的数据的非线性回归模型方面非常高效。我们现在将研究神经网络的基础理念以及可用于监督学习和无监督学习问题的几种神经网络模型。

表示神经网络

神经网络(ANNs)是从能够学习的生物体(如哺乳动物和爬行动物)的中枢神经系统行为中建模的。这些生物体的中枢神经系统包括生物体的脑、脊髓和一系列支持性神经组织。大脑处理信息并产生电信号,这些信号通过神经网络纤维传输到生物体的各个器官。尽管生物体的脑执行了许多复杂的处理和控制,但实际上它是由神经元组成的集合。然而,实际处理感觉信号的是这些神经元的一些复杂组合。当然,每个神经元都能够处理大脑处理的信息的极小部分。大脑实际上是通过将来自身体各种感觉器官的电信号通过这个复杂的神经元网络路由到其运动器官来发挥作用的。以下图示了单个神经元的细胞结构:

表示神经网络

神经元有几个接近细胞核的树突和一个单一的轴突,轴突用于从细胞的核传递信号。树突用于接收来自其他神经元的信号,可以将其视为神经元的输入。同样,神经元的轴突类似于神经元的输出。因此,神经元可以用一个处理多个输入并产生单个输出的函数来数学地表示。

其中一些神经元是相互连接的,这种网络被称为神经网络。神经元通过从其他神经元中传递微弱的电信号来执行其功能。两个神经元之间的连接空间称为突触

一个人工神经网络(ANN)由多个相互连接的神经元组成。每个神经元都可以用一个数学函数来表示,该函数消耗多个输入值并产生一个输出值,如下面的图所示:

表示神经网络

可以用前面的图来表示单个神经元。从数学的角度来看,它只是一个将一组输入值表示神经网络映射到输出值表示神经网络的函数表示神经网络。这个函数表示神经网络被称为神经元的激活函数,其输出值表示神经网络被称为神经元的激活。这种神经元的表示称为感知器。感知器可以单独使用,并且足够有效,可以估计监督机器学习模型,如线性回归和逻辑回归。然而,复杂非线性数据可以用多个相互连接的感知器更好地建模。

通常,会将一个偏差输入添加到供给感知器的输入值集合中。对于输入值表示神经网络,我们添加项表示神经网络作为偏差输入,使得表示神经网络。具有这个附加偏差值的神经元可以用以下图来表示:

表示神经网络

供给感知器的每个输入值表示神经网络都有一个相关的权重表示神经网络。这个权重与线性回归模型特征的系数类似。激活函数应用于这些权重及其相应的输入值。我们可以如下形式地定义感知器的估计输出值表示神经网络

表示神经网络

ANN(人工神经网络)的节点所使用的激活函数很大程度上取决于需要建模的样本数据。通常,Sigmoid双曲正切函数被用作分类问题的激活函数(更多信息,请参阅基于汽油近红外(NIR)光谱的校准模型构建的波形神经网络(WNN)方法)。据说 Sigmoid 函数在给定的阈值输入时会被激活

我们可以通过绘制 Sigmoid 函数的方差来描述这种行为,如下面的图表所示:

表示神经网络

ANN 可以广泛地分为前馈神经网络循环神经网络(更多信息,请参阅双向循环神经网络)。这两种类型 ANN 之间的区别在于,在前馈神经网络中,ANN 节点的连接不形成一个有向循环,而循环神经网络中节点之间的连接则形成一个有向循环。因此,在前馈神经网络中,ANN 给定层的每个节点只接收来自 ANN 中直接前一层的节点的输入。

有几种 ANN 模型具有实际应用,我们将在本章中探讨其中的一些。

理解多层感知器 ANN

现在我们介绍一个简单的前馈神经网络模型——多层感知器模型。该模型代表了一个基本的前馈神经网络,并且足够灵活,可以用于监督学习领域中回归和分类问题的建模。所有输入都通过一个前馈神经网络以单一方向流动。这是没有从或向任何层反馈的事实的一个直接后果。

通过反馈,我们指的是给定层的输出被反馈作为 ANN 中前一层的感知器的输入。此外,使用单层感知器意味着只使用一个激活函数,这相当于使用逻辑回归来建模给定的训练数据。这意味着该模型不能用于拟合非线性数据,而这正是 ANN 的主要动机。我们必须注意,我们在第三章数据分类中讨论了逻辑回归。

多层感知器 ANN 可以通过以下图表来表示:

理解多层感知器 ANN

多层感知器 ANN 由多个感知器节点层组成。它具有单个输入层、单个输出层和多个隐藏层。输入层只是将输入值传递给 ANN 的第一个隐藏层。然后这些值通过其他隐藏层传播到输出层,在这些层中使用激活函数进行加权求和,最终产生输出值。

训练数据中的每个样本由理解多层感知器 ANN元组表示,其中理解多层感知器 ANN是期望输出,理解多层感知器 ANN理解多层感知器 ANN训练样本的输入值。理解多层感知器 ANN输入向量包含与训练数据中特征数量相等的值。

每个节点的输出被称为该节点的激活,对于理解多层感知器 ANN层中的理解多层感知器 ANN节点,用术语理解多层感知器 ANN表示。正如我们之前提到的,用于生成此值的激活函数是 Sigmoid 函数或双曲正切函数。当然,任何其他数学函数都可以用来拟合样本数据。多层感知器网络的输入层只是将一个偏置输入加到输入值上,并将提供给 ANN 的输入集传递到下一层。我们可以用以下等式正式表示这个关系:

理解多层感知器 ANN

ANN 中每一对层之间的突触都有一个相关的权重矩阵。这些矩阵的行数等于输入值的数量,即 ANN 输入层附近层的节点数,列数等于突触层中靠近 ANN 输出层的节点数。对于层l,权重矩阵用术语理解多层感知器 ANN表示。

l的激活值可以使用 ANN 的激活函数来确定。激活函数应用于权重矩阵和由 ANN 中前一层的激活值产生的乘积。通常,用于多层感知器的激活函数是 Sigmoid 函数。这个等式可以正式表示如下:

理解多层感知器 ANN

通常,用于多层感知器的激活函数是 Sigmoid 函数。请注意,我们不在 ANN 的输出层中添加偏置值。此外,输出层可以产生任意数量的输出值。为了建模一个k类分类问题,我们需要一个产生k个输出值的 ANN。

要进行二元分类,我们只能对最多两个类别的输入数据进行建模。用于二元分类的 ANN 生成的输出值总是 0 或 1。因此,对于理解多层感知器 ANN类,理解多层感知器 ANN

我们也可以使用k个二进制输出值来模拟多类分类,因此,人工神经网络(ANN)的输出是一个理解多层感知器 ANN矩阵。这可以形式化地表达如下:

理解多层感知器 ANN

因此,我们可以使用多层感知器 ANN 来执行二类和多类分类。多层感知器 ANN 可以使用反向传播算法进行训练,我们将在本章后面学习和实现它。

假设我们想要模拟逻辑异或门的行为。异或门可以被看作是一个需要两个输入并生成单个输出的二进制分类器。模拟异或门的人工神经网络将具有以下图中所示的结构。有趣的是,线性回归可以用来模拟 AND 和 OR 逻辑门,但不能用来模拟异或门。这是因为异或门输出的非线性特性,因此,ANN 被用来克服这一限制。

理解多层感知器 ANN

前图中所示的多层感知器有三个输入层节点,四个隐藏层节点和一个输出层节点。观察发现,除了输出层之外,每个层都会向下一层的节点输入值集合中添加一个偏置输入。ANN 中有两个突触,如图所示,它们与权重矩阵理解多层感知器 ANN理解多层感知器 ANN相关联。请注意,第一个突触位于输入层和隐藏层之间,第二个突触位于隐藏层和输出层之间。权重矩阵理解多层感知器 ANN的大小为理解多层感知器 ANN,权重矩阵理解多层感知器 ANN的大小为理解多层感知器 ANN。此外,术语理解多层感知器 ANN用来表示 ANN 中的所有权重矩阵。

由于多层感知器 ANN 中每个节点的激活函数是 Sigmoid 函数,我们可以将 ANN 节点权重的成本函数定义为与逻辑回归模型的成本函数类似。ANN 的成本函数可以用权重矩阵来定义,如下所示:

理解多层感知器 ANN

前面的代价函数本质上是对 ANN 输出层中每个节点代价函数的平均值(更多信息,请参阅材料科学中的神经网络)。对于具有 K 个输出值的多层感知器 ANN,我们对 K 个项进行平均。请注意,理解多层感知器 ANN 代表 ANN 的理解多层感知器 ANN 输出值,理解多层感知器 ANN 代表 ANN 的输入变量,N 是训练数据中的样本值数量。代价函数本质上与逻辑回归相同,但在这里应用于 K 个输出值。我们可以在前面的代价函数中添加一个正则化参数,并使用以下方程表示正则化代价函数:

理解多层感知器 ANN

在前一个方程中定义的代价函数添加了一个类似于逻辑回归的正则化项。正则化项本质上是由 ANN 的多个层中所有输入值的权重平方和组成的,但不包括添加的偏置输入的权重。此外,术语理解多层感知器 ANN指的是 ANN 中第 l 层的节点数。值得注意的是,在前面的正则化代价函数中,只有正则化项依赖于 ANN 中的层数。因此,估计模型的泛化能力基于 ANN 中的层数。

理解反向传播算法

反向传播学习算法用于从给定的一组样本值中训练一个多层感知器 ANN。简而言之,该算法首先计算给定输入值集的输出值,并计算 ANN 输出的误差量。ANN 中的误差量是通过将 ANN 的预测输出值与训练数据提供给 ANN 的给定输入值的预期输出值进行比较来确定的。然后,计算出的误差用于修改 ANN 的权重。因此,在用合理数量的样本训练 ANN 后,ANN 将能够预测输入值集的输出值。该算法由三个不同的阶段组成。具体如下:

  • 前向传播阶段

  • 反向传播阶段

  • 权重更新阶段

ANN 中突触的权重最初被初始化为在理解反向传播算法理解反向传播算法范围内的随机值。我们将权重初始化为这个范围内的值,以避免权重矩阵中的对称性。这种避免对称性的做法称为对称破缺,其目的是使反向传播算法的每次迭代都能在 ANN 中突触的权重上产生明显的变化。这在人工神经网络中是可取的,因为每个节点都应该独立于 ANN 中的其他节点进行学习。如果所有节点都具有相同的权重,那么估计的学习模型可能会过拟合或欠拟合。

此外,反向传播学习算法还需要两个额外的参数,即学习率理解反向传播算法和学习动量理解反向传播算法。我们将在本节后面的示例中看到这些参数的影响。

算法的正向传播阶段简单地计算 ANN 各个层中所有节点的激活值。正如我们之前提到的,输入层中节点的激活值是 ANN 的输入值和偏置输入。这可以通过以下方程形式化定义:

理解反向传播算法

使用来自人工神经网络(ANN)输入层的这些激活值,可以确定 ANN 其他层中节点的激活状态。这是通过将给定层的权重矩阵与 ANN 前一层中节点的激活值相乘,然后应用激活函数来实现的。这可以形式化地表示如下:

理解反向传播算法

前面的方程解释了层 l 的激活值等于将前一层(或激活)值和给定层的权重矩阵的输出(或激活)值应用激活函数的结果。接下来,输出层的激活值将被反向传播。通过这种方式,我们指的是激活值从输出层通过隐藏层传播到 ANN 的输入层。在这个阶段,我们确定 ANN 中每个节点的误差或 delta 值。输出层的 delta 值是通过计算期望输出值理解反向传播算法和输出层的激活值理解反向传播算法之间的差异来确定的。这种差异计算可以总结为以下方程:

理解反向传播算法

l的术语理解反向传播算法是一个大小为理解反向传播算法的矩阵,其中j是层l中的节点数。这个术语可以正式定义为以下内容:

理解反向传播算法

ANN 中除了输出层之外的其他层的 delta 项由以下等式确定:

理解反向传播算法

在前面的方程中,二进制运算理解反向传播算法用于表示两个相同大小的矩阵的逐元素乘法。请注意,这种运算与矩阵乘法不同,逐元素乘法将返回一个由两个相同大小矩阵中相同位置的元素乘积组成的矩阵。术语理解反向传播算法表示在 ANN 中使用的激活函数的导数。由于我们使用 sigmoid 函数作为激活函数,因此术语理解反向传播算法的值为理解反向传播算法

因此,我们可以计算人工神经网络(ANN)中所有节点的 delta 值。我们可以使用这些 delta 值来确定 ANN 中突触的梯度。我们现在继续到反向传播算法的最后一步,即权重更新阶段。

各个突触的梯度首先初始化为所有元素均为 0 的矩阵。给定突触的梯度矩阵的大小与该突触的权重矩阵的大小相同。梯度项理解反向传播算法表示在 ANN 中位于层l之后的突触层的梯度。ANN 中突触梯度的初始化可以正式表示如下:

理解反向传播算法

对于训练数据中的每个样本值,我们计算 ANN 中所有节点的 delta 和激活值。这些值通过以下方程添加到突触的梯度中:

理解反向传播算法

然后,我们计算所有样本值的梯度的平均值,并使用给定层的 delta 和梯度值来更新权重矩阵,如下所示:

理解反向传播算法

因此,算法的学习率和学习动量参数仅在权重更新阶段发挥作用。前面的三个方程代表反向传播算法的单次迭代。必须执行大量迭代,直到 ANN 的整体误差收敛到一个很小的值。现在我们可以使用以下步骤总结反向传播学习算法:

  1. 将 ANN 的突触权重初始化为随机值。

  2. 选择一个样本值,并通过 ANN 的几层前向传播样本值以生成 ANN 中每个节点的激活。

  3. 将 ANN 最后一层生成的激活反向传播通过隐藏层到 ANN 的输入层。通过这一步,我们计算 ANN 中每个节点的误差或 delta。

  4. 计算从步骤 3 生成的误差与 ANN 中所有节点的突触权重或输入激活的乘积。这一步产生了网络中每个节点的权重梯度。每个梯度由一个比率或百分比表示。

  5. 使用给定层的梯度和 delta 来计算 ANN 中突触层权重的变化。然后,将这些变化从 ANN 中突触的权重中减去。这本质上是反向传播算法的权重更新步骤。

  6. 对训练数据中的其余样本重复步骤 2 到 5。

反向传播学习算法有几个不同的部分,我们现在将实现每个部分并将它们组合成一个完整的实现。由于 ANN 中的突触和激活的 delta 和权重可以用矩阵表示,我们可以编写这个算法的向量化实现。

注意

注意,对于以下示例,我们需要从 Incanter 库的incanter.core命名空间中获取函数。这个命名空间中的函数实际上使用 Clatrix 库来表示矩阵及其操作。

假设我们需要实现一个人工神经网络(ANN)来模拟逻辑异或(XOR)门。样本数据仅仅是异或门的真值表,可以表示为一个向量,如下所示:

;; truth table for XOR logic gate
(def sample-data [[[0 0] [0]]
                  [[0 1] [1]]
                  [[1 0] [1]]
                  [[1 1] [0]]])

在前面定义的向量 sample-data 中,每个元素本身也是一个向量,包含异或门的输入和输出值。我们将使用这个向量作为我们的训练数据来构建 ANN。这本质上是一个分类问题,我们将使用 ANN 来模拟它。在抽象意义上,ANN 应该能够执行二进制和多类分类。我们可以定义 ANN 的协议如下:

(defprotocol NeuralNetwork
  (run        [network inputs])
  (run-binary [network inputs])
  (train-ann  [network samples]))

前述代码中定义的 NeuralNetwork 协议有三个函数。train-ann 函数可以用来训练 ANN,并需要一些样本数据。runrun-binary 函数可以用于此 ANN 来执行多类和二分类,分别。runrun-binary 函数都需要一组输入值。

反向传播算法的第一步是初始化 ANN 突触的权重。我们可以使用 randmatrix 函数生成这些权重作为矩阵,如下所示:

(defn rand-list
  "Create a list of random doubles between 
  -epsilon and +epsilon."
  [len epsilon]
  (map (fn [x] (- (rand (* 2 epsilon)) epsilon))
         (range 0 len)))

(defn random-initial-weights
  "Generate random initial weight matrices for given layers.
  layers must be a vector of the sizes of the layers."
  [layers epsilon]
  (for [i (range 0 (dec (length layers)))]
    (let [cols (inc (get layers i))
          rows (get layers (inc i))]
      (matrix (rand-list (* rows cols) epsilon) cols))))

前述代码中显示的 rand-list 函数在 epsilon 的正负范围内创建一个随机元素列表。如我们之前所述,我们选择这个范围来打破权重矩阵的对称性。

random-initial-weights 函数为 ANN 的不同层生成多个权重矩阵。如前述代码中定义的,layers 参数必须是一个向量,包含 ANN 各层的尺寸。对于一个输入层有两个节点、隐藏层有三个节点、输出层有一个节点的 ANN,我们将 layers 作为 [2 3 1] 传递给 random-initial-weights 函数。每个权重矩阵的列数等于输入的数量,行数等于 ANN 下一个层的节点数。我们设置给定层的权重矩阵的列数为输入的数量,并额外添加一个用于神经网络偏置的输入。请注意,我们使用了一个稍微不同的 matrix 函数形式。这种形式接受一个单一向量,并将该向量分割成一个矩阵,其列数由该函数的第二个参数指定。因此,传递给这种 matrix 函数的向量必须包含 (* rows cols) 个元素,其中 rowscols 分别是权重矩阵的行数和列数。

由于我们需要将 sigmoid 函数应用于 ANN 中某一层的所有激活,我们必须定义一个函数,该函数将对给定矩阵中的所有元素应用 sigmoid 函数。我们可以使用 incanter.core 命名空间中的 divplusexpminus 函数来实现这样的函数,如下所示:

(defn sigmoid
  "Apply the sigmoid function 1/(1+exp(-z)) to all 
  elements in the matrix z."
  [z]
  (div 1 (plus 1 (exp (minus z)))))

注意

注意,所有之前定义的函数都在给定矩阵的所有元素上执行相应的算术运算,并返回一个新的矩阵。

我们还需要在 ANN 的每一层中隐式地添加一个偏置节点。这可以通过围绕 bind-rows 函数进行操作来实现,该函数向矩阵添加一行,如下所示:

(defn bind-bias
  "Add the bias input to a vector of inputs."
  [v]
  (bind-rows [1] v))

由于偏置值始终为 1,我们将元素行 [1] 指定给 bind-rows 函数。

使用之前定义的函数,我们可以实现前向传播。我们本质上需要在人工神经网络(ANN)中两个层之间给定突触的权重相乘,然后对每个生成的激活值应用 sigmoid 函数,如下面的代码所示:

(defn matrix-mult
  "Multiply two matrices and ensure the result is also a matrix."
  [a b]
  (let [result (mmult a b)]
    (if (matrix? result)
      result
      (matrix [result]))))

(defn forward-propagate-layer
  "Calculate activations for layer l+1 given weight matrix 
  of the synapse between layer l and l+1 and layer l activations."
  [weights activations]
  (sigmoid (matrix-mult weights activations)))

(defn forward-propagate
  "Propagate activation values through a network's
  weight matrix and return output layer activation values."
  [weights input-activations]
  (reduce #(forward-propagate-layer %2 (bind-bias %1))
          input-activations weights))

在前面的代码中,我们首先定义了一个matrix-mult函数,该函数执行矩阵乘法并确保结果是矩阵。请注意,为了定义matrix-mult,我们使用mmult函数而不是mult函数,后者用于乘以两个相同大小的矩阵中的对应元素。

使用matrix-multsigmoid函数,我们可以实现 ANN 中两个层之间的前向传播步骤。这通过forward-propagate-layer函数完成,该函数简单地乘以代表 ANN 中两个层之间突触权重的矩阵和输入激活值,同时确保返回的值始终是一个矩阵。为了将给定的一组值通过 ANN 的所有层传播,我们必须添加一个偏置输入,并对每个层应用forward-propagate-layer函数。这可以通过在forward-propagate函数中定义的forward-propagate-layer函数的闭包上使用reduce函数来简洁地完成。

虽然forward-propagate函数可以确定 ANN 的输出激活,但我们实际上需要 ANN 中所有节点的激活来进行反向传播。我们可以通过将reduce函数转换为递归函数并引入一个累加器变量来存储 ANN 中每一层的激活来实现这一点。在下面的代码中定义的forward-propagate-all-activations函数实现了这个想法,并使用loop形式递归地应用forward-propagate-layer函数:

(defn forward-propagate-all-activations
  "Propagate activation values through the network 
  and return all activation values for all nodes."
  [weights input-activations]
  (loop [all-weights     weights
         activations     (bind-bias input-activations)
         all-activations [activations]]
    (let [[weights
           & all-weights']  all-weights
           last-iter?       (empty? all-weights')
           out-activations  (forward-propagate-layer
                             weights activations)
           activations'     (if last-iter? out-activations
                                (bind-bias out-activations))
           all-activations' (conj all-activations activations')]
      (if last-iter? all-activations'
          (recur all-weights' activations' all-activations')))))

在前面的代码中定义的forward-propagate-all-activations函数需要 ANN 中所有节点的权重和输入值作为激活值通过 ANN。我们首先使用bind-bias函数将偏置输入添加到 ANN 的输入激活中。然后我们将此值存储在一个累加器中,即变量all-activations,作为一个包含 ANN 中所有激活的向量。然后,forward-propagate-layer函数被应用于 ANN 各个层的权重矩阵,每次迭代都会向 ANN 中相应层的输入激活添加一个偏置输入。

注意

注意,在最后一次迭代中我们不添加偏置输入,因为它计算 ANN 的输出层。因此,forward-propagate-all-activations函数通过 ANN 对输入值进行前向传播,并返回 ANN 中每个节点的激活值。注意,这个向量中的激活值是按照 ANN 层的顺序排列的。

我们现在将实现反向传播学习算法的反向传播阶段。首先,我们必须实现一个函数来从方程理解反向传播算法计算误差项理解反向传播算法。我们将借助以下代码来完成这项工作:

(defn back-propagate-layer
  "Back propagate deltas (from layer l+1) and 
  return layer l deltas."
  [deltas weights layer-activations]
  (mult (matrix-mult (trans weights) deltas)
        (mult layer-activations (minus 1 layer-activations))))

在前面的代码中定义的back-propagate-layer函数计算 ANN 中突触层l的误差或 delta 值,这些误差或 delta 值来自层的权重和 ANN 中下一层的 delta 值。

注意

注意,我们仅使用矩阵乘法通过matrix-mult函数计算术语理解反向传播算法。所有其他乘法操作都是矩阵的逐元素乘法,这使用mult函数完成。

实质上,我们必须从输出层通过 ANN 的各个隐藏层应用到输入层,以产生 ANN 中每个节点的 delta 值。然后,这些 delta 值可以添加到节点的激活中,从而产生通过调整 ANN 中节点权重所需的梯度值。我们可以以类似于forward-propagate-all-activations函数的方式来做这件事,即通过递归地应用back-propagate-layer函数到 ANN 的各个层。当然,我们必须以相反的顺序遍历 ANN 的层,即从输出层,通过隐藏层,到输入层。我们将借助以下代码来完成这项工作:

(defn calc-deltas
  "Calculate hidden deltas for back propagation.
  Returns all deltas including output-deltas."
  [weights activations output-deltas]
  (let [hidden-weights     (reverse (rest weights))
        hidden-activations (rest (reverse (rest activations)))]
    (loop [deltas          output-deltas
           all-weights     hidden-weights
           all-activations hidden-activations
           all-deltas      (list output-deltas)]
      (if (empty? all-weights) all-deltas
        (let [[weights
               & all-weights']      all-weights
               [activations
                & all-activations'] all-activations
              deltas'        (back-propagate-layer
                               deltas weights activations)
              all-deltas'    (cons (rest deltas') 
                                    all-deltas)]
          (recur deltas' all-weights' 
                 all-activations' all-deltas'))))))

calc-deltas函数确定 ANN 中所有感知器节点的 delta 值。为此计算,不需要输入和输出激活。只需要与hidden-activations变量绑定的隐藏激活来计算 delta 值。此外,输入层的权重被跳过,因为它们绑定到hidden-weights变量。然后calc-deltas函数将back-propagate-layer函数应用于 ANN 中每个突触层的所有权重矩阵,从而确定矩阵中所有节点的 delta 值。请注意,我们不将偏置节点的 delta 值添加到计算出的 delta 集合中。这是通过在给定突触层的计算 delta 值上使用rest函数(rest deltas')来完成的,因为第一个 delta 是给定层中偏置输入的 delta。

根据定义,给定突触层理解反向传播算法的梯度向量项是通过乘以矩阵理解反向传播算法理解反向传播算法来确定的,这些矩阵分别表示下一层的 delta 值和给定层的激活。我们将借助以下代码来完成这项工作:

(defn calc-gradients
  "Calculate gradients from deltas and activations."
  [deltas activations]
  (map #(mmult %1 (trans %2)) deltas activations))

前面代码中显示的calc-gradients函数是对术语理解反向传播算法的简洁实现。由于我们将处理一系列 delta 和激活项,我们使用map函数将前面的等式应用于 ANN 中相应的 delta 和激活项。使用calc-deltascalc-gradient函数,我们可以确定给定训练样本中 ANN 所有节点权重中的总误差。我们将通过以下代码来完成这项工作:

(defn calc-error
  "Calculate deltas and squared error for given weights."
  [weights [input expected-output]]
  (let [activations    (forward-propagate-all-activations 
                        weights (matrix input))
        output         (last activations)
        output-deltas  (minus output expected-output)
        all-deltas     (calc-deltas 
                        weights activations output-deltas)
        gradients      (calc-gradients all-deltas activations)]
    (list gradients
          (sum (pow output-deltas 2)))))

前面代码中定义的calc-error函数需要两个参数——ANN 中突触层的权重矩阵和一个样本训练值,这显示为[输入期望输出]。首先使用forward-propagate-all-activations函数计算 ANN 中所有节点的激活值,然后计算最后一层的 delta 值,即期望输出值与 ANN 产生的实际输出值之间的差值。ANN 计算得出的输出值仅仅是 ANN 产生的最后一个激活值,如前面代码中所示为(last activations)。使用计算出的激活值,通过calc-deltas函数确定所有感知器节点的 delta 值。这些 delta 值随后用于通过calc-gradients函数确定 ANN 中各层的权重梯度。对于给定的样本值,ANN 的均方误差(MSE)也通过添加输出层 delta 值的平方来计算。

对于 ANN 中某一层的给定权重矩阵,我们必须初始化该层的梯度为一个与权重矩阵具有相同维度的矩阵,并且梯度矩阵中的所有元素都必须设置为0。这可以通过使用dim函数的组合来实现,该函数返回矩阵的大小为一个向量,以及matrix函数的变体形式,如下面的代码所示:

(defn new-gradient-matrix
  "Create accumulator matrix of gradients with the
  same structure as the given weight matrix
  with all elements set to 0."
  [weight-matrix]
  (let [[rows cols] (dim weight-matrix)]
    (matrix 0 rows cols)))

在前面代码中定义的new-gradient-matrix函数中,matrix函数期望一个值、行数和列数来初始化一个矩阵。此函数生成一个具有与提供的权重矩阵相同结构的初始化梯度矩阵。

我们现在实现calc-gradients-and-error函数,以便在一系列权重矩阵和样本值上应用calc-error函数。我们基本上需要将calc-error函数应用于每个样本,并累积梯度值和均方误差(MSE)的总和。然后我们计算这些累积值的平均值,以返回给定样本值和权重矩阵的梯度矩阵和总 MSE。我们将通过以下代码来完成这项工作:

(defn calc-gradients-and-error' [weights samples]
  (loop [gradients   (map new-gradient-matrix weights)
         total-error 1
         samples     samples]
    (let [[sample
           & samples']     samples
           [new-gradients
            squared-error] (calc-error weights sample)
            gradients'     (map plus new-gradients gradients)
            total-error'   (+ total-error squared-error)]
      (if (empty? samples')
        (list gradients' total-error')
        (recur gradients' total-error' samples')))))

(defn calc-gradients-and-error
  "Calculate gradients and MSE for sample
  set and weight matrix."
  [weights samples]
  (let [num-samples   (length samples)
        [gradients
         total-error] (calc-gradients-and-error'
                       weights samples)]
    (list
      (map #(div % num-samples) gradients)    ; gradients
      (/ total-error num-samples))))          ; MSE

在前面的代码中定义的calc-gradients-and-error函数依赖于calc-gradients-and-error'辅助函数。calc-gradients-and-error'函数初始化梯度矩阵,执行calc-error函数的应用,并累计计算出的梯度值和 MSE。calc-gradients-and-error函数简单地计算从calc-gradients-and-error'函数返回的累计梯度矩阵和 MSE 的平均值。

现在,我们实现中唯一缺少的部分是使用计算出的梯度修改 ANN 中节点的权重。简而言之,我们必须反复更新权重,直到观察到 MSE 的收敛。这实际上是对 ANN 节点应用的一种梯度下降形式。我们现在将实现这种梯度下降的变体,通过反复修改 ANN 中节点的权重来训练 ANN,如下面的代码所示:

(defn gradient-descent-complete?
  "Returns true if gradient descent is complete."
  [network iter mse]
  (let [options (:options network)]
    (or (>= iter (:max-iters options))
        (< mse (:desired-error options)))))

在前面的代码中定义的gradient-descent-complete?函数简单地检查梯度下降的终止条件。这个函数假设 ANN,作为一个网络,是一个包含:options关键字的映射或记录。这个键的值反过来又是一个包含 ANN 各种配置选项的映射。gradient-descent-complete?函数检查 ANN 的总均方误差(MSE)是否小于由:desired-error选项指定的期望 MSE。此外,我们还添加了另一个条件来检查执行的迭代次数是否超过了由:max-iters选项指定的最大迭代次数。

现在,我们将为多层感知器人工神经网络(ANNs)实现一个梯度下降函数。在这个实现中,权重的变化是通过梯度下降算法提供的step函数来计算的。然后,这些计算出的变化简单地添加到 ANN 的突触层现有权重中。我们将使用以下代码帮助实现多层感知器 ANN 的梯度下降函数:

(defn apply-weight-changes
  "Applies changes to corresponding weights."
  [weights changes]
  (map plus weights changes))

(defn gradient-descent
  "Perform gradient descent to adjust network weights."
  [step-fn init-state network samples]
  (loop [network network
         state init-state
         iter 0]
    (let [iter     (inc iter)
          weights  (:weights network)
          [gradients
           mse]    (calc-gradients-and-error weights samples)]
      (if (gradient-descent-complete? network iter mse)
        network
        (let [[changes state] (step-fn network gradients state)
              new-weights     (apply-weight-changes 
                               weights changes)
              network         (assoc network 
                              :weights new-weights)]
          (recur network state iter))))))

在前面的代码中定义的apply-weight-changes函数简单地添加了 ANN 的权重和计算出的权重变化。gradient-descent函数需要一个step函数(指定为step-fn)、ANN 的初始状态、ANN 本身以及用于训练 ANN 的样本数据。这个函数必须从 ANN、初始梯度矩阵和 ANN 的初始状态计算权重变化。step-fn函数还返回 ANN 的更改状态。然后,使用apply-weight-changes函数更新 ANN 的权重,并且这个迭代过程会重复执行,直到gradient-descent-complete?函数返回true。ANN 的权重由network映射中的:weights关键字指定。然后,简单地通过覆盖由:weights关键字指定的network上的值来更新这些权重。

在反向传播算法的上下文中,我们需要指定 ANN 必须训练的学习率和学习动量。这些参数用于确定 ANN 中节点权重的变化。然后必须指定一个实现此计算的函数作为 gradient-descent 函数的 step-fn 参数,如下面的代码所示:

(defn calc-weight-changes
  "Calculate weight changes:
  changes = learning rate * gradients + 
            learning momentum * deltas."
  [gradients deltas learning-rate learning-momentum]
  (map #(plus (mult learning-rate %1)
              (mult learning-momentum %2))
       gradients deltas))

(defn bprop-step-fn [network gradients deltas]
  (let [options             (:options network)
        learning-rate       (:learning-rate options)
        learning-momentum   (:learning-momentum options)
        changes             (calc-weight-changes
                             gradients deltas
                             learning-rate learning-momentum)]
    [(map minus changes) changes]))

(defn gradient-descent-bprop [network samples]
  (let [gradients (map new-gradient-matrix (:weights network))]
    (gradient-descent bprop-step-fn gradients
                      network samples)))

在前面的代码中定义的 calc-weight-changes 函数根据给定 ANN 中某一层的梯度值和 delta 值计算权重的变化,称为 理解反向传播算法bprop-step-fn 函数从表示为 network 的 ANN 中提取学习率和学习动量参数,并使用 calc-weight-changes 函数。由于权重将由 gradient-descent 函数添加变化,我们使用 minus 函数以负值返回权重的变化。

gradient-descent-bprop 函数简单地初始化 ANN 给定权重的梯度矩阵,并通过指定 bprop-step-fn 作为要使用的 step 函数来调用 gradient-descent 函数。使用 gradient-descent-bprop 函数,我们可以实现我们之前定义的抽象 NeuralNetwork 协议,如下所示:

(defn round-output
  "Round outputs to nearest integer."
  [output]
  (mapv #(Math/round ^Double %) output))

(defrecord MultiLayerPerceptron [options]
  NeuralNetwork

  ;; Calculates the output values for the given inputs.
  (run [network inputs]
    (let [weights (:weights network)
          input-activations (matrix inputs)]
      (forward-propagate weights input-activations)))

  ;; Rounds the output values to binary values for
  ;; the given inputs.
  (run-binary [network inputs]
    (round-output (run network inputs)))

  ;; Trains a multilayer perceptron ANN from sample data.
  (train-ann [network samples]
    (let [options         (:options network)
          hidden-neurons  (:hidden-neurons options)
          epsilon         (:weight-epsilon options)
          [first-in
           first-out]     (first samples)
          num-inputs      (length first-in)
          num-outputs     (length first-out)
          sample-matrix   (map #(list (matrix (first %)) 
                                      (matrix (second %)))
                               samples)
          layer-sizes     (conj (vec (cons num-inputs 
                                           hidden-neurons))
                                num-outputs)
          new-weights     (random-initial-weights 
                           layer-sizes epsilon)
          network         (assoc network :weights new-weights)]
      (gradient-descent-bprop network sample-matrix))))

在前面的代码中定义的 MultiLayerPerceptron 记录使用 gradient-descent-bprop 函数训练一个多层感知器人工神经网络 (ANN)。train-ann 函数首先从指定的 ANN 选项映射中提取隐藏神经元的数量和常数 理解反向传播算法 的值。ANN 中各种突触层的尺寸首先从样本数据中确定,并绑定到 layer-sizes 变量。然后使用 random-initial-weights 函数初始化 ANN 的权重,并在 network 记录中使用 assoc 函数更新。最后,通过指定 bprop-step-fn 作为要使用的 step 函数,调用 gradient-descent-bprop 函数来使用反向传播学习算法训练 ANN。

MultiLayerPerceptron 记录定义的 ANN 还实现了 NeuralNetwork 协议中的两个其他函数,runrun-binaryrun 函数使用 forward-propagate 函数确定训练好的 MultiLayerPerceptron ANN 的输出值。run-binary 函数简单地将 run 函数为给定输入值集返回的输出值四舍五入。

使用 MultiLayerPerceptron 记录创建的 ANN 需要一个包含我们可以为 ANN 指定的各种选项的单个 options 参数。我们可以如下定义此类 ANN 的默认选项:

(def default-options
  {:max-iters 100
   :desired-error 0.20
   :hidden-neurons [3]
   :learning-rate 0.3
   :learning-momentum 0.01
   :weight-epsilon 50})

(defn train [samples]
  (let [network (MultiLayerPerceptron. default-options)]
    (train-ann network samples)))

default-options 变量定义的映射包含以下键,这些键指定了 MultiLayerPerceptron ANN 的选项:

  • :max-iter: 此键指定运行 gradient-descent 函数的最大迭代次数。

  • :desired-error:此变量指定 ANN 中期望或可接受的均方误差(MSE)。

  • :hidden-neurons:此变量指定网络中隐藏神经节点的数量。值[3]表示一个包含三个神经元的单个隐藏层。

  • :learning-rate:learning-momentum:这些键指定反向传播学习算法权重更新阶段的学习率和学习动量。

  • :epsilon:此变量指定random-initial-weights函数用于初始化 ANN 权重的常数。

我们还定义了一个简单的辅助函数train,用于创建MultiLayerPerceptron类型的 ANN,并使用train-ann函数和由samples参数指定的样本数据来训练 ANN。现在,我们可以根据sample-data变量指定的训练数据创建一个训练好的 ANN,如下所示:

user> (def MLP (train sample-data))
#'user/MLP

我们可以使用训练好的 ANN 来预测一些输入值的输出。由MLP定义的 ANN 生成的输出与 XOR 门的输出非常接近,如下所示:

user> (run-binary MLP  [0 1])
[1]
user> (run-binary MLP  [1 0])
[1]

然而,训练好的 ANN 对于某些输入集产生了不正确的输出,如下所示:

user> (run-binary MLP  [0 0])
[0]
user> (run-binary MLP  [1 1]) ;; incorrect output generated
[1]

为了提高训练好的 ANN 的准确性,我们可以实施几种措施。首先,我们可以使用 ANN 的权重矩阵来正则化计算的梯度。这种修改将使先前的实现产生明显的改进。我们还可以增加要执行的最大迭代次数。我们还可以调整算法,通过调整学习率、学习动量和 ANN 中的隐藏节点数量来提高性能。这些修改被跳过,因为它们需要由读者来完成。

Enclog库(github.com/jimpil/enclog)是一个 Clojure 包装库,用于机器学习算法和 ANN 的Encog库。Encog 库(github.com/encog)有两个主要实现:一个在 Java 中,一个在.NET 中。我们可以使用 Enclog 库轻松生成定制的 ANN 来模拟监督和非监督机器学习问题。

注意

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

[org.encog/encog-core "3.1.0"]
[enclog "0.6.3"]

注意,Enclog 库需要 Encog Java 库作为依赖项。

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

(ns my-namespace
  (:use [enclog nnets training]))

我们可以使用enclog.nnets命名空间中的neural-patternnetwork函数从 Enclog 库创建一个 ANN。neural-pattern函数用于指定 ANN 的神经网络模型。network函数接受从neural-pattern函数返回的神经网络模型,并创建一个新的 ANN。我们可以根据指定的神经网络模型向network函数提供几个选项。以下是一个前馈多层感知器网络的定义:

(def mlp (network (neural-pattern :feed-forward)
                  :activation :sigmoid
                  :input      2
                  :output     1
                  :hidden     [3]))

对于前馈神经网络,我们可以通过将:activation键指定给network函数来指定激活函数。在我们的例子中,我们使用了 sigmoid 函数,它被指定为:sigmoid作为 ANN 节点的激活函数。我们还使用:input:output:hidden键指定了 ANN 的输入、输出和隐藏层的节点数量。

要使用一些样本数据训练由network函数创建的 ANN,我们使用enclog.training命名空间中的trainertrain函数。用于训练 ANN 的学习算法必须作为trainer函数的第一个参数指定。对于反向传播算法,此参数是:back-prop关键字。trainer函数返回的值代表一个 ANN 以及用于训练 ANN 的学习算法。然后使用train函数在 ANN 上实际运行指定的训练算法。我们将通过以下代码来完成这项工作:

(defn train-network [network data trainer-algo]
  (let [trainer (trainer trainer-algo
                         :network network
                         :training-set data)]
    (train trainer 0.01 1000 []))) ;; 0.01 is the expected error

在前面的代码中定义的train-network函数接受三个参数。第一个参数是由network函数创建的 ANN,第二个参数是用于训练 ANN 的训练数据,第三个参数指定了 ANN 必须通过的学习算法。如前所述的代码所示,我们可以使用关键字参数:network:training-set将 ANN 和训练数据指定给trainer函数。然后使用train函数通过样本数据在 ANN 上运行训练算法。我们可以将 ANN 中期望的错误和训练算法的最大迭代次数作为train函数的第一个和第二个参数指定。在前面的例子中,期望的错误是0.01,最大迭代次数是 1000。传递给train函数的最后一个参数是一个指定 ANN 行为的向量,我们通过传递一个空向量来忽略它。

在对人工神经网络(ANN)运行训练算法时使用的训练数据可以通过使用 Enclog 的data函数来创建。例如,我们可以使用data函数创建一个用于逻辑异或门(XOR gate)的训练数据,如下所示:

(def dataset
  (let [xor-input [[0.0 0.0] [1.0 0.0] [0.0 1.0] [1.0 1.0]]
        xor-ideal [[0.0]     [1.0]     [1.0]     [0.0]]]
        (data :basic-dataset xor-input xor-ideal)))

data函数需要将数据类型作为函数的第一个参数,随后是训练数据的输入和输出值作为向量。在我们的例子中,我们将使用:basic-dataset:basic参数。:basic-dataset关键字可以用来创建训练数据,而:basic关键字可以用来指定一组输入值。

使用由dataset变量定义的数据和train-network函数,我们可以训练 ANN 的MLP来模拟异或门的输出,如下所示:

user> (def MLP (train-network mlp dataset :back-prop))
Iteration # 1 Error: 26.461526% Target-Error: 1.000000%
Iteration # 2 Error: 25.198031% Target-Error: 1.000000%
Iteration # 3 Error: 25.122343% Target-Error: 1.000000%
Iteration # 4 Error: 25.179218% Target-Error: 1.000000%
...
...
Iteration # 999 Error: 3.182540% Target-Error: 1.000000%
Iteration # 1,000 Error: 3.166906% Target-Error: 1.000000%
#'user/MLP

如前述输出所示,训练好的 ANN 的错误率约为 3.16%。现在我们可以使用训练好的 ANN 来预测一组输入值的输出。为此,我们使用 Java 的computegetData方法,分别由.compute.getData指定。我们可以定义一个简单的辅助函数来调用.compute方法,为输入值向量计算结果,并将输出四舍五入到二进制值,如下所示:

(defn run-network [network input]
  (let [input-data (data :basic input)
        output     (.compute network input-data)
        output-vec (.getData output)]
    (round-output output-vec)))

我们现在可以使用run-network函数,通过输入值向量来测试训练好的 ANN,如下所示:

user> (run-network MLP [1 1])
[0]
user> (run-network MLP [1 0])
[1]
user> (run-network MLP [0 1])
[1]
user> (run-network MLP [0 0])
[0]

如前述代码所示,由MLP表示的训练好的 ANN 完全符合 XOR 门的行为。

总结来说,Enclog 库为我们提供了一组强大的函数,可以用来构建 ANN。在前面的例子中,我们探讨了前馈多层感知器模型。该库还提供了其他几种 ANN 模型,例如自适应共振理论ART)、自组织映射SOM)和 Elman 网络。Enclog 库还允许我们自定义特定神经网络模型中节点的激活函数。在我们的例子中,我们使用了 sigmoid 函数。库还支持几种数学函数,如正弦、双曲正切、对数和线性函数。Enclog 库还支持一些机器学习算法,可用于训练 ANN。

理解循环神经网络

我们现在将关注点转向循环神经网络,并研究一个简单的循环神经网络模型。Elman 神经网络是一个简单的循环 ANN,具有单个输入、输出和隐藏层。还有一个额外的上下文层的神经网络节点。Elman 神经网络用于模拟监督和无监督机器学习问题中的短期记忆。Enclog 库确实包括对 Elman 神经网络的支持,我们将演示如何使用 Enclog 库构建 Elman 神经网络。

Elman 神经网络的上下文层从 ANN 的隐藏层接收无权重的输入。这样,ANN 可以记住我们使用隐藏层生成的先前值,并使用这些值来影响预测值。因此,上下文层充当 ANN 的短期记忆。以下图示可以说明 Elman 神经网络:

理解循环神经网络

如前述图所示,Elman 网络的结构类似于前馈多层感知器 ANN。Elman 网络向 ANN 添加了一个额外的上下文层神经网络。前述图中的 Elman 网络接受两个输入并产生两个输出。Elman 网络的输入和隐藏层添加了一个额外的偏置输入,类似于多层感知器。隐藏层神经元的激活直接馈送到两个上下文节点理解循环神经网络理解循环神经网络。这些上下文节点中存储的值随后被 ANN 隐藏层的节点使用,以回忆先前的激活并确定新的激活值。

我们可以创建一个 Elman 网络,将:elman关键字指定给 Enclog 库中的neural-pattern函数,如下所示:

(def elman-network (network (neural-pattern :elman)
                             :activation :sigmoid
                             :input      2
                             :output     1
                             :hidden     [3]))

要训练 Elman 网络,我们可以使用弹性传播算法(更多信息,请参阅Empirical Evaluation of the Improved Rprop Learning Algorithm)。此算法也可以用于训练 Enclog 支持的其他循环网络。有趣的是,弹性传播算法还可以用于训练前馈网络。此算法的性能也显著优于反向传播学习算法。尽管此算法的完整描述超出了本书的范围,但鼓励读者了解更多关于此学习算法的信息。弹性传播算法指定为train-network函数的:resilient-prop关键字,这是我们之前定义的。我们可以使用train-network函数和dataset变量来训练 Elman 神经网络,如下所示:

user> (def EN (train-network elman-network dataset 
                             :resilient-prop))
Iteration # 1 Error: 26.461526% Target-Error: 1.000000%
Iteration # 2 Error: 25.198031% Target-Error: 1.000000%
Iteration # 3 Error: 25.122343% Target-Error: 1.000000%
Iteration # 4 Error: 25.179218% Target-Error: 1.000000%
...
...
Iteration # 99 Error: 0.979165% Target-Error: 1.000000%
#'user/EN

如前述代码所示,与反向传播算法相比,弹性传播算法需要相对较少的迭代次数。现在我们可以使用这个训练好的 ANN 来模拟一个 XOR 门,就像我们在上一个例子中所做的那样。

总结来说,循环神经网络模型和训练算法是其他有用的模型,可以用于使用 ANN 来建模分类或回归问题。

构建 SOMs

SOM(发音为ess-o-em)是另一个有趣的 ANN 模型,它对无监督学习很有用。SOMs 被用于多个实际应用中,如手写识别和图像识别。当我们讨论第七章中的聚类时,我们也将重新审视 SOMs,聚类数据

在无监督学习中,样本数据不包含预期的输出值,人工神经网络(ANN)必须完全依靠自身识别和匹配输入数据中的模式。SOM 用于竞争学习,这是无监督学习的一个特殊类别,其中 ANN 输出层的神经元相互竞争以激活。激活的神经元决定了 ANN 的最终输出值,因此,激活的神经元也被称为获胜神经元

神经生物学研究表明,大脑接收到的不同感官输入以有序的模式映射到大脑大脑皮层的相应区域。因此,处理密切相关操作的神经元被保持在一起。这被称为拓扑形成原理,而 SOM 实际上是基于这种行为构建的。

自组织映射(SOM)本质上是将具有大量维度的输入数据转换为一个低维离散映射。通过将神经元放置在这个映射的节点上对 SOM 进行训练。SOM 的内部映射通常有一到两个维度。SOM 中的神经元会选择性地调整到输入值中的模式。当 SOM 中的某个特定神经元被激活以响应特定的输入模式时,其邻近的神经元往往会变得更加兴奋,并且更倾向于调整到输入值中的模式。这种行为被称为一组神经元的横向交互。因此,SOM 可以在输入数据中找到模式。当在输入数据集中找到相似模式时,SOM 会识别这个模式。SOM 中神经节点层的结构可以描述如下:

构建 SOMs

自组织映射(SOM)有一个输入层和一个计算层,如前图所示。计算层也被称为 SOM 的特征图。输入节点将输入值映射到计算层中的几个神经元。计算层中的每个节点都有其输出连接到其邻近节点,并且每个连接都有一个与之相关的权重。这些权重被称为特征图的连接权重。SOM 通过调整其计算层中节点的连接权重来记住输入值中的模式。

自组织映射(SOM)的自组织过程可以描述如下:

  1. 连接权重最初被初始化为随机值。

  2. 对于每个输入模式,计算层中的神经节点使用判别函数计算一个值。然后,这些值被用来决定获胜的神经元。

  3. 具有最小判别函数值的神经元被选中,并且对其周围神经元的连接权重进行修改,以便激活输入数据中的相似模式。

必须修改权重,使得对于输入中的给定模式,判别函数对邻近节点的值减少。因此,获胜节点及其周围节点对于输入数据中的相似模式产生更高的输出或激活值。权重调整的量取决于指定给训练算法的学习率。

对于输入数据中的给定维度数 D,判别函数可以正式定义为以下内容:

Building SOMs

在前面的方程中,术语 Building SOMs 是 SOM 中 Building SOMs 神经元的权重向量。向量 Building SOMs 的长度等于连接到 Building SOMs 神经元的神经元数量。

一旦我们在 SOM 中选定了获胜神经元,我们必须选择获胜神经元的邻近神经元。我们必须调整这些邻近神经元的权重以及获胜神经元的权重。可以使用各种方案来选择获胜神经元邻近节点。在最简单的情况下,我们可以选择一个邻近神经元。

我们可以改用 bubble 函数或 radial bias 函数来选择围绕获胜神经元的一组邻近神经元(更多信息,请参阅 Multivariable functional interpolation and adaptive networks)。

要训练一个 SOM,我们必须在训练算法中执行以下步骤:

  1. 将计算层中节点的权重设置为随机值。

  2. 从训练数据中选择一个样本输入模式。

  3. 找到所选输入模式集的获胜神经元。

  4. 更新获胜神经元及其周围节点的权重。

  5. 对于训练数据中的所有样本,重复步骤 2 到 4。

Enclog 库支持 SOM 神经网络模型和训练算法。我们可以按照以下方式从 Enclog 库创建和训练一个 SOM:

(def som (network (neural-pattern :som) :input 4 :output 2))

(defn train-som [data]
  (let [trainer (trainer :basic-som :network som
                         :training-set data
                         :learning-rate 0.7
                         :neighborhood-fn 
          (neighborhood-F :single))]
    (train trainer Double/NEGATIVE_INFINITY 10 [])))

在前面的代码中出现的 som 变量代表一个自组织映射(SOM)。可以使用 train-som 函数来训练 SOM。SOM 的训练算法指定为 :basic-som。注意,我们使用 :learning-rate 键将学习率指定为 0.7

在前面的代码中传递给 trainer 函数的 :neighborhood-fn 键指定了对于给定的一组输入值,我们在 SOM 中如何选择获胜节点的邻近节点。我们指定必须使用 (neighborhood-F :single) 来选择获胜节点的单个邻近节点。我们还可以指定不同的邻域函数。例如,我们可以指定 bubble 函数为 :bubble 或径向基函数为 :rbf

我们可以使用train-som函数使用一些输入模式来训练 SOM。请注意,用于训练 SOM 的训练数据将没有任何输出值。SOM 必须自行识别输入数据中的模式。一旦 SOM 被训练,我们可以使用 Java 的classify方法来检测输入中的模式。对于以下示例,我们只提供两个输入模式来训练 SOM:

(defn train-and-run-som []
  (let [input [[-1.0, -1.0, 1.0, 1.0 ]
               [1.0, 1.0, -1.0, -1.0]]
        input-data (data :basic-dataset input nil) ;no ideal data
        SOM        (train-som input-data)
        d1         (data :basic (first input))
        d2         (data :basic (second input))]
    (println "Pattern 1 class:" (.classify SOM d1))
    (println "Pattern 2 class:" (.classify SOM d2))
    SOM))

我们可以运行前面代码中定义的train-and-run-som函数,并观察到 SOM 将训练数据中的两个输入模式识别为两个不同的类别,如下所示:

user> (train-and-run-som)
Iteration # 1 Error: 2.137686% Target-Error: NaN
Iteration # 2 Error: 0.641306% Target-Error: NaN
Iteration # 3 Error: 0.192392% Target-Error: NaN
...
...
Iteration # 9 Error: 0.000140% Target-Error: NaN
Iteration # 10 Error: 0.000042% Target-Error: NaN
Pattern 1 class: 1
Pattern 2 class: 0
#<SOM org.encog.neural.som.SOM@19a0818>

总之,SOMs 是处理无监督学习问题的优秀模型。此外,我们可以轻松地使用 Enclog 库构建 SOM 来模拟这些问题。

摘要

我们在本章中探索了几种有趣的 ANN 模型。这些模型可以应用于解决监督学习和无监督机器学习问题。以下是我们所涵盖的一些其他要点:

  • 我们探讨了 ANN 的必要性和它们的广泛类型,即前馈和循环 ANN。

  • 我们研究了多层感知器 ANN 及其用于训练此 ANN 的反向传播算法。我们还提供了一个使用矩阵和矩阵运算在 Clojure 中实现的简单反向传播算法。

  • 我们介绍了 Enclog 库,该库可用于构建 ANN。这个库可以用于模拟监督学习和无监督机器学习问题。

  • 我们探索了循环 Elman 神经网络,它可以用于在相对较少的迭代次数中产生具有小误差的 ANN。我们还描述了如何使用 Enclog 库创建和训练这样的 ANN。

  • 我们介绍了 SOMs,这些神经网络可以应用于无监督学习的领域。我们还描述了如何使用 Enclog 库创建和训练 SOM。

第五章. 选择和评估数据

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

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

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

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

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

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

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

理解欠拟合和过拟合

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

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

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

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

理解欠拟合和过拟合

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

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

理解欠拟合和过拟合

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

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

理解欠拟合和过拟合

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

评估模型

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

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

然后计算测试集中评估模型样本的成本函数。这为我们提供了一个度量,即当模型用于之前未见过的数据时,模型的整体误差。这个值由估计模型评估模型的术语评估模型表示,也被称为该公式的测试误差。训练数据中的整体误差被称为模型的训练误差,由术语评估模型表示。线性回归模型的测试误差可以按以下方式计算:

评估模型

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

评估模型评估模型评估模型

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

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

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

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

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

预测类别
A B
--- ---
实际类别 A
B 30

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

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

预测类别
A B
--- ---
实际类别 A
B 0

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

理解特征选择

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

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

理解特征选择

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

调整正则化参数

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

调整正则化参数

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

理解学习曲线

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

理解学习曲线

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

理解学习曲线

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

理解学习曲线

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

改进模型

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

  • 添加或删除一些功能。正如我们稍后将要探讨的,这种技术可以用来改善欠拟合和过拟合的模型。

  • 调整正则化参数的值 改进模型。像添加或删除特征一样,这种方法可以应用于欠拟合和过拟合的模型。

  • 收集更多的训练数据。这种方法是改善过拟合模型的一个相当明显的解决方案,因为它需要制定一个更通用的模型来拟合训练数据。

  • 添加模型中其他特征的多项式项作为特征。这种方法可以用来改善欠拟合模型。例如,如果我们正在模拟两个独立的特征变量,改进模型改进模型,我们可以添加改进模型作为额外的特征来改善模型。多项式项可以是更高的次数,例如改进模型改进模型,尽管这可能会导致训练数据过拟合。

使用交叉验证

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

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

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

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

  • 一种常见类型是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。因此,我们使用 贝叶斯概率 函数来模拟特定单词的出现。

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

构建垃圾邮件分类器

这意味着使用具有构建垃圾邮件分类器自由度的构建垃圾邮件分类器分布,电子邮件是垃圾邮件或正常邮件的概率的累积分布函数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函数来计算由表达式构建垃圾邮件分类器转换的几个概率的 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的函数,如下所示:

理解大间隔分类

在前一个方程中,输出变量 理解大间隔分类 不仅依赖于变量 理解大间隔分类,还依赖于系数 理解大间隔分类。变量 理解大间隔分类 类似于我们模型中的输入值向量,而项 理解大间隔分类 是模型的参数向量。对于二元分类,Y 的值必须在 0 和 1 的范围内。此外,一组输入值的类别由输出变量 理解大间隔分类 是更接近 0 还是 1 来决定。对于这些 Y 的值,项 理解大间隔分类 要么远大于 0,要么远小于 0。这可以形式化地表达如下:

理解大间隔分类

对于具有输入值 理解大间隔分类 和输出值 理解大间隔分类理解大间隔分类 个样本,我们定义成本函数 理解大间隔分类 如下:

理解大间隔分类

注意

注意,项 理解大间隔分类 代表从估计模型计算得到的输出变量。

对于逻辑回归分类模型,理解大间隔分类 是将逻辑函数应用于一组输入值 理解大间隔分类 时的值。我们可以简化并展开前面方程定义的成本函数中的求和项 理解大间隔分类,如下所示:

理解大间隔分类

很明显,前面表达式中显示的成本函数取决于表达式中的两个对数项。因此,我们可以将成本函数表示为这两个对数项的函数,分别用项 理解大间隔分类理解大间隔分类 表示。现在,让我们假设以下方程中的两个项:

理解大间隔分类

函数理解大间隔分类理解大间隔分类都是使用逻辑函数组成的。一个模拟逻辑函数的分类器必须经过训练,使得这两个函数在参数向量理解大间隔分类的所有可能值上都被最小化。我们可以使用铰链损失函数来近似使用逻辑函数的线性分类器的期望行为(更多信息,请参阅“损失函数都一样吗?”)。现在,我们将通过将其与逻辑函数进行比较来研究铰链损失函数。以下图表描述了理解大间隔分类函数必须如何随理解大间隔分类项变化,以及它如何可以使用逻辑函数和铰链损失函数来建模:

理解大间隔分类

在前一张图中所示的图中,逻辑函数被表示为一条平滑的曲线。可以看到,在某个给定点之前,该函数迅速下降,然后以更低的速率下降。在这个例子中,逻辑函数速率变化发生的点是x = 0。铰链损失函数通过使用两个在x = 0点交汇的线段来近似这一点。有趣的是,这两个函数都模拟了一种随输入值x成反比变化的速率的行为。同样,我们可以使用铰链损失函数来近似理解大间隔分类函数的效果,如下所示:

理解大间隔分类

注意,理解大间隔分类函数与理解大间隔分类项成正比。因此,我们可以通过模拟铰链损失函数来实现逻辑函数的分类能力,而使用铰链损失函数构建的分类器将表现得与使用逻辑函数的分类器一样好。

如前图所示,hinge 损失函数仅在理解大间隔分类这一点上改变其值。这适用于理解大间隔分类理解大间隔分类这两个函数。因此,我们可以使用 hinge 损失函数根据理解大间隔分类的值是大于还是小于 0 来分离两类数据。在这种情况下,这两类数据之间几乎没有分离间隔。为了提高分类间隔,我们可以修改 hinge 损失函数,使其仅在理解大间隔分类理解大间隔分类时值为大于 0。

修改后的 hinge 损失函数可以如下绘制两类数据。以下图表描述了理解大间隔分类的情况:

理解大间隔分类

同样,对于理解大间隔分类情况修改后的 hinge 损失函数可以通过以下图表进行说明:

理解大间隔分类

注意,在理解大间隔分类的情况下,hinge发生在-1处。

如果我们将理解大间隔分类理解大间隔分类函数替换为 hinge 损失函数,我们就会得到 SVMs(支持向量机)的优化问题(更多信息,请参阅“支持向量网络”),其形式化表达如下:

理解大间隔分类

在前述方程中,项理解大间隔分类是正则化参数。此外,当理解大间隔分类时,SVM 的行为更多地受到理解大间隔分类函数的影响,反之亦然当理解大间隔分类。在某些情况下,模型的正则化参数理解大间隔分类作为常数C添加到优化问题中,其中C类似于理解大间隔分类。这种优化问题的表示可以形式化地表达如下:

理解大间隔分类

由于我们只处理两类数据,其中理解大间隔分类要么是 0 要么是 1,我们可以将之前描述的优化问题重写如下:

理解大间隔分类

让我们尝试可视化 SVM 在训练数据上的行为。假设我们在训练数据中有两个输入变量理解大间隔分类理解大间隔分类。输入值及其类别可以用以下图示表示:

理解大间隔分类

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

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

理解大间隔分类

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

理解大间隔分类

注意

注意,在前述方程中,常数理解大间隔分类仅仅是超平面的 y 截距。

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

理解大间隔分类理解大间隔分类

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

理解大间隔分类

注意

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

如前述方程所示,两个向量理解大间隔分类理解大间隔分类的内积等于理解大间隔分类的转置与向量理解大间隔分类的点积。另一种表示两个向量内积的方法是利用一个向量在另一个向量上的投影,如下所示:

理解大间隔分类

注意,项理解大间隔分类等同于向量 V 与向量 U 转置的向量积理解大间隔分类。由于表达式理解大间隔分类等同于向量的乘积理解大间隔分类,我们可以将我们之前用输入变量投影到输出变量描述的优化问题重新写为以下形式:

理解大间隔分类

因此,SVM 试图最小化参数向量理解大间隔分类中元素的平方和,同时确保将两个数据类别分开的最佳超平面位于两个平面之间以及理解大间隔分类理解大间隔分类。这两个平面被称为 SVM 的支持向量。由于我们必须最小化参数向量理解大间隔分类中元素的值,因此投影理解大间隔分类必须足够大,以确保理解大间隔分类理解大间隔分类

理解大间隔分类

因此,SVM 将确保输入变量理解大间隔分类投影到输出变量理解大间隔分类的投影尽可能大。这意味着 SVM 将在训练数据中找到两个输入值类别之间可能的最大间隔。

SVM 的替代形式

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

如果SVMs 的替代形式是 SVM 估计的超平面的法线,我们可以用以下方程表示这个分离超平面:

SVMs 的替代形式

注意

注意,在前面的方程中,项SVMs 的替代形式是超平面的 y 截距,与我们之前描述的超平面方程中的项SVMs 的替代形式类似。

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

SVMs 的替代形式

我们可以使用表达式SVMs 的替代形式来确定给定输入值集的类别。如果这个表达式的值小于或等于-1,那么我们可以说输入值属于两个数据类别之一。同样,如果表达式SVMs 的替代形式的值大于或等于 1,预测输入值属于第二个类别。这可以正式表示如下:

SVMs 的替代形式

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

SVMs 的替代形式

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

SVMs 的替代形式

在前面方程定义的受约束问题中,我们使用法线SVMs 的替代形式而不是参数向量SVMs 的替代形式来参数化优化问题。通过使用拉格朗日乘数SVMs 的替代形式,我们可以将优化问题表示如下:

SVMs 的替代形式

这种 SVM 优化问题的形式被称为原始形式。请注意,在实践中,只有少数拉格朗日乘数将具有大于 0 的值。此外,这个解可以表示为输入向量SVMs 的替代形式和输出变量SVMs 的替代形式的线性组合,如下所示:

SVMs 的替代形式

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

SVMs 的替代形式

在前面方程中描述的受约束问题中,函数SVMs 的替代形式被称为核函数,我们将在本章后面的部分讨论这个函数在 SVMs 中的作用。

使用 SVM 进行线性分类

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

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

使用 SVMs 进行线性分类

如前述图表所示,圆圈和交叉用于表示样本数据中的两个类别的输入值。线代表 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 将按照以下图示将训练数据分为两类:

使用核 SVM

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

核函数表示为使用核支持向量机,其中使用核支持向量机是从训练数据中得到的输入值向量,使用核支持向量机使用核支持向量机的转换向量。函数使用核支持向量机表示这两个向量的相似性,并且等同于转换空间中这两个向量的内积。如果输入向量使用核支持向量机具有给定的类别,那么当这两个向量的核函数值接近 1 时,即使用核支持向量机时,向量使用核支持向量机的类别与向量使用核支持向量机的类别相同。核函数可以用以下数学表达式表示:

使用核支持向量机

在前一个方程中,函数使用核支持向量机执行从非线性空间使用核支持向量机到线性空间使用核支持向量机的转换。请注意,使用核支持向量机的显式表示不是必需的,只需知道使用核支持向量机是一个内积空间即可。虽然我们可以自由选择任何任意的核函数来建模给定的训练数据,但我们必须努力减少最小化所构建 SVM 模型成本函数的问题。因此,核函数通常被选择,使得计算 SVM 的决策边界只需要确定转换特征空间使用核支持向量机中向量的点积。

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

使用核支持向量机

在前一个方程中,术语使用核支持向量机代表多项式特征的最高次数。此外,当(常数)使用核支持向量机时,核被称作同质的

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

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

使用核 SVM

在前面方程定义的高斯核函数中,术语使用核 SVM表示训练数据的方差,并代表高斯核的宽度

核函数的另一个流行选择是字符串核函数,它作用于字符串值。术语“字符串”指的是符号的有限序列。字符串核函数本质上衡量两个给定字符串之间的相似度。如果传递给字符串核函数的两个字符串相同,该函数返回的值将是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 均值算法的迭代过程:

使用 K 均值聚类

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

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

使用 K 均值聚类

在前面方程定义的优化问题中,使用 K 均值聚类这些项代表围绕输入值聚类的 K 均值。K 均值算法最小化聚类的尺寸,并确定可以最小化这些聚类尺寸的均值。

此算法需要使用 K-means 聚类样本值和使用 K-means 聚类初始均值作为输入。在分配步骤中,输入值被分配到算法提供的初始均值周围的聚类中。在后续的更新步骤中,从输入值计算新的均值。在大多数实现中,新的均值被计算为属于给定聚类的所有输入值的平均值。

大多数实现将使用 K-means 聚类初始均值设置为一些随机选择的输入值。这种技术被称为Forgy 方法的随机初始化。

当聚类数K或输入数据的维度d未定义时,K-means 算法是 NP 难的。当这两个值都固定时,K-means 算法的时间复杂度为使用 K-means 聚类。该算法有几种变体,这些变体在计算新均值的方式上有所不同。

现在,我们将演示如何在纯 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。数据以使用 clj-ml 进行聚类数据大小的矩阵形式提供,其中给定花的物种在该矩阵的最后一列中表示。

我们可以使用 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 聚类器。

使用层次聚类

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

因此,在聚合聚类中,我们将聚类合并成更大的聚类,而在划分聚类中,我们将聚类划分为更小的聚类。在性能方面,现代聚合聚类算法的实现具有使用层次聚类的时间复杂度,而划分聚类的时间复杂度则要高得多。

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

使用层次聚类

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

使用层次聚类

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

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

在层次聚类算法中,距离度量的选择将决定算法产生的聚类形状。两个常用的衡量两个输入向量 使用层次聚类使用层次聚类 之间距离的度量是欧几里得距离 使用层次聚类 和平方欧几里得距离 使用层次聚类,其形式可以表示如下:

使用层次聚类

另一个常用于衡量输入值之间距离的度量标准是最大距离 使用层次聚类,它计算两个给定向量中对应元素的绝对最大差值。此函数可以表示如下:

使用层次聚类

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

在聚合聚类中,两个具有最短距离度量的输入值或聚类被合并成一个新的聚类。当然,“最短距离”的定义是任何聚合聚类技术中独特的地方。在完全链接聚类中,使用彼此最远的输入值来确定分组。因此,这种方法也被称为最远邻聚类。两个值之间的距离使用层次聚类的度量可以如下正式表达:

使用层次聚类

在前面的方程中,函数使用层次聚类是两个输入向量之间选择的距离度量。完全链接聚类将本质上将具有最大距离度量使用层次聚类的值或聚类分组在一起。这种将聚类分组在一起的操作会重复进行,直到产生单个聚类。

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

使用层次聚类

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

现在我们演示层次聚类的简单实现。在这个实现中,我们采取了一种略有不同的方法,将部分所需功能嵌入到 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)(有关更多信息,请参阅观察指数族变量函数时的分布的最大似然理论和应用)。

假设我们想要确定抛硬币得到正面或反面的概率。如果我们抛硬币 使用期望最大化 次,我们将得到 使用期望最大化 次正面和 使用期望最大化 次反面的出现。我们可以通过以下方程估计出现正面的实际概率 使用期望最大化,即正面出现次数与抛硬币总次数的比值:

使用期望最大化

在前一个方程中定义的概率 使用期望最大化 是概率 使用期望最大化 的最大似然估计。在机器学习的背景下,最大似然估计可以被最大化以确定给定类别或类别的发生概率。然而,这个估计的概率可能不会在可用的训练数据上以良好的定义方式统计分布,这使得难以有效地确定最大似然估计。通过引入一组隐藏值来解释训练数据中的未观察值,问题得到了简化。隐藏值不是直接从数据中测量的,而是从影响数据的因素中确定的。对于给定的一组观察值 使用期望最大化 和一组隐藏值 使用期望最大化,参数 使用期望最大化 的似然函数定义为 使用期望最大化使用期望最大化 发生的概率,对于给定的一组参数 使用期望最大化。似然函数可以用 使用期望最大化 的数学表达式表示,可以表示如下:

使用期望最大化

EM 算法包括两个步骤——期望步骤和最大化步骤。在期望步骤中,我们计算 对数似然 函数的期望值。这一步确定了一个必须在下一步中最大化的度量 使用期望最大化,即算法的最大化步骤。这两个步骤可以正式总结如下:

使用期望最大化

在前一个方程中,通过迭代计算最大化函数 Q 值的 使用期望最大化 值,直到它收敛到一个特定的值。术语 使用期望最大化 代表算法 使用期望最大化 迭代中的估计参数。此外,术语 使用期望最大化 是对数似然函数的期望值。

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 变量实际上是一个 使用 SOMs 大小的矩阵,它表示我们从 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 的神经网络是一个 使用 SOMs 矩阵。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 函数时,产生了以下线性图:

使用 SOMs

上述图表展示了 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)的矩阵运算来计算给定输入值的特征值。表示为 降低数据维度 的协方差矩阵,可以从具有 降低数据维度 个样本的输入向量 降低数据维度 中确定如下:

降低数据维度

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

降低数据维度

可以将 SVD 视为将大小为 降低数据维度 的矩阵 降低数据维度 分解为三个矩阵 降低数据维度降低数据维度,和 降低数据维度。矩阵 降低数据维度 的大小为 降低数据维度,矩阵 降低数据维度 的大小为 降低数据维度,矩阵 降低数据维度 的大小为 降低数据维度。矩阵 降低数据维度 实际上代表了具有 降低数据维度 维度的 降低数据维度 输入向量。矩阵 降低数据维度 是一个对角矩阵,被称为矩阵 降低数据维度奇异值,而矩阵 降低数据维度降低数据维度 分别被称为 降低数据维度左奇异向量右奇异向量。在 PCA 的上下文中,矩阵 降低数据维度 被称为 降维成分,而矩阵 降低数据维度 被称为样本数据的 旋转成分

将输入向量的 降低数据维度 维度降低到 降低数据维度 维度的 PCA 算法可以总结如下:

  1. 从输入向量数据降维计算协方差矩阵数据降维

  2. 通过对协方差矩阵数据降维应用奇异值分解(SVD),计算矩阵数据降维数据降维,和数据降维

  3. 数据降维矩阵数据降维中,选择前数据降维列以生成矩阵数据降维,该矩阵被称为矩阵数据降维降维左奇异向量降维旋转矩阵。此矩阵代表了样本数据的数据降维个主成分,并将具有数据降维的大小。

  4. 计算具有数据降维维度的向量,用数据降维表示,如下所示:数据降维

注意,PCA 算法的输入是经过均值归一化和特征缩放后的样本数据集的输入向量集数据降维

由于在前面步骤中计算的矩阵数据降维数据降维列,矩阵数据降维将具有数据降维的大小,它代表了数据降维维度的数据降维个输入向量。我们应该注意,维度数数据降维的降低可能会导致数据方差损失增加。因此,我们应该选择数据降维,使得方差损失尽可能小。

原始输入向量数据降维可以通过矩阵数据降维和降维后的左奇异向量数据降维如下重建:

数据降维

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 函数执行。此函数返回一个包含旋转矩阵 数据维度降低 和降低矩阵 数据维度降低 的映射,这些矩阵是我们之前描述的。我们可以使用 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 函数时生成的:

数据维度降低

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

摘要

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

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

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

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

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

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

第八章. 异常检测与推荐

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

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

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

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

检测异常

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

  • 为了检测问题

  • 为了检测新现象

  • 为了监控异常行为

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

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

检测异常

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

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

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

检测异常

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

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

在那些样本值都应符合某个平均值并允许一定容忍度的模型中,通常使用高斯正态分布作为分布模型来训练异常检测器。此模型有两个参数——均值检测异常和方差检测异常。这种分布模型常用于异常检测的统计方法中,其中输入变量通常在统计上接近某个预定的平均值。

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

检测异常

PDF 也可以与正态分布模型结合用于异常检测的目的。正态分布的 PDF 由分布的均值检测异常和方差检测异常参数化,并且可以正式表示如下:

检测异常

我们现在将展示一个基于先前讨论的正态分布 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 的范围内。我们可以通过以下表格表示来可视化此推荐系统的样本数据:

基于内容的过滤

在前一个表中,系统有基于内容的过滤个产品以及基于内容的过滤个用户。每个产品由基于内容的过滤个特征定义,每个特征都将有一个在 0 到 10 之间的值,并且每个产品也会被用户评分。设用户基于内容的过滤对产品基于内容的过滤的评分表示为基于内容的过滤。使用输入值基于内容的过滤,或者说输入向量基于内容的过滤,以及用户基于内容的过滤的评分基于内容的过滤,我们可以估计一个参数向量基于内容的过滤,我们可以使用它来预测用户的评分。因此,基于内容的过滤实际上将线性回归的副本应用于每个用户的评分和每个产品的特征值,以估计一个回归模型,该模型反过来可以用来估计一些未评分产品的用户评分。实际上,我们使用独立变量基于内容的过滤和因变量基于内容的过滤以及系统中所有用户来学习参数基于内容的过滤。使用估计的参数基于内容的过滤和一些独立变量的给定值,我们可以预测任何给定用户的因变量值。因此,基于内容的过滤的优化问题可以表示如下:

基于内容的过滤基于内容的过滤

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

基于内容的过滤

简而言之,参数向量基于内容的过滤试图缩放或转换输入变量以匹配模型的输出变量。添加的第二项是为了正则化。有趣的是,在递减方程中定义的优化问题与线性回归类似,因此基于内容的过滤可以被视为线性回归的扩展。

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

协同过滤

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

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

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

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

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

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

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

  • 观察用户查看的项目

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

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

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

协同过滤

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

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

协同过滤协同过滤

前面的方程式被视为我们为基于内容的过滤定义的优化问题的逆。不是估计参数向量协同过滤,协同过滤试图确定产品的特征值协同过滤。同样,我们可以如下定义多个用户的优化问题:

协同过滤

使用协同过滤,我们可以估计产品的特征协同过滤协同过滤,然后使用这些特征值来改进用户的行为模型协同过滤。改进的用户行为模型可以再次用于生成物品的更好的特征值。这个过程然后重复进行,直到特征值和行为模型收敛到某些适当的值。

注意

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

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

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

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

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

使用 Slope One 算法

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

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

Slope One 算法使用一个更简单的预测器 使用 Slope One 算法 来模拟用户的回归行为模式,因此计算成本更低。参数 使用 Slope One 算法 可以通过计算两个项目之间用户评分的差异来估计。由于 Slope One 算法的定义简单,它可以轻松高效地实现。有趣的是,这个算法比其他协同过滤技术更不容易过拟合。

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

使用 Slope One 算法

在前面表格中显示的数据中,可以通过使用 用户 1 提供的评分来找到 项目 A项目 B 之间的评分差异。这个差异是 使用 Slope One 算法。因此,我们可以将这个差异加到 用户 2项目 A 的评分上,以预测他对 项目 B 的评分,这个评分等于 使用 Slope One 算法

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

使用 Slope One 算法

在这个例子中,项目 A项目 B 之间对于 用户 2 (-1) 和 用户 1 (+2) 的评分差异的平均值是 使用 Slope One 算法。因此,平均而言,项目 A 的评分比 项目 B使用 Slope One 算法。同样,项目 A项目 C 之间的平均评分差异是 使用 Slope One 算法。我们可以使用 用户 3项目 B 的评分以及 项目 A项目 B 的评分差异的平均值来预测 用户 3项目 A 的评分。这个值是 使用 Slope One 算法

现在我们将描述一个简洁的 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 算法,这是一种协同过滤的形式,并描述了该算法的简洁实现。

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

第九章:大规模机器学习

在本章中,我们将探讨一些处理大量数据以训练机器学习模型的方法。在本章的后半部分,我们还将演示如何使用基于云的服务进行机器学习。

使用 MapReduce

在数据并行性的背景下,经常遇到的一种数据处理方法是MapReduce。这种技术灵感来源于函数式编程中的mapreduce函数。尽管这些函数作为理解算法的基础,但 MapReduce 的实际实现更侧重于扩展和分布数据处理。目前有几种活跃的 MapReduce 实现,例如 Apache Hadoop 和 Google Bigtable。

MapReduce 引擎或程序由一个函数组成,该函数在可能非常大的数据集的给定记录上执行一些处理(更多信息,请参阅“多核机器学习中的 Map-Reduce”)。这个函数代表了算法的Map()步骤。这个函数应用于数据集中的所有记录,然后结果被合并。提取结果的后续步骤被称为算法的Reduce()步骤。为了在大数据集上扩展此过程,首先将提供给Map()步骤的输入数据分区,然后在不同的计算节点上处理。这些节点可能位于不同的机器上,也可能不在,但给定节点执行的处理与其他节点在系统中的处理是独立的。

一些系统采用不同的设计,其中代码或查询被发送到包含数据的节点,而不是相反。这个步骤,即对输入数据进行分区,然后将查询或数据转发到不同的节点,被称为算法的Partition()步骤。总结来说,这种处理大数据集的方法与传统方法大相径庭,传统方法尽可能快地迭代整个数据。

MapReduce 比其他方法具有更好的可扩展性,因为输入数据的分区可以在物理上不同的机器上独立处理,然后稍后合并。这种可扩展性的提升不仅是因为输入被分配到多个节点,还因为复杂性的内在降低。对于大的问题空间,一个 NP 难问题无法解决,但如果问题空间较小,则可以解决。

对于具有使用 MapReduce使用 MapReduce算法复杂度的问题,实际上将问题空间分区会增加解决给定问题所需的时间。然而,如果算法复杂度是使用 MapReduce,其中使用 MapReduce,那么将问题空间分区将减少解决问题所需的时间。在 NP-hard 问题的情况下,使用 MapReduce。因此,通过分区问题空间,MapReduce 减少了解决 NP-hard 问题所需的时间(更多信息,请参阅评估 MapReduce 在多核和多处理器系统中的应用)。

MapReduce 算法可以用以下图示说明:

使用 MapReduce

在前面的图中,输入数据首先被分区,然后在Map()步骤中独立处理每个分区。最后,在Reduce()步骤中将结果合并。

我们可以用 Clojure 伪代码简洁地定义 MapReduce 算法,如下面的代码所示:

(defn map-reduce [f partition-size coll]
  (->> coll
       (partition-all partition-size)   ; Partition
       (pmap f)                         ; Parallel Map
       (reduce concat)))                ; Reduce

在之前的代码中定义的map-reduce函数使用标准的pmap(并行映射的缩写)函数将函数f的应用分配给多个处理器(或线程)。输入数据,由集合coll表示,首先使用partition-all函数进行分区,然后使用pmap函数并行地将函数f应用于每个分区。然后使用标准reduceconcat函数的组合将这个Map()步骤的结果合并。请注意,这是由于每个数据分区都是一个序列,因此pmap函数将返回一个分区的序列,可以连接或连接成一个单一的序列以产生计算的结果。

当然,这只是一个关于 MapReduce 算法核心的理论解释。实际的实现往往更关注于在多台机器之间分配处理,而不是在之前代码中定义的map-reduce函数中的多个处理器或线程之间。

查询和存储数据集

当处理大量数据集时,能够根据一些任意条件查询数据是非常有用的。此外,将数据存储在数据库中而不是平面文件或内存资源中更为可靠。Incanter 库为我们提供了几个有用的函数来执行这些操作,我们将在接下来的代码示例中展示。

注意

在即将到来的示例中使用的 Incanter 库和 MongoDB 驱动程序可以通过将以下依赖项添加到project.clj文件中来添加到 Leiningen 项目中:

[congomongo "0.4.1"]
[incanter "1.5.4"]

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

(ns my-namespace
  (:use incanter.core
        [incanter.mongodb   :only [insert-dataset
                                   fetch-dataset]]
        [somnium.congomongo :only [mongo!]]
        [incanter.datasets  :only [get-dataset]]))

此外,此示例需要 MongoDB 已安装并运行。

对于这个示例,我们将使用 Iris 数据集,可以使用 incanter.datasets 命名空间中的 get-dataset 函数获取。代码如下:

(def iris (get-dataset :iris))

如前代码所示,我们只需将 Iris 数据集绑定到一个变量 iris。我们可以使用 with-data 函数对这个数据集执行各种操作。要查看数据,我们可以使用 view 函数结合 with-data 函数来提供数据集的表格表示,如下代码所示:

user> (with-data iris
        (view (conj-cols (range (nrow $data)) $data)))

$data 变量是一个特殊绑定,可以在 with-data 函数的作用域内表示整个数据集。在前面的代码中,我们通过 conj-colsnrowsrange 函数的组合向数据中添加一个额外的列来表示记录的行号。然后使用 view 函数以类似电子表格的表格形式显示数据。前面的代码生成了以下表格,表示数据集:

查询和存储数据集

我们还可以使用 with-data 函数作用域内的 $ 函数从原始数据集中选择我们感兴趣的列,如下代码所示:

user> (with-data iris ($ [:Species :Sepal.Length]))

|   :Species | :Sepal.Length |
|------------+---------------|
|     setosa |           5.1 |
|     setosa |           4.9 |
|     setosa |           4.7 |
  ...
|  virginica |           6.5 |
|  virginica |           6.2 |
|  virginica |           5.9 |

在前面所示的代码示例中,$ 函数从 iris 数据集中选择了 :Species:Sepal.Length 列。我们还可以使用 $where 函数根据条件过滤数据,如下代码所示:

user> (with-data iris ($ [:Species :Sepal.Length]
                         ($where {:Sepal.Length 7.7})))

|  :Species | :Sepal.Length |
|-----------+---------------|
| virginica |           7.7 |
| virginica |           7.7 |
| virginica |           7.7 |
| virginica |           7.7 |

前面的示例使用 $where 函数查询 iris 数据集中 :Sepal.Length 列等于 7.7 的记录。我们还可以在传递给 $where 函数的映射中使用 :$gt:$lt 符号来指定值的上下限,如下代码所示:

user> (with-data iris ($ [:Species :Sepal.Length]
                         ($where {:Sepal.Length {:$gt 7.0}})))

|  :Species | :Sepal.Length |
|-----------+---------------|
| virginica |           7.1 |
| virginica |           7.6 |
| virginica |           7.3 |
  ...
| virginica |           7.2 |
| virginica |           7.2 |
| virginica |           7.4 |

前面的示例检查具有大于 7:Sepal.Length 属性值的记录。要检查列的值是否在给定的范围内,我们可以在传递给 $where 函数的映射中指定 :$gt:$lt 键,如下代码所示:

user> (with-data iris ($ [:Species :Sepal.Length]
                         ($where {:Sepal.Length
                                  {:$gt 7.0 :$lt 7.5}})))

|  :Species  |:Sepal.Length |
|------------+--------------|
| virginica  |          7.1 |
| virginica  |          7.3 |
| virginica  |          7.2 |
| virginica  |          7.2 |
| virginica  |          7.2 |
| virginica  |          7.4 |

前面的示例检查具有 7.07.5 范围内的 :Sepal.Length 属性值的记录。我们还可以使用 $:in 键指定一组离散值,例如在表达式 {:$in #{7.2 7.3 7.5}} 中。Incanter 库还提供了其他几个函数,如 $join$group-by,可以用来表达更复杂的查询。

Incanter 库提供了操作 MongoDB 的函数,以持久化和检索数据集。MongoDB 是一个非关系型文档数据库,允许存储具有动态模式的 JSON 文档。要连接到 MongoDB 实例,我们使用 mongo! 函数,如下代码所示:

user> (mongo! :db "sampledb")
true

在前面的代码中,数据库名称 sampledb 作为关键字参数指定给 mongo! 函数的 :db 键。我们还可以分别使用 :host:port 关键字参数指定要连接的实例的主机名和端口号。

我们可以使用incanter.mongodb命名空间中的insert-dataset函数将数据集存储在连接的 MongoDB 实例中。不幸的是,MongoDB 不支持使用点字符(.)作为列名,因此我们必须更改iris数据集中的列名,才能成功使用insert-dataset函数存储它。可以使用col-names函数替换列名,如下面的代码所示:

user> (insert-dataset
:iris (col-names iris [:SepalLength
:SepalWidth
:PetalLength
:PetalWidth
:Species]))

之前的代码在替换列名中的点字符后,将iris数据集存储在 MongoDB 实例中。

注意

注意,数据集将被存储在sampledb数据库中名为iris的集合中。此外,MongoDB 将为存储在数据库中的数据集中的每条记录分配一个基于哈希的 ID。此列可以使用:_id关键字引用。

要从数据库中检索数据集,我们使用fetch-dataset函数,如下面的代码所示。此函数返回的值可以直接由with-data函数使用,以查询和查看检索到的数据集。

user> (with-data (fetch-dataset :iris) ($ [:Species :_id]
                                          ($where {:SepalLength
                                                   {:$gt 7}})))

|  :Species |                     :_id |
|-----------+--------------------------|
| virginica | 52ebcc1144ae6d6725965984 |
| virginica | 52ebcc1144ae6d6725965987 |
| virginica | 52ebcc1144ae6d6725965989 |
  ...
| virginica | 52ebcc1144ae6d67259659a0 |
| virginica | 52ebcc1144ae6d67259659a1 |
| virginica | 52ebcc1144ae6d67259659a5 |

我们也可以在存储我们的数据集之后,使用mongo客户端检查数据库,如下面的代码所示。正如我们提到的,我们的数据库名为sampledb,我们必须使用use命令选择此数据库,如下面的终端输出所示:

$ mongo
MongoDB shell version: 2.4.6
connecting to: test
Server has startup warnings:
...

> use sampledb
switched to db sampledb

我们可以使用show collections命令查看数据库中的所有集合。查询可以通过在变量db实例的适当属性上执行find()函数来执行,如下面的代码所示:

> show collections
iris
system.indexes
>
> db.iris.find({ SepalLength: 5})

{ "_id" : ObjectId("52ebcc1144ae6d6725965922"),
  "Species" : "setosa",
  "PetalWidth" : 0.2,
  "PetalLength" : 1.4,
  "SepalWidth" : 3.6,
  "SepalLength" : 5 }
{ "_id" : ObjectId("52ebcc1144ae6d6725965925"),
  "Species" : "setosa",
  "PetalWidth" : 0.2,
  "PetalLength" : 1.5,
  "SepalWidth" : 3.4,
  "SepalLength" : 5 }

...

总结来说,Incanter 库为我们提供了足够的工具来查询和存储数据集。此外,MongoDB 可以通过 Incanter 库轻松地用于存储数据集。

云端机器学习

在基于网络和云服务的现代,也可以将数据集和机器学习模型持久化到在线云存储中。当处理大量数据时,这是一个很好的解决方案,因为云解决方案负责处理大量数据的存储和处理。

BigML (bigml.com/) 是一个机器学习资源的云提供商。BigML 内部使用分类和回归树CARTs),这是决策树的一种特殊化(更多信息,请参阅Top-down induction of decision trees classifiers-a survey),作为机器学习模型。

BigML 为开发者提供了一个简单的 REST API,可以用于从任何可以发送 HTTP 请求的语言或平台中与该服务交互。该服务支持多种文件格式,如CSV(逗号分隔值)、Excel 电子表格和 Weka 库的 ARFF 格式,还支持多种数据压缩格式,如 TAR 和 GZIP。此服务还采用白盒方法,即在模型可以下载用于本地使用的同时,还可以通过在线 Web 界面使用模型进行预测。

BigML 在几种语言中都有绑定,我们将在本节中演示 BigML 的 Clojure 客户端库。与其他云服务一样,BigML 的用户和开发者必须首先注册账户。然后,他们可以使用这个账户和提供的 API 密钥通过客户端库访问 BigML。一个新的 BigML 账户提供了一些示例数据集以供实验,包括我们在本书中经常遇到的 Iris 数据集。

BigML 账户的仪表板提供了一个简单的基于 Web 的用户界面,用于账户可用的所有资源。

云中的机器学习

BigML 资源包括来源、数据集、模型、预测和评估。我们将在接下来的代码示例中讨论这些资源中的每一个。

注意

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

[bigml/clj-bigml "0.1.0"]

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

(ns my-namespace
  (:require [bigml.api [core :as api]
             [source :as source]
             [dataset :as dataset]
             [model :as model]
             [prediction :as prediction]
             [evaluation :as evaluation]]))

首先,我们必须为 BigML 服务提供认证详情。这是通过使用bigml.api命名空间中的make-connection函数来完成的。我们必须向make-connection函数提供用户名、API 密钥和一个标志,指示我们是否使用开发或生产数据集,如下面的代码所示。请注意,此用户名和 API 密钥将显示在您的 BigML 账户页面上。

(def default-connection
  (api/make-connection
   "my-username"                               ; username
   "a3015d5fa2ee19604d8a69335a4ac66664b8b34b"  ; API key
   true))

要使用之前代码中定义的default-connection连接,我们必须使用with-connection函数。我们可以通过使用一个简单的宏来避免重复使用带有default-connection变量的with-connection函数,如下面的代码所示:

(defmacro with-default-connection [& body]
  '(api/with-connection default-connection
     ~@body))

实际上,使用with-default-connection与使用带有default-connection绑定的with-connection函数一样好,从而帮助我们避免代码重复。

BigML 有一个表示可以转换为训练数据的资源的概念。BigML 支持本地文件、远程文件和内联代码资源作为来源,并支持多种数据类型。要创建资源,我们可以使用bigml.source命名空间中的create函数,如下面的代码所示:

(def default-source
  (with-default-connection
    (source/create [["Make"  "Model"   "Year" "Weight" "MPG"]
                    ["AMC"   "Gremlin" 1970   2648     21]
                    ["AMC"   "Matador" 1973   3672     14]
                    ["AMC"   "Gremlin" 1975   2914     20]
                    ["Honda" "Civic"   1974   2489     24]
                    ["Honda" "Civic"   1976   1795     33]])))

在前代码中,我们使用一些内联数据定义了一个源。实际上,这些数据是一组各种汽车模型的特征,例如它们的制造年份和总重量。最后一个特征是汽车模型的里程或 MPG。按照惯例,BigML 源将最后一列视为机器学习模型的输出或目标变量。

我们现在必须将源转换为 BigML 数据集,这是一个源原始数据的结构化和索引表示。数据集中的每个特征都被分配一个唯一的整数 ID。然后,可以使用此数据集来训练一个机器学习 CART 模型,在 BigML 术语中简单地称为模型。我们可以使用dataset/createmodel/create函数分别创建数据集和模型,如下代码所示。此外,我们还将不得不使用api/get-final函数来最终化已发送到 BigML 云服务进行处理的资源。

(def default-dataset
  (with-default-connection
    (api/get-final (dataset/create default-source))))

(def default-model
  (with-default-connection
    (api/get-final (model/create default-dataset))))

BigML 还提供了一个训练好的 CART 模型的交互式可视化。对于我们的训练数据,以下可视化被生成:

云中的机器学习

现在我们可以使用训练好的模型来预测输出变量的值。每次预测都存储在 BigML 云服务中,并在仪表板的预测选项卡中显示。这是通过bigml.prediction命名空间中的create函数完成的,如下代码所示:

(def default-remote-prediction
  (with-default-connection
    (prediction/create default-model [1973 3672])))

在前代码中,我们尝试通过向prediction/create函数提供制造年份和汽车重量来预测汽车模型的 MPG(每加仑英里数,是里程的衡量标准)。该函数返回的值是一个映射,其中包含一个名为:prediction的键,它代表输出变量的预测值。此键的值是另一个映射,其中包含列 ID 作为键和它们的预测值作为映射中的值,如下代码所示:

user> (:prediction default-remote-prediction)
{:000004 33}

如前代码所示,MPG 列(ID 为000004)被预测为具有值为 33,这是由训练模型预测的。prediction/create函数创建了一个在线或远程预测,并在每次调用时将数据发送到 BigML 服务。或者,我们可以从 BigML 服务下载一个函数,我们可以使用该函数通过prediction/predictor函数在本地执行预测,如下代码所示:

(def default-local-predictor
  (with-default-connection
    (prediction/predictor default-model)))

现在我们可以使用下载的函数default-local-predictor来执行本地预测,如下面的 REPL 输出所示:

user> (default-local-predictor [1983])
22.4
user> (default-local-predictor [1983] :details true)
{:prediction {:000004 22.4},
:confidence 24.37119,
:count 5, :id 0,
:objective_summary
 {:counts [[14 1] [20 1] [21 1] [24 1] [33 1]]}}

如前代码所示,局部预测函数预测 1983 年制造的汽车的 MPG 值为 22.4。我们还可以传递:details关键字参数给default-local-predictor函数,以提供更多关于预测的信息。

BigML 还允许我们评估训练好的 CART 模型。我们现在将使用 Iris 数据集训练一个模型,然后进行交叉验证。BigML 库中的evaluation/create函数将使用训练模型和一些交叉验证数据创建一个评估。此函数返回一个包含模型所有交叉验证信息的映射。

在之前的代码片段中,我们在训练模型的几乎所有阶段都使用了api/get-final函数。在以下代码示例中,我们将尝试通过使用宏来避免重复使用此函数。我们首先定义一个函数,将api/get-finalwith-default-connection函数应用于接受任意数量参数的任意函数。

(defn final-with-default-connection [f & xs]
  (with-default-connection
    (api/get-final (apply f xs))))

使用之前代码中定义的final-with-default-connection函数,我们可以定义一个宏,它将映射到值列表,如以下代码所示:

(defmacro get-final-> [head & body]
  (let [final-body (map list
                        (repeat 'final-with-default-connection)
                        body)]
    '(->> ~head
          ~@final-body)))

在之前的代码中定义的get-final->宏基本上使用->>线程宏将head参数中的值通过body参数中的函数传递。此外,之前的宏将final-with-default-connection函数的应用与body参数中函数返回的值交错。现在我们可以使用get-final->宏在单个表达式中创建源、数据集和模型,然后使用evaluation/create函数评估模型,如以下代码所示:

(def iris-model
  (get-final-> "https://static.bigml.com/csv/iris.csv"
               source/create
               dataset/create
               model/create))

(def iris-evaluation
  (with-default-conection
    (api/get-final
     (evaluation/create iris-model (:dataset iris-model)))))

在之前的代码片段中,我们使用包含 Iris 样本数据的远程文件作为源,并使用我们之前定义的get-final->宏按顺序将其传递给source/createdataset/createmodel/create函数。

然后使用api/get-finalevaluation/create函数的组合对构建的模型进行评估,并将结果存储在变量iris-evaluation中。请注意,我们使用训练数据本身进行交叉验证模型,这实际上并没有真正达到任何有用的效果。然而,在实践中,我们应该使用未见过的数据来评估训练好的机器学习模型。显然,当我们使用训练数据交叉验证模型时,模型的准确率被发现是 100%或 1,如以下代码所示:

user> (-> iris-evaluation :result :model :accuracy)
1

BigML 仪表板还将提供从之前示例中的数据构建的模型的可视化(如图所示)。此图展示了从 Iris 样本数据集构建的 CART 决策树。

云中的机器学习

总结来说,BigML 云服务为我们提供了多种灵活的选项,以可扩展和平台无关的方式从大型数据集中估计 CART。BigML 只是众多在线机器学习服务中的一种,鼓励读者探索其他机器学习云服务提供商。

摘要

在本章中,我们探讨了处理大量样本数据的一些有用技术。我们还描述了如何通过 BigML 等在线服务使用机器学习模型,如下所示:

  • 我们描述了 MapReduce 及其如何通过并行和分布式计算处理大量数据。

  • 我们探讨了如何使用 Incanter 库和 MongoDB 查询和持久化数据集。

  • 我们简要研究了 BigML 云服务提供商以及我们如何利用这项服务从样本数据中制定和评估 CARTs。

总之,我们描述了本书中可以用来实现机器学习系统的几种技术和工具。Clojure 通过利用 JVM 和同等强大的库的力量,以简单和可扩展的方式帮助我们构建这些系统。我们还研究了如何评估和改进机器学习系统。程序员和架构师可以使用这些工具和技术来对用户数据进行建模和学习,以及构建为用户提供更好体验的机器学习系统。

您可以通过本书中使用的各种引用和参考文献来探索机器学习的学术和研究。新的学术论文和关于机器学习的文章提供了对机器学习前沿的更多见解,并鼓励您去寻找和探索它们。

附录 A. 参考文献

第一章

  • Brin, Sergey 和 Lawrence Page. 大规模超文本网络搜索引擎的解剖学. 1998.

第二章

  • Anderson, Edgar. "Iris 中的物种问题". 密苏里植物园年报 23 (3): 457–509. 1936.

第三章

  • N. S., Altman. 核和最近邻非参数回归的介绍. 美国统计学家. 1992.

  • J. R., Quinlan. 决策树归纳, Mach. Learn. 1, 1 (Mar. 1986). 81–106. 1986.

  • J. R., Quinlan. C4.5:机器学习程序. 1993.

  • Shannon, Claude E. "通信的数学理论". 贝尔系统技术杂志 27 (3): 379–423. 1948.

  • Mansour, Y. "基于树大小的悲观决策树剪枝". 第 14 届国际机器学习会议论文集. 195–201. 1997.

第四章

  • Bhadeshia, H. K. D. H. 材料科学中的神经网络. ISIJ 国际 39 (10): 966–979. 1999.

  • Balabina, Roman M., Ravilya Z. Safievaa, 和 Ekaterina I. Lomakinab. 基于汽油近红外光谱的校准模型构建的波形神经网络(WNN)方法. 化学计量学和智能实验室系统,第 93 卷,第 1 期. 2008.

  • Schuster, M. 和 K. K. Paliwal. 双向循环神经网络. IEEE 信号处理杂志, 45: 2673–81, 1997 年 11 月.

  • Igel, Christian 和 Michael Hüsken. 改进 Rprop 学习算法的经验评估. 神经计算 50: 105–123. 2003.

  • Broomhead, D. S. 和 David Lowe. 多变量函数插值和自适应网络. 复杂系统 2: 321–355. 1988.

第五章

第六章

  • Rosasco, L., E. D. De Vito, A. Caponnetto, M. Piana 和 A. Verri. "损失函数是否都相同?". 神经计算 16 (5): 1063–1076. 2004.

  • Cortes, C. 和 V. Vapnik. "支持向量机". 机器学习 20 (3): 273. 1995.

  • Platt, John. 支持向量机的序列最小优化:一种快速训练算法. CiteSeerX: 10.1.1.43.4376. 1998.

第七章

  • Hartigan, J. A. 和 M. A. Wong. "算法 AS 136:K 均值聚类算法". 王室统计学会会刊,系列 C 28 (1): 100–108. JSTOR 2346830. 1979.

  • Sundberg, Rolf. 在观察指数族变量函数时生成的分布的最大似然理论及其应用. 博士论文. 斯德哥尔摩大学数学统计研究所. 1971.

  • Bacao, Fernando, Victor Lobo 和 Marco Painho. 自组织映射作为 K 均值聚类的替代. CS 2005,LNCS 3516,476–483. 2005.

  • Jolliffe, I.T. Principal Component Analysis, Series: Springer Series in Statistics. 2nd ed., Springer, NY, 2002. 2002.

第八章

  • Hodge, V. J., and J. Austin. "A Survey of Outlier Detection Methodologies". Artificial Intelligence Review 22 (2): 85. 2004.

  • Montaner, M, B Lopez, and J. L. de la Rosa. "A Taxonomy of Recommender Agents on the Internet". Artificial Intelligence Review 19 (4): 285–330. 2003.

  • Lemire, Daniel, and Anna Maclachlan. Slope One Predictors for Online Rating-Based Collaborative Filtering. In SIAM Data Mining (SDM'05). Newport Beach. California. 2005.

第九章

  • Chu, Cheng-Tao, Sang Kyun Kim, Yi-An Lin, YuanYuan Yu, Gary Bradski, Andrew Ng, and Kunle Olukotun. "Map-Reduce for Machine Learning on Multicore". NIPS 2006.

  • Ranger, C., R. Raghuraman, A. Penmetsa, G. Bradski, and C. Kozyrakis. Evaluating MapReduce for Multi-core and Multiprocessor Systems.IEEE 13th International Symposium on High Performance Computer Architecture. 13. 2007.

  • Rokachand, L., O. Maimon. Top-down induction of decision trees classifiers-a survey.IEEE Transactions on Systems, Man, and Cybernetics. Part C 35 (4): 476–48. 2005.

posted @ 2025-09-21 12:11  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报