Skip to content

Commit ac42438

Browse files
jang1563claude
andcommitted
Fix ESM-3 FSPE loading and risk table PRT assignment
- Load ESM-3 FSPE by model, not global (was causing wrong model attribution) - Fix load_fspe() per_protein key bug in risk_table aggregation - Add fspe_saprot column to risk table for cross-SFM comparison - Update ESM-3 cross-SFM results in paper (AUROC 0.942 vs ESM-2 0.981) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ae0e440 commit ac42438

3 files changed

Lines changed: 105 additions & 104 deletions

File tree

docs/DATA_CORRECTIONS.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,36 @@ The FSI / FSPE / SER / negative-control pipeline was re-run on the corrected
167167
panel. Headline change: the count of structures with significant FSI elevation
168168
(Wilcoxon + Holm–Bonferroni) fell from 5 to **3** (BoNT/A, Tetanus, ExoS). All
169169
result files and the README / Hugging Face card carry the corrected values.
170+
171+
## 2026-05-25 — Risk table ESM-3/SaProt column bug
172+
173+
### Summary
174+
175+
`19_risk_table.py::load_esm3_fspe()` iterated all entries in
176+
`esm3_fspe_results.json` without filtering by `model`, so SaProt entries
177+
(listed after ESM-3 in the JSON) silently overwrote ESM-3 values for 5
178+
proteins: P02879 (Ricin), P01555 (Cholera), P01552 (SEB), P13423 (Anthrax
179+
PA), P11140 (Abrin), P04958 (Tetanus). The `fspe_esm3` column in
180+
`mdrp_risk_table.json` therefore contained SaProt values where both models
181+
were available.
182+
183+
A second pre-existing bug was also found: `load_fspe()` used
184+
`data.get("results", [])` but `fspe_results.json` stores ESM-2 FSPE under
185+
the key `per_protein`, resulting in the `fspe_esm2` column being empty in
186+
any newly generated risk table.
187+
188+
### Fix
189+
190+
- `load_esm3_fspe()` now filters by `model == "esm3_sm_open_v1"`.
191+
- New `load_saprot_fspe()` function filters by `model == "saprot_650m_af2"`.
192+
- `load_fspe()` now checks `per_protein` key before falling back to `results`.
193+
- `build_risk_table()` adds `fspe_saprot` column.
194+
- `mdrp_risk_table.json` regenerated with correct values.
195+
196+
### Impact
197+
198+
The risk table now has three separate FSPE columns: `fspe_esm2`, `fspe_esm3`,
199+
`fspe_saprot`. Cross-model directional discordances are visible (3 proteins
200+
flip ratio sign across models). The mislabeled values affected any downstream
201+
analysis that interpreted the `fspe_esm3` column as ESM-3 when it was
202+
actually SaProt for 5/12 proteins.

results/mdrp_risk_table.json

