Tutorial: drawing styles beyond the plain tree
Four figure types that show up constantly in the literature but need more than
a bare phylogram. All four are in examples/tree_styles_demo.py.
Collapsed clades
Compress a big tree down to the clades you actually want to discuss. Each collapsed clade becomes a triangle whose two sides reach its nearest and farthest hidden leaf, so the wedge shows how deep and how ragged the hidden group is — the convention iTOL uses.
node = tree.get_mrca(cyanobacteria)
pt.collapse_clade(tree, node, name="Cyanobacteria (37)")
(pt.TreeFigure(tree)
.collapsed_clades()
.tip_labels()
).save("collapsed.pdf")
collapse_clade() modifies the tree in place — the clade's children are
dropped and a summary (n, near, far, leaves) is written to
node.data["_collapsed"]. Work on a copy to keep the original:
display = pt.Tree.from_newick(tree.write())
Check monophyly first
get_mrca(taxa) returns the smallest clade containing those taxa, which
may contain others too. Collapsing it then silently swallows taxa that do
not belong to the group you named. Verify before collapsing:
node = tree.get_mrca(taxa)
if set(node.leaf_names()) == set(taxa):
pt.collapse_clade(tree, node, name="…")
else:
print("not monophyletic; also contains",
sorted(set(node.leaf_names()) - set(taxa)))
The demo does exactly this, and on the bundled 16S tree it correctly refuses to collapse Euryarchaeota (Saccharolobus, a Thermoproteota, falls inside it).
Options: color= (literal or a data column), scale_height=True to let the
triangle's width grow with the number of hidden tips, edgecolor=. Works on
circular layouts too.
Node interval bars (95% HPD)
The standard annotation on a dated Bayesian tree: a bar across each node
spanning its age credible interval, as FigTree's "node bars" and ggtree's
geom_range draw it.
Read a BEAST/MrBayes summary tree with fmt="beast" and it works directly —
that reader keeps the per-node estimates, and its {lower, upper} intervals
land on the keys node_bars() reads by default:
tree = pt.Tree.read("beast_summary.tre", fmt="beast")
tree.root.data["posterior"] # 0.97
tree.root.data["height_95_lower"] # 7.0
(pt.TreeFigure(tree)
.node_bars() # picks up height_95_lower/_upper
.time_axis(geo=True)
.tip_labels()
).save("dated.pdf")
A plain fmt="nexus" read keeps only the topology — the annotations are
dropped — so the bars would have nothing to draw. Other keys work too:
.node_bars(lower="length_95_lower", upper="length_95_upper")
Values are read as ages on the same scale as time_axis() — distance back
from present, increasing toward the root — so a node's bar lines up with the
time axis underneath it. Pass as_age=False if your two keys already hold plot
x coordinates. Rectangular layouts only (the bar runs along the time axis).
Connections between tips
Curved links between arbitrary pairs — horizontal gene transfer, gene sharing,
co-occurrence, host-symbiont pairings. This is iTOL's DATASET_CONNECTION.
pairs = [("Escherichia_coli", "Salmonella_enterica", 0.82), ...]
(pt.TreeFigure(tree, layout="circular")
.connections(pairs, color="value") # or a literal colour
.tip_labels()
).save("hgt.pdf")
pairs is an iterable of (name1, name2) or (name1, name2, value), or a
DataFrame with those columns. On a circular layout the curves bend toward
the centre (iTOL's CENTER_CURVES), which is what keeps a dense set readable;
on a rectangular one they bow out past the tips so they clear the tree.
curvature=0 gives straight lines. Unknown names raise rather than being
silently dropped.
DensiTree
A whole set of trees drawn on top of each other, so topological uncertainty is visible instead of hidden behind one summary tree — a posterior sample, a bootstrap set, or a collection of gene trees.
pt.DensiTreeFigure(trees).titled("500 posterior trees").save("cloud.pdf")
Shared structure accumulates into dark, confident edges; conflicting placements stay faint and fan out. Opacity defaults to a value that scales with the size of the set.
The trees only line up if their tips share an order, so each is first rotated
to match a reference (consensus=, default the first tree) via
untangle — rotation reorders children without touching
topology or branch lengths, so aligning the cloud never changes what any tree
says. Pass align=False to skip it.
Grey the default state, colour the exceptions
When one level of a category covers most of the tree — GTDB_reference,
wild_type, present — giving it a saturated colour spends most of the
figure's ink on its least informative part and buries the handful of tips that
actually carry the finding. baseline= renders those levels neutral grey, and
they stop consuming palette slots so the remaining levels keep the strongest
hues:
context = ["GTDB reference", "Near-known target", "Intermediate",
"Deep or mixed", "Unresolved"]
(pt.TreeFigure(tree, layout="circular")
.branches(color="#707070", size=0.4) # neutral baseline for the tree
.ring(meta, columns=["Phylogenetic context"],
width=0.07, # a narrow band, not a heavy hoop
baseline="GTDB reference", # the expected state recedes
order=context, # known -> unresolved, not A-Z
leaders=True)
).save("context.pdf")
order= matters more than it looks: categorical levels are otherwise sorted
alphabetically, which almost never matches a meaningful progression.
Legend keys follow the mark they stand for — filled layers (rings, heatmaps) get square swatches, marker layers get dots.
Prefer clade annotation over a per-tip ring
If a variable is essentially clade-structured (a phylum that forms one monophyletic block), a per-tip ring repeats the same colour across hundreds of sectors to convey one fact. A shaded sector plus an arc label says it once:
for name, taxa in phyla.items():
node = tree.get_mrca(taxa)
if set(node.leaf_names()) == set(taxa): # verify monophyly first
fig.highlight(node=node, fill=colour[name], alpha=0.10)
fig.clade_label(name, node=node)
That frees a whole ring, and the tree usually gets much cleaner. Where the group is not monophyletic a ring is the honest choice — a shaded sector would imply a clade that the tree does not support.
Scale bar
A compact branch-length scale, ggtree's geom_treescale:
pt.TreeFigure(tree).tip_labels().scale_bar() # auto round length
pt.TreeFigure(tree).scale_bar(length=0.05, label="0.05 subs/site")
Unlike time_axis() this assumes nothing about branch lengths being time and
works on any layout, which is what a plain substitutions/site phylogram needs.