本文着重于介绍用c++实现K近邻算法。首先介绍一下K近邻算法的原理和优缺点。
KNN算法
原理
对于一个数据样本集合,每条数据都有对应的标签。当输入一个新的,没有标签的数据时,算法将新数据的每个特征与样本中对应的特征进行比较。然后选取数据集中前k个最相似的数据,其中出现最多的标签分类,作为新数据的分类。
优点
精度高,对异常值不敏感,无输入数据假定
缺点
计算复杂度高,空间复杂度高
问题:使用k近邻算法改进约会网站配对效果
海伦在约会网站寻找适合的约会对象,根据总结她发现曾结交的三类人:
- 不喜欢的人
- 一般喜欢的人
- 喜欢的人
她希望能够通过以往约会过的人的信息,对之后的约会对象能够作一个分类。
海伦的约会数据存放在文本datingTestSet2.txt中,每个样本占据一行,总共1000行。每个样本有以下3个信息:
- 每年获得的飞行里程
- 玩游戏所耗时间的百分比
- 每周消费的冰淇淋公升数
数据预处理
以上是部分数据的截图,其中前3列是数据的特征,最后一列是数据的标签。可以看到每一列数据的量级是不一样的。在计算测试数据与训练数据的距离的时候,一般是对两个数据对应列进行求差,然后再取平方和。如训练数据为(x
,x
,x
),测试数据尾为(y
,y
,y
),则两者距离可以表示为
当三个坐标的量级不一致时,绝对值最大的那个坐标就会对最终的距离造成极大的影响。如图上的第一列数据与二三列数据相比,计算距离的时候二三列对结果影响就很小了。
为了消除不同列的数据大小带来的影响,在正式使用KNN算法时,需要对每一列数据进行归一化,归一化公式如下
归一化之后,所有数据都会位于0-1之间。
代码实现
以下是用c++实现KNN算法的代码
#include<iostream>
#include<math.h>
#include<map>
using namespace std;
const int MaxCol= 10;
const int MaxRow= 10000;
const int MaxK = 10;
#include<fstream>
class KNN{
private:
int m_k;
int m_train_row;
int m_test_row;
int m_column;
double TrainData[MaxRow][MaxCol];
double TestData[MaxRow][MaxCol];
int TrainLabels[MaxRow];
int TestLabels[MaxRow];
public:
KNN(int k, char *FileName, int Row, int Column):m_k(k),m_column(Column){
ifstream fin;
fin.open(FileName);
assert(fin.is_open());
double Number;
cout<<"please input the number of test data:"<<endl;
cin>>Number;
assert(Number>0&&Number<Row);
m_train_row = Row-Number;
m_test_row = Number;
for(int i = 0; i < m_test_row; i++){
for(int j = 0; j < m_column; j++){
fin>>TestData[i][j];
}
fin>>TestLabels[i];
}
for(int i = 0; i < m_train_row; i++){
for(int j = 0; j < m_column; j++){
fin>>TrainData[i][j];
}
fin>>TrainLabels[i];
}
AutoNorm();
}
int Classify(double Input[]);
int GetMaxSeq(int LabelMinIdx[]);
int GetMinDistIndex(double Distance[]);
double GetDistance(double[], double[]);
double CorrectRate();
void AutoNorm();
void Print();
};
void KNN::Print(){
cout<<"**************Train Data***************"<<endl;
for(int i = 0; i < 10; i++){
for(int j = 0; j < m_column; j++){
cout<<TrainData[i][j]<<" ";
}
cout<<endl;
}
cout<<"**************Test Data***************"<<endl;
for(int i = 0; i < 10; i++){
for(int j = 0; j < m_column; j++){
cout<<TestData[i][j]<<" ";
}
cout<<endl;
}
cout<<"distance = "<<GetDistance(TestData[0],TrainData[0])<<endl;;
}
void KNN::AutoNorm(){
double TotalData[MaxRow][MaxCol];
for(int i = 0; i < m_train_row; i++){
for(int j = 0; j < m_column; j++){
TotalData[i][j] = TrainData[i][j];
}
}
for(int i = 0; i < m_test_row; i++){
for(int j = 0; j < m_column; j++){
TotalData[i+m_train_row][j] = TestData[i][j];
}
}
double Min = -1;
double Max = 9999;
double MinMax[2][MaxCol];
for(int j = 0; j < m_column; j++){
Min = 9999; Max = -1;
for(int i = 0; i < m_train_row + m_test_row; i++){
if(TotalData[i][j] < Min) Min = TotalData[i][j];
if(TotalData[i][j] > Max) Max = TotalData[i][j];
}
MinMax[0][j] = Min;
MinMax[1][j] = Max;
}
for(int i = 0; i < m_train_row; i++){
for(int j = 0; j < m_column; j++){
TrainData[i][j] = (TrainData[i][j] - MinMax[0][j])/(MinMax[1][j] - MinMax[0][j]);
}
}
for(int i = 0; i < m_test_row; i++){
for(int j = 0; j < m_column; j++){
TestData[i][j] = (TestData[i][j] - MinMax[0][j])/(MinMax[1][j] - MinMax[0][j]);
}
}
}
double KNN::GetDistance(double Input[], double TrainData[]){
if(Input==nullptr){
cout<<"error!"<<endl;
return -9999;
}
double Distance = 0;
for(int i = 0; i < m_column; i++){
Distance += (Input[i] - TrainData[i]) * (Input[i] - TrainData[i]);
}
return sqrt(Distance);
}
int KNN::GetMinDistIndex(double Distance[]){
int Index = -1;
double DistMin = 99;
if(Distance==nullptr){
cout<<"error!"<<endl;
return -9999;
}
for(int i = 0; i < m_train_row; i++){
if(Distance[i]<DistMin&&Distance[i]>=0) {
DistMin = Distance[i];
Index = i;
}
}
Distance[Index] = -1;//找出最小值后,将其置为-1
return Index;
}
int KNN::GetMaxSeq(int LabelMinIdx[]){
map<int,int> LabelAppearTime;//key为Label值,value为出现次数
map<int,int>::iterator iter;
for(int i = 0; i < m_k; i++){
iter = LabelAppearTime.find(TrainLabels[LabelMinIdx[i]]);
if(iter!=LabelAppearTime.end()) iter->second++;
else {
LabelAppearTime.insert(pair<int,int>(TrainLabels[LabelMinIdx[i]],1));
}
}
int LabelMaxSeq = -1;
int times = 0;
for(iter = LabelAppearTime.begin(); iter!=LabelAppearTime.end(); iter++){
if(iter->second>times) {
times = iter->second;
LabelMaxSeq = iter->first;
}
}
return LabelMaxSeq;
}
int KNN::Classify(double Input[]){
//Column是数据特征变量的个数
//Row是DataSet的输入向量的长度
/*
算法的基本思想是,计算Input向量与DataSet每个向量的距离,并用一个数组Distance储存。
找出Distance中的最小的前k个值,在Labels向量中找出对应下标并记录对应Labels值
找出记录下的Labels值中,出现频率最高的作为返回值。
*/
double Distance[MaxRow];
for(int i = 0; i < m_train_row; i++){
Distance[i] = GetDistance(Input,TrainData[i]);
}
int LabelMinIdx[MaxK];
for(int i = 0;i < m_k; i++){
LabelMinIdx[i] = GetMinDistIndex(Distance);//返回的是Label下标
}
return GetMaxSeq(LabelMinIdx);
}
double KNN::CorrectRate(){
// 对于每个TestData,利用Classify获得LabelsPredict,再和TestData的真实Label计算正确率
double CorrectNum = 0;
for(int i = 0; i < m_test_row; i++){
if(Classify(TestData[i])==TestLabels[i]) CorrectNum++;
}
double CorrectRate = CorrectNum/m_test_row;
cout<<"CorrectRate = "<<CorrectRate<<endl;
return 0;
}
int main(int argc , char** argv){
int k,row,col;
char *FileName ;
if(argc!=5){
cout<<"The input should be like this : ./a.out k row col filename"<<endl;
exit(1);
}
k = atoi(argv[1]);
row = atoi(argv[2]);
col = atoi(argv[3]);
FileName = argv[4];
//KNN *k = new KNN(7,FileName,1000,3);
KNN *knn= new KNN(k,FileName,row,col);
knn->CorrectRate();
return 0;
}
从main函数看到,算法需要自行指定k值,数据文件的名称,以及数据的行和列数目。在确认文件有效之后,在类KNN的构造函数中,还需要输入作测试集的数据量占比。在数据集中提取一部分数据用来测试算法的性能是在机器学习中的常用方法。
在knn类直接调用CorrectRate方法即可得到算法的正确率。
在CorrectRate方法中,对每一个测试事例,调用classify函数获得其预测标签,将其与真实标签进行对比。
Classify函数是KNN的核心。具体的意义已经在代码中进行说明。
运行结果如下图:
可以看到,在用100个数据作测试集,k=7的情况下,正确率有96%,可以说算法的性能是不错的。
泰坦尼克号数据集
泰坦尼克号乘客存活问题是kaggle著名的入门问题,详情可以参考这里。在上一篇文章中,对数据集进行了数据清洗,对清洗后的数据也用KNN算法进行了分类,数据截图如下
图中前6列数据是样本的特征,最后一列Survived是标签,表示该乘客是否生还,数据已经放上github,可以在文中末尾查看。
以下是运行截图
可以看到,在用100个事例作测试集,k=5的情况下,正确率有76%。此数据集用其他性能更好的算法正确率一般不超过80%,因此KNN算法性能还算过得去。
总结
本文主要对KNN算法的c++实现作一个展示,并且有两个数据集进行测试,证明算法实现是基本正确的。
当然算法还有可以优化之处。在计算测试集的数据与训练集的距离后,需要获取前k个距离最小的标签。此处获取的方法有多种:
- 排序法:比较容易想到的是将距离排序,然后取前k个值,但是这样的平均时间复杂度是O(NlogN)。
- 最小堆:可以用O(N)的时间建立最小堆,然后每次删除最小的元素,删除元素后再复原最小堆平均时间为O(LogN),因此此法的平均时间复杂度是O(N+kLogN)。
- 我用的方法为,每次遍历数组,找出最小值,找出之后将其设置为-1,重复遍历k次,即可找出前k个距离最小的元素。此法时间复杂度为O(kN),且需要改变数组元素,并不是特别好的方法。但是在N特别大,k比较小的时候,此法会比排序法要快。
算法文件以及数据集已经放到githup,地址为这里