Session 5 — BLAST and Profile Methods in code

▶  Open in Google Colab

This notebook reproduces every worked example on the Session 5 page with real code and real data: the BLOSUM62 neighbor-word scores from BLAST’s seeding stage, the X-drop extension rule, a BLASTP search of HBB (beta-globin) against its globin relatives together with the optimal alignment that BLAST’s heuristic missed, a PSSM built from scratch (with and without pseudocounts), and a live fetch of HBB’s real Pfam profile-HMM domain hit.

Setup

!pip install -q biopython

Stage 1: seeding — BLOSUM62 neighbor words

For the query word LWG, which other 3-letter words score above a threshold T=12 against it, using the real BLOSUM62 matrix?

from Bio.Align import substitution_matrices
from itertools import product

blosum62 = substitution_matrices.load("BLOSUM62")
AA = sorted(set(blosum62.alphabet) - {"*", "X", "B", "Z"})

def word_score(w1, w2):
    return sum(blosum62[a, b] for a, b in zip(w1, w2))

query_word = "LWG"
T = 12
scores = {"".join(w): word_score(query_word, "".join(w)) for w in product(AA, repeat=3)}
neighbors = sorted(scores.items(), key=lambda kv: -kv[1])
above_threshold = [(w, s) for w, s in neighbors if s >= T]

print(f"{len(above_threshold)} words score >= {T} against {query_word!r}; top 10:")
for w, s in above_threshold[:10]:
    print(f"  {w}  {s}")
49 words score >= 12 against 'LWG'; top 10:
  LWG  21.0
  IWG  19.0
  MWG  19.0
  VWG  18.0
  FWG  17.0
  AWG  16.0
  CWG  16.0
  TWG  16.0
  YWG  16.0
  KWG  15.0

Stage 2: extension — the X-drop rule

Track a running score and a running maximum; stop as soon as the running score falls more than X below the running maximum, then trim back to where that maximum actually occurred.

def xdrop_extend(query, target, X):
    q = query.replace(" ", "")
    t = target.replace(" ", "")
    n = min(len(q), len(t))

    score = 0
    running_max = 0
    max_pos = 0
    trace = []
    for i in range(n):
        score += 1 if q[i] == t[i] else -1
        if score > running_max:
            running_max = score
            max_pos = i
        trace.append(score)
        if running_max - score >= X:
            return {
                "stopped_at": i,
                "peak_score": running_max,
                "peak_position": max_pos,
                "trimmed_query": q[: max_pos + 1],
                "trimmed_target": t[: max_pos + 1],
                "trace": trace,
            }
    return {"stopped_at": None, "peak_score": running_max, "trace": trace}


result = xdrop_extend(
    "The quick brown fox jumps over the lazy dog.",
    "The quiet brown cat purrs when she sees him.",
    X=5,
)
print("Extension stopped at character index:", result["stopped_at"])
print("Peak running score:", result["peak_score"], "at position", result["peak_position"])
print("Trimmed alignment:")
print(" query:", result["trimmed_query"])
print(" target:", result["trimmed_target"])
assert result["trimmed_query"] == "Thequickbrown"
assert result["trimmed_target"] == "Thequietbrown"
Extension stopped at character index: 19
Peak running score: 9 at position 12
Trimmed alignment:
 query: Thequickbrown
 target: Thequietbrown

Stage 3: a real BLAST search, and what its heuristic missed

The book page’s table comes from a BLASTP search of HBB (beta-globin) against five other human globin-family proteins. It was run with BLAST+, the command-line version of BLAST, when this notebook was written. Its output is reproduced below, so that the notebook needs no command-line tools. The commands are given in a comment for reference.

The sequences themselves are fetched live from UniProt, because the next step aligns two of them again in Python.

import requests

GLOBINS = {
    "P68871": "HBB_HUMAN (beta-globin) -- the query",
    "P69905": "HBA_HUMAN (alpha-globin)",
    "P02144": "MYG_HUMAN (myoglobin)",
    "P02008": "HBAZ_HUMAN (zeta-globin, embryonic alpha-like)",
    "P02042": "HBD_HUMAN (delta-globin)",
    "P02100": "HBE_HUMAN (epsilon-globin, embryonic)",
}

