Files
ModelTrainEval/tests/test_simclr.py
T
Shaun ZhouandClaude Opus 5 004d7a1ce7 fix(simclr): 换优化器种类时不再加载不兼容的动量缓冲
adamw 换成 sgd 时 load_state_dict 不会报错,SGD 会收下 AdamW 的 exp_avg/exp_avg_sq
却永远用不上——动量实际从零开始,但状态里白占内存,之后存盘还会一路带下去。
改为检测到优化器种类变化就跳过加载并给出日志,模型权重与训练进度不受影响。

README 补一张表,说明续训时哪些配置按 yaml、哪些来自存档、哪些不能改。

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

549 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SimCLR 自监督:损失的数学性质 + 聚类脚本的 simclr 分支端到端。"""
import sys
import pytest
torch = pytest.importorskip("torch")
pytest.importorskip("PIL")
import numpy as np # noqa: E402
from PIL import Image # noqa: E402
from engine.simclr import SimCLRTrainer, nt_xent_loss # noqa: E402
from models.heads.projection import ProjectionHead # noqa: E402
from models.registry import BACKBONES # noqa: E402
def test_nt_xent_is_minimal_when_views_agree():
"""两个视图完全一致且各样本互相正交时,损失应接近理论下界 log(2B−1) 的反面——
即正样本对独占概率质量,损失趋近 0。"""
z = torch.eye(4) # 4 个互相正交的样本
aligned = nt_xent_loss(z, z.clone(), temperature=0.05)
misaligned = nt_xent_loss(z, z.roll(1, dims=0), temperature=0.05)
assert aligned < 0.1
assert misaligned > aligned
def test_nt_xent_upper_bound_at_chance_level():
"""所有表征相同时无法区分正负样本,损失 = log(2B−1)。"""
z = torch.ones(8, 16)
loss = float(nt_xent_loss(z, z.clone(), temperature=0.2))
assert loss == pytest.approx(float(torch.log(torch.tensor(2 * 8 - 1))), abs=1e-4)
def test_nt_xent_is_symmetric_and_temperature_sensitive():
torch.manual_seed(0)
z1, z2 = torch.randn(6, 8), torch.randn(6, 8)
assert float(nt_xent_loss(z1, z2, 0.2)) == pytest.approx(float(nt_xent_loss(z2, z1, 0.2)), abs=1e-5)
# 温度越低,随机表征的损失越大(softmax 更尖,惩罚更重)
assert nt_xent_loss(z1, z2, 0.05) > nt_xent_loss(z1, z2, 0.5)
def test_nt_xent_gradient_pulls_positive_pair_together():
torch.manual_seed(0)
z1 = torch.randn(4, 8, requires_grad=True)
z2 = torch.randn(4, 8, requires_grad=True)
before = float(torch.nn.functional.cosine_similarity(z1, z2).mean().detach())
optimizer = torch.optim.SGD([z1, z2], lr=1.0)
for _ in range(20):
optimizer.zero_grad()
nt_xent_loss(z1, z2, 0.2).backward()
optimizer.step()
after = float(torch.nn.functional.cosine_similarity(z1, z2).mean().detach())
assert after > before
def test_projection_head_accepts_feature_map_and_vector():
head = ProjectionHead(16, hidden_channels=32, out_channels=8)
assert head(torch.randn(4, 16, 5, 5)).shape == (4, 8)
assert head(torch.randn(4, 16)).shape == (4, 8)
@pytest.fixture
def tiny_image_dir(tmp_path):
root = tmp_path / "imgs"
root.mkdir()
rng = np.random.default_rng(0)
for i in range(8):
Image.fromarray(rng.integers(0, 255, (40, 60, 3), dtype=np.uint8)).save(root / f"{i}.png")
return root
def test_simclr_trainer_runs_and_saves_backbone_only(tiny_image_dir, tmp_path):
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,
view_transforms=[{"name": "RandomResizedCrop", "size": [32, 32], "scale": [0.5, 1.0]}],
)
loader = DataLoader(dataset, batch_size=4, shuffle=True, drop_last=True, collate_fn=unlabeled_collate)
backbone = BACKBONES.build({"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]})
trainer = SimCLRTrainer(
backbone,
loader,
{"epochs": 2, "amp": False, "log_interval": 100, "projection": {"hidden_channels": 16,
"out_channels": 8}},
torch.device("cpu"),
tmp_path / "run",
)
path = trainer.fit()
assert path.exists()
state = torch.load(path, map_location="cpu", weights_only=False)
# 只存骨架,不含投影头
assert all("projector" not in key and "mlp" not in key for key in state["model"])
backbone.load_state_dict(state["model"]) # 能被干净地加载回去
def test_simclr_logs_metrics_to_swanlab(tiny_image_dir, tmp_path, monkeypatch):
"""SwanLab 接线:按 step 记 loss/lr,按 epoch 记平均 loss、耗时与显存峰值,结束时收尾。"""
from torch.utils.data import DataLoader
import engine.simclr as simclr_mod
from datasets.unlabeled import UnlabeledImageDataset, unlabeled_collate
records: list[tuple[dict, int | None, str]] = []
finished: list[bool] = []
class StubLogger:
def __init__(self, **kwargs):
self.kwargs = kwargs
def log(self, metrics, step=None, prefix=""):
records.append((metrics, step, prefix))
def finish(self):
finished.append(True)
monkeypatch.setattr(simclr_mod, "SwanLabLogger", StubLogger)
dataset = UnlabeledImageDataset(
tiny_image_dir,
transforms=[{"name": "Resize", "size": [32, 32]}, {"name": "ToTensor"}],
views=2,
)
loader = DataLoader(dataset, batch_size=4, shuffle=True, drop_last=True, collate_fn=unlabeled_collate)
backbone = BACKBONES.build({"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]})
trainer = simclr_mod.SimCLRTrainer(
backbone,
loader,
{"epochs": 1, "amp": False, "log_interval": 1, "save_interval": 0,
"projection": {"hidden_channels": 16, "out_channels": 8},
"swanlab": {"enabled": True, "mode": "disabled"}},
torch.device("cpu"),
tmp_path / "run",
full_config={"method": "simclr"},
)
trainer.fit()
assert records, "没有任何指标被记录"
assert all(prefix == "simclr/" for _, _, prefix in records)
step_keys = {k for metrics, _, _ in records for k in metrics}
assert {"loss", "loss_avg", "lr"} <= step_keys
assert {"epoch", "epoch_loss", "epoch_seconds", "gpu_mem_peak_mb"} <= step_keys
assert all(step is not None and step > 0 for _, step, _ in records)
assert finished == [True] # fit() 结束时调用了 finish()
# 完整 yaml 配置作为超参传给了 SwanLab
assert trainer.visualizer.kwargs["config"] == {"method": "simclr"}
assert trainer.visualizer.kwargs["mode"] == "disabled"
def test_simclr_saves_intermediate_checkpoints(tiny_image_dir, tmp_path):
"""中途落盘:跑到一半被打断也要能拿到权重(此前只在全部 epoch 跑完后才存)。"""
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=True, drop_last=True, collate_fn=unlabeled_collate)
backbone = BACKBONES.build({"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]})
work_dir = tmp_path / "run"
trainer = SimCLRTrainer(
backbone,
loader,
{"epochs": 4, "amp": False, "log_interval": 100, "save_interval": 2,
"projection": {"hidden_channels": 16, "out_channels": 8}},
torch.device("cpu"),
work_dir,
)
# 手动走前两个 epoch,模拟"训练中途"
trainer.train_one_epoch(0)
trainer.train_one_epoch(1)
path = trainer.save_backbone(1)
assert path.exists()
assert torch.load(path, map_location="cpu", weights_only=False)["epochs"] == 2
trainer.fit() # 完整跑完后 epochs 应更新
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.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
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 计数接上
# 总轮数从 2 改成了 5,学习率按新计划重算(若沿用旧的 T_max,此处会是 0)
assert resumed.optimizer.param_groups[0]["lr"] > 1e-5
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_with_a_longer_schedule_rebuilds_the_lr_curve(tiny_image_dir, tmp_path):
"""续训时调大 epochs,学习率必须按新的总轮数重算。
回归测试:此前直接恢复调度器状态,会沿用存档里的 T_max。原计划 10 轮的 cosine
跑完后 lr 已经是 0,这时把 epochs 改成 30 接着练,lr 仍是 0——训练照跑但一步都学不动。
"""
import math
work_dir = tmp_path / "run"
first = _make_trainer(tiny_image_dir, work_dir, epochs=10)
first.fit()
assert first.optimizer.param_groups[0]["lr"] == pytest.approx(0.0, abs=1e-9) # cosine 已走完
resumed = SimCLRTrainer(
BACKBONES.build({"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]}),
first.dataloader,
{"epochs": 30, "amp": False, "log_interval": 999, "save_interval": 1,
"scheduler": {"name": "cosine"}, "optimizer": {"name": "adamw", "lr": 1e-3},
"projection": {"hidden_channels": 16, "out_channels": 8},
"swanlab": {"enabled": False}},
torch.device("cpu"),
work_dir,
resume=work_dir / "simclr_last.pth",
)
assert resumed.scheduler.T_max == 30
lr = resumed.optimizer.param_groups[0]["lr"]
assert lr > 1e-5, "续训后学习率仍是 0,等于白跑"
assert lr == pytest.approx(1e-3 * (1 + math.cos(math.pi * 10 / 30)) / 2, rel=1e-3)
def test_resume_takes_hyperparams_from_config_not_checkpoint(tiny_image_dir, tmp_path):
"""续训时 lr / weight_decay / 总轮数一律以 yaml 为准,存档只提供进度。
回归测试:optimizer.load_state_dict 会整体覆盖 param_groups,把超参连同动量缓冲
一起还原成存档时的值——改了配置再续训,改动被静默吃掉。
"""
import math
work_dir = tmp_path / "run"
first = _make_trainer(
tiny_image_dir, work_dir, epochs=4,
optimizer={"name": "adamw", "lr": 1e-3, "weight_decay": 1e-4},
)
first.fit()
resumed = SimCLRTrainer(
BACKBONES.build({"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]}),
first.dataloader,
{"epochs": 8, "amp": False, "log_interval": 999, "save_interval": 1,
"scheduler": {"name": "cosine"},
"optimizer": {"name": "adamw", "lr": 5e-4, "weight_decay": 0.05},
"projection": {"hidden_channels": 16, "out_channels": 8},
"swanlab": {"enabled": False}},
torch.device("cpu"),
work_dir,
resume=work_dir / "simclr_last.pth",
)
groups = resumed.optimizer.param_groups
assert groups[0]["initial_lr"] == pytest.approx(5e-4) # 不是存档里的 1e-3
assert groups[0]["weight_decay"] == pytest.approx(0.05) # 不是存档里的 1e-4
assert groups[1]["weight_decay"] == 0.0 # BN/bias 分组结构没被冲掉
assert resumed.scheduler.base_lrs == pytest.approx([5e-4, 5e-4])
assert resumed.scheduler.T_max == 8
assert groups[0]["lr"] == pytest.approx(5e-4 * (1 + math.cos(math.pi * 4 / 8)) / 2)
assert resumed.optimizer.state, "动量缓冲应当保留——那才是真正要续的'进度'"
def test_resume_across_optimizer_types_drops_incompatible_state(tiny_image_dir, tmp_path):
"""换优化器种类时不加载动量缓冲:AdamW 的 exp_avg 对 SGD 没有意义,硬塞只是白占内存。"""
work_dir = tmp_path / "run"
_make_trainer(
tiny_image_dir, work_dir, epochs=2,
optimizer={"name": "adamw", "lr": 1e-3, "weight_decay": 1e-4},
).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": 6, "amp": False, "log_interval": 999, "save_interval": 1,
"scheduler": {"name": "cosine"},
"optimizer": {"name": "sgd", "lr": 0.01, "momentum": 0.9},
"projection": {"hidden_channels": 16, "out_channels": 8},
"swanlab": {"enabled": False}},
torch.device("cpu"),
work_dir,
resume=work_dir / "simclr_last.pth",
)
assert isinstance(resumed.optimizer, torch.optim.SGD)
assert not resumed.optimizer.state, "不该带着 AdamW 的缓冲"
assert resumed.start_epoch == 2 # 训练进度仍然接上
assert resumed.optimizer.param_groups[0]["momentum"] == 0.9
def test_resume_with_same_schedule_restores_state(tiny_image_dir, tmp_path):
"""总轮数不变时,重建 + 快进的结果应与原调度进度一致。"""
work_dir = tmp_path / "run"
_make_trainer(tiny_image_dir, work_dir, epochs=6).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": 6, "amp": False, "log_interval": 999, "save_interval": 1,
"scheduler": {"name": "cosine"},
"projection": {"hidden_channels": 16, "out_channels": 8},
"swanlab": {"enabled": False}},
torch.device("cpu"),
work_dir,
resume=work_dir / "simclr_last.pth",
)
assert resumed.scheduler.T_max == 6
assert resumed.scheduler.last_epoch == 6
def test_intermediate_checkpoint_is_resumable(tiny_image_dir, tmp_path):
"""save_interval 落的中间盘必须能直接拿来续训(不必等训练正常结束)。"""
work_dir = tmp_path / "run"
trainer = _make_trainer(tiny_image_dir, work_dir, epochs=10)
# 只手动跑到第 4 轮就落盘,模拟被 kill 掉
for epoch in range(4):
trainer.train_one_epoch(epoch)
trainer.scheduler.step()
trainer._checkpoint(3)
resumed = SimCLRTrainer(
BACKBONES.build({"name": "tiny_conv", "widths": [8, 16], "depths": [1, 1]}),
trainer.dataloader,
{"epochs": 10, "amp": False, "log_interval": 999, "save_interval": 1,
"scheduler": {"name": "cosine"},
"projection": {"hidden_channels": 16, "out_channels": 8},
"swanlab": {"enabled": False}},
torch.device("cpu"),
work_dir,
resume=work_dir / "simclr_last.pth",
)
assert resumed.start_epoch == 4
resumed.fit()
state = torch.load(work_dir / "simclr_last.pth", map_location="cpu", weights_only=False)
assert state["epoch"] == 9
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):
"""method=simclr 全流程:训练 -> 存权重 -> 抽特征 -> 聚类 -> 产出。"""
sys.path.insert(0, "scripts")
import cluster_unlabeled as cu
work_dir = tmp_path / "run"
monkeypatch.setattr(
sys, "argv",
["cluster_unlabeled.py", "--config", "configs/cluster/film.yaml",
"--work-dir", str(work_dir), "--root", str(tiny_image_dir), "--set",
"device=cpu", "method=simclr", "loader.num_workers=0", "loader.batch_size=4",
"simclr.epochs=1", "simclr.batch_size=4", "simclr.amp=False",
"simclr.backbone.widths=[8,16]", "simclr.backbone.depths=[1,1]",
"simclr.projection.hidden_channels=16", "simclr.projection.out_channels=8",
"num_clusters=2", "n_init=2"],
)
assert cu.main() == 0
assert (work_dir / "simclr_backbone.pth").exists()
assert (work_dir / "assignments.csv").exists()
assert (work_dir / "summary.json").exists()
assert (work_dir / "clusters" / "cluster_00").is_dir()
def test_scatter_plot_is_written_next_to_previews(tiny_image_dir, tmp_path, monkeypatch):
"""cluster_scatter.png 要和 preview 拼图落在同一级目录。"""
sys.path.insert(0, "scripts")
import cluster_unlabeled as cu
work_dir = tmp_path / "run"
monkeypatch.setattr(
sys, "argv",
["cluster_unlabeled.py", "--config", "configs/cluster/film.yaml",
"--work-dir", str(work_dir), "--root", str(tiny_image_dir), "--set",
"device=cpu", "loader.num_workers=0", "loader.batch_size=4",
"num_clusters=2", "n_init=2"],
)
assert cu.main() == 0
scatter = work_dir / "cluster_scatter.png"
assert scatter.exists() and scatter.stat().st_size > 5000
assert (work_dir / "cluster_00_preview.png").parent == scatter.parent
import json
summary = json.loads((work_dir / "summary.json").read_text(encoding="utf-8"))
matrix = summary["centroid_distances"]
assert len(matrix) == 2 and matrix[0][0] == pytest.approx(0.0)
assert matrix[0][1] == pytest.approx(matrix[1][0])
def test_scatter_can_be_disabled(tiny_image_dir, tmp_path, monkeypatch):
sys.path.insert(0, "scripts")
import cluster_unlabeled as cu
work_dir = tmp_path / "run_off"
monkeypatch.setattr(
sys, "argv",
["cluster_unlabeled.py", "--config", "configs/cluster/film.yaml",
"--work-dir", str(work_dir), "--root", str(tiny_image_dir), "--set",
"device=cpu", "loader.num_workers=0", "loader.batch_size=4",
"num_clusters=2", "n_init=2", "scatter.enabled=False"],
)
assert cu.main() == 0
assert not (work_dir / "cluster_scatter.png").exists()
assert (work_dir / "cluster_00_preview.png").exists()
def test_simclr_runs_on_the_resolved_device(tiny_image_dir, tmp_path, monkeypatch):
"""method=simclr 在本机自动选中的后端上(MPS / CUDA / CPU)都应能跑通。"""
sys.path.insert(0, "scripts")
import cluster_unlabeled as cu
from utils.device import describe_device, resolve_device
device = resolve_device("auto")
work_dir = tmp_path / "auto_run"
monkeypatch.setattr(
sys, "argv",
["cluster_unlabeled.py", "--config", "configs/cluster/film.yaml",
"--work-dir", str(work_dir), "--root", str(tiny_image_dir), "--set",
"method=simclr", "loader.num_workers=0", "loader.batch_size=4",
"simclr.epochs=1", "simclr.batch_size=4",
"simclr.backbone.widths=[8,16]", "simclr.backbone.depths=[1,1]",
"simclr.projection.hidden_channels=16", "simclr.projection.out_channels=8",
"num_clusters=2", "n_init=2"],
)
assert cu.main() == 0, f"在 {describe_device(device)} 上跑失败"
assert (work_dir / "simclr_backbone.pth").exists()