Claude Skill

alterlab-medchem

Applies medicinal-chemistry filters with the medchem library — drug-likeness rules (Lipinski, Veber), PAINS filters, structural alerts, and molecular complexity metrics for compound prioritization and library cleanup. Use when filtering or triaging a compound library, flagging PA

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download alterlab-ieu-alterlab-academic-skills-skills_cheminformatics_alterlab-medchem-e4836c0.zip · 21 KB
Part of alterlab-ieu/alterlab-academic-skills — 94 skills

Install

skills CLI npx skills add https://github.com/AlterLab-IEU/AlterLab-Academic-Skills/tree/main/skills/cheminformatics/alterlab-medchem
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install alterlab-ieu-alterlab-academic-skills@llmmart
Git git clone https://github.com/AlterLab-IEU/AlterLab-Academic-Skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole alterlab-ieu/alterlab-academic-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Medchem

Overview

Medchem (datamol-io/medchem) is a Python library for molecular filtering and prioritization in drug-discovery workflows: medicinal-chemistry rules, structural alerts (ChEMBL/NIBR/PAINS), chemical-group detection, complexity metrics, and a query DSL. Rules and filters are context-specific guidelines, not hard truth — combine with domain expertise.

Verified against medchem==2.1.0 (current as of 2026-09; Python ≥ 3.11, RDKit 2026.03). API names below are checked against this version; earlier docs/blog posts described a different surface.

When to Use This Skill

This skill should be used when:

  • Applying drug-likeness rules (Lipinski, Veber, etc.) to compound libraries
  • Filtering molecules by structural alerts or PAINS patterns
  • Prioritizing compounds for lead optimization
  • Assessing compound quality and medicinal chemistry properties
  • Detecting reactive or problematic functional groups
  • Calculating molecular complexity metrics

Does NOT Trigger

Scenario Use Instead
Just computing descriptors (MW, cLogP, TPSA, HBD/HBA) or standardizing structures, no rule/alert filtering alterlab-datamol
Writing custom SMARTS queries or substructure logic outside the curated catalogs alterlab-rdkit
Fetching a labeled toxicity/ADMET benchmark (e.g. hERG, AMES) with scaffold splits alterlab-pytdc
Retrieving measured bioactivity (IC50/Ki) for compounds or targets alterlab-chembl

Installation

uv pip install medchem    # PyPI; pulls rdkit + datamol

Two features need extra native deps that PyPI cannot provide:

  • Lilly demerits (lilly_demerit_filter) shells out to the compiled Lilly MedChem Rules tools. Since medchem 2.1, medchem install-lilly downloads the checksum-pinned upstream release (2.1.0) and builds it next to the active Python — it needs make, a C++ compiler, and zlib (plus Ruby for the regression tests, or pass --no-test), and native Windows is unsupported (use WSL). The old conda-forge lilly-medchem-rules 1.0.1 build is obsolete. Without the tools, the call raises ImportError. 2.1 also changed the default Lilly atom-count limits (soft 25 / hard 40 / minimum 7; previously 30 / 50 / 1).
  • The ChemAxon rule (rule_of_chemaxon_druglikeness) needs a licensed ChemAxon install.

Everything else (RuleFilters, CommonAlerts, NIBR, complexity, groups, query) works from the PyPI wheel alone.

Core Capabilities

Conventions that hold across medchem. Filters take mols (a sequence of SMILES strings or RDKit mols), default to n_jobs=-1 (all cores), and accept progress=True. The medchem.structural / medchem.rules filter classes return a pandas DataFrame (one row per input mol); the medchem.functional.* helpers return a NumPy boolean array where True = the molecule passes / is kept. Get the canonical rule and alert names from mc.rules.RuleFilters.list_available_rules() and mc.structural.CommonAlertsFilters.list_default_available_alerts() rather than guessing.

1. Medicinal Chemistry Rules — medchem.rules

Single rulemedchem.rules.basic_rules.* functions take one mol (SMILES or RDKit) and return a plain bool:

import medchem as mc

smi = "CC(=O)OC1=CC=CC=C1C(=O)O"  # aspirin
mc.rules.basic_rules.rule_of_five(smi)   # -> True
mc.rules.basic_rules.rule_of_veber(smi)  # -> True
mc.rules.basic_rules.rule_of_cns(smi)

Available rules (full list via mc.rules.RuleFilters.list_available_rules()): rule_of_five, rule_of_five_beyond, rule_of_four, rule_of_three, rule_of_three_extended, rule_of_two, rule_of_ghose, rule_of_veber, rule_of_reos, rule_of_egan, rule_of_pfizer_3_75, rule_of_gsk_4_400, rule_of_oprea, rule_of_xu, rule_of_cns, rule_of_respiratory, rule_of_zinc, rule_of_leadlike_soft, rule_of_druglike_soft, rule_of_generative_design, rule_of_generative_design_strict, rule_of_chemaxon_druglikeness (needs ChemAxon).

There is no rule_of_drug, rule_of_leadlike_strict, golden_triangle, or pains_filter function (checked in 2.1.0). PAINS lives in the alert system (HASALERT("pains") or CommonAlertsFilters(alerts_set=["PAINS"])). For lead-likeness use rule_of_leadlike_soft or rule_of_oprea.

Multiple rulesRuleFilters returns a DataFrame with columns mol, pass_all, pass_any, and one boolean column per rule:

import datamol as dm
import medchem as mc

mols = [dm.to_mol(s) for s in smiles_list]
rfilter = mc.rules.RuleFilters(rule_list=["rule_of_five", "rule_of_veber", "rule_of_cns"])
df = rfilter(mols=mols, n_jobs=-1, progress=True)
# df["pass_all"] -> bool per molecule; df["rule_of_five"] -> per-rule bool
clean = [m for m, ok in zip(mols, df["pass_all"]) if ok]

Property windows — there is no all-in-one "Constraints(mw_range=...)" object (see note in section 7). Build custom property cutoffs with mc.rules.in_range over descriptor names from mc.rules.list_descriptors() (mw, clogp, tpsa, n_lipinski_hbd, n_lipinski_hba, n_rotatable_bonds, n_rings, ...), or use the query DSL (HASPROP, section 8).

2. Structural Alert Filters — medchem.structural

Two filter classes ship in medchem.structural: CommonAlertsFilters and NIBRFilters. (Lilly demerits is reached through medchem.functional, see section 3 — its class lives under medchem.structural.lilly_demerits and needs external binaries.)

Common alerts — curated alert sets from ChEMBL (Glaxo, Dundee, BMS, PAINS, SureChEMBL, ...). Returns a DataFrame with mol, pass_filter (bool), status (ok/exclude), reasons (matched alert names, ;-joined):

import medchem as mc

caf = mc.structural.CommonAlertsFilters()                 # all default sets
caf_pains = mc.structural.CommonAlertsFilters(alerts_set=["PAINS"])  # PAINS only
df = caf(mols=mol_list, n_jobs=-1, progress=True)
clean = df[df["pass_filter"]]
# discover sets: mc.structural.CommonAlertsFilters.list_default_available_alerts()

NIBR filters — Novartis filter set. Returns a DataFrame including mol, pass_filter, severity, status, reasons:

nibr = mc.structural.NIBRFilters()
df = nibr(mols=mol_list, n_jobs=-1)

3. Functional API — medchem.functional

One-call helpers that return a NumPy boolean array (True = keep). Pass return_idx=True to get indices of passing mols instead:

import medchem as mc

mc.functional.rules_filter(mol_list, rules=["rule_of_five", "rule_of_veber"], n_jobs=-1)
mc.functional.alert_filter(mol_list, alerts=["pains"], n_jobs=-1)   # alert names are lowercase here
mc.functional.nibr_filter(mol_list, max_severity=10, n_jobs=-1)
mc.functional.complexity_filter(mol_list, complexity_metric="bertz", limit="99", n_jobs=-1)
mc.functional.chemical_group_filter(mol_list, chemical_group=mc.groups.ChemicalGroup(groups=["hinge_binders"]))

Lilly demerits — requires the Lilly tools (medchem install-lilly, see Installation); raises ImportError if missing. Molecules above max_demerits (default 160) are rejected:

keep = mc.functional.lilly_demerit_filter(mol_list, max_demerits=160, n_jobs=-1)  # NumPy bool array

4. Chemical Groups Detection — medchem.groups

ChemicalGroup matches curated group catalogs. List valid catalog names with mc.groups.list_default_chemical_groups() (e.g. hinge_binders, electrophilic_warheads_for_kinases, common_warhead_covalent_inhibitors, privileged_kinase_inhibitor_scaffolds, aggregator). Per-mol functional-group names (for the query DSL HASGROUP) come from mc.groups.list_functional_group_names().

import medchem as mc

group = mc.groups.ChemicalGroup(groups=["hinge_binders"])
group.has_match(mol)        # bool for one mol
group.get_matches(mol)      # detailed matches
# batch: mc.functional.chemical_group_filter(mols, chemical_group=group)

phosphate_binders, michael_acceptors, and reactive_groups are not default catalog names. For reactive/electrophilic motifs use electrophilic_warheads_for_kinases / common_warhead_covalent_inhibitors, the alert filters (section 2), or a custom SMARTS catalog (mc.catalogs.catalog_from_smarts).

5. Named Catalogs — medchem.catalogs

import medchem as mc

mc.catalogs.list_named_catalogs()      # available catalog names
cat = mc.catalogs.NamedCatalogs.pains()  # e.g. a PAINS RDKit FilterCatalog
mc.catalogs.catalog_from_smarts(...)   # build a catalog from custom SMARTS

6. Molecular Complexity — medchem.complexity

ComplexityFilter flags molecules whose complexity exceeds a percentile threshold derived from a reference set (default ZINC). It is called per molecule and returns a bool (True = within limit / keep). Metrics: bertz, whitlock (WhitlockCT), barone (BaroneCT), smcm (SMCM), twc (TWC), plus sas/qed/clogp. New in 2.1: mc.complexity.SPS(mol) (normalized SpacialScore, Krzyzanowski et al., J. Med. Chem. 2023); as a ComplexityFilter metric ("spacialscore") it needs your own threshold_stats_file.

import medchem as mc

cflt = mc.complexity.ComplexityFilter(limit="99", complexity_metric="bertz")
keep = [cflt(m) for m in mol_list]
# or batch: mc.functional.complexity_filter(mol_list, complexity_metric="bertz", limit="99")

There is no mc.complexity.calculate_complexity(...) and ComplexityFilter takes limit/complexity_metric/threshold_stats_file, not max_complexity. For a raw score use the metric classes directly (mc.complexity.TWC, etc.).

