Skip to content

Commit cbbd5fa

Browse files
committed
Fix #169: add three-tier protein-coding biotype ontology
Introduces a layered set of flags for "does this transcript make a polypeptide?": * `is_protein_coding` - unchanged, strict canonical `protein_coding` only. Preserves the contract downstream effect predictors (varcode) depend on. * `is_protein_coding_extended` - widens to IG/TR gene segments and translated pseudogenes (`polymorphic_pseudogene`, `translated_{processed,unprocessed}_pseudogene`). Excludes NMD/NSD because those products are targeted for degradation rather than stable accumulation. * `is_translated` - widest tier. Adds `nonsense_mediated_decay` and `non_stop_decay` on top of the extended set. Useful when picking the top variant effect or doing RNA-seq peptide analysis where ribosome occupancy matters more than stable expression. Set constants `PROTEIN_CODING_BIOTYPES`, `EXTENDED_PROTEIN_CODING_BIOTYPES`, and `TRANSLATED_BIOTYPES` are exposed on `pyensembl.locus_with_genome` so callers can derive their own categorization if needed.
1 parent d25332b commit cbbd5fa

3 files changed

Lines changed: 276 additions & 13 deletions

File tree

pyensembl/locus_with_genome.py

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,54 @@
1414
from .locus import Locus
1515

1616

