大数跨境

导师急了:“你懂 KPCA 吗?”我:“不懂,但用 Codex 跑出来了!”导师:“重点是搞懂核技巧和核矩阵怎么把非线性结构展开!”

导师急了:“你懂 KPCA 吗?”我:“不懂,但用 Codex 跑出来了!”导师:“重点是搞懂核技巧和核矩阵怎么把非线性结构展开!” 机器学习和人工智能AI
2026-08-17
1

哈喽,大家好~

机器学习里经常会提到一个词:核(kernel)

第一次接触的时候,它很容易给人一种“数学很玄”的感觉:什么高维空间、希尔伯特空间、内积、Gram 矩阵……一堆概念叠在一起,越看越抽象。

其实我们先抓住一句最核心的话就够了:核方法的价值,是让一些原本在低维空间里很难用线性方法处理的问题,可以借助“隐式特征映射”获得非线性能力。

注意,这里不是说“只要映射到高维就一定线性可分”。更准确地说,核函数让我们能够在某个特征空间里使用线性代数和线性模型,而这个特征空间中的线性关系,映射回原空间之后往往对应复杂的非线性关系。

更巧妙的地方是:很多时候我们甚至不需要真的把数据算到那个高维空间里。

假设存在一个映射:

我们真正需要的,往往只是映射后两个样本的内积:

如果存在一个核函数满足:

那我们直接计算   就可以了,不必显式求出 

这就是著名的 Kernel Trick(核技巧)

下面我会给大家先把核心逻辑讲清楚,再用一个完整的 PyTorch 案例,把 RBF 核、核矩阵、Kernel PCA、Kernel Ridge 和决策边界串起来~

核方法在解决什么问题?

为什么需要“核”?

很多数据在原始特征空间里,本身就不是简单的线性关系。

比如经典的两个月牙数据 make_moons,你很难画一条直线,把两类样本干净地分开。

这时候,一种自然的思路就是:

既然原空间不好分,那能不能换一个特征表示?

比如把二维数据映射到更高维:

原空间里弯弯曲曲的关系,到了新的特征空间以后,有可能变得更容易用线性方法处理。

问题在于,如果真的显式做高维展开,维度很容易爆炸;有些核对应的特征空间甚至可能是无限维的,更不可能直接把所有特征都算出来。

核技巧解决的,就是这个问题。

Kernel Trick 怎么做?

很多算法最终并不需要知道   的每一个坐标,而只依赖样本之间的内积。

于是我们可以直接定义:

这样,算法看起来是在高维特征空间里工作,但程序实际上只需要计算核函数。

这也是为什么核方法很漂亮,它把“高维特征映射”藏在了一个两两相似度计算里。

不过这里也要注意,不是任何算法随便把内积换成核函数都能完成“核化”。通常要求算法能够写成只依赖样本内积的形式,或者能够通过对偶形式、表示定理等方式改写。

常见核函数

1. 线性核

它其实就是普通内积,本身没有额外引入非线性。

2. 多项式核

它可以理解成隐式加入不同阶数的多项式组合特征。

3. RBF / Gaussian 核

这是最经典、最常用的核之一。

它本质上是在衡量两个样本有多近:

  • 两个点越接近,核值越接近 1;
  • 两个点越远,核值越接近 0。

其中   控制相似度衰减速度。

 越大,核越“窄”,模型更关注非常局部的邻域;  越小,核越“宽”,相似度变化更平滑。

如果写成高斯分布里常见的尺度参数形式,也可以写成:

此时:

4. 其他核

还会见到 Laplacian Kernel、Sigmoid Kernel 等。

不过有一点值得注意:一个函数想作为标准核函数使用,需要满足对应的正半定条件。 有些形式(例如某些参数下的 Sigmoid Kernel)并不是任意参数都天然满足这个要求。

核矩阵是什么?

假设现在有   个样本:

两两计算核函数,可以得到一个   的矩阵:

这个矩阵就是 Gram Matrix(核矩阵)

对于一个合法的实值对称核,任意有限样本得到的 Gram 矩阵应该是对称正半定的。

很多核方法,最后其实都是围绕这个矩阵做线性代数。

例如 Kernel Ridge Regression 的一种常见写法是:

实际编程时,我们通常不会真的去显式计算逆矩阵,而是直接求解线性方程组:

对一个新样本  ,预测值为:

这就是核方法非常典型的一种计算方式。

这次实验要做什么?

