Skip to content

Commit a1b0870

Browse files
committed
Merge upstream CatPred updates
2 parents 8554ecb + b314a28 commit a1b0870

23 files changed

Lines changed: 2651 additions & 1621 deletions

catpred/args.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,8 @@ class PredictArgs(CommonArgs):
943943
"""Deprecated. Whether to calculate the variance of ensembles as a measure of epistemic uncertainty. If True, the variance is saved as an additional column for each target in the preds_path."""
944944
individual_ensemble_predictions: bool = False
945945
"""Whether to return the predictions made by each of the individual models rather than the average of the ensemble"""
946+
save_uncertainty_components: bool = False
947+
"""Whether to save aleatoric and epistemic uncertainty variance components when available."""
946948
# Uncertainty arguments
947949
uncertainty_method: Literal[
948950
'mve',

catpred/data/cache_utils.py

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,37 @@ def md5_hash_fn(s):
2828
encoded = s.encode('utf-8')
2929
return hashlib.md5(encoded).hexdigest()
3030

31+
32+
def cache_entry_path(path='', cache_key=None, hash_fn=md5_hash_fn, name=None):
33+
(CACHE_PATH / path).mkdir(parents=True, exist_ok=True)
34+
if name is None:
35+
key = hash_fn(cache_key)
36+
else:
37+
key = name
38+
return CACHE_PATH / path / f'{key}.pt'
39+
40+
41+
def load_cache_value(path='', cache_key=None, *, purpose="cache entry", hash_fn=md5_hash_fn, name=None, map_location=None):
42+
entry_path = cache_entry_path(path=path, cache_key=cache_key, hash_fn=hash_fn, name=name)
43+
if not entry_path.exists():
44+
return None
45+
46+
log(f'cache hit: fetching {cache_key} from {str(entry_path)}')
47+
return load_torch_artifact(
48+
str(entry_path),
49+
purpose=purpose,
50+
map_location=map_location,
51+
roots=[CACHE_PATH],
52+
)
53+
54+
55+
def save_cache_value(value, path='', cache_key=None, *, hash_fn=md5_hash_fn, name=None):
56+
entry_path = cache_entry_path(path=path, cache_key=cache_key, hash_fn=hash_fn, name=name)
57+
log(f'saving: {cache_key} to {str(entry_path)}')
58+
cache_value = value.detach().cpu() if isinstance(value, torch.Tensor) else value
59+
torch.save(cache_value, str(entry_path))
60+
return entry_path
61+
3162
# run once function
3263

3364
GLOBAL_RUN_RECORDS = dict()
@@ -83,26 +114,20 @@ def inner(t, *args, __cache_key = None, **kwargs):
83114
if clear:
84115
clear_cache_folder_()
85116

86-
if name is None:
87-
cache_str = __cache_key if exists(__cache_key) else t
88-
key = hash_fn(cache_str)
89-
else:
90-
key = name
91-
92-
entry_path = CACHE_PATH / path / f'{key}.pt'
93-
94-
if entry_path.exists():
95-
log(f'cache hit: fetching {t} from {str(entry_path)}')
96-
return load_torch_artifact(
97-
str(entry_path),
98-
purpose="esm cache entry",
99-
roots=[CACHE_PATH],
100-
)
117+
cache_str = __cache_key if exists(__cache_key) else t
118+
cached = load_cache_value(
119+
path=path,
120+
cache_key=cache_str,
121+
purpose="esm cache entry",
122+
hash_fn=hash_fn,
123+
name=name,
124+
)
125+
if cached is not None:
126+
return cached
101127

102128
out = fn(t, *args, **kwargs)
103129

104-
log(f'saving: {t} to {str(entry_path)}')
105-
torch.save(out, str(entry_path))
130+
save_cache_value(out, path=path, cache_key=cache_str, hash_fn=hash_fn, name=name)
106131
return out
107132

108133
return inner

catpred/data/esm_utils.py

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from functools import partial
44
import esm
55
from torch.nn.utils.rnn import pad_sequence
6-
from .cache_utils import cache_fn, run_once
6+
from .cache_utils import cache_fn, load_cache_value, run_once, save_cache_value
77

88
def exists(val):
99
return val is not None
@@ -63,6 +63,11 @@ def calc_protein_representations_with_subunits(proteins, get_repr_fn, *, device)
6363

6464
ESM_MAX_LENGTH = 2048
6565
ESM_EMBED_DIM = 1280
66+
ESM_CACHE_PATH = 'esm/proteins'
67+
DEFAULT_ESM_BATCH_SIZE = max(
68+
int(os.getenv("CATPRED_ESM_BATCH_SIZE", "1" if PROTEIN_EMBED_USE_CPU else "4")),
69+
1,
70+
)
6671

6772
INT_TO_AA_STR_MAP = {
6873
0: '<cls>',
@@ -154,14 +159,94 @@ def get_single_esm_repr(protein_str):
154159
representation = token_representations[0][1 : len(protein_str) + 1]
155160
return representation
156161

162+
163+
def _run_esm_batch(protein_strs):
164+
init_esm()
165+
model, batch_converter = GLOBAL_VARIABLES['model']
166+
167+
data = [(f'protein_{index}', protein_str) for index, protein_str in enumerate(protein_strs)]
168+
batch_labels, batch_strs, batch_tokens = batch_converter(data)
169+
170+
if batch_tokens.shape[1] > ESM_MAX_LENGTH:
171+
print(f'warning max length protein esm')
172+
173+
batch_tokens = batch_tokens[:, :ESM_MAX_LENGTH]
174+
175+
if not PROTEIN_EMBED_USE_CPU:
176+
batch_tokens = batch_tokens.to(next(model.parameters()).device)
177+
178+
with torch.no_grad():
179+
results = model(batch_tokens, repr_layers=[33])
180+
181+
token_representations = results['representations'][33]
182+
representations = []
183+
max_residue_tokens = ESM_MAX_LENGTH - 1
184+
for index, protein_str in enumerate(protein_strs):
185+
representation_length = min(len(protein_str), max_residue_tokens)
186+
representations.append(
187+
token_representations[index][1 : representation_length + 1].detach()
188+
)
189+
return representations
190+
191+
192+
def _run_esm_batch_with_fallback(protein_strs):
193+
try:
194+
return _run_esm_batch(protein_strs)
195+
except RuntimeError as e:
196+
if 'out of memory' not in str(e) or len(protein_strs) == 1:
197+
raise e
198+
print('| WARNING: ran out of memory, retrying smaller ESM batches')
199+
if torch.cuda.is_available():
200+
torch.cuda.empty_cache()
201+
midpoint = len(protein_strs) // 2
202+
return (
203+
_run_esm_batch_with_fallback(protein_strs[:midpoint])
204+
+ _run_esm_batch_with_fallback(protein_strs[midpoint:])
205+
)
206+
207+
208+
def get_many_esm_reprs(proteins, device='cpu', batch_size=None):
209+
if isinstance(proteins, torch.Tensor):
210+
proteins = tensor_to_aa_str(proteins)
211+
212+
batch_size = max(int(batch_size or DEFAULT_ESM_BATCH_SIZE), 1)
213+
ordered_unique_proteins = list(dict.fromkeys(proteins))
214+
representations_by_sequence = {}
215+
uncached_proteins = []
216+
217+
for protein_str in ordered_unique_proteins:
218+
cached = load_cache_value(
219+
path=ESM_CACHE_PATH,
220+
cache_key=protein_str,
221+
purpose="esm cache entry",
222+
map_location='cpu',
223+
)
224+
if cached is None:
225+
uncached_proteins.append(protein_str)
226+
else:
227+
representations_by_sequence[protein_str] = cached
228+
229+
for start in range(0, len(uncached_proteins), batch_size):
230+
batch = uncached_proteins[start : start + batch_size]
231+
batch_representations = _run_esm_batch_with_fallback(batch)
232+
for protein_str, representation in zip(batch, batch_representations):
233+
save_cache_value(representation, path=ESM_CACHE_PATH, cache_key=protein_str)
234+
representations_by_sequence[protein_str] = representation
235+
236+
return {
237+
protein_str: representations_by_sequence[protein_str].to(device)
238+
for protein_str in ordered_unique_proteins
239+
}
240+
241+
157242
def get_esm_repr(proteins, name, device):
158243
if isinstance(proteins, torch.Tensor):
159244
proteins = tensor_to_aa_str(proteins)
160245

161246
# Cache by sequence content to avoid collisions when different proteins
162247
# are accidentally given the same pdb/name identifier.
163248
_ = name
164-
get_protein_repr_fn = cache_fn(get_single_esm_repr, path='esm/proteins')
249+
get_protein_repr_fn = cache_fn(get_single_esm_repr, path=ESM_CACHE_PATH)
165250

166251
return calc_protein_representations_with_subunits([proteins], get_protein_repr_fn, device=device)
167252

@@ -208,6 +293,7 @@ def get_esm_tokens(protein_str, device):
208293
'esm': {
209294
'dim': ESM_EMBED_DIM,
210295
'fn': get_esm_repr,
296+
'batch_fn': get_many_esm_reprs,
211297
'tokenizer': get_esm_tokens,
212298
}
213299
}

catpred/data/utils.py

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,30 @@ def _load_protein_records(protein_records_path: str):
5858
"Expected a JSON mapping in .json or .json.gz format."
5959
) from plain_err
6060

