fix(data): every normalization reduces over a column's measured rows (#726) - #727
Open
wshlavacek wants to merge 1 commit into
Open
wshlavacek wants to merge 1 commit into
wshlavacek wants to merge 1 commit into
Conversation
…726) A sparse column now normalizes to exactly what the dense column of its measured values would: each reduction (max, min, mean, std) and each reference row (argmax, argmin, the init/unit baseline) skips the NaNs, so a NaN row stays a NaN row and moves nothing else. #479 established that for peak and floor; zero, init and unit were missed, and neither issue covered a column with no measured value at all. A simulated column can legitimately carry NaN at some output rows -- one failed integration step, or an observable that is 0/0 at t=0 -- while the exp file measures a handful of the simulated times, so that row is frequently one nothing scores. normalize_to_zero reduces the whole column, so a single NaN made the mean NaN, hence every centered value NaN, hence the std NaN. _subtract_baseline read row 0, so a NaN there did the same to init and unit. The column then scored as a failed simulation and the parameter set was thrown away, with nothing logged to say that a normalizer rather than the model had made the call. Under normalization = peak the identical simulation scored a real number, which is what showed this was a defect and not a policy. init and unit now take the first MEASURED row as their baseline, which is what "the initial value" meant; _subtract_baseline returns the row it used so normalize_to_unit_scale can record the real baseline_row, since the gradient reads the baseline back by index. A column with no measured value at all has no peak, no min, no mean and no baseline. np.nanargmax / np.nanargmin raise ValueError('All-NaN slice encountered') rather than returning a sentinel, so peak, floor and unit crashed outright and zero and init reached the same state by poisoning. For that case #479's nan-awareness had converted silent corruption into an unexplained traceback. Such a column is now left untouched, with an identity record. Normalization is a transform; whether an all-NaN simulated column is a failure is scoring's call, and scoring already makes it. Both fit scoring paths absorbed the exception in #388's blanket handler and penalized the evaluation with inf, so a fit never died, but two callers do not have that handler: - model_check.run_check calls normalize outside the try below it (which covers only postprocess_data), and neither it nor its caller in pybnf.py catches anything. So --check-simulation on a model producing an all-NaN observable died with a numpy traceback, bypassing the 'Simulation contained NaN or Inf values' diagnostic three lines later that the command exists to print. - the floor is applied to the EXPERIMENTAL data at config load, so an exp file carrying a wholly unmeasured observable column -- the shape #707 supports -- crashed before the fit began. Neither caller grows a handler; the transform stops raising instead. gradient/assembly.py's z-score rule changes with it. Its two reductions (s_bar and the dsigma sum) ran over every row and divided by nrows - ddof. Once a partially-NaN column scores it also reaches the gradient, where those full-column reductions return NaN and poison the whole Jacobian, so they now mask to the measured rows -- read off ``normed``, which marks them -- and count the same points the value-side std did. That keeps record.ddof at its plain user-facing meaning rather than smuggling the NaN count into it. peak, init and unit need no gradient change: their rules read two or three specific rows rather than reducing the column, and nanargmax never returns a NaN row. Tests. test_data_class gains the invariant as a property over random draws -- a sparse column against the dense column of its measured values, per method -- which is a stronger oracle than the hand-computed cases beside it and states the rule in one line; a hand-computed sparse case pinning the recorded scale, ref_row and baseline_row; and the untouched all-NaN column. test_gradient_assembly extends its finite-difference oracle to a column with a NaN at an unscored row, on the baseline row and mid-column. test_model_check pins that an all-NaN column reaches the NaN message rather than a traceback, driving the real Result.normalize rather than the file's recording fake. Against the old code 3 of the 5 property cases fail (peak and floor pass, the controls), 4 of the 8 finite-difference cases, and all 3 model-check cases. On a dense column every reduction is the plain one it always was, so existing fits are byte-identical. Two degenerate cases are unchanged and stay as they were: a z-score over a single measured value has an undefined std, and unit on a constant column divides by 0. Both behave exactly as the equivalent dense column always has -- verified against the old code -- so this change makes them reachable from sparse data rather than introducing them. Signed-off-by: Bill Hlavacek <hlavacek@lanl.gov>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #726. Completes the sweep #479 started.
The rule, stated once
A sparse column now normalizes to exactly what the dense column of its measured values would. Every reduction (max, min, mean, std) and every reference row (argmax, argmin, the
init/unitbaseline) skips the NaNs, so a NaN row stays a NaN row and moves nothing else. #479 established that forpeakandfloor;zero,initandunitwere missed, and neither issue covered a column with no measured value.That invariant is the main test: a property over random draws comparing a sparse column against the dense column of its measured values, per method.
Defect A — a NaN at an unscored row discarded a good parameter set
A simulated column can legitimately carry NaN at some output rows — one failed integration step, or an observable that is 0/0 at t=0 — while the exp file measures a handful of the simulated times, so that row is frequently one nothing scores.
normalize_to_zeroreduces the whole column, so one NaN made the mean NaN → every centered value NaN → the std NaN._subtract_baselineread row 0, so a NaN there did the same toinitandunit. The column then scored as a failed simulation and the parameter set was thrown away, with nothing logged to say a normalizer rather than the model made the call.peakon the identical input is the control that makes this a defect rather than a policy:init/unitnow take the first measured row as their baseline, which is what "the initial value" meant._subtract_baselinereturns the row it used sonormalize_to_unit_scalerecords the realbaseline_row— the gradient reads that baseline back by index, so recording0would have been a wrong row.Defect B — an all-NaN column raised instead of being scored as a failure
np.nanargmax/np.nanargminraiseValueError: All-NaN slice encounteredrather than returning a sentinel, sopeak,floorandunitcrashed outright andzero/initreached the same state by poisoning. For this case #479's nan-awareness had converted silent corruption into an unexplained traceback.Such a column is now left untouched with an identity record. Normalization is a transform; whether an all-NaN simulated column is a failure is scoring's call, and scoring already makes it. Both fit scoring paths absorbed the exception in #388's blanket handler and penalized the evaluation with
inf, so no fit ever died — but two callers lack that handler:model_check.run_checkcallsnormalizeoutside thetrybelow it (which covers onlypostprocess_data), and neither it nor its caller inpybnf.pycatches anything.--check-simulationon a model producing an all-NaN observable died with a numpy traceback, bypassing the'Simulation contained NaN or Inf values'diagnostic three lines later that the command exists to print.Neither caller grows a handler; the transform stops raising instead.
The gradient moves with it
gradient/assembly.py's z-score rule reduced over every row and divided bynrows - ddof. Once a partially-NaN column scores it also reaches the gradient, where those full-column reductions return NaN and poison the whole Jacobian. They now mask to the measured rows — read offnormed, which marks them — and count the same points the value-side std did. That keepsrecord.ddofat its plain user-facing meaning instead of smuggling the NaN count into it.peak/init/unitneed no gradient change: their rules read two or three specific rows rather than reducing the column, andnanargmaxnever returns a NaN row.Tests
test_data_class.pyscale/ref_row/baseline_row; the untouched all-NaN columntest_gradient_assembly.pytest_model_check.pyResult.normalize, not the file's recording fakeAgainst the old code: 3 of the 5 property cases fail (
peakandfloorpass — the controls), 4 of the 8 finite-difference cases, and all 3 model-check cases.Full suite with
BNGPATHset: 5111 passed, 25 skipped. Docs rebuilt clean under-W --keep-going.Behaviour and scope
On a dense column every reduction is the plain one it always was, so existing fits are byte-identical.
Two degenerate cases are unchanged and left as they were: a z-score over a single measured value has an undefined std, and
uniton a constant column divides by 0. I verified both against the old code — they behave exactly as the equivalent dense column always has, so this change makes them reachable from sparse data rather than introducing them. Fixing either would change dense behaviour and belongs in its own issue.A correction to the original framing
The report this came from described a sparse multi-observable exp file reaching
normalize_to_zero. It cannot:Configuration._SYMMETRIC_NORMALIZATIONSisfrozenset({'floor'}), and only the floor is applied to experimental data —peak/init/zero/unitare simulation-side only. The exp side is genuinely implicated, but throughflooralone (defect B, second bullet). #726 is written with the corrected framing.