下面我们用一个两个月牙数据集,把几个重要概念串起来:

  • 生成 make_moons 非线性分类数据;
  • 使用 RBF 核计算核矩阵;
  • 画核矩阵热图;
  • 手动实现 Kernel PCA;
  • 观察中心化核矩阵的特征值谱;
  • 使用 Kernel Ridge 得到非线性分类边界;
  • 增加一个线性 Logistic Regression 作为基线;
  • 使用独立测试集比较两种方法的准确率。

这里特意加入了训练集 / 测试集划分,因为如果最后要说“核方法在这个数据上表现更好”,最好还是用真正没参与训练的数据来验证,而不是只看训练集上的漂亮边界。

同时,为了让代码在普通电脑上更容易跑,我把样本数控制在 900。Kernel PCA 要对   核矩阵做特征分解,计算量和内存开销都会随着样本量快速增加,所以核方法并不是“大样本场景下免费的午餐”。



import numpy as np
import torch
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score

np.random.seed(0)
torch.manual_seed(0)

# 1. 生成非线性数据
n_samples = 2900
X_raw, y = make_moons(
    n_samples=n_samples,
    noise=0.12,
    random_state=0
)

# 先划分训练/测试集,再拟合标准化器,避免数据泄漏
X_train_raw, X_test_raw, y_train, y_test = train_test_split(
    X_raw,
    y,
    test_size=0.25,
    random_state=42,
    stratify=y,
)

scaler = StandardScaler()
X_train = scaler.fit_transform(X_train_raw).astype(np.float32)
X_test = scaler.transform(X_test_raw).astype(np.float32)

# 只用于整体可视化。
# 注意这里仍然使用“只在训练集上 fit”的 scaler。
X_all = scaler.transform(X_raw).astype(np.float32)

# Kernel Ridge 用 {-1, 1} 作为目标值,方便使用 0 作为分类阈值
y_train_signed = (2 * y_train - 1).astype(np.float32)

X_train_t = torch.from_numpy(X_train)
X_test_t = torch.from_numpy(X_test)
X_all_t = torch.from_numpy(X_all)
y_train_t = torch.from_numpy(y_train_signed)


# 2. 定义 RBF 核
# 使用 ||x-y||^2 = ||x||^2 + ||y||^2 - 2x^T y
# 避免直接构造 (n1, n2, d) 的三维差分张量,更省内存
def rbf_kernel(X1, X2, gamma=1.0):
    x1_sq = (X1 ** 2).sum(dim=1, keepdim=True)      # (n1, 1)
    x2_sq = (X2 ** 2).sum(dim=1, keepdim=True).T    # (1, n2)

    sqdist = (
        x1_sq + x2_sq - 2.0 * (X1 @ X2.T)
    ).clamp_min(0.0)

    return torch.exp(-gamma * sqdist)


# 教学示例固定参数。
# 真正项目中,gamma 和 lambda 建议通过验证集或交叉验证选择。
gamma = 1.5
lam = 1e-2


# 图 1:原始数据
plt.figure(figsize=(55))
plt.scatter(
    X_all[:, 0],
    X_all[:, 1],
    c=y,
    cmap="coolwarm",
    s=24,
    edgecolor="k",
    linewidth=0.3,
)
plt.title("标准化后的 make_moons 数据")
plt.xlabel("x1")
plt.ylabel("x2")
plt.tight_layout()
plt.show()


# 图 2:RBF 核矩阵
K_all = rbf_kernel(X_all_t, X_all_t, gamma=gamma)
K_all_np = K_all.numpy()

plt.figure(figsize=(65))
plt.imshow(K_all_np, cmap="plasma", aspect="auto")
plt.colorbar(label="kernel value")
plt.title(f"RBF 核矩阵热图(gamma={gamma:.2f})")
plt.xlabel("样本索引 j")
plt.ylabel("样本索引 i")
plt.tight_layout()
plt.show()


# 3. Kernel PCA
def center_kernel(K):
    """在特征空间中完成中心化,对应中心化 Gram 矩阵。"""
    row_mean = K.mean(dim=1, keepdim=True)
    col_mean = K.mean(dim=0, keepdim=True)
    grand_mean = K.mean()

    return K - row_mean - col_mean + grand_mean


Kc = center_kernel(K_all)

# 理论上 Kc 应该是对称矩阵。
# 这里做一次数值对称化,减少浮点误差对 eigh 的影响。
Kc = 0.5 * (Kc + Kc.T)

# 对称特征分解:eigh 默认返回升序特征值
eigvals, eigvecs = torch.linalg.eigh(Kc)

idx = torch.argsort(eigvals, descending=True)
eigvals = eigvals[idx]
eigvecs = eigvecs[:, idx]

# 取前两个核主成分
if torch.any(eigvals[:2] <= 0):
    raise RuntimeError("前两个特征值不是正数,请检查核参数或数值稳定性。")

