04 最近邻分类器 (K-Nearest Neighbors)

90 分钟学习安排:距离 → 邻居 → 样本外选择

  • 目标: 手算并解释距离;解释训练期标准化;在预设欧氏距离与等权投票下用时间验证选择 \(k\);诊断高维距离集中。

  • 学习顺序:

    • 距离 15 分钟 → 标准化和 \(k\) 20 分钟 → 偏差—方差与高维距离 20 分钟 → 福耀玻璃案例 25 分钟 → 练习与反馈 10 分钟。

    • 加权 KNN、回归和 KD 树可在课后选学。

  • 先修先答: \((0,0)\)\((3,4)\) 的欧氏距离和曼哈顿距离分别是多少?

  • 反馈: 欧氏距离为 \(5\),曼哈顿距离为 \(7\);应由业务相似性的含义选择距离,而非默认套用。

今天的议题:信用风险评估

假设你是一位信贷经理,一位新客户前来申请贷款。

你的核心任务是:

  • 评估风险:这位客户未来违约的可能性有多大?
  • 做出决策:是批准还是拒绝这笔贷款?

这是一个典型的商业分类问题

客户特征 → 距离度量 → 近邻投票 → 风险决策

数据驱动的解决方案

你并非毫无根据地猜测。你的银行拥有宝贵的历史数据。

  • 现有数据: 数千名历史客户的完整记录。
    • 特征 (Features): 收入、负债、年龄、职业…
    • 结果 (Outcome): 按时还款 ✅ 或 违约 ❌
  • 我们的方法: 利用这些数据,为新客户的风险画像,这就是最近邻 (KNN) 算法的用武之地。

历史客户记录 → 标准化 → 找到近邻 → 预测新客户类别

KNN的核心思想:“物以类聚,人以群分”

KNN算法的哲学非常简单:要判断一个新样本的类别,只需看离它最近的几个“邻居”属于哪个类别。

就像你要判断一个陌生人的兴趣爱好,最简单的方法就是看看他交往最密切的朋友们都是些什么样的人。

KNN的核心思想:“物以类聚,人以群分” 左侧以问号表示待分类样本,虚线圆内只有三个由距离线连接的最近邻,其中两个红色圆点属于类别 A,一个绿色菱形属于类别 B。右侧投票面板显示类别 A 获得两票、类别 B 获得一票,因此最终预测为类别 A。 类别 A 类别 B ? k=3:虚线圈内的 3 个近邻 多数投票 3 个近邻 类别 A:2 票 类别 B:1 票 预测结果 类别 A

KNN的商业类比

机器学习概念 商业场景类比 (信用评估)
新样本 (未知类别) 一位新的贷款申请人
邻居 (已知类别) 数据库中与新申请人情况最相似的历史客户
类别 ‘会违约’ 或 ‘不会违约’
“近”的度量 客户特征的相似度 (如收入、年龄、负债)
K值 我们参考多少个相似的客户来做决定?

本章学习内容概览

  1. 核心内容|先定义近邻规则:距离、\(k\) 与投票如何共同产生预测。
  2. 核心内容|先缩放再找邻居:只在训练期拟合标准化器,解释尺度为何改变邻居。
  3. 核心内容|按时间选择模型:事先确定欧氏距离与等权投票,只用训练期时间顺序折选择 \(k\)
  4. 核心内容|测试期检查:确定模型后一次报告类别比例、ROC-AUC、AP 与混淆矩阵,并说明边界。
  5. 拓展内容|机制与规模:加权 KNN、回归、KD/Ball Tree 与高维概率推导;核心内容只保留一个归一化距离直觉和算法含义。

4.1 最近邻规则 (k-NN) 的正式定义

给定一个待分类样本 \(x_n\),KNN的决策过程分为两步:

  1. 寻找邻居: 在整个数据集中,找出与 \(x_n\) 距离最近\(k\) 个样本。
  2. 投票决策: 在这 \(k\) 个邻居中,采用多数表决的方式,将得票最多的那个类别作为 \(x_n\) 的预测类别。

\[ \large{y_n = \underset{c \in \mathcal{Y}}{\arg\max} \sum_{i=1}^k I(y_i = c)} \tag{1}\]

其中,\(I(\cdot)\) 是指示函数,如果邻居 \(i\) 的类别 \(y_i\) 等于类别 \(c\),则为1,否则为0。这个公式本质上就是在数票数

理解KNN的三个关键要素

要成功应用KNN算法,我们必须明确定义三个核心要素。它们是构建任何KNN模型的基石。

  • 距离度量 (Distance Metric)
    • 如何量化样本之间的“相似性”?
  • \(k\) 值的选择 (The Choice of \(k\))
    • 应该看多少个邻居?一个还是十个?
  • 决策规则 (Decision Rule)
    • 如何根据邻居做出最终判断?(例如,简单投票)

关键要素 1: 距离度量

如何定义“近”?这取决于我们选择的距离度量。

距离度量 两个点之间用一条带刻度的线连接,表示距离的度量。 Distance Metric 样本 A 样本 B d(A, B) = ?

关键要素 2: k值的选择

参考多少个邻居?这是模型复杂度的关键开关。

关键要素 2: k值的选择 图中以“k值的选择 (The Choice of k)、k=3、k=8”呈现“关键要素 2: k值的选择”涉及的对象、方向或比较关系。 k值的选择 (The Choice of k) k=3 k=8

关键要素 3: 决策规则

如何汇总邻居的“意见”?最常见的是少数服从多数。

关键要素 3: 决策规则 图中以“决策规则: 多数投票 (Decision Rule: Majority Vote)、k=5 邻居的类别、最终决策: 类别 A、(Final Decision: Class A)”呈现“关键要素 3: 决策规则”涉及的对象、方向或比较关系。 决策规则: 多数投票 (Decision Rule: Majority Vote) k=5 邻居的类别 最终决策: 类别 A (Final Decision: Class A)

深入探讨:距离度量

样本间的相似性是通过距离函数来度量的。对于两个 \(d\) 维的样本 \(x_i\)\(x_j\),常见的距离度量有:

  • 欧几里得距离 (Euclidean Distance): 我们最熟悉的直线距离,也是KNN中最常用的。

    \[ \large{L_2(x_i, x_j) = \sqrt{\sum_{l=1}^d (x_{il} - x_{jl})^2}} \]

  • 曼哈顿距离 (Manhattan Distance): 想象在城市街道上开车,只能沿着网格线走。

    \[ \large{L_1(x_i, x_j) = \sum_{l=1}^d |x_{il} - x_{jl}|} \]

可视化距离度量

欧几里得距离是“抄近道”,曼哈顿距离是“走街区”。

可视化距离度量 图中以“欧几里得 vs. 曼哈顿距离 (Euclidean vs. Manhattan)、点 A (xA, yA)、点 B (xB, yB)、|xₓ - xₐ|、|yₐ - yₓ|”呈现“可视化距离度量”涉及的对象、方向或比较关系。 欧几里得 vs. 曼哈顿距离 (Euclidean vs. Manhattan) 点 A (xA, yA) 点 B (xB, yB) |xₓ - xₐ| |yₐ - yₓ| 曼哈顿距离 (L₁) 只能沿坐标轴移动 L₁ = |Δx| + |Δy| 欧几里得距离 (L₂) 两点间最短直线路径 L₂ = √(Δx² + Δy²)

关键前提:数据标准化

重要提示: 距离度量对特征的尺度 (scale) 非常敏感。

  • 问题: 如果“年收入”(单位:万)和“年龄”(单位:岁)直接计算距离,收入的巨大数值会完全主导距离计算,年龄的作用将微乎其微。
  • 解决方案: 数据标准化 (Standardization)。将所有特征转换到相似的尺度上(例如,均值为0,标准差为1)。

