Address reviewer feedback.

This commit is contained in:
Kevin Zakka
2026-05-27 13:07:35 -07:00
parent ab4102ea42
commit 2b9960a617
3 changed files with 42 additions and 39 deletions
+25 -28
View File
@@ -26,18 +26,17 @@ import scipy.optimize as scipy_optimize
import scipy.special
XScale = Literal["jac"] | np.ndarray | float
def _warn_if_ill_conditioned(
initial_params: parameter.ParameterDict,
residual_fn: Callable[..., Any],
threshold: float = 1e12,
eps: float | None = None,
) -> None:
"""Warn if cond(JᵀJ) at the starting point exceeds ``threshold``.
"""Warn if cond(J^T J) at the starting point exceeds ``threshold``.
Costs one extra finite-difference Jacobian. ``threshold=1e12`` corresponds
to cond(J) ~ 1e6, well below the float64 limit (~1e16).
to cond(J) ~ 1e6, well below the float64 limit (~1e16). ``eps`` defaults to
the finite-difference step used by the backends.
"""
x0 = initial_params.as_vector()
bounds = initial_params.get_bounds()
@@ -46,7 +45,8 @@ def _warn_if_ill_conditioned(
residuals, _, _ = residual_fn(x, initial_params)
return np.concatenate(residuals)
eps = np.finfo(np.float64).eps ** 0.5
if eps is None:
eps = np.finfo(np.float64).eps ** 0.5
r0 = f(x0).reshape(-1, 1)
jac = np.asarray(
mujoco_minimize.jacobian_fd(
@@ -59,13 +59,10 @@ def _warn_if_ill_conditioned(
)[0],
dtype=np.float64,
)
# eigvalsh on the gram matrix so rank-deficient directions are visible
# when n_params > n_residual components (SVD would drop to min(m, n)).
ev = np.maximum(np.linalg.eigvalsh(jac.T @ jac), 0.0)
cond_jtj = float(ev[-1] / ev[0]) if ev[0] > 0 else float("inf")
cond_jtj = float(np.linalg.cond(jac)) ** 2
if cond_jtj > threshold:
logging.warning(
"cond(JᵀJ) ≈ %.1e at the starting point; the problem may be "
"cond(J^T J) ~ %.1e at the starting point; the problem may be "
"ill-conditioned. Consider x_scale='jac' or regularizing.",
cond_jtj,
)
@@ -84,7 +81,6 @@ def _scipy_least_squares(
verbose = 2
else:
verbose = 0
x_scale = kwargs.pop("x_scale", "jac")
loss = kwargs.pop("loss", "linear")
jac_arg: str | Callable[..., Any]
@@ -117,7 +113,6 @@ def _scipy_least_squares(
bounds=bounds,
max_nfev=max_nfev,
verbose=verbose,
x_scale=x_scale,
loss=loss,
jac=jac_arg, # pyright: ignore[reportArgumentType]
**kwargs,
@@ -128,10 +123,13 @@ def _mujoco_least_squares(
x0: np.ndarray,
residual_fn: Callable[..., Any],
bounds: tuple[np.ndarray, np.ndarray],
x_scale: XScale = 1.0,
**kwargs,
) -> scipy_optimize.OptimizeResult:
"""Run MuJoCo's native least_squares optimizer."""
"""Run MuJoCo's native least_squares optimizer.
``**kwargs`` are forwarded to :func:`mujoco.minimize.least_squares`; see
its docstring (notably ``x_scale``).
"""
if kwargs.pop("verbose", True):
verbose = mujoco_minimize.Verbosity.FULLITER
else:
@@ -144,7 +142,6 @@ def _mujoco_least_squares(
residual=residual_fn,
verbose=verbose,
max_iter=max_iter,
x_scale=x_scale,
**kwargs,
)
@@ -203,7 +200,7 @@ def optimize(
optimizer: Backend — ``"mujoco"`` (default), ``"scipy"``, or
``"scipy_parallel_fd"`` (scipy with MuJoCo finite-difference Jacobian).
verbose: If True, log parameter comparison table after optimization.
check_conditioning: If True, estimate ``cond(JᵀJ)`` at the starting
check_conditioning: If True, estimate ``cond(J^T J)`` at the starting
point and emit a warning if it suggests numerical ill-conditioning.
Costs one extra finite-difference Jacobian.
**optimizer_kwargs: Forwarded to the backend. Common ones:
@@ -212,12 +209,9 @@ def optimize(
* ``verbose``: per-backend verbosity flag (separate from this
function's ``verbose``).
* ``loss``: scipy loss function name (scipy backends only).
* ``x_scale``: per-parameter scaling. ``"jac"`` is adaptive
``D_i = 1/||J(:,i)||`` per iteration; an explicit array or
positive scalar is used as ``D`` directly. Defaults: ``"jac"``
for scipy backends, ``1.0`` (no scaling) for the mujoco backend.
See :func:`scipy.optimize.least_squares` and
:func:`mujoco.minimize.least_squares` for details.
* ``x_scale``: per-parameter scaling, forwarded to the backend; see
:func:`scipy.optimize.least_squares` and
:func:`mujoco.minimize.least_squares`.
Returns:
``(opt_params, opt_result)`` — the optimized ParameterDict and a
@@ -241,16 +235,19 @@ def optimize(
)
if check_conditioning:
_warn_if_ill_conditioned(initial_params, residual_fn)
_warn_if_ill_conditioned(
initial_params, residual_fn, eps=optimizer_kwargs.get("diff_step"),
)
# Warn if any non-frozen parameter component starts at (or essentially at)
# a box bound. Optimization can stall in that corner on ill-conditioned or
# rank-deficient problems; both the mujoco and scipy backends are affected.
# a box bound. Only meaningful when x0 is feasible; otherwise the user is
# already outside the constraint set and the optimizer will clip first.
lo, hi = bounds
rng = hi - lo
safe_rng = np.where(rng > 0, rng, 1.0)
at_bound = ((x0 - lo) <= 1e-3 * safe_rng) | ((hi - x0) <= 1e-3 * safe_rng)
at_bound &= rng > 0
in_bounds = (x0 >= lo) & (x0 <= hi)
near_bound = ((x0 - lo) <= 1e-3 * safe_rng) | ((hi - x0) <= 1e-3 * safe_rng)
at_bound = in_bounds & near_bound & (rng > 0)
if at_bound.any():
logging.warning(
"%d of %d non-frozen parameter components start at (or essentially "