KNN算法简介

k近邻法(k-nearest neighbor,k-NN)属于监督学习中的分类算法,是机器学习中最简单的算法之一,同时也是最常用的分类算法之一。这篇文章将会基于numpy手动实现KNN,在此之后,将会利用sklearn实现的KNN完成数字识别。

KNN需要数据标准化。

算法原理

KNN不同于其他机器学习算法,它不需要训练过程,当使用该算法预测一个新的值时,它会根据距离最近的K个点的类型来判断自己的类型:K个点中哪个类型多就属于哪个类型。

例:

当K等于3时,根据上述规则可得预测为红类;

当K等于5时,根据上述规则可得预测为蓝类。

基本流程

  1. 计算新数据与每一个已知样本的距离

  2. 按距离从小到大排序

  3. 选出距离最小的K个点

  4. 通过这K个点的分类决策新数据所属类别

KNN算法的三要素

根据上面的例子,可以看出不同K的选择对预测结果有较大影响。实验表明,K值越小,越容易发生过拟合;K值越大,越容易发生欠拟合。除K值的选择外,影响KNN算法的还有距离度量分类决策规则

分类决策规则一般采用多数表决,即最近的K个样本中哪个种类多就属于哪一类。

距离度量

有同维度特征空间向量\(\vec A = (a_1,a_2,...,a_n),\vec B = (b_1,b_2,...,b_n)\) ,则\(\vec A\),\(\vec B\)\(L_P\) 距离为:

\(L_p(\vec A,\vec B) = (\sum_{i=1}^n|a_i-b_i|^p)^{\frac 1 p}\)

曼哈顿距离(P=1):\(L_1 = \sum_{i=1}^n|a_i-b_i|\)

欧式距离(P=2):\(L_2 = \sqrt{\sum_{i=1}^n|a_i-b_i|^2}\)

切比雪夫距离(\(P=\infty\)):\(L_{\infty} = max(|a_1-b_1|,|a_2-b_2|,...,|a_n-b_n|)\)

一般采用欧式距离作为距离度量。

K值选择-交叉验证

交叉验证简介:将原始数据集可以分为训练集、验证集、测试集。利用训练集和验证集测试模型好坏的方法叫做交叉验证。注:交叉验证是没有用到测试集的!如果用测试集进行交叉验证,会导致预测结果乐观化。

通过交叉验证方法可以得到最合适的K值。

KNN算法的优缺点以及改进方法

优点:

  1. 原理简单,便于实现。

  2. KNN是惰性模型,不需要训练。

  3. 预测效果好。

  4. 对异常值不敏感。

缺点:通过KNN算法原理可以得知,如果要预测一个新数据,需要将它和每一个样本都计算一次距离,然后排序,选出最小的K个样本进行决策。那么预测一次的时间复杂度为\(O(n)\),时间复杂度较高。并且需要将所有样本都储存在内存,空间复杂度较高,即对内存要求较高。所以KNN算法的缺点主要是:

  1. 效率比较低,运行时间可能比较长。

  2. 对内存要求较高。

KNN适用于数据集较小的分类。对于数据集较大的可使用神经网络进行分类。

KNN改进算法介绍

这里不会说明各算法的原理与实现,只会简单介绍,参考文章:盘点高效的KNN实现算法-技术圈 (proginn.com)

上述提到的均是KNN的线性扫描实现方法,即暴力法。通过分析,可知线性扫描不适用于数据规模较大的数据集。

实际上,KNN的实现方法高大10种,下面将介绍几种改进方法。

KD树

KD树是一种树形结构存储算法。

适用范围:

KD树在特征维度小于20时效率最高,一般适用于训练样本数远大于空间维数时的k近邻搜索。当空间维数接近训练实例数时,效率会迅速下降,几乎接近线形扫描。

Ball树

ball树是KD树的一种改良算法。

Annoy

Annoy,全称“Approximate Nearest Neighbors OYeah”,是一种适合实际应用的快速相似查找算法。

