目录

前言

8.2 非参数密度估计

8.2.1 直方图估计

核心概念

实战代码(含对比图)

运行效果

8.2.2 核估计

核心概念

实战代码(含对比图)

运行效果

8.2.3 k 最近邻估计

核心概念

实战代码(含对比图)

运行效果

8.3 推广到多变元数据

核心概念

实战代码(二维核密度估计 + 3D 可视化)

运行效果

8.4 非参数分类

核心概念

实战代码(k-NN 分类 + 效果对比)

运行效果

8.5 精简的最近邻

核心概念

实战代码

运行效果

8.6 基于距离的分类

核心概念

实战代码

运行效果

8.7 离群点检测

核心概念

实战代码(k-NN 离群点检测 + 可视化)

运行效果

8.8 非参数回归:光滑模型

8.8.1 移动均值光滑

核心概念

实战代码

运行效果

8.8.2 核光滑

核心概念

实战代码

运行效果

8.8.3 移动线光滑

核心概念

实战代码

运行效果

8.9 如何选择光滑参数

实战代码(交叉验证选择核光滑带宽)

运行效果

8.10 注释 & 8.11 习题 & 8.12 参考文献

8.10 注释

8.11 习题

8.12 参考文献

总结


前言

        大家好!今天我们来拆解《机器学习导论》第 8 章的核心内容 —— 非参数方法。和参数方法(比如线性回归、逻辑回归需要预设模型形式)不同,非参数方法就像 “不预设框架的画家”,不需要提前假设数据服从某种分布(比如正态分布),而是让数据自己 “说话”,从数据本身挖掘规律。这一章的内容非常实用,从密度估计分类、回归、离群点检测都有覆盖,我会结合通俗易懂的讲解 + 可直接运行的 Python 代码 + 直观的对比图,帮大家吃透这些知识点。

        先放一张本章核心知识点的思维导图,帮大家建立整体认知:

8.2 非参数密度估计

        非参数密度估计的核心目标:在不知道数据分布的情况下,估算数据在各个区间的概率密度就像我们想知道 “身高 170-175cm 的人在人群中的占比”,但不知道身高服从什么分布,这时候就可以用非参数密度估计。

8.2.1 直方图估计

核心概念

        直方图估计是最基础的非参数密度估计方法,原理很简单:

  1. 把数据的取值范围分成若干个等宽的 “箱子(bin)”;
  2. 统计每个箱子里的样本数量;
  3. 用 “样本数 / 总样本数 / 箱子宽度” 作为这个箱子的密度值。

        你可以把它理解成 “给数据分格子,数每个格子里的人数,再算密度”。但直方图有个关键问题:箱子宽度(bin width)的选择会严重影响结果 —— 太宽会丢失细节,太窄会有很多噪声

实战代码(含对比图)
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm

# Mac系统Matplotlib中文显示配置
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 生成模拟数据:混合正态分布(更贴近真实场景)
np.random.seed(42)  # 固定随机种子,结果可复现
data = np.concatenate([
    norm.rvs(loc=0, scale=1, size=1000),  # 均值0,方差1
    norm.rvs(loc=4, scale=1.5, size=800)  # 均值4,方差1.5
])

# 绘制不同bin宽度的直方图对比
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
bin_widths = [0.2, 1.0, 3.0]
# 修复:将标题和宽度配对,避免提前引用bw变量
width_title_pairs = [
    (0.2, '箱子宽度=0.2(过窄,噪声多)'),
    (1.0, '箱子宽度=1.0(适中)'),
    (3.0, '箱子宽度=3.0(过宽,丢失细节)')
]

# 循环遍历配对后的宽度和标题(核心修改处)
for ax, (bw, title) in zip(axes, width_title_pairs):
    # 计算直方图(返回密度值而非计数)
    counts, bins, patches = ax.hist(data, bins=np.arange(min(data), max(data) + bw, bw),
                                    density=True, alpha=0.7, color='skyblue', edgecolor='black')
    # 绘制真实密度曲线(对比用)
    x = np.linspace(min(data), max(data), 1000)
    true_pdf = (1000 / (1000 + 800)) * norm.pdf(x, 0, 1) + (800 / (1000 + 800)) * norm.pdf(x, 4, 1.5)
    ax.plot(x, true_pdf, 'r-', linewidth=2, label='真实密度曲线')

    ax.set_title(title, fontsize=12)
    ax.set_xlabel('数据值', fontsize=10)
    ax.set_ylabel('概率密度', fontsize=10)
    ax.legend()
    ax.grid(alpha=0.3)

plt.suptitle('不同箱子宽度的直方图估计对比', fontsize=14)
plt.tight_layout()
plt.show()
运行效果

        会显示 3 个子图:

  • 宽度 0.2:直方图锯齿状严重,噪声多,完全看不清分布趋势;
  • 宽度 1.0:和真实密度曲线贴合度最高,能清晰看到两个峰值;
  • 宽度 3.0:直方图过于平滑,两个峰值融合成一个,丢失了数据的核心特征。

8.2.2 核估计

