|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | from pathlib import Path |
| 4 | +from functools import lru_cache |
| 5 | +import gzip |
| 6 | +import json |
4 | 7 | import os |
5 | 8 | import subprocess |
6 | 9 | from typing import Tuple |
|
18 | 21 | "ki": ("log10ki_mean", "mM"), |
19 | 22 | } |
20 | 23 | _VALID_AAS = set("ACDEFGHIKLMNPQRSTVWY") |
| 24 | +_MODEL_CACHE_SIZE = max(int(os.environ.get("CATPRED_MODEL_CACHE_SIZE", "6")), 0) |
21 | 25 |
|
22 | 26 |
|
23 | 27 | def _validate_parameter(parameter: str) -> str: |
@@ -149,6 +153,162 @@ def _build_prediction_commands( |
149 | 153 | return create_records_cmd, predict_cmd |
150 | 154 |
|
151 | 155 |
|
| 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 | + |
152 | 312 | def run_raw_prediction(request: PredictionRequest, paths: PreparedInputPaths) -> None: |
153 | 313 | root = _resolve_repo_root(request.repo_root) |
154 | 314 | create_records_cmd, predict_cmd = _build_prediction_commands( |
@@ -192,50 +352,59 @@ def postprocess_predictions(parameter: str, output_csv: str) -> pd.DataFrame: |
192 | 352 | f'Prediction output is missing required column(s): {", ".join(missing_cols)}' |
193 | 353 | ) |
194 | 354 |
|
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) |
226 | 377 | return df |
227 | 378 |
|
228 | 379 |
|
229 | 380 | def run_prediction_pipeline(request: PredictionRequest, results_dir: str = "../results") -> str: |
230 | 381 | parameter = _validate_parameter(request.parameter) |
231 | 382 | paths = prepare_prediction_inputs(parameter, request.input_file, request.repo_root) |
232 | 383 | run_raw_prediction(request, paths) |
| 384 | + return _write_postprocessed_predictions(parameter, paths, request.repo_root, results_dir) |
233 | 385 |
|
| 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: |
234 | 403 | output_final = postprocess_predictions(parameter, paths.output_csv) |
235 | 404 |
|
236 | 405 | results_path = Path(results_dir) |
237 | 406 | 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() |
239 | 408 | results_path.mkdir(parents=True, exist_ok=True) |
240 | 409 |
|
241 | 410 | out_name = Path(paths.output_csv).name |
|
0 commit comments