Skip to content

Add BactrianSubtreeScale, a scale-invariant subtree slide, and use it in BEAUti - #214

Merged
walterxie merged 7 commits into
masterfrom
feature/bactrian-subtree-scale
Nov 8, 2026
Merged

walterxie merged 7 commits into
masterfrom
feature/bactrian-subtree-scale

Conversation

@alexeid

@alexeid alexeid commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Adds BactrianSubtreeScale, ported from beast-phylonco (commit 718de3a), where Yao has been using it for months, and makes it BEAUti's subtree slide. It's a subtree slide that's scale-invariant. Instead of adding a step to the slid node's height, it scales, in log space, the branch between the node and the subtree it carries:

gap'    = gap * exp(size * Z)     // Z: mean-0 Bactrian draw
height' = floor + gap'            // floor = height of the carried subtree
  • The new height can never fall below the carried subtree, so that flat rejection is gone.
  • The move is relative to the branch length, so one size suits any tree height or time unit.
  • Hastings ratio: log(gap′/gap) plus the same topology terms as BactrianSubtreeSlide.
  • One change from the phylonco version: it overrides getDefaultTargetAcceptanceProbability(), so the target can be set from XML like other operators.

Why

BactrianSubtreeSlide's additive step has to be sized to the tree. In the example XMLs its tuned size ranges from 0.008 (testCoalescent, height 0.06) to 2.5 (testBSP, height 70). Auto-tuning can't recover from a start that is far too large (#200), and it climbs slowly from one far too small (see the table in #201's comments). A fixed default, 1.0 or 0.01, fails one end or the other. This operator removes the problem at the source, which also makes #213 (predicting the tree height to size the slide) largely unnecessary.

BEAUti templates

The Standard (Yule) and TreePriors templates now use BactrianSubtreeScale instead of BactrianSubtreeSlide. The operator IDs and weights are unchanged, and there's no size, so the default of 0.3 applies to every tree. This replaces #201's fixed 0.01 start, which suits substitution-scale trees but takes millions of steps to tune up on dated trees in years, and still fails on very shallow ones (#201 comments). The proposal kernel affects only mixing, not the posterior, so comparisons with BEAST 2.7.9 stay like-for-like.

The core templates never build sampled-ancestor trees, whose zero-length branches this operator rejects. The sampled-ancestor package has its own templates and operators.

Tests: BactrianSubtreeScaleTest

  • Mechanics (from beast-phylonco): on an ultrametric tree the move never violates the floor and never flat-rejects.
  • Exact distribution: run alone on a 4-taxon tree under a constant-population coalescent with no data, it reproduces the closed-form answer. The interval means are θ/6, θ/3 and θ (sampled 0.1673, 0.3339, 0.9969 with θ = 1), and the balanced shape has probability 1/3 (sampled 0.3326). BactrianSubtreeSlide is run the same way as a control.
  • Unit-free: with tuning off and size 0.5, θ = 10⁻³ and θ = 10³ give the same acceptance rate (0.8273 vs 0.8272), and both reproduce their exact distributions. An additive slide with a fixed size of 0.3 accepts 0.01% and 99.6% of moves at the same two scales.

SimpleTreePriorTest now also checks, for each of its six tree priors, that the subtree slide BEAUti builds is a BactrianSubtreeScale at its default size. It fails on the previous templates.

Fast beast-base suite: 572 tests, 0 failures. beast-fx tests pass: 52 run, 0 failures, plus the slow ones.

Benchmark

Each operator replaced the slide in three examples (2M steps, 2 replicates, both auto-tuned). The additive slide started at its converged size, its best case; the scaled one at 0.5 everywhere.

Example Slide ESS/M posterior ESS/M root height Tuned size Acceptance
testBSP (tip-dated, height 70) additive 2140 2418 2.54 0.302
scaled 2222 2478 0.66 0.299
testCoalescent (substitutions, height 0.06) additive 13126 13119 0.0082 0.301
scaled 12334 11732 0.47 0.294
testYuleCalibrated (time, height 16) additive 461 5003 2.12 0.302
scaled 516 4964 0.46 0.300

Mixing is within about ±10% of the best-case additive slide. The scaled slide gets there from one default: its tuned size stays at 0.46–0.66 across tree heights spanning three orders of magnitude.

Shared code with BactrianSubtreeSlide

The slide and the scale move share everything but the height proposal, now in an abstract AbstractSubtreeSlide (node choice, reattachment up and down, intersectingEdges, markclades, source/destination counts) with one hook, proposeHeight(i, p). Each operator keeps its own inputs and tuning. The refactor leaves the random-number calls unchanged and is exact: 16 fixed-seed runs (4 examples × scale, scale+markclades, slide, slide+limit+markclades) give byte-identical logs and trees before and after.

Examples

The 28 examples that used BactrianSubtreeSlide (27 XML, testHKY.json) now use BactrianSubtreeScale, with size dropped: their sizes were tuned for the additive slide in tree-height units and mean something else for the unitless scale move. The examples/legacy/ copies don't use the slide and are unchanged.

All 28 run cleanly; Example* tests pass. Posterior against the existing slide logs in beast3-test (same chain length and sampling, 2 replicates each):

Example Verdict Max z Min ESS per replicate
testCoalescent matched 2.73 3936
testYuleCalibrated matched 2.01 720
testBSP matched 2.82 2825
testCalibration matched 1.93 772
testTwoCalibrationsPrior matched (4 replicates) 1.63 3005

testTwoCalibrationsPrior was borderline with 2 replicates (yule z = 3.77, a 0.05 difference in log density); with 4 fresh seeds it matches, so that was noise. ESS is within about ±10% of the slide's across these, as in the benchmark above.

Interaction with #204: #204 adds 8 more examples that use the slide (testMultipleAlignments_randomTaxaOrder, testRandomLocalClock, testRelaxedClock, testSRD06, testTipDates, testTipDates2, testUCLNclock.json, beast-fx testSimulatedAlignment). #204 merged first; they are switched here too.

Not in this PR

  • beast-phylonco switching to this copy.

Port BactrianSubtreeScale from beast-phylonco (commit 718de3a). It
proposes the slid node's height by scaling, in log space, the branch
between the node and the subtree it carries:
gap' = gap * exp(size * Z), height' = floor + gap'. The new height can
never fall below the carried subtree, so that flat rejection is gone,
and the move is relative to the branch length, so one size suits any
tree height or time unit. The Hastings ratio is log(gap'/gap) plus the
same topology terms as BactrianSubtreeSlide. The one change from the
phylonco version: it overrides getDefaultTargetAcceptanceProbability,
so the target can be set from XML as for other operators.

BactrianSubtreeSlide's additive step has to be sized to the tree:
tuned sizes in the example XMLs range from 0.008 (height 0.06) to 2.5
(height 70), and auto-tuning can't recover from a start that is far too
large (#200) or climbs slowly from one far too small. With this
operator, the tuned size on testCoalescent, testYuleCalibrated and
testBSP was 0.46-0.66 for all three, and ESS per step of the posterior
and root height was within about 10% of BactrianSubtreeSlide started at
its converged size.

BactrianSubtreeScaleTest:
- the floor is never violated and no proposal is rejected on an
  ultrametric tree (from beast-phylonco);
- run alone on a 4-taxon tree under a constant-population coalescent,
  it reproduces the exact distribution: interval means theta/6,
  theta/3, theta and the balanced shape at 1/3 (BactrianSubtreeSlide
  is run the same way as a control);
- with tuning off, theta = 1e-3 and 1e3 give the same acceptance rate
  (0.827) and the same rescaled distribution; an additive slide with a
  fixed size accepts 0.01% and 99.6% of moves.

Registered in module-info.java and version.xml. Templates are unchanged.
The Standard (Yule) and TreePriors templates used BactrianSubtreeSlide,
whose additive step has to be sized to the tree. #201 started it at
0.01, which suits substitution-scale trees but takes millions of steps
to tune up on dated trees in years, and still fails for very shallow
trees (#201 comments). Use BactrianSubtreeScale instead, with the same
operator IDs and weights and no size, so its unitless default of 0.3
applies to every tree. The proposal kernel affects mixing, not the
posterior.

The core templates never build sampled-ancestor trees, whose
zero-length branches BactrianSubtreeScale rejects; the sampled-ancestor
package has its own templates and operators.

SimpleTreePriorTest now checks, for each of its six tree priors, that
the subtree slide BEAUti builds is a BactrianSubtreeScale at its default
size. It fails on the previous templates. beast-fx tests pass, slow ones
included.
@alexeid alexeid changed the title Add BactrianSubtreeScale, a scale-invariant subtree slide (for after 2.8.0) Add BactrianSubtreeScale, a scale-invariant subtree slide, and use it in BEAUti Nov 6, 2026
Switch the 28 examples (27 XML, testHKY.json) that used
BactrianSubtreeSlide to BactrianSubtreeScale, as the BEAUti templates now
do. Their size values were tuned for the additive slide, in tree-height
units, and mean something different for the unitless scale move, so they
are dropped and the operator's default is used. The examples/legacy/
copies do not use the slide and are unchanged.
@walterxie

walterxie commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Minor issues (non-blocking)

  1. markclades is ignored. The input comes from TreeOperator, and BactrianSubtreeSlide honours it with makeDirty(Tree.IS_FILTHY). The new operator accepts the input but never acts on it, so any XML or package that sets markclades="true" gets no effect and no warning. Either add the two makeDirty blocks back or say in the docs that it isn't supported.
  2. About 150 lines are duplicated from BactrianSubtreeSlide. The two operators differ only in how the new height and its Jacobian are proposed. A protected hook in BactrianSubtreeSlide (something like proposeHeight(i, p) returning the new height and log-Jacobian) would let the scale version override just that and avoid the two copies drifting apart. That fits better in a follow-up than in this PR.
  3. The new tests are heavy for the fast suite. BactrianSubtreeScaleTest runs 6 MCMC chains of 2M steps each and has no @Tag("slow"). With 4 taxa it is probably tens of seconds, but it's still the slowest thing added to the default suite. The statistical assertions are deterministic only because the seeds are fixed. Any change that uses the random number generator differently could make one of the roughly 16 assertions (each at 4 standard errors) fail spuriously, so consider tagging the distribution tests slow.
  4. The additive contrast run checks nothing. In scaledSlideIsUnitFree, addSmall and addLarge only print their acceptance rates. Either assert that the gap is large (as the PR description says, 0.01% vs 99.6%) or drop the two runs to save 4M steps.
  5. Sampled-ancestor trees aren't guarded. If i is a zero-length tip the move returns −∞, which is fine. But if CiP is the zero-length sampled-ancestor tip and i is the other child, p can still move and silently turn the sampled ancestor into an ordinary tip. The old slide has the same behaviour and the PR says core templates never build these trees, so this isn't a regression. It would still be worth a line in the class Javadoc that the operator isn't safe for sampled-ancestor trees.
  6. Precision edge case (negligible). When the gap is tiny relative to the floor (for example, tip-dated heights around 1e3 with gaps around 1e-12), floor + oldGap*scale can round to exactly floor. That creates a zero-length branch, and the operator then always rejects moves on that node, though other operators can still move it.

#204 added 8 examples that use BactrianSubtreeSlide with tuned sizes
(testMultipleAlignments_randomTaxaOrder, testRandomLocalClock,
testRelaxedClock, testSRD06, testTipDates, testTipDates2,
testUCLNclock.json and beast-fx testSimulatedAlignment). Switch them to
BactrianSubtreeScale with no size, and rewrite their conversion comments
to match. Note the switch in MIGRATION-REPORT-batch2.md, keeping the
tuned slide sizes as a record.

CHANGELOG: list BactrianSubtreeScale as a new operator and as part of
BEAUti's default tree operator set. migration-guide: the recommended new
operator set uses it without a size.
From Walter's review of #214:
- markclades is now honoured, with the same makeDirty(IS_FILTHY) calls
  as BactrianSubtreeSlide.
- Reject a downward proposal whose height rounds to exactly the floor
  (a tiny gap relative to the floor), instead of making a zero-length
  branch.
- Javadoc: not for sampled-ancestor trees, as for BactrianSubtreeSlide.
- scaledSlideIsUnitFree now asserts the additive contrast it runs
  (acceptance < 0.05 at theta 1e-3, > 0.9 at theta 1e3).
BactrianSubtreeScale duplicated about 150 lines of BactrianSubtreeSlide
(Walter's review of #214). Move the shared move into an abstract
AbstractSubtreeSlide: node choice, reattachment up and down,
intersectingEdges, markclades, and the source/destination counts. One
hook, proposeHeight(i, p), returns the new height and its log-Jacobian,
or null to reject.

A base class rather than a hook in BactrianSubtreeSlide, so the scale
move does not inherit the slide's size (tree-height units) and limit
inputs, which it would accept and ignore. Each operator keeps its own
inputs and tuning; BactrianSubtreeSlide.getDelta() stays for packages
that override it.

The random-number calls are unchanged, so the refactor is exact: 16
fixed-seed runs (testCoalescent, testYuleCalibrated, testTipDates,
testBSP; scale, scale+markclades, slide, slide+limit+markclades) give
byte-identical logs and trees before and after.
@alexeid

alexeid commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Thanks Walter, all useful. Addressed in 6e62d24 and 1d7e680:

  1. markclades: now honoured, with the same two makeDirty(Tree.IS_FILTHY) blocks as BactrianSubtreeSlide.
  2. Duplication: done here rather than in a follow-up, in 1d7e680. The shared code (node choice, reattachment up and down, intersectingEdges, markclades, the source/destination counts) is now in an abstract AbstractSubtreeSlide, with one hook, proposeHeight(i, p), returning the new height and its log-Jacobian (or null to reject). I used a base class rather than a hook in BactrianSubtreeSlide so the scale move doesn't inherit the slide's size (tree-height units) and limit inputs, which would be accepted and ignored. Each operator keeps its own inputs and tuning; BactrianSubtreeSlide.getDelta() is still there for packages that override it. Since the random-number calls are unchanged, the refactor is checkable exactly: 16 fixed-seed runs (testCoalescent, testYuleCalibrated, testTipDates, testBSP × scale, scale+markclades, slide, slide+limit+markclades) give byte-identical logs and trees before and after.
  3. Test weight: BactrianSubtreeScaleTest takes about 4 s in all (4 taxa, so the chains are cheap), which seems fine for the default suite. I've left it untagged. If a change to how the random number generator is used ever trips one of the 4-SE assertions, tagging the distribution tests slow is the fix.
  4. Contrast run: it now asserts the gap: acceptance < 0.05 at θ = 1e-3 and > 0.9 at θ = 1e3 (it measures 0.0001 and 0.996).
  5. Sampled ancestors: added a Javadoc paragraph saying it isn't for sampled-ancestor trees, as for the old slide.
  6. Rounding: a downward proposal that rounds to exactly the floor is now rejected rather than creating a zero-length branch.

Also since your review: master is merged in, the full default test suite passes, and #204's 8 new examples are switched to BactrianSubtreeScale too (no size), with their conversion comments, the CHANGELOG and the migration guide updated.

@walterxie
walterxie merged commit 8cb5fd0 into master Nov 8, 2026
1 check passed
@walterxie
walterxie deleted the feature/bactrian-subtree-scale branch November 8, 2026 20: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