diff --git a/README.md b/README.md index 08f3da7..45d05c9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,244 @@ # ModelTrainEval +薄膜缺陷**分类 + 定位**模型的训练与评估。模型最终部署在实时设备上,因此只使用基于 +convolution 的结构(不含 attention / transformer),并支持导出 ONNX。 + +## 目录 + +| 目录 | 作用 | +| --- | --- | +| `configs/` | yaml 配置(`model/` 模型、`dataset/` 数据、`train/` 训练、`eval/` 评估) | +| `models/` | 模型骨架:`backbones/` + `necks/` + `heads/`,由 `defect_net.py` 组装 | +| `datasets/` | 抛出数据样本的 Dataset 类、数据增强、监督信号编码 | +| `data/` | 数据集本体或软链接(不入库,见 [data/README.md](data/README.md)) | +| `scripts/` | 格式转换、数据集切分、ONNX 导出等批处理脚本 | +| `engine/` | 训练 / 评估循环、损失、指标、优化器、检查点、SwanLab 可视化 | +| `utils/` | 配置、注册表、日志、随机种子、GPU 守卫 | +| `tests/` | 单元测试 | +| `docs/` | 算法与数学说明文档 | +| `figures/` | 文档用的 mermaid 图(`.mmd` 源 + `.md` 预览 + `.png`) | + +入口在仓库根目录:[train.py](train.py)、[eval.py](eval.py)。 + +## 环境 + +```bash +uv sync # 安装依赖(含 swanlab) +uv sync --group export # 需要导出 ONNX 时再装 +uv run python scripts/check_env.py # 自检(Python / PyTorch / Nvidia GPU) +``` + +> **训练机必须有 Nvidia GPU。** 所有训练入口先调用 `utils.device.require_cuda()`, +> 检测不到 CUDA 构建的 PyTorch 或可用 GPU 时直接报错退出。评估与 ONNX 导出允许 CPU。 +> Linux 上 PyPI 的 torch 即 CUDA 构建;如需指定 CUDA 版本: +> `uv pip install --index-url https://download.pytorch.org/whl/cu124 torch torchvision` + +## 数据准备 + +标注为 **YOLO 风格**:`images/` 下每张图在同层 `labels/` 有一个同名 `.txt`, +每行 `class_id cx cy w h`(相对宽高归一化到 [0,1]),空文件或缺失 `.txt` 即无缺陷样本。 + +``` +data/film/ +├── images/{train,val}/0001.png +├── labels/{train,val}/0001.txt +└── data.yaml # path / train / val / nc / names +``` + +```bash +# 1. labelme / VOC -> YOLO txt(顺带生成 data.yaml) +uv run python scripts/convert_annotations.py --format labelme \ + --input data/film/labelme --images data/film/images \ + --output data/film/labels --data-yaml data/film/data.yaml + +# 2. 切分 train / val(默认建软链,不复制数据) +uv run python scripts/split_dataset.py --images data/film/images \ + --labels data/film/labels --val-ratio 0.2 --data-yaml data/film/data.yaml +``` + +配置里的 `classes` 既可以直接写类别列表,也可以写成 `data/film/data.yaml`,由框架读 `names`。 +读取时也接受 YOLO 分割格式的多边形行(取外接矩形)。细节见 [data/README.md](data/README.md) +与 [datasets/yolo.py](datasets/yolo.py)。 + +## 训练与评估 + +```bash +uv run python train.py --config configs/train/baseline.yaml +uv run python train.py --config configs/train/baseline.yaml --set train.epochs=10 train.optimizer.lr=5e-4 +uv run python train.py --config configs/train/baseline.yaml --resume runs/baseline/last.pth + +uv run python eval.py --config configs/eval/baseline.yaml --checkpoint runs/baseline/best.pth +uv run python scripts/export_onnx.py --config configs/train/baseline.yaml \ + --checkpoint runs/baseline/best.pth --output runs/baseline/model.onnx +``` + +产物写在 `work_dir`(默认 `runs//`):`train.log`、`config.yaml`、`last.pth`、`best.pth`、`swanlog/`。 + +## 多类 / 单类分类 + +图像级分类的形式由 yaml 的 `label_mode` 决定,模型的分类头、分类损失、分类指标都跟着切换 +(定位分支不受影响,始终按 `classes` 逐类输出 heatmap): + +| `label_mode` | 含义 | 分类头输出 | 损失 | 主要指标 | +| --- | --- | --- | --- | --- | +| `multilabel`(默认) | **多类**:一张图可同时含多种缺陷,全 0 表示 OK 片 | `len(classes)` | 逐类 BCE | micro P/R/F1、`cls_exact_match` | +| `multiclass` | **单类**:一张图只归一个类别,互斥 | `len(classes) + 1`(末位为"无缺陷") | softmax CE | `cls_accuracy`、macro P/R/F1 | + +```bash +uv run python train.py --config configs/train/baseline.yaml # 多类 +uv run python train.py --config configs/train/multiclass.yaml # 单类 +uv run python train.py --config configs/train/baseline.yaml --set label_mode=multiclass +``` + +单类模式下若一张图标注了多种缺陷,取框数最多的类别(并列取下标最小的); +无缺陷样本落到背景下标 `len(classes)`。两种模式的分类指标都含 `cls_f1`, +因此 `train.monitor` 不必跟着改。 + +## 训练可视化(SwanLab) + +训练过程通过 [SwanLab](https://swanlab.cn) 记录:按 step 记录各项损失与学习率,按 epoch 记录 +验证指标与显存峰值,实验的超参就是完整的 yaml 配置。在 `train.swanlab` 里配置: + +```yaml +train: + swanlab: + enabled: true + project: film-defect + name: null # 缺省用 work_dir 目录名 + mode: online # online(云端) | local(本地看板) | offline(仅存盘) | disabled + tags: [tiny_conv, center-head] +``` + +```bash +swanlab login # 首次使用云端模式需登录 +uv run python train.py --config configs/train/baseline.yaml --set train.swanlab.mode=offline +uv run swanlab watch runs/baseline/swanlog # 本地看板 +``` + +未安装 swanlab、未登录或网络不通时会打一条警告并继续训练,不会中断。关掉用 +`--set train.swanlab.enabled=false`。 + +## 推理性能与显存分析 + +部署前用 `torch.profiler` 看单帧耗时与显存占用: + +```bash +uv run python scripts/profile_inference.py --config configs/train/baseline.yaml \ + --checkpoint runs/baseline/best.pth --batch-size 1 4 8 --postprocess + +# 导出 chrome trace,用 chrome://tracing 或 ui.perfetto.dev 打开 +uv run python scripts/profile_inference.py --config configs/train/baseline.yaml \ + --checkpoint runs/baseline/best.pth --trace runs/baseline/trace.json --out runs/baseline/profile.json +``` + +输出包含:每个 batch 的单帧延迟 / FPS、CUDA 显存峰值(`max_memory_allocated` 与 +`max_memory_reserved`)、权重占用,以及按显存和耗时各排一次序的算子级明细。 +`--amp` 用 fp16 推理对比,`--postprocess` 把 heatmap 解码也计入。没有 GPU 时自动退回 CPU +(只有耗时与 CPU 内存)。 + +## 未分类样本的无监督聚类 + +`data/film` 里大量样本还没人工分完。这条流水线把目录下**所有图片拉平**(不看任何 +目录名),做无监督聚类,产出可人工核对的分组: + +```bash +# ImageNet 预训练卷积特征,不训练,CPU 上 14.5k 张约 100 秒 +uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml + +# 先扫一遍簇数再决定分几类 +uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml --sweep-k 2-8 + +# 换成在本地数据上自监督训练的特征(SimCLR,属于训练,需要 Nvidia GPU) +uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml --set method=simclr +``` + +簇数由配置的 `num_clusters` 决定,也可以 `--num-clusters 6` 临时覆盖(实测 4 类的换种子稳定性最高,见文档 §10.4)。产出在 `work_dir`: +`assignments.csv`(每张图的簇号与到质心的距离)、`summary.json`、 +`clusters/cluster_XX/`(按簇建的软链目录,直接用看图软件翻)、 +`cluster_XX_preview.png`(每簇最靠近质心的代表样本拼图)。 + +翻图时注意 `clusters/` 里文件名前缀是排序号:`0000_` 最靠近质心(最典型), +末尾 20 张是离质心最远的离群样本——最能暴露这个簇是不是混了东西。 + +**尺寸不统一的处理**:这批图是已裁好的缺陷小图,中位 152×113,宽高比从 0.24 到 39.9。 +尺寸和宽高比是上游 ROI 裁剪的产物、不是缺陷属性,所以预处理的目标是把它抹掉: +直接拉伸到 128×128(不用 letterbox——填充比例等价于把宽高比明着喂给网络), +再做逐图 z-score 抹掉整体亮度/对比度。 + +无标签时用两个指标判断质量:**轮廓系数**(簇是否紧致)和**换随机种子的稳定性** +(同一份数据换初始化后划分是否还一样,低说明是在硬切一团连续数据)。 + +> 完整的数学推导、算法流程图与全部消融数据见 +> **[docs/unsupervised-clustering.md](docs/unsupervised-clustering.md)**。 + +### 自监督预训练(SimCLR) + +`method: pretrained`(默认)用 ImageNet 权重直接抽特征,不训练;`method: simclr` 则在 +**本批薄膜图上自监督训练**一个项目自己的纯卷积骨架,域适配更好。同一张图的两个随机增强 +视图互为正样本,batch 内其余 2B−2 个视图为负样本,用 NT-Xent 损失训练。 + +**这属于训练,因此必须有 Nvidia GPU**(走 `utils.device.require_cuda()` 守卫, +在无卡机器上会直接报错退出): + +```bash +# 1. 自监督训练 + 聚类一步跑完(首次运行) +uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml --set method=simclr + +# 2. 只想改簇数、复用已经训好的骨架:把权重路径填进 simclr.checkpoint +uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml --set method=simclr simclr.checkpoint=runs/cluster/simclr_backbone.pth --num-clusters 6 + +# 3. 调训练超参 +uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml --set method=simclr simclr.epochs=200 simclr.batch_size=512 simclr.temperature=0.1 +``` + +训练完权重存到 `/simclr_backbone.pth`(**只含骨架,不含投影头**——投影头被训练成 +对增强不变,会丢掉下游有用的信息,所以聚类和保存都只用骨架特征)。相关配置: + +```yaml +method: simclr + +simclr: + # 自监督用项目自己的纯卷积骨架(与 method=pretrained 的顶层 `backbone` 各自独立, + # 两者字段完全不同,分开配置以免 _base_ 合并时串味) + backbone: {name: tiny_conv, in_channels: 3, widths: [32, 64, 128, 256], depths: [1, 2, 2, 2]} + epochs: 100 + batch_size: 256 + temperature: 0.2 # 越小越关注难负样本 + amp: true + checkpoint: null # 填已有权重可跳过重复训练 + projection: {hidden_channels: 256, out_channels: 128} + optimizer: {name: adamw, lr: 0.001, weight_decay: 0.0001} + scheduler: {name: cosine} + +view_transforms: # 生成两个视图的随机增强 + - {name: RandomResizedCrop, size: [128, 128], scale: [0.5, 1.0]} + - {name: RandomHorizontalFlip, p: 0.5} + - {name: RandomVerticalFlip, p: 0.5} + - {name: GaussianBlur, p: 0.3} +``` + +> `RandomResizedCrop` 的 `scale` 下限别低于 0.4:裁太狠会让两个视图都只剩背景, +> "背景 vs 背景"被当成正样本对,学出来的特征会退化。 + +训练出的骨架和检测模型用的是同一套卷积骨架,可以直接拿去初始化 `DefectNet` 的 backbone—— +标注量有限时,通常比 ImageNet 权重更贴合薄膜的成像特性。 + +## 方法 + +- **骨架**:`tiny_conv`(自研深度可分离卷积,默认)或 `torchvision`(resnet / mobilenet / shufflenet)。 +- **neck**:nearest 上采样 + 3×3 卷积,把特征恢复到 stride 4,兼顾小缺陷定位与速度。 +- **定位头**:anchor-free 中心点(CenterNet 风格)——`heatmap` + `wh` + `offset`, + 推理端用 3×3 max-pool 做峰值抑制,无 NMS 循环,便于导出到 TensorRT / OpenVINO。 +- **分类头**:全局池化 + 全连接,输出**多热**标签(一张图可同时含多种缺陷,全 0 表示 OK 片)。 +- **指标**:定位 precision / recall / F1 / mean IoU(IoU 阈值匹配),分类 micro-F1 与整图完全正确率。 + `train.monitor` 决定用哪个指标挑选 `best.pth`。 + +配置支持 `_base_` 继承与命令行 `--set a.b.c=value` 覆盖,见 [utils/config.py](utils/config.py)。 +新增骨架 / 数据集只需用注册表装饰器注册,再在 yaml 里按 `name` 引用。 + +## 开发 + +```bash +uv run pytest # 单元测试 +uv run ruff check . # 静态检查 +``` diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..363f1c2 --- /dev/null +++ b/data/README.md @@ -0,0 +1,66 @@ +# data + +存放数据集本体或指向数据集的软链接;除本文件外的内容都不入库(见 .gitignore)。 + +标注采用 **YOLO 风格**:`images/` 下每张图在同层 `labels/` 里有一个同名 `.txt`。 + +``` +data/ +└── film/ + ├── images/ # 原图(或指向 NAS 的软链:ln -s /mnt/nas/film/images data/film/images) + │ ├── train/0001.png + │ └── val/0002.png + ├── labels/ # 与 images 目录结构一一对应 + │ ├── train/0001.txt + │ └── val/0002.txt + └── data.yaml # path / train / val / nc / names +``` + +标注文件每行一个目标,坐标**相对图像宽高归一化**到 [0, 1],中心点格式: + +``` + +0 0.312500 0.204688 0.087500 0.065625 +1 0.640625 0.712500 0.120000 0.093750 +``` + +- `class_id` 是 `data.yaml` / 配置里 `classes` 的下标,顺序即含义,改动必须重新训练。 +- **空文件或缺失 `.txt` = 无缺陷样本(OK 片)**,会作为纯负样本参与训练。 +- 也接受 YOLO 分割格式的多边形行(`class_id x1 y1 x2 y2 ...`),读取时取外接矩形。 + +`data.yaml` 与 ultralytics 通用: + +```yaml +path: data/film +train: images/train +val: images/val +nc: 5 +names: {0: scratch, 1: bubble, 2: particle, 3: crease, 4: stain} +``` + +配置里的 `classes` 既可以直接写类别列表,也可以写成 `data/film/data.yaml` 由框架读取 `names`。 + +## 还没分类的样本 + +`data/film` 下按人工整理留下的目录(`grease`、`wrinkle`、`反面纵黑点` ……)只是历史产物, +不是最终类别。要重新梳理类别时用无监督聚类,它会忽略这些目录名、把所有图拉平处理: + +```bash +uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml +``` + +结果里的 `clusters/cluster_XX/` 是软链目录,人工确认后再决定每个簇对应什么缺陷、 +以及是否要合并/拆分。 + +## 从其它格式转过来 + +```bash +# labelme / VOC -> YOLO txt(顺带生成 data.yaml) +uv run python scripts/convert_annotations.py --format labelme \ + --input data/film/labelme --images data/film/images \ + --output data/film/labels --data-yaml data/film/data.yaml + +# 划分 train / val(默认建软链,不复制数据) +uv run python scripts/split_dataset.py --images data/film/images \ + --labels data/film/labels --val-ratio 0.2 --data-yaml data/film/data.yaml +``` diff --git a/docs/unsupervised-clustering.md b/docs/unsupervised-clustering.md new file mode 100644 index 0000000..738fabe --- /dev/null +++ b/docs/unsupervised-clustering.md @@ -0,0 +1,776 @@ +# 未分类样本的无监督聚类:算法与数学说明 + +本文档说明 `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-消融实验数据) +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
需 Nvidia GPU"]:::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` 则在**这批薄膜图 +本身**上学特征,域适配更好。它是训练,因此按项目约定必须有 Nvidia GPU +(`utils.device.require_cuda()` 守卫)。 + +```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** | + +GPU 上特征提取会快一个数量级;瓶颈会转移到图像解码(可增大 `loader.num_workers`)。 + +--- + +## 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 全量最终结果 + +| 指标 | 值 | +| --- | --- | +| 样本数 | 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 特征终究是域外的。** `method: simclr` 在本批数据上自监督预训练,理论上更贴合, + 但需要 GPU 且要调 epoch / batch size。建议在 GPU 机器上跑一次并与 pretrained 对比 + 轮廓系数与稳定性。 + +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` | +| GPU 守卫 | `utils/device.py::require_cuda` | +| 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 下界) diff --git a/figures/clustering-pipeline.md b/figures/clustering-pipeline.md new file mode 100644 index 0000000..4f14d52 --- /dev/null +++ b/figures/clustering-pipeline.md @@ -0,0 +1,80 @@ +# 无监督聚类流水线 + +从 `data/film` 的原始图片到可人工核对的分组结果:六个阶段的数据流、张量形状与每步的数学操作。 +虚线是可选的 SimCLR 自监督分支(`method: simclr`,需要 Nvidia GPU)。 + +```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
需 Nvidia GPU"]:::feat + + scan --> PRE + PRE -->|"x̃ᵢ"| FEAT + scan -.->|"双视图增强"| simclr + simclr -.->|"域内骨架权重"| FEAT + FEAT -->|"z ∈ ℝᴺˣ³⁸⁴"| RED + RED -->|"y ∈ ℝᴺˣ³²"| CLU + CLU -->|"标签 c"| EVAL + CLU -->|"标签 c + 距离 d"| OUT +``` diff --git a/figures/clustering-pipeline.mmd b/figures/clustering-pipeline.mmd new file mode 100644 index 0000000..c814523 --- /dev/null +++ b/figures/clustering-pipeline.mmd @@ -0,0 +1,73 @@ +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
需 Nvidia GPU"]:::feat + + scan --> PRE + PRE -->|"x̃ᵢ"| FEAT + scan -.->|"双视图增强"| simclr + simclr -.->|"域内骨架权重"| FEAT + FEAT -->|"z ∈ ℝᴺˣ³⁸⁴"| RED + RED -->|"y ∈ ℝᴺˣ³²"| CLU + CLU -->|"标签 c"| EVAL + CLU -->|"标签 c + 距离 d"| OUT diff --git a/figures/clustering-pipeline.png b/figures/clustering-pipeline.png new file mode 100644 index 0000000..a968862 Binary files /dev/null and b/figures/clustering-pipeline.png differ diff --git a/figures/kmeans-lloyd.md b/figures/kmeans-lloyd.md new file mode 100644 index 0000000..e0e6908 --- /dev/null +++ b/figures/kmeans-lloyd.md @@ -0,0 +1,47 @@ +# K-Means:k-means++ 初始化 + Lloyd 迭代 + +`engine/cluster.py` 里 `kmeans()` 与 `cluster_features()` 的完整控制流:空簇处理、收敛判据、多次重启取最优,以及子集拟合 + 全量指派。 +虚线是各阶段内部的循环回边。 + +```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 +``` diff --git a/figures/kmeans-lloyd.mmd b/figures/kmeans-lloyd.mmd new file mode 100644 index 0000000..8a7dc80 --- /dev/null +++ b/figures/kmeans-lloyd.mmd @@ -0,0 +1,40 @@ +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 diff --git a/figures/kmeans-lloyd.png b/figures/kmeans-lloyd.png new file mode 100644 index 0000000..b8f9e7c Binary files /dev/null and b/figures/kmeans-lloyd.png differ diff --git a/figures/pca-spectrum-whitening.md b/figures/pca-spectrum-whitening.md new file mode 100644 index 0000000..4c41e1c --- /dev/null +++ b/figures/pca-spectrum-whitening.md @@ -0,0 +1,29 @@ +# PCA 主成分谱与白化的失效 + +**蓝柱**:真实特征在各主成分上的方差占比 λⱼ/Σλ(2000 张随机样本,layer2+3,384 维)。 +前 4 个成分占 68.7%,λ₁/λ₃₂ ≈ 165×。 + +**橙线**:白化后所有方向被强制拉平到 1/32 = 3.1%。 + +PC1 被压掉约 12 倍、PC32 被放大约 14 倍——K-Means 的欧氏距离于是主要由末尾那些 +近乎噪声的方向决定。实测开白化时最大簇吃掉 96% 的样本,关掉后降到 38%。 + +```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] +``` diff --git a/figures/pca-spectrum-whitening.mmd b/figures/pca-spectrum-whitening.mmd new file mode 100644 index 0000000..9a560a8 --- /dev/null +++ b/figures/pca-spectrum-whitening.mmd @@ -0,0 +1,17 @@ +--- +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] diff --git a/figures/pca-spectrum-whitening.png b/figures/pca-spectrum-whitening.png new file mode 100644 index 0000000..6b09db9 Binary files /dev/null and b/figures/pca-spectrum-whitening.png differ diff --git a/figures/simclr-contrastive.md b/figures/simclr-contrastive.md new file mode 100644 index 0000000..b7b772a --- /dev/null +++ b/figures/simclr-contrastive.md @@ -0,0 +1,56 @@ +# SimCLR 自监督对比学习 + +同一张图的两个随机增强视图互为正样本,batch 内其余 2B−2 个视图全是负样本。 +训练完只保留卷积骨架 f_θ,投影头 g_φ 丢弃。 + +```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 +``` diff --git a/figures/simclr-contrastive.mmd b/figures/simclr-contrastive.mmd new file mode 100644 index 0000000..71b19e7 --- /dev/null +++ b/figures/simclr-contrastive.mmd @@ -0,0 +1,49 @@ +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 diff --git a/figures/simclr-contrastive.png b/figures/simclr-contrastive.png new file mode 100644 index 0000000..c1cd1b7 Binary files /dev/null and b/figures/simclr-contrastive.png differ