genomeblocks.backends

Which library does the work. genomeblocks keeps its data as columnar tables; the heavy lifting on those tables runs through a backend, one per family of operations. The registry (this module) resolves names, checks installs and scopes choices; one submodule per family wraps the engines behind a small interface that returns plain numpy, so a caller never sees which engine ran. See Backends for the user-level overview and Benchmarks for the timings.

import os, numpy as np
import genomeblocks as gb
from genomeblocks import backends                        # the module; gb.backends() lists the engines
from genomeblocks.backends import families, installed, resolve, use_backend, unsupported, AUTO
from genomeblocks.backends import intervals, bigwig, motifs, fasta, tables, graph
Family What it does Default (automatic) Others on request
intervals overlap, nearest, merge, point lookups genomeblocks (numpy) cgranges, ncls, bioframe, pyranges, bedtools
bigwig reading bigWig signal pybigtools pybigwig, python
motifs scoring motif matrices along sequences moods, else lightmotif lightmotif, biopython
fasta fetching sequence genomeblocks (indexed reader) pysam, pyfaidx, memory, biopython
tables parsing text tables (BED, GTF, pairs) polars, else pandas pandas
graph graph algorithms on an Architecture graph-tool, else scipy igraph, networkx, scipy

Every default installs with pip install genomeblocks except a motif engine: MOODS ships with the conda package (bioconda moods); on pip it is the genomeblocks[motifs] extra (MOODS-python, compiled at install) or genomeblocks[lightmotif] (prebuilt wheels), and a bare pip install has no motif engine — scan_* then raise ImportError naming both extras. An engine is picked automatically only where every candidate gives the same answer (AUTO below). Anything else is a request: backend= on the call, or with gb.use_backend(...) for a stretch of code. A requested backend that is not installed raises ImportError with the install command — there is no silent switch.

Table of contents

  1. The registry
  2. backends.intervals
  3. backends.bigwig
  4. backends.motifs
    1. Library
    2. load_motifs
    3. logodds_matrix and threshold_from_pvalue
    4. Block
  5. backends.fasta
  6. backends.tables
  7. backends.graph

The registry

Name Signature One line
families families() -> dict {family: [backend, ...]}, default first.
installed installed(family, backend) -> bool Whether that engine can be imported here (and, for bedtools, whether the binary is on PATH). Unknown names raise ValueError.
resolve resolve(family, backend=None) -> str The backend a call uses: backend if given, else the one chosen by an enclosing use_backend, else the automatic default. Unknown → ValueError; known but missing → ImportError with the install command.
use_backend use_backend(**choices) (context manager) with use_backend(intervals="pyranges", graph="networkx"): — names are checked on entry, so a missing backend fails before any work; None or "auto" restores the default.
backends backends() -> DataFrame Every family and backend: installed, default, in use, install hint. Also gb.backends() — the subpackage is callable.
unsupported unsupported(family, backend, op, supported) -> NotImplementedError The error a family module raises when an engine lacks an operation.
AUTO dict What "auto" may pick, in order: only engines with identical answers.

Aliases are accepted anywhere a backend name is: numpy → genomeblocks, graph_tool / gt → graph-tool, nx → networkx, pybedtools → bedtools, pyBigWig → pybigwig, Bio → biopython, MOODS → moods, dict → memory.

The outputs below come from a pip environment with the interop extra and none of the conda-only engines (cgranges, bedtools, graph-tool); where those are installed, installed and resolve say so.