fasta = {}
for acc, label in GLOBINS.items():
    r = requests.get(f"https://rest.uniprot.org/uniprotkb/{acc}.fasta", timeout=10)
    r.raise_for_status()
    fasta[acc] = r.text
    print(label)
HBB_HUMAN (beta-globin) -- the query
HBA_HUMAN (alpha-globin)
MYG_HUMAN (myoglobin)
HBAZ_HUMAN (zeta-globin, embryonic alpha-like)
HBD_HUMAN (delta-globin)
HBE_HUMAN (epsilon-globin, embryonic)
# Run with BLAST+ 2.16.0, on the five sequences fetched above:
#   makeblastdb -in globin_db.fasta -dbtype prot -out globin_db
#   blastp -query hbb_query.fasta -db globin_db -outfmt "6 sseqid pident length evalue bitscore" -evalue 1000
#   blastp ... -window_size 0 -max_hsps 1        (the same search, with the two-hit rule switched off)
BLAST_DEFAULT = """sp|P02042|HBD_HUMAN  93.197  147 9.28e-105   284
sp|P02100|HBE_HUMAN 75.510  147 2.05e-87    240
sp|P69905|HBA_HUMAN 43.448  145 8.95e-38    114
sp|P02008|HBAZ_HUMAN    35.862  145 3.81e-32    100
sp|P02144|MYG_HUMAN 16.000  125 63  10.8"""
BLAST_NO_TWO_HIT = """sp|P02144|MYG_HUMAN   25.517  145 5.38e-11    45.4"""

def show_blast(table):
    print(f"{'Hit':<20}{'%identity':>10}{'length':>8}{'E-value':>14}{'bit score':>11}")
    for line in table.splitlines():
        sseqid, pident, length, evalue, bitscore = line.split("\t")
        print(f"{sseqid.split('|')[2]:<20}{float(pident):>10.1f}{length:>8}{float(evalue):>14.3g}{float(bitscore):>11.1f}")

show_blast(BLAST_DEFAULT)
Hit                  %identity  length       E-value  bit score
HBD_HUMAN                 93.2     147     9.28e-105      284.0
HBE_HUMAN                 75.5     147      2.05e-87      240.0
HBA_HUMAN                 43.4     145      8.95e-38      114.0
HBAZ_HUMAN                35.9     145      3.81e-32      100.0
MYG_HUMAN                 16.0     125            63       10.8

Four confident hits, and myoglobin with an E-value of about 63. Taken at face value, that is no statistical evidence at all, although myoglobin is a known remote homolog. But an E-value only evaluates the alignment BLAST found. BLAST’s default two-hit rule only extends a seed when a second seed lies nearby on the same diagonal. Is there a better alignment that BLAST never extended? Smith-Waterman (Session 4) finds the best local alignment by full dynamic programming, with no shortcuts. Here it is with BLAST’s own scoring (BLOSUM62, a gap costing 11 to open and 1 per residue):

import math
from Bio import Align
from Bio.Align import substitution_matrices

def seq_of(acc):
    return "".join(fasta[acc].splitlines()[1:])

aligner = Align.PairwiseAligner(mode="local", substitution_matrix=substitution_matrices.load("BLOSUM62"),
                                open_gap_score=-12, extend_gap_score=-1)   # a gap of length k costs 11 + k, as in BLAST
best = aligner.align(seq_of("P68871"), seq_of("P02144"))[0]
identity = sum(a == b and a != "-" for a, b in zip(best[0], best[1]))
length = best.shape[1]

lam, K = 0.267, 0.041                      # BLAST's Karlin-Altschul parameters for BLOSUM62 with these gap costs
bits = (lam * best.score - math.log(K)) / math.log(2)
m = len(seq_of("P68871"))
n = sum(len(seq_of(acc)) for acc in GLOBINS if acc != "P68871")
E = m * n * 2 ** -bits                     # E-value: the search space times 2 to the minus bit score
print(f"Smith-Waterman, HBB against MYG: raw score {best.score:.0f}, {bits:.1f} bits, "
      f"{length} columns, {100 * identity / length:.1f}% identity, E = {E:.1g}")