核心概念

        核估计(也叫核密度估计,KDE)是对直方图的 “升级版优化”。直方图是 “非黑即白”—— 一个样本只属于一个箱子;而核估计就像 “给每个样本画一个‘影响圈’(核函数)”,样本的影响会平滑地扩散到周围区域,最终的密度是所有样本 “影响圈” 的叠加

核心逻辑:

  1. 选择一个核函数(比如高斯核,像正态分布的钟形曲线);
  2. 给每个样本点分配一个核函数,控制核函数的 “宽度”(带宽,bandwidth)
  3. 所有核函数叠加后归一化,得到平滑的密度曲线。

        带宽是核估计的关键:带宽太小,曲线会过拟合噪声多);带宽太大,曲线会太平滑丢失细节

实战代码(含对比图)
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm, gaussian_kde
from scipy.signal import gaussian

# Mac系统Matplotlib中文显示配置(补充上,避免中文显示异常)
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 复用之前的模拟数据
np.random.seed(42)
data = np.concatenate([norm.rvs(loc=0, scale=1, size=1000), norm.rvs(loc=4, scale=1.5, size=800)])

# 手动实现核密度估计(高斯核),方便理解原理
def kernel_density_estimate(x, data, bandwidth):
    """
    高斯核密度估计
    :param x: 要估计密度的点
    :param data: 样本数据
    :param bandwidth: 带宽
    :return: x处的密度值
    """
    n = len(data)
    # 每个样本的高斯核贡献之和 / (n*带宽)
    kde = np.sum(norm.pdf((x - data[:, np.newaxis]) / bandwidth), axis=0) / (n * bandwidth)
    return kde

# 绘制不同带宽的核估计对比
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
bandwidths = [0.1, 1.0, 5.0]
# 修复:将带宽和标题配对,避免提前引用bw变量
bw_title_pairs = [
    (0.1, '带宽=0.1(过窄,过拟合)'),
    (1.0, '带宽=1.0(适中)'),
    (5.0, '带宽=5.0(过宽,欠拟合)')
]

x_grid = np.linspace(min(data) - 1, max(data) + 1, 1000)
# 真实密度曲线
true_pdf = (1000 / 1800) * norm.pdf(x_grid, 0, 1) + (800 / 1800) * norm.pdf(x_grid, 4, 1.5)

# 修复:循环遍历配对后的带宽和标题
for ax, (bw, title) in zip(axes, bw_title_pairs):
    # 手动计算KDE
    kde_values = kernel_density_estimate(x_grid, data, bw)
    # 绘制KDE曲线
    ax.plot(x_grid, kde_values, 'b-', linewidth=2, label='核估计曲线')
    # 绘制真实曲线
    ax.plot(x_grid, true_pdf, 'r--', linewidth=2, label='真实密度曲线')
    # 绘制样本点(散点)
    ax.scatter(data, np.zeros_like(data), alpha=0.1, color='gray', label='样本点')

    ax.set_title(title, fontsize=12)
    ax.set_xlabel('数据值', fontsize=10)
    ax.set_ylabel('概率密度', fontsize=10)
    ax.legend()
    ax.grid(alpha=0.3)

plt.suptitle('不同带宽的高斯核密度估计对比', fontsize=14)
plt.tight_layout()
plt.show()
运行效果
  • 带宽 0.1:曲线极度扭曲,跟着每个样本点 “波动”,过拟合;
  • 带宽 1.0:曲线平滑且和真实密度高度贴合,完美体现两个峰值;
  • 带宽 5.0:曲线过于平缓,两个峰值几乎消失,欠拟合。

8.2.3 k 最近邻估计

核心概念

        k 最近邻(k-NN)估计的思路和核估计相反:核估计是 “固定带宽,找范围内的样本”k-NN 估计是 “固定邻居数 k,找 k 个最近样本的范围,再算密度”

        可以比喻成:

  • 核估计:“我画一个半径固定的圆,数里面有多少人,算密度”;
  • k-NN 估计:“我要找 k 个邻居,画一个刚好包含这 k 个人的圆,用 k / 圆面积算密度”。

        k 值越小,密度曲线越 “抖”(噪声多);k 值越大,曲线越平滑。

实战代码(含对比图)
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm
from scipy.spatial.distance import cdist

# Mac系统Matplotlib中文显示配置(补充,避免中文乱码)
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 复用模拟数据
np.random.seed(42)
data = np.concatenate([norm.rvs(loc=0, scale=1, size=1000), norm.rvs(loc=4, scale=1.5, size=800)])
data = data.reshape(-1, 1)  # 转成二维,方便计算距离

# 实现k最近邻密度估计
def knn_density_estimate(x, data, k):
    """
    k近邻密度估计
    :param x: 要估计密度的点(二维数组)
    :param data: 样本数据(二维数组)
    :param k: 邻居数
    :return: x处的密度值
    """
    n = len(data)
    # 计算x到所有样本的距离
    distances = cdist(x, data)
    # 取每个x点的第k近邻距离(修复:原代码取[:,k]会越界,改为[:,k-1])
    k_dist = np.partition(distances, k-1, axis=1)[:, k-1]
    # 密度公式:k/(n * 体积),一维下体积是2*k_dist(区间长度)
    density = k / (n * 2 * k_dist)
    return density

