# 勤奋的凯尔森同学10000h刻意练习Day10:无监督学习 —— 没有标签,算法怎么自己发现规律?从KMeans聚类到PCA降维

所属专栏: 凯尔森的10000h刻意练习
Slogan: 一个36岁程序员, 研究生(学硕),前世界500强top20软件开发Java工程师,用10000小时深耕AI, 目标Top10 AI资深工程师, 全程日更记录。

如果给你一堆顾客的消费记录,但没有任何"高价值/低价值"标签,你怎么把顾客分群?

答案不是"请运营同事人工标",而是让算法自己在数据里找结构——这就是无监督学习。从Day6的线性回归、Day7的逻辑回归,到Day8的决策树、Day9的SVM,前面学的全是监督学习:有人给答案,模型学着拟合。今天翻个面:数据没有标签,让算法自己发现内部结构。

无监督学习涉及很多方向(密度估计、异常检测、生成建模、表示学习、关联规则等),本文先抓住其中最核心、也最适合建立直觉的两类任务:聚类(把相似样本归为一类,相当于自动分组)和降维(把高维数据压到低维,保留主要信息)。今天会认识四位主角:KMeans是最经典的球形聚类,像"画圈分地盘";层次聚类像"合并族谱",不需要提前说分几群;DBSCAN是"密度为王",专治KMeans搞不定的异形簇;GMM则给每个样本发"属于各簇的概率"。降维这边,PCA是"找数据最伸展的方向"做线性压缩,t-SNE专为可视化而生,把高维邻居关系搬到二维纸上。

# 1.1 KMeans聚类

# 1.1.1 思想:物以类聚,人以群分

KMeans的想法朴素到像分蛋糕:把数据分成K个簇,每个簇有一个中心(质心),每个样本归到最近的质心。目标是让所有样本到其所属质心的距离平方和最小:

逐项拆解:

  • :样本总数
  • :第个样本
  • :第个簇的质心(簇内所有样本的均值)
  • :在K个质心里挑离最近的那个
  • :所有样本到"自己归属质心"的距离平方和,也叫惯性(inertia),sklearn里的kmeans.inertia_就是它

为什么目标是最小化距离平方和?

几何直觉:越小,簇内越紧凑,簇和簇之间分得越开,聚类结果越"干净"。

**为什么"更新"一步是取均值?**固定每个样本的簇归属后,关于质心是一个二次凸函数,对它求导并令导数为0,解出来的最小值点恰好就是簇内样本的均值——所以"取均值"不是拍脑袋,而是让在当前分配下取到最优。

# 1.1.2 算法步骤

  1. 随机选K个样本作为初始质心
  2. 分配:每个样本归到最近的质心
  3. 更新:重新计算每个簇的质心(簇内样本的均值)
  4. 重复2-3,直到质心不再变化(或变化小于阈值)

# 手推小例子:6个点,K=2,手算两轮KMeans

取6个一维点2, 3, 4, 10, 11, 12,直觉上应该分成{2,3,4}和{10,11,12}两簇。设K=2,初始质心、。

第一轮分配:算每个点到两个质心的距离,归给更近的那个:

点 归属
2 1 9
3 0 8
4 1 7
10 7 1
11 8 0
12 9 1

第一轮更新:质心 = 簇内均值:

第二轮分配:质心没动,每个点到两个质心的距离和第一轮完全一样,归属不变;再更新一次,、依然不变——质心不再变化,算法收敛,只用了两轮。此时的目标函数值(惯性):

下面的代码就是把上面的手算过程向量化/代码化:

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_blobs
from sklearn.cluster import KMeans

# 造3簇数据
X, y_true = make_blobs(n_samples=300, centers=3, cluster_std=0.8, random_state=42)

kmeans = KMeans(n_clusters=3, random_state=42, n_init=10)
kmeans.fit(X)

print("质心:\n", kmeans.cluster_centers_)
print("每个样本的簇标签:", kmeans.labels_)
1
2
3
4
5
6
7
8
9
10
11
12
13

# 1.1.3 可视化聚类结果

# c=kmeans.labels_:按聚类标签给点上色。标签为0/1/2的样本会自动分配不同颜色,同一簇颜色相同
# cmap='viridis':指定颜色映射(色板),viridis是matplotlib默认好看的渐变色板
plt.scatter(X[:, 0], X[:, 1], c=kmeans.labels_, cmap='viridis', alpha=0.6)
plt.scatter(kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1],
            marker='X', s=32, c='red', label='质心')
