徒手实现代码的过程,真是含泪和心酸,浪费了生命中的三天,以及工作中的划水一小时
终于滤清思路后,自己实现了KNN
都说KNN是最基础,最简单的分类器
放屁!骗纸!!!它的想法是简单的,但实现的过程何其复杂!!!
问题何其之多!!是我实现感知机、逻辑回归分类、线性回归、朴素贝叶斯中,最难实现的分类算法!!!!
给多少时间都无法将这个槽吐尽,甚至算法都没完全弄好,但只剩收尾工作了
1. KNN 分类思想
首先,KNN思想很简单,找到离目标对象最近的K个对象,以K个对象中数量最多的那个类型为预测结果。
就好像:要对一个有喉结、短头发、平胸的对象进行性别的分类,那么特征最相近的K个人,如果男性居多,则将该对象分类为男性。如果女性居多,则分类为女性。
KNN搜索特征最相近的精髓,在于建立KD树——二叉树
2. KNN 分类流程
2.1 构建KD树
构建KD树的原因是,我们不能逐一去计算某个对象与所有对象的特征距离,因为数据太多,工作量太大。
那就对所有对象的特征,进行KD树的分组,分组规则别人说的非常清晰明了,我看了之后有种恍然大悟、拍手叫好的激动
kd树的搜索过程到底是怎么进行的? - 月来客栈的文章 - 知乎
但是当我自己去实现时,遇到重重的困难
先来实现 KD树:应用二叉树的原理
# 构建一个二叉树节点
class Node_1():
def __init__(self,X_value):
self.left = None
self.right = None
self.value = []
self.X = X_value # {"特证名":"x","划分标准":"aaaaaa"}
# 根据节点,构建一个二叉树
class Tree():
def __init__(self):
self.root = None
def get_value_1(self,X_arg,node_arg=None):
node = node_arg
if self.root == None:
node = Node_1(X_arg)
self.root = node
# 获取所有特征各自对应的方差
vars = [X_arg[i].var() for i in X_arg.columns[0:2]]
# 如果这些方差中,最大的那个方差为0或是无法计算,则说明数据已经分的很清楚了
"""重点:停止迭代的条件:所有特征的方差均为0 或无法计算"""
if max(vars) == 0 or math.isnan(max(vars)):
return
"""以方差最大的那个特征,作为分组的维度,根据该特征的中位数进行分组:
pandas中的median中位数,与统计学稍微不同
统计学中偶数个数据的中位数,是中间两个数据的均值
pandas中偶数个数据的中位数,是中间两个数据的其中一个
因此,用pandas的median计算中位数,还需要考虑一个非常坑的问题:如果最后只剩两个数据,中位数到底是偏大的那个,还是偏小的那个,这决定了我们分组到底能不能分清楚!!!"""
X_name = X_arg.columns[vars.index(max(vars))]
med = X_arg[X_name].median()
if med == X_arg[X_name].max():
"""要根据中位数,分为左组、右组:涉及到pandas的分组条件筛选【抄网上】"""
left = X_arg.groupby(X_name).apply(lambda x: x.loc[x.loc[x[X_name] < med].index]).reset_index(drop=True)
right = X_arg.groupby(X_name).apply(lambda x: x.loc[x.loc[x[X_name] >= med].index]).reset_index(drop=True)
else:
left = X_arg.groupby(X_name).apply(lambda x: x.loc[x.loc[x[X_name] <= med].index]).reset_index(drop=True)
right = X_arg.groupby(X_name).apply(lambda x: x.loc[x.loc[x[X_name] > med].index]).reset_index(drop=True)
"""如果左右组有一个为空,说明这个节点已经分完了"""
if left.empty or right.empty:
return
# print(f"当前节点以【{X_name}】为分组特征,中位数为{med},【{X_name}】方差为{X_arg[X_name].std()}")
# print(X_arg)
node.value=[X_name,med]
# print(f"【————————左子树(left)————————】")
# print(left)
node_left = Node_1(left)
node.left = node_left
self.get_value_1(left, node_left)
# print(f"【————————右子树(right)————————】")
# print(right)
node_right = Node_1(right)
node.right = node_right
self.get_value_1(right, node_right)
分的过程,很漂亮,我看了很多次。。。肉眼数它是怎么分的!!!瞎了
最终分组后的数据,都分在叶子节点上【全挂在树梢,中间节点不挂】
2.2 搜索最近邻&K近邻
搜索最近邻和K近邻也并不容易,用到了递归
我现在对递归,真的是轻车熟路!!!!
痛苦,总是让人印象深刻
最近邻,主要还是用到了欧式距离的计算
class Find_KD_Tree:
K = []
Y_hats = []
def __init__(self,X_arg):
self.k = 10
self.K_list = []
self.min_point = []
self.X = X_arg
def find_K(self,node_arg, X_arg):
row, columns_1 = node_arg.X.shape
if not node_arg.value:
"""计算叶子与当前数据的欧式距离:叶子上都是一样的数据,选择其中一个数据与当前数据进行计算即可。"""
k_distance = ((node_arg.X.loc[0][0:columns_1 - 1] - X_arg[0:columns_1 - 1]) ** 2).sum()
if (not self.min_point) or (k_distance < self.min_point[0]): # 当没有最小值,或比最小值更小时,更新min_point
self.min_point = [k_distance, node_arg]
if len(self.K_list) < self.k:
for i in range(row):
if len(self.K_list) <= self.k:
self.K_list.append([k_distance, node_arg.X.loc[i]])
self.K_list = sorted(self.K_list, key=lambda cus: cus[0], reverse=False)
elif k_distance < self.K_list[-1][0]:
self.K_list.pop()
self.K_list.append([k_distance, node_arg.X.loc[i]])
self.K_list = sorted(self.K_list, key=lambda cus: cus[0], reverse=False)
else:
break
elif k_distance < self.K_list[-1][0]:
for i in range(row):
self.K_list.pop()
self.K_list.append([k_distance, node_arg.X.loc[i]])
self.K_list = sorted(self.K_list, key=lambda cus: cus[0], reverse=False)
if k_distance > self.K_list[-1][0]:
break
else:
X_name = node_arg.value[0]
X_value = node_arg.value[1]
node_distance = (X_arg[X_name] - X_value) ** 2
"""
1、为对象选择搜索方向:根据每个节点的划分维度,来判断对象是节点的左侧,还是右侧分
2、判断最近邻是否有可能在节点的另外一侧:递归回溯判断每个子节点的维度距离,是否小于最近邻
(若小于最近邻,则最近邻也有可能在节点另一侧,进而去搜索)
3、判断 K近邻是否有可能在节点的另外一侧:递归回溯判断每个子节点的维度距离,是否小于K列表末尾最大的距离
(若小于K列表里的最大距离,则在节点另一侧也有可能存在K近邻数据点,进而去搜索)
"""
if X_arg[X_name] <= X_value:
self.find_K(node_arg.left, X_arg)
if node_distance <= self.min_point[0] or node_distance<self.K_list[-1][0]:
self.find_K(node_arg.right, X_arg)
else:
self.find_K(node_arg.right, X_arg)
if node_distance <= self.min_point[0] or node_distance<self.K_list[-1][0]:
self.find_K(node_arg.left, X_arg)
最终将每一条数据的K近邻,存储在一个二维列表中,输出结果如下
有些K近邻的列表,不足k个(我设为10个)元素,主要还是因为回溯到上一个维度时,维度距离超过了全局最小距离,因此就不再搜索另一边的树了。
搜索最近邻和K近邻的过程,比较简单:(主要是要细讲逻辑,太复杂了。。。放弃细讲。。。)
2.3 预测分类
最终,只需要将上述每个对象的K近邻,通过统计,得出它们各自类型最多的那个分类,即为预测分类的结果。
相比于前边徒手建立二叉树,和徒手进行K近邻、最近邻的回溯搜索来说,这一步真是太过easy,我都已经不稀罕浪费脑细胞来详写了!!!
待回头,收拾旧心情,再来写
来写了
def predict(self):
dict_temp = {}
y_hat_single = [i[1][-1] for i in self.K_list]
Find_KD_Tree.K.append(y_hat_single)
for i in set(y_hat_single):
dict_temp[i] = y_hat_single.count(i)
max_key = max(dict_temp, key=dict_temp.get)
Find_KD_Tree.Y_hats.append(max_key)
print(f"K近邻的分类统计为:{dict_temp},👉👉👉👉预测分类为:【{max_key}】")
3. KNN - 手动、sk代码、GPT代码效果对比
3.1 初版手动代码
""" 难点:构思要如何建立KD树,如何回溯找出最近邻和K近邻"""
import math
import time
import pandas as pd
datas = pd.read_excel('./datas1.xlsx')
important_features = ['推荐类型','推荐分值', '辅导老师专业度','回复速度']
datas_1 = datas[important_features]
# 明确实值Y为'推荐分值',X分别为'专业度','回复速度','用户群活跃天数'
Y = datas_1['推荐类型']
X = datas_1.drop('推荐类型',axis=1)
# X 归一化处理
data = (X-X.min())/(X.max()-X.min())
data["推荐类型"] = Y
# print(data)
"""
构建KNN二叉树
# 构造二叉树的节点、构造二叉树的内容
① 二叉树的节点是node:包含 4 个属性 - 值value、左left、右left、数据组
② 二叉树的内容是三个节点:根节点、左节点、右节点
# 迭代创建二叉树的三个关键:
① 停止迭代的条件:特征方差为0或Nan(无法计算方差)
② 如何分组迭代:选择方差最大的特征,按该特征的中位数,对数据进行分组,再对组调用构造二叉树的方法
"""
# 构建一个二叉树节点
class Node_1():
def __init__(self,X_value):
self.left = None
self.right = None
self.value = []
self.X = X_value # {"特证名":"x","划分标准":"aaaaaa"}
# 根据节点,构建一个二叉树
class Tree():
def __init__(self):
self.root = None
def get_value_1(self,X_arg,node_arg=None):
node = node_arg
if self.root == None:
node = Node_1(X_arg)
self.root = node
vars = [X_arg[i].var() for i in X_arg.columns[0:2]]
if max(vars) == 0 or math.isnan(max(vars)):
return
X_name = X_arg.columns[vars.index(max(vars))]
med = X_arg[X_name].median()
if med == X_arg[X_name].max():
"""要根据中位数,分为左组、右组:涉及到pandas的分组条件筛选"""
left = X_arg.groupby(X_name).apply(lambda x: x.loc[x.loc[x[X_name] < med].index]).reset_index(drop=True)
right = X_arg.groupby(X_name).apply(lambda x: x.loc[x.loc[x[X_name] >= med].index]).reset_index(drop=True)
else:
left = X_arg.groupby(X_name).apply(lambda x: x.loc[x.loc[x[X_name] <= med].index]).reset_index(drop=True)
right = X_arg.groupby(X_name).apply(lambda x: x.loc[x.loc[x[X_name] > med].index]).reset_index(drop=True)
if left.empty or right.empty:
return
node.value=[X_name,med]
node_left = Node_1(left)
node.left = node_left
self.get_value_1(left, node_left)
node_right = Node_1(right)
node.right = node_right
self.get_value_1(right, node_right)
def pre_print(self,root):
if root is None:
return
self.pre_print(root.left)
self.pre_print(root.right)
class Find_KD_Tree:
K = []
Y_hats = []
def __init__(self,X_arg):
self.k = 10
self.K_list = []
self.min_point = []
self.X = X_arg
def find_K(self,node_arg, X_arg):
row, columns_1 = node_arg.X.shape
if not node_arg.value:
"""计算叶子与当前数据的欧式距离:叶子上都是一样的数据,选择其中一个数据与当前数据进行计算即可。"""
k_distance = ((node_arg.X.loc[0][0:columns_1 - 1] - X_arg[0:columns_1 - 1]) ** 2).sum()
if (not self.min_point) or (k_distance < self.min_point[0]): # 当没有最小值,或比最小值更小时,更新min_point
self.min_point = [k_distance, node_arg]
if len(self.K_list) < self.k:
for i in range(row):
if len(self.K_list) <= self.k:
self.K_list.append([k_distance, node_arg.X.loc[i]])
self.K_list = sorted(self.K_list, key=lambda cus: cus[0], reverse=False)
elif k_distance < self.K_list[-1][0]:
self.K_list.pop()
self.K_list.append([k_distance, node_arg.X.loc[i]])
self.K_list = sorted(self.K_list, key=lambda cus: cus[0], reverse=False)
else:
break
elif k_distance < self.K_list[-1][0]:
for i in range(row):
self.K_list.pop()
self.K_list.append([k_distance, node_arg.X.loc[i]])
self.K_list = sorted(self.K_list, key=lambda cus: cus[0], reverse=False)
if k_distance > self.K_list[-1][0]:
break
else:
X_name = node_arg.value[0]
X_value = node_arg.value[1]
node_distance = (X_arg[X_name] - X_value) ** 2
"""
1、为对象选择搜索方向:根据每个节点的划分维度,来判断对象是节点的左侧,还是右侧分
2、判断最近邻是否有可能在节点的另外一侧:递归回溯判断每个子节点的维度距离,是否小于最近邻
(若小于最近邻,则最近邻也有可能在节点另一侧,进而去搜索)
3、判断 K近邻是否有可能在节点的另外一侧:递归回溯判断每个子节点的维度距离,是否小于K列表末尾最大的距离
(若小于K列表里的最大距离,则在节点另一侧也有可能存在K近邻数据点,进而去搜索)
"""
if X_arg[X_name] <= X_value:
self.find_K(node_arg.left, X_arg)
if node_distance <= self.min_point[0] or node_distance<self.K_list[-1][0]:
self.find_K(node_arg.right, X_arg)
else:
self.find_K(node_arg.right, X_arg)
if node_distance <= self.min_point[0] or node_distance<self.K_list[-1][0]:
self.find_K(node_arg.left, X_arg)
def predict(self):
dict_temp = {}
y_hat_single = [i[1][-1] for i in self.K_list]
Find_KD_Tree.K.append(y_hat_single)
for i in set(y_hat_single):
dict_temp[i] = y_hat_single.count(i)
max_key = max(dict_temp, key=dict_temp.get)
Find_KD_Tree.Y_hats.append(max_key)
print(f"K近邻的分类统计为:{dict_temp},👉👉👉👉预测分类为:【{max_key}】")
a = Tree()
a.get_value_1(data)
def func(datas_arg):
ob = Find_KD_Tree(datas_arg)
ob.find_K(a.root,datas_arg)
ob.predict()
data.apply(func,axis = 1)
compares = sum([Y[i]==Find_KD_Tree.Y_hats[i] for i in range(Y.shape[0])])
print(f"分类准确率为:{round(compares/Y.shape[0]*100,2)}%")
3.2 sklearn代码
from sklearn import neighbors
import time
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.metrics import classification_report
# 获取所需数据:'推荐分值', '专业度','回复速度','用户群活跃天数'
datas = pd.read_excel('./datas1.xlsx')
important_features = ['推荐类型','推荐分值', '专业度','回复速度','用户群活跃天数']
datas_1 = datas[important_features]
# 明确实值Y为'推荐分值',X分别为'专业度','回复速度','用户群活跃天数'
Y = datas_1['推荐类型']
X = datas_1.drop('推荐类型',axis=1)
start_time = time.time()
# 1. 建立模型
classifier = neighbors.KNeighborsClassifier(10)
classifier.fit(X,Y)
# 2. 学习模型
classifier.fit(X,Y)
Y_predict = classifier.predict(X)
result_P = classifier.predict_proba(X)
end_time = time.time()
# 3. 衡量模型
accurency = classifier.score(X,Y)
PRF = classification_report(Y,Y_predict)
# 输出模型最优状态下的参数及衡量模型的指标
print(f"KNN 耗时{end_time-start_time}秒")
print("KNN的分类【准备率】为:",accurency)
print("KNN的精确率、召回率、F1分数为:")
print(PRF)
print('【模型分类,实际分类】的对比如下:')
for index,value in enumerate(zip(Y_predict,Y)):
print(value)
print(result_P[index])
# print(result_P)
3.3 分类准确率对比
sklearn的准确率和手动代码的准确率对比,差别不大
为什么会有差别呢??
这是一个值得细思,反思的问题
3.4 手动代码KNN的实现与书中的思路稍有不同
首先,手动代码中的分类规则,我自作聪明地进行了修改。
修改点1:分组的判断方式不同
修改点2:确定分组维度的方式不同
逐一反思
修改点1:分组的判断方式不同
书中的分组判断方式,是根据中位数的位置,分为数据量相等的左、右两组。
而我不是
中位数的计算,我不是根据数据量的中间值来计算获取的,而是根据pandas的median函数,偷懒让它计算的!!!
为什么我要偷懒呢?主要还是为了避免每次分组,都要对数据重新根据维度来排序。
原想着能够减少每次分组前的排序过程,降低运算量。
但这也带来了一个问题,那就是我的分组,不是分成数据量相等的左、右两组了。
因为median函数获取到中位数时,我没有排序,而是与median中位数值比较大小,分为左、右两组
- 比median大的,分在右组
- 比median小的,分在左组
听起来也很合理对吧,只需要比较,还免去了排序这一步骤
但也带来了一个问题,那就是与median中位数的比较分组,无法分为数据量相等的左、右两组
因此,不知道是不是我的自作聪明,导致分类效果有细微的差别
修改点2:确定分组维度的方式不同
书中的分组维度,是按顺序更换的维度
而我,是根据方差大小来选择的分组维度,如果维度的方差较大,说明数据比较分散,因此可以先对这个维度进行分组。
目前修改下来,分类效果虽然略低于SKlearn的分类效果,但实际还是相差不大的。
想看看如果在没有【推荐分值】这个因素的影响下,对比一下分类效果
那还是手动的分类效果相对好一些的。
因此,总结来看:手动KNN的思路是没问题的。
3.5 ChatGPT的代码对比
等自己折磨完自己的脑子后,才想起GPT也是大有可为的!!!!
GPT给出的算法思路,基本是与书上相似的
且在搜索K近邻和最近邻时,是分开的
我直接合在一起了,其实还是分开会比较清晰