7. Substructure Constraints — medchem.constraints

mc.constraints.Constraints(core, constraint_fns, prop_name="query") enforces substructure / R-group constraints around a query core (via has_match / validate) — it is not a physchem property-window filter. For MW/logP/TPSA windows, use RuleFilters + in_range (section 1) or the query DSL HASPROP (section 8).

8. Query DSL — medchem.query

QueryFilter evaluates a boolean expression over rules, properties, alerts, and groups. Operators: AND, OR, NOT, comparisons < > <= >= == !=. Primitives: MATCHRULE("..."), HASPROP("<descriptor>" < value), HASALERT("<lowercase set>"), HASGROUP("..."), HASSUBSTRUCTURE/HASSUPERSTRUCTURE, LIKE.

import medchem as mc

qf = mc.query.QueryFilter('MATCHRULE("rule_of_five") AND HASPROP("mw" < 500) AND NOT HASALERT("pains")')
keep = qf(mol_list, n_jobs=-1)   # NumPy bool array

The syntax is the structured DSL above — not free-form text like "rule_of_five AND NOT common_alerts". There is no mc.query.parse(); construct mc.query.QueryFilter(query_string) and call it on the mols. Alert names inside HASALERT are lowercase (pains, tox, nih, ...).

Workflow Patterns

Pattern 1: Initial Triage of Compound Library

Filter a large collection to drug-like candidates, dropping anything with structural alerts.

import datamol as dm
import medchem as mc
import pandas as pd

df = pd.read_csv("compounds.csv")
mols = [dm.to_mol(smi) for smi in df["smiles"]]

# Rule filter -> DataFrame with pass_all + per-rule columns
rule_df = mc.rules.RuleFilters(rule_list=["rule_of_five", "rule_of_veber"])(
    mols=mols, n_jobs=-1, progress=True
)

# Structural alerts -> DataFrame with pass_filter (True = clean)
alert_df = mc.structural.CommonAlertsFilters()(mols=mols, n_jobs=-1, progress=True)

df["passes_rules"] = rule_df["pass_all"].to_numpy()
df["no_alerts"] = alert_df["pass_filter"].to_numpy()
df["drug_like"] = df["passes_rules"] & df["no_alerts"]

df[df["drug_like"]].to_csv("filtered_compounds.csv", index=False)

Pattern 2: Lead Optimization Filtering

Stack stricter filters and keep only molecules passing every stage. The functional.* helpers all return aligned NumPy bool arrays, so intersecting them is straightforward.

import numpy as np
import medchem as mc

f = mc.functional
keep = (
    f.rules_filter(candidate_mols, rules=["rule_of_oprea"], n_jobs=-1)
    & f.nibr_filter(candidate_mols, n_jobs=-1)
    & f.complexity_filter(candidate_mols, complexity_metric="bertz", limit="99", n_jobs=-1)
)
# Add lilly_demerit_filter(...) too if the Lilly binaries are installed.
survivors = [m for m, ok in zip(candidate_mols, keep) if ok]

Pattern 3: Identify Specific Chemical Groups

Flag molecules containing a target scaffold/motif (validate names with mc.groups.list_default_chemical_groups()).

import medchem as mc

group = mc.groups.ChemicalGroup(groups=["hinge_binders"])
keep = mc.functional.chemical_group_filter(mol_list, chemical_group=group)
with_group = [m for m, ok in zip(mol_list, keep) if ok]

Best Practices

  1. Context Matters: Don't blindly apply filters. Understand the biological target and chemical space.

  2. Combine Multiple Filters: Use rules, structural alerts, and domain knowledge together for better decisions.

  3. Use Parallelization: For large datasets (>1000 molecules), always use n_jobs=-1 for parallel processing.

  4. Iterative Refinement: Start with broad filters (Ro5), then apply more specific criteria (CNS, leadlike) as needed.

  5. Document Filtering Decisions: Track which molecules were filtered out and why for reproducibility.

  6. Validate Results: Remember that marketed drugs often fail standard filters—use these as guidelines, not absolute rules.

  7. Consider Prodrugs: Molecules designed as prodrugs may intentionally violate standard medicinal chemistry rules.

Resources

references/api_guide.md

Comprehensive API reference covering all medchem modules with detailed function signatures, parameters, and return types.

references/rules_catalog.md

Complete catalog of available rules, filters, and alerts with descriptions, thresholds, and literature references.

scripts/filter_molecules.py

Batch filtering CLI. Supports CSV/TSV, SDF, and plain-SMILES .txt input, configurable filter combinations, and a summary report.

Usage:

uv run python scripts/filter_molecules.py input.csv \
    --rules rule_of_five,rule_of_cns --nibr --output filtered.csv

Flags are individual switches (--nibr, --common-alerts, --lilly, --pains), not --alerts <name>. --lilly needs the Lilly tools (medchem install-lilly).

Documentation

Official documentation: https://medchem-docs.datamol.io/ GitHub repository: https://github.com/datamol-io/medchem

Part of the AlterLab Academic Skills suite.