# 绘制不同k值的k-NN估计对比
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
k_values = [5, 50, 500]
# 修复:将k值和标题配对,避免提前引用k变量
k_title_pairs = [
    (5, 'k=5(过小,噪声多)'),
    (50, 'k=50(适中)'),
    (500, 'k=500(过大,过平滑)')
]

x_grid = np.linspace(min(data) - 1, max(data) + 1, 1000).reshape(-1, 1)
true_pdf = (1000 / 1800) * norm.pdf(x_grid, 0, 1) + (800 / 1800) * norm.pdf(x_grid, 4, 1.5)

# 修复:循环遍历配对后的k值和标题
for ax, (k, title) in zip(axes, k_title_pairs):
    # 计算k-NN密度
    knn_pdf = knn_density_estimate(x_grid, data, k)
    # 绘制k-NN曲线
    ax.plot(x_grid, knn_pdf, 'g-', linewidth=2, label='k-NN估计曲线')
    # 绘制真实曲线
    ax.plot(x_grid, true_pdf, 'r--', linewidth=2, label='真实密度曲线')

    ax.set_title(title, fontsize=12)
    ax.set_xlabel('数据值', fontsize=10)
    ax.set_ylabel('概率密度', fontsize=10)
    ax.legend()
    ax.grid(alpha=0.3)
    # 限制y轴范围,方便对比
    ax.set_ylim(0, 0.2)

plt.suptitle('不同k值的k近邻密度估计对比', fontsize=14)
plt.tight_layout()
plt.show()
运行效果
  • k=5:曲线锯齿多,噪声明显;
  • k=50:曲线平滑,和真实密度贴合度高;
  • k=500:曲线过于平滑,两个峰值几乎融合。

8.3 推广到多变元数据

核心概念

        前面讲的都是一维数据的密度估计,而多变元(高维)数据的核心挑战“维度灾难”—— 维度越高,数据越稀疏,需要的样本量呈指数级增长。

比如:

  • 一维时,1000 个样本能覆盖 [0,10] 区间;
  • 二维时,要覆盖 [0,10]×[0,10] 的平面,需要 10000 个样本才能达到同样的密度;
  • 三维时,需要 100000 个样本,以此类推。

        多变元核估计的思路和一维一致,只是核函数变成高维(比如二维高斯核),k-NN 估计的 “体积” 变成高维体积(二维是面积,三维是体积)。

实战代码(二维核密度估计 + 3D 可视化)
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import multivariate_normal
from mpl_toolkits.mplot3d import Axes3D

# Mac系统Matplotlib中文显示配置(补充,避免中文乱码)
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 生成二维模拟数据:两个二维正态分布混合
np.random.seed(42)
# 分布1:均值(0,0),协方差[[1,0.5],[0.5,1]]
data1 = multivariate_normal.rvs(mean=[0, 0], cov=[[1, 0.5], [0.5, 1]], size=800)
# 分布2:均值(4,4),协方差[[1,-0.5],[-0.5,1]]
data2 = multivariate_normal.rvs(mean=[4, 4], cov=[[1, -0.5], [-0.5, 1]], size=600)
data_2d = np.concatenate([data1, data2])

# 二维高斯核密度估计
def kde_2d(x, y, data, bandwidth):
    """二维高斯核密度估计"""
    n = len(data)
    # 网格点坐标
    xy = np.vstack([x.ravel(), y.ravel()]).T
    # 每个样本的核贡献
    kde_vals = np.zeros(len(xy))
    for i in range(n):
        kde_vals += multivariate_normal.pdf(xy, mean=data[i], cov=bandwidth*np.eye(2))
    # 归一化
    kde_vals = kde_vals / (n * (2*np.pi*bandwidth))
    return kde_vals.reshape(x.shape)

# 创建网格
x = np.linspace(min(data_2d[:,0])-1, max(data_2d[:,0])+1, 100)
y = np.linspace(min(data_2d[:,1])-1, max(data_2d[:,1])+1, 100)
X, Y = np.meshgrid(x, y)
# 计算二维KDE(带宽=0.8)
Z = kde_2d(X, Y, data_2d, bandwidth=0.8)

# 绘制3D效果图
fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(111, projection='3d')
# 绘制3D曲面
surf = ax.plot_surface(X, Y, Z, cmap='viridis', alpha=0.8, linewidth=0)
# 绘制样本点
ax.scatter(data_2d[:,0], data_2d[:,1], np.zeros_like(data_2d[:,0]),
           color='red', alpha=0.5, s=10, label='样本点')

ax.set_title('二维核密度估计(3D可视化)', fontsize=14)
ax.set_xlabel('X轴', fontsize=10)
ax.set_ylabel('Y轴', fontsize=10)
ax.set_zlabel('概率密度', fontsize=10)
fig.colorbar(surf, ax=ax, shrink=0.5, aspect=5)
ax.legend()
plt.tight_layout()
plt.show()
运行效果

        会显示一个 3D 曲面图:曲面有两个明显的峰值(对应两个二维正态分布的均值),样本点分布在底部,直观展示了二维数据的密度分布。

8.4 非参数分类

