Skip to content

Add tree normalizing flow for phylogenetic variational inference - #116

Open
christiaanjs wants to merge 13 commits into
masterfrom
claude/tree-normalizing-flow-bijector-kcgn8d
Open

Add tree normalizing flow for phylogenetic variational inference#116
christiaanjs wants to merge 13 commits into
masterfrom
claude/tree-normalizing-flow-bijector-kcgn8d

Conversation

@christiaanjs

Copy link
Copy Markdown
Owner

Summary

Implements a tree normalizing flow — a flexible, tree-structured variational approximation for node heights in phylogenetic inference. The flow puts a learnable distribution on the unconstrained per-node coordinates that sit underneath treeflow's existing node-height ratio transform, enabling richer posterior approximations while preserving all tree constraints.

Key Changes

Core Flow Implementation

  • treeflow/bijectors/tree_normalizing_flow.py: Main bijector implementing a "traversal sandwich" architecture:

    • Postorder (tip-to-root) affine map reading children's outputs
    • Learnable elementwise nonlinearity (pluggable: spline, sinh_arcsinh, or affine-only)
    • Preorder (root-to-tip) affine map reading parent's output
    • Supports conditioning on auxiliary variables (clock rate, population size, etc.) and per-node parameters
    • Initializes as the identity, so it can be dropped into existing approximations without changing them
  • treeflow/traversal/tree_affine.py: Reference implementations of the two structured affine maps with forward/inverse operations and log-det-Jacobian computation

  • treeflow/bijectors/elementwise_node_flow.py: Nonlinear layer with three pluggable options (monotone rational-quadratic spline, sinh_arcsinh, or affine-only), each analytically invertible

  • treeflow/bijectors/tree_affine_bijector.py: TFP bijector wrappers around the affine maps for composition with other bijectors

Variational Approximation

  • treeflow/model/approximation/tree_flow.py: Variational approximation combining:
    • Parameter block (mean-field, full-rank, or inverse autoregressive flow)
    • Tree block (tree normalizing flow conditioned on parameters)
    • Block-triangular Jacobian for exact, cheap log-density

Native Acceleration

  • treeflow/acceleration/native/cc/tree_affine_op.cc: C++ custom ops for forward sweeps of both affine maps with analytic reverse-mode gradients
  • treeflow/acceleration/native/tree_affine.py: Python wrapper for native ops with fallback to pure TensorFlow
  • Updated build system to compile the new ops

Testing & Experiments

  • test/bijectors/test_tree_normalizing_flow.py: Tests for identity initialization, invertibility, dependence structure, and conditioning
  • test/bijectors/test_elementwise_node_flow.py: Tests for nonlinearity layers
  • test/traversal/test_tree_affine.py: Tests for affine map correctness against NumPy reference
  • test/acceleration/native/test_native_tree_affine.py: Tests for native ops against TensorFlow reference and finite differences
  • test/model/approximation/test_tree_flow.py: Integration tests for the full approximation
  • experiments/tree_normalizing_flow.ipynb: Comprehensive notebook comparing the flow with existing root full-rank approximation on two studies (heights-only and full model)

Notable Implementation Details

  • Traversal sandwich design: Up-then-down order (postorder then preorder) couples every pair of nodes in a single layer via belief propagation's collect-then-distribute schedule
  • Triangular structure: Both affine maps are triangular in their own traversal order, making log-det-Jacobian computation O(n) and inverses pure gathers
  • Bounded recursion weights: Parent weights use tanh (|w| < 1) and child weights are normalized to prevent numerical issues on deep trees
  • Zero-parameter initialization: All base parameters initialized to give scale=1, shift=0, zero recursion weights, and identity nonlinearity — the flow starts as exact identity
  • Conditional flow: Tree distribution can depend on other model parameters through auxiliary conditioning, enabling joint inference
  • Pluggable nonlinearity: Three options trade flexibility vs. parameter count; "affine" mode reduces to Gaussian with tree-structured covariance for

https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM

claude and others added 13 commits August 3, 2026 01:06
A normalising flow on the unconstrained per-internal-node coordinates that sit
underneath the node-height ratio transform, so a variational approximation can
put a flexible, tree-structured distribution on node heights while the existing
ratio machinery keeps enforcing the ordering and positivity constraints.

Each layer is a traversal sandwich: a postorder (tip-to-root) affine map, a
learnable elementwise nonlinearity, then a preorder (root-to-tip) affine map.
Up-then-down is the collect/distribute schedule of belief propagation, and it is
why a single layer couples every pair of nodes -- a node ends up depending on
the descendants of its ancestors, i.e. on everything. Down-then-up couples only
ancestor-descendant pairs until a second layer is stacked on; the order is
configurable and both patterns are checked by a test.

Invertibility is enforced by construction: every affine scale is a softplus
output, so each triangular map is non-singular; the nonlinearity is monotone and
analytically invertible; and the recursion weights are bounded (tanh, and
tanh/num_children) so a deep tree cannot amplify them. The log-det-Jacobian of
each affine map is sum_i log scale[i] -- no traversal -- and both inverses are a
single gather, so only the forward sweeps are actual traversals.

The nonlinearity is pluggable: a monotone rational-quadratic spline shared
across the tree with per-node affine conditioning (default), sinh-arcsinh
(unbounded, reshapes the tails), or none at all, which reduces the flow to a
Gaussian with tree-structured covariance in O(internal_node) parameters.

The flow is optionally conditional -- on auxiliary variables (clock rate,
population size, substitution parameters) and on per-node/per-branch variables --
through small conditioners whose output weights start at zero, so the whole flow
starts as the exact identity.

- treeflow/traversal/tree_affine.py: the affine maps on the existing preorder
  and postorder traversal primitives, plus their gather-based inverses.
- treeflow/acceleration/native/cc/tree_affine_op.cc: native C++ forward sweeps
  with analytic reverse-mode gradients, alongside the existing native ops.
- treeflow/bijectors/{tree_affine_bijector,elementwise_node_flow,
  tree_normalizing_flow}.py: the TFP bijectors.
- treeflow/model/approximation/tree_flow.py: a variational approximation
  coupling a mean-field/full-rank/IAF parameter block to the conditional tree
  flow, block-triangular so the density stays exact.
- experiments/tree_normalizing_flow.ipynb: comparison against the root
  full-rank approximation, with heights-only and full-model studies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
Executed experiments/tree_normalizing_flow.ipynb on the 71-taxon YFV dataset and
wrote up the findings. Budget-matched at 2500 steps per family, the flow reaches
63 nats more ELBO than the root full-rank approximation with the other
parameters fixed, and ~1195 nats more on the full model. Two results worth
noting: a free 70x70 covariance (whole-tree full rank) gains only 1.6 nats over
the root-only block, so the Gaussian families are limited by optimisation rather
than by covariance structure; and removing the flow's nonlinearity entirely still
recovers 62.9 of the 63.1 nats, so at this budget the tree-structured triangular
maps are doing the work and the nonlinearity is a refinement.

Adds experiments/tree_flow_boundary_mass.ipynb, which retains the comparison
behind a design question: should the nonlinearity sit after the constraining
sigmoid, so that the flow can represent near-polytomies (heavy mass at a height
ratio of 0 or 1)? Mass near a ratio boundary is mass in the tail of the
unconstrained coordinate, so what decides it is the tail class:

- the spline is the identity outside its window, so it reproduces the Gaussian
  coordinate's boundary decay exactly -- it contributes nothing there;
- a spline applied in constrained space instead is bi-Lipschitz on [0, 1], so it
  moves the constant (~40x more mass within 1e-2 of the boundary) but leaves the
  exponent alone, and it saturates numerically once 1-r falls below float64
  resolution;
- sinh-arcsinh does change the class: at tailweight 2 the local exponent is flat
  at ~1.15, i.e. genuine power-law mass against the boundary.

