机器学习 -- kNN算法

K近邻算法

什么是K近邻算法

何谓K近邻算法,即K-Nearest Neighbor algorithm,简称KNN算法。单从名字来猜想,可以简单的认为:K个最近的邻居。当K=1时,算法便成了寻找最近的那个邻居。

用官方的话来说,所谓K近邻算法,即是给定一个训练数据集。对新的输入实例,在训练数据集中找到与该实例最邻近的K个实例(也就是上面所说的K个邻居)。这K个实例的多数属于某个类,就把该输入实例分类到这个类中。

我们来看下图:
kNN

如上图所示,有两类不同的样本数据,分别用蓝色的小正方形和红色的小三角形表示。而图正中间的那个绿色的圆所标示的数据则是待分类的数据。也就是说,现在,我们不知道中间那个绿色的数据是从属于哪一类(蓝色小正方形或者红色小三角形)。所以,我们解决问题是给这个绿色的圆分类。

我们常说,物以类聚,人以群分,判别一个人是一个什么样品质特征的人,常常可以从他/她身边的朋友入手,所谓观其友,而识其人。我们不是要判别上图中那个绿色的圆是属于哪一类数据么,好说,从它的邻居下手。但一次性看多少个邻居呢?

从上图中,你还能看到:
+ 如果K=3,绿色圆点的最近的3个邻居是2个红色小三角形和1个蓝色小正方形,基于统计的方法,判定绿色的这个待分类点属于红色的三角形一类。
+ 如果K=5,绿色圆点的最近的5个邻居是2个红色三角形和3个蓝色的正方形,基于统计的方法,判定绿色的这个待分类点属于蓝色的正方形一类。

于此我们看到,当无法判定当前待分类点是从属于已知分类中的哪一类时,我们可以依据统计学的理论看它所处的位置特征,衡量它周围邻居的权重,而把它归为(或分配)到权重更大的那一类。这就是K近邻算法的核心思想。

邻近的距离度量方式

我们看到,K近邻算法的核心在于找到待测点的邻居。那么,问题就接踵而至了,如何找到邻居,邻居的判定标准是什么,用什么来度量。这一系列问题便是下面要讲的距离度量方式。

这是因为特征空间中两个实例点的距离可以反应出两个实例点之间的相似性程度。K近邻模型的特征空间一般是n维实数向量空间,使用的距离可以是欧式距离,也是可以是其它距离,既然扯到了距离,下面就来具体阐述下都有哪些距离度量的表示法,

欧式距离

最常见的两点之间或多点之间的距离表示法,又称之为欧几里得度量,它定义于欧几里得空间中,如点 x = (x1,…,xn) 和 y = (y1,…,yn) 之间的距离为:

d(x,y):=(x1y1)2+(x2y2)2+...+(xnyn)22

代码如下:

double euclideanDistance(const vector<double>& v1, const vector<double>& v2)
{
    assert(v1.size() == v2.size());
    double ret = 0.0;
    for (vector<double>::size_type i = 0; i != v1.size(); ++i)
    {
        ret += (v1[i] - v2[i]) * (v1[i] - v2[i]);
    }
    return sqrt(ret);
}

曼哈顿距离

我们可以定义曼哈顿距离的正式意义为L1-距离或城市区块距离,也就是在欧几里得空间的固定直角坐标系上两点所形成的线段对轴产生的投影的距离总和。例如在平面上,坐标(x1, y1)的点P1与坐标(x2, y2)的点P2的曼哈顿距离为:

|x1x2|+|y1y2|

通俗来讲,想象你在曼哈顿要从一个十字路口开车到另外一个十字路口,驾驶距离是两点间的直线距离吗?显然不是,除非你能穿越大楼。而实际驾驶距离就是这个曼哈顿距离,此即曼哈顿距离名称的来源。同时,曼哈顿距离也称为城市街区距离(City Block Distance)。

切比雪夫距离
闵可夫斯基距离(Minkowski Distance)
标准化欧氏距离 (Standardized Euclidean distance )
马氏距离(Mahalanobis Distance)
巴氏距离(Bhattacharyya Distance)
汉明距离(Hamming distance)
夹角余弦(Cosine)
杰卡德相似系数(Jaccard similarity coefficient)
皮尔逊系数(Pearson Correlation Coefficient)

