Advanced Optimization with Problem and Governors#

This how-to builds on Optimization Basics. It shows how to package an objective as a Problem, compose governors for reusable stop logic, and plot convergence diagnostics from the resulting step records.

The example is deliberately small: a weighted quadratic with box bounds. The same pattern scales to optical merit functions where evaluating the model and gradient is the expensive part.

[1]:
import numpy as np

from prysm.x.optym import (
    Problem,
    LBFGSB,
    AnyGovernor,
    Governor,
    GovernorDecision,
    FunctionTolerance,
    GradientTolerance,
    MaxIterations,
    StepTolerance,
    plot_convergence,
    run_until,
)

Define a Problem#

A Problem subclass advertises which pieces it can evaluate. This one provides _fg, meaning it returns the scalar objective and gradient together. Returning both from one method avoids duplicated model work in real applications.

[2]:
class WeightedBowl(Problem):
    has_fg = True

    def __init__(self, target, weights):
        super().__init__()
        self.target = np.asarray(target, dtype=float)
        self.weights = np.asarray(weights, dtype=float)

    def _fg(self, x):
        dx = x - self.target
        f = 0.5 * np.sum(self.weights * dx * dx)
        g = self.weights * dx
        return float(f), g

The unconstrained minimum is the target vector. Two target values are outside the bounds below, so the constrained solution will sit on active bounds.

[3]:
problem = WeightedBowl(
    target=[-1.0, 0.25, 3.0],
    weights=[1.0, 10.0, 0.5],
)

x0 = np.asarray([1.5, -0.45, 0.5], dtype=float)
lower = np.asarray([0.0, -0.5, 0.0], dtype=float)
upper = np.asarray([2.0, 0.5, 2.0], dtype=float)

Compose Governors#

Governors are reusable stop conditions. AnyGovernor stops as soon as one child stops. This mix says that a small gradient, small objective change, small step, or a hard iteration cap may end the run.

For bound-constrained problems, the raw gradient may not go to zero at the solution because the active bounds supply the missing optimality conditions. That is why this example includes function and step tolerances as well as a maximum iteration cap.

[4]:
governor = AnyGovernor([
    GradientTolerance(1e-8),
    FunctionTolerance(1e-12),
    StepTolerance(1e-12),
    MaxIterations(50),
])

Now create the optimizer and let run_until produce an OptimizationResult. The optimizer still owns one coherent step; the runner and governor own the stopping policy.

[5]:
optimizer = LBFGSB(
    problem,
    x0,
    lower_bounds=lower,
    upper_bounds=upper,
)

result = run_until(optimizer, governor, maxiter=50)
result.message, result.success, result.nit, result.x
[5]:
('optimizer stopped', True, 6, array([0.  , 0.25, 2.  ]))

Each record captures the pre-step iterate, the objective and gradient evaluated there, the post-step iterate, and any optimizer metadata. That makes downstream diagnostics independent of the optimizer implementation.

[6]:
trajectory = np.asarray([record.x_next for record in result.records])
trajectory
[6]:
array([[0.        , 0.5       , 1.75      ],
       [0.        , 0.32530445, 1.82176837],
       [0.        , 0.2287629 , 1.96611107],
       [0.        , 0.23374613, 2.        ],
       [0.        , 0.24945069, 2.        ],
       [0.        , 0.25      , 2.        ]])

Plot Convergence#

plot_convergence accepts the result directly. The bounded series counts variables sitting on finite lower or upper bounds for optimizers with box constraints. For optimizers that report active inequality metadata, it counts those active inequalities instead.

[7]:
fig, ax = plot_convergence(
    result,
    quantities=('f', 'g_norm', 'bounded'),
    marker='o',
)
../_images/how-tos_Advanced_Optimization_with_Problem_and_Governors_13_0.png

Custom Governors#

Built-in governors cover common cases, but a custom governor is just a small object with an observe(record) method. This one stops when the objective at the beginning of a step is below a requested value.

[8]:
class TargetCost(Governor):
    def __init__(self, target):
        self.target = float(target)

    def observe(self, record):
        if record.f <= self.target:
            return GovernorDecision(True, True, 'target cost reached')
        return GovernorDecision()

optimizer = LBFGSB(
    problem,
    x0,
    lower_bounds=lower,
    upper_bounds=upper,
)
custom_governor = AnyGovernor([
    TargetCost(0.751),
    MaxIterations(50),
])
custom_result = run_until(optimizer, custom_governor)
custom_result.message, custom_result.nit
[8]:
('target cost reached', 6)

The important design choice is separation: Problem describes what is being optimized, the optimizer describes how to take one step, and governors describe when enough work has been done.