Skip to content

Commit 04cfe3e

Browse files
committed
UPD: retrofingerprint generation
1 parent 5ba7d9c commit 04cfe3e

4 files changed

Lines changed: 171 additions & 58 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@
7171
bionexus dump-db --out dumps/bionexus_$(date +%Y%m%d).dump
7272
```
7373

74+
or create a dump directly from Docker:
75+
76+
```bash
77+
docker exec -t bionexus-db-1 pg_dump -U bionexus -d bionexus -Fc > /path/on/host/bionexus.dump
78+
```
79+
7480
Adminer: http://localhost:8080 (server: db, user: bionexus, db: bionexus)
7581

7682
## Database schema & migrations

src/bionexus/cli.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import logging
88
import os
99
import subprocess
10+
import shutil
1011
import sys
1112
from pathlib import Path
1213

@@ -281,6 +282,16 @@ def cmd_dump_db(args: argparse.Namespace) -> None:
281282
282283
:param args: command-line arguments
283284
"""
285+
if shutil.which("pg_dump") is None:
286+
console.print("[red]Error:[/] 'pg_dump' is not installed or not found in PATH.\n")
287+
console.print(
288+
"To install it in your conda environment, run:\n"
289+
" [yellow]conda install -c conda-forge postgresql[/]\n\n"
290+
"Or system-wide:\n"
291+
" [yellow]sudo apt-get install postgresql-client[/] (Ubuntu/Debian)\n"
292+
" [yellow]brew install libpq && brew link --force libpq[/] (macOS)\n"
293+
)
294+
sys.exit(1)
284295
os.makedirs(os.path.dirname(args.out), exist_ok=True)
285296
url = os.getenv("BIONEXUS_DB_URL")
286297
if not url:
@@ -408,31 +419,34 @@ def cmd_search_retro_gbk(args: argparse.Namespace) -> None:
408419
"""
409420
from bionexus.db.search import retro_search_gbk
410421

422+
metric = getattr(args, "metric", "cosine")
423+
411424
rows = retro_search_gbk(
412425
path=args.path,
413426
top_k=args.top_k,
414427
readout_toplevel=args.readout_toplevel,
415428
readout_sublevel=args.readout_sublevel,
416429
counted=getattr(args, "counted", False),
417430
cache_dir=getattr(args, "cache_dir", None),
431+
metric=metric,
418432
)
419433
df = pd.DataFrame(rows)
420434

421435
if not args.out:
422436
# console.print(df[["source", "name", "jacc"]])
423437
table = Table(show_header=True, header_style="bold magenta")
424438
table.add_column("record", style="dim", width=30)
425-
table.add_column("compound_id", style="dim", width=6)
439+
table.add_column("fp_id", style="dim", width=6)
426440
table.add_column("source", style="dim", width=10)
427441
table.add_column("name", style="white", width=30)
428-
table.add_column("cosine", justify="right")
442+
table.add_column(metric, justify="right")
429443
for _, row in df.iterrows():
430444
table.add_row(
431445
f"{row['record']}",
432446
f"{row['id']}",
433447
str(row["source"]),
434448
f"[cyan]{row['name']}",
435-
f"{row['cosine']:.3f}",
449+
f"{row['score']:.3f}",
436450
)
437451
console.print(table)
438452
else:
@@ -564,6 +578,8 @@ def build_parser() -> argparse.ArgumentParser:
564578
p_search_r_gbk.add_argument("--out", default=None, help="Optional output file (TSV/CSV)")
565579
p_search_r_gbk.add_argument("--counted", action="store_true", help="Use counted fingerprint for search")
566580
p_search_r_gbk.add_argument("--cache-dir", default=None, help="Cache/work dir for RetroMol")
581+
p_search_r_gbk.add_argument("--metric", choices=["cosine", "tanimoto"], default="cosine",
582+
help="Similarity metric to use (default: cosine)")
567583
p_search_r_gbk.set_defaults(func=cmd_search_retro_gbk)
568584

569585
return p

src/bionexus/db/search.py

Lines changed: 109 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import numpy as np
99
from pgvector.sqlalchemy import Vector
10-
from sqlalchemy import bindparam, text
10+
from sqlalchemy import Float, bindparam, text
1111
from sqlalchemy.dialects.postgresql import BIT
1212

1313
from bionexus.db.engine import SessionLocal
@@ -153,6 +153,8 @@ def retro_search_compound(
153153
except ImportError:
154154
logger.error("RetroMol library is not installed. Please install it to use retro_search.")
155155
return []
156+
157+
raise RuntimeError("Please update generator setup to match recent changes in retromol ETL code; centralize generator setup?")
156158

157159
# limit top-k to 500 for performance reasons
158160
if top_k > 500:
@@ -241,6 +243,7 @@ def retro_search_gbk(
241243
counted: bool = False,
242244
cache_dir: Path | str | None = None,
243245
kmer_sizes: list[int] | None = None,
246+
metric: Literal["cosine", "tanimoto"] = "tanimoto",
244247
) -> list[dict]:
245248
"""
246249
Perform a RetroMol fingerprint similarity search on compounds in a GenBank file.
@@ -252,6 +255,7 @@ def retro_search_gbk(
252255
:param counted: whether to use counted vector similarity or binary vector similarity
253256
:param cache_dir: optional path to a cache directory for intermediate files
254257
:param kmer_sizes: optional list of k-mer sizes to use for fingerprint generation
258+
:param metric: similarity metric to use ('cosine' or 'tanimoto')
255259
:return: a list of dictionaries containing compound information and similarity scores
256260
"""
257261
try:
@@ -263,7 +267,8 @@ def retro_search_gbk(
263267
FingerprintGenerator,
264268
NameSimilarityConfig,
265269
get_kmers,
266-
polyketide_family_of
270+
polyketide_family_of,
271+
# polyketide_ancestors_of
267272
)
268273
from retromol.rules import get_path_default_matching_rules
269274
except ImportError as e:
@@ -281,8 +286,13 @@ def retro_search_gbk(
281286
if kmer_sizes is None:
282287
kmer_sizes = [1, 2, 3]
283288

289+
# TODO: actually retrieve correction version of matching rules; only compare to fingerprints parsed with the same rules version
290+
284291
# Setup generator
285-
collapse_by_name = ["glycosylation", "methylation"]
292+
collapse_by_name = [
293+
"glycosylation", "methylation",
294+
*[f"{x}{i}" for x in "ABCDEF" for i in range(1,16)]
295+
]
286296
cfg = NameSimilarityConfig(family_of=polyketide_family_of, symmetric=True, family_repeat_scale=1)
287297
generator = FingerprintGenerator(
288298
matching_rules_yaml=get_path_default_matching_rules(),
@@ -291,37 +301,87 @@ def retro_search_gbk(
291301
)
292302
logger.info(f"Initialized RetroMol FingerprintGenerator: {generator}")
293303

294-
vec_col = "fp_retro_b512_vec_counted" if counted else "fp_retro_b512_vec_binary"
304+
if metric == "tanimoto":
305+
bit_col = "fp_retro_b512_bit"
306+
else:
307+
vec_col = "fp_retro_b512_vec_counted" if counted else "fp_retro_b512_vec_binary"
308+
309+
# Build SQL query
310+
if metric == "cosine":
311+
sql = text(f"""
312+
SELECT
313+
rf.id AS id,
314+
cr.source AS source,
315+
cr.name AS name,
316+
(1.0 - (rf.{vec_col} <=> :qv)) AS score,
317+
(1.0 - (rf.{vec_col} <=> :qv)) AS cosine,
318+
c.smiles AS smiles
319+
FROM retrofingerprint rf
320+
JOIN retromol_compound rmc
321+
ON rmc.id = rf.retromol_compound_id
322+
JOIN compound c
323+
ON c.id = rmc.compound_id
324+
LEFT JOIN compound_record cr
325+
ON cr.compound_id = c.id
326+
ORDER BY rf.{vec_col} <=> :qv, rf.id
327+
LIMIT :k
328+
""").bindparams(
329+
bindparam("qv", type_=Vector(512)),
330+
bindparam("k")
331+
)
295332

296-
# Cosine distance operator in pgvector is '<=>'; cosine similarity = 1 - distance
297-
sql = text(f"""
298-
SELECT
299-
rf.id AS id,
300-
cr.source AS source,
301-
cr.name AS name,
302-
(1.0 - (rf.{vec_col} <=> :qv)) AS cosine,
303-
c.smiles AS smiles
304-
FROM retrofingerprint rf
305-
JOIN retromol_compound rmc
306-
ON rmc.id = rf.retromol_compound_id
307-
JOIN compound c
308-
ON c.id = rmc.compound_id
309-
LEFT JOIN compound_record cr
310-
ON cr.compound_id = c.id
311-
ORDER BY rf.{vec_col} <=> :qv, rf.id
312-
LIMIT :k
313-
""").bindparams(
314-
bindparam("qv", type_=Vector(512)),
315-
bindparam("k")
316-
)
333+
elif metric == "tanimoto":
334+
# Exact Jaccard over BIT(512), same form as your jaccard_search_exact
335+
sql = text(f"""
336+
WITH top_hits AS (
337+
SELECT
338+
rf.id,
339+
cr.source,
340+
cr.name,
341+
c.smiles,
342+
CASE
343+
WHEN length(replace((rf.{bit_col} | :qb)::text, '0','')) = 0
344+
THEN 1.0
345+
ELSE length(replace((rf.{bit_col} & :qb)::text, '0',''))::float
346+
/ NULLIF(length(replace((rf.{bit_col} | :qb)::text, '0','')), 0)
347+
END AS score
348+
FROM retrofingerprint rf
349+
JOIN retromol_compound rmc ON rmc.id = rf.retromol_compound_id
350+
JOIN compound c ON c.id = rmc.compound_id
351+
LEFT JOIN compound_record cr ON cr.compound_id = c.id
352+
WHERE rf.{bit_col} IS NOT NULL
353+
ORDER BY score DESC
354+
LIMIT :k
355+
)
356+
SELECT id, source, name, score, smiles
357+
FROM top_hits
358+
WHERE name IS NOT NULL
359+
ORDER BY score DESC, source, name, id
360+
""").bindparams(
361+
bindparam("qb", type_=BIT(512)),
362+
bindparam("k"),
363+
)
364+
else:
365+
raise ValueError(f"Unknown metric: {metric}")
317366

318367
targets = parse_region_gbk_file(path, top_level=readout_toplevel)
319368
fp_labels = []
320369
fps = []
321370
for target in targets:
371+
kmers = []
372+
373+
# Get tokenspects, currently only using it to mine for glycosylation
374+
tokenspecs = get_default_tokenspecs()
375+
mined_tokenspecs = mine_virtual_tokens(target, tokenspecs)
376+
found_glycosylation = any(ts["token"] == "glycosyltransferase" for ts in mined_tokenspecs)
377+
found_methylation = any(ts["token"] == "methyltransferase" for ts in mined_tokenspecs)
378+
if found_glycosylation:
379+
kmers.append((("glycosylation", None),))
380+
if found_methylation:
381+
kmers.append((("methylation", None),))
382+
322383
# NOTE: readout for polyketides doesn't include structures
323384
fp_labels.append(f"{target.record_id}_{readout_toplevel}_{target.accession}")
324-
kmers = []
325385
for readout in linear_readouts(
326386
target,
327387
cache_dir_override=cache_dir_override,
@@ -331,16 +391,16 @@ def retro_search_gbk(
331391
seq = []
332392
for module in readout["readout"]:
333393
if isinstance(module, PKSModuleReadout):
334-
seq.append((module.module_type.split("_")[1] + "1", None))
394+
seq.append((module.module_type.split("_")[1] + "0", None))
335395
elif isinstance(module, NRPSModuleReadout):
336396
seq.append((module.substrate_name, module.substrate_smiles))
337397
else:
338398
raise ValueError(f"Unsupported module type: {type(module)}")
339-
399+
340400
for k in kmer_sizes:
341401
kmers.extend(get_kmers(seq, k=k))
342402

343-
fp = generator.fingerprint_from_kmers(kmers, num_bits=512, counted=counted)
403+
fp = generator.fingerprint_from_kmers(kmers, num_bits=512, counted=counted, kmer_weights={1: 2, 2: 4, 3: 8})
344404
if fp is not None:
345405
fps.append(fp)
346406

@@ -351,30 +411,34 @@ def retro_search_gbk(
351411
else:
352412
fps = np.vstack(fps)
353413

354-
# Collect top_k result for every individiual fingerprint, then merge and sort and return top_k overall
355-
best_by_id: dict[int, dict] = {}
414+
# return results
415+
best_by_id: dict[tuple[int, str | None], dict] = {}
356416
with SessionLocal() as s:
357417
for fp_idx, fp in enumerate(fps):
358-
qv = [float(x) for x in fp]
359-
rows = s.execute(sql, {"qv": qv, "k": top_k}).mappings().all()
418+
if metric == "cosine":
419+
qv = [float(x) for x in fp]
420+
rows = s.execute(sql, {"qv": qv, "k": top_k}).mappings().all()
421+
else: # tanimoto: build 512-bit string (same idea as your exact Jaccard)
422+
qb = "".join("1" if float(x) > 0 else "0" for x in fp) # length 512
423+
rows = s.execute(sql, {"qb": qb, "k": top_k}).mappings().all()
424+
360425
for r in rows:
361-
rid = r["id"]
362-
source = r["source"]
363-
cos = float(r["cosine"]) if r["cosine"] is not None else None
364-
if rid not in best_by_id or (cos is not None and cos > best_by_id[rid]["cosine"]):
365-
best_by_id[(rid, source)] = {
426+
key = (r["id"], r["source"])
427+
score = float(r["score"]) if r["score"] is not None else None
428+
prev = best_by_id.get(key)
429+
if prev is None or (score is not None and score > (prev["score"] or float("-inf"))):
430+
best_by_id[key] = {
366431
"record": fp_labels[fp_idx],
367432
"id": r["id"],
368433
"source": r["source"],
369434
"name": r["name"],
370-
"cosine": cos,
435+
"score": score,
436+
"metric": metric,
437+
"cosine": float(r["cosine"]) if metric == "cosine" and r.get("cosine") is not None else None,
371438
"smiles": r["smiles"],
372439
}
373440

374-
results = sorted(
375-
best_by_id.values(),
376-
key=lambda d: (d["cosine"] is not None, d["cosine"]),
377-
reverse=True
378-
)[:top_k]
379-
441+
results = sorted(best_by_id.values(),
442+
key=lambda d: (d["score"] is not None, d["score"]),
443+
reverse=True)[:top_k]
380444
return results

0 commit comments

Comments
 (0)