File size: 9,415 Bytes
e5a6e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
"""Create standalone scientific figures and audit actual inference artifacts."""
import json

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

from common import FIELDS, ROOT, load_config, output_dir, parser, save_json, sha256


def main():
    args = parser(__doc__).parse_args()
    cfg = load_config(args.config)
    out = output_dir(cfg)
    history = json.loads((out / "training_history.json").read_text())
    status = json.loads((out / "training_status.json").read_text())
    manifest = json.loads((out / "inference_manifest.json").read_text())
    metrics = json.loads((out / "metrics.json").read_text())
    assert status["completed_epochs"] == 300 and status["state"] == "completed"
    assert [r["epoch"] for r in history] == list(range(1, 301))
    assert all(np.isfinite(r["train_loss"]) and np.isfinite(r["val_mse"]) for r in history)
    selected = min(history, key=lambda r: r["val_mse"])
    assert selected["epoch"] == manifest["selected_epoch"] == status["best_epoch"]
    assert sha256(ROOT / cfg["paths"]["best_checkpoint"]) == manifest["sha256"]
    figures = []
    plt.rcParams.update({"font.size": 10, "figure.dpi": 140, "savefig.dpi": 180})
    fig, ax = plt.subplots(figsize=(7, 4))
    for key, label in [("train_step1_mse", "Train step 1"),
                       ("train_step5_mse", "Train step 5"), ("val_mse", "Validation step 1 + 5")]:
        ax.semilogy([r["epoch"] for r in history], [r[key] for r in history], label=label)
    ax.axvline(status["best_epoch"], color="0.6", lw=1, ls=":", label="Selected checkpoint")
    ax.set(xlabel="Epoch", ylabel="MSE in physical units", title="FluxNet-LAP: 300-epoch pushforward training")
    ax.legend()
    fig.tight_layout()
    fig.savefig(out / "training_curve.png")
    plt.close(fig)
    figures.append("training_curve.png")
    checks = {}
    for split in ("test", "test_long"):
        data = np.load(out / f"{split}.npz")
        expected_steps = cfg["data"]["expected_frames"][split] - 1
        assert data["mae"].shape == (50, expected_steps, 3)
        assert np.isfinite(data["mae"]).all()
        assert np.allclose(data["mae"].mean((0, 1)), metrics[split]["mae"], atol=1e-12)
        assert np.allclose(data["mae"][:, -1].mean(0), metrics[split]["mae_final"], atol=1e-12)
        assert float(data["negative_h_ratio"].mean()) == metrics[split]["negative_h_ratio"]
        assert np.allclose(data["conservation_absolute_sum"].max((0, 1)),
                           metrics[split]["conservation_absolute_sum_max"])
        fig, axes = plt.subplots(1, 3, figsize=(13, 3.6))
        for c, field in enumerate(FIELDS):
            axes[0].plot(data["time"], data["mae"].mean(0)[:, c], label=field)
            axes[1].plot(data["time"], data["conservation_absolute_sum"].max(0)[:, c], label=field)
        axes[0].set(xlabel="Time", ylabel="MAE", title="Autoregressive error")
        axes[1].set(xlabel="Time", ylabel="max |sum(u)-sum(u0)|", title="Conservation drift (float64 reduction)")
        axes[2].plot(data["time"], 100 * data["negative_h_ratio"].mean(0))
        axes[2].set(xlabel="Time", ylabel="Negative depth (%)", title="Depth bound violations")
        for ax in axes[:2]:
            ax.legend()
        fig.tight_layout()
        name = f"{split}_rollout_metrics.png"
        fig.savefig(out / name)
        figures.append(name)
        plt.close(fig)
        examples = np.load(out / f"{split}_examples.npz")
        for case in cfg["evaluation"]["example_cases"]:
            prediction = examples[case + "_prediction"]
            target = examples[case + "_target"]
            assert prediction.shape == target.shape == (expected_steps + 1, 3, 64, 64)
            assert np.array_equal(prediction[0], target[0])
            # Independent recomputation from saved physical fields.
            errors = np.abs(prediction[1:].astype(np.float64) - target[1:]).mean((0, 2, 3))
            file = str(examples[case + "_file"])
            index = list(data["files"]).index(file)
            assert np.allclose(errors, data["mae"][index].mean(0), atol=1e-12)
            frames = np.round(np.linspace(0, expected_steps, 6)).astype(int)
            for c, field in enumerate(FIELDS):
                fig, axes = plt.subplots(3, 6, figsize=(14, 7.3), layout="constrained")
                minimum = min(target[frames, c].min(), prediction[frames, c].min())
                maximum = max(target[frames, c].max(), prediction[frames, c].max())
                error_max = np.abs(prediction[frames, c] - target[frames, c]).max()
                for j, frame in enumerate(frames):
                    for r, values in enumerate((target, prediction)):
                        image = axes[r, j].imshow(values[frame, c], origin="lower", extent=(0, 10, 0, 10),
                                                 cmap="viridis", vmin=minimum, vmax=maximum)
                    err_image = axes[2, j].imshow(np.abs(prediction[frame, c] - target[frame, c]),
                                                 origin="lower", extent=(0, 10, 0, 10), cmap="magma",
                                                 vmin=0, vmax=max(float(error_max), 1e-12))
                    axes[0, j].set_title(f"t={examples['time'][frame]:.2f}")
                    for r in range(3):
                        axes[r, j].set_xticks([])
                        axes[r, j].set_yticks([])
                for r, label in enumerate(("Reference", "FluxNet-LAP", "Absolute error")):
                    axes[r, 0].set_ylabel(label)
                fig.colorbar(image, ax=axes[:2, :], shrink=0.75)
                fig.colorbar(err_image, ax=axes[2, :], shrink=0.75)
                fig.suptitle(f"{split} Case {case}: {field}")
                name = f"{split}_{case}_{field}.png"
                fig.savefig(out / name)
                figures.append(name)
                plt.close(fig)
        checks[split] = {"all_trajectories": True, "all_steps": True, "finite": True,
                         "metrics_recomputed": True, "example_fields_recomputed": True,
                         "zero_negative_depth": metrics[split]["negative_h_ratio"] == 0}
    report = ["# FluxNet shallow-water LAP pushforward reproduction", "",
              "Source: https://arxiv.org/pdf/2602.01941v2 (Sections 3.2–3.7, Appendix C.2).", "",
              "One independent training run (seed 42), 300 complete epochs. This is not a five-seed mean/std replication.",
              "64 channels, 6 residual blocks, kernel 5, transport stencil 3×3, detached pushforward length 5.",
              "AdamW lr=1e-3, weight_decay=1e-2; ReduceLROnPlateau factor=0.5, patience=15; float32 states.", "",
              f"Selected checkpoint epoch: {manifest['selected_epoch']}; validation MSE: {manifest['best_val_mse']:.8e}.",
              f"Checkpoint SHA256: `{manifest['sha256']}`.", "",
              "| Split | Horizon | MAE h | MAE mx | MAE my | Negative h (%) |",
              "| --- | ---: | ---: | ---: | ---: | ---: |"]
    for split, values in metrics.items():
        report.append(f"| {split} | {values['t_end']:.1f} | " + " | ".join(f"{v:.8e}" for v in values["mae"]) +
                      f" | {values['negative_h_percent']:.8e} |")
    report += ["", "MAE averages all forecast steps, grid cells, and 50 trajectories, excluding the initial frame.",
               "Final-time and T=2/T=2.4/T=4.8 prefix metrics are separately stored in metrics.json.",
               "The paper labels Table 3 as T=2 while Appendix C.1 specifies 2.4/4.8; both time conventions are retained.",
               "", "Conservation is computed using float64 reductions of float32 predictions relative to the initial state.",
               "Relative drift divides by |initial signed total|+1e-8; zero-total momentum makes this statistic ill-conditioned.",
               "Absolute sum drift and initial-L1-scaled drift are therefore also reported. No postprocessing correction is used.", ""]
    for split, values in metrics.items():
        report += [f"{split} maximum absolute sum drift [h,mx,my]: {values['conservation_absolute_sum_max']}",
                   f"{split} maximum relative drift [h,mx,my]: {values['conservation_relative_max']}",
                   f"{split} minimum depth: {values['min_h']:.8e}", ""]
    report += ["All arrays, checkpoints, training history, and saved-case MAE recomputations were checked.",
               "The original numerical-generator datasets were read directly; no data generation or official repository code was downloaded."]
    report += ["", "Reference data contains small negative roundoff depths (about 1e-11); these are retained unchanged.",
               "Data validation tolerates reference values down to -1e-9; model violation metrics use the strict condition h<0, with no tolerance or clipping."]
    (out / "report.md").write_text("\n".join(report) + "\n")
    save_json(out / "verification.json", {"completed_epochs": 300,
              "best_checkpoint_hash_matches": True, "selected_epoch_is_val_minimum": True,
              "checkpoint_sha256": manifest["sha256"], "splits": checks,
              "figures": figures, "report": "report.md"})
    print("RESULT_VERIFIED " + json.dumps({"completed_epochs": 300, "checks": checks,
                                          "figures": len(figures), "results": str(out)}), flush=True)


if __name__ == "__main__":
    main()