Annoy 同样通过建立一个二叉树,使得每个点查找时间复杂度是O(log n)。和KD树不同的是,Annoy没有对k维特征进行切分。

HNSW

HNSW(Hierarchcal Navigable Small World graphs)是基于图存储的数据结构。

上述四种算法中,Annoy和HNSW是可以在实际业务中落地的算法。

KNN算法手动实现完成鸢尾花分类

主体部分

详细解释都写代码里了,因为主体部分实在太短了没必要分开写,就直接写在一个模块了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import numpy as np
import operator

# 使用KNN预测数据类别
def run_KNN(X,X_train,y_train,K):
'''
:param X: 需要预测的数据集
:param X_train: X训练集
:param y_train: Y训练集
:param K: K个最近
:return: 预测集
'''
dataSize = X_train.shape[0]
y_predict = []
for x in X:
# 计算未知数据到每一个样本的欧氏距离
diff = np.tile(x,(dataSize,1)) - X_train # 把X扩大然后矩阵相减
squaredDist = np.sum(diff**2,axis=1) # axis = 1计算每一行的和
distance = squaredDist ** 0.5

# 对距离递增排序获取最前面K个样本的种类并统计各种类出现次数
nearIds = distance.argsort() # 按值排序,得到对应下标数组
classesCount = {}
for i in range(K):
y = y_train[nearIds[i]] # 得到对应的种类
classesCount[y] = classesCount.get(y,0)+1 # 0为设置默认值
# print(classesCount)
# 对字典按值进行递减排序
sortClassesCount = sorted(classesCount.items(),key=operator.itemgetter(1),reverse=True)
# 获取对象第二个元素 逆序
y_predict.append(sortClassesCount[0][0]) # 预测种类为出现次数最多的那一类
return y_predict

到这里,就已经手动实现了KNN算法,可以拿来预测类别了,确实很简单。

但我们还需要利用交叉验证集选择最好的K,本来想借助sklearn的交叉验证,但是对我们自己手写的KNN好像不太好用,所以还是自己写一个简易的吧。

不过像拆分训练集和计算Score这种事就交给sklearn吧。

交叉验证选择最适K值

注意,交叉验证集不能涉及到测试集!

这里用多次交叉验证计算score取均值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.metrics import f1_score

# 交叉验证集
def chooseK(X_train_old,y_train_old,K = 10,C = 5):
'''
:param X_train_old: 原训练特征集
:param y_train_old: 原训练目标集
:param K: 最大K
:param C: 计算次数
:return: 最好的K值
'''

# K从1取到maxK,分别计算Score
scores = [0]
for k in range(1,K+1):
score = 0
for j in range(1,C+1):
# 拆分训练集为训练集和验证集
X_train, X_check, y_train, y_check = train_test_split(X_train_old, y_train_old, train_size=0.6)
y_predict = run_KNN(X_check,X_train,y_train,k)
# 计算Score
score = score + f1_score(y_check, y_predict, average='weighted')
score = score/(C)
# print(f"k = {k},score = {score}")
scores.append(score)

scores = np.array(scores)
bestK = scores.argmax()
plt_KScore(scores,K,bestK)
return bestK

# 绘制K对应的Score图像
def plt_KScore(scores,K,bestK):
k_range = range(1,K+1)
plt.plot(k_range,scores[1:])
plt.scatter(bestK,scores[bestK],marker='o',c='red')
plt.xlabel('Value of K in KNN')
plt.ylabel('Score')
plt.show()'Score')
plt.show()

下面将测试交叉验证集选择K值。

这里使用sklearn自带的鸢尾花数据集,标准化之前的文章已经手动实现过,这里直接用sklearn自带。

导入数据和预处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import time
from sklearn import datasets
from sklearn.preprocessing import StandardScaler

if __name__ == '__main__':
# 导入鸢尾花数据集
iris = datasets.load_iris()
dataSet = iris.data
target = iris.target

