From bacc8763d05282f1ee19aedf62b0bc218efb0aab Mon Sep 17 00:00:00 2001 From: Lars Bogner Date: Mon, 17 Aug 2026 09:31:37 +0200 Subject: [PATCH] Clip raw predicted log_mass in decode_secondaries (gitea #54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decode_secondaries inverted a secondary's raw predicted log_mass with inv_log_transform (exp(y) - eps) unclipped. log_mass is a raw regression output, not itself the result of log_transform, so it isn't guaranteed to land in the range that round-trips cleanly: too negative and exp(y) undershoots eps, making the result go slightly negative; too positive and exp(y) overflows float32 to inf. Either one crashes the next rollout step, since a track descended from that secondary feeds its mass back in as conditioning, and log_transform raises on a non-finite input. Clip log_mass to [log(_EPS), _LOG_MASS_MAX] before inverting, guaranteeing a finite, non-negative mass. _LOG_MASS_MAX=80.0 matches the value from the stale fix/rollout-negative-secondary-mass branch (comfortably below float32's ~88.7 overflow point, far beyond any physical particle mass a converged model would predict) — that branch had already implemented this fix but forked before gitea #35/#36 and couldn't be merged as-is, so this reimplements it fresh against current master and leaves the stale branch untouched. Co-Authored-By: Claude Opus 5 --- giant/data/transforms.py | 23 +++++++++++++++--- tests/test_phase2.py | 50 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/giant/data/transforms.py b/giant/data/transforms.py index e436211..8c637f3 100644 --- a/giant/data/transforms.py +++ b/giant/data/transforms.py @@ -14,6 +14,14 @@ _EPS = 1e-8 # the conservation it slightly softens is physically negligible (~0.001%). _SIMPLEX_FLOOR = 1e-5 +# Upper clip for a raw predicted log_mass before inv_log_transform: exp(y) +# must stay well inside float32 range (~3.4e38, i.e. y < ~88.7) or it +# overflows to inf, which — like the negative-mass case below — blows up the +# next log_transform call once that mass is fed back in as conditioning. +# 80.0 leaves comfortable headroom while still being far beyond any physical +# particle mass a converged model would ever predict. +_LOG_MASS_MAX = 80.0 + def log_transform(x: np.ndarray, eps: float = _EPS) -> np.ndarray: x = np.asarray(x, dtype=np.float32) @@ -685,9 +693,18 @@ def decode_secondaries( log_mass = sec_cont[:, :, 4] # (N, K) charge = sec_cont[:, :, 5] # (N, K) - # mass is non-negative by construction (inv_log_transform of a real - # number is always > 0); clip to 0 for padded/invalid slots rather than - # leaving a spurious small positive floor from the log inverse. + # log_mass is a raw model prediction, not itself the output of + # log_transform, so it can land far outside the range that round-trips + # cleanly through inv_log_transform: too negative and exp(log_mass) + # undershoots _EPS, making inv_log_transform go slightly negative; too + # positive and exp(log_mass) overflows float32 to inf. Either one then + # blows up the next log_transform call on this track's mass once it's + # fed back in as conditioning for a further rollout step + # (giant/rollout.py -> build_cond_features -> _physical_cond_columns). + # Clip to a range whose inverse is guaranteed finite and >= 0 before + # that can happen; clip to 0 separately for padded/invalid slots rather + # than leaving a spurious small positive floor. + log_mass = np.clip(log_mass, np.log(_EPS), _LOG_MASS_MAX) sec_mass = np.where(sec_valid, inv_log_transform(log_mass), 0.0).astype(np.float32) sec_charge = np.where(sec_valid, charge, 0.0).astype(np.float32) diff --git a/tests/test_phase2.py b/tests/test_phase2.py index 38270b1..328262e 100644 --- a/tests/test_phase2.py +++ b/tests/test_phase2.py @@ -627,3 +627,53 @@ def test_decode_secondaries_mass_charge_round_trip_with_normalizer(): _, _, sec_mass, sec_charge, _ = decode_secondaries(sec_cont_normed, n_sec, e_sec, pre_dir, sec_phys_normalizer=norm) assert sec_mass[0, 0] == pytest.approx(938.27208943, abs=1e-2) assert sec_charge[0, 0] == pytest.approx(1.0, abs=1e-4) + + +def test_decode_secondaries_extreme_negative_log_mass_stays_nonnegative(): + from giant.data.transforms import decode_secondaries, log_transform + + N = 1 + e_sec = np.array([5.0], dtype=np.float32) + n_sec = np.array([1]) + pre_dir = np.array([[0.0, 0.0, 1.0]], dtype=np.float32) + + sec_cont = np.zeros((N, K_MAX, 6), dtype=np.float32) + sec_cont[0, 0, 0] = 10.0 # stick logit -> ~all of e_sec + sec_cont[0, 0, 1:4] = [0, 0, 1] + sec_cont[0, 0, 4] = -50.0 # raw model prediction: extremely negative log_mass + sec_cont[0, 0, 5] = 1.0 + + _, _, sec_mass, _, _ = decode_secondaries(sec_cont, n_sec, e_sec, pre_dir) + + # A raw model prediction isn't itself the output of log_transform, so + # naively applying inv_log_transform can undershoot zero (see + # decode_secondaries) — which then crashes the next log_transform call + # once this mass is fed back in as conditioning during rollout. The + # float32 residual from clipping can land a hair below zero, but must + # stay well above -eps so log_transform(mass) stays finite. + assert sec_mass[0, 0] > -1e-8 + log_transform(sec_mass[0, 0]) + + +def test_decode_secondaries_extreme_positive_log_mass_stays_finite(): + from giant.data.transforms import decode_secondaries, log_transform + + N = 1 + e_sec = np.array([5.0], dtype=np.float32) + n_sec = np.array([1]) + pre_dir = np.array([[0.0, 0.0, 1.0]], dtype=np.float32) + + sec_cont = np.zeros((N, K_MAX, 6), dtype=np.float32) + sec_cont[0, 0, 0] = 10.0 # stick logit -> ~all of e_sec + sec_cont[0, 0, 1:4] = [0, 0, 1] + sec_cont[0, 0, 4] = 200.0 # raw model prediction: extremely positive log_mass + sec_cont[0, 0, 5] = 1.0 + + _, _, sec_mass, _, _ = decode_secondaries(sec_cont, n_sec, e_sec, pre_dir) + + # Mirror image of the extreme-negative case above: exp(log_mass) + # overflows float32 to inf for an unclipped raw prediction this large, + # which then crashes the next log_transform call the same way a + # negative mass would. + assert np.isfinite(sec_mass[0, 0]) + log_transform(sec_mass[0, 0])