Files
SciMesh/scimesh/workloads/similarity_search.py
T

270 lines
9.8 KiB
Python

"""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, threshold_direction: str = "greater") -> tuple[float, str, str]:
"""Return a stable ranking key for similar or dissimilar searches."""
if threshold_direction == "less":
return (self.similarity, self.molecule_id, self.smiles)
return (-self.similarity, self.molecule_id, self.smiles)
@dataclass
class _HeapEntry:
"""Heap item whose minimum is the worst retained match."""
match: SimilarityMatch
rank_key: tuple[float, str, str]
def __lt__(self, other: object) -> bool:
if not isinstance(other, _HeapEntry):
return NotImplemented
return self.rank_key > other.rank_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,
threshold: float | None = None,
threshold_direction: str = "greater",
) -> 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")
if threshold is not None and not 0.0 <= threshold <= 1.0:
raise ValueError("--threshold must be between 0 and 1")
if threshold_direction not in {"greater", "less"}:
raise ValueError("--threshold-direction must be 'greater' or 'less'")
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
similarity = DataStructs.TanimotoSimilarity(
query_fingerprint, fingerprint(record.molecule)
)
if threshold is not None and (
similarity < threshold if threshold_direction == "greater" else similarity > threshold
):
continue
match = SimilarityMatch(
similarity,
record.molecule_id,
record.smiles,
)
rank_key = match.sort_key(threshold_direction)
entry = _HeapEntry(match, rank_key)
if len(heap) < top_k:
heapq.heappush(heap, entry)
elif rank_key < heap[0].rank_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=lambda match: match.sort_key(threshold_direction),
),
stats,
)
def write_search_results(output_path: Path, matches: list[SimilarityMatch]) -> None:
"""Write ranked matches to a deterministic CSV file."""
output_path.parent.mkdir(parents=True, exist_ok=True)
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(
"--threshold", type=float,
help="Keep similarities at or beyond this value",
)
parser.add_argument(
"--threshold-direction", choices=("greater", "less"), default="greater",
help="Use >= for similar molecules or <= for dissimilar molecules",
)
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,
args.threshold,
args.threshold_direction,
)
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}")
if args.threshold is not None:
operator = ">=" if args.threshold_direction == "greater" else "<="
print(f"Similarity filter: {operator} {args.threshold:.6f}")
elif args.threshold_direction == "less":
print("Ranking least similar molecules first.")
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