核心概念

        非参数分类不需要预设分类模型(比如逻辑回归的 Sigmoid 函数),核心思路是 “近朱者赤,近墨者黑”—— 通过样本的近邻信息来判断类别。最经典的就是 k 近邻分类(k-NN)。

逻辑:

  1. 对于待分类样本,找到它的 k 个最近邻样本;
  2. 统计这 k 个样本的类别;
  3. “少数服从多数”(分类)或 “加权投票”(比如按距离加权)确定待分类样本的类别。
实战代码(k-NN 分类 + 效果对比)
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

# Mac系统Matplotlib中文显示配置
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 生成分类数据集
X, y = make_classification(n_samples=1000, n_features=2, n_informative=2,
                           n_redundant=0, n_classes=2, random_state=42)
# 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

# 测试不同k值的k-NN分类效果
k_values = [1, 5, 50]
accuracies = []
fig, axes = plt.subplots(1, 3, figsize=(18, 6))

# 创建网格,用于绘制决策边界
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.linspace(x_min, x_max, 100), np.linspace(y_min, y_max, 100))

for ax, k in zip(axes, k_values):
    # 训练k-NN模型
    knn = KNeighborsClassifier(n_neighbors=k)
    knn.fit(X_train, y_train)
    # 预测测试集
    y_pred = knn.predict(X_test)
    acc = accuracy_score(y_test, y_pred)
    accuracies.append(acc)
    # 预测网格点,绘制决策边界
    Z = knn.predict(np.c_[xx.ravel(), yy.ravel()])
    Z = Z.reshape(xx.shape)
    ax.contourf(xx, yy, Z, alpha=0.3, cmap='coolwarm')
    # 绘制训练样本
    ax.scatter(X_train[:, 0], X_train[:, 1], c=y_train, edgecolors='k', cmap='coolwarm', s=50)
    # 设置标题和标签
    ax.set_title(f'k={k},测试集准确率={acc:.3f}', fontsize=12)
    ax.set_xlabel('特征1', fontsize=10)
    ax.set_ylabel('特征2', fontsize=10)
    ax.grid(alpha=0.3)

plt.suptitle('不同k值的k-NN分类决策边界对比', fontsize=14)
plt.tight_layout()
plt.show()

# 输出准确率
print(f"k=1 准确率:{accuracies[0]:.3f}")
print(f"k=5 准确率:{accuracies[1]:.3f}")
print(f"k=50 准确率:{accuracies[2]:.3f}")
运行效果
  • k=1:决策边界极度复杂(过拟合),准确率略低;
  • k=5:决策边界平滑,准确率最高;
  • k=50:决策边界过于简单(欠拟合),准确率下降。

8.5 精简的最近邻

核心概念

        标准 k-NN 的问题:样本量很大时,预测速度慢(需要计算待分类样本和所有训练样本的距离)。精简的最近邻(Condensed Nearest Neighbor,CNN)就是 “精简训练集”—— 只保留对分类关键的样本,去掉冗余样本,在不损失准确率的前提下提升速度

        可以比喻成:“挑出团队里的核心成员,去掉可有可无的人,团队效率更高”

实战代码
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from sklearn.neighbors import NearestNeighbors

# Mac系统Matplotlib中文显示配置
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 实现精简最近邻(CNN)算法
def condensed_nearest_neighbor(X, y):
    """
    精简最近邻算法
    :param X: 训练特征
    :param y: 训练标签
    :return: 精简后的特征和标签
    """
    # 初始化:随机选一个样本作为初始核心集
    np.random.seed(42)
    idx = np.random.randint(0, len(X))
    X_condensed = [X[idx]]
    y_condensed = [y[idx]]

    # 遍历所有样本,筛选核心样本
    for i in range(len(X)):
        if i == idx:
            continue
        # 用当前核心集做1-NN预测
        knn = KNeighborsClassifier(n_neighbors=1)
        knn.fit(X_condensed, y_condensed)
        y_pred = knn.predict([X[i]])
        # 如果预测错误,说明该样本是核心样本,加入核心集
        if y_pred[0] != y[i]:
            X_condensed.append(X[i])
            y_condensed.append(y[i])

    return np.array(X_condensed), np.array(y_condensed)


