Go 机器学习(一)
似乎机器学习和人工智能在时尚科技公司以及越来越多的企业公司中都非常流行。数据科学家正在使用机器学习来完成从驾驶汽车到绘制猫的各种事情。然而,如果您关注数据科学社区,您很可能看到 Python 和 R 用户之间发生某种类似于语言战争的情况。这些语言主导着机器学习对话,并且似乎是在组织中集成机器学习的唯一选择。本书将探索第三种选择: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
摘要
选择合适的评估指标并制定评估/验证流程是任何机器学习项目的关键部分。你已经了解了各种相关的评估指标以及如何使用保留集和/或交叉验证来避免过拟合。在下一章中,我们将开始探讨机器学习模型,并使用线性回归来构建我们的第一个模型!
更多推荐
所有评论(0)