# 关键点:
# v_k / sqrt(lambda_k) 是特征空间主轴展开时使用的系数;
# 对训练样本本身而言,第 k 个核主成分上的坐标为:
# sqrt(lambda_k) * v_k
X_kpca = (
    eigvecs[:, :2]
    * torch.sqrt(eigvals[:2]).unsqueeze(0)
)

X_kpca_np = X_kpca.numpy()

plt.figure(figsize=(55))
plt.scatter(
    X_kpca_np[:, 0],
    X_kpca_np[:, 1],
    c=y,
    cmap="coolwarm",
    s=24,
    edgecolor="k",
    linewidth=0.3,
)
plt.title("Kernel PCA 投影(前 2 个核主成分)")
plt.xlabel("KPCA-1")
plt.ylabel("KPCA-2")
plt.tight_layout()
plt.show()


# 图 4:特征值谱
# 浮点计算中可能出现非常小的负特征值,
# 对理论上的 PSD 核矩阵来说通常属于数值误差。
# 对数坐标下不能直接画 <= 0 的值,因此只保留正特征值。
positive_eigvals = eigvals[eigvals > 1e-8]
vals = positive_eigvals.numpy()

show_n = min(50, len(vals))

plt.figure(figsize=(64))
plt.plot(
    np.arange(1, show_n + 1),
    vals[:show_n],
    marker="o",
    markersize=3,
)
plt.yscale("log")
plt.title("中心化核矩阵的特征值谱(前 50 个正特征值)")
plt.xlabel("成分索引")
plt.ylabel("特征值(log)")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# 看看前两个核主成分到底占了多少,而不是直接假设“前两个占绝大多数”
top2_ratio = float(vals[:2].sum() / vals.sum())
print(
    f"前两个核主成分占正特征值总和的比例:"
    f"{top2_ratio:.3f}"
)


# 5. Kernel Ridge:只使用训练集拟合
K_train = rbf_kernel(
    X_train_t,
    X_train_t,
    gamma=gamma
)

# 数学上常写成 alpha = (K + lambda I)^(-1)y,
# 数值计算时直接 solve 更稳,也没必要显式求逆。
alpha = torch.linalg.solve(
    K_train
    + lam * torch.eye(
        K_train.shape[0],
        dtype=K_train.dtype
    ),
    y_train_t,
)

# 在独立测试集上评估
K_test = rbf_kernel(
    X_test_t,
    X_train_t,
    gamma=gamma
)

test_score = K_test @ alpha

# Kernel Ridge 输出的是连续分数,不是概率。
# 由于训练标签设成 {-1, 1},这里用 0 作为分类阈值。
y_pred_kernel = (
    test_score >= 0
).numpy().astype(int)

kernel_acc = accuracy_score(
    y_test,
    y_pred_kernel
)


# 6. 线性模型作为 baseline
linear_clf = LogisticRegression(max_iter=2000)
linear_clf.fit(X_train, y_train)

y_pred_linear = linear_clf.predict(X_test)
linear_acc = accuracy_score(
    y_test,
    y_pred_linear
)

print(
    f"Linear Logistic Regression 测试准确率:"
    f"{linear_acc:.3f}"
)
print(
    f"RBF Kernel Ridge 测试准确率:"
    f"{kernel_acc:.3f}"
)


# 图 5:决策边界
xx, yy = np.meshgrid(
    np.linspace(
        X_all[:, 0].min() - 0.5,
        X_all[:, 0].max() + 0.5,
        220,
    ),
    np.linspace(
        X_all[:, 1].min() - 0.5,
        X_all[:, 1].max() + 0.5,
        220,
    ),
)

grid = np.c_[
    xx.ravel(),
    yy.ravel()
].astype(np.float32)

grid_t = torch.from_numpy(grid)


# 网格点可能很多。
# 如果一次性计算 grid × train 的核矩阵,会占用较多内存。
# 这里采用 batch 方式预测。
def krr_decision_in_batches(
    grid_tensor,
    train_tensor,
    alpha,
    gamma,
    batch_size=2048,
)
:

    outputs = []

    for start in range(
        0,
        len(grid_tensor),
        batch_size,
    ):
        batch = grid_tensor[
            start:start + batch_size
        ]

        K_batch = rbf_kernel(
            batch,
            train_tensor,
            gamma=gamma,
        )

        outputs.append(K_batch @ alpha)

    return torch.cat(outputs, dim=0)


kernel_decision = krr_decision_in_batches(
    grid_t,
    X_train_t,
    alpha,
    gamma=gamma,
).numpy().reshape(xx.shape)

