feat: 新增缺陷分类训练与评估链路

在聚类 + 人工归类得到的数据集(data/<集>/<类别名>/*.png)上训练分类模型。

- datasets/classification.py:按目录组织的分类数据集,**逐类分层**切分 train/val
  (others 只有 65 张,整体随机切很容易让它在验证集里挂零),固定种子可复现
- models/classifier.py:卷积骨架 + 分类头,与 DefectNet 共用 backbone 注册表,
  因此自监督产出的 simclr_backbone.pth 可用 init_from 直接初始化;
  frozen_backbone 可做线性探针
- engine/classification.py:训练循环 + 指标。主指标用 macro-F1 而非准确率——
  数据从 normal 2679 张到 others 65 张差 41 倍,全预测成 normal 也有 24% 准确率;
  损失按类别频率加权,避免小类被淹没;输出逐类 P/R/F1 与混淆矩阵
- classify.py:训练/评估入口,--eval-only 可单独评估并导出 eval_report.json
- configs/classify/film.yaml:预处理沿用聚类阶段的结论(拉伸 + 逐图标准化)。
  抹掉亮度在这里尤其重要:有几类几乎只来自单一原始数据集,不抹掉的话模型可能
  靠"认出这是哪个数据集"作弊
- 续训语义与检测/自监督一致:检查点只提供进度,超参以配置为准

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shaun Zhou
2026-09-10 14:58:41 +08:00
co-authored by Claude Opus 5
parent 33d28dcc7c
commit 33fdf396ee
10 changed files with 1085 additions and 1 deletions
+48 -1
View File
@@ -18,7 +18,7 @@ convolution 的结构(不含 attention / transformer),并支持导出 ONNX
| `docs/` | 算法与数学说明文档 | | `docs/` | 算法与数学说明文档 |
| `figures/` | 文档用的 mermaid 图(`.mmd` 源 + `.md` 预览 + `.png`) | | `figures/` | 文档用的 mermaid 图(`.mmd` 源 + `.md` 预览 + `.png`) |
入口在仓库根目录:[train.py](train.py)、[eval.py](eval.py)。 入口在仓库根目录:[train.py](train.py)(检测+定位训练)、[eval.py](eval.py)、[classify.py](classify.py)(分类训练与评估)。
## 环境 ## 环境
@@ -585,6 +585,53 @@ uv run swanlab watch runs/simclr/swanlog
训练出的骨架和检测模型用的是同一套卷积骨架,可以直接拿去初始化 `DefectNet` 的 backbone—— 训练出的骨架和检测模型用的是同一套卷积骨架,可以直接拿去初始化 `DefectNet` 的 backbone——
标注量有限时,通常比 ImageNet 权重更贴合薄膜的成像特性。 标注量有限时,通常比 ImageNet 权重更贴合薄膜的成像特性。
## 缺陷分类模型
在聚类 + 人工归类得到的数据集上训练分类模型(`data/<数据集>/<类别名>/*.png`):
```bash
uv run python classify.py --config configs/classify/film.yaml
# 只评估
uv run python classify.py --config configs/classify/film.yaml --eval-only \
--checkpoint runs/classify/best.pth
```
产物在 `work_dir`:`best.pth` / `last.pth` / `config.yaml` / `classify.log` /
`eval_report.json`(逐类 P/R/F1 与完整混淆矩阵)。
### 三个关键取舍
**主指标是 macro-F1,不是准确率。** 数据从 `normal` 2679 张到 `others` 65 张差 41 倍,
全预测成 normal 也能拿 24% 准确率。配置里同时给出 `balanced_accuracy`(各类召回的均值),
两者一起看才知道小类有没有学会。
**损失按类别频率加权**(`class_weight: balanced`,逆频率归一化到均值 1),
否则占 24% 的 normal 会主导梯度。不需要时设 `none`。
**逐图标准化抹掉整体亮度。** 15 个类别里有几类几乎只来自单一原始数据集
(`ripple` 几乎全是 pearlescent,`grease`/`zigzag` 全是 film),不抹掉亮度的话模型
可能靠"认出这是哪个数据集"就把类别猜对——验证集上分数很高,换批数据就崩。
### 划分
没有预先的 train/val,按**类别分层**切分(默认 8:2,固定种子)。逐类切分是必须的:
`others` 只有 65 张,整体随机切很容易让它在验证集里一张不剩。
### 用自监督骨架初始化
前面自监督训练出的骨架可以直接拿来初始化——这正是聚类阶段产物的下游用途:
```yaml
model:
backbone: {name: torchvision, arch: resnet101} # 必须与自监督时一致
init_from: runs/simclr_r101/simclr_backbone.pth
frozen_backbone: false # true = 冻结骨架只训分类头(线性探针)
```
只加载 backbone,分类头仍随机初始化。`frozen_backbone: true` 可以单独衡量
自监督特征本身有多好。
## 方法 ## 方法
- **骨架**:`tiny_conv`(自研深度可分离卷积,默认)或 `torchvision`(resnet / mobilenet / shufflenet)。 - **骨架**:`tiny_conv`(自研深度可分离卷积,默认)或 `torchvision`(resnet / mobilenet / shufflenet)。
Executable
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python
"""缺陷分类模型的训练与评估。
uv run python classify.py --config configs/classify/film.yaml
uv run python classify.py --config configs/classify/film.yaml --eval-only \
--checkpoint runs/classify/best.pth
数据是 scripts/export_clusters.py 的产物:`root/<类别名>/*.png`。
没有预先划分 train/val,按类别分层切分(固定种子,两次运行划分一致)。
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from datasets import FolderClassificationDataset, classification_collate
from engine import ClassificationTrainer
from models import build_model
from utils import load_config, seed_everything, setup_logging
from utils.device import DeviceNotAvailableError, describe_device, resolve_device
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="薄膜缺陷分类模型训练/评估")
parser.add_argument("--config", required=True, help="yaml 配置路径")
parser.add_argument("--set", nargs="*", default=[], metavar="KEY=VALUE", help="覆盖配置项")
parser.add_argument("--work-dir", default=None)
parser.add_argument("--device", default=None, help="auto / cuda / mps / cpu")
parser.add_argument("--resume", default=None, help="从检查点续训")
parser.add_argument("--eval-only", action="store_true", help="只评估,不训练")
parser.add_argument("--checkpoint", default=None, help="评估用的权重")
parser.add_argument("--report", default=None, help="把逐类指标与混淆矩阵写成 json")
return parser.parse_args()
def build_loaders(cfg, split_cfg, classes=None):
from torch.utils.data import DataLoader
datasets = {}
for split, transforms_key in (("train", "train_transforms"), ("val", "val_transforms")):
datasets[split] = FolderClassificationDataset(
root=split_cfg["root"],
split=split,
val_ratio=float(split_cfg.get("val_ratio", 0.2)),
seed=int(cfg.get("seed", 42)),
classes=classes or split_cfg.get("classes"),
transforms=split_cfg[transforms_key],
exclude=split_cfg.get("exclude") or [],
)
loader_cfg = dict(split_cfg.get("loader", {}))
loaders = {
split: DataLoader(
dataset,
batch_size=int(loader_cfg.get("batch_size", 64)),
shuffle=(split == "train"),
num_workers=int(loader_cfg.get("num_workers", 4)),
collate_fn=classification_collate,
drop_last=False,
)
for split, dataset in datasets.items()
}
return datasets, loaders
def write_report(path: Path, trainer, metrics: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"classes": trainer.classes,
"metrics": metrics,
"per_class": trainer.metric.per_class(),
"confusion_matrix": trainer.metric.confusion.tolist(),
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
def log_per_class(logger, trainer) -> None:
logger.info("%-14s %9s %8s %8s %8s", "类别", "precision", "recall", "f1", "support")
for name, row in sorted(trainer.metric.per_class().items(), key=lambda kv: -kv[1]["support"]):
logger.info(
"%-14s %9.3f %8.3f %8.3f %8d",
name, row["precision"], row["recall"], row["f1"], row["support"],
)
def main() -> int:
args = parse_args()
cfg = load_config(args.config, args.set)
work_dir = Path(args.work_dir or cfg.get("work_dir", "runs/classify"))
work_dir.mkdir(parents=True, exist_ok=True)
logger = setup_logging(work_dir / "classify.log")
try:
device = resolve_device(args.device or cfg.get("device", "auto"))
except DeviceNotAvailableError as exc:
logger.error("%s", exc)
return 1
logger.info("设备: %s", describe_device(device))
seed_everything(int(cfg.get("seed", 42)))
datasets, loaders = build_loaders(cfg, cfg["data"])
classes = datasets["train"].classes
counts = datasets["train"].class_counts()
logger.info(
"数据集 %s:train %d / val %d,共 %d 类",
cfg["data"]["root"], len(datasets["train"]), len(datasets["val"]), len(classes),
)
logger.info("训练集各类样本数: %s", dict(zip(classes, counts, strict=True)))
model_cfg = dict(cfg["model"])
model_cfg.setdefault("num_classes", len(classes))
model = build_model(model_cfg).to(device)
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
logger.info(
"模型 %s | 参数量 %.2fM(可训练 %.2fM)",
model_cfg.get("backbone", {}).get("arch", model_cfg["name"]),
sum(p.numel() for p in model.parameters()) / 1e6,
trainable / 1e6,
)
trainer = ClassificationTrainer(
model=model,
train_loader=loaders["train"],
val_loader=loaders["val"],
cfg=cfg.get("train", {}),
device=device,
work_dir=work_dir,
classes=classes,
train_counts=counts,
full_config=cfg.to_dict(),
)
if args.eval_only:
checkpoint = args.checkpoint or (work_dir / "best.pth")
if not Path(checkpoint).exists():
logger.error("找不到权重 %s,先训练或用 --checkpoint 指定", checkpoint)
return 1
from engine import load_checkpoint
load_checkpoint(checkpoint, model, map_location=device)
metrics = trainer.evaluate()
logger.info("评估 %s", checkpoint)
for key, value in metrics.items():
logger.info(" %-24s %.4f", key, value)
log_per_class(logger, trainer)
write_report(Path(args.report or work_dir / "eval_report.json"), trainer, metrics)
return 0
if args.resume:
trainer.resume(args.resume)
cfg.dump(work_dir / "config.yaml")
best = trainer.fit()
logger.info("训练结束,最佳 %s=%.4f", trainer.monitor, best)
# 用 best.pth 再评一次,产出逐类明细与混淆矩阵
from engine import load_checkpoint
best_path = work_dir / "best.pth"
if best_path.exists():
load_checkpoint(best_path, model, map_location=device)
metrics = trainer.evaluate()
logger.info("best.pth 在验证集上的表现:")
for key, value in metrics.items():
logger.info(" %-24s %.4f", key, value)
log_per_class(logger, trainer)
write_report(Path(args.report or work_dir / "eval_report.json"), trainer, metrics)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+78
View File
@@ -0,0 +1,78 @@
# 在人工归类好的数据集上训练缺陷分类模型。
#
# 数据来自 scripts/export_clusters.py:自监督聚类 + 人工整理后的 15 个类别。
#
# 用法:
# uv run python classify.py --config configs/classify/film.yaml
# uv run python classify.py --config configs/classify/film.yaml --eval-only \
# --checkpoint runs/classify/best.pth
work_dir: runs/classify
seed: 42
device: auto
data:
root: data/orgnized_from_r101_k40
val_ratio: 0.2
exclude: [] # 需要剔除某些类别时写在这里,如 [unknown, others]
classes: null # null = 按目录名排序自动确定;训练与评估必须一致
# 预处理沿用聚类阶段的结论:
# - 拉伸到 128x128 而不是 letterbox——尺寸与宽高比是 ROI 裁剪的产物,不是缺陷属性;
# - 逐图标准化抹掉整体亮度/对比度。这里尤其重要:15 个类别里有几类几乎只来自
# 单一原始数据集(ripple 几乎全是 pearlescent、grease 与 zigzag 全是 film),
# 不抹掉亮度的话模型可能靠"认出这是哪个数据集"就把类别猜对,看着准确率很高,
# 换个批次的数据就崩。
image_size: [128, 128]
train_transforms:
- {name: Resize, size: [128, 128]}
- {name: RandomHorizontalFlip, p: 0.5}
- {name: RandomVerticalFlip, p: 0.5}
- {name: ToTensor}
- {name: PerImageStandardize}
val_transforms:
- {name: Resize, size: [128, 128]}
- {name: ToTensor}
- {name: PerImageStandardize}
loader:
batch_size: 128
num_workers: 8
model:
name: classifier
backbone:
name: torchvision
arch: resnet18
pretrained: true
in_channels: 3
classification:
dropout: 0.2
# 用自监督预训练的骨架初始化——那正是前面聚类阶段的产物。
# 例:runs/simclr_r101/simclr_backbone.pth(注意 backbone 配置要与自监督时一致)
init_from: null
frozen_backbone: false # true = 冻结骨架只训分类头(线性探针)
train:
epochs: 30
amp: auto
clip_grad: 5.0
log_interval: 20
# 主指标用 macro-F1 而不是准确率:数据从 normal 2679 张到 others 65 张差 41 倍,
# 全预测成 normal 也有 24% 准确率,但 macro-F1 会很低。
monitor: macro_f1
# 按类别频率加权,避免小类被淹没;不需要时设 none。
class_weight: balanced
label_smoothing: 0.05
optimizer: {name: adamw, lr: 0.0003, weight_decay: 0.0001}
scheduler: {name: cosine}
swanlab:
enabled: true
project: film-classify
name: null
mode: local
tags: [classify, resnet18]
+3
View File
@@ -1,6 +1,7 @@
"""数据集:抛出样本的 Dataset 类与相应的数据变换、标签编码。""" """数据集:抛出样本的 Dataset 类与相应的数据变换、标签编码。"""
from datasets.build import build_dataloader, build_dataset from datasets.build import build_dataloader, build_dataset
from datasets.classification import FolderClassificationDataset, classification_collate
from datasets.film_defect import FilmDefectDataset, collate_fn from datasets.film_defect import FilmDefectDataset, collate_fn
from datasets.registry import DATASETS from datasets.registry import DATASETS
from datasets.target_encoder import CenterTargetEncoder from datasets.target_encoder import CenterTargetEncoder
@@ -10,6 +11,8 @@ from datasets.yolo import format_label_lines, load_classes, parse_label_file, wr
__all__ = [ __all__ = [
"build_dataset", "build_dataset",
"FolderClassificationDataset",
"classification_collate",
"build_dataloader", "build_dataloader",
"FilmDefectDataset", "FilmDefectDataset",
"collate_fn", "collate_fn",
+122
View File
@@ -0,0 +1,122 @@
"""按目录组织的分类数据集:`root/<类别名>/*.png`,子目录名即标签。
这是 scripts/export_clusters.py 的产物结构——聚类 + 人工归类之后得到的数据集。
没有预先划分 train/val,这里按类别分层、用固定随机种子切分,保证两次运行拿到同一份划分。
"""
from __future__ import annotations
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
SPLITS = ("train", "val", "all")
@DATASETS.register("folder_classification")
class FolderClassificationDataset(Dataset):
"""Args:
root: 数据集根目录,其下每个子目录是一个类别。
split: "train" / "val" / "all"。
val_ratio: 验证集比例,**按类别分别切分**——小类只有几十张,随机整体切分
很容易让某个类在验证集里一张都没有。
seed: 划分用的随机种子;同一个 seed 两次运行得到完全相同的划分。
classes: 显式指定类别顺序;缺省按目录名排序。训练与评估必须一致,
否则标签下标对不上。
transforms: transform 配置列表,见 `datasets.transforms.build_transforms`。
exclude: 要跳过的子目录名。
"""
def __init__(
self,
root: str | Path,
split: str = "train",
val_ratio: float = 0.2,
seed: int = 42,
classes: Sequence[str] | None = None,
transforms: Sequence[Mapping[str, Any]] = (),
gray: bool = False,
exclude: Sequence[str] = (),
) -> None:
if split not in SPLITS:
raise ValueError(f"未知 split: {split},可选 {SPLITS}")
self.root = Path(root)
if not self.root.is_dir():
raise FileNotFoundError(f"数据集目录不存在: {self.root}")
self.split = split
self.gray = gray
self.transforms = build_transforms(transforms)
excluded = set(exclude)
found = sorted(
p.name for p in self.root.iterdir() if p.is_dir() and p.name not in excluded
)
self.classes = list(classes) if classes else found
if not self.classes:
raise FileNotFoundError(f"{self.root} 下没有找到类别子目录")
missing = [c for c in self.classes if not (self.root / c).is_dir()]
if missing:
raise FileNotFoundError(f"以下类别在 {self.root} 下不存在: {missing}")
self.class_to_idx = {name: i for i, name in enumerate(self.classes)}
self.samples = self._collect(val_ratio, seed)
if not self.samples:
raise ValueError(f"{self.root} 的 {split} 划分里没有样本")
def _collect(self, val_ratio: float, seed: int) -> list[tuple[Path, int]]:
samples: list[tuple[Path, int]] = []
for label, name in enumerate(self.classes):
files = sorted(
p
for p in (self.root / name).rglob("*")
if p.is_file() and p.suffix.lower() in IMAGE_SUFFIXES
)
if not files:
continue
# 逐类切分:小类只有几十张,整体随机切很容易让它在验证集里挂零
generator = torch.Generator().manual_seed(seed + label)
order = torch.randperm(len(files), generator=generator).tolist()
n_val = max(1, round(len(files) * val_ratio)) if len(files) > 1 else 0
val_idx = set(order[:n_val])
for index, path in enumerate(files):
in_val = index in val_idx
if self.split == "all" or (self.split == "val") == in_val:
samples.append((path, label))
return samples
def class_counts(self) -> list[int]:
counts = [0] * len(self.classes)
for _, label in self.samples:
counts[label] += 1
return counts
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, index: int) -> dict:
path, label = self.samples[index]
with Image.open(path) as raw:
image = raw.convert("L" if self.gray else "RGB")
sample = self.transforms({"image": image})
return {
"image": sample["image"],
"label": label,
"meta": {"path": str(path), "index": index, "class": self.classes[label]},
}
def classification_collate(batch: list[dict]) -> dict:
return {
"images": torch.stack([item["image"] for item in batch]),
"labels": torch.tensor([item["label"] for item in batch], dtype=torch.long),
"meta": [item["meta"] for item in batch],
}
+8
View File
@@ -1,6 +1,11 @@
"""训练 / 评估流程。""" """训练 / 评估流程。"""
from engine.checkpoint import load_checkpoint, save_checkpoint from engine.checkpoint import load_checkpoint, save_checkpoint
from engine.classification import (
ClassificationMetric,
ClassificationTrainer,
class_weights,
)
from engine.evaluator import Evaluator from engine.evaluator import Evaluator
from engine.losses import DetectionLoss from engine.losses import DetectionLoss
from engine.optim import build_optimizer, build_scheduler from engine.optim import build_optimizer, build_scheduler
@@ -8,6 +13,9 @@ from engine.trainer import Trainer
__all__ = [ __all__ = [
"Trainer", "Trainer",
"ClassificationTrainer",
"ClassificationMetric",
"class_weights",
"Evaluator", "Evaluator",
"DetectionLoss", "DetectionLoss",
"build_optimizer", "build_optimizer",
+288
View File
@@ -0,0 +1,288 @@
"""分类任务的训练、评估与指标。
数据极不均衡(normal 2679 张 vs others 65 张,差 41 倍),所以:
- 主指标用 **macro-F1** 而不是准确率——全预测成 normal 也能拿到 24% 准确率,但 macro-F1 会很低;
- 损失支持按类别频率加权,避免小类被淹没。
"""
from __future__ import annotations
import time
import warnings
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.checkpoint import load_checkpoint, save_checkpoint
from engine.metrics import AverageMeter
from engine.optim import build_optimizer, build_scheduler
from engine.visualizer import SwanLabLogger
from utils.device import PeakMemory, resolve_amp
from utils.logging import get_logger
logger = get_logger("engine.classification")
class ClassificationMetric:
"""混淆矩阵 + 由它导出的各项指标。
准确率在不均衡数据上会骗人,所以同时给 macro-F1(各类等权)与
weighted-F1(按样本数加权),并保留逐类明细供人工判断哪些类没学会。
"""
def __init__(self, classes: list[str]) -> None:
self.classes = list(classes)
self.reset()
def reset(self) -> None:
n = len(self.classes)
self.confusion = torch.zeros(n, n, dtype=torch.long) # 行=真实,列=预测
def update(self, logits: torch.Tensor, targets: torch.Tensor) -> None:
preds = logits.argmax(dim=1).cpu()
for target, pred in zip(targets.cpu().tolist(), preds.tolist(), strict=True):
self.confusion[target, pred] += 1
def per_class(self) -> dict[str, dict[str, float]]:
report = {}
for index, name in enumerate(self.classes):
tp = int(self.confusion[index, index])
support = int(self.confusion[index].sum())
predicted = int(self.confusion[:, index].sum())
precision = tp / predicted if predicted else 0.0
recall = tp / support if support else 0.0
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
report[name] = {
"precision": precision,
"recall": recall,
"f1": f1,
"support": support,
}
return report
def compute(self) -> dict[str, float]:
total = int(self.confusion.sum())
if not total:
return {"accuracy": 0.0, "macro_f1": 0.0, "weighted_f1": 0.0, "balanced_accuracy": 0.0}
report = self.per_class()
f1s = [r["f1"] for r in report.values()]
recalls = [r["recall"] for r in report.values() if r["support"]]
supports = [r["support"] for r in report.values()]
weighted = sum(r["f1"] * r["support"] for r in report.values()) / total
return {
"accuracy": float(self.confusion.diagonal().sum()) / total,
"macro_f1": sum(f1s) / len(f1s),
"weighted_f1": weighted,
# 各类召回的均值:小类分错会明显拉低它,比准确率更能反映"每类都学会了吗"
"balanced_accuracy": sum(recalls) / len(recalls) if recalls else 0.0,
"num_classes_with_support": sum(1 for s in supports if s),
}
def class_weights(counts: list[int], mode: str | None = "balanced") -> torch.Tensor | None:
"""按类别频率算损失权重。
`balanced` 用逆频率并归一化到均值 1:小类的每个样本被放大,
否则 normal(占 24%)会主导梯度,小类学不动。
"""
if not mode or str(mode).lower() in ("none", "false"):
return None
if str(mode).lower() != "balanced":
raise ValueError(f"未知 class_weight: {mode},可选 balanced / none")
counts_tensor = torch.tensor(counts, dtype=torch.float32).clamp(min=1)
weights = counts_tensor.sum() / (len(counts) * counts_tensor)
return weights / weights.mean()
class ClassificationTrainer:
"""Args:
model: 已 `.to(device)` 的分类模型。
train_loader / val_loader: 数据加载器。
cfg: `train` 段配置(epochs / optimizer / scheduler / amp / class_weight / monitor 等)。
classes: 类别名,顺序必须与标签下标一致。
"""
def __init__(
self,
model: nn.Module,
train_loader: DataLoader,
val_loader: DataLoader | None,
cfg: Mapping[str, Any],
device: torch.device,
work_dir: str | Path,
classes: list[str],
train_counts: list[int] | None = None,
full_config: dict | None = None,
) -> None:
self.model = model
self.train_loader = train_loader
self.val_loader = val_loader
self.cfg = dict(cfg)
self.device = device
self.classes = list(classes)
self.work_dir = Path(work_dir)
self.work_dir.mkdir(parents=True, exist_ok=True)
self.full_config = full_config
self.epochs = int(self.cfg.get("epochs", 30))
self.log_interval = int(self.cfg.get("log_interval", 20))
self.clip_grad = float(self.cfg.get("clip_grad", 0.0))
self.monitor = str(self.cfg.get("monitor", "macro_f1"))
self.label_smoothing = float(self.cfg.get("label_smoothing", 0.0))
weights = class_weights(train_counts or [], self.cfg.get("class_weight", "balanced"))
self.class_weight = weights.to(device) if weights is not None else None
if self.class_weight is not None:
extremes = sorted(zip(self.classes, weights.tolist(), strict=True), key=lambda kv: -kv[1])
logger.info(
"类别权重(逆频率):最大 %s=%.2f,最小 %s=%.2f",
extremes[0][0], extremes[0][1], extremes[-1][0], extremes[-1][1],
)
self.optimizer = build_optimizer(model, self.cfg.get("optimizer", {"name": "adamw", "lr": 1e-3}))
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), self.epochs)
self.amp = resolve_amp(self.cfg.get("amp", "auto"), device)
self.scaler = torch.amp.GradScaler(device.type, enabled=self.amp)
self.peak_memory = PeakMemory(device)
self.metric = ClassificationMetric(self.classes)
self.best_metric = float("-inf")
self.start_epoch = 0
self.global_step = 0
self.visualizer = SwanLabLogger(
work_dir=self.work_dir, config=full_config, **dict(self.cfg.get("swanlab", {}))
)
def _loss(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
return F.cross_entropy(
logits, targets, weight=self.class_weight, label_smoothing=self.label_smoothing
)
def train_one_epoch(self, epoch: int) -> dict[str, float]:
self.model.train()
loss_meter, acc_meter = AverageMeter(), AverageMeter()
start = time.time()
for step, batch in enumerate(self.train_loader):
images = batch["images"].to(self.device, non_blocking=True)
targets = batch["labels"].to(self.device, non_blocking=True)
with torch.amp.autocast(self.device.type, enabled=self.amp):
logits = self.model(images)
loss = self._loss(logits, targets)
self.optimizer.zero_grad(set_to_none=True)
self.scaler.scale(loss).backward()
if self.clip_grad > 0:
self.scaler.unscale_(self.optimizer)
nn.utils.clip_grad_norm_(self.model.parameters(), self.clip_grad)
self.scaler.step(self.optimizer)
self.scaler.update()
self.peak_memory.update()
batch_size = images.shape[0]
loss_meter.update(float(loss.detach()), batch_size)
acc_meter.update(float((logits.argmax(1) == targets).float().mean()), batch_size)
self.global_step += 1
if step % self.log_interval == 0:
logger.info(
"epoch %d [%d/%d] loss=%.4f acc=%.4f lr=%.2e",
epoch, step, len(self.train_loader), loss_meter.avg, acc_meter.avg,
self.optimizer.param_groups[0]["lr"],
)
self.visualizer.log(
{"loss": loss_meter.avg, "acc": acc_meter.avg,
"lr": self.optimizer.param_groups[0]["lr"]},
step=self.global_step, prefix="train/",
)
logger.info("epoch %d 训练耗时 %.1fs", epoch, time.time() - start)
return {"loss": loss_meter.avg, "acc": acc_meter.avg}
@torch.no_grad()
def evaluate(self, loader: DataLoader | None = None) -> dict[str, float]:
loader = loader or self.val_loader
if loader is None:
return {}
self.model.eval()
self.metric.reset()
loss_meter = AverageMeter()
for batch in loader:
images = batch["images"].to(self.device, non_blocking=True)
targets = batch["labels"].to(self.device, non_blocking=True)
logits = self.model(images)
loss_meter.update(float(self._loss(logits, targets)), images.shape[0])
self.metric.update(logits, targets)
metrics = self.metric.compute()
metrics["loss"] = loss_meter.avg
return metrics
def fit(self) -> float:
logger.info("开始训练:共 %d 个 epoch,%d 类,输出目录 %s",
self.epochs, len(self.classes), self.work_dir)
for epoch in range(self.start_epoch, self.epochs):
self.train_one_epoch(epoch)
if self.scheduler is not None:
self.scheduler.step()
metrics = self.evaluate()
if metrics:
logger.info(
"epoch %d 验证: %s",
epoch,
" ".join(f"{k}={v:.4f}" for k, v in metrics.items() if isinstance(v, float)),
)
self.visualizer.log(metrics, step=self.global_step, prefix="val/")
self._save("last.pth", epoch)
score = metrics.get(self.monitor)
if score is not None and score > self.best_metric:
self.best_metric = score
self._save("best.pth", epoch)
logger.info("epoch %d 刷新最佳 %s=%.4f,已保存 best.pth", epoch, self.monitor, score)
self.visualizer.finish()
return self.best_metric
def _save(self, name: str, epoch: int) -> None:
save_checkpoint(
self.work_dir / name, self.model, self.optimizer, epoch=epoch,
best_metric=self.best_metric, config=self.full_config,
scheduler=self.scheduler, global_step=self.global_step, train_cfg=dict(self.cfg),
)
def resume(self, path: str | Path) -> dict:
"""续训。与检测训练同样的语义:检查点只提供进度,超参以配置为准。"""
state = load_checkpoint(path, self.model, map_location=self.device)
if state.get("optimizer"):
intended = [
{k: v for k, v in group.items() if k != "params"}
for group in self.optimizer.param_groups
]
self.optimizer.load_state_dict(state["optimizer"])
for group, wanted in zip(self.optimizer.param_groups, intended, strict=True):
group.update(wanted)
self.start_epoch = int(state.get("epoch", -1)) + 1
self.global_step = int(state.get("global_step", 0))
best = state.get("best_metric")
self.best_metric = float(best) if best is not None else float("-inf")
if self.scheduler is not None:
for group in self.optimizer.param_groups:
group["lr"] = group.get("initial_lr", group["lr"])
self.scheduler = build_scheduler(self.optimizer, self.cfg.get("scheduler"), self.epochs)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
for _ in range(self.start_epoch):
self.scheduler.step()
logger.info("已从 %s 恢复:起始 epoch=%d,best_%s=%.4f",
path, self.start_epoch, self.monitor, self.best_metric)
return state
+2
View File
@@ -5,6 +5,7 @@
from models import backbones, heads, necks # noqa: F401 触发注册 from models import backbones, heads, necks # noqa: F401 触发注册
from models.build import build_model from models.build import build_model
from models.classifier import DefectClassifier
from models.defect_net import DefectNet from models.defect_net import DefectNet
from models.postprocess import decode_heatmap from models.postprocess import decode_heatmap
from models.registry import BACKBONES, HEADS, MODELS, NECKS from models.registry import BACKBONES, HEADS, MODELS, NECKS
@@ -12,6 +13,7 @@ from models.registry import BACKBONES, HEADS, MODELS, NECKS
__all__ = [ __all__ = [
"build_model", "build_model",
"DefectNet", "DefectNet",
"DefectClassifier",
"decode_heatmap", "decode_heatmap",
"BACKBONES", "BACKBONES",
"NECKS", "NECKS",
+82
View File
@@ -0,0 +1,82 @@
"""缺陷分类模型:卷积骨架 + 分类头。
与 `DefectNet` 共用同一套 backbone 注册表,所以自监督预训练出来的骨架
(`runs/*/simclr_backbone.pth`)可以直接拿来初始化——那正是前面聚类阶段的产物。
按项目约定只用 convolution,不含 attention / transformer。
"""
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from typing import Any
import torch
from torch import nn
from models.heads.classification import ClassificationHead
from models.registry import BACKBONES, MODELS
from utils.logging import get_logger
logger = get_logger("models.classifier")
@MODELS.register("classifier")
class DefectClassifier(nn.Module):
"""Args:
num_classes: 类别数。
backbone: backbone 注册表的配置。
classification: 分类头参数(`hidden`/`dropout` 等)。
init_from: 自监督骨架权重路径;只加载 backbone,分类头仍随机初始化。
frozen_backbone: 冻结骨架只训分类头(线性探针,用来单独衡量自监督特征的好坏)。
"""
def __init__(
self,
num_classes: int,
backbone: Mapping[str, Any] | None = None,
classification: Mapping[str, Any] | None = None,
init_from: str | Path | None = None,
frozen_backbone: bool = False,
) -> None:
super().__init__()
self.num_classes = num_classes
self.backbone = BACKBONES.build(dict(backbone or {"name": "tiny_conv"}))
self.head = ClassificationHead(
self.backbone.out_channels, num_classes, **dict(classification or {})
)
if init_from:
self.load_backbone(init_from)
if frozen_backbone:
for param in self.backbone.parameters():
param.requires_grad_(False)
logger.info("骨架已冻结,只训练分类头(线性探针)")
def load_backbone(self, path: str | Path) -> None:
"""加载自监督预训练的骨架权重。
只取 backbone 那部分:simclr_backbone.pth 里存的就是骨架 state_dict,
分类头是新的、必须从头学。
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"预训练骨架不存在: {path}")
state = torch.load(path, map_location="cpu", weights_only=False)
weights = state.get("model", state.get("backbone", state))
missing, unexpected = self.backbone.load_state_dict(weights, strict=False)
logger.info(
"已从 %s 加载骨架权重(缺失 %d 项,多余 %d 项,已训 %s 轮)",
path,
len(missing),
len(unexpected),
state.get("epochs", "?"),
)
if len(missing) > len(list(self.backbone.state_dict())) // 2:
raise ValueError(
f"{path} 与当前骨架结构对不上(缺失 {len(missing)} 项);"
"确认 backbone 配置与自监督训练时一致"
)
def forward(self, images: torch.Tensor) -> torch.Tensor:
return self.head(self.backbone(images))
+274
View File
@@ -0,0 +1,274 @@
"""分类数据集、模型与训练引擎。"""
import sys
import pytest
torch = pytest.importorskip("torch")
pytest.importorskip("PIL")
import numpy as np # noqa: E402
from PIL import Image # noqa: E402
from datasets import FolderClassificationDataset, classification_collate # noqa: E402
from engine.classification import ( # noqa: E402
ClassificationMetric,
ClassificationTrainer,
class_weights,
)
from models import build_model # noqa: E402
TRANSFORMS = [{"name": "Resize", "size": [32, 32]}, {"name": "ToTensor"}]
@pytest.fixture
def dataset_root(tmp_path):
"""三个类别,样本数刻意不均衡(20 / 6 / 2)。"""
rng = np.random.default_rng(0)
root = tmp_path / "dataset" # 单独一层:输出目录不能落在数据集根下,
root.mkdir() # 否则会被当成一个新类别扫进来
for name, count in (("normal", 20), ("grease", 6), ("others", 2)):
directory = root / name
directory.mkdir()
for i in range(count):
Image.fromarray(rng.integers(0, 255, (18, 24, 3), dtype=np.uint8)).save(
directory / f"{i}.png"
)
return root
def test_classes_come_from_subdirectory_names(dataset_root):
dataset = FolderClassificationDataset(dataset_root, split="all", transforms=TRANSFORMS)
assert dataset.classes == ["grease", "normal", "others"] # 按目录名排序
assert len(dataset) == 28
assert dataset.class_counts() == [6, 20, 2]
def test_split_is_stratified_and_deterministic(dataset_root):
train = FolderClassificationDataset(dataset_root, split="train", transforms=TRANSFORMS)
val = FolderClassificationDataset(dataset_root, split="val", transforms=TRANSFORMS)
assert len(train) + len(val) == 28
# 逐类切分:即使只有 2 张的类别也要在验证集里留下样本
assert all(count > 0 for count in val.class_counts()), val.class_counts()
assert all(count > 0 for count in train.class_counts())
again = FolderClassificationDataset(dataset_root, split="val", transforms=TRANSFORMS)
assert [p for p, _ in val.samples] == [p for p, _ in again.samples]
# 训练与验证不能有交集
assert not {p for p, _ in train.samples} & {p for p, _ in val.samples}
def test_different_seed_changes_the_split(dataset_root):
a = FolderClassificationDataset(dataset_root, split="val", seed=1, transforms=TRANSFORMS)
b = FolderClassificationDataset(dataset_root, split="val", seed=2, transforms=TRANSFORMS)
assert [p for p, _ in a.samples] != [p for p, _ in b.samples]
def test_exclude_and_explicit_classes(dataset_root):
dataset = FolderClassificationDataset(
dataset_root, split="all", transforms=TRANSFORMS, exclude=["others"]
)
assert dataset.classes == ["grease", "normal"]
ordered = FolderClassificationDataset(
dataset_root, split="all", transforms=TRANSFORMS, classes=["others", "normal"]
)
assert ordered.class_to_idx == {"others": 0, "normal": 1}
with pytest.raises(FileNotFoundError, match="不存在"):
FolderClassificationDataset(dataset_root, classes=["nope"], transforms=TRANSFORMS)
def test_sample_and_collate_shapes(dataset_root):
dataset = FolderClassificationDataset(dataset_root, split="all", transforms=TRANSFORMS)
sample = dataset[0]
assert sample["image"].shape == (3, 32, 32)
assert isinstance(sample["label"], int)
assert sample["meta"]["class"] == dataset.classes[sample["label"]]
batch = classification_collate([dataset[0], dataset[1]])
assert batch["images"].shape == (2, 3, 32, 32)
assert batch["labels"].shape == (2,) and batch["labels"].dtype == torch.long
def test_class_weights_favour_rare_classes():
weights = class_weights([100, 10, 1])
assert weights[2] > weights[1] > weights[0]
assert float(weights.mean()) == pytest.approx(1.0)
assert class_weights([1, 2], mode="none") is None
with pytest.raises(ValueError, match="未知 class_weight"):
class_weights([1, 2], mode="sqrt")
def test_metric_matches_hand_computed_values():
metric = ClassificationMetric(["a", "b"])
# 真实 a,a,b,b -> 预测 a,b,b,b:a 召回 0.5,b 精确 2/3
metric.update(torch.tensor([[9.0, 0.0], [0.0, 9.0], [0.0, 9.0], [0.0, 9.0]]),
torch.tensor([0, 0, 1, 1]))
report = metric.per_class()
assert report["a"] == pytest.approx({"precision": 1.0, "recall": 0.5,
"f1": 2 / 3, "support": 2})
assert report["b"]["precision"] == pytest.approx(2 / 3)
metrics = metric.compute()
assert metrics["accuracy"] == pytest.approx(0.75)
assert metrics["macro_f1"] == pytest.approx((2 / 3 + 0.8) / 2)
def test_macro_f1_punishes_majority_only_prediction():
"""全预测成多数类:准确率还行,但 macro-F1 很低——这正是选它当主指标的原因。"""
metric = ClassificationMetric(["major", "minor"])
logits = torch.tensor([[9.0, 0.0]]).repeat(100, 1)
targets = torch.tensor([0] * 90 + [1] * 10)
metric.update(logits, targets)
metrics = metric.compute()
assert metrics["accuracy"] == pytest.approx(0.9)
assert metrics["macro_f1"] < 0.5
assert metrics["balanced_accuracy"] == pytest.approx(0.5)
def test_classifier_is_convolution_only_and_shapes_match():
model = build_model({
"name": "classifier", "num_classes": 4,
"backbone": {"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]},
"classification": {"dropout": 0.1},
}).eval()
with torch.no_grad():
assert model(torch.randn(3, 3, 64, 64)).shape == (3, 4)
forbidden = (torch.nn.MultiheadAttention, torch.nn.Transformer, torch.nn.LSTM)
assert not any(isinstance(m, forbidden) for m in model.modules())
def test_init_from_loads_self_supervised_backbone(tmp_path):
"""自监督骨架能直接拿来初始化分类模型——这是聚类阶段产物的下游用途。"""
from models.registry import BACKBONES
backbone_cfg = {"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]}
pretrained = BACKBONES.build(dict(backbone_cfg))
with torch.no_grad():
for param in pretrained.parameters():
param.fill_(0.5)
path = tmp_path / "simclr_backbone.pth"
torch.save({"model": pretrained.state_dict(), "epochs": 40}, path)
model = build_model({"name": "classifier", "num_classes": 3,
"backbone": dict(backbone_cfg), "init_from": str(path)})
for param in model.backbone.parameters():
assert torch.allclose(param, torch.full_like(param, 0.5))
with pytest.raises(FileNotFoundError):
build_model({"name": "classifier", "num_classes": 3,
"backbone": dict(backbone_cfg), "init_from": str(tmp_path / "nope.pth")})
def test_frozen_backbone_only_trains_the_head():
model = build_model({
"name": "classifier", "num_classes": 3,
"backbone": {"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]},
"frozen_backbone": True,
})
assert not any(p.requires_grad for p in model.backbone.parameters())
assert all(p.requires_grad for p in model.head.parameters())
def _trainer(dataset_root, tmp_path, **cfg_extra):
from torch.utils.data import DataLoader
datasets = {
split: FolderClassificationDataset(dataset_root, split=split, transforms=TRANSFORMS)
for split in ("train", "val")
}
loaders = {
split: DataLoader(ds, batch_size=4, shuffle=(split == "train"),
collate_fn=classification_collate)
for split, ds in datasets.items()
}
model = build_model({"name": "classifier", "num_classes": len(datasets["train"].classes),
"backbone": {"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]}})
cfg = {"epochs": 2, "amp": False, "log_interval": 999, "scheduler": {"name": "cosine"},
"optimizer": {"name": "adamw", "lr": 1e-3}, "swanlab": {"enabled": False}, **cfg_extra}
return ClassificationTrainer(
model, loaders["train"], loaders["val"], cfg, torch.device("cpu"), tmp_path / "run",
classes=datasets["train"].classes, train_counts=datasets["train"].class_counts(),
)
def test_training_runs_and_saves_best_by_macro_f1(dataset_root, tmp_path):
trainer = _trainer(dataset_root, tmp_path)
best = trainer.fit()
assert (tmp_path / "run" / "best.pth").exists()
assert (tmp_path / "run" / "last.pth").exists()
assert best > float("-inf")
assert trainer.monitor == "macro_f1" # 不均衡数据下的默认主指标
metrics = trainer.evaluate()
assert {"accuracy", "macro_f1", "weighted_f1", "balanced_accuracy", "loss"} <= set(metrics)
def test_resume_takes_hyperparams_from_config(dataset_root, tmp_path):
"""与检测/自监督一致:检查点只提供进度,超参以配置为准。"""
trainer = _trainer(dataset_root, tmp_path, epochs=2,
optimizer={"name": "adamw", "lr": 1e-3, "weight_decay": 1e-4})
trainer.fit()
resumed = _trainer(dataset_root, tmp_path, epochs=6,
optimizer={"name": "adamw", "lr": 5e-4, "weight_decay": 0.05})
resumed.resume(tmp_path / "run" / "last.pth")
assert resumed.start_epoch == 2
groups = resumed.optimizer.param_groups
assert groups[0]["initial_lr"] == pytest.approx(5e-4)
assert groups[0]["weight_decay"] == pytest.approx(0.05)
assert resumed.scheduler.T_max == 6
def test_cli_train_and_eval_only(dataset_root, tmp_path, monkeypatch):
import classify
config = tmp_path / "cfg.yaml"
config.write_text(
f"""
work_dir: {tmp_path / "run_cli"}
seed: 42
device: cpu
data:
root: {dataset_root}
val_ratio: 0.25
exclude: []
classes: null
train_transforms: [{{name: Resize, size: [32, 32]}}, {{name: ToTensor}}]
val_transforms: [{{name: Resize, size: [32, 32]}}, {{name: ToTensor}}]
loader: {{batch_size: 4, num_workers: 0}}
model:
name: classifier
backbone: {{name: tiny_conv, widths: [8, 16], depths: [1, 1]}}
classification: {{dropout: 0.0}}
init_from: null
frozen_backbone: false
train:
epochs: 1
amp: false
log_interval: 999
monitor: macro_f1
class_weight: balanced
optimizer: {{name: adamw, lr: 0.001}}
scheduler: {{name: cosine}}
swanlab: {{enabled: false}}
""",
encoding="utf-8",
)
monkeypatch.setattr(sys, "argv", ["classify.py", "--config", str(config)])
assert classify.main() == 0
assert (tmp_path / "run_cli" / "eval_report.json").exists()
monkeypatch.setattr(
sys, "argv",
["classify.py", "--config", str(config), "--eval-only",
"--checkpoint", str(tmp_path / "run_cli" / "best.pth")],
)
assert classify.main() == 0