K近邻(KNN)算法原理及其Python实现

1.KNN算法原理

k k k近邻算法在1968年由Cover和Hart提出,它非常简单、直观:给定一个训练数据集,对新的输入实例,在训练数据集中找到与该输入实例最邻近的 k k k个实例,这 k k k个实例的多数属于某个类别,就把输入实例分为这个类别。如下图所示:

k近邻算法
 以下是来自李航《统计学习方法》对于 k k k近邻算法的论述:

输入:实例 x x x特征向量;训练数据集 T = { ( x 1 , y 1 ) , ( x 2 , y 2 ) , ( x 3 , y 3 ) , . . . , ( x N , y N ) } T = \left \{ (x_1,y_1),(x_2,y_2),(x_3,y_3),...,(x_N,y_N) \right \} T={(x1,y1),(x2,y2),(x3,y3),...,(xN,yN)}

 其中 x i ∈ X ⊆ R n x_i∈X\subseteq R^n xiXRn 为实例的特征向量, y i ∈ Y = { c 1 , c 2 , c 3 , . . . , c K } y_i∈Y= \left \{ c_1,c_2,c_3,...,c_K \right \} yiY={c1,c2,c3,...,cK}为实例类别。

输出:实例 x x x所属分类 y y y

 (1)根据给定的距离度量,在训练集 T T T中找到与实例 x x x最近邻的 k k k个点,将涵盖这 k k k个点的邻域记作 N k ( x ) N_k(x) Nk(x)

 (2)在 N k ( x ) N_k(x) Nk(x)中根据给定的分类决策规则(如多数表决)决定实例 x x x的分类 y y y

y = arg ⁡ max ⁡ c j ∑ x i ∈ N k ( x ) I ( y i = c j ) y = \mathop{\arg\max}_{c_j} \sum_{x_i\in N_k(x)}^{} I(y_i=c_j) y=argmaxcjxiNk(x)I(yi=cj)

    其中 i = 1 , 2 , 3 , . . . , N ; j = 1 , 2 , 3 , . . . , K 。 I i=1,2,3,...,N; j=1,2,3,...,K。I i=1,2,3,...,N;j=1,2,3,...,KI 为指示函数,即 y i = c j y_i=c_j yi=cj I = 1 I=1 I=1,否则 I = 0 I=0 I=0

 特殊的,当 k = 1 k=1 k=1时,称为最近邻算法。

k k k近邻算法的模型是基于样本集对特征空间的一个划分,没有显式的学习过程。 k k k近邻模型由 距离度量 k k k值选择分类决策规则 决定:

k近邻算法模型

 (1)距离度量:特征空间中两个特征向量 x i = ( x i 1 , x i 2 , . . . , x i n ) T , x j = ( x j 1 , x j 2 , . . . , x j n ) T x_{i}=(x_{i}^{1},x_{i}^{2},...,x_{i}^{n})^{T},x_{j}=(x_{j}^{1},x_{j}^{2},...,x_{j}^{n})^{T} xi=(xi1,xi2,...,xin)Txj=(xj1,xj2,...,xjn)T 的p范数定义为

L p ( x i , x j ) = ( ∑ l = 1 n ∣ x i l − x j l ∣ p ) 1 p L_{p}\left ( x_{i},x_{j} \right ) = \left ( \sum_{l=1}^{n}\left | x_{i}^{l} - x_{j}^{l} \right |^{p} \right ) ^{\frac{1}{p} } Lp(xi,xj)=(l=1n xilxjl p)p1

 由不同的度量方式所确定的最近邻点可能不同。

  • p = 1 p=1 p=1时,称为曼哈顿距离
    L p ( x i , x j ) = ∑ l = 1 n ∣ x i l − x j l ∣ L_{p}\left ( x_{i},x_{j} \right ) = \sum_{l=1}^{n}\left | x_{i}^{l} - x_{j}^{l} \right | Lp(xi,xj)=l=1n xilxjl
  • p = 2 p=2 p=2时,称为欧式距离
    L p ( x i , x j ) = ( ∑ l = 1 n ∣ x i l − x j l ∣ 2 ) 1 2 L_{p}\left ( x_{i},x_{j} \right ) = \left ( \sum_{l=1}^{n}\left | x_{i}^{l} - x_{j}^{l} \right |^{2} \right ) ^{\frac{1}{2} } Lp(xi,xj)=(l=1n xilxjl 2)21
  • p = ∞ p=∞ p=时,为各个坐标的距离最大值
    L p ( x i , x j ) = max ⁡ l ∣ x i l − x j l ∣ L_{p}\left ( x_{i},x_{j} \right ) = \mathop{\max}_{l} \left | x_{i}^{l} - x_{j}^{l} \right | Lp(xi,xj)=maxl xilxjl

