一、 k-近邻法简介
k近邻法(k-nearest neighbor, k-NN)是1967年由Cover T和Hart P提出的一种基本分类与回归方法。它的工作原理是:存在一个样本数据集合,也称作为训练样本集,并且样本集中每个数据都存在标签,即我们知道样本集中每一个数据与所属分类的对应关系。输入没有标签的新数据后,将新的数据的每个特征与样本集中数据对应的特征进行比较,然后算法提取样本最相似数据(最近邻)的分类标签。一般来说,我们只选择样本数据集中前k个最相似的数据,这就是k-近邻算法中k的出处,通常k是不大于20的整数。最后,选择k个最相似数据中出现次数最多的分类,作为新数据的分类。
举个简单的例子,我们可以使用k-近邻算法分类一个电影是爱情片还是动作片。
表1.1 每部电影的打斗镜头数、接吻镜头数以及电影类型
电影名称 | 打斗镜头 | 接吻镜头 | 电影类型 |
---|---|---|---|
电影1 | 1 | 101 | 爱情片 |
电影2 | 5 | 89 | 爱情片 |
电影3 | 108 | 5 | 动作片 |
电影4 | 115 | 8 | 动作片 |
\quad 表1.1就是我们已有的数据集合,也就是训练样本的集合。这个数据集有两个特征,即打斗镜头数和接吻镜头数。除此之外,我们也知道每个电影的所属类型,即分类标签。不管是从数据集来说,还是从实际情况来说,接吻镜头多的是爱情片,打斗镜头多的是动作片。就从多年的观影经验来说,这个分类还算合理。
\quad 如果现在给我一部电影,你告诉我这个电影打斗镜头数和接吻镜头数,不告诉我这个电影类型,我可以根据你给我的信息进行判断,这个电影是属于爱情片还是动作片。而k-近邻算法也可以像我们人一样做到这一点,不同的地方在于,我们的经验来自于日常生活中点点滴滴的积累,而k-邻近算法是靠已有的数据。比如,你告诉我这个电影打斗镜头数为2,接吻镜头数为102,我的经验会告诉你这个是爱情片,k-近邻算法也会告诉你这个是爱情片。你又告诉我另一个电影打斗镜头数为49,接吻镜头数为51,我"邪恶"的经验可能会告诉你,这有可能是个"爱情动作片",画面太美,我不敢想象。 (如果说,你不知道"爱情动作片"是什么?请评论留言与我联系,我需要你这样像我一样纯洁的朋友。) 但是k-近邻算法不会告诉你这些,因为在它的眼里,电影类型只有爱情片和动作片,它会提取样本集中特征最相似的数据(最邻近)的分类标签,得到的结果可能是爱情片,也可能是动作片,但绝不会是"爱情动作片"。当然,这些取决于数据集的大小以及最近邻的判断标准等因素。
1.2 距离度量
\quad
我们已经知道k-近邻算法根据特征比较,然后提取样本集中特征最相似数据(最邻近)的分类标签。那么,如何进行比较呢?比如,我们还是以表1.1为例,怎么判断红色圆点标记的电影所属的类别呢?如图1.1所示。
\quad
我们可以从散点图大致推断,这个红色圆点标记的电影可能属于动作片,因为距离已知的那两个动作片的圆点更近。k-近邻算法用什么方法进行判断呢?没错,就是距离度量。这个电影分类的例子有2个特征,也就是在2维实数向量空间,可以使用我们高中学过的两点距离公式计算距离,如图1.2所示。
∣
A
B
∣
=
(
x
1
−
x
2
)
2
+
(
y
1
−
y
2
)
2
|AB| = \sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2}
∣AB∣=(x1−x2)2+(y1−y2)2
通过计算,我们可以得到如下结果:
- (101,20)->动作片(108,5)的距离约为16.55
- (101,20)->动作片(115,8)的距离约为18.44
- (101,20)->爱情片(5,89)的距离约为118.22
- (101,20)->爱情片(1,101)的距离约为128.69
\quad 通过计算可知,红色圆点标记的电影到动作片 (108,5)的距离最近,为16.55。如果算法直接根据这个结果,判断该红色圆点标记的电影为动作片,这个算法就是最近邻算法,而非k-近邻算法。那么k-近邻算法是什么呢?k-近邻算法步骤如下:
- 计算已知类别数据集中的点与当前点之间的距离;
- 按照距离递增次序排序;
- 选取与当前点距离最小的k个点;
- 确定前k个点所在类别的出现频率;
- 返回前k个点所出现频率最高的类别作为当前点的预测分类。
\quad 比如,现在我这个k值取3,那么在电影例子中,按距离依次排序的三个点分别是动作片(108,5)、动作片(115,8)、爱情片(5,89)。在这三个点中,动作片出现的频率为三分之二,爱情片出现的频率为三分之一,所以该红色圆点标记的电影为动作片。这个判别过程就是k-近邻算法。
二、k-近邻算法实战之约会网站配对效果判定
2.1 k-近邻算法的一般流程:
- 收集数据:可以使用爬虫进行数据的收集,也可以使用第三方提供的免费或收费的数据。一般来讲,数据放在txt文本文件中,按照一定的格式进行存储,便于解析及处理。
- 准备数据:使用Python解析、预处理数据。
- 分析数据:可以使用很多方法对数据进行分析,例如使用Matplotlib将数据可视化。
- 测试算法:计算错误率。
- 使用算法:错误率在可接受范围内,就可以运行k-近邻算法进行分类。
2.2 实战背景
\quad 海伦女士一直使用在线约会网站寻找适合自己的约会对象。尽管约会网站会推荐不同的任选,但她并不是喜欢每一个人。经过一番总结,她发现自己交往过的人可以进行如下分类:
- 不喜欢的人
- 魅力一般的人
- 极具魅力的人
\quad 海伦收集约会数据已经有了一段时间,她把这些数据存放在文本文件datingTestSet.txt中,每个样本数据占据一行,总共有1000行。datingTestSet.txt数据集会放在后文的程序压缩包中
\quad 海伦收集的样本数据主要包含以下3种特征:
- 每年获得的飞行常客里程数
- 玩视频游戏所消耗时间百分比
- 每周消费的冰淇淋公升数
2.3 准备数据:数据解析
\quad 在将上述特征数据输入到分类器前,必须将待处理的数据的格式改变为分类器可以接收的格式。分类器接收的数据是什么格式的?我们要将数据分类两部分,即特征矩阵和对应的分类标签向量,编写代码如下:
def load_dataset(file_name):
with open(file_name, "r") as f:
data_matrix = []
labels = []
matrix_str = f.readlines()
for information in matrix_str:
information = information[:-1]
sample = []
attribute_list = information.split("\t")
for num in attribute_list[:-1]:
sample.append(float(num))
data_matrix.append(sample)
labels.append(attribute_list[-1])
return data_matrix, labels
2.4 分析数据:数据可视化
\quad 在dataShow.py文件用来将数据可视化。编写代码如下:
# 登陆数据集
data_matrix, labels = ut.load_dataset("datingTestSet.txt")
data_matrix = np.array(data_matrix)
# 设置汉字格式
font = FontProperties(fname=r"c:\windows\fonts\simsun.ttc", size=14)
# 将fig画布分隔成1行1列,不共享x轴和y轴,fig画布的大小为(7,12)
# 当nrow=0,nclos=3时,代表fig画布被分为三个区域,axs[0][0]表示第一行第一个区域
fig, axs = plt.subplots(nrows=1, ncols=3, figsize=(12, 7))
labels_colors = []
for label in labels:
if label == "largeDoses":
labels_colors.append("orange")
elif label == "smallDoses":
labels_colors.append("blue")
elif label == "didntLike":
labels_colors.append("black")
# 画出散点图,以data_matrix矩阵的第一(飞行常客例程)、第二列(玩游戏)数据画散点数据,散点大小为15,透明度为0.5
axs[0].scatter(x=data_matrix[:, 0], y=data_matrix[:, 1], color=labels_colors, s=15, alpha=.5)
# 设置标题,x轴label,y轴label
axs0_title_text = axs[0].set_title(u'每年获得的飞行常客里程数与玩视频游戏所消耗时间占比', FontProperties=font)
axs0_xlabel_text = axs[0].set_xlabel(u'每年获得的飞行常客里程数', FontProperties=font)
axs0_ylabel_text = axs[0].set_ylabel(u'玩视频游戏所消耗时间占', FontProperties=font)
plt.setp(axs0_title_text, size=9, weight='bold', color='red')
plt.setp(axs0_xlabel_text, size=7, weight='bold', color='black')
plt.setp(axs0_ylabel_text, size=7, weight='bold', color='black')
# 画出散点图,以datingDataMat矩阵的第一(飞行常客例程)、第三列(冰激凌)数据画散点数据,散点大小为15,透明度为0.5
axs[1].scatter(x=data_matrix[:, 0], y=data_matrix[:, 2], color=labels_colors, s=15, alpha=.5)
# 设置标题,x轴label,y轴label
axs1_title_text = axs[1].set_title(u'每年获得的飞行常客里程数与每周消费的冰激淋公升数', FontProperties=font)
axs1_xlabel_text = axs[1].set_xlabel(u'每年获得的飞行常客里程数', FontProperties=font)
axs1_ylabel_text = axs[1].set_ylabel(u'每周消费的冰激淋公升数', FontProperties=font)
plt.setp(axs1_title_text, size=9, weight='bold', color='red')
plt.setp(axs1_xlabel_text, size=7, weight='bold', color='black')
plt.setp(axs1_ylabel_text, size=7, weight='bold', color='black')
# 画出散点图,以datingDataMat矩阵的第二(玩游戏)、第三列(冰激凌)数据画散点数据,散点大小为15,透明度为0.5
axs[2].scatter(x=data_matrix[:, 1], y=data_matrix[:, 2], color=labels_colors, s=15, alpha=.5)
# 设置标题,x轴label,y轴label
axs2_title_text = axs[2].set_title(u'玩视频游戏所消耗时间占比与每周消费的冰激淋公升数', FontProperties=font)
axs2_xlabel_text = axs[2].set_xlabel(u'玩视频游戏所消耗时间占比', FontProperties=font)
axs2_ylabel_text = axs[2].set_ylabel(u'每周消费的冰激淋公升数', FontProperties=font)
plt.setp(axs2_title_text, size=9, weight='bold', color='red')
plt.setp(axs2_xlabel_text, size=7, weight='bold', color='black')
plt.setp(axs2_ylabel_text, size=7, weight='bold', color='black')
# 设置图例
didntLike = mlines.Line2D([], [], color='black', marker='.',
markersize=6, label='didntLike')
smallDoses = mlines.Line2D([], [], color='blue', marker='.',
markersize=6, label='smallDoses')
largeDoses = mlines.Line2D([], [], color='red', marker='.',
markersize=6, label='largeDoses')
# 添加图例
axs[0].legend(handles=[didntLike, smallDoses, largeDoses])
axs[1].legend(handles=[didntLike, smallDoses, largeDoses])
axs[2].legend(handles=[didntLike, smallDoses, largeDoses])
# 显示图片
plt.show()
\quad
运行结果如下图所示:
\quad
通过数据可以很直观的发现数据的规律,比如飞行常客里程数和玩游戏时间占比在二维平面上呈现出聚簇的关系,而飞行常客里程数和每周消耗的冰淇凌的公升数就呈现出了分层的关系,即如果选择飞行常客里程数和玩游戏时间占比这两个特征对该问题进行分类,那么采用聚类方法的效果应该会优于线性分类的方法,而如果选择飞行常客里程数和每周消耗的冰淇凌的公升数这两个特征对该问题进行分类,那么采用线性分类的方法的效果应该会优于聚类方法。
2.5 准备数据:数据处理
\quad 表2.1给出了四组样本,如果想要计算样本3和样本4之间的距离,可以使用欧式距离公式计算。
表2.1 约会网站样本数据
样本 | 玩游戏所耗时间百分比 | 每年获得飞行常用里程数 | 每周消费冰淇凌公升数 | 样本分类 |
---|---|---|---|---|
1 | 0.8 | 400 | 0.5 | 1 |
2 | 12 | 134000 | 0.9 | 3 |
3 | 0 | 20000 | 1.1 | 2 |
4 | 67 | 32000 | 0.1 | 2 |
计算方法下所示。
(
0
−
67
)
2
+
(
20000
−
32000
)
2
+
(
1.1
−
0.1
)
2
\sqrt{(0 - 67)^2 + (20000 - 32000)^2 + (1.1- 0.1)^2}
(0−67)2+(20000−32000)2+(1.1−0.1)2
\quad 我们很容易发现,上面方程中数字差值最大的属性对计算结果的影响最大,也就是说,每年获取的飞行常客里程数对于计算结果的影响将远远大于表2.1中其他两个特征-玩视频游戏所耗时间占比和每周消费冰淇淋公斤数的影响。而产生这种现象的唯一原因,仅仅是因为飞行常客里程数远大于其他特征值。但海伦认为这三种特征是同等重要的,因此作为三个等权重的特征之一,飞行常客里程数并不应该如此严重地影响到计算结果。
\quad 在处理这种不同取值范围的特征值时,我们通常采用的方法是归一化,或者是标准化,代码如下:
class Transverter(object):
def __init__(self, data_matrix, mode):
self.data_matrix = np.asarray(data_matrix)
self.__mode = mode
self.__parameters = None
def __normalization(self):
min_matrix = self.data_matrix.min(axis=0)
max_matrix = self.data_matrix.max(axis=0)
different_matrix = self.data_matrix - np.tile(min_matrix, (len(self.data_matrix), 1))
range_matrix = np.tile(max_matrix - min_matrix, (len(self.data_matrix), 1))
normal_matrix = different_matrix / range_matrix
return normal_matrix, min_matrix, max_matrix - min_matrix
def __standardization(self):
mean_attribute = np.asarray(self.data_matrix).mean(axis=0)
standard_attribute = np.asarray(self.data_matrix).std(axis=0)
different_matrix = self.data_matrix - np.tile(mean_attribute, (len(self.data_matrix), 1))
std_matrix = different_matrix / standard_attribute
return std_matrix, mean_attribute, standard_attribute
def fit(self, data_matrix=None):
if data_matrix is not None:
self.data_matrix = data_matrix
if self.__mode == "standardization":
data_matrix, mean_attribute, standard_attribute = self.__standardization()
self.__parameters = (mean_attribute, standard_attribute)
elif self.__mode == "normalization":
data_matrix, min_matrix, proportion = self.__normalization()
self.__parameters = (min_matrix, proportion)
return data_matrix
def transfer(self, query):
query = np.asarray(query)
if self.__parameters is not None:
if self.__mode == "standardization":
mean_attribute, standard_attribute = self.__parameters
query = query.reshape((-1, len(mean_attribute)))
different_matrix = query - np.tile(mean_attribute, (len(query), 1))
query = different_matrix / standard_attribute
elif self.__mode == "normalization":
min_matrix, proportion = self.__parameters
query = query.reshape((-1, len(min_matrix)))
different_matrix = query - np.tile(min_matrix, (len(query), 1))
query = different_matrix / proportion
else:
print("必须先执行 fit 函数")
assert False
return query
2.6 测试算法:验证分类器
\quad 机器学习算法一个很重要的工作就是评估算法的正确率,通常我们只提供已有数据的90%作为训练样本来训练分类器,而使用其余的10%数据去测试分类器,检测分类器的正确率。需要注意的是,10%的测试数据应该是随机选择的,由于海伦提供的数据并没有按照特定目的来排序,所以我们可以随意选择10%数据而不影响其随机性,代码如下:
def classify(query, k, dataprocess="standardization"):
data_matrix, labels = ut.load_dataset("dataset\datingTestSet.txt")
ts = ut.Transverter(data_matrix, dataprocess)
data_matrix = ts.fit()
query = ts.transfer(query)
sqrt_matrix = ut.computer_distance(query, data_matrix)
sqrt_matrix = np.array(sqrt_matrix)
indexes = sqrt_matrix.argsort()[:k]
vote_dict = {}
for index in indexes:
vote_dict[labels[index]] = vote_dict.get(labels[index], 0) + 1
print(vote_dict)
vote_sort = sorted(vote_dict.items(), key=operator.itemgetter(1), reverse=True)
return vote_sort[0][0]
\quad 验证一下该分类器的结果,代码如下:
data_matrix, labels = ut.load_dataset("dataset\datingTestSet.txt")
random_test = np.random.randint(0, 1000, 200)
precision = 0
for index in random_test:
query = data_matrix[index]
label_str = classify(query, 5)
if label_str == labels[index]:
precision += 1
print("预测:{},实际:{}".format(label_str, labels[index]))
print("准确率是: {}".format(precision / 200))
\quad
从上图的结果中可以看出,正确率是97%,这其实已经是一个想当不错的结果了。我们可以通过改变 k 的值,检测错误率是否随着变量值的变化而增加。不同的分类算法、数据集和参数设置,最终的输出结果可能有很大的不同。
三、k-近邻算法实战之sklearn手写数字识别
3.1 实战背景
\quad 对于需要识别的数字已经使用图形处理软件,处理成具有相同的色彩和大小:宽高是32像素x32像素。尽管采用本文格式存储图像不能有效地利用内存空间,但是为了方便理解,我们将图片转换为文本格式,数字的文本格式如图3.1所示。
图3.1 数字的文本格式
\quad 与此同时,这些文本格式存储的数字的文件命名也很有特点,格式为:数字的值_该数字的样本序号,如图3.2所示。
图3.2 文本数字的存储格式
\quad
对于这样已经整理好的文本,我们可以直接使用Python处理,进行数字预测。数据集分为训练集和测试集,使用上小结的方法,自己设计k-近邻算法分类器,可以实现分类。数据集和实现代码下载地址:数据集下载
\quad
接下来,我们将使用强大的第三方Python科学计算库Sklearn构建手写数字系统。
sklearn简介
\quad Scikit learn 也简称sklearn,是机器学习领域当中最知名的python模块之一。sklearn包含了很多机器学习的方式:
- Classification 分类
- Regression 回归
- Clustering 非监督分类
- Dimensionality reduction 数据降维
- Model Selection 模型选择
- Preprocessing 数据与处理
\quad 使用sklearn可以很方便地让我们实现一个机器学习算法。一个复杂度算法的实现,使用sklearn可能只需要调用几行API即可。所以学习sklearn,可以有效减少我们特定任务的实现周期,同样因为其高度集成,所以当我们想针对某个算法进行优化的时候就会显得力不从心。
3.2 sklearn实现k-近邻算法简介
\quad 官网英文文档:点我查看
\quad
sklearn.neighbors
模块实现了k-近邻算法,内容如图3.3所示。
图3.3 sklearn.neighbors
\quad
我们使用 sklearn.neighbors.KNeighborsClassifier
就可以是实现上小结,我们实现的k-近邻算法。KNeighborsClassifier函数一共有8个参数,如图3.4所示。
图3.4 KNeighborsClassifier
\quad KNneighborsClassifier参数说明:
n_neighbors
:默认为5,就是k-NN的k的值,选取最近的k个点。weights
:默认是uniform,参数可以是uniform、distance,也可以是用户自己定义的函数。uniform是均等的权重,就说所有的邻近点的权重都是相等的。distance是不均等的权重,距离近的点比距离远的点的影响大。用户自定义的函数,接收距离的数组,返回一组维数相同的权重。algorithm
:快速k近邻搜索算法,默认参数为auto,可以理解为算法自己决定合适的搜索算法。除此之外,用户也可以自己指定搜索算法ball_tree、kd_tree、brute方法进行搜索,brute是蛮力搜索,也就是线性扫描,当训练集很大时,计算非常耗时。kd_tree,构造kd树存储数据以便对其进行快速检索的树形数据结构,kd树也就是数据结构中的二叉树。以中值切分构造的树,每个结点是一个超矩形,在维数小于20时效率高。ball tree是为了克服kd树高纬失效而发明的,其构造过程是以质心C和半径r分割样本空间,每个节点是一个超球体。leaf_size
:默认是30,这个是构造的kd树和ball树的大小。这个值的设置会影响树构建的速度和搜索速度,同样也影响着存储树所需的内存大小。需要根据问题的性质选择最优的大小。metric
:用于距离度量,默认度量是minkowski,也就是p=2的欧氏距离(欧几里德度量)。p
:距离度量公式。在上小结,我们使用欧氏距离公式进行距离度量。除此之外,还有其他的度量方法,例如曼哈顿距离。这个参数默认为2,也就是默认使用欧式距离公式进行距离度量。也可以设置为1,使用曼哈顿距离公式进行距离度量。metric_params
:距离公式的其他关键参数,这个可以不管,使用默认的None即可。n_jobs
:并行处理设置。默认为1,临近点搜索并行工作数。如果为-1,那么CPU的所有cores都用于并行工作。
3.3 sklearn实现
\quad 我们知道数字图片是32x32的二进制图像,为了方便计算,我们可以将32x32的二进制图像转换为1x1024的向量,代码如下:
def load_dataset(file_name):
assert os.path.isdir(file_name)
print("数据文件夹:{}".format(file_name))
file_list = os.listdir(file_name)
data_matrix = []
labels = []
for num_flie in file_list:
label = int(num_flie.split("_")[0])
labels.append(label)
with open(os.path.join(file_name, num_flie), "r") as f:
sample = []
matrix_str = f.readlines()
for num_str in matrix_str:
num_str = num_str[:-1]
for num in num_str:
sample.append(float(num))
data_matrix.append(sample)
return data_matrix, labels
sklearn的估计器,实现简单的机器学习算法非常非常的快,直接上代码:
def main(train_path, test_path):
train_data, train_labels = ut.load_dataset(train_path)
std = StandardScaler()
train_data = std.fit_transform(train_data)
knn_classifier = KNeighborsClassifier(n_neighbors=5, algorithm="kd_tree")
knn_classifier.fit(train_data, train_labels)
test_data, test_labels = ut.load_dataset(test_path)
test_data = std.transform(test_data)
score = knn_classifier.score(test_data, test_labels)
print(score)
结果如下
四、总结
1. kNN算法的优缺点
优点
- 简单好用,容易理解,精度高,理论成熟,既可以用来做分类也可以用来做回归;
- 可用于数值型数据和离散型数据;
- 训练时间复杂度为O(n);无数据输入假定;
- 对异常值不敏感
缺点
- 计算复杂性高;空间复杂性高;
- 样本不平衡问题很难解决(即有些类别的样本数量很多,而其它样本的数量很少);
- 一般数值很大的时候不用这个,计算量太大。但是单个样本又不能太少,否则容易发生误分。
- 最大的缺点是无法给出数据的内在含义。
2. kNN算法的优缺点
- 关于距离度量的方法还有切比雪夫距离、马氏距离、巴氏距离等;