Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions tests/test_gradient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Standard Python modules
import unittest

# External modules
import numpy as np
from numpy.testing import assert_allclose
from parameterized import parameterized

# First party modules
from pyoptsparse import Optimization
from pyoptsparse.pyOpt_gradient import Gradient

# Base point at which we evaluate the derivatives
X0 = {"x": [1.5, -2.0], "y": 0.5}

# Analytic Jacobian at X0
ANALYTIC = {
"obj": {"x": [3.0, -8.0], "y": 3.0},
"c": {"x": [[-2.0, 1.5]], "y": 1.0},
}


def objfunc(xdict):
"""
Obj = x0^2 + 2*x1^2 + 3*y^2
c = x0*x1 + y
"""
x, y = xdict["x"], xdict["y"]
funcs = {}
funcs["obj"] = x[0] ** 2 + 2 * x[1] ** 2 + 3 * y**2
funcs["c"] = x[0] * x[1] + y
return funcs, False


def build_optProb(objfun=objfunc, xScale=1.0, conScale=1.0):
Comment thread
marcomangano marked this conversation as resolved.
optProb = Optimization("grad-test", objfun)
optProb.addVarGroup("x", 2, lower=-10, upper=10, value=X0["x"], scale=xScale)
optProb.addVar("y", lower=-10, upper=10, value=X0["y"], scale=xScale)
optProb.addObj("obj")
optProb.addCon("c", lower=-100, upper=100, scale=conScale)
optProb.finalize()
return optProb


def assert_sens_matches_analytic(funcsSens, atol):
for funcKey, perGroup in ANALYTIC.items():
for dvGroup, expected in perGroup.items():
assert_allclose(funcsSens[funcKey][dvGroup], expected, atol=atol)


class TestGradient(unittest.TestCase):
@parameterized.expand(["fd", "fdr", "cd", "cdr", "cs"])
def test_mode_matches_analytic(self, sensType):
optProb = build_optProb()
funcs, _ = objfunc(X0)
grad = Gradient(optProb, sensType=sensType)
funcsSens, fail = grad(X0, funcs)
self.assertFalse(fail)
atol = 1e-12 if sensType == "cs" else 1e-5
assert_sens_matches_analytic(funcsSens, atol=atol)

# test that we get real derivs for cs
if sensType == "cs":
Comment thread
marcomangano marked this conversation as resolved.
for funcKey in ANALYTIC:
for dvGroup in ANALYTIC[funcKey]:
self.assertFalse(np.iscomplexobj(funcsSens[funcKey][dvGroup]))

def test_failed_eval(self):
Comment thread
marcomangano marked this conversation as resolved.
def always_fail(xdict):
funcs, _ = objfunc(xdict)
return funcs, True

optProb = build_optProb(objfun=always_fail)
funcs, _ = objfunc(X0)
grad = Gradient(optProb, sensType="fd")
_, fail = grad(X0, funcs)
self.assertTrue(fail)

def test_scaling(self):
optProb = build_optProb(xScale=7.0, conScale=0.3)
funcs, _ = objfunc(X0)
grad = Gradient(optProb, sensType="cs")
funcsSens, _ = grad(X0, funcs)
assert_sens_matches_analytic(funcsSens, 1e-12)


if __name__ == "__main__":
unittest.main()
119 changes: 119 additions & 0 deletions tests/test_optProb.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

# First party modules
from pyoptsparse import OPT, Optimization
from pyoptsparse.pyOpt_utils import INFINITY, convertToCSR, convertToDense
from pyoptsparse.testing.pyOpt_testing import assert_optProb_size


Expand Down Expand Up @@ -295,5 +296,123 @@ def test_parallel_add(self):
self.assertEqual(allConNames[0], allConNames[1])


class TestScaling(unittest.TestCase):
def setUp(self):
# Distinct, non-trivial per-element scales and offsets so that any
# mixed-up indexing or row/column confusion shows up.
self.xScale = {"x": [2.0, 0.5, 4.0], "y": [10.0, 0.1]}
self.xOffset = {"x": [1.0, -2.0, 0.5], "y": [0.0, 3.0]}
self.objScale = 3.0
self.conScaleVals = {"c1": [5.0, 0.2], "c2": [7.0]}

def objfunc(xdict):
# Never actually called in these tests, but required by the API.
return {"obj": 0.0, "c1": np.zeros(2), "c2": np.zeros(1)}, False

optProb = Optimization("scaling-test", objfunc)
optProb.addVarGroup("x", 3, lower=-10, upper=10, scale=self.xScale["x"], offset=self.xOffset["x"])
optProb.addVarGroup("y", 2, lower=-10, upper=10, scale=self.xScale["y"], offset=self.xOffset["y"])
optProb.addObj("obj", scale=self.objScale)
optProb.addConGroup("c1", 2, lower=-1, upper=1, scale=self.conScaleVals["c1"])
optProb.addConGroup("c2", 1, lower=-1, upper=1, scale=self.conScaleVals["c2"])
optProb.finalize()