gb.backends()[["family", "backend", "installed", "default", "in use"]].head(8)
# ->       family       backend  installed  default  in use
#    0  intervals  genomeblocks       True     True    True
#    1  intervals      cgranges      False    False   False
#    2  intervals          ncls       True    False   False
#    3  intervals      bioframe       True    False   False
#    4  intervals      pyranges       True    False   False
#    5  intervals      bedtools      False    False   False
#    6     bigwig    pybigtools       True     True    True
#    7     bigwig      pybigwig       True    False   False
families()
# -> {'intervals': ['genomeblocks', 'cgranges', 'ncls', 'bioframe', 'pyranges', 'bedtools'], 'bigwig': ['pybigtools', 'pybigwig', 'python'],
#     'motifs': ['moods', 'lightmotif', 'biopython'], 'fasta': ['genomeblocks', 'pysam', 'pyfaidx', 'memory', 'biopython'],
#     'tables': ['polars', 'pandas'], 'graph': ['graph-tool', 'scipy', 'igraph', 'networkx']}
AUTO
# -> {'intervals': ['genomeblocks'], 'bigwig': ['pybigtools', 'python'], 'motifs': ['moods', 'lightmotif'],
#     'fasta': ['genomeblocks'], 'tables': ['polars', 'pandas'], 'graph': ['graph-tool', 'scipy']}
installed("intervals", "bioframe"), installed("intervals", "cgranges"), installed("graph", "graph-tool"), installed("intervals", "numpy")
# -> (True, False, False, True)
resolve("intervals"), resolve("graph"), resolve("tables"), resolve("bigwig"), resolve("graph", "nx")
# -> ('genomeblocks', 'scipy', 'polars', 'pybigtools', 'networkx')
resolve("intervals", "nope")
# -> ValueError: unknown intervals backend 'nope'; choose from: genomeblocks, cgranges, ncls, bioframe, pyranges, bedtools
resolve("nope")
# -> ValueError: unknown backend family 'nope'; families: intervals, bigwig, motifs, fasta, tables, graph
resolve("intervals", "cgranges")
# -> ImportError: the 'cgranges' intervals backend is not installed: conda install -c bioconda cgranges  (or pip install git+https://github.com/lh3/cgranges)
resolve("graph", "graph-tool")
# -> ImportError: the 'graph-tool' graph backend is not installed: conda install -c conda-forge graph-tool
with use_backend(intervals="bioframe", graph="networkx"):
    print(resolve("intervals"), resolve("graph"))
    with use_backend(intervals="auto"):
        print(resolve("intervals"))
# -> bioframe networkx
#    genomeblocks
resolve("intervals")
# -> 'genomeblocks'
with use_backend(intervals="cgranges"):                   # checked on entry
    pass
# -> ImportError: the 'cgranges' intervals backend is not installed: ...
unsupported("intervals", "ncls", "nearest", ("genomeblocks", "bioframe"))
# -> NotImplementedError("the 'ncls' intervals backend has no nearest; use one of: genomeblocks, bioframe")

The examples below use cre = gb.Loci.make("peaks.bed") (seven rows on chr1 / chr2), genome.fa, signal.bw, a two-motif JASPAR file (M1 = ACGT, M2 = GGAA) and loops.bedpe.


backends.intervals

Overlap, nearest, merge and point lookups. Every function takes columnar Loci that share one Genome and returns row numbers, normalised to genomeblocks’ rules whatever the engine: intervals are half-open (s1 < e2 and s2 < e1); overlap_pairs is sorted by (query row, reference row); nearest gives 0 to an overlap, else the gap in bases (book-ended = 0), -1 when the chromosome has nothing; merge fuses overlapping and book-ended intervals. Zero-length intervals are points between two bases — [p, p) overlaps [s, e) exactly when s < p < e — and every engine’s pairs are filtered with that rule. The chromosome code is handed to the engines as the chromosome label on both sides, so no decoding happens.

Function Signature Returns
overlap_pairs overlap_pairs(q, r, *, backend=None) (query rows, reference rows) of every overlapping pair.
overlap_any overlap_any(q, r, *, backend=None) Boolean mask over the query rows.
nearest nearest(q, r, *, backend=None) (reference row, distance) per query row. Ties among overlapping intervals follow the genomeblocks rule (lowest start, then lowest row) on every engine.
merge merge(L, *, backend=None) (codes, starts, ends, first) of the merged blocks; first is the row of the first interval (lowest start) of each block.
point_rows point_rows(L, code, start, end, *, backend=None) Rows of L overlapping one window (sorted); the lookup index is cached on L per backend.
  genomeblocks cgranges ncls bioframe pyranges bedtools
overlap yes yes yes yes yes yes
nearest yes – – yes yes yes
merge yes – – yes yes yes
point lookup yes yes yes yes yes yes

These are what Loci.overlap_pairs, overlap_any, nearest, merge, overlap_rows and the set operators call; at the Loci level the other side is first brought onto the same Genome (Loci._check).

