Skip to content

Commit ddbeaa9

Browse files
committed
eval for OPSIN and ZINC
1 parent 3dc21f1 commit ddbeaa9

2 files changed

Lines changed: 655 additions & 0 deletions

File tree

examples/opsin_eval_ZINC22.py

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
import csv
2+
import multiprocessing as mp
3+
import os
4+
import random
5+
from collections import Counter
6+
from concurrent.futures import ProcessPoolExecutor
7+
from pathlib import Path
8+
9+
import numpy as np
10+
import py2opsin
11+
from datasets import load_dataset
12+
from tqdm import tqdm
13+
from utils import standardize_mol
14+
15+
from bluenamer.namer import name_smiles
16+
17+
# --- Configuration ---
18+
N_PER_SEED = 100_000
19+
SEEDS = [42, 17, 87, 5, 63]
20+
OUT_DIR = Path("eval_failures")
21+
22+
NAME_CHUNKSIZE = 10
23+
OPSIN_BATCH_SIZE = 1000
24+
25+
26+
NO_NAME = "no name generated"
27+
OPSIN_MISMATCH = "opsin smiles mismatch"
28+
OPSIN_UNRECOGNIZED = "opsin - name is not recognized"
29+
30+
31+
def canon(smi):
32+
if not smi:
33+
return None
34+
try:
35+
return standardize_mol(smi)
36+
except Exception:
37+
return None
38+
39+
40+
def try_name_smiles(smi):
41+
try:
42+
name = name_smiles(smi)
43+
if not name or not str(name).strip():
44+
return None
45+
return str(name)
46+
except Exception:
47+
return None
48+
49+
50+
def parallel_map(fn, items, desc, chunksize=10):
51+
max_workers = max(1, os.cpu_count() - 1)
52+
53+
with ProcessPoolExecutor(max_workers=max_workers) as executor:
54+
return list(
55+
tqdm(
56+
executor.map(fn, items, chunksize=chunksize),
57+
total=len(items),
58+
desc=desc,
59+
)
60+
)
61+
62+
63+
def opsin_one(name):
64+
if not name:
65+
return None
66+
67+
try:
68+
result = py2opsin.py2opsin([name])
69+
if isinstance(result, list):
70+
return result[0] if result else None
71+
return result
72+
except Exception:
73+
return None
74+
75+
76+
def opsin_batch_with_fallback(names):
77+
"""
78+
Converts generated names to SMILES.
79+
80+
Empty names are left as None.
81+
If a batch fails, falls back to one-by-one OPSIN calls so failures
82+
can still be attributed per molecule.
83+
"""
84+
raw_smiles = [None] * len(names)
85+
86+
valid_positions = []
87+
valid_names = []
88+
89+
for i, name in enumerate(names):
90+
if name and str(name).strip():
91+
valid_positions.append(i)
92+
valid_names.append(name)
93+
94+
for start in tqdm(
95+
range(0, len(valid_names), OPSIN_BATCH_SIZE),
96+
total=(len(valid_names) + OPSIN_BATCH_SIZE - 1) // OPSIN_BATCH_SIZE,
97+
desc="Converting names with OPSIN",
98+
):
99+
pos_chunk = valid_positions[start : start + OPSIN_BATCH_SIZE]
100+
name_chunk = valid_names[start : start + OPSIN_BATCH_SIZE]
101+
102+
try:
103+
converted = py2opsin.py2opsin(name_chunk)
104+
105+
if not isinstance(converted, list):
106+
converted = [converted]
107+
108+
if len(converted) != len(name_chunk):
109+
raise ValueError(
110+
f"OPSIN returned {len(converted)} results for "
111+
f"{len(name_chunk)} names"
112+
)
113+
114+
except Exception:
115+
converted = [opsin_one(name) for name in name_chunk]
116+
117+
for pos, smi in zip(pos_chunk, converted):
118+
raw_smiles[pos] = smi if smi and str(smi).strip() else None
119+
120+
return raw_smiles
121+
122+
123+
def classify_failure(generated_name, opsin_raw_smiles, opsin_canon_smiles, original_canon_smiles):
124+
if generated_name is None or not str(generated_name).strip():
125+
return NO_NAME
126+
127+
if opsin_raw_smiles is None or not str(opsin_raw_smiles).strip():
128+
return OPSIN_UNRECOGNIZED
129+
130+
if opsin_canon_smiles is None:
131+
return OPSIN_MISMATCH
132+
133+
if original_canon_smiles is None:
134+
return OPSIN_MISMATCH
135+
136+
if opsin_canon_smiles != original_canon_smiles:
137+
return OPSIN_MISMATCH
138+
139+
return None
140+
141+
142+
def write_csv(path, rows, fieldnames):
143+
path.parent.mkdir(parents=True, exist_ok=True)
144+
145+
with path.open("w", newline="", encoding="utf-8") as f:
146+
writer = csv.DictWriter(f, fieldnames=fieldnames)
147+
writer.writeheader()
148+
writer.writerows(rows)
149+
150+
151+
def evaluate_seed(ds, seed):
152+
print(f"\n=== Seed {seed} | N={N_PER_SEED:,} ===")
153+
154+
rng = random.Random(seed)
155+
156+
indices = rng.sample(range(len(ds)), N_PER_SEED)
157+
dataset = list(ds.select(indices)["smiles"])
158+
159+
print("Converting SMILES to IUPAC names...")
160+
predicted_names = parallel_map(
161+
try_name_smiles,
162+
dataset,
163+
desc=f"Naming seed {seed}",
164+
chunksize=NAME_CHUNKSIZE,
165+
)
166+
167+
print("Converting generated names back to SMILES with OPSIN...")
168+
opsin_raw_smiles = opsin_batch_with_fallback(predicted_names)
169+
170+
print("Canonicalizing original and OPSIN SMILES...")
171+
original_canon = parallel_map(
172+
canon,
173+
dataset,
174+
desc=f"Canonicalizing original seed {seed}",
175+
chunksize=100,
176+
)
177+
178+
opsin_canon = parallel_map(
179+
canon,
180+
opsin_raw_smiles,
181+
desc=f"Canonicalizing OPSIN seed {seed}",
182+
chunksize=100,
183+
)
184+
185+
failures = []
186+
matches = []
187+
188+
for local_i, (
189+
dataset_index,
190+
original_smiles,
191+
original_canon_smiles,
192+
generated_name,
193+
raw_opsin_smiles,
194+
canon_opsin_smiles,
195+
) in enumerate(
196+
zip(
197+
indices,
198+
dataset,
199+
original_canon,
200+
predicted_names,
201+
opsin_raw_smiles,
202+
opsin_canon,
203+
)
204+
):
205+
failure_reason = classify_failure(
206+
generated_name,
207+
raw_opsin_smiles,
208+
canon_opsin_smiles,
209+
original_canon_smiles,
210+
)
211+
212+
is_match = failure_reason is None
213+
matches.append(is_match)
214+
215+
if not is_match:
216+
failures.append(
217+
{
218+
"seed": seed,
219+
"local_position": local_i,
220+
"dataset_index": dataset_index,
221+
"failure_reason": failure_reason,
222+
"original_smiles": original_smiles,
223+
"original_canon_smiles": original_canon_smiles,
224+
"generated_name": generated_name,
225+
"opsin_raw_smiles": raw_opsin_smiles,
226+
"opsin_canon_smiles": canon_opsin_smiles,
227+
}
228+
)
229+
230+
matches = np.array(matches, dtype=bool)
231+
accuracy = float(np.mean(matches))
232+
counts = Counter(row["failure_reason"] for row in failures)
233+
234+
summary = {
235+
"seed": seed,
236+
"n": N_PER_SEED,
237+
"matches": int(matches.sum()),
238+
"failures": len(failures),
239+
"accuracy": accuracy,
240+
NO_NAME: counts[NO_NAME],
241+
OPSIN_MISMATCH: counts[OPSIN_MISMATCH],
242+
OPSIN_UNRECOGNIZED: counts[OPSIN_UNRECOGNIZED],
243+
}
244+
245+
print(f"Seed {seed} accuracy: {accuracy:.2%}")
246+
print(f"Failures: {len(failures):,}")
247+
print(dict(counts))
248+
249+
failure_fieldnames = [
250+
"seed",
251+
"local_position",
252+
"dataset_index",
253+
"failure_reason",
254+
"original_smiles",
255+
"original_canon_smiles",
256+
"generated_name",
257+
"opsin_raw_smiles",
258+
"opsin_canon_smiles",
259+
]
260+
261+
write_csv(
262+
OUT_DIR / f"failures_seed_{seed}.csv",
263+
failures,
264+
failure_fieldnames,
265+
)
266+
267+
return summary, failures
268+
269+
270+
def main():
271+
os.environ["TOKENIZERS_PARALLELISM"] = "false"
272+
273+
OUT_DIR.mkdir(parents=True, exist_ok=True)
274+
275+
print("Loading ZINC22 dataset once...")
276+
ds = load_dataset("chandar-lab/ZINC_22", split=None)
277+
278+
all_summaries = []
279+
all_failures = []
280+
281+
for seed in SEEDS:
282+
summary, failures = evaluate_seed(ds, seed)
283+
all_summaries.append(summary)
284+
all_failures.extend(failures)
285+
286+
summary_fieldnames = [
287+
"seed",
288+
"n",
289+
"matches",
290+
"failures",
291+
"accuracy",
292+
NO_NAME,
293+
OPSIN_MISMATCH,
294+
OPSIN_UNRECOGNIZED,
295+
]
296+
297+
failure_fieldnames = [
298+
"seed",
299+
"local_position",
300+
"dataset_index",
301+
"failure_reason",
302+
"original_smiles",
303+
"original_canon_smiles",
304+
"generated_name",
305+
"opsin_raw_smiles",
306+
"opsin_canon_smiles",
307+
]
308+
309+
write_csv(OUT_DIR / "summary.csv", all_summaries, summary_fieldnames)
310+
write_csv(OUT_DIR / "all_failures.csv", all_failures, failure_fieldnames)
311+
312+
total_n = sum(row["n"] for row in all_summaries)
313+
total_matches = sum(row["matches"] for row in all_summaries)
314+
overall_accuracy = total_matches / total_n
315+
316+
print("\n=== Overall ===")
317+
print(f"Total molecules: {total_n:,}")
318+
print(f"Total matches: {total_matches:,}")
319+
print(f"Overall accuracy: {overall_accuracy:.2%}")
320+
print(f"Total failures: {len(all_failures):,}")
321+
print(f"Wrote results to: {OUT_DIR.resolve()}")
322+
323+
324+
if __name__ == "__main__":
325+
mp.freeze_support()
326+
main()

0 commit comments

Comments
 (0)