From 5ae677f026fa90136cadfe70b1a0db6b7a04e4a7 Mon Sep 17 00:00:00 2001 From: Kevin Zakka Date: Mon, 25 May 2026 20:30:33 -0700 Subject: [PATCH 1/4] Warn and rescue sysid parameters that start at a box bound. ParameterDict.move_off_bounds() shifts at-bound values into the interior; optimize() warns when any non-frozen component starts at a bound. --- python/mujoco/sysid/_src/optimize.py | 18 +++++++++++ python/mujoco/sysid/_src/parameter.py | 22 +++++++++++++ python/mujoco/sysid/tests/test_parameter.py | 35 +++++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/python/mujoco/sysid/_src/optimize.py b/python/mujoco/sysid/_src/optimize.py index 65227355..c36b2b4d 100644 --- a/python/mujoco/sysid/_src/optimize.py +++ b/python/mujoco/sysid/_src/optimize.py @@ -179,6 +179,24 @@ def optimize( extras={}, ) + # 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. + 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 + if at_bound.any(): + logging.warning( + "%d of %d non-frozen parameter components start at (or essentially " + "at) a box bound. Optimization can stall in that corner on " + "ill-conditioned problems; consider calling " + "initial_params.move_off_bounds() before optimize().", + int(at_bound.sum()), + x0.size, + ) + def optimized_residual_fn(x): residuals, _, _ = residual_fn(x, opt_params) return np.concatenate(residuals) diff --git a/python/mujoco/sysid/_src/parameter.py b/python/mujoco/sysid/_src/parameter.py index b5e7d46a..d6d0b621 100644 --- a/python/mujoco/sysid/_src/parameter.py +++ b/python/mujoco/sysid/_src/parameter.py @@ -139,6 +139,19 @@ class Parameter: rng = np.random.default_rng() return rng.uniform(self.min_value.flatten(), self.max_value.flatten()) + def move_off_bound(self, fraction: float = 0.05) -> None: + """Shift values within 0.1% of a bound to ``lo + fraction*(hi-lo)`` (or + symmetric for the upper bound). Interior components are unchanged.""" + lo, hi = self.get_bounds() + rng = hi - lo + safe_rng = np.where(rng > 0, rng, 1.0) + v = self.as_vector().copy() + at_lo = (v - lo) <= 1e-3 * safe_rng + at_hi = (hi - v) <= 1e-3 * safe_rng + v = np.where(at_lo, lo + fraction * rng, v) + v = np.where(at_hi, hi - fraction * rng, v) + self.update_from_vector(v) + def __str__(self) -> str: """Return a string representation of the parameter.""" if self.size == 1: @@ -293,6 +306,15 @@ class ParameterDict: param.update_from_vector(vector[start : start + size]) start += size + def move_off_bounds(self, fraction: float = 0.05) -> Self: + """Call :meth:`Parameter.move_off_bound` on every non-frozen parameter, + returning ``self`` so calls can be chained before + :func:`optimize`.""" + for param in self.parameters.values(): + if not param.frozen: + param.move_off_bound(fraction=fraction) + return self + def save_to_disk(self, path: str | pathlib.Path) -> None: """Save the parameter dictionary to disk (schema and data). diff --git a/python/mujoco/sysid/tests/test_parameter.py b/python/mujoco/sysid/tests/test_parameter.py index 069a771f..9274c1b2 100644 --- a/python/mujoco/sysid/tests/test_parameter.py +++ b/python/mujoco/sysid/tests/test_parameter.py @@ -146,3 +146,38 @@ def test_frozen_param_excluded(): np.testing.assert_array_equal(params["free"].value, [1.5]) # Frozen param unchanged. np.testing.assert_array_equal(params["frozen"].value, [5.0]) + + +def test_move_off_bound(): + """At-bound (or essentially at-bound) values shift inward; interior is untouched.""" + # At lower bound, at upper bound, essentially-at-lower, interior, vector mixed, + # custom fraction, and degenerate (zero-range) bounds. + cases = [ + (0.0, 0.0, 20.0, 0.05, 1.0), # at lower -> 0.05 * 20 + (1e-8, 0.0, 20.0, 0.05, 1.0), # essentially at lower + (20.0, 0.0, 20.0, 0.05, 19.0), # at upper -> 20 - 0.05 * 20 + (5.0, 0.0, 20.0, 0.05, 5.0), # interior unchanged + (0.0, 0.0, 100.0, 0.1, 10.0), # custom fraction + (3.0, 3.0, 3.0, 0.05, 3.0), # zero-range bound, pinned + ] + for nominal, lo, hi, fraction, expected in cases: + p = parameter.Parameter("d", nominal, lo, hi) + p.move_off_bound(fraction=fraction) + np.testing.assert_allclose(p.value, [expected]) + + # Vector parameter: only at-bound components are shifted. + p = parameter.Parameter( + "v", [0.0, 5.0, 10.0], [0.0, 0.0, 0.0], [10.0, 10.0, 10.0] + ) + p.move_off_bound() + np.testing.assert_allclose(p.value, [0.5, 5.0, 9.5]) + + +def test_move_off_bounds_dict_skips_frozen_and_returns_self(): + """ParameterDict shifts free params, leaves frozen alone, returns self.""" + pdict = parameter.ParameterDict() + pdict.add(parameter.Parameter("free", 0.0, 0.0, 1.0)) + pdict.add(parameter.Parameter("frozen", 0.0, 0.0, 1.0, frozen=True)) + assert pdict.move_off_bounds() is pdict + np.testing.assert_allclose(pdict["free"].value, [0.05]) + np.testing.assert_allclose(pdict["frozen"].value, [0.0]) From d75b7098924c2267d31b6f87fac3325d3ac8bdf9 Mon Sep 17 00:00:00 2001 From: Kevin Zakka Date: Tue, 26 May 2026 17:13:37 -0700 Subject: [PATCH 2/4] Forward x_scale through the sysid mujoco backend. mujoco.minimize.least_squares now supports x_scale natively, so the sysid wrapper becomes a thin pass-through. 'jac' is adaptive per iteration (was static at x0). --- python/mujoco/sysid/_src/optimize.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/python/mujoco/sysid/_src/optimize.py b/python/mujoco/sysid/_src/optimize.py index c36b2b4d..ed91a944 100644 --- a/python/mujoco/sysid/_src/optimize.py +++ b/python/mujoco/sysid/_src/optimize.py @@ -26,6 +26,9 @@ import scipy.optimize as scipy_optimize import scipy.special +XScale = Literal["jac"] | np.ndarray | float + + def _scipy_least_squares( x0: np.ndarray, residual_fn: Callable[..., Any], @@ -83,6 +86,7 @@ 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.""" @@ -91,16 +95,17 @@ def _mujoco_least_squares( else: verbose = mujoco_minimize.Verbosity.SILENT max_iter = kwargs.pop("max_iters", 200) + x, log = mujoco_minimize.least_squares( x0=x0, bounds=bounds, residual=residual_fn, verbose=verbose, max_iter=max_iter, + x_scale=x_scale, **kwargs, ) - # If verbose, return the full optimization log. extras = {} if verbose == mujoco_minimize.Verbosity.FULLITER: extras["objective"] = [entry.objective for entry in log] @@ -155,8 +160,18 @@ 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. - **optimizer_kwargs: Forwarded to the backend (e.g. ``max_iters``, - ``verbose``, ``loss``). + **optimizer_kwargs: Forwarded to the backend. Common ones: + + * ``max_iters``: maximum number of optimizer iterations. + * ``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. Returns: ``(opt_params, opt_result)`` — the optimized ParameterDict and a From ab4102ea4252fda03d68e7d91a988aa0e512be23 Mon Sep 17 00:00:00 2001 From: Kevin Zakka Date: Mon, 25 May 2026 20:50:06 -0700 Subject: [PATCH 3/4] Add opt-in cond(J^T J) check to sysid optimize(). Pass check_conditioning=True to FD the Jacobian at the starting point and warn if cond(J^T J) suggests numerical ill-conditioning. --- python/mujoco/sysid/_src/optimize.py | 49 +++++++++++++++++++ python/mujoco/sysid/tests/test_integration.py | 40 +++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/python/mujoco/sysid/_src/optimize.py b/python/mujoco/sysid/_src/optimize.py index ed91a944..860e710b 100644 --- a/python/mujoco/sysid/_src/optimize.py +++ b/python/mujoco/sysid/_src/optimize.py @@ -29,6 +29,48 @@ 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, +) -> None: + """Warn if cond(Jᵀ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). + """ + x0 = initial_params.as_vector() + bounds = initial_params.get_bounds() + + def f(x): + residuals, _, _ = residual_fn(x, initial_params) + return np.concatenate(residuals) + + eps = np.finfo(np.float64).eps ** 0.5 + r0 = f(x0).reshape(-1, 1) + jac = np.asarray( + mujoco_minimize.jacobian_fd( + residual=f, + x=x0.reshape(-1, 1), + r=r0, + eps=eps, + n_res=0, + bounds=[bounds[0].reshape(-1, 1), bounds[1].reshape(-1, 1)], + )[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") + if cond_jtj > threshold: + logging.warning( + "cond(JᵀJ) ≈ %.1e at the starting point; the problem may be " + "ill-conditioned. Consider x_scale='jac' or regularizing.", + cond_jtj, + ) + + def _scipy_least_squares( x0: np.ndarray, residual_fn: Callable[..., Any], @@ -149,6 +191,7 @@ def optimize( residual_fn: Callable[..., Any], optimizer: Literal["scipy", "mujoco", "scipy_parallel_fd"] = "mujoco", verbose: bool = True, + check_conditioning: bool = False, **optimizer_kwargs, ) -> tuple[parameter.ParameterDict, scipy_optimize.OptimizeResult]: """Run nonlinear least-squares optimization on the residual. @@ -160,6 +203,9 @@ 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 + 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: * ``max_iters``: maximum number of optimizer iterations. @@ -194,6 +240,9 @@ def optimize( extras={}, ) + if check_conditioning: + _warn_if_ill_conditioned(initial_params, residual_fn) + # 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. diff --git a/python/mujoco/sysid/tests/test_integration.py b/python/mujoco/sysid/tests/test_integration.py index d6b7eeb7..ff2688f0 100644 --- a/python/mujoco/sysid/tests/test_integration.py +++ b/python/mujoco/sysid/tests/test_integration.py @@ -14,6 +14,7 @@ # ============================================================================== """End-to-end integration tests for mujoco.sysid.""" +import logging import pathlib import tempfile @@ -21,6 +22,7 @@ import mujoco import mujoco.rollout as rollout from mujoco import sysid import numpy as np +import pytest # --------------------------------------------------------------------------- @@ -233,3 +235,41 @@ def test_arm_recover_armature(): assert (result_dir / "results.pkl").exists() assert (result_dir / "confidence.pkl").exists() assert (result_dir / "arm.xml").exists() + + +def _rank_1_residual_fn(x, p): + del p + if x.ndim == 1: + r = np.array([x[0] + x[1] - 2.0]) + else: + r = (x[0] + x[1] - 2.0).reshape(1, -1) + return [r], None, None + + +def _full_rank_residual_fn(x, p): + del p + if x.ndim == 1: + r = np.array([x[0] - 1.0, x[1] - 2.0]) + else: + r = np.stack([x[0] - 1.0, x[1] - 2.0]) + return [r], None, None + + +@pytest.mark.parametrize("residual_fn,expect_warning", [ + (_rank_1_residual_fn, True), + (_full_rank_residual_fn, False), +]) +def test_check_conditioning(residual_fn, expect_warning, caplog): + """check_conditioning=True warns iff cond(JᵀJ) is large at the starting point.""" + params = sysid.ParameterDict() + params.add(sysid.Parameter("a", 1.0, -10.0, 10.0)) + params.add(sysid.Parameter("b", 1.0, -10.0, 10.0)) + + with caplog.at_level(logging.WARNING, logger="absl"): + sysid.optimize( + initial_params=params, residual_fn=residual_fn, + optimizer="scipy", verbose=False, check_conditioning=True, + max_iters=1, + ) + fired = any("cond(JᵀJ)" in r.message for r in caplog.records) + assert fired is expect_warning From 2b9960a6170188caeac1f1524001beb75a2e0f52 Mon Sep 17 00:00:00 2001 From: Kevin Zakka Date: Wed, 27 May 2026 13:07:35 -0700 Subject: [PATCH 4/4] Address reviewer feedback. --- python/mujoco/sysid/_src/optimize.py | 53 +++++++++---------- python/mujoco/sysid/_src/parameter.py | 19 ++++--- python/mujoco/sysid/tests/test_integration.py | 9 ++-- 3 files changed, 42 insertions(+), 39 deletions(-) diff --git a/python/mujoco/sysid/_src/optimize.py b/python/mujoco/sysid/_src/optimize.py index 860e710b..b0e22982 100644 --- a/python/mujoco/sysid/_src/optimize.py +++ b/python/mujoco/sysid/_src/optimize.py @@ -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 " diff --git a/python/mujoco/sysid/_src/parameter.py b/python/mujoco/sysid/_src/parameter.py index d6d0b621..ab3877d7 100644 --- a/python/mujoco/sysid/_src/parameter.py +++ b/python/mujoco/sysid/_src/parameter.py @@ -139,15 +139,18 @@ class Parameter: rng = np.random.default_rng() return rng.uniform(self.min_value.flatten(), self.max_value.flatten()) - def move_off_bound(self, fraction: float = 0.05) -> None: - """Shift values within 0.1% of a bound to ``lo + fraction*(hi-lo)`` (or - symmetric for the upper bound). Interior components are unchanged.""" + def move_off_bound( + self, fraction: float = 0.05, at_bound_tol: float = 1e-3 + ) -> None: + """Shift values within ``at_bound_tol * (hi - lo)`` of a bound to + ``lo + fraction*(hi-lo)`` (or symmetric for the upper bound). Interior + components are unchanged.""" lo, hi = self.get_bounds() rng = hi - lo safe_rng = np.where(rng > 0, rng, 1.0) v = self.as_vector().copy() - at_lo = (v - lo) <= 1e-3 * safe_rng - at_hi = (hi - v) <= 1e-3 * safe_rng + at_lo = (v - lo) <= at_bound_tol * safe_rng + at_hi = (hi - v) <= at_bound_tol * safe_rng v = np.where(at_lo, lo + fraction * rng, v) v = np.where(at_hi, hi - fraction * rng, v) self.update_from_vector(v) @@ -306,13 +309,15 @@ class ParameterDict: param.update_from_vector(vector[start : start + size]) start += size - def move_off_bounds(self, fraction: float = 0.05) -> Self: + def move_off_bounds( + self, fraction: float = 0.05, at_bound_tol: float = 1e-3 + ) -> Self: """Call :meth:`Parameter.move_off_bound` on every non-frozen parameter, returning ``self`` so calls can be chained before :func:`optimize`.""" for param in self.parameters.values(): if not param.frozen: - param.move_off_bound(fraction=fraction) + param.move_off_bound(fraction=fraction, at_bound_tol=at_bound_tol) return self def save_to_disk(self, path: str | pathlib.Path) -> None: diff --git a/python/mujoco/sysid/tests/test_integration.py b/python/mujoco/sysid/tests/test_integration.py index ff2688f0..0ecfbc6c 100644 --- a/python/mujoco/sysid/tests/test_integration.py +++ b/python/mujoco/sysid/tests/test_integration.py @@ -238,11 +238,12 @@ def test_arm_recover_armature(): def _rank_1_residual_fn(x, p): + # Residual depends only on x[0]: J = [[1, 0], [2, 0]] is 2x2 rank 1. del p if x.ndim == 1: - r = np.array([x[0] + x[1] - 2.0]) + r = np.array([x[0] - 1.0, 2.0 * x[0] - 2.0]) else: - r = (x[0] + x[1] - 2.0).reshape(1, -1) + r = np.stack([x[0] - 1.0, 2.0 * x[0] - 2.0]) return [r], None, None @@ -260,7 +261,7 @@ def _full_rank_residual_fn(x, p): (_full_rank_residual_fn, False), ]) def test_check_conditioning(residual_fn, expect_warning, caplog): - """check_conditioning=True warns iff cond(JᵀJ) is large at the starting point.""" + """check_conditioning=True warns iff cond(J^T J) is large at the start.""" params = sysid.ParameterDict() params.add(sysid.Parameter("a", 1.0, -10.0, 10.0)) params.add(sysid.Parameter("b", 1.0, -10.0, 10.0)) @@ -271,5 +272,5 @@ def test_check_conditioning(residual_fn, expect_warning, caplog): optimizer="scipy", verbose=False, check_conditioning=True, max_iters=1, ) - fired = any("cond(JᵀJ)" in r.message for r in caplog.records) + fired = any("cond(J^T J)" in r.message for r in caplog.records) assert fired is expect_warning