# 未分类样本的无监督聚类:算法与数学说明
本文档说明 `scripts/cluster_unlabeled.py` 背后的完整算法:每一步在做什么数学变换、
为什么这样选、以及在 `data/film` 上的实测依据。
代码入口:[`scripts/cluster_unlabeled.py`](../scripts/cluster_unlabeled.py)、
[`engine/cluster.py`](../engine/cluster.py)、[`engine/simclr.py`](../engine/simclr.py)、
[`datasets/unlabeled.py`](../datasets/unlabeled.py)。配置:[`configs/cluster/film.yaml`](../configs/cluster/film.yaml)。
---
## 目录
1. [问题设定与记号](#1-问题设定与记号)
2. [流水线总览](#2-流水线总览)
3. [阶段一:预处理——抹掉采集因子](#3-阶段一预处理抹掉采集因子)
4. [阶段二:特征提取——为什么用中间层](#4-阶段二特征提取为什么用中间层)
5. [阶段三:归一化与 PCA 降维](#5-阶段三归一化与-pca-降维)
6. [阶段四:K-Means](#6-阶段四k-means)
7. [阶段五:无标签评估](#7-阶段五无标签评估)
8. [阶段六:SimCLR 自监督(可选)](#8-阶段六simclr-自监督可选)
9. [复杂度与实测耗时](#9-复杂度与实测耗时)
10. [消融实验数据](#10-消融实验数据)(含 [SimCLR vs ImageNet 对比](#105-simclr-域内自监督-vs-imagenet-预训练))
11. [已知局限与改进方向](#11-已知局限与改进方向)
12. [公式与代码对照表](#12-公式与代码对照表)
---
## 1. 问题设定与记号
给定一组**无标注**图像 $\mathcal{I} = \{I_1, \dots, I_N\}$(`data/film` 下递归扫描到
$N = 14{,}579$ 张 PNG),在没有任何类别标签的前提下,把它们划分成 $k$ 个互不相交的簇
$$
\mathcal{C} = \{C_1, \dots, C_k\}, \qquad
\bigcup_{j=1}^{k} C_j = \{1,\dots,N\}, \qquad C_j \cap C_{j'} = \varnothing \ (j \neq j'),
$$
使得同簇样本在"缺陷形态"这一语义上尽量相似。$k$ 由配置 `num_clusters` 给定(实测 $k=4$ 的稳定性最高,见 [§10.4](#104-簇数扫描);命令行可用 `--num-clusters` 或 `--sweep-k 2-8` 覆盖与扫描)。
| 记号 | 含义 |
| --- | --- |
| $I_i$ | 第 $i$ 张原图,尺寸 $H_i \times W_i$ 各不相同 |
| $x_i \in \mathbb{R}^{3\times128\times128}$ | 预处理后的张量 |
| $f_\theta$ | 卷积骨架(特征提取器) |
| $z_i \in \mathbb{R}^{384}$ | 骨架输出的 embedding |
| $y_i \in \mathbb{R}^{32}$ | PCA 降维后的向量 |
| $\mu_j \in \mathbb{R}^{32}$ | 第 $j$ 个簇的质心 |
| $c(i) \in \{1,\dots,k\}$ | 样本 $i$ 的簇标签 |
| $\tau$ | SimCLR 的温度系数 |
**这批数据的关键统计量**(400 张随机抽样测得):
| 量 | 值 |
| --- | --- |
| 样本数 | 14,579 |
| 尺寸中位数 | $152 \times 113$ |
| 尺寸范围 | $84\times83$ ~ $4464\times1139$ |
| 抽样内不同尺寸种类 | 326 / 400 |
| 宽高比范围 | 0.24 ~ 39.86(中位 1.36) |
| 通道模式 | 混合 RGB / RGBA |
即:**样本是已裁好的缺陷小图(ROI crop),不是整幅薄膜图**,且尺寸几乎两两不同。
---
## 2. 流水线总览
```mermaid
flowchart TB
classDef input fill:#D1FAE5,stroke:#10B981,stroke-width:2px,color:#064E3B
classDef prep fill:#DBEAFE,stroke:#2563EB,stroke-width:2px,color:#1E3A8A
classDef feat fill:#DBEAFE,stroke:#2563EB,stroke-width:2px,color:#1E3A8A
classDef reduce fill:#EDE9FE,stroke:#7C3AED,stroke-width:2px,color:#4C1D95
classDef cluster fill:#EDE9FE,stroke:#7C3AED,stroke-width:2px,color:#4C1D95
classDef out fill:#FFEDD5,stroke:#EA580C,stroke-width:2px,color:#7C2D12
classDef metric fill:#FEF3C7,stroke:#D97706,stroke-width:2px,color:#78350F
scan["data/film 递归扫描
N = 14,579 张 PNG
尺寸各异 84×83 ~ 4464×1139"]:::input
subgraph PRE["① 预处理 — 抹掉采集因子"]
direction LR
resize["双线性拉伸
H×W → 128×128
不保长宽比"]:::prep
tensor["转张量 + RGBA/L → RGB
x ∈ ℝ³ˣ¹²⁸ˣ¹²⁸"]:::prep
zscore["逐图标准化
x̃ = (x − μₓ) / σₓ
抹掉亮度 μ 与对比度 σ"]:::prep
resize --> tensor --> zscore
end
subgraph FEAT["② 特征提取 — 纹理优先"]
direction LR
stem["resnet18 stem
conv1 → bn → relu → maxpool"]:::feat
l1["layer1
64ch, stride 4"]:::feat
l2["layer2
128ch, stride 8"]:::feat
l3["layer3
256ch, stride 16"]:::feat
gap2["GAP → ℓ₂ 归一化
u⁽²⁾ ∈ S¹²⁷"]:::feat
gap3["GAP → ℓ₂ 归一化
u⁽³⁾ ∈ S²⁵⁵"]:::feat
concat["拼接
z = [u⁽²⁾; u⁽³⁾] ∈ ℝ³⁸⁴"]:::feat
stem --> l1 --> l2 --> l3
l2 --> gap2 --> concat
l3 --> gap3 --> concat
end
subgraph RED["③ 降维"]
direction LR
norm["ℓ₂ 归一化
ẑ = z / ‖z‖₂"]:::reduce
pca["PCA 投影 384 → 32
y = Uᵀ(ẑ − z̄)
不白化:保留 λ 谱"]:::reduce
norm --> pca
end
subgraph CLU["④ 聚类 K-Means"]
direction LR
init["k-means++ 初始化
p(xᵢ) ∝ D(xᵢ)²"]:::cluster
lloyd["Lloyd 迭代
指派 ↔ 更新质心"]:::cluster
best["n_init = 10
取 inertia 最小的一次"]:::cluster
assign["全量指派
cᵢ = argminⱼ ‖yᵢ − μⱼ‖₂"]:::cluster
init --> lloyd --> best --> assign
end
subgraph EVAL["⑤ 无标签评估"]
direction LR
sil["轮廓系数 s̄
簇是否紧致"]:::metric
stab["换种子稳定性
NMI(C_seed0, C_seed7)"]:::metric
end
subgraph OUT["⑥ 产出"]
direction LR
csv["assignments.csv
路径 / 簇号 / 距质心"]:::out
links["clusters/cluster_XX/
软链目录,按距质心排序"]:::out
prev["cluster_XX_preview.png
代表样本拼图"]:::out
summ["summary.json
簇大小 / 轮廓 / 稳定性"]:::out
end
simclr["可选:SimCLR 自监督
method = simclr
cuda / mps 均可"]:::feat
scan --> PRE
PRE -->|"x̃ᵢ"| FEAT
scan -.->|"双视图增强"| simclr
simclr -.->|"域内骨架权重"| FEAT
FEAT -->|"z ∈ ℝᴺˣ³⁸⁴"| RED
RED -->|"y ∈ ℝᴺˣ³²"| CLU
CLU -->|"标签 c"| EVAL
CLU -->|"标签 c + 距离 d"| OUT
```
---
## 3. 阶段一:预处理——抹掉采集因子
### 3.1 核心前提
样本的**尺寸、宽高比、整体亮度都是采集环节的产物,不是缺陷属性**:
- 尺寸与宽高比由上游 ROI 裁剪框决定;
- 整体亮度与对比度由光照和相机增益决定。
因此预处理的目标不是"保留信息",而是**主动消除这些混淆变量**。若不消除,聚类会
把它们当成最显著的方差方向——这在实测中确实发生了(见 [§10.3](#103-归一化方式))。
### 3.2 尺寸归一:为什么用拉伸而不是 letterbox
两种候选:
| 方案 | 定义 | 宽高比信息的去向 |
| --- | --- | --- |
| Letterbox | 等比缩放 $s=\min(128/W, 128/H)$,四周补灰边 | **保留**:灰边占比 $1 - s\cdot H/128$ 直接编码了宽高比 |
| 拉伸 Resize | 各向异性缩放 $(H,W) \to (128,128)$ | **抹掉**:所有图变成同一画幅 |
Letterbox 的填充比例是宽高比的可逆函数,等于把这个采集噪声明着喂给网络——
卷积网络识别一条恒定灰边是平凡的。既然宽高比不是缺陷属性,就选拉伸。
**代价**:拉伸让纹理各向异性形变,$40{:}1$ 的长条尤其明显。备选方案是在原分辨率上取
固定大小的中心 patch(`CenterCrop`,不缩放不填充),实测轮廓 0.258、稳定性 0.735,
略逊于拉伸的 0.256/0.989——主要是极端长条被裁掉了大部分内容。配置里两种都可切换。
### 3.3 逐图标准化:抹掉亮度与对比度
对每张图,在**全部像素与通道**上求统计量后做 z-score:
$$
\mu_x = \frac{1}{3HW}\sum_{c,h,w} x_{c,h,w}, \qquad
\sigma_x = \sqrt{\frac{1}{3HW}\sum_{c,h,w}(x_{c,h,w} - \mu_x)^2}, \qquad
\tilde{x} = \frac{x - \mu_x}{\sigma_x + \varepsilon}.
$$
**这个变换对仿射光照变化严格不变。** 设 $x' = a x + b$($a>0$ 为增益,$b$ 为偏置),则
$\mu_{x'} = a\mu_x + b$、$\sigma_{x'} = a\sigma_x$,于是
$$
\tilde{x}' = \frac{(ax + b) - (a\mu_x + b)}{a\sigma_x} = \frac{a(x - \mu_x)}{a\sigma_x} = \tilde{x}.
$$
即 $\tilde{x}$ 是仿射亮度群 $\{x \mapsto ax+b\}$ 作用下的**不变量**,光照与增益被整除掉了。
对比固定的 ImageNet `mean/std` 归一化:那只是全数据集共用的一个仿射变换,不同图之间
的亮度差异原样保留。实测结果非常直接——用固定归一化时,4 个簇里有 3 个在区分
"背景偏亮/偏暗",改成逐图标准化后簇才变成"条纹状/团块状/弱纹理"([§10.3](#103-归一化方式))。
> 实现见 [`datasets/transforms.py`](../datasets/transforms.py) 的 `PerImageStandardize`,
> 单元测试 `test_per_image_standardize_removes_brightness_and_contrast` 直接验证了上面的不变性。
---
## 4. 阶段二:特征提取——为什么用中间层
### 4.1 多 stage 特征的构造
设骨架为 resnet18,其 `features` 是一个 `nn.Sequential`,下标 4–7 依次是
`layer1`–`layer4`。取集合 $S = \{5, 6\}$(即 `layer2`、`layer3`),对每个 $s \in S$:
$$
A^{(s)} = \text{layer}_s(\cdots) \in \mathbb{R}^{C_s \times H_s \times W_s},
\qquad
u^{(s)} = \frac{\text{GAP}(A^{(s)})}{\lVert \text{GAP}(A^{(s)}) \rVert_2},
\qquad
\text{GAP}(A)_c = \frac{1}{H_sW_s}\sum_{h,w} A_{c,h,w}.
$$
最终 embedding 是各 stage 单位向量的拼接:
$$
z = \big[\, u^{(2)} ;\; u^{(3)} \,\big] \in \mathbb{R}^{128 + 256} = \mathbb{R}^{384}.
$$
**逐 stage 先归一化再拼接**很重要:不同 stage 的激活幅度差一个数量级,直接拼会让幅度大的
stage 独占距离。各自归一化后两段等权,$\lVert z \rVert_2 = \sqrt{|S|}$ 恒定。
### 4.2 为什么中间层优于最后一层
$\text{GAP}(A^{(s)})$ 是特征图各通道的**空间一阶统计量**。对卷积网络:
- **浅层/中层**通道响应的是方向性边缘、条纹频率、颗粒尺度等**纹理基元**,其空间平均刻画
"这张图由什么纹理构成";
- **最后一层**(`layer4`)在 ImageNet 上被训练成对 1000 个**物体类别**线性可分,其语义
是"猫/车/狗"。薄膜缺陷图里没有任何 ImageNet 物体,这个语义对本任务基本是噪声。
缺陷之间的差异恰恰是纹理层面的:颗粒感、条纹方向、边缘锐度。实测(2000 张随机样本,
$k=4$)完全印证:
| 特征来源 | 维度 | 轮廓系数 | 换种子稳定性 |
| --- | --- | --- | --- |
| 只用 layer4(最后一层) | 512 | 0.191 | 0.410 |
| **layer2 + layer3** | **384** | **0.256** | **0.989** |
| layer1 + layer2 + layer3 | 448 | 0.262 | 0.771 |
| layer2 + layer3 + layer4 | 896 | 0.220 | 0.627 |
| layer1 … layer4 | 960 | 0.222 | 0.702 |
加回 `layer4` 反而把稳定性从 0.989 拉低到 0.627——它引入的方差与任务无关。
`layer1` 太低级(近乎原始梯度),也不稳。
---
## 5. 阶段三:归一化与 PCA 降维
### 5.1 ℓ₂ 归一化:欧氏距离等价于余弦距离
$\hat{z} = z / \lVert z \rVert_2$ 后,两点欧氏距离与余弦相似度一一对应:
$$
\lVert \hat{z}_a - \hat{z}_b \rVert_2^2
= \lVert\hat{z}_a\rVert^2 + \lVert\hat{z}_b\rVert^2 - 2\hat{z}_a^\top \hat{z}_b
= 2\big(1 - \cos(z_a, z_b)\big).
$$
因此后续用欧氏度量的 K-Means 实际上在做**球面上的余弦聚类**,只比较特征的"方向"
(即激活模式的构成比例),忽略"强度"。
### 5.2 PCA 投影
对中心化矩阵 $\bar{Z} = \hat{Z} - \mathbf{1}\bar{z}^\top \in \mathbb{R}^{N \times 384}$,
协方差 $\Sigma = \frac{1}{N-1}\bar{Z}^\top\bar{Z}$,特征分解 $\Sigma = U\Lambda U^\top$,
其中 $\Lambda = \mathrm{diag}(\lambda_1 \ge \cdots \ge \lambda_{384})$。取前 $q=32$ 列:
$$
y_i = U_{:,1:q}^\top (\hat{z}_i - \bar{z}) \in \mathbb{R}^{32}.
$$
实现用 `torch.pca_lowrank`(随机化 SVD,Halko et al. 2011),复杂度 $O(Ndq)$ 而非
完整 SVD 的 $O(Nd^2)$;对 $N{=}14579, d{=}384, q{=}32$ 约 $1.8\times10^8$ 次乘加,毫秒级。
### 5.3 白化为什么必须关掉
白化在投影后再除以各方向标准差:
$$
y^{\text{white}}_i = \Lambda_{1:q}^{-1/2} U_{:,1:q}^\top (\hat{z}_i - \bar{z}),
\qquad \text{使得} \quad \mathrm{Var}(y^{\text{white}}_{\cdot,j}) = 1 \ \ \forall j .
$$
等价地,白化后的欧氏距离就是原空间的**马氏距离**:
$$
\lVert y^{\text{white}}_a - y^{\text{white}}_b \rVert_2^2
= (\hat{z}_a - \hat{z}_b)^\top \Sigma^{-1} (\hat{z}_a - \hat{z}_b)
= \sum_{j=1}^{q} \frac{\big(u_j^\top(\hat{z}_a - \hat{z}_b)\big)^2}{\lambda_j}.
$$
第 $j$ 项被 $1/\lambda_j$ 放大。真实特征的谱极其陡峭:
```mermaid
---
config:
xyChart:
width: 1250
height: 520
titlePadding: 16
themeVariables:
xyChart:
titleColor: "#1F2937"
plotColorPalette: "#2563EB, #EA580C"
---
xychart
title "主成分方差谱:白化会把末尾的噪声方向抬到与信号同权"
x-axis "主成分序号 j (PCA 降到 32 维)" [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]
y-axis "方差占比 (%)" 0 --> 40
bar [38.2, 13.0, 10.8, 6.7, 4.5, 3.6, 3.4, 2.3, 2.0, 1.6, 1.5, 1.3, 1.2, 1.0, 0.9, 0.8, 0.7, 0.7, 0.7, 0.7, 0.5, 0.5, 0.5, 0.4, 0.4, 0.4, 0.3, 0.3, 0.3, 0.3, 0.3, 0.2]
line [3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1, 3.1]
```
- 前 4 个主成分占总方差的 **68.7%**,前 8 个占 82.6%;
- $\lambda_1 / \lambda_{32} \approx \mathbf{165\times}$。
白化把所有方向拉平到 $1/32 = 3.1\%$:**PC1 被压掉约 12 倍,PC32 被放大约 14 倍**。
结构只存在于前几个方向,白化后距离却由后面 28 个近乎噪声的方向主导。
在受控的合成数据上(3 个信号维 + 200 个噪声维,4 个分离良好的高斯团):
| $q$ | 不白化 轮廓 | 白化 轮廓 | 白化后最大簇占比 |
| --- | --- | --- | --- |
| 16 | 0.923 | 0.078 | 58% |
| 32 | 0.922 | 0.050 | 59% |
| 64 | 0.922 | 0.047 | 82% |
不白化时结果对 $q$ 几乎不敏感(都是 0.92),白化则全线崩溃。在真实数据上,早期开白化的版本最大簇吃掉 **96%** 的样本、稳定性仅 0.01;关掉后最大簇降到 38%。
> 这条结论被回归测试 `test_whitening_amplifies_noise_directions` 锁死。
---
## 6. 阶段四:K-Means
### 6.1 目标函数
$$
\min_{\mathcal{C},\, \{\mu_j\}} \ J(\mathcal{C}, \mu) = \sum_{j=1}^{k}\sum_{i \in C_j} \lVert y_i - \mu_j \rVert_2^2 .
$$
这是 NP-hard 问题(即使 $k=2$ 或 $d=2$),实践中只求局部最优。
```mermaid
flowchart TB
classDef start fill:#D1FAE5,stroke:#10B981,stroke-width:2px,color:#064E3B
classDef step fill:#DBEAFE,stroke:#2563EB,stroke-width:2px,color:#1E3A8A
classDef decide fill:#FEF3C7,stroke:#D97706,stroke-width:2px,color:#78350F
classDef guard fill:#FFE4E6,stroke:#BE123C,stroke-width:2px,color:#881337
classDef done fill:#FFEDD5,stroke:#EA580C,stroke-width:2px,color:#7C2D12
input["输入 Y = { yᵢ } ⊂ ℝ³², i = 1…N (可先随机抽子集拟合:fit_max_samples)
目标 min_{C,μ} J(C,μ) = Σᵢ ‖yᵢ − μ_{c(i)}‖² — NP-hard,只求局部最优"]:::start
subgraph INIT["① k-means++ 初始化 (期望近似比 E[J] ≤ 8(ln k + 2)·J_opt)"]
direction LR
pick1["均匀随机取 μ₁"]:::step
dist["D(y) = min_{j≤t} ‖y − μⱼ‖"]:::step
sample["按 p(y) ∝ D(y)² 采样 μ_{t+1}
离已有质心越远越易被选中"]:::step
kdone{"够 k 个?"}:::decide
pick1 --> dist --> sample --> kdone
kdone -.->|"否,继续挑"| dist
end
subgraph LLOYD["② Lloyd 迭代 (J 单调不增且有下界,故必收敛)"]
direction LR
assign["指派步
c(i) = argminⱼ ‖yᵢ − μⱼ‖²
一次 cdist 算完 N×k 距离"]:::step
update["更新步
μⱼ ← mean{ yᵢ : c(i) = j }
均值是簇内平方距离的唯一最小点"]:::step
empty{"有空簇?"}:::decide
steal["把离自身质心最远的点
提为该空簇的新质心
保证簇数不缩水"]:::guard
conv{"|J_prev − J| ≤ tol·J_prev
或迭代满 100 次?"}:::decide
assign --> update --> empty
empty -->|"否"| conv
empty -->|"是"| steal --> conv
conv -.->|"否,回到指派步"| assign
end
repeat["③ 以上 ①② 整体重复 n_init = 10 次
每次换随机种子 seed + r"]:::step
best["④ 取 10 次里 J 最小的一次 (C*, μ*)
缓解 Lloyd 只保证局部最优"]:::done
final["⑤ 全量指派 cᵢ = argminⱼ ‖yᵢ − μ*ⱼ‖,i = 1…N
并记录到质心的距离 dᵢ,供排序与离群检查"]:::done
input --> INIT
kdone ==>|"是,得到初值 μ⁽⁰⁾"| assign
conv ==>|"是,本次收敛"| repeat
repeat --> best --> final
```
### 6.2 Lloyd 迭代与收敛性
两步交替,每步都在**各自变量上精确最小化** $J$:
**指派步**(固定 $\mu$,优化 $\mathcal{C}$):
$$c(i) = \arg\min_j \lVert y_i - \mu_j \rVert_2^2 .$$
**更新步**(固定 $\mathcal{C}$,优化 $\mu$):对 $\mu_j$ 求梯度并置零,
$$
\frac{\partial}{\partial \mu_j}\sum_{i \in C_j}\lVert y_i - \mu_j\rVert^2 = -2\sum_{i\in C_j}(y_i - \mu_j) = 0
\quad\Longrightarrow\quad
\mu_j = \frac{1}{|C_j|}\sum_{i \in C_j} y_i .
$$
由于 $J$ 在两步中都不增、且 $J \ge 0$ 有下界,同时 $N$ 个点划分成 $k$ 簇的方式**有限**,算法必在有限步内停止。收敛判据用相对变化:$|J_{\text{prev}} - J| \le \text{tol}\cdot J_{\text{prev}}$,`tol = 1e-5`,上限 100 次迭代。
> **注意**:收敛保证的是局部最优,不是全局最优——这正是需要 `n_init` 的原因。
### 6.3 k-means++ 初始化
均匀随机取初值时,多个初始质心可能落在同一团里,导致明显的坏解。k-means++ 按到已选质心的平方距离采样:
$$
\Pr(\text{选中 } y) = \frac{D(y)^2}{\sum_{y'} D(y')^2},
\qquad D(y) = \min_{j \le t} \lVert y - \mu_j \rVert_2 .
$$
Arthur & Vassilvitskii (2007) 证明它给出 $O(\log k)$ 的**期望近似比**:
$$
\mathbb{E}[J_{\text{k-means++}}] \le 8(\ln k + 2)\, J_{\text{opt}} .
$$
这是初始化就能拿到的保证,Lloyd 迭代只会让 $J$ 进一步下降。
### 6.4 空簇处理
更新步中若某簇没有任何成员,$\mu_j$ 无定义。实现的处理是:**把当前离自身质心最远的点提为该空簇的新质心**。这保证输出恰好 $k$ 个非空簇(否则实际簇数会悄悄缩水),且由于选的是 $J$ 贡献最大的点,这一步通常还会降低 $J$。
### 6.5 多次重启
跑 `n_init = 10` 次完整的(初始化 + Lloyd),取 $J$ 最小的一次。设单次落入某个坏局部最优的概率为 $p$,则 10 次全坏的概率降到 $p^{10}$。
### 6.6 子集拟合 + 全量指派
可选地只用随机子集 $\mathcal{S} \subset \{1..N\}$(`fit_max_samples`)拟合质心,再把全部 $N$ 个样本按最近质心指派:
$$
\mu^* = \arg\min_\mu J_{\mathcal{S}}(\mu), \qquad
c(i) = \arg\min_j \lVert y_i - \mu^*_j \rVert, \ \ i = 1,\dots,N .
$$
合理性:质心是簇内均值的估计,其标准误随 $1/\sqrt{|\mathcal{S}|}$ 下降,几千个样本已足够。**子集必须是纯随机抽取**——按目录或任何人工整理的历史标签做分层,等于把先验偷偷注入无监督流程。默认 `fit_max_samples: 0`(用全部样本),因为 14.5k × 32 维的 K-Means 本来就只要几秒。
---
## 7. 阶段五:无标签评估
没有真值标签时无法算准确率,用两个内部指标。
### 7.1 轮廓系数(簇是否紧致)
对样本 $i$,设 $a(i)$ 为它到**同簇**其他点的平均距离,$b(i)$ 为它到**最近的其他簇**的平均距离:
$$
a(i) = \frac{1}{|C_{c(i)}|-1}\sum_{\substack{i' \in C_{c(i)} \\ i' \neq i}} \lVert y_i - y_{i'}\rVert,
\qquad
b(i) = \min_{j \neq c(i)} \frac{1}{|C_j|}\sum_{i' \in C_j} \lVert y_i - y_{i'}\rVert,
$$
$$
s(i) = \frac{b(i) - a(i)}{\max\{a(i),\, b(i)\}} \in [-1, 1],
\qquad
\bar{s} = \frac{1}{N}\sum_i s(i).
$$
$s(i) \to 1$ 表示样本牢牢待在自己簇里,$s(i) < 0$ 表示它离别的簇更近(八成分错了)。计算是 $O(N^2 d)$,故超过 `max_samples = 2000` 时随机抽样估计。
### 7.2 换种子稳定性(划分是否可复现)
同一份特征,用两个不同随机种子各跑一次完整聚类,得到划分 $\mathcal{A}$ 与 $\mathcal{B}$,
计算归一化互信息:
$$
\mathrm{NMI}(\mathcal{A}, \mathcal{B}) = \frac{I(\mathcal{A};\mathcal{B})}{\sqrt{H(\mathcal{A})\,H(\mathcal{B})}},
$$
$$
I(\mathcal{A};\mathcal{B}) = \sum_{a}\sum_{b} p(a,b)\log\frac{p(a,b)}{p(a)p(b)},
\qquad
H(\mathcal{A}) = -\sum_a p(a)\log p(a),
$$
其中 $p(a,b) = |A_a \cap B_b| / N$。NMI 对簇编号的置换不变(换标签名不影响),取值 $[0,1]$,1 表示两次划分完全一致。
**为什么这个指标关键**:数据里真有 $k$ 团结构时,不同初始化会收敛到同一套划分,NMI ≈ 1;若是在硬切一团连续的数据,边界位置随初值乱跳,NMI 会很低。它比轮廓系数更能识破"强行分 $k$ 类"的情形。实测中它一眼揪出了白化的问题(稳定性 0.01)。
> **不使用外部标签。** `data/film` 下的目录名(`grease`、`wrinkle`、`反面纵黑点`……)
> 是人工整理的历史产物,既不完整也未必正确,因此既不参与聚类、也不用于评估。
---
## 8. 阶段六:SimCLR 自监督(可选)
`method: pretrained` 用的是 ImageNet 权重——域外的。`method: simclr` 则在**这批薄膜图本身**上学特征,域适配更好。它是训练,设备由 `device: auto` 按 `cuda > mps > cpu` 自动挑选:CUDA 与 Apple Silicon 的 MPS 都能跑(实测 14,579 张、batch 256 时 MPS 约 65 秒/epoch),CPU 慢约 30 倍不建议。
```mermaid
flowchart TB
classDef data fill:#D1FAE5,stroke:#10B981,stroke-width:2px,color:#064E3B
classDef aug fill:#DBEAFE,stroke:#2563EB,stroke-width:2px,color:#1E3A8A
classDef net fill:#EDE9FE,stroke:#7C3AED,stroke-width:2px,color:#4C1D95
classDef loss fill:#FFEDD5,stroke:#EA580C,stroke-width:2px,color:#7C2D12
classDef note fill:#FEF3C7,stroke:#D97706,stroke-width:2px,color:#78350F
batch["一个 batch:B 张无标注图 { Iᵢ }, i = 1…B"]:::data
aug["随机增强分布 𝒯:RandomResizedCrop 0.5~1.0 · 水平/垂直翻转 · 高斯模糊
对每张图独立采样两次 t, t′ ~ 𝒯"]:::aug
v1["视图 1 x⁽¹⁾ᵢ = t(Iᵢ)"]:::aug
v2["视图 2 x⁽²⁾ᵢ = t′(Iᵢ)"]:::aug
bb1["卷积骨架 f_θ"]:::net
bb2["卷积骨架 f_θ (共享同一份参数)"]:::net
h1["h⁽¹⁾ᵢ = f_θ(x⁽¹⁾ᵢ) ∈ ℝᴰ"]:::net
h2["h⁽²⁾ᵢ = f_θ(x⁽²⁾ᵢ) ∈ ℝᴰ"]:::net
g1["投影头 g_φ
Linear → BN → ReLU → Linear"]:::net
g2["投影头 g_φ (共享参数)"]:::net
z1["z⁽¹⁾ᵢ = g_φ(h⁽¹⁾ᵢ) ∈ ℝ¹²⁸"]:::net
z2["z⁽²⁾ᵢ = g_φ(h⁽²⁾ᵢ) ∈ ℝ¹²⁸"]:::net
sim["ℓ₂ 归一化后拼成 Z ∈ ℝ²ᴮˣ¹²⁸
相似度矩阵 S = ZZᵀ / τ ∈ ℝ²ᴮˣ²ᴮ,τ = 0.2
对角线置 −∞,排除自己和自己"]:::loss
nt["NT-Xent 损失
ℒ = − (1/2B) Σₖ log [ exp(S_{k,π(k)}) / Σ_{m≠k} exp(S_{k,m}) ]
π(k):同一张图的另一个视图"]:::loss
pos["分子 = 正样本对(1 个)
同图另一视图 π(k)"]:::note
neg["分母 = 全部 2B−2 个负样本
batch 内其他所有视图"]:::note
step["反向传播同时更新 θ 与 φ
AdamW + cosine,AMP"]:::loss
ckpt["训练收敛后只保存骨架
simclr_backbone.pth
投影头 g_φ 丢弃"]:::data
use["聚类阶段用 h = f_θ(x),不用 z
投影头会滤掉对比任务用不上、
但下游有用的信息"]:::note
batch --> aug
aug --> v1
aug --> v2
v1 --> bb1 --> h1 --> g1 --> z1
v2 --> bb2 --> h2 --> g2 --> z2
z1 --> sim
z2 --> sim
pos -.-> nt
neg -.-> nt
sim --> nt --> step --> ckpt --> use
```
### 8.1 正样本对的构造
从增强分布 $\mathcal{T}$ 中对每张图独立采样两次 $t, t' \sim \mathcal{T}$:
$$
x_i^{(1)} = t(I_i), \qquad x_i^{(2)} = t'(I_i).
$$
$\mathcal{T}$ 包含 `RandomResizedCrop(scale=0.5~1.0)`、水平/垂直翻转、高斯模糊。**监督信号完全来自"这两个视图来自同一张图"这一事实**,不需要任何人工标签。
> `scale` 下限不宜低于 0.4:裁得太狠容易两个视图都只剩背景,"背景 vs 背景"被当成正样本对,学出的特征会退化。另外主 transforms 里的 `PerImageStandardize` 在增强**之后**执行,因此 `ColorJitter` 的亮度扰动最终会被抵消,真正起作用的是裁剪/翻转/模糊。
### 8.2 NT-Xent 损失
把一个 batch 的 $2B$ 个视图编码、投影、$\ell_2$ 归一化后堆成 $Z \in \mathbb{R}^{2B \times 128}$,
相似度矩阵
$$
S = \frac{ZZ^\top}{\tau} \in \mathbb{R}^{2B\times2B},
\qquad S_{kk} := -\infty \ \ (\text{排除自身}).
$$
设 $\pi(k)$ 为 $k$ 的配对视图下标($\pi(i) = i+B$,$\pi(i+B) = i$),损失为
$$
\boxed{\ \mathcal{L} = -\frac{1}{2B}\sum_{k=1}^{2B}
\log \frac{\exp\!\big(S_{k,\pi(k)}\big)}{\sum_{m \neq k}\exp\!\big(S_{k,m}\big)}\ }
$$
分子只有 1 个正样本对,分母是全部 $2B-2$ 个负样本加正样本对。形式上就是把"在 $2B-1$ 个候选中认出配对视图"当成一个 $(2B-1)$ 类的分类问题——实现里直接用 `F.cross_entropy(S, targets)`,`targets[k] = π(k)`。
**温度 $\tau$** 控制分布的尖锐度:$\tau$ 小 ⇒ softmax 更尖 ⇒ 梯度集中在最难的负样本上;$\tau$ 大 ⇒ 所有负样本近乎等权。默认 $\tau = 0.2$。
**与互信息的关系**:NT-Xent 是 InfoNCE 的一个实例,最小化 $\mathcal{L}$ 等价于最大化两个视图表征之间互信息的一个下界:
$$
I\big(h^{(1)}; h^{(2)}\big) \;\ge\; \log(2B-1) - \mathcal{L}.
$$
这也解释了为什么 batch 越大效果越好——负样本越多,下界越紧。
### 8.3 为什么聚类用骨架特征而不是投影头输出
$g_\varphi$ 被显式训练成对增强 $\mathcal{T}$ **不变**。凡是被增强破坏掉的信息(模糊掉的高频细节、翻转掉的方向性),在 $z$ 里都会被压掉。而这些信息对下游任务往往有用。
$h = f_\theta(x)$ 位于投影头之前,保留得更完整——这是 SimCLR 原论文的既有结论(线性评估上 $h$ 比 $z$ 高十几个点)。因此:**训练用 $z$ 算损失,聚类和保存权重都只用 $h$。**
### 8.4 与检测任务的衔接
`simclr_backbone.pth` 里的骨架就是项目检测模型用的同一个卷积骨架,可以直接拿去初始化 `DefectNet` 的 backbone——在标注量有限时,用 14.5k 张无标注图预训练过的权重通常比 ImageNet 权重更贴合薄膜的成像特性。
---
## 9. 复杂度与实测耗时
| 阶段 | 复杂度 | 实测(14,579 张,Apple Silicon CPU) |
| --- | --- | --- |
| 图像读取 + 预处理 | $O(N \cdot HW)$ | 与特征提取重叠(4 个 worker) |
| 特征提取 | $O(N \cdot \mathrm{FLOPs}(f_\theta))$ | **~84 s**(占总耗时绝大部分) |
| ℓ₂ 归一化 | $O(Nd)$ | < 0.1 s |
| PCA(随机化 SVD) | $O(Ndq) \approx 1.8\times10^8$ | < 1 s |
| K-Means | $O(n_{\text{init}} \cdot T \cdot Nkq)$ | ~2 s($T \approx 20$–30 次迭代收敛) |
| 轮廓系数 | $O(M^2 q)$,$M = 2000$ | < 1 s |
| 稳定性(再跑 2 次聚类) | 同 K-Means × 2 | ~4 s |
| 产出(软链 + 拼图) | $O(N + k\cdot\text{preview})$ | ~2 s |
| **合计** | | **~100 s** |
换到 Apple Silicon 的 MPS(`device: auto` 自动选中)后,同样 14,579 张的特征提取从 84 秒降到 51 秒——瓶颈已转移到图像解码,继续加速要靠增大 `loader.num_workers`。SimCLR 自监督训练在 MPS 上约 65 秒/epoch(batch 256,双视图,显存峰值约 137 MB)。
---
## 10. 消融实验数据
以下实验均在**从全库纯随机抽取的 2000 张**上进行(不按目录分层),$k=4$,PCA 32 维。
### 10.1 特征来源
| 方案 | 维度 | 轮廓系数 | 稳定性 | 最大簇占比 |
| --- | --- | --- | --- | --- |
| 只用 layer4 | 512 | 0.191 | 0.410 | 0.37 |
| **layer2 + layer3** | **384** | **0.256** | **0.989** | 0.39 |
| layer1+2+3 | 448 | 0.262 | 0.771 | 0.39 |
| layer2+3+4 | 896 | 0.220 | 0.627 | 0.37 |
| layer1…4 | 960 | 0.222 | 0.702 | 0.38 |
| layer2+3(灰度输入) | 384 | 0.272 | 0.963 | 0.37 |
| layer2+3(CenterCrop) | 384 | 0.258 | 0.735 | 0.37 |
### 10.2 白化(合成数据:3 信号维 + 200 噪声维,4 个高斯团)
| $q$ | 不白化 | 白化 |
| --- | --- | --- |
| 16 | 0.923 | 0.078 |
| 32 | 0.922 | 0.050 |
| 64 | 0.922 | 0.047 |
### 10.3 归一化方式
| 归一化 | 轮廓系数 | 稳定性 | 簇的实际内容 |
| --- | --- | --- | --- |
| 固定 ImageNet mean/std | 0.256 | 0.966 | 4 个簇里 3 个在分"背景偏亮/偏暗" |
| **逐图标准化** | **0.322** | **0.995** | 条纹状 / 团块状 / 弱纹理 |
### 10.4 簇数扫描
| $k$ | 轮廓系数 | 稳定性 | 最大簇占比 | 簇大小 |
| --- | --- | --- | --- | --- |
| 2 | 0.183 | 0.770 | 0.71 | [1418, 582] |
| 3 | 0.260 | 0.671 | 0.51 | [209, 776, 1015] |
| **4** | 0.257 | **0.957** | 0.38 | [185, 769, 443, 603] |
| 5 | 0.238 | 0.738 | 0.28 | [328, 385, 195, 522, 570] |
| 6 | 0.228 | 0.665 | 0.28 | [242, 359, 161, 518, 556, 164] |
| 7 | 0.230 | 0.692 | 0.28 | [236, 345, 87, 509, 559, 150, 114] |
| 8 | 0.272 | 0.758 | 0.34 | [676, 215, 58, 27, 176, 99, 430, 319] |
$k=4$ 的稳定性在 $k \in [3,7]$ 中最高,轮廓也接近峰值——默认值 4 有数据支撑。命令行随时可扫:`--sweep-k 2-8`。
### 10.5 SimCLR 域内自监督 vs ImageNet 预训练
在全部 14,579 张上、用**完全相同**的聚类设置(PCA 32 维、不白化、`n_init=10`)对比两种特征。
SimCLR 一侧用的是在本批数据上**只训了 1 个 epoch**(Apple M 系列 MPS,65 秒)的 tiny_conv 骨架:
| 特征 | 维度 | $k=4$ 轮廓 | $k=4$ 稳定性 | $k=2$ 轮廓 | $k=2$ 稳定性 |
| --- | --- | --- | --- | --- | --- |
| ImageNet 预训练 layer2+3 | 384 | 0.325 | **0.977** | 0.354 | 0.887 |
| SimCLR 域内(1 epoch) | 256 | **0.334** | 0.829 | **0.469** | **0.991** |
两点值得注意:
1. **只训 1 个 epoch 就在 $k=4$ 上追平并略微超过 ImageNet 特征**(0.334 vs 0.325),而它的维度还少三分之一。域内自监督的性价比很高。
2. **$k=2$ 上 SimCLR 明显更强**(轮廓 0.469、稳定性 0.991)。目视确认这个二分就是"有明显缺陷 / 弱纹理近空白":8,750 张落在空白侧,5,829 张落在缺陷侧(褶皱、V 形折痕、条纹、异物、点串)。这正是 [§11](#11-已知局限与改进方向) 提到的"先筛掉无缺陷样本再细分"那一步——SimCLR 特征直接把它做出来了,可以作为流水线的第一级过滤。
$k=4$ 时 SimCLR 的四个簇为:暗底细长条纹/点串/异物(2,423)、空白暗底两类(5,801 + 2,927)、亮底团块/污渍(3,428)。相比 ImageNet 特征的划分,两个缺陷簇的代表样本明显更同质。
$k=4$ 稳定性略低于 ImageNet(0.83 vs 0.98),符合"数据的主结构是二分、强行切四份时边界不唯一"的解释。
> **这是下界,不是 SimCLR 的上限。** 原计划跑 40 epoch,中途在 epoch 12(loss 4.55 → 2.66)停掉,
> 而当时 `fit()` 只在全部 epoch 结束后落盘,训练成果没保住。该缺陷已修复(`save_interval`
> 每 5 个 epoch 存一次,并捕获 KeyboardInterrupt),上表用的是此前完整跑完的 1-epoch 权重。
### 10.6 全量最终结果
| 指标 | 值 |
| --- | --- |
| 样本数 | 14,579 |
| 簇大小 | 4507 / 3795 / 4849 / 1428 |
| 轮廓系数 | **0.3255** |
| 换种子稳定性 | **0.9641** |
| 各簇平均距质心 | 0.145 / 0.173 / 0.237 / 0.333 |
| 耗时(CPU) | ~100 s |
人工核对代表样本后的簇语义:
| 簇 | 数量 | 目视内容 |
| --- | --- | --- |
| cluster_00 | 4507 | 近乎空白的弱纹理底图(带缓慢灰度梯度) |
| cluster_01 | 3795 | 近乎空白的弱纹理底图(另一种梯度形态) |
| cluster_02 | 4849 | **亮底上的团块 / 点状缺陷**(蚊虫状、颗粒) |
| cluster_03 | 1428 | **暗底上的条纹 / 拉丝 / 褶皱** |
---
## 11. 已知局限与改进方向
1. **两个簇是"近乎空白"的。** 这批裁片里确实有很大比例在 128×128 尺度下看不出明显缺陷,$k=4$ 就会把其中两个名额花在它们的细微差异上。这是数据本身的性质,不是算法缺陷。对策:调大 $k$ 让缺陷簇进一步细分;或先用一个简单的对比度/能量阈值把"无明显缺陷"的样本筛掉,再对剩下的做聚类。
2. **ImageNet 特征终究是域外的。** 已实测:只训 1 个 epoch 的域内 SimCLR 就在 $k=4$ 上追平(0.334 vs 0.325),在 $k=2$ 上大幅胜出(0.469 / 稳定性 0.991),见 [§10.5](#105-simclr-域内自监督-vs-imagenet-预训练)。继续训到几十个 epoch 值得一试。
3. **拉伸带来的各向异性形变**在极端长条(40:1)上很严重。若发现某个簇专门收集长条,可换 `CenterCrop`(原分辨率取中心 patch),或改成多 patch 聚合(对每张图取若干 patch 分别编码后取平均/最大)。后者对"缺陷是局部的"这一先验更契合,目前尚未实现。
4. **K-Means 假设簇是各向同性的球。** 若缺陷类别在特征空间里呈细长流形,K-Means 会切错。备选:谱聚类(构图 + 拉普拉斯特征向量)或 HDBSCAN(不需要预设 $k$,能标记噪声点)。代价是复杂度更高、超参更难调。
5. **轮廓系数 0.33 属于"弱但存在"的结构。** 不要指望聚类结果能直接当标签用;它的定位是**把人工核对的工作量从 14,579 张降到 4 个簇的抽查**。
---
## 12. 公式与代码对照表
| 公式 / 概念 | 实现位置 |
| --- | --- |
| 逐图标准化 $\tilde{x} = (x-\mu_x)/\sigma_x$ | `datasets/transforms.py::PerImageStandardize` |
| 拉伸 / letterbox / 中心裁剪 | `datasets/transforms.py::Resize` / `Letterbox` / `CenterCrop` |
| 递归扫描与统一通道 | `datasets/unlabeled.py::UnlabeledImageDataset` |
| 多 stage GAP + ℓ₂ + 拼接 | `engine/cluster.py::StageFeatureExtractor` |
| $\hat{z} = z/\lVert z\rVert_2$ | `engine/cluster.py::l2_normalize` |
| PCA 投影与白化 | `engine/cluster.py::pca_reduce` |
| k-means++ 初始化 | `engine/cluster.py::kmeans_plusplus` |
| Lloyd 迭代 + 空簇处理 + n_init | `engine/cluster.py::kmeans` |
| 轮廓系数 | `engine/cluster.py::silhouette_score` |
| NMI | `engine/cluster.py::normalized_mutual_info` |
| 换种子稳定性 | `engine/cluster.py::cluster_stability` |
| 随机子集拟合 | `engine/cluster.py::random_fit_index` |
| 端到端聚类 | `engine/cluster.py::cluster_features` |
| NT-Xent 损失 | `engine/simclr.py::nt_xent_loss` |
| SimCLR 训练循环 | `engine/simclr.py::SimCLRTrainer` |
| 投影头 $g_\varphi$ | `models/heads/projection.py::ProjectionHead` |
| 设备解析(cuda/mps/cpu,auto 优先级) | `utils/device.py::resolve_device` |
| AMP 随后端的默认值 | `utils/device.py::resolve_amp` / `default_amp` |
| 跨后端显存峰值 | `utils/device.py::PeakMemory` |
| CLI 与产出 | `scripts/cluster_unlabeled.py` |
**相关测试**:`tests/test_cluster.py`(K-Means 正确性、白化回归、稳定性、NMI)、`tests/test_unlabeled_dataset.py`(扫描、混合尺寸/通道、逐图标准化的不变性)。
---
## 参考文献
1. Arthur, D., Vassilvitskii, S. *k-means++: The Advantages of Careful Seeding.* SODA 2007.
2. Lloyd, S. *Least Squares Quantization in PCM.* IEEE Trans. Information Theory, 1982.
3. Rousseeuw, P. *Silhouettes: a graphical aid to the interpretation and validation of cluster analysis.* J. Comput. Appl. Math., 1987.
4. Strehl, A., Ghosh, J. *Cluster Ensembles — A Knowledge Reuse Framework for Combining Multiple Partitions.* JMLR 2002.(NMI)
5. Halko, N., Martinsson, P.-G., Tropp, J. *Finding Structure with Randomness: Probabilistic Algorithms for Constructing Approximate Matrix Decompositions.* SIAM Review, 2011.(随机化 SVD)
6. Chen, T., Kornblith, S., Norouzi, M., Hinton, G. *A Simple Framework for Contrastive Learning of Visual Representations.* ICML 2020.(SimCLR / NT-Xent)
7. van den Oord, A., Li, Y., Vinyals, O. *Representation Learning with Contrastive Predictive Coding.* arXiv 2018.(InfoNCE 下界)