mgorkemuz commited on
Commit
3e1e93c
Β·
verified Β·
1 Parent(s): c2a89dc

Add /downstream endpoint serving 8 fine-tuned task heads per backbone

Browse files
Files changed (1) hide show
  1. app.py +133 -1
app.py CHANGED
@@ -7,6 +7,7 @@ Endpoints:
7
  POST /predict β€” per-position 20-AA softmax over the sequence
8
  POST /embed β€” mean-pooled last-layer embedding
9
  POST /mutation β€” wildtype-marginal Ξ”log-likelihood matrix [L, 20]
 
10
  """
11
 
12
  import os
@@ -17,7 +18,7 @@ import torch.nn.functional as F
17
  from fastapi import FastAPI, HTTPException
18
  from fastapi.middleware.cors import CORSMiddleware
19
  from pydantic import BaseModel, Field
20
- from transformers import EsmForMaskedLM, EsmTokenizer
21
 
22
 
23
  MODEL_REGISTRY: Dict[str, str] = {
@@ -26,11 +27,46 @@ MODEL_REGISTRY: Dict[str, str] = {
26
  "miplm-msa": "HUBioDataLab/esm2-8m-msa",
27
  }
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  STANDARD_AAS = "ACDEFGHIKLMNPQRSTVWY"
30
  MAX_LEN = 1024
31
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
32
 
33
  _models: Dict[str, Tuple[EsmForMaskedLM, EsmTokenizer, List[int]]] = {}
 
34
 
35
 
36
  def get_model(name: str):
@@ -100,6 +136,10 @@ def _warmup():
100
  def root():
101
  return {
102
  "models": list(MODEL_REGISTRY.keys()),
 
 
 
 
103
  "device": DEVICE,
104
  "max_length": MAX_LEN,
105
  "aa_order": STANDARD_AAS,
@@ -157,3 +197,95 @@ def mutation(req: _SeqRequest):
157
  wt_logp = log_probs.gather(1, wt_idx[:, None]) # [L, 1]
158
  delta = (log_probs - wt_logp).tolist()
159
  return MutationResponse(model=req.model, sequence=seq, aa_order=STANDARD_AAS, scores=delta)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  POST /predict β€” per-position 20-AA softmax over the sequence
8
  POST /embed β€” mean-pooled last-layer embedding
9
  POST /mutation β€” wildtype-marginal Ξ”log-likelihood matrix [L, 20]
10
+ POST /downstream β€” all fine-tuned task heads (classification + regression)
11
  """
12
 
13
  import os
 
18
  from fastapi import FastAPI, HTTPException
19
  from fastapi.middleware.cors import CORSMiddleware
20
  from pydantic import BaseModel, Field
21
+ from transformers import EsmForMaskedLM, EsmForSequenceClassification, EsmTokenizer
22
 
23
 
24
  MODEL_REGISTRY: Dict[str, str] = {
 
27
  "miplm-msa": "HUBioDataLab/esm2-8m-msa",
28
  }
29
 
30
+
31
+ # Downstream heads β€” one HF repo per backbone, with 8 task subfolders each.
32
+ # Map frontend backbone name β†’ HF repo.
33
+ DOWNSTREAM_REGISTRY: Dict[str, str] = {
34
+ "miplm-ce": "HUBioDataLab/miplm-ce-tasks",
35
+ "miplm-blosum": "HUBioDataLab/miplm-blosum-tasks",
36
+ "miplm-msa": "HUBioDataLab/miplm-msa-tasks",
37
+ }
38
+
39
+ # Task metadata. `kind` drives post-processing:
40
+ # single_label β†’ softmax β†’ top-K classes with probabilities
41
+ # multi_label β†’ sigmoid β†’ top-K classes with independent probabilities
42
+ # regression β†’ raw scalar
43
+ # `labels` is the human-readable class vocabulary (when known). When unset the
44
+ # response falls back to numeric class indices.
45
+ DEEPLOC10_LABELS = [
46
+ "Cytoplasm", "Nucleus", "Extracellular", "Cell membrane",
47
+ "Endoplasmic reticulum", "Plastid", "Golgi apparatus",
48
+ "Lysosome/Vacuole", "Mitochondrion", "Peroxisome",
49
+ ]
50
+ DEEPLOC2_LABELS = ["Soluble", "Membrane"]
51
+ METAL_LABELS = ["Non-binder", "Metal-ion binder"]
52
+
53
+ DOWNSTREAM_TASKS: Dict[str, Dict] = {
54
+ "deeploc-cls10": {"kind": "single_label", "labels": DEEPLOC10_LABELS, "title": "Subcellular localization (10-way)"},
55
+ "deeploc-cls2": {"kind": "single_label", "labels": DEEPLOC2_LABELS, "title": "Soluble vs membrane"},
56
+ "metalionbinding": {"kind": "single_label", "labels": METAL_LABELS, "title": "Metal-ion binding"},
57
+ "thermostability": {"kind": "regression", "labels": None, "title": "Thermostability", "unit": "normalised score"},
58
+ "ec": {"kind": "multi_label", "labels": None, "title": "EC enzyme class", "num_classes": 585},
59
+ "go-bp": {"kind": "multi_label", "labels": None, "title": "GO biological process", "num_classes": 1943},
60
+ "go-cc": {"kind": "multi_label", "labels": None, "title": "GO cellular component", "num_classes": 320},
61
+ "go-mf": {"kind": "multi_label", "labels": None, "title": "GO molecular function", "num_classes": 489},
62
+ }
63
+
64
  STANDARD_AAS = "ACDEFGHIKLMNPQRSTVWY"