plt.legend()
plt.show()
1
2
3
4
5
6
7

# 1.1.4 K值怎么选:肘部法则

KMeans的目标函数(惯性inertia)随K增大单调下降,但下降速度会变缓。画inertia-K曲线,找"拐点":

inertias = []
for k in range(1, 11):
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    km.fit(X)
    inertias.append(km.inertia_)

plt.plot(range(1, 11), inertias, 'o-')
plt.xlabel('K')
plt.ylabel('Inertia')
plt.title('肘部法则')
plt.show()
1
2
3
4
5
6
7
8
9
10
11

拐点就是合适的K。如果曲线没有明显拐点,还可以用**轮廓系数(Silhouette Score)**辅助判断(指标的详细解读见1.6.1):

from sklearn.metrics import silhouette_score

sil_scores = []
for k in range(2, 11):
    km = KMeans(n_clusters=k, random_state=42, n_init=10)
    labels = km.fit_predict(X)
    sil_scores.append(silhouette_score(X, labels))

best_k = range(2, 11)[np.argmax(sil_scores)]
print("最佳K:", best_k)

plt.plot(range(2,11), sil_scores, 'o-')
plt.xlabel('K')
plt.ylabel('Silhouette Score')
plt.title('轮廓系数选择最优K')
plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

轮廓系数范围[-1, 1],越接近1聚类效果越好。

# 1.1.5 KMeans的局限

  • 必须提前指定K
  • 对初始质心敏感(可能收敛到局部最优)→ 用n_init多次随机初始化 + KMeans++(sklearn默认init='k-means++'已启用,按与已选质心距离平方为概率采样,让初始质心尽量散开)
  • 对异常点敏感 → K-Medoids(用真实样本点当质心,而非均值)或先做离群点剔除
  • 假设簇是凸的、大小相近的 → 非凸簇用DBSCAN(见1.3)

KMeans到底在优化什么?把这条认知链理清,后面的算法就顺了:

KMeans优化的是簇内平方误差(inertia)→ 所以它喜欢紧凑的簇 → 欧氏距离决定了簇的几何边界 → 最终形成凸的、近似球形/各向同性的簇 → 所以半月形数据它会失败 → DBSCAN因此出现。

进一步:KMeans还隐含了几个假设——每个簇结构类似、用欧氏距离、一个样本必须属于某一个簇、簇内方差结构简单。当这些假设不成立时,就逼出了后续算法:不知道分几群?→ 层次聚类(1.2);簇是非凸的?→ DBSCAN(1.3);样本边界是模糊的、想要概率?→ GMM(1.5)。

# 1.2 层次聚类

层次聚类不需要提前指定K,它通过不断合并(凝聚)或不断分裂(分裂)构建一棵树状图(dendrogram),然后在任意高度切一刀得到K个簇。

一个生活类比:就像整理通讯录。一开始每个人单独一个分组,然后把关系最近的两个组合并,再把合并后的组和最近的组合并……直到所有人都在一个组里。这个过程画出来就是一棵"合并树",你想分3组就在某个高度横切一刀,想分5组就换个高度切——K不是提前定的,是看着树选的。

# 1.2.1 凝聚层次聚类

  1. 每个样本自成一簇
  2. 合并距离最近的两个簇
  3. 重复2,直到所有样本在一个簇里

簇之间的距离有多种度量:

链接方式 定义 特点
单链接(single) 两个簇最近样本的距离 容易形成链式,长条形簇
全链接(complete) 两个簇最远样本的距离 簇比较紧凑
平均链接(average) 两簇所有样本对距离的平均 折中
Ward 合并后方差增量最小 倾向于等大小球形簇
# AgglomerativeClustering:sklearn凝聚层次聚类模型
# linkage:scipy用来计算层次聚类合并信息,生成树状图需要的矩阵Z
# dendrogram:绘制层次聚类树状图(谱系图/聚类树)
from sklearn.cluster import AgglomerativeClustering
from scipy.cluster.hierarchy import dendrogram, linkage

# linkage计算样本之间逐层合并的信息,输出矩阵Z,形状为(n_samples-1,4)。
# 每一行代表一次簇合并:[簇A编号, 簇B编号, 两个簇之间的距离, 合并后新簇包含样本数量]
# method='ward':ward方差最小法。合并两组,使得合并后簇内总方差增加最小。
# 其他可选 linkage:single(最短距离), complete(最长距离), average(平均距离)
Z = linkage(X, method='ward')

