Skill Tracer
Skills › Scientific Agent Skills › datamol › workflow_patterns.md

workflow_patterns.md

115 lines · skills/datamol/references/workflow_patterns.md at 5f2bc03

Open on GitHub
1# Datamol pipeline patterns (0.13.0)
2
3These functions were tested on small synthetic inputs. Supply project inputs/paths when
4applying them. Use explicit structure policies and preserve source-row provenance.
5

Datamol pipeline patterns (0.13.0)

These functions were tested on small synthetic inputs. Supply project inputs/paths when applying them. Use explicit structure policies and preserve source-row provenance.

Raw lines 1 to 5
6## Load, screen and select diverse compounds
7
8```python
9import datamol as dm
10import numpy as np
11import pandas as pd
14def prepare_library(raw):
15 """Illustrative policy for ordinary organic compounds; returns accepted and rejected rows."""
16 if "mol" not in raw or "source_id" not in raw:
17 raise ValueError("Expected mol and source_id columns")
18 df = raw.copy()
19 df["source_smiles"] = [dm.to_smiles(m) if m is not None else None for m in df["mol"]]
20 transformed, reasons = [], []
21 for molecule in df["mol"]:
22 try:
23 clean = None if molecule is None else dm.standardize_mol(
24 molecule, disconnect_metals=False, normalize=True,
25 reionize=True, uncharge=False, stereo=True,
26 )
27 transformed.append(clean)
28 reasons.append(None if clean is not None else "invalid molecule")
29 except Exception as error:
30 transformed.append(None)
31 reasons.append(type(error).__name__)
32 df["mol"], df["rejection_reason"] = transformed, reasons
33 rejected = df.loc[df["mol"].isna()].copy()
34 accepted = df.loc[df["mol"].notna()].copy().reset_index(drop=True)
35 if accepted.empty:
36 return accepted, rejected
37 properties = {"mw": "MolWt", "logp": "MolLogP", "hbd": "NumHDonors", "hba": "NumHAcceptors"}
38 desc = dm.descriptors.batch_compute_many_descriptors(
39 accepted["mol"].tolist(), properties_fn=properties, add_properties=False, n_jobs=1,
40 )
41 # Both frames now have the same RangeIndex; no accidental label re-alignment.
42 accepted = pd.concat([accepted, desc.add_prefix("screen_")], axis=1)
43 accepted["passes_screen"] = ((desc["mw"] <= 500) & (desc["logp"] <= 5)
44 & (desc["hbd"] <= 5) & (desc["hba"] <= 10))
45 return accepted, rejected
48def select_diverse(accepted, limit=100):
49 if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1:
50 raise ValueError("limit must be a positive integer")
51 if accepted.empty:
52 return accepted.copy()
53 eligible = accepted.loc[accepted["passes_screen"]].copy().reset_index(drop=True)
54 if eligible.empty:
55 return eligible
56 indices, _ = dm.pick_diverse(eligible["mol"].tolist(), npick=min(limit, len(eligible)), seed=42)
57 return eligible.iloc[indices].copy()
58```
60Read SDF using `dm.read_sdf(path, as_df=True, mol_column="mol", discard_invalid=False)`;
61assign or validate a durable `source_id` before passing it to `prepare_library`. Its
62`source_smiles` is the parsed representation; archive raw files separately to retain records
63that cannot be parsed. Report all parsed-invalid and screening-excluded rows. Do not treat
64the screening criteria as validated activity, safety or bioavailability predictions.
66```python
67# Executed synthetic example with a failed record and non-contiguous row labels.
68raw = pd.DataFrame({"source_id": ["a", "bad", "b"],
69 "mol": [dm.to_mol("CCO"), None, dm.to_mol("c1ccccc1")]}, index=[8, 2, 20])
70accepted, rejected = prepare_library(raw)
71selection = select_diverse(accepted, limit=100)
72assert set(selection["source_id"]) == {"a", "b"}
73assert rejected["source_id"].tolist() == ["bad"]
74```
76Export the selected table and an ID-labelled PNG grid explicitly with `use_svg=False`.
77Keep rejected rows separately. Use a project-approved destination before writing artifacts.

Load, screen and select diverse compounds

import datamol as dm
import numpy as np
import pandas as pd


def prepare_library(raw):
    """Illustrative policy for ordinary organic compounds; returns accepted and rejected rows."""
    if "mol" not in raw or "source_id" not in raw:
        raise ValueError("Expected mol and source_id columns")
    df = raw.copy()
    df["source_smiles"] = [dm.to_smiles(m) if m is not None else None for m in df["mol"]]
    transformed, reasons = [], []
    for molecule in df["mol"]:
        try:
            clean = None if molecule is None else dm.standardize_mol(
                molecule, disconnect_metals=False, normalize=True,
                reionize=True, uncharge=False, stereo=True,
            )
            transformed.append(clean)
            reasons.append(None if clean is not None else "invalid molecule")
        except Exception as error:
            transformed.append(None)
            reasons.append(type(error).__name__)
    df["mol"], df["rejection_reason"] = transformed, reasons
    rejected = df.loc[df["mol"].isna()].copy()
    accepted = df.loc[df["mol"].notna()].copy().reset_index(drop=True)
    if accepted.empty:
        return accepted, rejected
    properties = {"mw": "MolWt", "logp": "MolLogP", "hbd": "NumHDonors", "hba": "NumHAcceptors"}
    desc = dm.descriptors.batch_compute_many_descriptors(
        accepted["mol"].tolist(), properties_fn=properties, add_properties=False, n_jobs=1,
    )
    # Both frames now have the same RangeIndex; no accidental label re-alignment.
    accepted = pd.concat([accepted, desc.add_prefix("screen_")], axis=1)
    accepted["passes_screen"] = ((desc["mw"] <= 500) & (desc["logp"] <= 5)
                                 & (desc["hbd"] <= 5) & (desc["hba"] <= 10))
    return accepted, rejected