K值的选择

除了上述如何定义邻居的问题之外,还有一个选择多少个邻居,即K值定义为多大的问题。不要小看了这个K值选择问题,因为它对K近邻算法的结果会产生重大影响。如下图所示,X是待分类样本,‘+’和‘-’是样本类别属性,
K Choice

  • 如果K选小了的话,结果对噪音样本点很敏感。

  • 如果K选大了的话,可能求出来的k最近邻集合可能包含了太多隶属于其它类别的样本点,最极端的就是k取训练集的大小,此时无论输入实例是什么,都只是简单的预测它属于在训练实例中最多的累,模型过于简单,忽略了训练实例中大量有用信息

在实际应用中,K值一般取一个比较小的数值,例如采用交叉验证法(一部分样本做训练集,而另一部分做测试集,即K值初始取一个比较小的数值,之后不断来调整K值的大小使得分类最优,得到的K值就是最优解。但是这个K值具有一定的局限性)来选择最优的K值。

Tensorflow中的K近邻算法实现

__author__ = 'Xinyou DONG'
import tensorflow as tf
import numpy as np

def loadMNIST():
    from tensorflow.examples.tutorials.mnist import input_data
    mnist = input_data.read_data_sets('MNIST_data', one_hot=True)
    return mnist

def KNN(mnist):
    train_x,train_y = mnist.train.next_batch(5000)
    test_x,test_y = mnist.train.next_batch(200)

    xtr = tf.placeholder(tf.float32, [None,784])
    xte = tf.placeholder(tf.float32, [784])
    distance = tf.sqrt(tf.reduce_sum(tf.pow(tf.add(xtr, tf.neg(xte)), 2), reduction_indices=1))

    pred = tf.argmin(distance,0)

    init = tf.initialize_all_variables()

    sess = tf.Session()
    sess.run(init)

    right = 0
    for i in range(200):
        ansIndex = sess.run(pred,{xtr:train_x, xte:test_x[i,:]})
        print 'prediction is ', np.argmax(train_y[ansIndex])
        print 'true value is ', np.argmax(test_y[i])
        if np.argmax(test_y[i]) == np.argmax(train_y[ansIndex]):
            right += 1.0
    accracy = right/200.0
    print accracy

if __name__ == "__main__":
    mnist = loadMNIST()
    KNN(mnist)

K近邻算法的实现 – KD树

背景

正如前面所讲的卷积神经元算法中图像匹配类似,特征点的匹配和数据库查、图像检索本质上是同一个问题,都可以归结为一个通过距离函数在高维向量之间进行相似性检索的问题,如何快速而准确地找到查询点的近邻,不少人提出了很多高维空间索引结构和近似查询的算法。

一般说来,索引结构中相似性查询有两种基本的方式:
+ 一种是范围查询,范围查询时给定查询点和查询距离阈值,从数据集中查找所有与查询点距离小于阈值的数据
+ 另一种是K近邻查询,就是给定查询点及正整数K,从数据集中找到距离查询点最近的K个数据,当K=1时,它就是最近邻查询。

同样,针对特征点匹配也有两种方法:
+ 最容易的办法就是线性扫描,也就是我们常说的穷举搜索,依次计算样本集E中每个样本到输入实例点的距离,然后抽取出计算出来的最小距离的点即为最近邻点。此种办法简单直白,但当样本集或训练集很大时,它的缺点就立马暴露出来了,举个例子,在物体识别的问题中,可能有数千个甚至数万个SIFT特征点,而去一一计算这成千上万的特征点与输入实例点的距离,明显是不足取的。
+ 另外一种,就是构建数据索引,因为实际数据一般都会呈现簇状的聚类形态,因此我们想到建立数据索引,然后再进行快速匹配。索引树是一种树结构索引方法,其基本思想是对搜索空间进行层次划分。根据划分的空间是否有混叠可以分为Clipping和Overlapping两种。前者划分空间没有重叠,其代表就是KD树;后者划分空间相互有交叠,其代表为R树。

