Applied - GFF and Genomic Intervals
Applied - GFF and Genomic Intervals
Core Principle: Genomic feature annotation parsing resolves 1-based closed coordinates in GFF/GTF files, unpacks structured attribute columns, and computes interval intersections against reference genes.
Uses: Reading and Writing Data · String Accessor · Coordinate Systems · Merging and Joining · SeqFeature and Location
Reading GFF/GTF
Nine tab-separated columns, no header, # comments, 1-based closed:
import pandas as pd
GFF_COLS = ["seqid", "source", "type", "start", "end",
"score", "strand", "phase", "attributes"]
def read_gff(path):
df = pd.read_csv(
path, sep="\t", comment="#", names=GFF_COLS,
dtype={"seqid": "category", "source": "category", "type": "category",
"start": "int32", "end": "int32", "strand": "category"},
na_values=["."],
)
df["start0"] = df["start"] - 1 # ← convert to 0-based half-open ONCE
df["end0"] = df["end"]
df["length"] = df["end"] - df["start"] + 1
return df
gff = read_gff("gencode.v45.gtf.gz")
gff["type"].value_counts()
The start0/end0 naming is the discipline. A bare start in a frame that has touched both BED and GFF data is a bug waiting to happen. See Coordinate Systems.
Parsing the attributes column
GFF3 and GTF use different syntax for column 9:
# GFF3: ID=gene-TP53;Name=TP53;gene_biotype=protein_coding
gff["gene_id"] = gff["attributes"].str.extract(r"(?:^|;)ID=([^;]+)")
gff["gene_name"] = gff["attributes"].str.extract(r"(?:^|;)Name=([^;]+)")
gff["parent"] = gff["attributes"].str.extract(r"(?:^|;)Parent=([^;]+)")
# GTF: gene_id "ENSG00000141510"; gene_name "TP53";
gff["gene_id"] = gff["attributes"].str.extract(r'gene_id "([^"]+)"')
gff["gene_name"] = gff["attributes"].str.extract(r'gene_name "([^"]+)"')
gff["transcript_id"] = gff["attributes"].str.extract(r'transcript_id "([^"]+)"')
# strip Ensembl version suffixes so joins will match
gff["gene_id_nov"] = gff["gene_id"].str.split(".").str[0]
Always check how many failed to extract:
print(gff["gene_id"].isna().sum(), "rows without a gene_id")
For full GFF3 parsing with parent–child relationships, use gffutils: it builds a SQLite database and lets you query db.children(gene, featuretype="exon") properly. Regex extraction is fine for flat queries and wrong for hierarchy traversal.
Deriving structures
genes = gff.query("type == 'gene'")
exons = gff.query("type == 'exon'")
# exons per transcript
exons.groupby("transcript_id").size().describe()
# transcript span from its exons
tx = exons.groupby("transcript_id").agg(
seqid=("seqid", "first"), strand=("strand", "first"),
start0=("start0", "min"), end0=("end0", "max"),
n_exons=("start0", "size"),
exonic_bp=("length", "sum"),
)
# introns, derived from consecutive exons
def introns_of(g):
g = g.sort_values("start0")
return pd.DataFrame({
"seqid": g["seqid"].iloc[0],
"start0": g["end0"].values[:-1],
"end0": g["start0"].values[1:],
})
introns = exons.groupby("transcript_id", group_keys=True).apply(
introns_of, include_groups=False
).reset_index()
Interval overlap: the core problem
pandas cannot do this efficiently. A merge matches on equality; overlap is a range predicate. A naive cross-join of 1 million variants against 60,000 genes is 60 billion comparisons.
The right tools
import pyranges as pr
variants = pr.PyRanges(chromosomes=vcf["CHROM"],
starts=vcf["POS"] - 1, ends=vcf["POS"])
genes_pr = pr.PyRanges(chromosomes=genes["seqid"],
starts=genes["start0"], ends=genes["end0"])
annotated = variants.join(genes_pr) # interval join
variants.overlap(genes_pr) # boolean filter
variants.nearest(genes_pr) # nearest feature + distance
genes_pr.merge() # collapse overlapping intervals
a.subtract(b); a.intersect(b)
pyranges wraps nested containment lists: O(log n) per query. bioframe offers a similar API that stays closer to pandas DataFrames. pybedtools wraps BEDTools, which is the gold standard if you already have it installed.
Pure Python, for smaller problems
from intervaltree import IntervalTree
from collections import defaultdict
trees = defaultdict(IntervalTree)
for r in genes.itertuples():
trees[r.seqid][r.start0:r.end0] = r.gene_name # half-open
def genes_at(chrom, pos0):
return [iv.data for iv in trees[chrom][pos0]]
vcf["genes"] = [genes_at(c, p - 1) for c, p in zip(vcf["CHROM"], vcf["POS"])]
vcf = vcf.explode("genes")
Build once, query many times. Fine for tens of thousands of intervals.
Sorted-array approach
For a one-off overlap of two sorted position lists, np.searchsorted is fast and dependency-free:
import numpy as np
starts = genes_chr["start0"].to_numpy()
ends = genes_chr["end0"].to_numpy()
order = np.argsort(starts)
starts, ends = starts[order], ends[order]
idx = np.searchsorted(starts, positions, side="right") - 1
hit = (idx >= 0) & (positions < ends[idx])
Correct only for non-overlapping intervals: which genes are not. Useful for exons within a transcript, or for binned data.
The classic mistakes
Off-by-one at the format boundary:
# GFF is 1-based closed; a Python slice is 0-based half-open
seq[gff_start - 1 : gff_end] # ✓
seq[gff_start : gff_end] # ✗: drops the first base
The overlap predicate. Two half-open intervals overlap when:
a_start < b_end and b_start < a_end
Note strict < on both. Using <= makes book-ended intervals (where one ends exactly where the next begins) count as overlapping, which is wrong for half-open coordinates and is exactly how adjacent exons get merged.
Strand. Overlap is usually strand-agnostic, but "the nearest upstream gene" is not: upstream means lower coordinates on the plus strand and higher on the minus strand.
Practical recipes
# gene body coverage: how much of each gene is covered by an interval set?
cov = pr.PyRanges(cov_df).merge()
genes_pr.coverage(cov)
# promoters (2 kb upstream, strand-aware)
genes["prom_start0"] = np.where(genes["strand"] == "+",
genes["start0"] - 2000, genes["end0"])
genes["prom_end0"] = np.where(genes["strand"] == "+",
genes["start0"], genes["end0"] + 2000)
genes["prom_start0"] = genes["prom_start0"].clip(lower=0)
# fixed-width genomic bins
BIN = 1_000_000
variants["bin"] = variants["POS"] // BIN
density = variants.groupby(["CHROM", "bin"], observed=True).size()
# is a variant in an exon, and if so which?
exon_tree = build_trees(exons)
vcf["in_exon"] = [bool(exon_tree[c][p - 1]) for c, p in zip(vcf.CHROM, vcf.POS)]
# distance to the nearest splice site: feeds splice-effect prediction
boundaries = np.sort(np.concatenate([exons["start0"], exons["end0"]]))
i = np.searchsorted(boundaries, positions)
dist = np.minimum(np.abs(positions - boundaries[np.clip(i-1, 0, None)]),
np.abs(positions - boundaries[np.clip(i, None, len(boundaries)-1)]))
The promoter definition is worth noting as an example of strand-awareness done explicitly: the naive start - 2000 is wrong for half your genes.
Chromosome naming, again
set(gff["seqid"]) & set(vcf["CHROM"]) # ← if this is empty, that is your bug
GENCODE uses chr1, Ensembl uses 1. Normalise at load, assert the intersection is non-empty. This check has saved more debugging time than any other single line in this vault.
Common mistakes
- Off-by-one converting GFF to 0-based.
<=in the overlap predicate, merging book-ended intervals.- A pandas cross-join for overlap.
chr1vs1.- Ignoring strand for directional queries.
- Regex-parsing GFF3 hierarchy instead of using gffutils.
- Version suffixes on gene IDs breaking joins.
- Not clipping negative coordinates after extending intervals.
- Assuming one transcript per gene.
See also
Coordinate Systems · Applied - Genomic File Formats · SeqFeature and Location · String Accessor · Merging and Joining · Applied - Annotating a Variant Table
Exercise
Answer "what overlaps what" without an interval library. The two mistakes the lesson warns about are both live here: the coordinate systems differ between the two inputs, and overlapping features double-count bases unless you merge them first.
Write overlaps(text), returning a list of output lines. The first input line
is the GFF3 feature type to consider. What follows is a GFF3 annotation, then a
line containing exactly --, then the query intervals as chrom, start,
end in 0-based half-open coordinates, tab separated. Ignore blank lines and
lines beginning with #. GFF3 is 1-based closed, so a feature overlaps a query
when start - 1 < query_end and end > query_start. Normalise chromosome
names on both sides by prefixing chr where it is missing, so that 17 and
chr17 are the same sequence.
Print features=<n> queries=<n>, where the feature count is the number of
annotation records of the requested type, whatever chromosome they lie on. Then
print one line per query in input order, holding chrom:start-end, the number
of overlapping features, the number of query bases covered by at least one of
them, and the overlapping features' ID attributes sorted ascending and joined
with commas, tab separated. Compute the covered bases by clipping each
overlapping feature to the query and merging the clipped intervals, so a base
under two exons counts once. Use . for the name field when nothing overlaps.
If there are no query intervals, print no queries after the first line.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | exon ##gff-version 3 chr17 RefSeq gene 7668402 7687550 . - . ID=gene-TP53;Name=TP53 chr17 RefSeq exon 7687377 7687550 . - . ID=exon-1;Parent=rna-NM_000546.6 chr17 RefSeq exon 7676521 7676622 . - . ID=exon-2;Parent=rna-NM_000546.6 -- chr17 7676520 7676530 17 7687376 7687400 chr17 7680000 7681000 | features=2 queries=3 chr17:7676520-7676530 1 10 exon-2 chr17:7687376-7687400 1 24 exon-1 chr17:7680000-7681000 0 0 . |
| 2 | stdin | exon chr1 src exon 100 200 . + . ID=e1 chr1 src exon 150 260 . + . ID=e2 chr1 src exon 400 410 . + . ID=e3 chr2 src exon 100 200 . + . ID=e4 -- 1 99 260 chr1 260 400 chr2 0 1000 | features=4 queries=3 chr1:99-260 2 161 e1,e2 chr1:260-400 1 1 e3 chr2:0-1000 1 101 e4 |
| 3 | stdin | CDS chr3 src exon 10 20 . + . ID=x -- chr3 0 100 | features=0 queries=1 chr3:0-100 0 0 . |
| 4 | stdin | exon chr9 src exon 1 10 . + . ID=only -- | features=1 queries=0 no queries |
Hints · 3
01Hint
Convert the GFF start once, when you parse it, and never again.
02Hint
Merging is a sort by start followed by a single pass that extends or emits.
03Hint
A feature that starts exactly at the query end does not overlap it.