def select_diverse(accepted, limit=100):
    if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1:
        raise ValueError("limit must be a positive integer")
    if accepted.empty:
        return accepted.copy()
    eligible = accepted.loc[accepted["passes_screen"]].copy().reset_index(drop=True)
    if eligible.empty:
        return eligible
    indices, _ = dm.pick_diverse(eligible["mol"].tolist(), npick=min(limit, len(eligible)), seed=42)
    return eligible.iloc[indices].copy()

Read SDF using dm.read_sdf(path, as_df=True, mol_column="mol", discard_invalid=False); assign or validate a durable source_id before passing it to prepare_library. Its source_smiles is the parsed representation; archive raw files separately to retain records that cannot be parsed. Report all parsed-invalid and screening-excluded rows. Do not treat the screening criteria as validated activity, safety or bioavailability predictions.

# Executed synthetic example with a failed record and non-contiguous row labels.
raw = pd.DataFrame({"source_id": ["a", "bad", "b"],
                    "mol": [dm.to_mol("CCO"), None, dm.to_mol("c1ccccc1")]}, index=[8, 2, 20])
accepted, rejected = prepare_library(raw)
selection = select_diverse(accepted, limit=100)
assert set(selection["source_id"]) == {"a", "b"}
assert rejected["source_id"].tolist() == ["bad"]

Export the selected table and an ID-labelled PNG grid explicitly with use_svg=False. Keep rejected rows separately. Use a project-approved destination before writing artifacts.

Raw lines 6 to 78
79## SAR by scaffold series
81Illustrative template: `mols`, `activities` and activity units must come from the same rows.
82Do not mix assays, censoring conventions or pActivity versus concentration scales.
84```python
85def sar_table(mols, activities):
86 if len(mols) != len(activities):
87 raise ValueError("Molecules and activities must be row-aligned")
88 return pd.DataFrame({"mol": mols,
89 "scaffold": [dm.to_smiles(dm.to_scaffold_murcko(m)) for m in mols],
90 "activity": activities})
91```
93Group this table by scaffold to summarize measured values and visualize related compounds
94with labelled units and `align=True`. An empty Murcko scaffold groups acyclic molecules
95without asserting they form a meaningful SAR series.

SAR by scaffold series

Illustrative template: mols, activities and activity units must come from the same rows. Do not mix assays, censoring conventions or pActivity versus concentration scales.

def sar_table(mols, activities):
    if len(mols) != len(activities):
        raise ValueError("Molecules and activities must be row-aligned")
    return pd.DataFrame({"mol": mols,
                         "scaffold": [dm.to_smiles(dm.to_scaffold_murcko(m)) for m in mols],
                         "activity": activities})

Group this table by scaffold to summarize measured values and visualize related compounds with labelled units and align=True. An empty Murcko scaffold groups acyclic molecules without asserting they form a meaningful SAR series.

Raw lines 79 to 96
97## Similarity ranking
99```python
100def similarity_ranking(query_actives, library_mols, limit=100):
101 if not query_actives or not library_mols:
102 raise ValueError("Need nonempty query and library molecules")
103 if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1:
104 raise ValueError("limit must be a positive integer")
105 distances = dm.cdist(query_actives, library_mols, n_jobs=1,
106 fp_type="ecfp", radius=2, fpSize=2048, includeChirality=True)
107 similarities = 1 - distances.min(axis=0)
108 indices = np.argsort(-similarities, kind="stable")[:limit]
109 return indices, similarities[indices]
110```
112This ranks by maximum binary Tanimoto similarity to any query; a hit is a structural
113candidate, not an experimentally confirmed active. Keep the source index alongside score.
114The returned cross-distance matrix uses O(query count × library count) memory. Chunk the
115library and retain top results for large screens, with the same fingerprint settings in every chunk.

Similarity ranking

def similarity_ranking(query_actives, library_mols, limit=100):
    if not query_actives or not library_mols:
        raise ValueError("Need nonempty query and library molecules")
    if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1:
        raise ValueError("limit must be a positive integer")
    distances = dm.cdist(query_actives, library_mols, n_jobs=1,
                         fp_type="ecfp", radius=2, fpSize=2048, includeChirality=True)
    similarities = 1 - distances.min(axis=0)
    indices = np.argsort(-similarities, kind="stable")[:limit]
    return indices, similarities[indices]

This ranks by maximum binary Tanimoto similarity to any query; a hit is a structural candidate, not an experimentally confirmed active. Keep the source index alongside score. The returned cross-distance matrix uses O(query count × library count) memory. Chunk the library and retain top results for large screens, with the same fingerprint settings in every chunk.

Raw lines 97 to 115