1975年,来自斯坦福大学的Jon Louis Bentley在ACM杂志上发表的一篇论文:Multidimensional Binary Search Trees Used for Associative Searching中正式提出和阐述的了如下图形式的把空间划分为多个部分的KD树。
Cup

什么是KD树

KD树是K-dimension tree的缩写,是对数据点在k维空间(如二维(x,y),三维(x,y,z),k维(x,y,z..))中划分的一种数据结构,主要应用于多维空间关键数据的搜索(如:范围搜索和最近邻搜索)。

首先必须搞清楚的是,KD树是一种空间划分树,就是把整个空间划分为特定的几个部分,然后在特定空间的部分内进行相关搜索操作。想像一个三维空间,K-D树按照一定的划分规则把这个三维空间划分了多个空间,如下图所示:
这里写图片描述

KD树的构建

KD树构建的伪代码如下图所示:
这里写图片描述

再举一个简单直观的实例来介绍KD树构建算法。假设有6个二维数据点{(2, 3),(5, 4),(9, 6),(4, 7),(8, 1),(7, 2)},数据点位于二维空间内,如下图所示。为了能有效的找到最近邻,KD树采用分而治之的思想,即将整个空间划分为几个小部分,首先,粗黑线将空间一分为二,然后在两个子空间中,细黑直线又将整个空间划分为四部分,最后虚黑直线将这四部分进一步划分。
这里写图片描述

6个二维数据点{(2, 3),(5, 4),(9, 6),(4, 7),(8, 1),(7, 2)}构建KD树的具体步骤为:
+ 确定split域。具体步骤是,6个数据点在x,y维度上的数据方差分别为39,28.63,所以在x轴上方差更大,故split域值为x;
+ 确定Node-data=(7, 2)。具体步骤是,根据x维上的值将数据排序,6个数据的中值(所谓中值,即中间大小的值)为7,所以Node-data域位数据点(7, 2)。这样,该节点的分割超平面就是通过(7, 2)并垂直于split=x轴的直线x=7;
+ 确定左子空间和右子空间。具体步骤是:分割超平面x=7将整个空间分为两部分,x<=7的部分为左子空间,包含3个节点{(2, 3),(5, 4),(4, 7)};另一部分为右子空间,包含2个节点{(9, 6),(8, 1)};

如上算法所述,KD树的构建是一个递归过程,我们对左子空间和右子空间内的数据重复根节点的过程就可以得到一级子节点(5, 4)和(9, 6),同时将空间和数据集进一步细分,如此往复直到空间中只包含一个数据点。
这里写图片描述

与此同时,经过对上面所示的空间划分之后,我们可以看出,点(7, 2)可以为根结点,从根结点出发的两条红粗斜线指向的(5, 4)和(9, 6)则为根结点的左右子结点,而(2, 3),(4, 7)则为(5, 4)的左右孩子(通过两条细红斜线相连),最后,(8,1)为(9,6)的左孩子(通过细红斜线相连)。如此,便形成了下面这样一棵k-d树:
这里写图片描述

KD树的数据结构(注,本文以下代码分析基于Rob Hess维护的sift库),

/** a node in a k-d tree */
struct kd_node
{
    int ki;                      /**< partition key index */
    double kv;                   /**< partition key value */
    int leaf;                    /**< 1 if node is a leaf, 0 otherwise */
    struct feature* features;    /**< features at this node */
    int n;                       /**< number of features */
    struct kd_node* kd_left;     /**< left child */
    struct kd_node* kd_right;    /**< right child */
}

也就是说,KD树中,KD代表k-dimension,每个节点即为一个k维的点。每个非叶节点可以想象为一个分割超平面,用垂直于坐标轴的超平面将空间分为两个部分,这样递归的从根节点不停的划分,直到没有实例为止。经典的构造k-d tree的规则如下:
+ 随着树的深度增加,循环的选取坐标轴,作为分割超平面的法向量。对于3-d tree来说,根节点选取x轴,根节点的孩子选取y轴,根节点的孙子选取z轴,根节点的曾孙子选取x轴,这样循环下去。
+ 每次均为所有对应实例的中位数的实例作为切分点,切分点作为父节点,左右两侧为划分的作为左右两子树。

对于n个实例的k维数据来说,建立kd-tree的时间复杂度为O(k*n*logn)。

以下是构建k-d树的代码:

struct kd_node* kdtree_build( struct feature* features, int n )
{
    struct kd_node* kd_root;

    if( ! features  ||  n <= 0 )
    {
        fprintf( stderr, "Warning: kdtree_build(): no features, %s, line %d\n", __FILE__, __LINE__ );
        return NULL;
    }

    kd_root = kd_node_init( features, n );  //n--number of features,initinalize root of tree.
    expand_kd_node_subtree( kd_root );  //kd tree expand

    return kd_root;
}

上面的涉及初始化操作的两个函数kd_node_init,及expand_kd_node_subtree代码分别如下所示:

static struct kd_node* kd_node_init( struct feature* features, int n )
{                                     //n--number of features
    struct kd_node* kd_node;

    kd_node = (struct kd_node*)(malloc(sizeof(struct kd_node) ));
    memset( kd_node, 0, sizeof( struct kd_node ) ); //0填充
    kd_node->ki = -1;
    kd_node->features = features;
    kd_node->n = n;

    return kd_node;
}

static void expand_kd_node_subtree( struct kd_node* kd_node )
{
    /* base case: leaf node */
    if( kd_node->n == 1  ||  kd_node->n == 0 )
    {   //叶节点               //伪叶节点
        kd_node->leaf = 1;
        return;
    }

    assign_part_key( kd_node ); //get ki,kv
    partition_features( kd_node ); //creat left and right children,特征点ki位置左树比右树模值小,kv作为分界模值
                                 //kd_node中关键点已经排序
    if( kd_node->kd_left )
        expand_kd_node_subtree( kd_node->kd_left );
    if( kd_node->kd_right )
        expand_kd_node_subtree( kd_node->kd_right );
}

KD树的插入

元素插入到一个K-D树的方法和二叉检索树类似。本质上,在偶数层比较x坐标值,而在奇数层比较y坐标值。当我们到达了树的底部,(也就是当一个空指针出现),我们也就找到了结点将要插入的位置。生成的K-D树的形状依赖于结点插入时的顺序。给定N个点,其中一个结点插入和检索的平均代价是

$O(log2N)
$。

我们来考虑如下的例子,用下列的点逐步插入KD树中,
(3, 6),(17, 15),(13, 15),(6, 12),(9, 1),(2, 7),(10, 19)

  • 插入点(3, 6):因为此时KD树为空,我们将此点作为树的根节点;
  • 插入点(17, 15):将此点与KD树的根节点比较,因为根节点是以x轴作为比较域的,所以当前点的x轴的坐标值要与根节点的值相比较用以决定当前点位于根节点的左子树还是右子树。在插入后,当前点是以y轴作为比较域的;
  • 插入点(13, 15):当前点的x轴坐标大于根节点的x轴坐标,所以当前点位于根节点的右子树。然后当前点与右子树的根节点比较y值,当前点应位于右子树根节点的右子树。在插入后,当前点是以x轴作为比较域的;
  • 插入点(6, 12):同理可得,当前点位于点(17, 15)的左子树;
  • 插入点(9, 1):同理可得,当前点位于点(6, 12)的右子树;
  • 插入点(2, 7):同理可得,当前点位于点(3, 6)的左子树;
  • 插入点(10, 19):同理可得,当前点位于点(13, 15)的左子树;
    这里写图片描述

KD树的删除

删除步骤
  • 如果当前节点是要删除的节点;
    • 如果当前节点是一个叶子节点,删除;
    • 如果当前节点的右子树非空;
      1.找到当前节点右子树中对应比较域值的对应的最小节点;
      2.删掉当前节点,并且用找到的最小节点代替删掉的节点;
      3.递归替换;
    • 如果当前节点的右子树为空且左子树非空;
      1.找到当前节点左子树中对应比较域值的对应的最小节点;
      2.删掉当前节点,并且用找到的最小节点代替删掉的节点;
      3.并将左子树作为当前节点的右子树;
      4.递归替换
  • 如果当前节点不是要删除的节点;
    • 如果要删除的节点比当前节点的比较域的值小,到当前节点的左子树中查询;
    • 如果相反,则到当前节点的右子树中查询;
