| """Tests for the evaluation package. |
| |
| These cover the pure functions that used to live in the root ``eval_*.py`` |
| scripts, where they were exercised only by running a full evaluation against a |
| trained checkpoint -- so a wrong reduction or a dropped payload key went |
| unnoticed until it silently changed a reported number. |
| """ |
|
|
| import json |
| import math |
|
|
| import numpy as np |
| import pytest |
| import torch |
| import torch.nn as nn |
|
|
| from affmae.eval.fpw import ( |
| FpwParams, |
| aggregate_results, |
| json_safe, |
| parse_grid_size, |
| summarize_across_seeds, |
| ) |
| from affmae.eval.loader import ( |
| _extract_state_dict, |
| amp_dtype_for, |
| load_state_dict_into, |
| resolve_checkpoint, |
| ) |
|
|
|
|
| class _Cfg: |
| """Minimal stand-in for a loaded Config.""" |
|
|
| def __init__(self, **kwargs): |
| self.__dict__.update(kwargs) |
|
|
|
|
| class TestParseGridSize: |
| def test_square_from_one_number(self): |
| assert parse_grid_size("512") == (512, 512) |
|
|
| def test_width_and_height(self): |
| assert parse_grid_size("1024,768") == (1024, 768) |
|
|
| @pytest.mark.parametrize("bad", ["", "1,2,3", "abc"]) |
| def test_rejects_anything_else(self, bad): |
| with pytest.raises(ValueError): |
| parse_grid_size(bad) |
|
|
|
|
| class TestFpwParams: |
| def test_is_frozen(self): |
| """Thresholds are shared across seeds; mutating one must not leak.""" |
| params = FpwParams() |
| with pytest.raises(Exception): |
| params.pgbmi_class = 5 |
|
|
| def test_defaults_match_the_documented_cli(self): |
| params = FpwParams() |
| assert (params.pgbmi_class, params.slit_class) == (1, 2) |
| assert params.eval_grid_size == (1024, 1024) |
|
|
|
|
| class TestJsonSafe: |
| def test_non_finite_floats_become_null(self): |
| """json.dump writes NaN, which is not valid JSON and breaks readers.""" |
| out = json_safe({"a": float("nan"), "b": float("inf"), |
| "c": float("-inf"), "d": 1.5}) |
| assert out == {"a": None, "b": None, "c": None, "d": 1.5} |
| json.dumps(out) |
|
|
| def test_numpy_scalars_become_python(self): |
| out = json_safe({"i": np.int64(3), "f": np.float32(0.5)}) |
| assert out == {"i": 3, "f": 0.5} |
| assert type(out["i"]) is int |
|
|
| def test_recurses_through_lists_and_tuples(self): |
| assert json_safe((1, [np.int64(2), {"x": np.float64(3.0)}])) == \ |
| [1, [2, {"x": 3.0}]] |
|
|
|
|
| class TestSummarizeAcrossSeeds: |
| @staticmethod |
| def _run(seed, match_rate): |
| return {"seed": seed, "summary": {"segment_match_rate": match_rate}} |
|
|
| def test_pools_mean_and_population_std(self): |
| out = summarize_across_seeds( |
| [self._run(1, 0.2), self._run(2, 0.4), self._run(3, 0.6)]) |
| assert out["num_seeds"] == 3 |
| assert out["seeds"] == [1, 2, 3] |
| assert out["segment_match_rate"]["count"] == 3 |
| assert out["segment_match_rate"]["mean"] == pytest.approx(0.4) |
| |
| |
| assert out["segment_match_rate"]["std"] == pytest.approx( |
| np.std([0.2, 0.4, 0.6], ddof=0)) |
|
|
| def test_drops_non_finite_seeds_instead_of_poisoning_the_mean(self): |
| out = summarize_across_seeds( |
| [self._run(1, 0.5), self._run(2, float("nan"))]) |
| assert out["segment_match_rate"]["count"] == 1 |
| assert out["segment_match_rate"]["mean"] == pytest.approx(0.5) |
|
|
| def test_all_non_finite_reports_nan_not_a_crash(self): |
| out = summarize_across_seeds([self._run(1, float("nan"))]) |
| assert out["segment_match_rate"]["count"] == 0 |
| assert math.isnan(out["segment_match_rate"]["mean"]) |
|
|
| def test_reads_nested_metric_blocks(self): |
| """The metric arrives as {count, mean, std} rather than a scalar.""" |
| runs = [{"seed": s, "summary": {"fpw_mean_abs_error": {"mean": v, "count": 1}}} |
| for s, v in ((1, 2.0), (2, 4.0))] |
| assert summarize_across_seeds(runs)["fpw_mean_abs_error"]["mean"] == ( |
| pytest.approx(3.0)) |
|
|
|
|
| class TestAggregateResults: |
| @staticmethod |
| def _image(n_gt, n_pred, matched, segments=()): |
| return {"num_gt_segments": n_gt, "num_pred_segments": n_pred, |
| "num_matched_segments": matched, |
| "num_unmatched_gt_segments": n_gt - matched, |
| "num_unmatched_pred_segments": n_pred - matched, |
| "num_insufficient_slit_pairs": 0, |
| "segments": list(segments)} |
|
|
| def test_match_rate_is_matched_over_ground_truth(self): |
| out = aggregate_results([self._image(4, 3, 2), self._image(6, 6, 3)]) |
| assert out["num_gt_segments"] == 10 |
| assert out["segment_match_rate"] == pytest.approx(5 / 10) |
|
|
| def test_no_ground_truth_gives_nan_not_a_zero_division(self): |
| out = aggregate_results([self._image(0, 2, 0)]) |
| assert math.isnan(out["segment_match_rate"]) |
|
|
| def test_pools_per_segment_metrics_across_images(self): |
| out = aggregate_results([ |
| self._image(1, 1, 1, [{"fpw_mean_abs_error": 1.0}]), |
| self._image(1, 1, 1, [{"fpw_mean_abs_error": 3.0}]), |
| ]) |
| assert out["fpw_mean_abs_error"]["count"] == 2 |
| assert out["fpw_mean_abs_error"]["mean"] == pytest.approx(2.0) |
|
|
| def test_only_foot_process_width_is_reported(self): |
| """Frechet, Chamfer and slit-count error were reported beside it. |
| |
| Four numbers per segment invited quoting whichever looked best, and only |
| width is the clinical quantity. Their absence is the point of this test. |
| """ |
| out = aggregate_results([self._image(1, 1, 1, [{"fpw_mean_abs_error": 1.0}])]) |
| blocks = {key for key, value in out.items() |
| if isinstance(value, dict) and "mean" in value} |
| assert blocks == {"fpw_mean_abs_error"}, ( |
| f"unexpected metric blocks in the summary: " |
| f"{sorted(blocks - {'fpw_mean_abs_error'})}") |
|
|
|
|
| class TestCheckpointResolution: |
| def test_defaults_to_the_run_directory(self): |
| cfg = _Cfg(output_dir="/out", name="run") |
| assert resolve_checkpoint(cfg) == "/out/run/last_model.pth" |
|
|
| def test_seed_suffixes_the_run_directory(self): |
| cfg = _Cfg(output_dir="/out", name="run") |
| assert resolve_checkpoint(cfg, seed=77) == "/out/run_seed77/last_model.pth" |
|
|
| def test_explicit_path_wins(self): |
| cfg = _Cfg(output_dir="/out", name="run") |
| assert resolve_checkpoint(cfg, "/tmp/a.pth") == "/tmp/a.pth" |
|
|
| def test_seed_is_formatted_into_an_explicit_template(self): |
| cfg = _Cfg(output_dir="/out", name="run") |
| assert resolve_checkpoint(cfg, "/tmp/s{seed}.pth", seed=3) == "/tmp/s3.pth" |
|
|
|
|
| class TestStateDictExtraction: |
| """Training runs saved weights under four different keys over time.""" |
|
|
| @pytest.mark.parametrize("wrapper", ["model_state_dict", "model", |
| "state_dict"]) |
| def test_unwraps_every_payload_key(self, wrapper): |
| state = _extract_state_dict({wrapper: {"weight": torch.zeros(2)}}) |
| assert list(state) == ["weight"] |
|
|
| def test_accepts_a_bare_state_dict(self): |
| assert list(_extract_state_dict({"weight": torch.zeros(2)})) == ["weight"] |
|
|
| def test_strips_the_ddp_module_prefix(self): |
| state = _extract_state_dict({"module.weight": torch.zeros(2)}) |
| assert list(state) == ["weight"] |
|
|
| def test_drops_rebuilt_position_tables(self): |
| """pre_table is rebuilt from the config, and a stale copy can disagree |
| about width, so loading one is worse than missing it.""" |
| state = _extract_state_dict( |
| {"weight": torch.zeros(2), "blocks.0.pre_table": torch.zeros(9)}) |
| assert list(state) == ["weight"] |
|
|
| def test_load_reports_a_missing_file_by_path(self, tmp_path): |
| with pytest.raises(FileNotFoundError, match="no checkpoint at"): |
| load_state_dict_into(nn.Linear(2, 2), str(tmp_path / "nope.pth")) |
|
|
| def test_round_trips_through_a_file(self, tmp_path): |
| source = nn.Linear(3, 2) |
| path = tmp_path / "ckpt.pth" |
| torch.save({"model_state_dict": {f"module.{k}": v for k, v |
| in source.state_dict().items()}}, path) |
| target = nn.Linear(3, 2) |
| load_state_dict_into(target, str(path)) |
| assert torch.equal(target.weight, source.weight) |
| assert torch.equal(target.bias, source.bias) |
|
|
|
|
| class TestAmpDtype: |
| @pytest.mark.parametrize("name,expected", [ |
| ("float16", torch.float16), ("fp16", torch.float16), |
| ("bfloat16", torch.bfloat16), ("bf16", torch.bfloat16)]) |
| def test_accepted_names(self, name, expected): |
| assert amp_dtype_for(_Cfg(amp_dtype=name)) is expected |
|
|
| def test_defaults_to_fp16(self): |
| assert amp_dtype_for(_Cfg()) is torch.float16 |
|
|
| def test_rejects_an_unknown_name(self): |
| with pytest.raises(ValueError, match="unsupported amp_dtype"): |
| amp_dtype_for(_Cfg(amp_dtype="float8")) |
|
|