在应用KNN之前,数据标准化几乎是必不可少的一步。

为什么标准化至关重要?

为什么标准化至关重要? 图中以“1. 标准化之前 (Before)、收入 (范围: 10k-100k)、年龄 (范围: 20-60)、A、B”呈现“为什么标准化至关重要?”涉及的对象、方向或比较关系。 1. 标准化之前 (Before) 收入 (范围: 10k-100k) 年龄 (范围: 20-60) A B 效果:距离被“收入”特征主导。 Δ收入 的数值量级淹没 Δ年龄 的贡献。 2. 标准化之后 (After) 收入 Z-score 年龄 Z-score A' B' 效果:特征统一为 Z 分数。 ΔZ-收入 与 ΔZ-年龄 对距离的贡献可直接比较。

深入探讨:k值的选择

\(k\) 值的选择直接影响模型的性能,它是一个偏差-方差权衡 (Bias-Variance Tradeoff) 的过程。

  • 较小的 \(k\):
    • 模型更复杂,决策边界更不规则。
    • 容易受到噪声数据的影响,导致过拟合 (Overfitting)
    • 低偏差 (Low Bias),高方差 (High Variance)。
  • 较大的 \(k\):
    • 模型更简单,决策边界更平滑。
    • 会忽略数据中局部的、细微的结构,导致欠拟合 (Underfitting)
    • 高偏差 (High Bias),低方差 (Low Variance)。

小 k值: 高度灵活,容易过拟合

当 k 很小时 (例如 k=1),模型决策边界会变得非常曲折,试图去完美匹配每一个训练数据点,包括噪声。

小 k值: 高度灵活,容易过拟合 图中以“KNN算法: k=1 如何导致过拟合 (Overfitting)、待分类点、(New Point)、唯一的最近邻 (k=1)、(一个噪声/离群点)”呈现“小 k值: 高度灵活,容易过拟合”涉及的对象、方向或比较关系。 KNN算法: k=1 如何导致过拟合 (Overfitting) 待分类点 (New Point) 唯一的最近邻 (k=1) (一个噪声/离群点) 1. 只取最近的 1 个邻居 2. 该邻居是粉色离群点 3. 因而将新点误判为粉色 结果:对噪声过于敏感

大 k值: 高度平滑,容易欠拟合

当 k 很大时 (例如 k=N),模型会忽略数据的局部结构,决策边界变得过于简单,无法捕捉类别间的差异。

大 k值: 高度平滑,容易欠拟合 左侧显示一个包含全部七个蓝色样本和四个红色样本的大邻域,新样本位于局部红色区域却被全局蓝色多数票主导;右侧三张解释卡说明全局邻域、多数投票和高偏差之间的关系。 大 k:全局多数票淹没局部结构 蓝色预测区 红色预测区 k=N 的全局邻域 新样本 1. 全局邻域 局部信息稀释 2. 多数投票 蓝色类别主导 3. 红簇被忽略 高偏差/欠拟合 结论:边界过平滑

寻找最优 k 值

那么,如何找到那个“刚刚好”的 \(k\) 值呢?

  • 答案不是猜的:我们不能凭感觉选择。
  • 系统性方法:
    • 通常使用交叉验证 (Cross-Validation)

    • 我们将数据分成几份,轮流使用一部分作为“迷你测试集”来评估不同 \(k\) 值的表现,最终选择表现最好的那个 \(k\)

我们将在稍后的Python实践中演示这个过程。

让我们通过一个例子来可视化KNN

假设我们有一组关于肿瘤的数据,分为“良性”和“恶性”两类。现在来了一个新的病人(用 ? 表示),我们需要判断其肿瘤是良性还是恶性。

Code
# 导入依赖以支持本页的数据处理、建模或可视化。
import numpy as np
# 导入依赖以支持本页的数据处理、建模或可视化。
import matplotlib.pyplot as plt
# 导入依赖以支持本页的数据处理、建模或可视化。
import seaborn as sns
# 导入依赖以支持本页的数据处理、建模或可视化。
from sklearn.datasets import make_blobs
# 教学说明:--- Generate Data ---。
input_feature_matrix, target_values = make_blobs(n_samples=50, centers=2, random_state=4, cluster_std=1.5)
new_point = np.array([[-3, 8]])
# 展示新病人与已标记肿瘤样本的初始空间关系。
plt.figure(figsize=(10, 6))  # 展示当前步骤的结果。
# 执行 `sns.scatterplot`,生成当前步骤需要的结果或可视化。
sns.scatterplot(x=input_feature_matrix[target_values==0, 0], y=input_feature_matrix[target_values==0, 1], s=120, label='良性 (Benign)', marker='o', color='royalblue', ec='black')  # 展示当前步骤的结果。
# 执行 `sns.scatterplot`,生成当前步骤需要的结果或可视化。
sns.scatterplot(x=input_feature_matrix[target_values==1, 0], y=input_feature_matrix[target_values==1, 1], s=120, label='恶性 (Malignant)', marker='X', color='darkorange', ec='black')  # 展示当前步骤的结果。
# 执行 `plt.scatter`,生成当前步骤需要的结果或可视化。
plt.scatter(new_point[:, 0], new_point[:, 1], s=300, c='red', marker='P', label='新样本', ec='black', linewidth=1.5)  # 展示当前步骤的结果。

# 执行 `plt.title`,生成当前步骤需要的结果或可视化。
plt.title('一个需要被分类的新样本', fontsize=20, pad=15)  # 展示当前步骤的结果。
# 执行 `plt.xlabel`,生成当前步骤需要的结果或可视化。
plt.xlabel('特征1 (Texture)', fontsize=20)  # 展示当前步骤的结果。
# 执行 `plt.ylabel`,生成当前步骤需要的结果或可视化。
plt.ylabel('特征2 (Radius)', fontsize=20)  # 展示当前步骤的结果。
# 执行 `plt.legend`,生成当前步骤需要的结果或可视化。
plt.legend(loc='upper right', fontsize=20)  # 展示当前步骤的结果。
# 执行 `plt.grid`,生成当前步骤需要的结果或可视化。
plt.grid(True, linestyle='--', alpha=0.6)  # 展示当前步骤的结果。
# 执行 `plt.gca().set_aspect`,生成当前步骤需要的结果或可视化。
plt.gca().set_aspect('equal', adjustable='box')  # 展示当前步骤的结果。
# 执行 `plt.tight_layout`,生成当前步骤需要的结果或可视化。
plt.tight_layout()  # 展示当前步骤的结果。
# 执行 `plt.show`,生成当前步骤需要的结果或可视化。
plt.show()  # 展示当前步骤的结果。
一个新样本和已标记的肿瘤数据
Figure 1: 一个新样本和已标记的肿瘤数据

当 k=1 时: 最近的邻居决定一切

1-NN是最简单的KNN形式。我们只看距离最近的那一个邻居。

在这个例子中,离新病人最近的样本是“恶性”类,所以1-NN会将其分类为恶性。这可能是一个受噪声影响的错误判断。