# 拆分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(dataSet,target, train_size=0.7, random_state=10)
# 数据标准化
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train) # 标准化训练集X
X_test = scaler.transform(X_test)

现在开始测试交叉验证集选择K:

1
2
3
4
5
6
start = time.time()
K = chooseK(X_train,y_train,50)
print(f"bestK is {K}")

end = time.time()
print(f"run time is {end-start}")

结果:

1
2
bestK is 7
run time is 2.0105364322662354

实际上会发现每次计算出来的“Best K”都不一样,不过都在那几个值跳动,这是因为在分割测试集和验证集时没有设置随机种子的原因。

现在,让我们用最好的K值来测试分类效果吧!

1
2
3
4
5
6
7
8
    start = time.time()
best_K = chooseK(X_train,y_train,50)
print(f"bestK is {best_K}")
y_predict = run_KNN(X_test,X_train,y_train,best_K)
score = f1_score(y_test, y_predict, average='weighted')
print(f"predict score is {score}")
end = time.time()
print(f"run time is {end-start}")

结果:

1
2
3
bestK is 4
predict score is 0.9778242192035296
run time is 0.400089502334594704

可以看出bestK和上一次算的不一样,不过最后分类的结果还是非常好的。

利用Sklearn实现KNN完成鸢尾花分类

关于sklearn封装的KNN的参数说明详细请参考文章: KNN算法详解及实现__dingzhen的博客-CSDN博客_knn

读懂每一个需要的参数很很很重要!!一定要去看参数说明

sklearn的KNN实现方式包括了之前提到的线性扫描KDTreeballTree,没有规定特定参数时它会自己选则实现方法。详细说明都在链接里了。

这次我们用Sklearn封装的KNN来完成上面提到的例子,即鸢尾花分类。同样也用了交叉验证寻找最佳K值。

详见代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import time
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import f1_score
from sklearn.model_selection import cross_val_score, train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn import datasets
from sklearn.preprocessing import StandardScaler

# 交叉验证找最好K
def findBestK(X_train,y_train,max_k = 50):
k_scores = [0]
for k in range(1,max_k+1):
Knn = KNeighborsClassifier(n_neighbors=k)
# 交叉验证,scoring='f1_weighted' 使用的与之前手写的交叉验证使用同一个F1-score规则
scores = cross_val_score(Knn,X_train,y_train,cv=5,scoring='f1_weighted')
k_scores.append(scores.mean())
k_scores = np.array(k_scores)
bestK = k_scores.argmax()
plt_KScore(k_scores,max_k,bestK)
return bestK

# 绘制K对应的Score图像
def plt_KScore(scores,K,bestK):
k_range = range(1,K+1)
plt.plot(k_range,scores[1:])
plt.scatter(bestK,scores[bestK],marker='o',c='red')
plt.xlabel('Value of K in KNN')
plt.ylabel('Score')
plt.show()


if __name__ == '__main__':
# 导入鸢尾花数据集
iris = datasets.load_iris()
dataSet = iris.data
target = iris.target

# 拆分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(dataSet,target, train_size=0.7, random_state=10)
# 数据标准化
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train) # 标准化训练集X
X_test = scaler.transform(X_test)

# 寻找最好K值
start = time.time()
best_K = findBestK(X_train,y_train)
print(f"best_k is {best_K}")
model = KNeighborsClassifier(n_neighbors=best_K)
model.fit(X_train,y_train)
y_predict = model.predict(X_test)
score = f1_score(y_test, y_predict, average='weighted')
print(f"predict score is {score}")
end = time.time()
print(f"run time is {end - start}")

交叉验证集选择K:

最终结果:

1
2
3
best_k is 11
predict score is 0.9778242192035296
run time is 0.7521629333496094

