假设你刚搬到一座陌生城市,想判断一家新餐馆是不是合你口味。一个很自然的办法是:找到和你口味最接近的几位朋友,听听多数人的意见。K 近邻算法做的事情几乎一模一样,只不过它找的“朋友”是数据里的已知样本。

读完你会做到

  • 看懂“特征、距离、邻居、投票”分别是什么
  • 不用代码也能手算一次 KNN 分类
  • 从零写出一个能复用的 Python 分类器
  • 知道怎样选 K、为什么要归一化,以及怎样判断模型好不好

一、KNN 到底在做什么

KNN 是一种监督学习算法。“监督”并不是有人盯着程序,而是训练数据已经带着正确答案。每一条旧数据都包含两部分:

当一条没有标签的新数据出现时,KNN 只做三件事:计算它到所有旧数据的距离,找出最近的 K 个邻居,让这些邻居投票。

KNN 邻居投票示意图:待预测的新样本周围最近的五个邻居中,蓝色有三个,红色有两个,所以预测为蓝色类别
图 1:当 K = 5 时,虚线圈内有 3 个蓝色圆点和 2 个红色三角形,新样本因此被预测为蓝色类别。

这里的字母 K 表示“要参考几个邻居”。如果 K = 1,只听最近那个人的;如果 K = 5,就听最近五个人的多数意见。KNN 的核心因此可以压缩成一句话:

离我最近的 K 个已知样本,多数属于哪一类,我就预测为哪一类。

二、先不写代码,手算一次

我们用一个只有两个特征的小例子。现在有 6 个已经分类的样本,每个样本都可以画成平面上的一个点。A 类和 B 类各有 3 个,新样本 X 的坐标是 (3, 3)

样本 横坐标 x 纵坐标 y 已知标签
A11.01.0A 类
A21.52.0A 类
A32.01.5A 类
B14.04.0B 类
B24.55.0B 类
B35.04.5B 类

第 1 步:算出新样本到每个旧样本的距离

平面上两点之间常用欧氏距离,也就是直尺量出来的直线距离。新样本 X 是 (3, 3),A2 是 (1.5, 2),它们的距离为:

d(X, A2) = √[(3 − 1.5)² + (3 − 2)²] ≈ 1.80

对另外 5 个样本重复同样的计算,得到:

1B11.41
2A21.80
3A31.80
4B22.50
5B32.50
6A12.83

第 2 步:取最近的 K 个邻居

K = 3,那么只看距离最小的三个样本:B1、A2、A3。

第 3 步:投票

B 类得到 1 票,A 类得到 2 票,所以新样本 X 被预测为 A 类。到这里,你已经完整执行了一次 KNN 分类。

三、把手算过程翻译成 Python

下面先安装并导入 NumPy。它能让我们一次计算新样本到所有训练样本的距离。

python -m pip install numpy

这是一个完整、可直接运行的 KNN 分类器。代码只有四个核心动作:相减、求距离、排序、投票。

from collections import Counter
import numpy as np


def knn_predict(train_features, train_labels, new_sample, k=3):
    """根据最近的 k 个训练样本,预测 new_sample 的类别。"""
    features = np.asarray(train_features, dtype=float)
    sample = np.asarray(new_sample, dtype=float)

    if features.ndim != 2:
        raise ValueError("train_features 必须是二维数据")
    if len(features) != len(train_labels):
        raise ValueError("每个训练样本都必须有一个标签")
    if sample.shape != (features.shape[1],):
        raise ValueError("新样本的特征数量必须和训练数据一致")
    if not 1 <= k <= len(features):
        raise ValueError("k 必须在 1 到训练样本数量之间")

    # 1. 新样本分别减去每个训练样本
    differences = features - sample

    # 2. 对每一行计算欧氏距离
    distances = np.sqrt(np.sum(differences ** 2, axis=1))

    # 3. 取得距离最小的 k 个样本下标
    nearest_indices = np.argsort(distances)[:k]
    nearest_labels = [train_labels[index] for index in nearest_indices]

    # 4. 返回票数最多的标签
    predicted_label = Counter(nearest_labels).most_common(1)[0][0]
    return predicted_label


