BUG: Fire IterationEvent before AdvanceOneStep in v4 gradient optimizers (closes #2570, supersedes #2572)#6196
Merged
hjmjohnson merged 1 commit intoInsightSoftwareConsortium:mainfrom May 6, 2026
Conversation
7 tasks
6687ce8 to
4e8804f
Compare
This comment was marked as resolved.
This comment was marked as resolved.
4e8804f to
3048603
Compare
Member
Author
|
@ntustison @stnava @blowekamp @cookpa This issue has been outstanding for a long time. The ANTs may have behavior that depends on this, so I'm giving you a chance to review before it is merged in the next few days. |
3048603 to
b9e59e9
Compare
ntustison
approved these changes
May 6, 2026
cookpa
reviewed
May 6, 2026
Contributor
|
Thanks for the heads up @hjmjohnson. I did a bit of checking with Codex and I think there will be no problems with ANTs. It threw out the suggestion I put in the comment above just as a general point |
Issue InsightSoftwareConsortium#2570: an observer attached to IterationEvent on GradientDescentOptimizerv4 (or its subclasses) was given an internally inconsistent (value, position) pair. The optimizer was firing the event at the end of AdvanceOneStep(), after UpdateTransformParameters() had already moved the position from X to X+step, while m_CurrentMetricValue still held v(X). Observers reading GetCurrentMetricValue() and GetCurrentPosition() saw value-at-old-position with the new position. Move InvokeEvent(IterationEvent()) into the loop in GradientDescentOptimizerv4Template::ResumeOptimization(), fired BEFORE AdvanceOneStep() so the reported (value, position) pair always agrees. Remove the now-redundant InvokeEvent at the end of each subclass's AdvanceOneStep override (RegularStep, QuasiNewton, GradientDescentLineSearch, ConjugateGradientLineSearch). Adds a GTest regression guard (itkGradientDescentOptimizerv4ObserverGTest.cxx) that fails on the unfixed code with a ~4-unit value/position mismatch on a quadratic fixture, and passes on the fixed code. Closes InsightSoftwareConsortium#2570. Supersedes the WIP draft InsightSoftwareConsortium#2572 by @gregsharp; the test demonstrates Greg's diagnosis is correct, the fix here is a simpler one-event-move that does not change iteration semantics. Co-Authored-By: Gregory C. Sharp <gregsharp.geo@yahoo.com>
b9e59e9 to
cf929f2
Compare
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.
Fixes #2570 and supersedes the WIP draft #2572 by @gregsharp. The 2021 PR stalled on coding-standards review, not on the diagnosis — Greg was right about the bug.
GradientDescentOptimizerv4(and its subclasses) firedIterationEventat the end ofAdvanceOneStep(), afterUpdateTransformParametershad already moved the position from X to X+step, whilem_CurrentMetricValuestill held v(X). Observers readingGetCurrentMetricValue()andGetCurrentPosition()got an inconsistent pair (old value with new position).This PR moves the event invocation to the loop in
ResumeOptimization(), fired BEFOREAdvanceOneStep(), so observers always see the value at the position where it was actually computed. Six-line change to the base; trailingInvokeEventremoved from each subclass override (RegularStep, QuasiNewton, GradientDescentLineSearch, ConjugateGradientLineSearch).User-visible symptom (from issue #2570)
The reproducer (a plastimatch v4-registration test program and its
AmoebaOptimizerv4vsRegularStepGradientDescentOptimizerv4comparison) produces this observer output at iteration 0:The score at iteration 0 is the score evaluated at the initial parameters
[0, 0, 0]— but the position the observer reads has already been advanced one step. Same value, different position, no way for the observer to reconstruct what happened. Discussed on Discourse: imageregistrationmethodv4-questions/4117/7.TDD evidence — failing test pinned the bug, then passes after fix
The new GTest
itkGradientDescentOptimizerv4ObserverGTest.cxxattaches an observer atIterationEventand asserts thatmetric_at(reported_position) == reported_value. On unfixedupstream/main:After the fix:
Why this approach over Greg's #2572 restructure
Greg's PR rewrote
ResumeOptimizationto compute the metric value/derivative afterAdvanceOneStep(~150 lines changed across 7 files). It's correct, but it changes the iteration semantics:@dzenanz pushed back on that semantic flip in the 2021 review, and the discussion never resolved. This PR is the minimal alternative: keep the existing "1 iteration = 1 step taken" semantic and move the event invocation 6 lines earlier in the loop. No iteration-semantic change, no risk of breaking dependent registration code.
Other items in Greg's branch — explicit zero-init of
m_Gradient/m_PreviousGradientinStartOptimization, an extram_PreviousGradient = m_Gradientafter the initial evaluation — were inspected for first-iteration correctness concerns. With the event-firing fix in place, neither is load-bearing: subclasses (RegularStepGradientDescentOptimizerv4) already initialize their own gradient buffers, and the relaxation dot product on iteration 0 is unchanged either way. They're omitted here to keep the surface minimal.Files changed
Modules/Numerics/Optimizersv4/include/itkGradientDescentOptimizerv4.hxx— addInvokeEvent(IterationEvent())beforeAdvanceOneStep()inResumeOptimization; remove the trailingInvokeEventfromAdvanceOneStepModules/Numerics/Optimizersv4/include/itkRegularStepGradientDescentOptimizerv4.hxx— remove trailingInvokeEventfromAdvanceOneStepModules/Numerics/Optimizersv4/include/itkQuasiNewtonOptimizerv4.hxx— sameModules/Numerics/Optimizersv4/include/itkGradientDescentLineSearchOptimizerv4.hxx— sameModules/Numerics/Optimizersv4/include/itkConjugateGradientLineSearchOptimizerv4.hxx— sameModules/Numerics/Optimizersv4/test/itkGradientDescentOptimizerv4ObserverGTest.cxx— new (regression guard)Modules/Numerics/Optimizersv4/test/CMakeLists.txt— register the new GTestVerification
Optimizersv4 + RegistrationMethodsv4labels pass locally on the fixpre-commit run --all-filesexit 0upstream/main(no fix), passes on this branch — confirms it actually guards against the bug