Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 58 additions & 6 deletions pybnf/transcription/outer.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,8 @@ def run(self, u0, multipliers=None):
inner_evals += outcome.n_evaluations

if not np.all(np.isfinite(outcome.point)):
self._record(self._certify_unusable(iteration, u, multipliers.penalty, m),
iterates, best)
stop_reason = 'inner_failed'
break
previous_point = u
Expand All @@ -522,6 +524,8 @@ def run(self, u0, multipliers=None):
model = subproblem.at(u)
outer_evals += 1
if not model.is_finite():
self._record(self._certify_unusable(iteration, u, multipliers.penalty, m),
iterates, best)
stop_reason = 'inner_failed'
break
defect_norm = model.defect_norm
Expand All @@ -532,12 +536,7 @@ def run(self, u0, multipliers=None):

record = self._certify(iteration, u, model, multipliers.penalty, optimality)
certified = certified and record.certificate.certified
iterates.append(record)
best.offer(record)
if self.shared_best is not None:
self.shared_best.offer(record)
if self.on_iterate is not None:
self.on_iterate(record)
self._record(record, iterates, best)

if m == 0:
# No constraints: the transcription already *is* the ordinary problem, and
Expand Down Expand Up @@ -593,6 +592,59 @@ def run(self, u0, multipliers=None):
defect_norm, optimality, defect_rms=defect_rms,
worst_defects=worst_defects)

def _record(self, record, iterates, best):
"""Enter one certified iterate: keep it, rank it, and tell the caller.

Factored out because the *unusable* branches have to do exactly this too (#581) --
an iterate that is not offered to ``best`` is an iterate the run cannot report, and
forgetting one of these four steps on one branch is precisely how that bug arose.
"""
if record is None:
return
iterates.append(record)
best.offer(record)
if self.shared_best is not None:
self.shared_best.offer(record)
if self.on_iterate is not None:
self.on_iterate(record)

def _certify_unusable(self, iteration, u, penalty, n_constraints):
"""Certify the reported parameters at a point whose *augmented* model is not finite.

The augmented model and the certificate are different computations, and the second
can succeed where the first fails (#581): certification discards every auxiliary
state and re-simulates the reported parameters through the fit's **ordinary
unsegmented** path, which needs no continuity block, no auxiliary bounds, and --
crucially -- no forward sensitivities. A parameter point whose trajectory integrates
perfectly well while its ``d(state)/d(theta)`` overflows makes the augmented model
non-finite and the certificate perfectly good.

Before this, such a point ended the run having reported *nothing*, because the
bail-out returned before any iterate was certified -- so a run holding a fit it had
already earned printed "No simulation completed, so there is no best fit to report".
On a single-start run, which is every ``refine_method = ms``, that was the whole
result. Measured on ``Borghans_BiophysChem1997``: 1 of 8 oscillating box draws, where
the discarded certificate was ``-150.70078`` -- the same number ``gntr`` reports from
the identical start.

The recorded iterate carries ``inf`` for every quantity the augmented model would
have supplied, because those genuinely are not available; only the certificate is.
"""
reported = self.problem.layout.reported_of(u)
certificate = self.problem.certify(reported)
if certificate is None:
# A transcription that cannot reconstruct has nothing to offer here: the
# augmented objective is the only score it could report, and that is the number
# this branch has just established is not finite.
return None
if not isinstance(certificate, Certificate):
raise TranscriptionError(
'TranscriptionProblem.certify must return a Certificate or None; got %r.'
% type(certificate).__name__)
return CertifiedIterate(self.problem.name, iteration, reported, u, certificate,
np.inf, np.inf, np.inf, penalty, np.inf,
defect_rms=np.inf, n_constraints=n_constraints)

def _certify(self, iteration, u, model, penalty, optimality):
"""Reconstruct this iterate's reported parameters and wrap it as a
:class:`CertifiedIterate`.
Expand Down
50 changes: 48 additions & 2 deletions tests/test_transcription.py
Original file line number Diff line number Diff line change
Expand Up @@ -868,14 +868,60 @@ def stop_after_two():
assert len(result.iterates) == 2
assert result.best is not None # the work already done is still reported

def test_a_non_finite_inner_point_stops_rather_than_propagating(self):
def test_a_non_finite_inner_point_stops_but_still_reports_what_it_earned(self):
"""A non-finite inner point ends the run and does **not** propagate -- but the run
still reports the certificate it already holds (#581).

This assertion was inverted until #581: the run stopped with ``best is None`` and a
caller saw "no fit at all" from a point whose reported parameters certify perfectly
well. That was inconsistent with the ``stopped`` branch immediately above, which has
always reported the work already done, and the inconsistency was the bug: a
certificate is a *reconstruction through the ordinary unsegmented path*, so it does
not care that the augmented model at this point is non-finite.
"""
def broken_solver(subproblem, u0, tolerance):
return InnerOutcome(np.full(subproblem.size, np.nan), converged=False)

result = AugmentedLagrangian(ConstrainedQuadratic(), broken_solver).run(
np.array([0.9, 0.0]))
assert result.stop_reason == 'inner_failed'
assert result.best is None
assert result.best is not None
assert np.isfinite(result.best.score)
# The augmented model genuinely had nothing to offer, and the record says so rather
# than inventing a defect norm or an optimality it never measured.
assert not np.isfinite(result.best.defect_norm)
assert not np.isfinite(result.best.optimality)

def test_a_point_whose_augmented_model_is_non_finite_still_certifies(self):
"""#581's shape exactly, offline: the objective blows up while the *reconstruction*
succeeds.

This is what a real fit hits when a parameter point's trajectory integrates fine but
its forward sensitivities overflow -- measured on ``Borghans_BiophysChem1997``, where
1 of 8 oscillating box draws produced ``m=4: inf`` and no reported fit at all, while
the discarded certificate was ``-150.70078``, the same value ``gntr`` reports from
that identical start.
"""
class OverflowingSensitivities(ShootingProblem):
def objective_at(self, u):
model = super().objective_at(u)
return ObjectiveModel(np.inf, np.full(len(model.gradient), np.nan))

problem = OverflowingSensitivities(4, *shooting_data(n=17), theta_seed=0.5)
start = problem.layout.initial_point([0.5])
# A solver that hands the point straight back, which is what any real one does when
# the local model it was given is not finite: there is no step to take.
result = AugmentedLagrangian(
problem, lambda sub, u0, tol: InnerOutcome(u0, converged=False),
max_outer=5).run(start)

assert result.stop_reason == 'inner_failed'
assert result.best is not None, 'the certificate at the start point was discarded'
assert np.isfinite(result.best.score)
# And it is the ordinary single-shoot reconstruction, not the augmented objective.
assert result.best.certificate.certified
assert result.best.score == pytest.approx(
problem.certify(problem.layout.reported_of(start)).objective)

def test_an_inner_solver_that_breaks_the_contract_refuses_loudly(self):
result = AugmentedLagrangian(ConstrainedQuadratic(),
Expand Down
Loading