train_features = np.array([
    [1.0, 1.0],
    [1.5, 2.0],
    [2.0, 1.5],
    [4.0, 4.0],
    [4.5, 5.0],
    [5.0, 4.5],
])
train_labels = ["A", "A", "A", "B", "B", "B"]

result = knn_predict(train_features, train_labels, [3.0, 3.0], k=3)
print(f"预测结果:{result} 类")

运行后会得到:

预测结果:A 类

逐行理解最关键的四句

  1. features - sample:让每个旧样本分别减去新样本,得到各个特征相差多少。
  2. np.sum(differences ** 2, axis=1):先把差值平方,再按行相加。axis=1 可以理解成“每个样本自己算自己的”。
  3. np.argsort(distances)[:k]:把距离从小到大排序,只取前 K 个样本的下标。
  4. Counter(...).most_common(1):统计邻居标签的票数,并取票数最多的类别。

真实项目里还需要约定“票数相同时怎么办”。常见做法是让距离更近的邻居优先,或者给越近的邻居越高的投票权重。后面的 scikit-learn 示例可以直接使用距离加权。

四、K 应该选多大

K 没有一个适合所有数据集的固定答案。它决定算法听多少人的意见,也决定模型是更敏感还是更平滑。

不同 K 值的效果对比:K 等于 1 时容易受噪声影响,K 等于 5 时边界较平衡,K 等于 15 时可能忽略局部规律
图 2:K 太小容易被单个异常点带偏;K 太大又可能把真正的局部差异抹平。

K 太小

模型非常相信最近的少数样本。边界会很曲折,对噪声和错误标签敏感,容易过拟合

K 太大

很远的样本也加入投票,局部规律可能被多数类淹没,容易欠拟合

适合初学者的选择方法

  1. 先从较小的奇数开始,例如 3、5、7、9。二分类时用奇数可以减少平票。
  2. 把数据分成训练集和验证集。训练集用来提供邻居,验证集用来模拟未见过的数据。
  3. 让多个 K 值分别预测验证集,记录准确率。
  4. 选择验证效果好、同时不过分大的 K,而不是挑训练集上最好的 K。

常见经验是先尝试不超过训练样本数平方根附近的若干奇数,但这只是搜索起点,不是定律。最终要以验证结果为准。

五、为什么必须注意数据尺度

假设我们用“年龄”和“年收入”判断用户类型。年龄通常是几十,年收入可能是几十万。两个人年龄差 10 岁、收入差 10 万元,直接计算距离时,收入这一列会完全压过年龄这一列。

原始距离 ≈ √(10² + 100000²)

这不代表收入一定比年龄重要,只是它的数字单位更大。解决方法是特征缩放,把不同列变到可比较的范围。最常见的两种方式是:

六、用 scikit-learn 完成一次正规训练

从零实现适合理解原理;实际项目更建议使用成熟库。下面用鸢尾花数据集完成一个完整流程:划分数据、标准化、训练、预测、评价。

python -m pip install scikit-learn
from sklearn.datasets import load_iris
from sklearn.metrics import accuracy_score, classification_report
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler


# 1. 读取数据:X 是花朵特征,y 是花的品种
iris = load_iris()
X, y = iris.data, iris.target

# 2. 留出 20% 作为测试集;stratify 保持各类别比例接近
X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.2,
    random_state=42,
    stratify=y,
)

# 3. Pipeline 保证先用训练集完成标准化,再训练 KNN
model = make_pipeline(
    StandardScaler(),
    KNeighborsClassifier(n_neighbors=5, weights="distance"),
)

# 4. 训练、预测、评价
model.fit(X_train, y_train)
y_pred = model.predict(X_test)

