Spaces:
Running
Receipts + seed: validation harness, DMS→commons seeding, evidence UI
Browse filesTurns the glass-box thesis from claims into proof — and gives the empty
commons a path to real data. Honest by construction: no benchmark number is
ever hand-entered; the app serves only what a real run produced, and ships an
explicit "pending" state until then (this sandbox has no torch/ESM, so the
numbers get made on a box with the weights).
- dee/core/benchmark.py — validation harness: average-rank Spearman ρ +
top-decile precision ("of your top picks, how many are genuinely high-
fitness?") + cross-dataset medians. Pure numpy, predictions injected →
unit-testable without ESM.
- dee/core/dms_seed.py — turns published DMS assays into de-identified
substitution-prior rows via the SAME k-anonymized aggregate machinery user
data uses (each assay = one contributor; a substitution needs ≥ MIN_USERS
independent studies to survive). ESM-free, so it can seed the commons with
real, citable data on day one; user results refine it after.
- scripts/run_benchmarks.py + scripts/seed_commons_from_dms.py — the
reproducible runners (the honesty artifacts: exactly how numbers/seed get
made) against ProteinGym-format DMS.
- /api/benchmarks — public read of dee/data/benchmarks.json (ships empty).
- Field Atlas modal gains a "Validated on public DMS" evidence strip
(median ρ + top-pick precision + per-dataset rows), honest pending state —
the receipts paired with the commons grid.
17 new tests (Spearman/ties/top-decile math, seed k-anonymity + ProteinGym
CSV parsing, /api/benchmarks public+empty). 451 green. Evidence strip verified
in the preview: honest pending state on the live empty endpoint, and the
populated render (headline ρ + per-dataset rows above the atlas grid). No
console errors; classic UI untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- dee/core/benchmark.py +128 -0
- dee/core/dms_seed.py +86 -0
- dee/data/benchmarks.json +12 -0
- dee/server.py +20 -0
- dee/static/app.css +21 -0
- dee/static/app.js +41 -2
- dee/static/index.html +2 -2
- scripts/run_benchmarks.py +99 -0
- scripts/seed_commons_from_dms.py +59 -0
- tests/test_benchmark.py +93 -0
- tests/test_dms_seed.py +75 -0
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validation harness — does the engine actually predict? Receipts, not claims.
|
| 2 |
+
|
| 3 |
+
The glass-box thesis lives or dies on this: every score we surface is framed
|
| 4 |
+
as a prediction to verify, so we owe the user proof the predictions track
|
| 5 |
+
reality. This harness correlates the engine's zero-shot ESM-2 ΔLL ranking
|
| 6 |
+
against measured deep-mutational-scanning (DMS) fitness from published studies
|
| 7 |
+
(e.g. ProteinGym) and reports the two numbers a protein engineer actually
|
| 8 |
+
cares about:
|
| 9 |
+
|
| 10 |
+
* Spearman ρ — does the ranking order match measured fitness order?
|
| 11 |
+
* top-decile precision — of the variants we rank in the top 10%, what
|
| 12 |
+
fraction are genuinely high-fitness (top quartile measured)? i.e. "if I
|
| 13 |
+
only make the picks you put on top, how often are they real?"
|
| 14 |
+
|
| 15 |
+
Pure numpy, predictions injected — so it's unit-testable without ESM. The
|
| 16 |
+
reproducible CLI (scripts/run_benchmarks.py) runs it against real DMS with the
|
| 17 |
+
live model where the weights exist; the app only ever serves results a real
|
| 18 |
+
run produced. No number is fabricated here.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
from typing import Dict, List, Optional, Sequence
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _average_ranks(x: np.ndarray) -> np.ndarray:
|
| 29 |
+
"""Ranks with ties resolved to the average rank (proper Spearman ties)."""
|
| 30 |
+
order = np.argsort(x, kind="mergesort")
|
| 31 |
+
ranks = np.empty(len(x), dtype=np.float64)
|
| 32 |
+
sx = x[order]
|
| 33 |
+
i = 0
|
| 34 |
+
n = len(x)
|
| 35 |
+
while i < n:
|
| 36 |
+
j = i
|
| 37 |
+
while j + 1 < n and sx[j + 1] == sx[i]:
|
| 38 |
+
j += 1
|
| 39 |
+
avg = (i + j) / 2.0 + 1.0 # 1-indexed average rank over the tie block
|
| 40 |
+
ranks[order[i:j + 1]] = avg
|
| 41 |
+
i = j + 1
|
| 42 |
+
return ranks
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def spearman(a: Sequence[float], b: Sequence[float]) -> Optional[float]:
|
| 46 |
+
"""Spearman rank correlation. None if <3 points or no variance."""
|
| 47 |
+
a = np.asarray(a, dtype=np.float64)
|
| 48 |
+
b = np.asarray(b, dtype=np.float64)
|
| 49 |
+
mask = np.isfinite(a) & np.isfinite(b)
|
| 50 |
+
a, b = a[mask], b[mask]
|
| 51 |
+
if len(a) < 3:
|
| 52 |
+
return None
|
| 53 |
+
ra, rb = _average_ranks(a), _average_ranks(b)
|
| 54 |
+
if ra.std() < 1e-9 or rb.std() < 1e-9:
|
| 55 |
+
return None
|
| 56 |
+
return float(np.corrcoef(ra, rb)[0, 1])
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def top_decile_precision(
|
| 60 |
+
predicted: Sequence[float], measured: Sequence[float],
|
| 61 |
+
*, pred_frac: float = 0.10, true_frac: float = 0.25,
|
| 62 |
+
) -> Optional[float]:
|
| 63 |
+
"""Of the top ``pred_frac`` by prediction, the fraction that land in the
|
| 64 |
+
top ``true_frac`` by measured fitness. The 'are your top picks real?' number.
|
| 65 |
+
None if too few points to form a meaningful top set."""
|
| 66 |
+
p = np.asarray(predicted, dtype=np.float64)
|
| 67 |
+
m = np.asarray(measured, dtype=np.float64)
|
| 68 |
+
mask = np.isfinite(p) & np.isfinite(m)
|
| 69 |
+
p, m = p[mask], m[mask]
|
| 70 |
+
n = len(p)
|
| 71 |
+
k = int(round(n * pred_frac))
|
| 72 |
+
if n < 10 or k < 1:
|
| 73 |
+
return None
|
| 74 |
+
top_pred_idx = np.argsort(-p)[:k]
|
| 75 |
+
true_cut = np.quantile(m, 1.0 - true_frac)
|
| 76 |
+
hits = int(np.sum(m[top_pred_idx] >= true_cut))
|
| 77 |
+
return float(hits / k)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@dataclass
|
| 81 |
+
class DatasetResult:
|
| 82 |
+
"""One DMS assay's validation result."""
|
| 83 |
+
name: str
|
| 84 |
+
protein: str # e.g. UniProt/DMS id
|
| 85 |
+
n: int # variants scored
|
| 86 |
+
spearman: Optional[float]
|
| 87 |
+
top_decile_precision: Optional[float]
|
| 88 |
+
source: str = "" # citation / DOI / dataset id (provenance)
|
| 89 |
+
|
| 90 |
+
def as_dict(self) -> dict:
|
| 91 |
+
return {
|
| 92 |
+
"name": self.name, "protein": self.protein, "n": self.n,
|
| 93 |
+
"spearman": None if self.spearman is None else round(self.spearman, 4),
|
| 94 |
+
"top_decile_precision": None if self.top_decile_precision is None
|
| 95 |
+
else round(self.top_decile_precision, 4),
|
| 96 |
+
"source": self.source,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def evaluate_dataset(
|
| 101 |
+
name: str, protein: str,
|
| 102 |
+
predicted: Sequence[float], measured: Sequence[float],
|
| 103 |
+
*, source: str = "",
|
| 104 |
+
) -> DatasetResult:
|
| 105 |
+
"""Score one aligned (predicted, measured) DMS assay."""
|
| 106 |
+
p = np.asarray(predicted, dtype=np.float64)
|
| 107 |
+
m = np.asarray(measured, dtype=np.float64)
|
| 108 |
+
mask = np.isfinite(p) & np.isfinite(m)
|
| 109 |
+
return DatasetResult(
|
| 110 |
+
name=name, protein=protein, n=int(mask.sum()),
|
| 111 |
+
spearman=spearman(p, m),
|
| 112 |
+
top_decile_precision=top_decile_precision(p, m),
|
| 113 |
+
source=source,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def summarize(results: Sequence[DatasetResult]) -> dict:
|
| 118 |
+
"""Headline across datasets: median Spearman, median top-decile precision,
|
| 119 |
+
dataset + variant counts. Medians (robust to a couple of hard assays)."""
|
| 120 |
+
rhos = [r.spearman for r in results if r.spearman is not None]
|
| 121 |
+
precs = [r.top_decile_precision for r in results if r.top_decile_precision is not None]
|
| 122 |
+
return {
|
| 123 |
+
"n_datasets": len(results),
|
| 124 |
+
"n_variants": int(sum(r.n for r in results)),
|
| 125 |
+
"median_spearman": None if not rhos else round(float(np.median(rhos)), 4),
|
| 126 |
+
"median_top_decile_precision": None if not precs
|
| 127 |
+
else round(float(np.median(precs)), 4),
|
| 128 |
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Seed the commons (public.mutation_priors) from published DMS studies.
|
| 2 |
+
|
| 3 |
+
The Field Atlas / cross-user prior pools MEASURED substitution effects across
|
| 4 |
+
labs (dee.core.aggregate). Public deep-mutational-scanning (DMS) datasets are
|
| 5 |
+
exactly that — real, measured effects from independent published assays. So we
|
| 6 |
+
can treat each assay as one contributor and pre-populate the commons with real,
|
| 7 |
+
citable data: the atlas has value on day one, and every user result afterward
|
| 8 |
+
refines it.
|
| 9 |
+
|
| 10 |
+
ESM-free — this only aggregates measured values, no model. It reuses the SAME
|
| 11 |
+
de-identification as user data (dee.core.aggregate.build_priors): z-score
|
| 12 |
+
within each assay, pool by substitution TYPE only, and keep a substitution
|
| 13 |
+
only when ≥ MIN_USERS *independent assays* measured it. So the seed carries no
|
| 14 |
+
per-variant, per-position, or per-study raw value — only 'the field finds W>L
|
| 15 |
+
favorable', backed by ≥3 studies. That k-anonymity bar makes the seed both
|
| 16 |
+
private-by-construction and defensibly strong.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import datetime as _dt
|
| 21 |
+
from typing import List, Optional, Sequence, Tuple
|
| 22 |
+
|
| 23 |
+
from dee.core import aggregate as _agg
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _normalize_label(label: str) -> str:
|
| 27 |
+
"""ProteinGym multi-mutants join with ':' ('A1C:D5E'); aggregate splits on
|
| 28 |
+
',/;/whitespace'. Normalize so multi-mutant assays still contribute (the
|
| 29 |
+
aggregate ridge decomposes them into single-site effects)."""
|
| 30 |
+
return str(label).strip().replace(":", ",")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def seed_rows(
|
| 34 |
+
assays: Sequence[Tuple[str, Sequence[Tuple[str, object]]]],
|
| 35 |
+
*,
|
| 36 |
+
now: Optional[_dt.date] = None,
|
| 37 |
+
enforce_gate: bool = True,
|
| 38 |
+
) -> List[dict]:
|
| 39 |
+
"""Turn published DMS assays into de-identified substitution-prior rows.
|
| 40 |
+
|
| 41 |
+
``assays``: list of ``(assay_id, [(mutant_label, score), ...])`` — one entry
|
| 42 |
+
per independent published assay. Returns rows shaped for
|
| 43 |
+
``auth.replace_mutation_priors`` ({substitution, n_users, n_obs,
|
| 44 |
+
mean_effect}); ``n_users`` here is the number of independent assays, so the
|
| 45 |
+
≥ MIN_USERS k-anonymity floor means ≥ that many studies agree.
|
| 46 |
+
"""
|
| 47 |
+
grouped: List[Tuple[str, List[Tuple[str, float]]]] = []
|
| 48 |
+
for assay_id, records in assays:
|
| 49 |
+
meas: List[Tuple[str, float]] = []
|
| 50 |
+
for label, score in records:
|
| 51 |
+
try:
|
| 52 |
+
v = float(score)
|
| 53 |
+
except (TypeError, ValueError):
|
| 54 |
+
continue
|
| 55 |
+
lab = _normalize_label(label)
|
| 56 |
+
if lab:
|
| 57 |
+
meas.append((lab, v))
|
| 58 |
+
if meas:
|
| 59 |
+
grouped.append((str(assay_id), meas))
|
| 60 |
+
prior = _agg.build_priors(grouped, now=now, enforce_gate=enforce_gate)
|
| 61 |
+
return prior.to_rows()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def parse_proteingym_csv(text: str) -> List[Tuple[str, float]]:
|
| 65 |
+
"""Parse a ProteinGym-style substitution CSV (header with a 'mutant' /
|
| 66 |
+
'mutation' column and a 'DMS_score' / 'score' column) into
|
| 67 |
+
[(mutant_label, score)]. Tolerant of column-name variants; skips rows it
|
| 68 |
+
can't read rather than guessing."""
|
| 69 |
+
import csv
|
| 70 |
+
import io
|
| 71 |
+
|
| 72 |
+
out: List[Tuple[str, float]] = []
|
| 73 |
+
reader = csv.DictReader(io.StringIO(text))
|
| 74 |
+
if not reader.fieldnames:
|
| 75 |
+
return out
|
| 76 |
+
lower = {f.lower(): f for f in reader.fieldnames}
|
| 77 |
+
mut_col = next((lower[k] for k in ("mutant", "mutation", "variant", "mutations") if k in lower), None)
|
| 78 |
+
score_col = next((lower[k] for k in ("dms_score", "score", "fitness", "value", "measured") if k in lower), None)
|
| 79 |
+
if not mut_col or not score_col:
|
| 80 |
+
return out
|
| 81 |
+
for row in reader:
|
| 82 |
+
try:
|
| 83 |
+
out.append((str(row[mut_col]).strip(), float(row[score_col])))
|
| 84 |
+
except (TypeError, ValueError, KeyError):
|
| 85 |
+
continue
|
| 86 |
+
return out
|
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"generated_at": null,
|
| 3 |
+
"model": null,
|
| 4 |
+
"note": "Validation results are produced by scripts/run_benchmarks.py against published DMS data with the live ESM-2 model (where the weights exist), never hand-entered. Until a real run populates this file, the app shows an honest 'pending' state — no fabricated numbers.",
|
| 5 |
+
"summary": {
|
| 6 |
+
"n_datasets": 0,
|
| 7 |
+
"n_variants": 0,
|
| 8 |
+
"median_spearman": null,
|
| 9 |
+
"median_top_decile_precision": null
|
| 10 |
+
},
|
| 11 |
+
"datasets": []
|
| 12 |
+
}
|
|
@@ -2719,6 +2719,26 @@ def create_app() -> Flask:
|
|
| 2719 |
"effective_date": _agg.EFFECTIVE_DATE.isoformat(),
|
| 2720 |
})
|
| 2721 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2722 |
@app.get("/api/admin/mutation-priors")
|
| 2723 |
def admin_mutation_priors() -> Response:
|
| 2724 |
"""View the stored cross-user aggregate (de-identified). Admin-token gated."""
|
|
|
|
| 2719 |
"effective_date": _agg.EFFECTIVE_DATE.isoformat(),
|
| 2720 |
})
|
| 2721 |
|
| 2722 |
+
@app.get("/api/benchmarks")
|
| 2723 |
+
def benchmarks() -> Response:
|
| 2724 |
+
"""The receipts — how well the engine's zero-shot ranking predicts
|
| 2725 |
+
measured DMS fitness (Spearman ρ + top-decile precision), per published
|
| 2726 |
+
dataset. PUBLIC read. Served from dee/data/benchmarks.json, which is
|
| 2727 |
+
produced ONLY by scripts/run_benchmarks.py against real data with the
|
| 2728 |
+
live model — never hand-entered. Ships empty (honest 'pending' state)
|
| 2729 |
+
until a real run populates it; no fabricated numbers ever reach here."""
|
| 2730 |
+
import json as _json
|
| 2731 |
+
path = Path(__file__).resolve().parent / "data" / "benchmarks.json"
|
| 2732 |
+
try:
|
| 2733 |
+
data = _json.loads(path.read_text(encoding="utf-8"))
|
| 2734 |
+
except Exception: # noqa: BLE001
|
| 2735 |
+
data = {"generated_at": None, "model": None,
|
| 2736 |
+
"summary": {"n_datasets": 0, "n_variants": 0,
|
| 2737 |
+
"median_spearman": None, "median_top_decile_precision": None},
|
| 2738 |
+
"datasets": []}
|
| 2739 |
+
data["ok"] = True
|
| 2740 |
+
return jsonify(data)
|
| 2741 |
+
|
| 2742 |
@app.get("/api/admin/mutation-priors")
|
| 2743 |
def admin_mutation_priors() -> Response:
|
| 2744 |
"""View the stored cross-user aggregate (de-identified). Admin-token gated."""
|
|
@@ -6498,6 +6498,27 @@ h3, h4 {
|
|
| 6498 |
.atlas-empty { padding: 20px; border: 1px dashed var(--line-strong); text-align: center; }
|
| 6499 |
.atlas-empty h3 { margin: 0 0 8px; font-size: 16px; font-weight: 600; color: var(--ink-strong); }
|
| 6500 |
.atlas-empty .muted { max-width: 52ch; margin: 0 auto 16px; line-height: 1.55; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6501 |
@media (max-width: 560px) {
|
| 6502 |
.ag-head { font-size: 7px; }
|
| 6503 |
.atlas-legend .al-note { margin-left: 0; width: 100%; }
|
|
|
|
| 6498 |
.atlas-empty { padding: 20px; border: 1px dashed var(--line-strong); text-align: center; }
|
| 6499 |
.atlas-empty h3 { margin: 0 0 8px; font-size: 16px; font-weight: 600; color: var(--ink-strong); }
|
| 6500 |
.atlas-empty .muted { max-width: 52ch; margin: 0 auto 16px; line-height: 1.55; }
|
| 6501 |
+
|
| 6502 |
+
/* Evidence strip — the receipts (Spearman ρ vs published DMS). */
|
| 6503 |
+
.atlas-evidence:empty { display: none; }
|
| 6504 |
+
.atlas-evidence-pending { padding: 12px 14px; border: 1px solid var(--line);
|
| 6505 |
+
color: var(--ink-faint); line-height: 1.55; }
|
| 6506 |
+
.atlas-evidence-pending b { color: var(--ink); }
|
| 6507 |
+
.atlas-ev-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 14px;
|
| 6508 |
+
padding: 14px; border: 1px solid var(--line-strong); }
|
| 6509 |
+
.atlas-ev-k { width: 100%; color: var(--ink-faint); text-transform: uppercase; letter-spacing: 0.14em; }
|
| 6510 |
+
.atlas-ev-big { font-family: var(--font-display); font-size: 26px; font-weight: 600;
|
| 6511 |
+
color: var(--ink-strong); line-height: 1; }
|
| 6512 |
+
.atlas-ev-lbl { color: var(--ink-faint); margin-right: 8px; }
|
| 6513 |
+
.atlas-ev-meta { width: 100%; color: var(--ink-disabled); }
|
| 6514 |
+
.atlas-ev-list { display: flex; flex-wrap: wrap; gap: 1px; margin-top: 1px;
|
| 6515 |
+
background: var(--line); border: 1px solid var(--line); }
|
| 6516 |
+
.atlas-ev-row { flex: 1 1 180px; display: flex; align-items: baseline; gap: 8px;
|
| 6517 |
+
padding: 8px 12px; background: var(--bg-card); }
|
| 6518 |
+
.aer-n { font-size: 12.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 6519 |
+
.aer-rho { font-family: var(--font-mono); font-size: 12px; color: var(--ink-strong); margin-left: auto; }
|
| 6520 |
+
.aer-var { color: var(--ink-faint); }
|
| 6521 |
+
|
| 6522 |
@media (max-width: 560px) {
|
| 6523 |
.ag-head { font-size: 7px; }
|
| 6524 |
.atlas-legend .al-note { margin-left: 0; width: 100%; }
|
|
@@ -9077,11 +9077,47 @@ function runOracle(opts){
|
|
| 9077 |
fetch('/api/atlas').then(function (r) { return r.json(); }).then(function (j) {
|
| 9078 |
if (!j || !j.ok) { body.innerHTML = '<p class="muted">The atlas is unavailable right now — try again shortly.</p>'; return; }
|
| 9079 |
render(j);
|
|
|
|
|
|
|
|
|
|
| 9080 |
}).catch(function () {
|
| 9081 |
body.innerHTML = '<p class="muted">Couldn’t reach the atlas — check your connection.</p>';
|
| 9082 |
});
|
| 9083 |
}
|
| 9084 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9085 |
// Collapse ESM-bin-split rows ("W>L@hi") back to the base substitution,
|
| 9086 |
// pooling by observation count so the grid shows one cell per (from,to).
|
| 9087 |
function collapse(rows) {
|
|
@@ -9120,6 +9156,9 @@ function runOracle(opts){
|
|
| 9120 |
var cells = collapse(j.substitutions);
|
| 9121 |
var count = j.count || 0;
|
| 9122 |
|
|
|
|
|
|
|
|
|
|
| 9123 |
var stats = '<div class="atlas-stats micro">'
|
| 9124 |
+ '<span><b>' + count + '</b> substitution type' + (count === 1 ? '' : 's') + '</span>'
|
| 9125 |
+ '<span><b>' + (j.total_observations || 0).toLocaleString() + '</b> pooled measurements</span>'
|
|
@@ -9127,7 +9166,7 @@ function runOracle(opts){
|
|
| 9127 |
+ '</div>';
|
| 9128 |
|
| 9129 |
if (!count) {
|
| 9130 |
-
body.innerHTML = stats
|
| 9131 |
+ '<div class="atlas-empty">'
|
| 9132 |
+ '<h3>The commons is still filling in.</h3>'
|
| 9133 |
+ '<p class="muted">A substitution appears here only once at least <b>' + (j.min_users || 3)
|
|
@@ -9143,7 +9182,7 @@ function runOracle(opts){
|
|
| 9143 |
}
|
| 9144 |
|
| 9145 |
// Grid: corner + 20 "to" headers, then 20 rows (from-header + 20 cells).
|
| 9146 |
-
var html = stats
|
| 9147 |
+ '<div class="atlas-legend micro"><span class="al-swatch al-pos"></span> field found favorable'
|
| 9148 |
+ '<span class="al-swatch al-neg"></span> unfavorable'
|
| 9149 |
+ '<span class="al-note">rows = original residue · columns = substituted residue</span></div>';
|
|
|
|
| 9077 |
fetch('/api/atlas').then(function (r) { return r.json(); }).then(function (j) {
|
| 9078 |
if (!j || !j.ok) { body.innerHTML = '<p class="muted">The atlas is unavailable right now — try again shortly.</p>'; return; }
|
| 9079 |
render(j);
|
| 9080 |
+
// Second half of the "receipts" pair: how well the engine predicts.
|
| 9081 |
+
fetch('/api/benchmarks').then(function (r) { return r.json(); })
|
| 9082 |
+
.then(renderEvidence).catch(function () {});
|
| 9083 |
}).catch(function () {
|
| 9084 |
body.innerHTML = '<p class="muted">Couldn’t reach the atlas — check your connection.</p>';
|
| 9085 |
});
|
| 9086 |
}
|
| 9087 |
|
| 9088 |
+
// Validation strip — Spearman ρ + top-decile precision vs published DMS.
|
| 9089 |
+
// Fills the placeholder render() leaves at the top of the modal. Honest
|
| 9090 |
+
// 'pending' state until a real benchmark run populates the numbers.
|
| 9091 |
+
function renderEvidence(b) {
|
| 9092 |
+
var host = el('atlasEvidence'); if (!host) return;
|
| 9093 |
+
var s = (b && b.summary) || {};
|
| 9094 |
+
if (!b || !b.ok || !s.n_datasets) {
|
| 9095 |
+
host.innerHTML = '<div class="atlas-evidence-pending micro">'
|
| 9096 |
+
+ '<b>Independent validation.</b> How well the engine’s ranking predicts measured '
|
| 9097 |
+
+ 'fitness is benchmarked against published deep-mutational-scanning datasets, computed on '
|
| 9098 |
+
+ 'the full model — results appear here once a run completes. We never hand-enter numbers.'
|
| 9099 |
+
+ '</div>';
|
| 9100 |
+
return;
|
| 9101 |
+
}
|
| 9102 |
+
var rho = s.median_spearman == null ? '—' : s.median_spearman.toFixed(2);
|
| 9103 |
+
var prec = s.median_top_decile_precision == null ? '—'
|
| 9104 |
+
: Math.round(s.median_top_decile_precision * 100) + '%';
|
| 9105 |
+
var head = '<div class="atlas-ev-head">'
|
| 9106 |
+
+ '<span class="atlas-ev-k micro">Validated on public DMS</span>'
|
| 9107 |
+
+ '<span class="atlas-ev-big">' + rho + '</span><span class="atlas-ev-lbl micro">median Spearman ρ</span>'
|
| 9108 |
+
+ '<span class="atlas-ev-big">' + prec + '</span><span class="atlas-ev-lbl micro">top-pick precision</span>'
|
| 9109 |
+
+ '<span class="atlas-ev-meta micro">' + (s.n_datasets) + ' datasets · '
|
| 9110 |
+
+ (s.n_variants || 0).toLocaleString() + ' variants'
|
| 9111 |
+
+ (b.model ? ' · ESM-2 ' + esc(b.model) : '') + '</span>'
|
| 9112 |
+
+ '</div>';
|
| 9113 |
+
var rows = (b.datasets || []).slice(0, 8).map(function (d) {
|
| 9114 |
+
return '<div class="atlas-ev-row"><span class="aer-n">' + esc(d.name) + '</span>'
|
| 9115 |
+
+ '<span class="aer-rho">ρ ' + (d.spearman == null ? '—' : d.spearman.toFixed(2)) + '</span>'
|
| 9116 |
+
+ '<span class="aer-var micro">' + (d.n || 0).toLocaleString() + ' var</span></div>';
|
| 9117 |
+
}).join('');
|
| 9118 |
+
host.innerHTML = head + '<div class="atlas-ev-list">' + rows + '</div>';
|
| 9119 |
+
}
|
| 9120 |
+
|
| 9121 |
// Collapse ESM-bin-split rows ("W>L@hi") back to the base substitution,
|
| 9122 |
// pooling by observation count so the grid shows one cell per (from,to).
|
| 9123 |
function collapse(rows) {
|
|
|
|
| 9156 |
var cells = collapse(j.substitutions);
|
| 9157 |
var count = j.count || 0;
|
| 9158 |
|
| 9159 |
+
// Evidence strip (filled by renderEvidence once /api/benchmarks returns)
|
| 9160 |
+
// sits at the very top — the receipts before the atlas grid.
|
| 9161 |
+
var ev = '<div class="atlas-evidence" id="atlasEvidence"></div>';
|
| 9162 |
var stats = '<div class="atlas-stats micro">'
|
| 9163 |
+ '<span><b>' + count + '</b> substitution type' + (count === 1 ? '' : 's') + '</span>'
|
| 9164 |
+ '<span><b>' + (j.total_observations || 0).toLocaleString() + '</b> pooled measurements</span>'
|
|
|
|
| 9166 |
+ '</div>';
|
| 9167 |
|
| 9168 |
if (!count) {
|
| 9169 |
+
body.innerHTML = ev + stats
|
| 9170 |
+ '<div class="atlas-empty">'
|
| 9171 |
+ '<h3>The commons is still filling in.</h3>'
|
| 9172 |
+ '<p class="muted">A substitution appears here only once at least <b>' + (j.min_users || 3)
|
|
|
|
| 9182 |
}
|
| 9183 |
|
| 9184 |
// Grid: corner + 20 "to" headers, then 20 rows (from-header + 20 cells).
|
| 9185 |
+
var html = ev + stats
|
| 9186 |
+ '<div class="atlas-legend micro"><span class="al-swatch al-pos"></span> field found favorable'
|
| 9187 |
+ '<span class="al-swatch al-neg"></span> unfavorable'
|
| 9188 |
+ '<span class="al-note">rows = original residue · columns = substituted residue</span></div>';
|
|
@@ -112,7 +112,7 @@
|
|
| 112 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 113 |
app.js change. Bump these numbers whenever you ship a frontend update —
|
| 114 |
without them, users keep getting the stale file for up to a week. -->
|
| 115 |
-
<link rel="stylesheet" href="/static/app.css?v=
|
| 116 |
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
|
| 117 |
<link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
|
| 118 |
<!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
|
|
@@ -2259,7 +2259,7 @@
|
|
| 2259 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2260 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2261 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2262 |
-
<script src="/static/app.js?v=
|
| 2263 |
<!-- Dwell-time heartbeat. Loads after auth.js so its /api/ping calls go
|
| 2264 |
through the JWT-attaching fetch wrapper (signed-in attribution). -->
|
| 2265 |
<script src="/static/telemetry.js?v=20260622-analytics" defer></script>
|
|
|
|
| 112 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 113 |
app.js change. Bump these numbers whenever you ship a frontend update —
|
| 114 |
without them, users keep getting the stale file for up to a week. -->
|
| 115 |
+
<link rel="stylesheet" href="/static/app.css?v=20260715-evidence" />
|
| 116 |
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=2" />
|
| 117 |
<link rel="apple-touch-icon" href="/static/favicon.svg?v=2" />
|
| 118 |
<!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
|
|
|
|
| 2259 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2260 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2261 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2262 |
+
<script src="/static/app.js?v=20260715-evidence" defer></script>
|
| 2263 |
<!-- Dwell-time heartbeat. Loads after auth.js so its /api/ping calls go
|
| 2264 |
through the JWT-attaching fetch wrapper (signed-in attribution). -->
|
| 2265 |
<script src="/static/telemetry.js?v=20260622-analytics" defer></script>
|
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Produce the REAL validation numbers — the receipts behind the glass-box claim.
|
| 3 |
+
|
| 4 |
+
Runs the engine's zero-shot ESM-2 ranking against published deep-mutational-
|
| 5 |
+
scanning (DMS) datasets and writes dee/data/benchmarks.json (Spearman ρ +
|
| 6 |
+
top-decile precision per dataset). Run this where torch + the ESM weights
|
| 7 |
+
exist (the HF Space, or any box with the model); it is the ONLY thing that
|
| 8 |
+
should ever populate benchmarks.json — the app never hand-enters numbers.
|
| 9 |
+
|
| 10 |
+
Input: a manifest JSON, a list of assays:
|
| 11 |
+
[
|
| 12 |
+
{"name": "...", "protein": "GENE_ORG", "sequence": "MSK...",
|
| 13 |
+
"csv": "path/to/dms.csv", "source": "doi:..."},
|
| 14 |
+
...
|
| 15 |
+
]
|
| 16 |
+
The CSV is ProteinGym-style (a 'mutant'/'mutation' column, a 'DMS_score'/
|
| 17 |
+
'score' column). Multi-mutants ('A1C:D5E') are scored as the sum of their
|
| 18 |
+
single-site ΔLLs (the same additive assumption the design engine makes — so
|
| 19 |
+
this validates exactly what we ship).
|
| 20 |
+
|
| 21 |
+
Usage:
|
| 22 |
+
python scripts/run_benchmarks.py manifest.json [--model small] [--out dee/data/benchmarks.json]
|
| 23 |
+
"""
|
| 24 |
+
import argparse
|
| 25 |
+
import datetime as dt
|
| 26 |
+
import json
|
| 27 |
+
import sys
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
# Make `dee` importable when run from the repo root.
|
| 31 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 32 |
+
|
| 33 |
+
from dee.core import benchmark as bm # noqa: E402
|
| 34 |
+
from dee.core.dms_seed import parse_proteingym_csv # noqa: E402
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _predict(sequence, labels, scorer):
|
| 38 |
+
"""Sum-of-single-site ESM-2 ΔLL for each (possibly multi-site) label."""
|
| 39 |
+
df = scorer.score_all_substitutions(sequence)
|
| 40 |
+
lut = {(int(r.position), str(r.mut_aa)): float(r.delta_ll)
|
| 41 |
+
for r in df.itertuples(index=False)}
|
| 42 |
+
import re
|
| 43 |
+
rx = re.compile(r"^([A-Za-z])(\d+)([A-Za-z*])$")
|
| 44 |
+
preds = []
|
| 45 |
+
for lab in labels:
|
| 46 |
+
total, ok = 0.0, True
|
| 47 |
+
for tok in str(lab).replace(":", ",").split(","):
|
| 48 |
+
m = rx.match(tok.strip())
|
| 49 |
+
if not m:
|
| 50 |
+
ok = False
|
| 51 |
+
break
|
| 52 |
+
pos, mut = int(m.group(2)) - 1, m.group(3).upper()
|
| 53 |
+
if (pos, mut) not in lut:
|
| 54 |
+
ok = False
|
| 55 |
+
break
|
| 56 |
+
total += lut[(pos, mut)]
|
| 57 |
+
preds.append(total if ok else float("nan"))
|
| 58 |
+
return preds
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def main():
|
| 62 |
+
ap = argparse.ArgumentParser()
|
| 63 |
+
ap.add_argument("manifest")
|
| 64 |
+
ap.add_argument("--model", default="small")
|
| 65 |
+
ap.add_argument("--out", default=str(Path(__file__).resolve().parent.parent / "dee" / "data" / "benchmarks.json"))
|
| 66 |
+
args = ap.parse_args()
|
| 67 |
+
|
| 68 |
+
from dee.core import scoring
|
| 69 |
+
scorer = scoring.get_scorer(args.model)
|
| 70 |
+
|
| 71 |
+
assays = json.loads(Path(args.manifest).read_text(encoding="utf-8"))
|
| 72 |
+
results = []
|
| 73 |
+
for a in assays:
|
| 74 |
+
recs = parse_proteingym_csv(Path(a["csv"]).read_text(encoding="utf-8"))
|
| 75 |
+
if not recs:
|
| 76 |
+
print(f" skip {a.get('name')}: no parseable records")
|
| 77 |
+
continue
|
| 78 |
+
labels = [lab for (lab, _v) in recs]
|
| 79 |
+
measured = [v for (_lab, v) in recs]
|
| 80 |
+
predicted = _predict(a["sequence"], labels, scorer)
|
| 81 |
+
r = bm.evaluate_dataset(a.get("name", a.get("protein", "?")),
|
| 82 |
+
a.get("protein", ""), predicted, measured,
|
| 83 |
+
source=a.get("source", ""))
|
| 84 |
+
results.append(r)
|
| 85 |
+
print(f" {r.name:24s} n={r.n:6d} rho={r.spearman} top10p={r.top_decile_precision}")
|
| 86 |
+
|
| 87 |
+
out = {
|
| 88 |
+
"generated_at": dt.datetime.utcnow().isoformat() + "Z",
|
| 89 |
+
"model": args.model,
|
| 90 |
+
"summary": bm.summarize(results),
|
| 91 |
+
"datasets": [r.as_dict() for r in results],
|
| 92 |
+
}
|
| 93 |
+
Path(args.out).write_text(json.dumps(out, indent=2), encoding="utf-8")
|
| 94 |
+
print(f"\nWrote {len(results)} dataset result(s) -> {args.out}")
|
| 95 |
+
print(f"Summary: {out['summary']}")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
if __name__ == "__main__":
|
| 99 |
+
main()
|
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Seed the Field Atlas / commons from published DMS studies (ESM-free).
|
| 3 |
+
|
| 4 |
+
Turns a set of public DMS assays into de-identified substitution-effect rows
|
| 5 |
+
(dee.core.dms_seed → the same k-anonymized aggregation user data goes through)
|
| 6 |
+
so the commons has real, citable value on day one. No model needed — this only
|
| 7 |
+
pools measured values by substitution TYPE, keeping a substitution only when
|
| 8 |
+
≥ MIN_USERS independent studies measured it.
|
| 9 |
+
|
| 10 |
+
Input: a manifest JSON, a list of assays:
|
| 11 |
+
[ {"name": "...", "csv": "path/to/dms.csv"}, ... ]
|
| 12 |
+
(ProteinGym-style CSVs: a 'mutant' column + a 'DMS_score' column.)
|
| 13 |
+
|
| 14 |
+
By default it WRITES the rows to a JSON file for review. Pass --push to upload
|
| 15 |
+
them to public.mutation_priors via dee.auth (requires SUPABASE creds in the
|
| 16 |
+
environment — run it where the service key lives, e.g. the deploy box).
|
| 17 |
+
|
| 18 |
+
Usage:
|
| 19 |
+
python scripts/seed_commons_from_dms.py manifest.json [--out seed_rows.json] [--push]
|
| 20 |
+
"""
|
| 21 |
+
import argparse
|
| 22 |
+
import json
|
| 23 |
+
import sys
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 27 |
+
|
| 28 |
+
from dee.core.dms_seed import parse_proteingym_csv, seed_rows # noqa: E402
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def main():
|
| 32 |
+
ap = argparse.ArgumentParser()
|
| 33 |
+
ap.add_argument("manifest")
|
| 34 |
+
ap.add_argument("--out", default="seed_rows.json")
|
| 35 |
+
ap.add_argument("--push", action="store_true",
|
| 36 |
+
help="upload to public.mutation_priors (needs SUPABASE env)")
|
| 37 |
+
args = ap.parse_args()
|
| 38 |
+
|
| 39 |
+
assays = []
|
| 40 |
+
for a in json.loads(Path(args.manifest).read_text(encoding="utf-8")):
|
| 41 |
+
recs = parse_proteingym_csv(Path(a["csv"]).read_text(encoding="utf-8"))
|
| 42 |
+
if recs:
|
| 43 |
+
assays.append((a.get("name", a["csv"]), recs))
|
| 44 |
+
print(f" {a.get('name', a['csv']):28s} {len(recs):7d} records")
|
| 45 |
+
|
| 46 |
+
rows = seed_rows(assays) # enforces the effective-date gate + k-anonymity
|
| 47 |
+
Path(args.out).write_text(json.dumps(rows, indent=2), encoding="utf-8")
|
| 48 |
+
print(f"\n{len(rows)} de-identified substitution row(s) (>= MIN_USERS studies each) -> {args.out}")
|
| 49 |
+
|
| 50 |
+
if args.push:
|
| 51 |
+
from dee import auth
|
| 52 |
+
result = auth.replace_mutation_priors(rows)
|
| 53 |
+
print(f"push -> {result}")
|
| 54 |
+
else:
|
| 55 |
+
print("(dry run — pass --push to upload to the live commons)")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
if __name__ == "__main__":
|
| 59 |
+
main()
|
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the validation harness (dee.core.benchmark) — the receipts engine.
|
| 2 |
+
|
| 3 |
+
Predictions are injected, so we can assert the exact correlation / precision
|
| 4 |
+
behaviour without ESM. These pin the numbers we'd publish as our benchmark.
|
| 5 |
+
"""
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from dee.core.benchmark import (
|
| 10 |
+
DatasetResult,
|
| 11 |
+
evaluate_dataset,
|
| 12 |
+
spearman,
|
| 13 |
+
summarize,
|
| 14 |
+
top_decile_precision,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_spearman_perfect_and_anti():
|
| 19 |
+
a = list(range(20))
|
| 20 |
+
assert spearman(a, a) == pytest.approx(1.0)
|
| 21 |
+
assert spearman(a, list(reversed(a))) == pytest.approx(-1.0)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_spearman_monotonic_nonlinear_is_one():
|
| 25 |
+
a = list(range(1, 11))
|
| 26 |
+
b = [x ** 3 for x in a] # monotonic → rank corr = 1 even if nonlinear
|
| 27 |
+
assert spearman(a, b) == pytest.approx(1.0)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_spearman_handles_ties():
|
| 31 |
+
a = [1, 1, 2, 2, 3, 3]
|
| 32 |
+
b = [1, 1, 2, 2, 3, 3]
|
| 33 |
+
assert spearman(a, b) == pytest.approx(1.0)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_spearman_none_when_degenerate():
|
| 37 |
+
assert spearman([1, 2], [3, 4]) is None # < 3 points
|
| 38 |
+
assert spearman([5, 5, 5, 5], [1, 2, 3, 4]) is None # no variance in a
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_top_decile_precision_perfect_alignment():
|
| 42 |
+
rng = np.random.default_rng(0)
|
| 43 |
+
measured = rng.normal(size=100)
|
| 44 |
+
predicted = measured.copy() # perfect ranking
|
| 45 |
+
# top 10% predicted are exactly the top 10% measured, all within top 25%.
|
| 46 |
+
assert top_decile_precision(predicted, measured) == pytest.approx(1.0)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_top_decile_precision_anti_alignment_is_low():
|
| 50 |
+
measured = np.linspace(0, 1, 100)
|
| 51 |
+
predicted = -measured # worst possible ranking
|
| 52 |
+
assert top_decile_precision(predicted, measured) == pytest.approx(0.0)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_top_decile_precision_none_when_too_small():
|
| 56 |
+
assert top_decile_precision([1, 2, 3], [3, 2, 1]) is None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_evaluate_dataset_and_summarize():
|
| 60 |
+
rng = np.random.default_rng(1)
|
| 61 |
+
m1 = rng.normal(size=60)
|
| 62 |
+
p1 = m1 + rng.normal(scale=0.3, size=60) # good but noisy predictor
|
| 63 |
+
m2 = rng.normal(size=40)
|
| 64 |
+
p2 = -m2 # a hard/anti assay
|
| 65 |
+
r1 = evaluate_dataset("assayA", "P1", p1, m1, source="doi:1")
|
| 66 |
+
r2 = evaluate_dataset("assayB", "P2", p2, m2, source="doi:2")
|
| 67 |
+
assert r1.spearman > 0.6
|
| 68 |
+
assert r2.spearman < 0
|
| 69 |
+
s = summarize([r1, r2])
|
| 70 |
+
assert s["n_datasets"] == 2
|
| 71 |
+
assert s["n_variants"] == 100
|
| 72 |
+
assert s["median_spearman"] is not None
|
| 73 |
+
# as_dict rounds + is JSON-safe
|
| 74 |
+
d = r1.as_dict()
|
| 75 |
+
assert set(d) == {"name", "protein", "n", "spearman", "top_decile_precision", "source"}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_summarize_empty():
|
| 79 |
+
s = summarize([])
|
| 80 |
+
assert s == {"n_datasets": 0, "n_variants": 0,
|
| 81 |
+
"median_spearman": None, "median_top_decile_precision": None}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_benchmarks_route_public_and_honest_empty():
|
| 85 |
+
from dee import server
|
| 86 |
+
app = server.create_app()
|
| 87 |
+
app.config.update(TESTING=True)
|
| 88 |
+
body = app.test_client().get("/api/benchmarks").get_json() # no auth — public
|
| 89 |
+
assert body["ok"] is True
|
| 90 |
+
assert "summary" in body and "datasets" in body
|
| 91 |
+
# Ships empty (no fabricated numbers) until a real run populates it.
|
| 92 |
+
assert body["summary"]["n_datasets"] == 0
|
| 93 |
+
assert body["datasets"] == []
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for seeding the commons from public DMS (dee.core.dms_seed).
|
| 2 |
+
|
| 3 |
+
ESM-free: pure aggregation. We construct synthetic 'assays' where a
|
| 4 |
+
substitution's effect is controlled across studies, and check the k-anonymity
|
| 5 |
+
floor (≥ MIN_USERS independent assays) and the de-identified output shape.
|
| 6 |
+
"""
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from dee.core import aggregate as _agg
|
| 10 |
+
from dee.core.dms_seed import parse_proteingym_csv, seed_rows
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _assay(aid, effects):
|
| 14 |
+
"""Build one assay's [(label, score)] where each single-site 'X{i}Y' gets a
|
| 15 |
+
base score = its intended effect (plus a couple of WT-ish low rows so the
|
| 16 |
+
within-assay z-score has spread)."""
|
| 17 |
+
recs = [(lab, val) for lab, val in effects]
|
| 18 |
+
return (aid, recs)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_seed_keeps_substitution_seen_in_enough_assays():
|
| 22 |
+
# 'W>L' favorable, measured in 3 independent assays → survives k-anon (=3).
|
| 23 |
+
assays = []
|
| 24 |
+
for i in range(_agg.MIN_USERS):
|
| 25 |
+
assays.append(_assay(f"study{i}", [
|
| 26 |
+
(f"W{10 + i}L", 2.0), # the favorable W>L
|
| 27 |
+
(f"K{20 + i}D", -1.0), # spread so z-score is defined
|
| 28 |
+
(f"A{30 + i}G", 0.0),
|
| 29 |
+
]))
|
| 30 |
+
rows = seed_rows(assays, enforce_gate=False)
|
| 31 |
+
subs = {r["substitution"] for r in rows}
|
| 32 |
+
assert "W>L" in subs
|
| 33 |
+
wl = next(r for r in rows if r["substitution"] == "W>L")
|
| 34 |
+
assert wl["n_users"] >= _agg.MIN_USERS # backed by ≥ 3 studies
|
| 35 |
+
assert wl["mean_effect"] > 0 # favorable, correct sign
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_seed_drops_substitution_below_k_anon():
|
| 39 |
+
# 'C>Y' appears in only ONE assay → dropped (privacy floor).
|
| 40 |
+
assays = [
|
| 41 |
+
_assay("only_study", [("C5Y", 3.0), ("K6D", -1.0), ("A7G", 0.0)]),
|
| 42 |
+
_assay("study2", [("K6D", -1.0), ("A7G", 0.5), ("M8I", 0.2)]),
|
| 43 |
+
_assay("study3", [("K6D", -0.8), ("A7G", 0.3), ("M8I", 0.1)]),
|
| 44 |
+
]
|
| 45 |
+
rows = seed_rows(assays, enforce_gate=False)
|
| 46 |
+
assert "C>Y" not in {r["substitution"] for r in rows}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_seed_handles_multi_mutant_colon_labels():
|
| 50 |
+
# ProteinGym multi-mutants ('A1C:D5E') must not crash and should contribute.
|
| 51 |
+
assays = [
|
| 52 |
+
_assay(f"s{i}", [("A1C:D5E", 1.5), ("K2D", -1.0), ("M3I", 0.0)])
|
| 53 |
+
for i in range(_agg.MIN_USERS)
|
| 54 |
+
]
|
| 55 |
+
rows = seed_rows(assays, enforce_gate=False)
|
| 56 |
+
assert isinstance(rows, list) # decomposed by the aggregate ridge, no error
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_seed_empty_when_no_assays():
|
| 60 |
+
assert seed_rows([], enforce_gate=False) == []
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_parse_proteingym_csv():
|
| 64 |
+
csv_text = "mutant,DMS_score\nA1C,1.2\nD5E,-0.4\nbad,notanumber\n"
|
| 65 |
+
recs = parse_proteingym_csv(csv_text)
|
| 66 |
+
assert recs == [("A1C", 1.2), ("D5E", -0.4)] # bad row skipped
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_parse_proteingym_csv_alt_columns():
|
| 70 |
+
csv_text = "mutation,fitness\nW10L,0.9\n"
|
| 71 |
+
assert parse_proteingym_csv(csv_text) == [("W10L", 0.9)]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_parse_proteingym_csv_missing_columns():
|
| 75 |
+
assert parse_proteingym_csv("foo,bar\n1,2\n") == []
|