plt.figure(figsize=(12, 5))
# dendrogram(Z):根据合并矩阵Z画树
# truncate_mode='level', p=5:截断树,只展示顶层5层合并,样本量大时防止底部标签密密麻麻挤在一起。去掉这个参数就会展示完整树
dendrogram(Z, truncate_mode='level', p=5)
plt.xlabel('样本')
plt.ylabel('距离')
plt.title('树状图')
plt.show()

# 聚类
agg = AgglomerativeClustering(n_clusters=3, linkage='ward')
labels = agg.fit_predict(X)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

# 1.2.2 层次聚类的优缺点

  • 优点:不需要提前指定K,树状图可解释
  • 缺点:计算复杂度高(),不能回溯,大数据集慢

# 1.3 DBSCAN

DBSCAN(Density-Based Spatial Clustering of Applications with Noise)是基于密度的聚类,核心思想是:密度高的区域是一伙的,密度低的地方是噪声。它不要求簇必须是球形或凸形,只要簇能通过密度连通关系被识别出来,就有机会发现复杂形状的簇,还能自动识别噪声点。(注意:当不同簇密度差异很大时,DBSCAN可能表现不佳。)

# 1.3.1 核心概念

DBSCAN 用两个参数刻画"密度":邻域半径 和最少样本数 min_samples。一个点的 -邻域(以该点为圆心、半径 的圆)内有多少个样本,决定了它是什么角色:

  • 核心点(core point):-邻域内样本数(含自身) min_samples——自己地盘上"人够多"
  • 边界点(border point):不是核心点,但落在某个核心点的 -邻域内——住在核心点的"郊区"
  • 噪声点(noise point):既不是核心点也不是边界点——荒郊野岭,没人管

要定义"簇",还需要两个递进的概念:

  • 直接密度可达(directly density-reachable):若 是核心点且 在 的 -邻域内,则称 从 直接密度可达。注意这有方向性—— 是核心点能"够到" ,但 不一定也是核心点,反过来不一定成立。
  • 密度可达(density-reachable):若存在一条链 ,其中 从 直接密度可达(链上除终点外每个点都是核心点),则 从 密度可达。这就是"一传十、十传百"——密度通过核心点链传递。
  • 密度相连(density-connected):若存在点 ,使得 和 都从 密度可达,则 和 密度相连。这打破了方向性——只要能追溯到同一个"源头"即可。

簇的形式定义:一个簇是密度相连点的最大集合——里面任意两点都密度相连,且不能被其他密度相连的点继续扩充。这个定义不要求簇是凸的或球形的,只要求"密度连通",所以 DBSCAN 能发现任意形状的簇。

# 1.3.2 算法

  1. 标记点类型:遍历所有点,计算每个点的 -邻域内样本数,标记为核心点/边界点/噪声点
  2. 生长簇:任选一个未访问的核心点 ,创建新簇。从 出发,找到所有从 密度可达的点:检查 的 -邻域,邻域里的每个核心点继续展开(队列),直到链断掉——路径上的核心点和边界点都归入本簇
  3. 重复:从未访问的核心点出发再建新簇,直到所有核心点都被处理
  4. 噪声:没被任何簇收入的就是噪声(标签 )
from sklearn.cluster import DBSCAN
from sklearn.datasets import make_moons

# 造半月形数据(KMeans搞不定的)
X, y = make_moons(n_samples=300, noise=0.08, random_state=42)

# eps(epsilon,邻域半径):以某个点为圆心,半径eps范围内的区域,就是该点的邻域
# min_samples(最小样本数):一个点的eps邻域内,至少要有min_samples个点,这个点才被认定为核心点
dbscan = DBSCAN(eps=0.2, min_samples=5)
# fit_predict(X):训练并返回每个样本的簇标签
labels = dbscan.fit_predict(X)
# set(labels)取出所有唯一标签.因为噪声标签-1不算簇,所以如果标签里包含-1:总簇数=标签集合大小−1
print("簇数:", len(set(labels)) - (1 if -1 in labels else 0))
print("噪声点数:", (labels == -1).sum())

plt.scatter(X[:, 0], X[:, 1], c=labels, cmap='viridis', alpha=0.7)
plt.scatter(X[labels == -1, 0], X[labels == -1, 1], c='red', marker='x', label='噪声')
plt.legend()
plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 1.3.3 DBSCAN的优缺点

  • 优点:不需要指定K,能发现非球形簇,自动识别噪声
  • 缺点:对eps和min_samples敏感,密度不均时效果差,高维数据距离失效;不能直接预测新样本的簇标签(新样本来了要重新跑整个算法,或手动算它落在哪个核心点的邻域里)

