feat: 未分类样本的无监督聚类(多 stage 特征 + KMeans,可选 SimCLR)
把 data/film 下所有图片拉平做聚类,不使用任何目录名,簇数由 num_clusters 决定。 预处理针对该批数据的实际情况(14.5k 张已裁好的缺陷小图,中位 152x113, 宽高比 0.24~39.9):尺寸与亮度都是 ROI 裁剪和光照的产物、不是缺陷属性, 因此拉伸到 128x128(不用 letterbox,其填充比例等价于编码了宽高比)并逐图 z-score。 - cluster: torch 实现的 kmeans++/Lloyd(含空簇处理与 n_init)、PCA、轮廓系数、 NMI 稳定性、多 stage 特征提取;不引 scikit-learn - 白化默认关闭:CNN 特征谱极陡(λ1/λ32≈165x),白化会把噪声方向抬到与信号同权, 实测最大簇一度吃掉 96% 的样本 - simclr: NT-Xent 自监督预训练,用项目自己的卷积骨架,权重可直接初始化 DefectNet; 属于训练,走 require_cuda() 守卫 - 产出 assignments.csv、summary.json、按簇的软链目录与代表样本拼图,供人工核对 全量 14,579 张:轮廓系数 0.326、换种子稳定性 0.964,CPU 约 100 秒。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4b1147ed8f
commit
f433e5cf59
@@ -0,0 +1,109 @@
|
||||
# 未分类样本的无监督聚类。
|
||||
#
|
||||
# 预处理决策(针对 data/film 的实际情况:14.5k 张已裁好的缺陷小图,
|
||||
# 中位尺寸 152x113,宽高比 0.24~39.9,尺寸几乎每张都不同):
|
||||
#
|
||||
# 前提:图片的尺寸和宽高比是上游 ROI 裁剪环节的产物,不是缺陷属性。
|
||||
# 所以预处理的目标是**把这个采集噪声抹掉**,只留下缺陷的纹理/形态。
|
||||
#
|
||||
# 1. 直接 Resize 拉伸到 128x128,不用 letterbox —— letterbox 的填充比例
|
||||
# 恰好等价于编码了宽高比,灰边占多少就等于把采集噪声明着喂给网络;
|
||||
# 拉伸则让所有图变成同一个画幅,宽高比不再是可用信号。
|
||||
# 2. use_shape_features 关掉 —— 同理,尺寸不进 embedding。
|
||||
# 3. 逐图标准化(PerImageStandardize)而不是固定的 ImageNet mean/std ——
|
||||
# 整体亮度来自光照和相机增益,同样不是缺陷属性。用固定归一化时,
|
||||
# 4 个簇里有 3 个花在"背景偏亮/偏暗"上;改成逐图 z-score 后,
|
||||
# 簇变成"条纹状 / 团块状 / 弱纹理",轮廓 0.256->0.322,稳定性 0.966->0.995。
|
||||
# 4. 不下采样到 224 —— 这批图本来就小,放大只会糊,128 已经够。
|
||||
#
|
||||
# 代价:拉伸会让纹理各向异性形变(40:1 的长条尤其明显)。如果后续发现
|
||||
# 聚类被形变干扰,可选的替代是在原始分辨率上取固定大小的中心 patch
|
||||
# (不缩放、不填充),配置里把 Resize 换成 CenterCrop 即可。
|
||||
|
||||
work_dir: runs/cluster
|
||||
seed: 42
|
||||
device: cuda # method=pretrained 时可以 --set device=cpu
|
||||
|
||||
num_clusters: 6 # 想分几类改这里(或 --num-clusters 6)
|
||||
|
||||
# pretrained: ImageNet 预训练卷积骨架直接抽特征,不训练,CPU 也能跑,几分钟出结果。
|
||||
# simclr: 在本目录数据上自监督训练项目自己的卷积骨架,域适配更好,需要 Nvidia GPU。
|
||||
method: pretrained
|
||||
|
||||
# method=pretrained 用的骨架(method=simclr 的骨架配在 simclr.backbone,两者字段完全不同,
|
||||
# 各自独立以免 _base_ 合并时串味)
|
||||
backbone:
|
||||
name: torchvision
|
||||
arch: resnet18
|
||||
pretrained: true
|
||||
in_channels: 3
|
||||
|
||||
image_size: [128, 128]
|
||||
use_shape_features: false # 尺寸/宽高比是采集产物,不作为聚类特征;true 仅供对照实验
|
||||
|
||||
# 取哪几个 stage 的特征拼起来(torchvision resnet 的 features 下标:4~7 = layer1~4)。
|
||||
# 实测(2000 张随机样本,k=4):
|
||||
# 只用最后一层 layer4 轮廓 0.191 换种子稳定性 0.410
|
||||
# layer2+3 轮廓 0.256 稳定性 0.989 <- 默认
|
||||
# layer2+3+4 轮廓 0.220 稳定性 0.627
|
||||
# 缺陷之间的差别主要在纹理(颗粒感、条纹方向、边缘锐度),中间层保留得比最后一层好;
|
||||
# 最后一层的 ImageNet 语义("这是猫还是车")对这批图基本是噪声。
|
||||
feature_stages: [5, 6]
|
||||
|
||||
# 特征降维。whiten 默认关闭:CNN 特征只有前若干主成分携带结构,
|
||||
# 白化会把剩下几十个噪声方向抬到同权,簇立刻塌成"一个大簇 + 几个碎簇"
|
||||
# (实测开白化时最大簇占 96%,关掉后降到 38%)。
|
||||
pca_dim: 32
|
||||
whiten: false
|
||||
n_init: 10
|
||||
|
||||
# 只用一部分样本拟合质心以节省时间(0 = 用全部);纯随机抽取,不按目录分层——
|
||||
# 目录名是人工整理的历史产物,不参与聚类。拟合完仍会把全部样本分配到最近的簇。
|
||||
fit_max_samples: 0
|
||||
|
||||
dataset:
|
||||
name: unlabeled
|
||||
root: data/film
|
||||
gray: false
|
||||
exclude: [images, labels] # 跳过已经整理成 YOLO 结构的部分
|
||||
transforms:
|
||||
- {name: Resize, size: [128, 128]} # 拉伸,不保长宽比:见文件开头的说明
|
||||
- {name: ToTensor}
|
||||
- {name: PerImageStandardize} # 逐图 z-score,抹掉整体亮度/对比度
|
||||
|
||||
loader:
|
||||
batch_size: 64
|
||||
num_workers: 8
|
||||
|
||||
# method=simclr 时每个视图各自过一遍的随机增强(正样本对就是同一张图的两个视图)
|
||||
view_transforms:
|
||||
- {name: RandomResizedCrop, size: [128, 128], scale: [0.5, 1.0]}
|
||||
- {name: RandomHorizontalFlip, p: 0.5}
|
||||
- {name: RandomVerticalFlip, p: 0.5}
|
||||
- {name: ColorJitter, brightness: 0.4, contrast: 0.4}
|
||||
- {name: GaussianBlur, p: 0.3}
|
||||
# 注意:主 transforms 里的 PerImageStandardize 会在这些增强之后执行,
|
||||
# 所以 ColorJitter 的亮度扰动最终会被抵消掉,起作用的主要是裁剪/翻转/模糊。
|
||||
|
||||
simclr:
|
||||
# 自监督用项目自己的纯卷积骨架:练出来的权重可以直接拿去初始化 DefectNet 的 backbone
|
||||
backbone:
|
||||
name: tiny_conv
|
||||
in_channels: 3
|
||||
widths: [32, 64, 128, 256]
|
||||
depths: [1, 2, 2, 2]
|
||||
block: dw
|
||||
feature_stages: null # tiny_conv 不是 nn.Sequential,取最终输出
|
||||
epochs: 100
|
||||
batch_size: 256
|
||||
temperature: 0.2 # 越小越关注难负样本
|
||||
amp: true
|
||||
log_interval: 20
|
||||
checkpoint: null # 填已有的 simclr_backbone.pth 可跳过重复训练
|
||||
projection: {hidden_channels: 256, out_channels: 128}
|
||||
optimizer: {name: adamw, lr: 0.001, weight_decay: 0.0001}
|
||||
scheduler: {name: cosine}
|
||||
|
||||
# 输出:每簇软链多少张图 / 预览拼图放多少张
|
||||
link_limit: 300
|
||||
preview_count: 25
|
||||
@@ -0,0 +1,123 @@
|
||||
"""无标注图像数据集,用于无监督聚类。
|
||||
|
||||
递归扫描一个目录下的所有图片,不需要任何标注。
|
||||
|
||||
样本还会带出形状特征(宽、高、宽高比、面积的对数),但**默认不参与聚类**:
|
||||
这些尺寸是上游 ROI 裁剪的产物,不是缺陷本身的属性,拿它做特征只会让聚类
|
||||
去区分采集方式。保留它只是为了写进 assignments.csv 供人工核对、以及做对照实验。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
from torch.utils.data import Dataset
|
||||
|
||||
from datasets.registry import DATASETS
|
||||
from datasets.transforms import build_transforms
|
||||
from datasets.yolo import IMAGE_SUFFIXES
|
||||
|
||||
SHAPE_FEATURE_DIM = 4
|
||||
|
||||
|
||||
def shape_features(width: int, height: int) -> torch.Tensor:
|
||||
"""[log 宽, log 高, log 宽高比, log 面积],取对数是因为尺寸跨了两个数量级。"""
|
||||
return torch.tensor(
|
||||
[
|
||||
math.log(width),
|
||||
math.log(height),
|
||||
math.log(width / height),
|
||||
math.log(width * height),
|
||||
],
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
@DATASETS.register("unlabeled")
|
||||
class UnlabeledImageDataset(Dataset):
|
||||
"""Args:
|
||||
root: 图片根目录,递归扫描。
|
||||
transforms: transform 配置列表;聚类默认用 Letterbox + ToTensor + Normalize。
|
||||
gray: 是否转单通道(RGBA / 调色板图会先统一到 RGB 或 L)。
|
||||
views: 每个样本返回几个增强视图。1 = 推理取特征;2 = SimCLR 对比学习。
|
||||
view_transforms: `views > 1` 时每个视图各自过一遍的随机增强。
|
||||
exclude: 需要跳过的目录名(默认跳过已划分好的 images/labels 与临时目录)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: str | Path,
|
||||
transforms: Sequence[Mapping[str, Any]] = (),
|
||||
gray: bool = False,
|
||||
views: int = 1,
|
||||
view_transforms: Sequence[Mapping[str, Any]] = (),
|
||||
exclude: Sequence[str] = (),
|
||||
) -> None:
|
||||
self.root = Path(root)
|
||||
if not self.root.is_dir():
|
||||
raise FileNotFoundError(f"目录不存在: {self.root}")
|
||||
self.gray = gray
|
||||
self.views = int(views)
|
||||
self.transforms = build_transforms(transforms)
|
||||
self.view_transforms = build_transforms(view_transforms) if view_transforms else None
|
||||
self.exclude = set(exclude)
|
||||
|
||||
self.images = sorted(
|
||||
path
|
||||
for path in self.root.rglob("*")
|
||||
if path.suffix.lower() in IMAGE_SUFFIXES
|
||||
and not path.name.startswith(".")
|
||||
and not self.exclude & set(path.relative_to(self.root).parts)
|
||||
)
|
||||
if not self.images:
|
||||
raise FileNotFoundError(f"{self.root} 下没有找到图片")
|
||||
|
||||
def source_dir(self, path: Path) -> str:
|
||||
"""图片相对 root 的父目录,用来和聚类结果做交叉表(人工已分好的部分即是弱标签)。"""
|
||||
relative = path.relative_to(self.root).parent
|
||||
return str(relative) if str(relative) != "." else "(root)"
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.images)
|
||||
|
||||
def __getitem__(self, index: int) -> dict:
|
||||
path = self.images[index]
|
||||
with Image.open(path) as raw:
|
||||
image = raw.convert("L" if self.gray else "RGB") # RGBA / P 一并统一掉
|
||||
width, height = image.size
|
||||
|
||||
if self.views == 1:
|
||||
views = [self.transforms({"image": image.copy()})["image"]]
|
||||
else:
|
||||
views = []
|
||||
for _ in range(self.views):
|
||||
sample = {"image": image.copy()}
|
||||
if self.view_transforms is not None:
|
||||
sample = self.view_transforms(sample)
|
||||
views.append(self.transforms(sample)["image"])
|
||||
|
||||
return {
|
||||
"views": views,
|
||||
"shape": shape_features(width, height),
|
||||
"meta": {
|
||||
"path": str(path),
|
||||
"index": index,
|
||||
"source_dir": self.source_dir(path),
|
||||
"size": (width, height),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def unlabeled_collate(batch: list[dict]) -> dict:
|
||||
"""把每个视图各自堆成一个 batch:views[i] 形状 (B, C, H, W)。"""
|
||||
num_views = len(batch[0]["views"])
|
||||
return {
|
||||
"views": [torch.stack([item["views"][i] for item in batch]) for i in range(num_views)],
|
||||
"shape": torch.stack([item["shape"] for item in batch]),
|
||||
"meta": [item["meta"] for item in batch],
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
"""无监督聚类:特征提取 + KMeans。
|
||||
|
||||
用 torch 自己实现 KMeans(kmeans++ 初始化 + Lloyd 迭代),不引 scikit-learn:
|
||||
一是少一个依赖,二是能直接吃 GPU 上的特征张量。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger("engine.cluster")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClusterResult:
|
||||
labels: torch.Tensor # (N,) 每个样本的簇下标
|
||||
centroids: torch.Tensor # (K, D)
|
||||
distances: torch.Tensor # (N,) 到所属质心的距离
|
||||
inertia: float # 簇内平方距离和,越小越紧
|
||||
silhouette: float = float("nan") # 轮廓系数,[-1, 1],越大越好
|
||||
sizes: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
def l2_normalize(x: torch.Tensor, eps: float = 1e-8) -> torch.Tensor:
|
||||
return x / x.norm(dim=1, keepdim=True).clamp(min=eps)
|
||||
|
||||
|
||||
def pca_reduce(x: torch.Tensor, dim: int, whiten: bool = False, eps: float = 1e-5) -> torch.Tensor:
|
||||
"""PCA 降维,`whiten` 决定是否把各主成分方差拉平。
|
||||
|
||||
**白化默认关闭**:CNN 特征里只有前若干个主成分携带结构,其余接近噪声;
|
||||
白化会把这些噪声方向抬到和信号同权,簇随即塌成"一个大簇 + 几个碎簇"。
|
||||
实测 3 个信号维 + 200 个噪声维的合成数据上,dim=3 轮廓 0.93,
|
||||
dim=64 + 白化掉到 0.10 且最大簇占 98%。需要白化时请同时把 dim 调小。
|
||||
"""
|
||||
dim = min(dim, x.shape[0], x.shape[1])
|
||||
centered = x - x.mean(dim=0, keepdim=True)
|
||||
_, singular, components = torch.pca_lowrank(centered, q=dim, center=False)
|
||||
projected = centered @ components[:, :dim]
|
||||
if not whiten:
|
||||
return projected
|
||||
return projected / (singular[:dim] / (x.shape[0] ** 0.5) + eps)
|
||||
|
||||
|
||||
def kmeans_plusplus(x: torch.Tensor, k: int, generator: torch.Generator) -> torch.Tensor:
|
||||
"""kmeans++ 初始化:按到已选质心距离的平方为概率挑下一个,避免初值退化。"""
|
||||
n = x.shape[0]
|
||||
centroids = [x[torch.randint(n, (1,), generator=generator, device=x.device).item()]]
|
||||
closest = torch.cdist(x, centroids[0][None]).squeeze(1) ** 2
|
||||
for _ in range(1, k):
|
||||
probs = closest / closest.sum().clamp(min=1e-12)
|
||||
index = torch.multinomial(probs, 1, generator=generator).item()
|
||||
centroids.append(x[index])
|
||||
closest = torch.minimum(closest, torch.cdist(x, centroids[-1][None]).squeeze(1) ** 2)
|
||||
return torch.stack(centroids)
|
||||
|
||||
|
||||
def kmeans(
|
||||
x: torch.Tensor,
|
||||
num_clusters: int,
|
||||
iters: int = 100,
|
||||
n_init: int = 10,
|
||||
tol: float = 1e-5,
|
||||
seed: int = 42,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, float]:
|
||||
"""跑 `n_init` 次取 inertia 最小的一次,返回 (labels, centroids, inertia)。"""
|
||||
if num_clusters < 2:
|
||||
raise ValueError(f"num_clusters 至少为 2,收到 {num_clusters}")
|
||||
if x.shape[0] < num_clusters:
|
||||
raise ValueError(f"样本数 {x.shape[0]} 少于簇数 {num_clusters}")
|
||||
|
||||
best: tuple[torch.Tensor, torch.Tensor, float] | None = None
|
||||
for run in range(n_init):
|
||||
generator = torch.Generator(device=x.device).manual_seed(seed + run)
|
||||
centroids = kmeans_plusplus(x, num_clusters, generator)
|
||||
previous = float("inf")
|
||||
|
||||
for _ in range(iters):
|
||||
distances = torch.cdist(x, centroids)
|
||||
labels = distances.argmin(dim=1)
|
||||
inertia = float((distances.gather(1, labels[:, None]) ** 2).sum())
|
||||
|
||||
for index in range(num_clusters):
|
||||
members = x[labels == index]
|
||||
if len(members):
|
||||
centroids[index] = members.mean(dim=0)
|
||||
else:
|
||||
# 空簇:把离自己质心最远的点抢过来,避免簇数缩水
|
||||
centroids[index] = x[distances.gather(1, labels[:, None]).squeeze(1).argmax()]
|
||||
if abs(previous - inertia) <= tol * max(previous, 1.0):
|
||||
break
|
||||
previous = inertia
|
||||
|
||||
if best is None or inertia < best[2]:
|
||||
best = (labels, centroids.clone(), inertia)
|
||||
|
||||
assert best is not None
|
||||
return best
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def silhouette_score(
|
||||
x: torch.Tensor, labels: torch.Tensor, max_samples: int = 2000, seed: int = 42
|
||||
) -> float:
|
||||
"""轮廓系数。是 O(N²) 的,所以超过 `max_samples` 就抽样估计。"""
|
||||
n = x.shape[0]
|
||||
if labels.unique().numel() < 2:
|
||||
return float("nan")
|
||||
if n > max_samples:
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
index = torch.randperm(n, generator=generator)[:max_samples].to(x.device)
|
||||
x, labels = x[index], labels[index]
|
||||
|
||||
distances = torch.cdist(x, x)
|
||||
scores = []
|
||||
for index in range(x.shape[0]):
|
||||
same = labels == labels[index]
|
||||
same[index] = False
|
||||
if not same.any():
|
||||
continue # 单点簇按惯例记 0
|
||||
a = distances[index][same].mean()
|
||||
b = min(
|
||||
distances[index][labels == other].mean()
|
||||
for other in labels.unique().tolist()
|
||||
if other != labels[index].item()
|
||||
)
|
||||
scores.append(((b - a) / torch.maximum(a, b)).item())
|
||||
return float(sum(scores) / len(scores)) if scores else float("nan")
|
||||
|
||||
|
||||
class StageFeatureExtractor(nn.Module):
|
||||
"""从卷积骨架的多个 stage 取特征,各自全局池化 + L2 归一化后拼接。
|
||||
|
||||
只用最后一层的语义特征对薄膜缺陷不够用——缺陷之间的差异很大程度是**纹理**层面的
|
||||
(颗粒感、条纹方向、边缘锐度),这些信息在中间层比在最后一层保留得好。
|
||||
"""
|
||||
|
||||
def __init__(self, backbone: nn.Module, stages: Sequence[int] | None = None) -> None:
|
||||
super().__init__()
|
||||
self.backbone = backbone
|
||||
layers = getattr(backbone, "features", backbone)
|
||||
self.layers = list(layers) if isinstance(layers, nn.Sequential) else None
|
||||
self.stages = sorted(stages) if stages else None
|
||||
if self.stages and self.layers is None:
|
||||
raise ValueError("该骨架不是 nn.Sequential,无法按 stage 取中间特征")
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
if not self.stages:
|
||||
feat = self.backbone(x)
|
||||
return feat.mean(dim=(2, 3)) if feat.dim() == 4 else feat
|
||||
|
||||
outputs = []
|
||||
for index, layer in enumerate(self.layers):
|
||||
x = layer(x)
|
||||
if index in self.stages:
|
||||
outputs.append(l2_normalize(x.mean(dim=(2, 3))))
|
||||
if not outputs:
|
||||
raise ValueError(f"stages {self.stages} 超出骨架层数 {len(self.layers)}")
|
||||
return torch.cat(outputs, dim=1)
|
||||
|
||||
|
||||
def normalized_mutual_info(labels_a: Sequence, labels_b: Sequence) -> float:
|
||||
"""两组划分的归一化互信息,[0, 1]。这里用来比较**两次聚类结果**的一致性。"""
|
||||
from collections import Counter
|
||||
|
||||
n = len(labels_a)
|
||||
if n == 0:
|
||||
return float("nan")
|
||||
count_a, count_b = Counter(labels_a), Counter(labels_b)
|
||||
joint = Counter(zip(labels_a, labels_b, strict=True))
|
||||
|
||||
def entropy(counter: Counter) -> float:
|
||||
return -sum((c / n) * math.log(c / n) for c in counter.values() if c)
|
||||
|
||||
mutual = sum(
|
||||
(c / n) * math.log((c / n) / ((count_a[a] / n) * (count_b[b] / n)))
|
||||
for (a, b), c in joint.items()
|
||||
if c
|
||||
)
|
||||
denominator = (entropy(count_a) * entropy(count_b)) ** 0.5
|
||||
return float(mutual / denominator) if denominator > 0 else float("nan")
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def extract_features(
|
||||
encoder: nn.Module,
|
||||
dataloader: DataLoader,
|
||||
device: torch.device,
|
||||
use_shape_features: bool = False,
|
||||
log_interval: int = 20,
|
||||
) -> tuple[torch.Tensor, list[dict]]:
|
||||
"""跑一遍数据集拿 embedding。
|
||||
|
||||
`encoder` 输出特征图,这里做全局平均池化。
|
||||
|
||||
`use_shape_features` 默认关闭:图片的宽高比/尺寸是 ROI 裁剪环节的产物,
|
||||
不是缺陷属性,拼进 embedding 只会让聚类去区分"采集方式"而不是缺陷类型。
|
||||
留着这个开关只为做对照实验。
|
||||
"""
|
||||
encoder.eval()
|
||||
features: list[torch.Tensor] = []
|
||||
shapes: list[torch.Tensor] = []
|
||||
metas: list[dict] = []
|
||||
|
||||
for step, batch in enumerate(dataloader):
|
||||
images = batch["views"][0].to(device, non_blocking=True)
|
||||
feat = encoder(images)
|
||||
if feat.dim() == 4:
|
||||
feat = feat.mean(dim=(2, 3))
|
||||
features.append(feat.float().cpu())
|
||||
shapes.append(batch["shape"])
|
||||
metas.extend(batch["meta"])
|
||||
if step % log_interval == 0:
|
||||
logger.info("提取特征 [%d/%d]", step, len(dataloader))
|
||||
|
||||
embeddings = torch.cat(features)
|
||||
if use_shape_features:
|
||||
shape = torch.cat(shapes)
|
||||
shape = (shape - shape.mean(dim=0)) / shape.std(dim=0).clamp(min=1e-6)
|
||||
# 图像特征先归一化,避免两部分量纲差太多导致形状特征被淹没
|
||||
embeddings = torch.cat([l2_normalize(embeddings), shape], dim=1)
|
||||
return embeddings, metas
|
||||
|
||||
|
||||
def cluster_features(
|
||||
embeddings: torch.Tensor,
|
||||
num_clusters: int,
|
||||
pca_dim: int = 32,
|
||||
whiten: bool = False,
|
||||
normalize: bool = True,
|
||||
n_init: int = 10,
|
||||
iters: int = 100,
|
||||
seed: int = 42,
|
||||
fit_index: torch.Tensor | None = None,
|
||||
) -> ClusterResult:
|
||||
"""L2 归一化 -> PCA 降维 -> KMeans -> 指标。
|
||||
|
||||
`fit_index` 给定时只用这部分样本拟合质心(数据极不均衡时用来做类别平衡采样),
|
||||
再把全部样本按最近质心分配。
|
||||
"""
|
||||
x = l2_normalize(embeddings) if normalize else embeddings
|
||||
if pca_dim:
|
||||
x = pca_reduce(x, pca_dim, whiten=whiten)
|
||||
|
||||
fit_x = x if fit_index is None else x[fit_index]
|
||||
_, centroids, _ = kmeans(fit_x, num_clusters, iters=iters, n_init=n_init, seed=seed)
|
||||
|
||||
distances = torch.cdist(x, centroids)
|
||||
labels = distances.argmin(dim=1)
|
||||
nearest = distances.gather(1, labels[:, None]).squeeze(1)
|
||||
|
||||
return ClusterResult(
|
||||
labels=labels,
|
||||
centroids=centroids,
|
||||
distances=nearest,
|
||||
inertia=float((nearest**2).sum()),
|
||||
silhouette=silhouette_score(x, labels, seed=seed),
|
||||
sizes=torch.bincount(labels, minlength=num_clusters).tolist(),
|
||||
)
|
||||
|
||||
|
||||
def random_fit_index(num_samples: int, max_samples: int, seed: int = 42) -> torch.Tensor | None:
|
||||
"""随机抽一部分样本用于拟合质心,纯粹为了省时间(拟合完仍会分配全部样本)。
|
||||
|
||||
不做任何按来源目录的分层——目录名是人工整理的历史产物,不应该影响聚类。
|
||||
"""
|
||||
if not max_samples or num_samples <= max_samples:
|
||||
return None
|
||||
generator = torch.Generator().manual_seed(seed)
|
||||
return torch.randperm(num_samples, generator=generator)[:max_samples].sort().values
|
||||
|
||||
|
||||
def cluster_stability(
|
||||
embeddings: torch.Tensor, num_clusters: int, seeds: Sequence[int] = (0, 1), **kwargs
|
||||
) -> float:
|
||||
"""换随机种子重跑,看两次划分的 NMI。
|
||||
|
||||
无标签场景下判断簇数是否合理的实用指标:真有结构时不同初始化会收敛到同一套划分,
|
||||
强行切分一团连续数据时结果会随种子乱跳。
|
||||
"""
|
||||
runs = [
|
||||
cluster_features(embeddings, num_clusters, seed=seed, **kwargs).labels.tolist()
|
||||
for seed in seeds
|
||||
]
|
||||
return normalized_mutual_info(runs[0], runs[1])
|
||||
@@ -0,0 +1,125 @@
|
||||
"""SimCLR 自监督预训练:在无标注的薄膜图上学一套特征,供聚类使用。
|
||||
|
||||
骨架仍然是项目里的纯卷积 backbone(`tiny_conv` 等),预训练完可以直接拿去
|
||||
初始化检测模型。属于"训练",因此调用方必须先过 `utils.device.require_cuda()`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
from torch.nn import functional as F
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
from engine.metrics import AverageMeter
|
||||
from engine.optim import build_optimizer, build_scheduler
|
||||
from models.heads.projection import ProjectionHead
|
||||
from utils.logging import get_logger
|
||||
|
||||
logger = get_logger("engine.simclr")
|
||||
|
||||
|
||||
def nt_xent_loss(z1: torch.Tensor, z2: torch.Tensor, temperature: float = 0.2) -> torch.Tensor:
|
||||
"""NT-Xent:同一张图的两个视图互为正样本,batch 内其余 2N-2 个都是负样本。"""
|
||||
batch_size = z1.shape[0]
|
||||
z = F.normalize(torch.cat([z1, z2]), dim=1)
|
||||
similarity = z @ z.t() / temperature
|
||||
similarity.fill_diagonal_(float("-inf")) # 排除自己和自己
|
||||
|
||||
targets = torch.cat(
|
||||
[
|
||||
torch.arange(batch_size, 2 * batch_size, device=z.device),
|
||||
torch.arange(0, batch_size, device=z.device),
|
||||
]
|
||||
)
|
||||
return F.cross_entropy(similarity, targets)
|
||||
|
||||
|
||||
class SimCLRTrainer:
|
||||
"""Args:
|
||||
backbone: 输出特征图的卷积骨架(需带 `out_channels`)。
|
||||
dataloader: 每个样本产出 2 个视图的 DataLoader(`UnlabeledImageDataset(views=2)`)。
|
||||
cfg: `epochs / temperature / optimizer / scheduler / amp / log_interval` 等。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
backbone: nn.Module,
|
||||
dataloader: DataLoader,
|
||||
cfg: Mapping[str, Any],
|
||||
device: torch.device,
|
||||
work_dir: str | Path,
|
||||
) -> None:
|
||||
self.backbone = backbone
|
||||
self.dataloader = dataloader
|
||||
self.cfg = dict(cfg)
|
||||
self.device = device
|
||||
self.work_dir = Path(work_dir)
|
||||
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.epochs = int(self.cfg.get("epochs", 50))
|
||||
self.temperature = float(self.cfg.get("temperature", 0.2))
|
||||
self.log_interval = int(self.cfg.get("log_interval", 20))
|
||||
|
||||
self.projector = ProjectionHead(
|
||||
backbone.out_channels, **dict(self.cfg.get("projection", {}))
|
||||
).to(device)
|
||||
self.model = nn.ModuleDict({"backbone": backbone, "projector": self.projector})
|
||||
self.optimizer = build_optimizer(
|
||||
self.model, self.cfg.get("optimizer", {"name": "adamw", "lr": 1e-3, "weight_decay": 1e-4})
|
||||
)
|
||||
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), self.epochs)
|
||||
self.amp = bool(self.cfg.get("amp", True)) and device.type == "cuda"
|
||||
self.scaler = torch.amp.GradScaler("cuda", enabled=self.amp)
|
||||
|
||||
def train_one_epoch(self, epoch: int) -> float:
|
||||
self.model.train()
|
||||
meter = AverageMeter()
|
||||
start = time.time()
|
||||
|
||||
for step, batch in enumerate(self.dataloader):
|
||||
view1, view2 = (view.to(self.device, non_blocking=True) for view in batch["views"][:2])
|
||||
|
||||
with torch.amp.autocast("cuda", enabled=self.amp):
|
||||
z1 = self.projector(self.backbone(view1))
|
||||
z2 = self.projector(self.backbone(view2))
|
||||
loss = nt_xent_loss(z1, z2, self.temperature)
|
||||
|
||||
self.optimizer.zero_grad(set_to_none=True)
|
||||
self.scaler.scale(loss).backward()
|
||||
self.scaler.step(self.optimizer)
|
||||
self.scaler.update()
|
||||
|
||||
meter.update(float(loss.detach()), view1.shape[0])
|
||||
if step % self.log_interval == 0:
|
||||
logger.info(
|
||||
"simclr epoch %d [%d/%d] loss=%.4f lr=%.2e",
|
||||
epoch,
|
||||
step,
|
||||
len(self.dataloader),
|
||||
meter.avg,
|
||||
self.optimizer.param_groups[0]["lr"],
|
||||
)
|
||||
|
||||
logger.info("simclr epoch %d 完成 loss=%.4f 耗时 %.1fs", epoch, meter.avg, time.time() - start)
|
||||
return meter.avg
|
||||
|
||||
def fit(self) -> Path:
|
||||
"""训练并保存骨架权重(不含投影头),返回权重路径。"""
|
||||
if len(self.dataloader) < 2:
|
||||
raise ValueError("SimCLR 需要多个 batch,请调小 batch_size 或增加样本")
|
||||
|
||||
for epoch in range(self.epochs):
|
||||
self.train_one_epoch(epoch)
|
||||
if self.scheduler is not None:
|
||||
self.scheduler.step()
|
||||
|
||||
path = self.work_dir / "simclr_backbone.pth"
|
||||
torch.save({"model": self.backbone.state_dict(), "epochs": self.epochs}, path)
|
||||
logger.info("自监督骨架已保存: %s", path)
|
||||
return path
|
||||
@@ -0,0 +1,30 @@
|
||||
"""SimCLR 的投影头。
|
||||
|
||||
对比损失只作用在投影后的空间;聚类时**丢掉投影头、用骨架特征**——
|
||||
这是 SimCLR 的既有结论:投影头会丢掉对下游任务有用但对比任务用不上的信息。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from models.registry import HEADS
|
||||
|
||||
|
||||
@HEADS.register("projection")
|
||||
class ProjectionHead(nn.Module):
|
||||
def __init__(self, in_channels: int, hidden_channels: int = 256, out_channels: int = 128) -> None:
|
||||
super().__init__()
|
||||
self.pool = nn.AdaptiveAvgPool2d(1)
|
||||
self.mlp = nn.Sequential(
|
||||
nn.Linear(in_channels, hidden_channels),
|
||||
nn.BatchNorm1d(hidden_channels),
|
||||
nn.ReLU(inplace=True),
|
||||
nn.Linear(hidden_channels, out_channels),
|
||||
)
|
||||
|
||||
def forward(self, feat: torch.Tensor) -> torch.Tensor:
|
||||
if feat.dim() == 4:
|
||||
feat = self.pool(feat).flatten(1)
|
||||
return self.mlp(feat)
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python
|
||||
"""把未分类的样本做无监督聚类,产出可人工核对的分组结果。
|
||||
|
||||
# 默认 4 类,ImageNet 预训练特征(不训练,CPU 可跑)
|
||||
uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml
|
||||
|
||||
# 改簇数 / 换成在本地数据上自监督训练的特征(需要 Nvidia GPU)
|
||||
uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml \
|
||||
--set num_clusters=6 method=simclr
|
||||
|
||||
产出(work_dir 下):
|
||||
assignments.csv 每张图的簇号、到质心距离、原目录
|
||||
summary.json 簇大小、轮廓系数、簇 × 原目录交叉表
|
||||
clusters/cluster_00/... 按簇建的软链目录,直接用看图软件翻
|
||||
cluster_00_preview.png 每簇代表样本(离质心最近)的拼图
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import torch # noqa: E402
|
||||
|
||||
from datasets.build import build_dataset # noqa: E402
|
||||
from datasets.unlabeled import unlabeled_collate # noqa: E402
|
||||
from engine.cluster import ( # noqa: E402
|
||||
StageFeatureExtractor,
|
||||
cluster_features,
|
||||
cluster_stability,
|
||||
extract_features,
|
||||
random_fit_index,
|
||||
)
|
||||
from models.registry import BACKBONES # noqa: E402
|
||||
from utils import load_config, seed_everything, setup_logging # noqa: E402
|
||||
from utils.device import NoNvidiaGpuError, require_cuda, resolve_device # noqa: E402
|
||||
|
||||
logger = None
|
||||
|
||||
|
||||
def build_encoder(cfg, device, work_dir):
|
||||
"""按 `method` 拿一个特征提取器。
|
||||
|
||||
pretrained: ImageNet 预训练卷积骨架,纯推理,CPU 也能跑。用顶层的 `backbone`。
|
||||
simclr: 在本目录数据上自监督训练一个项目自己的卷积骨架——属于训练,先过 GPU 守卫。
|
||||
用 `simclr.backbone`,与 pretrained 的骨架配置各自独立:两者的字段完全不同
|
||||
(arch/pretrained vs widths/depths),共用一个块会在 `_base_` 合并时串味。
|
||||
"""
|
||||
method = str(cfg.get("method", "pretrained"))
|
||||
|
||||
if method == "pretrained":
|
||||
backbone = BACKBONES.build(dict(cfg.get("backbone", {"name": "torchvision"})))
|
||||
return StageFeatureExtractor(backbone, cfg.get("feature_stages")).to(device).eval()
|
||||
|
||||
if method != "simclr":
|
||||
raise ValueError(f"未知 method: {method},可选 pretrained / simclr")
|
||||
|
||||
require_cuda() # 自监督预训练是训练,禁止在无 Nvidia GPU 的设备上跑
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
from engine.simclr import SimCLRTrainer
|
||||
|
||||
simclr_cfg = dict(cfg.get("simclr", {}))
|
||||
backbone_cfg = dict(simclr_cfg.pop("backbone", {"name": "tiny_conv"}))
|
||||
# 自研骨架不是 nn.Sequential,默认取最终输出而不是中间 stage
|
||||
stages = simclr_cfg.pop("feature_stages", None)
|
||||
backbone = BACKBONES.build(backbone_cfg).to(device)
|
||||
|
||||
checkpoint = simclr_cfg.pop("checkpoint", None)
|
||||
if checkpoint and Path(checkpoint).exists():
|
||||
state = torch.load(checkpoint, map_location=device, weights_only=False)
|
||||
backbone.load_state_dict(state["model"])
|
||||
logger.info("复用已有的自监督骨架: %s", checkpoint)
|
||||
return StageFeatureExtractor(backbone, stages).to(device).eval()
|
||||
|
||||
view_dataset = build_dataset(
|
||||
{**dict(cfg["dataset"]), "views": 2, "view_transforms": cfg.get("view_transforms", [])}
|
||||
)
|
||||
loader = DataLoader(
|
||||
view_dataset,
|
||||
batch_size=int(simclr_cfg.pop("batch_size", 128)),
|
||||
shuffle=True,
|
||||
drop_last=True,
|
||||
num_workers=int(cfg.get("loader", {}).get("num_workers", 4)),
|
||||
collate_fn=unlabeled_collate,
|
||||
)
|
||||
SimCLRTrainer(backbone, loader, simclr_cfg, device, work_dir).fit()
|
||||
return StageFeatureExtractor(backbone, stages).to(device).eval()
|
||||
|
||||
|
||||
def write_outputs(cfg, work_dir, metas, result, num_clusters, stability):
|
||||
"""CSV + JSON + 软链目录 + 预览拼图。人工只需要翻软链目录就能确认分组是否合理。"""
|
||||
labels = result.labels.tolist()
|
||||
distances = result.distances.tolist()
|
||||
|
||||
with (work_dir / "assignments.csv").open("w", newline="", encoding="utf-8") as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(["path", "cluster", "distance_to_centroid", "source_dir", "width", "height"])
|
||||
for meta, label, distance in zip(metas, labels, distances, strict=True):
|
||||
width, height = meta["size"]
|
||||
writer.writerow([meta["path"], label, f"{distance:.6f}", meta["source_dir"], width, height])
|
||||
|
||||
summary = {
|
||||
"num_clusters": num_clusters,
|
||||
"num_images": len(metas),
|
||||
"method": cfg.get("method", "pretrained"),
|
||||
"image_size": cfg.get("image_size"),
|
||||
"cluster_sizes": result.sizes,
|
||||
"silhouette": result.silhouette,
|
||||
"stability": stability,
|
||||
"inertia": result.inertia,
|
||||
"mean_distance_per_cluster": [
|
||||
float(result.distances[result.labels == index].mean()) if size else None
|
||||
for index, size in enumerate(result.sizes)
|
||||
],
|
||||
}
|
||||
(work_dir / "summary.json").write_text(
|
||||
json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
link_limit = int(cfg.get("link_limit", 300))
|
||||
preview_count = int(cfg.get("preview_count", 25))
|
||||
clusters_dir = work_dir / "clusters"
|
||||
if clusters_dir.exists():
|
||||
shutil.rmtree(clusters_dir)
|
||||
|
||||
order = sorted(range(len(metas)), key=lambda i: distances[i]) # 离质心近的排前面
|
||||
per_cluster: dict[int, list[int]] = defaultdict(list)
|
||||
for index in order:
|
||||
per_cluster[labels[index]].append(index)
|
||||
|
||||
for cluster, indices in sorted(per_cluster.items()):
|
||||
target = clusters_dir / f"cluster_{cluster:02d}"
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
# 近的取大头,末尾补几张最远的——离群样本最能暴露这个簇是不是混了东西
|
||||
chosen = indices[: max(link_limit - 20, 1)] + indices[-20:] if len(indices) > link_limit else indices
|
||||
for rank, index in enumerate(chosen):
|
||||
source = Path(metas[index]["path"]).resolve()
|
||||
link = target / f"{rank:04d}_{source.stem}{source.suffix}"
|
||||
if not link.exists():
|
||||
link.symlink_to(source)
|
||||
save_preview(work_dir / f"cluster_{cluster:02d}_preview.png", metas, indices[:preview_count])
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def save_preview(path: Path, metas: list[dict], indices: list[int], cell: int = 96, columns: int = 5):
|
||||
"""把代表样本拼成一张图,方便一眼看出这个簇是什么缺陷。"""
|
||||
if not indices:
|
||||
return
|
||||
from PIL import Image
|
||||
|
||||
rows = (len(indices) + columns - 1) // columns
|
||||
canvas = Image.new("RGB", (columns * cell, rows * cell), (32, 32, 32))
|
||||
for position, index in enumerate(indices):
|
||||
with Image.open(metas[index]["path"]) as raw:
|
||||
image = raw.convert("RGB")
|
||||
scale = min(cell / image.width, cell / image.height)
|
||||
image = image.resize((max(1, round(image.width * scale)), max(1, round(image.height * scale))))
|
||||
x = (position % columns) * cell + (cell - image.width) // 2
|
||||
y = (position // columns) * cell + (cell - image.height) // 2
|
||||
canvas.paste(image, (x, y))
|
||||
canvas.save(path)
|
||||
|
||||
|
||||
def sweep_clusters(embeddings, spec: str, cluster_kwargs: dict) -> None:
|
||||
"""扫一遍簇数,同时看轮廓系数和换种子的稳定性。
|
||||
|
||||
无标签时没有"正确答案",只能看:簇是否紧致(轮廓),以及换初始化后划分是否还一样
|
||||
(稳定性)。稳定性低说明那个 k 是在硬切一团连续的数据。
|
||||
"""
|
||||
start, end = (int(v) for v in spec.split("-"))
|
||||
logger.info("%3s %9s %10s %12s 簇大小", "k", "轮廓", "稳定性", "最大簇占比")
|
||||
for k in range(start, end + 1):
|
||||
result = cluster_features(embeddings, k, seed=42, **cluster_kwargs)
|
||||
stability = cluster_stability(embeddings, k, seeds=(0, 7), **cluster_kwargs)
|
||||
logger.info(
|
||||
"%3d %9.3f %10.3f %12.2f %s",
|
||||
k,
|
||||
result.silhouette,
|
||||
stability,
|
||||
max(result.sizes) / sum(result.sizes),
|
||||
result.sizes,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
global logger
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument("--config", default="configs/cluster/film.yaml")
|
||||
parser.add_argument("--root", default=None, help="覆盖配置里的图片根目录")
|
||||
parser.add_argument("--num-clusters", type=int, default=None, help="覆盖配置里的簇数")
|
||||
parser.add_argument("--work-dir", default=None)
|
||||
parser.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE")
|
||||
parser.add_argument("--save-features", action="store_true", help="额外保存 embedding 供复用")
|
||||
parser.add_argument(
|
||||
"--sweep-k", default=None, metavar="A-B", help="只扫簇数(如 2-8)打印指标表,不产出分组"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = load_config(args.config, args.set)
|
||||
if args.root:
|
||||
cfg["dataset"]["root"] = args.root
|
||||
if args.num_clusters:
|
||||
cfg["num_clusters"] = args.num_clusters
|
||||
|
||||
work_dir = Path(args.work_dir or cfg.get("work_dir", "runs/cluster"))
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger = setup_logging(work_dir / "cluster.log")
|
||||
seed_everything(int(cfg.get("seed", 42)))
|
||||
|
||||
num_clusters = int(cfg.get("num_clusters", 4))
|
||||
try:
|
||||
device = resolve_device(str(cfg.get("device", "cuda")))
|
||||
except NoNvidiaGpuError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
|
||||
dataset = build_dataset(dict(cfg["dataset"]))
|
||||
logger.info(
|
||||
"扫描到 %d 张图,来自 %d 个目录;簇数 %d,method=%s,device=%s",
|
||||
len(dataset),
|
||||
len({dataset.source_dir(path) for path in dataset.images}),
|
||||
num_clusters,
|
||||
cfg.get("method", "pretrained"),
|
||||
device,
|
||||
)
|
||||
|
||||
try:
|
||||
encoder = build_encoder(cfg, device, work_dir)
|
||||
except NoNvidiaGpuError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
|
||||
from torch.utils.data import DataLoader
|
||||
|
||||
loader = DataLoader(
|
||||
dataset,
|
||||
batch_size=int(cfg.get("loader", {}).get("batch_size", 64)),
|
||||
shuffle=False,
|
||||
num_workers=int(cfg.get("loader", {}).get("num_workers", 4)),
|
||||
collate_fn=unlabeled_collate,
|
||||
)
|
||||
embeddings, metas = extract_features(
|
||||
encoder, loader, device, use_shape_features=bool(cfg.get("use_shape_features", False))
|
||||
)
|
||||
logger.info("embedding: %s", tuple(embeddings.shape))
|
||||
|
||||
cluster_kwargs = {
|
||||
"pca_dim": int(cfg.get("pca_dim", 32)),
|
||||
"whiten": bool(cfg.get("whiten", False)),
|
||||
"n_init": int(cfg.get("n_init", 10)),
|
||||
}
|
||||
fit_index = random_fit_index(
|
||||
len(metas), int(cfg.get("fit_max_samples", 0)), seed=int(cfg.get("seed", 42))
|
||||
)
|
||||
if fit_index is not None:
|
||||
logger.info("随机抽 %d 个样本拟合质心(拟合后仍分配全部样本)", len(fit_index))
|
||||
|
||||
if args.sweep_k:
|
||||
sweep_clusters(embeddings, args.sweep_k, cluster_kwargs)
|
||||
return 0
|
||||
|
||||
result = cluster_features(
|
||||
embeddings, num_clusters, seed=int(cfg.get("seed", 42)), fit_index=fit_index, **cluster_kwargs
|
||||
)
|
||||
stability = cluster_stability(embeddings, num_clusters, seeds=(0, 7), **cluster_kwargs)
|
||||
logger.info(
|
||||
"簇大小: %s | 轮廓系数: %.4f | 换种子稳定性: %.4f",
|
||||
result.sizes,
|
||||
result.silhouette,
|
||||
stability,
|
||||
)
|
||||
|
||||
if args.save_features:
|
||||
torch.save({"embeddings": embeddings, "metas": metas}, work_dir / "features.pt")
|
||||
|
||||
write_outputs(cfg, work_dir, metas, result, num_clusters, stability)
|
||||
logger.info("结果已写入 %s(clusters/ 下是按簇建的软链目录,直接翻图即可核对)", work_dir)
|
||||
for index, size in enumerate(result.sizes):
|
||||
mean_distance = float(result.distances[result.labels == index].mean()) if size else float("nan")
|
||||
logger.info(" cluster_%02d %6d 张 平均距质心 %.3f", index, size, mean_distance)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user