Code
# 教学说明:--- Recalculate from previous cell ---。
input_feature_matrix, target_values = make_blobs(n_samples=50, centers=2, random_state=4, cluster_std=1.5)
new_point = np.array([[-3, 8]])
# 教学说明:--- Calculate Distances ---。
distances_k1 = np.sqrt(np.sum((input_feature_matrix - new_point)**2, axis=1))
nearest_neighbor_idx = np.argmin(distances_k1)
# 突出唯一最近邻,展示 1-NN 对局部噪声的敏感性。
plt.figure(figsize=(10, 6))  # 展示当前步骤的结果。
# 执行 `sns.scatterplot`,生成当前步骤需要的结果或可视化。
sns.scatterplot(x=input_feature_matrix[target_values==0, 0], y=input_feature_matrix[target_values==0, 1], s=100, label='良性 (Benign)', marker='o', color='royalblue', alpha=0.4)  # 展示当前步骤的结果。
# 执行 `sns.scatterplot`,生成当前步骤需要的结果或可视化。
sns.scatterplot(x=input_feature_matrix[target_values==1, 0], y=input_feature_matrix[target_values==1, 1], s=100, label='恶性 (Malignant)', marker='X', color='darkorange', alpha=0.4)  # 展示当前步骤的结果。
# 执行 `plt.scatter`,生成当前步骤需要的结果或可视化。
plt.scatter(new_point[:, 0], new_point[:, 1], s=300, c='red', marker='P', label='新病人 (New Patient)', ec='black', linewidth=1.5)  # 展示当前步骤的结果。

# 教学说明:Highlight the nearest neighbor。
plt.scatter(input_feature_matrix[nearest_neighbor_idx, 0], input_feature_matrix[nearest_neighbor_idx, 1], s=350,
            facecolors='none', edgecolors='green', linewidth=3, label='最近邻 (k=1)')
# 执行 `plt.title`,生成当前步骤需要的结果或可视化。
plt.title('k=1:最近邻为恶性', fontsize=34, pad=15)  # 展示当前步骤的结果。
# 执行 `plt.xlabel`,生成当前步骤需要的结果或可视化。
plt.xlabel('特征1 (Texture)', fontsize=34)  # 展示当前步骤的结果。
# 执行 `plt.ylabel`,生成当前步骤需要的结果或可视化。
plt.ylabel('特征2 (Radius)', fontsize=34)  # 展示当前步骤的结果。
# 执行 `plt.legend`,生成当前步骤需要的结果或可视化。
plt.legend(loc='upper right', fontsize=30)  # 展示当前步骤的结果。
plt.tick_params(axis='both', labelsize=30)  # 放大刻度文字以满足投影阅读距离。
# 执行 `plt.grid`,生成当前步骤需要的结果或可视化。
plt.grid(True, linestyle='--', alpha=0.6)  # 展示当前步骤的结果。
# 执行 `plt.gca().set_aspect`,生成当前步骤需要的结果或可视化。
plt.gca().set_aspect('equal', adjustable='box')  # 展示当前步骤的结果。
# 执行 `plt.tight_layout`,生成当前步骤需要的结果或可视化。
plt.tight_layout()  # 展示当前步骤的结果。
# 执行 `plt.show`,生成当前步骤需要的结果或可视化。
plt.show()  # 展示当前步骤的结果。
1-NN的决策过程
Figure 2: 1-NN的决策过程

当 k=5 时: 多数投票,结果反转

现在,我们将 \(k\) 增加到 5,考察最近的5个邻居。

  • 在这5个邻居中,有4个是“良性”,1个是“恶性”。
  • 根据多数投票原则 (4 > 1),5-NN会将其分类为良性

这个例子清晰地表明了 \(k\) 值的选择对最终结果有决定性影响。

Code
# 教学说明:--- Recalculate from previous cell ---。
input_feature_matrix, target_values = make_blobs(n_samples=50, centers=2, random_state=4, cluster_std=1.5)
new_point = np.array([[-3, 8]])
# 教学说明:--- Calculate Distances ---。
distances_k5 = np.sqrt(np.sum((input_feature_matrix - new_point)**2, axis=1))
neighbor_count = 5
nearest_k_indices = np.argsort(distances_k5)[:neighbor_count]
# 圈出五个最近邻,展示多数投票如何改变分类结果。
plt.figure(figsize=(10, 6))  # 展示当前步骤的结果。
# 执行 `sns.scatterplot`,生成当前步骤需要的结果或可视化。
sns.scatterplot(x=input_feature_matrix[target_values==0, 0], y=input_feature_matrix[target_values==0, 1], s=100, label='良性 (Benign)', marker='o', color='royalblue', alpha=0.3)  # 展示当前步骤的结果。
# 执行 `sns.scatterplot`,生成当前步骤需要的结果或可视化。
sns.scatterplot(x=input_feature_matrix[target_values==1, 0], y=input_feature_matrix[target_values==1, 1], s=100, label='恶性 (Malignant)', marker='X', color='darkorange', alpha=0.3)  # 展示当前步骤的结果。
# 执行 `plt.scatter`,生成当前步骤需要的结果或可视化。
plt.scatter(new_point[:, 0], new_point[:, 1], s=300, c='red', marker='P', label='新样本', ec='black', linewidth=1.5)  # 展示当前步骤的结果。

# 教学说明:Highlight the 5 nearest neighbors。
plt.scatter(input_feature_matrix[nearest_k_indices, 0], input_feature_matrix[nearest_k_indices, 1], s=350,
            facecolors='none', edgecolors='green', linewidth=3, label='5 个最近邻')
# 教学说明:Draw a circle enclosing the neighbors。
center = new_point.flatten()
radius = distances_k5[nearest_k_indices[-1]]
circle = plt.Circle(center, radius, color='green', fill=False, linestyle='--', linewidth=2)
# 执行 `plt.gca().add_patch`,生成当前步骤需要的结果或可视化。
plt.gca().add_patch(circle)  # 展示当前步骤的结果。

# 执行 `plt.title`,生成当前步骤需要的结果或可视化。
plt.title('k=5:多数投票为良性', fontsize=34, pad=15)  # 放大图题以满足投影阅读距离。
# 执行 `plt.xlabel`,生成当前步骤需要的结果或可视化。
plt.xlabel('特征1 (Texture)', fontsize=34)  # 放大横轴标题以满足投影阅读距离。
# 执行 `plt.ylabel`,生成当前步骤需要的结果或可视化。
plt.ylabel('特征2 (Radius)', fontsize=34)  # 放大纵轴标题以满足投影阅读距离。
# 执行 `plt.legend`,生成当前步骤需要的结果或可视化。
plt.legend(loc='upper right', fontsize=30)  # 放大图例以满足投影阅读距离。
plt.tick_params(axis='both', labelsize=30)  # 放大刻度文字以满足投影阅读距离。
# 执行 `plt.grid`,生成当前步骤需要的结果或可视化。
plt.grid(True, linestyle='--', alpha=0.6)  # 展示当前步骤的结果。
# 执行 `plt.gca().set_aspect`,生成当前步骤需要的结果或可视化。
plt.gca().set_aspect('equal', adjustable='box')  # 展示当前步骤的结果。
# 执行 `plt.tight_layout`,生成当前步骤需要的结果或可视化。
plt.tight_layout()  # 展示当前步骤的结果。
# 执行 `plt.show`,生成当前步骤需要的结果或可视化。
plt.show()  # 展示当前步骤的结果。
5-NN的决策过程
Figure 3: 5-NN的决策过程

4.2 加权最近邻分类器 (Weighted k-NN)

拓展内容提示:核心内容从 \(k\) 的偏差—方差检查直接前往高维距离诊断,再进入主要案例;加权、回归与 KD 树完成核心内容后选讲。

标准的KNN模型中,每个邻居的“投票权重”是相等的。但这是否合理?

  • 直觉: 距离更近的邻居应该比距离远的邻居更有发言权。
  • 解决方案: 加权最近邻 (Weighted k-NN)。根据邻居的远近来分配投票权重。

