Bio.motifs
Bio.motifs
Core Principle:
Bio.motifsmodels sequence binding motifs as count matrices, Position Weight Matrices (PWMs), and Position-Specific Scoring Matrices (PSSMs) across JASPAR, MEME, and TRANSFAC specifications.
Building a motif
[1.0, 5.0, 0.0, 2.0, 1.0]
The matrix progression
counts → PWM (normalised) → PSSM (log-odds vs background)
pwm = m.counts.normalize(pseudocounts=0.5) # position weight matrix
pssm = pwm.log_odds() # position-specific scoring matrix
pssm = pwm.log_odds(background={"A": .3, "C": .2, "G": .2, "T": .3})
pssm.max; pssm.min # best/worst possible score
pssm.mean(background); pssm.std(background) # expected score distribution
Pseudocounts are not optional. Without them, a position where a base was never observed gets probability 0, so its log-odds is −∞, and any sequence with that base scores −∞ regardless of how well it matches elsewhere. With five instances you have not proven a base is impossible.
pseudocounts=0.5 (Jeffreys prior) or pseudocounts="sqrt" are conventional.
Scanning a sequence
seq = Seq("TACACTGCATTACAACCCTAATT")
for pos, score in pssm.search(seq, threshold=3.0):
strand = "+" if pos >= 0 else "-"
start = pos if pos >= 0 else len(seq) + pos
print(f"{strand} strand, position {start}, score {score:.2f}")
scores = pssm.calculate(seq) # score at every position, as a NumPy array
A negative position means the reverse strand, and the value is measured from the end of the sequence. This is easy to mishandle. Transcription factors bind both strands, so scanning both is correct: just be careful converting back to coordinates.
pssm.calculate(seq) returning a NumPy array is the efficient route when you want to plot a score profile or find all local maxima. See ndarray.
Choosing a threshold
dist = pssm.distribution(background=background, precision=10**4)
threshold = dist.threshold_fpr(0.001) # 0.1% false positive rate
threshold = dist.threshold_fnr(0.1) # 10% false negative rate
threshold = dist.threshold_balanced(1000) # balance FPR and FNR
threshold = dist.threshold_patser() # a common heuristic
Do not pick a threshold by eye. A PWM scan over a 3 Gb genome at an arbitrary cutoff produces millions of "hits", nearly all of them noise: motif sites are short and information-poor, so statistical significance is not biological significance. Real TF binding depends on chromatin accessibility, cofactors and cooperativity, none of which a PWM knows about.
The honest use of a PWM scan is: restrict to regions you have independent evidence for (ATAC-seq peaks, ChIP-seq peaks, promoters), then scan.
Reading motif databases
Traceback (most recent call last):
File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 132, in run_cell_capture
exec(compile(code, "<cell>", "exec"), namespace)
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<cell>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'jaspar.pfm'
Supported formats: "jaspar", "pfm", "transfac", "meme", "minimal", "sites", "clusterbuster", "xms", "pfm-four-columns", "pfm-four-rows".
JASPAR is the usual source for curated TF motifs; MEME format is what the MEME suite emits.
'P0 A C G T\n01 1 0 0 4 T\n02 5 0 0 0 A\n03 0 5 0 0 C\n04 2 2 1 0 M\n05 1 4 0 0 C\nXX\n//\n'
Sequence logos
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 1320, in do_open
h.request(req.get_method(), req.selector, req.data, headers,
~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
encode_chunked=req.has_header('Transfer-encoding'))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/http/client.py", line 1386, in request
self._send_request(method, url, body, headers, encode_chunked)
~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/http/client.py", line 1432, in _send_request
self.endheaders(body, encode_chunked=encode_chunked)
~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/http/client.py", line 1381, in endheaders
self._send_output(message_body, encode_chunked=encode_chunked)
~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/http/client.py", line 1141, in _send_output
self.send(msg)
~~~~~~~~~^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/http/client.py", line 1085, in send
self.connect()
~~~~~~~~~~~~^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/http/client.py", line 1527, in connect
self.sock = self._context.wrap_socket(self.sock,
~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
server_hostname=server_hostname)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/ssl.py", line 455, in wrap_socket
return self.sslsocket_class._create(
~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
sock=sock,
^^^^^^^^^^
...<5 lines>...
session=session
^^^^^^^^^^^^^^^
)
^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/ssl.py", line 1076, in _create
self.do_handshake()
~~~~~~~~~~~~~~~~~^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/ssl.py", line 1372, in do_handshake
self._sslobj.do_handshake()
~~~~~~~~~~~~~~~~~~~~~~~~~^^
ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain (_ssl.c:1082)
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 128, in run_cell_capture
value = eval(compile(tail, "<cell>", "eval"), namespace)
File "<cell>", line 1, in <module>
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/site-packages/Bio/motifs/__init__.py", line 566, in weblogo
response = urlopen(req)
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 187, in urlopen
return opener.open(url, data, timeout)
~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 487, in open
response = self._open(req, data)
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 504, in _open
result = self._call_chain(self.handle_open, protocol, protocol +
'_open', req)
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 464, in _call_chain
result = func(*args)
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 1368, in https_open
return self.do_open(http.client.HTTPSConnection, req,
~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
context=self._context)
^^^^^^^^^^^^^^^^^^^^^^
File "/Library/Frameworks/Python.framework/Versions/3.14/lib/python3.14/urllib/request.py", line 1323, in do_open
raise URLError(err)
urllib.error.URLError: <urlopen error [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain (_ssl.c:1082)>
For local, controllable logos, use logomaker:
import logomaker
df = pd.DataFrame(m.counts).div(pd.DataFrame(m.counts).sum(axis=1), axis=0)
info = logomaker.transform_matrix(df, from_type="probability", to_type="information")
fig, ax = plt.subplots(figsize=(4, 2))
logomaker.Logo(info, ax=ax, color_scheme="classic")
ax.set(xlabel="Position", ylabel="Information (bits)")
The y-axis in bits is the informative one: total height at a position is 2 - H for DNA, so a fully constrained position is 2 bits and an unconstrained one is 0.
Information content
Traceback (most recent call last):
File "/Users/leviosman/Developer/python-for-bio/webapp/scripts/curriculum/generate_cell_outputs.py", line 132, in run_cell_capture
exec(compile(code, "<cell>", "exec"), namespace)
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<cell>", line 8, in <module>
NameError: name 'pwm' is not defined
Total information content tells you how specific a motif is. A 6-bp motif with 12 bits occurs by chance roughly every 4,096 bp: i.e. about 750,000 times in the human genome. That number is the reality check every motif analysis needs.
RNA motifs
Biopython 1.85 added RNA support to Bio.motifs, so you can build motifs over ACGU for RNA-binding-protein sites and splice signals.
Bioinformatics examples
# build a splice donor motif from annotated introns
donors = [genome[start:start+9] for start in intron_starts]
m = motifs.create(donors)
print(m.degenerate_consensus) # ~ 'MAGGTRAGT'
pwm = m.counts.normalize(pseudocounts=0.5)
# score a variant's effect on the splice site
ref_score = pwm.log_odds().calculate(Seq(ref_context))
alt_score = pwm.log_odds().calculate(Seq(alt_context))
print(f"Δ score = {alt_score.max() - ref_score.max():.2f}")
# scan ATAC peaks (not the whole genome) for a TF motif
with open("MA0139.1.jaspar") as fh:
ctcf = motifs.parse(fh, "jaspar")[0]
pssm = ctcf.counts.normalize(pseudocounts=0.5).log_odds()
thr = pssm.distribution(precision=10**4).threshold_fpr(1e-4)
hits = []
for peak in peaks.itertuples():
seq = genome[peak.chrom].seq[peak.start:peak.end]
for pos, score in pssm.search(seq, threshold=thr):
hits.append({"chrom": peak.chrom, "pos": peak.start + abs(pos),
"score": score, "strand": "+" if pos >= 0 else "-"})
hits = pd.DataFrame(hits)
# motif score profile across a promoter
scores = pssm.calculate(promoter_seq)
fig, ax = plt.subplots(figsize=(8, 2))
ax.plot(scores, lw=0.8)
ax.axhline(thr, ls="--", c="crimson", label=f"FPR 1e-4")
ax.set(xlabel="Position in promoter", ylabel="PSSM score")
The variant-effect example is a genuinely useful pattern: the difference in best motif score between reference and alternate context is a cheap, interpretable predictor of a regulatory or splicing effect.
Common mistakes
- No pseudocounts, giving −∞ scores.
- Arbitrary thresholds instead of
threshold_fpr. - Genome-wide scans treated as binding predictions.
- Ignoring the reverse strand, or mishandling the negative position convention.
- Wrong background frequencies. Human genome GC is ~41%, not 50%; using uniform background biases GC-rich motifs.
- Confusing PWM and PSSM. PWM is probabilities, PSSM is log-odds.
- Comparing scores across motifs of different lengths. Longer motifs score higher; normalise or use p-values.
- Forgetting
weblogo()needs network access: it fails silently offline in some setups.
See also
Seq · ndarray · Bio.SeqUtils · Applied - Sequence Composition Analysis · Linear Algebra with NumPy
Exercise
This exercise rebuilds the counts to PWM to PSSM progression
by hand, because Biopython is not installed here and because the arithmetic is
where the understanding lives. Add a pseudocount of 0.5 to every base at
every position before normalising, use a uniform background of 0.25 for all
four bases, and take base-2 logarithms.
Write scan(seq, instances), where instances are motif sequences of equal
length over ACGT and seq is the sequence to scan. Return the consensus, the
total information content, and the best-scoring window. The consensus takes the
highest-probability base at each position, breaking ties in favour of the base
that sorts first. Information content is the sum over all positions and bases
of p * log2(p / 0.25). Score a window by summing log2(p / 0.25) for its
bases, and skip any window containing a character other than A, C, G or
T: a run of N must not score. Print consensus=<s>, then ic=<x> rounded
to three decimals, then pos=<i> score=<x> with the 0-based start of the best
window and its score rounded to three decimals, breaking ties in favour of the
lower position. If no window can be scored, print no hit in place of that
last line. The sequence arrives on the first line of input and the instances
follow, one per line.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | TACACTGCATTACAACCCTAATT TACAA TACGC TACAC TACCC AACCC | consensus=TACAC ic=3.153 pos=0 score=6.544 |
| 2 | stdin | GGGGNNNNNGGGGCACGTGGGGG CACGTG CACGTG CACGTG CACGTG | consensus=CACGTG ic=4.755 pos=13 score=9.510 |
| 3 | stdin | ACGT AACCGGTT AACCGGTT | consensus=AACCGGTT ic=3.610 no hit |
| 4 | stdin | NNNNNNNNNN ATG ATG ATC | consensus=ATG ic=1.601 no hit |
Hints · 3
01Hint
Build the pseudocounted column totals first: every column sums to len(instances) + 2.0.
02Hint
max("ACGT", key=lambda b: column[b]) already returns the first base on a tie.
03Hint
Take the log-odds inside the window loop; the background is 0.25 for every base.