cre = gb.Loci.make("peaks.bed")
other = cre._check(gb.as_loci([("chr1", 1000, 1050), ("chr2", 0, 10_000)]))     # same Genome
intervals.overlap_pairs(cre, other), intervals.overlap_pairs(cre, other, backend="ncls")
# -> ((array([0, 5, 6]), array([0, 1, 1])), (array([0, 5, 6]), array([0, 1, 1])))
intervals.overlap_any(cre, other)
# -> array([ True, False, False, False, False,  True,  True])
intervals.nearest(cre, other)
# -> (array([0, 0, 0, 0, 0, 1, 1]), array([   0,  850, 3850, 8900, 9850,    0,    0]))
intervals.nearest(cre, other, backend="pyranges")
# -> (array([0, 0, 0, 0, 0, 1, 1]), array([   0,  850, 3850, 8900, 9850,    0,    0]))
intervals.nearest(cre, other, backend="ncls")
# -> NotImplementedError: the 'ncls' intervals backend has no nearest; use one of: genomeblocks, bioframe, pyranges, bedtools
m = gb.as_loci([("chr1", 10, 20), ("chr1", 20, 30), ("chr1", 40, 50), ("chr2", 0, 5)])
intervals.merge(m)
# -> (array([0, 0, 1], dtype=int32), array([10, 40,  0]), array([30, 50,  5]), array([0, 2, 3]))
intervals.merge(m, backend="bioframe")
# -> (array([0, 0, 1]), array([10, 40,  0]), array([30, 50,  5]), array([0, 2, 3]))
intervals.point_rows(cre, cre.genome["chr1"], 1000, 2000), intervals.point_rows(cre, cre.genome["chr1"], 1000, 2000, backend="ncls")
# -> (array([0, 1]), array([0, 1]))

bedtools widens empty intervals on its side, so it may miss pairs that need the widened base; every other engine agrees exactly after the half-open filter. The test suite checks each engine against the default.


backends.bigwig

One handle interface over pybigtools (Rust, the default), pyBigWig (C, libBigWig) and a pure-Python reader (numpy + mmap, no compiled dependency). Bin b of a window of n bases is [floor(n*b/n_bins), floor(n*(b+1)/n_bins)) for every engine, bins outside the chromosome are missing, and windows that leave the chromosome (or name one the file lacks) are clipped and padded the same way, so the three give the same numbers.

Name Signature One line
open_bigwig open_bigwig(src, *, backend=None) -> handle A uniform handle for a bigWig path or an already-open pyBigWig / pybigtools / BigWigReader handle (used as is, wrapped in its own backend). Any object with a stats_array method is accepted unchanged.
handle_backend handle_backend(src) -> str | None The backend an open handle belongs to (None for a path).
PyBigToolsHandle, PyBigWigHandle, PythonHandle classes The three handles; .backend names the engine.

The handle interface:

Method Returns
h.chroms() {chrom: size} (cached).
h.stats_array(chrom, start, end, *, n_bins=1, stat="mean", exact=True, missing=0.0) float64 array of n_bins values; stat is mean, min, max, sum, std (population) or coverage (fraction of bases with data).
h.values(chrom, start, end) Per-base float64 values, NaN where there is no data (also outside the chromosome).
h.close() Closes the file when the handle opened it.

Loci.signal / signal.signal open every track through open_bigwig and pass backend=; passing backend= together with someone else’s open handle of another engine raises ValueError.

h = bigwig.open_bigwig("signal.bw")
type(h).__name__, h.backend, h.chroms()
# -> ('PyBigToolsHandle', 'pybigtools', {'chr1': 20000})
h.stats_array("chr1", 0, 1000, n_bins=5).round(3)
# -> array([5.08 , 1.259, 8.767, 7.013, 7.654])
h.stats_array("chr1", 0, 1000, n_bins=5, stat="coverage")        # 50 bp of data per 100 bp
# -> array([0.5, 0.5, 0.5, 0.5, 0.5])
h.values("chr1", 45, 55)                                         # data ends at base 50
# -> array([6.73265505, 6.73265505, 6.73265505, 6.73265505, 6.73265505, nan, nan, nan, nan, nan])
h.stats_array("chr9", 0, 100, n_bins=2, missing=np.nan)          # unknown chromosome
# -> array([nan, nan])
h.close()
for b in ("pybigwig", "python"):
    hb = bigwig.open_bigwig("signal.bw", backend=b)
    assert np.allclose(hb.stats_array("chr1", 0, 1000, n_bins=5), bigwig.open_bigwig("signal.bw").stats_array("chr1", 0, 1000, n_bins=5))
    hb.close()
