Go-机器学习-全-
Go 机器学习(全)
原文:
annas-archive.org/md5/108241813dcacb35d00a6178bea25c3d
译者:飞龙
前言
似乎机器学习和人工智能在时尚科技公司以及越来越多的企业公司中都非常流行。数据科学家正在使用机器学习来完成从驾驶汽车到绘制猫的各种事情。然而,如果您关注数据科学社区,您很可能看到 Python 和 R 用户之间发生某种类似于语言战争的情况。这些语言主导着机器学习对话,并且似乎是在组织中集成机器学习的唯一选择。本书将探索第三种选择:Go 语言,这是一种由谷歌创建的开源编程语言。
Go 语言独特的特性和 Go 程序员的思维方式可以帮助数据科学家克服他们遇到的某些常见挑战。特别是,数据科学家(不幸的是)以生产出糟糕、低效且难以维护的代码而闻名。本书将解决这个问题,并清楚地展示如何在机器学习中保持高效,同时生产出保持高完整性水平的应用。它还将帮助您克服在现有工程组织中集成分析和机器学习代码的常见挑战。
本书将培养读者成为高效、创新的数据分析师,他们利用 Go 语言构建稳健且有价值的应用。为此,本书将清晰地介绍 Go 语言中机器学习的技术、编程方面,同时也会指导读者理解适用于现实世界分析的合理工作流程和哲学。
本书涵盖的内容
在机器学习工作流程中准备和分析数据:
-
第一章,收集和组织数据,涵盖了从本地和远程来源收集、组织和解析数据。一旦读者完成这一章节,他们将了解如何与存储在各种地方和不同格式的数据进行交互,如何解析和清理这些数据,以及如何输出清理和解析后的数据。
-
第二章,矩阵、概率和统计学,涵盖了将数据组织成矩阵以及矩阵运算。一旦读者完成这部分内容,他们将了解如何在 Go 程序中形成矩阵,以及如何利用这些矩阵执行各种类型的矩阵运算。这一章节还涵盖了日常数据分析工作中关键的统计指标和操作。一旦读者完成这一章节,他们将了解如何进行扎实的总结性数据分析,描述和可视化分布,量化假设,以及使用例如降维等方法转换数据集。
-
第三章,评估与验证,涵盖了评估与验证,这是衡量机器应用性能和确保它们泛化的关键。一旦读者完成本章,他们将了解各种指标来衡量模型的性能(换句话说,评估模型)以及各种验证模型的一般技术。
机器学习技术:
-
第四章,回归,解释了回归,这是一种广泛用于建模连续变量的技术,也是其他模型的基础。回归产生的模型是立即可解释的。因此,当在组织中引入预测能力时,它可以提供一个极好的起点。
-
第五章,分类,涵盖了分类,这是一种与回归不同的机器学习技术,其目标变量通常是分类或标记的。例如,分类模型可以将电子邮件分类为垃圾邮件和非垃圾邮件,或将网络流量分类为欺诈或非欺诈。
-
第六章,聚类,涵盖了聚类,这是一种无监督机器学习技术,用于形成样本分组。本章结束时,读者将能够自动形成数据点的分组,以更好地理解其结构。
-
第七章,时间序列与异常检测,介绍了用于建模时间序列数据的技术,例如股价、用户事件等。阅读本章后,读者将了解如何评估时间序列中的各种术语,构建时间序列模型,并在时间序列中检测异常。
将机器学习提升到下一个层次:
-
第八章,神经网络与深度学习,介绍了使用神经网络进行回归、分类和图像处理的技术。阅读本章后,读者将了解何时以及如何应用这些更复杂的建模技术。
-
第九章,部署和分析模型,使读者能够将我们在整个课程中开发的模型部署到生产环境中,并在生产规模数据上分配处理。本章说明了这两件事如何轻松完成,而无需对本书中使用的代码进行重大修改。
附录,与机器学习相关的算法/技术,可以在本书的文本中参考,并提供有关与机器学习工作流程相关的算法、优化和技术的信息。
你需要这本书的内容
要运行本书中的示例并实验书中涵盖的技术,通常需要以下条件:
-
访问类似 bash 的 shell。
-
包括 Go、编辑器和相关默认或自定义环境变量的完整 Go 环境。例如,您可以遵循此指南
www.goinggo.net/2016/05/installing-go-and-your-workspace.html
。 -
各种 Go 依赖项。这些依赖项可以在需要时通过
go get ...
获取。
然后,为了运行与一些高级主题相关的示例,例如数据管道和深度学习,您还需要一些额外的东西:
-
安装或部署 Pachyderm。您可以通过以下文档了解如何在本地或云中启动 Pachyderm,
pachyderm.readthedocs.io/en/latest/
。 -
一个工作的 Docker 安装(
www.docker.com/community-edition#/download
)。 -
安装 TensorFlow。要本地安装 TensorFlow,您可以遵循此指南
www.tensorflow.org/install/
。
本书面向对象
如果你是以下之一,这本书将对你很有用:
-
对机器学习和数据分析感兴趣的 Go 程序员
-
对 Go 感兴趣并希望将其集成到他们的机器学习和数据分析工作流程中的数据科学家/分析师/工程师
规范
许多 Go 代码片段可能不会包含package main
和func main() {}
。除非另有说明,否则假设 Go 代码片段是在这个必要的结构中编译的。本书中还将假设代码示例是在名为myprogram
的目录中编译的,并编译为名为myprogram
的二进制文件。然而,读者将认识到,代码可以被复制到GOPATH/src
目录中的任何文件夹中,并且/或根据读者的偏好编译为二进制文件。
在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“接下来的代码行读取链接并将其分配给BeautifulSoup
函数。”代码块设置如下:
#import packages into the project
from bs4 import BeautifulSoup
from urllib.request import urlopen
import pandas as pd
当我们希望引起您对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
[default] exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)
任何命令行输入或输出都按以下方式编写:
C:\Python34\Scripts> pip install -upgrade pip
C:\Python34\Scripts> pip install pandas
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“为了下载新模块,我们将转到文件 | 设置 | 项目名称 | 项目解释器。”
警告或重要提示看起来像这样。
小贴士和技巧看起来像这样。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者的反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。
要发送给我们一般性的反馈,只需发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书的标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经是 Packt 图书的骄傲拥有者,我们有一些东西可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以通过访问www.packtpub.com
上的您的账户,从出版日期下载原始代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的“支持”标签上。
-
点击“代码下载”和“错误更正”。
-
在搜索框中输入书的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买这本书的地方。
-
点击“代码下载”。
您还可以通过点击 Packt Publishing 网站上的书籍网页上的“代码文件”按钮来下载代码文件。您可以通过在搜索框中输入书的名称来访问此页面。请注意,您需要登录到您的 Packt 账户。
文件下载完成后,请确保您使用最新版本的以下软件解压缩或提取文件夹:
-
Windows 上的 WinRAR / 7-Zip
-
Mac 上的 Zipeg / iZip / UnRarX
-
Linux 上的 7-Zip / PeaZip
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Machine-Learning-With-Go
。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/
找到。去看看吧!
下载本书的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/MachineLearningWithGo_ColorImages.pdf
下载此文件。
错误更正
尽管我们已经尽最大努力确保内容的准确性,错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者感到沮丧,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata
,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。要查看之前提交的勘误,请访问www.packtpub.com/books/content/support
,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
侵权
互联网上版权材料的侵权是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在网上发现我们作品的任何非法副本,无论形式如何,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。请通过copyright@packtpub.com
与我们联系,并提供疑似侵权材料的链接。我们感谢您的帮助,以保护我们的作者和我们为您提供有价值内容的能力。
问题
如果您对本书的任何方面有问题,您可以通过questions@packtpub.com
联系我们,我们将尽力解决问题。
第一章:收集和组织数据
调查显示,90%或更多的数据科学家时间花在收集数据、组织数据和清洗数据上,而不是在训练/调整复杂的机器学习模型上。这是为什么?机器学习部分不是最有意思的部分吗?为什么我们需要如此关注我们数据的状态?首先,没有数据,我们的机器学习模型就无法学习。这看起来可能很明显。然而,我们需要意识到我们构建的模型的部分优势在于我们提供给它们的那些数据。正如常见的说法,“垃圾输入,垃圾输出”。我们需要确保收集相关、干净的数据来为我们的机器学习模型提供动力,这样它们才能按预期操作并产生有价值的结果。
并非所有类型的数据都适用于使用某些类型的模型。例如,当我们有高维数据(例如文本数据)时,某些模型的表现不佳,而其他模型则假设变量是正态分布的,这显然并不总是如此。因此,我们必须小心收集适合我们用例的数据,并确保我们理解我们的数据和模型将如何交互。
收集和组织数据消耗了数据科学家大量时间的原因之一是数据通常很混乱且难以聚合。在大多数组织中,数据可能存储在不同的系统和格式中,并具有不同的访问控制策略。我们不能假设向我们的模型提供训练集就像指定一个文件路径那样简单;这通常并非如此。
为了形成训练/测试集或向模型提供预测变量,我们可能需要处理各种数据格式,如 CSV、JSON、数据库表等,并且我们可能需要转换单个值。常见的转换包括解析日期时间、将分类数据转换为数值数据、归一化值以及应用一些函数到值上。然而,我们并不能总是假设某个变量的所有值都存在或能够以类似的方式进行解析。
通常数据中包含缺失值、混合类型或损坏值。我们如何处理这些情况将直接影响我们构建的模型的质量,因此,我们必须愿意仔细收集、组织和理解我们的数据。
尽管这本书的大部分内容将专注于各种建模技术,但你应始终将数据收集、解析和组织视为成功数据科学项目的关键组成部分(或可能是最重要的部分)。如果你的项目这部分没有经过精心开发且具有高度诚信,那么你将给自己在长远发展中埋下隐患。
处理数据 - Gopher 风格
与许多用于数据科学/分析的其它语言相比,Go 为数据操作和解析提供了一个非常强大的基础。尽管其他语言(例如 Python 或 R)可能允许用户快速交互式地探索数据,但它们通常促进破坏完整性的便利性,即动态和交互式数据探索通常会导致在更广泛的应用中行为异常的代码。
以这个简单的 CSV 文件为例:
1,blah1
2,blah2
3,blah3
诚然,我们很快就能编写一些 Python 代码来解析这个 CSV 文件,并从整数列中输出最大值,即使我们不知道数据中有什么类型:
import pandas as pd
# Define column names.
cols = [
'integercolumn',
'stringcolumn'
]
# Read in the CSV with pandas.
data = pd.read_csv('myfile.csv', names=cols)
# Print out the maximum value in the integer column.
print(data['integercolumn'].max())
这个简单的程序将打印出正确的结果:
$ python myprogram.py
3
我们现在删除一个整数值以产生一个缺失值,如下所示:
1,blah1
2,blah2
,blah3
Python 程序因此完全失去了完整性;具体来说,程序仍然运行,没有告诉我们任何事情有所不同,仍然产生了一个值,并且产生了一个不同类型的值:
$ python myprogram.py
2.0
这是不可接受的。除了一个整数值外,我们的所有整数值都可能消失,而我们不会对变化有任何洞察。这可能会对我们的建模产生深远的影响,但它们将非常难以追踪。通常,当我们选择动态类型和抽象的便利性时,我们正在接受这种行为的变化性。
这里重要的是,你并不是不能在 Python 中处理这种行为,因为专家会很快认识到你可以正确处理这种行为。关键是这种便利性并不默认促进完整性,因此很容易自食其果。
另一方面,我们可以利用 Go 的静态类型和显式错误处理来确保我们的数据以预期的方式被解析。在这个小例子中,我们也可以编写一些 Go 代码,而不会遇到太多麻烦来解析我们的 CSV(现在不用担心细节):
// Open the CSV.
f, err := os.Open("myfile.csv")
if err != nil {
log.Fatal(err)
}
// Read in the CSV records.
r := csv.NewReader(f)
records, err := r.ReadAll()
if err != nil {
log.Fatal(err)
}
// Get the maximum value in the integer column.
var intMax int
for _, record := range records {
// Parse the integer value.
intVal, err := strconv.Atoi(record[0])
if err != nil {
log.Fatal(err)
}
// Replace the maximum value if appropriate.
if intVal > intMax {
intMax = intVal
}
}
// Print the maximum value.
fmt.Println(intMax)
这将产生一个正确的结果,对于所有整数值都存在的 CSV 文件:
$ go build
$ ./myprogram
3
但与之前的 Python 代码相比,我们的 Go 代码将在我们遇到输入 CSV 中不期望遇到的内容时通知我们(对于删除值 3 的情况):
$ go build
$ ./myprogram
2017/04/29 12:29:45 strconv.ParseInt: parsing "": invalid syntax
在这里,我们保持了完整性,并且我们可以确保我们可以以适合我们用例的方式处理缺失值。
使用 Go 收集和组织数据的最佳实践
如前所述部分所示,Go 本身为我们提供了在数据收集、解析和组织中保持高完整性水平的机会。我们希望确保在为机器学习工作流程准备数据时,我们能够利用 Go 的独特属性。
通常,Go 数据科学家/分析师在收集和组织数据时应遵循以下最佳实践。这些最佳实践旨在帮助您在应用程序中保持完整性,并能够重现任何分析:
-
检查并强制执行预期的类型:这看起来可能很显然,但在使用动态类型语言时,它往往被忽视。尽管这稍微有些冗长,但将数据显式解析为预期类型并处理相关错误可以在将来为你节省很多麻烦。
-
标准化和简化你的数据输入/输出:有许多第三方包用于处理某些类型的数据或与某些数据源交互(其中一些我们将在本书中介绍)。然而,如果你标准化与数据源交互的方式,特别是围绕使用
stdlib
的使用,你可以开发可预测的模式并在团队内部保持一致性。一个很好的例子是选择使用database/sql
进行数据库交互,而不是使用各种第三方 API 和 DSL。 -
版本化你的数据:机器学习模型产生的结果极其不同,这取决于你使用的训练数据、参数选择和输入数据。因此,如果不版本化你的代码和数据,就无法重现结果。我们将在本章后面讨论数据版本化的适当技术。
如果你开始偏离这些基本原则,你应该立即停止。你可能会为了方便而牺牲完整性,这是一条危险的道路。我们将让这些原则引导我们在本书中的学习,并在下一节考虑各种数据格式/来源时,我们将遵循这些原则。
CSV 文件
CSV 文件可能不是大数据的首选格式,但作为一名机器学习领域的数据科学家或开发者,你肯定会遇到这种格式。你可能需要将邮政编码映射到经纬度,并在互联网上找到这个 CSV 文件,或者你的销售团队可能会以 CSV 格式提供销售数据。无论如何,我们需要了解如何解析这些文件。
我们将在解析 CSV 文件时利用的主要包是 Go 标准库中的 encoding/csv
。然而,我们还将讨论几个允许我们快速操作或转换 CSV 数据的包--github.com/kniren/gota/dataframe
和 go-hep.org/x/hep/csvutil
。
从文件中读取 CSV 数据
让我们考虑一个简单的 CSV 文件,我们将在稍后返回,命名为 iris.csv
(可在以下链接找到:archive.ics.uci.edu/ml/datasets/iris
)。这个 CSV 文件包括四个表示花朵测量的浮点列和一个表示相应花朵种类的字符串列:
$ head iris.csv
5.1,3.5,1.4,0.2,Iris-setosa
4.9,3.0,1.4,0.2,Iris-setosa
4.7,3.2,1.3,0.2,Iris-setosa
4.6,3.1,1.5,0.2,Iris-setosa
5.0,3.6,1.4,0.2,Iris-setosa
5.4,3.9,1.7,0.4,Iris-setosa
4.6,3.4,1.4,0.3,Iris-setosa
5.0,3.4,1.5,0.2,Iris-setosa
4.4,2.9,1.4,0.2,Iris-setosa
4.9,3.1,1.5,0.1,Iris-setosa
导入 encoding/csv
后,我们首先打开 CSV 文件并创建一个 CSV 读取器值:
// Open the iris dataset file.
f, err := os.Open("../data/iris.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
然后,我们可以读取 CSV 文件的所有记录(对应于行),这些记录被导入为 [][]string
:
// Assume we don't know the number of fields per line. By setting
// FieldsPerRecord negative, each row may have a variable
// number of fields.
reader.FieldsPerRecord = -1
// Read in all of the CSV records.
rawCSVData, err := reader.ReadAll()
if err != nil {
log.Fatal(err)
}
我们也可以通过无限循环逐个读取记录。只需确保检查文件末尾(io.EOF
),以便在读取所有数据后循环结束:
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
reader.FieldsPerRecord = -1
// rawCSVData will hold our successfully parsed rows.
var rawCSVData [][]string
// Read in the records one by one.
for {
// Read in a row. Check if we are at the end of the file.
record, err := reader.Read()
if err == io.EOF {
break
}
// Append the record to our dataset.
rawCSVData = append(rawCSVData, record)
}
如果你的 CSV 文件不是以逗号分隔的,或者如果你的 CSV 文件包含注释行,你可以利用csv.Reader.Comma
和csv.Reader.Comment
字段来正确处理格式独特的 CSV 文件。在字段在 CSV 文件中用单引号包围的情况下,你可能需要添加一个辅助函数来删除单引号并解析值。
处理意外的字段
前面的方法对干净的 CSV 数据工作得很好,但通常我们不会遇到干净的数据。我们必须解析混乱的数据。例如,你可能会在你的 CSV 记录中找到意外的字段或字段数量。这就是为什么reader.FieldsPerRecord
存在的原因。这个读取值字段让我们能够轻松地处理混乱的数据,如下所示:
4.3,3.0,1.1,0.1,Iris-setosa
5.8,4.0,1.2,0.2,Iris-setosa
5.7,4.4,1.5,0.4,Iris-setosa
5.4,3.9,1.3,0.4,blah,Iris-setosa
5.1,3.5,1.4,0.3,Iris-setosa
5.7,3.8,1.7,0.3,Iris-setosa
5.1,3.8,1.5,0.3,Iris-setosa
这个版本的iris.csv
文件在一行中有一个额外的字段。我们知道每个记录应该有五个字段,所以让我们将我们的reader.FieldsPerRecord
值设置为5
:
// We should have 5 fields per line. By setting
// FieldsPerRecord to 5, we can validate that each of the
// rows in our CSV has the correct number of fields.
reader.FieldsPerRecord = 5
那么当我们从 CSV 文件中读取记录时,我们可以检查意外的字段并保持我们数据的一致性:
// rawCSVData will hold our successfully parsed rows.
var rawCSVData [][]string
// Read in the records looking for unexpected numbers of fields.
for {
// Read in a row. Check if we are at the end of the file.
record, err := reader.Read()
if err == io.EOF {
break
}
// If we had a parsing error, log the error and move on.
if err != nil {
log.Println(err)
continue
}
// Append the record to our dataset, if it has the expected
// number of fields.
rawCSVData = append(rawCSVData, record)
}
在这里,我们选择通过记录错误来处理错误,并且我们只将成功解析的记录收集到rawCSVData
中。读者会注意到这种错误可以以许多不同的方式处理。重要的是我们正在强迫自己检查数据的一个预期属性,并提高我们应用程序的完整性。
处理意外的类型
我们刚刚看到 CSV 数据被读取为[][]string
。然而,Go 是静态类型的,这允许我们对每个 CSV 字段执行严格的检查。我们可以在解析每个字段以进行进一步处理时这样做。考虑一些混乱的数据,其中包含与列中其他值类型不匹配的随机字段:
4.6,3.1,1.5,0.2,Iris-setosa
5.0,string,1.4,0.2,Iris-setosa
5.4,3.9,1.7,0.4,Iris-setosa
5.3,3.7,1.5,0.2,Iris-setosa
5.0,3.3,1.4,0.2,Iris-setosa
7.0,3.2,4.7,1.4,Iris-versicolor
6.4,3.2,4.5,1.5,
6.9,3.1,4.9,1.5,Iris-versicolor
5.5,2.3,4.0,1.3,Iris-versicolor
4.9,3.1,1.5,0.1,Iris-setosa
5.0,3.2,1.2,string,Iris-setosa
5.5,3.5,1.3,0.2,Iris-setosa
4.9,3.1,1.5,0.1,Iris-setosa
4.4,3.0,1.3,0.2,Iris-setosa
为了检查我们 CSV 记录中字段的类型,让我们创建一个struct
变量来保存成功解析的值:
// CSVRecord contains a successfully parsed row of the CSV file.
type CSVRecord struct {
SepalLength float64
SepalWidth float64
PetalLength float64
PetalWidth float64
Species string
ParseError error
}
然后,在我们遍历记录之前,让我们初始化这些值的一个切片:
// Create a slice value that will hold all of the successfully parsed
// records from the CSV.
var csvData []CSVRecord
现在我们遍历记录时,我们可以解析为该记录的相关类型,捕获任何错误,并按需记录:
// Read in the records looking for unexpected types.
for {
// Read in a row. Check if we are at the end of the file.
record, err := reader.Read()
if err == io.EOF {
break
}
// Create a CSVRecord value for the row.
var csvRecord CSVRecord
// Parse each of the values in the record based on an expected type.
for idx, value := range record {
// Parse the value in the record as a string for the string column.
if idx == 4 {
// Validate that the value is not an empty string. If the
// value is an empty string break the parsing loop.
if value == "" {
log.Printf("Unexpected type in column %d\n", idx)
csvRecord.ParseError = fmt.Errorf("Empty string value")
break
}
// Add the string value to the CSVRecord.
csvRecord.Species = value
continue
}
// Otherwise, parse the value in the record as a float64.
var floatValue float64
// If the value can not be parsed as a float, log and break the
// parsing loop.
if floatValue, err = strconv.ParseFloat(value, 64); err != nil {
log.Printf("Unexpected type in column %d\n", idx)
csvRecord.ParseError = fmt.Errorf("Could not parse float")
break
}
// Add the float value to the respective field in the CSVRecord.
switch idx {
case 0:
csvRecord.SepalLength = floatValue
case 1:
csvRecord.SepalWidth = floatValue
case 2:
csvRecord.PetalLength = floatValue
case 3:
csvRecord.PetalWidth = floatValue
}
}
// Append successfully parsed records to the slice defined above.
if csvRecord.ParseError == nil {
csvData = append(csvData, csvRecord)
}
}
使用数据框操作 CSV 数据
正如你所见,手动解析许多不同的字段并逐行执行操作可能会相当冗长且繁琐。这绝对不是增加复杂性和导入大量非标准功能的借口。在大多数情况下,你应该仍然默认使用encoding/csv
。
然而,数据框的操作已被证明是处理表格数据的一种成功且相对标准化的方式(在数据科学社区中)。因此,在某些情况下,使用一些第三方功能来操作表格数据,如 CSV 数据,是值得的。例如,数据框及其对应的功能在你尝试过滤、子集化和选择表格数据集的部分时非常有用。在本节中,我们将介绍github.com/kniren/gota/dataframe
,这是一个为 Go 语言提供的优秀的dataframe
包:
import "github.com/kniren/gota/dataframe"
要从 CSV 文件创建数据框,我们使用os.Open()
打开一个文件,然后将返回的指针提供给dataframe.ReadCSV()
函数:
// Open the CSV file.
irisFile, err := os.Open("iris.csv")
if err != nil {
log.Fatal(err)
}
defer irisFile.Close()
// Create a dataframe from the CSV file.
// The types of the columns will be inferred.
irisDF := dataframe.ReadCSV(irisFile)
// As a sanity check, display the records to stdout.
// Gota will format the dataframe for pretty printing.
fmt.Println(irisDF)
如果我们编译并运行这个 Go 程序,我们将看到一个漂亮的、格式化的数据版本,其中包含了在解析过程中推断出的类型:
$ go build
$ ./myprogram
[150x5] DataFrame
sepal_length sepal_width petal_length petal_width species
0: 5.100000 3.500000 1.400000 0.200000 Iris-setosa
1: 4.900000 3.000000 1.400000 0.200000 Iris-setosa
2: 4.700000 3.200000 1.300000 0.200000 Iris-setosa
3: 4.600000 3.100000 1.500000 0.200000 Iris-setosa
4: 5.000000 3.600000 1.400000 0.200000 Iris-setosa
5: 5.400000 3.900000 1.700000 0.400000 Iris-setosa
6: 4.600000 3.400000 1.400000 0.300000 Iris-setosa
7: 5.000000 3.400000 1.500000 0.200000 Iris-setosa
8: 4.400000 2.900000 1.400000 0.200000 Iris-setosa
9: 4.900000 3.100000 1.500000 0.100000 Iris-setosa
... ... ... ... ...
<float> <float> <float> <float> <string>
一旦我们将数据解析到dataframe
中,我们就可以轻松地进行过滤、子集化和选择我们的数据:
// Create a filter for the dataframe.
filter := dataframe.F{
Colname: "species",
Comparator: "==",
Comparando: "Iris-versicolor",
}
// Filter the dataframe to see only the rows where
// the iris species is "Iris-versicolor".
versicolorDF := irisDF.Filter(filter)
if versicolorDF.Err != nil {
log.Fatal(versicolorDF.Err)
}
// Filter the dataframe again, but only select out the
// sepal_width and species columns.
versicolorDF = irisDF.Filter(filter).Select([]string{"sepal_width", "species"})
// Filter and select the dataframe again, but only display
// the first three results.
versicolorDF = irisDF.Filter(filter).Select([]string{"sepal_width", "species"}).Subset([]int{0, 1, 2})
这实际上只是对github.com/kniren/gota/dataframe
包表面的探索。你可以合并数据集,输出到其他格式,甚至处理 JSON 数据。关于这个包的更多信息,你应该访问自动生成的 GoDocs,网址为godoc.org/github.com/kniren/gota/dataframe
,这在一般情况下,对于我们在书中讨论的任何包来说都是好的实践。
JSON
在一个大多数数据都是通过网络访问的世界里,大多数工程组织实施了一定数量的微服务,我们将非常频繁地遇到 JSON 格式的数据。我们可能只需要在从 API 中拉取一些随机数据时处理它,或者它实际上可能是驱动我们的分析和机器学习工作流程的主要数据格式。
通常,当易用性是数据交换的主要目标时,会使用 JSON。由于 JSON 是可读的,如果出现问题,它很容易调试。记住,我们希望在用 Go 处理数据时保持我们数据处理的一致性,这个过程的一部分是确保,当可能时,我们的数据是可解释和可读的。JSON 在实现这些目标方面非常有用(这也是为什么它也常用于日志记录)。
Go 在其标准库中提供了非常好的 JSON 功能,使用encoding/json
。我们将在整个书中利用这个标准库功能。
解析 JSON
要了解如何在 Go 中解析(即反序列化)JSON 数据,我们将使用来自 Citi Bike API(www.citibikenyc.com/system-data
)的一些数据,这是一个在纽约市运营的自行车共享服务。Citi Bike 以 JSON 格式提供其自行车共享站点的频繁更新的运营信息,网址为gbfs.citibikenyc.com/gbfs/en/station_status.json
:
{
"last_updated": 1495252868,
"ttl": 10,
"data": {
"stations": [
{
"station_id": "72",
"num_bikes_available": 10,
"num_bikes_disabled": 3,
"num_docks_available": 26,
"num_docks_disabled": 0,
"is_installed": 1,
"is_renting": 1,
"is_returning": 1,
"last_reported": 1495249679,
"eightd_has_available_keys": false
},
{
"station_id": "79",
"num_bikes_available": 0,
"num_bikes_disabled": 0,
"num_docks_available": 33,
"num_docks_disabled": 0,
"is_installed": 1,
"is_renting": 1,
"is_returning": 1,
"last_reported": 1495248017,
"eightd_has_available_keys": false
},
etc...
{
"station_id": "3464",
"num_bikes_available": 1,
"num_bikes_disabled": 3,
"num_docks_available": 53,
"num_docks_disabled": 0,
"is_installed": 1,
"is_renting": 1,
"is_returning": 1,
"last_reported": 1495250340,
"eightd_has_available_keys": false
}
]
}
}
在 Go 中解析导入和这种类型的数据时,我们首先需要导入encoding/json
(以及从标准库中的一些其他东西,如net/http
,因为我们将从之前提到的网站上拉取这些数据)。我们还将定义struct
,它模仿了前面代码中显示的 JSON 结构:
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
)
// citiBikeURL provides the station statuses of CitiBike bike sharing stations.
const citiBikeURL = "https://gbfs.citibikenyc.com/gbfs/en/station_status.json"
// stationData is used to unmarshal the JSON document returned form citiBikeURL.
type stationData struct {
LastUpdated int `json:"last_updated"`
TTL int `json:"ttl"`
Data struct {
Stations []station `json:"stations"`
} `json:"data"`
}
// station is used to unmarshal each of the station documents in stationData.
type station struct {
ID string `json:"station_id"`
NumBikesAvailable int `json:"num_bikes_available"`
NumBikesDisabled int `json:"num_bike_disabled"`
NumDocksAvailable int `json:"num_docks_available"`
NumDocksDisabled int `json:"num_docks_disabled"`
IsInstalled int `json:"is_installed"`
IsRenting int `json:"is_renting"`
IsReturning int `json:"is_returning"`
LastReported int `json:"last_reported"`
HasAvailableKeys bool `json:"eightd_has_available_keys"`
}
注意这里的一些事情:(i)我们遵循了 Go 的惯例,避免了使用下划线的struct
字段名,但(ii)我们使用了json
结构标签来标记struct
字段,以对应 JSON 数据中的预期字段。
注意,为了正确解析 JSON 数据,结构体字段必须是导出字段。也就是说,字段需要以大写字母开头。encoding/json
无法使用反射查看未导出的字段。
现在,我们可以从 URL 获取 JSON 数据并将其反序列化到一个新的stationData
值中。这将产生一个struct
变量,其相应字段填充了标记的 JSON 数据字段中的数据。我们可以通过打印与某个站点相关的一些数据来检查它:
// Get the JSON response from the URL.
response, err := http.Get(citiBikeURL)
if err != nil {
log.Fatal(err)
}
defer response.Body.Close()
// Read the body of the response into []byte.
body, err := ioutil.ReadAll(response.Body)
if err != nil {
log.Fatal(err)
}
// Declare a variable of type stationData.
var sd stationData
// Unmarshal the JSON data into the variable.
if err := json.Unmarshal(body, &sd); err != nil {
log.Fatal(err)
}
// Print the first station.
fmt.Printf("%+v\n\n", sd.Data.Stations[0])
当我们运行此操作时,我们可以看到我们的struct
包含了从 URL 解析的数据:
$ go build
$ ./myprogram
{ID:72 NumBikesAvailable:11 NumBikesDisabled:0 NumDocksAvailable:25 NumDocksDisabled:0 IsInstalled:1 IsRenting:1 IsReturning:1 LastReported:1495252934 HasAvailableKeys:false}
JSON 输出
现在假设我们已经在stationData
结构体值中有了 Citi Bike 站点的数据,并希望将数据保存到文件中。我们可以使用json.marshal
来完成此操作:
// Marshal the data.
outputData, err := json.Marshal(sd)
if err != nil {
log.Fatal(err)
}
// Save the marshalled data to a file.
if err := ioutil.WriteFile("citibike.json", outputData, 0644); err != nil {
log.Fatal(err)
}
类似 SQL 的数据库
尽管围绕有趣的 NoSQL 数据库和键值存储有很多炒作,但类似 SQL 的数据库仍然无处不在。每个数据科学家在某个时候都会处理来自类似 SQL 的数据库的数据,例如 Postgres、MySQL 或 SQLite。
例如,我们可能需要查询 Postgres 数据库中的一个或多个表来生成用于模型训练的一组特征。在用该模型进行预测或识别异常之后,我们可能将结果发送到另一个数据库表,该表驱动仪表板或其他报告工具。
当然,Go 与所有流行的数据存储都很好地交互,例如 SQL、NoSQL、键值存储等,但在这里,我们将专注于类似 SQL 的交互。在整个书中,我们将使用database/sql
进行这些交互。
连接到 SQL 数据库
在连接类似 SQL 的数据库之前,我们需要做的第一件事是确定我们将与之交互的特定数据库,并导入相应的驱动程序。在以下示例中,我们将连接到 Postgres 数据库,并将使用github.com/lib/pq
数据库驱动程序来处理database/sql
。此驱动程序可以通过空导入(带有相应的注释)来加载:
import (
"database/sql"
"fmt"
"log"
"os"
// pq is the library that allows us to connect
// to postgres with databases/sql.
_ "github.com/lib/pq"
)
现在假设您已经将 Postgres 连接字符串导出到环境变量PGURL
中。我们可以通过以下代码轻松地为我们的连接创建一个sql.DB
值:
// Get the postgres connection URL. I have it stored in
// an environmental variable.
pgURL := os.Getenv("PGURL")
if pgURL == "" {
log.Fatal("PGURL empty")
}
// Open a database value. Specify the postgres driver
// for databases/sql.
db, err := sql.Open("postgres", pgURL)
if err != nil {
log.Fatal(err)
}
defer db.Close()
注意,我们需要延迟此值的close
方法。另外,请注意,创建此值并不意味着您已成功连接到数据库。这只是一个由database/sql
在触发某些操作(如查询)时用于连接数据库的值。
为了确保我们可以成功连接到数据库,我们可以使用Ping
方法:
if err := db.Ping(); err != nil {
log.Fatal(err)
}
查询数据库
现在我们知道了如何连接到数据库,让我们看看我们如何从数据库中获取数据。在这本书中,我们不会涵盖 SQL 查询和语句的细节。如果您不熟悉 SQL,我强烈建议您学习如何查询、插入等,但就我们这里的目的而言,您应该知道我们基本上有两种类型的操作与 SQL 数据库相关:
-
Query
操作在数据库中选取、分组或聚合数据,并将数据行返回给我们 -
Exec
操作更新、插入或以其他方式修改数据库的状态,而不期望数据库中存储的数据的部分应该被返回
如您所预期的那样,为了从我们的数据库中获取数据,我们将使用Query
操作。为此,我们需要使用 SQL 语句字符串查询数据库。例如,假设我们有一个存储大量鸢尾花测量数据(花瓣长度、花瓣宽度等)的数据库,我们可以查询与特定鸢尾花物种相关的数据如下:
// Query the database.
rows, err := db.Query(`
SELECT
sepal_length as sLength,
sepal_width as sWidth,
petal_length as pLength,
petal_width as pWidth
FROM iris
WHERE species = $1`, "Iris-setosa")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
注意,这返回了一个指向sql.Rows
值的指针,我们需要延迟关闭这个行值。然后我们可以遍历我们的行并将数据解析为预期的类型。我们利用Scan
方法在行上解析 SQL 查询返回的列并将它们打印到标准输出:
// Iterate over the rows, sending the results to
// standard out.
for rows.Next() {
var (
sLength float64
sWidth float64
pLength float64
pWidth float64
)
if err := rows.Scan(&sLength, &sWidth, &pLength, &pWidth); err != nil {
log.Fatal(err)
}
fmt.Printf("%.2f, %.2f, %.2f, %.2f\n", sLength, sWidth, pLength, pWidth)
}
最后,我们需要检查在处理我们的行时可能发生的任何错误。我们希望保持我们数据处理的一致性,我们不能假设我们在没有遇到错误的情况下遍历了所有的行:
// Check for errors after we are done iterating over rows.
if err := rows.Err(); err != nil {
log.Fatal(err)
}
修改数据库
如前所述,还有另一种与数据库的交互方式称为Exec
。使用这些类型的语句,我们关注的是更新、添加或以其他方式修改数据库中的一个或多个表的状态。我们使用相同类型的数据库连接,但不是调用db.Query
,我们将调用db.Exec
。
例如,假设我们想要更新我们 iris 数据库表中的某些值:
// Update some values.
res, err := db.Exec("UPDATE iris SET species = 'setosa' WHERE species = 'Iris-setosa'")
if err != nil {
log.Fatal(err)
}
但我们如何知道我们是否成功并改变了某些内容呢?嗯,这里返回的res
函数允许我们查看我们的表中有多少行受到了我们更新的影响:
// See how many rows where updated.
rowCount, err := res.RowsAffected()
if err != nil {
log.Fatal(err)
}
// Output the number of rows to standard out.
log.Printf("affected = %d\n", rowCount)
缓存
有时,我们的机器学习算法将通过外部来源(例如,API)的数据进行训练和/或提供预测输入,即不是运行我们的建模或分析的应用程序本地的数据。此外,我们可能有一些经常访问的数据集,可能很快会再次访问,或者可能需要在应用程序运行时提供。
在至少这些情况中,缓存数据在内存中或嵌入到应用程序运行的地方可能是合理的。例如,如果你经常访问政府 API(通常具有高延迟)以获取人口普查数据,你可能会考虑维护一个本地或内存中的缓存,以便你可以避免不断调用 API。
在内存中缓存数据
要在内存中缓存一系列值,我们将使用 github.com/patrickmn/go-cache
。使用这个包,我们可以创建一个包含键和相应值的内存缓存。我们甚至可以指定缓存中特定键值对的时间生存期。
要创建一个新的内存缓存并在缓存中设置键值对,我们执行以下操作:
// Create a cache with a default expiration time of 5 minutes, and which
// purges expired items every 30 seconds
c := cache.New(5*time.Minute, 30*time.Second)
// Put a key and value into the cache.
c.Set("mykey", "myvalue", cache.DefaultExpiration)
要从缓存中检索 mykey
的值,我们只需使用 Get
方法:
v, found := c.Get("mykey")
if found {
fmt.Printf("key: mykey, value: %s\n", v)
}
在磁盘上本地缓存数据
我们刚才看到的缓存是在内存中的。也就是说,缓存的数据在应用程序运行时存在并可访问,但一旦应用程序退出,数据就会消失。在某些情况下,你可能希望当你的应用程序重新启动或退出时,缓存的数据仍然保留。你也可能想要备份你的缓存,这样你就不需要在没有相关数据缓存的情况下从头开始启动应用程序。
在这些情况下,你可能考虑使用本地的嵌入式缓存,例如 github.com/boltdb/bolt
。BoltDB,正如其名,是这类应用中非常受欢迎的项目,基本上由一个本地的键值存储组成。要初始化这些本地键值存储之一,请执行以下操作:
// Open an embedded.db data file in your current directory.
// It will be created if it doesn't exist.
db, err := bolt.Open("embedded.db", 0600, nil)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Create a "bucket" in the boltdb file for our data.
if err := db.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucket([]byte("MyBucket"))
if err != nil {
return fmt.Errorf("create bucket: %s", err)
}
return nil
}); err != nil {
log.Fatal(err)
}
当然,你可以在 BoltDB 中拥有多个不同的数据桶,并使用除 embedded.db
之外的其他文件名。
接下来,假设你有一个内存中的字符串值映射,你需要将其缓存到 BoltDB 中。为此,你需要遍历映射中的键和值,更新你的 BoltDB:
// Put the map keys and values into the BoltDB file.
if err := db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("MyBucket"))
err := b.Put([]byte("mykey"), []byte("myvalue"))
return err
}); err != nil {
log.Fatal(err)
}
然后,要从 BoltDB 中获取值,你可以查看你的数据:
// Output the keys and values in the embedded
// BoltDB file to standard out.
if err := db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("MyBucket"))
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
fmt.Printf("key: %s, value: %s\n", k, v)
}
return nil
}); err != nil {
log.Fatal(err)
}
数据版本控制
如前所述,机器学习模型产生的结果极其不同,这取决于你使用的训练数据、参数的选择和输入数据。为了协作、创造性和合规性原因,能够重现结果是至关重要的:
-
协作:尽管你在社交媒体上看到的是,没有数据科学和机器学习独角兽(即在每个数据科学和机器学习领域都有知识和能力的人)。我们需要同事的审查并改进我们的工作,而如果他们无法重现我们的模型结果和分析,这是不可能的。
-
创造力:我不知道你怎么样,但即使是我也难以记住昨天做了什么。我们无法信任自己总是能记住我们的推理和逻辑,尤其是在处理机器学习工作流程时。我们需要精确跟踪我们使用的数据、我们创建的结果以及我们是如何创建它们的。这是我们能够不断改进我们的模型和技术的方式。
-
合规性:最后,我们可能很快就会在机器学习中没有选择地进行数据版本化和可重现性。世界各地正在通过法律(例如,欧盟的通用数据保护条例(GDPR))赋予用户对算法决策的解释权。如果我们没有一种稳健的方式来跟踪我们正在处理的数据和产生的结果,我们根本无法希望遵守这些裁决。
存在多个开源数据版本控制项目。其中一些专注于数据的安全性和对等分布式存储。其他一些则专注于数据科学工作流程。在这本书中,我们将重点关注并利用 Pachyderm (pachyderm.io/
),这是一个开源的数据版本控制和数据管道框架。其中一些原因将在本书后面关于生产部署和管理 ML 管道时变得清晰。现在,我将仅总结一些使 Pachyderm 成为基于 Go(和其他)ML 项目数据版本控制吸引力的特性:
-
它有一个方便的 Go 客户端,
github.com/pachyderm/pachyderm/src/client
-
能够对任何类型和格式的数据进行版本控制
-
为版本化数据提供灵活的对象存储后端
-
与数据管道系统集成以驱动版本化的 ML 工作流程
Pachyderm 术语
将 Pachyderm 中的数据版本化想象成在 Git 中版本化代码。基本原理是相似的:
-
仓库:这些是版本化的数据集合,类似于在 Git 仓库中拥有版本化的代码集合
-
提交:在 Pachyderm 中,通过将数据提交到数据仓库来对数据进行版本控制
-
分支:这些轻量级指针指向特定的提交或一系列提交(例如,master 指向最新的 HEAD 提交)
-
文件:在 Pachyderm 中,数据在文件级别进行版本控制,并且 Pachyderm 自动采用去重等策略来保持你的版本化数据空间高效
尽管使用 Pachyderm 对数据进行版本控制的感觉与使用 Git 对代码进行版本控制相似,但也有一些主要区别。例如,合并数据并不完全有意义。如果存在数 PB(皮字节)数据的合并冲突,没有人能够解决这些问题。此外,Git 协议在处理大量数据时通常不会很节省空间。Pachyderm 使用其自身的内部逻辑来执行版本控制和处理版本化数据,这种逻辑在缓存方面既节省空间又高效。
部署/安装 Pachyderm
我们将在本书的多个地方使用 Pachyderm 来对数据进行版本控制并创建分布式机器学习工作流程。Pachyderm 本身是一个运行在 Kubernetes(kubernetes.io/
)之上的应用程序,并支持你选择的任何对象存储。为了本书的开发和实验目的,你可以轻松地安装并本地运行 Pachyderm。安装应该需要 5-10 分钟,并且不需要太多努力。本地安装的说明可以在 Pachyderm 文档中找到,网址为docs.pachyderm.io
。
当你准备好在生产环境中运行你的工作流程或部署模型时,你可以轻松地部署一个生产就绪的 Pachyderm 集群,该集群将与你本地安装的行为完全相同。Pachyderm 可以部署到任何云中,甚至可以在本地部署。
如前所述,Pachyderm 是一个开源项目,并且有一个活跃的用户群体。如果你有问题或需要帮助,你可以通过访问slack.pachyderm.io/
加入公共 Pachyderm Slack 频道。活跃的 Pachyderm 用户和 Pachyderm 团队本身将能够快速回答你的问题。
为数据版本化创建数据仓库
如果你遵循了 Pachyderm 文档中指定的本地安装说明,你应该有以下内容:
-
在你的机器上的 Minikube VM 上运行的 Kubernetes
-
已安装并连接到你的 Pachyderm 集群的
pachctl
命令行工具
当然,如果你在云中运行一个生产集群,以下步骤仍然适用。你的pachctl
将连接到远程集群。
我们将在下面的示例中使用pachctl
命令行界面(CLI)(这是一个 Go 程序)来演示数据版本化功能。然而,如上所述,Pachyderm 有一个完整的 Go 客户端。你可以直接从你的 Go 程序中创建仓库、提交数据等等。这一功能将在第九章部署和分发分析和模型中演示。
要创建一个名为myrepo
的数据仓库,你可以运行以下代码:
$ pachctl create-repo myrepo
你可以使用list-repo
来确认仓库是否存在:
$ pachctl list-repo
NAME CREATED SIZE
myrepo 2 seconds ago 0 B
这个myrepo
仓库是我们定义的数据集合,已准备好存放版本化的数据。目前,仓库中没有数据,因为我们还没有放入任何数据。
将数据放入数据仓库
假设我们有一个简单的文本文件:
$ cat blah.txt
This is an example file.
如果这个文件是我们正在利用的机器学习工作流程中的数据的一部分,我们应该对其进行版本控制。要在我们的仓库myrepo
中对此文件进行版本控制,我们只需将其提交到该仓库:
$ pachctl put-file myrepo master -c -f blah.txt
-c
标志指定我们希望 Pachyderm 打开一个新提交,插入我们引用的文件,然后一次性关闭提交。-f
标志指定我们提供了一个文件。
注意,我们在这里是将单个文件提交到单个仓库的 master 分支。然而,Pachyderm API 非常灵活。我们可以在单个提交或多个提交中提交、删除或以其他方式修改许多版本化文件。此外,这些文件可以通过 URL、对象存储链接、数据库转储等方式进行版本化。
作为一种合理性检查,我们可以确认我们的文件已在仓库中进行了版本化:
$ pachctl list-repo
NAME CREATED SIZE
myrepo 10 minutes ago 25 B
$ pachctl list-file myrepo master
NAME TYPE SIZE
blah.txt file 25 B
从版本化数据仓库中获取数据
现在我们已经在 Pachyderm 中有了版本化的数据,我们可能想知道如何与这些数据交互。主要的方式是通过 Pachyderm 数据管道(本书后面将讨论)。在管道中使用时与版本化数据交互的机制是一个简单的文件 I/O。
然而,如果我们想手动从 Pachyderm 中提取某些版本的版本化数据,进行交互式分析,那么我们可以使用pachctl
CLI 来获取数据:
$ pachctl get-file myrepo master blah.txt
This is an example file.
参考文献
CSV 数据:
-
encoding/csv
文档:golang.org/pkg/encoding/csv/
-
github.com/kniren/gota/dataframe
文档:godoc.org/github.com/kniren/gota/dataframe
JSON 数据:
-
encoding/json
文档:golang.org/pkg/encoding/json/
-
Bill Kennedy 的博客文章 JSON 解码:
www.goinggo.net/2014/01/decode-json-documents-in-go.html
-
Ben Johnson 的博客文章 Go Walkthrough:
encoding/json
包:medium.com/go-walkthrough/go-walkthrough-encoding-json-package-9681d1d37a8f
缓存:
-
github.com/patrickmn/go-cache
文档:godoc.org/github.com/patrickmn/go-cache
-
github.com/boltdb/bolt
文档:godoc.org/github.com/boltdb/bolt
-
BoltDB 的相关信息和动机:
npf.io/2014/07/intro-to-boltdb-painless-performant-persistence/
Pachyderm:
-
通用文档:
docs.pachyderm.io
-
Go 客户端文档:
godoc.org/github.com/pachyderm/pachyderm/src/client
-
公共用户 Slack 团队注册:
docs.pachyderm.io
摘要
在本章中,你学习了如何收集、组织和解析数据。这是开发机器学习模型的第一步,也是最重要的一步,但如果我们不对数据进行一些直观的理解并将其放入标准形式进行处理,那么拥有数据也不会让我们走得很远。接下来,我们将探讨一些进一步结构化我们的数据(矩阵)和理解我们的数据(统计学和概率)的技术。
第二章:矩阵、概率和统计学
尽管我们将在整本书中主要采用实用/应用的方法来介绍机器学习,但某些基本主题对于理解和正确应用机器学习是必不可少的。特别是,对概率和统计学的深入理解将使我们能够将某些算法与相关问题匹配起来,理解我们的数据和结果,并对我们的数据进行必要的转换。矩阵和一点线性代数将使我们能够正确地表示我们的数据并实现优化、最小化和基于矩阵的转换。
如果你数学或统计学稍微有些生疏,不必过于担心。在这里,我们将介绍一些基础知识,并展示如何通过编程方式处理书中将要用到的相关统计指标和矩阵技术。但话说回来,这不是一本关于统计学、概率和线性代数的书。要真正精通机器学习,人们应该花时间在更深的层次上学习这些主题。
矩阵和向量
如果你花很多时间学习和应用机器学习,你会看到很多关于矩阵和向量的引用。实际上,许多机器学习算法可以归结为一系列矩阵的迭代运算。矩阵和向量是什么,我们如何在 Go 程序中表示它们?
在很大程度上,我们将利用来自 github.com/gonum
的包来构建和使用矩阵和向量。这是一系列专注于数值计算的 Go 包,而且它们的质量一直在不断提升。
向量
向量是有序排列的数字集合,这些数字可以按行(从左到右)或列(从上到下)排列。向量中的每个数字称为一个分量。例如,这可能是一组代表我们公司销售额的数字,或者是一组代表温度的数字。
当然,使用 Go 切片来表示这些有序数据集合是很自然的,如下所示:
// Initialize a "vector" via a slice.
var myvector []float64
// Add a couple of components to the vector.
myvector = append(myvector, 11.0)
myvector = append(myvector, 5.2)
// Output the results to stdout.
fmt.Println(myvector)
切片确实是有序集合。然而,它们并不真正代表行或列的概念,我们仍然需要在切片之上进行各种向量运算。幸运的是,在向量运算方面,gonum 提供了 gonum.org/v1/gonum/floats
来操作 float64
值的切片,以及 gonum.org/v1/gonum/mat
,它除了矩阵外,还提供了一个 Vector
类型(及其对应的方法):
// Create a new vector value.
myvector := mat.NewVector(2, []float64{11.0, 5.2})
向量运算
正如这里提到的,处理向量需要使用某些向量/矩阵特定的操作和规则。例如,我们如何将向量相乘?我们如何知道两个向量是否相似?gonum.org/v1/gonum/floats
和 gonum.org/v1/gonum/mat
都提供了用于向量/切片操作的内置方法和函数,例如点积、排序和距离。我们不会在这里涵盖所有功能,因为有很多,但我们可以大致了解我们如何与向量一起工作。首先,我们可以以下这种方式使用 gonum.org/v1/gonum/floats
:
// Initialize a couple of "vectors" represented as slices.
vectorA := []float64{11.0, 5.2, -1.3}
vectorB := []float64{-7.2, 4.2, 5.1}
// Compute the dot product of A and B
// (https://en.wikipedia.org/wiki/Dot_product).
dotProduct := floats.Dot(vectorA, vectorB)
fmt.Printf("The dot product of A and B is: %0.2f\n", dotProduct)
// Scale each element of A by 1.5.
floats.Scale(1.5, vectorA)
fmt.Printf("Scaling A by 1.5 gives: %v\n", vectorA)
// Compute the norm/length of B.
normB := floats.Norm(vectorB, 2)
fmt.Printf("The norm/length of B is: %0.2f\n", normB)
我们也可以使用 gonum.org/v1/gonum/mat
执行类似的操作:
// Initialize a couple of "vectors" represented as slices.
vectorA := mat.NewVector(3, []float64{11.0, 5.2, -1.3})
vectorB := mat.NewVector(3, []float64{-7.2, 4.2, 5.1})
// Compute the dot product of A and B
// (https://en.wikipedia.org/wiki/Dot_product).
dotProduct := mat.Dot(vectorA, vectorB)
fmt.Printf("The dot product of A and B is: %0.2f\n", dotProduct)
// Scale each element of A by 1.5.
vectorA.ScaleVec(1.5, vectorA)
fmt.Printf("Scaling A by 1.5 gives: %v\n", vectorA)
// Compute the norm/length of B.
normB := blas64.Nrm2(3, vectorB.RawVector())
fmt.Printf("The norm/length of B is: %0.2f\n", normB)
两种情况下的语义是相似的。如果你只处理向量(不是矩阵),并且/或者你只需要对浮点数的切片进行一些轻量级和快速的操作,那么 gonum.org/v1/gonum/floats
可能是一个不错的选择。然而,如果你同时处理矩阵和向量,并且/或者想要访问更广泛的向量/矩阵功能,那么使用 gonum.org/v1/gonum/mat
(偶尔参考 gonum.org/v1/gonum/blas/blas64
)可能更好。
矩阵
矩阵和线性代数可能对许多人来说看起来很复杂,但简单来说,矩阵只是数字的矩形组织,线性代数规定了它们操作的相关规则。例如,一个排列在 4 x 3 矩形上的矩阵 A 可能看起来像这样:
矩阵 A 的组件(a[11]、a[12] 等等)是我们安排到矩阵中的单个数字,下标表示组件在矩阵中的位置。第一个索引是行索引,第二个索引是列索引。更一般地,A 可以有任何形状/大小,有 M 行和 N 列:
要使用 gonum.org/v1/gonum/mat
形成这样的矩阵,我们需要创建一个 float64
值的切片,它是所有矩阵组件的平面表示。例如,在我们的例子中,我们想要形成以下矩阵:
我们需要创建一个 float64
值的切片,如下所示:
// Create a flat representation of our matrix.
components := []float64{1.2, -5.7, -2.4, 7.3}
然后,我们可以提供这个,以及维度信息,给 gonum.org/v1/gonum/mat
来形成一个新的 mat.Dense
矩阵值:
// Form our matrix (the first argument is the number of
// rows and the second argument is the number of columns).
a := mat.NewDense(2, 2, data)
// As a sanity check, output the matrix to standard out.
fa := mat.Formatted(a, mat.Prefix(" "))
fmt.Printf("mat = %v\n\n", fa)
注意,我们还在 gonum.org/v1/gonum/mat
中使用了漂亮的格式化逻辑来打印矩阵作为合理性检查。当你运行这个程序时,你应该看到以下内容:
$ go build
$ ./myprogram
A = [ 1.2 -5.7]
[-2.4 7.3]
然后,我们可以通过内置方法访问和修改 A 中的某些值:
// Get a single value from the matrix.
val := a.At(0, 1)
fmt.Printf("The value of a at (0,1) is: %.2f\n\n", val)
// Get the values in a specific column.
col := mat.Col(nil, 0, a)
fmt.Printf("The values in the 1st column are: %v\n\n", col)
// Get the values in a kspecific row.
row := mat.Row(nil, 1, a)
fmt.Printf("The values in the 2nd row are: %v\n\n", row)
// Modify a single element.
a.Set(0, 1, 11.2)
// Modify an entire row.
a.SetRow(0, []float64{14.3, -4.2})
// Modify an entire column.
a.SetCol(0, []float64{1.7, -0.3})
矩阵运算
与向量一样,矩阵有一套自己的算术规则和一系列特殊操作。与矩阵相关的某些算术行为可能与你预期的相似。然而,在进行矩阵相乘或求逆等操作时,你需要特别注意。
便利的是,gonum.org/v1/gonum/mat
为这种算术和许多其他特殊操作提供了一个很好的 API。以下是一个示例,展示了几个操作,如加法、乘法、除法等:
// Create two matrices of the same size, a and b.
a := mat.NewDense(3, 3, []float64{1, 2, 3, 0, 4, 5, 0, 0, 6})
b := mat.NewDense(3, 3, []float64{8, 9, 10, 1, 4, 2, 9, 0, 2})
// Create a third matrix of a different size.
c := mat.NewDense(3, 2, []float64{3, 2, 1, 4, 0, 8})
// Add a and b.
d := mat.NewDense(0, 0, nil)
d.Add(a, b)
fd := mat.Formatted(d, mat.Prefix(" "))
fmt.Printf("d = a + b = %0.4v\n\n", fd)
// Multiply a and c.
f := mat.NewDense(0, 0, nil)
f.Mul(a, c)
ff := mat.Formatted(f, mat.Prefix(" "))
fmt.Printf("f = a c = %0.4v\n\n", ff)
// Raising a matrix to a power.
g := mat.NewDense(0, 0, nil)
g.Pow(a, 5)
fg := mat.Formatted(g, mat.Prefix(" "))
fmt.Printf("g = a⁵ = %0.4v\n\n", fg)
// Apply a function to each of the elements of a.
h := mat.NewDense(0, 0, nil)
sqrt := func(_, _ int, v float64) float64 { return math.Sqrt(v) }
h.Apply(sqrt, a)
fh := mat.Formatted(h, mat.Prefix(" "))
fmt.Printf("h = sqrt(a) = %0.4v\n\n", fh)
特别是,请注意上面的 Apply()
方法。这个功能非常有用,因为它允许你将任何函数应用于矩阵的元素。你可以将相同的函数应用于所有元素,或者使函数依赖于矩阵元素的索引。例如,你可以使用这个方法进行逐元素乘法、应用用户定义的函数或应用第三方包中的函数。
然后,对于所有各种事情,比如行列式、特征值/向量求解器和逆矩阵,gonum.org/v1/gonum/mat
都为你提供了支持。再次强调,我不会详细介绍所有功能,但这里是一些操作的示例:
// Create a new matrix a.
a := mat.NewDense(3, 3, []float64{1, 2, 3, 0, 4, 5, 0, 0, 6})
// Compute and output the transpose of the matrix.
ft := mat.Formatted(a.T(), mat.Prefix(" "))
fmt.Printf("a^T = %v\n\n", ft)
// Compute and output the determinant of a.
deta := mat.Det(a)
fmt.Printf("det(a) = %.2f\n\n", deta)
// Compute and output the inverse of a.
aInverse := mat.NewDense(0, 0, nil)
if err := aInverse.Inverse(a); err != nil {
log.Fatal(err)
}
fi := mat.Formatted(aInverse, mat.Prefix(" "))
fmt.Printf("a^-1 = %v\n\n", fi)
注意,在这个例子中,当我们需要确保保持代码的完整性和可读性时,我们利用了 Go 的显式错误处理功能。矩阵并不总是有逆矩阵。在处理矩阵和大数据集时,会出现各种类似的情况,我们希望确保我们的应用程序按预期运行。
统计学
最后,你的机器学习应用程序的成功将取决于你数据的质量、你对数据的理解以及你对结果的评估/验证。这三件事都需要我们对统计学有一个理解。
统计学领域帮助我们理解数据,并量化我们的数据和结果看起来像什么。它还为我们提供了测量应用程序性能以及防止某些机器学习陷阱(如过拟合)的机制。
与线性代数一样,我们在这里无法提供统计学的完整介绍,但网上和印刷品中有很多资源可以学习统计学入门。在这里,我们将关注对基础知识的根本理解,以及 Go 中的实现实践。我们将介绍分布的概念,以及如何量化这些分布并可视化它们。
分布
分布是对数据集中值出现频率的表示。比如说,作为数据科学家,你跟踪的一件事是某产品或服务的每日销售额,你有一个长长的列表(你可以将其表示为向量或矩阵的一部分)记录了这些每日销售额。这些销售额数据是我们数据集的一部分,包括一天销售额为 $121,另一天销售额为 $207,等等。
将会有一个销售数字是我们积累的最低的。也将会有一个销售数字是我们积累的最高,其余的销售数字则位于两者之间(至少如果我们假设没有精确的重复)。以下图像表示了销售的低、高和中间值:
这因此是一个销售分布,或者至少是销售分布的一个表示。请注意,这个分布有更多数字和数字较少的区域。此外,请注意,数字似乎倾向于分布的中心。
统计测量
为了量化分布看起来像什么,我们将使用各种统计测量。通常,有两种类型的这些测量:
-
中心趋势测量:这些测量值的位置,或者分布的中心位置在哪里(例如,沿着前面的线性表示)。
-
离散度或分散度测量:这些测量值如何在分布的范围内(从最低值到最高值)分布。
有各种包允许你快速计算和/或利用这些统计测量。我们将使用gonum.org/v1/gonum/stat
(你可能开始注意到我们将大量使用 gonum)和github.com/montanaflynn/stats
。
注意,gonum.org/v1/gonum/stat
和 github.com/montanaflynn/stats
包的名称之间有一个字母的差异。在查看以下章节中的示例时请记住这一点。
均值测量
中心趋势测量包括以下内容:
-
均值
:这可能是你通常所说的平均值。我们通过将分布中的所有数字相加,然后除以数字的数量来计算这个值。 -
中位数
:如果我们按从低到高的顺序对分布中的所有数字进行排序,这个数字就是将数字的最低一半与最高一半分开的数字。 -
众数
:这是分布中出现频率最高的值。
让我们计算之前在第一章,“收集和组织数据”中介绍的鸢尾花数据集一列中的值。作为提醒,这个数据集包括四个花测量列,以及一个对应的花种列。因此,每个测量列都包含了一组代表该测量分布的值:
// Open the CSV file.
irisFile, err := os.Open("../data/iris.csv")
if err != nil {
log.Fatal(err)
}
defer irisFile.Close()
// Create a dataframe from the CSV file.
irisDF := dataframe.ReadCSV(irisFile)
// Get the float values from the "sepal_length" column as
// we will be looking at the measures for this variable.
sepalLength := irisDF.Col("sepal_length").Float()
// Calculate the Mean of the variable.
meanVal := stat.Mean(sepalLength, nil)
// Calculate the Mode of the variable.
modeVal, modeCount := stat.Mode(sepalLength, nil)
// Calculate the Median of the variable.
medianVal, err := stats.Median(sepalLength)
if err != nil {
log.Fatal(err)
}
// Output the results to standard out.
fmt.Printf("\nSepal Length Summary Statistics:\n")
fmt.Printf("Mean value: %0.2f\n", meanVal)
fmt.Printf("Mode value: %0.2f\n", modeVal)
fmt.Printf("Mode count: %d\n", int(modeCount))
fmt.Printf("Median value: %0.2f\n\n", medianVal)
运行此程序会产生以下结果:
$ go build
$ ./myprogram
Sepal Length Summary Statistics:
Mean value: 5.84
Mode value: 5.00
Mode count: 10
Median value: 5.80
你可以看到均值、众数和中位数都有所不同。然而,请注意,在sepal_length
列的值中,均值和中位数非常接近。
另一方面,如果我们把前面的代码中的sepal_length
改为petal_length
,我们将得到以下结果:
$ go build
$ ./myprogram
Sepal Length Summary Statistics:
Mean value: 3.76
Mode value: 1.50
Mode count: 14
Median value: 4.35
对于petal_length
值,平均值和中位数并不那么接近。我们可以从这些信息中开始对数据进行一些直观的了解。如果平均值和中位数不接近,这意味着高值或低值正在将平均值拉高或拉低,分别是一种在平均值中不那么明显的影响。我们称这种为偏斜的分布。
离散度或分散度
现在我们已经对大多数值的位置(或分布的中心)有了概念,让我们尝试量化分布的值是如何围绕分布中心分布的。以下是一些广泛使用的量化这种分布的指标:
-
最大值:分布的最高值
-
最小值:分布的最低值
-
范围:最大值和最小值之间的差
-
方差:这个度量是通过取分布中的每个值,计算每个值与分布平均值的差,平方这个差,将其加到其他平方差上,然后除以分布中的值数来计算的
-
标准差:方差的平方根
-
分位数/四分位数:与中位数类似,这些度量定义了分布中的截止点,其中一定数量的低值低于该度量,而一定数量的高值高于该度量
使用gonum.org/v1/gonum/stat
,这些度量的计算如下:
// Open the CSV file.
irisFile, err := os.Open("../data/iris.csv")
if err != nil {
log.Fatal(err)
}
defer irisFile.Close()
// Create a dataframe from the CSV file.
irisDF := dataframe.ReadCSV(irisFile)
// Get the float values from the "sepal_length" column as
// we will be looking at the measures for this variable.
sepalLength := irisDF.Col("petal_length").Float()
// Calculate the Max of the variable.
minVal := floats.Min(sepalLength)
// Calculate the Max of the variable.
maxVal := floats.Max(sepalLength)
// Calculate the Median of the variable.
rangeVal := maxVal - minVal
// Calculate the variance of the variable.
varianceVal := stat.Variance(sepalLength, nil)
// Calculate the standard deviation of the variable.
stdDevVal := stat.StdDev(sepalLength, nil)
// Sort the values.
inds := make([]int, len(sepalLength))
floats.Argsort(sepalLength, inds)
// Get the Quantiles.
quant25 := stat.Quantile(0.25, stat.Empirical, sepalLength, nil)
quant50 := stat.Quantile(0.50, stat.Empirical, sepalLength, nil)
quant75 := stat.Quantile(0.75, stat.Empirical, sepalLength, nil)
// Output the results to standard out.
fmt.Printf("\nSepal Length Summary Statistics:\n")
fmt.Printf("Max value: %0.2f\n", maxVal)
fmt.Printf("Min value: %0.2f\n", minVal)
fmt.Printf("Range value: %0.2f\n", rangeVal)
fmt.Printf("Variance value: %0.2f\n", varianceVal)
fmt.Printf("Std Dev value: %0.2f\n", stdDevVal)
fmt.Printf("25 Quantile: %0.2f\n", quant25)
fmt.Printf("50 Quantile: %0.2f\n", quant50)
fmt.Printf("75 Quantile: %0.2f\n\n", quant75)
运行这个程序给出以下结果:
$ go build
$ ./myprogram
Sepal Length Summary Statistics:
Max value: 6.90
Min value: 1.00
Range value: 5.90
Variance value: 3.11
Std Dev value: 1.76
25 Quantile: 1.60
50 Quantile: 4.30
75 Quantile: 5.10
好吧,让我们尝试理解这些数字,看看它们对sepal_length
列中值的分布意味着什么。我们可以得出以下结论。
首先,标准差是1.76
,整个值的范围是5.90
。与方差不同,标准差具有与值本身相同的单位,因此我们可以看到值实际上在值的范围内变化很大(标准差值大约是总范围值的 30%)。
接下来,让我们看看分位数。25%分位数表示分布中的一个点,其中 25%的值低于该度量,而其他 75%的值高于该度量。50%和 75%分位数也是类似的。由于 25%分位数比 75%分位数和最大值之间的距离更接近最小值,我们可以推断出分布中的高值可能比低值更分散。
当然,你可以利用这些度量以及中心趋势度量中的任何组合,来帮助你量化分布的外观,还有其他一些统计度量在这里无法涵盖。
这里要说明的是,你应该利用这类度量来帮助你建立数据的心智模型。这将使你能够将结果置于上下文中,并对你的工作进行合理性检查。
可视化分布
尽管量化分布的外观很重要,但我们实际上应该可视化分布以获得最直观的感知。有各种类型的图表和图形,允许我们创建值的分布的视觉表示。这些帮助我们形成数据的心智模型,并将我们数据的信息传达给团队成员、应用程序用户等。
直方图
帮助我们理解分布的第一种图表或图表称为 直方图。实际上,直方图是一种组织或计数你的值的方式,然后可以在直方图图中绘制。要形成直方图,我们首先创建一定数量的箱,划分出我们值范围的不同区域。例如,考虑我们在前几节中讨论的销售数字分布:
接下来,我们计算每个箱子中有多少个我们的值:
这些计数以及箱的定义形成了我们的直方图。然后我们可以轻松地将这些转换为计数的图,这为我们提供了分布的很好的视觉表示:
我们可以使用 gonum 从实际数据创建直方图并绘制直方图。gonum 提供用于此类绘图以及其他类型绘图的包可以在 gonum.org/v1/plot
中找到。作为一个例子,让我们为 iris 数据集中的每一列创建直方图图。
首先,从 gonum 导入以下内容:
import (
"gonum.org/v1/plot"
"gonum.org/v1/plot/plotter"
"gonum.org/v1/plot/vg"
)
然后,我们将读取 iris 数据集,创建一个数据框,并查看数值列生成直方图图:
// Open the CSV file.
irisFile, err := os.Open("../data/iris.csv")
if err != nil {
log.Fatal(err)
}
defer irisFile.Close()
// Create a dataframe from the CSV file.
irisDF := dataframe.ReadCSV(irisFile)
// Create a histogram for each of the feature columns in the dataset.
for _, colName := range irisDF.Names() {
// If the column is one of the feature columns, let's create
// a histogram of the values.
if colName != "species" {
// Create a plotter.Values value and fill it with the
// values from the respective column of the dataframe.
v := make(plotter.Values, irisDF.Nrow())
for i, floatVal := range irisDF.Col(colName).Float() {
v[i] = floatVal
}
// Make a plot and set its title.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.Title.Text = fmt.Sprintf("Histogram of a %s", colName)
// Create a histogram of our values drawn
// from the standard normal.
h, err := plotter.NewHist(v, 16)
if err != nil {
log.Fatal(err)
}
// Normalize the histogram.
h.Normalize(1)
// Add the histogram to the plot.
p.Add(h)
// Save the plot to a PNG file.
if err := p.Save(4*vg.Inch, 4*vg.Inch, colName+"_hist.png"); err != nil {
log.Fatal(err)
}
}
}
注意,我们已经对直方图进行了归一化(使用 h.Normalize()
)。这是典型的,因为通常你将想要比较具有不同值计数的不同分布。归一化直方图允许我们并排比较不同的分布。
上述代码将为 iris 数据集中的数值列生成以下直方图的四个 *.png
文件:
这些分布彼此看起来都不同。sepal_width
分布看起来像钟形曲线或正态/高斯分布(我们将在本书后面讨论)。另一方面,花瓣分布看起来像有两个不同的明显值簇。当我们开发机器学习工作流程时,我们将利用这些观察结果,但在此阶段,只需注意这些可视化如何帮助我们建立数据的心智模型。
箱线图
直方图绝不是唯一一种帮助我们直观理解数据的方式。另一种常用的图表类型被称为箱线图。这种图表类型也让我们对分布中值的分组和分布情况有所了解,但与直方图不同,箱线图有几个明显的特征,有助于引导我们的视线:
因为箱线图中箱体的边界由中位数、第一四分位数(25% 分位数/百分位数)和第三四分位数定义,所以两个中央箱体包含的分布值数量相同。如果一个箱体比另一个大,这意味着分布是偏斜的。
箱线图还包括两个尾部或须。这些给我们一个快速的可视指示,表明分布的范围与包含大多数值(中间 50%)的区域相比。
为了巩固这种图表类型,我们再次为鸢尾花数据集创建图表。与直方图类似,我们将使用gonum.org/v1/plot
。然而,在这种情况下,我们将所有箱线图放入同一个*.png
文件中:
// Open the CSV file.
irisFile, err := os.Open("../data/iris.csv")
if err != nil {
log.Fatal(err)
}
defer irisFile.Close()
// Create a dataframe from the CSV file.
irisDF := dataframe.ReadCSV(irisFile)
// Create the plot and set its title and axis label.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.Title.Text = "Box plots"
p.Y.Label.Text = "Values"
// Create the box for our data.
w := vg.Points(50)
// Create a box plot for each of the feature columns in the dataset.
for idx, colName := range irisDF.Names() {
// If the column is one of the feature columns, let's create
// a histogram of the values.
if colName != "species" {
// Create a plotter.Values value and fill it with the
// values from the respective column of the dataframe.
v := make(plotter.Values, irisDF.Nrow())
for i, floatVal := range irisDF.Col(colName).Float() {
v[i] = floatVal
}
// Add the data to the plot.
b, err := plotter.NewBoxPlot(w, float64(idx), v)
if err != nil {
log.Fatal(err)
}
p.Add(b)
}
}
// Set the X axis of the plot to nominal with
// the given names for x=0, x=1, etc.
p.NominalX("sepal_length", "sepal_width", "petal_length", "petal_width")
if err := p.Save(6*vg.Inch, 8*vg.Inch, "boxplots.png"); err != nil {
log.Fatal(err)
}
这将生成以下图形:
正如我们在直方图中观察到的,sepal_length
列看起来相对对称。另一方面,petal_length
看起来则不对称得多。还要注意的是,gonum 在箱线图中包括了几个异常值(标记为圆圈或点)。许多绘图包都包括这些。它们表示那些至少与分布的中位数有一定距离的值。
概率
到目前为止,我们现在已经了解了几种表示/操作数据的方法(矩阵和向量),并且我们知道如何获取对数据的理解,以及如何量化数据的外观(统计学)。然而,有时当我们开发机器学习应用时,我们也想知道预测正确的可能性有多大,或者给定结果历史,某些结果的重要性有多大。概率可以帮助我们回答这些“可能性”和“重要性”问题。
通常,概率与事件或观察的可能性有关。例如,如果我们抛硬币来做决定,看到正面(50%)的可能性有多大,看到反面(50%)的可能性有多大,甚至硬币是否是公平的硬币的可能性有多大?这看起来可能是一个微不足道的例子,但在进行机器学习时,许多类似的问题都会出现。事实上,一些机器学习算法是基于概率规则和定理构建的。
随机变量
假设我们有一个实验,就像我们抛硬币的场景,它可能有多个结果(正面或反面)。现在让我们定义一个变量,其值可以是这些结果之一。这个变量被称为随机变量。
在抛硬币的情况下(至少如果我们考虑的是公平硬币),随机变量的每个结果出现的可能性是相等的。也就是说,我们看到正面的概率是 50%,看到反面的概率也是 50%。然而,随机变量的各种值不必具有相等的可能性。如果我们试图预测是否会下雨,这些结果将不会具有相等的可能性。
随机变量使我们能够定义我们之前提到的那些“可能性”和“显著性”的问题。它们可以有有限个结果,或者可以代表连续变量的范围。
概率度量
那么,我们观察到特定实验结果的可能性有多大?为了量化这个问题的答案,我们引入概率度量,通常被称为概率。它们用一个介于 0 和 1 之间的数字表示,或者用一个介于 0%和 100%之间的百分比表示。
在抛公平硬币的情况下,我们有以下场景:
-
出现正面的概率是 0.5 或 50%,这里的 0.5 或 50%是一个概率度量
-
出现反面的概率是 0.5 或 50%
某个实验的概率度量必须加起来等于 1,因为当事件发生时,它必须对应于可能的某个结果。
独立和条件概率
如果一个事件(实验的结果)的概率在某种程度上不影响其他事件的概率,那么这两个事件(实验的结果)是独立的。独立事件的例子是抛硬币或掷骰子。另一方面,相关事件是那些一个事件的概率影响另一个事件概率的事件。相关事件的例子是从一副牌中抽取卡片而不放回。
我们如何量化这种第二种类型的概率,通常被称为条件概率?符号上,独立概率可以用P(A)表示,它是A的概率(其中A可能代表抛硬币、掷骰子等)。然后条件概率用P(B|A)表示,即在给定B的情况下A的概率(其中B是另一个结果)。
要实际计算条件概率,我们可以使用贝叶斯定理/规则:P(A|B) = P(B|A) P(A) / P(B)。有时你会看到这些术语如下定义:
-
P(A|B):后验概率,因为它是在观察B之后关于A的已知信息
-
P(A):先验概率,因为它是在观察B之前关于A的数据
-
P(B|A):似然性,因为它衡量了B与A的兼容性
-
P(B):证据概率,因为它衡量了B的概率,而我们已知B是真实的
这个定理是本书后面将要讨论的各种技术的基础,例如朴素贝叶斯分类技术。
假设检验
我们可以用概率来量化“可能性”,甚至可以用贝叶斯定理计算条件概率,但如何量化与实际观察相对应的“显著性”问题呢?例如,我们可以用公平的硬币量化正面/反面的概率,但当我们多次抛硬币并观察到 48%正面和 52%反面时,这有多显著?这是否意味着我们有一个不公平的硬币?
这些“显著性”问题可以通过称为假设检验的过程来回答。这个过程通常包括以下步骤:
-
提出一个零假设,称为H[0],以及一个备择假设,称为H[a]。H[0]代表你所观察到的(例如,48%正面和 52%反面)是纯粹偶然的结果,而H[a]代表某种潜在效应导致与纯粹偶然有显著偏差的场景(例如,一个不公平的硬币)。零假设始终被假定为真实的。
-
确定一个测试统计量,你将用它来确定H[0]的有效性。
-
确定一个p 值,它表示在H[0]为真的假设下,观察到至少与你的测试统计量一样显著的测试统计量的概率。这个 p 值可以从与测试统计量相对应的概率分布中获得(通常表示为表格或分布函数)。
-
将你的 p 值与预先设定的阈值进行比较。如果 p 值小于或等于预先设定的阈值,则拒绝H[0],接受H[a]。
这可能看起来相当抽象,但这个过程最终将与你的机器学习工作流程相交。例如,你可能改变了一个优化广告的机器学习模型,然后你可能想量化销售额的增加是否实际上具有统计学意义。在另一种情况下,你可能正在分析代表可能欺诈网络流量的日志,你可能需要构建一个模型来识别与预期网络流量有显著差异的统计显著偏差。
注意,你可能会看到某些假设检验被称为 A/B 测试,尽管这里列出的过程很常见,但绝不是假设检验的唯一方法。还有贝叶斯 A/B 测试、用于优化的 bandit 算法等等,这些内容本书不会详细涉及。
测试统计量
在假设检验中可以使用许多不同的测试统计量。这些包括 Z 统计量、T 统计量、F 统计量和卡方统计量。当然,你可以在 Go 中从头开始实现这些度量,而不会遇到太多麻烦。然而,也有一些现成的实现可供使用。
返回到gonum.org/v1/gonum/stat
,我们可以按照以下方式计算卡方统计量:
// Define observed and expected values. Most
// of the time these will come from your
// data (website visits, etc.).
observed := []float64{48, 52}
expected := []float64{50, 50}
// Calculate the ChiSquare test statistic.
chiSquare := stat.ChiSquare(observed, expected)
计算 p 值
假设我们有以下场景:
对当地居民的调查显示,60%的居民没有进行常规锻炼,25%偶尔锻炼,15%定期锻炼。在实施了一些复杂的建模和社区服务后,调查以同样的问题重复进行。后续调查由 500 名居民完成,以下为结果:
无规律锻炼:260
偶尔锻炼:135
规律锻炼:105
总计:500
现在,我们想要确定居民回答中是否存在统计上显著的转变的证据。我们的零假设和备择假设如下:
-
H[0]:与先前观察到的百分比偏差是由于纯粹偶然性
-
H[a]:偏差是由于一些超出纯粹偶然性的潜在效应(可能是我们新的社区服务)
首先,让我们使用卡方检验统计量来计算我们的检验统计量:
// Define the observed frequencies.
observed := []float64{
260.0, // This number is the number of observed with no regular exercise.
135.0, // This number is the number of observed with sporatic exercise.
105.0, // This number is the number of observed with regular exercise.
}
// Define the total observed.
totalObserved := 500.0
// Calculate the expected frequencies (again assuming the null Hypothesis).
expected := []float64{
totalObserved * 0.60,
totalObserved * 0.25,
totalObserved * 0.15,
}
// Calculate the ChiSquare test statistic.
chiSquare := stat.ChiSquare(observed, expected)
// Output the test statistic to standard out.
fmt.Printf("\nChi-square: %0.2f\n", chiSquare)
这将给我们以下卡方值:
$ go build
$ ./myprogram
Chi-square: 18.13
接下来,我们需要计算与这个卡方
值对应的 p 值。这需要我们知道关于卡方分布的信息,它定义了卡方某些测度值和某些自由度的 p 值。github.com/gonum/stat
还包括了从其中我们可以计算我们的p 值
的卡方分布表示:
// Create a Chi-squared distribution with K degrees of freedom.
// In this case we have K=3-1=2, because the degrees of freedom
// for a Chi-squared distribution is the number of possible
// categories minus one.
chiDist := distuv.ChiSquared{
K: 2.0,
Src: nil,
}
// Calculate the p-value for our specific test statistic.
pValue := chiDist.Prob(chiSquare)
// Output the p-value to standard out.
fmt.Printf("p-value: %0.4f\n\n", pValue)
这给我们以下结果:
$ go build
$ ./myprogram
Chi-square: 18.13
p-value: 0.0001
因此,在调查第二版中观察到的偏差结果完全是由于偶然性的可能性为 0.01%。如果我们,例如,使用 5%的阈值(这是常见的),我们就需要拒绝零假设并采用我们的备择假设。
参考文献
向量和矩阵:
-
gonum.org/v1/gonum/floats
文档:godoc.org/gonum.org/v1/gonum/floats
-
gonum.org/v1/gonum/mat
文档:godoc.org/gonum.org/v1/gonum/mat
统计学:
-
gonum.org/v1/gonum/stat
文档:godoc.org/gonum.org/v1/gonum/stat
-
github.com/montanaflynn/stats
文档:godoc.org/github.com/montanaflynn/stats
可视化:
-
gonum.org/v1/plot
文档:godoc.org/gonum.org/v1/plot
-
gonum.org/v1/plot
wiki 带示例:github.com/gonum/plot/wiki/Example-plots
概率:
gonum.org/v1/gonum/stat/distuv
文档:godoc.org/gonum.org/v1/gonum/stat/distuv
摘要
这本关于 Go 语言中矩阵、线性代数、统计学和概率的介绍,为我们提供了一套理解和操作数据的工具。这套工具将在我们解决各种问题时贯穿全书,并且这些工具可以在机器学习之外的各种环境中使用。然而,在下一章中,我们将讨论一些在机器学习环境中极为重要的思想和技巧,特别是评估和验证。
第三章:评估与验证
为了拥有可持续、负责任的机器学习工作流程,并开发出能够产生真正价值的机器学习应用,我们需要能够衡量我们的机器学习模型表现的好坏。我们还需要确保我们的机器学习模型能够泛化到它们在生产中可能会看到的数据。如果我们不这样做,我们基本上就是在黑暗中射击。我们将无法理解我们模型预期的行为,并且我们无法随着时间的推移来改进它们。
测量模型表现(相对于某些数据)的过程称为评估。确保我们的模型泛化到我们可能预期遇到的数据的过程称为验证。这两个过程都需要在每个机器学习工作流程和应用中存在,我们将在本章中介绍这两个过程。
评估
科学的一个基本原则是测量,机器学习的科学也不例外。我们需要能够衡量或评估我们的模型表现如何,这样我们才能继续改进它们,比较一个模型与另一个模型,并检测我们的模型何时表现不佳。
只有一个问题。我们如何评估我们的模型表现如何?我们应该衡量它们训练或推理的速度有多快?我们应该衡量它们正确回答的次数有多少?我们如何知道正确答案是什么?我们应该衡量我们偏离观察值的程度有多大?我们如何衡量这个距离?
正如你所见,我们在如何评估我们的模型方面有很多决定要做。真正重要的是上下文。在某些情况下,效率确实很重要,但每个机器学习上下文都要求我们衡量我们的预测、推理或结果与理想的预测、推理或结果之间的匹配程度。因此,测量计算结果与理想结果之间的比较应该始终优先于速度优化。
通常,有一些结果类型是我们需要评估的:
-
连续:结果如总销售额、股价和温度等,可以取任何连续数值($12102.21、92 度等)
-
分类:结果如欺诈/非欺诈、活动、名称等,可以属于有限数量的类别(欺诈、站立、弗兰克等)
这些结果类型中的每一种都有相应的评估指标,这里将进行介绍。然而,请记住,你选择的评估指标取决于你试图通过你的机器学习模型实现什么。没有一种适合所有情况的指标,在某些情况下,你可能甚至需要创建自己的指标。
连续指标
假设我们有一个应该预测某些连续值的模型,比如股价。假设我们已经积累了一些可以与实际观察值进行比较的预测值:
observation,prediction
22.1,17.9
10.4,9.1
9.3,7.8
18.5,14.2
12.9,15.6
7.2,7.4
11.8,9.7
...
现在,我们如何衡量这个模型的性能呢?首先一步是计算观察值和预测值之间的差异以得到一个error
:
observation,prediction,error
22.1,17.9,4.2
10.4,9.1,1.3
9.3,7.8,1.5
18.5,14.2,4.3
12.9,15.6,-2.7
7.2,7.4,-0.2
11.8,9.7,2.1
...
误差给我们一个大致的概念,即我们离我们本应预测的值有多远。然而,实际上或实际地查看所有误差值是不切实际的,尤其是在有大量数据的情况下。可能会有数百万或更多的这些误差值。因此,我们需要一种方法来理解误差的总体情况。
均方误差(MSE)和平均绝对误差(MAE)为我们提供了对误差的总体视图:
-
MSE 或均方偏差(MSD)是所有误差平方的平均值
-
MAE 是所有误差绝对值的平均值
MSE 和 MAE 都给我们提供了一个关于我们的预测有多好的整体图景,但它们确实有一些区别。由于 MSE 取误差的平方,因此相对于 MAE,大误差值(例如,对应于异常值)被强调得更多。换句话说,MSE 对异常值更敏感。另一方面,MAE 与我们要预测的变量的单位相同,因此可以直接与这些值进行比较。
对于这个数据集,我们可以解析观察到的和预测的值,并如下计算 MAE 和 MSE:
// Open the continuous observations and predictions.
f, err := os.Open("continuous_data.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
// observed and predicted will hold the parsed observed and predicted values
// form the continuous data file.
var observed []float64
var predicted []float64
// line will track row numbers for logging.
line := 1
// Read in the records looking for unexpected types in the columns.
for {
// Read in a row. Check if we are at the end of the file.
record, err := reader.Read()
if err == io.EOF {
break
}
// Skip the header.
if line == 1 {
line++
continue
}
// Read in the observed and predicted values.
observedVal, err := strconv.ParseFloat(record[0], 64)
if err != nil {
log.Printf("Parsing line %d failed, unexpected type\n", line)
continue
}
predictedVal, err := strconv.ParseFloat(record[1], 64)
if err != nil {
log.Printf("Parsing line %d failed, unexpected type\n", line)
continue
}
// Append the record to our slice, if it has the expected type.
observed = append(observed, observedVal)
predicted = append(predicted, predictedVal)
line++
}
// Calculate the mean absolute error and mean squared error.
var mAE float64
var mSE float64
for idx, oVal := range observed {
mAE += math.Abs(oVal-predicted[idx]) / float64(len(observed))
mSE += math.Pow(oVal-predicted[idx], 2) / float64(len(observed))
}
// Output the MAE and MSE value to standard out.
fmt.Printf("\nMAE = %0.2f\n", mAE)
fmt.Printf("\nMSE = %0.2f\n\n", mSE)
对于我们的示例数据,这导致以下结果:
$ go build
$ ./myprogram
MAE = 2.55
MSE = 10.51
为了判断这些值是否良好,我们需要将它们与我们的观察数据中的值进行比较。特别是,MAE 是2.55
,我们观察值的平均值是 14.0,因此我们的 MAE 大约是平均值的 20%。根据上下文,这并不很好。
除了 MSE 和 MAE 之外,你可能会看到R-squared(也称为R²或R2),或确定系数,用作连续变量模型的评估指标。R-squared 也给我们一个关于我们预测偏差的一般概念,但 R-squared 的想法略有不同。
R-squared 衡量的是观察值中我们捕捉到的预测值的方差比例。记住,我们试图预测的值有一些变异性。例如,我们可能试图预测股价、利率或疾病进展,它们本质上并不完全相同。我们试图创建一个可以预测观察值中这种变异性的模型,而我们捕捉到的变异百分比由 R-squared 表示。
便利的是,gonum.org/v1/gonum/stat
有一个内置函数来计算 R-squared:
// Calculate the R² value.
rSquared := stat.RSquaredFrom(observed, predicted, nil)
// Output the R² value to standard out.
fmt.Printf("\nR² = %0.2f\n\n", rSquared)
在我们的示例数据集上运行前面的代码会产生以下结果:
$ go build
$ ./myprogram
R² = 0.37
那么,这是一个好的还是坏的 R-squared?记住,R-squared 是一个百分比,百分比越高越好。在这里,我们捕捉到了我们试图预测的变量中大约 37%的方差。并不很好。
分类度量
假设我们有一个模型,该模型应该预测某些离散值,例如欺诈/非欺诈、站立/坐着/行走、批准/未批准等等。我们的数据可能看起来像以下这样:
observed,predicted
0,0
0,1
2,2
1,1
1,1
0,0
2,0
0,0
...
观察值可以取有限数量中的任何一个值(在这种情况下是 1、2 或 3)。这些值中的每一个代表我们数据中的一个离散类别(类别 1 可能对应欺诈交易,类别 2 可能对应非欺诈交易,类别 3 可能对应无效交易,例如)。预测值也可以取这些离散值之一。在评估我们的预测时,我们希望以某种方式衡量我们在做出这些离散预测时的正确性。
分类别变量的个体评估指标
实际上,有大量方法可以用指标来评估离散预测,包括准确率、精确度、召回率、特异性、灵敏度、漏报率、假遗漏率等等。与连续变量一样,没有一种适合所有情况的评估指标。每次你面对一个问题时,你需要确定适合该问题的指标,并符合项目的目标。你不想优化错误的事情,然后浪费大量时间根据其他指标重新实现你的模型。
为了理解这些指标并确定哪个适合我们的用例,我们需要意识到,当我们进行离散预测时可能会发生多种不同的场景:
-
真阳性(TP):我们预测了某个特定类别,而观察到的确实是那个类别(例如,我们预测欺诈,而观察到的确实是欺诈)
-
假阳性(FP):我们预测了某个特定类别,但观察到的实际上是另一个类别(例如,我们预测欺诈,但观察到的不是欺诈)
-
真阴性(TN):我们预测观察到的不是某个特定类别,而观察到的确实不是那个类别(例如,我们预测不是欺诈,而观察到的确实不是欺诈)
-
假阴性(FN):我们预测观察到的不是某个特定类别,但实际上确实是那个类别(例如,我们预测不是欺诈,但观察到的确实是欺诈)
你可以看到,我们有多种方式可以组合、汇总和衡量这些场景。实际上,我们甚至可以根据我们特定的问题以某种独特的方式汇总/衡量它们。然而,有一些相当标准的汇总和衡量这些场景的方法,结果产生了以下常见的指标:
-
准确率:预测正确的百分比,或 (TP + TN)/(TP + TN + FP + FN)
-
精确度:实际为正的预测的百分比,或 TP/(TP + FP)
-
召回率:被识别为正的预测的百分比,或 TP/(TP + FN)
尽管我将在这里强调这些,但你应该看看其他常见的指标及其含义。一个很好的概述可以在en.wikipedia.org/wiki/Precision_and_recall
找到。
以下是一个解析我们的数据并计算准确率的示例。首先,我们读取labeled.csv
文件,创建一个 CSV 读取器,并初始化两个切片,将保存我们的解析观察值/预测值:
// Open the binary observations and predictions.
f, err := os.Open("labeled.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
// observed and predicted will hold the parsed observed and predicted values
// form the labeled data file.
var observed []int
var predicted []int
然后,我们将遍历 CSV 中的记录,解析值,并将观察值和预测值进行比较以计算准确率:
// line will track row numbers for logging.
line := 1
// Read in the records looking for unexpected types in the columns.
for {
// Read in a row. Check if we are at the end of the file.
record, err := reader.Read()
if err == io.EOF {
break
}
// Skip the header.
if line == 1 {
line++
continue
}
// Read in the observed and predicted values.
observedVal, err := strconv.Atoi(record[0])
if err != nil {
log.Printf("Parsing line %d failed, unexpected type\n", line)
continue
}
predictedVal, err := strconv.Atoi(record[1])
if err != nil {
log.Printf("Parsing line %d failed, unexpected type\n", line)
continue
}
// Append the record to our slice, if it has the expected type.
observed = append(observed, observedVal)
predicted = append(predicted, predictedVal)
line++
}
// This variable will hold our count of true positive and
// true negative values.
var truePosNeg int
// Accumulate the true positive/negative count.
for idx, oVal := range observed {
if oVal == predicted[idx] {
truePosNeg++
}
}
// Calculate the accuracy (subset accuracy).
accuracy := float64(truePosNeg) / float64(len(observed))
// Output the Accuracy value to standard out.
fmt.Printf("\nAccuracy = %0.2f\n\n", accuracy)
运行此代码会产生以下结果:
$ go build
$ ./myprogram
Accuracy = 0.97
97%!这相当不错。这意味着我们 97%的时候是正确的。
我们可以类似地计算精确度和召回率。然而,你可能已经注意到,当我们有超过两个类别或类时,我们可以用几种方式来做这件事。我们可以将类别 1 视为正类,其他类别视为负类,将类别 2 视为正类,其他类别视为负类,依此类推。也就是说,我们可以为我们的每个类别计算一个精确度或召回率,如下面的代码示例所示:
// classes contains the three possible classes in the labeled data.
classes := []int{0, 1, 2}
// Loop over each class.
for _, class := range classes {
// These variables will hold our count of true positives and
// our count of false positives.
var truePos int
var falsePos int
var falseNeg int
// Accumulate the true positive and false positive counts.
for idx, oVal := range observed {
switch oVal {
// If the observed value is the relevant class, we should check to
// see if we predicted that class.
case class:
if predicted[idx] == class {
truePos++
continue
}
falseNeg++
// If the observed value is a different class, we should
// check to see if we predicted a false positive.
default:
if predicted[idx] == class {
falsePos++
}
}
}
// Calculate the precision.
precision := float64(truePos) / float64(truePos+falsePos)
// Calculate the recall.
recall := float64(truePos) / float64(truePos+falseNeg)
// Output the precision value to standard out.
fmt.Printf("\nPrecision (class %d) = %0.2f", class, precision)
fmt.Printf("\nRecall (class %d) = %0.2f\n\n", class, recall)
}
运行此代码会产生以下结果:
$ go build
$ ./myprogram
Precision (class 0) = 1.00
Recall (class 0) = 1.00
Precision (class 1) = 0.96
Recall (class 1) = 0.94
Precision (class 2) = 0.94
Recall (class 2) = 0.96
注意,精确度和召回率是稍微不同的指标,有不同的含义。如果我们想得到一个整体的精确度或召回率,我们可以平均每个类别的精确度和召回率。事实上,如果某些类别比其他类别更重要,我们可以对这些结果进行加权平均,并将其用作我们的评估指标。
你可以看到,有几个指标是 100%。这看起来很好,但实际上可能表明了一个问题,我们将在后面进一步讨论。
在某些情况下,例如金融和银行,假阳性或其他情况对于某些类别可能是非常昂贵的。例如,将交易错误标记为欺诈可能会造成重大损失。另一方面,其他类别的某些结果可能可以忽略不计。这些场景可能需要使用自定义指标或成本函数,该函数将某些类别、某些结果或某些结果的组合视为比其他结果更重要。
混淆矩阵、AUC 和 ROC
除了为我们的模型计算单个数值指标外,还有各种技术可以将各种指标组合成一种形式,为你提供一个更完整的模型性能表示。这包括但不限于混淆矩阵和曲线下面积(AUC)/接收者操作特征(ROC)曲线。
混淆矩阵允许我们以二维格式可视化我们预测的各种TP、TN、FP和FN值。混淆矩阵的行对应于你应该预测的类别,列对应于预测的类别。然后,每个元素的值是对应的计数:
如你所见,理想的情况是混淆矩阵只在对角线上有值(TP,TN)。对角线元素表示预测某个类别,而观察结果实际上就在那个类别中。非对角线元素包括预测错误的计数。
这种类型的混淆矩阵对于具有超过两个类别的实际问题特别有用。例如,你可能正在尝试根据移动加速器和位置数据预测各种活动。这些活动可能包括超过两个类别,如站立、坐着、跑步、驾驶等。这个问题的混淆矩阵将大于 2 x 2,这将使你能够快速评估模型在所有类别上的整体性能,并识别模型表现不佳的类别。
除了混淆矩阵外,ROC 曲线通常用于获得二元分类器(或训练用于预测两个类别之一的模型)的整体性能图。ROC 曲线绘制了每个可能的分类阈值下的召回率与假阳性率(FP/(FP + TN))。
ROC 曲线中使用的阈值代表你在分类的两个类别之间分离的各种边界或排名。也就是说,由 ROC 曲线评估的模型必须基于概率、排名或分数(在下图中称为分数)对两个类别进行预测。在前面提到的每个例子中,分数以一种方式分类,反之亦然:
要生成 ROC 曲线,我们为测试示例中的每个分数或排名绘制一个点(召回率、假阳性率)。然后我们可以将这些点连接起来形成曲线。在许多情况下,你会在 ROC 曲线图的对角线上看到一条直线。这条直线是分类器的参考线,具有大约随机的预测能力:
一个好的 ROC 曲线是位于图表右上方的曲线,这意味着我们的模型具有比随机预测能力更好的预测能力。ROC 曲线越靠近图表的右上角,越好。这意味着好的 ROC 曲线具有更高的 AUC;ROC 曲线的 AUC 也用作评估指标。参见图:
gonum.org/v1/gonum/stat
提供了一些内置函数和类型,可以帮助你构建 ROC 曲线和 AUC 指标:
func ROC(n int, y []float64, classes []bool, weights []float64) (tpr, fpr []float64)
这里是一个使用 gonum 快速计算 ROC 曲线 AUC 的示例:
// Define our scores and classes.
scores := []float64{0.1, 0.35, 0.4, 0.8}
classes := []bool{true, false, true, false}
// Calculate the true positive rates (recalls) and
// false positive rates.
tpr, fpr := stat.ROC(0, scores, classes, nil)
// Compute the Area Under Curve.
auc := integrate.Trapezoidal(fpr, tpr)
// Output the results to standard out.
fmt.Printf("true positive rate: %v\n", tpr)
fmt.Printf("false positive rate: %v\n", fpr)
fmt.Printf("auc: %v\n", auc)
运行此代码将产生以下结果:
$ go build
$ ./myprogram
true positive rate: [0 0.5 0.5 1 1]
false positive rate: [0 0 0.5 0.5 1]
auc: 0.75
验证
现在,我们知道了一些衡量我们的模型表现如何的方法。实际上,如果我们想的话,我们可以创建一个非常复杂、精确的模型,可以无误差地预测每一个观测值。例如,我们可以创建一个模型,它会取观测值的行索引,并为每一行返回精确的答案。这可能是一个具有很多参数的非常大的函数,但它会返回正确的答案。
那么,这有什么问题呢?问题是,它不会泛化到新数据。我们复杂的模型在我们向其展示的数据上会预测得很好,但一旦我们尝试一些新的输入数据(这些数据不是我们的训练数据集的一部分),模型很可能会表现不佳。
我们把这种(不能泛化)的模型称为过拟合的模型。也就是说,我们基于我们所拥有的数据,使模型越来越复杂的过程,是对模型进行了过拟合。
过拟合可能在预测连续值或离散/分类值时发生:
为了防止过拟合,我们需要验证我们的模型。有多种方式进行验证,我们在这里将介绍其中的一些。
每次你将模型投入生产时,你需要确保你已经验证了你的模型,并了解它如何泛化到新数据。
训练集和测试集
防止过拟合的第一种方法是使用数据集的一部分来训练或拟合你的模型,然后在数据集的另一部分上测试或评估你的模型。训练模型通常包括参数化一个或多个组成你的模型的功能,使得这些功能可以预测你想要预测的内容。然后,你可以使用我们之前讨论的评估指标之一或多个来评估这个训练好的模型。这里重要的是,你不想在用于训练模型的数据上测试/评估你的模型。
通过保留部分数据用于测试,你是在模拟模型看到新数据的情况。也就是说,模型是基于未用于参数化模型的数据进行预测。
许多人开始时将 80%的数据分成训练数据集,20%分成测试集(80/20 的分割)。然而,你会看到不同的人以不同的比例分割他们的数据集。测试数据与训练数据的比例取决于你拥有的数据类型和数量以及你试图训练的模型。一般来说,你想要确保你的训练数据和测试数据都能相当准确地代表你在大规模上的数据。
例如,如果你试图预测几个不同类别中的一个,比如 A、B 和 C,你不想你的训练数据只包含与 A 和 B 相对应的观察结果。在这样一个数据集上训练的模型可能只能预测 A 和 B 类别。同样,你也不想你的测试集包含某些类别的子集,或者类别的加权比例是人为的。这很容易发生,具体取决于你的数据是如何生成的。
此外,你需要确保你有足够的训练数据,以减少在反复计算过程中确定的参数的变异性。如果你有太多的训练数据点,或者训练数据点采样不佳,你的模型训练可能会产生具有很多变异性的参数,甚至可能无法进行数值收敛。这些都是表明你的模型缺乏预测能力的迹象。
通常,随着你增加模型的复杂性,你将能够提高你用于训练数据的评估指标,但到了某个点,评估指标将开始对你的测试数据变差。当评估指标开始对测试数据变差时,你开始过度拟合你的模型。理想的情况是,你能够将模型复杂性增加到拐点,此时测试评估指标开始下降。另一种说法(这与本书中关于模型构建的一般哲学非常契合)是,我们希望得到最可解释的模型(或最简模型),它能产生有价值的结果。
快速将数据集分割成训练集和测试集的一种方法就是使用github.com/kniren/gota/dataframe
。让我们用一个包括大量匿名医疗患者信息和相应疾病进展及糖尿病指示的数据集来演示这一点:
age,sex,bmi,map,tc,ldl,hdl,tch,ltg,glu,y
0.0380759064334,0.0506801187398,0.0616962065187,0.021872354995,-0.0442234984244,-0.0348207628377,-0.043400845652,-0.00259226199818,0.0199084208763,-0.0176461251598,151.0
-0.00188201652779,-0.044641636507,-0.0514740612388,-0.0263278347174,-0.00844872411122,-0.0191633397482,0.0744115640788,-0.0394933828741,-0.0683297436244,-0.0922040496268,75.0
0.0852989062967,0.0506801187398,0.0444512133366,-0.00567061055493,-0.0455994512826,-0.0341944659141,-0.0323559322398,-0.00259226199818,0.00286377051894,-0.0259303389895,141.0
-0.0890629393523,-0.044641636507,-0.0115950145052,-0.0366564467986,0.0121905687618,0.0249905933641,-0.0360375700439,0.0343088588777,0.0226920225667,-0.00936191133014,206.0
0.00538306037425,-0.044641636507,-0.0363846922045,0.021872354995,0.00393485161259,0.0155961395104,0.00814208360519,-0.00259226199818,-0.0319914449414,-0.0466408735636,135.0
...
你可以在这里检索这个数据集:archive.ics.uci.edu/ml/datasets/diabetes
。
要使用github.com/kniren/gota/dataframe
来分割这些数据,我们可以这样做(我们将训练和测试分割保存到相应的 CSV 文件中):
// Open the diabetes dataset file.
f, err := os.Open("diabetes.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a dataframe from the CSV file.
// The types of the columns will be inferred.
diabetesDF := dataframe.ReadCSV(f)
// Calculate the number of elements in each set.
trainingNum := (4 * diabetesDF.Nrow()) / 5
testNum := diabetesDF.Nrow() / 5
if trainingNum+testNum < diabetesDF.Nrow() {
trainingNum++
}
// Create the subset indices.
trainingIdx := make([]int, trainingNum)
testIdx := make([]int, testNum)
// Enumerate the training indices.
for i := 0; i < trainingNum; i++ {
trainingIdx[i] = i
}
// Enumerate the test indices.
for i := 0; i < testNum; i++ {
testIdx[i] = trainingNum + i
}
// Create the subset dataframes.
trainingDF := diabetesDF.Subset(trainingIdx)
testDF := diabetesDF.Subset(testIdx)
// Create a map that will be used in writing the data
// to files.
setMap := map[int]dataframe.DataFrame{
0: trainingDF,
1: testDF,
}
// Create the respective files.
for idx, setName := range []string{"training.csv", "test.csv"} {
// Save the filtered dataset file.
f, err := os.Create(setName)
if err != nil {
log.Fatal(err)
}
// Create a buffered writer.
w := bufio.NewWriter(f)
// Write the dataframe out as a CSV.
if err := setMap[idx].WriteCSV(w); err != nil {
log.Fatal(err)
}
}
运行此操作会产生以下结果:
$ go build
$ ./myprogram
$ wc -l *.csv
443 diabetes.csv
89 test.csv
355 training.csv
887 total
保留集
我们正在努力确保我们的模型使用训练集和测试集进行泛化。然而,想象以下场景:
-
我们根据我们的训练集开发我们模型的第一版。
-
我们在测试集上测试我们模型的第一版。
-
我们对测试集上的结果不满意,所以我们会回到步骤 1 并重复。
这个过程可能看起来合乎逻辑,但你可能已经看到了由此程序可能产生的问题。实际上,我们可以通过迭代地将模型暴露于测试集来过度拟合我们的模型。
有几种方法可以处理这种额外的过拟合级别。第一种是简单地创建我们数据的另一个分割,称为保留集(也称为验证集)。因此,现在我们将有一个训练集、测试集和保留集。这有时被称为三数据集验证,原因很明显。
请记住,为了真正了解你模型的泛化性能,你的保留集绝不能用于训练和测试。你应该在你完成模型的训练、调整模型并得到测试数据集的可接受性能后,将此数据集保留用于验证。
你可能会想知道如何管理随时间推移的数据分割,并恢复用于训练或测试某些模型的不同的数据集。这种“数据来源”对于在机器学习工作流程中保持完整性至关重要。这正是 Pachyderm 的数据版本控制(在第一章“收集和组织数据”中介绍)被创建来处理的。我们将在第九章“部署和分发分析和模型”中看到这一过程如何在规模上展开。
交叉验证
除了为验证保留一个保留集之外,交叉验证是验证模型泛化能力的一种常见技术。在交叉验证中,或者说是 k 折交叉验证中,你实际上是将你的数据集随机分成不同的训练和测试组合的k次。将这些看作k次实验。
在完成每个分割后,你将在该分割的训练数据上训练你的模型,然后在该分割的测试数据上评估它。这个过程为你的数据每个随机分割产生一个评估指标结果。然后你可以对这些评估指标进行平均,得到一个整体评估指标,它比任何单个评估指标本身更能代表模型性能。你还可以查看评估指标的变化,以了解你各种实验的稳定性。这个过程在以下图像中得到了说明:
与数据集验证相比,使用交叉验证的一些优点如下:
-
你正在使用你的整个数据集,因此实际上是在让你的模型接触到更多的训练示例和更多的测试示例。
-
已经有一些方便的函数和打包用于交叉验证。
-
它有助于防止由于选择单个验证集而可能产生的偏差。
github.com/sjwhitworth/golearn
是一个 Go 包,它提供了一些交叉验证的方便函数。实际上,github.com/sjwhitworth/golearn
包含了一系列我们将在本书后面部分介绍的机器学习功能,但就目前而言,让我们看看交叉验证可用哪些功能。
如果你查看 github.com/sjwhitworth/golearn/evaluation
包的 Godocs,你会看到以下可用于交叉验证的函数:
func GenerateCrossFoldValidationConfusionMatrices(data base.FixedDataGrid, cls base.Classifier, folds int) ([]ConfusionMatrix, error)
这个函数实际上可以与各种模型一起使用,但这里有一个使用决策树模型的例子(这里不需要担心模型的细节):
// Define the decision tree model.
tree := trees.NewID3DecisionTree(param)
// Perform the cross validation.
cfs, err := evaluation.GenerateCrossFoldValidationConfusionMatrices(myData, tree, 5)
if err != nil {
panic(err)
}
// Calculate the metrics.
mean, variance := evaluation.GetCrossValidatedMetric(cfs, evaluation.GetAccuracy)
stdev := math.Sqrt(variance)
// Output the results to standard out.
fmt.Printf("%0.2f\t\t%.2f (+/- %.2f)\n", param, mean, stdev*2)
参考文献
评估:
-
分类别评估指标的比较:
en.wikipedia.org/wiki/Precision_and_recall
-
gonum.org/v1/gonum/stat
文档:godoc.org/gonum.org/v1/gonum/stat
-
github.com/sjwhitworth/golearn/evaluation
文档:godoc.org/github.com/sjwhitworth/golearn/evaluation
验证:
-
github.com/kniren/gota/dataframe
文档:godoc.org/github.com/kniren/gota/dataframe
-
github.com/sjwhitworth/golearn/evaluation
文档:godoc.org/github.com/sjwhitworth/golearn/evaluation
摘要
选择合适的评估指标并制定评估/验证流程是任何机器学习项目的关键部分。你已经了解了各种相关的评估指标以及如何使用保留集和/或交叉验证来避免过拟合。在下一章中,我们将开始探讨机器学习模型,并使用线性回归来构建我们的第一个模型!
第四章:回归
我们将要探索的第一组机器学习技术通常被称为回归。回归是一个过程,通过它可以理解一个变量(例如,销售额)相对于另一个变量(例如,用户数量)是如何变化的。这些技术本身很有用。然而,它们也是讨论机器学习技术的良好起点,因为它们构成了我们将在本书后面讨论的更复杂技术的基础。
通常,机器学习中的回归技术关注预测连续值(例如,股价、温度或疾病进展)。下一章我们将讨论的分类关注预测离散变量,或一组离散类别中的一个(例如,欺诈/非欺诈,坐着/站着/跑步,或热狗/非热狗)。如前所述,回归技术作为分类算法的一部分在机器学习中使用,但本章我们将专注于它们的基本应用,以预测连续值。
理解回归模型术语
如前所述,回归本身是一个分析一个变量与另一个变量之间关系的过程,但在机器学习中,有一些术语用来描述这些变量,以及与回归相关的各种类型和过程:
-
响应变量或因变量:这些术语将交替使用,指基于一个或多个其他变量试图预测的变量。这个变量通常被标记为 y。
-
解释变量、自变量、特征、属性或回归变量:这些术语将交替使用,指我们用来预测响应的变量。这些变量通常被标记为 x 或 x[1], x[2], 等等。
-
线性回归:这种回归假设因变量线性地依赖于自变量(即,遵循直线的方程)。
-
非线性回归:这种回归假设因变量依赖于自变量的关系不是线性的(例如,多项式或指数)。
-
多元回归:包含多个自变量的回归。
-
拟合或训练:参数化模型(如回归模型)的过程,以便它可以预测某个因变量。
-
预测:使用参数化模型(如回归模型)来预测某个因变量的过程。
一些这些术语将在回归的上下文中使用,并在本书其余部分的其他上下文中使用。
线性回归
线性回归是最简单的机器学习模型之一。然而,你绝对不应该忽视这个模型。如前所述,它是其他模型中使用的必要构建块,并且它有一些非常重要的优点。
正如本书中讨论的那样,在机器学习应用中的完整性至关重要,模型越简单、可解释性越强,就越容易保持完整性。此外,由于模型简单且可解释,它允许你理解变量之间的推断关系,并在开发过程中通过心理检查你的工作。用 Fast Forward Labs 的 Mike Lee Williams 的话说(参见 blog.fastforwardlabs.com/2017/08/02/interpretability.html
):
未来是算法化的。可解释的模型为人类和智能机器之间提供了更安全、更富有成效、最终更协作的关系。
线性回归模型是可解释的,因此,它们可以为数据科学家提供一个安全且富有成效的选项。当你正在寻找一个模型来预测一个连续变量时,你应该考虑并尝试线性回归(甚至多重线性回归),如果你的数据和问题允许你使用它。
线性回归概述
在线性回归中,我们试图通过一个独立变量 x 来建模我们的因变量 y,使用线的方程:
在这里,m 是直线的斜率,b 是截距。例如,假设我们想要通过我们网站上每天的用户数量来模拟每天的 销售。为了使用线性回归来完成这项工作,我们需要确定一个 m 和 b,这样我们就可以通过以下公式预测销售:
因此,我们的训练模型实际上就是这个参数化函数。我们输入一个 用户数量,然后得到预测的 销售,如下所示:
线性回归模型的训练或拟合涉及确定 m 和 b 的值,使得得到的公式对我们的响应具有预测能力。有各种方法可以确定 m 和 b,但最常见和简单的方法被称为 普通最小二乘法(OLS)。
要使用 OLS 找到 m 和 b,我们首先为 m 和 b 选择一个值来创建第一条示例线。然后,我们测量每个已知点(例如,来自我们的训练集)与示例线之间的垂直距离。这些距离被称为 误差 或 残差,类似于我们在第三章 评估和验证 中讨论的误差,并在以下图中展示:
接下来,我们计算这些误差的平方和:
我们调整m和b,直到我们最小化这个误差平方和。换句话说,我们的训练线性回归线是使这个误差平方和最小的线。
有许多方法可以找到最小化平方误差和的线,对于 OLS 来说,线可以通过解析方法找到。然而,一个非常流行且通用的优化技术,用于最小化平方误差和,被称为梯度下降。这种方法在实现方面可能更高效,在计算上(例如,在内存方面)具有优势,并且比解析解更灵活。
梯度下降在附录“与机器学习相关的算法/技术”中有更详细的讨论,因此我们在这里将避免进行冗长的讨论。简单来说,许多线性回归和其他回归的实现都利用梯度下降来进行线性回归线的拟合或训练。实际上,梯度下降在机器学习中无处不在,并且也推动了更复杂的建模技术,如深度学习。
线性回归的假设和陷阱
就像所有机器学习模型一样,线性回归并不适用于所有情况,并且它确实对你的数据和数据中的关系做出了一些假设。线性回归的假设如下:
-
线性关系:这看起来可能很明显,但线性回归假设你的因变量线性地依赖于你的自变量(通过线的方程)。如果这种关系不是线性的,线性回归可能表现不佳。
-
正态性:这个假设意味着你的变量应该按照正态分布(看起来像钟形)分布。我们将在本章后面回到这个属性,并讨论在遇到非正态分布变量时的权衡和选项。
-
无多重共线性:多重共线性是一个术语,意味着自变量实际上并不是独立的。它们以某种方式相互依赖。
-
无自相关性:自相关性是另一个术语,意味着一个变量依赖于它自己或其某种位移版本(如在某些可预测的时间序列中)。
-
同方差性:这可能是这一系列术语中最复杂的,但它意味着相对简单的事情,并且实际上你并不需要经常担心。线性回归假设你的数据在独立变量的所有值周围围绕回归线具有大致相同的方差。
技术上,为了使用线性回归,所有这些假设都需要得到满足。了解我们的数据是如何分布的以及它的行为方式非常重要。当我们在一个线性回归的示例中分析数据时,我们将探讨这些假设。
作为数据科学家或分析师,在应用线性回归时,以下陷阱您需要牢记在心:
-
您正在为独立变量的某个范围训练线性回归模型。对于这个范围之外的数据值进行预测时,您应该小心,因为您的回归线可能不适用(例如,您的因变量可能在极端值处开始表现出非线性行为)。
-
您可能会通过发现两个实际上毫无关联的变量之间的虚假关系来错误地指定线性回归模型。您应该检查以确保变量之间可能存在某种逻辑上的功能关系。
-
您数据中的异常值或极端值可能会影响某些类型的拟合的回归线,例如最小二乘法。有一些方法可以拟合对异常值更免疫的回归线,或者对异常值有不同的行为,例如正交最小二乘法或岭回归。
线性回归示例
为了说明线性回归,让我们举一个例子问题并创建我们的第一个机器学习模型!我们将使用的是示例广告数据。它以.csv
格式存储,如下所示:
$ head Advertising.csv
TV,Radio,Newspaper,Sales
230.1,37.8,69.2,22.1
44.5,39.3,45.1,10.4
17.2,45.9,69.3,9.3
151.5,41.3,58.5,18.5
180.8,10.8,58.4,12.9
8.7,48.9,75,7.2
57.5,32.8,23.5,11.8
120.2,19.6,11.6,13.2
8.6,2.1,1,4.8
该数据集包括一组代表广告渠道支出(电视
、广播
和报纸
)的属性,以及相应的销售额(销售额
)。在这个例子中,我们的目标将是通过广告支出的一个属性(我们的独立变量)来建模销售额(我们的因变量)。
数据概览
为了确保我们创建的模型或至少是处理过程是我们所理解的,并且为了确保我们可以心理上检查我们的结果,我们需要从数据概览开始每一个机器学习模型构建过程。我们需要了解每个变量是如何分布的,以及它们的范围和变异性。
要做到这一点,我们将计算我们在第二章,“矩阵、概率和统计学”中讨论过的汇总统计信息。在这里,我们将利用github.com/kniren/gota/dataframe
包内置的方法,一次性计算我们数据集所有列的汇总统计信息:
// Open the CSV file.
advertFile, err := os.Open("Advertising.csv")
if err != nil {
log.Fatal(err)
}
defer advertFile.Close()
// Create a dataframe from the CSV file.
advertDF := dataframe.ReadCSV(advertFile)
// Use the Describe method to calculate summary statistics
// for all of the columns in one shot.
advertSummary := advertDF.Describe()
// Output the summary statistics to stdout.
fmt.Println(advertSummary)
编译并运行此代码将得到以下结果:
$ go build
$ ./myprogram
[7x5] DataFrame
column TV Radio Newspaper Sales
0: mean 147.042500 23.264000 30.554000 14.022500
1: stddev 85.854236 14.846809 21.778621 5.217457
2: min 0.700000 0.000000 0.300000 1.600000
3: 25% 73.400000 9.900000 12.600000 10.300000
4: 50% 149.700000 22.500000 25.600000 12.900000
5: 75% 218.500000 36.500000 45.100000 17.400000
6: max 296.400000 49.600000 114.000000 27.000000
<string> <float> <float> <float> <float>
正如您所看到的,这以漂亮的表格形式打印出我们所有的汇总统计信息,包括平均值、标准差、最小值、最大值、25%/75% 分位数和中间值(或 50% 分位数)。
这些值为我们提供了在训练线性回归模型时将看到的数字的良好数值参考。然而,这并没有给我们一个很好的数据视觉理解。为此,我们将为每个列中的值创建直方图:
// Open the advertising dataset file.
f, err := os.Open("Advertising.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a dataframe from the CSV file.
advertDF := dataframe.ReadCSV(f)
// Create a histogram for each of the columns in the dataset.
for _, colName := range advertDF.Names() {
// Create a plotter.Values value and fill it with the
// values from the respective column of the dataframe.
plotVals := make(plotter.Values, advertDF.Nrow())
for i, floatVal := range advertDF.Col(colName).Float() {
plotVals[i] = floatVal
}
// Make a plot and set its title.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.Title.Text = fmt.Sprintf("Histogram of a %s", colName)
// Create a histogram of our values drawn
// from the standard normal.
h, err := plotter.NewHist(plotVals, 16)
if err != nil {
log.Fatal(err)
}
// Normalize the histogram.
h.Normalize(1)
// Add the histogram to the plot.
p.Add(h)
// Save the plot to a PNG file.
if err := p.Save(4*vg.Inch, 4*vg.Inch, colName+"_hist.png"); err != nil {
log.Fatal(err)
}
}
此程序将为每个直方图创建一个.png
图像:
现在,查看这些直方图和我们计算出的汇总统计量,我们需要考虑我们是否在符合线性回归的假设下工作。特别是,我们可以看到,并不是我们所有的变量都是正态分布的(也就是说,它们呈钟形)。销售额可能有些钟形,但其他变量看起来并不正常。
我们可以使用统计工具,如分位数-分位数(q-q)图,来确定分布与正态分布的接近程度,我们甚至可以进行统计测试,以确定变量遵循正态分布的概率。然而,大多数时候,我们可以从直方图中得到一个大致的概念。
现在我们必须做出决定。至少我们的一些数据在技术上并不符合我们的线性回归模型的假设。我们现在可以采取以下行动之一:
-
尝试转换我们的变量(例如,使用幂转换),使其遵循正态分布,然后使用这些转换后的变量在我们的线性回归模型中。这种选项的优势是我们将在模型的假设下操作。缺点是这将使我们的模型更难以理解,并且可解释性更差。
-
获取不同的数据来解决我们的问题。
-
忽略我们与线性回归假设的问题,并尝试创建模型。
可能还有其他的观点,但我的建议是首先尝试第三个选项。这个选项没有太大的坏处,因为你可以快速训练线性回归模型。如果你最终得到一个表现良好的模型,你就避免了进一步的复杂化,并且得到了一个简单明了的模型。如果你最终得到一个表现不佳的模型,你可能需要求助于其他选项之一。
选择我们的独立变量
因此,现在我们对我们的数据有一些直观的认识,并且已经接受了我们的数据如何符合线性回归模型的假设。现在,我们如何在尝试预测我们的因变量,即每场比赛的平均得分时,选择哪个变量作为我们的独立变量呢?
做出这个决定的最简单方法是通过直观地探索因变量与所有独立变量选择之间的相关性。特别是,你可以绘制出因变量与每个其他变量的散点图(使用gonum.org/v1/plot
):
// Open the advertising dataset file.
f, err := os.Open("Advertising.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a dataframe from the CSV file.
advertDF := dataframe.ReadCSV(f)
// Extract the target column.
yVals := advertDF.Col("Sales").Float()
// Create a scatter plot for each of the features in the dataset.
for _, colName := range advertDF.Names() {
// pts will hold the values for plotting
pts := make(plotter.XYs, advertDF.Nrow())
// Fill pts with data.
for i, floatVal := range advertDF.Col(colName).Float() {
pts[i].X = floatVal
pts[i].Y = yVals[i]
}
// Create the plot.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.X.Label.Text = colName
p.Y.Label.Text = "y"
p.Add(plotter.NewGrid())
s, err := plotter.NewScatter(pts)
if err != nil {
log.Fatal(err)
}
s.GlyphStyle.Radius = vg.Points(3)
// Save the plot to a PNG file.
p.Add(s)
if err := p.Save(4*vg.Inch, 4*vg.Inch, colName+"_scatter.png"); err != nil {
log.Fatal(err)
}
}
这将创建以下散点图:
当我们查看这些散点图时,我们想要推断出哪些属性(电视、广播和/或报纸)与我们的因变量销售额之间存在线性关系。也就是说,我们能否在这些散点图中的任何一个上画一条线,这条线能符合销售额与相应属性的趋势?这并不总是可能的,而且对于给定问题中你必须处理的某些属性来说,可能根本不可能。
在这种情况下,Radio 和 TV 似乎与 Sales 有一定的线性相关性。Newspaper 可能与 Sales 有轻微的相关性,但相关性并不明显。与 TV 的线性关系似乎最为明显,所以让我们以 TV 作为线性回归模型中的自变量开始。这将使我们的线性回归公式如下:
这里还有一个需要注意的事项,即变量 TV 可能并不严格同方差,这之前作为线性回归的假设被讨论过。这一点值得注意(并且可能值得在项目中记录下来),但我们将继续看看我们是否可以创建具有一些预测能力的线性回归模型。如果我们的模型表现不佳,我们可以随时回顾这个假设,作为可能的解释。
创建我们的训练集和测试集
为了避免过拟合并确保我们的模型可以泛化,我们将按照第三章评估和验证中讨论的方法,将数据集分成训练集和测试集。在这里,我们不会使用保留集,因为我们只将进行一次模型训练,而不在训练和测试之间进行迭代往返。然而,如果你正在尝试不同的因变量,或者迭代调整模型参数,你将想要创建一个保留集,直到模型开发过程的最后阶段用于验证。
我们将使用 github.com/kniren/gota/dataframe
来创建我们的训练集和测试集,并将它们保存到相应的 .csv
文件中。在这种情况下,我们
将使用 80/20 的比例来分割我们的训练集和测试集:
// Open the advertising dataset file.
f, err := os.Open("Advertising.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a dataframe from the CSV file.
// The types of the columns will be inferred.
advertDF := dataframe.ReadCSV(f)
// Calculate the number of elements in each set.
trainingNum := (4 * advertDF.Nrow()) / 5
testNum := advertDF.Nrow() / 5
if trainingNum+testNum < advertDF.Nrow() {
trainingNum++
}
// Create the subset indices.
trainingIdx := make([]int, trainingNum)
testIdx := make([]int, testNum)
// Enumerate the training indices.
for i := 0; i < trainingNum; i++ {
trainingIdx[i] = i
}
// Enumerate the test indices.
for i := 0; i < testNum; i++ {
testIdx[i] = trainingNum + i
}
// Create the subset dataframes.
trainingDF := advertDF.Subset(trainingIdx)
testDF := advertDF.Subset(testIdx)
// Create a map that will be used in writing the data
// to files.
setMap := map[int]dataframe.DataFrame{
0: trainingDF,
1: testDF,
}
// Create the respective files.
for idx, setName := range []string{"training.csv", "test.csv"} {
// Save the filtered dataset file.
f, err := os.Create(setName)
if err != nil {
log.Fatal(err)
}
// Create a buffered writer.
w := bufio.NewWriter(f)
// Write the dataframe out as a CSV.
if err := setMap[idx].WriteCSV(w); err != nil {
log.Fatal(err)
}
}
此代码将输出以下我们将使用的训练集和测试集:
$ wc -l *.csv
201 Advertising.csv
41 test.csv
161 training.csv
403 total
我们在这里使用的数据并没有按照任何方式排序或排序。然而,如果你正在处理按响应、日期或其他方式排序的数据,那么将你的数据随机分成训练集和测试集是很重要的。如果你不这样做,你的训练集和测试集可能只包括响应的某些范围,可能受到时间/日期的人工影响,等等。
训练我们的模型
接下来,我们将实际训练或拟合我们的线性回归模型。如果你还记得,这意味着我们正在寻找最小化平方误差和的线的斜率(m)和截距(b)。为了进行这项训练,我们将使用来自 Sajari 的一个非常好的包:github.com/sajari/regression
。Sajari 是一家依赖 Go 语言和机器学习的搜索引擎公司,他们在生产中使用 github.com/sajari/regression。
要使用github.com/sajari/regression训练回归模型,我们需要初始化一个regression.Regression
值,设置几个标签,并将regression.Regression
值填充有标签的训练数据点。之后,训练我们的线性回归模型就像在regression.Regression
值上调用Run()
方法一样简单:
// Open the training dataset file.
f, err := os.Open("training.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
// Read in all of the CSV records
reader.FieldsPerRecord = 4
trainingData, err := reader.ReadAll()
if err != nil {
log.Fatal(err)
}
// In this case we are going to try and model our Sales (y)
// by the TV feature plus an intercept. As such, let's create
// the struct needed to train a model using github.com/sajari/regression.
var r regression.Regression
r.SetObserved("Sales")
r.SetVar(0, "TV")
// Loop of records in the CSV, adding the training data to the regression value.
for i, record := range trainingData {
// Skip the header.
if i == 0 {
continue
}
// Parse the Sales regression measure, or "y".
yVal, err := strconv.ParseFloat(record[3], 64)
if err != nil {
log.Fatal(err)
}
// Parse the TV value.
tvVal, err := strconv.ParseFloat(record[0], 64)
if err != nil {
log.Fatal(err)
}
// Add these points to the regression value.
r.Train(regression.DataPoint(yVal, []float64{tvVal}))
}
// Train/fit the regression model.
r.Run()
// Output the trained model parameters.
fmt.Printf("\nRegression Formula:\n%v\n\n", r.Formula)
编译并运行这将导致训练好的线性回归公式被打印到stdout
:
$ go build
$ ./myprogram
Regression Formula:
Predicted = 7.07 + TV*0.05
在这里,我们可以看到该软件包确定了具有截距7.07
和斜率0.5
的线性回归线。在这里我们可以进行一点心理检查,因为我们已经在散点图中看到了TV和Sales之间的相关性向上向右(即正相关)。这意味着公式中的斜率应该是正的,它确实是。
评估训练好的模型
现在,我们需要衡量我们模型的性能,看看我们是否真的有使用TV作为自变量的能力来预测Sales。为此,我们可以加载我们的测试集,使用我们的训练模型对每个测试示例进行预测,然后计算第三章中讨论的评估指标之一,即评估和验证。
对于这个问题,让我们使用平均绝对误差(MAE)作为我们的评估指标。这似乎是合理的,因为它产生的东西可以直接与我们的Sales
值进行比较,我们也不必过于担心异常值或极端值。
要使用我们的训练好的regression.Regression
值计算预测的Sales值,我们只需要解析测试集中的值,并在regression.Regression
值上调用Predict()
方法。然后我们将这些预测值与观察值之间的差异相减,得到差异的绝对值,然后将所有绝对值相加以获得 MAE:
// Open the test dataset file.
f, err = os.Open("test.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a CSV reader reading from the opened file.
reader = csv.NewReader(f)
// Read in all of the CSV records
reader.FieldsPerRecord = 4
testData, err := reader.ReadAll()
if err != nil {
log.Fatal(err)
}
// Loop over the test data predicting y and evaluating the prediction
// with the mean absolute error.
var mAE float64
for i, record := range testData {
// Skip the header.
if i == 0 {
continue
}
// Parse the observed Sales, or "y".
yObserved, err := strconv.ParseFloat(record[3], 64)
if err != nil {
log.Fatal(err)
}
// Parse the TV value.
tvVal, err := strconv.ParseFloat(record[0], 64)
if err != nil {
log.Fatal(err)
}
// Predict y with our trained model.
yPredicted, err := r.Predict([]float64{tvVal})
// Add the to the mean absolute error.
mAE += math.Abs(yObserved-yPredicted) / float64(len(testData))
}
// Output the MAE to standard out.
fmt.Printf("MAE = %0.2f\n\n", mAE)
编译并运行此评估给出以下结果:
$ go build
$ ./myprogram
Regression Formula:
Predicted = 7.07 + TV*0.05
MAE = 3.01
我们如何知道MAE = 3.01
是好是坏?这又是为什么有一个良好的数据心理模型很重要的原因。如果你记得,我们已经计算了销售额的平均值、范围和标准差。平均销售额为14.02
,标准差为5.21
。因此,我们的 MAE 小于我们的销售额标准差,并且大约是平均值的 20%,我们的模型具有一定的预测能力。
因此,恭喜!我们已经构建了我们第一个具有预测能力的机器学习模型!
为了更好地了解我们的模型表现如何,我们还可以创建一个图表来帮助我们可视化线性回归线。这可以通过gonum.org/v1/plot
来完成。首先,然而,让我们创建一个预测函数,允许我们做出预测而不需要导入github.com/sajari/regression
。这给我们提供了一个轻量级、内存中的训练模型版本:
// predict uses our trained regression model to made a prediction.
func predict(tv float64) float64 {
return 7.07 + tv*0.05
}
然后,我们可以创建回归线的可视化:
// Open the advertising dataset file.
f, err := os.Open("Advertising.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a dataframe from the CSV file.
advertDF := dataframe.ReadCSV(f)
// Extract the target column.
yVals := advertDF.Col("Sales").Float()
// pts will hold the values for plotting.
pts := make(plotter.XYs, advertDF.Nrow())
// ptsPred will hold the predicted values for plotting.
ptsPred := make(plotter.XYs, advertDF.Nrow())
// Fill pts with data.
for i, floatVal := range advertDF.Col("TV").Float() {
pts[i].X = floatVal
pts[i].Y = yVals[i]
ptsPred[i].X = floatVal
ptsPred[i].Y = predict(floatVal)
}
// Create the plot.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.X.Label.Text = "TV"
p.Y.Label.Text = "Sales"
p.Add(plotter.NewGrid())
// Add the scatter plot points for the observations.
s, err := plotter.NewScatter(pts)
if err != nil {
log.Fatal(err)
}
s.GlyphStyle.Radius = vg.Points(3)
// Add the line plot points for the predictions.
l, err := plotter.NewLine(ptsPred)
if err != nil {
log.Fatal(err)
}
l.LineStyle.Width = vg.Points(1)
l.LineStyle.Dashes = []vg.Length{vg.Points(5), vg.Points(5)}
// Save the plot to a PNG file.
p.Add(s, l)
if err := p.Save(4*vg.Inch, 4*vg.Inch, "regression_line.png"); err != nil {
log.Fatal(err)
}
编译并运行时将产生以下图表:
如您所见,我们训练的线性回归线遵循实际数据点的线性趋势。这是另一个视觉上的确认,表明我们正在正确的道路上!
多元线性回归
线性回归不仅限于只依赖于一个自变量的简单线性公式。多元线性回归与我们之前讨论的类似,但在这里我们有多个自变量(x[1]、x[2]等等)。在这种情况下,我们的简单线性方程如下:
在这里,x
代表各种自变量,m
代表与这些自变量相关的各种斜率。我们仍然有一个截距,b
。
多元线性回归在可视化和思考上稍微有点困难,因为这里不再是一条可以在二维中可视化的线。它是一个二维、三维或更多维度的线性表面。然而,我们用于单变量线性回归的许多相同技术仍然适用。
多元线性回归与普通线性回归有相同的假设。然而,还有一些陷阱我们应该牢记:
-
过拟合:通过向我们的模型添加越来越多的自变量,我们增加了模型复杂性,这使我们面临过拟合的风险。处理这个问题的技术之一,我建议您了解一下,被称为正则化。正则化在您的模型中创建一个惩罚项,它是模型复杂度的函数,有助于控制这种影响。
-
相对尺度:在某些情况下,您的自变量中的一个将比另一个自变量大几个数量级。较大的那个可能会抵消较小的那个的影响,您可能需要考虑对变量进行归一化。
考虑到这一点,让我们尝试将我们的销售模型从线性回归模型扩展到多元回归模型。回顾上一节中的散点图,我们可以看到Radio似乎也与销售线性相关,所以让我们尝试创建一个类似以下的多元线性回归模型:
要使用gihub.com/sajari/regression做这个,我们只需要在regression.Regression
值中标记另一个变量,并确保这些值在训练数据点中得到配对。然后我们将运行回归,看看公式如何得出:
// Open the training dataset file.
f, err := os.Open("training.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
// Read in all of the CSV records
reader.FieldsPerRecord = 4
trainingData, err := reader.ReadAll()
if err != nil {
log.Fatal(err)
}
// In this case we are going to try and model our Sales
// by the TV and Radio features plus an intercept.
var r regression.Regression
r.SetObserved("Sales")
r.SetVar(0, "TV")
r.SetVar(1, "Radio")
// Loop over the CSV records adding the training data.
for i, record := range trainingData {
// Skip the header.
if i == 0 {
continue
}
// Parse the Sales.
yVal, err := strconv.ParseFloat(record[3], 64)
if err != nil {
log.Fatal(err)
}
// Parse the TV value.
tvVal, err := strconv.ParseFloat(record[0], 64)
if err != nil {
log.Fatal(err)
}
// Parse the Radio value.
radioVal, err := strconv.ParseFloat(record[1], 64)
if err != nil {
log.Fatal(err)
}
// Add these points to the regression value.
r.Train(regression.DataPoint(yVal, []float64{tvVal, radioVal}))
}
// Train/fit the regression model.
r.Run()
// Output the trained model parameters.
fmt.Printf("\nRegression Formula:\n%v\n\n", r.Formula)
编译并运行后,我们得到以下回归公式:
$ go build
$ ./myprogram
Regression Formula:
Predicted = 2.93 + TV*0.05 + Radio*0.18
如您所见,回归公式现在包括一个额外的Radio
自变量项。截距值也与我们之前的单变量回归模型不同了。
我们可以使用Predict
方法类似地测试这个模型:
// Open the test dataset file.
f, err = os.Open("test.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a CSV reader reading from the opened file.
reader = csv.NewReader(f)
// Read in all of the CSV records
reader.FieldsPerRecord = 4
testData, err := reader.ReadAll()
if err != nil {
log.Fatal(err)
}
// Loop over the test data predicting y and evaluating the prediction
// with the mean absolute error.
var mAE float64
for i, record := range testData {
// Skip the header.
if i == 0 {
continue
}
// Parse the Sales.
yObserved, err := strconv.ParseFloat(record[3], 64)
if err != nil {
log.Fatal(err)
}
// Parse the TV value.
tvVal, err := strconv.ParseFloat(record[0], 64)
if err != nil {
log.Fatal(err)
}
// Parse the Radio value.
radioVal, err := strconv.ParseFloat(record[1], 64)
if err != nil {
log.Fatal(err)
}
// Predict y with our trained model.
yPredicted, err := r.Predict([]float64{tvVal, radioVal})
// Add the to the mean absolute error.
mAE += math.Abs(yObserved-yPredicted) / float64(len(testData))
}
// Output the MAE to standard out.
fmt.Printf("MAE = %0.2f\n\n", mAE)
运行此命令会显示我们新的多重回归模型的以下MAE
:
$ go build
$ ./myprogram
Regression Formula:
Predicted = 2.93 + TV*0.05 + Radio*0.18
MAE = 1.26
我们的新多重回归模型已经提高了我们的 MAE!现在我们肯定在预测基于我们的广告支出的Sales
方面处于非常好的状态。你也可以尝试将Newspaper
添加到模型中作为后续练习,看看模型性能是如何受到影响的。
记住,当你给模型增加更多复杂性时,你正在牺牲简单性,你可能会陷入过拟合的危险,因此只有当模型性能的提升实际上为你的用例创造更多价值时,你才应该增加更多的复杂性。
非线性和其他类型的回归
尽管我们在这章中专注于线性回归,但你当然不仅限于使用线性公式进行回归。你可以通过在你的自变量上使用一个或多个非线性项(如幂、指数或其他变换)来建模因变量。例如,我们可以通过TV
项的多项式级数来建模Sales:
然而,记住,当你增加这种复杂性时,你再次使自己处于过拟合的危险之中。
在实现非线性回归方面,你不能使用github.com/sajari/regression
,因为它仅限于线性回归。然而,go-hep.org/x/hep/fit
允许你拟合或训练某些非线性模型,Go 社区的其他各种人也在开发其他非线性建模工具。
此外,还有其他线性回归技术,除了 OLS 之外,可以帮助克服与最小二乘线性回归相关的一些假设和弱点。这些包括岭回归和Lasso 回归。这两种技术都惩罚回归系数,以减轻多重共线性和非正态独立变量的影响。
在 Go 实现方面,岭回归在github.com/berkmancenter/ridge
中实现。与github.com/sajari/regression
不同,我们的自变量和因变量数据通过 gonum 矩阵输入到github.com/berkmancenter/ridge
。因此,为了说明这种方法,我们首先形成一个包含我们的广告支出特征(TV
、Radio
和Newspaper
)的矩阵,以及一个包含我们的Sales
数据的矩阵。请注意,在github.com/berkmancenter/ridge
中,如果我们想在模型中包含截距项,我们需要明确在我们的输入自变量矩阵中添加一列。这一列中的每个值都是1.0
。
// Open the training dataset file.
f, err := os.Open("training.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
reader.FieldsPerRecord = 4
// Read in all of the CSV records
rawCSVData, err := reader.ReadAll()
if err != nil {
log.Fatal(err)
}
// featureData will hold all the float values that will eventually be
// used to form our matrix of features.
featureData := make([]float64, 4*len(rawCSVData))
yData := make([]float64, len(rawCSVData))
// featureIndex and yIndex will track the current index of the matrix values.
var featureIndex int
var yIndex int
// Sequentially move the rows into a slice of floats.
for idx, record := range rawCSVData {
// Skip the header row.
if idx == 0 {
continue
}
// Loop over the float columns.
for i, val := range record {
// Convert the value to a float.
valParsed, err := strconv.ParseFloat(val, 64)
if err != nil {
log.Fatal(err)
}
if i < 3 {
// Add an intercept to the model.
if i == 0 {
featureData[featureIndex] = 1
featureIndex++
}
// Add the float value to the slice of feature floats.
featureData[featureIndex] = valParsed
featureIndex++
}
if i == 3 {
// Add the float value to the slice of y floats.
yData[yIndex] = valParsed
yIndex++
}
}
}
// Form the matrices that will be input to our regression.
features := mat64.NewDense(len(rawCSVData), 4, featureData)
y := mat64.NewVector(len(rawCSVData), yData)
接下来,我们使用我们的自变量和因变量矩阵创建一个新的ridge.RidgeRegression
值,并调用Regress()
方法来训练我们的模型。然后我们可以打印出我们的训练回归公式:
// Create a new RidgeRegression value, where 1.0 is the
// penalty value.
r := ridge.New(features, y, 1.0)
// Train our regression model.
r.Regress()
// Print our regression formula.
c1 := r.Coefficients.At(0, 0)
c2 := r.Coefficients.At(1, 0)
c3 := r.Coefficients.At(2, 0)
c4 := r.Coefficients.At(3, 0)
fmt.Printf("\nRegression formula:\n")
fmt.Printf("y = %0.3f + %0.3f TV + %0.3f Radio + %0.3f Newspaper\n\n", c1, c2, c3, c4)
编译此程序并运行会得到以下回归公式:
$ go build
$ ./myprogram
Regression formula:
y = 3.038 + 0.047 TV + 0.177 Radio + 0.001 Newspaper
在这里,你可以看到TV
和Radio
的系数与我们在最小二乘回归中得到的结果相似,但略有不同。此外,请注意,我们添加了一个关于Newspaper
特征的项。
我们可以通过创建自己的predict
函数来测试这个岭回归公式:
// predict uses our trained regression model to made a prediction based on a
// TV, Radio, and Newspaper value.
func predict(tv, radio, newspaper float64) float64 {
return 3.038 + tv*0.047 + 0.177*radio + 0.001*newspaper
}
然后,我们使用这个predict
函数来测试我们的岭回归公式在测试示例上的效果:
// Open the test dataset file.
f, err := os.Open("test.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
// Read in all of the CSV records
reader.FieldsPerRecord = 4
testData, err := reader.ReadAll()
if err != nil {
log.Fatal(err)
}
// Loop over the holdout data predicting y and evaluating the prediction
// with the mean absolute error.
var mAE float64
for i, record := range testData {
// Skip the header.
if i == 0 {
continue
}
// Parse the Sales.
yObserved, err := strconv.ParseFloat(record[3], 64)
if err != nil {
log.Fatal(err)
}
// Parse the TV value.
tvVal, err := strconv.ParseFloat(record[0], 64)
if err != nil {
log.Fatal(err)
}
// Parse the Radio value.
radioVal, err := strconv.ParseFloat(record[1], 64)
if err != nil {
log.Fatal(err)
}
// Parse the Newspaper value.
newspaperVal, err := strconv.ParseFloat(record[2], 64)
if err != nil {
log.Fatal(err)
}
// Predict y with our trained model.
yPredicted := predict(tvVal, radioVal, newspaperVal)
// Add the to the mean absolute error.
mAE += math.Abs(yObserved-yPredicted) / float64(len(testData))
}
// Output the MAE to standard out.
fmt.Printf("\nMAE = %0.2f\n\n", mAE)
编译并运行此代码后,我们得到以下新的MAE
:
$ go build
$ ./myprogram
MAE = 1.26
注意,将Newspaper
添加到模型实际上并没有改善我们的MAE
。因此,在这种情况下,这不是一个好主意,因为它增加了额外的复杂性,并没有在我们的模型性能上带来任何显著的变化。
你添加到模型中的任何复杂或高级功能都应该伴随着对这种增加复杂性的可测量理由。仅仅因为一个模型在智力上有趣而使用一个复杂模型,这可能会导致头疼。
参考文献
线性回归:
-
普通最小二乘回归的直观解释:
setosa.io/ev/ordinary-least-squares-regression/
-
github.com/sajari/regression
文档:godoc.org/github.com/sajari/regression
多元回归:
非线性和其他回归:
-
go-hep.org/x/hep/fit
文档:godoc.org/go-hep.org/x/hep/fit
-
github.com/berkmancenter/ridge
文档:godoc.org/github.com/berkmancenter/ridge
摘要
恭喜!你已经正式使用 Go 语言完成了机器学习。特别是,你学习了关于回归模型的知识,包括线性回归、多元回归、非线性回归和岭回归。你应该能够在 Go 语言中实现基本的线性回归和多元回归。
现在我们已经对机器学习有了初步的了解,我们将进入下一章,学习分类问题。
第五章:分类
当许多人思考机器学习或人工智能时,他们可能首先想到的是机器学习来解决分类问题。这些问题是我们希望训练一个模型来预测有限数量的不同类别之一。例如,我们可能想要预测一笔金融交易是欺诈还是非欺诈,或者我们可能想要预测一张图片是否包含热狗、飞机、猫等,或者都不是这些。
我们试图预测的类别数量可能从两个到数百或数千不等。此外,我们可能只基于几个属性或许多属性进行预测。所有这些组合产生的场景都导致了一系列具有相应假设、优点和缺点的模型。
我们将在本章和本书的后续部分介绍一些这些模型,但为了简洁起见,我们将跳过许多模型。然而,正如我们在本书中解决任何问题时一样,简单性和完整性在选择适用于我们用例的模型时应该是一个主要关注点。有一些非常复杂和高级的模型可以很好地解决某些问题,但这些模型对于许多用例来说并不是必要的。应用简单且可解释的分类模型应该继续成为我们的目标之一。
理解分类模型术语
与回归一样,分类问题也有其一套术语。这些术语与回归中使用的术语有一些重叠,但也有一些是特定于分类的新术语:
-
类别、标签或类别:这些术语可以互换使用,以表示我们预测的各种不同选择。例如,我们可以有一个欺诈类别和一个非欺诈类别,或者我们可以有坐着、站着、跑步和行走类别。
-
二元分类:这种分类类型只有两个类别或类别,例如是/否或欺诈/非欺诈。
-
多类分类:这种分类类型具有超过两个类别,例如尝试将热狗、飞机、猫等中的一个分配给图像的分类。
-
标记数据或标注数据:与它们对应的类别配对的真实世界观察或记录。例如,如果我们通过交易时间预测欺诈,这些数据将包括一系列测量的交易时间以及一个相应的标签,指示它们是否是欺诈的。
逻辑回归
我们将要探索的第一个分类模型被称为逻辑回归。从名称上可以看出,这种方法基于回归,我们在上一章中详细讨论了回归。然而,这种特定的回归使用了一个特别适合分类问题的函数。
这也是一个简单且易于理解的模型,因此在解决分类问题时,它是一个非常好的首选。目前有各种现有的 Go 包实现了逻辑回归,包括github.com/xlvector/hector
、github.com/cdipaolo/goml
和github.com/sjwhitworth/golearn
。然而,在我们的例子中,我们将从头开始实现逻辑回归,这样你既可以全面了解模型训练的过程,也可以理解逻辑回归的简单性。此外,在某些情况下,你可能希望利用以下章节中所示的自定义实现来避免在代码库中引入额外的依赖。
逻辑回归概述
假设我们有两个类别A和B,我们正在尝试预测。让我们还假设我们正在根据变量x来预测A或B。当与x绘制时,类别A和B可能看起来像这样:
虽然我们可以绘制一条线来模拟这种行为,但这显然不是线性行为,并且不符合线性回归的假设。数据的形状更像是一个从一类到另一类的阶梯,作为x的函数。我们真正需要的是一个函数,它在x的较低值时趋近并保持在A,而在x的较高值时趋近并保持在B。
好吧,我们很幸运!确实存在这样一个函数。这个函数被称为逻辑函数,它为逻辑回归提供了其名称。它具有以下形式:
在 Go 中实现如下:
// logistic implements the logistic function, which
// is used in logistic regression.
func logistic(x float64) float64 {
return 1 / (1 + math.Exp(-x))
}
让我们使用gonum.org/v1/plot
来绘制逻辑函数,看看它是什么样子:
// Create a new plot.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.Title.Text = "Logistic Function"
p.X.Label.Text = "x"
p.Y.Label.Text = "f(x)"
// Create the plotter function.
logisticPlotter := plotter.NewFunction(func(x float64) float64 { return logistic(x) })
logisticPlotter.Color = color.RGBA{B: 255, A: 255}
// Add the plotter function to the plot.
p.Add(logisticPlotter)
// Set the axis ranges. Unlike other data sets,
// functions don't set the axis ranges automatically
// since functions don't necessarily have a
// finite range of x and y values.
p.X.Min = -10
p.X.Max = 10
p.Y.Min = -0.1
p.Y.Max = 1.1
// Save the plot to a PNG file.
if err := p.Save(4*vg.Inch, 4*vg.Inch, "logistic.png"); err != nil {
log.Fatal(err)
}
编译并运行此绘图代码将创建以下图表:
如你所见,这个函数具有我们所寻找的阶梯状行为,可以用来建模类别A和B之间的步骤(假设A对应于0.0,而B对应于1.0)。
不仅如此,逻辑函数还有一些非常方便的性质,我们可以在分类过程中利用这些性质。为了看到这一点,让我们退一步,考虑我们如何可能建模p,即类别A或B发生的概率。一种方法是将odds ratio(优势比)的log(对数)线性化,即log(p / (1 - p)),其中优势比告诉我们类别A的存在或不存在如何影响类别B的存在或不存在。使用这种奇怪的log(称为logit**)的原因很快就会变得有意义,但现在,我们只需假设我们想要如下线性化地建模这个:
现在,如果我们取这个优势比的指数,我们得到以下结果:
当我们简化前面的方程时,我们得到以下结果:
如果你看看这个方程的右侧,你会看到我们的逻辑函数出现了。这个方程为我们的假设提供了正式的依据,即逻辑函数适合于模拟两个类别A和B之间的分离。例如,如果我们把p看作是观察到B的概率,并将逻辑函数拟合到我们的数据上,我们就可以得到一个模型,该模型将B的概率作为x的函数来预测(从而预测A的概率为 1 减去该概率)。这在上面的图中得到了体现,我们在其中正式化了A和B的原始图,并叠加了模拟概率的逻辑函数:
因此,创建逻辑回归模型涉及找到最大化我们能够用逻辑函数预测的观测数目的逻辑函数。
注意,逻辑回归的一个优点是它保持简单且可解释。然而,模型中的系数m和b在解释上并不像线性回归中的那样。系数m(或者如果有多个独立变量,系数m[1]、m[2]等)与似然比有指数关系。因此,如果你有一个m系数为0.5,这通过exp(0.5 x)与似然比相关。如果我们有两个系数exp(0.5 x[1] + 1.0 x[2]),我们可以得出结论,对于x[1],模型类别的似然比是exp(0.5) = 1.65,而x[2]的似然比是exp(1.0) = 2.72。换句话说,我们不能直接比较系数。我们需要在指数的上下文中保持它们。
逻辑回归的假设和陷阱
记得之前应用到线性回归上的那些长长的假设列表吗?嗯,逻辑回归并不受那些相同假设的限制。然而,当我们使用逻辑回归时,仍然有一些重要的假设:
-
与对数似然比之间的线性关系:正如我们之前讨论的,逻辑回归的潜在假设是我们可以用一条线来模拟对数似然比。
-
因变量的编码:在我们之前设置模型时,我们假设我们正在尝试预测B的概率,其中概率为 1.0 对应于正的B例子。因此,我们需要用这种类型的编码准备我们的数据。这将在下面的例子中演示。
-
观测的独立性:我们数据中x的每一个例子都必须是独立的。也就是说,我们必须避免诸如多次包含相同例子这样的情况。
此外,以下是一些需要记住的逻辑回归常见陷阱:
-
逻辑回归可能比其他分类技术对异常值更敏感。请记住这一点,并相应地尝试分析你的数据。
-
由于逻辑回归依赖于一个永远不会真正达到0.0或1.0(除了在正负无穷大时)的指数函数,你可能会在评估指标中看到非常小的下降。
话虽如此,逻辑回归是一种相当稳健的方法,且易于解释。它是一个灵活的模型,在考虑如何解决分类问题时,应该排在你的首选列表中。
逻辑回归示例
我们将要用来展示逻辑回归的数据集是 LendingClub 发布的贷款数据。LendingClub 每季度发布这些数据,其原始形式可以在www.lendingclub.com/info/download-data.action
找到。我们将使用这本书附带代码包中的简化版数据(只包含两列),即FICO.Range
(表示贷款申请人的信用评分,由 Fair, Isaac and Company 提供,或称 FICO)和Interest.Rate
(表示授予贷款申请人的利率)。数据看起来是这样的:
$ head loan_data.csv
FICO.Range,Interest.Rate
735-739,8.90%
715-719,12.12%
690-694,21.98%
695-699,9.99%
695-699,11.71%
670-674,15.31%
720-724,7.90%
705-709,17.14%
685-689,14.33%
我们这个练习的目标是创建一个逻辑回归模型,它将告诉我们,对于给定的信用评分,我们能否以或低于某个利率获得贷款。例如,假设我们感兴趣的是利率低于 12%。我们的模型将告诉我们,在给定的信用评分下,我们能否(是的,或类别一)或不能(不,类别二)获得贷款。
清洗和描述数据
观察前述的贷款数据样本,我们可以看到它并不完全是我们需要的分类形式。具体来说,我们需要做以下几步:
-
从利率和 FICO 评分列中移除非数值字符。
-
将利率编码为两个类别,针对给定的利率阈值。我们将使用1.0来表示第一个类别(是的,我们可以以该利率获得贷款)和0.0来表示第二个类别(不,我们不能以该利率获得贷款)。
-
选择 FICO 信用评分的单个值。我们给出了一个信用评分的范围,但我们需要一个单一值。平均值、最小值或最大值是自然的选择,在我们的例子中,我们将使用最小值(为了保守起见)。
-
在这种情况下,我们将标准化我们的 FICO 评分(通过从每个评分中减去最小评分值然后除以评分范围)。这将使评分值分布在0.0到1.0之间。我们需要对此进行合理的解释,因为它会使我们的数据不那么易读。然而,有一个合理的解释。我们将使用梯度下降法来训练逻辑回归,这种方法在标准化数据上表现更好。实际上,当使用非标准化数据运行相同的示例时,会出现收敛问题。
让我们编写一个 Go 程序,该程序将为我们给定利率(例如 12%)的数据进行清理。我们将从指定的文件中读取数据,使用encoding/csv
解析值,并将清理后的数据放入名为clean_loan_data.csv
的输出文件中。在数据清理过程中,我们将使用以下最小和最大值,我们将它们定义为常量:
const (
scoreMax = 830.0
scoreMin = 640.0
)
然后,实际的清理功能如下所示:
// Open the loan dataset file.
f, err := os.Open("loan_data.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
reader.FieldsPerRecord = 2
// Read in all of the CSV records
rawCSVData, err := reader.ReadAll()
if err != nil {
log.Fatal(err)
}
// Create the output file.
f, err = os.Create("clean_loan_data.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a CSV writer.
w := csv.NewWriter(f)
// Sequentially move the rows writing out the parsed values.
for idx, record := range rawCSVData {
// Skip the header row.
if idx == 0 {
// Write the header to the output file.
if err := w.Write(record); err != nil {
log.Fatal(err)
}
continue
}
// Initialize a slice to hold our parsed values.
outRecord := make([]string, 2)
// Parse and standardize the FICO score.
score, err := strconv.ParseFloat(strings.Split(record[0], "-")[0], 64)
if err != nil {
log.Fatal(err)
}
outRecord[0] = strconv.FormatFloat((score-scoreMin)/(scoreMax-scoreMin), 'f', 4, 64)
// Parse the Interest rate class.
rate, err := strconv.ParseFloat(strings.TrimSuffix(record[1], "%"), 64)
if err != nil {
log.Fatal(err)
}
if rate <= 12.0 {
outRecord[1] = "1.0"
// Write the record to the output file.
if err := w.Write(outRecord); err != nil {
log.Fatal(err)
}
continue
}
outRecord[1] = "0.0"
// Write the record to the output file.
if err := w.Write(outRecord); err != nil {
log.Fatal(err)
}
}
// Write any buffered data to the underlying writer (standard output).
w.Flush()
if err := w.Error(); err != nil {
log.Fatal(err)
}
编译并运行它确认了我们的预期输出:
$ go build
$ ./example3
$ head clean_loan_data.csv
FICO_score,class
0.5000,1.0
0.3947,0.0
0.2632,0.0
0.2895,1.0
0.2895,1.0
0.1579,0.0
0.4211,1.0
0.3421,0.0
0.2368,0.0
太好了!我们的数据已经以所需的格式存在。现在,让我们通过创建 FICO 评分和利率数据的直方图以及计算摘要统计来对我们的数据有更多的直观了解。我们将使用github.com/kniren/gota/dataframe
来计算摘要统计,并使用gonum.org/v1/plot
来生成直方图:
// Open the CSV file.
loanDataFile, err := os.Open("clean_loan_data.csv")
if err != nil {
log.Fatal(err)
}
defer loanDataFile.Close()
// Create a dataframe from the CSV file.
loanDF := dataframe.ReadCSV(loanDataFile)
// Use the Describe method to calculate summary statistics
// for all of the columns in one shot.
loanSummary := loanDF.Describe()
// Output the summary statistics to stdout.
fmt.Println(loanSummary)
// Create a histogram for each of the columns in the dataset.
for _, colName := range loanDF.Names() {
// Create a plotter.Values value and fill it with the
// values from the respective column of the dataframe.
plotVals := make(plotter.Values, loanDF.Nrow())
for i, floatVal := range loanDF.Col(colName).Float() {
plotVals[i] = floatVal
}
// Make a plot and set its title.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.Title.Text = fmt.Sprintf("Histogram of a %s", colName)
// Create a histogram of our values.
h, err := plotter.NewHist(plotVals, 16)
if err != nil {
log.Fatal(err)
}
// Normalize the histogram.
h.Normalize(1)
// Add the histogram to the plot.
p.Add(h)
// Save the plot to a PNG file.
if err := p.Save(4*vg.Inch, 4*vg.Inch, colName+"_hist.png"); err != nil {
log.Fatal(err)
}
}
运行此代码将产生以下输出:
$ go build
$ ./myprogram
[7x3] DataFrame
column FICO_score class
0: mean 0.346782 0.396800
1: stddev 0.184383 0.489332
2: min 0.000000 0.000000
3: 25% 0.210500 0.000000
4: 50% 0.315800 0.000000
5: 75% 0.447400 1.000000
6: max 1.000000 1.000000
<string> <float> <float>
$ ls *.png
class_hist.png FICO_score_hist.png
我们可以看到,平均信用评分相当高,为 706.1,并且一和零类之间有一个相当好的平衡,这从接近 0.5 的平均值中可以看出。然而,似乎有更多的零类示例(这对应于没有以 12%或以下利率获得贷款)。此外,*.png
直方图图看起来如下:
这证实了我们对类别之间平衡的怀疑,并显示 FICO 评分略偏向较低值。
创建我们的训练和测试集
与前一章中的示例类似,我们需要将我们的数据分为训练集和测试集。我们再次使用github.com/kniren/gota/dataframe
来完成此操作:
// Open the clean loan dataset file.
f, err := os.Open("clean_loan_data.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a dataframe from the CSV file.
// The types of the columns will be inferred.
loanDF := dataframe.ReadCSV(f)
// Calculate the number of elements in each set.
trainingNum := (4 * loanDF.Nrow()) / 5
testNum := loanDF.Nrow() / 5
if trainingNum+testNum < loanDF.Nrow() {
trainingNum++
}
// Create the subset indices.
trainingIdx := make([]int, trainingNum)
testIdx := make([]int, testNum)
// Enumerate the training indices.
for i := 0; i < trainingNum; i++ {
trainingIdx[i] = i
}
// Enumerate the test indices.
for i := 0; i < testNum; i++ {
testIdx[i] = trainingNum + i
}
// Create the subset dataframes.
trainingDF := loanDF.Subset(trainingIdx)
testDF := loanDF.Subset(testIdx)
// Create a map that will be used in writing the data
// to files.
setMap := map[int]dataframe.DataFrame{
0: trainingDF,
1: testDF,
}
// Create the respective files.
for idx, setName := range []string{"training.csv", "test.csv"} {
// Save the filtered dataset file.
f, err := os.Create(setName)
if err != nil {
log.Fatal(err)
}
// Create a buffered writer.
w := bufio.NewWriter(f)
// Write the dataframe out as a CSV.
if err := setMap[idx].WriteCSV(w); err != nil {
log.Fatal(err)
}
}
编译并运行此代码将产生两个文件,包含我们的训练和测试示例:
$ go build
$ ./myprogram
$ wc -l *.csv
2046 clean_loan_data.csv
410 test.csv
1638 training.csv
4094 total
训练和测试逻辑回归模型
现在,让我们创建一个函数来训练逻辑回归模型。这个函数需要执行以下操作:
-
将我们的 FICO 评分数据作为独立变量接受。
-
在我们的模型中添加一个截距。
-
初始化并优化逻辑回归模型的系数(或权重)。
-
返回定义我们的训练模型的优化权重。
为了优化系数/权重,我们将使用一种称为随机梯度下降的技术。这种技术将在附录与机器学习相关的算法/技术中更详细地介绍。现在,只需说我们正在尝试使用一些未优化的权重进行预测,计算这些权重的错误,然后迭代地更新它们以最大化正确预测的可能性。
以下是对这种优化的实现。该函数接受以下输入:
-
features
:一个指向 gonummat64.Dense
矩阵的指针。这个矩阵包括一个用于任何独立变量(在我们的例子中是 FICO 评分)的列,以及表示截距的 1.0 列。 -
labels
:包含所有对应于我们的features
的类标签的浮点数切片。 -
numSteps
:优化的最大迭代次数。 -
learningRate
:一个可调整的参数,有助于优化的收敛。
然后该函数输出逻辑回归模型的优化权重:
// logisticRegression fits a logistic regression model
// for the given data.
func logisticRegression(features *mat64.Dense, labels []float64, numSteps int, learningRate float64) []float64 {
// Initialize random weights.
_, numWeights := features.Dims()
weights := make([]float64, numWeights)
s := rand.NewSource(time.Now().UnixNano())
r := rand.New(s)
for idx, _ := range weights {
weights[idx] = r.Float64()
}
// Iteratively optimize the weights.
for i := 0; i < numSteps; i++ {
// Initialize a variable to accumulate error for this iteration.
var sumError float64
// Make predictions for each label and accumulate error.
for idx, label := range labels {
// Get the features corresponding to this label.
featureRow := mat64.Row(nil, idx, features)
// Calculate the error for this iteration's weights.
pred := logistic(featureRow[0]*weights[0]
featureRow[1]*weights[1])
predError := label - pred
sumError += math.Pow(predError, 2)
// Update the feature weights.
for j := 0; j < len(featureRow); j++ {
weights[j] += learningRate * predError * pred * (1 - pred) * featureRow[j]
}
}
}
return weights
}
如您所见,这个函数相对紧凑且简单。这将使我们的代码易于阅读,并允许我们团队的人快速理解模型中的情况,而不会将事物隐藏在黑盒中。
尽管 R 和 Python 在机器学习中的流行,您可以看到机器学习算法可以在 Go 中快速且紧凑地实现。此外,这些实现立即达到了远远超过其他语言中天真实现的完整性水平。
要在我们的训练数据集上训练我们的逻辑回归模型,我们将使用encoding/csv
解析我们的训练文件,然后向logisticRegression
函数提供必要的参数。这个过程如下,以及一些代码,将我们的训练好的逻辑公式输出到stdout
:
// Open the training dataset file.
f, err := os.Open("training.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
reader.FieldsPerRecord = 2
// Read in all of the CSV records
rawCSVData, err := reader.ReadAll()
if err != nil {
log.Fatal(err)
}
// featureData and labels will hold all the float values that
// will eventually be used in our training.
featureData := make([]float64, 2*len(rawCSVData))
labels := make([]float64, len(rawCSVData))
// featureIndex will track the current index of the features
// matrix values.
var featureIndex int
// Sequentially move the rows into the slices of floats.
for idx, record := range rawCSVData {
// Skip the header row.
if idx == 0 {
continue
}
// Add the FICO score feature.
featureVal, err := strconv.ParseFloat(record[0], 64)
if err != nil {
log.Fatal(err)
}
featureData[featureIndex] = featureVal
// Add an intercept.
featureData[featureIndex+1] = 1.0
// Increment our feature row.
featureIndex += 2
// Add the class label.
labelVal, err := strconv.ParseFloat(record[1], 64)
if err != nil {
log.Fatal(err)
}
labels[idx] = labelVal
}
// Form a matrix from the features.
features := mat64.NewDense(len(rawCSVData), 2, featureData)
// Train the logistic regression model.
weights := logisticRegression(features, labels, 100, 0.3)
// Output the Logistic Regression model formula to stdout.
formula := "p = 1 / ( 1 + exp(- m1 * FICO.score - m2) )"
fmt.Printf("\n%s\n\nm1 = %0.2f\nm2 = %0.2f\n\n", formula, weights[0], weights[1])
编译并运行这个训练功能,得到以下训练好的逻辑回归公式:
$ go build
$ ./myprogram
p = 1 / ( 1 + exp(- m1 * FICO.score - m2) )
m1 = 13.65
m2 = -4.89
然后,我们可以直接使用这个公式进行预测。但是,请记住,这个模型预测的是获得贷款(利率为 12%)的概率。因此,在做出预测时,我们需要使用概率的阈值。例如,我们可以说任何p大于或等于0.5的将被视为正值(类别一,或获得贷款),任何更低的p值将被视为负值。这种预测在以下函数中实现:
// predict makes a prediction based on our
// trained logistic regression model.
func predict(score float64) float64 {
// Calculate the predicted probability.
p := 1 / (1 + math.Exp(-13.65*score+4.89))
// Output the corresponding class.
if p >= 0.5 {
return 1.0
}
return 0.0
}
使用这个predict
函数,我们可以使用书中前面介绍的评价指标之一来评估我们训练好的逻辑回归模型。在这种情况下,让我们使用准确率,如下面的代码所示:
// Open the test examples.
f, err := os.Open("test.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
// observed and predicted will hold the parsed observed and predicted values
// form the labeled data file.
var observed []float64
var predicted []float64
// line will track row numbers for logging.
line := 1
// Read in the records looking for unexpected types in the columns.
for {
// Read in a row. Check if we are at the end of the file.
record, err := reader.Read()
if err == io.EOF {
break
}
// Skip the header.
if line == 1 {
line++
continue
}
// Read in the observed value.
observedVal, err := strconv.ParseFloat(record[1], 64)
if err != nil {
log.Printf("Parsing line %d failed, unexpected type\n", line)
continue
}
// Make the corresponding prediction.
score, err := strconv.ParseFloat(record[0], 64)
if err != nil {
log.Printf("Parsing line %d failed, unexpected type\n", line)
continue
}
predictedVal := predict(score)
// Append the record to our slice, if it has the expected type.
observed = append(observed, observedVal)
predicted = append(predicted, predictedVal)
line++
}
// This variable will hold our count of true positive and
// true negative values.
var truePosNeg int
// Accumulate the true positive/negative count.
for idx, oVal := range observed {
if oVal == predicted[idx] {
truePosNeg++
}
}
// Calculate the accuracy (subset accuracy).
accuracy := float64(truePosNeg) / float64(len(observed))
// Output the Accuracy value to standard out.
fmt.Printf("\nAccuracy = %0.2f\n\n", accuracy)
在我们的数据上运行这个测试,得到的准确率如下:
$ go build
$ ./myprogram
Accuracy = 0.83
太好了!83%的准确率对于一个我们用大约 30 行 Go 语言实现的机器学习模型来说并不差。使用这个简单的模型,我们能够预测,给定一个特定的信用评分,贷款申请人是否会获得利率低于或等于 12%的贷款。不仅如此,我们使用的是来自真实公司的真实世界混乱数据。
k-最近邻
从逻辑回归转向,让我们尝试我们的第一个非回归模型,k-最近邻(kNN)。kNN 也是一个简单的分类模型,并且是掌握起来最容易的模型算法之一。它遵循这样一个基本前提:如果我想对一条记录进行分类,我应该考虑其他类似的记录。
kNN 在多个现有的 Go 包中实现,包括github.com/sjwhitworth/golearn
、github.com/rikonor/go-ann
、github.com/akreal/knn
和github.com/cdipaolo/goml
。我们将使用github.com/sjwhitworth/golearn
实现,这将作为使用github.com/sjwhitworth/golearn
的绝佳介绍。
kNN 概述
如前所述,kNN 基于这样的原则:我们应该根据相似记录来分类记录。在定义相似性时有一些细节需要处理。然而,kNN 没有许多模型所具有的参数和选项的复杂性。
再次想象一下,我们有两个类别A和B。然而,这次,假设我们想要根据两个特征x[1]和x[2]进行分类。直观上看,这看起来可能如下所示:
现在,假设我们有一个未知类别的新的数据点。这个新的数据点将位于这个空间中的某个位置。kNN 算法表示,为了对这个新的数据点进行分类,我们应该执行以下操作:
-
根据某种接近度度量(例如,在这个x[1]和x[2]的空间中的直线距离)找到新点的k个最近点。
-
确定有多少个k个最近的邻居属于类别A,以及有多少个属于类别B。
-
将新点分类为k个最近邻居中的主导类别。
例如,如果我们选择k为四,这看起来可能如下所示:
我们神秘点有三个A最近的邻居,只有一个B最近的邻居。因此,我们将这个新的神秘点分类为类别A。
你可以使用许多相似度度量来确定k个最近的邻居。其中最常见的是欧几里得距离,它只是由你的特征(在我们的例子中是x[1]和x[2]*)组成的空间中一个点到下一个点的直线距离。其他还包括曼哈顿距离、闵可夫斯基距离、余弦相似度和Jaccard 相似度。
就像评估指标一样,有各种方法可以测量距离或相似度。在使用 kNN 时,你应该研究这些度量的优缺点,并选择一个适合你的用例和数据的度量。然而,如果你不确定,可以从欧几里得距离开始尝试。
kNN 的假设和陷阱
由于其简单性,kNN 没有太多假设。然而,有一些常见的陷阱,你在应用 kNN 时应该注意:
-
kNN 是懒散评估的。这意味着,当我们需要做出预测时,才会计算距离或相似度。在做出预测之前,实际上并没有什么需要训练或拟合的。这有一些优点,但是当数据点很多时,计算和搜索点可能会很慢。
-
k的选择取决于你,但你应该围绕选择k制定一些形式化方法,并为你选择的k提供合理的解释。选择k的一个常见技术是搜索一系列k值。例如,你可以从k = 2开始。然后,你可以开始增加k,并对每个k在测试集上进行评估。
-
kNN 没有考虑哪些特征比其他特征更重要。此外,如果你的特征的确定性尺度比其他特征大得多,这可能会不自然地增加这些较大特征的重要性。
kNN 示例
对于这一点,以及本章剩余的示例,我们将使用关于鸢尾花的数据集来解决一个经典的分类问题。数据集看起来是这样的:
$ head iris.csv
sepal_length,sepal_width,petal_length,petal_width,species
5.1,3.5,1.4,0.2,Iris-setosa
4.9,3.0,1.4,0.2,Iris-setosa
4.7,3.2,1.3,0.2,Iris-setosa
4.6,3.1,1.5,0.2,Iris-setosa
5.0,3.6,1.4,0.2,Iris-setosa
5.4,3.9,1.7,0.4,Iris-setosa
4.6,3.4,1.4,0.3,Iris-setosa
5.0,3.4,1.5,0.2,Iris-setosa
4.4,2.9,1.4,0.2,Iris-setosa
前四列是鸢尾花的各种测量值,最后一列是对应的物种标签。本例的目标是创建一个 kNN 分类器,能够从一组测量值中预测鸢尾花的物种。有三种花类,或三个类别,这使得这是一个多类别分类(与我们在逻辑回归中进行的二进制分类相反)。
你可能还记得,我们在第二章,矩阵、概率和统计学中已经详细分析了鸢尾花数据集。我们在这里不会重新分析数据。然而,在我们开发 kNN 模型时,对数据有直观的了解仍然很重要。确保你翻回到第二章,矩阵、概率和统计学,以提醒自己关于这个数据集中变量分布的情况。
在本例中,我们将使用github.com/sjwhitworth/golearn
。github.com/sjwhitworth/golearn
实现了多种机器学习模型,包括 kNN 和一些我们很快将要探索的其他模型。github.com/sjwhitworth/golearn
还实现了交叉验证。我们将利用交叉验证在这里进行训练、测试和验证,这既方便又让我们避免了手动在训练集和测试集之间进行分割。
要使用github.com/sjwhitworth/golearn
的任何模型,我们首先必须将数据转换为github.com/sjwhitworth/golearn
内部格式,称为实例。对于鸢尾花数据,我们可以这样做:
// Read in the iris data set into golearn "instances".
irisData, err := base.ParseCSVToInstances("iris.csv", true)
if err != nil {
log.Fatal(err)
}
然后,初始化我们的 kNN 模型并进行交叉验证是快速且简单的:
// Initialize a new KNN classifier. We will use a simple
// Euclidean distance measure and k=2.
knn := knn.NewKnnClassifier("euclidean", "linear", 2)
// Use cross-fold validation to successively train and evaluate the model
// on 5 folds of the data set.
cv, err := evaluation.GenerateCrossFoldValidationConfusionMatrices(irisData, knn, 5)
if err != nil {
log.Fatal(err)
}
最后,我们可以得到交叉验证的五次折叠的平均准确率,并将该准确率输出到stdout
:
// Get the mean, variance and standard deviation of the accuracy for the
// cross validation.
mean, variance := evaluation.GetCrossValidatedMetric(cv, evaluation.GetAccuracy)
stdev := math.Sqrt(variance)
// Output the cross metrics to standard out.
fmt.Printf("\nAccuracy\n%.2f (+/- %.2f)\n\n", mean, stdev*2)
将所有这些编译并运行,会得到以下输出:
$ go build
$ ./myprogram
Optimisations are switched off
Optimisations are switched off
Optimisations are switched off
Optimisations are switched off
Optimisations are switched off
KNN: 95.00 % done
Accuracy
0.95 (+/- 0.05)
在交叉验证期间,从包中输出的良性日志显示,kNN(k = 2)能够以 95%的准确率预测鸢尾花物种!
下一步将是尝试使用不同的k值来测试这个模型。实际上,绘制准确率与k值的对比图,以查看哪个k值能给出最佳性能,将是一个很好的练习。
决策树和随机森林
基于树的模型与我们之前讨论的类型模型非常不同,但它们被广泛使用,并且非常强大。你可以将 决策树 模型想象成一系列应用于你的数据的 if-then
语句。当你训练这种类型的模型时,你正在构建一系列控制流语句,最终允许你分类记录。
决策树在 github.com/sjwhitworth/golearn
和 github.com/xlvector/hector
等地方实现,随机森林在 github.com/sjwhitworth/golearn
、github.com/xlvector/hector
和 github.com/ryanbressler/CloudForest
等地方实现。我们将在下一节中展示的示例中再次使用 github.com/sjwhitworth/golearn
。
决策树和随机森林概述
再次考虑我们的类 A 和 B。在这种情况下,假设我们有一个特征 x[1],其范围从 0.0 到 1.0,我们还有一个特征 x[2],它是分类的,可以取两个值之一,a[1] 和 a[2](这可能像男性/女性或红色/蓝色这样的东西)。一个用于分类新数据点的决策树可能看起来像以下这样:
有许多方法可以选择如何构建决策树,如何分割等。确定决策树构建的最常见方法之一是使用一个称为 熵 的量。这种基于熵的方法在 附录 中有更详细的讨论,但基本上,我们根据哪些特征给我们提供关于我们正在解决的问题的最多信息来构建树和分割。更重要特征在树上优先级更高。
这种对重要特征的优先级排序和自然的外观结构使得决策树非常可解释。这使得决策树对于你可能需要解释你的推理的应用非常重要(例如,出于合规原因)。
然而,单个决策树可能对训练数据的变化不稳定。换句话说,树的结构可能会随着训练数据中甚至很小的变化而显著改变。这在操作上和认知上都是一项挑战,这也是为什么创建 随机森林 模型的一个原因。
随机森林是一组协同工作的决策树,用于做出预测。与单个决策树相比,随机森林更稳定,并且对过拟合有更强的鲁棒性。实际上,这种将模型组合成 集成 的想法在机器学习中很普遍,旨在提高简单分类器(如决策树)的性能,并帮助防止过拟合。
为了构建一个随机森林,我们选择N个随机特征子集,并基于这些子集构建N个独立的决策树。在做出预测时,我们可以让这N个决策树中的每一个做出预测。为了得到最终的预测,我们可以对这N个预测进行多数投票。
决策树和随机森林的假设和陷阱
基于树的算法是非统计方法,没有许多与回归等事物相关的假设。然而,有一些陷阱需要记住:
-
单个决策树模型很容易对数据进行过拟合,尤其是如果你没有限制树的深度。大多数实现允许你通过一个参数(或剪枝决策树)来限制这个深度。剪枝参数通常会允许你移除对预测影响较小的树的某些部分,从而降低模型的整体复杂性。
-
当我们开始谈论集成模型,如随机森林时,我们正在进入一些相对不透明的模型。很难对模型集获得直观的认识,你必须在某种程度上将其视为黑盒。像这样的不太可解释的模型只有在必要时才应该应用。
-
尽管决策树本身在计算上非常高效,但随机森林的计算效率可能会非常低,这取决于你有多少特征以及你的随机森林中有多少棵树。
决策树示例
我们将再次使用鸢尾花数据集来演示这个例子。您已经学习了如何在github.com/sjwhitworth/golearn
中处理这个数据集,我们还可以再次遵循类似的模式。我们还将再次使用交叉验证。然而,这次我们将拟合一个决策树模型:
// Read in the iris data set into golearn "instances".
irisData, err := base.ParseCSVToInstances("iris.csv", true)
if err != nil {
log.Fatal(err)
}
// This is to seed the random processes involved in building the
// decision tree.
rand.Seed(44111342)
// We will use the ID3 algorithm to build our decision tree. Also, we
// will start with a parameter of 0.6 that controls the train-prune split.
tree := trees.NewID3DecisionTree(0.6)
// Use cross-fold validation to successively train and evaluate the model
// on 5 folds of the data set.
cv, err := evaluation.GenerateCrossFoldValidationConfusionMatrices(irisData, tree, 5)
if err != nil {
log.Fatal(err)
}
// Get the mean, variance and standard deviation of the accuracy for the
// cross validation.
mean, variance := evaluation.GetCrossValidatedMetric(cv, evaluation.GetAccuracy)
stdev := math.Sqrt(variance)
// Output the cross metrics to standard out.
fmt.Printf("\nAccuracy\n%.2f (+/- %.2f)\n\n", mean, stdev*2)
编译并运行这个决策树模型会得到以下结果:
$ go build
$ ./myprogram
Accuracy
0.94 (+/- 0.06)
这次达到了 94%的准确率。略低于我们的 kNN 模型,但仍然非常令人尊重。
随机森林示例
github.com/sjwhitworth/golearn
也实现了随机森林。为了在解决鸢尾花问题时使用随机森林,我们只需将我们的决策树模型替换为随机森林。我们需要告诉这个包我们想要构建多少棵树以及每棵树有多少随机选择的特征。
每棵树的特征数量的一个合理的默认值是总特征数的平方根,在我们的例子中将是两个。我们将看到,对于我们的小型数据集,这个选择不会产生好的结果,因为我们在这里需要所有特征来做出好的预测。然而,我们将使用合理的默认值来演示随机森林是如何工作的:
// Assemble a random forest with 10 trees and 2 features per tree,
// which is a sane default (number of features per tree is normally set
// to sqrt(number of features)).
rf := ensemble.NewRandomForest(10, 2)
// Use cross-fold validation to successively train and evaluate the model
// on 5 folds of the data set.
cv, err := evaluation.GenerateCrossFoldValidationConfusionMatrices(irisData, rf, 5)
if err != nil {
log.Fatal(err)
}
运行这个算法得到的准确率比单个决策树要差。如果我们把每棵树的特征数量恢复到四,我们将重新获得单个决策树的准确率。这意味着每棵树都是用与单个决策树相同的信息进行训练的,因此产生了相同的结果。
随机森林在这里可能过于强大,并且无法用任何性能提升来证明其合理性,因此最好坚持使用单个决策树。这个单一的决策树也更易于解释和更高效。
简单贝叶斯
我们在这里将要介绍的用于分类的最终模型被称为简单贝叶斯。在第二章,“矩阵、概率和统计学”中,我们讨论了贝叶斯定理,它是这种技术的基础。简单贝叶斯是一种基于概率的方法,类似于逻辑回归,但其基本思想和假设是不同的。
简单贝叶斯也实现了github.com/sjwhitworth/golearn
,这将使我们能够轻松尝试它。然而,还有许多其他的 Go 实现,包括github.com/jbrukh/bayesian
、github.com/lytics/multibayes
和github.com/cdipaolo/goml
。
简单贝叶斯及其大假设概述
简单贝叶斯在一条大假设下运行。这个假设说,类别和我们的数据集中某个特征的存在或不存在与数据集中其他特征的存在或不存在是独立的。这使我们能够为给定某些特征的存在或不存在写出非常简单的某个类别的概率公式。
让我们通过一个例子来使这个问题更具体。再次,假设我们正在尝试根据电子邮件中的单词预测两个类别,A和B(例如,垃圾邮件和非垃圾邮件),基于电子邮件中的单词。简单贝叶斯将假设某个单词的存在/不存在与其他单词的存在/不存在是独立的。如果我们做出这个假设,某个类别包含某些单词的概率与所有单个条件概率相乘的比例是相等的。使用这个,贝叶斯定理,一些链式规则以及我们的独立假设,我们可以写出以下某个类别的条件概率:
右侧的所有内容我们都可以通过计算训练集中特征和标签的出现次数来计算,这就是在训练模型时所做的。然后可以通过将这些概率连成链来做出预测。
实际上,在实践中,使用一个小技巧来避免将许多接近零的数字连在一起。我们可以取概率的对数,然后相加,最后取表达式的指数。这个过程在实践中通常更好。
简单贝叶斯示例
再次回到我们的贷款数据集,我们将使用github.com/sjwhitworth/golearn
来用简单贝叶斯解决相同的贷款接受问题。我们将使用与逻辑回归示例中相同的训练和测试集。然而,我们需要将数据集中的标签转换为github.com/sjwhitworth/golearn
中使用的二分类器格式。我们可以编写一个简单的函数来完成这个转换:
// convertToBinary utilizes built in golearn functionality to
// convert our labels to a binary label format.
func convertToBinary(src base.FixedDataGrid) base.FixedDataGrid {
b := filters.NewBinaryConvertFilter()
attrs := base.NonClassAttributes(src)
for _, a := range attrs {
b.AddAttribute(a)
}
b.Train()
ret := base.NewLazilyFilteredInstances(src, b)
return ret
}
一旦我们有了这些,我们就可以训练和测试我们的朴素贝叶斯模型,如下面的代码所示:
// Read in the loan training data set into golearn "instances".
trainingData, err := base.ParseCSVToInstances("training.csv", true)
if err != nil {
log.Fatal(err)
}
// Initialize a new Naive Bayes classifier.
nb := naive.NewBernoulliNBClassifier()
// Fit the Naive Bayes classifier.
nb.Fit(convertToBinary(trainingData))
// Read in the loan test data set into golearn "instances".
// This time we will utilize a template of the previous set
// of instances to validate the format of the test set.
testData, err := base.ParseCSVToTemplatedInstances("test.csv", true, trainingData)
if err != nil {
log.Fatal(err)
}
// Make our predictions.
predictions := nb.Predict(convertToBinary(testData))
// Generate a Confusion Matrix.
cm, err := evaluation.GetConfusionMatrix(testData, predictions)
if err != nil {
log.Fatal(err)
}
// Retrieve the accuracy.
accuracy := evaluation.GetAccuracy(cm)
fmt.Printf("\nAccuracy: %0.2f\n\n", accuracy)
编译并运行此代码给出的准确率如下:
$ go build
$ ./myprogram
Accuracy: 0.63
这并不如我们从头实现的逻辑回归那么好。然而,这里仍然有一定的预测能力。一个很好的练习是向这个模型添加一些来自 LendingClub 数据集的其他特征,特别是某些分类变量。这可能会提高朴素贝叶斯的结果。
参考文献
一般分类:
github.com/sjwhitworth/golearn
文档:godoc.org/github.com/sjwhitworth/golearn
摘要
我们已经涵盖了多种分类模型,包括逻辑回归、k-最近邻、决策树、随机森林和朴素贝叶斯。实际上,我们还从头实现了逻辑回归。所有这些模型都有它们各自的优势和劣势,我们已经讨论过了。然而,它们应该为你提供一套良好的工具,让你可以使用 Go 开始进行分类。
在下一章中,我们将讨论另一种类型的机器学习,称为聚类。这是我们将要讨论的第一个无监督技术,我们将尝试几种不同的方法。
第六章:聚类
通常,一组数据可以被组织成一组聚类。例如,你可能能够将数据组织成与某些潜在属性(如包括年龄、性别、地理、就业状态等人口统计属性)或某些潜在过程(如浏览、购物、机器人交互以及网站上的其他此类行为)相对应的聚类。用于检测和标记这些聚类的机器学习技术被称为聚类技术,这是很自然的。
到目前为止,我们所探讨的机器学习算法都是监督式的。也就是说,我们有一组特征或属性与相应的标签或数字配对,这是我们试图预测的。我们使用这些带标签的数据来调整我们的模型以适应我们在训练模型之前已经了解的行为。
大多数聚类技术都是无监督式的。与回归和分类的监督式技术相反,我们在使用聚类模型找到聚类之前,通常不知道数据集中的聚类。因此,我们带着未标记的数据集和算法进入聚类问题,并使用聚类算法为我们生成数据集的聚类标签。
此外,聚类技术与其他机器学习技术区分开来,因为很难说给定数据集的正确或准确聚类是什么。根据你寻找的聚类数量以及你用于数据点之间相似度的度量,你可能会得到一系列不同的聚类集合,每个集合都有一些潜在的意义。这并不意味着聚类技术不能被评估或验证,但这确实意味着我们需要了解我们的局限性,并在量化我们的结果时要小心。
理解聚类模型术语
聚类非常独特,并带有它自己的一套术语,如下所示。请记住,以下列表只是一个部分列表,因为有许多不同类型的聚类及其术语:
-
聚类或组:这些聚类或组中的每一个都是我们的聚类技术组织数据点的数据点集合。
-
组内或簇内:通过聚类产生的聚类可以使用数据点与其他相同结果簇中的数据点之间的相似度来评估。这被称为组内或簇内评估和相似度。
-
组间或簇间:通过聚类产生的聚类可以使用数据点与其他结果簇中的数据点之间的差异度来评估。这被称为组间或簇间评估和差异度。
-
内部标准:通常,我们并没有一个可以用来评估我们结果聚类的金标准聚类标签集。在这些情况下,我们利用聚类内和聚类间的相似性来衡量我们的聚类技术的性能。
-
外部标准:在其他情况下,我们可能有一个金标准的聚类标签或分组标准,例如由人类评委生成的一个标准。这些场景允许我们使用标准或外部标准来评估我们的聚类技术。
-
距离或相似度:这是衡量两个数据点之间接近程度的一个度量。这可能是特征空间中的欧几里得距离或某种其他接近程度的度量。
测量距离或相似度
为了将数据点聚在一起,我们需要定义并利用一些距离或相似度,这些距离或相似度可以定量地定义数据点之间的接近程度。选择这个度量是每个聚类项目的一个基本部分,因为它直接影响到聚类的生成方式。使用一个相似度度量生成的聚类可能与使用另一个相似度度量生成的聚类非常不同。
这些距离度量中最常见且简单的是欧几里得距离或平方欧几里得距离。这仅仅是两个数据点在特征空间中的直线距离(你可能记得这个距离,因为它也用于我们第五章中的 kNN 示例 Chapter 5,分类)或数量平方。然而,还有许多其他,有时更复杂的距离度量。以下图表中展示了其中的一些:
例如,曼哈顿距离是两点之间的绝对 x 距离加上 y 距离,而闵可夫斯基距离则是对欧几里得距离和曼哈顿距离的推广。与欧几里得距离相比,这些距离度量在面对数据中的异常值(或离群值)时将更加稳健。
其他距离度量,如汉明距离,适用于某些类型的数据,例如字符串。在示例中,Golang和Gopher之间的汉明距离是四,因为在字符串中有四个位置它们是不同的。因此,如果你在处理文本数据,如新闻文章或推文,汉明距离可能是一个好的距离度量选择。
在我们的目的中,我们将主要坚持使用欧几里得距离。这个距离在gonum.org/v1/gonum/floats
中通过Distance()
函数实现。为了说明,假设我们想要计算点(1, 2)
和点(3, 4)
之间的距离。我们可以这样做:
// Calculate the Euclidean distance, specified here via
// the last argument in the Distance function.
distance := floats.Distance([]float64{1, 2}, []float64{3, 4}, 2)
fmt.Printf("\nDistance: %0.2f\n\n", distance)
评估聚类技术
由于我们不是试图预测一个数字或类别,我们之前讨论的连续和离散变量的评估指标并不适用于聚类技术。这并不意味着我们将避免测量聚类算法的性能。我们需要知道我们的聚类表现如何。我们只需要引入一些特定的聚类评估指标。
内部聚类评估
如果我们没有为我们的簇设置一组金标准标签进行比较,我们就只能使用内部标准来评估我们的聚类技术表现。换句话说,我们仍然可以通过在簇内部进行相似性和差异性测量来评估我们的聚类。
我们将要介绍的第一种内部指标称为轮廓系数。轮廓系数可以按以下方式计算每个聚类数据点:
在这里,a是数据点到同一簇中所有其他点的平均距离(例如欧几里得距离),而b是数据点到其簇最近簇中所有其他点的平均距离。所有数据点的这个轮廓系数的平均值表示每个簇中点的紧密程度。这个平均值可以按簇或所有簇中的数据点来计算。
让我们尝试计算鸢尾花数据集的轮廓系数,这可以看作是三个簇的集合,对应于三种鸢尾花物种。首先,为了计算轮廓系数,我们需要知道三个簇的质心。这些质心仅仅是三个簇的中心点(在我们的四维特征空间中),这将允许我们确定哪个簇离某个数据点的簇最近。
为了达到这个目的,我们需要解析我们的鸢尾花数据集文件(最初在第一章,收集和组织数据)中引入的),根据簇标签分离我们的记录,计算每个簇中的特征平均值,然后计算相应的质心。首先,我们将为我们的质心定义一个type
:
type centroid []float64
然后,我们可以创建一个包含我们每种鸢尾花物种质心的映射,使用github.com/kniren/gota/dataframe
:
// Pull in the CSV file.
irisFile, err := os.Open("iris.csv")
if err != nil {
log.Fatal(err)
}
defer irisFile.Close()
// Create a dataframe from the CSV file.
irisDF := dataframe.ReadCSV(irisFile)
// Define the names of the three separate species contained in the CSV file.
speciesNames := []string{
"Iris-setosa",
"Iris-versicolor",
"Iris-virginica",
}
// Create a map to hold our centroid information.
centroids := make(map[string]centroid)
// Filter the dataset into three separate dataframes,
// each corresponding to one of the Iris species.
for _, species := range speciesNames {
// Filter the original dataset.
filter := dataframe.F{
Colname: "species",
Comparator: "==",
Comparando: species,
}
filtered := irisDF.Filter(filter)
// Calculate the mean of features.
summaryDF := filtered.Describe()
// Put each dimension's mean into the corresponding centroid.
var c centroid
for _, feature := range summaryDF.Names() {
// Skip the irrelevant columns.
if feature == "column" || feature == "species" {
continue
}
c = append(c, summaryDF.Col(feature).Float()[0])
}
// Add this centroid to our map.
centroids[species] = c
}
// As a sanity check, output our centroids.
for _, species := range speciesNames {
fmt.Printf("%s centroid: %v\n", species, centroids[species])
}
编译并运行此代码将给我们提供我们的质心:
$ go build
$ ./myprogram
Iris-setosa centroid: [5.005999999999999 3.4180000000000006 1.464 0.2439999999999999]
Iris-versicolor centroid: [5.936 2.7700000000000005 4.26 1.3259999999999998]
Iris-virginica centroid: [6.587999999999998 2.9739999999999998 5.552 2.026]
接下来,我们需要实际计算每个数据点的轮廓系数。为此,让我们修改前面的代码,以便我们可以在for loop
外部访问每个过滤后的数据点集:
// Create a map to hold the filtered dataframe for each cluster.
clusters := make(map[string]dataframe.DataFrame)
// Filter the dataset into three separate dataframes,
// each corresponding to one of the Iris species.
for _, species := range speciesNames {
...
// Add the filtered dataframe to the map of clusters.
clusters[species] = filtered
...
}
让我们再创建一个方便的函数来从dataframe.DataFrame
的行中检索浮点值:
// dfFloatRow retrieves a slice of float values from a DataFrame
// at the given index and for the given column names.
func dfFloatRow(df dataframe.DataFrame, names []string, idx int) []float64 {
var row []float64
for _, name := range names {
row = append(row, df.Col(name).Float()[idx])
}
return row
}
现在,我们可以遍历我们的记录,计算用于轮廓系数的a
和b
。我们还将计算轮廓系数的平均值,以获得我们簇的整体评估指标,如下面的代码所示:
// Convert our labels into a slice of strings and create a slice
// of float column names for convenience.
labels := irisDF.Col("species").Records()
floatColumns := []string{
"sepal_length",
"sepal_width",
"petal_length",
"petal_width",
}
// Loop over the records accumulating the average silhouette coefficient.
var silhouette float64
for idx, label := range labels {
// a will store our accumulated value for a.
var a float64
// Loop over the data points in the same cluster.
for i := 0; i < clusters[label].Nrow(); i++ {
// Get the data point for comparison.
current := dfFloatRow(irisDF, floatColumns, idx)
other := dfFloatRow(clusters[label], floatColumns, i)
// Add to a.
a += floats.Distance(current, other, 2) / float64(clusters[label].Nrow())
}
// Determine the nearest other cluster.
var otherCluster string
var distanceToCluster float64
for _, species := range speciesNames {
// Skip the cluster containing the data point.
if species == label {
continue
}
// Calculate the distance to the cluster from the current cluster.
distanceForThisCluster := floats.Distance(centroids[label], centroids[species], 2)
// Replace the current cluster if relevant.
if distanceToCluster == 0.0 || distanceForThisCluster < distanceToCluster {
otherCluster = species
distanceToCluster = distanceForThisCluster
}
}
// b will store our accumulated value for b.
var b float64
// Loop over the data points in the nearest other cluster.
for i := 0; i < clusters[otherCluster].Nrow(); i++ {
// Get the data point for comparison.
current := dfFloatRow(irisDF, floatColumns, idx)
other := dfFloatRow(clusters[otherCluster], floatColumns, i)
// Add to b.
b += floats.Distance(current, other, 2) / float64(clusters[otherCluster].Nrow())
}
// Add to the average silhouette coefficient.
if a > b {
silhouette += ((b - a) / a) / float64(len(labels))
}
silhouette += ((b - a) / b) / float64(len(labels))
}
// Output the final average silhouette coeffcient to stdout.
fmt.Printf("\nAverage Silhouette Coefficient: %0.2f\n\n", silhouette)
编译并运行此示例评估会产生以下结果:
$ go build
$ ./myprogram
Average Silhouette Coefficient: 0.51
我们如何知道0.51
是否是一个好或坏的轮廓系数平均值?嗯,记住轮廓系数与平均簇内距离和平均簇间距离之间的差异成正比,它总是在0.0
和1.0
之间。因此,更高的值(那些接近1.0
的值)意味着簇更紧密地堆积,并且与其他簇更明显。
通常,我们可能想要调整我们的簇数量和/或聚类技术以优化轮廓分数(使其更大)。在这里,我们正在处理实际手工标记的数据,所以0.51
对于这个数据集来说必须是一个好分数。对于其他数据集,它可能更高或更低,这取决于数据中簇的存在以及你选择的相似性度量。
轮廓分数绝对不是评估我们簇内部结构的唯一方法。我们实际上可以使用轮廓分数中的a
或b
量来评估我们簇的同质性,或者每个簇与其他簇的不相似性。此外,我们可以使用簇中点与簇质心的平均距离来衡量紧密堆积的簇。更进一步,我们可以使用各种其他评估指标,这些指标在此不详细讨论,例如Calinski-Harabaz 指数(进一步讨论见:datamining.rutgers.edu/publication/internalmeasures.pdf
)。
外部聚类评估
如果我们有我们簇的地面真相或黄金标准,那么我们可以利用各种外部聚类评估技术。这个地面真相或黄金标准意味着我们可以访问,或者可以通过人工标注获得,一组数据点,其中已标注了真实的或期望的簇标签。
通常,我们无法访问这种聚类黄金标准,因此我们不会在这里详细讨论这些评估技术。然而,如果你感兴趣或相关,你可以查看调整后的兰德指数、互信息、Fowlkes-Mallows 分数、完整性和V 度量,这些都是相关的外部聚类评估指标(更多详细信息请参阅:nlp.stanford.edu/IR-book/html/htmledition/evaluation-of-clustering-1.html
)。
k-means 聚类
我们在这里将要介绍的第一种聚类技术,可能是最著名的聚类技术,被称为 k-means 聚类,或简称 k-means。k-means 是一种迭代方法,其中数据点围绕在每次迭代中调整的簇质心周围聚类。这个技术相对容易理解,但有一些相关的细微差别很容易忽略。我们将确保在探讨这个技术时突出这些内容。
由于 k-means 聚类很容易实现,因此有大量的算法实现示例在 Go 中。您可以通过在此链接上搜索 k-means 来找到这些示例(golanglibs.com/top?q=kmeans
)。然而,我们将使用一个最近且相对简单易用的实现,github.com/mash/gokmeans
。
k-means 聚类概述
假设我们有一组由两个变量 x[1] 和 x[2] 定义的数据点。这些数据点自然地表现出一些聚类,如下面的图所示:
要使用 k-means 算法自动聚类这些点,我们首先需要选择聚类将产生多少个簇。这是参数 k,它赋予了 k-means 算法其名称。在这种情况下,让我们使用 k = 3。
然后,我们将随机选择 k 个质心的 x[1] 和 x[2] 位置。这些随机质心将作为算法的起点。以下图中的 Xs 显示了这些随机质心:
为了优化这些质心并聚类我们的点,我们接下来迭代执行以下操作:
-
将每个数据点分配到与最近质心相对应的簇(根据我们选择的距离度量,如欧几里得距离)。
-
计算每个簇内 x[1] 和 x[2] 位置的均值。
-
将每个质心的位置更新为计算出的 x[1] 和 x[2] 位置。
重复步骤一至三,直到步骤一中的分配不再改变。这个过程在下面的图中得到了说明:
你可以用静态图说明的只有这么多,但希望这有所帮助。如果您想通过可视化 k-means 过程来更好地理解更新,您应该查看 stanford.edu/class/ee103/visualizations/kmeans/kmeans.html
,其中包含 k-means 聚类过程的交互式动画。
k-means 假设和陷阱
k-means 算法可能看起来非常简单,它确实是。然而,它确实对您的数据做出了一些潜在的假设,这些假设很容易被忽视:
- 球形 或 空间分组聚类:k-means 基本上在我们的特征空间中绘制球形或空间接近的区域以找到聚类。这意味着对于非球形聚类(本质上,在我们特征空间中看起来不像分组团块的聚类),k-means 很可能失败。为了使这个想法更具体,k-means 很可能表现不佳的非球形聚类可能看起来如下:
- 相似大小:k-means 还假设您的聚类大小相似。小的异常聚类可能导致简单的 k-means 算法偏离轨道,产生奇怪的分组。
此外,在使用 k-means 对数据进行聚类时,我们可能会陷入几个陷阱:
-
k 的选择取决于我们。这意味着我们可能选择一个不合理的 k,但也意味着我们可以继续增加 k,直到每个点都有一个聚类(这将是一个非常不错的聚类,因为每个点都与其自身完全相同)。为了帮助您选择 k,您应该利用 肘图 方法。在这种方法中,您在计算评估指标的同时增加 k。随着 k 的增加,您的评估指标应该持续改善,但最终会出现一个拐点,表明收益递减。理想的 k 就在这个拐点。
-
并不能保证 k-means 总是收敛到相同的聚类。由于您是从随机质心开始的,您的 k-means 算法可能在不同的运行中收敛到不同的局部最小值。您应该意识到这一点,并从不同的初始化中运行 k-means 算法以确保稳定性。
k-means 聚类示例
我们将使用的数据集来展示聚类技术是关于快递司机的。数据集看起来是这样的:
$ head fleet_data.csv
Driver_ID,Distance_Feature,Speeding_Feature
3423311935,71.24,28.0
3423313212,52.53,25.0
3423313724,64.54,27.0
3423311373,55.69,22.0
3423310999,54.58,25.0
3423313857,41.91,10.0
3423312432,58.64,20.0
3423311434,52.02,8.0
3423311328,31.25,34.0
第一列,Driver_ID
,包括各种特定的司机的匿名标识。第二列和第三列是我们将在聚类中使用的属性。Distance_Feature
列是每次数据的平均行驶距离,而 Speeding_Feature
是司机在速度限制以上行驶 5+ 英里每小时的时间百分比的平均百分比。
聚类的目标将是根据 Distance_Feature
和 Speeding_Feature
将快递司机聚类成组。记住,这是一个无监督学习技术,因此我们实际上并不知道数据中应该或可能形成哪些聚类。希望我们能从练习开始时不知道的司机那里学到一些东西。
数据分析
是的,你猜对了!我们有一个新的数据集,我们需要对这个数据集进行配置,以便更多地了解它。让我们首先使用 github.com/kniren/dataframe
来计算摘要统计信息,并使用 gonum.org/v1/plot
创建每个特征的直方图。我们已经在第四章,回归和第五章,分类中多次这样做,所以这里我们不会重复代码。让我们看看结果:
$ go build
$ ./myprogram
[7x4] DataFrame
column Driver_ID Distance_Feature Speeding_Feature
0: mean 3423312447.500000 76.041523 10.721000
1: stddev 1154.844867 53.469563 13.708543
2: min 3423310448.000000 15.520000 0.000000
3: 25% 3423311447.000000 45.240000 4.000000
4: 50% 3423312447.000000 53.330000 6.000000
5: 75% 3423313447.000000 65.610000 9.000000
6: max 3423314447.000000 244.790000 100.000000
<string> <float> <float> <float>
哇!看起来大多数司机大约 10%的时间会超速,这有点可怕。有一位司机似乎 100%的时间都在超速。我希望我不要在他的路线上。
直方图特征在以下图中显示:
看起来在 Distance_Feature
数据中有一个有趣的结构。这实际上很快就会在我们的聚类中起作用,但我们可以通过创建特征空间的散点图来获取这个结构的另一个视角:
// Open the driver dataset file.
f, err := os.Open("fleet_data.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a dataframe from the CSV file.
driverDF := dataframe.ReadCSV(f)
// Extract the distance column.
yVals := driverDF.Col("Distance_Feature").Float()
// pts will hold the values for plotting
pts := make(plotter.XYs, driverDF.Nrow())
// Fill pts with data.
for i, floatVal := range driverDF.Col("Speeding_Feature").Float() {
pts[i].X = floatVal
pts[i].Y = yVals[i]
}
// Create the plot.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.X.Label.Text = "Speeding"
p.Y.Label.Text = "Distance"
p.Add(plotter.NewGrid())
s, err := plotter.NewScatter(pts)
if err != nil {
log.Fatal(err)
}
s.GlyphStyle.Color = color.RGBA{R: 255, B: 128, A: 255}
s.GlyphStyle.Radius = vg.Points(3)
// Save the plot to a PNG file.
p.Add(s)
if err := p.Save(4*vg.Inch, 4*vg.Inch, "fleet_data_scatter.png"); err != nil {
log.Fatal(err)
}
编译并运行它创建以下散点图:
在这里,我们可以看到比直方图中更多的一些结构。这里似乎至少有两个清晰的数据簇。关于我们数据的这种直觉可以在我们正式应用聚类技术时作为一个心理检查,并且它可以给我们提供一个实验 k 值的起点。
使用 k-means 生成聚类
现在,让我们通过实际应用 k-means 聚类到配送司机数据上来动手实践。为了利用 github.com/mash/gokmeans
,我们首先需要创建一个 gokmeans.Node
值的切片,这将作为聚类的输入:
// Open the driver dataset file.
f, err := os.Open("fleet_data.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader.
r := csv.NewReader(f)
r.FieldsPerRecord = 3
// Initialize a slice of gokmeans.Node's to
// hold our input data.
var data []gokmeans.Node
// Loop over the records creating our slice of
// gokmeans.Node's.
for {
// Read in our record and check for errors.
record, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
log.Fatal(err)
}
// Skip the header.
if record[0] == "Driver_ID" {
continue
}
// Initialize a point.
var point []float64
// Fill in our point.
for i := 1; i < 3; i++ {
// Parse the float value.
val, err := strconv.ParseFloat(record[i], 64)
if err != nil {
log.Fatal(err)
}
// Append this value to our point.
point = append(point, val)
}
// Append our point to the data.
data = append(data, gokmeans.Node{point[0], point[1]})
}
然后,生成我们的聚类就像调用 gomeans.Train(...)
函数一样简单。具体来说,我们将使用 k = 2 和最大 50
次迭代来调用此函数:
// Generate our clusters with k-means.
success, centroids := gokmeans.Train(data, 2, 50)
if !success {
log.Fatal("Could not generate clusters")
}
// Output the centroids to stdout.
fmt.Println("The centroids for our clusters are:")
for _, centroid := range centroids {
fmt.Println(centroid)
}
运行所有这些,得到以下生成的聚类的中心点:
$ go build
$ ./myprogram
The centroids for our clusters are:
[50.04763437499999 8.82875]
[180.01707499999992 18.29]
太好了!我们已经生成了我们的第一个聚类。现在,我们需要继续评估这些聚类的合法性。
我在这里只输出了聚类的中心点,因为那是我们真正需要知道组点的。如果我们想知道一个数据点是在第一个还是第二个簇中,我们只需要计算到那些中心点的距离。中心点越接近,对应的就是包含数据点的组。
评估生成的聚类
我们可以评估我们刚刚生成的聚类的第一种方式是视觉上的。让我们创建另一个散点图。然而,这次让我们为每个组使用不同的形状:
// Open the driver dataset file.
f, err := os.Open("fleet_data.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a dataframe from the CSV file.
driverDF := dataframe.ReadCSV(f)
// Extract the distance column.
yVals := driverDF.Col("Distance_Feature").Float()
// clusterOne and clusterTwo will hold the values for plotting.
var clusterOne [][]float64
var clusterTwo [][]float64
// Fill the clusters with data.
for i, xVal := range driverDF.Col("Speeding_Feature").Float() {
distanceOne := floats.Distance([]float64{yVals[i], xVal}, []float64{50.05, 8.83}, 2)
distanceTwo := floats.Distance([]float64{yVals[i], xVal}, []float64{180.02, 18.29}, 2)
if distanceOne < distanceTwo {
clusterOne = append(clusterOne, []float64{xVal, yVals[i]})
continue
}
clusterTwo = append(clusterTwo, []float64{xVal, yVals[i]})
}
// pts* will hold the values for plotting
ptsOne := make(plotter.XYs, len(clusterOne))
ptsTwo := make(plotter.XYs, len(clusterTwo))
// Fill pts with data.
for i, point := range clusterOne {
ptsOne[i].X = point[0]
ptsOne[i].Y = point[1]
}
for i, point := range clusterTwo {
ptsTwo[i].X = point[0]
ptsTwo[i].Y = point[1]
}
// Create the plot.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.X.Label.Text = "Speeding"
p.Y.Label.Text = "Distance"
p.Add(plotter.NewGrid())
sOne, err := plotter.NewScatter(ptsOne)
if err != nil {
log.Fatal(err)
}
sOne.GlyphStyle.Radius = vg.Points(3)
sOne.GlyphStyle.Shape = draw.PyramidGlyph{}
sTwo, err := plotter.NewScatter(ptsTwo)
if err != nil {
log.Fatal(err)
}
sTwo.GlyphStyle.Radius = vg.Points(3)
// Save the plot to a PNG file.
p.Add(sOne, sTwo)
if err := p.Save(4*vg.Inch, 4*vg.Inch, "fleet_data_clusters.png"); err != nil {
log.Fatal(err)
}
这段代码生成了以下散点图,清楚地显示了我们的成功聚类:
定性来看,我们可以看到有一个主要驾驶短距离的司机聚类,还有一个主要驾驶长距离的司机聚类。这实际上分别对应于农村和城市配送司机(或短途和长途司机)。
为了更定量地评估我们的聚类,我们可以计算聚类内点与聚类质心的平均距离。为了帮助我们完成这项任务,让我们创建一个将使事情变得更容易的函数:
// withinClusterMean calculates the mean distance between
// points in a cluster and the centroid of the cluster.
func withinClusterMean(cluster [][]float64, centroid []float64) float64 {
// meanDistance will hold our result.
var meanDistance float64
// Loop over the points in the cluster.
for _, point := range cluster {
meanDistance += floats.Distance(point, centroid, 2) / float64(len(cluster))
}
return meanDistance
}
现在,为了评估我们的聚类,我们只需为每个聚类调用此函数:
// Output our within cluster metrics.
fmt.Printf("\nCluster 1 Metric: %0.2f\n", withinClusterMean(clusterOne, []float64{50.05, 8.83}))
fmt.Printf("\nCluster 2 Metric: %0.2f\n", withinClusterMean(clusterTwo, []float64{180.02, 18.29}))
运行此操作会给我们以下指标:
$ go build
$ ./myprogram
Cluster 1 Metric: 11.68
Cluster 2 Metric: 23.52
如我们所见,第一个聚类(散点图中的粉色聚类)比第二个聚类紧凑约两倍(即紧密堆积)。这一点在没有图表的情况下也是一致的,并为我们提供了关于聚类的更多定量信息。
注意,在这里很明显我们正在寻找两个聚类。然而,在其他情况下,聚类的数量可能一开始并不明确,尤其是在你拥有的特征多于你能可视化的情况下。在这些场景中,利用一种方法,如elbow
方法,来确定合适的k是很重要的。关于此方法的更多信息可以在datasciencelab.wordpress.com/2013/12/27/finding-the-k-in-k-means-clustering/
找到。
其他聚类技术
在这里没有讨论的其他聚类技术有很多。这些包括 DBSCAN 和层次聚类。不幸的是,Go 语言中当前对这些其他聚类选项的实现有限。DBSCAN 在https://github.com/sjwhitworth/golearn
中实现,但据我所知,目前还没有其他聚类技术的实现。
这为社区贡献创造了极好的机会!聚类技术通常并不复杂,实现另一种聚类技术的实现可能是回馈 Go 数据科学社区的一种很好的方式。如果您想讨论实现、提问或寻求帮助,请随时在 Gophers Slack(@dwhitena
)或 Gophers Slack 的#data-science
频道联系作者或其他数据科学爱好者!
参考文献
距离度量与聚类评估:
-
聚类评估概述:
nlp.stanford.edu/IR-book/html/htmledition/evaluation-of-clustering-1.html
-
各种距离/相似度度量的比较:
journals.plos.org/plosone/article?id=10.1371/journal.pone.0144059
-
可视化 k-means 聚类:
www.naftaliharris.com/blog/visualizing-k-means-clustering/
-
github.com/mash/gokmeans
文档:godoc.org/github.com/mash/gokmeans
摘要
在本章中,我们介绍了聚类的通用原则,学习了如何评估生成的聚类,以及如何使用 Go 语言实现的 k-means 聚类。现在,你应该能够很好地检测数据集中的分组结构。
接下来,我们将讨论时间序列数据的建模,例如股票价格、传感器数据等。
第七章:时间序列与异常检测
我们到目前为止讨论的大多数模型都是基于与某个事物相关的其他属性来预测该事物的某个属性。例如,我们根据花朵的测量值预测了花朵的物种。我们还尝试根据患者的医疗属性来预测患者糖尿病的进展。
时间序列建模的前提与这些类型的属性预测问题不同。简单来说,时间序列建模帮助我们根据过去的属性来预测未来。例如,我们可能想根据该股票价格的历史值来预测未来的股票价格,或者我们可能想根据之前某个时间点我们网站上用户数量的数据来预测在某个时间点会有多少用户访问我们的网站。这有时被称为预测。
时间序列建模中使用的数据通常与分类、回归或聚类中使用的数据不同。时间序列模型基于一个或多个时间序列,正如人们所期望的那样。这个序列是一系列按顺序排列的属性、属性或其他数字,它们与相应的日期和时间或日期和时间的代理(例如测量索引或天数)配对。对于股票价格,这个序列将包括一系列(日期和时间,股票价格)配对。
这种时间序列数据在工业和学术界无处不在。随着我们探索和发展物联网(IoT),它也变得越来越重要。健身追踪器、智能设备,如冰箱、恒温器、摄像头、无人机以及许多其他新设备,正在产生令人震惊的大量时间序列数据。
当然,你不必仅限于使用这类数据来预测未来。你可以用时间序列数据做很多其他有用的事情,包括稍后在本章中将要介绍的异常检测。异常检测试图检测时间序列中的意外或非同寻常的事件。这些事件可能对应于灾难性的天气事件、基础设施故障、病毒式社交媒体行为等等。
在 Go 中表示时间序列数据
存在专门用于存储和处理时间序列数据的系统。其中一些甚至是用 Go 编写的,包括 Prometheus 和 InfluxDB。然而,我们在这本书中已经使用的一些工具也适合处理时间序列。具体来说,github.com/kniren/gota/dataframe
、gonum.org/v1/gonum/floats
和gonum.org/v1/gonum/mat
可以帮助我们在处理时间序列数据时。
以一个包括 1949-1960 年期间国际航空旅客数量时间序列的数据集为例(可在raw.github.com/vincentarelbundock/Rdatasets/master/csv/datasets/AirPassengers.csv
下载):
$ head AirPassengers.csv
time,AirPassengers
1949.0,112
1949.08333333,118
1949.16666667,132
1949.25,129
1949.33333333,121
1949.41666667,135
1949.5,148
1949.58333333,148
1949.66666667,136
在这里,time
列包含一系列由年份和十进制数表示的时间,而AirPassengers
列包含在那个time
时刻的国际航空旅客数量。换句话说,这是一个具有(时间,乘客数量)配对的时序。
这只是表格数据,我们可以用 dataframe 或矩阵完美地表示它。为了简单起见,让我们使用 dataframe,如下面的代码所示:
// Open the CSV file.
passengersFile, err := os.Open("AirPassengers.csv")
if err != nil {
log.Fatal(err)
}
defer passengersFile.Close()
// Create a dataframe from the CSV file.
passengersDF := dataframe.ReadCSV(passengersFile)
// As a sanity check, display the records to stdout.
// Gota will format the dataframe for pretty printing.
fmt.Println(passengersDF)
这将产生以下输出:
$ go build
$ ./myprogram
[144x2] DataFrame
time AirPassengers
0: 1949.000000 112
1: 1949.083333 118
2: 1949.166667 132
3: 1949.250000 129
4: 1949.333333 121
5: 1949.416667 135
6: 1949.500000 148
7: 1949.583333 148
8: 1949.666667 136
9: 1949.750000 119
... ...
<float> <int>
我们可以用gonum.org/v1/gonum/mat
类似地表示这个序列,并在需要时将 dataframe 转换为浮点数切片,用于gonum.org/v1/gonum/floats
。如果我们想绘制时间序列,例如,我们可以将列转换为浮点数,并用gonum.org/v1/plot
生成一个图表,如下面的代码所示:
// Open the CSV file.
passengersFile, err := os.Open("AirPassengers.csv")
if err != nil {
log.Fatal(err)
}
defer passengersFile.Close()
// Create a dataframe from the CSV file.
passengersDF := dataframe.ReadCSV(passengersFile)
// Extract the number of passengers column.
yVals := passengersDF.Col("AirPassengers").Float()
// pts will hold the values for plotting.
pts := make(plotter.XYs, passengersDF.Nrow())
// Fill pts with data.
for i, floatVal := range passengersDF.Col("time").Float() {
pts[i].X = floatVal
pts[i].Y = yVals[i]
}
// Create the plot.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.X.Label.Text = "time"
p.Y.Label.Text = "passengers"
p.Add(plotter.NewGrid())
// Add the line plot points for the time series.
l, err := plotter.NewLine(pts)
if err != nil {
log.Fatal(err)
}
l.LineStyle.Width = vg.Points(1)
l.LineStyle.Color = color.RGBA{B: 255, A: 255}
// Save the plot to a PNG file.
p.Add(l)
if err := p.Save(10*vg.Inch, 4*vg.Inch, "passengers_ts.png"); err != nil {
log.Fatal(err)
}
编译并运行此程序会产生以下时间序列的图表:
如预期的那样,随着时间的推移,越来越多的人开始通过飞机旅行,国际航空旅客的数量也在增加。我们还可以看到,似乎存在重复出现的波峰或峰值。我们将在稍后更深入地探讨这些特征。
理解时间序列术语
到这本书的这一部分,你可能已经注意到每一套机器学习技术都有一套相关的术语,时间序列也不例外。
这里是对本章余下部分将要用到的部分术语的解释:
-
时间、日期时间或时间戳:这个属性是我们时间序列中每一对的时间元素。这可以简单地是一个时间,也可以是日期和时间的组合(有时称为日期时间或时间戳)。它也可能包括时区。
-
观测值、测量值、信号或随机变量:这是我们试图作为时间函数预测和/或分析的性质。
-
季节性:像航空旅客数据这样的时间序列可能会表现出与季节(周、月、年等)相对应的变化。以这种方式表现的时间序列被称为表现出某种季节性。
-
趋势:随着时间的推移逐渐增加或减少的时间序列(与季节性效应分开)被称为表现出趋势。
-
平稳性:如果一个时间序列在时间上表现出相同的模式,没有趋势或其他逐渐变化(如方差或协方差的变化),则称其为平稳的。
-
时间周期:时间序列中连续观测之间的时间量,或者系列中一个时间戳与之前发生的时间戳之间的差异。
-
自回归模型:这是一个试图通过一个或多个延迟或滞后版本的同过程来模拟时间序列过程的模型。例如,股票价格的自回归模型会试图通过股票价格在先前时间间隔的值来模拟股票价格。
-
移动平均模型:这是一个试图根据一个不完全可预测的项的当前和过去各种值来模拟时间序列的模型,这个项通常被称为误差。例如,这个不完全可预测的项可能是时间序列中的某些白噪声。
与时间序列相关的统计
除了与时间序列相关的一些术语之外,还有一些重要的与时间序列相关的统计,我们在进行预测和异常检测时将依赖这些统计。这些统计主要与时间序列中的值如何与其他值相关联有关。
这些统计将帮助我们分析数据,这是任何时间序列建模项目的重要部分,正如我们在其他类型的建模中所做的那样。了解你的时间序列随时间、季节性和趋势的行为,对于确保你应用适当的模型并对你结果进行心理检查至关重要。
自相关
自相关是衡量一个信号与其延迟版本的相关程度。例如,一个或多个股票价格的先前观测值可能与下一个股票价格的观测值相关(或一起变化)。如果这种情况发生,我们就会说股票价格根据某些滞后或延迟影响了自己。然后我们可以通过在特定滞后中指示为高度相关的滞后版本来模拟未来的股票价格。
为了测量变量 x[t] 与其延迟版本(或滞后版本)x[s] 的自相关,我们可以利用自相关函数(ACF),其定义如下:
在这里,s 可以代表 x 的任何滞后版本。因此,我们可以计算 x 与滞后一个时间周期的 x 版本(x[t-1])之间的自相关,与滞后两个时间周期的 x 版本(x[t-2])之间的自相关,依此类推。这样做可以告诉我们哪些延迟版本的 x 与 x 最相关,从而帮助我们确定哪些延迟版本的 x 可能是用于模拟 x 未来版本的好候选者。
让我们尝试计算我们的航空公司乘客时间序列与其自身的第一个几个自相关系数。为此,我们首先需要创建一个函数,该函数将计算特定时间段滞后的时间序列中的自相关系数。以下是该函数的一个示例实现:
// acf calculates the autocorrelation for a series
// at the given lag.
func acf(x []float64, lag int) float64 {
// Shift the series.
xAdj := x[lag:len(x)]
xLag := x[0 : len(x)-lag]
// numerator will hold our accumulated numerator, and
// denominator will hold our accumulated denominator.
var numerator float64
var denominator float64
// Calculate the mean of our x values, which will be used
// in each term of the autocorrelation.
xBar := stat.Mean(x, nil)
// Calculate the numerator.
for idx, xVal := range xAdj {
numerator += ((xVal - xBar) * (xLag[idx] - xBar))
}
// Calculate the denominator.
for _, xVal := range x {
denominator += math.Pow(xVal-xBar, 2)
}
return numerator / denominator
}
然后,我们将遍历几个滞后,并利用acf()
函数计算各种自相关系数。这个过程在以下代码中显示:
// Open the CSV file.
passengersFile, err := os.Open("AirPassengers.csv")
if err != nil {
log.Fatal(err)
}
defer passengersFile.Close()
// Create a dataframe from the CSV file.
passengersDF := dataframe.ReadCSV(passengersFile)
// Get the time and passengers as a slice of floats.
passengers := passengersDF.Col("AirPassengers").Float()
// Loop over various values of lag in the series.
fmt.Println("Autocorrelation:")
for i := 1; i < 11; i++ {
// Shift the series.
adjusted := passengers[i:len(passengers)]
lag := passengers[0 : len(passengers)-i]
// Calculate the autocorrelation.
ac := stat.Correlation(adjusted, lag, nil)
fmt.Printf("Lag %d period: %0.2f\n", i, ac)
}
这会产生以下结果:
$ go build
$ ./myprogram
Autocorrelation:
Lag 1 period: 0.95
Lag 2 period: 0.88
Lag 3 period: 0.81
Lag 4 period: 0.75
Lag 5 period: 0.71
Lag 6 period: 0.68
Lag 7 period: 0.66
Lag 8 period: 0.66
Lag 9 period: 0.67
Lag 10 period: 0.70
如我们所见,序列中更早的滞后处的自相关系数往往较小(尽管,这并不是每个滞后都如此)。然而,这种信息在数值形式上可能有点难以吸收。让我们将这些值作为滞后函数的函数来绘制,以更好地可视化相关性:
// Open the CSV file.
passengersFile, err := os.Open("AirPassengers.csv")
if err != nil {
log.Fatal(err)
}
defer passengersFile.Close()
// Create a dataframe from the CSV file.
passengersDF := dataframe.ReadCSV(passengersFile)
// Get the time and passengers as a slice of floats.
passengers := passengersDF.Col("AirPassengers").Float()
// Create a new plot, to plot our autocorrelations.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.Title.Text = "Autocorrelations for AirPassengers"
p.X.Label.Text = "Lag"
p.Y.Label.Text = "ACF"
p.Y.Min = 0
p.Y.Max = 1
w := vg.Points(3)
// Create the points for plotting.
numLags := 20
pts := make(plotter.Values, numLags)
// Loop over various values of lag in the series.
for i := 1; i <= numLags; i++ {
// Calculate the autocorrelation.
pts[i-1] = acf(passengers, i)
}
// Add the points to the plot.
bars, err := plotter.NewBarChart(pts, w)
if err != nil {
log.Fatal(err)
}
bars.LineStyle.Width = vg.Length(0)
bars.Color = plotutil.Color(1)
// Save the plot to a PNG file.
p.Add(bars)
if err := p.Save(8*vg.Inch, 4*vg.Inch, "acf.png"); err != nil {
log.Fatal(err)
}
这段代码产生了以下 ACF 图的绘制:
注意到自相关系数总体上是下降的,但它们保持相当大(远高于 0.5),甚至到 20 个时间周期的滞后。这是我们的时间序列不是平稳的一个迹象。实际上,如果我们看我们时间序列的先前图,它显然是向上趋势的。我们将在本章的后面部分处理这种非平稳行为,但就目前而言,我们可以说 ACF 图正在向我们表明,乘客数量的滞后版本与其非延迟版本是相关的。
更一般地说,自相关函数将使我们能够确定我们正在建模的时间序列类型。对于一个可以用自回归模型很好地建模的过程,我们应该看到acf
函数随着滞后时间的增加而迅速下降,但不是立即下降。对于一个可以用所谓的移动平均模型很好地建模的过程,我们会在第一个滞后处看到一个显著的 ACF 项,但之后 ACF 会在第一个滞后之后消失。
关于如何解释自相关函数(ACF)图的信息,请参阅coolstatsblog.com/2013/08/07/how-to-use-the-autocorreation-function-acf/
。这篇文章提供了一些很好的细节,其中一些我们在这里无法涵盖。
部分自相关
如你所料,部分自相关与自相关相关,但也有一些细微的差别。部分意味着这是一种条件性的相关。本质上,部分自相关是在减去中间滞后处的自相关之后,测量序列与其自身在某个滞后处的相关程度。你可以将其视为在移除中间相关之后剩余的自相关。
我们可能想要这样的原因是我们需要比 ACF 更多的信息来确定时间序列模型的阶数,假设它可以由自回归模型来建模。假设我们使用 ACF 确定我们可以通过自回归模型来建模我们的序列,因为 ACF 随着滞后时间的增加而指数衰减。我们如何知道我们应该通过自身滞后一个时间周期的版本来建模这个时间序列,或者是一个滞后一个时间周期和一个滞后两个时间周期的版本,依此类推?
通过减去中间相关性,我们能够快速确定任何剩余的相关性,这些相关性可以用具有更多项的自回归模型来建模。如果偏自相关性在第一个滞后之后消失,我们知道我们可以根据自身的一个滞后版本(滞后一个时间周期)来建模我们的序列。然而,如果偏自相关性在第一个滞后之后没有消失,我们知道我们需要在我们的自回归模型中采用多个时间序列的滞后版本。
如果我们假设通过时间序列中逐次增加的滞后值(x[t-1], x[t-2], 以及等等)线性建模时间序列中的值(x[t]),我们的方程将如下所示:
各种系数m[1], m[2],等等,分别是一个时间周期的滞后偏自相关性,两个时间周期的滞后偏自相关性,依此类推。因此,我们为了计算某个滞后期的偏自相关性,只需要估计出给我们相应系数的线性回归公式。执行这种计算的功能被称为偏自相关函数(PACF)。
使用我们最喜欢的线性回归包github.com/sajari/regression
,我们可以创建一个 Go 函数来实现 PACF,如下所示:
// pacf calculates the partial autocorrelation for a series
// at the given lag.
func pacf(x []float64, lag int) float64 {
// Create a regresssion.Regression value needed to train
// a model using github.com/sajari/regression.
var r regression.Regression
r.SetObserved("x")
// Define the current lag and all of the intermediate lags.
for i := 0; i < lag; i++ {
r.SetVar(i, "x"+strconv.Itoa(i))
}
// Shift the series.
xAdj := x[lag:len(x)]
// Loop over the series creating the data set
// for the regression.
for i, xVal := range xAdj {
// Loop over the intermediate lags to build up
// our independent variables.
laggedVariables := make([]float64, lag)
for idx := 1; idx <= lag; idx++ {
// Get the lagged series variables.
laggedVariables[idx-1] = x[lag+i-idx]
}
// Add these points to the regression value.
r.Train(regression.DataPoint(xVal, laggedVariables))
}
// Fit the regression.
r.Run()
return r.Coeff(lag)
}
然后,我们可以使用这个pacf
函数来计算一些偏自相关值,这些值对应于我们之前计算自相关性的滞后。如下所示:
// Open the CSV file.
passengersFile, err := os.Open("AirPassengers.csv")
if err != nil {
log.Fatal(err)
}
defer passengersFile.Close()
// Create a dataframe from the CSV file.
passengersDF := dataframe.ReadCSV(passengersFile)
// Get the time and passengers as a slice of floats.
passengers := passengersDF.Col("AirPassengers").Float()
// Loop over various values of lag in the series.
fmt.Println("Partial Autocorrelation:")
for i := 1; i < 11; i++ {
// Calculate the partial autocorrelation.
pac := pacf(passengers, i)
fmt.Printf("Lag %d period: %0.2f\n", i, pac)
}
编译并运行此代码将给出我们航空乘客时间序列中的以下偏自相关性
值:
$ go build
$ ./myprogram
Partial Autocorrelation:
Lag 1 period: 0.96
Lag 2 period: -0.33
Lag 3 period: 0.20
Lag 4 period: 0.15
Lag 5 period: 0.26
Lag 6 period: -0.03
Lag 7 period: 0.20
Lag 8 period: 0.16
Lag 9 period: 0.57
Lag 10 period: 0.29
如您所见,偏自相关在第二个滞后之后迅速衰减。这表明,在考虑了时间序列与其第一和第二个滞后之间的关系后,时间序列中剩余的关系并不多。偏自相关性不会精确地达到0.0,这是由于数据中的一些噪声所预期的。
为了帮助我们更好地可视化 PACF,让我们创建另一个图表。我们可以用与创建 ACF 图表完全相同的方式来做这件事,只是将pacf()
函数替换为acf()
函数。结果图表如下:
预测的自回归模型
我们将要使用的第一个模型类别,用于尝试预测我们的时间序列,被称为自回归(AR)模型。如前所述,我们试图根据时间序列中的一或多个先前点来建模时间序列中的数据点。因此,我们使用时间序列本身来建模时间序列。这种使用序列本身是区分 AR 方法与第四章中讨论的更一般回归方法的特点。回归。
自回归模型概述
你经常会看到将 AR 模型称为 AR(1)、AR(2)等等。这些数字对应于你用于时间序列建模的 AR 模型或过程的阶数,你可以通过进行自相关和偏自相关分析来确定这个阶数。
AR(1)模型试图根据同一时间序列中一个时间周期延迟的观察值来建模你的序列中的观察值:
AR(2)模型将如下所示:
AR(3)模型将添加另一个项,依此类推,所有这些都遵循相同的模式。
这些公式可能会让你想起线性回归,实际上我们在这里将使用与创建线性回归模型时相同的一些方法。然而,时间序列建模的独特方面不应被忽视。了解数据的时间相关元素(季节性、趋势、自相关等)以及它们如何影响 AR 模型是很重要的。
你用于建模时间序列的 AR 模型阶数可以通过查看 PACF 图来确定。在图中,你会看到 PACF 值衰减到并围绕零徘徊。看看需要多少滞后时间 PACF 开始围绕零徘徊,然后使用相应滞后数的 AR 阶数。
注意,一些用于绘制 PACF 和 ACF 的软件包包括表示各种滞后项统计显著性的水平线。这里我没有包括这些,但如果你想要定量确定 AR 模型中的阶数,你可能需要考虑在这里进一步讨论的计算:
www.itl.nist.gov/div898/handbook/eda/section3/autocopl.htm
和 www.itl.nist.gov/div898/handbook/pmc/section4/pmc4463.htm
.
自回归模型假设和陷阱
自回归模型的主要假设如下:
-
平稳性:AR 模型假设你的时间序列是平稳的。如果我们计划使用 AR 模型,我们不应该在数据中看到任何趋势。
-
遍历性:这个术语听起来很复杂,基本上意味着时间序列的统计属性,如均值和方差,不应随时间变化或漂移。
我们使用 AR 方法建模的任何时间序列都应该满足这些假设。然而,即使有些数据(如我们的航空旅客数据)不满足这些假设,我们也可以使用一些差分技巧来仍然利用 AR 模型。
自回归模型示例
我们将尝试使用自回归模型来建模我们的航空旅客数据。现在,我们已经知道我们在打破 AR 模型的一个假设,即我们的数据不是平稳的。然而,我们可以应用一个常见的技巧来使我们的序列平稳,这被称为差分。
转换为平稳序列
为了使我们的时间序列平稳,我们将创建一个代理时间序列,其中时间周期t的观测值是原始时间序列中时间周期t的观测值减去前一个观测值。让我们以这种方式差分每个观测值,然后绘制结果以查看是否消除了数据中的趋势。我们还将输出这个差分时间序列到一个新的*.csv
文件,如下面的代码所示:
// as slices of floats.
passengerVals := passengersDF.Col("AirPassengers").Float()
timeVals := passengersDF.Col("time").Float()
// pts will hold the values for plotting.
pts := make(plotter.XYs, passengersDF.Nrow()-1)
// differenced will hold our differenced values
// that will be output to a new CSV file.
var differenced [][]string
differenced = append(differenced, []string{"time", "differenced_passengers"})
// Fill pts with data.
for i := 1; i < len(passengerVals); i++ {
pts[i-1].X = timeVals[i]
pts[i-1].Y = passengerVals[i] - passengerVals[i-1]
differenced = append(differenced, []string{
strconv.FormatFloat(timeVals[i], 'f', -1, 64),
strconv.FormatFloat(passengerVals[i]-passengerVals[i-1], 'f', -1, 64),
})
}
// Create the plot.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.X.Label.Text = "time"
p.Y.Label.Text = "differenced passengers"
p.Add(plotter.NewGrid())
// Add the line plot points for the time series.
l, err := plotter.NewLine(pts)
if err != nil {
log.Fatal(err)
}
l.LineStyle.Width = vg.Points(1)
l.LineStyle.Color = color.RGBA{B: 255, A: 255}
// Save the plot to a PNG file.
p.Add(l)
if err := p.Save(10*vg.Inch, 4*vg.Inch, "diff_passengers_ts.png"); err != nil {
log.Fatal(err)
}
// Save the differenced data out to a new CSV.
f, err := os.Create("diff_series.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
w := csv.NewWriter(f)
w.WriteAll(differenced)
if err := w.Error(); err != nil {
log.Fatal(err)
}
这导致了以下图表:
在这里,我们可以看到我们基本上消除了原始时间序列中存在的上升趋势。然而,似乎仍然存在与方差相关的问题。差分时间序列似乎在时间变大时围绕均值有增加的方差,这违反了我们的遍历性假设。
为了处理方差增加的问题,我们可以进一步使用对数或幂变换来转换我们的时间序列,这种变换会惩罚时间序列后期较大的值。让我们添加这个对数变换,重新绘制差分序列的对数,然后将结果数据保存到名为log_diff_series.csv
的文件中。完成这个任务的代码与之前的代码片段相同,只是我们使用math.Log()
来转换每个值,所以这里就不详细说明了。以下是对应的图表:
太棒了!现在我们得到了一个看起来是平稳序列,我们可以在 AR 模型中使用它。注意,在这个例子中,我们大部分是定性分析这个序列,但存在一些用于平稳性的定量测试(例如 Dickey-Fuller 测试)。
我们在这里使用差分和对数变换来转换我们的数据。这使我们能够满足 AR 模型的前提假设,但它也使得我们的数据和最终模型变得稍微难以解释。与时间序列本身相比,思考差分时间序列的对数更困难。我们在这里有这个权衡的合理性,但这个权衡应该被注意,希望我们能在可能的情况下避免这种混淆。
分析自相关函数并选择 AR 阶数
现在我们有一个符合我们模型假设的平稳序列,让我们重新审视我们的 ACF 和 PACF 图,看看有什么变化。我们可以利用之前用来绘制 ACF 和 PACF 的相同代码,但这次我们将使用我们的转换序列。
下面是生成的 ACF 图:
下面是生成的 PACF 图:
我们首先注意到,随着滞后时间的增加,ACF 图不再从1.0缓慢衰减。ACF 图衰减并在0.0附近波动。我们将在下一节回到 ACF。
接下来,我们可以看到,PACF 也衰减到0.0并在此之后围绕 0.0 波动。为了选择我们 AR 模型的阶数,我们想要检查 PACF 图首次似乎穿过零线的地方。在我们的例子中,这似乎是在第二个滞后期之后,因此,我们可能想要考虑使用 AR(2)模型来模型这个时间序列的自回归。
拟合和评估 AR(2)模型
我们已经看到,PACF 为我们 AR 模型中各种阶数的系数。利用这一点,我们可以通过以下代码中显示的pacf()
函数的略微修改版本,获取我们模型中第一和第二滞后项的系数以及截距(或误差项):
// autoregressive calculates an AR model for a series
// at a given order.
func autoregressive(x []float64, lag int) ([]float64, float64) {
// Create a regresssion.Regression value needed to train
// a model using github.com/sajari/regression.
var r regression.Regression
r.SetObserved("x")
// Define the current lag and all of the intermediate lags.
for i := 0; i < lag; i++ {
r.SetVar(i, "x"+strconv.Itoa(i))
}
// Shift the series.
xAdj := x[lag:len(x)]
// Loop over the series creating the data set
// for the regression.
for i, xVal := range xAdj {
// Loop over the intermediate lags to build up
// our independent variables.
laggedVariables := make([]float64, lag)
for idx := 1; idx <= lag; idx++ {
// Get the lagged series variables.
laggedVariables[idx-1] = x[lag+i-idx]
}
// Add these points to the regression value.
r.Train(regression.DataPoint(xVal, laggedVariables))
}
// Fit the regression.
r.Run()
// coeff hold the coefficients for our lags.
var coeff []float64
for i := 1; i <= lag; i++ {
coeff = append(coeff, r.Coeff(i))
}
return coeff, r.Coeff(0)
}
然后,我们可以调用我们的对数差分序列以获取我们的训练 AR(2)模型系数:
// Open the CSV file.
passengersFile, err := os.Open("log_diff_series.csv")
if err != nil {
log.Fatal(err)
}
defer passengersFile.Close()
// Create a dataframe from the CSV file.
passengersDF := dataframe.ReadCSV(passengersFile)
// Get the time and passengers as a slice of floats.
passengers := passengersDF.Col("log_differenced_passengers").Float()
// Calculate the coefficients for lag 1 and 2 and
// our error.
coeffs, intercept := autoregressive(passengers, 2)
// Output the AR(2) model to stdout.
fmt.Printf("\nlog(x(t)) - log(x(t-1)) = %0.6f + lag1*%0.6f + lag2*%0.6f\n\n", intercept, coeffs[0], coeffs[1])
编译并运行此训练给出了以下关于差分乘客计数对数的 AR(2)公式:
$ go build
$ ./myprogram
log(x(t)) - log(x(t-1)) = 0.008159 + lag1*0.234953 + lag2*-0.173682
为了评估这个 AR(2)模型,我们可以计算平均绝对误差(MAE),类似于我们计算线性回归模型的方式。具体来说,我们将计算预测的乘客计数值与观察到的乘客计数值配对,然后计算误差并累计 MAE。
首先,让我们计算我们的转换(对数和差分)预测:
// Open the log differenced dataset file.
transFile, err := os.Open("log_diff_series.csv")
if err != nil {
log.Fatal(err)
}
defer transFile.Close()
// Create a CSV reader reading from the opened file.
transReader := csv.NewReader(transFile)
// Read in all of the CSV records
transReader.FieldsPerRecord = 2
transData, err := transReader.ReadAll()
if err != nil {
log.Fatal(err)
}
// Loop over the data predicting the transformed
// observations.
var transPredictions []float64
for i, _ := range transData {
// Skip the header and the first two observations
// (because we need two lags to make a prediction).
if i == 0 || i == 1 || i == 2 {
continue
}
// Parse the first lag.
lagOne, err := strconv.ParseFloat(transData[i-1][1], 64)
if err != nil {
log.Fatal(err)
}
// Parse the second lag.
lagTwo, err := strconv.ParseFloat(transData[i-2][1], 64)
if err != nil {
log.Fatal(err)
}
// Predict the transformed variable with our trained AR model.
transPredictions = append(transPredictions, 0.008159+0.234953*lagOne-0.173682*lagTwo)
}
现在,为了计算我们的 MAE,我们需要将这些预测转换回正常的乘客计数(这样我们就可以直接与原始时间序列进行比较)。我们的对数和差分数据的反向转换涉及在转换序列中计算累积总和,将它们加回到基础序列值上,然后取指数。这个反向转换,MAE 的累积,以及将点聚合以绘制我们的观察值和预测值的聚合如下:
// Open the original dataset file.
origFile, err := os.Open("AirPassengers.csv")
if err != nil {
log.Fatal(err)
}
defer origFile.Close()
// Create a CSV reader reading from the opened file.
origReader := csv.NewReader(origFile)
// Read in all of the CSV records
origReader.FieldsPerRecord = 2
origData, err := origReader.ReadAll()
if err != nil {
log.Fatal(err)
}
// pts* will hold the values for plotting.
ptsObs := make(plotter.XYs, len(transPredictions))
ptsPred := make(plotter.XYs, len(transPredictions))
// Reverse the transformation and calculate the MAE.
var mAE float64
var cumSum float64
for i := 4; i <= len(origData)-1; i++ {
// Parse the original observation.
observed, err := strconv.ParseFloat(origData[i][1], 64)
if err != nil {
log.Fatal(err)
}
// Parse the original date.
date, err := strconv.ParseFloat(origData[i][0], 64)
if err != nil {
log.Fatal(err)
}
// Get the cumulative sum up to the index in
// the transformed predictions.
cumSum += transPredictions[i-4]
// Calculate the reverse transformed prediction.
predicted := math.Exp(math.Log(observed) + cumSum)
// Accumulate the MAE.
mAE += math.Abs(observed-predicted) / float64(len(transPredictions))
// Fill in the points for plotting.
ptsObs[i-4].X = date
ptsPred[i-4].X = date
ptsObs[i-4].Y = observed
ptsPred[i-4].Y = predicted
}
然后,让我们输出 MAE 以突出显示,并保存观察值和预测值的线图:
// Output the MAE to standard out.
fmt.Printf("\nMAE = %0.2f\n\n", mAE)
// Create the plot.
p, err := plot.New()
if err != nil {
log.Fatal(err)
}
p.X.Label.Text = "time"
p.Y.Label.Text = "passengers"
p.Add(plotter.NewGrid())
// Add the line plot points for the time series.
lObs, err := plotter.NewLine(ptsObs)
if err != nil {
log.Fatal(err)
}
lObs.LineStyle.Width = vg.Points(1)
lPred, err := plotter.NewLine(ptsPred)
if err != nil {
log.Fatal(err)
}
lPred.LineStyle.Width = vg.Points(1)
lPred.LineStyle.Dashes = []vg.Length{vg.Points(5), vg.Points(5)}
// Save the plot to a PNG file.
p.Add(lObs, lPred)
p.Legend.Add("Observed", lObs)
p.Legend.Add("Predicted", lPred)
if err := p.Save(10*vg.Inch, 4*vg.Inch, "passengers_ts.png"); err != nil {
log.Fatal(err)
}
编译此代码并运行得到以下MAE
:
$ go build
$ ./myprogram
MAE = 355.20
如果您还记得我们最初对这个序列的可视化,乘客计数从略高于零到略高于 600。因此,大约 355 的 MAE 并不是很好。然而,为了更全面地了解我们的预测和观察结果如何对齐,让我们看一下前面代码生成的图表:
如您所见,我们的模型对航空乘客数量的预测过高,尤其是在时间推移的情况下。模型确实表现出了一些在原始数据中可以看到的结构,并产生了相似的趋势。然而,似乎我们需要一个稍微复杂一些的模型来更真实地表示这个序列。
没有模型是完美的,我们在这里尝试了一个相对简单的时序模型。我们坚持使用简单且可解释的模型是好事,但我们的评估结果可能会激励我们在实际场景中重构我们的模型。重构是好事!这意味着我们学到了一些东西。
自回归移动平均和其他时间序列模型
我们之前尝试的模型是一个相对简单的纯自回归模型。然而,我们并不局限于在时序模型中使用自回归或纯自回归。与其他在本书中涵盖的机器学习模型类别一样,存在一系列时序技术,我们无法在这里全部涵盖。然而,我们确实想提到一些值得探索的显著技术,您可以在跟进这些材料时探索它们。
自回归模型通常与称为移动平均模型的模型相结合。当这些模型结合在一起时,它们通常被称为自回归移动平均(ARMA)或自回归积分移动平均(ARIMA)模型。ARMA/ARIMA 模型的移动平均部分允许你捕捉到时间序列中的白噪声或其他误差项的影响,这实际上可以改善我们为航空乘客建立的 AR(2)模型。
不幸的是,在撰写此内容时,没有现成的 Go 语言包可以执行 ARIMA。如前所述,自回归部分相对简单,但移动平均拟合稍微复杂一些。这是一个很好的地方,可以跳进来做出贡献!
也有一些时间序列模型超出了 ARIMA 模型的范围。例如,Holt-Winters 方法试图通过预测方程和三个平滑方程来捕捉时间序列数据中的季节性。在github.com/datastream/holtwinters
和github.com/dgryski/go-holtwinters
中已有 Holt-Winters 方法的初步实现。这些可能需要进一步维护和产品化,但它们可以作为起点。
异常检测
如本章引言所述,我们可能并不总是对预测时间序列感兴趣。我们可能想检测时间序列中的异常行为。例如,我们可能想知道异常流量爆发何时出现在我们的网络中,或者我们可能希望在应用程序内部有异常数量的用户尝试某些操作时收到警报。这些事件可能与安全相关,也可能只是用来调整我们的基础设施或应用程序设置。
幸运的是,由于 Go 在监控和基础设施方面的历史应用,有各种基于 Go 的选项可以检测时间序列数据中的异常。这些工具已在生产环境中用于在监控基础设施和应用时检测异常行为,尽管这里无法提及所有工具,但我将突出介绍几个。
首先,InfluxDB (www.influxdata.com/
) 和 Prometheus (prometheus.io/
) 生态系统在异常检测方面提供了多种选择。InfluxDB 和 Prometheus 都提供了基于 Go 的开源时序数据库和相关工具。它们在监控基础设施和应用方面非常有用,在 Go 社区和非 Go 社区都有广泛的应用。例如,如果你对使用 InfluxDB 感兴趣,你可以使用 github.com/nathanielc/morgoth
进行异常检测。
此包实现了 损失计数算法(LCA)。在 Prometheus 方面,你可以利用基于查询的方法,如 prometheus.io/blog/2015/06/18/practical-anomaly-detection/
中进一步讨论的那样。
同时,也有许多独立的 Go 包用于异常检测,包括 github.com/lytics/anomalyzer
和 github.com/sec51/goanomaly
。更具体地说,github.com/lytics/anomalyzer
实现了多种测试来确定你的序列中的观察值是否异常,包括基于累积分布函数、自举排列、排列秩和、相对幅度等测试。
要使用 github.com/lytics/anomalyzer
检测异常,我们需要创建一些配置和一个 anomalyzer.Anomalyzer
值。一旦完成这些,检测异常就像在 anomalyzer.Anomalyzer
值上调用 Push()
方法一样简单,如下面的代码所示:
// Initialize an AnomalyzerConf value with
// configurations such as which anomaly detection
// methods we want to use.
conf := &anomalyzer.AnomalyzerConf{
Sensitivity: 0.1,
UpperBound: 5,
LowerBound: anomalyzer.NA, // ignore the lower bound
ActiveSize: 1,
NSeasons: 4,
Methods: []string{"diff", "fence", "highrank", "lowrank", "magnitude"},
}
// Create a time series of periodic observations
// as a slice of floats. This could come from a
// database or file, as utilized in earlier examples.
ts := []float64{0.1, 0.2, 0.5, 0.12, 0.38, 0.9, 0.74}
// Create a new anomalyzer based on the existing
// time series values and configuration.
anom, err := anomalyzer.NewAnomalyzer(conf, ts)
if err != nil {
log.Fatal(err)
}
// Supply a new observed value to the Anomalyzer.
// The Anomalyzer will analyze the value in reference
// to pre-existing values in the series and output
// a probability of the value being anomalous.
prob := anom.Push(15.2)
fmt.Printf("Probability of 15.2 being anomalous: %0.2f\n", prob)
prob = anom.Push(0.43)
fmt.Printf("Probability of 0.33 being anomalous: %0.2f\n", prob)
编译并运行此异常检测会产生以下结果:
$ go build
$ ./myprogram
Probability of 15.2 being anomalous: 0.98
Probability of 0.33 being anomalous: 0.80
参考文献
时间序列统计(ACF 和 PACF):
-
如何使用 ACF:
coolstatsblog.com/2013/08/07/how-to-use-the-autocorreation-function-acf/
-
在 ARIMA 模型中识别 AR 或 MA 项的数量:
people.duke.edu/~rnau/411arim3.htm
自回归模型:
-
对 AR 模型更数学化的介绍:
onlinecourses.science.psu.edu/stat501/node/358
-
github.com/sajari/regression
文档:godoc.org/github.com/sajari/regression
ARMA/ARIMA 模型:
- ARIMA 简介:
people.duke.edu/~rnau/411arim.htm
异常检测:
-
InfluxDB:
www.influxdata.com/
-
Prometheus:
prometheus.io/
-
github.com/lytics/anomalyzer
文档:godoc.org/github.com/lytics/anomalyzer
-
github.com/sec51/goanomaly
文档:godoc.org/github.com/sec51/goanomaly
概述
好吧,这真是及时!我们现在知道了时间序列数据是什么,如何在 Go 中表示它,如何进行一些预测,以及如何在我们的时间序列数据中检测异常。这些技能在你处理随时间变化的数据时将非常有用,无论是与股价相关的数据,还是与你的基础设施相关的监控数据。
在下一章中,我们将通过查看一些高级技术,包括神经网络和深度学习,来提升我们的基于 Go 的机器学习水平。
第八章:神经网络与深度学习
在这本书中,我们已经讨论了很多关于训练或教导机器进行预测的内容。为此,我们采用了各种有用且有趣的算法,包括各种类型的回归、决策树和最近邻算法。然而,让我们退一步思考,如果我们试图做出准确的预测并了解数据,我们可能想要模仿哪种实体。
好吧,这个问题的最明显答案是我们应该模仿我们自己的大脑。作为人类,我们天生具有识别物体、预测数量、识别欺诈等能力,这些都是我们希望机器能够人工完成的事情。诚然,我们在这些活动中并不完美,但我们的表现相当不错!
这种思维方式导致了人工神经网络(也称为神经网络或简称神经网)的发展。这些模型试图大致模仿我们大脑中的某些结构,例如神经元。它们在各个行业中都取得了广泛的成功,并且目前正在被应用于解决各种有趣的问题。
最近,更多专业和复杂的神经网络类型吸引了大量兴趣和关注。这些神经网络属于深度学习类别,通常比常规神经网络的结构更深。也就是说,它们具有许多隐藏层结构,并且可以用数千万个参数或权重进行参数化。
我们将尝试在本章中介绍基于 Go 的神经网络和深度学习模型。这些主题非常广泛,仅深度学习就有整本书的篇幅。因此,我们在这里只会触及表面。话虽如此,以下内容应该为你提供一个坚实的起点,以便在 Go 中构建神经网络。
理解神经网络术语
神经网络种类繁多,每种类型都有自己的术语集。然而,无论我们使用哪种类型的神经网络,都有一些常见的术语是我们应该知道的。以下是一些常见的术语:
-
节点、感知器或神经元:这些可以互换使用的术语指的是神经网络的基本构建块。每个节点或神经元都会接收输入数据并对这些数据进行操作。在执行操作后,节点/神经元可能会也可能不会将操作的结果传递给其他节点/神经元。
-
激活:与节点操作相关的输出或值。
-
激活函数:将输入转换为节点输出或激活的函数的定义。
-
权重或偏差:这些值定义了激活函数中输入和输出数据之间的关系。
-
输入层:神经网络中的输入层包括一系列节点,这些节点将初始输入传递到神经网络模型中(例如一系列特征或属性)。
-
输出层:神经网络中的输出层包括一系列节点,这些节点接收神经网络内部传递的信息,并将其转换为最终输出。
-
隐藏层:这些层位于输入层和输出层之间,因此对外部输入或输出来说是隐藏的。
-
正向传播或前向传播:这指的是数据被输入到神经网络的输入层,并向前传递到输出层(没有循环)的情况。
-
反向传播:这是一种训练神经网络模型的方法,涉及通过网络传递正向值,计算生成的错误,然后根据这些错误将更改传递回网络。
-
架构:神经网络中神经元相互连接的整体结构称为架构。
为了巩固这些概念,考虑以下神经网络示意图:
这是一个基本的正向传播(即,无环或有回溯)神经网络。它有两个隐藏层,接受两个输入,并输出两个类别值(或结果)。
如果现在所有这些术语看起来有点令人不知所措,请不要担心。我们将在下一个具体示例中查看,这将巩固所有这些内容。此外,如果你在本章的各种示例中陷入困境并因术语而感到困惑,请回到这里作为提醒。
构建简单的神经网络
许多神经网络包和神经网络的应用将模型视为黑盒。也就是说,人们倾向于使用一些框架,允许他们快速使用一些默认值和自动化构建神经网络。他们通常能够产生一些结果,但这种便利通常不会对模型实际工作方式产生太多直观理解。因此,当模型的行为不符合预期时,很难理解它们为什么可能会做出奇怪的预测或遇到收敛困难。
在深入研究更复杂的神经网络之前,让我们建立一些关于神经网络的基本直觉,这样我们就不至于陷入这种模式。我们将从头开始构建一个简单的神经网络,以了解神经网络的基本组件以及它们是如何协同工作的。
注意:尽管我们在这里将从零开始构建我们的神经网络(你可能在某些情况下也想这样做),但存在各种 Go 包可以帮助你构建、训练和预测神经网络。这些包括 github.com/tleyden/neurgo
、github.com/fxsjy/gonn
、github.com/NOX73/go-neural
、github.com/milosgajdos83/gosom
、github.com/made2591/go-perceptron-go
和 github.com/chewxy/gorgonia
。
我们将在本章中利用神经网络进行分类。然而,神经网络也可以用于执行回归。你可以在这里了解更多关于该主题的信息:heuristically.wordpress.com/2011/11/17/using-neural-network-for-regression/
。
网络中的节点
我们神经网络中的节点或神经元本身具有相对简单的功能。每个神经元将接受一个或多个值(x[1]、x[2] 等等),根据激活函数将这些值组合,并产生一个输出。以下是一个输出示例:
我们应该如何组合输入以获得输出?嗯,我们需要一个可调整的输入组合方法(这样我们就可以训练模型),我们已经看到使用系数和截距组合变量是一种可训练的输入组合方法。只需回想一下第四章 回归。本着这种精神,我们将使用一些系数(权重)和截距(偏差)线性组合输入:
在这里,w[1]、w[2] 等等是我们的权重,b 是偏差。
这种输入组合是一个良好的开始,但最终它是线性的,因此无法对输入和输出之间的非线性关系进行建模。为了引入一些非线性,我们将对这个输入的线性组合应用一个激活函数。我们将使用的激活函数与第五章 分类 中引入的对数函数类似。在神经网络和以下形式中,对数函数被称为 sigmoid 函数:
sigmoid
函数是我们节点中使用的良好选择,因为它引入了非线性,但它也有一个有限的范围(在 0 和 1 之间),有一个简单定义的导数(我们将在网络的训练中使用它),并且它可以有概率解释(如第五章 分类 中进一步讨论)。
sigmoid
函数绝不是神经网络中激活函数的唯一选择。其他流行的选择包括双曲正切函数、softmax 和修正线性单元。激活函数的选择及其优缺点将在 medium.com/towards-data-science/activation-functions-and-its-types-which-is-better-a9a5310cc8f
中进一步讨论。
让我们继续在 Go 中定义我们的激活函数及其导数。这些定义在以下代码中显示:
// sigmoid implements the sigmoid function
// for use in activation functions.
func sigmoid(x float64) float64 {
return 1.0 / (1.0 + math.Exp(-x))
}
// sigmoidPrime implements the derivative
// of the sigmoid function for backpropagation.
func sigmoidPrime(x float64) float64 {
return x * (1.0 - x)
}
网络架构
我们将要构建的简单神经网络将包含一个输入层和一个输出层(任何神经网络都是如此)。网络将在输入层和输出层之间包含一个单一的隐藏层。这种架构如下所示:
特别是,我们将在输入层中包含四个节点,在隐藏层中包含三个节点,在输出层中包含三个节点。输入层中的四个节点对应于我们将要输入网络中的属性数量。想想这四个输入就像我们在第五章 [f0ffd10e-d2c4-41d7-8f26-95c05a30d818.xhtml],分类 中用来分类鸢尾花所使用的四个测量值。输出层将有三节点,因为我们将会设置网络对鸢尾花进行分类,这些分类可能属于三个类别之一。
现在,关于隐藏层——为什么我们使用一个有三个节点的隐藏层?嗯,一个隐藏层对于绝大多数简单任务来说是足够的。如果你有大量的非线性数据、许多输入和/或大量的训练数据,你可能需要引入更多的隐藏层(如本章后面在讨论深度学习时进一步讨论的那样)。隐藏层中三个节点数量的选择可以根据评估指标和一点试错来调整。你也可以搜索隐藏层中节点的数量来自动化你的选择。
请记住,你向隐藏层引入的节点越多,你的网络就能越完美地学习你的训练集。换句话说,你正在使你的模型面临过拟合的风险,以至于它无法泛化。在添加这种复杂性时,非常重要的一点是要非常仔细地验证你的模型,以确保它能够泛化。
我们为什么期望这种架构能起作用?
让我们暂停一下,思考一下为什么按照前面的安排设置一系列节点可以让我们预测某些东西。正如你所看到的,我们一直在做的就是反复组合输入以产生某种结果。我们怎么能期望这个结果在做出二元分类时是有用的呢?
好吧,当我们定义二元分类问题,或者任何分类或回归问题时,我们究竟意味着什么?我们基本上是在说,我们期望在一系列输入(我们的属性)和输出(我们的标签或响应)之间存在某些规则或关系。本质上,我们是在说,存在一些简单或复杂的函数能够将我们的属性转换为我们想要的响应或标签。
我们可以选择一种可能模拟属性到输出转换的函数类型,就像我们在线性或逻辑回归中所做的那样。然而,我们也可以设置一系列连锁的可调整函数,我们可以通过算法训练来检测输入和输出之间的关系和规则。这正是我们在神经网络中所做的!
神经网络的节点就像是被调整的子函数,直到它们模仿我们提供的输入和输出之间的规则和关系,无论这些规则和关系实际上是什么(线性、非线性、动态等)。如果输入和输出之间存在底层规则,那么很可能存在一些可以模仿这些规则的神经网络。
训练我们的神经网络
好吧,现在我们有一些很好的理由说明为什么这种节点组合可能有助于我们进行预测。我们究竟如何根据一些输入数据调整我们神经网络节点的所有子函数呢?答案就是反向传播。
反向传播是一种训练我们神经网络的方法,它涉及在一系列时代(或对训练数据集的暴露)上迭代执行以下操作:
-
将我们的训练数据正向通过神经网络来计算输出
-
计算输出中的错误
-
使用梯度下降(或其他相关方法)来确定我们应该如何根据错误来改变我们的权重和偏差
-
将这些权重/偏差变化反向传播到网络中
我们将在稍后实施此过程,并探讨一些细节。然而,像梯度下降这样的内容在附录“与机器学习相关的算法/技术”中有更详细的介绍。
这种训练神经网络的反向传播方法在建模世界中无处不在,但这里没有足够的空间来涵盖大量的独特网络架构和方法。神经网络是学术界和工业界的一个活跃的研究领域。
首先,让我们定义一个neuralNetConfig
结构,它将包含定义我们网络架构以及我们如何进行反向传播迭代的参数。同时,我们也定义一个neuralNet
结构,它将包含定义一个训练好的神经网络的全部信息。稍后,我们将利用训练好的neuralNet
值来进行预测。这些定义在以下代码中展示:
// neuralNet contains all of the information
// that defines a trained neural network.
type neuralNet struct {
config neuralNetConfig
wHidden *mat.Dense
bHidden *mat.Dense
wOut *mat.Dense
bOut *mat.Dense
}
// neuralNetConfig defines our neural network
// architecture and learning parameters.
type neuralNetConfig struct {
inputNeurons int
outputNeurons int
hiddenNeurons int
numEpochs int
learningRate float64
}
在这里,wHidden
和bHidden
是网络隐藏层的权重和偏差,而wOut
和bOut
分别是网络输出层的权重和偏差。请注意,我们使用gonum.org/v1/gonum/mat
矩阵来表示所有的权重和偏差,我们还将使用类似的矩阵来表示我们的输入和输出。这将使我们能够轻松执行与反向传播相关的操作,并将我们的训练推广到输入、隐藏和输出层中的任何数量的节点。
接下来,让我们定义一个函数,它根据neuralNetConfig
值初始化一个新的神经网络,以及一个基于输入矩阵(x)和标签矩阵(y)的训练neuralNet
值的函数:
// NewNetwork initializes a new neural network.
func newNetwork(config neuralNetConfig) *neuralNet {
return &neuralNet{config: config}
}
// Train trains a neural network using backpropagation.
func (nn *neuralNet) train(x, y *mat.Dense) error {
// To be filled in...
}
在train()
方法中,我们需要完成我们的反向传播方法,并将生成的训练好的权重和偏差放入接收器中。首先,让我们在train()
方法中随机初始化权重和偏差,如下面的代码所示:
// Initialize biases/weights.
randSource := rand.NewSource(time.Now().UnixNano())
randGen := rand.New(randSource)
wHiddenRaw := make([]float64, nn.config.hiddenNeurons*nn.config.inputNeurons)
bHiddenRaw := make([]float64, nn.config.hiddenNeurons)
wOutRaw := make([]float64, nn.config.outputNeurons*nn.config.hiddenNeurons)
bOutRaw := make([]float64, nn.config.outputNeurons)
for _, param := range [][]float64{wHiddenRaw, bHiddenRaw, wOutRaw, bOutRaw} {
for i := range param {
param[i] = randGen.Float64()
}
}
wHidden := mat.NewDense(nn.config.inputNeurons, nn.config.hiddenNeurons, wHiddenRaw)
bHidden := mat.NewDense(1, nn.config.hiddenNeurons, bHiddenRaw)
wOut := mat.NewDense(nn.config.hiddenNeurons, nn.config.outputNeurons, wOutRaw)
bOut := mat.NewDense(1, nn.config.outputNeurons, bOutRaw)
然后,我们需要遍历我们的每个 epoch,完成网络的反向传播。这又涉及到一个前向阶段,其中计算输出,以及一个反向传播阶段,其中应用权重和偏差的变化。如果您想深入了解,反向传播过程将在附录“与机器学习相关的算法/技术”中详细讨论。现在,让我们专注于实现。epoch 的循环如下,其中通过注释指出了反向传播过程的各个部分:
// Define the output of the neural network.
output := mat.NewDense(0, 0, nil)
// Loop over the number of epochs utilizing
// backpropagation to train our model.
for i := 0; i < nn.config.numEpochs; i++ {
// Complete the feed forward process.
...
// Complete the backpropagation.
...
// Adjust the parameters.
...
}
在这个循环的前向部分,输入通过我们的节点网络向前传播:
// Complete the feed forward process.
hiddenLayerInput := mat.NewDense(0, 0, nil)
hiddenLayerInput.Mul(x, wHidden)
addBHidden := func(_, col int, v float64) float64 { return v + bHidden.At(0, col) }
hiddenLayerInput.Apply(addBHidden, hiddenLayerInput)
hiddenLayerActivations := mat.NewDense(0, 0, nil)
applySigmoid := func(_, _ int, v float64) float64 { return sigmoid(v) }
hiddenLayerActivations.Apply(applySigmoid, hiddenLayerInput)
outputLayerInput := mat.NewDense(0, 0, nil)
outputLayerInput.Mul(hiddenLayerActivations, wOut)
addBOut := func(_, col int, v float64) float64 { return v + bOut.At(0, col) }
outputLayerInput.Apply(addBOut, outputLayerInput)
output.Apply(applySigmoid, outputLayerInput)
然后,一旦我们从前向过程中得到输出,我们就通过网络向后计算输出层和隐藏层的 delta(或变化),如下面的代码所示:
// Complete the backpropagation.
networkError := mat.NewDense(0, 0, nil)
networkError.Sub(y, output)
slopeOutputLayer := mat.NewDense(0, 0, nil)
applySigmoidPrime := func(_, _ int, v float64) float64 { return sigmoidPrime(v) }
slopeOutputLayer.Apply(applySigmoidPrime, output)
slopeHiddenLayer := mat.NewDense(0, 0, nil)
slopeHiddenLayer.Apply(applySigmoidPrime, hiddenLayerActivations)
dOutput := mat.NewDense(0, 0, nil)
dOutput.MulElem(networkError, slopeOutputLayer)
errorAtHiddenLayer := mat.NewDense(0, 0, nil)
errorAtHiddenLayer.Mul(dOutput, wOut.T())
dHiddenLayer := mat.NewDense(0, 0, nil)
dHiddenLayer.MulElem(errorAtHiddenLayer, slopeHiddenLayer)
然后,使用这些 delta 值来更新我们网络的权重和偏差。同时,使用学习率来缩放这些变化,这有助于算法收敛。反向传播循环的这一最后部分在此实现:
// Adjust the parameters.
wOutAdj := mat.NewDense(0, 0, nil)
wOutAdj.Mul(hiddenLayerActivations.T(), dOutput)
wOutAdj.Scale(nn.config.learningRate, wOutAdj)
wOut.Add(wOut, wOutAdj)
bOutAdj, err := sumAlongAxis(0, dOutput)
if err != nil {
return err
}
bOutAdj.Scale(nn.config.learningRate, bOutAdj)
bOut.Add(bOut, bOutAdj)
wHiddenAdj := mat.NewDense(0, 0, nil)
wHiddenAdj.Mul(x.T(), dHiddenLayer)
wHiddenAdj.Scale(nn.config.learningRate, wHiddenAdj)
wHidden.Add(wHidden, wHiddenAdj)
bHiddenAdj, err := sumAlongAxis(0, dHiddenLayer)
if err != nil {
return err
}
bHiddenAdj.Scale(nn.config.learningRate, bHiddenAdj)
bHidden.Add(bHidden, bHiddenAdj)
注意,在这里我们使用了另一个辅助函数,它允许我们在保持另一个维度完整的同时,对矩阵沿一个维度求和。这个sumAlongAxis()
函数如下所示,以示完整:
// sumAlongAxis sums a matrix along a
// particular dimension, preserving the
// other dimension.
func sumAlongAxis(axis int, m *mat.Dense) (*mat.Dense, error) {
numRows, numCols := m.Dims()
var output *mat.Dense
switch axis {
case 0:
data := make([]float64, numCols)
for i := 0; i < numCols; i++ {
col := mat.Col(nil, i, m)
data[i] = floats.Sum(col)
}
output = mat.NewDense(1, numCols, data)
case 1:
data := make([]float64, numRows)
for i := 0; i < numRows; i++ {
row := mat.Row(nil, i, m)
data[i] = floats.Sum(row)
}
output = mat.NewDense(numRows, 1, data)
default:
return nil, errors.New("invalid axis, must be 0 or 1")
}
return output, nil
}
在train()
方法中,我们最后要做的就是将训练好的权重和偏差添加到接收器值中并返回:
// Define our trained neural network.
nn.wHidden = wHidden
nn.bHidden = bHidden
nn.wOut = wOut
nn.bOut = bOut
return nil
太棒了!这并不那么糟糕。我们已经有了一种在约 100 行 Go 代码中训练我们的神经网络的方法。为了检查代码是否运行,以及了解我们的权重和偏差可能的样子,让我们在一些简单的虚拟数据上训练一个神经网络。我们将在下一节中用更真实的例子来展示网络,但现在,让我们用一些虚拟数据来对自己进行合理性检查,如下面的代码所示:
// Define our input attributes.
input := mat.NewDense(3, 4, []float64{
1.0, 0.0, 1.0, 0.0,
1.0, 0.0, 1.0, 1.0,
0.0, 1.0, 0.0, 1.0,
})
// Define our labels.
labels := mat.NewDense(3, 1, []float64{1.0, 1.0, 0.0})
// Define our network architecture and
// learning parameters.
config := neuralNetConfig{
inputNeurons: 4,
outputNeurons: 1,
hiddenNeurons: 3,
numEpochs: 5000,
learningRate: 0.3,
}
// Train the neural network.
network := newNetwork(config)
if err := network.train(input, labels); err != nil {
log.Fatal(err)
}
// Output the weights that define our network!
f := mat.Formatted(network.wHidden, mat.Prefix(" "))
fmt.Printf("\nwHidden = % v\n\n", f)
f = mat.Formatted(network.bHidden, mat.Prefix(" "))
fmt.Printf("\nbHidden = % v\n\n", f)
f = mat.Formatted(network.wOut, mat.Prefix(" "))
fmt.Printf("\nwOut = % v\n\n", f)
f = mat.Formatted(network.bOut, mat.Prefix(" "))
fmt.Printf("\nbOut = % v\n\n", f)
编译并运行此神经网络训练代码将产生以下输出权重和偏差:
bOut$ go build
$ ./myprogram
wHidden = [ 2.105009524680073 2.022752980359344 2.5200192466310547]
[ -2.033896626042324 -1.8652059633980662 -1.5861504822640748]
[1.821046795894909 2.436963623058306 1.717510016887383]
[-0.7400175664445425 -0.5800261988090052 -0.9277709997772002]
bHidden = [ 0.06850421921273529 -0.40252869229501825 -0.03392255287230178]
wOut = [ 2.321901257946553]
[ 3.343178681830189]
[1.7581701319375231]
bOut = [-3.471613333924047]
这些权重和偏差完全定义了我们的训练好的神经网络。也就是说,wHidden
和bHidden
使我们能够将输入转换为网络输出层的输入值,而wOut
和bOut
使我们能够将这些值转换为神经网络最终的输出。
由于我们使用了随机数,你的程序可能返回的结果与之前描述的不同。然而,你应该看到类似的价值范围。
利用简单的神经网络
现在我们有一些看起来似乎可以正常工作的神经网络训练功能,让我们尝试在更实际的建模场景中利用这些功能。特别是,让我们恢复我们最喜欢的分类数据集,鸢尾花数据集(在第五章分类中使用)。
如果你记得,当尝试使用此数据集对鸢尾花进行分类时,我们试图将它们分类为三种物种之一(setosa、virginica 或 versicolor)。由于我们的神经网络期望的是浮点值矩阵,我们需要将三种物种编码为数值列。一种方法是为每种物种在数据集中创建一个列。然后,我们将该列的值设置为1.0或0.0,具体取决于对应行的测量值是否对应于该物种(1.0)或另一个物种(0.0)。
我们还将标准化我们的数据,原因与在第五章“分类”中讨论的类似逻辑回归。这使得我们的数据集看起来与之前的示例略有不同,如下所示:
$ head train.csv
sepal_length,sepal_width,petal_length,petal_width,setosa,virginica,versicolor
0.305555555556,0.583333333333,0.118644067797,0.0416666666667,1.0,0.0,0.0
0.25,0.291666666667,0.491525423729,0.541666666667,0.0,0.0,1.0
0.194444444444,0.5,0.0338983050847,0.0416666666667,1.0,0.0,0.0
0.833333333333,0.375,0.898305084746,0.708333333333,0.0,1.0,0.0
0.555555555556,0.208333333333,0.661016949153,0.583333333333,0.0,0.0,1.0
0.416666666667,0.25,0.508474576271,0.458333333333,0.0,0.0,1.0
0.666666666667,0.416666666667,0.677966101695,0.666666666667,0.0,0.0,1.0
0.416666666667,0.291666666667,0.491525423729,0.458333333333,0.0,0.0,1.0
0.5,0.416666666667,0.661016949153,0.708333333333,0.0,1.0,0.0
我们将使用 80/20 的训练和测试数据分割来评估模型。训练数据将存储在train.csv
中,测试数据将存储在test.csv
中,如下所示:
$ wc -l *.csv
31 test.csv
121 train.csv
152 total
在真实数据上训练神经网络
为了在这个鸢尾花数据上训练我们的神经网络,我们需要读取训练数据并创建两个矩阵。第一个矩阵将包含所有属性(矩阵inputs
),第二个矩阵将包含所有标签(矩阵labels
)。我们将按照以下方式构建这些矩阵:
// Open the training dataset file.
f, err := os.Open("train.csv")
if err != nil {
log.Fatal(err)
}
defer f.Close()
// Create a new CSV reader reading from the opened file.
reader := csv.NewReader(f)
reader.FieldsPerRecord = 7
// Read in all of the CSV records
rawCSVData, err := reader.ReadAll()
if err != nil {
log.Fatal(err)
}
// inputsData and labelsData will hold all the
// float values that will eventually be
// used to form our matrices.
inputsData := make([]float64, 4*len(rawCSVData))
labelsData := make([]float64, 3*len(rawCSVData))
// inputsIndex will track the current index of
// inputs matrix values.
var inputsIndex int
var labelsIndex int
// Sequentially move the rows into a slice of floats.
for idx, record := range rawCSVData {
// Skip the header row.
if idx == 0 {
continue
}
// Loop over the float columns.
for i, val := range record {
// Convert the value to a float.
parsedVal, err := strconv.ParseFloat(val, 64)
if err != nil {
log.Fatal(err)
}
// Add to the labelsData if relevant.
if i == 4 || i == 5 || i == 6 {
labelsData[labelsIndex] = parsedVal
labelsIndex++
continue
}
// Add the float value to the slice of floats.
inputsData[inputsIndex] = parsedVal
inputsIndex++
}
}
// Form the matrices.
inputs := mat.NewDense(len(rawCSVData), 4, inputsData)
labels := mat.NewDense(len(rawCSVData), 3, labelsData)
我们可以初始化并训练我们的神经网络,类似于我们处理虚拟数据的方式。唯一的区别是我们将使用三个输出神经元,对应于我们的三个输出类别。训练过程如下:
// Define our network architecture and
// learning parameters.
config := neuralNetConfig{
inputNeurons: 4,
outputNeurons: 3,
hiddenNeurons: 3,
numEpochs: 5000,
learningRate: 0.3,
}
// Train the neural network.
network := newNetwork(config)
if err := network.train(inputs, labels); err != nil {
log.Fatal(err)
}
评估神经网络
要使用训练好的神经网络进行预测,我们将为neuralNet
类型创建一个predict()
方法。这个predict()
方法将接受新的输入,并将新的输入通过网络前向传播以产生预测输出,如下所示:
// predict makes a prediction based on a trained
// neural network.
func (nn *neuralNet) predict(x *mat.Dense) (*mat.Dense, error) {
// Check to make sure that our neuralNet value
// represents a trained model.
if nn.wHidden == nil || nn.wOut == nil || nn.bHidden == nil || nn.bOut == nil {
return nil, errors.New("the supplied neural net weights and biases are empty")
}
// Define the output of the neural network.
output := mat.NewDense(0, 0, nil)
github.com/tensorflow/tensorflow/tensorflow/go
// Complete the feed forward process.
hiddenLayerInput := mat.NewDense(0, 0, nil)
hiddenLayerInput.Mul(x, nn.wHidden)
addBHidden := func(_, col int, v float64) float64 { return v + nn.bHidden.At(0, col) }
hiddenLayerInput.Apply(addBHidden, hiddenLayerInput)
hiddenLayerActivations := mat.NewDense(0, 0, nil)
applySigmoid := func(_, _ int, v float64) float64 { return sigmoid(v) }
hiddenLayerActivations.Apply(applySigmoid, hiddenLayerInput)
outputLayerInput := mat.NewDense(0, 0, nil)
outputLayerInput.Mul(hiddenLayerActivations, nn.wOut)
addBOut := func(_, col int, v float64) float64 { return v + nn.bOut.At(0, col) }
outputLayerInput.Apply(addBOut, outputLayerInput)
output.Apply(applySigmoid, outputLayerInput)
return output, nil
}
我们可以使用这个predict()
方法在测试数据上评估我们的训练模型。具体来说,我们将读取test.csv
文件到两个新的矩阵testInputs
和testLabels
中,类似于我们读取train.csv
的方式(因此,这里将不包含细节)。testInputs
可以提供给predict()
方法以获取我们的预测,然后我们可以将我们的预测与testLabels
进行比较,以计算评估指标。在这种情况下,我们将计算我们模型的准确率。
在计算准确率时,我们将注意的一个细节是从我们的模型中确定单个输出预测。网络实际上为每种鸢尾花物种产生一个介于 0.0 和 1.0 之间的输出。我们将取这些输出中的最高值作为预测物种。
我们模型准确率的计算如下代码所示:
// Make the predictions using the trained model.
predictions, err := network.predict(testInputs)
if err != nil {
log.Fatal(err)
}
// Calculate the accuracy of our model.
var truePosNeg int
numPreds, _ := predictions.Dims()
for i := 0; i < numPreds; i++ {
// Get the label.
labelRow := mat.Row(nil, i, testLabels)
var species int
for idx, label := range labelRow {
if label == 1.0 {
species = idx
break
}
}
// Accumulate the true positive/negative count.
if predictions.At(i, species) == floats.Max(mat.Row(nil, i, predictions)) {
truePosNeg++
}
}
// Calculate the accuracy (subset accuracy).
accuracy := float64(truePosNeg) / float64(numPreds)
// Output the Accuracy value to standard out.
fmt.Printf("\nAccuracy = %0.2f\n\n", accuracy)
编译和运行评估结果类似于以下内容:
$ go build
$ ./myprogram
Accuracy = 0.97
哇!这相当不错。我们使用从头开始的神经网络对鸢尾花物种的预测准确率达到 97%。如前所述,这些单隐藏层神经网络在大多数分类任务中都非常强大,你可以看到其基本原理并不真的那么复杂。
介绍深度学习
尽管像前述章节中使用的简单神经网络在许多场景中非常强大,但近年来深度神经网络架构已经在各个行业中应用于各种类型的数据。这些更复杂的架构已被用于在棋类/视频游戏中击败冠军、驾驶汽车、生成艺术、转换图像等等。几乎感觉你可以把这些模型扔给任何东西,它们都会做一些有趣的事情,但它们似乎特别适合计算机视觉、语音识别、文本推理和其他非常复杂且难以定义的任务。
我们将在这里介绍深度学习,并在 Go 中运行一个深度学习模型。然而,深度学习模型的用途和多样性非常巨大,并且每天都在增长。
关于这个主题有许多书籍和教程,所以如果这个简短的介绍激发了你的兴趣,我们建议你查看以下资源之一:
-
使用 TensorFlow 进行动手深度学习:
www.packtpub.com/big-data-and-business-intelligence/hands-deep-learning-tensorflow
-
深度学习实例:
www.packtpub.com/big-data-and-business-intelligence/deep-learning-example
-
深度学习计算机视觉:
www.packtpub.com/big-data-and-business-intelligence/deep-learning-computer-vision
这些资源可能并不都会提到或承认 Go 语言,但正如你将在下面的示例中看到的,Go 语言完全能够应用这些技术并与 TensorFlow 或 H2O 等工具进行接口交互。
深度学习模型是什么?
深度学习中的“深度”指的是连续组合和利用各种神经网络结构,形成一个庞大且复杂的架构。这些庞大且复杂的架构通常需要大量的数据进行训练,并且生成的结构非常难以解释。
为了举例说明现代深度学习模型的规模和复杂性,以谷歌的 LeNet(www.cv-foundation.org/openaccess/content_cvpr_2015/papers/Szegedy_Going_Deeper_With_2015_CVPR_paper.pdf
)为例。这个可用于物体识别的模型在此处展示(图片由adeshpande3.github.io/adeshpande3.github.io/The-9-Deep-Learning-Papers-You-Need-To-Know-About.html
提供):
这个结构本身就很复杂。然而,当你意识到这个图中的每个块本身都可以是一个复杂的神经网络原语(如图例所示)时,它变得更加令人印象深刻。
通常,深度学习模型是通过将以下结构之一或多个链接在一起,并在某些情况下重新链接在一起来构建的:
-
完全连接的神经网络架构:这个构建块可能类似于我们在本章之前为鸢尾花检测构建的。它包括一系列按完全连接层排列的神经网络节点。也就是说,层从一层流向另一层,所有节点都从一层连接到下一层。
-
卷积神经网络(ConvNet或CNN):这是一种至少实现了一个卷积层的神经网络。卷积层包含由一组权重(也称为卷积核或过滤器)参数化的神经元,这些神经元仅部分连接到输入数据。大多数 ConvNet 包括这些卷积层的分层组合,这使得 ConvNet 能够对输入数据中的低级特征做出响应。
-
循环神经网络(RNN)和/或长短期记忆(LSTM)单元:这些组件试图将某种形式的记忆因素纳入模型。循环神经网络使它们的输出依赖于一系列输入,而不是假设每个输入都是独立的。LSTM 单元与循环神经网络相关,但还试图纳入一种更长期的记忆。
虽然我们将在本章中使用图像数据来展示深度学习,但使用循环神经网络(RNN)和长短期记忆网络(LSTM)的深度学习模型特别适合解决基于文本和其他自然语言处理(NLP)问题。这些网络内置的内存使它们能够理解句子中可能出现的下一个单词,例如。如果你对运行基于文本数据的深度学习模型感兴趣,我们建议查看使用 github.com/chewxy/gorgonia
包的此示例:
github.com/chewxy/gorgonia/tree/master/examples/charRNN.
深度学习模型非常强大!特别是对于计算机视觉等任务。然而,你也应该记住,这些神经网络组件的复杂组合也非常难以解释。也就是说,确定模型做出特定预测的原因可能几乎是不可能的。这在需要维护某些行业和司法管辖区合规性的情况下可能是一个问题,也可能阻碍应用程序的调试或维护。尽管如此,有一些主要努力旨在提高深度学习模型的解释性。在这些努力中,LIME 项目是值得注意的:
使用 Go 进行深度学习
当你想要从 Go 语言构建或使用深度学习模型时,有多种选择。这与深度学习本身一样,是一个不断变化的领域。然而,在 Go 语言中构建、训练和利用深度学习模型的选择通常如下:
-
使用 Go 包:有一些 Go 包允许你使用 Go 作为构建和训练深度学习模型的主要接口。其中功能最全面且发展最成熟的包是
github.com/chewxy/gorgonia
。github.com/chewxy/gorgonia
将 Go 视为第一等公民,并且是用 Go 编写的,即使它确实使用了cgo
来与数值库进行交互。 -
使用非 Go 语言深度学习框架的 API 或 Go 客户端:你可以从 Go 语言与流行的深度学习服务和框架进行交互,包括 TensorFlow、MachineBox、H2O 以及各种云服务提供商或第三方 API 提供商(如 IBM Watson)。TensorFlow 和 Machine Box 实际上提供了 Go 绑定或 SDK,这些绑定或 SDK 正在持续改进。对于其他服务,你可能需要通过 REST 或甚至使用
exec
调用二进制文件来进行交互。 -
使用 cgo:当然,Go 可以与 C/C++ 库进行交互和集成,用于深度学习,包括 TensorFlow 库和来自 Intel 的各种库。然而,这是一条艰难的道路,只有在绝对必要时才推荐使用。
由于 TensorFlow 目前是深度学习最受欢迎的框架,我们将简要探讨这里列出的第二类。然而,TensorFlow Go 绑定目前正处于积极开发中,一些功能相当原始。TensorFlow 团队建议,如果你打算在 Go 中使用 TensorFlow 模型,你首先应该使用 Python 训练并导出此模型。然后,这个预训练模型可以从 Go 中使用,正如我们将在下一节中展示的那样。
社区中有许多成员正在努力使 Go 成为 TensorFlow 的首选公民。因此,TensorFlow 绑定的粗糙边缘可能会在未来一年中得到平滑处理。为了保持在这一方面的更新,请确保加入 Gophers Slack 上的 #data-science 频道 (invite.slack.golangbridge.org/
)。那里是经常讨论的话题,也是提问和参与的好地方。
尽管我们在这里将探索一个快速的 TensorFlow 示例,但我们强烈建议你应该查看 github.com/chewxy/gorgonia
,特别是如果你考虑在 Go 中进行更多自定义或广泛的建模。这个包非常强大,并且已在生产中使用。
为 Go 设置 TensorFlow
TensorFlow 团队提供了一些很好的文档来安装 TensorFlow 并使其准备好与 Go 一起使用。这些文档可以在 www.tensorflow.org/install/install_go
找到。有一些初步步骤,但一旦你安装了 TensorFlow C 库,你就可以获取以下 Go 包:
$ go get github.com/tensorflow/tensorflow/tensorflow/go
如果你能无错误地获取 github.com/tensorflow/tensorflow/tensorflow/go
,那么一切应该都准备就绪了,但你可以通过执行以下测试来确保你已准备好使用 TensorFlow:
$ go test github.com/tensorflow/tensorflow/tensorflow/go
ok github.com/tensorflow/tensorflow/tensorflow/go 0.045s
获取并调用预训练的 TensorFlow 模型
我们将要使用的模型是 Google 用于图像中物体识别的模型,称为 Inception。模型可以通过以下方式获取:
$ mkdir model
$ cd model
$ wget https://storage.googleapis.com/download.tensorflow.org/models/inception5h.zip
--2017-09-09 18:29:03-- https://storage.googleapis.com/download.tensorflow.org/models/inception5h.zip
Resolving storage.googleapis.com (storage.googleapis.com)... 172.217.6.112, 2607:f8b0:4009:812::2010
Connecting to storage.googleapis.com (storage.googleapis.com)|172.217.6.112|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 49937555 (48M) [application/zip]
Saving to: ‘inception5h.zip’
inception5h.zip 100%[=========================================================================================================================>] 47.62M 19.0MB/s in 2.5s
2017-09-09 18:29:06 (19.0 MB/s) - ‘inception5h.zip’ saved [49937555/49937555]
$ unzip inception5h.zip
Archive: inception5h.zip
inflating: imagenet_comp_graph_label_strings.txt
inflating: tensorflow_inception_graph.pb
inflating: LICENSE
解压缩压缩模型后,你应该看到一个 *.pb
文件。这是一个表示模型冻结状态的 protobuf
文件。回想一下我们简单的神经网络。网络完全由一系列权重和偏差定义。尽管更复杂,但这个模型可以用类似的方式定义,并且这些定义存储在这个 protobuf
文件中。
要调用此模型,我们将使用 TensorFlow Go 绑定文档中的示例代码--godoc.org/github.com/tensorflow/tensorflow/tensorflow/go
。此代码加载模型并使用模型检测和标记 *.jpg
图像的内容。
由于代码包含在 TensorFlow 文档中,我将省略细节,只突出几个片段。为了加载模型,我们执行以下操作:
// Load the serialized GraphDef from a file.
modelfile, labelsfile, err := modelFiles(*modeldir)
if err != nil {
log.Fatal(err)
}
model, err := ioutil.ReadFile(modelfile)
if err != nil {
log.Fatal(err)
}
然后我们加载深度学习模型的图定义,并使用该图创建一个新的 TensorFlow 会话,如下面的代码所示:
// Construct an in-memory graph from the serialized form.
graph := tf.NewGraph()
if err := graph.Import(model, ""); err != nil {
log.Fatal(err)
}
// Create a session for inference over graph.
session, err := tf.NewSession(graph, nil)
if err != nil {
log.Fatal(err)
}
defer session.Close()
最后,我们可以使用以下方式使用模型进行推理:
// Run inference on *imageFile.
// For multiple images, session.Run() can be called in a loop (and
// concurrently). Alternatively, images can be batched since the model
// accepts batches of image data as input.
tensor, err := makeTensorFromImage(*imagefile)
if err != nil {
log.Fatal(err)
}
output, err := session.Run(
map[tf.Output]*tf.Tensor{
graph.Operation("input").Output(0): tensor,
},
[]tf.Output{
graph.Operation("output").Output(0),
},
nil)
if err != nil {
log.Fatal(err)
}
// output[0].Value() is a vector containing probabilities of
// labels for each image in the "batch". The batch size was 1.
// Find the most probable label index.
probabilities := output[0].Value().([][]float32)[0]
printBestLabel(probabilities, labelsfile)
使用 TensorFlow 从 Go 进行目标检测
根据 TensorFlow GoDocs 中指定的 Go 程序进行目标检测,可以按以下方式调用:
$ ./myprogram -dir=<path/to/the/model/dir> -image=<path/to/a/jpg/image>
当程序被调用时,它将利用预训练和加载的模型来推断指定图像的内容。然后,它将输出该图像最可能的内容及其计算出的概率。
为了说明这一点,让我们尝试在以下飞机图片上进行目标检测,保存为airplane.jpg
:
从 Go 运行 TensorFlow 模型得到以下结果:
$ go build
$ ./myprogram -dir=model -image=airplane.jpg
2017-09-09 20:17:30.655757: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use SSE4.1 instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:17:30.655807: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use SSE4.2 instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:17:30.655814: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use AVX instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:17:30.655818: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use AVX2 instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:17:30.655822: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use FMA instructions, but these are available on your machine and could speed up CPU computations.
BEST MATCH: (86% likely) airliner
在一些关于加快 CPU 计算的建议之后,我们得到了一个结果:airliner
。哇!这真是太酷了。我们刚刚在我们的 Go 程序中用 TensorFlow 进行了目标识别!
让我们再试一次。这次,我们将使用pug.jpg
,它看起来如下:
再次使用此图片运行我们的程序得到以下结果:
$ ./myprogram -dir=model -image=pug.jpg
2017-09-09 20:20:32.323855: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use SSE4.1 instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:20:32.323896: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use SSE4.2 instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:20:32.323902: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use AVX instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:20:32.323906: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use AVX2 instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:20:32.323911: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use FMA instructions, but these are available on your machine and could speed up CPU computations.
BEST MATCH: (84% likely) pug
成功!模型不仅检测到图片中有一只狗,而且正确地识别出图片中是一只斗牛犬。
让我们再试一次。由于这是一本 Go 书,我们忍不住想试试gopher.jpg
,它看起来如下(向 Go gopher 背后的艺术家 Renee French 表示巨大的感谢):
运行模型得到以下结果:
$ ./myprogram -dir=model -image=gopher.jpg
2017-09-09 20:25:57.967753: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use SSE4.1 instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:25:57.967801: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use SSE4.2 instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:25:57.967808: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use AVX instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:25:57.967812: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use AVX2 instructions, but these are available on your machine and could speed up CPU computations.
2017-09-09 20:25:57.967817: W tensorflow/core/platform/cpu_feature_guard.cc:45] The TensorFlow library wasn't compiled to use FMA instructions, but these are available on your machine and could speed up CPU computations.
BEST MATCH: (12% likely) safety pin
嗯,我想我们不可能每次都赢。看起来我们需要重构我们的模型,以便能够识别 Go gopher。更具体地说,我们可能需要向我们的训练数据集中添加一些 Go gopher,因为 Go gopher 绝对不是安全别针!
参考文献
基本神经网络:
-
神经网络快速入门:
ujjwalkarn.me/2016/08/09/quick-intro-neural-networks/
-
深度学习和神经网络友好入门:
youtu.be/BR9h47Jtqyw
-
github.com/tleyden/neurgo
文档:godoc.org/github.com/tleyden/neurgo
-
github.com/fxsjy/gonn/gonn
文档:godoc.org/github.com/fxsjy/gonn/gonn
更详细的深度学习资源:
-
使用 TensorFlow 进行实战深度学习 -
www.packtpub.com/big-data-and-business-intelligence/hands-deep-learning-tensorflow
-
通过实例学习深度学习 -
www.packtpub.com/big-data-and-business-intelligence/deep-learning-example
-
计算机视觉的深度学习 -
www.packtpub.com/big-data-and-business-intelligence/deep-learning-computer-vision
使用 Go 进行深度学习:
-
TensorFlow Go 绑定文档:
godoc.org/github.com/tensorflow/tensorflow/tensorflow/go
-
github.com/chewxy/gorgonia
文档:godoc.org/github.com/chewxy/gorgonia
-
MachineBox Go SDK 文档:
godoc.org/github.com/machinebox/sdk-go
-
使用
cgo
调用预训练模型的示例:github.com/gopherdata/dlinfer
摘要
恭喜!我们已经从使用 Go 解析数据转变为从 Go 调用深度学习模型。你现在已经了解了神经网络的基础知识,并且可以在你的 Go 程序中实现和使用它们。在下一章中,我们将讨论如何将这些模型和应用从你的笔记本电脑上移除,并在数据管道中以生产规模运行。
第九章:部署和分发分析和模型
我们已经在 Go 中实现了各种模型,包括回归、分类、聚类等。您也已经了解了一些关于开发机器学习模型的过程。我们的模型已经成功地预测了疾病进展、花卉种类和图像中的物体。然而,我们仍然缺少机器学习拼图中的一块重要部分:部署、维护和扩展。
如果我们的模型仅仅停留在我们的笔记本电脑上,它们在公司中就不会产生任何好处或创造价值。我们需要知道如何将我们的机器学习工作流程集成到我们组织内已经部署的系统,并且我们需要知道如何随着时间的推移进行扩展、更新和维护这些工作流程。
由于我们的机器学习工作流程本质上就是多阶段工作流程,这可能会使得部署和维护变得有些挑战。我们需要训练、测试和利用我们的模型,在某些情况下,我们还需要预处理和/或后处理我们的数据。我们可能还需要将某些模型串联起来。我们如何在保持应用程序简单性和完整性的同时部署和连接所有这些阶段呢?例如,我们如何在时间上更新训练数据集的同时,仍然知道哪个训练数据集产生了哪些模型,以及哪些模型产生了哪些结果?我们如何轻松地随着预测需求的上下波动而扩展我们的预测?最后,我们如何将我们的机器学习工作流程集成到我们基础设施中的其他应用程序或基础设施本身(数据库、队列等)?
我们将在本章中解决所有这些问题。事实证明,Go 和用 Go 编写的基础设施工具提供了一个出色的平台来部署和管理机器学习工作流程。我们将从底到顶使用完全基于 Go 的方法,并展示每个组件如何帮助我们进行大规模的数据科学!
在远程机器上可靠地运行模型
无论您的公司使用的是本地基础设施还是云基础设施,您都可能在某个时候需要在除笔记本电脑之外的地方运行您的机器学习模型。这些模型可能需要准备好提供欺诈预测,或者它们可能需要实时处理用户上传的图像。您不能让模型停留在您的笔记本电脑上,并成功提供这些信息。
然而,当我们将数据处理和机器学习应用程序从笔记本电脑上移除时,我们应该确保以下几点:
-
我们不应该为了部署和扩展而使我们的应用程序变得复杂。我们应该保持应用程序的简单性,这将帮助我们维护它们并确保随着时间的推移保持完整性。
-
我们应该确保我们的应用程序的行为与我们在本地机器上开发它们时的行为一致。
如果我们部署我们的机器学习工作流程,并且它们没有像开发期间那样执行或表现,它们将无法产生预期的价值。我们应该能够理解我们的模型在本地如何表现,并假设它们在生产环境中将以相同的方式表现。随着您在部署过程中向应用程序添加不必要的复杂性,这变得越来越难以实现。
一种保持您的部署简单、便携和可重复的方法是使用 Docker (www.docker.com/
),我们在这里将利用它来部署我们的机器学习应用程序。
Docker 本身是用 Go 编写的,这使得它成为我们将在机器学习部署堆栈中使用的第一个基于 Go 的基础设施工具。
Docker 和 Docker 术语的简要介绍
Docker 和整个容器生态系统有其自己的术语集,这可能会令人困惑,尤其是对于那些有虚拟机等经验的人来说。在我们继续之前,让我们如下巩固这些术语:
-
Docker 镜像是一组数据层,这些层共同定义了一个文件系统、库、环境变量等,这些将在运行在软件容器中的应用程序中看到。将镜像视为一个包含您的应用程序、其他相关库或包以及应用程序运行所需的环境其他部分的包。Docker 镜像不包括完整的操作系统。
-
Dockerfile 是一个文件,您在其中定义 Docker 镜像的各个层。
-
Docker 引擎帮助您构建、管理和运行 Docker 镜像。
-
为您的应用程序构建 Docker 镜像的过程通常被称为Docker 化您的应用程序。
-
容器或软件容器是 Docker 镜像的运行实例。本质上,这个运行的容器包括了您的 Docker 镜像的所有层,以及一个读写层,允许您的应用程序运行、输入/输出数据等。
-
Docker 仓库是您存放 Docker 镜像的地方。这个仓库可以是本地的,也可以是在远程机器上运行的。它还可以是一个托管仓库服务,如Docker Hub、Quay、亚马逊网络服务(AWS)、亚马逊 EC2 容器注册库(ECR)等等。
注意:Docker 镜像与虚拟机不同。Docker 镜像包括您的应用程序、文件系统以及各种库和包,但它实际上并不包括一个客户操作系统。此外,它在主机机器上运行时不会占用固定数量的内存、磁盘和 CPU。Docker 容器共享 Docker 引擎运行的底层内核的资源。
我们希望本节之后的示例能够巩固所有这些术语,但与其他本书中的主题一样,你可以更深入地了解 Docker 和软件容器。我们将在本章的参考文献中包含一些链接,以提供更多 Docker 资源。
在以下示例中,我们将假设你能够本地构建和运行 Docker 镜像。要安装 Docker,你可以按照www.docker.com/community-edition#/download
上的详细说明进行操作。
将机器学习应用程序 Docker 化
本章我们将部署和扩展的机器学习工作流程将是我们在第四章中开发的线性回归工作流程,用于预测糖尿病疾病进展。在我们的部署中,我们将考虑工作流程的三个不同部分:
-
训练和导出单一回归模型(使用体重指数建模疾病进展)
-
训练和导出多重回归模型(使用体重指数和血液测量 LTG 建模疾病进展)
-
基于训练模型之一和输入属性对疾病进展进行推断
在本章的后面部分,将会清楚为什么我们可能想要将这些工作流程分成这些部分。现在,让我们专注于将这些工作流程的部分 Docker 化(构建可以运行这些工作流程部分的 Docker 镜像)。
将模型训练和导出 Docker 化
我们将使用与第四章中“回归”相同的代码进行模型训练。然而,我们将对代码进行一些调整,使其更加用户友好,并能与其他工作流程部分进行接口。我们不会将这些复杂性视为实际建模代码的复杂性。相反,这些是你可能对任何准备更广泛使用的应用程序所做的事情。
首先,我们将向我们的应用程序添加一些命令行标志,这将允许我们指定训练数据集所在的输入目录,以及我们将导出模型持久化表示的输出目录。你可以如下实现这些命令行参数:
// Declare the input and output directory flags.
inDirPtr := flag.String("inDir", "", "The directory containing the training data")
outDirPtr := flag.String("outDir", "", "The output directory")
// Parse the command line flags.
flag.Parse()
然后,我们将创建几个结构类型,这将使我们能够将模型的系数和截距导出到 JSON 文件中。这个导出的 JSON 文件本质上是我们训练模型的持久化版本,因为系数和截距完全参数化了我们的模型。这些结构在这里定义:
// ModelInfo includes the information about the
// model that is output from the training.
type ModelInfo struct {
Intercept float64 `json:"intercept"`
Coefficients []CoefficientInfo `json:"coefficients"`
}
// CoefficientInfo include information about a
// particular model coefficient.
type CoefficientInfo struct {
Name string `json:"name"`
Coefficient float64 `json:"coefficient"`
}
除了这个之外,我们的训练代码与第四章中的回归相同。我们仍然会使用github.com/sajari/regression
来训练我们的模型。我们只是将模型导出为 JSON 文件。单变量回归模型的训练和导出包含在以下片段中:
// Train/fit the single regression model
// with github.com/sajari/regression
// like in Chapter 5, Regression.
// Fill in the model information.
modelInfo := ModelInfo{
Intercept: r.Coeff(0),
Coefficients: []CoefficientInfo{
CoefficientInfo{
Name: "bmi",
Coefficient: r.Coeff(1),
},
},
}
// Marshal the model information.
outputData, err := json.MarshalIndent(modelInfo, "", " ")
if err != nil {
log.Fatal(err)
}
// Save the marshalled output to a file, with
// certain permissions (http://permissions-calculator.org/decode/0644/).
if err := ioutil.WriteFile(filepath.Join(*outDirPtr, "model.json"), outputData, 0644); err != nil {e model to the JSON fil
log.Fatal(err)
}
然后,对于多元回归模型,过程看起来如下:
// Train/fit the multiple regression model
// with github.com/sajari/regression
// like in Chapter 5, Regression.
// Fill in the model information.
modelInfo := ModelInfo{
Intercept: r.Coeff(0),
Coefficients: []CoefficientInfo{
CoefficientInfo{
Name: "bmi",
Coefficient: r.Coeff(1),
},
CoefficientInfo{
Name: "ltg",
Coefficient: r.Coeff(2),
},
},
}
// Marshal the model information.
outputData, err := json.MarshalIndent(modelInfo, "", " ")
if err != nil {
log.Fatal(err)
}
// Save the marshalled output to a file.
if err := ioutil.WriteFile(filepath.Join(*outDirPtr, "model.json"), outputData, 0644); err != nil {
log.Fatal(err)
}
为了将这些训练过程 Docker 化,我们需要为每个单变量回归训练程序和多元回归训练程序创建一个 Dockerfile。然而,实际上我们可以为这些程序使用几乎相同的 Dockerfile。这个 Dockerfile 应该放在我们的程序相同的目录中,看起来如下:
FROM alpine
ADD goregtrain /
看起来很简单,对吧?让我们来探讨这两行代码的目的。记得 Docker 镜像是一系列层,这些层指定了您的应用程序将运行的环境吗?嗯,这个 Dockerfile 通过两个 Dockerfile 命令FROM
和ADD
构建了这两个层。
FROM alpine
指定我们希望我们的 Docker 镜像文件系统、应用程序和库基于 Docker Hub 上可用的官方 Alpine Linux Docker 镜像。我们之所以使用alpine
作为基础镜像,是因为它是一个非常小的 Docker 镜像(使其非常便携),并且包含了一些 Linux shell 的便利功能。
ADD goregtrain /
指定我们希望在 Docker 镜像的/
目录中添加一个goregtrain
文件。这个goregtrain
文件实际上是我们将从 Go 代码构建的 Go 二进制文件。因此,Dockerfile 实际上只是在说我们希望在 Alpine Linux 上运行我们的 Go 二进制文件。
除非您使用cgo
并依赖于大量外部 C 库,否则在构建 Docker 镜像之前,始终先构建 Go 二进制文件,并将此静态链接的 Go 二进制文件复制到 Docker 镜像中。这将加快您的 Docker 构建速度,并使您的 Docker 镜像非常小,这意味着您将能够轻松地将它们移植到任何系统并快速启动。
现在,在我们构建 Docker 镜像之前,我们需要先构建 Go 二进制文件,因为我们将要将其复制到镜像中。为此,我们将使用一个如下所示的Makefile
:
all: compile docker push clean
compile:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o goregtrain
docker:
sudo docker build --force-rm=true -t dwhitena/goregtrain:single .
push:
sudo docker push dwhitena/goregtrain:single
clean:
rm goregtrain
如您所见:
-
make compile
将为目标架构编译我们的 Go 二进制文件,并将其命名为goregtrain
。 -
make docker
将使用 Docker 引擎(通过docker
CLI)根据我们的 Dockerfile 构建一个镜像,并将我们的镜像dwhitena/goregtrain:single
进行标记。 -
标签中的
dwhitena
部分指定了我们将存储我们的镜像的 Docker Hub 用户名(在这种情况下,dwhitena
),goregtrain
指定了镜像的名称,而:single
指定了此镜像的标记版本。 -
一旦镜像构建完成,
make push
命令会将新构建的 Docker 镜像推送到一个注册库。在这种情况下,它将推送到名为dwhitena
的 Docker Hub 用户名下(当然,你也可以推送到任何其他私有或公共注册库)。 -
最后,
make clean
命令将清理我们的二进制文件。
如前所述,这个 Dockerfile
和 Makefile
对于单变量和多元回归模型都是相同的。然而,我们将使用不同的 Docker 镜像标签来区分这两个模型。我们将使用 dwhitena/goregtrain:single
作为单变量回归模型的标签,使用 dwhitena/goregtrain:multi
作为多元回归模型的标签。
在这些示例和本章的其余部分,你可以使用 Docker Hub 上 dwhitena
下的公共 Docker 镜像进行本地操作,这样你就不需要修改这里打印的示例。只需注意,你将无法在 Docker Hub 的 dwhitena
下构建和推送自己的镜像,因为你不是 dwhitena
。或者,你可以在示例中的所有地方用你自己的 Docker Hub 用户名替换 dwhitena
。这将允许你构建、推送并使用你自己的镜像。
要构建、推送并清理用于单变量或多元回归模型的 Docker 镜像,我们只需运行 make
,如下面的代码所示,该代码本身是用 Go 编写的:
$ make
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o goregtrain
sudo docker build --force-rm=true -t dwhitena/goregtrain:multi .
[sudo] password for dwhitena:
Sending build context to Docker daemon 2.449MB
Step 1/2 : FROM alpine
---> 7328f6f8b418
Step 2/2 : ADD goregtrain /
---> 9c13594ad7de
Removing intermediate container 6e717232c6d1
Successfully built 9c13594ad7de
Successfully tagged dwhitena/goregtrain:multi
sudo docker push dwhitena/goregtrain:multi
The push refers to a repository [docker.io/dwhitena/goregtrain]
375642156c06: Pushed
5bef08742407: Layer already exists
multi: digest: sha256:f35277a46ed840922a0d7e94c3c57632fc947f6ab004deed66d3eb80a331e40f size: 738
rm goregtrain
你可以在前面的输出中看到,Docker 引擎构建了我们的镜像的两个层,标记了镜像,并将其推送到 Docker Hub。现在我们可以通过以下方式在我们的本地注册库中看到 Docker 镜像:
$ sudo docker images | grep "goregtrain"
dwhitena/goregtrain multi 9c13594ad7de About a minute ago 6.41MB
你也可以在这里看到它(hub.docker.com/r/dwhitena/goregtrain/
)的 Docker Hub 上,如图所示:
我们将很快看到如何运行和利用这个 Docker 镜像,但首先让我们构建另一个 Docker 镜像,该镜像将根据我们 JSON 持久化的模型生成预测。
将模型预测 Docker 化
就像我们的模型训练一样,我们将利用命令行参数来指定我们的预测程序使用的输入目录和输出目录。这次我们将有两个输入目录;一个用于持久化的模型,另一个用于包含我们将从中进行预测的属性的目录。因此,我们的程序将执行以下操作:
-
从模型输入目录中读取模型。
-
遍历属性输入目录中的文件。
-
对于属性输入目录中的每个文件(包含没有相应疾病进展预测的属性),使用我们加载的模型来预测疾病进展。
-
将疾病进展输出到由命令行参数指定的目录中的输出文件。
将这个过程想象成这样。我们已经在历史数据上训练了我们的模型来预测疾病进展,我们希望医生或诊所以某种方式利用这种预测为新患者提供服务。他们发送给我们这些患者的属性(体质指数(BMI)或体质指数和长期增长(LTG)),然后我们根据这些输入属性进行预测。
我们将假设输入属性以 JSON 文件的形式进入我们的程序(这也可以被视为来自队列或 JSON API 响应的 JSON 消息),如下所示:
{
"independent_variables": [
{
"name": "bmi",
"value": 0.0616962065187
},
{
"name": "ltg",
"value": 0.0199084208763
}
]
}
因此,让我们在我们的预测程序中创建以下结构类型,以解码输入属性并序列化输出预测:
// PredictionData includes the data necessary to make
// a prediction and encodes the output prediction.
type PredictionData struct {
Prediction float64 `json:"predicted_diabetes_progression"`
IndependentVars []IndependentVar `json:"independent_variables"`
}
// IndependentVar include information about and a
// value for an independent variable.
type IndependentVar struct {
Name string `json:"name"`
Value float64 `json:"value"`
}
让我们再创建一个函数,允许我们根据从模型输入目录(即我们持久化的模型)读取的ModelInfo
值进行预测。以下代码展示了这个prediction
函数:
// Predict makes a prediction based on input JSON.
func Predict(modelInfo *ModelInfo, predictionData *PredictionData) error {
// Initialize the prediction value
// to the intercept.
prediction := modelInfo.Intercept
// Create a map of independent variable coefficients.
coeffs := make(map[string]float64)
varNames := make([]string, len(modelInfo.Coefficients))
for idx, coeff := range modelInfo.Coefficients {
coeffs[coeff.Name] = coeff.Coefficient
varNames[idx] = coeff.Name
}
// Create a map of the independent variable values.
varVals := make(map[string]float64)
for _, indVar := range predictionData.IndependentVars {
varVals[indVar.Name] = indVar.Value
}
// Loop over the independent variables.
for _, varName := range varNames {
// Get the coefficient.
coeff, ok := coeffs[varName]
if !ok {
return fmt.Errorf("Could not find model coefficient %s", varName)
}
// Get the variable value.
val, ok := varVals[varName]
if !ok {
return fmt.Errorf("Expected a value for variable %s", varName)
}
// Add to the prediction.
prediction = prediction + coeff*val
}
// Add the prediction to the prediction data.
predictionData.Prediction = prediction
return nil
}
这个预测函数以及类型将允许我们在指定的输入目录中遍历任何属性 JSON 文件,并为每个文件输出疾病预测。这个过程在以下代码中实现:
// Declare the input and output directory flags.
inModelDirPtr := flag.String("inModelDir", "", "The directory containing the model.")
inVarDirPtr := flag.String("inVarDir", "", "The directory containing the input attributes.")
outDirPtr := flag.String("outDir", "", "The output directory")
// Parse the command line flags.
flag.Parse()
// Load the model file.
f, err := ioutil.ReadFile(filepath.Join(*inModelDirPtr, "model.json"))
if err != nil {
log.Fatal(err)
}
// Unmarshal the model information.
var modelInfo ModelInfo
if err := json.Unmarshal(f, &modelInfo); err != nil {
log.Fatal(err)
}
// Walk over files in the input.
if err := filepath.Walk(*inVarDirPtr, func(path string, info os.FileInfo, err error) error {
// Skip any directories.
if info.IsDir() {
return nil
}
// Open any files.
f, err := ioutil.ReadFile(filepath.Join(*inVarDirPtr, info.Name()))
if err != nil {
return err
}
// Unmarshal the independent variables.
var predictionData PredictionData
if err := json.Unmarshal(f, &predictionData); err != nil {
return err
}
// Make the prediction.
if err := Predict(&modelInfo, &predictionData); err != nil {
return err
}
// Marshal the prediction data.
outputData, err := json.MarshalIndent(predictionData, "", " ")
if err != nil {
log.Fatal(err)
}
// Save the marshalled output to a file.
if err := ioutil.WriteFile(filepath.Join(*outDirPtr, info.Name()), outputData, 0644); err != nil {
log.Fatal(err)
}
return nil
}); err != nil {
log.Fatal(err)
}
我们将使用与上一节相同的Dockerfile
和Makefile
来将此预测程序 Docker 化。唯一的区别是,我们将 Go 二进制文件命名为goregpredict
,并将 Docker 镜像标记为dwhitena/goregpredict
。使用make
构建 Docker 镜像应该会返回与上一节类似的结果:
$ cat Makefile
all: compile docker push clean
compile:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o goregpredict
docker:
sudo docker build --force-rm=true -t dwhitena/goregpredict .
push:
sudo docker push dwhitena/goregpredict
clean:
rm goregpredict
$ cat Dockerfile
FROM alpine
ADD goregpredict /
$ make
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o goregpredict
sudo docker build --force-rm=true -t dwhitena/goregpredict .
[sudo] password for dwhitena:
Sending build context to Docker daemon 2.38MB
Step 1/2 : FROM alpine
---> 7328f6f8b418
Step 2/2 : ADD goregpredict /
---> a2d9a63f4926
Removing intermediate container c1610b425835
Successfully built a2d9a63f4926
Successfully tagged dwhitena/goregpredict:latest
sudo docker push dwhitena/goregpredict
The push refers to a repository [docker.io/dwhitena/goregpredict]
77f12cb6c6d4: Pushed
5bef08742407: Layer already exists
latest: digest: sha256:9a8a754c434bf2250b2f6102bb72d56fdf723f305aebcbf5bff7e5de707dd384 size: 738
3a05f65b1d1d: Layer already exists
5bef08742407: Layer already exists
ult: digest: sha256:153adaa9b4b9a1f2bf02466201163c60230ae85164d9d22261f455979a94aed4 size: 738
rm goregpredict
本地测试 Docker 镜像
在将我们的 Docker 化建模过程推送到任何服务器之前,在本地测试它们是明智的,以确保我们看到我们预期的行为。然后,一旦我们对这种行为感到满意,我们可以确信这些 Docker 镜像将在任何运行 Docker 的其他主机上以完全相同的方式运行。这种保证使得 Docker 镜像的使用成为我们部署中保持可重复性的重要贡献者。
假设我们的训练数据和一些输入属性文件位于以下目录中:
$ ls
attributes model training
$ ls model
$ ls attributes
1.json 2.json 3.json
$ ls training
diabetes.csv
我们可以使用docker run
命令在本地以软件容器的形式运行我们的 Docker 镜像。我们还将利用-v
标志,它允许我们将本地目录挂载到正在运行的容器中,使我们能够从本地文件系统中读取和写入文件。
首先,让我们按照以下方式在 Docker 容器中运行我们的单个回归模型训练:
$ sudo docker run -v $PWD/training:/tmp/training -v $PWD/model:/tmp/model dwhitena/goregtrain:single /goregtrain -inDir=/tmp/training -outDir=/tmp/model
Regression Formula:
Predicted = 152.13 + bmi*949.44
现在,如果我们查看我们的model
目录,我们将看到新训练的模型系数和截距在model.json
中,这是我们的程序在 Docker 镜像中运行时输出的。以下代码展示了这一点:
$ ls model
model.json
$ cat model/model.json
{
"intercept": 152.13348416289818,
"coefficients": [
{
"name": "bmi",
"coefficient": 949.4352603839862
}
]
}
极好!我们使用 Docker 容器训练了我们的模型。现在让我们利用这个模型进行预测。具体来说,让我们使用训练好的模型运行我们的goregpredict
Docker 镜像,对attributes
中的三个属性文件进行预测。在下面的代码中,你将看到在运行 Docker 镜像之前,属性文件没有相应的预测,但在运行 Docker 镜像之后,它们就有了:
$ cat attributes/1.json
{
"independent_variables": [
{
"name": "bmi",
"value": 0.0616962065187
},
{
"name": "ltg",
"value": 0.0199084208763
}
]
}
$ sudo docker run -v $PWD/attributes:/tmp/attributes -v $PWD/model:/tmp/model dwhitena/goregpredict /goregpredict -inModelDir=/tmp/model -inVarDir=/tmp/attributes -outDir=/tmp/attributes
$ cat attributes/1.json
{
"predicted_diabetes_progression": 210.7100380636843,
"independent_variables": [
{
"name": "bmi",
"value": 0.0616962065187
},
{
"name": "ltg",
"value": 0.0199084208763
}
]
}
在远程机器上运行 Docker 镜像
你可能正在想,“这有什么大不了的,你通过构建和运行你的 Go 程序就能得到相同的功能”。这在本地是正确的,但 Docker 的魔力在于我们想要在除了笔记本电脑之外的环境中运行相同的功能时。
我们可以将这些 Docker 镜像在任何运行 Docker 的主机上以完全相同的方式运行,并且它们将产生完全相同的行为。这对于任何 Docker 镜像都是成立的。你不需要安装 Docker 镜像内部可能包含的任何依赖项。你需要的只是 Docker。
猜猜看?我们的 Docker 镜像大小大约是 3 MB!这意味着它们将非常快地下载到任何主机上,并且启动和运行也非常快。你不必担心携带多个多吉字节大小的虚拟机,并手动指定资源。
构建可扩展且可重复的机器学习流程
Docker 为公司的基础设施中部署我们的机器学习工作流程做了很多工作。然而,正如这里概述的那样,还有一些缺失的部分。
-
我们如何将工作流程的各个阶段连接起来?在这个简单的例子中,我们有一个训练阶段和一个预测阶段。在其他流程中,你可能还会有数据预处理、数据拆分、数据合并、可视化、评估等等。
-
如何将正确的数据传输到工作流程的各个阶段,尤其是在我们接收新数据或数据发生变化时?每次我们需要进行新的预测时,手动将新属性复制到与我们的预测镜像位于同一文件夹中是不可持续的,并且我们不能每次更新训练集时都登录到服务器。
-
我们将如何跟踪和重现我们的工作流程的各种运行,以进行维护、持续开发或调试?如果我们随着时间的推移进行预测并更新我们的模型和/或训练集,我们需要了解哪些模型产生了哪些结果,以保持可重复性(并在某些情况下满足合规性)。
-
我们如何将我们的处理过程扩展到多台机器上,在某些情况下,扩展到多个共享资源上?我们可能需要在公司的一些共享资源集上运行我们的处理过程。如果我们需要更多的计算能力,或者当其他应用程序在这些资源上被调度时,我们能够扩展我们的处理过程将会很理想。
幸运的是,还有一些开源工具(都是用 Go 编写的)为我们解决了这些问题。不仅如此,它们还允许我们使用已经构建好的 Docker 镜像作为数据处理的主要单元。这些工具是Kubernetes(k8s)和Pachyderm。
您已经在第一章,《收集和组织数据》中接触到了 Pachyderm。在那章中,我们利用 Pachyderm 来阐述数据版本化的概念,正如您可能猜到的,Pachyderm 将帮助我们解决一些关于数据管理、跟踪和可重复性的问题。Pachyderm 还将解决我们需要解决的关于可扩展性和管道的所有剩余问题,因为 Pachyderm 提供了数据管理/版本化能力和数据处理管道能力。
Kubernetes 是一个容器编排引擎,擅长在共享资源集群中调度容器化工作负载(如我们的 Docker 镜像)。它目前非常流行,Pachyderm 在底层使用 Kubernetes 来管理容器化数据处理管道。
设置 Pachyderm 和 Kubernetes 集群
在 Kubernetes 上运行的 Pachyderm 几乎可以部署在任何地方,因为 Kubernetes 可以部署在任何地方。您可以在任何流行的云服务提供商、本地或甚至在自己的笔记本电脑上部署 Pachyderm。部署 Pachyderm 集群您需要做的只是以下几步:
-
拥有一个运行的 Kubernetes 集群。
-
有权访问您选择的对象存储(例如,S3、Glasglow Coma Scale(GCS)或 Minio)。这个对象存储将作为 Pachyderm 集群的存储后端,所有版本化和处理后的数据都存储在这里。
-
使用 Pachyderm CLI
pachctl
在 Kubernetes 集群上部署 Pachyderm。
在pachyderm.readthedocs.io/en/latest/deployment/deploy_intro.html
可以找到部署 Pachyderm 到任何云服务提供商或本地环境的详细说明。或者,您可以使用minikube
轻松地实验和开发本地 Pachyderm 集群,具体细节请参阅pachyderm.readthedocs.io/en/latest/getting_started/local_installation.html
。
按照这些说明中的一套,您应该可以达到以下状态:有一个 Kubernetes 集群正在以下状态下运行(可以使用名为kubectl
的 Kubernetes CLI 工具进行检查):
$ kubectl get all
NAME READY STATUS RESTARTS AGE
po/etcd-2142892294-38ptw 1/1 Running 0 2m
po/pachd-776177201-04l6w 1/1 Running 0 2m
NAME CLUSTER-IP EXTERNAL-IP PORT(S) AGE
svc/etcd 10.0.0.141 <nodes> 2379:32379/TCP 2m
svc/kubernetes 10.0.0.1 <none> 443/TCP 3m
svc/pachd 10.0.0.178 <nodes> 650:30650/TCP,651:30651/TCP,652:30652/TCP 2m
NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
deploy/etcd 1 1 1 1 2m
deploy/pachd 1 1 1 1 2m
NAME DESIRED CURRENT READY AGE
rs/etcd-2142892294 1 1 1 2m
rs/pachd-776177201 1 1 1 2m
在这里,您可以看到 Pachyderm 守护程序(或服务器),pachd
,作为pod在 Kubernetes 集群中运行,pod 只是由一个或多个容器组成的一组。我们的管道阶段也将作为 Kubernetes pod 运行,但您不必过于担心这一点,因为 Pachyderm 会处理细节。
上面的输出中列出的端口和 IP 地址可能因你的部署和不同的配置而异。然而,一个健康的 Pachyderm 集群看起来应该非常相似。
如果你的 Pachyderm 集群正在运行,并且你遵循了 Pachyderm 部署指南之一,那么你也应该已经安装了pachctl
CLI 工具。当pachctl
成功连接到 Pachyderm 集群时,你可以运行以下命令作为进一步的合理性检查(版本号可能根据你运行的 Pachyderm 版本而变化):
$ pachctl version
COMPONENT VERSION
pachctl 1.6.0
pachd 1.6.0
如果你遇到部署 Pachyderm 或构建和运行流程的任何问题,Pachyderm 社区有一个优秀的公共 Pachyderm Slack 频道。你可以通过访问slack.pachyderm.io/
加入。社区每天都在那里活跃,你提出的任何问题都将受到欢迎。
构建 Pachyderm 机器学习流程
正如你所看到的,我们的示例机器学习流程有两个阶段。我们的流程中的模型阶段将训练一个模型并将该模型的持久化版本导出到文件中。我们的流程中的预测阶段将利用训练好的模型对输入属性文件进行预测。总的来说,这个流程在 Pachyderm 中看起来如下:
前一个图中的每个圆柱体代表一个 Pachyderm 版本化数据的数据存储库。你已经在第一章,“收集和组织数据”中接触到了这些数据存储库。每个框代表一个容器化的数据处理阶段。Pachyderm 中数据处理的基本单元是 Docker 镜像。因此,我们可以利用在前几节中创建的 Docker 镜像来利用这个数据流程。
通过将版本化的数据集合(再次,将这些视为一种“数据 Git”),使用容器处理这些数据集合,并将结果保存到其他版本化的数据集合中,Pachyderm 流程有一些相当有趣和有用的含义。
首先,我们可以回到任何时间点,查看我们数据在那个时间点的任何部分的状态。如果我们想要开发我们数据的一个特定状态,这可能会在进一步开发我们的模型时帮助我们。这也可能帮助我们通过跟踪团队随时间变化的数据来协作开发。并不是所有的数据更改都是好的,我们需要在将不良或损坏的数据提交到系统中后能够回滚的能力。
接下来,我们所有管道的结果都与产生这些结果的精确 Docker 镜像和其他数据的精确状态相关联。Pachyderm 团队称这为溯源。这是理解哪些处理部分和哪些数据部分贡献了特定结果的能力。例如,使用这个管道,我们能够确定产生特定结果的持久化模型的精确版本,以及产生该模型的精确 Docker 镜像和输入到该精确 Docker 镜像的训练数据的精确状态。因此,我们能够精确地重现任何管道的整个运行过程,调试奇怪模型行为,并将结果归因于相应的输入数据。
最后,因为 Pachyderm 知道你的数据存储库中哪些部分是新的或不同的,我们的数据处理管道可以增量处理数据,并且可以自动触发。假设我们已经将一百万个属性文件提交到attributes
数据存储库中,然后我们提交了十个更多的属性文件。我们不需要重新处理前一百万个文件来更新我们的结果。Pachyderm 知道我们只需要处理这十个新文件,并将它们发送到prediction
阶段进行处理。此外,Pachyderm 将(默认情况下)自动触发这个更新,因为它知道需要更新以保持结果与输入同步。
增量处理和自动触发是 Pachyderm 管道的一些默认行为,但它们并不是你可以用 Pachyderm 做的唯一事情。实际上,我们在这里只是触及了 Pachyderm 可能性的表面。你可以进行分布式 map/reduce 风格的操作,分布式图像处理,定期处理数据库表,以及更多更多。因此,这里的技巧应该可以转移到各种领域。
创建和填充输入存储库
要在我们部署的 Pachyderm 集群上创建 Pachyderm 管道,我们首先需要为管道创建输入存储库。记住,我们的管道有training
和attributes
数据存储库,它们驱动着管道的其余部分。当我们将这些数据放入这些存储库时,Pachyderm 将触发管道的下游部分并计算结果。
在第一章“收集和组织数据”中,我们看到了如何在 Pachyderm 中创建数据存储库,并使用pachctl
将这些数据提交到这些存储库。但在这里,我们尝试直接从一个 Go 程序中完成这个操作。在这个特定的例子中,这样做并没有任何实际优势,因为我们已经将训练和测试数据存储在文件中,我们可以很容易地通过名称将这些文件提交到 Pachyderm。
然而,想象一个更现实的场景。我们可能希望我们的数据管道与我们公司已经编写的某些其他 Go 服务集成。这些服务之一可能会处理来自医生或诊所的传入患者医疗属性,我们希望将这些属性馈送到我们的数据管道中,以便数据管道可以做出相应的疾病进展预测。
在这种情况下,我们理想情况下希望直接从我们的服务将属性传递到 Pachyderm,而不是手动复制所有这些数据。这就是 Pachyderm Go 客户端,github.com/pachyderm/pachyderm/src/client
能派上大用场的地方。使用 Pachyderm Go 客户端,我们可以创建我们的输入仓库,如下所示(在连接到 Pachyderm 集群之后):
// Connect to Pachyderm using the IP of our
// Kubernetes cluster. Here we will use localhost
// to mimic the scenario when you have k8s running
// locally and/or you are forwarding the Pachyderm
// port to your localhost.. By default
// Pachyderm will be exposed on port 30650.
c, err := client.NewFromAddress("0.0.0.0:30650")
if err != nil {
log.Fatal(err)
}
defer c.Close()
// Create a data repository called "training."
if err := c.CreateRepo("training"); err != nil {
log.Fatal(err)
}
// Create a data repository called "attributes."
if err := c.CreateRepo("attributes"); err != nil {
log.Fatal(err)
}
// Now, we will list all the current data repositories
// on the Pachyderm cluster as a sanity check. We
// should now have two data repositories.
repos, err := c.ListRepo(nil)
if err != nil {
log.Fatal(err)
}
// Check that the number of repos is what we expect.
if len(repos) != 2 {
log.Fatal("Unexpected number of data repositories")
}
// Check that the name of the repo is what we expect.
if repos[0].Repo.Name != "attributes" || repos[1].Repo.Name != "training" {
log.Fatal("Unexpected data repository name")
}
编译并运行它确实创建了预期的repos
,如下所示:
$ go build
$ ./myprogram
$ pachctl list-repo
NAME CREATED SIZE
attributes 3 seconds ago 0B
training 3 seconds ago 0B
为了简单起见,上面的代码只是创建并检查了一次仓库。如果你再次运行代码,你会得到一个错误,因为仓库已经被创建了。为了改进这一点,你可以检查仓库是否存在,如果不存在则创建它。你也可以通过名称检查仓库是否存在。
现在,让我们将一个属性文件放入attributes
仓库,并将diabetes.csv
训练数据集放入training
仓库。这也可以通过 Pachyderm 客户端直接在 Go 中轻松完成:
// Connect to Pachyderm.
c, err := client.NewFromAddress("0.0.0.0:30650")
if err != nil {
log.Fatal(err)
}
defer c.Close()
// Start a commit in our "attributes" data repo on the "master" branch.
commit, err := c.StartCommit("attributes", "master")
if err != nil {
log.Fatal(err)
}
// Open one of the attributes JSON files.
f, err := os.Open("1.json")
if err != nil {
log.Fatal(err)
}
// Put a file containing the attributes into the data repository.
if _, err := c.PutFile("attributes", commit.ID, "1.json", f); err != nil {
log.Fatal(err)
}
// Finish the commit.
if err := c.FinishCommit("attributes", commit.ID); err != nil {
log.Fatal(err)
}
// Start a commit in our "training" data repo on the "master" branch.
commit, err = c.StartCommit("training", "master")
if err != nil {
log.Fatal(err)
}
// Open up the training data set.
f, err = os.Open("diabetes.csv")
if err != nil {
log.Fatal(err)
}
// Put a file containing the training data set into the data repository.
if _, err := c.PutFile("training", commit.ID, "diabetes.csv", f); err != nil {
log.Fatal(err)
}
// Finish the commit.
if err := c.FinishCommit("training", commit.ID); err != nil {
log.Fatal(err)
}
运行此操作确实将数据提交到 Pachyderm 的正确数据仓库中,如下所示:
$ go build
$ ./myprogram
$ pachctl list-repo
NAME CREATED SIZE
training 13 minutes ago 73.74KiB
attributes 13 minutes ago 210B
$ pachctl list-file training master
NAME TYPE SIZE
diabetes.csv file 73.74KiB
$ pachctl list-file attributes master
NAME TYPE SIZE
1.json file 210B
完成我们上面开始提交的提交是很重要的。如果你留下一个孤立的提交悬而未决,你可能会阻塞其他数据提交。因此,你可能考虑推迟提交的完成,以确保安全。
好的!现在我们已经在远程 Pachyderm 集群的适当输入数据仓库中有数据了。接下来,我们实际上可以处理这些数据并生成结果。
创建和运行处理阶段
在 Pachyderm 中,通过一个管道规范声明性地创建处理阶段。如果你之前使用过 Kubernetes,这种交互应该很熟悉。基本上,我们向 Pachyderm 声明我们想要进行哪些处理,然后 Pachyderm 处理所有细节以确保这些处理按声明执行。
让我们首先使用存储在train.json
中的 JSON 管道规范来创建我们管道的model
阶段。这个 JSON 规范如下:
{
"pipeline": {
"name": "model"
},
"transform": {
"image": "dwhitena/goregtrain:single",
"cmd": [
"/goregtrain",
"-inDir=/pfs/training",
"-outDir=/pfs/out"
]
},
"parallelism_spec": {
"constant": "1"
},
"input": {
"atom": {
"repo": "training",
"glob": "/"
}
}
}
这可能看起来有些复杂,但实际上这里只发生了几件事情。让我们分析这个规范,了解不同的部分:
-
首先,我们告诉 Pachyderm 将我们的管道命名为
model
。 -
接下来,我们告诉 Pachyderm,我们希望这个
model
管道使用我们的dwhitena/goregtrain:single
来处理数据,并且我们希望在处理数据时在容器中运行我们的goregtrain
二进制文件。 -
最后,规范告诉 Pachyderm 我们打算将
training
数据存储库作为输入处理。
为了理解规范的一些其他细节,我们首先需要讨论当我们使用规范在我们的 Pachyderm 集群上创建管道时会发生什么。当这种情况发生时,Pachyderm 将使用 Kubernetes 在 Kubernetes 集群上调度一个或多个工作 pod。这些工作 pod 将等待,准备好处理由 Pachyderm 守护进程提供给它们的任何数据。当输入存储库 training
中有需要处理的数据时,Pachyderm 守护进程将触发一个作业,这些工作进程将处理数据。Pachyderm 将自动将输入数据挂载到容器的 /pfs/<input repo name>
(在本例中为 /pfs/training
)。容器写入 /pfs/out
的数据将被自动版本化到具有与管道相同名称的输出数据存储库(在本例中为 model
)。
规范中的 parallelism_spec
部分让我们可以告诉 Pachyderm 它应该启动多少个工作进程来处理输入数据。在这里,我们只启动一个工作进程并串行处理数据。在章节的后面,我们将讨论并行化我们的管道和相关的数据分片,这由规范中 input
部分的 glob
模式控制。
虽然说得够多了!让我们实际上在我们的 Pachyderm 集群上创建这个 model
管道阶段,并开始处理一些数据。管道可以轻松地按以下方式创建:
$ pachctl create-pipeline -f model.json
当我们这样做时,我们可以使用 kubectl
在 Kubernetes 集群中看到 Pachyderm 创建的工作进程(如下面的代码所示):
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
etcd-2142892294-38ptw 1/1 Running 0 2h
pachd-776177201-04l6w 1/1 Running 0 2h
pipeline-model-v1-p0lnf 2/2 Running 0 1m
我们还将看到 Pachyderm 已经自动触发了一个作业来执行我们的模型训练。记住,这是因为 Pachyderm 知道输入存储库 training
中有尚未处理的数据。你可以如下查看触发的作业及其结果:
$ pachctl list-job
ID OUTPUT COMMIT STARTED DURATION RESTART PROGRESS DL UL STATE
14f052ae-878d-44c9-a1f9-ab0cf6d45227 model/a2c7b7dfb44a40e79318c2de30c7a0c8 3 minutes ago Less than a second 0 1 + 0 / 1 73.74KiB 160B success
$ pachctl list-repo
NAME CREATED SIZE
model 3 minutes ago 160B
training About an hour ago 73.74KiB
attributes About an hour ago 210B
$ pachctl list-file model master
NAME TYPE SIZE k8s
model.json file 160B
$ pachctl get-file model master model.json
{
"intercept": 152.13348416289818,
"coefficients": [
{
"name": "bmi",
"coefficient": 949.4352603839862
}
]
}
太棒了!我们可以看到我们的模型训练产生了我们预期的结果。然而,Pachyderm 足够聪明,足以触发管道,并且每当我们在训练数据集中修改模型时,它都会更新这个模型。
prediction
管道也可以以类似的方式创建。其管道规范 pipeline.json
如下所示:
{
"pipeline": {
"name": "prediction"
},
"transform": {
"image": "dwhitena/goregpredict",
"cmd": [
"/goregpredict",
"-inModelDir=/pfs/model",
"-inVarDir=/pfs/attributes",
"-outDir=/pfs/out"
]
},
"parallelism_spec": {
"constant": "1"
},
"input": {
"cross": [
{
"atom": {
"repo": "attributes",
"glob": "/*"
}
},
{
"atom": {
"repo": "model",
"glob": "/"
}
}
]
}
}
在这个管道规范中的主要区别是我们有两个输入存储库而不是一个。这两个输入存储库是我们之前创建的 attributes
存储库和包含 model
管道输出的 model
存储库。这些是通过 cross
原始操作结合的。cross
确保处理我们模型和属性文件的所有相关对。
如果你感兴趣,你可以在 Pachyderm 文档中了解更多关于在 Pachyderm 中组合数据的其他方法;请访问 pachyderm.readthedocs.io/en/latest/
。
创建此流水线并检查结果(对应于1.json
)可以按照以下方式完成:
$ pachctl create-pipeline -f prediction.json
$ pachctl list-job
ID OUTPUT COMMIT STARTED DURATION RESTART PROGRESS DL UL STATE
03f36398-89db-4de4-ad3d-7346d56883c0 prediction/5ce47c9e788d4893ae00c7ee6b1e8431 About a minute ago Less than a second 0 1 + 0 / 1 370B 266B success
14f052ae-878d-44c9-a1f9-ab0cf6d45227 model/a2c7b7dfb44a40e79318c2de30c7a0c8 19 minutes ago Less than a second 0 1 + 0 / 1 73.74KiB 160B success
$ pachctl list-repo
NAME CREATED SIZE
prediction About a minute ago 266B
model 19 minutes ago 160B
training About an hour ago 73.74KiB
attributes About an hour ago 210B
$ pachctl list-file prediction master
NAME TYPE SIZE
1.json file 266B
$ pachctl get-file prediction master 1.json
{
"predicted_diabetes_progression": 210.7100380636843,
"independent_variables": [
{
"name": "bmi",
"value": 0.0616962065187
},
{
"name": "ltg",
"value": 0.0199084208763
}
]
}
您可以看到,为了版本控制prediction
流水线的输出,创建了一个新的数据仓库prediction
。以这种方式,Pachyderm 逐渐建立起输入数据仓库、数据处理阶段和输出数据仓库之间的链接,这样它总是知道哪些数据和数据处理组件是链接在一起的。
完整的机器学习流水线现在已部署,我们已经运行了流水线的每个阶段。然而,流水线已经准备好并等待处理更多数据。我们只需将数据放在流水线的顶部,Pachyderm 就会自动触发任何需要发生的下游处理来更新我们的结果。让我们通过将两个更多文件提交到attributes
仓库来展示这一点,如下所示:
$ ls
2.json 3.json
$ pachctl put-file attributes master -c -r -f .
$ pachctl list-file attributes master
NAME TYPE SIZE
1.json file 210B
2.json file 211B
3.json file 211B
这将自动触发另一个预测作业来更新我们的结果。新的作业可以在以下代码中看到:
$ pachctl list-job
ID OUTPUT COMMIT STARTED DURATION RESTART PROGRESS DL UL STATE
be71b034-9333-4f75-b443-98d7bc5b48ab prediction/bb2fd223df664e70ad14b14491109c0f About a minute ago Less than a second 0 2 + 1 / 3 742B 536B success
03f36398-89db-4de4-ad3d-7346d56883c0 prediction/5ce47c9e788d4893ae00c7ee6b1e8431 8 minutes ago Less than a second 0 1 + 0 / 1 370B 266B success
14f052ae-878d-44c9-a1f9-ab0cf6d45227 model/a2c7b7dfb44a40e79318c2de30c7a0c8 26 minutes ago Less than a second 0 1 + 0 / 1 73.74KiB 160B success
此外,我们还可以看到,在再次对两个新的输入文件运行预测代码后,prediction
仓库出现了两个新的结果:
$ pachctl list-file prediction master
NAME TYPE SIZE
1.json file 266B
2.json file 268B
3.json file 268B
注意,尽管它显示了最新的结果,但 Pachyderm 在这里并没有重新处理1.json
。在底层,Pachyderm 知道在attributes
中已经有一个与1.json
相对应的结果,因此没有必要重新处理它。
更新流水线和检查溯源
随着时间的推移,我们很可能会想要更新我们的机器学习模型。它们处理的数据可能已经改变,或者我们可能已经开发了一个不同的或更好的模型。无论如何,我们很可能会需要更新我们的流水线。
假设我们的流水线处于前一个章节所描述的状态,其中我们已经创建了完整的流水线,训练过程已经运行过一次,并且我们已经将两个提交处理到了attributes
仓库中。现在,让我们更新模型流水线以训练我们的多元回归
现在,让我们更新模型流水线以训练我们的多元回归模型,而不是单个回归模型。这实际上非常简单。我们只需要将model.json
流水线规范中的"image": "dwhitena/goregtrain:single"
更改为"image": "dwhitena/goregtrain:multi"
,然后按照以下方式更新流水线:
$ pachctl update-pipeline -f model.json --reprocess
当我们向 Pachyderm 提供这个新的规范时,Pachyderm 将自动更新工作节点,使它们运行带有多元回归模型的容器。此外,因为我们提供了--reprocess
标志,Pachyderm 将触发一个或多个新的作业来重新处理任何使用新图像处理过的旧图像的输入提交。我们可以按照以下方式查看这些重新处理作业:
$ pachctl list-job
ID OUTPUT COMMIT STARTED DURATION RESTART PROGRESS DL UL STATE
03667301-980b-484b-afbe-7ea30da695f5 prediction/ef32a74b04ee4edda7bc2a2b469f3e03 2 minutes ago Less than a second 0 3 + 0 / 3 1.355KiB 803B success
5587e13c-4854-4f3a-bc4c-ae88c65c007f model/f54ca1a0142542579c1543e41f5e9403 2 minutes ago Less than a second 0 1 + 0 / 1 73.74KiB 252B success
be71b034-9333-4f75-b443-98d7bc5b48ab prediction/bb2fd223df664e70ad14b14491109c0f 16 minutes ago Less than a second 0 2 + 1 / 3 742B 536B success
03f36398-89db-4de4-ad3d-7346d56883c0 prediction/5ce47c9e788d4893ae00c7ee6b1e8431 23 minutes ago Less than a second 0 1 + 0 / 1 370B 266B success
14f052ae-878d-44c9-a1f9-ab0cf6d45227 model/a2c7b7dfb44a40e79318c2de30c7a0c8 41 minutes ago Less than a second 0 1 + 0 / 1 73.74KiB 160B success
看着这个输出,我们可以注意到一些有趣且非常有用的东西。当我们的model
阶段训练了新的多元回归模型并将该模型输出为model.json
时,Pachyderm 发现我们的prediction
管道的结果现在与最新模型不同步。因此,Pachyderm 自动触发了另一个作业,使用新的多元回归模型更新我们之前的结果。
这种工作流程在管理已部署的模型以及在模型开发期间都非常有用,因为你不需要手动移动一大堆模型版本和相关数据。所有事情都是自动发生的。然而,从这个更新中自然会产生一个疑问。我们如何知道哪些模型产生了哪些结果?
嗯,这正是 Pachyderm 的数据版本控制与管道结合真正发光的地方。我们可以查看任何预测结果并检查相应的提交,以确定提交的来源。这在此处得到了说明:
$ pachctl inspect-commit prediction ef32a74b04ee4edda7bc2a2b469f3e03
Commit: prediction/ef32a74b04ee4edda7bc2a2b469f3e03
Parent: bb2fd223df664e70ad14b14491109c0f
Started: 8 minutes ago
Finished: 8 minutes ago
Size: 803B
Provenance: training/e0f9357455234d8bb68540af1e8dde81 attributes/2e5fef211f14498ab34c0520727296bb model/f54ca1a0142542579c1543e41f5e9403
在这个例子中,我们可以看到预测ef32a74b04ee4edda7bc2a2b469f3e03
是由模型f54ca1a0142542579c1543e41f5e9403
产生的,该模型可以在此处检索到:
$ pachctl get-file model f54ca1a0142542579c1543e41f5e9403 model.json
{
"intercept": 152.13348416289583,
"coefficients": [
{
"name": "bmi",
"coefficient": 675.069774431606
},
{
"name": "ltg",
"coefficient": 614.9505047824742
}
]
}
然而,如果我们查看之前的预测,我们会看到它是由不同的模型产生的:
$ pachctl inspect-commit prediction bb2fd223df664e70ad14b14491109c0f
Commit: prediction/bb2fd223df664e70ad14b14491109c0f
Parent: 5ce47c9e788d4893ae00c7ee6b1e8431
Started: 25 minutes ago
Finished: 25 minutes ago
Size: 802B
Provenance: model/a2c7b7dfb44a40e79318c2de30c7a0c8 training/e0f9357455234d8bb68540af1e8dde81 attributes/2e5fef211f14498ab34c0520727296bb
$ pachctl get-file model a2c7b7dfb44a40e79318c2de30c7a0c8 model.json
{
"intercept": 152.13348416289818,
"coefficients": [
{
"name": "bmi",
"coefficient": 949.4352603839862
}
]
}
我们可以始终追溯管道中任何数据片段的来源,无论运行了多少个作业以及我们更新管道的次数。这对于创建可持续的机器学习工作流程非常重要,这些工作流程可以随着时间的推移进行维护、改进和更新,而不会遇到大的麻烦。
缩放管道阶段
Pachyderm 中的每个管道规范都可以有一个相应的parallelism_spec
字段。这个字段,连同你的输入中的glob
模式,允许你并行化你的管道阶段在其输入数据上。每个管道阶段都是独立可扩展的,与其他所有管道阶段无关。
管道规范中的parallelism_spec
字段允许你控制 Pachyderm 将启动多少个工作进程来处理该管道阶段的数據。例如,以下parallelism_spec
将告诉 Pachyderm 为管道启动 10 个工作进程:
"parallelism_spec": {
"constant": "10"
},
你的输入中的glob
模式告诉 Pachyderm 如何根据parallelism_spec
中声明的工怍进程共享你的输入数据。例如,glob
模式/*
会告诉 Pachyderm 它可以分割你的输入数据为包含任何文件或目录在仓库根目录的 datums。glob
模式/*/*
会告诉 Pachyderm 它可以分割你的数据为包含文件或目录在仓库目录结构深处的 datums。如果你对 glob 模式不熟悉,可以查看以下带有一些示例的描述:en.wikipedia.org/wiki/Glob_(programming)
。
在我们的示例管道中,我们可以想象我们需要扩展管道的 prediction
阶段,因为我们看到了大量需要相应预测的属性涌入。如果情况如此,我们可以在 prediction.json
管道规范中的 parallelism_spec
将其更改为 "constant": "10"
。一旦我们使用新的规范更新管道,Pachyderm 将启动 10 个新的工作节点用于 prediction
管道,如下所示:
$ pachctl update-pipeline -f prediction.json
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
etcd-2142892294-38ptw 1/1 Running 0 3h
pachd-776177201-04l6w 1/1 Running 0 3h
pipeline-model-v2-168k5 2/2 Running 0 29m
pipeline-prediction-v2-0p6zs 0/2 Init:0/1 0 6s
pipeline-prediction-v2-3fbsc 0/2 Init:0/1 0 6s
pipeline-prediction-v2-c3m4f 0/2 Init:0/1 0 6s
pipeline-prediction-v2-d11b9 0/2 Init:0/1 0 6s
pipeline-prediction-v2-hjdll 0/2 Init:0/1 0 6s
pipeline-prediction-v2-hnk64 0/2 Init:0/1 0 6s
pipeline-prediction-v2-q29f1 0/2 Init:0/1 0 6s
pipeline-prediction-v2-qlhm4 0/2 Init:0/1 0 6s
pipeline-prediction-v2-qrnf9 0/2 Init:0/1 0 6s
pipeline-prediction-v2-rdt81 0/2 Init:0/1 0 6s
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
etcd-2142892294-38ptw 1/1 Running 0 3h
pachd-776177201-04l6w 1/1 Running 0 3h
pipeline-model-v2-168k5 2/2 Running 0 30m
pipeline-prediction-v2-0p6zs 2/2 Running 0 26s
pipeline-prediction-v2-3fbsc 2/2 Running 0 26s
pipeline-prediction-v2-c3m4f 2/2 Running 0 26s
pipeline-prediction-v2-d11b9 2/2 Running 0 26s
pipeline-prediction-v2-hjdll 2/2 Running 0 26s
pipeline-prediction-v2-hnk64 2/2 Running 0 26s
pipeline-prediction-v2-q29f1 2/2 Running 0 26s
pipeline-prediction-v2-qlhm4 2/2 Running 0 26s
pipeline-prediction-v2-qrnf9 2/2 Running 0 26s
pipeline-prediction-v2-rdt81 2/2 Running 0 26s
现在,任何将属性提交到 attributes
仓库的新提交都将由 10 个工作节点并行处理。例如,如果我们向 attributes
提交了 100 个更多的属性文件,Pachyderm 将将其中 1/10 的文件发送到每个工作节点以并行处理。所有结果仍然以相同的方式在 prediction
仓库中可见。
当然,设置固定数量的工作节点并不是使用 Pachyderm 和 Kubernetes 进行扩展的唯一方法。Pachyderm 还允许您通过为空闲工作节点设置缩减阈值来缩减工作节点。此外,Pachyderm 的工作节点自动扩展可以与 Kubernetes 集群资源的自动扩展相结合,以处理数据突发和批量处理。更进一步,Pachyderm 还允许您指定管道所需的资源,并使用这种资源规范将机器学习工作负载卸载到加速器(例如 GPU)。
参考资料
Docker:
-
为 Go 应用构建最小化 Docker 镜像:
blog.codeship.com/building-minimal-docker-containers-for-go-applications/
Pachyderm:
-
公共 Slack 频道:
slack.pachyderm.io/
-
入门指南:
pachyderm.readthedocs.io/en/latest/getting_started/getting_started.html
-
Go 客户端文档:
godoc.org/github.com/pachyderm/pachyderm/src/client
-
使用 Pachyderm 进行机器学习:
pachyderm.readthedocs.io/en/latest/cookbook/ml.html
-
机器学习示例:
pachyderm.readthedocs.io/en/latest/examples/readme.html#machine-learning
-
使用 Pachyderm 进行分布式处理:
pachyderm.readthedocs.io/en/latest/fundamentals/distributed_computing.html
-
常见的 Pachyderm 部署:
pachyderm.readthedocs.io/en/latest/deployment/deploy_intro.html
-
自动扩展 Pachyderm 集群:
pachyderm.readthedocs.io/en/latest/managing_pachyderm/autoscaling.html
摘要
从仅仅收集 CSV 数据到完全部署的分布式机器学习管道,我们做到了!现在我们可以用 Go 语言构建机器学习模型,并在机器集群上自信地部署这些模型。这些模式应该允许你构建和部署各种智能应用,我迫不及待地想听听你创造了什么!请保持联系。
第十章:机器学习相关的算法/技术
在前面的章节中,我们未能对与本书中机器学习示例相关的某些算法和技术进行详细讨论。我们将在本节中解决这个问题。
梯度下降
在多个例子中(包括第四章“回归”和第五章“分类”中的例子),我们利用了一种称为梯度下降的优化技术。梯度下降方法有多种变体,通常在机器学习世界的各个地方都能看到它们。最显著的是,它们被用于确定线性或逻辑回归等算法的最佳系数,因此,它们在至少部分基于线性/逻辑回归的更复杂技术中也经常发挥作用(如神经网络)。
梯度下降方法的一般思想是确定某些参数的变化方向和幅度,这将使你朝着正确的方向移动以优化某些度量(如误差)。想象一下站在某个景观上。为了向更低的地势移动,你需要朝下走。这就是梯度下降在优化参数时算法上所做的事情。
通过观察所谓的随机梯度下降(SGD),我们可以进一步理解这个过程,它是一种增量式的梯度下降方法。如果你还记得,我们在第五章“分类”中对逻辑回归的实现中实际上使用了 SGD。在那个例子中,我们是这样实现逻辑回归参数的训练或拟合的:
// logisticRegression fits a logistic regression model
// for the given data.
func logisticRegression(features *mat64.Dense, labels []float64, numSteps int, learningRate float64) []float64 {
// Initialize random weights.
_, numWeights := features.Dims()
weights := make([]float64, numWeights)
s := rand.NewSource(time.Now().UnixNano())
r := rand.New(s)
for idx, _ := range weights {
weights[idx] = r.Float64()
}
// Iteratively optimize the weights.
for i := 0; i < numSteps; i++ {
// Initialize a variable to accumulate error for this iteration.
var sumError float64
// Make predictions for each label and accumulate error.
for idx, label := range labels {
// Get the features corresponding to this label.
featureRow := mat64.Row(nil, idx, features)
// Calculate the error for this iteration's weights.
pred := logistic(featureRow[0]*weights[0] + featureRow[1]*weights[1])
predError := label - pred
sumError += math.Pow(predError, 2)
// Update the feature weights.
for j := 0; j < len(featureRow); j++ {
weights[j] += learningRate * predError * pred * (1 - pred) * featureRow[j]
}
}
}
return weights
}
在// 递归优化权重
注释下的循环实现了 SGD 来优化逻辑回归参数。让我们分析这个循环,以确定到底发生了什么。
首先,我们使用当前权重计算模型的输出,并计算我们的预测值与理想值(即实际观察值)之间的差异:
// Calculate the error for this iteration's weights.
pred := logistic(featureRow[0]*weights[0] + featureRow[1]*weights[1])
predError := label - pred
sumError += math.Pow(predError, 2)
然后,根据 SGD,我们将根据以下公式计算参数(在这种情况下为权重
)的更新:
梯度是问题中成本函数的数学梯度。
关于这个量的更详细的数学信息可以在这里找到:
mathworld.wolfram.com/Gradient.html
更新可以应用于参数,如下所示:
在我们的逻辑回归模型中,这可以表示为以下形式:
// Update the feature weights.
for j := 0; j < len(featureRow); j++ {
weights[j] += learningRate * predError * pred * (1 - pred) * featureRow[j]
}
这种类型的 SGD 在机器学习中相当广泛地使用。然而,在某些情况下,这种梯度下降可能导致过拟合或陷入局部最小值/最大值(而不是找到全局最优解)。
为了解决这些问题,你可以利用一种称为批量梯度下降的梯度下降的变体。在批量梯度下降中,你根据整个训练数据集中的梯度来计算每个参数的更新,而不是根据特定观察或数据集的行来计算梯度。这有助于你防止过拟合,但它也可能相当慢,并可能存在内存问题,因为你需要为每个参数计算整个数据集的梯度。小批量 梯度下降,这是另一种变体,试图保持批量梯度下降的一些好处,同时更具计算上的可处理性。在小批量梯度下降中,梯度是在训练数据集的子集上计算的,而不是整个训练数据集。
在逻辑回归的情况下,你可能会看到梯度上升或下降的使用,其中梯度上升与梯度下降相同,只是它应用于成本函数的负值。逻辑成本函数只要你保持一致,就会给你这两个选项。这进一步讨论在stats.stackexchange.com/questions/261573/using-gradient-ascent-instead-of-gradient-descent-for-logistic-regression
。
梯度下降方法已经被 gonum 团队在gonum.org/v1/gonum/optimize
中实现。有关更多信息,请参阅这些文档:
godoc.org/gonum.org/v1/gonum/optimize#GradientDescent
熵、信息增益和相关方法
在第五章,分类中,我们探讨了决策树方法,其中模型由一个 if/then 语句的树组成。决策树的这些 if/then 部分根据训练集的一个特征来分割预测逻辑。在一个我们试图将医疗患者分类为不健康或健康类别的例子中,决策树可能会首先根据性别特征进行分割,然后根据年龄特征,然后根据体重特征,依此类推,最终落在健康或不健康上。
算法是如何在决策树中首先选择使用哪些特征的?在先前的例子中,我们可以首先根据性别进行分割,或者首先根据权重,然后是任何其他特征。我们需要一种方法来以最佳方式安排我们的分割,以便我们的模型做出最好的预测。
许多决策树模型实现,包括我们在第五章“分类”中使用的,使用一个称为熵的量和一个信息增益的分析来构建决策树。为了说明这个过程,让我们考虑一个例子。假设你有一些关于健康人数与这些人各种特征的数据:
健康 | 不健康 | |
---|---|---|
素食饮食 | 5 | 2 |
素食饮食 | 4 | 1 |
肉食饮食 | 3 | 4 |
健康 | 不健康 | |
40 岁及以上 | 3 | 5 |
40 岁以下 | 9 | 2 |
在这里,我们数据中有两个特征,饮食和年龄,我们希望构建一个决策树,根据饮食和年龄预测人们是否健康。为此,我们需要决定是否应该首先在年龄上还是饮食上分割我们的决策树。请注意,数据中还有总共 12 个健康人和 7 个不健康人。
首先,我们将计算我们数据中类别的整体或总熵。这定义如下:
在这里,p[1],p[2],等等,是第一类,第二类等的概率。在我们特定的案例中(因为我们有 12 个健康人和 7 个不健康人),我们的总熵如下:
这个0.95的度量代表了我们的健康数据的同质性。它介于 0 和 1 之间,高值对应于同质性较低的数据。
要确定我们首先应该在年龄上还是饮食上分割我们的树,我们将计算这些特征中哪一个给我们带来最大的信息增益。简单来说,我们将找到在分割该特征后给我们带来最大同质性的特征,这是通过之前的熵来衡量的。这种熵的减少被称为信息增益。
我们例子中某个特征的信息增益定义如下:
在这里,E(Health, Feature)是关于给定特征(年龄或饮食)的熵的第二个度量。对于饮食,这个第二个度量可以这样计算:
量p[40+]和p[<40]是年龄为40+或<40(8/19 和 11/19,分别)的概率。量E(Health,40+)和E(Health,<40)是健康熵(如前公式定义),但只使用与那些年龄40+和年龄<40对应的计数。
对于我们的示例数据,年龄特征的信息增益为0.152,饮食特征的信息增益为0.079。因此,我们会选择首先在年龄特征上分割我们的决策树,因为它最大限度地增加了我们数据的整体同质性。
你可以在www.saedsayad.com/decision_tree.htm
了解更多关于基于熵构建决策树的信息,你还可以在 Go 语言中看到一个示例实现github.com/sjwhitworth/golearn/blob/master/trees/entropy.go
。
反向传播
第八章,神经网络与深度学习,包含了一个从头开始构建的神经网络示例。这个神经网络包含了一个反向传播方法的实现,用于训练神经网络,这几乎可以在任何神经网络代码中找到。我们在那一章讨论了一些细节。然而,这个方法被如此频繁地使用,所以我们想在这里一步一步地介绍它。
要使用反向传播训练神经网络,我们为一系列的每个 epoch 执行以下操作:
-
将训练数据通过神经网络产生输出。
-
计算期望输出和预测输出之间的误差。
-
根据误差,计算神经网络权重和偏置的更新。
-
将这些更新反向传播到网络中。
作为提醒,我们对于具有单个隐藏层的网络实现这个过程的代码如下(其中wHidden
和wOut
是我们的隐藏层和输出层权重,而bHidden
和bOut
是我们的隐藏层和输出层偏置):
105 // Define the output of the neural network.
106 output := mat.NewDense(0, 0, nil)
107
108 // Loop over the number of epochs utilizing
109 // backpropagation to train our model.
110 for i := 0; i < nn.config.numEpochs; i++ {
111
112 // Complete the feed forward process.
113 hiddenLayerInput := mat.NewDense(0, 0, nil)
114 hiddenLayerInput.Mul(x, wHidden)
115 addBHidden := func(_, col int, v float64) float64 { return v + bHidden.At(0, col) }
116 hiddenLayerInput.Apply(addBHidden, hiddenLayerInput)
117
118 hiddenLayerActivations := mat.NewDense(0, 0, nil)
119 applySigmoid := func(_, _ int, v float64) float64 { return sigmoid(v) }
120 hiddenLayerActivations.Apply(applySigmoid, hiddenLayerInput)
121
122 outputLayerInput := mat.NewDense(0, 0, nil)
123 outputLayerInput.Mul(hiddenLayerActivations, wOut)
124 addBOut := func(_, col int, v float64) float64 { return v + bOut.At(0, col) }
125 outputLayerInput.Apply(addBOut, outputLayerInput)
126 output.Apply(applySigmoid, outputLayerInput)
127
128 // Complete the backpropagation.
129 networkError := mat.NewDense(0, 0, nil)
130 networkError.Sub(y, output)
131
132 slopeOutputLayer := mat.NewDense(0, 0, nil)
133 applySigmoidPrime := func(_, _ int, v float64) float64 { return sigmoidPrime(v) }
134 slopeOutputLayer.Apply(applySigmoidPrime, output)
135 slopeHiddenLayer := mat.NewDense(0, 0, nil)
136 slopeHiddenLayer.Apply(applySigmoidPrime, hiddenLayerActivations)
137
138 dOutput := mat.NewDense(0, 0, nil)
139 dOutput.MulElem(networkError, slopeOutputLayer)
140 errorAtHiddenLayer := mat.NewDense(0, 0, nil)
141 errorAtHiddenLayer.Mul(dOutput, wOut.T())
142
143 dHiddenLayer := mat.NewDense(0, 0, nil)
144 dHiddenLayer.MulElem(errorAtHiddenLayer, slopeHiddenLayer)
145
146 // Adjust the parameters.
147 wOutAdj := mat.NewDense(0, 0, nil)
148 wOutAdj.Mul(hiddenLayerActivations.T(), dOutput)
149 wOutAdj.Scale(nn.config.learningRate, wOutAdj)
150 wOut.Add(wOut, wOutAdj)
151
152 bOutAdj, err := sumAlongAxis(0, dOutput)
153 if err != nil {
154 return err
155 }
156 bOutAdj.Scale(nn.config.learningRate, bOutAdj)
157 bOut.Add(bOut, bOutAdj)
158
159 wHiddenAdj := mat.NewDense(0, 0, nil)
160 wHiddenAdj.Mul(x.T(), dHiddenLayer)
161 wHiddenAdj.Scale(nn.config.learningRate, wHiddenAdj)
162 wHidden.Add(wHidden, wHiddenAdj)
163
164 bHiddenAdj, err := sumAlongAxis(0, dHiddenLayer)
165 if err != nil {
166 return err
167 }
168 bHiddenAdj.Scale(nn.config.learningRate, bHiddenAdj)
169 bHidden.Add(bHidden, bHiddenAdj)
170 }
让我们详细分析这个实现,以了解到底发生了什么。
产生我们输出的前向过程执行以下操作:
-
将输入数据乘以隐藏层权重,加上隐藏层偏置,然后应用 sigmoid 激活函数来计算隐藏层的输出,即
hiddenLayerActivations
(前一个代码片段的第 112 到 120 行)。 -
将
hiddenLayerActivations
乘以输出层权重,然后加上输出层偏置,并应用 sigmoid 激活函数来计算output
(第 122 到 126 行)。
注意,在前向过程中,我们是始于输入层的输入数据,然后通过隐藏层逐步向前,直到达到输出。
在前向过程之后,我们需要计算权重和偏置的最佳更新。正如你可能预期的那样,在附录的这一部分经过梯度下降部分之后,梯度下降是找到这些权重和偏置的完美选择。前一个代码片段的第 129 到 144 行实现了 SGD。
最后,我们需要将这些更新反向应用到网络的第 147 到 169 行。这是反向传播更新,使得反向传播得名。这个过程并没有什么特别之处,我们只是执行以下操作:
-
将计算出的更新应用到输出层的权重和偏置(第 147 到 157 行)。
-
将计算出的更新应用到隐藏层权重和偏置(第 159 到 169 行)。
注意我们是如何从输出开始,逐步回溯到输入,应用这些变化的。
你可以在这里找到关于反向传播的非常详细的讨论,包括数学证明:
反向传播在 1986 年 David Rumelhart、Geoffrey Hinton 和 Ronald Williams 发表的一篇论文之后开始被广泛使用。尽管这种方法在神经网络行业中得到了广泛应用,但 Geoffrey Hinton 最近公开表示他对反向传播持深深怀疑的态度,并建议我们需要努力寻找替代方案。