From 4959324273c9d77458892f0fa2143f37b181389e Mon Sep 17 00:00:00 2001 From: "J.C. Macdonald" <72512262+jc-macdonald@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:53:18 -0400 Subject: [PATCH 1/5] fix(ci): include plot extra in typecheck recipe The typecheck recipe called `uv run mypy` without `--extra plot`, so matplotlib stubs were missing when the venv was created by `uv run` rather than `just uv-sync`. Add `--extra dev --extra plot` to match the sync recipe. --- justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/justfile b/justfile index b7c96cb..1aec108 100644 --- a/justfile +++ b/justfile @@ -26,7 +26,7 @@ format: # Type-check library code (strict mode). typecheck: - uv run mypy --strict src + uv run --extra dev --extra plot mypy --strict src # Run the test suite quietly. test: From 57a6ad1d46167ba5662af9a5b7d2878dc51cd8c7 Mon Sep 17 00:00:00 2001 From: "J.C. Macdonald" <72512262+jc-macdonald@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:53:30 -0400 Subject: [PATCH 2/5] feat(converge): add relative ELBO and curvature stopping criteria MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new ELBO-based convergence criteria: - cfstop_rel: stops when |ELBO[t] - ELBO[t-1]| / |ELBO[t]| < threshold (scale-invariant relative decrease). - cfstop_curv: stops when |ΔELBO[t] - ΔELBO[t-1]| < threshold (2nd difference / curvature, detects asymptotic regime). Both are evaluated after the existing cfstop plateau check, preserving priority order. Disabled by default (None). Refactors cost-related criteria into _cost_criteria() helper to keep convergence_check() complexity under the C901 limit. Refs: #54 --- src/vbpca_py/_converge.py | 105 ++++++++++++++-- src/vbpca_py/_pca_full.py | 2 + tests/test_converge.py | 247 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 8 deletions(-) diff --git a/src/vbpca_py/_converge.py b/src/vbpca_py/_converge.py index 2aa2ec4..d61393e 100644 --- a/src/vbpca_py/_converge.py +++ b/src/vbpca_py/_converge.py @@ -164,6 +164,64 @@ def _plateau_stop( return None +def _relative_elbo_stop( + cost: np.ndarray, + threshold: float | None, +) -> str | None: + """Return a message if relative ELBO decrease is below *threshold*. + + Checks ``|ELBO[t] - ELBO[t-1]| / |ELBO[t]| < threshold``. This is + scale-invariant and more robust than an absolute plateau check. + + Returns: + A human-readable stop message, or ``None``. + """ + if threshold is None or threshold <= 0 or cost.size < 2: + return None + + curr, prev = cost[-1], cost[-2] + if not (np.isfinite(curr) and np.isfinite(prev)): + return None + + rel_change = abs(curr - prev) / (abs(curr) + np.finfo(float).eps) + if rel_change < threshold: + return ( + f"Stop: relative ELBO change {rel_change:.3e} " + f"is below cfstop_rel = {threshold:.3e}." + ) + return None + + +def _elbo_curvature_stop( + cost: np.ndarray, + threshold: float | None, +) -> str | None: + """Return a message if ELBO curvature (2nd difference) is below *threshold*. + + Checks ``|ΔELBO[t] - ΔELBO[t-1]| < threshold``, i.e. whether the + *rate of improvement* has itself stabilised. + + Returns: + A human-readable stop message, or ``None``. + """ + if threshold is None or threshold <= 0 or cost.size < 3: + return None + + d1 = cost[-1] - cost[-2] + d0 = cost[-2] - cost[-3] + + if not (np.isfinite(d1) and np.isfinite(d0)): + return None + + curvature = abs(d1 - d0) + if curvature < threshold: + return ( + f"Stop: ELBO curvature {curvature:.3e} " + f"is below cfstop_curv = {threshold:.3e}." + ) + return None + + def _slowing_down_message(sd_iter: int | None) -> str | None: """Return a slowing-down message if sd_iter hits the threshold.""" if sd_iter is not None and sd_iter == 40: @@ -174,6 +232,39 @@ def _slowing_down_message(sd_iter: int | None) -> str | None: return None +def _cost_criteria( + opts: Mapping[str, Any], + cost: np.ndarray, +) -> str | None: + """Evaluate all cost/ELBO-based stopping criteria in priority order. + + Returns: + The first triggered message, or ``None``. + """ + # Cost plateau + cfstop = opts.get("cfstop") + if cost.size >= 2 and cfstop is not None: + plateau_msg = _plateau_stop(cost, cfstop, "cost") + if plateau_msg: + return plateau_msg + + # Relative ELBO decrease + cfstop_rel = opts.get("cfstop_rel") + if cfstop_rel is not None: + rel_msg = _relative_elbo_stop(cost, float(cfstop_rel)) + if rel_msg: + return rel_msg + + # ELBO curvature (2nd difference) + cfstop_curv = opts.get("cfstop_curv") + if cfstop_curv is not None: + curv_msg = _elbo_curvature_stop(cost, float(cfstop_curv)) + if curv_msg: + return curv_msg + + return None + + # --------------------------------------------------------------------------- # Public convergence check # --------------------------------------------------------------------------- @@ -193,8 +284,8 @@ def convergence_check( 1. Subspace-angle stop (``minangle``). 2. Early stopping based on probe RMS (``earlystop``). 3. RMS plateau stop (``rmsstop = [window, abs_tol, rel_tol]``). - 4. Cost plateau stop (``cfstop = [window, abs_tol, rel_tol]``). - 5. “Slowing-down'' stop based on ``sd_iter`` (gradient backtracking). + 4. Cost / ELBO criteria (``cfstop``, ``cfstop_rel``, ``cfstop_curv``). + 5. "Slowing-down'' stop based on ``sd_iter`` (gradient backtracking). Returns: A non-empty convergence message when a criterion triggers, @@ -221,12 +312,10 @@ def convergence_check( if plateau_msg: return plateau_msg - # 4. Cost plateau - cfstop = opts.get("cfstop") - if cost.size >= 2 and cfstop is not None: - plateau_msg = _plateau_stop(cost, cfstop, "cost") - if plateau_msg: - return plateau_msg + # 4. Cost / ELBO criteria + cost_msg = _cost_criteria(opts, cost) + if cost_msg: + return cost_msg # 5. Slowing-down criterion slow_msg = _slowing_down_message(sd_iter) diff --git a/src/vbpca_py/_pca_full.py b/src/vbpca_py/_pca_full.py index 82f5cd7..5d2fe0b 100644 --- a/src/vbpca_py/_pca_full.py +++ b/src/vbpca_py/_pca_full.py @@ -1938,6 +1938,8 @@ def _build_options(kwargs: Mapping[str, object]) -> dict[str, object]: "earlystop": False, "rmsstop": np.array([100, 1e-4, 1e-3]), "cfstop": np.array([]), + "cfstop_rel": None, + "cfstop_curv": None, "verbose": 1, "num_cpu": None, "num_cpu_score_update": None, diff --git a/tests/test_converge.py b/tests/test_converge.py index 301405a..ad2703a 100644 --- a/tests/test_converge.py +++ b/tests/test_converge.py @@ -320,3 +320,250 @@ def test_cost_plateau_message_contains_window_info() -> None: msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) assert "cost" in msg assert "over 2 iterations" in msg + + +# -------------------------------------------------------------------------- +# Relative ELBO decrease (cfstop_rel) behaviour +# -------------------------------------------------------------------------- + + +def test_cfstop_rel_triggers_when_change_small() -> None: + """Relative ELBO stop fires when |ΔELBO|/|ELBO| < threshold.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": 1e-3, + } + # |8.0001 - 8.0| / |8.0001| ≈ 1.25e-5 < 1e-3 -> trigger + lc = _lc( + rms=[0.5, 0.4], + prms=[1.0, 0.9], + cost=[8.0, 8.0001], + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert "relative ELBO" in msg.lower() or "cfstop_rel" in msg + + +def test_cfstop_rel_does_not_trigger_when_change_large() -> None: + """Relative ELBO stop should not fire when change is above threshold.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": 1e-6, + } + # |9.0 - 10.0| / |9.0| ≈ 0.111 >> 1e-6 + lc = _lc( + rms=[0.5, 0.4], + prms=[1.0, 0.9], + cost=[10.0, 9.0], + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert msg == "" + + +def test_cfstop_rel_needs_two_cost_values() -> None: + """Cannot compute relative change with only one cost value.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": 1e-3, + } + lc = _lc( + rms=[0.5], + prms=[1.0], + cost=[8.0], + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert msg == "" + + +def test_cfstop_rel_disabled_when_none() -> None: + """cfstop_rel=None should not trigger any stop.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + } + lc = _lc( + rms=[0.5, 0.4], + prms=[1.0, 0.9], + cost=[8.0, 8.0], # zero change, but disabled + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert msg == "" + + +def test_cfstop_rel_message_format() -> None: + """The stop message should include the relative change and threshold.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": 1e-4, + } + lc = _lc( + rms=[0.5, 0.4], + prms=[1.0, 0.9], + cost=[100.0, 100.000001], + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert "cfstop_rel" in msg + assert "1.000e-04" in msg + + +# -------------------------------------------------------------------------- +# ELBO curvature / 2nd difference (cfstop_curv) behaviour +# -------------------------------------------------------------------------- + + +def test_cfstop_curv_triggers_when_curvature_small() -> None: + """ELBO curvature stop fires when |Δ²ELBO| < threshold.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": 1e-3, + } + # Δ = [-1.0, -0.9999] -> curvature = |(-0.9999) - (-1.0)| = 1e-4 < 1e-3 + lc = _lc( + rms=[0.5, 0.4, 0.3], + prms=[1.0, 0.9, 0.8], + cost=[10.0, 9.0, 8.0001], + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert "curvature" in msg.lower() or "cfstop_curv" in msg + + +def test_cfstop_curv_does_not_trigger_when_curvature_large() -> None: + """ELBO curvature stop should not fire when curvature is above threshold.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": 1e-6, + } + # Δ = [-1.0, -0.5] -> curvature = |(-0.5) - (-1.0)| = 0.5 >> 1e-6 + lc = _lc( + rms=[0.5, 0.4, 0.3], + prms=[1.0, 0.9, 0.8], + cost=[10.0, 9.0, 8.5], + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert msg == "" + + +def test_cfstop_curv_needs_three_cost_values() -> None: + """Cannot compute 2nd difference with fewer than 3 cost values.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": 1e-3, + } + lc = _lc( + rms=[0.5, 0.4], + prms=[1.0, 0.9], + cost=[10.0, 9.0], + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert msg == "" + + +def test_cfstop_curv_disabled_when_none() -> None: + """cfstop_curv=None should not trigger any stop.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": None, + } + lc = _lc( + rms=[0.5, 0.4, 0.3], + prms=[1.0, 0.9, 0.8], + cost=[10.0, 10.0, 10.0], # zero curvature, but disabled + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert msg == "" + + +def test_cfstop_curv_message_format() -> None: + """The stop message should include the curvature value and threshold.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": 1e-2, + } + # Δ = [-1.0, -1.001] -> curvature = 0.001 < 0.01 + lc = _lc( + rms=[0.5, 0.4, 0.3], + prms=[1.0, 0.9, 0.8], + cost=[10.0, 9.0, 7.999], + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert "cfstop_curv" in msg + + +# -------------------------------------------------------------------------- +# Priority: cfstop_rel / cfstop_curv vs other criteria +# -------------------------------------------------------------------------- + + +def test_cost_plateau_has_priority_over_cfstop_rel() -> None: + """cfstop (plateau) should fire before cfstop_rel when both are met.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": [2, 1e-3, 1e-3], + "cfstop_rel": 1e-3, + } + # Both cost plateau and relative ELBO would trigger + lc = _lc( + rms=[0.5, 0.4, 0.3, 0.2], + prms=[1.0, 0.9, 0.8, 0.7], + cost=[8.0, 7.0, 7.0004, 7.0006], + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + # Cost plateau has priority (checked first) + assert "cost" in msg.lower() + assert "over 2 iterations" in msg + + +def test_cfstop_rel_has_priority_over_cfstop_curv() -> None: + """cfstop_rel should fire before cfstop_curv when both are met.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": 1e-3, + "cfstop_curv": 1e-3, + } + # Both would trigger + lc = _lc( + rms=[0.5, 0.4, 0.3], + prms=[1.0, 0.9, 0.8], + cost=[10.0, 10.00001, 10.00002], + ) + msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert "cfstop_rel" in msg From 36d5b90aee8e518b235402634ca3e4f0b4de969c Mon Sep 17 00:00:00 2001 From: "J.C. Macdonald" <72512262+jc-macdonald@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:56:50 -0400 Subject: [PATCH 3/5] feat(converge): add composite convergence criterion Add composite_stop option: a dict of sub-criteria that must ALL be satisfied simultaneously before declaring convergence. Supported sub-criteria: angle, rms (relative change), elbo_rel. Example: composite_stop={'angle': 1e-8, 'rms': 1e-4, 'elbo_rel': 1e-6} Refactors convergence_check() to use a list-of-checks pattern, eliminating excessive return statements and complexity. Refs: #54 --- src/vbpca_py/_converge.py | 138 +++++++++++++++++++++++++++------- src/vbpca_py/_pca_full.py | 1 + tests/test_converge.py | 154 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 28 deletions(-) diff --git a/src/vbpca_py/_converge.py b/src/vbpca_py/_converge.py index d61393e..70429b7 100644 --- a/src/vbpca_py/_converge.py +++ b/src/vbpca_py/_converge.py @@ -265,6 +265,94 @@ def _cost_criteria( return None +def _check_sub_criterion( + key: str, + threshold: float, + angle_a: float, + rms: np.ndarray, + cost: np.ndarray, +) -> str | None: + """Evaluate a single composite sub-criterion. + + Returns: + A short summary string like ``"angle=1.2e-04<1.0e-03"`` when the + criterion is satisfied, or ``None`` when it is not met. + + Raises: + ValueError: If *key* is not a recognised sub-criterion name. + """ + eps = np.finfo(float).eps + + if key == "angle": + if np.isfinite(angle_a) and angle_a < threshold: + return f"angle={angle_a:.2e}<{threshold:.2e}" + return None + + if key == "rms": + return _rel_change_check("rms_rel", rms, threshold, eps) + + if key == "elbo_rel": + return _rel_change_check("elbo_rel", cost, threshold, eps) + + msg = f"Unknown composite_stop key: {key!r}" + raise ValueError(msg) + + +def _rel_change_check( + label: str, + series: np.ndarray, + threshold: float, + eps: float, +) -> str | None: + """Check relative change between last two elements of *series*. + + Returns: + A summary string when change is below *threshold*, else ``None``. + """ + if series.size < 2: + return None + curr, prev = series[-1], series[-2] + if not (np.isfinite(curr) and np.isfinite(prev)): + return None + rel = abs(curr - prev) / (abs(curr) + eps) + if rel >= threshold: + return None + return f"{label}={rel:.2e}<{threshold:.2e}" + + +def _composite_stop( + composite_cfg: Mapping[str, float], + angle_a: float, + rms: np.ndarray, + cost: np.ndarray, +) -> str | None: + """Check whether **all** sub-criteria in *composite_cfg* are satisfied. + + Supported keys (all optional, but at least one must be present): + + - ``"angle"``: subspace angle must be below this value. + - ``"rms"``: relative RMS change over the last two iterations + must be below this value. + - ``"elbo_rel"``: relative ELBO change must be below this value. + + Returns: + A stop message listing which sub-criteria were satisfied, or + ``None`` if any sub-criterion is **not** met. + """ + satisfied: list[str] = [] + for key, threshold in composite_cfg.items(): + result = _check_sub_criterion(key, threshold, angle_a, rms, cost) + if result is None: + return None + satisfied.append(result) + + if not satisfied: + return None + + detail = ", ".join(satisfied) + return f"Composite stop: all criteria met ({detail})." + + # --------------------------------------------------------------------------- # Public convergence check # --------------------------------------------------------------------------- @@ -285,44 +373,38 @@ def convergence_check( 2. Early stopping based on probe RMS (``earlystop``). 3. RMS plateau stop (``rmsstop = [window, abs_tol, rel_tol]``). 4. Cost / ELBO criteria (``cfstop``, ``cfstop_rel``, ``cfstop_curv``). - 5. "Slowing-down'' stop based on ``sd_iter`` (gradient backtracking). + 5. Composite stop (``composite_stop``). + 6. "Slowing-down'' stop based on ``sd_iter`` (gradient backtracking). Returns: A non-empty convergence message when a criterion triggers, otherwise an empty string. """ - # 1. Angle-based stop - angle_msg = _angle_stop_message(opts, angle_a) - if angle_msg: - return angle_msg - rms = np.asarray(lc.get("rms", []), dtype=float) prms = np.asarray(lc.get("prms", []), dtype=float) cost = np.asarray(lc.get("cost", []), dtype=float) - # 2. Early stopping on probe RMS - early_msg = _early_stop_message(opts, prms) - if early_msg: - return early_msg - - # 3. RMS plateau rmsstop = opts.get("rmsstop") - if rms.size >= 2 and rmsstop is not None: - plateau_msg = _plateau_stop(rms, rmsstop, "RMS") - if plateau_msg: - return plateau_msg - - # 4. Cost / ELBO criteria - cost_msg = _cost_criteria(opts, cost) - if cost_msg: - return cost_msg - - # 5. Slowing-down criterion - slow_msg = _slowing_down_message(sd_iter) - if slow_msg: - return slow_msg - - return "" + composite_cfg = opts.get("composite_stop") + + # Evaluate criteria in priority order; return first trigger. + checks: list[str | None] = [ + # 1. Angle-based stop + _angle_stop_message(opts, angle_a), + # 2. Early stopping on probe RMS + _early_stop_message(opts, prms), + # 3. RMS plateau + _plateau_stop(rms, rmsstop, "RMS") + if rms.size >= 2 and rmsstop is not None + else None, + # 4. Cost / ELBO criteria + _cost_criteria(opts, cost), + # 5. Composite stop + _composite_stop(composite_cfg, angle_a, rms, cost) if composite_cfg else None, + # 6. Slowing-down criterion + _slowing_down_message(sd_iter), + ] + return next((msg for msg in checks if msg), "") # --------------------------------------------------------------------------- diff --git a/src/vbpca_py/_pca_full.py b/src/vbpca_py/_pca_full.py index 5d2fe0b..ea42163 100644 --- a/src/vbpca_py/_pca_full.py +++ b/src/vbpca_py/_pca_full.py @@ -1940,6 +1940,7 @@ def _build_options(kwargs: Mapping[str, object]) -> dict[str, object]: "cfstop": np.array([]), "cfstop_rel": None, "cfstop_curv": None, + "composite_stop": None, "verbose": 1, "num_cpu": None, "num_cpu_score_update": None, diff --git a/tests/test_converge.py b/tests/test_converge.py index ad2703a..c89767c 100644 --- a/tests/test_converge.py +++ b/tests/test_converge.py @@ -567,3 +567,157 @@ def test_cfstop_rel_has_priority_over_cfstop_curv() -> None: ) msg = convergence_check(opts, lc, angle_a=1.0, sd_iter=None) assert "cfstop_rel" in msg + + +# -------------------------------------------------------------------------- +# Composite stop (composite_stop) behaviour +# -------------------------------------------------------------------------- + + +def test_composite_stop_all_met() -> None: + """Composite stop triggers when ALL sub-criteria are satisfied.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": None, + "composite_stop": {"angle": 1e-3, "rms": 1e-3}, + } + # angle_a=1e-4 < 1e-3 ✓; rms rel change ≈ 2.5e-4 < 1e-3 ✓ + lc = _lc( + rms=[0.5, 0.4, 0.4001], + prms=[1.0, 0.9, 0.8], + cost=[10.0, 9.0, 8.0], + ) + msg = convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + assert "composite" in msg.lower() + assert "angle" in msg.lower() + assert "rms_rel" in msg.lower() + + +def test_composite_stop_partial_not_met() -> None: + """Composite stop does NOT trigger when only some sub-criteria are met.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": None, + "composite_stop": {"angle": 1e-3, "rms": 1e-6}, + } + # angle met (1e-4 < 1e-3), but rms rel change ≈ 0.25 >> 1e-6 + lc = _lc( + rms=[0.5, 0.4, 0.3], + prms=[1.0, 0.9, 0.8], + cost=[10.0, 9.0, 8.0], + ) + msg = convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + assert msg == "" + + +def test_composite_stop_with_elbo_rel() -> None: + """Composite stop works with the elbo_rel sub-criterion.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": None, + "composite_stop": {"angle": 1e-3, "rms": 1e-3, "elbo_rel": 1e-3}, + } + # All three met + lc = _lc( + rms=[0.5, 0.4, 0.4001], + prms=[1.0, 0.9, 0.8], + cost=[8.0, 8.0001, 8.00015], + ) + msg = convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + assert "composite" in msg.lower() + assert "elbo_rel" in msg.lower() + + +def test_composite_stop_elbo_rel_not_met() -> None: + """Composite stop fails when elbo_rel sub-criterion is not met.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": None, + "composite_stop": {"angle": 1e-3, "elbo_rel": 1e-8}, + } + # angle met, but ELBO change is large + lc = _lc( + rms=[0.5, 0.4], + prms=[1.0, 0.9], + cost=[10.0, 9.0], + ) + msg = convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + assert msg == "" + + +def test_composite_stop_disabled_when_none() -> None: + """composite_stop=None should not trigger any stop.""" + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": None, + "composite_stop": None, + } + lc = _lc( + rms=[0.5, 0.4, 0.4], + prms=[1.0, 0.9, 0.8], + cost=[8.0, 8.0, 8.0], + ) + msg = convergence_check(opts, lc, angle_a=1e-10, sd_iter=None) + assert msg == "" + + +def test_composite_stop_unknown_key_raises() -> None: + """Unknown keys in composite_stop should raise ValueError.""" + import pytest + + opts = { + "minangle": 0.0, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": None, + "composite_stop": {"angle": 1e-3, "bogus": 0.1}, + } + lc = _lc( + rms=[0.5, 0.4], + prms=[1.0, 0.9], + cost=[10.0, 9.0], + ) + with pytest.raises(ValueError, match="Unknown composite_stop key"): + convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + + +def test_individual_criteria_still_fire_with_composite_unset() -> None: + """Individual criteria still work when composite_stop is None.""" + opts = { + "minangle": 1e-3, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": None, + "composite_stop": None, + } + lc = _lc( + rms=[0.5, 0.4], + prms=[1.0, 0.9], + cost=[10.0, 9.0], + ) + msg = convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + assert "angle" in msg.lower() From b8f72be0df4df8f6d9d12ab433ec5c16f3a6ce0d Mon Sep 17 00:00:00 2001 From: "J.C. Macdonald" <72512262+jc-macdonald@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:00:26 -0400 Subject: [PATCH 4/5] feat(converge): add patience window for convergence criteria Gate all convergence signals through a configurable patience counter that requires N consecutive triggering iterations before reporting convergence. Default patience=1 preserves existing behavior. - Add _apply_patience() helper to _converge.py - Initialize _patience counter in lc dict (_monitoring.py) - Add patience option with default 1 to _pca_full.py - Add 4 tests covering suppression, consecutive trigger, reset, default Part of #54 --- src/vbpca_py/_converge.py | 45 ++++++++++++++++++++- src/vbpca_py/_monitoring.py | 1 + src/vbpca_py/_pca_full.py | 1 + tests/test_converge.py | 79 +++++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/vbpca_py/_converge.py b/src/vbpca_py/_converge.py index 70429b7..08c390f 100644 --- a/src/vbpca_py/_converge.py +++ b/src/vbpca_py/_converge.py @@ -353,6 +353,39 @@ def _composite_stop( return f"Composite stop: all criteria met ({detail})." +def _apply_patience( + msg: str, + lc: Mapping[str, Sequence[float]], + patience: int, +) -> str: + """Gate *msg* through a patience counter stored in ``lc["_patience"]``. + + When a criterion fires (``msg`` is non-empty), the counter is + incremented. The message is only returned once the counter reaches + *patience*. If no criterion fires, the counter resets to zero. + + Args: + msg: The candidate convergence message (may be empty). + lc: Learning-curve dict; ``lc["_patience"]`` is mutated in-place. + patience: Required number of consecutive satisfied iterations. + + Returns: + The convergence message when patience is exhausted, otherwise + an empty string. + """ + # Obtain the mutable patience list from lc. + patience_list: list[float] = lc.get("_patience", [0]) # type: ignore[assignment] + + if msg: + patience_list[0] = float(patience_list[0]) + 1 + if int(patience_list[0]) >= patience: + return msg + return "" + + patience_list[0] = 0.0 + return "" + + # --------------------------------------------------------------------------- # Public convergence check # --------------------------------------------------------------------------- @@ -376,6 +409,9 @@ def convergence_check( 5. Composite stop (``composite_stop``). 6. "Slowing-down'' stop based on ``sd_iter`` (gradient backtracking). + When ``patience`` is set (> 1), the winning criterion must fire for + that many **consecutive** iterations before the message is returned. + Returns: A non-empty convergence message when a criterion triggers, otherwise an empty string. @@ -404,7 +440,14 @@ def convergence_check( # 6. Slowing-down criterion _slowing_down_message(sd_iter), ] - return next((msg for msg in checks if msg), "") + candidate = next((msg for msg in checks if msg), "") + + # Apply patience window if configured. + patience_val = opts.get("patience") + patience = int(patience_val) if patience_val is not None else 1 + if patience <= 1: + return candidate + return _apply_patience(candidate, lc, patience) # --------------------------------------------------------------------------- diff --git a/src/vbpca_py/_monitoring.py b/src/vbpca_py/_monitoring.py index 0ca70de..72f9e59 100644 --- a/src/vbpca_py/_monitoring.py +++ b/src/vbpca_py/_monitoring.py @@ -652,6 +652,7 @@ def _initial_monitoring( "time": [0.0], "cost": [float("nan")], "angle": [float("nan")], + "_patience": [0.0], "phase_scores_sec": [0.0], "phase_loadings_sec": [0.0], "phase_rms_sec": [0.0], diff --git a/src/vbpca_py/_pca_full.py b/src/vbpca_py/_pca_full.py index ea42163..0911758 100644 --- a/src/vbpca_py/_pca_full.py +++ b/src/vbpca_py/_pca_full.py @@ -1941,6 +1941,7 @@ def _build_options(kwargs: Mapping[str, object]) -> dict[str, object]: "cfstop_rel": None, "cfstop_curv": None, "composite_stop": None, + "patience": 1, "verbose": 1, "num_cpu": None, "num_cpu_score_update": None, diff --git a/tests/test_converge.py b/tests/test_converge.py index c89767c..6bb3671 100644 --- a/tests/test_converge.py +++ b/tests/test_converge.py @@ -10,6 +10,8 @@ from typing import Any +import pytest + from vbpca_py._converge import convergence_check @@ -721,3 +723,80 @@ def test_individual_criteria_still_fire_with_composite_unset() -> None: ) msg = convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) assert "angle" in msg.lower() + + +# -------------------------------------------------------------------------- +# Patience window behaviour +# -------------------------------------------------------------------------- + + +def _opts_with_patience(patience: int) -> dict[str, object]: + """Build opts with angle criterion and a patience window.""" + return { + "minangle": 1e-3, + "earlystop": False, + "rmsstop": None, + "cfstop": None, + "cfstop_rel": None, + "cfstop_curv": None, + "composite_stop": None, + "patience": patience, + } + + +def test_patience_suppresses_first_trigger() -> None: + """With patience=3, the first trigger should be suppressed.""" + opts = _opts_with_patience(3) + lc = _lc(rms=[0.5, 0.4], prms=[1.0, 0.9], cost=[10.0, 9.0]) + lc["_patience"] = [0.0] + + msg = convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + assert msg == "" + assert lc["_patience"][0] == pytest.approx(1.0) + + +def test_patience_fires_after_consecutive_triggers() -> None: + """With patience=3, the third consecutive trigger emits the message.""" + opts = _opts_with_patience(3) + lc = _lc(rms=[0.5, 0.4], prms=[1.0, 0.9], cost=[10.0, 9.0]) + lc["_patience"] = [0.0] + + # Triggers 1 and 2: suppressed + convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + assert lc["_patience"][0] == pytest.approx(2.0) + + # Trigger 3: fires + msg = convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + assert "angle" in msg.lower() + + +def test_patience_resets_on_no_trigger() -> None: + """If a non-triggering iteration breaks the streak, counter resets.""" + opts = _opts_with_patience(3) + lc = _lc(rms=[0.5, 0.4], prms=[1.0, 0.9], cost=[10.0, 9.0]) + lc["_patience"] = [0.0] + + # Two triggers + convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + assert lc["_patience"][0] == pytest.approx(2.0) + + # No trigger (angle too large) + convergence_check(opts, lc, angle_a=1.0, sd_iter=None) + assert lc["_patience"][0] == pytest.approx(0.0) + + # Need 3 more consecutive to fire + convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + msg = convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + assert "angle" in msg.lower() + + +def test_patience_1_is_default_behaviour() -> None: + """patience=1 should behave like no patience (immediate trigger).""" + opts = _opts_with_patience(1) + lc = _lc(rms=[0.5, 0.4], prms=[1.0, 0.9], cost=[10.0, 9.0]) + + msg = convergence_check(opts, lc, angle_a=1e-4, sd_iter=None) + assert "angle" in msg.lower() From 4397070c1beb5c36a528f19353f2ebd10a181747 Mon Sep 17 00:00:00 2001 From: "J.C. Macdonald" <72512262+jc-macdonald@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:03:54 -0400 Subject: [PATCH 5/5] test(converge): integration tests for new convergence criteria Verify cfstop_rel, composite_stop, and patience options work in end-to-end pca_full fits on a low-rank synthetic matrix. Part of #54 --- tests/test_integration.py | 46 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_integration.py b/tests/test_integration.py index 854030f..a6c0f7b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -20,6 +20,7 @@ SelectionConfig, select_n_components, ) +from vbpca_py._pca_full import pca_full # -- fixtures -- @@ -202,3 +203,48 @@ def test_autoencoder_vbpca_roundtrip() -> None: np.linalg.norm(rec_cont - orig_cont) / (np.linalg.norm(orig_cont) + 1e-12) ) assert rel_error < 1.5, f"Continuous block relative error {rel_error:.3f} too large" + + +# -- convergence criteria integration tests -- + + +def test_cfstop_rel_terminates_fit( + low_rank_dense: tuple[np.ndarray, np.ndarray, int, float], +) -> None: + """cfstop_rel criterion terminates the fit before maxiters.""" + _x_clean, x_noisy, true_rank, _noise_std = low_rank_dense + result = pca_full(x_noisy, true_rank, bias=True, maxiters=500, cfstop_rel=1e-6) + n_iters = len(result["lc"]["rms"]) + assert n_iters < 500 + + +def test_composite_stop_terminates_fit( + low_rank_dense: tuple[np.ndarray, np.ndarray, int, float], +) -> None: + """composite_stop with angle + elbo_rel terminates the fit.""" + _x_clean, x_noisy, true_rank, _noise_std = low_rank_dense + result = pca_full( + x_noisy, + true_rank, + bias=True, + maxiters=500, + composite_stop={"angle": 1e-3, "elbo_rel": 1e-5}, + ) + n_iters = len(result["lc"]["rms"]) + assert n_iters < 500 + + +def test_patience_delays_convergence( + low_rank_dense: tuple[np.ndarray, np.ndarray, int, float], +) -> None: + """patience>1 causes at least as many iterations as patience=1.""" + _x_clean, x_noisy, true_rank, _noise_std = low_rank_dense + + r1 = pca_full( + x_noisy, true_rank, bias=True, maxiters=500, cfstop_rel=1e-6, patience=1 + ) + r5 = pca_full( + x_noisy, true_rank, bias=True, maxiters=500, cfstop_rel=1e-6, patience=5 + ) + + assert len(r5["lc"]["rms"]) >= len(r1["lc"]["rms"])