import pyBigWig
raw = pyBigWig.open("signal.bw")
bigwig.handle_backend(raw), bigwig.handle_backend("signal.bw"), type(bigwig.open_bigwig(raw)).__name__
# -> ('pybigwig', None, 'PyBigWigHandle')
bigwig.open_bigwig(raw, backend="pybigtools")
# -> ValueError: the track is an open pybigwig handle, so backend='pybigtools' cannot apply: pass the file path instead, or drop backend=

backends.motifs

One motif library, three scanning engines. A Library holds motifs as (W x 4) count matrices (columns A C G T) whatever they were read from — JASPAR (raw or bracketed), TRANSFAC, UniPROBE or MEME files, parsed here in pure Python (.gz too); Biopython Bio.motifs objects; lightmotif motifs; plain arrays. Each motif is scored with one log-odds matrix, log2((count + 0.1) / (column total + 0.4) / 0.25), computed by logodds_matrix bit for bit the way lightmotif computes it (float32, its summation order, the platform log2f), and every engine scans that same matrix, so MOODS (C++, the default), lightmotif (SIMD, used automatically only when MOODS is absent) and Biopython (numpy) report the same hits. A hit is a window position whose score is at least the threshold; windows containing N never hit. threshold_from_pvalue turns a p-value into a per-motif score cutoff, the same whichever engine scans.

Name Signature One line
Library class Motifs as count matrices + names; a table of name, description, width, consensus.
load_motifs load_motifs(src, format="jaspar") -> Library A Library from a motif file or motif objects (also gb.load_motifs).
logodds_matrix logodds_matrix(counts, pseudocount=0.1) -> ndarray (W x 4) log2-odds against a uniform background, float64 holding float32-exact values.
threshold_from_pvalue threshold_from_pvalue(lo_matrix, pvalue, *, resolution=1e-3) -> float The smallest score with P(score >= s) <= pvalue under a uniform background.
write_meme write_meme(pfms, path, *, alphabet="ACGT", bg=(0.25, 0.25, 0.25, 0.25)) {name: (4 x W) probability matrix} as MEME minimal format.
Block class Windows laid end to end once, scanned per motif by one engine.
FORMATS ('jaspar', 'jaspar16', 'transfac', 'uniprobe', 'meme') Accepted format= values.
PSEUDOCOUNT 0.1 The default pseudocount.

Library