65
  MAX_LEN = 1024
66
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
67
 
68
  _models: Dict[str, Tuple[EsmForMaskedLM, EsmTokenizer, List[int]]] = {}
69
+ _downstream: Dict[Tuple[str, str], EsmForSequenceClassification] = {}
70
 
71
 
72
  def get_model(name: str):
 
136
  def root():
137
  return {
138
  "models": list(MODEL_REGISTRY.keys()),
139
+ "downstream_tasks": [
140
+ {"id": t, "title": cfg["title"], "kind": cfg["kind"]}
141
+ for t, cfg in DOWNSTREAM_TASKS.items()
142
+ ],
143
  "device": DEVICE,
144
  "max_length": MAX_LEN,
145
  "aa_order": STANDARD_AAS,
 
197
  wt_logp = log_probs.gather(1, wt_idx[:, None]) # [L, 1]
198
  delta = (log_probs - wt_logp).tolist()
199
  return MutationResponse(model=req.model, sequence=seq, aa_order=STANDARD_AAS, scores=delta)
200
+
201
+
202
+ # ─── Downstream task heads ──────────────────────────────────────────────────
203
+
204
+ def get_downstream(backbone: str, task: str) -> EsmForSequenceClassification:
205
+ """Lazy-load and cache fine-tuned classification/regression heads."""
206
+ if backbone not in DOWNSTREAM_REGISTRY:
207
+ raise HTTPException(400, f"unknown backbone {backbone!r}; available: {list(DOWNSTREAM_REGISTRY)}")
208
+ if task not in DOWNSTREAM_TASKS:
209
+ raise HTTPException(400, f"unknown task {task!r}; available: {list(DOWNSTREAM_TASKS)}")
210
+ key = (backbone, task)
211
+ if key not in _downstream:
212
+ repo = DOWNSTREAM_REGISTRY[backbone]
213
+ print(f"[downstream] loading {repo} :: {task}")
214
+ m = EsmForSequenceClassification.from_pretrained(repo, subfolder=task).to(DEVICE).eval()
215
+ _downstream[key] = m
216
+ return _downstream[key]
217
+
218
+
219
+ class DownstreamRequest(BaseModel):
220
+ sequence: str = Field(..., min_length=1, max_length=MAX_LEN, pattern=r"^[A-Za-z]+$")
221
+ backbone: str = "miplm-blosum"
222
+ top_k: int = 3
223
+
224
+
225
+ class TaskPrediction(BaseModel):
226
+ task: str
227
+ title: str
228
+ kind: str # single_label | multi_label | regression
229
+ value: float | None = None # set for regression
230
+ unit: str | None = None
231
+ top: List[Dict] | None = None # set for classification β€” [{label/index, prob}]
232
+ num_classes: int | None = None
233
+
234
+
235
+ class DownstreamResponse(BaseModel):
236
+ backbone: str
237
+ sequence: str
238
+ predictions: List[TaskPrediction]
239
+
240
+
241
+ @app.post("/downstream", response_model=DownstreamResponse)
242
+ @torch.inference_mode()
243
+ def downstream(req: DownstreamRequest):
244
+ seq = req.sequence.upper()
245
+ if req.backbone not in DOWNSTREAM_REGISTRY:
246
+ raise HTTPException(400, f"unknown backbone {req.backbone!r}")
247
+
248
+ # All downstream heads share the ESM-2 tokenizer with the base backbone.
249
+ _, tokenizer, _ = get_model(req.backbone)
250
+ batch = tokenizer(seq, return_tensors="pt").to(DEVICE)
251
+
252
+ predictions: List[TaskPrediction] = []
253
+ for task, cfg in DOWNSTREAM_TASKS.items():
254
+ model = get_downstream(req.backbone, task)
255
+ logits = model(**batch).logits[0] # [num_labels] for sequence-level head
256
+
257
+ kind = cfg["kind"]
258
+ if kind == "regression":
259
+ predictions.append(TaskPrediction(
260
+ task=task, title=cfg["title"], kind=kind,
261
+ value=float(logits.item()),
262
+ unit=cfg.get("unit"),
263
+ ))
264
+ elif kind == "single_label":
265
+ probs = F.softmax(logits, dim=-1)
266
+ top_idx = torch.topk(probs, k=min(req.top_k, probs.numel())).indices.tolist()
267
+ top = [
268
+ {"label": cfg["labels"][i] if cfg["labels"] else f"class_{i}",
269
+ "index": int(i),
270
+ "prob": float(probs[i].item())}
271
+ for i in top_idx
272
+ ]
273
+ predictions.append(TaskPrediction(
274
+ task=task, title=cfg["title"], kind=kind,
275
+ top=top, num_classes=int(probs.numel()),
276
+ ))
277
+ else: # multi_label
278
+ probs = torch.sigmoid(logits)
279
+ top_idx = torch.topk(probs, k=min(req.top_k, probs.numel())).indices.tolist()
280
+ top = [
281
+ {"label": (cfg["labels"][i] if cfg["labels"] else f"class_{i}"),
282
+ "index": int(i),
283
+ "prob": float(probs[i].item())}
284
+ for i in top_idx
285
+ ]
286
+ predictions.append(TaskPrediction(
287
+ task=task, title=cfg["title"], kind=kind,
288
+ top=top, num_classes=int(probs.numel()),
289
+ ))
290
+
291
+ return DownstreamResponse(backbone=req.backbone, sequence=seq, predictions=predictions)