Tutorial

SplitAligner, end to end

Turn a species tree and a set of gene trees into a branch-coordinate matrix in which every branch either carries a value or says exactly why it cannot. This walkthrough uses the bundled toy example, so you can reproduce every output on your own machine.

SplitAligner is a small, dependency-light Perl program. There is nothing to compile: clone the repository and run SplitAligner.pl. The commands below mirror the steps in examples/run.sh.
Why

Why branch identity is a problem

Most comparative-genomics questions are answered by comparing a number attached to a branch — a rate, a residual, a path length — across many genes. Did this lineage speed up? Did constraint relax here? The comparison only means something if that branch denotes the same evolutionary object in every gene that contributes to it.

In practice it often does not. Genes differ in which taxa they recover, and gene trees do not always agree with the species tree. When a taxon is missing, two adjacent branches of the reference tree can collapse into one. The merged branch has a perfectly correct length — the sum of its parts — but it is no longer either of the branches that formed it.

A pipeline that files that number under one of them produces a value that is numerically right and biologically misassigned. Nothing crashes, no NA appears, and no check that only verifies arithmetic will catch it. The practical consequence is that a gene can appear to evolve unusually fast or slow on a lineage for reasons that have nothing to do with evolution on that lineage — and without a record of which cells are affected, there is no way to tell those cases from real ones.

SplitAligner's response is to make branch identity explicit, and to give every way a branch can fail to be observable its own name. The animated explainer makes the same argument visually; the rest of this page is how to run it.

Idea

A branch is the split it makes

SplitAligner identifies a branch not by its drawn position or by node order, but by the bipartition (split) it induces on the taxa. For a gene g that samples only some taxa Tg, each species-tree branch b is projected onto the taxa actually present:

σg(b) = ( Ab ∩ Tg | Bb ∩ Tg )

On the topology-congruent species-tree restriction, two non-deleted primitive branches map to the same reduced-tree edge exactly when their projected splits coincide. They do not become the same original branch: they become indistinguishable for that gene's taxon set, which is a fusion. If a projected side is empty, the branch instead has a structural absence. For an empirical free-topology gene tree, SplitAligner separately asks whether each independently observable projected split is recovered.

SplitAligner's algorithm is representation-independent. Because identity lives in the split, how a tree is written does not matter: every edge of a gene tree is reduced to a canonical unrooted split before any comparison. A gene tree may therefore be rooted or unrooted, binary or multifurcating, and written in any child order; rooted input is simply treated as unrooted: the two half-edges at a degree-2 root become one split; their lengths are summed when both are finite numeric values, and otherwise the split carries no numeric length. Its topology may be constrained to the species tree (fix) or completely free. Two practical requirements remain: the taxa of each gene tree must be a subset of the species-tree taxa, with names matching exactly, and the gene trees should carry branch lengths, since those are the values that get filed into the matrix. This is verified by a serialization- and input-variant regression battery on the bundled example: equivalent inputs give identical matrices, byte for byte when the species tree is unchanged and cell for cell after mapping the B aliases when it is re-serialized.

# Split-based branch-mapping algorithm

# Stage 1 — coverage level: uses only S and T_g, never the gene tree
for each gene g:
    T_g ← taxa of the gene tree (a subset of S)               # Step 1
    for each species-tree branch b:                           # Steps 2–3
        σ_g(b) ← canon(A_b ∩ T_g | B_b ∩ T_g)                 # projection
        if one side of σ_g(b) is empty:
            state(g,b) ← NA_struct                            # no projected identity
        else if b is internal and one side of σ_g(b) is a single taxon x:
            endpoint-collapsed: σ_g(b) coincides with the terminal branch of x,
            so b is kept for fusion bookkeeping, never as an independent branch
    group the retained branches by σ_g(b)                     # Step 4
        a group with ≥ 2 members is a fusion group: its members are
        indistinguishable on T_g and share one composite coordinate b|b'
    every retained (g,b) now has a settled identity; its value is still unknown