The notebook also fits each option to a target with boundary mass, which orders
them the same way. So the placement is not what limits boundary mass, and the
nonlinearity stays in unconstrained space alongside the affine tree maps (which
have to be there anyway, since an affine combination of ratios leaves (0, 1)).
This is documented in the elementwise_node_flow module docstring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
Moves the samplers out of treeflow.vi, which they were only ever housed in, into
a treeflow.mcmc package: hmc.py and random_walk.py over a shared util.py that
maps the model's constrained variables -- including the tree, through the
node-height ratio bijector -- to unconstrained space and back. treeflow.vi.hmc
was the one import path in use (the CLI and its tests) and is updated; hmc.py
otherwise keeps its behaviour, now built on the shared helper.

The new sampler is random-walk Metropolis-Hastings, intended as a reference
posterior for judging the variational approximations: it uses no gradients and
none of the flow machinery, so it is an independent check rather than a
variation on what is being measured. It runs the chain inside a tf.function with
the proposal scale as a tensor argument, so the whole chain compiles once and is
reused across adaptation chunks -- roughly a hundred times faster than the eager
loop, which matters when a usable reference needs tens of thousands of steps.

Supporting machinery, since TFP's step-size adaptation kernels do not apply to
RandomWalkMetropolis:

- Robbins-Monro tuning of log(scale) during burn-in towards the 0.234 target
  acceptance rate;
- per-coordinate preconditioning from the chain's own spread partway through
  burn-in (proposal only, so the sampled distribution is unchanged);
- thinning, so a long run's memory stays bounded;
- effective sample size per variable and, across chains, R-hat, with
  check_effective_sample_size turning them into a pass/fail. This is not
  decoration: a random walk over 70 correlated node heights reaches an effective
  sample size of order 10 per few thousand draws, so the check is what stops the
  reference being quoted as ground truth when it has not mixed.

The experiment notebook gains a reference-posterior section per study, and every
size in it -- optimisation steps, ELBO samples, chain length, burn-in, chains,
thinning -- now reads an environment variable defaulting to the notebook's own
value, so a browser run is unchanged. experiments/run_tree_flow_experiment.py
drives both experiment notebooks with those as command-line flags, streaming
progress bars live, in the style of examples/run_example.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
The root full-rank fit on the full model diverged on one run -- RobustOptimizer
skips non-finite gradient steps but gives up after 100 consecutive ones, which
aborted the whole notebook partway through study 2. The fit helper now retries
once at a fifth of the learning rate, as one would by hand, and otherwise
records the family as diverged rather than raising, so the rest of the
comparison still runs and the divergence is reported instead of being fatal.
Downstream cells iterate over the families that produced an approximation.

Also records the boundary-mass notebook's executed results and its conclusions:
against a target with real mass at the boundary the held-out KL orders the
options logit-normal 0.0328 > unconstrained spline 0.0157 > constrained-space
spline 0.0045 > sinh-arcsinh 0.0029 > sinh-arcsinh + spline 0.0006 nats. The
tail-exponent measurements explain the ordering -- only sinh-arcsinh changes the
rate at which boundary mass vanishes -- with the nuance that a spline acting in
constrained space does fit this target appreciably better than one confined to a
finite window, since none of the density is left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
Executed both experiment notebooks (71 taxa, 654 sites; ten variational fits and
two reference chains, a little over two hours on CPU) and wrote up the findings.

Heights only: every flow variant beats every Gaussian family by about 26 nats of
ELBO, and the node-height marginals are roughly twice as accurate in the mean and
3-8x more accurate in the spread against the random-walk reference -- the
Gaussian families put the root height at 596-656 with a standard deviation of
129-220, against the reference's 424 and 51, while the flows land within about 1%
of the mean. Two results worth noting beyond that: root full rank is *worse* than
mean field in this setting, exactly as its construction predicts once there are
no non-tree parameters to share its block with; and the affine-only ablation
already scores +26.1 of the flow's +26.4, so nearly all of the gain is the
tree-structured linear coupling rather than the nonlinearity.