通过自己实现的KNN与sklearn实现的KNN对比,发现:

  1. 自己实现的KNN寻找到的最佳K值不固定,而sklearn每次寻找的最佳K值都一样。导致自己实现的KNN最佳K值不固定的原因是拆分数据集和验证集时每次的随机种子不固定。偶尔有几次自己算出来的结果和sklearn算出来的结果完全一致。

  2. 使用同种实现方式(即线性扫描实现KNN)时,sklearn用的时间比自己实现的要长那么一点点,暂时不清楚原因。

利用Sklearn的KNN完成手写数字识别

到此为止,你已经学会了如何自己实现KNN算法,并且取得了不错的分类效果。现在我们用sklrean封装好的KNN来完成一些有趣的事吧!

手写数据识别是计算机视觉方面的入门级图像识别,一般都是用神经网络实现的,不过我们可以使用KNN来完成数据规模比较小的手写数字识别。

在此之前,请确保你已经安装tensorflow和keras,我们的数据集将来自keras。

当然也可以自己找数据。

需要的包:

1
2
3
4
import numpy as np
import matplotlib.pyplot as plt
from keras.datasets import mnist
from sklearn.neighbors import KNeighborsClassifier

导入数据并查看数字图像

1
2
3
4
5
6
7
8
if __name__ == '__main__':
# 加载数据
(X_train,y_train),(X_test,y_test) = mnist.load_data()
# 绘制训练集的部分数字图像
for i in range(6):
plt.figure()
plt.imshow(X_train[i],cmap='gray')
plt.show()

查看X_train训练集的形状:

1
    print(X_train.shape)
1
(60000, 28, 28)

看得出是个三维的,大概意思是60000张图片,\(28\times 28\)像素,且是灰色(因为如果是彩色,应该是\(28\times28\times3\))

要用KNN算法,需要先将三维转化为二维矩阵\((60000,28 \times 28)\) ,每一行代表一张图片,共60000张。

数据预处理

1
2
3
4
5
6
# 将训练集和测试集转化为二维数组
X_train = X_train.reshape([-1,28*28])
X_test = X_test.reshape([-1,28*28])
# 归一化
X_train /= 255
X_test /= 255

选择最佳K值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 交叉验证法求最好K值
def findBestK(X_train,y_train,max_k = 50):
k_scores = [0]
for k in range(1,max_k+1):
print(f"{k} time")
Knn = KNeighborsClassifier(n_neighbors=k)
# 交叉验证,scoring='f1_weighted' 使用的与之前手写的交叉验证使用同一个F1-score规则
scores = cross_val_score(Knn,X_train,y_train,cv=5,scoring='f1_weighted')
k_scores.append(scores.mean())
k_scores = np.array(k_scores)
bestK = k_scores.argmax()
# plt_KScore(k_scores,max_k,bestK)
return bestK

# 绘制K对应的Score图像
def plt_KScore(scores,K,bestK):
k_range = range(1,K+1)
plt.plot(k_range,scores[1:])
plt.scatter(bestK,scores[bestK],marker='o',c='red')
plt.xlabel('Value of K in KNN')
plt.ylabel('Score')
plt.show()
1
2
3
4
5
6
7
8
9
10
11
# 接主函数内

start = time.time()
# 选择最佳K值,这一步如果觉得耗时太长,其实可以去掉
# 为了缩短时间,我们只取10000个数据用于选择最佳K值
best_K = findBestK(X_train[:10000], y_train[:10000])
print(f"best_k is {best_K}")


end = time.time()
print(f"run time is {end - start}")

结果:

1
2
best_k is 4
run time is 54.95526051521301

这一步太慢了!!整整耗时55秒。强烈建议不要进行,如果需要最佳K值,上面已经算出来了。

训练模型,测试模型得分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
start = time.time()
# 选择最佳K值,这一步如果觉得耗时太长,其实可以去掉
# 为了缩短时间,我们只取10000个数据用于选择最佳K值
# best_K = findBestK(X_train[:10000], y_train[:10000])
# print(f"best_k is {best_K}")