Library(names, counts, descriptions=None, *, pseudocount=0.1)
Name Returns One line
len(lib), lib.shape, lib.columns   ['name', 'description', 'width', 'consensus'].
lib.names, lib.descriptions, lib.counts lists Names, header descriptions (TF symbols in JASPAR), (W x 4) count matrices.
lib.widths int64 array Motif widths.
lib[i], lib['CTCF'] ndarray The count matrix by position or name (a unique substring match works too).
for name in lib   Iterates the names.
consensus(i) str Most frequent base per position.
logodds(i) (W x 4) Log2-odds matrix (computed once, cached).
pfm(i, pseudo=0.01) (4 x W) Probability matrix, rows A C G T — the archetype functions’ format.
select(motifs=None, match="substring") Library The motifs named; substring is case-insensitive and searches descriptions too, exact compares names.
indices(motifs=None, match="substring") list[int] Their positions.
take(idx) Library A subset by position.
to_pandas(), to_arrow(), head(), tail(), describe(), _repr_html_()   Like every table; the Arrow / dataframe / narwhals protocols read to_arrow().
to_biopython() list[Bio.motifs.Motif] Counts, matrix_id = name, name = description.
to_moods() list One 4 x W log-odds list of lists per motif (A C G T rows), what MOODS.scan.Scanner.set_motifs takes.
to_meme(path, pseudo=0.01), to_jaspar(path)   Write the library (MEME probabilities / bracketed JASPAR counts).
lib = gb.load_motifs("motifs.jaspar")
lib, lib.columns, lib.shape
# -> (Library(2 motifs: M1, M2), ['name', 'description', 'width', 'consensus'], (2, 4))
lib.to_pandas()
# ->   name description  width consensus
#    0   M1         TFA      4      ACGT
#    1   M2         TFB      4      GGAA
lib["M1"]
# -> array([[10.,  0.,  0.,  0.],
#           [ 0., 10.,  0.,  0.],
#           [ 0.,  0., 10.,  0.],
#           [ 0.,  0.,  0., 10.]])
lib[0].shape, lib.widths, lib.consensus(1), lib.pfm(0).shape
# -> ((4, 4), array([4, 4]), 'GGAA', (4, 4))
lib.logodds(0).round(2)
# -> array([[ 1.96, -4.7 , -4.7 , -4.7 ],
#           [-4.7 ,  1.96, -4.7 , -4.7 ],
#           [-4.7 , -4.7 ,  1.96, -4.7 ],
#           [-4.7 , -4.7 , -4.7 ,  1.96]])
lib.select("TF"), lib.select("M1", match="exact"), lib.indices("tfb"), lib.take([1])
# -> (Library(2 motifs: M1, M2), Library(1 motifs: M1), [1], Library(1 motifs: M2))
list(lib), lib.to_moods()[0][0][:2], lib.to_biopython()[0].matrix_id
# -> (['M1', 'M2'], [1.9577716588974, -4.700439929962158], 'M1')
lib.to_meme("lib.meme"); lib.to_jaspar("lib.jaspar")
gb.load_motifs("lib.meme", format="meme"), gb.load_motifs("lib.jaspar", format="jaspar16")
# -> (Library(2 motifs: M1, M2), Library(2 motifs: M1, M2))

load_motifs

load_motifs(src, format="jaspar") -> Library

src may be a path (format = 'jaspar' for raw four-row counts, 'jaspar16' for the bracketed layout, 'transfac', 'uniprobe' or 'meme'), a Library (returned as is), one or many Biopython Bio.motifs.Motif, lightmotif motifs, or a {name: matrix} dict (or a bare list) of counts / probabilities, 4 x W or W x 4, rows or columns in A C G T order. Probability matrices (rows summing to 1) are scaled to 100 sites; MEME motifs by their nsites (100 when absent). Names follow lightmotif’s conventions: the JASPAR id and the rest of the header line as description; TRANSFAC NA, else ID, else AC.

gb.load_motifs({"X": np.array([[10, 0, 0, 0], [0, 10, 0, 0]])}), gb.load_motifs([np.eye(4)])
# -> (Library(1 motifs: X), Library(1 motifs: motif1))
gb.load_motifs(lib.to_biopython()).names, gb.load_motifs(lib) is lib
# -> (['M1', 'M2'], True)
gb.load_motifs("motifs.jaspar", format="nope")
# -> ValueError: unknown motif format 'nope'; use one of jaspar, jaspar16, transfac, uniprobe, meme
motifs.write_meme({"CTCF": lib.pfm(0)}, "ctcf.meme")
open("ctcf.meme").read().count("MOTIF")
# -> 1

logodds_matrix and threshold_from_pvalue

logodds_matrix(counts, pseudocount=0.1) -> ndarray            # (W x 4), A C G T
threshold_from_pvalue(lo_matrix, pvalue, *, resolution=1e-3) -> float

threshold_from_pvalue computes the exact score distribution of one log-odds matrix under a uniform background on a resolution-bit grid (a coarse pass bounds the answer from below so only the upper tail is built) and returns the smallest score whose tail probability is at most pvalue. It agrees with MOODS’ threshold_from_p to a few thousandths of a bit, and is what every scan_*(pvalue=...) call uses, so the cutoffs do not depend on the engine. A motif too short to reach pvalue gets its best possible score (only perfect matches count).

motifs.logodds_matrix(lib["M1"]).round(3)
# -> array([[ 1.958, -4.7  , -4.7  , -4.7  ],
#           [-4.7  ,  1.958, -4.7  , -4.7  ],
#           [-4.7  , -4.7  ,  1.958, -4.7  ],
#           [-4.7  , -4.7  , -4.7  ,  1.958]])
for p in (0.1, 0.01, 1e-3):
    print(p, round(motifs.threshold_from_pvalue(lib.logodds(0), p), 3))