Lp距离之间的关系
  其它的距离度量可以参考这篇文章: 《从K近邻算法、距离度量谈到KD树、SIFT+BBF算法》

 (2) k k k值选择 k k k的选择反映了训练误差(近似误差)与测试误差(估计误差)的权衡,即:

  • k k k取较小值(模型复杂),预测实例较依赖于近邻样本,样本整体利用率低,模型对噪声数据敏感,且可能出现训练误差小(过拟合)、测试误差大的情况;
  • k k k值较大(模型简单),预测实例可利用较多的样本信息,模型抗干扰性强,但计算复杂,且可能出现训练误差大(欠拟合)、测试误差小的情况。
  • 实际运用中,一般通过 交叉验证 选取较小的最优 k k k值。

 (3)分类决策规则 k k k近邻算法中的分类决策规则往往是 多数表决规则(majority voting rule),即由输入实例 x x x的邻域 N k ( x ) N_k(x) Nk(x)中的多数类决定输入实例的分类。

 如果分类的损失函数为0-1损失函数,即

f : R n ⟶ { c 1 , c 2 , . . . , c K } f:R^{n}\longrightarrow \left \{ c_{1},c_{2},...,c_{K} \right \} f:Rn{c1,c2,...,cK}

 那么误分概率为

P ( Y ≠ f ( X ) ) = 1 − P ( Y = f ( X ) ) P\left ( Y\ne f\left ( X \right ) \right ) = 1 - P\left ( Y = f\left ( X \right ) \right ) P(Y=f(X))=1P(Y=f(X))

 如果涵盖 N k ( x ) N_k(x) Nk(x)的类别是 c j c_{j} cj,那么误分概率为:

1 k ∑ x i ∈ N k ( x ) I ( y i ≠ c j ) = 1 − 1 k ∑ x i ∈ N k ( x ) I ( y i = c j ) \frac{1}{k} \sum_{x_{i}\in N_k(x) } I\left ( y_{i}\ne c_{j} \right ) = 1 - \frac{1}{k} \sum_{x_{i}\in N_k(x) } I\left ( y_{i} = c_{j} \right ) k1xiNk(x)I(yi=cj)=1k1xiNk(x)I(yi=cj)

 以上推导结果说明,要最小化误分概率,就要使 ∑ x i ∈ N k ( x ) I ( y i = c j ) \sum_{x_{i}\in N_k(x) } I\left ( y_{i} = c_{j} \right ) xiNk(x)I(yi=cj) 最大,所以多数表决规则等价于经验风险最小化。

2.Python基于K-D树实现KNN算法

 KNN算法可以简单的基于线性搜索来实现,但是这样的方法是低效率的,没有实际应用意义。一般KNN算法可以基于K-D树来实现。K-D树的原理和实现请阅读我之前整理的文章《K-D树算法原理以及python实现》。以下是基于K-D树实现的KNN算法完整代码:

import random
from copy import deepcopy
from time import time

import numpy as np
from numpy.linalg import norm


def partition_sort(arr, k, key=lambda x: x):
    """
    以枢纽(位置k)为中心将数组划分为两部分, 
	枢纽左侧的元素不大于枢纽右侧的元素。

    :param arr: 待划分数组
    :param k: 枢纽前部元素个数
    :param key: 比较方式
    :return: None
    """
    start, end = 0, len(arr) - 1
    assert 0 <= k <= end
    while True:
        i, j, pivot = start, end, deepcopy(arr[start])
        while i < j:
            # 从右向左查找较小元素
            while i < j and key(pivot) <= key(arr[j]):
                j -= 1
            if i == j:
                break
            arr[i] = arr[j]
            i += 1
            # 从左向右查找较大元素
            while i < j and key(arr[i]) <= key(pivot):
                i += 1
            if i == j:
                break
            arr[j] = arr[i]
            j -= 1
        arr[i] = pivot

        if i == k:
            return
        elif i < k:
            start = i + 1
        else:
            end = i - 1