17+
# Reference: http://www.gencodegenes.org/pages/biotypes.html
18+
#
19+
# Three-tier ontology of "does this transcript make a polypeptide?":
20+
#
21+
# strict ⊂ extended ⊂ translated
22+
#
23+
# Each tier widens the previous one.
24+
25+
# Tier 1 — strict canonical protein-coding biotype only. This is what
26+
# Ensembl uses for the bulk of reference proteome work; varcode and other
27+
# downstream effect predictors expect this set to drive `is_protein_coding`.
28+
PROTEIN_CODING_BIOTYPES = frozenset({"protein_coding"})
29+
30+
# Tier 2 — anything Ensembl/GENCODE marks as producing a stable, functional
31+
# polypeptide:
32+
# * IG_{C,D,J,V}_gene - immunoglobulin gene segments. They produce
33+
# protein after V(D)J recombination; the biotype reflects the productive
34+
# pre-recombination form.
35+
# * TR_{C,D,J,V}_gene - T-cell receptor gene segments, same situation.
36+
# * polymorphic_pseudogene - codes for protein in some individuals,
37+
# pseudogene in others. Genuinely protein-producing where it's coded.
38+
# * translated_processed_pseudogene / translated_unprocessed_pseudogene -
39+
# pseudogenes with ribosome occupancy / mass-spec evidence of being
40+
# translated into a stable product.
41+
#
42+
# Excludes NMD / NSD because those are translated transiently and the
43+
# product is targeted for degradation, not stable accumulation.
44+
EXTENDED_PROTEIN_CODING_BIOTYPES = frozenset(PROTEIN_CODING_BIOTYPES) | frozenset({
45+
"IG_C_gene", "IG_D_gene", "IG_J_gene", "IG_V_gene",
46+
"TR_C_gene", "TR_D_gene", "TR_J_gene", "TR_V_gene",
47+
"polymorphic_pseudogene",
48+
"translated_processed_pseudogene",
49+
"translated_unprocessed_pseudogene",
50+
})
51+
52+
# Tier 3 — every biotype that gets translated on a ribosome, regardless of
53+
# whether the product survives. Adds nonsense_mediated_decay and
54+
# non_stop_decay to the extended set.
55+
#
56+
# Useful when you care about whether a variant lands in a translated frame
57+
# (e.g. picking a top variant effect, RNA-seq peptide analysis) rather
58+
# than whether it perturbs a stably-expressed protein product.
59+
TRANSLATED_BIOTYPES = frozenset(EXTENDED_PROTEIN_CODING_BIOTYPES) | frozenset({
60+
"nonsense_mediated_decay",
61+
"non_stop_decay",
62+
})
63+
64+
1765
class LocusWithGenome(Locus):
1866
"""
1967
Common base class for Gene and Transcript to avoid copying
@@ -39,16 +87,47 @@ def to_dict(self):
3987
@property
4088
def is_protein_coding(self):
4189
"""
42-
We're not counting immunoglobulin-like genes from the T-cell receptor or
43-
or antibodies since they occur in fragments that must be recombined.
44-
It might be worth consider counting non-sense mediated decay and
45-
non-stop decay since variants in these could potentially make a
46-
functional protein. To read more about the biotypes used in Ensembl:
47-
http://vega.sanger.ac.uk/info/about/gene_and_transcript_types.html
48-
http://www.gencodegenes.org/gencode_biotypes.html
49-
50-
For now let's stick with the simple category of 'protein_coding', which
51-
means that there is an open reading frame in this gene/transcript
52-
whose successful transcription has been observed.
90+
True iff this entry's biotype is the canonical ``"protein_coding"``.
91+
Conservative by design - this is what downstream effect predictors
92+
(e.g. varcode) read to decide whether a variant lands in a
93+
translatable transcript. See :attr:`is_protein_coding_extended`
94+
for a wider definition that also covers IG/TR gene segments and
95+
translated pseudogenes, and :attr:`is_translated` for the widest
96+
definition that additionally includes NMD/NSD targets.
97+
"""
98+
return self.biotype in PROTEIN_CODING_BIOTYPES
99+
100+
@property
101+
def is_protein_coding_extended(self):
102+
"""
103+
True for any biotype that makes a stable, functional protein
104+
product:
105+
106+
* ``protein_coding`` (canonical)
107+
* ``IG_{C,D,J,V}_gene`` and ``TR_{C,D,J,V}_gene`` (immunoglobulin
108+
and T-cell receptor gene segments — produce protein after
109+
V(D)J recombination)
110+
* ``polymorphic_pseudogene`` (codes in some individuals)
111+
* ``translated_{processed,unprocessed}_pseudogene`` (pseudogenes
112+
with translation evidence)
113+
114+
Excludes ``nonsense_mediated_decay`` and ``non_stop_decay`` —
115+
those biotypes are translated but the product is targeted for
116+
degradation rather than stable accumulation. Use
117+
:attr:`is_translated` if you want those.
118+
"""
119+
return self.biotype in EXTENDED_PROTEIN_CODING_BIOTYPES
120+
121+
@property
122+
def is_translated(self):
123+
"""
124+
True for any biotype that gets translated on a ribosome, even
125+
transiently. Equivalent to :attr:`is_protein_coding_extended`
126+
plus ``nonsense_mediated_decay`` and ``non_stop_decay``.
127+
128+
Useful when you care about whether a variant lands in a
129+
translated frame at all (e.g. picking a top variant effect,
130+
RNA-seq peptide analysis) rather than whether the encoded
131+
protein product is stably expressed.
53132
"""
54-
return self.biotype == "protein_coding"
133+
return self.biotype in TRANSLATED_BIOTYPES

pyensembl/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "2.10.0"
1+
__version__ = "2.10.1"
22

33
def print_version():
44
print(f"v{__version__}")
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""
2+
Issue #169: three-tier protein-coding biotype ontology.
3+
4+
* ``is_protein_coding`` — strict, the canonical ``"protein_coding"`` only.
5+
Unchanged from previous releases for back-compat with downstream effect
6+
predictors (varcode).
7+
* ``is_protein_coding_extended`` — widens to IG/TR gene segments and
8+
translated pseudogenes. Excludes NMD/NSD because those are degraded.
9+
* ``is_translated`` — widens further to include NMD/NSD; covers any
10+
biotype that goes through the ribosome at all.
11+
"""
12+
13+
from pyensembl.locus_with_genome import (
14+
EXTENDED_PROTEIN_CODING_BIOTYPES,
15+
LocusWithGenome,
16+
PROTEIN_CODING_BIOTYPES,
17+
TRANSLATED_BIOTYPES,
18+
)
19+
20+
from .common import eq_
21+
from .data import (
22+
custom_mouse_genome_grcm38_subset,
23+
setup_init_custom_mouse_genome,
24+
)
25+
26+
27+
class _BiotypeFixture:
28+
"""Stand-in that exposes only ``.biotype`` so we can invoke the three
29+
LocusWithGenome property accessors without constructing a Genome."""
30+
31+
is_protein_coding = LocusWithGenome.is_protein_coding
32+
is_protein_coding_extended = LocusWithGenome.is_protein_coding_extended
33+
is_translated = LocusWithGenome.is_translated
34+
35+
def __init__(self, biotype):
36+
self.biotype = biotype
37+
38+
39+
# -----------------------------
40+
# Set constants
41+
# -----------------------------
42+
43+
44+
def test_biotype_set_ontology_is_monotonic():
45+
"""strict ⊂ extended ⊂ translated."""
46+
assert PROTEIN_CODING_BIOTYPES.issubset(EXTENDED_PROTEIN_CODING_BIOTYPES)
47+
assert EXTENDED_PROTEIN_CODING_BIOTYPES.issubset(TRANSLATED_BIOTYPES)
48+
49+
50+
def test_extended_set_adds_immunoglobulin_and_tcr_and_translated_pseudogenes():
51+
extras = EXTENDED_PROTEIN_CODING_BIOTYPES - PROTEIN_CODING_BIOTYPES
52+
eq_(
53+
extras,
54+
{
55+
"IG_C_gene", "IG_D_gene", "IG_J_gene", "IG_V_gene",
56+
"TR_C_gene", "TR_D_gene", "TR_J_gene", "TR_V_gene",
57+
"polymorphic_pseudogene",
58+
"translated_processed_pseudogene",
59+
"translated_unprocessed_pseudogene",
60+
},
61+
)
62+
63+
64+
def test_translated_set_only_adds_nmd_and_nsd_over_extended():
65+
extras = TRANSLATED_BIOTYPES - EXTENDED_PROTEIN_CODING_BIOTYPES
66+
eq_(extras, {"nonsense_mediated_decay", "non_stop_decay"})
67+
68+
69+
def test_strict_excludes_everything_but_canonical():
70+
eq_(PROTEIN_CODING_BIOTYPES, {"protein_coding"})
71+
72+
73+
# -----------------------------
74+
# Property dispatch
75+
# -----------------------------
76+
77+
78+
def test_canonical_protein_coding_is_true_in_every_tier():
79+
f = _BiotypeFixture("protein_coding")
80+
assert f.is_protein_coding
81+
assert f.is_protein_coding_extended
82+
assert f.is_translated
83+
84+
85+
def test_ig_gene_segments_are_extended_and_translated_but_not_strict():
86+
for biotype in ("IG_C_gene", "IG_D_gene", "IG_J_gene", "IG_V_gene"):
87+
f = _BiotypeFixture(biotype)
88+
assert not f.is_protein_coding, biotype
89+
assert f.is_protein_coding_extended, biotype
90+
assert f.is_translated, biotype
91+
92+
93+
def test_tcr_gene_segments_are_extended_and_translated_but_not_strict():
94+
for biotype in ("TR_C_gene", "TR_D_gene", "TR_J_gene", "TR_V_gene"):
95+
f = _BiotypeFixture(biotype)
96+
assert not f.is_protein_coding, biotype
97+
assert f.is_protein_coding_extended, biotype
98+
assert f.is_translated, biotype
99+
100+
101+
def test_translated_pseudogenes_are_extended_and_translated_but_not_strict():
102+
for biotype in (
103+
"polymorphic_pseudogene",
104+
"translated_processed_pseudogene",
105+
"translated_unprocessed_pseudogene",
106+
):
107+
f = _BiotypeFixture(biotype)
108+
assert not f.is_protein_coding, biotype
109+
assert f.is_protein_coding_extended, biotype
110+
assert f.is_translated, biotype
111+
112+
113+
def test_nmd_and_nsd_are_translated_only():
114+
"""NMD/NSD biotypes are translated by the ribosome but don't yield a
115+
stable protein product, so they're True for `is_translated` and
116+
False for the other two tiers."""
117+
for biotype in ("nonsense_mediated_decay", "non_stop_decay"):
118+
f = _BiotypeFixture(biotype)
119+
assert not f.is_protein_coding, biotype
120+
assert not f.is_protein_coding_extended, biotype
121+
assert f.is_translated, biotype
122+
123+
124+
def test_noncoding_biotypes_are_false_at_every_tier():
125+
"""Sanity: typical non-coding biotypes (lncRNA, miRNA, snRNA, plain
126+
pseudogenes, regulatory) are False across all three tiers."""
127+
for biotype in (
128+
"lncRNA",
129+
"miRNA",
130+
"snRNA",
131+
"snoRNA",
132+
"rRNA",
133+
"processed_pseudogene",
134+
"unprocessed_pseudogene",
135+
"transcribed_processed_pseudogene",
136+
"transcribed_unprocessed_pseudogene",
137+
"transcribed_unitary_pseudogene",
138+
"TEC",
139+
"retained_intron",
140+
"processed_transcript",
141+
"antisense",
142+
):
143+
f = _BiotypeFixture(biotype)
144+
assert not f.is_protein_coding, biotype
145+
assert not f.is_protein_coding_extended, biotype
146+
assert not f.is_translated, biotype
147+
148+
149+
def test_none_biotype_is_false_at_every_tier():
150+
"""Some GTFs (older Ensembl releases, TAIR) don't carry biotype
151+
attributes at all. `biotype=None` must not crash and must be False
152+
across the board."""
153+
f = _BiotypeFixture(None)
154+
assert not f.is_protein_coding
155+
assert not f.is_protein_coding_extended
156+
assert not f.is_translated
157+
158+
159+
# -----------------------------
160+
# Backward compat with existing callers
161+
# -----------------------------
162+
163+
164+
def setup_module(module):
165+
setup_init_custom_mouse_genome()
166+
167+
168+
def test_existing_mouse_protein_coding_transcript_is_strict_protein_coding():
169+
"""The mouse partial fixture has exactly one transcript with
170+
biotype=='protein_coding' and one with biotype=='processed_transcript'.
171+
is_protein_coding must continue to return True for the first and
172+
False for the second; downstream effect prediction depends on this."""
173+
coding = custom_mouse_genome_grcm38_subset.transcripts(biotype="protein_coding")
174+
eq_(len(coding), 1)
175+
assert coding[0].is_protein_coding
176+
assert coding[0].is_protein_coding_extended
177+
assert coding[0].is_translated
178+
179+
all_transcripts = custom_mouse_genome_grcm38_subset.transcripts()
180+
noncoding = [t for t in all_transcripts if t.biotype != "protein_coding"]
181+
eq_(len(noncoding), 1)
182+
assert not noncoding[0].is_protein_coding
183+
assert not noncoding[0].is_protein_coding_extended
184+
assert not noncoding[0].is_translated

0 commit comments

Comments
 (0)