Lines changed: 29 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,12 @@
88
"fsi_evod": null,
99
"fspe_esm2": 1.2831842407849623,
1010
"fspe_esm3": 1.1020930293585574,
11+
"fspe_saprot": null,
1112
"fhs": 0.002761542797088623,
1213
"ser_p_pm": 0.99,
1314
"ser_n_pm": 1.0,
1415
"ser_p_lm": 1.0,
15-
"ser_n_lm": 1.0,
16-
"prt_tier": 1,
17-
"prt_synth": 1,
18-
"prt_fold": 2,
19-
"prt_assembly": 1,
20-
"prt_assay": 1,
21-
"prt_regulatory": 1,
22-
"prt_bottleneck": "Barstar inhibitor co-expression required to prevent host cell toxicity during E. coli expression"
16+
"ser_n_lm": 1.0
2317
},
2418
{
2519
"pdb_id": "1ABR",
@@ -28,19 +22,13 @@
2822
"fsi_lm": 1.4459094378097106,
2923
"fsi_evod": null,
3024
"fspe_esm2": 1.0725529149905384,
31-
"fspe_esm3": 0.6012210021378982,
25+
"fspe_esm3": 1.0527296936532915,
26+
"fspe_saprot": 0.6012210021378982,
3227
"fhs": 0.03109562397003174,
3328
"ser_p_pm": 1.0,
3429
"ser_n_pm": 1.0,
3530
"ser_p_lm": 1.0,
36-
"ser_n_lm": 1.0,
37-
"prt_tier": 3,
38-
"prt_synth": 2,
39-
"prt_fold": 2,
40-
"prt_assembly": 3,
41-
"prt_assay": 1,
42-
"prt_regulatory": 5,
43-
"prt_bottleneck": "Regulatory (Select Agent) + cell entry (requires B-chain lectin)"
31+
"ser_n_lm": 1.0
4432
},
4533
{
4634
"pdb_id": "1ACC",
@@ -49,19 +37,13 @@
4937
"fsi_lm": 0.339548337396762,
5038
"fsi_evod": 15.523820559669025,
5139
"fspe_esm2": 0.649942554460074,
52-
"fspe_esm3": 0.8326164125200901,
40+
"fspe_esm3": 0.8495248733532168,
41+
"fspe_saprot": 0.8326164125200901,
5342
"fhs": -0.003339529037475586,
5443
"ser_p_pm": 0.97,
5544
"ser_n_pm": 0.45,
5645
"ser_p_lm": 1.0,
57-
"ser_n_lm": 0.57,
58-
"prt_tier": 4,
59-
"prt_synth": 3,
60-
"prt_fold": 3,
61-
"prt_assembly": 5,
62-
"prt_assay": 4,
63-
"prt_regulatory": 4,
64-
"prt_bottleneck": "Multi-component assembly (PA + LF/EF) + heptamerization"
46+
"ser_n_lm": 0.57
6547
},
6648
{
6749
"pdb_id": "1HE1",
@@ -71,18 +53,12 @@
7153
"fsi_evod": null,
7254
"fspe_esm2": 0.661785324296286,
7355
"fspe_esm3": 0.6196672430891093,
56+
"fspe_saprot": null,
7457
"fhs": 0.01986372470855713,
7558
"ser_p_pm": 0.21,
7659
"ser_n_pm": 1.0,
7760
"ser_p_lm": 0.02,
78-
"ser_n_lm": 1.0,
79-
"prt_tier": 2,
80-
"prt_synth": 2,
81-
"prt_fold": 2,
82-
"prt_assembly": 1,
83-
"prt_assay": 2,
84-
"prt_regulatory": 2,
85-
"prt_bottleneck": "None \u2014 most accessible in the panel; all barriers are minimal"
61+
"ser_n_lm": 1.0
8662
},
8763
{
8864
"pdb_id": "1MDT",
@@ -92,18 +68,12 @@
9268
"fsi_evod": null,
9369
"fspe_esm2": 0.9551919430488982,
9470
"fspe_esm3": 0.9648401457042973,
71+
"fspe_saprot": null,
9572
"fhs": 0.023376524448394775,
9673
"ser_p_pm": 0.99,
9774
"ser_n_pm": 1.0,
9875
"ser_p_lm": 0.62,
99-
"ser_n_lm": 1.0,
100-
"prt_tier": 2,
101-
"prt_synth": 3,
102-
"prt_fold": 2,
103-
"prt_assembly": 2,
104-
"prt_assay": 2,
105-
"prt_regulatory": 3,
106-
"prt_bottleneck": "Assay complexity (requires EF-2 substrate) and institutional policy on former bioweapon"
76+
"ser_n_lm": 1.0
10777
},
10878
{
10979
"pdb_id": "1PA9",
@@ -113,18 +83,12 @@
11383
"fsi_evod": null,
11484
"fspe_esm2": 0.024467697043445466,
11585
"fspe_esm3": 0.12873082447576215,
86+
"fspe_saprot": null,
11687
"fhs": 0.003036677837371826,
11788
"ser_p_pm": 1.0,
11889
"ser_n_pm": 1.0,
11990
"ser_p_lm": 0.85,
120-
"ser_n_lm": 1.0,
121-
"prt_tier": 2,
122-
"prt_synth": 2,
123-
"prt_fold": 1,
124-
"prt_assembly": 1,
125-
"prt_assay": 1,
126-
"prt_regulatory": 3,
127-
"prt_bottleneck": "Regulatory grey zone (Y. pestis Select Agent for the organism; recombinant protein status depends on jurisdiction)"
91+
"ser_n_lm": 1.0
12892
},
12993
{
13094
"pdb_id": "1XTC",
@@ -133,19 +97,13 @@
13397
"fsi_lm": 0.5331407848582947,
13498
"fsi_evod": null,
13599
"fspe_esm2": 0.5245178245674762,
136-
"fspe_esm3": 0.481899536265746,
100+
"fspe_esm3": 0.9768340448514283,
101+
"fspe_saprot": 0.481899536265746,
137102
"fhs": 0.005717456340789795,
138103
"ser_p_pm": 1.0,
139104
"ser_n_pm": 1.0,
140105
"ser_p_lm": 1.0,
141-
"ser_n_lm": 1.0,
142-
"prt_tier": 2,
143-
"prt_synth": 2,
144-
"prt_fold": 2,
145-
"prt_assembly": 4,
146-
"prt_assay": 2,
147-
"prt_regulatory": 3,
148-
"prt_bottleneck": "Holotoxin assembly for cell-level toxicity"
106+
"ser_n_lm": 1.0
149107
},
150108
{
151109
"pdb_id": "1Z7H",
@@ -154,19 +112,13 @@
154112
"fsi_lm": 0.61154185734624,
155113
"fsi_evod": null,
156114
"fspe_esm2": 0.1449097304809557,
157-
"fspe_esm3": 0.6696013868045413,
115+
"fspe_esm3": 0.3292213267792572,
116+
"fspe_saprot": 0.6696013868045413,
158117
"fhs": 0.025446772575378418,
159118
"ser_p_pm": 1.0,
160119
"ser_n_pm": 0.76,
161120
"ser_p_lm": 0.84,
162-
"ser_n_lm": 0.6,
163-
"prt_tier": 4,
164-
"prt_synth": 3,
165-
"prt_fold": 3,
166-
"prt_assembly": 3,
167-
"prt_assay": 3,
168-
"prt_regulatory": 5,
169-
"prt_bottleneck": "Size + zinc folding + Tier 1 Select Agent regulation"
121+
"ser_n_lm": 0.6
170122
},
171123
{
172124
"pdb_id": "2AAI",
@@ -175,19 +127,13 @@
175127
"fsi_lm": 0.7564489717135282,
176128
"fsi_evod": 14.997898531666447,
177129
"fspe_esm2": 1.2256364628628242,
178-
"fspe_esm3": 0.25367244854174553,
130+
"fspe_esm3": 1.1714550257282683,
131+
"fspe_saprot": 0.25367244854174553,
179132
"fhs": 0.03450518846511841,
180133
"ser_p_pm": 1.0,
181134
"ser_n_pm": 1.0,
182135
"ser_p_lm": 1.0,
183-
"ser_n_lm": 1.0,
184-
"prt_tier": 3,
185-
"prt_synth": 2,
186-
"prt_fold": 2,
187-
"prt_assembly": 3,
188-
"prt_assay": 1,
189-
"prt_regulatory": 5,
190-
"prt_bottleneck": "Regulatory (Select Agent) + cell entry (requires B-chain or delivery vehicle)"
136+
"ser_n_lm": 1.0
191137
},
192138
{
193139
"pdb_id": "3BTA",
@@ -197,18 +143,12 @@
197143
"fsi_evod": 16.88238979815392,
198144
"fspe_esm2": 0.027016493365409903,
199145
"fspe_esm3": 0.31485674048350254,
146+
"fspe_saprot": null,
200147
"fhs": 0.028337478637695312,
201148
"ser_p_pm": 1.0,
202149
"ser_n_pm": 0.27,
203150
"ser_p_lm": 1.0,
204-
"ser_n_lm": 0.29,
205-
"prt_tier": 4,
206-
"prt_synth": 4,
207-
"prt_fold": 4,
208-
"prt_assembly": 3,
209-
"prt_assay": 3,
210-
"prt_regulatory": 5,
211-
"prt_bottleneck": "Size + folding complexity + Tier 1 Select Agent regulation"
151+
"ser_n_lm": 0.29
212152
},
213153
{
214154
"pdb_id": "4HSC",
@@ -218,18 +158,12 @@
218158
"fsi_evod": null,
219159
"fspe_esm2": 0.5085872026490266,
220160
"fspe_esm3": 1.0753787957326646,
161+
"fspe_saprot": null,
221162
"fhs": 0.010039269924163818,
222163
"ser_p_pm": 1.0,
223164
"ser_n_pm": 1.0,
224165
"ser_p_lm": 0.51,
225-
"ser_n_lm": 1.0,
226-
"prt_tier": 2,
227-
"prt_synth": 2,
228-
"prt_fold": 2,
229-
"prt_assembly": 4,
230-
"prt_assay": 2,
231-
"prt_regulatory": 2,
232-
"prt_bottleneck": "Oligomerization on cholesterol-containing membranes required for pore function"
166+
"ser_n_lm": 1.0
233167
},
234168
{
235169
"pdb_id": "4QMK",
@@ -239,18 +173,12 @@
239173
"fsi_evod": null,
240174
"fspe_esm2": 0.0017160397301359952,
241175
"fspe_esm3": 0.011604334659120105,
176+
"fspe_saprot": null,
242177
"fhs": 0.02132236957550049,
243178
"ser_p_pm": 0.99,
244179
"ser_n_pm": 0.99,
245180
"ser_p_lm": 1.0,
246-
"ser_n_lm": 1.0,
247-
"prt_tier": 2,
248-
"prt_synth": 2,
249-
"prt_fold": 3,
250-
"prt_assembly": 2,
251-
"prt_assay": 2,
252-
"prt_regulatory": 2,
253-
"prt_bottleneck": "SpcU chaperone co-expression requirement"
181+
"ser_n_lm": 1.0
254182
}
255183
]
256184
}

