实现分类器

我们现在准备实现一个基于多个属性的 $k$-最近邻分类器。到目前为止,为了便于可视化,我们只使用了两个属性。但通常预测将基于许多属性。下面是一个示例,展示了多个属性如何比成对属性更好。

[In ]:
import matplotlib
#matplotlib.use('Agg')
path_data = '../../../assets/data/'
from datascience import *
%matplotlib inline
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import numpy as np
import math
import scipy.stats as stats
plt.style.use('fivethirtyeight')

纸币真伪鉴别

这次我们将考察预测一张纸币(例如 20 美元钞票)是伪造的还是真币。研究人员为我们整理了一个数据集,基于许多单独纸币的照片:有些是伪造的,有些是真币。他们使用一些技术从每张图像中计算了几个数值,我们不需要在本课程中关心这些技术。因此,对于每张纸币,我们知道从它的照片中计算出的几个数值以及它的类别(是否为伪造品)。让我们将其加载到表中看一看。

[In ]:
banknotes = Table.read_table(path_data + 'banknote.csv')
banknotes
WaveletVar | WaveletSkew | WaveletCurt | Entropy  | Class
3.6216     | 8.6661      | -2.8073     | -0.44699 | 0
4.5459     | 8.1674      | -2.4586     | -1.4621  | 0
3.866      | -2.6383     | 1.9242      | 0.10645  | 0
3.4566     | 9.5228      | -4.0112     | -3.5944  | 0
0.32924    | -4.4552     | 4.5718      | -0.9888  | 0
4.3684     | 9.6718      | -3.9606     | -3.1625  | 0
3.5912     | 3.0129      | 0.72888     | 0.56421  | 0
2.0922     | -6.81       | 8.4636      | -0.60216 | 0
3.2032     | 5.7588      | -0.75345    | -0.61251 | 0
1.5356     | 9.1772      | -2.2718     | -0.73535 | 0
... (1362 rows omitted)

让我们看看前两个数字是否能告诉我们关于纸币是否伪造的信息。这是散点图:

[In ]:
color_table = Table().with_columns(
    'Class', make_array(1, 0),
    'Color', make_array('darkblue', 'gold')
)
[In ]:
banknotes = banknotes.join('Class', color_table)
[In ]:
banknotes.scatter('WaveletVar', 'WaveletCurt', group='Color')
Scatterplot with 'WaveletVar' on the x-axis and 'WaveletCurt' on the y-axis. Data points are either in dark blue or gold. The dark blue data points appear more to the left and seem to have a negative correlation. There is a grouping of dark blue data points around y=0 extending to the left of the main descending pattern. The gold data points are more on the right and their largest y values are smaller than some of the dark blue data points. There is overlap between the dark blue and gold data points.

相当有趣!这两个测量值似乎确实有助于预测纸币是否伪造。然而,在这个例子中,你现在可以看到蓝色簇和金色簇之间有一些重叠。这表明仅凭这两个数字,有些图像很难判断纸币是否是真币。不过,你仍然可以使用 $k$-最近邻分类器来预测纸币的真伪。

花一分钟思考一下:假设我们使用 $k=11$。分类器会在图的哪些部分正确,哪些部分出错?决策边界会是什么样子?

数据中出现的模式可能非常复杂。例如,如果我们使用图像中不同的一对测量值,结果会是这样的:

[In ]:
banknotes.scatter('WaveletSkew', 'Entropy', group='Color')
Scatterplot with 'WaveletSkew' on the x-axis and 'Entropy' on the y-axis. There are dark blue and gold data points. Each set of data points appears in an upside down U shape with the gold data points shifted to the right of the dark blue data points.

似乎确实存在某种模式,但相当复杂。尽管如此,$k$-最近邻分类器仍然可以使用,并将有效地从中“发现”模式。这说明了机器学习有多强大:它甚至可以有效地利用我们未曾预料到或我们原本认为需要“编程输入”计算机的模式。

多个属性

到目前为止,我一直假设我们有恰好 2 个属性可以用来帮助我们做出预测。如果我们有超过 2 个呢?例如,如果我们有 3 个属性呢?

很酷的部分是:在这种情况下你也可以使用相同的思路。你只需要做一个三维散点图,而不是二维图。你仍然可以使用 $k$-最近邻分类器,但现在是在三维空间中计算距离,而不仅仅是二维。它就是有效。非常酷!