# 1.4 三种聚类算法对比

维度 KMeans 层次聚类 DBSCAN
是否需要指定K 是 否(可后切) 否
簇形状 球形(各向同性) 取决于链接方式(single可非球形,complete/Ward偏球形) 不要求球形/凸形,密度连通即可
异常点 敏感 一般 能识别
大数据速度 快 慢 中
可解释性 中 高(树状图) 中

# 1.5 高斯混合模型(GMM)

前面三种聚类都是硬聚类:一个样本要么属于这个簇,要么属于那个簇,边界分明。GMM是软聚类:它给出样本属于每个簇的概率,边界是模糊的。

直觉上,KMeans像"非黑即白"——每个点必须选一个簇;GMM像"灰色地带"——一个点可能70%属于A簇、30%属于B簇。当簇之间有重叠时,软聚类更诚实。

# 1.5.1 模型假设

GMM假设所有数据点都是从 个高斯分布中"抽样"出来的——每个高斯分布是一个"簇"。具体来说,数据的生成过程是:① 以概率 先选一个分量 ;② 再从该分量的高斯分布 中抽出一个样本。整体概率密度就是各高斯分量的加权和:

  • :第 个分量的混合系数(权重),满足 ,即数据有多大概率来自这个分量
  • :第 个分量的均值(簇中心位置)
  • :第 个分量的协方差矩阵(控制簇的形状、大小和朝向)

几何直觉:二维空间里,每个高斯分量的等密度线是一组同心椭圆。 决定椭圆中心; 的特征向量决定椭圆的朝向和长短轴——对角协方差(diag)的椭圆轴与坐标轴平行,满协方差(full)的椭圆可以任意倾斜。KMeans 的簇是"圆"(各向同性),GMM 的簇是"椭球"(可以拉伸、旋转),这就是 GMM 能适应更多形状的原因。

为什么用高斯? 高斯分布是给定均值和协方差后熵最大的分布(信息论视角:在已知一阶矩和二阶矩的约束下,高斯是不引入额外假设的最保守选择),也是许多自然现象的近似(中心极限定理)。用高斯建模每个簇,等于假设簇内数据围绕一个中心、向四周衰减——这个假设覆盖了现实中的大多数情况。

# 1.5.2 EM算法求解

GMM的参数 怎么估?直接最大似然估计,对数似然是:

困难在于对数里有求和 —— 无法像单高斯那样直接求导得到解析解。更关键的是**"鸡生蛋"问题**:知道每个样本属于哪个分量,就能估各分量的参数;知道各分量参数,就能算每个样本属于哪个分量。但两个都不知道,怎么办?

EM(Expectation-Maximization)算法用交替迭代打破这个死循环——先猜一组参数,用参数算"属于哪个分量"(E步),再用结果更新参数(M步),反复迭代:

  • E步(求期望):基于当前参数,算每个样本属于每个分量的"责任度"(后验概率):

直觉:分子是"分量 对样本 的贡献",分母是所有分量贡献之和——归一化后 就是样本 属于分量 的"软标签"。 越接近 1,说明样本 越明确地属于簇 ;如果两个分量贡献相当,责任度接近 0.5,说明样本在簇的边界上——这就是"软聚类"的量化体现。

  • M步(最大化):用责任度当权重,重新更新各分量的参数:

其中 是分给第 个分量的"有效样本数"(责任度之和)。三个更新公式的直觉:

参数 更新公式 直觉
分量 "分到"的总责任度占比
以责任度为权重的加权均值
以责任度为权重的加权协方差

E步和M步的关系:E 步用旧参数算责任度,M 步用责任度更新参数;更新后的参数又让 E 步算出新的责任度……如此交替,直到收敛。

EM 保证每次迭代对数似然单调递增(不下降),收敛到局部最优(和 KMeans 一样,多次随机初始化取最优)。单调递增的原因:EM 本质上是在优化对数似然的一个下界(ELBO)——E 步让下界紧贴当前似然,M 步最大化下界,下界上升带动似然跟着上升。

# 1.5.3 GMM vs KMeans

维度 KMeans GMM
聚类类型 硬聚类 软聚类(给概率)
簇形状 球形(各向同性) 椭球(可任意协方差)
距离度量 欧氏距离 马氏距离(由协方差决定)
概率输出 无 有
速度 快 慢
能否生成新样本 否 是(学到了密度)

# 1.5.4 代码实战:聚类 + 生成新样本

