Files
ModelTrainEval/docs/unsupervised-clustering.md
T
Shaun ZhouandClaude Opus 5 938d62ea53 docs: SimCLR 域内自监督的实测对比与多后端说明
- 新增 §10.5:SimCLR vs ImageNet 预训练在 14,579 张上的同设置对比。
  只训 1 个 epoch 的域内特征在 k=4 上即追平(轮廓 0.334 vs 0.325),
  在 k=2 上大幅胜出(轮廓 0.469、稳定性 0.991)——目视确认该二分正是
  "有明显缺陷 / 弱纹理近空白",可直接作为流水线的第一级过滤
- 复杂度表补 MPS 实测:特征提取 84s(CPU) -> 51s(MPS),SimCLR 约 65s/epoch
- README 增加"计算后端"小节(三后端的 AMP 默认值、显存统计与限制)
- 流程图与对照表同步去掉已移除的 GPU 守卫

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:43:35 +08:00

762 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 未分类样本的无监督聚类:算法与数学说明
本文档说明 `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 递归扫描<br/>N = 14,579 张 PNG<br/>尺寸各异 84×83 ~ 4464×1139"]:::input
subgraph PRE["① 预处理 — 抹掉采集因子"]
direction LR
resize["双线性拉伸<br/>H×W → 128×128<br/>不保长宽比"]:::prep
tensor["转张量 + RGBA/L → RGB<br/>x ∈ ℝ³ˣ¹²⁸ˣ¹²⁸"]:::prep
zscore["逐图标准化<br/>x̃ = (x − μₓ) / σₓ<br/>抹掉亮度 μ 与对比度 σ"]:::prep
resize --> tensor --> zscore
end
subgraph FEAT["② 特征提取 — 纹理优先"]
direction LR
stem["resnet18 stem<br/>conv1 → bn → relu → maxpool"]:::feat
l1["layer1<br/>64ch, stride 4"]:::feat
l2["layer2<br/>128ch, stride 8"]:::feat
l3["layer3<br/>256ch, stride 16"]:::feat
gap2["GAP → ℓ₂ 归一化<br/>u⁽²⁾ ∈ S¹²⁷"]:::feat
gap3["GAP → ℓ₂ 归一化<br/>u⁽³⁾ ∈ S²⁵⁵"]:::feat
concat["拼接<br/>z = [u⁽²⁾; u⁽³⁾] ∈ ℝ³⁸⁴"]:::feat
stem --> l1 --> l2 --> l3
l2 --> gap2 --> concat
l3 --> gap3 --> concat
end
subgraph RED["③ 降维"]
direction LR
norm["ℓ₂ 归一化<br/>ẑ = z / ‖z‖₂"]:::reduce
pca["PCA 投影 384 → 32<br/>y = Uᵀ(ẑ − z̄)<br/>不白化:保留 λ 谱"]:::reduce
norm --> pca
end
subgraph CLU["④ 聚类 K-Means"]
direction LR
init["k-means++ 初始化<br/>p(xᵢ) ∝ D(xᵢ)²"]:::cluster
lloyd["Lloyd 迭代<br/>指派 ↔ 更新质心"]:::cluster
best["n_init = 10<br/>取 inertia 最小的一次"]:::cluster
assign["全量指派<br/>cᵢ = argminⱼ ‖yᵢ − μⱼ‖₂"]:::cluster
init --> lloyd --> best --> assign
end
subgraph EVAL["⑤ 无标签评估"]
direction LR
sil["轮廓系数 s̄<br/>簇是否紧致"]:::metric
stab["换种子稳定性<br/>NMI(C_seed0, C_seed7)"]:::metric
end
subgraph OUT["⑥ 产出"]
direction LR
csv["assignments.csv<br/>路径 / 簇号 / 距质心"]:::out
links["clusters/cluster_XX/<br/>软链目录,按距质心排序"]:::out
prev["cluster_XX_preview.png<br/>代表样本拼图"]:::out
summ["summary.json<br/>簇大小 / 轮廓 / 稳定性"]:::out
end
simclr["可选:SimCLR 自监督<br/>method = simclr<br/>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&nbsp;&nbsp;(可先随机抽子集拟合:fit_max_samples)<br/>目标 &nbsp; min_{C,μ} J(C,μ) = Σᵢ ‖yᵢ − μ_{c(i)}‖²&nbsp;&nbsp;— NP-hard,只求局部最优"]:::start
subgraph INIT["① k-means++ 初始化&nbsp;&nbsp;(期望近似比 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}<br/>离已有质心越远越易被选中"]:::step
kdone{"够 k 个?"}:::decide
pick1 --> dist --> sample --> kdone
kdone -.->|"否,继续挑"| dist
end
subgraph LLOYD["② Lloyd 迭代&nbsp;&nbsp;(J 单调不增且有下界,故必收敛)"]
direction LR
assign["指派步<br/>c(i) = argminⱼ ‖yᵢ − μⱼ‖²<br/>一次 cdist 算完 N×k 距离"]:::step
update["更新步<br/>μⱼ ← mean{ yᵢ : c(i) = j }<br/>均值是簇内平方距离的唯一最小点"]:::step
empty{"有空簇?"}:::decide
steal["把离自身质心最远的点<br/>提为该空簇的新质心<br/>保证簇数不缩水"]:::guard
conv{"|J_prev − J| ≤ tol·J_prev<br/>或迭代满 100 次?"}:::decide
assign --> update --> empty
empty -->|"否"| conv
empty -->|"是"| steal --> conv
conv -.->|"否,回到指派步"| assign
end
repeat["③ 以上 ①②&nbsp;整体重复 n_init = 10 次<br/>每次换随机种子 seed + r"]:::step
best["④ 取 10 次里 J 最小的一次 (C*, μ*)<br/>缓解 Lloyd 只保证局部最优"]:::done
final["⑤ 全量指派&nbsp;&nbsp;cᵢ = argminⱼ ‖yᵢ − μ*ⱼ‖,i = 1…N<br/>并记录到质心的距离 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 · 水平/垂直翻转 · 高斯模糊<br/>对每张图独立采样两次 t, t′ ~ 𝒯"]:::aug
v1["视图 1&nbsp;&nbsp; x⁽¹⁾ᵢ = t(Iᵢ)"]:::aug
v2["视图 2&nbsp;&nbsp; x⁽²⁾ᵢ = t′(Iᵢ)"]:::aug
bb1["卷积骨架 f_θ"]:::net
bb2["卷积骨架 f_θ&nbsp;&nbsp;(共享同一份参数)"]:::net
h1["h⁽¹⁾ᵢ = f_θ(x⁽¹⁾ᵢ) ∈ ℝᴰ"]:::net
h2["h⁽²⁾ᵢ = f_θ(x⁽²⁾ᵢ) ∈ ℝᴰ"]:::net
g1["投影头 g_φ<br/>Linear → BN → ReLU → Linear"]:::net
g2["投影头 g_φ&nbsp;&nbsp;(共享参数)"]:::net
z1["z⁽¹⁾ᵢ = g_φ(h⁽¹⁾ᵢ) ∈ ℝ¹²⁸"]:::net
z2["z⁽²⁾ᵢ = g_φ(h⁽²⁾ᵢ) ∈ ℝ¹²⁸"]:::net
sim["ℓ₂ 归一化后拼成 Z ∈ ℝ²ᴮˣ¹²⁸<br/>相似度矩阵 S = ZZᵀ / τ ∈ ℝ²ᴮˣ²ᴮ,τ = 0.2<br/>对角线置 −∞,排除自己和自己"]:::loss
nt["NT-Xent 损失<br/>ℒ = − (1/2B) Σₖ log [ exp(S_{k,π(k)}) / Σ_{m≠k} exp(S_{k,m}) ]<br/>π(k):同一张图的另一个视图"]:::loss
pos["分子 = 正样本对(1 个)<br/>同图另一视图 π(k)"]:::note
neg["分母 = 全部 2B−2 个负样本<br/>batch 内其他所有视图"]:::note
step["反向传播同时更新 θ 与 φ<br/>AdamW + cosine,AMP"]:::loss
ckpt["训练收敛后只保存骨架<br/>simclr_backbone.pth<br/>投影头 g_φ 丢弃"]:::data
use["聚类阶段用 h = f_θ(x),不用 z<br/>投影头会滤掉对比任务用不上、<br/>但下游有用的信息"]:::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 下界)