# 训练模型
model = KNeighborsClassifier(n_neighbors=4)
model.fit(X_train,y_train)
# 计算得分
y_predict = model.predict(X_test)
score = f1_score(y_test, y_predict, average='weighted')
print(f"predict score is {score}")
end = time.time()
print(f"run time is {end - start}")

结果:

1
2
predict score is 0.9681286795453637
run time is 7.25964617729187

可以看出模型很不错!到此为止,使用KNN进行手写数字识别可以说完成了。

但是,作为一个合格的玩具,它还没完。

接下来是可选内容:把它变成可以玩的玩具!

完成手写数字识别玩具

现在假设你拿到了这样一张图片:

很显然,它是彩色的,但是我们之前训练用的图片是灰色的,而且,中间是白色的,像这样:

另外,两张像素可能不一样,前者像素不确定,后者为\(28\times 28\)所以我们需要先把图片做一些预处理:

  1. 将彩色图片变为灰色图片(即将三图层变为单图层)

  2. 将图片像素压缩为\(28\times28\)

  3. 可以将图片二值化(即0和255),可能识别效果会更好。

图片预处理

接下来会用到opencv-python包,但是注意高版本的tensorflow和opencv-python可能出现不兼容的问题。最开始我用的tensorflow2.9.0,对应的numpy为1.23。但是安装opencv后会自动把numpy降为1.16,导致tensorflow用不了。所以这里可能需要切换一下环境。

我用的环境:tensorflow2.6.0+opencv4.5.5+numpy1.19.5

tensorflow版本降低后,数据集的导入可能会发生变化。

改变导入形式:

1
2
3
4
5
6
7
8
import cv2
import time
import numpy as np
import matplotlib.pyplot as plt
from tensorflow.keras.datasets import mnist
from sklearn.metrics import f1_score
from sklearn.model_selection import cross_val_score
from sklearn.neighbors import KNeighborsClassifier

将彩色图片以灰度图片导入

查看原图片:

1
2
3
4
5
path = 'number1.png' # path为图片储存位置
# 原图像
img1 = plt.imread(path)
plt.imshow(img1)
plt.show()

以灰度图片导入并查看

1
2
3
4
5
6
7
imgOriginal = cv2.imread(path,0)
img = np.asarray(imgOriginal)
print(f"before resize shape is:{img.shape}")
img = cv2.resize(img,(28,28))
print(f"after resize shape is:{img.shape}")
plt.imshow(img,cmap='Greys_r') # cmap='Greys_r' 使plt显示灰度图像
plt.show()

如果不加cmap='Greys_r',matplotlib会把这张灰色图片由低维(1维)映射到高维(3维),然后以彩色图片的形式展出,这显然不符合我们这里的需求。

输出:

1
2
before resize shape is:(188, 186)
after resize shape is:(28, 28)

黑白颠倒以及二值化

我们的样本数据集为黑底白字,所以需要将这张图片也要变为黑底白字。同时我们可以将其二值化,变为黑白图像:

这里的二值化只是简单实现了下,还有更好的二值化方法,以后再学了。

1
2
3
4
5
6
7
8
9
10
11
12
# 二值化
def Binarization(img,Reverse = False):
'''
:param img:
:param Reverse: 是否黑白颠倒
:return:
'''
if Reverse == False:
img = np.where(img > 160,255,0)
else:
img = np.where(img > 160,0,255)
return img

现在将图片二值化:

1
2
3
img = Binarization(img,Reverse = True)
plt.imshow(img, cmap='Greys_r') # cmap='Greys_r' 使plt显示灰度图像
plt.show()

最后不要忘记了归一化和转化矩阵:

1
2
X = img/255.0
X = X.reshape(-1,28*28)

使用模型识别手写数字

1
2
predict = model.predict(X)
print(f"手写数字识别为:{predict[0]}")

结果:

