corems.molecular_networking
corems.molecular_networking
Molecular networking for comparing experimental and library MS2 spectra
using FlashEntropy similarity (and optional cosine) in open,
identity, or neutral_loss modes.
Quick start
from corems.molecular_networking import MolecularNetwork
# Optional: build FE library via MSPInterface / other CoreMS DB helpers
mn = MolecularNetwork(
fe_lib=fe_lib, # or None for query-only
search_type="open", # default; try "identity" / "neutral_loss"
similarity_thresholds={"entropy_similarity": 0.3, "cosine": 0.3},
)
# From an LCMSBase object (mass features with best_ms2):
spectra, ids, pmzs = MolecularNetwork.prepare_query_spectra_from_lcms_object(lcms)
# All-in-one (stages 1–2; optional stage 3 with hydrate_library_similarities=True)
mn.query_vs_library(spectra, ids, query_precursor_mzs=pmzs)
edges = mn.get_network_edges(metric="entropy_similarity")
mn.save_edge_list("edges.csv")
# Plotting/clustering require: pip install "corems[networking]" (networkx + ipysigma)
mn.plot_network(path="network.png", return_fig=True) # static
# mn.plot_interactive_network(out_path="network.html") # interactive HTML
Stages
- Query–query — temporary FE index over experimental spectra.
- Query–library — search against a reference FlashEntropy library.
- Library–library (optional) — all-vs-all among library hits above a threshold.
Internal library node IDs use the lib:<index> form so they never collide with
numeric mass-feature IDs. CSV export maps those nodes to FlashEntropy
spectra_id (or id) when available.
Spectrum protocol
Query spectra are duck-typed: objects must expose .mz_exp and .abundance
array-like attributes (e.g. CoreMS MS2 spectra).
Classes
MolecularNetwork Main interface for building and querying molecular networks. SimilarityMatrix Sparse symmetric matrix for one similarity metric. SimilarityEngine Computes pairwise similarities (FlashEntropy + optional cosine). NetworkVisualizeMixin Static and interactive plotting (mixed into MolecularNetwork).
See also
examples/notebooks/LCMS_Tutorial.ipynb End-to-end single-sample networking section. examples/notebooks/LCMS_Collection_Tutorial.ipynb Multi-sample / consensus representatives. tests/test_molecular_networking.py Unit tests and usage fixtures.
1""" 2corems.molecular_networking 3=========================== 4 5Molecular networking for comparing experimental and library MS2 spectra 6using FlashEntropy similarity (and optional cosine) in ``open``, 7``identity``, or ``neutral_loss`` modes. 8 9Quick start 10----------- 11 12.. code-block:: python 13 14 from corems.molecular_networking import MolecularNetwork 15 16 # Optional: build FE library via MSPInterface / other CoreMS DB helpers 17 mn = MolecularNetwork( 18 fe_lib=fe_lib, # or None for query-only 19 search_type="open", # default; try "identity" / "neutral_loss" 20 similarity_thresholds={"entropy_similarity": 0.3, "cosine": 0.3}, 21 ) 22 23 # From an LCMSBase object (mass features with best_ms2): 24 spectra, ids, pmzs = MolecularNetwork.prepare_query_spectra_from_lcms_object(lcms) 25 26 # All-in-one (stages 1–2; optional stage 3 with hydrate_library_similarities=True) 27 mn.query_vs_library(spectra, ids, query_precursor_mzs=pmzs) 28 29 edges = mn.get_network_edges(metric="entropy_similarity") 30 mn.save_edge_list("edges.csv") 31 # Plotting/clustering require: pip install "corems[networking]" (networkx + ipysigma) 32 mn.plot_network(path="network.png", return_fig=True) # static 33 # mn.plot_interactive_network(out_path="network.html") # interactive HTML 34 35Stages 36------ 371. **Query–query** — temporary FE index over experimental spectra. 382. **Query–library** — search against a reference FlashEntropy library. 393. **Library–library** (optional) — all-vs-all among library hits above a threshold. 40 41Internal library node IDs use the ``lib:<index>`` form so they never collide with 42numeric mass-feature IDs. CSV export maps those nodes to FlashEntropy 43``spectra_id`` (or ``id``) when available. 44 45Spectrum protocol 46----------------- 47Query spectra are duck-typed: objects must expose ``.mz_exp`` and ``.abundance`` 48array-like attributes (e.g. CoreMS MS2 spectra). 49 50Classes 51------- 52MolecularNetwork 53 Main interface for building and querying molecular networks. 54SimilarityMatrix 55 Sparse symmetric matrix for one similarity metric. 56SimilarityEngine 57 Computes pairwise similarities (FlashEntropy + optional cosine). 58NetworkVisualizeMixin 59 Static and interactive plotting (mixed into MolecularNetwork). 60 61See also 62-------- 63examples/notebooks/LCMS_Tutorial.ipynb 64 End-to-end single-sample networking section. 65examples/notebooks/LCMS_Collection_Tutorial.ipynb 66 Multi-sample / consensus representatives. 67tests/test_molecular_networking.py 68 Unit tests and usage fixtures. 69""" 70 71from corems.molecular_networking.similarity_matrix import SimilarityMatrix 72from corems.molecular_networking.similarity_engine import SimilarityEngine 73from corems.molecular_networking.network_builder import MolecularNetwork 74from corems.molecular_networking.network_visualize import NetworkVisualizeMixin 75 76__all__ = [ 77 "MolecularNetwork", 78 "SimilarityMatrix", 79 "SimilarityEngine", 80 "NetworkVisualizeMixin", 81]
51class MolecularNetwork(NetworkVisualizeMixin): 52 """Build a molecular network around a reference library. 53 54 Initialization is **lazy** — no similarity computation happens until you 55 call one of the computation methods. 56 57 Parameters 58 ---------- 59 fe_lib : ms_entropy.FlashEntropySearch or None, optional 60 Pre-built FlashEntropy search instance (built from the reference 61 library). Required for query-vs-library and library-vs-library 62 stages. Pass ``None`` (default) to build a query-only network 63 (stage 1 only). When provided, fragment-tolerance parameters are 64 automatically extracted from this library unless overridden by 65 *ms2_tolerance_da*. 66 search_type : str 67 FlashEntropy search mode: ``"identity"``, ``"open"``, or 68 ``"neutral_loss"``. Default ``"open"`` (typical DDA networking; 69 use ``"identity"`` when precursor filtering is required). 70 additional_similarities : list of str, optional 71 Extra similarity metrics to compute alongside entropy similarity. 72 Currently supported: ``["cosine"]``. Default ``["cosine"]``. 73 Cosine is gated by *entropy_threshold_low* (best-effort secondary score). 74 similarity_thresholds : dict, optional 75 Mapping of metric name → edge threshold. Edges are created in each 76 metric's network when score >= threshold. Metrics not listed use 77 a default of 0.5. 78 Example: ``{"entropy_similarity": 0.5, "cosine": 0.6}`` 79 ms1_tolerance_da : float, optional 80 Precursor m/z tolerance (Da) used for ``"identity"`` search to filter 81 library candidates by precursor m/z. Ignored for ``"open"`` and 82 ``"neutral_loss"`` (precursor filter is disabled for those modes). 83 If None (default), uses 0.01 Da. 84 ms2_tolerance_da : float, optional 85 Fragment m/z tolerance (Da) used for spectrum cleaning and similarity 86 scoring. Priority: explicit kwarg > extracted from *fe_lib* > 87 0.01 Da fallback. Override this when *fe_lib* is ``None`` or when 88 you need a tolerance different from the library's built-in value. 89 entropy_threshold_low : float or None, optional 90 Minimum entropy similarity score required to trigger additional metric 91 computation (e.g. cosine). If None (default), set to half the lowest 92 non-entropy similarity threshold, or 0.25 when no thresholds are given. 93 settings : SpectralSimilaritySearchSettings, optional 94 Encapsulated spectral-similarity / networking defaults (for example 95 ``lcms.parameters.mass_spectrum["ms2"].spectral_similarity_search`` or 96 :func:`~corems.encapsulation.factory.parameters.settings_from_lcms`). 97 When provided, fills any constructor fields left as ``None`` from this 98 object. Explicit keyword arguments always override *settings*. Stage-1 99 temporary FE index build uses ``settings.as_fe_kwargs()`` when 100 *fe_kwargs* is not passed to :meth:`query_vs_library` / 101 :meth:`run_query_vs_query_only`. Stage-3 defaults 102 (``hydrate_library_similarities``, ``library_similarity_threshold``) 103 are also taken from *settings* when those method arguments are omitted. 104 105 Attributes 106 ---------- 107 similarity_matrices : dict of str → SimilarityMatrix 108 One :class:`~corems.molecular_networking.similarity_matrix.SimilarityMatrix` 109 per metric (``"entropy_similarity"``, ``"cosine"``, …). 110 stage_query_query_done : bool 111 True after query-vs-query similarities have been computed (stage 1). 112 stage_query_library_done : bool 113 True after query-vs-library similarities have been computed (stage 2). 114 stage_library_library_done : bool 115 True after matched library-vs-library similarities have been computed 116 (stage 3). 117 118 Notes 119 ----- 120 **Tolerance resolution order** (highest priority first): 121 122 1. Explicit *ms2_tolerance_da* kwarg passed to this constructor. 123 2. *settings.ms2_tolerance_da* when *settings* is provided and the kwarg is 124 omitted. 125 3. Value extracted from ``fe_lib.entropy_search.max_ms2_tolerance_in_da``. 126 4. Hard-coded fallback of 0.01 Da (used when *fe_lib* is ``None`` and no 127 explicit value is given). 128 129 Prefer building *fe_lib* with the same 130 :class:`~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings` 131 instance used here (via library ``settings=`` / ``settings_from_lcms``) so 132 FlashEntropy index tolerances match networking. 133 134 **Precursor filter behaviour by search type:** 135 136 - ``"identity"`` : library candidates are filtered by precursor m/z within 137 *ms1_tolerance_da* before scoring. 138 - ``"open"`` : precursor filter is disabled; all library entries are scored. 139 - ``"neutral_loss"`` : precursor filter is disabled; matching occurs in 140 neutral-loss mass space (``precursor_mz − fragment_mz``), not on the 141 precursor itself. 142 143 Cosine scoring (when requested) is single-threaded: unique spectra are 144 pre-sorted once, then pairs are scored sequentially. 145 """ 146 147 def __init__( 148 self, 149 fe_lib=None, 150 search_type: str | None = None, 151 additional_similarities: list[str] | None = None, 152 similarity_thresholds: dict[str, float] | None = None, 153 ms1_tolerance_da: float | None = None, 154 ms2_tolerance_da: float | None = None, 155 entropy_threshold_low: float | None = None, 156 settings=None, 157 ): 158 if settings is not None: 159 if search_type is None: 160 search_type = settings.search_type 161 if additional_similarities is None: 162 additional_similarities = list(settings.additional_similarities) 163 if similarity_thresholds is None: 164 similarity_thresholds = dict(settings.similarity_thresholds) 165 if ms1_tolerance_da is None: 166 ms1_tolerance_da = settings.ms1_tolerance_da 167 if ms2_tolerance_da is None: 168 ms2_tolerance_da = settings.ms2_tolerance_da 169 if entropy_threshold_low is None: 170 entropy_threshold_low = settings.entropy_threshold_low 171 self._settings = settings 172 else: 173 self._settings = None 174 175 if search_type is None: 176 search_type = "open" 177 if additional_similarities is None: 178 additional_similarities = ["cosine"] 179 if similarity_thresholds is None: 180 similarity_thresholds = {} 181 182 if entropy_threshold_low is None: 183 non_entropy_thresholds = [ 184 v for k, v in similarity_thresholds.items() if k != "entropy_similarity" 185 ] 186 if non_entropy_thresholds: 187 entropy_threshold_low = min(non_entropy_thresholds) / 2 188 else: 189 entropy_threshold_low = 0.25 # Placeholder that won't be used if no additional similarities are computed 190 191 self.fe_lib = fe_lib 192 self.search_type = search_type 193 self.additional_similarities = list(additional_similarities) 194 self.similarity_thresholds = similarity_thresholds 195 196 # Build the engine (wraps fe_lib + search parameters) 197 # When fe_lib is provided, tolerance parameters are extracted from it. 198 # When fe_lib is None (queries-only use), SimilarityEngine will fall back 199 # to its internal defaults and only query-vs-query stage is allowed. 200 self._engine = SimilarityEngine( 201 fe_lib=fe_lib, 202 search_type=search_type, 203 additional_similarities=additional_similarities, 204 ms1_tolerance_da=ms1_tolerance_da, 205 ms2_tolerance_da=ms2_tolerance_da, 206 entropy_threshold_low=entropy_threshold_low, 207 settings=settings, 208 ) 209 210 # One SimilarityMatrix per metric – empty until query_vs_library() is called 211 all_metrics = ["entropy_similarity"] + list(additional_similarities) 212 self.similarity_matrices: dict[str, SimilarityMatrix] = { 213 m: SimilarityMatrix(metric_name=m) for m in all_metrics 214 } 215 216 # Accumulate query spectra (kept separate from the library FE index) 217 self._all_query_spectra: list = [] 218 self._all_query_ids: list[str] = [] 219 self._all_query_precursor_mzs: list[float | None] = [] 220 221 # Stage flags – enforce order: 222 # 1) query-vs-query 223 # 2) query-vs-library 224 # 3) matched library-vs-library 225 self.stage_query_query_done: bool = False 226 self.stage_query_library_done: bool = False 227 self.stage_library_library_done: bool = False 228 229 # Whether query_vs_library has been run (disabled repeated runs until dropped) 230 self._has_queries_run: bool = False 231 232 # Cache for stage 2 entropy pairs (used by stage 3) 233 self._stage2_entropy_pairs: dict[tuple[str, str], float] | None = None 234 235 # Cache of computed network clustering artifacts keyed by metric. 236 self._network_clusters: dict[str, dict] = {} 237 238 @staticmethod 239 def prepare_query_spectra_from_lcms_object( 240 lcms_obj, 241 mf_ids: set | None = None, 242 ) -> tuple[list, list[str], list[float]]: 243 """Build query lists from an ``LCMSBase`` object's mass features. 244 245 Iterates ``lcms_obj.mass_features`` and collects each feature's 246 ``best_ms2`` spectrum, skipping features with no MS2 or an empty 247 ``mz_exp``. If *mf_ids* is provided, only those mass features are 248 included. 249 250 Parameters 251 ---------- 252 lcms_obj : LCMSBase 253 LCMS object whose mass features have been populated with MS2 254 spectra (e.g. via :meth:`add_associated_ms2_dda`). 255 mf_ids : set, optional 256 If provided, only mass features whose id is in this set are 257 included. 258 259 Returns 260 ------- 261 (query_spectra, query_ids, query_precursor_mzs) 262 Three parallel lists ready to pass to :meth:`query_vs_library` 263 or :meth:`run_query_vs_query_only`. 264 """ 265 query_spectra: list = [] 266 query_ids: list[str] = [] 267 query_precursor_mzs: list[float] = [] 268 for mf_id, mf in lcms_obj.mass_features.items(): 269 if mf_ids is not None and mf_id not in mf_ids: 270 continue 271 ms2 = mf.best_ms2 272 if ms2 is None: 273 continue 274 if not hasattr(ms2, "mz_exp") or len(ms2.mz_exp) == 0: 275 continue 276 query_spectra.append(ms2) 277 query_ids.append(str(mf_id)) 278 query_precursor_mzs.append(float(mf.mz)) 279 return query_spectra, query_ids, query_precursor_mzs 280 281 @staticmethod 282 def prepare_query_spectra_from_lcms_collection( 283 lcms_collection, 284 cluster_ids: set | None = None, 285 representative_metric: str | None = None, 286 ) -> tuple[list, list[str], list[float]]: 287 """Build query lists from representative mass features in an ``LCMSCollection``. 288 289 Uses :meth:`~corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations.get_representative_mass_features_for_all_clusters` 290 to select one mass feature per consensus cluster, then collects each 291 feature's ``best_ms2`` from the corresponding sample object. 292 293 Representative mass features must already be loaded with MS2 spectra 294 (e.g. via ``process_consensus_features(load_representatives=True, 295 add_ms2=True, ...)`` or ``reload_representative_mass_features(add_ms2=True)``). 296 Clusters whose representative has no usable MS2 are skipped. 297 298 Parameters 299 ---------- 300 lcms_collection : LCMSCollection 301 Collection with consensus clusters and loaded representative 302 mass features (with MS2 where available). 303 cluster_ids : set, optional 304 If provided, only representatives for these cluster IDs are 305 included. 306 representative_metric : str, optional 307 Metric passed to 308 :meth:`~corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations.get_representative_mass_features_for_all_clusters`. 309 If ``None``, uses the collection parameter 310 ``consensus_representative_metric``. 311 312 Returns 313 ------- 314 (query_spectra, query_ids, query_precursor_mzs) 315 Three parallel lists ready to pass to :meth:`query_vs_library` 316 or :meth:`run_query_vs_query_only`. Query IDs are collection-level 317 ``coll_mf_id`` strings (``"{sample_id}_{mf_id}"``). 318 319 Raises 320 ------ 321 AttributeError 322 If *lcms_collection* does not expose representative lookup or 323 sample indexing (not an ``LCMSCollection``-like object). 324 """ 325 if not hasattr( 326 lcms_collection, "get_representative_mass_features_for_all_clusters" 327 ): 328 raise AttributeError( 329 "lcms_collection must provide get_representative_mass_features_for_all_clusters " 330 "(expected an LCMSCollection)." 331 ) 332 333 representatives = ( 334 lcms_collection.get_representative_mass_features_for_all_clusters( 335 representative_metric=representative_metric 336 ) 337 ) 338 if cluster_ids is not None: 339 representatives = representatives[ 340 representatives["cluster"].isin(cluster_ids) 341 ] 342 343 query_spectra: list = [] 344 query_ids: list[str] = [] 345 query_precursor_mzs: list[float] = [] 346 347 for _, row in representatives.iterrows(): 348 sample_id = int(row["sample_id"]) 349 mf_id = row["mf_id"] 350 coll_mf_id = str(row["coll_mf_id"]) 351 352 try: 353 sample = lcms_collection[sample_id] 354 except (IndexError, KeyError, TypeError): 355 continue 356 357 mass_features = getattr(sample, "mass_features", None) or {} 358 mf = mass_features.get(mf_id) 359 if mf is None: 360 # mf_id from dataframes may not match dict key type (int/str/np) 361 try: 362 mf = mass_features.get(int(mf_id)) 363 except (TypeError, ValueError): 364 mf = None 365 if mf is None: 366 try: 367 mf = mass_features.get(str(mf_id)) 368 except (TypeError, ValueError): 369 mf = None 370 if mf is None: 371 continue 372 373 ms2 = getattr(mf, "best_ms2", None) 374 if ms2 is None: 375 continue 376 if not hasattr(ms2, "mz_exp") or len(ms2.mz_exp) == 0: 377 continue 378 379 query_spectra.append(ms2) 380 query_ids.append(coll_mf_id) 381 query_precursor_mzs.append(float(mf.mz)) 382 383 return query_spectra, query_ids, query_precursor_mzs 384 385 # ── Internal helpers ────────────────────────────────────────────────────── 386 387 def _threshold_for(self, metric: str) -> float: 388 """Return the edge threshold for *metric*.""" 389 return self.similarity_thresholds.get(metric, 0.5) 390 391 @staticmethod 392 def library_node_id(lib_idx: int) -> str: 393 """Return the internal network node ID for a library spectrum index.""" 394 return f"{_LIB_NODE_PREFIX}{int(lib_idx)}" 395 396 @staticmethod 397 def library_index_from_node_id(node_id: str) -> int | None: 398 """Parse a library matrix index from an internal node ID, if any. 399 400 Accepts the canonical ``lib:<idx>`` form. Bare integer strings are 401 also accepted for backward compatibility with older matrices. 402 """ 403 if node_id is None: 404 return None 405 s = str(node_id) 406 if s.startswith(_LIB_NODE_PREFIX): 407 try: 408 return int(s[len(_LIB_NODE_PREFIX) :]) 409 except ValueError: 410 return None 411 try: 412 return int(s) 413 except (TypeError, ValueError): 414 return None 415 416 def _update_matrices(self, new_scores: dict[str, dict[tuple[str, str], float]]): 417 """Write new similarity scores into the appropriate SimilarityMatrix.""" 418 for metric, pairs in new_scores.items(): 419 mat = self.similarity_matrices.get(metric) 420 if mat is None: 421 continue 422 for (id1, id2), score in pairs.items(): 423 mat.set_similarity(id1, id2, score) 424 # Finalise all matrices for efficient querying 425 for mat in self.similarity_matrices.values(): 426 mat.finalise() 427 428 def _prepare_queries( 429 self, 430 query_spectra: list, 431 query_ids: list[str], 432 query_precursor_mzs: list[float | None] | None, 433 ) -> None: 434 """Validate and store query spectra, IDs, and precursor m/z values. 435 436 Resets all stage flags and the stage-2 entropy-pair cache so that a 437 fresh computation can proceed. Called internally by 438 :meth:`run_query_vs_query_only` and :meth:`query_vs_library`. 439 440 Parameters 441 ---------- 442 query_spectra : list 443 Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes. 444 query_ids : list of str 445 Unique string ID for each spectrum. Must be the same length as 446 *query_spectra*. 447 query_precursor_mzs : list of float or None, or None 448 Precursor m/z for each spectrum. Required (non-None list) for 449 ``"identity"`` and ``"neutral_loss"`` search types. Pass ``None`` 450 to auto-fill with ``None`` values for ``"open"`` search. 451 452 Raises 453 ------ 454 ValueError 455 If *query_spectra* is empty, lengths are mismatched, or precursor 456 m/z values are missing/None for a precursor-required search type. 457 """ 458 n_query = len(query_spectra) 459 if n_query == 0: 460 raise ValueError("No query spectra provided.") 461 462 if len(query_ids) != n_query: 463 raise ValueError( 464 f"query_ids length ({len(query_ids)}) must match query_spectra length ({n_query})." 465 ) 466 467 # Validate precursor_mzs requirement 468 if self.search_type in _PRECURSOR_REQUIRED: 469 if query_precursor_mzs is None: 470 raise ValueError( 471 f"query_precursor_mzs is required for " 472 f"search_type='{self.search_type}'. " 473 "Provide a list of precursor m/z values, one per query spectrum." 474 ) 475 if len(query_precursor_mzs) != n_query: 476 raise ValueError( 477 f"query_precursor_mzs length ({len(query_precursor_mzs)}) " 478 f"must match query_spectra length ({n_query})." 479 ) 480 if any(pmz is None for pmz in query_precursor_mzs): 481 raise ValueError( 482 f"query_precursor_mzs contains None values for search_type='{self.search_type}'. " 483 "Provide a valid precursor m/z for every query spectrum." 484 ) 485 else: 486 if query_precursor_mzs is None: 487 query_precursor_mzs = [None] * n_query 488 elif len(query_precursor_mzs) != n_query: 489 raise ValueError( 490 f"query_precursor_mzs length ({len(query_precursor_mzs)}) " 491 f"must match query_spectra length ({n_query})." 492 ) 493 494 # Store provided queries 495 self._all_query_spectra = list(query_spectra) 496 self._all_query_ids = list(query_ids) 497 self._all_query_precursor_mzs = list(query_precursor_mzs) 498 499 # Register query IDs now 500 for mat in self.similarity_matrices.values(): 501 mat.register_spectra(self._all_query_ids) 502 503 # Reset stage flags and caches 504 self.stage_query_query_done = False 505 self.stage_query_library_done = False 506 self.stage_library_library_done = False 507 self._stage2_entropy_pairs = None 508 509 def _run_query_vs_query( 510 self, 511 fe_kwargs: dict | None = None, 512 ) -> None: 513 """Stage 1 – compute query-vs-query similarities. 514 515 Builds a temporary FlashEntropy index from the stored query spectra 516 and computes all-vs-all pairwise similarities within that set. 517 Results are written into :attr:`similarity_matrices` and 518 :attr:`stage_query_query_done` is set to ``True``. 519 520 Parameters 521 ---------- 522 fe_kwargs : dict, optional 523 Extra keyword arguments forwarded to 524 :class:`ms_entropy.FlashEntropySearch` when building the 525 temporary query index. 526 527 Raises 528 ------ 529 RuntimeError 530 If no queries have been prepared (i.e. :meth:`_prepare_queries` 531 has not been called). 532 """ 533 if not self._all_query_spectra: 534 raise RuntimeError( 535 "No queries prepared. Call run_query_vs_query_only() or query_vs_library() first." 536 ) 537 538 if fe_kwargs is None and self._settings is not None: 539 fe_kwargs = self._settings.as_fe_kwargs() 540 541 query_fe_lib = self._engine.build_fe_index_from_spectra( 542 spectra=self._all_query_spectra, 543 precursor_mzs=self._all_query_precursor_mzs, 544 fe_kwargs=fe_kwargs, 545 ) 546 qq_scores = self._engine.compute_all_vs_all_with_lib( 547 spectra=self._all_query_spectra, 548 spectrum_ids=self._all_query_ids, 549 precursor_mzs=self._all_query_precursor_mzs, 550 fe_lib_override=query_fe_lib, 551 ) 552 self._update_matrices(qq_scores) 553 self.stage_query_query_done = True 554 555 def _run_query_vs_library( 556 self, 557 ) -> tuple[dict[tuple[str, str], float], dict[tuple[str, str], float]]: 558 """Stage 2 – compute query-vs-library similarities. 559 560 Searches each stored query spectrum against the reference FE library 561 (``self.fe_lib``). Entropy similarity scores are stored for all 562 library entries with score > 0; cosine scores are computed for pairs 563 above :attr:`~SimilarityEngine.entropy_threshold_low`. Results are 564 written into :attr:`similarity_matrices`, 565 :attr:`stage_query_library_done` is set to ``True``, and the entropy 566 pairs are cached in :attr:`_stage2_entropy_pairs` for use by stage 3. 567 568 Returns 569 ------- 570 entropy_pairs : dict mapping (query_id, lib_idx_str) → float 571 All query-vs-library entropy similarity scores > 0. 572 cosine_pairs : dict mapping (query_id, lib_idx_str) → float 573 Cosine scores for pairs above the entropy threshold. 574 575 Raises 576 ------ 577 RuntimeError 578 If stage 1 has not been completed or if ``self.fe_lib`` is ``None``. 579 """ 580 if not self.stage_query_query_done: 581 raise RuntimeError( 582 "query-vs-query stage must be run before query-vs-library stage." 583 ) 584 if self.fe_lib is None: 585 raise RuntimeError( 586 "query-vs-library stage requires a reference FE library (fe_lib). " 587 "Construct MolecularNetwork with fe_lib or use run_query_vs_query_only for query-only networks." 588 ) 589 590 combined, lib_size = self._engine.search_queries_against_library( 591 query_spectra=self._all_query_spectra, 592 query_ids=self._all_query_ids, 593 query_precursor_mzs=self._all_query_precursor_mzs, 594 format_library_id=self.library_node_id, 595 ) 596 597 lib_ids = [self.library_node_id(i) for i in range(lib_size)] 598 for mat in self.similarity_matrices.values(): 599 mat.register_spectra(lib_ids) 600 601 self._update_matrices(combined) 602 603 entropy_pairs = combined.get("entropy_similarity", {}) 604 cosine_pairs = combined.get("cosine", {}) 605 606 self.stage_query_library_done = True 607 self._stage2_entropy_pairs = entropy_pairs 608 609 return entropy_pairs, cosine_pairs 610 611 def _run_library_vs_library( 612 self, 613 entropy_pairs: dict[tuple[str, str], float], 614 library_similarity_threshold: float, 615 ) -> None: 616 """Stage 3 – compute library-vs-library similarities for matched entries. 617 618 Identifies the subset of library spectra that scored ≥ 619 *library_similarity_threshold* against any query in stage 2, then 620 computes all-vs-all pairwise similarities within that subset. 621 Results are written into :attr:`similarity_matrices` and 622 :attr:`stage_library_library_done` is set to ``True``. 623 624 Parameters 625 ---------- 626 entropy_pairs : dict mapping (query_id, lib_idx_str) → float 627 The entropy similarity pairs returned by :meth:`_run_query_vs_library`. 628 Used to identify which library indices exceeded the threshold. 629 library_similarity_threshold : float 630 Minimum entropy similarity score a library entry must have against 631 any query to be included in this stage. 632 633 Raises 634 ------ 635 RuntimeError 636 If stage 2 has not been completed. 637 """ 638 if not self.stage_query_library_done: 639 raise RuntimeError( 640 "query-vs-library stage must be run before library-vs-library stage." 641 ) 642 643 matched_lib_indices: list[int] = sorted( 644 { 645 lib_idx 646 for (q_id, lib_id), score in entropy_pairs.items() 647 if q_id in set(self._all_query_ids) 648 and score >= library_similarity_threshold 649 and (lib_idx := self.library_index_from_node_id(lib_id)) is not None 650 } 651 ) 652 653 n_matched = len(matched_lib_indices) 654 655 if n_matched > 1: 656 matched_lib_ids = [self.library_node_id(i) for i in matched_lib_indices] 657 ll_scores = self._engine.compute_library_vs_library_filtered( 658 library_indices=matched_lib_indices, 659 spectrum_ids=matched_lib_ids, 660 ) 661 self._update_matrices(ll_scores) 662 663 self.stage_library_library_done = True 664 665 def _export_node_id(self, node_id: str, query_id_set: set[str]) -> str: 666 """Return node ID for file export. 667 668 Query IDs are preserved as-is. Library nodes stored internally as 669 ``lib:<idx>`` (or bare index strings) are mapped to FlashEntropy entry 670 ``spectra_id`` when available (fallback to ``id``, then the internal 671 node ID). 672 """ 673 if node_id in query_id_set: 674 return node_id 675 676 lib_idx = self.library_index_from_node_id(node_id) 677 if lib_idx is None or lib_idx < 0: 678 return node_id 679 680 if self.fe_lib is None: 681 return node_id 682 683 try: 684 lib_entry = self.fe_lib[lib_idx] 685 except (IndexError, KeyError, TypeError): 686 return node_id 687 688 if not isinstance(lib_entry, dict): 689 return node_id 690 691 lib_id = lib_entry.get("spectra_id") 692 if lib_id is None: 693 lib_id = lib_entry.get("id") 694 if lib_id is None: 695 return node_id 696 697 lib_id_str = str(lib_id) 698 return lib_id_str if lib_id_str else node_id 699 700 # ── Public API ──────────────────────────────────────────────────────────── 701 702 def run_query_vs_query_only( 703 self, 704 query_spectra: list, 705 query_ids: list[str], 706 query_precursor_mzs: list[float | None] | None = None, 707 fe_kwargs: dict | None = None, 708 ) -> None: 709 """Compute query-vs-query similarities (stage 1 only). 710 711 Builds a temporary FlashEntropy index from *query_spectra* and 712 computes all-vs-all pairwise similarities within that set. Results 713 are stored in :attr:`similarity_matrices`. 714 715 After this call, :attr:`stage_query_query_done` is ``True``. If a 716 reference library was provided at construction time you may continue 717 with :meth:`run_query_vs_library_stage`. 718 719 Parameters 720 ---------- 721 query_spectra : list 722 Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes. 723 query_ids : list of str 724 Unique string ID for each spectrum. 725 query_precursor_mzs : list of float or None, optional 726 Precursor m/z for each spectrum. Required (non-None list) for 727 ``"identity"`` and ``"neutral_loss"`` search types. 728 fe_kwargs : dict, optional 729 Extra keyword arguments forwarded to 730 :class:`ms_entropy.FlashEntropySearch` when building the 731 temporary query index. 732 733 Raises 734 ------ 735 RuntimeError 736 If queries have already been run (call :meth:`drop_queries` first). 737 ValueError 738 If *query_spectra* is empty, lengths are mismatched, or precursor 739 m/z values are missing for a precursor-required search type. 740 """ 741 if self._has_queries_run: 742 raise RuntimeError( 743 "query_vs_library has already been run — call drop_queries() to clear queries and results before running again." 744 ) 745 746 self._prepare_queries( 747 query_spectra=query_spectra, 748 query_ids=query_ids, 749 query_precursor_mzs=query_precursor_mzs, 750 ) 751 self._run_query_vs_query(fe_kwargs=fe_kwargs) 752 self._has_queries_run = True 753 754 def run_query_vs_library_stage(self) -> None: 755 """Continue from stage 1 and compute query-vs-library similarities (stage 2). 756 757 Searches each stored query spectrum against the reference FE library 758 and stores entropy + cosine scores in :attr:`similarity_matrices`. 759 After this call, :attr:`stage_query_library_done` is ``True``. 760 761 Stage-3 library subset selection uses 762 *library_similarity_threshold* on :meth:`run_library_vs_library_stage` 763 (or the same keyword on :meth:`query_vs_library`), not this method. 764 765 Raises 766 ------ 767 RuntimeError 768 If stage 1 has not been completed (call 769 :meth:`run_query_vs_query_only` first) or if ``fe_lib`` is 770 ``None``. 771 """ 772 if not self._has_queries_run: 773 raise RuntimeError( 774 "No queries have been run yet — call run_query_vs_query_only() or query_vs_library() first." 775 ) 776 777 entropy_pairs, _ = self._run_query_vs_library() 778 self._stage2_entropy_pairs = entropy_pairs 779 780 def run_library_vs_library_stage( 781 self, 782 library_similarity_threshold: float = 0.3, 783 ) -> None: 784 """Continue from stage 2 and compute library-vs-library similarities (stage 3). 785 786 Identifies library spectra that scored ≥ *library_similarity_threshold* 787 against any query in stage 2, then computes all-vs-all pairwise 788 similarities within that subset. Results are stored in 789 :attr:`similarity_matrices` and :attr:`stage_library_library_done` 790 is set to ``True``. 791 792 Parameters 793 ---------- 794 library_similarity_threshold : float, optional 795 Minimum entropy similarity score (from stage 2) required for a 796 library spectrum to be included in this stage. Default 0.3. 797 798 Raises 799 ------ 800 RuntimeError 801 If stage 2 has not been completed (call 802 :meth:`run_query_vs_library_stage` first). 803 """ 804 if self._stage2_entropy_pairs is None: 805 raise RuntimeError( 806 "query-vs-library stage must be run before library-vs-library stage." 807 ) 808 809 self._run_library_vs_library( 810 entropy_pairs=self._stage2_entropy_pairs, 811 library_similarity_threshold=library_similarity_threshold, 812 ) 813 814 def query_vs_library( 815 self, 816 query_spectra: list, 817 query_ids: list[str], 818 query_precursor_mzs: list[float | None] | None = None, 819 fe_kwargs: dict | None = None, 820 *, 821 hydrate_library_similarities: bool | None = None, 822 library_similarity_threshold: float | None = None, 823 ) -> None: 824 """Run all three stages in one call (convenience wrapper). 825 826 Executes stages 1–3 sequentially: 827 828 1. **Query-vs-query** – builds a temporary FlashEntropy index from 829 *query_spectra* and computes all-vs-all pairwise similarities. 830 2. **Query-vs-library** – searches each query against the reference 831 FE library (``self.fe_lib``). 832 3. **Library-vs-library** *(optional)* – when 833 *hydrate_library_similarities* is ``True``, computes pairwise 834 similarities for the subset of library spectra that matched any 835 query above *library_similarity_threshold*. 836 837 All results are stored in :attr:`similarity_matrices`. 838 839 Parameters 840 ---------- 841 query_spectra : list 842 Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes. 843 query_ids : list of str 844 Unique string ID for each spectrum. 845 query_precursor_mzs : list of float or None, optional 846 Precursor m/z for each spectrum. Required (non-None list) for 847 ``"identity"`` and ``"neutral_loss"`` search types. 848 fe_kwargs : dict, optional 849 Extra keyword arguments forwarded to 850 :class:`ms_entropy.FlashEntropySearch` when building the 851 temporary query index (stage 1). When None and *settings* was 852 provided at construction, uses ``settings.as_fe_kwargs()``. 853 hydrate_library_similarities : bool, optional 854 When ``True``, run stage 3 (library-vs-library). Default from 855 *settings* or ``False``. 856 library_similarity_threshold : float, optional 857 Minimum entropy similarity score (stage 2) required for a library 858 spectrum to be included in stage 3. Default from *settings* or 0.3. 859 860 Raises 861 ------ 862 RuntimeError 863 If queries have already been run (call :meth:`drop_queries` first) 864 or if ``fe_lib`` is ``None`` (required for stages 2 and 3). 865 ValueError 866 If *query_spectra* is empty, lengths are mismatched, or precursor 867 m/z values are missing for a precursor-required search type. 868 """ 869 if self._has_queries_run: 870 raise RuntimeError( 871 "query_vs_library has already been run — call drop_queries() to clear queries and results before running again." 872 ) 873 874 if hydrate_library_similarities is None: 875 hydrate_library_similarities = ( 876 self._settings.hydrate_library_similarities 877 if self._settings is not None 878 else False 879 ) 880 if library_similarity_threshold is None: 881 library_similarity_threshold = ( 882 self._settings.library_similarity_threshold 883 if self._settings is not None 884 else 0.3 885 ) 886 887 self._prepare_queries( 888 query_spectra=query_spectra, 889 query_ids=query_ids, 890 query_precursor_mzs=query_precursor_mzs, 891 ) 892 self._run_query_vs_query(fe_kwargs=fe_kwargs) 893 entropy_pairs, _ = self._run_query_vs_library() 894 895 if hydrate_library_similarities: 896 self._run_library_vs_library( 897 entropy_pairs=entropy_pairs, 898 library_similarity_threshold=library_similarity_threshold, 899 ) 900 901 self._has_queries_run = True 902 903 def get_network_edges( 904 self, metric: str = "entropy_similarity" 905 ) -> list[tuple[str, str, float]]: 906 """Return all edges above the similarity threshold for *metric*. 907 908 Parameters 909 ---------- 910 metric : str 911 Similarity metric to query. Default ``"entropy_similarity"``. 912 913 Returns 914 ------- 915 list of (id1, id2, score) 916 Upper-triangle pairs with score >= threshold. 917 """ 918 mat = self._get_matrix(metric) 919 threshold = self._threshold_for(metric) 920 return mat.get_pairs_above_threshold(threshold) 921 922 def get_spectrum_neighbors( 923 self, 924 spectrum_id: str, 925 metric: str = "entropy_similarity", 926 ) -> list[tuple[str, float]]: 927 """Return all neighbors of *spectrum_id* above the threshold. 928 929 Parameters 930 ---------- 931 spectrum_id : str 932 Query spectrum ID. 933 metric : str 934 Similarity metric to query. Default ``"entropy_similarity"``. 935 936 Returns 937 ------- 938 list of (neighbor_id, score) 939 Sorted by score descending. 940 """ 941 mat = self._get_matrix(metric) 942 threshold = self._threshold_for(metric) 943 944 if spectrum_id not in mat._id_to_idx: 945 return [] 946 947 idx = mat._id_to_idx[spectrum_id] 948 if mat._matrix is None: 949 return [] 950 951 mat.finalise() 952 row = mat._matrix.getrow(idx) 953 cx = row.tocoo() 954 neighbors = [] 955 for j, v in zip(cx.col, cx.data): 956 if v >= threshold and j != idx: 957 neighbors.append((mat._idx_to_id[j], float(v))) 958 neighbors.sort(key=lambda x: x[1], reverse=True) 959 return neighbors 960 961 def get_network_stats(self, metric: str = "entropy_similarity") -> dict[str, Any]: 962 """Return basic network statistics for *metric*. 963 964 Parameters 965 ---------- 966 metric : str 967 Similarity metric to query. Default ``"entropy_similarity"``. 968 969 Returns 970 ------- 971 dict with keys: 972 - ``n_nodes`` : number of registered spectra 973 - ``n_edges`` : number of edges above threshold 974 - ``avg_degree`` : average node degree 975 - ``density`` : fraction of possible edges that exist 976 - ``threshold`` : the threshold used 977 - ``metric`` : the metric name 978 """ 979 mat = self._get_matrix(metric) 980 threshold = self._threshold_for(metric) 981 edges = mat.get_pairs_above_threshold(threshold) 982 n_nodes = mat.n_spectra 983 n_edges = len(edges) 984 max_edges = n_nodes * (n_nodes - 1) / 2 if n_nodes > 1 else 1 985 degrees = {sid: 0 for sid in mat.spectrum_ids} 986 for id1, id2, _ in edges: 987 degrees[id1] += 1 988 degrees[id2] += 1 989 avg_degree = np.mean(list(degrees.values())) if degrees else 0.0 990 return { 991 "metric": metric, 992 "threshold": threshold, 993 "n_nodes": n_nodes, 994 "n_edges": n_edges, 995 "avg_degree": float(avg_degree), 996 "density": n_edges / max_edges if max_edges > 0 else 0.0, 997 } 998 999 # ── File output ─────────────────────────────────────────────────────────── 1000 1001 def save_edge_list( 1002 self, 1003 path: str, 1004 metric: str = "entropy_similarity", 1005 ): 1006 """Save the edge list as a CSV file. 1007 1008 Parameters 1009 ---------- 1010 path : str 1011 Output file path. 1012 metric : str 1013 Similarity metric. Default ``"entropy_similarity"``. 1014 """ 1015 edges = self.get_network_edges(metric=metric) 1016 query_id_set = set(self._all_query_ids) 1017 export_edges = [ 1018 ( 1019 self._export_node_id(id1, query_id_set), 1020 self._export_node_id(id2, query_id_set), 1021 score, 1022 ) 1023 for id1, id2, score in edges 1024 ] 1025 df = pd.DataFrame(export_edges, columns=["id1", "id2", "score"]) 1026 df.to_csv(path, index=False) 1027 1028 def save_similarity_matrix( 1029 self, 1030 path: str, 1031 metric: str = "entropy_similarity", 1032 threshold: float = 0.0, 1033 ): 1034 """Save the similarity matrix as a CSV file. 1035 1036 Parameters 1037 ---------- 1038 path : str 1039 Output file path. 1040 metric : str 1041 Similarity metric. Default ``"entropy_similarity"``. 1042 threshold : float 1043 Minimum score to include. Default 0.0 (all stored pairs). 1044 """ 1045 mat = self._get_matrix(metric) 1046 df = mat.to_dataframe(threshold=threshold) 1047 df.to_csv(path, index=False) 1048 1049 # ── Helpers ─────────────────────────────────────────────────────────────── 1050 1051 def _get_matrix(self, metric: str) -> SimilarityMatrix: 1052 if metric not in self.similarity_matrices: 1053 raise KeyError( 1054 f"Unknown metric '{metric}'. " 1055 f"Available: {list(self.similarity_matrices.keys())}" 1056 ) 1057 return self.similarity_matrices[metric] 1058 1059 def drop_queries(self): 1060 """Clear all stored queries and computed similarity results. 1061 1062 Resets internal query storage, stage flags, the stage-2 entropy-pair 1063 cache, clustering artifacts, and reinitialises all 1064 :attr:`similarity_matrices` to empty so that 1065 :meth:`run_query_vs_query_only` or :meth:`query_vs_library` can be 1066 called again with a consistent cleared state. 1067 """ 1068 # Clear stored queries 1069 self._all_query_spectra = [] 1070 self._all_query_ids = [] 1071 self._all_query_precursor_mzs = [] 1072 self._has_queries_run = False 1073 self._stage2_entropy_pairs = None 1074 1075 # Stage flags must match empty matrices (not "stale done") 1076 self.stage_query_query_done = False 1077 self.stage_query_library_done = False 1078 self.stage_library_library_done = False 1079 1080 # Recreate empty similarity matrices for each metric 1081 all_metrics = list(self.similarity_matrices.keys()) 1082 self.similarity_matrices = { 1083 m: SimilarityMatrix(metric_name=m) for m in all_metrics 1084 } 1085 1086 # Drop any cached clustering artifacts tied to previous query results. 1087 self._network_clusters = {} 1088 1089 def __repr__(self) -> str: 1090 n_nodes = sum( 1091 mat.n_spectra for mat in self.similarity_matrices.values() 1092 ) // max(1, len(self.similarity_matrices)) 1093 metrics = list(self.similarity_matrices.keys()) 1094 return ( 1095 f"MolecularNetwork(search_type='{self.search_type}', " 1096 f"n_spectra={n_nodes}, metrics={metrics})" 1097 )
Build a molecular network around a reference library.
Initialization is lazy — no similarity computation happens until you call one of the computation methods.
Parameters
- fe_lib (ms_entropy.FlashEntropySearch or None, optional):
Pre-built FlashEntropy search instance (built from the reference
library). Required for query-vs-library and library-vs-library
stages. Pass
None(default) to build a query-only network (stage 1 only). When provided, fragment-tolerance parameters are automatically extracted from this library unless overridden by ms2_tolerance_da. - search_type (str):
FlashEntropy search mode:
"identity","open", or"neutral_loss". Default"open"(typical DDA networking; use"identity"when precursor filtering is required). - additional_similarities (list of str, optional):
Extra similarity metrics to compute alongside entropy similarity.
Currently supported:
["cosine"]. Default["cosine"]. Cosine is gated by entropy_threshold_low (best-effort secondary score). - similarity_thresholds (dict, optional):
Mapping of metric name → edge threshold. Edges are created in each
metric's network when score >= threshold. Metrics not listed use
a default of 0.5.
Example:
{"entropy_similarity": 0.5, "cosine": 0.6} - ms1_tolerance_da (float, optional):
Precursor m/z tolerance (Da) used for
"identity"search to filter library candidates by precursor m/z. Ignored for"open"and"neutral_loss"(precursor filter is disabled for those modes). If None (default), uses 0.01 Da. - ms2_tolerance_da (float, optional):
Fragment m/z tolerance (Da) used for spectrum cleaning and similarity
scoring. Priority: explicit kwarg > extracted from fe_lib >
0.01 Da fallback. Override this when fe_lib is
Noneor when you need a tolerance different from the library's built-in value. - entropy_threshold_low (float or None, optional): Minimum entropy similarity score required to trigger additional metric computation (e.g. cosine). If None (default), set to half the lowest non-entropy similarity threshold, or 0.25 when no thresholds are given.
- settings (SpectralSimilaritySearchSettings, optional):
Encapsulated spectral-similarity / networking defaults (for example
lcms.parameters.mass_spectrum["ms2"].spectral_similarity_searchor~corems.encapsulation.factory.parameters.settings_from_lcms()). When provided, fills any constructor fields left asNonefrom this object. Explicit keyword arguments always override settings. Stage-1 temporary FE index build usessettings.as_fe_kwargs()when fe_kwargs is not passed toquery_vs_library()/run_query_vs_query_only(). Stage-3 defaults (hydrate_library_similarities,library_similarity_threshold) are also taken from settings when those method arguments are omitted.
Attributes
- similarity_matrices (dict of str → SimilarityMatrix):
One
~corems.molecular_networking.similarity_matrix.SimilarityMatrixper metric ("entropy_similarity","cosine", …). - stage_query_query_done (bool): True after query-vs-query similarities have been computed (stage 1).
- stage_query_library_done (bool): True after query-vs-library similarities have been computed (stage 2).
- stage_library_library_done (bool): True after matched library-vs-library similarities have been computed (stage 3).
Notes
Tolerance resolution order (highest priority first):
- Explicit ms2_tolerance_da kwarg passed to this constructor.
- settings.ms2_tolerance_da when settings is provided and the kwarg is omitted.
- Value extracted from
fe_lib.entropy_search.max_ms2_tolerance_in_da. - Hard-coded fallback of 0.01 Da (used when fe_lib is
Noneand no explicit value is given).
Prefer building fe_lib with the same
~corems.encapsulation.factory.processingSetting.SpectralSimilaritySearchSettings
instance used here (via library settings= / settings_from_lcms) so
FlashEntropy index tolerances match networking.
Precursor filter behaviour by search type:
"identity": library candidates are filtered by precursor m/z within ms1_tolerance_da before scoring."open": precursor filter is disabled; all library entries are scored."neutral_loss": precursor filter is disabled; matching occurs in neutral-loss mass space (precursor_mz − fragment_mz), not on the precursor itself.
Cosine scoring (when requested) is single-threaded: unique spectra are pre-sorted once, then pairs are scored sequentially.
147 def __init__( 148 self, 149 fe_lib=None, 150 search_type: str | None = None, 151 additional_similarities: list[str] | None = None, 152 similarity_thresholds: dict[str, float] | None = None, 153 ms1_tolerance_da: float | None = None, 154 ms2_tolerance_da: float | None = None, 155 entropy_threshold_low: float | None = None, 156 settings=None, 157 ): 158 if settings is not None: 159 if search_type is None: 160 search_type = settings.search_type 161 if additional_similarities is None: 162 additional_similarities = list(settings.additional_similarities) 163 if similarity_thresholds is None: 164 similarity_thresholds = dict(settings.similarity_thresholds) 165 if ms1_tolerance_da is None: 166 ms1_tolerance_da = settings.ms1_tolerance_da 167 if ms2_tolerance_da is None: 168 ms2_tolerance_da = settings.ms2_tolerance_da 169 if entropy_threshold_low is None: 170 entropy_threshold_low = settings.entropy_threshold_low 171 self._settings = settings 172 else: 173 self._settings = None 174 175 if search_type is None: 176 search_type = "open" 177 if additional_similarities is None: 178 additional_similarities = ["cosine"] 179 if similarity_thresholds is None: 180 similarity_thresholds = {} 181 182 if entropy_threshold_low is None: 183 non_entropy_thresholds = [ 184 v for k, v in similarity_thresholds.items() if k != "entropy_similarity" 185 ] 186 if non_entropy_thresholds: 187 entropy_threshold_low = min(non_entropy_thresholds) / 2 188 else: 189 entropy_threshold_low = 0.25 # Placeholder that won't be used if no additional similarities are computed 190 191 self.fe_lib = fe_lib 192 self.search_type = search_type 193 self.additional_similarities = list(additional_similarities) 194 self.similarity_thresholds = similarity_thresholds 195 196 # Build the engine (wraps fe_lib + search parameters) 197 # When fe_lib is provided, tolerance parameters are extracted from it. 198 # When fe_lib is None (queries-only use), SimilarityEngine will fall back 199 # to its internal defaults and only query-vs-query stage is allowed. 200 self._engine = SimilarityEngine( 201 fe_lib=fe_lib, 202 search_type=search_type, 203 additional_similarities=additional_similarities, 204 ms1_tolerance_da=ms1_tolerance_da, 205 ms2_tolerance_da=ms2_tolerance_da, 206 entropy_threshold_low=entropy_threshold_low, 207 settings=settings, 208 ) 209 210 # One SimilarityMatrix per metric – empty until query_vs_library() is called 211 all_metrics = ["entropy_similarity"] + list(additional_similarities) 212 self.similarity_matrices: dict[str, SimilarityMatrix] = { 213 m: SimilarityMatrix(metric_name=m) for m in all_metrics 214 } 215 216 # Accumulate query spectra (kept separate from the library FE index) 217 self._all_query_spectra: list = [] 218 self._all_query_ids: list[str] = [] 219 self._all_query_precursor_mzs: list[float | None] = [] 220 221 # Stage flags – enforce order: 222 # 1) query-vs-query 223 # 2) query-vs-library 224 # 3) matched library-vs-library 225 self.stage_query_query_done: bool = False 226 self.stage_query_library_done: bool = False 227 self.stage_library_library_done: bool = False 228 229 # Whether query_vs_library has been run (disabled repeated runs until dropped) 230 self._has_queries_run: bool = False 231 232 # Cache for stage 2 entropy pairs (used by stage 3) 233 self._stage2_entropy_pairs: dict[tuple[str, str], float] | None = None 234 235 # Cache of computed network clustering artifacts keyed by metric. 236 self._network_clusters: dict[str, dict] = {}
238 @staticmethod 239 def prepare_query_spectra_from_lcms_object( 240 lcms_obj, 241 mf_ids: set | None = None, 242 ) -> tuple[list, list[str], list[float]]: 243 """Build query lists from an ``LCMSBase`` object's mass features. 244 245 Iterates ``lcms_obj.mass_features`` and collects each feature's 246 ``best_ms2`` spectrum, skipping features with no MS2 or an empty 247 ``mz_exp``. If *mf_ids* is provided, only those mass features are 248 included. 249 250 Parameters 251 ---------- 252 lcms_obj : LCMSBase 253 LCMS object whose mass features have been populated with MS2 254 spectra (e.g. via :meth:`add_associated_ms2_dda`). 255 mf_ids : set, optional 256 If provided, only mass features whose id is in this set are 257 included. 258 259 Returns 260 ------- 261 (query_spectra, query_ids, query_precursor_mzs) 262 Three parallel lists ready to pass to :meth:`query_vs_library` 263 or :meth:`run_query_vs_query_only`. 264 """ 265 query_spectra: list = [] 266 query_ids: list[str] = [] 267 query_precursor_mzs: list[float] = [] 268 for mf_id, mf in lcms_obj.mass_features.items(): 269 if mf_ids is not None and mf_id not in mf_ids: 270 continue 271 ms2 = mf.best_ms2 272 if ms2 is None: 273 continue 274 if not hasattr(ms2, "mz_exp") or len(ms2.mz_exp) == 0: 275 continue 276 query_spectra.append(ms2) 277 query_ids.append(str(mf_id)) 278 query_precursor_mzs.append(float(mf.mz)) 279 return query_spectra, query_ids, query_precursor_mzs
Build query lists from an LCMSBase object's mass features.
Iterates lcms_obj.mass_features and collects each feature's
best_ms2 spectrum, skipping features with no MS2 or an empty
mz_exp. If mf_ids is provided, only those mass features are
included.
Parameters
- lcms_obj (LCMSBase):
LCMS object whose mass features have been populated with MS2
spectra (e.g. via
add_associated_ms2_dda()). - mf_ids (set, optional): If provided, only mass features whose id is in this set are included.
Returns
- (query_spectra, query_ids, query_precursor_mzs): Three parallel lists ready to pass to
query_vs_library()orrun_query_vs_query_only().
281 @staticmethod 282 def prepare_query_spectra_from_lcms_collection( 283 lcms_collection, 284 cluster_ids: set | None = None, 285 representative_metric: str | None = None, 286 ) -> tuple[list, list[str], list[float]]: 287 """Build query lists from representative mass features in an ``LCMSCollection``. 288 289 Uses :meth:`~corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations.get_representative_mass_features_for_all_clusters` 290 to select one mass feature per consensus cluster, then collects each 291 feature's ``best_ms2`` from the corresponding sample object. 292 293 Representative mass features must already be loaded with MS2 spectra 294 (e.g. via ``process_consensus_features(load_representatives=True, 295 add_ms2=True, ...)`` or ``reload_representative_mass_features(add_ms2=True)``). 296 Clusters whose representative has no usable MS2 are skipped. 297 298 Parameters 299 ---------- 300 lcms_collection : LCMSCollection 301 Collection with consensus clusters and loaded representative 302 mass features (with MS2 where available). 303 cluster_ids : set, optional 304 If provided, only representatives for these cluster IDs are 305 included. 306 representative_metric : str, optional 307 Metric passed to 308 :meth:`~corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations.get_representative_mass_features_for_all_clusters`. 309 If ``None``, uses the collection parameter 310 ``consensus_representative_metric``. 311 312 Returns 313 ------- 314 (query_spectra, query_ids, query_precursor_mzs) 315 Three parallel lists ready to pass to :meth:`query_vs_library` 316 or :meth:`run_query_vs_query_only`. Query IDs are collection-level 317 ``coll_mf_id`` strings (``"{sample_id}_{mf_id}"``). 318 319 Raises 320 ------ 321 AttributeError 322 If *lcms_collection* does not expose representative lookup or 323 sample indexing (not an ``LCMSCollection``-like object). 324 """ 325 if not hasattr( 326 lcms_collection, "get_representative_mass_features_for_all_clusters" 327 ): 328 raise AttributeError( 329 "lcms_collection must provide get_representative_mass_features_for_all_clusters " 330 "(expected an LCMSCollection)." 331 ) 332 333 representatives = ( 334 lcms_collection.get_representative_mass_features_for_all_clusters( 335 representative_metric=representative_metric 336 ) 337 ) 338 if cluster_ids is not None: 339 representatives = representatives[ 340 representatives["cluster"].isin(cluster_ids) 341 ] 342 343 query_spectra: list = [] 344 query_ids: list[str] = [] 345 query_precursor_mzs: list[float] = [] 346 347 for _, row in representatives.iterrows(): 348 sample_id = int(row["sample_id"]) 349 mf_id = row["mf_id"] 350 coll_mf_id = str(row["coll_mf_id"]) 351 352 try: 353 sample = lcms_collection[sample_id] 354 except (IndexError, KeyError, TypeError): 355 continue 356 357 mass_features = getattr(sample, "mass_features", None) or {} 358 mf = mass_features.get(mf_id) 359 if mf is None: 360 # mf_id from dataframes may not match dict key type (int/str/np) 361 try: 362 mf = mass_features.get(int(mf_id)) 363 except (TypeError, ValueError): 364 mf = None 365 if mf is None: 366 try: 367 mf = mass_features.get(str(mf_id)) 368 except (TypeError, ValueError): 369 mf = None 370 if mf is None: 371 continue 372 373 ms2 = getattr(mf, "best_ms2", None) 374 if ms2 is None: 375 continue 376 if not hasattr(ms2, "mz_exp") or len(ms2.mz_exp) == 0: 377 continue 378 379 query_spectra.append(ms2) 380 query_ids.append(coll_mf_id) 381 query_precursor_mzs.append(float(mf.mz)) 382 383 return query_spectra, query_ids, query_precursor_mzs
Build query lists from representative mass features in an LCMSCollection.
Uses ~corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations.get_representative_mass_features_for_all_clusters()
to select one mass feature per consensus cluster, then collects each
feature's best_ms2 from the corresponding sample object.
Representative mass features must already be loaded with MS2 spectra
(e.g. via process_consensus_features(load_representatives=True,
add_ms2=True, ...) or reload_representative_mass_features(add_ms2=True)).
Clusters whose representative has no usable MS2 are skipped.
Parameters
- lcms_collection (LCMSCollection): Collection with consensus clusters and loaded representative mass features (with MS2 where available).
- cluster_ids (set, optional): If provided, only representatives for these cluster IDs are included.
- representative_metric (str, optional):
Metric passed to
~corems.mass_spectra.calc.lc_calc.LCMSCollectionCalculations.get_representative_mass_features_for_all_clusters(). IfNone, uses the collection parameterconsensus_representative_metric.
Returns
- (query_spectra, query_ids, query_precursor_mzs): Three parallel lists ready to pass to
query_vs_library()orrun_query_vs_query_only(). Query IDs are collection-levelcoll_mf_idstrings ("{sample_id}_{mf_id}").
Raises
- AttributeError: If lcms_collection does not expose representative lookup or
sample indexing (not an
LCMSCollection-like object).
391 @staticmethod 392 def library_node_id(lib_idx: int) -> str: 393 """Return the internal network node ID for a library spectrum index.""" 394 return f"{_LIB_NODE_PREFIX}{int(lib_idx)}"
Return the internal network node ID for a library spectrum index.
396 @staticmethod 397 def library_index_from_node_id(node_id: str) -> int | None: 398 """Parse a library matrix index from an internal node ID, if any. 399 400 Accepts the canonical ``lib:<idx>`` form. Bare integer strings are 401 also accepted for backward compatibility with older matrices. 402 """ 403 if node_id is None: 404 return None 405 s = str(node_id) 406 if s.startswith(_LIB_NODE_PREFIX): 407 try: 408 return int(s[len(_LIB_NODE_PREFIX) :]) 409 except ValueError: 410 return None 411 try: 412 return int(s) 413 except (TypeError, ValueError): 414 return None
Parse a library matrix index from an internal node ID, if any.
Accepts the canonical lib:<idx> form. Bare integer strings are
also accepted for backward compatibility with older matrices.
702 def run_query_vs_query_only( 703 self, 704 query_spectra: list, 705 query_ids: list[str], 706 query_precursor_mzs: list[float | None] | None = None, 707 fe_kwargs: dict | None = None, 708 ) -> None: 709 """Compute query-vs-query similarities (stage 1 only). 710 711 Builds a temporary FlashEntropy index from *query_spectra* and 712 computes all-vs-all pairwise similarities within that set. Results 713 are stored in :attr:`similarity_matrices`. 714 715 After this call, :attr:`stage_query_query_done` is ``True``. If a 716 reference library was provided at construction time you may continue 717 with :meth:`run_query_vs_library_stage`. 718 719 Parameters 720 ---------- 721 query_spectra : list 722 Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes. 723 query_ids : list of str 724 Unique string ID for each spectrum. 725 query_precursor_mzs : list of float or None, optional 726 Precursor m/z for each spectrum. Required (non-None list) for 727 ``"identity"`` and ``"neutral_loss"`` search types. 728 fe_kwargs : dict, optional 729 Extra keyword arguments forwarded to 730 :class:`ms_entropy.FlashEntropySearch` when building the 731 temporary query index. 732 733 Raises 734 ------ 735 RuntimeError 736 If queries have already been run (call :meth:`drop_queries` first). 737 ValueError 738 If *query_spectra* is empty, lengths are mismatched, or precursor 739 m/z values are missing for a precursor-required search type. 740 """ 741 if self._has_queries_run: 742 raise RuntimeError( 743 "query_vs_library has already been run — call drop_queries() to clear queries and results before running again." 744 ) 745 746 self._prepare_queries( 747 query_spectra=query_spectra, 748 query_ids=query_ids, 749 query_precursor_mzs=query_precursor_mzs, 750 ) 751 self._run_query_vs_query(fe_kwargs=fe_kwargs) 752 self._has_queries_run = True
Compute query-vs-query similarities (stage 1 only).
Builds a temporary FlashEntropy index from query_spectra and
computes all-vs-all pairwise similarities within that set. Results
are stored in similarity_matrices.
After this call, stage_query_query_done is True. If a
reference library was provided at construction time you may continue
with run_query_vs_library_stage().
Parameters
- query_spectra (list):
Spectrum objects with
.mz_expand.abundanceattributes. - query_ids (list of str): Unique string ID for each spectrum.
- query_precursor_mzs (list of float or None, optional):
Precursor m/z for each spectrum. Required (non-None list) for
"identity"and"neutral_loss"search types. - fe_kwargs (dict, optional):
Extra keyword arguments forwarded to
ms_entropy.FlashEntropySearchwhen building the temporary query index.
Raises
- RuntimeError: If queries have already been run (call
drop_queries()first). - ValueError: If query_spectra is empty, lengths are mismatched, or precursor m/z values are missing for a precursor-required search type.
754 def run_query_vs_library_stage(self) -> None: 755 """Continue from stage 1 and compute query-vs-library similarities (stage 2). 756 757 Searches each stored query spectrum against the reference FE library 758 and stores entropy + cosine scores in :attr:`similarity_matrices`. 759 After this call, :attr:`stage_query_library_done` is ``True``. 760 761 Stage-3 library subset selection uses 762 *library_similarity_threshold* on :meth:`run_library_vs_library_stage` 763 (or the same keyword on :meth:`query_vs_library`), not this method. 764 765 Raises 766 ------ 767 RuntimeError 768 If stage 1 has not been completed (call 769 :meth:`run_query_vs_query_only` first) or if ``fe_lib`` is 770 ``None``. 771 """ 772 if not self._has_queries_run: 773 raise RuntimeError( 774 "No queries have been run yet — call run_query_vs_query_only() or query_vs_library() first." 775 ) 776 777 entropy_pairs, _ = self._run_query_vs_library() 778 self._stage2_entropy_pairs = entropy_pairs
Continue from stage 1 and compute query-vs-library similarities (stage 2).
Searches each stored query spectrum against the reference FE library
and stores entropy + cosine scores in similarity_matrices.
After this call, stage_query_library_done is True.
Stage-3 library subset selection uses
library_similarity_threshold on run_library_vs_library_stage()
(or the same keyword on query_vs_library()), not this method.
Raises
- RuntimeError: If stage 1 has not been completed (call
run_query_vs_query_only()first) or iffe_libisNone.
780 def run_library_vs_library_stage( 781 self, 782 library_similarity_threshold: float = 0.3, 783 ) -> None: 784 """Continue from stage 2 and compute library-vs-library similarities (stage 3). 785 786 Identifies library spectra that scored ≥ *library_similarity_threshold* 787 against any query in stage 2, then computes all-vs-all pairwise 788 similarities within that subset. Results are stored in 789 :attr:`similarity_matrices` and :attr:`stage_library_library_done` 790 is set to ``True``. 791 792 Parameters 793 ---------- 794 library_similarity_threshold : float, optional 795 Minimum entropy similarity score (from stage 2) required for a 796 library spectrum to be included in this stage. Default 0.3. 797 798 Raises 799 ------ 800 RuntimeError 801 If stage 2 has not been completed (call 802 :meth:`run_query_vs_library_stage` first). 803 """ 804 if self._stage2_entropy_pairs is None: 805 raise RuntimeError( 806 "query-vs-library stage must be run before library-vs-library stage." 807 ) 808 809 self._run_library_vs_library( 810 entropy_pairs=self._stage2_entropy_pairs, 811 library_similarity_threshold=library_similarity_threshold, 812 )
Continue from stage 2 and compute library-vs-library similarities (stage 3).
Identifies library spectra that scored ≥ library_similarity_threshold
against any query in stage 2, then computes all-vs-all pairwise
similarities within that subset. Results are stored in
similarity_matrices and stage_library_library_done
is set to True.
Parameters
- library_similarity_threshold (float, optional): Minimum entropy similarity score (from stage 2) required for a library spectrum to be included in this stage. Default 0.3.
Raises
- RuntimeError: If stage 2 has not been completed (call
run_query_vs_library_stage()first).
814 def query_vs_library( 815 self, 816 query_spectra: list, 817 query_ids: list[str], 818 query_precursor_mzs: list[float | None] | None = None, 819 fe_kwargs: dict | None = None, 820 *, 821 hydrate_library_similarities: bool | None = None, 822 library_similarity_threshold: float | None = None, 823 ) -> None: 824 """Run all three stages in one call (convenience wrapper). 825 826 Executes stages 1–3 sequentially: 827 828 1. **Query-vs-query** – builds a temporary FlashEntropy index from 829 *query_spectra* and computes all-vs-all pairwise similarities. 830 2. **Query-vs-library** – searches each query against the reference 831 FE library (``self.fe_lib``). 832 3. **Library-vs-library** *(optional)* – when 833 *hydrate_library_similarities* is ``True``, computes pairwise 834 similarities for the subset of library spectra that matched any 835 query above *library_similarity_threshold*. 836 837 All results are stored in :attr:`similarity_matrices`. 838 839 Parameters 840 ---------- 841 query_spectra : list 842 Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes. 843 query_ids : list of str 844 Unique string ID for each spectrum. 845 query_precursor_mzs : list of float or None, optional 846 Precursor m/z for each spectrum. Required (non-None list) for 847 ``"identity"`` and ``"neutral_loss"`` search types. 848 fe_kwargs : dict, optional 849 Extra keyword arguments forwarded to 850 :class:`ms_entropy.FlashEntropySearch` when building the 851 temporary query index (stage 1). When None and *settings* was 852 provided at construction, uses ``settings.as_fe_kwargs()``. 853 hydrate_library_similarities : bool, optional 854 When ``True``, run stage 3 (library-vs-library). Default from 855 *settings* or ``False``. 856 library_similarity_threshold : float, optional 857 Minimum entropy similarity score (stage 2) required for a library 858 spectrum to be included in stage 3. Default from *settings* or 0.3. 859 860 Raises 861 ------ 862 RuntimeError 863 If queries have already been run (call :meth:`drop_queries` first) 864 or if ``fe_lib`` is ``None`` (required for stages 2 and 3). 865 ValueError 866 If *query_spectra* is empty, lengths are mismatched, or precursor 867 m/z values are missing for a precursor-required search type. 868 """ 869 if self._has_queries_run: 870 raise RuntimeError( 871 "query_vs_library has already been run — call drop_queries() to clear queries and results before running again." 872 ) 873 874 if hydrate_library_similarities is None: 875 hydrate_library_similarities = ( 876 self._settings.hydrate_library_similarities 877 if self._settings is not None 878 else False 879 ) 880 if library_similarity_threshold is None: 881 library_similarity_threshold = ( 882 self._settings.library_similarity_threshold 883 if self._settings is not None 884 else 0.3 885 ) 886 887 self._prepare_queries( 888 query_spectra=query_spectra, 889 query_ids=query_ids, 890 query_precursor_mzs=query_precursor_mzs, 891 ) 892 self._run_query_vs_query(fe_kwargs=fe_kwargs) 893 entropy_pairs, _ = self._run_query_vs_library() 894 895 if hydrate_library_similarities: 896 self._run_library_vs_library( 897 entropy_pairs=entropy_pairs, 898 library_similarity_threshold=library_similarity_threshold, 899 ) 900 901 self._has_queries_run = True
Run all three stages in one call (convenience wrapper).
Executes stages 1–3 sequentially:
- Query-vs-query – builds a temporary FlashEntropy index from query_spectra and computes all-vs-all pairwise similarities.
- Query-vs-library – searches each query against the reference
FE library (
self.fe_lib). - Library-vs-library (optional) – when
hydrate_library_similarities is
True, computes pairwise similarities for the subset of library spectra that matched any query above library_similarity_threshold.
All results are stored in similarity_matrices.
Parameters
- query_spectra (list):
Spectrum objects with
.mz_expand.abundanceattributes. - query_ids (list of str): Unique string ID for each spectrum.
- query_precursor_mzs (list of float or None, optional):
Precursor m/z for each spectrum. Required (non-None list) for
"identity"and"neutral_loss"search types. - fe_kwargs (dict, optional):
Extra keyword arguments forwarded to
ms_entropy.FlashEntropySearchwhen building the temporary query index (stage 1). When None and settings was provided at construction, usessettings.as_fe_kwargs(). - hydrate_library_similarities (bool, optional):
When
True, run stage 3 (library-vs-library). Default from settings orFalse. - library_similarity_threshold (float, optional): Minimum entropy similarity score (stage 2) required for a library spectrum to be included in stage 3. Default from settings or 0.3.
Raises
- RuntimeError: If queries have already been run (call
drop_queries()first) or iffe_libisNone(required for stages 2 and 3). - ValueError: If query_spectra is empty, lengths are mismatched, or precursor m/z values are missing for a precursor-required search type.
903 def get_network_edges( 904 self, metric: str = "entropy_similarity" 905 ) -> list[tuple[str, str, float]]: 906 """Return all edges above the similarity threshold for *metric*. 907 908 Parameters 909 ---------- 910 metric : str 911 Similarity metric to query. Default ``"entropy_similarity"``. 912 913 Returns 914 ------- 915 list of (id1, id2, score) 916 Upper-triangle pairs with score >= threshold. 917 """ 918 mat = self._get_matrix(metric) 919 threshold = self._threshold_for(metric) 920 return mat.get_pairs_above_threshold(threshold)
Return all edges above the similarity threshold for metric.
Parameters
- metric (str):
Similarity metric to query. Default
"entropy_similarity".
Returns
- list of (id1, id2, score): Upper-triangle pairs with score >= threshold.
922 def get_spectrum_neighbors( 923 self, 924 spectrum_id: str, 925 metric: str = "entropy_similarity", 926 ) -> list[tuple[str, float]]: 927 """Return all neighbors of *spectrum_id* above the threshold. 928 929 Parameters 930 ---------- 931 spectrum_id : str 932 Query spectrum ID. 933 metric : str 934 Similarity metric to query. Default ``"entropy_similarity"``. 935 936 Returns 937 ------- 938 list of (neighbor_id, score) 939 Sorted by score descending. 940 """ 941 mat = self._get_matrix(metric) 942 threshold = self._threshold_for(metric) 943 944 if spectrum_id not in mat._id_to_idx: 945 return [] 946 947 idx = mat._id_to_idx[spectrum_id] 948 if mat._matrix is None: 949 return [] 950 951 mat.finalise() 952 row = mat._matrix.getrow(idx) 953 cx = row.tocoo() 954 neighbors = [] 955 for j, v in zip(cx.col, cx.data): 956 if v >= threshold and j != idx: 957 neighbors.append((mat._idx_to_id[j], float(v))) 958 neighbors.sort(key=lambda x: x[1], reverse=True) 959 return neighbors
Return all neighbors of spectrum_id above the threshold.
Parameters
- spectrum_id (str): Query spectrum ID.
- metric (str):
Similarity metric to query. Default
"entropy_similarity".
Returns
- list of (neighbor_id, score): Sorted by score descending.
961 def get_network_stats(self, metric: str = "entropy_similarity") -> dict[str, Any]: 962 """Return basic network statistics for *metric*. 963 964 Parameters 965 ---------- 966 metric : str 967 Similarity metric to query. Default ``"entropy_similarity"``. 968 969 Returns 970 ------- 971 dict with keys: 972 - ``n_nodes`` : number of registered spectra 973 - ``n_edges`` : number of edges above threshold 974 - ``avg_degree`` : average node degree 975 - ``density`` : fraction of possible edges that exist 976 - ``threshold`` : the threshold used 977 - ``metric`` : the metric name 978 """ 979 mat = self._get_matrix(metric) 980 threshold = self._threshold_for(metric) 981 edges = mat.get_pairs_above_threshold(threshold) 982 n_nodes = mat.n_spectra 983 n_edges = len(edges) 984 max_edges = n_nodes * (n_nodes - 1) / 2 if n_nodes > 1 else 1 985 degrees = {sid: 0 for sid in mat.spectrum_ids} 986 for id1, id2, _ in edges: 987 degrees[id1] += 1 988 degrees[id2] += 1 989 avg_degree = np.mean(list(degrees.values())) if degrees else 0.0 990 return { 991 "metric": metric, 992 "threshold": threshold, 993 "n_nodes": n_nodes, 994 "n_edges": n_edges, 995 "avg_degree": float(avg_degree), 996 "density": n_edges / max_edges if max_edges > 0 else 0.0, 997 }
Return basic network statistics for metric.
Parameters
- metric (str):
Similarity metric to query. Default
"entropy_similarity".
Returns
- dict with keys:: -
n_nodes: number of registered spectran_edges: number of edges above thresholdavg_degree: average node degreedensity: fraction of possible edges that existthreshold: the threshold usedmetric: the metric name
1001 def save_edge_list( 1002 self, 1003 path: str, 1004 metric: str = "entropy_similarity", 1005 ): 1006 """Save the edge list as a CSV file. 1007 1008 Parameters 1009 ---------- 1010 path : str 1011 Output file path. 1012 metric : str 1013 Similarity metric. Default ``"entropy_similarity"``. 1014 """ 1015 edges = self.get_network_edges(metric=metric) 1016 query_id_set = set(self._all_query_ids) 1017 export_edges = [ 1018 ( 1019 self._export_node_id(id1, query_id_set), 1020 self._export_node_id(id2, query_id_set), 1021 score, 1022 ) 1023 for id1, id2, score in edges 1024 ] 1025 df = pd.DataFrame(export_edges, columns=["id1", "id2", "score"]) 1026 df.to_csv(path, index=False)
Save the edge list as a CSV file.
Parameters
- path (str): Output file path.
- metric (str):
Similarity metric. Default
"entropy_similarity".
1028 def save_similarity_matrix( 1029 self, 1030 path: str, 1031 metric: str = "entropy_similarity", 1032 threshold: float = 0.0, 1033 ): 1034 """Save the similarity matrix as a CSV file. 1035 1036 Parameters 1037 ---------- 1038 path : str 1039 Output file path. 1040 metric : str 1041 Similarity metric. Default ``"entropy_similarity"``. 1042 threshold : float 1043 Minimum score to include. Default 0.0 (all stored pairs). 1044 """ 1045 mat = self._get_matrix(metric) 1046 df = mat.to_dataframe(threshold=threshold) 1047 df.to_csv(path, index=False)
Save the similarity matrix as a CSV file.
Parameters
- path (str): Output file path.
- metric (str):
Similarity metric. Default
"entropy_similarity". - threshold (float): Minimum score to include. Default 0.0 (all stored pairs).
1059 def drop_queries(self): 1060 """Clear all stored queries and computed similarity results. 1061 1062 Resets internal query storage, stage flags, the stage-2 entropy-pair 1063 cache, clustering artifacts, and reinitialises all 1064 :attr:`similarity_matrices` to empty so that 1065 :meth:`run_query_vs_query_only` or :meth:`query_vs_library` can be 1066 called again with a consistent cleared state. 1067 """ 1068 # Clear stored queries 1069 self._all_query_spectra = [] 1070 self._all_query_ids = [] 1071 self._all_query_precursor_mzs = [] 1072 self._has_queries_run = False 1073 self._stage2_entropy_pairs = None 1074 1075 # Stage flags must match empty matrices (not "stale done") 1076 self.stage_query_query_done = False 1077 self.stage_query_library_done = False 1078 self.stage_library_library_done = False 1079 1080 # Recreate empty similarity matrices for each metric 1081 all_metrics = list(self.similarity_matrices.keys()) 1082 self.similarity_matrices = { 1083 m: SimilarityMatrix(metric_name=m) for m in all_metrics 1084 } 1085 1086 # Drop any cached clustering artifacts tied to previous query results. 1087 self._network_clusters = {}
Clear all stored queries and computed similarity results.
Resets internal query storage, stage flags, the stage-2 entropy-pair
cache, clustering artifacts, and reinitialises all
similarity_matrices to empty so that
run_query_vs_query_only() or query_vs_library() can be
called again with a consistent cleared state.
21class SimilarityMatrix: 22 """Sparse, symmetric similarity matrix for one spectral similarity metric. 23 24 Parameters 25 ---------- 26 metric_name : str 27 Name of the similarity metric stored in this matrix 28 (e.g., ``"entropy_similarity"``, ``"cosine"``). 29 30 Attributes 31 ---------- 32 metric_name : str 33 Name of the similarity metric. 34 _id_to_idx : dict 35 Mapping from spectrum ID (str) to integer matrix index. 36 _idx_to_id : list 37 Mapping from integer matrix index to spectrum ID. 38 _matrix : scipy.sparse.lil_matrix or None 39 Underlying sparse matrix (lil for construction, csr after finalise). 40 _is_csr : bool 41 True when the matrix has been converted to CSR format. 42 """ 43 44 def __init__(self, metric_name: str = "entropy_similarity"): 45 self.metric_name = metric_name 46 self._id_to_idx: dict[str, int] = {} 47 self._idx_to_id: list[str] = [] 48 self._matrix: lil_matrix | csr_matrix | None = None 49 self._is_csr: bool = False 50 51 # ── Internal helpers ────────────────────────────────────────────────────── 52 53 def _ensure_lil(self): 54 """Convert to LIL format if currently CSR (needed before adding entries).""" 55 if self._is_csr and self._matrix is not None: 56 self._matrix = self._matrix.tolil() 57 self._is_csr = False 58 59 def _grow_matrix(self, new_size: int): 60 """Grow the sparse matrix to accommodate *new_size* spectra. 61 62 Uses 1.5x growth factor to reduce reallocation frequency. 63 """ 64 if self._matrix is None: 65 # Pre-allocate with growth factor on first allocation 66 alloc_size = max(new_size, int(new_size * 1.5)) 67 self._matrix = lil_matrix((alloc_size, alloc_size), dtype=np.float32) 68 self._is_csr = False 69 else: 70 self._ensure_lil() 71 old_size = self._matrix.shape[0] 72 if new_size > old_size: 73 # Grow with 1.5x factor to reduce future reallocations 74 alloc_size = max(new_size, int(old_size * 1.5)) 75 new_mat = lil_matrix((alloc_size, alloc_size), dtype=np.float32) 76 cx = self._matrix.tocsr() 77 new_mat[:old_size, :old_size] = cx 78 self._matrix = new_mat 79 80 # ── Public API ──────────────────────────────────────────────────────────── 81 82 def register_spectra(self, spectrum_ids: list[str]): 83 """Register new spectrum IDs, growing the matrix as needed. 84 85 Parameters 86 ---------- 87 spectrum_ids : list of str 88 IDs to register. Already-registered IDs are silently skipped. 89 """ 90 new_ids = [sid for sid in spectrum_ids if sid not in self._id_to_idx] 91 if not new_ids: 92 return 93 for sid in new_ids: 94 self._id_to_idx[sid] = len(self._idx_to_id) 95 self._idx_to_id.append(sid) 96 self._grow_matrix(len(self._idx_to_id)) 97 98 def set_similarity(self, id1: str, id2: str, score: float): 99 """Store a similarity score for a pair of spectra. 100 101 The matrix is symmetric: both (i, j) and (j, i) are set. 102 103 Parameters 104 ---------- 105 id1, id2 : str 106 Spectrum IDs (must already be registered). 107 score : float 108 Similarity score to store. 109 110 Raises 111 ------ 112 KeyError 113 If either ID has not been registered. 114 """ 115 i = self._id_to_idx[id1] 116 j = self._id_to_idx[id2] 117 self._ensure_lil() 118 self._matrix[i, j] = score 119 self._matrix[j, i] = score 120 121 def get_similarity(self, id1: str, id2: str) -> float: 122 """Retrieve the similarity score for a pair of spectra. 123 124 Parameters 125 ---------- 126 id1, id2 : str 127 Spectrum IDs. 128 129 Returns 130 ------- 131 float 132 Similarity score, or 0.0 if the pair was never stored. 133 """ 134 if id1 not in self._id_to_idx or id2 not in self._id_to_idx: 135 return 0.0 136 i = self._id_to_idx[id1] 137 j = self._id_to_idx[id2] 138 if self._matrix is None: 139 return 0.0 140 return float(self._matrix[i, j]) 141 142 def finalise(self): 143 """Convert internal LIL matrix to CSR for efficient querying. 144 145 Call this after all ``set_similarity`` calls for a batch are done. 146 Trims matrix to logical size (removes over-allocated space). 147 """ 148 if self._matrix is not None and not self._is_csr: 149 logical_size = len(self._idx_to_id) 150 # Trim to logical size if over-allocated 151 if self._matrix.shape[0] > logical_size: 152 self._matrix = self._matrix[:logical_size, :logical_size] 153 self._matrix = self._matrix.tocsr() 154 self._is_csr = True 155 156 def get_pairs_above_threshold( 157 self, threshold: float 158 ) -> list[tuple[str, str, float]]: 159 """Return all stored pairs with score >= *threshold*. 160 161 Parameters 162 ---------- 163 threshold : float 164 Minimum score to include. 165 166 Returns 167 ------- 168 list of (id1, id2, score) 169 Only the upper-triangle pairs are returned (no duplicates). 170 """ 171 if self._matrix is None: 172 return [] 173 mat = self._matrix.tocsr() if not self._is_csr else self._matrix 174 cx = mat.tocoo() 175 results = [] 176 for i, j, v in zip(cx.row, cx.col, cx.data): 177 if i < j and v >= threshold: # upper triangle only 178 results.append((self._idx_to_id[i], self._idx_to_id[j], float(v))) 179 return results 180 181 def to_dataframe(self, threshold: float = 0.0) -> pd.DataFrame: 182 """Return pairs above *threshold* as a pandas DataFrame. 183 184 Parameters 185 ---------- 186 threshold : float 187 Minimum score to include. Default 0.0 (all stored pairs). 188 189 Returns 190 ------- 191 pandas.DataFrame 192 Columns: ``["id1", "id2", "score"]``. 193 """ 194 pairs = self.get_pairs_above_threshold(threshold) 195 return pd.DataFrame(pairs, columns=["id1", "id2", "score"]) 196 197 @property 198 def spectrum_ids(self) -> list[str]: 199 """List of registered spectrum IDs in index order.""" 200 return list(self._idx_to_id) 201 202 @property 203 def n_spectra(self) -> int: 204 """Number of registered spectra.""" 205 return len(self._idx_to_id) 206 207 # ── Persistence ─────────────────────────────────────────────────────────── 208 209 def save(self, path: str): 210 """Save the matrix and ID mapping to a ``.npz`` file. 211 212 Parameters 213 ---------- 214 path : str 215 Output file path (will be given ``.npz`` extension if absent). 216 """ 217 self.finalise() 218 mat = self._matrix if self._matrix is not None else csr_matrix((0, 0)) 219 save_npz(path, mat) 220 # Save ID list alongside 221 ids_path = str(path).replace(".npz", "") + "_ids.npy" 222 np.save(ids_path, np.array(self._idx_to_id, dtype=object)) 223 224 @classmethod 225 def load(cls, path: str) -> "SimilarityMatrix": 226 """Load a SimilarityMatrix from a ``.npz`` file. 227 228 Parameters 229 ---------- 230 path : str 231 Path to the ``.npz`` file saved by :meth:`save`. 232 233 Returns 234 ------- 235 SimilarityMatrix 236 """ 237 mat = load_npz(path) 238 ids_path = str(path).replace(".npz", "") + "_ids.npy" 239 ids = list(np.load(ids_path, allow_pickle=True)) 240 obj = cls() 241 obj._matrix = mat.tocsr() 242 obj._is_csr = True 243 obj._idx_to_id = ids 244 obj._id_to_idx = {sid: i for i, sid in enumerate(ids)} 245 return obj 246 247 def __repr__(self) -> str: 248 n = self.n_spectra 249 nnz = self._matrix.nnz // 2 if self._matrix is not None else 0 250 return ( 251 f"SimilarityMatrix(metric='{self.metric_name}', " 252 f"n_spectra={n}, n_pairs_stored={nnz})" 253 )
Sparse, symmetric similarity matrix for one spectral similarity metric.
Parameters
- metric_name (str):
Name of the similarity metric stored in this matrix
(e.g.,
"entropy_similarity","cosine").
Attributes
- metric_name (str): Name of the similarity metric.
- _id_to_idx (dict): Mapping from spectrum ID (str) to integer matrix index.
- _idx_to_id (list): Mapping from integer matrix index to spectrum ID.
- _matrix (scipy.sparse.lil_matrix or None): Underlying sparse matrix (lil for construction, csr after finalise).
- _is_csr (bool): True when the matrix has been converted to CSR format.
82 def register_spectra(self, spectrum_ids: list[str]): 83 """Register new spectrum IDs, growing the matrix as needed. 84 85 Parameters 86 ---------- 87 spectrum_ids : list of str 88 IDs to register. Already-registered IDs are silently skipped. 89 """ 90 new_ids = [sid for sid in spectrum_ids if sid not in self._id_to_idx] 91 if not new_ids: 92 return 93 for sid in new_ids: 94 self._id_to_idx[sid] = len(self._idx_to_id) 95 self._idx_to_id.append(sid) 96 self._grow_matrix(len(self._idx_to_id))
Register new spectrum IDs, growing the matrix as needed.
Parameters
- spectrum_ids (list of str): IDs to register. Already-registered IDs are silently skipped.
98 def set_similarity(self, id1: str, id2: str, score: float): 99 """Store a similarity score for a pair of spectra. 100 101 The matrix is symmetric: both (i, j) and (j, i) are set. 102 103 Parameters 104 ---------- 105 id1, id2 : str 106 Spectrum IDs (must already be registered). 107 score : float 108 Similarity score to store. 109 110 Raises 111 ------ 112 KeyError 113 If either ID has not been registered. 114 """ 115 i = self._id_to_idx[id1] 116 j = self._id_to_idx[id2] 117 self._ensure_lil() 118 self._matrix[i, j] = score 119 self._matrix[j, i] = score
Store a similarity score for a pair of spectra.
The matrix is symmetric: both (i, j) and (j, i) are set.
Parameters
- id1, id2 (str): Spectrum IDs (must already be registered).
- score (float): Similarity score to store.
Raises
- KeyError: If either ID has not been registered.
121 def get_similarity(self, id1: str, id2: str) -> float: 122 """Retrieve the similarity score for a pair of spectra. 123 124 Parameters 125 ---------- 126 id1, id2 : str 127 Spectrum IDs. 128 129 Returns 130 ------- 131 float 132 Similarity score, or 0.0 if the pair was never stored. 133 """ 134 if id1 not in self._id_to_idx or id2 not in self._id_to_idx: 135 return 0.0 136 i = self._id_to_idx[id1] 137 j = self._id_to_idx[id2] 138 if self._matrix is None: 139 return 0.0 140 return float(self._matrix[i, j])
Retrieve the similarity score for a pair of spectra.
Parameters
- id1, id2 (str): Spectrum IDs.
Returns
- float: Similarity score, or 0.0 if the pair was never stored.
142 def finalise(self): 143 """Convert internal LIL matrix to CSR for efficient querying. 144 145 Call this after all ``set_similarity`` calls for a batch are done. 146 Trims matrix to logical size (removes over-allocated space). 147 """ 148 if self._matrix is not None and not self._is_csr: 149 logical_size = len(self._idx_to_id) 150 # Trim to logical size if over-allocated 151 if self._matrix.shape[0] > logical_size: 152 self._matrix = self._matrix[:logical_size, :logical_size] 153 self._matrix = self._matrix.tocsr() 154 self._is_csr = True
Convert internal LIL matrix to CSR for efficient querying.
Call this after all set_similarity calls for a batch are done.
Trims matrix to logical size (removes over-allocated space).
156 def get_pairs_above_threshold( 157 self, threshold: float 158 ) -> list[tuple[str, str, float]]: 159 """Return all stored pairs with score >= *threshold*. 160 161 Parameters 162 ---------- 163 threshold : float 164 Minimum score to include. 165 166 Returns 167 ------- 168 list of (id1, id2, score) 169 Only the upper-triangle pairs are returned (no duplicates). 170 """ 171 if self._matrix is None: 172 return [] 173 mat = self._matrix.tocsr() if not self._is_csr else self._matrix 174 cx = mat.tocoo() 175 results = [] 176 for i, j, v in zip(cx.row, cx.col, cx.data): 177 if i < j and v >= threshold: # upper triangle only 178 results.append((self._idx_to_id[i], self._idx_to_id[j], float(v))) 179 return results
Return all stored pairs with score >= threshold.
Parameters
- threshold (float): Minimum score to include.
Returns
- list of (id1, id2, score): Only the upper-triangle pairs are returned (no duplicates).
181 def to_dataframe(self, threshold: float = 0.0) -> pd.DataFrame: 182 """Return pairs above *threshold* as a pandas DataFrame. 183 184 Parameters 185 ---------- 186 threshold : float 187 Minimum score to include. Default 0.0 (all stored pairs). 188 189 Returns 190 ------- 191 pandas.DataFrame 192 Columns: ``["id1", "id2", "score"]``. 193 """ 194 pairs = self.get_pairs_above_threshold(threshold) 195 return pd.DataFrame(pairs, columns=["id1", "id2", "score"])
Return pairs above threshold as a pandas DataFrame.
Parameters
- threshold (float): Minimum score to include. Default 0.0 (all stored pairs).
Returns
- pandas.DataFrame: Columns:
["id1", "id2", "score"].
197 @property 198 def spectrum_ids(self) -> list[str]: 199 """List of registered spectrum IDs in index order.""" 200 return list(self._idx_to_id)
List of registered spectrum IDs in index order.
202 @property 203 def n_spectra(self) -> int: 204 """Number of registered spectra.""" 205 return len(self._idx_to_id)
Number of registered spectra.
209 def save(self, path: str): 210 """Save the matrix and ID mapping to a ``.npz`` file. 211 212 Parameters 213 ---------- 214 path : str 215 Output file path (will be given ``.npz`` extension if absent). 216 """ 217 self.finalise() 218 mat = self._matrix if self._matrix is not None else csr_matrix((0, 0)) 219 save_npz(path, mat) 220 # Save ID list alongside 221 ids_path = str(path).replace(".npz", "") + "_ids.npy" 222 np.save(ids_path, np.array(self._idx_to_id, dtype=object))
Save the matrix and ID mapping to a .npz file.
Parameters
- path (str):
Output file path (will be given
.npzextension if absent).
224 @classmethod 225 def load(cls, path: str) -> "SimilarityMatrix": 226 """Load a SimilarityMatrix from a ``.npz`` file. 227 228 Parameters 229 ---------- 230 path : str 231 Path to the ``.npz`` file saved by :meth:`save`. 232 233 Returns 234 ------- 235 SimilarityMatrix 236 """ 237 mat = load_npz(path) 238 ids_path = str(path).replace(".npz", "") + "_ids.npy" 239 ids = list(np.load(ids_path, allow_pickle=True)) 240 obj = cls() 241 obj._matrix = mat.tocsr() 242 obj._is_csr = True 243 obj._idx_to_id = ids 244 obj._id_to_idx = {sid: i for i, sid in enumerate(ids)} 245 return obj
Load a SimilarityMatrix from a .npz file.
Parameters
- path (str):
Path to the
.npzfile saved bysave().
Returns
- SimilarityMatrix
167class SimilarityEngine: 168 """Compute pairwise spectral similarities using FlashEntropy + optional extras. 169 170 Parameters 171 ---------- 172 fe_lib : ms_entropy.FlashEntropySearch or None 173 Pre-built FlashEntropy search instance. Pass ``None`` for 174 query-only mode (only :meth:`compute_all_vs_all_with_lib` with a 175 temporary index is used; query-vs-library methods are unavailable). 176 When provided, fragment-tolerance parameters are extracted from this 177 library unless overridden by *ms2_tolerance_da*. 178 search_type : str 179 FlashEntropy search mode: ``"identity"``, ``"open"``, or 180 ``"neutral_loss"``. Default ``"open"`` (matches typical DDA 181 molecular-networking use; use ``"identity"`` when precursor 182 filtering is required). 183 additional_similarities : list of str, optional 184 Extra similarity metrics to compute for pairs that pass the entropy 185 threshold. Currently supported: ``["cosine"]``. 186 Default ``["cosine"]``. Cosine is a best-effort peak-aligned 187 score gated by *entropy_threshold_low* (not a second primary engine). 188 ms1_tolerance_da : float, optional 189 Precursor m/z tolerance (Da) used **only** for ``"identity"`` search 190 to filter library candidates by precursor m/z. Ignored for 191 ``"open"`` and ``"neutral_loss"`` (precursor filter is disabled for 192 those modes). If ``None`` (default), uses 0.01 Da. 193 ms2_tolerance_da : float, optional 194 Fragment m/z tolerance (Da) for spectrum cleaning and similarity 195 scoring. Resolution priority: explicit kwarg > extracted from 196 *fe_lib* > 0.01 Da fallback. 197 entropy_threshold_low : float or None, optional 198 Minimum entropy similarity score required to trigger additional 199 metric computation. If None and *settings* does not supply a value, 200 default is 0.1. 201 settings : SpectralSimilaritySearchSettings, optional 202 Encapsulated spectral-similarity defaults (for example 203 ``lcms.parameters.mass_spectrum["ms2"].spectral_similarity_search``). 204 When provided, fills any constructor fields left as ``None``. 205 Explicit keyword arguments always override *settings*. 206 207 Attributes 208 ---------- 209 ms2_tolerance_da : float 210 Fragment m/z tolerance resolved at construction time. 211 peak_sep_da : float 212 Minimum peak separation used during spectrum cleaning 213 (``2 * ms2_tolerance_da``). 214 ms1_tolerance_da : float 215 Precursor m/z tolerance for identity search. 216 entropy_threshold_low : float 217 Low-entropy gate for triggering additional metric computation. 218 219 Notes 220 ----- 221 **Tolerance resolution order** (highest priority first): 222 223 1. Explicit *ms2_tolerance_da* kwarg. 224 2. *settings.ms2_tolerance_da* when *settings* is provided and the kwarg 225 is omitted. 226 3. ``fe_lib.entropy_search.max_ms2_tolerance_in_da`` (when *fe_lib* is 227 not ``None``). 228 4. Hard-coded fallback of 0.01 Da. 229 230 **Precursor filter behaviour by search type:** 231 232 - ``"identity"`` : library candidates are filtered by precursor m/z 233 within *ms1_tolerance_da* before scoring. 234 - ``"open"`` : precursor filter is disabled (``ms1_tolerance_in_da`` 235 passed to FlashEntropy is set to ``1e9``). 236 - ``"neutral_loss"`` : precursor filter is disabled for the same reason 237 as ``"open"``; matching occurs in neutral-loss mass space 238 (``precursor_mz − fragment_mz``), not on the precursor itself. 239 240 Cosine (when requested) runs **single-threaded**: unique spectra are 241 pre-sorted once, then pairs are scored sequentially via 242 :func:`_align_and_compute_cosine_presorted`. 243 """ 244 245 def __init__( 246 self, 247 fe_lib, 248 search_type: str | None = None, 249 additional_similarities: list[str] | None = None, 250 ms1_tolerance_da: float | None = None, 251 ms2_tolerance_da: float | None = None, 252 entropy_threshold_low: float | None = None, 253 settings=None, 254 ): 255 if settings is not None: 256 if search_type is None: 257 search_type = settings.search_type 258 if additional_similarities is None: 259 additional_similarities = list(settings.additional_similarities) 260 if ms1_tolerance_da is None: 261 ms1_tolerance_da = settings.ms1_tolerance_da 262 if ms2_tolerance_da is None: 263 ms2_tolerance_da = settings.ms2_tolerance_da 264 if ( 265 entropy_threshold_low is None 266 and settings.entropy_threshold_low is not None 267 ): 268 entropy_threshold_low = settings.entropy_threshold_low 269 self._settings = settings 270 else: 271 self._settings = None 272 273 if search_type is None: 274 search_type = "open" 275 if search_type not in _FE_METHOD_MAP: 276 raise ValueError( 277 f"search_type must be one of {list(_FE_METHOD_MAP.keys())}, " 278 f"got '{search_type}'." 279 ) 280 if additional_similarities is None: 281 additional_similarities = ["cosine"] 282 unsupported = set(additional_similarities) - _SUPPORTED_ADDITIONAL 283 if unsupported: 284 raise ValueError( 285 f"Unsupported additional_similarities: {unsupported}. " 286 f"Supported: {_SUPPORTED_ADDITIONAL}" 287 ) 288 289 self.fe_lib = fe_lib 290 self.search_type = search_type 291 self.additional_similarities = list(additional_similarities) 292 293 # Tolerance priority: explicit kwarg > fe_lib extraction > 0.01 fallback. 294 # When fe_lib is None (queries-only mode), a temporary FE index is built 295 # from the query spectra themselves; ms2_tolerance_da controls that index. 296 if ms2_tolerance_da is not None: 297 self.ms2_tolerance_da = ms2_tolerance_da 298 elif fe_lib is not None: 299 self.ms2_tolerance_da = fe_lib.entropy_search.max_ms2_tolerance_in_da 300 else: 301 self.ms2_tolerance_da = 0.01 302 self.peak_sep_da = 2 * self.ms2_tolerance_da 303 self.ms1_tolerance_da = ( 304 ms1_tolerance_da if ms1_tolerance_da is not None else 0.01 305 ) 306 307 self.entropy_threshold_low = ( 308 entropy_threshold_low if entropy_threshold_low is not None else 0.1 309 ) 310 311 # ── Internal helpers ────────────────────────────────────────────────────── 312 313 def _peaks_array(self, spectrum) -> np.ndarray: 314 """Return (N, 2) peaks array from a spectrum object.""" 315 mz = np.asarray(spectrum.mz_exp, dtype=float) 316 ab = np.asarray(spectrum.abundance, dtype=float) 317 return np.column_stack((mz, ab)) 318 319 def _require_precursor_mz( 320 self, 321 precursor_mz: float | None, 322 context: str, 323 ) -> float: 324 """Validate precursor m/z and return it as float.""" 325 if precursor_mz is None: 326 raise ValueError( 327 f"{context} is missing precursor_mz, which is required for " 328 f"search_type='{self.search_type}'." 329 ) 330 331 precursor = float(precursor_mz) 332 if not np.isfinite(precursor): 333 raise ValueError(f"{context} has non-finite precursor_mz={precursor_mz!r}.") 334 return precursor 335 336 def _transform_to_neutral_loss_space( 337 self, 338 peaks: np.ndarray, 339 precursor_mz: float | None, 340 context: str, 341 ) -> np.ndarray: 342 """Transform peaks from fragment m/z to neutral-loss mass space. 343 344 Neutral-loss mass is computed as: 345 neutral_loss_mz = precursor_mz - fragment_mz 346 """ 347 precursor = self._require_precursor_mz(precursor_mz, context) 348 peaks = np.asarray(peaks, dtype=float) 349 350 if peaks.ndim != 2 or peaks.shape[1] < 2: 351 raise ValueError( 352 f"{context} peaks must be a 2D array with at least 2 columns; " 353 f"got shape={peaks.shape}." 354 ) 355 356 if peaks.shape[0] == 0: 357 return peaks.copy() 358 359 # Neutral-loss masses are only valid for fragment_mz <= precursor_mz. 360 # Drop invalid fragment peaks to mirror the neutral-loss search domain. 361 valid_mask = peaks[:, 0] <= precursor 362 if not np.any(valid_mask): 363 return np.empty((0, peaks.shape[1]), dtype=float) 364 365 nl_peaks = peaks[valid_mask].copy() 366 nl_peaks[:, 0] = precursor - nl_peaks[:, 0] 367 368 # Keep deterministic ordering before downstream alignment. 369 sort_idx = np.argsort(nl_peaks[:, 0]) 370 return nl_peaks[sort_idx] 371 372 def _clean_and_search( 373 self, 374 peaks: np.ndarray, 375 precursor_mz: float | None, 376 fe_lib_override=None, 377 ) -> np.ndarray: 378 """Run FlashEntropy search for one query spectrum. 379 380 Passes raw peaks directly to FlashEntropy's ``search()`` method, 381 which cleans the spectrum internally. This avoids double-cleaning 382 and ensures the query is processed identically to library entries. 383 384 **Precursor filter behaviour:** 385 386 - ``"identity"`` : ``ms1_tolerance_in_da`` is set to 387 :attr:`ms1_tolerance_da` so only library entries within that 388 precursor window are scored. 389 - ``"open"`` and ``"neutral_loss"`` : ``ms1_tolerance_in_da`` is 390 set to ``1e9`` (effectively no filter). For ``"neutral_loss"``, 391 matching occurs in neutral-loss mass space internally within 392 FlashEntropy, so filtering on precursor m/z would incorrectly 393 exclude valid matches. 394 395 For ``"neutral_loss"``, fragment peaks with m/z > precursor m/z are 396 stripped before the search call (they have no valid neutral-loss 397 representation). 398 399 Parameters 400 ---------- 401 peaks : np.ndarray of shape (N, 2) 402 Raw ``[[mz, abundance], ...]`` array. FlashEntropy cleans this 403 internally (normalisation, denoising, peak separation). 404 precursor_mz : float or None 405 Required for ``"identity"`` and ``"neutral_loss"``; a dummy 406 value of 0.0 is used for ``"open"`` when ``None`` is passed. 407 fe_lib_override : ms_entropy.FlashEntropySearch, optional 408 If provided, search against this library instead of 409 ``self.fe_lib``. Used for query-vs-query searches where a 410 temporary index is built from the query spectra themselves. 411 412 Returns 413 ------- 414 np.ndarray 415 1-D array of entropy similarity scores, one per library entry. 416 Returns a zero array if the query has no valid peaks after 417 pre-filtering. 418 """ 419 fe_method, fe_key = _FE_METHOD_MAP[self.search_type] 420 fe = fe_lib_override if fe_lib_override is not None else self.fe_lib 421 422 if self.search_type in ("identity", "neutral_loss"): 423 pmz = self._require_precursor_mz(precursor_mz, "Query spectrum") 424 else: 425 # Use a dummy precursor_mz for open search 426 pmz = precursor_mz if precursor_mz is not None else 0.0 427 428 peaks_for_search = peaks 429 if self.search_type == "neutral_loss": 430 peaks_for_search = np.asarray(peaks, dtype=float) 431 if peaks_for_search.ndim != 2 or peaks_for_search.shape[1] < 2: 432 raise ValueError( 433 f"Query spectrum peaks must be 2D with at least 2 columns for neutral_loss search; " 434 f"got shape={peaks_for_search.shape}." 435 ) 436 peaks_for_search = peaks_for_search[peaks_for_search[:, 0] <= pmz] 437 if peaks_for_search.shape[0] == 0: 438 try: 439 lib_size = len(fe) 440 except TypeError: 441 lib_size = len(getattr(fe, "precursor_mz_array", [])) 442 return np.zeros(lib_size, dtype=float) 443 444 # Pass raw peaks to search() - it will clean them internally 445 # This ensures consistent cleaning between library and query spectra 446 search_kwargs: dict[str, Any] = dict( 447 peaks=peaks_for_search, # Raw peaks - search() will clean them 448 ms2_tolerance_in_da=self.ms2_tolerance_da, 449 method={fe_method}, 450 precursor_ions_removal_da=None, 451 noise_threshold=0.0, # TODO: get this as an attribute from FE as well 452 min_ms2_difference_in_da=self.peak_sep_da, 453 target="cpu", 454 ) 455 if self.search_type == "identity": 456 search_kwargs["precursor_mz"] = pmz 457 search_kwargs["ms1_tolerance_in_da"] = self.ms1_tolerance_da 458 else: 459 # open search and neutral_loss: precursor_mz still required by ms_entropy 460 # API but precursor filtering is disabled (1e9 = no filter). 461 # For neutral_loss, matching occurs on neutral-loss fragment space, not 462 # on precursor m/z, so the precursor filter must be disabled. 463 search_kwargs["precursor_mz"] = pmz 464 search_kwargs["ms1_tolerance_in_da"] = 1e9 # effectively no filter 465 466 results = fe.search(**search_kwargs) 467 return results[fe_key] 468 469 def _compute_cosine_for_query_vs_library( 470 self, 471 query_spectrum, 472 query_precursor_mz: float | None, 473 library_indices: list[int], 474 ) -> dict[int, float]: 475 """Compute cosine similarity for one query against multiple library spectra. 476 477 Extracts cleaned peaks directly from the FlashEntropy library to ensure 478 consistency with entropy similarity calculations. For 479 ``"neutral_loss"`` search, both query and library peaks are 480 transformed to neutral-loss mass space before alignment. 481 482 Parameters 483 ---------- 484 query_spectrum : spectrum object 485 Query spectrum with ``.mz_exp`` and ``.abundance`` attributes. 486 query_precursor_mz : float or None 487 Precursor m/z for the query. Required for ``"neutral_loss"`` 488 search; used as a dummy 0.0 for ``"open"`` when ``None``. 489 library_indices : list of int 490 Indices into ``self.fe_lib`` of the library spectra to score. 491 492 Returns 493 ------- 494 dict mapping int → float 495 ``{library_idx: cosine_score}`` for all pairs with score > 0. 496 """ 497 # Get and clean query peaks (same as FlashEntropy does) 498 query_peaks = self._peaks_array(query_spectrum) 499 if query_peaks.shape[0] == 0: 500 return {} 501 502 pmz = query_precursor_mz if query_precursor_mz is not None else 0.0 503 cleaned_query = self.fe_lib.clean_spectrum_for_search( 504 precursor_mz=pmz, 505 peaks=query_peaks, 506 precursor_ions_removal_da=None, 507 noise_threshold=0.0, 508 min_ms2_difference_in_da=self.peak_sep_da, 509 ) 510 cleaned_query = np.asarray(cleaned_query, dtype=float) 511 512 if cleaned_query.shape[0] == 0: 513 return {} 514 515 if self.search_type == "neutral_loss": 516 cleaned_query = self._transform_to_neutral_loss_space( 517 cleaned_query, 518 query_precursor_mz, 519 "Query spectrum", 520 ) 521 522 # Sort query once; each library spectrum is sorted once before cosine 523 query_mz_sorted, query_abun_sorted = _sort_peaks_by_mz( 524 cleaned_query[:, 0], cleaned_query[:, 1] 525 ) 526 527 cosine_scores = {} 528 for lib_idx in library_indices: 529 # Extract cleaned peaks from FE library (same as entropy similarity uses) 530 try: 531 lib_entry = self.fe_lib[lib_idx] 532 lib_peaks = np.asarray(lib_entry["peaks"], dtype=float) 533 except (IndexError, KeyError, TypeError): 534 continue 535 536 if self.search_type == "neutral_loss": 537 lib_peaks = self._transform_to_neutral_loss_space( 538 lib_peaks, 539 lib_entry.get("precursor_mz"), 540 f"Library spectrum at index {lib_idx}", 541 ) 542 543 if lib_peaks.ndim != 2 or lib_peaks.shape[1] < 2 or lib_peaks.shape[0] == 0: 544 continue 545 546 lib_mz_sorted, lib_abun_sorted = _sort_peaks_by_mz( 547 lib_peaks[:, 0], lib_peaks[:, 1] 548 ) 549 550 cosine = _align_and_compute_cosine_presorted( 551 query_mz_sorted, 552 query_abun_sorted, 553 lib_mz_sorted, 554 lib_abun_sorted, 555 self.ms2_tolerance_da, 556 ) 557 if cosine > 0.0: 558 cosine_scores[lib_idx] = cosine 559 560 return cosine_scores 561 562 def _compute_entropy_matrix_and_pairs( 563 self, 564 spectra: list, 565 spectrum_ids: list[str], 566 precursor_mzs: list[float | None], 567 fe_lib_override=None, 568 ) -> tuple[dict[tuple[str, str], float], list[tuple[int, int]]]: 569 """Compute entropy similarity and extract upper-triangle pairs. 570 571 Uses FlashEntropy's built-in search to efficiently compute pairwise 572 similarities. Each spectrum is searched once against the library, 573 and pairwise scores are extracted from the result vectors. 574 575 Streams results directly to sparse storage without building dense matrix. 576 577 Parameters 578 ---------- 579 spectra : list 580 Spectrum objects with .mz_exp and .abundance attributes. 581 spectrum_ids : list of str 582 User-provided IDs, one per spectrum. 583 precursor_mzs : list of float or None 584 Precursor m/z for each spectrum. 585 fe_lib_override : FlashEntropySearch, optional 586 FlashEntropy library to search against. If None, uses self.fe_lib. 587 588 Returns 589 ------- 590 entropy_pairs : dict mapping (id1, id2) → score 591 Upper-triangle pairs with score > 0.0 592 pairs_for_additional : list of (i, j) 593 Index pairs with score >= entropy_threshold_low (for additional metrics) 594 """ 595 n = len(spectra) 596 fe = fe_lib_override if fe_lib_override is not None else self.fe_lib 597 598 # Stream results directly to sparse storage (no dense intermediate) 599 entropy_pairs: dict[tuple[str, str], float] = {} 600 pairs_for_additional: list[tuple[int, int]] = [] 601 602 for i, (spec, pmz) in enumerate(zip(spectra, precursor_mzs)): 603 peaks = self._peaks_array(spec) 604 if peaks.shape[0] == 0: 605 continue 606 # Search against the FE library 607 result_vec = self._clean_and_search(peaks, pmz, fe_lib_override=fe) 608 609 if result_vec is None or len(result_vec) == 0: 610 continue 611 612 # Extract all pairs from result_vec, but only store upper-triangle (j > i) 613 # result_vec[j] contains the similarity score between spectrum i and library entry j 614 # When searching within same library (all-vs-all), result_vec has n entries 615 for j in range(min(len(result_vec), n)): 616 if j <= i: 617 # Skip lower triangle and diagonal to avoid duplicates 618 continue 619 score = float(result_vec[j]) 620 if score > 0.0: 621 entropy_pairs[(spectrum_ids[i], spectrum_ids[j])] = score 622 if score >= self.entropy_threshold_low: 623 pairs_for_additional.append((i, j)) 624 625 return entropy_pairs, pairs_for_additional 626 627 def _compute_cosine_for_pairs( 628 self, 629 pairs: list[tuple[int, int]], 630 lib_indices: list[int | None], 631 fe_lib_override=None, 632 ) -> dict[tuple[int, int], float]: 633 """Compute cosine similarity for a list of (i, j) index pairs. 634 635 Extracts cleaned peaks directly from the FlashEntropy library for all 636 unique spectrum indices referenced in the pairs list. 637 638 Parameters 639 ---------- 640 pairs : list of (i, j) 641 Index pairs. Both i and j are indices into the lib_indices list. 642 lib_indices : list of int or None 643 FlashEntropy library indices. For each index in pairs, lib_indices[index] 644 gives the FE library position. Cleaned peaks are extracted via 645 fe_lib[lib_indices[idx]]["peaks"]. All spectra must be indexed. 646 fe_lib_override : FlashEntropySearch, optional 647 FlashEntropy library to use. If None, uses self.fe_lib. 648 649 Returns 650 ------- 651 dict mapping (i, j) → cosine score 652 """ 653 if not pairs: 654 return {} 655 656 # Get FE library 657 fe = fe_lib_override if fe_lib_override is not None else self.fe_lib 658 659 # Extract unique indices from all pairs 660 unique_indices = {i for i, _ in pairs} | {j for _, j in pairs} 661 662 # Extract cleaned peaks once per unique spectrum and sort by m/z once 663 # so pair scoring can use _align_and_compute_cosine_presorted. 664 sorted_peaks: dict[int, tuple[np.ndarray, np.ndarray]] = {} 665 for idx in unique_indices: 666 if lib_indices[idx] is not None: 667 spec_dict = fe[lib_indices[idx]] 668 peaks = np.asarray(spec_dict["peaks"], dtype=float) 669 if peaks.ndim != 2 or peaks.shape[1] < 2: 670 raise ValueError( 671 f"Spectrum at library index {lib_indices[idx]} has invalid peaks shape={peaks.shape}." 672 ) 673 674 if self.search_type == "neutral_loss": 675 peaks = self._transform_to_neutral_loss_space( 676 peaks, 677 spec_dict.get("precursor_mz"), 678 f"Library spectrum at index {lib_indices[idx]}", 679 ) 680 681 mz_s, ab_s = _sort_peaks_by_mz(peaks[:, 0], peaks[:, 1]) 682 sorted_peaks[idx] = (mz_s, ab_s) 683 else: 684 raise ValueError( 685 f"Spectrum index {idx} has no library index. " 686 "All spectra must be indexed in FlashEntropy library." 687 ) 688 689 # Score pairs sequentially with pre-sorted peak arrays 690 cosine_scores: dict[tuple[int, int], float] = {} 691 tol = self.ms2_tolerance_da 692 for i, j in pairs: 693 mz_i, ab_i = sorted_peaks[i] 694 mz_j, ab_j = sorted_peaks[j] 695 cosine_scores[(i, j)] = _align_and_compute_cosine_presorted( 696 mz_i, ab_i, mz_j, ab_j, tol 697 ) 698 699 return cosine_scores 700 701 # ── Public API ──────────────────────────────────────────────────────────── 702 703 def search_queries_against_library( 704 self, 705 query_spectra: list, 706 query_ids: list[str], 707 query_precursor_mzs: list[float | None] | None = None, 708 *, 709 format_library_id: Callable[[int], str] | None = None, 710 ) -> tuple[dict[str, dict[tuple[str, str], float]], int]: 711 """Search each query spectrum against ``self.fe_lib`` (query–library). 712 713 Computes entropy similarity for all library hits with score > 0 and, 714 when configured, cosine for pairs at or above 715 :attr:`entropy_threshold_low`. 716 717 Parameters 718 ---------- 719 query_spectra : list 720 Spectrum objects with ``.mz_exp`` and ``.abundance``. 721 query_ids : list of str 722 Unique ID for each query (same length as *query_spectra*). 723 query_precursor_mzs : list of float or None, optional 724 Precursor m/z per query. Required for ``"identity"`` and 725 ``"neutral_loss"``; optional for ``"open"``. 726 format_library_id : callable, optional 727 Maps library index → node ID string used in pair keys. 728 Default ``str`` (bare index). Callers that need non-colliding 729 IDs (e.g. ``MolecularNetwork.library_node_id``) should pass a 730 formatter. 731 732 Returns 733 ------- 734 scores : dict 735 ``{metric_name: {(query_id, library_id): score}}`` with 736 ``"entropy_similarity"`` and optionally ``"cosine"``. 737 library_size : int 738 Length of the FlashEntropy result vector (library size used for 739 registration / indexing). ``0`` if no successful searches ran. 740 741 Raises 742 ------ 743 RuntimeError 744 If ``self.fe_lib`` is ``None``. 745 ValueError 746 If lengths of *query_ids* / *query_precursor_mzs* do not match 747 *query_spectra*. 748 """ 749 if self.fe_lib is None: 750 raise RuntimeError( 751 "search_queries_against_library requires a reference FE library " 752 "(fe_lib)." 753 ) 754 755 n_query = len(query_spectra) 756 if len(query_ids) != n_query: 757 raise ValueError( 758 f"query_ids length ({len(query_ids)}) must match " 759 f"query_spectra length ({n_query})." 760 ) 761 if query_precursor_mzs is None: 762 query_precursor_mzs = [None] * n_query 763 elif len(query_precursor_mzs) != n_query: 764 raise ValueError( 765 f"query_precursor_mzs length ({len(query_precursor_mzs)}) must match " 766 f"query_spectra length ({n_query})." 767 ) 768 769 id_fmt = format_library_id if format_library_id is not None else str 770 771 entropy_pairs: dict[tuple[str, str], float] = {} 772 cosine_pairs: dict[tuple[str, str], float] = {} 773 query_to_lib_indices: dict[int, list[int]] = {} 774 library_size = 0 775 776 for qi, (spec, pmz) in enumerate(zip(query_spectra, query_precursor_mzs)): 777 peaks = self._peaks_array(spec) 778 if peaks.shape[0] == 0: 779 continue 780 result_vec = self._clean_and_search(peaks, pmz) 781 if result_vec is None: 782 continue 783 library_size = max(library_size, len(result_vec)) 784 785 lib_indices_for_query: list[int] = [] 786 for lib_idx, score in enumerate(result_vec): 787 if score > 0.0: 788 entropy_pairs[(query_ids[qi], id_fmt(lib_idx))] = float(score) 789 if score >= self.entropy_threshold_low: 790 lib_indices_for_query.append(lib_idx) 791 792 if lib_indices_for_query: 793 query_to_lib_indices[qi] = lib_indices_for_query 794 795 if "cosine" in self.additional_similarities and query_to_lib_indices: 796 for qi, lib_indices in query_to_lib_indices.items(): 797 cosine_scores = self._compute_cosine_for_query_vs_library( 798 query_spectrum=query_spectra[qi], 799 query_precursor_mz=query_precursor_mzs[qi], 800 library_indices=lib_indices, 801 ) 802 for lib_idx, score in cosine_scores.items(): 803 cosine_pairs[(query_ids[qi], id_fmt(lib_idx))] = score 804 805 scores: dict[str, dict[tuple[str, str], float]] = { 806 "entropy_similarity": entropy_pairs 807 } 808 if cosine_pairs: 809 scores["cosine"] = cosine_pairs 810 811 return scores, library_size 812 813 def build_fe_index_from_spectra( 814 self, 815 spectra: list, 816 precursor_mzs: list[float | None] | None = None, 817 fe_kwargs: dict | None = None, 818 ): 819 """Build a FlashEntropy search index from a list of spectrum objects. 820 821 This is used to create a temporary index from query spectra so that 822 query-vs-query similarities can be computed without requiring the 823 spectra to already be in the main library. 824 825 Parameters 826 ---------- 827 spectra : list 828 Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes. 829 precursor_mzs : list of float or None, optional 830 Precursor m/z for each spectrum. Used as the ``precursor_mz`` 831 field in the FlashEntropy library. Defaults to 0.0 for each. 832 fe_kwargs : dict, optional 833 Extra keyword arguments forwarded to ``FlashEntropySearch``. 834 Defaults to the same settings used for the main library. 835 836 Returns 837 ------- 838 ms_entropy.FlashEntropySearch 839 A new FlashEntropy search instance indexed on *spectra*. 840 """ 841 try: 842 from ms_entropy import FlashEntropySearch 843 except ImportError: 844 raise ImportError( 845 "ms_entropy is required for build_fe_index_from_spectra(). " 846 "Install with: pip install ms_entropy" 847 ) 848 849 if precursor_mzs is None: 850 precursor_mzs = [None] * len(spectra) 851 852 # Default FE kwargs mirror the main library settings 853 # Extract build_index parameters from the main FE library if available 854 # (these are stored as custom attributes by _build_flash_entropy_index) 855 default_fe_kwargs = { 856 "normalize_intensity": True, 857 "min_ms2_difference_in_da": self.peak_sep_da, # Already 2x tolerance from extraction 858 "max_ms2_tolerance_in_da": self.ms2_tolerance_da, 859 "max_indexed_mz": getattr(self.fe_lib, "_build_max_indexed_mz", 3000), 860 "precursor_ions_removal_da": getattr( 861 self.fe_lib, "_build_precursor_ions_removal_da", None 862 ), 863 "noise_threshold": getattr(self.fe_lib, "_build_noise_threshold", 0), 864 } 865 if fe_kwargs: 866 default_fe_kwargs.update(fe_kwargs) 867 868 # Build the spectral library list 869 spectral_library = [] 870 for i, (spec, pmz) in enumerate(zip(spectra, precursor_mzs)): 871 peaks = self._peaks_array(spec) 872 if peaks.shape[0] == 0: 873 continue 874 spectral_library.append( 875 { 876 "id": i, 877 "precursor_mz": float(pmz) if pmz is not None else 0.0, 878 "peaks": peaks.tolist(), 879 } 880 ) 881 882 # Match the FlashEntropy constructor/init and build_index signature 883 fe_init_kws = [ 884 "max_ms2_tolerance_in_da", 885 "mz_index_step", 886 "low_memory", 887 "path_data", 888 ] 889 fe_init_kws = {k: v for k, v in default_fe_kwargs.items() if k in fe_init_kws} 890 891 fe = FlashEntropySearch(**fe_init_kws) 892 893 fe_index_kws = [ 894 "max_indexed_mz", 895 "precursor_ions_removal_da", 896 "noise_threshold", 897 "min_ms2_difference_in_da", 898 "max_peak_num", 899 ] 900 fe_index_kws = {k: v for k, v in default_fe_kwargs.items() if k in fe_index_kws} 901 902 fe.build_index(spectral_library, **fe_index_kws, clean_spectra=True) 903 return fe 904 905 def compute_library_vs_library_filtered( 906 self, 907 library_indices: list[int], 908 spectrum_ids: list[str], 909 precursor_mzs: list[float | None] | None = None, 910 ) -> dict[str, dict[tuple[str, str], float]]: 911 """Compute library-vs-library similarities for a filtered subset of library spectra. 912 913 Extracts the spectra at *library_indices* from ``self.fe_lib``, builds a 914 temporary FlashEntropy index from them, and computes all-vs-all pairwise 915 similarities within that subset. 916 917 Parameters 918 ---------- 919 library_indices : list of int 920 Indices into ``self.fe_lib`` of the library spectra to include. 921 spectrum_ids : list of str 922 IDs to assign to each library spectrum (must be same length as 923 *library_indices*). 924 precursor_mzs : list of float or None, optional 925 Precursor m/z for each library spectrum. If None, attempts to read 926 from ``self.fe_lib`` (attribute ``precursor_mz`` on each entry), or 927 defaults to 0.0. 928 929 Returns 930 ------- 931 dict 932 ``{metric_name: {(id1, id2): score}}`` 933 All-vs-all pairs within the filtered library subset. 934 """ 935 n = len(library_indices) 936 if n == 0: 937 return {} 938 939 if self.fe_lib is None: 940 raise RuntimeError( 941 "compute_library_vs_library_filtered requires a reference FE library " 942 "(fe_lib)." 943 ) 944 945 if len(spectrum_ids) != n: 946 raise ValueError( 947 f"spectrum_ids length ({len(spectrum_ids)}) must match " 948 f"library_indices length ({n})." 949 ) 950 if precursor_mzs is not None and len(precursor_mzs) != n: 951 raise ValueError( 952 f"precursor_mzs length ({len(precursor_mzs)}) must match " 953 f"library_indices length ({n})." 954 ) 955 956 # Build lightweight spectrum objects from the raw library entries 957 class _LibSpec: 958 __slots__ = ("mz_exp", "abundance") 959 960 def __init__(self, peaks_arr): 961 self.mz_exp = peaks_arr[:, 0] 962 self.abundance = peaks_arr[:, 1] 963 964 lib_spectra: list = [] 965 lib_precursor_mzs: list[float | None] = [] 966 # IDs for successfully extracted entries only (same order as lib_spectra) 967 valid_ids: list[str] = [] 968 969 for i, idx in enumerate(library_indices): 970 try: 971 entry = self.fe_lib[idx] 972 except (IndexError, KeyError, TypeError) as exc: 973 raise ValueError(f"Cannot read library entry at index {idx}.") from exc 974 975 if not isinstance(entry, dict): 976 raise TypeError( 977 f"Library entry at index {idx} must be a dict; " 978 f"got {type(entry).__name__}." 979 ) 980 981 peaks = np.asarray(entry.get("peaks", []), dtype=float) 982 # Skip empty/invalid peak tables but keep spectrum_ids/precursors 983 # aligned to the original index *i* for survivors (not first-N trim). 984 if peaks.ndim != 2 or peaks.shape[1] < 2 or peaks.shape[0] == 0: 985 continue 986 987 lib_spectra.append(_LibSpec(peaks)) 988 valid_ids.append(spectrum_ids[i]) 989 if precursor_mzs is not None: 990 lib_precursor_mzs.append(precursor_mzs[i]) 991 else: 992 lib_precursor_mzs.append(float(entry.get("precursor_mz", 0.0) or 0.0)) 993 994 if not lib_spectra: 995 return {} 996 997 # Build a temporary FE index from the filtered library spectra 998 temp_fe = self.build_fe_index_from_spectra( 999 spectra=lib_spectra, 1000 precursor_mzs=lib_precursor_mzs, 1001 ) 1002 1003 # Compute all-vs-all using the temporary index 1004 return self.compute_all_vs_all_with_lib( 1005 spectra=lib_spectra, 1006 spectrum_ids=valid_ids, 1007 precursor_mzs=lib_precursor_mzs, 1008 fe_lib_override=temp_fe, 1009 ) 1010 1011 def compute_all_vs_all_with_lib( 1012 self, 1013 spectra: list, 1014 spectrum_ids: list[str], 1015 precursor_mzs: list[float | None] | None = None, 1016 fe_lib_override=None, 1017 ) -> dict[str, dict[tuple[str, str], float]]: 1018 """Compute all-vs-all pairwise similarities using a provided FE library. 1019 1020 Canonical all-vs-all path: each spectrum is searched once against the 1021 FlashEntropy index (*fe_lib_override* or ``self.fe_lib``), and 1022 upper-triangle pairwise scores are extracted from the result vectors. 1023 1024 Parameters 1025 ---------- 1026 spectra : list 1027 Spectrum objects with .mz_exp and .abundance attributes. 1028 spectrum_ids : list of str 1029 User-provided IDs, one per spectrum. 1030 precursor_mzs : list of float or None, optional 1031 Precursor m/z for each spectrum. 1032 fe_lib_override : ms_entropy.FlashEntropySearch, optional 1033 FlashEntropy library to search against. If None, falls back to 1034 ``self.fe_lib``. 1035 1036 Returns 1037 ------- 1038 dict 1039 ``{metric_name: {(id1, id2): score}}`` 1040 """ 1041 n = len(spectra) 1042 if n == 0: 1043 return {} 1044 1045 if precursor_mzs is None: 1046 precursor_mzs = [None] * n 1047 1048 # Library indices are simply 0..n-1 (positions in fe_lib_override) 1049 lib_indices = list(range(n)) 1050 1051 # ── Stage 1: Compute entropy similarity matrix using FE search ──────── 1052 entropy_pairs, pairs_for_additional = self._compute_entropy_matrix_and_pairs( 1053 spectra, spectrum_ids, precursor_mzs, fe_lib_override 1054 ) 1055 1056 result: dict[str, dict[tuple[str, str], float]] = { 1057 "entropy_similarity": entropy_pairs 1058 } 1059 1060 # ── Stage 2: Compute additional metrics for pairs above threshold ──── 1061 fe = fe_lib_override if fe_lib_override is not None else self.fe_lib 1062 for metric in self.additional_similarities: 1063 if metric == "cosine": 1064 cosine_idx_scores = self._compute_cosine_for_pairs( 1065 pairs_for_additional, 1066 lib_indices, 1067 fe_lib_override=fe, 1068 ) 1069 result["cosine"] = { 1070 (spectrum_ids[i], spectrum_ids[j]): score 1071 for (i, j), score in cosine_idx_scores.items() 1072 } 1073 1074 return result
Compute pairwise spectral similarities using FlashEntropy + optional extras.
Parameters
- fe_lib (ms_entropy.FlashEntropySearch or None):
Pre-built FlashEntropy search instance. Pass
Nonefor query-only mode (onlycompute_all_vs_all_with_lib()with a temporary index is used; query-vs-library methods are unavailable). When provided, fragment-tolerance parameters are extracted from this library unless overridden by ms2_tolerance_da. - search_type (str):
FlashEntropy search mode:
"identity","open", or"neutral_loss". Default"open"(matches typical DDA molecular-networking use; use"identity"when precursor filtering is required). - additional_similarities (list of str, optional):
Extra similarity metrics to compute for pairs that pass the entropy
threshold. Currently supported:
["cosine"]. Default["cosine"]. Cosine is a best-effort peak-aligned score gated by entropy_threshold_low (not a second primary engine). - ms1_tolerance_da (float, optional):
Precursor m/z tolerance (Da) used only for
"identity"search to filter library candidates by precursor m/z. Ignored for"open"and"neutral_loss"(precursor filter is disabled for those modes). IfNone(default), uses 0.01 Da. - ms2_tolerance_da (float, optional): Fragment m/z tolerance (Da) for spectrum cleaning and similarity scoring. Resolution priority: explicit kwarg > extracted from fe_lib > 0.01 Da fallback.
- entropy_threshold_low (float or None, optional): Minimum entropy similarity score required to trigger additional metric computation. If None and settings does not supply a value, default is 0.1.
- settings (SpectralSimilaritySearchSettings, optional):
Encapsulated spectral-similarity defaults (for example
lcms.parameters.mass_spectrum["ms2"].spectral_similarity_search). When provided, fills any constructor fields left asNone. Explicit keyword arguments always override settings.
Attributes
- ms2_tolerance_da (float): Fragment m/z tolerance resolved at construction time.
- peak_sep_da (float):
Minimum peak separation used during spectrum cleaning
(
2 * ms2_tolerance_da). - ms1_tolerance_da (float): Precursor m/z tolerance for identity search.
- entropy_threshold_low (float): Low-entropy gate for triggering additional metric computation.
Notes
Tolerance resolution order (highest priority first):
- Explicit ms2_tolerance_da kwarg.
- settings.ms2_tolerance_da when settings is provided and the kwarg is omitted.
fe_lib.entropy_search.max_ms2_tolerance_in_da(when fe_lib is notNone).- Hard-coded fallback of 0.01 Da.
Precursor filter behaviour by search type:
"identity": library candidates are filtered by precursor m/z within ms1_tolerance_da before scoring."open": precursor filter is disabled (ms1_tolerance_in_dapassed to FlashEntropy is set to1e9)."neutral_loss": precursor filter is disabled for the same reason as"open"; matching occurs in neutral-loss mass space (precursor_mz − fragment_mz), not on the precursor itself.
Cosine (when requested) runs single-threaded: unique spectra are
pre-sorted once, then pairs are scored sequentially via
_align_and_compute_cosine_presorted().
245 def __init__( 246 self, 247 fe_lib, 248 search_type: str | None = None, 249 additional_similarities: list[str] | None = None, 250 ms1_tolerance_da: float | None = None, 251 ms2_tolerance_da: float | None = None, 252 entropy_threshold_low: float | None = None, 253 settings=None, 254 ): 255 if settings is not None: 256 if search_type is None: 257 search_type = settings.search_type 258 if additional_similarities is None: 259 additional_similarities = list(settings.additional_similarities) 260 if ms1_tolerance_da is None: 261 ms1_tolerance_da = settings.ms1_tolerance_da 262 if ms2_tolerance_da is None: 263 ms2_tolerance_da = settings.ms2_tolerance_da 264 if ( 265 entropy_threshold_low is None 266 and settings.entropy_threshold_low is not None 267 ): 268 entropy_threshold_low = settings.entropy_threshold_low 269 self._settings = settings 270 else: 271 self._settings = None 272 273 if search_type is None: 274 search_type = "open" 275 if search_type not in _FE_METHOD_MAP: 276 raise ValueError( 277 f"search_type must be one of {list(_FE_METHOD_MAP.keys())}, " 278 f"got '{search_type}'." 279 ) 280 if additional_similarities is None: 281 additional_similarities = ["cosine"] 282 unsupported = set(additional_similarities) - _SUPPORTED_ADDITIONAL 283 if unsupported: 284 raise ValueError( 285 f"Unsupported additional_similarities: {unsupported}. " 286 f"Supported: {_SUPPORTED_ADDITIONAL}" 287 ) 288 289 self.fe_lib = fe_lib 290 self.search_type = search_type 291 self.additional_similarities = list(additional_similarities) 292 293 # Tolerance priority: explicit kwarg > fe_lib extraction > 0.01 fallback. 294 # When fe_lib is None (queries-only mode), a temporary FE index is built 295 # from the query spectra themselves; ms2_tolerance_da controls that index. 296 if ms2_tolerance_da is not None: 297 self.ms2_tolerance_da = ms2_tolerance_da 298 elif fe_lib is not None: 299 self.ms2_tolerance_da = fe_lib.entropy_search.max_ms2_tolerance_in_da 300 else: 301 self.ms2_tolerance_da = 0.01 302 self.peak_sep_da = 2 * self.ms2_tolerance_da 303 self.ms1_tolerance_da = ( 304 ms1_tolerance_da if ms1_tolerance_da is not None else 0.01 305 ) 306 307 self.entropy_threshold_low = ( 308 entropy_threshold_low if entropy_threshold_low is not None else 0.1 309 )
703 def search_queries_against_library( 704 self, 705 query_spectra: list, 706 query_ids: list[str], 707 query_precursor_mzs: list[float | None] | None = None, 708 *, 709 format_library_id: Callable[[int], str] | None = None, 710 ) -> tuple[dict[str, dict[tuple[str, str], float]], int]: 711 """Search each query spectrum against ``self.fe_lib`` (query–library). 712 713 Computes entropy similarity for all library hits with score > 0 and, 714 when configured, cosine for pairs at or above 715 :attr:`entropy_threshold_low`. 716 717 Parameters 718 ---------- 719 query_spectra : list 720 Spectrum objects with ``.mz_exp`` and ``.abundance``. 721 query_ids : list of str 722 Unique ID for each query (same length as *query_spectra*). 723 query_precursor_mzs : list of float or None, optional 724 Precursor m/z per query. Required for ``"identity"`` and 725 ``"neutral_loss"``; optional for ``"open"``. 726 format_library_id : callable, optional 727 Maps library index → node ID string used in pair keys. 728 Default ``str`` (bare index). Callers that need non-colliding 729 IDs (e.g. ``MolecularNetwork.library_node_id``) should pass a 730 formatter. 731 732 Returns 733 ------- 734 scores : dict 735 ``{metric_name: {(query_id, library_id): score}}`` with 736 ``"entropy_similarity"`` and optionally ``"cosine"``. 737 library_size : int 738 Length of the FlashEntropy result vector (library size used for 739 registration / indexing). ``0`` if no successful searches ran. 740 741 Raises 742 ------ 743 RuntimeError 744 If ``self.fe_lib`` is ``None``. 745 ValueError 746 If lengths of *query_ids* / *query_precursor_mzs* do not match 747 *query_spectra*. 748 """ 749 if self.fe_lib is None: 750 raise RuntimeError( 751 "search_queries_against_library requires a reference FE library " 752 "(fe_lib)." 753 ) 754 755 n_query = len(query_spectra) 756 if len(query_ids) != n_query: 757 raise ValueError( 758 f"query_ids length ({len(query_ids)}) must match " 759 f"query_spectra length ({n_query})." 760 ) 761 if query_precursor_mzs is None: 762 query_precursor_mzs = [None] * n_query 763 elif len(query_precursor_mzs) != n_query: 764 raise ValueError( 765 f"query_precursor_mzs length ({len(query_precursor_mzs)}) must match " 766 f"query_spectra length ({n_query})." 767 ) 768 769 id_fmt = format_library_id if format_library_id is not None else str 770 771 entropy_pairs: dict[tuple[str, str], float] = {} 772 cosine_pairs: dict[tuple[str, str], float] = {} 773 query_to_lib_indices: dict[int, list[int]] = {} 774 library_size = 0 775 776 for qi, (spec, pmz) in enumerate(zip(query_spectra, query_precursor_mzs)): 777 peaks = self._peaks_array(spec) 778 if peaks.shape[0] == 0: 779 continue 780 result_vec = self._clean_and_search(peaks, pmz) 781 if result_vec is None: 782 continue 783 library_size = max(library_size, len(result_vec)) 784 785 lib_indices_for_query: list[int] = [] 786 for lib_idx, score in enumerate(result_vec): 787 if score > 0.0: 788 entropy_pairs[(query_ids[qi], id_fmt(lib_idx))] = float(score) 789 if score >= self.entropy_threshold_low: 790 lib_indices_for_query.append(lib_idx) 791 792 if lib_indices_for_query: 793 query_to_lib_indices[qi] = lib_indices_for_query 794 795 if "cosine" in self.additional_similarities and query_to_lib_indices: 796 for qi, lib_indices in query_to_lib_indices.items(): 797 cosine_scores = self._compute_cosine_for_query_vs_library( 798 query_spectrum=query_spectra[qi], 799 query_precursor_mz=query_precursor_mzs[qi], 800 library_indices=lib_indices, 801 ) 802 for lib_idx, score in cosine_scores.items(): 803 cosine_pairs[(query_ids[qi], id_fmt(lib_idx))] = score 804 805 scores: dict[str, dict[tuple[str, str], float]] = { 806 "entropy_similarity": entropy_pairs 807 } 808 if cosine_pairs: 809 scores["cosine"] = cosine_pairs 810 811 return scores, library_size
Search each query spectrum against self.fe_lib (query–library).
Computes entropy similarity for all library hits with score > 0 and,
when configured, cosine for pairs at or above
entropy_threshold_low.
Parameters
- query_spectra (list):
Spectrum objects with
.mz_expand.abundance. - query_ids (list of str): Unique ID for each query (same length as query_spectra).
- query_precursor_mzs (list of float or None, optional):
Precursor m/z per query. Required for
"identity"and"neutral_loss"; optional for"open". - format_library_id (callable, optional):
Maps library index → node ID string used in pair keys.
Default
str(bare index). Callers that need non-colliding IDs (e.g.MolecularNetwork.library_node_id) should pass a formatter.
Returns
- scores (dict):
{metric_name: {(query_id, library_id): score}}with"entropy_similarity"and optionally"cosine". - library_size (int):
Length of the FlashEntropy result vector (library size used for
registration / indexing).
0if no successful searches ran.
Raises
- RuntimeError: If
self.fe_libisNone. - ValueError: If lengths of query_ids / query_precursor_mzs do not match query_spectra.
813 def build_fe_index_from_spectra( 814 self, 815 spectra: list, 816 precursor_mzs: list[float | None] | None = None, 817 fe_kwargs: dict | None = None, 818 ): 819 """Build a FlashEntropy search index from a list of spectrum objects. 820 821 This is used to create a temporary index from query spectra so that 822 query-vs-query similarities can be computed without requiring the 823 spectra to already be in the main library. 824 825 Parameters 826 ---------- 827 spectra : list 828 Spectrum objects with ``.mz_exp`` and ``.abundance`` attributes. 829 precursor_mzs : list of float or None, optional 830 Precursor m/z for each spectrum. Used as the ``precursor_mz`` 831 field in the FlashEntropy library. Defaults to 0.0 for each. 832 fe_kwargs : dict, optional 833 Extra keyword arguments forwarded to ``FlashEntropySearch``. 834 Defaults to the same settings used for the main library. 835 836 Returns 837 ------- 838 ms_entropy.FlashEntropySearch 839 A new FlashEntropy search instance indexed on *spectra*. 840 """ 841 try: 842 from ms_entropy import FlashEntropySearch 843 except ImportError: 844 raise ImportError( 845 "ms_entropy is required for build_fe_index_from_spectra(). " 846 "Install with: pip install ms_entropy" 847 ) 848 849 if precursor_mzs is None: 850 precursor_mzs = [None] * len(spectra) 851 852 # Default FE kwargs mirror the main library settings 853 # Extract build_index parameters from the main FE library if available 854 # (these are stored as custom attributes by _build_flash_entropy_index) 855 default_fe_kwargs = { 856 "normalize_intensity": True, 857 "min_ms2_difference_in_da": self.peak_sep_da, # Already 2x tolerance from extraction 858 "max_ms2_tolerance_in_da": self.ms2_tolerance_da, 859 "max_indexed_mz": getattr(self.fe_lib, "_build_max_indexed_mz", 3000), 860 "precursor_ions_removal_da": getattr( 861 self.fe_lib, "_build_precursor_ions_removal_da", None 862 ), 863 "noise_threshold": getattr(self.fe_lib, "_build_noise_threshold", 0), 864 } 865 if fe_kwargs: 866 default_fe_kwargs.update(fe_kwargs) 867 868 # Build the spectral library list 869 spectral_library = [] 870 for i, (spec, pmz) in enumerate(zip(spectra, precursor_mzs)): 871 peaks = self._peaks_array(spec) 872 if peaks.shape[0] == 0: 873 continue 874 spectral_library.append( 875 { 876 "id": i, 877 "precursor_mz": float(pmz) if pmz is not None else 0.0, 878 "peaks": peaks.tolist(), 879 } 880 ) 881 882 # Match the FlashEntropy constructor/init and build_index signature 883 fe_init_kws = [ 884 "max_ms2_tolerance_in_da", 885 "mz_index_step", 886 "low_memory", 887 "path_data", 888 ] 889 fe_init_kws = {k: v for k, v in default_fe_kwargs.items() if k in fe_init_kws} 890 891 fe = FlashEntropySearch(**fe_init_kws) 892 893 fe_index_kws = [ 894 "max_indexed_mz", 895 "precursor_ions_removal_da", 896 "noise_threshold", 897 "min_ms2_difference_in_da", 898 "max_peak_num", 899 ] 900 fe_index_kws = {k: v for k, v in default_fe_kwargs.items() if k in fe_index_kws} 901 902 fe.build_index(spectral_library, **fe_index_kws, clean_spectra=True) 903 return fe
Build a FlashEntropy search index from a list of spectrum objects.
This is used to create a temporary index from query spectra so that query-vs-query similarities can be computed without requiring the spectra to already be in the main library.
Parameters
- spectra (list):
Spectrum objects with
.mz_expand.abundanceattributes. - precursor_mzs (list of float or None, optional):
Precursor m/z for each spectrum. Used as the
precursor_mzfield in the FlashEntropy library. Defaults to 0.0 for each. - fe_kwargs (dict, optional):
Extra keyword arguments forwarded to
FlashEntropySearch. Defaults to the same settings used for the main library.
Returns
- ms_entropy.FlashEntropySearch: A new FlashEntropy search instance indexed on spectra.
905 def compute_library_vs_library_filtered( 906 self, 907 library_indices: list[int], 908 spectrum_ids: list[str], 909 precursor_mzs: list[float | None] | None = None, 910 ) -> dict[str, dict[tuple[str, str], float]]: 911 """Compute library-vs-library similarities for a filtered subset of library spectra. 912 913 Extracts the spectra at *library_indices* from ``self.fe_lib``, builds a 914 temporary FlashEntropy index from them, and computes all-vs-all pairwise 915 similarities within that subset. 916 917 Parameters 918 ---------- 919 library_indices : list of int 920 Indices into ``self.fe_lib`` of the library spectra to include. 921 spectrum_ids : list of str 922 IDs to assign to each library spectrum (must be same length as 923 *library_indices*). 924 precursor_mzs : list of float or None, optional 925 Precursor m/z for each library spectrum. If None, attempts to read 926 from ``self.fe_lib`` (attribute ``precursor_mz`` on each entry), or 927 defaults to 0.0. 928 929 Returns 930 ------- 931 dict 932 ``{metric_name: {(id1, id2): score}}`` 933 All-vs-all pairs within the filtered library subset. 934 """ 935 n = len(library_indices) 936 if n == 0: 937 return {} 938 939 if self.fe_lib is None: 940 raise RuntimeError( 941 "compute_library_vs_library_filtered requires a reference FE library " 942 "(fe_lib)." 943 ) 944 945 if len(spectrum_ids) != n: 946 raise ValueError( 947 f"spectrum_ids length ({len(spectrum_ids)}) must match " 948 f"library_indices length ({n})." 949 ) 950 if precursor_mzs is not None and len(precursor_mzs) != n: 951 raise ValueError( 952 f"precursor_mzs length ({len(precursor_mzs)}) must match " 953 f"library_indices length ({n})." 954 ) 955 956 # Build lightweight spectrum objects from the raw library entries 957 class _LibSpec: 958 __slots__ = ("mz_exp", "abundance") 959 960 def __init__(self, peaks_arr): 961 self.mz_exp = peaks_arr[:, 0] 962 self.abundance = peaks_arr[:, 1] 963 964 lib_spectra: list = [] 965 lib_precursor_mzs: list[float | None] = [] 966 # IDs for successfully extracted entries only (same order as lib_spectra) 967 valid_ids: list[str] = [] 968 969 for i, idx in enumerate(library_indices): 970 try: 971 entry = self.fe_lib[idx] 972 except (IndexError, KeyError, TypeError) as exc: 973 raise ValueError(f"Cannot read library entry at index {idx}.") from exc 974 975 if not isinstance(entry, dict): 976 raise TypeError( 977 f"Library entry at index {idx} must be a dict; " 978 f"got {type(entry).__name__}." 979 ) 980 981 peaks = np.asarray(entry.get("peaks", []), dtype=float) 982 # Skip empty/invalid peak tables but keep spectrum_ids/precursors 983 # aligned to the original index *i* for survivors (not first-N trim). 984 if peaks.ndim != 2 or peaks.shape[1] < 2 or peaks.shape[0] == 0: 985 continue 986 987 lib_spectra.append(_LibSpec(peaks)) 988 valid_ids.append(spectrum_ids[i]) 989 if precursor_mzs is not None: 990 lib_precursor_mzs.append(precursor_mzs[i]) 991 else: 992 lib_precursor_mzs.append(float(entry.get("precursor_mz", 0.0) or 0.0)) 993 994 if not lib_spectra: 995 return {} 996 997 # Build a temporary FE index from the filtered library spectra 998 temp_fe = self.build_fe_index_from_spectra( 999 spectra=lib_spectra, 1000 precursor_mzs=lib_precursor_mzs, 1001 ) 1002 1003 # Compute all-vs-all using the temporary index 1004 return self.compute_all_vs_all_with_lib( 1005 spectra=lib_spectra, 1006 spectrum_ids=valid_ids, 1007 precursor_mzs=lib_precursor_mzs, 1008 fe_lib_override=temp_fe, 1009 )
Compute library-vs-library similarities for a filtered subset of library spectra.
Extracts the spectra at library_indices from self.fe_lib, builds a
temporary FlashEntropy index from them, and computes all-vs-all pairwise
similarities within that subset.
Parameters
- library_indices (list of int):
Indices into
self.fe_libof the library spectra to include. - spectrum_ids (list of str): IDs to assign to each library spectrum (must be same length as library_indices).
- precursor_mzs (list of float or None, optional):
Precursor m/z for each library spectrum. If None, attempts to read
from
self.fe_lib(attributeprecursor_mzon each entry), or defaults to 0.0.
Returns
- dict:
{metric_name: {(id1, id2): score}}All-vs-all pairs within the filtered library subset.
1011 def compute_all_vs_all_with_lib( 1012 self, 1013 spectra: list, 1014 spectrum_ids: list[str], 1015 precursor_mzs: list[float | None] | None = None, 1016 fe_lib_override=None, 1017 ) -> dict[str, dict[tuple[str, str], float]]: 1018 """Compute all-vs-all pairwise similarities using a provided FE library. 1019 1020 Canonical all-vs-all path: each spectrum is searched once against the 1021 FlashEntropy index (*fe_lib_override* or ``self.fe_lib``), and 1022 upper-triangle pairwise scores are extracted from the result vectors. 1023 1024 Parameters 1025 ---------- 1026 spectra : list 1027 Spectrum objects with .mz_exp and .abundance attributes. 1028 spectrum_ids : list of str 1029 User-provided IDs, one per spectrum. 1030 precursor_mzs : list of float or None, optional 1031 Precursor m/z for each spectrum. 1032 fe_lib_override : ms_entropy.FlashEntropySearch, optional 1033 FlashEntropy library to search against. If None, falls back to 1034 ``self.fe_lib``. 1035 1036 Returns 1037 ------- 1038 dict 1039 ``{metric_name: {(id1, id2): score}}`` 1040 """ 1041 n = len(spectra) 1042 if n == 0: 1043 return {} 1044 1045 if precursor_mzs is None: 1046 precursor_mzs = [None] * n 1047 1048 # Library indices are simply 0..n-1 (positions in fe_lib_override) 1049 lib_indices = list(range(n)) 1050 1051 # ── Stage 1: Compute entropy similarity matrix using FE search ──────── 1052 entropy_pairs, pairs_for_additional = self._compute_entropy_matrix_and_pairs( 1053 spectra, spectrum_ids, precursor_mzs, fe_lib_override 1054 ) 1055 1056 result: dict[str, dict[tuple[str, str], float]] = { 1057 "entropy_similarity": entropy_pairs 1058 } 1059 1060 # ── Stage 2: Compute additional metrics for pairs above threshold ──── 1061 fe = fe_lib_override if fe_lib_override is not None else self.fe_lib 1062 for metric in self.additional_similarities: 1063 if metric == "cosine": 1064 cosine_idx_scores = self._compute_cosine_for_pairs( 1065 pairs_for_additional, 1066 lib_indices, 1067 fe_lib_override=fe, 1068 ) 1069 result["cosine"] = { 1070 (spectrum_ids[i], spectrum_ids[j]): score 1071 for (i, j), score in cosine_idx_scores.items() 1072 } 1073 1074 return result
Compute all-vs-all pairwise similarities using a provided FE library.
Canonical all-vs-all path: each spectrum is searched once against the
FlashEntropy index (fe_lib_override or self.fe_lib), and
upper-triangle pairwise scores are extracted from the result vectors.
Parameters
- spectra (list): Spectrum objects with .mz_exp and .abundance attributes.
- spectrum_ids (list of str): User-provided IDs, one per spectrum.
- precursor_mzs (list of float or None, optional): Precursor m/z for each spectrum.
- fe_lib_override (ms_entropy.FlashEntropySearch, optional):
FlashEntropy library to search against. If None, falls back to
self.fe_lib.
Returns
- dict:
{metric_name: {(id1, id2): score}}
40class NetworkVisualizeMixin: 41 """Mixin that adds static and interactive network plotting.""" 42 43 _DEFAULT_COLOR_MAP = { 44 "query": "#e74c3c", 45 "library": "#3498db", 46 "unknown": "#95a5a6", 47 } 48 49 _DEFAULT_LIBRARY_LABEL_FIELDS = ("compound_name", "name", "spectra_id", "id") 50 51 def _library_record_for_node(self, node_id: str) -> dict[str, Any] | None: 52 """Return FE library record dict for a library node ID, if available.""" 53 fe_lib = getattr(self, "fe_lib", None) 54 if fe_lib is None: 55 return None 56 57 parse_idx = getattr(self, "library_index_from_node_id", None) 58 if callable(parse_idx): 59 lib_idx = parse_idx(node_id) 60 else: 61 try: 62 lib_idx = int(node_id) 63 except (TypeError, ValueError): 64 lib_idx = None 65 if lib_idx is None: 66 return None 67 68 try: 69 record = fe_lib[lib_idx] 70 except (IndexError, KeyError, TypeError): 71 return None 72 73 return record if isinstance(record, dict) else None 74 75 @staticmethod 76 def _as_node_attr_value(value: Any) -> Any: 77 """Convert values to JSON-friendly node attributes for Sigma hover panels.""" 78 if value is None or isinstance(value, (str, int, float, bool)): 79 return value 80 return str(value) 81 82 def _resolve_library_label( 83 self, 84 node_id: str, 85 record: dict[str, Any] | None, 86 library_label_field: str | Sequence[str] | None, 87 ) -> str: 88 """Resolve display label for a library node from FE record fields.""" 89 fallback = node_id[:20] + "..." if len(node_id) > 20 else node_id 90 if not record: 91 return fallback 92 93 if library_label_field is None: 94 candidate_fields: Sequence[str] = self._DEFAULT_LIBRARY_LABEL_FIELDS 95 elif isinstance(library_label_field, str): 96 candidate_fields = (library_label_field,) 97 else: 98 candidate_fields = library_label_field 99 100 for field in candidate_fields: 101 value = record.get(field) 102 if value is None: 103 continue 104 value_str = str(value).strip() 105 if value_str: 106 return value_str 107 108 return fallback 109 110 def _edges_to_dataframe( 111 self, 112 metric: str, 113 score_threshold: float, 114 include_queries_only: bool, 115 exclude_self: bool, 116 ) -> pd.DataFrame: 117 """Return filtered edge list as a DataFrame with id1/id2/score.""" 118 mat = self.similarity_matrices[metric] 119 all_edges = mat.get_pairs_above_threshold(0.0) 120 121 if not all_edges: 122 return pd.DataFrame(columns=["id1", "id2", "score"]) 123 124 edges = pd.DataFrame(all_edges, columns=["id1", "id2", "score"]) 125 edges = edges[edges["score"] >= score_threshold] 126 127 if exclude_self: 128 edges = edges[edges["id1"] != edges["id2"]] 129 130 if include_queries_only and self._all_query_ids: 131 query_id_set = set(self._all_query_ids) 132 133 # Keep all query edges plus library-library edges where both 134 # library nodes are connected to at least one query node. 135 q1 = edges["id1"].isin(query_id_set) 136 q2 = edges["id2"].isin(query_id_set) 137 ql_or_qq = q1 | q2 138 139 query_edges = edges[ql_or_qq] 140 if query_edges.empty: 141 return pd.DataFrame(columns=["id1", "id2", "score"]) 142 143 lib_from_id1 = query_edges.loc[ 144 ~query_edges["id1"].isin(query_id_set), "id1" 145 ] 146 lib_from_id2 = query_edges.loc[ 147 ~query_edges["id2"].isin(query_id_set), "id2" 148 ] 149 query_connected_libs = set(pd.concat([lib_from_id1, lib_from_id2]).tolist()) 150 151 ll_mask = ( 152 ~q1 153 & ~q2 154 & edges["id1"].isin(query_connected_libs) 155 & edges["id2"].isin(query_connected_libs) 156 ) 157 158 edges = pd.concat( 159 [query_edges, edges[ll_mask]], ignore_index=True 160 ).drop_duplicates() 161 162 return edges 163 164 @staticmethod 165 def _sparsify_top_k_edges(G, weight_attr: str, top_k: int): 166 """Return an undirected top-k edge sparsified copy of *G*.""" 167 if top_k <= 0 or G.number_of_nodes() == 0: 168 return G.copy() 169 170 H = G.to_undirected().copy() 171 selected_edges: set[tuple[str, str]] = set() 172 173 for node in H.nodes: 174 neighbors = [] 175 for nbr in H.neighbors(node): 176 edge_data = H.get_edge_data(node, nbr, default={}) 177 weight = float(edge_data.get(weight_attr, 1.0)) 178 neighbors.append((nbr, weight)) 179 180 neighbors.sort(key=lambda t: (-t[1], str(t[0]))) 181 for nbr, _ in neighbors[:top_k]: 182 edge_key = tuple(sorted((str(node), str(nbr)))) 183 selected_edges.add(edge_key) 184 185 S = H.__class__() 186 S.add_nodes_from(H.nodes(data=True)) 187 for u, v, data in H.edges(data=True): 188 edge_key = tuple(sorted((str(u), str(v)))) 189 if edge_key in selected_edges: 190 S.add_edge(u, v, **data) 191 192 return S 193 194 def _detect_weighted_communities( 195 self, 196 G, 197 *, 198 weight_attr: str, 199 super_threshold: int, 200 sparsify_top_k: int, 201 recursive_split: bool, 202 ) -> list[set]: 203 """Detect weighted communities with optional recursive splitting.""" 204 nx = _import_networkx() 205 206 def partition_graph(graph, depth: int = 0) -> list[set]: 207 if graph.number_of_nodes() <= 1: 208 return [set(graph.nodes)] 209 210 communities = list( 211 nx.community.greedy_modularity_communities( 212 graph, 213 weight=weight_attr, 214 ) 215 ) 216 if len(communities) <= 1: 217 return [set(graph.nodes)] 218 219 sorted_communities = sorted( 220 communities, key=lambda c: (-len(c), min(str(n) for n in c)) 221 ) 222 out: list[set] = [] 223 224 for community in sorted_communities: 225 community_set = set(community) 226 if not recursive_split or len(community_set) <= super_threshold: 227 out.append(community_set) 228 continue 229 230 sub = graph.subgraph(community_set).copy() 231 if sparsify_top_k > 0: 232 sub = self._sparsify_top_k_edges( 233 sub, weight_attr=weight_attr, top_k=sparsify_top_k 234 ) 235 236 if sub.number_of_edges() == 0: 237 out.append(community_set) 238 continue 239 240 sub_parts = partition_graph(sub, depth + 1) 241 if len(sub_parts) <= 1: 242 out.append(community_set) 243 continue 244 245 for part in sub_parts: 246 if part: 247 out.append(set(part)) 248 249 return out 250 251 raw = partition_graph(G.to_undirected()) 252 normalized = [set(c) for c in raw if c] 253 if not normalized: 254 return [set(G.nodes)] 255 256 return sorted(normalized, key=lambda c: (-len(c), min(str(n) for n in c))) 257 258 @staticmethod 259 def _compute_hierarchical_layout( 260 G, communities: list[set], *, weight_attr: str, seed: int 261 ): 262 """Compute two-level (community + local) weighted spring layout.""" 263 nx = _import_networkx() 264 if G.number_of_nodes() == 0: 265 return {} 266 267 if not communities: 268 pos = nx.spring_layout( 269 G, 270 seed=seed, 271 weight=weight_attr, 272 k=2.0 / max(G.number_of_nodes() ** 0.5, 1), 273 ) 274 return {n: {"x": float(x), "y": float(y)} for n, (x, y) in pos.items()} 275 276 node_to_comm = {} 277 for comm_idx, nodes in enumerate(communities): 278 for node in nodes: 279 node_to_comm[node] = comm_idx 280 281 meta = nx.Graph() 282 for comm_idx, nodes in enumerate(communities): 283 meta.add_node(comm_idx, size=len(nodes)) 284 285 inter_weights: dict[tuple[int, int], float] = defaultdict(float) 286 for u, v, data in G.edges(data=True): 287 cu = node_to_comm.get(u) 288 cv = node_to_comm.get(v) 289 if cu is None or cv is None or cu == cv: 290 continue 291 edge = (cu, cv) if cu < cv else (cv, cu) 292 inter_weights[edge] += float(data.get(weight_attr, 1.0)) 293 294 for (cu, cv), w in inter_weights.items(): 295 meta.add_edge(cu, cv, weight=w) 296 297 # Build local layouts first to estimate community footprint radius. 298 local_layouts: dict[int, dict] = {} 299 community_radius: dict[int, float] = {} 300 for comm_idx, nodes in enumerate(communities): 301 sub = G.subgraph(nodes) 302 if sub.number_of_nodes() == 1: 303 only = next(iter(sub.nodes)) 304 local_pos = {only: (0.0, 0.0)} 305 else: 306 local_pos = nx.spring_layout( 307 sub, 308 seed=seed, 309 weight=weight_attr, 310 k=1.5 / max(sub.number_of_nodes() ** 0.5, 1), 311 ) 312 313 max_radius = 0.0 314 for x, y in local_pos.values(): 315 max_radius = max(max_radius, float((x * x + y * y) ** 0.5)) 316 if max_radius <= 0: 317 max_radius = 1.0 318 319 radius = 0.6 + 0.18 * (len(nodes) ** 0.5) 320 local_layouts[comm_idx] = local_pos 321 community_radius[comm_idx] = radius 322 323 if meta.number_of_nodes() == 1: 324 meta_pos = {0: (0.0, 0.0)} 325 else: 326 # Larger k spreads community centers to reduce overlap. 327 meta_pos = nx.spring_layout( 328 meta, 329 seed=seed, 330 weight="weight", 331 k=6.0 / max(meta.number_of_nodes() ** 0.5, 1), 332 ) 333 334 # Apply pairwise separation pass so center distances exceed 335 # footprint-based minimum spacing. 336 meta_xy = {idx: [float(x), float(y)] for idx, (x, y) in meta_pos.items()} 337 comm_ids = list(meta_xy.keys()) 338 for _ in range(8): 339 moved = False 340 for i in range(len(comm_ids)): 341 ci = comm_ids[i] 342 xi, yi = meta_xy[ci] 343 for j in range(i + 1, len(comm_ids)): 344 cj = comm_ids[j] 345 xj, yj = meta_xy[cj] 346 dx = xj - xi 347 dy = yj - yi 348 dist = (dx * dx + dy * dy) ** 0.5 349 min_dist = community_radius[ci] + community_radius[cj] + 0.8 350 if dist < min_dist: 351 moved = True 352 if dist <= 1e-9: 353 ux, uy = 1.0, 0.0 354 else: 355 ux, uy = dx / dist, dy / dist 356 push = 0.5 * (min_dist - max(dist, 1e-9)) 357 meta_xy[ci][0] -= ux * push 358 meta_xy[ci][1] -= uy * push 359 meta_xy[cj][0] += ux * push 360 meta_xy[cj][1] += uy * push 361 if not moved: 362 break 363 364 final_pos: dict = {} 365 for comm_idx, nodes in enumerate(communities): 366 local_pos = local_layouts[comm_idx] 367 max_radius = 0.0 368 for x, y in local_pos.values(): 369 max_radius = max(max_radius, float((x * x + y * y) ** 0.5)) 370 if max_radius <= 0: 371 max_radius = 1.0 372 373 cx, cy = meta_xy.get(comm_idx, [0.0, 0.0]) 374 radius = community_radius[comm_idx] 375 for node, (x, y) in local_pos.items(): 376 final_pos[node] = { 377 "x": float(cx + radius * (x / max_radius)), 378 "y": float(cy + radius * (y / max_radius)), 379 } 380 381 return final_pos 382 383 def _ensure_cluster_cache(self) -> dict[str, dict]: 384 """Return mutable in-memory cluster-artifact cache. 385 386 The cache is keyed by metric name and stores artifacts generated by 387 :meth:`compute_network_clusters` (tables, parameters, and layout). 388 """ 389 if not hasattr(self, "_network_clusters"): 390 self._network_clusters = {} 391 return self._network_clusters 392 393 def _export_cluster_node_id(self, node_id: str, query_id_set: set[str]) -> str: 394 """Return export-safe node ID for cluster file output. 395 396 Query IDs are preserved. Library IDs are mapped through 397 ``MolecularNetwork._export_node_id`` when present, so FlashEntropy 398 internal indices can be exported as stable library identifiers (for 399 example ``spectra_id``). 400 """ 401 export_fn = getattr(self, "_export_node_id", None) 402 if callable(export_fn): 403 try: 404 return str(export_fn(node_id, query_id_set)) 405 except (TypeError, ValueError, KeyError, IndexError): 406 pass 407 return str(node_id) 408 409 @staticmethod 410 def _apply_query_connectivity_filters( 411 G, 412 *, 413 nx, 414 query_id_set: set, 415 drop_components_without_queries: bool, 416 drop_nodes_without_query_connection: bool, 417 ) -> None: 418 """Apply query-connectivity pruning in-place on graph ``G``. 419 420 Parameters 421 ---------- 422 G 423 NetworkX graph to mutate. 424 nx 425 Imported ``networkx`` module, passed in to avoid repeated imports. 426 query_id_set : set 427 Set of query node IDs. 428 drop_components_without_queries : bool 429 Drop connected components that contain no query nodes. 430 drop_nodes_without_query_connection : bool 431 Keep only query nodes and one-hop neighbors of query nodes. 432 """ 433 if drop_components_without_queries and G.number_of_nodes() > 0: 434 if G.is_directed(): 435 components = list(nx.weakly_connected_components(G)) 436 else: 437 components = list(nx.connected_components(G)) 438 439 nodes_to_remove = [] 440 for component in components: 441 if component.isdisjoint(query_id_set): 442 nodes_to_remove.extend(component) 443 444 if nodes_to_remove: 445 G.remove_nodes_from(nodes_to_remove) 446 447 if ( 448 drop_nodes_without_query_connection 449 and G.number_of_nodes() > 0 450 and query_id_set 451 ): 452 query_nodes_in_graph = [n for n in query_id_set if G.has_node(n)] 453 if query_nodes_in_graph: 454 keep_nodes = set(query_nodes_in_graph) 455 for qnode in query_nodes_in_graph: 456 keep_nodes.update(G.neighbors(qnode)) 457 458 if len(keep_nodes) < G.number_of_nodes(): 459 nodes_to_remove = [n for n in G.nodes if n not in keep_nodes] 460 G.remove_nodes_from(nodes_to_remove) 461 462 def compute_network_clusters( 463 self, 464 metric: str = "entropy_similarity", 465 *, 466 include_queries_only: bool = True, 467 score_threshold: float | None = None, 468 max_edges: int | None = 500, 469 max_nodes: int | None = None, 470 exclude_self: bool = True, 471 directed: bool = False, 472 drop_components_without_queries: bool = True, 473 drop_nodes_without_query_connection: bool = True, 474 cluster_method: str = "weighted_greedy_modularity", 475 cluster_weight_attr: str = "score", 476 cluster_super_threshold: int = 400, 477 cluster_sparsify_top_k: int = 8, 478 cluster_recursive_split: bool = True, 479 cluster_min_size: int = 2, 480 compute_layout: bool = True, 481 layout_seed: int = 42, 482 ) -> dict[str, Any]: 483 """Compute and cache clustering artifacts for one similarity metric. 484 485 This stage is separate from plotting. It builds a graph from the 486 selected similarity matrix, applies query-connectivity filters, 487 computes communities, and stores node/community/edge/layout tables in 488 an in-memory artifact cache for later plotting or export. 489 490 Parameters 491 ---------- 492 metric : str 493 Similarity metric key from ``self.similarity_matrices``. 494 include_queries_only : bool 495 Use query-focused edge filtering during edge extraction. 496 score_threshold : float or None 497 Minimum similarity score to include; ``None`` uses 498 ``_threshold_for(metric)``. 499 max_edges : int or None 500 Keep top-N edges by score after filtering. 501 max_nodes : int or None 502 Keep edges whose endpoints are within highest-degree N nodes. 503 exclude_self : bool 504 Drop self-loop edges. 505 directed : bool 506 Build directed graph when ``True``. 507 drop_components_without_queries : bool 508 Remove connected components with no query nodes. 509 drop_nodes_without_query_connection : bool 510 Keep only query nodes and one-hop query neighbors. 511 cluster_method : str 512 Community method; currently ``"weighted_greedy_modularity"``. 513 cluster_weight_attr : str 514 Edge attribute used as clustering weight. 515 cluster_super_threshold : int 516 Threshold above which recursive splitting can apply. 517 cluster_sparsify_top_k : int 518 Top-k edge sparsification for oversized community refinement. 519 cluster_recursive_split : bool 520 Enable recursive refinement of oversized communities. 521 cluster_min_size : int 522 Reserved for future use; kept for API stability. 523 compute_layout : bool 524 Compute and cache hierarchical node coordinates. 525 layout_seed : int 526 Random seed for deterministic layout generation. 527 528 Returns 529 ------- 530 dict 531 Summary with ``metric``, ``n_nodes``, ``n_edges``, ``n_clusters``, 532 and ``score_threshold``. 533 534 Raises 535 ------ 536 KeyError 537 If *metric* is unknown. 538 ValueError 539 If *cluster_method* is unsupported. 540 """ 541 if metric not in self.similarity_matrices: 542 raise KeyError( 543 f"Unknown metric '{metric}'. Available: {list(self.similarity_matrices.keys())}" 544 ) 545 546 if cluster_method != "weighted_greedy_modularity": 547 raise ValueError( 548 "Unsupported cluster_method. " 549 "Currently supported: ['weighted_greedy_modularity']" 550 ) 551 552 nx = _import_networkx() 553 554 if score_threshold is None: 555 score_threshold = self._threshold_for(metric) 556 557 edges = self._edges_to_dataframe( 558 metric=metric, 559 score_threshold=score_threshold, 560 include_queries_only=include_queries_only, 561 exclude_self=exclude_self, 562 ) 563 564 if max_nodes is not None and not edges.empty: 565 degree = ( 566 pd.concat([edges["id1"], edges["id2"]]).value_counts().head(max_nodes) 567 ) 568 top_nodes = set(degree.index) 569 edges = edges[edges["id1"].isin(top_nodes) & edges["id2"].isin(top_nodes)] 570 571 if max_edges is not None and not edges.empty: 572 edges = edges.nlargest(max_edges, "score") 573 574 G = nx.DiGraph() if directed else nx.Graph() 575 query_id_set = set(self._all_query_ids) 576 577 if not edges.empty: 578 node_ids = ( 579 pd.concat([edges["id1"], edges["id2"]]).drop_duplicates().tolist() 580 ) 581 for node_id in node_ids: 582 G.add_node( 583 node_id, 584 node_type=("query" if node_id in query_id_set else "library"), 585 ) 586 for row in edges.itertuples(index=False): 587 G.add_edge(row.id1, row.id2, score=float(row.score)) 588 589 self._apply_query_connectivity_filters( 590 G, 591 nx=nx, 592 query_id_set=query_id_set, 593 drop_components_without_queries=drop_components_without_queries, 594 drop_nodes_without_query_connection=drop_nodes_without_query_connection, 595 ) 596 597 communities: list[set] = [] 598 if G.number_of_nodes() > 0: 599 communities = self._detect_weighted_communities( 600 G.to_undirected(), 601 weight_attr=cluster_weight_attr, 602 super_threshold=cluster_super_threshold, 603 sparsify_top_k=cluster_sparsify_top_k, 604 recursive_split=cluster_recursive_split, 605 ) 606 607 node_rows = [] 608 community_rows = [] 609 node_to_cluster: dict[str, str] = {} 610 611 for comm_idx, nodes in enumerate(communities): 612 cluster_id = f"C{comm_idx:03d}" 613 query_count = sum(1 for n in nodes if n in query_id_set) 614 community_rows.append( 615 { 616 "cluster_id": cluster_id, 617 "community_level": 0, 618 "size": len(nodes), 619 "query_count_in_community": query_count, 620 } 621 ) 622 for node in sorted(nodes, key=str): 623 node_to_cluster[str(node)] = cluster_id 624 node_rows.append( 625 { 626 "node_id": str(node), 627 "cluster_id": cluster_id, 628 "community_level": 0, 629 "community_size": len(nodes), 630 "query_count_in_community": query_count, 631 "node_type": G.nodes[node].get("node_type", "unknown"), 632 } 633 ) 634 635 # Ensure isolated nodes still receive a cluster assignment. 636 if G.number_of_nodes() > 0: 637 for node in G.nodes: 638 node_s = str(node) 639 if node_s in node_to_cluster: 640 continue 641 cluster_id = f"C{len(community_rows):03d}" 642 node_to_cluster[node_s] = cluster_id 643 query_count = 1 if node in query_id_set else 0 644 community_rows.append( 645 { 646 "cluster_id": cluster_id, 647 "community_level": 0, 648 "size": 1, 649 "query_count_in_community": query_count, 650 } 651 ) 652 node_rows.append( 653 { 654 "node_id": node_s, 655 "cluster_id": cluster_id, 656 "community_level": 0, 657 "community_size": 1, 658 "query_count_in_community": query_count, 659 "node_type": G.nodes[node].get("node_type", "unknown"), 660 } 661 ) 662 663 edge_rows = [] 664 for u, v, data in G.edges(data=True): 665 u_s = str(u) 666 v_s = str(v) 667 edge_rows.append( 668 { 669 "id1": u_s, 670 "id2": v_s, 671 "score": float(data.get("score", 0.0)), 672 "cluster_id_1": node_to_cluster.get(u_s), 673 "cluster_id_2": node_to_cluster.get(v_s), 674 "intra_cluster": node_to_cluster.get(u_s) 675 == node_to_cluster.get(v_s), 676 } 677 ) 678 679 layout_df = pd.DataFrame(columns=["node_id", "x", "y"]) 680 if compute_layout and G.number_of_nodes() > 0: 681 layout = self._compute_hierarchical_layout( 682 G, 683 communities, 684 weight_attr=cluster_weight_attr, 685 seed=layout_seed, 686 ) 687 layout_df = pd.DataFrame( 688 [ 689 {"node_id": str(node_id), "x": coords["x"], "y": coords["y"]} 690 for node_id, coords in layout.items() 691 ] 692 ) 693 694 artifact = { 695 "metric": metric, 696 "params": { 697 "include_queries_only": include_queries_only, 698 "score_threshold": score_threshold, 699 "max_edges": max_edges, 700 "max_nodes": max_nodes, 701 "exclude_self": exclude_self, 702 "directed": directed, 703 "drop_components_without_queries": drop_components_without_queries, 704 "drop_nodes_without_query_connection": drop_nodes_without_query_connection, 705 "cluster_method": cluster_method, 706 "cluster_weight_attr": cluster_weight_attr, 707 "cluster_super_threshold": cluster_super_threshold, 708 "cluster_sparsify_top_k": cluster_sparsify_top_k, 709 "cluster_recursive_split": cluster_recursive_split, 710 "cluster_min_size": cluster_min_size, 711 "compute_layout": compute_layout, 712 "layout_seed": layout_seed, 713 }, 714 "node_table": pd.DataFrame(node_rows), 715 "community_table": pd.DataFrame(community_rows), 716 "edge_table": pd.DataFrame(edge_rows), 717 "layout_table": layout_df, 718 "schema_version": 1, 719 } 720 721 self._ensure_cluster_cache()[metric] = artifact 722 723 return { 724 "metric": metric, 725 "n_nodes": G.number_of_nodes(), 726 "n_edges": G.number_of_edges(), 727 "n_clusters": int(artifact["community_table"].shape[0]), 728 "score_threshold": float(score_threshold), 729 } 730 731 def drop_network_clusters(self, metric: str | None = None) -> None: 732 """Drop cached clustering artifacts while preserving similarity results. 733 734 Parameters 735 ---------- 736 metric : str or None 737 Metric cache key to remove. When ``None``, clear all cached 738 clustering artifacts. 739 """ 740 cache = self._ensure_cluster_cache() 741 if metric is None: 742 cache.clear() 743 else: 744 cache.pop(metric, None) 745 746 def save_network_clusters( 747 self, output_dir: str, metric: str, run_id: str | None = None 748 ) -> dict[str, str]: 749 """Persist computed cluster artifacts to CSV files. 750 751 Files written include node, community, edge, layout, and manifest CSVs. 752 Library node IDs are exported using stable identifiers when available 753 (for example ``spectra_id``) instead of internal index IDs. 754 755 Parameters 756 ---------- 757 output_dir : str 758 Output directory for CSV artifacts. 759 metric : str 760 Metric cache key to persist. 761 run_id : str or None 762 Optional suffix to disambiguate multiple runs. 763 764 Returns 765 ------- 766 dict of str to str 767 Paths for ``nodes``, ``communities``, ``edges``, ``layout``, 768 and ``manifest`` files. 769 770 Raises 771 ------ 772 RuntimeError 773 If no cached clusters exist for *metric*. 774 """ 775 cache = self._ensure_cluster_cache() 776 artifact = cache.get(metric) 777 if artifact is None: 778 raise RuntimeError( 779 f"No clusters available for metric '{metric}'. Call compute_network_clusters(...) first." 780 ) 781 782 out_dir = Path(output_dir) 783 out_dir.mkdir(parents=True, exist_ok=True) 784 785 suffix = f"_{run_id}" if run_id else "" 786 prefix = f"{metric}_clusters{suffix}" 787 788 node_path = out_dir / f"{prefix}_nodes.csv" 789 community_path = out_dir / f"{prefix}_communities.csv" 790 edge_path = out_dir / f"{prefix}_edges.csv" 791 manifest_path = out_dir / f"{prefix}_manifest.csv" 792 layout_path = out_dir / f"{prefix}_layout.csv" 793 794 query_id_set = set(getattr(self, "_all_query_ids", []) or []) 795 796 node_export_df = artifact["node_table"].copy() 797 if "node_id" in node_export_df.columns: 798 node_export_df["node_id"] = ( 799 node_export_df["node_id"] 800 .astype(str) 801 .map( 802 lambda node_id: self._export_cluster_node_id(node_id, query_id_set) 803 ) 804 ) 805 806 edge_export_df = artifact["edge_table"].copy() 807 if "id1" in edge_export_df.columns: 808 edge_export_df["id1"] = ( 809 edge_export_df["id1"] 810 .astype(str) 811 .map( 812 lambda node_id: self._export_cluster_node_id(node_id, query_id_set) 813 ) 814 ) 815 if "id2" in edge_export_df.columns: 816 edge_export_df["id2"] = ( 817 edge_export_df["id2"] 818 .astype(str) 819 .map( 820 lambda node_id: self._export_cluster_node_id(node_id, query_id_set) 821 ) 822 ) 823 824 layout_export_df = artifact["layout_table"].copy() 825 if "node_id" in layout_export_df.columns: 826 layout_export_df["node_id"] = ( 827 layout_export_df["node_id"] 828 .astype(str) 829 .map( 830 lambda node_id: self._export_cluster_node_id(node_id, query_id_set) 831 ) 832 ) 833 834 node_export_df.to_csv(node_path, index=False) 835 artifact["community_table"].to_csv(community_path, index=False) 836 edge_export_df.to_csv(edge_path, index=False) 837 layout_export_df.to_csv(layout_path, index=False) 838 839 pd.DataFrame( 840 [ 841 { 842 "schema_version": artifact.get("schema_version", 1), 843 "metric": metric, 844 "run_id": run_id or "", 845 "params_json": json.dumps( 846 artifact.get("params", {}), sort_keys=True 847 ), 848 } 849 ] 850 ).to_csv(manifest_path, index=False) 851 852 return { 853 "nodes": str(node_path), 854 "communities": str(community_path), 855 "edges": str(edge_path), 856 "layout": str(layout_path), 857 "manifest": str(manifest_path), 858 } 859 860 def load_network_clusters( 861 self, output_dir: str, metric: str, run_id: str | None = None 862 ) -> dict[str, Any]: 863 """Load cluster artifacts from CSV files into in-memory cache. 864 865 Parameters 866 ---------- 867 output_dir : str 868 Directory containing saved cluster CSV artifacts. 869 metric : str 870 Metric key to load. 871 run_id : str or None 872 Optional run suffix used when saving. 873 874 Returns 875 ------- 876 dict 877 Summary with ``metric``, ``n_nodes``, ``n_edges``, and 878 ``n_clusters``. 879 880 Raises 881 ------ 882 FileNotFoundError 883 If required artifact files are missing. 884 """ 885 out_dir = Path(output_dir) 886 suffix = f"_{run_id}" if run_id else "" 887 prefix = f"{metric}_clusters{suffix}" 888 889 node_path = out_dir / f"{prefix}_nodes.csv" 890 community_path = out_dir / f"{prefix}_communities.csv" 891 edge_path = out_dir / f"{prefix}_edges.csv" 892 manifest_path = out_dir / f"{prefix}_manifest.csv" 893 layout_path = out_dir / f"{prefix}_layout.csv" 894 895 if ( 896 not node_path.exists() 897 or not community_path.exists() 898 or not edge_path.exists() 899 ): 900 raise FileNotFoundError( 901 f"Missing cluster artifact files for metric '{metric}' in '{output_dir}'." 902 ) 903 904 params = {} 905 schema_version = 1 906 if manifest_path.exists(): 907 mdf = pd.read_csv(manifest_path) 908 if not mdf.empty: 909 schema_version = int(mdf.iloc[0].get("schema_version", 1)) 910 params_json = mdf.iloc[0].get("params_json", "{}") 911 try: 912 params = ( 913 json.loads(params_json) if isinstance(params_json, str) else {} 914 ) 915 except (json.JSONDecodeError, TypeError, ValueError): 916 params = {} 917 918 artifact = { 919 "metric": metric, 920 "params": params, 921 "schema_version": schema_version, 922 "node_table": pd.read_csv(node_path), 923 "community_table": pd.read_csv(community_path), 924 "edge_table": pd.read_csv(edge_path), 925 "layout_table": pd.read_csv(layout_path) 926 if layout_path.exists() 927 else pd.DataFrame(columns=["node_id", "x", "y"]), 928 } 929 930 self._ensure_cluster_cache()[metric] = artifact 931 return { 932 "metric": metric, 933 "n_nodes": int(artifact["node_table"].shape[0]), 934 "n_edges": int(artifact["edge_table"].shape[0]), 935 "n_clusters": int(artifact["community_table"].shape[0]), 936 } 937 938 def _build_networkx_graph( 939 self, 940 metric: str = "entropy_similarity", 941 *, 942 include_queries_only: bool = True, 943 score_threshold: float | None = None, 944 max_edges: int | None = 500, 945 max_nodes: int | None = None, 946 exclude_self: bool = True, 947 directed: bool = False, 948 color_map: dict[str, str] | None = None, 949 metadata: dict[str, dict] | None = None, 950 library_label_field: str | Sequence[str] | None = None, 951 library_node_attrs: Sequence[str] | None = None, 952 drop_components_without_queries: bool = True, 953 drop_nodes_without_query_connection: bool = True, 954 layout_seed: int = 42, 955 bypass_clustering: bool = True, 956 separate_communities: bool = True, 957 ) -> tuple[Any, dict | None, dict[str, str], float]: 958 """Build a NetworkX graph + layout for static or interactive plotting. 959 960 Returns 961 ------- 962 G : networkx.Graph 963 Graph with node attrs ``label``, ``node_type``, optional community 964 and viz styling fields, and edge attr ``score``. 965 layout : dict or None 966 Mapping ``node_id → {"x": float, "y": float}`` for drawing. 967 palette : dict 968 Color map for ``node_type`` values (query / library / unknown). 969 score_threshold : float 970 Effective score threshold used for edges. 971 """ 972 if metric not in self.similarity_matrices: 973 raise KeyError( 974 f"Unknown metric '{metric}'. Available: {list(self.similarity_matrices.keys())}" 975 ) 976 977 nx = _import_networkx() 978 979 cache = self._ensure_cluster_cache() 980 artifact = cache.get(metric) 981 if not bypass_clustering and artifact is None: 982 raise RuntimeError( 983 f"No clusters available for metric '{metric}'. " 984 "Call compute_network_clusters(...) first, or set bypass_clustering=True." 985 ) 986 987 if score_threshold is None: 988 score_threshold = self._threshold_for(metric) 989 990 if bypass_clustering: 991 edges = self._edges_to_dataframe( 992 metric=metric, 993 score_threshold=score_threshold, 994 include_queries_only=include_queries_only, 995 exclude_self=exclude_self, 996 ) 997 else: 998 edges = artifact["edge_table"][["id1", "id2", "score"]].copy() 999 edges = edges[edges["score"] >= score_threshold] 1000 1001 if max_nodes is not None and not edges.empty: 1002 degree = ( 1003 pd.concat([edges["id1"], edges["id2"]]).value_counts().head(max_nodes) 1004 ) 1005 top_nodes = set(degree.index) 1006 edges = edges[edges["id1"].isin(top_nodes) & edges["id2"].isin(top_nodes)] 1007 1008 if max_edges is not None and not edges.empty: 1009 edges = edges.nlargest(max_edges, "score") 1010 1011 G = nx.DiGraph() if directed else nx.Graph() 1012 1013 palette = dict(self._DEFAULT_COLOR_MAP) 1014 if color_map: 1015 palette.update(color_map) 1016 1017 query_id_set = set(self._all_query_ids) 1018 1019 if not edges.empty: 1020 node_ids = ( 1021 pd.concat([edges["id1"], edges["id2"]]).drop_duplicates().tolist() 1022 ) 1023 1024 for node_id in node_ids: 1025 node_kind = "query" if node_id in query_id_set else "library" 1026 lib_record = ( 1027 self._library_record_for_node(node_id) 1028 if node_kind == "library" 1029 else None 1030 ) 1031 1032 label = ( 1033 self._resolve_library_label( 1034 node_id=node_id, 1035 record=lib_record, 1036 library_label_field=library_label_field, 1037 ) 1038 if node_kind == "library" 1039 else (node_id[:20] + "..." if len(node_id) > 20 else node_id) 1040 ) 1041 1042 attrs: dict = { 1043 "label": label, 1044 "node_type": node_kind, 1045 } 1046 1047 if node_kind == "library" and lib_record and library_node_attrs: 1048 for field in library_node_attrs: 1049 if field in lib_record: 1050 attrs[field] = self._as_node_attr_value( 1051 lib_record.get(field) 1052 ) 1053 1054 if metadata: 1055 attrs.update(metadata.get(node_id, {})) 1056 1057 G.add_node(node_id, **attrs) 1058 1059 for row in edges.itertuples(index=False): 1060 G.add_edge(row.id1, row.id2, score=float(row.score)) 1061 1062 if bypass_clustering: 1063 self._apply_query_connectivity_filters( 1064 G, 1065 nx=nx, 1066 query_id_set=query_id_set, 1067 drop_components_without_queries=drop_components_without_queries, 1068 drop_nodes_without_query_connection=drop_nodes_without_query_connection, 1069 ) 1070 1071 # Default node styling (shared by static and interactive plots) 1072 for node in G.nodes: 1073 node_type = G.nodes[node].get("node_type", "unknown") 1074 if node_type == "query": 1075 G.nodes[node]["viz_size"] = 2 1076 G.nodes[node]["viz_border_color"] = "#000000" 1077 G.nodes[node]["viz_border_size"] = 3 1078 else: 1079 G.nodes[node]["viz_size"] = 1 1080 G.nodes[node]["viz_border_color"] = "#00000000" 1081 G.nodes[node]["viz_border_size"] = 0 1082 1083 community_groups: list[set] = [] 1084 if ( 1085 not bypass_clustering 1086 and artifact is not None 1087 and not artifact["node_table"].empty 1088 ): 1089 node_table = artifact["node_table"].set_index("node_id") 1090 for node in list(G.nodes): 1091 node_s = str(node) 1092 if node_s not in node_table.index: 1093 continue 1094 row = node_table.loc[node_s] 1095 G.nodes[node]["community_id"] = row.get("cluster_id") 1096 G.nodes[node]["community_level"] = int(row.get("community_level", 0)) 1097 G.nodes[node]["community_size"] = int(row.get("community_size", 1)) 1098 G.nodes[node]["query_count_in_community"] = int( 1099 row.get("query_count_in_community", 0) 1100 ) 1101 1102 by_cluster = node_table.reset_index().groupby("cluster_id")["node_id"] 1103 community_groups = [set(group.tolist()) for _, group in by_cluster] 1104 1105 if separate_communities: 1106 inter_edges = [] 1107 for u, v in G.edges(): 1108 cu = G.nodes[u].get("community_id") 1109 cv = G.nodes[v].get("community_id") 1110 if cu is not None and cv is not None and cu != cv: 1111 inter_edges.append((u, v)) 1112 if inter_edges: 1113 G.remove_edges_from(inter_edges) 1114 1115 layout: dict | None = None 1116 if G.number_of_nodes() > 0: 1117 if ( 1118 not bypass_clustering 1119 and artifact is not None 1120 and not separate_communities 1121 and not artifact["layout_table"].empty 1122 ): 1123 layout = { 1124 str(row.node_id): {"x": float(row.x), "y": float(row.y)} 1125 for row in artifact["layout_table"].itertuples(index=False) 1126 if G.has_node(str(row.node_id)) 1127 } 1128 if len(layout) != G.number_of_nodes(): 1129 pos = nx.spring_layout( 1130 G, 1131 seed=layout_seed, 1132 weight="score", 1133 k=2.0 / max(G.number_of_nodes() ** 0.5, 1), 1134 ) 1135 for node, (x, y) in pos.items(): 1136 layout.setdefault(str(node), {"x": float(x), "y": float(y)}) 1137 elif not bypass_clustering and artifact is not None and community_groups: 1138 present_groups = [] 1139 for group in community_groups: 1140 nodes_present = {n for n in group if G.has_node(n)} 1141 if nodes_present: 1142 present_groups.append(nodes_present) 1143 layout = self._compute_hierarchical_layout( 1144 G, 1145 present_groups, 1146 weight_attr="score", 1147 seed=layout_seed, 1148 ) 1149 else: 1150 pos = nx.spring_layout( 1151 G, 1152 seed=layout_seed, 1153 weight="score", 1154 k=2.0 / max(G.number_of_nodes() ** 0.5, 1), 1155 ) 1156 layout = { 1157 str(n): {"x": float(x), "y": float(y)} for n, (x, y) in pos.items() 1158 } 1159 1160 return G, layout, palette, float(score_threshold) 1161 1162 def plot_network( 1163 self, 1164 metric: str = "entropy_similarity", 1165 *, 1166 ax=None, 1167 path: str | None = None, 1168 return_fig: bool = False, 1169 include_queries_only: bool = True, 1170 score_threshold: float | None = None, 1171 max_edges: int | None = 500, 1172 max_nodes: int | None = None, 1173 exclude_self: bool = True, 1174 drop_components_without_queries: bool = True, 1175 drop_nodes_without_query_connection: bool = True, 1176 layout_seed: int = 42, 1177 bypass_clustering: bool = True, 1178 separate_communities: bool = True, 1179 color_map: dict[str, str] | None = None, 1180 library_label_field: str | Sequence[str] | None = None, 1181 show_labels: bool = True, 1182 label_max_nodes: int = 40, 1183 node_size_query: float = 90, 1184 node_size_library: float = 55, 1185 figsize: tuple[float, float] = (9, 7), 1186 title: str | None = None, 1187 dpi: int = 150, 1188 ): 1189 """Draw a static matplotlib snapshot of the molecular network. 1190 1191 Portable for Jupyter notebooks rendered on GitLab/GitHub (PNG output). 1192 For interactive HTML exploration, use :meth:`plot_interactive_network` 1193 (requires ``ipysigma`` / ``corems[networking]``). 1194 1195 Parameters 1196 ---------- 1197 metric : str 1198 Similarity metric key. Default ``"entropy_similarity"``. 1199 ax : matplotlib.axes.Axes, optional 1200 Axes to draw on. If None, a new figure is created. 1201 path : str, optional 1202 If set, save the figure to this path (``savefig``). 1203 return_fig : bool 1204 If True, return the ``Figure`` and do not call ``plt.show()``. 1205 Default False. 1206 include_queries_only, score_threshold, max_edges, max_nodes, … 1207 Edge/node filtering and layout options (see 1208 :meth:`plot_interactive_network`). 1209 bypass_clustering : bool 1210 If True (default), plot the raw filtered graph without requiring 1211 precomputed clusters. 1212 show_labels : bool 1213 Draw node labels (capped by *label_max_nodes*). Default True. 1214 label_max_nodes : int 1215 Maximum number of labels to draw (highest-degree nodes). Default 40. 1216 node_size_query, node_size_library : float 1217 Matplotlib scatter sizes for query vs library nodes. 1218 figsize : tuple of float 1219 Figure size when *ax* is None. Default ``(9, 7)``. 1220 title : str, optional 1221 Axes title. Default summarizes metric, threshold, and edge count. 1222 dpi : int 1223 DPI used when saving *path*. Default 150. 1224 1225 Returns 1226 ------- 1227 matplotlib.figure.Figure or None 1228 The figure if *return_fig* is True, otherwise None. 1229 """ 1230 import matplotlib.pyplot as plt 1231 from matplotlib.lines import Line2D 1232 1233 G, layout, palette, thr = self._build_networkx_graph( 1234 metric=metric, 1235 include_queries_only=include_queries_only, 1236 score_threshold=score_threshold, 1237 max_edges=max_edges, 1238 max_nodes=max_nodes, 1239 exclude_self=exclude_self, 1240 directed=False, 1241 color_map=color_map, 1242 library_label_field=library_label_field, 1243 drop_components_without_queries=drop_components_without_queries, 1244 drop_nodes_without_query_connection=drop_nodes_without_query_connection, 1245 layout_seed=layout_seed, 1246 bypass_clustering=bypass_clustering, 1247 separate_communities=separate_communities, 1248 ) 1249 1250 if ax is None: 1251 fig, ax = plt.subplots(figsize=figsize) 1252 else: 1253 fig = ax.figure 1254 1255 if layout is None or G.number_of_nodes() == 0: 1256 ax.text(0.5, 0.5, "No network edges to plot", ha="center", va="center") 1257 ax.set_axis_off() 1258 else: 1259 pos = { 1260 n: (float(layout[str(n)]["x"]), float(layout[str(n)]["y"])) 1261 for n in G.nodes 1262 if str(n) in layout 1263 } 1264 # Fallback positions for any missing layout keys 1265 for n in G.nodes: 1266 if n not in pos: 1267 pos[n] = (0.0, 0.0) 1268 1269 scores = [float(d.get("score", 0.0)) for _, _, d in G.edges(data=True)] 1270 smin = min(scores) if scores else 0.0 1271 smax = max(scores) if scores else 1.0 1272 span = (smax - smin) + 1e-12 1273 1274 for u, v, data in G.edges(data=True): 1275 x0, y0 = pos[u] 1276 x1, y1 = pos[v] 1277 score = float(data.get("score", 0.0)) 1278 lw = 0.5 + 2.5 * ((score - smin) / span) 1279 ax.plot([x0, x1], [y0, y1], color="0.65", lw=lw, zorder=1, alpha=0.85) 1280 1281 for n, (x, y) in pos.items(): 1282 is_query = G.nodes[n].get("node_type") == "query" 1283 ax.scatter( 1284 [x], 1285 [y], 1286 s=node_size_query if is_query else node_size_library, 1287 c=palette.get("query" if is_query else "library", "#95a5a6"), 1288 edgecolors="white", 1289 linewidths=0.6, 1290 zorder=2, 1291 ) 1292 1293 if show_labels and G.number_of_nodes() > 0: 1294 degrees = dict(G.degree()) 1295 label_nodes = sorted(degrees, key=lambda n: (-degrees[n], str(n)))[ 1296 : max(0, int(label_max_nodes)) 1297 ] 1298 for n in label_nodes: 1299 x, y = pos[n] 1300 label = str(G.nodes[n].get("label", n)) 1301 if len(label) > 18: 1302 label = label[:15] + "…" 1303 ax.text(x, y, label, fontsize=7, ha="center", va="bottom", zorder=3) 1304 1305 legend = [ 1306 Line2D( 1307 [0], 1308 [0], 1309 marker="o", 1310 color="w", 1311 markerfacecolor=palette.get("query", "#e74c3c"), 1312 markersize=9, 1313 label="Experimental MS2 (query)", 1314 ), 1315 Line2D( 1316 [0], 1317 [0], 1318 marker="o", 1319 color="w", 1320 markerfacecolor=palette.get("library", "#3498db"), 1321 markersize=8, 1322 label="Library spectrum", 1323 ), 1324 ] 1325 ax.legend(handles=legend, loc="best", frameon=True) 1326 ax.set_axis_off() 1327 1328 if title is None: 1329 title = ( 1330 f"Molecular network ({metric}, threshold={thr:g}, " 1331 f"{G.number_of_edges()} edges)" 1332 ) 1333 ax.set_title(title) 1334 fig.tight_layout() 1335 1336 if path is not None: 1337 fig.savefig(path, dpi=dpi, bbox_inches="tight") 1338 1339 if return_fig: 1340 return fig 1341 1342 # Prefer IPython Image display so notebooks capture PNG under Agg / headless CI 1343 try: 1344 import io 1345 1346 from IPython import get_ipython 1347 from IPython.display import Image, display 1348 1349 if get_ipython() is not None: 1350 buf = io.BytesIO() 1351 fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight") 1352 display(Image(data=buf.getvalue())) 1353 plt.close(fig) 1354 return None 1355 except ImportError: 1356 pass 1357 1358 plt.show() 1359 return None 1360 1361 def plot_interactive_network( 1362 self, 1363 metric: str = "entropy_similarity", 1364 out_path: str = "network.html", 1365 *, 1366 include_queries_only: bool = True, 1367 score_threshold: float | None = None, 1368 max_edges: int | None = 500, 1369 max_nodes: int | None = None, 1370 exclude_self: bool = True, 1371 directed: bool = False, 1372 color_map: dict[str, str] | None = None, 1373 sigma_options: dict | None = None, 1374 metadata: dict[str, dict] | None = None, 1375 library_label_field: str | Sequence[str] | None = None, 1376 library_node_attrs: Sequence[str] | None = None, 1377 drop_components_without_queries: bool = True, 1378 drop_nodes_without_query_connection: bool = True, 1379 layout_seed: int = 42, 1380 bypass_clustering: bool = False, 1381 separate_communities: bool = True, 1382 display_in_notebook: bool = False, 1383 iframe_height: int = 650, 1384 ) -> str: 1385 """Write an interactive ipysigma HTML network visualization. 1386 1387 Prefer :meth:`plot_network` for static snapshots that render on 1388 GitLab/GitHub notebook previews. This method requires **ipysigma** 1389 (``pip install "corems[networking]"``). 1390 1391 Parameters 1392 ---------- 1393 out_path : str 1394 Output HTML file path. 1395 separate_communities : bool 1396 When plotting from precomputed clusters (``bypass_clustering=False``), 1397 remove inter-community edges so each community renders as a separate 1398 subnetwork. Default ``True``. 1399 display_in_notebook : bool 1400 If ``True``, also embed the HTML in a local Jupyter frontend via a 1401 data-URI iframe. Does **not** render on GitLab/GitHub notebook 1402 previews. Default ``False``. 1403 iframe_height : int 1404 Pixel height of the notebook iframe when *display_in_notebook* is 1405 True. Default ``650``. 1406 1407 Returns 1408 ------- 1409 str 1410 Path to the written HTML file. 1411 """ 1412 try: 1413 from ipysigma import Sigma 1414 except ImportError as exc: 1415 raise ImportError( 1416 "ipysigma is required for plot_interactive_network(). " 1417 'Install with: pip install "corems[networking]" ' 1418 "(or: pip install ipysigma)" 1419 ) from exc 1420 1421 G, layout, palette, _thr = self._build_networkx_graph( 1422 metric=metric, 1423 include_queries_only=include_queries_only, 1424 score_threshold=score_threshold, 1425 max_edges=max_edges, 1426 max_nodes=max_nodes, 1427 exclude_self=exclude_self, 1428 directed=directed, 1429 color_map=color_map, 1430 metadata=metadata, 1431 library_label_field=library_label_field, 1432 library_node_attrs=library_node_attrs, 1433 drop_components_without_queries=drop_components_without_queries, 1434 drop_nodes_without_query_connection=drop_nodes_without_query_connection, 1435 layout_seed=layout_seed, 1436 bypass_clustering=bypass_clustering, 1437 separate_communities=separate_communities, 1438 ) 1439 1440 cache = self._ensure_cluster_cache() 1441 artifact = cache.get(metric) 1442 clustered_mode = ( 1443 not bypass_clustering and artifact is not None and G.number_of_nodes() > 0 1444 ) 1445 write_kwargs: dict = { 1446 "layout": layout, 1447 "node_color": "community_id" if clustered_mode else "node_type", 1448 "node_size": "viz_size", 1449 "node_size_range": (8, 18), 1450 "node_border_color": "viz_border_color", 1451 "node_border_size": "viz_border_size", 1452 "node_border_size_range": (0, 4), 1453 "default_edge_type": "line", 1454 "default_edge_color": "#aaa", 1455 "fullscreen": True, 1456 } 1457 if not clustered_mode: 1458 write_kwargs["node_color_palette"] = palette 1459 if sigma_options: 1460 write_kwargs.update(sigma_options) 1461 1462 out = Path(out_path) 1463 out.parent.mkdir(parents=True, exist_ok=True) 1464 Sigma.write_html(G, str(out), **write_kwargs) 1465 1466 if display_in_notebook: 1467 self._display_network_html_in_notebook(out, height=iframe_height) 1468 1469 return str(out) 1470 1471 @staticmethod 1472 def _display_network_html_in_notebook( 1473 html_path: Path, *, height: int = 650 1474 ) -> None: 1475 """Embed a written Sigma HTML file in a Jupyter notebook output cell. 1476 1477 Uses a base64 data-URI iframe so the notebook remains portable (no 1478 machine-local ``file://`` paths). No-ops if IPython is unavailable. 1479 Note: GitLab/GitHub notebook previews do not execute this iframe. 1480 """ 1481 try: 1482 from base64 import b64encode 1483 1484 from IPython.display import IFrame, display 1485 except ImportError: 1486 return 1487 1488 html_bytes = Path(html_path).read_bytes() 1489 html_b64 = b64encode(html_bytes).decode("ascii") 1490 display( 1491 IFrame( 1492 src=f"data:text/html;base64,{html_b64}", 1493 width="100%", 1494 height=int(height), 1495 ) 1496 )
Mixin that adds static and interactive network plotting.
462 def compute_network_clusters( 463 self, 464 metric: str = "entropy_similarity", 465 *, 466 include_queries_only: bool = True, 467 score_threshold: float | None = None, 468 max_edges: int | None = 500, 469 max_nodes: int | None = None, 470 exclude_self: bool = True, 471 directed: bool = False, 472 drop_components_without_queries: bool = True, 473 drop_nodes_without_query_connection: bool = True, 474 cluster_method: str = "weighted_greedy_modularity", 475 cluster_weight_attr: str = "score", 476 cluster_super_threshold: int = 400, 477 cluster_sparsify_top_k: int = 8, 478 cluster_recursive_split: bool = True, 479 cluster_min_size: int = 2, 480 compute_layout: bool = True, 481 layout_seed: int = 42, 482 ) -> dict[str, Any]: 483 """Compute and cache clustering artifacts for one similarity metric. 484 485 This stage is separate from plotting. It builds a graph from the 486 selected similarity matrix, applies query-connectivity filters, 487 computes communities, and stores node/community/edge/layout tables in 488 an in-memory artifact cache for later plotting or export. 489 490 Parameters 491 ---------- 492 metric : str 493 Similarity metric key from ``self.similarity_matrices``. 494 include_queries_only : bool 495 Use query-focused edge filtering during edge extraction. 496 score_threshold : float or None 497 Minimum similarity score to include; ``None`` uses 498 ``_threshold_for(metric)``. 499 max_edges : int or None 500 Keep top-N edges by score after filtering. 501 max_nodes : int or None 502 Keep edges whose endpoints are within highest-degree N nodes. 503 exclude_self : bool 504 Drop self-loop edges. 505 directed : bool 506 Build directed graph when ``True``. 507 drop_components_without_queries : bool 508 Remove connected components with no query nodes. 509 drop_nodes_without_query_connection : bool 510 Keep only query nodes and one-hop query neighbors. 511 cluster_method : str 512 Community method; currently ``"weighted_greedy_modularity"``. 513 cluster_weight_attr : str 514 Edge attribute used as clustering weight. 515 cluster_super_threshold : int 516 Threshold above which recursive splitting can apply. 517 cluster_sparsify_top_k : int 518 Top-k edge sparsification for oversized community refinement. 519 cluster_recursive_split : bool 520 Enable recursive refinement of oversized communities. 521 cluster_min_size : int 522 Reserved for future use; kept for API stability. 523 compute_layout : bool 524 Compute and cache hierarchical node coordinates. 525 layout_seed : int 526 Random seed for deterministic layout generation. 527 528 Returns 529 ------- 530 dict 531 Summary with ``metric``, ``n_nodes``, ``n_edges``, ``n_clusters``, 532 and ``score_threshold``. 533 534 Raises 535 ------ 536 KeyError 537 If *metric* is unknown. 538 ValueError 539 If *cluster_method* is unsupported. 540 """ 541 if metric not in self.similarity_matrices: 542 raise KeyError( 543 f"Unknown metric '{metric}'. Available: {list(self.similarity_matrices.keys())}" 544 ) 545 546 if cluster_method != "weighted_greedy_modularity": 547 raise ValueError( 548 "Unsupported cluster_method. " 549 "Currently supported: ['weighted_greedy_modularity']" 550 ) 551 552 nx = _import_networkx() 553 554 if score_threshold is None: 555 score_threshold = self._threshold_for(metric) 556 557 edges = self._edges_to_dataframe( 558 metric=metric, 559 score_threshold=score_threshold, 560 include_queries_only=include_queries_only, 561 exclude_self=exclude_self, 562 ) 563 564 if max_nodes is not None and not edges.empty: 565 degree = ( 566 pd.concat([edges["id1"], edges["id2"]]).value_counts().head(max_nodes) 567 ) 568 top_nodes = set(degree.index) 569 edges = edges[edges["id1"].isin(top_nodes) & edges["id2"].isin(top_nodes)] 570 571 if max_edges is not None and not edges.empty: 572 edges = edges.nlargest(max_edges, "score") 573 574 G = nx.DiGraph() if directed else nx.Graph() 575 query_id_set = set(self._all_query_ids) 576 577 if not edges.empty: 578 node_ids = ( 579 pd.concat([edges["id1"], edges["id2"]]).drop_duplicates().tolist() 580 ) 581 for node_id in node_ids: 582 G.add_node( 583 node_id, 584 node_type=("query" if node_id in query_id_set else "library"), 585 ) 586 for row in edges.itertuples(index=False): 587 G.add_edge(row.id1, row.id2, score=float(row.score)) 588 589 self._apply_query_connectivity_filters( 590 G, 591 nx=nx, 592 query_id_set=query_id_set, 593 drop_components_without_queries=drop_components_without_queries, 594 drop_nodes_without_query_connection=drop_nodes_without_query_connection, 595 ) 596 597 communities: list[set] = [] 598 if G.number_of_nodes() > 0: 599 communities = self._detect_weighted_communities( 600 G.to_undirected(), 601 weight_attr=cluster_weight_attr, 602 super_threshold=cluster_super_threshold, 603 sparsify_top_k=cluster_sparsify_top_k, 604 recursive_split=cluster_recursive_split, 605 ) 606 607 node_rows = [] 608 community_rows = [] 609 node_to_cluster: dict[str, str] = {} 610 611 for comm_idx, nodes in enumerate(communities): 612 cluster_id = f"C{comm_idx:03d}" 613 query_count = sum(1 for n in nodes if n in query_id_set) 614 community_rows.append( 615 { 616 "cluster_id": cluster_id, 617 "community_level": 0, 618 "size": len(nodes), 619 "query_count_in_community": query_count, 620 } 621 ) 622 for node in sorted(nodes, key=str): 623 node_to_cluster[str(node)] = cluster_id 624 node_rows.append( 625 { 626 "node_id": str(node), 627 "cluster_id": cluster_id, 628 "community_level": 0, 629 "community_size": len(nodes), 630 "query_count_in_community": query_count, 631 "node_type": G.nodes[node].get("node_type", "unknown"), 632 } 633 ) 634 635 # Ensure isolated nodes still receive a cluster assignment. 636 if G.number_of_nodes() > 0: 637 for node in G.nodes: 638 node_s = str(node) 639 if node_s in node_to_cluster: 640 continue 641 cluster_id = f"C{len(community_rows):03d}" 642 node_to_cluster[node_s] = cluster_id 643 query_count = 1 if node in query_id_set else 0 644 community_rows.append( 645 { 646 "cluster_id": cluster_id, 647 "community_level": 0, 648 "size": 1, 649 "query_count_in_community": query_count, 650 } 651 ) 652 node_rows.append( 653 { 654 "node_id": node_s, 655 "cluster_id": cluster_id, 656 "community_level": 0, 657 "community_size": 1, 658 "query_count_in_community": query_count, 659 "node_type": G.nodes[node].get("node_type", "unknown"), 660 } 661 ) 662 663 edge_rows = [] 664 for u, v, data in G.edges(data=True): 665 u_s = str(u) 666 v_s = str(v) 667 edge_rows.append( 668 { 669 "id1": u_s, 670 "id2": v_s, 671 "score": float(data.get("score", 0.0)), 672 "cluster_id_1": node_to_cluster.get(u_s), 673 "cluster_id_2": node_to_cluster.get(v_s), 674 "intra_cluster": node_to_cluster.get(u_s) 675 == node_to_cluster.get(v_s), 676 } 677 ) 678 679 layout_df = pd.DataFrame(columns=["node_id", "x", "y"]) 680 if compute_layout and G.number_of_nodes() > 0: 681 layout = self._compute_hierarchical_layout( 682 G, 683 communities, 684 weight_attr=cluster_weight_attr, 685 seed=layout_seed, 686 ) 687 layout_df = pd.DataFrame( 688 [ 689 {"node_id": str(node_id), "x": coords["x"], "y": coords["y"]} 690 for node_id, coords in layout.items() 691 ] 692 ) 693 694 artifact = { 695 "metric": metric, 696 "params": { 697 "include_queries_only": include_queries_only, 698 "score_threshold": score_threshold, 699 "max_edges": max_edges, 700 "max_nodes": max_nodes, 701 "exclude_self": exclude_self, 702 "directed": directed, 703 "drop_components_without_queries": drop_components_without_queries, 704 "drop_nodes_without_query_connection": drop_nodes_without_query_connection, 705 "cluster_method": cluster_method, 706 "cluster_weight_attr": cluster_weight_attr, 707 "cluster_super_threshold": cluster_super_threshold, 708 "cluster_sparsify_top_k": cluster_sparsify_top_k, 709 "cluster_recursive_split": cluster_recursive_split, 710 "cluster_min_size": cluster_min_size, 711 "compute_layout": compute_layout, 712 "layout_seed": layout_seed, 713 }, 714 "node_table": pd.DataFrame(node_rows), 715 "community_table": pd.DataFrame(community_rows), 716 "edge_table": pd.DataFrame(edge_rows), 717 "layout_table": layout_df, 718 "schema_version": 1, 719 } 720 721 self._ensure_cluster_cache()[metric] = artifact 722 723 return { 724 "metric": metric, 725 "n_nodes": G.number_of_nodes(), 726 "n_edges": G.number_of_edges(), 727 "n_clusters": int(artifact["community_table"].shape[0]), 728 "score_threshold": float(score_threshold), 729 }
Compute and cache clustering artifacts for one similarity metric.
This stage is separate from plotting. It builds a graph from the selected similarity matrix, applies query-connectivity filters, computes communities, and stores node/community/edge/layout tables in an in-memory artifact cache for later plotting or export.
Parameters
- metric (str):
Similarity metric key from
self.similarity_matrices. - include_queries_only (bool): Use query-focused edge filtering during edge extraction.
- score_threshold (float or None):
Minimum similarity score to include;
Noneuses_threshold_for(metric). - max_edges (int or None): Keep top-N edges by score after filtering.
- max_nodes (int or None): Keep edges whose endpoints are within highest-degree N nodes.
- exclude_self (bool): Drop self-loop edges.
- directed (bool):
Build directed graph when
True. - drop_components_without_queries (bool): Remove connected components with no query nodes.
- drop_nodes_without_query_connection (bool): Keep only query nodes and one-hop query neighbors.
- cluster_method (str):
Community method; currently
"weighted_greedy_modularity". - cluster_weight_attr (str): Edge attribute used as clustering weight.
- cluster_super_threshold (int): Threshold above which recursive splitting can apply.
- cluster_sparsify_top_k (int): Top-k edge sparsification for oversized community refinement.
- cluster_recursive_split (bool): Enable recursive refinement of oversized communities.
- cluster_min_size (int): Reserved for future use; kept for API stability.
- compute_layout (bool): Compute and cache hierarchical node coordinates.
- layout_seed (int): Random seed for deterministic layout generation.
Returns
- dict: Summary with
metric,n_nodes,n_edges,n_clusters, andscore_threshold.
Raises
- KeyError: If metric is unknown.
- ValueError: If cluster_method is unsupported.
731 def drop_network_clusters(self, metric: str | None = None) -> None: 732 """Drop cached clustering artifacts while preserving similarity results. 733 734 Parameters 735 ---------- 736 metric : str or None 737 Metric cache key to remove. When ``None``, clear all cached 738 clustering artifacts. 739 """ 740 cache = self._ensure_cluster_cache() 741 if metric is None: 742 cache.clear() 743 else: 744 cache.pop(metric, None)
Drop cached clustering artifacts while preserving similarity results.
Parameters
- metric (str or None):
Metric cache key to remove. When
None, clear all cached clustering artifacts.
746 def save_network_clusters( 747 self, output_dir: str, metric: str, run_id: str | None = None 748 ) -> dict[str, str]: 749 """Persist computed cluster artifacts to CSV files. 750 751 Files written include node, community, edge, layout, and manifest CSVs. 752 Library node IDs are exported using stable identifiers when available 753 (for example ``spectra_id``) instead of internal index IDs. 754 755 Parameters 756 ---------- 757 output_dir : str 758 Output directory for CSV artifacts. 759 metric : str 760 Metric cache key to persist. 761 run_id : str or None 762 Optional suffix to disambiguate multiple runs. 763 764 Returns 765 ------- 766 dict of str to str 767 Paths for ``nodes``, ``communities``, ``edges``, ``layout``, 768 and ``manifest`` files. 769 770 Raises 771 ------ 772 RuntimeError 773 If no cached clusters exist for *metric*. 774 """ 775 cache = self._ensure_cluster_cache() 776 artifact = cache.get(metric) 777 if artifact is None: 778 raise RuntimeError( 779 f"No clusters available for metric '{metric}'. Call compute_network_clusters(...) first." 780 ) 781 782 out_dir = Path(output_dir) 783 out_dir.mkdir(parents=True, exist_ok=True) 784 785 suffix = f"_{run_id}" if run_id else "" 786 prefix = f"{metric}_clusters{suffix}" 787 788 node_path = out_dir / f"{prefix}_nodes.csv" 789 community_path = out_dir / f"{prefix}_communities.csv" 790 edge_path = out_dir / f"{prefix}_edges.csv" 791 manifest_path = out_dir / f"{prefix}_manifest.csv" 792 layout_path = out_dir / f"{prefix}_layout.csv" 793 794 query_id_set = set(getattr(self, "_all_query_ids", []) or []) 795 796 node_export_df = artifact["node_table"].copy() 797 if "node_id" in node_export_df.columns: 798 node_export_df["node_id"] = ( 799 node_export_df["node_id"] 800 .astype(str) 801 .map( 802 lambda node_id: self._export_cluster_node_id(node_id, query_id_set) 803 ) 804 ) 805 806 edge_export_df = artifact["edge_table"].copy() 807 if "id1" in edge_export_df.columns: 808 edge_export_df["id1"] = ( 809 edge_export_df["id1"] 810 .astype(str) 811 .map( 812 lambda node_id: self._export_cluster_node_id(node_id, query_id_set) 813 ) 814 ) 815 if "id2" in edge_export_df.columns: 816 edge_export_df["id2"] = ( 817 edge_export_df["id2"] 818 .astype(str) 819 .map( 820 lambda node_id: self._export_cluster_node_id(node_id, query_id_set) 821 ) 822 ) 823 824 layout_export_df = artifact["layout_table"].copy() 825 if "node_id" in layout_export_df.columns: 826 layout_export_df["node_id"] = ( 827 layout_export_df["node_id"] 828 .astype(str) 829 .map( 830 lambda node_id: self._export_cluster_node_id(node_id, query_id_set) 831 ) 832 ) 833 834 node_export_df.to_csv(node_path, index=False) 835 artifact["community_table"].to_csv(community_path, index=False) 836 edge_export_df.to_csv(edge_path, index=False) 837 layout_export_df.to_csv(layout_path, index=False) 838 839 pd.DataFrame( 840 [ 841 { 842 "schema_version": artifact.get("schema_version", 1), 843 "metric": metric, 844 "run_id": run_id or "", 845 "params_json": json.dumps( 846 artifact.get("params", {}), sort_keys=True 847 ), 848 } 849 ] 850 ).to_csv(manifest_path, index=False) 851 852 return { 853 "nodes": str(node_path), 854 "communities": str(community_path), 855 "edges": str(edge_path), 856 "layout": str(layout_path), 857 "manifest": str(manifest_path), 858 }
Persist computed cluster artifacts to CSV files.
Files written include node, community, edge, layout, and manifest CSVs.
Library node IDs are exported using stable identifiers when available
(for example spectra_id) instead of internal index IDs.
Parameters
- output_dir (str): Output directory for CSV artifacts.
- metric (str): Metric cache key to persist.
- run_id (str or None): Optional suffix to disambiguate multiple runs.
Returns
- dict of str to str: Paths for
nodes,communities,edges,layout, andmanifestfiles.
Raises
- RuntimeError: If no cached clusters exist for metric.
860 def load_network_clusters( 861 self, output_dir: str, metric: str, run_id: str | None = None 862 ) -> dict[str, Any]: 863 """Load cluster artifacts from CSV files into in-memory cache. 864 865 Parameters 866 ---------- 867 output_dir : str 868 Directory containing saved cluster CSV artifacts. 869 metric : str 870 Metric key to load. 871 run_id : str or None 872 Optional run suffix used when saving. 873 874 Returns 875 ------- 876 dict 877 Summary with ``metric``, ``n_nodes``, ``n_edges``, and 878 ``n_clusters``. 879 880 Raises 881 ------ 882 FileNotFoundError 883 If required artifact files are missing. 884 """ 885 out_dir = Path(output_dir) 886 suffix = f"_{run_id}" if run_id else "" 887 prefix = f"{metric}_clusters{suffix}" 888 889 node_path = out_dir / f"{prefix}_nodes.csv" 890 community_path = out_dir / f"{prefix}_communities.csv" 891 edge_path = out_dir / f"{prefix}_edges.csv" 892 manifest_path = out_dir / f"{prefix}_manifest.csv" 893 layout_path = out_dir / f"{prefix}_layout.csv" 894 895 if ( 896 not node_path.exists() 897 or not community_path.exists() 898 or not edge_path.exists() 899 ): 900 raise FileNotFoundError( 901 f"Missing cluster artifact files for metric '{metric}' in '{output_dir}'." 902 ) 903 904 params = {} 905 schema_version = 1 906 if manifest_path.exists(): 907 mdf = pd.read_csv(manifest_path) 908 if not mdf.empty: 909 schema_version = int(mdf.iloc[0].get("schema_version", 1)) 910 params_json = mdf.iloc[0].get("params_json", "{}") 911 try: 912 params = ( 913 json.loads(params_json) if isinstance(params_json, str) else {} 914 ) 915 except (json.JSONDecodeError, TypeError, ValueError): 916 params = {} 917 918 artifact = { 919 "metric": metric, 920 "params": params, 921 "schema_version": schema_version, 922 "node_table": pd.read_csv(node_path), 923 "community_table": pd.read_csv(community_path), 924 "edge_table": pd.read_csv(edge_path), 925 "layout_table": pd.read_csv(layout_path) 926 if layout_path.exists() 927 else pd.DataFrame(columns=["node_id", "x", "y"]), 928 } 929 930 self._ensure_cluster_cache()[metric] = artifact 931 return { 932 "metric": metric, 933 "n_nodes": int(artifact["node_table"].shape[0]), 934 "n_edges": int(artifact["edge_table"].shape[0]), 935 "n_clusters": int(artifact["community_table"].shape[0]), 936 }
Load cluster artifacts from CSV files into in-memory cache.
Parameters
- output_dir (str): Directory containing saved cluster CSV artifacts.
- metric (str): Metric key to load.
- run_id (str or None): Optional run suffix used when saving.
Returns
- dict: Summary with
metric,n_nodes,n_edges, andn_clusters.
Raises
- FileNotFoundError: If required artifact files are missing.
1162 def plot_network( 1163 self, 1164 metric: str = "entropy_similarity", 1165 *, 1166 ax=None, 1167 path: str | None = None, 1168 return_fig: bool = False, 1169 include_queries_only: bool = True, 1170 score_threshold: float | None = None, 1171 max_edges: int | None = 500, 1172 max_nodes: int | None = None, 1173 exclude_self: bool = True, 1174 drop_components_without_queries: bool = True, 1175 drop_nodes_without_query_connection: bool = True, 1176 layout_seed: int = 42, 1177 bypass_clustering: bool = True, 1178 separate_communities: bool = True, 1179 color_map: dict[str, str] | None = None, 1180 library_label_field: str | Sequence[str] | None = None, 1181 show_labels: bool = True, 1182 label_max_nodes: int = 40, 1183 node_size_query: float = 90, 1184 node_size_library: float = 55, 1185 figsize: tuple[float, float] = (9, 7), 1186 title: str | None = None, 1187 dpi: int = 150, 1188 ): 1189 """Draw a static matplotlib snapshot of the molecular network. 1190 1191 Portable for Jupyter notebooks rendered on GitLab/GitHub (PNG output). 1192 For interactive HTML exploration, use :meth:`plot_interactive_network` 1193 (requires ``ipysigma`` / ``corems[networking]``). 1194 1195 Parameters 1196 ---------- 1197 metric : str 1198 Similarity metric key. Default ``"entropy_similarity"``. 1199 ax : matplotlib.axes.Axes, optional 1200 Axes to draw on. If None, a new figure is created. 1201 path : str, optional 1202 If set, save the figure to this path (``savefig``). 1203 return_fig : bool 1204 If True, return the ``Figure`` and do not call ``plt.show()``. 1205 Default False. 1206 include_queries_only, score_threshold, max_edges, max_nodes, … 1207 Edge/node filtering and layout options (see 1208 :meth:`plot_interactive_network`). 1209 bypass_clustering : bool 1210 If True (default), plot the raw filtered graph without requiring 1211 precomputed clusters. 1212 show_labels : bool 1213 Draw node labels (capped by *label_max_nodes*). Default True. 1214 label_max_nodes : int 1215 Maximum number of labels to draw (highest-degree nodes). Default 40. 1216 node_size_query, node_size_library : float 1217 Matplotlib scatter sizes for query vs library nodes. 1218 figsize : tuple of float 1219 Figure size when *ax* is None. Default ``(9, 7)``. 1220 title : str, optional 1221 Axes title. Default summarizes metric, threshold, and edge count. 1222 dpi : int 1223 DPI used when saving *path*. Default 150. 1224 1225 Returns 1226 ------- 1227 matplotlib.figure.Figure or None 1228 The figure if *return_fig* is True, otherwise None. 1229 """ 1230 import matplotlib.pyplot as plt 1231 from matplotlib.lines import Line2D 1232 1233 G, layout, palette, thr = self._build_networkx_graph( 1234 metric=metric, 1235 include_queries_only=include_queries_only, 1236 score_threshold=score_threshold, 1237 max_edges=max_edges, 1238 max_nodes=max_nodes, 1239 exclude_self=exclude_self, 1240 directed=False, 1241 color_map=color_map, 1242 library_label_field=library_label_field, 1243 drop_components_without_queries=drop_components_without_queries, 1244 drop_nodes_without_query_connection=drop_nodes_without_query_connection, 1245 layout_seed=layout_seed, 1246 bypass_clustering=bypass_clustering, 1247 separate_communities=separate_communities, 1248 ) 1249 1250 if ax is None: 1251 fig, ax = plt.subplots(figsize=figsize) 1252 else: 1253 fig = ax.figure 1254 1255 if layout is None or G.number_of_nodes() == 0: 1256 ax.text(0.5, 0.5, "No network edges to plot", ha="center", va="center") 1257 ax.set_axis_off() 1258 else: 1259 pos = { 1260 n: (float(layout[str(n)]["x"]), float(layout[str(n)]["y"])) 1261 for n in G.nodes 1262 if str(n) in layout 1263 } 1264 # Fallback positions for any missing layout keys 1265 for n in G.nodes: 1266 if n not in pos: 1267 pos[n] = (0.0, 0.0) 1268 1269 scores = [float(d.get("score", 0.0)) for _, _, d in G.edges(data=True)] 1270 smin = min(scores) if scores else 0.0 1271 smax = max(scores) if scores else 1.0 1272 span = (smax - smin) + 1e-12 1273 1274 for u, v, data in G.edges(data=True): 1275 x0, y0 = pos[u] 1276 x1, y1 = pos[v] 1277 score = float(data.get("score", 0.0)) 1278 lw = 0.5 + 2.5 * ((score - smin) / span) 1279 ax.plot([x0, x1], [y0, y1], color="0.65", lw=lw, zorder=1, alpha=0.85) 1280 1281 for n, (x, y) in pos.items(): 1282 is_query = G.nodes[n].get("node_type") == "query" 1283 ax.scatter( 1284 [x], 1285 [y], 1286 s=node_size_query if is_query else node_size_library, 1287 c=palette.get("query" if is_query else "library", "#95a5a6"), 1288 edgecolors="white", 1289 linewidths=0.6, 1290 zorder=2, 1291 ) 1292 1293 if show_labels and G.number_of_nodes() > 0: 1294 degrees = dict(G.degree()) 1295 label_nodes = sorted(degrees, key=lambda n: (-degrees[n], str(n)))[ 1296 : max(0, int(label_max_nodes)) 1297 ] 1298 for n in label_nodes: 1299 x, y = pos[n] 1300 label = str(G.nodes[n].get("label", n)) 1301 if len(label) > 18: 1302 label = label[:15] + "…" 1303 ax.text(x, y, label, fontsize=7, ha="center", va="bottom", zorder=3) 1304 1305 legend = [ 1306 Line2D( 1307 [0], 1308 [0], 1309 marker="o", 1310 color="w", 1311 markerfacecolor=palette.get("query", "#e74c3c"), 1312 markersize=9, 1313 label="Experimental MS2 (query)", 1314 ), 1315 Line2D( 1316 [0], 1317 [0], 1318 marker="o", 1319 color="w", 1320 markerfacecolor=palette.get("library", "#3498db"), 1321 markersize=8, 1322 label="Library spectrum", 1323 ), 1324 ] 1325 ax.legend(handles=legend, loc="best", frameon=True) 1326 ax.set_axis_off() 1327 1328 if title is None: 1329 title = ( 1330 f"Molecular network ({metric}, threshold={thr:g}, " 1331 f"{G.number_of_edges()} edges)" 1332 ) 1333 ax.set_title(title) 1334 fig.tight_layout() 1335 1336 if path is not None: 1337 fig.savefig(path, dpi=dpi, bbox_inches="tight") 1338 1339 if return_fig: 1340 return fig 1341 1342 # Prefer IPython Image display so notebooks capture PNG under Agg / headless CI 1343 try: 1344 import io 1345 1346 from IPython import get_ipython 1347 from IPython.display import Image, display 1348 1349 if get_ipython() is not None: 1350 buf = io.BytesIO() 1351 fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight") 1352 display(Image(data=buf.getvalue())) 1353 plt.close(fig) 1354 return None 1355 except ImportError: 1356 pass 1357 1358 plt.show() 1359 return None
Draw a static matplotlib snapshot of the molecular network.
Portable for Jupyter notebooks rendered on GitLab/GitHub (PNG output).
For interactive HTML exploration, use plot_interactive_network()
(requires ipysigma / corems[networking]).
Parameters
- metric (str):
Similarity metric key. Default
"entropy_similarity". - ax (matplotlib.axes.Axes, optional): Axes to draw on. If None, a new figure is created.
- path (str, optional):
If set, save the figure to this path (
savefig). - return_fig (bool):
If True, return the
Figureand do not callplt.show(). Default False. - include_queries_only, score_threshold, max_edges, max_nodes, …: Edge/node filtering and layout options (see
plot_interactive_network()). - bypass_clustering (bool): If True (default), plot the raw filtered graph without requiring precomputed clusters.
- show_labels (bool): Draw node labels (capped by label_max_nodes). Default True.
- label_max_nodes (int): Maximum number of labels to draw (highest-degree nodes). Default 40.
- node_size_query, node_size_library (float): Matplotlib scatter sizes for query vs library nodes.
- figsize (tuple of float):
Figure size when ax is None. Default
(9, 7). - title (str, optional): Axes title. Default summarizes metric, threshold, and edge count.
- dpi (int): DPI used when saving path. Default 150.
Returns
- matplotlib.figure.Figure or None: The figure if return_fig is True, otherwise None.
1361 def plot_interactive_network( 1362 self, 1363 metric: str = "entropy_similarity", 1364 out_path: str = "network.html", 1365 *, 1366 include_queries_only: bool = True, 1367 score_threshold: float | None = None, 1368 max_edges: int | None = 500, 1369 max_nodes: int | None = None, 1370 exclude_self: bool = True, 1371 directed: bool = False, 1372 color_map: dict[str, str] | None = None, 1373 sigma_options: dict | None = None, 1374 metadata: dict[str, dict] | None = None, 1375 library_label_field: str | Sequence[str] | None = None, 1376 library_node_attrs: Sequence[str] | None = None, 1377 drop_components_without_queries: bool = True, 1378 drop_nodes_without_query_connection: bool = True, 1379 layout_seed: int = 42, 1380 bypass_clustering: bool = False, 1381 separate_communities: bool = True, 1382 display_in_notebook: bool = False, 1383 iframe_height: int = 650, 1384 ) -> str: 1385 """Write an interactive ipysigma HTML network visualization. 1386 1387 Prefer :meth:`plot_network` for static snapshots that render on 1388 GitLab/GitHub notebook previews. This method requires **ipysigma** 1389 (``pip install "corems[networking]"``). 1390 1391 Parameters 1392 ---------- 1393 out_path : str 1394 Output HTML file path. 1395 separate_communities : bool 1396 When plotting from precomputed clusters (``bypass_clustering=False``), 1397 remove inter-community edges so each community renders as a separate 1398 subnetwork. Default ``True``. 1399 display_in_notebook : bool 1400 If ``True``, also embed the HTML in a local Jupyter frontend via a 1401 data-URI iframe. Does **not** render on GitLab/GitHub notebook 1402 previews. Default ``False``. 1403 iframe_height : int 1404 Pixel height of the notebook iframe when *display_in_notebook* is 1405 True. Default ``650``. 1406 1407 Returns 1408 ------- 1409 str 1410 Path to the written HTML file. 1411 """ 1412 try: 1413 from ipysigma import Sigma 1414 except ImportError as exc: 1415 raise ImportError( 1416 "ipysigma is required for plot_interactive_network(). " 1417 'Install with: pip install "corems[networking]" ' 1418 "(or: pip install ipysigma)" 1419 ) from exc 1420 1421 G, layout, palette, _thr = self._build_networkx_graph( 1422 metric=metric, 1423 include_queries_only=include_queries_only, 1424 score_threshold=score_threshold, 1425 max_edges=max_edges, 1426 max_nodes=max_nodes, 1427 exclude_self=exclude_self, 1428 directed=directed, 1429 color_map=color_map, 1430 metadata=metadata, 1431 library_label_field=library_label_field, 1432 library_node_attrs=library_node_attrs, 1433 drop_components_without_queries=drop_components_without_queries, 1434 drop_nodes_without_query_connection=drop_nodes_without_query_connection, 1435 layout_seed=layout_seed, 1436 bypass_clustering=bypass_clustering, 1437 separate_communities=separate_communities, 1438 ) 1439 1440 cache = self._ensure_cluster_cache() 1441 artifact = cache.get(metric) 1442 clustered_mode = ( 1443 not bypass_clustering and artifact is not None and G.number_of_nodes() > 0 1444 ) 1445 write_kwargs: dict = { 1446 "layout": layout, 1447 "node_color": "community_id" if clustered_mode else "node_type", 1448 "node_size": "viz_size", 1449 "node_size_range": (8, 18), 1450 "node_border_color": "viz_border_color", 1451 "node_border_size": "viz_border_size", 1452 "node_border_size_range": (0, 4), 1453 "default_edge_type": "line", 1454 "default_edge_color": "#aaa", 1455 "fullscreen": True, 1456 } 1457 if not clustered_mode: 1458 write_kwargs["node_color_palette"] = palette 1459 if sigma_options: 1460 write_kwargs.update(sigma_options) 1461 1462 out = Path(out_path) 1463 out.parent.mkdir(parents=True, exist_ok=True) 1464 Sigma.write_html(G, str(out), **write_kwargs) 1465 1466 if display_in_notebook: 1467 self._display_network_html_in_notebook(out, height=iframe_height) 1468 1469 return str(out)
Write an interactive ipysigma HTML network visualization.
Prefer plot_network() for static snapshots that render on
GitLab/GitHub notebook previews. This method requires ipysigma
(pip install "corems[networking]").
Parameters
- out_path (str): Output HTML file path.
- separate_communities (bool):
When plotting from precomputed clusters (
bypass_clustering=False), remove inter-community edges so each community renders as a separate subnetwork. DefaultTrue. - display_in_notebook (bool):
If
True, also embed the HTML in a local Jupyter frontend via a data-URI iframe. Does not render on GitLab/GitHub notebook previews. DefaultFalse. - iframe_height (int):
Pixel height of the notebook iframe when display_in_notebook is
True. Default
650.
Returns
- str: Path to the written HTML file.