# Stage 2 — gene-tree level: each retained projected split consults the gene tree once
    Σ(G_g) ← canonical unrooted splits of G_g                 # Step 5
    for each retained projected split σ and its group:
        if σ ∉ Σ(G_g):            generic NA for every member   # cause not yet named
        else if the group has ≥ 2 members:
                                NA_fuse for every member      # the length is filed on b|b'
        else:                   Mapped — branch length recorded

# Stage 3 — finalize: paired fixed/free evidence names the remaining NAs
    NA_fuse stands only if the composite coordinate b|b' carries a numeric length
    a free-side generic NA becomes NA_topo only when the fixed-side primitive
    coordinate is Mapped with a numeric length; otherwise it stays residual NA

The workflow has two modes: matrix builds the coordinate system and projects every gene onto it; finalize then classifies the resulting missingness where the paired evidence permits.

1

Install

You need Perl 5 (already present on macOS and most Linux systems). SplitAligner uses only core modules — no CPAN packages to install.

# clone the repository
git clone https://github.com/wujiaqi06/SplitAligner
cd SplitAligner

# check it runs (prints the usage banner)
perl SplitAligner.pl

That is the entire installation. The main controller is SplitAligner.pl; the per-step helpers live in scripts/ and are called for you.

2

Prepare your inputs

Species tree — one Newick tree. Branch lengths and support values are allowed and simply ignored during split-based mapping.

((A:0.1,B:0.2):0.2,(C:0.1,D:0.1):0.1):0.1;

Gene trees — Newick, one record per line, each line starting with a gene identifier immediately followed by its tree. Taxon names must match the species tree.

GeneA((A:0.1,B:0.2):0.2,(C:0.1,D:0.1):0.1):0.1;
GeneB((A:0.2,C:0.1):0.1,(B:0.1,D:0.2):0.1):0.1;

SplitAligner distinguishes two kinds of gene-tree input, and you typically supply both:

  • free — each gene tree inferred independently (its topology may disagree with the species tree);
  • fix — each gene tree inferred under the species-tree topology (branch lengths only).

Comparing the two is what later separates coverage-driven absence from discordance-driven absence.

3

Quick start: the toy example

The repository ships a small example under examples/302mammal/ (a subset of free and fixed gene trees) for a fast end-to-end smoke test. From the repository root:

bash examples/run.sh toy

This runs the three program calls below in order. The rest of the tutorial unpacks what each one does and produces.

4

matrix mode — build the coordinate system

Run matrix once per gene-tree set. It labels the species-tree branches, projects every gene onto them, and writes a gene × branch matrix.

# from the repository root
cd examples/302mammal

perl ../../SplitAligner.pl --mode matrix \
  --species input/speciesTree302.nwk \
  --gene input/free_tree.examples.nwk \
  --label free

perl ../../SplitAligner.pl --mode matrix \
  --species input/speciesTree302.nwk \
  --gene input/fix_tree.examples.nwk \
  --label fix

Key outputs (prefixed by --label):

  • species_tree.splits.txt — the canonical branch coordinate system (B1, B2, … on the species-tree backbone). This is the shared axis every gene is measured against.
  • <label>.matrix_no_fuse.txt — primitive branches only.
  • <label>.matrix_with_fuse.txt — the same, plus explicit fused-branch columns such as B12|B47 for branches that merge under taxon pruning.
  • <label>_splits/ and <label>_split_branch_label/ — per-gene split representations and their mapping onto the backbone.

A cell holds a numeric branch length where the branch's projected split was recovered, or NA where it was not — yet. Naming those NAs is the next step.

gene   B1           B2   B3           B4           ...
A1BG   0.1809315089 NA   0.0047384154 0.0057110032 ...
A1CF   0.0376651404 0.0583312140 0.0032591533 ...
free.matrix_with_fuse.txt (truncated)

The display root is not the coordinate. SplitAligner's biological coordinate is the canonical unrooted split, not the displayed root position, internal-node number, traversal position, or child order. If two Newick representations encode the same weighted unrooted split object, changing only the display root or serialization does not change the underlying biological split identity.

