Skip to content

Commit 561513d

Browse files
lilyminiumpre-commit-ci[bot]mattwthompson
authored
Add tautomer filter (#760)
* add tautomer filter * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update openff/evaluator/datasets/curation/components/filtering.py Co-authored-by: Matt Thompson <matt.thompson@openforcefield.org> * rm try/except --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Matt Thompson <matt.thompson@openforcefield.org>
1 parent 03183c2 commit 561513d

4 files changed

Lines changed: 796 additions & 0 deletions

File tree

openff/evaluator/_tests/test_datasets/test_curation/test_filtering.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
FilterByStereochemistrySchema,
3939
FilterBySubstances,
4040
FilterBySubstancesSchema,
41+
FilterByTautomers,
42+
FilterByTautomersSchema,
4143
FilterByTemperature,
4244
FilterByTemperatureSchema,
4345
FilterDuplicates,
@@ -1212,3 +1214,224 @@ def test_curation_does_not_alter_precision():
12121214
)
12131215

12141216
assert list(data_frame["Mole Fraction 1"]) == list(filtered["Mole Fraction 1"])
1217+
1218+
1219+
def test_validate_filter_by_tautomers():
1220+
FilterByTautomersSchema()
1221+
FilterByTautomersSchema(categories_to_include=None, categories_to_exclude=[])
1222+
FilterByTautomersSchema(categories_to_include=["AMIDE_IMIDIC_ACID"])
1223+
1224+
with pytest.raises(ValidationError):
1225+
FilterByTautomersSchema(categories_to_include=None, categories_to_exclude=None)
1226+
1227+
with pytest.raises(ValidationError):
1228+
FilterByTautomersSchema(
1229+
categories_to_include=["AMIDE_IMIDIC_ACID"],
1230+
categories_to_exclude=["BETA_DIKETONE"],
1231+
)
1232+
1233+
with pytest.raises(ValidationError):
1234+
FilterByTautomersSchema(categories_to_include=["NOT_A_CATEGORY"])
1235+
1236+
1237+
def test_filter_by_tautomers_schema_default_whitelist():
1238+
schema = FilterByTautomersSchema()
1239+
1240+
assert set(schema.categories_to_include) == {
1241+
"ALPHA_AMINO_ACID",
1242+
"AMIDE_ENOL",
1243+
"AMIDE_IMIDIC_ACID",
1244+
"CARBOXYLIC_ACID_ENOL",
1245+
"ESTER_ENOL",
1246+
"IMINE_ENAMINE_PRIMARY",
1247+
"IMINE_ENAMINE_SECONDARY",
1248+
"KETO_ENOL_ALIPHATIC",
1249+
"KETO_ENOL_CYCLIC",
1250+
"KETO_ENOL_AROMATIC",
1251+
"KETENE_YNOL",
1252+
"LACTAM_LACTIM",
1253+
"OXIME_NITROSO",
1254+
}
1255+
1256+
1257+
def test_filter_by_tautomers_via_workflow():
1258+
data_frame = pandas.DataFrame(
1259+
[
1260+
{"N Components": 1, "Component 1": "C"},
1261+
{
1262+
"N Components": 1,
1263+
"Component 1": "CC(=O)CC(=O)C",
1264+
}, # beta-diketone, not in whitelist
1265+
]
1266+
)
1267+
1268+
schema = CurationWorkflowSchema(component_schemas=[FilterByTautomersSchema()])
1269+
filtered_frame = CurationWorkflow.apply(data_frame, schema)
1270+
1271+
assert len(filtered_frame) == 1
1272+
assert filtered_frame.iloc[0]["Component 1"] == "C"
1273+
1274+
1275+
def test_filter_by_tautomers_no_match_removed_default():
1276+
data_frame = pandas.DataFrame(
1277+
# ensure we don't start picking anything up crazy like ethanol
1278+
[
1279+
{"N Components": 1, "Component 1": "C"},
1280+
{"N Components": 1, "Component 1": "CCO"},
1281+
]
1282+
)
1283+
1284+
filtered_frame = FilterByTautomers.apply(data_frame, FilterByTautomersSchema())
1285+
1286+
assert len(filtered_frame) == len(data_frame)
1287+
1288+
1289+
def test_filter_by_tautomers_whitelist_keto_enol_aliphatic_kept():
1290+
"""Acetone (KETO_ENOL_ALIPHATIC) must survive the default filter."""
1291+
data_frame = pandas.DataFrame([{"N Components": 1, "Component 1": "CC(C)=O"}])
1292+
1293+
filtered_frame = FilterByTautomers.apply(data_frame, FilterByTautomersSchema())
1294+
1295+
assert len(filtered_frame) == 1
1296+
1297+
1298+
def test_filter_by_tautomers_default_whitelist_only():
1299+
data_frame = pandas.DataFrame(
1300+
[
1301+
{"N Components": 1, "Component 1": "C"},
1302+
{"N Components": 1, "Component 1": "CC(=O)N"}, # amide → kept
1303+
{
1304+
"N Components": 1,
1305+
"Component 1": "CC(=O)CC(=O)C",
1306+
}, # beta-diketone → removed
1307+
{"N Components": 1, "Component 1": "O=C1CCCCC1"}, # cyclic keto/enol → kept
1308+
]
1309+
)
1310+
1311+
filtered_frame = FilterByTautomers.apply(data_frame, FilterByTautomersSchema())
1312+
1313+
assert len(filtered_frame) == 3
1314+
assert set(filtered_frame["Component 1"]) == {"C", "CC(=O)N", "O=C1CCCCC1"}
1315+
1316+
1317+
def test_filter_by_tautomers_explicit_include():
1318+
data_frame = pandas.DataFrame(
1319+
[
1320+
{"N Components": 1, "Component 1": "C"},
1321+
{"N Components": 1, "Component 1": "CC(=O)N"},
1322+
{"N Components": 1, "Component 1": "CC(=O)CC(=O)C"},
1323+
]
1324+
)
1325+
1326+
filtered_frame = FilterByTautomers.apply(
1327+
data_frame,
1328+
FilterByTautomersSchema(categories_to_include=["AMIDE_IMIDIC_ACID"]),
1329+
)
1330+
1331+
assert len(filtered_frame) == 2
1332+
assert set(filtered_frame["Component 1"]) == {"C", "CC(=O)N"}
1333+
1334+
1335+
def test_filter_by_tautomers_explicit_exclude():
1336+
data_frame = pandas.DataFrame(
1337+
[
1338+
{"N Components": 1, "Component 1": "CC(=O)N"},
1339+
{"N Components": 1, "Component 1": "C[N+](=O)[O-]"},
1340+
]
1341+
)
1342+
1343+
filtered_frame = FilterByTautomers.apply(
1344+
data_frame,
1345+
FilterByTautomersSchema(
1346+
categories_to_include=None, categories_to_exclude=["AMIDE_IMIDIC_ACID"]
1347+
),
1348+
)
1349+
1350+
assert len(filtered_frame) == 1
1351+
assert filtered_frame.iloc[0]["Component 1"] == "C[N+](=O)[O-]"
1352+
1353+
1354+
def test_filter_by_tautomers_exclude_suppression_canonical():
1355+
"""Acetylacetone is canonically BETA_DIKETONE (suppression hides KETO_ENOL_ALIPHATIC).
1356+
Excluding KETO_ENOL_ALIPHATIC should therefore keep it; excluding BETA_DIKETONE
1357+
should remove it."""
1358+
data_frame = pandas.DataFrame([{"N Components": 1, "Component 1": "CC(=O)CC(=O)C"}])
1359+
1360+
# KETO_ENOL_ALIPHATIC is suppressed → molecule is kept
1361+
kept = FilterByTautomers.apply(
1362+
data_frame,
1363+
FilterByTautomersSchema(
1364+
categories_to_include=None,
1365+
categories_to_exclude=["KETO_ENOL_ALIPHATIC"],
1366+
),
1367+
)
1368+
assert len(kept) == 1
1369+
1370+
# BETA_DIKETONE is its canonical category → molecule is removed
1371+
removed = FilterByTautomers.apply(
1372+
data_frame,
1373+
FilterByTautomersSchema(
1374+
categories_to_include=None,
1375+
categories_to_exclude=["BETA_DIKETONE"],
1376+
),
1377+
)
1378+
assert len(removed) == 0
1379+
1380+
1381+
def test_filter_by_tautomers_include_suppression_applied():
1382+
"""In include mode, suppression must apply: enol-acetylacetone matches both
1383+
BETA_DIKETONE and KETO_ENOL_ALIPHATIC, but BETA_DIKETONE suppresses the
1384+
latter, so specifying categories_to_include=["BETA_DIKETONE"] must keep it.
1385+
The enol form CC(O)=CC(=O)C is used because BETA_DIKETONE's dominant form
1386+
is the enol."""
1387+
data_frame = pandas.DataFrame([{"N Components": 1, "Component 1": "CC(O)=CC(=O)C"}])
1388+
1389+
filtered_frame = FilterByTautomers.apply(
1390+
data_frame,
1391+
FilterByTautomersSchema(categories_to_include=["BETA_DIKETONE"]),
1392+
)
1393+
1394+
assert len(filtered_frame) == 1
1395+
1396+
1397+
def test_filter_by_tautomers_minor_form_rejected():
1398+
"""A molecule in the minor tautomeric form must be rejected even if its
1399+
category is whitelisted. CC(=O)O (acetic acid) is the dominant form and
1400+
passes; C=C(O)O (the enol/minor form) must be removed."""
1401+
data_frame = pandas.DataFrame(
1402+
[
1403+
{"N Components": 1, "Component 1": "CC(=O)O"}, # dominant — kept
1404+
{"N Components": 1, "Component 1": "C=C(O)O"}, # minor — removed
1405+
]
1406+
)
1407+
1408+
filtered_frame = FilterByTautomers.apply(data_frame, FilterByTautomersSchema())
1409+
1410+
assert len(filtered_frame) == 1
1411+
assert filtered_frame.iloc[0]["Component 1"] == "CC(=O)O"
1412+
1413+
1414+
def test_filter_by_tautomers_multi_component_row_removed():
1415+
data_frame = pandas.DataFrame(
1416+
[
1417+
{"N Components": 2, "Component 1": "C", "Component 2": "CCO"},
1418+
{
1419+
"N Components": 2,
1420+
"Component 1": "C",
1421+
"Component 2": "CC(=O)CC(=O)C",
1422+
},
1423+
]
1424+
)
1425+
1426+
filtered_frame = FilterByTautomers.apply(data_frame, FilterByTautomersSchema())
1427+
1428+
assert len(filtered_frame) == 1
1429+
assert filtered_frame.iloc[0]["Component 2"] == "CCO"
1430+
1431+
1432+
def test_filter_by_tautomers_empty_frame_no_failure():
1433+
data_frame = pandas.DataFrame(columns=["N Components", "Component 1"])
1434+
1435+
filtered_frame = FilterByTautomers.apply(data_frame, FilterByTautomersSchema())
1436+
1437+
assert len(filtered_frame) == 0
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import pytest
2+
from openff.toolkit.topology import Molecule
3+
4+
from openff.evaluator.datasets.curation.components.tautomers import (
5+
TAUTOMER_RULES,
6+
TautomerCategory,
7+
_enumerate_tautomers_cached,
8+
match_tautomer_categories,
9+
)
10+
11+
12+
def test_tautomer_category_table():
13+
assert set(TAUTOMER_RULES).issubset(set(TautomerCategory))
14+
15+
16+
@pytest.mark.parametrize(
17+
"category,dominant_smiles,minor_smiles",
18+
[
19+
# Dominant and minor SMILES are known molecules that each SMARTS must
20+
# directly match (no tautomer enumeration needed). This proves the
21+
# SMARTS are chemically meaningful, not merely syntactically valid.
22+
(TautomerCategory.AMIDE_IMIDIC_ACID, "CC(=O)N", "CC(=N)O"),
23+
(TautomerCategory.BETA_DIKETONE, "CC(O)=CC(=O)C", "CC(=O)CC(=O)C"),
24+
(TautomerCategory.KETO_ENOL_ALIPHATIC, "CCC(C)=O", "CCC(O)=C"),
25+
(TautomerCategory.KETO_ENOL_CYCLIC, "O=C1CCCCC1", "OC1=CCCCC1"),
26+
(TautomerCategory.THIOKETONE_THIOL_ALIPHATIC, "CC(=S)C", "CC(S)=C"),
27+
(TautomerCategory.KETO_ENOL_AROMATIC, "Oc1ccccc1", "O=C1C=CC=CC1"),
28+
(TautomerCategory.IMINE_ENAMINE_SECONDARY, "CCNC=C", "CCN=CC"),
29+
(TautomerCategory.IMINE_ENAMINE_PRIMARY, "CC=N", "CC(N)=C"),
30+
(TautomerCategory.ALPHA_AMINO_ACID, "NCC(=O)O", "N=CC(O)O"),
31+
# 4-methylimidazole: the two N-H tautomers have distinct SMILES, so each
32+
# SMARTS is tested against a different molecule and a swap bug is detectable.
33+
(TautomerCategory.ANNULAR_AZOLE, "Cc1c[nH]cn1", "Cc1cnc[nH]1"),
34+
(TautomerCategory.LACTAM_LACTIM, "O=C1CCCCN1", "OC1=NCCCC1"),
35+
(TautomerCategory.OXIME_NITROSO, "CC=NO", "CC(N=O)"),
36+
(TautomerCategory.KETENE_YNOL, "CC=C=O", "CC#CO"),
37+
(TautomerCategory.CARBOXYLIC_ACID_ENOL, "CC(=O)O", "C=C(O)O"),
38+
(TautomerCategory.ESTER_ENOL, "CC(=O)OC", "C=C(O)OC"),
39+
(TautomerCategory.AMIDE_ENOL, "CC(=O)NC", "C=C(O)NC"),
40+
],
41+
)
42+
def test_smarts_match_known_forms(category, dominant_smiles, minor_smiles):
43+
"""Each SMARTS must match a known molecule, not merely parse without error."""
44+
rule = TAUTOMER_RULES[category]
45+
dom_mol = Molecule.from_smiles(dominant_smiles, allow_undefined_stereo=True)
46+
min_mol = Molecule.from_smiles(minor_smiles, allow_undefined_stereo=True)
47+
assert dom_mol.chemical_environment_matches(
48+
rule.dominant_smarts
49+
), f"{category} dominant_smarts did not match {dominant_smiles!r}"
50+
assert min_mol.chemical_environment_matches(
51+
rule.minor_smarts
52+
), f"{category} minor_smarts did not match {minor_smiles!r}"
53+
54+
55+
@pytest.mark.parametrize(
56+
"smiles,expected_category",
57+
[
58+
("CC(=O)N", TautomerCategory.AMIDE_IMIDIC_ACID), # acetamide
59+
("CC(=O)CC(=O)C", TautomerCategory.BETA_DIKETONE), # acetylacetone
60+
("O=C1CCCCC1", TautomerCategory.KETO_ENOL_CYCLIC), # cyclohexanone
61+
("CC(C)=O", TautomerCategory.KETO_ENOL_ALIPHATIC), # acetone
62+
("c1ccccc1O", TautomerCategory.KETO_ENOL_AROMATIC), # phenol
63+
("Cc1ccc(O)cc1", TautomerCategory.KETO_ENOL_AROMATIC), # para-cresol
64+
("c1cnc[nH]1", TautomerCategory.ANNULAR_AZOLE), # imidazole
65+
("NCC(=O)O", TautomerCategory.ALPHA_AMINO_ACID), # glycine
66+
("O=C1CCCCN1", TautomerCategory.LACTAM_LACTIM), # 2-piperidinone
67+
("CC=NO", TautomerCategory.OXIME_NITROSO), # acetaldoxime
68+
(
69+
"CC=N",
70+
TautomerCategory.IMINE_ENAMINE_PRIMARY,
71+
), # ethylideneamine (acetaldehyde primary imine)
72+
("CCNC=C", TautomerCategory.IMINE_ENAMINE_SECONDARY), # secondary enamine
73+
("CC(=S)C", TautomerCategory.THIOKETONE_THIOL_ALIPHATIC), # thioacetone
74+
("CC=C=O", TautomerCategory.KETENE_YNOL), # methylketene
75+
("CC(=O)O", TautomerCategory.CARBOXYLIC_ACID_ENOL), # acetic acid
76+
("CC(=O)OC", TautomerCategory.ESTER_ENOL), # methyl acetate
77+
],
78+
)
79+
def test_match_tautomer_categories(smiles, expected_category):
80+
categories = match_tautomer_categories(smiles)
81+
assert expected_category in categories
82+
83+
84+
@pytest.mark.parametrize(
85+
"smiles",
86+
[
87+
"C", # methane
88+
"CC", # ethane
89+
"CCO", # ethanol
90+
"c1ccccc1", # benzene
91+
"C1CCCCC1", # cyclohexane
92+
"CCOCC", # diethyl ether
93+
],
94+
)
95+
def test_match_tautomer_categories_no_match(smiles):
96+
assert match_tautomer_categories(smiles) == frozenset()
97+
98+
99+
def test_amide_enol_secondary_amide():
100+
"""N-methyl acetamide (CC(=O)NC) triggers AMIDE_ENOL in raw SMARTS but is
101+
suppressed because AMIDE_IMIDIC_ACID co-fires and takes priority for amides."""
102+
raw = match_tautomer_categories("CC(=O)NC", suppress=False)
103+
assert TautomerCategory.AMIDE_ENOL in raw
104+
suppressed = match_tautomer_categories("CC(=O)NC")
105+
assert TautomerCategory.AMIDE_IMIDIC_ACID in suppressed
106+
assert TautomerCategory.AMIDE_ENOL not in suppressed
107+
108+
109+
@pytest.mark.parametrize(
110+
"category,smiles,expect_dominant,expect_minor",
111+
[
112+
# AMIDE_ENOL: both dominant and minor are reachable from a single seed.
113+
(TautomerCategory.AMIDE_ENOL, "CC(=O)NC", True, True),
114+
# Plot uses an alternate lactam seed to make the lactam/lactim pair
115+
# visually distinct while still matching both sides.
116+
(TautomerCategory.LACTAM_LACTIM, "O=C1NCCC=C1", True, True),
117+
],
118+
)
119+
def test_plot_example_tautomer_coverage(
120+
category,
121+
smiles,
122+
expect_dominant,
123+
expect_minor,
124+
):
125+
Molecule.from_smiles(smiles, allow_undefined_stereo=True)
126+
127+
rule = TAUTOMER_RULES[category]
128+
tautomers = _enumerate_tautomers_cached(smiles)
129+
130+
has_dominant = any(
131+
tautomer.chemical_environment_matches(rule.dominant_smarts)
132+
for tautomer in tautomers
133+
)
134+
has_minor = any(
135+
tautomer.chemical_environment_matches(rule.minor_smarts)
136+
for tautomer in tautomers
137+
)
138+
139+
assert has_dominant is expect_dominant
140+
assert has_minor is expect_minor

0 commit comments

Comments
 (0)