# -> 0.1 -5.483
#    0.01 1.175
#    0.001 7.831                  # 4 bp: only the perfect match (score 7.83) is that rare
import MOODS.tools
round(MOODS.tools.threshold_from_p(lib.logodds(0).T.tolist(), [0.25] * 4, 0.01), 3)
# -> 1.174

Block

Block(seqs, backend=None)

The scanning unit: the windows are upper-cased, those that are empty or hold letters other than A C G T N are dropped (rows maps block windows back to input rows), and the rest are laid end to end in text with offsets. Each motif is scanned once over the concatenation and the hits split back per window — a hit straddling two windows is dropped — so per-window results equal scanning each window alone. backend is resolved on construction.

Method Returns
hits(mat, threshold, both=False) (input row, position in the window, strand) of every hit of a (W x 4) log-odds matrix; strand 0 +, 1 - (the reverse complement of the matrix is scanned when both).
counts(mat, threshold, both=False) Hits per input window (int64, length = number of input windows).
moods_counts(mats, thresholds, both=False) MOODS only: many matrices in one pass, (windows x motifs) counts.
seqs = cre.sequences("genome.fa", r=50)                   # seven 100-bp windows
blk = motifs.Block(seqs)
blk.backend, blk.n, len(blk.text), blk.offsets
# -> ('moods', 7, 700, array([  0, 100, 200, 300, 400, 500, 600, 700]))
r, p, s = blk.hits(lib.logodds(0), 7.0, both=True)        # ACGT x 3 at the centre of window 0 ...
r, p, s
# -> (array([0, 0, 0, 5, 0, 0, 0, 5]), array([50, 54, 58, 44, 50, 54, 58, 44]), array([0, 0, 0, 0, 1, 1, 1, 1], dtype=int8))
blk.counts(lib.logodds(0), 7.0, both=True)                # ... ACGT is its own reverse complement
# -> array([6, 0, 0, 0, 0, 2, 0])
motifs.Block(seqs, backend="lightmotif").counts(lib.logodds(0), 7.0, both=True)
# -> array([6, 0, 0, 0, 0, 2, 0])
motifs.Block(seqs, backend="biopython").counts(lib.logodds(0), 7.0, both=True)
# -> array([6, 0, 0, 0, 0, 2, 0])
blk.moods_counts([lib.logodds(0), lib.logodds(1)], [7.0, 7.0], both=True).T
# -> array([[6, 0, 0, 0, 0, 2, 0],
#           [1, 0, 3, 0, 1, 0, 2]])
motifs.Block(["ACGTNACGT", ""]).rows                       # N is fine, an empty window is dropped
# -> array([0])

backends.fasta

Where sequence comes from. genomeblocks (the default) is a pure-Python reader on the samtools .fai index — built next to the file when missing — through a memory map, so only the requested bases are read; pysam (htslib), pyfaidx, memory (the whole file read into a {chrom: str} dict) and biopython (Bio.SeqIO.index) return the same bases. A gzip (not bgzip) FASTA cannot be indexed and is read into memory.

Name Signature One line
open_fasta open_fasta(src, *, backend=None) -> source A sequence source for a FASTA path, a {chrom: str} dict, a Biopython SeqIO.index / to_dict mapping, a pyfaidx.Fasta or a pysam.FastaFile (open objects and dicts are used as they are). Also gb.read_fasta’s dict.
read_fasta read_fasta(path) -> dict[str, str] Every record of a FASTA (plain or .gz) as {name: sequence}; gb.read_fasta.
IndexedSource, PysamSource, PyfaidxSource, MemorySource, BiopythonSource classes The five sources; .backend names the engine.

The source interface:

Method Returns
src.sizes() {chrom: length}.
src.fetch(chrom, start, end) str, as stored (case kept); clipped to the record.
src.fetch_many(chroms, starts, ends) list[str].
src.close() Closes what the source opened.

Loci.sequences, Locus.sequence, the motif scanners (fasta_backend=) and Genome.from_fasta all read through open_fasta.

