Skip to content

Commit e6d5bdd

Browse files
authored
OP dataset generator (for use in Kaggle Dataset publishing) (#491)
* Generate OP dataset from experiments. First prototype. * Isolate OPDataStorer class. Improve storing. * Add sim OP dataset. Refactor to remove code duplication. Add polymorphism. * feat: add --sim / --exp to gen-op-dataset.py * fix: small changes to address the review * publish default op error constant * fix: improve CLI behavior of gen-op-dataset * chore: refactor default h5 filenames * fix: include logging, gather statistics * fix: refactor to remove code duplication
1 parent be9bc96 commit e6d5bdd

4 files changed

Lines changed: 306 additions & 2 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ cover/
2525
.vscode/
2626

2727
# development
28+
developer/*.h5
2829
AddData-dev.ipynb
2930
test.py
3031

developer/gen-op-dataset.py

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import logging
5+
import re
6+
import sys
7+
from abc import ABC, abstractmethod
8+
9+
import numpy as np
10+
import pandas as pd
11+
from unidecode import unidecode
12+
13+
from fairmd.lipids._base import SampleComposition
14+
from fairmd.lipids.api import get_OP
15+
from fairmd.lipids.core import System, initialize_databank
16+
from fairmd.lipids.experiment import ExperimentCollection, OPExperiment
17+
from fairmd.lipids.molecules import Molecule
18+
19+
20+
class OPDataError(Exception):
21+
"""Our specific exception"""
22+
23+
24+
class OPDataStorer(ABC):
25+
"""Abstract class for OP data storage"""
26+
27+
@abstractmethod
28+
def prepare_dataframe(self) -> None:
29+
"""Prepare dataframe for storing"""
30+
31+
@property
32+
@abstractmethod
33+
def ass_id(self) -> str:
34+
"""Get id of assoc object"""
35+
36+
@property
37+
@abstractmethod
38+
def sample(self) -> SampleComposition:
39+
"""Return sample instance"""
40+
41+
def _prepare_df_common(self, mol: Molecule, opdict: dict, err_extractor: callable) -> None:
42+
"""Condition the dataframe for storage"""
43+
smi2uname = {}
44+
for uname, aprops in mol.mapping_dict.items():
45+
if "SMILEIDX" in aprops:
46+
smid = int(aprops["SMILEIDX"])
47+
smi2uname[smid] = uname
48+
if not smi2uname:
49+
# NO SMILEIDX. Cannot store.
50+
msg = f"Instance {self.ass_id} // {self._lname} cannot be stored: we don't have SMILEIDX."
51+
raise OPDataError(msg)
52+
smi2uname = dict(sorted(smi2uname.items()))
53+
df = pd.DataFrame(columns=["id", "val", "err"])
54+
cur_row = 0
55+
for id_, uname in smi2uname.items():
56+
for opdict_row in OPDataStorer.by_c_uname(opdict, uname):
57+
df.loc[cur_row] = [id_, opdict_row[0], err_extractor(opdict_row)]
58+
cur_row += 1
59+
self._df = df
60+
61+
@staticmethod
62+
def by_c_uname(opdict: dict, uname: str) -> dict:
63+
"""Return only OP vals of named heavy atoms. Sorted by val."""
64+
res = []
65+
for k, v in opdict.items():
66+
if k.split()[0] == uname:
67+
res.append(v)
68+
return sorted(res, key=lambda x: x[0])
69+
70+
def get_mcontent(self) -> dict:
71+
"""Generate membrane content dictionary"""
72+
_mcontent = self.sample.membrane_composition(basis="molar")
73+
rdict = {"name": [], "inchikey": [], "fraction": []}
74+
for lname, frac in _mcontent.items():
75+
k = lname
76+
ik = self.sample.lipids[lname].metadata["bioschema_properties"]["inChIKey"]
77+
rdict["name"] += [k]
78+
rdict["inchikey"] += [ik]
79+
rdict["fraction"] += [frac]
80+
return rdict
81+
82+
def get_DF_attrs(self) -> tuple[dict, dict]:
83+
"""Genererate attributes for OP and Composition dataframes"""
84+
inchikey = self.sample.lipids[self._lname].metadata["bioschema_properties"]["inChIKey"]
85+
smiles = self.sample.lipids[self._lname].metadata["bioschema_properties"]["smiles"]
86+
opt_attr = {
87+
"inchikey": inchikey,
88+
"smiles": smiles,
89+
}
90+
hydration = self.sample.get_hydration()
91+
scontent = self.sample.solution_composition(basis="molar")
92+
smp_attr = {
93+
"hydration": hydration,
94+
"solution": ", ".join([f"{k:<25} {v * 100:>6.1f}%" for k, v in sorted(scontent.items())]),
95+
}
96+
return opt_attr, smp_attr
97+
98+
@abstractmethod
99+
def get_H5_gname(self) -> str:
100+
"""Generate name for the record in the H5 table"""
101+
102+
def store_to_hdf5(self, hdf_fname: str) -> None:
103+
mcontent = self.get_mcontent()
104+
opt_attr, smp_attr = self.get_DF_attrs()
105+
# store all vars and df to the HDF5 table
106+
group = self.get_H5_gname()
107+
with pd.HDFStore(hdf_fname, "a") as store:
108+
# DataFrame table
109+
store.put(f"{group}/op_values", self._df, format="table", data_columns=True)
110+
opdf = pd.DataFrame(mcontent)
111+
print(opdf)
112+
store.put(f"{group}/sample_table", opdf, format="table", data_columns=True)
113+
# Metadata attributes - I
114+
op_storer = store.get_storer(f"{group}/op_values")
115+
for k, v in opt_attr.items():
116+
op_storer.attrs[k] = v
117+
# -//- II
118+
sample_storer = store.get_storer(f"{group}/sample_table")
119+
for k, v in smp_attr.items():
120+
sample_storer.attrs[k] = v
121+
122+
123+
class ExpOPDataStorer(OPDataStorer):
124+
"""OP data storer for experiments"""
125+
126+
DEFAULT_EXP_HDFNAME = "exp-op-dataset.h5"
127+
"""Default filename for the experimental dataset"""
128+
129+
@property
130+
def ass_id(self) -> str:
131+
return self._e.exp_id
132+
133+
@property
134+
def sample(self) -> SampleComposition:
135+
return self._e
136+
137+
def get_H5_gname(self) -> str:
138+
group = "E"
139+
group += re.sub(r"[^A-Za-z0-9_]", "_", unidecode(self._e.exp_id))
140+
group += "__" + self._lname
141+
return group
142+
143+
def get_DF_attrs(self) -> tuple[dict, dict]:
144+
opt_attr, smp_attr = super().get_DF_attrs()
145+
opt_attr ["fmdl_expid"] = self._e.exp_id
146+
nmr_method = self._e.metadata.get("NMR", {}).get("METHOD", False)
147+
if nmr_method:
148+
opt_attr["nmr_method"] = nmr_method
149+
smp_attr["temperature"] = self._e["TEMPERATURE"]
150+
return opt_attr, smp_attr
151+
152+
def __init__(self, e: OPExperiment, lname: str) -> None:
153+
"""Initialize with experiment object and lipid name"""
154+
self._e: OPExperiment = e
155+
self._lname = lname
156+
157+
def prepare_dataframe(self) -> None:
158+
"""Call dataframe preparation"""
159+
mol = self._e.lipids[self._lname]
160+
opdict = self._e.data[self._lname]
161+
self._prepare_df_common(
162+
mol,
163+
opdict,
164+
err_extractor=lambda x: OPExperiment.DEFAULT_ERROR if len(x) == 1 else x[1],
165+
)
166+
167+
168+
class SimOPDataStorer(OPDataStorer):
169+
DEFAULT_SIMS_HDFNAME = "sims-op-dataset.h5"
170+
"""Default Dataset Filename"""
171+
172+
@property
173+
def ass_id(self):
174+
return self._s["ID"]
175+
176+
@property
177+
def sample(self) -> SampleComposition:
178+
return self._s
179+
180+
def prepare_dataframe(self):
181+
mol = self._s.lipids[self._lname]
182+
opdict = get_OP(self._s)[self._lname]
183+
self._prepare_df_common(mol, opdict, err_extractor=lambda x: x[2])
184+
185+
def get_H5_gname(self) -> str:
186+
return f"SIM_{self._s['ID']}__{self._lname}"
187+
188+
def get_mcontent(self):
189+
retdic = super().get_mcontent()
190+
retdic["number"] = [0] * len(retdic["name"])
191+
retdic["asymmetry"] = [0] * len(retdic["name"])
192+
_simcomp = self._s["COMPOSITION"]
193+
for i, lname in enumerate(retdic["name"]):
194+
cnt = _simcomp[lname]["COUNT"]
195+
if isinstance(cnt, int):
196+
asm = np.nan
197+
cnt = [cnt / 2, cnt / 2]
198+
else:
199+
asm = cnt[0] / sum(cnt)
200+
retdic["number"][i] = sum(cnt) / 2
201+
retdic["asymmetry"][i] = asm
202+
return retdic
203+
204+
def get_DF_attrs(self) -> tuple[dict, dict]:
205+
opt_attr, smp_attr = super().get_DF_attrs()
206+
opt_attr["fmdl_simid"] = self._s["ID"]
207+
smp_attr["temperature"] = self._s["TEMPERATURE"]
208+
ff_name = self._s.readme.get("FF", False)
209+
if ff_name:
210+
smp_attr["ff_name"] = ff_name
211+
return opt_attr, smp_attr
212+
213+
def __init__(self, sim: System, lname: str) -> None:
214+
self._s = sim
215+
self._lname = lname
216+
217+
218+
def main_exps(log: logging.Logger) -> tuple[int, int]:
219+
"""Generate dataset for experiments"""
220+
stat_ok, stat_fail = 0, 0
221+
log.info("\n\nGenerating OP datasets from experiments.")
222+
exps = ExperimentCollection.load_from_data("OPExperiment")
223+
for exp in exps:
224+
for lname in exp.data:
225+
log.info("%s // %s", str(exp), lname)
226+
ods = ExpOPDataStorer(exp, lname)
227+
try:
228+
ods.prepare_dataframe()
229+
except OPDataError as e:
230+
log.error("[from .prepare_dataframe] %s", str(e)) # noqa: TRY400
231+
stat_fail += 1
232+
continue
233+
else:
234+
stat_ok += 1
235+
ods.store_to_hdf5(ExpOPDataStorer.DEFAULT_EXP_HDFNAME)
236+
log.info("..stored!")
237+
return stat_ok, stat_fail
238+
239+
240+
def main_sims(log: logging.Logger) -> tuple[int, int]:
241+
"""Generate dataset from simulations"""
242+
stat_ok, stat_fail = 0, 0
243+
log.info("\n\nGenerating OP dataset from simulations.")
244+
sims = initialize_databank()
245+
for sim in sims:
246+
opdata = get_OP(sim)
247+
for lname in opdata:
248+
if opdata is None or opdata[lname] is None:
249+
stat_fail += 1
250+
continue
251+
log.info("%s // %s", str(sim), lname)
252+
ods = SimOPDataStorer(sim, lname)
253+
try:
254+
ods.prepare_dataframe()
255+
except OPDataError as e:
256+
log.error("[from .prepare_dataframe] %s", str(e)) # noqa: TRY400
257+
stat_fail += 1
258+
continue
259+
else:
260+
stat_ok += 1
261+
ods.store_to_hdf5(SimOPDataStorer.DEFAULT_SIMS_HDFNAME)
262+
log.info("..stored!")
263+
return stat_ok, stat_fail
264+
265+
266+
if __name__ == "__main__":
267+
parser = argparse.ArgumentParser(
268+
prog="OP Dataset Generator",
269+
description="""
270+
CI helper script for delivering dataset for Kaggle in HDF5 format.
271+
Two DataFrames are stored for each Sim/Exp-lipid pair:
272+
1. SMILES-aligned values for each atom of the molecule
273+
2. Composition table of membrane part of the system""",
274+
formatter_class=argparse.RawDescriptionHelpFormatter,
275+
)
276+
parser.add_argument("--exps", action="store_true", help="Generate DS from experiments")
277+
parser.add_argument("--sims", action="store_true", help="Generate DS from simulations")
278+
args = parser.parse_args()
279+
280+
if not args.exps and not args.sims:
281+
parser.print_usage()
282+
sys.exit(1)
283+
284+
lg = logging.getLogger("cli")
285+
lg.setLevel(logging.INFO)
286+
h_stderr = logging.StreamHandler(sys.stderr)
287+
h_stderr.setLevel(logging.INFO)
288+
lg.addHandler(h_stderr)
289+
290+
if args.exps:
291+
e_ok, e_fail = main_exps(lg)
292+
if args.sims:
293+
s_ok, s_fail = main_sims(lg)
294+
295+
lg.info("======= STATISTICS ========")
296+
if args.exps:
297+
lg.info(f"Stored experiment-lipid pairs: {e_ok} // failed: {e_fail}")
298+
if args.sims:
299+
lg.info(f"Stored simulation-lipid pairs: {s_ok} // failed: {s_fail}")

src/fairmd/lipids/experiment.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ class OPExperiment(Experiment):
194194
:meth:`verify_data` checks that all atom unique names in the data correspond to known atoms in the lipid mapping.
195195
"""
196196

197+
DEFAULT_ERROR = 0.02
198+
"""Default error value to use when it is not provided"""
199+
197200
@property
198201
def data(self) -> dict:
199202
if self._data is None:

src/fairmd/lipids/quality.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from fairmd.lipids.api import get_FF, get_OP
1919
from fairmd.lipids.auxiliary import CompactJSONEncoder, mollib
2020
from fairmd.lipids.core import System, initialize_databank
21-
from fairmd.lipids.experiment import ExperimentCollection
21+
from fairmd.lipids.experiment import ExperimentCollection, OPExperiment
2222

2323

2424
class QualSimulation(System):
@@ -215,7 +215,8 @@ def atomic_quality(cls, exp_op_data: dict, sim_op_data: dict) -> dict:
215215
216216
:return: dictionary of type {"nC nH": quality value}.
217217
"""
218-
exp_error = 0.02 # TODO: hardcoded error value, should be taken from experiment data when available
218+
# TODO: hardcoded error value, should be taken from experiment data when available
219+
exp_error = OPExperiment.DEFAULT_ERROR
219220

220221
# union of keys in exp_op_data and sim_op_data
221222
all_keys = sorted(set(exp_op_data.keys()) | set(sim_op_data.keys()))

0 commit comments

Comments
 (0)