一种常见的加权方式是使用距离的倒数作为权重。

\[ \large{y_n = \underset{c \in \mathcal{Y}}{\arg\max} \sum_{i=1}^k w_i \cdot I(y_i = c) \quad \text{, where } w_i = \frac{1}{\text{distance}(x_n, x_i)}} \]

加权KNN的直观解释

近的邻居“嗓门大”,远的邻居“嗓门小”。

加权KNN的直观解释 图中以“加权KNN (Weighted KNN)、距离越近,权重越大、待分类点、权重: 0.8、权重: 0.3”呈现“加权KNN的直观解释”涉及的对象、方向或比较关系。 加权KNN (Weighted KNN) 距离越近,权重越大 待分类点 权重: 0.8 权重: 0.3 权重: 0.2 权重累加与决策 标准 KNN(k=3) 1 票 vs 2 票粉色 加权 KNN Σ W(紫) 0.8 Σ W(粉) 0.5 结论:分类为紫色

加权KNN在scikit-learn中的实现

  • scikit-learn 中,weights='distance' 令近邻拥有更大投票权。

  • 下面只展示拓展内容 的可执行配置;核心内容主要案例事先确定等权投票,不用测试结果决定是否采用加权。

Code
from sklearn.neighbors import KNeighborsClassifier  # 导入加权最近邻分类器
weighted_knn_example = KNeighborsClassifier(n_neighbors=5, weights='distance')  # 创建尚未拟合的候选模型设置
weighted_knn_example.get_params()["weights"]  # 核对课后拓展内容 的距离加权配置
'distance'

若课后比较距离加权,必须另设训练期验证并重新测试期;当前 核心内容不比较该规则。

4.3 KNN不仅能分类,还能用于回归预测

KNN的核心思想同样适用于回归问题,即预测一个连续值(如股票价格、房屋售价)。

分类 (Classification)

  • 目标:预测离散的类别
  • 方法:邻居投票
  • 例子:判断邮件是“垃圾”还是“非垃圾”

回归 (Regression)

  • 目标:预测连续的数值
  • 方法:邻居取平均值
  • 例子:预测房屋的售价

KNN回归的数学表达

对于一个新样本,KNN回归模型的预测值是其 \(k\) 个最近邻居的目标值的平均值(或加权平均值)。

\[ \large{\hat{y}_n = \frac{1}{k} \sum_{i=1}^k y_i} \]

其中,\(y_i\) 是第 \(i\) 个邻居的真实数值。

4.4 现实世界的挑战与解决方案

到目前为止,我们处理的都是理想化的数值型数据。但在商业世界中,数据往往更复杂。

  • 挑战1: 如何处理非数值特征 (如“城市”、“产品类别”)?
  • 挑战2: 当数据量巨大 (百万、千万级) 时怎么办?KNN的预测会变得极慢。

挑战1:处理分类特征 (Categorical Features)

问题

如何计算包含“北京”和“上海”这类文本特征的样本之间的“距离”?

解决方案:

  1. 独热编码 (One-Hot Encoding): 将分类特征转换为多个0/1的二元特征。这是最常用的方法。
  2. 使用特定的距离度量: 例如,汉明距离 (Hamming Distance),它计算两个等长字符串之间不同位置的字符数。

可视化独热编码 (One-Hot Encoding)

独热编码示意图 一个表格展示了如何将一个'城市'列转换为三个独立的0/1列,用颜色和箭头突出显示转换过程。 城市 北京 上海 广州 转换 京列 沪列 穗列 1 0 0 0 1 0 0 0 1

挑战2:数据规模与预测速度

KNN的一个主要缺点是其计算成本高

  • 训练阶段: 速度极快,几乎没有计算,只是存储数据。
  • 预测阶段: 速度极慢。对于每一个新的待预测样本,都需要计算它与所有训练样本的距离。如果训练集有百万样本,这将是无法接受的。

问题

如何在不牺牲太多精度的前提下,加快寻找最近邻的速度?

核心思想: 避免“蛮力搜索”

直接计算新样本与所有训练点的距离被称为蛮力搜索 (Brute-force search)

核心思想: 避免“蛮力搜索” 图中以“蛮力搜索 (Brute-force Search)、逐一计算所有距离、新样本 (Query)、计算过程:、1. 接收一个新样本点。”呈现“核心思想: 避免“蛮力搜索””涉及的对象、方向或比较关系。 蛮力搜索 (Brute-force Search) 逐一计算所有距离 新样本 (Query) 计算步骤 1. 接收新样本 2. 扫描 N 个点 3. 存储 N 个距离 成本随 N 线性增长

加速策略的核心思想是:通过构建智能的数据结构,来快速排除大量不可能成为最近邻的样本点。

加速策略1: KD树 (k-dimensional tree)

KD树是一种经典的空间划分数据结构,它将 \(k\) 维空间递归地划分为一系列超矩形区域。

  • 构建过程:
    1. 选择一个坐标轴(例如,方差最大的轴)。
    2. 找到所有数据点在该轴上的中位数
    3. 用一个垂直于该轴的超平面将空间一分为二。
    4. 对左右两个子空间递归地重复此过程,直到每个区域只包含少量数据点。

KD树如何划分二维空间?