The human-readable B aliases are serialization-local and may change between independently serialized species trees. When comparing independent representations, the canonical split key — not the B number alone — is the authoritative identity.

Rooting can still carry biological meaning in analyses that require direction or ancestry; it simply does not define SplitAligner's branch-coordinate identity.

Representation A · internal-edge root AB CD Representation B · branch-A root AB CD same canonical split key A,B | C,D B aliases may differ
5

finalize mode — name every absence

Give finalize both matrices. It first identifies primitive NAs represented by a numeric fused coordinate (NA_fuse), then compares the free and fixed matrices on shared genes to separate structural absence (NA_struct) from topology-induced absence (NA_topo). If fixed-side numeric evidence is unavailable, a residual generic NA is retained. Passing --species_tree also computes branch-wise support.

perl ../../SplitAligner.pl --mode finalize \
  --free free.matrix_with_fuse.txt \
  --fix  fix.matrix_with_fuse.txt \
  --final_label final \
  --species_tree species_tree.forSplit.nwk

Outputs:

  • final.free.na_classified.txt / final.fix.na_classified.txt — classified matrices, with residual generic NA retained when the fixed side lacks numeric evidence;
  • <input>.na_fuse.txt — the intermediate fusion-rescue tables;
  • final.support_b.txt and species_tree.support_b.nwk — branch-wise support (see step 7).

The comparison is defined only for genes present in both inputs; if there are no shared genes, SplitAligner stops with an error rather than guessing.

gene   B1           B2         B3           ...
A1BG   0.1809315089 NA_struct  0.0047384154 ...
final.free.na_classified.txt — the generic NA at B2 is now named
6

Reading the states

Every gene × branch cell ends up as exactly one of the values below. A residual generic NA is an intentional bookkeeping state: it is not promoted to NA_topo without fixed-side numeric evidence.

Cell valueMeaning
numericMapped. The branch's projected split was observed in this gene; the value is its branch length.
NA_fuseThe primitive branch is not independently observable after taxon pruning; a fused coordinate may carry a numeric edge length, but that value is not an independent estimate for the primitive branch.
NA_structA projected side is empty, so the branch has no projected identity for this gene and is not evaluable (a coverage effect).
NA_topoThe branch has numeric primitive evidence on the fixed side, but its projected split is not recovered in the inferred free-topology tree — consistent with topology-induced discordance.
NAResidual: retained when there is no fixed-side numeric evidence to decide otherwise.

The categories partition the axis — they add up to the whole ledger:

|C| = Mapped + NA_fuse + NA_struct + NA_topo + residual NA

Once NA is split into why, downstream rate or selection analyses can handle, filter, and audit the states explicitly instead of treating every absence as the same opaque value.

7

Branch-wise support

When you pass --species_tree, finalize writes final.support_b.txt: for each backbone branch, how much numeric evidence it retains in the free trees relative to the fixed trees, on shared genes.

branch_id  branch_type  n_shared_genes  n_fix_non_na  n_free_non_na  support_percent  discordance_percent
B1         terminal     5               5             5              100.0000000000   0.0000000000
B2         terminal     5               4             4              100.0000000000   0.0000000000
B3         terminal     5               4             4              100.0000000000   0.0000000000
final.support_b.txt (head)

The companion species_tree.support_b.nwk is a standard Newick tree with each internal branch's Support written in the bootstrap position, so you can open it in any tree viewer (e.g. FigTree) and see concordance mapped straight onto the topology.

8

Scaling up

The same two modes run at full scale. The bundled preprint example uses 2,275 free-topology and 2,275 fixed-topology gene trees across 302 mammals:

bash examples/run.sh preprint
9

Explore Catnip10

Let taxon pruning act alone, with no gene-tree discordance. The full B1–B17 axis stays frozen while each deletion updates the graph-determined ledger: observed, NA_fuse, or NA_struct. In this experiment NA_topo is always zero.