src = fasta.open_fasta("genome.fa")
type(src).__name__, src.backend, src.sizes()
# -> ('IndexedSource', 'genomeblocks', {'chr1': 20000, 'chr2': 8000})
src.fetch("chr1", 1000, 1012), src.fetch_many(["chr1", "chr2"], [1000, 0], [1004, 4])
# -> ('ACGTACGTACGT', ['ACGT', 'ATGT'])
src.close()
os.path.exists("genome.fa.fai")                           # the index was written next to the file
# -> True
for b in ("pysam", "pyfaidx", "memory", "biopython"):
    s = fasta.open_fasta("genome.fa", backend=b)
    print(b, type(s).__name__, s.fetch("chr1", 1000, 1012))
    s.close()
# -> pysam PysamSource ACGTACGTACGT
#    pyfaidx PyfaidxSource ACGTACGTACGT
#    memory MemorySource ACGTACGTACGT
#    biopython BiopythonSource ACGTACGTACGT
type(fasta.open_fasta({"chr1": "ACGT"})).__name__, fasta.open_fasta({"chr1": "ACGT"}).fetch("chr1", 1, 3)
# -> ('MemorySource', 'CG')
import pysam
type(fasta.open_fasta(pysam.FastaFile("genome.fa"))).__name__
# -> 'PysamSource'
d = fasta.read_fasta("genome.fa")
list(d), len(d["chr1"])
# -> (['chr1', 'chr2'], 20000)
type(fasta.open_fasta("genome.fa.gz")).__name__          # gzip: read into memory
# -> 'MemorySource'
fasta.open_fasta("genome.fa.gz", backend="genomeblocks")
# -> ValueError: a gzip FASTA cannot be indexed
gb.Genome.from_fasta("genome.fa")
# -> Genome(2 chroms: chr1, chr2)

The indexed reader needs fixed-width records (every line of a record the same length except the last); a FASTA with uneven lines raises a ValueError suggesting seqkit seq -w 60 or backend='memory' / 'pysam' / 'pyfaidx'.


backends.tables

Parsing tab-separated text (BED, BEDPE, narrowPeak, pairs) with polars (the default when installed) or pandas. Both return the same thing — a dict of numpy columns, positions as int64, floats as float64, everything else as object strings — so callers never touch the frame library. Leading # / track / browser lines are skipped and .gz files are read directly. Loci.make, Pairs.make and Genes.make (GTF / GFF3 parsing, in genes.py) take backend= for this family.

Function Signature Returns
sniff sniff(path, max_lines=50) (header lines to skip, number of columns of the first data line).
read_columns read_columns(path, columns, *, ints=(), floats=(), backend=None) {column index: array} for the 0-based columns of a header-less TSV; ints become int64, floats float64, the rest strings. A # inside a data field is data.
tables.sniff("peaks.bed"), tables.sniff("loops.bedpe")    # two header lines, six columns
# -> ((2, 6), (0, 10))
d = tables.read_columns("peaks.bed", [0, 1, 2, 4], ints=[1, 2], floats=[4])
{k: v[:2] for k, v in d.items()}
# -> {0: array(['chr1', 'chr1'], dtype=object), 1: array([ 900, 1900]), 2: array([1100, 2100]), 4: array([10., 20.])}
tables.read_columns("peaks.bed", [0, 1], ints=[1], backend="pandas")[1]
# -> array([  900,  1900,  4900,  9950, 10900,   500,  5000])
open("h.bed", "w").write("chrom\tstart\tend\nchr1\t1\t5\n")
tables.read_columns("h.bed", [0, 1, 2], ints=[1, 2])
# -> ValueError: h.bed: cannot read as a tab-separated BED-like table (column 2): ... Header lines must start with '#', 'track' or 'browser';
#    a file with a column-name header is read with Loci.from_frame(pandas.read_csv(path, sep='\t')) or as_loci(path) for .tsv / .csv

backends.graph

Graph algorithms on an Architecture: graph-tool (the default when installed), scipy (the pip default), igraph and networkx. The Architecture is two tables (vertices = Loci rows, edges = src / tgt + edge columns); a backend turns them into its own graph object when an algorithm needs one. Every function returns plain numpy aligned to the Loci rows, so results do not depend on the engine.