事实上,2 或 3 并没有什么特别之处。如果你有 4 个属性,你可以在四维空间中使用 $k$-最近邻分类器。5 个属性?在五维空间中工作。而且不必止步于此!这对于任意多个属性都适用;你只需要在一个非常高维的空间中工作。可视化变得极其困难,但这没关系。计算机算法的推广效果非常好:你只需要能够计算距离,而这并不难。令人震撼!

例如,让我们看看如果我们尝试使用 3 个测量值而不是 2 个来预测纸币是否伪造,会发生什么。结果如下:

[In ]:
ax = plt.figure(figsize=(8,8)).add_subplot(111, projection='3d')
ax.scatter(banknotes.column('WaveletSkew'), 
           banknotes.column('WaveletVar'), 
           banknotes.column('WaveletCurt'), 
           c=banknotes.column('Color'));
A 3D scatterplot with dark blue and gold data points. The two sets of data points have similar shapes but look to be mostly separable across one axis.

太棒了!仅用 2 个属性时,两个簇之间有一些重叠(这意味着分类器必然会对重叠部分中的点犯一些错误)。但当我们使用这 3 个属性时,两个簇几乎没有重叠。换句话说,使用这 3 个属性的分类器将比仅使用 2 个属性的分类器更准确。

这是分类中的一个普遍现象。每个属性都可能为你提供新信息,因此更多的属性有时有助于你构建更好的分类器。当然,代价是我们现在必须收集更多信息来测量每个属性的值,但如果这能显著提高分类器的准确率,这个代价可能是完全值得的。

总结:你现在知道了如何使用 $k$-最近邻分类,基于一些属性的值来预测一个是/否问题的答案,假设你有一个训练集,其中包含已知正确预测的示例。总体路线图如下:

  1. 确定一些你认为可能有助于预测问题答案的属性。
  2. 收集一个训练集,其中包含你知道属性值和正确预测的示例。
  3. 在未来进行预测时,测量属性的值,然后使用 $k$-最近邻分类来预测问题的答案。

多维空间中的距离

我们知道如何计算二维空间中的距离。如果有一个点坐标为 $(x_0,y_0)$,另一个点的坐标为 $(x_1,y_1)$,它们之间的距离为

$$D = \sqrt{(x_0-x_1)^2 + (y_0-y_1)^2}.$$

在三维空间中,点分别为 $(x_0, y_0, z_0)$ 和 $(x_1, y_1, z_1)$,它们之间的距离公式为

$$ D = \sqrt{(x_0-x_1)^2 + (y_0-y_1)^2 + (z_0-z_1)^2} $$

在 $n$ 维空间中,事情有点难以可视化,但我认为你可以看到公式是如何推广的:我们将每个坐标之间的差的平方求和,然后取平方根。

在上一节中,我们定义了函数 distance,它返回两点之间的距离。我们在二维中使用它,但好消息是该函数不在乎有多少维!它只是将两个坐标数组相减(无论数组有多长),对差求平方并求和,然后取平方根。要在多维中工作,我们完全不需要更改代码。

[In ]:
def distance(point1, point2):
    """Returns the distance between point1 and point2
    where each argument is an array 
    consisting of the coordinates of the point"""
    return np.sqrt(np.sum((point1 - point2)**2))

让我们在新数据集上使用这个函数。表 wine 包含 178 种不同意大利葡萄酒的化学成分。类别是葡萄品种,称为栽培品种。共有三个类别,但让我们看看能否将第 1 类与另外两类区分开。

[In ]:
wine = Table.read_table(path_data + 'wine.csv')

# For converting Class to binary

def is_one(x):
    if x == 1:
        return 1
    else:
        return 0
    