KD树节点删除的例子:

删除节点(30, 40):因为节点的右子树非空并且比较域是x轴,我们需要在右子树中查询x轴的最小值节点代替当前节点。又因为点(35, 45)是叶子节点,可直接删除。如下图所示:
这里写图片描述

删除节点(70, 70):因为当前节点的比较域在y轴,且右子树为空,所以我们需要在其左子树中查找对于值最小的节点。将查找的节点(50, 30)代替(70, 70),并将左子树作为当前节点的右子树。如下图所示:
这里写图片描述

KD树的最近邻搜索算法

现实生活中有许多问题需要在多维数据的快速分析和快速搜索,对于这个问题最常用的方法是所谓的KD树。在KD树中进行数据的查找也是特征匹配的重要环节,其目的是检索在KD树中与查询点距离最近的数据点。在一个N维的笛卡儿空间在两个点之间的距离是由下述公式确定:

d(x,y):=(x1y1)2+(x2y2)2+...+(xnyn)22

k-d树查询算法的伪代码
算法:KD树最邻近查找
输入:   Kd,       //k-d tree类型
        target     //查询数据点
输出:   nearest,  //最邻近数据点
        dist       //最邻近数据点和查询点间的距离

1. If KDNULL,则设dist为infinite并返回
2. //进行二叉查找,生成搜索路径
   Kd_point = &Kd;              //Kd-point中保存k-d tree根节点地址
   nearest = Kd_point -> Node-data;  //初始化最近邻点

   while(Kd_point)
     push(Kd_point)到search_path中; //search_path是一个堆栈结构,存储着搜索路径节点指针

      If Dist(nearest,target) > DistKd_point -> Node-data,target)
       nearest  = Kd_point -> Node-data;    //更新最近邻点
       Min_dist = Dist(Kd_point,target);  //更新最近邻点与查询点间的距离  ***/
     s = Kd_point -> split;                  //确定待分割的方向

     If target[s] <= Kd_point -> Node-data[s]     //进行二叉查找 
       Kd_point = Kd_point -> left;
     else
       Kd_point = Kd_point ->right;
   End while

3. //回溯查找
   while(search_path != NULL)
     back_point = 从search_path取出一个节点指针;  //从search_path堆栈弹栈
     s = back_point -> split;                   //确定分割方向
     If Dist(target[s],back_point -> Node-data[s]) < Max_dist   //判断还需进入的子空间
       If target[s] <= back_point -> Node-data[s]
         Kd_point = back_point -> right;  //如果target位于左子空间,就应进入右子空间
       else
         Kd_point = back_point -> left;    //如果target位于右子空间,就应进入左子空间
       将Kd_point压入search_path堆栈;

     If Dist(nearest,target) > DistKd_Point -> Node-data,target)
       nearest  = Kd_point -> Node-data;      //更新最近邻点
       Min_dist = DistKd_point -> Node-data,target);  //更新最近邻点与查询点间的距离的
   End while

将Kd_point压入search_path堆栈;”这行代码后,应该是调到步骤2再往下走二分搜索的逻辑一直到叶结点,我写了一个递归版本的二维kd tree的搜索函数你对比的看看:

void innerGetClosest(NODE* pNode, PT point, PT& res, int& nMinDis)
{
    if (NULL == pNode)
        return;
    int nCurDis = abs(point.x - pNode->pt.x) + abs(point.y - pNode->pt.y);
    if (nMinDis < 0 || nCurDis < nMinDis)
    {
        nMinDis = nCurDis;
        res = pNode->pt;
    }
    if (pNode->splitX && point.x <= pNode->pt.x || !pNode->splitX && point.y <= pNode->pt.y)
        innerGetClosest(pNode->pLft, point, res, nMinDis);
    else
        innerGetClosest(pNode->pRgt, point, res, nMinDis);
    int rang = pNode->splitX ? abs(point.x - pNode->pt.x) : abs(point.y - pNode->pt.y);
    if (rang > nMinDis)
        return;
    NODE* pGoInto = pNode->pLft;
    if (pNode->splitX && point.x > pNode->pt.x || !pNode->splitX && point.y > pNode->pt.y)
        pGoInto = pNode->pRgt;
    innerGetClosest(pGoInto, point, res, nMinDis);
}