# 线性 Logistic Regression 的 decision_function 同样不是概率,
# 0 对应它的分类边界。
linear_decision = (
    linear_clf
    .decision_function(grid)
    .reshape(xx.shape)
)

plt.figure(figsize=(65))

plt.contourf(
    xx,
    yy,
    kernel_decision,
    levels=40,
    cmap="coolwarm",
    alpha=0.55,
)

plt.scatter(
    X_all[:, 0],
    X_all[:, 1],
    c=y,
    cmap="coolwarm",
    s=24,
    edgecolor="k",
    linewidth=0.3,
)

# Kernel Ridge 的非线性边界
plt.contour(
    xx,
    yy,
    kernel_decision,
    levels=[0],
    colors="k",
    linewidths=1.8,
)

# 线性 Logistic Regression 的边界
plt.contour(
    xx,
    yy,
    linear_decision,
    levels=[0],
    colors="white",
    linestyles="--",
    linewidths=1.8,
)

legend_items = [
    Line2D(
        [0], [0],
        color="k",
        lw=1.8,
        label="RBF Kernel Ridge 边界",
    ),
    Line2D(
        [0], [0],
        color="white",
        lw=1.8,
        linestyle="--",
        label="线性逻辑回归边界",
    ),
]

plt.legend(
    handles=legend_items,
    loc="best"
)

plt.title(
    f"决策边界对比:"
    f"Linear={linear_acc:.3f},"
    f"Kernel={kernel_acc:.3f}"
)
plt.xlabel("x1")
plt.ylabel("x2")
plt.tight_layout()
plt.show()

核心步骤

1. 数据生成与标准化

我们使用 make_moons 生成两个月牙形的数据。

这种数据最适合拿来理解核方法,因为它在二维空间里明显不是“一条直线就能分开”的结构。

代码里有一个小细节很重要:

先划分训练集和测试集,再用训练集拟合 StandardScaler

也就是:

scaler.fit(X_train_raw)

而不是先对全体数据做标准化,再切训练测试集。

原因很简单:如果先拿全体数据计算均值和标准差,测试集的信息就提前参与了数据预处理,这属于一种轻微的数据泄漏。

在纯可视化教学里影响可能不大,但既然我们后面要计算测试准确率,流程最好一开始就写规范。

对于 RBF 核来说,标准化尤其有意义,因为它直接依赖欧氏距离:

如果某个特征的数值尺度特别大,它很容易主导距离,进而主导整个核值。

2. RBF 核:本质上是在算“局部相似度”

我们使用:

这里可以把它理解得非常直白:

离得越近,越相似;离得越远,相似度快速下降。

在代码里,没有使用:

X1.unsqueeze(1) - X2.unsqueeze(0)

直接构造三维差分张量,而是利用:

来计算距离。

这两个写法数学上是一回事,但后者通常更省内存。

原来如果网格里有几万个点、训练集又有几千个样本,再构造一个 (网格点数, 训练样本数, 特征维度) 的三维张量,很容易吃掉大量内存。

这也是很多教学代码“小数据能跑,一放大就炸”的原因之一。

3. 核矩阵热图到底怎么看?

核矩阵中的每一个元素:

都表示第   个样本和第   个样本在当前核定义下的相似度。

对 RBF 核来说,对角线一定接近 1,因为:

所以:

这里有一个很容易误解的地方:

核矩阵本身并不知道类别标签。

因此,对于随机顺序排列的 make_moons 样本,不应该默认热图一定会自动出现特别清晰的“两大类别方块”。

RBF 核描述的是几何距离上的局部相似度,而不是“同类为 1、异类为 0”。

如果你人为按照类别重新排列样本,确实可能更容易看出一些结构,但那只是为了可视化方便,不能把这种排序后的图理解成“核函数自己知道了标签”。

4. Kernel PCA 最容易写错的地方:投影坐标

Kernel PCA 的核心步骤,是先把核矩阵中心化:

其中   是每个元素都等于   的矩阵。

代码里没有真的构造完整的  ,而是直接用行均值、列均值和总均值完成同样的操作:

K - row_mean - col_mean + grand_mean

这样更直接,也更省一点内存。

然后对中心化核矩阵做特征分解:

这里要特别区分两个东西。

特征空间中的主轴展开系数通常写成:

但这不是训练样本最终画图时的投影坐标

对训练样本本身,第   个 Kernel PCA 成分上的坐标是:

所以代码里应该写成:

X_kpca = eigvecs[:, :2] * torch.sqrt(eigvals[:2])

而不是:

X_kpca = eigvecs[:, :2] / torch.sqrt(eigvals[:2])

