pythonforbio.
[WASM idle]
Applied Workflows12/13

Applied - Sequence Composition Analysis

Starting sandbox…
Beginnerlesson

Applied - Sequence Composition Analysis

In one line: GC content, k-mers, codon usage and sliding windows — where Biopython's objects meet NumPy's speed.

Uses: Seq · Bio.SeqUtils · ndarray · Vectorization · Memory Layout and Strides · Broadcasting

Encoding sequence as an array

The move that unlocks everything else:

import numpy as np
from Bio import SeqIO

BASES = np.array(list("ACGT"))

def encode(seq):
    """DNA string → int8 codes; N and other letters → 4."""
    arr = np.frombuffer(str(seq).upper().encode(), dtype=np.uint8)
    lut = np.full(256, 4, dtype=np.int8)
    for i, b in enumerate(b"ACGT"):
        lut[b] = i
    return lut[arr]

codes = encode(record.seq)              # (L,) of 0..4

np.frombuffer on the encoded bytes plus a 256-entry lookup table converts a 250 Mb chromosome in well under a second. A Python loop over the same string takes minutes. See Vectorization.

onehot = (codes[:, None] == np.arange(4)[None, :]).astype(np.int8)   # (L, 4)

That is Broadcasting: (L,1) against (1,4) gives (L,4).

GC content

from Bio.SeqUtils import gc_fraction

gc_fraction(record.seq)                        # 0.0–1.0 — a FRACTION
gc_fraction(record.seq, ambiguous="ignore")    # exclude N from the denominator

Remember SeqUtils.GC() was removed in 1.82 and returned a percentage. A naive swap introduces a 100× error. See Biopython Deprecations.

Vectorised, over an array:

is_gc = (codes == 1) | (codes == 2)            # C or G
is_n = codes == 4
gc = is_gc.sum() / (~is_n).sum()               # exclude N

Sliding windows — free, via strides

from numpy.lib.stride_tricks import sliding_window_view

W = 1000
gc_windows = sliding_window_view(is_gc, W).mean(axis=1)
n_windows = sliding_window_view(is_n, W).mean(axis=1)
gc_windows[n_windows > 0.5] = np.nan           # mask N-rich windows