Full model: the tree flow with an autoregressive flow on the parameters is 1,207
nats better than root full rank, of which about 540 is attributable to
conditioning the tree block on those parameters, and it is the only family that
reproduces the clock-rate x root-height dependence.

The reference chains are reported with their diagnostics rather than as ground
truth: minimum effective sample sizes of 40 (heights only, R-hat 1.16) and 10
(full model, R-hat 1.67) both fail the check, which is the expected behaviour of
a random walk in these dimensions. The differences discussed are far larger than
that uncertainty, but the fine ordering among flow variants is not something
these references can settle, and the findings say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
Replaces the 71-taxon YFV alignment with data simulated by
experiments/benchmarks/simulate.py -- the same simulator the benchmark pipeline
uses, which builds the alignment through get_sequence_distribution, the code path
that scores likelihoods. Twenty taxa by default, 500 sites, sampled serially over
a five-time-unit window so the clock stays identifiable, under known values.

The point is not only that it is faster. On the YFV data the random-walk
reference reached a minimum effective sample size of 40 with R-hat 1.16 and
failed its check, so the accuracy comparison rested on a reference that was
itself flagged as untrustworthy. On the simulated data the same chain reaches a
minimum effective sample size of 2153 with R-hat 1.00 (5122 at twelve taxa), and
passes. Twenty taxa is the compromise: nineteen node heights is enough tree
structure for the comparison to mean something while the reference still mixes
comfortably.

Simulating also supplies a second yardstick that owes nothing to the reference
chain: the values that generated the data. The accuracy tables now report error
against the truth alongside error against the reference, plus 95% credible
interval coverage of the true node heights, and the marginal plots mark the true
values.

The dataset's size, sampling window and seed are environment variables like every
other size in the notebook, exposed as --taxa, --sites, --sampling-window and
--data-seed on the runner, so the previous large-dataset regime is one flag away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
… bars

Raises the optimisation budget to the 60,000 steps at a 1e-3 learning rate these
models are known to need, and adds the RelativeLossNotDecreasing criterion that
advi_iaf.ipynb uses (rtol 1e-6, 1000-step window, 5000-step minimum, three
consecutive) so that budget is a ceiling rather than a target: each fit stops
when its own ELBO stops moving, and reports the steps it took and whether it
converged or ran out of ceiling. On the simulated dataset a fit runs at 24-39
steps per second, so the ceiling is 25-42 minutes per family.

The progress bars needed throttling to match. At 2,500 steps they already wrote
2,525 carriage-return refreshes into the executed notebook -- 141 kB of stream
output that buries the results it is meant to accompany, and twenty times worse
at the new budget. They now refresh every thirty seconds (configurable) with a
compact format, and the optimisation bar updates every hundred steps rather than
every ten, which brings a fit's recorded progress down to a few dozen lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
…rly exit

The smoke run exposed the problem: with a 5,000-step floor and a 1e-6 tolerance
every fit stopped at 5,001 steps, its own minimum, which defeats the point of
raising the budget. Tightens the tolerance to 1e-7 and raises the floor to
20,000 steps, so a fit runs between 20,000 and 60,000 steps and stops early only
when its ELBO has genuinely flattened. Both are exposed as flags on the runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
RelativeLossNotDecreasing fired within ~50 steps of whatever floor it was given
-- at 5,000 and at 20,000 steps, at tolerances of 1e-6 and 1e-7 -- so every
family "converged" at exactly the floor. A criterion that reports convergence
wherever its floor is put is measuring the floor, so early stopping is now off
by default and the fits run the full 60,000 steps; it can be switched back on
with TREEFLOW_TREE_FLOW_CONVERGENCE_MIN_STEPS.

Also adds the reference chain to the clock-rate x root-height correlation table,
so that diagnostic is adjudicated against the sampler rather than left as a
comparison between approximations with no yardstick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
…tion

