From 7aec01e709f0f2a1ffd19a9d0fdc2a2b80f06afb Mon Sep 17 00:00:00 2001 From: Janos Gabler Date: Sun, 17 Aug 2025 15:24:33 +0200 Subject: [PATCH] Add history collection. --- examples/example.ipynb | 8 ++++++-- src/optimini/history.py | 31 +++++++++++++++++++++++++++++++ src/optimini/internal_problem.py | 7 +++++-- src/optimini/minimize.py | 14 +++++++++----- src/optimini/utils.py | 15 +++++++++++++++ tests/test_optimini.py | 8 ++++++++ 6 files changed, 74 insertions(+), 9 deletions(-) create mode 100644 src/optimini/history.py create mode 100644 src/optimini/utils.py diff --git a/examples/example.ipynb b/examples/example.ipynb index a5fdb54..9563240 100644 --- a/examples/example.ipynb +++ b/examples/example.ipynb @@ -15,6 +15,7 @@ "metadata": {}, "outputs": [], "source": [ + "from optimini.history import history_plot\n", "from optimini.minimize import minimize\n", "\n", "\n", @@ -23,8 +24,11 @@ "\n", "\n", "params = {\"a\": 1, \"b\": 2}\n", - "res = minimize(my_fun, params, method=\"L-BFGS-B\")\n", - "res.x" + "results = {\n", + " \"L-BFGS-B\": minimize(my_fun, params, method=\"L-BFGS-B\"),\n", + " \"Nelder-Mead\": minimize(my_fun, params, method=\"Nelder-Mead\"),\n", + "}\n", + "history_plot(results)" ] } ], diff --git a/src/optimini/history.py b/src/optimini/history.py new file mode 100644 index 0000000..f15e910 --- /dev/null +++ b/src/optimini/history.py @@ -0,0 +1,31 @@ +import pandas as pd +import seaborn as sns + + +class History: + """History of parameters and function values.""" + + def __init__(self): + self.value = [] + self.params = [] + + def add(self, value, params): + self.value.append(value) + self.params.append(params) + + +def history_plot(results, max_n_evals=50, monotone=True): + """Plot the criterion values of multiple optimizations.""" + data = pd.DataFrame() + for name, res in results.items(): + values = res.history.value + df = pd.DataFrame({"value": values, "n_evals": range(len(values))}) + df["name"] = name + if monotone: + df["value"] = df["value"].cummin() + data = pd.concat([data, df]) + + if max_n_evals is not None: + data = data.query(f"n_evals <= {max_n_evals}") + + return sns.lineplot(data=data, x="n_evals", y="value", hue="name") diff --git a/src/optimini/internal_problem.py b/src/optimini/internal_problem.py index 5626e03..8fff759 100644 --- a/src/optimini/internal_problem.py +++ b/src/optimini/internal_problem.py @@ -1,10 +1,13 @@ class InternalProblem: """Wraps a user provided function to add functionality""" - def __init__(self, fun, converter): + def __init__(self, fun, converter, history): self._user_fun = fun self._converter = converter + self._history = history def fun(self, x): params = self._converter.unflatten(x) - return self._user_fun(params) + value = self._user_fun(params) + self._history.add(value, params) + return value diff --git a/src/optimini/minimize.py b/src/optimini/minimize.py index 502199a..8e19cd6 100644 --- a/src/optimini/minimize.py +++ b/src/optimini/minimize.py @@ -1,16 +1,17 @@ -from copy import deepcopy - from scipy.optimize import minimize as scipy_minimize from optimini.converter import Converter +from optimini.history import History from optimini.internal_problem import InternalProblem +from optimini.utils import OptimizeResult def minimize(fun, params, method, options=None): """Minimize a function using a given method""" options = {} if options is None else options converter = Converter(params) - internal_fun = InternalProblem(fun, converter) + history = History() + internal_fun = InternalProblem(fun, converter, history) x0 = converter.flatten(params) raw_res = scipy_minimize( fun=internal_fun.fun, @@ -18,6 +19,9 @@ def minimize(fun, params, method, options=None): method=method, options=options, ) - res = deepcopy(raw_res) - res.x = converter.unflatten(res.x) + res = OptimizeResult( + x=converter.unflatten(raw_res.x), + history=history, + fun=raw_res.fun, + ) return res diff --git a/src/optimini/utils.py b/src/optimini/utils.py new file mode 100644 index 0000000..f08a8d4 --- /dev/null +++ b/src/optimini/utils.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + +import numpy as np +from numpy.typing import NDArray + +from optimini.history import History + + +@dataclass +class OptimizeResult: + """An oversimplified optimization result.""" + + x: dict | NDArray[np.float64] + history: History + fun: float diff --git a/tests/test_optimini.py b/tests/test_optimini.py index 518938c..9840e2c 100644 --- a/tests/test_optimini.py +++ b/tests/test_optimini.py @@ -1,5 +1,6 @@ import numpy as np +from optimini.history import History, history_plot from optimini.minimize import minimize @@ -24,3 +25,10 @@ def test_simple_minimize_with_array_params(): res = minimize(array_fun, params, method="L-BFGS-B") assert isinstance(res.x, np.ndarray) assert np.allclose(res.x, np.array([0, 0])) + + +def test_history_collection(): + params = {"a": 1, "b": 2} + res = minimize(dict_fun, params, method="L-BFGS-B") + assert isinstance(res.history, History) + history_plot({"test": res})