Applied Proteomics
When to Use
- Interpreting an MS/MS spectrum's b/y ion ladder or checking a peptide-spectrum match by hand
- Simulating in-silico tryptic digestion (with missed cleavages) and matching masses via peptide mass fingerprinting (PMF)
- Quantifying label-free (LFQ) or TMT protein abundance between two conditions and building volcano/MA plots
- Computing PTM mass shifts (phosphorylation, oxidation, acetylation) on identified peptides
- Resolving protein inference when peptides map to multiple protein entries (razor peptide / parsimony)
Version Compatibility
- Python ≥3.10, NumPy ≥1.26, pandas ≥2.2, Matplotlib ≥3.8, SciPy ≥1.12
- Applies to bottom-up (shotgun) LC-MS/MS workflows (Orbitrap/Q-TOF, DDA); concepts map onto Mascot/MaxQuant/MSFragger/Comet search output
Prerequisites
pip install numpy pandas matplotlib scipy- Familiarity with amino acid chemistry and basic mass spectrometry (m/z, charge states)
- Related skills:
bio-proteomics-peptide-identification,bio-proteomics-quantification
MS/MS Fragmentation: b/y Ions
Goal: derive peptide and fragment-ion masses from a sequence to interpret or simulate an MS/MS spectrum. Approach: sum monoisotopic residue masses; b ions are N-terminal fragments (+ proton, no water), y ions are C-terminal fragments (+ water + proton).
import numpy as np
AA_MONO_MASS = {
'A': 71.03711, 'R': 156.10111, 'N': 114.04293, 'D': 115.02694,
'C': 103.00919, 'E': 129.04259, 'Q': 128.05858, 'G': 57.02146,
'H': 137.05891, 'I': 113.08406, 'L': 113.08406, 'K': 128.09496,
'M': 131.04049, 'F': 147.06841, 'P': 97.05276, 'S': 87.03203,
'T': 101.04768, 'W': 186.07931, 'Y': 163.06333, 'V': 99.06841,
}
H2O = 18.01056
H = 1.00728 # proton mass
def peptide_mass(seq: str) -> float:
"""Neutral monoisotopic mass of a peptide (sum of residues + one water)."""
return sum(AA_MONO_MASS[aa] for aa in seq) + H2O
def by_ions(seq: str) -> tuple[list[float], list[float]]:
"""Singly-charged b (N-term) and y (C-term) ion m/z for every cleavage site."""
n = len(seq)
b_ions, y_ions = [], []
for i in range(1, n):
b_ions.append(sum(AA_MONO_MASS[seq[j]] for j in range(i)) + H)
y_ions.append(sum(AA_MONO_MASS[seq[j]] for j in range(i, n)) + H2O + H)
return b_ions, y_ions
peptide = "ACDEFGHIK"
b, y = by_ions(peptide)
print(f"[M+H]+ = {peptide_mass(peptide) + H:.4f}")
for i, (bi, yi) in enumerate(zip(b, y), 1):
print(f"b{i}={bi:.4f} y{len(peptide) - i}={yi:.4f}")
Trypsin Digestion and Peptide Mass Fingerprinting
Goal: identify a protein from a set of observed peptide masses without MS/MS (PMF), or generate the candidate peptide list for a database search. Approach: cleave after K/R unless followed by P; compute theoretical masses for every candidate protein and count matches within a ppm tolerance.
import numpy as np
def trypsin_digest(sequence: str, missed_cleavages: int = 0) -> list[str]:
"""In-silico trypsin digestion: cleaves after K/R unless followed by P."""
seq = sequence.upper()
sites = [0]
for i in range(len(seq) - 1):
if seq[i] in ('K', 'R') and seq[i + 1] != 'P':
sites.append(i + 1)
sites.append(len(seq))
fragments = [seq[sites[i]:sites[i + 1]] for i in range(len(sites) - 1)]
fragments = [f for f in fragments if f]
if missed_cleavages == 0:
return fragments
result = list(fragments)
for mc in range(1, missed_cleavages + 1):
for i in range(len(fragments) - mc):
result.append(''.join(fragments[i:i + mc + 1]))
return sorted(set(result), key=lambda x: sequence.index(x))
def simple_pmf_search(
observed_masses: list[float],
database: dict[str, str],
tolerance_ppm: float = 20.0,
missed_cleavages: int = 1,
) -> list[tuple[str, int, float]]:
"""Match observed masses against theoretical tryptic peptides per protein.
Returns (protein_name, n_matched, fraction_matched) sorted by n_matched desc.
"""
results = []
obs = np.array(sorted(observed_masses))
for name, seq in database.items():
theo_peptides = trypsin_digest(seq, missed_cleavages)
theo_masses = np.array(sorted(
peptide_mass(p) for p in theo_peptides if all(aa in AA_MONO_MASS for aa in p)
))
matched = sum(1 for m in obs if np.any(np.abs(theo_masses - m) <= m * tolerance_ppm * 1e-6))
results.append((name, matched, matched / len(obs) if obs.size else 0.0))
return sorted(results, key=lambda x: -x[1])
PTM Mass Shifts
Goal: account for post-translational modifications when computing a modified peptide's mass. Approach: add the modification's monoisotopic delta mass at the modified residue(s); common shifts below.
PTM_MASS_SHIFT = {
'phospho': 79.9663, # S/T/Y
'oxidation': 15.9949, # M (variable mod)
'acetylation': 42.0106, # protein N-term or K
'carbamidomethyl': 57.0215, # C (fixed mod after alkylation with iodoacetamide)
'deamidation': 0.9840, # N/Q
}
def modified_peptide_mass(seq: str, mods: dict[int, str]) -> float:
"""Neutral mass of a peptide with PTMs.
mods: {0-based residue index: modification name in PTM_MASS_SHIFT}.
"""
base = peptide_mass(seq)
return base + sum(PTM_MASS_SHIFT[m] for m in mods.values())
print(f"{modified_peptide_mass('ACSDEFGHIK', {2: 'phospho'}):.4f} Da") # phosphopeptide: pS at position 2
Protein Inference (Razor Peptides)
Goal: assign shared (non-unique) peptides to a single "razor" protein instead of double-counting them across all matching entries. Approach: greedy parsimony — process peptides with the fewest candidate proteins first, assign each to whichever candidate already has the most peptide evidence.
from collections import Counter
def infer_proteins_razor(peptide_to_proteins: dict[str, set[str]]) -> dict[str, str]:
"""Simplified MaxQuant-style razor-peptide protein inference.
Returns {peptide: assigned_protein}.
"""
protein_counts = Counter()
for prots in peptide_to_proteins.values():
for p in prots:
protein_counts[p] += 1
assignment = {}
for pep, prots in sorted(peptide_to_proteins.items(), key=lambda kv: len(kv[1])):
assignment[pep] = max(prots, key=lambda p: protein_counts[p])
return assignment
Label-Free Quantification and Volcano Plot
Goal: compare protein abundance between two conditions from replicate LFQ log2 intensities. Approach: median-normalize each sample, compute per-protein log2 fold change, and test significance with Welch's t-test across replicates.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
def median_normalize(log2_intensities: np.ndarray) -> np.ndarray:
"""Median-center each sample's log2 intensities (columns = replicates)."""
return log2_intensities - np.nanmedian(log2_intensities, axis=0)
def lfq_differential_abundance(intensities_a: np.ndarray, intensities_b: np.ndarray) -> pd.DataFrame:
"""Per-protein log2FC + Welch's t-test p-value between two conditions.
intensities_a/b: (n_proteins, n_replicates) log2-transformed intensity arrays.
"""
log2fc = np.nanmean(intensities_b, axis=1) - np.nanmean(intensities_a, axis=1)
_, pvals = stats.ttest_ind(intensities_b, intensities_a, axis=1, nan_policy='omit', equal_var=False)
pvals = np.nan_to_num(pvals, nan=1.0)
return pd.DataFrame({'log2fc': log2fc, 'pvalue': pvals, 'neg_log10p': -np.log10(pvals)})
np.random.seed(7)
n_proteins, n_reps = 200, 3
base = np.random.normal(27, 3, (n_proteins, 1))
cond_a = median_normalize(base + np.random.normal(0, 0.3, (n_proteins, n_reps)))
fc_true = np.zeros((n_proteins, 1))
fc_true[np.random.choice(n_proteins, 20, replace=False)] = np.random.uniform(1, 3, (20, 1)) * np.random.choice([-1, 1], (20, 1))
cond_b = median_normalize(base + fc_true + np.random.normal(0, 0.3, (n_proteins, n_reps)))
de = lfq_differential_abundance(cond_a, cond_b)
sig = (de.log2fc.abs() > 1) & (de.pvalue < 0.05)
plt.scatter(de.log2fc, de.neg_log10p, c=np.where(sig, 'tomato', 'steelblue'), alpha=0.6, s=20)
plt.axvline(1, ls='--', c='gray'); plt.axvline(-1, ls='--', c='gray'); plt.axhline(1.3, ls='--', c='gray')
plt.xlabel('log2 fold change'); plt.ylabel('-log10(p-value)'); plt.title(f'{sig.sum()} DE proteins')
plt.tight_layout(); plt.show()
Pitfalls
- Trypsin does not cleave before proline — skipping this rule generates false theoretical peptides and misses real ones.
- b ions carry no C-terminal OH (only + proton); confusing b/y mass formulas is the most common manual-calculation bug.
- PMF alone cannot resolve complex mixtures (multiple proteins per sample) — use LC-MS/MS with target-decoy FDR filtering instead.
- Carbamidomethylation on cysteine is a fixed modification after standard iodoacetamide alkylation; forgetting it shifts every Cys-containing peptide by 57.02 Da.
- PTM site localization is ambiguous when a peptide has multiple candidate S/T/Y residues — needs a localization score (AScore, PTM score), not just the mass shift.
- Naive protein inference (assigning a shared peptide to every matching protein) inflates protein-group counts; use razor/parsimony logic.
- Missing values in LFQ intensity matrices should be handled with proper imputation (e.g. left-censored/MNAR-aware methods), not zero-filling, before differential testing.
See Also
bio-proteomics-peptide-identificationbio-proteomics-quantificationbio-proteomics-ptm-analysisbio-proteomics-protein-inference