fig, ax = plt.subplots(figsize=(11, 3))
ax.plot(np.arange(len(gc_windows)) + W // 2, gc_windows, lw=0.5)
ax.axhline(np.nanmean(gc_windows), ls="--", c="crimson", lw=0.8)
ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{v/1e6:.0f}"))
ax.set(xlabel="Position (Mb)", ylabel="GC fraction")

sliding_window_view builds the (L-W+1, W) window matrix by stride manipulation — it allocates nothing. The memory cost appears only at the reduction. See Memory Layout and Strides.

Masking N-rich windows matters: a centromeric gap of Ns otherwise reads as GC = 0, producing a dramatic and meaningless dip.

GC skew

from Bio.SeqUtils import gc_skew

is_g = codes == 2
is_c = codes == 1
W = 10_000
g = sliding_window_view(is_g, W).sum(axis=1)[::W]      # non-overlapping
c = sliding_window_view(is_c, W).sum(axis=1)[::W]
skew = (g - c) / np.maximum(g + c, 1)
cum = np.cumsum(skew)

origin = int(np.argmin(cum) * W)
terminus = int(np.argmax(cum) * W)

In bacteria, cumulative GC skew has a minimum at the replication origin and a maximum at the terminus — leading and lagging strands have asymmetric mutational biases. A clean V-shape in a bacterial genome is a good sign; a noisy one suggests a misassembly.

k-mer counting

def kmer_counts(codes, k=6):
    valid = codes < 4
    windows = sliding_window_view(codes, k)
    ok = sliding_window_view(valid, k).all(axis=1)      # skip windows with N
    powers = 4 ** np.arange(k - 1, -1, -1)
    idx = (windows[ok] * powers).sum(axis=1)             # base-4 encoding
    return np.bincount(idx, minlength=4**k)

counts = kmer_counts(codes, k=6)                         # 4096 entries

def kmer_name(i, k):
    return "".join("ACGT"[(i >> (2*(k-1-j))) & 3] for j in range(k))

top = np.argsort(counts)[::-1][:20]
for i in top:
    print(kmer_name(i, 6), counts[i])

Encoding each k-mer as a base-4 integer turns counting into a single np.bincount. For k=6 that is 4,096 bins; for k=12 it is 16 million (still fine); beyond k≈14 you need a hash-based counter (jellyfish, KMC).

k-mer spectra are how you estimate genome size and heterozygosity from raw reads before assembling (GenomeScope), and how you detect contamination.

Codon usage

from Bio import SeqIO
from collections import Counter
import pandas as pd

def codon_table(cds_records):
    c = Counter()
    for rec in cds_records:
        s = str(rec.seq).upper()
        s = s[:len(s) - len(s) % 3]
        c.update(s[i:i+3] for i in range(0, len(s), 3))
    return pd.Series(c).sort_values(ascending=False)

from Bio.Data import CodonTable
std = CodonTable.unambiguous_dna_by_id[1]
aa_of = {**std.forward_table, **{s: "*" for s in std.stop_codons}}

df = codon_table(cds).rename("count").to_frame()
df["aa"] = df.index.map(aa_of)
df["rscu"] = df.groupby("aa")["count"].transform(
    lambda s: s / s.mean()                      # relative synonymous codon usage
)

fig, ax = plt.subplots(figsize=(11, 3.5))
sns.barplot(data=df.reset_index().sort_values(["aa", "index"]),
            x="index", y="rscu", hue="aa", dodge=False, ax=ax, legend=False)
ax.tick_params(axis="x", rotation=90, labelsize=5)
ax.axhline(1, ls="--", c="grey", lw=0.8)

RSCU (relative synonymous codon usage) normalises within each amino acid, so RSCU = 1 means "used exactly as often as its synonyms". Values far from 1 indicate codon bias, which correlates with expression level and tRNA abundance.

The groupby().transform() is the per-group normalisation pattern from groupby — normalising within amino acid without a merge.

GC3 and codon bias

from Bio.SeqUtils import GC123

stats = pd.DataFrame([
    {"gene": r.id, "gc": GC123(r.seq)[0]/100, "gc1": GC123(r.seq)[1]/100,
     "gc2": GC123(r.seq)[2]/100, "gc3": GC123(r.seq)[3]/100, "length": len(r)}
    for r in SeqIO.parse("cds.fasta", "fasta")
])

sns.scatterplot(data=stats, x="gc3", y="gc", alpha=0.3)

GC3 — GC at the third codon position — is the standard codon-bias measure, because third positions are largely synonymous and therefore reflect mutational and translational pressure rather than protein constraint. A "GC3 plot" (GC3 against overall GC) is a classic comparative-genomics figure.

Sequence complexity

def shannon_entropy(codes, k=1):
    valid = codes[codes < 4]
    if k == 1:
        p = np.bincount(valid, minlength=4) / len(valid)
    else:
        p = kmer_counts(codes, k); p = p / p.sum()
    p = p[p > 0]
    return -(p * np.log2(p)).sum()

def dust_score(codes, window=64):
    """Low-complexity detection, roughly like the DUST algorithm."""
    tri = sliding_window_view(codes, 3)
    ok = (tri < 4).all(axis=1)
    idx = (tri[ok] * np.array([16, 4, 1])).sum(axis=1)
    counts = np.bincount(idx, minlength=64)
    return (counts * (counts - 1)).sum() / (2 * max(len(idx) - 1, 1))

Low-complexity regions (homopolymers, simple repeats) produce spurious alignments and inflated motif scores. Most search tools mask them by default; if you write your own scan, you need to.

CpG islands

def cpg_stats(codes, window=200, step=1):
    is_c, is_g = codes == 1, codes == 2
    cg = np.zeros(len(codes), dtype=bool)
    cg[:-1] = is_c[:-1] & is_g[1:]                     # CpG dinucleotide

    W = window
    c_n = sliding_window_view(is_c, W).sum(axis=1)
    g_n = sliding_window_view(is_g, W).sum(axis=1)
    cg_n = sliding_window_view(cg, W).sum(axis=1)

    gc_frac = (c_n + g_n) / W
    expected = np.maximum(c_n * g_n / W, 1e-9)
    obs_exp = cg_n / expected
    return gc_frac, obs_exp

gc_frac, obs_exp = cpg_stats(codes)
island = (gc_frac > 0.55) & (obs_exp > 0.65)           # Takai & Jones criteria

The observed/expected CpG ratio is the informative quantity: vertebrate genomes are globally CpG-depleted (methylated CpG deaminates to TpG), so an undepleted region marks an unmethylated CpG island, which usually marks a promoter.

Bringing it into pandas

summary = pd.DataFrame([
    {"id": r.id, "length": len(r),
     "gc": gc_fraction(r.seq),
     "n_frac": str(r.seq).upper().count("N") / len(r),
     "entropy": shannon_entropy(encode(r.seq)),
     "gc3": GC123(r.seq)[3] / 100 if len(r) % 3 == 0 else np.nan}
    for r in SeqIO.parse("sequences.fasta", "fasta")
])

sns.pairplot(summary[["length", "gc", "entropy", "n_frac"]],
             diag_kind="kde", corner=True, plot_kws=dict(s=8, alpha=0.4))

Common mistakes

  • SeqUtils.GC() — removed; 100× different from gc_fraction.
  • Not upper-casing soft-masked sequence.
  • Counting N in the GC denominator without deciding to.
  • Windows spanning assembly gaps, producing meaningless dips.
  • Python loops over long sequences. Encode to an array first.
  • as_strided instead of sliding_window_view. The former reads past the buffer.
  • Not filtering low-complexity regions before motif or alignment work.
  • Comparing GC between species without controlling for gene set.
  • Codon usage on sequences whose length is not a multiple of 3.

See also

Seq · Bio.SeqUtils · Vectorization · Memory Layout and Strides · Bio.motifs · Applied - FASTA and FASTQ Workflows · Broadcasting

scratch

No output yet — run the code to populate this drawer.