Skip to content

Commit 72882c7

Browse files
committed
Add splicing module; classify splicing type for alternative transcripts
1 parent cd2e4fa commit 72882c7

3 files changed

Lines changed: 196 additions & 3 deletions

File tree

src/geneml/outputs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def get_end_coordinate(end_value, offset=offset):
5252
f"{transcript.score:.3f}",
5353
strand,
5454
".",
55-
f"ID={transcript_id};Parent={gene_id}",
55+
f"ID={transcript_id};Parent={gene_id};SplicingType={transcript.splicing_type.name}",
5656
))
5757

5858
# exon records

src/geneml/splicing.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
from geneml.types import SplicingType, Transcript
2+
3+
4+
def get_ordered_introns(exons: list, strand: int) -> list[tuple[int,int]]:
5+
"""
6+
Returns introns (donor, acceptor) in transcriptional order.
7+
8+
Args:
9+
exons: list of Exon objects, sorted by genomic position.
10+
strand: +1 for forward strand, -1 for reverse strand.
11+
12+
Returns:
13+
List of introns as (donor, acceptor) tuples in transcriptional order.
14+
"""
15+
if len(exons) == 1:
16+
return [] # single-exon transcript has no introns
17+
18+
introns = []
19+
for i in range(len(exons) - 1):
20+
# donor = 5' splice site, acceptor = 3' splice site (transcriptional)
21+
if strand == 1:
22+
donor = exons[i].end
23+
acceptor = exons[i + 1].start
24+
elif strand == -1:
25+
donor = exons[i].start
26+
acceptor = exons[i + 1].end
27+
else:
28+
raise ValueError(f"Invalid strand: {strand}")
29+
introns.append((donor, acceptor))
30+
return introns
31+
32+
33+
def get_introns_in_range(introns: list[tuple[int,int]], range: tuple[int,int], strand: int
34+
) -> list[tuple[int,int]]:
35+
"""Returns introns that fall within the specified genomic range.
36+
37+
Args:
38+
introns: List of intron tuples (donor, acceptor) in transcriptional order.
39+
range: Tuple of (start, end) genomic coordinates defining the range.
40+
strand: +1 for forward strand, -1 for reverse strand.
41+
42+
Returns:
43+
List of introns that fall within the specified range.
44+
"""
45+
if strand == 1:
46+
return [(s, e) for s, e in introns if s >= range[0] and e <= range[1]]
47+
elif strand == -1:
48+
return [(s, e) for s, e in introns if s <= range[1] and e >= range[0]]
49+
else:
50+
raise ValueError(f"Invalid strand: {strand}")
51+
52+
53+
def get_alternative_splicing_type(primary: Transcript, alt: Transcript) -> SplicingType:
54+
"""Classify alternative splicing events between a primary and alternative transcript.
55+
56+
Compares intron junctions and terminal exon boundaries to detect exon skipping,
57+
alternative 3' and 5' splice sites, intron retention, and alternative first/last
58+
exons. If multiple event types are detected, the transcript is labeled as
59+
complex.
60+
61+
Args:
62+
primary: The reference transcript to compare against.
63+
alt: The alternative transcript being classified.
64+
65+
Returns:
66+
The assigned SplicingType for the alternative transcript.
67+
"""
68+
assert primary is not alt, "Should not compare to self"
69+
70+
events = set()
71+
strand = primary.strand
72+
73+
# Order by transcriptional order
74+
P = get_ordered_introns(primary.exons, strand)
75+
A = get_ordered_introns(alt.exons, strand)
76+
77+
# Only compare introns within the shared genomic region of the two transcripts
78+
shared_range = (max(primary.start, alt.start), min(primary.end, alt.end))
79+
P = get_introns_in_range(P, shared_range, strand)
80+
A = get_introns_in_range(A, shared_range, strand)
81+
82+
P_set = set(P)
83+
A_set = set(A)
84+
85+
# Track junctions consumed by exon skipping to avoid double-counting them
86+
consumed_P = set()
87+
consumed_A = set()
88+
89+
# 1. ALTERNATIVE FIRST / LAST EXON
90+
if strand == 1:
91+
if alt.exons[0].start != primary.exons[0].start:
92+
events.add(SplicingType.ALT_FIRST_EXON)
93+
if alt.exons[-1].end != primary.exons[-1].end:
94+
events.add(SplicingType.ALT_LAST_EXON)
95+
elif strand == -1:
96+
if alt.exons[0].end != primary.exons[0].end:
97+
events.add(SplicingType.ALT_FIRST_EXON)
98+
if alt.exons[-1].start != primary.exons[-1].start:
99+
events.add(SplicingType.ALT_LAST_EXON)
100+
else:
101+
raise ValueError(f"Invalid strand: {strand}")
102+
103+
# 2. EXON SKIPPING
104+
for s, e in P:
105+
for i in range(len(A) - 1):
106+
s1, e1 = A[i]
107+
s2, e2 = A[i + 1]
108+
109+
if s1 == s and e2 == e:
110+
events.add(SplicingType.EXON_SKIPPING)
111+
consumed_P.add((s, e))
112+
consumed_A.add((s1, e1))
113+
consumed_A.add((s2, e2))
114+
115+
for s, e in A:
116+
for i in range(len(P) - 1):
117+
s1, e1 = P[i]
118+
s2, e2 = P[i + 1]
119+
120+
if s1 == s and e2 == e:
121+
events.add(SplicingType.EXON_SKIPPING)
122+
consumed_A.add((s, e))
123+
consumed_P.add((s1, e1))
124+
consumed_P.add((s2, e2))
125+
126+
# 3. ALTERNATIVE 3' / 5' SPLICE SITES
127+
for s1, e1 in P:
128+
if (s1, e1) in consumed_P:
129+
continue
130+
for s2, e2 in A:
131+
if (s2, e2) in consumed_A:
132+
continue
133+
if s1 == s2 and e1 != e2:
134+
# Skip if this is the terminal exon boundary (already counted as ALT_LAST_EXON)
135+
if (s1, e1) == P[-1] or (s2, e2) == A[-1] and SplicingType.ALT_LAST_EXON in events:
136+
pass
137+
else:
138+
events.add(SplicingType.ALT_3_SPLICE_SITE)
139+
consumed_P.add((s1, e1))
140+
consumed_A.add((s2, e2))
141+
if e1 == e2 and s1 != s2:
142+
events.add(SplicingType.ALT_5_SPLICE_SITE)
143+
consumed_P.add((s1, e1))
144+
consumed_A.add((s2, e2))
145+
146+
# 4. INTRON RETENTION
147+
remaining_P = (P_set - A_set) - consumed_P
148+
remaining_A = (A_set - P_set) - consumed_A
149+
150+
if remaining_P or remaining_A:
151+
events.add(SplicingType.INTRON_RETENTION)
152+
153+
154+
# Assign final splicing type
155+
if not events:
156+
return SplicingType.UNKNOWN
157+
elif len(events) == 1:
158+
return events.pop()
159+
return SplicingType.COMPLEX

src/geneml/types.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from collections import namedtuple
22
from dataclasses import dataclass
3+
from enum import Enum
34

45
import numpy as np
56
from numba import typed, typeof
@@ -16,6 +17,18 @@
1617
GeneCallNumbaType = typeof(typed.List.empty_list(GeneEventNumbaType))
1718

1819

20+
class SplicingType(Enum):
21+
UNKNOWN = 0
22+
PRIMARY = 1
23+
INTRON_RETENTION = 2
24+
EXON_SKIPPING = 3
25+
ALT_FIRST_EXON = 4
26+
ALT_LAST_EXON = 5
27+
ALT_5_SPLICE_SITE = 6
28+
ALT_3_SPLICE_SITE = 7
29+
COMPLEX = 8
30+
31+
1932
@dataclass
2033
class Exon:
2134
start: int
@@ -39,6 +52,7 @@ class Transcript:
3952
exons: tuple[Exon, ...]
4053
group_id: int = -1
4154
transcript_id: str = ""
55+
splicing_type: SplicingType = SplicingType.UNKNOWN
4256

4357
def __post_init__(self):
4458
if not self.exons:
@@ -52,6 +66,18 @@ def __post_init__(self):
5266
def set_transcript_id(self, transcript_id: str):
5367
self.transcript_id = transcript_id
5468

69+
def set_splicing_type(self, splicing_type: SplicingType):
70+
self.splicing_type = splicing_type
71+
72+
def classify_splicing_type(self, primary_transcript: 'Transcript'):
73+
assert primary_transcript.splicing_type == SplicingType.PRIMARY
74+
75+
if self.exons == primary_transcript.exons:
76+
return SplicingType.PRIMARY
77+
78+
from geneml.splicing import get_alternative_splicing_type
79+
return get_alternative_splicing_type(primary_transcript, self)
80+
5581
def overlaps_with(self, other: 'Transcript', ignore_strand: bool = False) -> bool:
5682
# by default only consider overlaps on the same strand
5783
if not ignore_strand and self.strand != other.strand:
@@ -71,9 +97,17 @@ def __post_init__(self):
7197
if not self.transcripts:
7298
raise ValueError('A gene must have at least one transcript.')
7399

74-
for transcript in self.transcripts:
75-
transcript_id = f'{self.gene_id}_mRNA{self.transcripts.index(transcript)+1}'
100+
for i, transcript in enumerate(self.transcripts):
101+
transcript_id = f'{self.gene_id}_mRNA{i+1}'
76102
transcript.set_transcript_id(transcript_id)
77103

78104
if transcript.start < self.start or transcript.end > self.end:
79105
raise ValueError(f'Transcript {transcript} is out of gene bounds: {self.start}, {self.end}.')
106+
107+
# First transcript always denotes the primary splicing type
108+
if i == 0:
109+
transcript.set_splicing_type(SplicingType.PRIMARY)
110+
primary = transcript
111+
else:
112+
splicing_type = transcript.classify_splicing_type(primary)
113+
transcript.set_splicing_type(splicing_type)

0 commit comments

Comments
 (0)