77
88import numpy as np
99from pgvector .sqlalchemy import Vector
10- from sqlalchemy import bindparam , text
10+ from sqlalchemy import Float , bindparam , text
1111from sqlalchemy .dialects .postgresql import BIT
1212
1313from 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