`linear_cross_coupling=True` adds a dense linear term carrying the parameter
block into every tree coordinate, `y_tree += W @ y_parameters`. The flow's
existing auxiliary conditioning models that dependence by modulating the flow's
per-node parameters through a small tanh network -- flexible, but nonlinear and
indirect. This instead adds plain linear correlation between every parameter and
every node height, which is what a joint Gaussian would have, and what
root_full_rank provides for the root height alone.

Combined with `parameter_approximation="full_rank"`, `nonlinearity="affine"` and
no auxiliary conditioner it makes the whole approximation a structured joint
Gaussian: full covariance among the non-tree parameters, tree-structured
covariance among the node heights, and dense cross-covariance between them --
linear correlation everywhere, in parameter^2 + parameter*node + O(node)
parameters rather than the (parameter + node)^2 of a full covariance. The term
is a shift by a function of the parameter block only, so the Jacobian stays
block-triangular and the density stays exact, and it starts at zero so the
identity initialisation is preserved.

The notebook gains that hybrid as a study 2 family, along with a variant keeping
the cross term alongside the flow's nonlinearity, and a diagnostic reporting the
reference posterior's correlation in both height space and the unconstrained
coordinates the approximations actually work in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
Looks at the dependence the tree flow is built to capture directly in the
likelihood rather than through a fitted posterior: fix every parameter at its
maximum (treeflow's ML routine maximises the unnormalised posterior, so strictly
a MAP), then vary pairs of node heights on a grid and plot the surface, for
several sequence lengths.

Every surface is drawn twice: over the node heights, where combinations that are
not trees at all are masked and the shape of that region is part of the geometry;
and over the unconstrained coordinates the approximations actually parameterise,
where every point is a valid tree and a diagonal ridge is dependence a
mean-field family would miss. Grid ranges come from each node's own feasible
interval -- bounded by its tallest child and its parent -- rather than a fixed
percentage, so a node wedged between a close child and a close parent still gets
a usable window. Each surface is summarised by the correlation of the Gaussian
matching its curvature, so the pictures have a number attached.

Pairs include a parent and child, the root with its child, the root with a deep
node, and a genuinely non-adjacent pair (neither an ancestor of the other).

The last section switches to a relaxed clock, where a branch length is a rate
times a time: it plots node height against the rate on the node's own branch and
against a distant branch, and reports the posterior correlations between all node
heights and all branch rates from the random-walk reference sampler, split by
whether the branch is one of the node's own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
Replaces the write-up, which still described the earlier 71-taxon dataset, with
the results of the 60,000-step run on the 20-taxon simulation.

Heights only: the flow leads by +0.95 nats over root full rank against Monte
Carlo standard errors of 0.03-0.07 -- solid but small. The interesting part is
where it comes from. The affine-only ablation takes +0.39 of that, so the
nonlinearity supplies the larger share, which is the reverse of the 71-taxon run
where affine-only took +26.1 of +26.4. The correlation diagnostic explains it:
the ratio transform leaves mean |correlation| of 0.026 in the unconstrained
coordinates against 0.101 in height space, so there is little linear structure
left to model, and the whole-tree full-rank Gaussian gains only +0.30 with 190
covariance parameters where the structured affine gains +0.39 with O(node).

Full model: the conditional flow leads by +0.88, more than half of which is the
auxiliary conditioning rather than flexibility within the tree. The new linear
cross-coupling reaches +0.23 as a purely Gaussian family and recovers most of
the clock-root ridge, but adds nothing on top of the conditional flow (+0.83
against +0.88) -- an alternative route to the coupling, not a complement.

Both sections record what the numbers do not settle: node-height means are
slightly worse under the flow while standard deviations are twice as good, the
full-model reference did not mix on the tree block, and the cross-size comparison
is uncontrolled.

Also fixes the geometry notebook's memory use, which is what killed the previous
run: it evaluated a whole 900-point grid at once, materialising a 13 GB partials
tensor. Grids are now evaluated in chunks sized from the alignment length
against a fixed budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jnv3damzcA9TVa4n6ziwLM
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