61+
62+
def _populate_missing_esm2_features(
63+
protein_records,
64+
sequence_feat_getter,
65+
sequence_batch_getter=None):
66+
if not protein_records:
67+
return
68+
69+
unique_sequences = list(dict.fromkeys(record['seq'] for record in protein_records))
70+
if sequence_batch_getter is not None:
71+
features_by_sequence = sequence_batch_getter(unique_sequences, device='cpu')
72+
for record in protein_records:
73+
record['esm2_feats'] = features_by_sequence[record['seq']]
74+
return
75+
76+
features_by_sequence = {}
77+
for record in protein_records:
78+
sequence = record['seq']
79+
if sequence not in features_by_sequence:
80+
sequence_features, _ = sequence_feat_getter(sequence, name=record['name'], device='cpu')
81+
features_by_sequence[sequence] = sequence_features[0] # batch dim
82+
record['esm2_feats'] = features_by_sequence[sequence]
83+
84+
6185
def get_header(path: str) -> List[str]:
6286
"""
6387
Returns the header of a data CSV file.
@@ -509,7 +533,9 @@ def get_data(path: str,
509533

510534
# Load sequence features
511535
if not protein_records is None:
512-
sequence_feat_getter = get_protein_embedder('esm')['fn']
536+
protein_embedder = get_protein_embedder('esm')
537+
sequence_feat_getter = protein_embedder['fn']
538+
sequence_batch_getter = protein_embedder.get('batch_fn')
513539

514540
# Load data
515541
smoke_test_counter = 0
@@ -523,6 +549,7 @@ def get_data(path: str,
523549

524550
all_smiles, all_sequences, all_targets, all_atom_targets, all_bond_targets, all_rows, all_features, all_phase_features, all_constraints_data, all_raw_constraints_data, all_weights, all_gt, all_lt = [], [], [], [], [], [], [], [], [], [], [], [], []
525551
all_protein_records = []
552+
missing_esm2_records = []
526553
for i, row in enumerate(tqdm(reader)):
527554
smoke_test_counter+=1
528555
if args.smoke_test:
@@ -571,12 +598,7 @@ def get_data(path: str,
571598
roots=[esm2_feats_path.parent],
572599
)
573600
else:
574-
sequence_features, _ = sequence_feat_getter(
575-
protein_record['seq'],
576-
name=pdbname,
577-
device='cpu'
578-
)
579-
protein_record['esm2_feats'] = sequence_features[0] # batch dim
601+
missing_esm2_records.append(protein_record)
580602

581603
targets, atom_targets, bond_targets = [], [], []
582604
for column in target_columns:
@@ -650,6 +672,13 @@ def get_data(path: str,
650672
if len(all_smiles) >= max_data_size:
651673
break
652674

675+
if protein_records is not None:
676+
_populate_missing_esm2_features(
677+
missing_esm2_records,
678+
sequence_feat_getter=sequence_feat_getter,
679+
sequence_batch_getter=sequence_batch_getter,
680+
)
681+
653682
atom_features = None
654683
atom_descriptors = None
655684
if args is not None and args.atom_descriptors is not None:

catpred/inference/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,21 @@
2323
"PreparedInputPaths",
2424
"prepare_prediction_inputs",
2525
"run_raw_prediction",
26+
"run_inprocess_prediction",
2627
"postprocess_predictions",
2728
"run_prediction_pipeline",
29+
"run_inprocess_prediction_pipeline",
2830
]
2931

3032

3133
def __getattr__(name: str):
3234
if name in {
3335
"prepare_prediction_inputs",
3436
"run_raw_prediction",
37+
"run_inprocess_prediction",
3538
"postprocess_predictions",
3639
"run_prediction_pipeline",
40+
"run_inprocess_prediction_pipeline",
3741
}:
3842
from . import service
3943

catpred/inference/backends.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,17 @@ def predict(self, request_obj: PredictionRequest, results_dir: str) -> BackendPr
3737
class LocalInferenceBackend(InferenceBackend):
3838
name = "local"
3939

40-
def __init__(self, repo_root: str | None = None) -> None:
40+
def __init__(
41+
self,
42+
repo_root: str | None = None,
43+
use_subprocess: bool | None = None,
44+
) -> None:
4145
self._repo_root = repo_root
46+
self._use_subprocess = (
47+
_env_flag("CATPRED_LOCAL_SUBPROCESS", default=False)
48+
if use_subprocess is None
49+
else use_subprocess
50+
)
4251

4352
def readiness(self) -> dict[str, Any]:
4453
root = Path(self._repo_root) if self._repo_root else Path.cwd()
@@ -53,17 +62,27 @@ def readiness(self) -> dict[str, Any]:
5362
"ready": len(missing) == 0,
5463
"missing_files": missing,
5564
"repo_root": str(root),
65+
"mode": "subprocess" if self._use_subprocess else "in_process",
5666
}
5767

5868
def predict(self, request_obj: PredictionRequest, results_dir: str) -> BackendPredictionResult:
59-
from .service import run_prediction_pipeline
69+
from .service import run_inprocess_prediction_pipeline, run_prediction_pipeline
6070

6171
effective_request = request_obj
6272
if not request_obj.repo_root and self._repo_root:
6373
effective_request = replace(request_obj, repo_root=self._repo_root)
6474

65-
output_file = run_prediction_pipeline(effective_request, results_dir=results_dir)
66-
return BackendPredictionResult(backend_name=self.name, output_file=output_file)
75+
if self._use_subprocess:
76+
output_file = run_prediction_pipeline(effective_request, results_dir=results_dir)
77+
mode = "subprocess"
78+
else:
79+
output_file = run_inprocess_prediction_pipeline(effective_request, results_dir=results_dir)
80+
mode = "in_process"
81+
return BackendPredictionResult(
82+
backend_name=self.name,
83+
output_file=output_file,
84+
metadata={"mode": mode},
85+
)
6786

6887

6988
class ModalHTTPInferenceBackend(InferenceBackend):

0 commit comments

Comments
 (0)