Skip to content

Commit c1bee14

Browse files
author
Veda Sheersh Boorla
committed
Speed up warm inference path
1 parent 8e72d32 commit c1bee14

13 files changed

Lines changed: 769 additions & 58 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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ def inner(t, *args, __cache_key = None, **kwargs):
102102
out = fn(t, *args, **kwargs)
103103

104104
log(f'saving: {t} to {str(entry_path)}')
105-
torch.save(out, str(entry_path))
105+
cache_out = out.detach().cpu() if isinstance(out, torch.Tensor) else out
106+
torch.save(cache_out, str(entry_path))
106107
return out
107108

108109
return inner

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):

catpred/inference/service.py

Lines changed: 201 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from __future__ import annotations
22

33
from pathlib import Path
4+
from functools import lru_cache
5+
import gzip
6+
import json
47
import os
58
import subprocess
69
from typing import Tuple
@@ -18,6 +21,7 @@
1821
"ki": ("log10ki_mean", "mM"),
1922
}
2023
_VALID_AAS = set("ACDEFGHIKLMNPQRSTVWY")
24+
_MODEL_CACHE_SIZE = max(int(os.environ.get("CATPRED_MODEL_CACHE_SIZE", "6")), 0)
2125

2226

2327
def _validate_parameter(parameter: str) -> str:
@@ -149,6 +153,162 @@ def _build_prediction_commands(
149153
return create_records_cmd, predict_cmd
150154

151155

156+
def _write_protein_records(input_csv: str, records_file: str) -> None:
157+
df = pd.read_csv(input_csv)
158+
required = {"pdbpath", "sequence"}
159+
missing = required.difference(df.columns)
160+
if missing:
161+
raise ValueError(
162+
f'Missing required column(s) in "{input_csv}": {", ".join(sorted(missing))}'
163+
)
164+
165+
records = {}
166+
conflicts = []
167+
for index, row in df.iterrows():
168+
row_num = index + 2
169+
pdbpath = row["pdbpath"].strip() if isinstance(row["pdbpath"], str) else row["pdbpath"]
170+
sequence = row["sequence"].strip() if isinstance(row["sequence"], str) else row["sequence"]
171+
172+
if not pdbpath:
173+
raise ValueError(f'Empty "pdbpath" in row {row_num} of "{input_csv}".')
174+
if not sequence:
175+
raise ValueError(f'Empty "sequence" in row {row_num} of "{input_csv}".')
176+
177+
key = os.path.basename(pdbpath)
178+
existing = records.get(key)
179+
if existing is not None and existing["seq"] != sequence:
180+
conflicts.append((row_num, key))
181+
continue
182+
183+
records[key] = {"name": key, "seq": sequence}
184+
185+
if conflicts:
186+
preview = ", ".join(
187+
[f'{key} (row {row_num})' for row_num, key in conflicts[:5]]
188+
)
189+
raise ValueError(
190+
"Found pdbpath basenames reused for different sequences. "
191+
f"Each unique sequence must have a unique pdbpath. Examples: {preview}"
192+
)
193+
194+
with gzip.open(records_file, "wt", encoding="utf-8") as handle:
195+
json.dump(records, handle)
196+
197+
198+
def _build_predict_args(request: PredictionRequest, paths: PreparedInputPaths, repo_root: Path):
199+
from catpred.args import PredictArgs
200+
201+
checkpoint_dir = Path(request.checkpoint_dir)
202+
if not checkpoint_dir.is_absolute():
203+
checkpoint_dir = (repo_root / checkpoint_dir).resolve()
204+
205+
protein_records_path = paths.records_file
206+
if request.protein_records_file:
207+
protein_records_path = str(
208+
_resolve_existing_path(
209+
request.protein_records_file,
210+
repo_root=repo_root,
211+
purpose="Protein records file",
212+
)
213+
)
214+
215+
args = PredictArgs()
216+
args.test_path = paths.input_csv
217+
args.preds_path = paths.output_csv
218+
args.checkpoint_dir = str(checkpoint_dir)
219+
args.uncertainty_method = "mve"
220+
args.smiles_columns = ["SMILES"]
221+
args.individual_ensemble_predictions = False
222+
args.save_uncertainty_components = True
223+
args.protein_records_path = protein_records_path
224+
args.no_cuda = not request.use_gpu
225+
args.process_args()
226+
return args
227+
228+
229+
def _checkpoint_fingerprint(checkpoint_paths: tuple[str, ...]) -> tuple[tuple[str, int, int], ...]:
230+
fingerprint = []
231+
for checkpoint_path in checkpoint_paths:
232+
stat = Path(checkpoint_path).stat()
233+
fingerprint.append((checkpoint_path, stat.st_mtime_ns, stat.st_size))
234+
return tuple(fingerprint)
235+
236+
237+
@lru_cache(maxsize=_MODEL_CACHE_SIZE)
238+
def _load_cached_model_objects(
239+
checkpoint_paths: tuple[str, ...],
240+
checkpoint_fingerprint: tuple[tuple[str, int, int], ...],
241+
use_gpu: bool,
242+
gpu: int | None,
243+
pretrained_egnn_feats_path: str,
244+
):
245+
del checkpoint_fingerprint # Included in the cache key to invalidate changed checkpoints.
246+
247+
from catpred.args import PredictArgs
248+
from catpred.train.make_predictions import load_model
249+
250+
args = PredictArgs()
251+
args.checkpoint_paths = list(checkpoint_paths)
252+
args.no_cuda = not use_gpu
253+
args.gpu = gpu
254+
args.pretrained_egnn_feats_path = pretrained_egnn_feats_path
255+
loaded_args, train_args, models, scalers, num_tasks, task_names = load_model(
256+
args=args,
257+
generator=False,
258+
)
259+
return train_args, models, scalers, num_tasks, task_names, loaded_args.pretrained_egnn_feats_path
260+
261+
262+
def _load_model_objects_for_prediction(args):
263+
from catpred.train.make_predictions import load_model
264+
from catpred.utils import update_prediction_args
265+
266+
checkpoint_paths = tuple(args.checkpoint_paths)
267+
if _MODEL_CACHE_SIZE <= 0:
268+
loaded_args, train_args, models, scalers, num_tasks, task_names = load_model(
269+
args=args,
270+
generator=False,
271+
)
272+
return loaded_args, train_args, models, scalers, num_tasks, task_names
273+
274+
train_args, models, scalers, num_tasks, task_names, pretrained_egnn_feats_path = (
275+
_load_cached_model_objects(
276+
checkpoint_paths=checkpoint_paths,
277+
checkpoint_fingerprint=_checkpoint_fingerprint(checkpoint_paths),
278+
use_gpu=not args.no_cuda,
279+
gpu=args.gpu,
280+
pretrained_egnn_feats_path=args.pretrained_egnn_feats_path,
281+
)
282+
)
283+
args.pretrained_egnn_feats_path = pretrained_egnn_feats_path
284+
update_prediction_args(predict_args=args, train_args=train_args)
285+
return args, train_args, models, scalers, num_tasks, task_names
286+
287+
288+
def run_inprocess_prediction(request: PredictionRequest, paths: PreparedInputPaths) -> None:
289+
from catpred.train.make_predictions import make_predictions
290+
291+
root = _resolve_repo_root(request.repo_root)
292+
293+
previous_embed_cpu = os.environ.get("PROTEIN_EMBED_USE_CPU")
294+
os.environ["PROTEIN_EMBED_USE_CPU"] = "0" if request.use_gpu else "1"
295+
try:
296+
if not request.protein_records_file:
297+
_write_protein_records(paths.input_csv, paths.records_file)
298+
299+
args = _build_predict_args(request, paths, root)
300+
model_objects = _load_model_objects_for_prediction(args)
301+
make_predictions(args=args, model_objects=model_objects)
302+
finally:
303+
if previous_embed_cpu is None:
304+
os.environ.pop("PROTEIN_EMBED_USE_CPU", None)
305+
else:
306+
os.environ["PROTEIN_EMBED_USE_CPU"] = previous_embed_cpu
307+
308+
if not os.path.exists(paths.output_csv):
309+
raise FileNotFoundError(f'Prediction output file was not generated: "{paths.output_csv}"')
310+
311+
152312
def run_raw_prediction(request: PredictionRequest, paths: PreparedInputPaths) -> None:
153313
root = _resolve_repo_root(request.repo_root)
154314
create_records_cmd, predict_cmd = _build_prediction_commands(
@@ -192,50 +352,59 @@ def postprocess_predictions(parameter: str, output_csv: str) -> pd.DataFrame:
192352
f'Prediction output is missing required column(s): {", ".join(missing_cols)}'
193353
)
194354

195-
pred_col, pred_logcol, pred_sd_tot, pred_sd_alea, pred_sd_epi = [], [], [], [], []
196-
197-
for _, row in df.iterrows():
198-
model_cols = [col for col in row.index if col.startswith(target_col) and "model_" in col]
199-
200-
unc = row[unc_col]
201-
prediction_log = row[target_col]
202-
prediction_linear = np.power(10, prediction_log)
203-
204-
if model_cols:
205-
model_outs = np.array([row[col] for col in model_cols])
206-
epi_unc_var = np.var(model_outs)
207-
else:
208-
epi_unc_var = 0.0
209-
210-
alea_unc_var = max(unc - epi_unc_var, 0.0)
211-
epi_unc = np.sqrt(epi_unc_var)
212-
alea_unc = np.sqrt(alea_unc_var)
213-
total_unc = np.sqrt(max(unc, 0.0))
214-
215-
pred_col.append(prediction_linear)
216-
pred_logcol.append(prediction_log)
217-
pred_sd_tot.append(total_unc)
218-
pred_sd_alea.append(alea_unc)
219-
pred_sd_epi.append(epi_unc)
220-
221-
df[f"Prediction_({unit})"] = pred_col
222-
df["Prediction_log10"] = pred_logcol
223-
df["SD_total"] = pred_sd_tot
224-
df["SD_aleatoric"] = pred_sd_alea
225-
df["SD_epistemic"] = pred_sd_epi
355+
prediction_log = df[target_col].astype(float).to_numpy()
356+
unc = df[unc_col].astype(float).to_numpy()
357+
alea_component_col = f"{target_col}_mve_uncal_aleatoric_var"
358+
epi_component_col = f"{target_col}_mve_uncal_epistemic_var"
359+
if alea_component_col in df.columns and epi_component_col in df.columns:
360+
alea_unc_var = np.maximum(df[alea_component_col].astype(float).to_numpy(), 0.0)
361+
epi_unc_var = np.maximum(df[epi_component_col].astype(float).to_numpy(), 0.0)
362+
else:
363+
model_cols = [col for col in df.columns if col.startswith(target_col) and "model_" in col]
364+
if not model_cols:
365+
raise ValueError(
366+
"Prediction output is missing uncertainty component columns or individual "
367+
f"ensemble prediction columns for {target_col}."
368+
)
369+
epi_unc_var = df[model_cols].astype(float).to_numpy().var(axis=1)
370+
alea_unc_var = np.maximum(unc - epi_unc_var, 0.0)
371+
372+
df[f"Prediction_({unit})"] = np.power(10, prediction_log)
373+
df["Prediction_log10"] = prediction_log
374+
df["SD_total"] = np.sqrt(np.maximum(unc, 0.0))
375+
df["SD_aleatoric"] = np.sqrt(alea_unc_var)
376+
df["SD_epistemic"] = np.sqrt(epi_unc_var)
226377
return df
227378

228379

229380
def run_prediction_pipeline(request: PredictionRequest, results_dir: str = "../results") -> str:
230381
parameter = _validate_parameter(request.parameter)
231382
paths = prepare_prediction_inputs(parameter, request.input_file, request.repo_root)
232383
run_raw_prediction(request, paths)
384+
return _write_postprocessed_predictions(parameter, paths, request.repo_root, results_dir)
233385

386+
387+
def run_inprocess_prediction_pipeline(
388+
request: PredictionRequest,
389+
results_dir: str = "../results",
390+
) -> str:
391+
parameter = _validate_parameter(request.parameter)
392+
paths = prepare_prediction_inputs(parameter, request.input_file, request.repo_root)
393+
run_inprocess_prediction(request, paths)
394+
return _write_postprocessed_predictions(parameter, paths, request.repo_root, results_dir)
395+
396+
397+
def _write_postprocessed_predictions(
398+
parameter: str,
399+
paths: PreparedInputPaths,
400+
repo_root: str | None,
401+
results_dir: str,
402+
) -> str:
234403
output_final = postprocess_predictions(parameter, paths.output_csv)
235404

236405
results_path = Path(results_dir)
237406
if not results_path.is_absolute():
238-
results_path = (_resolve_repo_root(request.repo_root) / results_path).resolve()
407+
results_path = (_resolve_repo_root(repo_root) / results_path).resolve()
239408
results_path.mkdir(parents=True, exist_ok=True)
240409

241410
out_name = Path(paths.output_csv).name

0 commit comments

Comments
 (0)