feat(simclr): 支持断点续训,恢复优化器与调度器状态
此前 simclr.checkpoint 的语义是"有权重就跳过训练",没有真正的续训: 中途停掉再启动只能从头再来,或者放弃已训的轮次。 - 新增 simclr_last.pth:骨架 + 投影头 + 优化器 + 调度器 + scaler + epoch + step, 与给下游用的 simclr_backbone.pth 并存,两者在同一时机落盘 - 新增 simclr.resume:载入完整状态从下一个 epoch 接着练。只恢复权重不恢复动量与 cosine 进度的话,续训时损失会先反弹一段 - resume 与 checkpoint 互斥(同时给直接报错);拿只含骨架的 simclr_backbone.pth 去 resume 会给出可操作的报错,提示改用 checkpoint - 续训轮数已达计划值时不再训练,只打警告 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
be6173c746
commit
345dee50da
@@ -223,7 +223,9 @@ uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml \
|
|||||||
|
|
||||||
```
|
```
|
||||||
runs/simclr/
|
runs/simclr/
|
||||||
├── simclr_backbone.pth # 每 save_interval 个 epoch 覆盖一次;Ctrl-C 也会先落盘
|
├── simclr_backbone.pth # 只含骨架,给下游用(聚类 / 初始化 DefectNet)
|
||||||
|
├── simclr_last.pth # 完整训练状态,给断点续训用
|
||||||
|
├── swanlog/ # SwanLab 日志
|
||||||
├── cluster.log
|
├── cluster.log
|
||||||
├── assignments.csv
|
├── assignments.csv
|
||||||
├── summary.json
|
├── summary.json
|
||||||
@@ -241,6 +243,26 @@ nohup uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.y
|
|||||||
tail -f runs/simclr.log
|
tail -f runs/simclr.log
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 断点续训
|
||||||
|
|
||||||
|
训练中断(Ctrl-C、kill、机器重启)后接着练,用 `simclr.resume` 指向 `simclr_last.pth`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml \
|
||||||
|
--work-dir runs/simclr \
|
||||||
|
--set method=simclr simclr.epochs=40 loader.num_workers=8 \
|
||||||
|
simclr.resume=runs/simclr/simclr_last.pth
|
||||||
|
```
|
||||||
|
|
||||||
|
日志会打印"已载入训练状态 …:从 epoch N 接着练到 40"。恢复的不只是权重,还有**投影头、
|
||||||
|
优化器动量、学习率调度进度和 step 计数**——少了这些,续训等于把动量和 cosine 进度清零,
|
||||||
|
损失会先反弹一段。
|
||||||
|
|
||||||
|
> `resume` 与 `checkpoint` 语义不同,二选一(同时给会报错):
|
||||||
|
> **`resume`** = 载入完整状态**接着训练**(要 `simclr_last.pth`);
|
||||||
|
> **`checkpoint`** = 有权重就**跳过训练**直接聚类(要 `simclr_backbone.pth`)。
|
||||||
|
> 拿 `simclr_backbone.pth` 去 `resume` 会报错,因为它不含投影头和优化器状态。
|
||||||
|
|
||||||
#### 第二步:复用权重重新聚类
|
#### 第二步:复用权重重新聚类
|
||||||
|
|
||||||
想换簇数或改聚类参数时**不必重训**——把权重路径填进 `simclr.checkpoint` 就会跳过训练
|
想换簇数或改聚类参数时**不必重训**——把权重路径填进 `simclr.checkpoint` 就会跳过训练
|
||||||
@@ -285,7 +307,9 @@ simclr:
|
|||||||
temperature: 0.2 # 越小越关注难负样本
|
temperature: 0.2 # 越小越关注难负样本
|
||||||
amp: auto # CUDA 上开、MPS 上关
|
amp: auto # CUDA 上开、MPS 上关
|
||||||
save_interval: 5 # 每 5 个 epoch 落一次盘
|
save_interval: 5 # 每 5 个 epoch 落一次盘
|
||||||
checkpoint: null # 填 runs/simclr/simclr_backbone.pth 即可跳过重复训练
|
# 下面两个二选一,语义不同:
|
||||||
|
checkpoint: null # 有权重就跳过训练直接聚类,例 runs/simclr/simclr_backbone.pth
|
||||||
|
resume: null # 载入完整状态接着训练,例 runs/simclr/simclr_last.pth
|
||||||
projection: {hidden_channels: 256, out_channels: 128}
|
projection: {hidden_channels: 256, out_channels: 128}
|
||||||
optimizer: {name: adamw, lr: 0.001, weight_decay: 0.0001}
|
optimizer: {name: adamw, lr: 0.001, weight_decay: 0.0001}
|
||||||
scheduler: {name: cosine}
|
scheduler: {name: cosine}
|
||||||
|
|||||||
@@ -110,7 +110,11 @@ simclr:
|
|||||||
tags: [simclr, tiny_conv]
|
tags: [simclr, tiny_conv]
|
||||||
|
|
||||||
log_interval: 20
|
log_interval: 20
|
||||||
checkpoint: null # 填已有的 simclr_backbone.pth 可跳过重复训练
|
# checkpoint 与 resume 二选一,语义不同:
|
||||||
|
# checkpoint: 有权重就**跳过训练**,直接拿它抽特征聚类(换簇数时用这个)
|
||||||
|
# resume: 载入骨架+投影头+优化器+调度器,从上次的 epoch **接着训练**
|
||||||
|
checkpoint: null # 例:runs/simclr/simclr_backbone.pth
|
||||||
|
resume: null # 例:runs/simclr/simclr_last.pth
|
||||||
projection: {hidden_channels: 256, out_channels: 128}
|
projection: {hidden_channels: 256, out_channels: 128}
|
||||||
optimizer: {name: adamw, lr: 0.001, weight_decay: 0.0001}
|
optimizer: {name: adamw, lr: 0.001, weight_decay: 0.0001}
|
||||||
scheduler: {name: cosine}
|
scheduler: {name: cosine}
|
||||||
|
|||||||
@@ -740,6 +740,7 @@ $k=4$ 稳定性略低于 ImageNet(0.83 vs 0.98),符合"数据的主结构
|
|||||||
| 端到端聚类 | `engine/cluster.py::cluster_features` |
|
| 端到端聚类 | `engine/cluster.py::cluster_features` |
|
||||||
| NT-Xent 损失 | `engine/simclr.py::nt_xent_loss` |
|
| NT-Xent 损失 | `engine/simclr.py::nt_xent_loss` |
|
||||||
| SimCLR 训练循环 | `engine/simclr.py::SimCLRTrainer` |
|
| SimCLR 训练循环 | `engine/simclr.py::SimCLRTrainer` |
|
||||||
|
| 断点续训(完整状态存取) | `engine/simclr.py::save_state` / `load_state` |
|
||||||
| 投影头 $g_\varphi$ | `models/heads/projection.py::ProjectionHead` |
|
| 投影头 $g_\varphi$ | `models/heads/projection.py::ProjectionHead` |
|
||||||
| 设备解析(cuda/mps/cpu,auto 优先级) | `utils/device.py::resolve_device` |
|
| 设备解析(cuda/mps/cpu,auto 优先级) | `utils/device.py::resolve_device` |
|
||||||
| AMP 随后端的默认值 | `utils/device.py::resolve_amp` / `default_amp` |
|
| AMP 随后端的默认值 | `utils/device.py::resolve_amp` / `default_amp` |
|
||||||
|
|||||||
+93
-9
@@ -49,6 +49,9 @@ class SimCLRTrainer:
|
|||||||
cfg: `epochs / temperature / optimizer / scheduler / amp / log_interval / save_interval`
|
cfg: `epochs / temperature / optimizer / scheduler / amp / log_interval / save_interval`
|
||||||
以及 `swanlab`(可视化,字段同 `train.swanlab`)。
|
以及 `swanlab`(可视化,字段同 `train.swanlab`)。
|
||||||
full_config: 记录到 SwanLab 的超参,一般直接传完整 yaml 配置。
|
full_config: 记录到 SwanLab 的超参,一般直接传完整 yaml 配置。
|
||||||
|
resume: 训练状态文件(`simclr_last.pth`)路径,从上次中断处接着练。
|
||||||
|
与 `simclr.checkpoint` 不同:checkpoint 是"有权重就跳过训练直接聚类",
|
||||||
|
resume 是"载入骨架 + 投影头 + 优化器 + 调度器 + 起始 epoch 继续训练"。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -59,6 +62,7 @@ class SimCLRTrainer:
|
|||||||
device: torch.device,
|
device: torch.device,
|
||||||
work_dir: str | Path,
|
work_dir: str | Path,
|
||||||
full_config: dict | None = None,
|
full_config: dict | None = None,
|
||||||
|
resume: str | Path | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.backbone = backbone
|
self.backbone = backbone
|
||||||
self.dataloader = dataloader
|
self.dataloader = dataloader
|
||||||
@@ -86,6 +90,10 @@ class SimCLRTrainer:
|
|||||||
self.peak_memory = PeakMemory(device)
|
self.peak_memory = PeakMemory(device)
|
||||||
|
|
||||||
self.global_step = 0
|
self.global_step = 0
|
||||||
|
self.start_epoch = 0
|
||||||
|
if resume:
|
||||||
|
self.load_state(resume)
|
||||||
|
|
||||||
self.visualizer = SwanLabLogger(
|
self.visualizer = SwanLabLogger(
|
||||||
work_dir=self.work_dir,
|
work_dir=self.work_dir,
|
||||||
config=full_config,
|
config=full_config,
|
||||||
@@ -156,35 +164,111 @@ class SimCLRTrainer:
|
|||||||
return meter.avg
|
return meter.avg
|
||||||
|
|
||||||
def save_backbone(self, epoch: int) -> Path:
|
def save_backbone(self, epoch: int) -> Path:
|
||||||
"""只存骨架权重,不含投影头——投影头训练成对增强不变,会丢掉下游有用的信息。"""
|
"""只存骨架权重,不含投影头——投影头训练成对增强不变,会丢掉下游有用的信息。
|
||||||
|
|
||||||
|
这是给下游用的产物(聚类、初始化 DefectNet);要接着训练请用 `simclr_last.pth`。
|
||||||
|
"""
|
||||||
path = self.work_dir / "simclr_backbone.pth"
|
path = self.work_dir / "simclr_backbone.pth"
|
||||||
torch.save({"model": self.backbone.state_dict(), "epochs": epoch + 1}, path)
|
torch.save({"model": self.backbone.state_dict(), "epochs": epoch + 1}, path)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def fit(self) -> Path:
|
def save_state(self, epoch: int) -> Path:
|
||||||
"""训练并保存骨架权重(不含投影头),返回权重路径。
|
"""完整训练状态,供 `resume` 断点续训。
|
||||||
|
|
||||||
每 `save_interval` 个 epoch 落一次盘,中途被 Ctrl-C 或 kill 掉也能拿到当前权重继续用。
|
比 `simclr_backbone.pth` 多了投影头、优化器、调度器与 scaler——少了这些,
|
||||||
|
续训等于把动量和学习率进度清零,损失会先反弹一段。
|
||||||
|
"""
|
||||||
|
path = self.work_dir / "simclr_last.pth"
|
||||||
|
torch.save(
|
||||||
|
{
|
||||||
|
"backbone": self.backbone.state_dict(),
|
||||||
|
"projector": self.projector.state_dict(),
|
||||||
|
"optimizer": self.optimizer.state_dict(),
|
||||||
|
"scheduler": self.scheduler.state_dict() if self.scheduler else None,
|
||||||
|
"scaler": self.scaler.state_dict(),
|
||||||
|
"epoch": epoch,
|
||||||
|
"global_step": self.global_step,
|
||||||
|
"epochs_planned": self.epochs,
|
||||||
|
},
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def load_state(self, path: str | Path) -> None:
|
||||||
|
"""载入 `save_state` 写出的完整状态,从下一个 epoch 接着练。"""
|
||||||
|
path = Path(path)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"续训状态文件不存在: {path}")
|
||||||
|
|
||||||
|
state = torch.load(path, map_location=self.device, weights_only=False)
|
||||||
|
if "projector" not in state:
|
||||||
|
raise ValueError(
|
||||||
|
f"{path} 只含骨架权重,无法续训。续训要用 simclr_last.pth;"
|
||||||
|
"若只是想跳过训练直接聚类,请改用 simclr.checkpoint。"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.backbone.load_state_dict(state["backbone"])
|
||||||
|
self.projector.load_state_dict(state["projector"])
|
||||||
|
self.optimizer.load_state_dict(state["optimizer"])
|
||||||
|
if self.scheduler is not None and state.get("scheduler"):
|
||||||
|
self.scheduler.load_state_dict(state["scheduler"])
|
||||||
|
if state.get("scaler"):
|
||||||
|
self.scaler.load_state_dict(state["scaler"])
|
||||||
|
|
||||||
|
self.start_epoch = int(state["epoch"]) + 1
|
||||||
|
self.global_step = int(state.get("global_step", 0))
|
||||||
|
logger.info(
|
||||||
|
"已载入训练状态 %s:从 epoch %d 接着练到 %d", path, self.start_epoch, self.epochs
|
||||||
|
)
|
||||||
|
if self.start_epoch >= self.epochs:
|
||||||
|
logger.warning(
|
||||||
|
"状态里已训到 epoch %d,不小于计划的 %d 轮——不会再训练。"
|
||||||
|
"要继续请调大 simclr.epochs。",
|
||||||
|
self.start_epoch,
|
||||||
|
self.epochs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _checkpoint(self, epoch: int) -> None:
|
||||||
|
"""同时落两份:给下游用的骨架,和给续训用的完整状态。"""
|
||||||
|
self.save_backbone(epoch)
|
||||||
|
self.save_state(epoch)
|
||||||
|
|
||||||
|
def fit(self) -> Path:
|
||||||
|
"""训练并保存骨架权重(不含投影头),返回骨架路径。
|
||||||
|
|
||||||
|
每 `save_interval` 个 epoch 落一次盘(Ctrl-C 也会先落盘),
|
||||||
|
同时写 `simclr_backbone.pth`(下游用)与 `simclr_last.pth`(续训用)。
|
||||||
"""
|
"""
|
||||||
if len(self.dataloader) < 2:
|
if len(self.dataloader) < 2:
|
||||||
raise ValueError("SimCLR 需要多个 batch,请调小 batch_size 或增加样本")
|
raise ValueError("SimCLR 需要多个 batch,请调小 batch_size 或增加样本")
|
||||||
|
|
||||||
path = self.work_dir / "simclr_backbone.pth"
|
path = self.work_dir / "simclr_backbone.pth"
|
||||||
|
if self.start_epoch >= self.epochs:
|
||||||
|
self.save_backbone(self.epochs - 1)
|
||||||
|
self.visualizer.finish()
|
||||||
|
return path
|
||||||
|
|
||||||
|
epoch = self.start_epoch
|
||||||
try:
|
try:
|
||||||
for epoch in range(self.epochs):
|
for epoch in range(self.start_epoch, self.epochs):
|
||||||
self.train_one_epoch(epoch)
|
self.train_one_epoch(epoch)
|
||||||
if self.scheduler is not None:
|
if self.scheduler is not None:
|
||||||
self.scheduler.step()
|
self.scheduler.step()
|
||||||
if self.save_interval and (epoch + 1) % self.save_interval == 0:
|
if self.save_interval and (epoch + 1) % self.save_interval == 0:
|
||||||
self.save_backbone(epoch)
|
self._checkpoint(epoch)
|
||||||
logger.info("已保存中间权重(epoch %d): %s", epoch, path)
|
logger.info("已保存中间权重(epoch %d): %s", epoch, path)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
self.save_backbone(epoch)
|
self._checkpoint(epoch)
|
||||||
logger.warning("训练被中断,已保存 epoch %d 的权重: %s", epoch, path)
|
logger.warning(
|
||||||
|
"训练被中断,已保存 epoch %d 的权重: %s;续训用 --set simclr.resume=%s",
|
||||||
|
epoch,
|
||||||
|
path,
|
||||||
|
self.work_dir / "simclr_last.pth",
|
||||||
|
)
|
||||||
self.visualizer.finish()
|
self.visualizer.finish()
|
||||||
return path
|
return path
|
||||||
|
|
||||||
self.save_backbone(self.epochs - 1)
|
self._checkpoint(self.epochs - 1)
|
||||||
logger.info("自监督骨架已保存: %s", path)
|
logger.info("自监督骨架已保存: %s", path)
|
||||||
self.visualizer.finish()
|
self.visualizer.finish()
|
||||||
return path
|
return path
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml
|
uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml
|
||||||
|
|
||||||
# 改簇数 / 换成在本地数据上自监督训练的特征(Mac 上自动走 MPS)
|
# 改簇数 / 换成在本地数据上自监督训练的特征(Mac 上自动走 MPS)
|
||||||
|
# 断点续训:--set method=simclr simclr.resume=runs/simclr/simclr_last.pth
|
||||||
uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml \
|
uv run python scripts/cluster_unlabeled.py --config configs/cluster/film.yaml \
|
||||||
--set num_clusters=6 method=simclr
|
--set num_clusters=6 method=simclr
|
||||||
|
|
||||||
@@ -76,7 +77,13 @@ def build_encoder(cfg, device, work_dir):
|
|||||||
stages = simclr_cfg.pop("feature_stages", None)
|
stages = simclr_cfg.pop("feature_stages", None)
|
||||||
backbone = BACKBONES.build(backbone_cfg).to(device)
|
backbone = BACKBONES.build(backbone_cfg).to(device)
|
||||||
|
|
||||||
|
resume = simclr_cfg.pop("resume", None)
|
||||||
checkpoint = simclr_cfg.pop("checkpoint", None)
|
checkpoint = simclr_cfg.pop("checkpoint", None)
|
||||||
|
if resume and checkpoint:
|
||||||
|
raise ValueError(
|
||||||
|
"simclr.resume 与 simclr.checkpoint 只能二选一:"
|
||||||
|
"resume=接着训练,checkpoint=跳过训练直接聚类。"
|
||||||
|
)
|
||||||
if checkpoint and Path(checkpoint).exists():
|
if checkpoint and Path(checkpoint).exists():
|
||||||
state = torch.load(checkpoint, map_location=device, weights_only=False)
|
state = torch.load(checkpoint, map_location=device, weights_only=False)
|
||||||
backbone.load_state_dict(state["model"])
|
backbone.load_state_dict(state["model"])
|
||||||
@@ -95,7 +102,9 @@ def build_encoder(cfg, device, work_dir):
|
|||||||
collate_fn=unlabeled_collate,
|
collate_fn=unlabeled_collate,
|
||||||
)
|
)
|
||||||
full_config = cfg.to_dict() if hasattr(cfg, "to_dict") else dict(cfg)
|
full_config = cfg.to_dict() if hasattr(cfg, "to_dict") else dict(cfg)
|
||||||
SimCLRTrainer(backbone, loader, simclr_cfg, device, work_dir, full_config=full_config).fit()
|
SimCLRTrainer(
|
||||||
|
backbone, loader, simclr_cfg, device, work_dir, full_config=full_config, resume=resume
|
||||||
|
).fit()
|
||||||
return StageFeatureExtractor(backbone, stages).to(device).eval()
|
return StageFeatureExtractor(backbone, stages).to(device).eval()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,125 @@ def test_simclr_saves_intermediate_checkpoints(tiny_image_dir, tmp_path):
|
|||||||
assert torch.load(path, map_location="cpu", weights_only=False)["epochs"] == 4
|
assert torch.load(path, map_location="cpu", weights_only=False)["epochs"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
def _make_trainer(tiny_image_dir, work_dir, epochs=4, **cfg_extra):
|
||||||
|
from torch.utils.data import DataLoader
|
||||||
|
|
||||||
|
from datasets.unlabeled import UnlabeledImageDataset, unlabeled_collate
|
||||||
|
|
||||||
|
dataset = UnlabeledImageDataset(
|
||||||
|
tiny_image_dir,
|
||||||
|
transforms=[{"name": "Resize", "size": [32, 32]}, {"name": "ToTensor"}],
|
||||||
|
views=2,
|
||||||
|
)
|
||||||
|
loader = DataLoader(dataset, batch_size=4, shuffle=False, drop_last=True, collate_fn=unlabeled_collate)
|
||||||
|
torch.manual_seed(0)
|
||||||
|
backbone = BACKBONES.build({"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]})
|
||||||
|
cfg = {
|
||||||
|
"epochs": epochs,
|
||||||
|
"amp": False,
|
||||||
|
"log_interval": 100,
|
||||||
|
"save_interval": 1,
|
||||||
|
"projection": {"hidden_channels": 16, "out_channels": 8},
|
||||||
|
"scheduler": {"name": "cosine"},
|
||||||
|
"swanlab": {"enabled": False},
|
||||||
|
**cfg_extra,
|
||||||
|
}
|
||||||
|
return SimCLRTrainer(backbone, loader, cfg, torch.device("cpu"), work_dir,
|
||||||
|
resume=cfg_extra.pop("resume", None))
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_state_contains_full_training_state(tiny_image_dir, tmp_path):
|
||||||
|
trainer = _make_trainer(tiny_image_dir, tmp_path / "run", epochs=1)
|
||||||
|
trainer.fit()
|
||||||
|
|
||||||
|
state_path = tmp_path / "run" / "simclr_last.pth"
|
||||||
|
assert state_path.exists()
|
||||||
|
state = torch.load(state_path, map_location="cpu", weights_only=False)
|
||||||
|
# 续训需要的东西一个都不能少,否则动量和学习率进度会被清零
|
||||||
|
assert {"backbone", "projector", "optimizer", "scheduler", "scaler", "epoch", "global_step"} <= set(state)
|
||||||
|
assert state["epoch"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_continues_from_the_saved_epoch(tiny_image_dir, tmp_path):
|
||||||
|
"""resume 是接着练,不是从头再来。"""
|
||||||
|
work_dir = tmp_path / "run"
|
||||||
|
first = _make_trainer(tiny_image_dir, work_dir, epochs=2)
|
||||||
|
first.fit()
|
||||||
|
steps_after_2 = first.global_step
|
||||||
|
lr_after_2 = first.optimizer.param_groups[0]["lr"]
|
||||||
|
|
||||||
|
resumed = SimCLRTrainer(
|
||||||
|
BACKBONES.build({"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]}),
|
||||||
|
first.dataloader,
|
||||||
|
{"epochs": 5, "amp": False, "log_interval": 100, "save_interval": 1,
|
||||||
|
"projection": {"hidden_channels": 16, "out_channels": 8},
|
||||||
|
"scheduler": {"name": "cosine"}, "swanlab": {"enabled": False}},
|
||||||
|
torch.device("cpu"),
|
||||||
|
work_dir,
|
||||||
|
resume=work_dir / "simclr_last.pth",
|
||||||
|
)
|
||||||
|
assert resumed.start_epoch == 2 # 从第 3 轮开始
|
||||||
|
assert resumed.global_step == steps_after_2 # step 计数接上
|
||||||
|
assert resumed.optimizer.param_groups[0]["lr"] == pytest.approx(lr_after_2) # 学习率进度接上
|
||||||
|
|
||||||
|
resumed.fit()
|
||||||
|
state = torch.load(work_dir / "simclr_last.pth", map_location="cpu", weights_only=False)
|
||||||
|
assert state["epoch"] == 4 # 一路练到第 5 轮
|
||||||
|
assert resumed.global_step > steps_after_2
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_rejects_backbone_only_file(tiny_image_dir, tmp_path):
|
||||||
|
"""simclr_backbone.pth 只有骨架,不能当续训状态用,要给出可操作的报错。"""
|
||||||
|
work_dir = tmp_path / "run"
|
||||||
|
_make_trainer(tiny_image_dir, work_dir, epochs=1).fit()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="只含骨架权重"):
|
||||||
|
SimCLRTrainer(
|
||||||
|
BACKBONES.build({"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]}),
|
||||||
|
_make_trainer(tiny_image_dir, work_dir, epochs=1).dataloader,
|
||||||
|
{"epochs": 2, "amp": False, "projection": {"hidden_channels": 16, "out_channels": 8},
|
||||||
|
"swanlab": {"enabled": False}},
|
||||||
|
torch.device("cpu"),
|
||||||
|
work_dir,
|
||||||
|
resume=work_dir / "simclr_backbone.pth",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError, match="不存在"):
|
||||||
|
_make_trainer(tiny_image_dir, work_dir, epochs=1, resume=work_dir / "nope.pth")
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_beyond_planned_epochs_does_not_train(tiny_image_dir, tmp_path):
|
||||||
|
work_dir = tmp_path / "run"
|
||||||
|
_make_trainer(tiny_image_dir, work_dir, epochs=3).fit()
|
||||||
|
|
||||||
|
resumed = SimCLRTrainer(
|
||||||
|
BACKBONES.build({"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]}),
|
||||||
|
_make_trainer(tiny_image_dir, work_dir, epochs=1).dataloader,
|
||||||
|
{"epochs": 3, "amp": False, "projection": {"hidden_channels": 16, "out_channels": 8},
|
||||||
|
"swanlab": {"enabled": False}},
|
||||||
|
torch.device("cpu"),
|
||||||
|
work_dir,
|
||||||
|
resume=work_dir / "simclr_last.pth",
|
||||||
|
)
|
||||||
|
steps_before = resumed.global_step
|
||||||
|
resumed.fit()
|
||||||
|
assert resumed.global_step == steps_before # 一步都没再训
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_and_checkpoint_are_mutually_exclusive(tiny_image_dir, tmp_path, monkeypatch):
|
||||||
|
sys.path.insert(0, "scripts")
|
||||||
|
import cluster_unlabeled as cu
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="只能二选一"):
|
||||||
|
cu.build_encoder(
|
||||||
|
{"method": "simclr",
|
||||||
|
"simclr": {"resume": "a.pth", "checkpoint": "b.pth"},
|
||||||
|
"dataset": {"name": "unlabeled", "root": str(tiny_image_dir)}},
|
||||||
|
torch.device("cpu"),
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_cluster_script_simclr_branch_end_to_end(tiny_image_dir, tmp_path, monkeypatch):
|
def test_cluster_script_simclr_branch_end_to_end(tiny_image_dir, tmp_path, monkeypatch):
|
||||||
"""method=simclr 全流程:训练 -> 存权重 -> 抽特征 -> 聚类 -> 产出。"""
|
"""method=simclr 全流程:训练 -> 存权重 -> 抽特征 -> 聚类 -> 产出。"""
|
||||||
sys.path.insert(0, "scripts")
|
sys.path.insert(0, "scripts")
|
||||||
|
|||||||
Reference in New Issue
Block a user