Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
33 changes: 33 additions & 0 deletions desc/optimize/_desc_wrappers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from scipy.optimize import NonlinearConstraint

from desc.backend import jnp
from desc.utils import warnif

from .aug_lagrangian import fmin_auglag
from .aug_lagrangian_ls import lsq_auglag
Expand Down Expand Up @@ -45,6 +46,36 @@
]


def _warn_if_bounds(objective, constraint, x0, options):
"""Warn if bounds on sub-objectives can make the Jacobian rank-deficient.

A sub-objective with bounds contributes zero residuals, and hence zero Jacobian
rows, whenever it is inside its bounds. The full rank of the (m, n) Jacobian is
min(m, n), so those rows can only cost rank if the ones that always remain are
fewer than that. Only ``"svd"`` handles the deficient case reliably, so warn if
the user hasn't picked a method themselves.
"""
if "tr_method" in options: # user picked a method, don't second guess it
return

Check warning on line 59 in desc/optimize/_desc_wrappers.py

View check run for this annotation

Codecov / codecov/patch

desc/optimize/_desc_wrappers.py#L59

Added line #L59 was not covered by tests
sub = objective
while hasattr(sub, "_objective"): # unwrap Proximal/LinearConstraintProjection
sub = sub._objective
bounded = [obj for obj in sub.objectives if obj.bounds is not None]
dim_f_bounded = sum(obj.dim_f for obj in bounded)
# constraint rows never vanish, bounds there become slack variables instead
m = objective.dim_f + (0 if constraint is None else constraint.dim_f)
n = x0.size
warnif(
m - dim_f_bounded < min(m, n),
UserWarning,
f"Objectives {[obj.name for obj in bounded]} use bounds instead of target, so "
+ f"they can zero out {dim_f_bounded} of the {m} rows of the ({m}, {n}) "
+ f"Jacobian and drop its rank below {min(m, n)}. The default 'qr' trust "
+ "region method may then fail to solve the subproblem, in that case pass "
+ "options={'tr_method': 'svd'}.",
)