Code
import numpy as np  # 生成可复现的二维教学点集
import matplotlib.pyplot as plt  # 绘制 KD 树空间划分
np.random.seed(42)  # 固定教学示意随机种子
data_kd = np.random.rand(25, 2) * 10  # 生成二维点供划分演示
data_sorted_x = data_kd[data_kd[:, 0].argsort()]  # 按横轴排序以确定第一次切分
median_idx_x = len(data_sorted_x) // 2  # 定位横轴中位点
median_val_x = data_sorted_x[median_idx_x, 0]  # 提取第一次垂直切分位置
left_data = data_sorted_x[:median_idx_x]  # 取得第一次切分左侧点
left_data_sorted_y = left_data[left_data[:, 1].argsort()]  # 按纵轴排序左侧点
median_val_ly = left_data_sorted_y[len(left_data_sorted_y) // 2, 1]  # 计算左侧水平切分位置
right_data = data_sorted_x[median_idx_x + 1:]  # 取得第一次切分右侧点
right_data_sorted_y = right_data[right_data[:, 1].argsort()]  # 按纵轴排序右侧点
median_val_ry = right_data_sorted_y[len(right_data_sorted_y) // 2, 1]  # 计算右侧水平切分位置
Code
# 教学说明:--- Visualization ---。
fig, ax = plt.subplots(figsize=(8, 6.5))
# 执行 `ax.scatter`,生成当前步骤需要的结果或可视化。
ax.scatter(data_kd[:, 0], data_kd[:, 1], c='navy', s=50, ec='black', alpha=0.8)
# 教学说明:1st split (vertical)。
ax.plot([median_val_x, median_val_x], [-1, 11], color='red', linestyle='--', linewidth=2, label='第一次划分 (x轴)')
# 教学说明:2nd split (horizontal) on left side。
ax.plot([-1, median_val_x], [median_val_ly, median_val_ly], color='green', linestyle='--', linewidth=2, label='第二次划分 (y轴)')
# 教学说明:2nd split (horizontal) on right side。
ax.plot([median_val_x, 11], [median_val_ry, median_val_ry], color='green', linestyle='--')
# 执行 `ax.set_xlim`,生成当前步骤需要的结果或可视化。
ax.set_xlim(-1, 11)
# 执行 `ax.set_ylim`,生成当前步骤需要的结果或可视化。
ax.set_ylim(-1, 11)
# 执行 `ax.set_title`,生成当前步骤需要的结果或可视化。
ax.set_title('KD树的空间划分过程', fontsize=20, pad=15)
# 执行 `ax.set_xlabel`,生成当前步骤需要的结果或可视化。
ax.set_xlabel('特征 1', fontsize=20)
# 执行 `ax.set_ylabel`,生成当前步骤需要的结果或可视化。
ax.set_ylabel('特征 2', fontsize=20)
# 执行 `ax.legend`,生成当前步骤需要的结果或可视化。
ax.legend(fontsize=20)
# 执行 `ax.set_aspect`,生成当前步骤需要的结果或可视化。
ax.set_aspect('equal', adjustable='box')
# 执行 `plt.grid`,生成当前步骤需要的结果或可视化。
plt.grid(True, linestyle='--', alpha=0.6)  # 展示当前步骤的结果。
# 执行 `plt.tight_layout`,生成当前步骤需要的结果或可视化。
plt.tight_layout()  # 展示当前步骤的结果。
# 执行 `plt.show`,生成当前步骤需要的结果或可视化。
plt.show()  # 展示当前步骤的结果。
KD树如何递归地划分二维空间
Figure 4: KD树如何递归地划分二维空间

核心内容高维诊断:近邻对比为何消失

  • 核心内容要点: 维度升高时,距离趋同、“近邻”变模糊,KD 树剪枝逐渐接近蛮力搜索。

  • 检查:

    • 若最近/最远距离之比趋近 1,相对区分度便下降,局部投票和距离加权更不稳定。

    • 这是一项待诊断风险,而不是“所有高维 KNN 必然失效”的定理。

  • 学习提示: 下面先进入主要案例;高维概率推导可在课后选学。

高维灾难示意图 左侧是一个点分布密集的二维空间,右侧是一个点都分布在遥远角落的抽象高维空间。 低维空间 (d=2) 高维空间 (d >> 20) 邻居几乎等距

拓展内容:高维空间的诅咒

拓展内容结束:本分支结束后进入主要案例,不重放已经完成的 核心内容诊断。

核心问题:高维空间中的’距离’是什么?

拓展内容提示:90 分钟核心内容在进入本分支前已完成“相对距离差缩小会削弱邻居排序”的最小诊断;以下边界层、四阶矩与次高斯定理用于课后推导。本分支结束后只向前返回尚未完成的主要案例,不要求 核心内容先修概率论。

在高维空间中,我们基于低维(2D或3D)生活经验建立的几何直觉往往会失效。

  • 直觉:点与点之间有远有近,总能找到’最近的邻居’。
  • 高维现实:随着维度越来越高,点与点之间的欧氏距离会变得越来越相近。

本讲座旨在通过直观的例子和数学推导,揭示这一反直觉的现象——距离度量的失效

简化模型:角点邻域与边界层

我们从一个简单的思想实验开始:将单位超立方体 \([0, 1]^d\) 的每个维度都从中点 \(0.5\) 处切开。

这会将整个空间分割成 \(2^d\) 个相同大小的“小超立方体”。

  • 其中 \([0,1/2]^d\) 只是原点角落的一个子立方体,不能称为整个超立方体的“内层”。

  • 这个切分说明角点邻域的体积会缩小;真正的边界层需要另行定义。

低维直觉:d=1 和 d=2 的角点分区

在低维空间,可以直接看到原点角落的子区与其补集。注意:这里比较的是“一个角落”和“其余区域”,不是中心与边界。

低维空间切分 一维线段被切分为两部分,二维正方形被切分为四个象限。 维度 d=1 0 0.5 1 原点半区 [0, 0.5] 另一半区 (0.5, 1] 维度 d=2 原点角区 (1/4) 其余角区 (3/4) 0 1 1
Figure 5: 在 \(d=2\) 时,\(2^2=4\) 个小正方形中,只有一个 \([0,1/2]\times[0,1/2]\) 靠近原点。

高维现象:角点体积缩小;边界层体积增大

随着维度 \(d\) 增加,原点角落子立方体 \([0,1/2]^d\) 的体积 \(2^{-d}\) 迅速趋近于 0;其补集并不等同于边界层。

维度 (d) 子立方体数 (\(2^d\)) 角点概率 (\(2^{-d}\)) 补集概率 (\(1-2^{-d}\))
1 2 50% 50%
3 8 12.5% 87.5%
10 1,024 ~0.1% 99.9%
100 ~\(1.27 \times 10^{30}\) ~\(7.9 \times 10^{-31}\) ~100%

若把宽度为 \(\varepsilon\) 的边界层定义为

\[ B_\varepsilon=\{x\in[0,1]^d:\min_j\min(x_j,1-x_j)\le\varepsilon\}, \]

\(P(X\in B_\varepsilon)=1-(1-2\varepsilon)^d\to1\)(固定 \(0<\varepsilon<1/2\))。这才严格表达“均匀质量靠近某个边界面”。

从离散到连续:均匀分布下的距离集中

刚才的切分模型是一个简化。现在我们考虑更一般的情况:\(n\) 个数据点 \(X_1, \ldots, X_n\) 在单位超立方体 \([0, 1]^d\) 中独立同分布。

有条件的结论

若样本量 \(n\) 固定、坐标独立同分布为 \(U(0,1)\),则有限个点对的欧氏距离具有相同的一阶尺度,最大与最小距离的相对差距依概率趋于 0。

\[ \frac{dist_{max}-dist_{min}}{dist_{min}}\xrightarrow{p}0 \qquad(n\text{ 固定}). \]

\(n\)\(d\) 增长、坐标相关、分布重尾,或改变度量,上述极值结论需要额外条件,不能直接沿用。

数学推导 1:构建距离的随机变量

我们来分析任意两点 \(X_i\)\(X_j\) 之间的距离。

  1. 点的表示:每个点 \(X_j\) 是一个 \(d\) 维向量 \((X_{j1}, \ldots, X_{jd})^T\)
  2. 分量的分布:每个分量 \(X_{jk}\) 独立地服从 \(U(0, 1)\) 分布。
    • 期望:\(E(X_{jk}) = \frac{1}{2}\)
    • 方差:\(\operatorname{Var}(X_{jk}) = \frac{1}{12}\)
  3. 距离的平方:我们分析欧氏距离的平方 \(S_{ij} = \lVert X_i - X_j\rVert_2^2\),因为它更易于处理。

\[ \large{ S_{ij} = \sum_{k=1}^{d} (X_{ik} - X_{jk})^2 } \]

数学推导 2:计算距离平方的期望

利用期望的线性性质,我们可以计算 \(S_{ij}\) 的期望值。

首先,考虑单个维度上差的平方的期望 \(E[(X_{ik} - X_{jk})^2]\)

根据方差定义 \(\operatorname{Var}(Y) = E(Y^2) - [E(Y)]^2\),我们有:

\[ \large{E[(X_{ik} - X_{jk})^2] = \operatorname{Var}(X_{ik} - X_{jk}) + [E(X_{ik} - X_{jk})]^2} \]

由于 \(X_{ik}\)\(X_{jk}\) 独立同分布:

  • \(E(X_{ik} - X_{jk}) = E(X_{ik}) - E(X_{jk}) = \frac{1}{2} - \frac{1}{2} = 0\)
  • \(\operatorname{Var}(X_{ik} - X_{jk}) = \operatorname{Var}(X_{ik}) + \operatorname{Var}(X_{jk}) = \frac{1}{12} + \frac{1}{12} = \frac{1}{6}\)

所以,\(E[(X_{ik} - X_{jk})^2] = \frac{1}{6} + 0^2 = \frac{1}{6}\)

数学推导 3:对所有维度求和

最终,对所有维度求和:

\[ \large{ E(S_{ij}) = E[\sum_{k=1}^{d} (X_{ik} - X_{jk})^2] = \sum_{k=1}^{d} E[(X_{ik} - X_{jk})^2] = \frac{d}{6} } \]

数学推导 4:大数定律的应用

我们已经知道距离平方 \(S_{ij}\)\(d\) 个独立同分布随机变量 \((X_{ik} - X_{jk})^2\) 的和。

根据大数定律,当 d 趋于无穷时,样本均值会依概率收敛于期望值:

\[ \large{ \frac{S_{ij}}{d} = \frac{1}{d} \sum_{k=1}^{d} (X_{ik} - X_{jk})^2 \xrightarrow{p} E[(X_{ik} - X_{jk})^2] = \frac{1}{6} } \]

此外 \(E[(X_{ik}-X_{jk})^4]=1/15\),所以

\[ \operatorname{Var}(S_{ij})=\frac{7d}{180},\qquad \frac{\operatorname{sd}(S_{ij})}{E(S_{ij})}=\sqrt{\frac{7}{5d}}. \]

  • 因此收缩的是相对波动\(S_{ij}/d\xrightarrow{p}1/6\),等价地 \(\lVert X_i-X_j\rVert_2/\sqrt d\xrightarrow{p}1/\sqrt6\)

  • 绝对方差并不趋于 0;固定 \(n\) 时才能把有限点对同时纳入结论。

概念可视化:距离分布的集中

这个现象可以用距离分布的变化来直观理解。

距离分布的集中现象 一个图表,显示了随着维度d的增加,点对距离的概率分布从宽变窄,最终集中在一个点上。 距离 概率密度 维度 低维 d=2 中维 d=10 高维 d→∞ E[距离]
Figure 6: 图示应理解为对 \(D/\sqrt d\)\(S/d\)归一化分布:相对宽度随维度缩小。未归一化距离不能被解释为绝对宽度收缩成脉冲。

推广:范数的集中 (Concentration of the Norm)

类似集中结论可以推广,但必须陈述分布与相关性条件,不能把它当作任意高维数据的普遍定理。

  • 范数集中定理 (直观版): 例如,对均值为零、各向同性且次高斯的随机向量 \(X\in\mathbb R^d\)\(\lVert X\rVert_2\) 会在 \(\sqrt d\) 的尺度附近集中;

  • 常数与尾界取决于明确的次高斯参数。

  • 一个更强的结论(Vershynin, 2018)表明,对于次高斯向量,这种偏离 \(\sqrt{d}\) 的概率会随着维度 \(d\) 的增加而指数级衰减。

  • 这意味着在高维空间中,随机向量的长度几乎是一个确定性而非随机性的值。

实践意义:为什么’最近邻’会失效?

距离度量的失效对许多依赖于距离计算的算法构成了严峻挑战,特别是 k-最近邻 (k-NN) 算法。

  • k-NN的目标: 找到特征空间中与查询点’最相似’的 k 个邻居。
  • 高维困境: 如果所有邻居到查询点的距离都几乎相等,那么’最近’和’最远’的邻居之间没有实际差别。
  • 结果: 若无低维结构或有效信号,邻居排序的对比度会下降,预测可能变得不稳定;这不是 k-NN 在所有高维任务上必然“毫无意义”的定理。

总结

  1. 高维几何反直觉: 我们不能将低维空间的直觉直接推广到高维空间。
  2. 边界层需要定义: 对均匀超立方体,固定宽度边界层的体积占比趋近于 1;一个角点的补集不能冒充边界层。
  3. 相对距离集中: 在明确分布且固定样本量的设定下,归一化距离的相对波动收缩。
  4. 算法需要诊断: 距离集中可能削弱 k-NN 或聚类的邻近对比,但效果取决于尺度、结构、度量与样本增长。

拓展内容结束

继续进入主要案例;不要跳过训练期选参与测试期检查。

进入主要案例前的学习目标回顾

回顾开场目标:①手算欧氏/曼哈顿距离;②解释标准化为何改变邻居;③用时间验证选择 \(k\);④诊断高维距离集中。

  • 回顾题:\((0,0)\)\((3,4)\) 的欧氏距离与曼哈顿距离分别是多少?
  • 答案: 欧氏距离 \(\sqrt{3^2+4^2}=5\);曼哈顿距离 \(|3|+|4|=7\)。选择何者取决于任务中的相似性含义。

形成性检查 1:先缩放还是先切分?

标准化器应在全样本拟合,还是只在训练期拟合?为什么?

答案

只在训练期拟合,再原样变换验证/测试期;全样本均值与标准差包含未来信息,会造成预处理泄漏。

主要案例:福耀玻璃 KNN 下跌分类

  • 本例数据: data/stock/stock_price_pre_adjusted.h5;key=data;福耀玻璃 600660.XSHG;2018–2024;用当日收益、5 日收益与 5 日波动率预测下一日是否下跌;按时间 80/20 切分。

  • 邻近方法来源: Cover & Hart (1967), 最近邻分类;本例先用训练期标准化,再在同一距离尺度上预测。

Code
from pathlib import Path  # 使用统一路径对象定位数据文件

import pandas as pd  # 导入表格工具以读取本地行情
from sklearn.preprocessing import StandardScaler  # 用训练期尺度定义可比距离
from sklearn.neighbors import KNeighborsClassifier  # 使用最近邻多数投票完成分类
from sklearn.metrics import average_precision_score, balanced_accuracy_score, confusion_matrix, roc_auc_score  # 为确定测试检查准备排序与分类阈值指标
# 公网下载:https://assets.qiufei.site/data/stock/stock_price_pre_adjusted.h5
# 下载后把下一行改为本机文件位置;按课程结构存放时可用 Path("data/stock/stock_price_pre_adjusted.h5")。
# Windows:Path(r"C:\qiufei\data\stock\stock_price_pre_adjusted.h5")
# macOS:Path("/Users/你的用户名/data/stock/stock_price_pre_adjusted.h5")
# Linux:Path("/home/你的用户名/data/stock/stock_price_pre_adjusted.h5")
price_path = Path("/home/ubuntu/r2_data_mount/data/stock/stock_price_pre_adjusted.h5")
price_rows = pd.read_hdf(price_path, key='data', where=['order_book_id=="600660.XSHG"', 'date>=Timestamp("2018-01-01")', 'date<=Timestamp("2024-12-31")'], columns=['close'])  # 仅载入目标公司与收盘价
knn_frame = price_rows.reset_index().sort_values('date')  # 按交易日建立预测顺序
knn_frame['return_t'] = knn_frame['close'].pct_change()  # 构造单日收益率
knn_frame['return_5d_t'] = knn_frame['close'].pct_change(5)  # 构造五日累计收益
knn_frame['volatility_5d_t'] = knn_frame['return_t'].rolling(5).std()  # 构造五日历史波动率
knn_frame['future_return_t1'] = knn_frame['return_t'].shift(-1)  # 先保留连续未来收益以识别末端未知标签
knn_frame['target_date_t1'] = knn_frame['date'].shift(-1)  # 保存标签实现日以清除跨窗口样本
knn_frame = knn_frame.dropna()  # 在整数化之前删除滚动窗口与未来收益缺失行
Code
knn_frame['down_t1'] = (knn_frame['future_return_t1'] < 0).astype(int)  # 仅把真实可观测的未来收益转换为类别
assert knn_frame['future_return_t1'].notna().all() and knn_frame['date'].max() < price_rows.reset_index()['date'].max()  # 确认最后特征日早于最后标签实现日
split_row = int(len(knn_frame) * 0.8)  # 固定前百分之八十为训练期
test_start_date = knn_frame.iloc[split_row]['date']  # 从完整序列确定测试起点
train_rows = knn_frame[(knn_frame['date'] < test_start_date) & (knn_frame['target_date_t1'] < test_start_date)]  # 清除到测试期才实现的训练标签
test_rows = knn_frame[knn_frame['date'] >= test_start_date]  # 确定测试特征窗口
assert train_rows['target_date_t1'].max() < test_rows['date'].min()  # 确认标签实现边界
feature_names = ['return_t', 'return_5d_t', 'volatility_5d_t']  # 声明距离空间的三个语义维度
pd.Series({'train_end': train_rows['date'].max(), 'test_start': test_rows['date'].min()})  # 选参前只确认时间边界,不读取任何测试标签摘要
train_end    2023-08-04
test_start   2023-08-08
dtype: datetime64[ns]

形成性检查 2:\(k\) 的偏差—方差

\(k=1\) 增至 \(k=51\),边界通常如何变化?训练误差和方差通常如何变化?

答案

边界更平滑,训练误差通常上升,方差下降、偏差上升。最佳 \(k\) 必须在训练期内部验证,不能看最终测试结果挑选。

分步练习:选择 \(k\)

  • 任务:
    • 事先确定欧氏距离与等权投票,在训练期做 5 个扩展窗口切分,比较 \(k\in\{5,15,31\}\) 的平衡准确率;

    • 确定 \(k\) 后,一次报告测试期类别比例、ROC-AUC、AP 与混淆矩阵。

  • 完整解答:
    • 对每个窗口分别拟合 StandardScaler 和等权、欧氏 KNN,记录验证分数;以平均验证分数最高者选 \(k\)

    • 随后用完整训练期重新拟合,只打开固定测试期一次。

    • 多数投票产生分类阈值;距离加权与其他距离属于拓展内容,本例不宣称选择它们或另行选择概率阈值。

Code
from sklearn.model_selection import TimeSeriesSplit  # 用扩展窗口保持训练早于验证
validation_rows = []  # 收集每个窗口和候选 k 的平衡准确率
for fold_id, (fit_index, validation_index) in enumerate(TimeSeriesSplit(n_splits=5).split(train_rows), start=1):  # 生成五个时间有序窗口
    fold_train = train_rows.iloc[fit_index]  # 取得当前窗口的历史训练段
    fold_validation = train_rows.iloc[validation_index]  # 取得紧随其后的验证段
    fold_train = fold_train[fold_train['target_date_t1'] < fold_validation['date'].min()]  # 清除到验证折才实现的训练标签
    assert fold_train['target_date_t1'].max() < fold_validation['date'].min()  # 确认逐折标签实现边界
    fold_scaler = StandardScaler().fit(fold_train[feature_names])  # 只在当前训练段估计尺度
    for neighbor_count in [5, 15, 31]:  # 比较预先声明的三个候选值
        fold_model = KNeighborsClassifier(n_neighbors=neighbor_count, weights='uniform', metric='euclidean').fit(fold_scaler.transform(fold_train[feature_names]), fold_train['down_t1'])  # 在事先确定的等权欧氏规则下拟合候选 k
        fold_prediction = fold_model.predict(fold_scaler.transform(fold_validation[feature_names]))  # 在未来验证段生成预测
        validation_rows.append({'fold': fold_id, 'k': neighbor_count, 'balanced_accuracy': balanced_accuracy_score(fold_validation['down_t1'], fold_prediction)})  # 保存可重复的逐折结果
validation_table = pd.DataFrame(validation_rows)  # 整理逐折验证记录
validation_summary = validation_table.groupby('k', as_index=False)['balanced_accuracy'].mean().sort_values('balanced_accuracy', ascending=False)  # 汇总平均验证证据
selected_k = int(validation_summary.iloc[0]['k'])  # 仅依据训练期验证选择邻居数
final_scaler = StandardScaler().fit(train_rows[feature_names])  # 选参后用完整训练期重新估计尺度
final_knn = KNeighborsClassifier(n_neighbors=selected_k, weights='uniform', metric='euclidean').fit(final_scaler.transform(train_rows[feature_names]), train_rows['down_t1'])  # 用完整训练期重拟合已确定规则
final_test_prediction = final_knn.predict(final_scaler.transform(test_rows[feature_names]))  # 只对测试期评价一次
final_test_probability = final_knn.predict_proba(final_scaler.transform(test_rows[feature_names]))[:, 1]  # 由同一确定模型生成排序分数
final_test_confusion = confusion_matrix(test_rows['down_t1'], final_test_prediction)  # 记录默认多数投票分类阈值的四格计数
test_summary = pd.Series({'selected_k': selected_k, 'prevalence': test_rows['down_t1'].mean(), 'roc_auc': roc_auc_score(test_rows['down_t1'], final_test_probability), 'AP': average_precision_score(test_rows['down_t1'], final_test_probability), 'balanced_accuracy': balanced_accuracy_score(test_rows['down_t1'], final_test_prediction), 'TN': final_test_confusion[0, 0], 'FP': final_test_confusion[0, 1], 'FN': final_test_confusion[1, 0], 'TP': final_test_confusion[1, 1]})  # 汇总一次性测试期检查
display(validation_summary.round(6), test_summary.to_frame().T.round(6))  # 紧凑展示训练期选择与单行测试期检查
Table 1
k balanced_accuracy
0 5 0.530118
1 15 0.509807
2 31 0.508497
selected_k prevalence roc_auc AP balanced_accuracy TN FP FN TP
0 5.0 0.469027 0.4645 0.452156 0.474161 79.0 101.0 78.0 81.0

综合练习

  • 对象与规则: 换为恒瑞医药 600276.XSHG,并预设距离加权。

  • 验证选择: 只在训练期同一组时间折上比较欧氏/曼哈顿距离与 \(k\),确定唯一组合。

  • 一次测试: 报告类别比例、平衡准确率和查询耗时,不再回选模型。

完整答案提醒

  • 按时间顺序完成分析,只用训练期估计标准化参数,在同一验证期比较距离,同时报告运行速度和预测表现,并说明局限。

  • 结论必须引用实际输出,不能把预测上的邻近解释为因果相似。

综合练习完整答案:恒瑞医药距离比较

Table 2
Code
from time import perf_counter  # 测量确定测试集的查询耗时
from pathlib import Path  # 使用统一路径对象定位数据文件
from sklearn.model_selection import GridSearchCV, TimeSeriesSplit  # 只在训练期扩展窗口选参
from sklearn.pipeline import Pipeline  # 在每个训练期内完成缩放和近邻模型拟合
# 公网下载:https://assets.qiufei.site/data/stock/stock_price_pre_adjusted.h5
# 下载后把下一行改为本机文件位置;按课程结构存放时可用 Path("data/stock/stock_price_pre_adjusted.h5")。
# Windows:Path(r"C:\qiufei\data\stock\stock_price_pre_adjusted.h5")
# macOS:Path("/Users/你的用户名/data/stock/stock_price_pre_adjusted.h5")
# Linux:Path("/home/你的用户名/data/stock/stock_price_pre_adjusted.h5")
price_path = Path("/home/ubuntu/r2_data_mount/data/stock/stock_price_pre_adjusted.h5")
transfer_rows = pd.read_hdf(price_path, key='data', where=['order_book_id=="600276.XSHG"', 'date>=Timestamp("2018-01-01")', 'date<=Timestamp("2024-12-31")'], columns=['close'])  # 选择恒瑞医药收盘价
transfer_frame = transfer_rows.reset_index().sort_values('date')  # 按预测顺序排列交易日
transfer_frame['return_t'] = transfer_frame['close'].pct_change()  # 构造当日收益率
transfer_frame['return_5d_t'] = transfer_frame['close'].pct_change(5)  # 构造五日收益率
transfer_frame['volatility_5d_t'] = transfer_frame['return_t'].rolling(5).std()  # 构造历史五日波动率
transfer_frame['future_return_t1'] = transfer_frame['return_t'].shift(-1)  # 先保留连续未来收益
transfer_frame['target_date_t1'] = transfer_frame['date'].shift(-1)  # 保存标签实现日
transfer_frame = transfer_frame.dropna()  # 在整数化前删除未知未来和滚动缺失
transfer_frame['down_t1'] = (transfer_frame['future_return_t1'] < 0).astype(int)  # 仅对已观测未来收益构造标签
transfer_train = transfer_frame[(transfer_frame['date'] <= '2022-12-31') & (transfer_frame['target_date_t1'] < '2023-01-01')]  # 清除到测试期才实现的训练标签
transfer_test = transfer_frame[transfer_frame['date'] >= '2023-01-01']  # 测试期
assert transfer_train['target_date_t1'].max() < transfer_test['date'].min()  # 确认标签实现边界
transfer_features = ['return_t', 'return_5d_t', 'volatility_5d_t']  # 固定距离空间的三个维度
Table 3
Code
transfer_pipeline = Pipeline([('scale', StandardScaler()), ('knn', KNeighborsClassifier(weights='distance'))])  # 每一折只用训练数据估计缩放参数
transfer_cv = TimeSeriesSplit(5, gap=1)  # 一日预测 horizon 在每折训练与验证之间清除一行
assert all(transfer_train.iloc[fit_idx]['target_date_t1'].max() < transfer_train.iloc[val_idx]['date'].min() for fit_idx, val_idx in transfer_cv.split(transfer_train))  # 确认全部训练折标签先实现
transfer_grid = {'knn__metric': ['euclidean', 'manhattan'], 'knn__n_neighbors': [5, 15, 31]}  # 在同一验证证据上声明完整候选集合
transfer_search = GridSearchCV(transfer_pipeline, transfer_grid, cv=transfer_cv, scoring='balanced_accuracy', return_train_score=False).fit(transfer_train[transfer_features], transfer_train['down_t1'])  # 只用训练期时间折选择一个距离与 k 组合
validation_comparison = pd.DataFrame(transfer_search.cv_results_)[['param_knn__metric', 'param_knn__n_neighbors', 'mean_test_score']].sort_values('mean_test_score', ascending=False)  # 公开可比验证摘要
selected_metric = transfer_search.best_params_['knn__metric']  # 在查看测试标签前确定距离
selected_k = int(transfer_search.best_params_['knn__n_neighbors'])  # 在查看测试标签前确定邻居数
query_start = perf_counter()  # 在唯一测试预测前记录单调时钟
transfer_prediction = transfer_search.predict(transfer_test[transfer_features])  # 只用确定组合打开测试期一次
query_ms = (perf_counter() - query_start) * 1000  # 将唯一测试查询耗时换算为毫秒
transfer_test_summary = pd.Series({'selected_metric': selected_metric, 'selected_k': selected_k, 'test_prevalence': transfer_test['down_t1'].mean(), 'balanced_accuracy': balanced_accuracy_score(transfer_test['down_t1'], transfer_prediction), 'query_ms': query_ms})  # 保存一个模型设置的一次性测试检查

案例验证选择与测试期检查

Code
display(validation_comparison.round(6), transfer_test_summary.to_frame().T.round(6))  # 分开展示验证选择与测试期证据
param_knn__metric param_knn__n_neighbors mean_test_score
0 euclidean 5 0.519945
4 manhattan 15 0.517000
5 manhattan 31 0.512933
1 euclidean 15 0.511542
2 euclidean 31 0.510255
3 manhattan 5 0.508568
selected_metric selected_k test_prevalence balanced_accuracy query_ms
0 euclidean 5 0.52588 0.541321 3.145574
  • 训练期扩展窗口在六个候选中确定欧氏距离与 \(k=5\);随后只打开测试期一次。

  • 测试样本数为 483、下跌比例为 0.525880,确定组合的平衡准确率为 0.541321。

  • 最后特征日为 2024-12-30,未知未来标签已在整数化前删除。

  • 耗时随硬件变化;预测邻近不是因果相似。

形成性检查 3:维度灾难

维度上升时,最近点与最远点距离之比趋近 1,这对 KNN 意味着什么?

答案

邻居的相对区分度下降,距离加权和局部投票更不稳定;应做特征选择/降维、用领域距离或换用对高维更稳健的模型。

来源与延伸阅读

  • Cover & Hart (1967), “Nearest Neighbor Pattern Classification”。
  • Hastie, Tibshirani & Friedman, The Elements of Statistical Learning, Chapter 13。
  • scikit-learn User Guide:nearest neighbors 与 preprocessing。
  • 数据:本地 A 股前复权行情数据;文件、key、字段、时期和切分见 前面的案例。

4.5 总结: KNN算法的优缺点

课后选学

完成核心内容后,可以继续学习加权近邻、KNN 回归与 KD 树,随后进入最终思考

优点 (Pros) 缺点 (Cons)
原理简单,易于实现 计算成本高,预测速度慢
无需训练,模型适应性强 (非参数) ❌ 对内存需求大,需存储所有训练数据
✅ 对数据分布没有假设 ❌ 对不平衡数据敏感 (多数类会主导投票)
✅ 可用于分类和回归 高维灾难问题严重
✅ 决策边界可以非常灵活 ❌ 需要对特征进行标准化

KNN在商业决策中的应用场景

凭借其简单性和灵活性,KNN在多个商业领域都有广泛应用:

  • 金融: 客户信用评级,欺诈交易检测。
  • 市场营销: 客户细分,识别高价值客户群。
  • 推荐系统: “购买了X产品的用户也购买了Y产品”,这是典型的“寻找近邻”问题。
  • 医疗: 疾病诊断,基因模式识别。
  • 零售: 预测顾客购买行为,优化库存。

本章概念回顾:核心内容与拓展内容

概念 核心思想 关键参数/决策
KNN分类 少数服从多数 k, distance metric
KNN回归 邻居的平均值 k, distance metric
加权KNN 近朱者赤,近墨者黑 weights='distance'
KD树 空间换时间,预先划分 适用于低维数据
维度灾难 高维空间中距离失去意义 特征选择/降维是关键
标准化 使量纲可比,不等于赋予相同预测权重 仅在训练期拟合后再计算距离

最终思考: KNN成功的关键

要成功应用KNN,经济学家和数据科学家需要关注以下几点:

  1. 特征工程: 选择与问题最相关的特征至关重要。垃圾进,垃圾出。
  2. 距离度量: 为你的特定问题选择合适的距离度量。欧几里得距离并非总是最佳选择。
  3. 参数调优:
  • 核心内容预设等权欧氏多数投票,只在训练期时间顺序折中选择 \(k\),并在每折重新拟合标准化器;

  • 确定多数投票规则后,测试期只检查一次。

  1. 效率考量: 对于大规模数据集,必须考虑使用KD树、Ball Tree等加速算法,或采用近似最近邻搜索。

KNN是进入机器学习世界的一扇极好的大门,它简单、直观且功能强大。

谢谢!

Q & A