print(f"准确率:{accuracy_score(y_test, y_pred):.2%}")
print(classification_report(y_test, y_pred, target_names=iris.target_names))

# 5. 预测一朵新花;四个数字依次对应四项花朵特征
new_flower = [[5.1, 3.5, 1.4, 0.2]]
predicted_index = model.predict(new_flower)[0]
print("预测品种:", iris.target_names[predicted_index])

这里有三个容易忽略的细节:

七、怎样找到更合适的 K

只试一个 K 不够可靠。下面用 5 折交叉验证比较 120。所谓 5 折,就是把训练数据轮流分成 5 份:每次用其中 4 份训练、1 份验证,最后取 5 次成绩的平均值。

from sklearn.model_selection import cross_val_score


best_k = None
best_score = 0.0

for k in range(1, 21):
    candidate = make_pipeline(
        StandardScaler(),
        KNeighborsClassifier(n_neighbors=k),
    )
    scores = cross_val_score(candidate, X_train, y_train, cv=5)
    mean_score = scores.mean()

    print(f"K={k:2d},平均验证准确率={mean_score:.3f}")

    if mean_score > best_score:
        best_k = k
        best_score = mean_score

print(f"推荐 K={best_k},平均验证准确率={best_score:.3f}")

数据量更大、参数更多时,可以使用 GridSearchCV 自动搜索。无论用哪种工具,原则都一样:用验证过程选参数,测试集只留到最后做一次客观检查。

八、KNN 能做什么,不能做什么

适合它的情况

  • 数据量不大,特征含义清楚
  • 相近样本通常拥有相近答案
  • 需要一个容易解释的入门基线
  • 分类边界不一定是直线

需要谨慎的情况

  • 样本很多,预测时逐个算距离会变慢
  • 特征特别多,距离会逐渐失去区分度
  • 无关特征多,噪声会干扰邻居关系
  • 类别极不平衡,多数类容易占据投票优势

KNN 也能做回归。分类时邻居投票;回归时不投类别,而是把邻居的数值答案求平均。例如根据附近房屋的面积、楼龄和位置,估计一套房子的价格。

九、新手最常踩的 6 个坑

  1. 忘记标准化。数字范围最大的特征主导了距离,模型学到的不是你想表达的重要性。
  2. 直接在测试集上挑 K。测试集就不再客观。应该在训练集内部用验证集或交叉验证选 K。
  3. K 比训练样本还大。邻居根本不够,程序应提前检查并报出清楚的错误。
  4. 认为 K 越小越精确。K = 1 虽然能记住训练数据,却可能被噪声轻易带偏。
  5. 只看总体准确率。类别不平衡时,还要看每一类的精确率、召回率和混淆矩阵。
  6. 把类别编号当连续数值。邮编、商品编号等只是标识,数字差值通常没有距离意义,不能直接放进欧氏距离。

十、用一张清单完成自己的 KNN 项目

  1. 01
    定义问题

    要预测的是类别还是连续数值?哪些旧数据带有可靠答案?

  2. 02
    准备特征

    删除明显无关的信息,把文字类别正确编码,并处理缺失值。

  3. 03
    划分数据

    先保留测试集;调参只在训练集内部进行。

  4. 04
    统一尺度

    只在训练集上拟合标准化器,并用同一规则转换其余数据。

  5. 05
    选择 K

    用验证集或交叉验证比较一组候选值。

  6. 06
    最终评价

    在从未参与调参的测试集上检查模型,并记录完整流程。

最后,把 KNN 记成四个词

距离排序邻居投票

看到新样本,先算它和所有旧样本的距离;把距离从近到远排序;取前 K 个邻居;最后让邻居投票。KNN 的全部骨架就在这里。

建议你接下来亲手改动示例中的新样本坐标,再把 K 从 1 改到 3、5,观察预测何时发生变化。能预测变化方向,比只复制一次代码更接近真正学会。