Function Signature Returns
to_scipy to_scipy(A, weight="w", *, n=None) Symmetric CSR adjacency (n x n) over the Loci rows; weight an edge column name, an array, or None for 1 per edge; parallel edges are summed.
native native(A, backend=None, *, vprops=(), loci_cols=False) The engine’s own graph (vertex i = row i): a graph_tool.Graph, igraph.Graph, networkx.Graph or the scipy matrix. Edge columns become edge attributes; vprops names vertex columns to copy; loci_cols=True adds chrom / start / end per vertex. networkx holds one edge per pair, so parallel edges are merged with their numeric columns summed.
components components(A, backend=None) -> int64 array Connected component per row, numbered by first row (so every engine agrees); -1 for rows without links.
pagerank pagerank(A, weight=None, *, damping=0.85, backend=None, tol=1e-10) -> float64 array PageRank per row (unlinked rows included, as every engine does).
layout layout(A, rows, *, kind="spring", backend=None, seed=0) -> (len(rows) x 2) 2-D positions for the subgraph induced by rows: 'spring' (the engine’s force-directed layout; spectral for scipy), 'circular', or 'genomic' (x = genomic centre, y = 0).
layout_edges layout_edges(m, s, t, *, kind="spring", backend=None, seed=0, x=None) The same for m vertices joined by edges (s[k], t[k]); x feeds the genomic layout.

Architecture.graph(backend=), A.components(), A.pagerank() and architecture_draw.draw call these.

A = gb.Architecture.make(cre, gb.Pairs.make("loops.bedpe"), r=100, verbose=False)
A, A.src.tolist(), A.tgt.tolist()
# -> (Architecture(name='Skeleton', loci=6, links=4 [3 cis · 1 trans], edge_props=[w], vertex_props=[]), [0, 1, 5, 0], [2, 4, 6, 5])
M = graph.to_scipy(A)
type(M).__name__, M.shape, M.nnz
# -> ('csr_matrix', (7, 7), 8)
graph.to_scipy(A, weight=None).toarray().astype(int)[:3]
# -> array([[0, 0, 1, 0, 0, 1, 0],
#           [0, 0, 0, 0, 1, 0, 0],
#           [1, 0, 0, 0, 0, 0, 0]])
G = graph.native(A, "networkx")
print(G); list(G.edges(data=True))[:2]
# -> Graph with 7 nodes and 4 edges
#    [(0, 2, {'w': 0.0}), (0, 5, {'w': 0.0})]
graph.native(A, "igraph").summary()
# -> 'IGRAPH U--- 7 4 -- \n+ attr: w (e)'
graph.native(A, "networkx", loci_cols=True).nodes[0]
# -> {'chrom': 'chr1', 'start': 900, 'end': 1100}
graph.components(A).tolist(), graph.components(A, backend="networkx").tolist()
# -> ([0, 1, 0, -1, 1, 0, 0], [0, 1, 0, -1, 1, 0, 0])
graph.pagerank(A).round(3).tolist()
# -> [0.211, 0.163, 0.114, 0.024, 0.163, 0.211, 0.114]
np.allclose(graph.pagerank(A), graph.pagerank(A, backend="igraph"), atol=1e-6)
# -> True
graph.layout(A, [0, 2, 5], kind="genomic").tolist()
# -> [[1000.0, 0.0], [5000.0, 0.0], [550.0, 0.0]]
graph.layout(A, [0, 2, 5], kind="circular").round(2).tolist()
# -> [[1.0, 0.0], [-0.5, 0.87], [-0.5, -0.87]]
graph.layout(A, np.arange(len(cre))).shape, graph.layout_edges(3, [0, 1], [1, 2], backend="networkx").shape
# -> ((7, 2), (3, 2))
graph.to_scipy(A, weight="nope")
# -> ValueError: edge column 'nope' not found; have: w
graph.pagerank(A, backend="graph-tool")                    # on a pip install
# -> ImportError: the 'graph-tool' graph backend is not installed: conda install -c conda-forge graph-tool

make leaves the edge weight w at 0 until add_mcool fills it, which is why the weighted PageRank above would be uniform; pagerank(A, weight="w") is the call to make after normalisation.


Copyright © 2024–2026 Umut Berkay Altintas. MIT Licensed.

This site uses Just the Docs, a documentation theme for Jekyll.