corems.mass_spectra.calc.lc_calc
1import numpy as np 2import pandas as pd 3import warnings, scipy, multiprocessing 4 5if not hasattr(np, 'trapezoid'): # numpy < 2.0 6 np.trapezoid = np.trapz 7from ripser import ripser 8from scipy import sparse 9from scipy.spatial import KDTree 10from sklearn.svm import SVR 11from sklearn.cluster import AgglomerativeClustering 12import matplotlib.pyplot as plt 13from tqdm import tqdm 14 15from corems.chroma_peak.factory.chroma_peak_classes import LCMSMassFeature 16from corems.mass_spectra.calc import SignalProcessing as sp 17from corems.mass_spectra.factory.chromat_data import EIC_Data 18from corems.mass_spectrum.input.numpyArray import ms_from_array_profile 19 20warnings.filterwarnings("ignore", category=RuntimeWarning) 21 22def find_closest(A, target): 23 """Find the index of closest value in A to each value in target. 24 25 Parameters 26 ---------- 27 A : :obj:`~numpy.array` 28 The array to search (blueprint). A must be sorted. 29 target : :obj:`~numpy.array` 30 The array of values to search for. target must be sorted. 31 32 Returns 33 ------- 34 :obj:`~numpy.array` 35 The indices of the closest values in A to each value in target. 36 """ 37 idx = A.searchsorted(target) 38 idx = np.clip(idx, 1, len(A) - 1) 39 left = A[idx - 1] 40 right = A[idx] 41 idx -= target - left < right - target 42 return idx 43 44 45class LCCalculations: 46 """Methods for performing LC calculations on mass spectra data. 47 48 Notes 49 ----- 50 This class is intended to be used as a mixin for the LCMSBase class. 51 52 Methods 53 ------- 54 * get_max_eic(eic_data). 55 Returns the maximum EIC value from the given EIC data. A static method. 56 * smooth_tic(tic). 57 Smooths the TIC data using the specified smoothing method and settings. 58 * eic_centroid_detector(rt, eic, max_eic). 59 Performs EIC centroid detection on the given EIC data. 60 * find_nearest_scan(rt). 61 Finds the nearest scan to the given retention time. 62 * 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). 63 Returns an averaged mass spectrum object. 64 * find_mass_features(ms_level=1). 65 Find regions of interest for a given MS level (default is MS1). 66 * integrate_mass_features(drop_if_fail=False, ms_level=1). 67 Integrate mass features of interest and extracts EICs. 68 * find_c13_mass_features(). 69 Evaluate mass features and mark likely C13 isotopes. 70 * deconvolute_ms1_mass_features(). 71 Deconvolute mass features' ms1 mass spectra. 72 """ 73 74 @staticmethod 75 def get_max_eic(eic_data: dict): 76 """Returns the maximum EIC value from the given EIC data. 77 78 Notes 79 ----- 80 This is a static method. 81 82 Parameters 83 ---------- 84 eic_data : dict 85 A dictionary containing EIC data. 86 87 Returns 88 ------- 89 float 90 The maximum EIC value. 91 """ 92 max_eic = 0 93 for eic_data in eic_data.values(): 94 ind_max_eic = max(eic_data.get("EIC")) 95 max_eic = ind_max_eic if ind_max_eic > max_eic else max_eic 96 97 return max_eic 98 99 def smooth_tic(self, tic): 100 """Smooths the TIC or EIC data using the specified smoothing method and settings. 101 102 Parameters 103 ---------- 104 tic : numpy.ndarray 105 The TIC (or EIC) data to be smoothed. 106 107 Returns 108 ------- 109 numpy.ndarray 110 The smoothed TIC data. 111 """ 112 implemented_smooth_method = self.parameters.lc_ms.implemented_smooth_method 113 114 pol_order = self.parameters.lc_ms.savgol_pol_order 115 116 window_len = self.parameters.lc_ms.smooth_window 117 118 window = self.parameters.lc_ms.smooth_method 119 120 return sp.smooth_signal( 121 tic, window_len, window, pol_order, implemented_smooth_method 122 ) 123 124 def eic_centroid_detector(self, rt, eic, max_eic, apex_indexes=[]): 125 """Performs EIC centroid detection on the given EIC data. 126 127 Parameters 128 ---------- 129 rt : numpy.ndarray 130 The retention time data. 131 eic : numpy.ndarray 132 The EIC data. 133 max_eic : float 134 The maximum EIC value. 135 apex_indexes : list, optional 136 The apexes of the EIC peaks. Defaults to [], which means that the apexes will be calculated by the function. 137 138 Returns 139 ------- 140 numpy.ndarray 141 The indexes of left, apex, and right limits as a generator. 142 """ 143 144 max_prominence = self.parameters.lc_ms.peak_max_prominence_percent 145 146 max_height = self.parameters.lc_ms.peak_height_max_percent 147 148 signal_threshold = self.parameters.lc_ms.eic_signal_threshold 149 150 min_peak_datapoints = self.parameters.lc_ms.min_peak_datapoints 151 152 peak_derivative_threshold = self.parameters.lc_ms.peak_derivative_threshold 153 154 include_indexes = sp.peak_picking_first_derivative( 155 domain=rt, 156 signal=eic, 157 max_height=max_height, 158 max_prominence=max_prominence, 159 max_signal=max_eic, 160 min_peak_datapoints=min_peak_datapoints, 161 peak_derivative_threshold=peak_derivative_threshold, 162 signal_threshold=signal_threshold, 163 correct_baseline=False, 164 plot_res=False, 165 apex_indexes=apex_indexes, 166 ) 167 #include_indexes is a generator of tuples (left_index, apex_index, right_index) 168 include_indexes = list(include_indexes) 169 # Add check to make sure that there are at least 1/2 of min_peak_datapoints on either side of the apex 170 indicies = [x for x in include_indexes] 171 for idx in indicies: 172 if (idx[1] - idx[0] < min_peak_datapoints / 2) or ( 173 idx[2] - idx[1] < min_peak_datapoints / 2 174 ): 175 include_indexes.remove(idx) 176 return include_indexes 177 178 def find_nearest_scan(self, rt): 179 """Finds the nearest scan to the given retention time. 180 181 Parameters 182 ---------- 183 rt : float 184 The retention time (in minutes) to find the nearest scan for. 185 186 Returns 187 ------- 188 int 189 The scan number of the nearest scan. 190 """ 191 array_rt = np.array(self.retention_time) 192 193 scan_index = (np.abs(array_rt - rt)).argmin() 194 195 real_scan = self.scans_number[scan_index] 196 197 return real_scan 198 199 def add_peak_metrics(self, remove_by_metrics=True, induced_features=False): 200 """Add peak metrics to the mass features. 201 202 This function calculates the peak metrics for each mass feature and adds them to the mass feature objects. 203 204 Parameters 205 ---------- 206 remove_by_metrics : bool, optional 207 If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity, 208 dispersity index, and noise score. Default is True, which checks the setting in the processing parameters. 209 If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters. 210 induced_features : bool, optional 211 Whether the mass features to be integrated were induced. Default is False. 212 """ 213 # Check that at least some mass features have eic data 214 if induced_features: 215 mf_dict_values = self.induced_mass_features.values() 216 else: 217 mf_dict_values = self.mass_features.values() 218 219 if not any([mf._eic_data is not None for mf in mf_dict_values]): 220 raise ValueError( 221 "No mass features have EIC data. Run integrate_mass_features first." 222 ) 223 224 for mass_feature in mf_dict_values: 225 # Check if the mass feature has been integrated 226 if mass_feature._eic_data is not None and mass_feature.area is not None: 227 # Calculate peak metrics 228 mass_feature.calc_half_height_width() 229 mass_feature.calc_tailing_factor() 230 mass_feature.calc_dispersity_index() 231 mass_feature.calc_gaussian_similarity() 232 mass_feature.calc_noise_score() 233 234 # Remove mass features by peak metrics if designated in parameters 235 if self.parameters.lc_ms.remove_mass_features_by_peak_metrics and remove_by_metrics: 236 self._remove_mass_features_by_peak_metrics(induced_features=induced_features) 237 238 def get_average_mass_spectrum( 239 self, 240 scan_list, 241 apex_scan, 242 spectrum_mode="profile", 243 ms_level=1, 244 auto_process=True, 245 use_parser=False, 246 perform_checks=True, 247 polarity=None, 248 ms_params=None, 249 ): 250 """Returns an averaged mass spectrum object 251 252 Parameters 253 ---------- 254 scan_list : list 255 List of scan numbers to average. 256 apex_scan : int 257 Number of the apex scan 258 spectrum_mode : str, optional 259 The spectrum mode to use. Defaults to "profile". Not that only "profile" mode is supported for averaging. 260 ms_level : int, optional 261 The MS level to use. Defaults to 1. 262 auto_process : bool, optional 263 If True, the averaged mass spectrum will be auto-processed. Defaults to True. 264 use_parser : bool, optional 265 If True, the mass spectra will be obtained from the parser. Defaults to False. 266 perform_checks : bool, optional 267 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 268 polarity : int, optional 269 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) 270 ms_params : MSParameters, optional 271 The mass spectrum parameters to use. If not set (None), the globally set parameters will be used. Defaults to None. 272 273 Returns 274 ------- 275 MassSpectrumProfile 276 The averaged mass spectrum object. 277 278 Raises 279 ------ 280 ValueError 281 If the spectrum mode is not "profile". 282 If the MS level is not found in the unprocessed mass spectra dictionary. 283 If not all scan numbers are found in the unprocessed mass spectra dictionary. 284 """ 285 if perform_checks: 286 if spectrum_mode != "profile": 287 raise ValueError("Averaging only supported for profile mode") 288 289 if polarity is None: 290 # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation) 291 if self.polarity == "negative": 292 polarity = -1 293 elif self.polarity == "positive": 294 polarity = 1 295 else: 296 raise ValueError( 297 "Polarity not set for dataset, must be a set containing either 'positive' or 'negative'" 298 ) 299 300 # if not using_parser, check that scan numbers are in _ms_unprocessed 301 if not use_parser: 302 if perform_checks: 303 # Set index to scan for faster lookup 304 ms_df = ( 305 self._ms_unprocessed[ms_level] 306 .copy() 307 .set_index("scan", drop=False) 308 .sort_index() 309 ) 310 my_ms_df = ms_df.loc[scan_list] 311 # Check that all scan numbers are in the ms_df 312 if not all(np.isin(scan_list, ms_df.index)): 313 raise ValueError( 314 "Not all scan numbers found in the unprocessed mass spectra dictionary" 315 ) 316 else: 317 my_ms_df = ( 318 pd.DataFrame({"scan": scan_list}) 319 .set_index("scan") 320 .join(self._ms_unprocessed[ms_level], how="left") 321 ) 322 323 if use_parser: 324 ms_list = [ 325 self.spectra_parser.get_mass_spectrum_from_scan( 326 x, spectrum_mode=spectrum_mode, auto_process=False 327 ) 328 for x in scan_list 329 ] 330 ms_mz = [x._mz_exp for x in ms_list] 331 ms_int = [x._abundance for x in ms_list] 332 my_ms_df = [] 333 for i in np.arange(len(ms_mz)): 334 my_ms_df.append( 335 pd.DataFrame( 336 {"mz": ms_mz[i], "intensity": ms_int[i], "scan": scan_list[i]} 337 ) 338 ) 339 my_ms_df = pd.concat(my_ms_df) 340 341 if not self.check_if_grid(my_ms_df): 342 my_ms_df = self.grid_data(my_ms_df) 343 344 my_ms_ave = my_ms_df.groupby("mz")["intensity"].sum().reset_index() 345 346 ms = ms_from_array_profile( 347 my_ms_ave.mz, 348 my_ms_ave.intensity, 349 self.file_location, 350 polarity=polarity, 351 auto_process=False, 352 ) 353 354 # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset 355 if ms is not None: 356 if ms_params is not None: 357 ms.parameters = ms_params 358 ms.scan_number = apex_scan 359 if auto_process: 360 ms.process_mass_spec() 361 return ms 362 363 def find_mass_features(self, ms_level=1, grid=True, assign_ms2_scans=False, ms2_scan_filter=None, 364 targeted_search=False, target_search_dict=None, accumulate_features=False): 365 """Find mass features within an LCMSBase object 366 367 Note that this is a wrapper function that calls the find_mass_features_ph function, but can be extended to support other peak picking methods in the future. 368 369 Parameters 370 ---------- 371 ms_level : int, optional 372 The MS level to use for peak picking Default is 1. 373 grid : bool, optional 374 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded), 375 used for persistent homology peak picking for profile data only. Default is True. 376 assign_ms2_scans : bool, optional 377 If True, assign MS2 scan numbers to mass features after peak picking. 378 This populates the ms2_scan_numbers attribute on each mass feature, which enables 379 choosing representative features based on MS2 availability. Default is False. 380 ms2_scan_filter : str or None, optional 381 Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd'). 382 If None, all MS2 scans are considered. Default is None. 383 targeted_search : bool, optional 384 If True, perform targeted mass feature search using the target_search_dict. 385 This mode filters data to only m/z and RT windows of interest and bypasses 386 intensity and persistence thresholds. Default is False. 387 target_search_dict : dict or None, optional 388 Dictionary containing target search parameters. Required if targeted_search is True. 389 Must contain: 390 - 'target_mz_list': list of target m/z values 391 - 'target_rt_list': list of target retention times (in minutes) 392 - 'mz_tolerance_ppm': m/z tolerance in ppm 393 - 'rt_tolerance': retention time tolerance (in minutes) 394 Optionally can contain: 395 - 'type': type label for mass features (e.g., "internal standard") 396 If not provided, defaults to "targeted" 397 Default is None. 398 accumulate_features : bool, optional 399 If True, new mass features will be added to existing features rather than replacing them. 400 This allows multiple sequential calls to find_mass_features to build up a combined set. 401 Default is False (replace existing features for backwards compatibility). 402 403 Raises 404 ------ 405 ValueError 406 If no MS level data is found on the object. 407 If persistent homology peak picking is attempted on non-profile mode data. 408 If data is not gridded and grid is False. 409 If peak picking method is not implemented. 410 If targeted_search is True but target_search_dict is None or invalid. 411 412 Returns 413 ------- 414 None, but assigns the mass_features and eics attributes to the object. 415 416 """ 417 # Validate targeted search parameters 418 if targeted_search: 419 if target_search_dict is None: 420 raise ValueError("target_search_dict must be provided when targeted_search is True") 421 required_keys = ['target_mz_list', 'target_rt_list', 'mz_tolerance_ppm', 'rt_tolerance'] 422 for key in required_keys: 423 if key not in target_search_dict: 424 raise ValueError(f"target_search_dict must contain '{key}'") 425 if len(target_search_dict['target_mz_list']) != len(target_search_dict['target_rt_list']): 426 raise ValueError("target_mz_list and target_rt_list must have the same length") 427 428 pp_method = self.parameters.lc_ms.peak_picking_method 429 430 if pp_method == "persistent homology": 431 msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level] 432 if all(msx_scan_df["ms_format"] == "profile"): 433 # Determine mass feature type 434 if targeted_search: 435 mf_type = target_search_dict.get('type', 'targeted') 436 else: 437 mf_type = 'untargeted' 438 self.find_mass_features_ph(ms_level=ms_level, grid=grid, 439 targeted_search=targeted_search, 440 target_search_dict=target_search_dict, 441 mf_type=mf_type, 442 accumulate_features=accumulate_features) 443 else: 444 raise ValueError( 445 "MS{} scans are not profile mode, which is required for persistent homology peak picking.".format( 446 ms_level 447 ) 448 ) 449 elif pp_method == "centroided_persistent_homology": 450 msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level] 451 if all(msx_scan_df["ms_format"] == "centroid"): 452 # Determine mass feature type 453 if targeted_search: 454 mf_type = target_search_dict.get('type', 'targeted') 455 else: 456 mf_type = 'untargeted' 457 self.find_mass_features_ph_centroid(ms_level=ms_level, 458 targeted_search=targeted_search, 459 target_search_dict=target_search_dict, 460 mf_type=mf_type, 461 accumulate_features=accumulate_features) 462 else: 463 raise ValueError( 464 "MS{} scans are not centroid mode, which is required for persistent homology centroided peak picking.".format( 465 ms_level 466 ) 467 ) 468 else: 469 raise ValueError("Peak picking method not implemented") 470 471 # Cluster mass features to remove redundant features 472 self.cluster_mass_features(drop_children=True) 473 474 # Optionally assign MS2 scan numbers to mass features during peak picking 475 # This helps with choosing representative features that have MS2 data 476 if assign_ms2_scans: 477 try: 478 self._find_ms2_scans_for_mass_features( 479 mf_ids=None, # Process all mass features 480 scan_filter=ms2_scan_filter 481 ) 482 except ValueError: 483 # No MS2 scans found - this is okay, just skip 484 pass 485 486 # Remove noisey mass features if designated in parameters 487 if self.parameters.lc_ms.remove_redundant_mass_features and not targeted_search: 488 self._remove_redundant_mass_features() 489 490 def integrate_mass_features( 491 self, drop_if_fail=True, drop_duplicates=True, ms_level=1, induced_features=False 492 ): 493 """Integrate mass features and extract EICs. 494 495 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. 496 497 Parameters 498 ---------- 499 drop_if_fail : bool, optional 500 Whether to drop mass features if the EIC limit calculations fail. 501 Default is True. 502 drop_duplicates : bool, optional 503 Whether to mass features that appear to be duplicates 504 (i.e., mz is similar to another mass feature and limits of the EIC are similar or encapsulating). 505 Default is True. 506 ms_level : int, optional 507 The MS level to use. Default is 1. 508 induced_features : bool, optional 509 Whether the mass features to be intergrated were induced. Default is False. 510 511 Raises 512 ------ 513 ValueError 514 If no mass features are found. 515 If no MS level data is found for the given MS level (either in data or in the scan data) 516 517 Returns 518 ------- 519 None, but populates the eics attribute on the LCMSBase object and adds data (start_scan, final_scan, area) to the mass_features attribute. 520 521 Notes 522 ----- 523 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). 524 """ 525 526 # Check if there is data 527 if ms_level in self._ms_unprocessed.keys(): 528 raw_data = self._ms_unprocessed[ms_level].copy() 529 else: 530 raise ValueError("No MS level " + str(ms_level) + " data found") 531 532 # Check if mass_spectrum exists on each mass feature 533 if induced_features: 534 mf_dict = self.induced_mass_features 535 if len(mf_dict) == 0: 536 raise ValueError( 537 "No induced mass features found, did you run fill_missing_cluster_features() first?" 538 ) 539 540 ## remove not found induced mass features by mz <= 0 (-99 indicator) 541 # also remove any where mz is nan 542 mf_dict = {k:v for k, v in mf_dict.items() if v.mz > 0 and not np.isnan(v.mz)} 543 544 else: 545 mf_dict = self.mass_features 546 if len(mf_dict) == 0: 547 raise ValueError( 548 "No mass features found, did you run find_mass_features() first?" 549 ) 550 551 # Subset scan data to only include correct ms_level 552 scan_df_sub = self.scan_df[ 553 self.scan_df["ms_level"] == int(ms_level) 554 ].reset_index(drop=True) 555 if scan_df_sub.empty: 556 raise ValueError("No MS level " + ms_level + " data found in scan data") 557 scan_df_sub = scan_df_sub[["scan", "scan_time"]].copy() 558 559 mzs_to_extract = np.unique([mf.mz for mf in mf_dict.values()]) 560 mzs_to_extract.sort() 561 562 # Pre-sort raw_data by mz for faster filtering 563 raw_data_sorted = raw_data.sort_values(["mz", "scan"]).reset_index(drop=True) 564 raw_data_mz = raw_data_sorted["mz"].values 565 566 # Get EICs for each unique mz in mass features list 567 for mz in mzs_to_extract: 568 mz_max = mz + self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 569 mz_min = mz - self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 570 571 # Use binary search for faster mz range filtering 572 left_idx = np.searchsorted(raw_data_mz, mz_min, side="left") 573 right_idx = np.searchsorted(raw_data_mz, mz_max, side="right") 574 raw_data_sub = raw_data_sorted.iloc[left_idx:right_idx].copy() 575 576 raw_data_sub = ( 577 raw_data_sub.groupby(["scan"])["intensity"].sum().reset_index() 578 ) 579 raw_data_sub = scan_df_sub.merge(raw_data_sub, on="scan", how="left") 580 raw_data_sub["intensity"] = raw_data_sub["intensity"].fillna(0) 581 myEIC = EIC_Data( 582 scans=raw_data_sub["scan"].values, 583 time=raw_data_sub["scan_time"].values, 584 eic=raw_data_sub["intensity"].values, 585 ) 586 # Smooth EIC 587 smoothed_eic = self.smooth_tic(myEIC.eic) 588 smoothed_eic[smoothed_eic < 0] = 0 589 myEIC.eic_smoothed = smoothed_eic 590 self.eics[mz] = myEIC 591 592 # Get limits of mass features using EIC centroid detector and integrate 593 for idx, mass_feature in list(mf_dict.items()): 594 mz = mass_feature.mz 595 apex_scan = mass_feature.apex_scan 596 597 # Pull EIC data and find apex scan index 598 myEIC = self.eics[mz] 599 mf_dict[idx]._eic_data = myEIC 600 mf_dict[idx]._eic_mz = mz 601 apex_index = np.searchsorted(myEIC.scans, apex_scan) 602 603 # Find left and right limits of peak using EIC centroid detector, add to EICData 604 centroid_eics = self.eic_centroid_detector( 605 myEIC.time, 606 myEIC.eic_smoothed, 607 mass_feature.intensity * 1.1, 608 apex_indexes=[int(apex_index)], 609 ) 610 l_a_r_scan_idx = [i for i in centroid_eics] 611 if len(l_a_r_scan_idx) > 0: 612 # Calculate number of consecutive scans with intensity > 0 and check if it is above the minimum consecutive scans 613 # Find the number of consecutive non-zero values in the EIC segment 614 mask = myEIC.eic[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1] > 0 615 # Find the longest run of consecutive True values 616 if np.any(mask): 617 # Find indices where mask changes value 618 diff = np.diff(np.concatenate(([0], mask.astype(int), [0]))) 619 starts = np.where(diff == 1)[0] 620 ends = np.where(diff == -1)[0] 621 consecutive_scans = (ends - starts).max() 622 else: 623 consecutive_scans = 0 624 if consecutive_scans < self.parameters.lc_ms.consecutive_scan_min: 625 mf_dict.pop(idx) 626 continue 627 # Add start and final scan to mass_features and EICData 628 left_scan, right_scan = ( 629 myEIC.scans[l_a_r_scan_idx[0][0]], 630 myEIC.scans[l_a_r_scan_idx[0][2]], 631 ) 632 mf_scan_apex = [(left_scan, int(apex_scan), right_scan)] 633 myEIC.apexes = myEIC.apexes + mf_scan_apex 634 mf_dict[idx].start_scan = left_scan 635 mf_dict[idx].final_scan = right_scan 636 637 # Find area under peak using limits from EIC centroid detector, add to mass_features and EICData 638 area = np.trapezoid( 639 myEIC.eic_smoothed[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 640 myEIC.time[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 641 ) 642 myEIC.areas = myEIC.areas + [area] 643 self.eics[mz] = myEIC 644 mf_dict[idx]._area = area 645 else: 646 if drop_if_fail is True: 647 mf_dict.pop(idx) 648 649 if drop_duplicates: 650 # Prepare mass feature dataframe 651 if induced_features: 652 mf_df = self.mass_features_to_df(induced_features = True).copy() 653 mf_df = mf_df[mf_df.start_scan.notna()] 654 else: 655 mf_df = self.mass_features_to_df(induced_features = False).copy() 656 657 # 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 658 # Keep the first mass feature (highest persistence) 659 for idx, mass_feature in mf_df.iterrows(): 660 mz = mass_feature.mz 661 apex_scan = mass_feature.apex_scan 662 663 mf_df["mz_diff_ppm"] = np.abs(mf_df["mz"] - mz) / mz * 10**6 664 mf_df_sub = mf_df[ 665 mf_df["mz_diff_ppm"] 666 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 667 * 10**6 668 ].copy() 669 670 # 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 671 for idx2, mass_feature2 in mf_df_sub.iterrows(): 672 if idx2 != idx: 673 if ( 674 mass_feature2.start_scan >= mass_feature.start_scan 675 and mass_feature2.final_scan <= mass_feature.final_scan 676 ): 677 if idx2 in self.mass_features.keys(): 678 self.mass_features.pop(idx2) 679 680 # Filter MS2 scans to only include those within integration bounds 681 # This ensures MS2 scans outside start_scan to final_scan are removed 682 if induced_features: 683 self._filter_ms2_scans_by_integration_bounds(mf_dict=self.induced_mass_features) 684 else: 685 self._filter_ms2_scans_by_integration_bounds(mf_dict=self.mass_features) 686 687 def find_c13_mass_features(self): 688 """Mark likely C13 isotopes and connect to monoisoitopic mass features. 689 690 Returns 691 ------- 692 None, but populates the monoisotopic_mf_id and isotopologue_type attributes to the indivual LCMSMassFeatures within the mass_features attribute of the LCMSBase object. 693 694 Raises 695 ------ 696 ValueError 697 If no mass features are found. 698 """ 699 verbose = self.parameters.lc_ms.verbose_processing 700 if verbose: 701 print("evaluating mass features for C13 isotopes") 702 if self.mass_features is None: 703 raise ValueError("No mass features found, run find_mass_features() first") 704 705 # Data prep fo sparse distance matrix 706 dims = ["mz", "scan_time"] 707 mf_df = self.mass_features_to_df().copy() 708 # Drop mass features that have no area (these are likely to be noise) 709 mf_df = mf_df[mf_df["area"].notnull()] 710 mf_df["mf_id"] = mf_df.index.values 711 dims = ["mz", "scan_time"] 712 713 # Sort my ascending mz so we always get the monoisotopic mass first, regardless of the order/intensity of the mass features 714 mf_df = mf_df.sort_values(by=["mz"]).reset_index(drop=True).copy() 715 716 mz_diff = 1.003355 # C13-C12 mass difference 717 tol = [ 718 mf_df["mz"].median() 719 * self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 720 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance * 0.5, 721 ] # mz, in relative; scan_time in minutes 722 723 # Compute inter-feature distances 724 distances = None 725 for i in range(len(dims)): 726 # Construct k-d tree 727 values = mf_df[dims[i]].values 728 tree = KDTree(values.reshape(-1, 1)) 729 730 max_tol = tol[i] 731 if dims[i] == "mz": 732 # Maximum absolute tolerance 733 max_tol = mz_diff + tol[i] 734 735 # Compute sparse distance matrix 736 # the larger the max_tol, the slower this operation is 737 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 738 739 # Only consider forward case, exclude diagonal 740 sdm = sparse.triu(sdm, k=1) 741 742 if dims[i] == "mz": 743 min_tol = mz_diff - tol[i] 744 # Get only the ones that are above the min tol 745 idx = sdm.data > min_tol 746 747 # Reconstruct sparse distance matrix 748 sdm = sparse.coo_matrix( 749 (sdm.data[idx], (sdm.row[idx], sdm.col[idx])), 750 shape=(len(values), len(values)), 751 ) 752 753 # Cast as binary matrix 754 sdm.data = np.ones_like(sdm.data) 755 756 # Stack distances 757 if distances is None: 758 distances = sdm 759 else: 760 distances = distances.multiply(sdm) 761 762 # Extract indices of within-tolerance points 763 distances = distances.tocoo() 764 pairs = np.stack((distances.row, distances.col), axis=1) # C12 to C13 pairs 765 766 # Turn pairs (which are index of mf_df) into mf_id and then into two dataframes to join to mf_df 767 pairs_mf = pairs.copy() 768 pairs_mf[:, 0] = mf_df.iloc[pairs[:, 0]].mf_id.values 769 pairs_mf[:, 1] = mf_df.iloc[pairs[:, 1]].mf_id.values 770 771 # Connect monoisotopic masses with isotopologes within mass_features 772 monos = np.setdiff1d(np.unique(pairs_mf[:, 0]), np.unique(pairs_mf[:, 1])) 773 for mono in monos: 774 self.mass_features[mono].monoisotopic_mf_id = mono 775 pairs_iso_df = pd.DataFrame(pairs_mf, columns=["parent", "child"]) 776 while not pairs_iso_df.empty: 777 pairs_iso_df = pairs_iso_df.set_index("parent", drop=False) 778 m1_isos = pairs_iso_df.loc[monos, "child"].unique() 779 for iso in m1_isos: 780 # Set monoisotopic_mf_id and isotopologue_type for isotopologues 781 parent = pairs_mf[pairs_mf[:, 1] == iso, 0] 782 if len(parent) > 1: 783 # Choose the parent that is closest in time to the isotopologue 784 parent_time = [self.mass_features[p].retention_time for p in parent] 785 time_diff = [ 786 np.abs(self.mass_features[iso].retention_time - x) 787 for x in parent_time 788 ] 789 parent = parent[np.argmin(time_diff)] 790 else: 791 parent = parent[0] 792 self.mass_features[iso].monoisotopic_mf_id = self.mass_features[ 793 parent 794 ].monoisotopic_mf_id 795 if self.mass_features[iso].monoisotopic_mf_id is not None: 796 mass_diff = ( 797 self.mass_features[iso].mz 798 - self.mass_features[ 799 self.mass_features[iso].monoisotopic_mf_id 800 ].mz 801 ) 802 self.mass_features[iso].isotopologue_type = "13C" + str( 803 int(round(mass_diff, 0)) 804 ) 805 806 # Drop the mono and iso from the pairs_iso_df 807 pairs_iso_df = pairs_iso_df.drop( 808 index=monos, errors="ignore" 809 ) # Drop pairs where the parent is a child that is a child of a root 810 pairs_iso_df = pairs_iso_df.set_index("child", drop=False) 811 pairs_iso_df = pairs_iso_df.drop(index=m1_isos, errors="ignore") 812 813 if not pairs_iso_df.empty: 814 # Get new monos, recognizing that these are just 13C isotopologues that are connected to other 13C isotopologues to repeat the process 815 monos = np.setdiff1d( 816 np.unique(pairs_iso_df.parent), np.unique(pairs_iso_df.child) 817 ) 818 if verbose: 819 # Report fraction of compounds annotated with isotopes 820 mf_df["c13_flag"] = np.where( 821 np.logical_or( 822 np.isin(mf_df["mf_id"], pairs_mf[:, 0]), 823 np.isin(mf_df["mf_id"], pairs_mf[:, 1]), 824 ), 825 1, 826 0, 827 ) 828 print( 829 str(round(len(mf_df[mf_df["c13_flag"] == 1]) / len(mf_df), ndigits=3)) 830 + " of mass features have or are C13 isotopes" 831 ) 832 833 def deconvolute_ms1_mass_features(self): 834 """Deconvolute MS1 mass features 835 836 Deconvolute mass features ms1 spectrum based on the correlation of all masses within a spectrum over the EIC of the mass features 837 838 Parameters 839 ---------- 840 None 841 842 Returns 843 ------- 844 None, but assigns the _ms_deconvoluted_idx, mass_spectrum_deconvoluted_parent, 845 and associated_mass_features_deconvoluted attributes to the mass features in the 846 mass_features attribute of the LCMSBase object. 847 848 Raises 849 ------ 850 ValueError 851 If no mass features are found, must run find_mass_features() first. 852 If no EICs are found, did you run integrate_mass_features() first? 853 854 """ 855 # Checks for set mass_features and eics 856 if self.mass_features is None: 857 raise ValueError( 858 "No mass features found, did you run find_mass_features() first?" 859 ) 860 861 if self.eics == {}: 862 raise ValueError( 863 "No EICs found, did you run integrate_mass_features() first?" 864 ) 865 866 if 1 not in self._ms_unprocessed.keys(): 867 raise ValueError("No unprocessed MS1 spectra found.") 868 869 # Prep ms1 data 870 ms1_data = self._ms_unprocessed[1].copy() 871 ms1_data = ms1_data.set_index("scan") 872 873 # Prep mass feature summary 874 mass_feature_df = self.mass_features_to_df() 875 876 # Loop through each mass feature 877 for mf_id, mass_feature in self.mass_features.items(): 878 # Check that the mass_feature.mz attribute == the mz of the mass feature in the mass_feature_df 879 if mass_feature.mz != mass_feature.ms1_peak.mz_exp: 880 continue 881 882 # Get the left and right limits of the EIC of the mass feature 883 l_scan, _, r_scan = mass_feature._eic_data.apexes[0] 884 885 # Pull from the _ms1_unprocessed data the scan range of interest and sort by mz 886 ms1_data_sub = ms1_data.loc[l_scan:r_scan].copy() 887 ms1_data_sub = ms1_data_sub.sort_values(by=["mz"]).reset_index(drop=False) 888 889 # Get the centroided masses of the mass feature 890 mf_mspeak_mzs = mass_feature.mass_spectrum.mz_exp 891 892 # Find the closest mz in the ms1 data to the centroided masses of the mass feature 893 ms1_data_sub["mass_feature_mz"] = mf_mspeak_mzs[ 894 find_closest(mf_mspeak_mzs, ms1_data_sub.mz.values) 895 ] 896 897 # Drop rows with mz_diff > 0.01 between the mass feature mz and the ms1 data mz 898 ms1_data_sub["mz_diff_rel"] = ( 899 np.abs(ms1_data_sub["mass_feature_mz"] - ms1_data_sub["mz"]) 900 / ms1_data_sub["mz"] 901 ) 902 ms1_data_sub = ms1_data_sub[ 903 ms1_data_sub["mz_diff_rel"] 904 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 905 ].reset_index(drop=True) 906 907 # Group by mass_feature_mz and scan and sum intensity 908 ms1_data_sub_group = ( 909 ms1_data_sub.groupby(["mass_feature_mz", "scan"])["intensity"] 910 .sum() 911 .reset_index() 912 ) 913 914 # Calculate the correlation of the intensities of the mass feature and the ms1 data (set to 0 if no intensity) 915 corr = ( 916 ms1_data_sub_group.pivot( 917 index="scan", columns="mass_feature_mz", values="intensity" 918 ) 919 .fillna(0) 920 .corr() 921 ) 922 923 # Subset the correlation matrix to only include the masses of the mass feature and those with a correlation > 0.8 924 decon_corr_min = self.parameters.lc_ms.ms1_deconvolution_corr_min 925 926 # Try catch for KeyError in case the mass feature mz is not in the correlation matrix 927 try: 928 corr_subset = corr.loc[mass_feature.mz] 929 except KeyError: 930 # If the mass feature mz is not in the correlation matrix, skip to the next mass feature 931 continue 932 933 corr_subset = corr_subset[corr_subset > decon_corr_min] 934 935 # Get the masses from the mass spectrum that are the result of the deconvolution 936 mzs_decon = corr_subset.index.values 937 938 # Get the indices of the mzs_decon in mass_feature.mass_spectrum.mz_exp and assign to the mass feature 939 mzs_decon_idx = [ 940 id 941 for id, mz in enumerate(mass_feature.mass_spectrum.mz_exp) 942 if mz in mzs_decon 943 ] 944 mass_feature._ms_deconvoluted_idx = mzs_decon_idx 945 946 # Check if the mass feature's ms1 peak is the largest in the deconvoluted mass spectrum 947 if ( 948 mass_feature.ms1_peak.abundance 949 == mass_feature.mass_spectrum.abundance[mzs_decon_idx].max() 950 ): 951 mass_feature.mass_spectrum_deconvoluted_parent = True 952 else: 953 mass_feature.mass_spectrum_deconvoluted_parent = False 954 955 # Check for other mass features that are in the deconvoluted mass spectrum and add the deconvoluted mass spectrum to the mass feature 956 # Subset mass_feature_df to only include mass features that are within the clustering tolerance 957 mass_feature_df_sub = mass_feature_df[ 958 abs(mass_feature.retention_time - mass_feature_df["scan_time"]) 959 < self.parameters.lc_ms.mass_feature_cluster_rt_tolerance 960 ].copy() 961 # Calculate the mz difference in ppm between the mass feature and the peaks in the deconvoluted mass spectrum 962 mass_feature_df_sub["mz_diff_ppm"] = [ 963 np.abs(mzs_decon - mz).min() / mz * 10**6 964 for mz in mass_feature_df_sub["mz"] 965 ] 966 # Subset mass_feature_df to only include mass features that are within 1 ppm of the deconvoluted masses 967 mfs_associated_decon = mass_feature_df_sub[ 968 mass_feature_df_sub["mz_diff_ppm"] 969 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 10**6 970 ].index.values 971 972 mass_feature.associated_mass_features_deconvoluted = mfs_associated_decon 973 974 def _remove_redundant_mass_features( 975 self, 976 ) -> None: 977 """ 978 Identify and remove redundant mass features that are likely contaminants based on their m/z values and scan frequency. 979 Especially useful for HILIC data where signals do not return to baseline between peaks or for data with significant background noise. 980 981 Contaminants are characterized by: 982 1. Similar m/z values (within ppm_tolerance) 983 2. High frequency across scan numbers (ubiquitous presence) 984 985 Notes 986 ----- 987 Depends on self.mass_features being populated, uses the parameters in self.parameters.lc_ms for tolerances (mass_feature_cluster_mz_tolerance_rel) 988 """ 989 ppm_tolerance = self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel*1e6 990 min_scan_frequency = self.parameters.lc_ms.redundant_scan_frequency_min 991 n_retain = self.parameters.lc_ms.redundant_feature_retain_n 992 993 df = self.mass_features_to_df() 994 995 if df.empty: 996 return pd.DataFrame() 997 # df index should be mf_id 998 if 'mf_id' not in df.columns: 999 if 'mf_id' in df.index.names: 1000 df = df.reset_index() 1001 else: 1002 raise ValueError("DataFrame must contain 'mf_id' column or index.") 1003 1004 # Sort by m/z for efficient grouping 1005 df_sorted = df.sort_values('mz').reset_index(drop=True) 1006 1007 # Calculate total number of unique scans for frequency calculation 1008 # Calculate total possible scans (check the cluster rt tolerance and the min rt and max rt of the data) 1009 total_time = self.scan_df['scan_time'].max() - self.scan_df['scan_time'].min() 1010 cluster_rt_tolerance = self.parameters.lc_ms.mass_feature_cluster_rt_tolerance 1011 # If the feature was detected in every possible scan (and then rolled up), it would be in this many scans 1012 total_scans = int(total_time / cluster_rt_tolerance) + 1 1013 1014 # Group similar m/z values using ppm tolerance 1015 mz_groups = [] 1016 current_group = [] 1017 1018 for i, row in df_sorted.iterrows(): 1019 current_mz = row['mz'] 1020 1021 if not current_group: 1022 # Start first group 1023 current_group = [i] 1024 else: 1025 # Check if current m/z is within tolerance of group representative 1026 group_representative_mz = df_sorted.iloc[current_group[0]]['mz'] 1027 ppm_diff = abs(current_mz - group_representative_mz) / group_representative_mz * 1e6 1028 1029 if ppm_diff <= ppm_tolerance: 1030 # Add to current group 1031 current_group.append(i) 1032 else: 1033 # Start new group, but first process current group 1034 if len(current_group) > 0: 1035 mz_groups.append(current_group) 1036 current_group = [i] 1037 1038 # Don't forget the last group 1039 if current_group: 1040 mz_groups.append(current_group) 1041 1042 # Analyze each m/z group for contaminant characteristics 1043 1044 for group_indices in mz_groups: 1045 group_data = df_sorted.iloc[group_indices] 1046 1047 # Calculate group statistics 1048 unique_scans = group_data['apex_scan'].nunique() 1049 scan_frequency = unique_scans / total_scans 1050 1051 # Check if this group meets contaminant criteria 1052 if scan_frequency >= min_scan_frequency: 1053 group_data = group_data.sort_values('intensity', ascending=False) 1054 non_representative_mf_id = group_data.iloc[n_retain:]['mf_id'].tolist() # These will be removed 1055 1056 self.mass_features = { 1057 k: v for k, v in self.mass_features.items() if k not in non_representative_mf_id 1058 } 1059 1060 def _remove_mass_features_by_peak_metrics(self, induced_features=False) -> None: 1061 """Remove mass features based on peak metrics defined in mass_feature_attribute_filter_dict. 1062 1063 This method filters mass features based on various peak shape metrics and quality indicators 1064 such as noise scores, Gaussian similarity, tailing factors, dispersity index, etc. 1065 1066 The filtering criteria are defined in the mass_feature_attribute_filter_dict parameter, 1067 which should contain attribute names as keys and filter specifications as values. 1068 1069 Filter specification format: 1070 {attribute_name: {'value': threshold, 'operator': comparison}} 1071 1072 Available operators: 1073 - '>' or 'greater': Keep features where attribute > threshold 1074 - '<' or 'less': Keep features where attribute < threshold 1075 - '>=' or 'greater_equal': Keep features where attribute >= threshold 1076 - '<=' or 'less_equal': Keep features where attribute <= threshold 1077 1078 Examples: 1079 - {'noise_score_max': {'value': 0.5, 'operator': '>='}} - Keep features with noise_score_max >= 0.5 1080 - {'dispersity_index': {'value': 0.1, 'operator': '<'}} - Keep features with dispersity_index < 0.1 1081 - {'gaussian_similarity': {'value': 0.7, 'operator': '>='}} - Keep features with gaussian_similarity >= 0.7 1082 1083 Parameters 1084 ---------- 1085 induced_features : bool, optional 1086 If True, filter induced_mass_features instead of regular mass_features. Default is False. 1087 1088 Returns 1089 ------- 1090 None 1091 Modifies self.mass_features or self.induced_mass_features in place by removing filtered features. 1092 1093 Raises 1094 ------ 1095 ValueError 1096 If no mass features are found, if an invalid attribute is specified, or if filter specification is malformed. 1097 """ 1098 # Select the appropriate mass features dictionary 1099 if induced_features: 1100 mf_dict = self.induced_mass_features 1101 mf_type = "induced mass features" 1102 else: 1103 mf_dict = self.mass_features 1104 mf_type = "mass features" 1105 1106 if mf_dict is None or len(mf_dict) == 0: 1107 raise ValueError(f"No {mf_type} found, run {'gap filling' if induced_features else 'find_mass_features()'} first") 1108 1109 filter_dict = self.parameters.lc_ms.mass_feature_attribute_filter_dict 1110 1111 if not filter_dict: 1112 # No filtering criteria specified, return early 1113 return 1114 1115 verbose = self.parameters.lc_ms.verbose_processing 1116 initial_count = len(mf_dict) 1117 1118 if verbose: 1119 print(f"Filtering {mf_type} using peak metrics. Initial count: {initial_count}") 1120 1121 # List to collect IDs of mass features to remove 1122 features_to_remove = [] 1123 1124 for mf_id, mass_feature in mf_dict.items(): 1125 should_remove = False 1126 1127 for attribute_name, filter_spec in filter_dict.items(): 1128 # Validate filter specification structure 1129 if not isinstance(filter_spec, dict): 1130 raise ValueError(f"Filter specification for '{attribute_name}' must be a dictionary with 'value' and 'operator' keys") 1131 1132 if 'value' not in filter_spec or 'operator' not in filter_spec: 1133 raise ValueError(f"Filter specification for '{attribute_name}' must contain both 'value' and 'operator' keys") 1134 1135 threshold_value = filter_spec['value'] 1136 operator = filter_spec['operator'].lower().strip() 1137 1138 # Validate operator 1139 valid_operators = {'>', '<', '>=', '<=', 'greater', 'less', 'greater_equal', 'less_equal'} 1140 if operator not in valid_operators: 1141 raise ValueError(f"Invalid operator '{operator}' for attribute '{attribute_name}'. Valid operators: {valid_operators}") 1142 1143 # Normalize operator names 1144 operator_map = { 1145 'greater': '>', 1146 'less': '<', 1147 'greater_equal': '>=', 1148 'less_equal': '<=' 1149 } 1150 operator = operator_map.get(operator, operator) 1151 1152 # Get the attribute value from the mass feature 1153 try: 1154 if hasattr(mass_feature, attribute_name): 1155 attribute_value = getattr(mass_feature, attribute_name) 1156 else: 1157 raise ValueError(f"Mass feature does not have attribute '{attribute_name}'") 1158 1159 # Handle None values or attributes that haven't been calculated 1160 if attribute_value is None: 1161 if verbose: 1162 print(f"Warning: Mass feature {mf_id} has None value for '{attribute_name}'. Removing feature.") 1163 should_remove = True 1164 break 1165 1166 # Handle numpy arrays (like half_height_width which returns mean) 1167 if hasattr(attribute_value, '__len__') and not isinstance(attribute_value, str): 1168 # For arrays, we use the mean or appropriate summary statistic 1169 if attribute_name == 'half_height_width': 1170 # half_height_width property already returns the mean 1171 pass 1172 else: 1173 attribute_value = float(np.mean(attribute_value)) 1174 1175 # Handle NaN values 1176 if np.isnan(float(attribute_value)): 1177 if verbose: 1178 print(f"Warning: Mass feature {mf_id} has NaN value for '{attribute_name}'. Removing feature.") 1179 should_remove = True 1180 break 1181 1182 # Apply the threshold comparison based on operator 1183 attribute_value = float(attribute_value) 1184 threshold_value = float(threshold_value) 1185 1186 if operator == '>' and not (attribute_value > threshold_value): 1187 should_remove = True 1188 break 1189 elif operator == '<' and not (attribute_value < threshold_value): 1190 should_remove = True 1191 break 1192 elif operator == '>=' and not (attribute_value >= threshold_value): 1193 should_remove = True 1194 break 1195 elif operator == '<=' and not (attribute_value <= threshold_value): 1196 should_remove = True 1197 break 1198 1199 except (AttributeError, ValueError, TypeError) as e: 1200 if verbose: 1201 print(f"Error evaluating filter '{attribute_name}' for mass feature {mf_id}: {e}") 1202 should_remove = True 1203 break 1204 1205 if should_remove: 1206 features_to_remove.append(mf_id) 1207 1208 # Remove filtered mass features 1209 for mf_id in features_to_remove: 1210 del mf_dict[mf_id] 1211 1212 if verbose and len(features_to_remove) > 0: 1213 print(f"Removed {len(features_to_remove)} {mf_type} based on peak metrics. Remaining: {len(mf_dict)}") 1214 1215 # Update the appropriate dictionary 1216 if induced_features: 1217 self.induced_mass_features = mf_dict 1218 else: 1219 self.mass_features = mf_dict 1220 1221 # Clean up unassociated EICs and ms1 data (only for regular features) 1222 self._remove_unassociated_eics() 1223 self._remove_unassociated_ms1_spectra() 1224 1225 def _remove_unassociated_eics(self) -> None: 1226 """Remove EICs that are not associated with any mass features. 1227 1228 This method cleans up the eics attribute by removing any EICs that do not correspond to 1229 any mass features currently stored in the mass_features attribute. This is useful for 1230 freeing up memory and ensuring that only relevant EICs are retained. 1231 1232 Returns 1233 ------- 1234 None 1235 Modifies self.eics in place by removing unassociated EICs. 1236 """ 1237 if self.mass_features is None or len(self.mass_features) == 0: 1238 self.eics = {} 1239 return 1240 1241 # Get the set of m/z values associated with current mass features 1242 associated_mzs = {mf.mz for mf in self.mass_features.values()} 1243 1244 # Remove EICs that are not associated with any mass features 1245 self.eics = {mz: eic for mz, eic in self.eics.items() if mz in associated_mzs} 1246 1247 def _remove_unassociated_ms1_spectra(self) -> None: 1248 """Remove MS1 spectra that are not associated with any mass features. 1249 This method cleans up the _ms_unprocessed attribute by removing any MS1 spectra that do not correspond to 1250 any mass features currently stored in the mass_features attribute. This is useful for freeing up memory 1251 and ensuring that only relevant MS1 spectra are retained. 1252 1253 Returns 1254 ------- 1255 None 1256 """ 1257 if self.mass_features is None or len(self.mass_features) == 0: 1258 self._ms_unprocessed = {} 1259 return 1260 1261 # Get the set of m/z values associated with current mass features 1262 associated_ms1_scans = {mf.apex_scan for mf in self.mass_features.values()} 1263 associated_ms1_scans = [int(scan) for scan in associated_ms1_scans] 1264 1265 # Get keys within the _ms attribute (these are individual MassSpectrum objects) 1266 current_stored_spectra = list(set(self._ms.keys())) 1267 if len(current_stored_spectra) == 0: 1268 return 1269 current_stored_spectra = [int(scan) for scan in current_stored_spectra] 1270 1271 # Filter the current_stored_spectra to only ms1 scans 1272 current_stored_spectra_ms1 = [ scan for scan in current_stored_spectra if scan in self.ms1_scans ] 1273 1274 # Remove MS1 spectra that are not associated with any mass features 1275 scans_to_drop = [scan for scan in current_stored_spectra_ms1 if scan not in associated_ms1_scans] 1276 for scan in scans_to_drop: 1277 if scan in self._ms: 1278 del self._ms[scan] 1279 1280class PHCalculations: 1281 """Methods for performing calculations related to 2D peak picking via persistent homology on LCMS data. 1282 1283 Notes 1284 ----- 1285 This class is intended to be used as a mixin for the LCMSBase class. 1286 1287 Methods 1288 ------- 1289 * sparse_mean_filter(idx, V, radius=[0, 1, 1]). 1290 Sparse implementation of a mean filter. 1291 * embed_unique_indices(a). 1292 Creates an array of indices, sorted by unique element. 1293 * sparse_upper_star(idx, V). 1294 Sparse implementation of an upper star filtration. 1295 * check_if_grid(data). 1296 Check if the data is gridded in mz space. 1297 * grid_data(data). 1298 Grid the data in the mz dimension. 1299 * find_mass_features_ph(ms_level=1, grid=True). 1300 Find mass features within an LCMSBase object using persistent homology. 1301 * cluster_mass_features(drop_children=True). 1302 Cluster regions of interest. 1303 """ 1304 1305 @staticmethod 1306 def sparse_mean_filter(idx, V, radius=[0, 1, 1]): 1307 """Sparse implementation of a mean filter. 1308 1309 Parameters 1310 ---------- 1311 idx : :obj:`~numpy.array` 1312 Edge indices for each dimension (MxN). 1313 V : :obj:`~numpy.array` 1314 Array of intensity data (Mx1). 1315 radius : float or list 1316 Radius of the sparse filter in each dimension. Values less than 1317 zero indicate no connectivity in that dimension. 1318 1319 Returns 1320 ------- 1321 :obj:`~numpy.array` 1322 Filtered intensities (Mx1). 1323 1324 Notes 1325 ----- 1326 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos. 1327 This is a static method. 1328 """ 1329 1330 # Copy indices 1331 idx = idx.copy().astype(V.dtype) 1332 1333 # Scale 1334 for i, r in enumerate(radius): 1335 # Increase inter-index distance 1336 if r < 1: 1337 idx[:, i] *= 2 1338 1339 # Do nothing 1340 elif r == 1: 1341 pass 1342 1343 # Decrease inter-index distance 1344 else: 1345 idx[:, i] /= r 1346 1347 # Connectivity matrix 1348 cmat = KDTree(idx) 1349 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1350 cmat.setdiag(1) 1351 1352 # Pair indices 1353 I, J = cmat.nonzero() 1354 1355 # Delete cmat 1356 cmat_shape = cmat.shape 1357 del cmat 1358 1359 # Sum over columns 1360 V_sum = sparse.bsr_matrix( 1361 (V[J], (I, I)), shape=cmat_shape, dtype=V.dtype 1362 ).diagonal(0) 1363 1364 # Count over columns 1365 V_count = sparse.bsr_matrix( 1366 (np.ones_like(J), (I, I)), shape=cmat_shape, dtype=V.dtype 1367 ).diagonal(0) 1368 1369 return V_sum / V_count 1370 1371 @staticmethod 1372 def embed_unique_indices(a): 1373 """Creates an array of indices, sorted by unique element. 1374 1375 Parameters 1376 ---------- 1377 a : :obj:`~numpy.array` 1378 Array of unique elements (Mx1). 1379 1380 Returns 1381 ------- 1382 :obj:`~numpy.array` 1383 Array of indices (Mx1). 1384 1385 Notes 1386 ----- 1387 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1388 This is a static method. 1389 """ 1390 1391 def count_tens(n): 1392 # Count tens 1393 ntens = (n - 1) // 10 1394 1395 while True: 1396 ntens_test = (ntens + n - 1) // 10 1397 1398 if ntens_test == ntens: 1399 return ntens 1400 else: 1401 ntens = ntens_test 1402 1403 def arange_exclude_10s(n): 1404 # How many 10s will there be? 1405 ntens = count_tens(n) 1406 1407 # Base array 1408 arr = np.arange(0, n + ntens) 1409 1410 # Exclude 10s 1411 arr = arr[(arr == 0) | (arr % 10 != 0)][:n] 1412 1413 return arr 1414 1415 # Creates an array of indices, sorted by unique element 1416 idx_sort = np.argsort(a) 1417 idx_unsort = np.argsort(idx_sort) 1418 1419 # Sorts records array so all unique elements are together 1420 sorted_a = a[idx_sort] 1421 1422 # Returns the unique values, the index of the first occurrence, 1423 # and the count for each element 1424 vals, idx_start, count = np.unique( 1425 sorted_a, return_index=True, return_counts=True 1426 ) 1427 1428 # Splits the indices into separate arrays 1429 splits = np.split(idx_sort, idx_start[1:]) 1430 1431 # Creates unique indices for each split 1432 idx_unq = np.concatenate([arange_exclude_10s(len(x)) for x in splits]) 1433 1434 # Reorders according to input array 1435 idx_unq = idx_unq[idx_unsort] 1436 1437 # Magnitude of each index 1438 exp = np.log10( 1439 idx_unq, where=idx_unq > 0, out=np.zeros_like(idx_unq, dtype=np.float64) 1440 ) 1441 idx_unq_mag = np.power(10, np.floor(exp) + 1) 1442 1443 # Result 1444 return a + idx_unq / idx_unq_mag 1445 1446 @staticmethod 1447 def roll_up_dataframe( 1448 df: pd.DataFrame, 1449 sort_by: str, 1450 tol: list, 1451 relative: list, 1452 dims: list, 1453 memory_opt_threshold: int = 10000, 1454 ): 1455 """Subset data by rolling up into apex in appropriate dimensions. 1456 1457 Parameters 1458 ---------- 1459 data : pd.DataFrame 1460 The input data containing "dims" columns and the "sort_by" column. 1461 sort_by : str 1462 The column to sort the data by, this will determine which mass features get rolled up into a parent mass feature 1463 (i.e., the mass feature with the highest value in the sort_by column). 1464 dims : list 1465 A list of dimension names (column names in the data DataFrame) to roll up the mass features by. 1466 tol : list 1467 A list of tolerances for each dimension. The length of the list must match the number of dimensions. 1468 The tolerances can be relative (as a fraction of the maximum value in that dimension) or absolute (in the units of that dimension). 1469 If relative is True, the tolerance will be multiplied by the maximum value in that dimension. 1470 If relative is False, the tolerance will be used as is. 1471 relative : list 1472 A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False). 1473 memory_opt_threshold : int, optional 1474 Minimum number of rows to trigger memory-optimized processing. Default is 10000. 1475 1476 Returns 1477 ------- 1478 pd.DataFrame 1479 A DataFrame with only the rolled up mass features, with the original index and columns. 1480 1481 1482 Raises 1483 ------ 1484 ValueError 1485 If the input data is not a pandas DataFrame. 1486 If the input data does not have columns for each of the dimensions in "dims". 1487 If the length of "dims", "tol", and "relative" do not match. 1488 """ 1489 og_columns = df.columns.copy() 1490 1491 # Unindex the data, but keep the original index 1492 if df.index.name is not None: 1493 og_index = df.index.name 1494 else: 1495 og_index = "index" 1496 df = df.reset_index(drop=False) 1497 1498 # Sort data by sort_by column, and reindex 1499 df = df.sort_values(by=sort_by, ascending=False).reset_index(drop=True) 1500 1501 # Check that data is a DataFrame and has columns for each of the dims 1502 if not isinstance(df, pd.DataFrame): 1503 raise ValueError("Data must be a pandas DataFrame") 1504 for dim in dims: 1505 if dim not in df.columns: 1506 raise ValueError(f"Data must have a column for {dim}") 1507 if len(dims) != len(tol) or len(dims) != len(relative): 1508 raise ValueError( 1509 "Dimensions, tolerances, and relative flags must be the same length" 1510 ) 1511 1512 # Pre-compute all values arrays 1513 all_values = [df[dim].values for dim in dims] 1514 1515 # Choose processing method based on dataframe size 1516 if len(df) >= memory_opt_threshold: 1517 # Memory-optimized approach for large dataframes 1518 distances = PHCalculations._compute_distances_memory_optimized( 1519 all_values, tol, relative 1520 ) 1521 else: 1522 # Faster approach for smaller dataframes 1523 distances = PHCalculations._compute_distances_original( 1524 all_values, tol, relative 1525 ) 1526 1527 # Process pairs with original logic but memory optimizations 1528 distances = distances.tocoo() 1529 pairs = np.stack((distances.row, distances.col), axis=1) 1530 pairs_df = pd.DataFrame(pairs, columns=["parent", "child"]).set_index("parent") 1531 del distances, pairs # Free memory immediately 1532 1533 to_drop = [] 1534 while not pairs_df.empty: 1535 # Find root_parents and their children (original logic preserved) 1536 root_parents = np.setdiff1d( 1537 np.unique(pairs_df.index.values), np.unique(pairs_df.child.values) 1538 ) 1539 children_of_roots = pairs_df.loc[root_parents, "child"].unique() 1540 to_drop.extend(children_of_roots) # Use extend instead of append 1541 1542 # Remove root_children as possible parents from pairs_df for next iteration 1543 pairs_df = pairs_df.drop(index=children_of_roots, errors="ignore") 1544 pairs_df = pairs_df.reset_index().set_index("child") 1545 # Remove root_children as possible children from pairs_df for next iteration 1546 pairs_df = pairs_df.drop(index=children_of_roots) 1547 1548 # Prepare for next iteration 1549 pairs_df = pairs_df.reset_index().set_index("parent") 1550 1551 # Convert to numpy array for efficient dropping 1552 to_drop = np.array(to_drop) 1553 1554 # Drop mass features that are not cluster parents 1555 df_sub = df.drop(index=to_drop) 1556 1557 # Set index back to og_index and only keep original columns 1558 df_sub = df_sub.set_index(og_index).sort_index()[og_columns] 1559 1560 return df_sub 1561 1562 @staticmethod 1563 def _compute_distances_original(all_values, tol, relative): 1564 """Original distance computation method for smaller datasets. 1565 1566 This method computes the pairwise distances between features in the dataset 1567 using a straightforward approach. It is suitable for smaller datasets where 1568 memory usage is not a primary concern. 1569 1570 Parameters 1571 ---------- 1572 all_values : list of :obj:`~numpy.array` 1573 List of arrays containing the values for each dimension. 1574 tol : list of float 1575 List of tolerances for each dimension. 1576 relative : list of bool 1577 List of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False). 1578 1579 Returns 1580 ------- 1581 :obj:`~scipy.sparse.coo_matrix` 1582 Sparse matrix indicating pairwise distances within tolerances. 1583 """ 1584 # Compute inter-feature distances with memory optimization 1585 distances = None 1586 for i in range(len(all_values)): 1587 values = all_values[i] 1588 # Use single precision if possible to reduce memory 1589 tree = KDTree(values.reshape(-1, 1).astype(np.float32)) 1590 1591 max_tol = tol[i] 1592 if relative[i] is True: 1593 max_tol = tol[i] * values.max() 1594 1595 # Compute sparse distance matrix with smaller chunks if memory is an issue 1596 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 1597 1598 # Only consider forward case, exclude diagonal 1599 sdm = sparse.triu(sdm, k=1) 1600 1601 # Process relative distances more efficiently 1602 if relative[i] is True: 1603 # Vectorized computation without creating intermediate arrays 1604 row_values = values[sdm.row] 1605 valid_idx = sdm.data <= tol[i] * row_values 1606 1607 # Reconstruct sparse matrix more efficiently 1608 sdm = sparse.coo_matrix( 1609 ( 1610 np.ones(valid_idx.sum(), dtype=np.uint8), 1611 (sdm.row[valid_idx], sdm.col[valid_idx]), 1612 ), 1613 shape=(len(values), len(values)), 1614 ) 1615 else: 1616 # Cast as binary matrix with smaller data type 1617 sdm.data = np.ones(len(sdm.data), dtype=np.uint8) 1618 1619 # Stack distances with memory-efficient multiplication 1620 if distances is None: 1621 distances = sdm 1622 else: 1623 # Use in-place operations where possible 1624 distances = distances.multiply(sdm) 1625 del sdm # Free memory immediately 1626 1627 return distances 1628 1629 @staticmethod 1630 def _compute_distances_memory_optimized(all_values, tol, relative): 1631 """Memory-optimized distance computation for large datasets. 1632 1633 This method computes the pairwise distances between features in the dataset 1634 using a more memory-efficient approach. It is suitable for larger datasets 1635 where memory usage is a primary concern. 1636 1637 Parameters 1638 ---------- 1639 all_values : list of :obj:`~numpy.array` 1640 List of arrays containing the values for each dimension. 1641 tol : list of float 1642 List of tolerances for each dimension. 1643 relative : list of bool 1644 List of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False). 1645 1646 Returns 1647 ------- 1648 :obj:`~scipy.sparse.coo_matrix` 1649 Sparse matrix indicating pairwise distances within tolerances. 1650 """ 1651 # Compute distance matrix for first dimension (full matrix as before) 1652 values_0 = all_values[0].astype(np.float32) 1653 tree_0 = KDTree(values_0.reshape(-1, 1)) 1654 1655 max_tol_0 = tol[0] 1656 if relative[0] is True: 1657 max_tol_0 = tol[0] * values_0.max() 1658 1659 # Compute sparse distance matrix for first dimension 1660 distances = tree_0.sparse_distance_matrix( 1661 tree_0, max_tol_0, output_type="coo_matrix" 1662 ) 1663 distances = sparse.triu(distances, k=1) 1664 1665 # Process relative distances for first dimension 1666 if relative[0] is True: 1667 row_values = values_0[distances.row] 1668 valid_idx = distances.data <= tol[0] * row_values 1669 distances = sparse.coo_matrix( 1670 ( 1671 np.ones(valid_idx.sum(), dtype=np.uint8), 1672 (distances.row[valid_idx], distances.col[valid_idx]), 1673 ), 1674 shape=(len(values_0), len(values_0)), 1675 ) 1676 else: 1677 distances.data = np.ones(len(distances.data), dtype=np.uint8) 1678 1679 # For remaining dimensions, work only on chunks defined by first dimension pairs 1680 if len(all_values) > 1: 1681 distances_coo = distances.tocoo() 1682 valid_pairs = [] 1683 1684 # Process each pair from first dimension 1685 for idx in range(len(distances_coo.data)): 1686 i, j = distances_coo.row[idx], distances_coo.col[idx] 1687 is_valid_pair = True 1688 1689 # Check remaining dimensions for this specific pair 1690 for dim_idx in range(1, len(all_values)): 1691 values = all_values[dim_idx] 1692 val_i, val_j = values[i], values[j] 1693 1694 max_tol = tol[dim_idx] 1695 if relative[dim_idx] is True: 1696 max_tol = tol[dim_idx] * values.max() 1697 1698 distance_ij = abs(val_i - val_j) 1699 1700 # Check if this pair satisfies the tolerance for this dimension 1701 if relative[dim_idx] is True: 1702 if distance_ij > tol[dim_idx] * val_i: 1703 is_valid_pair = False 1704 break 1705 else: 1706 if distance_ij > max_tol: 1707 is_valid_pair = False 1708 break 1709 1710 if is_valid_pair: 1711 valid_pairs.append((i, j)) 1712 1713 # Rebuild distances matrix with only valid pairs 1714 if valid_pairs: 1715 valid_pairs = np.array(valid_pairs) 1716 distances = sparse.coo_matrix( 1717 ( 1718 np.ones(len(valid_pairs), dtype=np.uint8), 1719 (valid_pairs[:, 0], valid_pairs[:, 1]), 1720 ), 1721 shape=(len(values_0), len(values_0)), 1722 ) 1723 else: 1724 # No valid pairs found 1725 distances = sparse.coo_matrix( 1726 (len(values_0), len(values_0)), dtype=np.uint8 1727 ) 1728 1729 return distances 1730 1731 def sparse_upper_star(self, idx, V): 1732 """Sparse implementation of an upper star filtration. 1733 1734 Parameters 1735 ---------- 1736 idx : :obj:`~numpy.array` 1737 Edge indices for each dimension (MxN). 1738 V : :obj:`~numpy.array` 1739 Array of intensity data (Mx1). 1740 Returns 1741 ------- 1742 idx : :obj:`~numpy.array` 1743 Index of filtered points (Mx1). 1744 persistence : :obj:`~numpy.array` 1745 Persistence of each filtered point (Mx1). 1746 1747 Notes 1748 ----- 1749 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1750 """ 1751 1752 # Invert 1753 V = -1 * V.copy().astype(int) 1754 1755 # Embed indices 1756 V = self.embed_unique_indices(V) 1757 1758 # Connectivity matrix 1759 cmat = KDTree(idx) 1760 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1761 cmat.setdiag(1) 1762 cmat = sparse.triu(cmat) 1763 1764 # Pairwise minimums 1765 I, J = cmat.nonzero() 1766 d = np.maximum(V[I], V[J]) 1767 1768 # Delete connectiity matrix 1769 cmat_shape = cmat.shape 1770 del cmat 1771 1772 # Sparse distance matrix 1773 sdm = sparse.coo_matrix((d, (I, J)), shape=cmat_shape) 1774 1775 # Delete pairwise mins 1776 del d, I, J 1777 1778 # Persistence homology 1779 ph = ripser(sdm, distance_matrix=True, maxdim=0)["dgms"][0] 1780 1781 # Bound death values 1782 ph[ph[:, 1] == np.inf, 1] = np.max(V) 1783 1784 # Construct tree to query against 1785 tree = KDTree(V.reshape((-1, 1))) 1786 1787 # Get the indexes of the first nearest neighbor by birth 1788 _, nn = tree.query(ph[:, 0].reshape((-1, 1)), k=1, workers=-1) 1789 1790 return nn, -(ph[:, 0] // 1 - ph[:, 1] // 1) 1791 1792 def check_if_grid(self, data): 1793 """Check if the data are gridded in mz space. 1794 1795 Parameters 1796 ---------- 1797 data : DataFrame 1798 DataFrame containing the mass spectrometry data. Needs to have mz and scan columns. 1799 1800 Returns 1801 ------- 1802 bool 1803 True if the data is gridded in the mz direction, False otherwise. 1804 1805 Notes 1806 ----- 1807 This function is used within the grid_data function and the find_mass_features function and is not intended to be called directly. 1808 """ 1809 # Calculate the difference between consecutive mz values in a single scan 1810 dat_check = data.copy().reset_index(drop=True) 1811 dat_check["mz_diff"] = np.abs(dat_check["mz"].diff()) 1812 mz_diff_min = ( 1813 dat_check.groupby("scan")["mz_diff"].min().min() 1814 ) # within each scan, what is the smallest mz difference between consecutive mz values 1815 1816 # Find the mininum mz difference between mz values in the data; regardless of scan 1817 dat_check_mz = dat_check[["mz"]].drop_duplicates().copy() 1818 dat_check_mz = dat_check_mz.sort_values(by=["mz"]).reset_index(drop=True) 1819 dat_check_mz["mz_diff"] = np.abs(dat_check_mz["mz"].diff()) 1820 1821 # Get minimum mz_diff between mz values in the data 1822 mz_diff_min_raw = dat_check_mz["mz_diff"].min() 1823 1824 # 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 1825 if mz_diff_min_raw < mz_diff_min: 1826 return False 1827 else: 1828 return True 1829 1830 def grid_data(self, data, attempts=5): 1831 """Grid the data in the mz dimension. 1832 1833 Data must be gridded prior to persistent homology calculations and computing average mass spectrum 1834 1835 Parameters 1836 ---------- 1837 data : DataFrame 1838 The input data containing mz, scan, scan_time, and intensity columns. 1839 attempts : int, optional 1840 The number of attempts to grid the data. Default is 5. 1841 1842 Returns 1843 ------- 1844 DataFrame 1845 The gridded data with mz, scan, scan_time, and intensity columns. 1846 1847 Raises 1848 ------ 1849 ValueError 1850 If gridding fails after the specified number of attempts. 1851 """ 1852 attempt_i = 0 1853 while attempt_i < attempts: 1854 attempt_i += 1 1855 data = self._grid_data(data) 1856 1857 if self.check_if_grid(data): 1858 return data 1859 1860 if not self.check_if_grid(data): 1861 raise ValueError( 1862 "Gridding failed after " 1863 + str(attempt_i) 1864 + " attempts. Please check the data." 1865 ) 1866 else: 1867 return data 1868 1869 def _grid_data(self, data): 1870 """Internal method to grid the data in the mz dimension. 1871 1872 Notes 1873 ----- 1874 This method is called by the grid_data method and should not be called directly. 1875 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, 1876 but it does not check if the data is already gridded or if the gridding is successful. 1877 1878 Parameters 1879 ---------- 1880 data : pd.DataFrame or pl.DataFrame 1881 The input data to grid. 1882 1883 Returns 1884 ------- 1885 pd.DataFrame or pl.DataFrame 1886 The data after attempting to grid it in the mz dimension. 1887 """ 1888 # Calculate the difference between consecutive mz values in a single scan for grid spacing 1889 data_w = data.copy().reset_index(drop=True) 1890 data_w["mz_diff"] = np.abs(data_w["mz"].diff()) 1891 mz_diff_min = data_w.groupby("scan")["mz_diff"].min().min() * 0.99999 1892 1893 # Need high intensity mz values first so they are parents in the output pairs stack 1894 dat_mz = data_w[["mz", "intensity"]].sort_values( 1895 by=["intensity"], ascending=False 1896 ) 1897 dat_mz = dat_mz[["mz"]].drop_duplicates().reset_index(drop=True).copy() 1898 1899 # Construct KD tree 1900 tree = KDTree(dat_mz.mz.values.reshape(-1, 1)) 1901 sdm = tree.sparse_distance_matrix(tree, mz_diff_min, output_type="coo_matrix") 1902 sdm = sparse.triu(sdm, k=1) 1903 sdm.data = np.ones_like(sdm.data) 1904 distances = sdm.tocoo() 1905 pairs = np.stack((distances.row, distances.col), axis=1) 1906 1907 # Cull pairs to just get root 1908 to_drop = [] 1909 while len(pairs) > 0: 1910 root_parents = np.setdiff1d(np.unique(pairs[:, 0]), np.unique(pairs[:, 1])) 1911 id_root_parents = np.isin(pairs[:, 0], root_parents) 1912 children_of_roots = np.unique(pairs[id_root_parents, 1]) 1913 to_drop = np.append(to_drop, children_of_roots) 1914 1915 # Set up pairs array for next iteration by removing pairs with children or parents already dropped 1916 pairs = pairs[~np.isin(pairs[:, 1], to_drop), :] 1917 pairs = pairs[~np.isin(pairs[:, 0], to_drop), :] 1918 dat_mz = dat_mz.reset_index(drop=True).drop(index=np.array(to_drop)) 1919 mz_dat_np = ( 1920 dat_mz[["mz"]] 1921 .sort_values(by=["mz"]) 1922 .reset_index(drop=True) 1923 .values.flatten() 1924 ) 1925 1926 # Sort data by mz and recast mz to nearest value in mz_dat_np 1927 data_w = data_w.sort_values(by=["mz"]).reset_index(drop=True).copy() 1928 data_w["mz_new"] = mz_dat_np[find_closest(mz_dat_np, data_w["mz"].values)] 1929 data_w["mz_diff"] = np.abs(data_w["mz"] - data_w["mz_new"]) 1930 1931 # Rename mz_new as mz; drop mz_diff; groupby scan and mz and sum intensity 1932 new_data_w = data_w.rename(columns={"mz": "mz_orig", "mz_new": "mz"}).copy() 1933 new_data_w = ( 1934 new_data_w.drop(columns=["mz_diff", "mz_orig"]) 1935 .groupby(["scan", "mz"])["intensity"] 1936 .sum() 1937 .reset_index() 1938 ) 1939 new_data_w = ( 1940 new_data_w.sort_values(by=["scan", "mz"], ascending=[True, True]) 1941 .reset_index(drop=True) 1942 .copy() 1943 ) 1944 1945 return new_data_w 1946 1947 def _filter_data_by_targets(self, data, target_search_dict): 1948 """Filter MS data to only include m/z and RT windows around target values. 1949 1950 Parameters 1951 ---------- 1952 data : pd.DataFrame 1953 MS data with 'mz' and 'scan_time' columns 1954 target_search_dict : dict 1955 Dictionary with target_mz_list, target_rt_list, mz_tolerance_ppm, rt_tolerance 1956 1957 Returns 1958 ------- 1959 pd.DataFrame 1960 Filtered data containing only points within target windows 1961 """ 1962 target_mz_list = target_search_dict['target_mz_list'] 1963 target_rt_list = target_search_dict['target_rt_list'] 1964 mz_tolerance_ppm = target_search_dict['mz_tolerance_ppm'] 1965 rt_tolerance = target_search_dict['rt_tolerance'] 1966 1967 # Create a mask for data points that fall within any target window 1968 mask = np.zeros(len(data), dtype=bool) 1969 1970 for target_mz, target_rt in zip(target_mz_list, target_rt_list): 1971 # Calculate m/z window 1972 mz_tol = target_mz * mz_tolerance_ppm / 1e6 1973 mz_min = target_mz - mz_tol 1974 mz_max = target_mz + mz_tol 1975 1976 # Calculate RT window 1977 rt_min = target_rt - rt_tolerance 1978 rt_max = target_rt + rt_tolerance 1979 1980 # Create mask for this target 1981 target_mask = ( 1982 (data['mz'] >= mz_min) & (data['mz'] <= mz_max) & 1983 (data['scan_time'] >= rt_min) & (data['scan_time'] <= rt_max) 1984 ) 1985 1986 # Combine with overall mask 1987 mask |= target_mask 1988 1989 return data[mask].reset_index(drop=True) 1990 1991 def find_mass_features_ph(self, ms_level=1, grid=True, targeted_search=False, target_search_dict=None, mf_type="untargeted", accumulate_features=False): 1992 """Find mass features within an LCMSBase object using persistent homology. 1993 1994 Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id) 1995 1996 Parameters 1997 ---------- 1998 ms_level : int, optional 1999 The MS level to use. Default is 1. 2000 grid : bool, optional 2001 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True. 2002 targeted_search : bool, optional 2003 If True, perform targeted search mode. Default is False. 2004 target_search_dict : dict or None, optional 2005 Dictionary with target parameters for targeted search. Default is None. 2006 mf_type : str, optional 2007 Type label for the mass features. Default is "untargeted". 2008 accumulate_features : bool, optional 2009 If True, add to existing features rather than replacing them. Default is False. 2010 2011 Raises 2012 ------ 2013 ValueError 2014 If no MS level data is found on the object. 2015 If data is not gridded and grid is False. 2016 2017 Returns 2018 ------- 2019 None, but assigns the mass_features attribute to the object. 2020 2021 Notes 2022 ----- 2023 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 2024 """ 2025 # Check that ms_level is a key in self._ms_uprocessed 2026 if ms_level not in self._ms_unprocessed.keys(): 2027 raise ValueError( 2028 "No MS level " 2029 + str(ms_level) 2030 + " data found, did you instantiate with parser specific to MS level?" 2031 ) 2032 2033 # Get ms data 2034 data = self._ms_unprocessed[ms_level].copy() 2035 2036 # Drop rows with missing intensity values and reset index 2037 data = data.dropna(subset=["intensity"]).reset_index(drop=True) 2038 2039 # Add scan_time for filtering if in targeted mode 2040 if targeted_search: 2041 data = data.merge(self.scan_df[["scan", "scan_time"]], on="scan", how="left") 2042 2043 # Threshold data (bypass thresholds in targeted mode) 2044 dims = ["mz", "scan_time"] 2045 if targeted_search: 2046 # In targeted mode, bypass intensity and persistence thresholds 2047 threshold = 0 2048 persistence_threshold = 0 2049 # Filter data to only target windows 2050 data_thres = self._filter_data_by_targets(data, target_search_dict) 2051 if len(data_thres) == 0: 2052 if self.parameters.lc_ms.verbose_processing: 2053 print("No data found in target windows") 2054 self.mass_features = {} 2055 return 2056 else: 2057 threshold = self.parameters.lc_ms.ph_inten_min_rel * data.intensity.max() 2058 persistence_threshold = ( 2059 self.parameters.lc_ms.ph_persis_min_rel * data.intensity.max() 2060 ) 2061 data_thres = data[data["intensity"] > threshold].reset_index(drop=True).copy() 2062 2063 # Check if gridded, if not, grid 2064 gridded_mz = self.check_if_grid(data_thres) 2065 if gridded_mz is False: 2066 if grid is False: 2067 raise ValueError( 2068 "Data are not gridded in mz dimension, try reprocessing with a different params or grid data before running this function" 2069 ) 2070 else: 2071 data_thres = self.grid_data(data_thres) 2072 2073 # Add scan_time (skip if already present from targeted mode) 2074 if 'scan_time' not in data_thres.columns: 2075 data_thres = data_thres.merge(self.scan_df[["scan", "scan_time"]], on="scan") 2076 # Process in chunks if required 2077 if len(data_thres) > 10000: 2078 return self._find_mass_features_ph_partition( 2079 data_thres, dims, persistence_threshold, mf_type, accumulate_features 2080 ) 2081 else: 2082 # Process all at once 2083 return self._find_mass_features_ph_single( 2084 data_thres, dims, persistence_threshold, mf_type, accumulate_features 2085 ) 2086 return self._find_mass_features_ph_single( 2087 data_thres, dims, persistence_threshold, mf_type 2088 ) 2089 2090 def _find_mass_features_ph_single(self, data_thres, dims, persistence_threshold, mf_type="untargeted", accumulate_features=False): 2091 """Process all data at once (original logic).""" 2092 # Build factors 2093 factors = { 2094 dim: pd.factorize(data_thres[dim], sort=True)[1].astype(np.float32) 2095 for dim in dims 2096 } 2097 2098 # Build indexes 2099 index = { 2100 dim: np.searchsorted(factors[dim], data_thres[dim]).astype(np.float32) 2101 for dim in factors 2102 } 2103 2104 # Smooth and process 2105 mass_features_df = self._process_partition_ph( 2106 data_thres, index, dims, persistence_threshold 2107 ) 2108 2109 # Roll up within chunk to remove duplicates 2110 mass_features_df = self.roll_up_dataframe( 2111 df=mass_features_df, 2112 sort_by="persistence", 2113 dims=["mz", "scan_time"], 2114 tol=[ 2115 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2116 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2117 ], 2118 relative=[True, False], 2119 ) 2120 mass_features_df = mass_features_df.reset_index(drop=True) 2121 2122 # Populate mass_features attribute 2123 self._populate_mass_features(mass_features_df, mf_type, accumulate_features) 2124 2125 def _find_mass_features_ph_partition(self, data_thres, dims, persistence_threshold, mf_type="untargeted", accumulate_features=False): 2126 """Partition the persistent homology mass feature detection for large datasets. 2127 2128 This method splits the input data into overlapping scan partitions, processes each partition to detect mass features 2129 using persistent homology, rolls up duplicates within and across partitions, and populates the mass_features attribute. 2130 2131 Parameters 2132 ---------- 2133 data_thres : pd.DataFrame 2134 The thresholded input data containing mass spectrometry information. 2135 dims : list 2136 List of dimension names (e.g., ["mz", "scan_time"]) used for feature detection. 2137 persistence_threshold : float 2138 Minimum persistence value required for a detected mass feature to be retained. 2139 mf_type : str, optional 2140 Type label for the mass features. Default is "untargeted". 2141 accumulate_features : bool, optional 2142 If True, add to existing features rather than replacing them. Default is False. 2143 2144 Returns 2145 ------- 2146 None 2147 Populates the mass_features attribute of the object with detected mass features. 2148 """ 2149 all_mass_features = [] 2150 2151 # Split scans into partitions 2152 unique_scans = sorted(data_thres["scan"].unique()) 2153 unique_scans_n = len(unique_scans) 2154 2155 # Calculate partition size in scans based on goal 2156 partition_size_goal = 5000 2157 scans_per_partition = max( 2158 1, partition_size_goal // (len(data_thres) // unique_scans_n) 2159 ) 2160 if scans_per_partition == 0: 2161 scans_per_partition = 1 2162 2163 # Make partitions based on scans, with overlapping in partitioned scans 2164 scan_overlap = 4 2165 partition_scans = [] 2166 for i in range(0, unique_scans_n, scans_per_partition): 2167 start_idx = max(0, i - scan_overlap) 2168 end_idx = min( 2169 unique_scans_n - 1, i + scans_per_partition - 1 + scan_overlap 2170 ) 2171 scans_group = [int(s) for s in unique_scans[start_idx : end_idx + 1]] 2172 partition_scans.append(scans_group) 2173 2174 # Set index to scan for faster filtering 2175 data_thres = data_thres.set_index("scan") 2176 for scans in partition_scans: 2177 # Determine start and end scan for partition, with 5 scans overlap 2178 partition_data = data_thres.loc[scans].reset_index(drop=False).copy() 2179 2180 if len(partition_data) == 0: 2181 continue 2182 2183 # Build factors for this partition 2184 factors = { 2185 dim: pd.factorize(partition_data[dim], sort=True)[1].astype(np.float32) 2186 for dim in dims 2187 } 2188 2189 # Build indexes 2190 index = { 2191 dim: np.searchsorted(factors[dim], partition_data[dim]).astype( 2192 np.float32 2193 ) 2194 for dim in factors 2195 } 2196 2197 # Process partition 2198 partition_features = self._process_partition_ph( 2199 partition_data, index, dims, persistence_threshold 2200 ) 2201 2202 if len(partition_features) == 0: 2203 continue 2204 2205 # Roll up within partition 2206 partition_features = self.roll_up_dataframe( 2207 df=partition_features, 2208 sort_by="persistence", 2209 dims=["mz", "scan_time"], 2210 tol=[ 2211 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2212 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2213 ], 2214 relative=[True, False], 2215 ) 2216 partition_features = partition_features.reset_index(drop=True) 2217 2218 if len(partition_features) > 0: 2219 all_mass_features.append(partition_features) 2220 2221 # Combine results from all partitions 2222 if all_mass_features: 2223 combined_features = pd.concat(all_mass_features, ignore_index=True) 2224 2225 # Sort by persistence 2226 combined_features = combined_features.sort_values( 2227 by="persistence", ascending=False 2228 ).reset_index(drop=True) 2229 2230 # Remove duplicates from overlapping regions 2231 combined_features = self.roll_up_dataframe( 2232 df=combined_features, 2233 sort_by="persistence", 2234 dims=["mz", "scan_time"], 2235 tol=[ 2236 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2237 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2238 ], 2239 relative=[True, False], 2240 ) 2241 2242 # resort by persistence and reset index 2243 combined_features = combined_features.reset_index(drop=True) 2244 2245 # Populate mass_features attribute 2246 self._populate_mass_features(combined_features, mf_type, accumulate_features) 2247 else: 2248 self.mass_features = {} 2249 2250 def _process_partition_ph(self, partition_data, index, dims, persistence_threshold): 2251 """Process a single partition with persistent homology.""" 2252 # Smooth data 2253 iterations = self.parameters.lc_ms.ph_smooth_it 2254 smooth_radius = [ 2255 self.parameters.lc_ms.ph_smooth_radius_mz, 2256 self.parameters.lc_ms.ph_smooth_radius_scan, 2257 ] 2258 2259 index_array = np.vstack([index[dim] for dim in dims]).T 2260 V = partition_data["intensity"].values 2261 resid = np.inf 2262 2263 for i in range(iterations): 2264 # Previous iteration 2265 V_prev = V.copy() 2266 resid_prev = resid 2267 V = self.sparse_mean_filter(index_array, V, radius=smooth_radius) 2268 2269 # Calculate residual with previous iteration 2270 resid = np.sqrt(np.mean(np.square(V - V_prev))) 2271 2272 # Evaluate convergence 2273 if i > 0: 2274 # Percent change in residual 2275 test = np.abs(resid - resid_prev) / resid_prev 2276 2277 # Exit criteria 2278 if test <= 0: 2279 break 2280 2281 # Overwrite values 2282 partition_data = partition_data.copy() 2283 partition_data["intensity"] = V 2284 2285 # Use persistent homology to find regions of interest 2286 pidx, pers = self.sparse_upper_star(index_array, V) 2287 pidx = pidx[pers > 1] 2288 pers = pers[pers > 1] 2289 2290 if len(pidx) == 0: 2291 return pd.DataFrame() 2292 2293 # Get peaks 2294 peaks = partition_data.iloc[pidx, :].reset_index(drop=True) 2295 2296 # Add persistence column 2297 peaks["persistence"] = pers 2298 mass_features = peaks.sort_values( 2299 by="persistence", ascending=False 2300 ).reset_index(drop=True) 2301 2302 # Filter by persistence threshold 2303 mass_features = mass_features.loc[ 2304 mass_features["persistence"] > persistence_threshold, : 2305 ].reset_index(drop=True) 2306 2307 return mass_features 2308 2309 def _populate_mass_features(self, mass_features_df, mf_type="untargeted", accumulate_features=False): 2310 """Populate the mass_features attribute from a DataFrame. 2311 2312 Parameters 2313 ---------- 2314 mass_features_df : pd.DataFrame 2315 DataFrame containing mass feature information. 2316 Note that the order of this DataFrame will determine the order of mass features in the mass_features attribute. 2317 mf_type : str, optional 2318 Type label for the mass features. Default is "untargeted". 2319 accumulate_features : bool, optional 2320 If True, new features will be added to existing features rather than replacing them. 2321 Mass feature IDs will be offset to avoid conflicts. Default is False. 2322 2323 Returns 2324 ------- 2325 None, but assigns or updates the mass_features attribute to the object. 2326 """ 2327 # Rename scan column to apex_scan 2328 mass_features_df = mass_features_df.rename( 2329 columns={"scan": "apex_scan", "scan_time": "retention_time"} 2330 ) 2331 2332 # Initialize or preserve existing mass_features attribute 2333 if accumulate_features and self.mass_features is not None and len(self.mass_features) > 0: 2334 # Find the maximum existing ID to offset new IDs and avoid conflicts 2335 id_offset = max(self.mass_features.keys()) + 1 2336 initial_count = len(self.mass_features) 2337 else: 2338 # Replace mode (default/backwards compatible) 2339 self.mass_features = {} 2340 id_offset = 0 2341 initial_count = 0 2342 2343 # Add new mass features 2344 for idx, row in enumerate(mass_features_df.itertuples()): 2345 row_dict = mass_features_df.iloc[row.Index].to_dict() 2346 lcms_feature = LCMSMassFeature(self, **row_dict) 2347 lcms_feature.type = mf_type 2348 # Use sequential ID starting from id_offset to avoid conflicts with existing features 2349 new_id = idx + id_offset 2350 lcms_feature._id = new_id # Update the internal ID 2351 self.mass_features[new_id] = lcms_feature 2352 2353 if self.parameters.lc_ms.verbose_processing: 2354 if accumulate_features and initial_count > 0: 2355 print(f"Found {len(mass_features_df)} new mass features (total: {len(self.mass_features)})") 2356 else: 2357 print("Found " + str(len(mass_features_df)) + " initial mass features") 2358 2359 def find_mass_features_ph_centroid(self, ms_level=1, targeted_search=False, target_search_dict=None, mf_type="untargeted", accumulate_features=False): 2360 """Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data. 2361 2362 Parameters 2363 ---------- 2364 ms_level : int, optional 2365 The MS level to use. Default is 1. 2366 targeted_search : bool, optional 2367 If True, perform targeted search mode. Default is False. 2368 target_search_dict : dict or None, optional 2369 Dictionary with target parameters for targeted search. Default is None. 2370 mf_type : str, optional 2371 Type label for the mass features. Default is "untargeted". 2372 accumulate_features : bool, optional 2373 If True, add to existing features rather than replacing them. Default is False. 2374 2375 Raises 2376 ------ 2377 ValueError 2378 If no MS level data is found on the object. 2379 2380 Returns 2381 ------- 2382 None, but assigns the mass_features attribute to the object. 2383 """ 2384 # Check that ms_level is a key in self._ms_uprocessed 2385 if ms_level not in self._ms_unprocessed.keys(): 2386 raise ValueError( 2387 "No MS level " 2388 + str(ms_level) 2389 + " data found, did you instantiate with parser specific to MS level?" 2390 ) 2391 2392 # Work with reference instead of copy 2393 data = self._ms_unprocessed[ms_level] 2394 2395 # Merge with scan data first (needed for filtering in targeted mode) 2396 scan_subset = self.scan_df[["scan", "scan_time"]] 2397 data_with_time = data.merge(scan_subset, on="scan", how="inner") 2398 2399 # Calculate threshold and filter (bypass in targeted mode) 2400 if targeted_search: 2401 # In targeted mode, bypass intensity threshold 2402 threshold = 0 2403 valid_mask = data_with_time["intensity"].notna() 2404 required_cols = ["mz", "intensity", "scan", "scan_time"] 2405 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2406 2407 # Filter to target windows 2408 data_thres = self._filter_data_by_targets(data_thres, target_search_dict) 2409 2410 if len(data_thres) == 0: 2411 if self.parameters.lc_ms.verbose_processing: 2412 print("No data found in target windows") 2413 self.mass_features = {} 2414 return 2415 else: 2416 # Normal mode with threshold 2417 max_intensity = data_with_time["intensity"].max() 2418 threshold = self.parameters.lc_ms.ph_inten_min_rel * max_intensity 2419 valid_mask = data_with_time["intensity"].notna() & (data_with_time["intensity"] > threshold) 2420 required_cols = ["mz", "intensity", "scan", "scan_time"] 2421 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2422 2423 data_thres["persistence"] = data_thres["intensity"] 2424 mf_df = data_thres 2425 del data_thres, scan_subset, data_with_time 2426 2427 # Order by scan_time and then mz to ensure features near in rt are processed together 2428 # It's ok that different scans are in different partitions; we will roll up later 2429 mf_df = mf_df.sort_values( 2430 by=["scan_time", "mz"], ascending=[True, True] 2431 ).reset_index(drop=True) 2432 partition_size = 10000 2433 partitions = [ 2434 mf_df.iloc[i : i + partition_size].reset_index(drop=True) 2435 for i in range(0, len(mf_df), partition_size) 2436 ] 2437 del mf_df 2438 2439 # Run roll_up_dataframe on each partition 2440 rolled_partitions = [] 2441 for part in partitions: 2442 rolled = self.roll_up_dataframe( 2443 df=part, 2444 sort_by="persistence", 2445 dims=["mz", "scan_time"], 2446 tol=[ 2447 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2448 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2449 ], 2450 relative=[True, False], 2451 ) 2452 rolled_partitions.append(rolled) 2453 del partitions 2454 2455 # Run roll_up_dataframe on the rolled_up partitions to merge features near partition boundaries 2456 2457 # Combine results and run a final roll-up to merge features near partition boundaries 2458 mf_df_final = pd.concat(rolled_partitions, ignore_index=True) 2459 del rolled_partitions 2460 2461 # Reorder by persistence before final roll-up 2462 mf_df_final = mf_df_final.sort_values( 2463 by="persistence", ascending=False 2464 ).reset_index(drop=True) 2465 2466 mf_df_final = self.roll_up_dataframe( 2467 df=mf_df_final, 2468 sort_by="persistence", 2469 dims=["mz", "scan_time"], 2470 tol=[ 2471 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2472 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2473 ], 2474 relative=[True, False], 2475 ) 2476 # reset index 2477 mf_df_final = mf_df_final.reset_index(drop=True) 2478 2479 # Combine rename and sort operations 2480 mass_features = ( 2481 mf_df_final.rename( 2482 columns={"scan": "apex_scan", "scan_time": "retention_time"} 2483 ) 2484 .sort_values(by="persistence", ascending=False) 2485 .reset_index(drop=True) 2486 ) 2487 del mf_df_final # Free memory 2488 2489 # Order by persistence and reset index 2490 mass_features = mass_features.sort_values( 2491 by="persistence", ascending=False 2492 ).reset_index(drop=True) 2493 2494 self.mass_features = {} 2495 for idx, row in mass_features.iterrows(): 2496 row_dict = row.to_dict() 2497 lcms_feature = LCMSMassFeature(self, **row_dict) 2498 lcms_feature.type = mf_type 2499 self.mass_features[lcms_feature.id] = lcms_feature 2500 2501 if self.parameters.lc_ms.verbose_processing: 2502 print("Found " + str(len(mass_features)) + " initial mass features") 2503 2504 def cluster_mass_features(self, drop_children=True, sort_by="persistence"): 2505 """Cluster mass features 2506 2507 Based on their proximity in the mz and scan_time dimensions, priorizies the mass features with the highest persistence. 2508 2509 Parameters 2510 ---------- 2511 drop_children : bool, optional 2512 Whether to drop the mass features that are not cluster parents. Default is True. 2513 sort_by : str, optional 2514 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". 2515 2516 Raises 2517 ------ 2518 ValueError 2519 If no mass features are found. 2520 If too many mass features are found. 2521 2522 Returns 2523 ------- 2524 None if drop_children is True, otherwise returns a list of mass feature ids that are not cluster parents. 2525 """ 2526 if self.mass_features is None: 2527 raise ValueError("No mass features found, run find_mass_features() first") 2528 if len(self.mass_features) > 400000: 2529 raise ValueError( 2530 "Too many mass features of interest found, run find_mass_features() with a higher intensity threshold" 2531 ) 2532 dims = ["mz", "scan_time"] 2533 mf_df_og = self.mass_features_to_df() 2534 mf_df = mf_df_og.copy() 2535 2536 tol = [ 2537 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2538 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2539 ] # mz, in relative; scan_time in minutes 2540 relative = [True, False] 2541 2542 # Roll up mass features based on their proximity in the declared dimensions 2543 mf_df_new = self.roll_up_dataframe( 2544 df=mf_df, sort_by=sort_by, dims=dims, tol=tol, relative=relative 2545 ) 2546 2547 mf_df["cluster_parent"] = np.where( 2548 np.isin(mf_df.index, mf_df_new.index), True, False 2549 ) 2550 2551 # get mass feature ids of features that are not cluster parents 2552 cluster_daughters = mf_df[~mf_df["cluster_parent"]].index.values 2553 if drop_children is True: 2554 # Drop mass features that are not cluster parents from self 2555 self.mass_features = { 2556 k: v 2557 for k, v in self.mass_features.items() 2558 if k not in cluster_daughters 2559 } 2560 else: 2561 return cluster_daughters 2562 2563 2564class LCMSCollectionCalculations: 2565 """Methods for performing calculations related to LCMSCollection objects. 2566 2567 Notes 2568 ----- 2569 This class is intended as a mixin for the LCMSCollection class. 2570 """ 2571 2572 @staticmethod 2573 def _plot_multiple_eics(ax, cluster_mfs, induced_cluster_mfs, rep_sample_id, rep_mf_id, 2574 median_rt, eic_buffer_time, plot_smoothed=False, 2575 plot_datapoints=False, label_samples=False, lcms_collection=None): 2576 """Internal method to plot multiple EICs from different samples on a given axis. 2577 2578 Parameters 2579 ---------- 2580 ax : matplotlib.axes.Axes 2581 The axis to plot on. 2582 cluster_mfs : pd.DataFrame 2583 DataFrame containing cluster mass features (non-induced). 2584 induced_cluster_mfs : pd.DataFrame or None 2585 DataFrame containing induced (gap-filled) mass features. 2586 rep_sample_id : int 2587 Sample ID of the representative mass feature. 2588 rep_mf_id : int 2589 Mass feature ID of the representative mass feature. 2590 median_rt : float 2591 Median retention time for the cluster. 2592 eic_buffer_time : float 2593 Time buffer around the peak (minutes). 2594 plot_smoothed : bool, optional 2595 If True, plot smoothed EICs. Default is False. 2596 plot_datapoints : bool, optional 2597 If True, plot EIC datapoints. Default is False. 2598 label_samples : bool, optional 2599 If True, label each sample individually. Default is False. 2600 lcms_collection : LCMSCollection, optional 2601 The parent collection object for accessing samples. Required. 2602 """ 2603 ax.set_title("EICs from all samples", loc="left") 2604 2605 # Track if we've added labels for legend (to avoid duplicates) 2606 rep_labeled = False 2607 regular_labeled = False 2608 induced_labeled = False 2609 2610 # Plot regular (non-induced) mass features 2611 for _, row in cluster_mfs.iterrows(): 2612 sample_id = int(row['sample_id']) 2613 mf_id = row['mf_id'] 2614 sample = lcms_collection[sample_id] 2615 sample_name = row['sample_name'] 2616 2617 # Get EIC using eic_mz column from dataframe 2618 eic_mz = row.get('_eic_mz') 2619 if eic_mz is not None and not pd.isna(eic_mz) and hasattr(sample, 'eics') and sample.eics: 2620 eic_data = sample.eics.get(eic_mz) 2621 else: 2622 eic_data = None 2623 2624 if eic_data: 2625 # Determine line style and width 2626 if sample_id == rep_sample_id and mf_id == rep_mf_id: 2627 # Representative feature - bold line 2628 linewidth = 2.5 2629 alpha = 1.0 2630 color = 'tab:blue' 2631 if label_samples: 2632 label = f"{sample_name} (representative)" 2633 else: 2634 label = "Representative" if not rep_labeled else None 2635 rep_labeled = True 2636 else: 2637 # Other features - thinner line 2638 linewidth = 1.0 2639 alpha = 0.5 2640 color = 'tab:blue' 2641 if label_samples: 2642 label = sample_name 2643 else: 2644 label = "Regular features" if not regular_labeled else None 2645 regular_labeled = True 2646 2647 ax.plot( 2648 eic_data.time, 2649 eic_data.eic, 2650 c=color, 2651 linewidth=linewidth, 2652 alpha=alpha, 2653 linestyle='-', 2654 label=label 2655 ) 2656 2657 if plot_datapoints: 2658 ax.scatter( 2659 eic_data.time, 2660 eic_data.eic, 2661 c=color, 2662 alpha=alpha, 2663 s=10 2664 ) 2665 2666 if plot_smoothed and hasattr(eic_data, 'eic_smoothed'): 2667 ax.plot( 2668 eic_data.time, 2669 eic_data.eic_smoothed, 2670 c=color, 2671 linestyle='--', 2672 alpha=alpha * 0.8, 2673 linewidth=linewidth * 0.8 2674 ) 2675 2676 # Plot induced (gap-filled) mass features if available 2677 if induced_cluster_mfs is not None and not induced_cluster_mfs.empty: 2678 for _, row in induced_cluster_mfs.iterrows(): 2679 sample_id = int(row['sample_id']) 2680 mf_id = row['mf_id'] 2681 sample = lcms_collection[sample_id] 2682 sample_name = row['sample_name'] 2683 2684 # Get EIC using eic_mz column from dataframe 2685 eic_mz = row.get('_eic_mz') 2686 if eic_mz is not None and not pd.isna(eic_mz) and hasattr(sample, 'eics') and sample.eics: 2687 eic_data = sample.eics.get(eic_mz) 2688 else: 2689 eic_data = None 2690 2691 if eic_data: 2692 # Induced features - even thinner line 2693 linewidth = 0.5 2694 alpha = 0.4 2695 color = 'tab:orange' 2696 2697 if label_samples: 2698 label = f"{sample_name} (induced)" 2699 else: 2700 label = "Gap-filled features" if not induced_labeled else None 2701 induced_labeled = True 2702 2703 ax.plot( 2704 eic_data.time, 2705 eic_data.eic, 2706 c=color, 2707 linewidth=linewidth, 2708 alpha=alpha, 2709 linestyle='-', 2710 label=label 2711 ) 2712 2713 if plot_datapoints: 2714 ax.scatter( 2715 eic_data.time, 2716 eic_data.eic, 2717 c=color, 2718 alpha=alpha, 2719 s=5 2720 ) 2721 2722 if plot_smoothed and hasattr(eic_data, 'eic_smoothed'): 2723 ax.plot( 2724 eic_data.time, 2725 eic_data.eic_smoothed, 2726 c=color, 2727 linestyle='--', 2728 alpha=alpha * 0.8, 2729 linewidth=linewidth * 0.8 2730 ) 2731 2732 # Add vertical line at median RT 2733 ax.axvline( 2734 x=median_rt, 2735 color='k', 2736 linestyle='--', 2737 alpha=0.7, 2738 label='Median RT' 2739 ) 2740 2741 ax.set_ylabel("Intensity") 2742 ax.set_xlabel("Time (minutes)") 2743 ax.set_xlim( 2744 median_rt - eic_buffer_time, 2745 median_rt + eic_buffer_time, 2746 ) 2747 ax.legend(loc='upper left', fontsize=8) 2748 ax.yaxis.get_major_formatter().set_useOffset(False) 2749 2750 def clean_sparse_matrix(self, sparse_matrix): 2751 """Clean a sparse matrix by removing duplicates and sorting. 2752 2753 Parameters 2754 ---------- 2755 sparse_matrix : :obj:`~numpy.array` 2756 A sparse matrix to clean. 2757 2758 Returns 2759 ------- 2760 :obj:`~numpy.array` 2761 A cleaned sparse matrix. 2762 """ 2763 for match in sparse_matrix: 2764 match.sort() 2765 sparse_matrix.sort() 2766 dereplicated_sparse_matrix = np.unique(sparse_matrix, axis=0) 2767 return dereplicated_sparse_matrix 2768 2769 def match_mfs(self, mf_c, mf_i): 2770 """Match mass features between two LCMS objects. 2771 2772 Parameters 2773 ---------- 2774 mf_c : :obj:`~pandas.DataFrame` 2775 The mass features to match against. 2776 mf_i : :obj:`~pandas.DataFrame` 2777 The mass features to match. 2778 2779 Returns 2780 ------- 2781 :obj:`~pandas.DataFrame` 2782 The matched mass features from mf_c. 2783 :obj:`~pandas.DataFrame` 2784 The matched mass features from mf_i. 2785 2786 Notes 2787 ----- 2788 This function has been adapted from the original implementation in the Deimos package: 2789 https://github.com/pnnl/deimos 2790 """ 2791 if mf_c is None or mf_i is None or len(mf_c.index) < 1 or len(mf_i.index) < 1: 2792 return None, None 2793 2794 # Prepare dataframes 2795 mf_c = mf_c.copy() 2796 mf_c["id_i"] = 0 2797 mf_i = mf_i.copy() 2798 mf_i["id_i"] = 1 2799 2800 # Set dimensions for matching 2801 dims = ["mz", "scan_time"] 2802 relative = [True, False] 2803 mz_tol = self.parameters.lcms_collection.alignment_mz_tol_ppm * 1e-6 2804 rt_tol = self.parameters.lcms_collection.alignment_rt_tol 2805 tol = [mz_tol, rt_tol] 2806 2807 # Compute inter-feature distances 2808 idx = [] 2809 for i, f in enumerate(dims): 2810 # vectors 2811 v1 = mf_c[f].values.reshape(-1, 1) 2812 v2 = mf_i[f].values.reshape(-1, 1) 2813 2814 # Distances 2815 d = scipy.spatial.distance.cdist(v1, v2) 2816 2817 if relative[i] is True: 2818 # Divisor 2819 basis = np.repeat(v1, v2.shape[0], axis=1) 2820 fix = np.repeat(v2, v1.shape[0], axis=1).T 2821 basis = np.where(basis == 0, fix, basis) 2822 2823 # Divide 2824 d = np.divide(d, basis, out=np.zeros_like(basis), where=basis != 0) 2825 2826 # Check tol 2827 idx.append(d <= tol[i]) 2828 2829 # Stack truth arrays 2830 idx = np.prod(np.dstack(idx), axis=-1, dtype=bool) 2831 2832 # Compute normalized 3d distance 2833 v1 = mf_c[dims].values / tol 2834 v2 = mf_i[dims].values / tol 2835 dist3d = scipy.spatial.distance.cdist(v1, v2, "cityblock") 2836 2837 # Separate features within tolerance from those outside 2838 # Features outside tolerance should be inf, features within tolerance keep their distance 2839 # Use idx mask: True for within tolerance, False for outside 2840 dist3d_within_tol = np.where(idx, dist3d, np.inf) 2841 2842 # Normalize to 0-1 (only affects within-tolerance distances) 2843 mx = np.max(dist3d_within_tol[idx]) if np.sum(idx) > 0 else 0 2844 if mx > 0: 2845 # Lower distance is better - normalize only the within-tolerance values 2846 dist3d_within_tol = np.where(idx, dist3d_within_tol / mx, np.inf) 2847 else: 2848 # All matches are perfect (distance=0), assign tiny value to within-tolerance pairs 2849 dist3d_within_tol = np.where(idx, 1e-10, np.inf) 2850 2851 # Use the masked distance matrix 2852 dist3d = dist3d_within_tol 2853 2854 # Min over dims 2855 mincols = np.min(dist3d, axis=0, keepdims=True) 2856 2857 # Zero out mincols over dims 2858 dist3d[dist3d != mincols] = np.inf 2859 2860 # Min over clusters 2861 minrows = np.min(dist3d, axis=1, keepdims=True) 2862 2863 # Where max and nonzero 2864 ii, jj = np.where((dist3d == minrows) & (dist3d < np.inf)) 2865 2866 # Reorder 2867 mf_c = mf_c.iloc[ii] 2868 mf_i = mf_i.iloc[jj] 2869 2870 if len(mf_c.index) < 1 or len(mf_i.index) < 1: 2871 return None, None 2872 2873 return mf_c, mf_i 2874 2875 def fit_rts(self, a, b, align="scan_time", **kwargs): 2876 """ 2877 Fit a support vector regressor to matched features. 2878 2879 Parameters 2880 ---------- 2881 a : :obj:`~pandas.DataFrame` 2882 First set of input feature coordinates and intensities; the center object and the object to align to. 2883 b : :obj:`~pandas.DataFrame` 2884 Second set of input feature coordinates and intensities; the object to align to the center object. 2885 align : str 2886 Dimension to align. 2887 kwargs 2888 Keyword arguments for support vector regressor 2889 (:class:`sklearn.svm.SVR`). 2890 2891 Returns 2892 ------- 2893 :obj:`~function` 2894 An interpolation function where one can input a retention time and get the predicted retention time. 2895 2896 Notes 2897 ----- 2898 This function has been adapted from the original implementation in the Deimos package: 2899 https://github.com/pnnl/deimos 2900 2901 """ 2902 2903 # Uniqueify 2904 x = a[align].values 2905 y = b[align].values 2906 arr = np.vstack((x, y)).T 2907 arr = np.unique(arr, axis=0) 2908 2909 # Safety check: ensure we have data to work with 2910 if len(arr) == 0: 2911 warnings.warn("No data points available for retention time fitting. Returning identity function.") 2912 return lambda x: x 2913 2914 # Check kwargs 2915 if "kernel" in kwargs: 2916 kernel = kwargs.get("kernel") 2917 else: 2918 kernel = "linear" 2919 2920 # Construct interpolation axis 2921 newx = np.linspace(arr[:, 0].min(), arr[:, 0].max(), 1000) 2922 2923 # Linear kernel 2924 if kernel == "linear": 2925 reg = scipy.stats.linregress(x, y) 2926 newy = reg.slope * newx + reg.intercept 2927 2928 # Other kernels 2929 else: 2930 # Fit 2931 svr = SVR(**kwargs) 2932 svr.fit(arr[:, 1].reshape(-1, 1), arr[:, 0]) 2933 2934 # Predict 2935 newy = svr.predict(newx.reshape(-1, 1)) 2936 2937 # Pad x and y_pred with zeros to force interpolation to start at 0 2938 newx = np.concatenate(([0], newx)) 2939 newy = np.concatenate(([0], newy)) 2940 2941 # 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 2942 max_time = self[0].scan_df["scan_time"].max() 2943 newx = np.concatenate((newx, [max_time])) 2944 newy = np.concatenate((newy, [max_time])) 2945 2946 # Return an interpolation function for the x and y_pred 2947 def interp(x): 2948 pred_y = np.interp(x, newx, newy) 2949 return pred_y 2950 2951 return interp 2952 2953 def get_anchor_mass_features(self, mf_df): 2954 """ 2955 Get the anchor mass features from a DataFrame of mass features. 2956 2957 Parameters 2958 ---------- 2959 mf_df : :obj:`~pandas.DataFrame` 2960 The mass features to filter to just the anchor mass features. 2961 2962 Returns 2963 ------- 2964 :obj:`~pandas.DataFrame` 2965 The anchor mass features dataframe. 2966 """ 2967 mf_df = mf_df.copy() 2968 2969 if ( 2970 "deconvoluted_mass_spectra" 2971 in self.parameters.lcms_collection.mass_feature_anchor_technique 2972 ): 2973 # Drop features that are not mass_spectrum_deconvoluted_parent or are NA as mass_spectrum_deconvoluted_parent 2974 mf_df = mf_df.dropna(subset=["mass_spectrum_deconvoluted_parent"]) 2975 mf_df = mf_df[mf_df["mass_spectrum_deconvoluted_parent"]] 2976 2977 if ( 2978 "absolute_intensity" 2979 in self.parameters.lcms_collection.mass_feature_anchor_technique 2980 ): 2981 # Drop features that have an intensity lower than the threshold 2982 threshold = self.parameters.lcms_collection.mass_feature_anchor_absolute_intensity_threshold 2983 mf_df = mf_df[mf_df["intensity"] > threshold] 2984 2985 if ( 2986 "relative_intensity" 2987 in self.parameters.lcms_collection.mass_feature_anchor_technique 2988 ): 2989 # Drop features in the lower fraction of intensities 2990 threshold_quantile = self.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold 2991 intensity_threshold = mf_df["intensity"].quantile(threshold_quantile) 2992 mf_df = mf_df[mf_df["intensity"] >= intensity_threshold] 2993 2994 return mf_df 2995 2996 def attempt_alignment(self, matches_c, matches_i): 2997 """ 2998 Check if alignment is needed for the LCMS objects in the collection. 2999 """ 3000 3001 # Hold out a subset of matches_c and matches_i for spline fitting 3002 matches_c.reset_index(drop=False, inplace=True) 3003 matches_i.reset_index(drop=False, inplace=True) 3004 3005 # Check if there are enough matches to attempt alignment 3006 minimum_matches = self.parameters.lcms_collection.alignment_minimum_matches 3007 if len(matches_c) < minimum_matches: 3008 # Return False (no alignment) and identity function (returns original time) 3009 # which isn't used but is a placeholder to avoid errors in downstream code since 3010 # the function expects a callable to be returned 3011 return False, lambda x: x 3012 3013 # Rearrange matches_c and matches_i to be in the order of the scan_time of matches_c 3014 matches_c = matches_c.sort_values(by="scan_time") 3015 matches_i = matches_i.iloc[matches_c.index.values] 3016 3017 hold_out_fraction = self.parameters.lcms_collection.alignment_hold_out_fraction 3018 # starting with an array of length len(matches_c), select equally spaced indices to hold out 3019 idx_holdout = matches_c.index.values[ 3020 np.arange(0, len(matches_c), int(1 / hold_out_fraction)) 3021 ] 3022 3023 matches_c_holdout = matches_c.loc[idx_holdout].copy() 3024 matches_i_holdout = matches_i.loc[idx_holdout].copy() 3025 3026 # Remove the holdout matches from the matches_c and matches_i DataFrames and reset the index 3027 matches_c = matches_c.drop(index=idx_holdout).set_index("sample_name") 3028 matches_i = matches_i.drop(index=idx_holdout).set_index("sample_name") 3029 3030 # Reset the scan_time to the original scan_time 3031 matches_i = matches_i.copy() 3032 matches_i["scan_time"] = matches_i["scan_time_og"] 3033 3034 # Fit the retention times of the LCMS object to the center LCMS object using the matched mass features 3035 spl = self.fit_rts(matches_c, matches_i, kernel="rbf", C=1000) 3036 3037 # Check if the spline fitting improved the alignment for the holdout matches 3038 matches_i_holdout["scan_time_fit"] = spl(matches_i_holdout["scan_time"]) 3039 og_diff = np.abs( 3040 matches_i_holdout["scan_time"] - matches_c_holdout["scan_time"] 3041 ) 3042 fit_diff = np.abs( 3043 matches_i_holdout["scan_time_fit"] - matches_c_holdout["scan_time"] 3044 ) 3045 3046 if ( 3047 "fraction_improved" 3048 in self.parameters.lcms_collection.alignment_acceptance_technique 3049 ): 3050 fraction_improved = np.sum(fit_diff < og_diff) / len(og_diff) 3051 use_spline_alignment = ( 3052 fraction_improved 3053 > self.parameters.lcms_collection.alignment_acceptance_fraction_improved_threshold 3054 ) 3055 if ( 3056 "mean_squared_error_improved" 3057 in self.parameters.lcms_collection.alignment_acceptance_technique 3058 ): 3059 mse_og = np.mean(og_diff**2) 3060 mse = np.mean(fit_diff**2) 3061 use_spline_alignment = mse < mse_og 3062 # Convert to boolean 3063 use_spline_alignment = bool(use_spline_alignment) 3064 3065 return use_spline_alignment, spl 3066 3067 def align_lcms_objects(self, overwrite=False): 3068 """ 3069 Align LCMS objects in the collection. 3070 3071 Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects. 3072 First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects, 3073 starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects 3074 are then fit to the center LCMS object using the matched mass features. 3075 3076 Returns 3077 ------- 3078 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. 3079 3080 Notes 3081 ----- 3082 This function has been adapted from the original implementation in the Deimos package: 3083 https://github.com/pnnl/deimos 3084 """ 3085 3086 # Prepare the center LCMS object 3087 center_obj_ids = self.manifest_dataframe[ 3088 self.manifest_dataframe["center"] 3089 ].collection_id.values 3090 3091 full_mf_df = self.mass_features_dataframe 3092 # re-index to sample_name for faster lookups 3093 full_mf_df = full_mf_df.reset_index().set_index("sample_name") 3094 samples_with_features = set(full_mf_df.index.get_level_values("sample_name")) 3095 3096 if "scan_time_aligned" in full_mf_df.columns and not overwrite: 3097 raise ValueError("Mass features have already been aligned") 3098 3099 def _set_scan_time_alignment_for_sample(sample_idx, use_alignment, spline): 3100 """Set scan_time_aligned for one sample using spline or identity mapping.""" 3101 if use_alignment and spline is not None: 3102 self[sample_idx]._scan_info["scan_time_aligned"] = { 3103 k: spline(v) for k, v in self[sample_idx]._scan_info["scan_time"].items() 3104 } 3105 return True 3106 3107 self[sample_idx]._scan_info["scan_time_aligned"] = self[sample_idx]._scan_info[ 3108 "scan_time" 3109 ].copy() 3110 return False 3111 3112 def _get_feature_df_at_or_after(start_idx, index_step, use_alignment, spline): 3113 """Return next sample index/dataframe with features, aligning empty samples on the way.""" 3114 i = start_idx 3115 while 0 <= i < len(self): 3116 sample_name = self.samples[i] 3117 if sample_name in samples_with_features: 3118 mf_df_i = full_mf_df.loc[sample_name].copy() 3119 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3120 mf_df_i = mf_df_i.reset_index(drop=False) 3121 if use_alignment and spline is not None: 3122 # Use previous step transform as a better matching starting point. 3123 mf_df_i["scan_time"] = spline(mf_df_i["scan_time"]) 3124 return i, mf_df_i 3125 3126 _set_scan_time_alignment_for_sample(i, use_alignment, spline) 3127 self.rt_alignment_attempted = True 3128 i += index_step 3129 3130 return i, None 3131 3132 anchor_mf_dfs = [] 3133 for center_obj_id in center_obj_ids: 3134 # Get the anchor mass features from the center LCMS object 3135 mf_df_c = full_mf_df.loc[self.samples[center_obj_id]] 3136 mf_df_c = self.get_anchor_mass_features(mf_df_c) 3137 anchor_mf_dfs.append(mf_df_c) 3138 3139 # Set scan_time_aligned to scan_time for the center LCMS object 3140 center_scan_df = self[center_obj_id].scan_df.copy() 3141 center_scan_df["scan_time_aligned"] = center_scan_df["scan_time"] 3142 self[center_obj_id].scan_df = center_scan_df 3143 3144 # Store alignment data for center object (identity mapping) 3145 center_sample_name = self.samples[center_obj_id] 3146 3147 index_steps = (1, -1) 3148 # Run this twice, once going forward (+1 indexing) and once going backward (-1 indexing) 3149 for index_step in index_steps: 3150 # Initialize spline for propagation to samples without features 3151 spl = None 3152 use_spline_alignment = False 3153 3154 # Loop through the other LCMS objects in this direction. 3155 i, mf_df_i = _get_feature_df_at_or_after( 3156 center_obj_id + index_step, 3157 index_step, 3158 use_spline_alignment, 3159 spl, 3160 ) 3161 3162 while mf_df_i is not None: 3163 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3164 3165 # Match the mass features in the LCMS object to the anchor mass features in the center LCMS object. 3166 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3167 3168 if matches_c is not None: 3169 use_spline_alignment, spl = self.attempt_alignment( 3170 matches_c, matches_i 3171 ) 3172 3173 # Record if we used alignment for this sample 3174 sample_name = self.samples[i] 3175 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3176 use_spline_alignment 3177 ) 3178 3179 if use_spline_alignment: 3180 # Set new retention times on scan_df for lc_obj using the spline fitting 3181 matches_i["scan_time_fit"] = spl(matches_i["scan_time"]) 3182 3183 self.rt_aligned = _set_scan_time_alignment_for_sample( 3184 i, use_spline_alignment, spl 3185 ) 3186 self.rt_alignment_attempted = True 3187 3188 i, mf_df_i = _get_feature_df_at_or_after( 3189 i + index_step, 3190 index_step, 3191 use_spline_alignment, 3192 spl, 3193 ) 3194 else: 3195 # If no matches are found, propagate prior alignment from this index step. 3196 sample_name = self.samples[i] 3197 used_previous_alignment = use_spline_alignment and spl is not None 3198 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3199 used_previous_alignment 3200 ) 3201 3202 self.rt_aligned = _set_scan_time_alignment_for_sample( 3203 i, used_previous_alignment, spl 3204 ) 3205 self.rt_alignment_attempted = True 3206 3207 i, mf_df_i = _get_feature_df_at_or_after( 3208 i + index_step, 3209 index_step, 3210 used_previous_alignment, 3211 spl, 3212 ) 3213 3214 # Now align each batch using the center objects as anchors with the other batches 3215 mf_df_c = anchor_mf_dfs[0] 3216 for i in center_obj_ids[1:]: 3217 mf_df_i = full_mf_df.loc[self.samples[i]].copy() 3218 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3219 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3220 3221 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3222 if matches_c is not None: 3223 use_spline_alignment, spl = self.attempt_alignment(matches_c, matches_i) 3224 3225 # Record if we used alignment for this sample 3226 sample_name = self.samples[i] 3227 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3228 use_spline_alignment 3229 ) 3230 3231 if use_spline_alignment: 3232 # Set new retention times on all this object's 3233 new_times = spl(self[i].scan_df["scan_time"]) 3234 new_scan_info = self[i].scan_df.copy() 3235 new_scan_info["scan_time_aligned"] = new_times 3236 self[i].scan_df = new_scan_info 3237 3238 3239 # Get the batch that this object belongs to 3240 batch = self.manifest[self.samples[i]]["batch"] 3241 3242 for j in range(len(self)): 3243 if self.manifest[self.samples[j]]["batch"] == batch: 3244 if j != i: 3245 sample_name_j = self.samples[j] 3246 self._manifest_dict[sample_name_j]["use_rt_alignment"] = ( 3247 use_spline_alignment 3248 ) 3249 new_scan_info = self[j].scan_df.copy() 3250 aligned_times = spl(self[j].scan_df["scan_time_aligned"]) 3251 new_scan_info["scan_time_aligned"] = aligned_times 3252 self[j].scan_df = new_scan_info 3253 3254 # Set final mass_features_dataframe with the aligned scan_time 3255 center_sample_name = self.samples[center_obj_ids[0]] 3256 self._manifest_dict[center_sample_name]["use_rt_alignment"] = False 3257 new_scan_info = self[center_obj_ids[0]].scan_df.copy() 3258 new_scan_info["scan_time_aligned"] = new_scan_info["scan_time"] 3259 3260 def add_consensus_mass_features(self): 3261 """ 3262 Create consensus mass features by clustering aligned features across samples. 3263 3264 This method clusters mass features from all samples in the collection based on 3265 their m/z and aligned retention time proximity. Features that cluster together 3266 across samples are assigned a common cluster ID, creating consensus features 3267 that represent the same compound detected across multiple samples. 3268 3269 The clustering process: 3270 1. Partitions features by m/z to avoid large sparse matrices and enable parallelization 3271 2. Clusters features within each partition using hierarchical clustering 3272 3. Merges partition-boundary clusters that represent the same feature 3273 4. Filters out clusters not present in minimum fraction of samples 3274 3275 Must be run after align_lcms_objects(). Results are stored in the 3276 mass_features_dataframe with a 'cluster' column added. 3277 3278 Parameters 3279 ---------- 3280 None 3281 Uses parameters from self.parameters.lcms_collection: 3282 - consensus_mz_tol_ppm: m/z tolerance for clustering (ppm) 3283 - consensus_rt_tol: retention time tolerance for clustering (minutes) 3284 - consensus_partition_size: target partition size for managing memory and parallelization 3285 - consensus_min_sample_fraction: minimum fraction of samples a cluster 3286 must appear in to be retained (0-1) 3287 - cores: number of CPU cores to use for parallel partition processing 3288 3289 Returns 3290 ------- 3291 None 3292 Updates self.mass_features_dataframe in place by adding 'cluster' column 3293 and filtering to retain only clusters meeting minimum sample presence. 3294 3295 Raises 3296 ------ 3297 ValueError 3298 If mass features have not been aligned (run align_lcms_objects() first). 3299 3300 Notes 3301 ----- 3302 - Partitioning prevents memory issues with large sparse distance matrices 3303 - Each partition is processed in parallel (up to cores limit) 3304 - Clusters not meeting consensus_min_sample_fraction are automatically removed 3305 - Access cluster_summary_dataframe property for summary statistics 3306 - Use fill_missing_cluster_features() for gap-filling after clustering 3307 3308 See Also 3309 -------- 3310 align_lcms_objects : Aligns retention times before consensus clustering 3311 cluster_summary_dataframe : Property that generates summary statistics for clusters 3312 fill_missing_cluster_features : Gap-fill missing features in clusters 3313 """ 3314 # Get the combined mass features from all LCMS objects, keep the original index as a separate column 3315 combined_mfs = self.mass_features_dataframe.copy() 3316 combined_mfs["coll_mf_id"] = combined_mfs.index 3317 3318 # Check if the mass features have been aligned 3319 if "scan_time_aligned" not in combined_mfs.columns: 3320 raise ValueError( 3321 "Mass features have not been aligned, run align_lcms_objects() first" 3322 ) 3323 3324 # Partition the mass features by mz so we can parallelize the matching before clustering 3325 from corems.chroma_peak.calc import subset as corems_subset 3326 3327 # get max mz from combined_mfs and calculate tolerance from ppm 3328 mz_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 3329 n_partition_size = self.parameters.lcms_collection.consensus_partition_size 3330 lazy_partitions = corems_subset.multi_sample_partition( 3331 combined_mfs, 3332 split_on="mz", 3333 size=n_partition_size, 3334 tol=mz_tol, 3335 relative=True, 3336 ) 3337 3338 # If any of lazy_partitions._counts is 2xn_partition_size, issue a warning 3339 if np.array(lazy_partitions._counts).max() > 2 * n_partition_size: 3340 warnings.warn( 3341 "Some partitions are larger than 2x the goal partition size. Consider increasing the partition or decreasing the mz_tol." 3342 ) 3343 3344 # Cluster the mass features within each partition 3345 if self.parameters.lcms_collection.cores > lazy_partitions.n_partitions: 3346 cores_to_use = lazy_partitions.n_partitions 3347 else: 3348 cores_to_use = self.parameters.lcms_collection.cores 3349 # mfs_with_clusters = lazy_partitions.map(self.cluster_mass_features, processes=cores_to_use) 3350 mfs_with_clusters = lazy_partitions.map( 3351 self.cluster_mass_features_agg_cluster, processes=cores_to_use 3352 ) 3353 3354 # Clean up cluster id names after partitioning 3355 new_cluster_ids = ( 3356 mfs_with_clusters[["cluster", "partition_idx"]] 3357 .drop_duplicates() 3358 .reset_index(drop=True) 3359 ) 3360 new_cluster_ids["cluster_unqiue"] = new_cluster_ids.index 3361 mfs_with_clusters = mfs_with_clusters.merge( 3362 new_cluster_ids, on=["cluster", "partition_idx"] 3363 ) 3364 mfs_with_clusters["cluster"] = mfs_with_clusters["cluster_unqiue"] 3365 mfs_with_clusters = mfs_with_clusters.drop(columns=["cluster_unqiue"]) 3366 3367 # Embed a new cluster id into the mass features dataframe and set as index 3368 mfs_with_clusters["idx"] = mfs_with_clusters.index 3369 3370 try: 3371 # Check if any clusters can be merged into a single cluster 3372 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3373 3374 # Merge clusters identified in eval_dict 3375 while len(eval_dict["merge_these_clusters"]) > 0: 3376 list_of_clusters_to_merge = [ 3377 [x[0], x[1]] for x in eval_dict["merge_these_clusters"] 3378 ] 3379 # Convert to a dataframe with columns "new_cluster" and "cluster" 3380 df = pd.DataFrame( 3381 np.array(list_of_clusters_to_merge), columns=["new_cluster", "cluster"] 3382 ) 3383 # Drop duplicates of "child" clusters 3384 df = df.drop_duplicates("cluster", keep="first") 3385 df = df.drop_duplicates("new_cluster", keep="first") 3386 mfs_with_clusters = mfs_with_clusters.merge(df, on="cluster", how="left") 3387 mfs_with_clusters["cluster"] = mfs_with_clusters["new_cluster"].fillna( 3388 mfs_with_clusters["cluster"] 3389 ) 3390 mfs_with_clusters = mfs_with_clusters.drop(columns=["new_cluster"]) 3391 3392 # Re-evaluate clusters for repeats 3393 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3394 self.mass_features_dataframe = mfs_with_clusters 3395 3396 except: 3397 mfs_with_clusters.set_index('coll_mf_id', inplace = True) 3398 self.mass_features_dataframe = mfs_with_clusters 3399 3400 # Filter out clusters that don't meet minimum sample fraction 3401 self._filter_clusters_by_sample_presence() 3402 3403 # TODO KRH: Deal with isomers better? Pool them together and then split them out using samples with 2 as the template? 3404 3405 def _filter_clusters_by_sample_presence(self): 3406 """ 3407 Filter out clusters that don't meet the minimum sample fraction threshold. 3408 3409 Removes clusters (and their associated mass features) from the mass_features_dataframe 3410 if they don't appear in at least consensus_min_sample_fraction of samples. 3411 3412 This is called automatically at the end of add_consensus_mass_features(). 3413 3414 Returns 3415 ------- 3416 None 3417 Updates self.mass_features_dataframe in place by removing clusters that don't 3418 meet the minimum sample presence threshold. 3419 """ 3420 if self.mass_features_dataframe is None or len(self.mass_features_dataframe) == 0: 3421 return 3422 3423 min_sample_fraction = self.parameters.lcms_collection.consensus_min_sample_fraction 3424 3425 # Validate parameter 3426 if not 0 <= min_sample_fraction <= 1: 3427 raise ValueError("consensus_min_sample_fraction must be between 0 and 1") 3428 3429 # Calculate minimum number of samples required 3430 total_samples = len(self.samples) 3431 min_samples_required = min_sample_fraction * total_samples 3432 3433 # Count unique samples per cluster 3434 cluster_sample_counts = ( 3435 self.mass_features_dataframe.groupby('cluster')['sample_id'] 3436 .nunique() 3437 .reset_index(name='sample_count') 3438 ) 3439 3440 # Identify clusters to keep 3441 clusters_to_keep = cluster_sample_counts[ 3442 cluster_sample_counts['sample_count'] > min_samples_required 3443 ]['cluster'].values 3444 3445 # Filter mass features dataframe 3446 self.mass_features_dataframe = self.mass_features_dataframe[ 3447 self.mass_features_dataframe['cluster'].isin(clusters_to_keep) 3448 ] 3449 3450 def summarize_clusters(self): 3451 """ 3452 Generate summary statistics for consensus mass feature clusters. 3453 3454 Computes aggregate statistics (median, mean, std, min, max) for each cluster 3455 across all samples. Combines both regular mass features and induced mass features 3456 (from gap-filling) when available to provide complete cluster statistics. 3457 3458 Must be run after add_consensus_mass_features() which creates the cluster assignments. 3459 Results are stored in cluster_summary_dataframe property and used by plotting methods. 3460 3461 Parameters 3462 ---------- 3463 None 3464 Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe. 3465 Both must contain 'cluster' column. 3466 3467 Returns 3468 ------- 3469 :obj:`~pandas.DataFrame` or None 3470 DataFrame with one row per cluster containing summary statistics: 3471 - cluster: cluster ID 3472 - mz_{median,mean,std,max,min}: m/z statistics 3473 - scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics 3474 - half_height_width_{median,mean,std,max,min}: peak width statistics 3475 - tailing_factor_{median,mean,std,max,min}: peak shape statistics 3476 - dispersity_index_{median,mean,std,max,min}: peak quality statistics 3477 - sample_id_nunique: number of unique samples containing the cluster 3478 - intensity_{max,median,mean,std,min}: intensity statistics 3479 - persistence_{max,median,mean,std,min}: persistence statistics 3480 3481 Returns None if mass_features_dataframe is empty. 3482 3483 Notes 3484 ----- 3485 - Summary DataFrame is automatically stored in cluster_summary_dataframe property 3486 - Includes both regular and induced (gap-filled) mass features when available 3487 - Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster 3488 - Sample count (sample_id_nunique) indicates cluster prevalence across samples 3489 - Filters applied by consensus_min_sample_fraction affect which clusters appear 3490 3491 See Also 3492 -------- 3493 add_consensus_mass_features : Creates clusters before summarization 3494 fill_missing_cluster_features : Creates induced mass features via gap-filling 3495 plot_consensus_mz_features : Visualizes cluster summaries 3496 plot_mz_features_per_cluster : Shows cluster size distribution 3497 """ 3498 # First check if there are minimum columns in the features dataframe 3499 if len(self.mass_features_dataframe.columns) < 1: 3500 return None 3501 3502 # Combine regular and induced mass features 3503 mf_df = self.mass_features_dataframe.copy() 3504 mf_df = mf_df.reset_index(drop=False) 3505 3506 # Check if induced mass features are available and combine them 3507 if self.induced_mass_features_dataframe is not None and len(self.induced_mass_features_dataframe) > 0: 3508 imf_df = self.induced_mass_features_dataframe.copy() 3509 imf_df = imf_df.reset_index(drop=False) 3510 # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination 3511 # Combine regular and induced features 3512 mf_df = pd.concat([mf_df, imf_df], axis=0) 3513 mf_df = mf_df.reset_index(drop=True) 3514 3515 # Filter out any rows with NaN cluster values before converting to int 3516 if 'cluster' in mf_df.columns: 3517 mf_df = mf_df.dropna(subset=['cluster']) 3518 mf_df['cluster'] = mf_df['cluster'].astype(int) 3519 3520 # Build aggregation dictionary based on available columns 3521 agg_dict = { 3522 "mz": ["median", "mean", "std", "max", "min"], 3523 "scan_time_aligned": ["median", "mean", "std", "max", "min"], 3524 "sample_id": ["nunique"], 3525 "intensity": ["max", "median", "mean", "std", "min"], 3526 } 3527 3528 # Add optional columns if they exist 3529 optional_columns = { 3530 "half_height_width": ["median", "mean", "std", "max", "min"], 3531 "tailing_factor": ["median", "mean", "std", "max", "min"], 3532 "dispersity_index": ["median", "mean", "std", "max", "min"], 3533 "persistence": ["max", "median", "mean", "std", "min"], 3534 } 3535 3536 for col, funcs in optional_columns.items(): 3537 if col in mf_df.columns: 3538 agg_dict[col] = funcs 3539 3540 summary_df = ( 3541 mf_df.groupby("cluster") 3542 .agg(agg_dict) 3543 .reset_index() 3544 ) 3545 3546 # Fix the column names 3547 summary_df.columns = [ 3548 "_".join(col).strip() 3549 for col in summary_df.columns.values 3550 if col != "cluster" 3551 ] 3552 summary_df = summary_df.rename(columns={"cluster_": "cluster"}) 3553 # Set cluster as the index for easy lookup 3554 summary_df = summary_df.set_index('cluster') 3555 return summary_df 3556 3557 def plot_mz_features_per_cluster(self, return_fig = False): 3558 """ 3559 Plot the number of mass features in a cluster against how many clusters 3560 contain that number of mass features 3561 3562 Parameters 3563 ----------- 3564 return_fig : boolean 3565 Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. 3566 3567 Returns 3568 -------- 3569 matplotlib.pyplot.Figure 3570 A figure displaying the frequency with which clusters contain the given number of m/z features 3571 3572 Raises 3573 ------ 3574 Warning 3575 If consensus features haven't been added to the object yet 3576 """ 3577 3578 if not hasattr(self, 'cluster_summary_dataframe'): 3579 raise ValueError( 3580 'cluster_summary_dataframe is not set, must run add_consensus_mass_features() first' 3581 ) 3582 else: 3583 sum_data = self.cluster_summary_dataframe 3584 fig, ax = plt.subplots() 3585 sum_data.sample_id_nunique.value_counts().sort_index().plot(ax = ax, kind = 'bar') 3586 plt.xlabel('Number of mass features in a cluster') 3587 plt.ylabel('Number of clusters with this many mass features') 3588 if return_fig: 3589 plt.close(fig) 3590 return fig 3591 else: 3592 plt.show() 3593 3594 def plot_mz_features_across_samples(self, alpha = 0.75, s = 0.005, return_fig = False): 3595 """ 3596 Generate Scan Time vs m/z plot of all the mass features across all 3597 samples in collection where intensity of color on the plot indicates 3598 density of mass features, NOT INTENSITY 3599 3600 Parameters 3601 ----------- 3602 alpha : float 3603 Desired transparency for plotted m/z features. Defaults to 0.75. 3604 s : float 3605 Desired size of plotted m/z features. Defaults to 0.005. 3606 return_fig : boolean 3607 Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. 3608 3609 Returns 3610 -------- 3611 matplotlib.pyplot.Figure 3612 A figure displaying a scan time vs m/z scatterplot of all the m/z features identified in the collection. 3613 Parameters alpha (transparency) and s (marker size) allow the user to emphasize the density of features. 3614 Intensity of features is not represented. 3615 """ 3616 df = self.mass_features_dataframe.copy() 3617 fig = plt.figure() 3618 plt.scatter( 3619 df.scan_time_aligned, 3620 df.mz, 3621 c = 'tab:gray', 3622 alpha = alpha, 3623 s = s 3624 ) 3625 3626 plt.xlabel('Scan time') 3627 plt.ylabel('m/z') 3628 plt.ylim(0, np.ceil(np.max(df.mz))) 3629 plt.xlim(0, np.ceil(np.max(df.scan_time))) 3630 plt.title('All mass features, all samples') 3631 3632 if return_fig: 3633 plt.close(fig) 3634 return fig 3635 else: 3636 plt.show() 3637 3638 def plot_consensus_mz_features(self, xb = 'xb', xt = 'xt', yb = 'yb', yt = 'yt', show_all = True, return_fig = False): 3639 """ 3640 Generate Scan Time vs m/z plot of the consensus features scaled by size 3641 with option ('show_all') of leaving the individual m/z features in the figure. 3642 3643 Parameters 3644 ----------- 3645 xb : float 3646 Desired starting scan time value for the x-axis. Defaults to 0. 3647 xt : float 3648 Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data. 3649 yb : float 3650 Desired starting m/z value for the y-axis. Defaults to 0. 3651 yt : float 3652 Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data. 3653 show_all : boolean 3654 Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True. 3655 return_fig : boolean 3656 Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. 3657 3658 Returns 3659 -------- 3660 matplotlib.pyplot.Figure 3661 A scalable figure that overlays the consensus features over all the m/z features identified in the collection. 3662 Consensus features are scaled by how many m/z features are represented in the consensus. Figure can be scaled by 3663 inputting desired boundaries on the scan time (xb, xt) and m/z values (yb, yt). 3664 """ 3665 df = self.cluster_summary_dataframe.copy() 3666 mfdf = self.mass_features_dataframe.copy() 3667 3668 fig = plt.figure() 3669 if show_all: 3670 plt.scatter( 3671 mfdf.scan_time_aligned, 3672 mfdf.mz, 3673 c = 'tab:gray', 3674 s = 1 3675 ) 3676 3677 m = plt.scatter( 3678 df.scan_time_aligned_median, 3679 df.mz_median, 3680 c = 'tab:orange', 3681 alpha = 0.7, 3682 s = (df.sample_id_nunique**2)/5 3683 ) 3684 3685 plt.xlabel('Scan time') 3686 plt.ylabel('m/z') 3687 3688 if xt == 'xt': 3689 xt = np.ceil(np.max(mfdf.mz)) 3690 if yt == 'yt': 3691 yt = np.ceil(np.max(mfdf.scan_time)) 3692 if xb == 'xb': 3693 xb = 0 3694 if yb == 'yb': 3695 yb = 0 3696 plt.ylim(xb, xt) 3697 plt.xlim(yb, yt) 3698 3699 kw = dict( 3700 prop = 'sizes', 3701 num = max(1, int(len(df.sample_id_nunique.unique())/3)), 3702 color = 'tab:orange', 3703 alpha = 0.7, 3704 func = lambda s: np.sqrt(s*5) 3705 ) 3706 3707 plt.legend( 3708 *m.legend_elements(**kw), 3709 title = 'Features\nper cluster', 3710 bbox_to_anchor = (1.01, 0.4, 0.225, 0.5) 3711 ) 3712 plt.tight_layout() 3713 plt.title('Consensus Features') 3714 3715 if return_fig: 3716 plt.close(fig) 3717 return fig 3718 else: 3719 plt.show() 3720 3721 def plot_cluster( 3722 self, 3723 cluster_id, 3724 to_plot=["EIC", "MS1", "MS2"], 3725 return_fig=False, 3726 plot_smoothed_eic=False, 3727 plot_eic_datapoints=False, 3728 eic_buffer_time=None, 3729 label_samples=False, 3730 molecular_metadata=None, 3731 spectral_library=None, 3732 ): 3733 """ 3734 Plot a consensus mass feature cluster across all samples. 3735 3736 Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster, 3737 highlighting the representative mass feature. 3738 3739 Parameters 3740 ---------- 3741 cluster_id : int 3742 The cluster ID to plot 3743 to_plot : list, optional 3744 List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror". 3745 Default is ["EIC", "MS1", "MS2"]. 3746 return_fig : bool, optional 3747 If True, returns the figure object. Default is False. 3748 plot_smoothed_eic : bool, optional 3749 If True, plots smoothed EICs. Default is False. 3750 plot_eic_datapoints : bool, optional 3751 If True, plots EIC data points. Default is False. 3752 eic_buffer_time : float, optional 3753 Time buffer around the peak for EIC plotting (minutes). 3754 If None, uses parameter setting. Default is None. 3755 label_samples : bool, optional 3756 If True, labels each sample in the legend. Default is False. 3757 molecular_metadata : dict, optional 3758 Dictionary mapping molecular IDs to MetaboliteMetadata objects. 3759 Required for MS2_mirror plots. Default is None. 3760 spectral_library : FlashEntropySearch, optional 3761 FlashEntropy spectral library containing MS2 spectra. 3762 Required for MS2_mirror plots to retrieve library spectra. Default is None. 3763 3764 Returns 3765 ------- 3766 matplotlib.figure.Figure or None 3767 The figure object if return_fig=True, otherwise None 3768 3769 Raises 3770 ------ 3771 ValueError 3772 If cluster_id is not found or if required data is not loaded 3773 """ 3774 import matplotlib.pyplot as plt 3775 3776 # Get cluster summary for median values 3777 if cluster_id not in self.cluster_summary_dataframe.index: 3778 raise ValueError( 3779 f"Cluster {cluster_id} not found in cluster_summary_dataframe. " 3780 f"Run add_consensus_mass_features() first." 3781 ) 3782 3783 cluster_summary = self.cluster_summary_dataframe.loc[cluster_id] 3784 3785 # Get representative mass feature info 3786 rep_info = self.get_most_representative_sample_for_cluster(cluster_id) 3787 rep_sample_id = rep_info['sample_id'] 3788 rep_mf_id = rep_info['mf_id'] 3789 rep_sample = self[rep_sample_id] 3790 3791 # Check if representative mass feature is loaded 3792 if rep_mf_id not in rep_sample.mass_features: 3793 raise ValueError( 3794 f"Representative mass feature {rep_mf_id} not loaded in sample {rep_sample.sample_name}. " 3795 f"Run reload_representative_mass_features() or process_consensus_features() first." 3796 ) 3797 3798 rep_mf = rep_sample.mass_features[rep_mf_id] 3799 3800 # Get eic buffer time 3801 if eic_buffer_time is None: 3802 eic_buffer_time = self[0].parameters.lc_ms.eic_buffer_time 3803 3804 # Adjust to_plot based on available data 3805 if rep_mf.mass_spectrum is None: 3806 to_plot = [x for x in to_plot if x != "MS1"] 3807 if len(rep_mf.ms2_mass_spectra) == 0: 3808 to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]] 3809 3810 # Check if EICs are available 3811 cluster_mfs = self.mass_features_dataframe[ 3812 self.mass_features_dataframe['cluster'] == cluster_id 3813 ] 3814 3815 has_eics = False 3816 # Check regular features 3817 for _, row in cluster_mfs.iterrows(): 3818 sample_id = int(row['sample_id']) 3819 sample = self[sample_id] 3820 if hasattr(sample, 'eics') and sample.eics: 3821 if len(sample.eics) > 0: 3822 has_eics = True 3823 break 3824 3825 # Also check induced features if available 3826 induced_cluster_mfs = None 3827 if not has_eics and self.induced_mass_features_dataframe is not None: 3828 induced_cluster_mfs = self.induced_mass_features_dataframe[ 3829 self.induced_mass_features_dataframe['cluster'] == cluster_id 3830 ] 3831 for _, row in induced_cluster_mfs.iterrows(): 3832 sample_id = int(row['sample_id']) 3833 sample = self[sample_id] 3834 if hasattr(sample, 'eics') and sample.eics: 3835 if len(sample.eics) > 0: 3836 has_eics = True 3837 break 3838 3839 if not has_eics: 3840 to_plot = [x for x in to_plot if x != "EIC"] 3841 if len(to_plot) == 0: 3842 raise ValueError( 3843 f"No plottable data available for cluster {cluster_id}. " 3844 f"Run process_consensus_features(gather_eics=True, add_ms1=True, add_ms2=True) first." 3845 ) 3846 3847 # Get induced features if not already retrieved 3848 if induced_cluster_mfs is None and self.induced_mass_features_dataframe is not None: 3849 induced_cluster_mfs = self.induced_mass_features_dataframe[ 3850 self.induced_mass_features_dataframe['cluster'] == cluster_id 3851 ] 3852 3853 # Check if MS1 is deconvoluted 3854 deconvoluted = rep_mf._ms_deconvoluted_idx is not None 3855 3856 # Create figure 3857 fig, axs = plt.subplots( 3858 len(to_plot), 1, figsize=(10, len(to_plot) * 4), squeeze=False 3859 ) 3860 3861 fig.suptitle( 3862 f"Consensus Cluster {cluster_id}: " 3863 f"m/z = {cluster_summary['mz_median']:.4f} " 3864 f"(±{cluster_summary['mz_std']:.4f}); " 3865 f"RT = {cluster_summary['scan_time_aligned_median']:.2f} min " 3866 f"(±{cluster_summary['scan_time_aligned_std']:.2f}); " 3867 f"{int(cluster_summary['sample_id_nunique'])} samples" 3868 ) 3869 3870 i = 0 3871 3872 # EIC plot - show all samples using helper method 3873 if "EIC" in to_plot: 3874 self._plot_multiple_eics( 3875 axs[i][0], 3876 cluster_mfs, 3877 induced_cluster_mfs, 3878 rep_sample_id, 3879 rep_mf_id, 3880 cluster_summary['scan_time_aligned_median'], 3881 eic_buffer_time, 3882 plot_smoothed=plot_smoothed_eic, 3883 plot_datapoints=plot_eic_datapoints, 3884 label_samples=label_samples, 3885 lcms_collection=self 3886 ) 3887 i += 1 3888 3889 # MS1 plot - from representative using helper method 3890 if "MS1" in to_plot: 3891 rep_mf._plot_ms1_spectrum( 3892 axs[i][0], 3893 deconvoluted=deconvoluted, 3894 sample_name=rep_sample.sample_name 3895 ) 3896 i += 1 3897 3898 # MS2 plot - from representative using helper method 3899 if "MS2" in to_plot: 3900 rep_mf._plot_ms2_spectrum(axs[i][0], sample_name=rep_sample.sample_name) 3901 i += 1 3902 3903 # MS2 mirror plot - from representative using helper method 3904 if "MS2_mirror" in to_plot: 3905 rep_mf._plot_ms2_mirror(axs[i][0], molecular_metadata=molecular_metadata, spectral_library=spectral_library) 3906 i += 1 3907 3908 plt.tight_layout() 3909 3910 if return_fig: 3911 plt.close(fig) 3912 return fig 3913 else: 3914 plt.show() 3915 return None 3916 3917 def get_representative_mass_features_for_all_clusters(self, representative_metric=None): 3918 """ 3919 Get the most representative mass feature for all clusters in bulk. 3920 3921 This is much more efficient than calling get_most_representative_sample_for_cluster 3922 in a loop, as it processes all clusters in a single pass over the dataframe. 3923 3924 Parameters 3925 ---------- 3926 representative_metric : str, optional 3927 The metric to use to determine the most representative sample. 3928 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 3929 Options: 3930 - 'intensity': Selects the mass feature with the highest intensity 3931 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 3932 or the highest intensity overall if none have MS2 3933 Default is None (uses parameter setting). 3934 3935 Returns 3936 ------- 3937 :obj:`~pandas.DataFrame` 3938 DataFrame with one row per cluster containing: 3939 - cluster: cluster ID 3940 - sample_id: The sample ID of the most representative sample 3941 - mf_id: The mass feature ID in the sample 3942 - coll_mf_id: The collection-level mass feature ID (index) 3943 - has_ms2: Whether this mass feature has MS2 scan numbers 3944 - intensity: The intensity value of the representative mass feature 3945 """ 3946 # Use default from parameters if not specified 3947 if representative_metric is None: 3948 representative_metric = self.parameters.lcms_collection.consensus_representative_metric 3949 3950 mf_df = self.mass_features_dataframe.copy() 3951 # Reset index to make coll_mf_id a column we can work with 3952 mf_df = mf_df.reset_index(drop=False) 3953 3954 # Handle special metric 'intensity_prefer_ms2' 3955 if representative_metric == 'intensity_prefer_ms2': 3956 if 'intensity' not in mf_df.columns: 3957 raise ValueError( 3958 f"'intensity' column not found in mass_features_dataframe. " 3959 f"Available columns: {mf_df.columns.tolist()}" 3960 ) 3961 3962 # Add has_ms2 flag if ms2_scan_numbers column exists 3963 if 'ms2_scan_numbers' in mf_df.columns: 3964 def has_ms2_scans(val): 3965 if val is None: 3966 return False 3967 try: 3968 return len(val) > 0 3969 except (TypeError, ValueError): 3970 return False 3971 3972 mf_df['has_ms2'] = mf_df['ms2_scan_numbers'].apply(has_ms2_scans) 3973 3974 # Sort by has_ms2 (descending) then intensity (descending) 3975 # This ensures features with MS2 are preferred when intensities are equal 3976 mf_df = mf_df.sort_values(['has_ms2', 'intensity'], ascending=[False, False]) 3977 else: 3978 mf_df['has_ms2'] = False 3979 mf_df = mf_df.sort_values('intensity', ascending=False) 3980 3981 # Group by cluster and take the first (highest intensity, preferring MS2) 3982 representatives = mf_df.groupby('cluster').first().reset_index() 3983 3984 else: 3985 # Standard metric - check if it exists 3986 if representative_metric not in mf_df.columns: 3987 raise ValueError( 3988 f"Metric '{representative_metric}' not found. Available columns: {mf_df.columns.tolist()}" 3989 ) 3990 3991 # Add has_ms2 flag for consistency 3992 if 'ms2_scan_numbers' in mf_df.columns: 3993 def has_ms2_scans(val): 3994 if val is None: 3995 return False 3996 try: 3997 return len(val) > 0 3998 except (TypeError, ValueError): 3999 return False 4000 mf_df['has_ms2'] = mf_df['ms2_scan_numbers'].apply(has_ms2_scans) 4001 else: 4002 mf_df['has_ms2'] = False 4003 4004 # Get the index of max value for each cluster 4005 idx = mf_df.groupby('cluster')[representative_metric].idxmax() 4006 representatives = mf_df.loc[idx].copy() 4007 4008 # Select only the columns we need 4009 result_cols = ['cluster', 'sample_id', 'mf_id', 'coll_mf_id', 'has_ms2', 'intensity'] 4010 representatives = representatives[result_cols] 4011 4012 return representatives 4013 4014 def get_sample_mf_map_for_representatives(self, representative_metric=None, include_cluster_id=True): 4015 """ 4016 Build a mapping of sample_id -> list of representative mass feature IDs to load. 4017 4018 This is a DRY helper method used by both process_consensus_features() and 4019 ReadSavedLCMSCollection to determine which mass features should be loaded 4020 for each sample when loading representatives. 4021 4022 Parameters 4023 ---------- 4024 representative_metric : str, optional 4025 The metric to use to determine the most representative sample. 4026 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4027 Default is None. 4028 include_cluster_id : bool, optional 4029 If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id. 4030 Default is True. 4031 4032 Returns 4033 ------- 4034 dict 4035 Dictionary mapping sample_id (int) to list of mass feature identifiers. 4036 If include_cluster_id=True: list of tuples (mf_id, cluster_id) 4037 If include_cluster_id=False: list of mf_id integers 4038 4039 Examples 4040 -------- 4041 >>> # Get map with cluster IDs for loading 4042 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives() 4043 >>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...} 4044 >>> 4045 >>> # Get map without cluster IDs for pipeline 4046 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False) 4047 >>> # sample_mf_map = {0: [123, 456], 1: [789], ...} 4048 """ 4049 # Get all representative mass features in bulk (much faster than looping) 4050 representatives = self.get_representative_mass_features_for_all_clusters( 4051 representative_metric=representative_metric 4052 ) 4053 4054 # Build sample_mf_map 4055 sample_mf_map = {} 4056 for _, row in representatives.iterrows(): 4057 sample_id = row['sample_id'] 4058 mf_id = row['mf_id'] 4059 cluster_id = row['cluster'] 4060 4061 if sample_id not in sample_mf_map: 4062 sample_mf_map[sample_id] = [] 4063 4064 if include_cluster_id: 4065 sample_mf_map[sample_id].append((mf_id, cluster_id)) 4066 else: 4067 sample_mf_map[sample_id].append(mf_id) 4068 4069 return sample_mf_map 4070 4071 def get_most_representative_sample_for_cluster(self, cluster_id, representative_metric=None): 4072 """ 4073 Get the most representative sample for a given cluster based on a metric. 4074 4075 Parameters 4076 ---------- 4077 cluster_id : int 4078 The cluster ID to find the representative sample for. 4079 representative_metric : str, optional 4080 The metric to use to determine the most representative sample. 4081 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4082 Options: 4083 - 'intensity': Selects the mass feature with the highest intensity 4084 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 4085 or the highest intensity overall if none have MS2 4086 Default is None (uses parameter setting). 4087 4088 Returns 4089 ------- 4090 dict 4091 Dictionary containing: 4092 - 'sample_id': The sample ID of the most representative sample 4093 - 'sample_name': The sample name of the most representative sample 4094 - 'mf_id': The mass feature ID in the sample 4095 - 'coll_mf_id': The collection-level mass feature ID (index) 4096 - 'has_ms2': Whether this mass feature has MS2 scan numbers 4097 - 'intensity': The intensity value of the representative mass feature 4098 4099 Raises 4100 ------ 4101 ValueError 4102 If cluster_id is not found or if representative_metric is not a valid column. 4103 """ 4104 # Use the bulk method to get all representatives, then filter to this cluster 4105 # This follows DRY principle and ensures consistency 4106 all_representatives = self.get_representative_mass_features_for_all_clusters( 4107 representative_metric=representative_metric 4108 ) 4109 4110 # Filter to the requested cluster 4111 cluster_rep = all_representatives[all_representatives['cluster'] == cluster_id] 4112 4113 if len(cluster_rep) == 0: 4114 # Try to provide helpful error message 4115 available_clusters = self.mass_features_dataframe['cluster'].unique() 4116 raise ValueError( 4117 f"Cluster {cluster_id} not found in mass_features_dataframe. " 4118 f"Available clusters: {sorted(available_clusters[:10].tolist())}... " 4119 f"(showing first 10 of {len(available_clusters)} total clusters)" 4120 ) 4121 4122 # Get the representative row (should only be one) 4123 rep_row = cluster_rep.iloc[0] 4124 4125 # Get sample name from sample_id (convert to int for list indexing) 4126 sample_id = int(rep_row['sample_id']) 4127 sample_name = self.samples[sample_id] 4128 4129 return { 4130 'sample_id': sample_id, 4131 'sample_name': sample_name, 4132 'mf_id': rep_row['mf_id'], 4133 'coll_mf_id': rep_row['coll_mf_id'], 4134 'has_ms2': rep_row['has_ms2'], 4135 'intensity': rep_row['intensity'] 4136 } 4137 4138 def reload_representative_mass_features(self, add_ms2=False, auto_process_ms2=True, ms2_spectrum_mode=None, ms2_scan_filter=None): 4139 """ 4140 Reload mass features for all representative samples in the cluster summary. 4141 4142 This method is useful when the collection was loaded with load_light=True, 4143 which stores mass features only in the collection dataframe. This reloads 4144 the specific mass features that are representatives for each cluster, 4145 allowing them to be accessed as LCMSMassFeature objects. 4146 4147 Parameters 4148 ---------- 4149 add_ms2 : bool, optional 4150 If True, also loads and associates MS2 spectra with mass features. Default is False. 4151 auto_process_ms2 : bool, optional 4152 If True and add_ms2=True, auto-processes MS2 spectra. Default is True. 4153 ms2_spectrum_mode : str or None, optional 4154 Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. 4155 ms2_scan_filter : str or None, optional 4156 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 4157 4158 Returns 4159 ------- 4160 dict 4161 Dictionary mapping sample_id to list of reloaded mf_ids. 4162 4163 Raises 4164 ------ 4165 ValueError 4166 If cluster_summary_dataframe is not set (run add_consensus_mass_features first). 4167 4168 Notes 4169 ----- 4170 - Only reloads mass features that are cluster representatives 4171 - Uses get_most_representative_sample_for_cluster() to determine which to reload 4172 - More memory-efficient than reloading all mass features 4173 - Parallelized based on lcms_collection.cores parameter 4174 - MS2 association uses same logic as add_associated_ms2_dda() 4175 4176 See Also 4177 -------- 4178 _reload_sample_mass_features : Low-level method to reload specific mass features 4179 get_most_representative_sample_for_cluster : Gets representative sample for cluster 4180 """ 4181 # Validate prerequisites 4182 if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: 4183 raise ValueError( 4184 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 4185 ) 4186 4187 # Get all representative mass features in bulk (much faster than looping) 4188 representatives = self.get_representative_mass_features_for_all_clusters() 4189 4190 # Build a dictionary of sample_id -> list of mf_ids that are representatives 4191 sample_mf_map = {} 4192 for _, row in representatives.iterrows(): 4193 sample_id = row['sample_id'] 4194 mf_id = row['mf_id'] 4195 4196 if sample_id not in sample_mf_map: 4197 sample_mf_map[sample_id] = [] 4198 sample_mf_map[sample_id].append(mf_id) 4199 4200 # Reload mass features for each sample (parallelized) 4201 if self.parameters.lcms_collection.cores == 1: 4202 # Serial processing 4203 from tqdm import tqdm 4204 for sample_id in tqdm(sample_mf_map.keys(), desc="Reloading representative mass features", unit="sample"): 4205 mf_ids = sample_mf_map[sample_id] 4206 self._reload_sample_mass_features(sample_id, mf_ids_to_load=mf_ids, add_ms2=add_ms2, 4207 auto_process_ms2=auto_process_ms2, ms2_spectrum_mode=ms2_spectrum_mode, 4208 ms2_scan_filter=ms2_scan_filter) 4209 else: 4210 # Parallel processing 4211 import multiprocessing 4212 from tqdm import tqdm 4213 4214 if self.parameters.lcms_collection.cores > len(sample_mf_map): 4215 ncores = len(sample_mf_map) 4216 else: 4217 ncores = self.parameters.lcms_collection.cores 4218 4219 pool = multiprocessing.Pool(ncores) 4220 4221 # Build arguments list for starmap 4222 args_list = [ 4223 (sample_id, sample_mf_map[sample_id], add_ms2, auto_process_ms2, 4224 ms2_spectrum_mode, ms2_scan_filter, False) 4225 for sample_id in sample_mf_map.keys() 4226 ] 4227 4228 # Execute in parallel 4229 mp_result = pool.starmap(self._reload_sample_mass_features, args_list) 4230 pool.close() 4231 pool.join() 4232 4233 # Collect results back into samples 4234 for i, sample_id in enumerate(tqdm(sample_mf_map.keys(), desc="Collecting reloaded mass features", unit="sample")): 4235 self[sample_id].mass_features = mp_result[i] 4236 4237 return sample_mf_map 4238 4239 def _associate_ms2_with_mass_features(self, sample, local_mf_ids, auto_process=True, 4240 spectrum_mode=None, scan_filter=None): 4241 """ 4242 Associate MS2 spectra with specific mass features in a sample. 4243 4244 Uses the LCMSBase helper method to find and load MS2 scans for the specified mass features. 4245 4246 Parameters 4247 ---------- 4248 sample : LCMSBase 4249 The sample object containing mass features and scan data. 4250 local_mf_ids : list of int 4251 List of local (sample-level) mass feature IDs to find MS2 for. 4252 auto_process : bool, optional 4253 If True, auto-processes the MS2 spectra. Default is True. 4254 spectrum_mode : str or None, optional 4255 Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. 4256 scan_filter : str or None, optional 4257 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 4258 4259 Returns 4260 ------- 4261 dict 4262 Dictionary of scan_number -> MassSpectrum objects for the loaded MS2 spectra. 4263 """ 4264 # Check if we have scan data 4265 if not hasattr(sample, 'scan_df') or sample.scan_df is None: 4266 return {} 4267 4268 # Separate mass features into those that need scan finding vs those that already have scans 4269 mfs_needing_scan_finding = [] 4270 unique_dda_scans = set() 4271 4272 for mf_id in local_mf_ids: 4273 if mf_id not in sample.mass_features: 4274 continue 4275 mf = sample.mass_features[mf_id] 4276 # If this mass feature already has MS2 scans, add them to our set 4277 if mf.ms2_scan_numbers is not None and len(mf.ms2_scan_numbers) > 0: 4278 # Convert to integers in case they come from HDF5 as numpy types 4279 unique_dda_scans.update([int(scan) for scan in mf.ms2_scan_numbers]) 4280 else: 4281 # Otherwise, we need to find scans for this mass feature 4282 mfs_needing_scan_finding.append(mf_id) 4283 4284 # Only run the scan finding for mass features that need it 4285 if mfs_needing_scan_finding: 4286 found_scans = sample._find_ms2_scans_for_mass_features( 4287 mf_ids=mfs_needing_scan_finding, 4288 scan_filter=scan_filter 4289 ) 4290 unique_dda_scans.update(found_scans) 4291 4292 if len(unique_dda_scans) == 0: 4293 return {} 4294 4295 # Get ms2 parameters from sample 4296 #TODO KRH: deal with different ms2 scan types here (CID vs HCD), may need to add scan translator to the initializeion 4297 ms_params = sample.parameters.mass_spectrum['ms2'] 4298 4299 # Load MS2 spectra (convert set to list) 4300 sample.add_mass_spectra( 4301 scan_list=list(unique_dda_scans), 4302 auto_process=auto_process, 4303 spectrum_mode=spectrum_mode, 4304 ms_level=2, 4305 use_parser=True, 4306 ms_params=ms_params, 4307 ) 4308 4309 # Associate MS2 spectra with mass features 4310 for mf_id in local_mf_ids: 4311 if mf_id not in sample.mass_features: 4312 continue 4313 if sample.mass_features[mf_id].ms2_scan_numbers is not None and len(sample.mass_features[mf_id].ms2_scan_numbers) > 0: 4314 for dda_scan in sample.mass_features[mf_id].ms2_scan_numbers: 4315 if dda_scan in sample._ms: 4316 sample.mass_features[mf_id].ms2_mass_spectra[dda_scan] = sample._ms[dda_scan] 4317 4318 # Return only the MS2 spectra we loaded (for parallel processing) 4319 return {scan: sample._ms[scan] for scan in unique_dda_scans if scan in sample._ms} 4320 4321 def _reload_sample_mass_features(self, sample_id, mf_ids_to_load=None, add_ms2=False, 4322 auto_process_ms2=True, ms2_spectrum_mode=None, ms2_scan_filter=None, 4323 inplace=True): 4324 """ 4325 Reload specific mass features for a sample from HDF5. 4326 4327 This is useful when the collection was loaded with load_light=True, 4328 which stores mass features only in the collection dataframe and not 4329 as LCMSMassFeature objects in individual samples. 4330 4331 Parameters 4332 ---------- 4333 sample_id : int 4334 The sample ID to reload mass features for. 4335 mf_ids_to_load : list of str, optional 4336 List of collection-level mf_ids (format: '{sample_id}_{local_mf_id}') to load. 4337 If None, loads all mass features for the sample. 4338 add_ms2 : bool, optional 4339 If True, also loads and associates MS2 spectra. Default is False. 4340 auto_process_ms2 : bool, optional 4341 If True, auto-processes MS2 spectra. Default is True. 4342 ms2_spectrum_mode : str or None, optional 4343 Spectrum mode for MS2 spectra. Default is None. 4344 ms2_scan_filter : str or None, optional 4345 Filter string for MS2 scans. Default is None. 4346 inplace : bool, optional 4347 If True, updates the sample's mass_features in place. If False, returns the 4348 mass_features dictionary (for multiprocessing). Default is True. 4349 4350 Returns 4351 ------- 4352 dict or None 4353 If inplace=False, returns dictionary of mass features. 4354 Otherwise returns None and updates object in place. 4355 """ 4356 sample = self[sample_id] 4357 sample_name = self.samples[sample_id] 4358 4359 # Check if we have a collection parser that can reload 4360 if not hasattr(self, 'collection_parser') or self.collection_parser is None: 4361 print("Warning: Cannot reload mass features - no collection_parser available") 4362 if not inplace: 4363 return {} 4364 return 4365 4366 # Get the HDF5 file for this sample 4367 hdf5_file = self.collection_parser.folder_location / f"{sample_name}.corems/{sample_name}.hdf5" 4368 4369 if not hdf5_file.exists(): 4370 print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") 4371 if not inplace: 4372 return {} 4373 return 4374 4375 # Import here to avoid circular imports 4376 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 4377 4378 # If specific mf_ids requested, use them directly 4379 local_mf_ids_to_load = None 4380 if mf_ids_to_load is not None: 4381 # mf_ids_to_load is already a list of sample-level mf_ids (integers) 4382 # No parsing needed - they come from the mf_id column in the dataframe 4383 local_mf_ids_to_load = set(mf_ids_to_load) 4384 4385 # Reload mass features from HDF5 4386 with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: 4387 # Load mass features - if specific IDs requested, only load those 4388 parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) 4389 4390 # If add_ms2, associate MS2 spectra with the loaded mass features 4391 if add_ms2 and local_mf_ids_to_load is not None: 4392 self._associate_ms2_with_mass_features( 4393 sample, 4394 list(local_mf_ids_to_load), 4395 auto_process=auto_process_ms2, 4396 spectrum_mode=ms2_spectrum_mode, 4397 scan_filter=ms2_scan_filter 4398 ) 4399 4400 # Return mass features if not inplace (for multiprocessing) 4401 if not inplace: 4402 return sample.mass_features 4403 4404 def add_sparse_distance_matrix(self, features): 4405 if features is None: 4406 return None 4407 else: 4408 features = features.copy() 4409 4410 # Parameters for calculating distance between features 4411 dims = ["mz", "scan_time_aligned"] 4412 relative = [True, False] 4413 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4414 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 4415 dist_weight = [1, 1] 4416 4417 # Check that the dimensions and tolerances are the same length 4418 if ( 4419 len(dims) != len(tol) 4420 or len(dims) != len(relative) 4421 or len(dims) != len(dist_weight) 4422 ): 4423 raise ValueError( 4424 "The dimensions, tolerances, relative, dist_weight, and na_allow lists must be the same length" 4425 ) 4426 4427 # Make connectivity matrix for masking within sample mass features 4428 ## Masking matrix cmat will mark all features from the same sample as 0 4429 ## To mask, a matrix can be multiplied by cmat and features from same 4430 ## samples are multiplied by 0, while features from different samples 4431 ## are multiplied by 1 4432 4433 if "sample_id" not in features.columns: 4434 cmat = None 4435 else: 4436 vals = features["sample_id"].values.reshape(-1, 1) 4437 cmat = scipy.spatial.distance.cdist(vals, vals) 4438 # Convert to binary (0 if same sample, 1 if different) 4439 cmat = np.where(cmat == 0, 0, 1) 4440 # Convert to coordinate matrix for sparse operations later 4441 cmat = sparse.coo_matrix(cmat) 4442 4443 # Compute inter-feature distances using sparse matrix approach 4444 distances = None # clear the distances object before starting 4445 for i in range(len(dims)): # iterate through all dimensions to be considered 4446 # Construct k-d tree 4447 values = features[dims[i]].values 4448 4449 tree = KDTree(values.reshape(-1, 1)) 4450 4451 max_tol = tol[i] 4452 if relative[i] is True: 4453 # Maximum absolute tolerance 4454 max_tol = tol[i] * values.max() 4455 4456 # Compute sparse distance matrix 4457 # the larger the max_tol, the slower this operation is 4458 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 4459 4460 # Only consider forward case, exclude diagonal 4461 sdm = sparse.triu(sdm, k=1) 4462 4463 # Filter relative distances 4464 if relative[i] is True: 4465 # Compute relative distances 4466 rel_dists = sdm.data / values[sdm.row] 4467 4468 # Indices of relative distances less than tolerance 4469 idx = rel_dists <= tol[i] 4470 4471 # Reconstruct sparse distance matrix 4472 sdm = sparse.coo_matrix( 4473 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 4474 shape=(len(values), len(values)), 4475 ) 4476 4477 # Scaled distances wrt the maximum tolerance for the dimension 4478 sdm.data = sdm.data / tol[i] 4479 4480 # Stack distances for dimensions where na_allow is False 4481 if distances is None: 4482 sdm.data = sdm.data * dist_weight[i] 4483 # Replace zeros with epsilon to handle perfect matches 4484 sdm.data[sdm.data == 0] = 1e-10 4485 distances = sdm 4486 else: 4487 # Prepare sdm to match shape of existing distances 4488 distances_truth = distances.copy() 4489 # make new sparse matrix with same positions as previous 4490 # distance matrix but all ones for values 4491 distances_truth.data = np.ones_like(distances_truth.data) 4492 4493 # Replace zeros with epsilon BEFORE multiply to prevent sparse matrix from dropping them 4494 sdm.data[sdm.data == 0] = 1e-10 4495 4496 # multiply the new sparse matrix (sdm) by this mask to remove 4497 # data that doesn't exist in original sparse matrix 4498 sdm = distances_truth.multiply(sdm) 4499 4500 sdm.data = sdm.data * dist_weight[i] 4501 # Replace zeros with epsilon to handle perfect matches 4502 sdm.data[sdm.data == 0] = 1e-10 4503 4504 # use same process as before to remove data from previous 4505 # distances matrix that isn't in new distances matrix 4506 sdm_truth = sdm.copy() 4507 sdm_truth.data = np.ones_like(sdm_truth.data) 4508 4509 # remove the distances that are not sdm 4510 distances = distances.multiply(sdm_truth) 4511 4512 # Sum the new distances 4513 distances = distances + sdm 4514 4515 # Multiply by connectivity matrix for more masking 4516 distances = distances.multiply(cmat) 4517 4518 # Set attribute holding distance matrix 4519 self._sparse_distance_matrix = distances 4520 4521 def evaluate_clusters_for_repeats(self, features): 4522 raise NotImplementedError('evaluate_clusters_for_repeats not implemented yet') 4523 summary_df = self.cluster_summary_dataframe.copy() 4524 4525 # Arrange by decreasing median intensity 4526 summary_df = summary_df.sort_values( 4527 by="intensity_median", ascending=False 4528 ).reset_index(drop=True) 4529 4530 # Find clusters that are within the mz_tol and rt_tol of each other (on the medians) 4531 # Create a distance matrix 4532 # Define how to calculate the distance between features 4533 dims = ["mz_median", "scan_time_aligned_median"] 4534 relative = [True, False] 4535 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4536 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 4537 4538 # Compute inter-feature distances 4539 distances = None 4540 for i in range(len(dims)): 4541 # Construct k-d tree 4542 values = summary_df[dims[i]].values 4543 tree = KDTree(values.reshape(-1, 1)) 4544 4545 max_tol = tol[i] 4546 if relative[i] is True: 4547 # Maximum absolute tolerance 4548 max_tol = tol[i] * values.max() 4549 4550 # Compute sparse distance matrix 4551 # the larger the max_tol, the slower this operation is 4552 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 4553 4554 # Only consider forward case, exclude diagonal 4555 sdm = sparse.triu(sdm, k=1) 4556 4557 # Filter relative distances 4558 if relative[i] is True: 4559 # Compute relative distances 4560 rel_dists = sdm.data / values[sdm.row] # or col? 4561 4562 # Indices of relative distances less than tolerance 4563 idx = rel_dists <= tol[i] 4564 4565 # Reconstruct sparse distance matrix 4566 sdm = sparse.coo_matrix( 4567 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 4568 shape=(len(values), len(values)), 4569 ) 4570 4571 # Cast as binary matrix 4572 sdm.data = np.ones_like(sdm.data) 4573 4574 # Stack distances 4575 if distances is None: 4576 distances = sdm 4577 else: 4578 distances = distances.multiply(sdm) 4579 4580 # Roll up features 4581 # Extract indices of within-tolerance points 4582 distances = distances.tocoo() 4583 pairs = np.stack( 4584 (distances.row, distances.col), axis=1 4585 ) # These are the index values of the clusters, not the cluster ids 4586 # Conver to cluster ids 4587 pairs_df = pd.DataFrame(pairs, columns=["parent", "child"]) 4588 pairs_df["parent"] = summary_df.loc[pairs[:, 0]]["cluster"].values 4589 pairs_df["child"] = summary_df.loc[pairs[:, 1]]["cluster"].values 4590 pairs_df = pairs_df.set_index("parent") 4591 4592 merge_these_clusters = [] 4593 possible_overlaps = [] 4594 root_parents = np.setdiff1d( 4595 np.unique(pairs_df.index.values), np.unique(pairs_df.child.values) 4596 ) 4597 for parent in root_parents: 4598 parent_features = features[features["cluster"] == parent] 4599 children = pairs_df.loc[[parent], "child"].tolist() 4600 for child in children: 4601 overlap = self.check_merge(parent_features, child, features) 4602 if len(overlap) == 0: 4603 merge_these_clusters.append((parent, child, len(overlap))) 4604 else: 4605 possible_overlaps.append((parent, child, len(overlap))) 4606 4607 result_dict = {} 4608 result_dict["merge_these_clusters"] = merge_these_clusters 4609 result_dict["possible_overlaps"] = possible_overlaps 4610 4611 return result_dict 4612 4613 def check_merge(self, parent_features, child, features): 4614 # Grab the features of the parent and children 4615 child_features = features[features["cluster"] == child] 4616 4617 # Check if there is an overlap between mf_coll_id in the parent and child clusters 4618 overlap = np.intersect1d( 4619 parent_features["sample_id"].values, child_features["sample_id"].values 4620 ) 4621 4622 return overlap 4623 4624 def cluster_mass_features_agg_cluster(self, features): 4625 if features is None: 4626 return None 4627 4628 features = features.copy() 4629 4630 self.add_sparse_distance_matrix(features) 4631 4632 distances = self._sparse_distance_matrix 4633 4634 # Convert to full matrix 4635 distances = distances.todense() 4636 4637 # Cast all 0s to 1s for a distance matrix 4638 distances[distances == 0] = 1 4639 distances = np.asarray(distances) 4640 4641 # Perform clustering 4642 try: 4643 clustering = AgglomerativeClustering( 4644 n_clusters=None, 4645 linkage="complete", 4646 # using complete linkage will prevent one sample from being assigned to multiple clusters 4647 metric="precomputed", 4648 distance_threshold=1, 4649 ).fit(distances) 4650 features["cluster"] = clustering.labels_ 4651 4652 # All data points are singleton clusters 4653 except: 4654 features["cluster"] = np.arange(len(features.index)) 4655 4656 return features 4657 4658 def cluster_inspection_plot(self, clu, return_fig = False): 4659 """ 4660 Generate Scan Time vs m/z plot for a narrow range around a given 4661 cluster. This tool is meant to support the user in fine tuning the 4662 tolerances used for the clustering algorithm. The user-provided cluster 4663 ID is highlighted in larger, magenta marker and the ten largest of the 4664 remaining clusters are idenfitied with different colors while the 4665 smallest clusters are light gray. 4666 4667 Parameters 4668 ----------- 4669 clu : integer 4670 A cluster ID that exists in self.mass_features_dataframe 4671 return_fig : boolean 4672 Indicates whether to plot cluster inspection figure (False) or 4673 return figure object (True). Defaults to False. 4674 4675 Returns 4676 -------- 4677 matplotlib.pyplot.Figure 4678 A figure displaying a scan time vs m/z scatterplot of small region 4679 around a given cluster with the ten largest clusters in the region 4680 distinctly identified 4681 4682 Raises 4683 ------ 4684 Warning 4685 If cluster data haven't been added to the object yet 4686 """ 4687 4688 if 'cluster' not in self.mass_features_dataframe.columns: 4689 raise ValueError( 4690 'Cluster information is not yet added to mass_features_dataframe, must run add_consensus_mass_features() first' 4691 ) 4692 4693 else: 4694 mztol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4695 rttol = self.parameters.lcms_collection.consensus_rt_tol 4696 clu_features = self.mass_features_dataframe.copy() 4697 4698 inclu = clu_features[clu_features.cluster == clu] 4699 exclu = clu_features[clu_features.cluster != clu] 4700 4701 dt_ymin = np.floor(min(inclu.mz)) - 1 4702 dt_ymax = np.ceil(max(inclu.mz)) + 1 4703 dt_xmin = np.floor(min(inclu.scan_time_aligned)) - 1 4704 dt_xmax = np.ceil(max(inclu.scan_time_aligned)) + 1 4705 4706 exclu = exclu[ 4707 ( 4708 exclu.mz.between(dt_ymin, dt_ymax, inclusive = 'both') 4709 ) & ( 4710 exclu.scan_time_aligned.between(dt_xmin, dt_xmax, inclusive = 'both') 4711 ) 4712 ] 4713 4714 bigclulist = list(exclu.cluster.value_counts()[:10].index) 4715 bigclu = exclu[exclu.cluster.isin(bigclulist)] 4716 smclu = exclu[~exclu.cluster.isin(bigclulist)] 4717 4718 colors = np.arange(0, 10) 4719 colordict = dict(zip(bigclulist, colors)) 4720 bigclu['color'] = bigclu.cluster.apply(lambda x: colordict[x]) 4721 4722 fig = plt.figure(figsize = (7.5, 5)) 4723 4724 plt.scatter( 4725 inclu.scan_time_aligned, 4726 inclu.mz, 4727 c = 'm', 4728 s = 3, 4729 label = 'Cluster ' + str(clu) 4730 ) 4731 4732 plt.scatter( 4733 bigclu.scan_time_aligned, 4734 bigclu.mz, 4735 c = bigclu.color, 4736 cmap = 'tab10', 4737 s = 1.5 4738 ) 4739 4740 plt.scatter( 4741 smclu.scan_time_aligned, 4742 smclu.mz, 4743 c = 'silver', 4744 s = 2, 4745 label = 'Small clusters' 4746 ) 4747 4748 plt.ylim(dt_ymin, dt_ymax) 4749 plt.xlim(dt_xmin, dt_xmax) 4750 plt.legend(ncol = 2, bbox_to_anchor = (0.8, -0.1)) 4751 plt.xlabel('Scan time') 4752 plt.ylabel('m/z') 4753 title_str = 'Cluster ' + str(clu) 4754 title_str += ': representing ' + str(len(inclu.sample_id.unique())) 4755 title_str += ' of ' + str(len(clu_features.sample_id.unique())) 4756 title_str += ' samples\n' 4757 title_str += 'M/Z tolerance: ' + str(mztol) + '\n' 4758 title_str += 'Scan Time tolerance: ' + str(rttol) 4759 plt.title(title_str, fontsize = 10) 4760 4761 if return_fig: 4762 plt.close(fig) 4763 return fig 4764 else: 4765 plt.show() 4766 4767 def plot_cluster_outlier_frequency(self, dim_list = ['mz', 'scan_time_aligned'], clu_size_thresh = 0.5, return_fig = False): 4768 """ 4769 Generate histogram showing the frequency of outlier occurrences by 4770 clustering dimension across all clusters 4771 4772 Parameters 4773 ----------- 4774 dim_list : list 4775 List of strings describing dimensions that can be used in 4776 clustering. Available list items: 4777 - 'mz' 4778 - 'scan_time_aligned' 4779 - 'half_height_width' 4780 - 'tailing_factor' 4781 - 'dispersity_index' 4782 - 'intensity' 4783 - 'persistence' 4784 clu_size_thresh : float 4785 Value between 0 and 1 that indicates what percentage of samples 4786 need to be present in a cluster before it's evaluated for outliers. 4787 Defaults to 0.5. 4788 return_fig : boolean 4789 Indicates whether to plot cluster inspection figure (False) or 4790 return figure object (True). Defaults to False. 4791 4792 Returns 4793 -------- 4794 matplotlib.pyplot.Figure 4795 A figure displaying the frequency of outlier occurrences across all 4796 clusters in the provided measurement dimensions 4797 4798 Raises 4799 ------ 4800 Warning 4801 If cluster data haven't been added to the object yet 4802 """ 4803 4804 if not hasattr(self, 'cluster_summary_dataframe'): 4805 raise ValueError( 4806 'cluster_summary_dataframe is not yet added, must run add_consensus_mass_features() first' 4807 ) 4808 4809 mfdf = self.mass_features_dataframe.copy() 4810 summarydf = self.cluster_summary_dataframe 4811 4812 numsamples = len(self) 4813 sumdf = summarydf[summarydf.sample_id_nunique > numsamples * clu_size_thresh].reset_index(drop = True).copy() 4814 4815 ## find the ranges for non-outlier values and add them to sumdf 4816 mergelist = ['cluster'] 4817 for dim in dim_list: 4818 maxtag = dim + '_outmax' 4819 mintag = dim + '_outmin' 4820 mergelist.append(maxtag) 4821 mergelist.append(mintag) 4822 # Calculate outlier thresholds using vectorized operations 4823 sumdf[mintag] = sumdf[dim + '_mean'] - 3*sumdf[dim + '_std'] 4824 sumdf[maxtag] = sumdf[dim + '_mean'] + 3*sumdf[dim + '_std'] 4825 ## If NaN shows up anywhere in dim_min, dim_max calculations, value is set to NaN and it's 4826 ## not flagged. This happens when there's not enough values to compute median/std for that 4827 ## dimension therefore can't have outliers 4828 4829 ## add ranges to mfdf and identify mass features that fall outside the ranges 4830 # Merge without dropping NaN - we'll handle it per-dimension 4831 outdf = pd.merge(mfdf, sumdf[mergelist], on = 'cluster') 4832 4833 outtags = ['cluster'] 4834 for dim in dim_list: 4835 dimtag = dim + '_outlier' 4836 outtags.append(dimtag) 4837 maxtag = dim + '_outmax' 4838 mintag = dim + '_outmin' 4839 # Only flag as outlier if thresholds are valid (not NaN) 4840 outdf[dimtag] = np.where( 4841 (outdf[maxtag].notna() & outdf[mintag].notna()) & 4842 (((outdf[dim] > outdf[maxtag])) | ((outdf[dim] < outdf[mintag]))), 4843 True, 4844 False 4845 ) 4846 4847 ## identify number of outliers in each cluster 4848 outliers = outdf[outtags] 4849 outliers = outliers.groupby(['cluster']).sum() 4850 4851 ## plot number of clusters that contain any outliers 4852 fig = plt.figure() 4853 plt.bar(dim_list, outliers.sum().values, width = 0.5) 4854 plt.xticks(rotation = 90) 4855 plt.title('Frequency of outliers across all clusters by category') 4856 4857 if return_fig: 4858 plt.close(fig) 4859 return fig 4860 else: 4861 plt.show() 4862 4863 def _search_for_targeted_mass_features_in_sample(self, obj_idx, missingdf, cluster_dict, expand_on_miss=False, inplace=True): 4864 """ 4865 Helper method to search for missing mass features in a single sample. 4866 4867 Internal method called by fill_missing_cluster_features() to perform 4868 gap-filling for one sample in the collection. 4869 4870 Parameters 4871 ---------- 4872 obj_idx : int 4873 Index of the sample being processed 4874 missingdf : pd.DataFrame 4875 DataFrame containing cluster information with columns: 4876 'cluster', 'sample_id_nunique', 'mz_min', 'mz_max', 4877 'scan_time_aligned_min', 'scan_time_aligned_max', 'mz_min_allowed', 4878 'mz_max_allowed', 'scan_time_aligned_min_allowed', 4879 'scan_time_aligned_max_allowed', 'missing_samples' 4880 cluster_dict : dict 4881 Pre-computed cluster feature dictionary to avoid recomputation 4882 expand_on_miss : bool 4883 If True, expands search window when no peak found initially 4884 inplace : bool 4885 If True, assigns induced_mass_features in place. If False, returns the 4886 induced features dictionary (for multiprocessing) 4887 4888 Returns 4889 ------- 4890 dict or None 4891 If inplace=False, returns dictionary of induced mass features. 4892 Otherwise returns None and updates object in place. 4893 """ 4894 ## Use the pre-computed cluster dictionary passed as parameter 4895 4896 ## to get clusters missing data based on sample index: 4897 sampledf = missingdf[ 4898 missingdf.missing_samples.apply(lambda x: obj_idx in x) 4899 ].reset_index(drop = True).copy() 4900 4901 # Skip if no missing features for this sample 4902 if len(sampledf) == 0: 4903 if not inplace: 4904 return {} 4905 return 4906 4907 self.load_raw_data(obj_idx, 1) 4908 4909 ## this is the line that bugs due to _ms_unprocessed not having key 1 4910 ms1df = self[obj_idx]._ms_unprocessed[1].copy() 4911 scan_df = self[obj_idx].scan_df[['scan', 'scan_time_aligned']] 4912 ms1df = pd.merge(ms1df, scan_df, on = 'scan') 4913 4914 # Pre-extract all values from sampledf to avoid repeated .iloc calls 4915 clusters = sampledf.cluster.values 4916 mz_mins = sampledf.mz_min.values 4917 mz_maxs = sampledf.mz_max.values 4918 st_mins = sampledf.scan_time_aligned_min.values 4919 st_maxs = sampledf.scan_time_aligned_max.values 4920 4921 if expand_on_miss: 4922 mz_mins_allowed = sampledf.mz_min_allowed.values 4923 mz_maxs_allowed = sampledf.mz_max_allowed.values 4924 st_mins_allowed = sampledf.sta_min_allowed.values 4925 st_maxs_allowed = sampledf.sta_max_allowed.values 4926 4927 # Pre-filter ms1df to reduce search space 4928 mz_global_min = mz_mins.min() 4929 mz_global_max = mz_maxs.max() 4930 st_global_min = st_mins.min() 4931 st_global_max = st_maxs.max() 4932 4933 if expand_on_miss: 4934 mz_global_min = min(mz_global_min, mz_mins_allowed.min()) 4935 mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) 4936 st_global_min = min(st_global_min, st_mins_allowed.min()) 4937 st_global_max = max(st_global_max, st_maxs_allowed.max()) 4938 4939 ms1df_filtered = ms1df[ 4940 (ms1df.mz >= mz_global_min) & 4941 (ms1df.mz <= mz_global_max) & 4942 (ms1df.scan_time_aligned >= st_global_min) & 4943 (ms1df.scan_time_aligned <= st_global_max) 4944 ].copy() 4945 4946 # Generate set_ids for all features 4947 set_ids = [f'c{clusters[i]}_{i}_i' for i in range(len(sampledf))] 4948 4949 # Use batch method to process all features at once 4950 if expand_on_miss: 4951 # First try with normal bounds 4952 peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch( 4953 ms1df_filtered, 4954 mz_mins, 4955 mz_maxs, 4956 st_mins, 4957 st_maxs, 4958 set_ids, 4959 obj_idx=obj_idx, 4960 st_aligned=True 4961 ) 4962 4963 # Retry failed features with expanded bounds 4964 failed_indices = [i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99] 4965 if failed_indices: 4966 failed_ids = [set_ids[i] for i in failed_indices] 4967 retry_peaks = self[obj_idx].search_for_targeted_mass_features_batch( 4968 ms1df_filtered, 4969 mz_mins_allowed[failed_indices], 4970 mz_maxs_allowed[failed_indices], 4971 st_mins_allowed[failed_indices], 4972 st_maxs_allowed[failed_indices], 4973 failed_ids, 4974 obj_idx=obj_idx, 4975 st_aligned=True 4976 ) 4977 peaks_dict.update(retry_peaks) 4978 else: 4979 peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch( 4980 ms1df_filtered, 4981 mz_mins, 4982 mz_maxs, 4983 st_mins, 4984 st_maxs, 4985 set_ids, 4986 obj_idx=obj_idx, 4987 st_aligned=True 4988 ) 4989 4990 # Assign peaks to induced_mass_features and cluster_dict 4991 for i in range(len(sampledf)): 4992 peak = peaks_dict[set_ids[i]] 4993 self[obj_idx].induced_mass_features[peak.id] = peak 4994 cluster_dict[clusters[i]] += [set_ids[i]] 4995 4996 # TODO KRH: Let's try to avoid these steps unless asked for by parameters to pick up speed 4997 if False: 4998 self[obj_idx].add_associated_ms1(induced_features = True) 4999 # need to set drop_if_fail to false for induced features as they will fail 5000 self[obj_idx].add_peak_metrics(induced_features = True) 5001 5002 self[obj_idx].integrate_mass_features(drop_if_fail = False, induced_features = True) 5003 5004 if not inplace: 5005 return self[obj_idx].induced_mass_features 5006 5007 def fill_missing_cluster_features(self): 5008 """ 5009 Gap-filling for consensus mass features across collection samples. 5010 5011 For clusters present in multiple samples but missing from others, searches 5012 raw MS1 data to find peaks in expected m/z and retention time windows. This 5013 creates "induced" mass features for peaks that exist in the data but weren't 5014 detected in the initial peak detection. 5015 5016 Must be run after add_consensus_mass_features(). Results are accessible via 5017 induced_mass_features_dataframe property and included in collection_pivot_table 5018 and collection_consensus_report outputs. 5019 5020 Parameters 5021 ---------- 5022 None 5023 Uses parameters from self.parameters.lcms_collection: 5024 - consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain 5025 a cluster before gap-filling is attempted 5026 - gap_fill_expand_on_miss: If True, expands search window when no peak is found 5027 5028 Returns 5029 ------- 5030 None 5031 Updates induced_mass_features attribute for each LCMSBase object and 5032 combines them into induced_mass_features_dataframe. 5033 5034 Raises 5035 ------ 5036 ValueError 5037 If cluster_summary_dataframe is not set (must run add_consensus_mass_features first). 5038 5039 Notes 5040 ----- 5041 - Loads raw MS1 data for each sample, which may be memory intensive 5042 - Induced features are integrated and metrics calculated automatically 5043 - Processing can be parallelized using parameters.lcms_collection.cores 5044 5045 See Also 5046 -------- 5047 add_consensus_mass_features : Creates consensus features before gap-filling 5048 collection_pivot_table : Includes both regular and induced features 5049 collection_consensus_report : Reports on complete feature matrix 5050 """ 5051 5052 # Validate prerequisites 5053 if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: 5054 raise ValueError( 5055 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 5056 ) 5057 5058 # Get parameters from settings 5059 min_cluster_presence = self.parameters.lcms_collection.consensus_min_sample_fraction 5060 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5061 5062 # Validate parameters 5063 if not 0 <= min_cluster_presence <= 1: 5064 raise ValueError("consensus_min_sample_fraction must be between 0 and 1") 5065 5066 summarydf = self.cluster_summary_dataframe 5067 mfdf = self.mass_features_dataframe 5068 5069 sample_ct = len(self.samples) 5070 5071 # Identify clusters present in sufficient samples but not all samples 5072 missingdf = summarydf[[ 5073 'cluster', 5074 'sample_id_nunique', 5075 'mz_min', 5076 'mz_max', 5077 'scan_time_aligned_min', 5078 'scan_time_aligned_max' 5079 ]] 5080 missingdf = missingdf[missingdf.sample_id_nunique > min_cluster_presence * sample_ct] 5081 missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] 5082 5083 # Check if there are any clusters to gap-fill 5084 if len(missingdf) == 0: 5085 return 5086 5087 # Find which samples are missing for each cluster 5088 # Use range(sample_ct) to include all samples, even those with no mass features 5089 all_sample_ids = list(range(sample_ct)) 5090 missing_samples_list = [] 5091 for c in missingdf.cluster.to_numpy(): 5092 cludf = mfdf[mfdf.cluster == c] 5093 missing = [x for x in all_sample_ids if x not in cludf.sample_id.unique()] 5094 missing_samples_list.append(missing) 5095 missingdf['missing_samples'] = missing_samples_list 5096 5097 # Calculate expanded search windows for expand_on_miss option 5098 mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5099 rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol 5100 missingdf['mz_max_allowed'] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max 5101 missingdf['mz_min_allowed'] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min 5102 missingdf['sta_max_allowed'] = missingdf.scan_time_aligned_max + rt_clu_tol * missingdf.scan_time_aligned_max 5103 missingdf['sta_min_allowed'] = missingdf.scan_time_aligned_min - rt_clu_tol * missingdf.scan_time_aligned_min 5104 5105 # Compute cluster dictionary once to avoid recomputing for each sample 5106 cluster_dict = self.cluster_feature_dictionary 5107 5108 # Process each sample to search for missing features 5109 if self.parameters.lcms_collection.cores == 1: 5110 for i in tqdm(range(sample_ct), desc="Gap-filling samples", unit="sample"): 5111 self._search_for_targeted_mass_features_in_sample(i, missingdf, cluster_dict, expand_on_miss) 5112 5113 if self.parameters.lcms_collection.cores > 1: 5114 if self.parameters.lcms_collection.cores > len(self): 5115 ncores = len(self) 5116 else: 5117 ncores = self.parameters.lcms_collection.cores 5118 pool = multiprocessing.Pool(ncores) 5119 mp_result = pool.starmap( 5120 self._search_for_targeted_mass_features_in_sample, 5121 [(x, missingdf, cluster_dict, expand_on_miss, False) for x in range(sample_ct)] 5122 ) 5123 5124 for i in tqdm(range(sample_ct), desc="Collecting gap-filled features", unit="sample"): 5125 self[i].induced_mass_features = mp_result[i] 5126 5127 self._combine_mass_features(induced_features = True) 5128 5129 # Mark that gap-filling has been performed 5130 self.missing_mass_features_searched = True 5131 5132 for sample_name in self.samples: 5133 self._lcms[sample_name].mass_features = {} 5134 5135 def process_samples_pipeline(self, operations, description=None, keep_raw_data=False, show_progress=True): 5136 """ 5137 Execute a pipeline of operations on all samples in parallel. 5138 5139 This method provides a flexible framework for performing multiple 5140 sample-level operations in a single parallelized pass, which is more 5141 efficient than calling separate methods sequentially. 5142 5143 Parameters 5144 ---------- 5145 operations : list of SampleOperation 5146 List of operations to perform on each sample, in order. 5147 Each operation should be an instance of a class derived from 5148 SampleOperation (see lc_calc_operations module). 5149 description : str or None, optional 5150 Progress bar description. If None, automatically generates description 5151 from operation descriptions (e.g., "gap-filling, reloading features"). 5152 Default is None. 5153 keep_raw_data : bool, optional 5154 If True, keeps raw MS data loaded in memory after pipeline completes. 5155 If False, cleans up raw data to free memory. Default is False. 5156 show_progress : bool, optional 5157 If True, displays progress bars during processing. If False, runs silently. 5158 Default is True. 5159 5160 Returns 5161 ------- 5162 dict 5163 Dictionary with results from pipeline execution, keyed by operation name. 5164 Structure: {operation_name: {sample_id: result, ...}, ...} 5165 5166 Raises 5167 ------ 5168 ValueError 5169 If operations list is empty or contains invalid operations. 5170 5171 Notes 5172 ----- 5173 - Operations are executed sequentially within each sample 5174 - Samples are processed in parallel based on parameters.lcms_collection.cores 5175 - Each operation can have conditional execution via can_execute() 5176 - Results are collected back via collect_results() method of each operation 5177 - Failed operations for a sample are logged but don't halt processing 5178 - Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True 5179 5180 Examples 5181 -------- 5182 >>> from corems.mass_spectra.calc.lc_calc_operations import ( 5183 ... GapFillOperation, ReloadFeaturesOperation 5184 ... ) 5185 >>> ops = [ 5186 ... GapFillOperation('gap_fill', expand_on_miss=True), 5187 ... ReloadFeaturesOperation('reload', add_ms2=True) 5188 ... ] 5189 >>> results = lcms_collection.process_samples_pipeline(ops) 5190 5191 See Also 5192 -------- 5193 lc_calc_operations : Module containing built-in operation classes 5194 fill_and_process_features : Convenience method combining common operations 5195 """ 5196 from corems.mass_spectra.calc.lc_calc_operations import SampleOperation 5197 5198 # Validate operations 5199 if not operations or len(operations) == 0: 5200 raise ValueError("operations list cannot be empty") 5201 5202 for op in operations: 5203 if not isinstance(op, SampleOperation): 5204 raise ValueError(f"All operations must be SampleOperation instances, got {type(op)}") 5205 5206 # Generate description from operations if not provided 5207 if description is None: 5208 operation_descriptions = [op.description for op in operations] 5209 description = ", ".join(operation_descriptions).capitalize() 5210 5211 # Prepare runtime parameters for each operation 5212 # This is where we gather collection-level data that operations need 5213 runtime_params = self._prepare_pipeline_runtime_params(operations) 5214 runtime_params['keep_raw_data'] = keep_raw_data 5215 5216 # Execute pipeline 5217 sample_ct = len(self.samples) 5218 5219 if self.parameters.lcms_collection.cores == 1: 5220 # Serial processing 5221 results_by_operation = {op.name: {} for op in operations} 5222 5223 if show_progress: 5224 from tqdm import tqdm 5225 # Print description on its own line before progress bar 5226 print(f"\n{description.capitalize()}:") 5227 iterator = tqdm(range(sample_ct), unit="sample", ncols=80) 5228 else: 5229 iterator = range(sample_ct) 5230 5231 for sample_id in iterator: 5232 sample_results = self._execute_sample_pipeline( 5233 sample_id, operations, runtime_params, inplace=True 5234 ) 5235 # Collect results (collect_results already called in _execute_sample_pipeline when inplace=True) 5236 # Skip 'sample_id' key which is added for tracking 5237 for op_name, result in sample_results.items(): 5238 if op_name != 'sample_id': 5239 results_by_operation[op_name][sample_id] = result 5240 else: 5241 # Parallel processing 5242 import multiprocessing 5243 5244 if self.parameters.lcms_collection.cores > sample_ct: 5245 ncores = sample_ct 5246 else: 5247 ncores = self.parameters.lcms_collection.cores 5248 5249 pool = multiprocessing.Pool(ncores) 5250 5251 # Build arguments for each sample 5252 args_list = [ 5253 (sample_id, operations, runtime_params, False) 5254 for sample_id in range(sample_ct) 5255 ] 5256 5257 # Execute in parallel with progress tracking 5258 results_by_operation = {op.name: {} for op in operations} 5259 5260 if show_progress: 5261 from tqdm import tqdm 5262 import time 5263 5264 # Use starmap_async for parallel execution with progress tracking 5265 async_result = pool.starmap_async(self._execute_sample_pipeline, args_list) 5266 5267 # Poll for completion and update progress bar 5268 print(description) 5269 pbar = tqdm( 5270 total=sample_ct, 5271 desc="", 5272 unit="sample", 5273 position=0, 5274 leave=True, 5275 dynamic_ncols=True 5276 ) 5277 prev_completed = 0 5278 while not async_result.ready(): 5279 # Get number of completed tasks by checking remaining 5280 completed = sample_ct - async_result._number_left 5281 if completed > prev_completed: 5282 pbar.update(completed - prev_completed) 5283 prev_completed = completed 5284 time.sleep(0.5) # Poll every 500ms to avoid spam 5285 5286 # Final update to 100% 5287 if prev_completed < sample_ct: 5288 pbar.update(sample_ct - prev_completed) 5289 pbar.close() 5290 5291 # Get all results 5292 mp_results = async_result.get() 5293 else: 5294 # Execute without progress 5295 mp_results = pool.starmap(self._execute_sample_pipeline, args_list) 5296 5297 pool.close() 5298 pool.join() 5299 5300 # Collect results back into collection 5301 for result in mp_results: 5302 sample_id = result.get('sample_id') 5303 for op in operations: 5304 op_result = result.get(op.name) 5305 if op_result is not None: 5306 op.collect_results(sample_id, op_result, self) 5307 results_by_operation[op.name][sample_id] = op_result 5308 5309 return results_by_operation 5310 5311 def _prepare_pipeline_runtime_params(self, operations): 5312 """ 5313 Prepare runtime parameters needed by operations in the pipeline. 5314 5315 This method gathers collection-level data that operations need, 5316 such as cluster information for gap-filling or mf_ids for reloading. 5317 5318 Parameters 5319 ---------- 5320 operations : list of SampleOperation 5321 List of operations that will be executed 5322 5323 Returns 5324 ------- 5325 dict 5326 Dictionary of runtime parameters for operations 5327 """ 5328 from corems.mass_spectra.calc.lc_calc_operations import ( 5329 GapFillOperation, ReloadFeaturesOperation, MS2SpectralSearchOperation, 5330 LoadEICsOperation 5331 ) 5332 5333 runtime_params = {} 5334 5335 # Check if any operation needs gap-fill parameters 5336 needs_gap_fill = any(isinstance(op, GapFillOperation) for op in operations) 5337 if needs_gap_fill: 5338 # Prepare gap-fill parameters (same as fill_missing_cluster_features) 5339 min_cluster_presence = self.parameters.lcms_collection.consensus_min_sample_fraction 5340 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5341 5342 summarydf = self.cluster_summary_dataframe 5343 mfdf = self.mass_features_dataframe 5344 sample_ct = len(self.samples) 5345 5346 # Identify clusters needing gap-filling 5347 # Note: cluster_summary_dataframe has 'cluster' as index, need to reset it 5348 missingdf = summarydf.reset_index()[[ 5349 'cluster', 5350 'sample_id_nunique', 5351 'mz_min', 5352 'mz_max', 5353 'scan_time_aligned_min', 5354 'scan_time_aligned_max' 5355 ]].copy() 5356 missingdf = missingdf[missingdf.sample_id_nunique > min_cluster_presence * sample_ct] 5357 missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] 5358 5359 if len(missingdf) > 0: 5360 # Find which samples are missing for each cluster 5361 # Use range(sample_ct) to include all samples, even those with no mass features 5362 all_sample_ids = list(range(sample_ct)) 5363 missing_samples_list = [] 5364 for c in missingdf.cluster.to_numpy(): 5365 cludf = mfdf[mfdf.cluster == c] 5366 missing = [x for x in all_sample_ids if x not in cludf.sample_id.unique()] 5367 missing_samples_list.append(missing) 5368 missingdf['missing_samples'] = missing_samples_list 5369 5370 # Calculate expanded search windows 5371 mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5372 rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol 5373 missingdf['mz_max_allowed'] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max 5374 missingdf['mz_min_allowed'] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min 5375 missingdf['sta_max_allowed'] = missingdf.scan_time_aligned_max + rt_clu_tol * missingdf.scan_time_aligned_max 5376 missingdf['sta_min_allowed'] = missingdf.scan_time_aligned_min - rt_clu_tol * missingdf.scan_time_aligned_min 5377 5378 runtime_params['missingdf'] = missingdf 5379 runtime_params['cluster_dict'] = self.cluster_feature_dictionary 5380 runtime_params['expand_on_miss'] = expand_on_miss 5381 5382 # Check if any operation needs reload parameters 5383 needs_reload = any(isinstance(op, ReloadFeaturesOperation) for op in operations) 5384 if needs_reload: 5385 # Use DRY helper method to build sample_mf_map 5386 sample_mf_map = self.get_sample_mf_map_for_representatives(include_cluster_id=False) 5387 runtime_params['sample_mf_map'] = sample_mf_map 5388 5389 # Check if any operation needs MS2 spectral search parameters 5390 needs_ms2_search = any(isinstance(op, MS2SpectralSearchOperation) for op in operations) 5391 if needs_ms2_search: 5392 # Pass through pre-prepared spectral library 5393 if hasattr(self, '_spectral_lib') and self._spectral_lib is not None: 5394 runtime_params['fe_lib'] = self._spectral_lib 5395 if hasattr(self, '_spectral_search_molecular_metadata'): 5396 runtime_params['molecular_metadata'] = self._spectral_search_molecular_metadata 5397 5398 # Check if any operation needs EIC loading parameters 5399 needs_eic_loading = any(isinstance(op, LoadEICsOperation) for op in operations) 5400 if needs_eic_loading: 5401 # Build cluster_mz_dict: map of sample_id -> list of m/z values in clusters 5402 mfdf = self.mass_features_dataframe 5403 cluster_mz_dict = {} 5404 5405 # Get all mass features that belong to clusters (cluster is not NaN) 5406 clustered_mf = mfdf[mfdf['cluster'].notna()] 5407 5408 # Group by sample_id and collect all m/z values associated with eics 5409 for sample_id in clustered_mf['sample_id'].unique(): 5410 sample_df = clustered_mf[clustered_mf['sample_id'] == sample_id] 5411 sample = self[sample_id] # Get the LCMS object for this sample 5412 5413 # Extract _eic_mz from actual mass feature objects, not from dataframe 5414 eic_mz_list = [] 5415 for mf_id in sample_df['mf_id'].values: 5416 if mf_id in sample.mass_features: 5417 mf = sample.mass_features[mf_id] 5418 if hasattr(mf, '_eic_mz') and mf._eic_mz is not None: 5419 eic_mz_list.append(mf._eic_mz) 5420 5421 # Use the collected m/z values, or fallback to empty list if none found 5422 cluster_mz_dict[sample_id] = list(set(eic_mz_list)) if eic_mz_list else [] 5423 5424 runtime_params['cluster_mz_dict'] = cluster_mz_dict 5425 5426 return runtime_params 5427 5428 def _execute_sample_pipeline(self, sample_id, operations, runtime_params, inplace=True): 5429 """ 5430 Execute a pipeline of operations on a single sample. 5431 5432 This is the worker function called (potentially in parallel) for each sample. 5433 5434 Parameters 5435 ---------- 5436 sample_id : int 5437 Sample ID to process 5438 operations : list of SampleOperation 5439 Operations to execute in order 5440 runtime_params : dict 5441 Runtime parameters prepared by _prepare_pipeline_runtime_params 5442 inplace : bool, optional 5443 If True, updates sample in place. If False, returns results for 5444 multiprocessing. Default is True. 5445 5446 Returns 5447 ------- 5448 dict 5449 Dictionary with results from each operation, keyed by operation name. 5450 If inplace=True, returns results that need to be collected. 5451 If inplace=False, returns all results for multiprocessing collection. 5452 """ 5453 results = {} 5454 5455 # Check if any operations need raw MS data 5456 needs_raw_data = {} # {ms_level: True/False} 5457 for op in operations: 5458 needs_raw, ms_level = op.needs_raw_ms_data() 5459 if needs_raw and ms_level: 5460 needs_raw_data[ms_level] = True 5461 5462 # Load raw data once if any operations need it 5463 # Note: For gap-filling, it loads data internally, so we just track it here 5464 for ms_level in needs_raw_data.keys(): 5465 # Gap-filling loads its own data, but we want to keep track that it's loaded 5466 # Other operations can then use the loaded data 5467 pass 5468 5469 for op in operations: 5470 # Check if operation can execute on this sample 5471 sample = self[sample_id] 5472 if not op.can_execute(sample, self): 5473 # Skip this operation for this sample if prerequisites aren't met 5474 # This allows processing to continue for samples that don't have 5475 # all required data (e.g., MS2 spectra) 5476 results[op.name] = None 5477 continue 5478 5479 # Prepare operation-specific runtime params 5480 op_runtime_params = {} 5481 5482 # Add gap-fill params if this is a gap-fill operation 5483 from corems.mass_spectra.calc.lc_calc_operations import ( 5484 GapFillOperation, ReloadFeaturesOperation, MS2SpectralSearchOperation, LoadEICsOperation 5485 ) 5486 5487 if isinstance(op, GapFillOperation): 5488 if 'missingdf' in runtime_params: 5489 op_runtime_params['missingdf'] = runtime_params['missingdf'] 5490 op_runtime_params['cluster_dict'] = runtime_params['cluster_dict'] 5491 op_runtime_params['expand_on_miss'] = runtime_params['expand_on_miss'] 5492 5493 elif isinstance(op, ReloadFeaturesOperation): 5494 if 'sample_mf_map' in runtime_params: 5495 sample_mf_map = runtime_params['sample_mf_map'] 5496 # Always pass mf_ids_to_load to ensure we only load what's needed 5497 # If sample not in map, it has no representatives - pass empty list 5498 op_runtime_params['mf_ids_to_load'] = sample_mf_map.get(sample_id, []) 5499 5500 elif isinstance(op, MS2SpectralSearchOperation): 5501 # Add MS2 spectral search parameters 5502 if 'fe_lib' in runtime_params: 5503 op_runtime_params['fe_lib'] = runtime_params['fe_lib'] 5504 if 'molecular_metadata' in runtime_params: 5505 op_runtime_params['molecular_metadata'] = runtime_params['molecular_metadata'] 5506 5507 elif isinstance(op, LoadEICsOperation): 5508 # Add EIC loading parameters 5509 if 'cluster_mz_dict' in runtime_params: 5510 op_runtime_params['cluster_mz_dict'] = runtime_params['cluster_mz_dict'] 5511 5512 # Execute the operation 5513 result = op.execute(sample_id, self, **op_runtime_params) 5514 results[op.name] = result 5515 5516 # If inplace, collect immediately 5517 if inplace and result is not None: 5518 op.collect_results(sample_id, result, self) 5519 5520 # Clean up raw data if requested 5521 keep_raw_data = runtime_params.get('keep_raw_data', False) 5522 if not keep_raw_data: 5523 for ms_level in needs_raw_data.keys(): 5524 if ms_level in self[sample_id]._ms_unprocessed: 5525 del self[sample_id]._ms_unprocessed[ms_level] 5526 5527 # Include sample_id in results for tracking (especially important for imap_unordered) 5528 results['sample_id'] = sample_id 5529 return results 5530 5531 def process_consensus_features(self, load_representatives=True, perform_gap_filling=True, 5532 add_ms1=False, add_ms2=False, 5533 ms2_scan_filter=None, molecular_formula_search=False, 5534 ms2_spectral_search=False, spectral_lib=None, 5535 molecular_metadata=None, 5536 gather_eics=False, 5537 keep_raw_data=False, 5538 show_progress=True): 5539 """ 5540 Process consensus mass features across the collection in a single parallelized pass. 5541 5542 This method provides a convenient interface to the sample processing pipeline, 5543 allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association, 5544 molecular formula search, and MS2 spectral search) to be performed efficiently in 5545 a single pass through all samples. 5546 5547 Parameters 5548 ---------- 5549 load_representatives : bool, optional 5550 If True, loads representative mass features from HDF5. Default is True. 5551 perform_gap_filling : bool, optional 5552 If True, performs gap-filling for missing cluster features. Default is True. 5553 This operation loads raw MS1 data which can be reused by subsequent operations. 5554 add_ms1 : bool, optional 5555 If True and load_representatives=True, associates MS1 spectra with 5556 loaded features. Automatically uses raw data from gap-filling if available, 5557 otherwise uses parser. Spectrum mode is auto-detected. Default is False. 5558 add_ms2 : bool, optional 5559 If True and load_representatives=True, associates MS2 spectra with 5560 loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False. 5561 ms2_scan_filter : str or None, optional 5562 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 5563 molecular_formula_search : bool, optional 5564 If True, performs molecular formula search on mass features using 5565 associated MS1 spectra. Requires add_ms1=True or that MS1 spectra 5566 are already associated. Uses parameters from 5567 parameters.mass_spectrum["ms1"].molecular_search. Default is False. 5568 ms2_spectral_search : bool, optional 5569 If True, performs MS2 spectral library search using FlashEntropy. 5570 Requires add_ms2=True and spectral_lib to be provided. Default is False. 5571 spectral_lib : FlashEntropy library, optional 5572 Pre-prepared FlashEntropy spectral library for MS2 search. 5573 Create using MSPInterface.get_metabolomics_spectra_library(). 5574 Required if ms2_spectral_search=True. Default is None. 5575 molecular_metadata : pd.DataFrame, optional 5576 Molecular metadata corresponding to spectral_lib. 5577 Returned from MSPInterface.get_metabolomics_spectra_library(). 5578 Stored as self.spectral_search_molecular_metadata for later export. 5579 Default is None. 5580 gather_eics : bool, optional 5581 If True, loads extracted ion chromatograms (EICs) from HDF5 for all 5582 mass features with assigned cluster_index (including gap-filled features). 5583 Enables access to EICs via get_eics_for_cluster(cluster_id) method. 5584 Requires that EICs were previously exported with export_eics=True. 5585 Default is False. 5586 keep_raw_data : bool, optional 5587 If True, keeps raw MS data loaded in memory after pipeline completes. 5588 If False, cleans up raw data to free memory. Default is False. 5589 show_progress : bool, optional 5590 If True, displays progress bars during processing. If False, runs silently. 5591 Default is True. 5592 5593 Returns 5594 ------- 5595 dict 5596 Dictionary with pipeline results. Keys include: 5597 - 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling) 5598 - 'reload': dict mapping sample_id to reloaded mass features (if reloading) 5599 - 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search) 5600 - 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search) 5601 5602 Raises 5603 ------ 5604 ValueError 5605 If neither operation is enabled, or if required parameters are missing. 5606 5607 Notes 5608 ----- 5609 - Must run add_consensus_mass_features() before calling this method 5610 - Processes samples in parallel based on parameters.lcms_collection.cores 5611 - Raw MS1 data loaded by gap-filling is automatically reused by MS1 association 5612 - MS2 spectral search requires add_ms2=True and msp_file_path 5613 - FlashEntropy library is created once and reused across all samples 5614 - More efficient than calling individual methods separately 5615 - After gap-filling, sets missing_mass_features_searched = True 5616 - Mass features remain loaded in memory for downstream processing 5617 - For more advanced workflows, use process_samples_pipeline() directly 5618 5619 Examples 5620 -------- 5621 >>> # Prepare spectral library for MS2 search 5622 >>> from corems.molecular_id.search.database_interfaces import MSPInterface 5623 >>> my_msp = MSPInterface(file_path='path/to/library.msp') 5624 >>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library( 5625 ... polarity='negative', 5626 ... format='flashentropy', 5627 ... normalize=True, 5628 ... fe_kwargs={ 5629 ... 'normalize_intensity': True, 5630 ... 'min_ms2_difference_in_da': 0.02, 5631 ... 'max_ms2_tolerance_in_da': 0.01, 5632 ... 'max_indexed_mz': 3000, 5633 ... 'precursor_ions_removal_da': None, 5634 ... 'noise_threshold': 0, 5635 ... } 5636 ... ) 5637 >>> 5638 >>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search 5639 >>> results = lcms_collection.process_consensus_features( 5640 ... load_representatives=True, 5641 ... perform_gap_filling=True, 5642 ... add_ms1=True, 5643 ... add_ms2=True, 5644 ... molecular_formula_search=True, 5645 ... ms2_spectral_search=True, 5646 ... spectral_lib=spectral_lib, 5647 ... molecular_metadata=molecular_metadata 5648 ... ) 5649 5650 See Also 5651 -------- 5652 process_samples_pipeline : Generic pipeline executor for custom workflows 5653 fill_missing_cluster_features : Original gap-filling method 5654 reload_representative_mass_features : Original reload method 5655 """ 5656 from corems.mass_spectra.calc.lc_calc_operations import ( 5657 GapFillOperation, ReloadFeaturesOperation, MolecularFormulaSearchOperation, 5658 MS2SpectralSearchOperation, LoadEICsOperation 5659 ) 5660 5661 # Validate that at least one meaningful operation is enabled 5662 has_operations = ( 5663 perform_gap_filling or 5664 load_representatives or 5665 molecular_formula_search or 5666 ms2_spectral_search or 5667 gather_eics or 5668 add_ms1 or 5669 add_ms2 5670 ) 5671 5672 if not has_operations: 5673 raise ValueError( 5674 "At least one operation must be enabled: perform_gap_filling, load_representatives, " 5675 "molecular_formula_search, ms2_spectral_search, gather_eics, add_ms1, or add_ms2" 5676 ) 5677 5678 # Validate prerequisites for gap-filling 5679 if perform_gap_filling: 5680 if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: 5681 raise ValueError( 5682 "Cannot perform gap-filling: cluster_summary_dataframe not set. " 5683 "You must run add_consensus_mass_features() before calling process_consensus_features()." 5684 ) 5685 5686 # Validate prerequisites for MS2 spectral search 5687 if ms2_spectral_search: 5688 if spectral_lib is None: 5689 raise ValueError( 5690 "MS2 spectral search requires spectral_lib to be provided. " 5691 "Create it using MSPInterface.get_metabolomics_spectra_library() before calling this method." 5692 ) 5693 # Check if mass features will be loaded OR are already loaded 5694 # (The operation's can_execute will check if MS2 spectra are actually present) 5695 if not load_representatives and not perform_gap_filling: 5696 # Check if at least one sample has mass features loaded 5697 # This allows MS2 search on already-loaded features 5698 has_loaded_features = any( 5699 len(self[i].mass_features) > 0 if hasattr(self[i], 'mass_features') and self[i].mass_features is not None else False 5700 for i in range(len(self.samples)) 5701 ) 5702 if not has_loaded_features: 5703 raise ValueError( 5704 "MS2 spectral search requires mass features to be loaded. " 5705 "Either set load_representatives=True or perform_gap_filling=True to load them, " 5706 "or load them in a previous call to process_consensus_features() before calling " 5707 "with ms2_spectral_search=True." 5708 ) 5709 5710 # Build pipeline 5711 operations = [] 5712 5713 if perform_gap_filling: 5714 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5715 operations.append(GapFillOperation('gap_fill', expand_on_miss=expand_on_miss)) 5716 5717 if load_representatives: 5718 operations.append(ReloadFeaturesOperation( 5719 'reload', 5720 add_ms1=add_ms1, 5721 add_ms2=add_ms2, 5722 auto_process_ms2=add_ms2, # Auto-process MS2 if add_ms2 is enabled 5723 ms2_scan_filter=ms2_scan_filter 5724 )) 5725 5726 if molecular_formula_search: 5727 operations.append(MolecularFormulaSearchOperation('mf_search')) 5728 5729 if ms2_spectral_search: 5730 operations.append(MS2SpectralSearchOperation( 5731 'ms2_search', 5732 ms2_scan_filter=ms2_scan_filter 5733 )) 5734 # Store spectral library and metadata for runtime preparation 5735 self._spectral_lib = spectral_lib 5736 self._spectral_search_molecular_metadata = molecular_metadata 5737 5738 if gather_eics: 5739 operations.append(LoadEICsOperation('load_eics')) 5740 5741 # Execute pipeline (description auto-generated from operations) 5742 results = self.process_samples_pipeline( 5743 operations, 5744 keep_raw_data=keep_raw_data, 5745 show_progress=show_progress 5746 ) 5747 5748 # Store molecular metadata if spectral search was performed 5749 if ms2_spectral_search and hasattr(self, '_spectral_search_molecular_metadata'): 5750 # This allows users to access the metadata for reporting 5751 self.spectral_search_molecular_metadata = self._spectral_search_molecular_metadata 5752 # Post-processing 5753 if perform_gap_filling: 5754 # Combine induced mass features into dataframe 5755 self._combine_mass_features(induced_features=True) 5756 # Mark that gap-filling has been performed 5757 self.missing_mass_features_searched = True 5758 5759 # Add ._eic_mz to induced_mass_features_dataframe if it exists 5760 if self.induced_mass_features_dataframe is not None and len(self.induced_mass_features_dataframe) > 0: 5761 eics_mz = [] 5762 for i, row in self.induced_mass_features_dataframe.iterrows(): 5763 sample_id = row['sample_id'] 5764 sample = self[sample_id] 5765 if row['mf_id'] in sample.induced_mass_features.keys(): 5766 eic_mz = sample.induced_mass_features[row['mf_id']]._eic_mz 5767 eics_mz.append(eic_mz) 5768 else: 5769 eics_mz.append(None) 5770 self.induced_mass_features_dataframe['_eic_mz'] = eics_mz 5771 5772 # Clear mass features from samples to free memory 5773 for sample_name in self.samples: 5774 self._lcms[sample_name].induced_mass_features = {} 5775 5776 # Associate EICs with mass features if they were loaded 5777 # This must happen after all operations complete to work on the actual sample objects 5778 if gather_eics: 5779 print("\nAssociating EICs with mass features:") 5780 from tqdm import tqdm 5781 5782 for sample_id in tqdm(range(len(self.samples)), unit="sample", ncols=80): 5783 sample = self[sample_id] 5784 if sample.eics: # Only if EICs were loaded 5785 # Associate EICs with regular mass features 5786 sample.associate_eics_with_mass_features(induced=False) 5787 # Associate EICs with induced mass features 5788 sample.associate_eics_with_mass_features(induced=True) 5789 5790 return results
23def find_closest(A, target): 24 """Find the index of closest value in A to each value in target. 25 26 Parameters 27 ---------- 28 A : :obj:`~numpy.array` 29 The array to search (blueprint). A must be sorted. 30 target : :obj:`~numpy.array` 31 The array of values to search for. target must be sorted. 32 33 Returns 34 ------- 35 :obj:`~numpy.array` 36 The indices of the closest values in A to each value in target. 37 """ 38 idx = A.searchsorted(target) 39 idx = np.clip(idx, 1, len(A) - 1) 40 left = A[idx - 1] 41 right = A[idx] 42 idx -= target - left < right - target 43 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.
46class LCCalculations: 47 """Methods for performing LC calculations on mass spectra data. 48 49 Notes 50 ----- 51 This class is intended to be used as a mixin for the LCMSBase class. 52 53 Methods 54 ------- 55 * get_max_eic(eic_data). 56 Returns the maximum EIC value from the given EIC data. A static method. 57 * smooth_tic(tic). 58 Smooths the TIC data using the specified smoothing method and settings. 59 * eic_centroid_detector(rt, eic, max_eic). 60 Performs EIC centroid detection on the given EIC data. 61 * find_nearest_scan(rt). 62 Finds the nearest scan to the given retention time. 63 * 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). 64 Returns an averaged mass spectrum object. 65 * find_mass_features(ms_level=1). 66 Find regions of interest for a given MS level (default is MS1). 67 * integrate_mass_features(drop_if_fail=False, ms_level=1). 68 Integrate mass features of interest and extracts EICs. 69 * find_c13_mass_features(). 70 Evaluate mass features and mark likely C13 isotopes. 71 * deconvolute_ms1_mass_features(). 72 Deconvolute mass features' ms1 mass spectra. 73 """ 74 75 @staticmethod 76 def get_max_eic(eic_data: dict): 77 """Returns the maximum EIC value from the given EIC data. 78 79 Notes 80 ----- 81 This is a static method. 82 83 Parameters 84 ---------- 85 eic_data : dict 86 A dictionary containing EIC data. 87 88 Returns 89 ------- 90 float 91 The maximum EIC value. 92 """ 93 max_eic = 0 94 for eic_data in eic_data.values(): 95 ind_max_eic = max(eic_data.get("EIC")) 96 max_eic = ind_max_eic if ind_max_eic > max_eic else max_eic 97 98 return max_eic 99 100 def smooth_tic(self, tic): 101 """Smooths the TIC or EIC data using the specified smoothing method and settings. 102 103 Parameters 104 ---------- 105 tic : numpy.ndarray 106 The TIC (or EIC) data to be smoothed. 107 108 Returns 109 ------- 110 numpy.ndarray 111 The smoothed TIC data. 112 """ 113 implemented_smooth_method = self.parameters.lc_ms.implemented_smooth_method 114 115 pol_order = self.parameters.lc_ms.savgol_pol_order 116 117 window_len = self.parameters.lc_ms.smooth_window 118 119 window = self.parameters.lc_ms.smooth_method 120 121 return sp.smooth_signal( 122 tic, window_len, window, pol_order, implemented_smooth_method 123 ) 124 125 def eic_centroid_detector(self, rt, eic, max_eic, apex_indexes=[]): 126 """Performs EIC centroid detection on the given EIC data. 127 128 Parameters 129 ---------- 130 rt : numpy.ndarray 131 The retention time data. 132 eic : numpy.ndarray 133 The EIC data. 134 max_eic : float 135 The maximum EIC value. 136 apex_indexes : list, optional 137 The apexes of the EIC peaks. Defaults to [], which means that the apexes will be calculated by the function. 138 139 Returns 140 ------- 141 numpy.ndarray 142 The indexes of left, apex, and right limits as a generator. 143 """ 144 145 max_prominence = self.parameters.lc_ms.peak_max_prominence_percent 146 147 max_height = self.parameters.lc_ms.peak_height_max_percent 148 149 signal_threshold = self.parameters.lc_ms.eic_signal_threshold 150 151 min_peak_datapoints = self.parameters.lc_ms.min_peak_datapoints 152 153 peak_derivative_threshold = self.parameters.lc_ms.peak_derivative_threshold 154 155 include_indexes = sp.peak_picking_first_derivative( 156 domain=rt, 157 signal=eic, 158 max_height=max_height, 159 max_prominence=max_prominence, 160 max_signal=max_eic, 161 min_peak_datapoints=min_peak_datapoints, 162 peak_derivative_threshold=peak_derivative_threshold, 163 signal_threshold=signal_threshold, 164 correct_baseline=False, 165 plot_res=False, 166 apex_indexes=apex_indexes, 167 ) 168 #include_indexes is a generator of tuples (left_index, apex_index, right_index) 169 include_indexes = list(include_indexes) 170 # Add check to make sure that there are at least 1/2 of min_peak_datapoints on either side of the apex 171 indicies = [x for x in include_indexes] 172 for idx in indicies: 173 if (idx[1] - idx[0] < min_peak_datapoints / 2) or ( 174 idx[2] - idx[1] < min_peak_datapoints / 2 175 ): 176 include_indexes.remove(idx) 177 return include_indexes 178 179 def find_nearest_scan(self, rt): 180 """Finds the nearest scan to the given retention time. 181 182 Parameters 183 ---------- 184 rt : float 185 The retention time (in minutes) to find the nearest scan for. 186 187 Returns 188 ------- 189 int 190 The scan number of the nearest scan. 191 """ 192 array_rt = np.array(self.retention_time) 193 194 scan_index = (np.abs(array_rt - rt)).argmin() 195 196 real_scan = self.scans_number[scan_index] 197 198 return real_scan 199 200 def add_peak_metrics(self, remove_by_metrics=True, induced_features=False): 201 """Add peak metrics to the mass features. 202 203 This function calculates the peak metrics for each mass feature and adds them to the mass feature objects. 204 205 Parameters 206 ---------- 207 remove_by_metrics : bool, optional 208 If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity, 209 dispersity index, and noise score. Default is True, which checks the setting in the processing parameters. 210 If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters. 211 induced_features : bool, optional 212 Whether the mass features to be integrated were induced. Default is False. 213 """ 214 # Check that at least some mass features have eic data 215 if induced_features: 216 mf_dict_values = self.induced_mass_features.values() 217 else: 218 mf_dict_values = self.mass_features.values() 219 220 if not any([mf._eic_data is not None for mf in mf_dict_values]): 221 raise ValueError( 222 "No mass features have EIC data. Run integrate_mass_features first." 223 ) 224 225 for mass_feature in mf_dict_values: 226 # Check if the mass feature has been integrated 227 if mass_feature._eic_data is not None and mass_feature.area is not None: 228 # Calculate peak metrics 229 mass_feature.calc_half_height_width() 230 mass_feature.calc_tailing_factor() 231 mass_feature.calc_dispersity_index() 232 mass_feature.calc_gaussian_similarity() 233 mass_feature.calc_noise_score() 234 235 # Remove mass features by peak metrics if designated in parameters 236 if self.parameters.lc_ms.remove_mass_features_by_peak_metrics and remove_by_metrics: 237 self._remove_mass_features_by_peak_metrics(induced_features=induced_features) 238 239 def get_average_mass_spectrum( 240 self, 241 scan_list, 242 apex_scan, 243 spectrum_mode="profile", 244 ms_level=1, 245 auto_process=True, 246 use_parser=False, 247 perform_checks=True, 248 polarity=None, 249 ms_params=None, 250 ): 251 """Returns an averaged mass spectrum object 252 253 Parameters 254 ---------- 255 scan_list : list 256 List of scan numbers to average. 257 apex_scan : int 258 Number of the apex scan 259 spectrum_mode : str, optional 260 The spectrum mode to use. Defaults to "profile". Not that only "profile" mode is supported for averaging. 261 ms_level : int, optional 262 The MS level to use. Defaults to 1. 263 auto_process : bool, optional 264 If True, the averaged mass spectrum will be auto-processed. Defaults to True. 265 use_parser : bool, optional 266 If True, the mass spectra will be obtained from the parser. Defaults to False. 267 perform_checks : bool, optional 268 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 269 polarity : int, optional 270 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) 271 ms_params : MSParameters, optional 272 The mass spectrum parameters to use. If not set (None), the globally set parameters will be used. Defaults to None. 273 274 Returns 275 ------- 276 MassSpectrumProfile 277 The averaged mass spectrum object. 278 279 Raises 280 ------ 281 ValueError 282 If the spectrum mode is not "profile". 283 If the MS level is not found in the unprocessed mass spectra dictionary. 284 If not all scan numbers are found in the unprocessed mass spectra dictionary. 285 """ 286 if perform_checks: 287 if spectrum_mode != "profile": 288 raise ValueError("Averaging only supported for profile mode") 289 290 if polarity is None: 291 # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation) 292 if self.polarity == "negative": 293 polarity = -1 294 elif self.polarity == "positive": 295 polarity = 1 296 else: 297 raise ValueError( 298 "Polarity not set for dataset, must be a set containing either 'positive' or 'negative'" 299 ) 300 301 # if not using_parser, check that scan numbers are in _ms_unprocessed 302 if not use_parser: 303 if perform_checks: 304 # Set index to scan for faster lookup 305 ms_df = ( 306 self._ms_unprocessed[ms_level] 307 .copy() 308 .set_index("scan", drop=False) 309 .sort_index() 310 ) 311 my_ms_df = ms_df.loc[scan_list] 312 # Check that all scan numbers are in the ms_df 313 if not all(np.isin(scan_list, ms_df.index)): 314 raise ValueError( 315 "Not all scan numbers found in the unprocessed mass spectra dictionary" 316 ) 317 else: 318 my_ms_df = ( 319 pd.DataFrame({"scan": scan_list}) 320 .set_index("scan") 321 .join(self._ms_unprocessed[ms_level], how="left") 322 ) 323 324 if use_parser: 325 ms_list = [ 326 self.spectra_parser.get_mass_spectrum_from_scan( 327 x, spectrum_mode=spectrum_mode, auto_process=False 328 ) 329 for x in scan_list 330 ] 331 ms_mz = [x._mz_exp for x in ms_list] 332 ms_int = [x._abundance for x in ms_list] 333 my_ms_df = [] 334 for i in np.arange(len(ms_mz)): 335 my_ms_df.append( 336 pd.DataFrame( 337 {"mz": ms_mz[i], "intensity": ms_int[i], "scan": scan_list[i]} 338 ) 339 ) 340 my_ms_df = pd.concat(my_ms_df) 341 342 if not self.check_if_grid(my_ms_df): 343 my_ms_df = self.grid_data(my_ms_df) 344 345 my_ms_ave = my_ms_df.groupby("mz")["intensity"].sum().reset_index() 346 347 ms = ms_from_array_profile( 348 my_ms_ave.mz, 349 my_ms_ave.intensity, 350 self.file_location, 351 polarity=polarity, 352 auto_process=False, 353 ) 354 355 # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset 356 if ms is not None: 357 if ms_params is not None: 358 ms.parameters = ms_params 359 ms.scan_number = apex_scan 360 if auto_process: 361 ms.process_mass_spec() 362 return ms 363 364 def find_mass_features(self, ms_level=1, grid=True, assign_ms2_scans=False, ms2_scan_filter=None, 365 targeted_search=False, target_search_dict=None, accumulate_features=False): 366 """Find mass features within an LCMSBase object 367 368 Note that this is a wrapper function that calls the find_mass_features_ph function, but can be extended to support other peak picking methods in the future. 369 370 Parameters 371 ---------- 372 ms_level : int, optional 373 The MS level to use for peak picking Default is 1. 374 grid : bool, optional 375 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded), 376 used for persistent homology peak picking for profile data only. Default is True. 377 assign_ms2_scans : bool, optional 378 If True, assign MS2 scan numbers to mass features after peak picking. 379 This populates the ms2_scan_numbers attribute on each mass feature, which enables 380 choosing representative features based on MS2 availability. Default is False. 381 ms2_scan_filter : str or None, optional 382 Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd'). 383 If None, all MS2 scans are considered. Default is None. 384 targeted_search : bool, optional 385 If True, perform targeted mass feature search using the target_search_dict. 386 This mode filters data to only m/z and RT windows of interest and bypasses 387 intensity and persistence thresholds. Default is False. 388 target_search_dict : dict or None, optional 389 Dictionary containing target search parameters. Required if targeted_search is True. 390 Must contain: 391 - 'target_mz_list': list of target m/z values 392 - 'target_rt_list': list of target retention times (in minutes) 393 - 'mz_tolerance_ppm': m/z tolerance in ppm 394 - 'rt_tolerance': retention time tolerance (in minutes) 395 Optionally can contain: 396 - 'type': type label for mass features (e.g., "internal standard") 397 If not provided, defaults to "targeted" 398 Default is None. 399 accumulate_features : bool, optional 400 If True, new mass features will be added to existing features rather than replacing them. 401 This allows multiple sequential calls to find_mass_features to build up a combined set. 402 Default is False (replace existing features for backwards compatibility). 403 404 Raises 405 ------ 406 ValueError 407 If no MS level data is found on the object. 408 If persistent homology peak picking is attempted on non-profile mode data. 409 If data is not gridded and grid is False. 410 If peak picking method is not implemented. 411 If targeted_search is True but target_search_dict is None or invalid. 412 413 Returns 414 ------- 415 None, but assigns the mass_features and eics attributes to the object. 416 417 """ 418 # Validate targeted search parameters 419 if targeted_search: 420 if target_search_dict is None: 421 raise ValueError("target_search_dict must be provided when targeted_search is True") 422 required_keys = ['target_mz_list', 'target_rt_list', 'mz_tolerance_ppm', 'rt_tolerance'] 423 for key in required_keys: 424 if key not in target_search_dict: 425 raise ValueError(f"target_search_dict must contain '{key}'") 426 if len(target_search_dict['target_mz_list']) != len(target_search_dict['target_rt_list']): 427 raise ValueError("target_mz_list and target_rt_list must have the same length") 428 429 pp_method = self.parameters.lc_ms.peak_picking_method 430 431 if pp_method == "persistent homology": 432 msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level] 433 if all(msx_scan_df["ms_format"] == "profile"): 434 # Determine mass feature type 435 if targeted_search: 436 mf_type = target_search_dict.get('type', 'targeted') 437 else: 438 mf_type = 'untargeted' 439 self.find_mass_features_ph(ms_level=ms_level, grid=grid, 440 targeted_search=targeted_search, 441 target_search_dict=target_search_dict, 442 mf_type=mf_type, 443 accumulate_features=accumulate_features) 444 else: 445 raise ValueError( 446 "MS{} scans are not profile mode, which is required for persistent homology peak picking.".format( 447 ms_level 448 ) 449 ) 450 elif pp_method == "centroided_persistent_homology": 451 msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level] 452 if all(msx_scan_df["ms_format"] == "centroid"): 453 # Determine mass feature type 454 if targeted_search: 455 mf_type = target_search_dict.get('type', 'targeted') 456 else: 457 mf_type = 'untargeted' 458 self.find_mass_features_ph_centroid(ms_level=ms_level, 459 targeted_search=targeted_search, 460 target_search_dict=target_search_dict, 461 mf_type=mf_type, 462 accumulate_features=accumulate_features) 463 else: 464 raise ValueError( 465 "MS{} scans are not centroid mode, which is required for persistent homology centroided peak picking.".format( 466 ms_level 467 ) 468 ) 469 else: 470 raise ValueError("Peak picking method not implemented") 471 472 # Cluster mass features to remove redundant features 473 self.cluster_mass_features(drop_children=True) 474 475 # Optionally assign MS2 scan numbers to mass features during peak picking 476 # This helps with choosing representative features that have MS2 data 477 if assign_ms2_scans: 478 try: 479 self._find_ms2_scans_for_mass_features( 480 mf_ids=None, # Process all mass features 481 scan_filter=ms2_scan_filter 482 ) 483 except ValueError: 484 # No MS2 scans found - this is okay, just skip 485 pass 486 487 # Remove noisey mass features if designated in parameters 488 if self.parameters.lc_ms.remove_redundant_mass_features and not targeted_search: 489 self._remove_redundant_mass_features() 490 491 def integrate_mass_features( 492 self, drop_if_fail=True, drop_duplicates=True, ms_level=1, induced_features=False 493 ): 494 """Integrate mass features and extract EICs. 495 496 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. 497 498 Parameters 499 ---------- 500 drop_if_fail : bool, optional 501 Whether to drop mass features if the EIC limit calculations fail. 502 Default is True. 503 drop_duplicates : bool, optional 504 Whether to mass features that appear to be duplicates 505 (i.e., mz is similar to another mass feature and limits of the EIC are similar or encapsulating). 506 Default is True. 507 ms_level : int, optional 508 The MS level to use. Default is 1. 509 induced_features : bool, optional 510 Whether the mass features to be intergrated were induced. Default is False. 511 512 Raises 513 ------ 514 ValueError 515 If no mass features are found. 516 If no MS level data is found for the given MS level (either in data or in the scan data) 517 518 Returns 519 ------- 520 None, but populates the eics attribute on the LCMSBase object and adds data (start_scan, final_scan, area) to the mass_features attribute. 521 522 Notes 523 ----- 524 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). 525 """ 526 527 # Check if there is data 528 if ms_level in self._ms_unprocessed.keys(): 529 raw_data = self._ms_unprocessed[ms_level].copy() 530 else: 531 raise ValueError("No MS level " + str(ms_level) + " data found") 532 533 # Check if mass_spectrum exists on each mass feature 534 if induced_features: 535 mf_dict = self.induced_mass_features 536 if len(mf_dict) == 0: 537 raise ValueError( 538 "No induced mass features found, did you run fill_missing_cluster_features() first?" 539 ) 540 541 ## remove not found induced mass features by mz <= 0 (-99 indicator) 542 # also remove any where mz is nan 543 mf_dict = {k:v for k, v in mf_dict.items() if v.mz > 0 and not np.isnan(v.mz)} 544 545 else: 546 mf_dict = self.mass_features 547 if len(mf_dict) == 0: 548 raise ValueError( 549 "No mass features found, did you run find_mass_features() first?" 550 ) 551 552 # Subset scan data to only include correct ms_level 553 scan_df_sub = self.scan_df[ 554 self.scan_df["ms_level"] == int(ms_level) 555 ].reset_index(drop=True) 556 if scan_df_sub.empty: 557 raise ValueError("No MS level " + ms_level + " data found in scan data") 558 scan_df_sub = scan_df_sub[["scan", "scan_time"]].copy() 559 560 mzs_to_extract = np.unique([mf.mz for mf in mf_dict.values()]) 561 mzs_to_extract.sort() 562 563 # Pre-sort raw_data by mz for faster filtering 564 raw_data_sorted = raw_data.sort_values(["mz", "scan"]).reset_index(drop=True) 565 raw_data_mz = raw_data_sorted["mz"].values 566 567 # Get EICs for each unique mz in mass features list 568 for mz in mzs_to_extract: 569 mz_max = mz + self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 570 mz_min = mz - self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 571 572 # Use binary search for faster mz range filtering 573 left_idx = np.searchsorted(raw_data_mz, mz_min, side="left") 574 right_idx = np.searchsorted(raw_data_mz, mz_max, side="right") 575 raw_data_sub = raw_data_sorted.iloc[left_idx:right_idx].copy() 576 577 raw_data_sub = ( 578 raw_data_sub.groupby(["scan"])["intensity"].sum().reset_index() 579 ) 580 raw_data_sub = scan_df_sub.merge(raw_data_sub, on="scan", how="left") 581 raw_data_sub["intensity"] = raw_data_sub["intensity"].fillna(0) 582 myEIC = EIC_Data( 583 scans=raw_data_sub["scan"].values, 584 time=raw_data_sub["scan_time"].values, 585 eic=raw_data_sub["intensity"].values, 586 ) 587 # Smooth EIC 588 smoothed_eic = self.smooth_tic(myEIC.eic) 589 smoothed_eic[smoothed_eic < 0] = 0 590 myEIC.eic_smoothed = smoothed_eic 591 self.eics[mz] = myEIC 592 593 # Get limits of mass features using EIC centroid detector and integrate 594 for idx, mass_feature in list(mf_dict.items()): 595 mz = mass_feature.mz 596 apex_scan = mass_feature.apex_scan 597 598 # Pull EIC data and find apex scan index 599 myEIC = self.eics[mz] 600 mf_dict[idx]._eic_data = myEIC 601 mf_dict[idx]._eic_mz = mz 602 apex_index = np.searchsorted(myEIC.scans, apex_scan) 603 604 # Find left and right limits of peak using EIC centroid detector, add to EICData 605 centroid_eics = self.eic_centroid_detector( 606 myEIC.time, 607 myEIC.eic_smoothed, 608 mass_feature.intensity * 1.1, 609 apex_indexes=[int(apex_index)], 610 ) 611 l_a_r_scan_idx = [i for i in centroid_eics] 612 if len(l_a_r_scan_idx) > 0: 613 # Calculate number of consecutive scans with intensity > 0 and check if it is above the minimum consecutive scans 614 # Find the number of consecutive non-zero values in the EIC segment 615 mask = myEIC.eic[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1] > 0 616 # Find the longest run of consecutive True values 617 if np.any(mask): 618 # Find indices where mask changes value 619 diff = np.diff(np.concatenate(([0], mask.astype(int), [0]))) 620 starts = np.where(diff == 1)[0] 621 ends = np.where(diff == -1)[0] 622 consecutive_scans = (ends - starts).max() 623 else: 624 consecutive_scans = 0 625 if consecutive_scans < self.parameters.lc_ms.consecutive_scan_min: 626 mf_dict.pop(idx) 627 continue 628 # Add start and final scan to mass_features and EICData 629 left_scan, right_scan = ( 630 myEIC.scans[l_a_r_scan_idx[0][0]], 631 myEIC.scans[l_a_r_scan_idx[0][2]], 632 ) 633 mf_scan_apex = [(left_scan, int(apex_scan), right_scan)] 634 myEIC.apexes = myEIC.apexes + mf_scan_apex 635 mf_dict[idx].start_scan = left_scan 636 mf_dict[idx].final_scan = right_scan 637 638 # Find area under peak using limits from EIC centroid detector, add to mass_features and EICData 639 area = np.trapezoid( 640 myEIC.eic_smoothed[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 641 myEIC.time[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 642 ) 643 myEIC.areas = myEIC.areas + [area] 644 self.eics[mz] = myEIC 645 mf_dict[idx]._area = area 646 else: 647 if drop_if_fail is True: 648 mf_dict.pop(idx) 649 650 if drop_duplicates: 651 # Prepare mass feature dataframe 652 if induced_features: 653 mf_df = self.mass_features_to_df(induced_features = True).copy() 654 mf_df = mf_df[mf_df.start_scan.notna()] 655 else: 656 mf_df = self.mass_features_to_df(induced_features = False).copy() 657 658 # 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 659 # Keep the first mass feature (highest persistence) 660 for idx, mass_feature in mf_df.iterrows(): 661 mz = mass_feature.mz 662 apex_scan = mass_feature.apex_scan 663 664 mf_df["mz_diff_ppm"] = np.abs(mf_df["mz"] - mz) / mz * 10**6 665 mf_df_sub = mf_df[ 666 mf_df["mz_diff_ppm"] 667 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 668 * 10**6 669 ].copy() 670 671 # 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 672 for idx2, mass_feature2 in mf_df_sub.iterrows(): 673 if idx2 != idx: 674 if ( 675 mass_feature2.start_scan >= mass_feature.start_scan 676 and mass_feature2.final_scan <= mass_feature.final_scan 677 ): 678 if idx2 in self.mass_features.keys(): 679 self.mass_features.pop(idx2) 680 681 # Filter MS2 scans to only include those within integration bounds 682 # This ensures MS2 scans outside start_scan to final_scan are removed 683 if induced_features: 684 self._filter_ms2_scans_by_integration_bounds(mf_dict=self.induced_mass_features) 685 else: 686 self._filter_ms2_scans_by_integration_bounds(mf_dict=self.mass_features) 687 688 def find_c13_mass_features(self): 689 """Mark likely C13 isotopes and connect to monoisoitopic mass features. 690 691 Returns 692 ------- 693 None, but populates the monoisotopic_mf_id and isotopologue_type attributes to the indivual LCMSMassFeatures within the mass_features attribute of the LCMSBase object. 694 695 Raises 696 ------ 697 ValueError 698 If no mass features are found. 699 """ 700 verbose = self.parameters.lc_ms.verbose_processing 701 if verbose: 702 print("evaluating mass features for C13 isotopes") 703 if self.mass_features is None: 704 raise ValueError("No mass features found, run find_mass_features() first") 705 706 # Data prep fo sparse distance matrix 707 dims = ["mz", "scan_time"] 708 mf_df = self.mass_features_to_df().copy() 709 # Drop mass features that have no area (these are likely to be noise) 710 mf_df = mf_df[mf_df["area"].notnull()] 711 mf_df["mf_id"] = mf_df.index.values 712 dims = ["mz", "scan_time"] 713 714 # Sort my ascending mz so we always get the monoisotopic mass first, regardless of the order/intensity of the mass features 715 mf_df = mf_df.sort_values(by=["mz"]).reset_index(drop=True).copy() 716 717 mz_diff = 1.003355 # C13-C12 mass difference 718 tol = [ 719 mf_df["mz"].median() 720 * self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 721 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance * 0.5, 722 ] # mz, in relative; scan_time in minutes 723 724 # Compute inter-feature distances 725 distances = None 726 for i in range(len(dims)): 727 # Construct k-d tree 728 values = mf_df[dims[i]].values 729 tree = KDTree(values.reshape(-1, 1)) 730 731 max_tol = tol[i] 732 if dims[i] == "mz": 733 # Maximum absolute tolerance 734 max_tol = mz_diff + tol[i] 735 736 # Compute sparse distance matrix 737 # the larger the max_tol, the slower this operation is 738 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 739 740 # Only consider forward case, exclude diagonal 741 sdm = sparse.triu(sdm, k=1) 742 743 if dims[i] == "mz": 744 min_tol = mz_diff - tol[i] 745 # Get only the ones that are above the min tol 746 idx = sdm.data > min_tol 747 748 # Reconstruct sparse distance matrix 749 sdm = sparse.coo_matrix( 750 (sdm.data[idx], (sdm.row[idx], sdm.col[idx])), 751 shape=(len(values), len(values)), 752 ) 753 754 # Cast as binary matrix 755 sdm.data = np.ones_like(sdm.data) 756 757 # Stack distances 758 if distances is None: 759 distances = sdm 760 else: 761 distances = distances.multiply(sdm) 762 763 # Extract indices of within-tolerance points 764 distances = distances.tocoo() 765 pairs = np.stack((distances.row, distances.col), axis=1) # C12 to C13 pairs 766 767 # Turn pairs (which are index of mf_df) into mf_id and then into two dataframes to join to mf_df 768 pairs_mf = pairs.copy() 769 pairs_mf[:, 0] = mf_df.iloc[pairs[:, 0]].mf_id.values 770 pairs_mf[:, 1] = mf_df.iloc[pairs[:, 1]].mf_id.values 771 772 # Connect monoisotopic masses with isotopologes within mass_features 773 monos = np.setdiff1d(np.unique(pairs_mf[:, 0]), np.unique(pairs_mf[:, 1])) 774 for mono in monos: 775 self.mass_features[mono].monoisotopic_mf_id = mono 776 pairs_iso_df = pd.DataFrame(pairs_mf, columns=["parent", "child"]) 777 while not pairs_iso_df.empty: 778 pairs_iso_df = pairs_iso_df.set_index("parent", drop=False) 779 m1_isos = pairs_iso_df.loc[monos, "child"].unique() 780 for iso in m1_isos: 781 # Set monoisotopic_mf_id and isotopologue_type for isotopologues 782 parent = pairs_mf[pairs_mf[:, 1] == iso, 0] 783 if len(parent) > 1: 784 # Choose the parent that is closest in time to the isotopologue 785 parent_time = [self.mass_features[p].retention_time for p in parent] 786 time_diff = [ 787 np.abs(self.mass_features[iso].retention_time - x) 788 for x in parent_time 789 ] 790 parent = parent[np.argmin(time_diff)] 791 else: 792 parent = parent[0] 793 self.mass_features[iso].monoisotopic_mf_id = self.mass_features[ 794 parent 795 ].monoisotopic_mf_id 796 if self.mass_features[iso].monoisotopic_mf_id is not None: 797 mass_diff = ( 798 self.mass_features[iso].mz 799 - self.mass_features[ 800 self.mass_features[iso].monoisotopic_mf_id 801 ].mz 802 ) 803 self.mass_features[iso].isotopologue_type = "13C" + str( 804 int(round(mass_diff, 0)) 805 ) 806 807 # Drop the mono and iso from the pairs_iso_df 808 pairs_iso_df = pairs_iso_df.drop( 809 index=monos, errors="ignore" 810 ) # Drop pairs where the parent is a child that is a child of a root 811 pairs_iso_df = pairs_iso_df.set_index("child", drop=False) 812 pairs_iso_df = pairs_iso_df.drop(index=m1_isos, errors="ignore") 813 814 if not pairs_iso_df.empty: 815 # Get new monos, recognizing that these are just 13C isotopologues that are connected to other 13C isotopologues to repeat the process 816 monos = np.setdiff1d( 817 np.unique(pairs_iso_df.parent), np.unique(pairs_iso_df.child) 818 ) 819 if verbose: 820 # Report fraction of compounds annotated with isotopes 821 mf_df["c13_flag"] = np.where( 822 np.logical_or( 823 np.isin(mf_df["mf_id"], pairs_mf[:, 0]), 824 np.isin(mf_df["mf_id"], pairs_mf[:, 1]), 825 ), 826 1, 827 0, 828 ) 829 print( 830 str(round(len(mf_df[mf_df["c13_flag"] == 1]) / len(mf_df), ndigits=3)) 831 + " of mass features have or are C13 isotopes" 832 ) 833 834 def deconvolute_ms1_mass_features(self): 835 """Deconvolute MS1 mass features 836 837 Deconvolute mass features ms1 spectrum based on the correlation of all masses within a spectrum over the EIC of the mass features 838 839 Parameters 840 ---------- 841 None 842 843 Returns 844 ------- 845 None, but assigns the _ms_deconvoluted_idx, mass_spectrum_deconvoluted_parent, 846 and associated_mass_features_deconvoluted attributes to the mass features in the 847 mass_features attribute of the LCMSBase object. 848 849 Raises 850 ------ 851 ValueError 852 If no mass features are found, must run find_mass_features() first. 853 If no EICs are found, did you run integrate_mass_features() first? 854 855 """ 856 # Checks for set mass_features and eics 857 if self.mass_features is None: 858 raise ValueError( 859 "No mass features found, did you run find_mass_features() first?" 860 ) 861 862 if self.eics == {}: 863 raise ValueError( 864 "No EICs found, did you run integrate_mass_features() first?" 865 ) 866 867 if 1 not in self._ms_unprocessed.keys(): 868 raise ValueError("No unprocessed MS1 spectra found.") 869 870 # Prep ms1 data 871 ms1_data = self._ms_unprocessed[1].copy() 872 ms1_data = ms1_data.set_index("scan") 873 874 # Prep mass feature summary 875 mass_feature_df = self.mass_features_to_df() 876 877 # Loop through each mass feature 878 for mf_id, mass_feature in self.mass_features.items(): 879 # Check that the mass_feature.mz attribute == the mz of the mass feature in the mass_feature_df 880 if mass_feature.mz != mass_feature.ms1_peak.mz_exp: 881 continue 882 883 # Get the left and right limits of the EIC of the mass feature 884 l_scan, _, r_scan = mass_feature._eic_data.apexes[0] 885 886 # Pull from the _ms1_unprocessed data the scan range of interest and sort by mz 887 ms1_data_sub = ms1_data.loc[l_scan:r_scan].copy() 888 ms1_data_sub = ms1_data_sub.sort_values(by=["mz"]).reset_index(drop=False) 889 890 # Get the centroided masses of the mass feature 891 mf_mspeak_mzs = mass_feature.mass_spectrum.mz_exp 892 893 # Find the closest mz in the ms1 data to the centroided masses of the mass feature 894 ms1_data_sub["mass_feature_mz"] = mf_mspeak_mzs[ 895 find_closest(mf_mspeak_mzs, ms1_data_sub.mz.values) 896 ] 897 898 # Drop rows with mz_diff > 0.01 between the mass feature mz and the ms1 data mz 899 ms1_data_sub["mz_diff_rel"] = ( 900 np.abs(ms1_data_sub["mass_feature_mz"] - ms1_data_sub["mz"]) 901 / ms1_data_sub["mz"] 902 ) 903 ms1_data_sub = ms1_data_sub[ 904 ms1_data_sub["mz_diff_rel"] 905 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 906 ].reset_index(drop=True) 907 908 # Group by mass_feature_mz and scan and sum intensity 909 ms1_data_sub_group = ( 910 ms1_data_sub.groupby(["mass_feature_mz", "scan"])["intensity"] 911 .sum() 912 .reset_index() 913 ) 914 915 # Calculate the correlation of the intensities of the mass feature and the ms1 data (set to 0 if no intensity) 916 corr = ( 917 ms1_data_sub_group.pivot( 918 index="scan", columns="mass_feature_mz", values="intensity" 919 ) 920 .fillna(0) 921 .corr() 922 ) 923 924 # Subset the correlation matrix to only include the masses of the mass feature and those with a correlation > 0.8 925 decon_corr_min = self.parameters.lc_ms.ms1_deconvolution_corr_min 926 927 # Try catch for KeyError in case the mass feature mz is not in the correlation matrix 928 try: 929 corr_subset = corr.loc[mass_feature.mz] 930 except KeyError: 931 # If the mass feature mz is not in the correlation matrix, skip to the next mass feature 932 continue 933 934 corr_subset = corr_subset[corr_subset > decon_corr_min] 935 936 # Get the masses from the mass spectrum that are the result of the deconvolution 937 mzs_decon = corr_subset.index.values 938 939 # Get the indices of the mzs_decon in mass_feature.mass_spectrum.mz_exp and assign to the mass feature 940 mzs_decon_idx = [ 941 id 942 for id, mz in enumerate(mass_feature.mass_spectrum.mz_exp) 943 if mz in mzs_decon 944 ] 945 mass_feature._ms_deconvoluted_idx = mzs_decon_idx 946 947 # Check if the mass feature's ms1 peak is the largest in the deconvoluted mass spectrum 948 if ( 949 mass_feature.ms1_peak.abundance 950 == mass_feature.mass_spectrum.abundance[mzs_decon_idx].max() 951 ): 952 mass_feature.mass_spectrum_deconvoluted_parent = True 953 else: 954 mass_feature.mass_spectrum_deconvoluted_parent = False 955 956 # Check for other mass features that are in the deconvoluted mass spectrum and add the deconvoluted mass spectrum to the mass feature 957 # Subset mass_feature_df to only include mass features that are within the clustering tolerance 958 mass_feature_df_sub = mass_feature_df[ 959 abs(mass_feature.retention_time - mass_feature_df["scan_time"]) 960 < self.parameters.lc_ms.mass_feature_cluster_rt_tolerance 961 ].copy() 962 # Calculate the mz difference in ppm between the mass feature and the peaks in the deconvoluted mass spectrum 963 mass_feature_df_sub["mz_diff_ppm"] = [ 964 np.abs(mzs_decon - mz).min() / mz * 10**6 965 for mz in mass_feature_df_sub["mz"] 966 ] 967 # Subset mass_feature_df to only include mass features that are within 1 ppm of the deconvoluted masses 968 mfs_associated_decon = mass_feature_df_sub[ 969 mass_feature_df_sub["mz_diff_ppm"] 970 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 10**6 971 ].index.values 972 973 mass_feature.associated_mass_features_deconvoluted = mfs_associated_decon 974 975 def _remove_redundant_mass_features( 976 self, 977 ) -> None: 978 """ 979 Identify and remove redundant mass features that are likely contaminants based on their m/z values and scan frequency. 980 Especially useful for HILIC data where signals do not return to baseline between peaks or for data with significant background noise. 981 982 Contaminants are characterized by: 983 1. Similar m/z values (within ppm_tolerance) 984 2. High frequency across scan numbers (ubiquitous presence) 985 986 Notes 987 ----- 988 Depends on self.mass_features being populated, uses the parameters in self.parameters.lc_ms for tolerances (mass_feature_cluster_mz_tolerance_rel) 989 """ 990 ppm_tolerance = self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel*1e6 991 min_scan_frequency = self.parameters.lc_ms.redundant_scan_frequency_min 992 n_retain = self.parameters.lc_ms.redundant_feature_retain_n 993 994 df = self.mass_features_to_df() 995 996 if df.empty: 997 return pd.DataFrame() 998 # df index should be mf_id 999 if 'mf_id' not in df.columns: 1000 if 'mf_id' in df.index.names: 1001 df = df.reset_index() 1002 else: 1003 raise ValueError("DataFrame must contain 'mf_id' column or index.") 1004 1005 # Sort by m/z for efficient grouping 1006 df_sorted = df.sort_values('mz').reset_index(drop=True) 1007 1008 # Calculate total number of unique scans for frequency calculation 1009 # Calculate total possible scans (check the cluster rt tolerance and the min rt and max rt of the data) 1010 total_time = self.scan_df['scan_time'].max() - self.scan_df['scan_time'].min() 1011 cluster_rt_tolerance = self.parameters.lc_ms.mass_feature_cluster_rt_tolerance 1012 # If the feature was detected in every possible scan (and then rolled up), it would be in this many scans 1013 total_scans = int(total_time / cluster_rt_tolerance) + 1 1014 1015 # Group similar m/z values using ppm tolerance 1016 mz_groups = [] 1017 current_group = [] 1018 1019 for i, row in df_sorted.iterrows(): 1020 current_mz = row['mz'] 1021 1022 if not current_group: 1023 # Start first group 1024 current_group = [i] 1025 else: 1026 # Check if current m/z is within tolerance of group representative 1027 group_representative_mz = df_sorted.iloc[current_group[0]]['mz'] 1028 ppm_diff = abs(current_mz - group_representative_mz) / group_representative_mz * 1e6 1029 1030 if ppm_diff <= ppm_tolerance: 1031 # Add to current group 1032 current_group.append(i) 1033 else: 1034 # Start new group, but first process current group 1035 if len(current_group) > 0: 1036 mz_groups.append(current_group) 1037 current_group = [i] 1038 1039 # Don't forget the last group 1040 if current_group: 1041 mz_groups.append(current_group) 1042 1043 # Analyze each m/z group for contaminant characteristics 1044 1045 for group_indices in mz_groups: 1046 group_data = df_sorted.iloc[group_indices] 1047 1048 # Calculate group statistics 1049 unique_scans = group_data['apex_scan'].nunique() 1050 scan_frequency = unique_scans / total_scans 1051 1052 # Check if this group meets contaminant criteria 1053 if scan_frequency >= min_scan_frequency: 1054 group_data = group_data.sort_values('intensity', ascending=False) 1055 non_representative_mf_id = group_data.iloc[n_retain:]['mf_id'].tolist() # These will be removed 1056 1057 self.mass_features = { 1058 k: v for k, v in self.mass_features.items() if k not in non_representative_mf_id 1059 } 1060 1061 def _remove_mass_features_by_peak_metrics(self, induced_features=False) -> None: 1062 """Remove mass features based on peak metrics defined in mass_feature_attribute_filter_dict. 1063 1064 This method filters mass features based on various peak shape metrics and quality indicators 1065 such as noise scores, Gaussian similarity, tailing factors, dispersity index, etc. 1066 1067 The filtering criteria are defined in the mass_feature_attribute_filter_dict parameter, 1068 which should contain attribute names as keys and filter specifications as values. 1069 1070 Filter specification format: 1071 {attribute_name: {'value': threshold, 'operator': comparison}} 1072 1073 Available operators: 1074 - '>' or 'greater': Keep features where attribute > threshold 1075 - '<' or 'less': Keep features where attribute < threshold 1076 - '>=' or 'greater_equal': Keep features where attribute >= threshold 1077 - '<=' or 'less_equal': Keep features where attribute <= threshold 1078 1079 Examples: 1080 - {'noise_score_max': {'value': 0.5, 'operator': '>='}} - Keep features with noise_score_max >= 0.5 1081 - {'dispersity_index': {'value': 0.1, 'operator': '<'}} - Keep features with dispersity_index < 0.1 1082 - {'gaussian_similarity': {'value': 0.7, 'operator': '>='}} - Keep features with gaussian_similarity >= 0.7 1083 1084 Parameters 1085 ---------- 1086 induced_features : bool, optional 1087 If True, filter induced_mass_features instead of regular mass_features. Default is False. 1088 1089 Returns 1090 ------- 1091 None 1092 Modifies self.mass_features or self.induced_mass_features in place by removing filtered features. 1093 1094 Raises 1095 ------ 1096 ValueError 1097 If no mass features are found, if an invalid attribute is specified, or if filter specification is malformed. 1098 """ 1099 # Select the appropriate mass features dictionary 1100 if induced_features: 1101 mf_dict = self.induced_mass_features 1102 mf_type = "induced mass features" 1103 else: 1104 mf_dict = self.mass_features 1105 mf_type = "mass features" 1106 1107 if mf_dict is None or len(mf_dict) == 0: 1108 raise ValueError(f"No {mf_type} found, run {'gap filling' if induced_features else 'find_mass_features()'} first") 1109 1110 filter_dict = self.parameters.lc_ms.mass_feature_attribute_filter_dict 1111 1112 if not filter_dict: 1113 # No filtering criteria specified, return early 1114 return 1115 1116 verbose = self.parameters.lc_ms.verbose_processing 1117 initial_count = len(mf_dict) 1118 1119 if verbose: 1120 print(f"Filtering {mf_type} using peak metrics. Initial count: {initial_count}") 1121 1122 # List to collect IDs of mass features to remove 1123 features_to_remove = [] 1124 1125 for mf_id, mass_feature in mf_dict.items(): 1126 should_remove = False 1127 1128 for attribute_name, filter_spec in filter_dict.items(): 1129 # Validate filter specification structure 1130 if not isinstance(filter_spec, dict): 1131 raise ValueError(f"Filter specification for '{attribute_name}' must be a dictionary with 'value' and 'operator' keys") 1132 1133 if 'value' not in filter_spec or 'operator' not in filter_spec: 1134 raise ValueError(f"Filter specification for '{attribute_name}' must contain both 'value' and 'operator' keys") 1135 1136 threshold_value = filter_spec['value'] 1137 operator = filter_spec['operator'].lower().strip() 1138 1139 # Validate operator 1140 valid_operators = {'>', '<', '>=', '<=', 'greater', 'less', 'greater_equal', 'less_equal'} 1141 if operator not in valid_operators: 1142 raise ValueError(f"Invalid operator '{operator}' for attribute '{attribute_name}'. Valid operators: {valid_operators}") 1143 1144 # Normalize operator names 1145 operator_map = { 1146 'greater': '>', 1147 'less': '<', 1148 'greater_equal': '>=', 1149 'less_equal': '<=' 1150 } 1151 operator = operator_map.get(operator, operator) 1152 1153 # Get the attribute value from the mass feature 1154 try: 1155 if hasattr(mass_feature, attribute_name): 1156 attribute_value = getattr(mass_feature, attribute_name) 1157 else: 1158 raise ValueError(f"Mass feature does not have attribute '{attribute_name}'") 1159 1160 # Handle None values or attributes that haven't been calculated 1161 if attribute_value is None: 1162 if verbose: 1163 print(f"Warning: Mass feature {mf_id} has None value for '{attribute_name}'. Removing feature.") 1164 should_remove = True 1165 break 1166 1167 # Handle numpy arrays (like half_height_width which returns mean) 1168 if hasattr(attribute_value, '__len__') and not isinstance(attribute_value, str): 1169 # For arrays, we use the mean or appropriate summary statistic 1170 if attribute_name == 'half_height_width': 1171 # half_height_width property already returns the mean 1172 pass 1173 else: 1174 attribute_value = float(np.mean(attribute_value)) 1175 1176 # Handle NaN values 1177 if np.isnan(float(attribute_value)): 1178 if verbose: 1179 print(f"Warning: Mass feature {mf_id} has NaN value for '{attribute_name}'. Removing feature.") 1180 should_remove = True 1181 break 1182 1183 # Apply the threshold comparison based on operator 1184 attribute_value = float(attribute_value) 1185 threshold_value = float(threshold_value) 1186 1187 if operator == '>' and not (attribute_value > threshold_value): 1188 should_remove = True 1189 break 1190 elif operator == '<' and not (attribute_value < threshold_value): 1191 should_remove = True 1192 break 1193 elif operator == '>=' and not (attribute_value >= threshold_value): 1194 should_remove = True 1195 break 1196 elif operator == '<=' and not (attribute_value <= threshold_value): 1197 should_remove = True 1198 break 1199 1200 except (AttributeError, ValueError, TypeError) as e: 1201 if verbose: 1202 print(f"Error evaluating filter '{attribute_name}' for mass feature {mf_id}: {e}") 1203 should_remove = True 1204 break 1205 1206 if should_remove: 1207 features_to_remove.append(mf_id) 1208 1209 # Remove filtered mass features 1210 for mf_id in features_to_remove: 1211 del mf_dict[mf_id] 1212 1213 if verbose and len(features_to_remove) > 0: 1214 print(f"Removed {len(features_to_remove)} {mf_type} based on peak metrics. Remaining: {len(mf_dict)}") 1215 1216 # Update the appropriate dictionary 1217 if induced_features: 1218 self.induced_mass_features = mf_dict 1219 else: 1220 self.mass_features = mf_dict 1221 1222 # Clean up unassociated EICs and ms1 data (only for regular features) 1223 self._remove_unassociated_eics() 1224 self._remove_unassociated_ms1_spectra() 1225 1226 def _remove_unassociated_eics(self) -> None: 1227 """Remove EICs that are not associated with any mass features. 1228 1229 This method cleans up the eics attribute by removing any EICs that do not correspond to 1230 any mass features currently stored in the mass_features attribute. This is useful for 1231 freeing up memory and ensuring that only relevant EICs are retained. 1232 1233 Returns 1234 ------- 1235 None 1236 Modifies self.eics in place by removing unassociated EICs. 1237 """ 1238 if self.mass_features is None or len(self.mass_features) == 0: 1239 self.eics = {} 1240 return 1241 1242 # Get the set of m/z values associated with current mass features 1243 associated_mzs = {mf.mz for mf in self.mass_features.values()} 1244 1245 # Remove EICs that are not associated with any mass features 1246 self.eics = {mz: eic for mz, eic in self.eics.items() if mz in associated_mzs} 1247 1248 def _remove_unassociated_ms1_spectra(self) -> None: 1249 """Remove MS1 spectra that are not associated with any mass features. 1250 This method cleans up the _ms_unprocessed attribute by removing any MS1 spectra that do not correspond to 1251 any mass features currently stored in the mass_features attribute. This is useful for freeing up memory 1252 and ensuring that only relevant MS1 spectra are retained. 1253 1254 Returns 1255 ------- 1256 None 1257 """ 1258 if self.mass_features is None or len(self.mass_features) == 0: 1259 self._ms_unprocessed = {} 1260 return 1261 1262 # Get the set of m/z values associated with current mass features 1263 associated_ms1_scans = {mf.apex_scan for mf in self.mass_features.values()} 1264 associated_ms1_scans = [int(scan) for scan in associated_ms1_scans] 1265 1266 # Get keys within the _ms attribute (these are individual MassSpectrum objects) 1267 current_stored_spectra = list(set(self._ms.keys())) 1268 if len(current_stored_spectra) == 0: 1269 return 1270 current_stored_spectra = [int(scan) for scan in current_stored_spectra] 1271 1272 # Filter the current_stored_spectra to only ms1 scans 1273 current_stored_spectra_ms1 = [ scan for scan in current_stored_spectra if scan in self.ms1_scans ] 1274 1275 # Remove MS1 spectra that are not associated with any mass features 1276 scans_to_drop = [scan for scan in current_stored_spectra_ms1 if scan not in associated_ms1_scans] 1277 for scan in scans_to_drop: 1278 if scan in self._ms: 1279 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.
75 @staticmethod 76 def get_max_eic(eic_data: dict): 77 """Returns the maximum EIC value from the given EIC data. 78 79 Notes 80 ----- 81 This is a static method. 82 83 Parameters 84 ---------- 85 eic_data : dict 86 A dictionary containing EIC data. 87 88 Returns 89 ------- 90 float 91 The maximum EIC value. 92 """ 93 max_eic = 0 94 for eic_data in eic_data.values(): 95 ind_max_eic = max(eic_data.get("EIC")) 96 max_eic = ind_max_eic if ind_max_eic > max_eic else max_eic 97 98 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.
100 def smooth_tic(self, tic): 101 """Smooths the TIC or EIC data using the specified smoothing method and settings. 102 103 Parameters 104 ---------- 105 tic : numpy.ndarray 106 The TIC (or EIC) data to be smoothed. 107 108 Returns 109 ------- 110 numpy.ndarray 111 The smoothed TIC data. 112 """ 113 implemented_smooth_method = self.parameters.lc_ms.implemented_smooth_method 114 115 pol_order = self.parameters.lc_ms.savgol_pol_order 116 117 window_len = self.parameters.lc_ms.smooth_window 118 119 window = self.parameters.lc_ms.smooth_method 120 121 return sp.smooth_signal( 122 tic, window_len, window, pol_order, implemented_smooth_method 123 )
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.
125 def eic_centroid_detector(self, rt, eic, max_eic, apex_indexes=[]): 126 """Performs EIC centroid detection on the given EIC data. 127 128 Parameters 129 ---------- 130 rt : numpy.ndarray 131 The retention time data. 132 eic : numpy.ndarray 133 The EIC data. 134 max_eic : float 135 The maximum EIC value. 136 apex_indexes : list, optional 137 The apexes of the EIC peaks. Defaults to [], which means that the apexes will be calculated by the function. 138 139 Returns 140 ------- 141 numpy.ndarray 142 The indexes of left, apex, and right limits as a generator. 143 """ 144 145 max_prominence = self.parameters.lc_ms.peak_max_prominence_percent 146 147 max_height = self.parameters.lc_ms.peak_height_max_percent 148 149 signal_threshold = self.parameters.lc_ms.eic_signal_threshold 150 151 min_peak_datapoints = self.parameters.lc_ms.min_peak_datapoints 152 153 peak_derivative_threshold = self.parameters.lc_ms.peak_derivative_threshold 154 155 include_indexes = sp.peak_picking_first_derivative( 156 domain=rt, 157 signal=eic, 158 max_height=max_height, 159 max_prominence=max_prominence, 160 max_signal=max_eic, 161 min_peak_datapoints=min_peak_datapoints, 162 peak_derivative_threshold=peak_derivative_threshold, 163 signal_threshold=signal_threshold, 164 correct_baseline=False, 165 plot_res=False, 166 apex_indexes=apex_indexes, 167 ) 168 #include_indexes is a generator of tuples (left_index, apex_index, right_index) 169 include_indexes = list(include_indexes) 170 # Add check to make sure that there are at least 1/2 of min_peak_datapoints on either side of the apex 171 indicies = [x for x in include_indexes] 172 for idx in indicies: 173 if (idx[1] - idx[0] < min_peak_datapoints / 2) or ( 174 idx[2] - idx[1] < min_peak_datapoints / 2 175 ): 176 include_indexes.remove(idx) 177 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.
179 def find_nearest_scan(self, rt): 180 """Finds the nearest scan to the given retention time. 181 182 Parameters 183 ---------- 184 rt : float 185 The retention time (in minutes) to find the nearest scan for. 186 187 Returns 188 ------- 189 int 190 The scan number of the nearest scan. 191 """ 192 array_rt = np.array(self.retention_time) 193 194 scan_index = (np.abs(array_rt - rt)).argmin() 195 196 real_scan = self.scans_number[scan_index] 197 198 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.
200 def add_peak_metrics(self, remove_by_metrics=True, induced_features=False): 201 """Add peak metrics to the mass features. 202 203 This function calculates the peak metrics for each mass feature and adds them to the mass feature objects. 204 205 Parameters 206 ---------- 207 remove_by_metrics : bool, optional 208 If True, remove mass features based on their peak metrics such as S/N, Gaussian similarity, 209 dispersity index, and noise score. Default is True, which checks the setting in the processing parameters. 210 If False, peak metrics are calculated but no mass features are removed, regardless of the setting in the processing parameters. 211 induced_features : bool, optional 212 Whether the mass features to be integrated were induced. Default is False. 213 """ 214 # Check that at least some mass features have eic data 215 if induced_features: 216 mf_dict_values = self.induced_mass_features.values() 217 else: 218 mf_dict_values = self.mass_features.values() 219 220 if not any([mf._eic_data is not None for mf in mf_dict_values]): 221 raise ValueError( 222 "No mass features have EIC data. Run integrate_mass_features first." 223 ) 224 225 for mass_feature in mf_dict_values: 226 # Check if the mass feature has been integrated 227 if mass_feature._eic_data is not None and mass_feature.area is not None: 228 # Calculate peak metrics 229 mass_feature.calc_half_height_width() 230 mass_feature.calc_tailing_factor() 231 mass_feature.calc_dispersity_index() 232 mass_feature.calc_gaussian_similarity() 233 mass_feature.calc_noise_score() 234 235 # Remove mass features by peak metrics if designated in parameters 236 if self.parameters.lc_ms.remove_mass_features_by_peak_metrics and remove_by_metrics: 237 self._remove_mass_features_by_peak_metrics(induced_features=induced_features)
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.
239 def get_average_mass_spectrum( 240 self, 241 scan_list, 242 apex_scan, 243 spectrum_mode="profile", 244 ms_level=1, 245 auto_process=True, 246 use_parser=False, 247 perform_checks=True, 248 polarity=None, 249 ms_params=None, 250 ): 251 """Returns an averaged mass spectrum object 252 253 Parameters 254 ---------- 255 scan_list : list 256 List of scan numbers to average. 257 apex_scan : int 258 Number of the apex scan 259 spectrum_mode : str, optional 260 The spectrum mode to use. Defaults to "profile". Not that only "profile" mode is supported for averaging. 261 ms_level : int, optional 262 The MS level to use. Defaults to 1. 263 auto_process : bool, optional 264 If True, the averaged mass spectrum will be auto-processed. Defaults to True. 265 use_parser : bool, optional 266 If True, the mass spectra will be obtained from the parser. Defaults to False. 267 perform_checks : bool, optional 268 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 269 polarity : int, optional 270 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) 271 ms_params : MSParameters, optional 272 The mass spectrum parameters to use. If not set (None), the globally set parameters will be used. Defaults to None. 273 274 Returns 275 ------- 276 MassSpectrumProfile 277 The averaged mass spectrum object. 278 279 Raises 280 ------ 281 ValueError 282 If the spectrum mode is not "profile". 283 If the MS level is not found in the unprocessed mass spectra dictionary. 284 If not all scan numbers are found in the unprocessed mass spectra dictionary. 285 """ 286 if perform_checks: 287 if spectrum_mode != "profile": 288 raise ValueError("Averaging only supported for profile mode") 289 290 if polarity is None: 291 # set polarity to -1 if negative mode, 1 if positive mode (for mass spectrum creation) 292 if self.polarity == "negative": 293 polarity = -1 294 elif self.polarity == "positive": 295 polarity = 1 296 else: 297 raise ValueError( 298 "Polarity not set for dataset, must be a set containing either 'positive' or 'negative'" 299 ) 300 301 # if not using_parser, check that scan numbers are in _ms_unprocessed 302 if not use_parser: 303 if perform_checks: 304 # Set index to scan for faster lookup 305 ms_df = ( 306 self._ms_unprocessed[ms_level] 307 .copy() 308 .set_index("scan", drop=False) 309 .sort_index() 310 ) 311 my_ms_df = ms_df.loc[scan_list] 312 # Check that all scan numbers are in the ms_df 313 if not all(np.isin(scan_list, ms_df.index)): 314 raise ValueError( 315 "Not all scan numbers found in the unprocessed mass spectra dictionary" 316 ) 317 else: 318 my_ms_df = ( 319 pd.DataFrame({"scan": scan_list}) 320 .set_index("scan") 321 .join(self._ms_unprocessed[ms_level], how="left") 322 ) 323 324 if use_parser: 325 ms_list = [ 326 self.spectra_parser.get_mass_spectrum_from_scan( 327 x, spectrum_mode=spectrum_mode, auto_process=False 328 ) 329 for x in scan_list 330 ] 331 ms_mz = [x._mz_exp for x in ms_list] 332 ms_int = [x._abundance for x in ms_list] 333 my_ms_df = [] 334 for i in np.arange(len(ms_mz)): 335 my_ms_df.append( 336 pd.DataFrame( 337 {"mz": ms_mz[i], "intensity": ms_int[i], "scan": scan_list[i]} 338 ) 339 ) 340 my_ms_df = pd.concat(my_ms_df) 341 342 if not self.check_if_grid(my_ms_df): 343 my_ms_df = self.grid_data(my_ms_df) 344 345 my_ms_ave = my_ms_df.groupby("mz")["intensity"].sum().reset_index() 346 347 ms = ms_from_array_profile( 348 my_ms_ave.mz, 349 my_ms_ave.intensity, 350 self.file_location, 351 polarity=polarity, 352 auto_process=False, 353 ) 354 355 # Set the mass spectrum parameters, auto-process if auto_process is True, and add to the dataset 356 if ms is not None: 357 if ms_params is not None: 358 ms.parameters = ms_params 359 ms.scan_number = apex_scan 360 if auto_process: 361 ms.process_mass_spec() 362 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.
364 def find_mass_features(self, ms_level=1, grid=True, assign_ms2_scans=False, ms2_scan_filter=None, 365 targeted_search=False, target_search_dict=None, accumulate_features=False): 366 """Find mass features within an LCMSBase object 367 368 Note that this is a wrapper function that calls the find_mass_features_ph function, but can be extended to support other peak picking methods in the future. 369 370 Parameters 371 ---------- 372 ms_level : int, optional 373 The MS level to use for peak picking Default is 1. 374 grid : bool, optional 375 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded), 376 used for persistent homology peak picking for profile data only. Default is True. 377 assign_ms2_scans : bool, optional 378 If True, assign MS2 scan numbers to mass features after peak picking. 379 This populates the ms2_scan_numbers attribute on each mass feature, which enables 380 choosing representative features based on MS2 availability. Default is False. 381 ms2_scan_filter : str or None, optional 382 Filter string for MS2 scans when assign_ms2_scans is True (e.g., 'hcd'). 383 If None, all MS2 scans are considered. Default is None. 384 targeted_search : bool, optional 385 If True, perform targeted mass feature search using the target_search_dict. 386 This mode filters data to only m/z and RT windows of interest and bypasses 387 intensity and persistence thresholds. Default is False. 388 target_search_dict : dict or None, optional 389 Dictionary containing target search parameters. Required if targeted_search is True. 390 Must contain: 391 - 'target_mz_list': list of target m/z values 392 - 'target_rt_list': list of target retention times (in minutes) 393 - 'mz_tolerance_ppm': m/z tolerance in ppm 394 - 'rt_tolerance': retention time tolerance (in minutes) 395 Optionally can contain: 396 - 'type': type label for mass features (e.g., "internal standard") 397 If not provided, defaults to "targeted" 398 Default is None. 399 accumulate_features : bool, optional 400 If True, new mass features will be added to existing features rather than replacing them. 401 This allows multiple sequential calls to find_mass_features to build up a combined set. 402 Default is False (replace existing features for backwards compatibility). 403 404 Raises 405 ------ 406 ValueError 407 If no MS level data is found on the object. 408 If persistent homology peak picking is attempted on non-profile mode data. 409 If data is not gridded and grid is False. 410 If peak picking method is not implemented. 411 If targeted_search is True but target_search_dict is None or invalid. 412 413 Returns 414 ------- 415 None, but assigns the mass_features and eics attributes to the object. 416 417 """ 418 # Validate targeted search parameters 419 if targeted_search: 420 if target_search_dict is None: 421 raise ValueError("target_search_dict must be provided when targeted_search is True") 422 required_keys = ['target_mz_list', 'target_rt_list', 'mz_tolerance_ppm', 'rt_tolerance'] 423 for key in required_keys: 424 if key not in target_search_dict: 425 raise ValueError(f"target_search_dict must contain '{key}'") 426 if len(target_search_dict['target_mz_list']) != len(target_search_dict['target_rt_list']): 427 raise ValueError("target_mz_list and target_rt_list must have the same length") 428 429 pp_method = self.parameters.lc_ms.peak_picking_method 430 431 if pp_method == "persistent homology": 432 msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level] 433 if all(msx_scan_df["ms_format"] == "profile"): 434 # Determine mass feature type 435 if targeted_search: 436 mf_type = target_search_dict.get('type', 'targeted') 437 else: 438 mf_type = 'untargeted' 439 self.find_mass_features_ph(ms_level=ms_level, grid=grid, 440 targeted_search=targeted_search, 441 target_search_dict=target_search_dict, 442 mf_type=mf_type, 443 accumulate_features=accumulate_features) 444 else: 445 raise ValueError( 446 "MS{} scans are not profile mode, which is required for persistent homology peak picking.".format( 447 ms_level 448 ) 449 ) 450 elif pp_method == "centroided_persistent_homology": 451 msx_scan_df = self.scan_df[self.scan_df["ms_level"] == ms_level] 452 if all(msx_scan_df["ms_format"] == "centroid"): 453 # Determine mass feature type 454 if targeted_search: 455 mf_type = target_search_dict.get('type', 'targeted') 456 else: 457 mf_type = 'untargeted' 458 self.find_mass_features_ph_centroid(ms_level=ms_level, 459 targeted_search=targeted_search, 460 target_search_dict=target_search_dict, 461 mf_type=mf_type, 462 accumulate_features=accumulate_features) 463 else: 464 raise ValueError( 465 "MS{} scans are not centroid mode, which is required for persistent homology centroided peak picking.".format( 466 ms_level 467 ) 468 ) 469 else: 470 raise ValueError("Peak picking method not implemented") 471 472 # Cluster mass features to remove redundant features 473 self.cluster_mass_features(drop_children=True) 474 475 # Optionally assign MS2 scan numbers to mass features during peak picking 476 # This helps with choosing representative features that have MS2 data 477 if assign_ms2_scans: 478 try: 479 self._find_ms2_scans_for_mass_features( 480 mf_ids=None, # Process all mass features 481 scan_filter=ms2_scan_filter 482 ) 483 except ValueError: 484 # No MS2 scans found - this is okay, just skip 485 pass 486 487 # Remove noisey mass features if designated in parameters 488 if self.parameters.lc_ms.remove_redundant_mass_features and not targeted_search: 489 self._remove_redundant_mass_features()
Find mass features within an LCMSBase object
Note that this is a wrapper function that calls the find_mass_features_ph function, but can be extended to support other peak picking methods in the future.
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 persistent homology peak picking is attempted on non-profile 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.
491 def integrate_mass_features( 492 self, drop_if_fail=True, drop_duplicates=True, ms_level=1, induced_features=False 493 ): 494 """Integrate mass features and extract EICs. 495 496 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. 497 498 Parameters 499 ---------- 500 drop_if_fail : bool, optional 501 Whether to drop mass features if the EIC limit calculations fail. 502 Default is True. 503 drop_duplicates : bool, optional 504 Whether to mass features that appear to be duplicates 505 (i.e., mz is similar to another mass feature and limits of the EIC are similar or encapsulating). 506 Default is True. 507 ms_level : int, optional 508 The MS level to use. Default is 1. 509 induced_features : bool, optional 510 Whether the mass features to be intergrated were induced. Default is False. 511 512 Raises 513 ------ 514 ValueError 515 If no mass features are found. 516 If no MS level data is found for the given MS level (either in data or in the scan data) 517 518 Returns 519 ------- 520 None, but populates the eics attribute on the LCMSBase object and adds data (start_scan, final_scan, area) to the mass_features attribute. 521 522 Notes 523 ----- 524 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). 525 """ 526 527 # Check if there is data 528 if ms_level in self._ms_unprocessed.keys(): 529 raw_data = self._ms_unprocessed[ms_level].copy() 530 else: 531 raise ValueError("No MS level " + str(ms_level) + " data found") 532 533 # Check if mass_spectrum exists on each mass feature 534 if induced_features: 535 mf_dict = self.induced_mass_features 536 if len(mf_dict) == 0: 537 raise ValueError( 538 "No induced mass features found, did you run fill_missing_cluster_features() first?" 539 ) 540 541 ## remove not found induced mass features by mz <= 0 (-99 indicator) 542 # also remove any where mz is nan 543 mf_dict = {k:v for k, v in mf_dict.items() if v.mz > 0 and not np.isnan(v.mz)} 544 545 else: 546 mf_dict = self.mass_features 547 if len(mf_dict) == 0: 548 raise ValueError( 549 "No mass features found, did you run find_mass_features() first?" 550 ) 551 552 # Subset scan data to only include correct ms_level 553 scan_df_sub = self.scan_df[ 554 self.scan_df["ms_level"] == int(ms_level) 555 ].reset_index(drop=True) 556 if scan_df_sub.empty: 557 raise ValueError("No MS level " + ms_level + " data found in scan data") 558 scan_df_sub = scan_df_sub[["scan", "scan_time"]].copy() 559 560 mzs_to_extract = np.unique([mf.mz for mf in mf_dict.values()]) 561 mzs_to_extract.sort() 562 563 # Pre-sort raw_data by mz for faster filtering 564 raw_data_sorted = raw_data.sort_values(["mz", "scan"]).reset_index(drop=True) 565 raw_data_mz = raw_data_sorted["mz"].values 566 567 # Get EICs for each unique mz in mass features list 568 for mz in mzs_to_extract: 569 mz_max = mz + self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 570 mz_min = mz - self.parameters.lc_ms.eic_tolerance_ppm * mz / 1e6 571 572 # Use binary search for faster mz range filtering 573 left_idx = np.searchsorted(raw_data_mz, mz_min, side="left") 574 right_idx = np.searchsorted(raw_data_mz, mz_max, side="right") 575 raw_data_sub = raw_data_sorted.iloc[left_idx:right_idx].copy() 576 577 raw_data_sub = ( 578 raw_data_sub.groupby(["scan"])["intensity"].sum().reset_index() 579 ) 580 raw_data_sub = scan_df_sub.merge(raw_data_sub, on="scan", how="left") 581 raw_data_sub["intensity"] = raw_data_sub["intensity"].fillna(0) 582 myEIC = EIC_Data( 583 scans=raw_data_sub["scan"].values, 584 time=raw_data_sub["scan_time"].values, 585 eic=raw_data_sub["intensity"].values, 586 ) 587 # Smooth EIC 588 smoothed_eic = self.smooth_tic(myEIC.eic) 589 smoothed_eic[smoothed_eic < 0] = 0 590 myEIC.eic_smoothed = smoothed_eic 591 self.eics[mz] = myEIC 592 593 # Get limits of mass features using EIC centroid detector and integrate 594 for idx, mass_feature in list(mf_dict.items()): 595 mz = mass_feature.mz 596 apex_scan = mass_feature.apex_scan 597 598 # Pull EIC data and find apex scan index 599 myEIC = self.eics[mz] 600 mf_dict[idx]._eic_data = myEIC 601 mf_dict[idx]._eic_mz = mz 602 apex_index = np.searchsorted(myEIC.scans, apex_scan) 603 604 # Find left and right limits of peak using EIC centroid detector, add to EICData 605 centroid_eics = self.eic_centroid_detector( 606 myEIC.time, 607 myEIC.eic_smoothed, 608 mass_feature.intensity * 1.1, 609 apex_indexes=[int(apex_index)], 610 ) 611 l_a_r_scan_idx = [i for i in centroid_eics] 612 if len(l_a_r_scan_idx) > 0: 613 # Calculate number of consecutive scans with intensity > 0 and check if it is above the minimum consecutive scans 614 # Find the number of consecutive non-zero values in the EIC segment 615 mask = myEIC.eic[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1] > 0 616 # Find the longest run of consecutive True values 617 if np.any(mask): 618 # Find indices where mask changes value 619 diff = np.diff(np.concatenate(([0], mask.astype(int), [0]))) 620 starts = np.where(diff == 1)[0] 621 ends = np.where(diff == -1)[0] 622 consecutive_scans = (ends - starts).max() 623 else: 624 consecutive_scans = 0 625 if consecutive_scans < self.parameters.lc_ms.consecutive_scan_min: 626 mf_dict.pop(idx) 627 continue 628 # Add start and final scan to mass_features and EICData 629 left_scan, right_scan = ( 630 myEIC.scans[l_a_r_scan_idx[0][0]], 631 myEIC.scans[l_a_r_scan_idx[0][2]], 632 ) 633 mf_scan_apex = [(left_scan, int(apex_scan), right_scan)] 634 myEIC.apexes = myEIC.apexes + mf_scan_apex 635 mf_dict[idx].start_scan = left_scan 636 mf_dict[idx].final_scan = right_scan 637 638 # Find area under peak using limits from EIC centroid detector, add to mass_features and EICData 639 area = np.trapezoid( 640 myEIC.eic_smoothed[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 641 myEIC.time[l_a_r_scan_idx[0][0] : l_a_r_scan_idx[0][2] + 1], 642 ) 643 myEIC.areas = myEIC.areas + [area] 644 self.eics[mz] = myEIC 645 mf_dict[idx]._area = area 646 else: 647 if drop_if_fail is True: 648 mf_dict.pop(idx) 649 650 if drop_duplicates: 651 # Prepare mass feature dataframe 652 if induced_features: 653 mf_df = self.mass_features_to_df(induced_features = True).copy() 654 mf_df = mf_df[mf_df.start_scan.notna()] 655 else: 656 mf_df = self.mass_features_to_df(induced_features = False).copy() 657 658 # 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 659 # Keep the first mass feature (highest persistence) 660 for idx, mass_feature in mf_df.iterrows(): 661 mz = mass_feature.mz 662 apex_scan = mass_feature.apex_scan 663 664 mf_df["mz_diff_ppm"] = np.abs(mf_df["mz"] - mz) / mz * 10**6 665 mf_df_sub = mf_df[ 666 mf_df["mz_diff_ppm"] 667 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 668 * 10**6 669 ].copy() 670 671 # 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 672 for idx2, mass_feature2 in mf_df_sub.iterrows(): 673 if idx2 != idx: 674 if ( 675 mass_feature2.start_scan >= mass_feature.start_scan 676 and mass_feature2.final_scan <= mass_feature.final_scan 677 ): 678 if idx2 in self.mass_features.keys(): 679 self.mass_features.pop(idx2) 680 681 # Filter MS2 scans to only include those within integration bounds 682 # This ensures MS2 scans outside start_scan to final_scan are removed 683 if induced_features: 684 self._filter_ms2_scans_by_integration_bounds(mf_dict=self.induced_mass_features) 685 else: 686 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).
688 def find_c13_mass_features(self): 689 """Mark likely C13 isotopes and connect to monoisoitopic mass features. 690 691 Returns 692 ------- 693 None, but populates the monoisotopic_mf_id and isotopologue_type attributes to the indivual LCMSMassFeatures within the mass_features attribute of the LCMSBase object. 694 695 Raises 696 ------ 697 ValueError 698 If no mass features are found. 699 """ 700 verbose = self.parameters.lc_ms.verbose_processing 701 if verbose: 702 print("evaluating mass features for C13 isotopes") 703 if self.mass_features is None: 704 raise ValueError("No mass features found, run find_mass_features() first") 705 706 # Data prep fo sparse distance matrix 707 dims = ["mz", "scan_time"] 708 mf_df = self.mass_features_to_df().copy() 709 # Drop mass features that have no area (these are likely to be noise) 710 mf_df = mf_df[mf_df["area"].notnull()] 711 mf_df["mf_id"] = mf_df.index.values 712 dims = ["mz", "scan_time"] 713 714 # Sort my ascending mz so we always get the monoisotopic mass first, regardless of the order/intensity of the mass features 715 mf_df = mf_df.sort_values(by=["mz"]).reset_index(drop=True).copy() 716 717 mz_diff = 1.003355 # C13-C12 mass difference 718 tol = [ 719 mf_df["mz"].median() 720 * self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 721 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance * 0.5, 722 ] # mz, in relative; scan_time in minutes 723 724 # Compute inter-feature distances 725 distances = None 726 for i in range(len(dims)): 727 # Construct k-d tree 728 values = mf_df[dims[i]].values 729 tree = KDTree(values.reshape(-1, 1)) 730 731 max_tol = tol[i] 732 if dims[i] == "mz": 733 # Maximum absolute tolerance 734 max_tol = mz_diff + tol[i] 735 736 # Compute sparse distance matrix 737 # the larger the max_tol, the slower this operation is 738 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 739 740 # Only consider forward case, exclude diagonal 741 sdm = sparse.triu(sdm, k=1) 742 743 if dims[i] == "mz": 744 min_tol = mz_diff - tol[i] 745 # Get only the ones that are above the min tol 746 idx = sdm.data > min_tol 747 748 # Reconstruct sparse distance matrix 749 sdm = sparse.coo_matrix( 750 (sdm.data[idx], (sdm.row[idx], sdm.col[idx])), 751 shape=(len(values), len(values)), 752 ) 753 754 # Cast as binary matrix 755 sdm.data = np.ones_like(sdm.data) 756 757 # Stack distances 758 if distances is None: 759 distances = sdm 760 else: 761 distances = distances.multiply(sdm) 762 763 # Extract indices of within-tolerance points 764 distances = distances.tocoo() 765 pairs = np.stack((distances.row, distances.col), axis=1) # C12 to C13 pairs 766 767 # Turn pairs (which are index of mf_df) into mf_id and then into two dataframes to join to mf_df 768 pairs_mf = pairs.copy() 769 pairs_mf[:, 0] = mf_df.iloc[pairs[:, 0]].mf_id.values 770 pairs_mf[:, 1] = mf_df.iloc[pairs[:, 1]].mf_id.values 771 772 # Connect monoisotopic masses with isotopologes within mass_features 773 monos = np.setdiff1d(np.unique(pairs_mf[:, 0]), np.unique(pairs_mf[:, 1])) 774 for mono in monos: 775 self.mass_features[mono].monoisotopic_mf_id = mono 776 pairs_iso_df = pd.DataFrame(pairs_mf, columns=["parent", "child"]) 777 while not pairs_iso_df.empty: 778 pairs_iso_df = pairs_iso_df.set_index("parent", drop=False) 779 m1_isos = pairs_iso_df.loc[monos, "child"].unique() 780 for iso in m1_isos: 781 # Set monoisotopic_mf_id and isotopologue_type for isotopologues 782 parent = pairs_mf[pairs_mf[:, 1] == iso, 0] 783 if len(parent) > 1: 784 # Choose the parent that is closest in time to the isotopologue 785 parent_time = [self.mass_features[p].retention_time for p in parent] 786 time_diff = [ 787 np.abs(self.mass_features[iso].retention_time - x) 788 for x in parent_time 789 ] 790 parent = parent[np.argmin(time_diff)] 791 else: 792 parent = parent[0] 793 self.mass_features[iso].monoisotopic_mf_id = self.mass_features[ 794 parent 795 ].monoisotopic_mf_id 796 if self.mass_features[iso].monoisotopic_mf_id is not None: 797 mass_diff = ( 798 self.mass_features[iso].mz 799 - self.mass_features[ 800 self.mass_features[iso].monoisotopic_mf_id 801 ].mz 802 ) 803 self.mass_features[iso].isotopologue_type = "13C" + str( 804 int(round(mass_diff, 0)) 805 ) 806 807 # Drop the mono and iso from the pairs_iso_df 808 pairs_iso_df = pairs_iso_df.drop( 809 index=monos, errors="ignore" 810 ) # Drop pairs where the parent is a child that is a child of a root 811 pairs_iso_df = pairs_iso_df.set_index("child", drop=False) 812 pairs_iso_df = pairs_iso_df.drop(index=m1_isos, errors="ignore") 813 814 if not pairs_iso_df.empty: 815 # Get new monos, recognizing that these are just 13C isotopologues that are connected to other 13C isotopologues to repeat the process 816 monos = np.setdiff1d( 817 np.unique(pairs_iso_df.parent), np.unique(pairs_iso_df.child) 818 ) 819 if verbose: 820 # Report fraction of compounds annotated with isotopes 821 mf_df["c13_flag"] = np.where( 822 np.logical_or( 823 np.isin(mf_df["mf_id"], pairs_mf[:, 0]), 824 np.isin(mf_df["mf_id"], pairs_mf[:, 1]), 825 ), 826 1, 827 0, 828 ) 829 print( 830 str(round(len(mf_df[mf_df["c13_flag"] == 1]) / len(mf_df), ndigits=3)) 831 + " of mass features have or are C13 isotopes" 832 )
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.
834 def deconvolute_ms1_mass_features(self): 835 """Deconvolute MS1 mass features 836 837 Deconvolute mass features ms1 spectrum based on the correlation of all masses within a spectrum over the EIC of the mass features 838 839 Parameters 840 ---------- 841 None 842 843 Returns 844 ------- 845 None, but assigns the _ms_deconvoluted_idx, mass_spectrum_deconvoluted_parent, 846 and associated_mass_features_deconvoluted attributes to the mass features in the 847 mass_features attribute of the LCMSBase object. 848 849 Raises 850 ------ 851 ValueError 852 If no mass features are found, must run find_mass_features() first. 853 If no EICs are found, did you run integrate_mass_features() first? 854 855 """ 856 # Checks for set mass_features and eics 857 if self.mass_features is None: 858 raise ValueError( 859 "No mass features found, did you run find_mass_features() first?" 860 ) 861 862 if self.eics == {}: 863 raise ValueError( 864 "No EICs found, did you run integrate_mass_features() first?" 865 ) 866 867 if 1 not in self._ms_unprocessed.keys(): 868 raise ValueError("No unprocessed MS1 spectra found.") 869 870 # Prep ms1 data 871 ms1_data = self._ms_unprocessed[1].copy() 872 ms1_data = ms1_data.set_index("scan") 873 874 # Prep mass feature summary 875 mass_feature_df = self.mass_features_to_df() 876 877 # Loop through each mass feature 878 for mf_id, mass_feature in self.mass_features.items(): 879 # Check that the mass_feature.mz attribute == the mz of the mass feature in the mass_feature_df 880 if mass_feature.mz != mass_feature.ms1_peak.mz_exp: 881 continue 882 883 # Get the left and right limits of the EIC of the mass feature 884 l_scan, _, r_scan = mass_feature._eic_data.apexes[0] 885 886 # Pull from the _ms1_unprocessed data the scan range of interest and sort by mz 887 ms1_data_sub = ms1_data.loc[l_scan:r_scan].copy() 888 ms1_data_sub = ms1_data_sub.sort_values(by=["mz"]).reset_index(drop=False) 889 890 # Get the centroided masses of the mass feature 891 mf_mspeak_mzs = mass_feature.mass_spectrum.mz_exp 892 893 # Find the closest mz in the ms1 data to the centroided masses of the mass feature 894 ms1_data_sub["mass_feature_mz"] = mf_mspeak_mzs[ 895 find_closest(mf_mspeak_mzs, ms1_data_sub.mz.values) 896 ] 897 898 # Drop rows with mz_diff > 0.01 between the mass feature mz and the ms1 data mz 899 ms1_data_sub["mz_diff_rel"] = ( 900 np.abs(ms1_data_sub["mass_feature_mz"] - ms1_data_sub["mz"]) 901 / ms1_data_sub["mz"] 902 ) 903 ms1_data_sub = ms1_data_sub[ 904 ms1_data_sub["mz_diff_rel"] 905 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel 906 ].reset_index(drop=True) 907 908 # Group by mass_feature_mz and scan and sum intensity 909 ms1_data_sub_group = ( 910 ms1_data_sub.groupby(["mass_feature_mz", "scan"])["intensity"] 911 .sum() 912 .reset_index() 913 ) 914 915 # Calculate the correlation of the intensities of the mass feature and the ms1 data (set to 0 if no intensity) 916 corr = ( 917 ms1_data_sub_group.pivot( 918 index="scan", columns="mass_feature_mz", values="intensity" 919 ) 920 .fillna(0) 921 .corr() 922 ) 923 924 # Subset the correlation matrix to only include the masses of the mass feature and those with a correlation > 0.8 925 decon_corr_min = self.parameters.lc_ms.ms1_deconvolution_corr_min 926 927 # Try catch for KeyError in case the mass feature mz is not in the correlation matrix 928 try: 929 corr_subset = corr.loc[mass_feature.mz] 930 except KeyError: 931 # If the mass feature mz is not in the correlation matrix, skip to the next mass feature 932 continue 933 934 corr_subset = corr_subset[corr_subset > decon_corr_min] 935 936 # Get the masses from the mass spectrum that are the result of the deconvolution 937 mzs_decon = corr_subset.index.values 938 939 # Get the indices of the mzs_decon in mass_feature.mass_spectrum.mz_exp and assign to the mass feature 940 mzs_decon_idx = [ 941 id 942 for id, mz in enumerate(mass_feature.mass_spectrum.mz_exp) 943 if mz in mzs_decon 944 ] 945 mass_feature._ms_deconvoluted_idx = mzs_decon_idx 946 947 # Check if the mass feature's ms1 peak is the largest in the deconvoluted mass spectrum 948 if ( 949 mass_feature.ms1_peak.abundance 950 == mass_feature.mass_spectrum.abundance[mzs_decon_idx].max() 951 ): 952 mass_feature.mass_spectrum_deconvoluted_parent = True 953 else: 954 mass_feature.mass_spectrum_deconvoluted_parent = False 955 956 # Check for other mass features that are in the deconvoluted mass spectrum and add the deconvoluted mass spectrum to the mass feature 957 # Subset mass_feature_df to only include mass features that are within the clustering tolerance 958 mass_feature_df_sub = mass_feature_df[ 959 abs(mass_feature.retention_time - mass_feature_df["scan_time"]) 960 < self.parameters.lc_ms.mass_feature_cluster_rt_tolerance 961 ].copy() 962 # Calculate the mz difference in ppm between the mass feature and the peaks in the deconvoluted mass spectrum 963 mass_feature_df_sub["mz_diff_ppm"] = [ 964 np.abs(mzs_decon - mz).min() / mz * 10**6 965 for mz in mass_feature_df_sub["mz"] 966 ] 967 # Subset mass_feature_df to only include mass features that are within 1 ppm of the deconvoluted masses 968 mfs_associated_decon = mass_feature_df_sub[ 969 mass_feature_df_sub["mz_diff_ppm"] 970 < self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel * 10**6 971 ].index.values 972 973 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?
1281class PHCalculations: 1282 """Methods for performing calculations related to 2D peak picking via persistent homology on LCMS data. 1283 1284 Notes 1285 ----- 1286 This class is intended to be used as a mixin for the LCMSBase class. 1287 1288 Methods 1289 ------- 1290 * sparse_mean_filter(idx, V, radius=[0, 1, 1]). 1291 Sparse implementation of a mean filter. 1292 * embed_unique_indices(a). 1293 Creates an array of indices, sorted by unique element. 1294 * sparse_upper_star(idx, V). 1295 Sparse implementation of an upper star filtration. 1296 * check_if_grid(data). 1297 Check if the data is gridded in mz space. 1298 * grid_data(data). 1299 Grid the data in the mz dimension. 1300 * find_mass_features_ph(ms_level=1, grid=True). 1301 Find mass features within an LCMSBase object using persistent homology. 1302 * cluster_mass_features(drop_children=True). 1303 Cluster regions of interest. 1304 """ 1305 1306 @staticmethod 1307 def sparse_mean_filter(idx, V, radius=[0, 1, 1]): 1308 """Sparse implementation of a mean filter. 1309 1310 Parameters 1311 ---------- 1312 idx : :obj:`~numpy.array` 1313 Edge indices for each dimension (MxN). 1314 V : :obj:`~numpy.array` 1315 Array of intensity data (Mx1). 1316 radius : float or list 1317 Radius of the sparse filter in each dimension. Values less than 1318 zero indicate no connectivity in that dimension. 1319 1320 Returns 1321 ------- 1322 :obj:`~numpy.array` 1323 Filtered intensities (Mx1). 1324 1325 Notes 1326 ----- 1327 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos. 1328 This is a static method. 1329 """ 1330 1331 # Copy indices 1332 idx = idx.copy().astype(V.dtype) 1333 1334 # Scale 1335 for i, r in enumerate(radius): 1336 # Increase inter-index distance 1337 if r < 1: 1338 idx[:, i] *= 2 1339 1340 # Do nothing 1341 elif r == 1: 1342 pass 1343 1344 # Decrease inter-index distance 1345 else: 1346 idx[:, i] /= r 1347 1348 # Connectivity matrix 1349 cmat = KDTree(idx) 1350 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1351 cmat.setdiag(1) 1352 1353 # Pair indices 1354 I, J = cmat.nonzero() 1355 1356 # Delete cmat 1357 cmat_shape = cmat.shape 1358 del cmat 1359 1360 # Sum over columns 1361 V_sum = sparse.bsr_matrix( 1362 (V[J], (I, I)), shape=cmat_shape, dtype=V.dtype 1363 ).diagonal(0) 1364 1365 # Count over columns 1366 V_count = sparse.bsr_matrix( 1367 (np.ones_like(J), (I, I)), shape=cmat_shape, dtype=V.dtype 1368 ).diagonal(0) 1369 1370 return V_sum / V_count 1371 1372 @staticmethod 1373 def embed_unique_indices(a): 1374 """Creates an array of indices, sorted by unique element. 1375 1376 Parameters 1377 ---------- 1378 a : :obj:`~numpy.array` 1379 Array of unique elements (Mx1). 1380 1381 Returns 1382 ------- 1383 :obj:`~numpy.array` 1384 Array of indices (Mx1). 1385 1386 Notes 1387 ----- 1388 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1389 This is a static method. 1390 """ 1391 1392 def count_tens(n): 1393 # Count tens 1394 ntens = (n - 1) // 10 1395 1396 while True: 1397 ntens_test = (ntens + n - 1) // 10 1398 1399 if ntens_test == ntens: 1400 return ntens 1401 else: 1402 ntens = ntens_test 1403 1404 def arange_exclude_10s(n): 1405 # How many 10s will there be? 1406 ntens = count_tens(n) 1407 1408 # Base array 1409 arr = np.arange(0, n + ntens) 1410 1411 # Exclude 10s 1412 arr = arr[(arr == 0) | (arr % 10 != 0)][:n] 1413 1414 return arr 1415 1416 # Creates an array of indices, sorted by unique element 1417 idx_sort = np.argsort(a) 1418 idx_unsort = np.argsort(idx_sort) 1419 1420 # Sorts records array so all unique elements are together 1421 sorted_a = a[idx_sort] 1422 1423 # Returns the unique values, the index of the first occurrence, 1424 # and the count for each element 1425 vals, idx_start, count = np.unique( 1426 sorted_a, return_index=True, return_counts=True 1427 ) 1428 1429 # Splits the indices into separate arrays 1430 splits = np.split(idx_sort, idx_start[1:]) 1431 1432 # Creates unique indices for each split 1433 idx_unq = np.concatenate([arange_exclude_10s(len(x)) for x in splits]) 1434 1435 # Reorders according to input array 1436 idx_unq = idx_unq[idx_unsort] 1437 1438 # Magnitude of each index 1439 exp = np.log10( 1440 idx_unq, where=idx_unq > 0, out=np.zeros_like(idx_unq, dtype=np.float64) 1441 ) 1442 idx_unq_mag = np.power(10, np.floor(exp) + 1) 1443 1444 # Result 1445 return a + idx_unq / idx_unq_mag 1446 1447 @staticmethod 1448 def roll_up_dataframe( 1449 df: pd.DataFrame, 1450 sort_by: str, 1451 tol: list, 1452 relative: list, 1453 dims: list, 1454 memory_opt_threshold: int = 10000, 1455 ): 1456 """Subset data by rolling up into apex in appropriate dimensions. 1457 1458 Parameters 1459 ---------- 1460 data : pd.DataFrame 1461 The input data containing "dims" columns and the "sort_by" column. 1462 sort_by : str 1463 The column to sort the data by, this will determine which mass features get rolled up into a parent mass feature 1464 (i.e., the mass feature with the highest value in the sort_by column). 1465 dims : list 1466 A list of dimension names (column names in the data DataFrame) to roll up the mass features by. 1467 tol : list 1468 A list of tolerances for each dimension. The length of the list must match the number of dimensions. 1469 The tolerances can be relative (as a fraction of the maximum value in that dimension) or absolute (in the units of that dimension). 1470 If relative is True, the tolerance will be multiplied by the maximum value in that dimension. 1471 If relative is False, the tolerance will be used as is. 1472 relative : list 1473 A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False). 1474 memory_opt_threshold : int, optional 1475 Minimum number of rows to trigger memory-optimized processing. Default is 10000. 1476 1477 Returns 1478 ------- 1479 pd.DataFrame 1480 A DataFrame with only the rolled up mass features, with the original index and columns. 1481 1482 1483 Raises 1484 ------ 1485 ValueError 1486 If the input data is not a pandas DataFrame. 1487 If the input data does not have columns for each of the dimensions in "dims". 1488 If the length of "dims", "tol", and "relative" do not match. 1489 """ 1490 og_columns = df.columns.copy() 1491 1492 # Unindex the data, but keep the original index 1493 if df.index.name is not None: 1494 og_index = df.index.name 1495 else: 1496 og_index = "index" 1497 df = df.reset_index(drop=False) 1498 1499 # Sort data by sort_by column, and reindex 1500 df = df.sort_values(by=sort_by, ascending=False).reset_index(drop=True) 1501 1502 # Check that data is a DataFrame and has columns for each of the dims 1503 if not isinstance(df, pd.DataFrame): 1504 raise ValueError("Data must be a pandas DataFrame") 1505 for dim in dims: 1506 if dim not in df.columns: 1507 raise ValueError(f"Data must have a column for {dim}") 1508 if len(dims) != len(tol) or len(dims) != len(relative): 1509 raise ValueError( 1510 "Dimensions, tolerances, and relative flags must be the same length" 1511 ) 1512 1513 # Pre-compute all values arrays 1514 all_values = [df[dim].values for dim in dims] 1515 1516 # Choose processing method based on dataframe size 1517 if len(df) >= memory_opt_threshold: 1518 # Memory-optimized approach for large dataframes 1519 distances = PHCalculations._compute_distances_memory_optimized( 1520 all_values, tol, relative 1521 ) 1522 else: 1523 # Faster approach for smaller dataframes 1524 distances = PHCalculations._compute_distances_original( 1525 all_values, tol, relative 1526 ) 1527 1528 # Process pairs with original logic but memory optimizations 1529 distances = distances.tocoo() 1530 pairs = np.stack((distances.row, distances.col), axis=1) 1531 pairs_df = pd.DataFrame(pairs, columns=["parent", "child"]).set_index("parent") 1532 del distances, pairs # Free memory immediately 1533 1534 to_drop = [] 1535 while not pairs_df.empty: 1536 # Find root_parents and their children (original logic preserved) 1537 root_parents = np.setdiff1d( 1538 np.unique(pairs_df.index.values), np.unique(pairs_df.child.values) 1539 ) 1540 children_of_roots = pairs_df.loc[root_parents, "child"].unique() 1541 to_drop.extend(children_of_roots) # Use extend instead of append 1542 1543 # Remove root_children as possible parents from pairs_df for next iteration 1544 pairs_df = pairs_df.drop(index=children_of_roots, errors="ignore") 1545 pairs_df = pairs_df.reset_index().set_index("child") 1546 # Remove root_children as possible children from pairs_df for next iteration 1547 pairs_df = pairs_df.drop(index=children_of_roots) 1548 1549 # Prepare for next iteration 1550 pairs_df = pairs_df.reset_index().set_index("parent") 1551 1552 # Convert to numpy array for efficient dropping 1553 to_drop = np.array(to_drop) 1554 1555 # Drop mass features that are not cluster parents 1556 df_sub = df.drop(index=to_drop) 1557 1558 # Set index back to og_index and only keep original columns 1559 df_sub = df_sub.set_index(og_index).sort_index()[og_columns] 1560 1561 return df_sub 1562 1563 @staticmethod 1564 def _compute_distances_original(all_values, tol, relative): 1565 """Original distance computation method for smaller datasets. 1566 1567 This method computes the pairwise distances between features in the dataset 1568 using a straightforward approach. It is suitable for smaller datasets where 1569 memory usage is not a primary concern. 1570 1571 Parameters 1572 ---------- 1573 all_values : list of :obj:`~numpy.array` 1574 List of arrays containing the values for each dimension. 1575 tol : list of float 1576 List of tolerances for each dimension. 1577 relative : list of bool 1578 List of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False). 1579 1580 Returns 1581 ------- 1582 :obj:`~scipy.sparse.coo_matrix` 1583 Sparse matrix indicating pairwise distances within tolerances. 1584 """ 1585 # Compute inter-feature distances with memory optimization 1586 distances = None 1587 for i in range(len(all_values)): 1588 values = all_values[i] 1589 # Use single precision if possible to reduce memory 1590 tree = KDTree(values.reshape(-1, 1).astype(np.float32)) 1591 1592 max_tol = tol[i] 1593 if relative[i] is True: 1594 max_tol = tol[i] * values.max() 1595 1596 # Compute sparse distance matrix with smaller chunks if memory is an issue 1597 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 1598 1599 # Only consider forward case, exclude diagonal 1600 sdm = sparse.triu(sdm, k=1) 1601 1602 # Process relative distances more efficiently 1603 if relative[i] is True: 1604 # Vectorized computation without creating intermediate arrays 1605 row_values = values[sdm.row] 1606 valid_idx = sdm.data <= tol[i] * row_values 1607 1608 # Reconstruct sparse matrix more efficiently 1609 sdm = sparse.coo_matrix( 1610 ( 1611 np.ones(valid_idx.sum(), dtype=np.uint8), 1612 (sdm.row[valid_idx], sdm.col[valid_idx]), 1613 ), 1614 shape=(len(values), len(values)), 1615 ) 1616 else: 1617 # Cast as binary matrix with smaller data type 1618 sdm.data = np.ones(len(sdm.data), dtype=np.uint8) 1619 1620 # Stack distances with memory-efficient multiplication 1621 if distances is None: 1622 distances = sdm 1623 else: 1624 # Use in-place operations where possible 1625 distances = distances.multiply(sdm) 1626 del sdm # Free memory immediately 1627 1628 return distances 1629 1630 @staticmethod 1631 def _compute_distances_memory_optimized(all_values, tol, relative): 1632 """Memory-optimized distance computation for large datasets. 1633 1634 This method computes the pairwise distances between features in the dataset 1635 using a more memory-efficient approach. It is suitable for larger datasets 1636 where memory usage is a primary concern. 1637 1638 Parameters 1639 ---------- 1640 all_values : list of :obj:`~numpy.array` 1641 List of arrays containing the values for each dimension. 1642 tol : list of float 1643 List of tolerances for each dimension. 1644 relative : list of bool 1645 List of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False). 1646 1647 Returns 1648 ------- 1649 :obj:`~scipy.sparse.coo_matrix` 1650 Sparse matrix indicating pairwise distances within tolerances. 1651 """ 1652 # Compute distance matrix for first dimension (full matrix as before) 1653 values_0 = all_values[0].astype(np.float32) 1654 tree_0 = KDTree(values_0.reshape(-1, 1)) 1655 1656 max_tol_0 = tol[0] 1657 if relative[0] is True: 1658 max_tol_0 = tol[0] * values_0.max() 1659 1660 # Compute sparse distance matrix for first dimension 1661 distances = tree_0.sparse_distance_matrix( 1662 tree_0, max_tol_0, output_type="coo_matrix" 1663 ) 1664 distances = sparse.triu(distances, k=1) 1665 1666 # Process relative distances for first dimension 1667 if relative[0] is True: 1668 row_values = values_0[distances.row] 1669 valid_idx = distances.data <= tol[0] * row_values 1670 distances = sparse.coo_matrix( 1671 ( 1672 np.ones(valid_idx.sum(), dtype=np.uint8), 1673 (distances.row[valid_idx], distances.col[valid_idx]), 1674 ), 1675 shape=(len(values_0), len(values_0)), 1676 ) 1677 else: 1678 distances.data = np.ones(len(distances.data), dtype=np.uint8) 1679 1680 # For remaining dimensions, work only on chunks defined by first dimension pairs 1681 if len(all_values) > 1: 1682 distances_coo = distances.tocoo() 1683 valid_pairs = [] 1684 1685 # Process each pair from first dimension 1686 for idx in range(len(distances_coo.data)): 1687 i, j = distances_coo.row[idx], distances_coo.col[idx] 1688 is_valid_pair = True 1689 1690 # Check remaining dimensions for this specific pair 1691 for dim_idx in range(1, len(all_values)): 1692 values = all_values[dim_idx] 1693 val_i, val_j = values[i], values[j] 1694 1695 max_tol = tol[dim_idx] 1696 if relative[dim_idx] is True: 1697 max_tol = tol[dim_idx] * values.max() 1698 1699 distance_ij = abs(val_i - val_j) 1700 1701 # Check if this pair satisfies the tolerance for this dimension 1702 if relative[dim_idx] is True: 1703 if distance_ij > tol[dim_idx] * val_i: 1704 is_valid_pair = False 1705 break 1706 else: 1707 if distance_ij > max_tol: 1708 is_valid_pair = False 1709 break 1710 1711 if is_valid_pair: 1712 valid_pairs.append((i, j)) 1713 1714 # Rebuild distances matrix with only valid pairs 1715 if valid_pairs: 1716 valid_pairs = np.array(valid_pairs) 1717 distances = sparse.coo_matrix( 1718 ( 1719 np.ones(len(valid_pairs), dtype=np.uint8), 1720 (valid_pairs[:, 0], valid_pairs[:, 1]), 1721 ), 1722 shape=(len(values_0), len(values_0)), 1723 ) 1724 else: 1725 # No valid pairs found 1726 distances = sparse.coo_matrix( 1727 (len(values_0), len(values_0)), dtype=np.uint8 1728 ) 1729 1730 return distances 1731 1732 def sparse_upper_star(self, idx, V): 1733 """Sparse implementation of an upper star filtration. 1734 1735 Parameters 1736 ---------- 1737 idx : :obj:`~numpy.array` 1738 Edge indices for each dimension (MxN). 1739 V : :obj:`~numpy.array` 1740 Array of intensity data (Mx1). 1741 Returns 1742 ------- 1743 idx : :obj:`~numpy.array` 1744 Index of filtered points (Mx1). 1745 persistence : :obj:`~numpy.array` 1746 Persistence of each filtered point (Mx1). 1747 1748 Notes 1749 ----- 1750 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1751 """ 1752 1753 # Invert 1754 V = -1 * V.copy().astype(int) 1755 1756 # Embed indices 1757 V = self.embed_unique_indices(V) 1758 1759 # Connectivity matrix 1760 cmat = KDTree(idx) 1761 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1762 cmat.setdiag(1) 1763 cmat = sparse.triu(cmat) 1764 1765 # Pairwise minimums 1766 I, J = cmat.nonzero() 1767 d = np.maximum(V[I], V[J]) 1768 1769 # Delete connectiity matrix 1770 cmat_shape = cmat.shape 1771 del cmat 1772 1773 # Sparse distance matrix 1774 sdm = sparse.coo_matrix((d, (I, J)), shape=cmat_shape) 1775 1776 # Delete pairwise mins 1777 del d, I, J 1778 1779 # Persistence homology 1780 ph = ripser(sdm, distance_matrix=True, maxdim=0)["dgms"][0] 1781 1782 # Bound death values 1783 ph[ph[:, 1] == np.inf, 1] = np.max(V) 1784 1785 # Construct tree to query against 1786 tree = KDTree(V.reshape((-1, 1))) 1787 1788 # Get the indexes of the first nearest neighbor by birth 1789 _, nn = tree.query(ph[:, 0].reshape((-1, 1)), k=1, workers=-1) 1790 1791 return nn, -(ph[:, 0] // 1 - ph[:, 1] // 1) 1792 1793 def check_if_grid(self, data): 1794 """Check if the data are gridded in mz space. 1795 1796 Parameters 1797 ---------- 1798 data : DataFrame 1799 DataFrame containing the mass spectrometry data. Needs to have mz and scan columns. 1800 1801 Returns 1802 ------- 1803 bool 1804 True if the data is gridded in the mz direction, False otherwise. 1805 1806 Notes 1807 ----- 1808 This function is used within the grid_data function and the find_mass_features function and is not intended to be called directly. 1809 """ 1810 # Calculate the difference between consecutive mz values in a single scan 1811 dat_check = data.copy().reset_index(drop=True) 1812 dat_check["mz_diff"] = np.abs(dat_check["mz"].diff()) 1813 mz_diff_min = ( 1814 dat_check.groupby("scan")["mz_diff"].min().min() 1815 ) # within each scan, what is the smallest mz difference between consecutive mz values 1816 1817 # Find the mininum mz difference between mz values in the data; regardless of scan 1818 dat_check_mz = dat_check[["mz"]].drop_duplicates().copy() 1819 dat_check_mz = dat_check_mz.sort_values(by=["mz"]).reset_index(drop=True) 1820 dat_check_mz["mz_diff"] = np.abs(dat_check_mz["mz"].diff()) 1821 1822 # Get minimum mz_diff between mz values in the data 1823 mz_diff_min_raw = dat_check_mz["mz_diff"].min() 1824 1825 # 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 1826 if mz_diff_min_raw < mz_diff_min: 1827 return False 1828 else: 1829 return True 1830 1831 def grid_data(self, data, attempts=5): 1832 """Grid the data in the mz dimension. 1833 1834 Data must be gridded prior to persistent homology calculations and computing average mass spectrum 1835 1836 Parameters 1837 ---------- 1838 data : DataFrame 1839 The input data containing mz, scan, scan_time, and intensity columns. 1840 attempts : int, optional 1841 The number of attempts to grid the data. Default is 5. 1842 1843 Returns 1844 ------- 1845 DataFrame 1846 The gridded data with mz, scan, scan_time, and intensity columns. 1847 1848 Raises 1849 ------ 1850 ValueError 1851 If gridding fails after the specified number of attempts. 1852 """ 1853 attempt_i = 0 1854 while attempt_i < attempts: 1855 attempt_i += 1 1856 data = self._grid_data(data) 1857 1858 if self.check_if_grid(data): 1859 return data 1860 1861 if not self.check_if_grid(data): 1862 raise ValueError( 1863 "Gridding failed after " 1864 + str(attempt_i) 1865 + " attempts. Please check the data." 1866 ) 1867 else: 1868 return data 1869 1870 def _grid_data(self, data): 1871 """Internal method to grid the data in the mz dimension. 1872 1873 Notes 1874 ----- 1875 This method is called by the grid_data method and should not be called directly. 1876 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, 1877 but it does not check if the data is already gridded or if the gridding is successful. 1878 1879 Parameters 1880 ---------- 1881 data : pd.DataFrame or pl.DataFrame 1882 The input data to grid. 1883 1884 Returns 1885 ------- 1886 pd.DataFrame or pl.DataFrame 1887 The data after attempting to grid it in the mz dimension. 1888 """ 1889 # Calculate the difference between consecutive mz values in a single scan for grid spacing 1890 data_w = data.copy().reset_index(drop=True) 1891 data_w["mz_diff"] = np.abs(data_w["mz"].diff()) 1892 mz_diff_min = data_w.groupby("scan")["mz_diff"].min().min() * 0.99999 1893 1894 # Need high intensity mz values first so they are parents in the output pairs stack 1895 dat_mz = data_w[["mz", "intensity"]].sort_values( 1896 by=["intensity"], ascending=False 1897 ) 1898 dat_mz = dat_mz[["mz"]].drop_duplicates().reset_index(drop=True).copy() 1899 1900 # Construct KD tree 1901 tree = KDTree(dat_mz.mz.values.reshape(-1, 1)) 1902 sdm = tree.sparse_distance_matrix(tree, mz_diff_min, output_type="coo_matrix") 1903 sdm = sparse.triu(sdm, k=1) 1904 sdm.data = np.ones_like(sdm.data) 1905 distances = sdm.tocoo() 1906 pairs = np.stack((distances.row, distances.col), axis=1) 1907 1908 # Cull pairs to just get root 1909 to_drop = [] 1910 while len(pairs) > 0: 1911 root_parents = np.setdiff1d(np.unique(pairs[:, 0]), np.unique(pairs[:, 1])) 1912 id_root_parents = np.isin(pairs[:, 0], root_parents) 1913 children_of_roots = np.unique(pairs[id_root_parents, 1]) 1914 to_drop = np.append(to_drop, children_of_roots) 1915 1916 # Set up pairs array for next iteration by removing pairs with children or parents already dropped 1917 pairs = pairs[~np.isin(pairs[:, 1], to_drop), :] 1918 pairs = pairs[~np.isin(pairs[:, 0], to_drop), :] 1919 dat_mz = dat_mz.reset_index(drop=True).drop(index=np.array(to_drop)) 1920 mz_dat_np = ( 1921 dat_mz[["mz"]] 1922 .sort_values(by=["mz"]) 1923 .reset_index(drop=True) 1924 .values.flatten() 1925 ) 1926 1927 # Sort data by mz and recast mz to nearest value in mz_dat_np 1928 data_w = data_w.sort_values(by=["mz"]).reset_index(drop=True).copy() 1929 data_w["mz_new"] = mz_dat_np[find_closest(mz_dat_np, data_w["mz"].values)] 1930 data_w["mz_diff"] = np.abs(data_w["mz"] - data_w["mz_new"]) 1931 1932 # Rename mz_new as mz; drop mz_diff; groupby scan and mz and sum intensity 1933 new_data_w = data_w.rename(columns={"mz": "mz_orig", "mz_new": "mz"}).copy() 1934 new_data_w = ( 1935 new_data_w.drop(columns=["mz_diff", "mz_orig"]) 1936 .groupby(["scan", "mz"])["intensity"] 1937 .sum() 1938 .reset_index() 1939 ) 1940 new_data_w = ( 1941 new_data_w.sort_values(by=["scan", "mz"], ascending=[True, True]) 1942 .reset_index(drop=True) 1943 .copy() 1944 ) 1945 1946 return new_data_w 1947 1948 def _filter_data_by_targets(self, data, target_search_dict): 1949 """Filter MS data to only include m/z and RT windows around target values. 1950 1951 Parameters 1952 ---------- 1953 data : pd.DataFrame 1954 MS data with 'mz' and 'scan_time' columns 1955 target_search_dict : dict 1956 Dictionary with target_mz_list, target_rt_list, mz_tolerance_ppm, rt_tolerance 1957 1958 Returns 1959 ------- 1960 pd.DataFrame 1961 Filtered data containing only points within target windows 1962 """ 1963 target_mz_list = target_search_dict['target_mz_list'] 1964 target_rt_list = target_search_dict['target_rt_list'] 1965 mz_tolerance_ppm = target_search_dict['mz_tolerance_ppm'] 1966 rt_tolerance = target_search_dict['rt_tolerance'] 1967 1968 # Create a mask for data points that fall within any target window 1969 mask = np.zeros(len(data), dtype=bool) 1970 1971 for target_mz, target_rt in zip(target_mz_list, target_rt_list): 1972 # Calculate m/z window 1973 mz_tol = target_mz * mz_tolerance_ppm / 1e6 1974 mz_min = target_mz - mz_tol 1975 mz_max = target_mz + mz_tol 1976 1977 # Calculate RT window 1978 rt_min = target_rt - rt_tolerance 1979 rt_max = target_rt + rt_tolerance 1980 1981 # Create mask for this target 1982 target_mask = ( 1983 (data['mz'] >= mz_min) & (data['mz'] <= mz_max) & 1984 (data['scan_time'] >= rt_min) & (data['scan_time'] <= rt_max) 1985 ) 1986 1987 # Combine with overall mask 1988 mask |= target_mask 1989 1990 return data[mask].reset_index(drop=True) 1991 1992 def find_mass_features_ph(self, ms_level=1, grid=True, targeted_search=False, target_search_dict=None, mf_type="untargeted", accumulate_features=False): 1993 """Find mass features within an LCMSBase object using persistent homology. 1994 1995 Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id) 1996 1997 Parameters 1998 ---------- 1999 ms_level : int, optional 2000 The MS level to use. Default is 1. 2001 grid : bool, optional 2002 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True. 2003 targeted_search : bool, optional 2004 If True, perform targeted search mode. Default is False. 2005 target_search_dict : dict or None, optional 2006 Dictionary with target parameters for targeted search. Default is None. 2007 mf_type : str, optional 2008 Type label for the mass features. Default is "untargeted". 2009 accumulate_features : bool, optional 2010 If True, add to existing features rather than replacing them. Default is False. 2011 2012 Raises 2013 ------ 2014 ValueError 2015 If no MS level data is found on the object. 2016 If data is not gridded and grid is False. 2017 2018 Returns 2019 ------- 2020 None, but assigns the mass_features attribute to the object. 2021 2022 Notes 2023 ----- 2024 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 2025 """ 2026 # Check that ms_level is a key in self._ms_uprocessed 2027 if ms_level not in self._ms_unprocessed.keys(): 2028 raise ValueError( 2029 "No MS level " 2030 + str(ms_level) 2031 + " data found, did you instantiate with parser specific to MS level?" 2032 ) 2033 2034 # Get ms data 2035 data = self._ms_unprocessed[ms_level].copy() 2036 2037 # Drop rows with missing intensity values and reset index 2038 data = data.dropna(subset=["intensity"]).reset_index(drop=True) 2039 2040 # Add scan_time for filtering if in targeted mode 2041 if targeted_search: 2042 data = data.merge(self.scan_df[["scan", "scan_time"]], on="scan", how="left") 2043 2044 # Threshold data (bypass thresholds in targeted mode) 2045 dims = ["mz", "scan_time"] 2046 if targeted_search: 2047 # In targeted mode, bypass intensity and persistence thresholds 2048 threshold = 0 2049 persistence_threshold = 0 2050 # Filter data to only target windows 2051 data_thres = self._filter_data_by_targets(data, target_search_dict) 2052 if len(data_thres) == 0: 2053 if self.parameters.lc_ms.verbose_processing: 2054 print("No data found in target windows") 2055 self.mass_features = {} 2056 return 2057 else: 2058 threshold = self.parameters.lc_ms.ph_inten_min_rel * data.intensity.max() 2059 persistence_threshold = ( 2060 self.parameters.lc_ms.ph_persis_min_rel * data.intensity.max() 2061 ) 2062 data_thres = data[data["intensity"] > threshold].reset_index(drop=True).copy() 2063 2064 # Check if gridded, if not, grid 2065 gridded_mz = self.check_if_grid(data_thres) 2066 if gridded_mz is False: 2067 if grid is False: 2068 raise ValueError( 2069 "Data are not gridded in mz dimension, try reprocessing with a different params or grid data before running this function" 2070 ) 2071 else: 2072 data_thres = self.grid_data(data_thres) 2073 2074 # Add scan_time (skip if already present from targeted mode) 2075 if 'scan_time' not in data_thres.columns: 2076 data_thres = data_thres.merge(self.scan_df[["scan", "scan_time"]], on="scan") 2077 # Process in chunks if required 2078 if len(data_thres) > 10000: 2079 return self._find_mass_features_ph_partition( 2080 data_thres, dims, persistence_threshold, mf_type, accumulate_features 2081 ) 2082 else: 2083 # Process all at once 2084 return self._find_mass_features_ph_single( 2085 data_thres, dims, persistence_threshold, mf_type, accumulate_features 2086 ) 2087 return self._find_mass_features_ph_single( 2088 data_thres, dims, persistence_threshold, mf_type 2089 ) 2090 2091 def _find_mass_features_ph_single(self, data_thres, dims, persistence_threshold, mf_type="untargeted", accumulate_features=False): 2092 """Process all data at once (original logic).""" 2093 # Build factors 2094 factors = { 2095 dim: pd.factorize(data_thres[dim], sort=True)[1].astype(np.float32) 2096 for dim in dims 2097 } 2098 2099 # Build indexes 2100 index = { 2101 dim: np.searchsorted(factors[dim], data_thres[dim]).astype(np.float32) 2102 for dim in factors 2103 } 2104 2105 # Smooth and process 2106 mass_features_df = self._process_partition_ph( 2107 data_thres, index, dims, persistence_threshold 2108 ) 2109 2110 # Roll up within chunk to remove duplicates 2111 mass_features_df = self.roll_up_dataframe( 2112 df=mass_features_df, 2113 sort_by="persistence", 2114 dims=["mz", "scan_time"], 2115 tol=[ 2116 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2117 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2118 ], 2119 relative=[True, False], 2120 ) 2121 mass_features_df = mass_features_df.reset_index(drop=True) 2122 2123 # Populate mass_features attribute 2124 self._populate_mass_features(mass_features_df, mf_type, accumulate_features) 2125 2126 def _find_mass_features_ph_partition(self, data_thres, dims, persistence_threshold, mf_type="untargeted", accumulate_features=False): 2127 """Partition the persistent homology mass feature detection for large datasets. 2128 2129 This method splits the input data into overlapping scan partitions, processes each partition to detect mass features 2130 using persistent homology, rolls up duplicates within and across partitions, and populates the mass_features attribute. 2131 2132 Parameters 2133 ---------- 2134 data_thres : pd.DataFrame 2135 The thresholded input data containing mass spectrometry information. 2136 dims : list 2137 List of dimension names (e.g., ["mz", "scan_time"]) used for feature detection. 2138 persistence_threshold : float 2139 Minimum persistence value required for a detected mass feature to be retained. 2140 mf_type : str, optional 2141 Type label for the mass features. Default is "untargeted". 2142 accumulate_features : bool, optional 2143 If True, add to existing features rather than replacing them. Default is False. 2144 2145 Returns 2146 ------- 2147 None 2148 Populates the mass_features attribute of the object with detected mass features. 2149 """ 2150 all_mass_features = [] 2151 2152 # Split scans into partitions 2153 unique_scans = sorted(data_thres["scan"].unique()) 2154 unique_scans_n = len(unique_scans) 2155 2156 # Calculate partition size in scans based on goal 2157 partition_size_goal = 5000 2158 scans_per_partition = max( 2159 1, partition_size_goal // (len(data_thres) // unique_scans_n) 2160 ) 2161 if scans_per_partition == 0: 2162 scans_per_partition = 1 2163 2164 # Make partitions based on scans, with overlapping in partitioned scans 2165 scan_overlap = 4 2166 partition_scans = [] 2167 for i in range(0, unique_scans_n, scans_per_partition): 2168 start_idx = max(0, i - scan_overlap) 2169 end_idx = min( 2170 unique_scans_n - 1, i + scans_per_partition - 1 + scan_overlap 2171 ) 2172 scans_group = [int(s) for s in unique_scans[start_idx : end_idx + 1]] 2173 partition_scans.append(scans_group) 2174 2175 # Set index to scan for faster filtering 2176 data_thres = data_thres.set_index("scan") 2177 for scans in partition_scans: 2178 # Determine start and end scan for partition, with 5 scans overlap 2179 partition_data = data_thres.loc[scans].reset_index(drop=False).copy() 2180 2181 if len(partition_data) == 0: 2182 continue 2183 2184 # Build factors for this partition 2185 factors = { 2186 dim: pd.factorize(partition_data[dim], sort=True)[1].astype(np.float32) 2187 for dim in dims 2188 } 2189 2190 # Build indexes 2191 index = { 2192 dim: np.searchsorted(factors[dim], partition_data[dim]).astype( 2193 np.float32 2194 ) 2195 for dim in factors 2196 } 2197 2198 # Process partition 2199 partition_features = self._process_partition_ph( 2200 partition_data, index, dims, persistence_threshold 2201 ) 2202 2203 if len(partition_features) == 0: 2204 continue 2205 2206 # Roll up within partition 2207 partition_features = self.roll_up_dataframe( 2208 df=partition_features, 2209 sort_by="persistence", 2210 dims=["mz", "scan_time"], 2211 tol=[ 2212 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2213 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2214 ], 2215 relative=[True, False], 2216 ) 2217 partition_features = partition_features.reset_index(drop=True) 2218 2219 if len(partition_features) > 0: 2220 all_mass_features.append(partition_features) 2221 2222 # Combine results from all partitions 2223 if all_mass_features: 2224 combined_features = pd.concat(all_mass_features, ignore_index=True) 2225 2226 # Sort by persistence 2227 combined_features = combined_features.sort_values( 2228 by="persistence", ascending=False 2229 ).reset_index(drop=True) 2230 2231 # Remove duplicates from overlapping regions 2232 combined_features = self.roll_up_dataframe( 2233 df=combined_features, 2234 sort_by="persistence", 2235 dims=["mz", "scan_time"], 2236 tol=[ 2237 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2238 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2239 ], 2240 relative=[True, False], 2241 ) 2242 2243 # resort by persistence and reset index 2244 combined_features = combined_features.reset_index(drop=True) 2245 2246 # Populate mass_features attribute 2247 self._populate_mass_features(combined_features, mf_type, accumulate_features) 2248 else: 2249 self.mass_features = {} 2250 2251 def _process_partition_ph(self, partition_data, index, dims, persistence_threshold): 2252 """Process a single partition with persistent homology.""" 2253 # Smooth data 2254 iterations = self.parameters.lc_ms.ph_smooth_it 2255 smooth_radius = [ 2256 self.parameters.lc_ms.ph_smooth_radius_mz, 2257 self.parameters.lc_ms.ph_smooth_radius_scan, 2258 ] 2259 2260 index_array = np.vstack([index[dim] for dim in dims]).T 2261 V = partition_data["intensity"].values 2262 resid = np.inf 2263 2264 for i in range(iterations): 2265 # Previous iteration 2266 V_prev = V.copy() 2267 resid_prev = resid 2268 V = self.sparse_mean_filter(index_array, V, radius=smooth_radius) 2269 2270 # Calculate residual with previous iteration 2271 resid = np.sqrt(np.mean(np.square(V - V_prev))) 2272 2273 # Evaluate convergence 2274 if i > 0: 2275 # Percent change in residual 2276 test = np.abs(resid - resid_prev) / resid_prev 2277 2278 # Exit criteria 2279 if test <= 0: 2280 break 2281 2282 # Overwrite values 2283 partition_data = partition_data.copy() 2284 partition_data["intensity"] = V 2285 2286 # Use persistent homology to find regions of interest 2287 pidx, pers = self.sparse_upper_star(index_array, V) 2288 pidx = pidx[pers > 1] 2289 pers = pers[pers > 1] 2290 2291 if len(pidx) == 0: 2292 return pd.DataFrame() 2293 2294 # Get peaks 2295 peaks = partition_data.iloc[pidx, :].reset_index(drop=True) 2296 2297 # Add persistence column 2298 peaks["persistence"] = pers 2299 mass_features = peaks.sort_values( 2300 by="persistence", ascending=False 2301 ).reset_index(drop=True) 2302 2303 # Filter by persistence threshold 2304 mass_features = mass_features.loc[ 2305 mass_features["persistence"] > persistence_threshold, : 2306 ].reset_index(drop=True) 2307 2308 return mass_features 2309 2310 def _populate_mass_features(self, mass_features_df, mf_type="untargeted", accumulate_features=False): 2311 """Populate the mass_features attribute from a DataFrame. 2312 2313 Parameters 2314 ---------- 2315 mass_features_df : pd.DataFrame 2316 DataFrame containing mass feature information. 2317 Note that the order of this DataFrame will determine the order of mass features in the mass_features attribute. 2318 mf_type : str, optional 2319 Type label for the mass features. Default is "untargeted". 2320 accumulate_features : bool, optional 2321 If True, new features will be added to existing features rather than replacing them. 2322 Mass feature IDs will be offset to avoid conflicts. Default is False. 2323 2324 Returns 2325 ------- 2326 None, but assigns or updates the mass_features attribute to the object. 2327 """ 2328 # Rename scan column to apex_scan 2329 mass_features_df = mass_features_df.rename( 2330 columns={"scan": "apex_scan", "scan_time": "retention_time"} 2331 ) 2332 2333 # Initialize or preserve existing mass_features attribute 2334 if accumulate_features and self.mass_features is not None and len(self.mass_features) > 0: 2335 # Find the maximum existing ID to offset new IDs and avoid conflicts 2336 id_offset = max(self.mass_features.keys()) + 1 2337 initial_count = len(self.mass_features) 2338 else: 2339 # Replace mode (default/backwards compatible) 2340 self.mass_features = {} 2341 id_offset = 0 2342 initial_count = 0 2343 2344 # Add new mass features 2345 for idx, row in enumerate(mass_features_df.itertuples()): 2346 row_dict = mass_features_df.iloc[row.Index].to_dict() 2347 lcms_feature = LCMSMassFeature(self, **row_dict) 2348 lcms_feature.type = mf_type 2349 # Use sequential ID starting from id_offset to avoid conflicts with existing features 2350 new_id = idx + id_offset 2351 lcms_feature._id = new_id # Update the internal ID 2352 self.mass_features[new_id] = lcms_feature 2353 2354 if self.parameters.lc_ms.verbose_processing: 2355 if accumulate_features and initial_count > 0: 2356 print(f"Found {len(mass_features_df)} new mass features (total: {len(self.mass_features)})") 2357 else: 2358 print("Found " + str(len(mass_features_df)) + " initial mass features") 2359 2360 def find_mass_features_ph_centroid(self, ms_level=1, targeted_search=False, target_search_dict=None, mf_type="untargeted", accumulate_features=False): 2361 """Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data. 2362 2363 Parameters 2364 ---------- 2365 ms_level : int, optional 2366 The MS level to use. Default is 1. 2367 targeted_search : bool, optional 2368 If True, perform targeted search mode. Default is False. 2369 target_search_dict : dict or None, optional 2370 Dictionary with target parameters for targeted search. Default is None. 2371 mf_type : str, optional 2372 Type label for the mass features. Default is "untargeted". 2373 accumulate_features : bool, optional 2374 If True, add to existing features rather than replacing them. Default is False. 2375 2376 Raises 2377 ------ 2378 ValueError 2379 If no MS level data is found on the object. 2380 2381 Returns 2382 ------- 2383 None, but assigns the mass_features attribute to the object. 2384 """ 2385 # Check that ms_level is a key in self._ms_uprocessed 2386 if ms_level not in self._ms_unprocessed.keys(): 2387 raise ValueError( 2388 "No MS level " 2389 + str(ms_level) 2390 + " data found, did you instantiate with parser specific to MS level?" 2391 ) 2392 2393 # Work with reference instead of copy 2394 data = self._ms_unprocessed[ms_level] 2395 2396 # Merge with scan data first (needed for filtering in targeted mode) 2397 scan_subset = self.scan_df[["scan", "scan_time"]] 2398 data_with_time = data.merge(scan_subset, on="scan", how="inner") 2399 2400 # Calculate threshold and filter (bypass in targeted mode) 2401 if targeted_search: 2402 # In targeted mode, bypass intensity threshold 2403 threshold = 0 2404 valid_mask = data_with_time["intensity"].notna() 2405 required_cols = ["mz", "intensity", "scan", "scan_time"] 2406 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2407 2408 # Filter to target windows 2409 data_thres = self._filter_data_by_targets(data_thres, target_search_dict) 2410 2411 if len(data_thres) == 0: 2412 if self.parameters.lc_ms.verbose_processing: 2413 print("No data found in target windows") 2414 self.mass_features = {} 2415 return 2416 else: 2417 # Normal mode with threshold 2418 max_intensity = data_with_time["intensity"].max() 2419 threshold = self.parameters.lc_ms.ph_inten_min_rel * max_intensity 2420 valid_mask = data_with_time["intensity"].notna() & (data_with_time["intensity"] > threshold) 2421 required_cols = ["mz", "intensity", "scan", "scan_time"] 2422 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2423 2424 data_thres["persistence"] = data_thres["intensity"] 2425 mf_df = data_thres 2426 del data_thres, scan_subset, data_with_time 2427 2428 # Order by scan_time and then mz to ensure features near in rt are processed together 2429 # It's ok that different scans are in different partitions; we will roll up later 2430 mf_df = mf_df.sort_values( 2431 by=["scan_time", "mz"], ascending=[True, True] 2432 ).reset_index(drop=True) 2433 partition_size = 10000 2434 partitions = [ 2435 mf_df.iloc[i : i + partition_size].reset_index(drop=True) 2436 for i in range(0, len(mf_df), partition_size) 2437 ] 2438 del mf_df 2439 2440 # Run roll_up_dataframe on each partition 2441 rolled_partitions = [] 2442 for part in partitions: 2443 rolled = self.roll_up_dataframe( 2444 df=part, 2445 sort_by="persistence", 2446 dims=["mz", "scan_time"], 2447 tol=[ 2448 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2449 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2450 ], 2451 relative=[True, False], 2452 ) 2453 rolled_partitions.append(rolled) 2454 del partitions 2455 2456 # Run roll_up_dataframe on the rolled_up partitions to merge features near partition boundaries 2457 2458 # Combine results and run a final roll-up to merge features near partition boundaries 2459 mf_df_final = pd.concat(rolled_partitions, ignore_index=True) 2460 del rolled_partitions 2461 2462 # Reorder by persistence before final roll-up 2463 mf_df_final = mf_df_final.sort_values( 2464 by="persistence", ascending=False 2465 ).reset_index(drop=True) 2466 2467 mf_df_final = self.roll_up_dataframe( 2468 df=mf_df_final, 2469 sort_by="persistence", 2470 dims=["mz", "scan_time"], 2471 tol=[ 2472 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2473 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2474 ], 2475 relative=[True, False], 2476 ) 2477 # reset index 2478 mf_df_final = mf_df_final.reset_index(drop=True) 2479 2480 # Combine rename and sort operations 2481 mass_features = ( 2482 mf_df_final.rename( 2483 columns={"scan": "apex_scan", "scan_time": "retention_time"} 2484 ) 2485 .sort_values(by="persistence", ascending=False) 2486 .reset_index(drop=True) 2487 ) 2488 del mf_df_final # Free memory 2489 2490 # Order by persistence and reset index 2491 mass_features = mass_features.sort_values( 2492 by="persistence", ascending=False 2493 ).reset_index(drop=True) 2494 2495 self.mass_features = {} 2496 for idx, row in mass_features.iterrows(): 2497 row_dict = row.to_dict() 2498 lcms_feature = LCMSMassFeature(self, **row_dict) 2499 lcms_feature.type = mf_type 2500 self.mass_features[lcms_feature.id] = lcms_feature 2501 2502 if self.parameters.lc_ms.verbose_processing: 2503 print("Found " + str(len(mass_features)) + " initial mass features") 2504 2505 def cluster_mass_features(self, drop_children=True, sort_by="persistence"): 2506 """Cluster mass features 2507 2508 Based on their proximity in the mz and scan_time dimensions, priorizies the mass features with the highest persistence. 2509 2510 Parameters 2511 ---------- 2512 drop_children : bool, optional 2513 Whether to drop the mass features that are not cluster parents. Default is True. 2514 sort_by : str, optional 2515 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". 2516 2517 Raises 2518 ------ 2519 ValueError 2520 If no mass features are found. 2521 If too many mass features are found. 2522 2523 Returns 2524 ------- 2525 None if drop_children is True, otherwise returns a list of mass feature ids that are not cluster parents. 2526 """ 2527 if self.mass_features is None: 2528 raise ValueError("No mass features found, run find_mass_features() first") 2529 if len(self.mass_features) > 400000: 2530 raise ValueError( 2531 "Too many mass features of interest found, run find_mass_features() with a higher intensity threshold" 2532 ) 2533 dims = ["mz", "scan_time"] 2534 mf_df_og = self.mass_features_to_df() 2535 mf_df = mf_df_og.copy() 2536 2537 tol = [ 2538 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2539 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2540 ] # mz, in relative; scan_time in minutes 2541 relative = [True, False] 2542 2543 # Roll up mass features based on their proximity in the declared dimensions 2544 mf_df_new = self.roll_up_dataframe( 2545 df=mf_df, sort_by=sort_by, dims=dims, tol=tol, relative=relative 2546 ) 2547 2548 mf_df["cluster_parent"] = np.where( 2549 np.isin(mf_df.index, mf_df_new.index), True, False 2550 ) 2551 2552 # get mass feature ids of features that are not cluster parents 2553 cluster_daughters = mf_df[~mf_df["cluster_parent"]].index.values 2554 if drop_children is True: 2555 # Drop mass features that are not cluster parents from self 2556 self.mass_features = { 2557 k: v 2558 for k, v in self.mass_features.items() 2559 if k not in cluster_daughters 2560 } 2561 else: 2562 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.
1306 @staticmethod 1307 def sparse_mean_filter(idx, V, radius=[0, 1, 1]): 1308 """Sparse implementation of a mean filter. 1309 1310 Parameters 1311 ---------- 1312 idx : :obj:`~numpy.array` 1313 Edge indices for each dimension (MxN). 1314 V : :obj:`~numpy.array` 1315 Array of intensity data (Mx1). 1316 radius : float or list 1317 Radius of the sparse filter in each dimension. Values less than 1318 zero indicate no connectivity in that dimension. 1319 1320 Returns 1321 ------- 1322 :obj:`~numpy.array` 1323 Filtered intensities (Mx1). 1324 1325 Notes 1326 ----- 1327 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos. 1328 This is a static method. 1329 """ 1330 1331 # Copy indices 1332 idx = idx.copy().astype(V.dtype) 1333 1334 # Scale 1335 for i, r in enumerate(radius): 1336 # Increase inter-index distance 1337 if r < 1: 1338 idx[:, i] *= 2 1339 1340 # Do nothing 1341 elif r == 1: 1342 pass 1343 1344 # Decrease inter-index distance 1345 else: 1346 idx[:, i] /= r 1347 1348 # Connectivity matrix 1349 cmat = KDTree(idx) 1350 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1351 cmat.setdiag(1) 1352 1353 # Pair indices 1354 I, J = cmat.nonzero() 1355 1356 # Delete cmat 1357 cmat_shape = cmat.shape 1358 del cmat 1359 1360 # Sum over columns 1361 V_sum = sparse.bsr_matrix( 1362 (V[J], (I, I)), shape=cmat_shape, dtype=V.dtype 1363 ).diagonal(0) 1364 1365 # Count over columns 1366 V_count = sparse.bsr_matrix( 1367 (np.ones_like(J), (I, I)), shape=cmat_shape, dtype=V.dtype 1368 ).diagonal(0) 1369 1370 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.
1372 @staticmethod 1373 def embed_unique_indices(a): 1374 """Creates an array of indices, sorted by unique element. 1375 1376 Parameters 1377 ---------- 1378 a : :obj:`~numpy.array` 1379 Array of unique elements (Mx1). 1380 1381 Returns 1382 ------- 1383 :obj:`~numpy.array` 1384 Array of indices (Mx1). 1385 1386 Notes 1387 ----- 1388 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1389 This is a static method. 1390 """ 1391 1392 def count_tens(n): 1393 # Count tens 1394 ntens = (n - 1) // 10 1395 1396 while True: 1397 ntens_test = (ntens + n - 1) // 10 1398 1399 if ntens_test == ntens: 1400 return ntens 1401 else: 1402 ntens = ntens_test 1403 1404 def arange_exclude_10s(n): 1405 # How many 10s will there be? 1406 ntens = count_tens(n) 1407 1408 # Base array 1409 arr = np.arange(0, n + ntens) 1410 1411 # Exclude 10s 1412 arr = arr[(arr == 0) | (arr % 10 != 0)][:n] 1413 1414 return arr 1415 1416 # Creates an array of indices, sorted by unique element 1417 idx_sort = np.argsort(a) 1418 idx_unsort = np.argsort(idx_sort) 1419 1420 # Sorts records array so all unique elements are together 1421 sorted_a = a[idx_sort] 1422 1423 # Returns the unique values, the index of the first occurrence, 1424 # and the count for each element 1425 vals, idx_start, count = np.unique( 1426 sorted_a, return_index=True, return_counts=True 1427 ) 1428 1429 # Splits the indices into separate arrays 1430 splits = np.split(idx_sort, idx_start[1:]) 1431 1432 # Creates unique indices for each split 1433 idx_unq = np.concatenate([arange_exclude_10s(len(x)) for x in splits]) 1434 1435 # Reorders according to input array 1436 idx_unq = idx_unq[idx_unsort] 1437 1438 # Magnitude of each index 1439 exp = np.log10( 1440 idx_unq, where=idx_unq > 0, out=np.zeros_like(idx_unq, dtype=np.float64) 1441 ) 1442 idx_unq_mag = np.power(10, np.floor(exp) + 1) 1443 1444 # Result 1445 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.
1447 @staticmethod 1448 def roll_up_dataframe( 1449 df: pd.DataFrame, 1450 sort_by: str, 1451 tol: list, 1452 relative: list, 1453 dims: list, 1454 memory_opt_threshold: int = 10000, 1455 ): 1456 """Subset data by rolling up into apex in appropriate dimensions. 1457 1458 Parameters 1459 ---------- 1460 data : pd.DataFrame 1461 The input data containing "dims" columns and the "sort_by" column. 1462 sort_by : str 1463 The column to sort the data by, this will determine which mass features get rolled up into a parent mass feature 1464 (i.e., the mass feature with the highest value in the sort_by column). 1465 dims : list 1466 A list of dimension names (column names in the data DataFrame) to roll up the mass features by. 1467 tol : list 1468 A list of tolerances for each dimension. The length of the list must match the number of dimensions. 1469 The tolerances can be relative (as a fraction of the maximum value in that dimension) or absolute (in the units of that dimension). 1470 If relative is True, the tolerance will be multiplied by the maximum value in that dimension. 1471 If relative is False, the tolerance will be used as is. 1472 relative : list 1473 A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False). 1474 memory_opt_threshold : int, optional 1475 Minimum number of rows to trigger memory-optimized processing. Default is 10000. 1476 1477 Returns 1478 ------- 1479 pd.DataFrame 1480 A DataFrame with only the rolled up mass features, with the original index and columns. 1481 1482 1483 Raises 1484 ------ 1485 ValueError 1486 If the input data is not a pandas DataFrame. 1487 If the input data does not have columns for each of the dimensions in "dims". 1488 If the length of "dims", "tol", and "relative" do not match. 1489 """ 1490 og_columns = df.columns.copy() 1491 1492 # Unindex the data, but keep the original index 1493 if df.index.name is not None: 1494 og_index = df.index.name 1495 else: 1496 og_index = "index" 1497 df = df.reset_index(drop=False) 1498 1499 # Sort data by sort_by column, and reindex 1500 df = df.sort_values(by=sort_by, ascending=False).reset_index(drop=True) 1501 1502 # Check that data is a DataFrame and has columns for each of the dims 1503 if not isinstance(df, pd.DataFrame): 1504 raise ValueError("Data must be a pandas DataFrame") 1505 for dim in dims: 1506 if dim not in df.columns: 1507 raise ValueError(f"Data must have a column for {dim}") 1508 if len(dims) != len(tol) or len(dims) != len(relative): 1509 raise ValueError( 1510 "Dimensions, tolerances, and relative flags must be the same length" 1511 ) 1512 1513 # Pre-compute all values arrays 1514 all_values = [df[dim].values for dim in dims] 1515 1516 # Choose processing method based on dataframe size 1517 if len(df) >= memory_opt_threshold: 1518 # Memory-optimized approach for large dataframes 1519 distances = PHCalculations._compute_distances_memory_optimized( 1520 all_values, tol, relative 1521 ) 1522 else: 1523 # Faster approach for smaller dataframes 1524 distances = PHCalculations._compute_distances_original( 1525 all_values, tol, relative 1526 ) 1527 1528 # Process pairs with original logic but memory optimizations 1529 distances = distances.tocoo() 1530 pairs = np.stack((distances.row, distances.col), axis=1) 1531 pairs_df = pd.DataFrame(pairs, columns=["parent", "child"]).set_index("parent") 1532 del distances, pairs # Free memory immediately 1533 1534 to_drop = [] 1535 while not pairs_df.empty: 1536 # Find root_parents and their children (original logic preserved) 1537 root_parents = np.setdiff1d( 1538 np.unique(pairs_df.index.values), np.unique(pairs_df.child.values) 1539 ) 1540 children_of_roots = pairs_df.loc[root_parents, "child"].unique() 1541 to_drop.extend(children_of_roots) # Use extend instead of append 1542 1543 # Remove root_children as possible parents from pairs_df for next iteration 1544 pairs_df = pairs_df.drop(index=children_of_roots, errors="ignore") 1545 pairs_df = pairs_df.reset_index().set_index("child") 1546 # Remove root_children as possible children from pairs_df for next iteration 1547 pairs_df = pairs_df.drop(index=children_of_roots) 1548 1549 # Prepare for next iteration 1550 pairs_df = pairs_df.reset_index().set_index("parent") 1551 1552 # Convert to numpy array for efficient dropping 1553 to_drop = np.array(to_drop) 1554 1555 # Drop mass features that are not cluster parents 1556 df_sub = df.drop(index=to_drop) 1557 1558 # Set index back to og_index and only keep original columns 1559 df_sub = df_sub.set_index(og_index).sort_index()[og_columns] 1560 1561 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, the tolerance will be multiplied by the maximum value in that dimension. If relative is False, the tolerance will be used as is.
- relative (list): A list of booleans indicating whether the tolerance for each dimension is relative (True) or absolute (False).
- memory_opt_threshold (int, optional): Minimum number of rows to trigger memory-optimized processing. 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.
1732 def sparse_upper_star(self, idx, V): 1733 """Sparse implementation of an upper star filtration. 1734 1735 Parameters 1736 ---------- 1737 idx : :obj:`~numpy.array` 1738 Edge indices for each dimension (MxN). 1739 V : :obj:`~numpy.array` 1740 Array of intensity data (Mx1). 1741 Returns 1742 ------- 1743 idx : :obj:`~numpy.array` 1744 Index of filtered points (Mx1). 1745 persistence : :obj:`~numpy.array` 1746 Persistence of each filtered point (Mx1). 1747 1748 Notes 1749 ----- 1750 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 1751 """ 1752 1753 # Invert 1754 V = -1 * V.copy().astype(int) 1755 1756 # Embed indices 1757 V = self.embed_unique_indices(V) 1758 1759 # Connectivity matrix 1760 cmat = KDTree(idx) 1761 cmat = cmat.sparse_distance_matrix(cmat, 1, p=np.inf, output_type="coo_matrix") 1762 cmat.setdiag(1) 1763 cmat = sparse.triu(cmat) 1764 1765 # Pairwise minimums 1766 I, J = cmat.nonzero() 1767 d = np.maximum(V[I], V[J]) 1768 1769 # Delete connectiity matrix 1770 cmat_shape = cmat.shape 1771 del cmat 1772 1773 # Sparse distance matrix 1774 sdm = sparse.coo_matrix((d, (I, J)), shape=cmat_shape) 1775 1776 # Delete pairwise mins 1777 del d, I, J 1778 1779 # Persistence homology 1780 ph = ripser(sdm, distance_matrix=True, maxdim=0)["dgms"][0] 1781 1782 # Bound death values 1783 ph[ph[:, 1] == np.inf, 1] = np.max(V) 1784 1785 # Construct tree to query against 1786 tree = KDTree(V.reshape((-1, 1))) 1787 1788 # Get the indexes of the first nearest neighbor by birth 1789 _, nn = tree.query(ph[:, 0].reshape((-1, 1)), k=1, workers=-1) 1790 1791 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
1793 def check_if_grid(self, data): 1794 """Check if the data are gridded in mz space. 1795 1796 Parameters 1797 ---------- 1798 data : DataFrame 1799 DataFrame containing the mass spectrometry data. Needs to have mz and scan columns. 1800 1801 Returns 1802 ------- 1803 bool 1804 True if the data is gridded in the mz direction, False otherwise. 1805 1806 Notes 1807 ----- 1808 This function is used within the grid_data function and the find_mass_features function and is not intended to be called directly. 1809 """ 1810 # Calculate the difference between consecutive mz values in a single scan 1811 dat_check = data.copy().reset_index(drop=True) 1812 dat_check["mz_diff"] = np.abs(dat_check["mz"].diff()) 1813 mz_diff_min = ( 1814 dat_check.groupby("scan")["mz_diff"].min().min() 1815 ) # within each scan, what is the smallest mz difference between consecutive mz values 1816 1817 # Find the mininum mz difference between mz values in the data; regardless of scan 1818 dat_check_mz = dat_check[["mz"]].drop_duplicates().copy() 1819 dat_check_mz = dat_check_mz.sort_values(by=["mz"]).reset_index(drop=True) 1820 dat_check_mz["mz_diff"] = np.abs(dat_check_mz["mz"].diff()) 1821 1822 # Get minimum mz_diff between mz values in the data 1823 mz_diff_min_raw = dat_check_mz["mz_diff"].min() 1824 1825 # 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 1826 if mz_diff_min_raw < mz_diff_min: 1827 return False 1828 else: 1829 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.
1831 def grid_data(self, data, attempts=5): 1832 """Grid the data in the mz dimension. 1833 1834 Data must be gridded prior to persistent homology calculations and computing average mass spectrum 1835 1836 Parameters 1837 ---------- 1838 data : DataFrame 1839 The input data containing mz, scan, scan_time, and intensity columns. 1840 attempts : int, optional 1841 The number of attempts to grid the data. Default is 5. 1842 1843 Returns 1844 ------- 1845 DataFrame 1846 The gridded data with mz, scan, scan_time, and intensity columns. 1847 1848 Raises 1849 ------ 1850 ValueError 1851 If gridding fails after the specified number of attempts. 1852 """ 1853 attempt_i = 0 1854 while attempt_i < attempts: 1855 attempt_i += 1 1856 data = self._grid_data(data) 1857 1858 if self.check_if_grid(data): 1859 return data 1860 1861 if not self.check_if_grid(data): 1862 raise ValueError( 1863 "Gridding failed after " 1864 + str(attempt_i) 1865 + " attempts. Please check the data." 1866 ) 1867 else: 1868 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.
1992 def find_mass_features_ph(self, ms_level=1, grid=True, targeted_search=False, target_search_dict=None, mf_type="untargeted", accumulate_features=False): 1993 """Find mass features within an LCMSBase object using persistent homology. 1994 1995 Assigns the mass_features attribute to the object (a dictionary of LCMSMassFeature objects, keyed by mass feature id) 1996 1997 Parameters 1998 ---------- 1999 ms_level : int, optional 2000 The MS level to use. Default is 1. 2001 grid : bool, optional 2002 If True, will regrid the data before running the persistent homology calculations (after checking if the data is gridded). Default is True. 2003 targeted_search : bool, optional 2004 If True, perform targeted search mode. Default is False. 2005 target_search_dict : dict or None, optional 2006 Dictionary with target parameters for targeted search. Default is None. 2007 mf_type : str, optional 2008 Type label for the mass features. Default is "untargeted". 2009 accumulate_features : bool, optional 2010 If True, add to existing features rather than replacing them. Default is False. 2011 2012 Raises 2013 ------ 2014 ValueError 2015 If no MS level data is found on the object. 2016 If data is not gridded and grid is False. 2017 2018 Returns 2019 ------- 2020 None, but assigns the mass_features attribute to the object. 2021 2022 Notes 2023 ----- 2024 This function has been adapted from the original implementation in the Deimos package: https://github.com/pnnl/deimos 2025 """ 2026 # Check that ms_level is a key in self._ms_uprocessed 2027 if ms_level not in self._ms_unprocessed.keys(): 2028 raise ValueError( 2029 "No MS level " 2030 + str(ms_level) 2031 + " data found, did you instantiate with parser specific to MS level?" 2032 ) 2033 2034 # Get ms data 2035 data = self._ms_unprocessed[ms_level].copy() 2036 2037 # Drop rows with missing intensity values and reset index 2038 data = data.dropna(subset=["intensity"]).reset_index(drop=True) 2039 2040 # Add scan_time for filtering if in targeted mode 2041 if targeted_search: 2042 data = data.merge(self.scan_df[["scan", "scan_time"]], on="scan", how="left") 2043 2044 # Threshold data (bypass thresholds in targeted mode) 2045 dims = ["mz", "scan_time"] 2046 if targeted_search: 2047 # In targeted mode, bypass intensity and persistence thresholds 2048 threshold = 0 2049 persistence_threshold = 0 2050 # Filter data to only target windows 2051 data_thres = self._filter_data_by_targets(data, target_search_dict) 2052 if len(data_thres) == 0: 2053 if self.parameters.lc_ms.verbose_processing: 2054 print("No data found in target windows") 2055 self.mass_features = {} 2056 return 2057 else: 2058 threshold = self.parameters.lc_ms.ph_inten_min_rel * data.intensity.max() 2059 persistence_threshold = ( 2060 self.parameters.lc_ms.ph_persis_min_rel * data.intensity.max() 2061 ) 2062 data_thres = data[data["intensity"] > threshold].reset_index(drop=True).copy() 2063 2064 # Check if gridded, if not, grid 2065 gridded_mz = self.check_if_grid(data_thres) 2066 if gridded_mz is False: 2067 if grid is False: 2068 raise ValueError( 2069 "Data are not gridded in mz dimension, try reprocessing with a different params or grid data before running this function" 2070 ) 2071 else: 2072 data_thres = self.grid_data(data_thres) 2073 2074 # Add scan_time (skip if already present from targeted mode) 2075 if 'scan_time' not in data_thres.columns: 2076 data_thres = data_thres.merge(self.scan_df[["scan", "scan_time"]], on="scan") 2077 # Process in chunks if required 2078 if len(data_thres) > 10000: 2079 return self._find_mass_features_ph_partition( 2080 data_thres, dims, persistence_threshold, mf_type, accumulate_features 2081 ) 2082 else: 2083 # Process all at once 2084 return self._find_mass_features_ph_single( 2085 data_thres, dims, persistence_threshold, mf_type, accumulate_features 2086 ) 2087 return self._find_mass_features_ph_single( 2088 data_thres, dims, persistence_threshold, mf_type 2089 )
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
2360 def find_mass_features_ph_centroid(self, ms_level=1, targeted_search=False, target_search_dict=None, mf_type="untargeted", accumulate_features=False): 2361 """Find mass features within an LCMSBase object using persistent homology-type approach but with centroided data. 2362 2363 Parameters 2364 ---------- 2365 ms_level : int, optional 2366 The MS level to use. Default is 1. 2367 targeted_search : bool, optional 2368 If True, perform targeted search mode. Default is False. 2369 target_search_dict : dict or None, optional 2370 Dictionary with target parameters for targeted search. Default is None. 2371 mf_type : str, optional 2372 Type label for the mass features. Default is "untargeted". 2373 accumulate_features : bool, optional 2374 If True, add to existing features rather than replacing them. Default is False. 2375 2376 Raises 2377 ------ 2378 ValueError 2379 If no MS level data is found on the object. 2380 2381 Returns 2382 ------- 2383 None, but assigns the mass_features attribute to the object. 2384 """ 2385 # Check that ms_level is a key in self._ms_uprocessed 2386 if ms_level not in self._ms_unprocessed.keys(): 2387 raise ValueError( 2388 "No MS level " 2389 + str(ms_level) 2390 + " data found, did you instantiate with parser specific to MS level?" 2391 ) 2392 2393 # Work with reference instead of copy 2394 data = self._ms_unprocessed[ms_level] 2395 2396 # Merge with scan data first (needed for filtering in targeted mode) 2397 scan_subset = self.scan_df[["scan", "scan_time"]] 2398 data_with_time = data.merge(scan_subset, on="scan", how="inner") 2399 2400 # Calculate threshold and filter (bypass in targeted mode) 2401 if targeted_search: 2402 # In targeted mode, bypass intensity threshold 2403 threshold = 0 2404 valid_mask = data_with_time["intensity"].notna() 2405 required_cols = ["mz", "intensity", "scan", "scan_time"] 2406 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2407 2408 # Filter to target windows 2409 data_thres = self._filter_data_by_targets(data_thres, target_search_dict) 2410 2411 if len(data_thres) == 0: 2412 if self.parameters.lc_ms.verbose_processing: 2413 print("No data found in target windows") 2414 self.mass_features = {} 2415 return 2416 else: 2417 # Normal mode with threshold 2418 max_intensity = data_with_time["intensity"].max() 2419 threshold = self.parameters.lc_ms.ph_inten_min_rel * max_intensity 2420 valid_mask = data_with_time["intensity"].notna() & (data_with_time["intensity"] > threshold) 2421 required_cols = ["mz", "intensity", "scan", "scan_time"] 2422 data_thres = data_with_time.loc[valid_mask, required_cols].copy() 2423 2424 data_thres["persistence"] = data_thres["intensity"] 2425 mf_df = data_thres 2426 del data_thres, scan_subset, data_with_time 2427 2428 # Order by scan_time and then mz to ensure features near in rt are processed together 2429 # It's ok that different scans are in different partitions; we will roll up later 2430 mf_df = mf_df.sort_values( 2431 by=["scan_time", "mz"], ascending=[True, True] 2432 ).reset_index(drop=True) 2433 partition_size = 10000 2434 partitions = [ 2435 mf_df.iloc[i : i + partition_size].reset_index(drop=True) 2436 for i in range(0, len(mf_df), partition_size) 2437 ] 2438 del mf_df 2439 2440 # Run roll_up_dataframe on each partition 2441 rolled_partitions = [] 2442 for part in partitions: 2443 rolled = self.roll_up_dataframe( 2444 df=part, 2445 sort_by="persistence", 2446 dims=["mz", "scan_time"], 2447 tol=[ 2448 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2449 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2450 ], 2451 relative=[True, False], 2452 ) 2453 rolled_partitions.append(rolled) 2454 del partitions 2455 2456 # Run roll_up_dataframe on the rolled_up partitions to merge features near partition boundaries 2457 2458 # Combine results and run a final roll-up to merge features near partition boundaries 2459 mf_df_final = pd.concat(rolled_partitions, ignore_index=True) 2460 del rolled_partitions 2461 2462 # Reorder by persistence before final roll-up 2463 mf_df_final = mf_df_final.sort_values( 2464 by="persistence", ascending=False 2465 ).reset_index(drop=True) 2466 2467 mf_df_final = self.roll_up_dataframe( 2468 df=mf_df_final, 2469 sort_by="persistence", 2470 dims=["mz", "scan_time"], 2471 tol=[ 2472 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2473 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2474 ], 2475 relative=[True, False], 2476 ) 2477 # reset index 2478 mf_df_final = mf_df_final.reset_index(drop=True) 2479 2480 # Combine rename and sort operations 2481 mass_features = ( 2482 mf_df_final.rename( 2483 columns={"scan": "apex_scan", "scan_time": "retention_time"} 2484 ) 2485 .sort_values(by="persistence", ascending=False) 2486 .reset_index(drop=True) 2487 ) 2488 del mf_df_final # Free memory 2489 2490 # Order by persistence and reset index 2491 mass_features = mass_features.sort_values( 2492 by="persistence", ascending=False 2493 ).reset_index(drop=True) 2494 2495 self.mass_features = {} 2496 for idx, row in mass_features.iterrows(): 2497 row_dict = row.to_dict() 2498 lcms_feature = LCMSMassFeature(self, **row_dict) 2499 lcms_feature.type = mf_type 2500 self.mass_features[lcms_feature.id] = lcms_feature 2501 2502 if self.parameters.lc_ms.verbose_processing: 2503 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.
2505 def cluster_mass_features(self, drop_children=True, sort_by="persistence"): 2506 """Cluster mass features 2507 2508 Based on their proximity in the mz and scan_time dimensions, priorizies the mass features with the highest persistence. 2509 2510 Parameters 2511 ---------- 2512 drop_children : bool, optional 2513 Whether to drop the mass features that are not cluster parents. Default is True. 2514 sort_by : str, optional 2515 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". 2516 2517 Raises 2518 ------ 2519 ValueError 2520 If no mass features are found. 2521 If too many mass features are found. 2522 2523 Returns 2524 ------- 2525 None if drop_children is True, otherwise returns a list of mass feature ids that are not cluster parents. 2526 """ 2527 if self.mass_features is None: 2528 raise ValueError("No mass features found, run find_mass_features() first") 2529 if len(self.mass_features) > 400000: 2530 raise ValueError( 2531 "Too many mass features of interest found, run find_mass_features() with a higher intensity threshold" 2532 ) 2533 dims = ["mz", "scan_time"] 2534 mf_df_og = self.mass_features_to_df() 2535 mf_df = mf_df_og.copy() 2536 2537 tol = [ 2538 self.parameters.lc_ms.mass_feature_cluster_mz_tolerance_rel, 2539 self.parameters.lc_ms.mass_feature_cluster_rt_tolerance, 2540 ] # mz, in relative; scan_time in minutes 2541 relative = [True, False] 2542 2543 # Roll up mass features based on their proximity in the declared dimensions 2544 mf_df_new = self.roll_up_dataframe( 2545 df=mf_df, sort_by=sort_by, dims=dims, tol=tol, relative=relative 2546 ) 2547 2548 mf_df["cluster_parent"] = np.where( 2549 np.isin(mf_df.index, mf_df_new.index), True, False 2550 ) 2551 2552 # get mass feature ids of features that are not cluster parents 2553 cluster_daughters = mf_df[~mf_df["cluster_parent"]].index.values 2554 if drop_children is True: 2555 # Drop mass features that are not cluster parents from self 2556 self.mass_features = { 2557 k: v 2558 for k, v in self.mass_features.items() 2559 if k not in cluster_daughters 2560 } 2561 else: 2562 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.
2565class LCMSCollectionCalculations: 2566 """Methods for performing calculations related to LCMSCollection objects. 2567 2568 Notes 2569 ----- 2570 This class is intended as a mixin for the LCMSCollection class. 2571 """ 2572 2573 @staticmethod 2574 def _plot_multiple_eics(ax, cluster_mfs, induced_cluster_mfs, rep_sample_id, rep_mf_id, 2575 median_rt, eic_buffer_time, plot_smoothed=False, 2576 plot_datapoints=False, label_samples=False, lcms_collection=None): 2577 """Internal method to plot multiple EICs from different samples on a given axis. 2578 2579 Parameters 2580 ---------- 2581 ax : matplotlib.axes.Axes 2582 The axis to plot on. 2583 cluster_mfs : pd.DataFrame 2584 DataFrame containing cluster mass features (non-induced). 2585 induced_cluster_mfs : pd.DataFrame or None 2586 DataFrame containing induced (gap-filled) mass features. 2587 rep_sample_id : int 2588 Sample ID of the representative mass feature. 2589 rep_mf_id : int 2590 Mass feature ID of the representative mass feature. 2591 median_rt : float 2592 Median retention time for the cluster. 2593 eic_buffer_time : float 2594 Time buffer around the peak (minutes). 2595 plot_smoothed : bool, optional 2596 If True, plot smoothed EICs. Default is False. 2597 plot_datapoints : bool, optional 2598 If True, plot EIC datapoints. Default is False. 2599 label_samples : bool, optional 2600 If True, label each sample individually. Default is False. 2601 lcms_collection : LCMSCollection, optional 2602 The parent collection object for accessing samples. Required. 2603 """ 2604 ax.set_title("EICs from all samples", loc="left") 2605 2606 # Track if we've added labels for legend (to avoid duplicates) 2607 rep_labeled = False 2608 regular_labeled = False 2609 induced_labeled = False 2610 2611 # Plot regular (non-induced) mass features 2612 for _, row in cluster_mfs.iterrows(): 2613 sample_id = int(row['sample_id']) 2614 mf_id = row['mf_id'] 2615 sample = lcms_collection[sample_id] 2616 sample_name = row['sample_name'] 2617 2618 # Get EIC using eic_mz column from dataframe 2619 eic_mz = row.get('_eic_mz') 2620 if eic_mz is not None and not pd.isna(eic_mz) and hasattr(sample, 'eics') and sample.eics: 2621 eic_data = sample.eics.get(eic_mz) 2622 else: 2623 eic_data = None 2624 2625 if eic_data: 2626 # Determine line style and width 2627 if sample_id == rep_sample_id and mf_id == rep_mf_id: 2628 # Representative feature - bold line 2629 linewidth = 2.5 2630 alpha = 1.0 2631 color = 'tab:blue' 2632 if label_samples: 2633 label = f"{sample_name} (representative)" 2634 else: 2635 label = "Representative" if not rep_labeled else None 2636 rep_labeled = True 2637 else: 2638 # Other features - thinner line 2639 linewidth = 1.0 2640 alpha = 0.5 2641 color = 'tab:blue' 2642 if label_samples: 2643 label = sample_name 2644 else: 2645 label = "Regular features" if not regular_labeled else None 2646 regular_labeled = True 2647 2648 ax.plot( 2649 eic_data.time, 2650 eic_data.eic, 2651 c=color, 2652 linewidth=linewidth, 2653 alpha=alpha, 2654 linestyle='-', 2655 label=label 2656 ) 2657 2658 if plot_datapoints: 2659 ax.scatter( 2660 eic_data.time, 2661 eic_data.eic, 2662 c=color, 2663 alpha=alpha, 2664 s=10 2665 ) 2666 2667 if plot_smoothed and hasattr(eic_data, 'eic_smoothed'): 2668 ax.plot( 2669 eic_data.time, 2670 eic_data.eic_smoothed, 2671 c=color, 2672 linestyle='--', 2673 alpha=alpha * 0.8, 2674 linewidth=linewidth * 0.8 2675 ) 2676 2677 # Plot induced (gap-filled) mass features if available 2678 if induced_cluster_mfs is not None and not induced_cluster_mfs.empty: 2679 for _, row in induced_cluster_mfs.iterrows(): 2680 sample_id = int(row['sample_id']) 2681 mf_id = row['mf_id'] 2682 sample = lcms_collection[sample_id] 2683 sample_name = row['sample_name'] 2684 2685 # Get EIC using eic_mz column from dataframe 2686 eic_mz = row.get('_eic_mz') 2687 if eic_mz is not None and not pd.isna(eic_mz) and hasattr(sample, 'eics') and sample.eics: 2688 eic_data = sample.eics.get(eic_mz) 2689 else: 2690 eic_data = None 2691 2692 if eic_data: 2693 # Induced features - even thinner line 2694 linewidth = 0.5 2695 alpha = 0.4 2696 color = 'tab:orange' 2697 2698 if label_samples: 2699 label = f"{sample_name} (induced)" 2700 else: 2701 label = "Gap-filled features" if not induced_labeled else None 2702 induced_labeled = True 2703 2704 ax.plot( 2705 eic_data.time, 2706 eic_data.eic, 2707 c=color, 2708 linewidth=linewidth, 2709 alpha=alpha, 2710 linestyle='-', 2711 label=label 2712 ) 2713 2714 if plot_datapoints: 2715 ax.scatter( 2716 eic_data.time, 2717 eic_data.eic, 2718 c=color, 2719 alpha=alpha, 2720 s=5 2721 ) 2722 2723 if plot_smoothed and hasattr(eic_data, 'eic_smoothed'): 2724 ax.plot( 2725 eic_data.time, 2726 eic_data.eic_smoothed, 2727 c=color, 2728 linestyle='--', 2729 alpha=alpha * 0.8, 2730 linewidth=linewidth * 0.8 2731 ) 2732 2733 # Add vertical line at median RT 2734 ax.axvline( 2735 x=median_rt, 2736 color='k', 2737 linestyle='--', 2738 alpha=0.7, 2739 label='Median RT' 2740 ) 2741 2742 ax.set_ylabel("Intensity") 2743 ax.set_xlabel("Time (minutes)") 2744 ax.set_xlim( 2745 median_rt - eic_buffer_time, 2746 median_rt + eic_buffer_time, 2747 ) 2748 ax.legend(loc='upper left', fontsize=8) 2749 ax.yaxis.get_major_formatter().set_useOffset(False) 2750 2751 def clean_sparse_matrix(self, sparse_matrix): 2752 """Clean a sparse matrix by removing duplicates and sorting. 2753 2754 Parameters 2755 ---------- 2756 sparse_matrix : :obj:`~numpy.array` 2757 A sparse matrix to clean. 2758 2759 Returns 2760 ------- 2761 :obj:`~numpy.array` 2762 A cleaned sparse matrix. 2763 """ 2764 for match in sparse_matrix: 2765 match.sort() 2766 sparse_matrix.sort() 2767 dereplicated_sparse_matrix = np.unique(sparse_matrix, axis=0) 2768 return dereplicated_sparse_matrix 2769 2770 def match_mfs(self, mf_c, mf_i): 2771 """Match mass features between two LCMS objects. 2772 2773 Parameters 2774 ---------- 2775 mf_c : :obj:`~pandas.DataFrame` 2776 The mass features to match against. 2777 mf_i : :obj:`~pandas.DataFrame` 2778 The mass features to match. 2779 2780 Returns 2781 ------- 2782 :obj:`~pandas.DataFrame` 2783 The matched mass features from mf_c. 2784 :obj:`~pandas.DataFrame` 2785 The matched mass features from mf_i. 2786 2787 Notes 2788 ----- 2789 This function has been adapted from the original implementation in the Deimos package: 2790 https://github.com/pnnl/deimos 2791 """ 2792 if mf_c is None or mf_i is None or len(mf_c.index) < 1 or len(mf_i.index) < 1: 2793 return None, None 2794 2795 # Prepare dataframes 2796 mf_c = mf_c.copy() 2797 mf_c["id_i"] = 0 2798 mf_i = mf_i.copy() 2799 mf_i["id_i"] = 1 2800 2801 # Set dimensions for matching 2802 dims = ["mz", "scan_time"] 2803 relative = [True, False] 2804 mz_tol = self.parameters.lcms_collection.alignment_mz_tol_ppm * 1e-6 2805 rt_tol = self.parameters.lcms_collection.alignment_rt_tol 2806 tol = [mz_tol, rt_tol] 2807 2808 # Compute inter-feature distances 2809 idx = [] 2810 for i, f in enumerate(dims): 2811 # vectors 2812 v1 = mf_c[f].values.reshape(-1, 1) 2813 v2 = mf_i[f].values.reshape(-1, 1) 2814 2815 # Distances 2816 d = scipy.spatial.distance.cdist(v1, v2) 2817 2818 if relative[i] is True: 2819 # Divisor 2820 basis = np.repeat(v1, v2.shape[0], axis=1) 2821 fix = np.repeat(v2, v1.shape[0], axis=1).T 2822 basis = np.where(basis == 0, fix, basis) 2823 2824 # Divide 2825 d = np.divide(d, basis, out=np.zeros_like(basis), where=basis != 0) 2826 2827 # Check tol 2828 idx.append(d <= tol[i]) 2829 2830 # Stack truth arrays 2831 idx = np.prod(np.dstack(idx), axis=-1, dtype=bool) 2832 2833 # Compute normalized 3d distance 2834 v1 = mf_c[dims].values / tol 2835 v2 = mf_i[dims].values / tol 2836 dist3d = scipy.spatial.distance.cdist(v1, v2, "cityblock") 2837 2838 # Separate features within tolerance from those outside 2839 # Features outside tolerance should be inf, features within tolerance keep their distance 2840 # Use idx mask: True for within tolerance, False for outside 2841 dist3d_within_tol = np.where(idx, dist3d, np.inf) 2842 2843 # Normalize to 0-1 (only affects within-tolerance distances) 2844 mx = np.max(dist3d_within_tol[idx]) if np.sum(idx) > 0 else 0 2845 if mx > 0: 2846 # Lower distance is better - normalize only the within-tolerance values 2847 dist3d_within_tol = np.where(idx, dist3d_within_tol / mx, np.inf) 2848 else: 2849 # All matches are perfect (distance=0), assign tiny value to within-tolerance pairs 2850 dist3d_within_tol = np.where(idx, 1e-10, np.inf) 2851 2852 # Use the masked distance matrix 2853 dist3d = dist3d_within_tol 2854 2855 # Min over dims 2856 mincols = np.min(dist3d, axis=0, keepdims=True) 2857 2858 # Zero out mincols over dims 2859 dist3d[dist3d != mincols] = np.inf 2860 2861 # Min over clusters 2862 minrows = np.min(dist3d, axis=1, keepdims=True) 2863 2864 # Where max and nonzero 2865 ii, jj = np.where((dist3d == minrows) & (dist3d < np.inf)) 2866 2867 # Reorder 2868 mf_c = mf_c.iloc[ii] 2869 mf_i = mf_i.iloc[jj] 2870 2871 if len(mf_c.index) < 1 or len(mf_i.index) < 1: 2872 return None, None 2873 2874 return mf_c, mf_i 2875 2876 def fit_rts(self, a, b, align="scan_time", **kwargs): 2877 """ 2878 Fit a support vector regressor to matched features. 2879 2880 Parameters 2881 ---------- 2882 a : :obj:`~pandas.DataFrame` 2883 First set of input feature coordinates and intensities; the center object and the object to align to. 2884 b : :obj:`~pandas.DataFrame` 2885 Second set of input feature coordinates and intensities; the object to align to the center object. 2886 align : str 2887 Dimension to align. 2888 kwargs 2889 Keyword arguments for support vector regressor 2890 (:class:`sklearn.svm.SVR`). 2891 2892 Returns 2893 ------- 2894 :obj:`~function` 2895 An interpolation function where one can input a retention time and get the predicted retention time. 2896 2897 Notes 2898 ----- 2899 This function has been adapted from the original implementation in the Deimos package: 2900 https://github.com/pnnl/deimos 2901 2902 """ 2903 2904 # Uniqueify 2905 x = a[align].values 2906 y = b[align].values 2907 arr = np.vstack((x, y)).T 2908 arr = np.unique(arr, axis=0) 2909 2910 # Safety check: ensure we have data to work with 2911 if len(arr) == 0: 2912 warnings.warn("No data points available for retention time fitting. Returning identity function.") 2913 return lambda x: x 2914 2915 # Check kwargs 2916 if "kernel" in kwargs: 2917 kernel = kwargs.get("kernel") 2918 else: 2919 kernel = "linear" 2920 2921 # Construct interpolation axis 2922 newx = np.linspace(arr[:, 0].min(), arr[:, 0].max(), 1000) 2923 2924 # Linear kernel 2925 if kernel == "linear": 2926 reg = scipy.stats.linregress(x, y) 2927 newy = reg.slope * newx + reg.intercept 2928 2929 # Other kernels 2930 else: 2931 # Fit 2932 svr = SVR(**kwargs) 2933 svr.fit(arr[:, 1].reshape(-1, 1), arr[:, 0]) 2934 2935 # Predict 2936 newy = svr.predict(newx.reshape(-1, 1)) 2937 2938 # Pad x and y_pred with zeros to force interpolation to start at 0 2939 newx = np.concatenate(([0], newx)) 2940 newy = np.concatenate(([0], newy)) 2941 2942 # 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 2943 max_time = self[0].scan_df["scan_time"].max() 2944 newx = np.concatenate((newx, [max_time])) 2945 newy = np.concatenate((newy, [max_time])) 2946 2947 # Return an interpolation function for the x and y_pred 2948 def interp(x): 2949 pred_y = np.interp(x, newx, newy) 2950 return pred_y 2951 2952 return interp 2953 2954 def get_anchor_mass_features(self, mf_df): 2955 """ 2956 Get the anchor mass features from a DataFrame of mass features. 2957 2958 Parameters 2959 ---------- 2960 mf_df : :obj:`~pandas.DataFrame` 2961 The mass features to filter to just the anchor mass features. 2962 2963 Returns 2964 ------- 2965 :obj:`~pandas.DataFrame` 2966 The anchor mass features dataframe. 2967 """ 2968 mf_df = mf_df.copy() 2969 2970 if ( 2971 "deconvoluted_mass_spectra" 2972 in self.parameters.lcms_collection.mass_feature_anchor_technique 2973 ): 2974 # Drop features that are not mass_spectrum_deconvoluted_parent or are NA as mass_spectrum_deconvoluted_parent 2975 mf_df = mf_df.dropna(subset=["mass_spectrum_deconvoluted_parent"]) 2976 mf_df = mf_df[mf_df["mass_spectrum_deconvoluted_parent"]] 2977 2978 if ( 2979 "absolute_intensity" 2980 in self.parameters.lcms_collection.mass_feature_anchor_technique 2981 ): 2982 # Drop features that have an intensity lower than the threshold 2983 threshold = self.parameters.lcms_collection.mass_feature_anchor_absolute_intensity_threshold 2984 mf_df = mf_df[mf_df["intensity"] > threshold] 2985 2986 if ( 2987 "relative_intensity" 2988 in self.parameters.lcms_collection.mass_feature_anchor_technique 2989 ): 2990 # Drop features in the lower fraction of intensities 2991 threshold_quantile = self.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold 2992 intensity_threshold = mf_df["intensity"].quantile(threshold_quantile) 2993 mf_df = mf_df[mf_df["intensity"] >= intensity_threshold] 2994 2995 return mf_df 2996 2997 def attempt_alignment(self, matches_c, matches_i): 2998 """ 2999 Check if alignment is needed for the LCMS objects in the collection. 3000 """ 3001 3002 # Hold out a subset of matches_c and matches_i for spline fitting 3003 matches_c.reset_index(drop=False, inplace=True) 3004 matches_i.reset_index(drop=False, inplace=True) 3005 3006 # Check if there are enough matches to attempt alignment 3007 minimum_matches = self.parameters.lcms_collection.alignment_minimum_matches 3008 if len(matches_c) < minimum_matches: 3009 # Return False (no alignment) and identity function (returns original time) 3010 # which isn't used but is a placeholder to avoid errors in downstream code since 3011 # the function expects a callable to be returned 3012 return False, lambda x: x 3013 3014 # Rearrange matches_c and matches_i to be in the order of the scan_time of matches_c 3015 matches_c = matches_c.sort_values(by="scan_time") 3016 matches_i = matches_i.iloc[matches_c.index.values] 3017 3018 hold_out_fraction = self.parameters.lcms_collection.alignment_hold_out_fraction 3019 # starting with an array of length len(matches_c), select equally spaced indices to hold out 3020 idx_holdout = matches_c.index.values[ 3021 np.arange(0, len(matches_c), int(1 / hold_out_fraction)) 3022 ] 3023 3024 matches_c_holdout = matches_c.loc[idx_holdout].copy() 3025 matches_i_holdout = matches_i.loc[idx_holdout].copy() 3026 3027 # Remove the holdout matches from the matches_c and matches_i DataFrames and reset the index 3028 matches_c = matches_c.drop(index=idx_holdout).set_index("sample_name") 3029 matches_i = matches_i.drop(index=idx_holdout).set_index("sample_name") 3030 3031 # Reset the scan_time to the original scan_time 3032 matches_i = matches_i.copy() 3033 matches_i["scan_time"] = matches_i["scan_time_og"] 3034 3035 # Fit the retention times of the LCMS object to the center LCMS object using the matched mass features 3036 spl = self.fit_rts(matches_c, matches_i, kernel="rbf", C=1000) 3037 3038 # Check if the spline fitting improved the alignment for the holdout matches 3039 matches_i_holdout["scan_time_fit"] = spl(matches_i_holdout["scan_time"]) 3040 og_diff = np.abs( 3041 matches_i_holdout["scan_time"] - matches_c_holdout["scan_time"] 3042 ) 3043 fit_diff = np.abs( 3044 matches_i_holdout["scan_time_fit"] - matches_c_holdout["scan_time"] 3045 ) 3046 3047 if ( 3048 "fraction_improved" 3049 in self.parameters.lcms_collection.alignment_acceptance_technique 3050 ): 3051 fraction_improved = np.sum(fit_diff < og_diff) / len(og_diff) 3052 use_spline_alignment = ( 3053 fraction_improved 3054 > self.parameters.lcms_collection.alignment_acceptance_fraction_improved_threshold 3055 ) 3056 if ( 3057 "mean_squared_error_improved" 3058 in self.parameters.lcms_collection.alignment_acceptance_technique 3059 ): 3060 mse_og = np.mean(og_diff**2) 3061 mse = np.mean(fit_diff**2) 3062 use_spline_alignment = mse < mse_og 3063 # Convert to boolean 3064 use_spline_alignment = bool(use_spline_alignment) 3065 3066 return use_spline_alignment, spl 3067 3068 def align_lcms_objects(self, overwrite=False): 3069 """ 3070 Align LCMS objects in the collection. 3071 3072 Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects. 3073 First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects, 3074 starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects 3075 are then fit to the center LCMS object using the matched mass features. 3076 3077 Returns 3078 ------- 3079 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. 3080 3081 Notes 3082 ----- 3083 This function has been adapted from the original implementation in the Deimos package: 3084 https://github.com/pnnl/deimos 3085 """ 3086 3087 # Prepare the center LCMS object 3088 center_obj_ids = self.manifest_dataframe[ 3089 self.manifest_dataframe["center"] 3090 ].collection_id.values 3091 3092 full_mf_df = self.mass_features_dataframe 3093 # re-index to sample_name for faster lookups 3094 full_mf_df = full_mf_df.reset_index().set_index("sample_name") 3095 samples_with_features = set(full_mf_df.index.get_level_values("sample_name")) 3096 3097 if "scan_time_aligned" in full_mf_df.columns and not overwrite: 3098 raise ValueError("Mass features have already been aligned") 3099 3100 def _set_scan_time_alignment_for_sample(sample_idx, use_alignment, spline): 3101 """Set scan_time_aligned for one sample using spline or identity mapping.""" 3102 if use_alignment and spline is not None: 3103 self[sample_idx]._scan_info["scan_time_aligned"] = { 3104 k: spline(v) for k, v in self[sample_idx]._scan_info["scan_time"].items() 3105 } 3106 return True 3107 3108 self[sample_idx]._scan_info["scan_time_aligned"] = self[sample_idx]._scan_info[ 3109 "scan_time" 3110 ].copy() 3111 return False 3112 3113 def _get_feature_df_at_or_after(start_idx, index_step, use_alignment, spline): 3114 """Return next sample index/dataframe with features, aligning empty samples on the way.""" 3115 i = start_idx 3116 while 0 <= i < len(self): 3117 sample_name = self.samples[i] 3118 if sample_name in samples_with_features: 3119 mf_df_i = full_mf_df.loc[sample_name].copy() 3120 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3121 mf_df_i = mf_df_i.reset_index(drop=False) 3122 if use_alignment and spline is not None: 3123 # Use previous step transform as a better matching starting point. 3124 mf_df_i["scan_time"] = spline(mf_df_i["scan_time"]) 3125 return i, mf_df_i 3126 3127 _set_scan_time_alignment_for_sample(i, use_alignment, spline) 3128 self.rt_alignment_attempted = True 3129 i += index_step 3130 3131 return i, None 3132 3133 anchor_mf_dfs = [] 3134 for center_obj_id in center_obj_ids: 3135 # Get the anchor mass features from the center LCMS object 3136 mf_df_c = full_mf_df.loc[self.samples[center_obj_id]] 3137 mf_df_c = self.get_anchor_mass_features(mf_df_c) 3138 anchor_mf_dfs.append(mf_df_c) 3139 3140 # Set scan_time_aligned to scan_time for the center LCMS object 3141 center_scan_df = self[center_obj_id].scan_df.copy() 3142 center_scan_df["scan_time_aligned"] = center_scan_df["scan_time"] 3143 self[center_obj_id].scan_df = center_scan_df 3144 3145 # Store alignment data for center object (identity mapping) 3146 center_sample_name = self.samples[center_obj_id] 3147 3148 index_steps = (1, -1) 3149 # Run this twice, once going forward (+1 indexing) and once going backward (-1 indexing) 3150 for index_step in index_steps: 3151 # Initialize spline for propagation to samples without features 3152 spl = None 3153 use_spline_alignment = False 3154 3155 # Loop through the other LCMS objects in this direction. 3156 i, mf_df_i = _get_feature_df_at_or_after( 3157 center_obj_id + index_step, 3158 index_step, 3159 use_spline_alignment, 3160 spl, 3161 ) 3162 3163 while mf_df_i is not None: 3164 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3165 3166 # Match the mass features in the LCMS object to the anchor mass features in the center LCMS object. 3167 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3168 3169 if matches_c is not None: 3170 use_spline_alignment, spl = self.attempt_alignment( 3171 matches_c, matches_i 3172 ) 3173 3174 # Record if we used alignment for this sample 3175 sample_name = self.samples[i] 3176 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3177 use_spline_alignment 3178 ) 3179 3180 if use_spline_alignment: 3181 # Set new retention times on scan_df for lc_obj using the spline fitting 3182 matches_i["scan_time_fit"] = spl(matches_i["scan_time"]) 3183 3184 self.rt_aligned = _set_scan_time_alignment_for_sample( 3185 i, use_spline_alignment, spl 3186 ) 3187 self.rt_alignment_attempted = True 3188 3189 i, mf_df_i = _get_feature_df_at_or_after( 3190 i + index_step, 3191 index_step, 3192 use_spline_alignment, 3193 spl, 3194 ) 3195 else: 3196 # If no matches are found, propagate prior alignment from this index step. 3197 sample_name = self.samples[i] 3198 used_previous_alignment = use_spline_alignment and spl is not None 3199 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3200 used_previous_alignment 3201 ) 3202 3203 self.rt_aligned = _set_scan_time_alignment_for_sample( 3204 i, used_previous_alignment, spl 3205 ) 3206 self.rt_alignment_attempted = True 3207 3208 i, mf_df_i = _get_feature_df_at_or_after( 3209 i + index_step, 3210 index_step, 3211 used_previous_alignment, 3212 spl, 3213 ) 3214 3215 # Now align each batch using the center objects as anchors with the other batches 3216 mf_df_c = anchor_mf_dfs[0] 3217 for i in center_obj_ids[1:]: 3218 mf_df_i = full_mf_df.loc[self.samples[i]].copy() 3219 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3220 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3221 3222 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3223 if matches_c is not None: 3224 use_spline_alignment, spl = self.attempt_alignment(matches_c, matches_i) 3225 3226 # Record if we used alignment for this sample 3227 sample_name = self.samples[i] 3228 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3229 use_spline_alignment 3230 ) 3231 3232 if use_spline_alignment: 3233 # Set new retention times on all this object's 3234 new_times = spl(self[i].scan_df["scan_time"]) 3235 new_scan_info = self[i].scan_df.copy() 3236 new_scan_info["scan_time_aligned"] = new_times 3237 self[i].scan_df = new_scan_info 3238 3239 3240 # Get the batch that this object belongs to 3241 batch = self.manifest[self.samples[i]]["batch"] 3242 3243 for j in range(len(self)): 3244 if self.manifest[self.samples[j]]["batch"] == batch: 3245 if j != i: 3246 sample_name_j = self.samples[j] 3247 self._manifest_dict[sample_name_j]["use_rt_alignment"] = ( 3248 use_spline_alignment 3249 ) 3250 new_scan_info = self[j].scan_df.copy() 3251 aligned_times = spl(self[j].scan_df["scan_time_aligned"]) 3252 new_scan_info["scan_time_aligned"] = aligned_times 3253 self[j].scan_df = new_scan_info 3254 3255 # Set final mass_features_dataframe with the aligned scan_time 3256 center_sample_name = self.samples[center_obj_ids[0]] 3257 self._manifest_dict[center_sample_name]["use_rt_alignment"] = False 3258 new_scan_info = self[center_obj_ids[0]].scan_df.copy() 3259 new_scan_info["scan_time_aligned"] = new_scan_info["scan_time"] 3260 3261 def add_consensus_mass_features(self): 3262 """ 3263 Create consensus mass features by clustering aligned features across samples. 3264 3265 This method clusters mass features from all samples in the collection based on 3266 their m/z and aligned retention time proximity. Features that cluster together 3267 across samples are assigned a common cluster ID, creating consensus features 3268 that represent the same compound detected across multiple samples. 3269 3270 The clustering process: 3271 1. Partitions features by m/z to avoid large sparse matrices and enable parallelization 3272 2. Clusters features within each partition using hierarchical clustering 3273 3. Merges partition-boundary clusters that represent the same feature 3274 4. Filters out clusters not present in minimum fraction of samples 3275 3276 Must be run after align_lcms_objects(). Results are stored in the 3277 mass_features_dataframe with a 'cluster' column added. 3278 3279 Parameters 3280 ---------- 3281 None 3282 Uses parameters from self.parameters.lcms_collection: 3283 - consensus_mz_tol_ppm: m/z tolerance for clustering (ppm) 3284 - consensus_rt_tol: retention time tolerance for clustering (minutes) 3285 - consensus_partition_size: target partition size for managing memory and parallelization 3286 - consensus_min_sample_fraction: minimum fraction of samples a cluster 3287 must appear in to be retained (0-1) 3288 - cores: number of CPU cores to use for parallel partition processing 3289 3290 Returns 3291 ------- 3292 None 3293 Updates self.mass_features_dataframe in place by adding 'cluster' column 3294 and filtering to retain only clusters meeting minimum sample presence. 3295 3296 Raises 3297 ------ 3298 ValueError 3299 If mass features have not been aligned (run align_lcms_objects() first). 3300 3301 Notes 3302 ----- 3303 - Partitioning prevents memory issues with large sparse distance matrices 3304 - Each partition is processed in parallel (up to cores limit) 3305 - Clusters not meeting consensus_min_sample_fraction are automatically removed 3306 - Access cluster_summary_dataframe property for summary statistics 3307 - Use fill_missing_cluster_features() for gap-filling after clustering 3308 3309 See Also 3310 -------- 3311 align_lcms_objects : Aligns retention times before consensus clustering 3312 cluster_summary_dataframe : Property that generates summary statistics for clusters 3313 fill_missing_cluster_features : Gap-fill missing features in clusters 3314 """ 3315 # Get the combined mass features from all LCMS objects, keep the original index as a separate column 3316 combined_mfs = self.mass_features_dataframe.copy() 3317 combined_mfs["coll_mf_id"] = combined_mfs.index 3318 3319 # Check if the mass features have been aligned 3320 if "scan_time_aligned" not in combined_mfs.columns: 3321 raise ValueError( 3322 "Mass features have not been aligned, run align_lcms_objects() first" 3323 ) 3324 3325 # Partition the mass features by mz so we can parallelize the matching before clustering 3326 from corems.chroma_peak.calc import subset as corems_subset 3327 3328 # get max mz from combined_mfs and calculate tolerance from ppm 3329 mz_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 3330 n_partition_size = self.parameters.lcms_collection.consensus_partition_size 3331 lazy_partitions = corems_subset.multi_sample_partition( 3332 combined_mfs, 3333 split_on="mz", 3334 size=n_partition_size, 3335 tol=mz_tol, 3336 relative=True, 3337 ) 3338 3339 # If any of lazy_partitions._counts is 2xn_partition_size, issue a warning 3340 if np.array(lazy_partitions._counts).max() > 2 * n_partition_size: 3341 warnings.warn( 3342 "Some partitions are larger than 2x the goal partition size. Consider increasing the partition or decreasing the mz_tol." 3343 ) 3344 3345 # Cluster the mass features within each partition 3346 if self.parameters.lcms_collection.cores > lazy_partitions.n_partitions: 3347 cores_to_use = lazy_partitions.n_partitions 3348 else: 3349 cores_to_use = self.parameters.lcms_collection.cores 3350 # mfs_with_clusters = lazy_partitions.map(self.cluster_mass_features, processes=cores_to_use) 3351 mfs_with_clusters = lazy_partitions.map( 3352 self.cluster_mass_features_agg_cluster, processes=cores_to_use 3353 ) 3354 3355 # Clean up cluster id names after partitioning 3356 new_cluster_ids = ( 3357 mfs_with_clusters[["cluster", "partition_idx"]] 3358 .drop_duplicates() 3359 .reset_index(drop=True) 3360 ) 3361 new_cluster_ids["cluster_unqiue"] = new_cluster_ids.index 3362 mfs_with_clusters = mfs_with_clusters.merge( 3363 new_cluster_ids, on=["cluster", "partition_idx"] 3364 ) 3365 mfs_with_clusters["cluster"] = mfs_with_clusters["cluster_unqiue"] 3366 mfs_with_clusters = mfs_with_clusters.drop(columns=["cluster_unqiue"]) 3367 3368 # Embed a new cluster id into the mass features dataframe and set as index 3369 mfs_with_clusters["idx"] = mfs_with_clusters.index 3370 3371 try: 3372 # Check if any clusters can be merged into a single cluster 3373 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3374 3375 # Merge clusters identified in eval_dict 3376 while len(eval_dict["merge_these_clusters"]) > 0: 3377 list_of_clusters_to_merge = [ 3378 [x[0], x[1]] for x in eval_dict["merge_these_clusters"] 3379 ] 3380 # Convert to a dataframe with columns "new_cluster" and "cluster" 3381 df = pd.DataFrame( 3382 np.array(list_of_clusters_to_merge), columns=["new_cluster", "cluster"] 3383 ) 3384 # Drop duplicates of "child" clusters 3385 df = df.drop_duplicates("cluster", keep="first") 3386 df = df.drop_duplicates("new_cluster", keep="first") 3387 mfs_with_clusters = mfs_with_clusters.merge(df, on="cluster", how="left") 3388 mfs_with_clusters["cluster"] = mfs_with_clusters["new_cluster"].fillna( 3389 mfs_with_clusters["cluster"] 3390 ) 3391 mfs_with_clusters = mfs_with_clusters.drop(columns=["new_cluster"]) 3392 3393 # Re-evaluate clusters for repeats 3394 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3395 self.mass_features_dataframe = mfs_with_clusters 3396 3397 except: 3398 mfs_with_clusters.set_index('coll_mf_id', inplace = True) 3399 self.mass_features_dataframe = mfs_with_clusters 3400 3401 # Filter out clusters that don't meet minimum sample fraction 3402 self._filter_clusters_by_sample_presence() 3403 3404 # TODO KRH: Deal with isomers better? Pool them together and then split them out using samples with 2 as the template? 3405 3406 def _filter_clusters_by_sample_presence(self): 3407 """ 3408 Filter out clusters that don't meet the minimum sample fraction threshold. 3409 3410 Removes clusters (and their associated mass features) from the mass_features_dataframe 3411 if they don't appear in at least consensus_min_sample_fraction of samples. 3412 3413 This is called automatically at the end of add_consensus_mass_features(). 3414 3415 Returns 3416 ------- 3417 None 3418 Updates self.mass_features_dataframe in place by removing clusters that don't 3419 meet the minimum sample presence threshold. 3420 """ 3421 if self.mass_features_dataframe is None or len(self.mass_features_dataframe) == 0: 3422 return 3423 3424 min_sample_fraction = self.parameters.lcms_collection.consensus_min_sample_fraction 3425 3426 # Validate parameter 3427 if not 0 <= min_sample_fraction <= 1: 3428 raise ValueError("consensus_min_sample_fraction must be between 0 and 1") 3429 3430 # Calculate minimum number of samples required 3431 total_samples = len(self.samples) 3432 min_samples_required = min_sample_fraction * total_samples 3433 3434 # Count unique samples per cluster 3435 cluster_sample_counts = ( 3436 self.mass_features_dataframe.groupby('cluster')['sample_id'] 3437 .nunique() 3438 .reset_index(name='sample_count') 3439 ) 3440 3441 # Identify clusters to keep 3442 clusters_to_keep = cluster_sample_counts[ 3443 cluster_sample_counts['sample_count'] > min_samples_required 3444 ]['cluster'].values 3445 3446 # Filter mass features dataframe 3447 self.mass_features_dataframe = self.mass_features_dataframe[ 3448 self.mass_features_dataframe['cluster'].isin(clusters_to_keep) 3449 ] 3450 3451 def summarize_clusters(self): 3452 """ 3453 Generate summary statistics for consensus mass feature clusters. 3454 3455 Computes aggregate statistics (median, mean, std, min, max) for each cluster 3456 across all samples. Combines both regular mass features and induced mass features 3457 (from gap-filling) when available to provide complete cluster statistics. 3458 3459 Must be run after add_consensus_mass_features() which creates the cluster assignments. 3460 Results are stored in cluster_summary_dataframe property and used by plotting methods. 3461 3462 Parameters 3463 ---------- 3464 None 3465 Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe. 3466 Both must contain 'cluster' column. 3467 3468 Returns 3469 ------- 3470 :obj:`~pandas.DataFrame` or None 3471 DataFrame with one row per cluster containing summary statistics: 3472 - cluster: cluster ID 3473 - mz_{median,mean,std,max,min}: m/z statistics 3474 - scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics 3475 - half_height_width_{median,mean,std,max,min}: peak width statistics 3476 - tailing_factor_{median,mean,std,max,min}: peak shape statistics 3477 - dispersity_index_{median,mean,std,max,min}: peak quality statistics 3478 - sample_id_nunique: number of unique samples containing the cluster 3479 - intensity_{max,median,mean,std,min}: intensity statistics 3480 - persistence_{max,median,mean,std,min}: persistence statistics 3481 3482 Returns None if mass_features_dataframe is empty. 3483 3484 Notes 3485 ----- 3486 - Summary DataFrame is automatically stored in cluster_summary_dataframe property 3487 - Includes both regular and induced (gap-filled) mass features when available 3488 - Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster 3489 - Sample count (sample_id_nunique) indicates cluster prevalence across samples 3490 - Filters applied by consensus_min_sample_fraction affect which clusters appear 3491 3492 See Also 3493 -------- 3494 add_consensus_mass_features : Creates clusters before summarization 3495 fill_missing_cluster_features : Creates induced mass features via gap-filling 3496 plot_consensus_mz_features : Visualizes cluster summaries 3497 plot_mz_features_per_cluster : Shows cluster size distribution 3498 """ 3499 # First check if there are minimum columns in the features dataframe 3500 if len(self.mass_features_dataframe.columns) < 1: 3501 return None 3502 3503 # Combine regular and induced mass features 3504 mf_df = self.mass_features_dataframe.copy() 3505 mf_df = mf_df.reset_index(drop=False) 3506 3507 # Check if induced mass features are available and combine them 3508 if self.induced_mass_features_dataframe is not None and len(self.induced_mass_features_dataframe) > 0: 3509 imf_df = self.induced_mass_features_dataframe.copy() 3510 imf_df = imf_df.reset_index(drop=False) 3511 # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination 3512 # Combine regular and induced features 3513 mf_df = pd.concat([mf_df, imf_df], axis=0) 3514 mf_df = mf_df.reset_index(drop=True) 3515 3516 # Filter out any rows with NaN cluster values before converting to int 3517 if 'cluster' in mf_df.columns: 3518 mf_df = mf_df.dropna(subset=['cluster']) 3519 mf_df['cluster'] = mf_df['cluster'].astype(int) 3520 3521 # Build aggregation dictionary based on available columns 3522 agg_dict = { 3523 "mz": ["median", "mean", "std", "max", "min"], 3524 "scan_time_aligned": ["median", "mean", "std", "max", "min"], 3525 "sample_id": ["nunique"], 3526 "intensity": ["max", "median", "mean", "std", "min"], 3527 } 3528 3529 # Add optional columns if they exist 3530 optional_columns = { 3531 "half_height_width": ["median", "mean", "std", "max", "min"], 3532 "tailing_factor": ["median", "mean", "std", "max", "min"], 3533 "dispersity_index": ["median", "mean", "std", "max", "min"], 3534 "persistence": ["max", "median", "mean", "std", "min"], 3535 } 3536 3537 for col, funcs in optional_columns.items(): 3538 if col in mf_df.columns: 3539 agg_dict[col] = funcs 3540 3541 summary_df = ( 3542 mf_df.groupby("cluster") 3543 .agg(agg_dict) 3544 .reset_index() 3545 ) 3546 3547 # Fix the column names 3548 summary_df.columns = [ 3549 "_".join(col).strip() 3550 for col in summary_df.columns.values 3551 if col != "cluster" 3552 ] 3553 summary_df = summary_df.rename(columns={"cluster_": "cluster"}) 3554 # Set cluster as the index for easy lookup 3555 summary_df = summary_df.set_index('cluster') 3556 return summary_df 3557 3558 def plot_mz_features_per_cluster(self, return_fig = False): 3559 """ 3560 Plot the number of mass features in a cluster against how many clusters 3561 contain that number of mass features 3562 3563 Parameters 3564 ----------- 3565 return_fig : boolean 3566 Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. 3567 3568 Returns 3569 -------- 3570 matplotlib.pyplot.Figure 3571 A figure displaying the frequency with which clusters contain the given number of m/z features 3572 3573 Raises 3574 ------ 3575 Warning 3576 If consensus features haven't been added to the object yet 3577 """ 3578 3579 if not hasattr(self, 'cluster_summary_dataframe'): 3580 raise ValueError( 3581 'cluster_summary_dataframe is not set, must run add_consensus_mass_features() first' 3582 ) 3583 else: 3584 sum_data = self.cluster_summary_dataframe 3585 fig, ax = plt.subplots() 3586 sum_data.sample_id_nunique.value_counts().sort_index().plot(ax = ax, kind = 'bar') 3587 plt.xlabel('Number of mass features in a cluster') 3588 plt.ylabel('Number of clusters with this many mass features') 3589 if return_fig: 3590 plt.close(fig) 3591 return fig 3592 else: 3593 plt.show() 3594 3595 def plot_mz_features_across_samples(self, alpha = 0.75, s = 0.005, return_fig = False): 3596 """ 3597 Generate Scan Time vs m/z plot of all the mass features across all 3598 samples in collection where intensity of color on the plot indicates 3599 density of mass features, NOT INTENSITY 3600 3601 Parameters 3602 ----------- 3603 alpha : float 3604 Desired transparency for plotted m/z features. Defaults to 0.75. 3605 s : float 3606 Desired size of plotted m/z features. Defaults to 0.005. 3607 return_fig : boolean 3608 Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. 3609 3610 Returns 3611 -------- 3612 matplotlib.pyplot.Figure 3613 A figure displaying a scan time vs m/z scatterplot of all the m/z features identified in the collection. 3614 Parameters alpha (transparency) and s (marker size) allow the user to emphasize the density of features. 3615 Intensity of features is not represented. 3616 """ 3617 df = self.mass_features_dataframe.copy() 3618 fig = plt.figure() 3619 plt.scatter( 3620 df.scan_time_aligned, 3621 df.mz, 3622 c = 'tab:gray', 3623 alpha = alpha, 3624 s = s 3625 ) 3626 3627 plt.xlabel('Scan time') 3628 plt.ylabel('m/z') 3629 plt.ylim(0, np.ceil(np.max(df.mz))) 3630 plt.xlim(0, np.ceil(np.max(df.scan_time))) 3631 plt.title('All mass features, all samples') 3632 3633 if return_fig: 3634 plt.close(fig) 3635 return fig 3636 else: 3637 plt.show() 3638 3639 def plot_consensus_mz_features(self, xb = 'xb', xt = 'xt', yb = 'yb', yt = 'yt', show_all = True, return_fig = False): 3640 """ 3641 Generate Scan Time vs m/z plot of the consensus features scaled by size 3642 with option ('show_all') of leaving the individual m/z features in the figure. 3643 3644 Parameters 3645 ----------- 3646 xb : float 3647 Desired starting scan time value for the x-axis. Defaults to 0. 3648 xt : float 3649 Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data. 3650 yb : float 3651 Desired starting m/z value for the y-axis. Defaults to 0. 3652 yt : float 3653 Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data. 3654 show_all : boolean 3655 Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True. 3656 return_fig : boolean 3657 Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. 3658 3659 Returns 3660 -------- 3661 matplotlib.pyplot.Figure 3662 A scalable figure that overlays the consensus features over all the m/z features identified in the collection. 3663 Consensus features are scaled by how many m/z features are represented in the consensus. Figure can be scaled by 3664 inputting desired boundaries on the scan time (xb, xt) and m/z values (yb, yt). 3665 """ 3666 df = self.cluster_summary_dataframe.copy() 3667 mfdf = self.mass_features_dataframe.copy() 3668 3669 fig = plt.figure() 3670 if show_all: 3671 plt.scatter( 3672 mfdf.scan_time_aligned, 3673 mfdf.mz, 3674 c = 'tab:gray', 3675 s = 1 3676 ) 3677 3678 m = plt.scatter( 3679 df.scan_time_aligned_median, 3680 df.mz_median, 3681 c = 'tab:orange', 3682 alpha = 0.7, 3683 s = (df.sample_id_nunique**2)/5 3684 ) 3685 3686 plt.xlabel('Scan time') 3687 plt.ylabel('m/z') 3688 3689 if xt == 'xt': 3690 xt = np.ceil(np.max(mfdf.mz)) 3691 if yt == 'yt': 3692 yt = np.ceil(np.max(mfdf.scan_time)) 3693 if xb == 'xb': 3694 xb = 0 3695 if yb == 'yb': 3696 yb = 0 3697 plt.ylim(xb, xt) 3698 plt.xlim(yb, yt) 3699 3700 kw = dict( 3701 prop = 'sizes', 3702 num = max(1, int(len(df.sample_id_nunique.unique())/3)), 3703 color = 'tab:orange', 3704 alpha = 0.7, 3705 func = lambda s: np.sqrt(s*5) 3706 ) 3707 3708 plt.legend( 3709 *m.legend_elements(**kw), 3710 title = 'Features\nper cluster', 3711 bbox_to_anchor = (1.01, 0.4, 0.225, 0.5) 3712 ) 3713 plt.tight_layout() 3714 plt.title('Consensus Features') 3715 3716 if return_fig: 3717 plt.close(fig) 3718 return fig 3719 else: 3720 plt.show() 3721 3722 def plot_cluster( 3723 self, 3724 cluster_id, 3725 to_plot=["EIC", "MS1", "MS2"], 3726 return_fig=False, 3727 plot_smoothed_eic=False, 3728 plot_eic_datapoints=False, 3729 eic_buffer_time=None, 3730 label_samples=False, 3731 molecular_metadata=None, 3732 spectral_library=None, 3733 ): 3734 """ 3735 Plot a consensus mass feature cluster across all samples. 3736 3737 Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster, 3738 highlighting the representative mass feature. 3739 3740 Parameters 3741 ---------- 3742 cluster_id : int 3743 The cluster ID to plot 3744 to_plot : list, optional 3745 List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror". 3746 Default is ["EIC", "MS1", "MS2"]. 3747 return_fig : bool, optional 3748 If True, returns the figure object. Default is False. 3749 plot_smoothed_eic : bool, optional 3750 If True, plots smoothed EICs. Default is False. 3751 plot_eic_datapoints : bool, optional 3752 If True, plots EIC data points. Default is False. 3753 eic_buffer_time : float, optional 3754 Time buffer around the peak for EIC plotting (minutes). 3755 If None, uses parameter setting. Default is None. 3756 label_samples : bool, optional 3757 If True, labels each sample in the legend. Default is False. 3758 molecular_metadata : dict, optional 3759 Dictionary mapping molecular IDs to MetaboliteMetadata objects. 3760 Required for MS2_mirror plots. Default is None. 3761 spectral_library : FlashEntropySearch, optional 3762 FlashEntropy spectral library containing MS2 spectra. 3763 Required for MS2_mirror plots to retrieve library spectra. Default is None. 3764 3765 Returns 3766 ------- 3767 matplotlib.figure.Figure or None 3768 The figure object if return_fig=True, otherwise None 3769 3770 Raises 3771 ------ 3772 ValueError 3773 If cluster_id is not found or if required data is not loaded 3774 """ 3775 import matplotlib.pyplot as plt 3776 3777 # Get cluster summary for median values 3778 if cluster_id not in self.cluster_summary_dataframe.index: 3779 raise ValueError( 3780 f"Cluster {cluster_id} not found in cluster_summary_dataframe. " 3781 f"Run add_consensus_mass_features() first." 3782 ) 3783 3784 cluster_summary = self.cluster_summary_dataframe.loc[cluster_id] 3785 3786 # Get representative mass feature info 3787 rep_info = self.get_most_representative_sample_for_cluster(cluster_id) 3788 rep_sample_id = rep_info['sample_id'] 3789 rep_mf_id = rep_info['mf_id'] 3790 rep_sample = self[rep_sample_id] 3791 3792 # Check if representative mass feature is loaded 3793 if rep_mf_id not in rep_sample.mass_features: 3794 raise ValueError( 3795 f"Representative mass feature {rep_mf_id} not loaded in sample {rep_sample.sample_name}. " 3796 f"Run reload_representative_mass_features() or process_consensus_features() first." 3797 ) 3798 3799 rep_mf = rep_sample.mass_features[rep_mf_id] 3800 3801 # Get eic buffer time 3802 if eic_buffer_time is None: 3803 eic_buffer_time = self[0].parameters.lc_ms.eic_buffer_time 3804 3805 # Adjust to_plot based on available data 3806 if rep_mf.mass_spectrum is None: 3807 to_plot = [x for x in to_plot if x != "MS1"] 3808 if len(rep_mf.ms2_mass_spectra) == 0: 3809 to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]] 3810 3811 # Check if EICs are available 3812 cluster_mfs = self.mass_features_dataframe[ 3813 self.mass_features_dataframe['cluster'] == cluster_id 3814 ] 3815 3816 has_eics = False 3817 # Check regular features 3818 for _, row in cluster_mfs.iterrows(): 3819 sample_id = int(row['sample_id']) 3820 sample = self[sample_id] 3821 if hasattr(sample, 'eics') and sample.eics: 3822 if len(sample.eics) > 0: 3823 has_eics = True 3824 break 3825 3826 # Also check induced features if available 3827 induced_cluster_mfs = None 3828 if not has_eics and self.induced_mass_features_dataframe is not None: 3829 induced_cluster_mfs = self.induced_mass_features_dataframe[ 3830 self.induced_mass_features_dataframe['cluster'] == cluster_id 3831 ] 3832 for _, row in induced_cluster_mfs.iterrows(): 3833 sample_id = int(row['sample_id']) 3834 sample = self[sample_id] 3835 if hasattr(sample, 'eics') and sample.eics: 3836 if len(sample.eics) > 0: 3837 has_eics = True 3838 break 3839 3840 if not has_eics: 3841 to_plot = [x for x in to_plot if x != "EIC"] 3842 if len(to_plot) == 0: 3843 raise ValueError( 3844 f"No plottable data available for cluster {cluster_id}. " 3845 f"Run process_consensus_features(gather_eics=True, add_ms1=True, add_ms2=True) first." 3846 ) 3847 3848 # Get induced features if not already retrieved 3849 if induced_cluster_mfs is None and self.induced_mass_features_dataframe is not None: 3850 induced_cluster_mfs = self.induced_mass_features_dataframe[ 3851 self.induced_mass_features_dataframe['cluster'] == cluster_id 3852 ] 3853 3854 # Check if MS1 is deconvoluted 3855 deconvoluted = rep_mf._ms_deconvoluted_idx is not None 3856 3857 # Create figure 3858 fig, axs = plt.subplots( 3859 len(to_plot), 1, figsize=(10, len(to_plot) * 4), squeeze=False 3860 ) 3861 3862 fig.suptitle( 3863 f"Consensus Cluster {cluster_id}: " 3864 f"m/z = {cluster_summary['mz_median']:.4f} " 3865 f"(±{cluster_summary['mz_std']:.4f}); " 3866 f"RT = {cluster_summary['scan_time_aligned_median']:.2f} min " 3867 f"(±{cluster_summary['scan_time_aligned_std']:.2f}); " 3868 f"{int(cluster_summary['sample_id_nunique'])} samples" 3869 ) 3870 3871 i = 0 3872 3873 # EIC plot - show all samples using helper method 3874 if "EIC" in to_plot: 3875 self._plot_multiple_eics( 3876 axs[i][0], 3877 cluster_mfs, 3878 induced_cluster_mfs, 3879 rep_sample_id, 3880 rep_mf_id, 3881 cluster_summary['scan_time_aligned_median'], 3882 eic_buffer_time, 3883 plot_smoothed=plot_smoothed_eic, 3884 plot_datapoints=plot_eic_datapoints, 3885 label_samples=label_samples, 3886 lcms_collection=self 3887 ) 3888 i += 1 3889 3890 # MS1 plot - from representative using helper method 3891 if "MS1" in to_plot: 3892 rep_mf._plot_ms1_spectrum( 3893 axs[i][0], 3894 deconvoluted=deconvoluted, 3895 sample_name=rep_sample.sample_name 3896 ) 3897 i += 1 3898 3899 # MS2 plot - from representative using helper method 3900 if "MS2" in to_plot: 3901 rep_mf._plot_ms2_spectrum(axs[i][0], sample_name=rep_sample.sample_name) 3902 i += 1 3903 3904 # MS2 mirror plot - from representative using helper method 3905 if "MS2_mirror" in to_plot: 3906 rep_mf._plot_ms2_mirror(axs[i][0], molecular_metadata=molecular_metadata, spectral_library=spectral_library) 3907 i += 1 3908 3909 plt.tight_layout() 3910 3911 if return_fig: 3912 plt.close(fig) 3913 return fig 3914 else: 3915 plt.show() 3916 return None 3917 3918 def get_representative_mass_features_for_all_clusters(self, representative_metric=None): 3919 """ 3920 Get the most representative mass feature for all clusters in bulk. 3921 3922 This is much more efficient than calling get_most_representative_sample_for_cluster 3923 in a loop, as it processes all clusters in a single pass over the dataframe. 3924 3925 Parameters 3926 ---------- 3927 representative_metric : str, optional 3928 The metric to use to determine the most representative sample. 3929 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 3930 Options: 3931 - 'intensity': Selects the mass feature with the highest intensity 3932 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 3933 or the highest intensity overall if none have MS2 3934 Default is None (uses parameter setting). 3935 3936 Returns 3937 ------- 3938 :obj:`~pandas.DataFrame` 3939 DataFrame with one row per cluster containing: 3940 - cluster: cluster ID 3941 - sample_id: The sample ID of the most representative sample 3942 - mf_id: The mass feature ID in the sample 3943 - coll_mf_id: The collection-level mass feature ID (index) 3944 - has_ms2: Whether this mass feature has MS2 scan numbers 3945 - intensity: The intensity value of the representative mass feature 3946 """ 3947 # Use default from parameters if not specified 3948 if representative_metric is None: 3949 representative_metric = self.parameters.lcms_collection.consensus_representative_metric 3950 3951 mf_df = self.mass_features_dataframe.copy() 3952 # Reset index to make coll_mf_id a column we can work with 3953 mf_df = mf_df.reset_index(drop=False) 3954 3955 # Handle special metric 'intensity_prefer_ms2' 3956 if representative_metric == 'intensity_prefer_ms2': 3957 if 'intensity' not in mf_df.columns: 3958 raise ValueError( 3959 f"'intensity' column not found in mass_features_dataframe. " 3960 f"Available columns: {mf_df.columns.tolist()}" 3961 ) 3962 3963 # Add has_ms2 flag if ms2_scan_numbers column exists 3964 if 'ms2_scan_numbers' in mf_df.columns: 3965 def has_ms2_scans(val): 3966 if val is None: 3967 return False 3968 try: 3969 return len(val) > 0 3970 except (TypeError, ValueError): 3971 return False 3972 3973 mf_df['has_ms2'] = mf_df['ms2_scan_numbers'].apply(has_ms2_scans) 3974 3975 # Sort by has_ms2 (descending) then intensity (descending) 3976 # This ensures features with MS2 are preferred when intensities are equal 3977 mf_df = mf_df.sort_values(['has_ms2', 'intensity'], ascending=[False, False]) 3978 else: 3979 mf_df['has_ms2'] = False 3980 mf_df = mf_df.sort_values('intensity', ascending=False) 3981 3982 # Group by cluster and take the first (highest intensity, preferring MS2) 3983 representatives = mf_df.groupby('cluster').first().reset_index() 3984 3985 else: 3986 # Standard metric - check if it exists 3987 if representative_metric not in mf_df.columns: 3988 raise ValueError( 3989 f"Metric '{representative_metric}' not found. Available columns: {mf_df.columns.tolist()}" 3990 ) 3991 3992 # Add has_ms2 flag for consistency 3993 if 'ms2_scan_numbers' in mf_df.columns: 3994 def has_ms2_scans(val): 3995 if val is None: 3996 return False 3997 try: 3998 return len(val) > 0 3999 except (TypeError, ValueError): 4000 return False 4001 mf_df['has_ms2'] = mf_df['ms2_scan_numbers'].apply(has_ms2_scans) 4002 else: 4003 mf_df['has_ms2'] = False 4004 4005 # Get the index of max value for each cluster 4006 idx = mf_df.groupby('cluster')[representative_metric].idxmax() 4007 representatives = mf_df.loc[idx].copy() 4008 4009 # Select only the columns we need 4010 result_cols = ['cluster', 'sample_id', 'mf_id', 'coll_mf_id', 'has_ms2', 'intensity'] 4011 representatives = representatives[result_cols] 4012 4013 return representatives 4014 4015 def get_sample_mf_map_for_representatives(self, representative_metric=None, include_cluster_id=True): 4016 """ 4017 Build a mapping of sample_id -> list of representative mass feature IDs to load. 4018 4019 This is a DRY helper method used by both process_consensus_features() and 4020 ReadSavedLCMSCollection to determine which mass features should be loaded 4021 for each sample when loading representatives. 4022 4023 Parameters 4024 ---------- 4025 representative_metric : str, optional 4026 The metric to use to determine the most representative sample. 4027 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4028 Default is None. 4029 include_cluster_id : bool, optional 4030 If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id. 4031 Default is True. 4032 4033 Returns 4034 ------- 4035 dict 4036 Dictionary mapping sample_id (int) to list of mass feature identifiers. 4037 If include_cluster_id=True: list of tuples (mf_id, cluster_id) 4038 If include_cluster_id=False: list of mf_id integers 4039 4040 Examples 4041 -------- 4042 >>> # Get map with cluster IDs for loading 4043 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives() 4044 >>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...} 4045 >>> 4046 >>> # Get map without cluster IDs for pipeline 4047 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False) 4048 >>> # sample_mf_map = {0: [123, 456], 1: [789], ...} 4049 """ 4050 # Get all representative mass features in bulk (much faster than looping) 4051 representatives = self.get_representative_mass_features_for_all_clusters( 4052 representative_metric=representative_metric 4053 ) 4054 4055 # Build sample_mf_map 4056 sample_mf_map = {} 4057 for _, row in representatives.iterrows(): 4058 sample_id = row['sample_id'] 4059 mf_id = row['mf_id'] 4060 cluster_id = row['cluster'] 4061 4062 if sample_id not in sample_mf_map: 4063 sample_mf_map[sample_id] = [] 4064 4065 if include_cluster_id: 4066 sample_mf_map[sample_id].append((mf_id, cluster_id)) 4067 else: 4068 sample_mf_map[sample_id].append(mf_id) 4069 4070 return sample_mf_map 4071 4072 def get_most_representative_sample_for_cluster(self, cluster_id, representative_metric=None): 4073 """ 4074 Get the most representative sample for a given cluster based on a metric. 4075 4076 Parameters 4077 ---------- 4078 cluster_id : int 4079 The cluster ID to find the representative sample for. 4080 representative_metric : str, optional 4081 The metric to use to determine the most representative sample. 4082 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4083 Options: 4084 - 'intensity': Selects the mass feature with the highest intensity 4085 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 4086 or the highest intensity overall if none have MS2 4087 Default is None (uses parameter setting). 4088 4089 Returns 4090 ------- 4091 dict 4092 Dictionary containing: 4093 - 'sample_id': The sample ID of the most representative sample 4094 - 'sample_name': The sample name of the most representative sample 4095 - 'mf_id': The mass feature ID in the sample 4096 - 'coll_mf_id': The collection-level mass feature ID (index) 4097 - 'has_ms2': Whether this mass feature has MS2 scan numbers 4098 - 'intensity': The intensity value of the representative mass feature 4099 4100 Raises 4101 ------ 4102 ValueError 4103 If cluster_id is not found or if representative_metric is not a valid column. 4104 """ 4105 # Use the bulk method to get all representatives, then filter to this cluster 4106 # This follows DRY principle and ensures consistency 4107 all_representatives = self.get_representative_mass_features_for_all_clusters( 4108 representative_metric=representative_metric 4109 ) 4110 4111 # Filter to the requested cluster 4112 cluster_rep = all_representatives[all_representatives['cluster'] == cluster_id] 4113 4114 if len(cluster_rep) == 0: 4115 # Try to provide helpful error message 4116 available_clusters = self.mass_features_dataframe['cluster'].unique() 4117 raise ValueError( 4118 f"Cluster {cluster_id} not found in mass_features_dataframe. " 4119 f"Available clusters: {sorted(available_clusters[:10].tolist())}... " 4120 f"(showing first 10 of {len(available_clusters)} total clusters)" 4121 ) 4122 4123 # Get the representative row (should only be one) 4124 rep_row = cluster_rep.iloc[0] 4125 4126 # Get sample name from sample_id (convert to int for list indexing) 4127 sample_id = int(rep_row['sample_id']) 4128 sample_name = self.samples[sample_id] 4129 4130 return { 4131 'sample_id': sample_id, 4132 'sample_name': sample_name, 4133 'mf_id': rep_row['mf_id'], 4134 'coll_mf_id': rep_row['coll_mf_id'], 4135 'has_ms2': rep_row['has_ms2'], 4136 'intensity': rep_row['intensity'] 4137 } 4138 4139 def reload_representative_mass_features(self, add_ms2=False, auto_process_ms2=True, ms2_spectrum_mode=None, ms2_scan_filter=None): 4140 """ 4141 Reload mass features for all representative samples in the cluster summary. 4142 4143 This method is useful when the collection was loaded with load_light=True, 4144 which stores mass features only in the collection dataframe. This reloads 4145 the specific mass features that are representatives for each cluster, 4146 allowing them to be accessed as LCMSMassFeature objects. 4147 4148 Parameters 4149 ---------- 4150 add_ms2 : bool, optional 4151 If True, also loads and associates MS2 spectra with mass features. Default is False. 4152 auto_process_ms2 : bool, optional 4153 If True and add_ms2=True, auto-processes MS2 spectra. Default is True. 4154 ms2_spectrum_mode : str or None, optional 4155 Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. 4156 ms2_scan_filter : str or None, optional 4157 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 4158 4159 Returns 4160 ------- 4161 dict 4162 Dictionary mapping sample_id to list of reloaded mf_ids. 4163 4164 Raises 4165 ------ 4166 ValueError 4167 If cluster_summary_dataframe is not set (run add_consensus_mass_features first). 4168 4169 Notes 4170 ----- 4171 - Only reloads mass features that are cluster representatives 4172 - Uses get_most_representative_sample_for_cluster() to determine which to reload 4173 - More memory-efficient than reloading all mass features 4174 - Parallelized based on lcms_collection.cores parameter 4175 - MS2 association uses same logic as add_associated_ms2_dda() 4176 4177 See Also 4178 -------- 4179 _reload_sample_mass_features : Low-level method to reload specific mass features 4180 get_most_representative_sample_for_cluster : Gets representative sample for cluster 4181 """ 4182 # Validate prerequisites 4183 if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: 4184 raise ValueError( 4185 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 4186 ) 4187 4188 # Get all representative mass features in bulk (much faster than looping) 4189 representatives = self.get_representative_mass_features_for_all_clusters() 4190 4191 # Build a dictionary of sample_id -> list of mf_ids that are representatives 4192 sample_mf_map = {} 4193 for _, row in representatives.iterrows(): 4194 sample_id = row['sample_id'] 4195 mf_id = row['mf_id'] 4196 4197 if sample_id not in sample_mf_map: 4198 sample_mf_map[sample_id] = [] 4199 sample_mf_map[sample_id].append(mf_id) 4200 4201 # Reload mass features for each sample (parallelized) 4202 if self.parameters.lcms_collection.cores == 1: 4203 # Serial processing 4204 from tqdm import tqdm 4205 for sample_id in tqdm(sample_mf_map.keys(), desc="Reloading representative mass features", unit="sample"): 4206 mf_ids = sample_mf_map[sample_id] 4207 self._reload_sample_mass_features(sample_id, mf_ids_to_load=mf_ids, add_ms2=add_ms2, 4208 auto_process_ms2=auto_process_ms2, ms2_spectrum_mode=ms2_spectrum_mode, 4209 ms2_scan_filter=ms2_scan_filter) 4210 else: 4211 # Parallel processing 4212 import multiprocessing 4213 from tqdm import tqdm 4214 4215 if self.parameters.lcms_collection.cores > len(sample_mf_map): 4216 ncores = len(sample_mf_map) 4217 else: 4218 ncores = self.parameters.lcms_collection.cores 4219 4220 pool = multiprocessing.Pool(ncores) 4221 4222 # Build arguments list for starmap 4223 args_list = [ 4224 (sample_id, sample_mf_map[sample_id], add_ms2, auto_process_ms2, 4225 ms2_spectrum_mode, ms2_scan_filter, False) 4226 for sample_id in sample_mf_map.keys() 4227 ] 4228 4229 # Execute in parallel 4230 mp_result = pool.starmap(self._reload_sample_mass_features, args_list) 4231 pool.close() 4232 pool.join() 4233 4234 # Collect results back into samples 4235 for i, sample_id in enumerate(tqdm(sample_mf_map.keys(), desc="Collecting reloaded mass features", unit="sample")): 4236 self[sample_id].mass_features = mp_result[i] 4237 4238 return sample_mf_map 4239 4240 def _associate_ms2_with_mass_features(self, sample, local_mf_ids, auto_process=True, 4241 spectrum_mode=None, scan_filter=None): 4242 """ 4243 Associate MS2 spectra with specific mass features in a sample. 4244 4245 Uses the LCMSBase helper method to find and load MS2 scans for the specified mass features. 4246 4247 Parameters 4248 ---------- 4249 sample : LCMSBase 4250 The sample object containing mass features and scan data. 4251 local_mf_ids : list of int 4252 List of local (sample-level) mass feature IDs to find MS2 for. 4253 auto_process : bool, optional 4254 If True, auto-processes the MS2 spectra. Default is True. 4255 spectrum_mode : str or None, optional 4256 Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. 4257 scan_filter : str or None, optional 4258 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 4259 4260 Returns 4261 ------- 4262 dict 4263 Dictionary of scan_number -> MassSpectrum objects for the loaded MS2 spectra. 4264 """ 4265 # Check if we have scan data 4266 if not hasattr(sample, 'scan_df') or sample.scan_df is None: 4267 return {} 4268 4269 # Separate mass features into those that need scan finding vs those that already have scans 4270 mfs_needing_scan_finding = [] 4271 unique_dda_scans = set() 4272 4273 for mf_id in local_mf_ids: 4274 if mf_id not in sample.mass_features: 4275 continue 4276 mf = sample.mass_features[mf_id] 4277 # If this mass feature already has MS2 scans, add them to our set 4278 if mf.ms2_scan_numbers is not None and len(mf.ms2_scan_numbers) > 0: 4279 # Convert to integers in case they come from HDF5 as numpy types 4280 unique_dda_scans.update([int(scan) for scan in mf.ms2_scan_numbers]) 4281 else: 4282 # Otherwise, we need to find scans for this mass feature 4283 mfs_needing_scan_finding.append(mf_id) 4284 4285 # Only run the scan finding for mass features that need it 4286 if mfs_needing_scan_finding: 4287 found_scans = sample._find_ms2_scans_for_mass_features( 4288 mf_ids=mfs_needing_scan_finding, 4289 scan_filter=scan_filter 4290 ) 4291 unique_dda_scans.update(found_scans) 4292 4293 if len(unique_dda_scans) == 0: 4294 return {} 4295 4296 # Get ms2 parameters from sample 4297 #TODO KRH: deal with different ms2 scan types here (CID vs HCD), may need to add scan translator to the initializeion 4298 ms_params = sample.parameters.mass_spectrum['ms2'] 4299 4300 # Load MS2 spectra (convert set to list) 4301 sample.add_mass_spectra( 4302 scan_list=list(unique_dda_scans), 4303 auto_process=auto_process, 4304 spectrum_mode=spectrum_mode, 4305 ms_level=2, 4306 use_parser=True, 4307 ms_params=ms_params, 4308 ) 4309 4310 # Associate MS2 spectra with mass features 4311 for mf_id in local_mf_ids: 4312 if mf_id not in sample.mass_features: 4313 continue 4314 if sample.mass_features[mf_id].ms2_scan_numbers is not None and len(sample.mass_features[mf_id].ms2_scan_numbers) > 0: 4315 for dda_scan in sample.mass_features[mf_id].ms2_scan_numbers: 4316 if dda_scan in sample._ms: 4317 sample.mass_features[mf_id].ms2_mass_spectra[dda_scan] = sample._ms[dda_scan] 4318 4319 # Return only the MS2 spectra we loaded (for parallel processing) 4320 return {scan: sample._ms[scan] for scan in unique_dda_scans if scan in sample._ms} 4321 4322 def _reload_sample_mass_features(self, sample_id, mf_ids_to_load=None, add_ms2=False, 4323 auto_process_ms2=True, ms2_spectrum_mode=None, ms2_scan_filter=None, 4324 inplace=True): 4325 """ 4326 Reload specific mass features for a sample from HDF5. 4327 4328 This is useful when the collection was loaded with load_light=True, 4329 which stores mass features only in the collection dataframe and not 4330 as LCMSMassFeature objects in individual samples. 4331 4332 Parameters 4333 ---------- 4334 sample_id : int 4335 The sample ID to reload mass features for. 4336 mf_ids_to_load : list of str, optional 4337 List of collection-level mf_ids (format: '{sample_id}_{local_mf_id}') to load. 4338 If None, loads all mass features for the sample. 4339 add_ms2 : bool, optional 4340 If True, also loads and associates MS2 spectra. Default is False. 4341 auto_process_ms2 : bool, optional 4342 If True, auto-processes MS2 spectra. Default is True. 4343 ms2_spectrum_mode : str or None, optional 4344 Spectrum mode for MS2 spectra. Default is None. 4345 ms2_scan_filter : str or None, optional 4346 Filter string for MS2 scans. Default is None. 4347 inplace : bool, optional 4348 If True, updates the sample's mass_features in place. If False, returns the 4349 mass_features dictionary (for multiprocessing). Default is True. 4350 4351 Returns 4352 ------- 4353 dict or None 4354 If inplace=False, returns dictionary of mass features. 4355 Otherwise returns None and updates object in place. 4356 """ 4357 sample = self[sample_id] 4358 sample_name = self.samples[sample_id] 4359 4360 # Check if we have a collection parser that can reload 4361 if not hasattr(self, 'collection_parser') or self.collection_parser is None: 4362 print("Warning: Cannot reload mass features - no collection_parser available") 4363 if not inplace: 4364 return {} 4365 return 4366 4367 # Get the HDF5 file for this sample 4368 hdf5_file = self.collection_parser.folder_location / f"{sample_name}.corems/{sample_name}.hdf5" 4369 4370 if not hdf5_file.exists(): 4371 print(f"Warning: HDF5 file not found for sample {sample_name}: {hdf5_file}") 4372 if not inplace: 4373 return {} 4374 return 4375 4376 # Import here to avoid circular imports 4377 from corems.mass_spectra.input.corems_hdf5 import ReadCoreMSHDFMassSpectra 4378 4379 # If specific mf_ids requested, use them directly 4380 local_mf_ids_to_load = None 4381 if mf_ids_to_load is not None: 4382 # mf_ids_to_load is already a list of sample-level mf_ids (integers) 4383 # No parsing needed - they come from the mf_id column in the dataframe 4384 local_mf_ids_to_load = set(mf_ids_to_load) 4385 4386 # Reload mass features from HDF5 4387 with ReadCoreMSHDFMassSpectra(hdf5_file) as parser: 4388 # Load mass features - if specific IDs requested, only load those 4389 parser.import_mass_features(sample, mf_ids=local_mf_ids_to_load) 4390 4391 # If add_ms2, associate MS2 spectra with the loaded mass features 4392 if add_ms2 and local_mf_ids_to_load is not None: 4393 self._associate_ms2_with_mass_features( 4394 sample, 4395 list(local_mf_ids_to_load), 4396 auto_process=auto_process_ms2, 4397 spectrum_mode=ms2_spectrum_mode, 4398 scan_filter=ms2_scan_filter 4399 ) 4400 4401 # Return mass features if not inplace (for multiprocessing) 4402 if not inplace: 4403 return sample.mass_features 4404 4405 def add_sparse_distance_matrix(self, features): 4406 if features is None: 4407 return None 4408 else: 4409 features = features.copy() 4410 4411 # Parameters for calculating distance between features 4412 dims = ["mz", "scan_time_aligned"] 4413 relative = [True, False] 4414 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4415 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 4416 dist_weight = [1, 1] 4417 4418 # Check that the dimensions and tolerances are the same length 4419 if ( 4420 len(dims) != len(tol) 4421 or len(dims) != len(relative) 4422 or len(dims) != len(dist_weight) 4423 ): 4424 raise ValueError( 4425 "The dimensions, tolerances, relative, dist_weight, and na_allow lists must be the same length" 4426 ) 4427 4428 # Make connectivity matrix for masking within sample mass features 4429 ## Masking matrix cmat will mark all features from the same sample as 0 4430 ## To mask, a matrix can be multiplied by cmat and features from same 4431 ## samples are multiplied by 0, while features from different samples 4432 ## are multiplied by 1 4433 4434 if "sample_id" not in features.columns: 4435 cmat = None 4436 else: 4437 vals = features["sample_id"].values.reshape(-1, 1) 4438 cmat = scipy.spatial.distance.cdist(vals, vals) 4439 # Convert to binary (0 if same sample, 1 if different) 4440 cmat = np.where(cmat == 0, 0, 1) 4441 # Convert to coordinate matrix for sparse operations later 4442 cmat = sparse.coo_matrix(cmat) 4443 4444 # Compute inter-feature distances using sparse matrix approach 4445 distances = None # clear the distances object before starting 4446 for i in range(len(dims)): # iterate through all dimensions to be considered 4447 # Construct k-d tree 4448 values = features[dims[i]].values 4449 4450 tree = KDTree(values.reshape(-1, 1)) 4451 4452 max_tol = tol[i] 4453 if relative[i] is True: 4454 # Maximum absolute tolerance 4455 max_tol = tol[i] * values.max() 4456 4457 # Compute sparse distance matrix 4458 # the larger the max_tol, the slower this operation is 4459 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 4460 4461 # Only consider forward case, exclude diagonal 4462 sdm = sparse.triu(sdm, k=1) 4463 4464 # Filter relative distances 4465 if relative[i] is True: 4466 # Compute relative distances 4467 rel_dists = sdm.data / values[sdm.row] 4468 4469 # Indices of relative distances less than tolerance 4470 idx = rel_dists <= tol[i] 4471 4472 # Reconstruct sparse distance matrix 4473 sdm = sparse.coo_matrix( 4474 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 4475 shape=(len(values), len(values)), 4476 ) 4477 4478 # Scaled distances wrt the maximum tolerance for the dimension 4479 sdm.data = sdm.data / tol[i] 4480 4481 # Stack distances for dimensions where na_allow is False 4482 if distances is None: 4483 sdm.data = sdm.data * dist_weight[i] 4484 # Replace zeros with epsilon to handle perfect matches 4485 sdm.data[sdm.data == 0] = 1e-10 4486 distances = sdm 4487 else: 4488 # Prepare sdm to match shape of existing distances 4489 distances_truth = distances.copy() 4490 # make new sparse matrix with same positions as previous 4491 # distance matrix but all ones for values 4492 distances_truth.data = np.ones_like(distances_truth.data) 4493 4494 # Replace zeros with epsilon BEFORE multiply to prevent sparse matrix from dropping them 4495 sdm.data[sdm.data == 0] = 1e-10 4496 4497 # multiply the new sparse matrix (sdm) by this mask to remove 4498 # data that doesn't exist in original sparse matrix 4499 sdm = distances_truth.multiply(sdm) 4500 4501 sdm.data = sdm.data * dist_weight[i] 4502 # Replace zeros with epsilon to handle perfect matches 4503 sdm.data[sdm.data == 0] = 1e-10 4504 4505 # use same process as before to remove data from previous 4506 # distances matrix that isn't in new distances matrix 4507 sdm_truth = sdm.copy() 4508 sdm_truth.data = np.ones_like(sdm_truth.data) 4509 4510 # remove the distances that are not sdm 4511 distances = distances.multiply(sdm_truth) 4512 4513 # Sum the new distances 4514 distances = distances + sdm 4515 4516 # Multiply by connectivity matrix for more masking 4517 distances = distances.multiply(cmat) 4518 4519 # Set attribute holding distance matrix 4520 self._sparse_distance_matrix = distances 4521 4522 def evaluate_clusters_for_repeats(self, features): 4523 raise NotImplementedError('evaluate_clusters_for_repeats not implemented yet') 4524 summary_df = self.cluster_summary_dataframe.copy() 4525 4526 # Arrange by decreasing median intensity 4527 summary_df = summary_df.sort_values( 4528 by="intensity_median", ascending=False 4529 ).reset_index(drop=True) 4530 4531 # Find clusters that are within the mz_tol and rt_tol of each other (on the medians) 4532 # Create a distance matrix 4533 # Define how to calculate the distance between features 4534 dims = ["mz_median", "scan_time_aligned_median"] 4535 relative = [True, False] 4536 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4537 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 4538 4539 # Compute inter-feature distances 4540 distances = None 4541 for i in range(len(dims)): 4542 # Construct k-d tree 4543 values = summary_df[dims[i]].values 4544 tree = KDTree(values.reshape(-1, 1)) 4545 4546 max_tol = tol[i] 4547 if relative[i] is True: 4548 # Maximum absolute tolerance 4549 max_tol = tol[i] * values.max() 4550 4551 # Compute sparse distance matrix 4552 # the larger the max_tol, the slower this operation is 4553 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 4554 4555 # Only consider forward case, exclude diagonal 4556 sdm = sparse.triu(sdm, k=1) 4557 4558 # Filter relative distances 4559 if relative[i] is True: 4560 # Compute relative distances 4561 rel_dists = sdm.data / values[sdm.row] # or col? 4562 4563 # Indices of relative distances less than tolerance 4564 idx = rel_dists <= tol[i] 4565 4566 # Reconstruct sparse distance matrix 4567 sdm = sparse.coo_matrix( 4568 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 4569 shape=(len(values), len(values)), 4570 ) 4571 4572 # Cast as binary matrix 4573 sdm.data = np.ones_like(sdm.data) 4574 4575 # Stack distances 4576 if distances is None: 4577 distances = sdm 4578 else: 4579 distances = distances.multiply(sdm) 4580 4581 # Roll up features 4582 # Extract indices of within-tolerance points 4583 distances = distances.tocoo() 4584 pairs = np.stack( 4585 (distances.row, distances.col), axis=1 4586 ) # These are the index values of the clusters, not the cluster ids 4587 # Conver to cluster ids 4588 pairs_df = pd.DataFrame(pairs, columns=["parent", "child"]) 4589 pairs_df["parent"] = summary_df.loc[pairs[:, 0]]["cluster"].values 4590 pairs_df["child"] = summary_df.loc[pairs[:, 1]]["cluster"].values 4591 pairs_df = pairs_df.set_index("parent") 4592 4593 merge_these_clusters = [] 4594 possible_overlaps = [] 4595 root_parents = np.setdiff1d( 4596 np.unique(pairs_df.index.values), np.unique(pairs_df.child.values) 4597 ) 4598 for parent in root_parents: 4599 parent_features = features[features["cluster"] == parent] 4600 children = pairs_df.loc[[parent], "child"].tolist() 4601 for child in children: 4602 overlap = self.check_merge(parent_features, child, features) 4603 if len(overlap) == 0: 4604 merge_these_clusters.append((parent, child, len(overlap))) 4605 else: 4606 possible_overlaps.append((parent, child, len(overlap))) 4607 4608 result_dict = {} 4609 result_dict["merge_these_clusters"] = merge_these_clusters 4610 result_dict["possible_overlaps"] = possible_overlaps 4611 4612 return result_dict 4613 4614 def check_merge(self, parent_features, child, features): 4615 # Grab the features of the parent and children 4616 child_features = features[features["cluster"] == child] 4617 4618 # Check if there is an overlap between mf_coll_id in the parent and child clusters 4619 overlap = np.intersect1d( 4620 parent_features["sample_id"].values, child_features["sample_id"].values 4621 ) 4622 4623 return overlap 4624 4625 def cluster_mass_features_agg_cluster(self, features): 4626 if features is None: 4627 return None 4628 4629 features = features.copy() 4630 4631 self.add_sparse_distance_matrix(features) 4632 4633 distances = self._sparse_distance_matrix 4634 4635 # Convert to full matrix 4636 distances = distances.todense() 4637 4638 # Cast all 0s to 1s for a distance matrix 4639 distances[distances == 0] = 1 4640 distances = np.asarray(distances) 4641 4642 # Perform clustering 4643 try: 4644 clustering = AgglomerativeClustering( 4645 n_clusters=None, 4646 linkage="complete", 4647 # using complete linkage will prevent one sample from being assigned to multiple clusters 4648 metric="precomputed", 4649 distance_threshold=1, 4650 ).fit(distances) 4651 features["cluster"] = clustering.labels_ 4652 4653 # All data points are singleton clusters 4654 except: 4655 features["cluster"] = np.arange(len(features.index)) 4656 4657 return features 4658 4659 def cluster_inspection_plot(self, clu, return_fig = False): 4660 """ 4661 Generate Scan Time vs m/z plot for a narrow range around a given 4662 cluster. This tool is meant to support the user in fine tuning the 4663 tolerances used for the clustering algorithm. The user-provided cluster 4664 ID is highlighted in larger, magenta marker and the ten largest of the 4665 remaining clusters are idenfitied with different colors while the 4666 smallest clusters are light gray. 4667 4668 Parameters 4669 ----------- 4670 clu : integer 4671 A cluster ID that exists in self.mass_features_dataframe 4672 return_fig : boolean 4673 Indicates whether to plot cluster inspection figure (False) or 4674 return figure object (True). Defaults to False. 4675 4676 Returns 4677 -------- 4678 matplotlib.pyplot.Figure 4679 A figure displaying a scan time vs m/z scatterplot of small region 4680 around a given cluster with the ten largest clusters in the region 4681 distinctly identified 4682 4683 Raises 4684 ------ 4685 Warning 4686 If cluster data haven't been added to the object yet 4687 """ 4688 4689 if 'cluster' not in self.mass_features_dataframe.columns: 4690 raise ValueError( 4691 'Cluster information is not yet added to mass_features_dataframe, must run add_consensus_mass_features() first' 4692 ) 4693 4694 else: 4695 mztol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4696 rttol = self.parameters.lcms_collection.consensus_rt_tol 4697 clu_features = self.mass_features_dataframe.copy() 4698 4699 inclu = clu_features[clu_features.cluster == clu] 4700 exclu = clu_features[clu_features.cluster != clu] 4701 4702 dt_ymin = np.floor(min(inclu.mz)) - 1 4703 dt_ymax = np.ceil(max(inclu.mz)) + 1 4704 dt_xmin = np.floor(min(inclu.scan_time_aligned)) - 1 4705 dt_xmax = np.ceil(max(inclu.scan_time_aligned)) + 1 4706 4707 exclu = exclu[ 4708 ( 4709 exclu.mz.between(dt_ymin, dt_ymax, inclusive = 'both') 4710 ) & ( 4711 exclu.scan_time_aligned.between(dt_xmin, dt_xmax, inclusive = 'both') 4712 ) 4713 ] 4714 4715 bigclulist = list(exclu.cluster.value_counts()[:10].index) 4716 bigclu = exclu[exclu.cluster.isin(bigclulist)] 4717 smclu = exclu[~exclu.cluster.isin(bigclulist)] 4718 4719 colors = np.arange(0, 10) 4720 colordict = dict(zip(bigclulist, colors)) 4721 bigclu['color'] = bigclu.cluster.apply(lambda x: colordict[x]) 4722 4723 fig = plt.figure(figsize = (7.5, 5)) 4724 4725 plt.scatter( 4726 inclu.scan_time_aligned, 4727 inclu.mz, 4728 c = 'm', 4729 s = 3, 4730 label = 'Cluster ' + str(clu) 4731 ) 4732 4733 plt.scatter( 4734 bigclu.scan_time_aligned, 4735 bigclu.mz, 4736 c = bigclu.color, 4737 cmap = 'tab10', 4738 s = 1.5 4739 ) 4740 4741 plt.scatter( 4742 smclu.scan_time_aligned, 4743 smclu.mz, 4744 c = 'silver', 4745 s = 2, 4746 label = 'Small clusters' 4747 ) 4748 4749 plt.ylim(dt_ymin, dt_ymax) 4750 plt.xlim(dt_xmin, dt_xmax) 4751 plt.legend(ncol = 2, bbox_to_anchor = (0.8, -0.1)) 4752 plt.xlabel('Scan time') 4753 plt.ylabel('m/z') 4754 title_str = 'Cluster ' + str(clu) 4755 title_str += ': representing ' + str(len(inclu.sample_id.unique())) 4756 title_str += ' of ' + str(len(clu_features.sample_id.unique())) 4757 title_str += ' samples\n' 4758 title_str += 'M/Z tolerance: ' + str(mztol) + '\n' 4759 title_str += 'Scan Time tolerance: ' + str(rttol) 4760 plt.title(title_str, fontsize = 10) 4761 4762 if return_fig: 4763 plt.close(fig) 4764 return fig 4765 else: 4766 plt.show() 4767 4768 def plot_cluster_outlier_frequency(self, dim_list = ['mz', 'scan_time_aligned'], clu_size_thresh = 0.5, return_fig = False): 4769 """ 4770 Generate histogram showing the frequency of outlier occurrences by 4771 clustering dimension across all clusters 4772 4773 Parameters 4774 ----------- 4775 dim_list : list 4776 List of strings describing dimensions that can be used in 4777 clustering. Available list items: 4778 - 'mz' 4779 - 'scan_time_aligned' 4780 - 'half_height_width' 4781 - 'tailing_factor' 4782 - 'dispersity_index' 4783 - 'intensity' 4784 - 'persistence' 4785 clu_size_thresh : float 4786 Value between 0 and 1 that indicates what percentage of samples 4787 need to be present in a cluster before it's evaluated for outliers. 4788 Defaults to 0.5. 4789 return_fig : boolean 4790 Indicates whether to plot cluster inspection figure (False) or 4791 return figure object (True). Defaults to False. 4792 4793 Returns 4794 -------- 4795 matplotlib.pyplot.Figure 4796 A figure displaying the frequency of outlier occurrences across all 4797 clusters in the provided measurement dimensions 4798 4799 Raises 4800 ------ 4801 Warning 4802 If cluster data haven't been added to the object yet 4803 """ 4804 4805 if not hasattr(self, 'cluster_summary_dataframe'): 4806 raise ValueError( 4807 'cluster_summary_dataframe is not yet added, must run add_consensus_mass_features() first' 4808 ) 4809 4810 mfdf = self.mass_features_dataframe.copy() 4811 summarydf = self.cluster_summary_dataframe 4812 4813 numsamples = len(self) 4814 sumdf = summarydf[summarydf.sample_id_nunique > numsamples * clu_size_thresh].reset_index(drop = True).copy() 4815 4816 ## find the ranges for non-outlier values and add them to sumdf 4817 mergelist = ['cluster'] 4818 for dim in dim_list: 4819 maxtag = dim + '_outmax' 4820 mintag = dim + '_outmin' 4821 mergelist.append(maxtag) 4822 mergelist.append(mintag) 4823 # Calculate outlier thresholds using vectorized operations 4824 sumdf[mintag] = sumdf[dim + '_mean'] - 3*sumdf[dim + '_std'] 4825 sumdf[maxtag] = sumdf[dim + '_mean'] + 3*sumdf[dim + '_std'] 4826 ## If NaN shows up anywhere in dim_min, dim_max calculations, value is set to NaN and it's 4827 ## not flagged. This happens when there's not enough values to compute median/std for that 4828 ## dimension therefore can't have outliers 4829 4830 ## add ranges to mfdf and identify mass features that fall outside the ranges 4831 # Merge without dropping NaN - we'll handle it per-dimension 4832 outdf = pd.merge(mfdf, sumdf[mergelist], on = 'cluster') 4833 4834 outtags = ['cluster'] 4835 for dim in dim_list: 4836 dimtag = dim + '_outlier' 4837 outtags.append(dimtag) 4838 maxtag = dim + '_outmax' 4839 mintag = dim + '_outmin' 4840 # Only flag as outlier if thresholds are valid (not NaN) 4841 outdf[dimtag] = np.where( 4842 (outdf[maxtag].notna() & outdf[mintag].notna()) & 4843 (((outdf[dim] > outdf[maxtag])) | ((outdf[dim] < outdf[mintag]))), 4844 True, 4845 False 4846 ) 4847 4848 ## identify number of outliers in each cluster 4849 outliers = outdf[outtags] 4850 outliers = outliers.groupby(['cluster']).sum() 4851 4852 ## plot number of clusters that contain any outliers 4853 fig = plt.figure() 4854 plt.bar(dim_list, outliers.sum().values, width = 0.5) 4855 plt.xticks(rotation = 90) 4856 plt.title('Frequency of outliers across all clusters by category') 4857 4858 if return_fig: 4859 plt.close(fig) 4860 return fig 4861 else: 4862 plt.show() 4863 4864 def _search_for_targeted_mass_features_in_sample(self, obj_idx, missingdf, cluster_dict, expand_on_miss=False, inplace=True): 4865 """ 4866 Helper method to search for missing mass features in a single sample. 4867 4868 Internal method called by fill_missing_cluster_features() to perform 4869 gap-filling for one sample in the collection. 4870 4871 Parameters 4872 ---------- 4873 obj_idx : int 4874 Index of the sample being processed 4875 missingdf : pd.DataFrame 4876 DataFrame containing cluster information with columns: 4877 'cluster', 'sample_id_nunique', 'mz_min', 'mz_max', 4878 'scan_time_aligned_min', 'scan_time_aligned_max', 'mz_min_allowed', 4879 'mz_max_allowed', 'scan_time_aligned_min_allowed', 4880 'scan_time_aligned_max_allowed', 'missing_samples' 4881 cluster_dict : dict 4882 Pre-computed cluster feature dictionary to avoid recomputation 4883 expand_on_miss : bool 4884 If True, expands search window when no peak found initially 4885 inplace : bool 4886 If True, assigns induced_mass_features in place. If False, returns the 4887 induced features dictionary (for multiprocessing) 4888 4889 Returns 4890 ------- 4891 dict or None 4892 If inplace=False, returns dictionary of induced mass features. 4893 Otherwise returns None and updates object in place. 4894 """ 4895 ## Use the pre-computed cluster dictionary passed as parameter 4896 4897 ## to get clusters missing data based on sample index: 4898 sampledf = missingdf[ 4899 missingdf.missing_samples.apply(lambda x: obj_idx in x) 4900 ].reset_index(drop = True).copy() 4901 4902 # Skip if no missing features for this sample 4903 if len(sampledf) == 0: 4904 if not inplace: 4905 return {} 4906 return 4907 4908 self.load_raw_data(obj_idx, 1) 4909 4910 ## this is the line that bugs due to _ms_unprocessed not having key 1 4911 ms1df = self[obj_idx]._ms_unprocessed[1].copy() 4912 scan_df = self[obj_idx].scan_df[['scan', 'scan_time_aligned']] 4913 ms1df = pd.merge(ms1df, scan_df, on = 'scan') 4914 4915 # Pre-extract all values from sampledf to avoid repeated .iloc calls 4916 clusters = sampledf.cluster.values 4917 mz_mins = sampledf.mz_min.values 4918 mz_maxs = sampledf.mz_max.values 4919 st_mins = sampledf.scan_time_aligned_min.values 4920 st_maxs = sampledf.scan_time_aligned_max.values 4921 4922 if expand_on_miss: 4923 mz_mins_allowed = sampledf.mz_min_allowed.values 4924 mz_maxs_allowed = sampledf.mz_max_allowed.values 4925 st_mins_allowed = sampledf.sta_min_allowed.values 4926 st_maxs_allowed = sampledf.sta_max_allowed.values 4927 4928 # Pre-filter ms1df to reduce search space 4929 mz_global_min = mz_mins.min() 4930 mz_global_max = mz_maxs.max() 4931 st_global_min = st_mins.min() 4932 st_global_max = st_maxs.max() 4933 4934 if expand_on_miss: 4935 mz_global_min = min(mz_global_min, mz_mins_allowed.min()) 4936 mz_global_max = max(mz_global_max, mz_maxs_allowed.max()) 4937 st_global_min = min(st_global_min, st_mins_allowed.min()) 4938 st_global_max = max(st_global_max, st_maxs_allowed.max()) 4939 4940 ms1df_filtered = ms1df[ 4941 (ms1df.mz >= mz_global_min) & 4942 (ms1df.mz <= mz_global_max) & 4943 (ms1df.scan_time_aligned >= st_global_min) & 4944 (ms1df.scan_time_aligned <= st_global_max) 4945 ].copy() 4946 4947 # Generate set_ids for all features 4948 set_ids = [f'c{clusters[i]}_{i}_i' for i in range(len(sampledf))] 4949 4950 # Use batch method to process all features at once 4951 if expand_on_miss: 4952 # First try with normal bounds 4953 peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch( 4954 ms1df_filtered, 4955 mz_mins, 4956 mz_maxs, 4957 st_mins, 4958 st_maxs, 4959 set_ids, 4960 obj_idx=obj_idx, 4961 st_aligned=True 4962 ) 4963 4964 # Retry failed features with expanded bounds 4965 failed_indices = [i for i, sid in enumerate(set_ids) if peaks_dict[sid].apex_scan == -99] 4966 if failed_indices: 4967 failed_ids = [set_ids[i] for i in failed_indices] 4968 retry_peaks = self[obj_idx].search_for_targeted_mass_features_batch( 4969 ms1df_filtered, 4970 mz_mins_allowed[failed_indices], 4971 mz_maxs_allowed[failed_indices], 4972 st_mins_allowed[failed_indices], 4973 st_maxs_allowed[failed_indices], 4974 failed_ids, 4975 obj_idx=obj_idx, 4976 st_aligned=True 4977 ) 4978 peaks_dict.update(retry_peaks) 4979 else: 4980 peaks_dict = self[obj_idx].search_for_targeted_mass_features_batch( 4981 ms1df_filtered, 4982 mz_mins, 4983 mz_maxs, 4984 st_mins, 4985 st_maxs, 4986 set_ids, 4987 obj_idx=obj_idx, 4988 st_aligned=True 4989 ) 4990 4991 # Assign peaks to induced_mass_features and cluster_dict 4992 for i in range(len(sampledf)): 4993 peak = peaks_dict[set_ids[i]] 4994 self[obj_idx].induced_mass_features[peak.id] = peak 4995 cluster_dict[clusters[i]] += [set_ids[i]] 4996 4997 # TODO KRH: Let's try to avoid these steps unless asked for by parameters to pick up speed 4998 if False: 4999 self[obj_idx].add_associated_ms1(induced_features = True) 5000 # need to set drop_if_fail to false for induced features as they will fail 5001 self[obj_idx].add_peak_metrics(induced_features = True) 5002 5003 self[obj_idx].integrate_mass_features(drop_if_fail = False, induced_features = True) 5004 5005 if not inplace: 5006 return self[obj_idx].induced_mass_features 5007 5008 def fill_missing_cluster_features(self): 5009 """ 5010 Gap-filling for consensus mass features across collection samples. 5011 5012 For clusters present in multiple samples but missing from others, searches 5013 raw MS1 data to find peaks in expected m/z and retention time windows. This 5014 creates "induced" mass features for peaks that exist in the data but weren't 5015 detected in the initial peak detection. 5016 5017 Must be run after add_consensus_mass_features(). Results are accessible via 5018 induced_mass_features_dataframe property and included in collection_pivot_table 5019 and collection_consensus_report outputs. 5020 5021 Parameters 5022 ---------- 5023 None 5024 Uses parameters from self.parameters.lcms_collection: 5025 - consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain 5026 a cluster before gap-filling is attempted 5027 - gap_fill_expand_on_miss: If True, expands search window when no peak is found 5028 5029 Returns 5030 ------- 5031 None 5032 Updates induced_mass_features attribute for each LCMSBase object and 5033 combines them into induced_mass_features_dataframe. 5034 5035 Raises 5036 ------ 5037 ValueError 5038 If cluster_summary_dataframe is not set (must run add_consensus_mass_features first). 5039 5040 Notes 5041 ----- 5042 - Loads raw MS1 data for each sample, which may be memory intensive 5043 - Induced features are integrated and metrics calculated automatically 5044 - Processing can be parallelized using parameters.lcms_collection.cores 5045 5046 See Also 5047 -------- 5048 add_consensus_mass_features : Creates consensus features before gap-filling 5049 collection_pivot_table : Includes both regular and induced features 5050 collection_consensus_report : Reports on complete feature matrix 5051 """ 5052 5053 # Validate prerequisites 5054 if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: 5055 raise ValueError( 5056 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 5057 ) 5058 5059 # Get parameters from settings 5060 min_cluster_presence = self.parameters.lcms_collection.consensus_min_sample_fraction 5061 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5062 5063 # Validate parameters 5064 if not 0 <= min_cluster_presence <= 1: 5065 raise ValueError("consensus_min_sample_fraction must be between 0 and 1") 5066 5067 summarydf = self.cluster_summary_dataframe 5068 mfdf = self.mass_features_dataframe 5069 5070 sample_ct = len(self.samples) 5071 5072 # Identify clusters present in sufficient samples but not all samples 5073 missingdf = summarydf[[ 5074 'cluster', 5075 'sample_id_nunique', 5076 'mz_min', 5077 'mz_max', 5078 'scan_time_aligned_min', 5079 'scan_time_aligned_max' 5080 ]] 5081 missingdf = missingdf[missingdf.sample_id_nunique > min_cluster_presence * sample_ct] 5082 missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] 5083 5084 # Check if there are any clusters to gap-fill 5085 if len(missingdf) == 0: 5086 return 5087 5088 # Find which samples are missing for each cluster 5089 # Use range(sample_ct) to include all samples, even those with no mass features 5090 all_sample_ids = list(range(sample_ct)) 5091 missing_samples_list = [] 5092 for c in missingdf.cluster.to_numpy(): 5093 cludf = mfdf[mfdf.cluster == c] 5094 missing = [x for x in all_sample_ids if x not in cludf.sample_id.unique()] 5095 missing_samples_list.append(missing) 5096 missingdf['missing_samples'] = missing_samples_list 5097 5098 # Calculate expanded search windows for expand_on_miss option 5099 mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5100 rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol 5101 missingdf['mz_max_allowed'] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max 5102 missingdf['mz_min_allowed'] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min 5103 missingdf['sta_max_allowed'] = missingdf.scan_time_aligned_max + rt_clu_tol * missingdf.scan_time_aligned_max 5104 missingdf['sta_min_allowed'] = missingdf.scan_time_aligned_min - rt_clu_tol * missingdf.scan_time_aligned_min 5105 5106 # Compute cluster dictionary once to avoid recomputing for each sample 5107 cluster_dict = self.cluster_feature_dictionary 5108 5109 # Process each sample to search for missing features 5110 if self.parameters.lcms_collection.cores == 1: 5111 for i in tqdm(range(sample_ct), desc="Gap-filling samples", unit="sample"): 5112 self._search_for_targeted_mass_features_in_sample(i, missingdf, cluster_dict, expand_on_miss) 5113 5114 if self.parameters.lcms_collection.cores > 1: 5115 if self.parameters.lcms_collection.cores > len(self): 5116 ncores = len(self) 5117 else: 5118 ncores = self.parameters.lcms_collection.cores 5119 pool = multiprocessing.Pool(ncores) 5120 mp_result = pool.starmap( 5121 self._search_for_targeted_mass_features_in_sample, 5122 [(x, missingdf, cluster_dict, expand_on_miss, False) for x in range(sample_ct)] 5123 ) 5124 5125 for i in tqdm(range(sample_ct), desc="Collecting gap-filled features", unit="sample"): 5126 self[i].induced_mass_features = mp_result[i] 5127 5128 self._combine_mass_features(induced_features = True) 5129 5130 # Mark that gap-filling has been performed 5131 self.missing_mass_features_searched = True 5132 5133 for sample_name in self.samples: 5134 self._lcms[sample_name].mass_features = {} 5135 5136 def process_samples_pipeline(self, operations, description=None, keep_raw_data=False, show_progress=True): 5137 """ 5138 Execute a pipeline of operations on all samples in parallel. 5139 5140 This method provides a flexible framework for performing multiple 5141 sample-level operations in a single parallelized pass, which is more 5142 efficient than calling separate methods sequentially. 5143 5144 Parameters 5145 ---------- 5146 operations : list of SampleOperation 5147 List of operations to perform on each sample, in order. 5148 Each operation should be an instance of a class derived from 5149 SampleOperation (see lc_calc_operations module). 5150 description : str or None, optional 5151 Progress bar description. If None, automatically generates description 5152 from operation descriptions (e.g., "gap-filling, reloading features"). 5153 Default is None. 5154 keep_raw_data : bool, optional 5155 If True, keeps raw MS data loaded in memory after pipeline completes. 5156 If False, cleans up raw data to free memory. Default is False. 5157 show_progress : bool, optional 5158 If True, displays progress bars during processing. If False, runs silently. 5159 Default is True. 5160 5161 Returns 5162 ------- 5163 dict 5164 Dictionary with results from pipeline execution, keyed by operation name. 5165 Structure: {operation_name: {sample_id: result, ...}, ...} 5166 5167 Raises 5168 ------ 5169 ValueError 5170 If operations list is empty or contains invalid operations. 5171 5172 Notes 5173 ----- 5174 - Operations are executed sequentially within each sample 5175 - Samples are processed in parallel based on parameters.lcms_collection.cores 5176 - Each operation can have conditional execution via can_execute() 5177 - Results are collected back via collect_results() method of each operation 5178 - Failed operations for a sample are logged but don't halt processing 5179 - Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True 5180 5181 Examples 5182 -------- 5183 >>> from corems.mass_spectra.calc.lc_calc_operations import ( 5184 ... GapFillOperation, ReloadFeaturesOperation 5185 ... ) 5186 >>> ops = [ 5187 ... GapFillOperation('gap_fill', expand_on_miss=True), 5188 ... ReloadFeaturesOperation('reload', add_ms2=True) 5189 ... ] 5190 >>> results = lcms_collection.process_samples_pipeline(ops) 5191 5192 See Also 5193 -------- 5194 lc_calc_operations : Module containing built-in operation classes 5195 fill_and_process_features : Convenience method combining common operations 5196 """ 5197 from corems.mass_spectra.calc.lc_calc_operations import SampleOperation 5198 5199 # Validate operations 5200 if not operations or len(operations) == 0: 5201 raise ValueError("operations list cannot be empty") 5202 5203 for op in operations: 5204 if not isinstance(op, SampleOperation): 5205 raise ValueError(f"All operations must be SampleOperation instances, got {type(op)}") 5206 5207 # Generate description from operations if not provided 5208 if description is None: 5209 operation_descriptions = [op.description for op in operations] 5210 description = ", ".join(operation_descriptions).capitalize() 5211 5212 # Prepare runtime parameters for each operation 5213 # This is where we gather collection-level data that operations need 5214 runtime_params = self._prepare_pipeline_runtime_params(operations) 5215 runtime_params['keep_raw_data'] = keep_raw_data 5216 5217 # Execute pipeline 5218 sample_ct = len(self.samples) 5219 5220 if self.parameters.lcms_collection.cores == 1: 5221 # Serial processing 5222 results_by_operation = {op.name: {} for op in operations} 5223 5224 if show_progress: 5225 from tqdm import tqdm 5226 # Print description on its own line before progress bar 5227 print(f"\n{description.capitalize()}:") 5228 iterator = tqdm(range(sample_ct), unit="sample", ncols=80) 5229 else: 5230 iterator = range(sample_ct) 5231 5232 for sample_id in iterator: 5233 sample_results = self._execute_sample_pipeline( 5234 sample_id, operations, runtime_params, inplace=True 5235 ) 5236 # Collect results (collect_results already called in _execute_sample_pipeline when inplace=True) 5237 # Skip 'sample_id' key which is added for tracking 5238 for op_name, result in sample_results.items(): 5239 if op_name != 'sample_id': 5240 results_by_operation[op_name][sample_id] = result 5241 else: 5242 # Parallel processing 5243 import multiprocessing 5244 5245 if self.parameters.lcms_collection.cores > sample_ct: 5246 ncores = sample_ct 5247 else: 5248 ncores = self.parameters.lcms_collection.cores 5249 5250 pool = multiprocessing.Pool(ncores) 5251 5252 # Build arguments for each sample 5253 args_list = [ 5254 (sample_id, operations, runtime_params, False) 5255 for sample_id in range(sample_ct) 5256 ] 5257 5258 # Execute in parallel with progress tracking 5259 results_by_operation = {op.name: {} for op in operations} 5260 5261 if show_progress: 5262 from tqdm import tqdm 5263 import time 5264 5265 # Use starmap_async for parallel execution with progress tracking 5266 async_result = pool.starmap_async(self._execute_sample_pipeline, args_list) 5267 5268 # Poll for completion and update progress bar 5269 print(description) 5270 pbar = tqdm( 5271 total=sample_ct, 5272 desc="", 5273 unit="sample", 5274 position=0, 5275 leave=True, 5276 dynamic_ncols=True 5277 ) 5278 prev_completed = 0 5279 while not async_result.ready(): 5280 # Get number of completed tasks by checking remaining 5281 completed = sample_ct - async_result._number_left 5282 if completed > prev_completed: 5283 pbar.update(completed - prev_completed) 5284 prev_completed = completed 5285 time.sleep(0.5) # Poll every 500ms to avoid spam 5286 5287 # Final update to 100% 5288 if prev_completed < sample_ct: 5289 pbar.update(sample_ct - prev_completed) 5290 pbar.close() 5291 5292 # Get all results 5293 mp_results = async_result.get() 5294 else: 5295 # Execute without progress 5296 mp_results = pool.starmap(self._execute_sample_pipeline, args_list) 5297 5298 pool.close() 5299 pool.join() 5300 5301 # Collect results back into collection 5302 for result in mp_results: 5303 sample_id = result.get('sample_id') 5304 for op in operations: 5305 op_result = result.get(op.name) 5306 if op_result is not None: 5307 op.collect_results(sample_id, op_result, self) 5308 results_by_operation[op.name][sample_id] = op_result 5309 5310 return results_by_operation 5311 5312 def _prepare_pipeline_runtime_params(self, operations): 5313 """ 5314 Prepare runtime parameters needed by operations in the pipeline. 5315 5316 This method gathers collection-level data that operations need, 5317 such as cluster information for gap-filling or mf_ids for reloading. 5318 5319 Parameters 5320 ---------- 5321 operations : list of SampleOperation 5322 List of operations that will be executed 5323 5324 Returns 5325 ------- 5326 dict 5327 Dictionary of runtime parameters for operations 5328 """ 5329 from corems.mass_spectra.calc.lc_calc_operations import ( 5330 GapFillOperation, ReloadFeaturesOperation, MS2SpectralSearchOperation, 5331 LoadEICsOperation 5332 ) 5333 5334 runtime_params = {} 5335 5336 # Check if any operation needs gap-fill parameters 5337 needs_gap_fill = any(isinstance(op, GapFillOperation) for op in operations) 5338 if needs_gap_fill: 5339 # Prepare gap-fill parameters (same as fill_missing_cluster_features) 5340 min_cluster_presence = self.parameters.lcms_collection.consensus_min_sample_fraction 5341 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5342 5343 summarydf = self.cluster_summary_dataframe 5344 mfdf = self.mass_features_dataframe 5345 sample_ct = len(self.samples) 5346 5347 # Identify clusters needing gap-filling 5348 # Note: cluster_summary_dataframe has 'cluster' as index, need to reset it 5349 missingdf = summarydf.reset_index()[[ 5350 'cluster', 5351 'sample_id_nunique', 5352 'mz_min', 5353 'mz_max', 5354 'scan_time_aligned_min', 5355 'scan_time_aligned_max' 5356 ]].copy() 5357 missingdf = missingdf[missingdf.sample_id_nunique > min_cluster_presence * sample_ct] 5358 missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] 5359 5360 if len(missingdf) > 0: 5361 # Find which samples are missing for each cluster 5362 # Use range(sample_ct) to include all samples, even those with no mass features 5363 all_sample_ids = list(range(sample_ct)) 5364 missing_samples_list = [] 5365 for c in missingdf.cluster.to_numpy(): 5366 cludf = mfdf[mfdf.cluster == c] 5367 missing = [x for x in all_sample_ids if x not in cludf.sample_id.unique()] 5368 missing_samples_list.append(missing) 5369 missingdf['missing_samples'] = missing_samples_list 5370 5371 # Calculate expanded search windows 5372 mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5373 rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol 5374 missingdf['mz_max_allowed'] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max 5375 missingdf['mz_min_allowed'] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min 5376 missingdf['sta_max_allowed'] = missingdf.scan_time_aligned_max + rt_clu_tol * missingdf.scan_time_aligned_max 5377 missingdf['sta_min_allowed'] = missingdf.scan_time_aligned_min - rt_clu_tol * missingdf.scan_time_aligned_min 5378 5379 runtime_params['missingdf'] = missingdf 5380 runtime_params['cluster_dict'] = self.cluster_feature_dictionary 5381 runtime_params['expand_on_miss'] = expand_on_miss 5382 5383 # Check if any operation needs reload parameters 5384 needs_reload = any(isinstance(op, ReloadFeaturesOperation) for op in operations) 5385 if needs_reload: 5386 # Use DRY helper method to build sample_mf_map 5387 sample_mf_map = self.get_sample_mf_map_for_representatives(include_cluster_id=False) 5388 runtime_params['sample_mf_map'] = sample_mf_map 5389 5390 # Check if any operation needs MS2 spectral search parameters 5391 needs_ms2_search = any(isinstance(op, MS2SpectralSearchOperation) for op in operations) 5392 if needs_ms2_search: 5393 # Pass through pre-prepared spectral library 5394 if hasattr(self, '_spectral_lib') and self._spectral_lib is not None: 5395 runtime_params['fe_lib'] = self._spectral_lib 5396 if hasattr(self, '_spectral_search_molecular_metadata'): 5397 runtime_params['molecular_metadata'] = self._spectral_search_molecular_metadata 5398 5399 # Check if any operation needs EIC loading parameters 5400 needs_eic_loading = any(isinstance(op, LoadEICsOperation) for op in operations) 5401 if needs_eic_loading: 5402 # Build cluster_mz_dict: map of sample_id -> list of m/z values in clusters 5403 mfdf = self.mass_features_dataframe 5404 cluster_mz_dict = {} 5405 5406 # Get all mass features that belong to clusters (cluster is not NaN) 5407 clustered_mf = mfdf[mfdf['cluster'].notna()] 5408 5409 # Group by sample_id and collect all m/z values associated with eics 5410 for sample_id in clustered_mf['sample_id'].unique(): 5411 sample_df = clustered_mf[clustered_mf['sample_id'] == sample_id] 5412 sample = self[sample_id] # Get the LCMS object for this sample 5413 5414 # Extract _eic_mz from actual mass feature objects, not from dataframe 5415 eic_mz_list = [] 5416 for mf_id in sample_df['mf_id'].values: 5417 if mf_id in sample.mass_features: 5418 mf = sample.mass_features[mf_id] 5419 if hasattr(mf, '_eic_mz') and mf._eic_mz is not None: 5420 eic_mz_list.append(mf._eic_mz) 5421 5422 # Use the collected m/z values, or fallback to empty list if none found 5423 cluster_mz_dict[sample_id] = list(set(eic_mz_list)) if eic_mz_list else [] 5424 5425 runtime_params['cluster_mz_dict'] = cluster_mz_dict 5426 5427 return runtime_params 5428 5429 def _execute_sample_pipeline(self, sample_id, operations, runtime_params, inplace=True): 5430 """ 5431 Execute a pipeline of operations on a single sample. 5432 5433 This is the worker function called (potentially in parallel) for each sample. 5434 5435 Parameters 5436 ---------- 5437 sample_id : int 5438 Sample ID to process 5439 operations : list of SampleOperation 5440 Operations to execute in order 5441 runtime_params : dict 5442 Runtime parameters prepared by _prepare_pipeline_runtime_params 5443 inplace : bool, optional 5444 If True, updates sample in place. If False, returns results for 5445 multiprocessing. Default is True. 5446 5447 Returns 5448 ------- 5449 dict 5450 Dictionary with results from each operation, keyed by operation name. 5451 If inplace=True, returns results that need to be collected. 5452 If inplace=False, returns all results for multiprocessing collection. 5453 """ 5454 results = {} 5455 5456 # Check if any operations need raw MS data 5457 needs_raw_data = {} # {ms_level: True/False} 5458 for op in operations: 5459 needs_raw, ms_level = op.needs_raw_ms_data() 5460 if needs_raw and ms_level: 5461 needs_raw_data[ms_level] = True 5462 5463 # Load raw data once if any operations need it 5464 # Note: For gap-filling, it loads data internally, so we just track it here 5465 for ms_level in needs_raw_data.keys(): 5466 # Gap-filling loads its own data, but we want to keep track that it's loaded 5467 # Other operations can then use the loaded data 5468 pass 5469 5470 for op in operations: 5471 # Check if operation can execute on this sample 5472 sample = self[sample_id] 5473 if not op.can_execute(sample, self): 5474 # Skip this operation for this sample if prerequisites aren't met 5475 # This allows processing to continue for samples that don't have 5476 # all required data (e.g., MS2 spectra) 5477 results[op.name] = None 5478 continue 5479 5480 # Prepare operation-specific runtime params 5481 op_runtime_params = {} 5482 5483 # Add gap-fill params if this is a gap-fill operation 5484 from corems.mass_spectra.calc.lc_calc_operations import ( 5485 GapFillOperation, ReloadFeaturesOperation, MS2SpectralSearchOperation, LoadEICsOperation 5486 ) 5487 5488 if isinstance(op, GapFillOperation): 5489 if 'missingdf' in runtime_params: 5490 op_runtime_params['missingdf'] = runtime_params['missingdf'] 5491 op_runtime_params['cluster_dict'] = runtime_params['cluster_dict'] 5492 op_runtime_params['expand_on_miss'] = runtime_params['expand_on_miss'] 5493 5494 elif isinstance(op, ReloadFeaturesOperation): 5495 if 'sample_mf_map' in runtime_params: 5496 sample_mf_map = runtime_params['sample_mf_map'] 5497 # Always pass mf_ids_to_load to ensure we only load what's needed 5498 # If sample not in map, it has no representatives - pass empty list 5499 op_runtime_params['mf_ids_to_load'] = sample_mf_map.get(sample_id, []) 5500 5501 elif isinstance(op, MS2SpectralSearchOperation): 5502 # Add MS2 spectral search parameters 5503 if 'fe_lib' in runtime_params: 5504 op_runtime_params['fe_lib'] = runtime_params['fe_lib'] 5505 if 'molecular_metadata' in runtime_params: 5506 op_runtime_params['molecular_metadata'] = runtime_params['molecular_metadata'] 5507 5508 elif isinstance(op, LoadEICsOperation): 5509 # Add EIC loading parameters 5510 if 'cluster_mz_dict' in runtime_params: 5511 op_runtime_params['cluster_mz_dict'] = runtime_params['cluster_mz_dict'] 5512 5513 # Execute the operation 5514 result = op.execute(sample_id, self, **op_runtime_params) 5515 results[op.name] = result 5516 5517 # If inplace, collect immediately 5518 if inplace and result is not None: 5519 op.collect_results(sample_id, result, self) 5520 5521 # Clean up raw data if requested 5522 keep_raw_data = runtime_params.get('keep_raw_data', False) 5523 if not keep_raw_data: 5524 for ms_level in needs_raw_data.keys(): 5525 if ms_level in self[sample_id]._ms_unprocessed: 5526 del self[sample_id]._ms_unprocessed[ms_level] 5527 5528 # Include sample_id in results for tracking (especially important for imap_unordered) 5529 results['sample_id'] = sample_id 5530 return results 5531 5532 def process_consensus_features(self, load_representatives=True, perform_gap_filling=True, 5533 add_ms1=False, add_ms2=False, 5534 ms2_scan_filter=None, molecular_formula_search=False, 5535 ms2_spectral_search=False, spectral_lib=None, 5536 molecular_metadata=None, 5537 gather_eics=False, 5538 keep_raw_data=False, 5539 show_progress=True): 5540 """ 5541 Process consensus mass features across the collection in a single parallelized pass. 5542 5543 This method provides a convenient interface to the sample processing pipeline, 5544 allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association, 5545 molecular formula search, and MS2 spectral search) to be performed efficiently in 5546 a single pass through all samples. 5547 5548 Parameters 5549 ---------- 5550 load_representatives : bool, optional 5551 If True, loads representative mass features from HDF5. Default is True. 5552 perform_gap_filling : bool, optional 5553 If True, performs gap-filling for missing cluster features. Default is True. 5554 This operation loads raw MS1 data which can be reused by subsequent operations. 5555 add_ms1 : bool, optional 5556 If True and load_representatives=True, associates MS1 spectra with 5557 loaded features. Automatically uses raw data from gap-filling if available, 5558 otherwise uses parser. Spectrum mode is auto-detected. Default is False. 5559 add_ms2 : bool, optional 5560 If True and load_representatives=True, associates MS2 spectra with 5561 loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False. 5562 ms2_scan_filter : str or None, optional 5563 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 5564 molecular_formula_search : bool, optional 5565 If True, performs molecular formula search on mass features using 5566 associated MS1 spectra. Requires add_ms1=True or that MS1 spectra 5567 are already associated. Uses parameters from 5568 parameters.mass_spectrum["ms1"].molecular_search. Default is False. 5569 ms2_spectral_search : bool, optional 5570 If True, performs MS2 spectral library search using FlashEntropy. 5571 Requires add_ms2=True and spectral_lib to be provided. Default is False. 5572 spectral_lib : FlashEntropy library, optional 5573 Pre-prepared FlashEntropy spectral library for MS2 search. 5574 Create using MSPInterface.get_metabolomics_spectra_library(). 5575 Required if ms2_spectral_search=True. Default is None. 5576 molecular_metadata : pd.DataFrame, optional 5577 Molecular metadata corresponding to spectral_lib. 5578 Returned from MSPInterface.get_metabolomics_spectra_library(). 5579 Stored as self.spectral_search_molecular_metadata for later export. 5580 Default is None. 5581 gather_eics : bool, optional 5582 If True, loads extracted ion chromatograms (EICs) from HDF5 for all 5583 mass features with assigned cluster_index (including gap-filled features). 5584 Enables access to EICs via get_eics_for_cluster(cluster_id) method. 5585 Requires that EICs were previously exported with export_eics=True. 5586 Default is False. 5587 keep_raw_data : bool, optional 5588 If True, keeps raw MS data loaded in memory after pipeline completes. 5589 If False, cleans up raw data to free memory. Default is False. 5590 show_progress : bool, optional 5591 If True, displays progress bars during processing. If False, runs silently. 5592 Default is True. 5593 5594 Returns 5595 ------- 5596 dict 5597 Dictionary with pipeline results. Keys include: 5598 - 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling) 5599 - 'reload': dict mapping sample_id to reloaded mass features (if reloading) 5600 - 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search) 5601 - 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search) 5602 5603 Raises 5604 ------ 5605 ValueError 5606 If neither operation is enabled, or if required parameters are missing. 5607 5608 Notes 5609 ----- 5610 - Must run add_consensus_mass_features() before calling this method 5611 - Processes samples in parallel based on parameters.lcms_collection.cores 5612 - Raw MS1 data loaded by gap-filling is automatically reused by MS1 association 5613 - MS2 spectral search requires add_ms2=True and msp_file_path 5614 - FlashEntropy library is created once and reused across all samples 5615 - More efficient than calling individual methods separately 5616 - After gap-filling, sets missing_mass_features_searched = True 5617 - Mass features remain loaded in memory for downstream processing 5618 - For more advanced workflows, use process_samples_pipeline() directly 5619 5620 Examples 5621 -------- 5622 >>> # Prepare spectral library for MS2 search 5623 >>> from corems.molecular_id.search.database_interfaces import MSPInterface 5624 >>> my_msp = MSPInterface(file_path='path/to/library.msp') 5625 >>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library( 5626 ... polarity='negative', 5627 ... format='flashentropy', 5628 ... normalize=True, 5629 ... fe_kwargs={ 5630 ... 'normalize_intensity': True, 5631 ... 'min_ms2_difference_in_da': 0.02, 5632 ... 'max_ms2_tolerance_in_da': 0.01, 5633 ... 'max_indexed_mz': 3000, 5634 ... 'precursor_ions_removal_da': None, 5635 ... 'noise_threshold': 0, 5636 ... } 5637 ... ) 5638 >>> 5639 >>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search 5640 >>> results = lcms_collection.process_consensus_features( 5641 ... load_representatives=True, 5642 ... perform_gap_filling=True, 5643 ... add_ms1=True, 5644 ... add_ms2=True, 5645 ... molecular_formula_search=True, 5646 ... ms2_spectral_search=True, 5647 ... spectral_lib=spectral_lib, 5648 ... molecular_metadata=molecular_metadata 5649 ... ) 5650 5651 See Also 5652 -------- 5653 process_samples_pipeline : Generic pipeline executor for custom workflows 5654 fill_missing_cluster_features : Original gap-filling method 5655 reload_representative_mass_features : Original reload method 5656 """ 5657 from corems.mass_spectra.calc.lc_calc_operations import ( 5658 GapFillOperation, ReloadFeaturesOperation, MolecularFormulaSearchOperation, 5659 MS2SpectralSearchOperation, LoadEICsOperation 5660 ) 5661 5662 # Validate that at least one meaningful operation is enabled 5663 has_operations = ( 5664 perform_gap_filling or 5665 load_representatives or 5666 molecular_formula_search or 5667 ms2_spectral_search or 5668 gather_eics or 5669 add_ms1 or 5670 add_ms2 5671 ) 5672 5673 if not has_operations: 5674 raise ValueError( 5675 "At least one operation must be enabled: perform_gap_filling, load_representatives, " 5676 "molecular_formula_search, ms2_spectral_search, gather_eics, add_ms1, or add_ms2" 5677 ) 5678 5679 # Validate prerequisites for gap-filling 5680 if perform_gap_filling: 5681 if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: 5682 raise ValueError( 5683 "Cannot perform gap-filling: cluster_summary_dataframe not set. " 5684 "You must run add_consensus_mass_features() before calling process_consensus_features()." 5685 ) 5686 5687 # Validate prerequisites for MS2 spectral search 5688 if ms2_spectral_search: 5689 if spectral_lib is None: 5690 raise ValueError( 5691 "MS2 spectral search requires spectral_lib to be provided. " 5692 "Create it using MSPInterface.get_metabolomics_spectra_library() before calling this method." 5693 ) 5694 # Check if mass features will be loaded OR are already loaded 5695 # (The operation's can_execute will check if MS2 spectra are actually present) 5696 if not load_representatives and not perform_gap_filling: 5697 # Check if at least one sample has mass features loaded 5698 # This allows MS2 search on already-loaded features 5699 has_loaded_features = any( 5700 len(self[i].mass_features) > 0 if hasattr(self[i], 'mass_features') and self[i].mass_features is not None else False 5701 for i in range(len(self.samples)) 5702 ) 5703 if not has_loaded_features: 5704 raise ValueError( 5705 "MS2 spectral search requires mass features to be loaded. " 5706 "Either set load_representatives=True or perform_gap_filling=True to load them, " 5707 "or load them in a previous call to process_consensus_features() before calling " 5708 "with ms2_spectral_search=True." 5709 ) 5710 5711 # Build pipeline 5712 operations = [] 5713 5714 if perform_gap_filling: 5715 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5716 operations.append(GapFillOperation('gap_fill', expand_on_miss=expand_on_miss)) 5717 5718 if load_representatives: 5719 operations.append(ReloadFeaturesOperation( 5720 'reload', 5721 add_ms1=add_ms1, 5722 add_ms2=add_ms2, 5723 auto_process_ms2=add_ms2, # Auto-process MS2 if add_ms2 is enabled 5724 ms2_scan_filter=ms2_scan_filter 5725 )) 5726 5727 if molecular_formula_search: 5728 operations.append(MolecularFormulaSearchOperation('mf_search')) 5729 5730 if ms2_spectral_search: 5731 operations.append(MS2SpectralSearchOperation( 5732 'ms2_search', 5733 ms2_scan_filter=ms2_scan_filter 5734 )) 5735 # Store spectral library and metadata for runtime preparation 5736 self._spectral_lib = spectral_lib 5737 self._spectral_search_molecular_metadata = molecular_metadata 5738 5739 if gather_eics: 5740 operations.append(LoadEICsOperation('load_eics')) 5741 5742 # Execute pipeline (description auto-generated from operations) 5743 results = self.process_samples_pipeline( 5744 operations, 5745 keep_raw_data=keep_raw_data, 5746 show_progress=show_progress 5747 ) 5748 5749 # Store molecular metadata if spectral search was performed 5750 if ms2_spectral_search and hasattr(self, '_spectral_search_molecular_metadata'): 5751 # This allows users to access the metadata for reporting 5752 self.spectral_search_molecular_metadata = self._spectral_search_molecular_metadata 5753 # Post-processing 5754 if perform_gap_filling: 5755 # Combine induced mass features into dataframe 5756 self._combine_mass_features(induced_features=True) 5757 # Mark that gap-filling has been performed 5758 self.missing_mass_features_searched = True 5759 5760 # Add ._eic_mz to induced_mass_features_dataframe if it exists 5761 if self.induced_mass_features_dataframe is not None and len(self.induced_mass_features_dataframe) > 0: 5762 eics_mz = [] 5763 for i, row in self.induced_mass_features_dataframe.iterrows(): 5764 sample_id = row['sample_id'] 5765 sample = self[sample_id] 5766 if row['mf_id'] in sample.induced_mass_features.keys(): 5767 eic_mz = sample.induced_mass_features[row['mf_id']]._eic_mz 5768 eics_mz.append(eic_mz) 5769 else: 5770 eics_mz.append(None) 5771 self.induced_mass_features_dataframe['_eic_mz'] = eics_mz 5772 5773 # Clear mass features from samples to free memory 5774 for sample_name in self.samples: 5775 self._lcms[sample_name].induced_mass_features = {} 5776 5777 # Associate EICs with mass features if they were loaded 5778 # This must happen after all operations complete to work on the actual sample objects 5779 if gather_eics: 5780 print("\nAssociating EICs with mass features:") 5781 from tqdm import tqdm 5782 5783 for sample_id in tqdm(range(len(self.samples)), unit="sample", ncols=80): 5784 sample = self[sample_id] 5785 if sample.eics: # Only if EICs were loaded 5786 # Associate EICs with regular mass features 5787 sample.associate_eics_with_mass_features(induced=False) 5788 # Associate EICs with induced mass features 5789 sample.associate_eics_with_mass_features(induced=True) 5790 5791 return results
Methods for performing calculations related to LCMSCollection objects.
Notes
This class is intended as a mixin for the LCMSCollection class.
2751 def clean_sparse_matrix(self, sparse_matrix): 2752 """Clean a sparse matrix by removing duplicates and sorting. 2753 2754 Parameters 2755 ---------- 2756 sparse_matrix : :obj:`~numpy.array` 2757 A sparse matrix to clean. 2758 2759 Returns 2760 ------- 2761 :obj:`~numpy.array` 2762 A cleaned sparse matrix. 2763 """ 2764 for match in sparse_matrix: 2765 match.sort() 2766 sparse_matrix.sort() 2767 dereplicated_sparse_matrix = np.unique(sparse_matrix, axis=0) 2768 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.
2770 def match_mfs(self, mf_c, mf_i): 2771 """Match mass features between two LCMS objects. 2772 2773 Parameters 2774 ---------- 2775 mf_c : :obj:`~pandas.DataFrame` 2776 The mass features to match against. 2777 mf_i : :obj:`~pandas.DataFrame` 2778 The mass features to match. 2779 2780 Returns 2781 ------- 2782 :obj:`~pandas.DataFrame` 2783 The matched mass features from mf_c. 2784 :obj:`~pandas.DataFrame` 2785 The matched mass features from mf_i. 2786 2787 Notes 2788 ----- 2789 This function has been adapted from the original implementation in the Deimos package: 2790 https://github.com/pnnl/deimos 2791 """ 2792 if mf_c is None or mf_i is None or len(mf_c.index) < 1 or len(mf_i.index) < 1: 2793 return None, None 2794 2795 # Prepare dataframes 2796 mf_c = mf_c.copy() 2797 mf_c["id_i"] = 0 2798 mf_i = mf_i.copy() 2799 mf_i["id_i"] = 1 2800 2801 # Set dimensions for matching 2802 dims = ["mz", "scan_time"] 2803 relative = [True, False] 2804 mz_tol = self.parameters.lcms_collection.alignment_mz_tol_ppm * 1e-6 2805 rt_tol = self.parameters.lcms_collection.alignment_rt_tol 2806 tol = [mz_tol, rt_tol] 2807 2808 # Compute inter-feature distances 2809 idx = [] 2810 for i, f in enumerate(dims): 2811 # vectors 2812 v1 = mf_c[f].values.reshape(-1, 1) 2813 v2 = mf_i[f].values.reshape(-1, 1) 2814 2815 # Distances 2816 d = scipy.spatial.distance.cdist(v1, v2) 2817 2818 if relative[i] is True: 2819 # Divisor 2820 basis = np.repeat(v1, v2.shape[0], axis=1) 2821 fix = np.repeat(v2, v1.shape[0], axis=1).T 2822 basis = np.where(basis == 0, fix, basis) 2823 2824 # Divide 2825 d = np.divide(d, basis, out=np.zeros_like(basis), where=basis != 0) 2826 2827 # Check tol 2828 idx.append(d <= tol[i]) 2829 2830 # Stack truth arrays 2831 idx = np.prod(np.dstack(idx), axis=-1, dtype=bool) 2832 2833 # Compute normalized 3d distance 2834 v1 = mf_c[dims].values / tol 2835 v2 = mf_i[dims].values / tol 2836 dist3d = scipy.spatial.distance.cdist(v1, v2, "cityblock") 2837 2838 # Separate features within tolerance from those outside 2839 # Features outside tolerance should be inf, features within tolerance keep their distance 2840 # Use idx mask: True for within tolerance, False for outside 2841 dist3d_within_tol = np.where(idx, dist3d, np.inf) 2842 2843 # Normalize to 0-1 (only affects within-tolerance distances) 2844 mx = np.max(dist3d_within_tol[idx]) if np.sum(idx) > 0 else 0 2845 if mx > 0: 2846 # Lower distance is better - normalize only the within-tolerance values 2847 dist3d_within_tol = np.where(idx, dist3d_within_tol / mx, np.inf) 2848 else: 2849 # All matches are perfect (distance=0), assign tiny value to within-tolerance pairs 2850 dist3d_within_tol = np.where(idx, 1e-10, np.inf) 2851 2852 # Use the masked distance matrix 2853 dist3d = dist3d_within_tol 2854 2855 # Min over dims 2856 mincols = np.min(dist3d, axis=0, keepdims=True) 2857 2858 # Zero out mincols over dims 2859 dist3d[dist3d != mincols] = np.inf 2860 2861 # Min over clusters 2862 minrows = np.min(dist3d, axis=1, keepdims=True) 2863 2864 # Where max and nonzero 2865 ii, jj = np.where((dist3d == minrows) & (dist3d < np.inf)) 2866 2867 # Reorder 2868 mf_c = mf_c.iloc[ii] 2869 mf_i = mf_i.iloc[jj] 2870 2871 if len(mf_c.index) < 1 or len(mf_i.index) < 1: 2872 return None, None 2873 2874 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
2876 def fit_rts(self, a, b, align="scan_time", **kwargs): 2877 """ 2878 Fit a support vector regressor to matched features. 2879 2880 Parameters 2881 ---------- 2882 a : :obj:`~pandas.DataFrame` 2883 First set of input feature coordinates and intensities; the center object and the object to align to. 2884 b : :obj:`~pandas.DataFrame` 2885 Second set of input feature coordinates and intensities; the object to align to the center object. 2886 align : str 2887 Dimension to align. 2888 kwargs 2889 Keyword arguments for support vector regressor 2890 (:class:`sklearn.svm.SVR`). 2891 2892 Returns 2893 ------- 2894 :obj:`~function` 2895 An interpolation function where one can input a retention time and get the predicted retention time. 2896 2897 Notes 2898 ----- 2899 This function has been adapted from the original implementation in the Deimos package: 2900 https://github.com/pnnl/deimos 2901 2902 """ 2903 2904 # Uniqueify 2905 x = a[align].values 2906 y = b[align].values 2907 arr = np.vstack((x, y)).T 2908 arr = np.unique(arr, axis=0) 2909 2910 # Safety check: ensure we have data to work with 2911 if len(arr) == 0: 2912 warnings.warn("No data points available for retention time fitting. Returning identity function.") 2913 return lambda x: x 2914 2915 # Check kwargs 2916 if "kernel" in kwargs: 2917 kernel = kwargs.get("kernel") 2918 else: 2919 kernel = "linear" 2920 2921 # Construct interpolation axis 2922 newx = np.linspace(arr[:, 0].min(), arr[:, 0].max(), 1000) 2923 2924 # Linear kernel 2925 if kernel == "linear": 2926 reg = scipy.stats.linregress(x, y) 2927 newy = reg.slope * newx + reg.intercept 2928 2929 # Other kernels 2930 else: 2931 # Fit 2932 svr = SVR(**kwargs) 2933 svr.fit(arr[:, 1].reshape(-1, 1), arr[:, 0]) 2934 2935 # Predict 2936 newy = svr.predict(newx.reshape(-1, 1)) 2937 2938 # Pad x and y_pred with zeros to force interpolation to start at 0 2939 newx = np.concatenate(([0], newx)) 2940 newy = np.concatenate(([0], newy)) 2941 2942 # 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 2943 max_time = self[0].scan_df["scan_time"].max() 2944 newx = np.concatenate((newx, [max_time])) 2945 newy = np.concatenate((newy, [max_time])) 2946 2947 # Return an interpolation function for the x and y_pred 2948 def interp(x): 2949 pred_y = np.interp(x, newx, newy) 2950 return pred_y 2951 2952 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
2954 def get_anchor_mass_features(self, mf_df): 2955 """ 2956 Get the anchor mass features from a DataFrame of mass features. 2957 2958 Parameters 2959 ---------- 2960 mf_df : :obj:`~pandas.DataFrame` 2961 The mass features to filter to just the anchor mass features. 2962 2963 Returns 2964 ------- 2965 :obj:`~pandas.DataFrame` 2966 The anchor mass features dataframe. 2967 """ 2968 mf_df = mf_df.copy() 2969 2970 if ( 2971 "deconvoluted_mass_spectra" 2972 in self.parameters.lcms_collection.mass_feature_anchor_technique 2973 ): 2974 # Drop features that are not mass_spectrum_deconvoluted_parent or are NA as mass_spectrum_deconvoluted_parent 2975 mf_df = mf_df.dropna(subset=["mass_spectrum_deconvoluted_parent"]) 2976 mf_df = mf_df[mf_df["mass_spectrum_deconvoluted_parent"]] 2977 2978 if ( 2979 "absolute_intensity" 2980 in self.parameters.lcms_collection.mass_feature_anchor_technique 2981 ): 2982 # Drop features that have an intensity lower than the threshold 2983 threshold = self.parameters.lcms_collection.mass_feature_anchor_absolute_intensity_threshold 2984 mf_df = mf_df[mf_df["intensity"] > threshold] 2985 2986 if ( 2987 "relative_intensity" 2988 in self.parameters.lcms_collection.mass_feature_anchor_technique 2989 ): 2990 # Drop features in the lower fraction of intensities 2991 threshold_quantile = self.parameters.lcms_collection.mass_feature_anchor_relative_intensity_threshold 2992 intensity_threshold = mf_df["intensity"].quantile(threshold_quantile) 2993 mf_df = mf_df[mf_df["intensity"] >= intensity_threshold] 2994 2995 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.
2997 def attempt_alignment(self, matches_c, matches_i): 2998 """ 2999 Check if alignment is needed for the LCMS objects in the collection. 3000 """ 3001 3002 # Hold out a subset of matches_c and matches_i for spline fitting 3003 matches_c.reset_index(drop=False, inplace=True) 3004 matches_i.reset_index(drop=False, inplace=True) 3005 3006 # Check if there are enough matches to attempt alignment 3007 minimum_matches = self.parameters.lcms_collection.alignment_minimum_matches 3008 if len(matches_c) < minimum_matches: 3009 # Return False (no alignment) and identity function (returns original time) 3010 # which isn't used but is a placeholder to avoid errors in downstream code since 3011 # the function expects a callable to be returned 3012 return False, lambda x: x 3013 3014 # Rearrange matches_c and matches_i to be in the order of the scan_time of matches_c 3015 matches_c = matches_c.sort_values(by="scan_time") 3016 matches_i = matches_i.iloc[matches_c.index.values] 3017 3018 hold_out_fraction = self.parameters.lcms_collection.alignment_hold_out_fraction 3019 # starting with an array of length len(matches_c), select equally spaced indices to hold out 3020 idx_holdout = matches_c.index.values[ 3021 np.arange(0, len(matches_c), int(1 / hold_out_fraction)) 3022 ] 3023 3024 matches_c_holdout = matches_c.loc[idx_holdout].copy() 3025 matches_i_holdout = matches_i.loc[idx_holdout].copy() 3026 3027 # Remove the holdout matches from the matches_c and matches_i DataFrames and reset the index 3028 matches_c = matches_c.drop(index=idx_holdout).set_index("sample_name") 3029 matches_i = matches_i.drop(index=idx_holdout).set_index("sample_name") 3030 3031 # Reset the scan_time to the original scan_time 3032 matches_i = matches_i.copy() 3033 matches_i["scan_time"] = matches_i["scan_time_og"] 3034 3035 # Fit the retention times of the LCMS object to the center LCMS object using the matched mass features 3036 spl = self.fit_rts(matches_c, matches_i, kernel="rbf", C=1000) 3037 3038 # Check if the spline fitting improved the alignment for the holdout matches 3039 matches_i_holdout["scan_time_fit"] = spl(matches_i_holdout["scan_time"]) 3040 og_diff = np.abs( 3041 matches_i_holdout["scan_time"] - matches_c_holdout["scan_time"] 3042 ) 3043 fit_diff = np.abs( 3044 matches_i_holdout["scan_time_fit"] - matches_c_holdout["scan_time"] 3045 ) 3046 3047 if ( 3048 "fraction_improved" 3049 in self.parameters.lcms_collection.alignment_acceptance_technique 3050 ): 3051 fraction_improved = np.sum(fit_diff < og_diff) / len(og_diff) 3052 use_spline_alignment = ( 3053 fraction_improved 3054 > self.parameters.lcms_collection.alignment_acceptance_fraction_improved_threshold 3055 ) 3056 if ( 3057 "mean_squared_error_improved" 3058 in self.parameters.lcms_collection.alignment_acceptance_technique 3059 ): 3060 mse_og = np.mean(og_diff**2) 3061 mse = np.mean(fit_diff**2) 3062 use_spline_alignment = mse < mse_og 3063 # Convert to boolean 3064 use_spline_alignment = bool(use_spline_alignment) 3065 3066 return use_spline_alignment, spl
Check if alignment is needed for the LCMS objects in the collection.
3068 def align_lcms_objects(self, overwrite=False): 3069 """ 3070 Align LCMS objects in the collection. 3071 3072 Aligns the LCMS objects in the collection by aligning the retention times of the mass features in the LCMS objects. 3073 First, the mass features in the center LCMS object are matched to the mass features in the other LCMS objects, 3074 starting with the LCMS object immediately following the center LCMS object. The retention times of the LCMS objects 3075 are then fit to the center LCMS object using the matched mass features. 3076 3077 Returns 3078 ------- 3079 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. 3080 3081 Notes 3082 ----- 3083 This function has been adapted from the original implementation in the Deimos package: 3084 https://github.com/pnnl/deimos 3085 """ 3086 3087 # Prepare the center LCMS object 3088 center_obj_ids = self.manifest_dataframe[ 3089 self.manifest_dataframe["center"] 3090 ].collection_id.values 3091 3092 full_mf_df = self.mass_features_dataframe 3093 # re-index to sample_name for faster lookups 3094 full_mf_df = full_mf_df.reset_index().set_index("sample_name") 3095 samples_with_features = set(full_mf_df.index.get_level_values("sample_name")) 3096 3097 if "scan_time_aligned" in full_mf_df.columns and not overwrite: 3098 raise ValueError("Mass features have already been aligned") 3099 3100 def _set_scan_time_alignment_for_sample(sample_idx, use_alignment, spline): 3101 """Set scan_time_aligned for one sample using spline or identity mapping.""" 3102 if use_alignment and spline is not None: 3103 self[sample_idx]._scan_info["scan_time_aligned"] = { 3104 k: spline(v) for k, v in self[sample_idx]._scan_info["scan_time"].items() 3105 } 3106 return True 3107 3108 self[sample_idx]._scan_info["scan_time_aligned"] = self[sample_idx]._scan_info[ 3109 "scan_time" 3110 ].copy() 3111 return False 3112 3113 def _get_feature_df_at_or_after(start_idx, index_step, use_alignment, spline): 3114 """Return next sample index/dataframe with features, aligning empty samples on the way.""" 3115 i = start_idx 3116 while 0 <= i < len(self): 3117 sample_name = self.samples[i] 3118 if sample_name in samples_with_features: 3119 mf_df_i = full_mf_df.loc[sample_name].copy() 3120 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3121 mf_df_i = mf_df_i.reset_index(drop=False) 3122 if use_alignment and spline is not None: 3123 # Use previous step transform as a better matching starting point. 3124 mf_df_i["scan_time"] = spline(mf_df_i["scan_time"]) 3125 return i, mf_df_i 3126 3127 _set_scan_time_alignment_for_sample(i, use_alignment, spline) 3128 self.rt_alignment_attempted = True 3129 i += index_step 3130 3131 return i, None 3132 3133 anchor_mf_dfs = [] 3134 for center_obj_id in center_obj_ids: 3135 # Get the anchor mass features from the center LCMS object 3136 mf_df_c = full_mf_df.loc[self.samples[center_obj_id]] 3137 mf_df_c = self.get_anchor_mass_features(mf_df_c) 3138 anchor_mf_dfs.append(mf_df_c) 3139 3140 # Set scan_time_aligned to scan_time for the center LCMS object 3141 center_scan_df = self[center_obj_id].scan_df.copy() 3142 center_scan_df["scan_time_aligned"] = center_scan_df["scan_time"] 3143 self[center_obj_id].scan_df = center_scan_df 3144 3145 # Store alignment data for center object (identity mapping) 3146 center_sample_name = self.samples[center_obj_id] 3147 3148 index_steps = (1, -1) 3149 # Run this twice, once going forward (+1 indexing) and once going backward (-1 indexing) 3150 for index_step in index_steps: 3151 # Initialize spline for propagation to samples without features 3152 spl = None 3153 use_spline_alignment = False 3154 3155 # Loop through the other LCMS objects in this direction. 3156 i, mf_df_i = _get_feature_df_at_or_after( 3157 center_obj_id + index_step, 3158 index_step, 3159 use_spline_alignment, 3160 spl, 3161 ) 3162 3163 while mf_df_i is not None: 3164 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3165 3166 # Match the mass features in the LCMS object to the anchor mass features in the center LCMS object. 3167 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3168 3169 if matches_c is not None: 3170 use_spline_alignment, spl = self.attempt_alignment( 3171 matches_c, matches_i 3172 ) 3173 3174 # Record if we used alignment for this sample 3175 sample_name = self.samples[i] 3176 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3177 use_spline_alignment 3178 ) 3179 3180 if use_spline_alignment: 3181 # Set new retention times on scan_df for lc_obj using the spline fitting 3182 matches_i["scan_time_fit"] = spl(matches_i["scan_time"]) 3183 3184 self.rt_aligned = _set_scan_time_alignment_for_sample( 3185 i, use_spline_alignment, spl 3186 ) 3187 self.rt_alignment_attempted = True 3188 3189 i, mf_df_i = _get_feature_df_at_or_after( 3190 i + index_step, 3191 index_step, 3192 use_spline_alignment, 3193 spl, 3194 ) 3195 else: 3196 # If no matches are found, propagate prior alignment from this index step. 3197 sample_name = self.samples[i] 3198 used_previous_alignment = use_spline_alignment and spl is not None 3199 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3200 used_previous_alignment 3201 ) 3202 3203 self.rt_aligned = _set_scan_time_alignment_for_sample( 3204 i, used_previous_alignment, spl 3205 ) 3206 self.rt_alignment_attempted = True 3207 3208 i, mf_df_i = _get_feature_df_at_or_after( 3209 i + index_step, 3210 index_step, 3211 used_previous_alignment, 3212 spl, 3213 ) 3214 3215 # Now align each batch using the center objects as anchors with the other batches 3216 mf_df_c = anchor_mf_dfs[0] 3217 for i in center_obj_ids[1:]: 3218 mf_df_i = full_mf_df.loc[self.samples[i]].copy() 3219 mf_df_i["scan_time_og"] = mf_df_i["scan_time"] 3220 mf_df_i = self.get_anchor_mass_features(mf_df_i) 3221 3222 matches_c, matches_i = self.match_mfs(mf_df_c, mf_df_i) 3223 if matches_c is not None: 3224 use_spline_alignment, spl = self.attempt_alignment(matches_c, matches_i) 3225 3226 # Record if we used alignment for this sample 3227 sample_name = self.samples[i] 3228 self._manifest_dict[sample_name]["use_rt_alignment"] = ( 3229 use_spline_alignment 3230 ) 3231 3232 if use_spline_alignment: 3233 # Set new retention times on all this object's 3234 new_times = spl(self[i].scan_df["scan_time"]) 3235 new_scan_info = self[i].scan_df.copy() 3236 new_scan_info["scan_time_aligned"] = new_times 3237 self[i].scan_df = new_scan_info 3238 3239 3240 # Get the batch that this object belongs to 3241 batch = self.manifest[self.samples[i]]["batch"] 3242 3243 for j in range(len(self)): 3244 if self.manifest[self.samples[j]]["batch"] == batch: 3245 if j != i: 3246 sample_name_j = self.samples[j] 3247 self._manifest_dict[sample_name_j]["use_rt_alignment"] = ( 3248 use_spline_alignment 3249 ) 3250 new_scan_info = self[j].scan_df.copy() 3251 aligned_times = spl(self[j].scan_df["scan_time_aligned"]) 3252 new_scan_info["scan_time_aligned"] = aligned_times 3253 self[j].scan_df = new_scan_info 3254 3255 # Set final mass_features_dataframe with the aligned scan_time 3256 center_sample_name = self.samples[center_obj_ids[0]] 3257 self._manifest_dict[center_sample_name]["use_rt_alignment"] = False 3258 new_scan_info = self[center_obj_ids[0]].scan_df.copy() 3259 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
3261 def add_consensus_mass_features(self): 3262 """ 3263 Create consensus mass features by clustering aligned features across samples. 3264 3265 This method clusters mass features from all samples in the collection based on 3266 their m/z and aligned retention time proximity. Features that cluster together 3267 across samples are assigned a common cluster ID, creating consensus features 3268 that represent the same compound detected across multiple samples. 3269 3270 The clustering process: 3271 1. Partitions features by m/z to avoid large sparse matrices and enable parallelization 3272 2. Clusters features within each partition using hierarchical clustering 3273 3. Merges partition-boundary clusters that represent the same feature 3274 4. Filters out clusters not present in minimum fraction of samples 3275 3276 Must be run after align_lcms_objects(). Results are stored in the 3277 mass_features_dataframe with a 'cluster' column added. 3278 3279 Parameters 3280 ---------- 3281 None 3282 Uses parameters from self.parameters.lcms_collection: 3283 - consensus_mz_tol_ppm: m/z tolerance for clustering (ppm) 3284 - consensus_rt_tol: retention time tolerance for clustering (minutes) 3285 - consensus_partition_size: target partition size for managing memory and parallelization 3286 - consensus_min_sample_fraction: minimum fraction of samples a cluster 3287 must appear in to be retained (0-1) 3288 - cores: number of CPU cores to use for parallel partition processing 3289 3290 Returns 3291 ------- 3292 None 3293 Updates self.mass_features_dataframe in place by adding 'cluster' column 3294 and filtering to retain only clusters meeting minimum sample presence. 3295 3296 Raises 3297 ------ 3298 ValueError 3299 If mass features have not been aligned (run align_lcms_objects() first). 3300 3301 Notes 3302 ----- 3303 - Partitioning prevents memory issues with large sparse distance matrices 3304 - Each partition is processed in parallel (up to cores limit) 3305 - Clusters not meeting consensus_min_sample_fraction are automatically removed 3306 - Access cluster_summary_dataframe property for summary statistics 3307 - Use fill_missing_cluster_features() for gap-filling after clustering 3308 3309 See Also 3310 -------- 3311 align_lcms_objects : Aligns retention times before consensus clustering 3312 cluster_summary_dataframe : Property that generates summary statistics for clusters 3313 fill_missing_cluster_features : Gap-fill missing features in clusters 3314 """ 3315 # Get the combined mass features from all LCMS objects, keep the original index as a separate column 3316 combined_mfs = self.mass_features_dataframe.copy() 3317 combined_mfs["coll_mf_id"] = combined_mfs.index 3318 3319 # Check if the mass features have been aligned 3320 if "scan_time_aligned" not in combined_mfs.columns: 3321 raise ValueError( 3322 "Mass features have not been aligned, run align_lcms_objects() first" 3323 ) 3324 3325 # Partition the mass features by mz so we can parallelize the matching before clustering 3326 from corems.chroma_peak.calc import subset as corems_subset 3327 3328 # get max mz from combined_mfs and calculate tolerance from ppm 3329 mz_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 3330 n_partition_size = self.parameters.lcms_collection.consensus_partition_size 3331 lazy_partitions = corems_subset.multi_sample_partition( 3332 combined_mfs, 3333 split_on="mz", 3334 size=n_partition_size, 3335 tol=mz_tol, 3336 relative=True, 3337 ) 3338 3339 # If any of lazy_partitions._counts is 2xn_partition_size, issue a warning 3340 if np.array(lazy_partitions._counts).max() > 2 * n_partition_size: 3341 warnings.warn( 3342 "Some partitions are larger than 2x the goal partition size. Consider increasing the partition or decreasing the mz_tol." 3343 ) 3344 3345 # Cluster the mass features within each partition 3346 if self.parameters.lcms_collection.cores > lazy_partitions.n_partitions: 3347 cores_to_use = lazy_partitions.n_partitions 3348 else: 3349 cores_to_use = self.parameters.lcms_collection.cores 3350 # mfs_with_clusters = lazy_partitions.map(self.cluster_mass_features, processes=cores_to_use) 3351 mfs_with_clusters = lazy_partitions.map( 3352 self.cluster_mass_features_agg_cluster, processes=cores_to_use 3353 ) 3354 3355 # Clean up cluster id names after partitioning 3356 new_cluster_ids = ( 3357 mfs_with_clusters[["cluster", "partition_idx"]] 3358 .drop_duplicates() 3359 .reset_index(drop=True) 3360 ) 3361 new_cluster_ids["cluster_unqiue"] = new_cluster_ids.index 3362 mfs_with_clusters = mfs_with_clusters.merge( 3363 new_cluster_ids, on=["cluster", "partition_idx"] 3364 ) 3365 mfs_with_clusters["cluster"] = mfs_with_clusters["cluster_unqiue"] 3366 mfs_with_clusters = mfs_with_clusters.drop(columns=["cluster_unqiue"]) 3367 3368 # Embed a new cluster id into the mass features dataframe and set as index 3369 mfs_with_clusters["idx"] = mfs_with_clusters.index 3370 3371 try: 3372 # Check if any clusters can be merged into a single cluster 3373 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3374 3375 # Merge clusters identified in eval_dict 3376 while len(eval_dict["merge_these_clusters"]) > 0: 3377 list_of_clusters_to_merge = [ 3378 [x[0], x[1]] for x in eval_dict["merge_these_clusters"] 3379 ] 3380 # Convert to a dataframe with columns "new_cluster" and "cluster" 3381 df = pd.DataFrame( 3382 np.array(list_of_clusters_to_merge), columns=["new_cluster", "cluster"] 3383 ) 3384 # Drop duplicates of "child" clusters 3385 df = df.drop_duplicates("cluster", keep="first") 3386 df = df.drop_duplicates("new_cluster", keep="first") 3387 mfs_with_clusters = mfs_with_clusters.merge(df, on="cluster", how="left") 3388 mfs_with_clusters["cluster"] = mfs_with_clusters["new_cluster"].fillna( 3389 mfs_with_clusters["cluster"] 3390 ) 3391 mfs_with_clusters = mfs_with_clusters.drop(columns=["new_cluster"]) 3392 3393 # Re-evaluate clusters for repeats 3394 eval_dict = self.evaluate_clusters_for_repeats(mfs_with_clusters) 3395 self.mass_features_dataframe = mfs_with_clusters 3396 3397 except: 3398 mfs_with_clusters.set_index('coll_mf_id', inplace = True) 3399 self.mass_features_dataframe = mfs_with_clusters 3400 3401 # Filter out clusters that don't meet minimum sample fraction 3402 self._filter_clusters_by_sample_presence() 3403 3404 # 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
3451 def summarize_clusters(self): 3452 """ 3453 Generate summary statistics for consensus mass feature clusters. 3454 3455 Computes aggregate statistics (median, mean, std, min, max) for each cluster 3456 across all samples. Combines both regular mass features and induced mass features 3457 (from gap-filling) when available to provide complete cluster statistics. 3458 3459 Must be run after add_consensus_mass_features() which creates the cluster assignments. 3460 Results are stored in cluster_summary_dataframe property and used by plotting methods. 3461 3462 Parameters 3463 ---------- 3464 None 3465 Operates on self.mass_features_dataframe and self.induced_mass_features_dataframe. 3466 Both must contain 'cluster' column. 3467 3468 Returns 3469 ------- 3470 :obj:`~pandas.DataFrame` or None 3471 DataFrame with one row per cluster containing summary statistics: 3472 - cluster: cluster ID 3473 - mz_{median,mean,std,max,min}: m/z statistics 3474 - scan_time_aligned_{median,mean,std,max,min}: aligned RT statistics 3475 - half_height_width_{median,mean,std,max,min}: peak width statistics 3476 - tailing_factor_{median,mean,std,max,min}: peak shape statistics 3477 - dispersity_index_{median,mean,std,max,min}: peak quality statistics 3478 - sample_id_nunique: number of unique samples containing the cluster 3479 - intensity_{max,median,mean,std,min}: intensity statistics 3480 - persistence_{max,median,mean,std,min}: persistence statistics 3481 3482 Returns None if mass_features_dataframe is empty. 3483 3484 Notes 3485 ----- 3486 - Summary DataFrame is automatically stored in cluster_summary_dataframe property 3487 - Includes both regular and induced (gap-filled) mass features when available 3488 - Used by plotting methods: plot_consensus_mz_features, plot_mz_features_per_cluster 3489 - Sample count (sample_id_nunique) indicates cluster prevalence across samples 3490 - Filters applied by consensus_min_sample_fraction affect which clusters appear 3491 3492 See Also 3493 -------- 3494 add_consensus_mass_features : Creates clusters before summarization 3495 fill_missing_cluster_features : Creates induced mass features via gap-filling 3496 plot_consensus_mz_features : Visualizes cluster summaries 3497 plot_mz_features_per_cluster : Shows cluster size distribution 3498 """ 3499 # First check if there are minimum columns in the features dataframe 3500 if len(self.mass_features_dataframe.columns) < 1: 3501 return None 3502 3503 # Combine regular and induced mass features 3504 mf_df = self.mass_features_dataframe.copy() 3505 mf_df = mf_df.reset_index(drop=False) 3506 3507 # Check if induced mass features are available and combine them 3508 if self.induced_mass_features_dataframe is not None and len(self.induced_mass_features_dataframe) > 0: 3509 imf_df = self.induced_mass_features_dataframe.copy() 3510 imf_df = imf_df.reset_index(drop=False) 3511 # Cluster column extracted from mf_id in _prepare_lcms_mass_features_for_combination 3512 # Combine regular and induced features 3513 mf_df = pd.concat([mf_df, imf_df], axis=0) 3514 mf_df = mf_df.reset_index(drop=True) 3515 3516 # Filter out any rows with NaN cluster values before converting to int 3517 if 'cluster' in mf_df.columns: 3518 mf_df = mf_df.dropna(subset=['cluster']) 3519 mf_df['cluster'] = mf_df['cluster'].astype(int) 3520 3521 # Build aggregation dictionary based on available columns 3522 agg_dict = { 3523 "mz": ["median", "mean", "std", "max", "min"], 3524 "scan_time_aligned": ["median", "mean", "std", "max", "min"], 3525 "sample_id": ["nunique"], 3526 "intensity": ["max", "median", "mean", "std", "min"], 3527 } 3528 3529 # Add optional columns if they exist 3530 optional_columns = { 3531 "half_height_width": ["median", "mean", "std", "max", "min"], 3532 "tailing_factor": ["median", "mean", "std", "max", "min"], 3533 "dispersity_index": ["median", "mean", "std", "max", "min"], 3534 "persistence": ["max", "median", "mean", "std", "min"], 3535 } 3536 3537 for col, funcs in optional_columns.items(): 3538 if col in mf_df.columns: 3539 agg_dict[col] = funcs 3540 3541 summary_df = ( 3542 mf_df.groupby("cluster") 3543 .agg(agg_dict) 3544 .reset_index() 3545 ) 3546 3547 # Fix the column names 3548 summary_df.columns = [ 3549 "_".join(col).strip() 3550 for col in summary_df.columns.values 3551 if col != "cluster" 3552 ] 3553 summary_df = summary_df.rename(columns={"cluster_": "cluster"}) 3554 # Set cluster as the index for easy lookup 3555 summary_df = summary_df.set_index('cluster') 3556 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
- 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
3558 def plot_mz_features_per_cluster(self, return_fig = False): 3559 """ 3560 Plot the number of mass features in a cluster against how many clusters 3561 contain that number of mass features 3562 3563 Parameters 3564 ----------- 3565 return_fig : boolean 3566 Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. 3567 3568 Returns 3569 -------- 3570 matplotlib.pyplot.Figure 3571 A figure displaying the frequency with which clusters contain the given number of m/z features 3572 3573 Raises 3574 ------ 3575 Warning 3576 If consensus features haven't been added to the object yet 3577 """ 3578 3579 if not hasattr(self, 'cluster_summary_dataframe'): 3580 raise ValueError( 3581 'cluster_summary_dataframe is not set, must run add_consensus_mass_features() first' 3582 ) 3583 else: 3584 sum_data = self.cluster_summary_dataframe 3585 fig, ax = plt.subplots() 3586 sum_data.sample_id_nunique.value_counts().sort_index().plot(ax = ax, kind = 'bar') 3587 plt.xlabel('Number of mass features in a cluster') 3588 plt.ylabel('Number of clusters with this many mass features') 3589 if return_fig: 3590 plt.close(fig) 3591 return fig 3592 else: 3593 plt.show()
Plot the number of mass features in a cluster against how many clusters contain that number of mass features
Parameters
- return_fig (boolean): Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False.
Returns
- matplotlib.pyplot.Figure: A figure displaying the frequency with which clusters contain the given number of m/z features
Raises
- Warning: If consensus features haven't been added to the object yet
3595 def plot_mz_features_across_samples(self, alpha = 0.75, s = 0.005, return_fig = False): 3596 """ 3597 Generate Scan Time vs m/z plot of all the mass features across all 3598 samples in collection where intensity of color on the plot indicates 3599 density of mass features, NOT INTENSITY 3600 3601 Parameters 3602 ----------- 3603 alpha : float 3604 Desired transparency for plotted m/z features. Defaults to 0.75. 3605 s : float 3606 Desired size of plotted m/z features. Defaults to 0.005. 3607 return_fig : boolean 3608 Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. 3609 3610 Returns 3611 -------- 3612 matplotlib.pyplot.Figure 3613 A figure displaying a scan time vs m/z scatterplot of all the m/z features identified in the collection. 3614 Parameters alpha (transparency) and s (marker size) allow the user to emphasize the density of features. 3615 Intensity of features is not represented. 3616 """ 3617 df = self.mass_features_dataframe.copy() 3618 fig = plt.figure() 3619 plt.scatter( 3620 df.scan_time_aligned, 3621 df.mz, 3622 c = 'tab:gray', 3623 alpha = alpha, 3624 s = s 3625 ) 3626 3627 plt.xlabel('Scan time') 3628 plt.ylabel('m/z') 3629 plt.ylim(0, np.ceil(np.max(df.mz))) 3630 plt.xlim(0, np.ceil(np.max(df.scan_time))) 3631 plt.title('All mass features, all samples') 3632 3633 if return_fig: 3634 plt.close(fig) 3635 return fig 3636 else: 3637 plt.show()
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 (boolean): Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False.
Returns
- matplotlib.pyplot.Figure: A figure displaying a scan time vs m/z scatterplot of all the m/z features identified in the collection. Parameters alpha (transparency) and s (marker size) allow the user to emphasize the density of features. Intensity of features is not represented.
3639 def plot_consensus_mz_features(self, xb = 'xb', xt = 'xt', yb = 'yb', yt = 'yt', show_all = True, return_fig = False): 3640 """ 3641 Generate Scan Time vs m/z plot of the consensus features scaled by size 3642 with option ('show_all') of leaving the individual m/z features in the figure. 3643 3644 Parameters 3645 ----------- 3646 xb : float 3647 Desired starting scan time value for the x-axis. Defaults to 0. 3648 xt : float 3649 Desired ending scan time for the x-axis. Defaults to the maximum scan time value in the provided data. 3650 yb : float 3651 Desired starting m/z value for the y-axis. Defaults to 0. 3652 yt : float 3653 Desired ending m/z for the y-axis. Defaults to the maximum m/z value in the provided data. 3654 show_all : boolean 3655 Indicates whether to display all identified m/z features (True) or just the consensus features (False). Defaults to True. 3656 return_fig : boolean 3657 Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False. 3658 3659 Returns 3660 -------- 3661 matplotlib.pyplot.Figure 3662 A scalable figure that overlays the consensus features over all the m/z features identified in the collection. 3663 Consensus features are scaled by how many m/z features are represented in the consensus. Figure can be scaled by 3664 inputting desired boundaries on the scan time (xb, xt) and m/z values (yb, yt). 3665 """ 3666 df = self.cluster_summary_dataframe.copy() 3667 mfdf = self.mass_features_dataframe.copy() 3668 3669 fig = plt.figure() 3670 if show_all: 3671 plt.scatter( 3672 mfdf.scan_time_aligned, 3673 mfdf.mz, 3674 c = 'tab:gray', 3675 s = 1 3676 ) 3677 3678 m = plt.scatter( 3679 df.scan_time_aligned_median, 3680 df.mz_median, 3681 c = 'tab:orange', 3682 alpha = 0.7, 3683 s = (df.sample_id_nunique**2)/5 3684 ) 3685 3686 plt.xlabel('Scan time') 3687 plt.ylabel('m/z') 3688 3689 if xt == 'xt': 3690 xt = np.ceil(np.max(mfdf.mz)) 3691 if yt == 'yt': 3692 yt = np.ceil(np.max(mfdf.scan_time)) 3693 if xb == 'xb': 3694 xb = 0 3695 if yb == 'yb': 3696 yb = 0 3697 plt.ylim(xb, xt) 3698 plt.xlim(yb, yt) 3699 3700 kw = dict( 3701 prop = 'sizes', 3702 num = max(1, int(len(df.sample_id_nunique.unique())/3)), 3703 color = 'tab:orange', 3704 alpha = 0.7, 3705 func = lambda s: np.sqrt(s*5) 3706 ) 3707 3708 plt.legend( 3709 *m.legend_elements(**kw), 3710 title = 'Features\nper cluster', 3711 bbox_to_anchor = (1.01, 0.4, 0.225, 0.5) 3712 ) 3713 plt.tight_layout() 3714 plt.title('Consensus Features') 3715 3716 if return_fig: 3717 plt.close(fig) 3718 return fig 3719 else: 3720 plt.show()
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 (boolean): Indicates whether to plot composite feature map (False) or return figure object (True). Defaults to False.
Returns
- matplotlib.pyplot.Figure: A scalable figure that overlays the consensus features over all the m/z features identified in the collection. 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).
3722 def plot_cluster( 3723 self, 3724 cluster_id, 3725 to_plot=["EIC", "MS1", "MS2"], 3726 return_fig=False, 3727 plot_smoothed_eic=False, 3728 plot_eic_datapoints=False, 3729 eic_buffer_time=None, 3730 label_samples=False, 3731 molecular_metadata=None, 3732 spectral_library=None, 3733 ): 3734 """ 3735 Plot a consensus mass feature cluster across all samples. 3736 3737 Similar to LCMSMassFeature.plot() but shows EICs from all samples in the cluster, 3738 highlighting the representative mass feature. 3739 3740 Parameters 3741 ---------- 3742 cluster_id : int 3743 The cluster ID to plot 3744 to_plot : list, optional 3745 List of strings specifying what to plot: "EIC", "MS1", "MS2", "MS2_mirror". 3746 Default is ["EIC", "MS1", "MS2"]. 3747 return_fig : bool, optional 3748 If True, returns the figure object. Default is False. 3749 plot_smoothed_eic : bool, optional 3750 If True, plots smoothed EICs. Default is False. 3751 plot_eic_datapoints : bool, optional 3752 If True, plots EIC data points. Default is False. 3753 eic_buffer_time : float, optional 3754 Time buffer around the peak for EIC plotting (minutes). 3755 If None, uses parameter setting. Default is None. 3756 label_samples : bool, optional 3757 If True, labels each sample in the legend. Default is False. 3758 molecular_metadata : dict, optional 3759 Dictionary mapping molecular IDs to MetaboliteMetadata objects. 3760 Required for MS2_mirror plots. Default is None. 3761 spectral_library : FlashEntropySearch, optional 3762 FlashEntropy spectral library containing MS2 spectra. 3763 Required for MS2_mirror plots to retrieve library spectra. Default is None. 3764 3765 Returns 3766 ------- 3767 matplotlib.figure.Figure or None 3768 The figure object if return_fig=True, otherwise None 3769 3770 Raises 3771 ------ 3772 ValueError 3773 If cluster_id is not found or if required data is not loaded 3774 """ 3775 import matplotlib.pyplot as plt 3776 3777 # Get cluster summary for median values 3778 if cluster_id not in self.cluster_summary_dataframe.index: 3779 raise ValueError( 3780 f"Cluster {cluster_id} not found in cluster_summary_dataframe. " 3781 f"Run add_consensus_mass_features() first." 3782 ) 3783 3784 cluster_summary = self.cluster_summary_dataframe.loc[cluster_id] 3785 3786 # Get representative mass feature info 3787 rep_info = self.get_most_representative_sample_for_cluster(cluster_id) 3788 rep_sample_id = rep_info['sample_id'] 3789 rep_mf_id = rep_info['mf_id'] 3790 rep_sample = self[rep_sample_id] 3791 3792 # Check if representative mass feature is loaded 3793 if rep_mf_id not in rep_sample.mass_features: 3794 raise ValueError( 3795 f"Representative mass feature {rep_mf_id} not loaded in sample {rep_sample.sample_name}. " 3796 f"Run reload_representative_mass_features() or process_consensus_features() first." 3797 ) 3798 3799 rep_mf = rep_sample.mass_features[rep_mf_id] 3800 3801 # Get eic buffer time 3802 if eic_buffer_time is None: 3803 eic_buffer_time = self[0].parameters.lc_ms.eic_buffer_time 3804 3805 # Adjust to_plot based on available data 3806 if rep_mf.mass_spectrum is None: 3807 to_plot = [x for x in to_plot if x != "MS1"] 3808 if len(rep_mf.ms2_mass_spectra) == 0: 3809 to_plot = [x for x in to_plot if x not in ["MS2", "MS2_mirror"]] 3810 3811 # Check if EICs are available 3812 cluster_mfs = self.mass_features_dataframe[ 3813 self.mass_features_dataframe['cluster'] == cluster_id 3814 ] 3815 3816 has_eics = False 3817 # Check regular features 3818 for _, row in cluster_mfs.iterrows(): 3819 sample_id = int(row['sample_id']) 3820 sample = self[sample_id] 3821 if hasattr(sample, 'eics') and sample.eics: 3822 if len(sample.eics) > 0: 3823 has_eics = True 3824 break 3825 3826 # Also check induced features if available 3827 induced_cluster_mfs = None 3828 if not has_eics and self.induced_mass_features_dataframe is not None: 3829 induced_cluster_mfs = self.induced_mass_features_dataframe[ 3830 self.induced_mass_features_dataframe['cluster'] == cluster_id 3831 ] 3832 for _, row in induced_cluster_mfs.iterrows(): 3833 sample_id = int(row['sample_id']) 3834 sample = self[sample_id] 3835 if hasattr(sample, 'eics') and sample.eics: 3836 if len(sample.eics) > 0: 3837 has_eics = True 3838 break 3839 3840 if not has_eics: 3841 to_plot = [x for x in to_plot if x != "EIC"] 3842 if len(to_plot) == 0: 3843 raise ValueError( 3844 f"No plottable data available for cluster {cluster_id}. " 3845 f"Run process_consensus_features(gather_eics=True, add_ms1=True, add_ms2=True) first." 3846 ) 3847 3848 # Get induced features if not already retrieved 3849 if induced_cluster_mfs is None and self.induced_mass_features_dataframe is not None: 3850 induced_cluster_mfs = self.induced_mass_features_dataframe[ 3851 self.induced_mass_features_dataframe['cluster'] == cluster_id 3852 ] 3853 3854 # Check if MS1 is deconvoluted 3855 deconvoluted = rep_mf._ms_deconvoluted_idx is not None 3856 3857 # Create figure 3858 fig, axs = plt.subplots( 3859 len(to_plot), 1, figsize=(10, len(to_plot) * 4), squeeze=False 3860 ) 3861 3862 fig.suptitle( 3863 f"Consensus Cluster {cluster_id}: " 3864 f"m/z = {cluster_summary['mz_median']:.4f} " 3865 f"(±{cluster_summary['mz_std']:.4f}); " 3866 f"RT = {cluster_summary['scan_time_aligned_median']:.2f} min " 3867 f"(±{cluster_summary['scan_time_aligned_std']:.2f}); " 3868 f"{int(cluster_summary['sample_id_nunique'])} samples" 3869 ) 3870 3871 i = 0 3872 3873 # EIC plot - show all samples using helper method 3874 if "EIC" in to_plot: 3875 self._plot_multiple_eics( 3876 axs[i][0], 3877 cluster_mfs, 3878 induced_cluster_mfs, 3879 rep_sample_id, 3880 rep_mf_id, 3881 cluster_summary['scan_time_aligned_median'], 3882 eic_buffer_time, 3883 plot_smoothed=plot_smoothed_eic, 3884 plot_datapoints=plot_eic_datapoints, 3885 label_samples=label_samples, 3886 lcms_collection=self 3887 ) 3888 i += 1 3889 3890 # MS1 plot - from representative using helper method 3891 if "MS1" in to_plot: 3892 rep_mf._plot_ms1_spectrum( 3893 axs[i][0], 3894 deconvoluted=deconvoluted, 3895 sample_name=rep_sample.sample_name 3896 ) 3897 i += 1 3898 3899 # MS2 plot - from representative using helper method 3900 if "MS2" in to_plot: 3901 rep_mf._plot_ms2_spectrum(axs[i][0], sample_name=rep_sample.sample_name) 3902 i += 1 3903 3904 # MS2 mirror plot - from representative using helper method 3905 if "MS2_mirror" in to_plot: 3906 rep_mf._plot_ms2_mirror(axs[i][0], molecular_metadata=molecular_metadata, spectral_library=spectral_library) 3907 i += 1 3908 3909 plt.tight_layout() 3910 3911 if return_fig: 3912 plt.close(fig) 3913 return fig 3914 else: 3915 plt.show() 3916 return None
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, returns the figure object. Default is False.
- 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
3918 def get_representative_mass_features_for_all_clusters(self, representative_metric=None): 3919 """ 3920 Get the most representative mass feature for all clusters in bulk. 3921 3922 This is much more efficient than calling get_most_representative_sample_for_cluster 3923 in a loop, as it processes all clusters in a single pass over the dataframe. 3924 3925 Parameters 3926 ---------- 3927 representative_metric : str, optional 3928 The metric to use to determine the most representative sample. 3929 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 3930 Options: 3931 - 'intensity': Selects the mass feature with the highest intensity 3932 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 3933 or the highest intensity overall if none have MS2 3934 Default is None (uses parameter setting). 3935 3936 Returns 3937 ------- 3938 :obj:`~pandas.DataFrame` 3939 DataFrame with one row per cluster containing: 3940 - cluster: cluster ID 3941 - sample_id: The sample ID of the most representative sample 3942 - mf_id: The mass feature ID in the sample 3943 - coll_mf_id: The collection-level mass feature ID (index) 3944 - has_ms2: Whether this mass feature has MS2 scan numbers 3945 - intensity: The intensity value of the representative mass feature 3946 """ 3947 # Use default from parameters if not specified 3948 if representative_metric is None: 3949 representative_metric = self.parameters.lcms_collection.consensus_representative_metric 3950 3951 mf_df = self.mass_features_dataframe.copy() 3952 # Reset index to make coll_mf_id a column we can work with 3953 mf_df = mf_df.reset_index(drop=False) 3954 3955 # Handle special metric 'intensity_prefer_ms2' 3956 if representative_metric == 'intensity_prefer_ms2': 3957 if 'intensity' not in mf_df.columns: 3958 raise ValueError( 3959 f"'intensity' column not found in mass_features_dataframe. " 3960 f"Available columns: {mf_df.columns.tolist()}" 3961 ) 3962 3963 # Add has_ms2 flag if ms2_scan_numbers column exists 3964 if 'ms2_scan_numbers' in mf_df.columns: 3965 def has_ms2_scans(val): 3966 if val is None: 3967 return False 3968 try: 3969 return len(val) > 0 3970 except (TypeError, ValueError): 3971 return False 3972 3973 mf_df['has_ms2'] = mf_df['ms2_scan_numbers'].apply(has_ms2_scans) 3974 3975 # Sort by has_ms2 (descending) then intensity (descending) 3976 # This ensures features with MS2 are preferred when intensities are equal 3977 mf_df = mf_df.sort_values(['has_ms2', 'intensity'], ascending=[False, False]) 3978 else: 3979 mf_df['has_ms2'] = False 3980 mf_df = mf_df.sort_values('intensity', ascending=False) 3981 3982 # Group by cluster and take the first (highest intensity, preferring MS2) 3983 representatives = mf_df.groupby('cluster').first().reset_index() 3984 3985 else: 3986 # Standard metric - check if it exists 3987 if representative_metric not in mf_df.columns: 3988 raise ValueError( 3989 f"Metric '{representative_metric}' not found. Available columns: {mf_df.columns.tolist()}" 3990 ) 3991 3992 # Add has_ms2 flag for consistency 3993 if 'ms2_scan_numbers' in mf_df.columns: 3994 def has_ms2_scans(val): 3995 if val is None: 3996 return False 3997 try: 3998 return len(val) > 0 3999 except (TypeError, ValueError): 4000 return False 4001 mf_df['has_ms2'] = mf_df['ms2_scan_numbers'].apply(has_ms2_scans) 4002 else: 4003 mf_df['has_ms2'] = False 4004 4005 # Get the index of max value for each cluster 4006 idx = mf_df.groupby('cluster')[representative_metric].idxmax() 4007 representatives = mf_df.loc[idx].copy() 4008 4009 # Select only the columns we need 4010 result_cols = ['cluster', 'sample_id', 'mf_id', 'coll_mf_id', 'has_ms2', 'intensity'] 4011 representatives = representatives[result_cols] 4012 4013 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
4015 def get_sample_mf_map_for_representatives(self, representative_metric=None, include_cluster_id=True): 4016 """ 4017 Build a mapping of sample_id -> list of representative mass feature IDs to load. 4018 4019 This is a DRY helper method used by both process_consensus_features() and 4020 ReadSavedLCMSCollection to determine which mass features should be loaded 4021 for each sample when loading representatives. 4022 4023 Parameters 4024 ---------- 4025 representative_metric : str, optional 4026 The metric to use to determine the most representative sample. 4027 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4028 Default is None. 4029 include_cluster_id : bool, optional 4030 If True, returns tuples of (mf_id, cluster_id). If False, returns just mf_id. 4031 Default is True. 4032 4033 Returns 4034 ------- 4035 dict 4036 Dictionary mapping sample_id (int) to list of mass feature identifiers. 4037 If include_cluster_id=True: list of tuples (mf_id, cluster_id) 4038 If include_cluster_id=False: list of mf_id integers 4039 4040 Examples 4041 -------- 4042 >>> # Get map with cluster IDs for loading 4043 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives() 4044 >>> # sample_mf_map = {0: [(123, 0), (456, 1)], 1: [(789, 2)], ...} 4045 >>> 4046 >>> # Get map without cluster IDs for pipeline 4047 >>> sample_mf_map = collection.get_sample_mf_map_for_representatives(include_cluster_id=False) 4048 >>> # sample_mf_map = {0: [123, 456], 1: [789], ...} 4049 """ 4050 # Get all representative mass features in bulk (much faster than looping) 4051 representatives = self.get_representative_mass_features_for_all_clusters( 4052 representative_metric=representative_metric 4053 ) 4054 4055 # Build sample_mf_map 4056 sample_mf_map = {} 4057 for _, row in representatives.iterrows(): 4058 sample_id = row['sample_id'] 4059 mf_id = row['mf_id'] 4060 cluster_id = row['cluster'] 4061 4062 if sample_id not in sample_mf_map: 4063 sample_mf_map[sample_id] = [] 4064 4065 if include_cluster_id: 4066 sample_mf_map[sample_id].append((mf_id, cluster_id)) 4067 else: 4068 sample_mf_map[sample_id].append(mf_id) 4069 4070 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], ...}
4072 def get_most_representative_sample_for_cluster(self, cluster_id, representative_metric=None): 4073 """ 4074 Get the most representative sample for a given cluster based on a metric. 4075 4076 Parameters 4077 ---------- 4078 cluster_id : int 4079 The cluster ID to find the representative sample for. 4080 representative_metric : str, optional 4081 The metric to use to determine the most representative sample. 4082 If None, uses the value from self.parameters.lcms_collection.consensus_representative_metric. 4083 Options: 4084 - 'intensity': Selects the mass feature with the highest intensity 4085 - 'intensity_prefer_ms2': Selects the highest intensity feature that has MS2 scans, 4086 or the highest intensity overall if none have MS2 4087 Default is None (uses parameter setting). 4088 4089 Returns 4090 ------- 4091 dict 4092 Dictionary containing: 4093 - 'sample_id': The sample ID of the most representative sample 4094 - 'sample_name': The sample name of the most representative sample 4095 - 'mf_id': The mass feature ID in the sample 4096 - 'coll_mf_id': The collection-level mass feature ID (index) 4097 - 'has_ms2': Whether this mass feature has MS2 scan numbers 4098 - 'intensity': The intensity value of the representative mass feature 4099 4100 Raises 4101 ------ 4102 ValueError 4103 If cluster_id is not found or if representative_metric is not a valid column. 4104 """ 4105 # Use the bulk method to get all representatives, then filter to this cluster 4106 # This follows DRY principle and ensures consistency 4107 all_representatives = self.get_representative_mass_features_for_all_clusters( 4108 representative_metric=representative_metric 4109 ) 4110 4111 # Filter to the requested cluster 4112 cluster_rep = all_representatives[all_representatives['cluster'] == cluster_id] 4113 4114 if len(cluster_rep) == 0: 4115 # Try to provide helpful error message 4116 available_clusters = self.mass_features_dataframe['cluster'].unique() 4117 raise ValueError( 4118 f"Cluster {cluster_id} not found in mass_features_dataframe. " 4119 f"Available clusters: {sorted(available_clusters[:10].tolist())}... " 4120 f"(showing first 10 of {len(available_clusters)} total clusters)" 4121 ) 4122 4123 # Get the representative row (should only be one) 4124 rep_row = cluster_rep.iloc[0] 4125 4126 # Get sample name from sample_id (convert to int for list indexing) 4127 sample_id = int(rep_row['sample_id']) 4128 sample_name = self.samples[sample_id] 4129 4130 return { 4131 'sample_id': sample_id, 4132 'sample_name': sample_name, 4133 'mf_id': rep_row['mf_id'], 4134 'coll_mf_id': rep_row['coll_mf_id'], 4135 'has_ms2': rep_row['has_ms2'], 4136 'intensity': rep_row['intensity'] 4137 }
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.
4139 def reload_representative_mass_features(self, add_ms2=False, auto_process_ms2=True, ms2_spectrum_mode=None, ms2_scan_filter=None): 4140 """ 4141 Reload mass features for all representative samples in the cluster summary. 4142 4143 This method is useful when the collection was loaded with load_light=True, 4144 which stores mass features only in the collection dataframe. This reloads 4145 the specific mass features that are representatives for each cluster, 4146 allowing them to be accessed as LCMSMassFeature objects. 4147 4148 Parameters 4149 ---------- 4150 add_ms2 : bool, optional 4151 If True, also loads and associates MS2 spectra with mass features. Default is False. 4152 auto_process_ms2 : bool, optional 4153 If True and add_ms2=True, auto-processes MS2 spectra. Default is True. 4154 ms2_spectrum_mode : str or None, optional 4155 Spectrum mode for MS2 spectra. If None, determines from parser. Default is None. 4156 ms2_scan_filter : str or None, optional 4157 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 4158 4159 Returns 4160 ------- 4161 dict 4162 Dictionary mapping sample_id to list of reloaded mf_ids. 4163 4164 Raises 4165 ------ 4166 ValueError 4167 If cluster_summary_dataframe is not set (run add_consensus_mass_features first). 4168 4169 Notes 4170 ----- 4171 - Only reloads mass features that are cluster representatives 4172 - Uses get_most_representative_sample_for_cluster() to determine which to reload 4173 - More memory-efficient than reloading all mass features 4174 - Parallelized based on lcms_collection.cores parameter 4175 - MS2 association uses same logic as add_associated_ms2_dda() 4176 4177 See Also 4178 -------- 4179 _reload_sample_mass_features : Low-level method to reload specific mass features 4180 get_most_representative_sample_for_cluster : Gets representative sample for cluster 4181 """ 4182 # Validate prerequisites 4183 if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: 4184 raise ValueError( 4185 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 4186 ) 4187 4188 # Get all representative mass features in bulk (much faster than looping) 4189 representatives = self.get_representative_mass_features_for_all_clusters() 4190 4191 # Build a dictionary of sample_id -> list of mf_ids that are representatives 4192 sample_mf_map = {} 4193 for _, row in representatives.iterrows(): 4194 sample_id = row['sample_id'] 4195 mf_id = row['mf_id'] 4196 4197 if sample_id not in sample_mf_map: 4198 sample_mf_map[sample_id] = [] 4199 sample_mf_map[sample_id].append(mf_id) 4200 4201 # Reload mass features for each sample (parallelized) 4202 if self.parameters.lcms_collection.cores == 1: 4203 # Serial processing 4204 from tqdm import tqdm 4205 for sample_id in tqdm(sample_mf_map.keys(), desc="Reloading representative mass features", unit="sample"): 4206 mf_ids = sample_mf_map[sample_id] 4207 self._reload_sample_mass_features(sample_id, mf_ids_to_load=mf_ids, add_ms2=add_ms2, 4208 auto_process_ms2=auto_process_ms2, ms2_spectrum_mode=ms2_spectrum_mode, 4209 ms2_scan_filter=ms2_scan_filter) 4210 else: 4211 # Parallel processing 4212 import multiprocessing 4213 from tqdm import tqdm 4214 4215 if self.parameters.lcms_collection.cores > len(sample_mf_map): 4216 ncores = len(sample_mf_map) 4217 else: 4218 ncores = self.parameters.lcms_collection.cores 4219 4220 pool = multiprocessing.Pool(ncores) 4221 4222 # Build arguments list for starmap 4223 args_list = [ 4224 (sample_id, sample_mf_map[sample_id], add_ms2, auto_process_ms2, 4225 ms2_spectrum_mode, ms2_scan_filter, False) 4226 for sample_id in sample_mf_map.keys() 4227 ] 4228 4229 # Execute in parallel 4230 mp_result = pool.starmap(self._reload_sample_mass_features, args_list) 4231 pool.close() 4232 pool.join() 4233 4234 # Collect results back into samples 4235 for i, sample_id in enumerate(tqdm(sample_mf_map.keys(), desc="Collecting reloaded mass features", unit="sample")): 4236 self[sample_id].mass_features = mp_result[i] 4237 4238 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
4405 def add_sparse_distance_matrix(self, features): 4406 if features is None: 4407 return None 4408 else: 4409 features = features.copy() 4410 4411 # Parameters for calculating distance between features 4412 dims = ["mz", "scan_time_aligned"] 4413 relative = [True, False] 4414 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4415 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 4416 dist_weight = [1, 1] 4417 4418 # Check that the dimensions and tolerances are the same length 4419 if ( 4420 len(dims) != len(tol) 4421 or len(dims) != len(relative) 4422 or len(dims) != len(dist_weight) 4423 ): 4424 raise ValueError( 4425 "The dimensions, tolerances, relative, dist_weight, and na_allow lists must be the same length" 4426 ) 4427 4428 # Make connectivity matrix for masking within sample mass features 4429 ## Masking matrix cmat will mark all features from the same sample as 0 4430 ## To mask, a matrix can be multiplied by cmat and features from same 4431 ## samples are multiplied by 0, while features from different samples 4432 ## are multiplied by 1 4433 4434 if "sample_id" not in features.columns: 4435 cmat = None 4436 else: 4437 vals = features["sample_id"].values.reshape(-1, 1) 4438 cmat = scipy.spatial.distance.cdist(vals, vals) 4439 # Convert to binary (0 if same sample, 1 if different) 4440 cmat = np.where(cmat == 0, 0, 1) 4441 # Convert to coordinate matrix for sparse operations later 4442 cmat = sparse.coo_matrix(cmat) 4443 4444 # Compute inter-feature distances using sparse matrix approach 4445 distances = None # clear the distances object before starting 4446 for i in range(len(dims)): # iterate through all dimensions to be considered 4447 # Construct k-d tree 4448 values = features[dims[i]].values 4449 4450 tree = KDTree(values.reshape(-1, 1)) 4451 4452 max_tol = tol[i] 4453 if relative[i] is True: 4454 # Maximum absolute tolerance 4455 max_tol = tol[i] * values.max() 4456 4457 # Compute sparse distance matrix 4458 # the larger the max_tol, the slower this operation is 4459 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 4460 4461 # Only consider forward case, exclude diagonal 4462 sdm = sparse.triu(sdm, k=1) 4463 4464 # Filter relative distances 4465 if relative[i] is True: 4466 # Compute relative distances 4467 rel_dists = sdm.data / values[sdm.row] 4468 4469 # Indices of relative distances less than tolerance 4470 idx = rel_dists <= tol[i] 4471 4472 # Reconstruct sparse distance matrix 4473 sdm = sparse.coo_matrix( 4474 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 4475 shape=(len(values), len(values)), 4476 ) 4477 4478 # Scaled distances wrt the maximum tolerance for the dimension 4479 sdm.data = sdm.data / tol[i] 4480 4481 # Stack distances for dimensions where na_allow is False 4482 if distances is None: 4483 sdm.data = sdm.data * dist_weight[i] 4484 # Replace zeros with epsilon to handle perfect matches 4485 sdm.data[sdm.data == 0] = 1e-10 4486 distances = sdm 4487 else: 4488 # Prepare sdm to match shape of existing distances 4489 distances_truth = distances.copy() 4490 # make new sparse matrix with same positions as previous 4491 # distance matrix but all ones for values 4492 distances_truth.data = np.ones_like(distances_truth.data) 4493 4494 # Replace zeros with epsilon BEFORE multiply to prevent sparse matrix from dropping them 4495 sdm.data[sdm.data == 0] = 1e-10 4496 4497 # multiply the new sparse matrix (sdm) by this mask to remove 4498 # data that doesn't exist in original sparse matrix 4499 sdm = distances_truth.multiply(sdm) 4500 4501 sdm.data = sdm.data * dist_weight[i] 4502 # Replace zeros with epsilon to handle perfect matches 4503 sdm.data[sdm.data == 0] = 1e-10 4504 4505 # use same process as before to remove data from previous 4506 # distances matrix that isn't in new distances matrix 4507 sdm_truth = sdm.copy() 4508 sdm_truth.data = np.ones_like(sdm_truth.data) 4509 4510 # remove the distances that are not sdm 4511 distances = distances.multiply(sdm_truth) 4512 4513 # Sum the new distances 4514 distances = distances + sdm 4515 4516 # Multiply by connectivity matrix for more masking 4517 distances = distances.multiply(cmat) 4518 4519 # Set attribute holding distance matrix 4520 self._sparse_distance_matrix = distances
4522 def evaluate_clusters_for_repeats(self, features): 4523 raise NotImplementedError('evaluate_clusters_for_repeats not implemented yet') 4524 summary_df = self.cluster_summary_dataframe.copy() 4525 4526 # Arrange by decreasing median intensity 4527 summary_df = summary_df.sort_values( 4528 by="intensity_median", ascending=False 4529 ).reset_index(drop=True) 4530 4531 # Find clusters that are within the mz_tol and rt_tol of each other (on the medians) 4532 # Create a distance matrix 4533 # Define how to calculate the distance between features 4534 dims = ["mz_median", "scan_time_aligned_median"] 4535 relative = [True, False] 4536 mz_tol_relative = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4537 tol = [mz_tol_relative, self.parameters.lcms_collection.consensus_rt_tol] 4538 4539 # Compute inter-feature distances 4540 distances = None 4541 for i in range(len(dims)): 4542 # Construct k-d tree 4543 values = summary_df[dims[i]].values 4544 tree = KDTree(values.reshape(-1, 1)) 4545 4546 max_tol = tol[i] 4547 if relative[i] is True: 4548 # Maximum absolute tolerance 4549 max_tol = tol[i] * values.max() 4550 4551 # Compute sparse distance matrix 4552 # the larger the max_tol, the slower this operation is 4553 sdm = tree.sparse_distance_matrix(tree, max_tol, output_type="coo_matrix") 4554 4555 # Only consider forward case, exclude diagonal 4556 sdm = sparse.triu(sdm, k=1) 4557 4558 # Filter relative distances 4559 if relative[i] is True: 4560 # Compute relative distances 4561 rel_dists = sdm.data / values[sdm.row] # or col? 4562 4563 # Indices of relative distances less than tolerance 4564 idx = rel_dists <= tol[i] 4565 4566 # Reconstruct sparse distance matrix 4567 sdm = sparse.coo_matrix( 4568 (rel_dists[idx], (sdm.row[idx], sdm.col[idx])), 4569 shape=(len(values), len(values)), 4570 ) 4571 4572 # Cast as binary matrix 4573 sdm.data = np.ones_like(sdm.data) 4574 4575 # Stack distances 4576 if distances is None: 4577 distances = sdm 4578 else: 4579 distances = distances.multiply(sdm) 4580 4581 # Roll up features 4582 # Extract indices of within-tolerance points 4583 distances = distances.tocoo() 4584 pairs = np.stack( 4585 (distances.row, distances.col), axis=1 4586 ) # These are the index values of the clusters, not the cluster ids 4587 # Conver to cluster ids 4588 pairs_df = pd.DataFrame(pairs, columns=["parent", "child"]) 4589 pairs_df["parent"] = summary_df.loc[pairs[:, 0]]["cluster"].values 4590 pairs_df["child"] = summary_df.loc[pairs[:, 1]]["cluster"].values 4591 pairs_df = pairs_df.set_index("parent") 4592 4593 merge_these_clusters = [] 4594 possible_overlaps = [] 4595 root_parents = np.setdiff1d( 4596 np.unique(pairs_df.index.values), np.unique(pairs_df.child.values) 4597 ) 4598 for parent in root_parents: 4599 parent_features = features[features["cluster"] == parent] 4600 children = pairs_df.loc[[parent], "child"].tolist() 4601 for child in children: 4602 overlap = self.check_merge(parent_features, child, features) 4603 if len(overlap) == 0: 4604 merge_these_clusters.append((parent, child, len(overlap))) 4605 else: 4606 possible_overlaps.append((parent, child, len(overlap))) 4607 4608 result_dict = {} 4609 result_dict["merge_these_clusters"] = merge_these_clusters 4610 result_dict["possible_overlaps"] = possible_overlaps 4611 4612 return result_dict
4614 def check_merge(self, parent_features, child, features): 4615 # Grab the features of the parent and children 4616 child_features = features[features["cluster"] == child] 4617 4618 # Check if there is an overlap between mf_coll_id in the parent and child clusters 4619 overlap = np.intersect1d( 4620 parent_features["sample_id"].values, child_features["sample_id"].values 4621 ) 4622 4623 return overlap
4625 def cluster_mass_features_agg_cluster(self, features): 4626 if features is None: 4627 return None 4628 4629 features = features.copy() 4630 4631 self.add_sparse_distance_matrix(features) 4632 4633 distances = self._sparse_distance_matrix 4634 4635 # Convert to full matrix 4636 distances = distances.todense() 4637 4638 # Cast all 0s to 1s for a distance matrix 4639 distances[distances == 0] = 1 4640 distances = np.asarray(distances) 4641 4642 # Perform clustering 4643 try: 4644 clustering = AgglomerativeClustering( 4645 n_clusters=None, 4646 linkage="complete", 4647 # using complete linkage will prevent one sample from being assigned to multiple clusters 4648 metric="precomputed", 4649 distance_threshold=1, 4650 ).fit(distances) 4651 features["cluster"] = clustering.labels_ 4652 4653 # All data points are singleton clusters 4654 except: 4655 features["cluster"] = np.arange(len(features.index)) 4656 4657 return features
4659 def cluster_inspection_plot(self, clu, return_fig = False): 4660 """ 4661 Generate Scan Time vs m/z plot for a narrow range around a given 4662 cluster. This tool is meant to support the user in fine tuning the 4663 tolerances used for the clustering algorithm. The user-provided cluster 4664 ID is highlighted in larger, magenta marker and the ten largest of the 4665 remaining clusters are idenfitied with different colors while the 4666 smallest clusters are light gray. 4667 4668 Parameters 4669 ----------- 4670 clu : integer 4671 A cluster ID that exists in self.mass_features_dataframe 4672 return_fig : boolean 4673 Indicates whether to plot cluster inspection figure (False) or 4674 return figure object (True). Defaults to False. 4675 4676 Returns 4677 -------- 4678 matplotlib.pyplot.Figure 4679 A figure displaying a scan time vs m/z scatterplot of small region 4680 around a given cluster with the ten largest clusters in the region 4681 distinctly identified 4682 4683 Raises 4684 ------ 4685 Warning 4686 If cluster data haven't been added to the object yet 4687 """ 4688 4689 if 'cluster' not in self.mass_features_dataframe.columns: 4690 raise ValueError( 4691 'Cluster information is not yet added to mass_features_dataframe, must run add_consensus_mass_features() first' 4692 ) 4693 4694 else: 4695 mztol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 4696 rttol = self.parameters.lcms_collection.consensus_rt_tol 4697 clu_features = self.mass_features_dataframe.copy() 4698 4699 inclu = clu_features[clu_features.cluster == clu] 4700 exclu = clu_features[clu_features.cluster != clu] 4701 4702 dt_ymin = np.floor(min(inclu.mz)) - 1 4703 dt_ymax = np.ceil(max(inclu.mz)) + 1 4704 dt_xmin = np.floor(min(inclu.scan_time_aligned)) - 1 4705 dt_xmax = np.ceil(max(inclu.scan_time_aligned)) + 1 4706 4707 exclu = exclu[ 4708 ( 4709 exclu.mz.between(dt_ymin, dt_ymax, inclusive = 'both') 4710 ) & ( 4711 exclu.scan_time_aligned.between(dt_xmin, dt_xmax, inclusive = 'both') 4712 ) 4713 ] 4714 4715 bigclulist = list(exclu.cluster.value_counts()[:10].index) 4716 bigclu = exclu[exclu.cluster.isin(bigclulist)] 4717 smclu = exclu[~exclu.cluster.isin(bigclulist)] 4718 4719 colors = np.arange(0, 10) 4720 colordict = dict(zip(bigclulist, colors)) 4721 bigclu['color'] = bigclu.cluster.apply(lambda x: colordict[x]) 4722 4723 fig = plt.figure(figsize = (7.5, 5)) 4724 4725 plt.scatter( 4726 inclu.scan_time_aligned, 4727 inclu.mz, 4728 c = 'm', 4729 s = 3, 4730 label = 'Cluster ' + str(clu) 4731 ) 4732 4733 plt.scatter( 4734 bigclu.scan_time_aligned, 4735 bigclu.mz, 4736 c = bigclu.color, 4737 cmap = 'tab10', 4738 s = 1.5 4739 ) 4740 4741 plt.scatter( 4742 smclu.scan_time_aligned, 4743 smclu.mz, 4744 c = 'silver', 4745 s = 2, 4746 label = 'Small clusters' 4747 ) 4748 4749 plt.ylim(dt_ymin, dt_ymax) 4750 plt.xlim(dt_xmin, dt_xmax) 4751 plt.legend(ncol = 2, bbox_to_anchor = (0.8, -0.1)) 4752 plt.xlabel('Scan time') 4753 plt.ylabel('m/z') 4754 title_str = 'Cluster ' + str(clu) 4755 title_str += ': representing ' + str(len(inclu.sample_id.unique())) 4756 title_str += ' of ' + str(len(clu_features.sample_id.unique())) 4757 title_str += ' samples\n' 4758 title_str += 'M/Z tolerance: ' + str(mztol) + '\n' 4759 title_str += 'Scan Time tolerance: ' + str(rttol) 4760 plt.title(title_str, fontsize = 10) 4761 4762 if return_fig: 4763 plt.close(fig) 4764 return fig 4765 else: 4766 plt.show()
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 (boolean): Indicates whether to plot cluster inspection figure (False) or return figure object (True). Defaults to False.
Returns
- matplotlib.pyplot.Figure: 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
Raises
- Warning: If cluster data haven't been added to the object yet
4768 def plot_cluster_outlier_frequency(self, dim_list = ['mz', 'scan_time_aligned'], clu_size_thresh = 0.5, return_fig = False): 4769 """ 4770 Generate histogram showing the frequency of outlier occurrences by 4771 clustering dimension across all clusters 4772 4773 Parameters 4774 ----------- 4775 dim_list : list 4776 List of strings describing dimensions that can be used in 4777 clustering. Available list items: 4778 - 'mz' 4779 - 'scan_time_aligned' 4780 - 'half_height_width' 4781 - 'tailing_factor' 4782 - 'dispersity_index' 4783 - 'intensity' 4784 - 'persistence' 4785 clu_size_thresh : float 4786 Value between 0 and 1 that indicates what percentage of samples 4787 need to be present in a cluster before it's evaluated for outliers. 4788 Defaults to 0.5. 4789 return_fig : boolean 4790 Indicates whether to plot cluster inspection figure (False) or 4791 return figure object (True). Defaults to False. 4792 4793 Returns 4794 -------- 4795 matplotlib.pyplot.Figure 4796 A figure displaying the frequency of outlier occurrences across all 4797 clusters in the provided measurement dimensions 4798 4799 Raises 4800 ------ 4801 Warning 4802 If cluster data haven't been added to the object yet 4803 """ 4804 4805 if not hasattr(self, 'cluster_summary_dataframe'): 4806 raise ValueError( 4807 'cluster_summary_dataframe is not yet added, must run add_consensus_mass_features() first' 4808 ) 4809 4810 mfdf = self.mass_features_dataframe.copy() 4811 summarydf = self.cluster_summary_dataframe 4812 4813 numsamples = len(self) 4814 sumdf = summarydf[summarydf.sample_id_nunique > numsamples * clu_size_thresh].reset_index(drop = True).copy() 4815 4816 ## find the ranges for non-outlier values and add them to sumdf 4817 mergelist = ['cluster'] 4818 for dim in dim_list: 4819 maxtag = dim + '_outmax' 4820 mintag = dim + '_outmin' 4821 mergelist.append(maxtag) 4822 mergelist.append(mintag) 4823 # Calculate outlier thresholds using vectorized operations 4824 sumdf[mintag] = sumdf[dim + '_mean'] - 3*sumdf[dim + '_std'] 4825 sumdf[maxtag] = sumdf[dim + '_mean'] + 3*sumdf[dim + '_std'] 4826 ## If NaN shows up anywhere in dim_min, dim_max calculations, value is set to NaN and it's 4827 ## not flagged. This happens when there's not enough values to compute median/std for that 4828 ## dimension therefore can't have outliers 4829 4830 ## add ranges to mfdf and identify mass features that fall outside the ranges 4831 # Merge without dropping NaN - we'll handle it per-dimension 4832 outdf = pd.merge(mfdf, sumdf[mergelist], on = 'cluster') 4833 4834 outtags = ['cluster'] 4835 for dim in dim_list: 4836 dimtag = dim + '_outlier' 4837 outtags.append(dimtag) 4838 maxtag = dim + '_outmax' 4839 mintag = dim + '_outmin' 4840 # Only flag as outlier if thresholds are valid (not NaN) 4841 outdf[dimtag] = np.where( 4842 (outdf[maxtag].notna() & outdf[mintag].notna()) & 4843 (((outdf[dim] > outdf[maxtag])) | ((outdf[dim] < outdf[mintag]))), 4844 True, 4845 False 4846 ) 4847 4848 ## identify number of outliers in each cluster 4849 outliers = outdf[outtags] 4850 outliers = outliers.groupby(['cluster']).sum() 4851 4852 ## plot number of clusters that contain any outliers 4853 fig = plt.figure() 4854 plt.bar(dim_list, outliers.sum().values, width = 0.5) 4855 plt.xticks(rotation = 90) 4856 plt.title('Frequency of outliers across all clusters by category') 4857 4858 if return_fig: 4859 plt.close(fig) 4860 return fig 4861 else: 4862 plt.show()
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 (boolean): Indicates whether to plot cluster inspection figure (False) or return figure object (True). Defaults to False.
Returns
- matplotlib.pyplot.Figure: A figure displaying the frequency of outlier occurrences across all clusters in the provided measurement dimensions
Raises
- Warning: If cluster data haven't been added to the object yet
5008 def fill_missing_cluster_features(self): 5009 """ 5010 Gap-filling for consensus mass features across collection samples. 5011 5012 For clusters present in multiple samples but missing from others, searches 5013 raw MS1 data to find peaks in expected m/z and retention time windows. This 5014 creates "induced" mass features for peaks that exist in the data but weren't 5015 detected in the initial peak detection. 5016 5017 Must be run after add_consensus_mass_features(). Results are accessible via 5018 induced_mass_features_dataframe property and included in collection_pivot_table 5019 and collection_consensus_report outputs. 5020 5021 Parameters 5022 ---------- 5023 None 5024 Uses parameters from self.parameters.lcms_collection: 5025 - consensus_min_sample_fraction: Minimum fraction of samples (0-1) that must contain 5026 a cluster before gap-filling is attempted 5027 - gap_fill_expand_on_miss: If True, expands search window when no peak is found 5028 5029 Returns 5030 ------- 5031 None 5032 Updates induced_mass_features attribute for each LCMSBase object and 5033 combines them into induced_mass_features_dataframe. 5034 5035 Raises 5036 ------ 5037 ValueError 5038 If cluster_summary_dataframe is not set (must run add_consensus_mass_features first). 5039 5040 Notes 5041 ----- 5042 - Loads raw MS1 data for each sample, which may be memory intensive 5043 - Induced features are integrated and metrics calculated automatically 5044 - Processing can be parallelized using parameters.lcms_collection.cores 5045 5046 See Also 5047 -------- 5048 add_consensus_mass_features : Creates consensus features before gap-filling 5049 collection_pivot_table : Includes both regular and induced features 5050 collection_consensus_report : Reports on complete feature matrix 5051 """ 5052 5053 # Validate prerequisites 5054 if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: 5055 raise ValueError( 5056 "cluster_summary_dataframe not found. Must run add_consensus_mass_features() first." 5057 ) 5058 5059 # Get parameters from settings 5060 min_cluster_presence = self.parameters.lcms_collection.consensus_min_sample_fraction 5061 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5062 5063 # Validate parameters 5064 if not 0 <= min_cluster_presence <= 1: 5065 raise ValueError("consensus_min_sample_fraction must be between 0 and 1") 5066 5067 summarydf = self.cluster_summary_dataframe 5068 mfdf = self.mass_features_dataframe 5069 5070 sample_ct = len(self.samples) 5071 5072 # Identify clusters present in sufficient samples but not all samples 5073 missingdf = summarydf[[ 5074 'cluster', 5075 'sample_id_nunique', 5076 'mz_min', 5077 'mz_max', 5078 'scan_time_aligned_min', 5079 'scan_time_aligned_max' 5080 ]] 5081 missingdf = missingdf[missingdf.sample_id_nunique > min_cluster_presence * sample_ct] 5082 missingdf = missingdf[missingdf.sample_id_nunique != sample_ct] 5083 5084 # Check if there are any clusters to gap-fill 5085 if len(missingdf) == 0: 5086 return 5087 5088 # Find which samples are missing for each cluster 5089 # Use range(sample_ct) to include all samples, even those with no mass features 5090 all_sample_ids = list(range(sample_ct)) 5091 missing_samples_list = [] 5092 for c in missingdf.cluster.to_numpy(): 5093 cludf = mfdf[mfdf.cluster == c] 5094 missing = [x for x in all_sample_ids if x not in cludf.sample_id.unique()] 5095 missing_samples_list.append(missing) 5096 missingdf['missing_samples'] = missing_samples_list 5097 5098 # Calculate expanded search windows for expand_on_miss option 5099 mz_clu_tol = self.parameters.lcms_collection.consensus_mz_tol_ppm * 1e-6 5100 rt_clu_tol = self.parameters.lcms_collection.consensus_rt_tol 5101 missingdf['mz_max_allowed'] = missingdf.mz_max + mz_clu_tol * missingdf.mz_max 5102 missingdf['mz_min_allowed'] = missingdf.mz_min - mz_clu_tol * missingdf.mz_min 5103 missingdf['sta_max_allowed'] = missingdf.scan_time_aligned_max + rt_clu_tol * missingdf.scan_time_aligned_max 5104 missingdf['sta_min_allowed'] = missingdf.scan_time_aligned_min - rt_clu_tol * missingdf.scan_time_aligned_min 5105 5106 # Compute cluster dictionary once to avoid recomputing for each sample 5107 cluster_dict = self.cluster_feature_dictionary 5108 5109 # Process each sample to search for missing features 5110 if self.parameters.lcms_collection.cores == 1: 5111 for i in tqdm(range(sample_ct), desc="Gap-filling samples", unit="sample"): 5112 self._search_for_targeted_mass_features_in_sample(i, missingdf, cluster_dict, expand_on_miss) 5113 5114 if self.parameters.lcms_collection.cores > 1: 5115 if self.parameters.lcms_collection.cores > len(self): 5116 ncores = len(self) 5117 else: 5118 ncores = self.parameters.lcms_collection.cores 5119 pool = multiprocessing.Pool(ncores) 5120 mp_result = pool.starmap( 5121 self._search_for_targeted_mass_features_in_sample, 5122 [(x, missingdf, cluster_dict, expand_on_miss, False) for x in range(sample_ct)] 5123 ) 5124 5125 for i in tqdm(range(sample_ct), desc="Collecting gap-filled features", unit="sample"): 5126 self[i].induced_mass_features = mp_result[i] 5127 5128 self._combine_mass_features(induced_features = True) 5129 5130 # Mark that gap-filling has been performed 5131 self.missing_mass_features_searched = True 5132 5133 for sample_name in self.samples: 5134 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
5136 def process_samples_pipeline(self, operations, description=None, keep_raw_data=False, show_progress=True): 5137 """ 5138 Execute a pipeline of operations on all samples in parallel. 5139 5140 This method provides a flexible framework for performing multiple 5141 sample-level operations in a single parallelized pass, which is more 5142 efficient than calling separate methods sequentially. 5143 5144 Parameters 5145 ---------- 5146 operations : list of SampleOperation 5147 List of operations to perform on each sample, in order. 5148 Each operation should be an instance of a class derived from 5149 SampleOperation (see lc_calc_operations module). 5150 description : str or None, optional 5151 Progress bar description. If None, automatically generates description 5152 from operation descriptions (e.g., "gap-filling, reloading features"). 5153 Default is None. 5154 keep_raw_data : bool, optional 5155 If True, keeps raw MS data loaded in memory after pipeline completes. 5156 If False, cleans up raw data to free memory. Default is False. 5157 show_progress : bool, optional 5158 If True, displays progress bars during processing. If False, runs silently. 5159 Default is True. 5160 5161 Returns 5162 ------- 5163 dict 5164 Dictionary with results from pipeline execution, keyed by operation name. 5165 Structure: {operation_name: {sample_id: result, ...}, ...} 5166 5167 Raises 5168 ------ 5169 ValueError 5170 If operations list is empty or contains invalid operations. 5171 5172 Notes 5173 ----- 5174 - Operations are executed sequentially within each sample 5175 - Samples are processed in parallel based on parameters.lcms_collection.cores 5176 - Each operation can have conditional execution via can_execute() 5177 - Results are collected back via collect_results() method of each operation 5178 - Failed operations for a sample are logged but don't halt processing 5179 - Raw MS data loaded by operations is automatically cleaned up unless keep_raw_data=True 5180 5181 Examples 5182 -------- 5183 >>> from corems.mass_spectra.calc.lc_calc_operations import ( 5184 ... GapFillOperation, ReloadFeaturesOperation 5185 ... ) 5186 >>> ops = [ 5187 ... GapFillOperation('gap_fill', expand_on_miss=True), 5188 ... ReloadFeaturesOperation('reload', add_ms2=True) 5189 ... ] 5190 >>> results = lcms_collection.process_samples_pipeline(ops) 5191 5192 See Also 5193 -------- 5194 lc_calc_operations : Module containing built-in operation classes 5195 fill_and_process_features : Convenience method combining common operations 5196 """ 5197 from corems.mass_spectra.calc.lc_calc_operations import SampleOperation 5198 5199 # Validate operations 5200 if not operations or len(operations) == 0: 5201 raise ValueError("operations list cannot be empty") 5202 5203 for op in operations: 5204 if not isinstance(op, SampleOperation): 5205 raise ValueError(f"All operations must be SampleOperation instances, got {type(op)}") 5206 5207 # Generate description from operations if not provided 5208 if description is None: 5209 operation_descriptions = [op.description for op in operations] 5210 description = ", ".join(operation_descriptions).capitalize() 5211 5212 # Prepare runtime parameters for each operation 5213 # This is where we gather collection-level data that operations need 5214 runtime_params = self._prepare_pipeline_runtime_params(operations) 5215 runtime_params['keep_raw_data'] = keep_raw_data 5216 5217 # Execute pipeline 5218 sample_ct = len(self.samples) 5219 5220 if self.parameters.lcms_collection.cores == 1: 5221 # Serial processing 5222 results_by_operation = {op.name: {} for op in operations} 5223 5224 if show_progress: 5225 from tqdm import tqdm 5226 # Print description on its own line before progress bar 5227 print(f"\n{description.capitalize()}:") 5228 iterator = tqdm(range(sample_ct), unit="sample", ncols=80) 5229 else: 5230 iterator = range(sample_ct) 5231 5232 for sample_id in iterator: 5233 sample_results = self._execute_sample_pipeline( 5234 sample_id, operations, runtime_params, inplace=True 5235 ) 5236 # Collect results (collect_results already called in _execute_sample_pipeline when inplace=True) 5237 # Skip 'sample_id' key which is added for tracking 5238 for op_name, result in sample_results.items(): 5239 if op_name != 'sample_id': 5240 results_by_operation[op_name][sample_id] = result 5241 else: 5242 # Parallel processing 5243 import multiprocessing 5244 5245 if self.parameters.lcms_collection.cores > sample_ct: 5246 ncores = sample_ct 5247 else: 5248 ncores = self.parameters.lcms_collection.cores 5249 5250 pool = multiprocessing.Pool(ncores) 5251 5252 # Build arguments for each sample 5253 args_list = [ 5254 (sample_id, operations, runtime_params, False) 5255 for sample_id in range(sample_ct) 5256 ] 5257 5258 # Execute in parallel with progress tracking 5259 results_by_operation = {op.name: {} for op in operations} 5260 5261 if show_progress: 5262 from tqdm import tqdm 5263 import time 5264 5265 # Use starmap_async for parallel execution with progress tracking 5266 async_result = pool.starmap_async(self._execute_sample_pipeline, args_list) 5267 5268 # Poll for completion and update progress bar 5269 print(description) 5270 pbar = tqdm( 5271 total=sample_ct, 5272 desc="", 5273 unit="sample", 5274 position=0, 5275 leave=True, 5276 dynamic_ncols=True 5277 ) 5278 prev_completed = 0 5279 while not async_result.ready(): 5280 # Get number of completed tasks by checking remaining 5281 completed = sample_ct - async_result._number_left 5282 if completed > prev_completed: 5283 pbar.update(completed - prev_completed) 5284 prev_completed = completed 5285 time.sleep(0.5) # Poll every 500ms to avoid spam 5286 5287 # Final update to 100% 5288 if prev_completed < sample_ct: 5289 pbar.update(sample_ct - prev_completed) 5290 pbar.close() 5291 5292 # Get all results 5293 mp_results = async_result.get() 5294 else: 5295 # Execute without progress 5296 mp_results = pool.starmap(self._execute_sample_pipeline, args_list) 5297 5298 pool.close() 5299 pool.join() 5300 5301 # Collect results back into collection 5302 for result in mp_results: 5303 sample_id = result.get('sample_id') 5304 for op in operations: 5305 op_result = result.get(op.name) 5306 if op_result is not None: 5307 op.collect_results(sample_id, op_result, self) 5308 results_by_operation[op.name][sample_id] = op_result 5309 5310 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
5532 def process_consensus_features(self, load_representatives=True, perform_gap_filling=True, 5533 add_ms1=False, add_ms2=False, 5534 ms2_scan_filter=None, molecular_formula_search=False, 5535 ms2_spectral_search=False, spectral_lib=None, 5536 molecular_metadata=None, 5537 gather_eics=False, 5538 keep_raw_data=False, 5539 show_progress=True): 5540 """ 5541 Process consensus mass features across the collection in a single parallelized pass. 5542 5543 This method provides a convenient interface to the sample processing pipeline, 5544 allowing multiple operations (gap-filling, feature reloading, MS1/MS2 association, 5545 molecular formula search, and MS2 spectral search) to be performed efficiently in 5546 a single pass through all samples. 5547 5548 Parameters 5549 ---------- 5550 load_representatives : bool, optional 5551 If True, loads representative mass features from HDF5. Default is True. 5552 perform_gap_filling : bool, optional 5553 If True, performs gap-filling for missing cluster features. Default is True. 5554 This operation loads raw MS1 data which can be reused by subsequent operations. 5555 add_ms1 : bool, optional 5556 If True and load_representatives=True, associates MS1 spectra with 5557 loaded features. Automatically uses raw data from gap-filling if available, 5558 otherwise uses parser. Spectrum mode is auto-detected. Default is False. 5559 add_ms2 : bool, optional 5560 If True and load_representatives=True, associates MS2 spectra with 5561 loaded features and automatically processes them. Spectrum mode is auto-detected. Default is False. 5562 ms2_scan_filter : str or None, optional 5563 Filter string for MS2 scans (e.g., 'hcd'). Default is None. 5564 molecular_formula_search : bool, optional 5565 If True, performs molecular formula search on mass features using 5566 associated MS1 spectra. Requires add_ms1=True or that MS1 spectra 5567 are already associated. Uses parameters from 5568 parameters.mass_spectrum["ms1"].molecular_search. Default is False. 5569 ms2_spectral_search : bool, optional 5570 If True, performs MS2 spectral library search using FlashEntropy. 5571 Requires add_ms2=True and spectral_lib to be provided. Default is False. 5572 spectral_lib : FlashEntropy library, optional 5573 Pre-prepared FlashEntropy spectral library for MS2 search. 5574 Create using MSPInterface.get_metabolomics_spectra_library(). 5575 Required if ms2_spectral_search=True. Default is None. 5576 molecular_metadata : pd.DataFrame, optional 5577 Molecular metadata corresponding to spectral_lib. 5578 Returned from MSPInterface.get_metabolomics_spectra_library(). 5579 Stored as self.spectral_search_molecular_metadata for later export. 5580 Default is None. 5581 gather_eics : bool, optional 5582 If True, loads extracted ion chromatograms (EICs) from HDF5 for all 5583 mass features with assigned cluster_index (including gap-filled features). 5584 Enables access to EICs via get_eics_for_cluster(cluster_id) method. 5585 Requires that EICs were previously exported with export_eics=True. 5586 Default is False. 5587 keep_raw_data : bool, optional 5588 If True, keeps raw MS data loaded in memory after pipeline completes. 5589 If False, cleans up raw data to free memory. Default is False. 5590 show_progress : bool, optional 5591 If True, displays progress bars during processing. If False, runs silently. 5592 Default is True. 5593 5594 Returns 5595 ------- 5596 dict 5597 Dictionary with pipeline results. Keys include: 5598 - 'gap_fill': dict mapping sample_id to induced mass features (if gap-filling) 5599 - 'reload': dict mapping sample_id to reloaded mass features (if reloading) 5600 - 'mf_search': dict mapping sample_id to number of features searched (if molecular formula search) 5601 - 'ms2_search': dict mapping sample_id to number of spectra searched (if MS2 spectral search) 5602 5603 Raises 5604 ------ 5605 ValueError 5606 If neither operation is enabled, or if required parameters are missing. 5607 5608 Notes 5609 ----- 5610 - Must run add_consensus_mass_features() before calling this method 5611 - Processes samples in parallel based on parameters.lcms_collection.cores 5612 - Raw MS1 data loaded by gap-filling is automatically reused by MS1 association 5613 - MS2 spectral search requires add_ms2=True and msp_file_path 5614 - FlashEntropy library is created once and reused across all samples 5615 - More efficient than calling individual methods separately 5616 - After gap-filling, sets missing_mass_features_searched = True 5617 - Mass features remain loaded in memory for downstream processing 5618 - For more advanced workflows, use process_samples_pipeline() directly 5619 5620 Examples 5621 -------- 5622 >>> # Prepare spectral library for MS2 search 5623 >>> from corems.molecular_id.search.database_interfaces import MSPInterface 5624 >>> my_msp = MSPInterface(file_path='path/to/library.msp') 5625 >>> spectral_lib, molecular_metadata = my_msp.get_metabolomics_spectra_library( 5626 ... polarity='negative', 5627 ... format='flashentropy', 5628 ... normalize=True, 5629 ... fe_kwargs={ 5630 ... 'normalize_intensity': True, 5631 ... 'min_ms2_difference_in_da': 0.02, 5632 ... 'max_ms2_tolerance_in_da': 0.01, 5633 ... 'max_indexed_mz': 3000, 5634 ... 'precursor_ions_removal_da': None, 5635 ... 'noise_threshold': 0, 5636 ... } 5637 ... ) 5638 >>> 5639 >>> # Gap-fill, reload with MS1/MS2, perform molecular formula and spectral search 5640 >>> results = lcms_collection.process_consensus_features( 5641 ... load_representatives=True, 5642 ... perform_gap_filling=True, 5643 ... add_ms1=True, 5644 ... add_ms2=True, 5645 ... molecular_formula_search=True, 5646 ... ms2_spectral_search=True, 5647 ... spectral_lib=spectral_lib, 5648 ... molecular_metadata=molecular_metadata 5649 ... ) 5650 5651 See Also 5652 -------- 5653 process_samples_pipeline : Generic pipeline executor for custom workflows 5654 fill_missing_cluster_features : Original gap-filling method 5655 reload_representative_mass_features : Original reload method 5656 """ 5657 from corems.mass_spectra.calc.lc_calc_operations import ( 5658 GapFillOperation, ReloadFeaturesOperation, MolecularFormulaSearchOperation, 5659 MS2SpectralSearchOperation, LoadEICsOperation 5660 ) 5661 5662 # Validate that at least one meaningful operation is enabled 5663 has_operations = ( 5664 perform_gap_filling or 5665 load_representatives or 5666 molecular_formula_search or 5667 ms2_spectral_search or 5668 gather_eics or 5669 add_ms1 or 5670 add_ms2 5671 ) 5672 5673 if not has_operations: 5674 raise ValueError( 5675 "At least one operation must be enabled: perform_gap_filling, load_representatives, " 5676 "molecular_formula_search, ms2_spectral_search, gather_eics, add_ms1, or add_ms2" 5677 ) 5678 5679 # Validate prerequisites for gap-filling 5680 if perform_gap_filling: 5681 if not hasattr(self, 'cluster_summary_dataframe') or self.cluster_summary_dataframe is None: 5682 raise ValueError( 5683 "Cannot perform gap-filling: cluster_summary_dataframe not set. " 5684 "You must run add_consensus_mass_features() before calling process_consensus_features()." 5685 ) 5686 5687 # Validate prerequisites for MS2 spectral search 5688 if ms2_spectral_search: 5689 if spectral_lib is None: 5690 raise ValueError( 5691 "MS2 spectral search requires spectral_lib to be provided. " 5692 "Create it using MSPInterface.get_metabolomics_spectra_library() before calling this method." 5693 ) 5694 # Check if mass features will be loaded OR are already loaded 5695 # (The operation's can_execute will check if MS2 spectra are actually present) 5696 if not load_representatives and not perform_gap_filling: 5697 # Check if at least one sample has mass features loaded 5698 # This allows MS2 search on already-loaded features 5699 has_loaded_features = any( 5700 len(self[i].mass_features) > 0 if hasattr(self[i], 'mass_features') and self[i].mass_features is not None else False 5701 for i in range(len(self.samples)) 5702 ) 5703 if not has_loaded_features: 5704 raise ValueError( 5705 "MS2 spectral search requires mass features to be loaded. " 5706 "Either set load_representatives=True or perform_gap_filling=True to load them, " 5707 "or load them in a previous call to process_consensus_features() before calling " 5708 "with ms2_spectral_search=True." 5709 ) 5710 5711 # Build pipeline 5712 operations = [] 5713 5714 if perform_gap_filling: 5715 expand_on_miss = self.parameters.lcms_collection.gap_fill_expand_on_miss 5716 operations.append(GapFillOperation('gap_fill', expand_on_miss=expand_on_miss)) 5717 5718 if load_representatives: 5719 operations.append(ReloadFeaturesOperation( 5720 'reload', 5721 add_ms1=add_ms1, 5722 add_ms2=add_ms2, 5723 auto_process_ms2=add_ms2, # Auto-process MS2 if add_ms2 is enabled 5724 ms2_scan_filter=ms2_scan_filter 5725 )) 5726 5727 if molecular_formula_search: 5728 operations.append(MolecularFormulaSearchOperation('mf_search')) 5729 5730 if ms2_spectral_search: 5731 operations.append(MS2SpectralSearchOperation( 5732 'ms2_search', 5733 ms2_scan_filter=ms2_scan_filter 5734 )) 5735 # Store spectral library and metadata for runtime preparation 5736 self._spectral_lib = spectral_lib 5737 self._spectral_search_molecular_metadata = molecular_metadata 5738 5739 if gather_eics: 5740 operations.append(LoadEICsOperation('load_eics')) 5741 5742 # Execute pipeline (description auto-generated from operations) 5743 results = self.process_samples_pipeline( 5744 operations, 5745 keep_raw_data=keep_raw_data, 5746 show_progress=show_progress 5747 ) 5748 5749 # Store molecular metadata if spectral search was performed 5750 if ms2_spectral_search and hasattr(self, '_spectral_search_molecular_metadata'): 5751 # This allows users to access the metadata for reporting 5752 self.spectral_search_molecular_metadata = self._spectral_search_molecular_metadata 5753 # Post-processing 5754 if perform_gap_filling: 5755 # Combine induced mass features into dataframe 5756 self._combine_mass_features(induced_features=True) 5757 # Mark that gap-filling has been performed 5758 self.missing_mass_features_searched = True 5759 5760 # Add ._eic_mz to induced_mass_features_dataframe if it exists 5761 if self.induced_mass_features_dataframe is not None and len(self.induced_mass_features_dataframe) > 0: 5762 eics_mz = [] 5763 for i, row in self.induced_mass_features_dataframe.iterrows(): 5764 sample_id = row['sample_id'] 5765 sample = self[sample_id] 5766 if row['mf_id'] in sample.induced_mass_features.keys(): 5767 eic_mz = sample.induced_mass_features[row['mf_id']]._eic_mz 5768 eics_mz.append(eic_mz) 5769 else: 5770 eics_mz.append(None) 5771 self.induced_mass_features_dataframe['_eic_mz'] = eics_mz 5772 5773 # Clear mass features from samples to free memory 5774 for sample_name in self.samples: 5775 self._lcms[sample_name].induced_mass_features = {} 5776 5777 # Associate EICs with mass features if they were loaded 5778 # This must happen after all operations complete to work on the actual sample objects 5779 if gather_eics: 5780 print("\nAssociating EICs with mass features:") 5781 from tqdm import tqdm 5782 5783 for sample_id in tqdm(range(len(self.samples)), unit="sample", ncols=80): 5784 sample = self[sample_id] 5785 if sample.eics: # Only if EICs were loaded 5786 # Associate EICs with regular mass features 5787 sample.associate_eics_with_mass_features(induced=False) 5788 # Associate EICs with induced mass features 5789 sample.associate_eics_with_mass_features(induced=True) 5790 5791 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.
- 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