一篇文彻底理解KNN算法 - 我点了一包华子,终于明白了海伦是个好女孩
大家好,我是W
这次我们要手撕KNN,同时自己实现KNN。当然KNN的思想很简单,所以重点会放在实现自己的KNN上。K-近邻(KNN,K-NearestNeighbor)算法是一种基本分类与回归算法,这一篇文中我们用来实现分类。接下来的内容顺序是:KNN算法的原理、案例1-海伦约会、案例2-手写数字识别。
KNN算法的原理
假设在你面前摆设有一堆瓜子,你只能通过观察其外表来分别瓜子的好坏,那么你能够获得的观察维度只能包括两种特征:瓜子壳的破损情况、瓜子的香味。因为瓜子最初并没有标签,所以你并不知道瓜子好坏的标准,所以你被允许记录并试吃一百颗瓜子。所以你在试吃的过程中得到了100颗瓜子的信息,接下来你需要根据学习到的信息来对剩余的瓜子进行分类,所以你选择的策略是**“在坐标轴中标出试吃过的瓜子的坐标,并且对每一颗需要判断的瓜子也标注在坐标轴中,最终通过与待判断瓜子最近的K个已知瓜子的好坏程度来判断其好坏”**。
所以,KNN的核心就是一句话“近朱者赤”。
KNN的开发流程
步骤 | 方法 |
---|---|
收集数据 | 任何方法 |
准备数据 | 得到计算所需的格式 |
分析数据 | 任何方法 |
测试算法 | 计算准确率等指标 |
使用算法 | 输入样本数据和结构化的输出结果,运行knn判断类别 |
KNN的特点
优点 | 缺点 |
---|---|
精度高 | 计算复杂度高 |
对异常值不敏感、无数据输入假定 | 空间复杂度高 |
适用数据范围: 数值型和标称型
案例1 - 海伦约会
案例背景
海伦在相亲网站找约会对象,经过一段时间之后,她发现曾交往过三种类型的人:
- 不喜欢的人
- 魅力一般的人
- 极具魅力的人
并且她希望:
- 工作日跟魅力一般的人约会
- 周末与极具魅力的人约会
- 排除不喜欢的人
现在,她收集到了一些约会网站未曾记录的数据信息,这更有助于匹配对象的归类。
开发流程
- 观察数据集
- 解析数据集
- 分析数据集
- 测试算法
1’ 观察数据集
点击这里下载数据集,海伦的数据集中共有三个特征,分别是:
- 每年飞行公里数
- 玩videoGame的时间占比
- 每周消耗雪糕公升数
可以看到数据集并没有表头,但是还是可以清楚分辨出对应数据,并且第四列很明显表示的是海伦对该样本的喜爱程度。数据集共有1000个样本,不是很大。
2’ 解析数据集
这一步我们使用pandas库来对数据进行解析。
import pandas as pd
def read_data(file_path):
"""
读取数据集
:param file_path: 文件路径
:return: dating_data
"""
dating_data = pd.read_csv(file_path, delimiter="\t", names=["飞行公里数", "游戏时间比例", "雪糕消耗", "喜爱程度"])
data = dating_data.iloc[:, :3]
target = dating_data.iloc[:, 3]
return data, target
3’ 分析数据集
在分析数据集这一步,我们使用matplotlib的pyplot从几个角度分别画出其二维散点图。
import matplotlib.pyplot as plt
def draw_raw_data_01(data):
"""
画出海伦约会数据集中游戏时间与飞行时间分布散点图
:param data: [1000,4] 矩阵
:return: None
"""
plt.rcParams['font.sans-serif'] = ['SimHei'] # 显示中文
plt.rcParams['axes.unicode_minus'] = False # 显示负号
plt.figure(figsize=(20, 9), dpi=100)
plt.xlabel("飞行公里数")
plt.ylabel("游戏时间占比")
plt.title("海伦约会数据集飞行公里数与游戏时间占比喜爱程度分布图")
g1 = plt.scatter(x=data[data["喜爱程度"] == "largeDoses"]["飞行公里数"], y=data[data["喜爱程度"] == "largeDoses"]["游戏时间比例"],
c="red")
g2 = plt.scatter(x=data[data["喜爱程度"] == "smallDoses"]["飞行公里数"], y=data[data["喜爱程度"] == "smallDoses"]["游戏时间比例"],
c="blue")
g3 = plt.scatter(x=data[data["喜爱程度"] == "didntLike"]["飞行公里数"], y=data[data["喜爱程度"] == "didntLike"]["游戏时间比例"],
c="black")
plt.legend(handles=[g1, g2, g3], labels=["largeDoses", "smallDoses", "didntLike"], prop={'size': 16})
plt.savefig("./dataset/dating_pic/飞行时间与游戏占比分布图.png")
def draw_raw_data_02(data):
"""
画出海伦约会数据集中飞行公里数与雪糕消耗分布散点图
:param data: [1000,4] 矩阵
:return: None
"""
plt.rcParams['font.sans-serif'] = ['SimHei'] # 显示中文
plt.rcParams['axes.unicode_minus'] = False # 显示负号
plt.figure(figsize=(20, 9), dpi=100)
plt.xlabel("飞行公里数")
plt.ylabel("雪糕消耗")
plt.title("海伦约会数据集飞行公里数与雪糕消耗占比喜爱程度分布图")
g1 = plt.scatter(x=data[data["喜爱程度"] == "largeDoses"]["飞行公里数"], y=data[data["喜爱程度"] == "largeDoses"]["雪糕消耗"],
c="red")
g2 = plt.scatter(x=data[data["喜爱程度"] == "smallDoses"]["飞行公里数"], y=data[data["喜爱程度"] == "smallDoses"]["雪糕消耗"],
c="blue")
g3 = plt.scatter(x=data[data["喜爱程度"] == "didntLike"]["飞行公里数"], y=data[data["喜爱程度"] == "didntLike"]["雪糕消耗"],
c="black")
plt.legend(handles=[g1, g2, g3], labels=["largeDoses", "smallDoses", "didntLike"], prop={'size': 16})
plt.savefig("./dataset/dating_pic/飞行时间与雪糕消耗分布图.png")
def draw_raw_data_03(data):
"""
画出海伦约会数据集中游戏时间比例与雪糕消耗的喜爱成图散点图
:param data: [1000,4] 矩阵
:return: None
"""
plt.rcParams['font.sans-serif'] = ['SimHei'] # 显示中文
plt.rcParams['axes.unicode_minus'] = False # 显示负号
plt.figure(figsize=(20, 9), dpi=100)
plt.xlabel("游戏时间比例")
plt.ylabel("雪糕消耗")
plt.title("海伦约会数据集游戏时间比例与雪糕消耗占比喜爱程度分布图")
g1 = plt.scatter(x=data[data["喜爱程度"] == "largeDoses"]["游戏时间比例"], y=data[data["喜爱程度"] == "largeDoses"]["雪糕消耗"],
c="red")
g2 = plt.scatter(x=data[data["喜爱程度"] == "smallDoses"]["游戏时间比例"], y=data[data["喜爱程度"] == "smallDoses"]["雪糕消耗"],
c="blue")
g3 = plt.scatter(x=data[data["喜爱程度"] == "didntLike"]["游戏时间比例"], y=data[data["喜爱程度"] == "didntLike"]["雪糕消耗"],
c="black")
plt.legend(handles=[g1, g2, g3], labels=["largeDoses", "smallDoses", "didntLike"], prop={'size': 16})
plt.savefig("./dataset/dating_pic/游戏时间比例与雪糕消耗分布图.png")
因为总共就3个特征,所以只需要画出3张2维图片就可以了,当然如果会花3-D的会更好,可惜我不会=。=
最终得到的图片分别是:
在上图可以看到,蓝、红、黑三种点可以说是区分的很明显的,对于飞行公里数小于20000的男士,海伦的态度是smallDoses,即不是热爱但也不讨厌。对于飞行公里数在20000~50000的男士,海伦大多数的态度是热爱的。而对于飞行公里数大于50000的男士,海伦显然是didn’tLike的。
飞行公里数在实际场景中可能代表着男人的事业,所以说,海伦对于男性的要求是希望对方有自己的事业(红区),但是又能够有时间陪陪自己(蓝区),而对于只顾着工作的工作狂,海伦是没有好感的(黑区)。
在这张图可以看到,横轴依然是飞行公里数,纵轴则变成了游戏时间占比。与上一张图一样,我们可以在2万公里和5万公里处画一条线。那么上图就会分为左中右三个区域,对于左边区域可以看到大部分的态度是smallDoses,而对于中间区域又可以分为上下两部分,上部分是largeDoses,下部分是didn’tLike,而右边区域则基本上都是didn’tLike。
这就很有意思了,在实际场景中,游戏时间可能代表着男生的幽默程度。所以说,海伦可以接受事业不繁忙,同时也没那么有趣的人(可能这就是女生的安全感吧,你可以陪着我,哪怕不是那么优秀=。=)。海伦热爱的是有自己的事业,同时相当有趣的男生(这是谁都会热爱吧,又有事业又幽默),海伦讨厌的是一心只顾着事业,没有幽默感或者虽然有幽默感但是相当繁忙的男生。
所以,可以看出海伦更希望男朋友能够多陪陪自己,给自己带来快乐,有自己的事业是好事,但是没有也没关系啊!
这张图可以看到三色点散乱分布在坐标轴上,并没有像上两张图那么清晰的划分,这就表明雪糕消耗对于海伦的喜好并没有良好的区分。但是可以看到:
- 黑色均匀分布在各个角落
- 蓝色和红色依然有一条较为清晰的分界线,即游戏占比为7%左右
首先,雪糕消耗在实际场景中可以理解为男生的身材,虽然我们没有把数据拉到3维来看,但是依然可以想象到,在上两张图中海伦厌恶的往往是过于繁忙和无趣的人,并且在这张图中并没有因雪糕消耗而产生的明显分界,可以看出海伦并不是那么看脸的人!
综上,海伦希望寻找的是有自己的事业、幽默风趣的男生,她对自己的另一半身材相貌并没有明显的要求。她希望另一半能够多陪陪自己(哪怕对方没那么有趣),而不是每天顾着工作不着家(哪怕对方很幽默)。当我从新看到这个数据我深深的陷入了沉思,抽了一包中华我才逐渐醒悟过来,原来海伦是个好女孩,她并不虚荣,并不看颜,她需要的是真真实实的,能够跟她脚踏实地地过日子的男孩,陪伴对她来说胜过一切。
4’ 测试算法
归一化
因为数据量纲的不同可以考虑归一化,但是对于KNN分类来说这个并不是问题。但是归一化的确可以带来一些好处:
- 可以把数据限制在一定的范围内
- 方便后期数据的处理
- 保证程序运行时收敛加快
归一化的方法有很多,我们采用最常见的0-1归一化:
import numpy
def to_normalize(data):
"""
给数据做(0,1)归一化
:param data: [1000,4]
:return: data_normed
"""
# minVal_0 = min(data["飞行公里数"])
# maxVal_0 = max(data["飞行公里数"])
# print(minVal_0, maxVal_0)
# minVal_1 = min(data["游戏时间比例"])
# maxVal_1 = max(data["游戏时间比例"])
# print(minVal_1, maxVal_1)
# minVal_2 = min(data["雪糕消耗"])
# maxVal_2 = max(data["雪糕消耗"])
# print(minVal_2, maxVal_2)
# 取得data里的每个特征的最小值、最大值
minVal = data.min(0)
maxVal = data.max(0)
# 各个特征的极差
ranges = maxVal - minVal
# print(ranges)
# 使用numpy生成新矩阵
new_matrix = numpy.zeros(shape=numpy.shape(data)) # [1000, 3]
# 做(0,1)归一化
for i in range(numpy.shape(data)[0]):
new_matrix[i][0] = (data["飞行公里数"][i] - minVal[0]) / ranges[0]
new_matrix[i][1] = (data["游戏时间比例"][i] - minVal[1]) / ranges[1]
new_matrix[i][2] = (data["雪糕消耗"][i] - minVal[2]) / ranges[2]
return new_matrix
knn测试
import operator
def my_knn(data_normed, sample, labels, k=3):
"""
实现KNN算法
:param data_normed: 归一化后的样本集
:param sample: 需要predict的样本
:param k: 最近的k个人
:return: final_label
"""
# 通过sample数组构建[1000,3]矩阵,然后实现矩阵相减得到new_data_normed
new_data_normed = tile(sample, (data_normed.shape[0], 1)) - data_normed
print(tile(sample, (data_normed.shape[0], 1)))
# 计算欧氏距离
double_matrix = new_data_normed ** 2
double_distance = double_matrix.sum(axis=1)
sqrt_distance = double_distance ** 0.5
new_matrix = pd.DataFrame()
new_matrix["distance"] = sqrt_distance
new_matrix["label"] = labels
# 排序
new_matrix = new_matrix.sort_values(by=["distance"], ascending=True)
# 取前k个
final_matrix = new_matrix.iloc[:k, :]
label_dict = {"didntLike": 0, "smallDoses": 0, "largeDoses": 0}
for i in range(k):
label_dict[final_matrix.iloc[i]["label"]] += 1
print(label_dict)
sorted_label = sorted(label_dict.items(), key=operator.itemgetter(1), reverse=True)
return sorted_label[0][0]
在这一个环节涉及一些numpy库中的矩阵运算,大家若是由看不懂的地方我推荐可以打印出来看一看数据类型和实际数据,并不难理解。
主调函数
if __name__ == '__main__':
file_path = "./dataset/datingTestSet.txt"
data, target = read_data(file_path)
# print(data)
# draw_raw_data_01(data)
# draw_raw_data_02(data)
# draw_raw_data_03(data)
data_normed = to_normalize(data)
print(data_normed)
# 由于懒得对测试数据做归一化所以直接写入
sample = [0.29115949, 0.50910294, 0.51079493]
label = my_knn(data_normed, sample, target, k=3)
print("KNN结果是:", label)
案例2-手写数字识别
案例背景
手写数字识别也是tensorflow的一个经典案例,但是这个案例一样可以使用KNN算法来做。
开发流程
- 观察数据集
- 解析数据集
- 测试算法
1’ 观察数据集
点击这里可以下载数据集,提取码:xrm6。打开数据集可以看到两个文件夹,一个是trainingDigits一个是testDigits,里面的文件都是txt格式的,并且文件命名规则都是标签_编号.txt格式,所以可以预测到我们需要对文件名做处理得到标签。
点开txt文件,可以看到是一个32*32的[0,1]矩阵,1的位置表示手写数字的位置。
2’ 分析数据集
数据集标签需要通过python的os模块和字符串split拿到,我们需要做成一个np.array类型,也可以考虑与数据部分合并成一个DataFrame。
因为本次使用的是KNN,并且矩阵是[0,1]矩阵,显然不需要做归一化,我们需要做的就是把矩阵从[32,32]转变为[1,1024]形状。
3’ 测试算法
这里直接把整个代码贴上来了,但是不建议大家去读。我建议大家可以自己想想如何去做,这里的难点主要是数据读取和处理的过程,KNN的过程其实和海伦约会一样。
import os
import operator
import pandas as pd
import numpy as np
def read_data(file_path):
"""
读取指定目录下的所有txt文件
并且[32,32] -> [1024,1] 的data转换
并且获取每一个文件的target 做成一个向量
:param file_path: 需要读取的文件夹路径
:return: DataFrame[data,target]
"""
dir_list = os.listdir(file_path)
data = pd.DataFrame(columns=("data", "target"))
for file_name in dir_list:
# 获得目标值
file_target = file_name.split(sep="_")[0]
# 读取txt文件,转为[1024,1]
data_32 = pd.read_table(filepath_or_buffer=file_path + file_name, header=None)
# df格式转为np.array
data_32 = np.array(data_32)
new_str = ""
for i in data_32:
new_str += i[0]
# 向dataframe中加一行
data = data.append({"data": new_str, "target": file_target}, ignore_index=True)
return data
def df_to_np(data):
"""
将df对象矩阵转为np.array矩阵
:param data: df类型 ["data","target"]
:return: np.array
"""
data = data["data"]
# 需要先做一个0向量才能生成
np_matrix = np.array(np.zeros(shape=(1, 1024)))
# 先构造一个np矩阵 因为最小0 最大1 所以不需要归一化了
for item in data:
item_list = []
for index in item:
item_list.append(int(index))
item_nparray = np.array(item_list)
np_matrix = np.row_stack((np_matrix, item_nparray))
np_matrix = np_matrix[1:]
# 把第一行全0除去
return np_matrix
def my_knn(line, train_matrix, train_label, k):
np_matrix = np.tile(line, (train_matrix.shape[0], 1)) - train_matrix
# 计算欧式距离
double_matrix = np_matrix ** 2
double_distance = double_matrix.sum(axis=1)
distance = double_distance ** 0.5
new_matrix = pd.DataFrame()
new_matrix["distance"] = distance
new_matrix["label"] = train_label
# 排序
new_matrix = new_matrix.sort_values(by=["distance"], ascending=True)
# 取前k个
final_matrix = new_matrix.iloc[:k, :]
label_dict = {}
for i in range(10):
label_dict[str(i)] = 0
for i in range(k):
label_dict[final_matrix.iloc[i]["label"]] += 1
sorted_label = sorted(label_dict.items(), key=operator.itemgetter(1), reverse=True)
print(sorted_label[0][0])
return sorted_label[0][0]
def cal_acc_recall(train_data, test_data, k=3):
"""
通过train_data来实现knn,然后遍历test_data来算acc recall
:param train_data: 训练集
:param test_data: 测试集
:param k: k个最临近值
:return: None
"""
train_matrix = df_to_np(train_data)
train_label = train_data["target"]
test_matrix = df_to_np(test_data)
test_label = test_data["target"]
print(test_label[0])
# 计数器
iter = 0
# acc_num
acc_num = 0
for line in test_matrix:
predict_label = my_knn(line, train_matrix, train_label, k)
acc_num = acc_num + 1 if predict_label == test_label[iter] else acc_num
iter += 1
# 准确率
print("准确率是%f" % (acc_num / (iter + 1)))
# 召回率 懒得做了
return None
if __name__ == '__main__':
# 1' 读取数据集、返回data,target
trainingDigits_path = "./dataset/knn-handwriting_num/trainingDigits/"
testDigits_path = "./dataset/knn-handwriting_num/testDigits/"
train_data = read_data(trainingDigits_path)
# 2' 读取测试数据集
test_data = read_data(testDigits_path)
# 3' 使用KNN对test_data一一测试
cal_acc_recall(train_data, test_data, 3)