corems.mass_spectra.calc.lc_calc
1import math 2import time 3from itertools import product 4 5import numpy as np 6import pandas as pd 7import warnings 8import scipy 9 10if not hasattr(np, "trapezoid"): # numpy < 2.0 11 np.trapezoid = np.trapz 12from ripser import ripser 13from scipy import sparse 14from scipy.spatial import KDTree 15from sklearn.svm import SVR 16from sklearn.cluster import AgglomerativeClustering 17import matplotlib.pyplot as plt 18from tqdm import tqdm 19 20from corems.chroma_peak.factory.chroma_peak_classes import LCMSMassFeature 21from corems.encapsulation.plot_utils import _finalize_plot 22from corems.encapsulation.spawn_pool import spawn_pool 23from corems.mass_spectra.calc import SignalProcessing as sp 24from corems.mass_spectra.factory.chromat_data import EIC_Data 25from corems.mass_spectrum.input.numpyArray import ms_from_array_profile 26 27warnings.filterwarnings("ignore", category=RuntimeWarning) 28 29 30def find_closest(A, target): 31 """Find the index of closest value in A to each value in target. 32 33 Parameters 34 ---------- 35 A : :obj:`~numpy.array` 36 The array to search (blueprint). A must be sorted. 37 target : :obj:`~numpy.array` 38 The array of values to search for. target must be sorted. 39 40 Returns 41 ------- 42 :obj:`~numpy.array` 43 The indices of the closest values in A to each value in target. 44 """ 45 idx = A.searchsorted(target) 46 idx = np.clip(idx, 1, len(A) - 1) 47 left = A[idx - 1] 48 right = A[idx] 49 idx -= target - left < right - target 50 return idx 51 52 53def _mz_grid_centers(mz_desc, radius): 54 """m/z bin centers, highest intensity first. 55 56 ``mz_desc`` is unique and ordered from highest intensity to lowest. A value 57 is kept when no kept higher-intensity value lies within ``radius``. That is 58 the parent rule of the distance-pair grid, without building the pair matrix. 59 """ 60 mz = np.asarray(mz_desc, dtype=np.float64) 61 n = mz.size 62 if n == 0: 63 return mz 64 rank_order = np.argsort(mz, kind="mergesort") 65 mz_by_rank = mz[rank_order] 66 rank_of = np.empty(n, dtype=np.intp) 67 rank_of[rank_order] = np.arange(n) 68 bit = np.zeros(n + 1, dtype=np.int32) 69 keep = np.zeros(n, dtype=bool) 70 71 def prefix(end): 72 total = 0 73 while end: 74 total += int(bit[end]) 75 end -= end & -end 76 return total 77 78 def mark(rank): 79 node = rank + 1 80 while node <= n: 81 bit[node] += 1 82 node += node & -node 83 84 for i, value in enumerate(mz): 85 lo = int(np.searchsorted(mz_by_rank, value - radius, side="left")) 86 hi = int(np.searchsorted(mz_by_rank, value + radius, side="right")) 87 if prefix(hi) - prefix(lo) > 0: 88 continue 89 mark(int(rank_of[i])) 90 keep[i] = True 91 return mz[keep] 92 93 94class LCCalculations: 95 """Methods for performing LC calculations on mass spectra data. 96 97 Notes 98 ----- 99 This class is intended to be used as a mixin for the LCMSBase class. 100 101 Methods 102 ------- 103 * get_max_eic(eic_data). 104 Returns the maximum EIC value from the given EIC data. A static method. 105 * smooth_tic(tic). 106 Smooths the TIC data using the specified smoothing method and settings. 107 * eic_centroid_detector(rt, eic, max_eic). 108 Performs EIC centroid detection on the given EIC data. 109 * find_nearest_scan(rt). 110 Finds the nearest scan to the given retention time. 111 * get_average_mass_spectrum(scan_list, apex_scan, spectrum_mode="profile", ms_level=1, auto_process=True, use_parser=False, perform_checks=True, polarity=None). 112 Returns an averaged mass spectrum object. 113 * find_mass_features(ms_level=1). 114 Find regions of interest for a given MS level (default is MS1). 115 * integrate_mass_features(drop_if_fail=False, ms_level=1). 116 Integrate mass features of interest and extracts EICs. 117 * find_c13_mass_features(). 118 Evaluate mass features and mark likely C13 isotopes. 119 * deconvolute_ms1_mass_features(). 120 Deconvolute mass features' ms1 mass spectra. 121 """ 122 123 @staticmethod 124 def get_max_eic(eic_data: dict): 125 """Returns the maximum EIC value from the given EIC data. 126 127 Notes 128 ----- 129 This is a static method. 130 131 Parameters 132 ---------- 133 eic_data : dict 134 A dictionary containing EIC data. 135 136 Returns 137 ------- 138 float 139 The maximum EIC value. 140 """ 141 max_eic = 0 142 for eic_data in eic_data.values(): 143 ind_max_eic = max(eic_data.get("EIC")) 144 max_eic = ind_max_eic if ind_max_eic > max_eic else max_eic 145 146 return max_eic 147 148 def smooth_tic(self, tic): 149 """Smooths the TIC or EIC data using the specified smoothing method and settings. 150 151 Parameters 152 ---------- 153 tic : numpy.ndarray 154 The TIC (or EIC) data to be smoothed. 155 156 Returns 157 ------- 158 numpy.ndarray 159 The smoothed TIC data. 160 """ 161 implemented_smooth_method = self.parameters.lc_ms.implemented_smooth_method 162 163 pol_order = self.parameters.lc_ms.savgol_pol_order 164 165 window_len = self.parameters.lc_ms.smooth_window 166 167 window = self.parameters.lc_ms.smooth_method 168 169 return sp.smooth_signal( 170 tic, window_len, window, pol_order, implemented_smooth_method 171 ) 172 173 def eic_centroid_detector(self, rt, eic, max_eic, apex_indexes=[]): 174 """Performs EIC centroid detection on the given EIC data. 175 176 Parameters 177 ---------- 178 rt : numpy.ndarray 179 The retention time data. 180 eic : numpy.ndarray 181 The EIC data. 182 max_eic : float 183 The maximum EIC value. 184 apex_indexes : list, optional 185 The apexes of the EIC peaks. Defaults to [], which means that the apexes will be calculated by the function. 186 187 Returns 188 ------- 189 numpy.ndarray 190 The indexes of left, apex, and right limits as a generator. 191 """ 192 193 max_prominence = self.parameters.lc_ms.peak_max_prominence_percent 194 195 max_height = self.parameters.lc_ms.peak_height_max_percent 196 197 signal_threshold = self.parameters.lc_ms.eic_signal_threshold 198 199 min_peak_datapoints = self.parameters.lc_ms.min_peak_datapoints 200 201 peak_derivative_threshold = self.parameters.lc_ms.peak_derivative_threshold 202 203 include_indexes = sp.peak_picking_first_derivative( 204 domain=rt, 205 signal=eic, 206 max_height=max_height, 207 max_prominence=max_prominence, 208 max_signal=max_eic, 209 min_peak_datapoints=min_peak_datapoints, 210 peak_derivative_threshold=peak_derivative_threshold, 211 signal_threshold=signal_threshold, 212 correct_baseline=False, 213 plot_res=False, 214 apex_indexes=apex_indexes, 215 ) 216 # include_indexes is a generator of tuples (left_index, apex_index, right_index) 217 include_indexes = list(include_indexes) 218 # Add check to make sure that there are at least 1/2 of min_peak_datapoints on either side of the apex 219 indicies = [x for x in include_indexes] 220 for idx in indicies: 221 if (idx[1] - idx[0] < min_peak_datapoints / 2) or ( 222 idx[2] - idx[1] < min_peak_datapoints / 2 223 ): 224 include_indexes.remove(idx) 225 return include_indexes 226 227 def find_nearest_scan(self, rt): 228 """Finds the nearest scan to the given retention time. 229 230 Parameters 231 ---------- 232 rt : float 233 The retention time (in minutes) to find the nearest scan for. 234 235 Returns 236 ------- 237 int 238 The scan number of the nearest scan. 239 """ 240 array_rt = np.array(self.retention_time) 241 242 scan_index = (np.abs(array_rt - rt)).argmin() 243 244 real_scan = self.scans_number[scan_index] 245 246 return real_scan 247 248 def add_peak_metrics(self, remove_by_metrics=True, induced_features=False): 249 """Add peak metrics to the mass features. 250 251 This function calculates the peak metrics for each mass feature and adds them to the mass feature objects. 252 253 Parameters 254 ---------- 255 remove_by_metrics : bool, optional 256 If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity, 257 dispersity index, and noise score. Default is True, which checks the setting in the processing parameters. 258 If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters. 259 induced_features : bool, optional 260 Whether the mass features to be integrated were induced. Default is False. 261 """ 262 # Check that at least some mass features have eic data 263 if induced_features: 264 mf_dict_values = self.induced_mass_features.values() 265 else: 266 mf_dict_values = self.mass_features.values() 267 268 if not any([mf._eic_data is not None for mf in mf_dict_values]): 269 raise ValueError( 270 "No mass features have EIC data. Run integrate_mass_features first." 271 ) 272 273 for mass_feature in mf_dict_values: 274 # Check if the mass feature has been integrated 275 if mass_feature._eic_data is not None and mass_feature.area is not None: 276 # Calculate peak metrics 277 mass_feature.calc_half_height_width() 278 mass_feature.calc_tailing_factor() 279 mass_feature.calc_dispersity_index() 280 mass_feature.calc_gaussian_similarity() 281 mass_feature.calc_noise_score() 282 283 # Remove mass features by peak metrics if designated in parameters 284 if ( 285 self.parameters.lc_ms.remove_mass_features_by_peak_metrics 286 and remove_by_metrics 287 ): 288 self._remove_mass_features_by_peak_metrics( 289 induced_features=induced_features 290 ) 291 292 def get_average_mass_spectrum( 293 self, 294 scan_list, 295 apex_scan, 296 spectrum_mode="profile", 297 ms_level=1, 298 auto_process=True, 299 use_parser=False, 300 perform_checks=True, 301 polarity=None, 302 ms_params=None, 303 ): 304 """Returns an averaged mass spectrum object 305 306 Parameters 307 ---------- 308 scan_list : list 309 List of scan numbers to average. 310 apex_scan : int 311 Number of the apex scan 312 spectrum_mode : str, optional 313 The spectrum mode to use. Defaults to "profile". Not that only "profile" mode is supported for averaging. 314 ms_level : int, optional 315 The MS level to use. Defaults to 1. 316 auto_process : bool, optional 317 If True, the averaged mass spectrum will be auto-processed. Defaults to True. 318 use_parser : bool, optional 319 If True, the mass spectra will be obtained from the parser. Defaults to False. 320 perform_checks : bool, optional 321 If True, the function will check if the data are within the ms_unprocessed dictionary and are the correct mode. Defaults to True. Only set to False if you are sure the data are profile, and (if not using the parser) are in the ms_unprocessed dictionary! ms_unprocessed dictionary also must be indexed on scan 322 polarity : int, optional 323 The polarity of the mass spectra (1 or -1). If not set, the polarity will be determined from the dataset. Defaults to None. (fastest if set to -1 or 1) 324 ms_params : MSParameters, optional 325 The mass spectrum parameters to use. If not set (None), the globally set parameters will be used. Defaults to None. 326 327 Returns 328 ------- 329 MassSpectrumProfile 330 The averaged mass spectrum object. 331 332 Raises 333 ------ 334 ValueError 335 If the spectrum mode is not "profile". 336 If the MS level is not found in the unprocessed mass spectra dictionary. 337 If not all scan numbers are found in the unprocessed mass spectra dictionary. 338 """ 339 if perform_checks: 340 if spectrum_mode != "profile": 341 raise ValueError("Averaging only supported for profile mode") 342 343 if polarity is None: 344 # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation) 345 if self.polarity == "negative": 346 polarity = -1 347 elif self.polarity == "positive": 348 polarity = 1 349 else: 350 raise ValueError( 351 "Polarity not set for dataset, must be a set containing either 'positive' or 'negative'" 352 ) 353 354 # if not using_parser, check that scan numbers are in _ms_unprocessed 355 if not use_parser: 356 if perform_checks: 357 # Set index to scan for faster lookup 358 ms_df = ( 359 self._ms_unprocessed[ms_level] 360 .copy() 361 .set_index("scan", drop=False) 362 .sort_index() 363 ) 364 my_ms_df = ms_df.loc[scan_list] 365 # Check that all scan numbers are in the ms_df 366 if not all(np.isin(scan_list, ms_df.index)): 367 raise ValueError( 368 "Not all scan numbers found in the unprocessed mass spectra dictionary" 369 ) 370 else: 371 my_ms_df = ( 372 pd.DataFrame({"scan": scan_list}) 373 .set_index("scan") 374 .join(self._ms_unprocessed[ms_level], how="left") 375 ) 376 377 if use_parser: 378 parser = self.spectra_parser 379 ms_list = [ 380 parser.get_mass_spectrum_from_scan( 381 x, spectrum_mode=spectrum_mode, auto_process=False 382 ) 383 for x in scan_list 384 ] 385 ms_mz = [x._mz_exp for x in ms_list] 386 ms_int = [x._abundance for x in ms_list] 387 my_ms_df = [] 388 for i in np.arange(len(ms_mz)): 389 my_ms_df.append( 390 pd.DataFrame( 391 {"mz": ms_mz[i], "intensity": ms_int[i], "scan": scan_list[i]} 392 ) 393 ) 394 my_ms_df = pd.concat(my_ms_df) 395 396 if not self.check_if_grid(my_ms_df): 397 my_ms_df = self.grid_data(my_ms_df) 398 399 my_ms_ave = my_ms_df.groupby("mz")["intensity"].sum().reset_index() 400 401 ms = ms_from_array_profile( 402 my_ms_ave.mz, 403 my_ms_ave.intensity, 404 self.file_location, 405 polarity=polarity, 406 auto_process=False, 407 ) 408 409 # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset 410 if ms is not None: 411 if ms_params is not None: 412 ms.parameters = ms_params 413 ms.scan_number = apex_scan 414 if auto_process: 415 ms.process_mass_spec() 416 return ms 417 418 def _resolve_lcms_peak_picking_method(self, ms_level=1): 419 """Resolve and validate the 2D peak picking method against scan formats. 420 421 Parameters 422 ---------- 423 ms_level : int, optional 424 MS level whose ``scan_df`` formats are checked. Default is 1. 425 426 Returns 427 ------- 428 str 429 Concrete method: ``persistent homology`` or 430 ``centroided_persistent_homology``. If the stored method is 431 ``by_format``, it is chosen from scan formats (all profile or all 432 centroid). 433 434 Raises 435 ------ 436 ValueError 437 If the method is not implemented, ``by_format`` cannot be resolved 438 (empty, mixed, or unknown formats), or an explicit method does 439 not match the scan formats at ``ms_level``. 440 """ 441 method = self.parameters.lc_ms.peak_picking_method 442 msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level] 443 values = list(msx_scan_df["ms_format"]) 444 445 if method == "by_format": 446 if len(values) == 0: 447 raise ValueError( 448 "Cannot resolve by_format peak picking: no scans found " 449 "for the requested MS level." 450 ) 451 if all(v == "profile" for v in values): 452 return "persistent homology" 453 if all(v == "centroid" for v in values): 454 return "centroided_persistent_homology" 455 found = sorted({str(v) for v in values}) 456 raise ValueError( 457 "Cannot resolve by_format peak picking: MS scans are mixed " 458 f"or unknown format (found {found}). Set peak_picking_method " 459 "explicitly to 'persistent homology' or " 460 "'centroided_persistent_homology'." 461 ) 462 463 if method == "persistent homology": 464 if len(values) == 0 or not all(v == "profile" for v in values): 465 raise ValueError( 466 "MS{} scans are not profile mode, which is required for persistent homology peak picking.".format( 467 ms_level 468 ) 469 ) 470 return method 471 472 if method == "centroided_persistent_homology": 473 if len(values) == 0 or not all(v == "centroid" for v in values): 474 raise ValueError( 475 "MS{} scans are not centroid mode, which is required for persistent homology centroided peak picking.".format( 476 ms_level 477 ) 478 ) 479 return method 480 481 raise ValueError("Peak picking method not implemented") 482 483 def find_mass_features( 484 self, 485 ms_level=1, 486 grid=True, 487 assign_ms2_scans=False, 488 ms2_scan_filter=None, 489 targeted_search=False, 490 target_search_dict=None, 491 accumulate_features=False, 492 ): 493 """Find mass features within an LCMSBase object 494 495 Wrapper that dispatches to a 2D peak picking implementation. 496 497 If ``parameters.lc_ms.peak_picking_method`` is ``by_format``, the method is 498 chosen from ``scan_df.ms_format`` at the requested MS level (all 499 profile → persistent homology; all centroid → centroided persistent 500 homology). A successful resolve overwrites 501 ``peak_picking_method`` with the concrete method so saved parameters 502 record what was used. 503 504 Parameters 505 ---------- 506 ms_level : int, optional 507 The MS level to use for peak picking Default is 1. 508 grid : bool, optional 509 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded), 510 used for persistent homology peak picking for profile data only. Default is True. 511 assign_ms2_scans : bool, optional 512 If True, assign MS2 scan numbers to mass features after peak picking. 513 This populates the ms2_scan_numbers attribute on each mass feature, which enables 514 choosing representative features based on MS2 availability. Default is False. 515 ms2_scan_filter : str or None, optional 516 Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd'). 517 If None, all MS2 scans are considered. Default is None. 518 targeted_search : bool, optional 519 If True, perform targeted mass feature search using the target_search_dict. 520 This mode filters data to only m/z and RT windows of interest and bypasses 521 intensity and persistence thresholds. Default is False. 522 target_search_dict : dict or None, optional 523 Dictionary containing target search parameters. Required if targeted_search is True. 524 Must contain: 525 - 'target_mz_list': list of target m/z values 526 - 'target_rt_list': list of target retention times (in minutes) 527 - 'mz_tolerance_ppm': m/z tolerance in ppm 528 - 'rt_tolerance': retention time tolerance (in minutes) 529 Optionally can contain: 530 - 'type': type label for mass features (e.g., "internal standard") 531 If not provided, defaults to "targeted" 532 Default is None. 533 accumulate_features : bool, optional 534 If True, new mass features will be added to existing features rather than replacing them. 535 This allows multiple sequential calls to find_mass_features to build up a combined set. 536 Default is False (replace existing features for backwards compatibility). 537 538 Raises 539 ------ 540 ValueError 541 If no MS level data is found on the object. 542 If ``by_format`` cannot resolve a method (empty, mixed, or unknown 543 ``ms_format`` values at the requested MS level). 544 If persistent homology peak picking is attempted on non-profile mode data. 545 If centroided persistent homology is attempted on non-centroid mode data. 546 If data is not gridded and grid is False. 547 If peak picking method is not implemented. 548 If targeted_search is True but target_search_dict is None or invalid. 549 550 Returns 551 ------- 552 None, but assigns the mass_features and eics attributes to the object. 553 554 """ 555 # Validate targeted search parameters 556 if targeted_search: 557 if target_search_dict is None: 558 raise ValueError( 559 "target_search_dict must be provided when targeted_search is True" 560 ) 561 required_keys = [ 562 "target_mz_list", 563 "target_rt_list", 564 "mz_tolerance_ppm", 565 "rt_tolerance", 566 ] 567 for key in required_keys: 568 if key not in target_search_dict: 569 raise ValueError(f"target_search_dict must contain '{key}'") 570 if len(target_search_dict["target_mz_list"]) != len( 571 target_search_dict["target_rt_list"] 572 ): 573 raise ValueError( 574 "target_mz_list and target_rt_list must have the same length" 575 ) 576 577 requested_method = self.parameters.lc_ms.peak_picking_method 578 pp_method = self._resolve_lcms_peak_picking_method(ms_level=ms_level) 579 if requested_method == "by_format": 580 self.parameters.lc_ms.peak_picking_method = pp_method 581 582 if targeted_search: 583 mf_type = target_search_dict.get("type", "targeted") 584 else: 585 mf_type = "untargeted" 586 587 if pp_method == "persistent homology": 588 self.find_mass_features_ph( 589 ms_level=ms_level, 590 grid=grid, 591 targeted_search=targeted_search, 592 target_search_dict=target_search_dict, 593 mf_type=mf_type, 594 accumulate_features=accumulate_features, 595 ) 596 elif pp_method == "centroided_persistent_homology": 597 self.find_mass_features_ph_centroid( 598 ms_level=ms_level, 599 targeted_search=targeted_search, 600 target_search_dict=target_search_dict, 601 mf_type=mf_type, 602 accumulate_features=accumulate_features, 603 ) 604 else: 605 raise ValueError("Peak picking method not implemented") 606 607 # Cluster mass features to remove redundant features 608 self.cluster_mass_features(drop_children=True) 609 610 # Optionally assign MS2 scan numbers to mass features during peak picking 611 # This helps with choosing representative features that have MS2 data 612 if assign_ms2_scans: 613 try: 614 self._find_ms2_scans_for_mass_features( 615 mf_ids=None, # Process all mass features 616 scan_filter=ms2_scan_filter, 617 ) 618 except ValueError: 619 # No MS2 scans found - this is okay, just skip 620 pass 621 622 # Remove noisey mass features if designated in parameters 623 if self.parameters.lc_ms.remove_redundant_mass_features and not targeted_search: 624 self._remove_redundant_mass_features() 625 626 def integrate_mass_features( 627 self, 628 drop_if_fail=True, 629 drop_duplicates=True, 630 ms_level=1, 631 induced_features=False, 632 ): 633 """Integrate mass features and extract EICs. 634 635 Populates the _eics attribute on the LCMSBase object for each unique mz in the mass_features dataframe and adds data (start_scan, final_scan, area) to the mass_features attribute. 636 637 Parameters 638 ---------- 639 drop_if_fail : bool, optional 640 Whether to drop mass features if the EIC limit calculations fail. 641 Default is True. 642 drop_duplicates : bool, optional 643 Whether to mass features that appear to be duplicates 644 (i.e., mz is similar to another mass feature and limits of the EIC are similar or encapsulating). 645 Default is True. 646 ms_level : int, optional 647 The MS level to use. Default is 1. 648 induced_features : bool, optional 649 Whether the mass features to be intergrated were induced. Default is False. 650 651 Raises 652 ------ 653 ValueError 654 If no mass features are found. 655 If no MS level data is found for the given MS level (either in data or in the scan data) 656 657 Returns 658 ------- 659 None, but populates the eics attribute on the LCMSBase object and adds data (start_scan, final_scan, area) to the mass_features attribute. 660 661 Notes 662 ----- 663 drop_if_fail is useful for discarding mass features that do not have good shapes, usually due to a detection on a shoulder of a peak or a noisy region (especially if minimal smoothing is used during mass feature detection). 664 """ 665 666 # Check if there is data 667 if ms_level in self._ms_unprocessed.keys(): 668 raw_data = self._ms_unprocessed[ms_level].copy() 669 else: 670 raise ValueError("No MS level " + str(ms_level) + " data found") 671 672 # Check if mass_spectrum exists on each mass feature 673 if induced_features: 674 mf_dict = self.induced_mass_features 675 if len(mf_dict) == 0: 676 raise ValueError( 677 "No induced mass features found, did you run fill_missing_cluster_features() first?" 678 ) 679 680 ## remove not found induced mass features by mz <= 0 (-99 indicator) 681 # also remove any where mz is nan 682 mf_dict = { 683 k: v for k, v in mf_dict.items() if v.mz > 0 and not np.isnan(v.mz) 684 } 685 686 else: 687 mf_dict = self.mass_features 688 if len(mf_dict) == 0: 689 raise ValueError( 690 "No mass features found, did you run find_mass_features() first?" 691 ) 692 693 # Subset scan data to only include correct ms_level 694 scan_df_sub = self.scan_df[ 695 self.scan_df["ms_level"] == int(ms_level) 696 ].reset_index(drop=True) 697 if scan_df_sub.empty: 698 raise ValueError("No MS level " + ms_level + " data found in scan data") 699 scan_df_sub = scan_df_sub[["scan", "scan_time"]].copy() 700 701 mzs_to_extract = np.unique([mf.mz for mf in mf_dict.values()]) 702 mzs_to_extract.sort() 703 704 # Pre-sort raw_data by mz for faster filtering 705 raw_data_sorted = raw_data.sort_values(["mz", "scan"]).reset_index(drop=True) 706 raw_data_mz = raw_data_sorted["mz"].to_numpy() 707 raw_scan = raw_data_sorted["scan"].to_numpy() 708 raw_intensity = raw_data_sorted["intensity"].to_numpy() 709 scan_ids = scan_df_sub["scan"].to_numpy() 710 scan_time = scan_df_sub["scan_time"].to_numpy() 711 n_scans = scan_ids.size 712 # Sum into the scan-table row with the same float32 Kahan sum the 713 # previous groupby used, so the chromatogram values stay identical. 714 scan_order = np.argsort(scan_ids, kind="mergesort") 715 sorted_scan_ids = scan_ids[scan_order] 716 found = np.searchsorted(sorted_scan_ids, raw_scan) 717 in_range = found < n_scans 718 scan_col = np.empty(raw_scan.shape, dtype=np.intp) 719 scan_col[in_range] = scan_order[found[in_range]] 720 matched = np.zeros(raw_scan.shape, dtype=bool) 721 matched[in_range] = sorted_scan_ids[found[in_range]] == raw_scan[in_range] 722 723 # Get EICs for each unique mz in mass features list 724 for mz in mzs_to_extract: 725 mz_max = mz + self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 726 mz_min = mz - self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 727 728 # Use binary search for faster mz range filtering 729 left_idx = int(np.searchsorted(raw_data_mz, mz_min, side="left")) 730 right_idx = int(np.searchsorted(raw_data_mz, mz_max, side="right")) 731 eic = np.zeros(n_scans, dtype=np.float32) 732 if left_idx < right_idx: 733 comp = np.zeros(n_scans, dtype=np.float32) 734 cols = scan_col[left_idx:right_idx] 735 vals = raw_intensity[left_idx:right_idx] 736 ok = matched[left_idx:right_idx] 737 for i in range(cols.size): 738 if not ok[i]: 739 continue 740 col = cols[i] 741 val = vals[i] 742 y = val - comp[col] 743 t = eic[col] + y 744 c = (t - eic[col]) - y 745 comp[col] = c if np.isfinite(c) else np.float32(0) 746 eic[col] = t 747 myEIC = EIC_Data( 748 scans=scan_ids.copy(), 749 time=scan_time.copy(), 750 eic=eic, 751 ) 752 # Smooth EIC 753 smoothed_eic = self.smooth_tic(myEIC.eic) 754 smoothed_eic[smoothed_eic < 0] = 0 755 myEIC.eic_smoothed = smoothed_eic 756 self.eics[mz] = myEIC 757 758 # Get limits of mass features using EIC centroid detector and integrate 759 for idx, mass_feature in list(mf_dict.items()): 760 mz = mass_feature.mz 761 apex_scan = mass_feature.apex_scan 762 763 # Pull EIC data and find apex scan index 764 myEIC = self.eics[mz] 765 mf_dict[idx]._eic_data = myEIC 766 mf_dict[idx]._eic_mz = mz 767 apex_index = np.searchsorted(myEIC.scans, apex_scan) 768 769 # Find left and right limits of peak using EIC centroid detector, add to EICData 770 centroid_eics = self.eic_centroid_detector( 771 myEIC.time, 772 myEIC.eic_smoothed, 773 mass_feature.intensity * 1.1, 774 apex_indexes=[int(apex_index)], 775 ) 776 l_a_r_scan_idx = [i for i in centroid_eics] 777 if len(l_a_r_scan_idx) > 0: 778 # Calculate number of consecutive scans with intensity > 0 and check if it is above the minimum consecutive scans 779 # Find the number of consecutive non-zero values in the EIC segment 780 mask = myEIC.eic[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1] > 0 781 # Find the longest run of consecutive True values 782 if np.any(mask): 783 # Find indices where mask changes value 784 diff = np.diff(np.concatenate(([0], mask.astype(int), [0]))) 785 starts = np.where(diff == 1)[0] 786 ends = np.where(diff == -1)[0] 787 consecutive_scans = (ends - starts).max() 788 else: 789 consecutive_scans = 0 790 if consecutive_scans < self.parameters.lc_ms.consecutive_scan_min: 791 mf_dict.pop(idx) 792 continue 793 # Add start and final scan to mass_features and EICData 794 left_scan, right_scan = ( 795 myEIC.scans[l_a_r_scan_idx[0][0]], 796 myEIC.scans[l_a_r_scan_idx[0][2]], 797 ) 798 mf_scan_apex = [(left_scan, int(apex_scan), right_scan)] 799 myEIC.apexes = myEIC.apexes + mf_scan_apex 800 mf_dict[idx].start_scan = left_scan 801 mf_dict[idx].final_scan = right_scan 802 803 # Find area under peak using limits from EIC centroid detector, add to mass_features and EICData 804 area = np.trapezoid( 805 myEIC.eic_smoothed[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 806 myEIC.time[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 807 ) 808 myEIC.areas = myEIC.areas + [area] 809 self.eics[mz] = myEIC 810 mf_dict[idx]._area = area 811 else: 812 if drop_if_fail is True: 813 mf_dict.pop(idx) 814 815 if drop_duplicates: 816 # Prepare mass feature dataframe 817 # Skip features without scan bounds (failed EIC limits when 818 # drop_if_fail=False); they cannot participate in containment de-dup 819 if induced_features: 820 mf_df = self.mass_features_to_df(induced_features=True).copy() 821 mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()] 822 else: 823 mf_df = self.mass_features_to_df(induced_features=False).copy() 824 mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()] 825 826 # For each mass feature, find all mass features within the clustering tolerance ppm and drop if their start and end times are within another mass feature 827 # Keep the first mass feature (highest persistence) 828 for idx, mass_feature in mf_df.iterrows(): 829 mz = mass_feature.mz 830 apex_scan = mass_feature.apex_scan 831 832 mf_df["mz_diff_ppm"] = np.abs(mf_df["mz"] - mz) / mz * 10**6 833 mf_df_sub = mf_df[ 834 mf_df["mz_diff_ppm"] 835 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 836 * 10**6 837 ].copy() 838 839 # For all mass features within the clustering tolerance, check if the start and end times are within the start and end times of the mass feature 840 for idx2, mass_feature2 in mf_df_sub.iterrows(): 841 if idx2 != idx: 842 s1, e1 = mass_feature.start_scan, mass_feature.final_scan 843 s2, e2 = mass_feature2.start_scan, mass_feature2.final_scan 844 # Guard incomplete bounds (None / NaN) so ordering 845 # comparisons never raise TypeError 846 if s1 is None or e1 is None or s2 is None or e2 is None: 847 continue 848 if pd.isna(s1) or pd.isna(e1) or pd.isna(s2) or pd.isna(e2): 849 continue 850 if s2 >= s1 and e2 <= e1: 851 if idx2 in self.mass_features.keys(): 852 self.mass_features.pop(idx2) 853 854 # Filter MS2 scans to only include those within integration bounds 855 # This ensures MS2 scans outside start_scan to final_scan are removed 856 if induced_features: 857 self._filter_ms2_scans_by_integration_bounds( 858 mf_dict=self.induced_mass_features 859 ) 860 else: 861 self._filter_ms2_scans_by_integration_bounds(mf_dict=self.mass_features) 862 863 def find_c13_mass_features(self): 864 """Mark likely C13 isotopes and connect to monoisoitopic mass features. 865 866 Returns 867 ------- 868 None, but populates the monoisotopic_mf_id and isotopologue_type attributes to the indivual LCMSMassFeatures within the mass_features attribute of the LCMSBase object. 869 870 Raises 871 ------ 872 ValueError 873 If no mass features are found. 874 """ 875 verbose = self.parameters.lc_ms.verbose_processing 876 if verbose: 877 print("evaluating mass features for C13 isotopes") 878 if self.mass_features is None: 879 raise ValueError("No mass features found, run find_mass_features() first") 880 881 # Data prep fo sparse distance matrix 882 dims = ["mz", "scan_time"] 883 mf_df = self.mass_features_to_df().copy() 884 # Drop mass features that have no area (these are likely to be noise) 885 mf_df = mf_df[mf_df["area"].notnull()] 886 mf_df["mf_id"] = mf_df.index.values 887 dims = ["mz", "scan_time"] 888 889 # Sort my ascending mz so we always get the monoisotopic mass first, regardless of the order/intensity of the mass features 890 mf_df = mf_df.sort_values(by=["mz"]).reset_index(drop=True).copy() 891 892 mz_diff = 1.003355 # C13-C12 mass difference 893 tol = [ 894 mf_df["mz"].median() 895 * self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 896 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance * 0.5, 897 ] # mz, in relative; scan_time in minutes 898 899 # Compute inter-feature distances 900 distances = None 901 for i in range(len(dims)): 902 # Construct k-d tree 903 values = mf_df[dims[i]].values 904 tree = KDTree(values.reshape(-1, 1)) 905 906 max_tol = tol[i] 907 if dims[i] == "mz": 908 # Maximum absolute tolerance 909 max_tol = mz_diff + tol[i] 910 911 # Compute sparse distance matrix 912 # the larger the max_tol, the slower this operation is 913 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 914 915 # Only consider forward case, exclude diagonal 916 sdm = sparse.triu(sdm, k=1) 917 918 if dims[i] == "mz": 919 min_tol = mz_diff - tol[i] 920 # Get only the ones that are above the min tol 921 idx = sdm.data > min_tol 922 923 # Reconstruct sparse distance matrix 924 sdm = sparse.coo_matrix( 925 (sdm.data[idx], (sdm.row[idx], sdm.col[idx])), 926 shape=(len(values), len(values)), 927 ) 928 929 # Cast as binary matrix 930 sdm.data = np.ones_like(sdm.data) 931 932 # Stack distances 933 if distances is None: 934 distances = sdm 935 else: 936 distances = distances.multiply(sdm) 937 938 # Extract indices of within-tolerance points 939 distances = distances.tocoo() 940 pairs = np.stack((distances.row, distances.col), axis=1) # C12 to C13 pairs 941 942 # Turn pairs (which are index of mf_df) into mf_id and then into two dataframes to join to mf_df 943 pairs_mf = pairs.copy() 944 pairs_mf[:, 0] = mf_df.iloc[pairs[:, 0]].mf_id.values 945 pairs_mf[:, 1] = mf_df.iloc[pairs[:, 1]].mf_id.values 946 947 # Connect monoisotopic masses with isotopologes within mass_features 948 monos = np.setdiff1d(np.unique(pairs_mf[:, 0]), np.unique(pairs_mf[:, 1])) 949 for mono in monos: 950 self.mass_features[mono].monoisotopic_mf_id = mono 951 pairs_iso_df = pd.DataFrame(pairs_mf, columns=["parent", "child"]) 952 while not pairs_iso_df.empty: 953 pairs_iso_df = pairs_iso_df.set_index("parent", drop=False) 954 m1_isos = pairs_iso_df.loc[monos, "child"].unique() 955 for iso in m1_isos: 956 # Set monoisotopic_mf_id and isotopologue_type for isotopologues 957 parent = pairs_mf[pairs_mf[:, 1] == iso, 0] 958 if len(parent) > 1: 959 # Choose the parent that is closest in time to the isotopologue 960 parent_time = [self.mass_features[p].retention_time for p in parent] 961 time_diff = [ 962 np.abs(self.mass_features[iso].retention_time - x) 963 for x in parent_time 964 ] 965 parent = parent[np.argmin(time_diff)] 966 else: 967 parent = parent[0] 968 self.mass_features[iso].monoisotopic_mf_id = self.mass_features[ 969 parent 970 ].monoisotopic_mf_id 971 if self.mass_features[iso].monoisotopic_mf_id is not None: 972 mass_diff = ( 973 self.mass_features[iso].mz 974 - self.mass_features[ 975 self.mass_features[iso].monoisotopic_mf_id 976 ].mz 977 ) 978 self.mass_features[iso].isotopologue_type = "13C" + str( 979 int(round(mass_diff, 0)) 980 ) 981 982 # Drop the mono and iso from the pairs_iso_df 983 pairs_iso_df = pairs_iso_df.drop( 984 index=monos, errors="ignore" 985 ) # Drop pairs where the parent is a child that is a child of a root 986 pairs_iso_df = pairs_iso_df.set_index("child", drop=False) 987 pairs_iso_df = pairs_iso_df.drop(index=m1_isos, errors="ignore") 988 989 if not pairs_iso_df.empty: 990 # Get new monos, recognizing that these are just 13C isotopologues that are connected to other 13C isotopologues to repeat the process 991 monos = np.setdiff1d( 992 np.unique(pairs_iso_df.parent), np.unique(pairs_iso_df.child) 993 ) 994 if verbose: 995 # Report fraction of compounds annotated with isotopes 996 mf_df["c13_flag"] = np.where( 997 np.logical_or( 998 np.isin(mf_df["mf_id"], pairs_mf[:, 0]), 999 np.isin(mf_df["mf_id"], pairs_mf[:, 1]), 1000 ), 1001 1, 1002 0, 1003 ) 1004 print( 1005 str(round(len(mf_df[mf_df["c13_flag"] == 1]) / len(mf_df), ndigits=3)) 1006 + " of mass features have or are C13 isotopes" 1007 ) 1008 1009 def deconvolute_ms1_mass_features(self): 1010 """Deconvolute MS1 mass features 1011 1012 Deconvolute mass features ms1 spectrum based on the correlation of all masses within a spectrum over the EIC of the mass features 1013 1014 Parameters 1015 ---------- 1016 None 1017 1018 Returns 1019 ------- 1020 None, but assigns the _ms_deconvoluted_idx, mass_spectrum_deconvoluted_parent, 1021 and associated_mass_features_deconvoluted attributes to the mass features in the 1022 mass_features attribute of the LCMSBase object. 1023 1024 Raises 1025 ------ 1026 ValueError 1027 If no mass features are found, must run find_mass_features() first. 1028 If no EICs are found, did you run integrate_mass_features() first? 1029 1030 """ 1031 # Checks for set mass_features and eics 1032 if self.mass_features is None: 1033 raise ValueError( 1034 "No mass features found, did you run find_mass_features() first?" 1035 ) 1036 1037 if self.eics == {}: 1038 raise ValueError( 1039 "No EICs found, did you run integrate_mass_features() first?" 1040 ) 1041 1042 if 1 not in self._ms_unprocessed.keys(): 1043 raise ValueError("No unprocessed MS1 spectra found.") 1044 1045 # Prep ms1 data 1046 ms1_data = self._ms_unprocessed[1].copy() 1047 ms1_data = ms1_data.set_index("scan") 1048 1049 # Prep mass feature summary 1050 mass_feature_df = self.mass_features_to_df() 1051 1052 # Loop through each mass feature 1053 for mf_id, mass_feature in self.mass_features.items(): 1054 # Check that the mass_feature.mz attribute == the mz of the mass feature in the mass_feature_df 1055 if mass_feature.mz != mass_feature.ms1_peak.mz_exp: 1056 continue 1057 1058 # Get the left and right limits of the EIC of the mass feature 1059 l_scan, _, r_scan = mass_feature._eic_data.apexes[0] 1060 1061 # Pull from the _ms1_unprocessed data the scan range of interest and sort by mz 1062 ms1_data_sub = ms1_data.loc[l_scan:r_scan].copy() 1063 ms1_data_sub = ms1_data_sub.sort_values(by=["mz"]).reset_index(drop=False) 1064 1065 # Get the centroided masses of the mass feature 1066 mf_mspeak_mzs = mass_feature.mass_spectrum.mz_exp 1067 1068 # Find the closest mz in the ms1 data to the centroided masses of the mass feature 1069 ms1_data_sub["mass_feature_mz"] = mf_mspeak_mzs[ 1070 find_closest(mf_mspeak_mzs, ms1_data_sub.mz.values) 1071 ] 1072 1073 # Drop rows with mz_diff > 0.01 between the mass feature mz and the ms1 data mz 1074 ms1_data_sub["mz_diff_rel"] = ( 1075 np.abs(ms1_data_sub["mass_feature_mz"] - ms1_data_sub["mz"]) 1076 / ms1_data_sub["mz"] 1077 ) 1078 ms1_data_sub = ms1_data_sub[ 1079 ms1_data_sub["mz_diff_rel"] 1080 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 1081 ].reset_index(drop=True) 1082 1083 # Group by mass_feature_mz and scan and sum intensity 1084 ms1_data_sub_group = ( 1085 ms1_data_sub.groupby(["mass_feature_mz", "scan"])["intensity"] 1086 .sum() 1087 .reset_index() 1088 ) 1089 1090 # Calculate the correlation of the intensities of the mass feature and the ms1 data (set to 0 if no intensity) 1091 corr = ( 1092 ms1_data_sub_group.pivot( 1093 index="scan", columns="mass_feature_mz", values="intensity" 1094 ) 1095 .fillna(0) 1096 .corr() 1097 ) 1098 1099 # Subset the correlation matrix to only include the masses of the mass feature and those with a correlation > 0.8 1100 decon_corr_min = self.parameters.lc_ms.ms1_deconvolution_corr_min 1101 1102 # Try catch for KeyError in case the mass feature mz is not in the correlation matrix 1103 try: 1104 corr_subset = corr.loc[mass_feature.mz] 1105 except KeyError: 1106 # If the mass feature mz is not in the correlation matrix, skip to the next mass feature 1107 continue 1108 1109 corr_subset = corr_subset[corr_subset > decon_corr_min] 1110 1111 # Get the masses from the mass spectrum that are the result of the deconvolution 1112 mzs_decon = corr_subset.index.values 1113 1114 # Get the indices of the mzs_decon in mass_feature.mass_spectrum.mz_exp and assign to the mass feature 1115 mzs_decon_idx = [ 1116 id 1117 for id, mz in enumerate(mass_feature.mass_spectrum.mz_exp) 1118 if mz in mzs_decon 1119 ] 1120 mass_feature._ms_deconvoluted_idx = mzs_decon_idx 1121 1122 # Check if the mass feature's ms1 peak is the largest in the deconvoluted mass spectrum 1123 if ( 1124 mass_feature.ms1_peak.abundance 1125 == mass_feature.mass_spectrum.abundance[mzs_decon_idx].max() 1126 ): 1127 mass_feature.mass_spectrum_deconvoluted_parent = True 1128 else: 1129 mass_feature.mass_spectrum_deconvoluted_parent = False 1130 1131 # Check for other mass features that are in the deconvoluted mass spectrum and add the deconvoluted mass spectrum to the mass feature 1132 # Subset mass_feature_df to only include mass features that are within the clustering tolerance 1133 mass_feature_df_sub = mass_feature_df[ 1134 abs(mass_feature.retention_time - mass_feature_df["scan_time"]) 1135 < self.parameters.lc_ms.mass_feature_cluster_rt_tolerance 1136 ].copy() 1137 # Calculate the mz difference in ppm between the mass feature and the peaks in the deconvoluted mass spectrum 1138 mass_feature_df_sub["mz_diff_ppm"] = [ 1139 np.abs(mzs_decon - mz).min() / mz * 10**6 1140 for mz in mass_feature_df_sub["mz"] 1141 ] 1142 # Subset mass_feature_df to only include mass features that are within 1 ppm of the deconvoluted masses 1143 mfs_associated_decon = mass_feature_df_sub[ 1144 mass_feature_df_sub["mz_diff_ppm"] 1145 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 10**6 1146 ].index.values 1147 1148 mass_feature.associated_mass_features_deconvoluted = mfs_associated_decon 1149 1150 def _remove_redundant_mass_features( 1151 self, 1152 ) -> None: 1153 """ 1154 Identify and remove redundant mass features that are likely contaminants based on their m/z values and scan frequency. 1155 Especially useful for HILIC data where signals do not return to baseline between peaks or for data with significant background noise. 1156 1157 Contaminants are characterized by: 1158 1. Similar m/z values (within ppm_tolerance) 1159 2. High frequency across scan numbers (ubiquitous presence) 1160 1161 Notes 1162 ----- 1163 Depends on self.mass_features being populated, uses the parameters in self.parameters.lc_ms for tolerances (mass_feature_cluster_mz_tolerance_rel) 1164 """ 1165 ppm_tolerance = ( 1166 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 1e6 1167 ) 1168 min_scan_frequency = self.parameters.lc_ms.redundant_scan_frequency_min 1169 n_retain = self.parameters.lc_ms.redundant_feature_retain_n 1170 1171 df = self.mass_features_to_df() 1172 1173 if df.empty: 1174 return pd.DataFrame() 1175 # df index should be mf_id 1176 if "mf_id" not in df.columns: 1177 if "mf_id" in df.index.names: 1178 df = df.reset_index() 1179 else: 1180 raise ValueError("DataFrame must contain 'mf_id' column or index.") 1181 1182 # Sort by m/z for efficient grouping 1183 df_sorted = df.sort_values("mz").reset_index(drop=True) 1184 1185 # Calculate total number of unique scans for frequency calculation 1186 # Calculate total possible scans (check the cluster rt tolerance and the min rt and max rt of the data) 1187 total_time = self.scan_df["scan_time"].max() - self.scan_df["scan_time"].min() 1188 cluster_rt_tolerance = self.parameters.lc_ms.mass_feature_cluster_rt_tolerance 1189 # If the feature was detected in every possible scan (and then rolled up), it would be in this many scans 1190 total_scans = int(total_time / cluster_rt_tolerance) + 1 1191 1192 # Group similar m/z values using ppm tolerance 1193 mz_groups = [] 1194 current_group = [] 1195 1196 for i, row in df_sorted.iterrows(): 1197 current_mz = row["mz"] 1198 1199 if not current_group: 1200 # Start first group 1201 current_group = [i] 1202 else: 1203 # Check if current m/z is within tolerance of group representative 1204 group_representative_mz = df_sorted.iloc[current_group[0]]["mz"] 1205 ppm_diff = ( 1206 abs(current_mz - group_representative_mz) 1207 / group_representative_mz 1208 * 1e6 1209 ) 1210 1211 if ppm_diff <= ppm_tolerance: 1212 # Add to current group 1213 current_group.append(i) 1214 else: 1215 # Start new group, but first process current group 1216 if len(current_group) > 0: 1217 mz_groups.append(current_group) 1218 current_group = [i] 1219 1220 # Don't forget the last group 1221 if current_group: 1222 mz_groups.append(current_group) 1223 1224 # Analyze each m/z group for contaminant characteristics 1225 1226 for group_indices in mz_groups: 1227 group_data = df_sorted.iloc[group_indices] 1228 1229 # Calculate group statistics 1230 unique_scans = group_data["apex_scan"].nunique() 1231 scan_frequency = unique_scans / total_scans 1232 1233 # Check if this group meets contaminant criteria 1234 if scan_frequency >= min_scan_frequency: 1235 group_data = group_data.sort_values("intensity", ascending=False) 1236 non_representative_mf_id = group_data.iloc[n_retain:][ 1237 "mf_id" 1238 ].tolist() # These will be removed 1239 1240 self.mass_features = { 1241 k: v 1242 for k, v in self.mass_features.items() 1243 if k not in non_representative_mf_id 1244 } 1245 1246 def _remove_mass_features_by_peak_metrics(self, induced_features=False) -> None: 1247 """Remove mass features based on peak metrics defined in mass_feature_attribute_filter_dict. 1248 1249 This method filters mass features based on various peak shape metrics and quality indicators 1250 such as noise scores, Gaussian similarity, tailing factors, dispersity index, etc. 1251 1252 The filtering criteria are defined in the mass_feature_attribute_filter_dict parameter, 1253 which should contain attribute names as keys and filter specifications as values. 1254 1255 Filter specification format: 1256 {attribute_name: {'value': threshold, 'operator': comparison}} 1257 1258 Available operators: 1259 - '>' or 'greater': Keep features where attribute > threshold 1260 - '<' or 'less': Keep features where attribute < threshold 1261 - '>=' or 'greater_equal': Keep features where attribute >= threshold 1262 - '<=' or 'less_equal': Keep features where attribute <= threshold 1263 1264 Examples: 1265 - {'noise_score_max': {'value': 0.5, 'operator': '>='}} - Keep features with noise_score_max >= 0.5 1266 - {'dispersity_index': {'value': 0.1, 'operator': '<'}} - Keep features with dispersity_index < 0.1 1267 - {'gaussian_similarity': {'value': 0.7, 'operator': '>='}} - Keep features with gaussian_similarity >= 0.7 1268 1269 Parameters 1270 ---------- 1271 induced_features : bool, optional 1272 If True, filter induced_mass_features instead of regular mass_features. Default is False. 1273 1274 Returns 1275 ------- 1276 None 1277 Modifies self.mass_features or self.induced_mass_features in place by removing filtered features. 1278 1279 Raises 1280 ------ 1281 ValueError 1282 If no mass features are found, if an invalid attribute is specified, or if filter specification is malformed. 1283 """ 1284 # Select the appropriate mass features dictionary 1285 if induced_features: 1286 mf_dict = self.induced_mass_features 1287 mf_type = "induced mass features" 1288 else: 1289 mf_dict = self.mass_features 1290 mf_type = "mass features" 1291 1292 if mf_dict is None or len(mf_dict) == 0: 1293 raise ValueError( 1294 f"No {mf_type} found, run {'gap filling' if induced_features else 'find_mass_features()'} first" 1295 ) 1296 1297 filter_dict = self.parameters.lc_ms.mass_feature_attribute_filter_dict 1298 1299 if not filter_dict: 1300 # No filtering criteria specified, return early 1301 return 1302 1303 verbose = self.parameters.lc_ms.verbose_processing 1304 initial_count = len(mf_dict) 1305 1306 if verbose: 1307 print( 1308 f"Filtering {mf_type} using peak metrics. Initial count: {initial_count}" 1309 ) 1310 1311 # List to collect IDs of mass features to remove 1312 features_to_remove = [] 1313 1314 for mf_id, mass_feature in mf_dict.items(): 1315 should_remove = False 1316 1317 for attribute_name, filter_spec in filter_dict.items(): 1318 # Validate filter specification structure 1319 if not isinstance(filter_spec, dict): 1320 raise ValueError( 1321 f"Filter specification for '{attribute_name}' must be a dictionary with 'value' and 'operator' keys" 1322 ) 1323 1324 if "value" not in filter_spec or "operator" not in filter_spec: 1325 raise ValueError( 1326 f"Filter specification for '{attribute_name}' must contain both 'value' and 'operator' keys" 1327 ) 1328 1329 threshold_value = filter_spec["value"] 1330 operator = filter_spec["operator"].lower().strip() 1331 1332 # Validate operator 1333 valid_operators = { 1334 ">", 1335 "<", 1336 ">=", 1337 "<=", 1338 "greater", 1339 "less", 1340 "greater_equal", 1341 "less_equal", 1342 } 1343 if operator not in valid_operators: 1344 raise ValueError( 1345 f"Invalid operator '{operator}' for attribute '{attribute_name}'. Valid operators: {valid_operators}" 1346 ) 1347 1348 # Normalize operator names 1349 operator_map = { 1350 "greater": ">", 1351 "less": "<", 1352 "greater_equal": ">=", 1353 "less_equal": "<=", 1354 } 1355 operator = operator_map.get(operator, operator) 1356 1357 # Get the attribute value from the mass feature 1358 try: 1359 if hasattr(mass_feature, attribute_name): 1360 attribute_value = getattr(mass_feature, attribute_name) 1361 else: 1362 raise ValueError( 1363 f"Mass feature does not have attribute '{attribute_name}'" 1364 ) 1365 1366 # Handle None values or attributes that haven't been calculated 1367 if attribute_value is None: 1368 if verbose: 1369 print( 1370 f"Warning: Mass feature {mf_id} has None value for '{attribute_name}'. Removing feature." 1371 ) 1372 should_remove = True 1373 break 1374 1375 # Handle numpy arrays (like half_height_width which returns mean) 1376 if hasattr(attribute_value, "__len__") and not isinstance( 1377 attribute_value, str 1378 ): 1379 # For arrays, we use the mean or appropriate summary statistic 1380 if attribute_name == "half_height_width": 1381 # half_height_width property already returns the mean 1382 pass 1383 else: 1384 attribute_value = float(np.mean(attribute_value)) 1385 1386 # Handle NaN values 1387 if np.isnan(float(attribute_value)): 1388 if verbose: 1389 print( 1390 f"Warning: Mass feature {mf_id} has NaN value for '{attribute_name}'. Removing feature." 1391 ) 1392 should_remove = True 1393 break 1394 1395 # Apply the threshold comparison based on operator 1396 attribute_value = float(attribute_value) 1397 threshold_value = float(threshold_value) 1398 1399 if operator == ">" and not (attribute_value > threshold_value): 1400 should_remove = True 1401 break 1402 elif operator == "<" and not (attribute_value < threshold_value): 1403 should_remove = True 1404 break 1405 elif operator == ">=" and not (attribute_value >= threshold_value): 1406 should_remove = True 1407 break 1408 elif operator == "<=" and not (attribute_value <= threshold_value): 1409 should_remove = True 1410 break 1411 1412 except (AttributeError, ValueError, TypeError) as e: 1413 if verbose: 1414 print( 1415 f"Error evaluating filter '{attribute_name}' for mass feature {mf_id}: {e}" 1416 ) 1417 should_remove = True 1418 break 1419 1420 if should_remove: 1421 features_to_remove.append(mf_id) 1422 1423 # Remove filtered mass features 1424 for mf_id in features_to_remove: 1425 del mf_dict[mf_id] 1426 1427 if verbose and len(features_to_remove) > 0: 1428 print( 1429 f"Removed {len(features_to_remove)} {mf_type} based on peak metrics. Remaining: {len(mf_dict)}" 1430 ) 1431 1432 # Update the appropriate dictionary 1433 if induced_features: 1434 self.induced_mass_features = mf_dict 1435 else: 1436 self.mass_features = mf_dict 1437 1438 # Clean up unassociated EICs and ms1 data (only for regular features) 1439 self._remove_unassociated_eics() 1440 self._remove_unassociated_ms1_spectra() 1441 1442 def _remove_unassociated_eics(self) -> None: 1443 """Remove EICs that are not associated with any mass features. 1444 1445 This method cleans up the eics attribute by removing any EICs that do not correspond to 1446 any mass features currently stored in the mass_features attribute. This is useful for 1447 freeing up memory and ensuring that only relevant EICs are retained. 1448 1449 Returns 1450 ------- 1451 None 1452 Modifies self.eics in place by removing unassociated EICs. 1453 """ 1454 if self.mass_features is None or len(self.mass_features) == 0: 1455 self.eics = {} 1456 return 1457 1458 # Get the set of m/z values associated with current mass features 1459 associated_mzs = {mf.mz for mf in self.mass_features.values()} 1460 1461 # Remove EICs that are not associated with any mass features 1462 self.eics = {mz: eic for mz, eic in self.eics.items() if mz in associated_mzs} 1463 1464 def _remove_unassociated_ms1_spectra(self) -> None: 1465 """Remove MS1 spectra that are not associated with any mass features. 1466 This method cleans up the _ms_unprocessed attribute by removing any MS1 spectra that do not correspond to 1467 any mass features currently stored in the mass_features attribute. This is useful for freeing up memory 1468 and ensuring that only relevant MS1 spectra are retained. 1469 1470 Returns 1471 ------- 1472 None 1473 """ 1474 if self.mass_features is None or len(self.mass_features) == 0: 1475 self._ms_unprocessed = {} 1476 return 1477 1478 # Get the set of m/z values associated with current mass features 1479 associated_ms1_scans = {mf.apex_scan for mf in self.mass_features.values()} 1480 associated_ms1_scans = [int(scan) for scan in associated_ms1_scans] 1481 1482 # Get keys within the _ms attribute (these are individual MassSpectrum objects) 1483 current_stored_spectra = list(set(self._ms.keys())) 1484 if len(current_stored_spectra) == 0: 1485 return 1486 current_stored_spectra = [int(scan) for scan in current_stored_spectra] 1487 1488 # Filter the current_stored_spectra to only ms1 scans 1489 current_stored_spectra_ms1 = [ 1490 scan for scan in current_stored_spectra if scan in self.ms1_scans 1491 ] 1492 1493 # Remove MS1 spectra that are not associated with any mass features 1494 scans_to_drop = [ 1495 scan 1496 for scan in current_stored_spectra_ms1 1497 if scan not in associated_ms1_scans 1498 ] 1499 for scan in scans_to_drop: 1500 if scan in self._ms: 1501 del self._ms[scan] 1502 1503 1504class PHCalculations: 1505 """Methods for performing calculations related to 2D peak picking via persistent homology on LCMS data. 1506 1507 Notes 1508 ----- 1509 This class is intended to be used as a mixin for the LCMSBase class. 1510 1511 Methods 1512 ------- 1513 * sparse_mean_filter(idx, V, radius=[0, 1, 1]). 1514 Sparse implementation of a mean filter. 1515 * embed_unique_indices(a). 1516 Creates an array of indices, sorted by unique element. 1517 * sparse_upper_star(idx, V). 1518 Sparse implementation of an upper star filtration. 1519 * check_if_grid(data). 1520 Check if the data is gridded in mz space. 1521 * grid_data(data). 1522 Grid the data in the mz dimension. 1523 * find_mass_features_ph(ms_level=1, grid=True). 1524 Find mass features within an LCMSBase object using persistent homology. 1525 * cluster_mass_features(drop_children=True). 1526 Cluster regions of interest. 1527 """ 1528 1529 @staticmethod 1530 def sparse_mean_filter(idx, V, radius=[0, 1, 1]): 1531 """Sparse implementation of a mean filter. 1532 1533 Parameters 1534 ---------- 1535 idx : :obj:`~numpy.array` 1536 Edge indices for each dimension (MxN). 1537 V : :obj:`~numpy.array` 1538 Array of intensity data (Mx1). 1539 radius : float or list 1540 Radius of the sparse filter in each dimension. Values less than 1541 zero indicate no connectivity in that dimension. 1542 1543 Returns 1544 ------- 1545 :obj:`~numpy.array` 1546 Filtered intensities (Mx1). 1547 1548 Notes 1549 ----- 1550 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos. 1551 This is a static method. 1552 """ 1553 1554 # Copy indices 1555 idx = idx.copy().astype(V.dtype) 1556 1557 # Scale 1558 for i, r in enumerate(radius): 1559 # Increase inter-index distance 1560 if r < 1: 1561 idx[:, i] *= 2 1562 1563 # Do nothing 1564 elif r == 1: 1565 pass 1566 1567 # Decrease inter-index distance 1568 else: 1569 idx[:, i] /= r 1570 1571 # Connectivity matrix 1572 cmat = KDTree(idx) 1573 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1574 cmat.setdiag(1) 1575 1576 # Pair indices 1577 I, J = cmat.nonzero() 1578 1579 # Delete cmat 1580 cmat_shape = cmat.shape 1581 del cmat 1582 1583 # Sum over columns 1584 V_sum = sparse.bsr_matrix( 1585 (V[J], (I, I)), shape=cmat_shape, dtype=V.dtype 1586 ).diagonal(0) 1587 1588 # Count over columns 1589 V_count = sparse.bsr_matrix( 1590 (np.ones_like(J), (I, I)), shape=cmat_shape, dtype=V.dtype 1591 ).diagonal(0) 1592 1593 return V_sum / V_count 1594 1595 @staticmethod 1596 def embed_unique_indices(a): 1597 """Creates an array of indices, sorted by unique element. 1598 1599 Parameters 1600 ---------- 1601 a : :obj:`~numpy.array` 1602 Array of unique elements (Mx1). 1603 1604 Returns 1605 ------- 1606 :obj:`~numpy.array` 1607 Array of indices (Mx1). 1608 1609 Notes 1610 ----- 1611 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1612 This is a static method. 1613 """ 1614 1615 def count_tens(n): 1616 # Count tens 1617 ntens = (n - 1) // 10 1618 1619 while True: 1620 ntens_test = (ntens + n - 1) // 10 1621 1622 if ntens_test == ntens: 1623 return ntens 1624 else: 1625 ntens = ntens_test 1626 1627 def arange_exclude_10s(n): 1628 # How many 10s will there be? 1629 ntens = count_tens(n) 1630 1631 # Base array 1632 arr = np.arange(0, n + ntens) 1633 1634 # Exclude 10s 1635 arr = arr[(arr == 0) | (arr % 10 != 0)][:n] 1636 1637 return arr 1638 1639 # Creates an array of indices, sorted by unique element 1640 idx_sort = np.argsort(a) 1641 idx_unsort = np.argsort(idx_sort) 1642 1643 # Sorts records array so all unique elements are together 1644 sorted_a = a[idx_sort] 1645 1646 # Returns the unique values, the index of the first occurrence, 1647 # and the count for each element 1648 vals, idx_start, count = np.unique( 1649 sorted_a, return_index=True, return_counts=True 1650 ) 1651 1652 # Splits the indices into separate arrays 1653 splits = np.split(idx_sort, idx_start[1:]) 1654 1655 # Creates unique indices for each split 1656 idx_unq = np.concatenate([arange_exclude_10s(len(x)) for x in splits]) 1657 1658 # Reorders according to input array 1659 idx_unq = idx_unq[idx_unsort] 1660 1661 # Magnitude of each index 1662 exp = np.log10( 1663 idx_unq, where=idx_unq > 0, out=np.zeros_like(idx_unq, dtype=np.float64) 1664 ) 1665 idx_unq_mag = np.power(10, np.floor(exp) + 1) 1666 1667 # Result 1668 return a + idx_unq / idx_unq_mag 1669 1670 @staticmethod 1671 def _greedy_keep_mask(all_values, tol, relative): 1672 """Rows to keep, highest sort_by first. 1673 1674 ``all_values`` is aligned to a frame already sorted by ``sort_by`` 1675 descending. A later row is dropped when a kept row passes every 1676 dimension. Relative limits use the kept row. Values are compared 1677 as float32. No pair matrix is built. 1678 """ 1679 values = [np.asarray(col, dtype=np.float32) for col in all_values] 1680 n = len(values[0]) if values else 0 1681 keep = np.ones(n, dtype=bool) 1682 if n == 0 or any(float(t) < 0 for t in tol): 1683 return keep 1684 n_dims = len(values) 1685 grid = {} 1686 loose = [] 1687 1688 def bin_index(raw, tol_d, rel): 1689 fv = np.float32(raw) 1690 if float(tol_d) == 0.0: 1691 if fv == 0: 1692 fv = np.float32(0.0) 1693 return int(np.asarray(fv).view(np.int32)) 1694 if (not rel) and float(tol_d) > 0.0: 1695 return int(math.floor(float(fv) / float(tol_d))) 1696 if rel and 0.0 < float(tol_d) < 1.0 and float(fv) > 0.0: 1697 return int( 1698 math.floor(math.log(float(fv)) / math.log(1.0 + float(tol_d))) 1699 ) 1700 return None 1701 1702 def query_bins(raw, tol_d, rel): 1703 key = bin_index(raw, tol_d, rel) 1704 if key is None: 1705 return None 1706 if float(tol_d) == 0.0: 1707 return (key,) 1708 fv = float(np.float32(raw)) 1709 if not rel: 1710 width = float(tol_d) 1711 lo = int(math.floor((fv - width) / width)) - 1 1712 hi = int(math.floor((fv + width) / width)) + 1 1713 return range(lo, hi + 1) 1714 log_width = math.log(1.0 + float(tol_d)) 1715 lo_v = fv / (1.0 + float(tol_d)) 1716 hi_v = fv / (1.0 - float(tol_d)) 1717 if lo_v <= 0.0: 1718 return None 1719 lo = int(math.floor(math.log(lo_v) / log_width)) - 1 1720 hi = int(math.floor(math.log(hi_v) / log_width)) + 1 1721 return range(lo, hi + 1) 1722 1723 def claims(parent_i, child_i): 1724 for d in range(n_dims): 1725 pv = values[d][parent_i] 1726 cv = values[d][child_i] 1727 if not np.isfinite(pv) or not np.isfinite(cv): 1728 return False 1729 diff = np.float32(np.abs(np.float32(cv) - np.float32(pv))) 1730 if relative[d]: 1731 limit = np.float32(np.float32(tol[d]) * np.float32(pv)) 1732 else: 1733 limit = np.float32(tol[d]) 1734 if diff > limit: 1735 return False 1736 return True 1737 1738 for i in range(n): 1739 if not all(np.isfinite(values[d][i]) for d in range(n_dims)): 1740 continue 1741 bins = [ 1742 query_bins(values[d][i], tol[d], relative[d]) for d in range(n_dims) 1743 ] 1744 if any(bucket is None for bucket in bins): 1745 search = list(loose) 1746 for bucket in grid.values(): 1747 search.extend(bucket) 1748 else: 1749 search = [] 1750 for combo in product(*bins): 1751 search.extend(grid.get(combo, ())) 1752 search.extend(loose) 1753 if any(claims(parent_i, i) for parent_i in search): 1754 keep[i] = False 1755 continue 1756 keys = [bin_index(values[d][i], tol[d], relative[d]) for d in range(n_dims)] 1757 if any(key is None for key in keys): 1758 loose.append(i) 1759 else: 1760 grid.setdefault(tuple(keys), []).append(i) 1761 return keep 1762 1763 @staticmethod 1764 def roll_up_dataframe( 1765 df: pd.DataFrame, 1766 sort_by: str, 1767 tol: list, 1768 relative: list, 1769 dims: list, 1770 memory_opt_threshold: int = 10000, 1771 ): 1772 """Subset data by rolling up into apex in appropriate dimensions. 1773 1774 Parameters 1775 ---------- 1776 data : pd.DataFrame 1777 The input data containing "dims" columns and the "sort_by" column. 1778 sort_by : str 1779 The column to sort the data by, this will determine which mass features get rolled up into a parent mass feature 1780 (i.e., the mass feature with the highest value in the sort_by column). 1781 dims : list 1782 A list of dimension names (column names in the data DataFrame) to roll up the mass features by. 1783 tol : list 1784 A list of tolerances for each dimension. The length of the list must match the number of dimensions. 1785 The tolerances can be relative (as a fraction of the maximum value in that dimension) or absolute (in the units of that dimension). 1786 If relative is True, a kept row claims a later row when the absolute difference is at most tol times the kept row's value. 1787 If relative is False, the absolute difference must be at most tol. 1788 relative : list 1789 A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False). 1790 memory_opt_threshold : int, optional 1791 Accepted for compatibility. Ignored. Every frame uses the same greedy search. Default is 10000. 1792 1793 Returns 1794 ------- 1795 pd.DataFrame 1796 A DataFrame with only the rolled up mass features, with the original index and columns. 1797 1798 1799 Raises 1800 ------ 1801 ValueError 1802 If the input data is not a pandas DataFrame. 1803 If the input data does not have columns for each of the dimensions in "dims". 1804 If the length of "dims", "tol", and "relative" do not match. 1805 """ 1806 og_columns = df.columns.copy() 1807 1808 # Unindex the data, but keep the original index 1809 if df.index.name is not None: 1810 og_index = df.index.name 1811 else: 1812 og_index = "index" 1813 df = df.reset_index(drop=False) 1814 1815 # Sort data by sort_by column, and reindex 1816 df = df.sort_values(by=sort_by, ascending=False).reset_index(drop=True) 1817 1818 # Check that data is a DataFrame and has columns for each of the dims 1819 if not isinstance(df, pd.DataFrame): 1820 raise ValueError("Data must be a pandas DataFrame") 1821 for dim in dims: 1822 if dim not in df.columns: 1823 raise ValueError(f"Data must have a column for {dim}") 1824 if len(dims) != len(tol) or len(dims) != len(relative): 1825 raise ValueError( 1826 "Dimensions, tolerances, and relative flags must be the same length" 1827 ) 1828 1829 # Pre-compute all values arrays 1830 all_values = [df[dim].values for dim in dims] 1831 1832 keep = PHCalculations._greedy_keep_mask(all_values, tol, relative) 1833 df_sub = df.iloc[np.flatnonzero(keep)] 1834 1835 # Set index back to og_index and only keep original columns 1836 df_sub = df_sub.set_index(og_index).sort_index()[og_columns] 1837 1838 return df_sub 1839 1840 def sparse_upper_star(self, idx, V): 1841 """Sparse implementation of an upper star filtration. 1842 1843 Parameters 1844 ---------- 1845 idx : :obj:`~numpy.array` 1846 Edge indices for each dimension (MxN). 1847 V : :obj:`~numpy.array` 1848 Array of intensity data (Mx1). 1849 Returns 1850 ------- 1851 idx : :obj:`~numpy.array` 1852 Index of filtered points (Mx1). 1853 persistence : :obj:`~numpy.array` 1854 Persistence of each filtered point (Mx1). 1855 1856 Notes 1857 ----- 1858 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1859 """ 1860 1861 # Invert 1862 V = -1 * V.copy().astype(int) 1863 1864 # Embed indices 1865 V = self.embed_unique_indices(V) 1866 1867 # Connectivity matrix 1868 cmat = KDTree(idx) 1869 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1870 cmat.setdiag(1) 1871 cmat = sparse.triu(cmat) 1872 1873 # Pairwise minimums 1874 I, J = cmat.nonzero() 1875 d = np.maximum(V[I], V[J]) 1876 1877 # Delete connectiity matrix 1878 cmat_shape = cmat.shape 1879 del cmat 1880 1881 # Sparse distance matrix 1882 sdm = sparse.coo_matrix((d, (I, J)), shape=cmat_shape) 1883 1884 # Delete pairwise mins 1885 del d, I, J 1886 1887 # Persistence homology 1888 ph = ripser(sdm, distance_matrix=True, maxdim=0)["dgms"][0] 1889 1890 # Bound death values 1891 ph[ph[:, 1] == np.inf, 1] = np.max(V) 1892 1893 # Construct tree to query against 1894 tree = KDTree(V.reshape((-1, 1))) 1895 1896 # Get the indexes of the first nearest neighbor by birth 1897 _, nn = tree.query(ph[:, 0].reshape((-1, 1)), k=1, workers=-1) 1898 1899 return nn, -(ph[:, 0] // 1 - ph[:, 1] // 1) 1900 1901 def check_if_grid(self, data): 1902 """Check if the data are gridded in mz space. 1903 1904 Parameters 1905 ---------- 1906 data : DataFrame 1907 DataFrame containing the mass spectrometry data. Needs to have mz and scan columns. 1908 1909 Returns 1910 ------- 1911 bool 1912 True if the data is gridded in the mz direction, False otherwise. 1913 1914 Notes 1915 ----- 1916 This function is used within the grid_data function and the find_mass_features function and is not intended to be called directly. 1917 """ 1918 # Calculate the difference between consecutive mz values in a single scan 1919 dat_check = data.copy().reset_index(drop=True) 1920 dat_check["mz_diff"] = np.abs(dat_check["mz"].diff()) 1921 mz_diff_min = ( 1922 dat_check.groupby("scan")["mz_diff"].min().min() 1923 ) # within each scan, what is the smallest mz difference between consecutive mz values 1924 1925 # Find the mininum mz difference between mz values in the data; regardless of scan 1926 dat_check_mz = dat_check[["mz"]].drop_duplicates().copy() 1927 dat_check_mz = dat_check_mz.sort_values(by=["mz"]).reset_index(drop=True) 1928 dat_check_mz["mz_diff"] = np.abs(dat_check_mz["mz"].diff()) 1929 1930 # Get minimum mz_diff between mz values in the data 1931 mz_diff_min_raw = dat_check_mz["mz_diff"].min() 1932 1933 # If the minimum mz difference between mz values in the data is less than the minimum mz difference between mz values within a single scan, then the data is not gridded 1934 if mz_diff_min_raw < mz_diff_min: 1935 return False 1936 else: 1937 return True 1938 1939 def grid_data(self, data, attempts=5): 1940 """Grid the data in the mz dimension. 1941 1942 Data must be gridded prior to persistent homology calculations and computing average mass spectrum 1943 1944 Parameters 1945 ---------- 1946 data : DataFrame 1947 The input data containing mz, scan, scan_time, and intensity columns. 1948 attempts : int, optional 1949 The number of attempts to grid the data. Default is 5. 1950 1951 Returns 1952 ------- 1953 DataFrame 1954 The gridded data with mz, scan, scan_time, and intensity columns. 1955 1956 Raises 1957 ------ 1958 ValueError 1959 If gridding fails after the specified number of attempts. 1960 """ 1961 attempt_i = 0 1962 while attempt_i < attempts: 1963 attempt_i += 1 1964 data = self._grid_data(data) 1965 1966 if self.check_if_grid(data): 1967 return data 1968 1969 if not self.check_if_grid(data): 1970 raise ValueError( 1971 "Gridding failed after " 1972 + str(attempt_i) 1973 + " attempts. Please check the data." 1974 ) 1975 else: 1976 return data 1977 1978 def _grid_data(self, data): 1979 """Internal method to grid the data in the mz dimension. 1980 1981 Notes 1982 ----- 1983 This method is called by the grid_data method and should not be called directly. 1984 It will attempt to grid the data in the mz dimension by creating a grid of mz values based on the minimum mz difference within each scan, 1985 but it does not check if the data is already gridded or if the gridding is successful. 1986 1987 Parameters 1988 ---------- 1989 data : pd.DataFrame or pl.DataFrame 1990 The input data to grid. 1991 1992 Returns 1993 ------- 1994 pd.DataFrame or pl.DataFrame 1995 The data after attempting to grid it in the mz dimension. 1996 """ 1997 # Calculate the difference between consecutive mz values in a single scan for grid spacing 1998 data_w = data.copy().reset_index(drop=True) 1999 data_w["mz_diff"] = np.abs(data_w["mz"].diff()) 2000 mz_diff_min = data_w.groupby("scan")["mz_diff"].min().min() * 0.99999 2001 2002 # Need high intensity mz values first so they are parents in the output pairs stack 2003 dat_mz = data_w[["mz", "intensity"]].sort_values( 2004 by=["intensity"], ascending=False 2005 ) 2006 dat_mz = dat_mz[["mz"]].drop_duplicates().reset_index(drop=True).copy() 2007 # Same kept centers as the distance-pair grid. A 1D index replaces the 2008 # pair matrix, which grew with the square of the scan count. 2009 mz_dat_np = np.sort( 2010 _mz_grid_centers(dat_mz["mz"].to_numpy(dtype=np.float64), mz_diff_min) 2011 ) 2012 2013 # Sort data by mz and recast mz to nearest value in mz_dat_np 2014 data_w = data_w.sort_values(by=["mz"]).reset_index(drop=True).copy() 2015 data_w["mz_new"] = mz_dat_np[find_closest(mz_dat_np, data_w["mz"].values)] 2016 data_w["mz_diff"] = np.abs(data_w["mz"] - data_w["mz_new"]) 2017 2018 # Rename mz_new as mz; drop mz_diff; groupby scan and mz and sum intensity 2019 new_data_w = data_w.rename(columns={"mz": "mz_orig", "mz_new": "mz"}).copy() 2020 new_data_w = ( 2021 new_data_w.drop(columns=["mz_diff", "mz_orig"]) 2022 .groupby(["scan", "mz"])["intensity"] 2023 .sum() 2024 .reset_index() 2025 ) 2026 new_data_w = ( 2027 new_data_w.sort_values(by=["scan", "mz"], ascending=[True, True]) 2028 .reset_index(drop=True) 2029 .copy() 2030 ) 2031 2032 return new_data_w 2033 2034 def _filter_data_by_targets(self, data, target_search_dict): 2035 """Filter MS data to only include m/z and RT windows around target values. 2036 2037 Parameters 2038 ---------- 2039 data : pd.DataFrame 2040 MS data with 'mz' and 'scan_time' columns 2041 target_search_dict : dict 2042 Dictionary with target_mz_list, target_rt_list, mz_tolerance_ppm, rt_tolerance 2043 2044 Returns 2045 ------- 2046 pd.DataFrame 2047 Filtered data containing only points within target windows 2048 """ 2049 target_mz_list = target_search_dict["target_mz_list"] 2050 target_rt_list = target_search_dict["target_rt_list"] 2051 mz_tolerance_ppm = target_search_dict["mz_tolerance_ppm"] 2052 rt_tolerance = target_search_dict["rt_tolerance"] 2053 2054 # Create a mask for data points that fall within any target window 2055 mask = np.zeros(len(data), dtype=bool) 2056 2057 for target_mz, target_rt in zip(target_mz_list, target_rt_list): 2058 # Calculate m/z window 2059 mz_tol = target_mz * mz_tolerance_ppm / 1e6 2060 mz_min = target_mz - mz_tol 2061 mz_max = target_mz + mz_tol 2062 2063 # Calculate RT window 2064 rt_min = target_rt - rt_tolerance 2065 rt_max = target_rt + rt_tolerance 2066 2067 # Create mask for this target 2068 target_mask = ( 2069 (data["mz"] >= mz_min) 2070 & (data["mz"] <= mz_max) 2071 & (data["scan_time"] >= rt_min) 2072 & (data["scan_time"] <= rt_max) 2073 ) 2074 2075 # Combine with overall mask 2076 mask |= target_mask 2077 2078 return data[mask].reset_index(drop=True) 2079 2080 def find_mass_features_ph( 2081 self, 2082 ms_level=1, 2083 grid=True, 2084 targeted_search=False, 2085 target_search_dict=None, 2086 mf_type="untargeted", 2087 accumulate_features=False, 2088 ): 2089 """Find mass features within an LCMSBase object using persistent homology. 2090 2091 Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id) 2092 2093 Parameters 2094 ---------- 2095 ms_level : int, optional 2096 The MS level to use. Default is 1. 2097 grid : bool, optional 2098 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True. 2099 targeted_search : bool, optional 2100 If True, perform targeted search mode. Default is False. 2101 target_search_dict : dict or None, optional 2102 Dictionary with target parameters for targeted search. Default is None. 2103 mf_type : str, optional 2104 Type label for the mass features. Default is "untargeted". 2105 accumulate_features : bool, optional 2106 If True, add to existing features rather than replacing them. Default is False. 2107 2108 Raises 2109 ------ 2110 ValueError 2111 If no MS level data is found on the object. 2112 If data is not gridded and grid is False. 2113 2114 Returns 2115 ------- 2116 None, but assigns the mass_features attribute to the object. 2117 2118 Notes 2119 ----- 2120 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 2121 """ 2122 # Check that ms_level is a key in self._ms_uprocessed 2123 if ms_level not in self._ms_unprocessed.keys(): 2124 raise ValueError( 2125 "No MS level " 2126 + str(ms_level) 2127 + " data found, did you instantiate with parser specific to MS level?" 2128 ) 2129 2130 # Get ms data 2131 data = self._ms_unprocessed[ms_level].copy() 2132 2133 # Drop rows with missing intensity values and reset index 2134 data = data.dropna(subset=["intensity"]).reset_index(drop=True) 2135 2136 # Add scan_time for filtering if in targeted mode 2137 if targeted_search: 2138 data = data.merge( 2139 self.scan_df[["scan", "scan_time"]], on="scan", how="left" 2140 ) 2141 2142 # Threshold data (bypass thresholds in targeted mode) 2143 dims = ["mz", "scan_time"] 2144 if targeted_search: 2145 # In targeted mode, bypass intensity and persistence thresholds 2146 threshold = 0 2147 persistence_threshold = 0 2148 # Filter data to only target windows 2149 data_thres = self._filter_data_by_targets(data, target_search_dict) 2150 if len(data_thres) == 0: 2151 if self.parameters.lc_ms.verbose_processing: 2152 print("No data found in target windows") 2153 self.mass_features = {} 2154 return 2155 else: 2156 threshold = max( 2157 self.parameters.lc_ms.ph_inten_min_rel * data.intensity.max(), 2158 self.parameters.lc_ms.ph_inten_min_abs, 2159 ) 2160 persistence_threshold = ( 2161 self.parameters.lc_ms.ph_persis_min_rel * data.intensity.max() 2162 ) 2163 data_thres = ( 2164 data[data["intensity"] > threshold].reset_index(drop=True).copy() 2165 ) 2166 2167 # One m/z grid for the whole run. A separate grid in each scan window 2168 # moves the apex of the same ion. 2169 if not self.check_if_grid(data_thres): 2170 if grid is False: 2171 raise ValueError( 2172 "Data are not gridded in mz dimension, try reprocessing with a different params or grid data before running this function" 2173 ) 2174 data_thres = self.grid_data(data_thres) 2175 if "scan_time" not in data_thres.columns: 2176 data_thres = data_thres.merge( 2177 self.scan_df[["scan", "scan_time"]], on="scan" 2178 ) 2179 2180 return self._find_mass_features_ph_partition( 2181 data_thres, 2182 dims, 2183 persistence_threshold, 2184 mf_type, 2185 accumulate_features, 2186 ) 2187 2188 def _find_mass_features_ph_partition( 2189 self, 2190 data_thres, 2191 dims, 2192 persistence_threshold, 2193 mf_type="untargeted", 2194 accumulate_features=False, 2195 ): 2196 """Find mass features on overlapping scan partitions of one m/z grid. 2197 2198 Splits the already gridded input into overlapping scan partitions, 2199 detects mass features with persistent homology, rolls up duplicates 2200 within and across partitions, and populates mass_features. 2201 2202 Parameters 2203 ---------- 2204 data_thres : pd.DataFrame 2205 The thresholded input data containing mass spectrometry information. 2206 dims : list 2207 List of dimension names (e.g., ["mz", "scan_time"]) used for feature detection. 2208 persistence_threshold : float 2209 Minimum persistence value required for a detected mass feature to be retained. 2210 mf_type : str, optional 2211 Type label for the mass features. Default is "untargeted". 2212 accumulate_features : bool, optional 2213 If True, add to existing features rather than replacing them. Default is False. 2214 2215 Returns 2216 ------- 2217 None 2218 Populates the mass_features attribute of the object with detected mass features. 2219 """ 2220 all_mass_features = [] 2221 2222 # Split scans into partitions 2223 unique_scans = sorted(data_thres["scan"].unique()) 2224 unique_scans_n = len(unique_scans) 2225 if unique_scans_n == 0: 2226 if not accumulate_features: 2227 self.mass_features = {} 2228 return 2229 2230 # Calculate partition size in scans based on goal 2231 partition_size_goal = 5000 2232 scans_per_partition = max( 2233 1, partition_size_goal // (len(data_thres) // unique_scans_n) 2234 ) 2235 2236 # Make partitions based on scans, with overlapping in partitioned scans 2237 scan_overlap = 4 2238 partition_scans = [] 2239 for i in range(0, unique_scans_n, scans_per_partition): 2240 start_idx = max(0, i - scan_overlap) 2241 end_idx = min( 2242 unique_scans_n - 1, i + scans_per_partition - 1 + scan_overlap 2243 ) 2244 scans_group = [int(s) for s in unique_scans[start_idx : end_idx + 1]] 2245 partition_scans.append(scans_group) 2246 2247 # Set index to scan for faster filtering 2248 data_thres = data_thres.set_index("scan") 2249 for scans in partition_scans: 2250 # Determine start and end scan for partition, with 5 scans overlap 2251 partition_data = data_thres.loc[scans].reset_index(drop=False).copy() 2252 2253 if len(partition_data) == 0: 2254 continue 2255 2256 if "scan_time" not in partition_data.columns: 2257 partition_data = partition_data.merge( 2258 self.scan_df[["scan", "scan_time"]], on="scan" 2259 ) 2260 2261 # Build factors for this partition 2262 factors = { 2263 dim: pd.factorize(partition_data[dim], sort=True)[1].astype(np.float32) 2264 for dim in dims 2265 } 2266 2267 # Build indexes 2268 index = { 2269 dim: np.searchsorted(factors[dim], partition_data[dim]).astype( 2270 np.float32 2271 ) 2272 for dim in factors 2273 } 2274 2275 # Process partition 2276 partition_features = self._process_partition_ph( 2277 partition_data, index, dims, persistence_threshold 2278 ) 2279 2280 if len(partition_features) == 0: 2281 continue 2282 2283 # Roll up within partition 2284 partition_features = self.roll_up_dataframe( 2285 df=partition_features, 2286 sort_by="persistence", 2287 dims=["mz", "scan_time"], 2288 tol=[ 2289 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2290 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2291 ], 2292 relative=[True, False], 2293 ) 2294 partition_features = partition_features.reset_index(drop=True) 2295 2296 if len(partition_features) > 0: 2297 all_mass_features.append(partition_features) 2298 2299 # Combine results from all partitions 2300 if all_mass_features: 2301 combined_features = pd.concat(all_mass_features, ignore_index=True) 2302 2303 # Sort by persistence 2304 combined_features = combined_features.sort_values( 2305 by="persistence", ascending=False 2306 ).reset_index(drop=True) 2307 2308 # Remove duplicates from overlapping regions 2309 combined_features = self.roll_up_dataframe( 2310 df=combined_features, 2311 sort_by="persistence", 2312 dims=["mz", "scan_time"], 2313 tol=[ 2314 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2315 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2316 ], 2317 relative=[True, False], 2318 ) 2319 2320 # resort by persistence and reset index 2321 combined_features = combined_features.reset_index(drop=True) 2322 2323 # Populate mass_features attribute 2324 self._populate_mass_features( 2325 combined_features, mf_type, accumulate_features 2326 ) 2327 else: 2328 self.mass_features = {} 2329 2330 def _process_partition_ph(self, partition_data, index, dims, persistence_threshold): 2331 """Process a single partition with persistent homology.""" 2332 # Smooth data 2333 iterations = self.parameters.lc_ms.ph_smooth_it 2334 smooth_radius = [ 2335 self.parameters.lc_ms.ph_smooth_radius_mz, 2336 self.parameters.lc_ms.ph_smooth_radius_scan, 2337 ] 2338 2339 index_array = np.vstack([index[dim] for dim in dims]).T 2340 V = partition_data["intensity"].values 2341 resid = np.inf 2342 2343 for i in range(iterations): 2344 # Previous iteration 2345 V_prev = V.copy() 2346 resid_prev = resid 2347 V = self.sparse_mean_filter(index_array, V, radius=smooth_radius) 2348 2349 # Calculate residual with previous iteration 2350 resid = np.sqrt(np.mean(np.square(V - V_prev))) 2351 2352 # Evaluate convergence 2353 if i > 0: 2354 # Percent change in residual 2355 test = np.abs(resid - resid_prev) / resid_prev 2356 2357 # Exit criteria 2358 if test <= 0: 2359 break 2360 2361 # Overwrite values 2362 partition_data = partition_data.copy() 2363 partition_data["intensity"] = V 2364 2365 # Use persistent homology to find regions of interest 2366 pidx, pers = self.sparse_upper_star(index_array, V) 2367 pidx = pidx[pers > 1] 2368 pers = pers[pers > 1] 2369 2370 if len(pidx) == 0: 2371 return pd.DataFrame() 2372 2373 # Get peaks 2374 peaks = partition_data.iloc[pidx, :].reset_index(drop=True) 2375 2376 # Add persistence column 2377 peaks["persistence"] = pers 2378 mass_features = peaks.sort_values( 2379 by="persistence", ascending=False 2380 ).reset_index(drop=True) 2381 2382 # Filter by persistence threshold 2383 mass_features = mass_features.loc[ 2384 mass_features["persistence"] > persistence_threshold, : 2385 ].reset_index(drop=True) 2386 2387 return mass_features 2388 2389 def _populate_mass_features( 2390 self, mass_features_df, mf_type="untargeted", accumulate_features=False 2391 ): 2392 """Populate the mass_features attribute from a DataFrame. 2393 2394 Parameters 2395 ---------- 2396 mass_features_df : pd.DataFrame 2397 DataFrame containing mass feature information. 2398 Note that the order of this DataFrame will determine the order of mass features in the mass_features attribute. 2399 mf_type : str, optional 2400 Type label for the mass features. Default is "untargeted". 2401 accumulate_features : bool, optional 2402 If True, new features will be added to existing features rather than replacing them. 2403 Mass feature IDs will be offset to avoid conflicts. Default is False. 2404 2405 Returns 2406 ------- 2407 None, but assigns or updates the mass_features attribute to the object. 2408 """ 2409 # Rename scan column to apex_scan 2410 mass_features_df = mass_features_df.rename( 2411 columns={"scan": "apex_scan", "scan_time": "retention_time"} 2412 ) 2413 2414 # Initialize or preserve existing mass_features attribute 2415 if ( 2416 accumulate_features 2417 and self.mass_features is not None 2418 and len(self.mass_features) > 0 2419 ): 2420 # Find the maximum existing ID to offset new IDs and avoid conflicts 2421 id_offset = max(self.mass_features.keys()) + 1 2422 initial_count = len(self.mass_features) 2423 else: 2424 # Replace mode (default/backwards compatible) 2425 self.mass_features = {} 2426 id_offset = 0 2427 initial_count = 0 2428 2429 # Add new mass features 2430 for idx, row in enumerate(mass_features_df.itertuples()): 2431 row_dict = mass_features_df.iloc[row.Index].to_dict() 2432 lcms_feature = LCMSMassFeature(self, **row_dict) 2433 lcms_feature.type = mf_type 2434 # Use sequential ID starting from id_offset to avoid conflicts with existing features 2435 new_id = idx + id_offset 2436 lcms_feature._id = new_id # Update the internal ID 2437 self.mass_features[new_id] = lcms_feature 2438 2439 if self.parameters.lc_ms.verbose_processing: 2440 if accumulate_features and initial_count > 0: 2441 print( 2442 f"Found {len(mass_features_df)} new mass features (total: {len(self.mass_features)})" 2443 ) 2444 else: 2445 print("Found " + str(len(mass_features_df)) + " initial mass features") 2446 2447 def find_mass_features_ph_centroid( 2448 self, 2449 ms_level=1, 2450 targeted_search=False, 2451 target_search_dict=None, 2452 mf_type="untargeted", 2453 accumulate_features=False, 2454 ): 2455 """Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data. 2456 2457 Parameters 2458 ---------- 2459 ms_level : int, optional 2460 The MS level to use. Default is 1. 2461 targeted_search : bool, optional 2462 If True, perform targeted search mode. Default is False. 2463 target_search_dict : dict or None, optional 2464 Dictionary with target parameters for targeted search. Default is None. 2465 mf_type : str, optional 2466 Type label for the mass features. Default is "untargeted". 2467 accumulate_features : bool, optional 2468 If True, add to existing features rather than replacing them. Default is False. 2469 2470 Raises 2471 ------ 2472 ValueError 2473 If no MS level data is found on the object. 2474 2475 Returns 2476 ------- 2477 None, but assigns the mass_features attribute to the object. 2478 """ 2479 # Check that ms_level is a key in self._ms_uprocessed 2480 if ms_level not in self._ms_unprocessed.keys(): 2481 raise ValueError( 2482 "No MS level " 2483 + str(ms_level) 2484 + " data found, did you instantiate with parser specific to MS level?" 2485 ) 2486 2487 # Work with reference instead of copy 2488 data = self._ms_unprocessed[ms_level] 2489 2490 # Merge with scan data first (needed for filtering in targeted mode) 2491 scan_subset = self.scan_df[["scan", "scan_time"]] 2492 data_with_time = data.merge(scan_subset, on="scan", how="inner") 2493 2494 # Calculate threshold and filter (bypass in targeted mode) 2495 if targeted_search: 2496 # In targeted mode, bypass intensity threshold 2497 threshold = 0 2498 valid_mask = data_with_time["intensity"].notna() 2499 required_cols = ["mz", "intensity", "scan", "scan_time"] 2500 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2501 2502 # Filter to target windows 2503 data_thres = self._filter_data_by_targets(data_thres, target_search_dict) 2504 2505 if len(data_thres) == 0: 2506 if self.parameters.lc_ms.verbose_processing: 2507 print("No data found in target windows") 2508 self.mass_features = {} 2509 return 2510 else: 2511 # Normal mode with threshold 2512 max_intensity = data_with_time["intensity"].max() 2513 threshold = max( 2514 self.parameters.lc_ms.ph_inten_min_rel * max_intensity, 2515 self.parameters.lc_ms.ph_inten_min_abs, 2516 ) 2517 valid_mask = data_with_time["intensity"].notna() & ( 2518 data_with_time["intensity"] > threshold 2519 ) 2520 required_cols = ["mz", "intensity", "scan", "scan_time"] 2521 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2522 2523 data_thres["persistence"] = data_thres["intensity"] 2524 mf_df = data_thres 2525 del data_thres, scan_subset, data_with_time 2526 2527 # Order by scan_time and then mz to ensure features near in rt are processed together 2528 # It's ok that different scans are in different partitions; we will roll up later 2529 mf_df = mf_df.sort_values( 2530 by=["scan_time", "mz"], ascending=[True, True] 2531 ).reset_index(drop=True) 2532 partition_size = 10000 2533 partitions = [ 2534 mf_df.iloc[i : i + partition_size].reset_index(drop=True) 2535 for i in range(0, len(mf_df), partition_size) 2536 ] 2537 del mf_df 2538 2539 # Run roll_up_dataframe on each partition 2540 rolled_partitions = [] 2541 for part in partitions: 2542 rolled = self.roll_up_dataframe( 2543 df=part, 2544 sort_by="persistence", 2545 dims=["mz", "scan_time"], 2546 tol=[ 2547 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2548 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2549 ], 2550 relative=[True, False], 2551 ) 2552 rolled_partitions.append(rolled) 2553 del partitions 2554 2555 # Run roll_up_dataframe on the rolled_up partitions to merge features near partition boundaries 2556 2557 # Combine results and run a final roll-up to merge features near partition boundaries 2558 mf_df_final = pd.concat(rolled_partitions, ignore_index=True) 2559 del rolled_partitions 2560 2561 # Reorder by persistence before final roll-up 2562 mf_df_final = mf_df_final.sort_values( 2563 by="persistence", ascending=False 2564 ).reset_index(drop=True) 2565 2566 mf_df_final = self.roll_up_dataframe( 2567 df=mf_df_final, 2568 sort_by="persistence", 2569 dims=["mz", "scan_time"], 2570 tol=[ 2571 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2572 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2573 ], 2574 relative=[True, False], 2575 ) 2576 # reset index 2577 mf_df_final = mf_df_final.reset_index(drop=True) 2578 2579 # Combine rename and sort operations 2580 mass_features = ( 2581 mf_df_final.rename( 2582 columns={"scan": "apex_scan", "scan_time": "retention_time"} 2583 ) 2584 .sort_values(by="persistence", ascending=False) 2585 .reset_index(drop=True) 2586 ) 2587 del mf_df_final # Free memory 2588 2589 # Order by persistence and reset index 2590 mass_features = mass_features.sort_values( 2591 by="persistence", ascending=False 2592 ).reset_index(drop=True) 2593 2594 self.mass_features = {} 2595 for idx, row in mass_features.iterrows(): 2596 row_dict = row.to_dict() 2597 lcms_feature = LCMSMassFeature(self, **row_dict) 2598 lcms_feature.type = mf_type 2599 self.mass_features[lcms_feature.id] = lcms_feature 2600 2601 if self.parameters.lc_ms.verbose_processing: 2602 print("Found " + str(len(mass_features)) + " initial mass features") 2603 2604 def cluster_mass_features(self, drop_children=True, sort_by="persistence"): 2605 """Cluster mass features 2606 2607 Based on their proximity in the mz and scan_time dimensions, priorizies the mass features with the highest persistence. 2608 2609 Parameters 2610 ---------- 2611 drop_children : bool, optional 2612 Whether to drop the mass features that are not cluster parents. Default is True. 2613 sort_by : str, optional 2614 The column to sort the mass features by, this will determine which mass features get rolled up into a parent mass feature. Default is "persistence". 2615 2616 Raises 2617 ------ 2618 ValueError 2619 If no mass features are found. 2620 If too many mass features are found. 2621 2622 Returns 2623 ------- 2624 None if drop_children is True, otherwise returns a list of mass feature ids that are not cluster parents. 2625 """ 2626 if self.mass_features is None: 2627 raise ValueError("No mass features found, run find_mass_features() first") 2628 if len(self.mass_features) > 400000: 2629 raise ValueError( 2630 "Too many mass features of interest found, run find_mass_features() with a higher intensity threshold" 2631 ) 2632 dims = ["mz", "scan_time"] 2633 mf_df_og = self.mass_features_to_df() 2634 mf_df = mf_df_og.copy() 2635 2636 tol = [ 2637 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2638 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2639 ] # mz, in relative; scan_time in minutes 2640 relative = [True, False] 2641 2642 # Roll up mass features based on their proximity in the declared dimensions 2643 mf_df_new = self.roll_up_dataframe( 2644 df=mf_df, sort_by=sort_by, dims=dims, tol=tol, relative=relative 2645 ) 2646 2647 mf_df["cluster_parent"] = np.where( 2648 np.isin(mf_df.index, mf_df_new.index), True, False 2649 ) 2650 2651 # get mass feature ids of features that are not cluster parents 2652 cluster_daughters = mf_df[~mf_df["cluster_parent"]].index.values 2653 if drop_children is True: 2654 # Drop mass features that are not cluster parents from self 2655 self.mass_features = { 2656 k: v 2657 for k, v in self.mass_features.items() 2658 if k not in cluster_daughters 2659 } 2660 else: 2661 return cluster_daughters 2662 2663 2664class LCMSCollectionCalculations: 2665 """Methods for performing calculations related to LCMSCollection objects. 2666 2667 Notes 2668 ----- 2669 This class is intended as a mixin for the LCMSCollection class. 2670 """ 2671 2672 @staticmethod 2673 def _resolve_eic_query_mz(row): 2674 """Pick m/z used to look up ``sample.eics`` for a mass-feature row. 2675 2676 Prefer ``_eic_mz`` when present (set after integrate / gap-fill). 2677 Regular features in ``mass_features_dataframe`` often have NaN 2678 ``_eic_mz`` (never back-filled from objects on light load / reload), 2679 while induced features get ``_eic_mz`` written after gap-fill. Fall 2680 back to the feature ``mz`` so regular and representative traces are 2681 not silently dropped from multi-sample EIC plots. 2682 """ 2683 if row is None: 2684 return None 2685 eic_mz = row.get("_eic_mz") if hasattr(row, "get") else None 2686 if eic_mz is not None and not pd.isna(eic_mz): 2687 return eic_mz 2688 mz = row.get("mz") if hasattr(row, "get") else None 2689 if mz is not None and not pd.isna(mz): 2690 return mz 2691 return None 2692 2693 @staticmethod 2694 def _get_eic_data_for_mz(sample, eic_mz, tolerance=0.0001): 2695 """Resolve EIC data for an m/z using exact key, then tolerance match. 2696 2697 Plotting previously used ``sample.eics.get(eic_mz)`` only. HDF5 EIC 2698 keys and feature ``_eic_mz`` / ``mz`` often differ by float noise, which 2699 silently dropped traces (including the representative). Elsewhere 2700 CoreMS uses the same default tolerance via 2701 ``get_eic_mz_for_mass_feature`` / ``associate_eics_with_mass_features``. 2702 2703 Parameters 2704 ---------- 2705 sample : LCMSBase 2706 Sample that holds ``eics`` (dict keyed by m/z). 2707 eic_mz : float or None 2708 Target m/z (typically from the mass-feature ``_eic_mz`` or ``mz``). 2709 tolerance : float, optional 2710 Maximum |Δm/z| for fallback matching. Default is 0.0001 Da. 2711 2712 Returns 2713 ------- 2714 EIC_Data or None 2715 Matching EIC data, or None if no key is within tolerance. 2716 """ 2717 if eic_mz is None or pd.isna(eic_mz): 2718 return None 2719 if not hasattr(sample, "eics") or not sample.eics: 2720 return None 2721 2722 eic_data = sample.eics.get(eic_mz) 2723 if eic_data is not None: 2724 return eic_data 2725 2726 # Exact key miss: tolerance match (same default as associate_eics_with_mass_features) 2727 if hasattr(sample, "get_eic_mz_for_mass_feature"): 2728 matched_mz = sample.get_eic_mz_for_mass_feature( 2729 float(eic_mz), tolerance=tolerance 2730 ) 2731 if matched_mz is not None: 2732 return sample.eics.get(matched_mz) 2733 return None 2734 2735 # Fallback if sample is a simple mock without the helper 2736 best_key = min(sample.eics, key=lambda k: abs(float(k) - float(eic_mz))) 2737 if abs(float(best_key) - float(eic_mz)) < tolerance: 2738 return sample.eics[best_key] 2739 return None 2740 2741 @staticmethod 2742 def _plot_multiple_eics( 2743 ax, 2744 cluster_mfs, 2745 induced_cluster_mfs, 2746 rep_sample_id, 2747 rep_mf_id, 2748 median_rt, 2749 eic_buffer_time, 2750 plot_smoothed=False, 2751 plot_datapoints=False, 2752 label_samples=False, 2753 lcms_collection=None, 2754 ): 2755 """Internal method to plot multiple EICs from different samples on a given axis. 2756 2757 Parameters 2758 ---------- 2759 ax : matplotlib.axes.Axes 2760 The axis to plot on. 2761 cluster_mfs : pd.DataFrame 2762 DataFrame containing cluster mass features (non-induced). 2763 induced_cluster_mfs : pd.DataFrame or None 2764 DataFrame containing induced (gap-filled) mass features. 2765 rep_sample_id : int 2766 Sample ID of the representative mass feature. 2767 rep_mf_id : int 2768 Mass feature ID of the representative mass feature. 2769 median_rt : float 2770 Median retention time for the cluster. 2771 eic_buffer_time : float 2772 Time buffer around the peak (minutes). 2773 plot_smoothed : bool, optional 2774 If True, plot smoothed EICs. Default is False. 2775 plot_datapoints : bool, optional 2776 If True, plot EIC datapoints. Default is False. 2777 label_samples : bool, optional 2778 If True, label each sample individually. Default is False. 2779 lcms_collection : LCMSCollection, optional 2780 The parent collection object for accessing samples. Required. 2781 """ 2782 ax.set_title("EICs from all samples", loc="left") 2783 2784 # Track if we've added labels for legend (to avoid duplicates) 2785 rep_labeled = False 2786 regular_labeled = False 2787 induced_labeled = False 2788 2789 # Plot regular (non-induced) mass features 2790 for _, row in cluster_mfs.iterrows(): 2791 sample_id = int(row["sample_id"]) 2792 mf_id = row["mf_id"] 2793 sample = lcms_collection[sample_id] 2794 sample_name = row["sample_name"] 2795 2796 # Prefer _eic_mz, else feature mz; then exact/tolerance key match (#257) 2797 eic_data = LCMSCollectionCalculations._get_eic_data_for_mz( 2798 sample, LCMSCollectionCalculations._resolve_eic_query_mz(row) 2799 ) 2800 2801 if eic_data: 2802 # Determine line style and width 2803 if sample_id == rep_sample_id and mf_id == rep_mf_id: 2804 # Representative feature - bold line 2805 linewidth = 2.5 2806 alpha = 1.0 2807 color = "tab:blue" 2808 if label_samples: 2809 label = f"{sample_name} (representative)" 2810 else: 2811 label = "Representative" if not rep_labeled else None 2812 rep_labeled = True 2813 else: 2814 # Other features - thinner line 2815 linewidth = 1.0 2816 alpha = 0.5 2817 color = "tab:blue" 2818 if label_samples: 2819 label = sample_name 2820 else: 2821 label = "Regular features" if not regular_labeled else None 2822 regular_labeled = True 2823 2824 ax.plot( 2825 eic_data.time, 2826 eic_data.eic, 2827 c=color, 2828 linewidth=linewidth, 2829 alpha=alpha, 2830 linestyle="-", 2831 label=label, 2832 ) 2833 2834 if plot_datapoints: 2835 ax.scatter(eic_data.time, eic_data.eic, c=color, alpha=alpha, s=10) 2836 2837 if plot_smoothed and hasattr(eic_data, "eic_smoothed"): 2838 ax.plot( 2839 eic_data.time, 2840 eic_data.eic_smoothed, 2841 c=color, 2842 linestyle="--", 2843 alpha=alpha * 0.8, 2844 linewidth=linewidth * 0.8, 2845 ) 2846 2847 # Plot induced (gap-filled) mass features if available 2848 if induced_cluster_mfs is not None and not induced_cluster_mfs.empty: 2849 for _, row in induced_cluster_mfs.iterrows(): 2850 sample_id = int(row["sample_id"]) 2851 mf_id = row["mf_id"] 2852 sample = lcms_collection[sample_id] 2853 sample_name = row["sample_name"] 2854 2855 # Prefer _eic_mz, else feature mz; then exact/tolerance key match (#257) 2856 eic_data = LCMSCollectionCalculations._get_eic_data_for_mz( 2857 sample, LCMSCollectionCalculations._resolve_eic_query_mz(row) 2858 ) 2859 2860 if eic_data: 2861 # Induced features - even thinner line 2862 linewidth = 0.5 2863 alpha = 0.4 2864 color = "tab:orange" 2865 2866 if label_samples: 2867 label = f"{sample_name} (induced)" 2868 else: 2869 label = "Gap-filled features" if not induced_labeled else None 2870 induced_labeled = True 2871 2872 ax.plot( 2873 eic_data.time, 2874 eic_data.eic, 2875 c=color, 2876 linewidth=linewidth, 2877 alpha=alpha, 2878 linestyle="-", 2879 label=label, 2880 ) 2881 2882 if plot_datapoints: 2883 ax.scatter( 2884 eic_data.time, eic_data.eic, c=color, alpha=alpha, s=5 2885 ) 2886 2887 if plot_smoothed and hasattr(eic_data, "eic_smoothed"): 2888 ax.plot( 2889 eic_data.time, 2890 eic_data.eic_smoothed, 2891 c=color, 2892 linestyle="--", 2893 alpha=alpha * 0.8, 2894 linewidth=linewidth * 0.8, 2895 ) 2896 2897 # Add vertical line at median RT 2898 ax.axvline(x=median_rt, color="k", linestyle="--", alpha=0.7, label="Median RT") 2899 2900 ax.set_ylabel("Intensity") 2901 ax.set_xlabel("Time (minutes)") 2902 ax.set_xlim( 2903 median_rt - eic_buffer_time, 2904 median_rt + eic_buffer_time, 2905 ) 2906 ax.legend(loc="upper left", fontsize=8) 2907 ax.yaxis.get_major_formatter().set_useOffset(False) 2908 2909 def clean_sparse_matrix(self, sparse_matrix): 2910 """Clean a sparse matrix by removing duplicates and sorting. 2911 2912 Parameters 2913 ---------- 2914 sparse_matrix : :obj:`~numpy.array` 2915 A sparse matrix to clean. 2916 2917 Returns 2918 ------- 2919 :obj:`~numpy.array` 2920 A cleaned sparse matrix. 2921 """ 2922 for match in sparse_matrix: 2923 match.sort() 2924 sparse_matrix.sort() 2925 dereplicated_sparse_matrix = np.unique(sparse_matrix, axis=0) 2926 return dereplicated_sparse_matrix 2927 2928 def match_mfs(self, mf_c, mf_i): 2929 """Match mass features between two LCMS objects. 2930 2931 Parameters 2932 ---------- 2933 mf_c : :obj:`~pandas.DataFrame` 2934 The mass features to match against. 2935 mf_i : :obj:`~pandas.DataFrame` 2936 The mass features to match. 2937 2938 Returns 2939 ------- 2940 :obj:`~pandas.DataFrame` 2941 The matched mass features from mf_c. 2942 :obj:`~pandas.DataFrame` 2943 The matched mass features from mf_i. 2944 2945 Notes 2946 ----- 2947 This function has been adapted from the original implementation in the Deimos package: 2948 https://github.com/pnnl/deimos 2949 """ 2950 if mf_c is None or mf_i is None or len(mf_c.index) < 1 or len(mf_i.index) < 1: 2951 return None, None 2952 2953 # Prepare dataframes 2954 mf_c = mf_c.copy() 2955 mf_c["id_i"] = 0 2956 mf_i = mf_i.copy() 2957 mf_i["id_i"] = 1 2958 2959 # Set dimensions for matching 2960 dims = ["mz", "scan_time"] 2961 relative = [True, False] 2962 mz_tol = self.parameters.lcms_collection.alignment_mz_tol_ppm * 1e-6 2963 rt_tol = self.parameters.lcms_collection.alignment_rt_tol 2964 tol = [mz_tol, rt_tol] 2965 2966 # Compute inter-feature distances 2967 idx = [] 2968 for i, f in enumerate(dims): 2969 # vectors 2970 v1 = mf_c[f].values.reshape(-1, 1) 2971 v2 = mf_i[f].values.reshape(-1, 1) 2972 2973 # Distances 2974 d = scipy.spatial.distance.cdist(v1, v2) 2975 2976 if relative[i] is True: 2977 # Divisor 2978 basis = np.repeat(v1, v2.shape[0], axis=1) 2979 fix = np.repeat(v2, v1.shape[0], axis=1).T 2980 basis = np.where(basis == 0, fix, basis) 2981 2982 # Divide 2983 d = np.divide(d, basis, out=np.zeros_like(basis), where=basis != 0) 2984 2985 # Check tol 2986 idx.append(d <= tol[i]) 2987 2988 # Stack truth arrays 2989 idx = np.prod(np.dstack(idx), axis=-1, dtype=bool) 2990 2991 # Compute normalized 3d distance 2992 v1 = mf_c[dims].values / tol 2993 v2 = mf_i[dims].values / tol 2994 dist3d = scipy.spatial.distance.cdist(v1, v2, "cityblock") 2995 2996 # Separate features within tolerance from those outside 2997 # Features outside tolerance should be inf, features within tolerance keep their distance 2998 # Use idx mask: True for within tolerance, False for outside 2999 dist3d_within_tol = np.where(idx, dist3d, np.inf) 3000 3001 # Normalize to 0-1 (only affects within-tolerance distances) 3002 mx = np.max(dist3d_within_tol[idx]) if np.sum(idx) > 0 else 0 3003 if mx > 0: 3004 # Lower distance is better - normalize only the within-tolerance values 3005 dist3d_within_tol = np.where(idx, dist3d_within_tol / mx, np.inf) 3006 else: 3007 # All matches are perfect (distance=0), assign tiny value to within-tolerance pairs 3008 dist3d_within_tol = np.where(idx, 1e-10, np.inf) 3009 3010 # Use the masked distance matrix 3011 dist3d = dist3d_within_tol 3012 3013 # Min over dims 3014 mincols = np.min(dist3d, axis=0, keepdims=True) 3015 3016 # Zero out mincols over dims 3017 dist3d[dist3d != mincols] = np.inf 3018 3019 # Min over clusters 3020 minrows = np.min(dist3d, axis=1, keepdims=True) 3021 3022 # Where max and nonzero 3023 ii, jj = np.where((dist3d == minrows) & (dist3d < np.inf)) 3024 3025 # Reorder 3026 mf_c = mf_c.iloc[ii] 3027 mf_i = mf_i.iloc[jj] 3028 3029 if len(mf_c.index) < 1 or len(mf_i.index) < 1: 3030 return None, None 3031 3032 return mf_c, mf_i 3033 3034 def fit_rts(self, a, b, align="scan_time", **kwargs): 3035 """ 3036 Fit a support vector regressor to matched features. 3037 3038 Parameters 3039 ---------- 3040 a : :obj:`~pandas.DataFrame` 3041 First set of input feature coordinates and intensities; the center object and the object to align to. 3042 b : :obj:`~pandas.DataFrame` 3043 Second set of input feature coordinates and intensities; the object to align to the center object. 3044 align : str 3045 Dimension to align. 3046 kwargs 3047 Keyword arguments for support vector regressor 3048 (:class:`sklearn.svm.SVR`). 3049 3050 Returns 3051 ------- 3052 :obj:`~function` 3053 An interpolation function where one can input a retention time and get the predicted retention time. 3054 3055 Notes 3056 ----- 3057 This function has been adapted from the original implementation in the Deimos package: 3058 https://github.com/pnnl/deimos 3059 3060 """ 3061 3062 # Uniqueify 3063 x = a[align].values 3064 y = b[align].values 3065 arr = np.vstack((x, y)).T 3066 arr = np.unique(arr, axis=0) 3067 3068 # Safety check: ensure we have data to work with 3069 if len(arr) == 0: 3070 warnings.warn( 3071 "No data points available for retention time fitting. Returning identity function." 3072 ) 3073 return lambda x: x 3074 3075 # Check kwargs 3076 if "kernel" in kwargs: 3077 kernel = kwargs.get("kernel") 3078 else: 3079 kernel = "linear" 3080 3081 # Construct interpolation axis 3082 newx = np.linspace(arr[:, 0].min(), arr[:, 0].max(), 1000) 3083 3084 # Linear kernel 3085 if kernel == "linear": 3086 reg = scipy.stats.linregress(x, y) 3087 newy = reg.slope * newx + reg.intercept 3088 3089 # Other kernels 3090 else: 3091 # Fit 3092 svr = SVR(**kwargs) 3093 svr.fit(arr[:, 1].reshape(-1, 1), arr[:, 0]) 3094 3095 # Predict 3096 newy = svr.predict(newx.reshape(-1, 1)) 3097 3098 # Pad x and y_pred with zeros to force interpolation to start at 0 3099 newx = np.concatenate(([0], newx)) 3100 newy = np.concatenate(([0], newy)) 3101 3102 # Pad x and y_pred with max time to force interpolation to end at max time to force interpolation to match at end max time 3103 max_time = self[0].scan_df["scan_time"].max() 3104 newx = np.concatenate((newx, [max_time])) 3105 newy = np.concatenate((newy, [max_time])) 3106 3107 # Return an interpolation function for the x and y_pred 3108 def interp(x): 3109 pred_y = np.interp(x, newx, newy) 3110 return pred_y 3111 3112 return interp 3113 3114 def get_anchor_mass_features(self, mf_df): 3115 """ 3116 Get the anchor mass features from a DataFrame of mass features. 3117 3118 Parameters 3119 ---------- 3120 mf_df : :obj:`~pandas.DataFrame` 3121 The mass features to filter to just the anchor mass features. 3122 3123 Returns 3124 ------- 3125 :obj:`~pandas.DataFrame` 3126 The anchor mass features dataframe. 3127 """ 3128 mf_df = mf_df.copy() 3129 3130 if ( 3131 "deconvoluted_mass_spectra" 3132 in self.parameters.lcms_collection.mass_feature_anchor_technique 3133 ): 3134 # Drop features that are not mass_spectrum_deconvoluted_parent or are NA as mass_spectrum_deconvoluted_parent 3135 mf_df = mf_df.dropna(subset=["mass_spectrum_deconvoluted_parent"]) 3136 mf_df = mf_df[mf_df["mass_spectrum_deconvoluted_parent"]] 3137 3138 if ( 3139 "absolute_intensity" 3140 in self.parameters.lcms_collection.mass_feature_anchor_technique 3141 ): 3142 # Drop features that have an intensity lower than the threshold 3143 threshold = self.parameters.lcms_collection.mass_feature_anchor_absolute_intensity_threshold 3144 mf_df = mf_df[mf_df["intensity"] > threshold] 3145 3146 if ( 3147 "relative_intensity" 3148 in self.parameters.lcms_collection.mass_feature_anchor_technique 3149 ): 3150 # Drop features in the lower fraction of intensities 3151 threshold_quantile = self.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold 3152 intensity_threshold = mf_df["intensity"].quantile(threshold_quantile) 3153 mf_df = mf_df[mf_df["intensity"] >= intensity_threshold] 3154 3155 return mf_df 3156 3157 def attempt_alignment(self, matches_c, matches_i): 3158 """ 3159 Check if alignment is needed for the LCMS objects in the collection. 3160 """ 3161 3162 # Hold out a subset of matches_c and matches_i for spline fitting 3163 matches_c.reset_index(drop=False, inplace=True) 3164 matches_i.reset_index(drop=False, inplace=True) 3165 3166 # Check if there are enough matches to attempt alignment 3167 minimum_matches = self.parameters.lcms_collection.alignment_minimum_matches 3168 if len(matches_c) < minimum_matches: 3169 # Return False (no alignment) and identity function (returns original time) 3170 # which isn't used but is a placeholder to avoid errors in downstream code since 3171 # the function expects a callable to be returned 3172 return False, lambda x: x 3173 3174 # Rearrange matches_c and matches_i to be in the order of the scan_time of matches_c 3175 matches_c = matches_c.sort_values(by="scan_time") 3176 matches_i = matches_i.iloc[matches_c.index.values] 3177 3178 hold_out_fraction = self.parameters.lcms_collection.alignment_hold_out_fraction 3179 # starting with an array of length len(matches_c), select equally spaced indices to hold out 3180 idx_holdout = matches_c.index.values[ 3181 np.arange(0, len(matches_c), int(1 / hold_out_fraction)) 3182 ] 3183 3184 matches_c_holdout = matches_c.loc[idx_holdout].copy() 3185 matches_i_holdout = matches_i.loc[idx_holdout].copy() 3186 3187 # Remove the holdout matches from the matches_c and matches_i DataFrames and reset the index 3188 matches_c = matches_c.drop(index=idx_holdout).set_index("sample_name") 3189 matches_i = matches_i.drop(index=idx_holdout).set_index("sample_name") 3190 3191 # Reset the scan_time to the original scan_time 3192 matches_i = matches_i.copy() 3193 matches_i["scan_time"] = matches_i["scan_time_og"] 3194 3195 # Fit the retention times of the LCMS object to the center LCMS object using the matched mass features 3196 spl = self.fit_rts(matches_c, matches_i, kernel="rbf", C=1000) 3197 3198 # Check if the spline fitting improved the alignment for the holdout matches 3199 matches_i_holdout["scan_time_fit"] = spl(matches_i_holdout["scan_time"]) 3200 og_diff = np.abs( 3201 matches_i_holdout["scan_time"] - matches_c_holdout["scan_time"] 3202 ) 3203 fit_diff = np.abs( 3204 matches_i_holdout["scan_time_fit"] - matches_c_holdout["scan_time"] 3205 ) 3206 3207 if ( 3208 "fraction_improved" 3209 in self.parameters.lcms_collection.alignment_acceptance_technique 3210 ): 3211 fraction_improved = np.sum(fit_diff < og_diff) / len(og_diff) 3212 use_spline_alignment = ( 3213 fraction_improved 3214 > self.parameters.lcms_collection.alignment_acceptance_fraction_improved_threshold 3215 ) 3216 if ( 3217 "mean_squared_error_improved" 3218 in self.parameters.lcms_collection.alignment_acceptance_technique 3219 ): 3220 mse_og = np.mean(og_diff**2) 3221 mse = np.mean(fit_diff**2) 3222 use_spline_alignment = mse < mse_og 3223 # Convert to boolean 3224 use_spline_alignment = bool(use_spline_alignment) 3225 3226 return use_spline_alignment, spl 3227 3228 def align_lcms_objects(self, overwrite=False): 3229 """ 3230 Align LCMS objects in the collection. 3231 3232 Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects. 3233 First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects, 3234 starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects 3235 are then fit to the center LCMS object using the matched mass features. 3236 3237 Returns 3238 ------- 3239 None, but aligns the LCMS objects in the collection and sets the scan_time_aligned column in the scan_df attribute of each LCMS object. 3240 3241 Notes 3242 ----- 3243 This function has been adapted from the original implementation in the Deimos package: 3244 https://github.com/pnnl/deimos 3245 """ 3246 3247 # Prepare the center LCMS object 3248 center_obj_ids = self.manifest_dataframe[ 3249 self.manifest_dataframe["center"] 3250 ].collection_id.values 3251 3252 full_mf_df = self.mass_features_dataframe 3253 # re-index to sample_name for faster lookups 3254 full_mf_df = full_mf_df.reset_index().set_index("sample_name") 3255 samples_with_features = set(full_mf_df.index.get_level_values("sample_name")) 3256 3257 if "scan_time_aligned" in full_mf_df.columns and not overwrite: 3258 raise ValueError("Mass features have already been aligned") 3259 3260 def _set_scan_time_alignment_for_sample(sample_idx, use_alignment, spline): 3261 """Set scan_time_aligned for one sample using spline or identity mapping.""" 3262 if use_alignment and spline is not None: 3263 self[sample_idx]._scan_info["scan_time_aligned"] = { 3264 k: spline(v) 3265 for k, v in self[sample_idx]._scan_info["scan_time"].items() 3266 } 3267 return True 3268 3269 self[sample_idx]._scan_info["scan_time_aligned"] = ( 3270 self[sample_idx]._scan_info["scan_time"].copy() 3271 ) 3272 return False 3273 3274 def _get_feature_df_at_or_after(start_idx, index_step, use_alignment, spline): 3275 """Return next sample index/dataframe with features, aligning empty samples on the way.""" 3276 i = start_idx 3277 while 0 <= i < len(self): 3278 sample_name = self.samples[i] 3279 if sample_name in samples_with_features: 3280 mf_df_i = full_mf_df.loc[sample_name].copy() 3281 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3282 mf_df_i = mf_df_i.reset_index(drop=False) 3283 if use_alignment and spline is not None: 3284 # Use previous step transform as a better matching starting point. 3285 mf_df_i["scan_time"] = spline(mf_df_i["scan_time"]) 3286 return i, mf_df_i 3287 3288 _set_scan_time_alignment_for_sample(i, use_alignment, spline) 3289 self.rt_alignment_attempted = True 3290 i += index_step 3291 3292 return i, None 3293 3294 anchor_mf_dfs = [] 3295 for center_obj_id in center_obj_ids: 3296 # Get the anchor mass features from the center LCMS object 3297 mf_df_c = full_mf_df.loc[self.samples[center_obj_id]] 3298 mf_df_c = self.get_anchor_mass_features(mf_df_c) 3299 anchor_mf_dfs.append(mf_df_c) 3300 3301 # Set scan_time_aligned to scan_time for the center LCMS object 3302 center_scan_df = self[center_obj_id].scan_df.copy() 3303 center_scan_df["scan_time_aligned"] = center_scan_df["scan_time"] 3304 self[center_obj_id].scan_df = center_scan_df 3305 3306 # Store alignment data for center object (identity mapping) 3307 center_sample_name = self.samples[center_obj_id] 3308 3309 index_steps = (1, -1) 3310 # Run this twice, once going forward (+1 indexing) and once going backward (-1 indexing) 3311 for index_step in index_steps: 3312 # Initialize spline for propagation to samples without features 3313 spl = None 3314 use_spline_alignment = False 3315 3316 # Loop through the other LCMS objects in this direction. 3317 i, mf_df_i = _get_feature_df_at_or_after( 3318 center_obj_id + index_step, 3319 index_step, 3320 use_spline_alignment, 3321 spl, 3322 ) 3323 3324 while mf_df_i is not None: 3325 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3326 3327 # Match the mass features in the LCMS object to the anchor mass features in the center LCMS object. 3328 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3329 3330 if matches_c is not None: 3331 use_spline_alignment, spl = self.attempt_alignment( 3332 matches_c, matches_i 3333 ) 3334 3335 # Record if we used alignment for this sample 3336 sample_name = self.samples[i] 3337 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3338 use_spline_alignment 3339 ) 3340 3341 if use_spline_alignment: 3342 # Set new retention times on scan_df for lc_obj using the spline fitting 3343 matches_i["scan_time_fit"] = spl(matches_i["scan_time"]) 3344 3345 self.rt_aligned = _set_scan_time_alignment_for_sample( 3346 i, use_spline_alignment, spl 3347 ) 3348 self.rt_alignment_attempted = True 3349 3350 i, mf_df_i = _get_feature_df_at_or_after( 3351 i + index_step, 3352 index_step, 3353 use_spline_alignment, 3354 spl, 3355 ) 3356 else: 3357 # If no matches are found, propagate prior alignment from this index step. 3358 sample_name = self.samples[i] 3359 used_previous_alignment = ( 3360 use_spline_alignment and spl is not None 3361 ) 3362 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3363 used_previous_alignment 3364 ) 3365 3366 self.rt_aligned = _set_scan_time_alignment_for_sample( 3367 i, used_previous_alignment, spl 3368 ) 3369 self.rt_alignment_attempted = True 3370 3371 i, mf_df_i = _get_feature_df_at_or_after( 3372 i + index_step, 3373 index_step, 3374 used_previous_alignment, 3375 spl, 3376 ) 3377 3378 # Now align each batch using the center objects as anchors with the other batches 3379 mf_df_c = anchor_mf_dfs[0] 3380 for i in center_obj_ids[1:]: 3381 mf_df_i = full_mf_df.loc[self.samples[i]].copy() 3382 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3383 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3384 3385 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3386 if matches_c is not None: 3387 use_spline_alignment, spl = self.attempt_alignment(matches_c, matches_i) 3388 3389 # Record if we used alignment for this sample 3390 sample_name = self.samples[i] 3391 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3392 use_spline_alignment 3393 ) 3394 3395 if use_spline_alignment: 3396 # Set new retention times on all this object's 3397 new_times = spl(self[i].scan_df["scan_time"]) 3398 new_scan_info = self[i].scan_df.copy() 3399 new_scan_info["scan_time_aligned"] = new_times 3400 self[i].scan_df = new_scan_info 3401 3402 # Get the batch that this object belongs to 3403 batch = self.manifest[self.samples[i]]["batch"] 3404 3405 for j in range(len(self)): 3406 if self.manifest[self.samples[j]]["batch"] == batch: 3407 if j != i: 3408 sample_name_j = self.samples[j] 3409 self._manifest_dict[sample_name_j][ 3410 "use_rt_alignment" 3411 ] = use_spline_alignment 3412 new_scan_info = self[j].scan_df.copy() 3413 aligned_times = spl( 3414 self[j].scan_df["scan_time_aligned"] 3415 ) 3416 new_scan_info["scan_time_aligned"] = aligned_times 3417 self[j].scan_df = new_scan_info 3418 3419 # Set final mass_features_dataframe with the aligned scan_time 3420 center_sample_name = self.samples[center_obj_ids[0]] 3421 self._manifest_dict[center_sample_name]["use_rt_alignment"] = False 3422 new_scan_info = self[center_obj_ids[0]].scan_df.copy() 3423 new_scan_info["scan_time_aligned"] = new_scan_info["scan_time"] 3424 3425 def add_consensus_mass_features(self): 3426 """ 3427 Create consensus mass features by clustering aligned features across samples. 3428 3429 This method clusters mass features from all samples in the collection based on 3430 their m/z and aligned retention time proximity. Features that cluster together 3431 across samples are assigned a common cluster ID, creating consensus features 3432 that represent the same compound detected across multiple samples. 3433 3434 The clustering process: 3435 1. Partitions features by m/z to avoid large sparse matrices and enable parallelization 3436 2. Clusters features within each partition using hierarchical clustering 3437 3. Merges partition-boundary clusters that represent the same feature 3438 4. Filters out clusters not present in minimum fraction of samples 3439 3440 Must be run after align_lcms_objects(). Results are stored in the 3441 mass_features_dataframe with a 'cluster' column added. 3442 3443 Parameters 3444 ---------- 3445 None 3446 Uses parameters from self.parameters.lcms_collection: 3447 - consensus_mz_tol_ppm: m/z tolerance for clustering (ppm) 3448 - consensus_rt_tol: retention time tolerance for clustering (minutes) 3449 - consensus_partition_size: target partition size for managing memory and parallelization 3450 - consensus_min_sample_fraction: minimum fraction of samples a cluster 3451 must appear in to be retained (0-1) 3452 - cores: number of CPU cores to use for parallel partition processing 3453 3454 Returns 3455 ------- 3456 None 3457 Updates self.mass_features_dataframe in place by adding 'cluster' column 3458 and filtering to retain only clusters meeting minimum sample presence. 3459 3460 Raises 3461 ------ 3462 ValueError 3463 If mass features have not been aligned (run align_lcms_objects() first). 3464 3465 Notes 3466 ----- 3467 - Partitioning prevents memory issues with large sparse distance matrices 3468 - Each partition is processed in parallel (up to cores limit) 3469 - Clusters not meeting consensus_min_sample_fraction are automatically removed 3470 - Access cluster_summary_dataframe property for summary statistics 3471 - Use fill_missing_cluster_features() for gap-filling after clustering 3472 3473 See Also 3474 -------- 3475 align_lcms_objects : Aligns retention times before consensus clustering 3476 cluster_summary_dataframe : Property that generates summary statistics for clusters 3477 fill_missing_cluster_features : Gap-fill missing features in clusters 3478 """ 3479 # Get the combined mass features from all LCMS objects, keep the original index as a separate column 3480 combined_mfs = self.mass_features_dataframe.copy() 3481 combined_mfs["coll_mf_id"] = combined_mfs.index 3482 3483 # Check if the mass features have been aligned 3484 if "scan_time_aligned" not in combined_mfs.columns: 3485 raise ValueError( 3486 "Mass features have not been aligned, run align_lcms_objects() first" 3487 ) 3488 3489 # Partition the mass features by mz so we can parallelize the matching before clustering 3490 from corems.chroma_peak.calc import subset as corems_subset 3491 3492 # get max mz from combined_mfs and calculate tolerance from ppm 3493 mz_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 3494 n_partition_size = self.parameters.lcms_collection.consensus_partition_size 3495 lazy_partitions = corems_subset.multi_sample_partition( 3496 combined_mfs, 3497 split_on="mz", 3498 size=n_partition_size, 3499 tol=mz_tol, 3500 relative=True, 3501 ) 3502 3503 # If any of lazy_partitions._counts is 2xn_partition_size, issue a warning 3504 if np.array(lazy_partitions._counts).max() > 2 * n_partition_size: 3505 warnings.warn( 3506 "Some partitions are larger than 2x the goal partition size. Consider increasing the partition or decreasing the mz_tol." 3507 ) 3508 3509 # Cluster the mass features within each partition 3510 if self.parameters.lcms_collection.cores > lazy_partitions.n_partitions: 3511 cores_to_use = lazy_partitions.n_partitions 3512 else: 3513 cores_to_use = self.parameters.lcms_collection.cores 3514 # mfs_with_clusters = lazy_partitions.map(self.cluster_mass_features, processes=cores_to_use) 3515 mfs_with_clusters = lazy_partitions.map( 3516 self.cluster_mass_features_agg_cluster, processes=cores_to_use 3517 ) 3518 3519 # Clean up cluster id names after partitioning 3520 new_cluster_ids = ( 3521 mfs_with_clusters[["cluster", "partition_idx"]] 3522 .drop_duplicates() 3523 .reset_index(drop=True) 3524 ) 3525 new_cluster_ids["cluster_unqiue"] = new_cluster_ids.index 3526 mfs_with_clusters = mfs_with_clusters.merge( 3527 new_cluster_ids, on=["cluster", "partition_idx"] 3528 ) 3529 mfs_with_clusters["cluster"] = mfs_with_clusters["cluster_unqiue"] 3530 mfs_with_clusters = mfs_with_clusters.drop(columns=["cluster_unqiue"]) 3531 3532 # Embed a new cluster id into the mass features dataframe and set as index 3533 mfs_with_clusters["idx"] = mfs_with_clusters.index 3534 3535 try: 3536 # Check if any clusters can be merged into a single cluster 3537 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3538 3539 # Merge clusters identified in eval_dict 3540 while len(eval_dict["merge_these_clusters"]) > 0: 3541 list_of_clusters_to_merge = [ 3542 [x[0], x[1]] for x in eval_dict["merge_these_clusters"] 3543 ] 3544 # Convert to a dataframe with columns "new_cluster" and "cluster" 3545 df = pd.DataFrame( 3546 np.array(list_of_clusters_to_merge), 3547 columns=["new_cluster", "cluster"], 3548 ) 3549 # Drop duplicates of "child" clusters 3550 df = df.drop_duplicates("cluster", keep="first") 3551 df = df.drop_duplicates("new_cluster", keep="first") 3552 mfs_with_clusters = mfs_with_clusters.merge( 3553 df, on="cluster", how="left" 3554 ) 3555 mfs_with_clusters["cluster"] = mfs_with_clusters["new_cluster"].fillna( 3556 mfs_with_clusters["cluster"] 3557 ) 3558 mfs_with_clusters = mfs_with_clusters.drop(columns=["new_cluster"]) 3559 3560 # Re-evaluate clusters for repeats 3561 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3562 self.mass_features_dataframe = mfs_with_clusters 3563 3564 except: 3565 mfs_with_clusters.set_index("coll_mf_id", inplace=True) 3566 self.mass_features_dataframe = mfs_with_clusters 3567 3568 # Filter out clusters that don't meet minimum sample fraction 3569 self._filter_clusters_by_sample_presence() 3570 3571 # TODO KRH: Deal with isomers better? Pool them together and then split them out using samples with 2 as the template? 3572 3573 def _filter_clusters_by_sample_presence(self): 3574 """ 3575 Filter out clusters that don't meet the minimum sample fraction threshold. 3576 3577 Removes clusters (and their associated mass features) from the mass_features_dataframe 3578 if they don't appear in at least consensus_min_sample_fraction of samples. 3579 3580 This is called automatically at the end of add_consensus_mass_features(). 3581 3582 Returns 3583 ------- 3584 None 3585 Updates self.mass_features_dataframe in place by removing clusters that don't 3586 meet the minimum sample presence threshold. 3587 """ 3588 if ( 3589 self.mass_features_dataframe is None 3590 or len(self.mass_features_dataframe) == 0 3591 ): 3592 return 3593 3594 min_sample_fraction = ( 3595 self.parameters.lcms_collection.consensus_min_sample_fraction 3596 ) 3597 3598 # Validate parameter 3599 if not 0 <= min_sample_fraction <= 1: 3600 raise ValueError("consensus_min_sample_fraction must be between 0 and 1") 3601 3602 # Calculate minimum number of samples required 3603 total_samples = len(self.samples) 3604 min_samples_required = min_sample_fraction * total_samples 3605 3606 # Count unique samples per cluster 3607 cluster_sample_counts = ( 3608 self.mass_features_dataframe.groupby("cluster")["sample_id"] 3609 .nunique() 3610 .reset_index(name="sample_count") 3611 ) 3612 3613 # Identify clusters to keep 3614 clusters_to_keep = cluster_sample_counts[ 3615 cluster_sample_counts["sample_count"] > min_samples_required 3616 ]["cluster"].values 3617 3618 # Filter mass features dataframe 3619 self.mass_features_dataframe = self.mass_features_dataframe[ 3620 self.mass_features_dataframe["cluster"].isin(clusters_to_keep) 3621 ] 3622 3623 def summarize_clusters(self): 3624 """ 3625 Generate summary statistics for consensus mass feature clusters. 3626 3627 Computes aggregate statistics (median, mean, std, min, max) for each cluster 3628 across all samples. Combines both regular mass features and induced mass features 3629 (from gap-filling) when available to provide complete cluster statistics. 3630 3631 Must be run after add_consensus_mass_features() which creates the cluster assignments. 3632 Results are stored in cluster_summary_dataframe property and used by plotting methods. 3633 3634 Parameters 3635 ---------- 3636 None 3637 Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe. 3638 Both must contain 'cluster' column. 3639 3640 Returns 3641 ------- 3642 :obj:`~pandas.DataFrame` or None 3643 DataFrame with one row per cluster containing summary statistics: 3644 - cluster: cluster ID 3645 - mz_{median,mean,std,max,min}: m/z statistics 3646 - scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics 3647 - half_height_width_{median,mean,std,max,min}: peak width statistics 3648 - tailing_factor_{median,mean,std,max,min}: peak shape statistics 3649 - dispersity_index_{median,mean,std,max,min}: peak quality statistics 3650 - sample_id_nunique: number of unique samples containing the cluster 3651 - intensity_{max,median,mean,std,min}: intensity statistics 3652 - persistence_{max,median,mean,std,min}: persistence statistics 3653 3654 Returns None if mass_features_dataframe is empty. 3655 3656 Notes 3657 ----- 3658 - Summary DataFrame is automatically stored in cluster_summary_dataframe property 3659 - Includes both regular and induced (gap-filled) mass features when available. 3660 ``group_consensus_features`` does not use this m/z median for ion-role 3661 geometry; that median is detected features only. 3662 - Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster 3663 - Sample count (sample_id_nunique) indicates cluster prevalence across samples 3664 - Filters applied by consensus_min_sample_fraction affect which clusters appear 3665 3666 See Also 3667 -------- 3668 add_consensus_mass_features : Creates clusters before summarization 3669 fill_missing_cluster_features : Creates induced mass features via gap-filling 3670 plot_consensus_mz_features : Visualizes cluster summaries 3671 plot_mz_features_per_cluster : Shows cluster size distribution 3672 """ 3673 # First check if there are minimum columns in the features dataframe 3674 if ( 3675 self.mass_features_dataframe is None 3676 or len(self.mass_features_dataframe.columns) < 1 3677 ): 3678 return None 3679 3680 # Combine regular and induced mass features 3681 mf_df = self.mass_features_dataframe.copy() 3682 mf_df = mf_df.reset_index(drop=False) 3683 3684 # Check if induced mass features are available and combine them 3685 if ( 3686 self.induced_mass_features_dataframe is not None 3687 and len(self.induced_mass_features_dataframe) > 0 3688 ): 3689 imf_df = self.induced_mass_features_dataframe.copy() 3690 imf_df = imf_df.reset_index(drop=False) 3691 # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination 3692 # Combine regular and induced features 3693 mf_df = pd.concat([mf_df, imf_df], axis=0) 3694 mf_df = mf_df.reset_index(drop=True) 3695 3696 # Filter out any rows with NaN cluster values before converting to int 3697 if "cluster" in mf_df.columns: 3698 mf_df = mf_df.dropna(subset=["cluster"]) 3699 mf_df["cluster"] = mf_df["cluster"].astype(int) 3700 3701 # Build aggregation dictionary based on available columns 3702 agg_dict = { 3703 "mz": ["median", "mean", "std", "max", "min"], 3704 "scan_time_aligned": ["median", "mean", "std", "max", "min"], 3705 "sample_id": ["nunique"], 3706 "intensity": ["max", "median", "mean", "std", "min"], 3707 } 3708 3709 # Add optional columns if they exist 3710 optional_columns = { 3711 "half_height_width": ["median", "mean", "std", "max", "min"], 3712 "tailing_factor": ["median", "mean", "std", "max", "min"], 3713 "dispersity_index": ["median", "mean", "std", "max", "min"], 3714 "persistence": ["max", "median", "mean", "std", "min"], 3715 } 3716 3717 for col, funcs in optional_columns.items(): 3718 if col in mf_df.columns: 3719 agg_dict[col] = funcs 3720 3721 summary_df = mf_df.groupby("cluster").agg(agg_dict).reset_index() 3722 3723 # Fix the column names 3724 summary_df.columns = [ 3725 "_".join(col).strip() 3726 for col in summary_df.columns.values 3727 if col != "cluster" 3728 ] 3729 summary_df = summary_df.rename(columns={"cluster_": "cluster"}) 3730 # Set cluster as the index for easy lookup 3731 summary_df = summary_df.set_index("cluster") 3732 3733 # Merge consensus feature-group labels when present (isotopes + adducts). 3734 # Cluster summary exposes possible_ion_types (all 3735 # residual-tied candidates) and drops ion_type so a single type column 3736 # is not mistaken for unique assignment. 3737 fg = getattr(self, "feature_group_dataframe", None) 3738 if fg is not None and len(fg) > 0: 3739 from corems.mass_spectra.calc.feature_grouping import ( 3740 GROUP_COLUMNS, 3741 merge_possible_ion_types, 3742 ) 3743 3744 cols = [c for c in GROUP_COLUMNS if c in fg.columns] 3745 if cols: 3746 drop_cols = [c for c in cols if c in summary_df.columns] 3747 if drop_cols: 3748 summary_df = summary_df.drop(columns=drop_cols) 3749 # Also drop stale ion_type from a prior join shape 3750 if "ion_type" in summary_df.columns: 3751 summary_df = summary_df.drop(columns=["ion_type"]) 3752 summary_df = summary_df.join(fg[cols], how="left") 3753 3754 if ( 3755 "possible_ion_types" in summary_df.columns 3756 and "ion_type" in summary_df.columns 3757 ): 3758 for cid in summary_df.index: 3759 it = summary_df.loc[cid, "ion_type"] 3760 poss = summary_df.loc[cid, "possible_ion_types"] 3761 filled = merge_possible_ion_types(it, poss) 3762 if filled is not None: 3763 summary_df.loc[cid, "possible_ion_types"] = filled 3764 if "ion_type" in summary_df.columns: 3765 summary_df = summary_df.drop(columns=["ion_type"]) 3766 3767 return summary_df 3768 3769 def group_consensus_features(self): 3770 """ 3771 Group consensus features into natural-abundance isotope and adduct families. 3772 3773 After consensus clustering and gap-filling, labels clusters that share 3774 similar retention time, an interpretable **singly-charged** mass offset, 3775 and correlated cross-sample **apex peak heights**. 3776 3777 Two stages 3778 ---------- 3779 1. **Isotopes.** Unit ``Atoms`` edges (e.g. ¹²C/¹³C) plus roll-up to 3780 ¹³C₂, ¹³C₃, … when intermediates exist. Spacing is the raw Atoms 3781 Δm (``|z| = 1``). Multi-charge envelopes are out of scope. 3782 Chemical mono is the geometry root, not the tallest peak. 3783 2. **Adducts.** Pairwise ``ion_type_dict`` forms in 3784 ``feature_group_ion_types`` (must be ``|z| = 1``). Empty or a 3785 single configured type skips adduct linking (isotopes only). 3786 3787 **Not** for tracer, enriched, or isotopically labeled experiments. 3788 3789 Quant gate is fixed (no settings switch): 3790 3791 - Correlation: **Pearson** only, pairwise-complete on samples with both 3792 heights > 0 (threshold ``feature_group_corr_threshold``, default 0.80) 3793 - Abundance: mass-feature apex ``intensity`` only (not integrated area) 3794 3795 Coelution and m/z delta windows reuse collection 3796 ``alignment_rt_tol`` and ``alignment_mz_tol_ppm``. Other knobs 3797 (``feature_group_isotope_atoms``, shared-sample fraction, 3798 ``feature_group_ion_types``, …) live on ``parameters.lcms_collection``. 3799 3800 ``feature_group_ion_types`` is filtered to the collection polarity 3801 (all samples share one polarity; mixed collections cannot be 3802 instantiated). Only keys ending in ``+`` are kept on positive data 3803 and only keys ending in ``-`` on negative data. 3804 3805 Preferred for collection-level families. Optional per-file 3806 ``LCMSBase.find_c13_mass_features`` remains available for single-file 3807 workflows; do not treat both as authoritative in the same pipeline. 3808 3809 Must run after ``add_consensus_mass_features()``. The m/z median used for 3810 isotope and adduct spacing is calculated from detected features only. 3811 Gap-filled m/z values stay on the induced rows and do not enter that 3812 median. The height matrix still includes gap-filled apex intensities 3813 when present; missing entries are 0. 3814 3815 Returns 3816 ------- 3817 pandas.DataFrame 3818 Cluster-indexed labels with columns ``feature_group_id``, 3819 ``ion_role``, ``ion_type``, ``possible_ion_types``, 3820 ``isotope_state``, ``mono_cluster_id`` (set only when the form 3821 has at least one isotope). Also stored on 3822 ``self.feature_group_dataframe`` and merged into 3823 ``mass_features_dataframe`` / ``induced_mass_features_dataframe``. 3824 3825 Raises 3826 ------ 3827 ValueError 3828 If consensus features are missing or settings are invalid. 3829 """ 3830 from corems.mass_spectra.calc.feature_grouping import ( 3831 GROUP_COLUMNS, 3832 HEIGHT_COL, 3833 FeatureGroupParams, 3834 build_height_matrix_from_features, 3835 detected_cluster_mz_median, 3836 group_features_arrays, 3837 normalize_ms_polarity, 3838 ) 3839 3840 if ( 3841 self.mass_features_dataframe is None 3842 or len(self.mass_features_dataframe) == 0 3843 ): 3844 raise ValueError( 3845 "No mass features dataframe. Run add_consensus_mass_features() first." 3846 ) 3847 if "cluster" not in self.mass_features_dataframe.columns: 3848 raise ValueError( 3849 "mass_features_dataframe has no 'cluster' column. " 3850 "Run add_consensus_mass_features() first." 3851 ) 3852 3853 # Clear prior labels (idempotent re-run) 3854 self.feature_group_dataframe = None 3855 for col in GROUP_COLUMNS: 3856 if col in self.mass_features_dataframe.columns: 3857 self.mass_features_dataframe = self.mass_features_dataframe.drop( 3858 columns=[col] 3859 ) 3860 if ( 3861 self.induced_mass_features_dataframe is not None 3862 and col in self.induced_mass_features_dataframe.columns 3863 ): 3864 self.induced_mass_features_dataframe = ( 3865 self.induced_mass_features_dataframe.drop(columns=[col]) 3866 ) 3867 3868 summary = self.summarize_clusters() 3869 if summary is None or len(summary) == 0: 3870 raise ValueError( 3871 "cluster summary is empty. Run add_consensus_mass_features() first." 3872 ) 3873 3874 # Collection polarity: all samples share one polarity (enforced when 3875 # LCMSCollection is loaded). Filter opposite-sign adducts. 3876 collection_polarity = None 3877 if len(self): 3878 collection_polarity = normalize_ms_polarity( 3879 getattr(self[0], "polarity", None) 3880 ) 3881 3882 params = FeatureGroupParams.from_lcms_collection_settings( 3883 self.parameters.lcms_collection, 3884 polarity=collection_polarity, 3885 ) 3886 3887 cluster_ids = summary.index.to_numpy() 3888 # Gap-filled apex m/z is not part of ion-role geometry. 3889 mz = detected_cluster_mz_median(self.mass_features_dataframe, cluster_ids) 3890 if "scan_time_aligned_median" not in summary.columns: 3891 raise ValueError( 3892 "cluster summary missing scan_time_aligned_median; " 3893 "ensure features are RT-aligned before consensus." 3894 ) 3895 rt = summary["scan_time_aligned_median"].to_numpy(dtype=float) 3896 3897 # Sample ids as used in mass_features_dataframe 3898 sample_ids = sorted(self.mass_features_dataframe["sample_id"].dropna().unique()) 3899 if self.induced_mass_features_dataframe is not None and len( 3900 self.induced_mass_features_dataframe 3901 ): 3902 sample_ids = sorted( 3903 set(sample_ids) 3904 | set( 3905 self.induced_mass_features_dataframe["sample_id"] 3906 .dropna() 3907 .unique() 3908 .tolist() 3909 ) 3910 ) 3911 3912 t_height = time.perf_counter() 3913 heights = build_height_matrix_from_features( 3914 self.mass_features_dataframe, 3915 cluster_ids=cluster_ids, 3916 sample_ids=sample_ids, 3917 induced_df=self.induced_mass_features_dataframe, 3918 intensity_col=HEIGHT_COL, # apex intensity only; not area 3919 ) 3920 stage_timings = { 3921 "height_matrix": time.perf_counter() - t_height, 3922 "n_ion_types_configured": float(len(params.ion_types)), 3923 "collection_polarity": ( 3924 str(collection_polarity) if collection_polarity else "unknown" 3925 ), 3926 } 3927 3928 algo_timings: dict = {} 3929 labels = group_features_arrays( 3930 cluster_ids, mz, rt, heights, params, timings_out=algo_timings 3931 ) 3932 stage_timings.update(algo_timings) 3933 self._last_feature_group_timings = stage_timings 3934 self.feature_group_dataframe = labels 3935 3936 from corems.mass_spectra.calc.feature_grouping import ( 3937 merge_feature_group_labels_into_frames, 3938 ) 3939 3940 self.mass_features_dataframe, induced = merge_feature_group_labels_into_frames( 3941 labels, 3942 self.mass_features_dataframe, 3943 self.induced_mass_features_dataframe, 3944 ) 3945 if self.induced_mass_features_dataframe is not None: 3946 self.induced_mass_features_dataframe = induced 3947 3948 return labels 3949 3950 def plot_mz_features_per_cluster(self, return_fig=False, path=None): 3951 """ 3952 Plot the number of mass features in a cluster against how many clusters 3953 contain that number of mass features 3954 3955 Parameters 3956 ----------- 3957 return_fig : bool, optional 3958 If True, return the open figure (caller owns lifecycle). 3959 Default is False. 3960 path : str or path-like, optional 3961 If set, save the figure to this path. When ``return_fig`` is False, 3962 the figure is closed after saving and ``plt.show()`` is not called. 3963 3964 Returns 3965 -------- 3966 matplotlib.figure.Figure or None 3967 The figure if ``return_fig`` is True; otherwise None. 3968 3969 Raises 3970 ------ 3971 ValueError 3972 If consensus features haven't been added to the object yet 3973 """ 3974 3975 if not hasattr(self, "cluster_summary_dataframe"): 3976 raise ValueError( 3977 "cluster_summary_dataframe is not set, must run add_consensus_mass_features() first" 3978 ) 3979 sum_data = self.cluster_summary_dataframe 3980 fig, ax = plt.subplots() 3981 sum_data.sample_id_nunique.value_counts().sort_index().plot(ax=ax, kind="bar") 3982 plt.xlabel("Number of mass features in a cluster") 3983 plt.ylabel("Number of clusters with this many mass features") 3984 return _finalize_plot(fig, return_fig=return_fig, path=path) 3985 3986 def plot_mz_features_across_samples( 3987 self, alpha=0.75, s=0.005, return_fig=False, path=None 3988 ): 3989 """ 3990 Generate Scan Time vs m/z plot of all the mass features across all 3991 samples in collection where intensity of color on the plot indicates 3992 density of mass features, NOT INTENSITY 3993 3994 Parameters 3995 ----------- 3996 alpha : float 3997 Desired transparency for plotted m/z features. Defaults to 0.75. 3998 s : float 3999 Desired size of plotted m/z features. Defaults to 0.005. 4000 return_fig : bool, optional 4001 If True, return the open figure (caller owns lifecycle). 4002 Default is False. 4003 path : str or path-like, optional 4004 If set, save the figure to this path. When ``return_fig`` is False, 4005 the figure is closed after saving and ``plt.show()`` is not called. 4006 4007 Returns 4008 -------- 4009 matplotlib.figure.Figure or None 4010 A figure displaying a scan time vs m/z scatterplot of all the m/z 4011 features identified in the collection if ``return_fig`` is True; 4012 otherwise None. Parameters alpha (transparency) and s (marker size) 4013 allow the user to emphasize the density of features. Intensity of 4014 features is not represented. 4015 """ 4016 df = self.mass_features_dataframe.copy() 4017 fig = plt.figure() 4018 plt.scatter(df.scan_time_aligned, df.mz, c="tab:gray", alpha=alpha, s=s) 4019 4020 plt.xlabel("Scan time") 4021 plt.ylabel("m/z") 4022 plt.ylim(0, np.ceil(np.max(df.mz))) 4023 plt.xlim(0, np.ceil(np.max(df.scan_time))) 4024 plt.title("All mass features, all samples") 4025 4026 return _finalize_plot(fig, return_fig=return_fig, path=path) 4027 4028 def plot_consensus_mz_features( 4029 self, 4030 xb="xb", 4031 xt="xt", 4032 yb="yb", 4033 yt="yt", 4034 show_all=True, 4035 return_fig=False, 4036 path=None, 4037 ): 4038 """ 4039 Generate Scan Time vs m/z plot of the consensus features scaled by size 4040 with option ('show_all') of leaving the individual m/z features in the figure. 4041 4042 Parameters 4043 ----------- 4044 xb : float 4045 Desired starting scan time value for the x-axis. Defaults to 0. 4046 xt : float 4047 Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data. 4048 yb : float 4049 Desired starting m/z value for the y-axis. Defaults to 0. 4050 yt : float 4051 Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data. 4052 show_all : boolean 4053 Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True. 4054 return_fig : bool, optional 4055 If True, return the open figure (caller owns lifecycle). 4056 Default is False. 4057 path : str or path-like, optional 4058 If set, save the figure to this path. When ``return_fig`` is False, 4059 the figure is closed after saving and ``plt.show()`` is not called. 4060 4061 Returns 4062 -------- 4063 matplotlib.figure.Figure or None 4064 A scalable figure that overlays the consensus features over all the 4065 m/z features identified in the collection if ``return_fig`` is True; 4066 otherwise None. Consensus features are scaled by how many m/z 4067 features are represented in the consensus. Figure can be scaled by 4068 inputting desired boundaries on the scan time (xb, xt) and m/z 4069 values (yb, yt). 4070 """ 4071 df = self.cluster_summary_dataframe.copy() 4072 mfdf = self.mass_features_dataframe.copy() 4073 4074 fig = plt.figure() 4075 if show_all: 4076 plt.scatter(mfdf.scan_time_aligned, mfdf.mz, c="tab:gray", s=1) 4077 4078 m = plt.scatter( 4079 df.scan_time_aligned_median, 4080 df.mz_median, 4081 c="tab:orange", 4082 alpha=0.7, 4083 s=(df.sample_id_nunique**2) / 5, 4084 ) 4085 4086 plt.xlabel("Scan time") 4087 plt.ylabel("m/z") 4088 4089 if xt == "xt": 4090 xt = np.ceil(np.max(mfdf.mz)) 4091 if yt == "yt": 4092 yt = np.ceil(np.max(mfdf.scan_time)) 4093 if xb == "xb": 4094 xb = 0 4095 if yb == "yb": 4096 yb = 0 4097 plt.ylim(xb, xt) 4098 plt.xlim(yb, yt) 4099 4100 kw = dict( 4101 prop="sizes", 4102 num=max(1, int(len(df.sample_id_nunique.unique()) / 3)), 4103 color="tab:orange", 4104 alpha=0.7, 4105 func=lambda s: np.sqrt(s * 5), 4106 ) 4107 4108 plt.legend( 4109 *m.legend_elements(**kw), 4110 title="Features\nper cluster", 4111 bbox_to_anchor=(1.01, 0.4, 0.225, 0.5), 4112 ) 4113 plt.tight_layout() 4114 plt.title("Consensus Features") 4115 4116 return _finalize_plot(fig, return_fig=return_fig, path=path) 4117 4118 def plot_cluster( 4119 self, 4120 cluster_id, 4121 to_plot=["EIC", "MS1", "MS2"], 4122 return_fig=False, 4123 path=None, 4124 plot_smoothed_eic=False, 4125 plot_eic_datapoints=False, 4126 eic_buffer_time=None, 4127 label_samples=False, 4128 molecular_metadata=None, 4129 spectral_library=None, 4130 ): 4131 """ 4132 Plot a consensus mass feature cluster across all samples. 4133 4134 Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster, 4135 highlighting the representative mass feature. 4136 4137 Parameters 4138 ---------- 4139 cluster_id : int 4140 The cluster ID to plot 4141 to_plot : list, optional 4142 List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror". 4143 Default is ["EIC", "MS1", "MS2"]. 4144 return_fig : bool, optional 4145 If True, return the open figure (caller owns lifecycle). 4146 Default is False. 4147 path : str or path-like, optional 4148 If set, save the figure to this path. When ``return_fig`` is False, 4149 the figure is closed after saving and ``plt.show()`` is not called. 4150 plot_smoothed_eic : bool, optional 4151 If True, plots smoothed EICs. Default is False. 4152 plot_eic_datapoints : bool, optional 4153 If True, plots EIC data points. Default is False. 4154 eic_buffer_time : float, optional 4155 Time buffer around the peak for EIC plotting (minutes). 4156 If None, uses parameter setting. Default is None. 4157 label_samples : bool, optional 4158 If True, labels each sample in the legend. Default is False. 4159 molecular_metadata : dict, optional 4160 Dictionary mapping molecular IDs to MetaboliteMetadata objects. 4161 Required for MS2_mirror plots. Default is None. 4162 spectral_library : FlashEntropySearch, optional 4163 FlashEntropy spectral library containing MS2 spectra. 4164 Required for MS2_mirror plots to retrieve library spectra. Default is None. 4165 4166 Returns 4167 ------- 4168 matplotlib.figure.Figure or None 4169 The figure object if return_fig=True, otherwise None 4170 4171 Raises 4172 ------ 4173 ValueError 4174 If cluster_id is not found or if required data is not loaded 4175 """ 4176 import matplotlib.pyplot as plt 4177 4178 # Get cluster summary for median values 4179 if cluster_id not in self.cluster_summary_dataframe.index: 4180 raise ValueError( 4181 f"Cluster {cluster_id} not found in cluster_summary_dataframe. " 4182 f"Run add_consensus_mass_features() first." 4183 ) 4184 4185 cluster_summary = self.cluster_summary_dataframe.loc[cluster_id] 4186 4187 # Get representative mass feature info 4188 rep_info = self.get_most_representative_sample_for_cluster(cluster_id) 4189 rep_sample_id = rep_info["sample_id"] 4190 rep_mf_id = rep_info["mf_id"] 4191 rep_sample = self[rep_sample_id] 4192 4193 # Check if representative mass feature is loaded 4194 if rep_mf_id not in rep_sample.mass_features: 4195 raise ValueError( 4196 f"Representative mass feature {rep_mf_id} not loaded in sample {rep_sample.sample_name}. " 4197 f"Run reload_representative_mass_features() or process_consensus_features() first." 4198 ) 4199 4200 rep_mf = rep_sample.mass_features[rep_mf_id] 4201 4202 # Get eic buffer time 4203 if eic_buffer_time is None: 4204 eic_buffer_time = self[0].parameters.lc_ms.eic_buffer_time 4205 4206 # Adjust to_plot based on available data 4207 if rep_mf.mass_spectrum is None: 4208 to_plot = [x for x in to_plot if x != "MS1"] 4209 if len(rep_mf.ms2_mass_spectra) == 0: 4210 to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]] 4211 4212 # Check if EICs are available 4213 cluster_mfs = self.mass_features_dataframe[ 4214 self.mass_features_dataframe["cluster"] == cluster_id 4215 ] 4216 4217 has_eics = False 4218 # Check regular features 4219 for _, row in cluster_mfs.iterrows(): 4220 sample_id = int(row["sample_id"]) 4221 sample = self[sample_id] 4222 if hasattr(sample, "eics") and sample.eics: 4223 if len(sample.eics) > 0: 4224 has_eics = True 4225 break 4226 4227 # Also check induced features if available 4228 induced_cluster_mfs = None 4229 if not has_eics and self.induced_mass_features_dataframe is not None: 4230 induced_cluster_mfs = self.induced_mass_features_dataframe[ 4231 self.induced_mass_features_dataframe["cluster"] == cluster_id 4232 ] 4233 for _, row in induced_cluster_mfs.iterrows(): 4234 sample_id = int(row["sample_id"]) 4235 sample = self[sample_id] 4236 if hasattr(sample, "eics") and sample.eics: 4237 if len(sample.eics) > 0: 4238 has_eics = True 4239 break 4240 4241 if not has_eics: 4242 to_plot = [x for x in to_plot if x != "EIC"] 4243 if len(to_plot) == 0: 4244 raise ValueError( 4245 f"No plottable data available for cluster {cluster_id}. " 4246 f"Run process_consensus_features(gather_eics=True, add_ms1=True, add_ms2=True) first." 4247 ) 4248 4249 # Get induced features if not already retrieved 4250 if ( 4251 induced_cluster_mfs is None 4252 and self.induced_mass_features_dataframe is not None 4253 ): 4254 induced_cluster_mfs = self.induced_mass_features_dataframe[ 4255 self.induced_mass_features_dataframe["cluster"] == cluster_id 4256 ] 4257 4258 # Check if MS1 is deconvoluted 4259 deconvoluted = rep_mf._ms_deconvoluted_idx is not None 4260 4261 # Create figure 4262 fig, axs = plt.subplots( 4263 len(to_plot), 1, figsize=(10, len(to_plot) * 4), squeeze=False 4264 ) 4265 4266 fig.suptitle( 4267 f"Consensus Cluster {cluster_id}: " 4268 f"m/z = {cluster_summary['mz_median']:.4f} " 4269 f"(±{cluster_summary['mz_std']:.4f}); " 4270 f"RT = {cluster_summary['scan_time_aligned_median']:.2f} min " 4271 f"(±{cluster_summary['scan_time_aligned_std']:.2f}); " 4272 f"{int(cluster_summary['sample_id_nunique'])} samples" 4273 ) 4274 4275 i = 0 4276 4277 # EIC plot - show all samples using helper method 4278 if "EIC" in to_plot: 4279 self._plot_multiple_eics( 4280 axs[i][0], 4281 cluster_mfs, 4282 induced_cluster_mfs, 4283 rep_sample_id, 4284 rep_mf_id, 4285 cluster_summary["scan_time_aligned_median"], 4286 eic_buffer_time, 4287 plot_smoothed=plot_smoothed_eic, 4288 plot_datapoints=plot_eic_datapoints, 4289 label_samples=label_samples, 4290 lcms_collection=self, 4291 ) 4292 i += 1 4293 4294 # MS1 plot - from representative using helper method 4295 if "MS1" in to_plot: 4296 rep_mf._plot_ms1_spectrum( 4297 axs[i][0], deconvoluted=deconvoluted, sample_name=rep_sample.sample_name 4298 ) 4299 i += 1 4300 4301 # MS2 plot - from representative using helper method 4302 if "MS2" in to_plot: 4303 rep_mf._plot_ms2_spectrum(axs[i][0], sample_name=rep_sample.sample_name) 4304 i += 1 4305 4306 # MS2 mirror plot - from representative using helper method 4307 if "MS2_mirror" in to_plot: 4308 rep_mf._plot_ms2_mirror( 4309 axs[i][0], 4310 molecular_metadata=molecular_metadata, 4311 spectral_library=spectral_library, 4312 ) 4313 i += 1 4314 4315 plt.tight_layout() 4316 4317 return _finalize_plot(fig, return_fig=return_fig, path=path) 4318 4319 def get_representative_mass_features_for_all_clusters( 4320 self, representative_metric=None 4321 ): 4322 """ 4323 Get the most representative mass feature for all clusters in bulk. 4324 4325 This is much more efficient than calling get_most_representative_sample_for_cluster 4326 in a loop, as it processes all clusters in a single pass over the dataframe. 4327 4328 Parameters 4329 ---------- 4330 representative_metric : str, optional 4331 The metric to use to determine the most representative sample. 4332 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4333 Options: 4334 - 'intensity': Selects the mass feature with the highest intensity 4335 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 4336 or the highest intensity overall if none have MS2 4337 Default is None (uses parameter setting). 4338 4339 Returns 4340 ------- 4341 :obj:`~pandas.DataFrame` 4342 DataFrame with one row per cluster containing: 4343 - cluster: cluster ID 4344 - sample_id: The sample ID of the most representative sample 4345 - mf_id: The mass feature ID in the sample 4346 - coll_mf_id: The collection-level mass feature ID (index) 4347 - has_ms2: Whether this mass feature has MS2 scan numbers 4348 - intensity: The intensity value of the representative mass feature 4349 """ 4350 # Use default from parameters if not specified 4351 if representative_metric is None: 4352 representative_metric = ( 4353 self.parameters.lcms_collection.consensus_representative_metric 4354 ) 4355 4356 mf_df = self.mass_features_dataframe.copy() 4357 # Reset index to make coll_mf_id a column we can work with 4358 mf_df = mf_df.reset_index(drop=False) 4359 4360 # Handle special metric 'intensity_prefer_ms2' 4361 if representative_metric == "intensity_prefer_ms2": 4362 if "intensity" not in mf_df.columns: 4363 raise ValueError( 4364 f"'intensity' column not found in mass_features_dataframe. " 4365 f"Available columns: {mf_df.columns.tolist()}" 4366 ) 4367 4368 # Add has_ms2 flag if ms2_scan_numbers column exists 4369 if "ms2_scan_numbers" in mf_df.columns: 4370 4371 def has_ms2_scans(val): 4372 if val is None: 4373 return False 4374 try: 4375 return len(val) > 0 4376 except (TypeError, ValueError): 4377 return False 4378 4379 mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans) 4380 4381 # Sort by has_ms2 (descending) then intensity (descending) 4382 # This ensures features with MS2 are preferred when intensities are equal 4383 mf_df = mf_df.sort_values( 4384 ["has_ms2", "intensity"], ascending=[False, False] 4385 ) 4386 else: 4387 mf_df["has_ms2"] = False 4388 mf_df = mf_df.sort_values("intensity", ascending=False) 4389 4390 # Group by cluster and take the first (highest intensity, preferring MS2) 4391 representatives = mf_df.groupby("cluster").first().reset_index() 4392 4393 else: 4394 # Standard metric - check if it exists 4395 if representative_metric not in mf_df.columns: 4396 raise ValueError( 4397 f"Metric '{representative_metric}' not found. Available columns: {mf_df.columns.tolist()}" 4398 ) 4399 4400 # Add has_ms2 flag for consistency 4401 if "ms2_scan_numbers" in mf_df.columns: 4402 4403 def has_ms2_scans(val): 4404 if val is None: 4405 return False 4406 try: 4407 return len(val) > 0 4408 except (TypeError, ValueError): 4409 return False 4410 4411 mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans) 4412 else: 4413 mf_df["has_ms2"] = False 4414 4415 # Get the index of max value for each cluster 4416 idx = mf_df.groupby("cluster")[representative_metric].idxmax() 4417 representatives = mf_df.loc[idx].copy() 4418 4419 # Select only the columns we need 4420 result_cols = [ 4421 "cluster", 4422 "sample_id", 4423 "mf_id", 4424 "coll_mf_id", 4425 "has_ms2", 4426 "intensity", 4427 ] 4428 representatives = representatives[result_cols] 4429 4430 return representatives 4431 4432 def get_sample_mf_map_for_representatives( 4433 self, representative_metric=None, include_cluster_id=True 4434 ): 4435 """ 4436 Build a mapping of sample_id -> list of representative mass feature IDs to load. 4437 4438 This is a DRY helper method used by both process_consensus_features() and 4439 ReadSavedLCMSCollection to determine which mass features should be loaded 4440 for each sample when loading representatives. 4441 4442 Parameters 4443 ---------- 4444 representative_metric : str, optional 4445 The metric to use to determine the most representative sample. 4446 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4447 Default is None. 4448 include_cluster_id : bool, optional 4449 If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id. 4450 Default is True. 4451 4452 Returns 4453 ------- 4454 dict 4455 Dictionary mapping sample_id (int) to list of mass feature identifiers. 4456 If include_cluster_id=True: list of tuples (mf_id, cluster_id) 4457 If include_cluster_id=False: list of mf_id integers 4458 4459 Examples 4460 -------- 4461 >>> # Get map with cluster IDs for loading 4462 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives() 4463 >>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...} 4464 >>> 4465 >>> # Get map without cluster IDs for pipeline 4466 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False) 4467 >>> # sample_mf_map = {0: [123, 456], 1: [789], ...} 4468 """ 4469 # Get all representative mass features in bulk (much faster than looping) 4470 representatives = self.get_representative_mass_features_for_all_clusters( 4471 representative_metric=representative_metric 4472 ) 4473 4474 # Build sample_mf_map 4475 sample_mf_map = {} 4476 for _, row in representatives.iterrows(): 4477 sample_id = row["sample_id"] 4478 mf_id = row["mf_id"] 4479 cluster_id = row["cluster"] 4480 4481 if sample_id not in sample_mf_map: 4482 sample_mf_map[sample_id] = [] 4483 4484 if include_cluster_id: 4485 sample_mf_map[sample_id].append((mf_id, cluster_id)) 4486 else: 4487 sample_mf_map[sample_id].append(mf_id) 4488 4489 return sample_mf_map 4490 4491 def get_most_representative_sample_for_cluster( 4492 self, cluster_id, representative_metric=None 4493 ): 4494 """ 4495 Get the most representative sample for a given cluster based on a metric. 4496 4497 Parameters 4498 ---------- 4499 cluster_id : int 4500 The cluster ID to find the representative sample for. 4501 representative_metric : str, optional 4502 The metric to use to determine the most representative sample. 4503 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4504 Options: 4505 - 'intensity': Selects the mass feature with the highest intensity 4506 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 4507 or the highest intensity overall if none have MS2 4508 Default is None (uses parameter setting). 4509 4510 Returns 4511 ------- 4512 dict 4513 Dictionary containing: 4514 - 'sample_id': The sample ID of the most representative sample 4515 - 'sample_name': The sample name of the most representative sample 4516 - 'mf_id': The mass feature ID in the sample 4517 - 'coll_mf_id': The collection-level mass feature ID (index) 4518 - 'has_ms2': Whether this mass feature has MS2 scan numbers 4519 - 'intensity': The intensity value of the representative mass feature 4520 4521 Raises 4522 ------ 4523 ValueError 4524 If cluster_id is not found or if representative_metric is not a valid column. 4525 """ 4526 # Use the bulk method to get all representatives, then filter to this cluster 4527 # This follows DRY principle and ensures consistency 4528 all_representatives = self.get_representative_mass_features_for_all_clusters( 4529 representative_metric=representative_metric 4530 ) 4531 4532 # Filter to the requested cluster 4533 cluster_rep = all_representatives[all_representatives["cluster"] == cluster_id] 4534 4535 if len(cluster_rep) == 0: 4536 # Try to provide helpful error message 4537 available_clusters = self.mass_features_dataframe["cluster"].unique() 4538 raise ValueError( 4539 f"Cluster {cluster_id} not found in mass_features_dataframe. " 4540 f"Available clusters: {sorted(available_clusters[:10].tolist())}... " 4541 f"(showing first 10 of {len(available_clusters)} total clusters)" 4542 ) 4543 4544 # Get the representative row (should only be one) 4545 rep_row = cluster_rep.iloc[0] 4546 4547 # Get sample name from sample_id (convert to int for list indexing) 4548 sample_id = int(rep_row["sample_id"]) 4549 sample_name = self.samples[sample_id] 4550 4551 return { 4552 "sample_id": sample_id, 4553 "sample_name": sample_name, 4554 "mf_id": rep_row["mf_id"], 4555 "coll_mf_id": rep_row["coll_mf_id"], 4556 "has_ms2": rep_row["has_ms2"], 4557 "intensity": rep_row["intensity"], 4558 } 4559 4560 def reload_representative_mass_features( 4561 self, 4562 add_ms2=False, 4563 auto_process_ms2=True, 4564 ms2_spectrum_mode=None, 4565 ms2_scan_filter=None, 4566 ): 4567 """ 4568 Reload mass features for all representative samples in the cluster summary. 4569 4570 This method is useful when the collection was loaded with load_light=True, 4571 which stores mass features only in the collection dataframe. This reloads 4572 the specific mass features that are representatives for each cluster, 4573 allowing them to be accessed as LCMSMassFeature objects. 4574 4575 Parameters 4576 ---------- 4577 add_ms2 : bool, optional 4578 If True, also loads and associates MS2 spectra with mass features. Default is False. 4579 auto_process_ms2 : bool, optional 4580 If True and add_ms2=True, auto-processes MS2 spectra. Default is True. 4581 ms2_spectrum_mode : str or None, optional 4582 Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. 4583 ms2_scan_filter : str or None, optional 4584 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 4585 4586 Returns 4587 ------- 4588 dict 4589 Dictionary mapping sample_id to list of reloaded mf_ids. 4590 4591 Raises 4592 ------ 4593 ValueError 4594 If cluster_summary_dataframe is not set (run add_consensus_mass_features first). 4595 4596 Notes 4597 ----- 4598 - Only reloads mass features that are cluster representatives 4599 - Uses get_most_representative_sample_for_cluster() to determine which to reload 4600 - More memory-efficient than reloading all mass features 4601 - Parallelized based on lcms_collection.cores parameter 4602 - MS2 association uses same logic as add_associated_ms2_dda() 4603 4604 See Also 4605 -------- 4606 _reload_sample_mass_features : Low-level method to reload specific mass features 4607 get_most_representative_sample_for_cluster : Gets representative sample for cluster 4608 """ 4609 # Validate prerequisites 4610 if ( 4611 not hasattr(self, "cluster_summary_dataframe") 4612 or self.cluster_summary_dataframe is None 4613 ): 4614 raise ValueError( 4615 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 4616 ) 4617 4618 # Get all representative mass features in bulk (much faster than looping) 4619 representatives = self.get_representative_mass_features_for_all_clusters() 4620 4621 # Build a dictionary of sample_id -> list of mf_ids that are representatives 4622 sample_mf_map = {} 4623 for _, row in representatives.iterrows(): 4624 sample_id = row["sample_id"] 4625 mf_id = row["mf_id"] 4626 4627 if sample_id not in sample_mf_map: 4628 sample_mf_map[sample_id] = [] 4629 sample_mf_map[sample_id].append(mf_id) 4630 4631 # Reload mass features for each sample (parallelized) 4632 if self.parameters.lcms_collection.cores == 1: 4633 # Serial processing 4634 from tqdm import tqdm 4635 4636 for sample_id in tqdm( 4637 sample_mf_map.keys(), 4638 desc="Reloading representative mass features", 4639 unit="sample", 4640 ): 4641 mf_ids = sample_mf_map[sample_id] 4642 self._reload_sample_mass_features( 4643 sample_id, 4644 mf_ids_to_load=mf_ids, 4645 add_ms2=add_ms2, 4646 auto_process_ms2=auto_process_ms2, 4647 ms2_spectrum_mode=ms2_spectrum_mode, 4648 ms2_scan_filter=ms2_scan_filter, 4649 ) 4650 else: 4651 # Parallel processing 4652 from tqdm import tqdm 4653 4654 if self.parameters.lcms_collection.cores > len(sample_mf_map): 4655 ncores = len(sample_mf_map) 4656 else: 4657 ncores = self.parameters.lcms_collection.cores 4658 4659 # Build arguments list for starmap 4660 args_list = [ 4661 ( 4662 sample_id, 4663 sample_mf_map[sample_id], 4664 add_ms2, 4665 auto_process_ms2, 4666 ms2_spectrum_mode, 4667 ms2_scan_filter, 4668 False, 4669 ) 4670 for sample_id in sample_mf_map.keys() 4671 ] 4672 4673 # Execute in parallel 4674 with spawn_pool(ncores) as pool: 4675 mp_result = pool.starmap(self._reload_sample_mass_features, args_list) 4676 4677 # Collect results back into samples 4678 for i, sample_id in enumerate( 4679 tqdm( 4680 sample_mf_map.keys(), 4681 desc="Collecting reloaded mass features", 4682 unit="sample", 4683 ) 4684 ): 4685 self[sample_id].mass_features = mp_result[i] 4686 4687 return sample_mf_map 4688 4689 def _associate_ms2_with_mass_features( 4690 self, 4691 sample, 4692 local_mf_ids, 4693 auto_process=True, 4694 spectrum_mode=None, 4695 scan_filter=None, 4696 ): 4697 """ 4698 Associate MS2 spectra with specific mass features in a sample. 4699 4700 Uses the LCMSBase helper method to find and load MS2 scans for the specified mass features. 4701 4702 Parameters 4703 ---------- 4704 sample : LCMSBase 4705 The sample object containing mass features and scan data. 4706 local_mf_ids : list of int 4707 List of local (sample-level) mass feature IDs to find MS2 for. 4708 auto_process : bool, optional 4709 If True, auto-processes the MS2 spectra. Default is True. 4710 spectrum_mode : str or None, optional 4711 Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. 4712 scan_filter : str or None, optional 4713 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 4714 4715 Returns 4716 ------- 4717 dict 4718 Dictionary of scan_number -> MassSpectrum objects for the loaded MS2 spectra. 4719 """ 4720 # Check if we have scan data 4721 if not hasattr(sample, "scan_df") or sample.scan_df is None: 4722 return {} 4723 4724 # Separate mass features into those that need scan finding vs those that already have scans 4725 mfs_needing_scan_finding = [] 4726 unique_dda_scans = set() 4727 4728 for mf_id in local_mf_ids: 4729 if mf_id not in sample.mass_features: 4730 continue 4731 mf = sample.mass_features[mf_id] 4732 # If this mass feature already has MS2 scans, add them to our set 4733 if mf.ms2_scan_numbers is not None and len(mf.ms2_scan_numbers) > 0: 4734 # Convert to integers in case they come from HDF5 as numpy types 4735 unique_dda_scans.update([int(scan) for scan in mf.ms2_scan_numbers]) 4736 else: 4737 # Otherwise, we need to find scans for this mass feature 4738 mfs_needing_scan_finding.append(mf_id) 4739 4740 # Only run the scan finding for mass features that need it 4741 if mfs_needing_scan_finding: 4742 found_scans = sample._find_ms2_scans_for_mass_features( 4743 mf_ids=mfs_needing_scan_finding, scan_filter=scan_filter 4744 ) 4745 unique_dda_scans.update(found_scans) 4746 4747 if len(unique_dda_scans) == 0: 4748 return {} 4749 4750 # Get ms2 parameters from sample 4751 # TODO KRH: deal with different ms2 scan types here (CID vs HCD), may need to add scan translator to the initializeion 4752 ms_params = sample.parameters.mass_spectrum["ms2"] 4753 4754 # Load MS2 spectra (convert set to list) 4755 sample.add_mass_spectra( 4756 scan_list=list(unique_dda_scans), 4757 auto_process=auto_process, 4758 spectrum_mode=spectrum_mode, 4759 ms_level=2, 4760 use_parser=True, 4761 ms_params=ms_params, 4762 ) 4763 4764 # Associate MS2 spectra with mass features 4765 for mf_id in local_mf_ids: 4766 if mf_id not in sample.mass_features: 4767 continue 4768 if ( 4769 sample.mass_features[mf_id].ms2_scan_numbers is not None 4770 and len(sample.mass_features[mf_id].ms2_scan_numbers) > 0 4771 ): 4772 for dda_scan in sample.mass_features[mf_id].ms2_scan_numbers: 4773 if dda_scan in sample._ms: 4774 sample.mass_features[mf_id].ms2_mass_spectra[dda_scan] = ( 4775 sample._ms[dda_scan] 4776 ) 4777 4778 # Return only the MS2 spectra we loaded (for parallel processing) 4779 return { 4780 scan: sample._ms[scan] for scan in unique_dda_scans if scan in sample._ms 4781 } 4782 4783 def _reload_sample_mass_features( 4784 self, 4785 sample_id, 4786 mf_ids_to_load=None, 4787 add_ms2=False, 4788 auto_process_ms2=True, 4789 ms2_spectrum_mode=None, 4790 ms2_scan_filter=None, 4791 inplace=True, 4792 ): 4793 """ 4794 Reload specific mass features for a sample from HDF5. 4795 4796 This is useful when the collection was loaded with load_light=True, 4797 which stores mass features only in the collection dataframe and not 4798 as LCMSMassFeature objects in individual samples. 4799 4800 Parameters 4801 ---------- 4802 sample_id : int 4803 The sample ID to reload mass features for. 4804 mf_ids_to_load : list of str, optional 4805 List of collection-level mf_ids (format: '{sample_id}_{local_mf_id}') to load. 4806 If None, loads all mass features for the sample. 4807 add_ms2 : bool, optional 4808 If True, also loads and associates MS2 spectra. Default is False. 4809 auto_process_ms2 : bool, optional 4810 If True, auto-processes MS2 spectra. Default is True. 4811 ms2_spectrum_mode : str or None, optional 4812 Spectrum mode for MS2 spectra. Default is None. 4813 ms2_scan_filter : str or None, optional 4814 Filter string for MS2 scans. Default is None. 4815 inplace : bool, optional 4816 If True, updates the sample's mass_features in place. If False, returns the 4817 mass_features dictionary (for multiprocessing). Default is True. 4818 4819 Returns 4820 ------- 4821 dict or None 4822 If inplace=False, returns dictionary of mass features. 4823 Otherwise returns None and updates object in place. 4824 """ 4825 sample = self[sample_id] 4826 sample_name = self.samples[sample_id] 4827 4828 # Check if we have a collection parser that can reload 4829 if not hasattr(self, "collection_parser") or self.collection_parser is None: 4830 print( 4831 "Warning: Cannot reload mass features - no collection_parser available" 4832 ) 4833 if not inplace: 4834 return {} 4835 return 4836 4837 # Get the HDF5 file for this sample 4838 hdf5_file = ( 4839 self.collection_parser.folder_location 4840 / f"{sample_name}.corems/{sample_name}.hdf5" 4841 ) 4842 4843 if not hdf5_file.exists(): 4844 print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") 4845 if not inplace: 4846 return {} 4847 return 4848 4849 # Import here to avoid circular imports 4850 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 4851 4852 # If specific mf_ids requested, use them directly 4853 local_mf_ids_to_load = None 4854 if mf_ids_to_load is not None: 4855 # mf_ids_to_load is already a list of sample-level mf_ids (integers) 4856 # No parsing needed - they come from the mf_id column in the dataframe 4857 local_mf_ids_to_load = set(mf_ids_to_load) 4858 4859 # Reload mass features from HDF5 4860 with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: 4861 # Load mass features - if specific IDs requested, only load those 4862 parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) 4863 4864 # If add_ms2, associate MS2 spectra with the loaded mass features 4865 if add_ms2 and local_mf_ids_to_load is not None: 4866 self._associate_ms2_with_mass_features( 4867 sample, 4868 list(local_mf_ids_to_load), 4869 auto_process=auto_process_ms2, 4870 spectrum_mode=ms2_spectrum_mode, 4871 scan_filter=ms2_scan_filter, 4872 ) 4873 4874 # Return mass features if not inplace (for multiprocessing) 4875 if not inplace: 4876 return sample.mass_features 4877 4878 def add_sparse_distance_matrix(self, features): 4879 if features is None: 4880 return None 4881 else: 4882 features = features.copy() 4883 4884 # Parameters for calculating distance between features 4885 dims = ["mz", "scan_time_aligned"] 4886 relative = [True, False] 4887 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4888 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 4889 dist_weight = [1, 1] 4890 4891 # Check that the dimensions and tolerances are the same length 4892 if ( 4893 len(dims) != len(tol) 4894 or len(dims) != len(relative) 4895 or len(dims) != len(dist_weight) 4896 ): 4897 raise ValueError( 4898 "The dimensions, tolerances, relative, dist_weight, and na_allow lists must be the same length" 4899 ) 4900 4901 # Make connectivity matrix for masking within sample mass features 4902 ## Masking matrix cmat will mark all features from the same sample as 0 4903 ## To mask, a matrix can be multiplied by cmat and features from same 4904 ## samples are multiplied by 0, while features from different samples 4905 ## are multiplied by 1 4906 4907 if "sample_id" not in features.columns: 4908 cmat = None 4909 else: 4910 vals = features["sample_id"].values.reshape(-1, 1) 4911 cmat = scipy.spatial.distance.cdist(vals, vals) 4912 # Convert to binary (0 if same sample, 1 if different) 4913 cmat = np.where(cmat == 0, 0, 1) 4914 # Convert to coordinate matrix for sparse operations later 4915 cmat = sparse.coo_matrix(cmat) 4916 4917 # Compute inter-feature distances using sparse matrix approach 4918 distances = None # clear the distances object before starting 4919 for i in range(len(dims)): # iterate through all dimensions to be considered 4920 # Construct k-d tree 4921 values = features[dims[i]].values 4922 4923 tree = KDTree(values.reshape(-1, 1)) 4924 4925 max_tol = tol[i] 4926 if relative[i] is True: 4927 # Maximum absolute tolerance 4928 max_tol = tol[i] * values.max() 4929 4930 # Compute sparse distance matrix 4931 # the larger the max_tol, the slower this operation is 4932 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 4933 4934 # Only consider forward case, exclude diagonal 4935 sdm = sparse.triu(sdm, k=1) 4936 4937 # Filter relative distances 4938 if relative[i] is True: 4939 # Compute relative distances 4940 rel_dists = sdm.data / values[sdm.row] 4941 4942 # Indices of relative distances less than tolerance 4943 idx = rel_dists <= tol[i] 4944 4945 # Reconstruct sparse distance matrix 4946 sdm = sparse.coo_matrix( 4947 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 4948 shape=(len(values), len(values)), 4949 ) 4950 4951 # Scaled distances wrt the maximum tolerance for the dimension 4952 sdm.data = sdm.data / tol[i] 4953 4954 # Stack distances for dimensions where na_allow is False 4955 if distances is None: 4956 sdm.data = sdm.data * dist_weight[i] 4957 # Replace zeros with epsilon to handle perfect matches 4958 sdm.data[sdm.data == 0] = 1e-10 4959 distances = sdm 4960 else: 4961 # Prepare sdm to match shape of existing distances 4962 distances_truth = distances.copy() 4963 # make new sparse matrix with same positions as previous 4964 # distance matrix but all ones for values 4965 distances_truth.data = np.ones_like(distances_truth.data) 4966 4967 # Replace zeros with epsilon BEFORE multiply to prevent sparse matrix from dropping them 4968 sdm.data[sdm.data == 0] = 1e-10 4969 4970 # multiply the new sparse matrix (sdm) by this mask to remove 4971 # data that doesn't exist in original sparse matrix 4972 sdm = distances_truth.multiply(sdm) 4973 4974 sdm.data = sdm.data * dist_weight[i] 4975 # Replace zeros with epsilon to handle perfect matches 4976 sdm.data[sdm.data == 0] = 1e-10 4977 4978 # use same process as before to remove data from previous 4979 # distances matrix that isn't in new distances matrix 4980 sdm_truth = sdm.copy() 4981 sdm_truth.data = np.ones_like(sdm_truth.data) 4982 4983 # remove the distances that are not sdm 4984 distances = distances.multiply(sdm_truth) 4985 4986 # Sum the new distances 4987 distances = distances + sdm 4988 4989 # Multiply by connectivity matrix for more masking 4990 distances = distances.multiply(cmat) 4991 4992 # Set attribute holding distance matrix 4993 self._sparse_distance_matrix = distances 4994 4995 def evaluate_clusters_for_repeats(self, features): 4996 raise NotImplementedError("evaluate_clusters_for_repeats not implemented yet") 4997 summary_df = self.cluster_summary_dataframe.copy() 4998 4999 # Arrange by decreasing median intensity 5000 summary_df = summary_df.sort_values( 5001 by="intensity_median", ascending=False 5002 ).reset_index(drop=True) 5003 5004 # Find clusters that are within the mz_tol and rt_tol of each other (on the medians) 5005 # Create a distance matrix 5006 # Define how to calculate the distance between features 5007 dims = ["mz_median", "scan_time_aligned_median"] 5008 relative = [True, False] 5009 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5010 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 5011 5012 # Compute inter-feature distances 5013 distances = None 5014 for i in range(len(dims)): 5015 # Construct k-d tree 5016 values = summary_df[dims[i]].values 5017 tree = KDTree(values.reshape(-1, 1)) 5018 5019 max_tol = tol[i] 5020 if relative[i] is True: 5021 # Maximum absolute tolerance 5022 max_tol = tol[i] * values.max() 5023 5024 # Compute sparse distance matrix 5025 # the larger the max_tol, the slower this operation is 5026 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 5027 5028 # Only consider forward case, exclude diagonal 5029 sdm = sparse.triu(sdm, k=1) 5030 5031 # Filter relative distances 5032 if relative[i] is True: 5033 # Compute relative distances 5034 rel_dists = sdm.data / values[sdm.row] # or col? 5035 5036 # Indices of relative distances less than tolerance 5037 idx = rel_dists <= tol[i] 5038 5039 # Reconstruct sparse distance matrix 5040 sdm = sparse.coo_matrix( 5041 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 5042 shape=(len(values), len(values)), 5043 ) 5044 5045 # Cast as binary matrix 5046 sdm.data = np.ones_like(sdm.data) 5047 5048 # Stack distances 5049 if distances is None: 5050 distances = sdm 5051 else: 5052 distances = distances.multiply(sdm) 5053 5054 # Roll up features 5055 # Extract indices of within-tolerance points 5056 distances = distances.tocoo() 5057 pairs = np.stack( 5058 (distances.row, distances.col), axis=1 5059 ) # These are the index values of the clusters, not the cluster ids 5060 # Conver to cluster ids 5061 pairs_df = pd.DataFrame(pairs, columns=["parent", "child"]) 5062 pairs_df["parent"] = summary_df.loc[pairs[:, 0]]["cluster"].values 5063 pairs_df["child"] = summary_df.loc[pairs[:, 1]]["cluster"].values 5064 pairs_df = pairs_df.set_index("parent") 5065 5066 merge_these_clusters = [] 5067 possible_overlaps = [] 5068 root_parents = np.setdiff1d( 5069 np.unique(pairs_df.index.values), np.unique(pairs_df.child.values) 5070 ) 5071 for parent in root_parents: 5072 parent_features = features[features["cluster"] == parent] 5073 children = pairs_df.loc[[parent], "child"].tolist() 5074 for child in children: 5075 overlap = self.check_merge(parent_features, child, features) 5076 if len(overlap) == 0: 5077 merge_these_clusters.append((parent, child, len(overlap))) 5078 else: 5079 possible_overlaps.append((parent, child, len(overlap))) 5080 5081 result_dict = {} 5082 result_dict["merge_these_clusters"] = merge_these_clusters 5083 result_dict["possible_overlaps"] = possible_overlaps 5084 5085 return result_dict 5086 5087 def check_merge(self, parent_features, child, features): 5088 # Grab the features of the parent and children 5089 child_features = features[features["cluster"] == child] 5090 5091 # Check if there is an overlap between mf_coll_id in the parent and child clusters 5092 overlap = np.intersect1d( 5093 parent_features["sample_id"].values, child_features["sample_id"].values 5094 ) 5095 5096 return overlap 5097 5098 def cluster_mass_features_agg_cluster(self, features): 5099 if features is None: 5100 return None 5101 5102 features = features.copy() 5103 5104 self.add_sparse_distance_matrix(features) 5105 5106 distances = self._sparse_distance_matrix 5107 5108 # Convert to full matrix 5109 distances = distances.todense() 5110 5111 # Cast all 0s to 1s for a distance matrix 5112 distances[distances == 0] = 1 5113 distances = np.asarray(distances) 5114 5115 # Perform clustering 5116 try: 5117 clustering = AgglomerativeClustering( 5118 n_clusters=None, 5119 linkage="complete", 5120 # using complete linkage will prevent one sample from being assigned to multiple clusters 5121 metric="precomputed", 5122 distance_threshold=1, 5123 ).fit(distances) 5124 features["cluster"] = clustering.labels_ 5125 5126 # All data points are singleton clusters 5127 except: 5128 features["cluster"] = np.arange(len(features.index)) 5129 5130 return features 5131 5132 def cluster_inspection_plot(self, clu, return_fig=False, path=None): 5133 """ 5134 Generate Scan Time vs m/z plot for a narrow range around a given 5135 cluster. This tool is meant to support the user in fine tuning the 5136 tolerances used for the clustering algorithm. The user-provided cluster 5137 ID is highlighted in larger, magenta marker and the ten largest of the 5138 remaining clusters are idenfitied with different colors while the 5139 smallest clusters are light gray. 5140 5141 Parameters 5142 ----------- 5143 clu : integer 5144 A cluster ID that exists in self.mass_features_dataframe 5145 return_fig : bool, optional 5146 If True, return the open figure (caller owns lifecycle). 5147 Default is False. 5148 path : str or path-like, optional 5149 If set, save the figure to this path. When ``return_fig`` is False, 5150 the figure is closed after saving and ``plt.show()`` is not called. 5151 5152 Returns 5153 -------- 5154 matplotlib.figure.Figure or None 5155 A figure displaying a scan time vs m/z scatterplot of small region 5156 around a given cluster with the ten largest clusters in the region 5157 distinctly identified if ``return_fig`` is True; otherwise None. 5158 5159 Raises 5160 ------ 5161 ValueError 5162 If cluster data haven't been added to the object yet 5163 """ 5164 5165 if "cluster" not in self.mass_features_dataframe.columns: 5166 raise ValueError( 5167 "Cluster information is not yet added to mass_features_dataframe, must run add_consensus_mass_features() first" 5168 ) 5169 5170 else: 5171 mztol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5172 rttol = self.parameters.lcms_collection.consensus_rt_tol 5173 clu_features = self.mass_features_dataframe.copy() 5174 5175 inclu = clu_features[clu_features.cluster == clu] 5176 exclu = clu_features[clu_features.cluster != clu] 5177 5178 dt_ymin = np.floor(min(inclu.mz)) - 1 5179 dt_ymax = np.ceil(max(inclu.mz)) + 1 5180 dt_xmin = np.floor(min(inclu.scan_time_aligned)) - 1 5181 dt_xmax = np.ceil(max(inclu.scan_time_aligned)) + 1 5182 5183 exclu = exclu[ 5184 (exclu.mz.between(dt_ymin, dt_ymax, inclusive="both")) 5185 & (exclu.scan_time_aligned.between(dt_xmin, dt_xmax, inclusive="both")) 5186 ] 5187 5188 bigclulist = list(exclu.cluster.value_counts()[:10].index) 5189 bigclu = exclu[exclu.cluster.isin(bigclulist)] 5190 smclu = exclu[~exclu.cluster.isin(bigclulist)] 5191 5192 colors = np.arange(0, 10) 5193 colordict = dict(zip(bigclulist, colors)) 5194 bigclu["color"] = bigclu.cluster.apply(lambda x: colordict[x]) 5195 5196 fig = plt.figure(figsize=(7.5, 5)) 5197 5198 plt.scatter( 5199 inclu.scan_time_aligned, 5200 inclu.mz, 5201 c="m", 5202 s=3, 5203 label="Cluster " + str(clu), 5204 ) 5205 5206 plt.scatter( 5207 bigclu.scan_time_aligned, bigclu.mz, c=bigclu.color, cmap="tab10", s=1.5 5208 ) 5209 5210 plt.scatter( 5211 smclu.scan_time_aligned, 5212 smclu.mz, 5213 c="silver", 5214 s=2, 5215 label="Small clusters", 5216 ) 5217 5218 plt.ylim(dt_ymin, dt_ymax) 5219 plt.xlim(dt_xmin, dt_xmax) 5220 plt.legend(ncol=2, bbox_to_anchor=(0.8, -0.1)) 5221 plt.xlabel("Scan time") 5222 plt.ylabel("m/z") 5223 title_str = "Cluster " + str(clu) 5224 title_str += ": representing " + str(len(inclu.sample_id.unique())) 5225 title_str += " of " + str(len(clu_features.sample_id.unique())) 5226 title_str += " samples\n" 5227 title_str += "M/Z tolerance: " + str(mztol) + "\n" 5228 title_str += "Scan Time tolerance: " + str(rttol) 5229 plt.title(title_str, fontsize=10) 5230 5231 return _finalize_plot(fig, return_fig=return_fig, path=path) 5232 5233 def plot_cluster_outlier_frequency( 5234 self, 5235 dim_list=["mz", "scan_time_aligned"], 5236 clu_size_thresh=0.5, 5237 return_fig=False, 5238 path=None, 5239 ): 5240 """ 5241 Generate histogram showing the frequency of outlier occurrences by 5242 clustering dimension across all clusters 5243 5244 Parameters 5245 ----------- 5246 dim_list : list 5247 List of strings describing dimensions that can be used in 5248 clustering. Available list items: 5249 - 'mz' 5250 - 'scan_time_aligned' 5251 - 'half_height_width' 5252 - 'tailing_factor' 5253 - 'dispersity_index' 5254 - 'intensity' 5255 - 'persistence' 5256 clu_size_thresh : float 5257 Value between 0 and 1 that indicates what percentage of samples 5258 need to be present in a cluster before it's evaluated for outliers. 5259 Defaults to 0.5. 5260 return_fig : bool, optional 5261 If True, return the open figure (caller owns lifecycle). 5262 Default is False. 5263 path : str or path-like, optional 5264 If set, save the figure to this path. When ``return_fig`` is False, 5265 the figure is closed after saving and ``plt.show()`` is not called. 5266 5267 Returns 5268 -------- 5269 matplotlib.figure.Figure or None 5270 A figure displaying the frequency of outlier occurrences across all 5271 clusters in the provided measurement dimensions if ``return_fig`` is 5272 True; otherwise None. 5273 5274 Raises 5275 ------ 5276 ValueError 5277 If cluster data haven't been added to the object yet 5278 """ 5279 5280 if not hasattr(self, "cluster_summary_dataframe"): 5281 raise ValueError( 5282 "cluster_summary_dataframe is not yet added, must run add_consensus_mass_features() first" 5283 ) 5284 5285 mfdf = self.mass_features_dataframe.copy() 5286 summarydf = self.cluster_summary_dataframe 5287 5288 numsamples = len(self) 5289 sumdf = ( 5290 summarydf[summarydf.sample_id_nunique > numsamples * clu_size_thresh] 5291 .reset_index(drop=True) 5292 .copy() 5293 ) 5294 5295 ## find the ranges for non-outlier values and add them to sumdf 5296 mergelist = ["cluster"] 5297 for dim in dim_list: 5298 maxtag = dim + "_outmax" 5299 mintag = dim + "_outmin" 5300 mergelist.append(maxtag) 5301 mergelist.append(mintag) 5302 # Calculate outlier thresholds using vectorized operations 5303 sumdf[mintag] = sumdf[dim + "_mean"] - 3 * sumdf[dim + "_std"] 5304 sumdf[maxtag] = sumdf[dim + "_mean"] + 3 * sumdf[dim + "_std"] 5305 ## If NaN shows up anywhere in dim_min, dim_max calculations, value is set to NaN and it's 5306 ## not flagged. This happens when there's not enough values to compute median/std for that 5307 ## dimension therefore can't have outliers 5308 5309 ## add ranges to mfdf and identify mass features that fall outside the ranges 5310 # Merge without dropping NaN - we'll handle it per-dimension 5311 outdf = pd.merge(mfdf, sumdf[mergelist], on="cluster") 5312 5313 outtags = ["cluster"] 5314 for dim in dim_list: 5315 dimtag = dim + "_outlier" 5316 outtags.append(dimtag) 5317 maxtag = dim + "_outmax" 5318 mintag = dim + "_outmin" 5319 # Only flag as outlier if thresholds are valid (not NaN) 5320 outdf[dimtag] = np.where( 5321 (outdf[maxtag].notna() & outdf[mintag].notna()) 5322 & ((outdf[dim] > outdf[maxtag]) | (outdf[dim] < outdf[mintag])), 5323 True, 5324 False, 5325 ) 5326 5327 ## identify number of outliers in each cluster 5328 outliers = outdf[outtags] 5329 outliers = outliers.groupby(["cluster"]).sum() 5330 5331 ## plot number of clusters that contain any outliers 5332 fig = plt.figure() 5333 plt.bar(dim_list, outliers.sum().values, width=0.5) 5334 plt.xticks(rotation=90) 5335 plt.title("Frequency of outliers across all clusters by category") 5336 5337 return _finalize_plot(fig, return_fig=return_fig, path=path) 5338 5339 def _search_for_targeted_mass_features_in_sample( 5340 self, obj_idx, missingdf, cluster_dict, expand_on_miss=False, inplace=True 5341 ): 5342 """ 5343 Helper method to search for missing mass features in a single sample. 5344 5345 Internal method called by fill_missing_cluster_features() to perform 5346 gap-filling for one sample in the collection. 5347 5348 Parameters 5349 ---------- 5350 obj_idx : int 5351 Index of the sample being processed 5352 missingdf : pd.DataFrame 5353 DataFrame containing cluster information with columns: 5354 'cluster', 'sample_id_nunique', 'mz_min', 'mz_max', 5355 'scan_time_aligned_min', 'scan_time_aligned_max', 'mz_min_allowed', 5356 'mz_max_allowed', 'scan_time_aligned_min_allowed', 5357 'scan_time_aligned_max_allowed', 'missing_samples' 5358 cluster_dict : dict 5359 Pre-computed cluster feature dictionary to avoid recomputation 5360 expand_on_miss : bool 5361 If True, expands search window when no peak found initially 5362 inplace : bool 5363 If True, assigns induced_mass_features in place. If False, returns the 5364 induced features dictionary (for multiprocessing) 5365 5366 Returns 5367 ------- 5368 dict or None 5369 If inplace=False, returns dictionary of induced mass features. 5370 Otherwise returns None and updates object in place. 5371 """ 5372 ## Use the pre-computed cluster dictionary passed as parameter 5373 5374 ## to get clusters missing data based on sample index: 5375 sampledf = ( 5376 missingdf[missingdf.missing_samples.apply(lambda x: obj_idx in x)] 5377 .reset_index(drop=True) 5378 .copy() 5379 ) 5380 5381 # Skip if no missing features for this sample 5382 if len(sampledf) == 0: 5383 if not inplace: 5384 return {} 5385 return 5386 5387 self.load_raw_data(obj_idx, 1) 5388 5389 ## this is the line that bugs due to _ms_unprocessed not having key 1 5390 ms1df = self[obj_idx]._ms_unprocessed[1].copy() 5391 scan_df = self[obj_idx].scan_df[["scan", "scan_time_aligned"]] 5392 ms1df = pd.merge(ms1df, scan_df, on="scan") 5393 5394 # Pre-extract all values from sampledf to avoid repeated .iloc calls 5395 clusters = sampledf.cluster.values 5396 mz_mins = sampledf.mz_min.values 5397 mz_maxs = sampledf.mz_max.values 5398 st_mins = sampledf.scan_time_aligned_min.values 5399 st_maxs = sampledf.scan_time_aligned_max.values 5400 5401 if expand_on_miss: 5402 mz_mins_allowed = sampledf.mz_min_allowed.values 5403 mz_maxs_allowed = sampledf.mz_max_allowed.values 5404 st_mins_allowed = sampledf.sta_min_allowed.values 5405 st_maxs_allowed = sampledf.sta_max_allowed.values 5406 5407 # Pre-filter ms1df to reduce search space 5408 mz_global_min = mz_mins.min() 5409 mz_global_max = mz_maxs.max() 5410 st_global_min = st_mins.min() 5411 st_global_max = st_maxs.max() 5412 5413 if expand_on_miss: 5414 mz_global_min = min(mz_global_min, mz_mins_allowed.min()) 5415 mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) 5416 st_global_min = min(st_global_min, st_mins_allowed.min()) 5417 st_global_max = max(st_global_max, st_maxs_allowed.max()) 5418 5419 ms1df_filtered = ms1df[ 5420 (ms1df.mz >= mz_global_min) 5421 & (ms1df.mz <= mz_global_max) 5422 & (ms1df.scan_time_aligned >= st_global_min) 5423 & (ms1df.scan_time_aligned <= st_global_max) 5424 ].copy() 5425 5426 # Generate set_ids for all features 5427 set_ids = [f"c{clusters[i]}_{i}_i" for i in range(len(sampledf))] 5428 5429 # Use batch method to process all features at once 5430 if expand_on_miss: 5431 # First try with normal bounds 5432 peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch( 5433 ms1df_filtered, 5434 mz_mins, 5435 mz_maxs, 5436 st_mins, 5437 st_maxs, 5438 set_ids, 5439 obj_idx=obj_idx, 5440 st_aligned=True, 5441 ) 5442 5443 # Retry failed features with expanded bounds 5444 failed_indices = [ 5445 i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99 5446 ] 5447 if failed_indices: 5448 failed_ids = [set_ids[i] for i in failed_indices] 5449 retry_peaks = self[obj_idx].search_for_targeted_mass_features_batch( 5450 ms1df_filtered, 5451 mz_mins_allowed[failed_indices], 5452 mz_maxs_allowed[failed_indices], 5453 st_mins_allowed[failed_indices], 5454 st_maxs_allowed[failed_indices], 5455 failed_ids, 5456 obj_idx=obj_idx, 5457 st_aligned=True, 5458 ) 5459 peaks_dict.update(retry_peaks) 5460 else: 5461 peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch( 5462 ms1df_filtered, 5463 mz_mins, 5464 mz_maxs, 5465 st_mins, 5466 st_maxs, 5467 set_ids, 5468 obj_idx=obj_idx, 5469 st_aligned=True, 5470 ) 5471 5472 # Assign peaks to induced_mass_features and cluster_dict 5473 for i in range(len(sampledf)): 5474 peak = peaks_dict[set_ids[i]] 5475 self[obj_idx].induced_mass_features[peak.id] = peak 5476 cluster_dict[clusters[i]] += [set_ids[i]] 5477 5478 # TODO KRH: Let's try to avoid these steps unless asked for by parameters to pick up speed 5479 if False: 5480 self[obj_idx].add_associated_ms1(induced_features=True) 5481 # need to set drop_if_fail to false for induced features as they will fail 5482 self[obj_idx].add_peak_metrics(induced_features=True) 5483 5484 # The duplicate scan only deletes keys from mass_features. Induced ids 5485 # are strings such as c12_0_i, so that scan does not remove them. 5486 self[obj_idx].integrate_mass_features( 5487 drop_if_fail=False, induced_features=True, drop_duplicates=False 5488 ) 5489 5490 if not inplace: 5491 return self[obj_idx].induced_mass_features 5492 5493 def _missing_sample_ids_by_cluster(self, mfdf, cluster_ids): 5494 """Sample ids with no feature row for each cluster, in sample-id order. 5495 5496 Includes samples that have no mass features. Lookup matches numpy 5497 integers and integral floats to the cluster key. 5498 """ 5499 all_sample_ids = list(range(len(self.samples))) 5500 if mfdf is None or len(mfdf) == 0: 5501 present_by_cluster = pd.Series(dtype=object) 5502 else: 5503 present_by_cluster = mfdf.groupby("cluster", sort=False)["sample_id"].agg( 5504 lambda ids: set(ids.to_numpy()) 5505 ) 5506 missing = [] 5507 for cluster_id in cluster_ids: 5508 present = present_by_cluster.get(cluster_id, ()) 5509 missing.append( 5510 [sample_id for sample_id in all_sample_ids if sample_id not in present] 5511 ) 5512 return missing 5513 5514 def fill_missing_cluster_features(self): 5515 """ 5516 Gap-filling for consensus mass features across collection samples. 5517 5518 For clusters present in multiple samples but missing from others, searches 5519 raw MS1 data to find peaks in expected m/z and retention time windows. This 5520 creates "induced" mass features for peaks that exist in the data but weren't 5521 detected in the initial peak detection. 5522 5523 Must be run after add_consensus_mass_features(). Results are accessible via 5524 induced_mass_features_dataframe property and included in collection_pivot_table 5525 and collection_consensus_report outputs. 5526 5527 Parameters 5528 ---------- 5529 None 5530 Uses parameters from self.parameters.lcms_collection: 5531 - consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain 5532 a cluster before gap-filling is attempted 5533 - gap_fill_expand_on_miss: If True, expands search window when no peak is found 5534 5535 Returns 5536 ------- 5537 None 5538 Updates induced_mass_features attribute for each LCMSBase object and 5539 combines them into induced_mass_features_dataframe. 5540 5541 Raises 5542 ------ 5543 ValueError 5544 If cluster_summary_dataframe is not set (must run add_consensus_mass_features first). 5545 5546 Notes 5547 ----- 5548 - Loads raw MS1 data for each sample, which may be memory intensive 5549 - Induced features are integrated and metrics calculated automatically 5550 - Processing can be parallelized using parameters.lcms_collection.cores 5551 5552 See Also 5553 -------- 5554 add_consensus_mass_features : Creates consensus features before gap-filling 5555 collection_pivot_table : Includes both regular and induced features 5556 collection_consensus_report : Reports on complete feature matrix 5557 """ 5558 5559 # Validate prerequisites 5560 if ( 5561 not hasattr(self, "cluster_summary_dataframe") 5562 or self.cluster_summary_dataframe is None 5563 ): 5564 raise ValueError( 5565 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 5566 ) 5567 5568 # Get parameters from settings 5569 min_cluster_presence = ( 5570 self.parameters.lcms_collection.consensus_min_sample_fraction 5571 ) 5572 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5573 5574 # Validate parameters 5575 if not 0 <= min_cluster_presence <= 1: 5576 raise ValueError("consensus_min_sample_fraction must be between 0 and 1") 5577 5578 summarydf = self.cluster_summary_dataframe 5579 mfdf = self.mass_features_dataframe 5580 5581 sample_ct = len(self.samples) 5582 5583 # Identify clusters present in sufficient samples but not all samples 5584 missingdf = summarydf[ 5585 [ 5586 "cluster", 5587 "sample_id_nunique", 5588 "mz_min", 5589 "mz_max", 5590 "scan_time_aligned_min", 5591 "scan_time_aligned_max", 5592 ] 5593 ] 5594 missingdf = missingdf[ 5595 missingdf.sample_id_nunique > min_cluster_presence * sample_ct 5596 ] 5597 missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] 5598 5599 # Check if there are any clusters to gap-fill 5600 if len(missingdf) == 0: 5601 return 5602 5603 # One groupby. Sample ids stay in collection order, including samples 5604 # that have no mass features. 5605 missingdf["missing_samples"] = self._missing_sample_ids_by_cluster( 5606 mfdf, missingdf.cluster.to_numpy() 5607 ) 5608 5609 # Calculate expanded search windows for expand_on_miss option 5610 mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5611 rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol 5612 missingdf["mz_max_allowed"] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max 5613 missingdf["mz_min_allowed"] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min 5614 # consensus_rt_tol is an absolute tolerance in minutes, same as clustering. 5615 missingdf["sta_max_allowed"] = missingdf.scan_time_aligned_max + rt_clu_tol 5616 missingdf["sta_min_allowed"] = missingdf.scan_time_aligned_min - rt_clu_tol 5617 5618 # Compute cluster dictionary once to avoid recomputing for each sample 5619 cluster_dict = self.cluster_feature_dictionary 5620 5621 # Process each sample to search for missing features 5622 if self.parameters.lcms_collection.cores == 1: 5623 for i in tqdm(range(sample_ct), desc="Gap-filling samples", unit="sample"): 5624 self._search_for_targeted_mass_features_in_sample( 5625 i, missingdf, cluster_dict, expand_on_miss 5626 ) 5627 5628 if self.parameters.lcms_collection.cores > 1: 5629 if self.parameters.lcms_collection.cores > len(self): 5630 ncores = len(self) 5631 else: 5632 ncores = self.parameters.lcms_collection.cores 5633 with spawn_pool(ncores) as pool: 5634 mp_result = pool.starmap( 5635 self._search_for_targeted_mass_features_in_sample, 5636 [ 5637 (x, missingdf, cluster_dict, expand_on_miss, False) 5638 for x in range(sample_ct) 5639 ], 5640 ) 5641 5642 for i in tqdm( 5643 range(sample_ct), desc="Collecting gap-filled features", unit="sample" 5644 ): 5645 self[i].induced_mass_features = mp_result[i] 5646 5647 self._combine_mass_features(induced_features=True) 5648 5649 # Mark that gap-filling has been performed 5650 self.missing_mass_features_searched = True 5651 5652 for sample_name in self.samples: 5653 self._lcms[sample_name].mass_features = {} 5654 5655 def process_samples_pipeline( 5656 self, operations, description=None, keep_raw_data=False, show_progress=True 5657 ): 5658 """ 5659 Execute a pipeline of operations on all samples in parallel. 5660 5661 This method provides a flexible framework for performing multiple 5662 sample-level operations in a single parallelized pass, which is more 5663 efficient than calling separate methods sequentially. 5664 5665 Parameters 5666 ---------- 5667 operations : list of SampleOperation 5668 List of operations to perform on each sample, in order. 5669 Each operation should be an instance of a class derived from 5670 SampleOperation (see lc_calc_operations module). 5671 description : str or None, optional 5672 Progress bar description. If None, automatically generates description 5673 from operation descriptions (e.g., "gap-filling, reloading features"). 5674 Default is None. 5675 keep_raw_data : bool, optional 5676 If True, keeps raw MS data loaded in memory after pipeline completes. 5677 If False, cleans up raw data to free memory. Default is False. 5678 show_progress : bool, optional 5679 If True, displays progress bars during processing. If False, runs silently. 5680 Default is True. 5681 5682 Returns 5683 ------- 5684 dict 5685 Dictionary with results from pipeline execution, keyed by operation name. 5686 Structure: {operation_name: {sample_id: result, ...}, ...} 5687 5688 Raises 5689 ------ 5690 ValueError 5691 If operations list is empty or contains invalid operations. 5692 5693 Notes 5694 ----- 5695 - Operations are executed sequentially within each sample 5696 - Samples are processed in parallel based on parameters.lcms_collection.cores 5697 - Each operation can have conditional execution via can_execute() 5698 - Results are collected back via collect_results() method of each operation 5699 - Failed operations for a sample are logged but don't halt processing 5700 - Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True 5701 5702 Examples 5703 -------- 5704 >>> from corems.mass_spectra.calc.lc_calc_operations import ( 5705 ... GapFillOperation, ReloadFeaturesOperation 5706 ... ) 5707 >>> ops = [ 5708 ... GapFillOperation('gap_fill', expand_on_miss=True), 5709 ... ReloadFeaturesOperation('reload', add_ms2=True) 5710 ... ] 5711 >>> results = lcms_collection.process_samples_pipeline(ops) 5712 5713 See Also 5714 -------- 5715 lc_calc_operations : Module containing built-in operation classes 5716 fill_and_process_features : Convenience method combining common operations 5717 """ 5718 from corems.mass_spectra.calc.lc_calc_operations import SampleOperation 5719 5720 # Validate operations 5721 if not operations or len(operations) == 0: 5722 raise ValueError("operations list cannot be empty") 5723 5724 for op in operations: 5725 if not isinstance(op, SampleOperation): 5726 raise ValueError( 5727 f"All operations must be SampleOperation instances, got {type(op)}" 5728 ) 5729 5730 # Generate description from operations if not provided 5731 if description is None: 5732 operation_descriptions = [op.description for op in operations] 5733 description = ", ".join(operation_descriptions).capitalize() 5734 5735 # Prepare runtime parameters for each operation 5736 # This is where we gather collection-level data that operations need 5737 runtime_params = self._prepare_pipeline_runtime_params(operations) 5738 runtime_params["keep_raw_data"] = keep_raw_data 5739 5740 # Execute pipeline 5741 sample_ct = len(self.samples) 5742 5743 if self.parameters.lcms_collection.cores == 1: 5744 # Serial processing 5745 results_by_operation = {op.name: {} for op in operations} 5746 5747 if show_progress: 5748 from tqdm import tqdm 5749 5750 # Print description on its own line before progress bar 5751 print(f"\n{description.capitalize()}:") 5752 iterator = tqdm(range(sample_ct), unit="sample", ncols=80) 5753 else: 5754 iterator = range(sample_ct) 5755 5756 for sample_id in iterator: 5757 sample_results = self._execute_sample_pipeline( 5758 sample_id, operations, runtime_params, inplace=True 5759 ) 5760 # Collect results (collect_results already called in _execute_sample_pipeline when inplace=True) 5761 # Skip 'sample_id' key which is added for tracking 5762 for op_name, result in sample_results.items(): 5763 if op_name != "sample_id": 5764 results_by_operation[op_name][sample_id] = result 5765 else: 5766 # Parallel processing 5767 if self.parameters.lcms_collection.cores > sample_ct: 5768 ncores = sample_ct 5769 else: 5770 ncores = self.parameters.lcms_collection.cores 5771 5772 # Build arguments for each sample 5773 args_list = [ 5774 (sample_id, operations, runtime_params, False) 5775 for sample_id in range(sample_ct) 5776 ] 5777 5778 # Execute in parallel with progress tracking 5779 results_by_operation = {op.name: {} for op in operations} 5780 5781 with spawn_pool(ncores) as pool: 5782 if show_progress: 5783 from tqdm import tqdm 5784 import time 5785 5786 # Use starmap_async for parallel execution with progress tracking 5787 async_result = pool.starmap_async( 5788 self._execute_sample_pipeline, args_list 5789 ) 5790 5791 # Poll for completion and update progress bar 5792 print(description) 5793 pbar = tqdm( 5794 total=sample_ct, 5795 desc="", 5796 unit="sample", 5797 position=0, 5798 leave=True, 5799 dynamic_ncols=True, 5800 ) 5801 prev_completed = 0 5802 while not async_result.ready(): 5803 # Get number of completed tasks by checking remaining 5804 completed = sample_ct - async_result._number_left 5805 if completed > prev_completed: 5806 pbar.update(completed - prev_completed) 5807 prev_completed = completed 5808 time.sleep(0.5) # Poll every 500ms to avoid spam 5809 5810 # Final update to 100% 5811 if prev_completed < sample_ct: 5812 pbar.update(sample_ct - prev_completed) 5813 pbar.close() 5814 5815 # Get all results 5816 mp_results = async_result.get() 5817 else: 5818 # Execute without progress 5819 mp_results = pool.starmap(self._execute_sample_pipeline, args_list) 5820 5821 # Collect results back into collection 5822 for result in mp_results: 5823 sample_id = result.get("sample_id") 5824 for op in operations: 5825 op_result = result.get(op.name) 5826 if op_result is not None: 5827 op.collect_results(sample_id, op_result, self) 5828 results_by_operation[op.name][sample_id] = op_result 5829 5830 return results_by_operation 5831 5832 def _prepare_pipeline_runtime_params(self, operations): 5833 """ 5834 Prepare runtime parameters needed by operations in the pipeline. 5835 5836 This method gathers collection-level data that operations need, 5837 such as cluster information for gap-filling or mf_ids for reloading. 5838 5839 Parameters 5840 ---------- 5841 operations : list of SampleOperation 5842 List of operations that will be executed 5843 5844 Returns 5845 ------- 5846 dict 5847 Dictionary of runtime parameters for operations 5848 """ 5849 from corems.mass_spectra.calc.lc_calc_operations import ( 5850 GapFillOperation, 5851 ReloadFeaturesOperation, 5852 MS2SpectralSearchOperation, 5853 LoadEICsOperation, 5854 ) 5855 5856 runtime_params = {} 5857 5858 # Check if any operation needs gap-fill parameters 5859 needs_gap_fill = any(isinstance(op, GapFillOperation) for op in operations) 5860 if needs_gap_fill: 5861 # Prepare gap-fill parameters (same as fill_missing_cluster_features) 5862 min_cluster_presence = ( 5863 self.parameters.lcms_collection.consensus_min_sample_fraction 5864 ) 5865 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5866 5867 summarydf = self.cluster_summary_dataframe 5868 mfdf = self.mass_features_dataframe 5869 sample_ct = len(self.samples) 5870 5871 # Identify clusters needing gap-filling 5872 # Note: cluster_summary_dataframe has 'cluster' as index, need to reset it 5873 missingdf = summarydf.reset_index()[ 5874 [ 5875 "cluster", 5876 "sample_id_nunique", 5877 "mz_min", 5878 "mz_max", 5879 "scan_time_aligned_min", 5880 "scan_time_aligned_max", 5881 ] 5882 ].copy() 5883 missingdf = missingdf[ 5884 missingdf.sample_id_nunique > min_cluster_presence * sample_ct 5885 ] 5886 missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] 5887 5888 if len(missingdf) > 0: 5889 # One groupby. Sample ids stay in collection order, including samples 5890 # that have no mass features. 5891 missingdf["missing_samples"] = self._missing_sample_ids_by_cluster( 5892 mfdf, missingdf.cluster.to_numpy() 5893 ) 5894 5895 # Calculate expanded search windows 5896 mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5897 rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol 5898 missingdf["mz_max_allowed"] = ( 5899 missingdf.mz_max + mz_clu_tol * missingdf.mz_max 5900 ) 5901 missingdf["mz_min_allowed"] = ( 5902 missingdf.mz_min - mz_clu_tol * missingdf.mz_min 5903 ) 5904 # consensus_rt_tol is an absolute tolerance in minutes, same as clustering. 5905 missingdf["sta_max_allowed"] = ( 5906 missingdf.scan_time_aligned_max + rt_clu_tol 5907 ) 5908 missingdf["sta_min_allowed"] = ( 5909 missingdf.scan_time_aligned_min - rt_clu_tol 5910 ) 5911 5912 runtime_params["missingdf"] = missingdf 5913 runtime_params["cluster_dict"] = self.cluster_feature_dictionary 5914 runtime_params["expand_on_miss"] = expand_on_miss 5915 5916 # Check if any operation needs reload parameters 5917 needs_reload = any(isinstance(op, ReloadFeaturesOperation) for op in operations) 5918 if needs_reload: 5919 # Use DRY helper method to build sample_mf_map 5920 sample_mf_map = self.get_sample_mf_map_for_representatives( 5921 include_cluster_id=False 5922 ) 5923 runtime_params["sample_mf_map"] = sample_mf_map 5924 5925 # Check if any operation needs MS2 spectral search parameters 5926 needs_ms2_search = any( 5927 isinstance(op, MS2SpectralSearchOperation) for op in operations 5928 ) 5929 if needs_ms2_search: 5930 # Pass through pre-prepared spectral library 5931 if hasattr(self, "_spectral_lib") and self._spectral_lib is not None: 5932 runtime_params["fe_lib"] = self._spectral_lib 5933 if hasattr(self, "_spectral_search_molecular_metadata"): 5934 runtime_params["molecular_metadata"] = ( 5935 self._spectral_search_molecular_metadata 5936 ) 5937 5938 # Check if any operation needs EIC loading parameters 5939 needs_eic_loading = any(isinstance(op, LoadEICsOperation) for op in operations) 5940 if needs_eic_loading: 5941 # Map sample_id -> m/z list for LoadEICsOperation. 5942 # Must use collection *dataframes*, not only in-memory mass_features: 5943 # after load_representatives=True each sample holds only a sparse set 5944 # of representative objects, so object-only walks miss most cluster 5945 # members and plot_cluster shows incomplete multi-sample EICs (#258). 5946 runtime_params["cluster_mz_dict"] = ( 5947 self._build_cluster_mz_dict_for_eic_loading() 5948 ) 5949 5950 return runtime_params 5951 5952 def _build_cluster_mz_dict_for_eic_loading(self): 5953 """Build sample_id -> list of EIC m/z targets for all clustered features. 5954 5955 Uses ``mass_features_dataframe`` (and induced dataframe if present). 5956 Prefers ``_eic_mz``, falls back to ``mz``. Does not require feature 5957 objects to be loaded in ``sample.mass_features``. 5958 5959 Returns 5960 ------- 5961 dict 5962 Mapping of sample_id (int) to unique float m/z values to load. 5963 """ 5964 cluster_mz_dict = {} 5965 5966 def _add_from_df(df): 5967 if df is None or len(df) == 0: 5968 return 5969 if "cluster" in df.columns: 5970 df = df[df["cluster"].notna()] 5971 if len(df) == 0 or "sample_id" not in df.columns: 5972 return 5973 for sample_id, sample_df in df.groupby("sample_id"): 5974 mzs = [] 5975 if "_eic_mz" in sample_df.columns: 5976 mzs.extend(sample_df["_eic_mz"].dropna().tolist()) 5977 if "mz" in sample_df.columns: 5978 # Include mz for rows with missing _eic_mz 5979 if "_eic_mz" in sample_df.columns: 5980 missing = sample_df["_eic_mz"].isna() 5981 mzs.extend(sample_df.loc[missing, "mz"].dropna().tolist()) 5982 else: 5983 mzs.extend(sample_df["mz"].dropna().tolist()) 5984 sid = int(sample_id) 5985 existing = cluster_mz_dict.get(sid, []) 5986 cluster_mz_dict[sid] = list(set(existing + [float(m) for m in mzs])) 5987 5988 # Regular clustered features only: these are what were exported to per-sample 5989 # HDF5 EICs. Induced (gap-filled) features get EICs at gap-fill time on the 5990 # sample object; they are typically not present in the original HDF5 eics group. 5991 _add_from_df(self.mass_features_dataframe) 5992 5993 return cluster_mz_dict 5994 5995 def _execute_sample_pipeline( 5996 self, sample_id, operations, runtime_params, inplace=True 5997 ): 5998 """ 5999 Execute a pipeline of operations on a single sample. 6000 6001 This is the worker function called (potentially in parallel) for each sample. 6002 6003 Parameters 6004 ---------- 6005 sample_id : int 6006 Sample ID to process 6007 operations : list of SampleOperation 6008 Operations to execute in order 6009 runtime_params : dict 6010 Runtime parameters prepared by _prepare_pipeline_runtime_params 6011 inplace : bool, optional 6012 If True, updates sample in place. If False, returns results for 6013 multiprocessing. Default is True. 6014 6015 Returns 6016 ------- 6017 dict 6018 Dictionary with results from each operation, keyed by operation name. 6019 If inplace=True, returns results that need to be collected. 6020 If inplace=False, returns all results for multiprocessing collection. 6021 """ 6022 results = {} 6023 6024 # Check if any operations need raw MS data 6025 needs_raw_data = {} # {ms_level: True/False} 6026 for op in operations: 6027 needs_raw, ms_level = op.needs_raw_ms_data() 6028 if needs_raw and ms_level: 6029 needs_raw_data[ms_level] = True 6030 6031 # Load raw data once if any operations need it 6032 # Note: For gap-filling, it loads data internally, so we just track it here 6033 for ms_level in needs_raw_data.keys(): 6034 # Gap-filling loads its own data, but we want to keep track that it's loaded 6035 # Other operations can then use the loaded data 6036 pass 6037 6038 for op in operations: 6039 # Check if operation can execute on this sample 6040 sample = self[sample_id] 6041 if not op.can_execute(sample, self): 6042 # Skip this operation for this sample if prerequisites aren't met 6043 # This allows processing to continue for samples that don't have 6044 # all required data (e.g., MS2 spectra) 6045 results[op.name] = None 6046 continue 6047 6048 # Prepare operation-specific runtime params 6049 op_runtime_params = {} 6050 6051 # Add gap-fill params if this is a gap-fill operation 6052 from corems.mass_spectra.calc.lc_calc_operations import ( 6053 GapFillOperation, 6054 ReloadFeaturesOperation, 6055 MS2SpectralSearchOperation, 6056 LoadEICsOperation, 6057 ) 6058 6059 if isinstance(op, GapFillOperation): 6060 if "missingdf" in runtime_params: 6061 op_runtime_params["missingdf"] = runtime_params["missingdf"] 6062 op_runtime_params["cluster_dict"] = runtime_params["cluster_dict"] 6063 op_runtime_params["expand_on_miss"] = runtime_params[ 6064 "expand_on_miss" 6065 ] 6066 6067 elif isinstance(op, ReloadFeaturesOperation): 6068 if "sample_mf_map" in runtime_params: 6069 sample_mf_map = runtime_params["sample_mf_map"] 6070 # Always pass mf_ids_to_load to ensure we only load what's needed 6071 # If sample not in map, it has no representatives - pass empty list 6072 op_runtime_params["mf_ids_to_load"] = sample_mf_map.get( 6073 sample_id, [] 6074 ) 6075 6076 elif isinstance(op, MS2SpectralSearchOperation): 6077 # Add MS2 spectral search parameters 6078 if "fe_lib" in runtime_params: 6079 op_runtime_params["fe_lib"] = runtime_params["fe_lib"] 6080 if "molecular_metadata" in runtime_params: 6081 op_runtime_params["molecular_metadata"] = runtime_params[ 6082 "molecular_metadata" 6083 ] 6084 6085 elif isinstance(op, LoadEICsOperation): 6086 # Add EIC loading parameters 6087 if "cluster_mz_dict" in runtime_params: 6088 op_runtime_params["cluster_mz_dict"] = runtime_params[ 6089 "cluster_mz_dict" 6090 ] 6091 6092 # Execute the operation 6093 result = op.execute(sample_id, self, **op_runtime_params) 6094 results[op.name] = result 6095 6096 # If inplace, collect immediately 6097 if inplace and result is not None: 6098 op.collect_results(sample_id, result, self) 6099 6100 # Clean up raw data if requested 6101 keep_raw_data = runtime_params.get("keep_raw_data", False) 6102 if not keep_raw_data: 6103 for ms_level in needs_raw_data.keys(): 6104 if ms_level in self[sample_id]._ms_unprocessed: 6105 del self[sample_id]._ms_unprocessed[ms_level] 6106 6107 # Include sample_id in results for tracking (especially important for imap_unordered) 6108 results["sample_id"] = sample_id 6109 return results 6110 6111 def process_consensus_features( 6112 self, 6113 load_representatives=True, 6114 perform_gap_filling=True, 6115 add_ms1=False, 6116 add_ms2=False, 6117 ms2_scan_filter=None, 6118 molecular_formula_search=False, 6119 ms2_spectral_search=False, 6120 spectral_lib=None, 6121 molecular_metadata=None, 6122 gather_eics=False, 6123 group_features=False, 6124 keep_raw_data=False, 6125 show_progress=True, 6126 ): 6127 """ 6128 Process consensus mass features across the collection in a single parallelized pass. 6129 6130 This method provides a convenient interface to the sample processing pipeline, 6131 allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association, 6132 molecular formula search, and MS2 spectral search) to be performed efficiently in 6133 a single pass through all samples. 6134 6135 Parameters 6136 ---------- 6137 load_representatives : bool, optional 6138 If True, loads representative mass features from HDF5. Default is True. 6139 perform_gap_filling : bool, optional 6140 If True, performs gap-filling for missing cluster features. Default is True. 6141 This operation loads raw MS1 data which can be reused by subsequent operations. 6142 group_features : bool, optional 6143 If True, run ``group_consensus_features()`` after gap-fill (and before 6144 molecular formula / MS2 search when those are also enabled). 6145 Collection-level natural-abundance **isotope and adduct** family 6146 labeling (singly charged only; not tracer/enriched). Default is False. 6147 add_ms1 : bool, optional 6148 If True and load_representatives=True, associates MS1 spectra with 6149 loaded features. Automatically uses raw data from gap-filling if available, 6150 otherwise uses parser. Spectrum mode is auto-detected. Default is False. 6151 add_ms2 : bool, optional 6152 If True and load_representatives=True, associates MS2 spectra with 6153 loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False. 6154 ms2_scan_filter : str or None, optional 6155 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 6156 molecular_formula_search : bool, optional 6157 If True, performs molecular formula search on mass features using 6158 associated MS1 spectra. Requires add_ms1=True or that MS1 spectra 6159 are already associated. Uses parameters from 6160 parameters.mass_spectrum["ms1"].molecular_search. Default is False. 6161 ms2_spectral_search : bool, optional 6162 If True, performs MS2 spectral library search using FlashEntropy. 6163 Requires add_ms2=True and spectral_lib to be provided. Default is False. 6164 spectral_lib : FlashEntropy library, optional 6165 Pre-prepared FlashEntropy spectral library for MS2 search. 6166 Create using MSPInterface.get_metabolomics_spectra_library(). 6167 Required if ms2_spectral_search=True. Default is None. 6168 molecular_metadata : pd.DataFrame, optional 6169 Molecular metadata corresponding to spectral_lib. 6170 Returned from MSPInterface.get_metabolomics_spectra_library(). 6171 Stored as self.spectral_search_molecular_metadata for later export. 6172 Default is None. 6173 gather_eics : bool, optional 6174 If True, loads extracted ion chromatograms (EICs) from HDF5 for all 6175 mass features with assigned cluster_index (including gap-filled features). 6176 Enables access to EICs via get_eics_for_cluster(cluster_id) method. 6177 Requires that EICs were previously exported with export_eics=True. 6178 Default is False. 6179 keep_raw_data : bool, optional 6180 If True, keeps raw MS data loaded in memory after pipeline completes. 6181 If False, cleans up raw data to free memory. Default is False. 6182 show_progress : bool, optional 6183 If True, displays progress bars during processing. If False, runs silently. 6184 Default is True. 6185 6186 Returns 6187 ------- 6188 dict 6189 Dictionary with pipeline results. Keys include: 6190 - 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling) 6191 - 'reload': dict mapping sample_id to reloaded mass features (if reloading) 6192 - 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search) 6193 - 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search) 6194 6195 Raises 6196 ------ 6197 ValueError 6198 If neither operation is enabled, or if required parameters are missing. 6199 6200 Notes 6201 ----- 6202 - Must run add_consensus_mass_features() before calling this method 6203 - Processes samples in parallel based on parameters.lcms_collection.cores 6204 - Raw MS1 data loaded by gap-filling is automatically reused by MS1 association 6205 - MS2 spectral search requires add_ms2=True and msp_file_path 6206 - FlashEntropy library is created once and reused across all samples 6207 - More efficient than calling individual methods separately 6208 - After gap-filling, sets missing_mass_features_searched = True 6209 - Mass features remain loaded in memory for downstream processing 6210 - For more advanced workflows, use process_samples_pipeline() directly 6211 6212 Examples 6213 -------- 6214 >>> # Prepare spectral library for MS2 search 6215 >>> from corems.molecular_id.search.database_interfaces import MSPInterface 6216 >>> my_msp = MSPInterface(file_path='path/to/library.msp') 6217 >>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library( 6218 ... polarity='negative', 6219 ... format='flashentropy', 6220 ... normalize=True, 6221 ... fe_kwargs={ 6222 ... 'normalize_intensity': True, 6223 ... 'min_ms2_difference_in_da': 0.02, 6224 ... 'max_ms2_tolerance_in_da': 0.01, 6225 ... 'max_indexed_mz': 3000, 6226 ... 'precursor_ions_removal_da': None, 6227 ... 'noise_threshold': 0, 6228 ... } 6229 ... ) 6230 >>> 6231 >>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search 6232 >>> results = lcms_collection.process_consensus_features( 6233 ... load_representatives=True, 6234 ... perform_gap_filling=True, 6235 ... add_ms1=True, 6236 ... add_ms2=True, 6237 ... molecular_formula_search=True, 6238 ... ms2_spectral_search=True, 6239 ... spectral_lib=spectral_lib, 6240 ... molecular_metadata=molecular_metadata 6241 ... ) 6242 6243 See Also 6244 -------- 6245 process_samples_pipeline : Generic pipeline executor for custom workflows 6246 fill_missing_cluster_features : Original gap-filling method 6247 reload_representative_mass_features : Original reload method 6248 """ 6249 from corems.mass_spectra.calc.lc_calc_operations import ( 6250 GapFillOperation, 6251 ReloadFeaturesOperation, 6252 MolecularFormulaSearchOperation, 6253 MS2SpectralSearchOperation, 6254 LoadEICsOperation, 6255 ) 6256 6257 # Validate that at least one meaningful operation is enabled 6258 has_operations = ( 6259 perform_gap_filling 6260 or load_representatives 6261 or molecular_formula_search 6262 or ms2_spectral_search 6263 or gather_eics 6264 or add_ms1 6265 or add_ms2 6266 or group_features 6267 ) 6268 6269 if not has_operations: 6270 raise ValueError( 6271 "At least one operation must be enabled: perform_gap_filling, load_representatives, " 6272 "molecular_formula_search, ms2_spectral_search, gather_eics, add_ms1, add_ms2, " 6273 "or group_features" 6274 ) 6275 6276 # Validate prerequisites for gap-filling 6277 if perform_gap_filling: 6278 if ( 6279 not hasattr(self, "cluster_summary_dataframe") 6280 or self.cluster_summary_dataframe is None 6281 ): 6282 raise ValueError( 6283 "Cannot perform gap-filling: cluster_summary_dataframe not set. " 6284 "You must run add_consensus_mass_features() before calling process_consensus_features()." 6285 ) 6286 6287 # Validate prerequisites for MS2 spectral search 6288 if ms2_spectral_search: 6289 if spectral_lib is None: 6290 raise ValueError( 6291 "MS2 spectral search requires spectral_lib to be provided. " 6292 "Create it using MSPInterface.get_metabolomics_spectra_library() before calling this method." 6293 ) 6294 # Check if mass features will be loaded OR are already loaded 6295 # (The operation's can_execute will check if MS2 spectra are actually present) 6296 if not load_representatives and not perform_gap_filling: 6297 # Check if at least one sample has mass features loaded 6298 # This allows MS2 search on already-loaded features 6299 has_loaded_features = any( 6300 len(self[i].mass_features) > 0 6301 if hasattr(self[i], "mass_features") 6302 and self[i].mass_features is not None 6303 else False 6304 for i in range(len(self.samples)) 6305 ) 6306 if not has_loaded_features: 6307 raise ValueError( 6308 "MS2 spectral search requires mass features to be loaded. " 6309 "Either set load_representatives=True or perform_gap_filling=True to load them, " 6310 "or load them in a previous call to process_consensus_features() before calling " 6311 "with ms2_spectral_search=True." 6312 ) 6313 6314 # Build pipeline in two phases so group_features runs after gap-fill 6315 # and before formula / MS2 search when those are requested in the same call. 6316 pre_operations = [] 6317 post_operations = [] 6318 6319 if perform_gap_filling: 6320 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 6321 pre_operations.append( 6322 GapFillOperation("gap_fill", expand_on_miss=expand_on_miss) 6323 ) 6324 6325 if load_representatives: 6326 # MS1/MS2 association for representatives can happen in pre-pass; 6327 # formula/MS2 library search stay in post-pass after grouping. 6328 pre_operations.append( 6329 ReloadFeaturesOperation( 6330 "reload", 6331 add_ms1=add_ms1, 6332 add_ms2=add_ms2, 6333 auto_process_ms2=add_ms2, 6334 ms2_scan_filter=ms2_scan_filter, 6335 ) 6336 ) 6337 6338 if molecular_formula_search: 6339 post_operations.append(MolecularFormulaSearchOperation("mf_search")) 6340 6341 if ms2_spectral_search: 6342 post_operations.append( 6343 MS2SpectralSearchOperation( 6344 "ms2_search", ms2_scan_filter=ms2_scan_filter 6345 ) 6346 ) 6347 self._spectral_lib = spectral_lib 6348 self._spectral_search_molecular_metadata = molecular_metadata 6349 6350 if gather_eics: 6351 # After grouping when grouping is requested; otherwise with pre-pass 6352 # (or post-pass if only annotation ops are enabled). 6353 if group_features or not pre_operations: 6354 post_operations.append(LoadEICsOperation("load_eics")) 6355 else: 6356 pre_operations.append(LoadEICsOperation("load_eics")) 6357 6358 results = {} 6359 6360 def _run_ops(operations): 6361 if not operations: 6362 return {} 6363 return self.process_samples_pipeline( 6364 operations, keep_raw_data=keep_raw_data, show_progress=show_progress 6365 ) 6366 6367 # Phase 1: gap-fill / reload (and eics if not grouping) 6368 if pre_operations: 6369 results.update(_run_ops(pre_operations)) 6370 6371 # Post-processing for gap-fill must happen before grouping so heights include induced 6372 if perform_gap_filling: 6373 self._combine_mass_features(induced_features=True) 6374 self.missing_mass_features_searched = True 6375 6376 if ( 6377 self.induced_mass_features_dataframe is not None 6378 and len(self.induced_mass_features_dataframe) > 0 6379 ): 6380 eics_mz = [] 6381 for i, row in self.induced_mass_features_dataframe.iterrows(): 6382 sample_id = row["sample_id"] 6383 sample = self[sample_id] 6384 if row["mf_id"] in sample.induced_mass_features.keys(): 6385 eic_mz = sample.induced_mass_features[row["mf_id"]]._eic_mz 6386 eics_mz.append(eic_mz) 6387 else: 6388 eics_mz.append(None) 6389 self.induced_mass_features_dataframe["_eic_mz"] = eics_mz 6390 6391 # Collection-level isotope + adduct grouping 6392 if group_features: 6393 t_group = time.perf_counter() 6394 labels = self.group_consensus_features() 6395 group_wall = time.perf_counter() - t_group 6396 results["group_features"] = { 6397 "n_clusters": len(labels), 6398 "n_grouped": int(labels["feature_group_id"].notna().sum()), 6399 "n_groups": int(labels["feature_group_id"].nunique(dropna=True)), 6400 "wall_seconds": group_wall, 6401 "stage_timings": getattr(self, "_last_feature_group_timings", None), 6402 } 6403 6404 # Phase 2: annotation / optional EICs after grouping 6405 if post_operations: 6406 # If only post_operations and no pre, still need a pipeline run 6407 post_results = _run_ops(post_operations) 6408 for k, v in post_results.items(): 6409 results[k] = v 6410 6411 # Store molecular metadata if spectral search was performed 6412 if ms2_spectral_search and hasattr(self, "_spectral_search_molecular_metadata"): 6413 self.spectral_search_molecular_metadata = ( 6414 self._spectral_search_molecular_metadata 6415 ) 6416 6417 # Associate EICs while induced feature objects still exist (before any clear). 6418 if gather_eics: 6419 print("\nAssociating EICs with mass features:") 6420 from tqdm import tqdm 6421 6422 for sample_id in tqdm(range(len(self.samples)), unit="sample", ncols=80): 6423 sample = self[sample_id] 6424 if sample.eics: 6425 sample.associate_eics_with_mass_features(induced=False) 6426 sample.associate_eics_with_mass_features(induced=True) 6427 6428 # Drop induced feature objects to free memory after grouping + post-ops 6429 if perform_gap_filling: 6430 for sample_name in self.samples: 6431 self._lcms[sample_name].induced_mass_features = {} 6432 6433 return results
31def find_closest(A, target): 32 """Find the index of closest value in A to each value in target. 33 34 Parameters 35 ---------- 36 A : :obj:`~numpy.array` 37 The array to search (blueprint). A must be sorted. 38 target : :obj:`~numpy.array` 39 The array of values to search for. target must be sorted. 40 41 Returns 42 ------- 43 :obj:`~numpy.array` 44 The indices of the closest values in A to each value in target. 45 """ 46 idx = A.searchsorted(target) 47 idx = np.clip(idx, 1, len(A) - 1) 48 left = A[idx - 1] 49 right = A[idx] 50 idx -= target - left < right - target 51 return idx
Find the index of closest value in A to each value in target.
Parameters
- A (
~numpy.array): The array to search (blueprint). A must be sorted. - target (
~numpy.array): The array of values to search for. target must be sorted.
Returns
~numpy.array: The indices of the closest values in A to each value in target.
95class LCCalculations: 96 """Methods for performing LC calculations on mass spectra data. 97 98 Notes 99 ----- 100 This class is intended to be used as a mixin for the LCMSBase class. 101 102 Methods 103 ------- 104 * get_max_eic(eic_data). 105 Returns the maximum EIC value from the given EIC data. A static method. 106 * smooth_tic(tic). 107 Smooths the TIC data using the specified smoothing method and settings. 108 * eic_centroid_detector(rt, eic, max_eic). 109 Performs EIC centroid detection on the given EIC data. 110 * find_nearest_scan(rt). 111 Finds the nearest scan to the given retention time. 112 * get_average_mass_spectrum(scan_list, apex_scan, spectrum_mode="profile", ms_level=1, auto_process=True, use_parser=False, perform_checks=True, polarity=None). 113 Returns an averaged mass spectrum object. 114 * find_mass_features(ms_level=1). 115 Find regions of interest for a given MS level (default is MS1). 116 * integrate_mass_features(drop_if_fail=False, ms_level=1). 117 Integrate mass features of interest and extracts EICs. 118 * find_c13_mass_features(). 119 Evaluate mass features and mark likely C13 isotopes. 120 * deconvolute_ms1_mass_features(). 121 Deconvolute mass features' ms1 mass spectra. 122 """ 123 124 @staticmethod 125 def get_max_eic(eic_data: dict): 126 """Returns the maximum EIC value from the given EIC data. 127 128 Notes 129 ----- 130 This is a static method. 131 132 Parameters 133 ---------- 134 eic_data : dict 135 A dictionary containing EIC data. 136 137 Returns 138 ------- 139 float 140 The maximum EIC value. 141 """ 142 max_eic = 0 143 for eic_data in eic_data.values(): 144 ind_max_eic = max(eic_data.get("EIC")) 145 max_eic = ind_max_eic if ind_max_eic > max_eic else max_eic 146 147 return max_eic 148 149 def smooth_tic(self, tic): 150 """Smooths the TIC or EIC data using the specified smoothing method and settings. 151 152 Parameters 153 ---------- 154 tic : numpy.ndarray 155 The TIC (or EIC) data to be smoothed. 156 157 Returns 158 ------- 159 numpy.ndarray 160 The smoothed TIC data. 161 """ 162 implemented_smooth_method = self.parameters.lc_ms.implemented_smooth_method 163 164 pol_order = self.parameters.lc_ms.savgol_pol_order 165 166 window_len = self.parameters.lc_ms.smooth_window 167 168 window = self.parameters.lc_ms.smooth_method 169 170 return sp.smooth_signal( 171 tic, window_len, window, pol_order, implemented_smooth_method 172 ) 173 174 def eic_centroid_detector(self, rt, eic, max_eic, apex_indexes=[]): 175 """Performs EIC centroid detection on the given EIC data. 176 177 Parameters 178 ---------- 179 rt : numpy.ndarray 180 The retention time data. 181 eic : numpy.ndarray 182 The EIC data. 183 max_eic : float 184 The maximum EIC value. 185 apex_indexes : list, optional 186 The apexes of the EIC peaks. Defaults to [], which means that the apexes will be calculated by the function. 187 188 Returns 189 ------- 190 numpy.ndarray 191 The indexes of left, apex, and right limits as a generator. 192 """ 193 194 max_prominence = self.parameters.lc_ms.peak_max_prominence_percent 195 196 max_height = self.parameters.lc_ms.peak_height_max_percent 197 198 signal_threshold = self.parameters.lc_ms.eic_signal_threshold 199 200 min_peak_datapoints = self.parameters.lc_ms.min_peak_datapoints 201 202 peak_derivative_threshold = self.parameters.lc_ms.peak_derivative_threshold 203 204 include_indexes = sp.peak_picking_first_derivative( 205 domain=rt, 206 signal=eic, 207 max_height=max_height, 208 max_prominence=max_prominence, 209 max_signal=max_eic, 210 min_peak_datapoints=min_peak_datapoints, 211 peak_derivative_threshold=peak_derivative_threshold, 212 signal_threshold=signal_threshold, 213 correct_baseline=False, 214 plot_res=False, 215 apex_indexes=apex_indexes, 216 ) 217 # include_indexes is a generator of tuples (left_index, apex_index, right_index) 218 include_indexes = list(include_indexes) 219 # Add check to make sure that there are at least 1/2 of min_peak_datapoints on either side of the apex 220 indicies = [x for x in include_indexes] 221 for idx in indicies: 222 if (idx[1] - idx[0] < min_peak_datapoints / 2) or ( 223 idx[2] - idx[1] < min_peak_datapoints / 2 224 ): 225 include_indexes.remove(idx) 226 return include_indexes 227 228 def find_nearest_scan(self, rt): 229 """Finds the nearest scan to the given retention time. 230 231 Parameters 232 ---------- 233 rt : float 234 The retention time (in minutes) to find the nearest scan for. 235 236 Returns 237 ------- 238 int 239 The scan number of the nearest scan. 240 """ 241 array_rt = np.array(self.retention_time) 242 243 scan_index = (np.abs(array_rt - rt)).argmin() 244 245 real_scan = self.scans_number[scan_index] 246 247 return real_scan 248 249 def add_peak_metrics(self, remove_by_metrics=True, induced_features=False): 250 """Add peak metrics to the mass features. 251 252 This function calculates the peak metrics for each mass feature and adds them to the mass feature objects. 253 254 Parameters 255 ---------- 256 remove_by_metrics : bool, optional 257 If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity, 258 dispersity index, and noise score. Default is True, which checks the setting in the processing parameters. 259 If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters. 260 induced_features : bool, optional 261 Whether the mass features to be integrated were induced. Default is False. 262 """ 263 # Check that at least some mass features have eic data 264 if induced_features: 265 mf_dict_values = self.induced_mass_features.values() 266 else: 267 mf_dict_values = self.mass_features.values() 268 269 if not any([mf._eic_data is not None for mf in mf_dict_values]): 270 raise ValueError( 271 "No mass features have EIC data. Run integrate_mass_features first." 272 ) 273 274 for mass_feature in mf_dict_values: 275 # Check if the mass feature has been integrated 276 if mass_feature._eic_data is not None and mass_feature.area is not None: 277 # Calculate peak metrics 278 mass_feature.calc_half_height_width() 279 mass_feature.calc_tailing_factor() 280 mass_feature.calc_dispersity_index() 281 mass_feature.calc_gaussian_similarity() 282 mass_feature.calc_noise_score() 283 284 # Remove mass features by peak metrics if designated in parameters 285 if ( 286 self.parameters.lc_ms.remove_mass_features_by_peak_metrics 287 and remove_by_metrics 288 ): 289 self._remove_mass_features_by_peak_metrics( 290 induced_features=induced_features 291 ) 292 293 def get_average_mass_spectrum( 294 self, 295 scan_list, 296 apex_scan, 297 spectrum_mode="profile", 298 ms_level=1, 299 auto_process=True, 300 use_parser=False, 301 perform_checks=True, 302 polarity=None, 303 ms_params=None, 304 ): 305 """Returns an averaged mass spectrum object 306 307 Parameters 308 ---------- 309 scan_list : list 310 List of scan numbers to average. 311 apex_scan : int 312 Number of the apex scan 313 spectrum_mode : str, optional 314 The spectrum mode to use. Defaults to "profile". Not that only "profile" mode is supported for averaging. 315 ms_level : int, optional 316 The MS level to use. Defaults to 1. 317 auto_process : bool, optional 318 If True, the averaged mass spectrum will be auto-processed. Defaults to True. 319 use_parser : bool, optional 320 If True, the mass spectra will be obtained from the parser. Defaults to False. 321 perform_checks : bool, optional 322 If True, the function will check if the data are within the ms_unprocessed dictionary and are the correct mode. Defaults to True. Only set to False if you are sure the data are profile, and (if not using the parser) are in the ms_unprocessed dictionary! ms_unprocessed dictionary also must be indexed on scan 323 polarity : int, optional 324 The polarity of the mass spectra (1 or -1). If not set, the polarity will be determined from the dataset. Defaults to None. (fastest if set to -1 or 1) 325 ms_params : MSParameters, optional 326 The mass spectrum parameters to use. If not set (None), the globally set parameters will be used. Defaults to None. 327 328 Returns 329 ------- 330 MassSpectrumProfile 331 The averaged mass spectrum object. 332 333 Raises 334 ------ 335 ValueError 336 If the spectrum mode is not "profile". 337 If the MS level is not found in the unprocessed mass spectra dictionary. 338 If not all scan numbers are found in the unprocessed mass spectra dictionary. 339 """ 340 if perform_checks: 341 if spectrum_mode != "profile": 342 raise ValueError("Averaging only supported for profile mode") 343 344 if polarity is None: 345 # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation) 346 if self.polarity == "negative": 347 polarity = -1 348 elif self.polarity == "positive": 349 polarity = 1 350 else: 351 raise ValueError( 352 "Polarity not set for dataset, must be a set containing either 'positive' or 'negative'" 353 ) 354 355 # if not using_parser, check that scan numbers are in _ms_unprocessed 356 if not use_parser: 357 if perform_checks: 358 # Set index to scan for faster lookup 359 ms_df = ( 360 self._ms_unprocessed[ms_level] 361 .copy() 362 .set_index("scan", drop=False) 363 .sort_index() 364 ) 365 my_ms_df = ms_df.loc[scan_list] 366 # Check that all scan numbers are in the ms_df 367 if not all(np.isin(scan_list, ms_df.index)): 368 raise ValueError( 369 "Not all scan numbers found in the unprocessed mass spectra dictionary" 370 ) 371 else: 372 my_ms_df = ( 373 pd.DataFrame({"scan": scan_list}) 374 .set_index("scan") 375 .join(self._ms_unprocessed[ms_level], how="left") 376 ) 377 378 if use_parser: 379 parser = self.spectra_parser 380 ms_list = [ 381 parser.get_mass_spectrum_from_scan( 382 x, spectrum_mode=spectrum_mode, auto_process=False 383 ) 384 for x in scan_list 385 ] 386 ms_mz = [x._mz_exp for x in ms_list] 387 ms_int = [x._abundance for x in ms_list] 388 my_ms_df = [] 389 for i in np.arange(len(ms_mz)): 390 my_ms_df.append( 391 pd.DataFrame( 392 {"mz": ms_mz[i], "intensity": ms_int[i], "scan": scan_list[i]} 393 ) 394 ) 395 my_ms_df = pd.concat(my_ms_df) 396 397 if not self.check_if_grid(my_ms_df): 398 my_ms_df = self.grid_data(my_ms_df) 399 400 my_ms_ave = my_ms_df.groupby("mz")["intensity"].sum().reset_index() 401 402 ms = ms_from_array_profile( 403 my_ms_ave.mz, 404 my_ms_ave.intensity, 405 self.file_location, 406 polarity=polarity, 407 auto_process=False, 408 ) 409 410 # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset 411 if ms is not None: 412 if ms_params is not None: 413 ms.parameters = ms_params 414 ms.scan_number = apex_scan 415 if auto_process: 416 ms.process_mass_spec() 417 return ms 418 419 def _resolve_lcms_peak_picking_method(self, ms_level=1): 420 """Resolve and validate the 2D peak picking method against scan formats. 421 422 Parameters 423 ---------- 424 ms_level : int, optional 425 MS level whose ``scan_df`` formats are checked. Default is 1. 426 427 Returns 428 ------- 429 str 430 Concrete method: ``persistent homology`` or 431 ``centroided_persistent_homology``. If the stored method is 432 ``by_format``, it is chosen from scan formats (all profile or all 433 centroid). 434 435 Raises 436 ------ 437 ValueError 438 If the method is not implemented, ``by_format`` cannot be resolved 439 (empty, mixed, or unknown formats), or an explicit method does 440 not match the scan formats at ``ms_level``. 441 """ 442 method = self.parameters.lc_ms.peak_picking_method 443 msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level] 444 values = list(msx_scan_df["ms_format"]) 445 446 if method == "by_format": 447 if len(values) == 0: 448 raise ValueError( 449 "Cannot resolve by_format peak picking: no scans found " 450 "for the requested MS level." 451 ) 452 if all(v == "profile" for v in values): 453 return "persistent homology" 454 if all(v == "centroid" for v in values): 455 return "centroided_persistent_homology" 456 found = sorted({str(v) for v in values}) 457 raise ValueError( 458 "Cannot resolve by_format peak picking: MS scans are mixed " 459 f"or unknown format (found {found}). Set peak_picking_method " 460 "explicitly to 'persistent homology' or " 461 "'centroided_persistent_homology'." 462 ) 463 464 if method == "persistent homology": 465 if len(values) == 0 or not all(v == "profile" for v in values): 466 raise ValueError( 467 "MS{} scans are not profile mode, which is required for persistent homology peak picking.".format( 468 ms_level 469 ) 470 ) 471 return method 472 473 if method == "centroided_persistent_homology": 474 if len(values) == 0 or not all(v == "centroid" for v in values): 475 raise ValueError( 476 "MS{} scans are not centroid mode, which is required for persistent homology centroided peak picking.".format( 477 ms_level 478 ) 479 ) 480 return method 481 482 raise ValueError("Peak picking method not implemented") 483 484 def find_mass_features( 485 self, 486 ms_level=1, 487 grid=True, 488 assign_ms2_scans=False, 489 ms2_scan_filter=None, 490 targeted_search=False, 491 target_search_dict=None, 492 accumulate_features=False, 493 ): 494 """Find mass features within an LCMSBase object 495 496 Wrapper that dispatches to a 2D peak picking implementation. 497 498 If ``parameters.lc_ms.peak_picking_method`` is ``by_format``, the method is 499 chosen from ``scan_df.ms_format`` at the requested MS level (all 500 profile → persistent homology; all centroid → centroided persistent 501 homology). A successful resolve overwrites 502 ``peak_picking_method`` with the concrete method so saved parameters 503 record what was used. 504 505 Parameters 506 ---------- 507 ms_level : int, optional 508 The MS level to use for peak picking Default is 1. 509 grid : bool, optional 510 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded), 511 used for persistent homology peak picking for profile data only. Default is True. 512 assign_ms2_scans : bool, optional 513 If True, assign MS2 scan numbers to mass features after peak picking. 514 This populates the ms2_scan_numbers attribute on each mass feature, which enables 515 choosing representative features based on MS2 availability. Default is False. 516 ms2_scan_filter : str or None, optional 517 Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd'). 518 If None, all MS2 scans are considered. Default is None. 519 targeted_search : bool, optional 520 If True, perform targeted mass feature search using the target_search_dict. 521 This mode filters data to only m/z and RT windows of interest and bypasses 522 intensity and persistence thresholds. Default is False. 523 target_search_dict : dict or None, optional 524 Dictionary containing target search parameters. Required if targeted_search is True. 525 Must contain: 526 - 'target_mz_list': list of target m/z values 527 - 'target_rt_list': list of target retention times (in minutes) 528 - 'mz_tolerance_ppm': m/z tolerance in ppm 529 - 'rt_tolerance': retention time tolerance (in minutes) 530 Optionally can contain: 531 - 'type': type label for mass features (e.g., "internal standard") 532 If not provided, defaults to "targeted" 533 Default is None. 534 accumulate_features : bool, optional 535 If True, new mass features will be added to existing features rather than replacing them. 536 This allows multiple sequential calls to find_mass_features to build up a combined set. 537 Default is False (replace existing features for backwards compatibility). 538 539 Raises 540 ------ 541 ValueError 542 If no MS level data is found on the object. 543 If ``by_format`` cannot resolve a method (empty, mixed, or unknown 544 ``ms_format`` values at the requested MS level). 545 If persistent homology peak picking is attempted on non-profile mode data. 546 If centroided persistent homology is attempted on non-centroid mode data. 547 If data is not gridded and grid is False. 548 If peak picking method is not implemented. 549 If targeted_search is True but target_search_dict is None or invalid. 550 551 Returns 552 ------- 553 None, but assigns the mass_features and eics attributes to the object. 554 555 """ 556 # Validate targeted search parameters 557 if targeted_search: 558 if target_search_dict is None: 559 raise ValueError( 560 "target_search_dict must be provided when targeted_search is True" 561 ) 562 required_keys = [ 563 "target_mz_list", 564 "target_rt_list", 565 "mz_tolerance_ppm", 566 "rt_tolerance", 567 ] 568 for key in required_keys: 569 if key not in target_search_dict: 570 raise ValueError(f"target_search_dict must contain '{key}'") 571 if len(target_search_dict["target_mz_list"]) != len( 572 target_search_dict["target_rt_list"] 573 ): 574 raise ValueError( 575 "target_mz_list and target_rt_list must have the same length" 576 ) 577 578 requested_method = self.parameters.lc_ms.peak_picking_method 579 pp_method = self._resolve_lcms_peak_picking_method(ms_level=ms_level) 580 if requested_method == "by_format": 581 self.parameters.lc_ms.peak_picking_method = pp_method 582 583 if targeted_search: 584 mf_type = target_search_dict.get("type", "targeted") 585 else: 586 mf_type = "untargeted" 587 588 if pp_method == "persistent homology": 589 self.find_mass_features_ph( 590 ms_level=ms_level, 591 grid=grid, 592 targeted_search=targeted_search, 593 target_search_dict=target_search_dict, 594 mf_type=mf_type, 595 accumulate_features=accumulate_features, 596 ) 597 elif pp_method == "centroided_persistent_homology": 598 self.find_mass_features_ph_centroid( 599 ms_level=ms_level, 600 targeted_search=targeted_search, 601 target_search_dict=target_search_dict, 602 mf_type=mf_type, 603 accumulate_features=accumulate_features, 604 ) 605 else: 606 raise ValueError("Peak picking method not implemented") 607 608 # Cluster mass features to remove redundant features 609 self.cluster_mass_features(drop_children=True) 610 611 # Optionally assign MS2 scan numbers to mass features during peak picking 612 # This helps with choosing representative features that have MS2 data 613 if assign_ms2_scans: 614 try: 615 self._find_ms2_scans_for_mass_features( 616 mf_ids=None, # Process all mass features 617 scan_filter=ms2_scan_filter, 618 ) 619 except ValueError: 620 # No MS2 scans found - this is okay, just skip 621 pass 622 623 # Remove noisey mass features if designated in parameters 624 if self.parameters.lc_ms.remove_redundant_mass_features and not targeted_search: 625 self._remove_redundant_mass_features() 626 627 def integrate_mass_features( 628 self, 629 drop_if_fail=True, 630 drop_duplicates=True, 631 ms_level=1, 632 induced_features=False, 633 ): 634 """Integrate mass features and extract EICs. 635 636 Populates the _eics attribute on the LCMSBase object for each unique mz in the mass_features dataframe and adds data (start_scan, final_scan, area) to the mass_features attribute. 637 638 Parameters 639 ---------- 640 drop_if_fail : bool, optional 641 Whether to drop mass features if the EIC limit calculations fail. 642 Default is True. 643 drop_duplicates : bool, optional 644 Whether to mass features that appear to be duplicates 645 (i.e., mz is similar to another mass feature and limits of the EIC are similar or encapsulating). 646 Default is True. 647 ms_level : int, optional 648 The MS level to use. Default is 1. 649 induced_features : bool, optional 650 Whether the mass features to be intergrated were induced. Default is False. 651 652 Raises 653 ------ 654 ValueError 655 If no mass features are found. 656 If no MS level data is found for the given MS level (either in data or in the scan data) 657 658 Returns 659 ------- 660 None, but populates the eics attribute on the LCMSBase object and adds data (start_scan, final_scan, area) to the mass_features attribute. 661 662 Notes 663 ----- 664 drop_if_fail is useful for discarding mass features that do not have good shapes, usually due to a detection on a shoulder of a peak or a noisy region (especially if minimal smoothing is used during mass feature detection). 665 """ 666 667 # Check if there is data 668 if ms_level in self._ms_unprocessed.keys(): 669 raw_data = self._ms_unprocessed[ms_level].copy() 670 else: 671 raise ValueError("No MS level " + str(ms_level) + " data found") 672 673 # Check if mass_spectrum exists on each mass feature 674 if induced_features: 675 mf_dict = self.induced_mass_features 676 if len(mf_dict) == 0: 677 raise ValueError( 678 "No induced mass features found, did you run fill_missing_cluster_features() first?" 679 ) 680 681 ## remove not found induced mass features by mz <= 0 (-99 indicator) 682 # also remove any where mz is nan 683 mf_dict = { 684 k: v for k, v in mf_dict.items() if v.mz > 0 and not np.isnan(v.mz) 685 } 686 687 else: 688 mf_dict = self.mass_features 689 if len(mf_dict) == 0: 690 raise ValueError( 691 "No mass features found, did you run find_mass_features() first?" 692 ) 693 694 # Subset scan data to only include correct ms_level 695 scan_df_sub = self.scan_df[ 696 self.scan_df["ms_level"] == int(ms_level) 697 ].reset_index(drop=True) 698 if scan_df_sub.empty: 699 raise ValueError("No MS level " + ms_level + " data found in scan data") 700 scan_df_sub = scan_df_sub[["scan", "scan_time"]].copy() 701 702 mzs_to_extract = np.unique([mf.mz for mf in mf_dict.values()]) 703 mzs_to_extract.sort() 704 705 # Pre-sort raw_data by mz for faster filtering 706 raw_data_sorted = raw_data.sort_values(["mz", "scan"]).reset_index(drop=True) 707 raw_data_mz = raw_data_sorted["mz"].to_numpy() 708 raw_scan = raw_data_sorted["scan"].to_numpy() 709 raw_intensity = raw_data_sorted["intensity"].to_numpy() 710 scan_ids = scan_df_sub["scan"].to_numpy() 711 scan_time = scan_df_sub["scan_time"].to_numpy() 712 n_scans = scan_ids.size 713 # Sum into the scan-table row with the same float32 Kahan sum the 714 # previous groupby used, so the chromatogram values stay identical. 715 scan_order = np.argsort(scan_ids, kind="mergesort") 716 sorted_scan_ids = scan_ids[scan_order] 717 found = np.searchsorted(sorted_scan_ids, raw_scan) 718 in_range = found < n_scans 719 scan_col = np.empty(raw_scan.shape, dtype=np.intp) 720 scan_col[in_range] = scan_order[found[in_range]] 721 matched = np.zeros(raw_scan.shape, dtype=bool) 722 matched[in_range] = sorted_scan_ids[found[in_range]] == raw_scan[in_range] 723 724 # Get EICs for each unique mz in mass features list 725 for mz in mzs_to_extract: 726 mz_max = mz + self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 727 mz_min = mz - self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 728 729 # Use binary search for faster mz range filtering 730 left_idx = int(np.searchsorted(raw_data_mz, mz_min, side="left")) 731 right_idx = int(np.searchsorted(raw_data_mz, mz_max, side="right")) 732 eic = np.zeros(n_scans, dtype=np.float32) 733 if left_idx < right_idx: 734 comp = np.zeros(n_scans, dtype=np.float32) 735 cols = scan_col[left_idx:right_idx] 736 vals = raw_intensity[left_idx:right_idx] 737 ok = matched[left_idx:right_idx] 738 for i in range(cols.size): 739 if not ok[i]: 740 continue 741 col = cols[i] 742 val = vals[i] 743 y = val - comp[col] 744 t = eic[col] + y 745 c = (t - eic[col]) - y 746 comp[col] = c if np.isfinite(c) else np.float32(0) 747 eic[col] = t 748 myEIC = EIC_Data( 749 scans=scan_ids.copy(), 750 time=scan_time.copy(), 751 eic=eic, 752 ) 753 # Smooth EIC 754 smoothed_eic = self.smooth_tic(myEIC.eic) 755 smoothed_eic[smoothed_eic < 0] = 0 756 myEIC.eic_smoothed = smoothed_eic 757 self.eics[mz] = myEIC 758 759 # Get limits of mass features using EIC centroid detector and integrate 760 for idx, mass_feature in list(mf_dict.items()): 761 mz = mass_feature.mz 762 apex_scan = mass_feature.apex_scan 763 764 # Pull EIC data and find apex scan index 765 myEIC = self.eics[mz] 766 mf_dict[idx]._eic_data = myEIC 767 mf_dict[idx]._eic_mz = mz 768 apex_index = np.searchsorted(myEIC.scans, apex_scan) 769 770 # Find left and right limits of peak using EIC centroid detector, add to EICData 771 centroid_eics = self.eic_centroid_detector( 772 myEIC.time, 773 myEIC.eic_smoothed, 774 mass_feature.intensity * 1.1, 775 apex_indexes=[int(apex_index)], 776 ) 777 l_a_r_scan_idx = [i for i in centroid_eics] 778 if len(l_a_r_scan_idx) > 0: 779 # Calculate number of consecutive scans with intensity > 0 and check if it is above the minimum consecutive scans 780 # Find the number of consecutive non-zero values in the EIC segment 781 mask = myEIC.eic[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1] > 0 782 # Find the longest run of consecutive True values 783 if np.any(mask): 784 # Find indices where mask changes value 785 diff = np.diff(np.concatenate(([0], mask.astype(int), [0]))) 786 starts = np.where(diff == 1)[0] 787 ends = np.where(diff == -1)[0] 788 consecutive_scans = (ends - starts).max() 789 else: 790 consecutive_scans = 0 791 if consecutive_scans < self.parameters.lc_ms.consecutive_scan_min: 792 mf_dict.pop(idx) 793 continue 794 # Add start and final scan to mass_features and EICData 795 left_scan, right_scan = ( 796 myEIC.scans[l_a_r_scan_idx[0][0]], 797 myEIC.scans[l_a_r_scan_idx[0][2]], 798 ) 799 mf_scan_apex = [(left_scan, int(apex_scan), right_scan)] 800 myEIC.apexes = myEIC.apexes + mf_scan_apex 801 mf_dict[idx].start_scan = left_scan 802 mf_dict[idx].final_scan = right_scan 803 804 # Find area under peak using limits from EIC centroid detector, add to mass_features and EICData 805 area = np.trapezoid( 806 myEIC.eic_smoothed[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 807 myEIC.time[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 808 ) 809 myEIC.areas = myEIC.areas + [area] 810 self.eics[mz] = myEIC 811 mf_dict[idx]._area = area 812 else: 813 if drop_if_fail is True: 814 mf_dict.pop(idx) 815 816 if drop_duplicates: 817 # Prepare mass feature dataframe 818 # Skip features without scan bounds (failed EIC limits when 819 # drop_if_fail=False); they cannot participate in containment de-dup 820 if induced_features: 821 mf_df = self.mass_features_to_df(induced_features=True).copy() 822 mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()] 823 else: 824 mf_df = self.mass_features_to_df(induced_features=False).copy() 825 mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()] 826 827 # For each mass feature, find all mass features within the clustering tolerance ppm and drop if their start and end times are within another mass feature 828 # Keep the first mass feature (highest persistence) 829 for idx, mass_feature in mf_df.iterrows(): 830 mz = mass_feature.mz 831 apex_scan = mass_feature.apex_scan 832 833 mf_df["mz_diff_ppm"] = np.abs(mf_df["mz"] - mz) / mz * 10**6 834 mf_df_sub = mf_df[ 835 mf_df["mz_diff_ppm"] 836 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 837 * 10**6 838 ].copy() 839 840 # For all mass features within the clustering tolerance, check if the start and end times are within the start and end times of the mass feature 841 for idx2, mass_feature2 in mf_df_sub.iterrows(): 842 if idx2 != idx: 843 s1, e1 = mass_feature.start_scan, mass_feature.final_scan 844 s2, e2 = mass_feature2.start_scan, mass_feature2.final_scan 845 # Guard incomplete bounds (None / NaN) so ordering 846 # comparisons never raise TypeError 847 if s1 is None or e1 is None or s2 is None or e2 is None: 848 continue 849 if pd.isna(s1) or pd.isna(e1) or pd.isna(s2) or pd.isna(e2): 850 continue 851 if s2 >= s1 and e2 <= e1: 852 if idx2 in self.mass_features.keys(): 853 self.mass_features.pop(idx2) 854 855 # Filter MS2 scans to only include those within integration bounds 856 # This ensures MS2 scans outside start_scan to final_scan are removed 857 if induced_features: 858 self._filter_ms2_scans_by_integration_bounds( 859 mf_dict=self.induced_mass_features 860 ) 861 else: 862 self._filter_ms2_scans_by_integration_bounds(mf_dict=self.mass_features) 863 864 def find_c13_mass_features(self): 865 """Mark likely C13 isotopes and connect to monoisoitopic mass features. 866 867 Returns 868 ------- 869 None, but populates the monoisotopic_mf_id and isotopologue_type attributes to the indivual LCMSMassFeatures within the mass_features attribute of the LCMSBase object. 870 871 Raises 872 ------ 873 ValueError 874 If no mass features are found. 875 """ 876 verbose = self.parameters.lc_ms.verbose_processing 877 if verbose: 878 print("evaluating mass features for C13 isotopes") 879 if self.mass_features is None: 880 raise ValueError("No mass features found, run find_mass_features() first") 881 882 # Data prep fo sparse distance matrix 883 dims = ["mz", "scan_time"] 884 mf_df = self.mass_features_to_df().copy() 885 # Drop mass features that have no area (these are likely to be noise) 886 mf_df = mf_df[mf_df["area"].notnull()] 887 mf_df["mf_id"] = mf_df.index.values 888 dims = ["mz", "scan_time"] 889 890 # Sort my ascending mz so we always get the monoisotopic mass first, regardless of the order/intensity of the mass features 891 mf_df = mf_df.sort_values(by=["mz"]).reset_index(drop=True).copy() 892 893 mz_diff = 1.003355 # C13-C12 mass difference 894 tol = [ 895 mf_df["mz"].median() 896 * self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 897 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance * 0.5, 898 ] # mz, in relative; scan_time in minutes 899 900 # Compute inter-feature distances 901 distances = None 902 for i in range(len(dims)): 903 # Construct k-d tree 904 values = mf_df[dims[i]].values 905 tree = KDTree(values.reshape(-1, 1)) 906 907 max_tol = tol[i] 908 if dims[i] == "mz": 909 # Maximum absolute tolerance 910 max_tol = mz_diff + tol[i] 911 912 # Compute sparse distance matrix 913 # the larger the max_tol, the slower this operation is 914 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 915 916 # Only consider forward case, exclude diagonal 917 sdm = sparse.triu(sdm, k=1) 918 919 if dims[i] == "mz": 920 min_tol = mz_diff - tol[i] 921 # Get only the ones that are above the min tol 922 idx = sdm.data > min_tol 923 924 # Reconstruct sparse distance matrix 925 sdm = sparse.coo_matrix( 926 (sdm.data[idx], (sdm.row[idx], sdm.col[idx])), 927 shape=(len(values), len(values)), 928 ) 929 930 # Cast as binary matrix 931 sdm.data = np.ones_like(sdm.data) 932 933 # Stack distances 934 if distances is None: 935 distances = sdm 936 else: 937 distances = distances.multiply(sdm) 938 939 # Extract indices of within-tolerance points 940 distances = distances.tocoo() 941 pairs = np.stack((distances.row, distances.col), axis=1) # C12 to C13 pairs 942 943 # Turn pairs (which are index of mf_df) into mf_id and then into two dataframes to join to mf_df 944 pairs_mf = pairs.copy() 945 pairs_mf[:, 0] = mf_df.iloc[pairs[:, 0]].mf_id.values 946 pairs_mf[:, 1] = mf_df.iloc[pairs[:, 1]].mf_id.values 947 948 # Connect monoisotopic masses with isotopologes within mass_features 949 monos = np.setdiff1d(np.unique(pairs_mf[:, 0]), np.unique(pairs_mf[:, 1])) 950 for mono in monos: 951 self.mass_features[mono].monoisotopic_mf_id = mono 952 pairs_iso_df = pd.DataFrame(pairs_mf, columns=["parent", "child"]) 953 while not pairs_iso_df.empty: 954 pairs_iso_df = pairs_iso_df.set_index("parent", drop=False) 955 m1_isos = pairs_iso_df.loc[monos, "child"].unique() 956 for iso in m1_isos: 957 # Set monoisotopic_mf_id and isotopologue_type for isotopologues 958 parent = pairs_mf[pairs_mf[:, 1] == iso, 0] 959 if len(parent) > 1: 960 # Choose the parent that is closest in time to the isotopologue 961 parent_time = [self.mass_features[p].retention_time for p in parent] 962 time_diff = [ 963 np.abs(self.mass_features[iso].retention_time - x) 964 for x in parent_time 965 ] 966 parent = parent[np.argmin(time_diff)] 967 else: 968 parent = parent[0] 969 self.mass_features[iso].monoisotopic_mf_id = self.mass_features[ 970 parent 971 ].monoisotopic_mf_id 972 if self.mass_features[iso].monoisotopic_mf_id is not None: 973 mass_diff = ( 974 self.mass_features[iso].mz 975 - self.mass_features[ 976 self.mass_features[iso].monoisotopic_mf_id 977 ].mz 978 ) 979 self.mass_features[iso].isotopologue_type = "13C" + str( 980 int(round(mass_diff, 0)) 981 ) 982 983 # Drop the mono and iso from the pairs_iso_df 984 pairs_iso_df = pairs_iso_df.drop( 985 index=monos, errors="ignore" 986 ) # Drop pairs where the parent is a child that is a child of a root 987 pairs_iso_df = pairs_iso_df.set_index("child", drop=False) 988 pairs_iso_df = pairs_iso_df.drop(index=m1_isos, errors="ignore") 989 990 if not pairs_iso_df.empty: 991 # Get new monos, recognizing that these are just 13C isotopologues that are connected to other 13C isotopologues to repeat the process 992 monos = np.setdiff1d( 993 np.unique(pairs_iso_df.parent), np.unique(pairs_iso_df.child) 994 ) 995 if verbose: 996 # Report fraction of compounds annotated with isotopes 997 mf_df["c13_flag"] = np.where( 998 np.logical_or( 999 np.isin(mf_df["mf_id"], pairs_mf[:, 0]), 1000 np.isin(mf_df["mf_id"], pairs_mf[:, 1]), 1001 ), 1002 1, 1003 0, 1004 ) 1005 print( 1006 str(round(len(mf_df[mf_df["c13_flag"] == 1]) / len(mf_df), ndigits=3)) 1007 + " of mass features have or are C13 isotopes" 1008 ) 1009 1010 def deconvolute_ms1_mass_features(self): 1011 """Deconvolute MS1 mass features 1012 1013 Deconvolute mass features ms1 spectrum based on the correlation of all masses within a spectrum over the EIC of the mass features 1014 1015 Parameters 1016 ---------- 1017 None 1018 1019 Returns 1020 ------- 1021 None, but assigns the _ms_deconvoluted_idx, mass_spectrum_deconvoluted_parent, 1022 and associated_mass_features_deconvoluted attributes to the mass features in the 1023 mass_features attribute of the LCMSBase object. 1024 1025 Raises 1026 ------ 1027 ValueError 1028 If no mass features are found, must run find_mass_features() first. 1029 If no EICs are found, did you run integrate_mass_features() first? 1030 1031 """ 1032 # Checks for set mass_features and eics 1033 if self.mass_features is None: 1034 raise ValueError( 1035 "No mass features found, did you run find_mass_features() first?" 1036 ) 1037 1038 if self.eics == {}: 1039 raise ValueError( 1040 "No EICs found, did you run integrate_mass_features() first?" 1041 ) 1042 1043 if 1 not in self._ms_unprocessed.keys(): 1044 raise ValueError("No unprocessed MS1 spectra found.") 1045 1046 # Prep ms1 data 1047 ms1_data = self._ms_unprocessed[1].copy() 1048 ms1_data = ms1_data.set_index("scan") 1049 1050 # Prep mass feature summary 1051 mass_feature_df = self.mass_features_to_df() 1052 1053 # Loop through each mass feature 1054 for mf_id, mass_feature in self.mass_features.items(): 1055 # Check that the mass_feature.mz attribute == the mz of the mass feature in the mass_feature_df 1056 if mass_feature.mz != mass_feature.ms1_peak.mz_exp: 1057 continue 1058 1059 # Get the left and right limits of the EIC of the mass feature 1060 l_scan, _, r_scan = mass_feature._eic_data.apexes[0] 1061 1062 # Pull from the _ms1_unprocessed data the scan range of interest and sort by mz 1063 ms1_data_sub = ms1_data.loc[l_scan:r_scan].copy() 1064 ms1_data_sub = ms1_data_sub.sort_values(by=["mz"]).reset_index(drop=False) 1065 1066 # Get the centroided masses of the mass feature 1067 mf_mspeak_mzs = mass_feature.mass_spectrum.mz_exp 1068 1069 # Find the closest mz in the ms1 data to the centroided masses of the mass feature 1070 ms1_data_sub["mass_feature_mz"] = mf_mspeak_mzs[ 1071 find_closest(mf_mspeak_mzs, ms1_data_sub.mz.values) 1072 ] 1073 1074 # Drop rows with mz_diff > 0.01 between the mass feature mz and the ms1 data mz 1075 ms1_data_sub["mz_diff_rel"] = ( 1076 np.abs(ms1_data_sub["mass_feature_mz"] - ms1_data_sub["mz"]) 1077 / ms1_data_sub["mz"] 1078 ) 1079 ms1_data_sub = ms1_data_sub[ 1080 ms1_data_sub["mz_diff_rel"] 1081 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 1082 ].reset_index(drop=True) 1083 1084 # Group by mass_feature_mz and scan and sum intensity 1085 ms1_data_sub_group = ( 1086 ms1_data_sub.groupby(["mass_feature_mz", "scan"])["intensity"] 1087 .sum() 1088 .reset_index() 1089 ) 1090 1091 # Calculate the correlation of the intensities of the mass feature and the ms1 data (set to 0 if no intensity) 1092 corr = ( 1093 ms1_data_sub_group.pivot( 1094 index="scan", columns="mass_feature_mz", values="intensity" 1095 ) 1096 .fillna(0) 1097 .corr() 1098 ) 1099 1100 # Subset the correlation matrix to only include the masses of the mass feature and those with a correlation > 0.8 1101 decon_corr_min = self.parameters.lc_ms.ms1_deconvolution_corr_min 1102 1103 # Try catch for KeyError in case the mass feature mz is not in the correlation matrix 1104 try: 1105 corr_subset = corr.loc[mass_feature.mz] 1106 except KeyError: 1107 # If the mass feature mz is not in the correlation matrix, skip to the next mass feature 1108 continue 1109 1110 corr_subset = corr_subset[corr_subset > decon_corr_min] 1111 1112 # Get the masses from the mass spectrum that are the result of the deconvolution 1113 mzs_decon = corr_subset.index.values 1114 1115 # Get the indices of the mzs_decon in mass_feature.mass_spectrum.mz_exp and assign to the mass feature 1116 mzs_decon_idx = [ 1117 id 1118 for id, mz in enumerate(mass_feature.mass_spectrum.mz_exp) 1119 if mz in mzs_decon 1120 ] 1121 mass_feature._ms_deconvoluted_idx = mzs_decon_idx 1122 1123 # Check if the mass feature's ms1 peak is the largest in the deconvoluted mass spectrum 1124 if ( 1125 mass_feature.ms1_peak.abundance 1126 == mass_feature.mass_spectrum.abundance[mzs_decon_idx].max() 1127 ): 1128 mass_feature.mass_spectrum_deconvoluted_parent = True 1129 else: 1130 mass_feature.mass_spectrum_deconvoluted_parent = False 1131 1132 # Check for other mass features that are in the deconvoluted mass spectrum and add the deconvoluted mass spectrum to the mass feature 1133 # Subset mass_feature_df to only include mass features that are within the clustering tolerance 1134 mass_feature_df_sub = mass_feature_df[ 1135 abs(mass_feature.retention_time - mass_feature_df["scan_time"]) 1136 < self.parameters.lc_ms.mass_feature_cluster_rt_tolerance 1137 ].copy() 1138 # Calculate the mz difference in ppm between the mass feature and the peaks in the deconvoluted mass spectrum 1139 mass_feature_df_sub["mz_diff_ppm"] = [ 1140 np.abs(mzs_decon - mz).min() / mz * 10**6 1141 for mz in mass_feature_df_sub["mz"] 1142 ] 1143 # Subset mass_feature_df to only include mass features that are within 1 ppm of the deconvoluted masses 1144 mfs_associated_decon = mass_feature_df_sub[ 1145 mass_feature_df_sub["mz_diff_ppm"] 1146 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 10**6 1147 ].index.values 1148 1149 mass_feature.associated_mass_features_deconvoluted = mfs_associated_decon 1150 1151 def _remove_redundant_mass_features( 1152 self, 1153 ) -> None: 1154 """ 1155 Identify and remove redundant mass features that are likely contaminants based on their m/z values and scan frequency. 1156 Especially useful for HILIC data where signals do not return to baseline between peaks or for data with significant background noise. 1157 1158 Contaminants are characterized by: 1159 1. Similar m/z values (within ppm_tolerance) 1160 2. High frequency across scan numbers (ubiquitous presence) 1161 1162 Notes 1163 ----- 1164 Depends on self.mass_features being populated, uses the parameters in self.parameters.lc_ms for tolerances (mass_feature_cluster_mz_tolerance_rel) 1165 """ 1166 ppm_tolerance = ( 1167 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 1e6 1168 ) 1169 min_scan_frequency = self.parameters.lc_ms.redundant_scan_frequency_min 1170 n_retain = self.parameters.lc_ms.redundant_feature_retain_n 1171 1172 df = self.mass_features_to_df() 1173 1174 if df.empty: 1175 return pd.DataFrame() 1176 # df index should be mf_id 1177 if "mf_id" not in df.columns: 1178 if "mf_id" in df.index.names: 1179 df = df.reset_index() 1180 else: 1181 raise ValueError("DataFrame must contain 'mf_id' column or index.") 1182 1183 # Sort by m/z for efficient grouping 1184 df_sorted = df.sort_values("mz").reset_index(drop=True) 1185 1186 # Calculate total number of unique scans for frequency calculation 1187 # Calculate total possible scans (check the cluster rt tolerance and the min rt and max rt of the data) 1188 total_time = self.scan_df["scan_time"].max() - self.scan_df["scan_time"].min() 1189 cluster_rt_tolerance = self.parameters.lc_ms.mass_feature_cluster_rt_tolerance 1190 # If the feature was detected in every possible scan (and then rolled up), it would be in this many scans 1191 total_scans = int(total_time / cluster_rt_tolerance) + 1 1192 1193 # Group similar m/z values using ppm tolerance 1194 mz_groups = [] 1195 current_group = [] 1196 1197 for i, row in df_sorted.iterrows(): 1198 current_mz = row["mz"] 1199 1200 if not current_group: 1201 # Start first group 1202 current_group = [i] 1203 else: 1204 # Check if current m/z is within tolerance of group representative 1205 group_representative_mz = df_sorted.iloc[current_group[0]]["mz"] 1206 ppm_diff = ( 1207 abs(current_mz - group_representative_mz) 1208 / group_representative_mz 1209 * 1e6 1210 ) 1211 1212 if ppm_diff <= ppm_tolerance: 1213 # Add to current group 1214 current_group.append(i) 1215 else: 1216 # Start new group, but first process current group 1217 if len(current_group) > 0: 1218 mz_groups.append(current_group) 1219 current_group = [i] 1220 1221 # Don't forget the last group 1222 if current_group: 1223 mz_groups.append(current_group) 1224 1225 # Analyze each m/z group for contaminant characteristics 1226 1227 for group_indices in mz_groups: 1228 group_data = df_sorted.iloc[group_indices] 1229 1230 # Calculate group statistics 1231 unique_scans = group_data["apex_scan"].nunique() 1232 scan_frequency = unique_scans / total_scans 1233 1234 # Check if this group meets contaminant criteria 1235 if scan_frequency >= min_scan_frequency: 1236 group_data = group_data.sort_values("intensity", ascending=False) 1237 non_representative_mf_id = group_data.iloc[n_retain:][ 1238 "mf_id" 1239 ].tolist() # These will be removed 1240 1241 self.mass_features = { 1242 k: v 1243 for k, v in self.mass_features.items() 1244 if k not in non_representative_mf_id 1245 } 1246 1247 def _remove_mass_features_by_peak_metrics(self, induced_features=False) -> None: 1248 """Remove mass features based on peak metrics defined in mass_feature_attribute_filter_dict. 1249 1250 This method filters mass features based on various peak shape metrics and quality indicators 1251 such as noise scores, Gaussian similarity, tailing factors, dispersity index, etc. 1252 1253 The filtering criteria are defined in the mass_feature_attribute_filter_dict parameter, 1254 which should contain attribute names as keys and filter specifications as values. 1255 1256 Filter specification format: 1257 {attribute_name: {'value': threshold, 'operator': comparison}} 1258 1259 Available operators: 1260 - '>' or 'greater': Keep features where attribute > threshold 1261 - '<' or 'less': Keep features where attribute < threshold 1262 - '>=' or 'greater_equal': Keep features where attribute >= threshold 1263 - '<=' or 'less_equal': Keep features where attribute <= threshold 1264 1265 Examples: 1266 - {'noise_score_max': {'value': 0.5, 'operator': '>='}} - Keep features with noise_score_max >= 0.5 1267 - {'dispersity_index': {'value': 0.1, 'operator': '<'}} - Keep features with dispersity_index < 0.1 1268 - {'gaussian_similarity': {'value': 0.7, 'operator': '>='}} - Keep features with gaussian_similarity >= 0.7 1269 1270 Parameters 1271 ---------- 1272 induced_features : bool, optional 1273 If True, filter induced_mass_features instead of regular mass_features. Default is False. 1274 1275 Returns 1276 ------- 1277 None 1278 Modifies self.mass_features or self.induced_mass_features in place by removing filtered features. 1279 1280 Raises 1281 ------ 1282 ValueError 1283 If no mass features are found, if an invalid attribute is specified, or if filter specification is malformed. 1284 """ 1285 # Select the appropriate mass features dictionary 1286 if induced_features: 1287 mf_dict = self.induced_mass_features 1288 mf_type = "induced mass features" 1289 else: 1290 mf_dict = self.mass_features 1291 mf_type = "mass features" 1292 1293 if mf_dict is None or len(mf_dict) == 0: 1294 raise ValueError( 1295 f"No {mf_type} found, run {'gap filling' if induced_features else 'find_mass_features()'} first" 1296 ) 1297 1298 filter_dict = self.parameters.lc_ms.mass_feature_attribute_filter_dict 1299 1300 if not filter_dict: 1301 # No filtering criteria specified, return early 1302 return 1303 1304 verbose = self.parameters.lc_ms.verbose_processing 1305 initial_count = len(mf_dict) 1306 1307 if verbose: 1308 print( 1309 f"Filtering {mf_type} using peak metrics. Initial count: {initial_count}" 1310 ) 1311 1312 # List to collect IDs of mass features to remove 1313 features_to_remove = [] 1314 1315 for mf_id, mass_feature in mf_dict.items(): 1316 should_remove = False 1317 1318 for attribute_name, filter_spec in filter_dict.items(): 1319 # Validate filter specification structure 1320 if not isinstance(filter_spec, dict): 1321 raise ValueError( 1322 f"Filter specification for '{attribute_name}' must be a dictionary with 'value' and 'operator' keys" 1323 ) 1324 1325 if "value" not in filter_spec or "operator" not in filter_spec: 1326 raise ValueError( 1327 f"Filter specification for '{attribute_name}' must contain both 'value' and 'operator' keys" 1328 ) 1329 1330 threshold_value = filter_spec["value"] 1331 operator = filter_spec["operator"].lower().strip() 1332 1333 # Validate operator 1334 valid_operators = { 1335 ">", 1336 "<", 1337 ">=", 1338 "<=", 1339 "greater", 1340 "less", 1341 "greater_equal", 1342 "less_equal", 1343 } 1344 if operator not in valid_operators: 1345 raise ValueError( 1346 f"Invalid operator '{operator}' for attribute '{attribute_name}'. Valid operators: {valid_operators}" 1347 ) 1348 1349 # Normalize operator names 1350 operator_map = { 1351 "greater": ">", 1352 "less": "<", 1353 "greater_equal": ">=", 1354 "less_equal": "<=", 1355 } 1356 operator = operator_map.get(operator, operator) 1357 1358 # Get the attribute value from the mass feature 1359 try: 1360 if hasattr(mass_feature, attribute_name): 1361 attribute_value = getattr(mass_feature, attribute_name) 1362 else: 1363 raise ValueError( 1364 f"Mass feature does not have attribute '{attribute_name}'" 1365 ) 1366 1367 # Handle None values or attributes that haven't been calculated 1368 if attribute_value is None: 1369 if verbose: 1370 print( 1371 f"Warning: Mass feature {mf_id} has None value for '{attribute_name}'. Removing feature." 1372 ) 1373 should_remove = True 1374 break 1375 1376 # Handle numpy arrays (like half_height_width which returns mean) 1377 if hasattr(attribute_value, "__len__") and not isinstance( 1378 attribute_value, str 1379 ): 1380 # For arrays, we use the mean or appropriate summary statistic 1381 if attribute_name == "half_height_width": 1382 # half_height_width property already returns the mean 1383 pass 1384 else: 1385 attribute_value = float(np.mean(attribute_value)) 1386 1387 # Handle NaN values 1388 if np.isnan(float(attribute_value)): 1389 if verbose: 1390 print( 1391 f"Warning: Mass feature {mf_id} has NaN value for '{attribute_name}'. Removing feature." 1392 ) 1393 should_remove = True 1394 break 1395 1396 # Apply the threshold comparison based on operator 1397 attribute_value = float(attribute_value) 1398 threshold_value = float(threshold_value) 1399 1400 if operator == ">" and not (attribute_value > threshold_value): 1401 should_remove = True 1402 break 1403 elif operator == "<" and not (attribute_value < threshold_value): 1404 should_remove = True 1405 break 1406 elif operator == ">=" and not (attribute_value >= threshold_value): 1407 should_remove = True 1408 break 1409 elif operator == "<=" and not (attribute_value <= threshold_value): 1410 should_remove = True 1411 break 1412 1413 except (AttributeError, ValueError, TypeError) as e: 1414 if verbose: 1415 print( 1416 f"Error evaluating filter '{attribute_name}' for mass feature {mf_id}: {e}" 1417 ) 1418 should_remove = True 1419 break 1420 1421 if should_remove: 1422 features_to_remove.append(mf_id) 1423 1424 # Remove filtered mass features 1425 for mf_id in features_to_remove: 1426 del mf_dict[mf_id] 1427 1428 if verbose and len(features_to_remove) > 0: 1429 print( 1430 f"Removed {len(features_to_remove)} {mf_type} based on peak metrics. Remaining: {len(mf_dict)}" 1431 ) 1432 1433 # Update the appropriate dictionary 1434 if induced_features: 1435 self.induced_mass_features = mf_dict 1436 else: 1437 self.mass_features = mf_dict 1438 1439 # Clean up unassociated EICs and ms1 data (only for regular features) 1440 self._remove_unassociated_eics() 1441 self._remove_unassociated_ms1_spectra() 1442 1443 def _remove_unassociated_eics(self) -> None: 1444 """Remove EICs that are not associated with any mass features. 1445 1446 This method cleans up the eics attribute by removing any EICs that do not correspond to 1447 any mass features currently stored in the mass_features attribute. This is useful for 1448 freeing up memory and ensuring that only relevant EICs are retained. 1449 1450 Returns 1451 ------- 1452 None 1453 Modifies self.eics in place by removing unassociated EICs. 1454 """ 1455 if self.mass_features is None or len(self.mass_features) == 0: 1456 self.eics = {} 1457 return 1458 1459 # Get the set of m/z values associated with current mass features 1460 associated_mzs = {mf.mz for mf in self.mass_features.values()} 1461 1462 # Remove EICs that are not associated with any mass features 1463 self.eics = {mz: eic for mz, eic in self.eics.items() if mz in associated_mzs} 1464 1465 def _remove_unassociated_ms1_spectra(self) -> None: 1466 """Remove MS1 spectra that are not associated with any mass features. 1467 This method cleans up the _ms_unprocessed attribute by removing any MS1 spectra that do not correspond to 1468 any mass features currently stored in the mass_features attribute. This is useful for freeing up memory 1469 and ensuring that only relevant MS1 spectra are retained. 1470 1471 Returns 1472 ------- 1473 None 1474 """ 1475 if self.mass_features is None or len(self.mass_features) == 0: 1476 self._ms_unprocessed = {} 1477 return 1478 1479 # Get the set of m/z values associated with current mass features 1480 associated_ms1_scans = {mf.apex_scan for mf in self.mass_features.values()} 1481 associated_ms1_scans = [int(scan) for scan in associated_ms1_scans] 1482 1483 # Get keys within the _ms attribute (these are individual MassSpectrum objects) 1484 current_stored_spectra = list(set(self._ms.keys())) 1485 if len(current_stored_spectra) == 0: 1486 return 1487 current_stored_spectra = [int(scan) for scan in current_stored_spectra] 1488 1489 # Filter the current_stored_spectra to only ms1 scans 1490 current_stored_spectra_ms1 = [ 1491 scan for scan in current_stored_spectra if scan in self.ms1_scans 1492 ] 1493 1494 # Remove MS1 spectra that are not associated with any mass features 1495 scans_to_drop = [ 1496 scan 1497 for scan in current_stored_spectra_ms1 1498 if scan not in associated_ms1_scans 1499 ] 1500 for scan in scans_to_drop: 1501 if scan in self._ms: 1502 del self._ms[scan]
Methods for performing LC calculations on mass spectra data.
Notes
This class is intended to be used as a mixin for the LCMSBase class.
Methods
- get_max_eic(eic_data). Returns the maximum EIC value from the given EIC data. A static method.
- smooth_tic(tic). Smooths the TIC data using the specified smoothing method and settings.
- eic_centroid_detector(rt, eic, max_eic). Performs EIC centroid detection on the given EIC data.
- find_nearest_scan(rt). Finds the nearest scan to the given retention time.
- get_average_mass_spectrum(scan_list, apex_scan, spectrum_mode="profile", ms_level=1, auto_process=True, use_parser=False, perform_checks=True, polarity=None). Returns an averaged mass spectrum object.
- find_mass_features(ms_level=1). Find regions of interest for a given MS level (default is MS1).
- integrate_mass_features(drop_if_fail=False, ms_level=1). Integrate mass features of interest and extracts EICs.
- find_c13_mass_features(). Evaluate mass features and mark likely C13 isotopes.
- deconvolute_ms1_mass_features(). Deconvolute mass features' ms1 mass spectra.
124 @staticmethod 125 def get_max_eic(eic_data: dict): 126 """Returns the maximum EIC value from the given EIC data. 127 128 Notes 129 ----- 130 This is a static method. 131 132 Parameters 133 ---------- 134 eic_data : dict 135 A dictionary containing EIC data. 136 137 Returns 138 ------- 139 float 140 The maximum EIC value. 141 """ 142 max_eic = 0 143 for eic_data in eic_data.values(): 144 ind_max_eic = max(eic_data.get("EIC")) 145 max_eic = ind_max_eic if ind_max_eic > max_eic else max_eic 146 147 return max_eic
Returns the maximum EIC value from the given EIC data.
Notes
This is a static method.
Parameters
- eic_data (dict): A dictionary containing EIC data.
Returns
- float: The maximum EIC value.
149 def smooth_tic(self, tic): 150 """Smooths the TIC or EIC data using the specified smoothing method and settings. 151 152 Parameters 153 ---------- 154 tic : numpy.ndarray 155 The TIC (or EIC) data to be smoothed. 156 157 Returns 158 ------- 159 numpy.ndarray 160 The smoothed TIC data. 161 """ 162 implemented_smooth_method = self.parameters.lc_ms.implemented_smooth_method 163 164 pol_order = self.parameters.lc_ms.savgol_pol_order 165 166 window_len = self.parameters.lc_ms.smooth_window 167 168 window = self.parameters.lc_ms.smooth_method 169 170 return sp.smooth_signal( 171 tic, window_len, window, pol_order, implemented_smooth_method 172 )
Smooths the TIC or EIC data using the specified smoothing method and settings.
Parameters
- tic (numpy.ndarray): The TIC (or EIC) data to be smoothed.
Returns
- numpy.ndarray: The smoothed TIC data.
174 def eic_centroid_detector(self, rt, eic, max_eic, apex_indexes=[]): 175 """Performs EIC centroid detection on the given EIC data. 176 177 Parameters 178 ---------- 179 rt : numpy.ndarray 180 The retention time data. 181 eic : numpy.ndarray 182 The EIC data. 183 max_eic : float 184 The maximum EIC value. 185 apex_indexes : list, optional 186 The apexes of the EIC peaks. Defaults to [], which means that the apexes will be calculated by the function. 187 188 Returns 189 ------- 190 numpy.ndarray 191 The indexes of left, apex, and right limits as a generator. 192 """ 193 194 max_prominence = self.parameters.lc_ms.peak_max_prominence_percent 195 196 max_height = self.parameters.lc_ms.peak_height_max_percent 197 198 signal_threshold = self.parameters.lc_ms.eic_signal_threshold 199 200 min_peak_datapoints = self.parameters.lc_ms.min_peak_datapoints 201 202 peak_derivative_threshold = self.parameters.lc_ms.peak_derivative_threshold 203 204 include_indexes = sp.peak_picking_first_derivative( 205 domain=rt, 206 signal=eic, 207 max_height=max_height, 208 max_prominence=max_prominence, 209 max_signal=max_eic, 210 min_peak_datapoints=min_peak_datapoints, 211 peak_derivative_threshold=peak_derivative_threshold, 212 signal_threshold=signal_threshold, 213 correct_baseline=False, 214 plot_res=False, 215 apex_indexes=apex_indexes, 216 ) 217 # include_indexes is a generator of tuples (left_index, apex_index, right_index) 218 include_indexes = list(include_indexes) 219 # Add check to make sure that there are at least 1/2 of min_peak_datapoints on either side of the apex 220 indicies = [x for x in include_indexes] 221 for idx in indicies: 222 if (idx[1] - idx[0] < min_peak_datapoints / 2) or ( 223 idx[2] - idx[1] < min_peak_datapoints / 2 224 ): 225 include_indexes.remove(idx) 226 return include_indexes
Performs EIC centroid detection on the given EIC data.
Parameters
- rt (numpy.ndarray): The retention time data.
- eic (numpy.ndarray): The EIC data.
- max_eic (float): The maximum EIC value.
- apex_indexes (list, optional): The apexes of the EIC peaks. Defaults to [], which means that the apexes will be calculated by the function.
Returns
- numpy.ndarray: The indexes of left, apex, and right limits as a generator.
228 def find_nearest_scan(self, rt): 229 """Finds the nearest scan to the given retention time. 230 231 Parameters 232 ---------- 233 rt : float 234 The retention time (in minutes) to find the nearest scan for. 235 236 Returns 237 ------- 238 int 239 The scan number of the nearest scan. 240 """ 241 array_rt = np.array(self.retention_time) 242 243 scan_index = (np.abs(array_rt - rt)).argmin() 244 245 real_scan = self.scans_number[scan_index] 246 247 return real_scan
Finds the nearest scan to the given retention time.
Parameters
- rt (float): The retention time (in minutes) to find the nearest scan for.
Returns
- int: The scan number of the nearest scan.
249 def add_peak_metrics(self, remove_by_metrics=True, induced_features=False): 250 """Add peak metrics to the mass features. 251 252 This function calculates the peak metrics for each mass feature and adds them to the mass feature objects. 253 254 Parameters 255 ---------- 256 remove_by_metrics : bool, optional 257 If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity, 258 dispersity index, and noise score. Default is True, which checks the setting in the processing parameters. 259 If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters. 260 induced_features : bool, optional 261 Whether the mass features to be integrated were induced. Default is False. 262 """ 263 # Check that at least some mass features have eic data 264 if induced_features: 265 mf_dict_values = self.induced_mass_features.values() 266 else: 267 mf_dict_values = self.mass_features.values() 268 269 if not any([mf._eic_data is not None for mf in mf_dict_values]): 270 raise ValueError( 271 "No mass features have EIC data. Run integrate_mass_features first." 272 ) 273 274 for mass_feature in mf_dict_values: 275 # Check if the mass feature has been integrated 276 if mass_feature._eic_data is not None and mass_feature.area is not None: 277 # Calculate peak metrics 278 mass_feature.calc_half_height_width() 279 mass_feature.calc_tailing_factor() 280 mass_feature.calc_dispersity_index() 281 mass_feature.calc_gaussian_similarity() 282 mass_feature.calc_noise_score() 283 284 # Remove mass features by peak metrics if designated in parameters 285 if ( 286 self.parameters.lc_ms.remove_mass_features_by_peak_metrics 287 and remove_by_metrics 288 ): 289 self._remove_mass_features_by_peak_metrics( 290 induced_features=induced_features 291 )
Add peak metrics to the mass features.
This function calculates the peak metrics for each mass feature and adds them to the mass feature objects.
Parameters
- remove_by_metrics (bool, optional): If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity, dispersity index, and noise score. Default is True, which checks the setting in the processing parameters. If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters.
- induced_features (bool, optional): Whether the mass features to be integrated were induced. Default is False.
293 def get_average_mass_spectrum( 294 self, 295 scan_list, 296 apex_scan, 297 spectrum_mode="profile", 298 ms_level=1, 299 auto_process=True, 300 use_parser=False, 301 perform_checks=True, 302 polarity=None, 303 ms_params=None, 304 ): 305 """Returns an averaged mass spectrum object 306 307 Parameters 308 ---------- 309 scan_list : list 310 List of scan numbers to average. 311 apex_scan : int 312 Number of the apex scan 313 spectrum_mode : str, optional 314 The spectrum mode to use. Defaults to "profile". Not that only "profile" mode is supported for averaging. 315 ms_level : int, optional 316 The MS level to use. Defaults to 1. 317 auto_process : bool, optional 318 If True, the averaged mass spectrum will be auto-processed. Defaults to True. 319 use_parser : bool, optional 320 If True, the mass spectra will be obtained from the parser. Defaults to False. 321 perform_checks : bool, optional 322 If True, the function will check if the data are within the ms_unprocessed dictionary and are the correct mode. Defaults to True. Only set to False if you are sure the data are profile, and (if not using the parser) are in the ms_unprocessed dictionary! ms_unprocessed dictionary also must be indexed on scan 323 polarity : int, optional 324 The polarity of the mass spectra (1 or -1). If not set, the polarity will be determined from the dataset. Defaults to None. (fastest if set to -1 or 1) 325 ms_params : MSParameters, optional 326 The mass spectrum parameters to use. If not set (None), the globally set parameters will be used. Defaults to None. 327 328 Returns 329 ------- 330 MassSpectrumProfile 331 The averaged mass spectrum object. 332 333 Raises 334 ------ 335 ValueError 336 If the spectrum mode is not "profile". 337 If the MS level is not found in the unprocessed mass spectra dictionary. 338 If not all scan numbers are found in the unprocessed mass spectra dictionary. 339 """ 340 if perform_checks: 341 if spectrum_mode != "profile": 342 raise ValueError("Averaging only supported for profile mode") 343 344 if polarity is None: 345 # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation) 346 if self.polarity == "negative": 347 polarity = -1 348 elif self.polarity == "positive": 349 polarity = 1 350 else: 351 raise ValueError( 352 "Polarity not set for dataset, must be a set containing either 'positive' or 'negative'" 353 ) 354 355 # if not using_parser, check that scan numbers are in _ms_unprocessed 356 if not use_parser: 357 if perform_checks: 358 # Set index to scan for faster lookup 359 ms_df = ( 360 self._ms_unprocessed[ms_level] 361 .copy() 362 .set_index("scan", drop=False) 363 .sort_index() 364 ) 365 my_ms_df = ms_df.loc[scan_list] 366 # Check that all scan numbers are in the ms_df 367 if not all(np.isin(scan_list, ms_df.index)): 368 raise ValueError( 369 "Not all scan numbers found in the unprocessed mass spectra dictionary" 370 ) 371 else: 372 my_ms_df = ( 373 pd.DataFrame({"scan": scan_list}) 374 .set_index("scan") 375 .join(self._ms_unprocessed[ms_level], how="left") 376 ) 377 378 if use_parser: 379 parser = self.spectra_parser 380 ms_list = [ 381 parser.get_mass_spectrum_from_scan( 382 x, spectrum_mode=spectrum_mode, auto_process=False 383 ) 384 for x in scan_list 385 ] 386 ms_mz = [x._mz_exp for x in ms_list] 387 ms_int = [x._abundance for x in ms_list] 388 my_ms_df = [] 389 for i in np.arange(len(ms_mz)): 390 my_ms_df.append( 391 pd.DataFrame( 392 {"mz": ms_mz[i], "intensity": ms_int[i], "scan": scan_list[i]} 393 ) 394 ) 395 my_ms_df = pd.concat(my_ms_df) 396 397 if not self.check_if_grid(my_ms_df): 398 my_ms_df = self.grid_data(my_ms_df) 399 400 my_ms_ave = my_ms_df.groupby("mz")["intensity"].sum().reset_index() 401 402 ms = ms_from_array_profile( 403 my_ms_ave.mz, 404 my_ms_ave.intensity, 405 self.file_location, 406 polarity=polarity, 407 auto_process=False, 408 ) 409 410 # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset 411 if ms is not None: 412 if ms_params is not None: 413 ms.parameters = ms_params 414 ms.scan_number = apex_scan 415 if auto_process: 416 ms.process_mass_spec() 417 return ms
Returns an averaged mass spectrum object
Parameters
- scan_list (list): List of scan numbers to average.
- apex_scan (int): Number of the apex scan
- spectrum_mode (str, optional): The spectrum mode to use. Defaults to "profile". Not that only "profile" mode is supported for averaging.
- ms_level (int, optional): The MS level to use. Defaults to 1.
- auto_process (bool, optional): If True, the averaged mass spectrum will be auto-processed. Defaults to True.
- use_parser (bool, optional): If True, the mass spectra will be obtained from the parser. Defaults to False.
- perform_checks (bool, optional): If True, the function will check if the data are within the ms_unprocessed dictionary and are the correct mode. Defaults to True. Only set to False if you are sure the data are profile, and (if not using the parser) are in the ms_unprocessed dictionary! ms_unprocessed dictionary also must be indexed on scan
- polarity (int, optional): The polarity of the mass spectra (1 or -1). If not set, the polarity will be determined from the dataset. Defaults to None. (fastest if set to -1 or 1)
- ms_params (MSParameters, optional): The mass spectrum parameters to use. If not set (None), the globally set parameters will be used. Defaults to None.
Returns
- MassSpectrumProfile: The averaged mass spectrum object.
Raises
- ValueError: If the spectrum mode is not "profile". If the MS level is not found in the unprocessed mass spectra dictionary. If not all scan numbers are found in the unprocessed mass spectra dictionary.
484 def find_mass_features( 485 self, 486 ms_level=1, 487 grid=True, 488 assign_ms2_scans=False, 489 ms2_scan_filter=None, 490 targeted_search=False, 491 target_search_dict=None, 492 accumulate_features=False, 493 ): 494 """Find mass features within an LCMSBase object 495 496 Wrapper that dispatches to a 2D peak picking implementation. 497 498 If ``parameters.lc_ms.peak_picking_method`` is ``by_format``, the method is 499 chosen from ``scan_df.ms_format`` at the requested MS level (all 500 profile → persistent homology; all centroid → centroided persistent 501 homology). A successful resolve overwrites 502 ``peak_picking_method`` with the concrete method so saved parameters 503 record what was used. 504 505 Parameters 506 ---------- 507 ms_level : int, optional 508 The MS level to use for peak picking Default is 1. 509 grid : bool, optional 510 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded), 511 used for persistent homology peak picking for profile data only. Default is True. 512 assign_ms2_scans : bool, optional 513 If True, assign MS2 scan numbers to mass features after peak picking. 514 This populates the ms2_scan_numbers attribute on each mass feature, which enables 515 choosing representative features based on MS2 availability. Default is False. 516 ms2_scan_filter : str or None, optional 517 Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd'). 518 If None, all MS2 scans are considered. Default is None. 519 targeted_search : bool, optional 520 If True, perform targeted mass feature search using the target_search_dict. 521 This mode filters data to only m/z and RT windows of interest and bypasses 522 intensity and persistence thresholds. Default is False. 523 target_search_dict : dict or None, optional 524 Dictionary containing target search parameters. Required if targeted_search is True. 525 Must contain: 526 - 'target_mz_list': list of target m/z values 527 - 'target_rt_list': list of target retention times (in minutes) 528 - 'mz_tolerance_ppm': m/z tolerance in ppm 529 - 'rt_tolerance': retention time tolerance (in minutes) 530 Optionally can contain: 531 - 'type': type label for mass features (e.g., "internal standard") 532 If not provided, defaults to "targeted" 533 Default is None. 534 accumulate_features : bool, optional 535 If True, new mass features will be added to existing features rather than replacing them. 536 This allows multiple sequential calls to find_mass_features to build up a combined set. 537 Default is False (replace existing features for backwards compatibility). 538 539 Raises 540 ------ 541 ValueError 542 If no MS level data is found on the object. 543 If ``by_format`` cannot resolve a method (empty, mixed, or unknown 544 ``ms_format`` values at the requested MS level). 545 If persistent homology peak picking is attempted on non-profile mode data. 546 If centroided persistent homology is attempted on non-centroid mode data. 547 If data is not gridded and grid is False. 548 If peak picking method is not implemented. 549 If targeted_search is True but target_search_dict is None or invalid. 550 551 Returns 552 ------- 553 None, but assigns the mass_features and eics attributes to the object. 554 555 """ 556 # Validate targeted search parameters 557 if targeted_search: 558 if target_search_dict is None: 559 raise ValueError( 560 "target_search_dict must be provided when targeted_search is True" 561 ) 562 required_keys = [ 563 "target_mz_list", 564 "target_rt_list", 565 "mz_tolerance_ppm", 566 "rt_tolerance", 567 ] 568 for key in required_keys: 569 if key not in target_search_dict: 570 raise ValueError(f"target_search_dict must contain '{key}'") 571 if len(target_search_dict["target_mz_list"]) != len( 572 target_search_dict["target_rt_list"] 573 ): 574 raise ValueError( 575 "target_mz_list and target_rt_list must have the same length" 576 ) 577 578 requested_method = self.parameters.lc_ms.peak_picking_method 579 pp_method = self._resolve_lcms_peak_picking_method(ms_level=ms_level) 580 if requested_method == "by_format": 581 self.parameters.lc_ms.peak_picking_method = pp_method 582 583 if targeted_search: 584 mf_type = target_search_dict.get("type", "targeted") 585 else: 586 mf_type = "untargeted" 587 588 if pp_method == "persistent homology": 589 self.find_mass_features_ph( 590 ms_level=ms_level, 591 grid=grid, 592 targeted_search=targeted_search, 593 target_search_dict=target_search_dict, 594 mf_type=mf_type, 595 accumulate_features=accumulate_features, 596 ) 597 elif pp_method == "centroided_persistent_homology": 598 self.find_mass_features_ph_centroid( 599 ms_level=ms_level, 600 targeted_search=targeted_search, 601 target_search_dict=target_search_dict, 602 mf_type=mf_type, 603 accumulate_features=accumulate_features, 604 ) 605 else: 606 raise ValueError("Peak picking method not implemented") 607 608 # Cluster mass features to remove redundant features 609 self.cluster_mass_features(drop_children=True) 610 611 # Optionally assign MS2 scan numbers to mass features during peak picking 612 # This helps with choosing representative features that have MS2 data 613 if assign_ms2_scans: 614 try: 615 self._find_ms2_scans_for_mass_features( 616 mf_ids=None, # Process all mass features 617 scan_filter=ms2_scan_filter, 618 ) 619 except ValueError: 620 # No MS2 scans found - this is okay, just skip 621 pass 622 623 # Remove noisey mass features if designated in parameters 624 if self.parameters.lc_ms.remove_redundant_mass_features and not targeted_search: 625 self._remove_redundant_mass_features()
Find mass features within an LCMSBase object
Wrapper that dispatches to a 2D peak picking implementation.
If parameters.lc_ms.peak_picking_method is by_format, the method is
chosen from scan_df.ms_format at the requested MS level (all
profile → persistent homology; all centroid → centroided persistent
homology). A successful resolve overwrites
peak_picking_method with the concrete method so saved parameters
record what was used.
Parameters
- ms_level (int, optional): The MS level to use for peak picking Default is 1.
- grid (bool, optional): If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded), used for persistent homology peak picking for profile data only. Default is True.
- assign_ms2_scans (bool, optional): If True, assign MS2 scan numbers to mass features after peak picking. This populates the ms2_scan_numbers attribute on each mass feature, which enables choosing representative features based on MS2 availability. Default is False.
- ms2_scan_filter (str or None, optional): Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd'). If None, all MS2 scans are considered. Default is None.
- targeted_search (bool, optional): If True, perform targeted mass feature search using the target_search_dict. This mode filters data to only m/z and RT windows of interest and bypasses intensity and persistence thresholds. Default is False.
- target_search_dict (dict or None, optional): Dictionary containing target search parameters. Required if targeted_search is True. Must contain: - 'target_mz_list': list of target m/z values - 'target_rt_list': list of target retention times (in minutes) - 'mz_tolerance_ppm': m/z tolerance in ppm - 'rt_tolerance': retention time tolerance (in minutes) Optionally can contain: - 'type': type label for mass features (e.g., "internal standard") If not provided, defaults to "targeted" Default is None.
- accumulate_features (bool, optional): If True, new mass features will be added to existing features rather than replacing them. This allows multiple sequential calls to find_mass_features to build up a combined set. Default is False (replace existing features for backwards compatibility).
Raises
- ValueError: If no MS level data is found on the object.
If
by_formatcannot resolve a method (empty, mixed, or unknownms_formatvalues at the requested MS level). If persistent homology peak picking is attempted on non-profile mode data. If centroided persistent homology is attempted on non-centroid mode data. If data is not gridded and grid is False. If peak picking method is not implemented. If targeted_search is True but target_search_dict is None or invalid.
Returns
- None, but assigns the mass_features and eics attributes to the object.
627 def integrate_mass_features( 628 self, 629 drop_if_fail=True, 630 drop_duplicates=True, 631 ms_level=1, 632 induced_features=False, 633 ): 634 """Integrate mass features and extract EICs. 635 636 Populates the _eics attribute on the LCMSBase object for each unique mz in the mass_features dataframe and adds data (start_scan, final_scan, area) to the mass_features attribute. 637 638 Parameters 639 ---------- 640 drop_if_fail : bool, optional 641 Whether to drop mass features if the EIC limit calculations fail. 642 Default is True. 643 drop_duplicates : bool, optional 644 Whether to mass features that appear to be duplicates 645 (i.e., mz is similar to another mass feature and limits of the EIC are similar or encapsulating). 646 Default is True. 647 ms_level : int, optional 648 The MS level to use. Default is 1. 649 induced_features : bool, optional 650 Whether the mass features to be intergrated were induced. Default is False. 651 652 Raises 653 ------ 654 ValueError 655 If no mass features are found. 656 If no MS level data is found for the given MS level (either in data or in the scan data) 657 658 Returns 659 ------- 660 None, but populates the eics attribute on the LCMSBase object and adds data (start_scan, final_scan, area) to the mass_features attribute. 661 662 Notes 663 ----- 664 drop_if_fail is useful for discarding mass features that do not have good shapes, usually due to a detection on a shoulder of a peak or a noisy region (especially if minimal smoothing is used during mass feature detection). 665 """ 666 667 # Check if there is data 668 if ms_level in self._ms_unprocessed.keys(): 669 raw_data = self._ms_unprocessed[ms_level].copy() 670 else: 671 raise ValueError("No MS level " + str(ms_level) + " data found") 672 673 # Check if mass_spectrum exists on each mass feature 674 if induced_features: 675 mf_dict = self.induced_mass_features 676 if len(mf_dict) == 0: 677 raise ValueError( 678 "No induced mass features found, did you run fill_missing_cluster_features() first?" 679 ) 680 681 ## remove not found induced mass features by mz <= 0 (-99 indicator) 682 # also remove any where mz is nan 683 mf_dict = { 684 k: v for k, v in mf_dict.items() if v.mz > 0 and not np.isnan(v.mz) 685 } 686 687 else: 688 mf_dict = self.mass_features 689 if len(mf_dict) == 0: 690 raise ValueError( 691 "No mass features found, did you run find_mass_features() first?" 692 ) 693 694 # Subset scan data to only include correct ms_level 695 scan_df_sub = self.scan_df[ 696 self.scan_df["ms_level"] == int(ms_level) 697 ].reset_index(drop=True) 698 if scan_df_sub.empty: 699 raise ValueError("No MS level " + ms_level + " data found in scan data") 700 scan_df_sub = scan_df_sub[["scan", "scan_time"]].copy() 701 702 mzs_to_extract = np.unique([mf.mz for mf in mf_dict.values()]) 703 mzs_to_extract.sort() 704 705 # Pre-sort raw_data by mz for faster filtering 706 raw_data_sorted = raw_data.sort_values(["mz", "scan"]).reset_index(drop=True) 707 raw_data_mz = raw_data_sorted["mz"].to_numpy() 708 raw_scan = raw_data_sorted["scan"].to_numpy() 709 raw_intensity = raw_data_sorted["intensity"].to_numpy() 710 scan_ids = scan_df_sub["scan"].to_numpy() 711 scan_time = scan_df_sub["scan_time"].to_numpy() 712 n_scans = scan_ids.size 713 # Sum into the scan-table row with the same float32 Kahan sum the 714 # previous groupby used, so the chromatogram values stay identical. 715 scan_order = np.argsort(scan_ids, kind="mergesort") 716 sorted_scan_ids = scan_ids[scan_order] 717 found = np.searchsorted(sorted_scan_ids, raw_scan) 718 in_range = found < n_scans 719 scan_col = np.empty(raw_scan.shape, dtype=np.intp) 720 scan_col[in_range] = scan_order[found[in_range]] 721 matched = np.zeros(raw_scan.shape, dtype=bool) 722 matched[in_range] = sorted_scan_ids[found[in_range]] == raw_scan[in_range] 723 724 # Get EICs for each unique mz in mass features list 725 for mz in mzs_to_extract: 726 mz_max = mz + self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 727 mz_min = mz - self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 728 729 # Use binary search for faster mz range filtering 730 left_idx = int(np.searchsorted(raw_data_mz, mz_min, side="left")) 731 right_idx = int(np.searchsorted(raw_data_mz, mz_max, side="right")) 732 eic = np.zeros(n_scans, dtype=np.float32) 733 if left_idx < right_idx: 734 comp = np.zeros(n_scans, dtype=np.float32) 735 cols = scan_col[left_idx:right_idx] 736 vals = raw_intensity[left_idx:right_idx] 737 ok = matched[left_idx:right_idx] 738 for i in range(cols.size): 739 if not ok[i]: 740 continue 741 col = cols[i] 742 val = vals[i] 743 y = val - comp[col] 744 t = eic[col] + y 745 c = (t - eic[col]) - y 746 comp[col] = c if np.isfinite(c) else np.float32(0) 747 eic[col] = t 748 myEIC = EIC_Data( 749 scans=scan_ids.copy(), 750 time=scan_time.copy(), 751 eic=eic, 752 ) 753 # Smooth EIC 754 smoothed_eic = self.smooth_tic(myEIC.eic) 755 smoothed_eic[smoothed_eic < 0] = 0 756 myEIC.eic_smoothed = smoothed_eic 757 self.eics[mz] = myEIC 758 759 # Get limits of mass features using EIC centroid detector and integrate 760 for idx, mass_feature in list(mf_dict.items()): 761 mz = mass_feature.mz 762 apex_scan = mass_feature.apex_scan 763 764 # Pull EIC data and find apex scan index 765 myEIC = self.eics[mz] 766 mf_dict[idx]._eic_data = myEIC 767 mf_dict[idx]._eic_mz = mz 768 apex_index = np.searchsorted(myEIC.scans, apex_scan) 769 770 # Find left and right limits of peak using EIC centroid detector, add to EICData 771 centroid_eics = self.eic_centroid_detector( 772 myEIC.time, 773 myEIC.eic_smoothed, 774 mass_feature.intensity * 1.1, 775 apex_indexes=[int(apex_index)], 776 ) 777 l_a_r_scan_idx = [i for i in centroid_eics] 778 if len(l_a_r_scan_idx) > 0: 779 # Calculate number of consecutive scans with intensity > 0 and check if it is above the minimum consecutive scans 780 # Find the number of consecutive non-zero values in the EIC segment 781 mask = myEIC.eic[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1] > 0 782 # Find the longest run of consecutive True values 783 if np.any(mask): 784 # Find indices where mask changes value 785 diff = np.diff(np.concatenate(([0], mask.astype(int), [0]))) 786 starts = np.where(diff == 1)[0] 787 ends = np.where(diff == -1)[0] 788 consecutive_scans = (ends - starts).max() 789 else: 790 consecutive_scans = 0 791 if consecutive_scans < self.parameters.lc_ms.consecutive_scan_min: 792 mf_dict.pop(idx) 793 continue 794 # Add start and final scan to mass_features and EICData 795 left_scan, right_scan = ( 796 myEIC.scans[l_a_r_scan_idx[0][0]], 797 myEIC.scans[l_a_r_scan_idx[0][2]], 798 ) 799 mf_scan_apex = [(left_scan, int(apex_scan), right_scan)] 800 myEIC.apexes = myEIC.apexes + mf_scan_apex 801 mf_dict[idx].start_scan = left_scan 802 mf_dict[idx].final_scan = right_scan 803 804 # Find area under peak using limits from EIC centroid detector, add to mass_features and EICData 805 area = np.trapezoid( 806 myEIC.eic_smoothed[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 807 myEIC.time[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 808 ) 809 myEIC.areas = myEIC.areas + [area] 810 self.eics[mz] = myEIC 811 mf_dict[idx]._area = area 812 else: 813 if drop_if_fail is True: 814 mf_dict.pop(idx) 815 816 if drop_duplicates: 817 # Prepare mass feature dataframe 818 # Skip features without scan bounds (failed EIC limits when 819 # drop_if_fail=False); they cannot participate in containment de-dup 820 if induced_features: 821 mf_df = self.mass_features_to_df(induced_features=True).copy() 822 mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()] 823 else: 824 mf_df = self.mass_features_to_df(induced_features=False).copy() 825 mf_df = mf_df[mf_df.start_scan.notna() & mf_df.final_scan.notna()] 826 827 # For each mass feature, find all mass features within the clustering tolerance ppm and drop if their start and end times are within another mass feature 828 # Keep the first mass feature (highest persistence) 829 for idx, mass_feature in mf_df.iterrows(): 830 mz = mass_feature.mz 831 apex_scan = mass_feature.apex_scan 832 833 mf_df["mz_diff_ppm"] = np.abs(mf_df["mz"] - mz) / mz * 10**6 834 mf_df_sub = mf_df[ 835 mf_df["mz_diff_ppm"] 836 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 837 * 10**6 838 ].copy() 839 840 # For all mass features within the clustering tolerance, check if the start and end times are within the start and end times of the mass feature 841 for idx2, mass_feature2 in mf_df_sub.iterrows(): 842 if idx2 != idx: 843 s1, e1 = mass_feature.start_scan, mass_feature.final_scan 844 s2, e2 = mass_feature2.start_scan, mass_feature2.final_scan 845 # Guard incomplete bounds (None / NaN) so ordering 846 # comparisons never raise TypeError 847 if s1 is None or e1 is None or s2 is None or e2 is None: 848 continue 849 if pd.isna(s1) or pd.isna(e1) or pd.isna(s2) or pd.isna(e2): 850 continue 851 if s2 >= s1 and e2 <= e1: 852 if idx2 in self.mass_features.keys(): 853 self.mass_features.pop(idx2) 854 855 # Filter MS2 scans to only include those within integration bounds 856 # This ensures MS2 scans outside start_scan to final_scan are removed 857 if induced_features: 858 self._filter_ms2_scans_by_integration_bounds( 859 mf_dict=self.induced_mass_features 860 ) 861 else: 862 self._filter_ms2_scans_by_integration_bounds(mf_dict=self.mass_features)
Integrate mass features and extract EICs.
Populates the _eics attribute on the LCMSBase object for each unique mz in the mass_features dataframe and adds data (start_scan, final_scan, area) to the mass_features attribute.
Parameters
- drop_if_fail (bool, optional): Whether to drop mass features if the EIC limit calculations fail. Default is True.
- drop_duplicates (bool, optional): Whether to mass features that appear to be duplicates (i.e., mz is similar to another mass feature and limits of the EIC are similar or encapsulating). Default is True.
- ms_level (int, optional): The MS level to use. Default is 1.
- induced_features (bool, optional): Whether the mass features to be intergrated were induced. Default is False.
Raises
- ValueError: If no mass features are found. If no MS level data is found for the given MS level (either in data or in the scan data)
Returns
- None, but populates the eics attribute on the LCMSBase object and adds data (start_scan, final_scan, area) to the mass_features attribute.
Notes
drop_if_fail is useful for discarding mass features that do not have good shapes, usually due to a detection on a shoulder of a peak or a noisy region (especially if minimal smoothing is used during mass feature detection).
864 def find_c13_mass_features(self): 865 """Mark likely C13 isotopes and connect to monoisoitopic mass features. 866 867 Returns 868 ------- 869 None, but populates the monoisotopic_mf_id and isotopologue_type attributes to the indivual LCMSMassFeatures within the mass_features attribute of the LCMSBase object. 870 871 Raises 872 ------ 873 ValueError 874 If no mass features are found. 875 """ 876 verbose = self.parameters.lc_ms.verbose_processing 877 if verbose: 878 print("evaluating mass features for C13 isotopes") 879 if self.mass_features is None: 880 raise ValueError("No mass features found, run find_mass_features() first") 881 882 # Data prep fo sparse distance matrix 883 dims = ["mz", "scan_time"] 884 mf_df = self.mass_features_to_df().copy() 885 # Drop mass features that have no area (these are likely to be noise) 886 mf_df = mf_df[mf_df["area"].notnull()] 887 mf_df["mf_id"] = mf_df.index.values 888 dims = ["mz", "scan_time"] 889 890 # Sort my ascending mz so we always get the monoisotopic mass first, regardless of the order/intensity of the mass features 891 mf_df = mf_df.sort_values(by=["mz"]).reset_index(drop=True).copy() 892 893 mz_diff = 1.003355 # C13-C12 mass difference 894 tol = [ 895 mf_df["mz"].median() 896 * self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 897 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance * 0.5, 898 ] # mz, in relative; scan_time in minutes 899 900 # Compute inter-feature distances 901 distances = None 902 for i in range(len(dims)): 903 # Construct k-d tree 904 values = mf_df[dims[i]].values 905 tree = KDTree(values.reshape(-1, 1)) 906 907 max_tol = tol[i] 908 if dims[i] == "mz": 909 # Maximum absolute tolerance 910 max_tol = mz_diff + tol[i] 911 912 # Compute sparse distance matrix 913 # the larger the max_tol, the slower this operation is 914 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 915 916 # Only consider forward case, exclude diagonal 917 sdm = sparse.triu(sdm, k=1) 918 919 if dims[i] == "mz": 920 min_tol = mz_diff - tol[i] 921 # Get only the ones that are above the min tol 922 idx = sdm.data > min_tol 923 924 # Reconstruct sparse distance matrix 925 sdm = sparse.coo_matrix( 926 (sdm.data[idx], (sdm.row[idx], sdm.col[idx])), 927 shape=(len(values), len(values)), 928 ) 929 930 # Cast as binary matrix 931 sdm.data = np.ones_like(sdm.data) 932 933 # Stack distances 934 if distances is None: 935 distances = sdm 936 else: 937 distances = distances.multiply(sdm) 938 939 # Extract indices of within-tolerance points 940 distances = distances.tocoo() 941 pairs = np.stack((distances.row, distances.col), axis=1) # C12 to C13 pairs 942 943 # Turn pairs (which are index of mf_df) into mf_id and then into two dataframes to join to mf_df 944 pairs_mf = pairs.copy() 945 pairs_mf[:, 0] = mf_df.iloc[pairs[:, 0]].mf_id.values 946 pairs_mf[:, 1] = mf_df.iloc[pairs[:, 1]].mf_id.values 947 948 # Connect monoisotopic masses with isotopologes within mass_features 949 monos = np.setdiff1d(np.unique(pairs_mf[:, 0]), np.unique(pairs_mf[:, 1])) 950 for mono in monos: 951 self.mass_features[mono].monoisotopic_mf_id = mono 952 pairs_iso_df = pd.DataFrame(pairs_mf, columns=["parent", "child"]) 953 while not pairs_iso_df.empty: 954 pairs_iso_df = pairs_iso_df.set_index("parent", drop=False) 955 m1_isos = pairs_iso_df.loc[monos, "child"].unique() 956 for iso in m1_isos: 957 # Set monoisotopic_mf_id and isotopologue_type for isotopologues 958 parent = pairs_mf[pairs_mf[:, 1] == iso, 0] 959 if len(parent) > 1: 960 # Choose the parent that is closest in time to the isotopologue 961 parent_time = [self.mass_features[p].retention_time for p in parent] 962 time_diff = [ 963 np.abs(self.mass_features[iso].retention_time - x) 964 for x in parent_time 965 ] 966 parent = parent[np.argmin(time_diff)] 967 else: 968 parent = parent[0] 969 self.mass_features[iso].monoisotopic_mf_id = self.mass_features[ 970 parent 971 ].monoisotopic_mf_id 972 if self.mass_features[iso].monoisotopic_mf_id is not None: 973 mass_diff = ( 974 self.mass_features[iso].mz 975 - self.mass_features[ 976 self.mass_features[iso].monoisotopic_mf_id 977 ].mz 978 ) 979 self.mass_features[iso].isotopologue_type = "13C" + str( 980 int(round(mass_diff, 0)) 981 ) 982 983 # Drop the mono and iso from the pairs_iso_df 984 pairs_iso_df = pairs_iso_df.drop( 985 index=monos, errors="ignore" 986 ) # Drop pairs where the parent is a child that is a child of a root 987 pairs_iso_df = pairs_iso_df.set_index("child", drop=False) 988 pairs_iso_df = pairs_iso_df.drop(index=m1_isos, errors="ignore") 989 990 if not pairs_iso_df.empty: 991 # Get new monos, recognizing that these are just 13C isotopologues that are connected to other 13C isotopologues to repeat the process 992 monos = np.setdiff1d( 993 np.unique(pairs_iso_df.parent), np.unique(pairs_iso_df.child) 994 ) 995 if verbose: 996 # Report fraction of compounds annotated with isotopes 997 mf_df["c13_flag"] = np.where( 998 np.logical_or( 999 np.isin(mf_df["mf_id"], pairs_mf[:, 0]), 1000 np.isin(mf_df["mf_id"], pairs_mf[:, 1]), 1001 ), 1002 1, 1003 0, 1004 ) 1005 print( 1006 str(round(len(mf_df[mf_df["c13_flag"] == 1]) / len(mf_df), ndigits=3)) 1007 + " of mass features have or are C13 isotopes" 1008 )
Mark likely C13 isotopes and connect to monoisoitopic mass features.
Returns
- None, but populates the monoisotopic_mf_id and isotopologue_type attributes to the indivual LCMSMassFeatures within the mass_features attribute of the LCMSBase object.
Raises
- ValueError: If no mass features are found.
1010 def deconvolute_ms1_mass_features(self): 1011 """Deconvolute MS1 mass features 1012 1013 Deconvolute mass features ms1 spectrum based on the correlation of all masses within a spectrum over the EIC of the mass features 1014 1015 Parameters 1016 ---------- 1017 None 1018 1019 Returns 1020 ------- 1021 None, but assigns the _ms_deconvoluted_idx, mass_spectrum_deconvoluted_parent, 1022 and associated_mass_features_deconvoluted attributes to the mass features in the 1023 mass_features attribute of the LCMSBase object. 1024 1025 Raises 1026 ------ 1027 ValueError 1028 If no mass features are found, must run find_mass_features() first. 1029 If no EICs are found, did you run integrate_mass_features() first? 1030 1031 """ 1032 # Checks for set mass_features and eics 1033 if self.mass_features is None: 1034 raise ValueError( 1035 "No mass features found, did you run find_mass_features() first?" 1036 ) 1037 1038 if self.eics == {}: 1039 raise ValueError( 1040 "No EICs found, did you run integrate_mass_features() first?" 1041 ) 1042 1043 if 1 not in self._ms_unprocessed.keys(): 1044 raise ValueError("No unprocessed MS1 spectra found.") 1045 1046 # Prep ms1 data 1047 ms1_data = self._ms_unprocessed[1].copy() 1048 ms1_data = ms1_data.set_index("scan") 1049 1050 # Prep mass feature summary 1051 mass_feature_df = self.mass_features_to_df() 1052 1053 # Loop through each mass feature 1054 for mf_id, mass_feature in self.mass_features.items(): 1055 # Check that the mass_feature.mz attribute == the mz of the mass feature in the mass_feature_df 1056 if mass_feature.mz != mass_feature.ms1_peak.mz_exp: 1057 continue 1058 1059 # Get the left and right limits of the EIC of the mass feature 1060 l_scan, _, r_scan = mass_feature._eic_data.apexes[0] 1061 1062 # Pull from the _ms1_unprocessed data the scan range of interest and sort by mz 1063 ms1_data_sub = ms1_data.loc[l_scan:r_scan].copy() 1064 ms1_data_sub = ms1_data_sub.sort_values(by=["mz"]).reset_index(drop=False) 1065 1066 # Get the centroided masses of the mass feature 1067 mf_mspeak_mzs = mass_feature.mass_spectrum.mz_exp 1068 1069 # Find the closest mz in the ms1 data to the centroided masses of the mass feature 1070 ms1_data_sub["mass_feature_mz"] = mf_mspeak_mzs[ 1071 find_closest(mf_mspeak_mzs, ms1_data_sub.mz.values) 1072 ] 1073 1074 # Drop rows with mz_diff > 0.01 between the mass feature mz and the ms1 data mz 1075 ms1_data_sub["mz_diff_rel"] = ( 1076 np.abs(ms1_data_sub["mass_feature_mz"] - ms1_data_sub["mz"]) 1077 / ms1_data_sub["mz"] 1078 ) 1079 ms1_data_sub = ms1_data_sub[ 1080 ms1_data_sub["mz_diff_rel"] 1081 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 1082 ].reset_index(drop=True) 1083 1084 # Group by mass_feature_mz and scan and sum intensity 1085 ms1_data_sub_group = ( 1086 ms1_data_sub.groupby(["mass_feature_mz", "scan"])["intensity"] 1087 .sum() 1088 .reset_index() 1089 ) 1090 1091 # Calculate the correlation of the intensities of the mass feature and the ms1 data (set to 0 if no intensity) 1092 corr = ( 1093 ms1_data_sub_group.pivot( 1094 index="scan", columns="mass_feature_mz", values="intensity" 1095 ) 1096 .fillna(0) 1097 .corr() 1098 ) 1099 1100 # Subset the correlation matrix to only include the masses of the mass feature and those with a correlation > 0.8 1101 decon_corr_min = self.parameters.lc_ms.ms1_deconvolution_corr_min 1102 1103 # Try catch for KeyError in case the mass feature mz is not in the correlation matrix 1104 try: 1105 corr_subset = corr.loc[mass_feature.mz] 1106 except KeyError: 1107 # If the mass feature mz is not in the correlation matrix, skip to the next mass feature 1108 continue 1109 1110 corr_subset = corr_subset[corr_subset > decon_corr_min] 1111 1112 # Get the masses from the mass spectrum that are the result of the deconvolution 1113 mzs_decon = corr_subset.index.values 1114 1115 # Get the indices of the mzs_decon in mass_feature.mass_spectrum.mz_exp and assign to the mass feature 1116 mzs_decon_idx = [ 1117 id 1118 for id, mz in enumerate(mass_feature.mass_spectrum.mz_exp) 1119 if mz in mzs_decon 1120 ] 1121 mass_feature._ms_deconvoluted_idx = mzs_decon_idx 1122 1123 # Check if the mass feature's ms1 peak is the largest in the deconvoluted mass spectrum 1124 if ( 1125 mass_feature.ms1_peak.abundance 1126 == mass_feature.mass_spectrum.abundance[mzs_decon_idx].max() 1127 ): 1128 mass_feature.mass_spectrum_deconvoluted_parent = True 1129 else: 1130 mass_feature.mass_spectrum_deconvoluted_parent = False 1131 1132 # Check for other mass features that are in the deconvoluted mass spectrum and add the deconvoluted mass spectrum to the mass feature 1133 # Subset mass_feature_df to only include mass features that are within the clustering tolerance 1134 mass_feature_df_sub = mass_feature_df[ 1135 abs(mass_feature.retention_time - mass_feature_df["scan_time"]) 1136 < self.parameters.lc_ms.mass_feature_cluster_rt_tolerance 1137 ].copy() 1138 # Calculate the mz difference in ppm between the mass feature and the peaks in the deconvoluted mass spectrum 1139 mass_feature_df_sub["mz_diff_ppm"] = [ 1140 np.abs(mzs_decon - mz).min() / mz * 10**6 1141 for mz in mass_feature_df_sub["mz"] 1142 ] 1143 # Subset mass_feature_df to only include mass features that are within 1 ppm of the deconvoluted masses 1144 mfs_associated_decon = mass_feature_df_sub[ 1145 mass_feature_df_sub["mz_diff_ppm"] 1146 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 10**6 1147 ].index.values 1148 1149 mass_feature.associated_mass_features_deconvoluted = mfs_associated_decon
Deconvolute MS1 mass features
Deconvolute mass features ms1 spectrum based on the correlation of all masses within a spectrum over the EIC of the mass features
Parameters
- None
Returns
- None, but assigns the _ms_deconvoluted_idx, mass_spectrum_deconvoluted_parent,
- and associated_mass_features_deconvoluted attributes to the mass features in the
- mass_features attribute of the LCMSBase object.
Raises
- ValueError: If no mass features are found, must run find_mass_features() first. If no EICs are found, did you run integrate_mass_features() first?
1505class PHCalculations: 1506 """Methods for performing calculations related to 2D peak picking via persistent homology on LCMS data. 1507 1508 Notes 1509 ----- 1510 This class is intended to be used as a mixin for the LCMSBase class. 1511 1512 Methods 1513 ------- 1514 * sparse_mean_filter(idx, V, radius=[0, 1, 1]). 1515 Sparse implementation of a mean filter. 1516 * embed_unique_indices(a). 1517 Creates an array of indices, sorted by unique element. 1518 * sparse_upper_star(idx, V). 1519 Sparse implementation of an upper star filtration. 1520 * check_if_grid(data). 1521 Check if the data is gridded in mz space. 1522 * grid_data(data). 1523 Grid the data in the mz dimension. 1524 * find_mass_features_ph(ms_level=1, grid=True). 1525 Find mass features within an LCMSBase object using persistent homology. 1526 * cluster_mass_features(drop_children=True). 1527 Cluster regions of interest. 1528 """ 1529 1530 @staticmethod 1531 def sparse_mean_filter(idx, V, radius=[0, 1, 1]): 1532 """Sparse implementation of a mean filter. 1533 1534 Parameters 1535 ---------- 1536 idx : :obj:`~numpy.array` 1537 Edge indices for each dimension (MxN). 1538 V : :obj:`~numpy.array` 1539 Array of intensity data (Mx1). 1540 radius : float or list 1541 Radius of the sparse filter in each dimension. Values less than 1542 zero indicate no connectivity in that dimension. 1543 1544 Returns 1545 ------- 1546 :obj:`~numpy.array` 1547 Filtered intensities (Mx1). 1548 1549 Notes 1550 ----- 1551 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos. 1552 This is a static method. 1553 """ 1554 1555 # Copy indices 1556 idx = idx.copy().astype(V.dtype) 1557 1558 # Scale 1559 for i, r in enumerate(radius): 1560 # Increase inter-index distance 1561 if r < 1: 1562 idx[:, i] *= 2 1563 1564 # Do nothing 1565 elif r == 1: 1566 pass 1567 1568 # Decrease inter-index distance 1569 else: 1570 idx[:, i] /= r 1571 1572 # Connectivity matrix 1573 cmat = KDTree(idx) 1574 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1575 cmat.setdiag(1) 1576 1577 # Pair indices 1578 I, J = cmat.nonzero() 1579 1580 # Delete cmat 1581 cmat_shape = cmat.shape 1582 del cmat 1583 1584 # Sum over columns 1585 V_sum = sparse.bsr_matrix( 1586 (V[J], (I, I)), shape=cmat_shape, dtype=V.dtype 1587 ).diagonal(0) 1588 1589 # Count over columns 1590 V_count = sparse.bsr_matrix( 1591 (np.ones_like(J), (I, I)), shape=cmat_shape, dtype=V.dtype 1592 ).diagonal(0) 1593 1594 return V_sum / V_count 1595 1596 @staticmethod 1597 def embed_unique_indices(a): 1598 """Creates an array of indices, sorted by unique element. 1599 1600 Parameters 1601 ---------- 1602 a : :obj:`~numpy.array` 1603 Array of unique elements (Mx1). 1604 1605 Returns 1606 ------- 1607 :obj:`~numpy.array` 1608 Array of indices (Mx1). 1609 1610 Notes 1611 ----- 1612 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1613 This is a static method. 1614 """ 1615 1616 def count_tens(n): 1617 # Count tens 1618 ntens = (n - 1) // 10 1619 1620 while True: 1621 ntens_test = (ntens + n - 1) // 10 1622 1623 if ntens_test == ntens: 1624 return ntens 1625 else: 1626 ntens = ntens_test 1627 1628 def arange_exclude_10s(n): 1629 # How many 10s will there be? 1630 ntens = count_tens(n) 1631 1632 # Base array 1633 arr = np.arange(0, n + ntens) 1634 1635 # Exclude 10s 1636 arr = arr[(arr == 0) | (arr % 10 != 0)][:n] 1637 1638 return arr 1639 1640 # Creates an array of indices, sorted by unique element 1641 idx_sort = np.argsort(a) 1642 idx_unsort = np.argsort(idx_sort) 1643 1644 # Sorts records array so all unique elements are together 1645 sorted_a = a[idx_sort] 1646 1647 # Returns the unique values, the index of the first occurrence, 1648 # and the count for each element 1649 vals, idx_start, count = np.unique( 1650 sorted_a, return_index=True, return_counts=True 1651 ) 1652 1653 # Splits the indices into separate arrays 1654 splits = np.split(idx_sort, idx_start[1:]) 1655 1656 # Creates unique indices for each split 1657 idx_unq = np.concatenate([arange_exclude_10s(len(x)) for x in splits]) 1658 1659 # Reorders according to input array 1660 idx_unq = idx_unq[idx_unsort] 1661 1662 # Magnitude of each index 1663 exp = np.log10( 1664 idx_unq, where=idx_unq > 0, out=np.zeros_like(idx_unq, dtype=np.float64) 1665 ) 1666 idx_unq_mag = np.power(10, np.floor(exp) + 1) 1667 1668 # Result 1669 return a + idx_unq / idx_unq_mag 1670 1671 @staticmethod 1672 def _greedy_keep_mask(all_values, tol, relative): 1673 """Rows to keep, highest sort_by first. 1674 1675 ``all_values`` is aligned to a frame already sorted by ``sort_by`` 1676 descending. A later row is dropped when a kept row passes every 1677 dimension. Relative limits use the kept row. Values are compared 1678 as float32. No pair matrix is built. 1679 """ 1680 values = [np.asarray(col, dtype=np.float32) for col in all_values] 1681 n = len(values[0]) if values else 0 1682 keep = np.ones(n, dtype=bool) 1683 if n == 0 or any(float(t) < 0 for t in tol): 1684 return keep 1685 n_dims = len(values) 1686 grid = {} 1687 loose = [] 1688 1689 def bin_index(raw, tol_d, rel): 1690 fv = np.float32(raw) 1691 if float(tol_d) == 0.0: 1692 if fv == 0: 1693 fv = np.float32(0.0) 1694 return int(np.asarray(fv).view(np.int32)) 1695 if (not rel) and float(tol_d) > 0.0: 1696 return int(math.floor(float(fv) / float(tol_d))) 1697 if rel and 0.0 < float(tol_d) < 1.0 and float(fv) > 0.0: 1698 return int( 1699 math.floor(math.log(float(fv)) / math.log(1.0 + float(tol_d))) 1700 ) 1701 return None 1702 1703 def query_bins(raw, tol_d, rel): 1704 key = bin_index(raw, tol_d, rel) 1705 if key is None: 1706 return None 1707 if float(tol_d) == 0.0: 1708 return (key,) 1709 fv = float(np.float32(raw)) 1710 if not rel: 1711 width = float(tol_d) 1712 lo = int(math.floor((fv - width) / width)) - 1 1713 hi = int(math.floor((fv + width) / width)) + 1 1714 return range(lo, hi + 1) 1715 log_width = math.log(1.0 + float(tol_d)) 1716 lo_v = fv / (1.0 + float(tol_d)) 1717 hi_v = fv / (1.0 - float(tol_d)) 1718 if lo_v <= 0.0: 1719 return None 1720 lo = int(math.floor(math.log(lo_v) / log_width)) - 1 1721 hi = int(math.floor(math.log(hi_v) / log_width)) + 1 1722 return range(lo, hi + 1) 1723 1724 def claims(parent_i, child_i): 1725 for d in range(n_dims): 1726 pv = values[d][parent_i] 1727 cv = values[d][child_i] 1728 if not np.isfinite(pv) or not np.isfinite(cv): 1729 return False 1730 diff = np.float32(np.abs(np.float32(cv) - np.float32(pv))) 1731 if relative[d]: 1732 limit = np.float32(np.float32(tol[d]) * np.float32(pv)) 1733 else: 1734 limit = np.float32(tol[d]) 1735 if diff > limit: 1736 return False 1737 return True 1738 1739 for i in range(n): 1740 if not all(np.isfinite(values[d][i]) for d in range(n_dims)): 1741 continue 1742 bins = [ 1743 query_bins(values[d][i], tol[d], relative[d]) for d in range(n_dims) 1744 ] 1745 if any(bucket is None for bucket in bins): 1746 search = list(loose) 1747 for bucket in grid.values(): 1748 search.extend(bucket) 1749 else: 1750 search = [] 1751 for combo in product(*bins): 1752 search.extend(grid.get(combo, ())) 1753 search.extend(loose) 1754 if any(claims(parent_i, i) for parent_i in search): 1755 keep[i] = False 1756 continue 1757 keys = [bin_index(values[d][i], tol[d], relative[d]) for d in range(n_dims)] 1758 if any(key is None for key in keys): 1759 loose.append(i) 1760 else: 1761 grid.setdefault(tuple(keys), []).append(i) 1762 return keep 1763 1764 @staticmethod 1765 def roll_up_dataframe( 1766 df: pd.DataFrame, 1767 sort_by: str, 1768 tol: list, 1769 relative: list, 1770 dims: list, 1771 memory_opt_threshold: int = 10000, 1772 ): 1773 """Subset data by rolling up into apex in appropriate dimensions. 1774 1775 Parameters 1776 ---------- 1777 data : pd.DataFrame 1778 The input data containing "dims" columns and the "sort_by" column. 1779 sort_by : str 1780 The column to sort the data by, this will determine which mass features get rolled up into a parent mass feature 1781 (i.e., the mass feature with the highest value in the sort_by column). 1782 dims : list 1783 A list of dimension names (column names in the data DataFrame) to roll up the mass features by. 1784 tol : list 1785 A list of tolerances for each dimension. The length of the list must match the number of dimensions. 1786 The tolerances can be relative (as a fraction of the maximum value in that dimension) or absolute (in the units of that dimension). 1787 If relative is True, a kept row claims a later row when the absolute difference is at most tol times the kept row's value. 1788 If relative is False, the absolute difference must be at most tol. 1789 relative : list 1790 A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False). 1791 memory_opt_threshold : int, optional 1792 Accepted for compatibility. Ignored. Every frame uses the same greedy search. Default is 10000. 1793 1794 Returns 1795 ------- 1796 pd.DataFrame 1797 A DataFrame with only the rolled up mass features, with the original index and columns. 1798 1799 1800 Raises 1801 ------ 1802 ValueError 1803 If the input data is not a pandas DataFrame. 1804 If the input data does not have columns for each of the dimensions in "dims". 1805 If the length of "dims", "tol", and "relative" do not match. 1806 """ 1807 og_columns = df.columns.copy() 1808 1809 # Unindex the data, but keep the original index 1810 if df.index.name is not None: 1811 og_index = df.index.name 1812 else: 1813 og_index = "index" 1814 df = df.reset_index(drop=False) 1815 1816 # Sort data by sort_by column, and reindex 1817 df = df.sort_values(by=sort_by, ascending=False).reset_index(drop=True) 1818 1819 # Check that data is a DataFrame and has columns for each of the dims 1820 if not isinstance(df, pd.DataFrame): 1821 raise ValueError("Data must be a pandas DataFrame") 1822 for dim in dims: 1823 if dim not in df.columns: 1824 raise ValueError(f"Data must have a column for {dim}") 1825 if len(dims) != len(tol) or len(dims) != len(relative): 1826 raise ValueError( 1827 "Dimensions, tolerances, and relative flags must be the same length" 1828 ) 1829 1830 # Pre-compute all values arrays 1831 all_values = [df[dim].values for dim in dims] 1832 1833 keep = PHCalculations._greedy_keep_mask(all_values, tol, relative) 1834 df_sub = df.iloc[np.flatnonzero(keep)] 1835 1836 # Set index back to og_index and only keep original columns 1837 df_sub = df_sub.set_index(og_index).sort_index()[og_columns] 1838 1839 return df_sub 1840 1841 def sparse_upper_star(self, idx, V): 1842 """Sparse implementation of an upper star filtration. 1843 1844 Parameters 1845 ---------- 1846 idx : :obj:`~numpy.array` 1847 Edge indices for each dimension (MxN). 1848 V : :obj:`~numpy.array` 1849 Array of intensity data (Mx1). 1850 Returns 1851 ------- 1852 idx : :obj:`~numpy.array` 1853 Index of filtered points (Mx1). 1854 persistence : :obj:`~numpy.array` 1855 Persistence of each filtered point (Mx1). 1856 1857 Notes 1858 ----- 1859 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1860 """ 1861 1862 # Invert 1863 V = -1 * V.copy().astype(int) 1864 1865 # Embed indices 1866 V = self.embed_unique_indices(V) 1867 1868 # Connectivity matrix 1869 cmat = KDTree(idx) 1870 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1871 cmat.setdiag(1) 1872 cmat = sparse.triu(cmat) 1873 1874 # Pairwise minimums 1875 I, J = cmat.nonzero() 1876 d = np.maximum(V[I], V[J]) 1877 1878 # Delete connectiity matrix 1879 cmat_shape = cmat.shape 1880 del cmat 1881 1882 # Sparse distance matrix 1883 sdm = sparse.coo_matrix((d, (I, J)), shape=cmat_shape) 1884 1885 # Delete pairwise mins 1886 del d, I, J 1887 1888 # Persistence homology 1889 ph = ripser(sdm, distance_matrix=True, maxdim=0)["dgms"][0] 1890 1891 # Bound death values 1892 ph[ph[:, 1] == np.inf, 1] = np.max(V) 1893 1894 # Construct tree to query against 1895 tree = KDTree(V.reshape((-1, 1))) 1896 1897 # Get the indexes of the first nearest neighbor by birth 1898 _, nn = tree.query(ph[:, 0].reshape((-1, 1)), k=1, workers=-1) 1899 1900 return nn, -(ph[:, 0] // 1 - ph[:, 1] // 1) 1901 1902 def check_if_grid(self, data): 1903 """Check if the data are gridded in mz space. 1904 1905 Parameters 1906 ---------- 1907 data : DataFrame 1908 DataFrame containing the mass spectrometry data. Needs to have mz and scan columns. 1909 1910 Returns 1911 ------- 1912 bool 1913 True if the data is gridded in the mz direction, False otherwise. 1914 1915 Notes 1916 ----- 1917 This function is used within the grid_data function and the find_mass_features function and is not intended to be called directly. 1918 """ 1919 # Calculate the difference between consecutive mz values in a single scan 1920 dat_check = data.copy().reset_index(drop=True) 1921 dat_check["mz_diff"] = np.abs(dat_check["mz"].diff()) 1922 mz_diff_min = ( 1923 dat_check.groupby("scan")["mz_diff"].min().min() 1924 ) # within each scan, what is the smallest mz difference between consecutive mz values 1925 1926 # Find the mininum mz difference between mz values in the data; regardless of scan 1927 dat_check_mz = dat_check[["mz"]].drop_duplicates().copy() 1928 dat_check_mz = dat_check_mz.sort_values(by=["mz"]).reset_index(drop=True) 1929 dat_check_mz["mz_diff"] = np.abs(dat_check_mz["mz"].diff()) 1930 1931 # Get minimum mz_diff between mz values in the data 1932 mz_diff_min_raw = dat_check_mz["mz_diff"].min() 1933 1934 # If the minimum mz difference between mz values in the data is less than the minimum mz difference between mz values within a single scan, then the data is not gridded 1935 if mz_diff_min_raw < mz_diff_min: 1936 return False 1937 else: 1938 return True 1939 1940 def grid_data(self, data, attempts=5): 1941 """Grid the data in the mz dimension. 1942 1943 Data must be gridded prior to persistent homology calculations and computing average mass spectrum 1944 1945 Parameters 1946 ---------- 1947 data : DataFrame 1948 The input data containing mz, scan, scan_time, and intensity columns. 1949 attempts : int, optional 1950 The number of attempts to grid the data. Default is 5. 1951 1952 Returns 1953 ------- 1954 DataFrame 1955 The gridded data with mz, scan, scan_time, and intensity columns. 1956 1957 Raises 1958 ------ 1959 ValueError 1960 If gridding fails after the specified number of attempts. 1961 """ 1962 attempt_i = 0 1963 while attempt_i < attempts: 1964 attempt_i += 1 1965 data = self._grid_data(data) 1966 1967 if self.check_if_grid(data): 1968 return data 1969 1970 if not self.check_if_grid(data): 1971 raise ValueError( 1972 "Gridding failed after " 1973 + str(attempt_i) 1974 + " attempts. Please check the data." 1975 ) 1976 else: 1977 return data 1978 1979 def _grid_data(self, data): 1980 """Internal method to grid the data in the mz dimension. 1981 1982 Notes 1983 ----- 1984 This method is called by the grid_data method and should not be called directly. 1985 It will attempt to grid the data in the mz dimension by creating a grid of mz values based on the minimum mz difference within each scan, 1986 but it does not check if the data is already gridded or if the gridding is successful. 1987 1988 Parameters 1989 ---------- 1990 data : pd.DataFrame or pl.DataFrame 1991 The input data to grid. 1992 1993 Returns 1994 ------- 1995 pd.DataFrame or pl.DataFrame 1996 The data after attempting to grid it in the mz dimension. 1997 """ 1998 # Calculate the difference between consecutive mz values in a single scan for grid spacing 1999 data_w = data.copy().reset_index(drop=True) 2000 data_w["mz_diff"] = np.abs(data_w["mz"].diff()) 2001 mz_diff_min = data_w.groupby("scan")["mz_diff"].min().min() * 0.99999 2002 2003 # Need high intensity mz values first so they are parents in the output pairs stack 2004 dat_mz = data_w[["mz", "intensity"]].sort_values( 2005 by=["intensity"], ascending=False 2006 ) 2007 dat_mz = dat_mz[["mz"]].drop_duplicates().reset_index(drop=True).copy() 2008 # Same kept centers as the distance-pair grid. A 1D index replaces the 2009 # pair matrix, which grew with the square of the scan count. 2010 mz_dat_np = np.sort( 2011 _mz_grid_centers(dat_mz["mz"].to_numpy(dtype=np.float64), mz_diff_min) 2012 ) 2013 2014 # Sort data by mz and recast mz to nearest value in mz_dat_np 2015 data_w = data_w.sort_values(by=["mz"]).reset_index(drop=True).copy() 2016 data_w["mz_new"] = mz_dat_np[find_closest(mz_dat_np, data_w["mz"].values)] 2017 data_w["mz_diff"] = np.abs(data_w["mz"] - data_w["mz_new"]) 2018 2019 # Rename mz_new as mz; drop mz_diff; groupby scan and mz and sum intensity 2020 new_data_w = data_w.rename(columns={"mz": "mz_orig", "mz_new": "mz"}).copy() 2021 new_data_w = ( 2022 new_data_w.drop(columns=["mz_diff", "mz_orig"]) 2023 .groupby(["scan", "mz"])["intensity"] 2024 .sum() 2025 .reset_index() 2026 ) 2027 new_data_w = ( 2028 new_data_w.sort_values(by=["scan", "mz"], ascending=[True, True]) 2029 .reset_index(drop=True) 2030 .copy() 2031 ) 2032 2033 return new_data_w 2034 2035 def _filter_data_by_targets(self, data, target_search_dict): 2036 """Filter MS data to only include m/z and RT windows around target values. 2037 2038 Parameters 2039 ---------- 2040 data : pd.DataFrame 2041 MS data with 'mz' and 'scan_time' columns 2042 target_search_dict : dict 2043 Dictionary with target_mz_list, target_rt_list, mz_tolerance_ppm, rt_tolerance 2044 2045 Returns 2046 ------- 2047 pd.DataFrame 2048 Filtered data containing only points within target windows 2049 """ 2050 target_mz_list = target_search_dict["target_mz_list"] 2051 target_rt_list = target_search_dict["target_rt_list"] 2052 mz_tolerance_ppm = target_search_dict["mz_tolerance_ppm"] 2053 rt_tolerance = target_search_dict["rt_tolerance"] 2054 2055 # Create a mask for data points that fall within any target window 2056 mask = np.zeros(len(data), dtype=bool) 2057 2058 for target_mz, target_rt in zip(target_mz_list, target_rt_list): 2059 # Calculate m/z window 2060 mz_tol = target_mz * mz_tolerance_ppm / 1e6 2061 mz_min = target_mz - mz_tol 2062 mz_max = target_mz + mz_tol 2063 2064 # Calculate RT window 2065 rt_min = target_rt - rt_tolerance 2066 rt_max = target_rt + rt_tolerance 2067 2068 # Create mask for this target 2069 target_mask = ( 2070 (data["mz"] >= mz_min) 2071 & (data["mz"] <= mz_max) 2072 & (data["scan_time"] >= rt_min) 2073 & (data["scan_time"] <= rt_max) 2074 ) 2075 2076 # Combine with overall mask 2077 mask |= target_mask 2078 2079 return data[mask].reset_index(drop=True) 2080 2081 def find_mass_features_ph( 2082 self, 2083 ms_level=1, 2084 grid=True, 2085 targeted_search=False, 2086 target_search_dict=None, 2087 mf_type="untargeted", 2088 accumulate_features=False, 2089 ): 2090 """Find mass features within an LCMSBase object using persistent homology. 2091 2092 Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id) 2093 2094 Parameters 2095 ---------- 2096 ms_level : int, optional 2097 The MS level to use. Default is 1. 2098 grid : bool, optional 2099 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True. 2100 targeted_search : bool, optional 2101 If True, perform targeted search mode. Default is False. 2102 target_search_dict : dict or None, optional 2103 Dictionary with target parameters for targeted search. Default is None. 2104 mf_type : str, optional 2105 Type label for the mass features. Default is "untargeted". 2106 accumulate_features : bool, optional 2107 If True, add to existing features rather than replacing them. Default is False. 2108 2109 Raises 2110 ------ 2111 ValueError 2112 If no MS level data is found on the object. 2113 If data is not gridded and grid is False. 2114 2115 Returns 2116 ------- 2117 None, but assigns the mass_features attribute to the object. 2118 2119 Notes 2120 ----- 2121 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 2122 """ 2123 # Check that ms_level is a key in self._ms_uprocessed 2124 if ms_level not in self._ms_unprocessed.keys(): 2125 raise ValueError( 2126 "No MS level " 2127 + str(ms_level) 2128 + " data found, did you instantiate with parser specific to MS level?" 2129 ) 2130 2131 # Get ms data 2132 data = self._ms_unprocessed[ms_level].copy() 2133 2134 # Drop rows with missing intensity values and reset index 2135 data = data.dropna(subset=["intensity"]).reset_index(drop=True) 2136 2137 # Add scan_time for filtering if in targeted mode 2138 if targeted_search: 2139 data = data.merge( 2140 self.scan_df[["scan", "scan_time"]], on="scan", how="left" 2141 ) 2142 2143 # Threshold data (bypass thresholds in targeted mode) 2144 dims = ["mz", "scan_time"] 2145 if targeted_search: 2146 # In targeted mode, bypass intensity and persistence thresholds 2147 threshold = 0 2148 persistence_threshold = 0 2149 # Filter data to only target windows 2150 data_thres = self._filter_data_by_targets(data, target_search_dict) 2151 if len(data_thres) == 0: 2152 if self.parameters.lc_ms.verbose_processing: 2153 print("No data found in target windows") 2154 self.mass_features = {} 2155 return 2156 else: 2157 threshold = max( 2158 self.parameters.lc_ms.ph_inten_min_rel * data.intensity.max(), 2159 self.parameters.lc_ms.ph_inten_min_abs, 2160 ) 2161 persistence_threshold = ( 2162 self.parameters.lc_ms.ph_persis_min_rel * data.intensity.max() 2163 ) 2164 data_thres = ( 2165 data[data["intensity"] > threshold].reset_index(drop=True).copy() 2166 ) 2167 2168 # One m/z grid for the whole run. A separate grid in each scan window 2169 # moves the apex of the same ion. 2170 if not self.check_if_grid(data_thres): 2171 if grid is False: 2172 raise ValueError( 2173 "Data are not gridded in mz dimension, try reprocessing with a different params or grid data before running this function" 2174 ) 2175 data_thres = self.grid_data(data_thres) 2176 if "scan_time" not in data_thres.columns: 2177 data_thres = data_thres.merge( 2178 self.scan_df[["scan", "scan_time"]], on="scan" 2179 ) 2180 2181 return self._find_mass_features_ph_partition( 2182 data_thres, 2183 dims, 2184 persistence_threshold, 2185 mf_type, 2186 accumulate_features, 2187 ) 2188 2189 def _find_mass_features_ph_partition( 2190 self, 2191 data_thres, 2192 dims, 2193 persistence_threshold, 2194 mf_type="untargeted", 2195 accumulate_features=False, 2196 ): 2197 """Find mass features on overlapping scan partitions of one m/z grid. 2198 2199 Splits the already gridded input into overlapping scan partitions, 2200 detects mass features with persistent homology, rolls up duplicates 2201 within and across partitions, and populates mass_features. 2202 2203 Parameters 2204 ---------- 2205 data_thres : pd.DataFrame 2206 The thresholded input data containing mass spectrometry information. 2207 dims : list 2208 List of dimension names (e.g., ["mz", "scan_time"]) used for feature detection. 2209 persistence_threshold : float 2210 Minimum persistence value required for a detected mass feature to be retained. 2211 mf_type : str, optional 2212 Type label for the mass features. Default is "untargeted". 2213 accumulate_features : bool, optional 2214 If True, add to existing features rather than replacing them. Default is False. 2215 2216 Returns 2217 ------- 2218 None 2219 Populates the mass_features attribute of the object with detected mass features. 2220 """ 2221 all_mass_features = [] 2222 2223 # Split scans into partitions 2224 unique_scans = sorted(data_thres["scan"].unique()) 2225 unique_scans_n = len(unique_scans) 2226 if unique_scans_n == 0: 2227 if not accumulate_features: 2228 self.mass_features = {} 2229 return 2230 2231 # Calculate partition size in scans based on goal 2232 partition_size_goal = 5000 2233 scans_per_partition = max( 2234 1, partition_size_goal // (len(data_thres) // unique_scans_n) 2235 ) 2236 2237 # Make partitions based on scans, with overlapping in partitioned scans 2238 scan_overlap = 4 2239 partition_scans = [] 2240 for i in range(0, unique_scans_n, scans_per_partition): 2241 start_idx = max(0, i - scan_overlap) 2242 end_idx = min( 2243 unique_scans_n - 1, i + scans_per_partition - 1 + scan_overlap 2244 ) 2245 scans_group = [int(s) for s in unique_scans[start_idx : end_idx + 1]] 2246 partition_scans.append(scans_group) 2247 2248 # Set index to scan for faster filtering 2249 data_thres = data_thres.set_index("scan") 2250 for scans in partition_scans: 2251 # Determine start and end scan for partition, with 5 scans overlap 2252 partition_data = data_thres.loc[scans].reset_index(drop=False).copy() 2253 2254 if len(partition_data) == 0: 2255 continue 2256 2257 if "scan_time" not in partition_data.columns: 2258 partition_data = partition_data.merge( 2259 self.scan_df[["scan", "scan_time"]], on="scan" 2260 ) 2261 2262 # Build factors for this partition 2263 factors = { 2264 dim: pd.factorize(partition_data[dim], sort=True)[1].astype(np.float32) 2265 for dim in dims 2266 } 2267 2268 # Build indexes 2269 index = { 2270 dim: np.searchsorted(factors[dim], partition_data[dim]).astype( 2271 np.float32 2272 ) 2273 for dim in factors 2274 } 2275 2276 # Process partition 2277 partition_features = self._process_partition_ph( 2278 partition_data, index, dims, persistence_threshold 2279 ) 2280 2281 if len(partition_features) == 0: 2282 continue 2283 2284 # Roll up within partition 2285 partition_features = self.roll_up_dataframe( 2286 df=partition_features, 2287 sort_by="persistence", 2288 dims=["mz", "scan_time"], 2289 tol=[ 2290 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2291 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2292 ], 2293 relative=[True, False], 2294 ) 2295 partition_features = partition_features.reset_index(drop=True) 2296 2297 if len(partition_features) > 0: 2298 all_mass_features.append(partition_features) 2299 2300 # Combine results from all partitions 2301 if all_mass_features: 2302 combined_features = pd.concat(all_mass_features, ignore_index=True) 2303 2304 # Sort by persistence 2305 combined_features = combined_features.sort_values( 2306 by="persistence", ascending=False 2307 ).reset_index(drop=True) 2308 2309 # Remove duplicates from overlapping regions 2310 combined_features = self.roll_up_dataframe( 2311 df=combined_features, 2312 sort_by="persistence", 2313 dims=["mz", "scan_time"], 2314 tol=[ 2315 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2316 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2317 ], 2318 relative=[True, False], 2319 ) 2320 2321 # resort by persistence and reset index 2322 combined_features = combined_features.reset_index(drop=True) 2323 2324 # Populate mass_features attribute 2325 self._populate_mass_features( 2326 combined_features, mf_type, accumulate_features 2327 ) 2328 else: 2329 self.mass_features = {} 2330 2331 def _process_partition_ph(self, partition_data, index, dims, persistence_threshold): 2332 """Process a single partition with persistent homology.""" 2333 # Smooth data 2334 iterations = self.parameters.lc_ms.ph_smooth_it 2335 smooth_radius = [ 2336 self.parameters.lc_ms.ph_smooth_radius_mz, 2337 self.parameters.lc_ms.ph_smooth_radius_scan, 2338 ] 2339 2340 index_array = np.vstack([index[dim] for dim in dims]).T 2341 V = partition_data["intensity"].values 2342 resid = np.inf 2343 2344 for i in range(iterations): 2345 # Previous iteration 2346 V_prev = V.copy() 2347 resid_prev = resid 2348 V = self.sparse_mean_filter(index_array, V, radius=smooth_radius) 2349 2350 # Calculate residual with previous iteration 2351 resid = np.sqrt(np.mean(np.square(V - V_prev))) 2352 2353 # Evaluate convergence 2354 if i > 0: 2355 # Percent change in residual 2356 test = np.abs(resid - resid_prev) / resid_prev 2357 2358 # Exit criteria 2359 if test <= 0: 2360 break 2361 2362 # Overwrite values 2363 partition_data = partition_data.copy() 2364 partition_data["intensity"] = V 2365 2366 # Use persistent homology to find regions of interest 2367 pidx, pers = self.sparse_upper_star(index_array, V) 2368 pidx = pidx[pers > 1] 2369 pers = pers[pers > 1] 2370 2371 if len(pidx) == 0: 2372 return pd.DataFrame() 2373 2374 # Get peaks 2375 peaks = partition_data.iloc[pidx, :].reset_index(drop=True) 2376 2377 # Add persistence column 2378 peaks["persistence"] = pers 2379 mass_features = peaks.sort_values( 2380 by="persistence", ascending=False 2381 ).reset_index(drop=True) 2382 2383 # Filter by persistence threshold 2384 mass_features = mass_features.loc[ 2385 mass_features["persistence"] > persistence_threshold, : 2386 ].reset_index(drop=True) 2387 2388 return mass_features 2389 2390 def _populate_mass_features( 2391 self, mass_features_df, mf_type="untargeted", accumulate_features=False 2392 ): 2393 """Populate the mass_features attribute from a DataFrame. 2394 2395 Parameters 2396 ---------- 2397 mass_features_df : pd.DataFrame 2398 DataFrame containing mass feature information. 2399 Note that the order of this DataFrame will determine the order of mass features in the mass_features attribute. 2400 mf_type : str, optional 2401 Type label for the mass features. Default is "untargeted". 2402 accumulate_features : bool, optional 2403 If True, new features will be added to existing features rather than replacing them. 2404 Mass feature IDs will be offset to avoid conflicts. Default is False. 2405 2406 Returns 2407 ------- 2408 None, but assigns or updates the mass_features attribute to the object. 2409 """ 2410 # Rename scan column to apex_scan 2411 mass_features_df = mass_features_df.rename( 2412 columns={"scan": "apex_scan", "scan_time": "retention_time"} 2413 ) 2414 2415 # Initialize or preserve existing mass_features attribute 2416 if ( 2417 accumulate_features 2418 and self.mass_features is not None 2419 and len(self.mass_features) > 0 2420 ): 2421 # Find the maximum existing ID to offset new IDs and avoid conflicts 2422 id_offset = max(self.mass_features.keys()) + 1 2423 initial_count = len(self.mass_features) 2424 else: 2425 # Replace mode (default/backwards compatible) 2426 self.mass_features = {} 2427 id_offset = 0 2428 initial_count = 0 2429 2430 # Add new mass features 2431 for idx, row in enumerate(mass_features_df.itertuples()): 2432 row_dict = mass_features_df.iloc[row.Index].to_dict() 2433 lcms_feature = LCMSMassFeature(self, **row_dict) 2434 lcms_feature.type = mf_type 2435 # Use sequential ID starting from id_offset to avoid conflicts with existing features 2436 new_id = idx + id_offset 2437 lcms_feature._id = new_id # Update the internal ID 2438 self.mass_features[new_id] = lcms_feature 2439 2440 if self.parameters.lc_ms.verbose_processing: 2441 if accumulate_features and initial_count > 0: 2442 print( 2443 f"Found {len(mass_features_df)} new mass features (total: {len(self.mass_features)})" 2444 ) 2445 else: 2446 print("Found " + str(len(mass_features_df)) + " initial mass features") 2447 2448 def find_mass_features_ph_centroid( 2449 self, 2450 ms_level=1, 2451 targeted_search=False, 2452 target_search_dict=None, 2453 mf_type="untargeted", 2454 accumulate_features=False, 2455 ): 2456 """Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data. 2457 2458 Parameters 2459 ---------- 2460 ms_level : int, optional 2461 The MS level to use. Default is 1. 2462 targeted_search : bool, optional 2463 If True, perform targeted search mode. Default is False. 2464 target_search_dict : dict or None, optional 2465 Dictionary with target parameters for targeted search. Default is None. 2466 mf_type : str, optional 2467 Type label for the mass features. Default is "untargeted". 2468 accumulate_features : bool, optional 2469 If True, add to existing features rather than replacing them. Default is False. 2470 2471 Raises 2472 ------ 2473 ValueError 2474 If no MS level data is found on the object. 2475 2476 Returns 2477 ------- 2478 None, but assigns the mass_features attribute to the object. 2479 """ 2480 # Check that ms_level is a key in self._ms_uprocessed 2481 if ms_level not in self._ms_unprocessed.keys(): 2482 raise ValueError( 2483 "No MS level " 2484 + str(ms_level) 2485 + " data found, did you instantiate with parser specific to MS level?" 2486 ) 2487 2488 # Work with reference instead of copy 2489 data = self._ms_unprocessed[ms_level] 2490 2491 # Merge with scan data first (needed for filtering in targeted mode) 2492 scan_subset = self.scan_df[["scan", "scan_time"]] 2493 data_with_time = data.merge(scan_subset, on="scan", how="inner") 2494 2495 # Calculate threshold and filter (bypass in targeted mode) 2496 if targeted_search: 2497 # In targeted mode, bypass intensity threshold 2498 threshold = 0 2499 valid_mask = data_with_time["intensity"].notna() 2500 required_cols = ["mz", "intensity", "scan", "scan_time"] 2501 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2502 2503 # Filter to target windows 2504 data_thres = self._filter_data_by_targets(data_thres, target_search_dict) 2505 2506 if len(data_thres) == 0: 2507 if self.parameters.lc_ms.verbose_processing: 2508 print("No data found in target windows") 2509 self.mass_features = {} 2510 return 2511 else: 2512 # Normal mode with threshold 2513 max_intensity = data_with_time["intensity"].max() 2514 threshold = max( 2515 self.parameters.lc_ms.ph_inten_min_rel * max_intensity, 2516 self.parameters.lc_ms.ph_inten_min_abs, 2517 ) 2518 valid_mask = data_with_time["intensity"].notna() & ( 2519 data_with_time["intensity"] > threshold 2520 ) 2521 required_cols = ["mz", "intensity", "scan", "scan_time"] 2522 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2523 2524 data_thres["persistence"] = data_thres["intensity"] 2525 mf_df = data_thres 2526 del data_thres, scan_subset, data_with_time 2527 2528 # Order by scan_time and then mz to ensure features near in rt are processed together 2529 # It's ok that different scans are in different partitions; we will roll up later 2530 mf_df = mf_df.sort_values( 2531 by=["scan_time", "mz"], ascending=[True, True] 2532 ).reset_index(drop=True) 2533 partition_size = 10000 2534 partitions = [ 2535 mf_df.iloc[i : i + partition_size].reset_index(drop=True) 2536 for i in range(0, len(mf_df), partition_size) 2537 ] 2538 del mf_df 2539 2540 # Run roll_up_dataframe on each partition 2541 rolled_partitions = [] 2542 for part in partitions: 2543 rolled = self.roll_up_dataframe( 2544 df=part, 2545 sort_by="persistence", 2546 dims=["mz", "scan_time"], 2547 tol=[ 2548 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2549 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2550 ], 2551 relative=[True, False], 2552 ) 2553 rolled_partitions.append(rolled) 2554 del partitions 2555 2556 # Run roll_up_dataframe on the rolled_up partitions to merge features near partition boundaries 2557 2558 # Combine results and run a final roll-up to merge features near partition boundaries 2559 mf_df_final = pd.concat(rolled_partitions, ignore_index=True) 2560 del rolled_partitions 2561 2562 # Reorder by persistence before final roll-up 2563 mf_df_final = mf_df_final.sort_values( 2564 by="persistence", ascending=False 2565 ).reset_index(drop=True) 2566 2567 mf_df_final = self.roll_up_dataframe( 2568 df=mf_df_final, 2569 sort_by="persistence", 2570 dims=["mz", "scan_time"], 2571 tol=[ 2572 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2573 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2574 ], 2575 relative=[True, False], 2576 ) 2577 # reset index 2578 mf_df_final = mf_df_final.reset_index(drop=True) 2579 2580 # Combine rename and sort operations 2581 mass_features = ( 2582 mf_df_final.rename( 2583 columns={"scan": "apex_scan", "scan_time": "retention_time"} 2584 ) 2585 .sort_values(by="persistence", ascending=False) 2586 .reset_index(drop=True) 2587 ) 2588 del mf_df_final # Free memory 2589 2590 # Order by persistence and reset index 2591 mass_features = mass_features.sort_values( 2592 by="persistence", ascending=False 2593 ).reset_index(drop=True) 2594 2595 self.mass_features = {} 2596 for idx, row in mass_features.iterrows(): 2597 row_dict = row.to_dict() 2598 lcms_feature = LCMSMassFeature(self, **row_dict) 2599 lcms_feature.type = mf_type 2600 self.mass_features[lcms_feature.id] = lcms_feature 2601 2602 if self.parameters.lc_ms.verbose_processing: 2603 print("Found " + str(len(mass_features)) + " initial mass features") 2604 2605 def cluster_mass_features(self, drop_children=True, sort_by="persistence"): 2606 """Cluster mass features 2607 2608 Based on their proximity in the mz and scan_time dimensions, priorizies the mass features with the highest persistence. 2609 2610 Parameters 2611 ---------- 2612 drop_children : bool, optional 2613 Whether to drop the mass features that are not cluster parents. Default is True. 2614 sort_by : str, optional 2615 The column to sort the mass features by, this will determine which mass features get rolled up into a parent mass feature. Default is "persistence". 2616 2617 Raises 2618 ------ 2619 ValueError 2620 If no mass features are found. 2621 If too many mass features are found. 2622 2623 Returns 2624 ------- 2625 None if drop_children is True, otherwise returns a list of mass feature ids that are not cluster parents. 2626 """ 2627 if self.mass_features is None: 2628 raise ValueError("No mass features found, run find_mass_features() first") 2629 if len(self.mass_features) > 400000: 2630 raise ValueError( 2631 "Too many mass features of interest found, run find_mass_features() with a higher intensity threshold" 2632 ) 2633 dims = ["mz", "scan_time"] 2634 mf_df_og = self.mass_features_to_df() 2635 mf_df = mf_df_og.copy() 2636 2637 tol = [ 2638 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2639 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2640 ] # mz, in relative; scan_time in minutes 2641 relative = [True, False] 2642 2643 # Roll up mass features based on their proximity in the declared dimensions 2644 mf_df_new = self.roll_up_dataframe( 2645 df=mf_df, sort_by=sort_by, dims=dims, tol=tol, relative=relative 2646 ) 2647 2648 mf_df["cluster_parent"] = np.where( 2649 np.isin(mf_df.index, mf_df_new.index), True, False 2650 ) 2651 2652 # get mass feature ids of features that are not cluster parents 2653 cluster_daughters = mf_df[~mf_df["cluster_parent"]].index.values 2654 if drop_children is True: 2655 # Drop mass features that are not cluster parents from self 2656 self.mass_features = { 2657 k: v 2658 for k, v in self.mass_features.items() 2659 if k not in cluster_daughters 2660 } 2661 else: 2662 return cluster_daughters
Methods for performing calculations related to 2D peak picking via persistent homology on LCMS data.
Notes
This class is intended to be used as a mixin for the LCMSBase class.
Methods
- sparse_mean_filter(idx, V, radius=[0, 1, 1]). Sparse implementation of a mean filter.
- embed_unique_indices(a). Creates an array of indices, sorted by unique element.
- sparse_upper_star(idx, V). Sparse implementation of an upper star filtration.
- check_if_grid(data). Check if the data is gridded in mz space.
- grid_data(data). Grid the data in the mz dimension.
- find_mass_features_ph(ms_level=1, grid=True). Find mass features within an LCMSBase object using persistent homology.
- cluster_mass_features(drop_children=True). Cluster regions of interest.
1530 @staticmethod 1531 def sparse_mean_filter(idx, V, radius=[0, 1, 1]): 1532 """Sparse implementation of a mean filter. 1533 1534 Parameters 1535 ---------- 1536 idx : :obj:`~numpy.array` 1537 Edge indices for each dimension (MxN). 1538 V : :obj:`~numpy.array` 1539 Array of intensity data (Mx1). 1540 radius : float or list 1541 Radius of the sparse filter in each dimension. Values less than 1542 zero indicate no connectivity in that dimension. 1543 1544 Returns 1545 ------- 1546 :obj:`~numpy.array` 1547 Filtered intensities (Mx1). 1548 1549 Notes 1550 ----- 1551 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos. 1552 This is a static method. 1553 """ 1554 1555 # Copy indices 1556 idx = idx.copy().astype(V.dtype) 1557 1558 # Scale 1559 for i, r in enumerate(radius): 1560 # Increase inter-index distance 1561 if r < 1: 1562 idx[:, i] *= 2 1563 1564 # Do nothing 1565 elif r == 1: 1566 pass 1567 1568 # Decrease inter-index distance 1569 else: 1570 idx[:, i] /= r 1571 1572 # Connectivity matrix 1573 cmat = KDTree(idx) 1574 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1575 cmat.setdiag(1) 1576 1577 # Pair indices 1578 I, J = cmat.nonzero() 1579 1580 # Delete cmat 1581 cmat_shape = cmat.shape 1582 del cmat 1583 1584 # Sum over columns 1585 V_sum = sparse.bsr_matrix( 1586 (V[J], (I, I)), shape=cmat_shape, dtype=V.dtype 1587 ).diagonal(0) 1588 1589 # Count over columns 1590 V_count = sparse.bsr_matrix( 1591 (np.ones_like(J), (I, I)), shape=cmat_shape, dtype=V.dtype 1592 ).diagonal(0) 1593 1594 return V_sum / V_count
Sparse implementation of a mean filter.
Parameters
- idx (
~numpy.array): Edge indices for each dimension (MxN). - V (
~numpy.array): Array of intensity data (Mx1). - radius (float or list): Radius of the sparse filter in each dimension. Values less than zero indicate no connectivity in that dimension.
Returns
~numpy.array: Filtered intensities (Mx1).
Notes
This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos. This is a static method.
1596 @staticmethod 1597 def embed_unique_indices(a): 1598 """Creates an array of indices, sorted by unique element. 1599 1600 Parameters 1601 ---------- 1602 a : :obj:`~numpy.array` 1603 Array of unique elements (Mx1). 1604 1605 Returns 1606 ------- 1607 :obj:`~numpy.array` 1608 Array of indices (Mx1). 1609 1610 Notes 1611 ----- 1612 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1613 This is a static method. 1614 """ 1615 1616 def count_tens(n): 1617 # Count tens 1618 ntens = (n - 1) // 10 1619 1620 while True: 1621 ntens_test = (ntens + n - 1) // 10 1622 1623 if ntens_test == ntens: 1624 return ntens 1625 else: 1626 ntens = ntens_test 1627 1628 def arange_exclude_10s(n): 1629 # How many 10s will there be? 1630 ntens = count_tens(n) 1631 1632 # Base array 1633 arr = np.arange(0, n + ntens) 1634 1635 # Exclude 10s 1636 arr = arr[(arr == 0) | (arr % 10 != 0)][:n] 1637 1638 return arr 1639 1640 # Creates an array of indices, sorted by unique element 1641 idx_sort = np.argsort(a) 1642 idx_unsort = np.argsort(idx_sort) 1643 1644 # Sorts records array so all unique elements are together 1645 sorted_a = a[idx_sort] 1646 1647 # Returns the unique values, the index of the first occurrence, 1648 # and the count for each element 1649 vals, idx_start, count = np.unique( 1650 sorted_a, return_index=True, return_counts=True 1651 ) 1652 1653 # Splits the indices into separate arrays 1654 splits = np.split(idx_sort, idx_start[1:]) 1655 1656 # Creates unique indices for each split 1657 idx_unq = np.concatenate([arange_exclude_10s(len(x)) for x in splits]) 1658 1659 # Reorders according to input array 1660 idx_unq = idx_unq[idx_unsort] 1661 1662 # Magnitude of each index 1663 exp = np.log10( 1664 idx_unq, where=idx_unq > 0, out=np.zeros_like(idx_unq, dtype=np.float64) 1665 ) 1666 idx_unq_mag = np.power(10, np.floor(exp) + 1) 1667 1668 # Result 1669 return a + idx_unq / idx_unq_mag
Creates an array of indices, sorted by unique element.
Parameters
- a (
~numpy.array): Array of unique elements (Mx1).
Returns
~numpy.array: Array of indices (Mx1).
Notes
This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos This is a static method.
1764 @staticmethod 1765 def roll_up_dataframe( 1766 df: pd.DataFrame, 1767 sort_by: str, 1768 tol: list, 1769 relative: list, 1770 dims: list, 1771 memory_opt_threshold: int = 10000, 1772 ): 1773 """Subset data by rolling up into apex in appropriate dimensions. 1774 1775 Parameters 1776 ---------- 1777 data : pd.DataFrame 1778 The input data containing "dims" columns and the "sort_by" column. 1779 sort_by : str 1780 The column to sort the data by, this will determine which mass features get rolled up into a parent mass feature 1781 (i.e., the mass feature with the highest value in the sort_by column). 1782 dims : list 1783 A list of dimension names (column names in the data DataFrame) to roll up the mass features by. 1784 tol : list 1785 A list of tolerances for each dimension. The length of the list must match the number of dimensions. 1786 The tolerances can be relative (as a fraction of the maximum value in that dimension) or absolute (in the units of that dimension). 1787 If relative is True, a kept row claims a later row when the absolute difference is at most tol times the kept row's value. 1788 If relative is False, the absolute difference must be at most tol. 1789 relative : list 1790 A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False). 1791 memory_opt_threshold : int, optional 1792 Accepted for compatibility. Ignored. Every frame uses the same greedy search. Default is 10000. 1793 1794 Returns 1795 ------- 1796 pd.DataFrame 1797 A DataFrame with only the rolled up mass features, with the original index and columns. 1798 1799 1800 Raises 1801 ------ 1802 ValueError 1803 If the input data is not a pandas DataFrame. 1804 If the input data does not have columns for each of the dimensions in "dims". 1805 If the length of "dims", "tol", and "relative" do not match. 1806 """ 1807 og_columns = df.columns.copy() 1808 1809 # Unindex the data, but keep the original index 1810 if df.index.name is not None: 1811 og_index = df.index.name 1812 else: 1813 og_index = "index" 1814 df = df.reset_index(drop=False) 1815 1816 # Sort data by sort_by column, and reindex 1817 df = df.sort_values(by=sort_by, ascending=False).reset_index(drop=True) 1818 1819 # Check that data is a DataFrame and has columns for each of the dims 1820 if not isinstance(df, pd.DataFrame): 1821 raise ValueError("Data must be a pandas DataFrame") 1822 for dim in dims: 1823 if dim not in df.columns: 1824 raise ValueError(f"Data must have a column for {dim}") 1825 if len(dims) != len(tol) or len(dims) != len(relative): 1826 raise ValueError( 1827 "Dimensions, tolerances, and relative flags must be the same length" 1828 ) 1829 1830 # Pre-compute all values arrays 1831 all_values = [df[dim].values for dim in dims] 1832 1833 keep = PHCalculations._greedy_keep_mask(all_values, tol, relative) 1834 df_sub = df.iloc[np.flatnonzero(keep)] 1835 1836 # Set index back to og_index and only keep original columns 1837 df_sub = df_sub.set_index(og_index).sort_index()[og_columns] 1838 1839 return df_sub
Subset data by rolling up into apex in appropriate dimensions.
Parameters
- data (pd.DataFrame): The input data containing "dims" columns and the "sort_by" column.
- sort_by (str): The column to sort the data by, this will determine which mass features get rolled up into a parent mass feature (i.e., the mass feature with the highest value in the sort_by column).
- dims (list): A list of dimension names (column names in the data DataFrame) to roll up the mass features by.
- tol (list): A list of tolerances for each dimension. The length of the list must match the number of dimensions. The tolerances can be relative (as a fraction of the maximum value in that dimension) or absolute (in the units of that dimension). If relative is True, a kept row claims a later row when the absolute difference is at most tol times the kept row's value. If relative is False, the absolute difference must be at most tol.
- relative (list): A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False).
- memory_opt_threshold (int, optional): Accepted for compatibility. Ignored. Every frame uses the same greedy search. Default is 10000.
Returns
- pd.DataFrame: A DataFrame with only the rolled up mass features, with the original index and columns.
Raises
- ValueError: If the input data is not a pandas DataFrame. If the input data does not have columns for each of the dimensions in "dims". If the length of "dims", "tol", and "relative" do not match.
1841 def sparse_upper_star(self, idx, V): 1842 """Sparse implementation of an upper star filtration. 1843 1844 Parameters 1845 ---------- 1846 idx : :obj:`~numpy.array` 1847 Edge indices for each dimension (MxN). 1848 V : :obj:`~numpy.array` 1849 Array of intensity data (Mx1). 1850 Returns 1851 ------- 1852 idx : :obj:`~numpy.array` 1853 Index of filtered points (Mx1). 1854 persistence : :obj:`~numpy.array` 1855 Persistence of each filtered point (Mx1). 1856 1857 Notes 1858 ----- 1859 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1860 """ 1861 1862 # Invert 1863 V = -1 * V.copy().astype(int) 1864 1865 # Embed indices 1866 V = self.embed_unique_indices(V) 1867 1868 # Connectivity matrix 1869 cmat = KDTree(idx) 1870 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1871 cmat.setdiag(1) 1872 cmat = sparse.triu(cmat) 1873 1874 # Pairwise minimums 1875 I, J = cmat.nonzero() 1876 d = np.maximum(V[I], V[J]) 1877 1878 # Delete connectiity matrix 1879 cmat_shape = cmat.shape 1880 del cmat 1881 1882 # Sparse distance matrix 1883 sdm = sparse.coo_matrix((d, (I, J)), shape=cmat_shape) 1884 1885 # Delete pairwise mins 1886 del d, I, J 1887 1888 # Persistence homology 1889 ph = ripser(sdm, distance_matrix=True, maxdim=0)["dgms"][0] 1890 1891 # Bound death values 1892 ph[ph[:, 1] == np.inf, 1] = np.max(V) 1893 1894 # Construct tree to query against 1895 tree = KDTree(V.reshape((-1, 1))) 1896 1897 # Get the indexes of the first nearest neighbor by birth 1898 _, nn = tree.query(ph[:, 0].reshape((-1, 1)), k=1, workers=-1) 1899 1900 return nn, -(ph[:, 0] // 1 - ph[:, 1] // 1)
Sparse implementation of an upper star filtration.
Parameters
- idx (
~numpy.array): Edge indices for each dimension (MxN). - V (
~numpy.array): Array of intensity data (Mx1).
Returns
- idx (
~numpy.array): Index of filtered points (Mx1). - persistence (
~numpy.array): Persistence of each filtered point (Mx1).
Notes
This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
1902 def check_if_grid(self, data): 1903 """Check if the data are gridded in mz space. 1904 1905 Parameters 1906 ---------- 1907 data : DataFrame 1908 DataFrame containing the mass spectrometry data. Needs to have mz and scan columns. 1909 1910 Returns 1911 ------- 1912 bool 1913 True if the data is gridded in the mz direction, False otherwise. 1914 1915 Notes 1916 ----- 1917 This function is used within the grid_data function and the find_mass_features function and is not intended to be called directly. 1918 """ 1919 # Calculate the difference between consecutive mz values in a single scan 1920 dat_check = data.copy().reset_index(drop=True) 1921 dat_check["mz_diff"] = np.abs(dat_check["mz"].diff()) 1922 mz_diff_min = ( 1923 dat_check.groupby("scan")["mz_diff"].min().min() 1924 ) # within each scan, what is the smallest mz difference between consecutive mz values 1925 1926 # Find the mininum mz difference between mz values in the data; regardless of scan 1927 dat_check_mz = dat_check[["mz"]].drop_duplicates().copy() 1928 dat_check_mz = dat_check_mz.sort_values(by=["mz"]).reset_index(drop=True) 1929 dat_check_mz["mz_diff"] = np.abs(dat_check_mz["mz"].diff()) 1930 1931 # Get minimum mz_diff between mz values in the data 1932 mz_diff_min_raw = dat_check_mz["mz_diff"].min() 1933 1934 # If the minimum mz difference between mz values in the data is less than the minimum mz difference between mz values within a single scan, then the data is not gridded 1935 if mz_diff_min_raw < mz_diff_min: 1936 return False 1937 else: 1938 return True
Check if the data are gridded in mz space.
Parameters
- data (DataFrame): DataFrame containing the mass spectrometry data. Needs to have mz and scan columns.
Returns
- bool: True if the data is gridded in the mz direction, False otherwise.
Notes
This function is used within the grid_data function and the find_mass_features function and is not intended to be called directly.
1940 def grid_data(self, data, attempts=5): 1941 """Grid the data in the mz dimension. 1942 1943 Data must be gridded prior to persistent homology calculations and computing average mass spectrum 1944 1945 Parameters 1946 ---------- 1947 data : DataFrame 1948 The input data containing mz, scan, scan_time, and intensity columns. 1949 attempts : int, optional 1950 The number of attempts to grid the data. Default is 5. 1951 1952 Returns 1953 ------- 1954 DataFrame 1955 The gridded data with mz, scan, scan_time, and intensity columns. 1956 1957 Raises 1958 ------ 1959 ValueError 1960 If gridding fails after the specified number of attempts. 1961 """ 1962 attempt_i = 0 1963 while attempt_i < attempts: 1964 attempt_i += 1 1965 data = self._grid_data(data) 1966 1967 if self.check_if_grid(data): 1968 return data 1969 1970 if not self.check_if_grid(data): 1971 raise ValueError( 1972 "Gridding failed after " 1973 + str(attempt_i) 1974 + " attempts. Please check the data." 1975 ) 1976 else: 1977 return data
Grid the data in the mz dimension.
Data must be gridded prior to persistent homology calculations and computing average mass spectrum
Parameters
- data (DataFrame): The input data containing mz, scan, scan_time, and intensity columns.
- attempts (int, optional): The number of attempts to grid the data. Default is 5.
Returns
- DataFrame: The gridded data with mz, scan, scan_time, and intensity columns.
Raises
- ValueError: If gridding fails after the specified number of attempts.
2081 def find_mass_features_ph( 2082 self, 2083 ms_level=1, 2084 grid=True, 2085 targeted_search=False, 2086 target_search_dict=None, 2087 mf_type="untargeted", 2088 accumulate_features=False, 2089 ): 2090 """Find mass features within an LCMSBase object using persistent homology. 2091 2092 Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id) 2093 2094 Parameters 2095 ---------- 2096 ms_level : int, optional 2097 The MS level to use. Default is 1. 2098 grid : bool, optional 2099 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True. 2100 targeted_search : bool, optional 2101 If True, perform targeted search mode. Default is False. 2102 target_search_dict : dict or None, optional 2103 Dictionary with target parameters for targeted search. Default is None. 2104 mf_type : str, optional 2105 Type label for the mass features. Default is "untargeted". 2106 accumulate_features : bool, optional 2107 If True, add to existing features rather than replacing them. Default is False. 2108 2109 Raises 2110 ------ 2111 ValueError 2112 If no MS level data is found on the object. 2113 If data is not gridded and grid is False. 2114 2115 Returns 2116 ------- 2117 None, but assigns the mass_features attribute to the object. 2118 2119 Notes 2120 ----- 2121 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 2122 """ 2123 # Check that ms_level is a key in self._ms_uprocessed 2124 if ms_level not in self._ms_unprocessed.keys(): 2125 raise ValueError( 2126 "No MS level " 2127 + str(ms_level) 2128 + " data found, did you instantiate with parser specific to MS level?" 2129 ) 2130 2131 # Get ms data 2132 data = self._ms_unprocessed[ms_level].copy() 2133 2134 # Drop rows with missing intensity values and reset index 2135 data = data.dropna(subset=["intensity"]).reset_index(drop=True) 2136 2137 # Add scan_time for filtering if in targeted mode 2138 if targeted_search: 2139 data = data.merge( 2140 self.scan_df[["scan", "scan_time"]], on="scan", how="left" 2141 ) 2142 2143 # Threshold data (bypass thresholds in targeted mode) 2144 dims = ["mz", "scan_time"] 2145 if targeted_search: 2146 # In targeted mode, bypass intensity and persistence thresholds 2147 threshold = 0 2148 persistence_threshold = 0 2149 # Filter data to only target windows 2150 data_thres = self._filter_data_by_targets(data, target_search_dict) 2151 if len(data_thres) == 0: 2152 if self.parameters.lc_ms.verbose_processing: 2153 print("No data found in target windows") 2154 self.mass_features = {} 2155 return 2156 else: 2157 threshold = max( 2158 self.parameters.lc_ms.ph_inten_min_rel * data.intensity.max(), 2159 self.parameters.lc_ms.ph_inten_min_abs, 2160 ) 2161 persistence_threshold = ( 2162 self.parameters.lc_ms.ph_persis_min_rel * data.intensity.max() 2163 ) 2164 data_thres = ( 2165 data[data["intensity"] > threshold].reset_index(drop=True).copy() 2166 ) 2167 2168 # One m/z grid for the whole run. A separate grid in each scan window 2169 # moves the apex of the same ion. 2170 if not self.check_if_grid(data_thres): 2171 if grid is False: 2172 raise ValueError( 2173 "Data are not gridded in mz dimension, try reprocessing with a different params or grid data before running this function" 2174 ) 2175 data_thres = self.grid_data(data_thres) 2176 if "scan_time" not in data_thres.columns: 2177 data_thres = data_thres.merge( 2178 self.scan_df[["scan", "scan_time"]], on="scan" 2179 ) 2180 2181 return self._find_mass_features_ph_partition( 2182 data_thres, 2183 dims, 2184 persistence_threshold, 2185 mf_type, 2186 accumulate_features, 2187 )
Find mass features within an LCMSBase object using persistent homology.
Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id)
Parameters
- ms_level (int, optional): The MS level to use. Default is 1.
- grid (bool, optional): If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True.
- targeted_search (bool, optional): If True, perform targeted search mode. Default is False.
- target_search_dict (dict or None, optional): Dictionary with target parameters for targeted search. Default is None.
- mf_type (str, optional): Type label for the mass features. Default is "untargeted".
- accumulate_features (bool, optional): If True, add to existing features rather than replacing them. Default is False.
Raises
- ValueError: If no MS level data is found on the object. If data is not gridded and grid is False.
Returns
- None, but assigns the mass_features attribute to the object.
Notes
This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
2448 def find_mass_features_ph_centroid( 2449 self, 2450 ms_level=1, 2451 targeted_search=False, 2452 target_search_dict=None, 2453 mf_type="untargeted", 2454 accumulate_features=False, 2455 ): 2456 """Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data. 2457 2458 Parameters 2459 ---------- 2460 ms_level : int, optional 2461 The MS level to use. Default is 1. 2462 targeted_search : bool, optional 2463 If True, perform targeted search mode. Default is False. 2464 target_search_dict : dict or None, optional 2465 Dictionary with target parameters for targeted search. Default is None. 2466 mf_type : str, optional 2467 Type label for the mass features. Default is "untargeted". 2468 accumulate_features : bool, optional 2469 If True, add to existing features rather than replacing them. Default is False. 2470 2471 Raises 2472 ------ 2473 ValueError 2474 If no MS level data is found on the object. 2475 2476 Returns 2477 ------- 2478 None, but assigns the mass_features attribute to the object. 2479 """ 2480 # Check that ms_level is a key in self._ms_uprocessed 2481 if ms_level not in self._ms_unprocessed.keys(): 2482 raise ValueError( 2483 "No MS level " 2484 + str(ms_level) 2485 + " data found, did you instantiate with parser specific to MS level?" 2486 ) 2487 2488 # Work with reference instead of copy 2489 data = self._ms_unprocessed[ms_level] 2490 2491 # Merge with scan data first (needed for filtering in targeted mode) 2492 scan_subset = self.scan_df[["scan", "scan_time"]] 2493 data_with_time = data.merge(scan_subset, on="scan", how="inner") 2494 2495 # Calculate threshold and filter (bypass in targeted mode) 2496 if targeted_search: 2497 # In targeted mode, bypass intensity threshold 2498 threshold = 0 2499 valid_mask = data_with_time["intensity"].notna() 2500 required_cols = ["mz", "intensity", "scan", "scan_time"] 2501 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2502 2503 # Filter to target windows 2504 data_thres = self._filter_data_by_targets(data_thres, target_search_dict) 2505 2506 if len(data_thres) == 0: 2507 if self.parameters.lc_ms.verbose_processing: 2508 print("No data found in target windows") 2509 self.mass_features = {} 2510 return 2511 else: 2512 # Normal mode with threshold 2513 max_intensity = data_with_time["intensity"].max() 2514 threshold = max( 2515 self.parameters.lc_ms.ph_inten_min_rel * max_intensity, 2516 self.parameters.lc_ms.ph_inten_min_abs, 2517 ) 2518 valid_mask = data_with_time["intensity"].notna() & ( 2519 data_with_time["intensity"] > threshold 2520 ) 2521 required_cols = ["mz", "intensity", "scan", "scan_time"] 2522 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2523 2524 data_thres["persistence"] = data_thres["intensity"] 2525 mf_df = data_thres 2526 del data_thres, scan_subset, data_with_time 2527 2528 # Order by scan_time and then mz to ensure features near in rt are processed together 2529 # It's ok that different scans are in different partitions; we will roll up later 2530 mf_df = mf_df.sort_values( 2531 by=["scan_time", "mz"], ascending=[True, True] 2532 ).reset_index(drop=True) 2533 partition_size = 10000 2534 partitions = [ 2535 mf_df.iloc[i : i + partition_size].reset_index(drop=True) 2536 for i in range(0, len(mf_df), partition_size) 2537 ] 2538 del mf_df 2539 2540 # Run roll_up_dataframe on each partition 2541 rolled_partitions = [] 2542 for part in partitions: 2543 rolled = self.roll_up_dataframe( 2544 df=part, 2545 sort_by="persistence", 2546 dims=["mz", "scan_time"], 2547 tol=[ 2548 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2549 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2550 ], 2551 relative=[True, False], 2552 ) 2553 rolled_partitions.append(rolled) 2554 del partitions 2555 2556 # Run roll_up_dataframe on the rolled_up partitions to merge features near partition boundaries 2557 2558 # Combine results and run a final roll-up to merge features near partition boundaries 2559 mf_df_final = pd.concat(rolled_partitions, ignore_index=True) 2560 del rolled_partitions 2561 2562 # Reorder by persistence before final roll-up 2563 mf_df_final = mf_df_final.sort_values( 2564 by="persistence", ascending=False 2565 ).reset_index(drop=True) 2566 2567 mf_df_final = self.roll_up_dataframe( 2568 df=mf_df_final, 2569 sort_by="persistence", 2570 dims=["mz", "scan_time"], 2571 tol=[ 2572 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2573 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2574 ], 2575 relative=[True, False], 2576 ) 2577 # reset index 2578 mf_df_final = mf_df_final.reset_index(drop=True) 2579 2580 # Combine rename and sort operations 2581 mass_features = ( 2582 mf_df_final.rename( 2583 columns={"scan": "apex_scan", "scan_time": "retention_time"} 2584 ) 2585 .sort_values(by="persistence", ascending=False) 2586 .reset_index(drop=True) 2587 ) 2588 del mf_df_final # Free memory 2589 2590 # Order by persistence and reset index 2591 mass_features = mass_features.sort_values( 2592 by="persistence", ascending=False 2593 ).reset_index(drop=True) 2594 2595 self.mass_features = {} 2596 for idx, row in mass_features.iterrows(): 2597 row_dict = row.to_dict() 2598 lcms_feature = LCMSMassFeature(self, **row_dict) 2599 lcms_feature.type = mf_type 2600 self.mass_features[lcms_feature.id] = lcms_feature 2601 2602 if self.parameters.lc_ms.verbose_processing: 2603 print("Found " + str(len(mass_features)) + " initial mass features")
Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data.
Parameters
- ms_level (int, optional): The MS level to use. Default is 1.
- targeted_search (bool, optional): If True, perform targeted search mode. Default is False.
- target_search_dict (dict or None, optional): Dictionary with target parameters for targeted search. Default is None.
- mf_type (str, optional): Type label for the mass features. Default is "untargeted".
- accumulate_features (bool, optional): If True, add to existing features rather than replacing them. Default is False.
Raises
- ValueError: If no MS level data is found on the object.
Returns
- None, but assigns the mass_features attribute to the object.
2605 def cluster_mass_features(self, drop_children=True, sort_by="persistence"): 2606 """Cluster mass features 2607 2608 Based on their proximity in the mz and scan_time dimensions, priorizies the mass features with the highest persistence. 2609 2610 Parameters 2611 ---------- 2612 drop_children : bool, optional 2613 Whether to drop the mass features that are not cluster parents. Default is True. 2614 sort_by : str, optional 2615 The column to sort the mass features by, this will determine which mass features get rolled up into a parent mass feature. Default is "persistence". 2616 2617 Raises 2618 ------ 2619 ValueError 2620 If no mass features are found. 2621 If too many mass features are found. 2622 2623 Returns 2624 ------- 2625 None if drop_children is True, otherwise returns a list of mass feature ids that are not cluster parents. 2626 """ 2627 if self.mass_features is None: 2628 raise ValueError("No mass features found, run find_mass_features() first") 2629 if len(self.mass_features) > 400000: 2630 raise ValueError( 2631 "Too many mass features of interest found, run find_mass_features() with a higher intensity threshold" 2632 ) 2633 dims = ["mz", "scan_time"] 2634 mf_df_og = self.mass_features_to_df() 2635 mf_df = mf_df_og.copy() 2636 2637 tol = [ 2638 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2639 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2640 ] # mz, in relative; scan_time in minutes 2641 relative = [True, False] 2642 2643 # Roll up mass features based on their proximity in the declared dimensions 2644 mf_df_new = self.roll_up_dataframe( 2645 df=mf_df, sort_by=sort_by, dims=dims, tol=tol, relative=relative 2646 ) 2647 2648 mf_df["cluster_parent"] = np.where( 2649 np.isin(mf_df.index, mf_df_new.index), True, False 2650 ) 2651 2652 # get mass feature ids of features that are not cluster parents 2653 cluster_daughters = mf_df[~mf_df["cluster_parent"]].index.values 2654 if drop_children is True: 2655 # Drop mass features that are not cluster parents from self 2656 self.mass_features = { 2657 k: v 2658 for k, v in self.mass_features.items() 2659 if k not in cluster_daughters 2660 } 2661 else: 2662 return cluster_daughters
Cluster mass features
Based on their proximity in the mz and scan_time dimensions, priorizies the mass features with the highest persistence.
Parameters
- drop_children (bool, optional): Whether to drop the mass features that are not cluster parents. Default is True.
- sort_by (str, optional): The column to sort the mass features by, this will determine which mass features get rolled up into a parent mass feature. Default is "persistence".
Raises
- ValueError: If no mass features are found. If too many mass features are found.
Returns
- None if drop_children is True, otherwise returns a list of mass feature ids that are not cluster parents.
2665class LCMSCollectionCalculations: 2666 """Methods for performing calculations related to LCMSCollection objects. 2667 2668 Notes 2669 ----- 2670 This class is intended as a mixin for the LCMSCollection class. 2671 """ 2672 2673 @staticmethod 2674 def _resolve_eic_query_mz(row): 2675 """Pick m/z used to look up ``sample.eics`` for a mass-feature row. 2676 2677 Prefer ``_eic_mz`` when present (set after integrate / gap-fill). 2678 Regular features in ``mass_features_dataframe`` often have NaN 2679 ``_eic_mz`` (never back-filled from objects on light load / reload), 2680 while induced features get ``_eic_mz`` written after gap-fill. Fall 2681 back to the feature ``mz`` so regular and representative traces are 2682 not silently dropped from multi-sample EIC plots. 2683 """ 2684 if row is None: 2685 return None 2686 eic_mz = row.get("_eic_mz") if hasattr(row, "get") else None 2687 if eic_mz is not None and not pd.isna(eic_mz): 2688 return eic_mz 2689 mz = row.get("mz") if hasattr(row, "get") else None 2690 if mz is not None and not pd.isna(mz): 2691 return mz 2692 return None 2693 2694 @staticmethod 2695 def _get_eic_data_for_mz(sample, eic_mz, tolerance=0.0001): 2696 """Resolve EIC data for an m/z using exact key, then tolerance match. 2697 2698 Plotting previously used ``sample.eics.get(eic_mz)`` only. HDF5 EIC 2699 keys and feature ``_eic_mz`` / ``mz`` often differ by float noise, which 2700 silently dropped traces (including the representative). Elsewhere 2701 CoreMS uses the same default tolerance via 2702 ``get_eic_mz_for_mass_feature`` / ``associate_eics_with_mass_features``. 2703 2704 Parameters 2705 ---------- 2706 sample : LCMSBase 2707 Sample that holds ``eics`` (dict keyed by m/z). 2708 eic_mz : float or None 2709 Target m/z (typically from the mass-feature ``_eic_mz`` or ``mz``). 2710 tolerance : float, optional 2711 Maximum |Δm/z| for fallback matching. Default is 0.0001 Da. 2712 2713 Returns 2714 ------- 2715 EIC_Data or None 2716 Matching EIC data, or None if no key is within tolerance. 2717 """ 2718 if eic_mz is None or pd.isna(eic_mz): 2719 return None 2720 if not hasattr(sample, "eics") or not sample.eics: 2721 return None 2722 2723 eic_data = sample.eics.get(eic_mz) 2724 if eic_data is not None: 2725 return eic_data 2726 2727 # Exact key miss: tolerance match (same default as associate_eics_with_mass_features) 2728 if hasattr(sample, "get_eic_mz_for_mass_feature"): 2729 matched_mz = sample.get_eic_mz_for_mass_feature( 2730 float(eic_mz), tolerance=tolerance 2731 ) 2732 if matched_mz is not None: 2733 return sample.eics.get(matched_mz) 2734 return None 2735 2736 # Fallback if sample is a simple mock without the helper 2737 best_key = min(sample.eics, key=lambda k: abs(float(k) - float(eic_mz))) 2738 if abs(float(best_key) - float(eic_mz)) < tolerance: 2739 return sample.eics[best_key] 2740 return None 2741 2742 @staticmethod 2743 def _plot_multiple_eics( 2744 ax, 2745 cluster_mfs, 2746 induced_cluster_mfs, 2747 rep_sample_id, 2748 rep_mf_id, 2749 median_rt, 2750 eic_buffer_time, 2751 plot_smoothed=False, 2752 plot_datapoints=False, 2753 label_samples=False, 2754 lcms_collection=None, 2755 ): 2756 """Internal method to plot multiple EICs from different samples on a given axis. 2757 2758 Parameters 2759 ---------- 2760 ax : matplotlib.axes.Axes 2761 The axis to plot on. 2762 cluster_mfs : pd.DataFrame 2763 DataFrame containing cluster mass features (non-induced). 2764 induced_cluster_mfs : pd.DataFrame or None 2765 DataFrame containing induced (gap-filled) mass features. 2766 rep_sample_id : int 2767 Sample ID of the representative mass feature. 2768 rep_mf_id : int 2769 Mass feature ID of the representative mass feature. 2770 median_rt : float 2771 Median retention time for the cluster. 2772 eic_buffer_time : float 2773 Time buffer around the peak (minutes). 2774 plot_smoothed : bool, optional 2775 If True, plot smoothed EICs. Default is False. 2776 plot_datapoints : bool, optional 2777 If True, plot EIC datapoints. Default is False. 2778 label_samples : bool, optional 2779 If True, label each sample individually. Default is False. 2780 lcms_collection : LCMSCollection, optional 2781 The parent collection object for accessing samples. Required. 2782 """ 2783 ax.set_title("EICs from all samples", loc="left") 2784 2785 # Track if we've added labels for legend (to avoid duplicates) 2786 rep_labeled = False 2787 regular_labeled = False 2788 induced_labeled = False 2789 2790 # Plot regular (non-induced) mass features 2791 for _, row in cluster_mfs.iterrows(): 2792 sample_id = int(row["sample_id"]) 2793 mf_id = row["mf_id"] 2794 sample = lcms_collection[sample_id] 2795 sample_name = row["sample_name"] 2796 2797 # Prefer _eic_mz, else feature mz; then exact/tolerance key match (#257) 2798 eic_data = LCMSCollectionCalculations._get_eic_data_for_mz( 2799 sample, LCMSCollectionCalculations._resolve_eic_query_mz(row) 2800 ) 2801 2802 if eic_data: 2803 # Determine line style and width 2804 if sample_id == rep_sample_id and mf_id == rep_mf_id: 2805 # Representative feature - bold line 2806 linewidth = 2.5 2807 alpha = 1.0 2808 color = "tab:blue" 2809 if label_samples: 2810 label = f"{sample_name} (representative)" 2811 else: 2812 label = "Representative" if not rep_labeled else None 2813 rep_labeled = True 2814 else: 2815 # Other features - thinner line 2816 linewidth = 1.0 2817 alpha = 0.5 2818 color = "tab:blue" 2819 if label_samples: 2820 label = sample_name 2821 else: 2822 label = "Regular features" if not regular_labeled else None 2823 regular_labeled = True 2824 2825 ax.plot( 2826 eic_data.time, 2827 eic_data.eic, 2828 c=color, 2829 linewidth=linewidth, 2830 alpha=alpha, 2831 linestyle="-", 2832 label=label, 2833 ) 2834 2835 if plot_datapoints: 2836 ax.scatter(eic_data.time, eic_data.eic, c=color, alpha=alpha, s=10) 2837 2838 if plot_smoothed and hasattr(eic_data, "eic_smoothed"): 2839 ax.plot( 2840 eic_data.time, 2841 eic_data.eic_smoothed, 2842 c=color, 2843 linestyle="--", 2844 alpha=alpha * 0.8, 2845 linewidth=linewidth * 0.8, 2846 ) 2847 2848 # Plot induced (gap-filled) mass features if available 2849 if induced_cluster_mfs is not None and not induced_cluster_mfs.empty: 2850 for _, row in induced_cluster_mfs.iterrows(): 2851 sample_id = int(row["sample_id"]) 2852 mf_id = row["mf_id"] 2853 sample = lcms_collection[sample_id] 2854 sample_name = row["sample_name"] 2855 2856 # Prefer _eic_mz, else feature mz; then exact/tolerance key match (#257) 2857 eic_data = LCMSCollectionCalculations._get_eic_data_for_mz( 2858 sample, LCMSCollectionCalculations._resolve_eic_query_mz(row) 2859 ) 2860 2861 if eic_data: 2862 # Induced features - even thinner line 2863 linewidth = 0.5 2864 alpha = 0.4 2865 color = "tab:orange" 2866 2867 if label_samples: 2868 label = f"{sample_name} (induced)" 2869 else: 2870 label = "Gap-filled features" if not induced_labeled else None 2871 induced_labeled = True 2872 2873 ax.plot( 2874 eic_data.time, 2875 eic_data.eic, 2876 c=color, 2877 linewidth=linewidth, 2878 alpha=alpha, 2879 linestyle="-", 2880 label=label, 2881 ) 2882 2883 if plot_datapoints: 2884 ax.scatter( 2885 eic_data.time, eic_data.eic, c=color, alpha=alpha, s=5 2886 ) 2887 2888 if plot_smoothed and hasattr(eic_data, "eic_smoothed"): 2889 ax.plot( 2890 eic_data.time, 2891 eic_data.eic_smoothed, 2892 c=color, 2893 linestyle="--", 2894 alpha=alpha * 0.8, 2895 linewidth=linewidth * 0.8, 2896 ) 2897 2898 # Add vertical line at median RT 2899 ax.axvline(x=median_rt, color="k", linestyle="--", alpha=0.7, label="Median RT") 2900 2901 ax.set_ylabel("Intensity") 2902 ax.set_xlabel("Time (minutes)") 2903 ax.set_xlim( 2904 median_rt - eic_buffer_time, 2905 median_rt + eic_buffer_time, 2906 ) 2907 ax.legend(loc="upper left", fontsize=8) 2908 ax.yaxis.get_major_formatter().set_useOffset(False) 2909 2910 def clean_sparse_matrix(self, sparse_matrix): 2911 """Clean a sparse matrix by removing duplicates and sorting. 2912 2913 Parameters 2914 ---------- 2915 sparse_matrix : :obj:`~numpy.array` 2916 A sparse matrix to clean. 2917 2918 Returns 2919 ------- 2920 :obj:`~numpy.array` 2921 A cleaned sparse matrix. 2922 """ 2923 for match in sparse_matrix: 2924 match.sort() 2925 sparse_matrix.sort() 2926 dereplicated_sparse_matrix = np.unique(sparse_matrix, axis=0) 2927 return dereplicated_sparse_matrix 2928 2929 def match_mfs(self, mf_c, mf_i): 2930 """Match mass features between two LCMS objects. 2931 2932 Parameters 2933 ---------- 2934 mf_c : :obj:`~pandas.DataFrame` 2935 The mass features to match against. 2936 mf_i : :obj:`~pandas.DataFrame` 2937 The mass features to match. 2938 2939 Returns 2940 ------- 2941 :obj:`~pandas.DataFrame` 2942 The matched mass features from mf_c. 2943 :obj:`~pandas.DataFrame` 2944 The matched mass features from mf_i. 2945 2946 Notes 2947 ----- 2948 This function has been adapted from the original implementation in the Deimos package: 2949 https://github.com/pnnl/deimos 2950 """ 2951 if mf_c is None or mf_i is None or len(mf_c.index) < 1 or len(mf_i.index) < 1: 2952 return None, None 2953 2954 # Prepare dataframes 2955 mf_c = mf_c.copy() 2956 mf_c["id_i"] = 0 2957 mf_i = mf_i.copy() 2958 mf_i["id_i"] = 1 2959 2960 # Set dimensions for matching 2961 dims = ["mz", "scan_time"] 2962 relative = [True, False] 2963 mz_tol = self.parameters.lcms_collection.alignment_mz_tol_ppm * 1e-6 2964 rt_tol = self.parameters.lcms_collection.alignment_rt_tol 2965 tol = [mz_tol, rt_tol] 2966 2967 # Compute inter-feature distances 2968 idx = [] 2969 for i, f in enumerate(dims): 2970 # vectors 2971 v1 = mf_c[f].values.reshape(-1, 1) 2972 v2 = mf_i[f].values.reshape(-1, 1) 2973 2974 # Distances 2975 d = scipy.spatial.distance.cdist(v1, v2) 2976 2977 if relative[i] is True: 2978 # Divisor 2979 basis = np.repeat(v1, v2.shape[0], axis=1) 2980 fix = np.repeat(v2, v1.shape[0], axis=1).T 2981 basis = np.where(basis == 0, fix, basis) 2982 2983 # Divide 2984 d = np.divide(d, basis, out=np.zeros_like(basis), where=basis != 0) 2985 2986 # Check tol 2987 idx.append(d <= tol[i]) 2988 2989 # Stack truth arrays 2990 idx = np.prod(np.dstack(idx), axis=-1, dtype=bool) 2991 2992 # Compute normalized 3d distance 2993 v1 = mf_c[dims].values / tol 2994 v2 = mf_i[dims].values / tol 2995 dist3d = scipy.spatial.distance.cdist(v1, v2, "cityblock") 2996 2997 # Separate features within tolerance from those outside 2998 # Features outside tolerance should be inf, features within tolerance keep their distance 2999 # Use idx mask: True for within tolerance, False for outside 3000 dist3d_within_tol = np.where(idx, dist3d, np.inf) 3001 3002 # Normalize to 0-1 (only affects within-tolerance distances) 3003 mx = np.max(dist3d_within_tol[idx]) if np.sum(idx) > 0 else 0 3004 if mx > 0: 3005 # Lower distance is better - normalize only the within-tolerance values 3006 dist3d_within_tol = np.where(idx, dist3d_within_tol / mx, np.inf) 3007 else: 3008 # All matches are perfect (distance=0), assign tiny value to within-tolerance pairs 3009 dist3d_within_tol = np.where(idx, 1e-10, np.inf) 3010 3011 # Use the masked distance matrix 3012 dist3d = dist3d_within_tol 3013 3014 # Min over dims 3015 mincols = np.min(dist3d, axis=0, keepdims=True) 3016 3017 # Zero out mincols over dims 3018 dist3d[dist3d != mincols] = np.inf 3019 3020 # Min over clusters 3021 minrows = np.min(dist3d, axis=1, keepdims=True) 3022 3023 # Where max and nonzero 3024 ii, jj = np.where((dist3d == minrows) & (dist3d < np.inf)) 3025 3026 # Reorder 3027 mf_c = mf_c.iloc[ii] 3028 mf_i = mf_i.iloc[jj] 3029 3030 if len(mf_c.index) < 1 or len(mf_i.index) < 1: 3031 return None, None 3032 3033 return mf_c, mf_i 3034 3035 def fit_rts(self, a, b, align="scan_time", **kwargs): 3036 """ 3037 Fit a support vector regressor to matched features. 3038 3039 Parameters 3040 ---------- 3041 a : :obj:`~pandas.DataFrame` 3042 First set of input feature coordinates and intensities; the center object and the object to align to. 3043 b : :obj:`~pandas.DataFrame` 3044 Second set of input feature coordinates and intensities; the object to align to the center object. 3045 align : str 3046 Dimension to align. 3047 kwargs 3048 Keyword arguments for support vector regressor 3049 (:class:`sklearn.svm.SVR`). 3050 3051 Returns 3052 ------- 3053 :obj:`~function` 3054 An interpolation function where one can input a retention time and get the predicted retention time. 3055 3056 Notes 3057 ----- 3058 This function has been adapted from the original implementation in the Deimos package: 3059 https://github.com/pnnl/deimos 3060 3061 """ 3062 3063 # Uniqueify 3064 x = a[align].values 3065 y = b[align].values 3066 arr = np.vstack((x, y)).T 3067 arr = np.unique(arr, axis=0) 3068 3069 # Safety check: ensure we have data to work with 3070 if len(arr) == 0: 3071 warnings.warn( 3072 "No data points available for retention time fitting. Returning identity function." 3073 ) 3074 return lambda x: x 3075 3076 # Check kwargs 3077 if "kernel" in kwargs: 3078 kernel = kwargs.get("kernel") 3079 else: 3080 kernel = "linear" 3081 3082 # Construct interpolation axis 3083 newx = np.linspace(arr[:, 0].min(), arr[:, 0].max(), 1000) 3084 3085 # Linear kernel 3086 if kernel == "linear": 3087 reg = scipy.stats.linregress(x, y) 3088 newy = reg.slope * newx + reg.intercept 3089 3090 # Other kernels 3091 else: 3092 # Fit 3093 svr = SVR(**kwargs) 3094 svr.fit(arr[:, 1].reshape(-1, 1), arr[:, 0]) 3095 3096 # Predict 3097 newy = svr.predict(newx.reshape(-1, 1)) 3098 3099 # Pad x and y_pred with zeros to force interpolation to start at 0 3100 newx = np.concatenate(([0], newx)) 3101 newy = np.concatenate(([0], newy)) 3102 3103 # Pad x and y_pred with max time to force interpolation to end at max time to force interpolation to match at end max time 3104 max_time = self[0].scan_df["scan_time"].max() 3105 newx = np.concatenate((newx, [max_time])) 3106 newy = np.concatenate((newy, [max_time])) 3107 3108 # Return an interpolation function for the x and y_pred 3109 def interp(x): 3110 pred_y = np.interp(x, newx, newy) 3111 return pred_y 3112 3113 return interp 3114 3115 def get_anchor_mass_features(self, mf_df): 3116 """ 3117 Get the anchor mass features from a DataFrame of mass features. 3118 3119 Parameters 3120 ---------- 3121 mf_df : :obj:`~pandas.DataFrame` 3122 The mass features to filter to just the anchor mass features. 3123 3124 Returns 3125 ------- 3126 :obj:`~pandas.DataFrame` 3127 The anchor mass features dataframe. 3128 """ 3129 mf_df = mf_df.copy() 3130 3131 if ( 3132 "deconvoluted_mass_spectra" 3133 in self.parameters.lcms_collection.mass_feature_anchor_technique 3134 ): 3135 # Drop features that are not mass_spectrum_deconvoluted_parent or are NA as mass_spectrum_deconvoluted_parent 3136 mf_df = mf_df.dropna(subset=["mass_spectrum_deconvoluted_parent"]) 3137 mf_df = mf_df[mf_df["mass_spectrum_deconvoluted_parent"]] 3138 3139 if ( 3140 "absolute_intensity" 3141 in self.parameters.lcms_collection.mass_feature_anchor_technique 3142 ): 3143 # Drop features that have an intensity lower than the threshold 3144 threshold = self.parameters.lcms_collection.mass_feature_anchor_absolute_intensity_threshold 3145 mf_df = mf_df[mf_df["intensity"] > threshold] 3146 3147 if ( 3148 "relative_intensity" 3149 in self.parameters.lcms_collection.mass_feature_anchor_technique 3150 ): 3151 # Drop features in the lower fraction of intensities 3152 threshold_quantile = self.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold 3153 intensity_threshold = mf_df["intensity"].quantile(threshold_quantile) 3154 mf_df = mf_df[mf_df["intensity"] >= intensity_threshold] 3155 3156 return mf_df 3157 3158 def attempt_alignment(self, matches_c, matches_i): 3159 """ 3160 Check if alignment is needed for the LCMS objects in the collection. 3161 """ 3162 3163 # Hold out a subset of matches_c and matches_i for spline fitting 3164 matches_c.reset_index(drop=False, inplace=True) 3165 matches_i.reset_index(drop=False, inplace=True) 3166 3167 # Check if there are enough matches to attempt alignment 3168 minimum_matches = self.parameters.lcms_collection.alignment_minimum_matches 3169 if len(matches_c) < minimum_matches: 3170 # Return False (no alignment) and identity function (returns original time) 3171 # which isn't used but is a placeholder to avoid errors in downstream code since 3172 # the function expects a callable to be returned 3173 return False, lambda x: x 3174 3175 # Rearrange matches_c and matches_i to be in the order of the scan_time of matches_c 3176 matches_c = matches_c.sort_values(by="scan_time") 3177 matches_i = matches_i.iloc[matches_c.index.values] 3178 3179 hold_out_fraction = self.parameters.lcms_collection.alignment_hold_out_fraction 3180 # starting with an array of length len(matches_c), select equally spaced indices to hold out 3181 idx_holdout = matches_c.index.values[ 3182 np.arange(0, len(matches_c), int(1 / hold_out_fraction)) 3183 ] 3184 3185 matches_c_holdout = matches_c.loc[idx_holdout].copy() 3186 matches_i_holdout = matches_i.loc[idx_holdout].copy() 3187 3188 # Remove the holdout matches from the matches_c and matches_i DataFrames and reset the index 3189 matches_c = matches_c.drop(index=idx_holdout).set_index("sample_name") 3190 matches_i = matches_i.drop(index=idx_holdout).set_index("sample_name") 3191 3192 # Reset the scan_time to the original scan_time 3193 matches_i = matches_i.copy() 3194 matches_i["scan_time"] = matches_i["scan_time_og"] 3195 3196 # Fit the retention times of the LCMS object to the center LCMS object using the matched mass features 3197 spl = self.fit_rts(matches_c, matches_i, kernel="rbf", C=1000) 3198 3199 # Check if the spline fitting improved the alignment for the holdout matches 3200 matches_i_holdout["scan_time_fit"] = spl(matches_i_holdout["scan_time"]) 3201 og_diff = np.abs( 3202 matches_i_holdout["scan_time"] - matches_c_holdout["scan_time"] 3203 ) 3204 fit_diff = np.abs( 3205 matches_i_holdout["scan_time_fit"] - matches_c_holdout["scan_time"] 3206 ) 3207 3208 if ( 3209 "fraction_improved" 3210 in self.parameters.lcms_collection.alignment_acceptance_technique 3211 ): 3212 fraction_improved = np.sum(fit_diff < og_diff) / len(og_diff) 3213 use_spline_alignment = ( 3214 fraction_improved 3215 > self.parameters.lcms_collection.alignment_acceptance_fraction_improved_threshold 3216 ) 3217 if ( 3218 "mean_squared_error_improved" 3219 in self.parameters.lcms_collection.alignment_acceptance_technique 3220 ): 3221 mse_og = np.mean(og_diff**2) 3222 mse = np.mean(fit_diff**2) 3223 use_spline_alignment = mse < mse_og 3224 # Convert to boolean 3225 use_spline_alignment = bool(use_spline_alignment) 3226 3227 return use_spline_alignment, spl 3228 3229 def align_lcms_objects(self, overwrite=False): 3230 """ 3231 Align LCMS objects in the collection. 3232 3233 Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects. 3234 First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects, 3235 starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects 3236 are then fit to the center LCMS object using the matched mass features. 3237 3238 Returns 3239 ------- 3240 None, but aligns the LCMS objects in the collection and sets the scan_time_aligned column in the scan_df attribute of each LCMS object. 3241 3242 Notes 3243 ----- 3244 This function has been adapted from the original implementation in the Deimos package: 3245 https://github.com/pnnl/deimos 3246 """ 3247 3248 # Prepare the center LCMS object 3249 center_obj_ids = self.manifest_dataframe[ 3250 self.manifest_dataframe["center"] 3251 ].collection_id.values 3252 3253 full_mf_df = self.mass_features_dataframe 3254 # re-index to sample_name for faster lookups 3255 full_mf_df = full_mf_df.reset_index().set_index("sample_name") 3256 samples_with_features = set(full_mf_df.index.get_level_values("sample_name")) 3257 3258 if "scan_time_aligned" in full_mf_df.columns and not overwrite: 3259 raise ValueError("Mass features have already been aligned") 3260 3261 def _set_scan_time_alignment_for_sample(sample_idx, use_alignment, spline): 3262 """Set scan_time_aligned for one sample using spline or identity mapping.""" 3263 if use_alignment and spline is not None: 3264 self[sample_idx]._scan_info["scan_time_aligned"] = { 3265 k: spline(v) 3266 for k, v in self[sample_idx]._scan_info["scan_time"].items() 3267 } 3268 return True 3269 3270 self[sample_idx]._scan_info["scan_time_aligned"] = ( 3271 self[sample_idx]._scan_info["scan_time"].copy() 3272 ) 3273 return False 3274 3275 def _get_feature_df_at_or_after(start_idx, index_step, use_alignment, spline): 3276 """Return next sample index/dataframe with features, aligning empty samples on the way.""" 3277 i = start_idx 3278 while 0 <= i < len(self): 3279 sample_name = self.samples[i] 3280 if sample_name in samples_with_features: 3281 mf_df_i = full_mf_df.loc[sample_name].copy() 3282 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3283 mf_df_i = mf_df_i.reset_index(drop=False) 3284 if use_alignment and spline is not None: 3285 # Use previous step transform as a better matching starting point. 3286 mf_df_i["scan_time"] = spline(mf_df_i["scan_time"]) 3287 return i, mf_df_i 3288 3289 _set_scan_time_alignment_for_sample(i, use_alignment, spline) 3290 self.rt_alignment_attempted = True 3291 i += index_step 3292 3293 return i, None 3294 3295 anchor_mf_dfs = [] 3296 for center_obj_id in center_obj_ids: 3297 # Get the anchor mass features from the center LCMS object 3298 mf_df_c = full_mf_df.loc[self.samples[center_obj_id]] 3299 mf_df_c = self.get_anchor_mass_features(mf_df_c) 3300 anchor_mf_dfs.append(mf_df_c) 3301 3302 # Set scan_time_aligned to scan_time for the center LCMS object 3303 center_scan_df = self[center_obj_id].scan_df.copy() 3304 center_scan_df["scan_time_aligned"] = center_scan_df["scan_time"] 3305 self[center_obj_id].scan_df = center_scan_df 3306 3307 # Store alignment data for center object (identity mapping) 3308 center_sample_name = self.samples[center_obj_id] 3309 3310 index_steps = (1, -1) 3311 # Run this twice, once going forward (+1 indexing) and once going backward (-1 indexing) 3312 for index_step in index_steps: 3313 # Initialize spline for propagation to samples without features 3314 spl = None 3315 use_spline_alignment = False 3316 3317 # Loop through the other LCMS objects in this direction. 3318 i, mf_df_i = _get_feature_df_at_or_after( 3319 center_obj_id + index_step, 3320 index_step, 3321 use_spline_alignment, 3322 spl, 3323 ) 3324 3325 while mf_df_i is not None: 3326 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3327 3328 # Match the mass features in the LCMS object to the anchor mass features in the center LCMS object. 3329 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3330 3331 if matches_c is not None: 3332 use_spline_alignment, spl = self.attempt_alignment( 3333 matches_c, matches_i 3334 ) 3335 3336 # Record if we used alignment for this sample 3337 sample_name = self.samples[i] 3338 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3339 use_spline_alignment 3340 ) 3341 3342 if use_spline_alignment: 3343 # Set new retention times on scan_df for lc_obj using the spline fitting 3344 matches_i["scan_time_fit"] = spl(matches_i["scan_time"]) 3345 3346 self.rt_aligned = _set_scan_time_alignment_for_sample( 3347 i, use_spline_alignment, spl 3348 ) 3349 self.rt_alignment_attempted = True 3350 3351 i, mf_df_i = _get_feature_df_at_or_after( 3352 i + index_step, 3353 index_step, 3354 use_spline_alignment, 3355 spl, 3356 ) 3357 else: 3358 # If no matches are found, propagate prior alignment from this index step. 3359 sample_name = self.samples[i] 3360 used_previous_alignment = ( 3361 use_spline_alignment and spl is not None 3362 ) 3363 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3364 used_previous_alignment 3365 ) 3366 3367 self.rt_aligned = _set_scan_time_alignment_for_sample( 3368 i, used_previous_alignment, spl 3369 ) 3370 self.rt_alignment_attempted = True 3371 3372 i, mf_df_i = _get_feature_df_at_or_after( 3373 i + index_step, 3374 index_step, 3375 used_previous_alignment, 3376 spl, 3377 ) 3378 3379 # Now align each batch using the center objects as anchors with the other batches 3380 mf_df_c = anchor_mf_dfs[0] 3381 for i in center_obj_ids[1:]: 3382 mf_df_i = full_mf_df.loc[self.samples[i]].copy() 3383 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3384 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3385 3386 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3387 if matches_c is not None: 3388 use_spline_alignment, spl = self.attempt_alignment(matches_c, matches_i) 3389 3390 # Record if we used alignment for this sample 3391 sample_name = self.samples[i] 3392 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3393 use_spline_alignment 3394 ) 3395 3396 if use_spline_alignment: 3397 # Set new retention times on all this object's 3398 new_times = spl(self[i].scan_df["scan_time"]) 3399 new_scan_info = self[i].scan_df.copy() 3400 new_scan_info["scan_time_aligned"] = new_times 3401 self[i].scan_df = new_scan_info 3402 3403 # Get the batch that this object belongs to 3404 batch = self.manifest[self.samples[i]]["batch"] 3405 3406 for j in range(len(self)): 3407 if self.manifest[self.samples[j]]["batch"] == batch: 3408 if j != i: 3409 sample_name_j = self.samples[j] 3410 self._manifest_dict[sample_name_j][ 3411 "use_rt_alignment" 3412 ] = use_spline_alignment 3413 new_scan_info = self[j].scan_df.copy() 3414 aligned_times = spl( 3415 self[j].scan_df["scan_time_aligned"] 3416 ) 3417 new_scan_info["scan_time_aligned"] = aligned_times 3418 self[j].scan_df = new_scan_info 3419 3420 # Set final mass_features_dataframe with the aligned scan_time 3421 center_sample_name = self.samples[center_obj_ids[0]] 3422 self._manifest_dict[center_sample_name]["use_rt_alignment"] = False 3423 new_scan_info = self[center_obj_ids[0]].scan_df.copy() 3424 new_scan_info["scan_time_aligned"] = new_scan_info["scan_time"] 3425 3426 def add_consensus_mass_features(self): 3427 """ 3428 Create consensus mass features by clustering aligned features across samples. 3429 3430 This method clusters mass features from all samples in the collection based on 3431 their m/z and aligned retention time proximity. Features that cluster together 3432 across samples are assigned a common cluster ID, creating consensus features 3433 that represent the same compound detected across multiple samples. 3434 3435 The clustering process: 3436 1. Partitions features by m/z to avoid large sparse matrices and enable parallelization 3437 2. Clusters features within each partition using hierarchical clustering 3438 3. Merges partition-boundary clusters that represent the same feature 3439 4. Filters out clusters not present in minimum fraction of samples 3440 3441 Must be run after align_lcms_objects(). Results are stored in the 3442 mass_features_dataframe with a 'cluster' column added. 3443 3444 Parameters 3445 ---------- 3446 None 3447 Uses parameters from self.parameters.lcms_collection: 3448 - consensus_mz_tol_ppm: m/z tolerance for clustering (ppm) 3449 - consensus_rt_tol: retention time tolerance for clustering (minutes) 3450 - consensus_partition_size: target partition size for managing memory and parallelization 3451 - consensus_min_sample_fraction: minimum fraction of samples a cluster 3452 must appear in to be retained (0-1) 3453 - cores: number of CPU cores to use for parallel partition processing 3454 3455 Returns 3456 ------- 3457 None 3458 Updates self.mass_features_dataframe in place by adding 'cluster' column 3459 and filtering to retain only clusters meeting minimum sample presence. 3460 3461 Raises 3462 ------ 3463 ValueError 3464 If mass features have not been aligned (run align_lcms_objects() first). 3465 3466 Notes 3467 ----- 3468 - Partitioning prevents memory issues with large sparse distance matrices 3469 - Each partition is processed in parallel (up to cores limit) 3470 - Clusters not meeting consensus_min_sample_fraction are automatically removed 3471 - Access cluster_summary_dataframe property for summary statistics 3472 - Use fill_missing_cluster_features() for gap-filling after clustering 3473 3474 See Also 3475 -------- 3476 align_lcms_objects : Aligns retention times before consensus clustering 3477 cluster_summary_dataframe : Property that generates summary statistics for clusters 3478 fill_missing_cluster_features : Gap-fill missing features in clusters 3479 """ 3480 # Get the combined mass features from all LCMS objects, keep the original index as a separate column 3481 combined_mfs = self.mass_features_dataframe.copy() 3482 combined_mfs["coll_mf_id"] = combined_mfs.index 3483 3484 # Check if the mass features have been aligned 3485 if "scan_time_aligned" not in combined_mfs.columns: 3486 raise ValueError( 3487 "Mass features have not been aligned, run align_lcms_objects() first" 3488 ) 3489 3490 # Partition the mass features by mz so we can parallelize the matching before clustering 3491 from corems.chroma_peak.calc import subset as corems_subset 3492 3493 # get max mz from combined_mfs and calculate tolerance from ppm 3494 mz_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 3495 n_partition_size = self.parameters.lcms_collection.consensus_partition_size 3496 lazy_partitions = corems_subset.multi_sample_partition( 3497 combined_mfs, 3498 split_on="mz", 3499 size=n_partition_size, 3500 tol=mz_tol, 3501 relative=True, 3502 ) 3503 3504 # If any of lazy_partitions._counts is 2xn_partition_size, issue a warning 3505 if np.array(lazy_partitions._counts).max() > 2 * n_partition_size: 3506 warnings.warn( 3507 "Some partitions are larger than 2x the goal partition size. Consider increasing the partition or decreasing the mz_tol." 3508 ) 3509 3510 # Cluster the mass features within each partition 3511 if self.parameters.lcms_collection.cores > lazy_partitions.n_partitions: 3512 cores_to_use = lazy_partitions.n_partitions 3513 else: 3514 cores_to_use = self.parameters.lcms_collection.cores 3515 # mfs_with_clusters = lazy_partitions.map(self.cluster_mass_features, processes=cores_to_use) 3516 mfs_with_clusters = lazy_partitions.map( 3517 self.cluster_mass_features_agg_cluster, processes=cores_to_use 3518 ) 3519 3520 # Clean up cluster id names after partitioning 3521 new_cluster_ids = ( 3522 mfs_with_clusters[["cluster", "partition_idx"]] 3523 .drop_duplicates() 3524 .reset_index(drop=True) 3525 ) 3526 new_cluster_ids["cluster_unqiue"] = new_cluster_ids.index 3527 mfs_with_clusters = mfs_with_clusters.merge( 3528 new_cluster_ids, on=["cluster", "partition_idx"] 3529 ) 3530 mfs_with_clusters["cluster"] = mfs_with_clusters["cluster_unqiue"] 3531 mfs_with_clusters = mfs_with_clusters.drop(columns=["cluster_unqiue"]) 3532 3533 # Embed a new cluster id into the mass features dataframe and set as index 3534 mfs_with_clusters["idx"] = mfs_with_clusters.index 3535 3536 try: 3537 # Check if any clusters can be merged into a single cluster 3538 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3539 3540 # Merge clusters identified in eval_dict 3541 while len(eval_dict["merge_these_clusters"]) > 0: 3542 list_of_clusters_to_merge = [ 3543 [x[0], x[1]] for x in eval_dict["merge_these_clusters"] 3544 ] 3545 # Convert to a dataframe with columns "new_cluster" and "cluster" 3546 df = pd.DataFrame( 3547 np.array(list_of_clusters_to_merge), 3548 columns=["new_cluster", "cluster"], 3549 ) 3550 # Drop duplicates of "child" clusters 3551 df = df.drop_duplicates("cluster", keep="first") 3552 df = df.drop_duplicates("new_cluster", keep="first") 3553 mfs_with_clusters = mfs_with_clusters.merge( 3554 df, on="cluster", how="left" 3555 ) 3556 mfs_with_clusters["cluster"] = mfs_with_clusters["new_cluster"].fillna( 3557 mfs_with_clusters["cluster"] 3558 ) 3559 mfs_with_clusters = mfs_with_clusters.drop(columns=["new_cluster"]) 3560 3561 # Re-evaluate clusters for repeats 3562 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3563 self.mass_features_dataframe = mfs_with_clusters 3564 3565 except: 3566 mfs_with_clusters.set_index("coll_mf_id", inplace=True) 3567 self.mass_features_dataframe = mfs_with_clusters 3568 3569 # Filter out clusters that don't meet minimum sample fraction 3570 self._filter_clusters_by_sample_presence() 3571 3572 # TODO KRH: Deal with isomers better? Pool them together and then split them out using samples with 2 as the template? 3573 3574 def _filter_clusters_by_sample_presence(self): 3575 """ 3576 Filter out clusters that don't meet the minimum sample fraction threshold. 3577 3578 Removes clusters (and their associated mass features) from the mass_features_dataframe 3579 if they don't appear in at least consensus_min_sample_fraction of samples. 3580 3581 This is called automatically at the end of add_consensus_mass_features(). 3582 3583 Returns 3584 ------- 3585 None 3586 Updates self.mass_features_dataframe in place by removing clusters that don't 3587 meet the minimum sample presence threshold. 3588 """ 3589 if ( 3590 self.mass_features_dataframe is None 3591 or len(self.mass_features_dataframe) == 0 3592 ): 3593 return 3594 3595 min_sample_fraction = ( 3596 self.parameters.lcms_collection.consensus_min_sample_fraction 3597 ) 3598 3599 # Validate parameter 3600 if not 0 <= min_sample_fraction <= 1: 3601 raise ValueError("consensus_min_sample_fraction must be between 0 and 1") 3602 3603 # Calculate minimum number of samples required 3604 total_samples = len(self.samples) 3605 min_samples_required = min_sample_fraction * total_samples 3606 3607 # Count unique samples per cluster 3608 cluster_sample_counts = ( 3609 self.mass_features_dataframe.groupby("cluster")["sample_id"] 3610 .nunique() 3611 .reset_index(name="sample_count") 3612 ) 3613 3614 # Identify clusters to keep 3615 clusters_to_keep = cluster_sample_counts[ 3616 cluster_sample_counts["sample_count"] > min_samples_required 3617 ]["cluster"].values 3618 3619 # Filter mass features dataframe 3620 self.mass_features_dataframe = self.mass_features_dataframe[ 3621 self.mass_features_dataframe["cluster"].isin(clusters_to_keep) 3622 ] 3623 3624 def summarize_clusters(self): 3625 """ 3626 Generate summary statistics for consensus mass feature clusters. 3627 3628 Computes aggregate statistics (median, mean, std, min, max) for each cluster 3629 across all samples. Combines both regular mass features and induced mass features 3630 (from gap-filling) when available to provide complete cluster statistics. 3631 3632 Must be run after add_consensus_mass_features() which creates the cluster assignments. 3633 Results are stored in cluster_summary_dataframe property and used by plotting methods. 3634 3635 Parameters 3636 ---------- 3637 None 3638 Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe. 3639 Both must contain 'cluster' column. 3640 3641 Returns 3642 ------- 3643 :obj:`~pandas.DataFrame` or None 3644 DataFrame with one row per cluster containing summary statistics: 3645 - cluster: cluster ID 3646 - mz_{median,mean,std,max,min}: m/z statistics 3647 - scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics 3648 - half_height_width_{median,mean,std,max,min}: peak width statistics 3649 - tailing_factor_{median,mean,std,max,min}: peak shape statistics 3650 - dispersity_index_{median,mean,std,max,min}: peak quality statistics 3651 - sample_id_nunique: number of unique samples containing the cluster 3652 - intensity_{max,median,mean,std,min}: intensity statistics 3653 - persistence_{max,median,mean,std,min}: persistence statistics 3654 3655 Returns None if mass_features_dataframe is empty. 3656 3657 Notes 3658 ----- 3659 - Summary DataFrame is automatically stored in cluster_summary_dataframe property 3660 - Includes both regular and induced (gap-filled) mass features when available. 3661 ``group_consensus_features`` does not use this m/z median for ion-role 3662 geometry; that median is detected features only. 3663 - Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster 3664 - Sample count (sample_id_nunique) indicates cluster prevalence across samples 3665 - Filters applied by consensus_min_sample_fraction affect which clusters appear 3666 3667 See Also 3668 -------- 3669 add_consensus_mass_features : Creates clusters before summarization 3670 fill_missing_cluster_features : Creates induced mass features via gap-filling 3671 plot_consensus_mz_features : Visualizes cluster summaries 3672 plot_mz_features_per_cluster : Shows cluster size distribution 3673 """ 3674 # First check if there are minimum columns in the features dataframe 3675 if ( 3676 self.mass_features_dataframe is None 3677 or len(self.mass_features_dataframe.columns) < 1 3678 ): 3679 return None 3680 3681 # Combine regular and induced mass features 3682 mf_df = self.mass_features_dataframe.copy() 3683 mf_df = mf_df.reset_index(drop=False) 3684 3685 # Check if induced mass features are available and combine them 3686 if ( 3687 self.induced_mass_features_dataframe is not None 3688 and len(self.induced_mass_features_dataframe) > 0 3689 ): 3690 imf_df = self.induced_mass_features_dataframe.copy() 3691 imf_df = imf_df.reset_index(drop=False) 3692 # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination 3693 # Combine regular and induced features 3694 mf_df = pd.concat([mf_df, imf_df], axis=0) 3695 mf_df = mf_df.reset_index(drop=True) 3696 3697 # Filter out any rows with NaN cluster values before converting to int 3698 if "cluster" in mf_df.columns: 3699 mf_df = mf_df.dropna(subset=["cluster"]) 3700 mf_df["cluster"] = mf_df["cluster"].astype(int) 3701 3702 # Build aggregation dictionary based on available columns 3703 agg_dict = { 3704 "mz": ["median", "mean", "std", "max", "min"], 3705 "scan_time_aligned": ["median", "mean", "std", "max", "min"], 3706 "sample_id": ["nunique"], 3707 "intensity": ["max", "median", "mean", "std", "min"], 3708 } 3709 3710 # Add optional columns if they exist 3711 optional_columns = { 3712 "half_height_width": ["median", "mean", "std", "max", "min"], 3713 "tailing_factor": ["median", "mean", "std", "max", "min"], 3714 "dispersity_index": ["median", "mean", "std", "max", "min"], 3715 "persistence": ["max", "median", "mean", "std", "min"], 3716 } 3717 3718 for col, funcs in optional_columns.items(): 3719 if col in mf_df.columns: 3720 agg_dict[col] = funcs 3721 3722 summary_df = mf_df.groupby("cluster").agg(agg_dict).reset_index() 3723 3724 # Fix the column names 3725 summary_df.columns = [ 3726 "_".join(col).strip() 3727 for col in summary_df.columns.values 3728 if col != "cluster" 3729 ] 3730 summary_df = summary_df.rename(columns={"cluster_": "cluster"}) 3731 # Set cluster as the index for easy lookup 3732 summary_df = summary_df.set_index("cluster") 3733 3734 # Merge consensus feature-group labels when present (isotopes + adducts). 3735 # Cluster summary exposes possible_ion_types (all 3736 # residual-tied candidates) and drops ion_type so a single type column 3737 # is not mistaken for unique assignment. 3738 fg = getattr(self, "feature_group_dataframe", None) 3739 if fg is not None and len(fg) > 0: 3740 from corems.mass_spectra.calc.feature_grouping import ( 3741 GROUP_COLUMNS, 3742 merge_possible_ion_types, 3743 ) 3744 3745 cols = [c for c in GROUP_COLUMNS if c in fg.columns] 3746 if cols: 3747 drop_cols = [c for c in cols if c in summary_df.columns] 3748 if drop_cols: 3749 summary_df = summary_df.drop(columns=drop_cols) 3750 # Also drop stale ion_type from a prior join shape 3751 if "ion_type" in summary_df.columns: 3752 summary_df = summary_df.drop(columns=["ion_type"]) 3753 summary_df = summary_df.join(fg[cols], how="left") 3754 3755 if ( 3756 "possible_ion_types" in summary_df.columns 3757 and "ion_type" in summary_df.columns 3758 ): 3759 for cid in summary_df.index: 3760 it = summary_df.loc[cid, "ion_type"] 3761 poss = summary_df.loc[cid, "possible_ion_types"] 3762 filled = merge_possible_ion_types(it, poss) 3763 if filled is not None: 3764 summary_df.loc[cid, "possible_ion_types"] = filled 3765 if "ion_type" in summary_df.columns: 3766 summary_df = summary_df.drop(columns=["ion_type"]) 3767 3768 return summary_df 3769 3770 def group_consensus_features(self): 3771 """ 3772 Group consensus features into natural-abundance isotope and adduct families. 3773 3774 After consensus clustering and gap-filling, labels clusters that share 3775 similar retention time, an interpretable **singly-charged** mass offset, 3776 and correlated cross-sample **apex peak heights**. 3777 3778 Two stages 3779 ---------- 3780 1. **Isotopes.** Unit ``Atoms`` edges (e.g. ¹²C/¹³C) plus roll-up to 3781 ¹³C₂, ¹³C₃, … when intermediates exist. Spacing is the raw Atoms 3782 Δm (``|z| = 1``). Multi-charge envelopes are out of scope. 3783 Chemical mono is the geometry root, not the tallest peak. 3784 2. **Adducts.** Pairwise ``ion_type_dict`` forms in 3785 ``feature_group_ion_types`` (must be ``|z| = 1``). Empty or a 3786 single configured type skips adduct linking (isotopes only). 3787 3788 **Not** for tracer, enriched, or isotopically labeled experiments. 3789 3790 Quant gate is fixed (no settings switch): 3791 3792 - Correlation: **Pearson** only, pairwise-complete on samples with both 3793 heights > 0 (threshold ``feature_group_corr_threshold``, default 0.80) 3794 - Abundance: mass-feature apex ``intensity`` only (not integrated area) 3795 3796 Coelution and m/z delta windows reuse collection 3797 ``alignment_rt_tol`` and ``alignment_mz_tol_ppm``. Other knobs 3798 (``feature_group_isotope_atoms``, shared-sample fraction, 3799 ``feature_group_ion_types``, …) live on ``parameters.lcms_collection``. 3800 3801 ``feature_group_ion_types`` is filtered to the collection polarity 3802 (all samples share one polarity; mixed collections cannot be 3803 instantiated). Only keys ending in ``+`` are kept on positive data 3804 and only keys ending in ``-`` on negative data. 3805 3806 Preferred for collection-level families. Optional per-file 3807 ``LCMSBase.find_c13_mass_features`` remains available for single-file 3808 workflows; do not treat both as authoritative in the same pipeline. 3809 3810 Must run after ``add_consensus_mass_features()``. The m/z median used for 3811 isotope and adduct spacing is calculated from detected features only. 3812 Gap-filled m/z values stay on the induced rows and do not enter that 3813 median. The height matrix still includes gap-filled apex intensities 3814 when present; missing entries are 0. 3815 3816 Returns 3817 ------- 3818 pandas.DataFrame 3819 Cluster-indexed labels with columns ``feature_group_id``, 3820 ``ion_role``, ``ion_type``, ``possible_ion_types``, 3821 ``isotope_state``, ``mono_cluster_id`` (set only when the form 3822 has at least one isotope). Also stored on 3823 ``self.feature_group_dataframe`` and merged into 3824 ``mass_features_dataframe`` / ``induced_mass_features_dataframe``. 3825 3826 Raises 3827 ------ 3828 ValueError 3829 If consensus features are missing or settings are invalid. 3830 """ 3831 from corems.mass_spectra.calc.feature_grouping import ( 3832 GROUP_COLUMNS, 3833 HEIGHT_COL, 3834 FeatureGroupParams, 3835 build_height_matrix_from_features, 3836 detected_cluster_mz_median, 3837 group_features_arrays, 3838 normalize_ms_polarity, 3839 ) 3840 3841 if ( 3842 self.mass_features_dataframe is None 3843 or len(self.mass_features_dataframe) == 0 3844 ): 3845 raise ValueError( 3846 "No mass features dataframe. Run add_consensus_mass_features() first." 3847 ) 3848 if "cluster" not in self.mass_features_dataframe.columns: 3849 raise ValueError( 3850 "mass_features_dataframe has no 'cluster' column. " 3851 "Run add_consensus_mass_features() first." 3852 ) 3853 3854 # Clear prior labels (idempotent re-run) 3855 self.feature_group_dataframe = None 3856 for col in GROUP_COLUMNS: 3857 if col in self.mass_features_dataframe.columns: 3858 self.mass_features_dataframe = self.mass_features_dataframe.drop( 3859 columns=[col] 3860 ) 3861 if ( 3862 self.induced_mass_features_dataframe is not None 3863 and col in self.induced_mass_features_dataframe.columns 3864 ): 3865 self.induced_mass_features_dataframe = ( 3866 self.induced_mass_features_dataframe.drop(columns=[col]) 3867 ) 3868 3869 summary = self.summarize_clusters() 3870 if summary is None or len(summary) == 0: 3871 raise ValueError( 3872 "cluster summary is empty. Run add_consensus_mass_features() first." 3873 ) 3874 3875 # Collection polarity: all samples share one polarity (enforced when 3876 # LCMSCollection is loaded). Filter opposite-sign adducts. 3877 collection_polarity = None 3878 if len(self): 3879 collection_polarity = normalize_ms_polarity( 3880 getattr(self[0], "polarity", None) 3881 ) 3882 3883 params = FeatureGroupParams.from_lcms_collection_settings( 3884 self.parameters.lcms_collection, 3885 polarity=collection_polarity, 3886 ) 3887 3888 cluster_ids = summary.index.to_numpy() 3889 # Gap-filled apex m/z is not part of ion-role geometry. 3890 mz = detected_cluster_mz_median(self.mass_features_dataframe, cluster_ids) 3891 if "scan_time_aligned_median" not in summary.columns: 3892 raise ValueError( 3893 "cluster summary missing scan_time_aligned_median; " 3894 "ensure features are RT-aligned before consensus." 3895 ) 3896 rt = summary["scan_time_aligned_median"].to_numpy(dtype=float) 3897 3898 # Sample ids as used in mass_features_dataframe 3899 sample_ids = sorted(self.mass_features_dataframe["sample_id"].dropna().unique()) 3900 if self.induced_mass_features_dataframe is not None and len( 3901 self.induced_mass_features_dataframe 3902 ): 3903 sample_ids = sorted( 3904 set(sample_ids) 3905 | set( 3906 self.induced_mass_features_dataframe["sample_id"] 3907 .dropna() 3908 .unique() 3909 .tolist() 3910 ) 3911 ) 3912 3913 t_height = time.perf_counter() 3914 heights = build_height_matrix_from_features( 3915 self.mass_features_dataframe, 3916 cluster_ids=cluster_ids, 3917 sample_ids=sample_ids, 3918 induced_df=self.induced_mass_features_dataframe, 3919 intensity_col=HEIGHT_COL, # apex intensity only; not area 3920 ) 3921 stage_timings = { 3922 "height_matrix": time.perf_counter() - t_height, 3923 "n_ion_types_configured": float(len(params.ion_types)), 3924 "collection_polarity": ( 3925 str(collection_polarity) if collection_polarity else "unknown" 3926 ), 3927 } 3928 3929 algo_timings: dict = {} 3930 labels = group_features_arrays( 3931 cluster_ids, mz, rt, heights, params, timings_out=algo_timings 3932 ) 3933 stage_timings.update(algo_timings) 3934 self._last_feature_group_timings = stage_timings 3935 self.feature_group_dataframe = labels 3936 3937 from corems.mass_spectra.calc.feature_grouping import ( 3938 merge_feature_group_labels_into_frames, 3939 ) 3940 3941 self.mass_features_dataframe, induced = merge_feature_group_labels_into_frames( 3942 labels, 3943 self.mass_features_dataframe, 3944 self.induced_mass_features_dataframe, 3945 ) 3946 if self.induced_mass_features_dataframe is not None: 3947 self.induced_mass_features_dataframe = induced 3948 3949 return labels 3950 3951 def plot_mz_features_per_cluster(self, return_fig=False, path=None): 3952 """ 3953 Plot the number of mass features in a cluster against how many clusters 3954 contain that number of mass features 3955 3956 Parameters 3957 ----------- 3958 return_fig : bool, optional 3959 If True, return the open figure (caller owns lifecycle). 3960 Default is False. 3961 path : str or path-like, optional 3962 If set, save the figure to this path. When ``return_fig`` is False, 3963 the figure is closed after saving and ``plt.show()`` is not called. 3964 3965 Returns 3966 -------- 3967 matplotlib.figure.Figure or None 3968 The figure if ``return_fig`` is True; otherwise None. 3969 3970 Raises 3971 ------ 3972 ValueError 3973 If consensus features haven't been added to the object yet 3974 """ 3975 3976 if not hasattr(self, "cluster_summary_dataframe"): 3977 raise ValueError( 3978 "cluster_summary_dataframe is not set, must run add_consensus_mass_features() first" 3979 ) 3980 sum_data = self.cluster_summary_dataframe 3981 fig, ax = plt.subplots() 3982 sum_data.sample_id_nunique.value_counts().sort_index().plot(ax=ax, kind="bar") 3983 plt.xlabel("Number of mass features in a cluster") 3984 plt.ylabel("Number of clusters with this many mass features") 3985 return _finalize_plot(fig, return_fig=return_fig, path=path) 3986 3987 def plot_mz_features_across_samples( 3988 self, alpha=0.75, s=0.005, return_fig=False, path=None 3989 ): 3990 """ 3991 Generate Scan Time vs m/z plot of all the mass features across all 3992 samples in collection where intensity of color on the plot indicates 3993 density of mass features, NOT INTENSITY 3994 3995 Parameters 3996 ----------- 3997 alpha : float 3998 Desired transparency for plotted m/z features. Defaults to 0.75. 3999 s : float 4000 Desired size of plotted m/z features. Defaults to 0.005. 4001 return_fig : bool, optional 4002 If True, return the open figure (caller owns lifecycle). 4003 Default is False. 4004 path : str or path-like, optional 4005 If set, save the figure to this path. When ``return_fig`` is False, 4006 the figure is closed after saving and ``plt.show()`` is not called. 4007 4008 Returns 4009 -------- 4010 matplotlib.figure.Figure or None 4011 A figure displaying a scan time vs m/z scatterplot of all the m/z 4012 features identified in the collection if ``return_fig`` is True; 4013 otherwise None. Parameters alpha (transparency) and s (marker size) 4014 allow the user to emphasize the density of features. Intensity of 4015 features is not represented. 4016 """ 4017 df = self.mass_features_dataframe.copy() 4018 fig = plt.figure() 4019 plt.scatter(df.scan_time_aligned, df.mz, c="tab:gray", alpha=alpha, s=s) 4020 4021 plt.xlabel("Scan time") 4022 plt.ylabel("m/z") 4023 plt.ylim(0, np.ceil(np.max(df.mz))) 4024 plt.xlim(0, np.ceil(np.max(df.scan_time))) 4025 plt.title("All mass features, all samples") 4026 4027 return _finalize_plot(fig, return_fig=return_fig, path=path) 4028 4029 def plot_consensus_mz_features( 4030 self, 4031 xb="xb", 4032 xt="xt", 4033 yb="yb", 4034 yt="yt", 4035 show_all=True, 4036 return_fig=False, 4037 path=None, 4038 ): 4039 """ 4040 Generate Scan Time vs m/z plot of the consensus features scaled by size 4041 with option ('show_all') of leaving the individual m/z features in the figure. 4042 4043 Parameters 4044 ----------- 4045 xb : float 4046 Desired starting scan time value for the x-axis. Defaults to 0. 4047 xt : float 4048 Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data. 4049 yb : float 4050 Desired starting m/z value for the y-axis. Defaults to 0. 4051 yt : float 4052 Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data. 4053 show_all : boolean 4054 Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True. 4055 return_fig : bool, optional 4056 If True, return the open figure (caller owns lifecycle). 4057 Default is False. 4058 path : str or path-like, optional 4059 If set, save the figure to this path. When ``return_fig`` is False, 4060 the figure is closed after saving and ``plt.show()`` is not called. 4061 4062 Returns 4063 -------- 4064 matplotlib.figure.Figure or None 4065 A scalable figure that overlays the consensus features over all the 4066 m/z features identified in the collection if ``return_fig`` is True; 4067 otherwise None. Consensus features are scaled by how many m/z 4068 features are represented in the consensus. Figure can be scaled by 4069 inputting desired boundaries on the scan time (xb, xt) and m/z 4070 values (yb, yt). 4071 """ 4072 df = self.cluster_summary_dataframe.copy() 4073 mfdf = self.mass_features_dataframe.copy() 4074 4075 fig = plt.figure() 4076 if show_all: 4077 plt.scatter(mfdf.scan_time_aligned, mfdf.mz, c="tab:gray", s=1) 4078 4079 m = plt.scatter( 4080 df.scan_time_aligned_median, 4081 df.mz_median, 4082 c="tab:orange", 4083 alpha=0.7, 4084 s=(df.sample_id_nunique**2) / 5, 4085 ) 4086 4087 plt.xlabel("Scan time") 4088 plt.ylabel("m/z") 4089 4090 if xt == "xt": 4091 xt = np.ceil(np.max(mfdf.mz)) 4092 if yt == "yt": 4093 yt = np.ceil(np.max(mfdf.scan_time)) 4094 if xb == "xb": 4095 xb = 0 4096 if yb == "yb": 4097 yb = 0 4098 plt.ylim(xb, xt) 4099 plt.xlim(yb, yt) 4100 4101 kw = dict( 4102 prop="sizes", 4103 num=max(1, int(len(df.sample_id_nunique.unique()) / 3)), 4104 color="tab:orange", 4105 alpha=0.7, 4106 func=lambda s: np.sqrt(s * 5), 4107 ) 4108 4109 plt.legend( 4110 *m.legend_elements(**kw), 4111 title="Features\nper cluster", 4112 bbox_to_anchor=(1.01, 0.4, 0.225, 0.5), 4113 ) 4114 plt.tight_layout() 4115 plt.title("Consensus Features") 4116 4117 return _finalize_plot(fig, return_fig=return_fig, path=path) 4118 4119 def plot_cluster( 4120 self, 4121 cluster_id, 4122 to_plot=["EIC", "MS1", "MS2"], 4123 return_fig=False, 4124 path=None, 4125 plot_smoothed_eic=False, 4126 plot_eic_datapoints=False, 4127 eic_buffer_time=None, 4128 label_samples=False, 4129 molecular_metadata=None, 4130 spectral_library=None, 4131 ): 4132 """ 4133 Plot a consensus mass feature cluster across all samples. 4134 4135 Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster, 4136 highlighting the representative mass feature. 4137 4138 Parameters 4139 ---------- 4140 cluster_id : int 4141 The cluster ID to plot 4142 to_plot : list, optional 4143 List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror". 4144 Default is ["EIC", "MS1", "MS2"]. 4145 return_fig : bool, optional 4146 If True, return the open figure (caller owns lifecycle). 4147 Default is False. 4148 path : str or path-like, optional 4149 If set, save the figure to this path. When ``return_fig`` is False, 4150 the figure is closed after saving and ``plt.show()`` is not called. 4151 plot_smoothed_eic : bool, optional 4152 If True, plots smoothed EICs. Default is False. 4153 plot_eic_datapoints : bool, optional 4154 If True, plots EIC data points. Default is False. 4155 eic_buffer_time : float, optional 4156 Time buffer around the peak for EIC plotting (minutes). 4157 If None, uses parameter setting. Default is None. 4158 label_samples : bool, optional 4159 If True, labels each sample in the legend. Default is False. 4160 molecular_metadata : dict, optional 4161 Dictionary mapping molecular IDs to MetaboliteMetadata objects. 4162 Required for MS2_mirror plots. Default is None. 4163 spectral_library : FlashEntropySearch, optional 4164 FlashEntropy spectral library containing MS2 spectra. 4165 Required for MS2_mirror plots to retrieve library spectra. Default is None. 4166 4167 Returns 4168 ------- 4169 matplotlib.figure.Figure or None 4170 The figure object if return_fig=True, otherwise None 4171 4172 Raises 4173 ------ 4174 ValueError 4175 If cluster_id is not found or if required data is not loaded 4176 """ 4177 import matplotlib.pyplot as plt 4178 4179 # Get cluster summary for median values 4180 if cluster_id not in self.cluster_summary_dataframe.index: 4181 raise ValueError( 4182 f"Cluster {cluster_id} not found in cluster_summary_dataframe. " 4183 f"Run add_consensus_mass_features() first." 4184 ) 4185 4186 cluster_summary = self.cluster_summary_dataframe.loc[cluster_id] 4187 4188 # Get representative mass feature info 4189 rep_info = self.get_most_representative_sample_for_cluster(cluster_id) 4190 rep_sample_id = rep_info["sample_id"] 4191 rep_mf_id = rep_info["mf_id"] 4192 rep_sample = self[rep_sample_id] 4193 4194 # Check if representative mass feature is loaded 4195 if rep_mf_id not in rep_sample.mass_features: 4196 raise ValueError( 4197 f"Representative mass feature {rep_mf_id} not loaded in sample {rep_sample.sample_name}. " 4198 f"Run reload_representative_mass_features() or process_consensus_features() first." 4199 ) 4200 4201 rep_mf = rep_sample.mass_features[rep_mf_id] 4202 4203 # Get eic buffer time 4204 if eic_buffer_time is None: 4205 eic_buffer_time = self[0].parameters.lc_ms.eic_buffer_time 4206 4207 # Adjust to_plot based on available data 4208 if rep_mf.mass_spectrum is None: 4209 to_plot = [x for x in to_plot if x != "MS1"] 4210 if len(rep_mf.ms2_mass_spectra) == 0: 4211 to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]] 4212 4213 # Check if EICs are available 4214 cluster_mfs = self.mass_features_dataframe[ 4215 self.mass_features_dataframe["cluster"] == cluster_id 4216 ] 4217 4218 has_eics = False 4219 # Check regular features 4220 for _, row in cluster_mfs.iterrows(): 4221 sample_id = int(row["sample_id"]) 4222 sample = self[sample_id] 4223 if hasattr(sample, "eics") and sample.eics: 4224 if len(sample.eics) > 0: 4225 has_eics = True 4226 break 4227 4228 # Also check induced features if available 4229 induced_cluster_mfs = None 4230 if not has_eics and self.induced_mass_features_dataframe is not None: 4231 induced_cluster_mfs = self.induced_mass_features_dataframe[ 4232 self.induced_mass_features_dataframe["cluster"] == cluster_id 4233 ] 4234 for _, row in induced_cluster_mfs.iterrows(): 4235 sample_id = int(row["sample_id"]) 4236 sample = self[sample_id] 4237 if hasattr(sample, "eics") and sample.eics: 4238 if len(sample.eics) > 0: 4239 has_eics = True 4240 break 4241 4242 if not has_eics: 4243 to_plot = [x for x in to_plot if x != "EIC"] 4244 if len(to_plot) == 0: 4245 raise ValueError( 4246 f"No plottable data available for cluster {cluster_id}. " 4247 f"Run process_consensus_features(gather_eics=True, add_ms1=True, add_ms2=True) first." 4248 ) 4249 4250 # Get induced features if not already retrieved 4251 if ( 4252 induced_cluster_mfs is None 4253 and self.induced_mass_features_dataframe is not None 4254 ): 4255 induced_cluster_mfs = self.induced_mass_features_dataframe[ 4256 self.induced_mass_features_dataframe["cluster"] == cluster_id 4257 ] 4258 4259 # Check if MS1 is deconvoluted 4260 deconvoluted = rep_mf._ms_deconvoluted_idx is not None 4261 4262 # Create figure 4263 fig, axs = plt.subplots( 4264 len(to_plot), 1, figsize=(10, len(to_plot) * 4), squeeze=False 4265 ) 4266 4267 fig.suptitle( 4268 f"Consensus Cluster {cluster_id}: " 4269 f"m/z = {cluster_summary['mz_median']:.4f} " 4270 f"(±{cluster_summary['mz_std']:.4f}); " 4271 f"RT = {cluster_summary['scan_time_aligned_median']:.2f} min " 4272 f"(±{cluster_summary['scan_time_aligned_std']:.2f}); " 4273 f"{int(cluster_summary['sample_id_nunique'])} samples" 4274 ) 4275 4276 i = 0 4277 4278 # EIC plot - show all samples using helper method 4279 if "EIC" in to_plot: 4280 self._plot_multiple_eics( 4281 axs[i][0], 4282 cluster_mfs, 4283 induced_cluster_mfs, 4284 rep_sample_id, 4285 rep_mf_id, 4286 cluster_summary["scan_time_aligned_median"], 4287 eic_buffer_time, 4288 plot_smoothed=plot_smoothed_eic, 4289 plot_datapoints=plot_eic_datapoints, 4290 label_samples=label_samples, 4291 lcms_collection=self, 4292 ) 4293 i += 1 4294 4295 # MS1 plot - from representative using helper method 4296 if "MS1" in to_plot: 4297 rep_mf._plot_ms1_spectrum( 4298 axs[i][0], deconvoluted=deconvoluted, sample_name=rep_sample.sample_name 4299 ) 4300 i += 1 4301 4302 # MS2 plot - from representative using helper method 4303 if "MS2" in to_plot: 4304 rep_mf._plot_ms2_spectrum(axs[i][0], sample_name=rep_sample.sample_name) 4305 i += 1 4306 4307 # MS2 mirror plot - from representative using helper method 4308 if "MS2_mirror" in to_plot: 4309 rep_mf._plot_ms2_mirror( 4310 axs[i][0], 4311 molecular_metadata=molecular_metadata, 4312 spectral_library=spectral_library, 4313 ) 4314 i += 1 4315 4316 plt.tight_layout() 4317 4318 return _finalize_plot(fig, return_fig=return_fig, path=path) 4319 4320 def get_representative_mass_features_for_all_clusters( 4321 self, representative_metric=None 4322 ): 4323 """ 4324 Get the most representative mass feature for all clusters in bulk. 4325 4326 This is much more efficient than calling get_most_representative_sample_for_cluster 4327 in a loop, as it processes all clusters in a single pass over the dataframe. 4328 4329 Parameters 4330 ---------- 4331 representative_metric : str, optional 4332 The metric to use to determine the most representative sample. 4333 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4334 Options: 4335 - 'intensity': Selects the mass feature with the highest intensity 4336 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 4337 or the highest intensity overall if none have MS2 4338 Default is None (uses parameter setting). 4339 4340 Returns 4341 ------- 4342 :obj:`~pandas.DataFrame` 4343 DataFrame with one row per cluster containing: 4344 - cluster: cluster ID 4345 - sample_id: The sample ID of the most representative sample 4346 - mf_id: The mass feature ID in the sample 4347 - coll_mf_id: The collection-level mass feature ID (index) 4348 - has_ms2: Whether this mass feature has MS2 scan numbers 4349 - intensity: The intensity value of the representative mass feature 4350 """ 4351 # Use default from parameters if not specified 4352 if representative_metric is None: 4353 representative_metric = ( 4354 self.parameters.lcms_collection.consensus_representative_metric 4355 ) 4356 4357 mf_df = self.mass_features_dataframe.copy() 4358 # Reset index to make coll_mf_id a column we can work with 4359 mf_df = mf_df.reset_index(drop=False) 4360 4361 # Handle special metric 'intensity_prefer_ms2' 4362 if representative_metric == "intensity_prefer_ms2": 4363 if "intensity" not in mf_df.columns: 4364 raise ValueError( 4365 f"'intensity' column not found in mass_features_dataframe. " 4366 f"Available columns: {mf_df.columns.tolist()}" 4367 ) 4368 4369 # Add has_ms2 flag if ms2_scan_numbers column exists 4370 if "ms2_scan_numbers" in mf_df.columns: 4371 4372 def has_ms2_scans(val): 4373 if val is None: 4374 return False 4375 try: 4376 return len(val) > 0 4377 except (TypeError, ValueError): 4378 return False 4379 4380 mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans) 4381 4382 # Sort by has_ms2 (descending) then intensity (descending) 4383 # This ensures features with MS2 are preferred when intensities are equal 4384 mf_df = mf_df.sort_values( 4385 ["has_ms2", "intensity"], ascending=[False, False] 4386 ) 4387 else: 4388 mf_df["has_ms2"] = False 4389 mf_df = mf_df.sort_values("intensity", ascending=False) 4390 4391 # Group by cluster and take the first (highest intensity, preferring MS2) 4392 representatives = mf_df.groupby("cluster").first().reset_index() 4393 4394 else: 4395 # Standard metric - check if it exists 4396 if representative_metric not in mf_df.columns: 4397 raise ValueError( 4398 f"Metric '{representative_metric}' not found. Available columns: {mf_df.columns.tolist()}" 4399 ) 4400 4401 # Add has_ms2 flag for consistency 4402 if "ms2_scan_numbers" in mf_df.columns: 4403 4404 def has_ms2_scans(val): 4405 if val is None: 4406 return False 4407 try: 4408 return len(val) > 0 4409 except (TypeError, ValueError): 4410 return False 4411 4412 mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans) 4413 else: 4414 mf_df["has_ms2"] = False 4415 4416 # Get the index of max value for each cluster 4417 idx = mf_df.groupby("cluster")[representative_metric].idxmax() 4418 representatives = mf_df.loc[idx].copy() 4419 4420 # Select only the columns we need 4421 result_cols = [ 4422 "cluster", 4423 "sample_id", 4424 "mf_id", 4425 "coll_mf_id", 4426 "has_ms2", 4427 "intensity", 4428 ] 4429 representatives = representatives[result_cols] 4430 4431 return representatives 4432 4433 def get_sample_mf_map_for_representatives( 4434 self, representative_metric=None, include_cluster_id=True 4435 ): 4436 """ 4437 Build a mapping of sample_id -> list of representative mass feature IDs to load. 4438 4439 This is a DRY helper method used by both process_consensus_features() and 4440 ReadSavedLCMSCollection to determine which mass features should be loaded 4441 for each sample when loading representatives. 4442 4443 Parameters 4444 ---------- 4445 representative_metric : str, optional 4446 The metric to use to determine the most representative sample. 4447 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4448 Default is None. 4449 include_cluster_id : bool, optional 4450 If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id. 4451 Default is True. 4452 4453 Returns 4454 ------- 4455 dict 4456 Dictionary mapping sample_id (int) to list of mass feature identifiers. 4457 If include_cluster_id=True: list of tuples (mf_id, cluster_id) 4458 If include_cluster_id=False: list of mf_id integers 4459 4460 Examples 4461 -------- 4462 >>> # Get map with cluster IDs for loading 4463 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives() 4464 >>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...} 4465 >>> 4466 >>> # Get map without cluster IDs for pipeline 4467 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False) 4468 >>> # sample_mf_map = {0: [123, 456], 1: [789], ...} 4469 """ 4470 # Get all representative mass features in bulk (much faster than looping) 4471 representatives = self.get_representative_mass_features_for_all_clusters( 4472 representative_metric=representative_metric 4473 ) 4474 4475 # Build sample_mf_map 4476 sample_mf_map = {} 4477 for _, row in representatives.iterrows(): 4478 sample_id = row["sample_id"] 4479 mf_id = row["mf_id"] 4480 cluster_id = row["cluster"] 4481 4482 if sample_id not in sample_mf_map: 4483 sample_mf_map[sample_id] = [] 4484 4485 if include_cluster_id: 4486 sample_mf_map[sample_id].append((mf_id, cluster_id)) 4487 else: 4488 sample_mf_map[sample_id].append(mf_id) 4489 4490 return sample_mf_map 4491 4492 def get_most_representative_sample_for_cluster( 4493 self, cluster_id, representative_metric=None 4494 ): 4495 """ 4496 Get the most representative sample for a given cluster based on a metric. 4497 4498 Parameters 4499 ---------- 4500 cluster_id : int 4501 The cluster ID to find the representative sample for. 4502 representative_metric : str, optional 4503 The metric to use to determine the most representative sample. 4504 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4505 Options: 4506 - 'intensity': Selects the mass feature with the highest intensity 4507 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 4508 or the highest intensity overall if none have MS2 4509 Default is None (uses parameter setting). 4510 4511 Returns 4512 ------- 4513 dict 4514 Dictionary containing: 4515 - 'sample_id': The sample ID of the most representative sample 4516 - 'sample_name': The sample name of the most representative sample 4517 - 'mf_id': The mass feature ID in the sample 4518 - 'coll_mf_id': The collection-level mass feature ID (index) 4519 - 'has_ms2': Whether this mass feature has MS2 scan numbers 4520 - 'intensity': The intensity value of the representative mass feature 4521 4522 Raises 4523 ------ 4524 ValueError 4525 If cluster_id is not found or if representative_metric is not a valid column. 4526 """ 4527 # Use the bulk method to get all representatives, then filter to this cluster 4528 # This follows DRY principle and ensures consistency 4529 all_representatives = self.get_representative_mass_features_for_all_clusters( 4530 representative_metric=representative_metric 4531 ) 4532 4533 # Filter to the requested cluster 4534 cluster_rep = all_representatives[all_representatives["cluster"] == cluster_id] 4535 4536 if len(cluster_rep) == 0: 4537 # Try to provide helpful error message 4538 available_clusters = self.mass_features_dataframe["cluster"].unique() 4539 raise ValueError( 4540 f"Cluster {cluster_id} not found in mass_features_dataframe. " 4541 f"Available clusters: {sorted(available_clusters[:10].tolist())}... " 4542 f"(showing first 10 of {len(available_clusters)} total clusters)" 4543 ) 4544 4545 # Get the representative row (should only be one) 4546 rep_row = cluster_rep.iloc[0] 4547 4548 # Get sample name from sample_id (convert to int for list indexing) 4549 sample_id = int(rep_row["sample_id"]) 4550 sample_name = self.samples[sample_id] 4551 4552 return { 4553 "sample_id": sample_id, 4554 "sample_name": sample_name, 4555 "mf_id": rep_row["mf_id"], 4556 "coll_mf_id": rep_row["coll_mf_id"], 4557 "has_ms2": rep_row["has_ms2"], 4558 "intensity": rep_row["intensity"], 4559 } 4560 4561 def reload_representative_mass_features( 4562 self, 4563 add_ms2=False, 4564 auto_process_ms2=True, 4565 ms2_spectrum_mode=None, 4566 ms2_scan_filter=None, 4567 ): 4568 """ 4569 Reload mass features for all representative samples in the cluster summary. 4570 4571 This method is useful when the collection was loaded with load_light=True, 4572 which stores mass features only in the collection dataframe. This reloads 4573 the specific mass features that are representatives for each cluster, 4574 allowing them to be accessed as LCMSMassFeature objects. 4575 4576 Parameters 4577 ---------- 4578 add_ms2 : bool, optional 4579 If True, also loads and associates MS2 spectra with mass features. Default is False. 4580 auto_process_ms2 : bool, optional 4581 If True and add_ms2=True, auto-processes MS2 spectra. Default is True. 4582 ms2_spectrum_mode : str or None, optional 4583 Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. 4584 ms2_scan_filter : str or None, optional 4585 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 4586 4587 Returns 4588 ------- 4589 dict 4590 Dictionary mapping sample_id to list of reloaded mf_ids. 4591 4592 Raises 4593 ------ 4594 ValueError 4595 If cluster_summary_dataframe is not set (run add_consensus_mass_features first). 4596 4597 Notes 4598 ----- 4599 - Only reloads mass features that are cluster representatives 4600 - Uses get_most_representative_sample_for_cluster() to determine which to reload 4601 - More memory-efficient than reloading all mass features 4602 - Parallelized based on lcms_collection.cores parameter 4603 - MS2 association uses same logic as add_associated_ms2_dda() 4604 4605 See Also 4606 -------- 4607 _reload_sample_mass_features : Low-level method to reload specific mass features 4608 get_most_representative_sample_for_cluster : Gets representative sample for cluster 4609 """ 4610 # Validate prerequisites 4611 if ( 4612 not hasattr(self, "cluster_summary_dataframe") 4613 or self.cluster_summary_dataframe is None 4614 ): 4615 raise ValueError( 4616 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 4617 ) 4618 4619 # Get all representative mass features in bulk (much faster than looping) 4620 representatives = self.get_representative_mass_features_for_all_clusters() 4621 4622 # Build a dictionary of sample_id -> list of mf_ids that are representatives 4623 sample_mf_map = {} 4624 for _, row in representatives.iterrows(): 4625 sample_id = row["sample_id"] 4626 mf_id = row["mf_id"] 4627 4628 if sample_id not in sample_mf_map: 4629 sample_mf_map[sample_id] = [] 4630 sample_mf_map[sample_id].append(mf_id) 4631 4632 # Reload mass features for each sample (parallelized) 4633 if self.parameters.lcms_collection.cores == 1: 4634 # Serial processing 4635 from tqdm import tqdm 4636 4637 for sample_id in tqdm( 4638 sample_mf_map.keys(), 4639 desc="Reloading representative mass features", 4640 unit="sample", 4641 ): 4642 mf_ids = sample_mf_map[sample_id] 4643 self._reload_sample_mass_features( 4644 sample_id, 4645 mf_ids_to_load=mf_ids, 4646 add_ms2=add_ms2, 4647 auto_process_ms2=auto_process_ms2, 4648 ms2_spectrum_mode=ms2_spectrum_mode, 4649 ms2_scan_filter=ms2_scan_filter, 4650 ) 4651 else: 4652 # Parallel processing 4653 from tqdm import tqdm 4654 4655 if self.parameters.lcms_collection.cores > len(sample_mf_map): 4656 ncores = len(sample_mf_map) 4657 else: 4658 ncores = self.parameters.lcms_collection.cores 4659 4660 # Build arguments list for starmap 4661 args_list = [ 4662 ( 4663 sample_id, 4664 sample_mf_map[sample_id], 4665 add_ms2, 4666 auto_process_ms2, 4667 ms2_spectrum_mode, 4668 ms2_scan_filter, 4669 False, 4670 ) 4671 for sample_id in sample_mf_map.keys() 4672 ] 4673 4674 # Execute in parallel 4675 with spawn_pool(ncores) as pool: 4676 mp_result = pool.starmap(self._reload_sample_mass_features, args_list) 4677 4678 # Collect results back into samples 4679 for i, sample_id in enumerate( 4680 tqdm( 4681 sample_mf_map.keys(), 4682 desc="Collecting reloaded mass features", 4683 unit="sample", 4684 ) 4685 ): 4686 self[sample_id].mass_features = mp_result[i] 4687 4688 return sample_mf_map 4689 4690 def _associate_ms2_with_mass_features( 4691 self, 4692 sample, 4693 local_mf_ids, 4694 auto_process=True, 4695 spectrum_mode=None, 4696 scan_filter=None, 4697 ): 4698 """ 4699 Associate MS2 spectra with specific mass features in a sample. 4700 4701 Uses the LCMSBase helper method to find and load MS2 scans for the specified mass features. 4702 4703 Parameters 4704 ---------- 4705 sample : LCMSBase 4706 The sample object containing mass features and scan data. 4707 local_mf_ids : list of int 4708 List of local (sample-level) mass feature IDs to find MS2 for. 4709 auto_process : bool, optional 4710 If True, auto-processes the MS2 spectra. Default is True. 4711 spectrum_mode : str or None, optional 4712 Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. 4713 scan_filter : str or None, optional 4714 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 4715 4716 Returns 4717 ------- 4718 dict 4719 Dictionary of scan_number -> MassSpectrum objects for the loaded MS2 spectra. 4720 """ 4721 # Check if we have scan data 4722 if not hasattr(sample, "scan_df") or sample.scan_df is None: 4723 return {} 4724 4725 # Separate mass features into those that need scan finding vs those that already have scans 4726 mfs_needing_scan_finding = [] 4727 unique_dda_scans = set() 4728 4729 for mf_id in local_mf_ids: 4730 if mf_id not in sample.mass_features: 4731 continue 4732 mf = sample.mass_features[mf_id] 4733 # If this mass feature already has MS2 scans, add them to our set 4734 if mf.ms2_scan_numbers is not None and len(mf.ms2_scan_numbers) > 0: 4735 # Convert to integers in case they come from HDF5 as numpy types 4736 unique_dda_scans.update([int(scan) for scan in mf.ms2_scan_numbers]) 4737 else: 4738 # Otherwise, we need to find scans for this mass feature 4739 mfs_needing_scan_finding.append(mf_id) 4740 4741 # Only run the scan finding for mass features that need it 4742 if mfs_needing_scan_finding: 4743 found_scans = sample._find_ms2_scans_for_mass_features( 4744 mf_ids=mfs_needing_scan_finding, scan_filter=scan_filter 4745 ) 4746 unique_dda_scans.update(found_scans) 4747 4748 if len(unique_dda_scans) == 0: 4749 return {} 4750 4751 # Get ms2 parameters from sample 4752 # TODO KRH: deal with different ms2 scan types here (CID vs HCD), may need to add scan translator to the initializeion 4753 ms_params = sample.parameters.mass_spectrum["ms2"] 4754 4755 # Load MS2 spectra (convert set to list) 4756 sample.add_mass_spectra( 4757 scan_list=list(unique_dda_scans), 4758 auto_process=auto_process, 4759 spectrum_mode=spectrum_mode, 4760 ms_level=2, 4761 use_parser=True, 4762 ms_params=ms_params, 4763 ) 4764 4765 # Associate MS2 spectra with mass features 4766 for mf_id in local_mf_ids: 4767 if mf_id not in sample.mass_features: 4768 continue 4769 if ( 4770 sample.mass_features[mf_id].ms2_scan_numbers is not None 4771 and len(sample.mass_features[mf_id].ms2_scan_numbers) > 0 4772 ): 4773 for dda_scan in sample.mass_features[mf_id].ms2_scan_numbers: 4774 if dda_scan in sample._ms: 4775 sample.mass_features[mf_id].ms2_mass_spectra[dda_scan] = ( 4776 sample._ms[dda_scan] 4777 ) 4778 4779 # Return only the MS2 spectra we loaded (for parallel processing) 4780 return { 4781 scan: sample._ms[scan] for scan in unique_dda_scans if scan in sample._ms 4782 } 4783 4784 def _reload_sample_mass_features( 4785 self, 4786 sample_id, 4787 mf_ids_to_load=None, 4788 add_ms2=False, 4789 auto_process_ms2=True, 4790 ms2_spectrum_mode=None, 4791 ms2_scan_filter=None, 4792 inplace=True, 4793 ): 4794 """ 4795 Reload specific mass features for a sample from HDF5. 4796 4797 This is useful when the collection was loaded with load_light=True, 4798 which stores mass features only in the collection dataframe and not 4799 as LCMSMassFeature objects in individual samples. 4800 4801 Parameters 4802 ---------- 4803 sample_id : int 4804 The sample ID to reload mass features for. 4805 mf_ids_to_load : list of str, optional 4806 List of collection-level mf_ids (format: '{sample_id}_{local_mf_id}') to load. 4807 If None, loads all mass features for the sample. 4808 add_ms2 : bool, optional 4809 If True, also loads and associates MS2 spectra. Default is False. 4810 auto_process_ms2 : bool, optional 4811 If True, auto-processes MS2 spectra. Default is True. 4812 ms2_spectrum_mode : str or None, optional 4813 Spectrum mode for MS2 spectra. Default is None. 4814 ms2_scan_filter : str or None, optional 4815 Filter string for MS2 scans. Default is None. 4816 inplace : bool, optional 4817 If True, updates the sample's mass_features in place. If False, returns the 4818 mass_features dictionary (for multiprocessing). Default is True. 4819 4820 Returns 4821 ------- 4822 dict or None 4823 If inplace=False, returns dictionary of mass features. 4824 Otherwise returns None and updates object in place. 4825 """ 4826 sample = self[sample_id] 4827 sample_name = self.samples[sample_id] 4828 4829 # Check if we have a collection parser that can reload 4830 if not hasattr(self, "collection_parser") or self.collection_parser is None: 4831 print( 4832 "Warning: Cannot reload mass features - no collection_parser available" 4833 ) 4834 if not inplace: 4835 return {} 4836 return 4837 4838 # Get the HDF5 file for this sample 4839 hdf5_file = ( 4840 self.collection_parser.folder_location 4841 / f"{sample_name}.corems/{sample_name}.hdf5" 4842 ) 4843 4844 if not hdf5_file.exists(): 4845 print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") 4846 if not inplace: 4847 return {} 4848 return 4849 4850 # Import here to avoid circular imports 4851 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 4852 4853 # If specific mf_ids requested, use them directly 4854 local_mf_ids_to_load = None 4855 if mf_ids_to_load is not None: 4856 # mf_ids_to_load is already a list of sample-level mf_ids (integers) 4857 # No parsing needed - they come from the mf_id column in the dataframe 4858 local_mf_ids_to_load = set(mf_ids_to_load) 4859 4860 # Reload mass features from HDF5 4861 with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: 4862 # Load mass features - if specific IDs requested, only load those 4863 parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) 4864 4865 # If add_ms2, associate MS2 spectra with the loaded mass features 4866 if add_ms2 and local_mf_ids_to_load is not None: 4867 self._associate_ms2_with_mass_features( 4868 sample, 4869 list(local_mf_ids_to_load), 4870 auto_process=auto_process_ms2, 4871 spectrum_mode=ms2_spectrum_mode, 4872 scan_filter=ms2_scan_filter, 4873 ) 4874 4875 # Return mass features if not inplace (for multiprocessing) 4876 if not inplace: 4877 return sample.mass_features 4878 4879 def add_sparse_distance_matrix(self, features): 4880 if features is None: 4881 return None 4882 else: 4883 features = features.copy() 4884 4885 # Parameters for calculating distance between features 4886 dims = ["mz", "scan_time_aligned"] 4887 relative = [True, False] 4888 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4889 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 4890 dist_weight = [1, 1] 4891 4892 # Check that the dimensions and tolerances are the same length 4893 if ( 4894 len(dims) != len(tol) 4895 or len(dims) != len(relative) 4896 or len(dims) != len(dist_weight) 4897 ): 4898 raise ValueError( 4899 "The dimensions, tolerances, relative, dist_weight, and na_allow lists must be the same length" 4900 ) 4901 4902 # Make connectivity matrix for masking within sample mass features 4903 ## Masking matrix cmat will mark all features from the same sample as 0 4904 ## To mask, a matrix can be multiplied by cmat and features from same 4905 ## samples are multiplied by 0, while features from different samples 4906 ## are multiplied by 1 4907 4908 if "sample_id" not in features.columns: 4909 cmat = None 4910 else: 4911 vals = features["sample_id"].values.reshape(-1, 1) 4912 cmat = scipy.spatial.distance.cdist(vals, vals) 4913 # Convert to binary (0 if same sample, 1 if different) 4914 cmat = np.where(cmat == 0, 0, 1) 4915 # Convert to coordinate matrix for sparse operations later 4916 cmat = sparse.coo_matrix(cmat) 4917 4918 # Compute inter-feature distances using sparse matrix approach 4919 distances = None # clear the distances object before starting 4920 for i in range(len(dims)): # iterate through all dimensions to be considered 4921 # Construct k-d tree 4922 values = features[dims[i]].values 4923 4924 tree = KDTree(values.reshape(-1, 1)) 4925 4926 max_tol = tol[i] 4927 if relative[i] is True: 4928 # Maximum absolute tolerance 4929 max_tol = tol[i] * values.max() 4930 4931 # Compute sparse distance matrix 4932 # the larger the max_tol, the slower this operation is 4933 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 4934 4935 # Only consider forward case, exclude diagonal 4936 sdm = sparse.triu(sdm, k=1) 4937 4938 # Filter relative distances 4939 if relative[i] is True: 4940 # Compute relative distances 4941 rel_dists = sdm.data / values[sdm.row] 4942 4943 # Indices of relative distances less than tolerance 4944 idx = rel_dists <= tol[i] 4945 4946 # Reconstruct sparse distance matrix 4947 sdm = sparse.coo_matrix( 4948 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 4949 shape=(len(values), len(values)), 4950 ) 4951 4952 # Scaled distances wrt the maximum tolerance for the dimension 4953 sdm.data = sdm.data / tol[i] 4954 4955 # Stack distances for dimensions where na_allow is False 4956 if distances is None: 4957 sdm.data = sdm.data * dist_weight[i] 4958 # Replace zeros with epsilon to handle perfect matches 4959 sdm.data[sdm.data == 0] = 1e-10 4960 distances = sdm 4961 else: 4962 # Prepare sdm to match shape of existing distances 4963 distances_truth = distances.copy() 4964 # make new sparse matrix with same positions as previous 4965 # distance matrix but all ones for values 4966 distances_truth.data = np.ones_like(distances_truth.data) 4967 4968 # Replace zeros with epsilon BEFORE multiply to prevent sparse matrix from dropping them 4969 sdm.data[sdm.data == 0] = 1e-10 4970 4971 # multiply the new sparse matrix (sdm) by this mask to remove 4972 # data that doesn't exist in original sparse matrix 4973 sdm = distances_truth.multiply(sdm) 4974 4975 sdm.data = sdm.data * dist_weight[i] 4976 # Replace zeros with epsilon to handle perfect matches 4977 sdm.data[sdm.data == 0] = 1e-10 4978 4979 # use same process as before to remove data from previous 4980 # distances matrix that isn't in new distances matrix 4981 sdm_truth = sdm.copy() 4982 sdm_truth.data = np.ones_like(sdm_truth.data) 4983 4984 # remove the distances that are not sdm 4985 distances = distances.multiply(sdm_truth) 4986 4987 # Sum the new distances 4988 distances = distances + sdm 4989 4990 # Multiply by connectivity matrix for more masking 4991 distances = distances.multiply(cmat) 4992 4993 # Set attribute holding distance matrix 4994 self._sparse_distance_matrix = distances 4995 4996 def evaluate_clusters_for_repeats(self, features): 4997 raise NotImplementedError("evaluate_clusters_for_repeats not implemented yet") 4998 summary_df = self.cluster_summary_dataframe.copy() 4999 5000 # Arrange by decreasing median intensity 5001 summary_df = summary_df.sort_values( 5002 by="intensity_median", ascending=False 5003 ).reset_index(drop=True) 5004 5005 # Find clusters that are within the mz_tol and rt_tol of each other (on the medians) 5006 # Create a distance matrix 5007 # Define how to calculate the distance between features 5008 dims = ["mz_median", "scan_time_aligned_median"] 5009 relative = [True, False] 5010 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5011 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 5012 5013 # Compute inter-feature distances 5014 distances = None 5015 for i in range(len(dims)): 5016 # Construct k-d tree 5017 values = summary_df[dims[i]].values 5018 tree = KDTree(values.reshape(-1, 1)) 5019 5020 max_tol = tol[i] 5021 if relative[i] is True: 5022 # Maximum absolute tolerance 5023 max_tol = tol[i] * values.max() 5024 5025 # Compute sparse distance matrix 5026 # the larger the max_tol, the slower this operation is 5027 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 5028 5029 # Only consider forward case, exclude diagonal 5030 sdm = sparse.triu(sdm, k=1) 5031 5032 # Filter relative distances 5033 if relative[i] is True: 5034 # Compute relative distances 5035 rel_dists = sdm.data / values[sdm.row] # or col? 5036 5037 # Indices of relative distances less than tolerance 5038 idx = rel_dists <= tol[i] 5039 5040 # Reconstruct sparse distance matrix 5041 sdm = sparse.coo_matrix( 5042 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 5043 shape=(len(values), len(values)), 5044 ) 5045 5046 # Cast as binary matrix 5047 sdm.data = np.ones_like(sdm.data) 5048 5049 # Stack distances 5050 if distances is None: 5051 distances = sdm 5052 else: 5053 distances = distances.multiply(sdm) 5054 5055 # Roll up features 5056 # Extract indices of within-tolerance points 5057 distances = distances.tocoo() 5058 pairs = np.stack( 5059 (distances.row, distances.col), axis=1 5060 ) # These are the index values of the clusters, not the cluster ids 5061 # Conver to cluster ids 5062 pairs_df = pd.DataFrame(pairs, columns=["parent", "child"]) 5063 pairs_df["parent"] = summary_df.loc[pairs[:, 0]]["cluster"].values 5064 pairs_df["child"] = summary_df.loc[pairs[:, 1]]["cluster"].values 5065 pairs_df = pairs_df.set_index("parent") 5066 5067 merge_these_clusters = [] 5068 possible_overlaps = [] 5069 root_parents = np.setdiff1d( 5070 np.unique(pairs_df.index.values), np.unique(pairs_df.child.values) 5071 ) 5072 for parent in root_parents: 5073 parent_features = features[features["cluster"] == parent] 5074 children = pairs_df.loc[[parent], "child"].tolist() 5075 for child in children: 5076 overlap = self.check_merge(parent_features, child, features) 5077 if len(overlap) == 0: 5078 merge_these_clusters.append((parent, child, len(overlap))) 5079 else: 5080 possible_overlaps.append((parent, child, len(overlap))) 5081 5082 result_dict = {} 5083 result_dict["merge_these_clusters"] = merge_these_clusters 5084 result_dict["possible_overlaps"] = possible_overlaps 5085 5086 return result_dict 5087 5088 def check_merge(self, parent_features, child, features): 5089 # Grab the features of the parent and children 5090 child_features = features[features["cluster"] == child] 5091 5092 # Check if there is an overlap between mf_coll_id in the parent and child clusters 5093 overlap = np.intersect1d( 5094 parent_features["sample_id"].values, child_features["sample_id"].values 5095 ) 5096 5097 return overlap 5098 5099 def cluster_mass_features_agg_cluster(self, features): 5100 if features is None: 5101 return None 5102 5103 features = features.copy() 5104 5105 self.add_sparse_distance_matrix(features) 5106 5107 distances = self._sparse_distance_matrix 5108 5109 # Convert to full matrix 5110 distances = distances.todense() 5111 5112 # Cast all 0s to 1s for a distance matrix 5113 distances[distances == 0] = 1 5114 distances = np.asarray(distances) 5115 5116 # Perform clustering 5117 try: 5118 clustering = AgglomerativeClustering( 5119 n_clusters=None, 5120 linkage="complete", 5121 # using complete linkage will prevent one sample from being assigned to multiple clusters 5122 metric="precomputed", 5123 distance_threshold=1, 5124 ).fit(distances) 5125 features["cluster"] = clustering.labels_ 5126 5127 # All data points are singleton clusters 5128 except: 5129 features["cluster"] = np.arange(len(features.index)) 5130 5131 return features 5132 5133 def cluster_inspection_plot(self, clu, return_fig=False, path=None): 5134 """ 5135 Generate Scan Time vs m/z plot for a narrow range around a given 5136 cluster. This tool is meant to support the user in fine tuning the 5137 tolerances used for the clustering algorithm. The user-provided cluster 5138 ID is highlighted in larger, magenta marker and the ten largest of the 5139 remaining clusters are idenfitied with different colors while the 5140 smallest clusters are light gray. 5141 5142 Parameters 5143 ----------- 5144 clu : integer 5145 A cluster ID that exists in self.mass_features_dataframe 5146 return_fig : bool, optional 5147 If True, return the open figure (caller owns lifecycle). 5148 Default is False. 5149 path : str or path-like, optional 5150 If set, save the figure to this path. When ``return_fig`` is False, 5151 the figure is closed after saving and ``plt.show()`` is not called. 5152 5153 Returns 5154 -------- 5155 matplotlib.figure.Figure or None 5156 A figure displaying a scan time vs m/z scatterplot of small region 5157 around a given cluster with the ten largest clusters in the region 5158 distinctly identified if ``return_fig`` is True; otherwise None. 5159 5160 Raises 5161 ------ 5162 ValueError 5163 If cluster data haven't been added to the object yet 5164 """ 5165 5166 if "cluster" not in self.mass_features_dataframe.columns: 5167 raise ValueError( 5168 "Cluster information is not yet added to mass_features_dataframe, must run add_consensus_mass_features() first" 5169 ) 5170 5171 else: 5172 mztol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5173 rttol = self.parameters.lcms_collection.consensus_rt_tol 5174 clu_features = self.mass_features_dataframe.copy() 5175 5176 inclu = clu_features[clu_features.cluster == clu] 5177 exclu = clu_features[clu_features.cluster != clu] 5178 5179 dt_ymin = np.floor(min(inclu.mz)) - 1 5180 dt_ymax = np.ceil(max(inclu.mz)) + 1 5181 dt_xmin = np.floor(min(inclu.scan_time_aligned)) - 1 5182 dt_xmax = np.ceil(max(inclu.scan_time_aligned)) + 1 5183 5184 exclu = exclu[ 5185 (exclu.mz.between(dt_ymin, dt_ymax, inclusive="both")) 5186 & (exclu.scan_time_aligned.between(dt_xmin, dt_xmax, inclusive="both")) 5187 ] 5188 5189 bigclulist = list(exclu.cluster.value_counts()[:10].index) 5190 bigclu = exclu[exclu.cluster.isin(bigclulist)] 5191 smclu = exclu[~exclu.cluster.isin(bigclulist)] 5192 5193 colors = np.arange(0, 10) 5194 colordict = dict(zip(bigclulist, colors)) 5195 bigclu["color"] = bigclu.cluster.apply(lambda x: colordict[x]) 5196 5197 fig = plt.figure(figsize=(7.5, 5)) 5198 5199 plt.scatter( 5200 inclu.scan_time_aligned, 5201 inclu.mz, 5202 c="m", 5203 s=3, 5204 label="Cluster " + str(clu), 5205 ) 5206 5207 plt.scatter( 5208 bigclu.scan_time_aligned, bigclu.mz, c=bigclu.color, cmap="tab10", s=1.5 5209 ) 5210 5211 plt.scatter( 5212 smclu.scan_time_aligned, 5213 smclu.mz, 5214 c="silver", 5215 s=2, 5216 label="Small clusters", 5217 ) 5218 5219 plt.ylim(dt_ymin, dt_ymax) 5220 plt.xlim(dt_xmin, dt_xmax) 5221 plt.legend(ncol=2, bbox_to_anchor=(0.8, -0.1)) 5222 plt.xlabel("Scan time") 5223 plt.ylabel("m/z") 5224 title_str = "Cluster " + str(clu) 5225 title_str += ": representing " + str(len(inclu.sample_id.unique())) 5226 title_str += " of " + str(len(clu_features.sample_id.unique())) 5227 title_str += " samples\n" 5228 title_str += "M/Z tolerance: " + str(mztol) + "\n" 5229 title_str += "Scan Time tolerance: " + str(rttol) 5230 plt.title(title_str, fontsize=10) 5231 5232 return _finalize_plot(fig, return_fig=return_fig, path=path) 5233 5234 def plot_cluster_outlier_frequency( 5235 self, 5236 dim_list=["mz", "scan_time_aligned"], 5237 clu_size_thresh=0.5, 5238 return_fig=False, 5239 path=None, 5240 ): 5241 """ 5242 Generate histogram showing the frequency of outlier occurrences by 5243 clustering dimension across all clusters 5244 5245 Parameters 5246 ----------- 5247 dim_list : list 5248 List of strings describing dimensions that can be used in 5249 clustering. Available list items: 5250 - 'mz' 5251 - 'scan_time_aligned' 5252 - 'half_height_width' 5253 - 'tailing_factor' 5254 - 'dispersity_index' 5255 - 'intensity' 5256 - 'persistence' 5257 clu_size_thresh : float 5258 Value between 0 and 1 that indicates what percentage of samples 5259 need to be present in a cluster before it's evaluated for outliers. 5260 Defaults to 0.5. 5261 return_fig : bool, optional 5262 If True, return the open figure (caller owns lifecycle). 5263 Default is False. 5264 path : str or path-like, optional 5265 If set, save the figure to this path. When ``return_fig`` is False, 5266 the figure is closed after saving and ``plt.show()`` is not called. 5267 5268 Returns 5269 -------- 5270 matplotlib.figure.Figure or None 5271 A figure displaying the frequency of outlier occurrences across all 5272 clusters in the provided measurement dimensions if ``return_fig`` is 5273 True; otherwise None. 5274 5275 Raises 5276 ------ 5277 ValueError 5278 If cluster data haven't been added to the object yet 5279 """ 5280 5281 if not hasattr(self, "cluster_summary_dataframe"): 5282 raise ValueError( 5283 "cluster_summary_dataframe is not yet added, must run add_consensus_mass_features() first" 5284 ) 5285 5286 mfdf = self.mass_features_dataframe.copy() 5287 summarydf = self.cluster_summary_dataframe 5288 5289 numsamples = len(self) 5290 sumdf = ( 5291 summarydf[summarydf.sample_id_nunique > numsamples * clu_size_thresh] 5292 .reset_index(drop=True) 5293 .copy() 5294 ) 5295 5296 ## find the ranges for non-outlier values and add them to sumdf 5297 mergelist = ["cluster"] 5298 for dim in dim_list: 5299 maxtag = dim + "_outmax" 5300 mintag = dim + "_outmin" 5301 mergelist.append(maxtag) 5302 mergelist.append(mintag) 5303 # Calculate outlier thresholds using vectorized operations 5304 sumdf[mintag] = sumdf[dim + "_mean"] - 3 * sumdf[dim + "_std"] 5305 sumdf[maxtag] = sumdf[dim + "_mean"] + 3 * sumdf[dim + "_std"] 5306 ## If NaN shows up anywhere in dim_min, dim_max calculations, value is set to NaN and it's 5307 ## not flagged. This happens when there's not enough values to compute median/std for that 5308 ## dimension therefore can't have outliers 5309 5310 ## add ranges to mfdf and identify mass features that fall outside the ranges 5311 # Merge without dropping NaN - we'll handle it per-dimension 5312 outdf = pd.merge(mfdf, sumdf[mergelist], on="cluster") 5313 5314 outtags = ["cluster"] 5315 for dim in dim_list: 5316 dimtag = dim + "_outlier" 5317 outtags.append(dimtag) 5318 maxtag = dim + "_outmax" 5319 mintag = dim + "_outmin" 5320 # Only flag as outlier if thresholds are valid (not NaN) 5321 outdf[dimtag] = np.where( 5322 (outdf[maxtag].notna() & outdf[mintag].notna()) 5323 & ((outdf[dim] > outdf[maxtag]) | (outdf[dim] < outdf[mintag])), 5324 True, 5325 False, 5326 ) 5327 5328 ## identify number of outliers in each cluster 5329 outliers = outdf[outtags] 5330 outliers = outliers.groupby(["cluster"]).sum() 5331 5332 ## plot number of clusters that contain any outliers 5333 fig = plt.figure() 5334 plt.bar(dim_list, outliers.sum().values, width=0.5) 5335 plt.xticks(rotation=90) 5336 plt.title("Frequency of outliers across all clusters by category") 5337 5338 return _finalize_plot(fig, return_fig=return_fig, path=path) 5339 5340 def _search_for_targeted_mass_features_in_sample( 5341 self, obj_idx, missingdf, cluster_dict, expand_on_miss=False, inplace=True 5342 ): 5343 """ 5344 Helper method to search for missing mass features in a single sample. 5345 5346 Internal method called by fill_missing_cluster_features() to perform 5347 gap-filling for one sample in the collection. 5348 5349 Parameters 5350 ---------- 5351 obj_idx : int 5352 Index of the sample being processed 5353 missingdf : pd.DataFrame 5354 DataFrame containing cluster information with columns: 5355 'cluster', 'sample_id_nunique', 'mz_min', 'mz_max', 5356 'scan_time_aligned_min', 'scan_time_aligned_max', 'mz_min_allowed', 5357 'mz_max_allowed', 'scan_time_aligned_min_allowed', 5358 'scan_time_aligned_max_allowed', 'missing_samples' 5359 cluster_dict : dict 5360 Pre-computed cluster feature dictionary to avoid recomputation 5361 expand_on_miss : bool 5362 If True, expands search window when no peak found initially 5363 inplace : bool 5364 If True, assigns induced_mass_features in place. If False, returns the 5365 induced features dictionary (for multiprocessing) 5366 5367 Returns 5368 ------- 5369 dict or None 5370 If inplace=False, returns dictionary of induced mass features. 5371 Otherwise returns None and updates object in place. 5372 """ 5373 ## Use the pre-computed cluster dictionary passed as parameter 5374 5375 ## to get clusters missing data based on sample index: 5376 sampledf = ( 5377 missingdf[missingdf.missing_samples.apply(lambda x: obj_idx in x)] 5378 .reset_index(drop=True) 5379 .copy() 5380 ) 5381 5382 # Skip if no missing features for this sample 5383 if len(sampledf) == 0: 5384 if not inplace: 5385 return {} 5386 return 5387 5388 self.load_raw_data(obj_idx, 1) 5389 5390 ## this is the line that bugs due to _ms_unprocessed not having key 1 5391 ms1df = self[obj_idx]._ms_unprocessed[1].copy() 5392 scan_df = self[obj_idx].scan_df[["scan", "scan_time_aligned"]] 5393 ms1df = pd.merge(ms1df, scan_df, on="scan") 5394 5395 # Pre-extract all values from sampledf to avoid repeated .iloc calls 5396 clusters = sampledf.cluster.values 5397 mz_mins = sampledf.mz_min.values 5398 mz_maxs = sampledf.mz_max.values 5399 st_mins = sampledf.scan_time_aligned_min.values 5400 st_maxs = sampledf.scan_time_aligned_max.values 5401 5402 if expand_on_miss: 5403 mz_mins_allowed = sampledf.mz_min_allowed.values 5404 mz_maxs_allowed = sampledf.mz_max_allowed.values 5405 st_mins_allowed = sampledf.sta_min_allowed.values 5406 st_maxs_allowed = sampledf.sta_max_allowed.values 5407 5408 # Pre-filter ms1df to reduce search space 5409 mz_global_min = mz_mins.min() 5410 mz_global_max = mz_maxs.max() 5411 st_global_min = st_mins.min() 5412 st_global_max = st_maxs.max() 5413 5414 if expand_on_miss: 5415 mz_global_min = min(mz_global_min, mz_mins_allowed.min()) 5416 mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) 5417 st_global_min = min(st_global_min, st_mins_allowed.min()) 5418 st_global_max = max(st_global_max, st_maxs_allowed.max()) 5419 5420 ms1df_filtered = ms1df[ 5421 (ms1df.mz >= mz_global_min) 5422 & (ms1df.mz <= mz_global_max) 5423 & (ms1df.scan_time_aligned >= st_global_min) 5424 & (ms1df.scan_time_aligned <= st_global_max) 5425 ].copy() 5426 5427 # Generate set_ids for all features 5428 set_ids = [f"c{clusters[i]}_{i}_i" for i in range(len(sampledf))] 5429 5430 # Use batch method to process all features at once 5431 if expand_on_miss: 5432 # First try with normal bounds 5433 peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch( 5434 ms1df_filtered, 5435 mz_mins, 5436 mz_maxs, 5437 st_mins, 5438 st_maxs, 5439 set_ids, 5440 obj_idx=obj_idx, 5441 st_aligned=True, 5442 ) 5443 5444 # Retry failed features with expanded bounds 5445 failed_indices = [ 5446 i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99 5447 ] 5448 if failed_indices: 5449 failed_ids = [set_ids[i] for i in failed_indices] 5450 retry_peaks = self[obj_idx].search_for_targeted_mass_features_batch( 5451 ms1df_filtered, 5452 mz_mins_allowed[failed_indices], 5453 mz_maxs_allowed[failed_indices], 5454 st_mins_allowed[failed_indices], 5455 st_maxs_allowed[failed_indices], 5456 failed_ids, 5457 obj_idx=obj_idx, 5458 st_aligned=True, 5459 ) 5460 peaks_dict.update(retry_peaks) 5461 else: 5462 peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch( 5463 ms1df_filtered, 5464 mz_mins, 5465 mz_maxs, 5466 st_mins, 5467 st_maxs, 5468 set_ids, 5469 obj_idx=obj_idx, 5470 st_aligned=True, 5471 ) 5472 5473 # Assign peaks to induced_mass_features and cluster_dict 5474 for i in range(len(sampledf)): 5475 peak = peaks_dict[set_ids[i]] 5476 self[obj_idx].induced_mass_features[peak.id] = peak 5477 cluster_dict[clusters[i]] += [set_ids[i]] 5478 5479 # TODO KRH: Let's try to avoid these steps unless asked for by parameters to pick up speed 5480 if False: 5481 self[obj_idx].add_associated_ms1(induced_features=True) 5482 # need to set drop_if_fail to false for induced features as they will fail 5483 self[obj_idx].add_peak_metrics(induced_features=True) 5484 5485 # The duplicate scan only deletes keys from mass_features. Induced ids 5486 # are strings such as c12_0_i, so that scan does not remove them. 5487 self[obj_idx].integrate_mass_features( 5488 drop_if_fail=False, induced_features=True, drop_duplicates=False 5489 ) 5490 5491 if not inplace: 5492 return self[obj_idx].induced_mass_features 5493 5494 def _missing_sample_ids_by_cluster(self, mfdf, cluster_ids): 5495 """Sample ids with no feature row for each cluster, in sample-id order. 5496 5497 Includes samples that have no mass features. Lookup matches numpy 5498 integers and integral floats to the cluster key. 5499 """ 5500 all_sample_ids = list(range(len(self.samples))) 5501 if mfdf is None or len(mfdf) == 0: 5502 present_by_cluster = pd.Series(dtype=object) 5503 else: 5504 present_by_cluster = mfdf.groupby("cluster", sort=False)["sample_id"].agg( 5505 lambda ids: set(ids.to_numpy()) 5506 ) 5507 missing = [] 5508 for cluster_id in cluster_ids: 5509 present = present_by_cluster.get(cluster_id, ()) 5510 missing.append( 5511 [sample_id for sample_id in all_sample_ids if sample_id not in present] 5512 ) 5513 return missing 5514 5515 def fill_missing_cluster_features(self): 5516 """ 5517 Gap-filling for consensus mass features across collection samples. 5518 5519 For clusters present in multiple samples but missing from others, searches 5520 raw MS1 data to find peaks in expected m/z and retention time windows. This 5521 creates "induced" mass features for peaks that exist in the data but weren't 5522 detected in the initial peak detection. 5523 5524 Must be run after add_consensus_mass_features(). Results are accessible via 5525 induced_mass_features_dataframe property and included in collection_pivot_table 5526 and collection_consensus_report outputs. 5527 5528 Parameters 5529 ---------- 5530 None 5531 Uses parameters from self.parameters.lcms_collection: 5532 - consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain 5533 a cluster before gap-filling is attempted 5534 - gap_fill_expand_on_miss: If True, expands search window when no peak is found 5535 5536 Returns 5537 ------- 5538 None 5539 Updates induced_mass_features attribute for each LCMSBase object and 5540 combines them into induced_mass_features_dataframe. 5541 5542 Raises 5543 ------ 5544 ValueError 5545 If cluster_summary_dataframe is not set (must run add_consensus_mass_features first). 5546 5547 Notes 5548 ----- 5549 - Loads raw MS1 data for each sample, which may be memory intensive 5550 - Induced features are integrated and metrics calculated automatically 5551 - Processing can be parallelized using parameters.lcms_collection.cores 5552 5553 See Also 5554 -------- 5555 add_consensus_mass_features : Creates consensus features before gap-filling 5556 collection_pivot_table : Includes both regular and induced features 5557 collection_consensus_report : Reports on complete feature matrix 5558 """ 5559 5560 # Validate prerequisites 5561 if ( 5562 not hasattr(self, "cluster_summary_dataframe") 5563 or self.cluster_summary_dataframe is None 5564 ): 5565 raise ValueError( 5566 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 5567 ) 5568 5569 # Get parameters from settings 5570 min_cluster_presence = ( 5571 self.parameters.lcms_collection.consensus_min_sample_fraction 5572 ) 5573 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5574 5575 # Validate parameters 5576 if not 0 <= min_cluster_presence <= 1: 5577 raise ValueError("consensus_min_sample_fraction must be between 0 and 1") 5578 5579 summarydf = self.cluster_summary_dataframe 5580 mfdf = self.mass_features_dataframe 5581 5582 sample_ct = len(self.samples) 5583 5584 # Identify clusters present in sufficient samples but not all samples 5585 missingdf = summarydf[ 5586 [ 5587 "cluster", 5588 "sample_id_nunique", 5589 "mz_min", 5590 "mz_max", 5591 "scan_time_aligned_min", 5592 "scan_time_aligned_max", 5593 ] 5594 ] 5595 missingdf = missingdf[ 5596 missingdf.sample_id_nunique > min_cluster_presence * sample_ct 5597 ] 5598 missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] 5599 5600 # Check if there are any clusters to gap-fill 5601 if len(missingdf) == 0: 5602 return 5603 5604 # One groupby. Sample ids stay in collection order, including samples 5605 # that have no mass features. 5606 missingdf["missing_samples"] = self._missing_sample_ids_by_cluster( 5607 mfdf, missingdf.cluster.to_numpy() 5608 ) 5609 5610 # Calculate expanded search windows for expand_on_miss option 5611 mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5612 rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol 5613 missingdf["mz_max_allowed"] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max 5614 missingdf["mz_min_allowed"] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min 5615 # consensus_rt_tol is an absolute tolerance in minutes, same as clustering. 5616 missingdf["sta_max_allowed"] = missingdf.scan_time_aligned_max + rt_clu_tol 5617 missingdf["sta_min_allowed"] = missingdf.scan_time_aligned_min - rt_clu_tol 5618 5619 # Compute cluster dictionary once to avoid recomputing for each sample 5620 cluster_dict = self.cluster_feature_dictionary 5621 5622 # Process each sample to search for missing features 5623 if self.parameters.lcms_collection.cores == 1: 5624 for i in tqdm(range(sample_ct), desc="Gap-filling samples", unit="sample"): 5625 self._search_for_targeted_mass_features_in_sample( 5626 i, missingdf, cluster_dict, expand_on_miss 5627 ) 5628 5629 if self.parameters.lcms_collection.cores > 1: 5630 if self.parameters.lcms_collection.cores > len(self): 5631 ncores = len(self) 5632 else: 5633 ncores = self.parameters.lcms_collection.cores 5634 with spawn_pool(ncores) as pool: 5635 mp_result = pool.starmap( 5636 self._search_for_targeted_mass_features_in_sample, 5637 [ 5638 (x, missingdf, cluster_dict, expand_on_miss, False) 5639 for x in range(sample_ct) 5640 ], 5641 ) 5642 5643 for i in tqdm( 5644 range(sample_ct), desc="Collecting gap-filled features", unit="sample" 5645 ): 5646 self[i].induced_mass_features = mp_result[i] 5647 5648 self._combine_mass_features(induced_features=True) 5649 5650 # Mark that gap-filling has been performed 5651 self.missing_mass_features_searched = True 5652 5653 for sample_name in self.samples: 5654 self._lcms[sample_name].mass_features = {} 5655 5656 def process_samples_pipeline( 5657 self, operations, description=None, keep_raw_data=False, show_progress=True 5658 ): 5659 """ 5660 Execute a pipeline of operations on all samples in parallel. 5661 5662 This method provides a flexible framework for performing multiple 5663 sample-level operations in a single parallelized pass, which is more 5664 efficient than calling separate methods sequentially. 5665 5666 Parameters 5667 ---------- 5668 operations : list of SampleOperation 5669 List of operations to perform on each sample, in order. 5670 Each operation should be an instance of a class derived from 5671 SampleOperation (see lc_calc_operations module). 5672 description : str or None, optional 5673 Progress bar description. If None, automatically generates description 5674 from operation descriptions (e.g., "gap-filling, reloading features"). 5675 Default is None. 5676 keep_raw_data : bool, optional 5677 If True, keeps raw MS data loaded in memory after pipeline completes. 5678 If False, cleans up raw data to free memory. Default is False. 5679 show_progress : bool, optional 5680 If True, displays progress bars during processing. If False, runs silently. 5681 Default is True. 5682 5683 Returns 5684 ------- 5685 dict 5686 Dictionary with results from pipeline execution, keyed by operation name. 5687 Structure: {operation_name: {sample_id: result, ...}, ...} 5688 5689 Raises 5690 ------ 5691 ValueError 5692 If operations list is empty or contains invalid operations. 5693 5694 Notes 5695 ----- 5696 - Operations are executed sequentially within each sample 5697 - Samples are processed in parallel based on parameters.lcms_collection.cores 5698 - Each operation can have conditional execution via can_execute() 5699 - Results are collected back via collect_results() method of each operation 5700 - Failed operations for a sample are logged but don't halt processing 5701 - Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True 5702 5703 Examples 5704 -------- 5705 >>> from corems.mass_spectra.calc.lc_calc_operations import ( 5706 ... GapFillOperation, ReloadFeaturesOperation 5707 ... ) 5708 >>> ops = [ 5709 ... GapFillOperation('gap_fill', expand_on_miss=True), 5710 ... ReloadFeaturesOperation('reload', add_ms2=True) 5711 ... ] 5712 >>> results = lcms_collection.process_samples_pipeline(ops) 5713 5714 See Also 5715 -------- 5716 lc_calc_operations : Module containing built-in operation classes 5717 fill_and_process_features : Convenience method combining common operations 5718 """ 5719 from corems.mass_spectra.calc.lc_calc_operations import SampleOperation 5720 5721 # Validate operations 5722 if not operations or len(operations) == 0: 5723 raise ValueError("operations list cannot be empty") 5724 5725 for op in operations: 5726 if not isinstance(op, SampleOperation): 5727 raise ValueError( 5728 f"All operations must be SampleOperation instances, got {type(op)}" 5729 ) 5730 5731 # Generate description from operations if not provided 5732 if description is None: 5733 operation_descriptions = [op.description for op in operations] 5734 description = ", ".join(operation_descriptions).capitalize() 5735 5736 # Prepare runtime parameters for each operation 5737 # This is where we gather collection-level data that operations need 5738 runtime_params = self._prepare_pipeline_runtime_params(operations) 5739 runtime_params["keep_raw_data"] = keep_raw_data 5740 5741 # Execute pipeline 5742 sample_ct = len(self.samples) 5743 5744 if self.parameters.lcms_collection.cores == 1: 5745 # Serial processing 5746 results_by_operation = {op.name: {} for op in operations} 5747 5748 if show_progress: 5749 from tqdm import tqdm 5750 5751 # Print description on its own line before progress bar 5752 print(f"\n{description.capitalize()}:") 5753 iterator = tqdm(range(sample_ct), unit="sample", ncols=80) 5754 else: 5755 iterator = range(sample_ct) 5756 5757 for sample_id in iterator: 5758 sample_results = self._execute_sample_pipeline( 5759 sample_id, operations, runtime_params, inplace=True 5760 ) 5761 # Collect results (collect_results already called in _execute_sample_pipeline when inplace=True) 5762 # Skip 'sample_id' key which is added for tracking 5763 for op_name, result in sample_results.items(): 5764 if op_name != "sample_id": 5765 results_by_operation[op_name][sample_id] = result 5766 else: 5767 # Parallel processing 5768 if self.parameters.lcms_collection.cores > sample_ct: 5769 ncores = sample_ct 5770 else: 5771 ncores = self.parameters.lcms_collection.cores 5772 5773 # Build arguments for each sample 5774 args_list = [ 5775 (sample_id, operations, runtime_params, False) 5776 for sample_id in range(sample_ct) 5777 ] 5778 5779 # Execute in parallel with progress tracking 5780 results_by_operation = {op.name: {} for op in operations} 5781 5782 with spawn_pool(ncores) as pool: 5783 if show_progress: 5784 from tqdm import tqdm 5785 import time 5786 5787 # Use starmap_async for parallel execution with progress tracking 5788 async_result = pool.starmap_async( 5789 self._execute_sample_pipeline, args_list 5790 ) 5791 5792 # Poll for completion and update progress bar 5793 print(description) 5794 pbar = tqdm( 5795 total=sample_ct, 5796 desc="", 5797 unit="sample", 5798 position=0, 5799 leave=True, 5800 dynamic_ncols=True, 5801 ) 5802 prev_completed = 0 5803 while not async_result.ready(): 5804 # Get number of completed tasks by checking remaining 5805 completed = sample_ct - async_result._number_left 5806 if completed > prev_completed: 5807 pbar.update(completed - prev_completed) 5808 prev_completed = completed 5809 time.sleep(0.5) # Poll every 500ms to avoid spam 5810 5811 # Final update to 100% 5812 if prev_completed < sample_ct: 5813 pbar.update(sample_ct - prev_completed) 5814 pbar.close() 5815 5816 # Get all results 5817 mp_results = async_result.get() 5818 else: 5819 # Execute without progress 5820 mp_results = pool.starmap(self._execute_sample_pipeline, args_list) 5821 5822 # Collect results back into collection 5823 for result in mp_results: 5824 sample_id = result.get("sample_id") 5825 for op in operations: 5826 op_result = result.get(op.name) 5827 if op_result is not None: 5828 op.collect_results(sample_id, op_result, self) 5829 results_by_operation[op.name][sample_id] = op_result 5830 5831 return results_by_operation 5832 5833 def _prepare_pipeline_runtime_params(self, operations): 5834 """ 5835 Prepare runtime parameters needed by operations in the pipeline. 5836 5837 This method gathers collection-level data that operations need, 5838 such as cluster information for gap-filling or mf_ids for reloading. 5839 5840 Parameters 5841 ---------- 5842 operations : list of SampleOperation 5843 List of operations that will be executed 5844 5845 Returns 5846 ------- 5847 dict 5848 Dictionary of runtime parameters for operations 5849 """ 5850 from corems.mass_spectra.calc.lc_calc_operations import ( 5851 GapFillOperation, 5852 ReloadFeaturesOperation, 5853 MS2SpectralSearchOperation, 5854 LoadEICsOperation, 5855 ) 5856 5857 runtime_params = {} 5858 5859 # Check if any operation needs gap-fill parameters 5860 needs_gap_fill = any(isinstance(op, GapFillOperation) for op in operations) 5861 if needs_gap_fill: 5862 # Prepare gap-fill parameters (same as fill_missing_cluster_features) 5863 min_cluster_presence = ( 5864 self.parameters.lcms_collection.consensus_min_sample_fraction 5865 ) 5866 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5867 5868 summarydf = self.cluster_summary_dataframe 5869 mfdf = self.mass_features_dataframe 5870 sample_ct = len(self.samples) 5871 5872 # Identify clusters needing gap-filling 5873 # Note: cluster_summary_dataframe has 'cluster' as index, need to reset it 5874 missingdf = summarydf.reset_index()[ 5875 [ 5876 "cluster", 5877 "sample_id_nunique", 5878 "mz_min", 5879 "mz_max", 5880 "scan_time_aligned_min", 5881 "scan_time_aligned_max", 5882 ] 5883 ].copy() 5884 missingdf = missingdf[ 5885 missingdf.sample_id_nunique > min_cluster_presence * sample_ct 5886 ] 5887 missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] 5888 5889 if len(missingdf) > 0: 5890 # One groupby. Sample ids stay in collection order, including samples 5891 # that have no mass features. 5892 missingdf["missing_samples"] = self._missing_sample_ids_by_cluster( 5893 mfdf, missingdf.cluster.to_numpy() 5894 ) 5895 5896 # Calculate expanded search windows 5897 mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5898 rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol 5899 missingdf["mz_max_allowed"] = ( 5900 missingdf.mz_max + mz_clu_tol * missingdf.mz_max 5901 ) 5902 missingdf["mz_min_allowed"] = ( 5903 missingdf.mz_min - mz_clu_tol * missingdf.mz_min 5904 ) 5905 # consensus_rt_tol is an absolute tolerance in minutes, same as clustering. 5906 missingdf["sta_max_allowed"] = ( 5907 missingdf.scan_time_aligned_max + rt_clu_tol 5908 ) 5909 missingdf["sta_min_allowed"] = ( 5910 missingdf.scan_time_aligned_min - rt_clu_tol 5911 ) 5912 5913 runtime_params["missingdf"] = missingdf 5914 runtime_params["cluster_dict"] = self.cluster_feature_dictionary 5915 runtime_params["expand_on_miss"] = expand_on_miss 5916 5917 # Check if any operation needs reload parameters 5918 needs_reload = any(isinstance(op, ReloadFeaturesOperation) for op in operations) 5919 if needs_reload: 5920 # Use DRY helper method to build sample_mf_map 5921 sample_mf_map = self.get_sample_mf_map_for_representatives( 5922 include_cluster_id=False 5923 ) 5924 runtime_params["sample_mf_map"] = sample_mf_map 5925 5926 # Check if any operation needs MS2 spectral search parameters 5927 needs_ms2_search = any( 5928 isinstance(op, MS2SpectralSearchOperation) for op in operations 5929 ) 5930 if needs_ms2_search: 5931 # Pass through pre-prepared spectral library 5932 if hasattr(self, "_spectral_lib") and self._spectral_lib is not None: 5933 runtime_params["fe_lib"] = self._spectral_lib 5934 if hasattr(self, "_spectral_search_molecular_metadata"): 5935 runtime_params["molecular_metadata"] = ( 5936 self._spectral_search_molecular_metadata 5937 ) 5938 5939 # Check if any operation needs EIC loading parameters 5940 needs_eic_loading = any(isinstance(op, LoadEICsOperation) for op in operations) 5941 if needs_eic_loading: 5942 # Map sample_id -> m/z list for LoadEICsOperation. 5943 # Must use collection *dataframes*, not only in-memory mass_features: 5944 # after load_representatives=True each sample holds only a sparse set 5945 # of representative objects, so object-only walks miss most cluster 5946 # members and plot_cluster shows incomplete multi-sample EICs (#258). 5947 runtime_params["cluster_mz_dict"] = ( 5948 self._build_cluster_mz_dict_for_eic_loading() 5949 ) 5950 5951 return runtime_params 5952 5953 def _build_cluster_mz_dict_for_eic_loading(self): 5954 """Build sample_id -> list of EIC m/z targets for all clustered features. 5955 5956 Uses ``mass_features_dataframe`` (and induced dataframe if present). 5957 Prefers ``_eic_mz``, falls back to ``mz``. Does not require feature 5958 objects to be loaded in ``sample.mass_features``. 5959 5960 Returns 5961 ------- 5962 dict 5963 Mapping of sample_id (int) to unique float m/z values to load. 5964 """ 5965 cluster_mz_dict = {} 5966 5967 def _add_from_df(df): 5968 if df is None or len(df) == 0: 5969 return 5970 if "cluster" in df.columns: 5971 df = df[df["cluster"].notna()] 5972 if len(df) == 0 or "sample_id" not in df.columns: 5973 return 5974 for sample_id, sample_df in df.groupby("sample_id"): 5975 mzs = [] 5976 if "_eic_mz" in sample_df.columns: 5977 mzs.extend(sample_df["_eic_mz"].dropna().tolist()) 5978 if "mz" in sample_df.columns: 5979 # Include mz for rows with missing _eic_mz 5980 if "_eic_mz" in sample_df.columns: 5981 missing = sample_df["_eic_mz"].isna() 5982 mzs.extend(sample_df.loc[missing, "mz"].dropna().tolist()) 5983 else: 5984 mzs.extend(sample_df["mz"].dropna().tolist()) 5985 sid = int(sample_id) 5986 existing = cluster_mz_dict.get(sid, []) 5987 cluster_mz_dict[sid] = list(set(existing + [float(m) for m in mzs])) 5988 5989 # Regular clustered features only: these are what were exported to per-sample 5990 # HDF5 EICs. Induced (gap-filled) features get EICs at gap-fill time on the 5991 # sample object; they are typically not present in the original HDF5 eics group. 5992 _add_from_df(self.mass_features_dataframe) 5993 5994 return cluster_mz_dict 5995 5996 def _execute_sample_pipeline( 5997 self, sample_id, operations, runtime_params, inplace=True 5998 ): 5999 """ 6000 Execute a pipeline of operations on a single sample. 6001 6002 This is the worker function called (potentially in parallel) for each sample. 6003 6004 Parameters 6005 ---------- 6006 sample_id : int 6007 Sample ID to process 6008 operations : list of SampleOperation 6009 Operations to execute in order 6010 runtime_params : dict 6011 Runtime parameters prepared by _prepare_pipeline_runtime_params 6012 inplace : bool, optional 6013 If True, updates sample in place. If False, returns results for 6014 multiprocessing. Default is True. 6015 6016 Returns 6017 ------- 6018 dict 6019 Dictionary with results from each operation, keyed by operation name. 6020 If inplace=True, returns results that need to be collected. 6021 If inplace=False, returns all results for multiprocessing collection. 6022 """ 6023 results = {} 6024 6025 # Check if any operations need raw MS data 6026 needs_raw_data = {} # {ms_level: True/False} 6027 for op in operations: 6028 needs_raw, ms_level = op.needs_raw_ms_data() 6029 if needs_raw and ms_level: 6030 needs_raw_data[ms_level] = True 6031 6032 # Load raw data once if any operations need it 6033 # Note: For gap-filling, it loads data internally, so we just track it here 6034 for ms_level in needs_raw_data.keys(): 6035 # Gap-filling loads its own data, but we want to keep track that it's loaded 6036 # Other operations can then use the loaded data 6037 pass 6038 6039 for op in operations: 6040 # Check if operation can execute on this sample 6041 sample = self[sample_id] 6042 if not op.can_execute(sample, self): 6043 # Skip this operation for this sample if prerequisites aren't met 6044 # This allows processing to continue for samples that don't have 6045 # all required data (e.g., MS2 spectra) 6046 results[op.name] = None 6047 continue 6048 6049 # Prepare operation-specific runtime params 6050 op_runtime_params = {} 6051 6052 # Add gap-fill params if this is a gap-fill operation 6053 from corems.mass_spectra.calc.lc_calc_operations import ( 6054 GapFillOperation, 6055 ReloadFeaturesOperation, 6056 MS2SpectralSearchOperation, 6057 LoadEICsOperation, 6058 ) 6059 6060 if isinstance(op, GapFillOperation): 6061 if "missingdf" in runtime_params: 6062 op_runtime_params["missingdf"] = runtime_params["missingdf"] 6063 op_runtime_params["cluster_dict"] = runtime_params["cluster_dict"] 6064 op_runtime_params["expand_on_miss"] = runtime_params[ 6065 "expand_on_miss" 6066 ] 6067 6068 elif isinstance(op, ReloadFeaturesOperation): 6069 if "sample_mf_map" in runtime_params: 6070 sample_mf_map = runtime_params["sample_mf_map"] 6071 # Always pass mf_ids_to_load to ensure we only load what's needed 6072 # If sample not in map, it has no representatives - pass empty list 6073 op_runtime_params["mf_ids_to_load"] = sample_mf_map.get( 6074 sample_id, [] 6075 ) 6076 6077 elif isinstance(op, MS2SpectralSearchOperation): 6078 # Add MS2 spectral search parameters 6079 if "fe_lib" in runtime_params: 6080 op_runtime_params["fe_lib"] = runtime_params["fe_lib"] 6081 if "molecular_metadata" in runtime_params: 6082 op_runtime_params["molecular_metadata"] = runtime_params[ 6083 "molecular_metadata" 6084 ] 6085 6086 elif isinstance(op, LoadEICsOperation): 6087 # Add EIC loading parameters 6088 if "cluster_mz_dict" in runtime_params: 6089 op_runtime_params["cluster_mz_dict"] = runtime_params[ 6090 "cluster_mz_dict" 6091 ] 6092 6093 # Execute the operation 6094 result = op.execute(sample_id, self, **op_runtime_params) 6095 results[op.name] = result 6096 6097 # If inplace, collect immediately 6098 if inplace and result is not None: 6099 op.collect_results(sample_id, result, self) 6100 6101 # Clean up raw data if requested 6102 keep_raw_data = runtime_params.get("keep_raw_data", False) 6103 if not keep_raw_data: 6104 for ms_level in needs_raw_data.keys(): 6105 if ms_level in self[sample_id]._ms_unprocessed: 6106 del self[sample_id]._ms_unprocessed[ms_level] 6107 6108 # Include sample_id in results for tracking (especially important for imap_unordered) 6109 results["sample_id"] = sample_id 6110 return results 6111 6112 def process_consensus_features( 6113 self, 6114 load_representatives=True, 6115 perform_gap_filling=True, 6116 add_ms1=False, 6117 add_ms2=False, 6118 ms2_scan_filter=None, 6119 molecular_formula_search=False, 6120 ms2_spectral_search=False, 6121 spectral_lib=None, 6122 molecular_metadata=None, 6123 gather_eics=False, 6124 group_features=False, 6125 keep_raw_data=False, 6126 show_progress=True, 6127 ): 6128 """ 6129 Process consensus mass features across the collection in a single parallelized pass. 6130 6131 This method provides a convenient interface to the sample processing pipeline, 6132 allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association, 6133 molecular formula search, and MS2 spectral search) to be performed efficiently in 6134 a single pass through all samples. 6135 6136 Parameters 6137 ---------- 6138 load_representatives : bool, optional 6139 If True, loads representative mass features from HDF5. Default is True. 6140 perform_gap_filling : bool, optional 6141 If True, performs gap-filling for missing cluster features. Default is True. 6142 This operation loads raw MS1 data which can be reused by subsequent operations. 6143 group_features : bool, optional 6144 If True, run ``group_consensus_features()`` after gap-fill (and before 6145 molecular formula / MS2 search when those are also enabled). 6146 Collection-level natural-abundance **isotope and adduct** family 6147 labeling (singly charged only; not tracer/enriched). Default is False. 6148 add_ms1 : bool, optional 6149 If True and load_representatives=True, associates MS1 spectra with 6150 loaded features. Automatically uses raw data from gap-filling if available, 6151 otherwise uses parser. Spectrum mode is auto-detected. Default is False. 6152 add_ms2 : bool, optional 6153 If True and load_representatives=True, associates MS2 spectra with 6154 loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False. 6155 ms2_scan_filter : str or None, optional 6156 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 6157 molecular_formula_search : bool, optional 6158 If True, performs molecular formula search on mass features using 6159 associated MS1 spectra. Requires add_ms1=True or that MS1 spectra 6160 are already associated. Uses parameters from 6161 parameters.mass_spectrum["ms1"].molecular_search. Default is False. 6162 ms2_spectral_search : bool, optional 6163 If True, performs MS2 spectral library search using FlashEntropy. 6164 Requires add_ms2=True and spectral_lib to be provided. Default is False. 6165 spectral_lib : FlashEntropy library, optional 6166 Pre-prepared FlashEntropy spectral library for MS2 search. 6167 Create using MSPInterface.get_metabolomics_spectra_library(). 6168 Required if ms2_spectral_search=True. Default is None. 6169 molecular_metadata : pd.DataFrame, optional 6170 Molecular metadata corresponding to spectral_lib. 6171 Returned from MSPInterface.get_metabolomics_spectra_library(). 6172 Stored as self.spectral_search_molecular_metadata for later export. 6173 Default is None. 6174 gather_eics : bool, optional 6175 If True, loads extracted ion chromatograms (EICs) from HDF5 for all 6176 mass features with assigned cluster_index (including gap-filled features). 6177 Enables access to EICs via get_eics_for_cluster(cluster_id) method. 6178 Requires that EICs were previously exported with export_eics=True. 6179 Default is False. 6180 keep_raw_data : bool, optional 6181 If True, keeps raw MS data loaded in memory after pipeline completes. 6182 If False, cleans up raw data to free memory. Default is False. 6183 show_progress : bool, optional 6184 If True, displays progress bars during processing. If False, runs silently. 6185 Default is True. 6186 6187 Returns 6188 ------- 6189 dict 6190 Dictionary with pipeline results. Keys include: 6191 - 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling) 6192 - 'reload': dict mapping sample_id to reloaded mass features (if reloading) 6193 - 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search) 6194 - 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search) 6195 6196 Raises 6197 ------ 6198 ValueError 6199 If neither operation is enabled, or if required parameters are missing. 6200 6201 Notes 6202 ----- 6203 - Must run add_consensus_mass_features() before calling this method 6204 - Processes samples in parallel based on parameters.lcms_collection.cores 6205 - Raw MS1 data loaded by gap-filling is automatically reused by MS1 association 6206 - MS2 spectral search requires add_ms2=True and msp_file_path 6207 - FlashEntropy library is created once and reused across all samples 6208 - More efficient than calling individual methods separately 6209 - After gap-filling, sets missing_mass_features_searched = True 6210 - Mass features remain loaded in memory for downstream processing 6211 - For more advanced workflows, use process_samples_pipeline() directly 6212 6213 Examples 6214 -------- 6215 >>> # Prepare spectral library for MS2 search 6216 >>> from corems.molecular_id.search.database_interfaces import MSPInterface 6217 >>> my_msp = MSPInterface(file_path='path/to/library.msp') 6218 >>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library( 6219 ... polarity='negative', 6220 ... format='flashentropy', 6221 ... normalize=True, 6222 ... fe_kwargs={ 6223 ... 'normalize_intensity': True, 6224 ... 'min_ms2_difference_in_da': 0.02, 6225 ... 'max_ms2_tolerance_in_da': 0.01, 6226 ... 'max_indexed_mz': 3000, 6227 ... 'precursor_ions_removal_da': None, 6228 ... 'noise_threshold': 0, 6229 ... } 6230 ... ) 6231 >>> 6232 >>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search 6233 >>> results = lcms_collection.process_consensus_features( 6234 ... load_representatives=True, 6235 ... perform_gap_filling=True, 6236 ... add_ms1=True, 6237 ... add_ms2=True, 6238 ... molecular_formula_search=True, 6239 ... ms2_spectral_search=True, 6240 ... spectral_lib=spectral_lib, 6241 ... molecular_metadata=molecular_metadata 6242 ... ) 6243 6244 See Also 6245 -------- 6246 process_samples_pipeline : Generic pipeline executor for custom workflows 6247 fill_missing_cluster_features : Original gap-filling method 6248 reload_representative_mass_features : Original reload method 6249 """ 6250 from corems.mass_spectra.calc.lc_calc_operations import ( 6251 GapFillOperation, 6252 ReloadFeaturesOperation, 6253 MolecularFormulaSearchOperation, 6254 MS2SpectralSearchOperation, 6255 LoadEICsOperation, 6256 ) 6257 6258 # Validate that at least one meaningful operation is enabled 6259 has_operations = ( 6260 perform_gap_filling 6261 or load_representatives 6262 or molecular_formula_search 6263 or ms2_spectral_search 6264 or gather_eics 6265 or add_ms1 6266 or add_ms2 6267 or group_features 6268 ) 6269 6270 if not has_operations: 6271 raise ValueError( 6272 "At least one operation must be enabled: perform_gap_filling, load_representatives, " 6273 "molecular_formula_search, ms2_spectral_search, gather_eics, add_ms1, add_ms2, " 6274 "or group_features" 6275 ) 6276 6277 # Validate prerequisites for gap-filling 6278 if perform_gap_filling: 6279 if ( 6280 not hasattr(self, "cluster_summary_dataframe") 6281 or self.cluster_summary_dataframe is None 6282 ): 6283 raise ValueError( 6284 "Cannot perform gap-filling: cluster_summary_dataframe not set. " 6285 "You must run add_consensus_mass_features() before calling process_consensus_features()." 6286 ) 6287 6288 # Validate prerequisites for MS2 spectral search 6289 if ms2_spectral_search: 6290 if spectral_lib is None: 6291 raise ValueError( 6292 "MS2 spectral search requires spectral_lib to be provided. " 6293 "Create it using MSPInterface.get_metabolomics_spectra_library() before calling this method." 6294 ) 6295 # Check if mass features will be loaded OR are already loaded 6296 # (The operation's can_execute will check if MS2 spectra are actually present) 6297 if not load_representatives and not perform_gap_filling: 6298 # Check if at least one sample has mass features loaded 6299 # This allows MS2 search on already-loaded features 6300 has_loaded_features = any( 6301 len(self[i].mass_features) > 0 6302 if hasattr(self[i], "mass_features") 6303 and self[i].mass_features is not None 6304 else False 6305 for i in range(len(self.samples)) 6306 ) 6307 if not has_loaded_features: 6308 raise ValueError( 6309 "MS2 spectral search requires mass features to be loaded. " 6310 "Either set load_representatives=True or perform_gap_filling=True to load them, " 6311 "or load them in a previous call to process_consensus_features() before calling " 6312 "with ms2_spectral_search=True." 6313 ) 6314 6315 # Build pipeline in two phases so group_features runs after gap-fill 6316 # and before formula / MS2 search when those are requested in the same call. 6317 pre_operations = [] 6318 post_operations = [] 6319 6320 if perform_gap_filling: 6321 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 6322 pre_operations.append( 6323 GapFillOperation("gap_fill", expand_on_miss=expand_on_miss) 6324 ) 6325 6326 if load_representatives: 6327 # MS1/MS2 association for representatives can happen in pre-pass; 6328 # formula/MS2 library search stay in post-pass after grouping. 6329 pre_operations.append( 6330 ReloadFeaturesOperation( 6331 "reload", 6332 add_ms1=add_ms1, 6333 add_ms2=add_ms2, 6334 auto_process_ms2=add_ms2, 6335 ms2_scan_filter=ms2_scan_filter, 6336 ) 6337 ) 6338 6339 if molecular_formula_search: 6340 post_operations.append(MolecularFormulaSearchOperation("mf_search")) 6341 6342 if ms2_spectral_search: 6343 post_operations.append( 6344 MS2SpectralSearchOperation( 6345 "ms2_search", ms2_scan_filter=ms2_scan_filter 6346 ) 6347 ) 6348 self._spectral_lib = spectral_lib 6349 self._spectral_search_molecular_metadata = molecular_metadata 6350 6351 if gather_eics: 6352 # After grouping when grouping is requested; otherwise with pre-pass 6353 # (or post-pass if only annotation ops are enabled). 6354 if group_features or not pre_operations: 6355 post_operations.append(LoadEICsOperation("load_eics")) 6356 else: 6357 pre_operations.append(LoadEICsOperation("load_eics")) 6358 6359 results = {} 6360 6361 def _run_ops(operations): 6362 if not operations: 6363 return {} 6364 return self.process_samples_pipeline( 6365 operations, keep_raw_data=keep_raw_data, show_progress=show_progress 6366 ) 6367 6368 # Phase 1: gap-fill / reload (and eics if not grouping) 6369 if pre_operations: 6370 results.update(_run_ops(pre_operations)) 6371 6372 # Post-processing for gap-fill must happen before grouping so heights include induced 6373 if perform_gap_filling: 6374 self._combine_mass_features(induced_features=True) 6375 self.missing_mass_features_searched = True 6376 6377 if ( 6378 self.induced_mass_features_dataframe is not None 6379 and len(self.induced_mass_features_dataframe) > 0 6380 ): 6381 eics_mz = [] 6382 for i, row in self.induced_mass_features_dataframe.iterrows(): 6383 sample_id = row["sample_id"] 6384 sample = self[sample_id] 6385 if row["mf_id"] in sample.induced_mass_features.keys(): 6386 eic_mz = sample.induced_mass_features[row["mf_id"]]._eic_mz 6387 eics_mz.append(eic_mz) 6388 else: 6389 eics_mz.append(None) 6390 self.induced_mass_features_dataframe["_eic_mz"] = eics_mz 6391 6392 # Collection-level isotope + adduct grouping 6393 if group_features: 6394 t_group = time.perf_counter() 6395 labels = self.group_consensus_features() 6396 group_wall = time.perf_counter() - t_group 6397 results["group_features"] = { 6398 "n_clusters": len(labels), 6399 "n_grouped": int(labels["feature_group_id"].notna().sum()), 6400 "n_groups": int(labels["feature_group_id"].nunique(dropna=True)), 6401 "wall_seconds": group_wall, 6402 "stage_timings": getattr(self, "_last_feature_group_timings", None), 6403 } 6404 6405 # Phase 2: annotation / optional EICs after grouping 6406 if post_operations: 6407 # If only post_operations and no pre, still need a pipeline run 6408 post_results = _run_ops(post_operations) 6409 for k, v in post_results.items(): 6410 results[k] = v 6411 6412 # Store molecular metadata if spectral search was performed 6413 if ms2_spectral_search and hasattr(self, "_spectral_search_molecular_metadata"): 6414 self.spectral_search_molecular_metadata = ( 6415 self._spectral_search_molecular_metadata 6416 ) 6417 6418 # Associate EICs while induced feature objects still exist (before any clear). 6419 if gather_eics: 6420 print("\nAssociating EICs with mass features:") 6421 from tqdm import tqdm 6422 6423 for sample_id in tqdm(range(len(self.samples)), unit="sample", ncols=80): 6424 sample = self[sample_id] 6425 if sample.eics: 6426 sample.associate_eics_with_mass_features(induced=False) 6427 sample.associate_eics_with_mass_features(induced=True) 6428 6429 # Drop induced feature objects to free memory after grouping + post-ops 6430 if perform_gap_filling: 6431 for sample_name in self.samples: 6432 self._lcms[sample_name].induced_mass_features = {} 6433 6434 return results
Methods for performing calculations related to LCMSCollection objects.
Notes
This class is intended as a mixin for the LCMSCollection class.
2910 def clean_sparse_matrix(self, sparse_matrix): 2911 """Clean a sparse matrix by removing duplicates and sorting. 2912 2913 Parameters 2914 ---------- 2915 sparse_matrix : :obj:`~numpy.array` 2916 A sparse matrix to clean. 2917 2918 Returns 2919 ------- 2920 :obj:`~numpy.array` 2921 A cleaned sparse matrix. 2922 """ 2923 for match in sparse_matrix: 2924 match.sort() 2925 sparse_matrix.sort() 2926 dereplicated_sparse_matrix = np.unique(sparse_matrix, axis=0) 2927 return dereplicated_sparse_matrix
Clean a sparse matrix by removing duplicates and sorting.
Parameters
- sparse_matrix (
~numpy.array): A sparse matrix to clean.
Returns
~numpy.array: A cleaned sparse matrix.
2929 def match_mfs(self, mf_c, mf_i): 2930 """Match mass features between two LCMS objects. 2931 2932 Parameters 2933 ---------- 2934 mf_c : :obj:`~pandas.DataFrame` 2935 The mass features to match against. 2936 mf_i : :obj:`~pandas.DataFrame` 2937 The mass features to match. 2938 2939 Returns 2940 ------- 2941 :obj:`~pandas.DataFrame` 2942 The matched mass features from mf_c. 2943 :obj:`~pandas.DataFrame` 2944 The matched mass features from mf_i. 2945 2946 Notes 2947 ----- 2948 This function has been adapted from the original implementation in the Deimos package: 2949 https://github.com/pnnl/deimos 2950 """ 2951 if mf_c is None or mf_i is None or len(mf_c.index) < 1 or len(mf_i.index) < 1: 2952 return None, None 2953 2954 # Prepare dataframes 2955 mf_c = mf_c.copy() 2956 mf_c["id_i"] = 0 2957 mf_i = mf_i.copy() 2958 mf_i["id_i"] = 1 2959 2960 # Set dimensions for matching 2961 dims = ["mz", "scan_time"] 2962 relative = [True, False] 2963 mz_tol = self.parameters.lcms_collection.alignment_mz_tol_ppm * 1e-6 2964 rt_tol = self.parameters.lcms_collection.alignment_rt_tol 2965 tol = [mz_tol, rt_tol] 2966 2967 # Compute inter-feature distances 2968 idx = [] 2969 for i, f in enumerate(dims): 2970 # vectors 2971 v1 = mf_c[f].values.reshape(-1, 1) 2972 v2 = mf_i[f].values.reshape(-1, 1) 2973 2974 # Distances 2975 d = scipy.spatial.distance.cdist(v1, v2) 2976 2977 if relative[i] is True: 2978 # Divisor 2979 basis = np.repeat(v1, v2.shape[0], axis=1) 2980 fix = np.repeat(v2, v1.shape[0], axis=1).T 2981 basis = np.where(basis == 0, fix, basis) 2982 2983 # Divide 2984 d = np.divide(d, basis, out=np.zeros_like(basis), where=basis != 0) 2985 2986 # Check tol 2987 idx.append(d <= tol[i]) 2988 2989 # Stack truth arrays 2990 idx = np.prod(np.dstack(idx), axis=-1, dtype=bool) 2991 2992 # Compute normalized 3d distance 2993 v1 = mf_c[dims].values / tol 2994 v2 = mf_i[dims].values / tol 2995 dist3d = scipy.spatial.distance.cdist(v1, v2, "cityblock") 2996 2997 # Separate features within tolerance from those outside 2998 # Features outside tolerance should be inf, features within tolerance keep their distance 2999 # Use idx mask: True for within tolerance, False for outside 3000 dist3d_within_tol = np.where(idx, dist3d, np.inf) 3001 3002 # Normalize to 0-1 (only affects within-tolerance distances) 3003 mx = np.max(dist3d_within_tol[idx]) if np.sum(idx) > 0 else 0 3004 if mx > 0: 3005 # Lower distance is better - normalize only the within-tolerance values 3006 dist3d_within_tol = np.where(idx, dist3d_within_tol / mx, np.inf) 3007 else: 3008 # All matches are perfect (distance=0), assign tiny value to within-tolerance pairs 3009 dist3d_within_tol = np.where(idx, 1e-10, np.inf) 3010 3011 # Use the masked distance matrix 3012 dist3d = dist3d_within_tol 3013 3014 # Min over dims 3015 mincols = np.min(dist3d, axis=0, keepdims=True) 3016 3017 # Zero out mincols over dims 3018 dist3d[dist3d != mincols] = np.inf 3019 3020 # Min over clusters 3021 minrows = np.min(dist3d, axis=1, keepdims=True) 3022 3023 # Where max and nonzero 3024 ii, jj = np.where((dist3d == minrows) & (dist3d < np.inf)) 3025 3026 # Reorder 3027 mf_c = mf_c.iloc[ii] 3028 mf_i = mf_i.iloc[jj] 3029 3030 if len(mf_c.index) < 1 or len(mf_i.index) < 1: 3031 return None, None 3032 3033 return mf_c, mf_i
Match mass features between two LCMS objects.
Parameters
- mf_c (
~pandas.DataFrame): The mass features to match against. - mf_i (
~pandas.DataFrame): The mass features to match.
Returns
~pandas.DataFrame: The matched mass features from mf_c.~pandas.DataFrame: The matched mass features from mf_i.
Notes
This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
3035 def fit_rts(self, a, b, align="scan_time", **kwargs): 3036 """ 3037 Fit a support vector regressor to matched features. 3038 3039 Parameters 3040 ---------- 3041 a : :obj:`~pandas.DataFrame` 3042 First set of input feature coordinates and intensities; the center object and the object to align to. 3043 b : :obj:`~pandas.DataFrame` 3044 Second set of input feature coordinates and intensities; the object to align to the center object. 3045 align : str 3046 Dimension to align. 3047 kwargs 3048 Keyword arguments for support vector regressor 3049 (:class:`sklearn.svm.SVR`). 3050 3051 Returns 3052 ------- 3053 :obj:`~function` 3054 An interpolation function where one can input a retention time and get the predicted retention time. 3055 3056 Notes 3057 ----- 3058 This function has been adapted from the original implementation in the Deimos package: 3059 https://github.com/pnnl/deimos 3060 3061 """ 3062 3063 # Uniqueify 3064 x = a[align].values 3065 y = b[align].values 3066 arr = np.vstack((x, y)).T 3067 arr = np.unique(arr, axis=0) 3068 3069 # Safety check: ensure we have data to work with 3070 if len(arr) == 0: 3071 warnings.warn( 3072 "No data points available for retention time fitting. Returning identity function." 3073 ) 3074 return lambda x: x 3075 3076 # Check kwargs 3077 if "kernel" in kwargs: 3078 kernel = kwargs.get("kernel") 3079 else: 3080 kernel = "linear" 3081 3082 # Construct interpolation axis 3083 newx = np.linspace(arr[:, 0].min(), arr[:, 0].max(), 1000) 3084 3085 # Linear kernel 3086 if kernel == "linear": 3087 reg = scipy.stats.linregress(x, y) 3088 newy = reg.slope * newx + reg.intercept 3089 3090 # Other kernels 3091 else: 3092 # Fit 3093 svr = SVR(**kwargs) 3094 svr.fit(arr[:, 1].reshape(-1, 1), arr[:, 0]) 3095 3096 # Predict 3097 newy = svr.predict(newx.reshape(-1, 1)) 3098 3099 # Pad x and y_pred with zeros to force interpolation to start at 0 3100 newx = np.concatenate(([0], newx)) 3101 newy = np.concatenate(([0], newy)) 3102 3103 # Pad x and y_pred with max time to force interpolation to end at max time to force interpolation to match at end max time 3104 max_time = self[0].scan_df["scan_time"].max() 3105 newx = np.concatenate((newx, [max_time])) 3106 newy = np.concatenate((newy, [max_time])) 3107 3108 # Return an interpolation function for the x and y_pred 3109 def interp(x): 3110 pred_y = np.interp(x, newx, newy) 3111 return pred_y 3112 3113 return interp
Fit a support vector regressor to matched features.
Parameters
- a (
~pandas.DataFrame): First set of input feature coordinates and intensities; the center object and the object to align to. - b (
~pandas.DataFrame): Second set of input feature coordinates and intensities; the object to align to the center object. - align (str): Dimension to align.
- kwargs: Keyword arguments for support vector regressor
(
sklearn.svm.SVR).
Returns
~function: An interpolation function where one can input a retention time and get the predicted retention time.
Notes
This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
3115 def get_anchor_mass_features(self, mf_df): 3116 """ 3117 Get the anchor mass features from a DataFrame of mass features. 3118 3119 Parameters 3120 ---------- 3121 mf_df : :obj:`~pandas.DataFrame` 3122 The mass features to filter to just the anchor mass features. 3123 3124 Returns 3125 ------- 3126 :obj:`~pandas.DataFrame` 3127 The anchor mass features dataframe. 3128 """ 3129 mf_df = mf_df.copy() 3130 3131 if ( 3132 "deconvoluted_mass_spectra" 3133 in self.parameters.lcms_collection.mass_feature_anchor_technique 3134 ): 3135 # Drop features that are not mass_spectrum_deconvoluted_parent or are NA as mass_spectrum_deconvoluted_parent 3136 mf_df = mf_df.dropna(subset=["mass_spectrum_deconvoluted_parent"]) 3137 mf_df = mf_df[mf_df["mass_spectrum_deconvoluted_parent"]] 3138 3139 if ( 3140 "absolute_intensity" 3141 in self.parameters.lcms_collection.mass_feature_anchor_technique 3142 ): 3143 # Drop features that have an intensity lower than the threshold 3144 threshold = self.parameters.lcms_collection.mass_feature_anchor_absolute_intensity_threshold 3145 mf_df = mf_df[mf_df["intensity"] > threshold] 3146 3147 if ( 3148 "relative_intensity" 3149 in self.parameters.lcms_collection.mass_feature_anchor_technique 3150 ): 3151 # Drop features in the lower fraction of intensities 3152 threshold_quantile = self.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold 3153 intensity_threshold = mf_df["intensity"].quantile(threshold_quantile) 3154 mf_df = mf_df[mf_df["intensity"] >= intensity_threshold] 3155 3156 return mf_df
Get the anchor mass features from a DataFrame of mass features.
Parameters
- mf_df (
~pandas.DataFrame): The mass features to filter to just the anchor mass features.
Returns
~pandas.DataFrame: The anchor mass features dataframe.
3158 def attempt_alignment(self, matches_c, matches_i): 3159 """ 3160 Check if alignment is needed for the LCMS objects in the collection. 3161 """ 3162 3163 # Hold out a subset of matches_c and matches_i for spline fitting 3164 matches_c.reset_index(drop=False, inplace=True) 3165 matches_i.reset_index(drop=False, inplace=True) 3166 3167 # Check if there are enough matches to attempt alignment 3168 minimum_matches = self.parameters.lcms_collection.alignment_minimum_matches 3169 if len(matches_c) < minimum_matches: 3170 # Return False (no alignment) and identity function (returns original time) 3171 # which isn't used but is a placeholder to avoid errors in downstream code since 3172 # the function expects a callable to be returned 3173 return False, lambda x: x 3174 3175 # Rearrange matches_c and matches_i to be in the order of the scan_time of matches_c 3176 matches_c = matches_c.sort_values(by="scan_time") 3177 matches_i = matches_i.iloc[matches_c.index.values] 3178 3179 hold_out_fraction = self.parameters.lcms_collection.alignment_hold_out_fraction 3180 # starting with an array of length len(matches_c), select equally spaced indices to hold out 3181 idx_holdout = matches_c.index.values[ 3182 np.arange(0, len(matches_c), int(1 / hold_out_fraction)) 3183 ] 3184 3185 matches_c_holdout = matches_c.loc[idx_holdout].copy() 3186 matches_i_holdout = matches_i.loc[idx_holdout].copy() 3187 3188 # Remove the holdout matches from the matches_c and matches_i DataFrames and reset the index 3189 matches_c = matches_c.drop(index=idx_holdout).set_index("sample_name") 3190 matches_i = matches_i.drop(index=idx_holdout).set_index("sample_name") 3191 3192 # Reset the scan_time to the original scan_time 3193 matches_i = matches_i.copy() 3194 matches_i["scan_time"] = matches_i["scan_time_og"] 3195 3196 # Fit the retention times of the LCMS object to the center LCMS object using the matched mass features 3197 spl = self.fit_rts(matches_c, matches_i, kernel="rbf", C=1000) 3198 3199 # Check if the spline fitting improved the alignment for the holdout matches 3200 matches_i_holdout["scan_time_fit"] = spl(matches_i_holdout["scan_time"]) 3201 og_diff = np.abs( 3202 matches_i_holdout["scan_time"] - matches_c_holdout["scan_time"] 3203 ) 3204 fit_diff = np.abs( 3205 matches_i_holdout["scan_time_fit"] - matches_c_holdout["scan_time"] 3206 ) 3207 3208 if ( 3209 "fraction_improved" 3210 in self.parameters.lcms_collection.alignment_acceptance_technique 3211 ): 3212 fraction_improved = np.sum(fit_diff < og_diff) / len(og_diff) 3213 use_spline_alignment = ( 3214 fraction_improved 3215 > self.parameters.lcms_collection.alignment_acceptance_fraction_improved_threshold 3216 ) 3217 if ( 3218 "mean_squared_error_improved" 3219 in self.parameters.lcms_collection.alignment_acceptance_technique 3220 ): 3221 mse_og = np.mean(og_diff**2) 3222 mse = np.mean(fit_diff**2) 3223 use_spline_alignment = mse < mse_og 3224 # Convert to boolean 3225 use_spline_alignment = bool(use_spline_alignment) 3226 3227 return use_spline_alignment, spl
Check if alignment is needed for the LCMS objects in the collection.
3229 def align_lcms_objects(self, overwrite=False): 3230 """ 3231 Align LCMS objects in the collection. 3232 3233 Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects. 3234 First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects, 3235 starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects 3236 are then fit to the center LCMS object using the matched mass features. 3237 3238 Returns 3239 ------- 3240 None, but aligns the LCMS objects in the collection and sets the scan_time_aligned column in the scan_df attribute of each LCMS object. 3241 3242 Notes 3243 ----- 3244 This function has been adapted from the original implementation in the Deimos package: 3245 https://github.com/pnnl/deimos 3246 """ 3247 3248 # Prepare the center LCMS object 3249 center_obj_ids = self.manifest_dataframe[ 3250 self.manifest_dataframe["center"] 3251 ].collection_id.values 3252 3253 full_mf_df = self.mass_features_dataframe 3254 # re-index to sample_name for faster lookups 3255 full_mf_df = full_mf_df.reset_index().set_index("sample_name") 3256 samples_with_features = set(full_mf_df.index.get_level_values("sample_name")) 3257 3258 if "scan_time_aligned" in full_mf_df.columns and not overwrite: 3259 raise ValueError("Mass features have already been aligned") 3260 3261 def _set_scan_time_alignment_for_sample(sample_idx, use_alignment, spline): 3262 """Set scan_time_aligned for one sample using spline or identity mapping.""" 3263 if use_alignment and spline is not None: 3264 self[sample_idx]._scan_info["scan_time_aligned"] = { 3265 k: spline(v) 3266 for k, v in self[sample_idx]._scan_info["scan_time"].items() 3267 } 3268 return True 3269 3270 self[sample_idx]._scan_info["scan_time_aligned"] = ( 3271 self[sample_idx]._scan_info["scan_time"].copy() 3272 ) 3273 return False 3274 3275 def _get_feature_df_at_or_after(start_idx, index_step, use_alignment, spline): 3276 """Return next sample index/dataframe with features, aligning empty samples on the way.""" 3277 i = start_idx 3278 while 0 <= i < len(self): 3279 sample_name = self.samples[i] 3280 if sample_name in samples_with_features: 3281 mf_df_i = full_mf_df.loc[sample_name].copy() 3282 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3283 mf_df_i = mf_df_i.reset_index(drop=False) 3284 if use_alignment and spline is not None: 3285 # Use previous step transform as a better matching starting point. 3286 mf_df_i["scan_time"] = spline(mf_df_i["scan_time"]) 3287 return i, mf_df_i 3288 3289 _set_scan_time_alignment_for_sample(i, use_alignment, spline) 3290 self.rt_alignment_attempted = True 3291 i += index_step 3292 3293 return i, None 3294 3295 anchor_mf_dfs = [] 3296 for center_obj_id in center_obj_ids: 3297 # Get the anchor mass features from the center LCMS object 3298 mf_df_c = full_mf_df.loc[self.samples[center_obj_id]] 3299 mf_df_c = self.get_anchor_mass_features(mf_df_c) 3300 anchor_mf_dfs.append(mf_df_c) 3301 3302 # Set scan_time_aligned to scan_time for the center LCMS object 3303 center_scan_df = self[center_obj_id].scan_df.copy() 3304 center_scan_df["scan_time_aligned"] = center_scan_df["scan_time"] 3305 self[center_obj_id].scan_df = center_scan_df 3306 3307 # Store alignment data for center object (identity mapping) 3308 center_sample_name = self.samples[center_obj_id] 3309 3310 index_steps = (1, -1) 3311 # Run this twice, once going forward (+1 indexing) and once going backward (-1 indexing) 3312 for index_step in index_steps: 3313 # Initialize spline for propagation to samples without features 3314 spl = None 3315 use_spline_alignment = False 3316 3317 # Loop through the other LCMS objects in this direction. 3318 i, mf_df_i = _get_feature_df_at_or_after( 3319 center_obj_id + index_step, 3320 index_step, 3321 use_spline_alignment, 3322 spl, 3323 ) 3324 3325 while mf_df_i is not None: 3326 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3327 3328 # Match the mass features in the LCMS object to the anchor mass features in the center LCMS object. 3329 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3330 3331 if matches_c is not None: 3332 use_spline_alignment, spl = self.attempt_alignment( 3333 matches_c, matches_i 3334 ) 3335 3336 # Record if we used alignment for this sample 3337 sample_name = self.samples[i] 3338 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3339 use_spline_alignment 3340 ) 3341 3342 if use_spline_alignment: 3343 # Set new retention times on scan_df for lc_obj using the spline fitting 3344 matches_i["scan_time_fit"] = spl(matches_i["scan_time"]) 3345 3346 self.rt_aligned = _set_scan_time_alignment_for_sample( 3347 i, use_spline_alignment, spl 3348 ) 3349 self.rt_alignment_attempted = True 3350 3351 i, mf_df_i = _get_feature_df_at_or_after( 3352 i + index_step, 3353 index_step, 3354 use_spline_alignment, 3355 spl, 3356 ) 3357 else: 3358 # If no matches are found, propagate prior alignment from this index step. 3359 sample_name = self.samples[i] 3360 used_previous_alignment = ( 3361 use_spline_alignment and spl is not None 3362 ) 3363 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3364 used_previous_alignment 3365 ) 3366 3367 self.rt_aligned = _set_scan_time_alignment_for_sample( 3368 i, used_previous_alignment, spl 3369 ) 3370 self.rt_alignment_attempted = True 3371 3372 i, mf_df_i = _get_feature_df_at_or_after( 3373 i + index_step, 3374 index_step, 3375 used_previous_alignment, 3376 spl, 3377 ) 3378 3379 # Now align each batch using the center objects as anchors with the other batches 3380 mf_df_c = anchor_mf_dfs[0] 3381 for i in center_obj_ids[1:]: 3382 mf_df_i = full_mf_df.loc[self.samples[i]].copy() 3383 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3384 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3385 3386 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3387 if matches_c is not None: 3388 use_spline_alignment, spl = self.attempt_alignment(matches_c, matches_i) 3389 3390 # Record if we used alignment for this sample 3391 sample_name = self.samples[i] 3392 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3393 use_spline_alignment 3394 ) 3395 3396 if use_spline_alignment: 3397 # Set new retention times on all this object's 3398 new_times = spl(self[i].scan_df["scan_time"]) 3399 new_scan_info = self[i].scan_df.copy() 3400 new_scan_info["scan_time_aligned"] = new_times 3401 self[i].scan_df = new_scan_info 3402 3403 # Get the batch that this object belongs to 3404 batch = self.manifest[self.samples[i]]["batch"] 3405 3406 for j in range(len(self)): 3407 if self.manifest[self.samples[j]]["batch"] == batch: 3408 if j != i: 3409 sample_name_j = self.samples[j] 3410 self._manifest_dict[sample_name_j][ 3411 "use_rt_alignment" 3412 ] = use_spline_alignment 3413 new_scan_info = self[j].scan_df.copy() 3414 aligned_times = spl( 3415 self[j].scan_df["scan_time_aligned"] 3416 ) 3417 new_scan_info["scan_time_aligned"] = aligned_times 3418 self[j].scan_df = new_scan_info 3419 3420 # Set final mass_features_dataframe with the aligned scan_time 3421 center_sample_name = self.samples[center_obj_ids[0]] 3422 self._manifest_dict[center_sample_name]["use_rt_alignment"] = False 3423 new_scan_info = self[center_obj_ids[0]].scan_df.copy() 3424 new_scan_info["scan_time_aligned"] = new_scan_info["scan_time"]
Align LCMS objects in the collection.
Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects. First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects, starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects are then fit to the center LCMS object using the matched mass features.
Returns
- None, but aligns the LCMS objects in the collection and sets the scan_time_aligned column in the scan_df attribute of each LCMS object.
Notes
This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos
3426 def add_consensus_mass_features(self): 3427 """ 3428 Create consensus mass features by clustering aligned features across samples. 3429 3430 This method clusters mass features from all samples in the collection based on 3431 their m/z and aligned retention time proximity. Features that cluster together 3432 across samples are assigned a common cluster ID, creating consensus features 3433 that represent the same compound detected across multiple samples. 3434 3435 The clustering process: 3436 1. Partitions features by m/z to avoid large sparse matrices and enable parallelization 3437 2. Clusters features within each partition using hierarchical clustering 3438 3. Merges partition-boundary clusters that represent the same feature 3439 4. Filters out clusters not present in minimum fraction of samples 3440 3441 Must be run after align_lcms_objects(). Results are stored in the 3442 mass_features_dataframe with a 'cluster' column added. 3443 3444 Parameters 3445 ---------- 3446 None 3447 Uses parameters from self.parameters.lcms_collection: 3448 - consensus_mz_tol_ppm: m/z tolerance for clustering (ppm) 3449 - consensus_rt_tol: retention time tolerance for clustering (minutes) 3450 - consensus_partition_size: target partition size for managing memory and parallelization 3451 - consensus_min_sample_fraction: minimum fraction of samples a cluster 3452 must appear in to be retained (0-1) 3453 - cores: number of CPU cores to use for parallel partition processing 3454 3455 Returns 3456 ------- 3457 None 3458 Updates self.mass_features_dataframe in place by adding 'cluster' column 3459 and filtering to retain only clusters meeting minimum sample presence. 3460 3461 Raises 3462 ------ 3463 ValueError 3464 If mass features have not been aligned (run align_lcms_objects() first). 3465 3466 Notes 3467 ----- 3468 - Partitioning prevents memory issues with large sparse distance matrices 3469 - Each partition is processed in parallel (up to cores limit) 3470 - Clusters not meeting consensus_min_sample_fraction are automatically removed 3471 - Access cluster_summary_dataframe property for summary statistics 3472 - Use fill_missing_cluster_features() for gap-filling after clustering 3473 3474 See Also 3475 -------- 3476 align_lcms_objects : Aligns retention times before consensus clustering 3477 cluster_summary_dataframe : Property that generates summary statistics for clusters 3478 fill_missing_cluster_features : Gap-fill missing features in clusters 3479 """ 3480 # Get the combined mass features from all LCMS objects, keep the original index as a separate column 3481 combined_mfs = self.mass_features_dataframe.copy() 3482 combined_mfs["coll_mf_id"] = combined_mfs.index 3483 3484 # Check if the mass features have been aligned 3485 if "scan_time_aligned" not in combined_mfs.columns: 3486 raise ValueError( 3487 "Mass features have not been aligned, run align_lcms_objects() first" 3488 ) 3489 3490 # Partition the mass features by mz so we can parallelize the matching before clustering 3491 from corems.chroma_peak.calc import subset as corems_subset 3492 3493 # get max mz from combined_mfs and calculate tolerance from ppm 3494 mz_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 3495 n_partition_size = self.parameters.lcms_collection.consensus_partition_size 3496 lazy_partitions = corems_subset.multi_sample_partition( 3497 combined_mfs, 3498 split_on="mz", 3499 size=n_partition_size, 3500 tol=mz_tol, 3501 relative=True, 3502 ) 3503 3504 # If any of lazy_partitions._counts is 2xn_partition_size, issue a warning 3505 if np.array(lazy_partitions._counts).max() > 2 * n_partition_size: 3506 warnings.warn( 3507 "Some partitions are larger than 2x the goal partition size. Consider increasing the partition or decreasing the mz_tol." 3508 ) 3509 3510 # Cluster the mass features within each partition 3511 if self.parameters.lcms_collection.cores > lazy_partitions.n_partitions: 3512 cores_to_use = lazy_partitions.n_partitions 3513 else: 3514 cores_to_use = self.parameters.lcms_collection.cores 3515 # mfs_with_clusters = lazy_partitions.map(self.cluster_mass_features, processes=cores_to_use) 3516 mfs_with_clusters = lazy_partitions.map( 3517 self.cluster_mass_features_agg_cluster, processes=cores_to_use 3518 ) 3519 3520 # Clean up cluster id names after partitioning 3521 new_cluster_ids = ( 3522 mfs_with_clusters[["cluster", "partition_idx"]] 3523 .drop_duplicates() 3524 .reset_index(drop=True) 3525 ) 3526 new_cluster_ids["cluster_unqiue"] = new_cluster_ids.index 3527 mfs_with_clusters = mfs_with_clusters.merge( 3528 new_cluster_ids, on=["cluster", "partition_idx"] 3529 ) 3530 mfs_with_clusters["cluster"] = mfs_with_clusters["cluster_unqiue"] 3531 mfs_with_clusters = mfs_with_clusters.drop(columns=["cluster_unqiue"]) 3532 3533 # Embed a new cluster id into the mass features dataframe and set as index 3534 mfs_with_clusters["idx"] = mfs_with_clusters.index 3535 3536 try: 3537 # Check if any clusters can be merged into a single cluster 3538 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3539 3540 # Merge clusters identified in eval_dict 3541 while len(eval_dict["merge_these_clusters"]) > 0: 3542 list_of_clusters_to_merge = [ 3543 [x[0], x[1]] for x in eval_dict["merge_these_clusters"] 3544 ] 3545 # Convert to a dataframe with columns "new_cluster" and "cluster" 3546 df = pd.DataFrame( 3547 np.array(list_of_clusters_to_merge), 3548 columns=["new_cluster", "cluster"], 3549 ) 3550 # Drop duplicates of "child" clusters 3551 df = df.drop_duplicates("cluster", keep="first") 3552 df = df.drop_duplicates("new_cluster", keep="first") 3553 mfs_with_clusters = mfs_with_clusters.merge( 3554 df, on="cluster", how="left" 3555 ) 3556 mfs_with_clusters["cluster"] = mfs_with_clusters["new_cluster"].fillna( 3557 mfs_with_clusters["cluster"] 3558 ) 3559 mfs_with_clusters = mfs_with_clusters.drop(columns=["new_cluster"]) 3560 3561 # Re-evaluate clusters for repeats 3562 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3563 self.mass_features_dataframe = mfs_with_clusters 3564 3565 except: 3566 mfs_with_clusters.set_index("coll_mf_id", inplace=True) 3567 self.mass_features_dataframe = mfs_with_clusters 3568 3569 # Filter out clusters that don't meet minimum sample fraction 3570 self._filter_clusters_by_sample_presence() 3571 3572 # TODO KRH: Deal with isomers better? Pool them together and then split them out using samples with 2 as the template?
Create consensus mass features by clustering aligned features across samples.
This method clusters mass features from all samples in the collection based on their m/z and aligned retention time proximity. Features that cluster together across samples are assigned a common cluster ID, creating consensus features that represent the same compound detected across multiple samples.
The clustering process:
- Partitions features by m/z to avoid large sparse matrices and enable parallelization
- Clusters features within each partition using hierarchical clustering
- Merges partition-boundary clusters that represent the same feature
- Filters out clusters not present in minimum fraction of samples
Must be run after align_lcms_objects(). Results are stored in the mass_features_dataframe with a 'cluster' column added.
Parameters
- None: Uses parameters from self.parameters.lcms_collection:
- consensus_mz_tol_ppm: m/z tolerance for clustering (ppm)
- consensus_rt_tol: retention time tolerance for clustering (minutes)
- consensus_partition_size: target partition size for managing memory and parallelization
- consensus_min_sample_fraction: minimum fraction of samples a cluster must appear in to be retained (0-1)
- cores: number of CPU cores to use for parallel partition processing
Returns
- None: Updates self.mass_features_dataframe in place by adding 'cluster' column and filtering to retain only clusters meeting minimum sample presence.
Raises
- ValueError: If mass features have not been aligned (run align_lcms_objects() first).
Notes
- Partitioning prevents memory issues with large sparse distance matrices
- Each partition is processed in parallel (up to cores limit)
- Clusters not meeting consensus_min_sample_fraction are automatically removed
- Access cluster_summary_dataframe property for summary statistics
- Use fill_missing_cluster_features() for gap-filling after clustering
See Also
align_lcms_objects: Aligns retention times before consensus clustering
cluster_summary_dataframe: Property that generates summary statistics for clusters
fill_missing_cluster_features: Gap-fill missing features in clusters
3624 def summarize_clusters(self): 3625 """ 3626 Generate summary statistics for consensus mass feature clusters. 3627 3628 Computes aggregate statistics (median, mean, std, min, max) for each cluster 3629 across all samples. Combines both regular mass features and induced mass features 3630 (from gap-filling) when available to provide complete cluster statistics. 3631 3632 Must be run after add_consensus_mass_features() which creates the cluster assignments. 3633 Results are stored in cluster_summary_dataframe property and used by plotting methods. 3634 3635 Parameters 3636 ---------- 3637 None 3638 Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe. 3639 Both must contain 'cluster' column. 3640 3641 Returns 3642 ------- 3643 :obj:`~pandas.DataFrame` or None 3644 DataFrame with one row per cluster containing summary statistics: 3645 - cluster: cluster ID 3646 - mz_{median,mean,std,max,min}: m/z statistics 3647 - scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics 3648 - half_height_width_{median,mean,std,max,min}: peak width statistics 3649 - tailing_factor_{median,mean,std,max,min}: peak shape statistics 3650 - dispersity_index_{median,mean,std,max,min}: peak quality statistics 3651 - sample_id_nunique: number of unique samples containing the cluster 3652 - intensity_{max,median,mean,std,min}: intensity statistics 3653 - persistence_{max,median,mean,std,min}: persistence statistics 3654 3655 Returns None if mass_features_dataframe is empty. 3656 3657 Notes 3658 ----- 3659 - Summary DataFrame is automatically stored in cluster_summary_dataframe property 3660 - Includes both regular and induced (gap-filled) mass features when available. 3661 ``group_consensus_features`` does not use this m/z median for ion-role 3662 geometry; that median is detected features only. 3663 - Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster 3664 - Sample count (sample_id_nunique) indicates cluster prevalence across samples 3665 - Filters applied by consensus_min_sample_fraction affect which clusters appear 3666 3667 See Also 3668 -------- 3669 add_consensus_mass_features : Creates clusters before summarization 3670 fill_missing_cluster_features : Creates induced mass features via gap-filling 3671 plot_consensus_mz_features : Visualizes cluster summaries 3672 plot_mz_features_per_cluster : Shows cluster size distribution 3673 """ 3674 # First check if there are minimum columns in the features dataframe 3675 if ( 3676 self.mass_features_dataframe is None 3677 or len(self.mass_features_dataframe.columns) < 1 3678 ): 3679 return None 3680 3681 # Combine regular and induced mass features 3682 mf_df = self.mass_features_dataframe.copy() 3683 mf_df = mf_df.reset_index(drop=False) 3684 3685 # Check if induced mass features are available and combine them 3686 if ( 3687 self.induced_mass_features_dataframe is not None 3688 and len(self.induced_mass_features_dataframe) > 0 3689 ): 3690 imf_df = self.induced_mass_features_dataframe.copy() 3691 imf_df = imf_df.reset_index(drop=False) 3692 # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination 3693 # Combine regular and induced features 3694 mf_df = pd.concat([mf_df, imf_df], axis=0) 3695 mf_df = mf_df.reset_index(drop=True) 3696 3697 # Filter out any rows with NaN cluster values before converting to int 3698 if "cluster" in mf_df.columns: 3699 mf_df = mf_df.dropna(subset=["cluster"]) 3700 mf_df["cluster"] = mf_df["cluster"].astype(int) 3701 3702 # Build aggregation dictionary based on available columns 3703 agg_dict = { 3704 "mz": ["median", "mean", "std", "max", "min"], 3705 "scan_time_aligned": ["median", "mean", "std", "max", "min"], 3706 "sample_id": ["nunique"], 3707 "intensity": ["max", "median", "mean", "std", "min"], 3708 } 3709 3710 # Add optional columns if they exist 3711 optional_columns = { 3712 "half_height_width": ["median", "mean", "std", "max", "min"], 3713 "tailing_factor": ["median", "mean", "std", "max", "min"], 3714 "dispersity_index": ["median", "mean", "std", "max", "min"], 3715 "persistence": ["max", "median", "mean", "std", "min"], 3716 } 3717 3718 for col, funcs in optional_columns.items(): 3719 if col in mf_df.columns: 3720 agg_dict[col] = funcs 3721 3722 summary_df = mf_df.groupby("cluster").agg(agg_dict).reset_index() 3723 3724 # Fix the column names 3725 summary_df.columns = [ 3726 "_".join(col).strip() 3727 for col in summary_df.columns.values 3728 if col != "cluster" 3729 ] 3730 summary_df = summary_df.rename(columns={"cluster_": "cluster"}) 3731 # Set cluster as the index for easy lookup 3732 summary_df = summary_df.set_index("cluster") 3733 3734 # Merge consensus feature-group labels when present (isotopes + adducts). 3735 # Cluster summary exposes possible_ion_types (all 3736 # residual-tied candidates) and drops ion_type so a single type column 3737 # is not mistaken for unique assignment. 3738 fg = getattr(self, "feature_group_dataframe", None) 3739 if fg is not None and len(fg) > 0: 3740 from corems.mass_spectra.calc.feature_grouping import ( 3741 GROUP_COLUMNS, 3742 merge_possible_ion_types, 3743 ) 3744 3745 cols = [c for c in GROUP_COLUMNS if c in fg.columns] 3746 if cols: 3747 drop_cols = [c for c in cols if c in summary_df.columns] 3748 if drop_cols: 3749 summary_df = summary_df.drop(columns=drop_cols) 3750 # Also drop stale ion_type from a prior join shape 3751 if "ion_type" in summary_df.columns: 3752 summary_df = summary_df.drop(columns=["ion_type"]) 3753 summary_df = summary_df.join(fg[cols], how="left") 3754 3755 if ( 3756 "possible_ion_types" in summary_df.columns 3757 and "ion_type" in summary_df.columns 3758 ): 3759 for cid in summary_df.index: 3760 it = summary_df.loc[cid, "ion_type"] 3761 poss = summary_df.loc[cid, "possible_ion_types"] 3762 filled = merge_possible_ion_types(it, poss) 3763 if filled is not None: 3764 summary_df.loc[cid, "possible_ion_types"] = filled 3765 if "ion_type" in summary_df.columns: 3766 summary_df = summary_df.drop(columns=["ion_type"]) 3767 3768 return summary_df
Generate summary statistics for consensus mass feature clusters.
Computes aggregate statistics (median, mean, std, min, max) for each cluster across all samples. Combines both regular mass features and induced mass features (from gap-filling) when available to provide complete cluster statistics.
Must be run after add_consensus_mass_features() which creates the cluster assignments. Results are stored in cluster_summary_dataframe property and used by plotting methods.
Parameters
- None: Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe. Both must contain 'cluster' column.
Returns
~pandas.DataFrameor None: DataFrame with one row per cluster containing summary statistics:- cluster: cluster ID
- mz_{median,mean,std,max,min}: m/z statistics
- scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics
- half_height_width_{median,mean,std,max,min}: peak width statistics
- tailing_factor_{median,mean,std,max,min}: peak shape statistics
- dispersity_index_{median,mean,std,max,min}: peak quality statistics
- sample_id_nunique: number of unique samples containing the cluster
- intensity_{max,median,mean,std,min}: intensity statistics
- persistence_{max,median,mean,std,min}: persistence statistics
Returns None if mass_features_dataframe is empty.
Notes
- Summary DataFrame is automatically stored in cluster_summary_dataframe property
- Includes both regular and induced (gap-filled) mass features when available.
group_consensus_featuresdoes not use this m/z median for ion-role geometry; that median is detected features only. - Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster
- Sample count (sample_id_nunique) indicates cluster prevalence across samples
- Filters applied by consensus_min_sample_fraction affect which clusters appear
See Also
add_consensus_mass_features: Creates clusters before summarization
fill_missing_cluster_features: Creates induced mass features via gap-filling
plot_consensus_mz_features: Visualizes cluster summaries
plot_mz_features_per_cluster: Shows cluster size distribution
3770 def group_consensus_features(self): 3771 """ 3772 Group consensus features into natural-abundance isotope and adduct families. 3773 3774 After consensus clustering and gap-filling, labels clusters that share 3775 similar retention time, an interpretable **singly-charged** mass offset, 3776 and correlated cross-sample **apex peak heights**. 3777 3778 Two stages 3779 ---------- 3780 1. **Isotopes.** Unit ``Atoms`` edges (e.g. ¹²C/¹³C) plus roll-up to 3781 ¹³C₂, ¹³C₃, … when intermediates exist. Spacing is the raw Atoms 3782 Δm (``|z| = 1``). Multi-charge envelopes are out of scope. 3783 Chemical mono is the geometry root, not the tallest peak. 3784 2. **Adducts.** Pairwise ``ion_type_dict`` forms in 3785 ``feature_group_ion_types`` (must be ``|z| = 1``). Empty or a 3786 single configured type skips adduct linking (isotopes only). 3787 3788 **Not** for tracer, enriched, or isotopically labeled experiments. 3789 3790 Quant gate is fixed (no settings switch): 3791 3792 - Correlation: **Pearson** only, pairwise-complete on samples with both 3793 heights > 0 (threshold ``feature_group_corr_threshold``, default 0.80) 3794 - Abundance: mass-feature apex ``intensity`` only (not integrated area) 3795 3796 Coelution and m/z delta windows reuse collection 3797 ``alignment_rt_tol`` and ``alignment_mz_tol_ppm``. Other knobs 3798 (``feature_group_isotope_atoms``, shared-sample fraction, 3799 ``feature_group_ion_types``, …) live on ``parameters.lcms_collection``. 3800 3801 ``feature_group_ion_types`` is filtered to the collection polarity 3802 (all samples share one polarity; mixed collections cannot be 3803 instantiated). Only keys ending in ``+`` are kept on positive data 3804 and only keys ending in ``-`` on negative data. 3805 3806 Preferred for collection-level families. Optional per-file 3807 ``LCMSBase.find_c13_mass_features`` remains available for single-file 3808 workflows; do not treat both as authoritative in the same pipeline. 3809 3810 Must run after ``add_consensus_mass_features()``. The m/z median used for 3811 isotope and adduct spacing is calculated from detected features only. 3812 Gap-filled m/z values stay on the induced rows and do not enter that 3813 median. The height matrix still includes gap-filled apex intensities 3814 when present; missing entries are 0. 3815 3816 Returns 3817 ------- 3818 pandas.DataFrame 3819 Cluster-indexed labels with columns ``feature_group_id``, 3820 ``ion_role``, ``ion_type``, ``possible_ion_types``, 3821 ``isotope_state``, ``mono_cluster_id`` (set only when the form 3822 has at least one isotope). Also stored on 3823 ``self.feature_group_dataframe`` and merged into 3824 ``mass_features_dataframe`` / ``induced_mass_features_dataframe``. 3825 3826 Raises 3827 ------ 3828 ValueError 3829 If consensus features are missing or settings are invalid. 3830 """ 3831 from corems.mass_spectra.calc.feature_grouping import ( 3832 GROUP_COLUMNS, 3833 HEIGHT_COL, 3834 FeatureGroupParams, 3835 build_height_matrix_from_features, 3836 detected_cluster_mz_median, 3837 group_features_arrays, 3838 normalize_ms_polarity, 3839 ) 3840 3841 if ( 3842 self.mass_features_dataframe is None 3843 or len(self.mass_features_dataframe) == 0 3844 ): 3845 raise ValueError( 3846 "No mass features dataframe. Run add_consensus_mass_features() first." 3847 ) 3848 if "cluster" not in self.mass_features_dataframe.columns: 3849 raise ValueError( 3850 "mass_features_dataframe has no 'cluster' column. " 3851 "Run add_consensus_mass_features() first." 3852 ) 3853 3854 # Clear prior labels (idempotent re-run) 3855 self.feature_group_dataframe = None 3856 for col in GROUP_COLUMNS: 3857 if col in self.mass_features_dataframe.columns: 3858 self.mass_features_dataframe = self.mass_features_dataframe.drop( 3859 columns=[col] 3860 ) 3861 if ( 3862 self.induced_mass_features_dataframe is not None 3863 and col in self.induced_mass_features_dataframe.columns 3864 ): 3865 self.induced_mass_features_dataframe = ( 3866 self.induced_mass_features_dataframe.drop(columns=[col]) 3867 ) 3868 3869 summary = self.summarize_clusters() 3870 if summary is None or len(summary) == 0: 3871 raise ValueError( 3872 "cluster summary is empty. Run add_consensus_mass_features() first." 3873 ) 3874 3875 # Collection polarity: all samples share one polarity (enforced when 3876 # LCMSCollection is loaded). Filter opposite-sign adducts. 3877 collection_polarity = None 3878 if len(self): 3879 collection_polarity = normalize_ms_polarity( 3880 getattr(self[0], "polarity", None) 3881 ) 3882 3883 params = FeatureGroupParams.from_lcms_collection_settings( 3884 self.parameters.lcms_collection, 3885 polarity=collection_polarity, 3886 ) 3887 3888 cluster_ids = summary.index.to_numpy() 3889 # Gap-filled apex m/z is not part of ion-role geometry. 3890 mz = detected_cluster_mz_median(self.mass_features_dataframe, cluster_ids) 3891 if "scan_time_aligned_median" not in summary.columns: 3892 raise ValueError( 3893 "cluster summary missing scan_time_aligned_median; " 3894 "ensure features are RT-aligned before consensus." 3895 ) 3896 rt = summary["scan_time_aligned_median"].to_numpy(dtype=float) 3897 3898 # Sample ids as used in mass_features_dataframe 3899 sample_ids = sorted(self.mass_features_dataframe["sample_id"].dropna().unique()) 3900 if self.induced_mass_features_dataframe is not None and len( 3901 self.induced_mass_features_dataframe 3902 ): 3903 sample_ids = sorted( 3904 set(sample_ids) 3905 | set( 3906 self.induced_mass_features_dataframe["sample_id"] 3907 .dropna() 3908 .unique() 3909 .tolist() 3910 ) 3911 ) 3912 3913 t_height = time.perf_counter() 3914 heights = build_height_matrix_from_features( 3915 self.mass_features_dataframe, 3916 cluster_ids=cluster_ids, 3917 sample_ids=sample_ids, 3918 induced_df=self.induced_mass_features_dataframe, 3919 intensity_col=HEIGHT_COL, # apex intensity only; not area 3920 ) 3921 stage_timings = { 3922 "height_matrix": time.perf_counter() - t_height, 3923 "n_ion_types_configured": float(len(params.ion_types)), 3924 "collection_polarity": ( 3925 str(collection_polarity) if collection_polarity else "unknown" 3926 ), 3927 } 3928 3929 algo_timings: dict = {} 3930 labels = group_features_arrays( 3931 cluster_ids, mz, rt, heights, params, timings_out=algo_timings 3932 ) 3933 stage_timings.update(algo_timings) 3934 self._last_feature_group_timings = stage_timings 3935 self.feature_group_dataframe = labels 3936 3937 from corems.mass_spectra.calc.feature_grouping import ( 3938 merge_feature_group_labels_into_frames, 3939 ) 3940 3941 self.mass_features_dataframe, induced = merge_feature_group_labels_into_frames( 3942 labels, 3943 self.mass_features_dataframe, 3944 self.induced_mass_features_dataframe, 3945 ) 3946 if self.induced_mass_features_dataframe is not None: 3947 self.induced_mass_features_dataframe = induced 3948 3949 return labels
Group consensus features into natural-abundance isotope and adduct families.
After consensus clustering and gap-filling, labels clusters that share similar retention time, an interpretable singly-charged mass offset, and correlated cross-sample apex peak heights.
Two stages
- Isotopes. Unit
Atomsedges (e.g. ¹²C/¹³C) plus roll-up to ¹³C₂, ¹³C₃, … when intermediates exist. Spacing is the raw Atoms Δm (|z| = 1). Multi-charge envelopes are out of scope. Chemical mono is the geometry root, not the tallest peak. - Adducts. Pairwise
ion_type_dictforms infeature_group_ion_types(must be|z| = 1). Empty or a single configured type skips adduct linking (isotopes only).
Not for tracer, enriched, or isotopically labeled experiments.
Quant gate is fixed (no settings switch):
- Correlation: Pearson only, pairwise-complete on samples with both
heights > 0 (threshold
feature_group_corr_threshold, default 0.80) - Abundance: mass-feature apex
intensityonly (not integrated area)
Coelution and m/z delta windows reuse collection
alignment_rt_tol and alignment_mz_tol_ppm. Other knobs
(feature_group_isotope_atoms, shared-sample fraction,
feature_group_ion_types, …) live on parameters.lcms_collection.
feature_group_ion_types is filtered to the collection polarity
(all samples share one polarity; mixed collections cannot be
instantiated). Only keys ending in + are kept on positive data
and only keys ending in - on negative data.
Preferred for collection-level families. Optional per-file
LCMSBase.find_c13_mass_features remains available for single-file
workflows; do not treat both as authoritative in the same pipeline.
Must run after add_consensus_mass_features(). The m/z median used for
isotope and adduct spacing is calculated from detected features only.
Gap-filled m/z values stay on the induced rows and do not enter that
median. The height matrix still includes gap-filled apex intensities
when present; missing entries are 0.
Returns
- pandas.DataFrame: Cluster-indexed labels with columns
feature_group_id,ion_role,ion_type,possible_ion_types,isotope_state,mono_cluster_id(set only when the form has at least one isotope). Also stored onself.feature_group_dataframeand merged intomass_features_dataframe/induced_mass_features_dataframe.
Raises
- ValueError: If consensus features are missing or settings are invalid.
3951 def plot_mz_features_per_cluster(self, return_fig=False, path=None): 3952 """ 3953 Plot the number of mass features in a cluster against how many clusters 3954 contain that number of mass features 3955 3956 Parameters 3957 ----------- 3958 return_fig : bool, optional 3959 If True, return the open figure (caller owns lifecycle). 3960 Default is False. 3961 path : str or path-like, optional 3962 If set, save the figure to this path. When ``return_fig`` is False, 3963 the figure is closed after saving and ``plt.show()`` is not called. 3964 3965 Returns 3966 -------- 3967 matplotlib.figure.Figure or None 3968 The figure if ``return_fig`` is True; otherwise None. 3969 3970 Raises 3971 ------ 3972 ValueError 3973 If consensus features haven't been added to the object yet 3974 """ 3975 3976 if not hasattr(self, "cluster_summary_dataframe"): 3977 raise ValueError( 3978 "cluster_summary_dataframe is not set, must run add_consensus_mass_features() first" 3979 ) 3980 sum_data = self.cluster_summary_dataframe 3981 fig, ax = plt.subplots() 3982 sum_data.sample_id_nunique.value_counts().sort_index().plot(ax=ax, kind="bar") 3983 plt.xlabel("Number of mass features in a cluster") 3984 plt.ylabel("Number of clusters with this many mass features") 3985 return _finalize_plot(fig, return_fig=return_fig, path=path)
Plot the number of mass features in a cluster against how many clusters contain that number of mass features
Parameters
- return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
- path (str or path-like, optional):
If set, save the figure to this path. When
return_figis False, the figure is closed after saving andplt.show()is not called.
Returns
- matplotlib.figure.Figure or None: The figure if
return_figis True; otherwise None.
Raises
- ValueError: If consensus features haven't been added to the object yet
3987 def plot_mz_features_across_samples( 3988 self, alpha=0.75, s=0.005, return_fig=False, path=None 3989 ): 3990 """ 3991 Generate Scan Time vs m/z plot of all the mass features across all 3992 samples in collection where intensity of color on the plot indicates 3993 density of mass features, NOT INTENSITY 3994 3995 Parameters 3996 ----------- 3997 alpha : float 3998 Desired transparency for plotted m/z features. Defaults to 0.75. 3999 s : float 4000 Desired size of plotted m/z features. Defaults to 0.005. 4001 return_fig : bool, optional 4002 If True, return the open figure (caller owns lifecycle). 4003 Default is False. 4004 path : str or path-like, optional 4005 If set, save the figure to this path. When ``return_fig`` is False, 4006 the figure is closed after saving and ``plt.show()`` is not called. 4007 4008 Returns 4009 -------- 4010 matplotlib.figure.Figure or None 4011 A figure displaying a scan time vs m/z scatterplot of all the m/z 4012 features identified in the collection if ``return_fig`` is True; 4013 otherwise None. Parameters alpha (transparency) and s (marker size) 4014 allow the user to emphasize the density of features. Intensity of 4015 features is not represented. 4016 """ 4017 df = self.mass_features_dataframe.copy() 4018 fig = plt.figure() 4019 plt.scatter(df.scan_time_aligned, df.mz, c="tab:gray", alpha=alpha, s=s) 4020 4021 plt.xlabel("Scan time") 4022 plt.ylabel("m/z") 4023 plt.ylim(0, np.ceil(np.max(df.mz))) 4024 plt.xlim(0, np.ceil(np.max(df.scan_time))) 4025 plt.title("All mass features, all samples") 4026 4027 return _finalize_plot(fig, return_fig=return_fig, path=path)
Generate Scan Time vs m/z plot of all the mass features across all samples in collection where intensity of color on the plot indicates density of mass features, NOT INTENSITY
Parameters
- alpha (float): Desired transparency for plotted m/z features. Defaults to 0.75.
- s (float): Desired size of plotted m/z features. Defaults to 0.005.
- return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
- path (str or path-like, optional):
If set, save the figure to this path. When
return_figis False, the figure is closed after saving andplt.show()is not called.
Returns
- matplotlib.figure.Figure or None: A figure displaying a scan time vs m/z scatterplot of all the m/z
features identified in the collection if
return_figis True; otherwise None. Parameters alpha (transparency) and s (marker size) allow the user to emphasize the density of features. Intensity of features is not represented.
4029 def plot_consensus_mz_features( 4030 self, 4031 xb="xb", 4032 xt="xt", 4033 yb="yb", 4034 yt="yt", 4035 show_all=True, 4036 return_fig=False, 4037 path=None, 4038 ): 4039 """ 4040 Generate Scan Time vs m/z plot of the consensus features scaled by size 4041 with option ('show_all') of leaving the individual m/z features in the figure. 4042 4043 Parameters 4044 ----------- 4045 xb : float 4046 Desired starting scan time value for the x-axis. Defaults to 0. 4047 xt : float 4048 Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data. 4049 yb : float 4050 Desired starting m/z value for the y-axis. Defaults to 0. 4051 yt : float 4052 Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data. 4053 show_all : boolean 4054 Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True. 4055 return_fig : bool, optional 4056 If True, return the open figure (caller owns lifecycle). 4057 Default is False. 4058 path : str or path-like, optional 4059 If set, save the figure to this path. When ``return_fig`` is False, 4060 the figure is closed after saving and ``plt.show()`` is not called. 4061 4062 Returns 4063 -------- 4064 matplotlib.figure.Figure or None 4065 A scalable figure that overlays the consensus features over all the 4066 m/z features identified in the collection if ``return_fig`` is True; 4067 otherwise None. Consensus features are scaled by how many m/z 4068 features are represented in the consensus. Figure can be scaled by 4069 inputting desired boundaries on the scan time (xb, xt) and m/z 4070 values (yb, yt). 4071 """ 4072 df = self.cluster_summary_dataframe.copy() 4073 mfdf = self.mass_features_dataframe.copy() 4074 4075 fig = plt.figure() 4076 if show_all: 4077 plt.scatter(mfdf.scan_time_aligned, mfdf.mz, c="tab:gray", s=1) 4078 4079 m = plt.scatter( 4080 df.scan_time_aligned_median, 4081 df.mz_median, 4082 c="tab:orange", 4083 alpha=0.7, 4084 s=(df.sample_id_nunique**2) / 5, 4085 ) 4086 4087 plt.xlabel("Scan time") 4088 plt.ylabel("m/z") 4089 4090 if xt == "xt": 4091 xt = np.ceil(np.max(mfdf.mz)) 4092 if yt == "yt": 4093 yt = np.ceil(np.max(mfdf.scan_time)) 4094 if xb == "xb": 4095 xb = 0 4096 if yb == "yb": 4097 yb = 0 4098 plt.ylim(xb, xt) 4099 plt.xlim(yb, yt) 4100 4101 kw = dict( 4102 prop="sizes", 4103 num=max(1, int(len(df.sample_id_nunique.unique()) / 3)), 4104 color="tab:orange", 4105 alpha=0.7, 4106 func=lambda s: np.sqrt(s * 5), 4107 ) 4108 4109 plt.legend( 4110 *m.legend_elements(**kw), 4111 title="Features\nper cluster", 4112 bbox_to_anchor=(1.01, 0.4, 0.225, 0.5), 4113 ) 4114 plt.tight_layout() 4115 plt.title("Consensus Features") 4116 4117 return _finalize_plot(fig, return_fig=return_fig, path=path)
Generate Scan Time vs m/z plot of the consensus features scaled by size with option ('show_all') of leaving the individual m/z features in the figure.
Parameters
- xb (float): Desired starting scan time value for the x-axis. Defaults to 0.
- xt (float): Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data.
- yb (float): Desired starting m/z value for the y-axis. Defaults to 0.
- yt (float): Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data.
- show_all (boolean): Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True.
- return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
- path (str or path-like, optional):
If set, save the figure to this path. When
return_figis False, the figure is closed after saving andplt.show()is not called.
Returns
- matplotlib.figure.Figure or None: A scalable figure that overlays the consensus features over all the
m/z features identified in the collection if
return_figis True; otherwise None. Consensus features are scaled by how many m/z features are represented in the consensus. Figure can be scaled by inputting desired boundaries on the scan time (xb, xt) and m/z values (yb, yt).
4119 def plot_cluster( 4120 self, 4121 cluster_id, 4122 to_plot=["EIC", "MS1", "MS2"], 4123 return_fig=False, 4124 path=None, 4125 plot_smoothed_eic=False, 4126 plot_eic_datapoints=False, 4127 eic_buffer_time=None, 4128 label_samples=False, 4129 molecular_metadata=None, 4130 spectral_library=None, 4131 ): 4132 """ 4133 Plot a consensus mass feature cluster across all samples. 4134 4135 Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster, 4136 highlighting the representative mass feature. 4137 4138 Parameters 4139 ---------- 4140 cluster_id : int 4141 The cluster ID to plot 4142 to_plot : list, optional 4143 List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror". 4144 Default is ["EIC", "MS1", "MS2"]. 4145 return_fig : bool, optional 4146 If True, return the open figure (caller owns lifecycle). 4147 Default is False. 4148 path : str or path-like, optional 4149 If set, save the figure to this path. When ``return_fig`` is False, 4150 the figure is closed after saving and ``plt.show()`` is not called. 4151 plot_smoothed_eic : bool, optional 4152 If True, plots smoothed EICs. Default is False. 4153 plot_eic_datapoints : bool, optional 4154 If True, plots EIC data points. Default is False. 4155 eic_buffer_time : float, optional 4156 Time buffer around the peak for EIC plotting (minutes). 4157 If None, uses parameter setting. Default is None. 4158 label_samples : bool, optional 4159 If True, labels each sample in the legend. Default is False. 4160 molecular_metadata : dict, optional 4161 Dictionary mapping molecular IDs to MetaboliteMetadata objects. 4162 Required for MS2_mirror plots. Default is None. 4163 spectral_library : FlashEntropySearch, optional 4164 FlashEntropy spectral library containing MS2 spectra. 4165 Required for MS2_mirror plots to retrieve library spectra. Default is None. 4166 4167 Returns 4168 ------- 4169 matplotlib.figure.Figure or None 4170 The figure object if return_fig=True, otherwise None 4171 4172 Raises 4173 ------ 4174 ValueError 4175 If cluster_id is not found or if required data is not loaded 4176 """ 4177 import matplotlib.pyplot as plt 4178 4179 # Get cluster summary for median values 4180 if cluster_id not in self.cluster_summary_dataframe.index: 4181 raise ValueError( 4182 f"Cluster {cluster_id} not found in cluster_summary_dataframe. " 4183 f"Run add_consensus_mass_features() first." 4184 ) 4185 4186 cluster_summary = self.cluster_summary_dataframe.loc[cluster_id] 4187 4188 # Get representative mass feature info 4189 rep_info = self.get_most_representative_sample_for_cluster(cluster_id) 4190 rep_sample_id = rep_info["sample_id"] 4191 rep_mf_id = rep_info["mf_id"] 4192 rep_sample = self[rep_sample_id] 4193 4194 # Check if representative mass feature is loaded 4195 if rep_mf_id not in rep_sample.mass_features: 4196 raise ValueError( 4197 f"Representative mass feature {rep_mf_id} not loaded in sample {rep_sample.sample_name}. " 4198 f"Run reload_representative_mass_features() or process_consensus_features() first." 4199 ) 4200 4201 rep_mf = rep_sample.mass_features[rep_mf_id] 4202 4203 # Get eic buffer time 4204 if eic_buffer_time is None: 4205 eic_buffer_time = self[0].parameters.lc_ms.eic_buffer_time 4206 4207 # Adjust to_plot based on available data 4208 if rep_mf.mass_spectrum is None: 4209 to_plot = [x for x in to_plot if x != "MS1"] 4210 if len(rep_mf.ms2_mass_spectra) == 0: 4211 to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]] 4212 4213 # Check if EICs are available 4214 cluster_mfs = self.mass_features_dataframe[ 4215 self.mass_features_dataframe["cluster"] == cluster_id 4216 ] 4217 4218 has_eics = False 4219 # Check regular features 4220 for _, row in cluster_mfs.iterrows(): 4221 sample_id = int(row["sample_id"]) 4222 sample = self[sample_id] 4223 if hasattr(sample, "eics") and sample.eics: 4224 if len(sample.eics) > 0: 4225 has_eics = True 4226 break 4227 4228 # Also check induced features if available 4229 induced_cluster_mfs = None 4230 if not has_eics and self.induced_mass_features_dataframe is not None: 4231 induced_cluster_mfs = self.induced_mass_features_dataframe[ 4232 self.induced_mass_features_dataframe["cluster"] == cluster_id 4233 ] 4234 for _, row in induced_cluster_mfs.iterrows(): 4235 sample_id = int(row["sample_id"]) 4236 sample = self[sample_id] 4237 if hasattr(sample, "eics") and sample.eics: 4238 if len(sample.eics) > 0: 4239 has_eics = True 4240 break 4241 4242 if not has_eics: 4243 to_plot = [x for x in to_plot if x != "EIC"] 4244 if len(to_plot) == 0: 4245 raise ValueError( 4246 f"No plottable data available for cluster {cluster_id}. " 4247 f"Run process_consensus_features(gather_eics=True, add_ms1=True, add_ms2=True) first." 4248 ) 4249 4250 # Get induced features if not already retrieved 4251 if ( 4252 induced_cluster_mfs is None 4253 and self.induced_mass_features_dataframe is not None 4254 ): 4255 induced_cluster_mfs = self.induced_mass_features_dataframe[ 4256 self.induced_mass_features_dataframe["cluster"] == cluster_id 4257 ] 4258 4259 # Check if MS1 is deconvoluted 4260 deconvoluted = rep_mf._ms_deconvoluted_idx is not None 4261 4262 # Create figure 4263 fig, axs = plt.subplots( 4264 len(to_plot), 1, figsize=(10, len(to_plot) * 4), squeeze=False 4265 ) 4266 4267 fig.suptitle( 4268 f"Consensus Cluster {cluster_id}: " 4269 f"m/z = {cluster_summary['mz_median']:.4f} " 4270 f"(±{cluster_summary['mz_std']:.4f}); " 4271 f"RT = {cluster_summary['scan_time_aligned_median']:.2f} min " 4272 f"(±{cluster_summary['scan_time_aligned_std']:.2f}); " 4273 f"{int(cluster_summary['sample_id_nunique'])} samples" 4274 ) 4275 4276 i = 0 4277 4278 # EIC plot - show all samples using helper method 4279 if "EIC" in to_plot: 4280 self._plot_multiple_eics( 4281 axs[i][0], 4282 cluster_mfs, 4283 induced_cluster_mfs, 4284 rep_sample_id, 4285 rep_mf_id, 4286 cluster_summary["scan_time_aligned_median"], 4287 eic_buffer_time, 4288 plot_smoothed=plot_smoothed_eic, 4289 plot_datapoints=plot_eic_datapoints, 4290 label_samples=label_samples, 4291 lcms_collection=self, 4292 ) 4293 i += 1 4294 4295 # MS1 plot - from representative using helper method 4296 if "MS1" in to_plot: 4297 rep_mf._plot_ms1_spectrum( 4298 axs[i][0], deconvoluted=deconvoluted, sample_name=rep_sample.sample_name 4299 ) 4300 i += 1 4301 4302 # MS2 plot - from representative using helper method 4303 if "MS2" in to_plot: 4304 rep_mf._plot_ms2_spectrum(axs[i][0], sample_name=rep_sample.sample_name) 4305 i += 1 4306 4307 # MS2 mirror plot - from representative using helper method 4308 if "MS2_mirror" in to_plot: 4309 rep_mf._plot_ms2_mirror( 4310 axs[i][0], 4311 molecular_metadata=molecular_metadata, 4312 spectral_library=spectral_library, 4313 ) 4314 i += 1 4315 4316 plt.tight_layout() 4317 4318 return _finalize_plot(fig, return_fig=return_fig, path=path)
Plot a consensus mass feature cluster across all samples.
Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster, highlighting the representative mass feature.
Parameters
- cluster_id (int): The cluster ID to plot
- to_plot (list, optional): List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror". Default is ["EIC", "MS1", "MS2"].
- return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
- path (str or path-like, optional):
If set, save the figure to this path. When
return_figis False, the figure is closed after saving andplt.show()is not called. - plot_smoothed_eic (bool, optional): If True, plots smoothed EICs. Default is False.
- plot_eic_datapoints (bool, optional): If True, plots EIC data points. Default is False.
- eic_buffer_time (float, optional): Time buffer around the peak for EIC plotting (minutes). If None, uses parameter setting. Default is None.
- label_samples (bool, optional): If True, labels each sample in the legend. Default is False.
- molecular_metadata (dict, optional): Dictionary mapping molecular IDs to MetaboliteMetadata objects. Required for MS2_mirror plots. Default is None.
- spectral_library (FlashEntropySearch, optional): FlashEntropy spectral library containing MS2 spectra. Required for MS2_mirror plots to retrieve library spectra. Default is None.
Returns
- matplotlib.figure.Figure or None: The figure object if return_fig=True, otherwise None
Raises
- ValueError: If cluster_id is not found or if required data is not loaded
4320 def get_representative_mass_features_for_all_clusters( 4321 self, representative_metric=None 4322 ): 4323 """ 4324 Get the most representative mass feature for all clusters in bulk. 4325 4326 This is much more efficient than calling get_most_representative_sample_for_cluster 4327 in a loop, as it processes all clusters in a single pass over the dataframe. 4328 4329 Parameters 4330 ---------- 4331 representative_metric : str, optional 4332 The metric to use to determine the most representative sample. 4333 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4334 Options: 4335 - 'intensity': Selects the mass feature with the highest intensity 4336 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 4337 or the highest intensity overall if none have MS2 4338 Default is None (uses parameter setting). 4339 4340 Returns 4341 ------- 4342 :obj:`~pandas.DataFrame` 4343 DataFrame with one row per cluster containing: 4344 - cluster: cluster ID 4345 - sample_id: The sample ID of the most representative sample 4346 - mf_id: The mass feature ID in the sample 4347 - coll_mf_id: The collection-level mass feature ID (index) 4348 - has_ms2: Whether this mass feature has MS2 scan numbers 4349 - intensity: The intensity value of the representative mass feature 4350 """ 4351 # Use default from parameters if not specified 4352 if representative_metric is None: 4353 representative_metric = ( 4354 self.parameters.lcms_collection.consensus_representative_metric 4355 ) 4356 4357 mf_df = self.mass_features_dataframe.copy() 4358 # Reset index to make coll_mf_id a column we can work with 4359 mf_df = mf_df.reset_index(drop=False) 4360 4361 # Handle special metric 'intensity_prefer_ms2' 4362 if representative_metric == "intensity_prefer_ms2": 4363 if "intensity" not in mf_df.columns: 4364 raise ValueError( 4365 f"'intensity' column not found in mass_features_dataframe. " 4366 f"Available columns: {mf_df.columns.tolist()}" 4367 ) 4368 4369 # Add has_ms2 flag if ms2_scan_numbers column exists 4370 if "ms2_scan_numbers" in mf_df.columns: 4371 4372 def has_ms2_scans(val): 4373 if val is None: 4374 return False 4375 try: 4376 return len(val) > 0 4377 except (TypeError, ValueError): 4378 return False 4379 4380 mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans) 4381 4382 # Sort by has_ms2 (descending) then intensity (descending) 4383 # This ensures features with MS2 are preferred when intensities are equal 4384 mf_df = mf_df.sort_values( 4385 ["has_ms2", "intensity"], ascending=[False, False] 4386 ) 4387 else: 4388 mf_df["has_ms2"] = False 4389 mf_df = mf_df.sort_values("intensity", ascending=False) 4390 4391 # Group by cluster and take the first (highest intensity, preferring MS2) 4392 representatives = mf_df.groupby("cluster").first().reset_index() 4393 4394 else: 4395 # Standard metric - check if it exists 4396 if representative_metric not in mf_df.columns: 4397 raise ValueError( 4398 f"Metric '{representative_metric}' not found. Available columns: {mf_df.columns.tolist()}" 4399 ) 4400 4401 # Add has_ms2 flag for consistency 4402 if "ms2_scan_numbers" in mf_df.columns: 4403 4404 def has_ms2_scans(val): 4405 if val is None: 4406 return False 4407 try: 4408 return len(val) > 0 4409 except (TypeError, ValueError): 4410 return False 4411 4412 mf_df["has_ms2"] = mf_df["ms2_scan_numbers"].apply(has_ms2_scans) 4413 else: 4414 mf_df["has_ms2"] = False 4415 4416 # Get the index of max value for each cluster 4417 idx = mf_df.groupby("cluster")[representative_metric].idxmax() 4418 representatives = mf_df.loc[idx].copy() 4419 4420 # Select only the columns we need 4421 result_cols = [ 4422 "cluster", 4423 "sample_id", 4424 "mf_id", 4425 "coll_mf_id", 4426 "has_ms2", 4427 "intensity", 4428 ] 4429 representatives = representatives[result_cols] 4430 4431 return representatives
Get the most representative mass feature for all clusters in bulk.
This is much more efficient than calling get_most_representative_sample_for_cluster in a loop, as it processes all clusters in a single pass over the dataframe.
Parameters
- representative_metric (str, optional):
The metric to use to determine the most representative sample.
If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric.
Options:
- 'intensity': Selects the mass feature with the highest intensity
- 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, or the highest intensity overall if none have MS2 Default is None (uses parameter setting).
Returns
~pandas.DataFrame: DataFrame with one row per cluster containing:- cluster: cluster ID
- sample_id: The sample ID of the most representative sample
- mf_id: The mass feature ID in the sample
- coll_mf_id: The collection-level mass feature ID (index)
- has_ms2: Whether this mass feature has MS2 scan numbers
- intensity: The intensity value of the representative mass feature
4433 def get_sample_mf_map_for_representatives( 4434 self, representative_metric=None, include_cluster_id=True 4435 ): 4436 """ 4437 Build a mapping of sample_id -> list of representative mass feature IDs to load. 4438 4439 This is a DRY helper method used by both process_consensus_features() and 4440 ReadSavedLCMSCollection to determine which mass features should be loaded 4441 for each sample when loading representatives. 4442 4443 Parameters 4444 ---------- 4445 representative_metric : str, optional 4446 The metric to use to determine the most representative sample. 4447 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4448 Default is None. 4449 include_cluster_id : bool, optional 4450 If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id. 4451 Default is True. 4452 4453 Returns 4454 ------- 4455 dict 4456 Dictionary mapping sample_id (int) to list of mass feature identifiers. 4457 If include_cluster_id=True: list of tuples (mf_id, cluster_id) 4458 If include_cluster_id=False: list of mf_id integers 4459 4460 Examples 4461 -------- 4462 >>> # Get map with cluster IDs for loading 4463 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives() 4464 >>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...} 4465 >>> 4466 >>> # Get map without cluster IDs for pipeline 4467 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False) 4468 >>> # sample_mf_map = {0: [123, 456], 1: [789], ...} 4469 """ 4470 # Get all representative mass features in bulk (much faster than looping) 4471 representatives = self.get_representative_mass_features_for_all_clusters( 4472 representative_metric=representative_metric 4473 ) 4474 4475 # Build sample_mf_map 4476 sample_mf_map = {} 4477 for _, row in representatives.iterrows(): 4478 sample_id = row["sample_id"] 4479 mf_id = row["mf_id"] 4480 cluster_id = row["cluster"] 4481 4482 if sample_id not in sample_mf_map: 4483 sample_mf_map[sample_id] = [] 4484 4485 if include_cluster_id: 4486 sample_mf_map[sample_id].append((mf_id, cluster_id)) 4487 else: 4488 sample_mf_map[sample_id].append(mf_id) 4489 4490 return sample_mf_map
Build a mapping of sample_id -> list of representative mass feature IDs to load.
This is a DRY helper method used by both process_consensus_features() and ReadSavedLCMSCollection to determine which mass features should be loaded for each sample when loading representatives.
Parameters
- representative_metric (str, optional): The metric to use to determine the most representative sample. If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. Default is None.
- include_cluster_id (bool, optional): If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id. Default is True.
Returns
- dict: Dictionary mapping sample_id (int) to list of mass feature identifiers. If include_cluster_id=True: list of tuples (mf_id, cluster_id) If include_cluster_id=False: list of mf_id integers
Examples
>>> # Get map with cluster IDs for loading
>>> sample_mf_map = collection.get_sample_mf_map_for_representatives()
>>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...}
>>>
>>> # Get map without cluster IDs for pipeline
>>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False)
>>> # sample_mf_map = {0: [123, 456], 1: [789], ...}
4492 def get_most_representative_sample_for_cluster( 4493 self, cluster_id, representative_metric=None 4494 ): 4495 """ 4496 Get the most representative sample for a given cluster based on a metric. 4497 4498 Parameters 4499 ---------- 4500 cluster_id : int 4501 The cluster ID to find the representative sample for. 4502 representative_metric : str, optional 4503 The metric to use to determine the most representative sample. 4504 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4505 Options: 4506 - 'intensity': Selects the mass feature with the highest intensity 4507 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 4508 or the highest intensity overall if none have MS2 4509 Default is None (uses parameter setting). 4510 4511 Returns 4512 ------- 4513 dict 4514 Dictionary containing: 4515 - 'sample_id': The sample ID of the most representative sample 4516 - 'sample_name': The sample name of the most representative sample 4517 - 'mf_id': The mass feature ID in the sample 4518 - 'coll_mf_id': The collection-level mass feature ID (index) 4519 - 'has_ms2': Whether this mass feature has MS2 scan numbers 4520 - 'intensity': The intensity value of the representative mass feature 4521 4522 Raises 4523 ------ 4524 ValueError 4525 If cluster_id is not found or if representative_metric is not a valid column. 4526 """ 4527 # Use the bulk method to get all representatives, then filter to this cluster 4528 # This follows DRY principle and ensures consistency 4529 all_representatives = self.get_representative_mass_features_for_all_clusters( 4530 representative_metric=representative_metric 4531 ) 4532 4533 # Filter to the requested cluster 4534 cluster_rep = all_representatives[all_representatives["cluster"] == cluster_id] 4535 4536 if len(cluster_rep) == 0: 4537 # Try to provide helpful error message 4538 available_clusters = self.mass_features_dataframe["cluster"].unique() 4539 raise ValueError( 4540 f"Cluster {cluster_id} not found in mass_features_dataframe. " 4541 f"Available clusters: {sorted(available_clusters[:10].tolist())}... " 4542 f"(showing first 10 of {len(available_clusters)} total clusters)" 4543 ) 4544 4545 # Get the representative row (should only be one) 4546 rep_row = cluster_rep.iloc[0] 4547 4548 # Get sample name from sample_id (convert to int for list indexing) 4549 sample_id = int(rep_row["sample_id"]) 4550 sample_name = self.samples[sample_id] 4551 4552 return { 4553 "sample_id": sample_id, 4554 "sample_name": sample_name, 4555 "mf_id": rep_row["mf_id"], 4556 "coll_mf_id": rep_row["coll_mf_id"], 4557 "has_ms2": rep_row["has_ms2"], 4558 "intensity": rep_row["intensity"], 4559 }
Get the most representative sample for a given cluster based on a metric.
Parameters
- cluster_id (int): The cluster ID to find the representative sample for.
- representative_metric (str, optional):
The metric to use to determine the most representative sample.
If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric.
Options:
- 'intensity': Selects the mass feature with the highest intensity
- 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, or the highest intensity overall if none have MS2 Default is None (uses parameter setting).
Returns
- dict: Dictionary containing:
- 'sample_id': The sample ID of the most representative sample
- 'sample_name': The sample name of the most representative sample
- 'mf_id': The mass feature ID in the sample
- 'coll_mf_id': The collection-level mass feature ID (index)
- 'has_ms2': Whether this mass feature has MS2 scan numbers
- 'intensity': The intensity value of the representative mass feature
Raises
- ValueError: If cluster_id is not found or if representative_metric is not a valid column.
4561 def reload_representative_mass_features( 4562 self, 4563 add_ms2=False, 4564 auto_process_ms2=True, 4565 ms2_spectrum_mode=None, 4566 ms2_scan_filter=None, 4567 ): 4568 """ 4569 Reload mass features for all representative samples in the cluster summary. 4570 4571 This method is useful when the collection was loaded with load_light=True, 4572 which stores mass features only in the collection dataframe. This reloads 4573 the specific mass features that are representatives for each cluster, 4574 allowing them to be accessed as LCMSMassFeature objects. 4575 4576 Parameters 4577 ---------- 4578 add_ms2 : bool, optional 4579 If True, also loads and associates MS2 spectra with mass features. Default is False. 4580 auto_process_ms2 : bool, optional 4581 If True and add_ms2=True, auto-processes MS2 spectra. Default is True. 4582 ms2_spectrum_mode : str or None, optional 4583 Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. 4584 ms2_scan_filter : str or None, optional 4585 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 4586 4587 Returns 4588 ------- 4589 dict 4590 Dictionary mapping sample_id to list of reloaded mf_ids. 4591 4592 Raises 4593 ------ 4594 ValueError 4595 If cluster_summary_dataframe is not set (run add_consensus_mass_features first). 4596 4597 Notes 4598 ----- 4599 - Only reloads mass features that are cluster representatives 4600 - Uses get_most_representative_sample_for_cluster() to determine which to reload 4601 - More memory-efficient than reloading all mass features 4602 - Parallelized based on lcms_collection.cores parameter 4603 - MS2 association uses same logic as add_associated_ms2_dda() 4604 4605 See Also 4606 -------- 4607 _reload_sample_mass_features : Low-level method to reload specific mass features 4608 get_most_representative_sample_for_cluster : Gets representative sample for cluster 4609 """ 4610 # Validate prerequisites 4611 if ( 4612 not hasattr(self, "cluster_summary_dataframe") 4613 or self.cluster_summary_dataframe is None 4614 ): 4615 raise ValueError( 4616 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 4617 ) 4618 4619 # Get all representative mass features in bulk (much faster than looping) 4620 representatives = self.get_representative_mass_features_for_all_clusters() 4621 4622 # Build a dictionary of sample_id -> list of mf_ids that are representatives 4623 sample_mf_map = {} 4624 for _, row in representatives.iterrows(): 4625 sample_id = row["sample_id"] 4626 mf_id = row["mf_id"] 4627 4628 if sample_id not in sample_mf_map: 4629 sample_mf_map[sample_id] = [] 4630 sample_mf_map[sample_id].append(mf_id) 4631 4632 # Reload mass features for each sample (parallelized) 4633 if self.parameters.lcms_collection.cores == 1: 4634 # Serial processing 4635 from tqdm import tqdm 4636 4637 for sample_id in tqdm( 4638 sample_mf_map.keys(), 4639 desc="Reloading representative mass features", 4640 unit="sample", 4641 ): 4642 mf_ids = sample_mf_map[sample_id] 4643 self._reload_sample_mass_features( 4644 sample_id, 4645 mf_ids_to_load=mf_ids, 4646 add_ms2=add_ms2, 4647 auto_process_ms2=auto_process_ms2, 4648 ms2_spectrum_mode=ms2_spectrum_mode, 4649 ms2_scan_filter=ms2_scan_filter, 4650 ) 4651 else: 4652 # Parallel processing 4653 from tqdm import tqdm 4654 4655 if self.parameters.lcms_collection.cores > len(sample_mf_map): 4656 ncores = len(sample_mf_map) 4657 else: 4658 ncores = self.parameters.lcms_collection.cores 4659 4660 # Build arguments list for starmap 4661 args_list = [ 4662 ( 4663 sample_id, 4664 sample_mf_map[sample_id], 4665 add_ms2, 4666 auto_process_ms2, 4667 ms2_spectrum_mode, 4668 ms2_scan_filter, 4669 False, 4670 ) 4671 for sample_id in sample_mf_map.keys() 4672 ] 4673 4674 # Execute in parallel 4675 with spawn_pool(ncores) as pool: 4676 mp_result = pool.starmap(self._reload_sample_mass_features, args_list) 4677 4678 # Collect results back into samples 4679 for i, sample_id in enumerate( 4680 tqdm( 4681 sample_mf_map.keys(), 4682 desc="Collecting reloaded mass features", 4683 unit="sample", 4684 ) 4685 ): 4686 self[sample_id].mass_features = mp_result[i] 4687 4688 return sample_mf_map
Reload mass features for all representative samples in the cluster summary.
This method is useful when the collection was loaded with load_light=True, which stores mass features only in the collection dataframe. This reloads the specific mass features that are representatives for each cluster, allowing them to be accessed as LCMSMassFeature objects.
Parameters
- add_ms2 (bool, optional): If True, also loads and associates MS2 spectra with mass features. Default is False.
- auto_process_ms2 (bool, optional): If True and add_ms2=True, auto-processes MS2 spectra. Default is True.
- ms2_spectrum_mode (str or None, optional): Spectrum mode for MS2 spectra. If None, determines from parser. Default is None.
- ms2_scan_filter (str or None, optional): Filter string for MS2 scans (e.g., 'hcd'). Default is None.
Returns
- dict: Dictionary mapping sample_id to list of reloaded mf_ids.
Raises
- ValueError: If cluster_summary_dataframe is not set (run add_consensus_mass_features first).
Notes
- Only reloads mass features that are cluster representatives
- Uses get_most_representative_sample_for_cluster() to determine which to reload
- More memory-efficient than reloading all mass features
- Parallelized based on lcms_collection.cores parameter
- MS2 association uses same logic as add_associated_ms2_dda()
See Also
_reload_sample_mass_features: Low-level method to reload specific mass features
get_most_representative_sample_for_cluster: Gets representative sample for cluster
4879 def add_sparse_distance_matrix(self, features): 4880 if features is None: 4881 return None 4882 else: 4883 features = features.copy() 4884 4885 # Parameters for calculating distance between features 4886 dims = ["mz", "scan_time_aligned"] 4887 relative = [True, False] 4888 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4889 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 4890 dist_weight = [1, 1] 4891 4892 # Check that the dimensions and tolerances are the same length 4893 if ( 4894 len(dims) != len(tol) 4895 or len(dims) != len(relative) 4896 or len(dims) != len(dist_weight) 4897 ): 4898 raise ValueError( 4899 "The dimensions, tolerances, relative, dist_weight, and na_allow lists must be the same length" 4900 ) 4901 4902 # Make connectivity matrix for masking within sample mass features 4903 ## Masking matrix cmat will mark all features from the same sample as 0 4904 ## To mask, a matrix can be multiplied by cmat and features from same 4905 ## samples are multiplied by 0, while features from different samples 4906 ## are multiplied by 1 4907 4908 if "sample_id" not in features.columns: 4909 cmat = None 4910 else: 4911 vals = features["sample_id"].values.reshape(-1, 1) 4912 cmat = scipy.spatial.distance.cdist(vals, vals) 4913 # Convert to binary (0 if same sample, 1 if different) 4914 cmat = np.where(cmat == 0, 0, 1) 4915 # Convert to coordinate matrix for sparse operations later 4916 cmat = sparse.coo_matrix(cmat) 4917 4918 # Compute inter-feature distances using sparse matrix approach 4919 distances = None # clear the distances object before starting 4920 for i in range(len(dims)): # iterate through all dimensions to be considered 4921 # Construct k-d tree 4922 values = features[dims[i]].values 4923 4924 tree = KDTree(values.reshape(-1, 1)) 4925 4926 max_tol = tol[i] 4927 if relative[i] is True: 4928 # Maximum absolute tolerance 4929 max_tol = tol[i] * values.max() 4930 4931 # Compute sparse distance matrix 4932 # the larger the max_tol, the slower this operation is 4933 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 4934 4935 # Only consider forward case, exclude diagonal 4936 sdm = sparse.triu(sdm, k=1) 4937 4938 # Filter relative distances 4939 if relative[i] is True: 4940 # Compute relative distances 4941 rel_dists = sdm.data / values[sdm.row] 4942 4943 # Indices of relative distances less than tolerance 4944 idx = rel_dists <= tol[i] 4945 4946 # Reconstruct sparse distance matrix 4947 sdm = sparse.coo_matrix( 4948 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 4949 shape=(len(values), len(values)), 4950 ) 4951 4952 # Scaled distances wrt the maximum tolerance for the dimension 4953 sdm.data = sdm.data / tol[i] 4954 4955 # Stack distances for dimensions where na_allow is False 4956 if distances is None: 4957 sdm.data = sdm.data * dist_weight[i] 4958 # Replace zeros with epsilon to handle perfect matches 4959 sdm.data[sdm.data == 0] = 1e-10 4960 distances = sdm 4961 else: 4962 # Prepare sdm to match shape of existing distances 4963 distances_truth = distances.copy() 4964 # make new sparse matrix with same positions as previous 4965 # distance matrix but all ones for values 4966 distances_truth.data = np.ones_like(distances_truth.data) 4967 4968 # Replace zeros with epsilon BEFORE multiply to prevent sparse matrix from dropping them 4969 sdm.data[sdm.data == 0] = 1e-10 4970 4971 # multiply the new sparse matrix (sdm) by this mask to remove 4972 # data that doesn't exist in original sparse matrix 4973 sdm = distances_truth.multiply(sdm) 4974 4975 sdm.data = sdm.data * dist_weight[i] 4976 # Replace zeros with epsilon to handle perfect matches 4977 sdm.data[sdm.data == 0] = 1e-10 4978 4979 # use same process as before to remove data from previous 4980 # distances matrix that isn't in new distances matrix 4981 sdm_truth = sdm.copy() 4982 sdm_truth.data = np.ones_like(sdm_truth.data) 4983 4984 # remove the distances that are not sdm 4985 distances = distances.multiply(sdm_truth) 4986 4987 # Sum the new distances 4988 distances = distances + sdm 4989 4990 # Multiply by connectivity matrix for more masking 4991 distances = distances.multiply(cmat) 4992 4993 # Set attribute holding distance matrix 4994 self._sparse_distance_matrix = distances
4996 def evaluate_clusters_for_repeats(self, features): 4997 raise NotImplementedError("evaluate_clusters_for_repeats not implemented yet") 4998 summary_df = self.cluster_summary_dataframe.copy() 4999 5000 # Arrange by decreasing median intensity 5001 summary_df = summary_df.sort_values( 5002 by="intensity_median", ascending=False 5003 ).reset_index(drop=True) 5004 5005 # Find clusters that are within the mz_tol and rt_tol of each other (on the medians) 5006 # Create a distance matrix 5007 # Define how to calculate the distance between features 5008 dims = ["mz_median", "scan_time_aligned_median"] 5009 relative = [True, False] 5010 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5011 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 5012 5013 # Compute inter-feature distances 5014 distances = None 5015 for i in range(len(dims)): 5016 # Construct k-d tree 5017 values = summary_df[dims[i]].values 5018 tree = KDTree(values.reshape(-1, 1)) 5019 5020 max_tol = tol[i] 5021 if relative[i] is True: 5022 # Maximum absolute tolerance 5023 max_tol = tol[i] * values.max() 5024 5025 # Compute sparse distance matrix 5026 # the larger the max_tol, the slower this operation is 5027 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 5028 5029 # Only consider forward case, exclude diagonal 5030 sdm = sparse.triu(sdm, k=1) 5031 5032 # Filter relative distances 5033 if relative[i] is True: 5034 # Compute relative distances 5035 rel_dists = sdm.data / values[sdm.row] # or col? 5036 5037 # Indices of relative distances less than tolerance 5038 idx = rel_dists <= tol[i] 5039 5040 # Reconstruct sparse distance matrix 5041 sdm = sparse.coo_matrix( 5042 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 5043 shape=(len(values), len(values)), 5044 ) 5045 5046 # Cast as binary matrix 5047 sdm.data = np.ones_like(sdm.data) 5048 5049 # Stack distances 5050 if distances is None: 5051 distances = sdm 5052 else: 5053 distances = distances.multiply(sdm) 5054 5055 # Roll up features 5056 # Extract indices of within-tolerance points 5057 distances = distances.tocoo() 5058 pairs = np.stack( 5059 (distances.row, distances.col), axis=1 5060 ) # These are the index values of the clusters, not the cluster ids 5061 # Conver to cluster ids 5062 pairs_df = pd.DataFrame(pairs, columns=["parent", "child"]) 5063 pairs_df["parent"] = summary_df.loc[pairs[:, 0]]["cluster"].values 5064 pairs_df["child"] = summary_df.loc[pairs[:, 1]]["cluster"].values 5065 pairs_df = pairs_df.set_index("parent") 5066 5067 merge_these_clusters = [] 5068 possible_overlaps = [] 5069 root_parents = np.setdiff1d( 5070 np.unique(pairs_df.index.values), np.unique(pairs_df.child.values) 5071 ) 5072 for parent in root_parents: 5073 parent_features = features[features["cluster"] == parent] 5074 children = pairs_df.loc[[parent], "child"].tolist() 5075 for child in children: 5076 overlap = self.check_merge(parent_features, child, features) 5077 if len(overlap) == 0: 5078 merge_these_clusters.append((parent, child, len(overlap))) 5079 else: 5080 possible_overlaps.append((parent, child, len(overlap))) 5081 5082 result_dict = {} 5083 result_dict["merge_these_clusters"] = merge_these_clusters 5084 result_dict["possible_overlaps"] = possible_overlaps 5085 5086 return result_dict
5088 def check_merge(self, parent_features, child, features): 5089 # Grab the features of the parent and children 5090 child_features = features[features["cluster"] == child] 5091 5092 # Check if there is an overlap between mf_coll_id in the parent and child clusters 5093 overlap = np.intersect1d( 5094 parent_features["sample_id"].values, child_features["sample_id"].values 5095 ) 5096 5097 return overlap
5099 def cluster_mass_features_agg_cluster(self, features): 5100 if features is None: 5101 return None 5102 5103 features = features.copy() 5104 5105 self.add_sparse_distance_matrix(features) 5106 5107 distances = self._sparse_distance_matrix 5108 5109 # Convert to full matrix 5110 distances = distances.todense() 5111 5112 # Cast all 0s to 1s for a distance matrix 5113 distances[distances == 0] = 1 5114 distances = np.asarray(distances) 5115 5116 # Perform clustering 5117 try: 5118 clustering = AgglomerativeClustering( 5119 n_clusters=None, 5120 linkage="complete", 5121 # using complete linkage will prevent one sample from being assigned to multiple clusters 5122 metric="precomputed", 5123 distance_threshold=1, 5124 ).fit(distances) 5125 features["cluster"] = clustering.labels_ 5126 5127 # All data points are singleton clusters 5128 except: 5129 features["cluster"] = np.arange(len(features.index)) 5130 5131 return features
5133 def cluster_inspection_plot(self, clu, return_fig=False, path=None): 5134 """ 5135 Generate Scan Time vs m/z plot for a narrow range around a given 5136 cluster. This tool is meant to support the user in fine tuning the 5137 tolerances used for the clustering algorithm. The user-provided cluster 5138 ID is highlighted in larger, magenta marker and the ten largest of the 5139 remaining clusters are idenfitied with different colors while the 5140 smallest clusters are light gray. 5141 5142 Parameters 5143 ----------- 5144 clu : integer 5145 A cluster ID that exists in self.mass_features_dataframe 5146 return_fig : bool, optional 5147 If True, return the open figure (caller owns lifecycle). 5148 Default is False. 5149 path : str or path-like, optional 5150 If set, save the figure to this path. When ``return_fig`` is False, 5151 the figure is closed after saving and ``plt.show()`` is not called. 5152 5153 Returns 5154 -------- 5155 matplotlib.figure.Figure or None 5156 A figure displaying a scan time vs m/z scatterplot of small region 5157 around a given cluster with the ten largest clusters in the region 5158 distinctly identified if ``return_fig`` is True; otherwise None. 5159 5160 Raises 5161 ------ 5162 ValueError 5163 If cluster data haven't been added to the object yet 5164 """ 5165 5166 if "cluster" not in self.mass_features_dataframe.columns: 5167 raise ValueError( 5168 "Cluster information is not yet added to mass_features_dataframe, must run add_consensus_mass_features() first" 5169 ) 5170 5171 else: 5172 mztol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5173 rttol = self.parameters.lcms_collection.consensus_rt_tol 5174 clu_features = self.mass_features_dataframe.copy() 5175 5176 inclu = clu_features[clu_features.cluster == clu] 5177 exclu = clu_features[clu_features.cluster != clu] 5178 5179 dt_ymin = np.floor(min(inclu.mz)) - 1 5180 dt_ymax = np.ceil(max(inclu.mz)) + 1 5181 dt_xmin = np.floor(min(inclu.scan_time_aligned)) - 1 5182 dt_xmax = np.ceil(max(inclu.scan_time_aligned)) + 1 5183 5184 exclu = exclu[ 5185 (exclu.mz.between(dt_ymin, dt_ymax, inclusive="both")) 5186 & (exclu.scan_time_aligned.between(dt_xmin, dt_xmax, inclusive="both")) 5187 ] 5188 5189 bigclulist = list(exclu.cluster.value_counts()[:10].index) 5190 bigclu = exclu[exclu.cluster.isin(bigclulist)] 5191 smclu = exclu[~exclu.cluster.isin(bigclulist)] 5192 5193 colors = np.arange(0, 10) 5194 colordict = dict(zip(bigclulist, colors)) 5195 bigclu["color"] = bigclu.cluster.apply(lambda x: colordict[x]) 5196 5197 fig = plt.figure(figsize=(7.5, 5)) 5198 5199 plt.scatter( 5200 inclu.scan_time_aligned, 5201 inclu.mz, 5202 c="m", 5203 s=3, 5204 label="Cluster " + str(clu), 5205 ) 5206 5207 plt.scatter( 5208 bigclu.scan_time_aligned, bigclu.mz, c=bigclu.color, cmap="tab10", s=1.5 5209 ) 5210 5211 plt.scatter( 5212 smclu.scan_time_aligned, 5213 smclu.mz, 5214 c="silver", 5215 s=2, 5216 label="Small clusters", 5217 ) 5218 5219 plt.ylim(dt_ymin, dt_ymax) 5220 plt.xlim(dt_xmin, dt_xmax) 5221 plt.legend(ncol=2, bbox_to_anchor=(0.8, -0.1)) 5222 plt.xlabel("Scan time") 5223 plt.ylabel("m/z") 5224 title_str = "Cluster " + str(clu) 5225 title_str += ": representing " + str(len(inclu.sample_id.unique())) 5226 title_str += " of " + str(len(clu_features.sample_id.unique())) 5227 title_str += " samples\n" 5228 title_str += "M/Z tolerance: " + str(mztol) + "\n" 5229 title_str += "Scan Time tolerance: " + str(rttol) 5230 plt.title(title_str, fontsize=10) 5231 5232 return _finalize_plot(fig, return_fig=return_fig, path=path)
Generate Scan Time vs m/z plot for a narrow range around a given cluster. This tool is meant to support the user in fine tuning the tolerances used for the clustering algorithm. The user-provided cluster ID is highlighted in larger, magenta marker and the ten largest of the remaining clusters are idenfitied with different colors while the smallest clusters are light gray.
Parameters
- clu (integer): A cluster ID that exists in self.mass_features_dataframe
- return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
- path (str or path-like, optional):
If set, save the figure to this path. When
return_figis False, the figure is closed after saving andplt.show()is not called.
Returns
- matplotlib.figure.Figure or None: A figure displaying a scan time vs m/z scatterplot of small region
around a given cluster with the ten largest clusters in the region
distinctly identified if
return_figis True; otherwise None.
Raises
- ValueError: If cluster data haven't been added to the object yet
5234 def plot_cluster_outlier_frequency( 5235 self, 5236 dim_list=["mz", "scan_time_aligned"], 5237 clu_size_thresh=0.5, 5238 return_fig=False, 5239 path=None, 5240 ): 5241 """ 5242 Generate histogram showing the frequency of outlier occurrences by 5243 clustering dimension across all clusters 5244 5245 Parameters 5246 ----------- 5247 dim_list : list 5248 List of strings describing dimensions that can be used in 5249 clustering. Available list items: 5250 - 'mz' 5251 - 'scan_time_aligned' 5252 - 'half_height_width' 5253 - 'tailing_factor' 5254 - 'dispersity_index' 5255 - 'intensity' 5256 - 'persistence' 5257 clu_size_thresh : float 5258 Value between 0 and 1 that indicates what percentage of samples 5259 need to be present in a cluster before it's evaluated for outliers. 5260 Defaults to 0.5. 5261 return_fig : bool, optional 5262 If True, return the open figure (caller owns lifecycle). 5263 Default is False. 5264 path : str or path-like, optional 5265 If set, save the figure to this path. When ``return_fig`` is False, 5266 the figure is closed after saving and ``plt.show()`` is not called. 5267 5268 Returns 5269 -------- 5270 matplotlib.figure.Figure or None 5271 A figure displaying the frequency of outlier occurrences across all 5272 clusters in the provided measurement dimensions if ``return_fig`` is 5273 True; otherwise None. 5274 5275 Raises 5276 ------ 5277 ValueError 5278 If cluster data haven't been added to the object yet 5279 """ 5280 5281 if not hasattr(self, "cluster_summary_dataframe"): 5282 raise ValueError( 5283 "cluster_summary_dataframe is not yet added, must run add_consensus_mass_features() first" 5284 ) 5285 5286 mfdf = self.mass_features_dataframe.copy() 5287 summarydf = self.cluster_summary_dataframe 5288 5289 numsamples = len(self) 5290 sumdf = ( 5291 summarydf[summarydf.sample_id_nunique > numsamples * clu_size_thresh] 5292 .reset_index(drop=True) 5293 .copy() 5294 ) 5295 5296 ## find the ranges for non-outlier values and add them to sumdf 5297 mergelist = ["cluster"] 5298 for dim in dim_list: 5299 maxtag = dim + "_outmax" 5300 mintag = dim + "_outmin" 5301 mergelist.append(maxtag) 5302 mergelist.append(mintag) 5303 # Calculate outlier thresholds using vectorized operations 5304 sumdf[mintag] = sumdf[dim + "_mean"] - 3 * sumdf[dim + "_std"] 5305 sumdf[maxtag] = sumdf[dim + "_mean"] + 3 * sumdf[dim + "_std"] 5306 ## If NaN shows up anywhere in dim_min, dim_max calculations, value is set to NaN and it's 5307 ## not flagged. This happens when there's not enough values to compute median/std for that 5308 ## dimension therefore can't have outliers 5309 5310 ## add ranges to mfdf and identify mass features that fall outside the ranges 5311 # Merge without dropping NaN - we'll handle it per-dimension 5312 outdf = pd.merge(mfdf, sumdf[mergelist], on="cluster") 5313 5314 outtags = ["cluster"] 5315 for dim in dim_list: 5316 dimtag = dim + "_outlier" 5317 outtags.append(dimtag) 5318 maxtag = dim + "_outmax" 5319 mintag = dim + "_outmin" 5320 # Only flag as outlier if thresholds are valid (not NaN) 5321 outdf[dimtag] = np.where( 5322 (outdf[maxtag].notna() & outdf[mintag].notna()) 5323 & ((outdf[dim] > outdf[maxtag]) | (outdf[dim] < outdf[mintag])), 5324 True, 5325 False, 5326 ) 5327 5328 ## identify number of outliers in each cluster 5329 outliers = outdf[outtags] 5330 outliers = outliers.groupby(["cluster"]).sum() 5331 5332 ## plot number of clusters that contain any outliers 5333 fig = plt.figure() 5334 plt.bar(dim_list, outliers.sum().values, width=0.5) 5335 plt.xticks(rotation=90) 5336 plt.title("Frequency of outliers across all clusters by category") 5337 5338 return _finalize_plot(fig, return_fig=return_fig, path=path)
Generate histogram showing the frequency of outlier occurrences by clustering dimension across all clusters
Parameters
- dim_list (list): List of strings describing dimensions that can be used in clustering. Available list items: - 'mz' - 'scan_time_aligned' - 'half_height_width' - 'tailing_factor' - 'dispersity_index' - 'intensity' - 'persistence'
- clu_size_thresh (float): Value between 0 and 1 that indicates what percentage of samples need to be present in a cluster before it's evaluated for outliers. Defaults to 0.5.
- return_fig (bool, optional): If True, return the open figure (caller owns lifecycle). Default is False.
- path (str or path-like, optional):
If set, save the figure to this path. When
return_figis False, the figure is closed after saving andplt.show()is not called.
Returns
- matplotlib.figure.Figure or None: A figure displaying the frequency of outlier occurrences across all
clusters in the provided measurement dimensions if
return_figis True; otherwise None.
Raises
- ValueError: If cluster data haven't been added to the object yet
5515 def fill_missing_cluster_features(self): 5516 """ 5517 Gap-filling for consensus mass features across collection samples. 5518 5519 For clusters present in multiple samples but missing from others, searches 5520 raw MS1 data to find peaks in expected m/z and retention time windows. This 5521 creates "induced" mass features for peaks that exist in the data but weren't 5522 detected in the initial peak detection. 5523 5524 Must be run after add_consensus_mass_features(). Results are accessible via 5525 induced_mass_features_dataframe property and included in collection_pivot_table 5526 and collection_consensus_report outputs. 5527 5528 Parameters 5529 ---------- 5530 None 5531 Uses parameters from self.parameters.lcms_collection: 5532 - consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain 5533 a cluster before gap-filling is attempted 5534 - gap_fill_expand_on_miss: If True, expands search window when no peak is found 5535 5536 Returns 5537 ------- 5538 None 5539 Updates induced_mass_features attribute for each LCMSBase object and 5540 combines them into induced_mass_features_dataframe. 5541 5542 Raises 5543 ------ 5544 ValueError 5545 If cluster_summary_dataframe is not set (must run add_consensus_mass_features first). 5546 5547 Notes 5548 ----- 5549 - Loads raw MS1 data for each sample, which may be memory intensive 5550 - Induced features are integrated and metrics calculated automatically 5551 - Processing can be parallelized using parameters.lcms_collection.cores 5552 5553 See Also 5554 -------- 5555 add_consensus_mass_features : Creates consensus features before gap-filling 5556 collection_pivot_table : Includes both regular and induced features 5557 collection_consensus_report : Reports on complete feature matrix 5558 """ 5559 5560 # Validate prerequisites 5561 if ( 5562 not hasattr(self, "cluster_summary_dataframe") 5563 or self.cluster_summary_dataframe is None 5564 ): 5565 raise ValueError( 5566 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 5567 ) 5568 5569 # Get parameters from settings 5570 min_cluster_presence = ( 5571 self.parameters.lcms_collection.consensus_min_sample_fraction 5572 ) 5573 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5574 5575 # Validate parameters 5576 if not 0 <= min_cluster_presence <= 1: 5577 raise ValueError("consensus_min_sample_fraction must be between 0 and 1") 5578 5579 summarydf = self.cluster_summary_dataframe 5580 mfdf = self.mass_features_dataframe 5581 5582 sample_ct = len(self.samples) 5583 5584 # Identify clusters present in sufficient samples but not all samples 5585 missingdf = summarydf[ 5586 [ 5587 "cluster", 5588 "sample_id_nunique", 5589 "mz_min", 5590 "mz_max", 5591 "scan_time_aligned_min", 5592 "scan_time_aligned_max", 5593 ] 5594 ] 5595 missingdf = missingdf[ 5596 missingdf.sample_id_nunique > min_cluster_presence * sample_ct 5597 ] 5598 missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] 5599 5600 # Check if there are any clusters to gap-fill 5601 if len(missingdf) == 0: 5602 return 5603 5604 # One groupby. Sample ids stay in collection order, including samples 5605 # that have no mass features. 5606 missingdf["missing_samples"] = self._missing_sample_ids_by_cluster( 5607 mfdf, missingdf.cluster.to_numpy() 5608 ) 5609 5610 # Calculate expanded search windows for expand_on_miss option 5611 mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5612 rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol 5613 missingdf["mz_max_allowed"] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max 5614 missingdf["mz_min_allowed"] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min 5615 # consensus_rt_tol is an absolute tolerance in minutes, same as clustering. 5616 missingdf["sta_max_allowed"] = missingdf.scan_time_aligned_max + rt_clu_tol 5617 missingdf["sta_min_allowed"] = missingdf.scan_time_aligned_min - rt_clu_tol 5618 5619 # Compute cluster dictionary once to avoid recomputing for each sample 5620 cluster_dict = self.cluster_feature_dictionary 5621 5622 # Process each sample to search for missing features 5623 if self.parameters.lcms_collection.cores == 1: 5624 for i in tqdm(range(sample_ct), desc="Gap-filling samples", unit="sample"): 5625 self._search_for_targeted_mass_features_in_sample( 5626 i, missingdf, cluster_dict, expand_on_miss 5627 ) 5628 5629 if self.parameters.lcms_collection.cores > 1: 5630 if self.parameters.lcms_collection.cores > len(self): 5631 ncores = len(self) 5632 else: 5633 ncores = self.parameters.lcms_collection.cores 5634 with spawn_pool(ncores) as pool: 5635 mp_result = pool.starmap( 5636 self._search_for_targeted_mass_features_in_sample, 5637 [ 5638 (x, missingdf, cluster_dict, expand_on_miss, False) 5639 for x in range(sample_ct) 5640 ], 5641 ) 5642 5643 for i in tqdm( 5644 range(sample_ct), desc="Collecting gap-filled features", unit="sample" 5645 ): 5646 self[i].induced_mass_features = mp_result[i] 5647 5648 self._combine_mass_features(induced_features=True) 5649 5650 # Mark that gap-filling has been performed 5651 self.missing_mass_features_searched = True 5652 5653 for sample_name in self.samples: 5654 self._lcms[sample_name].mass_features = {}
Gap-filling for consensus mass features across collection samples.
For clusters present in multiple samples but missing from others, searches raw MS1 data to find peaks in expected m/z and retention time windows. This creates "induced" mass features for peaks that exist in the data but weren't detected in the initial peak detection.
Must be run after add_consensus_mass_features(). Results are accessible via induced_mass_features_dataframe property and included in collection_pivot_table and collection_consensus_report outputs.
Parameters
- None: Uses parameters from self.parameters.lcms_collection:
- consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain a cluster before gap-filling is attempted
- gap_fill_expand_on_miss: If True, expands search window when no peak is found
Returns
- None: Updates induced_mass_features attribute for each LCMSBase object and combines them into induced_mass_features_dataframe.
Raises
- ValueError: If cluster_summary_dataframe is not set (must run add_consensus_mass_features first).
Notes
- Loads raw MS1 data for each sample, which may be memory intensive
- Induced features are integrated and metrics calculated automatically
- Processing can be parallelized using parameters.lcms_collection.cores
See Also
add_consensus_mass_features: Creates consensus features before gap-filling
collection_pivot_table: Includes both regular and induced features
collection_consensus_report: Reports on complete feature matrix
5656 def process_samples_pipeline( 5657 self, operations, description=None, keep_raw_data=False, show_progress=True 5658 ): 5659 """ 5660 Execute a pipeline of operations on all samples in parallel. 5661 5662 This method provides a flexible framework for performing multiple 5663 sample-level operations in a single parallelized pass, which is more 5664 efficient than calling separate methods sequentially. 5665 5666 Parameters 5667 ---------- 5668 operations : list of SampleOperation 5669 List of operations to perform on each sample, in order. 5670 Each operation should be an instance of a class derived from 5671 SampleOperation (see lc_calc_operations module). 5672 description : str or None, optional 5673 Progress bar description. If None, automatically generates description 5674 from operation descriptions (e.g., "gap-filling, reloading features"). 5675 Default is None. 5676 keep_raw_data : bool, optional 5677 If True, keeps raw MS data loaded in memory after pipeline completes. 5678 If False, cleans up raw data to free memory. Default is False. 5679 show_progress : bool, optional 5680 If True, displays progress bars during processing. If False, runs silently. 5681 Default is True. 5682 5683 Returns 5684 ------- 5685 dict 5686 Dictionary with results from pipeline execution, keyed by operation name. 5687 Structure: {operation_name: {sample_id: result, ...}, ...} 5688 5689 Raises 5690 ------ 5691 ValueError 5692 If operations list is empty or contains invalid operations. 5693 5694 Notes 5695 ----- 5696 - Operations are executed sequentially within each sample 5697 - Samples are processed in parallel based on parameters.lcms_collection.cores 5698 - Each operation can have conditional execution via can_execute() 5699 - Results are collected back via collect_results() method of each operation 5700 - Failed operations for a sample are logged but don't halt processing 5701 - Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True 5702 5703 Examples 5704 -------- 5705 >>> from corems.mass_spectra.calc.lc_calc_operations import ( 5706 ... GapFillOperation, ReloadFeaturesOperation 5707 ... ) 5708 >>> ops = [ 5709 ... GapFillOperation('gap_fill', expand_on_miss=True), 5710 ... ReloadFeaturesOperation('reload', add_ms2=True) 5711 ... ] 5712 >>> results = lcms_collection.process_samples_pipeline(ops) 5713 5714 See Also 5715 -------- 5716 lc_calc_operations : Module containing built-in operation classes 5717 fill_and_process_features : Convenience method combining common operations 5718 """ 5719 from corems.mass_spectra.calc.lc_calc_operations import SampleOperation 5720 5721 # Validate operations 5722 if not operations or len(operations) == 0: 5723 raise ValueError("operations list cannot be empty") 5724 5725 for op in operations: 5726 if not isinstance(op, SampleOperation): 5727 raise ValueError( 5728 f"All operations must be SampleOperation instances, got {type(op)}" 5729 ) 5730 5731 # Generate description from operations if not provided 5732 if description is None: 5733 operation_descriptions = [op.description for op in operations] 5734 description = ", ".join(operation_descriptions).capitalize() 5735 5736 # Prepare runtime parameters for each operation 5737 # This is where we gather collection-level data that operations need 5738 runtime_params = self._prepare_pipeline_runtime_params(operations) 5739 runtime_params["keep_raw_data"] = keep_raw_data 5740 5741 # Execute pipeline 5742 sample_ct = len(self.samples) 5743 5744 if self.parameters.lcms_collection.cores == 1: 5745 # Serial processing 5746 results_by_operation = {op.name: {} for op in operations} 5747 5748 if show_progress: 5749 from tqdm import tqdm 5750 5751 # Print description on its own line before progress bar 5752 print(f"\n{description.capitalize()}:") 5753 iterator = tqdm(range(sample_ct), unit="sample", ncols=80) 5754 else: 5755 iterator = range(sample_ct) 5756 5757 for sample_id in iterator: 5758 sample_results = self._execute_sample_pipeline( 5759 sample_id, operations, runtime_params, inplace=True 5760 ) 5761 # Collect results (collect_results already called in _execute_sample_pipeline when inplace=True) 5762 # Skip 'sample_id' key which is added for tracking 5763 for op_name, result in sample_results.items(): 5764 if op_name != "sample_id": 5765 results_by_operation[op_name][sample_id] = result 5766 else: 5767 # Parallel processing 5768 if self.parameters.lcms_collection.cores > sample_ct: 5769 ncores = sample_ct 5770 else: 5771 ncores = self.parameters.lcms_collection.cores 5772 5773 # Build arguments for each sample 5774 args_list = [ 5775 (sample_id, operations, runtime_params, False) 5776 for sample_id in range(sample_ct) 5777 ] 5778 5779 # Execute in parallel with progress tracking 5780 results_by_operation = {op.name: {} for op in operations} 5781 5782 with spawn_pool(ncores) as pool: 5783 if show_progress: 5784 from tqdm import tqdm 5785 import time 5786 5787 # Use starmap_async for parallel execution with progress tracking 5788 async_result = pool.starmap_async( 5789 self._execute_sample_pipeline, args_list 5790 ) 5791 5792 # Poll for completion and update progress bar 5793 print(description) 5794 pbar = tqdm( 5795 total=sample_ct, 5796 desc="", 5797 unit="sample", 5798 position=0, 5799 leave=True, 5800 dynamic_ncols=True, 5801 ) 5802 prev_completed = 0 5803 while not async_result.ready(): 5804 # Get number of completed tasks by checking remaining 5805 completed = sample_ct - async_result._number_left 5806 if completed > prev_completed: 5807 pbar.update(completed - prev_completed) 5808 prev_completed = completed 5809 time.sleep(0.5) # Poll every 500ms to avoid spam 5810 5811 # Final update to 100% 5812 if prev_completed < sample_ct: 5813 pbar.update(sample_ct - prev_completed) 5814 pbar.close() 5815 5816 # Get all results 5817 mp_results = async_result.get() 5818 else: 5819 # Execute without progress 5820 mp_results = pool.starmap(self._execute_sample_pipeline, args_list) 5821 5822 # Collect results back into collection 5823 for result in mp_results: 5824 sample_id = result.get("sample_id") 5825 for op in operations: 5826 op_result = result.get(op.name) 5827 if op_result is not None: 5828 op.collect_results(sample_id, op_result, self) 5829 results_by_operation[op.name][sample_id] = op_result 5830 5831 return results_by_operation
Execute a pipeline of operations on all samples in parallel.
This method provides a flexible framework for performing multiple sample-level operations in a single parallelized pass, which is more efficient than calling separate methods sequentially.
Parameters
- operations (list of SampleOperation): List of operations to perform on each sample, in order. Each operation should be an instance of a class derived from SampleOperation (see lc_calc_operations module).
- description (str or None, optional): Progress bar description. If None, automatically generates description from operation descriptions (e.g., "gap-filling, reloading features"). Default is None.
- keep_raw_data (bool, optional): If True, keeps raw MS data loaded in memory after pipeline completes. If False, cleans up raw data to free memory. Default is False.
- show_progress (bool, optional): If True, displays progress bars during processing. If False, runs silently. Default is True.
Returns
- dict: Dictionary with results from pipeline execution, keyed by operation name. Structure: {operation_name: {sample_id: result, ...}, ...}
Raises
- ValueError: If operations list is empty or contains invalid operations.
Notes
- Operations are executed sequentially within each sample
- Samples are processed in parallel based on parameters.lcms_collection.cores
- Each operation can have conditional execution via can_execute()
- Results are collected back via collect_results() method of each operation
- Failed operations for a sample are logged but don't halt processing
- Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True
Examples
>>> from corems.mass_spectra.calc.lc_calc_operations import (
... GapFillOperation, ReloadFeaturesOperation
... )
>>> ops = [
... GapFillOperation('gap_fill', expand_on_miss=True),
... ReloadFeaturesOperation('reload', add_ms2=True)
... ]
>>> results = lcms_collection.process_samples_pipeline(ops)
See Also
lc_calc_operations: Module containing built-in operation classes
fill_and_process_features: Convenience method combining common operations
6112 def process_consensus_features( 6113 self, 6114 load_representatives=True, 6115 perform_gap_filling=True, 6116 add_ms1=False, 6117 add_ms2=False, 6118 ms2_scan_filter=None, 6119 molecular_formula_search=False, 6120 ms2_spectral_search=False, 6121 spectral_lib=None, 6122 molecular_metadata=None, 6123 gather_eics=False, 6124 group_features=False, 6125 keep_raw_data=False, 6126 show_progress=True, 6127 ): 6128 """ 6129 Process consensus mass features across the collection in a single parallelized pass. 6130 6131 This method provides a convenient interface to the sample processing pipeline, 6132 allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association, 6133 molecular formula search, and MS2 spectral search) to be performed efficiently in 6134 a single pass through all samples. 6135 6136 Parameters 6137 ---------- 6138 load_representatives : bool, optional 6139 If True, loads representative mass features from HDF5. Default is True. 6140 perform_gap_filling : bool, optional 6141 If True, performs gap-filling for missing cluster features. Default is True. 6142 This operation loads raw MS1 data which can be reused by subsequent operations. 6143 group_features : bool, optional 6144 If True, run ``group_consensus_features()`` after gap-fill (and before 6145 molecular formula / MS2 search when those are also enabled). 6146 Collection-level natural-abundance **isotope and adduct** family 6147 labeling (singly charged only; not tracer/enriched). Default is False. 6148 add_ms1 : bool, optional 6149 If True and load_representatives=True, associates MS1 spectra with 6150 loaded features. Automatically uses raw data from gap-filling if available, 6151 otherwise uses parser. Spectrum mode is auto-detected. Default is False. 6152 add_ms2 : bool, optional 6153 If True and load_representatives=True, associates MS2 spectra with 6154 loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False. 6155 ms2_scan_filter : str or None, optional 6156 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 6157 molecular_formula_search : bool, optional 6158 If True, performs molecular formula search on mass features using 6159 associated MS1 spectra. Requires add_ms1=True or that MS1 spectra 6160 are already associated. Uses parameters from 6161 parameters.mass_spectrum["ms1"].molecular_search. Default is False. 6162 ms2_spectral_search : bool, optional 6163 If True, performs MS2 spectral library search using FlashEntropy. 6164 Requires add_ms2=True and spectral_lib to be provided. Default is False. 6165 spectral_lib : FlashEntropy library, optional 6166 Pre-prepared FlashEntropy spectral library for MS2 search. 6167 Create using MSPInterface.get_metabolomics_spectra_library(). 6168 Required if ms2_spectral_search=True. Default is None. 6169 molecular_metadata : pd.DataFrame, optional 6170 Molecular metadata corresponding to spectral_lib. 6171 Returned from MSPInterface.get_metabolomics_spectra_library(). 6172 Stored as self.spectral_search_molecular_metadata for later export. 6173 Default is None. 6174 gather_eics : bool, optional 6175 If True, loads extracted ion chromatograms (EICs) from HDF5 for all 6176 mass features with assigned cluster_index (including gap-filled features). 6177 Enables access to EICs via get_eics_for_cluster(cluster_id) method. 6178 Requires that EICs were previously exported with export_eics=True. 6179 Default is False. 6180 keep_raw_data : bool, optional 6181 If True, keeps raw MS data loaded in memory after pipeline completes. 6182 If False, cleans up raw data to free memory. Default is False. 6183 show_progress : bool, optional 6184 If True, displays progress bars during processing. If False, runs silently. 6185 Default is True. 6186 6187 Returns 6188 ------- 6189 dict 6190 Dictionary with pipeline results. Keys include: 6191 - 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling) 6192 - 'reload': dict mapping sample_id to reloaded mass features (if reloading) 6193 - 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search) 6194 - 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search) 6195 6196 Raises 6197 ------ 6198 ValueError 6199 If neither operation is enabled, or if required parameters are missing. 6200 6201 Notes 6202 ----- 6203 - Must run add_consensus_mass_features() before calling this method 6204 - Processes samples in parallel based on parameters.lcms_collection.cores 6205 - Raw MS1 data loaded by gap-filling is automatically reused by MS1 association 6206 - MS2 spectral search requires add_ms2=True and msp_file_path 6207 - FlashEntropy library is created once and reused across all samples 6208 - More efficient than calling individual methods separately 6209 - After gap-filling, sets missing_mass_features_searched = True 6210 - Mass features remain loaded in memory for downstream processing 6211 - For more advanced workflows, use process_samples_pipeline() directly 6212 6213 Examples 6214 -------- 6215 >>> # Prepare spectral library for MS2 search 6216 >>> from corems.molecular_id.search.database_interfaces import MSPInterface 6217 >>> my_msp = MSPInterface(file_path='path/to/library.msp') 6218 >>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library( 6219 ... polarity='negative', 6220 ... format='flashentropy', 6221 ... normalize=True, 6222 ... fe_kwargs={ 6223 ... 'normalize_intensity': True, 6224 ... 'min_ms2_difference_in_da': 0.02, 6225 ... 'max_ms2_tolerance_in_da': 0.01, 6226 ... 'max_indexed_mz': 3000, 6227 ... 'precursor_ions_removal_da': None, 6228 ... 'noise_threshold': 0, 6229 ... } 6230 ... ) 6231 >>> 6232 >>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search 6233 >>> results = lcms_collection.process_consensus_features( 6234 ... load_representatives=True, 6235 ... perform_gap_filling=True, 6236 ... add_ms1=True, 6237 ... add_ms2=True, 6238 ... molecular_formula_search=True, 6239 ... ms2_spectral_search=True, 6240 ... spectral_lib=spectral_lib, 6241 ... molecular_metadata=molecular_metadata 6242 ... ) 6243 6244 See Also 6245 -------- 6246 process_samples_pipeline : Generic pipeline executor for custom workflows 6247 fill_missing_cluster_features : Original gap-filling method 6248 reload_representative_mass_features : Original reload method 6249 """ 6250 from corems.mass_spectra.calc.lc_calc_operations import ( 6251 GapFillOperation, 6252 ReloadFeaturesOperation, 6253 MolecularFormulaSearchOperation, 6254 MS2SpectralSearchOperation, 6255 LoadEICsOperation, 6256 ) 6257 6258 # Validate that at least one meaningful operation is enabled 6259 has_operations = ( 6260 perform_gap_filling 6261 or load_representatives 6262 or molecular_formula_search 6263 or ms2_spectral_search 6264 or gather_eics 6265 or add_ms1 6266 or add_ms2 6267 or group_features 6268 ) 6269 6270 if not has_operations: 6271 raise ValueError( 6272 "At least one operation must be enabled: perform_gap_filling, load_representatives, " 6273 "molecular_formula_search, ms2_spectral_search, gather_eics, add_ms1, add_ms2, " 6274 "or group_features" 6275 ) 6276 6277 # Validate prerequisites for gap-filling 6278 if perform_gap_filling: 6279 if ( 6280 not hasattr(self, "cluster_summary_dataframe") 6281 or self.cluster_summary_dataframe is None 6282 ): 6283 raise ValueError( 6284 "Cannot perform gap-filling: cluster_summary_dataframe not set. " 6285 "You must run add_consensus_mass_features() before calling process_consensus_features()." 6286 ) 6287 6288 # Validate prerequisites for MS2 spectral search 6289 if ms2_spectral_search: 6290 if spectral_lib is None: 6291 raise ValueError( 6292 "MS2 spectral search requires spectral_lib to be provided. " 6293 "Create it using MSPInterface.get_metabolomics_spectra_library() before calling this method." 6294 ) 6295 # Check if mass features will be loaded OR are already loaded 6296 # (The operation's can_execute will check if MS2 spectra are actually present) 6297 if not load_representatives and not perform_gap_filling: 6298 # Check if at least one sample has mass features loaded 6299 # This allows MS2 search on already-loaded features 6300 has_loaded_features = any( 6301 len(self[i].mass_features) > 0 6302 if hasattr(self[i], "mass_features") 6303 and self[i].mass_features is not None 6304 else False 6305 for i in range(len(self.samples)) 6306 ) 6307 if not has_loaded_features: 6308 raise ValueError( 6309 "MS2 spectral search requires mass features to be loaded. " 6310 "Either set load_representatives=True or perform_gap_filling=True to load them, " 6311 "or load them in a previous call to process_consensus_features() before calling " 6312 "with ms2_spectral_search=True." 6313 ) 6314 6315 # Build pipeline in two phases so group_features runs after gap-fill 6316 # and before formula / MS2 search when those are requested in the same call. 6317 pre_operations = [] 6318 post_operations = [] 6319 6320 if perform_gap_filling: 6321 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 6322 pre_operations.append( 6323 GapFillOperation("gap_fill", expand_on_miss=expand_on_miss) 6324 ) 6325 6326 if load_representatives: 6327 # MS1/MS2 association for representatives can happen in pre-pass; 6328 # formula/MS2 library search stay in post-pass after grouping. 6329 pre_operations.append( 6330 ReloadFeaturesOperation( 6331 "reload", 6332 add_ms1=add_ms1, 6333 add_ms2=add_ms2, 6334 auto_process_ms2=add_ms2, 6335 ms2_scan_filter=ms2_scan_filter, 6336 ) 6337 ) 6338 6339 if molecular_formula_search: 6340 post_operations.append(MolecularFormulaSearchOperation("mf_search")) 6341 6342 if ms2_spectral_search: 6343 post_operations.append( 6344 MS2SpectralSearchOperation( 6345 "ms2_search", ms2_scan_filter=ms2_scan_filter 6346 ) 6347 ) 6348 self._spectral_lib = spectral_lib 6349 self._spectral_search_molecular_metadata = molecular_metadata 6350 6351 if gather_eics: 6352 # After grouping when grouping is requested; otherwise with pre-pass 6353 # (or post-pass if only annotation ops are enabled). 6354 if group_features or not pre_operations: 6355 post_operations.append(LoadEICsOperation("load_eics")) 6356 else: 6357 pre_operations.append(LoadEICsOperation("load_eics")) 6358 6359 results = {} 6360 6361 def _run_ops(operations): 6362 if not operations: 6363 return {} 6364 return self.process_samples_pipeline( 6365 operations, keep_raw_data=keep_raw_data, show_progress=show_progress 6366 ) 6367 6368 # Phase 1: gap-fill / reload (and eics if not grouping) 6369 if pre_operations: 6370 results.update(_run_ops(pre_operations)) 6371 6372 # Post-processing for gap-fill must happen before grouping so heights include induced 6373 if perform_gap_filling: 6374 self._combine_mass_features(induced_features=True) 6375 self.missing_mass_features_searched = True 6376 6377 if ( 6378 self.induced_mass_features_dataframe is not None 6379 and len(self.induced_mass_features_dataframe) > 0 6380 ): 6381 eics_mz = [] 6382 for i, row in self.induced_mass_features_dataframe.iterrows(): 6383 sample_id = row["sample_id"] 6384 sample = self[sample_id] 6385 if row["mf_id"] in sample.induced_mass_features.keys(): 6386 eic_mz = sample.induced_mass_features[row["mf_id"]]._eic_mz 6387 eics_mz.append(eic_mz) 6388 else: 6389 eics_mz.append(None) 6390 self.induced_mass_features_dataframe["_eic_mz"] = eics_mz 6391 6392 # Collection-level isotope + adduct grouping 6393 if group_features: 6394 t_group = time.perf_counter() 6395 labels = self.group_consensus_features() 6396 group_wall = time.perf_counter() - t_group 6397 results["group_features"] = { 6398 "n_clusters": len(labels), 6399 "n_grouped": int(labels["feature_group_id"].notna().sum()), 6400 "n_groups": int(labels["feature_group_id"].nunique(dropna=True)), 6401 "wall_seconds": group_wall, 6402 "stage_timings": getattr(self, "_last_feature_group_timings", None), 6403 } 6404 6405 # Phase 2: annotation / optional EICs after grouping 6406 if post_operations: 6407 # If only post_operations and no pre, still need a pipeline run 6408 post_results = _run_ops(post_operations) 6409 for k, v in post_results.items(): 6410 results[k] = v 6411 6412 # Store molecular metadata if spectral search was performed 6413 if ms2_spectral_search and hasattr(self, "_spectral_search_molecular_metadata"): 6414 self.spectral_search_molecular_metadata = ( 6415 self._spectral_search_molecular_metadata 6416 ) 6417 6418 # Associate EICs while induced feature objects still exist (before any clear). 6419 if gather_eics: 6420 print("\nAssociating EICs with mass features:") 6421 from tqdm import tqdm 6422 6423 for sample_id in tqdm(range(len(self.samples)), unit="sample", ncols=80): 6424 sample = self[sample_id] 6425 if sample.eics: 6426 sample.associate_eics_with_mass_features(induced=False) 6427 sample.associate_eics_with_mass_features(induced=True) 6428 6429 # Drop induced feature objects to free memory after grouping + post-ops 6430 if perform_gap_filling: 6431 for sample_name in self.samples: 6432 self._lcms[sample_name].induced_mass_features = {} 6433 6434 return results
Process consensus mass features across the collection in a single parallelized pass.
This method provides a convenient interface to the sample processing pipeline, allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association, molecular formula search, and MS2 spectral search) to be performed efficiently in a single pass through all samples.
Parameters
- load_representatives (bool, optional): If True, loads representative mass features from HDF5. Default is True.
- perform_gap_filling (bool, optional): If True, performs gap-filling for missing cluster features. Default is True. This operation loads raw MS1 data which can be reused by subsequent operations.
- group_features (bool, optional):
If True, run
group_consensus_features()after gap-fill (and before molecular formula / MS2 search when those are also enabled). Collection-level natural-abundance isotope and adduct family labeling (singly charged only; not tracer/enriched). Default is False. - add_ms1 (bool, optional): If True and load_representatives=True, associates MS1 spectra with loaded features. Automatically uses raw data from gap-filling if available, otherwise uses parser. Spectrum mode is auto-detected. Default is False.
- add_ms2 (bool, optional): If True and load_representatives=True, associates MS2 spectra with loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False.
- ms2_scan_filter (str or None, optional): Filter string for MS2 scans (e.g., 'hcd'). Default is None.
- molecular_formula_search (bool, optional): If True, performs molecular formula search on mass features using associated MS1 spectra. Requires add_ms1=True or that MS1 spectra are already associated. Uses parameters from parameters.mass_spectrum["ms1"].molecular_search. Default is False.
- ms2_spectral_search (bool, optional): If True, performs MS2 spectral library search using FlashEntropy. Requires add_ms2=True and spectral_lib to be provided. Default is False.
- spectral_lib (FlashEntropy library, optional): Pre-prepared FlashEntropy spectral library for MS2 search. Create using MSPInterface.get_metabolomics_spectra_library(). Required if ms2_spectral_search=True. Default is None.
- molecular_metadata (pd.DataFrame, optional): Molecular metadata corresponding to spectral_lib. Returned from MSPInterface.get_metabolomics_spectra_library(). Stored as self.spectral_search_molecular_metadata for later export. Default is None.
- gather_eics (bool, optional): If True, loads extracted ion chromatograms (EICs) from HDF5 for all mass features with assigned cluster_index (including gap-filled features). Enables access to EICs via get_eics_for_cluster(cluster_id) method. Requires that EICs were previously exported with export_eics=True. Default is False.
- keep_raw_data (bool, optional): If True, keeps raw MS data loaded in memory after pipeline completes. If False, cleans up raw data to free memory. Default is False.
- show_progress (bool, optional): If True, displays progress bars during processing. If False, runs silently. Default is True.
Returns
- dict: Dictionary with pipeline results. Keys include:
- 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling)
- 'reload': dict mapping sample_id to reloaded mass features (if reloading)
- 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search)
- 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search)
Raises
- ValueError: If neither operation is enabled, or if required parameters are missing.
Notes
- Must run add_consensus_mass_features() before calling this method
- Processes samples in parallel based on parameters.lcms_collection.cores
- Raw MS1 data loaded by gap-filling is automatically reused by MS1 association
- MS2 spectral search requires add_ms2=True and msp_file_path
- FlashEntropy library is created once and reused across all samples
- More efficient than calling individual methods separately
- After gap-filling, sets missing_mass_features_searched = True
- Mass features remain loaded in memory for downstream processing
- For more advanced workflows, use process_samples_pipeline() directly
Examples
>>> # Prepare spectral library for MS2 search
>>> from corems.molecular_id.search.database_interfaces import MSPInterface
>>> my_msp = MSPInterface(file_path='path/to/library.msp')
>>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library(
... polarity='negative',
... format='flashentropy',
... normalize=True,
... fe_kwargs={
... 'normalize_intensity': True,
... 'min_ms2_difference_in_da': 0.02,
... 'max_ms2_tolerance_in_da': 0.01,
... 'max_indexed_mz': 3000,
... 'precursor_ions_removal_da': None,
... 'noise_threshold': 0,
... }
... )
>>>
>>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search
>>> results = lcms_collection.process_consensus_features(
... load_representatives=True,
... perform_gap_filling=True,
... add_ms1=True,
... add_ms2=True,
... molecular_formula_search=True,
... ms2_spectral_search=True,
... spectral_lib=spectral_lib,
... molecular_metadata=molecular_metadata
... )
See Also
process_samples_pipeline: Generic pipeline executor for custom workflows
fill_missing_cluster_features: Original gap-filling method
reload_representative_mass_features: Original reload method