Skip to content

Commit 583c8bf

Browse files
Fix MLX rholrate to ratchet at maxdecs (#195) (#197)
* Fix MLX rholrate to ratchet at maxdecs (#195) * Match newt_start default to torch/numpy; add reset test
1 parent cd42cfd commit 583c8bf

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

pamica/mlx_impl/core.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def __init__(
9393
lratefact: float = 0.5,
9494
maxdecs: int = 5,
9595
newt_ramp: int = 10,
96+
newt_start: int = 20,
9697
do_newton: bool = False,
9798
rho0: float = 1.5,
9899
minrho: float = 1.0,
@@ -134,6 +135,11 @@ def __init__(
134135
self.lratefact = lratefact
135136
self.maxdecs = maxdecs
136137
self.newt_ramp = newt_ramp
138+
# Schedule threshold for the rholrate ceiling ratchet (Fortran
139+
# amica15.f90:1049 gates it on iter > newt_start, independent of
140+
# do_newton). MLX does no Newton, but keeps newt_start as this gate so the
141+
# rho-rate schedule matches Fortran/torch.
142+
self.newt_start = newt_start
137143
self.do_newton = False
138144

139145
self.rho0 = rho0
@@ -602,7 +608,18 @@ def fit(
602608
self.ll_history.append(ll)
603609

604610
# Learning-rate control (Fortran amica17.f90:1062-1108): anneal on an
605-
# LL decrease; ratchet the ceiling after maxdecs persistent decreases.
611+
# LL decrease; ratchet the ceilings after maxdecs persistent decreases.
612+
#
613+
# rholrate is a maxdecs-ratcheted CEILING, not a per-decrease-annealed
614+
# working rate. Fortran resets rholrate=rholrate0 every iteration before
615+
# the rho update (amica15.f90:1788) and only tightens the rholrate0
616+
# ceiling at maxdecs (amica15.f90:1050, gated on iter > newt_start), so
617+
# its per-decrease rholrate*=rholratefact (:1045) never reaches the rho
618+
# update. rho has no ramp, so self.rholrate carries that ceiling directly
619+
# (reset to rholrate0 at fit start, nothing re-inflates it) and must
620+
# ratchet ONLY at maxdecs. The previous per-decrease decay collapsed the
621+
# rho rate to ~1e-5 within a few hundred iterations and froze rho at a
622+
# stale shape (issue #195, mirroring the torch/numpy fix in #193/#194).
606623
if len(self.ll_history) > 1 and ll < self.ll_history[-2]:
607624
if self.lrate <= self.minlrate:
608625
logger.warning(
@@ -613,10 +630,11 @@ def fit(
613630
self.stop_reason = "lrate_floor"
614631
break
615632
self.lrate *= self.lratefact
616-
self.rholrate *= self.rholratefact
617633
numdecs += 1
618634
if numdecs >= self.maxdecs:
619635
self.lrate_cap *= self.lratefact
636+
if it > self.newt_start:
637+
self.rholrate *= self.rholratefact
620638
numdecs = 0
621639

622640
if self.stop_reason in self._DEGENERATE_STOP_REASONS:

pamica/tests/mlx_tests/test_mlx_backend.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,81 @@ def test_multimodel_dead_model_keeps_prior_c():
310310
m.c = mx.array(np.stack([np.zeros(NW, np.float32), marker], axis=1))
311311
m._update_parameters(acc, xt.shape[1])
312312
assert np.allclose(np.array(m.c)[:, 1], marker) # dead model kept its prior c
313+
314+
315+
def test_rholrate_ratchets_at_maxdecs_not_per_decrease():
316+
"""Issue #195 (mirrors #193/#194 for torch/numpy): the MLX rho learning rate is
317+
a maxdecs-ratcheted CEILING, not a per-LL-decrease monotone decay.
318+
319+
Fortran resets ``rholrate = rholrate0`` each iteration before the rho update
320+
(amica15.f90:1788) and only tightens the ceiling at ``maxdecs``
321+
(amica15.f90:1050, gated on ``iter > newt_start``). MLX previously decayed
322+
``rholrate`` on EVERY LL decrease with no reset, collapsing it toward ~1e-5
323+
within a few hundred iterations and freezing rho at a stale shape.
324+
325+
An aggressive-lrate natural-gradient run on the real sample overshoots and
326+
triggers several LL decreases; with ``newt_start=0`` (gate always open) the
327+
ceiling ratchets once per ``maxdecs`` decreases, NOT once per decrease.
328+
"""
329+
from pamica.mlx_impl import AMICAMLXNG
330+
331+
data = _load_real_data()
332+
m = AMICAMLXNG(
333+
n_channels=NW, n_mix=NMIX, seed=SEED, block_size=256,
334+
lrate=0.5, lratefact=0.5, rholrate=0.05, rholratefact=0.5,
335+
maxdecs=3, newt_start=0,
336+
) # fmt: skip
337+
m.fit(data, max_iter=400, verbose=False)
338+
339+
ll = m.ll_history
340+
n_dec = sum(1 for i in range(1, len(ll)) if ll[i] < ll[i - 1])
341+
assert m.lrate > m.minlrate, "run hit the lrate floor; use a gentler config"
342+
assert n_dec >= m.maxdecs, "too few LL decreases to exercise the ratchet"
343+
344+
# The ceiling ratcheted a whole number of times at the maxdecs cadence
345+
# (rholrate0 * rholratefact**k), far fewer steps than one-per-decrease.
346+
assert m.rholrate < m.rholrate0
347+
k = round(np.log(m.rholrate / m.rholrate0) / np.log(m.rholratefact))
348+
assert m.rholrate == pytest.approx(m.rholrate0 * m.rholratefact**k)
349+
assert k <= n_dec // m.maxdecs + 1
350+
# Guard against a regression to the old per-decrease decay (orders below).
351+
buggy = m.rholrate0 * (m.rholratefact**n_dec)
352+
assert m.rholrate > buggy * 10
353+
354+
355+
def test_rholrate_ceiling_ratchet_gated_on_newt_start():
356+
"""The rholrate ceiling ratchet is gated on ``iter > newt_start`` (Fortran
357+
amica15.f90:1049, independent of do_newton). With ``newt_start`` past the whole
358+
budget, LL decreases must NOT tighten the rho ceiling -- it stays at
359+
``rholrate0`` exactly (only ``lrate_cap`` ratchets, which is not gated)."""
360+
from pamica.mlx_impl import AMICAMLXNG
361+
362+
data = _load_real_data()
363+
m = AMICAMLXNG(
364+
n_channels=NW, n_mix=NMIX, seed=SEED, block_size=256,
365+
lrate=0.5, lratefact=0.5, rholrate=0.05, rholratefact=0.5,
366+
maxdecs=3, newt_start=100_000,
367+
) # fmt: skip
368+
m.fit(data, max_iter=400, verbose=False)
369+
370+
ll = m.ll_history
371+
n_dec = sum(1 for i in range(1, len(ll)) if ll[i] < ll[i - 1])
372+
assert n_dec >= m.maxdecs, "config did not exercise the decrease path"
373+
# Gated off for the whole run: the rho ceiling never moved.
374+
assert m.rholrate == m.rholrate0
375+
376+
377+
def test_rholrate_ceiling_resets_at_fit_start():
378+
"""Issue #195: the rho-rate ceiling is reset to rholrate0 at fit start
379+
(``_initialize_parameters``, the same call ``fit`` makes), so a previously
380+
ratcheted ``rholrate`` does not carry across a re-fit/restart -- parity with
381+
the numpy backend's ``test_reinitialize_for_restart_resets_rho_ceiling``."""
382+
from pamica.mlx_impl import AMICAMLXNG
383+
384+
m = AMICAMLXNG(n_channels=NW, n_mix=NMIX, seed=SEED)
385+
# Simulate a prior fit that ratcheted both ceilings down at maxdecs.
386+
m.rholrate = m.rholrate0 * 0.25
387+
m.lrate_cap = m.lrate0 * 0.25
388+
m._initialize_parameters() # fit-start reset
389+
assert m.rholrate == m.rholrate0
390+
assert m.lrate_cap == m.lrate0

0 commit comments

Comments
 (0)