这两个量的含义是不一样的。

另外还有一个非常重要的理解:

Kernel PCA 是无监督降维,不是分类算法。

它寻找的是特征空间里方差较大的方向,并不会直接利用   去最大化类别间隔。

所以我们可以说它有能力揭示非线性结构,但不能笼统地说:

做完 Kernel PCA,两类一定会分得更开。

有时候会,有时候不会,取决于数据、核函数和参数。

5. 特征值谱:不要看到“前两个成分”就默认信息够了

中心化核矩阵的特征值,可以帮助我们观察特征空间中不同方向的重要程度。

但这里也不要套用一句固定话术:

“前几个特征值一般占据绝大多数能量,所以二维就够了。”

这不一定成立。

在当前代码固定的随机种子和参数下,实际运行得到:

前两个核主成分占正特征值总和的比例:约 0.302

也就是大约 30%。

这恰恰说明:前两个成分虽然方便我们画二维图,但不能因此就说它们已经保留了绝大多数核空间结构。

这也是数据分析时非常值得注意的一点:

先看数据,再下结论,不要为了配合图去提前写结论。

代码里还过滤了非常小的非正特征值:

eigvals[eigvals > 1e-8]

理论上,合法 PSD 核对应的核矩阵应该是正半定的;但经过中心化和浮点运算以后,数值上可能出现类似   这种非常小的负值。

这通常属于浮点误差,不代表理论突然失效。

而我们又使用了对数坐标,所以不能直接把非正数拿去画 log 图。

6. Kernel Ridge 为什么可以拿来演示分类?

严格来说,Kernel Ridge Regression 本身首先是一个回归方法。

这次只是为了教学演示,把二分类标签从:

转换成:

然后让 Kernel Ridge 学习一个连续分数:

最后使用:

完成类别判断。

所以更准确的说法应该是:

我们把 Kernel Ridge 的连续输出当作分类得分来使用。

而不是把它说成一个标准意义上的概率分类器。

尤其要注意:

test_score = K_test @ alpha

这里得到的是决策分数,不是概率。

不能因为它是一个连续值,就直接解释成“属于某一类的概率”。

7. 为什么一定要加一个线性 baseline?

如果我们只画 Kernel Ridge 的边界,确实会看到一条弯曲的非线性边界。

但“看起来很漂亮”和“核方法真的比线性方法更适合这个问题”,其实不是完全一回事。

所以代码里额外训练了一个 Logistic Regression:

linear_clf = LogisticRegression(max_iter=2000)

然后在同一个独立测试集上比较准确率。

在当前随机种子、噪声和参数设置下,我实际检查代码得到大约:

Linear Logistic Regression 测试准确率:0.889
RBF Kernel Ridge 测试准确率:0.991

这个结果就比较直观了。

线性模型只能学习一条线性边界,因此面对月牙形结构时会受到明显限制;RBF 核能够基于局部相似度形成弯曲的非线性边界,所以在这个专门设计的非线性数据上表现更好。

不过也不要把 0.991 > 0.889 理解成一个普遍结论。

它只能说明:在当前这个数据生成方式、训练测试划分和参数设置下,RBF Kernel Ridge 比线性 Logistic Regression 更适合。

换数据、换噪声、换  、换正则化强度,结果都可能变化。

这才是比较严谨的数据分析表达。

总结

总的来说,核方法背后的思路:如果一个问题在原来的表示空间里不好处理,那就换一种表示;如果显式构造新表示太贵,那就想办法只计算算法真正需要的量。

Kernel Trick 做的就是这件事。

RBF 核把样本之间的局部距离转换成相似度,核矩阵把所有样本之间的关系组织起来;Kernel PCA 在这个关系结构上寻找主要变化方向;Kernel Ridge 则借助核函数把原本线性的求解过程变成可以表达非线性边界的模型。

另外,这个案例里还有几个很值得记住的数据分析习惯:训练测试集先划分再做标准化;无监督降维不能直接等同于“类别分得更开”;看特征值谱再判断低维是否足够;比较模型时要有 baseline;连续决策分数不要随手当成概率。

把这些细节想明白之后,核函数就不再是一个很玄的数学名词了,而是一套非常清晰的“表示 + 相似度 + 线性代数”的方法论。

【声明】内容源于网络
0
0
机器学习和人工智能AI
让我们一起期待 AI 带给我们的每一场变革!推送最新行业内最新最前沿人工智能技术!
内容 381
粉丝 0
机器学习和人工智能AI 让我们一起期待 AI 带给我们的每一场变革!推送最新行业内最新最前沿人工智能技术!
总阅读5.0k
粉丝0
内容381