Anamitra Sarkar commited on
Commit
a37c9b9
·
1 Parent(s): b93585f

Initial sync of ESM-2 model-serving app from cancer-mutation-predictor repo

Browse files
Files changed (5) hide show
  1. Dockerfile +11 -0
  2. README.md +16 -5
  3. app.py +113 -0
  4. model.py +50 -0
  5. requirements.txt +7 -0
Dockerfile ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 7860
11
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,21 @@
1
  ---
2
- title: Cancer Mutation Esm2 Serving
3
- emoji: 👁
4
- colorFrom: pink
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Cancer Mutation ESM2 Serving
3
+ emoji: 🧬
4
+ colorFrom: blue
5
+ colorTo: gray
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # ESM-2 model serving
12
+
13
+ Internal-only inference API for the Cancer Mutation Predictor project. Loads
14
+ a fine-tuned `facebook/esm2_t12_35M_UR50D` checkpoint from
15
+ `Arko007/esm2-cancer-nlr-35M` at startup and exposes `/score` and
16
+ `/score/batch`, gated by a shared-secret `X-Internal-Key` header so only the
17
+ orchestrator backend can call it.
18
+
19
+ This folder is synced here automatically from
20
+ [Anamitra-Sarkar/cancer-mutation-predictor](https://github.com/Anamitra-Sarkar/cancer-mutation-predictor)
21
+ via GitHub Actions — do not edit directly in the Space.
app.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HF Space (Arko006 account) — the ONLY component that loads the ESM-2 model.
3
+ Downloads the fine-tuned checkpoint from the Arko007 HF *model* repo at
4
+ container startup (never bundled into this Space's own git history) and
5
+ exposes a small, shared-secret-gated scoring API for the Render orchestrator.
6
+ """
7
+
8
+ import os
9
+ import threading
10
+ from contextlib import asynccontextmanager
11
+ from typing import Optional
12
+
13
+ import torch
14
+ from fastapi import FastAPI, Header, HTTPException
15
+ from pydantic import BaseModel
16
+ from huggingface_hub import hf_hub_download
17
+
18
+ from model import TransformerDMSRegressor, DEFAULT_MODEL_NAME
19
+
20
+ CHECKPOINT_REPO = os.getenv("CHECKPOINT_REPO", "Arko007/esm2-cancer-nlr-35M")
21
+ CHECKPOINT_FILE = os.getenv("CHECKPOINT_FILE", "best.pt")
22
+ SHARED_SECRET = os.getenv("MODEL_SERVING_SHARED_SECRET", "")
23
+
24
+ _state = {"model": None, "loading": True, "revision": None, "error": None}
25
+ _device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
26
+
27
+
28
+ def _load_model():
29
+ try:
30
+ model = TransformerDMSRegressor(DEFAULT_MODEL_NAME)
31
+ checkpoint_path = hf_hub_download(repo_id=CHECKPOINT_REPO, filename=CHECKPOINT_FILE)
32
+ state_dict = torch.load(checkpoint_path, map_location=_device)
33
+ model.load_state_dict(state_dict["model_state_dict"])
34
+ model.to(_device)
35
+ model.eval()
36
+ _state["model"] = model
37
+ _state["revision"] = f"{CHECKPOINT_REPO}/{CHECKPOINT_FILE} (epoch {state_dict.get('epoch', '?')})"
38
+ except Exception as e:
39
+ # No fine-tuned checkpoint yet is a legitimate startup state (e.g.
40
+ # before the first Kaggle training run has completed) — fall back to
41
+ # the base pretrained ESM-2 model so /score still returns real
42
+ # zero-shot LLR scores (nlr_fitness will be None until fine-tuned).
43
+ print(f"Could not load fine-tuned checkpoint ({e}); falling back to base ESM-2 zero-shot scoring.")
44
+ try:
45
+ model = TransformerDMSRegressor(DEFAULT_MODEL_NAME)
46
+ model.to(_device)
47
+ model.eval()
48
+ _state["model"] = model
49
+ _state["revision"] = f"{DEFAULT_MODEL_NAME} (base, not fine-tuned)"
50
+ except Exception as inner_e:
51
+ _state["error"] = str(inner_e)
52
+ finally:
53
+ _state["loading"] = False
54
+
55
+
56
+ @asynccontextmanager
57
+ async def lifespan(app: FastAPI):
58
+ threading.Thread(target=_load_model, daemon=True).start()
59
+ yield
60
+
61
+
62
+ app = FastAPI(title="Cancer Mutation Predictor — ESM-2 model serving", lifespan=lifespan)
63
+
64
+
65
+ class ScoreRequest(BaseModel):
66
+ sequence: str
67
+ position: int
68
+ ref_aa: str
69
+ alt_aa: str
70
+
71
+
72
+ def _check_secret(x_internal_key: Optional[str]):
73
+ if SHARED_SECRET and x_internal_key != SHARED_SECRET:
74
+ raise HTTPException(status_code=401, detail="Invalid or missing X-Internal-Key")
75
+
76
+
77
+ @app.get("/health")
78
+ def health():
79
+ return {
80
+ "status": "ok" if not _state["loading"] else "warming",
81
+ "model_loaded": _state["model"] is not None,
82
+ "checkpoint": _state["revision"],
83
+ "error": _state["error"],
84
+ }
85
+
86
+
87
+ @app.post("/score")
88
+ def score(req: ScoreRequest, x_internal_key: Optional[str] = Header(None)):
89
+ _check_secret(x_internal_key)
90
+ if _state["model"] is None:
91
+ raise HTTPException(status_code=503, detail="Model still warming up, try again shortly")
92
+
93
+ if not (1 <= req.position <= len(req.sequence)):
94
+ raise HTTPException(status_code=400, detail="position out of range for the given sequence")
95
+
96
+ import time
97
+ t0 = time.time()
98
+ llr, fitness = _state["model"].score(req.sequence, req.position, req.ref_aa, req.alt_aa, _device)
99
+ return {
100
+ "raw_llr": llr,
101
+ "nlr_fitness": fitness if "not fine-tuned" not in (_state["revision"] or "") else None,
102
+ "model_id": DEFAULT_MODEL_NAME,
103
+ "checkpoint_revision": _state["revision"],
104
+ "inference_ms": round((time.time() - t0) * 1000, 1),
105
+ }
106
+
107
+
108
+ @app.post("/score/batch")
109
+ def score_batch(reqs: list[ScoreRequest], x_internal_key: Optional[str] = Header(None)):
110
+ _check_secret(x_internal_key)
111
+ if len(reqs) > 50:
112
+ raise HTTPException(status_code=400, detail="Batch limited to 50 variants per call")
113
+ return [score(r, x_internal_key) for r in reqs]
model.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inference-mode mirror of training/model.py's TransformerDMSRegressor.
3
+ Kept as a separate copy (not an import) because this file is the ONLY
4
+ directory git-synced to the HF Space — it must be fully self-contained.
5
+ """
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ from transformers import EsmForMaskedLM, EsmTokenizer
10
+
11
+ DEFAULT_MODEL_NAME = "facebook/esm2_t12_35M_UR50D"
12
+
13
+
14
+ class TransformerDMSRegressor(nn.Module):
15
+ def __init__(self, model_name: str = DEFAULT_MODEL_NAME):
16
+ super().__init__()
17
+ self.tokenizer = EsmTokenizer.from_pretrained(model_name)
18
+ self.backbone = EsmForMaskedLM.from_pretrained(model_name)
19
+ for param in self.backbone.parameters():
20
+ param.requires_grad = False
21
+ for param in self.backbone.esm.encoder.layer[-2:].parameters():
22
+ param.requires_grad = True
23
+ self.regression_head = nn.Linear(1, 1)
24
+
25
+ @torch.no_grad()
26
+ def score(self, sequence: str, position: int, ref_aa: str, alt_aa: str, device: torch.device):
27
+ max_len = 1022 # ESM tokenizer budget minus special tokens
28
+ start = max(0, position - 1 - max_len // 2)
29
+ end = min(len(sequence), start + max_len)
30
+ start = max(0, end - max_len)
31
+ window = sequence[start:end]
32
+ local_pos = position - start # 1-based within window, aligns with <cls> offset
33
+
34
+ encoding = self.tokenizer(window, return_tensors="pt", truncation=True, max_length=max_len + 2)
35
+ input_ids = encoding["input_ids"].to(device)
36
+ attention_mask = encoding["attention_mask"].to(device)
37
+
38
+ seq_len = input_ids.size(1)
39
+ mutation_idx = torch.tensor([min(local_pos, seq_len - 1)], device=device)
40
+ ref_id = torch.tensor([self.tokenizer.convert_tokens_to_ids(ref_aa)], device=device)
41
+ alt_id = torch.tensor([self.tokenizer.convert_tokens_to_ids(alt_aa)], device=device)
42
+
43
+ outputs = self.backbone(input_ids=input_ids, attention_mask=attention_mask)
44
+ logits = outputs.logits
45
+ logits_at_mut = logits[0, mutation_idx[0], :]
46
+ log_probs = torch.log_softmax(logits_at_mut, dim=-1)
47
+ llr = (log_probs[alt_id[0]] - log_probs[ref_id[0]]).item()
48
+
49
+ fitness = self.regression_head(torch.tensor([[llr]], device=device)).item()
50
+ return llr, fitness
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ fastapi==0.115.6
3
+ uvicorn[standard]==0.34.0
4
+ pydantic==2.10.4
5
+ torch
6
+ transformers==4.47.1
7
+ huggingface_hub==0.27.0