def max_heap_replace(heap, new_node, key=lambda x: x[1]):
    """
    大根堆替换堆顶元素

    :param heap: 大根堆/列表
    :param new_node: 新节点
    :return: None
    """
    heap[0] = new_node
    root, child = 0, 1
    end = len(heap) - 1
    while child <= end:
        if child < end and key(heap[child]) < key(heap[child + 1]):
            child += 1
        if key(heap[child]) <= key(new_node):
            break
        heap[root] = heap[child]
        root, child = child, 2 * child + 1
    heap[root] = new_node


def max_heap_push(heap, new_node, key=lambda x: x[1]):
    """
    大根堆插入元素

    :param heap: 大根堆/列表
    :param new_node: 新节点
    :return: None
    """
    heap.append(new_node)
    pos = len(heap) - 1
    while 0 < pos:
        parent_pos = pos - 1 >> 1 # 右移1位,相当于除以2,也就是取一半的值
        if key(new_node) <= key(heap[parent_pos]):
            break
        heap[pos] = heap[parent_pos]
        pos = parent_pos
    heap[pos] = new_node


class KDNode(object):
    """K-D树节点"""

    def __init__(self, data=None, label=None, left=None, right=None, axis=None, parent=None):
        """
        构造函数

        :param data: 数据
        :param label: 数据标签
        :param left: 左孩子节点
        :param right: 右孩子节点
        :param axis: 分割轴
        :param parent: 父节点
        """
        self.data = data
        self.label = label
        self.left = left
        self.right = right
        self.axis = axis
        self.parent = parent


class KDTree(object):
    """K-D树"""

    def __init__(self, X, y=None):
        """
        构造函数

        :param X: 输入特征集, n_samples*n_features
        :param y: 输入标签集, 1*n_samples
        """
        self.root = None
        self.y_valid = False if y is None else True
        self.create(X, y)

    def create(self, X, y=None):
        """
        构建K-D树

        :param X: 输入特征集, n_samples*n_features
        :param y: 输入标签集, 1*n_samples
        :return: KDNode
        """

        def create_(X, axis, parent=None):
            """
            递归生成K-D树

            :param X: 合并标签后输入集
            :param axis: 切分轴
            :param parent: 父节点
            :return: KDNode
            """
            n_samples = np.shape(X)[0]
            if n_samples == 0:
                return None
            mid = n_samples >> 1 # 右移1位,相当于除以2,也就是取一半的值
            partition_sort(X, mid, key=lambda x: x[axis])

            if self.y_valid:
                kd_node = KDNode(X[mid][:-1], X[mid][-1], axis=axis, parent=parent)
            else:
                kd_node = KDNode(X[mid], axis=axis, parent=parent)

            next_axis = (axis + 1) % k_dimensions
            kd_node.left = create_(X[:mid], next_axis, kd_node)
            kd_node.right = create_(X[mid + 1:], next_axis, kd_node)
            return kd_node

        print('building kd-tree...')
        k_dimensions = np.shape(X)[1]
        if y is not None:
            X = np.hstack((np.array(X), np.array([y]).T)).tolist()
        self.root = create_(X, 0)

    def search_knn(self, point, k, dist=None):
        """
        K-D树中搜索k个最近邻样本

        :param point: 样本点
        :param k: 近邻数
        :param dist: 度量方式
        :return:
        """

        def search_knn_(kd_node):
            """
            搜索k近邻节点

            :param kd_node: KDNode
            :return: None
            """
            if kd_node is None:
                return
            data = kd_node.data
            distance = p_dist(data)
            if len(heap) < k:
                # 向大根堆中插入新元素
                max_heap_push(heap, (kd_node, distance))
            elif distance < heap[0][1]:
                # 替换大根堆堆顶元素
                max_heap_replace(heap, (kd_node, distance))

            axis = kd_node.axis
            if abs(point[axis] - data[axis]) < heap[0][1] or len(heap) < k:
                # 当前最小超球体与分割超平面相交或堆中元素少于k个
                search_knn_(kd_node.left)
                search_knn_(kd_node.right)
            elif point[axis] < data[axis]:
                search_knn_(kd_node.left)
            else:
                search_knn_(kd_node.right)

        if self.root is None:
            raise Exception('kd-tree must be not null.')
        if k < 1:
            raise ValueError("k must be greater than 0.")

        # 默认使用2范数度量距离
        if dist is None:
            p_dist = lambda x: norm(np.array(x) - np.array(point))
        else:
            p_dist = lambda x: dist(x, point)

        heap = []
        search_knn_(self.root)
        return sorted(heap, key=lambda x: x[1])

    def search_nn(self, point, dist=None):
        """
        搜索point在样本集中的最近邻

        :param point:
        :param dist:
        :return:
        """
        return self.search_knn(point, 1, dist)[0]

    def pre_order(self, root=KDNode()):
        """先序遍历"""
        if root is None:
            return
        elif root.data is None:
            root = self.root

        yield root
        for x in self.pre_order(root.left):
            yield x
        for x in self.pre_order(root.right):
            yield x

    def lev_order(self, root=KDNode(), queue=None):
        """层次遍历"""
        if root is None:
            return
        elif root.data is None:
            root = self.root

        if queue is None:
            queue = []

        yield root
        if root.left:
            queue.append(root.left)
        if root.right:
            queue.append(root.right)
        if queue:
            for x in self.lev_order(queue.pop(0), queue):
                yield x

    @classmethod
    def height(cls, root):
        """kd-tree深度"""
        if root is None:
            return 0
        else:
            return max(cls.height(root.left), cls.height(root.right)) + 1

