Source code for bonni.ipopt

from pathlib import Path
from typing import Literal
import cyipopt
import numpy as np

from bonni.constants import INPUT_FN_TYPE
from bonni.function import MAX_EVAL_MSG_STR, FunctionWrapper


[docs] def optimize_ipopt( fn: INPUT_FN_TYPE, x0: np.ndarray, bounds: np.ndarray, max_fn_eval: int | None = None, max_iterations: int | None = None, save_path: Path | str | None = None, tol: float = 1e-8, direction: Literal["maximize", "minimize"] = "minimize", bound_contract_ratio: float = 1e-3, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """ Optimize a black-box function using the Interior Point Optimizer (IPOPT). This function acts as a convenient wrapper around `cyipopt`, managing the interface between the objective function (which must provide gradients) and the solver. It handles bound constraints, optimization direction, and recording of the optimization history. Args: fn (Callable[[np.ndarray], tuple[np.ndarray, np.ndarray]]): The objective function to optimize. It must accept an input array of shape (num_dims,) and return a tuple `(y, g)`, where `y` is the scalar objective value and `g` represents gradients of shape (num_dims,). x0 (np.ndarray): The initial starting point for the optimization. Must have shape (num_dims,). bounds (np.ndarray): A 2D array of shape `(num_dims, 2)` specifying the (lower, upper) search space boundaries for each of the `num_dims` input dimensions. max_fn_eval (int | None, optional): The maximum number of allowed function evaluations. Note that IPOPT performs line searches, so one iteration results in multiple function evaluations. Defaults to None. max_iterations (int | None, optional): The maximum number of solver iterations. Defaults to None. save_path (Path | str | None, optional): Directory path to save the results of function evaluation as an npz-file. Defaults to None. tol (float, optional): The tolerance for convergence. Defaults to 1e-8. direction (Literal["maximize", "minimize"], optional): The optimization goal. Defaults to "minimize". bound_contract_ratio (float, optional): A small epsilon value used to contract the provided bounds slightly (bounds +/- eps * bound_range). This prevents the interior point method from starting or evaluating exactly on the boundary, which can cause numerical issues. Defaults to 1e-3. Returns: tuple[np.ndarray, np.ndarray, np.ndarray]: A tuple containing the full history of: - `xs`: Input parameters (shape `(N, D)`). - `ys`: Objective values (shape `(N,)`). - `gs`: Gradients (shape `(N, D)`). """ assert bounds.ndim == 2 and bounds.shape[1] == 2, ( "bounds needs to be array of shape (degree_of_freedom, 2)" ) assert x0.ndim == 1 and x0.shape[0] == bounds.shape[0], ( f"x0 needs to have shape (degree_of_freedom,), but got {x0.shape}" ) if direction not in ["maximize", "minimize"]: raise Exception(f"Invalid direction argument: {direction}") if save_path is not None: save_path = Path(save_path) wrapper = FunctionWrapper( fn, np.copy(bounds).astype(float), negate=(direction == "maximize"), max_fn_eval=max_fn_eval, save_path=save_path, ) options = {} if max_iterations is not None: options["maxiter"] = max_iterations options["print_level"] = 0 bounds_ipopt = np.copy(bounds).astype(float) bounds_range = bounds_ipopt[:, 1] - bounds_ipopt[:, 0] bounds_eps = bounds_range * bound_contract_ratio / 2 bounds_ipopt[:, 0] += bounds_eps bounds_ipopt[:, 1] -= bounds_eps try: cyipopt.minimize_ipopt( fun=wrapper, x0=x0, args=(), method=None, jac=True, bounds=bounds_ipopt, tol=tol, options=options, ) except Exception as e: # if we are early stopping due, then do nothing. If this is real error, raise it again if str(e) != MAX_EVAL_MSG_STR: raise e return wrapper.get_saved_obs()