Go 机器学习(二)
原文:
annas-archive.org/md5/108241813dcacb35d00a6178bea25c3d译者:飞龙
第四章:回归
我们将要探索的第一组机器学习技术通常被称为回归。回归是一个过程,通过它可以理解一个变量(例如,销售额)相对于另一个变量(例如,用户数量)是如何变化的。这些技术本身很有用。然而,它们也是讨论机器学习技术的良好起点,因为它们构成了我们将在本书后面讨论的更复杂技术的基础。
通常,机器学习中的回归技术关注预测连续值(例如,股价、温度或疾病进展)。下一章我们将讨论的分类关注预测离散变量,或一组离散类别中的一个(例如,欺诈/非欺诈,坐着/站着/跑步,或热狗/非热狗)。如前所述,回归技术作为分类算法的一部分在机器学习中使用,但本章我们将专注于它们的基本应用,以预测连续值。
理解回归模型术语
如前所述,回归本身是一个分析一个变量与另一个变量之间关系的过程,但在机器学习中,有一些术语用来描述这些变量,以及与回归相关的各种类型和过程:
-
响应变量或因变量:这些术语将交替使用,指基于一个或多个其他变量试图预测的变量。这个变量通常被标记为 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/regr…。
要使用github.com/sajari/regr…训练回归模型,我们需要初始化一个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/regr…做这个,我们只需要在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 聚类。现在,你应该能够很好地检测数据集中的分组结构。
接下来,我们将讨论时间序列数据的建模,例如股票价格、传感器数据等。