from collections import Counter

# 用于计算出现频次最高的元素
# Counter([0, 1, 1, 2, 2, 3, 3, 4, 3, 3]).most_common(1)

class KNeighborsClassifier(object):
    """K近邻分类器"""

    def __init__(self, k, dist=None):
        """构造函数"""
        self.k = k
        self.dist = dist
        self.kd_tree = None

    def fit(self, X, y):
        """建立K-D树"""
        print('fitting...')
        X = self._data_processing(X)
        self.kd_tree = KDTree(X, y)

    def predict(self, X):
        """预测类别"""
        if self.kd_tree is None:
            raise TypeError('Classifier must be fitted before predict!')
        search_knn = lambda x: self.kd_tree.search_knn(point=x, k=self.k, dist=self.dist)
        y_ptd = []
        X = (X - self.x_min) / (self.x_max - self.x_min)
        for x in X:
            y = int(Counter(r[0].label for r in search_knn(x)).most_common(1)[0][0])
            y_ptd.append(y)
        return y_ptd

    def score(self, X, y):
        """预测正确率"""
        y_ptd = self.predict(X)
        correct_nums = len(np.where(np.array(y_ptd) == np.array(y))[0])
        return correct_nums / len(y)

    def _data_processing(self, X):
        """数据归一化"""
        X = np.array(X)
        self.x_min = np.min(X, axis=0)
        self.x_max = np.max(X, axis=0)
        X = (X - self.x_min) / (self.x_max - self.x_min)
        return X

3.基于KNN算法的约会网站数据集分类实战

 现在我们利用以上实现的KNN算法,对 约会网站数据集进行分类。

# 读取约会网站数据集
import pandas as pd

df = pd.read_csv("datingTestSet2.txt", header=None, sep="\t", 
                 names=['f1', 'f2', 'f3', 'label'])
df.tail()

约会网站数据集 图1

# 数据集有3列特征值,1列标签值。数据没有异常信息。

# 可视化
import matplotlib.pyplot as plt

fig = plt.figure(figsize=(14, 4))
ax = fig.add_subplot(131)
ax.scatter(df['f2'], df['f3'], 1.5*df['label'], 1.5*df['label'])
ax = fig.add_subplot(132)
ax.scatter(df['f1'], df['f3'], 1.5*df['label'], 1.5*df['label'])
ax = fig.add_subplot(133)
ax.scatter(df['f1'], df['f2'], 1.5*df['label'], 1.5*df['label'])
plt.show()

约会网站数据集 图2

# 通过可视化看出,采用特征f1和f2可以得到较好的分类效果。

# 切分数据集
from sklearn.model_selection import train_test_split

df_train, df_test = train_test_split(df, test_size=0.1, random_state=1234)

# 训练knn模型
knn = KNeighborsClassifier(k=3)
knn.fit(df_train[['f1','f2','f3']].values, df_train['label'].values)

# 预测
prd = knn.predict(df_test[['f1','f2','f3']].values)
print(prd[:10])
print(df_test['label'].values.tolist()[:10])

# 预测分数
score = knn.score(df_test[['f1','f2','f3']].values, df_test['label'].values)
print(score)

约会网站数据集 图3

4.参考文章

1.《统计学习方法》第2版:第3章k近邻法。作者:李航
2.K近邻(KNN)算法、KD树及其python实现
3.从K近邻算法、距离度量谈到KD树、SIFT+BBF算法
4.《机器学习实战》:第2章k近邻算法。作者:Peter Harrington

  • 24
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值