from sklearn.mixture import GaussianMixture
from sklearn.datasets import load_iris
from sklearn.metrics import adjusted_rand_score

iris = load_iris()
X = iris.data

gmm = GaussianMixture(n_components=3, random_state=42)
gmm.fit(X)

# 硬聚类结果
labels = gmm.predict(X)
print("GMM聚类ARI:", adjusted_rand_score(iris.target, labels))

# 软聚类:每个样本属于各簇的概率(前5个)
print("责任度(概率)前5行:")
print(gmm.predict_proba(X)[:5].round(3))

# 生成新样本(密度估计的额外能力)
X_new, y_new = gmm.sample(100)
print("生成样本形状:", X_new.shape)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 1.5.5 用BIC/AIC选组件数

GMM的组件数怎么选?用信息准则:在拟合优度和模型复杂度之间找平衡,是模型参数数量,是似然函数最大值,是样本数。

AIC/BIC越小,模型越好(BIC对复杂度惩罚更重)。以GMM(full协方差)为例,参数数量(混合权重 + 均值 + 协方差)。iris上、时。

import numpy as np
import matplotlib.pyplot as plt

bic_scores, aic_scores = [], []
K_range = range(1, 10)
for k in K_range:
    g = GaussianMixture(n_components=k, random_state=42).fit(X)
    bic_scores.append(g.bic(X))
    aic_scores.append(g.aic(X))

plt.plot(K_range, bic_scores, 'o-', label='BIC')
plt.plot(K_range, aic_scores, 's-', label='AIC')
plt.xlabel('组件数K')
plt.ylabel('分数')
plt.title('BIC/AIC选GMM组件数')
plt.legend()
plt.show()

best_k_bic = K_range[np.argmin(bic_scores)]
print("BIC最优组件数:", best_k_bic)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 1.6 聚类评估指标

聚类是无监督学习,没有真实标签可直接对照,怎么知道聚得好不好?评估方式分两类:

  • 内部指标:只用数据本身,不需要真实标签。如轮廓系数、Davies-Bouldin指数。
  • 外部指标:需要真实标签(常用于在已知标签数据上检验算法)。如调整兰德指数(ARI)、互信息。

# 1.6.1 轮廓系数(Silhouette Score)

对每个样本:

  • :到同簇其他样本的平均距离(簇内不相似度,越小说明簇内越紧凑)
  • :到最近异簇所有样本的平均距离(簇间不相似度,越大说明簇间越分离)

把这两个量合在一起,单个样本的轮廓系数定义为:

整体轮廓系数取所有样本的均值,范围:

取值 含义
接近1 簇内紧凑、簇间分离,聚类效果好
接近0 簇重叠,聚类不明确
接近-1 样本被分到了错误的簇

# 1.6.2 Davies-Bouldin指数

衡量"簇内紧凑度"与"簇间分离度"的比值,定义为各簇与其最相似簇的相似度均值:

  • :第簇内样本到质心的平均距离
  • :两簇质心间距离

DB越小越好(簇内紧凑、簇间远),最小值0。

# 1.6.3 调整兰德指数(Adjusted Rand Index, ARI)

有真实标签时才能用。它统计"被分到同簇/不同簇"的样本对在预测与真实标签中是否一致,并减去随机划分的期望值,校正了巧合一致:

  • 范围,1表示完全一致,0表示随机,负值表示比随机还差

# 1.6.4 代码实战

from sklearn.datasets import load_iris
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score, davies_bouldin_score, adjusted_rand_score

iris = load_iris()
X = StandardScaler().fit_transform(iris.data)
y_true = iris.target

kmeans = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X)
labels = kmeans.labels_

# 内部指标(不需要真实标签)
print("轮廓系数:", f"{silhouette_score(X, labels):.4f}")
print("Davies-Bouldin指数:", f"{davies_bouldin_score(X, labels):.4f}")

# 外部指标(需要真实标签)
print("调整兰德指数ARI:", f"{adjusted_rand_score(y_true, labels):.4f}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
轮廓系数: 0.4599
Davies-Bouldin指数: 0.8336
调整兰德指数ARI: 0.6201
1
2
3

对比三种聚类算法在鸢尾花数据上的表现:

from sklearn.datasets import load_iris
from sklearn.cluster import KMeans, AgglomerativeClustering, DBSCAN
from sklearn.preprocessing import StandardScaler

iris = load_iris()
X = StandardScaler().fit_transform(iris.data)
y_true = iris.target

results = {}
# KMeans
km = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X)
results['KMeans'] = km.labels_
# 层次聚类
agg = AgglomerativeClustering(n_clusters=3, linkage='ward').fit(X)
results['层次聚类'] = agg.labels_
# DBSCAN
db = DBSCAN(eps=0.5, min_samples=5).fit(X)
results['DBSCAN'] = db.labels_