1
手写数字识别为:2

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import cv2
import time
import numpy as np
import matplotlib.pyplot as plt
from tensorflow.keras.datasets import mnist
from sklearn.metrics import f1_score
from sklearn.model_selection import cross_val_score
from sklearn.neighbors import KNeighborsClassifier


# 交叉验证法求最好K值
def findBestK(X_train,y_train,max_k = 50):
k_scores = [0]
for k in range(1,max_k+1):
print(f"{k} time")
Knn = KNeighborsClassifier(n_neighbors=k)
# 交叉验证,scoring='f1_weighted' 使用的与之前手写的交叉验证使用同一个F1-score规则
scores = cross_val_score(Knn,X_train,y_train,cv=5,scoring='f1_weighted')
k_scores.append(scores.mean())
k_scores = np.array(k_scores)
bestK = k_scores.argmax()
# plt_KScore(k_scores,max_k,bestK)
return bestK

# 绘制K对应的Score图像
def plt_KScore(scores,K,bestK):
k_range = range(1,K+1)
plt.plot(k_range,scores[1:])
plt.scatter(bestK,scores[bestK],marker='o',c='red')
plt.xlabel('Value of K in KNN')
plt.ylabel('Score')
plt.show()

# 计算模型得分
def cal_score(model,X_test,y_test):
y_predict = model.predict(X_test)
score = f1_score(y_test, y_predict, average='weighted')
print(f"predict score is {score}")

# 二值化
def Binarization(img,Reverse):
'''
:param img:
:param Reverse: 是否黑白颠倒
:return:
'''
if Reverse == False:
img = np.where(img > 160,255,0)
else:
img = np.where(img > 160,0,255)
return img

# 识别数字
def identification_number(path,model = None,Reverse = False):
# 原图像
# img1 = plt.imread(path)
# plt.imshow(img1)
# plt.show()

imgOriginal = cv2.imread(path,0)
img = np.asarray(imgOriginal)
img = cv2.resize(img,(28,28))
# plt.imshow(img,cmap='Greys_r') # cmap='Greys_r' 使plt显示灰度图像
# plt.show()

img = Binarization(img,Reverse)
# plt.imshow(img, cmap='Greys_r') # cmap='Greys_r' 使plt显示灰度图像
# plt.show()

X = img/255.0
X = X.reshape(-1,28*28)
predict = model.predict(X)
print(f"手写数字识别为:{predict[0]}")

if __name__ == '__main__':
# 加载数据
(X_train,y_train),(X_test,y_test) = mnist.load_data()
# 归一化
X_train = X_train /255.0
X_test = X_test /255.0
# 将训练集和测试集转化为二维数组
X_train = X_train.reshape([-1,28*28])
X_test = X_test.reshape([-1,28*28])

# start = time.time()

# 选择最佳K值,这一步如果觉得耗时太长,其实可以去掉
# 为了缩短时间,我们只取10000个数据用于选择最佳K值
# best_K = findBestK(X_train[:10000], y_train[:10000])
# print(f"best_k is {best_K}")

# 构建模型,喂数据
model = KNeighborsClassifier(n_neighbors=4)
model.fit(X_train,y_train)
#
# # 计算得分
# cal_score(model,X_test,y_test)

# 识别图片
img_path = 'number1.png'
identification_number(img_path,model,Reverse=True) # 在这里修改是否需要颠倒图像
# end = time.time()

对模型的总结和评价

实际上因为没有用神经网络来做这个手写数字识别模型,加上二值化写的比较草率,它对于真实手写数字识别效果并没有预料的好。待会儿会举两个识别自己手写数字的例子。

在二值化时阈值的选择影响比较大,最开始阈值设置的200,结果自己手写的数字二值化后变成了一片白或黑色。后来调整到160-170左右,感觉比较合适。

咱也是第一次做手写数字识别玩具,整个过程还是很有意思的。

自己写的数字:

二值化和预测结果:

1
手写数字识别为:5

识别失败:

1
手写数字识别为:1