以两个简单的实例(例子来自图像局部不变特性特征与描述一书)来描述最邻近查找的基本思路。

查询点(2.1, 3.1)

加号表示要查询的点(2.1, 3.1)。通过二叉搜索,顺着搜索路径很快就能找到最邻近的近似点,也就是叶子节点(2, 3)。而找到的叶子节点并不一定就是最邻近的,最邻近肯定距离查询点更近,应该位于以查询点为圆心且通过叶子节点的圆域内。为了找到真正的最近邻,还需要进行相关的回溯操作。也就是说,算法首先沿搜索路径反向查找是否有距离查询点更近的数据点。

以查询(2.1, 3.1)为例:

  • 二叉树搜索:先从(7, 2)点开始进行二叉查找,然后到达(5, 4),最后到达(2, 3),此时搜索路径中的节点为<(7, 2),(5, 4),(2, 3)>,首先以(2, 3)作为当前最近邻点,计算其到查询点(2.1, 3.1)的距离为0.1414,
  • 回溯查找:在得到(2, 3)为查询点的最近点之后,回溯到其父节点(5,4),并判断在该父节点的其他子节点空间中是否有距离查询点更近的数据点。以(2.1, 3.1)为圆心,以0.1414为半径画圆,如下图所示。发现该圆并不和超平面y = 4交割,因此不用进入(5, 4)节点右子空间中(图中灰色区域)去搜索;
  • 最后,再回溯到(7, 2),以(2.1, 3.1)为圆心,以0.1414为半径的圆更不会与x = 7超平面交割,因此不用进入(7, 2)右子空间进行查找。至此,搜索路径中的节点已经全部回溯完,结束整个搜索,返回最近邻点(2, 3),最近距离为0.1414
    这里写图片描述
查询点(2, 4.5)

一个复杂点了例子如查找点为(2, 4.5),具体步骤依次如下:

  • 同样先进行二叉查找,先从(7, 2)查找到(5, 4)节点,在进行查找时是由y = 4为分割超平面的,由于查找点为y值为4.5,因此进入右子空间查找到(4, 7),形成搜索路径<(7, 2),(5, 4),(4, 7)>,但(4, 7)与目标查找点的距离为3.202,而(5, 4)与查找点之间的距离为3.041,所以(5, 4)为查询点的最近点;
  • 以(2, 4.5)为圆心,以3.041为半径作圆,如下图所示。可见该圆和y = 4超平面交割,所以需要进入(5, 4)左子空间进行查找,也就是将(2, 3)节点加入搜索路径中得<(7, 2),(2, 3)>;于是接着搜索至(2, 3)叶子节点,(2, 3)距离(2, 4.5)比(5, 4)要近,所以最近邻点更新为(2, 3),最近距离更新为1.5;
  • 回溯查找至(5, 4),直到最后回溯到根结点(7, 2)的时候,以(2, 4.5)为圆心1.5为半径作圆,并不和x = 7分割超平面交割,如下图所示。至此,搜索路径回溯完,返回最近邻点(2, 3),最近距离1.5。
    这里写图片描述

上述两次实例表明,当查询点的邻域与分割超平面两侧空间交割时,需要查找另一侧子空间,导致检索过程复杂,效率下降。

一般来讲,最临近搜索只需要检测几个叶子结点即可,如下图所示:
这里写图片描述

但是,如果当实例点的分布比较糟糕时,几乎要遍历所有的结点,如下所示:
这里写图片描述

研究表明N个节点的K维KD树搜索过程时间复杂度为: Tworst=O(kn11k)

同时,以上为了介绍方便,讨论的是二维或三维情形。但在实际的应用中,如SIFT特征矢量128维,SURF特征矢量64维,维度都比较大,直接利用KD树快速检索(维数不超过20)的性能急剧下降,几乎接近贪婪线性扫描。假设数据集的维数为D,一般来说要求数据的规模N满足

N>>2D

才能达到高效的搜索。所以这就引出了一系列对KD树算法的改进:BBF算法,和一系列M树、VP树、MVP树等高维空间索引树。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值