DNA and FASTA¶
Read FASTA, pick the right alphabet, profile complexity, and build an alignment-free distance matrix.
Nucleotide sequence is the least fussy input this library takes. There is no symbolisation step and no thresholding decision: DNA is already a string over a four-letter alphabet. Everything that goes wrong in genomics goes wrong at the edges — letter case, ambiguity codes, format auto-detection, and sequence length.
Two functions carry this page. lz.h turns one sequence into an entropy density. lz.nid turns two
sequences into a distance. The lzdistance binary computes the whole matrix over a directory.
Reading FASTA¶
From the shell¶
Both binaries read FASTA, and both gunzip transparently. The reader trims whitespace from each line
and concatenates what is left, so line wrapping is invisible; > and ; lines both start a new
record and their text is discarded.
$ lzcomplexity -n -F fasta genomes/strain_a.fasta
$ cat genomes/strain_a.lz76.json
{"filename":"genomes/strain_a.fasta","format":"","sequences":[{"alphabet":[84,71,67,65],
"alphabet_size":4,"lz76Complexity":1211,"lz76EntropyDensity":0.9813477980453618,
"lz76RandomShuffleComplexity":{"max_block_size":-1,"multi_information":0.0,"value":0.0},
"size":8000}],"size":1}
The JSON is emitted on a single line with no trailing newline; it is wrapped here to fit.
lzcomplexity replaces the input extension, so strain_a.fasta becomes strain_a.lz76.json
next to the input, not in the working directory. Under -n the shuffle stage is skipped and
reported as {-1, 0.0, 0.0} rather than omitted.
Three flags matter for FASTA:
| Flag | Effect |
|---|---|
-F fasta |
Forces the FASTA reader. .fasta, .fna and .gz already route there by extension, so this only matters for other filenames. -F dna and -F rna reach the same reader — there is no U↔T handling and no alphabet validation. |
-m |
One sequence per record. Without it, only the first record is read. |
-n |
Entropy density only; skips the block-shuffle stage. |
Gzip needs no extra flag and gives identical numbers:
$ lzcomplexity -n gz/strain_a.fna.gz
$ python3 -c "import json;d=json.load(open('gz/strain_a.fna.lz76.json'));print(d['sequences'][0])"
{'alphabet': [84, 71, 67, 65], 'alphabet_size': 4, 'lz76Complexity': 1211,
'lz76EntropyDensity': 0.9813477980453618,
'lz76RandomShuffleComplexity': {'max_block_size': -1, 'multi_information': 0.0, 'value': 0.0},
'size': 8000}
Decompression keys off the filename, never the contents
A gzipped FASTA whose name does not end in .gz fails with
stream did not contain valid UTF-8, and a plain-text file named .gz fails with
invalid gzip header. The reader checks the path extension and nothing else. Rename the file,
or decompress it first.
-n is 14× faster on a whole genome
Measured on a 2 Mbp single-record FASTA, 16 cores: 4.8 s for the default run and
0.33 s with -n. The block-shuffle stage that -n skips costs one extra factorization per
block scale and dominates the runtime. Every profiling and screening workflow on this page wants
h and nothing else, so pass -n.
From Python¶
The Python API has no FASTA reader; it takes strings. Twelve lines cover the format including gzip, and let you normalise case at read time — which, as the next section shows, is the decision that matters most.
import gzip
import lzcomplexity as lz
def read_fasta(path):
"""Yield (header, sequence) pairs. Handles .gz. Upper-cases as it goes."""
op = gzip.open if path.endswith(".gz") else open
with op(path, "rt") as fh:
name, chunks = None, []
for line in fh:
line = line.strip()
if line.startswith((">", ";")):
if name is not None:
yield name, "".join(chunks)
name, chunks = line[1:], []
elif line:
chunks.append(line.upper())
if name is not None:
yield name, "".join(chunks)
for name, seq in read_fasta("genomes/strain_a.fasta"):
c, factors = lz.factorization(seq)
print(name, len(seq), c, round(lz.h(seq), 6), round(lz.h(seq, log_base=2), 6))
Biopython's SeqIO.parse works equally well; pass str(record.seq).upper(). See
Input types for the full conversion rules — in particular, a list[int] is
not what you want for DNA.
The factor count is one short of the textbook count, deliberately
complexity counts only complete LZ76 components. The trailing component that runs past
the end of the sequence is not counted, so the library's number is one less than the textbook
exhaustive-history count whenever a sequence ends mid-component. The conversion is exact:
For strain_a above, c = 1211 and factors[-1] = 8001 > 8000, so the textbook count is
1212. This shifts h by O(1/n) and nid by at most one factor quantum; it changes no
ordering. LZ76 factorization has the derivation.
The alphabet question¶
h is a normalised quantity, and the normalisation depends on the alphabet size:
h = c(S) · logb n ⁄ n
- c(S)
- number of complete LZ76 factors
- n
- sequence length in bytes
- b
- logarithm base — the
log_baseargument, defaulting to the auto-detected alphabet size k
For clean DNA k = 4 and the default is what you want. Every letter that is not A, C, G or T raises k.
The alphabet is detected as the number of distinct bytes. a and A are different bytes. N
is a fifth symbol. A soft-masked, ambiguity-coded FASTA straight off a genome browser therefore has
k = 8 or more, and the default normalisation quietly rescales every number you compute from it.
What soft-masking actually costs¶
A 4600 bp construct: random background with a 300 bp element inserted twice, at 500 and at 2500 — the classic repeat that LZ76 exists to find. The masked version is the same string with the second copy lower-cased, exactly as RepeatMasker would leave it.
| Input | k |
c(S) |
lz.h(seq) |
error vs truth |
|---|---|---|---|---|
| upper-case (ground truth) | 4 | 704 | 0.931072 | — |
| soft-masked, default | 8 | 777 | 0.685079 | 26% low |
soft-masked, log_base=4 |
8 | 777 | 1.027618 | 10% high |
soft-masked, .upper() first |
4 | 704 | 0.931072 | none |
Two independent failures are stacked here, and they are worth separating:
- The normalisation breaks.
kjumps from 4 to 8,log₈ nis two-thirds oflog₄ n, andhfalls by a third for no biological reason. - The parse breaks.
c(S)rises from 704 to 777. The lower-cased copy of the repeat shares no symbol with the upper-case copy, so LZ76 cannot match it and re-parses all 300 bp from scratch — 73 extra factors, which is what 300 bp of unseen DNA costs. The repeat becomes invisible.
log_base=4 fixes the first failure only. That is why the third row is still 10% off.
Upper-case before you measure, or the numbers describe your masker
Soft-masking changes both the alphabet size and the factorization, and nothing in the library
warns you. log_base=4 corrects the units but not the parse. The only correct fix is to
normalise case in the reader:
Strip N rather than keeping it: 40 scattered Ns in the same 4600 bp construct push k to 5
and h from 0.931072 to 0.820209 — a 12% shift caused by 0.9% of the sequence. Deleting
them gives 0.935601, within finite-size noise of the truth. If your N runs are long (assembly
gaps, telomeres), split the record on them and analyse the segments separately rather than
splicing across a gap you have no evidence for.
Pass log_base=4 explicitly when you have already cleaned the alphabet and want the quaternary
normalisation guaranteed regardless of what the sequence happens to contain. log_base=2 gives
bits per base and is the more portable choice for a paper: lz.h(seq, log_base=2) returned
1.962696 for strain_a, exactly twice the default 0.981348, because log₂ 4 = 2.
Alphabets and log bases covers the general case.
h above 1 is normal, not a bug
The bound c(S) < n / log n is asymptotic, so at finite n the ratio overshoots and h
exceeds 1 on high-entropy input. In the sliding-window profile below, two windows of clean
random DNA read 1.0016 and 1.0115. Do not clip the value, and do not treat 1 as a ceiling when
you set an axis or a threshold. Sequence length and convergence
quantifies the bias.
Profiling: repeats, coding, non-coding¶
LZ76 entered genomics because its factor count measures internal repetition directly: a substring
the parser has already seen costs one factor no matter how long it is. A sliding window turns that
into a track you can plot against genomic coordinate; the Complexity web server (Orlov and
Potapov, 2004) uses a 1000 bp default window, which is a reasonable starting point.
A tandem repeat is a tenfold drop in a 1 kb window
6000 bp of random background with a 50 bp unit repeated 30 times over [2000, 3500).
Windows of 1000 bp, step 500.
import random
import lzcomplexity as lz
rng = random.Random(7)
bg = "".join(rng.choice("ACGT") for _ in range(6000))
unit = "".join(rng.choice("ACGT") for _ in range(50))
seq = bg[:2000] + unit * 30 + bg[3500:] # 6000 bp, repeat over [2000, 3500)
for start in range(0, len(seq) - 999, 500):
w = seq[start:start + 1000]
c, _ = lz.factorization(w)
print(f"{start:5d}-{start + 1000:5d} c={c:4d} h={lz.h(w):.4f}")
0- 1000 c= 198 h=0.9866
500- 1500 c= 198 h=0.9866
1000- 2000 c= 194 h=0.9667
1500- 2500 c= 119 h=0.5930
2000- 3000 c= 18 h=0.0897
2500- 3500 c= 18 h=0.0897
3000- 4000 c= 120 h=0.5979
3500- 4500 c= 201 h=1.0016
4000- 5000 c= 203 h=1.0115
4500- 5500 c= 196 h=0.9766
5000- 6000 c= 195 h=0.9717
The two windows wholly inside the repeat drop to c = 18 and h = 0.0897, more than a tenfold
fall from the background. The half-overlapping windows sit near 0.59. Window boundaries smear the
edges by half a window: with step 500 the repeat's extent is bracketed to ±500 bp and no better.
The seed is fixed so the run is reproducible; a different seed moves each c by a few counts
and leaves the shape unchanged.
The literature's headline result is that exons are on average more complex than introns and
regulatory regions. That is a population tendency, not a classifier — the distributions overlap
heavily, and a single window's h will not tell you whether you are inside coding sequence. Treat
it as a feature or a screen, never as an annotation.
What the measure is reliable for is exactly what the profile above shows: locating low-complexity and repetitive tracts. That is a detection problem with a large effect size, rather than a classification problem with a small one.
Sequence distance for phylogeny¶
lz.nid(x, y) is the LZ76 information distance:
d*(X,Y) = max{ C(XY) − C(X), C(YX) − C(Y) } ⁄ max{ C(X), C(Y) }
- C(·)
- LZ76 factor count — a count of phrases, not a compressed bit length
- XY
- the two sequences concatenated, with no separator symbol
This is the d* of Otu & Sayood, Bioinformatics 19(16), 2122–2130 (2003) — evaluated with this library's complete-factor convention rather than their c(S), so the values shift by O(1/C).
The reading is Cilibrasi and Vitányi's: C(XY) − C(X) is the cost of describing Y given the
dictionary already extracted from X. Four factorizations per pair, no alignment, no gene models,
no tunable parameters. Normalized information distance derives it from
Kolmogorov complexity and audits how far it falls short of being a metric.
In Python¶
Four 8000 bp sequences: strain_a, strain_b (1% of sites mutated), strain_c (10% mutated), and
an independent outgroup.
import lzcomplexity as lz
names = ["outgroup", "strain_a", "strain_b", "strain_c"]
seqs = [dict(read_fasta(f"genomes/{n}.fasta"))[n] for n in names]
for i, n in enumerate(names):
print(f"{n:9s}", [round(lz.nid(seqs[i], seqs[j]), 4) for j in range(4)])
outgroup [0.0008, 0.8557, 0.8492, 0.8567]
strain_a [0.8557, 0.0008, 0.0379, 0.4758]
strain_b [0.8492, 0.0379, 0.0, 0.5029]
strain_c [0.8567, 0.4758, 0.5029, 0.0008]
1% divergence reads 0.0379, 10% reads 0.4758, and an unrelated sequence reads 0.855. The measure is strongly non-linear in sequence divergence and saturates well before the sequences are unrelated, so use it as a ranking rather than as a rate.
Note the diagonal: nid(x, x) is 0.0008, not 0, and for strain_b it lands on exactly 0.0.
Neither is a rounding artefact.
Distance 0 does not mean identical, and values do not transfer between lengths
nid fails the identity axiom at finite n: d(X, X) = (C(XX) − C(X)) / C(X), which is
typically 1/C(X) and occasionally exactly 0. Both the floor and the ceiling move with
sequence length:
| Length | typical self-distance | d for an unrelated pair |
|---|---|---|
| 500 bp | 0.0089 | ≈ 0.80 |
| 1 kbp | 0.0051 | ≈ 0.82 |
| 10 kbp | 0.0007 | ≈ 0.86 |
| 100 kbp | 0.00008 | ≈ 0.89 |
A distance computed on 500 bp windows and a distance computed on 50 kbp contigs are different
quantities. Keep every sequence in one matrix inside a narrow length band, or report the length
distribution alongside the matrix. The ceiling tracks (log₂ n − 1) / (log₂ n + 1) and is
independent of alphabet size, so 0.86 means "as different as two independent random sequences of
this length", not "86% of the way to maximally different".
Minimum useful length. The self-distance is your noise floor. To resolve differences of size δ
you need C(X) ≳ 1/δ — for DNA, roughly 500 bp for δ = 0.01 and 6 kbp for δ = 0.001. Below
C(X) ≈ 30, about 100 bp of DNA, the measure is quantised more coarsely than the differences you
are trying to see and should be read qualitatively at most.
From the shell, over a whole directory¶
lzdistance takes a directory and emits the full matrix: one sequence per file, files in sorted
path order, .json and .log skipped, sub-directories ignored, and only the first record of each
file used.
outgroup strain_a strain_b strain_c
outgroup 0.0008 0.8557 0.8492 0.8567
strain_a 0.8557 0.0008 0.0379 0.4758
strain_b 0.8492 0.0379 0.0000 0.5029
strain_c 0.8567 0.4758 0.5029 0.0008
Identical to the Python matrix, to the last bit. The JSON holds two matrices:
information_distance is the one above, and shuffle_information_distance is a different statistic
that is asymmetric and can exceed 1 — do not feed it to a tree builder. directed_matrix is an
empty list unless you pass -g with a non-zero threshold; bare -g (threshold 0) computes the
matrix and then drops the key from the JSON entirely. The lzdistance
reference covers the rest of the surface.
Every distance changes if you omit -I fasta on a directory
A directory source disables extension-based format detection, so .fasta files fall through to
magic-byte detection — and a file whose first byte is > is classified as raw binary. The
same 8000-base record is then read as 65 144 bits over a 2-letter alphabet. The run succeeds,
prints no warning, and produces a plausible-looking matrix with different numbers throughout:
the outgroup/strain_a cell moves from 0.8557 to 0.8372. Always pass -I fasta for
directories. A directory of .fasta.gz files read with -I fasta gives a matrix identical to
the uncompressed one.
Output paths follow lzdistance's own rule — the suffix is appended, so genomes yields
genomes.lzdist.json. A trailing slash (genomes/) writes the hidden file genomes/.lzdist.json
inside the directory. Pass -o and the question does not arise.
What -a does, exactly¶
-a / --adn is the DNA strategy. It builds six variants of the second operand, and the matrix
cell is the minimum distance over all six. The first operand is never transformed.
| # | Variant | Definition |
|---|---|---|
| 1 | b |
unchanged |
| 2 | reverse(b) |
sequence reversed |
| 3 | at(b) |
A↔T swapped, and those two bases lower-cased; C and G untouched |
| 4 | reverse(at(b)) |
variant 3, reversed |
| 5 | cg(b) |
C↔G swapped, and those two bases lower-cased; A and T untouched |
| 6 | reverse(cg(b)) |
variant 5, reversed |
Measured — one upper-case query record against six targets, 3000 bp each:
| Target record | default | -a |
|---|---|---|
| identical to the query | 0.0020 | 0.0020 |
| reversed | 0.8379 | 0.0020 |
A↔T swapped and lower-cased (the form at emits) |
0.9882 | 0.9882 |
C↔G swapped and lower-cased (the form cg emits) |
0.9882 | 0.9882 |
| reverse complement | 0.8457 | 0.8422 |
| unrelated | 0.8314 | 0.8314 |
Plain upper-case swaps behave no better: an upper-case A↔T-swapped target reads 0.8501 by default
and 0.8438 under -a, an upper-case C↔G-swapped target 0.8442 and 0.8223.
Read that table carefully, because the flag does considerably less than its name suggests.
-a does not find reverse complements
The reverse complement of the query stays at 0.8422 under -a — further away than the
unrelated sequence at 0.8314, and nowhere near the 0.0020 a match scores. The two base swaps
are never composed, so a full complement is not among the six variants; only reverse is, and
reversal alone does not make a strand match.
The swaps also lower-case their output, so variants 3–6 can only match a first operand that is
already in that half-swapped lower-case form. Verified: a query record written as
A↔T-swapped-and-lower-cased sits at 0.9882 by default and collapses to 0.0020 under -a.
With ordinary upper-case FASTA on both sides, -a reduces to
min(d(a, b), d(a, reverse(b))).
If you need strand independence, reverse-complement the sequences yourself before calling
lz.nid, and take the minimum over the two orientations in your own code.
One further consequence of the minimum: taking a min over variants breaks the triangle inequality,
so -a output is a dissimilarity, not a metric. Cells that have nothing to do with strand symmetry
can also shrink, because the minimum has six chances to go low — the upper-case C↔G row above falls
from 0.8442 to 0.8223 for no biological reason. The unrelated cell happened not to move here, so
the effect is a bias you should expect rather than one you can predict per cell.
Getting to a tree¶
information_distance is already a symmetric square matrix with a near-zero diagonal, which is what
every clustering routine wants. Convert to a condensed vector and hand it to a method that is
sensitive to small differences:
import json
import numpy as np
from scipy.cluster.hierarchy import linkage
from scipy.spatial.distance import squareform
with open("genomes.lzdist.json") as fh:
D = np.array(json.load(fh)["information_distance"])
D = (D + D.T) / 2 # symmetric by construction; this only kills float asymmetry
np.fill_diagonal(D, 0.0) # the self-distance floor is not biological signal
condensed = squareform(D) # default checks=True: validates symmetry and zero diagonal
Z = linkage(condensed, method="average") # UPGMA
print(np.round(condensed, 4))
print(np.round(Z, 4))
[0.8557 0.8492 0.8567 0.0379 0.4758 0.5029]
[[1. 2. 0.0379 2. ]
[3. 4. 0.4894 3. ]
[0. 5. 0.8539 4. ]]
The condensed vector is the upper triangle in row order: (outgroup, strain_a),
(outgroup, strain_b), (outgroup, strain_c), (strain_a, strain_b), (strain_a, strain_c),
(strain_b, strain_c). The linkage joins strain_a with strain_b first, then strain_c, then
the outgroup — the correct topology.
Zeroing the diagonal is the one liberty you have to take, and you should: the diagonal is
(C(XX) − C(X)) / C(X), a finite-size artefact, and squareform raises
Distance matrix 'X' diagonal must be zero if you leave it in. Passing checks=False silences
that error but does not fix the matrix — it only skips the validation.
For real phylogeny prefer neighbour-joining over UPGMA — UPGMA assumes a molecular clock that LZ distances certainly do not obey. Whatever you use, pick a method that exploits small differences: compression distances concentrate in a narrow band near their ceiling, and minimum spanning trees or single-linkage discard most of the signal.
What the benchmarks say
Otu and Sayood (2003) recovered mammalian phylogenies from complete mitochondrial genomes
without alignment. The independent benchmark — Höhl, Rigoutsos and Ragan (2007) — is less
flattering and worth knowing about: on 700 synthetic trees d_LZ ranked 15th and 16th of 19
methods, and on the empirical BAliBASE data 10th and 15th of 22. That paper's own conclusion is
that most alignment-free methods are statistically indistinguishable from one another, and that
on ordinary collinear sequences the alignment-based estimates beat every alignment-free method
that is not pattern-based. Two caveats in the other direction: the benchmark ran on protein
sequences throughout, and on their shuffled-domain data the ordering reverses — d_LZ there
reconstructed more trees correctly than three of the four alignment-based pipelines.
Use LZ distance for what it is good at: fast, automatic, coarse grouping over sequences too long or too divergent to align. Do not use it to resolve a contested node.
Cost of an all-pairs matrix¶
Each information_distance cell costs four LZ76 factorizations over strings up to |X| + |Y| long,
and C(X) is recomputed for every pair — the single-sequence counts are not cached. On top of that,
lzdistance always fills the shuffle_information_distance matrix as well, and there is no flag to
turn it off: the wall times below cover both matrices. An all-pairs lz.nid loop in Python over the
same 8 genomes takes 1.3 s against lzdistance's 2.2 s, and that gap is what the second matrix
costs you.
Genomes m |
Length each | Wall time, 16 cores |
|---|---|---|
| 8 | 50 kbp | 2.2 s |
| 16 | 50 kbp | 9.9 s |
| 32 | 50 kbp | 39.0 s |
Best of three runs on one 16-core box; individual runs on the same input varied by up to 30%, so
read these as a shape, not as a spec. The shape is the O(m²) one — 4.5× and 3.9× per doubling of
m, with the small-m row noisiest because there are fewer matrix rows than cores. Extrapolating,
128 genomes of 50 kbp is roughly 10 minutes and 1000 would be about 11 hours. Sequence length enters
on top of that, roughly linearly per pair.
Three things to do before starting a large run:
- Trim to a common length band. It removes the length confound and bounds the cost.
- Size the thread pool with
-jif you share the machine. It changes wall time only, never the numbers. - Split the job.
lzdistance A_dir B_dircomputes a rectangular cross matrix, so a large all-pairs run can be tiled into blocks and reassembled.
Comparing many sequences covers the batch workflow, the line-range gates and the reassembly in detail.
Checklist¶
Before you measure¶
Upper-case every sequence. Strip or split on N. Confirm alphabet_size is 4 in the JSON before
trusting a single number.
seq.upper().replace("N", "")
Reading FASTA¶
-I fasta is mandatory for lzdistance on a directory, where extension detection is switched off.
lzcomplexity needs -F fasta only for filenames that are not .fasta, .fna or .gz.
lzdistance -I fasta genomes
Distances¶
Keep lengths in one band, zero the diagonal, cluster with a sensitive method. Never compare d
across length scales.
lz.nid(x, y)
Strand symmetry¶
-a gives you reversal, not reverse complement. Build the complement yourself and take the minimum
over orientations.
min(lz.nid(x, y), lz.nid(x, rc(y)))
Full citations for Otu & Sayood, Orlov & Potapov and Höhl et al. are in References. For the interpretation rules that apply to every measure on this page, read Reading the numbers.