better-optimize 0.1.2__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. better_optimize-0.1.4/PKG-INFO +146 -0
  2. better_optimize-0.1.4/README.md +124 -0
  3. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize/_version.py +3 -3
  4. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize/basinhopping.py +70 -15
  5. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize/minimize.py +14 -7
  6. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize/root.py +7 -6
  7. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize/utilities.py +208 -22
  8. better_optimize-0.1.4/better_optimize.egg-info/PKG-INFO +146 -0
  9. better_optimize-0.1.2/PKG-INFO +0 -30
  10. better_optimize-0.1.2/README.md +0 -8
  11. better_optimize-0.1.2/better_optimize.egg-info/PKG-INFO +0 -30
  12. {better_optimize-0.1.2 → better_optimize-0.1.4}/LICENSE +0 -0
  13. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize/__init__.py +0 -0
  14. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize/constants.py +0 -0
  15. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize/wrapper.py +0 -0
  16. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize.egg-info/SOURCES.txt +0 -0
  17. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize.egg-info/dependency_links.txt +0 -0
  18. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize.egg-info/requires.txt +0 -0
  19. {better_optimize-0.1.2 → better_optimize-0.1.4}/better_optimize.egg-info/top_level.txt +0 -0
  20. {better_optimize-0.1.2 → better_optimize-0.1.4}/pyproject.toml +0 -0
  21. {better_optimize-0.1.2 → better_optimize-0.1.4}/setup.cfg +0 -0
  22. {better_optimize-0.1.2 → better_optimize-0.1.4}/setup.py +0 -0
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.1
2
+ Name: better_optimize
3
+ Version: 0.1.4
4
+ Summary: A drop-in replacement for scipy optimize functions with quality of life improvements
5
+ License: MIT License
6
+ Project-URL: Homepage, https://github.com/jessegrabowski/better_optimize
7
+ Project-URL: Repository, https://github.com/jessegrabowski/better_optimize.git
8
+ Project-URL: Issues, https://github.com/jessegrabowski/better_optimize/issues
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
18
+ Requires-Python: <3.14,>=3.10
19
+ Description-Content-Type: text/markdown
20
+ Provides-Extra: tests
21
+ License-File: LICENSE
22
+
23
+ # Better Optimization!
24
+
25
+ `better_optimize` is a friendlier front-end to scipy's `optimize.minimize` and `optimize.root` functions. Features
26
+ include:
27
+
28
+ - Progress bar!
29
+ - Early stopping!
30
+ - Better propagation of common arguments (`maxiters`, `tol`)!
31
+
32
+ ## Installation
33
+
34
+ To install `better_optimize`, simply use conda:
35
+
36
+ ```bash
37
+ conda install -c conda-forge better_optimize
38
+ ```
39
+
40
+ Or, if you prefer pip:
41
+
42
+ ```bash
43
+ pip install better_optimize
44
+ ```
45
+
46
+ ## What does `better_optimize` provide over basic scipy?
47
+
48
+ ### 1. Progress Bars
49
+
50
+ All optimization routines in `better_optimize` can display a rich, informative progress bar using the [rich](https://github.com/Textualize/rich) library. This includes:
51
+
52
+ - Iteration counts, elapsed time, and objective values.
53
+ - Gradient and Hessian norms (when available).
54
+ - Separate progress bars for global (basinhopping) and local (minimizer) steps.
55
+ - Toggleable display for headless or script environments.
56
+
57
+ ### 2. Flat and Generalized Keyword Arguments
58
+
59
+ - No more nested `options` dictionaries! You can pass `tol`, `maxiter`, and other common options directly as top-level keyword arguments.
60
+ - `better_optimize` automatically sorts and promotes these arguments to the correct place for each optimizer.
61
+ - Generalizes argument handling: always provides `tol` and `maxiter` (or their equivalents) to the optimizer, even if you forget.
62
+
63
+ ### 3. Argument Checking and Validation
64
+
65
+ - Automatic checking of provided gradient (`jac`), Hessian (`hess`), and Hessian-vector (`hessp`) functions.
66
+ - Warns if you provide unnecessary or unused arguments for a given method.
67
+ - Detects and handles fused objective functions (e.g., functions returning `(loss, grad)` or `(loss, grad, hess)` tuples).
68
+ - Ensures that the correct function signatures and return types are used for each optimizer.
69
+
70
+ ### 4. LRUCache1 for Fused Functions
71
+
72
+ - Provides an `LRUCache1` utility to cache the results of expensive objective/gradient/Hessian computations.
73
+ - Especially useful for triple-fused functions that return value, gradient, and Hessian together, avoiding redundant computation.
74
+ - Totally invisible -- just pass a function with 3 return values. Seamlessly integrated into the optimization workflow.
75
+
76
+ ### 5. Robust Basin-Hopping with Failure Tolerance
77
+
78
+ - Enhanced `basinhopping` implementation allows you to continue even if the local minimizer fails.
79
+ - Optionally accepts and stores failed minimizer results if they improve the global minimum.
80
+ - Useful for noisy or non-smooth objective functions where local minimization may occasionally fail.
81
+
82
+ ---
83
+
84
+ ## Example Usage
85
+
86
+ ### Simple Example
87
+
88
+ ```python
89
+ from better_optimize import minimize
90
+
91
+ def rosenbrock(x):
92
+ return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
93
+
94
+ result = minimize(
95
+ rosenbrock,
96
+ x0=[-1, 2],
97
+ method="L-BFGS-B",
98
+ tol=1e-6,
99
+ maxiter=1000,
100
+ progressbar=True, # Show a rich progress bar!
101
+ )
102
+ ```
103
+
104
+ ```shell
105
+ Minimizing Elapsed Iteration Objective ||grad||
106
+ ──────────────────────────────────────────────────────────────────────────────────────────────────
107
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0:00:00 721/721 0.34271757 0.92457651
108
+ ```
109
+
110
+ The result object is a standard `OptimizeResult` from `scipy.optimize`, so there are no surprises there!
111
+
112
+ ### Triple-Fused Function using Pytensor
113
+
114
+ ```python
115
+ from better_optimize import minimize
116
+ import pytensor.tensor as pt
117
+ from pytensor import function
118
+ import numpy as np
119
+
120
+ x = pt.vector('x')
121
+ value = pt.sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
122
+ grad = pt.grad(value, x)
123
+ hess = pt.hessian(value, x)
124
+
125
+ fused_fn = function([x], [value, grad, hess])
126
+ x0 = np.array([1.3, 0.7, 0.8, 1.9, 1.2])
127
+
128
+ result = minimize(
129
+ fused_fn, # No need to set flags separately, `better_optimize` handles it!
130
+ x0=x0,
131
+ method="Newton-CG",
132
+ tol=1e-6,
133
+ maxiter=1000,
134
+ progressbar=True, # Show a rich progress bar!
135
+ )
136
+ ```
137
+
138
+ Many sub-computations are repeated between the objective, gradient, and hessian functions. Scipy allows you to pass a
139
+ fused value_and_grad function, but `better_optimize` also lets you pass a triple-fused value_grad_and_hess function.
140
+ This avoids redundant computation and speeds up the optimization process.
141
+
142
+
143
+ ## Contributing
144
+
145
+ We welcome contributions! If you find a bug, have a feature request, or want to improve the documentation, please open
146
+ an issue or submit a pull request on GitHub.
@@ -0,0 +1,124 @@
1
+ # Better Optimization!
2
+
3
+ `better_optimize` is a friendlier front-end to scipy's `optimize.minimize` and `optimize.root` functions. Features
4
+ include:
5
+
6
+ - Progress bar!
7
+ - Early stopping!
8
+ - Better propagation of common arguments (`maxiters`, `tol`)!
9
+
10
+ ## Installation
11
+
12
+ To install `better_optimize`, simply use conda:
13
+
14
+ ```bash
15
+ conda install -c conda-forge better_optimize
16
+ ```
17
+
18
+ Or, if you prefer pip:
19
+
20
+ ```bash
21
+ pip install better_optimize
22
+ ```
23
+
24
+ ## What does `better_optimize` provide over basic scipy?
25
+
26
+ ### 1. Progress Bars
27
+
28
+ All optimization routines in `better_optimize` can display a rich, informative progress bar using the [rich](https://github.com/Textualize/rich) library. This includes:
29
+
30
+ - Iteration counts, elapsed time, and objective values.
31
+ - Gradient and Hessian norms (when available).
32
+ - Separate progress bars for global (basinhopping) and local (minimizer) steps.
33
+ - Toggleable display for headless or script environments.
34
+
35
+ ### 2. Flat and Generalized Keyword Arguments
36
+
37
+ - No more nested `options` dictionaries! You can pass `tol`, `maxiter`, and other common options directly as top-level keyword arguments.
38
+ - `better_optimize` automatically sorts and promotes these arguments to the correct place for each optimizer.
39
+ - Generalizes argument handling: always provides `tol` and `maxiter` (or their equivalents) to the optimizer, even if you forget.
40
+
41
+ ### 3. Argument Checking and Validation
42
+
43
+ - Automatic checking of provided gradient (`jac`), Hessian (`hess`), and Hessian-vector (`hessp`) functions.
44
+ - Warns if you provide unnecessary or unused arguments for a given method.
45
+ - Detects and handles fused objective functions (e.g., functions returning `(loss, grad)` or `(loss, grad, hess)` tuples).
46
+ - Ensures that the correct function signatures and return types are used for each optimizer.
47
+
48
+ ### 4. LRUCache1 for Fused Functions
49
+
50
+ - Provides an `LRUCache1` utility to cache the results of expensive objective/gradient/Hessian computations.
51
+ - Especially useful for triple-fused functions that return value, gradient, and Hessian together, avoiding redundant computation.
52
+ - Totally invisible -- just pass a function with 3 return values. Seamlessly integrated into the optimization workflow.
53
+
54
+ ### 5. Robust Basin-Hopping with Failure Tolerance
55
+
56
+ - Enhanced `basinhopping` implementation allows you to continue even if the local minimizer fails.
57
+ - Optionally accepts and stores failed minimizer results if they improve the global minimum.
58
+ - Useful for noisy or non-smooth objective functions where local minimization may occasionally fail.
59
+
60
+ ---
61
+
62
+ ## Example Usage
63
+
64
+ ### Simple Example
65
+
66
+ ```python
67
+ from better_optimize import minimize
68
+
69
+ def rosenbrock(x):
70
+ return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
71
+
72
+ result = minimize(
73
+ rosenbrock,
74
+ x0=[-1, 2],
75
+ method="L-BFGS-B",
76
+ tol=1e-6,
77
+ maxiter=1000,
78
+ progressbar=True, # Show a rich progress bar!
79
+ )
80
+ ```
81
+
82
+ ```shell
83
+ Minimizing Elapsed Iteration Objective ||grad||
84
+ ──────────────────────────────────────────────────────────────────────────────────────────────────
85
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0:00:00 721/721 0.34271757 0.92457651
86
+ ```
87
+
88
+ The result object is a standard `OptimizeResult` from `scipy.optimize`, so there are no surprises there!
89
+
90
+ ### Triple-Fused Function using Pytensor
91
+
92
+ ```python
93
+ from better_optimize import minimize
94
+ import pytensor.tensor as pt
95
+ from pytensor import function
96
+ import numpy as np
97
+
98
+ x = pt.vector('x')
99
+ value = pt.sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
100
+ grad = pt.grad(value, x)
101
+ hess = pt.hessian(value, x)
102
+
103
+ fused_fn = function([x], [value, grad, hess])
104
+ x0 = np.array([1.3, 0.7, 0.8, 1.9, 1.2])
105
+
106
+ result = minimize(
107
+ fused_fn, # No need to set flags separately, `better_optimize` handles it!
108
+ x0=x0,
109
+ method="Newton-CG",
110
+ tol=1e-6,
111
+ maxiter=1000,
112
+ progressbar=True, # Show a rich progress bar!
113
+ )
114
+ ```
115
+
116
+ Many sub-computations are repeated between the objective, gradient, and hessian functions. Scipy allows you to pass a
117
+ fused value_and_grad function, but `better_optimize` also lets you pass a triple-fused value_grad_and_hess function.
118
+ This avoids redundant computation and speeds up the optimization process.
119
+
120
+
121
+ ## Contributing
122
+
123
+ We welcome contributions! If you find a bug, have a feature request, or want to improve the documentation, please open
124
+ an issue or submit a pull request on GitHub.
@@ -8,11 +8,11 @@ import json
8
8
 
9
9
  version_json = '''
10
10
  {
11
- "date": "2025-05-14T08:34:39+0200",
11
+ "date": "2025-06-28T08:22:47+0800",
12
12
  "dirty": false,
13
13
  "error": null,
14
- "full-revisionid": "b9f19e0aad67eca088085f337a4627b537f71e3c",
15
- "version": "0.1.2"
14
+ "full-revisionid": "323b00a655190dbb83a66ff9c7ce55df30db16c4",
15
+ "version": "0.1.4"
16
16
  }
17
17
  ''' # END VERSION_JSON
18
18
 
@@ -22,7 +22,13 @@ from scipy.optimize._basinhopping import (
22
22
  )
23
23
 
24
24
  from better_optimize.minimize import minimize
25
- from better_optimize.utilities import ToggleableProgress, check_f_is_fused
25
+ from better_optimize.utilities import (
26
+ LRUCache1,
27
+ ToggleableProgress,
28
+ check_f_is_fused_minimize,
29
+ determine_maxiter,
30
+ validate_provided_functions_minimize,
31
+ )
26
32
 
27
33
 
28
34
  def initialize_progress_bar(progressbar, use_jac=True, use_hess=False):
@@ -166,12 +172,36 @@ def basinhopping(
166
172
  # set up minimizer
167
173
  if minimizer_kwargs is None:
168
174
  minimizer_kwargs = dict()
175
+ minimizer_kwargs = minimizer_kwargs.copy() # avoid mutating caller's dict
176
+
177
+ has_fused_f_and_grad, has_fused_f_grad_hess = check_f_is_fused_minimize(
178
+ func, x0, minimizer_kwargs.get("args", ())
179
+ )
180
+
181
+ jac = minimizer_kwargs.pop("jac", None)
182
+ hess = minimizer_kwargs.pop("hess", None)
183
+ hessp = minimizer_kwargs.pop("hessp", None)
184
+ method = minimizer_kwargs.pop("method", "L-BFGS-B")
185
+ args = minimizer_kwargs.pop("args", ())
186
+
187
+ # Use the validation helper to determine which functions to use
188
+ use_jac, use_hess, use_hessp = validate_provided_functions_minimize(
189
+ method,
190
+ jac,
191
+ hess,
192
+ hessp,
193
+ has_fused_f_and_grad,
194
+ has_fused_f_grad_hess,
195
+ verbose=verbose,
196
+ )
169
197
 
170
- has_fused_f_and_grad = check_f_is_fused(func, x0, minimizer_kwargs.get("args", ()))
171
- if has_fused_f_and_grad:
172
- minimizer_kwargs.pop("jac", None)
198
+ f_returns_list = has_fused_f_and_grad or has_fused_f_grad_hess
199
+ f_cached = LRUCache1(func, f_returns_list=f_returns_list, copy_x=False, dtype=x0.dtype)
200
+
201
+ # If triple-fused, use hess from cache
202
+ if has_fused_f_grad_hess:
203
+ hess = f_cached.hess
173
204
 
174
- use_jac = has_fused_f_and_grad or minimizer_kwargs.get("jac", False)
175
205
  progress = initialize_progress_bar(progressbar, use_jac=use_jac, use_hess=False)
176
206
 
177
207
  if progressbar:
@@ -188,6 +218,11 @@ def basinhopping(
188
218
  hess_norm=0.0,
189
219
  )
190
220
 
221
+ n_vars = x0.size if hasattr(x0, "size") else len(x0)
222
+ if "options" not in minimizer_kwargs:
223
+ minimizer_kwargs["options"] = {}
224
+ minimizer_maxiter, minimizer_kwargs = determine_maxiter(minimizer_kwargs, method, n_vars)
225
+
191
226
  minimize_task = progress.add_task(
192
227
  description="Minimize",
193
228
  name="Minimize",
@@ -196,18 +231,28 @@ def basinhopping(
196
231
  f_value=0.0,
197
232
  grad_norm=0.0,
198
233
  hess_norm=0.0,
234
+ total=minimizer_maxiter,
199
235
  )
200
236
 
201
- wrapped_minimizer = MinimizerWrapper(
202
- minimize,
203
- func,
204
- progressbar=progress,
205
- progress_task=minimize_task,
206
- verbose=verbose,
207
- **minimizer_kwargs,
208
- )
237
+ def wrapped_minimize(x0_inner, *args, **kwargs):
238
+ # Always keep the minimizer progress bar's total fixed at the requested maxiter
239
+ progress.update(minimize_task, total=minimizer_maxiter)
240
+ return minimize(
241
+ method=method,
242
+ f=f_cached.value_and_grad if has_fused_f_and_grad else f_cached.value,
243
+ x0=x0_inner,
244
+ args=args,
245
+ jac=None if not use_jac else (True if has_fused_f_and_grad else jac),
246
+ hess=None if not use_hess else hess,
247
+ hessp=None if not use_hessp else hessp,
248
+ progressbar=progress,
249
+ progress_task=minimize_task,
250
+ verbose=verbose,
251
+ **minimizer_kwargs,
252
+ )
253
+
254
+ wrapped_minimizer = MinimizerWrapper(wrapped_minimize)
209
255
 
210
- # set up step-taking algorithm
211
256
  if take_step is not None:
212
257
  if not callable(take_step):
213
258
  raise TypeError("take_step must be callable")
@@ -268,9 +313,19 @@ def basinhopping(
268
313
 
269
314
  grad_norm_at_min = 0.0
270
315
  if use_jac:
271
- _, grad_val = func(bh.x)
316
+ grad_val = f_cached.grad(bh.x, *args)
272
317
  grad_norm_at_min = np.linalg.norm(grad_val)
273
318
 
319
+ # Update the progress bar with initial values
320
+ progress.update(
321
+ bh_task,
322
+ advance=1,
323
+ step_size=take_step_wrapped.takestep.stepsize,
324
+ target_accept=take_step_wrapped.target_accept_rate,
325
+ f_value=bh.storage.get_lowest().fun,
326
+ grad_norm=grad_norm_at_min,
327
+ )
328
+
274
329
  with progress:
275
330
  for i in range(niter):
276
331
  if progressbar:
@@ -10,7 +10,8 @@ from scipy.sparse.linalg import LinearOperator
10
10
 
11
11
  from better_optimize.constants import minimize_method
12
12
  from better_optimize.utilities import (
13
- check_f_is_fused,
13
+ LRUCache1,
14
+ check_f_is_fused_minimize,
14
15
  determine_maxiter,
15
16
  determine_tolerance,
16
17
  kwargs_to_options,
@@ -69,11 +70,18 @@ def minimize(
69
70
  Optimization result
70
71
 
71
72
  """
72
- has_fused_f_and_grad = check_f_is_fused(f, x0, args)
73
- validate_provided_functions_minimize(
74
- method, jac, hess, hessp, has_fused_f_and_grad, verbose=verbose
73
+ has_fused_f_and_grad, has_fused_f_grad_hess = check_f_is_fused_minimize(f, x0, args)
74
+
75
+ use_grad, use_hess, use_hessp = validate_provided_functions_minimize(
76
+ method, jac, hess, hessp, has_fused_f_and_grad, has_fused_f_grad_hess, verbose=verbose
75
77
  )
76
78
 
79
+ f_returns_list = has_fused_f_and_grad or has_fused_f_grad_hess
80
+ f_cached = LRUCache1(f, f_returns_list=f_returns_list, copy_x=False, dtype=x0.dtype)
81
+
82
+ if has_fused_f_grad_hess:
83
+ hess = f_cached.hess
84
+
77
85
  options = optimizer_kwargs.pop("options", {})
78
86
  optimizer_kwargs["options"] = options
79
87
 
@@ -83,12 +91,11 @@ def minimize(
83
91
 
84
92
  # Test hessian function -- if it returns a LinearOperator, it can't be used inside the wrapper
85
93
  args = () if args is None else args
86
- use_hess = hess is not None and not isinstance(hess(x0, *args), LinearOperator)
87
- use_hessp = hessp is not None
94
+ use_hess = use_hess and not isinstance(hess(x0, *args), LinearOperator)
88
95
 
89
96
  objective = ObjectiveWrapper(
90
97
  maxeval=maxiter,
91
- f=f,
98
+ f=f_cached.value_and_grad if has_fused_f_and_grad else f_cached.value,
92
99
  jac=jac,
93
100
  hess=hess if use_hess else None,
94
101
  args=args,
@@ -8,7 +8,8 @@ from scipy.optimize import root as sp_root
8
8
 
9
9
  from better_optimize.constants import root_method
10
10
  from better_optimize.utilities import (
11
- check_f_is_fused,
11
+ LRUCache1,
12
+ check_f_is_fused_root,
12
13
  determine_maxiter,
13
14
  determine_tolerance,
14
15
  kwargs_to_jac_options,
@@ -65,10 +66,10 @@ def root(
65
66
  Optimization result
66
67
 
67
68
  """
68
- has_fused_f_and_grad = check_f_is_fused(f, x0, args)
69
- validate_provided_functions_root(
70
- method, jac, has_fused_f_and_grad, has_fused_f_and_grad, verbose=verbose
71
- )
69
+ has_fused_f_and_grad = check_f_is_fused_root(f, x0, args)
70
+ validate_provided_functions_root(method, f, jac, has_fused_f_and_grad, verbose=verbose)
71
+
72
+ f_cached = LRUCache1(f, f_returns_list=has_fused_f_and_grad, copy_x=True, dtype=x0.dtype)
72
73
 
73
74
  options = optimizer_kwargs.pop("options", {})
74
75
  optimizer_kwargs["options"] = options
@@ -80,7 +81,7 @@ def root(
80
81
 
81
82
  objective = ObjectiveWrapper(
82
83
  maxeval=maxiter,
83
- f=f,
84
+ f=f_cached.value_and_grad if has_fused_f_and_grad else f_cached.value,
84
85
  jac=jac,
85
86
  args=args,
86
87
  progressbar=progressbar,
@@ -154,19 +154,36 @@ def validate_provided_functions_minimize(
154
154
  f_hess: Callable[[np.ndarray], np.ndarray] | None,
155
155
  f_hessp: Callable[[np.ndarray], np.ndarray] | None,
156
156
  has_fused_f_and_grad: bool,
157
+ has_fused_f_grad_hess: bool,
157
158
  verbose=True,
158
- ) -> None:
159
+ ) -> tuple[bool, bool, bool]:
159
160
  has_grad, has_hess, has_hessp = map(lambda f: f is not None, [f_grad, f_hess, f_hessp])
160
161
  uses_grad, uses_hess, uses_hessp, *_ = MINIMIZE_MODE_KWARGS[method].values()
161
162
 
162
- if has_fused_f_and_grad and has_grad:
163
- _log.warning(
164
- "Objective function returns a tuple (interpreted as (loss, gradient), but a gradient function was "
165
- "also provided. The gradient function will be ignored."
166
- )
163
+ # Handle fused outputs first
164
+ if has_fused_f_grad_hess:
165
+ if verbose and (has_grad or has_hess):
166
+ _log.warning(
167
+ "Objective function returns a tuple (interpreted as (loss, gradient, hessian)), "
168
+ "but a gradient or hessian function was also provided. The gradient and hessian functions will be ignored."
169
+ )
170
+ if verbose and has_hessp:
171
+ _log.warning(
172
+ "Objective function returns a tuple (interpreted as (loss, gradient, hessian)), "
173
+ "but a hessian-vector product function was also provided. The hessian-vector product function will be ignored."
174
+ )
175
+ # Triple-fused disables external grad/hess/hessp
176
+ has_grad = True
177
+ has_hess = True
178
+ has_hessp = False
167
179
 
168
180
  elif has_fused_f_and_grad:
169
- has_grad = True
181
+ if verbose and has_grad:
182
+ _log.warning(
183
+ "Objective function returns a tuple (interpreted as (loss, gradient)), "
184
+ "but a gradient function was also provided. The gradient function will be ignored."
185
+ )
186
+ has_grad = True # fused (loss, grad) disables external grad
170
187
 
171
188
  if method not in MINIMIZE_MODE_KWARGS:
172
189
  raise ValueError(
@@ -179,31 +196,33 @@ def validate_provided_functions_minimize(
179
196
  "except trust-exact and dogleg, use_hessp is preferred."
180
197
  )
181
198
 
182
- if has_grad and not uses_grad and verbose:
199
+ if verbose and (has_grad and not uses_grad):
183
200
  _log.warning(
184
201
  f"Gradient provided but not used by method {method}. Gradients will still be evaluated at each "
185
- f"optimzer step and the norm will be reported as a diagnositc. For large problems, this may be "
202
+ f"optimizer step and the norm will be reported as a diagnostic. For large problems, this may be "
186
203
  f"computationally intensive."
187
204
  )
188
205
 
189
- if (has_hess and not uses_hess) or (has_hessp and not uses_hessp) and verbose:
206
+ if verbose and ((has_hess and not uses_hess) or (has_hessp and not uses_hessp)):
190
207
  _log.warning(
191
- f"Hessian provided but not used by method {method}. The full hessian will still be evaluated at "
192
- f"each optimzer step and the norm will be reported as a diagnositc. For large problems, this may "
193
- f"be computationally intensive."
208
+ f"Hessian or Hessian-vector product provided but not used by method {method}. "
209
+ f"The full Hessian or Hessian-vector product will still be evaluated at each optimizer step and "
210
+ f"the norm will be reported as a diagnostic. For large problems, this may be computationally intensive."
194
211
  )
195
212
 
196
- if has_hess and not has_hessp and uses_hessp and uses_hess and verbose:
213
+ if verbose and (has_hess and not has_hessp and uses_hessp and uses_hess):
197
214
  _log.warning(
198
- f"You provided a function to compute the full hessian, but method {method} allows the use of a "
199
- f"hessian-vector product instead. Consider passing hessp instead -- this may be significantly "
215
+ f"You provided a function to compute the full Hessian, but method {method} allows the use of a "
216
+ f"Hessian-vector product instead. Consider passing hessp instead -- this may be significantly "
200
217
  f"more efficient."
201
218
  )
202
219
 
220
+ return has_grad, has_hess, has_hessp
221
+
203
222
 
204
223
  def validate_provided_functions_root(
205
224
  method: root_method, f, jac, has_fused_f_and_grad: bool, verbose: bool = True
206
- ):
225
+ ) -> bool:
207
226
  has_jac = jac is not None
208
227
  info_dict = get_option_kwargs(method)
209
228
  uses_jac = info_dict["uses_jac"]
@@ -224,19 +243,87 @@ def validate_provided_functions_root(
224
243
  f"computationally intensive."
225
244
  )
226
245
 
246
+ return has_jac
247
+
227
248
 
228
- def check_f_is_fused(f, x0, args):
249
+ def check_f_is_fused_minimize(f, x0, args) -> tuple[bool, bool]:
250
+ """
251
+ Check if the minimize objective function returns fused outputs (value, grad[, hess]).
252
+ Returns (is_fused, has_hess).
253
+ """
229
254
  args = () if args is None else args
230
255
  output = f(x0, *args)
256
+
257
+ if not isinstance(output, tuple | list):
258
+ return (False, False)
259
+
260
+ if len(output) == 2:
261
+ value, grad = output
262
+ hess = None
263
+ ret_val = (True, False)
264
+ elif len(output) == 3:
265
+ value, grad, hess = output
266
+ ret_val = (True, True)
267
+ else:
268
+ raise ValueError(
269
+ "Objective function should return either a scalar loss, a two-tuple of (loss, gradient), "
270
+ "or three-tuple of (loss, gradient, hessian)."
271
+ )
272
+
273
+ if not (
274
+ np.isscalar(value)
275
+ or (hasattr(value, "shape") and (value.shape == () or value.shape == (1,)))
276
+ ):
277
+ raise ValueError(
278
+ "First value returned by the objective function must be a scalar (float or 0-d array)."
279
+ )
280
+
281
+ if not (hasattr(grad, "ndim") and grad.ndim == 1):
282
+ raise ValueError(
283
+ "Second value returned by the objective function must be a 1d array representing the gradient."
284
+ )
285
+
286
+ if hess is not None:
287
+ if not (hasattr(hess, "ndim") and hess.ndim == 2):
288
+ raise ValueError(
289
+ "Third value returned by the objective function must be a 2d array representing the Hessian."
290
+ )
291
+
292
+ return ret_val
293
+
294
+
295
+ def check_f_is_fused_root(f, x0, args) -> bool:
296
+ """
297
+ Check if the root objective function returns fused outputs (value[, jac]), and returns True if it does.
298
+ """
299
+ args = () if args is None else args
300
+ output = f(x0, *args)
301
+
231
302
  if not isinstance(output, tuple | list):
232
303
  return False
233
304
 
234
- # If the output is a tuple, it should be length 2 (returning the value and the grad).If not, something is wrong
235
- if len(output) != 2:
305
+ if len(output) == 2:
306
+ value, jac = output
307
+ ret_val = True
308
+ elif len(output) == 1:
309
+ value = output[0]
310
+ jac = None
311
+ ret_val = True
312
+ else:
236
313
  raise ValueError(
237
- "Objective function should return either a scalar loss or a two-tuple of (loss, gradient)"
314
+ "Objective function should return either a 1d array or a two-tuple of (value, jacobian)."
238
315
  )
239
- return True
316
+
317
+ if not (hasattr(value, "ndim") and value.ndim == 1):
318
+ raise ValueError("First value returned by the objective function must be a 1d array.")
319
+
320
+ if jac is not None:
321
+ if not (hasattr(jac, "ndim") and jac.ndim == 2):
322
+ raise ValueError(
323
+ "Second value returned by the objective function must be a 2d array representing the Jacobian."
324
+ )
325
+
326
+ return ret_val
240
327
 
241
328
 
242
329
  def determine_maxiter(
@@ -314,3 +401,102 @@ def determine_tolerance(optimizer_kwargs: dict, method: minimize_method | root_m
314
401
  optimizer_kwargs["options"][tol_name] = tol
315
402
 
316
403
  return optimizer_kwargs
404
+
405
+
406
+ class LRUCache1:
407
+ """
408
+ Simple LRU cache with a memory size of 1.
409
+
410
+ This cache is only usable for a function that takes a single input `x` and returns a single output. The
411
+ function can also take any number of additional arguments `*args`, but these are assumed to be constant
412
+ between function calls.
413
+
414
+ The purpose of this cache is to allow for Hessian computation to be reused when calling scipy.optimize functions.
415
+ It is very often the case that some sub-computations are repeated between the objective, gradient, and hessian
416
+ functions, but by default scipy only allows for the objective and gradient to be fused.
417
+
418
+ By using this cache, all 3 functions can be fused, which can significantly speed up the optimization process for
419
+ expensive functions.
420
+ """
421
+
422
+ def __init__(
423
+ self, fn, f_returns_list: bool = False, copy_x: bool = False, dtype: str | None = None
424
+ ):
425
+ self.fn = fn
426
+ self.last_x = None
427
+ self.last_result = None
428
+ self.copy_x = copy_x
429
+ self.f_returns_list = f_returns_list
430
+
431
+ # Scipy does not respect dtypes *at all*, so we have to force it ourselves.
432
+ self.dtype = dtype
433
+
434
+ self.cache_hits = 0
435
+ self.cache_misses = 0
436
+
437
+ self.value_calls = 0
438
+ self.grad_calls = 0
439
+ self.value_and_grad_calls = 0
440
+ self.hess_calls = 0
441
+
442
+ def __call__(self, x, *args):
443
+ """
444
+ Call the cached function with the given input `x` and additional arguments `*args`.
445
+
446
+ If the input `x` is the same as the last input, return the cached result. Otherwise update the cache with the
447
+ new input and result.
448
+ """
449
+ x = x.astype(self.dtype)
450
+
451
+ if self.last_result is None or not (x == self.last_x).all():
452
+ self.cache_misses += 1
453
+
454
+ # scipy.optimize.root changes x in place, so the cache has to copy it, otherwise we get false
455
+ # cache hits and optimization always fails.
456
+ if self.copy_x:
457
+ x = x.copy()
458
+ self.last_x = x
459
+
460
+ result = self.fn(x, *args)
461
+ self.last_result = result
462
+
463
+ return result
464
+
465
+ else:
466
+ self.cache_hits += 1
467
+ return self.last_result
468
+
469
+ def value(self, x, *args):
470
+ self.value_calls += 1
471
+ if not self.f_returns_list:
472
+ return self(x, *args)
473
+ else:
474
+ return self(x, *args)[0]
475
+
476
+ def grad(self, x, *args):
477
+ self.grad_calls += 1
478
+ return self(x, *args)[1]
479
+
480
+ def value_and_grad(self, x, *args):
481
+ self.value_and_grad_calls += 1
482
+ return self(x, *args)[:2]
483
+
484
+ def hess(self, x, *args):
485
+ self.hess_calls += 1
486
+ return self(x, *args)[-1]
487
+
488
+ def report(self):
489
+ _log.info(f"Value and Grad calls: {self.value_and_grad_calls}")
490
+ _log.info(f"Hess Calls: {self.hess_calls}")
491
+ _log.info(f"Hits: {self.cache_hits}")
492
+ _log.info(f"Misses: {self.cache_misses}")
493
+
494
+ def clear_cache(self):
495
+ self.last_x = None
496
+ self.last_result = None
497
+ self.cache_hits = 0
498
+ self.cache_misses = 0
499
+ self.value_calls = 0
500
+ self.grad_calls = 0
501
+ self.value_and_grad_calls = 0
502
+ self.hess_calls = 0
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.1
2
+ Name: better-optimize
3
+ Version: 0.1.4
4
+ Summary: A drop-in replacement for scipy optimize functions with quality of life improvements
5
+ License: MIT License
6
+ Project-URL: Homepage, https://github.com/jessegrabowski/better_optimize
7
+ Project-URL: Repository, https://github.com/jessegrabowski/better_optimize.git
8
+ Project-URL: Issues, https://github.com/jessegrabowski/better_optimize/issues
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
18
+ Requires-Python: <3.14,>=3.10
19
+ Description-Content-Type: text/markdown
20
+ Provides-Extra: tests
21
+ License-File: LICENSE
22
+
23
+ # Better Optimization!
24
+
25
+ `better_optimize` is a friendlier front-end to scipy's `optimize.minimize` and `optimize.root` functions. Features
26
+ include:
27
+
28
+ - Progress bar!
29
+ - Early stopping!
30
+ - Better propagation of common arguments (`maxiters`, `tol`)!
31
+
32
+ ## Installation
33
+
34
+ To install `better_optimize`, simply use conda:
35
+
36
+ ```bash
37
+ conda install -c conda-forge better_optimize
38
+ ```
39
+
40
+ Or, if you prefer pip:
41
+
42
+ ```bash
43
+ pip install better_optimize
44
+ ```
45
+
46
+ ## What does `better_optimize` provide over basic scipy?
47
+
48
+ ### 1. Progress Bars
49
+
50
+ All optimization routines in `better_optimize` can display a rich, informative progress bar using the [rich](https://github.com/Textualize/rich) library. This includes:
51
+
52
+ - Iteration counts, elapsed time, and objective values.
53
+ - Gradient and Hessian norms (when available).
54
+ - Separate progress bars for global (basinhopping) and local (minimizer) steps.
55
+ - Toggleable display for headless or script environments.
56
+
57
+ ### 2. Flat and Generalized Keyword Arguments
58
+
59
+ - No more nested `options` dictionaries! You can pass `tol`, `maxiter`, and other common options directly as top-level keyword arguments.
60
+ - `better_optimize` automatically sorts and promotes these arguments to the correct place for each optimizer.
61
+ - Generalizes argument handling: always provides `tol` and `maxiter` (or their equivalents) to the optimizer, even if you forget.
62
+
63
+ ### 3. Argument Checking and Validation
64
+
65
+ - Automatic checking of provided gradient (`jac`), Hessian (`hess`), and Hessian-vector (`hessp`) functions.
66
+ - Warns if you provide unnecessary or unused arguments for a given method.
67
+ - Detects and handles fused objective functions (e.g., functions returning `(loss, grad)` or `(loss, grad, hess)` tuples).
68
+ - Ensures that the correct function signatures and return types are used for each optimizer.
69
+
70
+ ### 4. LRUCache1 for Fused Functions
71
+
72
+ - Provides an `LRUCache1` utility to cache the results of expensive objective/gradient/Hessian computations.
73
+ - Especially useful for triple-fused functions that return value, gradient, and Hessian together, avoiding redundant computation.
74
+ - Totally invisible -- just pass a function with 3 return values. Seamlessly integrated into the optimization workflow.
75
+
76
+ ### 5. Robust Basin-Hopping with Failure Tolerance
77
+
78
+ - Enhanced `basinhopping` implementation allows you to continue even if the local minimizer fails.
79
+ - Optionally accepts and stores failed minimizer results if they improve the global minimum.
80
+ - Useful for noisy or non-smooth objective functions where local minimization may occasionally fail.
81
+
82
+ ---
83
+
84
+ ## Example Usage
85
+
86
+ ### Simple Example
87
+
88
+ ```python
89
+ from better_optimize import minimize
90
+
91
+ def rosenbrock(x):
92
+ return sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
93
+
94
+ result = minimize(
95
+ rosenbrock,
96
+ x0=[-1, 2],
97
+ method="L-BFGS-B",
98
+ tol=1e-6,
99
+ maxiter=1000,
100
+ progressbar=True, # Show a rich progress bar!
101
+ )
102
+ ```
103
+
104
+ ```shell
105
+ Minimizing Elapsed Iteration Objective ||grad||
106
+ ──────────────────────────────────────────────────────────────────────────────────────────────────
107
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0:00:00 721/721 0.34271757 0.92457651
108
+ ```
109
+
110
+ The result object is a standard `OptimizeResult` from `scipy.optimize`, so there are no surprises there!
111
+
112
+ ### Triple-Fused Function using Pytensor
113
+
114
+ ```python
115
+ from better_optimize import minimize
116
+ import pytensor.tensor as pt
117
+ from pytensor import function
118
+ import numpy as np
119
+
120
+ x = pt.vector('x')
121
+ value = pt.sum(100.0*(x[1:] - x[:-1]**2.0)**2.0 + (1 - x[:-1])**2.0)
122
+ grad = pt.grad(value, x)
123
+ hess = pt.hessian(value, x)
124
+
125
+ fused_fn = function([x], [value, grad, hess])
126
+ x0 = np.array([1.3, 0.7, 0.8, 1.9, 1.2])
127
+
128
+ result = minimize(
129
+ fused_fn, # No need to set flags separately, `better_optimize` handles it!
130
+ x0=x0,
131
+ method="Newton-CG",
132
+ tol=1e-6,
133
+ maxiter=1000,
134
+ progressbar=True, # Show a rich progress bar!
135
+ )
136
+ ```
137
+
138
+ Many sub-computations are repeated between the objective, gradient, and hessian functions. Scipy allows you to pass a
139
+ fused value_and_grad function, but `better_optimize` also lets you pass a triple-fused value_grad_and_hess function.
140
+ This avoids redundant computation and speeds up the optimization process.
141
+
142
+
143
+ ## Contributing
144
+
145
+ We welcome contributions! If you find a bug, have a feature request, or want to improve the documentation, please open
146
+ an issue or submit a pull request on GitHub.
@@ -1,30 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: better_optimize
3
- Version: 0.1.2
4
- Summary: A drop-in replacement for scipy optimize functions with quality of life improvements
5
- License: MIT License
6
- Project-URL: Homepage, https://github.com/jessegrabowski/better_optimize
7
- Project-URL: Repository, https://github.com/jessegrabowski/better_optimize.git
8
- Project-URL: Issues, https://github.com/jessegrabowski/better_optimize/issues
9
- Classifier: Development Status :: 4 - Beta
10
- Classifier: Intended Audience :: Science/Research
11
- Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3.10
13
- Classifier: Programming Language :: Python :: 3.11
14
- Classifier: Programming Language :: Python :: 3.12
15
- Classifier: Programming Language :: Python :: 3.13
16
- Classifier: Topic :: Scientific/Engineering
17
- Classifier: Topic :: Scientific/Engineering :: Mathematics
18
- Requires-Python: <3.14,>=3.10
19
- Description-Content-Type: text/markdown
20
- Provides-Extra: tests
21
- License-File: LICENSE
22
-
23
- # Better Optimization!
24
-
25
- `better_optimize` is a friendlier front-end to scipy's `optimize.minimize` and `optimize.root` functions. Features
26
- include:
27
-
28
- - Progress bar!
29
- - Early stopping!
30
- - Better propagation of common arguments (`maxiters`, `tol`)!
@@ -1,8 +0,0 @@
1
- # Better Optimization!
2
-
3
- `better_optimize` is a friendlier front-end to scipy's `optimize.minimize` and `optimize.root` functions. Features
4
- include:
5
-
6
- - Progress bar!
7
- - Early stopping!
8
- - Better propagation of common arguments (`maxiters`, `tol`)!
@@ -1,30 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: better-optimize
3
- Version: 0.1.2
4
- Summary: A drop-in replacement for scipy optimize functions with quality of life improvements
5
- License: MIT License
6
- Project-URL: Homepage, https://github.com/jessegrabowski/better_optimize
7
- Project-URL: Repository, https://github.com/jessegrabowski/better_optimize.git
8
- Project-URL: Issues, https://github.com/jessegrabowski/better_optimize/issues
9
- Classifier: Development Status :: 4 - Beta
10
- Classifier: Intended Audience :: Science/Research
11
- Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3.10
13
- Classifier: Programming Language :: Python :: 3.11
14
- Classifier: Programming Language :: Python :: 3.12
15
- Classifier: Programming Language :: Python :: 3.13
16
- Classifier: Topic :: Scientific/Engineering
17
- Classifier: Topic :: Scientific/Engineering :: Mathematics
18
- Requires-Python: <3.14,>=3.10
19
- Description-Content-Type: text/markdown
20
- Provides-Extra: tests
21
- License-File: LICENSE
22
-
23
- # Better Optimization!
24
-
25
- `better_optimize` is a friendlier front-end to scipy's `optimize.minimize` and `optimize.root` functions. Features
26
- include:
27
-
28
- - Progress bar!
29
- - Early stopping!
30
- - Better propagation of common arguments (`maxiters`, `tol`)!
File without changes