|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +from collections import OrderedDict |
3 | 4 | from pathlib import Path |
4 | 5 | from functools import lru_cache |
5 | 6 | import gzip |
| 7 | +import hashlib |
6 | 8 | import json |
7 | 9 | import os |
8 | 10 | import subprocess |
| 11 | +import threading |
9 | 12 | from typing import Tuple |
10 | 13 |
|
11 | 14 | import numpy as np |
|
22 | 25 | } |
23 | 26 | _VALID_AAS = set("ACDEFGHIKLMNPQRSTVWY") |
24 | 27 | _MODEL_CACHE_SIZE = max(int(os.environ.get("CATPRED_MODEL_CACHE_SIZE", "6")), 0) |
| 28 | +_PREDICTION_CACHE_SIZE = max(int(os.environ.get("CATPRED_PREDICTION_CACHE_SIZE", "128")), 0) |
| 29 | +_PREDICTION_CACHE: OrderedDict[tuple, str] = OrderedDict() |
| 30 | +_PREDICTION_CACHE_LOCK = threading.Lock() |
25 | 31 |
|
26 | 32 |
|
27 | 33 | def _validate_parameter(parameter: str) -> str: |
@@ -234,6 +240,140 @@ def _checkpoint_fingerprint(checkpoint_paths: tuple[str, ...]) -> tuple[tuple[st |
234 | 240 | return tuple(fingerprint) |
235 | 241 |
|
236 | 242 |
|
| 243 | +def _deduplicate_prediction_input(paths: PreparedInputPaths) -> tuple[PreparedInputPaths, bool]: |
| 244 | + input_df = pd.read_csv(paths.input_csv) |
| 245 | + key_columns = ["SMILES", "sequence"] |
| 246 | + if any(column not in input_df.columns for column in key_columns): |
| 247 | + return paths, False |
| 248 | + |
| 249 | + unique_df = input_df.drop_duplicates(subset=key_columns, keep="first") |
| 250 | + if len(unique_df) == len(input_df): |
| 251 | + return paths, False |
| 252 | + |
| 253 | + input_path = Path(paths.input_csv) |
| 254 | + unique_input_csv = input_path.with_name(f"{input_path.stem}_unique{input_path.suffix}") |
| 255 | + unique_output_csv = unique_input_csv.with_name(f"{unique_input_csv.with_suffix('').name}_output.csv") |
| 256 | + unique_df.to_csv(unique_input_csv, index=False) |
| 257 | + return ( |
| 258 | + PreparedInputPaths( |
| 259 | + input_csv=str(unique_input_csv), |
| 260 | + records_file=paths.records_file, |
| 261 | + output_csv=str(unique_output_csv), |
| 262 | + ), |
| 263 | + True, |
| 264 | + ) |
| 265 | + |
| 266 | + |
| 267 | +def _expand_deduplicated_prediction_output( |
| 268 | + original_paths: PreparedInputPaths, |
| 269 | + deduplicated_paths: PreparedInputPaths, |
| 270 | +) -> None: |
| 271 | + full_df = pd.read_csv(original_paths.input_csv) |
| 272 | + unique_input_df = pd.read_csv(deduplicated_paths.input_csv) |
| 273 | + unique_output_df = pd.read_csv(deduplicated_paths.output_csv) |
| 274 | + key_columns = ["SMILES", "sequence"] |
| 275 | + prediction_columns = [ |
| 276 | + column for column in unique_output_df.columns if column not in unique_input_df.columns |
| 277 | + ] |
| 278 | + |
| 279 | + if not prediction_columns: |
| 280 | + raise ValueError("Deduplicated prediction output did not contain prediction columns.") |
| 281 | + |
| 282 | + prediction_lookup = unique_output_df[key_columns + prediction_columns].drop_duplicates( |
| 283 | + subset=key_columns, |
| 284 | + keep="first", |
| 285 | + ) |
| 286 | + expanded_df = full_df.merge(prediction_lookup, on=key_columns, how="left", sort=False) |
| 287 | + |
| 288 | + if expanded_df[prediction_columns].isnull().any().any(): |
| 289 | + raise ValueError("Failed to expand deduplicated predictions to all input rows.") |
| 290 | + |
| 291 | + expanded_df.to_csv(original_paths.output_csv, index=False) |
| 292 | + |
| 293 | + |
| 294 | +def _file_digest(path: str) -> str: |
| 295 | + digest = hashlib.sha256() |
| 296 | + with open(path, "rb") as handle: |
| 297 | + for chunk in iter(lambda: handle.read(1024 * 1024), b""): |
| 298 | + digest.update(chunk) |
| 299 | + return digest.hexdigest() |
| 300 | + |
| 301 | + |
| 302 | +def _prediction_checkpoint_paths(checkpoint_dir: str, repo_root: Path) -> tuple[str, ...]: |
| 303 | + checkpoint_path = Path(checkpoint_dir) |
| 304 | + if not checkpoint_path.is_absolute(): |
| 305 | + checkpoint_path = (repo_root / checkpoint_path).resolve() |
| 306 | + if checkpoint_path.is_file(): |
| 307 | + return (str(checkpoint_path),) |
| 308 | + if not checkpoint_path.is_dir(): |
| 309 | + raise FileNotFoundError(f'Checkpoint directory not found: "{checkpoint_path}"') |
| 310 | + model_paths = sorted(str(path.resolve()) for path in checkpoint_path.rglob("model.pt")) |
| 311 | + if not model_paths: |
| 312 | + raise FileNotFoundError(f'No model.pt checkpoints found in "{checkpoint_path}"') |
| 313 | + return tuple(model_paths) |
| 314 | + |
| 315 | + |
| 316 | +def _prediction_cache_key( |
| 317 | + parameter: str, |
| 318 | + request: PredictionRequest, |
| 319 | + paths: PreparedInputPaths, |
| 320 | + repo_root: Path, |
| 321 | +) -> tuple: |
| 322 | + protein_records_digest = None |
| 323 | + if request.protein_records_file: |
| 324 | + protein_records_path = _resolve_existing_path( |
| 325 | + request.protein_records_file, |
| 326 | + repo_root=repo_root, |
| 327 | + purpose="Protein records file", |
| 328 | + ) |
| 329 | + protein_records_digest = _file_digest(str(protein_records_path)) |
| 330 | + |
| 331 | + checkpoint_paths = _prediction_checkpoint_paths(request.checkpoint_dir, repo_root) |
| 332 | + return ( |
| 333 | + parameter, |
| 334 | + bool(request.use_gpu), |
| 335 | + _file_digest(paths.input_csv), |
| 336 | + protein_records_digest, |
| 337 | + _checkpoint_fingerprint(checkpoint_paths), |
| 338 | + ) |
| 339 | + |
| 340 | + |
| 341 | +def _prediction_cache_get(cache_key: tuple) -> str | None: |
| 342 | + if _PREDICTION_CACHE_SIZE <= 0: |
| 343 | + return None |
| 344 | + with _PREDICTION_CACHE_LOCK: |
| 345 | + cached = _PREDICTION_CACHE.get(cache_key) |
| 346 | + if cached is not None: |
| 347 | + _PREDICTION_CACHE.move_to_end(cache_key) |
| 348 | + return cached |
| 349 | + |
| 350 | + |
| 351 | +def _prediction_cache_put(cache_key: tuple, csv_text: str) -> None: |
| 352 | + if _PREDICTION_CACHE_SIZE <= 0: |
| 353 | + return |
| 354 | + with _PREDICTION_CACHE_LOCK: |
| 355 | + _PREDICTION_CACHE[cache_key] = csv_text |
| 356 | + _PREDICTION_CACHE.move_to_end(cache_key) |
| 357 | + while len(_PREDICTION_CACHE) > _PREDICTION_CACHE_SIZE: |
| 358 | + _PREDICTION_CACHE.popitem(last=False) |
| 359 | + |
| 360 | + |
| 361 | +def _write_cached_prediction( |
| 362 | + cached_csv: str, |
| 363 | + paths: PreparedInputPaths, |
| 364 | + repo_root: str | None, |
| 365 | + results_dir: str, |
| 366 | +) -> str: |
| 367 | + results_path = Path(results_dir) |
| 368 | + if not results_path.is_absolute(): |
| 369 | + results_path = (_resolve_repo_root(repo_root) / results_path).resolve() |
| 370 | + results_path.mkdir(parents=True, exist_ok=True) |
| 371 | + |
| 372 | + final_output = results_path / Path(paths.output_csv).name |
| 373 | + final_output.write_text(cached_csv, encoding="utf-8") |
| 374 | + return str(final_output) |
| 375 | + |
| 376 | + |
237 | 377 | @lru_cache(maxsize=_MODEL_CACHE_SIZE) |
238 | 378 | def _load_cached_model_objects( |
239 | 379 | checkpoint_paths: tuple[str, ...], |
@@ -390,8 +530,22 @@ def run_inprocess_prediction_pipeline( |
390 | 530 | ) -> str: |
391 | 531 | parameter = _validate_parameter(request.parameter) |
392 | 532 | 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) |
| 533 | + root = _resolve_repo_root(request.repo_root) |
| 534 | + cache_key = _prediction_cache_key(parameter, request, paths, root) |
| 535 | + cached_csv = _prediction_cache_get(cache_key) |
| 536 | + if cached_csv is not None: |
| 537 | + return _write_cached_prediction(cached_csv, paths, request.repo_root, results_dir) |
| 538 | + |
| 539 | + if request.protein_records_file: |
| 540 | + prediction_paths, was_deduplicated = paths, False |
| 541 | + else: |
| 542 | + prediction_paths, was_deduplicated = _deduplicate_prediction_input(paths) |
| 543 | + run_inprocess_prediction(request, prediction_paths) |
| 544 | + if was_deduplicated: |
| 545 | + _expand_deduplicated_prediction_output(paths, prediction_paths) |
| 546 | + final_output = _write_postprocessed_predictions(parameter, paths, request.repo_root, results_dir) |
| 547 | + _prediction_cache_put(cache_key, Path(final_output).read_text(encoding="utf-8")) |
| 548 | + return final_output |
395 | 549 |
|
396 | 550 |
|
397 | 551 | def _write_postprocessed_predictions( |
|
0 commit comments