Boundary-Radial Persistence Loss / run_experiment.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
 1import json
 2import numpy as np
 3from boundary_radial import bars_from_radii, radial_loss, match_loss
 4
 5np.set_printoptions(precision=6, suppress=True)
 6
 7def fit_slope(x, y):
 8    return float(np.polyfit(np.asarray(x), np.asarray(y), 1)[0])
 9
10def main():
11    target = [3.0, 5.0]
12    # Prediction 1: identical boundary barcodes have exactly zero matching loss.
13    zero = radial_loss(target, target, unmatched=1.0)
14
15    # Prediction 2: a uniform radial displacement eps changes both endpoints,
16    # hence each matched circle costs 2|eps|, and two circles cost 4|eps|.
17    eps = np.linspace(0.0, 1.0, 11)
18    perturbed = [radial_loss([3.0 + e, 5.0 + e], target, unmatched=10.0)
19                 for e in eps]
20    slope = fit_slope(eps[1:], perturbed[1:])
21    max_abs_fit_error = float(np.max(np.abs(np.asarray(perturbed) - 4*eps)))
22
23    # Prediction 3: with an empty target and unmatched penalty lambda, each
24    # predicted interval contributes lambda, so the loss is q*lambda.
25    lambdas = [0.25, 0.5, 1.0, 2.0]
26    qs = [0, 1, 2, 3, 4]
27    unmatched_table = []
28    for lam in lambdas:
29        vals = [radial_loss([3.0 + 2*j for j in range(q)], [], unmatched=lam)
30                for q in qs]
31        unmatched_table.append(vals)
32    # Fit all nonzero q/lambda values to q*lambda.
33    observed = np.array(unmatched_table)
34    expected = np.array([[q*lam for q in qs] for lam in lambdas])
35    unmatched_max_error = float(np.max(np.abs(observed-expected)))
36
37    # Matching is order-invariant: a permutation of target intervals has zero cost.
38    permutation_loss = radial_loss([3.0, 5.0, 7.0], [7.0, 3.0, 5.0], unmatched=10.0)
39
40    # Small baseline-vs-idea proxy: noisy candidate boundaries. The standard
41    # endpoint MSE and persistence L1 both recover the true radial vector here;
42    # this intentionally reports a sanity comparison, not a CNN claim.
43    rng = np.random.default_rng(7)
44    true = np.array([3.0, 5.0, 7.0])
45    noisy = true + rng.normal(0, 0.30, size=true.shape)
46    baseline_endpoint_l1 = float(np.sum(np.abs(noisy-true)))
47    idea_bar_loss = radial_loss(noisy.tolist(), true.tolist(), unmatched=10.0)
48
49    result = {
50        "zero_loss": zero,
51        "perturbation_eps": eps.tolist(),
52        "perturbation_loss": perturbed,
53        "predicted_perturbation_slope": 4.0,
54        "observed_perturbation_slope": slope,
55        "perturbation_max_abs_fit_error": max_abs_fit_error,
56        "unmatched_lambdas": lambdas,
57        "unmatched_qs": qs,
58        "unmatched_observed": observed.tolist(),
59        "unmatched_expected": expected.tolist(),
60        "unmatched_max_abs_error": unmatched_max_error,
61        "permutation_loss": permutation_loss,
62        "noisy_radii": noisy.tolist(),
63        "baseline_endpoint_l1": baseline_endpoint_l1,
64        "idea_persistence_loss": idea_bar_loss,
65    }
66    print(json.dumps(result, indent=2))
67    with open("results.json", "w") as f:
68        json.dump(result, f, indent=2)
69
70    assert zero < 1e-12
71    assert abs(slope - 4.0) < 1e-8 and max_abs_fit_error < 1e-8
72    assert unmatched_max_error < 1e-12
73    assert permutation_loss < 1e-12
74
75if __name__ == "__main__":
76    main()