Files (alterlab-academic-skills)
  • evals
    • evals.json 4.7 KB
      {
        "skill": "alterlab-medchem",
        "evals": [
          {
            "id": "library-triage-rules",
            "prompt": "Triage my 5000-compound library: keep only molecules that pass Rule of Five and Veber, and drop anything with a known structural alert. Give me back the clean set.",
            "expected_output": "Invokes alterlab-medchem: builds mc.rules.RuleFilters(rule_list=['rule_of_five','rule_of_veber']) and applies it with n_jobs=-1/progress=True, then runs mc.structural.CommonAlertsFilters to flag alerts, and combines pass-rules AND not-has-alerts into a filtered DataFrame for the clean set.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "behavior", "value": "Applies medchem rule filters (Ro5, Veber) and structural alerts with parallel n_jobs, then intersects the pass conditions." }
            ]
          },
          {
            "id": "pains-flagging",
            "prompt": "Before I trust these screening hits, flag any PAINS or reactive Michael-acceptor groups so I don't chase assay artifacts.",
            "expected_output": "Invokes alterlab-medchem: applies PAINS filtering (CommonAlertsFilters with the PAINS set, or HASALERT(\"pains\") in the query DSL) and flags reactive/Michael-acceptor motifs via the structural-alert sets (common alerts / NIBR) or the electrophilic-warhead ChemicalGroup catalogs (there is no 'michael_acceptors' group), returning per-molecule flags so the user can deprioritize likely assay-interference artifacts.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "output_contains", "value": "PAINS" }
            ]
          },
          {
            "id": "lilly-nibr-lead-opt",
            "prompt": "For lead optimization, run the NIBR filter set and Lilly demerits on my candidate list and tell me which ones exceed 100 demerits.",
            "expected_output": "Invokes alterlab-medchem: applies mc.structural.NIBRFilters and the Lilly demerits filter (mc.functional.lilly_demerit_filter, which needs the Lilly MedChem Rules tools installed via `medchem install-lilly`) with max_demerits=100 to honor the user's threshold (noting medchem's default ceiling is 160), reporting which molecules are rejected, for stricter lead-optimization triage.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "behavior", "value": "Uses NIBRFilters plus the Lilly demerit filter (via medchem.functional.lilly_demerit_filter, flagging the external-binary requirement) and applies the 100-demerit threshold the user asked for." }
            ]
          },
          {
            "id": "complexity-and-constraints",
            "prompt": "Filter my set to molecular weight 200-500, logP between -2 and 5, TPSA under 140, and also drop anything with high Bertz molecular complexity.",
            "expected_output": "Invokes alterlab-medchem: enforces the MW/logP/TPSA property windows via the query DSL HASPROP (or mc.rules.in_range over computed descriptors, since medchem has no all-in-one Constraints property object) and applies the Bertz complexity filter (mc.complexity.ComplexityFilter / mc.functional.complexity_filter with complexity_metric='bertz' and a percentile limit), returning molecules that satisfy all conditions.",
            "assertions": [
              { "type": "should_trigger", "value": true },
              { "type": "behavior", "value": "Enforces the MW/logP/TPSA windows via HASPROP or in_range (NOT a fabricated Constraints(mw_range=...) object) and applies the Bertz complexity filter via ComplexityFilter/complexity_filter with complexity_metric='bertz'." }
            ]
          },
          {
            "id": "near-miss-datamol",
            "prompt": "I just need to compute MW, logP, HBD, HBA and TPSA descriptors in bulk for my SDF and standardize the structures first. No filtering decisions, just the numbers.",
            "expected_output": "Does NOT invoke this skill; defers to alterlab-datamol. The user only wants descriptor computation and standardization, not medicinal-chemistry filtering / alert flagging, so datamol's batch descriptor and standardization utilities are the right tool.",
            "assertions": [
              { "type": "should_not_trigger", "value": true },
              { "type": "output_contains", "value": "alterlab-datamol" }
            ]
          },
          {
            "id": "near-miss-pytdc",
            "prompt": "I want a labeled hERG cardiotoxicity dataset with a proper scaffold train/test split to train a toxicity classifier.",
            "expected_output": "Does NOT invoke this skill; defers to alterlab-pytdc. The user wants a curated, AI-ready benchmark dataset (Tox hERG) with scaffold splits for model training, not rule-based filtering of their own compounds, which is Therapeutics Data Commons territory.",
            "assertions": [
              { "type": "should_not_trigger", "value": true },
              { "type": "output_contains", "value": "alterlab-pytdc" }
            ]
          }
        ]
      }
      
  • references
    • api_guide.md 8.2 KB
      # Medchem API Reference
      
      Verified against `medchem==2.1.0`. For the authoritative, always-current API, run
      `mc.rules.RuleFilters.list_available_rules()`,
      `mc.structural.CommonAlertsFilters.list_default_available_alerts()`,
      `mc.groups.list_default_chemical_groups()`, and
      `mc.catalogs.list_named_catalogs()` — and read https://medchem-docs.datamol.io/.
      
      ## Cross-cutting conventions
      
      - Inputs accept SMILES strings **or** RDKit mols (`Sequence[Union[str, Chem.Mol]]`).
      - Filter **classes** (`medchem.rules.RuleFilters`, `medchem.structural.*`) return a **pandas DataFrame**, one row per input mol, with a `mol` column.
      - Filter **functions** (`medchem.functional.*`) return a **NumPy boolean array** (`True` = keep); pass `return_idx=True` for indices instead.
      - Common kwargs: `n_jobs` (default `-1` = all cores), `progress` (bool), `scheduler` (`"auto"`/`"threads"`/`"processes"`).
      
      ---
      
      ## medchem.rules
      
      ### RuleFilters
      
      ```python
      RuleFilters(rule_list: List[Union[str, Callable]], rule_list_names: Optional[List[Optional[str]]] = None)
      __call__(mols, n_jobs=-1, progress=False, keep_props=False, fail_if_invalid=True, ...) -> pandas.DataFrame
      ```
      
      Returns a DataFrame with columns: `mol`, `pass_all`, `pass_any`, and one boolean column per rule.
      
      - `RuleFilters.list_available_rules()` -> DataFrame of rule names + metadata.
      
      ### medchem.rules.basic_rules
      
      Single-molecule rule functions returning `bool`:
      
      ```python
      rule_of_five(mol, mw=None, clogp=None, n_lipinski_hbd=None, n_lipinski_hba=None, **kwargs) -> bool
      ```
      
      Available (subset): `rule_of_five`, `rule_of_five_beyond`, `rule_of_four`, `rule_of_three`,
      `rule_of_three_extended`, `rule_of_two`, `rule_of_ghose`, `rule_of_veber`, `rule_of_reos`,
      `rule_of_egan`, `rule_of_pfizer_3_75`, `rule_of_gsk_4_400`, `rule_of_oprea`, `rule_of_xu`,
      `rule_of_cns`, `rule_of_respiratory`, `rule_of_zinc`, `rule_of_leadlike_soft`,
      `rule_of_druglike_soft`, `rule_of_generative_design`, `rule_of_chemaxon_druglikeness` (needs ChemAxon).
      
      > No `rule_of_drug`, `rule_of_leadlike_strict`, `golden_triangle`, or `pains_filter`. Use the
      > alert system for PAINS and `rule_of_leadlike_soft`/`rule_of_oprea` for lead-likeness.
      
      ### Helpers
      
      - `in_range(x, min_val=-inf, max_val=inf) -> bool` — gate a numeric descriptor.
      - `list_descriptors()` -> names usable for property windows: `mw`, `clogp`, `tpsa`, `fsp3`, `qed`,
        `sas`, `n_lipinski_hbd`, `n_lipinski_hba`, `n_rotatable_bonds`, `n_rings`, `n_aromatic_rings`,
        `n_heavy_atoms`, `n_hetero_atoms`, `formal_charge`, ...
      - `n_fused_aromatic_rings`, `n_heavy_metals`, `fraction_atom_in_scaff`, `has_spider_chains`.
      
      ---
      
      ## medchem.structural
      
      ### CommonAlertsFilters
      
      ```python
      CommonAlertsFilters(alerts_set: Union[str, List[str], None] = None, alerts_db_path=None)
      __call__(mols, n_jobs=-1, progress=False, batch_size=None, keep_details=False) -> pandas.DataFrame
      ```
      
      Returns DataFrame columns: `mol`, `pass_filter` (bool; `True` = clean), `status` (`"ok"`/`"exclude"`),
      `reasons` (`;`-joined alert names, `NaN` when clean).
      
      - `CommonAlertsFilters.list_default_available_alerts()` -> DataFrame of alert sets
        (`Glaxo`, `Dundee`, `BMS`, `PAINS`, `SureChEMBL`, ...). Pass a subset via `alerts_set=["PAINS"]`.
      
      ### NIBRFilters
      
      ```python
      NIBRFilters()
      __call__(mols, n_jobs=-1, progress=False, keep_details=False) -> pandas.DataFrame
      ```
      
      Returns DataFrame including `mol`, `pass_filter`, `severity`, `status`, `reasons`,
      `n_covalent_motif`, `special_mol`.
      
      ### Lilly demerits
      
      The class is `medchem.structural.lilly_demerits.LillyDemeritsFilters` (not exported at
      `medchem.structural` top level) and **requires the compiled Lilly MedChem Rules tools**
      (`medchem install-lilly` in medchem ≥ 2.1 builds the pinned upstream release; needs `make`,
      a C++ compiler and zlib; not native Windows). Running it without them raises `ImportError`.
      Prefer the functional entry point (below).
      
      ---
      
      ## medchem.functional
      
      All return a NumPy bool array (`True` = keep); `return_idx=True` returns passing indices.
      
      ```python
      rules_filter(mols, rules: Union[List, RuleFilters], n_jobs=None, progress=False, ...) -> np.ndarray
      alert_filter(mols, alerts: List[str], alerts_db=None, n_jobs=1, progress=False, ...) -> np.ndarray
      nibr_filter(mols, n_jobs=None, max_severity=10, progress=False, ...) -> np.ndarray
      lilly_demerit_filter(mols, max_demerits=160, n_jobs=None, progress=False, ...) -> np.ndarray  # needs binaries
      complexity_filter(mols, complexity_metric="bertz", limit="99", threshold_stats_file="zinc_15_available", ...) -> np.ndarray
      chemical_group_filter(mols, chemical_group: ChemicalGroup, exact_match=False, ...) -> np.ndarray
      ```
      
      > `alert_filter` alert names are **lowercase** (`"pains"`, `"tox"`, `"nih"`, ...). There is no
      > `common_alerts_filter` / `lilly_demerits_filter`; the names are `alert_filter` / `lilly_demerit_filter`.
      
      Other helpers: `atom_list_filter`, `bredt_filter`, `catalog_filter`, `halogenicity_filter`,
      `macrocycle_filter`, `molecular_graph_filter`, `num_atom_filter`, `num_stereo_center_filter`,
      `protecting_groups_filter`, `ring_infraction_filter`, `symmetry_filter`.
      
      ---
      
      ## medchem.groups
      
      ### ChemicalGroup
      
      ```python
      ChemicalGroup(groups: Union[str, List[str], None] = None, n_jobs=None, groups_db=None)
      has_match(mol, exact_match=False, terminal_only=False) -> bool
      get_matches(mol, use_smiles=True, exact_match=False, terminal_only=False)
      filter(names: List[str], fuzzy=False)
      # also: dataframe, get_catalog, list_groups, mols, smarts, smiles
      ```
      
      - `list_default_chemical_groups()` -> catalog group names (e.g. `hinge_binders`,
        `electrophilic_warheads_for_kinases`, `common_warhead_covalent_inhibitors`,
        `privileged_kinase_inhibitor_scaffolds`, `aggregator`, `privileged_scaffolds`).
      - `list_functional_group_names()` -> ~579 fine-grained functional-group names used by the
        query DSL `HASGROUP(...)`.
      
      > `phosphate_binders`, `michael_acceptors`, and `reactive_groups` are **not** default group names.
      
      ---
      
      ## medchem.complexity
      
      ```python
      ComplexityFilter(limit="99", complexity_metric="bertz", threshold_stats_file="zinc_15_available")
      __call__(mol) -> bool        # per molecule; True = within the limit
      ```
      
      Metric classes for raw scores: `BaroneCT`, `WhitlockCT`, `SMCM`, `TWC` (metric strings:
      `"bertz"`, `"barone"`, `"whitlock"`, `"smcm"`, `"twc"`). There is no `calculate_complexity` and no
      `max_complexity` argument; the filter thresholds against a percentile of a reference set.
      
      ---
      
      ## medchem.catalogs
      
      ```python
      NamedCatalogs.pains()   # RDKit FilterCatalog; also .pains_a/.pains_b/.pains_c, .brenk, .nih, .bms, ...
      list_named_catalogs() -> List[str]   # ['tox','pains','pains_a','pains_b','pains_c','nih', ...]
      catalog_from_smarts(...)             # build a catalog from custom SMARTS
      merge_catalogs(...)
      ```
      
      ---
      
      ## medchem.constraints
      
      ```python
      Constraints(core: Chem.Mol, constraint_fns: Dict[str, Callable], prop_name="query")
      has_match(...) / validate(...) / get_matches(...)
      ```
      
      Enforces **substructure / R-group** constraints around a query `core` — **not** physchem
      property windows. For MW/logP/TPSA/HBD windows use `medchem.rules.in_range` over computed
      descriptors, or the query DSL `HASPROP` (below).
      
      ---
      
      ## medchem.query
      
      ```python
      QueryFilter(query: str, grammar=None, parser="lalr")
      __call__(mols, n_jobs=-1, progress=False) -> array of bool
      ```
      
      Structured boolean grammar (not free-form text). Primitives:
      
      - `MATCHRULE("rule_of_five")`
      - `HASPROP("mw" < 500)` — comparison operators `< > <= >= == !=`; descriptor names from `list_descriptors()`
      - `HASALERT("pains")` — alert-set names are lowercase
      - `HASGROUP("<functional_group_name>")`
      - `HASSUBSTRUCTURE(...)`, `HASSUPERSTRUCTURE(...)`, `LIKE(...)`
      - Combine with `AND`, `OR`, `NOT`, parentheses.
      
      ```python
      qf = mc.query.QueryFilter('MATCHRULE("rule_of_five") AND HASPROP("mw" < 500) AND NOT HASALERT("pains")')
      keep = qf(mol_list, n_jobs=-1)
      ```
      
      > There is no `mc.query.parse()`. Construct `QueryFilter(query_string)` and call it directly.
      
      ---
      
      ## Working with DataFrames
      
      ```python
      import pandas as pd
      import datamol as dm
      import medchem as mc
      
      df = pd.read_csv("molecules.csv")
      mols = [dm.to_mol(smi) for smi in df["smiles"]]
      
      res = mc.rules.RuleFilters(rule_list=["rule_of_five", "rule_of_cns"])(mols=mols, n_jobs=-1)
      df["passes_ro5"] = res["rule_of_five"].to_numpy()
      df["passes_cns"] = res["rule_of_cns"].to_numpy()
      df["pass_all"]   = res["pass_all"].to_numpy()
      
      filtered = df[df["pass_all"]]
      ```
      
    • rules_catalog.md 15 KB
      # Medchem Rules and Filters Catalog
      
      Background and selection guidance for medicinal-chemistry rules, structural alerts, and filters.
      The rule criteria and literature references below are conceptual background; for the exact
      implementation and the canonical name list use `mc.rules.RuleFilters.list_available_rules()`.
      
      > **Verified against `medchem==2.1.0`.** Not every rule below is shipped as a named medchem
      > function (e.g. Rule of Drug, strict lead-likeness, and Golden Triangle have no dedicated
      > function in this version — see notes). Apply those criteria via `RuleFilters` of the available
      > rules plus property windows (`mc.rules.in_range`) or the query DSL.
      
      ## Table of Contents
      
      1. [Drug-Likeness Rules](#drug-likeness-rules)
      2. [Lead-Likeness Rules](#lead-likeness-rules)
      3. [Fragment Rules](#fragment-rules)
      4. [CNS Rules](#cns-rules)
      5. [Structural Alert Filters](#structural-alert-filters)
      6. [Chemical Group Patterns](#chemical-group-patterns)
      
      ---
      
      ## Drug-Likeness Rules
      
      ### Rule of Five (Lipinski)
      
      **Reference:** Lipinski et al., Adv Drug Deliv Rev (1997) 23:3-25
      
      **Purpose:** Predict oral bioavailability
      
      **Criteria:**
      - Molecular Weight ≤ 500 Da
      - LogP ≤ 5
      - Hydrogen Bond Donors ≤ 5
      - Hydrogen Bond Acceptors ≤ 10
      
      **Usage:**
      ```python
      mc.rules.basic_rules.rule_of_five(mol)
      ```
      
      **Notes:**
      - One of the most widely used filters in drug discovery
      - About 90% of orally active drugs comply with these rules
      - Exceptions exist, especially for natural products and antibiotics
      
      ---
      
      ### Rule of Veber
      
      **Reference:** Veber et al., J Med Chem (2002) 45:2615-2623
      
      **Purpose:** Additional criteria for oral bioavailability
      
      **Criteria:**
      - Rotatable Bonds ≤ 10
      - Topological Polar Surface Area (TPSA) ≤ 140 Ų
      
      **Usage:**
      ```python
      mc.rules.basic_rules.rule_of_veber(mol)
      ```
      
      **Notes:**
      - Complements Rule of Five
      - TPSA correlates with cell permeability
      - Rotatable bonds affect molecular flexibility
      
      ---
      
      ### Rule of Drug (composite — no single function)
      
      **Purpose:** Combined drug-likeness assessment
      
      **Criteria:**
      - Passes Rule of Five
      - Passes Veber rules
      - Does not contain PAINS substructures
      
      **There is no `rule_of_drug` function in medchem 2.1.0.** Compose it explicitly, e.g. with the query DSL:
      ```python
      qf = mc.query.QueryFilter(
          'MATCHRULE("rule_of_five") AND MATCHRULE("rule_of_veber") AND NOT HASALERT("pains")'
      )
      keep = qf(mol_list, n_jobs=-1)
      ```
      
      ---
      
      ### REOS (Rapid Elimination Of Swill)
      
      **Reference:** Walters & Murcko, Adv Drug Deliv Rev (2002) 54:255-271
      
      **Purpose:** Filter out compounds unlikely to be drugs
      
      **Criteria:**
      - Molecular Weight: 200-500 Da
      - LogP: -5 to 5
      - Hydrogen Bond Donors: 0-5
      - Hydrogen Bond Acceptors: 0-10
      
      **Usage:**
      ```python
      mc.rules.basic_rules.rule_of_reos(mol)
      ```
      
      ---
      
      ### Golden Triangle (no single function)
      
      **Reference:** Johnson et al., J Med Chem (2009) 52:5487-5500
      
      **Purpose:** Balance lipophilicity and molecular weight
      
      **Criteria:**
      - 200 ≤ MW ≤ 50 × LogP + 400
      - LogP: -2 to 5
      
      **No `golden_triangle` function in medchem 2.1.0.** Implement with computed `mw`/`clogp`
      descriptors and `mc.rules.in_range`, or a custom callable passed to `RuleFilters(rule_list=[...])`.
      
      **Notes:**
      - Defines an optimal physicochemical space (a triangle on the MW vs LogP plot).
      
      ---
      
      ## Lead-Likeness Rules
      
      ### Rule of Oprea
      
      **Reference:** Oprea et al., J Chem Inf Comput Sci (2001) 41:1308-1315
      
      **Purpose:** Identify lead-like compounds for optimization
      
      **Criteria:**
      - Molecular Weight: 200-350 Da
      - LogP: -2 to 4
      - Rotatable Bonds ≤ 7
      - Number of Rings ≤ 4
      
      **Usage:**
      ```python
      mc.rules.basic_rules.rule_of_oprea(mol)
      ```
      
      **Rationale:** Lead compounds should have "room to grow" during optimization
      
      ---
      
      ### Rule of Leadlike (Soft)
      
      **Purpose:** Permissive lead-like criteria
      
      **Criteria:**
      - Molecular Weight: 250-450 Da
      - LogP: -3 to 4
      - Rotatable Bonds ≤ 10
      
      **Usage:**
      ```python
      mc.rules.basic_rules.rule_of_leadlike_soft(mol)
      ```
      
      ---
      
      ### Rule of Leadlike (Strict) — not shipped
      
      **Purpose:** Restrictive lead-like criteria (more aggressive than `rule_of_leadlike_soft`)
      
      **Criteria (conceptual):**
      - Molecular Weight: 200-350 Da
      - LogP: -2 to 3.5
      - Rotatable Bonds ≤ 7
      - Number of Rings: 1-3
      
      **medchem 2.1.0 ships only `rule_of_leadlike_soft`** (there is no `rule_of_leadlike_strict`).
      For stricter lead-likeness, combine `rule_of_oprea` with tighter property windows
      (`mc.rules.in_range`) or `HASPROP` queries.
      
      ---
      
      ## Fragment Rules
      
      ### Rule of Three
      
      **Reference:** Congreve et al., Drug Discov Today (2003) 8:876-877
      
      **Purpose:** Screen fragment libraries for fragment-based drug discovery
      
      **Criteria:**
      - Molecular Weight ≤ 300 Da
      - LogP ≤ 3
      - Hydrogen Bond Donors ≤ 3
      - Hydrogen Bond Acceptors ≤ 3
      - Rotatable Bonds ≤ 3
      - Polar Surface Area ≤ 60 Ų
      
      **Usage:**
      ```python
      mc.rules.basic_rules.rule_of_three(mol)
      ```
      
      **Notes:**
      - Fragments are grown into leads during optimization
      - Lower complexity allows more starting points
      
      ---
      
      ## CNS Rules
      
      ### Rule of CNS
      
      **Purpose:** Central nervous system drug-likeness
      
      **Criteria:**
      - Molecular Weight ≤ 450 Da
      - LogP: -1 to 5
      - Hydrogen Bond Donors ≤ 2
      - TPSA ≤ 90 Ų
      
      **Usage:**
      ```python
      mc.rules.basic_rules.rule_of_cns(mol)
      ```
      
      **Rationale:**
      - Blood-brain barrier penetration requires specific properties
      - Lower TPSA and HBD count improve BBB permeability
      - Tight constraints reflect CNS challenges
      
      ---
      
      ## Structural Alert Filters
      
      ### PAINS (Pan Assay INterference compoundS)
      
      **Reference:** Baell & Holloway, J Med Chem (2010) 53:2719-2740
      
      **Purpose:** Identify compounds that interfere with assays
      
      **Categories:**
      - Catechols
      - Quinones
      - Rhodanines
      - Hydroxyphenylhydrazones
      - Alkyl/aryl aldehydes
      - Michael acceptors (specific patterns)
      
      **Usage** (PAINS lives in the alert system, not a `pains_filter` rule function):
      ```python
      # Functional API (lowercase set name), True = no PAINS / keep:
      mc.functional.alert_filter(mol_list, alerts=["pains"], n_jobs=-1)
      # Or via the CommonAlerts class restricted to the PAINS set:
      mc.structural.CommonAlertsFilters(alerts_set=["PAINS"])(mols=mol_list, n_jobs=-1)
      # Or the RDKit catalog: mc.catalogs.NamedCatalogs.pains()
      ```
      
      **Notes:**
      - PAINS compounds show activity in multiple assays through non-specific mechanisms
      - Common false positives in screening campaigns
      - Should be deprioritized in lead selection
      
      ---
      
      ### Common Alerts Filters
      
      **Source:** Derived from ChEMBL curation and medicinal chemistry literature
      
      **Purpose:** Flag common problematic structural patterns
      
      **Alert Categories:**
      1. **Reactive Groups**
         - Epoxides
         - Aziridines
         - Acid halides
         - Isocyanates
      
      2. **Metabolic Liabilities**
         - Hydrazines
         - Thioureas
         - Anilines (certain patterns)
      
      3. **Aggregators**
         - Polyaromatic systems
         - Long aliphatic chains
      
      4. **Toxicophores**
         - Nitro aromatics
         - Aromatic N-oxides
         - Certain heterocycles
      
      **Usage:**
      ```python
      alert_filter = mc.structural.CommonAlertsFilters()   # or alerts_set=["PAINS", "BMS"]
      df = alert_filter(mols=mol_list, n_jobs=-1, progress=True)
      ```
      
      **Return format** — a pandas DataFrame, one row per molecule:
      
      | column        | meaning                                              |
      |---------------|------------------------------------------------------|
      | `mol`         | the RDKit molecule                                   |
      | `pass_filter` | `True` if clean (no alert triggered)                 |
      | `status`      | `"ok"` or `"exclude"`                                |
      | `reasons`     | `;`-joined matched alert names (`NaN` when clean)    |
      
      (There is no `check_mol` method in 2.1.0.) Discover the available alert sets with
      `mc.structural.CommonAlertsFilters.list_default_available_alerts()`.
      
      ---
      
      ### NIBR Filters
      
      **Source:** Novartis Institutes for BioMedical Research
      
      **Purpose:** Industrial medicinal chemistry filtering rules
      
      **Features:**
      - Proprietary filter set developed from Novartis experience
      - Balances drug-likeness with practical medicinal chemistry
      - Includes both structural alerts and property filters
      
      **Usage:**
      ```python
      nibr = mc.structural.NIBRFilters()
      df = nibr(mols=mol_list, n_jobs=-1)        # DataFrame: mol, pass_filter, severity, status, reasons, ...
      # or the functional one-liner returning a NumPy bool array:
      keep = mc.functional.nibr_filter(mol_list, max_severity=10, n_jobs=-1)
      ```
      
      ---
      
      ### Lilly Demerits Filter
      
      **Reference:** Bruns & Watson, J Med Chem (2012) 55:9763-9772, doi 10.1021/jm301008n
      (275 rules developed over ~18 years).
      
      **Purpose:** Identify assay interference and problematic functionalities via a demerit score.
      
      **Mechanism:**
      - Each matched pattern adds demerits; molecules above a demerit ceiling are rejected.
      - The original paper rejects at >100 demerits; medchem exposes this as the `max_demerits`
        argument (**default 160** in 2.1.0 — set `max_demerits=100` to match the paper).
      - High-severity patterns can hard-reject; lower-severity patterns accumulate.
      
      **Requires the Lilly MedChem Rules tools** (`medchem install-lilly`, medchem ≥ 2.1; the
      conda-forge `lilly-medchem-rules` 1.0.1 build is obsolete); the call raises `ImportError` if they
      are missing. The class lives at
      `medchem.structural.lilly_demerits.LillyDemeritsFilters`; use the functional entry point:
      
      ```python
      keep = mc.functional.lilly_demerit_filter(mol_list, max_demerits=160, n_jobs=-1)
      # NumPy bool array: True = within the demerit ceiling (kept)
      ```
      
      ---
      
      ## Chemical Group Patterns
      
      `ChemicalGroup(groups=[...])` matches curated catalog groups. **Validate names against
      `mc.groups.list_default_chemical_groups()`** — verified default groups include:
      
      | group name                              | use                                  |
      |-----------------------------------------|--------------------------------------|
      | `hinge_binders`                         | kinase hinge-binding motifs          |
      | `electrophilic_warheads_for_kinases`    | covalent kinase warheads             |
      | `common_warhead_covalent_inhibitors`    | covalent-inhibitor warheads          |
      | `privileged_kinase_inhibitor_scaffolds` | privileged kinase scaffolds          |
      | `privileged_scaffolds`                  | privileged drug scaffolds            |
      | `aggregator`                            | aggregation-prone motifs             |
      
      ```python
      group = mc.groups.ChemicalGroup(groups=["hinge_binders"])
      group.has_match(mol)                                       # bool for ONE molecule
      keep = mc.functional.chemical_group_filter(mol_list, chemical_group=group)  # batch -> bool array
      ```
      
      > `phosphate_binders`, `michael_acceptors`, and `reactive_groups` are **not** default catalog
      > names in 2.1.0. For Michael acceptors / reactive electrophiles, use the alert filters
      > (`alert_filter`, `CommonAlertsFilters`) or a custom SMARTS catalog (below). For covalent
      > warheads, use `electrophilic_warheads_for_kinases` / `common_warhead_covalent_inhibitors`.
      
      ---
      
      ## Custom SMARTS Patterns
      
      `ChemicalGroup` has no `custom_smarts` argument. Build a catalog from your own SMARTS with
      `mc.catalogs.catalog_from_smarts`, then match via `mc.functional.catalog_filter`:
      
      ```python
      cat = mc.catalogs.catalog_from_smarts(
          smarts=["[CX3]=[CX3]C(=O)[NX3]", "[C;H0](=O)C(F)(F)F"],  # acrylamide, CF3-ketone warhead
          labels=["acrylamide", "tfm_ketone"],
      )
      keep = mc.functional.catalog_filter(mol_list, catalogs=[cat])  # True = no match / keep
      ```
      
      ---
      
      ## Filter Selection Guidelines
      
      ### Initial Screening (High-Throughput)
      
      Recommended filters:
      - Rule of Five
      - PAINS filter
      - Common Alerts (permissive settings)
      
      ```python
      rfilter = mc.rules.RuleFilters(rule_list=["rule_of_five"])
      alert_filter = mc.structural.CommonAlertsFilters(alerts_set=["PAINS"])  # PAINS via alerts, not a rule
      ```
      
      ---
      
      ### Hit-to-Lead
      
      Recommended filters:
      - Rule of Oprea or Leadlike (soft)
      - NIBR filters
      - Lilly Demerits (needs external binaries)
      
      ```python
      rfilter = mc.rules.RuleFilters(rule_list=["rule_of_oprea"])
      nibr = mc.structural.NIBRFilters()
      # Lilly via the functional API (requires the tools from `medchem install-lilly`):
      # keep_lilly = mc.functional.lilly_demerit_filter(mols, n_jobs=-1)
      ```
      
      ---
      
      ### Lead Optimization
      
      Recommended filters:
      - Rule of Five + Veber (compose "rule of drug")
      - Lead-likeness (`rule_of_oprea`) + tighter property windows
      - Full structural alert analysis
      - Complexity filter
      
      ```python
      rfilter = mc.rules.RuleFilters(rule_list=["rule_of_five", "rule_of_veber", "rule_of_oprea"])
      alert_filter = mc.structural.CommonAlertsFilters()
      complexity_filter = mc.complexity.ComplexityFilter(limit="95", complexity_metric="bertz")
      ```
      
      ---
      
      ### CNS Targets
      
      Recommended filters:
      - Rule of CNS
      - PAINS / alert screening
      - BBB-oriented property windows (low TPSA, low HBD)
      
      ```python
      rfilter = mc.rules.RuleFilters(rule_list=["rule_of_cns"])
      # Property windows via the query DSL (no all-in-one Constraints object):
      qf = mc.query.QueryFilter('HASPROP("tpsa" <= 90) AND HASPROP("n_lipinski_hbd" <= 2) AND HASPROP("mw" <= 450)')
      ```
      
      ---
      
      ### Fragment-Based Drug Discovery
      
      Recommended filters:
      - Rule of Three
      - Low complexity
      - Reactive-group / alert check
      
      ```python
      rfilter = mc.rules.RuleFilters(rule_list=["rule_of_three"])
      complexity_filter = mc.complexity.ComplexityFilter(limit="90", complexity_metric="bertz")
      ```
      
      ---
      
      ## Important Considerations
      
      ### False Positives and False Negatives
      
      **Filters are guidelines, not absolutes:**
      
      1. **False Positives** (good drugs flagged):
         - ~10% of marketed drugs fail Rule of Five
         - Natural products often violate standard rules
         - Prodrugs intentionally break rules
         - Antibiotics and antivirals frequently non-compliant
      
      2. **False Negatives** (bad compounds passing):
         - Passing filters doesn't guarantee success
         - Target-specific issues not captured
         - In vivo properties not fully predicted
      
      ### Context-Specific Application
      
      **Different contexts require different criteria:**
      
      - **Target Class:** Kinases vs GPCRs vs ion channels have different optimal spaces
      - **Modality:** Small molecules vs PROTACs vs molecular glues
      - **Administration Route:** Oral vs IV vs topical
      - **Disease Area:** CNS vs oncology vs infectious disease
      - **Stage:** Screening vs hit-to-lead vs lead optimization
      
      ### Complementing with Machine Learning
      
      Modern approaches combine rules with ML:
      
      ```python
      # Rule-based pre-filtering (RuleFilters returns a DataFrame with a pass_all column)
      res = mc.rules.RuleFilters(rule_list=["rule_of_five"])(mols=mols, n_jobs=-1)
      filtered_mols = [mol for mol, ok in zip(mols, res["pass_all"]) if ok]
      
      # ML model scoring on the filtered set
      ml_scores = ml_model.predict(filtered_mols)
      
      # Combined decision
      final_candidates = [
          mol for mol, score in zip(filtered_mols, ml_scores)
          if score > threshold
      ]
      ```
      
      ---
      
      ## References
      
      1. Lipinski CA et al. Adv Drug Deliv Rev (1997) 23:3-25
      2. Veber DF et al. J Med Chem (2002) 45:2615-2623
      3. Oprea TI et al. J Chem Inf Comput Sci (2001) 41:1308-1315
      4. Congreve M et al. Drug Discov Today (2003) 8:876-877
      5. Baell JB & Holloway GA. J Med Chem (2010) 53:2719-2740
      6. Johnson TW et al. J Med Chem (2009) 52:5487-5500
      7. Walters WP & Murcko MA. Adv Drug Deliv Rev (2002) 54:255-271
      8. Hann MM & Oprea TI. Curr Opin Chem Biol (2004) 8:255-263
      9. Rishton GM. Drug Discov Today (1997) 2:382-384
      10. Bruns RF & Watson IA. J Med Chem (2012) 55:9763-9772 (Lilly MedChem Rules)
      
  • scripts
    • filter_molecules.py 15.8 KB
      #!/usr/bin/env python3
      """
      Batch molecular filtering using medchem library.
      
      This script provides a production-ready workflow for filtering compound libraries
      using medchem rules, structural alerts, and custom constraints.
      
      Verified against medchem==2.1.0.
      
      Usage:
          python filter_molecules.py input.csv --rules rule_of_five,rule_of_cns --nibr --output filtered.csv
          python filter_molecules.py input.sdf --rules rule_of_oprea --lilly --complexity-metric bertz --output results.csv
          python filter_molecules.py smiles.txt --nibr --pains --n-jobs -1 --output clean.csv
      """
      
      import argparse
      import sys
      from pathlib import Path
      from typing import List, Tuple
      
      try:
          import numpy as np
          import pandas as pd
          import datamol as dm
          import medchem as mc
          from rdkit import Chem
          from tqdm import tqdm
      except ImportError as e:
          print(f"Error: Missing required package: {e}")
          print("Install dependencies: uv pip install medchem datamol pandas tqdm")
          sys.exit(1)
      
      
      def load_molecules(input_file: Path, smiles_column: str = "smiles") -> Tuple[pd.DataFrame, List[Chem.Mol]]:
          """
          Load molecules from various file formats.
      
          Supports:
          - CSV/TSV with SMILES column
          - SDF files
          - Plain text files with one SMILES per line
      
          Returns:
              Tuple of (DataFrame with metadata, list of RDKit molecules)
          """
          suffix = input_file.suffix.lower()
      
          if suffix == ".sdf":
              print(f"Loading SDF file: {input_file}")
              supplier = Chem.SDMolSupplier(str(input_file))
              mols = [mol for mol in supplier if mol is not None]
      
              # Create DataFrame from SDF properties
              data = []
              for mol in mols:
                  props = mol.GetPropsAsDict()
                  props["smiles"] = Chem.MolToSmiles(mol)
                  data.append(props)
              df = pd.DataFrame(data)
      
          elif suffix in [".csv", ".tsv"]:
              print(f"Loading CSV/TSV file: {input_file}")
              sep = "\t" if suffix == ".tsv" else ","
              df = pd.read_csv(input_file, sep=sep)
      
              if smiles_column not in df.columns:
                  print(f"Error: Column '{smiles_column}' not found in file")
                  print(f"Available columns: {', '.join(df.columns)}")
                  sys.exit(1)
      
              print(f"Converting SMILES to molecules...")
              mols = [dm.to_mol(smi) for smi in tqdm(df[smiles_column], desc="Parsing")]
      
          elif suffix == ".txt":
              print(f"Loading text file: {input_file}")
              with open(input_file) as f:
                  smiles_list = [line.strip() for line in f if line.strip()]
      
              df = pd.DataFrame({"smiles": smiles_list})
              print(f"Converting SMILES to molecules...")
              mols = [dm.to_mol(smi) for smi in tqdm(smiles_list, desc="Parsing")]
      
          else:
              print(f"Error: Unsupported file format: {suffix}")
              print("Supported formats: .csv, .tsv, .sdf, .txt")
              sys.exit(1)
      
          # Filter out invalid molecules
          valid_indices = [i for i, mol in enumerate(mols) if mol is not None]
          if len(valid_indices) < len(mols):
              n_invalid = len(mols) - len(valid_indices)
              print(f"Warning: {n_invalid} invalid molecules removed")
              df = df.iloc[valid_indices].reset_index(drop=True)
              mols = [mols[i] for i in valid_indices]
      
          print(f"Loaded {len(mols)} valid molecules")
          return df, mols
      
      
      def apply_rule_filters(mols: List[Chem.Mol], rules: List[str], n_jobs: int) -> pd.DataFrame:
          """Apply medicinal chemistry rule filters.
      
          RuleFilters(...)(...) returns a DataFrame with a `mol` column, `pass_all`,
          `pass_any`, and one boolean column per rule. We keep the per-rule booleans
          plus `pass_all` (renamed for clarity).
          """
          print(f"\nApplying rule filters: {', '.join(rules)}")
      
          rfilter = mc.rules.RuleFilters(rule_list=rules)
          res = rfilter(mols=mols, n_jobs=n_jobs, progress=True)
      
          df_results = res[rules].copy().reset_index(drop=True)
          df_results["passes_all_rules"] = res["pass_all"].to_numpy()
          return df_results
      
      
      def apply_structural_alerts(mols: List[Chem.Mol], alert_type: str, n_jobs: int) -> pd.DataFrame:
          """Apply structural alert filters."""
          print(f"\nApplying {alert_type} structural alerts...")
      
          if alert_type == "common":
              res = mc.structural.CommonAlertsFilters()(mols=mols, n_jobs=n_jobs, progress=True)
              # `pass_filter` is True when the molecule is clean (no alert triggered).
              df_results = pd.DataFrame({
                  "passes_common_alerts": res["pass_filter"].to_numpy(),
                  "common_alert_details": res["reasons"].fillna("").to_numpy(),
              })
      
          elif alert_type == "nibr":
              res = mc.structural.NIBRFilters()(mols=mols, n_jobs=n_jobs, progress=True)
              df_results = pd.DataFrame({
                  "passes_nibr": res["pass_filter"].to_numpy(),
                  "nibr_severity": res["severity"].to_numpy(),
              })
      
          elif alert_type == "lilly":
              # Requires the Lilly MedChem Rules tools (medchem >= 2.1: `medchem install-lilly`).
              try:
                  keep = mc.functional.lilly_demerit_filter(mols, n_jobs=n_jobs, progress=True)
              except ImportError:
                  print("Error: Lilly demerits need the Lilly MedChem Rules tools. "
                        "Install them with `medchem install-lilly` (needs make, a C++ compiler "
                        "and zlib), then re-run with --lilly.")
                  sys.exit(1)
              df_results = pd.DataFrame({"passes_lilly": np.asarray(keep, dtype=bool)})
      
          elif alert_type == "pains":
              keep = mc.functional.alert_filter(mols, alerts=["pains"], n_jobs=n_jobs, progress=True)
              df_results = pd.DataFrame({"passes_pains": np.asarray(keep, dtype=bool)})
      
          else:
              raise ValueError(f"Unknown alert type: {alert_type}")
      
          return df_results
      
      
      def apply_complexity_filter(mols: List[Chem.Mol], method: str, limit: str, n_jobs: int) -> pd.DataFrame:
          """Flag molecules exceeding the complexity percentile threshold.
      
          `complexity_filter` returns a boolean array (True = within the limit / keep).
          """
          print(f"\nApplying complexity filter (metric={method}, limit={limit} percentile)...")
          keep = mc.functional.complexity_filter(
              mols, complexity_metric=method, limit=limit, n_jobs=n_jobs, progress=True
          )
          return pd.DataFrame({"passes_complexity": np.asarray(keep, dtype=bool)})
      
      
      def apply_chemical_groups(mols: List[Chem.Mol], groups: List[str]) -> pd.DataFrame:
          """Detect chemical groups (one boolean column per group)."""
          print(f"\nDetecting chemical groups: {', '.join(groups)}")
          df_results = pd.DataFrame()
          for group in groups:
              cg = mc.groups.ChemicalGroup(groups=[group])
              matched = mc.functional.chemical_group_filter(mols, chemical_group=cg)
              df_results[f"has_{group}"] = np.asarray(matched, dtype=bool)
          return df_results
      
      
      def apply_property_windows(mols: List[Chem.Mol], args) -> pd.DataFrame:
          """Enforce physchem property windows via RDKit descriptors + medchem.rules.in_range.
      
          medchem has no all-in-one property-window object, so we compute the standard
          descriptors with RDKit and gate each with `mc.rules.in_range`.
          """
          from rdkit.Chem import Descriptors, Crippen
          from rdkit.Chem import rdMolDescriptors as rd
      
          print("\nApplying property windows...")
          in_range = mc.rules.in_range
          passes = []
          for mol in tqdm(mols, desc="Properties"):
              ok = True
              if args.mw_range:
                  lo, hi = map(float, args.mw_range.split(","))
                  ok &= in_range(Descriptors.MolWt(mol), min_val=lo, max_val=hi)
              if args.logp_range:
                  lo, hi = map(float, args.logp_range.split(","))
                  ok &= in_range(Crippen.MolLogP(mol), min_val=lo, max_val=hi)
              if args.tpsa_max is not None:
                  ok &= in_range(rd.CalcTPSA(mol), max_val=args.tpsa_max)
              if args.hbd_max is not None:
                  ok &= in_range(rd.CalcNumLipinskiHBD(mol), max_val=args.hbd_max)
              if args.hba_max is not None:
                  ok &= in_range(rd.CalcNumLipinskiHBA(mol), max_val=args.hba_max)
              if args.rotatable_bonds_max is not None:
                  ok &= in_range(rd.CalcNumRotatableBonds(mol), max_val=args.rotatable_bonds_max)
              passes.append(bool(ok))
          return pd.DataFrame({"passes_properties": passes})
      
      
      def generate_summary(df: pd.DataFrame, output_file: Path):
          """Generate filtering summary report."""
          summary_file = output_file.parent / f"{output_file.stem}_summary.txt"
      
          with open(summary_file, "w") as f:
              f.write("=" * 80 + "\n")
              f.write("MEDCHEM FILTERING SUMMARY\n")
              f.write("=" * 80 + "\n\n")
      
              f.write(f"Total molecules processed: {len(df)}\n\n")
      
              # Rule results
              rule_cols = [col for col in df.columns if col.startswith("rule_") or col == "passes_all_rules"]
              if rule_cols:
                  f.write("RULE FILTERS:\n")
                  f.write("-" * 40 + "\n")
                  for col in rule_cols:
                      if col in df.columns and df[col].dtype == bool:
                          n_pass = df[col].sum()
                          pct = 100 * n_pass / len(df)
                          f.write(f"  {col}: {n_pass} passed ({pct:.1f}%)\n")
                  f.write("\n")
      
              # Structural alerts
              alert_cols = [col for col in df.columns if "alert" in col.lower() or "nibr" in col.lower() or "lilly" in col.lower() or "pains" in col.lower()]
              if alert_cols:
                  f.write("STRUCTURAL ALERTS:\n")
                  f.write("-" * 40 + "\n")
                  if "passes_common_alerts" in df.columns:
                      n_clean = df["passes_common_alerts"].sum()
                      pct = 100 * n_clean / len(df)
                      f.write(f"  No common alerts: {n_clean} ({pct:.1f}%)\n")
                  if "passes_nibr" in df.columns:
                      n_pass = df["passes_nibr"].sum()
                      pct = 100 * n_pass / len(df)
                      f.write(f"  Passes NIBR: {n_pass} ({pct:.1f}%)\n")
                  if "passes_lilly" in df.columns:
                      n_pass = df["passes_lilly"].sum()
                      pct = 100 * n_pass / len(df)
                      f.write(f"  Passes Lilly: {n_pass} ({pct:.1f}%)\n")
                  if "passes_pains" in df.columns:
                      n_pass = df["passes_pains"].sum()
                      pct = 100 * n_pass / len(df)
                      f.write(f"  Passes PAINS: {n_pass} ({pct:.1f}%)\n")
                  f.write("\n")
      
              # Complexity
              if "passes_complexity" in df.columns:
                  f.write("COMPLEXITY:\n")
                  f.write("-" * 40 + "\n")
                  n_pass = df["passes_complexity"].sum()
                  pct = 100 * n_pass / len(df)
                  f.write(f"  Within complexity limit: {n_pass} ({pct:.1f}%)\n")
                  f.write("\n")
      
              # Overall pass rate
              pass_cols = [col for col in df.columns if col.startswith("passes_")]
              if pass_cols:
                  df["passes_all_filters"] = df[pass_cols].all(axis=1)
                  n_pass = df["passes_all_filters"].sum()
                  pct = 100 * n_pass / len(df)
                  f.write("OVERALL:\n")
                  f.write("-" * 40 + "\n")
                  f.write(f"  Molecules passing all filters: {n_pass} ({pct:.1f}%)\n")
      
              f.write("\n" + "=" * 80 + "\n")
      
          print(f"\nSummary report saved to: {summary_file}")
      
      
      def main():
          parser = argparse.ArgumentParser(
              description="Batch molecular filtering using medchem",
              formatter_class=argparse.RawDescriptionHelpFormatter,
              epilog=__doc__
          )
      
          # Input/Output
          parser.add_argument("input", type=Path, help="Input file (CSV, TSV, SDF, or TXT)")
          parser.add_argument("--output", "-o", type=Path, required=True, help="Output CSV file")
          parser.add_argument("--smiles-column", default="smiles", help="Name of SMILES column (default: smiles)")
      
          # Rule filters
          parser.add_argument("--rules", help="Comma-separated list of rules (e.g., rule_of_five,rule_of_cns)")
      
          # Structural alerts
          parser.add_argument("--common-alerts", action="store_true", help="Apply common structural alerts")
          parser.add_argument("--nibr", action="store_true", help="Apply NIBR filters")
          parser.add_argument("--lilly", action="store_true", help="Apply Lilly demerits filter")
          parser.add_argument("--pains", action="store_true", help="Apply PAINS filter")
      
          # Complexity (percentile threshold against a reference set)
          parser.add_argument("--complexity", action="store_true",
                              help="Apply the complexity filter (percentile threshold)")
          parser.add_argument("--complexity-method", default="bertz",
                              choices=["bertz", "whitlock", "barone", "smcm", "twc"],
                              help="Complexity metric (default: bertz)")
          parser.add_argument("--complexity-limit", default="99",
                              help="Percentile limit against the reference set (default: 99)")
      
          # Property windows (RDKit descriptors + medchem.rules.in_range)
          parser.add_argument("--mw-range", help="Molecular weight range (e.g., 200,500)")
          parser.add_argument("--logp-range", help="cLogP range (e.g., -2,5)")
          parser.add_argument("--tpsa-max", type=float, help="Maximum TPSA")
          parser.add_argument("--hbd-max", type=int, help="Maximum Lipinski H-bond donors")
          parser.add_argument("--hba-max", type=int, help="Maximum Lipinski H-bond acceptors")
          parser.add_argument("--rotatable-bonds-max", type=int, help="Maximum rotatable bonds")
      
          # Chemical groups
          parser.add_argument("--groups", help="Comma-separated chemical groups to detect")
      
          # Processing options
          parser.add_argument("--n-jobs", type=int, default=-1, help="Number of parallel jobs (-1 = all cores)")
          parser.add_argument("--no-summary", action="store_true", help="Don't generate summary report")
          parser.add_argument("--filter-output", action="store_true", help="Only output molecules passing all filters")
      
          args = parser.parse_args()
      
          # Load molecules
          df, mols = load_molecules(args.input, args.smiles_column)
      
          # Apply filters
          result_dfs = [df]
      
          # Rules
          if args.rules:
              rule_list = [r.strip() for r in args.rules.split(",")]
              df_rules = apply_rule_filters(mols, rule_list, args.n_jobs)
              result_dfs.append(df_rules)
      
          # Structural alerts
          if args.common_alerts:
              df_alerts = apply_structural_alerts(mols, "common", args.n_jobs)
              result_dfs.append(df_alerts)
      
          if args.nibr:
              df_nibr = apply_structural_alerts(mols, "nibr", args.n_jobs)
              result_dfs.append(df_nibr)
      
          if args.lilly:
              df_lilly = apply_structural_alerts(mols, "lilly", args.n_jobs)
              result_dfs.append(df_lilly)
      
          if args.pains:
              df_pains = apply_structural_alerts(mols, "pains", args.n_jobs)
              result_dfs.append(df_pains)
      
          # Complexity
          if args.complexity:
              df_complexity = apply_complexity_filter(
                  mols, args.complexity_method, args.complexity_limit, args.n_jobs
              )
              result_dfs.append(df_complexity)
      
          # Property windows
          if any([args.mw_range, args.logp_range, args.tpsa_max,
                  args.hbd_max, args.hba_max, args.rotatable_bonds_max]):
              df_props = apply_property_windows(mols, args)
              result_dfs.append(df_props)
      
          # Chemical groups
          if args.groups:
              group_list = [g.strip() for g in args.groups.split(",")]
              df_groups = apply_chemical_groups(mols, group_list)
              result_dfs.append(df_groups)
      
          # Combine results
          df_final = pd.concat(result_dfs, axis=1)
      
          # Filter output if requested
          if args.filter_output:
              pass_cols = [col for col in df_final.columns if col.startswith("passes_")]
              if pass_cols:
                  df_final["passes_all"] = df_final[pass_cols].all(axis=1)
                  df_final = df_final[df_final["passes_all"]]
                  print(f"\nFiltered to {len(df_final)} molecules passing all filters")
      
          # Save results
          args.output.parent.mkdir(parents=True, exist_ok=True)
          df_final.to_csv(args.output, index=False)
          print(f"\nResults saved to: {args.output}")
      
          # Generate summary
          if not args.no_summary:
              generate_summary(df_final, args.output)
      
          print("\nDone!")
      
      
      if __name__ == "__main__":
          main()
      
  • SKILL.md 15 KB
    ---
    name: alterlab-medchem
    description: Applies medicinal-chemistry filters with the medchem library — drug-likeness rules (Lipinski, Veber), PAINS filters, structural alerts, and molecular complexity metrics for compound prioritization and library cleanup. Use when filtering or triaging a compound library, flagging PAINS or reactive groups, or assessing drug-likeness of candidate molecules. Part of the AlterLab Academic Skills suite.
    license: Apache-2.0
    allowed-tools: Read Write Edit Bash(python:*) Bash(uv:*)
    compatibility: "Self-contained — runs under `uv run python` with the skill's Python package installed; no API key or account required."
    metadata:
        skill-author: AlterLab
        version: "1.2.0"
        last_updated: "2026-09-23"
    ---
    
    # Medchem
    
    ## Overview
    
    Medchem (`datamol-io/medchem`) is a Python library for molecular filtering and prioritization in drug-discovery workflows: medicinal-chemistry rules, structural alerts (ChEMBL/NIBR/PAINS), chemical-group detection, complexity metrics, and a query DSL. Rules and filters are context-specific guidelines, not hard truth — combine with domain expertise.
    
    **Verified against `medchem==2.1.0` (current as of 2026-09; Python ≥ 3.11, RDKit 2026.03).** API names below are checked against this version; earlier docs/blog posts described a different surface.
    
    ## When to Use This Skill
    
    This skill should be used when:
    - Applying drug-likeness rules (Lipinski, Veber, etc.) to compound libraries
    - Filtering molecules by structural alerts or PAINS patterns
    - Prioritizing compounds for lead optimization
    - Assessing compound quality and medicinal chemistry properties
    - Detecting reactive or problematic functional groups
    - Calculating molecular complexity metrics
    
    ### Does NOT Trigger
    
    | Scenario | Use Instead |
    |----------|-------------|
    | Just computing descriptors (MW, cLogP, TPSA, HBD/HBA) or standardizing structures, no rule/alert filtering | `alterlab-datamol` |
    | Writing custom SMARTS queries or substructure logic outside the curated catalogs | `alterlab-rdkit` |
    | Fetching a labeled toxicity/ADMET benchmark (e.g. hERG, AMES) with scaffold splits | `alterlab-pytdc` |
    | Retrieving measured bioactivity (IC50/Ki) for compounds or targets | `alterlab-chembl` |
    
    ## Installation
    
    ```bash
    uv pip install medchem    # PyPI; pulls rdkit + datamol
    ```
    
    Two features need extra native deps that PyPI cannot provide:
    - **Lilly demerits** (`lilly_demerit_filter`) shells out to the compiled Lilly MedChem Rules tools. Since medchem 2.1, `medchem install-lilly` downloads the checksum-pinned upstream release (2.1.0) and builds it next to the active Python — it needs `make`, a C++ compiler, and zlib (plus Ruby for the regression tests, or pass `--no-test`), and native Windows is unsupported (use WSL). The old conda-forge `lilly-medchem-rules` 1.0.1 build is obsolete. Without the tools, the call raises `ImportError`. 2.1 also changed the default Lilly atom-count limits (soft 25 / hard 40 / minimum 7; previously 30 / 50 / 1).
    - The ChemAxon rule (`rule_of_chemaxon_druglikeness`) needs a licensed ChemAxon install.
    
    Everything else (RuleFilters, CommonAlerts, NIBR, complexity, groups, query) works from the PyPI wheel alone.
    
    ## Core Capabilities
    
    > **Conventions that hold across medchem.** Filters take `mols` (a sequence of SMILES strings or RDKit mols), default to `n_jobs=-1` (all cores), and accept `progress=True`. The `medchem.structural` / `medchem.rules` filter *classes* return a **pandas DataFrame** (one row per input mol); the `medchem.functional.*` helpers return a **NumPy boolean array** where `True` = the molecule passes / is kept. Get the canonical rule and alert names from `mc.rules.RuleFilters.list_available_rules()` and `mc.structural.CommonAlertsFilters.list_default_available_alerts()` rather than guessing.
    
    ### 1. Medicinal Chemistry Rules — `medchem.rules`
    
    **Single rule** — `medchem.rules.basic_rules.*` functions take one mol (SMILES or RDKit) and return a plain `bool`:
    
    ```python
    import medchem as mc
    
    smi = "CC(=O)OC1=CC=CC=C1C(=O)O"  # aspirin
    mc.rules.basic_rules.rule_of_five(smi)   # -> True
    mc.rules.basic_rules.rule_of_veber(smi)  # -> True
    mc.rules.basic_rules.rule_of_cns(smi)
    ```
    
    Available rules (full list via `mc.rules.RuleFilters.list_available_rules()`): `rule_of_five`, `rule_of_five_beyond`, `rule_of_four`, `rule_of_three`, `rule_of_three_extended`, `rule_of_two`, `rule_of_ghose`, `rule_of_veber`, `rule_of_reos`, `rule_of_egan`, `rule_of_pfizer_3_75`, `rule_of_gsk_4_400`, `rule_of_oprea`, `rule_of_xu`, `rule_of_cns`, `rule_of_respiratory`, `rule_of_zinc`, `rule_of_leadlike_soft`, `rule_of_druglike_soft`, `rule_of_generative_design`, `rule_of_generative_design_strict`, `rule_of_chemaxon_druglikeness` (needs ChemAxon).
    
    > There is **no** `rule_of_drug`, `rule_of_leadlike_strict`, `golden_triangle`, or `pains_filter` function (checked in 2.1.0). PAINS lives in the alert system (`HASALERT("pains")` or `CommonAlertsFilters(alerts_set=["PAINS"])`). For lead-likeness use `rule_of_leadlike_soft` or `rule_of_oprea`.
    
    **Multiple rules** — `RuleFilters` returns a DataFrame with columns `mol`, `pass_all`, `pass_any`, and one boolean column per rule:
    
    ```python
    import datamol as dm
    import medchem as mc
    
    mols = [dm.to_mol(s) for s in smiles_list]
    rfilter = mc.rules.RuleFilters(rule_list=["rule_of_five", "rule_of_veber", "rule_of_cns"])
    df = rfilter(mols=mols, n_jobs=-1, progress=True)
    # df["pass_all"] -> bool per molecule; df["rule_of_five"] -> per-rule bool
    clean = [m for m, ok in zip(mols, df["pass_all"]) if ok]
    ```
    
    **Property windows** — there is no all-in-one "Constraints(mw_range=...)" object (see note in section 7). Build custom property cutoffs with `mc.rules.in_range` over descriptor names from `mc.rules.list_descriptors()` (`mw`, `clogp`, `tpsa`, `n_lipinski_hbd`, `n_lipinski_hba`, `n_rotatable_bonds`, `n_rings`, ...), or use the query DSL (`HASPROP`, section 8).
    
    ### 2. Structural Alert Filters — `medchem.structural`
    
    Two filter classes ship in `medchem.structural`: `CommonAlertsFilters` and `NIBRFilters`. (Lilly demerits is reached through `medchem.functional`, see section 3 — its class lives under `medchem.structural.lilly_demerits` and needs external binaries.)
    
    **Common alerts** — curated alert sets from ChEMBL (Glaxo, Dundee, BMS, **PAINS**, SureChEMBL, ...). Returns a DataFrame with `mol`, `pass_filter` (bool), `status` (`ok`/`exclude`), `reasons` (matched alert names, `;`-joined):
    
    ```python
    import medchem as mc
    
    caf = mc.structural.CommonAlertsFilters()                 # all default sets
    caf_pains = mc.structural.CommonAlertsFilters(alerts_set=["PAINS"])  # PAINS only
    df = caf(mols=mol_list, n_jobs=-1, progress=True)
    clean = df[df["pass_filter"]]
    # discover sets: mc.structural.CommonAlertsFilters.list_default_available_alerts()
    ```
    
    **NIBR filters** — Novartis filter set. Returns a DataFrame including `mol`, `pass_filter`, `severity`, `status`, `reasons`:
    
    ```python
    nibr = mc.structural.NIBRFilters()
    df = nibr(mols=mol_list, n_jobs=-1)
    ```
    
    ### 3. Functional API — `medchem.functional`
    
    One-call helpers that return a NumPy boolean array (`True` = keep). Pass `return_idx=True` to get indices of passing mols instead:
    
    ```python
    import medchem as mc
    
    mc.functional.rules_filter(mol_list, rules=["rule_of_five", "rule_of_veber"], n_jobs=-1)
    mc.functional.alert_filter(mol_list, alerts=["pains"], n_jobs=-1)   # alert names are lowercase here
    mc.functional.nibr_filter(mol_list, max_severity=10, n_jobs=-1)
    mc.functional.complexity_filter(mol_list, complexity_metric="bertz", limit="99", n_jobs=-1)
    mc.functional.chemical_group_filter(mol_list, chemical_group=mc.groups.ChemicalGroup(groups=["hinge_binders"]))
    ```
    
    **Lilly demerits** — requires the Lilly tools (`medchem install-lilly`, see Installation); raises `ImportError` if missing. Molecules above `max_demerits` (default 160) are rejected:
    
    ```python
    keep = mc.functional.lilly_demerit_filter(mol_list, max_demerits=160, n_jobs=-1)  # NumPy bool array
    ```
    
    ### 4. Chemical Groups Detection — `medchem.groups`
    
    `ChemicalGroup` matches curated group catalogs. List valid catalog names with `mc.groups.list_default_chemical_groups()` (e.g. `hinge_binders`, `electrophilic_warheads_for_kinases`, `common_warhead_covalent_inhibitors`, `privileged_kinase_inhibitor_scaffolds`, `aggregator`). Per-mol functional-group names (for the query DSL `HASGROUP`) come from `mc.groups.list_functional_group_names()`.
    
    ```python
    import medchem as mc
    
    group = mc.groups.ChemicalGroup(groups=["hinge_binders"])
    group.has_match(mol)        # bool for one mol
    group.get_matches(mol)      # detailed matches
    # batch: mc.functional.chemical_group_filter(mols, chemical_group=group)
    ```
    
    > `phosphate_binders`, `michael_acceptors`, and `reactive_groups` are **not** default catalog names. For reactive/electrophilic motifs use `electrophilic_warheads_for_kinases` / `common_warhead_covalent_inhibitors`, the alert filters (section 2), or a custom SMARTS catalog (`mc.catalogs.catalog_from_smarts`).
    
    ### 5. Named Catalogs — `medchem.catalogs`
    
    ```python
    import medchem as mc
    
    mc.catalogs.list_named_catalogs()      # available catalog names
    cat = mc.catalogs.NamedCatalogs.pains()  # e.g. a PAINS RDKit FilterCatalog
    mc.catalogs.catalog_from_smarts(...)   # build a catalog from custom SMARTS
    ```
    
    ### 6. Molecular Complexity — `medchem.complexity`
    
    `ComplexityFilter` flags molecules whose complexity exceeds a percentile threshold derived from a reference set (default ZINC). It is **called per molecule** and returns a bool (`True` = within limit / keep). Metrics: `bertz`, `whitlock` (`WhitlockCT`), `barone` (`BaroneCT`), `smcm` (`SMCM`), `twc` (`TWC`), plus `sas`/`qed`/`clogp`. New in 2.1: `mc.complexity.SPS(mol)` (normalized SpacialScore, Krzyzanowski et al., J. Med. Chem. 2023); as a `ComplexityFilter` metric (`"spacialscore"`) it needs your own `threshold_stats_file`.
    
    ```python
    import medchem as mc
    
    cflt = mc.complexity.ComplexityFilter(limit="99", complexity_metric="bertz")
    keep = [cflt(m) for m in mol_list]
    # or batch: mc.functional.complexity_filter(mol_list, complexity_metric="bertz", limit="99")
    ```
    
    > There is no `mc.complexity.calculate_complexity(...)` and `ComplexityFilter` takes `limit`/`complexity_metric`/`threshold_stats_file`, **not** `max_complexity`. For a raw score use the metric classes directly (`mc.complexity.TWC`, etc.).
    
    ### 7. Substructure Constraints — `medchem.constraints`
    
    `mc.constraints.Constraints(core, constraint_fns, prop_name="query")` enforces **substructure / R-group** constraints around a query core (via `has_match` / `validate`) — it is **not** a physchem property-window filter. For MW/logP/TPSA windows, use `RuleFilters` + `in_range` (section 1) or the query DSL `HASPROP` (section 8).
    
    ### 8. Query DSL — `medchem.query`
    
    `QueryFilter` evaluates a boolean expression over rules, properties, alerts, and groups. Operators: `AND`, `OR`, `NOT`, comparisons `< > <= >= == !=`. Primitives: `MATCHRULE("...")`, `HASPROP("<descriptor>" < value)`, `HASALERT("<lowercase set>")`, `HASGROUP("...")`, `HASSUBSTRUCTURE`/`HASSUPERSTRUCTURE`, `LIKE`.
    
    ```python
    import medchem as mc
    
    qf = mc.query.QueryFilter('MATCHRULE("rule_of_five") AND HASPROP("mw" < 500) AND NOT HASALERT("pains")')
    keep = qf(mol_list, n_jobs=-1)   # NumPy bool array
    ```
    
    > The syntax is the structured DSL above — **not** free-form text like `"rule_of_five AND NOT common_alerts"`. There is no `mc.query.parse()`; construct `mc.query.QueryFilter(query_string)` and call it on the mols. Alert names inside `HASALERT` are lowercase (`pains`, `tox`, `nih`, ...).
    
    ## Workflow Patterns
    
    ### Pattern 1: Initial Triage of Compound Library
    
    Filter a large collection to drug-like candidates, dropping anything with structural alerts.
    
    ```python
    import datamol as dm
    import medchem as mc
    import pandas as pd
    
    df = pd.read_csv("compounds.csv")
    mols = [dm.to_mol(smi) for smi in df["smiles"]]
    
    # Rule filter -> DataFrame with pass_all + per-rule columns
    rule_df = mc.rules.RuleFilters(rule_list=["rule_of_five", "rule_of_veber"])(
        mols=mols, n_jobs=-1, progress=True
    )
    
    # Structural alerts -> DataFrame with pass_filter (True = clean)
    alert_df = mc.structural.CommonAlertsFilters()(mols=mols, n_jobs=-1, progress=True)
    
    df["passes_rules"] = rule_df["pass_all"].to_numpy()
    df["no_alerts"] = alert_df["pass_filter"].to_numpy()
    df["drug_like"] = df["passes_rules"] & df["no_alerts"]
    
    df[df["drug_like"]].to_csv("filtered_compounds.csv", index=False)
    ```
    
    ### Pattern 2: Lead Optimization Filtering
    
    Stack stricter filters and keep only molecules passing every stage. The `functional.*` helpers all return aligned NumPy bool arrays, so intersecting them is straightforward.
    
    ```python
    import numpy as np
    import medchem as mc
    
    f = mc.functional
    keep = (
        f.rules_filter(candidate_mols, rules=["rule_of_oprea"], n_jobs=-1)
        & f.nibr_filter(candidate_mols, n_jobs=-1)
        & f.complexity_filter(candidate_mols, complexity_metric="bertz", limit="99", n_jobs=-1)
    )
    # Add lilly_demerit_filter(...) too if the Lilly binaries are installed.
    survivors = [m for m, ok in zip(candidate_mols, keep) if ok]
    ```
    
    ### Pattern 3: Identify Specific Chemical Groups
    
    Flag molecules containing a target scaffold/motif (validate names with `mc.groups.list_default_chemical_groups()`).
    
    ```python
    import medchem as mc
    
    group = mc.groups.ChemicalGroup(groups=["hinge_binders"])
    keep = mc.functional.chemical_group_filter(mol_list, chemical_group=group)
    with_group = [m for m, ok in zip(mol_list, keep) if ok]
    ```
    
    ## Best Practices
    
    1. **Context Matters**: Don't blindly apply filters. Understand the biological target and chemical space.
    
    2. **Combine Multiple Filters**: Use rules, structural alerts, and domain knowledge together for better decisions.
    
    3. **Use Parallelization**: For large datasets (>1000 molecules), always use `n_jobs=-1` for parallel processing.
    
    4. **Iterative Refinement**: Start with broad filters (Ro5), then apply more specific criteria (CNS, leadlike) as needed.
    
    5. **Document Filtering Decisions**: Track which molecules were filtered out and why for reproducibility.
    
    6. **Validate Results**: Remember that marketed drugs often fail standard filters—use these as guidelines, not absolute rules.
    
    7. **Consider Prodrugs**: Molecules designed as prodrugs may intentionally violate standard medicinal chemistry rules.
    
    ## Resources
    
    ### references/api_guide.md
    Comprehensive API reference covering all medchem modules with detailed function signatures, parameters, and return types.
    
    ### references/rules_catalog.md
    Complete catalog of available rules, filters, and alerts with descriptions, thresholds, and literature references.
    
    ### scripts/filter_molecules.py
    Batch filtering CLI. Supports CSV/TSV, SDF, and plain-SMILES `.txt` input, configurable filter combinations, and a summary report.
    
    **Usage:**
    ```bash
    uv run python scripts/filter_molecules.py input.csv \
        --rules rule_of_five,rule_of_cns --nibr --output filtered.csv
    ```
    Flags are individual switches (`--nibr`, `--common-alerts`, `--lilly`, `--pains`), not `--alerts <name>`. `--lilly` needs the Lilly tools (`medchem install-lilly`).
    
    ## Documentation
    
    Official documentation: https://medchem-docs.datamol.io/
    GitHub repository: https://github.com/datamol-io/medchem
    
    Part of the AlterLab Academic Skills suite.
    
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related