Skip to content

Add a gainExponent for auto-tuning; default 0.6 for BactrianSubtreeSlide - #218

Merged
walterxie merged 2 commits into
masterfrom
feature/adaptation-gain-exponent
Nov 9, 2026
Merged

walterxie merged 2 commits into
masterfrom
feature/adaptation-gain-exponent

Conversation

@alexeid

@alexeid alexeid commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Adds a gainExponent for operator auto-tuning, and makes BactrianSubtreeSlide default to 0.6, so that the subtree slide tunes from any start size. Addresses #200; an alternative or complement to #201 (start at 0.01) and #214 (BactrianSubtreeScale). Found during release testing (#183).

Problem

Auto-tuning moves an operator's tuned value, on its tuning scale, by (acceptance − target) / n after n proposals. With this 1/n gain the total distance it can travel grows only as about 0.7 ln n. In our runs the slide's size moved by at most about 8,000-fold in 3M steps, so a start that is wrong by more than that never tunes.

The subtree slide's size is in the tree's own units, so its right value ranges over many orders of magnitude between ordinary analyses. Measured on the BEAUti test analyses (beast3-test, BEAST 3 v2.8.0-rc2, 3M steps, seeds 1 and 2):

Tree height Slide from 1.0 (current default) Slide from 0.01 (#201)
≈ 0.19 (anolis, JC69) stuck too large: size 0.023, 1–3 % acceptance tunes
≈ 1,900 (same, clock fixed at 1e-4) tunes stuck too small: 73 % acceptance
≈ 1.9 million (clock 1e-7) stuck too small: 95 % stuck too small: 99.9 %

Trees this tall are not unusual: on PhyloData, 29 of 110 experiments have a mean root age ≥ 100 and 3 have one ≥ 1000, the tallest 2.3 million (a Yule tree with the clock fixed per year). The posterior is correct in every case; a stuck slide costs about 20–35 % of the ESS of the likelihood and tree height.

Change

The gain becomes 1/n^e, with 0 < e ≤ 1:

  • OperatorSchedule gets a gainExponent input, for operators that don't set their own. 1 is the classic 1/n and 0.5 is the same as transform="sqrt". It can't be combined with a transform other than none.
  • Operator gets a gainExponent input and getGainExponent() / getDefaultGainExponent(), on the pattern of targetAcceptanceProbability. The default is NaN, meaning the schedule decides, so no other operator's tuning changes.
  • BactrianSubtreeSlide and the classic SubtreeSlide override the default with 0.6, the usual choice for diminishing adaptation (Andrieu & Thoms 2008). Their log size can then travel as n^0.4 instead of ln n. gainExponent="1" on the operator (or on the schedule) restores the old behaviour.

Precedence: the operator's input, then the schedule's gainExponent or transform, then the operator's default, then 1/n. Exponents are checked at load time; 0 < e ≤ 1 is allowed, with a warning at e ≤ 0.5, where the tuned value may not settle. The state file already stores the counts the gain is computed from, so resumed runs of the same build continue with the same gain.

Because 1/n can also get stuck on other operators, the schedule-wide gainExponent lets an analysis use 0.6 for every operator. Whether that should become the schedule default needs a broader test and is left for later.

Results

With this branch and unchanged XML, all four stuck cases tune to 30 % acceptance and match the posterior of the reference runs (seeds 1 and 2, ESS per million steps, two replicates summed):

Case Final size likelihood ESS/M Tree.height ESS/M
≈ 0.19, from 1.0 0.0089 3592 (2753 with 1/n) 6482 (4211 with 1/n)
≈ 1,900, from 0.01 88 4672 (BactrianSubtreeScale: 3309) 6263 (BactrianSubtreeScale: 5639)
≈ 1.9M, from 1.0 88,900 4189 (BactrianSubtreeScale: 3121) 5990 (BactrianSubtreeScale: 5681)
≈ 1.9M, from 0.01 88,000 3489 (BactrianSubtreeScale: 3121) 5557 (BactrianSubtreeScale: 5681)

Reference runs: the default-tuned slide on the 0.19 tree (rc2), and BactrianSubtreeScale from #214 on the tall trees. The tuned size from both starts is the same.

Where 1/n already tuned

Same build, unchanged XML, seeds 1 and 2, the slide starting at 1.0. The slide reaches the same size, every posterior matches, and the ESS of every logged column is unchanged on median:

Analysis H final size, 1/n → 1/n^0.6 acceptance ESS ratio (1/n^0.6 ÷ 1/n), median (range)
C06, 4 partitions, HKY+Γ 1.4 0.075 → 0.072 28 → 30 % 0.99 (0.71–1.37)
C10, RSV2 tip dates 56 1.03 → 1.05 30 → 30 % 0.98 (0.90–1.21)
C13, RSV2 EBSP* 56 1.03 → 1.02 30 → 30 % 0.98 (0.50–2.43)
C18, WAG protein 0.25 0.032 → 0.025 19 → 30 % 0.99 (0.88–1.10)
C01, clock 1e-4 1,926 83 → 89 34 → 30 % 0.97 (0.83–1.00)

* compared with the BactrianSubtreeScale run, whose build also has the SampleOffValues fix (#203); its columns include 129 binary indicators.

Across all tuned runs, from either start and with either gain, the slide settles at 0.25–0.45 × H / √(2n − 1).

Tests

  • New OperatorGainExponentTest: the default gain is still exactly 1/n; schedule gainExponent 1 and 0.5 reproduce the default and transform="sqrt"; an operator's exponent overrides the schedule's; both slides default to 0.6, the schedule's settings override that, and the slide's own input overrides the schedule; invalid values are rejected at load time, also with auto-tuning off; operator boundary values (1, 0.5, 1e-6) are accepted.
  • The full beast-base suite passes: 580 tests, 3 skipped (JDK 26, macOS).

Not done

  • The slide's new default changes the tuning, so the traces, though not the posterior, of every analysis that uses BactrianSubtreeSlide. See Where 1/n already tuned above: no systematic change in mixing.
  • No change to BEAUti or the templates. Start the BEAUti subtree slide at size 0.01 #201's start of 0.01 complements this change. With the slide's 0.3 target, a size that is too small moves up by 0.7/k per proposal but a size that is too large moves down by only 0.3/k (with 1/n: about 8,000-fold up against 50-fold down in 3M steps), so a small start is better. With 1/n^0.6 both directions reach far enough, and the small start still makes tuning faster. It also helps analyses that set gainExponent="1".
  • Compatible with Add BactrianSubtreeScale, a scale-invariant subtree slide, and use it in BEAUti #214: a trial merge of Add BactrianSubtreeScale, a scale-invariant subtree slide, and use it in BEAUti #214's branch into this one has no conflicts, and the gain, schedule, BactrianSubtreeScale and resume tests pass on the result. With both merged, BactrianSubtreeSlide defaults to 0.6 and BactrianSubtreeScale keeps the schedule's gain; it doesn't need a slower one, since it ends at the same size (about 0.19) on every tree height.

🤖 Generated with Claude Code

Auto-tuning moves an operator's tuned value (on its tuning scale) by
(acceptance - target) / n after n proposals. With that 1/n gain the
total distance it can travel grows only as about 0.7 ln n, so a start
that is wrong by more than a few thousand-fold never tunes. The subtree
slide's size is in the tree's own units, so its right value ranges from
about 0.01 to 10^5 between ordinary analyses (#200): a start at 1.0 is
stuck too large on substitution-scale trees, and a start at 0.01 (#201)
is stuck too small on trees a few thousand units tall.

Add gainExponent e, so that the gain is 1/n^e with 0 < e <= 1:

- OperatorSchedule gets a gainExponent input, for operators that don't
  set their own. 1 is the classic 1/n and 0.5 is transform="sqrt"; it
  can't be combined with a transform other than none.
- Operator gets a gainExponent input and getGainExponent() /
  getDefaultGainExponent(), on the pattern of targetAcceptanceProbability.
  The default is NaN, meaning the schedule decides, so no operator
  changes unless it overrides it.
- BactrianSubtreeSlide defaults to 0.6, the usual choice for diminishing
  adaptation (Andrieu and Thoms 2008); it can travel as n^0.4 instead.

The state file already stores the counts the gain is computed from, so
resumed runs continue with the same gain.
@walterxie

Copy link
Copy Markdown
Member

To check:

  1. The slide's built-in 0.6 beats the user's schedule settings (OperatorSchedule.java:460). The operator's class default is used before the schedule's gainExponent or transform. So or gainExponent="1" is silently ignored for BactrianSubtreeSlide. This changes how existing XMLs behave, with no warning. A clean order would be: operator input, then schedule input, then operator default, then transform.
  2. A bad operator-level exponent is only caught during the run (:464). gainExponent="1.5" on an operator throws only after autoOptimizeDelay steps, so the run is lost partway. With autoOptimize=false it is never reported. The schedule-level value is already checked at load time.
  3. The "cannot be combined with a transform" rule only applies at schedule level (:117). An operator's exponent combined with transform="sqrt" passes validation, and the transform is quietly ignored for that operator. The same combination on the schedule throws.
  4. Resumed runs switch the gain mid-run (:406). The state file stores the counts but not which exponent was used. Resuming a 2.8.0-rc2 state on this branch changes 1/n to 1/n^0.6, which is about 400× larger at 3M steps. This contradicts the PR's claim that "resumed runs continue with the same gain".
  5. The allowed range 0 < e ≤ 1 is too wide (:490). At e ≤ 0.5, standard stochastic-approximation theory no longer guarantees the tuned size converges. It should be restricted to (0.5, 1], or at least warn below 0.5.

Smaller points:

  1. Wasted work in the MCMC loop: every calcDelta call rebuilds an error-message string and re-checks the range. BactrianSubtreeSlide is one of the most-used operators, so that cost adds up.
  2. Only BactrianSubtreeSlide gets the 0.6 default. The classic SubtreeSlide has the same stuck-tuning problem from beast3#200 and still uses 1/n.
  3. Design (judgement call): the real cause is that the slide starts at size 1.0 whatever the tree height. Starting it from the tree's scale, as limit already does, would fix both slide classes without changing the gain.
  4. Tests: the bad-exponent test calls delta() directly, so it locks in the mid-run failure. Nothing tests an operator exponent combined with a schedule transform, or the boundary values.

…s at load

From the review of #218:

- Precedence is now: the operator's gainExponent input, then the
  schedule's gainExponent or transform, then the operator's built-in
  default, then 1/n. A schedule transform="sqrt" or gainExponent="1" is
  no longer ignored for the subtree slide.
- An operator's gainExponent is checked when it is added to a schedule,
  so a bad value fails at load time, also with autoOptimize="false",
  and calcDelta no longer builds a message string on every call.
- Warn, don't reject, at gainExponent <= 0.5: the tuned value may not
  settle (Robbins-Monro needs 0.5 < e <= 1); transform="sqrt" is 0.5.
- The classic SubtreeSlide also defaults to 0.6 (same issue, #200).
- Tests: schedule settings override the slide default, bad operator
  exponents fail when added, operator boundary values, classic slide.
@alexeid

alexeid commented Nov 8, 2026

Copy link
Copy Markdown
Member Author

Thanks, Walter. Fixed in 0ccfdc8, point by point:

  1. Fixed. Precedence is now: the operator's gainExponent input, then the schedule's gainExponent or transform, then the operator's built-in default, then 1/n. A schedule transform="sqrt" or gainExponent="1" now applies to the subtree slide too; the slide's 0.6 only applies when nothing is set.
  2. Fixed. An operator's exponent (input and default) is checked when the operator is added to a schedule, so a bad value fails at load time, also with autoOptimize="false".
  3. Kept, documented. An operator's own gainExponent is a per-operator override, so it beating the schedule's transform is intended. The schedule-level error is for two schedule settings that contradict each other. The input descriptions now say so.
  4. Right, the wording was wrong. The state file stores the counts, not the exponent, so resuming a state written by another build (e.g. rc2) switches gains. I've changed the description to "resumed runs of the same build continue with the same gain". It doesn't affect correctness: the adaptation still diminishes either way.
  5. Now a warning, not an error. At e ≤ 0.5 the tuned value may not settle (Robbins–Monro needs 0.5 < e ≤ 1), but the chain is still a valid diminishing-adaptation MCMC, and transform="sqrt" already is e = 0.5, so rejecting it would be inconsistent. Values ≤ 0.5 now print a warning at load time. 0.6 is inside the safe range.
  6. Fixed by 2: calcDelta no longer checks or builds a message.
  7. Fixed. The classic SubtreeSlide defaults to 0.6 as well.
  8. Not in this PR. Starting from the tree's scale and a slower gain are complementary: Start the BEAUti subtree slide at size 0.01 #201 already moves the start to 0.01, and the tree's height can change by orders of magnitude during burn-in, which a start-only fix can't follow. Happy to look at a tree-scaled start separately.
  9. Partly. An operator exponent combined with a schedule transform was already tested (operatorGainExponentOverridesSchedule: gainExponent=1.0 with transform="sqrt"), as were the schedule boundaries. You're right that the bad-exponent test locked in the mid-run failure; it now checks the load-time failure, with and without auto-tuning. New tests: schedule settings override the slide's default, operator boundary values (1, 0.5, 1e-6), and the classic slide.

The full beast-base suite passes: 580 tests, 3 skipped (JDK 26, macOS).

@alexeid
alexeid marked this pull request as ready for review November 8, 2026 23:50
@walterxie
walterxie merged commit 395460d into master Nov 9, 2026
1 check passed
@walterxie
walterxie deleted the feature/adaptation-gain-exponent branch November 9, 2026 00:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants