Skip to content

Commit a9b78a8

Browse files
Merge pull request #14 from scbirlab/dev
Add PairedMSA.split and more robust species parsing
2 parents f6f7991 + b24bb23 commit a9b78a8

8 files changed

Lines changed: 108 additions & 54 deletions

File tree

test/scripts/test-af2.sh

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,10 @@ python -c '
2222
from yunta.structs.msa import MSA
2323
2424
def make_stub(f):
25-
msa = MSA.from_file(f)
25+
msa = MSA.from_file(f).truncate(20)
2626
# Write a cropped version — first 60 columns
2727
with open(f.split(".")[0] + "_stub.a3m", "w") as f:
28-
for i, line in enumerate(msa.lines):
29-
line.sequence = line.sequence[:20]
30-
print(str(line), file=f)
31-
if i > 40:
32-
break
28+
msa.write(f)
3329
3430
make_stub("test/inputs/DYR_YEAST.a3m")
3531
make_stub("test/inputs/CAPZA_YEAST.a3m")

yunta/interactions/af2/modelling.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def make_model_runner(
3131
print_err(f"[INFO] Setting up AlphaFold2 model. XLA platform available: {xla_bridge.get_backend().platform}")
3232

3333
if model_name is None:
34-
model_name = 'model_1'
34+
model_name = 'model_1_ptm'
3535
if param_dir is None:
3636
param_dir = get_model_weights(model_name)
3737

yunta/interactions/af2/run.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def post_af2(
129129
(i0, j0, n, m), (contact_block, plddt_block) = next(iter(results.items()))
130130
contact_dist = contact_block
131131
plddt = plddt_block["plddt"]
132+
ptm = plddt_block.get("ptm")
132133
else:
133134
n_msa_columns = paired_msa.seq_length
134135
contact_dist = np.zeros(
@@ -149,10 +150,12 @@ def post_af2(
149150
contact_dist[si, sj] = contact_block[:n, n:]
150151
contact_dist[sj, si] = contact_block[n:, :n]
151152
plddt[sj] = plddt_block[n:]
153+
ptm = None
152154
contact_dist_interaction = contact_dist[:paired_msa.chain_a_length, paired_msa.chain_a_length:]
153155
return _post_score_ppi(
154156
contact_dist_interaction,
155157
plddt,
158+
ptm,
156159
chain_a_length=paired_msa.chain_a_length,
157160
contact_radius=8.,
158161
)

yunta/interactions/af2/scoring.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def get_contact_matrix(unrelaxed_protein) -> Tuple[ndarray, ...]:
4444
def _post_score_ppi(
4545
contact_dists: ArrayLike,
4646
plddt: ArrayLike,
47+
ptm: float,
4748
chain_a_length: int,
4849
contact_radius: float = 8.
4950
):
@@ -53,6 +54,7 @@ def _post_score_ppi(
5354
"n_contacts": n_contacts,
5455
"mean_interface_plddt": 0.,
5556
"pdockq": 0.,
57+
"ptm": ptm,
5658
}
5759
if n_contacts >= 1: # no contacts
5860
#Get plddt per chain

yunta/interactions/runner.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,6 @@ def post_run(
290290
return {"apc": apc}
291291

292292

293-
294293
class RF2TRunner(Runner):
295294

296295
metric_container = RF2TMetrics

yunta/structs/metrics.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,5 +95,6 @@ class AF2Metrics(InteractionMetrics):
9595
n_contacts: int = field(metadata={"named": True})
9696
mean_interface_plddt: float = field(metadata={"named": True})
9797
pdockq: float = field(metadata={"named": True})
98+
ptm: float = field(metadata={"named": True})
9899
seed: int = field(metadata={"named": True})
99100
# max_recycles: int = field(metadata={"named": True})

yunta/structs/msa.py

Lines changed: 98 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Data structures for multiple sequence alignments."""
22

3-
from typing import Iterable, List, Mapping, Tuple, Optional, Union
3+
from collections.abc import Iterable, Mapping
44
from copy import deepcopy
5-
from dataclasses import asdict, dataclass, field, fields
5+
from dataclasses import asdict, dataclass, field, fields, replace
66
from io import TextIOWrapper
77
from itertools import dropwhile, product
88
import sys
@@ -34,29 +34,40 @@ class MSAName:
3434
>>> MSAName('>sp|P07807|DYR_YEAST').entry_name
3535
'DYR_YEAST'
3636
>>> MSAName('>UniRef90_A0A1B2').unique_id
37-
'__NO_ENTRY_ID__'
37+
'A0A1B2'
38+
>>> MSAName('>UniRef90_A0A1B2').database
39+
'UniRef90'
40+
>>> MSAName('>MGYP000745883360').unique_id
41+
'MGYP000745883360'
3842
3943
"""
4044
name: str
41-
input_name: str = field(init=False)
4245
database: str = field(init=False)
4346
unique_id: str = field(init=False)
4447
entry_name: str = field(init=False)
4548

4649
def __post_init__(self):
4750
if not isinstance(self.name, str):
4851
try:
49-
self.input_name = "".join(self.name)
52+
self._input_name = "".join(self.name)
5053
except TypeError:
5154
raise TypeError(f"MSA name `{self.name}` is type {type(self.name)}.")
5255
else:
53-
self.input_name = self.name
54-
self.name = "".join(dropwhile(lambda s: s == ">", self.name)).rstrip() # Strip out leading ">"
55-
try:
56-
self.database, self.unique_id, self.entry_name = self.name.split("|")
57-
except ValueError:
58-
# print_err(self.name)
59-
self.database, self.unique_id, self.entry_name = "__NO_NAME__", "__NO_ENTRY_ID__", "__NO_ENTRY_NAME__"
56+
self._input_name = self.name
57+
self.name = self._input_name.removeprefix(">").rstrip() # Strip out leading ">"
58+
db, _id, _name = None, None, None
59+
if "|" in self.name:
60+
parts = self.name.split("|", maxsplit=2)
61+
if len(parts) == 3:
62+
db, _id, _name = parts
63+
elif self.name.startswith("UniRef"):
64+
parts = self.name.split("_", maxsplit=1)
65+
db = parts[0]
66+
_id = parts[1] if len(parts) == 2 else None
67+
68+
self.database = db or "__NO_DB_NAME__"
69+
self.unique_id = _id or self.name or "__NO_ENTRY_ID__"
70+
self.entry_name = _name or _id or self.name or "__NO_ENTRY_NAME__"
6071

6172
def __str__(self) -> str:
6273
return self.name
@@ -87,7 +98,7 @@ class MSADescription:
8798
description: str
8899
species_id: str = field(init=False)
89100
prefix: str = field(init=False)
90-
info: Mapping[str, Union[int, str]] = field(init=False)
101+
info: Mapping[str, int | str] = field(init=False)
91102
_verbose: bool = False
92103

93104
def __post_init__(self):
@@ -107,25 +118,32 @@ def __post_init__(self):
107118
val = int(val)
108119
info[key] = val
109120
self.info = info
110-
if "OX" in self.info: # NCBI identifier. Doesn't exist for everything
111-
species_id = f"NCBI:{self.info['OX']}"
112-
elif "TaxID" in self.info:
113-
species_id = f"NCBI:{self.info['TaxID']}"
121+
taxon_id = self.info.get("OX", self.info.get("TaxID")) or -1
122+
if taxon_id > -1: # NCBI identifier. Doesn't exist for everything
123+
if isinstance(taxon_id, str) and taxon_id.isdigit():
124+
self.taxon_id = int(taxon_id)
125+
species_id = f"NCBI:{taxon_id}"
114126
elif "OS" in self.info: # UniProt species name fallback
115127
species_id = f"Name:{self.info['OS']}"
116128
else:
117129
species_id = -1
118130
if self.description != '__BLOCK_GAPS__' and self._verbose:
119131
print_err(f"[WARN] MSA has no species info. Description string: {self.description.rstrip()}")
132+
self.taxon_id = taxon_id
120133
self.species_id = species_id
134+
121135
if species_id == -1:
122-
self.generic_species_name = None
136+
self.generic_species_name = None
123137
else:
124-
normed_name = _name_normalizer([self.info.get('OS', '')])
125-
try:
126-
self.generic_species_name = normed_name[0]
127-
except IndexError:
128-
self.generic_species_name = self.info['OS']
138+
os_val = self.info.get('OS', '')
139+
if os_val:
140+
normed_name = _name_normalizer([os_val])
141+
try:
142+
self.generic_species_name = normed_name[0]
143+
except IndexError:
144+
self.generic_species_name = os_val
145+
else:
146+
self.generic_species_name = None
129147

130148
def __str__(self) -> str:
131149
return self.description
@@ -160,7 +178,8 @@ class MSALine:
160178
gap_fraction: float = field(init=False)
161179

162180
def __post_init__(self):
163-
self.sequence = ''.join(letter for letter in self.sequence if not letter.islower()) # remove insertions(?)
181+
self._input_sequence = self.sequence
182+
self.sequence = ''.join(letter for letter in self._input_sequence if not letter.islower()) # remove insertions(?)
164183
self.name = MSAName(self.name)
165184
self.unique_id = self.name.unique_id
166185
self.entry_name = self.name.entry_name
@@ -174,12 +193,13 @@ def __repr__(self) -> str:
174193
return f"MSALine(name='{self.name}', length={len(self)})"
175194

176195
def __str__(self) -> str:
177-
return f">{str(self.name)} {str(self.description)}\n{self.sequence}"
196+
return f">{str(self.name)} {str(self.description)}\n{self._input_sequence}"
178197

179198

180199
class PairedMSALine(MSALine):
181200

182201
def __post_init__(self):
202+
self._input_sequence = self.sequence
183203
if not _PAIRED_SPACER in self.name:
184204
raise ValueError(f"Paired MSA must contain '{_PAIRED_SPACER}' separator in name: {self.name}")
185205
self.name = tuple(MSAName(name) for name in self.name.split(_PAIRED_SPACER))
@@ -192,7 +212,7 @@ def __repr__(self) -> str:
192212
return "Paired " + super().__repr__()
193213

194214
def __str__(self) -> str:
195-
return f">{_PAIRED_SPACER.join(map(str, self.name))} {_PAIRED_SPACER.join(map(str, self.description))}\n{self.sequence}"
215+
return f">{_PAIRED_SPACER.join(map(str, self.name))} {_PAIRED_SPACER.join(map(str, self.description))}\n{self._input_sequence}"
196216

197217

198218
@dataclass
@@ -224,14 +244,24 @@ def __post_init__(self):
224244
for line in self.lines
225245
]
226246

227-
def sequences(self) -> List[str]:
247+
def sequences(self) -> list[str]:
228248
return [line.sequence for line in self.lines]
229249

230-
def gap_fraction(self) -> List[float]:
250+
def gap_fraction(self) -> list[float]:
231251
return [line.gap_fraction for line in self.lines]
232252

253+
def truncate(self, n: int) -> 'MSA':
254+
return replace(self, lines=[
255+
MSALine(
256+
sequence=line.sequence[:n],
257+
description=str(line.description),
258+
name=str(line.name),
259+
)
260+
for line in self.lines
261+
])
262+
233263
@classmethod
234-
def from_file(cls, file: Union[str, TextIOWrapper]) -> 'MSA':
264+
def from_file(cls, file: str | TextIOWrapper) -> 'MSA':
235265
from bioino import FastaCollection
236266
collection = list(FastaCollection.from_file(file).sequences)
237267
# print(collection[0])
@@ -311,23 +341,43 @@ def write(self, file=sys.stdout) -> None:
311341
return None
312342

313343

344+
@dataclass
314345
class PairedMSA(MSA):
315-
316346
"""Paired MSA object which can be used for co-evolutionary analyses.
317347
"""
348+
chain_a_length: int
349+
chain_b_length: int = field(init=False)
318350

319-
def __init__(self,
320-
chain_a_length: int,
321-
*args, **kwargs):
322-
super().__init__(*args, **kwargs)
323-
self.chain_a_length = chain_a_length
351+
def __post_init__(self):
352+
super().__post_init__()
324353
self.chain_b_length = self.seq_length - self.chain_a_length
325354

355+
def split(
356+
self
357+
):
358+
msa1 = MSA([
359+
MSALine(
360+
sequence=line.sequence[:self.chain_a_length],
361+
description=str(line.description[0]),
362+
name=str(line.name[0]),
363+
)
364+
for line in self.lines
365+
])
366+
msa2 = MSA([
367+
MSALine(
368+
sequence=line.sequence[self.chain_a_length:],
369+
description=str(line.description[1]),
370+
name=str(line.name[1]),
371+
)
372+
for line in self.lines
373+
])
374+
return msa1, msa2
375+
326376
@staticmethod
327377
def _check_ref_match(
328378
msa1: MSA,
329379
msa2: MSA,
330-
interaction_map: Optional[Mapping[str, Iterable[str]]] = None,
380+
interaction_map: Mapping[str, Iterable[str]] | None = None,
331381
name_attr: str = "species_id"
332382
) -> None:
333383
"""Validate that the reference sequences (first lines) of two MSAs
@@ -381,13 +431,13 @@ def _check_ref_match(
381431
@staticmethod
382432
def join_msa(
383433
msa1: MSA,
384-
msa2: Optional[MSA] = None,
434+
msa2: MSA | None = None,
385435
blocked: bool = False,
386-
interaction_map: Optional[Union[str, Mapping[str, Iterable[str]]]] = None,
436+
interaction_map: str | Mapping[str, Iterable[str]] | None = None,
387437
strict_species_match: bool = False,
388438
enforce_ref_match: bool = False,
389439
name_attr: str = "species_id"
390-
) -> Tuple[List[PairedMSALine], int]:
440+
) -> tuple[list[PairedMSALine], int]:
391441
if strict_species_match or interaction_map is None:
392442
fallback_name_attr = name_attr
393443
else:
@@ -530,7 +580,10 @@ def join_msa(
530580
)
531581
] for lines in (msa1.lines, msa2.lines)
532582
)
533-
msa1, msa2 = (msa._filter_by_index(idx) for idx, msa in zip((idx1, idx2), (msa1, msa2)))
583+
msa1, msa2 = (
584+
msa._filter_by_index(idx)
585+
for idx, msa in zip((idx1, idx2), (msa1, msa2))
586+
)
534587
msa_lines += PairedMSA.__make_blocked(msa1, msa2)
535588

536589
return msa_lines, msa1.seq_length
@@ -540,7 +593,7 @@ def __make_blocked(
540593
msa1: MSA,
541594
msa2: MSA,
542595
gap_char: str = '-'
543-
) -> List[PairedMSALine]:
596+
) -> list[PairedMSALine]:
544597
gaps1, gaps2 = (gap_char * msa.seq_length for msa in (msa1, msa2))
545598
# The msas must be str representations of the blocked+paired MSAs here
546599
block1 = [
@@ -563,9 +616,9 @@ def __make_blocked(
563616
def from_msa(
564617
cls,
565618
msa1: MSA,
566-
msa2: Optional[MSA] = None,
619+
msa2: MSA | None = None,
567620
blocked: bool = False,
568-
interaction_map: Optional[Union[str, Mapping[str, Iterable[str]]]] = None,
621+
interaction_map: str | Mapping[str, Iterable[str]] | None = None,
569622
strict_species_match: bool = False,
570623
enforce_ref_match: bool = False,
571624
**kwargs
@@ -587,8 +640,8 @@ def from_msa(
587640
@classmethod
588641
def from_file(
589642
cls,
590-
file1: Union[str, TextIOWrapper],
591-
file2: Optional[Union[str, TextIOWrapper]] = None,
643+
file1: str | TextIOWrapper,
644+
file2: str | TextIOWrapper | None = None,
592645
blocked: bool = False,
593646
**kwargs
594647
) -> 'PairedMSA':

yunta/weights.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def get_model_weights(
2424
if path is None:
2525
path = os.path.expanduser("~")
2626
if model_name is None:
27-
model_name = "model_1"
27+
model_name = "model_1_ptm" #"model_1"
2828

2929
weight_dir = os.path.join(os.path.realpath(path), ".weights", "af2-yunta", "params")
3030
weight_filename = os.path.join(weight_dir, f"params_{model_name}.npz")

0 commit comments

Comments
 (0)