# 生成数据集
X, y = make_classification(n_samples=1000, n_features=2, n_informative=2,
                           n_redundant=0, n_classes=2, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

# 精简训练集
X_condensed, y_condensed = condensed_nearest_neighbor(X_train, y_train)

# 对比原始和精简后的效果
# 原始k-NN
knn_original = KNeighborsClassifier(n_neighbors=5)
knn_original.fit(X_train, y_train)
acc_original = accuracy_score(y_test, knn_original.predict(X_test))

# 精简后k-NN
knn_condensed = KNeighborsClassifier(n_neighbors=5)
knn_condensed.fit(X_condensed, y_condensed)
acc_condensed = accuracy_score(y_test, knn_condensed.predict(X_test))

# 输出结果
print(f"原始训练集样本数:{len(X_train)}")
print(f"精简后训练集样本数:{len(X_condensed)}")
print(f"原始k-NN准确率:{acc_original:.3f}")
print(f"精简k-NN准确率:{acc_condensed:.3f}")

# 可视化对比
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
# 原始样本
ax1.scatter(X_train[:, 0], X_train[:, 1], c=y_train, cmap='coolwarm', alpha=0.6, s=50)
ax1.set_title(f'原始训练集({len(X_train)}个样本)', fontsize=12)
ax1.set_xlabel('特征1', fontsize=10)
ax1.set_ylabel('特征2', fontsize=10)
# 精简样本
ax2.scatter(X_condensed[:, 0], X_condensed[:, 1], c=y_condensed, cmap='coolwarm', alpha=0.8, s=50)
ax2.set_title(f'精简训练集({len(X_condensed)}个样本)', fontsize=12)
ax2.set_xlabel('特征1', fontsize=10)
ax2.set_ylabel('特征2', fontsize=10)

plt.tight_layout()
plt.show()
运行效果
  • 精简后的训练集样本数远少于原始样本(通常能减少 50% 以上);
  • 准确率几乎和原始 k-NN 一致,但预测速度大幅提升。

8.6 基于距离的分类

核心概念

        基于距离的分类是 k-NN 的扩展,核心是 “给距离加权重”—— 距离越近的样本,投票权重越高。常见的有:

  • 距离倒数加权:权重 = 1 / 距离;
  • 高斯加权:权重 = exp (- 距离 ²/ 带宽 ²)。

        相比标准 k-NN(等权重),加权距离分类能减少 “远邻” 的干扰,提升分类准确率

实战代码
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

# Mac系统Matplotlib中文显示配置
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 生成数据集
X, y = make_classification(n_samples=1000, n_features=2, n_informative=2,
                           n_redundant=0, n_classes=2, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

# 对比不同权重的k-NN
# 1. 等权重(标准k-NN)
knn_unweighted = KNeighborsClassifier(n_neighbors=5, weights='uniform')
knn_unweighted.fit(X_train, y_train)
acc_unweighted = accuracy_score(y_test, knn_unweighted.predict(X_test))

# 2. 距离倒数加权
knn_distance = KNeighborsClassifier(n_neighbors=5, weights='distance')
knn_distance.fit(X_train, y_train)
acc_distance = accuracy_score(y_test, knn_distance.predict(X_test))

# 输出结果
print(f"等权重k-NN准确率:{acc_unweighted:.3f}")
print(f"距离倒数加权k-NN准确率:{acc_distance:.3f}")

# 绘制决策边界对比
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
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.linspace(x_min, x_max, 100), np.linspace(y_min, y_max, 100))

# 等权重
Z1 = knn_unweighted.predict(np.c_[xx.ravel(), yy.ravel()])
Z1 = Z1.reshape(xx.shape)
ax1.contourf(xx, yy, Z1, alpha=0.3, cmap='coolwarm')
ax1.scatter(X_train[:,0], X_train[:,1], c=y_train, edgecolors='k', cmap='coolwarm', s=50)
ax1.set_title(f'等权重k-NN(准确率={acc_unweighted:.3f})', fontsize=12)

# 距离加权
Z2 = knn_distance.predict(np.c_[xx.ravel(), yy.ravel()])
Z2 = Z2.reshape(xx.shape)
ax2.contourf(xx, yy, Z2, alpha=0.3, cmap='coolwarm')
ax2.scatter(X_train[:,0], X_train[:,1], c=y_train, edgecolors='k', cmap='coolwarm', s=50)
ax2.set_title(f'距离加权k-NN(准确率={acc_distance:.3f})', fontsize=12)

plt.tight_layout()
plt.show()
运行效果

        距离加权的 k-NN 决策边界更 “贴合” 数据分布,准确率通常高于等权重 k-NN。

8.7 离群点检测

核心概念

        离群点(异常值)是 “与众不同” 的样本,非参数方法检测离群点的核心思路:

  • k-NN 离群点检测:样本的 k 近邻平均距离越大,越可能是离群点;
  • 可以比喻成:“一个人周围越空(邻居少 / 距离远),他越可能是‘异类’”。
实战代码(k-NN 离群点检测 + 可视化)
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs
from scipy.spatial.distance import cdist

# Mac系统Matplotlib中文显示配置
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 生成带离群点的数据集
np.random.seed(42)
# 正常样本
X_normal, _ = make_blobs(n_samples=500, centers=[[0,0]], cluster_std=1, random_state=42)
# 离群点(随机生成)
X_outliers = np.random.uniform(low=-10, high=10, size=(50, 2))
X = np.concatenate([X_normal, X_outliers])
# 标记真实离群点
y_true = np.zeros(len(X))
y_true[-50:] = 1

# k-NN离群点检测
def knn_outlier_detection(X, k, threshold):
    """
    k-NN离群点检测
    :param X: 数据
    :param k: 邻居数
    :param threshold: 距离阈值(大于阈值则为离群点)
    :return: 离群点标记(1=离群点,0=正常)
    """
    # 计算每个样本到k近邻的平均距离
    distances = cdist(X, X)
    # 排除自身(距离为0),取k个最近邻的距离
    distances[np.diag_indices(len(X))] = np.inf
    k_distances = np.partition(distances, k, axis=1)[:, :k]
    mean_k_dist = np.mean(k_distances, axis=1)
    # 标记离群点
    y_pred = (mean_k_dist > threshold).astype(int)
    return y_pred, mean_k_dist

# 检测离群点
k = 10
threshold = np.percentile(knn_outlier_detection(X, k, 0)[1], 95)  # 取95分位数作为阈值
y_pred, mean_k_dist = knn_outlier_detection(X, k, threshold)

# 可视化结果
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
# 原始数据
ax1.scatter(X[:,0], X[:,1], c=y_true, cmap='coolwarm', alpha=0.6, s=50)
ax1.set_title('原始数据(红色=真实离群点)', fontsize=12)
ax1.set_xlabel('特征1', fontsize=10)
ax1.set_ylabel('特征2', fontsize=10)

# 检测结果
ax2.scatter(X[:,0], X[:,1], c=y_pred, cmap='coolwarm', alpha=0.6, s=50)
ax2.set_title(f'k-NN检测结果(红色=检测出的离群点)', fontsize=12)
ax2.set_xlabel('特征1', fontsize=10)
ax2.set_ylabel('特征2', fontsize=10)

plt.tight_layout()
plt.show()

# 输出检测效果
from sklearn.metrics import f1_score
print(f"离群点检测F1分数:{f1_score(y_true, y_pred):.3f}")
运行效果
  • 左图:红色是人工生成的离群点,蓝色是正常样本;
  • 右图:红色是 k-NN 检测出的离群点,能准确识别大部分异常值。

8.8 非参数回归:光滑模型

        非参数回归和参数回归(比如线性回归)的区别:不需要预设 y 和 x 的线性 / 非线性关系,而是通过 “光滑” 的方式,让回归曲线贴合数据趋势。

8.8.1 移动均值光滑

核心概念

        移动均值光滑(也叫滑动平均):“以当前点为中心,取一个窗口内的所有样本,用均值作为当前点的回归值”。就像 “用周围人的平均身高,估计你的身高”,窗口大小(带宽)决定了光滑程度。

实战代码
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm

# Mac系统Matplotlib中文显示配置
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 生成回归模拟数据(带噪声)
np.random.seed(42)
x = np.linspace(0, 10, 200)
# 真实函数:sin(x) + 0.5x
y_true = np.sin(x) + 0.5 * x
# 加噪声
y_noisy = y_true + norm.rvs(0, 0.3, size=len(x))

# 移动均值光滑
def moving_average_smooth(x, y, window_size):
    """
    移动均值光滑
    :param x: 自变量
    :param y: 因变量(带噪声)
    :param window_size: 窗口大小(相邻样本数)
    :return: 光滑后的y值
    """
    y_smooth = np.zeros_like(y)
    half_window = window_size // 2
    for i in range(len(x)):
        # 确定窗口范围
        start = max(0, i - half_window)
        end = min(len(x), i + half_window + 1)
        # 窗口内的均值
        y_smooth[i] = np.mean(y[start:end])
    return y_smooth

# 不同窗口大小的对比
window_sizes = [3, 10, 30]
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
# 修复:将窗口大小和标题配对,避免提前引用ws变量
ws_title_pairs = [
    (3, '窗口大小=3(过窄,欠光滑)'),
    (10, '窗口大小=10(适中)'),
    (30, '窗口大小=30(过宽,过光滑)')
]

# 修复:循环遍历配对后的窗口大小和标题
for ax, (ws, title) in zip(axes, ws_title_pairs):
    y_smooth = moving_average_smooth(x, y_noisy, ws)
    # 绘制原始数据、真实曲线、光滑曲线
    ax.scatter(x, y_noisy, alpha=0.5, color='gray', label='带噪声数据')
    ax.plot(x, y_true, 'r-', linewidth=2, label='真实曲线')
    ax.plot(x, y_smooth, 'b-', linewidth=2, label='移动均值光滑曲线')
    ax.set_title(title, fontsize=12)
    ax.set_xlabel('x', fontsize=10)
    ax.set_ylabel('y', fontsize=10)
    ax.legend()
    ax.grid(alpha=0.3)

plt.suptitle('不同窗口大小的移动均值光滑对比', fontsize=14)
plt.tight_layout()
plt.show()
运行效果
  • 窗口 3:光滑曲线跟着噪声波动,欠光滑;
  • 窗口 10:光滑曲线和真实曲线贴合度高;
  • 窗口 30:光滑曲线过于平缓,丢失了真实曲线的波动。

8.8.2 核光滑

核心概念

        核光滑是移动均值的升级版:移动均值是 “窗口内等权重”核光滑是 “窗口内按距离加权(近的权重高,远的权重低)”,用核函数(比如高斯核)计算权重。

实战代码
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm

# Mac系统Matplotlib中文显示配置
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 复用之前的回归数据
np.random.seed(42)
x = np.linspace(0, 10, 200)
y_true = np.sin(x) + 0.5 * x
y_noisy = y_true + norm.rvs(0, 0.3, size=len(x))

# 核光滑(高斯核)
def kernel_smooth(x, y, bandwidth):
    """
    高斯核光滑
    :param x: 自变量
    :param y: 因变量(带噪声)
    :param bandwidth: 带宽
    :return: 光滑后的y值
    """
    y_smooth = np.zeros_like(y)
    for i in range(len(x)):
        # 计算当前点到所有点的距离
        dist = x - x[i]
        # 高斯核权重
        weights = norm.pdf(dist, 0, bandwidth)
        # 加权平均
        y_smooth[i] = np.sum(weights * y) / np.sum(weights)
    return y_smooth

# 不同带宽的对比
bandwidths = [0.1, 0.5, 2.0]
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
# 修复:将带宽和标题配对,避免提前引用bw变量
bw_title_pairs = [
    (0.1, '带宽=0.1(过窄,欠光滑)'),
    (0.5, '带宽=0.5(适中)'),
    (2.0, '带宽=2.0(过宽,过光滑)')
]

# 修复:循环遍历配对后的带宽和标题
for ax, (bw, title) in zip(axes, bw_title_pairs):
    y_smooth = kernel_smooth(x, y_noisy, bw)
    ax.scatter(x, y_noisy, alpha=0.5, color='gray', label='带噪声数据')
    ax.plot(x, y_true, 'r-', linewidth=2, label='真实曲线')
    ax.plot(x, y_smooth, 'b-', linewidth=2, label='核光滑曲线')
    ax.set_title(title, fontsize=12)
    ax.set_xlabel('x', fontsize=10)
    ax.set_ylabel('y', fontsize=10)
    ax.legend()
    ax.grid(alpha=0.3)

plt.suptitle('不同带宽的高斯核光滑对比', fontsize=14)
plt.tight_layout()
plt.show()
运行效果
  • 带宽 0.1:曲线跟着噪声走,欠光滑;
  • 带宽 0.5:曲线平滑且贴合真实趋势;
  • 带宽 2.0:曲线过于平缓,丢失细节。

8.8.3 移动线光滑

核心概念

        移动线光滑(也叫局部线性回归):比移动均值和核光滑更灵活 —— 不是用均值 / 加权均值,而是在每个窗口内拟合一条直线,用直线的预测值作为当前点的回归值。能更好地捕捉数据的局部趋势

实战代码
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm
from sklearn.linear_model import LinearRegression

# Mac系统Matplotlib中文显示配置
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 复用回归数据
np.random.seed(42)
x = np.linspace(0, 10, 200)
y_true = np.sin(x) + 0.5 * x
y_noisy = y_true + norm.rvs(0, 0.3, size=len(x))
x_reshaped = x.reshape(-1, 1)

# 移动线光滑(局部线性回归)
def moving_line_smooth(x, y, window_size):
    """
    移动线光滑
    :param x: 自变量(一维)
    :param y: 因变量(带噪声)
    :param window_size: 窗口大小
    :return: 光滑后的y值
    """
    y_smooth = np.zeros_like(y)
    half_window = window_size // 2
    for i in range(len(x)):
        # 确定窗口范围
        start = max(0, i - half_window)
        end = min(len(x), i + half_window + 1)
        # 窗口内拟合线性回归
        lr = LinearRegression()
        lr.fit(x[start:end].reshape(-1, 1), y[start:end])
        # 预测当前点
        y_smooth[i] = lr.predict([[x[i]]])[0]
    return y_smooth

# 不同窗口大小的对比
window_sizes = [5, 15, 40]
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
# 修复:将窗口大小和标题配对,避免提前引用ws变量
ws_title_pairs = [
    (5, '窗口大小=5(过窄)'),
    (15, '窗口大小=15(适中)'),
    (40, '窗口大小=40(过宽)')
]

# 修复:循环遍历配对后的窗口大小和标题
for ax, (ws, title) in zip(axes, ws_title_pairs):
    y_smooth = moving_line_smooth(x, y_noisy, ws)
    ax.scatter(x, y_noisy, alpha=0.5, color='gray', label='带噪声数据')
    ax.plot(x, y_true, 'r-', linewidth=2, label='真实曲线')
    ax.plot(x, y_smooth, 'b-', linewidth=2, label='移动线光滑曲线')
    ax.set_title(title, fontsize=12)
    ax.set_xlabel('x', fontsize=10)
    ax.set_ylabel('y', fontsize=10)
    ax.legend()
    ax.grid(alpha=0.3)

plt.suptitle('不同窗口大小的移动线光滑对比', fontsize=14)
plt.tight_layout()
plt.show()
运行效果

        移动线光滑的曲线比移动均值更贴合真实曲线,尤其是在数据有局部线性趋势的区域。

8.9 如何选择光滑参数

        光滑参数(比如核估计的带宽、移动均值的窗口大小)是决定非参数方法效果的核心,常见的选择方法:

  1. 经验法:通过可视化对比不同参数的效果(就像我们前面的代码一样);
  2. 交叉验证:将数据分成训练集验证集,选择验证集误差最小的参数;
  3. 规则法:比如 Scott 规则、Silverman 规则(针对核密度估计的带宽)。
实战代码(交叉验证选择核光滑带宽)
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import norm
from sklearn.model_selection import LeaveOneOut

# Mac系统Matplotlib中文显示配置
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.family'] = 'Arial Unicode MS'
plt.rcParams['axes.facecolor'] = 'white'

# 复用回归数据
np.random.seed(42)
x = np.linspace(0, 10, 200)
y_true = np.sin(x) + 0.5 * x
y_noisy = y_true + norm.rvs(0, 0.3, size=len(x))

# 核光滑函数(复用)
def kernel_smooth(x, y, bandwidth):
    y_smooth = np.zeros_like(y)
    for i in range(len(x)):
        dist = x - x[i]
        weights = norm.pdf(dist, 0, bandwidth)
        y_smooth[i] = np.sum(weights * y) / np.sum(weights)
    return y_smooth

# 留一交叉验证选择带宽
def cross_validate_bandwidth(x, y, bandwidths):
    """
    留一交叉验证选择最优带宽
    :param x: 自变量
    :param y: 因变量
    :param bandwidths: 待测试的带宽列表
    :return: 最优带宽,各带宽的均方误差
    """
    loo = LeaveOneOut()
    mse_list = []
    for bw in bandwidths:
        mse = 0
        for train_idx, test_idx in loo.split(x):
            x_train, y_train = x[train_idx], y[train_idx]
            x_test, y_test = x[test_idx], y[test_idx]
            # 训练集上做核光滑,预测测试点
            y_smooth = kernel_smooth(x_train, y_train, bw)
            # 找到测试点在训练集的位置(近似)
            pred = kernel_smooth(np.array([x_test[0]]), y_train, bw)[0]
            mse += (pred - y_test[0])**2
        mse /= len(x)
        mse_list.append(mse)
    # 找MSE最小的带宽
    best_bw = bandwidths[np.argmin(mse_list)]
    return best_bw, mse_list

# 测试带宽范围
bandwidths = np.linspace(0.1, 2.0, 20)
best_bw, mse_list = cross_validate_bandwidth(x, y_noisy, bandwidths)

# 可视化MSE曲线
plt.figure(figsize=(10, 6))
plt.plot(bandwidths, mse_list, 'b-', linewidth=2)
plt.scatter(best_bw, np.min(mse_list), color='red', s=100, label=f'最优带宽={best_bw:.2f}')
plt.title('交叉验证选择核光滑带宽(MSE越小越好)', fontsize=14)
plt.xlabel('带宽', fontsize=10)
plt.ylabel('留一交叉验证MSE', fontsize=10)
plt.legend()
plt.grid(alpha=0.3)
plt.show()

# 用最优带宽做核光滑
y_smooth_best = kernel_smooth(x, y_noisy, best_bw)
plt.figure(figsize=(10, 6))
plt.scatter(x, y_noisy, alpha=0.5, color='gray', label='带噪声数据')
plt.plot(x, y_true, 'r-', linewidth=2, label='真实曲线')
plt.plot(x, y_smooth_best, 'b-', linewidth=2, label=f'最优带宽({best_bw:.2f})核光滑曲线')
plt.legend()
plt.grid(alpha=0.3)
plt.show()

运行效果
  • 第一张图:MSE 曲线先降后升,最低点对应的带宽就是最优带宽;
  • 第二张图:用最优带宽得到的光滑曲线和真实曲线高度贴合。

8.10 注释 & 8.11 习题 & 8.12 参考文献

8.10 注释

        1.非参数方法的优势是 “灵活”,不需要预设模型;劣势是 “计算量大”“对样本量要求高”(尤其是高维数据);

        2.所有非参数方法的核心都是 “光滑参数的选择”—— 没有万能参数,需结合数据和业务场景

        3.k-NN 是 “懒惰学习”(Lazy Learning)—— 训练时不学习模型,预测时才计算距离,适合小样本场景。

8.11 习题

  1. 用自己的数据集(比如鸢尾花数据集)对比直方图估计、核估计、k-NN 估计的密度估计效果;
  2. 调整 k-NN 分类的 k 值,观察过拟合 / 欠拟合的变化规律;
  3. 用交叉验证选择 k-NN 离群点检测的 k 值和阈值,提升检测准确率;
  4. 对比移动均值、核光滑、移动线光滑在不同噪声水平下的回归效果。

8.12 参考文献

  1. 《机器学习导论》(原书第 4 版)——Ethem Alpaydin 著;
  2. 《统计学习方法》(第 2 版)—— 李航 著;
  3. Scikit-learn 官方文档:https://scikit-learn.org/stable/
  4. 《Kernel Methods for Pattern Analysis》——John Shawe-Taylor 等著。

总结

        1.非参数方法的核心是 “让数据自己说话”,无需预设分布 / 模型,关键是选择合适的光滑参数(带宽、k 值、窗口大小);

        2.非参数密度估计包括直方图(基础)、核估计(平滑)、k-NN 估计(自适应范围),高维数据需注意维度灾难;

        3.非参数方法可覆盖分类、离群点检测、回归等场景,其中 k-NN 分类 / 回归、核光滑回归是最常用的实战方法,交叉验证是选择最优参数的有效手段。

        希望这篇帖子能帮大家吃透《机器学习导论》第 8 章的非参数方法!所有代码都可直接运行,建议大家动手调参,直观感受参数对结果的影响~如果有问题,欢迎评论区交流!

更多推荐