Skip to content

Commit c43cb83

Browse files
committed
update symbolic classifiers
1 parent 8911830 commit c43cb83

6 files changed

Lines changed: 101 additions & 28 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ Not all models can be installed automatically at the moment:
1010
- `chebai-graph` and its dependencies. To install them, follow
1111
the instructions in the [chebai-graph repository](https://github.com/ChEB-AI/python-chebai-graph).
1212
- `chemlog-extra` can be installed with `pip install git+https://github.com/ChEB-AI/chemlog-extra.git`
13-
- The automatically installed version of `c3p` may not work under Windows. If you want to run chebifier on Windows, we
14-
recommend using this forked version: `pip install git+https://github.com/sfluegel05/c3p.git`
13+
- `c3p` reads its generated programs assuming a UTF-8 locale and guards each of them with a
14+
SIGALRM-based timeout, neither of which holds on Windows. The `c3p` predictor works around both
15+
(see `_patch_c3p`), at the price of running the programs without a timeout there.
1516

1617

1718
You can get the package from PyPI:

chebifier/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,9 @@ def build(
234234
ensemble_param,
235235
):
236236
"""Build (calibrate) an ensemble on the ChEBI validation set."""
237-
base_learners = build_base_learners(ensemble_config)
237+
base_learners = build_base_learners(
238+
ensemble_config, prediction_cache_dir=prediction_cache_dir, split="validation"
239+
)
238240
ensemble_model = ENSEMBLES[ensemble_type](
239241
ensemble_dir, **parse_ensemble_params(ensemble_param)
240242
)

chebifier/prediction_models/c3p_predictor.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,37 @@
1+
import functools
12
from pathlib import Path
23
from typing import List, Optional
34

45
import tqdm
56

67
from chebifier import modelwise_smiles_lru_cache
78
from chebifier.prediction_models import BasePredictor
9+
from chebifier.utils import get_superclasses, to_smiles
10+
11+
12+
def _patch_c3p(c3p_classifier):
13+
"""Two things C3P 0.5.0 assumes that do not hold on Windows, both of which make it return no
14+
classification at all rather than a wrong one:
15+
16+
- it reads its generated programs with `open(program, "r")`, i.e. in the platform default
17+
encoding, while the programs are UTF-8. `open` is looked up in the module globals before the
18+
builtins, so binding a UTF-8 `open` there fixes the reads without affecting any other module.
19+
- it guards every program with timeout_decorator, which needs SIGALRM. Running the programs
20+
without their 2s timeout is the only way to get predictions on a platform that has no
21+
SIGALRM - timeout_decorator's signal-free mode forks a process per call, which is not
22+
affordable for 300 programs per molecule.
23+
"""
24+
import signal
25+
26+
if not hasattr(c3p_classifier, "open"):
27+
c3p_classifier.open = functools.partial(open, encoding="utf-8")
28+
if hasattr(signal, "SIGALRM"):
29+
return
30+
from c3p import learn
31+
32+
if hasattr(learn.eval_with_timeout, "__wrapped__"):
33+
print("No SIGALRM on this platform, running C3P programs without a timeout.")
34+
learn.eval_with_timeout = learn.eval_with_timeout.__wrapped__
835

936

1037
class C3PPredictor(BasePredictor):
@@ -28,6 +55,9 @@ def __init__(
2855
def predict_list(self, smiles_list: list[str]) -> list:
2956
from c3p import classifier as c3p_classifier
3057

58+
_patch_c3p(c3p_classifier)
59+
# C3P only takes SMILES, while the evaluation datasets hand out RDKit molecules
60+
smiles_list = [to_smiles(molecule) for molecule in smiles_list]
3161
result_list = []
3262
for batch_start in tqdm.tqdm(
3363
range(0, len(smiles_list), 32), desc="Classifying with C3P"
@@ -55,9 +85,7 @@ def predict_list(self, smiles_list: list[str]) -> list:
5585
for result in tqdm.tqdm(result_list, desc="Reformatting C3P results"):
5686
chebi_id = result.class_id.split(":")[1]
5787
if result.is_match and self.chebi_graph is not None:
58-
parents = [
59-
str(parent) for parent in self.chebi_graph.predecessors(chebi_id)
60-
]
88+
parents = get_superclasses(self.chebi_graph, chebi_id)
6189
else:
6290
parents = []
6391
for idx in indices_by_smiles[result.input_smiles]:
@@ -74,6 +102,7 @@ def explain_smiles(self, smiles):
74102
"""
75103
from c3p import classifier as c3p_classifier
76104

105+
_patch_c3p(c3p_classifier)
77106
highlights = []
78107
result_list = c3p_classifier.classify(
79108
[smiles], self.program_directory, self.chemical_classes, strict=False

chebifier/prediction_models/chebi_lookup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from chebifier import modelwise_smiles_lru_cache
88
from chebifier.prediction_models import BasePredictor
9-
from chebifier.utils import _smiles_to_mol, load_chebi_graph
9+
from chebifier.utils import _smiles_to_mol, get_superclasses, load_chebi_graph
1010

1111

1212
class ChEBILookupPredictor(BasePredictor):
@@ -61,7 +61,7 @@ def build_smiles_lookup(self):
6161
smiles_lookup[canonical_smiles] = []
6262
# if the canonical SMILES is already in the lookup, append "different interpretation of the SMILES"
6363
smiles_lookup[canonical_smiles].append(
64-
(chebi_id, list(self.chebi_graph.predecessors(chebi_id)))
64+
(chebi_id, list(get_superclasses(self.chebi_graph, chebi_id)))
6565
)
6666
except Exception as e:
6767
print(

chebifier/prediction_models/chemlog_predictor.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from chebifier import modelwise_smiles_lru_cache
66
from chebifier.prediction_models.base_predictor import BasePredictor
7+
from chebifier.utils import CHEBI_VERSION, get_superclasses, to_mol
78

89
AA_DICT = {
910
"A": "L-alanine",
@@ -70,41 +71,41 @@ def predict_list(self, smiles_list: list[str]) -> list:
7071
return self._predict_smiles_list(smiles_list)
7172

7273
def _predict_smiles_list(self, smiles_list: list[str]) -> list:
73-
from chemlog.cli import _smiles_to_mol
74-
75-
mol_list = [_smiles_to_mol(smiles) for smiles in smiles_list]
74+
mol_list = [to_mol(molecule) for molecule in smiles_list]
7675
res = self.classifier.classify(mol_list)
7776
if self.chebi_graph is not None:
7877
for sample in res:
7978
sample_additions = dict()
8079
for cls in sample:
8180
if sample[cls] == 1:
82-
successors = list(self.chebi_graph.predecessors(cls))
83-
if successors:
84-
for succ in successors:
85-
sample_additions[str(succ)] = 1
81+
for superclass in get_superclasses(self.chebi_graph, cls):
82+
sample_additions[superclass] = 1
8683
sample.update(sample_additions)
8784
return res
8885

8986

9087
class ChemlogXMolecularEntityPredictor(ChemlogExtraPredictor):
91-
def __init__(self, model_name: str, **kwargs):
88+
def __init__(self, model_name: str, chebi_version: int = CHEBI_VERSION, **kwargs):
9289
from chemlog_extra.alg_classification.by_element_classification import (
9390
XMolecularEntityClassifier,
9491
)
9592

9693
super().__init__(model_name, **kwargs)
97-
self.classifier = XMolecularEntityClassifier(chebi_graph=self.chebi_graph)
94+
self.classifier = XMolecularEntityClassifier(
95+
chebi_graph=self.chebi_graph, chebi_version=chebi_version
96+
)
9897

9998

10099
class ChemlogOrganoXCompoundPredictor(ChemlogExtraPredictor):
101-
def __init__(self, model_name: str, **kwargs):
100+
def __init__(self, model_name: str, chebi_version: int = CHEBI_VERSION, **kwargs):
102101
from chemlog_extra.alg_classification.by_element_classification import (
103102
OrganoXCompoundClassifier,
104103
)
105104

106105
super().__init__(model_name, **kwargs)
107-
self.classifier = OrganoXCompoundClassifier(chebi_graph=self.chebi_graph)
106+
self.classifier = OrganoXCompoundClassifier(
107+
chebi_graph=self.chebi_graph, chebi_version=chebi_version
108+
)
108109

109110

110111
class ChemlogLopsterPredictor(ChemlogExtraPredictor):
@@ -142,9 +143,9 @@ def __init__(self, model_name: str, **kwargs):
142143
print(f"Initialised ChemLog model {self.model_name}")
143144

144145
def predict(self, smiles: str) -> Optional[dict]:
145-
from chemlog.cli import _smiles_to_mol, strategy_call
146+
from chemlog.cli import strategy_call
146147

147-
mol = _smiles_to_mol(smiles)
148+
mol = to_mol(smiles)
148149
if mol is None:
149150
return None
150151
pos_labels = [
@@ -157,9 +158,9 @@ def predict(self, smiles: str) -> Optional[dict]:
157158
]
158159
if self.chebi_graph:
159160
indirect_pos_labels = [
160-
str(pr)
161+
superclass
161162
for label in pos_labels
162-
for pr in self.chebi_graph.predecessors(label)
163+
for superclass in get_superclasses(self.chebi_graph, label)
163164
]
164165
pos_labels = list(set(pos_labels + indirect_pos_labels))
165166
return {
@@ -181,7 +182,7 @@ def _predict_smiles_list(self, smiles_list: list[str]) -> list:
181182

182183
return results
183184

184-
def get_chemlog_result_info(self, smiles):
185+
def get_chemlog_result_info(self, molecule):
185186
"""Get classification for single molecule with additional information."""
186187
from chemlog.alg_classification.charge_classifier import get_charge_category
187188
from chemlog.alg_classification.peptide_size_classifier import (
@@ -194,10 +195,9 @@ def get_chemlog_result_info(self, smiles):
194195
is_diketopiperazine,
195196
is_emericellamide,
196197
)
197-
from chemlog.cli import _smiles_to_mol
198198

199-
mol = _smiles_to_mol(smiles)
200-
if mol is None or not smiles:
199+
mol = to_mol(molecule) if molecule else None
200+
if mol is None:
201201
return {"error": "Failed to parse SMILES"}
202202

203203
charge_category = get_charge_category(mol)

chebifier/utils.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
import os
44
import pickle
55

6+
import networkx as nx
67
import yaml
8+
from chebi_utils.obo_extractor import get_hierarchy_subgraph
79
from rdkit import Chem
810

911
from chebifier.hugging_face import download_model_files
1012

13+
CHEBI_VERSION = 252
14+
1115

1216
def load_chebi_graph(filename=None):
1317
"""Load ChEBI graph from Hugging Face (if filename is None) or local file"""
@@ -17,7 +21,7 @@ def load_chebi_graph(filename=None):
1721
{
1822
"repo_id": "chebai/chebifier",
1923
"repo_type": "dataset",
20-
"files": {"f": "chebi_graph_v252.pkl"},
24+
"files": {"f": f"chebi_graph_v{CHEBI_VERSION}.pkl"},
2125
}
2226
)["f"]
2327
else:
@@ -89,3 +93,40 @@ def _smiles_to_mol(smiles: str):
8993
except Chem.KekulizeException as e:
9094
print(f"Failed to Kekulize {smiles}: {e}")
9195
return mol
96+
97+
98+
def to_mol(molecule: str | Chem.Mol):
99+
"""Molecules reach a predictor either as SMILES or as RDKit molecules (the evaluation datasets
100+
store the latter). Rule-based classifiers expect kekulised molecules, and Kekulize works in
101+
place, so a molecule that is not ours to modify is copied first."""
102+
if not isinstance(molecule, Chem.Mol):
103+
return _smiles_to_mol(molecule)
104+
molecule = Chem.Mol(molecule)
105+
try:
106+
Chem.Kekulize(molecule)
107+
except Chem.KekulizeException as e:
108+
print(f"Failed to Kekulize {Chem.MolToSmiles(molecule)}: {e}")
109+
return molecule
110+
111+
112+
def to_smiles(molecule: str | Chem.Mol) -> str:
113+
return Chem.MolToSmiles(molecule) if isinstance(molecule, Chem.Mol) else molecule
114+
115+
116+
@functools.lru_cache(maxsize=2)
117+
def _isa_graph(chebi_graph):
118+
return get_hierarchy_subgraph(chebi_graph)
119+
120+
121+
@functools.lru_cache(maxsize=None)
122+
def get_superclasses(chebi_graph, chebi_id: str) -> tuple[str, ...]:
123+
"""All transitive superclasses of a ChEBI class.
124+
125+
is-a edges point from child to parent, and the graph also carries non-subsumption relations
126+
(has role, conjugate acid/base, ...), so the superclasses of a node are neither its
127+
predecessors nor all of its successors.
128+
"""
129+
isa_graph = _isa_graph(chebi_graph)
130+
if chebi_id not in isa_graph:
131+
return ()
132+
return tuple(str(cls) for cls in nx.descendants(isa_graph, chebi_id))

0 commit comments

Comments
 (0)