KNN算法
KNN算法是本书中的第一个分类算法,也是最简单的算法之一。本文章对书中代码基于Python3做了少稍微的修改
以电影分类举例,动作片中也会存在接吻镜头,爱情片中也会存在打斗场景,我们不能单纯依靠是否存在打斗或者亲吻来判断影片的类型。但是爱情片中的亲吻镜头更多,动作片中的打斗场景也更频繁,基于此类场景在某部电影中出现的次数可以用来进行电影分类。
1.概述
简单地说,k-近邻算法采用测量不同特征值之间的距离方法进行分类。
优点:精度高、对异常值不敏感、无数据输入假定。
缺点:计算复杂度高、空间复杂度高。
适用数据范围:数值型和标称型。
工作原理:存在一个样本数据集合,也称作训练样本集,并且样本集中每个数据都存在标签,即我们知道样本集中每一数据 与所属分类的对应关系。输入没有标签的新数据后,将新数据的每个特征与样本集中数据对应的特征进行比较,然后算法提取样本集中特征最相似数据(最近邻)的分类标签。一般来说,我们只选择样本数据集中前k个最相似的数据,这就是k-近邻算法中k的出处,通常k是不大于20的整数。 最后,选择k个最相似数据中出现次数最多的分类,作为新数据的分类。
比如电影分类的例子,加入我们已知很多电影的亲吻镜头和打斗镜头,也给他们做好了分类,现知一个新电影存在多少个打斗镜头和接吻镜头,我们就可以用KNN来给新电影分类。
首先计算未知电影 与样本集中其他电影的距离,此处暂时不要关心如何计算得到这些距离值。
得到了样本集中所有电影与未知电影的距离,按照距离递增排序,可以找到k个距离最近的电影。假定k=3,则三个最靠近的电影依次是He’s Not Really into Dudes、Beautiful Woman 和California Man。k-近邻算法按照距离最近的三部电影的类型,决定未知电影的类型,而这三部 电影全是爱情片,因此我们判定未知电影是爱情片。
2.一般流程
基于以上例子,看下K-近邻算法的一般流程:
- (1)收集数据:可以使用任何方法。
- (2) 准备数据:距离计算所需要的数值,最好是结构化的数据格式。
- (3)分析数据:可以使用任何方法。
- (4)训练算法:此步骤不适用于k-近邻算法。
- (5) 测试算法:计算错误率。
- (6)使用算法:首先需要输入样本数据和结构化的输出结果,然后运行k-近邻算法判定输入数据分别属于哪个分类,最后应用对计算出的分类执行后续的处理。
3.KNN算法代码
伪代码如下:
对未知类别属性的数据集中的每个点依次执行以下操作:
(1) 计算已知类别数据集中的点与当前点之间的距离;
(2) 按照距离递增次序排序;
(3) 选取与当前点距离最小的k个点;
(4) 确定前k个点所在类别的出现频率;
(5) 返回前k个点出现频率最高的类别作为当前点的预测分类。
需要说明的一点是,在这里距离计算采用欧式距离公式,计算两个向量点xA和xB之间的距离:
当然也有其他距离计算方法,想具体了解的可以自己查阅资料学习。
KNN分类代码如下:
import numpy as np
import pandas as pd
import operator
import os
def classify0(inX, dataSet, labels, k):
'''
KNN分类器
'''
dataSetSize = dataSet.shape[0] # 获取数据集的行数
diffMat = np.tile(inX, (dataSetSize,1)) - dataSet # 把输入数据复制dataSetSize行,计算输入集和数据集差值
sqDiffMat = diffMat**2 # 每个元素平方
sqDistances = sqDiffMat.sum(axis=1) # 每行相加
distances = sqDistances**0.5 # 开根号得到距离数组
sortedDistIndicies = distances.argsort() # 把所有距离索引排序,得到索引列表
classCount = {} # 定义一个空字典,用于存储每个标签出现的次数
# 下面选择距离最小的k个点依次统计标签数量
for i in range(k):
voteIlabel = labels[sortedDistIndicies[i]]
classCount[voteIlabel] = classCount.get(voteIlabel,0) + 1
# 下面对标签字典进行排序,输出结果为元组列表,形如[('age',19),('wangyan',21),('lilee',25),('liqun',32)]
sortedClassCount = sorted(classCount.items(), key=operator.itemgetter(1), reverse=True)
# 也可用lambda表达式: sortedClassCount = sorted(classCount.items, key=lambda item:item[1], reverse=True)
return sortedClassCount[0][0]
函数有4个输入参数:用于分类的输入向量是inX,输入的训练样本集为dataSet, 标签向量为labels,最后的参数k表示用于选择最近邻居的数目,其中标签向量的元素数目和矩 阵dataSet的行数相同。
本章为了测试分类器的效果,可以通过大量的测试数据,得到分类器的错误率——分类器给出错误结果的次数除以测试执行的总数。错误率是最简单的评估方法,主要用于评估分类器在某个数据集上的执行效果。
对于分类问题还有许多评估方法,如精确率/查准率、查全率/召回率、F1-score等。后面会专门总结一遍关于分类问题评估方法的文章。
4.示例
4.1使用 k-近邻算法改进约会网站的配对效果
4.1.1解析数据
现想通过分类器对网站上的约会对象进行分类。这里约会对象的特征有玩视频游戏所耗时间百分比 、每年获得的飞行常客里程数、每周消费的冰淇淋公升数 ,通过这些特征,将约会对象分为三种人,分别是不喜欢的人 、魅力一般的人 、 极具魅力的人 。在数据集中,这三种人使用数字1、2、3代表。
本书提供数据集为datingTestSet2.txt,共1000行。首先,来大概看一下数据集的情况,这里只截取前几行:
一共有4列,其中前三列为特征。最后一列为分类标签。
在将上述特征数据输入到分类器之前,必须将待处理数据的格式改变为分类器可以接受的格式。定义一个函数的输入为文 件名字符串,输出为训练样本矩阵和类标签向量,如下:
def file2matrix(filename):
'''
把文件数据转为向量数组
'''
fr = open(filename)
arrayOLines = fr.readlines() #读取所有行,并返回列表
numberOfLines = len(arrayOLines) # 文件行数
returnMat = np.zeros((numberOfLines,3)) # 初始化返回的数据数组
classLabelVector = [] # 初始化标签列表
index = 0
for line in arrayOLines:
line = line.strip() # 去掉每行前后回车符
listFromLine = line.split('\t') # 按\t分割每行数据
returnMat[index,:] = listFromLine[0:3] # 将前三列特征加入特征数组
classLabelVector.append(int(listFromLine[-1])) # 标签(每行最后一列)添加到列表
index += 1
return returnMat, classLabelVector
下面测试一下该函数:
datingDataMat, datingLabels = file2matrix('datingTestSet2.txt')
print(datingDataMat[:5])
看到输出结果如下,证明我们成功把数据转为列表
[[4.0920000e+04 8.3269760e+00 9.5395200e-01]
[1.4488000e+04 7.1534690e+00 1.6739040e+00]
[2.6052000e+04 1.4418710e+00 8.0512400e-01]
[7.5136000e+04 1.3147394e+01 4.2896400e-01]
[3.8344000e+04 1.6697880e+00 1.3429600e-01]]
4.1.2归一化数值
如果按照样本数据直接计算距离,会发现,因为每年获取的飞行常客里程数数值非常大,所以每年获取的飞行常客里程数数对于计算结果的影响将远远大于其他两个特征——玩视频游戏 和每周消费冰淇淋公升数。但是这三种特征是同等重要的,因此作为三个等权重的特征之一,飞行常客里程数并不应该如此严重地影响到计算结果。
在处理这种不同取值范围的特征值时,我们通常采用的方法是将数值归一化,如将取值范围 处理为0到1或者-1到1之间。下面的公式可以将任意取值范围的特征值转化为0到1区间内的值:
其中min和max分别是数据集中的某个特征的最值和最大值。
下面编写一个函数完成此计算:
def autoNorm(dataSet):
'''
归一化特征值
'''
# 找到每列的最小值和最大值
minVals = dataSet.min(0)
maxVals = dataSet.max(0)
# 初始化归一化的后数组
normDataSet = np.zeros(dataSet.shape)
# 归一化
normDataSet = (dataSet - minVals)/(maxVals - minVals)
return normDataSet
注意这里涉及到numpy矩阵运算,当两个维度不同的数组进行运算,书中使用tile()函数将数组复制了,但此处代码并没有使用,而是利用numpy的广播机制。
4.1.3测试算法
机器学习算法一个很重要的工作就是评估算法的正确率,通常我们只提供已有数据的90%作为训练样本来训练分类 器,而使用其余的10%数据去测试分类器,检测分类器的正确率。
下面是计算分类错误率的函数:
def datingClassTest():
'''
测试分类效果
'''
hoRatio = 0.10
datingDataMat,datingLabels = file2matrix('datingTestSet2.txt') #将文本数据转为数组
normMat = autoNorm(datingDataMat) #归一化数值
m = normMat.shape[0] #数据集行数
numTestVecs = int(m*hoRatio) #测试集大小
errorCount = 0.0 #错误数目
#下面逐个查看测试集是否分类正确
for i in range(numTestVecs):
classifierResult = classify0(normMat[i,:], #要测试的数据
normMat[numTestVecs:m,:], #90%的训练数据
datingLabels[numTestVecs:m], #训练数据的标签
5)
if (classifierResult != datingLabels[i]):
errorCount += 1.0
print ("the total error rate is: %f" % (errorCount/float(numTestVecs)))
print (errorCount)
执行函数,查看分类效果:
datingClassTest()
outPut:the total error rate is: 0.050000
5.0
看到分类器的错误率是5%。
我们可以改变函数 datingClassTest内变量hoRatio和变量k的值,检测错误率是否随着变量值的变化而增加。依赖于分类算法、数据集和程序设置,分类器的输出结果可能有很大的不同。
4.2手写识别系统
构造一个能够识别0-9数字的系统。
需要识别的数字已经使用图形处理软件,处理成具有相同的色 彩和大小:宽高是32像素×32像素的黑白图像。尽管采用文本格式存储图像不能有效地利用内 存空间,但是为了方便理解,我们还是将图像转换为文本格式。
步骤如下:
(1) 收集数据:提供文本文件。
(2) 准备数据:编写函数classify0(),将图像格式转换为分类器使用的list格式。
(3) 分析数据:在Python命令提示符中检查数据,确保它符合要求。
(4) 训练算法:此步骤不适用于k-近邻算法。
(5) 测试算法:编写函数使用提供的部分数据集作为测试样本,测试样本与非测试样本 的区别在于测试样本是已经完成分类的数据,如果预测分类与实际类别不同,则标记 为一个错误。
(6) 使用算法:本例没有完成此步骤,若你感兴趣可以构建完整的应用程序,从图像中提 取数字,并完成数字识别,美国的邮件分拣系统就是一个实际运行的类似系统。
4.2.1准备数据
实际图像存储在两个子目录内:目录trainingDigits中包含了大约2000个例子,每个数字大约有200个样本;目录testDigits中包含了大约900个测试数据。我们使用目录trainingDigits中的数据训练分类器,使用目录testDigits中的数据测试分类器的效果。
打开数据集如下,可以看到,文件夹下每个文本代表一个数字,其中文件名以‘_’分割,前面代表该文本的数字是什么,后面代表序号。
为了使用前面两个例子的分类器,我们必须将图像格式化处理为一个向量。我们将把一个32× 32的二进制图像矩阵转换为1×1024的向量,这样前两节使用的分类器就可以处理数字图像信息了。
将图像转换为向量:该函数创建1×1024的NumPy数 组,然后打开给定的文件,循环读出文件的前32行,并将每行的头32个字符值存储在NumPy数组 中,最后返回数组。
def img2vector(filename):
'''
把文本数据转为数组数据,文本是32x32的数字表
'''
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
4.2.2测试算法
测试代码如下:
def handwritingClassTest():
'''
手写数字识别测试
'''
hwLabels = [] # 初始化标签列表
trainingFileList = os.listdir('trainingDigits') # 读取trainingDigits文件夹下所有的文本名,名字中包含数字标签
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 = os.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) #当前测试集分类器分类结果
if classifierResult != classNumStr: #如果分类器分类结果和实际结果不同
errorCount += 1.0
print ("\nthe total number of errors is: %d" % errorCount)
print ("\nthe total error rate is: %f" % (errorCount/float(mTest)))
运行函数:
handwritingClassTest()
得到结果:the total number of errors is: 10
the total error rate is: 0.010571
k-近邻算法识别手写数字数据集,错误率为1.06%。改变变量k的值、修改函数handwriting- ClassTest随机选取训练样本、改变训练样本的数目,都会对k-近邻算法的错误率产生影响,感 兴趣的话可以改变这些变量值,观察错误率的变化。
实际使用这个算法时,算法的执行效率并不高。因为算法需要为每个测试向量做2000次距离 计算,每个距离计算包括了1024个维度浮点运算,总计要执行900次,此外,我们还需要为测试 向量准备2MB的存储空间
5.总结
k-近邻算法是分类数据最简单最有效的算法,k-近邻算法是基于实例的学习,使用算法时我们必须有接近实际数据的训练样本数 据。k-近邻算法必须保存全部数据集,如果训练数据集的很大,必须使用大量的存储空间。此外, 由于必须对数据集中的每个数据计算距离值,实际使用时可能非常耗时。 k-近邻算法的另一个缺陷是它无法给出任何数据的基础结构信息,因此我们也无法知晓平均实例样本和典型实例样本具有什么特征。下一章我们将使用概率测量方法处理分类问题,该算法可以解决这个问题。
最后:关于数据集的问题。如果有需要本书所有数据集的伙伴,可以私聊我提供。
参考书:《机器学习实战》