wine = wine.with_column('Class', wine.apply(is_one, 0))
[In ]:
wine
Class | Alcohol | Malic Acid | Ash  | Alcalinity of Ash | Magnesium | Total Phenols | Flavanoids | Nonflavanoid phenols | Proanthocyanins | Color Intensity | Hue  | OD280/OD315 of diulted wines | Proline
1     | 14.23   | 1.71       | 2.43 | 15.6              | 127       | 2.8           | 3.06       | 0.28                 | 2.29            | 5.64            | 1.04 | 3.92                         | 1065
1     | 13.2    | 1.78       | 2.14 | 11.2              | 100       | 2.65          | 2.76       | 0.26                 | 1.28            | 4.38            | 1.05 | 3.4                          | 1050
1     | 13.16   | 2.36       | 2.67 | 18.6              | 101       | 2.8           | 3.24       | 0.3                  | 2.81            | 5.68            | 1.03 | 3.17                         | 1185
1     | 14.37   | 1.95       | 2.5  | 16.8              | 113       | 3.85          | 3.49       | 0.24                 | 2.18            | 7.8             | 0.86 | 3.45                         | 1480
1     | 13.24   | 2.59       | 2.87 | 21                | 118       | 2.8           | 2.69       | 0.39                 | 1.82            | 4.32            | 1.04 | 2.93                         | 735
1     | 14.2    | 1.76       | 2.45 | 15.2              | 112       | 3.27          | 3.39       | 0.34                 | 1.97            | 6.75            | 1.05 | 2.85                         | 1450
1     | 14.39   | 1.87       | 2.45 | 14.6              | 96        | 2.5           | 2.52       | 0.3                  | 1.98            | 5.25            | 1.02 | 3.58                         | 1290
1     | 14.06   | 2.15       | 2.61 | 17.6              | 121       | 2.6           | 2.51       | 0.31                 | 1.25            | 5.05            | 1.06 | 3.58                         | 1295
1     | 14.83   | 1.64       | 2.17 | 14                | 97        | 2.8           | 2.98       | 0.29                 | 1.98            | 5.2             | 1.08 | 2.85                         | 1045
1     | 13.86   | 1.35       | 2.27 | 16                | 98        | 2.98          | 3.15       | 0.22                 | 1.85            | 7.22            | 1.01 | 3.55                         | 1045
... (168 rows omitted)

前两种葡萄酒都属于第 1 类。要找出它们之间的距离,我们首先需要一个仅包含属性的表:

[In ]:
wine_attributes = wine.drop('Class')
[In ]:
distance(np.array(wine_attributes.row(0)), np.array(wine_attributes.row(1)))
31.265012394048398

表中的最后一种葡萄酒属于第 0 类。它与第一种葡萄酒的距离为:

[In ]:
distance(np.array(wine_attributes.row(0)), np.array(wine_attributes.row(177)))
506.05936766351834

这要大很多!让我们做一些可视化,看看第 1 类是否真的与第 0 类看起来不同。

[In ]:
wine_with_colors = wine.join('Class', color_table)
[In ]:
wine_with_colors.scatter('Flavanoids', 'Alcohol', group='Color')
Scatterplot with 'Flavanoids' on the x-axis and 'Alcohol' on the y-axis. Data points are either dark blue or gold. The gold data points exist on both sides of the graph and generally have a negative correlation; as the x values increase the y values decrease. The dark blue data points exist primarily in the upper right hand corner of the graph and have a positive association. There is some overlap between the dark blue and gold data points, but not too much.

蓝色点(第 1 类)几乎完全与金色点分开。这就是为什么两种第 1 类葡萄酒之间的距离会比两种不同类别的葡萄酒之间的距离更小的一个迹象。我们也可以通过不同的一对属性看到类似的现象:

[In ]:
wine_with_colors.scatter('Alcalinity of Ash', 'Ash', group='Color')
Scatterplot with 'Alcalinity of Ash' on the x-axis and 'Ash' on the y-axis. Data points are in dark blue and gold. Both sets of data points appear to have a positive association. The dark blue data points look like their regression line would have a larger y-intercept than the regression line for the gold data points.

但对于某些成对属性,情况更加模糊。

[In ]:
wine_with_colors.scatter('Magnesium', 'Total Phenols', group='Color')
Scatterplot with 'Magnesium' on the x-axis ranging from 75 to 160 and 'Total Phenols' on the y-axis ranging from 1 to 4. There are data points in dark blue and in gold. The dark blue data points are in a blob from about x=90 to x=130 and y=2.25 to y=4. The gold data points exist throughout the graph, though there are noticeably fewer data points on the right hand side of the graph.

让我们看看能否实现一个基于所有属性的分类器。之后,我们来看看它的准确率如何。

实现计划

是时候编写一些代码来实现分类器了。输入是我们想要分类的一个 point。分类器的工作原理是从训练集中找到 point 的 $k$ 个最近邻。因此,我们的方法是这样的:

  1. 找到 point 的 $k$ 个最近邻,即训练集中与 point 最相似的 $k$ 种葡萄酒。

  2. 查看这 $k$ 个最近邻的类别,进行多数投票以找到最常见的葡萄酒类别。将其作为我们对 point 的预测类别。

这将指导我们的 Python 代码结构。

[In ]:
def closest(training, p, k):
    ...

def majority(topkclasses):
    ...

def classify(training, p, k):
    kclosest = closest(training, p, k)
    kclosest.classes = kclosest.select('Class')
    return majority(kclosest)

