Files
ModelTrainEval/docs/unsupervised-clustering.md
T
Shaun ZhouandClaude Opus 5 e4bd794025 feat(cluster): 输出簇分布图,直观展示簇间距离
在 preview 拼图的同级目录生成 cluster_scatter.png:
- 左:聚类空间的 PCA 二维投影,散点按簇着色 + 质心标记,标题标注两个主成分的方差占比
- 右:质心两两距离矩阵(在聚类空间里算,不是投影后),带数值标注

投影用 PCA 而不是 t-SNE/UMAP——后两者为了把簇拉开会扭曲簇间距离,
而这张图的目的就是看距离。

- ClusterResult 新增 points 字段,保留 KMeans 实际使用的空间供可视化复用,
  质心与散点用同一组主成分变换,避免质心飘到簇外
- summary.json 新增 centroid_distances
- 新增 scatter 配置块(enabled / max_points / point_size / alpha / figsize / dpi)
- 中文字体按 PingFang / Noto Sans CJK 等顺序探测,找不到自动退回英文标签,
  不会画成豆腐块
- 新增依赖 matplotlib

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

37 KiB
Raw Blame History

未分类样本的无监督聚类:算法与数学说明

本文档说明 scripts/cluster_unlabeled.py 背后的完整算法:每一步在做什么数学变换、 为什么这样选、以及在 data/film 上的实测依据。

代码入口:scripts/cluster_unlabeled.py、 engine/cluster.py、engine/simclr.py、 datasets/unlabeled.py。配置:configs/cluster/film.yaml。


目录

  1. 问题设定与记号
  2. 流水线总览
  3. 阶段一:预处理——抹掉采集因子
  4. 阶段二:特征提取——为什么用中间层
  5. 阶段三:归一化与 PCA 降维
  6. 阶段四:K-Means
  7. 阶段五:无标签评估
  8. 阶段六:SimCLR 自监督(可选)
  9. 复杂度与实测耗时
  10. 消融实验数据(含 SimCLR vs ImageNet 对比)
  11. 已知局限与改进方向
  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;命令行可用 --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. 流水线总览

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)。

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)。

实现见 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 放大。真实特征的谱极其陡峭:

---
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$),实践中只求局部最优。

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 倍不建议。

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 提到的"先筛掉无缺陷样本再细分"那一步——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。继续训到几十个 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
投影头 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 下界)