@register_optimizer(
name=["fmin-auglag", "fmin-auglag-bfgs"],
description=[
Expand Down Expand Up @@ -206,6 +237,7 @@
if not isinstance(x_scale, str) and jnp.allclose(x_scale, 1):
options.setdefault("initial_trust_radius", 1e-3)
options.setdefault("max_trust_radius", 1.0)
_warn_if_bounds(objective, constraint, x0, options)

Check warning on line 240 in desc/optimize/_desc_wrappers.py

View check run for this annotation

Codecov / codecov/patch

desc/optimize/_desc_wrappers.py#L240

Added line #L240 was not covered by tests
options["max_nfev"] = stoptol["max_nfev"]

if constraint is not None:
Expand Down Expand Up @@ -300,6 +332,7 @@
options.setdefault("max_trust_radius", 1.0)
elif options.get("initial_trust_radius", "scipy") == "scipy":
options.setdefault("initial_trust_ratio", 0.1)
_warn_if_bounds(objective, constraint, x0, options)
options["max_nfev"] = stoptol["max_nfev"]

result = lsqtr(
Expand Down
4 changes: 3 additions & 1 deletion desc/optimize/aug_lagrangian_ls.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@ def lsq_auglag( # noqa: C901
Cholesky factorizations (generally 2-3), while ``"svd"`` uses one singular
value decomposition. ``"cho"`` is generally the fastest for large systems,
especially on GPU, but may be less accurate for badly scaled systems.
``"svd"`` is the most accurate but significantly slower. Default ``"qr"``.
``"svd"`` is the most accurate but significantly slower. If any of the
sub-objective includes bounds, the ``'svd'`` is recommended since the linear
system has a chance to be rank-deficient. Default ``"qr"``.
- ``"scaled_termination"`` : Whether to evaluate termination criteria for
``xtol`` and ``gtol`` in scaled / normalized units (default) or base units.

Expand Down
4 changes: 3 additions & 1 deletion desc/optimize/least_squares.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ def lsqtr( # noqa: C901
Cholesky factorizations (generally 2-3), while ``"svd"`` uses one singular
value decomposition. ``"cho"`` is generally the fastest for large systems,
especially on GPU, but may be less accurate for badly scaled systems.
``"svd"`` is the most accurate but significantly slower. Default ``"qr"``.
``"svd"`` is the most accurate but significantly slower. If any of the
sub-objective includes bounds, the ``'svd'`` is recommended since the linear
system has a chance to be rank-deficient. Default ``"qr"``.
- ``"scaled_termination"`` : Whether to evaluate termination criteria for
``xtol`` and ``gtol`` in scaled / normalized units (default) or base units.

Expand Down
7 changes: 5 additions & 2 deletions tests/test_compute_everything.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,16 @@ def _compare_against_master(
else:
mean = np.mean(np.atleast_1d(np.abs(master_data[p][name])))
try:
rtol = 1e-5 if "Gamma_" in name and OLD_FINUFFT else 1e-8
atol = 1e-4 if "Gamma_" in name and OLD_FINUFFT else 1e-8
atol = atol * mean + 1e-9 # add 1e-9 for basically-zero things
err_msg = f"Parameterization: {p}. Name: {name}."
assert np.isfinite(mean).all(), err_msg
np.testing.assert_allclose(
actual=data[p][name],
desired=master_data[p][name],
atol=1e-8 * mean + 1e-9, # add 1e-9 for basically-zero things
rtol=1e-8,
atol=atol,
rtol=rtol,
err_msg=err_msg,
)
except AssertionError as e:
Expand Down
24 changes: 18 additions & 6 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,15 @@ def test_solve_bounds():
obj = ObjectiveFunction(
ForceBalance(normalize=False, normalize_target=False, bounds=(-3e3, 3e3), eq=eq)
)
eq.solve(objective=obj, ftol=1e-16, xtol=1e-16, maxiter=200, verbose=3)
# solve with bounds creates singular Jacobian which QR cannot handle
eq.solve(
objective=obj,
ftol=1e-16,
xtol=1e-16,
maxiter=200,
verbose=3,
options={"tr_method": "svd"},
)

# check that all errors are nearly 0, since residual values are within target bounds
f = obj.compute_scaled_error(obj.x(eq))
Expand Down Expand Up @@ -1171,7 +1179,8 @@ def test_omnigenity_proximal():
FixPsi(eq=eq),
)
optimizer = Optimizer("proximal-lsq-exact")
[eq], _ = optimizer.optimize(eq, objective, constraints, maxiter=2, verbose=3)
with pytest.warns(UserWarning, match="use bounds instead of target"):
[eq], _ = optimizer.optimize(eq, objective, constraints, maxiter=2, verbose=3)

# second, test optimizing both the equilibrium and the field simultaneously
objective = ObjectiveFunction(
Expand All @@ -1188,9 +1197,10 @@ def test_omnigenity_proximal():
FixPsi(eq=eq),
)
optimizer = Optimizer("proximal-lsq-exact")
(eq, field), _ = optimizer.optimize(
(eq, field), objective, constraints, maxiter=2, verbose=3
)
with pytest.warns(UserWarning, match="use bounds instead of target"):
(eq, field), _ = optimizer.optimize(
(eq, field), objective, constraints, maxiter=2, verbose=3
)


@pytest.mark.unit
Expand Down Expand Up @@ -2408,7 +2418,9 @@ def test_ballooning_stability_opt():
gtol=1e-6,
maxiter=2, # increase maxiter to 50 for a better result
verbose=3,
options={"initial_trust_ratio": 2e-3},
# Jacobian has only 2 rows and 1 of them can be full of 0s
# default QR can fail, choose SVD instead
options={"initial_trust_ratio": 2e-3, "tr_method": "svd"},
)
data = eq.compute("ideal ballooning lambda", grid=grid)
lam2_optimized = data["ideal ballooning lambda"].max((-1, -2, -3))
Expand Down
Loading