Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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)
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
24 changes: 19 additions & 5 deletions desc/optimize/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,20 @@ def gershgorin_bounds(H):
return lb, ub


@jit
def _chol_failed(L):
"""Whether a Cholesky factor is unusable because the matrix was not positive.

``potrf`` only errors out, and so returns nan, when a pivot comes out
non-positive. A pivot at the noise level instead completes but gives a factor that
is useless to solve with, and which of the two a marginal matrix gets depends on

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.

For example, test_multiobject_optimization_al was creating such matrices, failing on CI for #2293, but not locally

the LAPACK build, so treat both as a failure.
"""
d = jnp.diag(L)
tol = L.shape[-1] * jnp.finfo(L.dtype).eps
return jnp.any(jnp.isnan(L)) | (jnp.min(d) ** 2 <= tol * jnp.max(d) ** 2)


@jit
def _cholmod(A, maxiter=4):
"""Modified Cholesky factorization of indefinite matrix.
Expand Down Expand Up @@ -233,13 +247,13 @@ def _cholmod(A, maxiter=4):
for i in range(maxiter):
L = jnp.linalg.cholesky(A + alphas[kbest] * eye)
# check if it succeeded
isnan = jnp.any(jnp.isnan(L))
failed = _chol_failed(L)
# adjust bounds for correction
klow = isnan * kbest + (1 - isnan) * klow
khigh = isnan * khigh + (1 - isnan) * kbest
klow = failed * kbest + (1 - failed) * klow
khigh = failed * khigh + (1 - failed) * kbest
kbest = (klow + khigh) // 2
# if it succeeded, mark it as the best so far
Lbest = cond(isnan, lambda _: Lbest, lambda _: L, None)
Lbest = cond(failed, lambda _: Lbest, lambda _: L, None)
return Lbest


Expand All @@ -263,7 +277,7 @@ def chol(A):

"""
L = jnp.linalg.cholesky(A)
L = cond(jnp.any(jnp.isnan(L)), lambda A: _cholmod(A), lambda A: L, A)
L = cond(_chol_failed(L), lambda A: _cholmod(A), lambda A: L, A)
return L


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