1-k-近邻算法
👓 k - 近邻算法 kNN
🚨 注意 k - 近邻算法 和 k - 均值算法 是不同的,一个是监督学习算法,一个是非监督学习算法
众所周知,电影可以按照题材进行分类,然而题材本身是如何定义的?由谁来判定某部电影属于哪个题材?比如说爱情片中的 kiss 镜头更多,而动作片中的打斗场景更加频繁,基于此类场景在某部电影中出现的次数来进行电影分类。本章我们将基于此使用 k-近邻算法构造程序,自动划分电影的题材类型。🏃
1. k-近邻算法
① 概述
简单来说,k-近邻算法就是采用测量不同特征值之间的距离方法进行分类。
- 优点:精度高、对异常值不敏感、无数据输入假定
- 缺点:计算复杂度高、空间复杂度高
- 适用数据范围:数值型和标称型
⭐ kNN 算法原理:存在一个带标签的训练样本集,输入没有标签的新数据后,将新数据的每个特征和样本集中的数据对应的特征进行比较,然后算法提取样本集中 k 个特征最相似(最邻近)的分类标签(这就是 k-近邻算法中 k 的 出处,一般 k 不大于 20)。最后,选择这 k 个最相似数据中出现次数最多的分类,作为新数据的分类。
回到我们前面电影分类的例子,假如有一部未看过的电影,如何确定它是爱情片还是动作片呢?
下图显示了 6 部电影的打斗和接吻镜头数,?就是我们要判断的电影:
计算未知电影与样本集中其他电影的距离(此处暂时不要关心如何计算得到这些距离,使用 Python 实现电影分类应用时,会提供具体的计算方法)
假定 k = 3
则最靠近的三个电影分别是 He's Not Really into Dudes
,Beautiful Woman
,California Man
,这三部电影都是爱情片,所以我们判断未知电影是爱情片。
✍ OK,下面用具体的 Python 代码实现
② 代码实现
Ⅰ 数据准备
我们所有的代码都写在 kNN.py
文件中
首先 ,创建数据集和标签:
import numpy as np
import operator # 运算符模块
def createDataSet():
group = np.array([[1.0, 1.1],[1.0, 1.0],[0, 0],[0, 0.1]])
labels = ['A', 'A', 'B' ,'B']
return group,labels
这里有 4 组数据,每组数据有 2 个属性/特征值,向量 labels 包含了每个数据点的标签信息,数据点 [1.0, 1.1]
定义为类 A,数据点 [0, 0]
定义为类 B。
为了说明方便,例子中的数值并没有给出轴标签:
Ⅱ kNN 算法
本节使用 kNN 算法为每组数据分类。这里首先给出 kNN 算法的伪代码:
⭐ 对未知类别属性的数据集中的每个点依次执行以下操作:
- 计算已知类别数据集中的点与当前点的距离
- 按照距离递增排序
- 选择与当前点距离最小的 k 个点
- 确定前 k 个点所在类别的出现频率
- 返回前 k 个点出现频率最高的类别作为当前点的预测分类
Python 代码如下:
# inx: 输入向量(未知数据)
# dataSet:训练样本集
# labels: 标签向量
# k:选择最近邻居的数目
def classify0(inX, dataSet, labels, k):
dataSetSize = dataSet.shape[0] # 4
diffMat = np.tile(inX,(dataSetSize, 1)) - dataSet # 计算未知类的数据集与已知数据集的差
sqDiffMat = diffMat**2 # 差值平方化
sqDistances = sqDiffMat.sum(axis=1) # 把(未平方根化之前的)未知数据集与两个已知数据的距离分别计算出来
distances = sqDistances**0.5 # 距离平方根化
sortedDistIndicies = distances.argsort() # 排序,返回下标
classCount = {}
for i in range(k):
voteIlabel = labels[sortedDistIndicies[i]] # 遍历排序后的前 k 个标签
classCount[voteIlabel] = classCount.get(voteIlabel,0) + 1 # 记录这 k 个标签出现的次数
sortedClassCount = sorted(classCount.items(), key = operator.itemgetter(1), reverse = True) # 按照出现次数对标签进行从大到小排序
return sortedClassCount[0][0] # 返回出现次数最多的那个标签
详解如下:
😊 1:获取数据集行数
dataSetSize = dataSet.shape[0]
返回数据集的行数,数据集是 4x2 的,所以返回 4
😊 2:计算欧式距离
接下来开始计算距离,使用欧式距离公式,计算两个向量点 xA 和 xB 之间的距离:
# 计算未知类的数据集与已知数据集的差,为了方便要把未知类的数据集化成矩阵计算
diffMat = np.tile(inX,(dataSetSize, 1)) - dataSet
np.tile(inX,(dataSetSize, 1))
将未知类数据 inX 沿着 Y 轴方向扩充 dataSetSize 倍(4 倍),沿着 X 轴扩充 1 倍,即不扩充
💬 举个例子:设未知类数据 inX 是 ([[0,3]]) 即 $\begin{bmatrix} 0 & 3\end{bmatrix}$,数据集 dataSet是 ([[1,3],[3,4]]) 即 $\begin{bmatrix} 1 & 3 \ 3 & 4 \end{bmatrix}$
将 inx 沿着 Y 轴扩充 2 倍后:$\begin{bmatrix} 0 & 3 \ 0 & 3 \end{bmatrix}$
计算未知类数据集与已知数据集的差:$\begin{bmatrix} 0 & 3 \ 0 & 3 \end{bmatrix} - \begin{bmatrix} 1 & 3 \ 3 & 4 \end{bmatrix}$ = $\begin{bmatrix} -1 & 0 \ -3 & 1 \end{bmatrix}$
sqDiffMat = diffMat**2 # 差值平方化
$sqDiffMat = \begin{bmatrix} -1 & 0 \ -3 & 1 \end{bmatrix}^2$ = $\begin{bmatrix} 1 & 0 \ 9 & 1 \end{bmatrix}$
# 把(未平方根化之前的)未知数据集与两个已知数据的距离分别计算出来
sqDistances = sqDiffMat.sum(axis=1)
按行向量进行相加:
$sqDistances = [1, 10] $
1 是未知数据与 [1, 3] 的距离,10 是未知数据与 [3, 4] 的距离
distances = sqDistances**0.5 # 距离平方根化
将两个距离分别进行平方根化,分别得到该未知标签向量与已知点的欧式距离
sortedDistIndicies = distances.argsort() # 排序,返回下标
比如说:[3, 5, 1] 从小到大排序分别是:1,3,5,对应的索引是 2,0,1
这样我们在循环的时候, 可以将欧式距离按值从小到大遍历出来
😊 3:获取距离最近的前 k 个标签出现的次数
接下来是一个循环,用来记录前 k 个标签分别出现的次数:
classCount = {}
for i in range(k):
voteIlabel = labels[sortedDistIndicies[i]] # 遍历排序后的前 k 个标签
classCount[voteIlabel] = classCount.get(voteIlabel,0) + 1 # 记录这 k 个标签出现的次数
classCount
是一个字典,用来存储标签和标签出现的次数
classCount.get(voteIlabel,0)
返回字典 classCount
中 voteIlabel
元素对应的值,若无,则进行初始化为 0,若有,则返回该值
classCount[voteIlabel] = classCount.get(voteIlabel,0) + 1
初始化 classCount = {}
时,此时输入classCount
,输出为:
classCount = {}
当第一次遇到新的 label 时,将新的 label 添加到字典 classCount
,并初始化其对应数值为 0
然后 +1,即该 label 已经出现过一次,此时输入classCount
,输出为:
classCount = {voteIlabel: 1}
当第二次遇到同一个 label 时,classCount.get(voteIlabel,0)
返回对应的数值(此时括号内的 0 不起作用,因为已经初始化过了),然后+1,此时输入classCount
,输出为:
classCount = {voteIlabel: 2}
可以看出,+1 是每一次都会起作用的, 因为不管遇到字典内已经存在的或者不存在的,都需要把这个元素记录下来
😊 4:按照标签出现的次数对标签进行排序
循环结束后,按照标签出现的次数对标签进行排序:
sortedClassCount = sorted(classCount.items(), key = operator.itemgetter(1), reverse = True) # 按照出现次数对标签进行从大到小排序
classCount.items()
返回的是一个元组 dict_items,即将 classCount
转化为元组。例如:
operator.itemgetter(1)
按照第二个元素的次序对元组进行排序(默认从小到大),reverse=True
是逆序,即按照从大到小的顺序排列
😊 5:返回出现次数最多的标签
排序完毕后,返回元组中的第一个元素即出现次数最多的标签:
return sortedClassCount[0][0] # 返回出现次数最多的那个标签
Ⅲ 运行效果
OK,我们看一下上述代码的运行效果:
2. 示例:使用 k-近邻算法改进约会网站的配对效果
海伦使用约会网站寻找约会对象。经过一段时间之后,她发现曾交往过三种类型的人:
- 不喜欢的人
- 魅力一般的人
- 极具魅力的人
她希望:
- 工作日与魅力一般的人约会
- 周末与极具魅力的人约会
- 不喜欢的人则直接排除掉
现在她收集到了一些约会网站未曾记录的数据信息,这更有助于匹配对象的归类。
① 准备数据:从文本文件中解析数据
数据存放在 datingTestSet.txt 中,每个样本数据占一行,总共有 1000 行,每行 4 列:
- 第一列:每年获得的飞行常客里程数
- 第二列:玩视频游戏所消耗时间百分比
- 第三列:每周消费的冰淇淋公升数
- 第四列:喜欢程度(标签)非常喜欢 largeDoses | 一般喜欢 smallDoses | 不喜欢 didntLike
在上述特征数据输入到分类器之前,必须将待处理数据的格式改变为分类器可以接受的格式。在 kNN.py
文件中创建 file2matrix
的函数来处理输入格式问题。该函数的输入为文件名字符串,输出为训练样本矩阵和类标签向量。
# 将文本记录转换成 Numpy 矩阵
def file2matrix(filename):
love_dictionary = {'largeDoses':3, 'smallDoses':2, 'didntLike':1} # 标签数字化
fr = open(filename)
arrayOLines = fr.readlines()
numberOfLines = len(arrayOLines) # 获取文件行数 1000
returnMat = np.zeros((numberOfLines, 3)) # 初始化矩阵 1000 x 3 存储所有数据(除标签外)
classLabelVecotr = [] # 标签列表
index = 0
# 循环处理文件中的每行数据
for line in arrayOLines:
line = line.strip() # 返回已移除字符串头尾指定字符所生成的新字符串
listFromLine = line.split('\t') # 以 '\t' 切割字符串
returnMat[index,:] = listFromLine[0:3] # 选取前 3 个元素,将其存储到returnMat矩阵中
# 将listFromLine列表的最后一列即标签,存入classLabelVector标签列表中
if(listFromLine[-1].isdigit()):
classLabelVecotr.append(int(listFromLine[-1]))
else:
classLabelVecotr.append(love_dictionary.get(listFromLine[-1]))
index += 1
return returnMat, classLabelVecotr
💡 需要注意的是,我们必须明确的通知解释器,告诉它列表中存储的元素为整型,否则Python会将这些元素当作字符串处理:
if(listFromLine[-1].isdigit()): classLabelVecotr.append(int(listFromLine[-1])) else: classLabelVecotr.append(love_dictionary.get(listFromLine[-1]))
Ok,查看一下我们的数据处理结果:
② 分析数据:使用 Matplotlib 创建散点图
接下来,我们将数据进行可视化,直观的展示数据,使用 Matplotlib 画二维散点图:
import matplotlib
import matplotlib.pyplot as plt
fig = plt.figure()
ax = fig.add_subplot(1,1,1)
ax.scatter(datingDataMat[:,1],datingDataMat[:,2],15.0*array(datingLabels), 15.0*array(datingLabels))
plt.show()
下图中采用矩阵的第一和第二列属性得到很好的展示效果,清晰地标识了三个不同的样本分类区域,具有不同爱好的人其类别区域也不同。
③ 准备数据:归一化数值
比如说,我们计算样本 3 和样本 4 之间的距离:
显然,每年获得的飞行常客里程数对于计算结果的影响远大于其他两个特征。所以我们可以使用归一化,将取值范围都处理到 -1 ~ 1 之间。方法有如下:
Python 实现如下:
# 归一化特征值
def autoNorm(dataSet):
minVals = dataSet.min(0) # 每列的最小值 1 x 3
maxVals = dataSet.max(0) # 每列的最大值 1 x 3
ranges = maxVals - minVals # 1 x 3
normDataSet = np.zeros(np.shape(dataSet)) # 初始化新的矩阵 1000 x 3
m = dataSet.shape[0]
normDataSet = dataSet - np.tile(minVals, (m,1))
normDataSet = normDataSet / np.tile(ranges, (m, 1))
return normDataSet, ranges, minVals
📜 这里可以只返回
normDataSet
,但是在下一节我们需要将取值范围和最小值归一化测试数据。
需要注意的是,特征值矩阵有 1000 x 3 个值,而 minVals
和 range
的值都为 1 x 3,为了解决这个问题,我们使用 Numpy 库中 tile()
函数将变量内容复制成输入矩阵同样大小的矩阵。注意在 Numpy 中 /
是具体特征值相除,而在其他某些包中可能意味着矩阵除法,但在 Numpy 中 矩阵除法需要使用函数 linalg.solve(matA,matB)
④ 测试算法: 作为完整程序验证分类器
使用海伦提供的部分数据作为测试样本。如果预测分类与实际类别不同,则标记为一个错误。
通常我们提供已有数据的 90% 作为训练样本来训练分类器,而使用剩余的 10% 数据(随机选择)去测试分类器,检测分类器的正确率。
代码里我们定义一个计数器变量,每次分类器错误地分类数据,计数器就加 1,程序执行完毕后计数器除以数据总数即是错误率。
# 分类器针对约会网站地测试代码
def datingClassTest():
hoRatio = 0.10 # 设置测试数据的的一个比例(训练数据集比例=1-hoRatio)
datingDataMat, datingLabels = file2matrix('datingTestSet2.txt') # 加载文件
normMat, ranges, minVals = autoNorm(datingDataMat) # 归一化数据
m = normMat.shape[0] # 数据的行数
numTestVecs = int(m * hoRatio) # 测试样本的行数
print('测试样本的行数 = ', numTestVecs)
errorCount = 0.0 # 计数器
# 测试数据
for i in range(numTestVecs):
# normMat[i, :]: 测试样本集(未知数据)
# numTestVecs:m 训练样本集
# datingLabels[numTestVecs:m]: 标签向量
# 3:选择最近邻居的数目
classifierResult = classify0(normMat[i, :], normMat[numTestVecs:m, :], datingLabels[numTestVecs:m], 3) # 返回判断的数据标签
print("the classifier came back with: %d, the real answer is: %d" % (classifierResult, datingLabels[i]))
if (classifierResult != datingLabels[i]):
errorCount += 1.0
print("错误率 = : %f" %(errorCount / float(numTestVecs)))
datingClassTest()
运行结果的部分截图如下:
错误率是 5%,还不错 👍
⑤ 使用算法:构建完整可用系统
下面我们构造一个完整可用的系统,通过该程序海伦可以在约会网站上找到某个人并输入他的信息,程序会给出她对对方的喜欢程度的预测值。
# 约会网站预测函数
def classifyPerson():
resultList = ['不喜欢', '一般喜欢', '非常喜欢'] # 定义标签
percentTats = float(input("玩视频游戏所消耗时间百分比: "))
ffMiles =float(input("每年获得的飞行常客里程数: "))
iceCream = float(input("每周消费的冰淇淋公升数: "))
datingDataMat, datingLabels = file2matrix('datingTestSet2.txt') # 数据集和标签
normMat,ranges,minVals = autoNorm(datingDataMat) # 数据集归一化
inArr = np.array([ffMiles, percentTats, iceCream,]) # 输入向量(未知数据/测试数据)
classifierResult = classify0((inArr - minVals) / ranges, normMat, datingLabels, 3) # 测试数据同样也要归一化
print("你可能喜欢这个人的程度:%s" %resultList[classifierResult - 1])
3. 示例:手写识别系统
项目概述:
构造一个能识别数字 0 到 9 的基于 KNN 分类器的手写数字识别系统。
需要识别的数字是存储在文本文件中的具有相同的色彩和大小: 宽高是 32 像素 * 32 像素的黑白图像。
开发流程:
- 收集数据: 提供文本文件。
- 准备数据: 编写函数
img2vector()
, 将图像格式转换为分类器使用的向量格式 - 分析数据: 检查数据,确保它符合要求
- 训练算法: 此步骤不适用于 KNN
- 测试算法: 编写函数使用提供的部分数据集作为测试样本,测试样本与非测试样本的区别在于测试样本是已经完成分类的数据,如果预测分类与实际类别不同,则标记为一个错误
- 使用算法: 本例没有完成此步骤,若你感兴趣可以构建完整的应用程序,从图像中提取数字,并完成数字识别,美国的邮件分拣系统就是一个实际运行的类似系统
① 准备数据:将图像转为测试向量
目录 trainingDigits 中包含了大约 2000 个例子,每个例子内容如下图所示,每个数字大约有 200 个样本;目录 testDigits 中包含了大约 900 个测试数据。
前缀 0 就表示这个数字是 0,可以作为我们的标签 ~
为了使用前面两个例子的分类器,我们必须将图像格式化处理为一个向量,我们将把一个 32 x 32 的二进制图像矩阵转化为 1 x 1024 的 向量,这样前两节使用的分类器就可以处理数字图像信息了。
首先编写一段函数 img2vector
将图像转化为向量:该函数创建 1 x 1024 的 Numpy 数组,然后打开给定的文件,循环读出文件的前 32 行,并将每行的头 32 个字符值存储在 Numpy 数组中,最后返回数组。
def img2vector(filename):
returnVect = np.zeros((1, 1024)) # 初始化矩阵
fr = open(filename)
for i in range(32):
lineStr = fr.readline() # 读取行
for j in range(32): # 读取列
returnVect[0, 32*i+j] = int(lineStr[j]) # 存入数组
return returnVect
② 测试算法:使用 k-近邻算法识别手写数字
上节我们已经将数据处理成分类器可以识别的格式,本节中我们将这些数据输入到分类器,检测分类器的执行效果。
from os import listdir
# 手写数字识别系统的测试代码
def handwritingClassTest():
hwLabels = [] # 标签
# 获取目录内容
trainingFileList = listdir('trainingDigits') # listdir 列出给定目录(训练集)的文件名
m = len(trainingFileList) # 训练集个数
trainingMat = np.zeros((m,1024)) # 初始化训练集矩阵
# 从文件名解析分类数字
for i in range(m):
fileNameStr = trainingFileList[i] # 文件名
fileStr = fileNameStr.split('.')[0] # 以 . 为分割符
classNumStr = int(fileStr.split('_')[0]) # 分离出前缀(标签)
hwLabels.append(classNumStr)
trainingMat[i, :] = img2vector('trainingDigits/%s' %fileNameStr) # 训练集图像转化成向量
testFileList = listdir('testDigits') # 列出测试集的文件名
errorCount = 0.0 # 判断错误的个数
mTest = len(testFileList) # 测试集个数
for i in range(mTest):
fileNameStr = testFileList[i] # 文件名
fileStr = fileNameStr.split('.')[0] # 以 . 为分割符
classNumStr = int(fileStr.split('_')[0]) # 分离出前缀(标签)
vectorUnderTest = img2vector('testDigits/%s' %fileNameStr) # 测试集图像转化成向量
classifierResult = classify0(vectorUnderTest, trainingMat, hwLabels, 3) # kNN 算法进行分类
print("分类器判断的标签为: %d, 真实标签为: %d" % (classifierResult, classNumStr))
if (classifierResult != classNumStr):
errorCount += 1.0
print("\n总共判断错误的个数为: %d" % errorCount)
print("\n错误率为: %f" % (errorCount/float(mTest)))
4. 小结
kNN 算法是分类数据最简单最有效的算法。但是 kNN 算法必须保存全部数据集,如何训练数据集很大,必须使用大量的存储空间。此外,由于必须对数据集中的每个数据计算距离值,实际使用时可能非常耗时。
kNN 算法的另一个缺陷是它无法给出任何数据的基础结构信息,因此我们也无法知道平均实例样本和典型实例样本具有什么特征。下一章我们将使用概率测量方法处理分类问题,该算法可以解决这一问题。
📚 References
-
《Machine Learning in Action》