self.optProb = optProb
self.ndvs = optProb.ndvs
self.nCon = optProb.nCon
# invXScale = 1/scale, in DV order (x then y)
self.invXScale = optProb.invXScale
# conScale in natural (un-reordered) order: c1, c2
self.conScale = optProb.conScale

def test_finalize_populated_scales(self):
assert_allclose(self.invXScale, 1.0 / np.array([2.0, 0.5, 4.0, 10.0, 0.1]))
assert_allclose(self.conScale, [5.0, 0.2, 7.0])
assert_allclose(self.optProb.xOffset, [1.0, -2.0, 0.5, 0.0, 3.0])

def test_mapX_roundtrip_and_formula(self):
rng = np.random.default_rng(0)
x_user = rng.uniform(-5, 5, self.ndvs)
x_opt = self.optProb._mapXtoOpt(x_user)
# x_opt = (x_user - offset) / invXScale
assert_allclose(x_opt, (x_user - self.optProb.xOffset) / self.invXScale)
# round trip
assert_allclose(self.optProb._mapXtoUser(x_opt), x_user)

def test_mapObjGrad(self):
# Objective gradient mapping: g_opt = g_user * s_f * invXScale (column/chain-rule scaling).
rng = np.random.default_rng(1)
gobj = rng.uniform(-3, 3, (self.optProb.nObj, self.ndvs))
gobj_orig = gobj.copy()
gobj_opt = self.optProb._mapObjGradtoOpt(gobj)
assert_allclose(gobj_opt, gobj * self.objScale * self.invXScale)
# the method must not mutate its input
assert_allclose(gobj, gobj_orig)

def test_mapConJac_formula_and_roundtrip(self):
# Build an arbitrary dense Jacobian of the right shape and convert to CSR.
rng = np.random.default_rng(2)
dense = rng.uniform(-2, 2, (self.nCon, self.ndvs))
jac = convertToCSR(dense)

# _mapConJactoOpt works in place: J_opt = diag(conScale) . J . diag(invXScale)
self.optProb._mapConJactoOpt(jac)
Comment thread
marcomangano marked this conversation as resolved.
expected = np.diag(self.conScale) @ dense @ np.diag(self.invXScale)
assert_allclose(convertToDense(jac), expected)

# _mapConJactoUser must invert it back to the original.
self.optProb._mapConJactoUser(jac)
assert_allclose(convertToDense(jac), dense)

def test_mapObj_value_roundtrip(self):
f_user = 2.5
f_opt = self.optProb._mapObjtoOpt(f_user)
assert_allclose(f_opt, f_user * self.objScale)
assert_allclose(self.optProb._mapObjtoUser(f_opt), f_user)

def test_mapCon_value_roundtrip(self):
c_user = [1.0, -2.0, 3.0]
c_opt = self.optProb._mapContoOpt(c_user)
assert_allclose(c_opt, c_user * self.conScale)
assert_allclose(self.optProb._mapContoUser(c_opt), c_user)

def test_combined_scale_and_offset(self):
"""A DV group with both a non-unit scale and a non-zero offset is the
classic place to get the order of operations wrong.
"""

def objfunc(xdict):
return {"obj": 0.0}, False

optProb = Optimization("edge", objfunc)
optProb.addVarGroup("x", 2, lower=-10, upper=10, scale=4.0, offset=3.0)
optProb.addObj("obj")
optProb.finalize()

x_user = [3.0, 7.0] # note x_user[0] == offset
x_opt = optProb._mapXtoOpt(x_user)
# (x - 3) * 4
assert_allclose(x_opt, [0.0, 16.0])
assert_allclose(optProb._mapXtoUser(x_opt), x_user)

def test_infinite_bounds_not_scaled(self):
"""INFINITY bounds must remain unbounded; scale/offset must not turn
them into finite numbers in the assembled bounds.
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you add docstrings like this, that explain what the test is checking, to all these new tests? I usually find LLMs are pretty good at writing them, especially if you tell them to use the Given-When-Then style.


def objfunc(xdict):
return {"obj": 0.0}, False

optProb = Optimization("inf", objfunc)
optProb.addVarGroup("x", 1, lower=None, upper=None, scale=10.0, offset=5.0)
optProb.addObj("obj")
optProb.finalize()

var = optProb.variables["x"][0]
# Variable stores scaled bounds; unbounded sides stay at exactly +/- INFINITY.
self.assertEqual(var.lower, -INFINITY)
self.assertEqual(var.upper, INFINITY)


if __name__ == "__main__":
unittest.main()
Loading
Loading