numpy实现K近邻算法——离得近关系多半不错
K近邻(KNN)
俗话说得好哇,叫物以类聚,人以群分哈哈。在分类算法中,这句话简直真理。
我们来想象这样的场景,有一天土豆来到地里,看着漫山遍野绿油油地植物,有玉米,有土豆,有狗尾巴花,有喇叭花,有各种草,土豆不禁心旷神怡,但土豆突然发现自己有很多植物并不认识,而只知道他们是玉米,土豆,狗尾巴花…中的(土豆怕不是个傻子)不太真实哈哈哈。不过想象土豆是个计算机呢,计算机都是大傻子,我们想想土豆该怎么分辨这些植物呢。
土豆已经知道了一些玉米,土豆等植物的样子,他仔细的量出了这些植物的高度,叶长,枝干直径,如下(数据是编的,土豆没去量,坏土豆),
高度(cm) | 叶长(cm) | 枝干直径(cm) | 种类 |
---|---|---|---|
185 | 30 | 5 | 玉米 |
50 | 8 | 2 | 土豆 |
20 | 20 | 1 | 草 |
… | … | … | … |
根据这些数据,土豆怎么分辨玉米,土豆还有草呢?当然是,长得像咯~
现在土豆看到了一个新的植物,他的高是170cm,叶子长20cm,主茎直径6cm,于是土豆猜测该植物是玉米。
先抛开这个不靠谱的故事和不靠谱的数据,我们回头来看看我们的K近邻算法,它是怎么工作的呢?我们有一个训练样本集(土豆已经知道的那些植物数据),样本集的每个数据都已经标定(土豆知道了植物类型),即我们知道了样本集的每一个数据和所属分类。对于没有标定的新数据(土豆不认识的植物),将新数据的特征与样本集中每个数据进行比较,选择样本集中与新数据最相似的数据的分类标签作为分类。不过,我们不能简单的选择最相似的数据的分类作为结果,因为如果有一个特殊数据点(比如长得比较低的玉米,或者长得比较高的草)那么将会得到偏离一般情况的结果。
针对这个问题,我们选择k个最相似的数据,然后取这个k个数据中数量最多的分类作为结果,其实呢就是投票表决,少数服从多数,这里的k就是k邻近中的k啦~k通常不超过20。
伪造数据
土豆没有去量,所以我们自己生成一组数据用于我们的分类任务。
我们假设,如下
种类 | 高度(cm) | 叶长(cm) | 枝干直径(cm) |
---|---|---|---|
玉米 | 150~190 | 40~70 | 2~4 |
土豆 | 30~60 | 7~10 | 1~2 |
草 | 10~40 | 10~40 | 0~1 |
我们通过numpy的随机数模块产生这些范围内的数据,并添加一些错误的数据作为干扰点,之后我们可以看出,对于k近邻算法,这些干扰点将被排除掉。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei']
# 数据生成
train_num = 200
test_num = 100
config = {
'玉米': [[150, 190], [40, 70], [2,4]],
'土豆': [[30, 60], [7, 10], [1, 2]],
'草': [[10, 40], [10, 40], [0, 1]]
}
plants = list(config.keys())
dataset = pd.DataFrame(columns=['高度(cm)', '叶长(cm)', '枝干直径(cm)', '种类'])
index = 0
# 正常的
for p in config:
for i in range(int(train_num/3-3)):
row = []
for j, [min_val, max_val] in enumerate(config[p]):
v = round(np.random.rand()*(max_val-min_val)+min_val, 2)
while v in dataset[dataset.columns[j]]:
v = round(np.random.rand()*(max_val-min_val)+min_val, 2)
row.append(v)
row.append(p)
dataset.loc[index] = row
index += 1
# 错数据
for i in range(train_num - index):
k = np.random.randint(3)
p = plants[k]
row = []
for j, [min_val, max_val] in enumerate(config[p]):
v = round(np.random.rand()*(max_val-min_val)+min_val, 2)
while v in dataset[dataset.columns[j]]:
v = round(np.random.rand()*(max_val-min_val)+min_val, 2)
row.append(v)
row.append(plants[(k+1)%3])
dataset.loc[index] = row
index+=1
# dataset = dataset.infer_objects()
dataset = dataset.reindex(np.random.permutation(len(dataset)))
dataset.reset_index(drop=True, inplace=True)
dataset.iloc[:int(train_num), :-1].to_csv('potato_train_data.csv', index=False)
dataset.iloc[:int(train_num):, [-1]].to_csv('potato_train_label.csv', index=False)
这里只有训练数据集的生成,测试数据与此类似~
数据可视化
我们通过将两个维度的数据画散点图来看看数据点的分布。
def visualize(dataset, labels, features, classes, fig_size=(10, 10), layout=None):
plt.figure(figsize=fig_size)
index = 1
if layout == None:
layout = [len(features), 1]
for i in range(len(features)):
for j in range(i+1, len(features)):
p = plt.subplot(layout[0], layout[1], index)
plt.subplots_adjust(hspace=0.4)
p.set_title(features[i]+'&'+features[j])
p.set_xlabel(features[i])
p.set_ylabel(features[j])
for k in range(len(classes)):
p.scatter(dataset[labels==k, i], dataset[labels==k, j], label=classes[k])
p.legend()
index += 1
plt.show()
dataset = pd.read_csv('potato_train_data.csv')
labels = pd.read_csv('potato_train_label.csv')
features = list(dataset.keys())
classes = np.array(['玉米', '土豆', '草'])
for i in range(3):
labels.loc[labels['种类']==classes[i], '种类'] = i
dataset = dataset.values
labels = labels['种类'].values
visualize(dataset, labels, features, classes)
从图中,我们可以看到我们伪造的数据分布成一团一团的,也可以看到我们干扰点在错误的地方待着~
KNN算法实现
像我们之前说的,我们要找到与待分类数据最相似的k个数据,那我们怎么衡量有多相似呢,这里我们使用欧式距离来衡量特征的距离。
D
I
S
=
∑
i
=
1
n
(
x
i
−
y
i
)
2
DIS=\sqrt{\sum^n_{i=1}(x_i-y_i)^2}
DIS=i=1∑n(xi−yi)2
其中,
[
x
]
[x]
[x]为待分类数据特征,
[
y
]
[y]
[y]为数据集数据特征,写出计算欧氏距离的函数如下,
def euc_dis(data, dataset):
diff = np.tile(data, [len(dataset), 1])
dis = (diff - dataset) ** 2
dis = dis.sum(axis=1) # 对每行求和
dis = dis**0.5
return dis
然后我们就可以写knn的函数了,
'''
data: 输入,shape:size*features
dataset: 训练数据集
labels: 数据集标签
k: k值
dis_func: 距离函数
'''
def classify(data, dataset, labels, diff_func, k):
r = np.zeros(len(data), dtype=np.int)
for i, d in enumerate(data):
diff = diff_func(d, dataset)
diff_sort_arg = diff.argsort() #排序获取索引
label_count = {}
# 计算k个数据中最多的类别
for l in labels[diff_sort_arg[:k]]:
label_count[l] = label_count.get(l, 0) + 1
r[i] = sorted(label_count.items(), key=lambda a: a[1], reverse=True)[0][0]
return r
测试
使用我们之前生成的测试数据来测试一下我们的knn吧~
%%time
test_dataset = pd.read_csv('potato_test_data.csv').values
test_labels = pd.read_csv('potato_test_label.csv')
for i in range(3):
test_labels.loc[test_labels['种类']==classes[i], '种类'] = i
test_labels = test_labels['种类'].values
predicted = classify(test_dataset,
dataset, labels, euc_dis, 20)
print('测试数据总数:%d'%len(test_labels))
print('准确率:%.2f%%'%((predicted==test_labels).sum()/len(test_labels)*100))
print('错误测试:')
for i, p in enumerate(predicted):
if p != test_labels[i]:
print(test_dataset[i])
测试数据总数:100
准确率:97.00%
错误测试:
[38.73 11.85 0.15]
[36.67 11.31 0.57]
[3.706e+01 1.388e+01 1.000e-02]
CPU times: user 31.2 ms, sys: 628 µs, total: 31.8 ms
Wall time: 45.5 ms
我们在取k值为20时,准确率为99%,我们试试其他的k值时,
k | 准确率 |
---|---|
1 | 98% |
5 | 99% |
10 | 97% |
20 | 97% |
可见,不同的k值结果略有差异,不过由于我们这里的数据量较小,并没有代表性,尤其是k值取1,实际上是错误的,原因就是我们原理部分提过的,抗干扰能力会很差~一般来讲,我们取5~20的值就可以了。
决策边界
我们说knn是通过寻找数据集中与待分类数据相似的数据来确定待分类数据的分类值,那么在特征空间中分类区域的划分是怎样的呢?决策边界就是在特征空间中划分分类区域的边界。
def draw_boundary(dataset, labels, features, classes, k=20, step=0.1,\
fig_size=(10, 10), layout=None):
plt.figure(figsize=fig_size)
index = 1
if layout == None:
layout = [len(features), 1]
for i in range(len(features)):
for j in range(i+1, len(features)):
p = plt.subplot(layout[0], layout[1], index)
plt.subplots_adjust(hspace=0.4)
p.set_title(features[i]+'&'+features[j])
p.set_xlabel(features[i])
p.set_ylabel(features[j])
X = np.arange(np.min(dataset[:, i]), np.max(dataset[:, i])+step, step)
Y = np.arange(np.min(dataset[:, j]), np.max(dataset[:, j])+step, step)
X_G, Y_G = np.meshgrid(X, Y)
Z = np.zeros([len(Y), len(X)])
for m, x in enumerate(X):
for n, y in enumerate(Y):
Z[n, m] = classify([[x, y]], dataset[:, [i, j]],
labels, euc_dis, k)[0]
plt.contourf(X_G, Y_G, Z, cmap=plt.cm.RdYlBu)
for i_c in range(len(classes)):
p.scatter(dataset[labels==i_c, i], dataset[labels==i_c, j], label=classes[i_c])
p.legend()
index += 1
plt.show()
draw_boundary(dataset, labels, features, classes)
我们使用两个特征来在二维空间中绘制决策边界,结果如下
可以看到,二维特征平面中决策边界大致符合特征点的分布,但有些地方出现了错误的条状区域,如图中的红线圈中的区域。这是什么原因导致的呢?
归一化
我们来看看下面这几个数据:
index | 高度 |
---|---|
A | 10 |
B | 15 |
index | 高度 |
---|---|
M | 100 |
N | 105 |
那么是A与B更相似还是M与N更相似呢?我们来画图看看
可以看到相同的尺度下,M与N在视觉上更加相似,而实际上他们相差都是5。这里我们可以想一下相似的这个概念,我们要看的是绝对值还是相对值,我们是否需要缩放到相同的尺度呢?
我们在计算样本欧氏距离时计算几个特征的差值的平方和,我们伪造的数据中,高度数值的范围远大于叶长和直径,因此在计算距离时,高度对结果的贡献远大于叶长和直径,即高度所占权重更大。
画出样本集中距离测试点最近的20个点,如下图,
可以看出,这些点在叶长方向上分布更多,并不均匀,而我们认为这些特征的权重应该是相同的。
所以,我们要对特征进行归一化,
n
o
r
m
a
l
i
z
e
d
=
d
a
t
a
s
e
t
−
m
i
n
r
a
n
g
e
normalized=\frac{dataset-min}{range}
normalized=rangedataset−min
即将特征缩放到[0, 1]
def normalize(dataset):
min_vals = dataset.min(axis=0)
max_vals = dataset.max(axis=0)
ranges = max_vals - min_vals
normalized = (dataset - min_vals) / ranges
return normalized, min_vals, ranges
归一化函数会返回最小值和范围,在对数据集进行归一化后,我们需要用同样的方式对测试数据进行归一化。
normalized_dataset, min_vals, ranges = normalize(dataset)
normalized_test = (test_dataset - min_vals) / ranges
在归一化之后,准确率如何呢?
测试数据总数:100
准确率:100.00%
错误测试:
CPU times: user 34.4 ms, sys: 0 ns, total: 34.4 ms
Wall time: 50.1 ms
我们再来看看决策边界:
可以看到,决策边界变得更加漂亮了~
此时,样本集中20个距离测试点最近的点如下:
可以看到,点数在两个轴的分布变得更均匀了。
这就是归一化的作用,它使各个特征在距离中的作用以百分比的形式呈现。当然,有些时候我们会有其他选择。
画置信椭圆的代码1:
def confidence_ellipse(x, y, ax, n_std=3.0, facecolor='none', **kwargs):
if x.size != y.size:
raise ValueError("x and y must be the same size")
cov = np.cov(x, y)
pearson = cov[0, 1]/np.sqrt(cov[0, 0] * cov[1, 1])
# Using a special case to obtain the eigenvalues of this
# two-dimensionl dataset.
ell_radius_x = np.sqrt(1 + pearson)
ell_radius_y = np.sqrt(1 - pearson)
ellipse = Ellipse((0, 0),
width=ell_radius_x * 2,
height=ell_radius_y * 2,
facecolor=facecolor,
**kwargs)
# Calculating the stdandard deviation of x from
# the squareroot of the variance and multiplying
# with the given number of standard deviations.
scale_x = np.sqrt(cov[0, 0]) * n_std
mean_x = np.mean(x)
# calculating the stdandard deviation of y ...
scale_y = np.sqrt(cov[1, 1]) * n_std
mean_y = np.mean(y)
transf = transforms.Affine2D() \
.rotate_deg(45) \
.scale(scale_x, scale_y) \
.translate(mean_x, mean_y)
ellipse.set_transform(transf + ax.transData)
return ax.add_patch(ellipse)
关于knn的一些事情
至此,我们将knn的实现差不多完成了~
我们来看看knn一些特点:
- knn对于异常数据不敏感,不会因为少数错误数据而影响结果,还记得我们之前伪造数据时加入的错误数据吗,小伙伴们可以试试哟,即便我们的测试点就是错误数据本身,最后的结果也是正确的。因为knn本身是一种少数服从多数的决策~
- knn不需要对数据做特殊假设,这一点我们的实现过程中没有体现,但我们在之后的学习中会体会到这一点哒~
- knn的计算量很大。这一点土豆印象深刻,knn的计算量完全取决于数据集的大小,每次分类都需要将数据集中数据遍历一次加上排序,上面我们在画决策边界时可以清晰的感觉到这一点。
- knn消耗的内存很大。我们需要在运行中时刻保存数据集本身用于分类,当数据集很大时,这个内存占用会很大,而此时3和4本身就成为制约数据集变大的主要因素。
- knn无法获取数据集的内部信息,只能单纯地从数值接近本身来推断分类,我们也无法使用knn来了解数据本身的特征,或者说,knn只是一种算法而非模型。
- knn对复杂的决策边界处理不好,knn是求取所有特征的等权相似度,因此范围相近的特征同样会对结果产生影响,同时,对边界不清晰的数据处理也不好。
Iris数据集试炼
经过了我们伪造数据的洗礼,我们的knn分类函数已经诞生了,下面,我们就使用Iris数据集来对我们的knn分类函数做最后试炼。
可视化
决策边界(未归一化)k=20
测试结果(未归一化)k=20
测试数据总数:50
准确率:96.00%
错误测试:
[6.7 3. 5. 1.7]
[6. 3. 4.8 1.8]
CPU times: user 21 ms, sys: 5.21 ms, total: 26.2 ms
Wall time: 27.3 ms
k数据点决策(未归一)k=20
决策边界(归一化)
测试结果(归一化)
测试数据总数:50
准确率:96.00%
错误数据:
[0.65714286 0.41666667 0.6779661 0.66666667]
[0.48571429 0.25 0.77966102 0.54166667]
CPU times: user 3.09 ms, sys: 2.99 ms, total: 6.08 ms
Wall time: 5.58 ms
k数据点决策(归一化)k=20
结论
惊讶~Iris归一化后结果没有变好太多,首先iris数据集数据量较小,而sepal_length与sepal_width两个特征,Iris-versicolor,Iris-virginica几乎完全重叠,而其他几个特征的边界处几种鸢尾花也都密集分布。
归一化对Iris数据集的作用有限,因为Iris数据集本身分布较为均匀。
这里验证了我们上面结论中,knn对复杂边界和混杂度高的数据效果不好。2
所有的代码(jupyter-notebook)可以在这里下载哟~
来自matplotlib示例:Plot a confidence ellipse of a two-dimensional dataset,原理我们后续来学习哟~ ↩︎
参考了人民邮电出版社·《机器学习实战》·[美]Peter Harrington 著·李锐 李鹏 曲亚东 王斌 译 ↩︎