Skip to content
Open
2 changes: 1 addition & 1 deletion pyoptsparse/pyCONMIN/pyCONMIN.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ def cnmngrad(n1, n2, x, f, g, ct, df, a, ic, nac):
dabfun = self.getOption("DABFUN")

itrm = self.getOption("ITRM")
nfeasct = self.getOption("ITRM")
nfeasct = self.getOption("NFEASCT")
nfdg = 1 # User will supply all gradients

# Counters for functions and gradients
Expand Down
39 changes: 31 additions & 8 deletions pyoptsparse/pyOpt_gradient.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Standard Python modules
from typing import Literal

# External modules
import numpy as np
import numpy.typing as npt
Expand All @@ -9,7 +12,14 @@


class Gradient:
def __init__(self, optProb: Optimization, sensType: str, sensStep: float = None, sensMode: str = "", comm=None):
def __init__(
self,
optProb: Optimization,
sensType: Literal["fd", "cd", "fdr", "cdr", "cs"],
sensStep: complex | None = None,
sensMode: str = "",
comm=None,
):
"""
Gradient class for automatically computing gradients with finite
difference or complex step.
Expand All @@ -20,14 +30,15 @@ def __init__(self, optProb: Optimization, sensType: str, sensStep: float = None,
This is the complete description of the optimization problem.

sensType : str
- ``FD`` for forward difference
- ``CD`` for central difference
- ``FDR`` for forward difference with relative step size
- ``CDR`` for central difference with relative step size
- ``CS`` for complex step
- ``fd`` for forward difference
- ``cd`` for central difference
- ``fdr`` for forward difference with relative step size
- ``cdr`` for central difference with relative step size
- ``cs`` for complex step

sensStep : float
Step size to use for differencing
sensStep : float | complex, optional
Step size to use for differencing. By default ``1e-4`` for ``fd/fdr``, ``1e-6``` for ``cd/cdr``, ``1e-40j`` for ``cs``.
Must be a purely imaginary value for ``cs``.

sensMode : str
Flag to compute gradients in parallel.
Expand All @@ -44,6 +55,18 @@ def __init__(self, optProb: Optimization, sensType: str, sensStep: float = None,
self.sensStep = 1e-40j
else:
self.sensStep = sensStep

if self.sensType == "cs":
# Complex step divides by the imaginary part of the step, so a purely
# real step would silently yield NaN gradients.
if np.imag(self.sensStep) == 0:
raise ValueError(f"The complex step size must have a nonzero imaginary part, got {self.sensStep}.")

# A nonzero real part would perturb x along the real axis as well, corrupting the function
# value used implicitly in the complex-step formula.
if np.real(self.sensStep) != 0:
raise ValueError(f"The complex step size must have a zero real part, got {self.sensStep}.")

self.sensMode = sensMode
self.comm = comm

Expand Down
16 changes: 6 additions & 10 deletions pyoptsparse/testing/pyOpt_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,46 +356,42 @@ def check_hist_file(self, tol):
for varName in self.DVs:
assert_allclose(val[varName].flatten(), self.xStar[self.sol_index][varName], atol=tol, rtol=tol)

def optimize_with_hotstart(self, tol, optOptions=None, x0=None):
def optimize_with_hotstart(self, tol, x0=None, **kwargs):
"""
This code will perform 4 optimizations, one real opt and three restarts.
In this process, it will check various combinations of storeHistory and hotStart filenames.
It will also call `check_hist_file` after the first optimization.
"""
# we use a non-default starting point to test that the hotstart works
# even if it does not match optProb initial values
sol = self.optimize(storeHistory=True, optOptions=optOptions, setDV=x0)
sol = self.optimize(storeHistory=True, setDV=x0, **kwargs)
self.assert_solution_allclose(sol, tol)
self.assertGreater(self.nf, 0)
if self.optName in GRAD_BASED_OPTIMIZERS:
self.assertGreater(self.ng, 0)
self.check_hist_file(tol)

# re-optimize with hotstart
sol = self.optimize(storeHistory=False, hotStart=True, optOptions=optOptions)
sol = self.optimize(storeHistory=False, hotStart=True, **kwargs)
self.assert_solution_allclose(sol, tol)
# we should have zero actual function/gradient evaluations
self.assertEqual(self.nf, 0)
self.assertEqual(self.ng, 0)
# another test with hotstart, this time with storeHistory = hotStart
sol = self.optimize(storeHistory=True, hotStart=True, optOptions=optOptions)
sol = self.optimize(storeHistory=True, hotStart=True, **kwargs)
self.assert_solution_allclose(sol, tol)
# we should have zero actual function/gradient evaluations
self.assertEqual(self.nf, 0)
self.assertEqual(self.ng, 0)
# another test with hotstart, this time with a non-existing history file
# this will perform a cold start
self.optimize(storeHistory=True, hotStart="notexisting.hst", optOptions=optOptions)
self.optimize(storeHistory=True, hotStart="notexisting.hst", **kwargs)
self.assertGreater(self.nf, 0)
if self.optName in GRAD_BASED_OPTIMIZERS:
self.assertGreater(self.ng, 0)
self.check_hist_file(tol)
# final test with hotstart, this time with a different storeHistory
sol = self.optimize(
storeHistory=f"{self.id()}_new_hotstart.hst",
hotStart=True,
optOptions=optOptions,
)
sol = self.optimize(storeHistory=f"{self.id()}_new_hotstart.hst", hotStart=True, **kwargs)
self.assert_solution_allclose(sol, tol)
# we should have zero actual function/gradient evaluations
self.assertEqual(self.nf, 0)
Expand Down
6 changes: 6 additions & 0 deletions tests/test_gradient.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ def test_scaling(self):
funcsSens, _ = grad(X0, funcs)
assert_sens_matches_analytic(funcsSens, 1e-12)

@parameterized.expand([("real_step", 1e-40), ("nonzero_real_part", 1 + 1e-40j)])
def test_cs_invalid_step_raises(self, _, sensStep):
optProb = build_optProb()
with self.assertRaises(ValueError):
Gradient(optProb, sensType="cs", sensStep=sensStep)


if __name__ == "__main__":
unittest.main()
15 changes: 15 additions & 0 deletions tests/test_sphere.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Test solution of Sphere problem"""

# Standard Python modules
from itertools import product
import unittest

# External modules
Expand Down Expand Up @@ -57,6 +58,9 @@ class TestSphere(OptTest):
"maxGen": 100,
"seed": 123,
},
"CONMIN": { # CONMIN diverges when gradient is near zero, here we stop on first optimal iterate
"ITRM": 1,
},
"SNOPT": {
"Major iterations limit": 10,
},
Expand Down Expand Up @@ -101,6 +105,17 @@ def test_optimization(self, optName):
optOptions = self.optOptions.get(optName, {})
self.optimize_with_hotstart(self.tol[optName], optOptions=optOptions)

@parameterized.expand(
product(ALL_OPTIMIZERS, ["fd", "fdr", "cd", "cdr", "cs"]),
name_func=lambda f, n, p: f"{f.__name__}_{p.args[0]}_{p.args[1]}",
)
def test_optimization_approx_deriv(self, optName, sens):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only new test added here, the rest are from the other branch (the diff will look better once the other PR is merged).

self.optName = optName
self.setup_optProb()
optOptions = self.optOptions.get(optName, {})
sol = self.optimize(optOptions=optOptions, sens=sens)
self.assert_solution_allclose(sol, self.tol[optName])

@parameterized.expand(["filtersqp", "funnelsqp"])
def test_uno_presets(self, preset):
self.optName = "Uno"
Expand Down
Loading