KNN 的 “距离度量” 选择:欧氏距离 vs 曼哈顿距离,如何适配不同数据分布?(附高维数据降维后 KNN 优化)
如果你对机器学习充满好奇,那么 K-最近邻(K-Nearest Neighbors, KNN)算法很可能是你最先接触到的几个算法之一。它的核心思想简单得就像一句我们从小就听熟的话:“物以类聚,人以群分”。没错,KNN 就是这么直观——一个未知样本的类别,由它在特征空间里最亲密的 K 个邻居来决定。
然而,正是这份“简单”,让许多初学者陷入了一个误区:认为 KNN 不需要太多思考就能用好。但现实往往是,当我们将一个基础的 KNN 模型应用到真实数据上时,效果却不尽如人意。问题出在哪里?
答案藏在细节里。比如,我们如何定义“邻居”的“远近”?是两点之间的直线距离,还是像在城市街区里开车那样,只能沿着横竖街道走?这背后是两种不同的“距离度量”:欧氏距离和曼哈顿距离。选择哪一个,完全取决于你的数据长什么样。
更进一步,如果你的数据特征非常多(比如成百上千个维度),KNN 还会遭遇一个著名的难题——“维度诅咒”。在高维空间里,所有的数据点都互相离得很远,KNN 赖以生存的“近邻”概念几乎失效。
本篇文章,就将带你深入 KNN 的“心脏地带”,解决这两个核心问题。我们将一起:
- 动手实践:从零开始搭建一个 Python 实验环境。
- 深入对比:通过代码和可视化,直观感受欧氏距离和曼哈顿距离在不同数据分布下的表现差异。
- 学习优化:引入强大的 PCA 降维技术,看它如何“拯救”陷入维度诅咒的 KNN 算法。
准备好了吗?让我们一起揭开 KNN 算法简单外表下的深刻内涵。
实验准备:搭建一个干净的 Python 环境
在开始任何编程项目之前,一个良好且隔离的开发环境是成功的关键。这可以确保我们项目中使用的库版本不会与你电脑上其他项目的库发生冲突。我们将使用 Python 自带的 venv 工具来创建一个虚拟环境。
第一步:创建项目文件夹
首先,打开你的终端(在 macOS 或 Linux 上是 Terminal,在 Windows 上是 PowerShell 或命令提示符),为我们的项目创建一个专属的“房间”。
# 创建一个名为 knn-distance-project 的文件夹
mkdir knn-distance-project
# 进入这个新创建的文件夹
cd knn-distance-project
这个操作很简单,mkdir (make directory) 用于创建文件夹,cd (change directory) 用于切换当前工作目录。
第二步:创建并激活虚拟环境
在项目文件夹内,我们来创建一个名为 venv 的虚拟环境。
# 使用 python3 的 venv 模块创建一个名为 venv 的虚拟环境
python3 -m venv venv
执行完毕后,你会看到项目文件夹下多了一个 venv 子文件夹,这里面包含了 Python 解释器的一个独立副本和相关的管理脚本。
接下来,我们需要“激活”这个环境,让终端后续的命令都在这个隔离的环境中执行。
对于 macOS 或 Linux 用户:
# 激活虚拟环境
source venv/bin/activate
对于 Windows 用户:
# 激活虚拟环境
.\venv\Scripts\Activate.ps1
激活成功后,你通常会在终端提示符的前面看到 (venv) 的字样,这表示你已经成功进入了我们为项目准备的“无尘室”。
第三步:安装核心库
在这个干净的环境里,我们可以安装本次实验所需要的“兵器”了。我们将安装三个非常流行的 Python 库。
# 使用 pip 安装 numpy, scikit-learn, 和 matplotlib
pip install numpy scikit-learn matplotlib
让我们来解释一下这三个库各自的角色:
numpy:是 Python 科学计算的基石。它提供了强大的多维数组对象和丰富的数学函数,是处理数据的基础。scikit-learn:机器学习领域的“瑞士军刀”。它内置了包括 KNN 在内的大量机器学习算法、预处理工具、数据集等,极大地方便了我们的实验。matplotlib:一个强大的数据可视化库。它能帮助我们将抽象的数据和算法结果“画”出来,让理解变得直观和简单。
至此,我们的实验环境已经准备就绪。接下来,让我们深入核心概念。
核心概念:用“距离”丈量数据的相似度
KNN 算法的决策过程可以用下面这幅流程图来概括:
graph TD
A[未知样本 X] --> B{计算 X 与所有训练样本的距离};
B --> C{找到距离最近的 K 个邻居};
C --> D{K 个邻居进行“投票”};
D --> E[根据投票结果<br/>(少数服从多数)<br/>决定 X 的类别];
这个流程中最关键的一步,就是 “计算距离”。所谓距离,本质上是一种将两个样本在特征空间中差异“数值化”的方式。这个数值,就代表了它们的相似程度。我们主要探讨两种最常用的距离度量。
欧氏距离 (Euclidean Distance)
这是我们最熟悉、最直观的距离概念。在二维或三维空间中,它就是两点之间的直线距离。
其数学公式为:
[ d_{euclidean}(A, B) = \sqrt{\sum_{i=1}^{n}(A_i - B_i)^2} ]
其中,( A ) 和 ( B ) 是两个样本,( n ) 是特征维度,( A_i ) 和 ( B_i ) 分别是它们在第 ( i ) 个维度上的坐标。
- 直观理解:勾股定理在多维空间中的推广。
- 适用场景:当数据的各个特征维度都是同一尺度(或者已经经过标准化处理),并且数据分布在空间中呈现“团状”或球形时,欧氏距离通常能很好地度量样本间的真实相似度。
曼哈顿距离 (Manhattan Distance)
想象一下你在一个像曼哈顿那样的城市里,街道都是横平竖直的。你不能斜着穿过大楼,只能沿着街道走。从一个十字路口到另一个十字路口的距离,就是你在各个坐标轴上移动距离的总和。
其数学公式为:
[ d_{manhattan}(A, B) = \sum_{i=1}^{n}|A_i - B_i| ]
- 直观理解:城市街区距离,计算各个坐标轴上的距离差之和。
- 适用场景:当数据特征的尺度不一致时,曼哈顿距离会比欧氏距离更加鲁棒。此外,在一些具有明显“网格”或“路径”结构的数据中,或者在高维数据中,它有时会表现出比欧氏距离更优的性能,因为它对单个维度的巨大差异(异常值)不如欧氏距离敏感(欧氏距离有平方操作,会放大差异)。
理论已经铺垫完毕,是时候通过代码来亲眼见证实战效果了。
实战对比:距离度量如何影响分类结果?
我们将设计一个实验,在两种形态完全不同的数据集上,分别使用欧氏距离和曼哈顿距离的 KNN 算法,并通过可视化的决策边界来观察它们的差异。
第一步:创建 Python 脚本
在你的 knn-distance-project 文件夹中,创建一个名为 knn_distance_comparison.py 的文件。
第二步:编写代码
将以下代码完整地复制到你的 knn_distance_comparison.py 文件中。代码中包含了详细的注释,解释了每一步的作用。
# 导入所需的库
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.datasets import make_blobs
from matplotlib.colors import ListedColormap
def run_knn_and_plot(X, y, distance_metric, title):
"""
一个封装了 KNN 训练、预测和决策边界可视化过程的函数。
参数:
X (np.array): 特征数据
y (np.array): 标签数据
distance_metric (str): 'euclidean' 或 'manhattan'
title (str): 图表的标题
"""
# 将数据划分为训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
# 初始化 KNN 分类器,设置 K=5,并指定距离度量
# 这里的 metric 参数是关键,我们通过它来选择使用哪种距离
knn = KNeighborsClassifier(n_neighbors=5, metric=distance_metric)
# 训练模型
knn.fit(X_train, y_train)
# 在测试集上评估模型
accuracy = knn.score(X_test, y_test)
print(f"数据集: '{title}', 距离度量: {distance_metric}, 准确率: {accuracy:.2f}")
# --- 决策边界可视化 ---
# 设置网格的步长和边界
h = .02
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1
# 创建网格点
xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
np.arange(y_min, y_max, h))
# 使用训练好的模型对网格上每个点进行预测
Z = knn.predict(np.c_[xx.ravel(), yy.ravel()])
# 将预测结果重塑为网格形状
Z = Z.reshape(xx.shape)
# 绘制决策边界和数据点
plt.figure(figsize=(8, 6))
cmap_light = ListedColormap(['#FFAAAA', '#AAFFAA'])
cmap_bold = ListedColormap(['#FF0000', '#00FF00'])
plt.contourf(xx, yy, Z, cmap=cmap_light)
# 绘制训练数据点
plt.scatter(X_train[:, 0], X_train[:, 1], c=y_train, cmap=cmap_bold, edgecolor='k', s=20)
plt.title(f"{title}\nMetric: {distance_metric}, Accuracy: {accuracy:.2f}")
plt.xlabel("Feature 1")
plt.ylabel("Feature 2")
# --- 实验一:在标准球形分布的数据集上进行测试 ---
# 生成球形分布的数据
X1, y1 = make_blobs(n_samples=200, centers=2, random_state=42, cluster_std=1.2)
# 使用欧氏距离
run_knn_and_plot(X1, y1, 'euclidean', '球形分布数据 (Blobs)')
# 使用曼哈顿距离
run_knn_and_plot(X1, y1, 'manhattan', '球形分布数据 (Blobs)')
# --- 实验二:在轴向分布的数据集上进行测试 ---
# 手动创建沿坐标轴分布的数据
np.random.seed(42)
# 第一类数据,主要沿 x 轴分布
class1_x = np.random.uniform(-5, 5, 50)
class1_y = np.random.normal(0, 0.5, 50)
# 第二类数据,主要沿 y 轴分布
class2_x = np.random.normal(0, 0.5, 50)
class2_y = np.random.uniform(-5, 5, 50)
# 合并数据
X2 = np.vstack((np.c_[class1_x, class1_y], np.c_[class2_x, class2_y]))
y2 = np.array([0] * 50 + [1] * 50)
# 使用欧氏距离
run_knn_and_plot(X2, y2, 'euclidean', '轴向分布数据 (Axial)')
# 使用曼哈顿距离
run_knn_and_plot(X2, y2, 'manhattan', '轴向分布数据 (Axial)')
# 显示所有图表
plt.show()
第三步:运行脚本并分析结果
在激活了虚拟环境的终端中,运行这个 Python 脚本:
python knn_distance_comparison.py
程序会输出四个准确率,并弹出四张图。让我们来仔细分析这些结果。
结果分析:
-
对于球形分布的数据:
- 你会看到欧氏距离和曼哈顿距离的准确率非常接近,可能完全相同。
- 观察决策边界图,欧氏距离画出的分界线通常是一条平滑的曲线,非常符合我们对“中分线”的直观感觉。曼哈顿距离的边界则可能呈现出一些“阶梯状”的棱角。
- 结论:在特征均衡、分布规整的数据上,两种距离度量表现相当,欧氏距离因其平滑性而更符合直觉。
-
对于轴向分布的数据:
- 在这里,你很可能会看到曼哈顿距离的准确率高于欧氏距离。
- 观察决策边界图,你会发现一个有趣的现象:欧氏距离试图用一条平滑的斜线来分割数据,但这显然不符合数据的真实分布,导致了一些错误分类。而曼哈顿距离的决策边界呈现出明显的“十字”或阶梯形状,完美地适应了数据沿着坐标轴分布的特性。
- 结论:当数据的内在结构与坐标轴高度相关时,曼哈顿距离能够更好地捕捉这种结构,从而获得更好的分类效果。
这个实验清晰地证明了:没有绝对最好的距离度量,只有最适合你的数据形态的度量。
挑战升级:当 KNN 遭遇“维度诅咒”
我们刚才的实验都是在二维空间中进行的,所以可以很方便地可视化。但在真实世界中,数据往往有几十、几百甚至上千个特征维度。当维度急剧增加时,KNN 算法会面临一个巨大的挑战——维度诅咒 (Curse of Dimensionality)。
什么是维度诅咒?
这听起来很玄乎,但可以用一个简单的比喻来理解。
- 一维空间:想象一条 100 米的线段,你想找离你 10 米内的人,这很容易,覆盖了线段的 20%。
- 二维空间:现在是一个 100x100 米的正方形广场,你想覆盖 20% 的面积,你需要一个半径约 25 米的圈。
- 三维空间:在一个 100x100x100 米的立方体中,要覆盖 20% 的体积,你需要一个半径约 37 米的球。
发现了吗?为了覆盖同样比例的空间,你需要的“半径”随着维度的增加而急剧增大。换句话说,在高维空间中,数据点会变得极其稀疏,任意两个点之间的距离都倾向于变得很大且彼此相等。
graph LR
subgraph 低维空间 (特征少)
A((点A)) -- 近 --> B((邻居B));
A -- 远 --> C((远点C));
end
subgraph 高维空间 (特征多)
D((点D)) -- 都很远 --> E((点E));
D -- 都很远 --> F((点F));
end
低维空间 --> |维度增加| 高维空间;
这对 KNN 是致命的,因为它的决策完全依赖于“近邻”这个概念。如果所有邻居都离你很“远”,那么“最近的 K 个邻居”也就失去了意义,算法的性能会急剧下降。
破局之法:数据降维
如何打破这个诅咒?答案是:降维。我们尝试将数据从高维空间投影到低维空间,同时尽可能地保留其原始的主要信息。
最常用的一种线性降维方法叫做 主成分分析 (Principal Component Analysis, PCA)。PCA 的核心思想是,在数据中找到一组新的坐标轴(称为主成分),第一个主成分指向数据方差最大的方向,第二个主成分指向与第一个正交且方差次大的方向,以此类推。我们只保留前几个最重要的主成分,从而在丢失最少信息的前提下,大幅减少数据维度。
终极优化:PCA 降维与 KNN 的强强联合
现在,我们将通过一个实战案例,在一个真实的高维数据集上,对比“直接使用 KNN”和“先 PCA 降维再用 KNN”的效果差异。
第一步:创建新脚本
在你的项目文件夹中,创建第二个脚本文件 knn_pca_optimization.py。
第二步:编写代码
我们将使用 scikit-learn 中内置的手写数字数据集。这个数据集包含了大量 8x8 像素的数字灰度图,每个图像有 64 个特征(像素点),是一个典型的高维数据。
# 导入所需的库
import time
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.metrics import accuracy_score
# 加载手写数字数据集 (64 维)
digits = load_digits()
X, y = digits.data, digits.target
# 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
# --- 实验组 A: 直接在高维数据上应用 KNN ---
print("--- 实验组 A: 直接在 64 维数据上使用 KNN ---")
# 标准化数据
scaler_A = StandardScaler()
X_train_scaled_A = scaler_A.fit_transform(X_train)
X_test_scaled_A = scaler_A.transform(X_test)
# 初始化并训练 KNN
knn_A = KNeighborsClassifier(n_neighbors=5, metric='euclidean')
start_time_A = time.time()
knn_A.fit(X_train_scaled_A, y_train)
y_pred_A = knn_A.predict(X_test_scaled_A)
end_time_A = time.time()
accuracy_A = accuracy_score(y_test, y_pred_A)
duration_A = end_time_A - start_time_A
print(f"准确率: {accuracy_A:.4f}")
print(f"运行时间: {duration_A:.4f} 秒")
print("-" * 40)
# --- 实验组 B: 先 PCA 降维,再应用 KNN ---
print("--- 实验组 B: PCA 降维 (至 10 维) + KNN ---")
# 使用 Pipeline 将标准化、PCA 和 KNN 串联起来
# 这是 scikit-learn 中一个非常强大的工具,可以简化工作流
pipeline_B = Pipeline([
('scaler', StandardScaler()), # 第一步:标准化
('pca', PCA(n_components=10)), # 第二步:PCA降维,从64维降到10维
('knn', KNeighborsClassifier(n_neighbors=5, metric='euclidean')) # 第三步:KNN分类
])
start_time_B = time.time()
pipeline_B.fit(X_train, y_train)
y_pred_B = pipeline_B.predict(X_test)
end_time_B = time.time()
accuracy_B = accuracy_score(y_test, y_pred_B)
duration_B = end_time_B - start_time_B
print(f"准确率: {accuracy_B:.4f}")
print(f"运行时间: {duration_B:.4f} 秒")
print("-" * 40)
# --- 结果对比 ---
print("\n--- 性能对比 ---")
print(f"直接 KNN (64维) -> 准确率: {accuracy_A:.4f}, 耗时: {duration_A:.4f}s")
print(f"PCA + KNN (10维) -> 准确率: {accuracy_B:.4f}, 耗时: {duration_B:.4f}s")
第三步:运行并解读结果
在终端中运行这个新脚本:
python knn_pca_optimization.py
你会得到类似下面这样的输出(具体数值可能因机器性能略有差异):
--- 实验组 A: 直接在 64 维数据上使用 KNN ---
准确率: 0.9833
运行时间: 0.0150 秒
----------------------------------------
--- 实验组 B: PCA 降维 (至 10 维) + KNN ---
准确率: 0.9852
运行时间: 0.0050 秒
----------------------------------------
--- 性能对比 ---
直接 KNN (64维) -> 准确率: 0.9833, 耗时: 0.0150s
PCA + KNN (10维) -> 准确率: 0.9852, 耗时: 0.0050s
结果解读:
从这个对比中,我们可以清晰地看到:
- 运行效率:经过 PCA 降维后,KNN 的训练和预测时间大幅缩短(在这个例子中可能快了 2-3 倍)。这是因为计算样本间距离的复杂度与维度成正比,维度越低,计算越快。
- 分类准确率:令人惊喜的是,降维后的模型的准确率甚至略有提升!这可能是因为 PCA 在降维的同时,也帮助去除了原始数据中的一些噪声,使得 KNN 能够更好地抓住数据的主要模式。
这个实验有力地证明了,PCA 降维是应对维度诅咒、优化高维数据下 KNN 性能的强大武器。
388



被折叠的 条评论
为什么被折叠?



