Gene rollup for proteomics mode (Point 6) — accession -> real gene symbol.
THE LOAD-BEARING STEP. TrEMBL isoform entries frequently carry GN=
(no real gene symbol), so a search engine counts each accession as a distinct
"gene" — inflating gene counts and breaking protein grouping (e.g. three
Ceruloplasmin isoforms A8K5A4/B7Z5Q2/A5PL27 should ALL roll up to CP).
Method = peptide-voting (sequence-grounded, not name-guessing):
1. Digest every canonical SwissProt protein (GN= reliable) into tryptic peptides.
Build {peptide -> gene} keeping ONLY peptides discriminative for a single gene.
2. For a query protein, digest it, tally votes from discriminative peptides,
assign the plurality gene. Deterministic tie-break: more votes, then more
total index peptides for that gene, then lexicographic.
3. Fallbacks when no peptide votes: exact protein-name match against canonical
name->gene; else leave the existing GN= (flagged unmapped).
Why peptide-voting: a TrEMBL isoform is near-identical to its canonical protein,
so the overwhelming majority of its tryptic peptides are gene-unique and vote the
correct gene. Paralog/shared regions are excluded automatically (non-discriminative
peptides never enter the index). No external idmapping file needed.
Deterministic: same inputs -> same output. No randomness.
is_ig
is_ig(header: str) -> bool
Test whether the header matches the configured immunoglobulin pattern.
Source code in fasta_lake/proteomics/grouping.py
| def is_ig(header: str) -> bool:
"""Test whether the header matches the configured immunoglobulin pattern."""
return bool(_IG_RE.search(header))
|
iter_fasta
iter_fasta(path: str | Path)
Yield (header_without_gt, sequence).
Source code in fasta_lake/proteomics/grouping.py
| def iter_fasta(path: str | Path):
"""Yield (header_without_gt, sequence)."""
h, buf = None, []
with open(path) as f:
for line in f:
if line.startswith(">"):
if h is not None:
yield h, "".join(buf)
h, buf = line[1:].rstrip("\n"), []
else:
buf.append(line.strip())
if h is not None:
yield h, "".join(buf)
|
parse_gene
parse_gene(header: str) -> str | None
Return the first GN= gene token, or None when the header has none.
Source code in fasta_lake/proteomics/grouping.py
| def parse_gene(header: str) -> str | None:
"""Return the first GN= gene token, or None when the header has none."""
m = _GN_RE.search(header)
return m.group(1) if m else None
|
parse_protein_name
parse_protein_name(header: str) -> str | None
Protein description between the id token and the first KEY= tag.
e.g. 'sp|P00450|CERU_HUMAN Ceruloplasmin OS=Homo sapiens ...' -> 'Ceruloplasmin'
Source code in fasta_lake/proteomics/grouping.py
| def parse_protein_name(header: str) -> str | None:
"""Protein description between the id token and the first KEY= tag.
e.g. 'sp|P00450|CERU_HUMAN Ceruloplasmin OS=Homo sapiens ...' -> 'Ceruloplasmin'
"""
parts = header.split(None, 1)
if len(parts) < 2:
return None
rest = parts[1]
rest = re.split(r"\s+[A-Z][A-Za-z]*=", rest, maxsplit=1)[0]
return rest.strip() or None
|
is_accession_gene
is_accession_gene(gene: str | None) -> bool
True if GN= is actually a UniProt accession (i.e. a fake gene).
Source code in fasta_lake/proteomics/grouping.py
| def is_accession_gene(gene: str | None) -> bool:
"""True if GN= is actually a UniProt accession (i.e. a fake gene)."""
return bool(gene) and bool(_ACC_RE.match(gene))
|
tryptic_peptides
tryptic_peptides(seq: str, min_len: int = 7, max_len: int = 40, max_missed: int = 1) -> set[str]
Cut after K/R (no proline rule — matches the run's --cut 'K*,R*'). I/L folded.
I/L folding (both -> 'I') makes voting robust to isoleucine/leucine ambiguity,
which MS cannot distinguish anyway.
Source code in fasta_lake/proteomics/grouping.py
| def tryptic_peptides(
seq: str, min_len: int = 7, max_len: int = 40, max_missed: int = 1
) -> set[str]:
"""Cut after K/R (no proline rule — matches the run's --cut 'K*,R*'). I/L folded.
I/L folding (both -> 'I') makes voting robust to isoleucine/leucine ambiguity,
which MS cannot distinguish anyway.
"""
seq = seq.replace("L", "I")
# cut sites: after every K or R
cuts = [0]
for i, aa in enumerate(seq):
if aa in "KR":
cuts.append(i + 1)
if cuts[-1] != len(seq):
cuts.append(len(seq))
peps: set[str] = set()
n = len(cuts) - 1
for i in range(n):
for m in range(max_missed + 1):
j = i + 1 + m
if j > n:
break
pep = seq[cuts[i] : cuts[j]]
if min_len <= len(pep) <= max_len:
peps.add(pep)
return peps
|
build_gene_index
build_gene_index(canonical_fasta: str | Path, min_len: int = 7, max_missed: int = 1) -> GeneIndex
Build the discriminative peptide->gene index from canonical SwissProt.
Source code in fasta_lake/proteomics/grouping.py
| def build_gene_index(
canonical_fasta: str | Path, min_len: int = 7, max_missed: int = 1
) -> GeneIndex:
"""Build the discriminative peptide->gene index from canonical SwissProt."""
pep_genes: dict[str, set[str]] = defaultdict(set)
name_genes: dict[str, set[str]] = defaultdict(set)
for header, seq in iter_fasta(canonical_fasta):
gene = parse_gene(header)
if not gene or is_accession_gene(gene):
continue # only trust real symbols as ground truth
for pep in tryptic_peptides(seq, min_len=min_len, max_missed=max_missed):
pep_genes[pep].add(gene)
name = parse_protein_name(header)
if name:
name_genes[_norm_name(name)].add(gene)
pep2gene = {p: next(iter(g)) for p, g in pep_genes.items() if len(g) == 1}
name2gene = {n: next(iter(g)) for n, g in name_genes.items() if len(g) == 1}
gene_pep_count: Counter = Counter(pep2gene.values())
return GeneIndex(pep2gene, name2gene, dict(gene_pep_count))
|
assign_gene
assign_gene(seq: str, header: str, idx: GeneIndex, min_len: int = 7, max_missed: int = 1, ambig_ratio: float = 0.5) -> tuple[str | None, str]
Return (gene, method).
method in {peptide_vote, peptide_vote_ambiguous, ig_repertoire, name_match,
kept_existing, unmapped}. For ig_repertoire / unmapped, gene is None (NOT
counted as a real gene — prevents accession-as-gene inflation; Ig is tracked
as its own repertoire class).
Source code in fasta_lake/proteomics/grouping.py
| def assign_gene(
seq: str,
header: str,
idx: GeneIndex,
min_len: int = 7,
max_missed: int = 1,
ambig_ratio: float = 0.5,
) -> tuple[str | None, str]:
"""Return (gene, method).
method in {peptide_vote, peptide_vote_ambiguous, ig_repertoire, name_match,
kept_existing, unmapped}. For ig_repertoire / unmapped, gene is None (NOT
counted as a real gene — prevents accession-as-gene inflation; Ig is tracked
as its own repertoire class).
"""
votes: Counter = Counter()
for pep in tryptic_peptides(seq, min_len=min_len, max_missed=max_missed):
g = idx.pep2gene.get(pep)
if g:
votes[g] += 1
if votes:
ranked = sorted(
votes.items(), key=lambda kv: (-kv[1], -idx.gene_pep_count.get(kv[0], 0), kv[0])
)
winner, top = ranked[0]
# paralog ambiguity: runner-up wins a comparable share of votes (e.g. C2 vs CFB)
if len(ranked) > 1 and ranked[1][1] >= ambig_ratio * top:
return winner, "peptide_vote_ambiguous"
return winner, "peptide_vote"
# No discriminative votes. Immunoglobulin variable regions land here legitimately —
# the somatic repertoire isn't in canonical SwissProt with a standard gene symbol.
if is_ig(header):
return None, "ig_repertoire"
name = parse_protein_name(header)
if name:
g = idx.name2gene.get(_norm_name(name))
if g:
return g, "name_match"
existing = parse_gene(header)
if existing and not is_accession_gene(existing):
return existing, "kept_existing"
return None, "unmapped"
|
heal_fasta_genes
heal_fasta_genes(input_fasta: str | Path, canonical_fasta: str | Path, output_fasta: str | Path, mapping_tsv: str | Path | None = None, min_len: int = 7, max_missed: int = 1) -> dict
Rewrite GN= in input_fasta with peptide-voted real gene symbols. Returns stats.
Source code in fasta_lake/proteomics/grouping.py
| def heal_fasta_genes(
input_fasta: str | Path,
canonical_fasta: str | Path,
output_fasta: str | Path,
mapping_tsv: str | Path | None = None,
min_len: int = 7,
max_missed: int = 1,
) -> dict:
"""Rewrite GN= in input_fasta with peptide-voted real gene symbols. Returns stats."""
idx = build_gene_index(canonical_fasta, min_len=min_len, max_missed=max_missed)
stats: Counter = Counter()
rows = []
with open(output_fasta, "w") as out:
for header, seq in iter_fasta(input_fasta):
old = parse_gene(header)
gene, method = assign_gene(seq, header, idx, min_len=min_len, max_missed=max_missed)
stats[method] += 1
if gene:
new_header = _set_gene(header, gene)
changed = old != gene
stats["changed"] += int(changed)
else:
new_header = header
out.write(f">{new_header}\n{seq}\n")
acc = header.split("|")[1] if header.startswith(("sp|", "tr|")) else header.split()[0]
rows.append((acc, old or "", gene or "", method))
if mapping_tsv:
with open(mapping_tsv, "w") as m:
m.write("accession\told_gene\tnew_gene\tmethod\n")
for r in rows:
m.write("\t".join(r) + "\n")
stats["total"] = sum(
stats[k]
for k in (
"peptide_vote",
"peptide_vote_ambiguous",
"ig_repertoire",
"name_match",
"kept_existing",
"unmapped",
)
)
stats["index_peptides"] = len(idx.pep2gene)
stats["index_genes"] = len(idx.gene_pep_count)
return dict(stats)
|