Refactor into modular molecular workloads
This commit is contained in:
+2
-1
@@ -1,11 +1,12 @@
|
|||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
# Local ChEMBL download (about 738 MB); obtain it separately before running.
|
# Local ChEMBL download (about 738 MB); obtain it separately before running.
|
||||||
chembl_*.txt
|
chembl_*.txt
|
||||||
|
|
||||||
# Files generated by scimesh.py
|
# Files generated by scimesh workloads
|
||||||
*_similarities.csv
|
*_similarities.csv
|
||||||
test_results.csv
|
test_results.csv
|
||||||
test_structures/
|
test_structures/
|
||||||
|
|||||||
@@ -1,56 +1,77 @@
|
|||||||
# SciMesh
|
# SciMesh
|
||||||
|
|
||||||
Minimal local search for ChEMBL molecules similar to gefitinib (`CHEMBL939`).
|
SciMesh is a small local framework for scientific workloads on molecular datasets. It currently provides exact molecular similarity search and exact sparse similarity-graph construction. It runs in one local Python process: there is no network service, multiprocessing, coordinator, database, or dense similarity matrix.
|
||||||
|
|
||||||
The script finds `CHEMBL939` in the TSV file and uses its `canonical_smiles` as the reference. It then makes a second streaming pass through the file, generates Morgan fingerprints (`radius=2`, `fpSize=2048`), and ranks the remaining valid SMILES by Tanimoto similarity. Invalid SMILES and `CHEMBL939` itself are skipped. Only the best 20 results (or the value passed to `--top`) are kept in memory.
|
The ChEMBL TSV database is intentionally not included in this repository. Download it separately and pass its path to the commands below. The expected columns are `chembl_id` and `canonical_smiles`.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
SciMesh requires Python 3.10+ and RDKit.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
RDKit can also be installed through conda-forge:
|
RDKit can alternatively be installed from conda-forge:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
conda install -c conda-forge rdkit
|
conda install -c conda-forge rdkit
|
||||||
|
pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Similarity search
|
||||||
|
|
||||||
|
`similarity-search` finds the top-k molecules most similar to a query. The query is supplied either by ChEMBL ID or by SMILES. It uses Morgan fingerprints with `radius=2` and `fpSize=2048`, Tanimoto similarity, streaming TSV reads, and a bounded heap. Invalid SMILES and the query molecule are skipped.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scimesh.py chembl_37_chemreps.txt -o gefitinib_similarities.csv
|
scimesh similarity-search chembl_37_chemreps.txt \
|
||||||
|
--query-id CHEMBL939 \
|
||||||
|
--top-k 20 \
|
||||||
|
--output results.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
By default, the script writes a CSV with `rank,chembl_id,canonical_smiles,similarity` columns and prints the same top 20 results to the terminal. To choose a different number of results:
|
Use a SMILES query when it is not identified by ChEMBL ID:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scimesh.py chembl_37_chemreps.txt --top 50 -o top_50.csv
|
scimesh similarity-search chembl_37_chemreps.txt \
|
||||||
|
--query-smiles 'COc1cc2ncnc(Nc3ccc(F)c(Cl)c3)c2cc1OCCCN1CCOCC1' \
|
||||||
|
--top-k 20 \
|
||||||
|
--output results.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
During the search, status is written to `stderr` every 100,000 rows: number of processed rows, current and average rates, elapsed time, and the number of invalid SMILES skipped. The interval can be changed or disabled:
|
The output CSV contains `rank,chembl_id,canonical_smiles,similarity`. Search progress and valid/invalid-SMILES statistics are written to the terminal. `--max-rows` limits the candidate scan for small tests, while `--progress-every 0` disables progress reports.
|
||||||
|
|
||||||
|
To render the query and retained candidates:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scimesh.py chembl_37_chemreps.txt --progress-every 500000
|
scimesh similarity-search chembl_37_chemreps.txt \
|
||||||
python scimesh.py chembl_37_chemreps.txt --progress-every 0
|
--query-id CHEMBL939 \
|
||||||
|
--images-dir structures
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick test on part of the database
|
This writes `query.png` and `top_candidates.png` into `structures`.
|
||||||
|
|
||||||
The `--max-rows` option limits the second pass to the first `N` TSV rows. `CHEMBL939` is still found in its own streaming pass first, so the reference stays the same. The resulting CSV is the top 20 only within the processed subset, not the full database.
|
## Similarity graph
|
||||||
|
|
||||||
|
`similarity-graph` constructs an exact sparse undirected graph. Every valid molecule is a vertex; an edge is emitted only when Tanimoto similarity is at least `--threshold`. Each fingerprint is calculated once. Comparisons are processed block by block, each pair is tested once (`i < j`), and no dense N×N matrix is created or stored.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scimesh.py chembl_37_chemreps.txt --max-rows 10000 -o test_results.csv
|
scimesh similarity-graph chembl_37_chemreps.txt \
|
||||||
|
--max-rows 10000 \
|
||||||
|
--threshold 0.7 \
|
||||||
|
--block-size 1000 \
|
||||||
|
--output similarity_graph.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
## Structure images
|
The deterministic edge-list CSV has `source_id,target_id,similarity` columns. The command reports valid molecules, checked pairs, emitted edges, rate, and elapsed time. `--block-size` changes only how comparisons are grouped, not the result.
|
||||||
|
|
||||||
Pass a directory to `--images-dir` to create `CHEMBL939_gefitinib.png` for gefitinib and `top_candidates.png` with a grid of top candidates. Candidate images show rank, ChEMBL ID, and Tanimoto similarity.
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scimesh.py chembl_37_chemreps.txt --images-dir structures
|
pip install -e '.[dev]'
|
||||||
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
The `--image-columns` option controls the number of structures per grid row (default: `4`).
|
The package separates common dataset parsing and fingerprints from independent workloads. Add future workloads through the workload registry without changing the main CLI.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "scimesh"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Local scientific workloads for molecular similarity analysis"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = ["rdkit>=2024.3"]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=8"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
scimesh = "scimesh.cli:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["scimesh*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
rdkit>=2024.3
|
-e .
|
||||||
|
|||||||
-270
@@ -1,270 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""SciMesh: streaming search for ChEMBL molecules similar to gefitinib."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import csv
|
|
||||||
import heapq
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterator
|
|
||||||
|
|
||||||
from rdkit import Chem, DataStructs, RDLogger
|
|
||||||
from rdkit.Chem import Draw, rdFingerprintGenerator
|
|
||||||
|
|
||||||
|
|
||||||
REFERENCE_CHEMBL_ID = "CHEMBL939"
|
|
||||||
FP_RADIUS = 2
|
|
||||||
FP_SIZE = 2048
|
|
||||||
|
|
||||||
# Invalid records are intentionally skipped, so do not emit one RDKit error per row.
|
|
||||||
RDLogger.DisableLog("rdApp.error")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SearchStats:
|
|
||||||
"""Counters collected while scanning the candidate records."""
|
|
||||||
|
|
||||||
scanned: int = 0
|
|
||||||
valid: int = 0
|
|
||||||
invalid: int = 0
|
|
||||||
stopped_early: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def rows(tsv_path: Path) -> Iterator[dict[str, str]]:
|
|
||||||
"""Read ChEMBL records one at a time without loading the file into memory."""
|
|
||||||
with tsv_path.open("r", encoding="utf-8", newline="") as source:
|
|
||||||
yield from csv.DictReader(source, delimiter="\t")
|
|
||||||
|
|
||||||
|
|
||||||
def molecule(smiles: str | None) -> Chem.Mol | None:
|
|
||||||
"""Return a parsed molecule, or None when a SMILES is empty or invalid."""
|
|
||||||
if not smiles:
|
|
||||||
return None
|
|
||||||
return Chem.MolFromSmiles(smiles)
|
|
||||||
|
|
||||||
|
|
||||||
def find_reference_smiles(tsv_path: Path) -> str:
|
|
||||||
"""Locate CHEMBL939 and return its canonical_smiles."""
|
|
||||||
for row in rows(tsv_path):
|
|
||||||
if row.get("chembl_id") == REFERENCE_CHEMBL_ID:
|
|
||||||
smiles = row.get("canonical_smiles", "")
|
|
||||||
if molecule(smiles) is None:
|
|
||||||
raise ValueError(f"{REFERENCE_CHEMBL_ID} has an invalid canonical_smiles")
|
|
||||||
return smiles
|
|
||||||
raise ValueError(f"{REFERENCE_CHEMBL_ID} was not found in {tsv_path}")
|
|
||||||
|
|
||||||
|
|
||||||
def find_similar(
|
|
||||||
tsv_path: Path,
|
|
||||||
reference_smiles: str,
|
|
||||||
limit: int,
|
|
||||||
progress_every: int,
|
|
||||||
max_rows: int | None,
|
|
||||||
) -> tuple[list[tuple[float, str, str]], SearchStats]:
|
|
||||||
"""Calculate top similarities, retaining only ``limit`` rows in memory."""
|
|
||||||
generator = rdFingerprintGenerator.GetMorganGenerator(
|
|
||||||
radius=FP_RADIUS, fpSize=FP_SIZE
|
|
||||||
)
|
|
||||||
reference_fp = generator.GetFingerprint(molecule(reference_smiles))
|
|
||||||
best: list[tuple[float, str, str]] = []
|
|
||||||
stats = SearchStats()
|
|
||||||
started_at = time.perf_counter()
|
|
||||||
last_report_at = started_at
|
|
||||||
last_report_rows = 0
|
|
||||||
|
|
||||||
print("Searching candidates...", file=sys.stderr)
|
|
||||||
|
|
||||||
for row in rows(tsv_path):
|
|
||||||
if max_rows is not None and stats.scanned >= max_rows:
|
|
||||||
stats.stopped_early = True
|
|
||||||
break
|
|
||||||
stats.scanned += 1
|
|
||||||
chembl_id = row.get("chembl_id", "")
|
|
||||||
smiles = row.get("canonical_smiles", "")
|
|
||||||
if chembl_id != REFERENCE_CHEMBL_ID:
|
|
||||||
mol = molecule(smiles)
|
|
||||||
if mol is None:
|
|
||||||
stats.invalid += 1
|
|
||||||
else:
|
|
||||||
stats.valid += 1
|
|
||||||
similarity = DataStructs.TanimotoSimilarity(
|
|
||||||
reference_fp, generator.GetFingerprint(mol)
|
|
||||||
)
|
|
||||||
candidate = (similarity, chembl_id, smiles)
|
|
||||||
if len(best) < limit:
|
|
||||||
heapq.heappush(best, candidate)
|
|
||||||
elif candidate > best[0]:
|
|
||||||
heapq.heapreplace(best, candidate)
|
|
||||||
|
|
||||||
if progress_every and stats.scanned % progress_every == 0:
|
|
||||||
now = time.perf_counter()
|
|
||||||
interval_seconds = now - last_report_at
|
|
||||||
total_seconds = now - started_at
|
|
||||||
interval_rows = stats.scanned - last_report_rows
|
|
||||||
current_rate = interval_rows / interval_seconds if interval_seconds else 0.0
|
|
||||||
average_rate = stats.scanned / total_seconds if total_seconds else 0.0
|
|
||||||
print(
|
|
||||||
"Processed "
|
|
||||||
f"{stats.scanned:,} rows | {current_rate:,.0f} rows/s current | "
|
|
||||||
f"{average_rate:,.0f} rows/s average | {total_seconds:.1f}s elapsed | "
|
|
||||||
f"{stats.valid:,} valid | {stats.invalid:,} invalid | top {len(best)}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
last_report_at = now
|
|
||||||
last_report_rows = stats.scanned
|
|
||||||
|
|
||||||
return sorted(best, reverse=True), stats
|
|
||||||
|
|
||||||
|
|
||||||
def write_results(output_path: Path, matches: list[tuple[float, str, str]]) -> None:
|
|
||||||
"""Write ranked matching records as CSV."""
|
|
||||||
with output_path.open("w", encoding="utf-8", newline="") as destination:
|
|
||||||
writer = csv.DictWriter(
|
|
||||||
destination,
|
|
||||||
fieldnames=["rank", "chembl_id", "canonical_smiles", "similarity"],
|
|
||||||
)
|
|
||||||
writer.writeheader()
|
|
||||||
for rank, (similarity, chembl_id, smiles) in enumerate(matches, start=1):
|
|
||||||
writer.writerow(
|
|
||||||
{
|
|
||||||
"rank": rank,
|
|
||||||
"chembl_id": chembl_id,
|
|
||||||
"canonical_smiles": smiles,
|
|
||||||
"similarity": f"{similarity:.6f}",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def write_images(
|
|
||||||
output_dir: Path,
|
|
||||||
reference_smiles: str,
|
|
||||||
matches: list[tuple[float, str, str]],
|
|
||||||
columns: int,
|
|
||||||
) -> tuple[Path, Path]:
|
|
||||||
"""Create PNG depictions for the reference molecule and ranked matches."""
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
reference_path = output_dir / "CHEMBL939_gefitinib.png"
|
|
||||||
reference_image = Draw.MolToImage(
|
|
||||||
molecule(reference_smiles), size=(600, 400), legend="CHEMBL939 (gefitinib)"
|
|
||||||
)
|
|
||||||
reference_image.save(reference_path)
|
|
||||||
|
|
||||||
candidate_path = output_dir / "top_candidates.png"
|
|
||||||
candidate_molecules = [molecule(smiles) for _, _, smiles in matches]
|
|
||||||
legends = [
|
|
||||||
f"#{rank} {chembl_id}\nTanimoto: {similarity:.4f}"
|
|
||||||
for rank, (similarity, chembl_id, _) in enumerate(matches, start=1)
|
|
||||||
]
|
|
||||||
candidate_image = Draw.MolsToGridImage(
|
|
||||||
candidate_molecules,
|
|
||||||
molsPerRow=columns,
|
|
||||||
subImgSize=(350, 250),
|
|
||||||
legends=legends,
|
|
||||||
)
|
|
||||||
candidate_image.save(candidate_path)
|
|
||||||
return reference_path, candidate_path
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Find ChEMBL molecules similar to gefitinib (CHEMBL939)."
|
|
||||||
)
|
|
||||||
parser.add_argument("input", type=Path, help="Path to ChEMBL TSV file")
|
|
||||||
parser.add_argument(
|
|
||||||
"-o",
|
|
||||||
"--output",
|
|
||||||
type=Path,
|
|
||||||
default=Path("gefitinib_similarities.csv"),
|
|
||||||
help="Output CSV path (default: gefitinib_similarities.csv)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--top",
|
|
||||||
type=int,
|
|
||||||
default=20,
|
|
||||||
help="Number of matches to retain (default: 20)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--progress-every",
|
|
||||||
type=int,
|
|
||||||
default=100_000,
|
|
||||||
help="Print progress after this many rows; 0 disables it (default: 100000)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--max-rows",
|
|
||||||
type=int,
|
|
||||||
help="For testing, scan only the first N TSV rows after locating CHEMBL939",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--images-dir",
|
|
||||||
type=Path,
|
|
||||||
help="Create reference and top-candidate PNG images in this directory",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--image-columns",
|
|
||||||
type=int,
|
|
||||||
default=4,
|
|
||||||
help="Number of molecules per row in the candidate image (default: 4)",
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
if args.top < 1:
|
|
||||||
print("--top must be a positive integer", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
if args.progress_every < 0:
|
|
||||||
print("--progress-every cannot be negative", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
if args.max_rows is not None and args.max_rows < 1:
|
|
||||||
print("--max-rows must be a positive integer", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
if args.image_columns < 1:
|
|
||||||
print("--image-columns must be a positive integer", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
if not args.input.is_file():
|
|
||||||
print(f"Input file not found: {args.input}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
try:
|
|
||||||
reference_smiles = find_reference_smiles(args.input)
|
|
||||||
matches, stats = find_similar(
|
|
||||||
args.input,
|
|
||||||
reference_smiles,
|
|
||||||
args.top,
|
|
||||||
args.progress_every,
|
|
||||||
args.max_rows,
|
|
||||||
)
|
|
||||||
except ValueError as error:
|
|
||||||
print(f"Error: {error}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
write_results(args.output, matches)
|
|
||||||
image_paths: tuple[Path, Path] | None = None
|
|
||||||
if args.images_dir:
|
|
||||||
image_paths = write_images(
|
|
||||||
args.images_dir, reference_smiles, matches, args.image_columns
|
|
||||||
)
|
|
||||||
print(f"Reference {REFERENCE_CHEMBL_ID}: {reference_smiles}")
|
|
||||||
print(
|
|
||||||
f"Scanned {stats.scanned:,} rows: {stats.valid:,} valid, "
|
|
||||||
f"{stats.invalid:,} invalid SMILES."
|
|
||||||
)
|
|
||||||
if stats.stopped_early:
|
|
||||||
print("Stopped early because of --max-rows; results cover only that subset.")
|
|
||||||
print(f"Saved {len(matches)} matches to {args.output}")
|
|
||||||
if image_paths:
|
|
||||||
print(f"Saved reference image to {image_paths[0]}")
|
|
||||||
print(f"Saved candidate image to {image_paths[1]}")
|
|
||||||
for rank, (similarity, chembl_id, smiles) in enumerate(matches, start=1):
|
|
||||||
print(f"{rank:>2}. {chembl_id}\t{similarity:.6f}\t{smiles}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""SciMesh local scientific workloads."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Shared ChEMBL parsing and fingerprint helpers."""
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Streaming readers for ChEMBL-style TSV datasets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from rdkit import Chem, RDLogger
|
||||||
|
|
||||||
|
|
||||||
|
ID_COLUMN = "chembl_id"
|
||||||
|
SMILES_COLUMN = "canonical_smiles"
|
||||||
|
|
||||||
|
# Invalid records are expected in large datasets; suppress one RDKit error per row.
|
||||||
|
RDLogger.DisableLog("rdApp.error")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DatasetStats:
|
||||||
|
"""Counters collected while streaming a dataset."""
|
||||||
|
|
||||||
|
scanned: int = 0
|
||||||
|
valid: int = 0
|
||||||
|
invalid: int = 0
|
||||||
|
stopped_early: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MoleculeRecord:
|
||||||
|
"""A valid molecule parsed from a ChEMBL TSV row."""
|
||||||
|
|
||||||
|
molecule_id: str
|
||||||
|
smiles: str
|
||||||
|
molecule: Chem.Mol
|
||||||
|
|
||||||
|
|
||||||
|
def parse_smiles(smiles: str | None) -> Chem.Mol | None:
|
||||||
|
"""Return a molecule for a non-empty valid SMILES, otherwise None."""
|
||||||
|
if not smiles:
|
||||||
|
return None
|
||||||
|
return Chem.MolFromSmiles(smiles)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_rows(tsv_path: Path) -> Iterator[dict[str, str]]:
|
||||||
|
"""Yield TSV rows without loading the full file into memory."""
|
||||||
|
with tsv_path.open("r", encoding="utf-8", newline="") as source:
|
||||||
|
reader = csv.DictReader(source, delimiter="\t")
|
||||||
|
fieldnames = set(reader.fieldnames or [])
|
||||||
|
missing = {ID_COLUMN, SMILES_COLUMN} - fieldnames
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Dataset is missing required columns: {', '.join(sorted(missing))}")
|
||||||
|
yield from reader
|
||||||
|
|
||||||
|
|
||||||
|
def iter_valid_molecules(
|
||||||
|
tsv_path: Path, stats: DatasetStats, max_rows: int | None = None
|
||||||
|
) -> Iterator[MoleculeRecord]:
|
||||||
|
"""Yield valid molecules while updating streaming statistics."""
|
||||||
|
for row in iter_rows(tsv_path):
|
||||||
|
if max_rows is not None and stats.scanned >= max_rows:
|
||||||
|
stats.stopped_early = True
|
||||||
|
break
|
||||||
|
stats.scanned += 1
|
||||||
|
smiles = row.get(SMILES_COLUMN, "")
|
||||||
|
molecule = parse_smiles(smiles)
|
||||||
|
if molecule is None:
|
||||||
|
stats.invalid += 1
|
||||||
|
continue
|
||||||
|
stats.valid += 1
|
||||||
|
yield MoleculeRecord(row.get(ID_COLUMN, ""), smiles, molecule)
|
||||||
|
|
||||||
|
|
||||||
|
def find_molecule_by_id(tsv_path: Path, molecule_id: str) -> MoleculeRecord:
|
||||||
|
"""Find and validate a molecule by ChEMBL identifier in a streaming pass."""
|
||||||
|
for row in iter_rows(tsv_path):
|
||||||
|
if row.get(ID_COLUMN) != molecule_id:
|
||||||
|
continue
|
||||||
|
smiles = row.get(SMILES_COLUMN, "")
|
||||||
|
molecule = parse_smiles(smiles)
|
||||||
|
if molecule is None:
|
||||||
|
raise ValueError(f"{molecule_id} has an invalid canonical_smiles")
|
||||||
|
return MoleculeRecord(molecule_id, smiles, molecule)
|
||||||
|
raise ValueError(f"{molecule_id} was not found in {tsv_path}")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Morgan fingerprint utilities shared by molecular workloads."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from rdkit import Chem
|
||||||
|
from rdkit.Chem import rdFingerprintGenerator
|
||||||
|
|
||||||
|
|
||||||
|
FP_RADIUS = 2
|
||||||
|
FP_SIZE = 2048
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def morgan_generator() -> Any:
|
||||||
|
"""Return the standard SciMesh Morgan fingerprint generator."""
|
||||||
|
return rdFingerprintGenerator.GetMorganGenerator(radius=FP_RADIUS, fpSize=FP_SIZE)
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(molecule: Chem.Mol) -> Any:
|
||||||
|
"""Build a Morgan radius-2, 2048-bit fingerprint."""
|
||||||
|
return morgan_generator().GetFingerprint(molecule)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Command-line entry point for SciMesh."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from scimesh.core.registry import WorkloadRegistry
|
||||||
|
from scimesh.workloads import register_workloads
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""Build the top-level parser from the registered workloads."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="scimesh",
|
||||||
|
description="Run local scientific workloads on molecular datasets.",
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="workload", required=True)
|
||||||
|
registry = WorkloadRegistry()
|
||||||
|
register_workloads(registry)
|
||||||
|
registry.add_subparsers(subparsers)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Run a selected workload and return its exit status."""
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
return args.handler(args)
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
print(f"Error: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Core workload abstractions."""
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Registry for locally available workloads."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from scimesh.core.workload import Workload
|
||||||
|
|
||||||
|
|
||||||
|
class WorkloadRegistry:
|
||||||
|
"""Collect workloads and expose each one as a CLI subcommand."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._workloads: dict[str, Workload] = {}
|
||||||
|
|
||||||
|
def register(self, workload: Workload) -> None:
|
||||||
|
"""Register a workload by its unique command name."""
|
||||||
|
if workload.name in self._workloads:
|
||||||
|
raise ValueError(f"Workload already registered: {workload.name}")
|
||||||
|
self._workloads[workload.name] = workload
|
||||||
|
|
||||||
|
def add_subparsers(self, subparsers: argparse._SubParsersAction) -> None:
|
||||||
|
"""Add a parser for every registered workload."""
|
||||||
|
for workload in self._workloads.values():
|
||||||
|
parser = subparsers.add_parser(workload.name, help=workload.help)
|
||||||
|
workload.configure_parser(parser)
|
||||||
|
parser.set_defaults(handler=workload.run)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Minimal interface implemented by every SciMesh workload."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class Workload(Protocol):
|
||||||
|
"""A workload that can add its CLI and execute from parsed arguments."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
help: str
|
||||||
|
|
||||||
|
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""Add workload-specific command-line arguments."""
|
||||||
|
|
||||||
|
def run(self, args: argparse.Namespace) -> int:
|
||||||
|
"""Execute the workload."""
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Built-in SciMesh workloads."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from scimesh.core.registry import WorkloadRegistry
|
||||||
|
from scimesh.workloads.similarity_graph import SimilarityGraphWorkload
|
||||||
|
from scimesh.workloads.similarity_search import SimilaritySearchWorkload
|
||||||
|
|
||||||
|
|
||||||
|
def register_workloads(registry: WorkloadRegistry) -> None:
|
||||||
|
"""Register built-in workloads in one place, outside the main CLI."""
|
||||||
|
registry.register(SimilaritySearchWorkload())
|
||||||
|
registry.register(SimilarityGraphWorkload())
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Exact sparse molecular similarity graph workload."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from rdkit import DataStructs
|
||||||
|
|
||||||
|
from scimesh.chemistry.dataset import DatasetStats, MoleculeRecord, iter_valid_molecules
|
||||||
|
from scimesh.chemistry.fingerprints import fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GraphMolecule:
|
||||||
|
"""A valid record and its fingerprint, built once for graph construction."""
|
||||||
|
|
||||||
|
molecule_id: str
|
||||||
|
fingerprint: Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SimilarityEdge:
|
||||||
|
"""A thresholded, undirected similarity edge represented once as i < j."""
|
||||||
|
|
||||||
|
source_id: str
|
||||||
|
target_id: str
|
||||||
|
similarity: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GraphResult:
|
||||||
|
"""Edges, dataset statistics, and pair-comparison statistics."""
|
||||||
|
|
||||||
|
edges: list[SimilarityEdge]
|
||||||
|
stats: DatasetStats
|
||||||
|
checked_pairs: int
|
||||||
|
elapsed_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
def _fingerprinted_molecules(
|
||||||
|
tsv_path: Path, max_rows: int | None
|
||||||
|
) -> tuple[list[GraphMolecule], DatasetStats]:
|
||||||
|
stats = DatasetStats()
|
||||||
|
molecules = [
|
||||||
|
GraphMolecule(record.molecule_id, fingerprint(record.molecule))
|
||||||
|
for record in iter_valid_molecules(tsv_path, stats, max_rows=max_rows)
|
||||||
|
]
|
||||||
|
return molecules, stats
|
||||||
|
|
||||||
|
|
||||||
|
def build_similarity_graph(
|
||||||
|
tsv_path: Path,
|
||||||
|
threshold: float,
|
||||||
|
block_size: int,
|
||||||
|
max_rows: int | None = None,
|
||||||
|
progress_every: int = 0,
|
||||||
|
) -> GraphResult:
|
||||||
|
"""Build an exact sparse graph without creating a dense similarity matrix."""
|
||||||
|
if not 0.0 <= threshold <= 1.0:
|
||||||
|
raise ValueError("--threshold must be between 0 and 1")
|
||||||
|
if block_size < 1:
|
||||||
|
raise ValueError("--block-size must be a positive integer")
|
||||||
|
|
||||||
|
molecules, stats = _fingerprinted_molecules(tsv_path, max_rows)
|
||||||
|
edges: list[SimilarityEdge] = []
|
||||||
|
checked_pairs = 0
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
next_report = progress_every
|
||||||
|
|
||||||
|
for left_block_start in range(0, len(molecules), block_size):
|
||||||
|
left_block_end = min(left_block_start + block_size, len(molecules))
|
||||||
|
for right_block_start in range(left_block_start, len(molecules), block_size):
|
||||||
|
right_block_end = min(right_block_start + block_size, len(molecules))
|
||||||
|
same_block = left_block_start == right_block_start
|
||||||
|
for left_index in range(left_block_start, left_block_end):
|
||||||
|
right_start = left_index + 1 if same_block else right_block_start
|
||||||
|
for right_index in range(right_start, right_block_end):
|
||||||
|
checked_pairs += 1
|
||||||
|
similarity = DataStructs.TanimotoSimilarity(
|
||||||
|
molecules[left_index].fingerprint,
|
||||||
|
molecules[right_index].fingerprint,
|
||||||
|
)
|
||||||
|
if similarity >= threshold:
|
||||||
|
edges.append(
|
||||||
|
SimilarityEdge(
|
||||||
|
molecules[left_index].molecule_id,
|
||||||
|
molecules[right_index].molecule_id,
|
||||||
|
similarity,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if progress_every and checked_pairs >= next_report:
|
||||||
|
elapsed = time.perf_counter() - started_at
|
||||||
|
rate = checked_pairs / elapsed if elapsed else 0.0
|
||||||
|
print(
|
||||||
|
f"Checked {checked_pairs:,} pairs | {len(edges):,} edges | "
|
||||||
|
f"{rate:,.0f} pairs/s | {elapsed:.1f}s elapsed",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
next_report += progress_every
|
||||||
|
|
||||||
|
elapsed_seconds = time.perf_counter() - started_at
|
||||||
|
edges.sort(key=lambda edge: (edge.source_id, edge.target_id, -edge.similarity))
|
||||||
|
return GraphResult(edges, stats, checked_pairs, elapsed_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def write_graph_edges(output_path: Path, edges: list[SimilarityEdge]) -> None:
|
||||||
|
"""Write a deterministic sparse edge list CSV."""
|
||||||
|
with output_path.open("w", encoding="utf-8", newline="") as destination:
|
||||||
|
writer = csv.DictWriter(destination, fieldnames=["source_id", "target_id", "similarity"])
|
||||||
|
writer.writeheader()
|
||||||
|
for edge in edges:
|
||||||
|
writer.writerow(
|
||||||
|
{
|
||||||
|
"source_id": edge.source_id,
|
||||||
|
"target_id": edge.target_id,
|
||||||
|
"similarity": f"{edge.similarity:.6f}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SimilarityGraphWorkload:
|
||||||
|
"""CLI adapter for exact block-wise sparse similarity graph construction."""
|
||||||
|
|
||||||
|
name = "similarity-graph"
|
||||||
|
help = "Build an exact sparse graph of thresholded molecular similarities."
|
||||||
|
|
||||||
|
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("input", type=Path, help="Path to ChEMBL TSV file")
|
||||||
|
parser.add_argument(
|
||||||
|
"--threshold", type=float, required=True,
|
||||||
|
help="Create edges at or above this Tanimoto similarity",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--block-size", type=int, default=1_000,
|
||||||
|
help="Number of molecules per comparison block (default: 1000)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-rows", type=int,
|
||||||
|
help="Read only the first N dataset rows",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--progress-every", type=int, default=100_000,
|
||||||
|
help="Print progress after this many pairs; 0 disables it",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o", "--output", type=Path, default=Path("similarity_graph.csv"),
|
||||||
|
help="Output edge-list CSV path",
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self, args: argparse.Namespace) -> int:
|
||||||
|
if args.max_rows is not None and args.max_rows < 1:
|
||||||
|
raise ValueError("--max-rows must be a positive integer")
|
||||||
|
if args.progress_every < 0:
|
||||||
|
raise ValueError("--progress-every cannot be negative")
|
||||||
|
result = build_similarity_graph(
|
||||||
|
args.input,
|
||||||
|
args.threshold,
|
||||||
|
args.block_size,
|
||||||
|
args.max_rows,
|
||||||
|
args.progress_every,
|
||||||
|
)
|
||||||
|
write_graph_edges(args.output, result.edges)
|
||||||
|
rate = (
|
||||||
|
result.checked_pairs / result.elapsed_seconds
|
||||||
|
if result.elapsed_seconds
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Valid molecules: {result.stats.valid:,} | invalid SMILES: "
|
||||||
|
f"{result.stats.invalid:,} | scanned rows: {result.stats.scanned:,}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Checked pairs: {result.checked_pairs:,} | edges: {len(result.edges):,} | "
|
||||||
|
f"{rate:,.0f} pairs/s | {result.elapsed_seconds:.1f}s elapsed"
|
||||||
|
)
|
||||||
|
if result.stats.stopped_early:
|
||||||
|
print("Stopped early because of --max-rows; graph covers only that subset.")
|
||||||
|
print(f"Saved {len(result.edges)} edges to {args.output}")
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Top-k molecular similarity search workload."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import heapq
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from rdkit import Chem, DataStructs
|
||||||
|
from rdkit.Chem import Draw
|
||||||
|
|
||||||
|
from scimesh.chemistry.dataset import (
|
||||||
|
DatasetStats,
|
||||||
|
MoleculeRecord,
|
||||||
|
find_molecule_by_id,
|
||||||
|
iter_valid_molecules,
|
||||||
|
parse_smiles,
|
||||||
|
)
|
||||||
|
from scimesh.chemistry.fingerprints import fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SimilarityMatch:
|
||||||
|
"""A candidate ranked by descending similarity and stable tie-breakers."""
|
||||||
|
|
||||||
|
similarity: float
|
||||||
|
molecule_id: str
|
||||||
|
smiles: str
|
||||||
|
|
||||||
|
def sort_key(self) -> tuple[float, str, str]:
|
||||||
|
return (-self.similarity, self.molecule_id, self.smiles)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _HeapEntry:
|
||||||
|
"""Heap item whose minimum is the worst retained match."""
|
||||||
|
|
||||||
|
match: SimilarityMatch
|
||||||
|
|
||||||
|
def __lt__(self, other: object) -> bool:
|
||||||
|
if not isinstance(other, _HeapEntry):
|
||||||
|
return NotImplemented
|
||||||
|
return self.match.sort_key() > other.match.sort_key()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SearchResult:
|
||||||
|
"""Results and scan statistics for a similarity search."""
|
||||||
|
|
||||||
|
matches: list[SimilarityMatch]
|
||||||
|
stats: DatasetStats
|
||||||
|
|
||||||
|
|
||||||
|
def search_similar(
|
||||||
|
tsv_path: Path,
|
||||||
|
query: MoleculeRecord,
|
||||||
|
top_k: int,
|
||||||
|
max_rows: int | None = None,
|
||||||
|
progress_every: int = 0,
|
||||||
|
) -> SearchResult:
|
||||||
|
"""Stream top-k matches, retaining only a bounded heap in memory."""
|
||||||
|
if top_k < 1:
|
||||||
|
raise ValueError("--top-k must be a positive integer")
|
||||||
|
query_fingerprint = fingerprint(query.molecule)
|
||||||
|
query_canonical_smiles = Chem.MolToSmiles(query.molecule, canonical=True)
|
||||||
|
stats = DatasetStats()
|
||||||
|
heap: list[_HeapEntry] = []
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
last_report_at = started_at
|
||||||
|
last_report_rows = 0
|
||||||
|
|
||||||
|
for record in iter_valid_molecules(tsv_path, stats, max_rows=max_rows):
|
||||||
|
candidate_canonical_smiles = Chem.MolToSmiles(record.molecule, canonical=True)
|
||||||
|
if (
|
||||||
|
record.molecule_id == query.molecule_id
|
||||||
|
or candidate_canonical_smiles == query_canonical_smiles
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
match = SimilarityMatch(
|
||||||
|
DataStructs.TanimotoSimilarity(query_fingerprint, fingerprint(record.molecule)),
|
||||||
|
record.molecule_id,
|
||||||
|
record.smiles,
|
||||||
|
)
|
||||||
|
entry = _HeapEntry(match)
|
||||||
|
if len(heap) < top_k:
|
||||||
|
heapq.heappush(heap, entry)
|
||||||
|
elif match.sort_key() < heap[0].match.sort_key():
|
||||||
|
heapq.heapreplace(heap, entry)
|
||||||
|
|
||||||
|
if progress_every and stats.scanned % progress_every == 0:
|
||||||
|
now = time.perf_counter()
|
||||||
|
interval = now - last_report_at
|
||||||
|
total = now - started_at
|
||||||
|
current_rate = (stats.scanned - last_report_rows) / interval if interval else 0.0
|
||||||
|
average_rate = stats.scanned / total if total else 0.0
|
||||||
|
print(
|
||||||
|
f"Processed {stats.scanned:,} rows | {current_rate:,.0f} rows/s current | "
|
||||||
|
f"{average_rate:,.0f} rows/s average | {total:.1f}s elapsed | "
|
||||||
|
f"{stats.valid:,} valid | {stats.invalid:,} invalid | top {len(heap)}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
last_report_at = now
|
||||||
|
last_report_rows = stats.scanned
|
||||||
|
|
||||||
|
return SearchResult(sorted((entry.match for entry in heap), key=SimilarityMatch.sort_key), stats)
|
||||||
|
|
||||||
|
|
||||||
|
def write_search_results(output_path: Path, matches: list[SimilarityMatch]) -> None:
|
||||||
|
"""Write ranked matches to a deterministic CSV file."""
|
||||||
|
with output_path.open("w", encoding="utf-8", newline="") as destination:
|
||||||
|
writer = csv.DictWriter(
|
||||||
|
destination,
|
||||||
|
fieldnames=["rank", "chembl_id", "canonical_smiles", "similarity"],
|
||||||
|
)
|
||||||
|
writer.writeheader()
|
||||||
|
for rank, match in enumerate(matches, start=1):
|
||||||
|
writer.writerow(
|
||||||
|
{
|
||||||
|
"rank": rank,
|
||||||
|
"chembl_id": match.molecule_id,
|
||||||
|
"canonical_smiles": match.smiles,
|
||||||
|
"similarity": f"{match.similarity:.6f}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_search_images(
|
||||||
|
output_dir: Path, query: MoleculeRecord, matches: list[SimilarityMatch], columns: int
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
"""Create PNG depictions for the query molecule and retained matches."""
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
query_path = output_dir / "query.png"
|
||||||
|
Draw.MolToImage(
|
||||||
|
query.molecule, size=(600, 400), legend=f"Query: {query.molecule_id}"
|
||||||
|
).save(query_path)
|
||||||
|
|
||||||
|
candidates_path = output_dir / "top_candidates.png"
|
||||||
|
molecules = [parse_smiles(match.smiles) for match in matches]
|
||||||
|
legends = [
|
||||||
|
f"#{rank} {match.molecule_id}\nTanimoto: {match.similarity:.4f}"
|
||||||
|
for rank, match in enumerate(matches, start=1)
|
||||||
|
]
|
||||||
|
Draw.MolsToGridImage(
|
||||||
|
molecules, molsPerRow=columns, subImgSize=(350, 250), legends=legends
|
||||||
|
).save(candidates_path)
|
||||||
|
return query_path, candidates_path
|
||||||
|
|
||||||
|
|
||||||
|
class SimilaritySearchWorkload:
|
||||||
|
"""CLI adapter for streaming top-k molecular similarity search."""
|
||||||
|
|
||||||
|
name = "similarity-search"
|
||||||
|
help = "Find top-k molecules similar to a query molecule."
|
||||||
|
|
||||||
|
def configure_parser(self, parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument("input", type=Path, help="Path to ChEMBL TSV file")
|
||||||
|
query_group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
query_group.add_argument("--query-id", help="ChEMBL ID of the query molecule")
|
||||||
|
query_group.add_argument("--query-smiles", help="SMILES of the query molecule")
|
||||||
|
parser.add_argument(
|
||||||
|
"--top-k", "--top", dest="top_k", type=int, default=20,
|
||||||
|
help="Number of matches to retain (default: 20)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o", "--output", type=Path, default=Path("similarity_results.csv"),
|
||||||
|
help="Output CSV path",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--progress-every", type=int, default=100_000,
|
||||||
|
help="Print progress after this many rows; 0 disables it",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-rows", type=int,
|
||||||
|
help="Scan only the first N rows after resolving the query",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--images-dir", type=Path,
|
||||||
|
help="Directory for query and top-candidate PNG images",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--image-columns", type=int, default=4,
|
||||||
|
help="Number of molecules per row in the candidate image",
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self, args: argparse.Namespace) -> int:
|
||||||
|
if args.progress_every < 0:
|
||||||
|
raise ValueError("--progress-every cannot be negative")
|
||||||
|
if args.max_rows is not None and args.max_rows < 1:
|
||||||
|
raise ValueError("--max-rows must be a positive integer")
|
||||||
|
if args.image_columns < 1:
|
||||||
|
raise ValueError("--image-columns must be a positive integer")
|
||||||
|
if args.query_id:
|
||||||
|
query = find_molecule_by_id(args.input, args.query_id)
|
||||||
|
else:
|
||||||
|
molecule = parse_smiles(args.query_smiles)
|
||||||
|
if molecule is None:
|
||||||
|
raise ValueError("--query-smiles is invalid")
|
||||||
|
query = MoleculeRecord("query", args.query_smiles, molecule)
|
||||||
|
|
||||||
|
result = search_similar(
|
||||||
|
args.input, query, args.top_k, args.max_rows, args.progress_every
|
||||||
|
)
|
||||||
|
write_search_results(args.output, result.matches)
|
||||||
|
image_paths: tuple[Path, Path] | None = None
|
||||||
|
if args.images_dir:
|
||||||
|
image_paths = write_search_images(
|
||||||
|
args.images_dir, query, result.matches, args.image_columns
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Query {query.molecule_id}: {query.smiles}")
|
||||||
|
print(
|
||||||
|
f"Scanned {result.stats.scanned:,} rows: {result.stats.valid:,} valid, "
|
||||||
|
f"{result.stats.invalid:,} invalid SMILES."
|
||||||
|
)
|
||||||
|
if result.stats.stopped_early:
|
||||||
|
print("Stopped early because of --max-rows; results cover only that subset.")
|
||||||
|
print(f"Saved {len(result.matches)} matches to {args.output}")
|
||||||
|
if image_paths:
|
||||||
|
print(f"Saved query image to {image_paths[0]}")
|
||||||
|
print(f"Saved candidate image to {image_paths[1]}")
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def small_dataset(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "molecules.tsv"
|
||||||
|
path.write_text(
|
||||||
|
"chembl_id\tcanonical_smiles\n"
|
||||||
|
"QUERY\tCCO\n"
|
||||||
|
"ALCOHOL\tCCCO\n"
|
||||||
|
"AMINE\tCCN\n"
|
||||||
|
"BENZENE\tc1ccccc1\n"
|
||||||
|
"BROKEN\tnot-a-smiles\n"
|
||||||
|
"DUPLICATE\tCCO\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return path
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from rdkit import DataStructs
|
||||||
|
|
||||||
|
from scimesh.chemistry.dataset import DatasetStats, iter_valid_molecules
|
||||||
|
from scimesh.chemistry.fingerprints import fingerprint
|
||||||
|
from scimesh.workloads.similarity_graph import (
|
||||||
|
SimilarityEdge,
|
||||||
|
build_similarity_graph,
|
||||||
|
write_graph_edges,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _brute_force_edges(dataset: Path, threshold: float) -> list[SimilarityEdge]:
|
||||||
|
records = list(iter_valid_molecules(dataset, DatasetStats()))
|
||||||
|
edges = []
|
||||||
|
for left_index, left in enumerate(records):
|
||||||
|
for right in records[left_index + 1 :]:
|
||||||
|
similarity = DataStructs.TanimotoSimilarity(
|
||||||
|
fingerprint(left.molecule), fingerprint(right.molecule)
|
||||||
|
)
|
||||||
|
if similarity >= threshold:
|
||||||
|
edges.append(SimilarityEdge(left.molecule_id, right.molecule_id, similarity))
|
||||||
|
return sorted(edges, key=lambda edge: (edge.source_id, edge.target_id, -edge.similarity))
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_matches_brute_force_and_has_unique_non_self_edges(
|
||||||
|
small_dataset: Path,
|
||||||
|
) -> None:
|
||||||
|
threshold = 0.15
|
||||||
|
result = build_similarity_graph(small_dataset, threshold, block_size=2)
|
||||||
|
|
||||||
|
assert result.edges == _brute_force_edges(small_dataset, threshold)
|
||||||
|
assert result.checked_pairs == 10
|
||||||
|
edge_pairs = [(edge.source_id, edge.target_id) for edge in result.edges]
|
||||||
|
assert all(source != target for source, target in edge_pairs)
|
||||||
|
assert len(edge_pairs) == len(set(edge_pairs))
|
||||||
|
assert result.stats.invalid == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_is_block_size_independent_and_deterministic(
|
||||||
|
small_dataset: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
first = build_similarity_graph(small_dataset, threshold=0.15, block_size=1)
|
||||||
|
second = build_similarity_graph(small_dataset, threshold=0.15, block_size=3)
|
||||||
|
repeated = build_similarity_graph(small_dataset, threshold=0.15, block_size=3)
|
||||||
|
|
||||||
|
assert first.edges == second.edges == repeated.edges
|
||||||
|
first_path = tmp_path / "first.csv"
|
||||||
|
second_path = tmp_path / "second.csv"
|
||||||
|
write_graph_edges(first_path, first.edges)
|
||||||
|
write_graph_edges(second_path, repeated.edges)
|
||||||
|
assert first_path.read_bytes() == second_path.read_bytes()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from rdkit import Chem, DataStructs
|
||||||
|
|
||||||
|
from scimesh.chemistry.dataset import DatasetStats, find_molecule_by_id, iter_valid_molecules
|
||||||
|
from scimesh.chemistry.fingerprints import fingerprint
|
||||||
|
from scimesh.workloads.similarity_search import SimilarityMatch, search_similar
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_matches_full_sorting_and_skips_query_and_invalid(
|
||||||
|
small_dataset: Path,
|
||||||
|
) -> None:
|
||||||
|
query = find_molecule_by_id(small_dataset, "QUERY")
|
||||||
|
result = search_similar(small_dataset, query, top_k=2)
|
||||||
|
|
||||||
|
query_smiles = Chem.MolToSmiles(query.molecule, canonical=True)
|
||||||
|
expected = []
|
||||||
|
for record in iter_valid_molecules(small_dataset, DatasetStats()):
|
||||||
|
if record.molecule_id == query.molecule_id:
|
||||||
|
continue
|
||||||
|
if Chem.MolToSmiles(record.molecule, canonical=True) == query_smiles:
|
||||||
|
continue
|
||||||
|
expected.append(
|
||||||
|
SimilarityMatch(
|
||||||
|
DataStructs.TanimotoSimilarity(
|
||||||
|
fingerprint(query.molecule), fingerprint(record.molecule)
|
||||||
|
),
|
||||||
|
record.molecule_id,
|
||||||
|
record.smiles,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.matches == sorted(expected, key=SimilarityMatch.sort_key)[:2]
|
||||||
|
assert "QUERY" not in {match.molecule_id for match in result.matches}
|
||||||
|
assert "DUPLICATE" not in {match.molecule_id for match in result.matches}
|
||||||
|
assert result.stats.invalid == 1
|
||||||
|
assert result.stats.valid == 5
|
||||||
Reference in New Issue
Block a user