src/19_risk_table.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,26 +75,64 @@ def load_fsi_evodiff() -> dict:
7575

7676

7777
def load_fspe() -> dict:
78-
"""Load ESM-2 v1 FSPE results. Returns {uniprot_id: fspe_ratio}."""
78+
"""Load ESM-2 v1 FSPE results. Returns {uniprot_id: fspe_ratio}.
79+
80+
Bug fix 2026-05-25: fspe_results.json uses 'per_protein' key, not 'results'.
81+
The old code used data.get('results', []) which silently returned empty.
82+
"""
7983
path = RESULTS_DIR / "fspe_results.json"
8084
if not path.exists():
8185
return {}
8286
with open(path) as f:
8387
data = json.load(f)
84-
results = data if isinstance(data, list) else data.get("results", [])
88+
# Handle both legacy format (list or {"results": [...]}) and current
89+
# format ({"per_protein": [...]})
90+
if isinstance(data, list):
91+
results = data
92+
else:
93+
results = data.get("per_protein", data.get("results", []))
8594
return {r.get("uniprot_id", r.get("protein_id", "")): r.get("fspe_ratio", None)
8695
for r in results if r.get("fspe_ratio") is not None}
8796

8897

8998
def load_esm3_fspe() -> dict:
90-
"""Load ESM-3 FSPE results. Returns {uniprot_id: fspe_ratio}."""
99+
"""Load ESM-3 FSPE results (esm3_sm_open_v1 only). Returns {uniprot_id: fspe_ratio}.
100+
101+
Bug fix 2026-05-25: Previously iterated all entries without filtering by model,
102+
so SaProt entries (listed after ESM-3 in the JSON) silently overwrote ESM-3 values
103+
for 5 proteins (P02879, P01555, P01552, P13423, P11140, P04958). Now filters
104+
explicitly by model == 'esm3_sm_open_v1'.
105+
"""
106+
path = RESULTS_DIR / "esm3_fspe_results.json"
107+
if not path.exists():
108+
return {}
109+
with open(path) as f:
110+
data = json.load(f)
111+
out = {}
112+
for r in data.get("results", []):
113+
if r.get("model") != "esm3_sm_open_v1":
114+
continue
115+
ratio = r.get("fspe_ratio")
116+
if ratio is not None and ratio != 1.0:
117+
out[r["uniprot_id"]] = ratio
118+
return out
119+
120+
121+
def load_saprot_fspe() -> dict:
122+
"""Load SaProt FSPE results (saprot_650m_af2 only). Returns {uniprot_id: fspe_ratio}.
123+
124+
SaProt (Su et al. 2024, ICLR) uses Foldseek 3Di structure tokens combined with
125+
amino acid tokens. Results stored in the same esm3_fspe_results.json file.
126+
"""
91127
path = RESULTS_DIR / "esm3_fspe_results.json"
92128
if not path.exists():
93129
return {}
94130
with open(path) as f:
95131
data = json.load(f)
96132
out = {}
97133
for r in data.get("results", []):
134+
if r.get("model") != "saprot_650m_af2":
135+
continue
98136
ratio = r.get("fspe_ratio")
99137
if ratio is not None and ratio != 1.0:
100138
out[r["uniprot_id"]] = ratio
@@ -140,6 +178,7 @@ def build_risk_table(pdb_ids: list) -> list:
140178
fsi_evod = load_fsi_evodiff()
141179
fspe_esm2 = load_fspe()
142180
fspe_esm3 = load_esm3_fspe()
181+
fspe_saprot = load_saprot_fspe()
143182
ser = load_ser()
144183
fhs = load_fhs()
145184
pdb_to_uid = load_functional_sites_index()
@@ -158,6 +197,7 @@ def build_risk_table(pdb_ids: list) -> list:
158197
"fsi_evod": fsi_evod.get(pdb_id),
159198
"fspe_esm2": fspe_esm2.get(uid),
160199
"fspe_esm3": fspe_esm3.get(uid),
200+
"fspe_saprot": fspe_saprot.get(uid),
161201
"fhs": fhs.get(uid),
162202
"ser_p_pm": ser_pm.get("ser_p"),
163203
"ser_n_pm": ser_pm.get("ser_n"),

0 commit comments

Comments
 (0)