# 未分类样本的无监督聚类:算法与数学说明 本文档说明 `scripts/cluster_unlabeled.py` 背后的完整算法:每一步在做什么数学变换、 为什么这样选、以及在 `data/raw/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/raw/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/raw/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/raw/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` | | 质心两两距离 | `engine/cluster.py::centroid_distances` | | 簇分布图的二维投影 | `engine/cluster.py::project_2d` | | NT-Xent 损失 | `engine/simclr.py::nt_xent_loss` | | SimCLR 训练循环 | `engine/simclr.py::SimCLRTrainer` | | 断点续训(完整状态存取) | `engine/simclr.py::save_state` / `load_state` | | 配置整块替换 `_replace_` | `utils/config.py::deep_merge` | | 投影头 $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 下界)