print()
print("BLAST with the two-hit rule switched off (-window_size 0) finds the same alignment:")
show_blast(BLAST_NO_TWO_HIT)
Smith-Waterman, HBB against MYG: raw score 110, 47.0 bits, 145 columns, 25.5% identity, E = 8e-10

BLAST with the two-hit rule switched off (-window_size 0) finds the same alignment:
Hit                  %identity  length       E-value  bit score
MYG_HUMAN                 25.5     145      5.38e-11       45.4

The optimal alignment covers almost the whole of both proteins (145 residues, about 25% identity) and is highly significant. BLAST with the two-hit rule switched off finds it too, with almost the same bit score; its E-value is a little smaller because BLAST also corrects for sequence composition and for the ends of the sequences. With the default settings no two seeds happened to land close together on the right diagonal, so the good alignment was never extended: the price of a fast heuristic. More distant relatives than myoglobin give no pairwise signal even with an optimal alignment, which is the case profile methods are built for.

Building a PSSM from scratch

The same 5-sequence toy alignment from the book page, with and without pseudocounts.

import math

msa = ["AGLSP", "AGLTP", "RGISP", "AALSQ", "AALSP"]

# Typical background frequencies for these residues in proteins
background = {
    "A": 0.074, "R": 0.042, "G": 0.074, "L": 0.076, "S": 0.081,
    "T": 0.062, "P": 0.050, "Q": 0.037, "I": 0.038, "W": 0.013,
}

residues = sorted(r for r in background if r != "W")  # residues that actually appear in this MSA
n_pos = len(msa[0])

def counts_at(pos):
    col = [seq[pos] for seq in msa]
    return {r: col.count(r) for r in residues}

def pssm(pseudocount):
    matrix = []
    for pos in range(n_pos):
        c = counts_at(pos)
        total = sum(c.values()) + pseudocount * len(residues)
        freqs = {r: (c[r] + pseudocount) / total for r in residues}
        matrix.append({r: math.log2(freqs[r] / background[r]) if freqs[r] > 0 else float("-inf") for r in residues})
    return matrix

def best_sequence(matrix):
    best = [max(col, key=col.get) for col in matrix]
    score = sum(matrix[i][best[i]] for i in range(len(best)))
    return "".join(best), score

pssm_pseudo = pssm(pseudocount=1)
best_seq, best_score = best_sequence(pssm_pseudo)
print("With pseudocounts, highest-scoring sequence:", best_seq, f"(score {best_score:.2f})")
assert best_seq == "AGLSP", "should recover the alignment's own consensus"
With pseudocounts, highest-scoring sequence: AGLSP (score 11.43)
# Why pseudocounts matter: a rare residue occurring once can outscore
# a common residue occurring four times, purely because it's rarer overall.
a_count, w_count, n = 4, 1, 5
score_A = math.log2((a_count / n) / background["A"])
score_W = math.log2((w_count / n) / background["W"])
print(f"A (appears in 4/5 sequences):  log-odds = {score_A:.2f}")
print(f"W (appears in 1/5 sequences):  log-odds = {score_W:.2f}")
assert score_W > score_A, "the rare residue should outscore the common one without pseudocounts"
A (appears in 4/5 sequences):  log-odds = 3.43
W (appears in 1/5 sequences):  log-odds = 3.94

A real profile-HMM hit on HBB

Pfam’s profile HMMs, queried live via EBI’s InterPro API, for the same HBB UniProt entry used throughout this course.

r = requests.get("https://www.ebi.ac.uk/interpro/api/entry/pfam/protein/uniprot/P68871/", timeout=10)
r.raise_for_status()
entry = r.json()["results"][0]

meta = entry["metadata"]
loc = entry["proteins"][0]["entry_protein_locations"][0]
frag = loc["fragments"][0]

print("Pfam accession:  ", meta["accession"])
print("Domain name:     ", meta["name"])
print("Matched region:  ", f"residues {frag['start']}-{frag['end']} of {entry['proteins'][0]['protein_length']}")
print("E-value:         ", loc["score"])   # InterPro calls the domain E-value "score"
Pfam accession:   PF00042
Domain name:      Globin
Matched region:   residues 27-142 of 147
E-value:          1.3e-29