Protein databases¶
A search result tells you which accession a peptide matched. It does not tell you what organism
that accession is, what it does, which gene encodes it, or whether the peptide could have come from
somewhere else. The protein database you searched already knows all four, and
pymzlib.proteins reads them out of it with mzLib:
| You want to know | Call | mzLib |
|---|---|---|
| What organism, taxon, gene and mass is this accession? What are its GO terms? | read() |
ProteinDbLoader, Protein.GoTerms (#1336), Protein.EnsemblGeneReferences |
| Which Ensembl gene is this protein, in a way I can reproduce next year? | resolve_genes() |
EnsemblGeneResolver (#1338) |
| Does this peptide identify one protein, one gene, or neither? | classify_peptides() |
PeptideUniquenessClassifier (#1348) |
| Which GO terms does each protein group carry, with every member kept and the ontology's ancestors filled in? | annotate_go() |
GoGroupAnnotator (#1353), ToGoAnnotationGroups (#1366) |
| Where do I get a go.obo, and how do I keep the release pinned? | update_go() |
Loaders.UpdateGeneOntology (#1353) |
>>> import pymzlib
>>> databases = ["human_subset.xml", "human_extra.fasta", "mouse_aifm1.fasta"]
>>> db = pymzlib.proteins.read(databases, contaminants=["contaminants.fasta"],
... tables=pymzlib.proteins.TABLES)
>>> db.record_count # one row per protein
8
>>> db.taxonomy()["P04406"]
'9606'
Every >>> example on this page runs in CI against output recorded from the real bridge, on the
small databases committed in pyMzLib's test suite (pkg/python/tests/fixtures/proteins/): two real
UniProt XML entries (GAPDH, PSCA), human albumin and three DYNC1I2 isoforms as FASTA, mouse AIFM1
as FASTA, and bovine albumin as a contaminant. The Gene Ontology section adds a real MetaMorpheus
protein-group table and the UniProt entries it names. Blocks titled Not run say why.
Before you start: XML or FASTA?¶
Use the UniProt XML if you have the choice. Both formats give you accession, organism, gene name and sequence. Only the XML carries cross-references, so only the XML has GO terms and Ensembl genes:
UniProt XML (.xml, .xml.gz) |
FASTA (.fasta, .fa, .faa, .fas, .gz) |
|
|---|---|---|
| organism | <organism> |
OS= |
| NCBI taxonomy id | <dbReference type="NCBI Taxonomy"> |
OX= (UniProt headers; absent in older and non-UniProt FASTA) |
| gene names | primary, synonyms, ORF and locus names | GN= only |
| GO terms | ✅ | ❌ |
| Ensembl gene links | ✅ | ❌ |
A FASTA's empty GO table is the format being silent, not a protein with no annotation. pyMzLib
will not let those two look alike: every result lists each input in files, and a FASTA's entry
says so explicitly.
>>> for f in db.files:
... print(f.file_type, f.contaminant, f.absent_fields)
UniProtXml False []
Fasta False ['go_terms', 'ensembl_genes', 'ensembl_gene_ids']
Fasta False ['go_terms', 'ensembl_genes', 'ensembl_gene_ids']
Fasta True ['go_terms', 'ensembl_genes', 'ensembl_gene_ids']
Download the XML from UniProt with format=xml on the same query you would use for FASTA, e.g.
https://rest.uniprot.org/uniprotkb/stream?query=proteome:UP000005640&format=xml&compressed=true.
It is larger (the human reference proteome is a few hundred MB compressed) and takes longer to read,
which is the price of the annotations.
Look up organism and taxon for a list of accessions¶
The problem. MetaMorpheus writes the organism name in its protein-group table, not the NCBI taxonomy id, and a mixed-species search (a host and a pathogen, a xenograft, a spike-in) needs the id to join against anything taxonomic. You have the accessions; the database you searched has the answers.
>>> found = pymzlib.proteins.read(
... databases,
... contaminants=["contaminants.fasta"],
... accessions=["P04406", "Q9Z0X1", "P02769", "P04406-1"],
... )
>>> found.taxonomy()
{'P04406': '9606', 'Q9Z0X1': '10090', 'P02769': '9913'}
>>> found.organisms()["P02769"]
'Bos taurus'
>>> found.accessions_not_found
['P04406-1']
Three things in that output are worth reading twice:
accessions_not_foundis not empty. Matching is exact:P04406-1is notP04406. The filter never guesses which isoform you meant, and a miss is reported rather than dropped. Always check it.- The taxon is a string (
'9606'), because it is an identifier, not a quantity.Nonemeans the entry records none - a FASTA header withoutOX=- never a guessed id. - The bovine albumin came from
contaminants=, so its row hasis_contaminant == True. Marking contaminants matters more for the other two functions than for this one.
The whole protein table is column-major, one list per column:
>>> for row in found.records:
... print(row["accession"], row["organism"], row["primary_gene_name"], row["length"],
... round(row["monoisotopic_mass"], 2), row["is_contaminant"])
P04406 Homo sapiens GAPDH 335 36030.4 False
Q9Z0X1 Mus musculus Aifm1 612 66723.84 False
P02769 Bos taurus ALB 607 69248.44 True
so it goes straight into pandas:
import pandas as pd
proteins = pd.DataFrame(found.columns)
monoisotopic_mass is in daltons, for the unmodified sequence as written - initiator
methionine, signal peptide and propeptide included - so it is the precursor's mass, not the mature
protein's. It is None when the sequence holds a letter with no defined mass (X, B, Z, J).
Many accessions, many databases. The accessions travel on stdin, not the command line, so a list
of 20,000 is fine. Databases are read in the order given; threads=4 reads four at once, and the
result is identical at any thread count - only memory changes, since each database is read whole.
Look up the GO terms a protein was annotated with¶
The problem. You want to know what a protein does, from the annotations UniProt itself records for it - but only those that were not assigned automatically.
GO terms come from the UniProt XML you already have, with no extra download: UniProt ships every
entry's GO annotations as <dbReference type="GO">, with the aspect as a prefix on the term
(C:cytoplasm) and one reference per line of evidence. mzLib (#1336) turns that into one term per
GO id with its evidence unioned, and pyMzLib returns it as a long table:
Here are PSCA's (O43653), from the read above:
>>> psca = [r for r in db.go_terms.records if r["accession"] == "O43653"]
>>> for r in psca:
... print(r["go_id"], r["aspect"], r["term_name"], r["evidence_codes"], r["projects"])
GO:0031225 CellularComponent anchored component of membrane ['ECO:0000501'] ['UniProtKB-KW']
GO:0070062 CellularComponent extracellular exosome ['ECO:0007005'] ['UniProtKB']
GO:0005576 CellularComponent extracellular region ['ECO:0000304'] ['Reactome']
GO:0016020 CellularComponent membrane ['ECO:0000318'] ['GO_Central']
GO:0005886 CellularComponent plasma membrane ['ECO:0000314'] ['HPA']
GO:0033130 MolecularFunction acetylcholine receptor binding ['ECO:0000314'] ['UniProtKB']
GO:0070373 BiologicalProcess negative regulation of ERK1 and ERK2 cascade ['ECO:0000314'] ['UniProtKB']
GO:0099601 BiologicalProcess regulation of neurotransmitter receptor activity ['ECO:0000314'] ['UniProtKB']
Evidence codes are ECO ids, sorted. To drop annotations that
rest only on automatic assertion (ECO:0000501), filter on them; it is a column, not a hidden mode:
>>> automatic = {"ECO:0000501"}
>>> curated = [r for r in psca if set(r["evidence_codes"]) - automatic]
>>> len(psca), len(curated)
(8, 7)
What the table does and does not say:
aspectis"Unknown"when UniProt gave noC:/F:/P:prefix. It is never inferred: an organelle claim built on a guessed aspect is worse than one that admits it does not know.- One row per GO id per protein. A term UniProt repeats with three lines of evidence is one row with three evidence codes, so counting rows counts terms.
- These are the terms as annotated, not propagated. A protein annotated to
plasma membraneis not also listed undermembraneunless UniProt says so. To put a term's ancestors in as well - and to do it for whole protein groups - useannotate_go(), next.
Annotate protein groups with GO, keeping every member¶
The problem. Your search reports protein groups, and a group can have several members: two
histone H2A.Z variants that no peptide could tell apart are one group, P0C0S5|Q71UI9. A GO
enrichment tool wants one set of terms per group. The usual shortcut - take the first accession -
throws away half the evidence, and the first accession is not even a choice MetaMorpheus made: it
sorts members alphabetically and never picks a leading protein. And the terms UniProt records are
the most specific ones, so a group annotated to mitochondrial inner membrane is not, as written,
"in the mitochondrion" at all.
annotate_go() answers both, with mzLib's
GoGroupAnnotator (#1353) reading the protein-group table MetaMorpheus already wrote
(#1366):
- one row per (group, term) that any member holds - directly, or through an ancestor in the
ontology (
is_aandpart_of, nothing weaker); - every row says who carries the term, how directly, and on what evidence, so the union, the consensus and the direct-only views are all filters you apply, not choices made for you;
- every non-decoy group gets at least one row, and a group with no term gets one row that says why.
| Question | Code | mzLib |
|---|---|---|
| Which GO terms does each group carry, members kept apart? | annotate_go(groups, database, go_obo=...) |
GoGroupAnnotator (#1353), ToGoAnnotationGroups (#1366) |
| Which terms do all members share? | rows with n_with == n_members |
the same rows |
| Which organelles, complexes, pathways - in my vocabulary? | category_map= |
GoCategoryResolver |
| Which GO release did I use, exactly? | go.go.sha256, go.header |
GeneOntologyGraph |
| How do I get a go.obo, once, on purpose? | update_go("go.obo") |
Loaders.UpdateGeneOntology |
Every example in this section is executed when the docs are built, against output recorded from the real bridge, so what you see is what pyMzLib returns. The data is real: mzLib's own copy of a MetaMorpheus 1.1.11 protein-group table from PXD036557 (six groups: tubulin alpha-1B, ADP/ATP translocase 2, the H2A.Z pair, 14-3-3 zeta, bovine serum albumin as a contaminant, and one decoy), the five human UniProt entries it names, cut whole from UniProt's reviewed human proteome, and GO release 2026-07-26. The go.obo committed for the tests is trimmed to the 412 terms these proteins reach; against the full 48,340-term release the same call gives the same 563 rows.
First, get a go.obo - once, and keep it¶
Terms are added, renamed and moved between GO releases, so a GO result means something only
relative to one release. annotate_go() therefore never downloads anything: it reads the
go.obo you name. Fetching one is its own function, which you call on purpose:
>>> update = pymzlib.proteins.update_go("go.obo")
>>> update.go.release, update.go.term_count, update.changed
('releases/2026-07-26', 48340, True)
update_go() streams the whole file (about 37 MB) from GO's PURL, which always serves the current
release. Call it again later and a newer release replaces the file, while the old one is kept beside
it as go.obo.<timestamp>, so a result you already published can still be reproduced. Keep the
go.obo with your project, the way you keep the FASTA you searched.
Annotate the groups¶
Give it the protein-group table, the UniProt XML the search used, and the go.obo:
>>> go = pymzlib.proteins.annotate_go(
... "PXD036557_AllQuantifiedProteinGroups.tsv", "pxd036557_proteins.xml",
... go_obo="go-pxd036557.obo", category_map="organelle_map.tsv")
>>> go.table_row_count, go.decoy_group_count, go.group_count, go.row_count
(6, 1, 5, 563)
Six rows in the table, one of them a decoy, which is skipped because decoys carry no GO: five groups,
563 rows. Any MetaMorpheus protein-group table works - AllQuantifiedProteinGroups.tsv,
AllProteinGroups.tsv from a search without quantification, or one file's _ProteinGroups.tsv.
Hand go.columns to pandas.DataFrame for a data frame; go.records gives one dict per row.
Read one row¶
Both histones are annotated to the nucleosome, each on its own evidence:
>>> nucleosome = next(r for r in go.records
... if r["protein_group"] == "P0C0S5|Q71UI9" and r["go_id"] == "GO:0000786")
>>> nucleosome["go_name"], nucleosome["aspect"], nucleosome["n_with"], nucleosome["n_members"]
('nucleosome', 'cellular_component', 2, 2)
>>> nucleosome["accession_used"], nucleosome["evidence_by_member"]
(['P0C0S5', 'Q71UI9'], {'P0C0S5': ['ECO:0000353'], 'Q71UI9': ['ECO:0000353']})
>>> nucleosome["propagated"], nucleosome["inherited"]
(False, False)
| column | what it tells you |
|---|---|
accession_used |
the members carrying the term - directly or through a more specific one |
accession_direct |
of those, the members annotated to this exact term |
n_with of n_members |
how many members carry it; n_with == n_members is the consensus |
evidence, evidence_by_member |
ECO codes, pooled and per member |
propagated |
True: no member is annotated to this term; it is implied by a more specific one |
inherited |
True: the members' terms were borrowed - an isoform (P04406-2) or a sequence variant (P04406_A20T) the database does not annotate takes its entry's terms |
entrapment_members |
the group's entrapment members, on every row of the group, so you decide whether they count |
q_value |
the group's q-value, from the table - never filtered on |
The column names are mzLib's own GoAnnotationTsv schema, so this table, the file out= writes
and the file mzLib writes inside a search all mean the same thing.
Union, consensus, direct only: filters, not modes¶
The table is the union over a group's members. Everything narrower is one comparison:
>>> histones = [r for r in go.records if r["protein_group"] == "P0C0S5|Q71UI9"]
>>> union = {r["go_id"] for r in histones}
>>> consensus = {r["go_id"] for r in histones if r["n_with"] == r["n_members"]}
>>> len(union), len(consensus)
(106, 57)
>>> euchromatin = next(r for r in histones if r["go_name"] == "euchromatin")
>>> euchromatin["accession_used"]
['P0C0S5']
Of the 106 terms either H2A.Z variant carries, 57 are carried by both. Euchromatin is not one of
them: only P0C0S5 is annotated to it. Whether that should count for the group is a scientific
question, which is why the table answers it per member rather than deciding.
Propagation is what makes broad questions answerable. 14-3-3 zeta has 205 rows, and only 42 of them are terms UniProt annotates directly; the other 163 are their ancestors:
>>> zeta = [r for r in go.records if r["protein_group"] == "P63104"]
>>> len(zeta), sum(r["propagated"] is False for r in zeta)
(205, 42)
Keep propagated in mind before an enrichment test: most tools propagate terms themselves, and
feeding them already-propagated rows counts every ancestor twice. Give such a tool the rows with
propagated is False.
A group with no term still gets a row¶
Bovine serum albumin is in the table as a contaminant. It gets exactly one row, with no term, and a status that says why:
>>> [(r["protein_group"], r["annotation_status"]) for r in go.records if r["go_id"] is None]
[('P02769', 'contaminant')]
annotation_status |
means |
|---|---|
annotated |
the row has a term (every row with a go_id is annotated) |
no_go_terms |
the group's members are in the database, but none has a GO annotation |
no_entry |
a member is not in the database at all - annotate against the database the search used |
contaminant |
the group is a contaminant and none of its members has a term |
So a group never silently disappears, and no annotation is never confused with not looked up.
Count groups, not rows¶
go.header is the provenance header mzLib's writer puts at the top of the file, read back. Its
counters count groups, and only groups at q ≤ 0.01:
>>> go.header["counter_q_value_max"]
'0.01'
>>> {k: go.header[k] for k in ("status_annotated", "status_no_go_terms", "status_no_entry", "status_contaminant")}
{'status_annotated': '4', 'status_no_go_terms': '0', 'status_no_entry': '0', 'status_contaminant': '1'}
The rows themselves are never filtered by q-value: every group is in the table, and you choose the cut-off.
Put terms into your own categories¶
GO has tens of thousands of terms; a figure usually wants a handful of categories. mzLib ships no vocabulary - the categories are your science - but it applies yours with one rule: a term belongs to a category when one of the category's anchors is the term itself or one of its ancestors, and within a category the most specific anchor wins. A map is a small tab-separated file:
#!category_map_format 1
#!map_name organelle
#!map_version 1
category subcategory anchor_go_id
nucleus GO:0005634
nucleus chromatin GO:0000785
mitochondrion GO:0005739
mitochondrion inner_membrane GO:0005743
cytoskeleton GO:0005856
cytoskeleton microtubule GO:0005874
cytosol GO:0005829
plasma_membrane GO:0005886
extracellular GO:0005576
The categories come back as their own table, joined to the annotations on go_id:
>>> go.categories.map_name, go.categories.anchor_count, go.categories.row_count
('organelle', 9, 30)
>>> inner = next(c for c in go.categories.records if c["go_id"] == "GO:0005743")
>>> inner["category"], inner["subcategory"]
('mitochondrion', 'mitochondrion:inner_membrane')
>>> place = {}
>>> for c in go.categories.records:
... place.setdefault(c["go_id"], set()).add(c["category"])
>>> where = {}
>>> for r in go.records:
... for category in place.get(r["go_id"], ()):
... where.setdefault(r["protein_group"], set()).add(category)
>>> sorted(where["P05141"])
['cytoskeleton', 'mitochondrion', 'nucleus', 'plasma_membrane']
A term under none of your anchors has no category row, so absence means "outside your map", not
"unknown". The map's name, version and sha256 travel with the result (go.categories.sha256): a
version is a claim, the hash is proof two runs used the same map.
A large run: write the file, return the summary¶
A real search annotates hundreds of groups into tens of thousands of rows: the full MetaMorpheus table from the same PXD036557 search gave 715 target groups and 87,513 rows, in about 20 s, against the whole human proteome (#1353). Write them to a file with mzLib's own writer and bring back only the summary:
>>> big = pymzlib.proteins.annotate_go(
... "PXD036557_AllQuantifiedProteinGroups.tsv", "pxd036557_proteins.xml",
... go_obo="go-pxd036557.obo", out="go_annotations.tsv", limit=0)
>>> big.written.path, big.written.row_count, big.returned_count
('go_annotations.tsv', 563, 0)
limit and offset shape only what comes back; the file always gets every row. It is a
tab-separated table whose header records what it was computed from:
#!go_annotation_format 1
#!mzlib_version 1.0.0+0a808fec346e6e8f334e455490faab463ea65457
#!mzlib_release none
#!go_release releases/2026-07-26
#!go_obo_sha256 c1cdfd098c5cf395ce4bbefdea017fd698fdf22d135b28623e74f6f714e1c5fa
#!annotation_db_sha256 484e16a9a2fa5f4754f90d904f9030621c6ce3257488fd39fdff9b4fb8a0b5db
#!source_file_sha256 751a00c5109bdf350db75d2d4053e0fd78fea8326c5a38bce95b1ce0d8445ae6
#!counter_q_value_max 0.01
#!n_multi_member_groups 1
#!status_annotated 4
#!status_no_go_terms 0
#!status_no_entry 0
#!status_contaminant 1
protein_group accession_used accession_direct ... go_id go_name aspect ...
mzlib_release reads none because the bridge builds mzLib from source; mzlib_version names the
exact commit. out= must end in .tsv, and is checked before anything is read, so a typo costs
milliseconds, not a proteome load.
When the database is newer than the ontology¶
UniProt is released more often than you will move your go.obo, so sooner or later the database
cites a GO id your release does not have. By default that fails, naming every missing id,
because a silently dropped term is a silently wrong answer. If you would rather keep the run,
pass skip_unknown_go_ids=True: each such id is dropped, listed in go.unresolved_go_ids, counted
in the file header as unresolved_go_ids, and described in go.caveats. A protein whose only terms
were dropped then reads no_go_terms. The better fix is usually a newer go.obo.
What it will not do, and says so¶
- One annotation database. Every row carries the sha256 of the database its terms came from, so
two databases have no honest single value. Use the UniProt XML of the proteome you searched. A
FASTA is refused outright: it carries no GO, and every group would read
no_go_termswhatever the proteins are. - No hidden download. A missing go.obo is an error that tells you to run
update_go(). - No filtering. Not by q-value, not by evidence, not by member. Every filter is a column.
- Obsolete terms are kept if your database still cites them; they have no ancestors in the release, so they never propagate.
To reproduce a GO result, record three hashes with it: go.go.sha256 (the ontology),
go.annotation_database.sha256 (the database) and go.groups_file_sha256 (the table). The file
out= writes already carries all three.
Resolve proteins to Ensembl genes, reproducibly¶
The problem. You want gene-level results - to join with RNA-seq, to count genes, to compare
with last year's analysis - and "the gene" has to mean the same thing each time. A gene symbol
cannot do that: symbols are renamed, one symbol can name several loci, and the Gene column of a
protein-group table is only the first name per protein. A stable Ensembl gene id counted against
one Ensembl release can.
resolve_genes() reads the Ensembl links the UniProt XML carries for each protein and counts them
against a gene set you supply: an Ensembl GTF for one release.
>>> genes = pymzlib.proteins.resolve_genes(
... ["human_subset.xml", "human_extra.fasta"],
... contaminants=["contaminants.fasta"],
... gtf="Homo_sapiens.GRCh38.116.gtf", # yours: the release's .gtf.gz
... xref="Homo_sapiens.GRCh38.116.uniprot.tsv", # optional second opinion
... )
>>> genes.gene_set.release, genes.gene_set.genome_build
('116', 'GRCh38.p14')
>>> genes.outcome_counts # doctest: +NORMALIZE_WHITESPACE
{'resolved': 1, 'multi_gene': 0, 'off_primary_only': 1, 'not_in_source': 4,
'unrecognized_accession': 0, 'contaminant_not_mapped': 1}
>>> for r in genes.records:
... print(r["accession"], r["outcome"], r["gene_id"], r["gene_symbol"], r["ensembl_xref_agrees"])
P04406 resolved ENSG00000111640 GAPDH True
O43653 off_primary_only None None None
P02768 not_in_source None None None
Q13409 not_in_source None None None
Q13409-2 not_in_source None None None
Q13409-3 not_in_source None None None
P02769 contaminant_not_mapped None None None
(The example's gene set is a three-gene test GTF, which is why PSCA's gene is "off the assembly".)
Every protein gets exactly one outcome¶
None in gene_id can mean five different things, so the table never leaves it at None:
| outcome | what happened | what to do |
|---|---|---|
resolved |
exactly one gene on the gene set's assembly | use gene_id |
multi_gene |
several genes on the assembly: one row per gene, never a pick | decide per question; n_genes says how many |
off_primary_only |
the database links only genes outside the gene set (ALT haplotypes, patches) | the source has an answer, just not on the assembly everything else is counted against |
not_in_source |
a well-formed accession the database links to no gene | for a FASTA, always - see below |
unrecognized_accession |
not a UniProt or RefSeq accession (a decoy or entrapment prefix, a mangled id) | a data problem, not a biology result |
contaminant_not_mapped |
from a contaminants= database: never mapped, never dropped |
nothing - that is the point |
Why you supply the GTF, and why you pin it¶
Nothing is downloaded or defaulted. Which release you resolve against is part of the answer: between releases, genes are merged, split and retired, versions increment, symbols change, and the set of genes on the primary assembly moves. A resolution with no release attached cannot be reproduced or compared.
- Use the primary-assembly GTF,
Homo_sapiens.GRCh38.<release>.gtf.gzfromhttps://ftp.ensembl.org/pub/release-<release>/gtf/homo_sapiens/- not thechr_patch_hapl_scafffile. UniProt links a protein to every Ensembl transcript that encodes it, alternative haplotypes included, where one locus is described many times. Counting those as separate genes made human release 116 look 6.99% multi-gene when it is 0.36%. - Keep Ensembl's file name. The release is read from it (
.116.gtf.gz→"116"); a renamed file givesgene_set_release == Noneand a caveat. - Record the hashes. Every row carries
search_database_sha256(of the decompressed database, so.xmland.xml.gzagree),gene_set_sha256, andensembl_xref_sha256. Together they name exactly the inputs a row was computed from. - A GTF is large (human 116: 141 MB compressed, 4.66 GB unzipped) and only its gene rows are
read. If you resolve often, write the gene rows once with mzLib's
EnsemblGeneSetWriter(about 0.5 MB) and pass that asgene_set=instead. It carries the GTF's provenance, so rows are keyed exactly as against the GTF.
The optional Ensembl cross-reference¶
UniProt and Ensembl each map accessions to genes, and they are expected to disagree for real
reasons (readthrough genes, identical paralogs). Passing Ensembl's own
Homo_sapiens.GRCh38.<release>.uniprot.tsv.gz as xref= keeps both answers:
ensembl_xref_agrees says per row whether Ensembl links the same pair, ensembl_xref_info_type
gives Ensembl's evidence (DIRECT, SEQUENCE_MATCH, INFERRED_PAIR), and a gene only Ensembl
links gets a row of its own with source == "ensembl_xref". Those rows never change a protein's
outcome. Without xref=, agreement is None - unknown, not false.
A FASTA cannot resolve¶
A FASTA header has no Ensembl links, so every FASTA protein is not_in_source, whatever Ensembl
knows about it. The result's caveats counts them. Resolve against the UniProt XML of the same
proteome - the accessions are the same, so you can resolve a FASTA search's results that way.
Decide whether a peptide is unique, with I = L¶
The problem. Protein inference, isoform claims and "is this contamination?" all start from the same question: could this peptide have come from anything else in the search space?
>>> calls = pymzlib.proteins.classify_peptides(
... ["VGVNGFGR", "LVLNGNPLTLFQER", "ALSEQINIFFDYSGR", "YLYEIAR", "AEFVEVTK", "PEPTIDEK"],
... ["human_subset.xml", "human_extra.fasta"],
... contaminants=["contaminants.fasta"],
... )
>>> for r in calls.records:
... print(r["peptide"], r["sharing"], r["accessions"])
VGVNGFGR Unique ['P04406']
LVLNGNPLTLFQER Unique ['P04406']
ALSEQINIFFDYSGR SharedWithinGene ['Q13409', 'Q13409-2', 'Q13409-3']
YLYEIAR SharedAcrossGenes ['P02768', 'P02769']
AEFVEVTK Unique ['P02769']
PEPTIDEK NotInDatabase []
>>> calls.records[2]["shared_gene_keys"]
['entry:Q13409', 'gene:Homo sapiens:DYNC1I2']
Reading it row by row:
LVLNGNPLTLFQERis GAPDH'sLVINGNPITIFQER. I and L have the same mass, so a mass spectrometer cannot tell them apart and neither does the classifier: everyIis matched asL. Thepeptidecolumn echoes what you passed, unfolded.calls.i_and_l_equivalentis alwaysTrue, so the rule travels with the result.ALSEQINIFFDYSGRis in all three DYNC1I2 isoforms. It supports the gene, not any one isoform. An isoform-level claim needs aUniquepeptide.YLYEIARis in human and bovine albumin. It is evidence for albumin, but not for human albumin: on its own it cannot distinguish a sample protein from BSA contamination.AEFVEVTK,Uniqueto bovine albumin, points at the contaminant. This is whycontaminants=matters: leave the contaminant database out andYLYEIARwould be calledUniqueto human albumin.
The rules, exactly¶
- Containment, not digestion. A protein contains a peptide if its sequence contains it
anywhere, whatever the protease. That is deliberately conservative: a peptide is
Uniqueonly if no other sequence contains it at all, so an isoform claim cannot be undone by an entry at a site your search's cleavage rules happened to skip. - Identical sequences are one sequence. Two accessions with the same sequence cannot be told
apart by any peptide, so a peptide in both is
Uniqueand lists both accessions. - "Same gene" means sharing a gene key: an Ensembl gene id (
ensembl:, from the XML), organism plus primary gene name (gene:Homo sapiens:GAPDH- so human and bovine ALB stay apart), or the UniProt entry (entry:, soP12345-2meetsP12345). A FASTA isoform and its XML entry meet on the entry key. - Decoys are ignored (
decoy_proteins_ignoredcounts them); contaminants are included. - Base sequences only, upper case. Strip modifications first: mzLib refuses
PEPT[Phospho]IDEKandpeptidekwith aUsageErrornaming the peptide, rather than guess what you meant.
Every database is part of one search space, so classify_peptides has no on_error="skip": if a
database fails to load, the call fails, because carrying on would report the peptides only that
database contains as unique.
Many databases, threads and failures¶
All three functions take one database or a list, and contaminants= as a separate list. They are
read in one bridge process, in order: databases first, then contaminants, and every table's
source_index column points into files in that order.
| Argument | Default | Meaning |
|---|---|---|
threads |
1 |
Databases read at once; -1 means every core. The result is byte-identical at any value - this is a memory choice, since each database is read whole. |
on_error |
"fail" |
"fail" raises on the first unreadable database in input order; "skip" records it in files[i].error and reads the rest (read and resolve_genes only). |
Results come back as JSON tables only: read has no out= option, because its three tables
would need three files.
mzLib's loader reads each database exactly as a search would, except that no decoys are
generated. A decoy already in the file (an accession starting DECOY) is kept, with
is_decoy == True. A UniProt XML that records a genotype (a Spritz-style variant database) still
has those variants applied, which renames the accession - P38936_C117Y - and changes its length
and mass; that file's caveats say how many.
Errors¶
| Raised | When |
|---|---|
UsageError |
no database; a blank or repeated path; a file that is not .xml or a FASTA extension; a missing file (under on_error="fail"); an unknown table; threads of 0 or below -1; an empty accessions list; neither or both of gtf / gene_set; a missing GTF or xref; a peptide that is not upper-case A-Z |
BridgeError |
mzLib could not parse a database, a GTF gene row has no gene_id, or an xref table's columns are not Ensembl's layout |
For annotate_go() and update_go():
| Raised | When |
|---|---|
UsageError |
a missing groups table, database, go.obo or category map; a FASTA database; an out or categories_out that is not .tsv, names an input, or sits in a folder that does not exist; categories_out without category_map; a negative limit or offset - all before any file is read. For update_go(), a folder that does not exist |
BridgeError |
the database cites a GO id the release lacks (type InvalidDataException, every id named) unless skip_unknown_go_ids=True; a go.obo, category map or table mzLib cannot parse; a group written twice with different members. For update_go(), GO's server is unreachable (type ServiceUnavailable: retry later) |
Cite¶
If this guide's results go into a paper, cite mzLib (see Citing) and the resources the answers come from:
- doi:10.1093/nar/gkaf1239: Ensembl releases, GTFs and cross-references (Ensembl 2026, NAR)
- doi:10.1093/nar/gkae1010: UniProt, whose XML dbReferences carry the gene links resolved (UniProt Consortium 2025, NAR)
- doi:10.1093/genetics/iyad031: The Gene Ontology knowledgebase in 2023 (GO Consortium, Genetics 224, iyad031), the ontology and annotations used
- doi:10.1038/75556: Gene Ontology: tool for the unification of biology (Ashburner et al., Nat Genet 25, 25-29, 2000), the is_a / part_of graph propagated over
The same verbs are specified once, language-neutrally, for pyMzLib, mzLibRust and mzLibR:
proteins read, genes resolve, proteins classify-peptides, proteins annotate-go and
proteins update-go. See the
wire-verb reference for every parameter and field with its unit, and the
API reference for the Python classes.