Download hub_predict.py from StarLiu714/AF3-NA-plus: direct link, hf CLI and curl.
- Browser
- Download file 13.8 kB
-
https://huggingface.co/StarLiu714/AF3-NA-plus/resolve/main/hub_predict.py
- Command line
-
hf download hf://StarLiu714/AF3-NA-plus/hub_predict.py
-
curl -L -o hub_predict.py https://huggingface.co/StarLiu714/AF3-NA-plus/resolve/main/hub_predict.py
13.8 kB
| #!/usr/bin/env python3 | |
| """Run AF3-NA+ from Hugging Face files and emit one K=4 artifact.""" | |
| from __future__ import annotations | |
| import argparse | |
| from dataclasses import asdict, dataclass | |
| import json | |
| import os | |
| import pathlib | |
| import platform | |
| import tempfile | |
| import time | |
| from typing import Any, Mapping, Sequence | |
| import numpy as np | |
| from hub_loader import LoadedRelease, load_release | |
| from evotemplate_na.inference import ( | |
| load_artifact, | |
| materialize_torch_tile_artifact_arrays, | |
| write_artifact, | |
| ) | |
| from evotemplate_na.model import NucleicAcidChain, NucleicAcidTokenizer | |
| class HubPredictionError(ValueError): | |
| """Raised before publishing an invalid or ambiguous artifact.""" | |
| class PredictionSummary: | |
| artifact_directory: str | |
| arrays_file: str | |
| manifest_file: str | |
| artifact_id: str | |
| length: int | |
| num_chains: int | |
| device: str | |
| dtype: str | |
| wall_seconds: float | |
| gpu_seconds: float | None | |
| peak_memory_mb: float | None | |
| def _load_json(path: pathlib.Path) -> Any: | |
| try: | |
| return json.loads(path.read_text(encoding="utf-8")) | |
| except OSError as exc: | |
| raise HubPredictionError(f"cannot read input JSON: {exc}") from exc | |
| except (UnicodeDecodeError, json.JSONDecodeError) as exc: | |
| raise HubPredictionError(f"invalid input JSON: {exc}") from exc | |
| def _canonical_chain(value: Mapping[str, Any], index: int) -> dict[str, str]: | |
| if not isinstance(value, Mapping): | |
| raise HubPredictionError(f"chains[{index}] must be an object") | |
| if set(value) != {"chain_id", "polymer_type", "sequence"}: | |
| raise HubPredictionError( | |
| f"chains[{index}] requires exactly chain_id/polymer_type/sequence" | |
| ) | |
| chain_id = value["chain_id"] | |
| polymer = value["polymer_type"] | |
| sequence = value["sequence"] | |
| if not isinstance(chain_id, str) or not chain_id.strip(): | |
| raise HubPredictionError("chain_id must be a non-empty string") | |
| if not isinstance(polymer, str) or polymer.strip().upper() not in ("RNA", "DNA"): | |
| raise HubPredictionError("polymer_type must be RNA or DNA") | |
| if not isinstance(sequence, str): | |
| raise HubPredictionError("sequence must be a string") | |
| canonical = "".join(sequence.split()).upper() | |
| normalized_polymer = polymer.strip().upper() | |
| alphabet = set("ACGU" if normalized_polymer == "RNA" else "ACGT") | |
| if not canonical or not set(canonical) <= alphabet: | |
| raise HubPredictionError( | |
| f"chain {chain_id!r} is not canonical {normalized_polymer}" | |
| ) | |
| return { | |
| "chain_id": chain_id.strip(), | |
| "polymer_type": normalized_polymer, | |
| "sequence": canonical, | |
| } | |
| def _af3_chains(payload: Mapping[str, Any]) -> list[dict[str, str]]: | |
| if payload.get("dialect") != "alphafold3" or not isinstance( | |
| payload.get("sequences"), list | |
| ): | |
| raise HubPredictionError("input is not an AlphaFold 3 JSON object") | |
| raw: list[dict[str, Any]] = [] | |
| for entry in payload["sequences"]: | |
| if not isinstance(entry, Mapping) or len(entry) != 1: | |
| raise HubPredictionError("malformed AlphaFold 3 sequence entry") | |
| kind = next(iter(entry)) | |
| if kind not in ("rna", "dna"): | |
| continue | |
| molecule = entry[kind] | |
| if not isinstance(molecule, Mapping): | |
| raise HubPredictionError("malformed AlphaFold 3 NA molecule") | |
| raw_ids = molecule.get("id") | |
| ids = raw_ids if isinstance(raw_ids, list) else [raw_ids] | |
| for chain_id in ids: | |
| raw.append( | |
| { | |
| "chain_id": chain_id, | |
| "polymer_type": kind.upper(), | |
| "sequence": molecule.get("sequence"), | |
| } | |
| ) | |
| if not raw: | |
| raise HubPredictionError("AlphaFold 3 input contains no RNA/DNA chains") | |
| return [_canonical_chain(chain, index) for index, chain in enumerate(raw)] | |
| def normalize_input( | |
| payload: Any, | |
| *, | |
| fallback_artifact_id: str, | |
| artifact_id_override: str | None = None, | |
| ) -> tuple[str, tuple[dict[str, str], ...], tuple[tuple[str, str], ...], int]: | |
| """Normalize either AF3 JSON or ``{name, chains}`` release input.""" | |
| if not isinstance(payload, Mapping): | |
| raise HubPredictionError("input JSON root must be an object") | |
| if payload.get("dialect") == "alphafold3": | |
| chains = _af3_chains(payload) | |
| explicit_pairs = None | |
| declared_max_length = None | |
| else: | |
| raw_chains = payload.get("chains") | |
| if not isinstance(raw_chains, list) or not raw_chains: | |
| raise HubPredictionError("simple input requires a non-empty chains list") | |
| chains = [ | |
| _canonical_chain(chain, index) for index, chain in enumerate(raw_chains) | |
| ] | |
| explicit_pairs = payload.get("authorized_chain_pairs") | |
| declared_max_length = payload.get("max_length") | |
| ids = [chain["chain_id"] for chain in chains] | |
| if len(set(ids)) != len(ids): | |
| raise HubPredictionError("chain_id values must be unique") | |
| if explicit_pairs is None: | |
| pairs = [ | |
| (left, right) | |
| for index, left in enumerate(ids) | |
| for right in ids[index:] | |
| ] | |
| else: | |
| if not isinstance(explicit_pairs, list) or not explicit_pairs: | |
| raise HubPredictionError("authorized_chain_pairs must be a non-empty list") | |
| pairs = [] | |
| seen: set[tuple[str, str]] = set() | |
| for pair in explicit_pairs: | |
| if ( | |
| not isinstance(pair, list) | |
| or len(pair) != 2 | |
| or pair[0] not in ids | |
| or pair[1] not in ids | |
| ): | |
| raise HubPredictionError("authorized_chain_pairs contains an invalid pair") | |
| canonical = tuple(sorted((str(pair[0]), str(pair[1])))) | |
| if canonical in seen: | |
| raise HubPredictionError("authorized_chain_pairs contains duplicates") | |
| seen.add(canonical) | |
| pairs.append((str(pair[0]), str(pair[1]))) | |
| length = sum(len(chain["sequence"]) for chain in chains) | |
| max_length = length if declared_max_length is None else declared_max_length | |
| if ( | |
| isinstance(max_length, bool) | |
| or not isinstance(max_length, int) | |
| or max_length < length | |
| ): | |
| raise HubPredictionError("max_length must be an integer covering all residues") | |
| raw_name = artifact_id_override or payload.get("name") or fallback_artifact_id | |
| if not isinstance(raw_name, str) or not raw_name.strip(): | |
| raise HubPredictionError("artifact_id/name must be a non-empty string") | |
| return raw_name.strip(), tuple(chains), tuple(pairs), max_length | |
| def authorized_pair_mask( | |
| chains: Sequence[Mapping[str, str]], pairs: Sequence[Sequence[str]] | |
| ) -> np.ndarray: | |
| offsets: dict[str, slice] = {} | |
| cursor = 0 | |
| for chain in chains: | |
| stop = cursor + len(chain["sequence"]) | |
| offsets[chain["chain_id"]] = slice(cursor, stop) | |
| cursor = stop | |
| result = np.zeros((cursor, cursor), dtype=np.bool_) | |
| for left, right in pairs: | |
| left_slice, right_slice = offsets[left], offsets[right] | |
| result[left_slice, right_slice] = True | |
| result[right_slice, left_slice] = True | |
| return result | |
| def predict( | |
| *, | |
| model_dir: str | pathlib.Path, | |
| input_path: str | pathlib.Path, | |
| output_directory: str | pathlib.Path, | |
| device: str = "cpu", | |
| dtype: str = "auto", | |
| block_size: int | None = None, | |
| artifact_id: str | None = None, | |
| overwrite: bool = False, | |
| ) -> PredictionSummary: | |
| """Load one release and publish one AF3-compatible K=4 artifact.""" | |
| try: | |
| import torch | |
| except ImportError as exc: | |
| raise RuntimeError("AF3-NA+ requires torch") from exc | |
| input_source = pathlib.Path(input_path).expanduser().resolve(strict=True) | |
| payload = _load_json(input_source) | |
| name, chains, pairs, max_length = normalize_input( | |
| payload, | |
| fallback_artifact_id=input_source.stem, | |
| artifact_id_override=artifact_id, | |
| ) | |
| if block_size is not None and ( | |
| isinstance(block_size, bool) or not isinstance(block_size, int) or block_size <= 0 | |
| ): | |
| raise HubPredictionError("block_size must be a positive integer") | |
| if device == "auto": | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| loaded: LoadedRelease = load_release(model_dir, device=device, dtype=dtype) | |
| if len(chains) > loaded.spec.model_config.student.max_chains: | |
| raise HubPredictionError("input chain count exceeds the release max_chains") | |
| destination = pathlib.Path(output_directory).expanduser().resolve() | |
| if not overwrite and any( | |
| (destination / name).exists() for name in ("manifest.json", "arrays.npz") | |
| ): | |
| raise HubPredictionError("artifact exists; pass --overwrite to replace it") | |
| destination.mkdir(parents=True, exist_ok=True) | |
| tokenizer = NucleicAcidTokenizer( | |
| max_chains=loaded.spec.model_config.student.max_chains | |
| ) | |
| model_chains = [ | |
| NucleicAcidChain( | |
| chain_id=chain["chain_id"], | |
| polymer_type=chain["polymer_type"], | |
| sequence=chain["sequence"], | |
| ) | |
| for chain in chains | |
| ] | |
| batch = tokenizer.batch_encode([model_chains]).to(loaded.device) | |
| length = sum(len(chain["sequence"]) for chain in chains) | |
| residue_mask = np.ones(length, dtype=np.bool_) | |
| pair_mask = authorized_pair_mask(chains, pairs) | |
| tile_size = block_size or loaded.spec.model_config.pair_decoder.block_size | |
| with tempfile.TemporaryDirectory(prefix=".af3-na-plus-", dir=destination) as scratch: | |
| start = time.perf_counter() | |
| gpu_seconds: float | None = None | |
| peak_memory_mb: float | None = None | |
| start_event = end_event = None | |
| if loaded.device.type == "cuda": | |
| if loaded.device.index is not None: | |
| torch.cuda.set_device(loaded.device.index) | |
| torch.cuda.reset_peak_memory_stats(loaded.device) | |
| start_event = torch.cuda.Event(enable_timing=True) | |
| end_event = torch.cuda.Event(enable_timing=True) | |
| start_event.record() | |
| with torch.inference_mode(): | |
| student = loaded.model.encode_full_chain(batch) | |
| context = loaded.model.prepare_context(batch, student_output=student) | |
| arrays = materialize_torch_tile_artifact_arrays( | |
| tile_forward=loaded.model.forward_tile, | |
| context=context, | |
| slot_logits=context.decoder_context.slot_logits, | |
| length=length, | |
| active_pair_mask=pair_mask, | |
| residue_mask=residue_mask, | |
| scratch_directory=pathlib.Path(scratch), | |
| block_size=tile_size, | |
| ) | |
| if start_event is not None and end_event is not None: | |
| end_event.record() | |
| torch.cuda.synchronize(loaded.device) | |
| gpu_seconds = float(start_event.elapsed_time(end_event)) / 1000.0 | |
| peak_memory_mb = float(torch.cuda.max_memory_allocated(loaded.device)) / (1024.0**2) | |
| wall_seconds = time.perf_counter() - start | |
| precision = "bf16" if loaded.dtype == torch.bfloat16 else "fp32" | |
| write_artifact( | |
| destination, | |
| arrays=arrays, | |
| artifact_id=name, | |
| chains=chains, | |
| authorized_chain_pairs=pairs, | |
| max_length=max_length, | |
| view_mode="forward+reverse_complement", | |
| runtime={ | |
| "python": platform.python_version(), | |
| "torch": torch.__version__, | |
| "pid": os.getpid(), | |
| "wall_seconds": wall_seconds, | |
| "gpu_seconds": gpu_seconds, | |
| "peak_memory_mb": peak_memory_mb, | |
| }, | |
| device=str(loaded.device), | |
| precision=precision, | |
| quantization=None, | |
| slot_weight_is_normalized=True, | |
| ) | |
| # Re-open through the existing scientific schema validator. This checks | |
| # shapes, masks, and probabilities. | |
| artifact = load_artifact(destination) | |
| return PredictionSummary( | |
| artifact_directory=str(destination), | |
| arrays_file=str(destination / "arrays.npz"), | |
| manifest_file=str(destination / "manifest.json"), | |
| artifact_id=name, | |
| length=artifact.length, | |
| num_chains=len(chains), | |
| device=str(loaded.device), | |
| dtype=str(loaded.dtype).removeprefix("torch."), | |
| wall_seconds=wall_seconds, | |
| gpu_seconds=gpu_seconds, | |
| peak_memory_mb=peak_memory_mb, | |
| ) | |
| def main(argv: Sequence[str] | None = None) -> int: | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument("--model-dir", type=pathlib.Path, default=pathlib.Path(".")) | |
| parser.add_argument("--input", type=pathlib.Path, required=True) | |
| parser.add_argument("--output", type=pathlib.Path, required=True) | |
| parser.add_argument("--device", default="auto") | |
| parser.add_argument( | |
| "--dtype", choices=("auto", "bfloat16", "float32"), default="auto" | |
| ) | |
| parser.add_argument("--block-size", type=int) | |
| parser.add_argument("--artifact-id") | |
| parser.add_argument("--overwrite", action="store_true") | |
| args = parser.parse_args(argv) | |
| summary = predict( | |
| model_dir=args.model_dir, | |
| input_path=args.input, | |
| output_directory=args.output, | |
| device=args.device, | |
| dtype=args.dtype, | |
| block_size=args.block_size, | |
| artifact_id=args.artifact_id, | |
| overwrite=args.overwrite, | |
| ) | |
| print(json.dumps(asdict(summary), sort_keys=True)) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |
| __all__ = [ | |
| "HubPredictionError", | |
| "PredictionSummary", | |
| "authorized_pair_mask", | |
| "main", | |
| "normalize_input", | |
| "predict", | |
| ] | |