print(f"{'算法':<10}{'轮廓系数':>12}{'DB指数':>12}{'ARI':>10}")
for name, labels in results.items():
    sil = silhouette_score(X, labels)
    dbi = davies_bouldin_score(X, labels)
    ari = adjusted_rand_score(y_true, labels)
    print(f"{name:<12}{sil:>12.3f}{dbi:>12.3f}{ari:>10.3f}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 1.7 PCA降维

聚类解决"样本如何分组"的问题,降维解决"特征太多怎么办"。先从最经典的线性降维方法**PCA(主成分分析,Principal Component Analysis)**讲起。

# 1.7.1 为什么要降维

  • 可视化:人类只能看懂2D/3D,高维数据需要压到2D才能画出来
  • 去噪:噪声往往藏在小方差维度里,丢掉它们可能去掉部分噪声(但低方差≠噪声,真正有用的信号也可能在低方差方向)
  • 加速:维度低了,后续算法(距离计算、矩阵运算)更快
  • 缓解维度灾难:高维空间样本稀疏,距离计算失效

# 1.7.2 PCA的核心思想

PCA的直觉:把数据想象成一团云,找它"伸展得最开"的方向——那就是第一主成分;再找与它正交、伸展得次开的方向——第二主成分。把数据投影到这些方向上,就完成了降维。被丢掉的方向是数据"挤在一起"的方向,方差小。

原始数据(二维) → 找方差最大的方向(第一主成分) → 投影到这个方向(一维)
1

# 1.7.3 数学推导

# 优化目标:最大化投影方差

PCA 的核心问题:找一个方向 (单位向量),让数据投影上去后方差最大。为什么是最大化方差?因为方差大 = 数据在这个方向上"伸展得开" = 携带的信息多;方差为零的方向上所有点重叠,没有信息。

设中心化后的数据为 (),投影到方向 上得到 ()。投影方差为:

其中 就是协方差矩阵。于是 PCA 变成了一个约束优化问题:

# 求解:拉格朗日 → 特征值问题

用拉格朗日乘子法,构造 ,对 求导并令其为零:

这就是特征值方程。 最优投影方向 是协方差矩阵 的特征向量,投影方差 ——特征值 恰好等于该方向上的投影方差。

第二主成分在"与第一主成分正交"()的约束下最大化方差,同样的推导给出 的第二个特征向量。依次类推, 个特征向量构成 个互相正交的主成分方向。

特征向量=方向,特征值等于这个方向的重要程度。

# 算法步骤

步骤 操作 为什么
① 中心化 PCA 对"方差"负责,中心化后均值在原点,方差计算才正确
② 算协方差 投影方差 ,需要
③ 特征分解 最优方向 = 特征向量,投影方差 = 特征值
④ 选前 个 特征值降序排列,取前 列组成 保留方差最大的 个方向
⑤ 投影降维 维 维, 是
from sklearn.decomposition import PCA
from sklearn.datasets import load_iris

iris = load_iris()
X = iris.data
y = iris.target

# iris四特征同为厘米,但方差相差十几倍;这里为简化直接降维,通用流程务必先StandardScaler
pca = PCA(n_components=2)  # 降到2维
# fit_transform(X):两步合并
# fit(X):在原始数据上计算协方差矩阵、特征值、特征向量,得到主成分方向
# transform(X):把原始4维样本投影到新的2维主成分坐标轴上,输出降维后的X_reduced
X_reduced = pca.fit_transform(X)

print("原始形状:", X.shape)          # (150, 4)
print("降维后形状:", X_reduced.shape) # (150, 2)
# pca.explained_variance_ratio_ ,返回长度为2的数组:[pc1方差占比, pc2方差占比]
print("各主成分解释方差比:", pca.explained_variance_ratio_)

# 可视化
plt.scatter(X_reduced[:, 0], X_reduced[:, 1], c=y, cmap='viridis')
plt.xlabel('第一主成分')
plt.ylabel('第二主成分')
plt.title('Iris数据PCA降维到2D')
plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 1.7.4 选多少维:累积解释方差比

# PCA():不指定n_components`,代表**保留全部主成分。
pca_full = PCA()
# fit(X):计算协方差矩阵、特征值、特征向量,得到每个主成分单独的解释方差占比。
pca_full.fit(X)
# pca_full.explained_variance_ratio_:数组,长度=原始特征数(4)。每个元素代表单个主成分能解释的原始数据总方差百分比,降序排列。
cumsum = np.cumsum(pca_full.explained_variance_ratio_)
# np.cumsum()=累积求和
plt.plot(cumsum)
plt.xlabel('主成分数')
plt.ylabel('累积解释方差比')
plt.axhline(0.95, color='r', linestyle='--', label='95%')
plt.legend()
plt.show()

# 自动选保留95%方差的维度数
pca_95 = PCA(n_components=0.95)
X_95 = pca_95.fit_transform(X)
print("保留95%方差所需维度数:", pca_95.n_components_)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

n_components=0.95表示保留95%的方差,sklearn自动选维度数。

# 1.7.5 PCA的注意事项

  • 必须先做特征缩放:PCA对方差敏感,量纲或方差大的特征会主导主成分。用StandardScaler标准化。以iris为例:四特征虽同为厘米,但petal length的方差是sepal width的十几倍——不标准化时第一主成分解释方差比92.46%,标准化后降到72.96%,差了近20个百分点。这个对比本身就是最好的教学案例。
  • PCA是线性降维:非线性数据需要用Kernel PCA或t-SNE。
  • 主成分通常不像原始特征那样有直接语义:降维后的新特征是原始特征的线性组合,需要结合载荷矩阵(loading)看哪些原始变量对该主成分贡献最大,才能解释其含义。在很多实际项目中PC1、PC2是可以解释的,只是解释方式变了。

# 1.8 t-SNE:高维数据可视化

t-SNE(t-distributed Stochastic Neighbor Embedding,t分布邻域嵌入)是专门为可视化设计的非线性降维算法,它保留数据的局部结构,让相似的样本在低维空间中也靠近。

# 1.8.1 t-SNE的核心思想

t-SNE的直觉:高维空间里是邻居的点,搬到二维后也应该是邻居。它分两步:

  1. 高维:算每对点的"相似度"(用高斯分布,距离近的相似度高)
  2. 低维:用t分布(比高斯尾巴更重)算低维空间里每对点的相似度,然后让低维相似度尽量匹配高维相似度

为什么低维用t分布而不是高斯?因为高斯尾巴太轻,高维里距离远的点在低维会被挤在一起,导致"拥挤问题";t分布尾巴重,能把远处的点推得更开,可视化效果更好。

# 1.8.2 perplexity参数

perplexity可以理解为"每个点考虑多少个近邻",典型取值5-50。它控制t-SNE关注局部还是全局结构:

  • perplexity小:关注非常近的邻居,簇可能被打散
  • perplexity大:关注更远的邻居,可能合并小簇
from sklearn.manifold import TSNE
from sklearn.decomposition import PCA

# 官方推荐:高维数据先PCA降到~50维再喂t-SNE(去噪+大幅加速)
# iris只有4个特征,无需降维;这里演示用digits(64维)的思路:
# X_pca = PCA(n_components=50).fit_transform(digits.data)
# iris只有150个样本,perplexity=30偏大,建议5~15
# perplexity:困惑度,t-SNE最核心的超参数。可以理解为:算法计算每个点的分布时,会参考周围约15个近邻点。-值越大:越关注全局结构,忽略局部细节。 -值越小:越放大局部簇结构,容易碎成多块
tsne = TSNE(n_components=2, random_state=42, perplexity=15)
X_tsne = tsne.fit_transform(X)

plt.scatter(X_tsne[:, 0], X_tsne[:, 1], c=y, cmap='viridis')
plt.title('t-SNE可视化')
plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 1.8.3 t-SNE vs PCA

维度 PCA t-SNE
线性/非线性 线性 非线性
保留全局结构 是 否(只保留局部)
速度 快 慢
用途 去噪、加速 纯可视化

# 1.9 聚类+降维组合实战

最后把两大工具组合起来,在8×8=64维的手写数字数据集上走一遍完整流程:先标准化,用PCA压到2维做可视化,KMeans则在缩放后的高维空间里聚类——注意聚类用高维、可视化才用低维,两件事不冲突。

from sklearn.datasets import load_digits
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score, adjusted_rand_score

# 手写数字数据集(8x8=64维)
digits = load_digits()
X = digits.data
y = digits.target

# 1. 标准化
X_scaled = StandardScaler().fit_transform(X)

# 2. PCA降维到2维(可视化用)
pca = PCA(n_components=2)
X_2d = pca.fit_transform(X_scaled)
print("PCA累积解释方差:", pca.explained_variance_ratio_.sum())

# 3. KMeans聚类(在原始64维上)
kmeans = KMeans(n_clusters=10, random_state=42, n_init=10)
labels_64d = kmeans.fit_predict(X_scaled)

# 4. 可视化:用PCA降维后的坐标画聚类结果
plt.figure(figsize=(12, 5))
plt.subplot(1, 2, 1)
plt.scatter(X_2d[:, 0], X_2d[:, 1], c=labels_64d, cmap='tab10', s=5, alpha=0.6)
plt.title('KMeans聚类结果(PCA投影)')

plt.subplot(1, 2, 2)
plt.scatter(X_2d[:, 0], X_2d[:, 1], c=y, cmap='tab10', s=5, alpha=0.6)
plt.title('真实标签(PCA投影)')
plt.show()

# 实验A:64维直接聚类
sil_64 = silhouette_score(X_scaled, labels_64d)
ari_64 = adjusted_rand_score(y, labels_64d)

# 实验B:先PCA降到2维再聚类
km_2d = KMeans(n_clusters=10, random_state=42, n_init=10)
labels_2d = km_2d.fit_predict(X_2d)
sil_2d = silhouette_score(X_2d, labels_2d)
ari_2d = adjusted_rand_score(y, labels_2d)

print(f"64维聚类: 轮廓系数={sil_64:.4f}, ARI={ari_64:.4f}")
print(f"2维聚类:  轮廓系数={sil_2d:.4f}, ARI={ari_2d:.4f}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

关键发现(实测数据):

做法 轮廓系数 ARI
64维直接聚类(实验A) 0.139 0.534
PCA降到2维再聚类(实验B) 0.378 0.326

看出冲突了吗?轮廓系数从0.14涨到0.38(看着变好了近3倍),但ARI从0.53掉到0.33(其实变差了)!这是因为高维空间里距离本身被维度灾难扭曲,轮廓系数的绝对数值没有意义,更不能跨维度比较。轮廓系数只能比"同一维度下的不同K",不能比"不同维度"。为了画图而降维 ≠ 为了建模而降维——这就是为什么聚类用高维、可视化才用低维。

# 1.10 小结

# 算法选择决策表

回到开头的顾客分群问题,拿到数据该选哪个算法?一张表说清:

你的数据长这样 选它
球形簇、样本多、要快 KMeans(先跑,它是对的默认基线)
不知道分几群、要可解释 层次聚类(看树状图切)
弯月/环形/异形、有噪声 DBSCAN
簇有重叠、想要概率、要生成样本 GMM
特征太多要压缩/去噪/加速 PCA(先标准化!)
只想把高维画成图看一眼 UMAP(或t-SNE,固定random_state)

# 今天的认知升级:算法其实都在偷偷定义"什么叫相似"

这篇文章,最大的收获不是记住了KMeans、DBSCAN和PCA的公式,而是终于想通了一件事:无监督学习的核心,是在没有标准答案时,替数据定义"什么叫相似"。

  • KMeans认为:距离近就是相似(欧氏距离)
  • DBSCAN认为:密度相近、彼此可达就是相似
  • GMM认为:来自同一个概率分布就是相似
  • 层次聚类认为:合并代价最小就是相似
  • PCA则换了一个问题:不是"谁和谁相似",而是"数据最主要沿哪个方向变化"

**算法不同,本质上是我们对数据结构的假设不同。**选算法不是在选"哪个更高级",而是在选"哪个假设更符合你的数据"。这可能是今天真正值得记住的东西。

# 从无监督学习到深度学习

这些"老算法"在今天的大模型时代并没有过时,反而成了理解现代方法的钥匙:

  • PCA(线性、最大方差) → 自编码器(非线性PCA,从"最小重构误差"视角直接延伸)
  • KMeans → 深度聚类DEC(先学表征再聚类)
  • GMM的EM → VAE与变分推断(ELBO就是EM的贝叶斯升级版)
  • t-SNE → 大模型embedding可视化(你今天看LLM词向量散点图,用的就是它的后代)
  • "没有标签怎么学" → 对比学习、自监督预训练(SimCLR、BERT的MLM,本质都是"自己造监督信号"的无监督学习)

BERT的"完形填空"和对比学习,本质都是"没有标签,就自己造标签"——这一句能把Day6到Day10串成一条线。

最近更新: 2026/9/30
 |  Copyright © -Present Kelsen All Rights Reserved. | Author KelsenJing