feat: modernize PSO and add convergence research

Migrate the package and examples to the tensor-native PyTorch implementation, add benchmark evidence, and add the guarded post-training convergence protocol with TensorBoard progress monitoring and hash-verified recovery.

Constraint: Preserve one-shot official-test sealing and auditable research artifacts

Rejected: Commit local .omc runs and downloaded datasets | multi-gigabyte runtime state is machine-local

Confidence: high

Scope-risk: broad

Not-tested: Production CUDA run on pieroot-server
This commit is contained in:
2026-09-07 22:03:25 +09:00
parent 9745bb7ad4
commit 813433000a
120 changed files with 198148 additions and 4745 deletions
@@ -0,0 +1,331 @@
"""Offline behavioral tests for the pinned VOC/YOLO convergence adapter."""
from __future__ import annotations
import builtins
import json
import sys
from pathlib import Path
import numpy as np
import pytest
import torch
from torch import nn
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from test import post_training_yolo_convergence as study
from test.post_training_model_convergence import SealError, StudyConfig, prepare_run
def _record(index: int, *, fingerprint: str | None = None) -> study.VOCRecord:
"""Build a cheap, label-complete synthetic record for manifest tests."""
labels = tuple((class_id, 0.5, 0.5, 0.25, 0.25) for class_id in range(20))
return study.VOCRecord(
year="2007" if index % 2 == 0 else "2012",
image_id=f"item-{index:05d}",
image_path=f"/synthetic/{index}.jpg",
annotation_path=f"/synthetic/{index}.xml",
width=640,
height=480,
labels=labels,
difficult_excluded=0,
fingerprint=fingerprint or f"{index:064x}",
)
def test_optional_detection_imports_are_lazy(monkeypatch: pytest.MonkeyPatch) -> None:
"""Importing the adapter stays safe when optional detection packages are absent."""
real_import = builtins.__import__
def block_ultralytics(name, *args, **kwargs):
if name == "ultralytics":
raise ImportError("blocked optional dependency")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", block_ultralytics)
with pytest.raises(study.YoloProtocolError, match="Ultralytics is required"):
study._ultralytics()
def block_ensemble_boxes(name, *args, **kwargs):
if name == "ensemble_boxes":
raise ImportError("blocked optional dependency")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", block_ensemble_boxes)
with pytest.raises(study.YoloProtocolError, match="ensemble-boxes is required"):
study._wbf()
def test_parse_voc_xml_excludes_difficult_and_uses_pinned_coordinates(tmp_path: Path) -> None:
Image = pytest.importorskip("PIL.Image")
image_path = tmp_path / "sample.jpg"
Image.new("RGB", (20, 20), (10, 20, 30)).save(image_path)
xml_path = tmp_path / "sample.xml"
xml_path.write_text(
"""<annotation>
<size><width>20</width><height>20</height><depth>3</depth></size>
<object><name>cat</name><difficult>0</difficult>
<bndbox><xmin>1</xmin><ymin>2</ymin><xmax>9</xmax><ymax>10</ymax></bndbox>
</object>
<object><name>dog</name><difficult>1</difficult>
<bndbox><xmin>0</xmin><ymin>0</ymin><xmax>19</xmax><ymax>19</ymax></bndbox>
</object>
</annotation>""",
encoding="utf-8",
)
record = study.parse_voc_xml(xml_path, image_path, year="2007", image_id="sample")
assert record.width == 20 and record.height == 20
assert record.difficult_excluded == 1
assert record.labels == ((7, 0.2, 0.25, 0.4, 0.4),)
assert record.fingerprint == study.image_fingerprint(image_path)
def test_duplicate_grouping_keeps_group_members_together() -> None:
duplicate_a = _record(0, fingerprint="same")
duplicate_b = _record(1, fingerprint="same")
unique = _record(2, fingerprint="unique")
ordered, groups = study._assign_duplicate_groups(
[duplicate_a, duplicate_b, unique], seed=20260908
)
assert groups["same"] == tuple(f"{item.year}:{item.image_id}" for item in ordered if item.fingerprint == "same")
same_positions = [index for index, item in enumerate(ordered) if item.fingerprint == "same"]
assert same_positions == list(range(min(same_positions), max(same_positions) + 1))
assert {item.image_id for item in ordered} == {"item-00000", "item-00001", "item-00002"}
def test_manifest_has_exact_disjoint_partitions_and_objective_prefix() -> None:
records = [_record(index) for index in range(16551)]
manifest = study.make_voc_manifests(records, seed=20260908)
assert manifest.counts == {
"bp_train": study.BP_COUNT,
"refine_search": study.REFINE_COUNT,
"selection_val": study.SELECTION_COUNT,
}
partitions = (manifest.bp_train, manifest.refine_search, manifest.selection_val)
keys = [
{f"{item.year}:{item.image_id}" for item in partition}
for partition in partitions
]
assert [len(partition) for partition in partitions] == [11551, 2500, 2500]
assert not (keys[0] & keys[1] or keys[0] & keys[2] or keys[1] & keys[2])
assert manifest.objective_keys == tuple(
(item.year, item.image_id) for item in manifest.refine_search[: study.OBJECTIVE_COUNT]
)
for partition in partitions:
assert {label[0] for item in partition for label in item.labels} == set(range(20))
def test_letterbox_box_round_trip_preserves_original_coordinates() -> None:
original = np.array([[10.0, 5.0, 190.0, 95.0, 0.87]], dtype=np.float64)
ratio_pad = (3.2, (0.0, 160.0)) # 200x100 image letterboxed to 640x640
letterboxed = study.transform_boxes_to_letterbox(original, ratio_pad=ratio_pad)
restored = study.transform_boxes_to_original(
letterboxed, ratio_pad=ratio_pad, shape=(100, 200)
)
assert np.allclose(restored, original, atol=1e-12)
assert np.allclose(letterboxed[0, :4], [32.0, 176.0, 608.0, 464.0])
clipped = study.transform_boxes_to_original(
np.array([[-10.0, 150.0, 650.0, 500.0]]),
ratio_pad=ratio_pad,
shape=(100, 200),
)
assert np.array_equal(clipped, np.array([[0.0, 0.0, 200.0, 100.0]]))
def test_native_target_uses_non_square_letterbox_geometry() -> None:
record = study.VOCRecord(
year="2007",
image_id="wide",
image_path="/synthetic/wide.jpg",
annotation_path="/synthetic/wide.xml",
width=200,
height=100,
labels=((0, 0.5, 0.5, 0.5, 0.5),),
difficult_excluded=0,
fingerprint="a" * 64,
)
target = study._native_target(
record,
index=3,
ratio_pad=(3.2, (0.0, 160.0)),
)
assert target["batch_idx"].tolist() == [3]
assert target["cls"].tolist() == [[0.0]]
assert np.allclose(
target["bboxes"].numpy(),
np.array([[0.5, 0.5, 0.5, 0.25]]),
)
def test_wbf_uses_normalized_weights_and_stable_score_order(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, object] = {}
def fake_fusion(boxes, scores, labels, **kwargs):
captured["weights"] = kwargs["weights"]
captured["kwargs"] = kwargs
return (
[[0.1, 0.1, 0.2, 0.2], [0.3, 0.3, 0.4, 0.4], [0.5, 0.5, 0.6, 0.6]],
[0.20, 0.90, 0.50],
[2, 1, 0],
)
monkeypatch.setattr(study, "_wbf", lambda: fake_fusion)
result = study.weighted_box_fusion(
[
{"boxes": np.array([[0.1, 0.1, 0.2, 0.2]]), "scores": [0.8], "labels": [2]},
{"boxes": np.array([[0.3, 0.3, 0.4, 0.4]]), "scores": [0.7], "labels": [1]},
],
[2.0, 6.0],
)
assert captured["weights"] == pytest.approx([0.25, 0.75])
assert sum(captured["weights"]) == pytest.approx(1.0)
assert captured["kwargs"]["iou_thr"] == 0.55
assert result["scores"].tolist() == [0.90, 0.50, 0.20]
assert result["labels"].tolist() == [1, 0, 2]
def test_wbf_pso_and_random_have_exact_12x20_query_accounting(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[float, ...]] = []
def tiny_metric(member_predictions, targets, weights):
values = tuple(float(value) for value in weights)
calls.append(values)
assert sum(values) == pytest.approx(1.0)
return {"map50_95": values[0]}
monkeypatch.setattr(study, "_wbf_dataset_metrics", tiny_metric)
targets = [{"image_id": "a"}, {"image_id": "b"}]
members = [[{} for _ in targets] for _ in range(3)]
pso = study.run_wbf_weight_search(members, targets, seed=601, random_mode=False)
random_result = study.run_wbf_weight_search(members, targets, seed=601, random_mode=True)
assert pso["method"] == "ensemble_pso"
assert random_result["method"] == "ensemble_random"
for result in (pso, random_result):
assert result["queries"] == 12 * 20
assert result["sample_evaluations"] == 12 * 20 * len(targets)
assert len(result["trajectory"]) == 20
assert sum(result["weights"]) == pytest.approx(1.0)
assert all(0.0 <= weight <= 1.0 for weight in result["weights"])
assert len(calls) == 2 * 12 * 20
class C3k2(nn.Module):
pass
class Detect(nn.Module):
def __init__(self, nc: int = 20) -> None:
super().__init__()
self.nc = nc
self.cv2 = nn.ModuleList([nn.Sequential(nn.Linear(1, 42)) for _ in range(3)])
self.cv3 = nn.ModuleList([nn.Sequential(nn.Linear(1, 42)) for _ in range(3)])
class WrongDetect(Detect):
pass
class WrongBlock(nn.Module):
pass
def _tiny_graph(*, block: nn.Module | None = None, detect: nn.Module | None = None) -> nn.Module:
graph = nn.Module()
graph.model = nn.ModuleList([nn.Identity() for _ in range(22)] + [block or C3k2(), detect or Detect()])
return graph
@pytest.mark.parametrize(
("graph", "message"),
[
(nn.Module(), "shorter than"),
(_tiny_graph(block=WrongBlock()), "expected model.22 C3k2"),
(_tiny_graph(detect=WrongDetect()), "expected model.23 Detect"),
(_tiny_graph(detect=Detect(nc=19)), "expected Detect.nc=20"),
],
)
def test_topology_guard_rejects_tiny_mismatched_graphs(graph: nn.Module, message: str) -> None:
if not hasattr(graph, "model"):
graph.model = nn.ModuleList([nn.Identity(), nn.Identity()])
with pytest.raises(study.YoloProtocolError, match=message):
study.assert_yolo_topology(graph)
def test_official_test_loader_refuses_before_frozen_confirmation(tmp_path: Path) -> None:
run_root = tmp_path / "run"
data_root = tmp_path / "data"
state = prepare_run(run_root, StudyConfig(device="cpu"))
with pytest.raises(SealError, match="sealed until confirm phase"):
study.guarded_voc_test_loader(data_root, run_root, confirmation=False)
assert state.state.value == "prepared"
assert not (data_root / "VOCdevkit").exists()
with pytest.raises(SealError, match="sealed until frozen confirmation"):
study.VOCTestGuard(str(run_root), "", False).require_open()
def test_native_baseline_reuse_verifies_complete_epoch_artifacts(
tmp_path: Path,
) -> None:
run_root = tmp_path / "run"
baseline_root = (
run_root
/ "workloads"
/ study.WORKLOAD_ID
/ "baselines"
/ "501"
)
baseline_root.mkdir(parents=True)
checkpoint = baseline_root / "ema_fp32.pt"
torch.save({"weight": torch.ones(2)}, checkpoint)
results = (
run_root
/ "ultralytics"
/ "base-501-100e"
/ "results.csv"
)
results.parent.mkdir(parents=True)
results.write_text(
"epoch,train/loss\n"
+ "".join(f"{epoch},{1 / epoch}\n" for epoch in range(1, 101)),
encoding="utf-8",
)
marker = {
"protocol_version": study.PROTOCOL_VERSION,
"source_run": "source",
"checkpoint_hash": study.fingerprint_file(checkpoint),
"results_hash": study.fingerprint_file(results),
}
(baseline_root / "baseline_reuse.json").write_text(
json.dumps(marker),
encoding="utf-8",
)
reused = study._reused_native_baseline(
run_root,
study.StrictScratchTrainer(device="cpu"),
501,
)
assert reused is not None
assert reused["reused"] is True
assert len(reused["telemetry"]) == 11
results.write_text("epoch,train/loss\n1,1\n", encoding="utf-8")
with pytest.raises(study.YoloProtocolError, match="reused baseline marker"):
study._reused_native_baseline(
run_root,
study.StrictScratchTrainer(device="cpu"),
501,
)