实现步骤 1

为了实现肾脏疾病数据的第一步,我们必须计算训练集中每个患者到 point 的距离,按距离排序,并取训练集中最接近的 $k$ 个患者。

这正是我们在上一节中对 Alice 对应的点所做的。让我们将那段代码通用化。为了方便起见,我们在这里重新定义 distance

[In ]:
def distance(point1, point2):
    """Returns the distance between point1 and point2
    where each argument is an array 
    consisting of the coordinates of the point"""
    return np.sqrt(np.sum((point1 - point2)**2))

def all_distances(training, new_point):
    """Returns an array of distances
    between each point in the training set
    and the new point (which is a row of attributes)"""
    attributes = training.drop('Class')
    def distance_from_point(row):
        return distance(np.array(new_point), np.array(row))
    return attributes.apply(distance_from_point)

def table_with_distances(training, new_point):
    """Augments the training table 
    with a column of distances from new_point"""
    return training.with_column('Distance', all_distances(training, new_point))

def closest(training, new_point, k):
    """Returns a table of the k rows of the augmented table
    corresponding to the k smallest distances"""
    with_dists = table_with_distances(training, new_point)
    sorted_by_distance = with_dists.sort('Distance')
    topk = sorted_by_distance.take(np.arange(k))
    return topk

让我们看看这在我们的 wine 数据上如何工作。我们只取第一种葡萄酒,并在所有葡萄酒中找到它的五个最近邻。记住,由于这种葡萄酒是数据集的一部分,它自己是自己的最近邻。因此,我们应该预期看到它位于列表顶部,后面跟着其他四个。

首先让我们提取它的属性:

[In ]:
special_wine = wine.drop('Class').row(0)

现在让我们找到它的 5 个最近邻。

[In ]:
closest(wine, special_wine, 5)
Class | Alcohol | Malic Acid | Ash  | Alcalinity of Ash | Magnesium | Total Phenols | Flavanoids | Nonflavanoid phenols | Proanthocyanins | Color Intensity | Hue  | OD280/OD315 of diulted wines | Proline | Distance
1     | 14.23   | 1.71       | 2.43 | 15.6              | 127       | 2.8           | 3.06       | 0.28                 | 2.29            | 5.64            | 1.04 | 3.92                         | 1065    | 0
1     | 13.74   | 1.67       | 2.25 | 16.4              | 118       | 2.6           | 2.9        | 0.21                 | 1.62            | 5.85            | 0.92 | 3.2                          | 1060    | 10.3928
1     | 14.21   | 4.04       | 2.44 | 18.9              | 111       | 2.85          | 2.65       | 0.3                  | 1.25            | 5.24            | 0.87 | 3.33                         | 1080    | 22.3407
1     | 14.1    | 2.02       | 2.4  | 18.8              | 103       | 2.75          | 2.92       | 0.32                 | 2.38            | 6.2             | 1.07 | 2.75                         | 1060    | 24.7602
1     | 14.38   | 3.59       | 2.28 | 16                | 102       | 3.25          | 3.17       | 0.27                 | 2.19            | 4.9             | 1.04 | 3.44                         | 1065    | 25.0947

太好了!第一行是最近邻,也就是它自己——Distance 列中有一个 0,正如预期的那样。所有五个最近邻都属于第 1 类,这与我们之前的观察一致,即第 1 类葡萄酒在某些维度上似乎是聚在一起的。

实现步骤 2 和 3

接下来,我们需要对最近邻进行“多数投票”,并为我们的点分配与多数相同的类别。

[In ]:
def majority(topkclasses):
    ones = topkclasses.where('Class', are.equal_to(1)).num_rows
    zeros = topkclasses.where('Class', are.equal_to(0)).num_rows
    if ones > zeros:
        return 1
    else:
        return 0

def classify(training, new_point, k):
    closestk = closest(training, new_point, k)
    topkclasses = closestk.select('Class')
    return majority(topkclasses)
[In ]:
classify(wine, special_wine, 5)
1

如果我们将 special_wine 改为数据集中的最后一个,我们的分类器能否判断它属于第 0 类?

[In ]:
special_wine = wine.drop('Class').row(177)
classify(wine, special_wine, 5)
0

是的!分类器也正确判断了这个。

但我们还不知道它在所有其他葡萄酒上的表现如何,而且无论如何,我们知道在已经是训练集一部分的葡萄酒上进行测试可能过于乐观。在本章的最后一节中,我们将把葡萄酒分成训练集和测试集,然后在测试集上衡量分类器的准确率。