jdti.utils
1import os 2import re 3from typing import Literal 4 5import matplotlib as mpl 6import matplotlib.patches as mpatches 7import matplotlib.pyplot as plt 8import numpy as np 9import pandas as pd 10import scipy.stats as stats 11from adjustText import adjust_text 12from joblib import Parallel, delayed 13from matplotlib.lines import Line2D 14from mpl_toolkits.axes_grid1.inset_locator import inset_axes 15from scipy.cluster.hierarchy import dendrogram, linkage 16from scipy.io import mmread 17from tqdm import tqdm 18 19 20def load_sparse(path: str, name: str): 21 """ 22 Load a sparse matrix dataset along with associated gene 23 and cell metadata, and return it as a dense DataFrame. 24 25 This function expects the input directory to contain three files in standard 26 10x Genomics format: 27 - "matrix.mtx": the gene expression matrix in Matrix Market format 28 - "genes.tsv": tab-separated file containing gene identifiers 29 - "barcodes.tsv": tab-separated file containing cell barcodes / names 30 31 Parameters 32 ---------- 33 path : str 34 Path to the directory containing the matrix and annotation files. 35 36 name : str 37 Label or dataset identifier to be assigned to all cells in the metadata. 38 39 Returns 40 ------- 41 data : pandas.DataFrame 42 Dense expression matrix where rows correspond to genes and columns 43 correspond to cells. 44 metadata : pandas.DataFrame 45 Metadata DataFrame with two columns: 46 - "cell_names": the names of the cells (barcodes) 47 - "sets": the dataset label assigned to each cell (from `name`) 48 49 Notes 50 ----- 51 The function converts the sparse matrix into a dense DataFrame. This may 52 require a large amount of memory for datasets with many cells and genes. 53 """ 54 55 data = mmread(os.path.join(path, "matrix.mtx")) 56 data = pd.DataFrame(data.todense()) 57 genes = pd.read_csv(os.path.join(path, "genes.tsv"), header=None, sep="\t") 58 names = pd.read_csv(os.path.join(path, "barcodes.tsv"), header=None, sep="\t") 59 data.columns = [str(x) for x in names[0]] 60 data.index = list(genes[0]) 61 62 names = list(data.columns) 63 sets = [name] * len(names) 64 65 metadata = pd.DataFrame({"cell_names": names, "sets": sets}) 66 67 return data, metadata 68 69 70def volcano_plot( 71 deg_data: pd.DataFrame, 72 p_adj: bool = True, 73 top: int = 25, 74 top_rank: str = "p_value", 75 p_val: float | int = 0.05, 76 lfc: float | int = 0.25, 77 rescale_adj: bool = True, 78 image_width: int = 12, 79 image_high: int = 12, 80): 81 """ 82 Generate a volcano plot from differential expression results. 83 84 A volcano plot visualizes the relationship between statistical significance 85 (p-values or standarized p-value) and log(fold change) for each gene, highlighting 86 genes that pass significance thresholds. 87 88 Parameters 89 ---------- 90 deg_data : pandas.DataFrame 91 DataFrame containing differential expression results from calc_DEG() function. 92 93 p_adj : bool, default=True 94 If True, use adjusted p-values. If False, use raw p-values. 95 96 top : int, default=25 97 Number of top significant genes to highlight on the plot. 98 99 top_rank : str, default='p_value' 100 Statistic used primarily to determine the top significant genes to highlight on the plot. ['p_value' or 'FC'] 101 102 p_val : float | int, default=0.05 103 Significance threshold for p-values (or adjusted p-values). 104 105 lfc : float | int, default=0.25 106 Threshold for absolute log fold change. 107 108 rescale_adj : bool, default=True 109 If True, rescale p-values to avoid long breaks caused by outlier values. 110 111 image_width : int, default=12 112 Width of the generated plot in inches. 113 114 image_high : int, default=12 115 Height of the generated plot in inches. 116 117 Returns 118 ------- 119 matplotlib.figure.Figure 120 The generated volcano plot figure. 121 122 """ 123 124 if top_rank.upper() not in ["FC", "P_VALUE"]: 125 raise ValueError("top_rank must be either 'FC' or 'p_value'") 126 127 if p_adj: 128 pv = "adj_pval" 129 else: 130 pv = "p_val" 131 132 deg_df = deg_data.copy() 133 134 shift = 0.25 135 136 p_val_scale = "-log(p_val)" 137 138 min_minus = min(deg_df[pv][(deg_df[pv] != 0) & (deg_df["log(FC)"] < 0)]) 139 min_plus = min(deg_df[pv][(deg_df[pv] != 0) & (deg_df["log(FC)"] > 0)]) 140 141 zero_p_plus = deg_df[(deg_df[pv] == 0) & (deg_df["log(FC)"] > 0)] 142 zero_p_plus = zero_p_plus.sort_values(by="log(FC)", ascending=False).reset_index( 143 drop=True 144 ) 145 zero_p_plus[pv] = [ 146 (shift * x) * min_plus for x in range(1, len(zero_p_plus.index) + 1) 147 ] 148 149 zero_p_minus = deg_df[(deg_df[pv] == 0) & (deg_df["log(FC)"] < 0)] 150 zero_p_minus = zero_p_minus.sort_values(by="log(FC)", ascending=True).reset_index( 151 drop=True 152 ) 153 zero_p_minus[pv] = [ 154 (shift * x) * min_minus for x in range(1, len(zero_p_minus.index) + 1) 155 ] 156 157 tmp_p = deg_df[ 158 ((deg_df[pv] != 0) & (deg_df["log(FC)"] < 0)) 159 | ((deg_df[pv] != 0) & (deg_df["log(FC)"] > 0)) 160 ] 161 162 del deg_df 163 164 deg_df = pd.concat([zero_p_plus, tmp_p, zero_p_minus], ignore_index=True) 165 166 deg_df[pv] = deg_df[pv].replace(0, 2**-1074) 167 168 deg_df[p_val_scale] = -np.log10(deg_df[pv]) 169 170 deg_df["top100"] = None 171 172 if rescale_adj: 173 174 deg_df = deg_df.sort_values(by=p_val_scale, ascending=False) 175 176 deg_df = deg_df.reset_index(drop=True) 177 178 eps = 1e-300 179 doubled = [] 180 ratio = [] 181 for n, i in enumerate(deg_df.index): 182 for j in range(1, 6): 183 if ( 184 n + j < len(deg_df.index) 185 and (deg_df[p_val_scale][n] + eps) 186 / (deg_df[p_val_scale][n + j] + eps) 187 >= 2 188 ): 189 doubled.append(n) 190 ratio.append( 191 (deg_df[p_val_scale][n + j] + eps) 192 / (deg_df[p_val_scale][n] + eps) 193 ) 194 195 df = pd.DataFrame({"doubled": doubled, "ratio": ratio}) 196 df = df[df["doubled"] < 100] 197 198 df["ratio"] = (1 - df["ratio"]) / 5 199 df = df.reset_index(drop=True) 200 201 df = df.sort_values("doubled") 202 203 if len(df["doubled"]) == 1 and 0 in df["doubled"]: 204 df = df 205 else: 206 doubled2 = [] 207 208 for l in df["doubled"]: 209 if l + 1 != len(doubled) and l + 1 - l == 1: 210 doubled2.append(l) 211 doubled2.append(l + 1) 212 else: 213 break 214 215 doubled2 = sorted(set(doubled2), reverse=True) 216 217 if len(doubled2) > 1: 218 df = df[df["doubled"].isin(doubled2)] 219 df = df.sort_values("doubled", ascending=False) 220 df = df.reset_index(drop=True) 221 for c in df.index: 222 deg_df.loc[df["doubled"][c], p_val_scale] = deg_df.loc[ 223 df["doubled"][c] + 1, p_val_scale 224 ] * (1 + df["ratio"][c]) 225 226 deg_df.loc[(deg_df["log(FC)"] <= 0) & (deg_df[pv] <= p_val), "top100"] = "red" 227 deg_df.loc[(deg_df["log(FC)"] > 0) & (deg_df[pv] <= p_val), "top100"] = "blue" 228 deg_df.loc[deg_df[pv] > p_val, "top100"] = "lightgray" 229 230 if lfc > 0: 231 deg_df.loc[ 232 (deg_df["log(FC)"] <= lfc) & (deg_df["log(FC)"] >= -lfc), "top100" 233 ] = "lightgray" 234 235 down_int = len( 236 deg_df["top100"][(deg_df["log(FC)"] <= lfc * -1) & (deg_df[pv] <= p_val)] 237 ) 238 up_int = len(deg_df["top100"][(deg_df["log(FC)"] > lfc) & (deg_df[pv] <= p_val)]) 239 240 deg_df_up = deg_df[deg_df["log(FC)"] > 0] 241 242 if top_rank.upper() == "P_VALUE": 243 deg_df_up = deg_df_up.sort_values([pv, "log(FC)"], ascending=[True, False]) 244 elif top_rank.upper() == "FC": 245 deg_df_up = deg_df_up.sort_values(["log(FC)", pv], ascending=[False, True]) 246 247 deg_df_up = deg_df_up.reset_index(drop=True) 248 249 n = -1 250 l = 0 251 while True: 252 n += 1 253 if deg_df_up["log(FC)"][n] > lfc and deg_df_up[pv][n] <= p_val: 254 deg_df_up.loc[n, "top100"] = "green" 255 l += 1 256 if l == top or deg_df_up[pv][n] > p_val: 257 break 258 259 deg_df_down = deg_df[deg_df["log(FC)"] <= 0] 260 261 if top_rank.upper() == "P_VALUE": 262 deg_df_down = deg_df_down.sort_values([pv, "log(FC)"], ascending=[True, True]) 263 elif top_rank.upper() == "FC": 264 deg_df_down = deg_df_down.sort_values(["log(FC)", pv], ascending=[True, True]) 265 266 deg_df_down = deg_df_down.reset_index(drop=True) 267 268 n = -1 269 l = 0 270 while True: 271 n += 1 272 if deg_df_down["log(FC)"][n] < lfc * -1 and deg_df_down[pv][n] <= p_val: 273 deg_df_down.loc[n, "top100"] = "yellow" 274 275 l += 1 276 if l == top or deg_df_down[pv][n] > p_val: 277 break 278 279 deg_df = pd.concat([deg_df_up, deg_df_down]) 280 281 que = ["lightgray", "red", "blue", "yellow", "green"] 282 283 deg_df = deg_df.sort_values( 284 by="top100", key=lambda x: x.map({v: i for i, v in enumerate(que)}) 285 ) 286 287 deg_df = deg_df.reset_index(drop=True) 288 289 fig, ax = plt.subplots(figsize=(image_width, image_high)) 290 291 plt.scatter( 292 x=deg_df["log(FC)"], y=deg_df[p_val_scale], color=deg_df["top100"], zorder=2 293 ) 294 295 tl = deg_df[p_val_scale][deg_df[pv] >= p_val] 296 297 if len(tl) > 0: 298 299 line_p = np.max(tl) 300 301 else: 302 line_p = np.min(deg_df[p_val_scale]) 303 304 plt.plot( 305 [max(deg_df["log(FC)"]) * -1.1, max(deg_df["log(FC)"]) * 1.1], 306 [line_p, line_p], 307 linestyle="--", 308 linewidth=3, 309 color="lightgray", 310 zorder=1, 311 ) 312 313 if lfc > 0: 314 plt.plot( 315 [lfc * -1, lfc * -1], 316 [-3, max(deg_df[p_val_scale]) * 1.1], 317 linestyle="--", 318 linewidth=3, 319 color="lightgray", 320 zorder=1, 321 ) 322 plt.plot( 323 [lfc, lfc], 324 [-3, max(deg_df[p_val_scale]) * 1.1], 325 linestyle="--", 326 linewidth=3, 327 color="lightgray", 328 zorder=1, 329 ) 330 331 plt.xlabel("log(FC)") 332 plt.ylabel(p_val_scale) 333 plt.title("Volcano plot") 334 335 plt.ylim(min(deg_df[p_val_scale]) - 5, max(deg_df[p_val_scale]) * 1.25) 336 337 texts = [ 338 ax.text(deg_df["log(FC)"][i], deg_df[p_val_scale][i], deg_df["feature"][i]) 339 for i in deg_df.index 340 if deg_df["top100"][i] in ["green", "yellow"] 341 ] 342 343 adjust_text(texts, arrowprops=dict(arrowstyle="-", color="gray", alpha=0.5)) 344 345 legend_elements = [ 346 Line2D( 347 [0], 348 [0], 349 marker="o", 350 color="w", 351 label="top-upregulated", 352 markerfacecolor="green", 353 markersize=10, 354 ), 355 Line2D( 356 [0], 357 [0], 358 marker="o", 359 color="w", 360 label="top-downregulated", 361 markerfacecolor="yellow", 362 markersize=10, 363 ), 364 Line2D( 365 [0], 366 [0], 367 marker="o", 368 color="w", 369 label="upregulated", 370 markerfacecolor="blue", 371 markersize=10, 372 ), 373 Line2D( 374 [0], 375 [0], 376 marker="o", 377 color="w", 378 label="downregulated", 379 markerfacecolor="red", 380 markersize=10, 381 ), 382 Line2D( 383 [0], 384 [0], 385 marker="o", 386 color="w", 387 label="non-significant", 388 markerfacecolor="lightgray", 389 markersize=10, 390 ), 391 ] 392 393 ax.legend(handles=legend_elements, loc="upper right") 394 ax.grid(visible=False) 395 396 ax.annotate( 397 f"\nmin {pv} = " + str(p_val), 398 xy=(0.025, 0.975), 399 xycoords="axes fraction", 400 fontsize=12, 401 ) 402 403 if lfc > 0: 404 ax.annotate( 405 "\nmin log(FC) = " + str(lfc), 406 xy=(0.025, 0.95), 407 xycoords="axes fraction", 408 fontsize=12, 409 ) 410 411 ax.annotate( 412 "\nDownregulated: " + str(down_int), 413 xy=(0.025, 0.925), 414 xycoords="axes fraction", 415 fontsize=12, 416 color="red", 417 ) 418 419 ax.annotate( 420 "\nUpregulated: " + str(up_int), 421 xy=(0.025, 0.9), 422 xycoords="axes fraction", 423 fontsize=12, 424 color="blue", 425 ) 426 427 plt.show() 428 429 return fig 430 431 432def find_features(data: pd.DataFrame, features: list): 433 """ 434 Identify features (rows) from a DataFrame that match a given list of features, 435 ignoring case sensitivity. 436 437 Parameters 438 ---------- 439 data : pandas.DataFrame 440 DataFrame with features in the index (rows). 441 442 features : list 443 List of feature names to search for. 444 445 Returns 446 ------- 447 dict 448 Dictionary with keys: 449 - "included": list of features found in the DataFrame index. 450 - "not_included": list of requested features not found in the DataFrame index. 451 - "potential": list of features in the DataFrame that may be similar. 452 """ 453 454 features_upper = [str(x).upper() for x in features] 455 456 index_set = set(data.index) 457 458 features_in = [x for x in index_set if x.upper() in features_upper] 459 features_in_upper = [x.upper() for x in features_in] 460 features_out_upper = [x for x in features_upper if x not in features_in_upper] 461 features_out = [x for x in features if x.upper() in features_out_upper] 462 similar_features = [ 463 idx for idx in index_set if any(x in idx.upper() for x in features_out_upper) 464 ] 465 466 return { 467 "included": features_in, 468 "not_included": features_out, 469 "potential": similar_features, 470 } 471 472 473def find_names(data: pd.DataFrame, names: list): 474 """ 475 Identify names (columns) from a DataFrame that match a given list of names, 476 ignoring case sensitivity. 477 478 Parameters 479 ---------- 480 data : pandas.DataFrame 481 DataFrame with names in the columns. 482 483 names : list 484 List of names to search for. 485 486 Returns 487 ------- 488 dict 489 Dictionary with keys: 490 - "included": list of names found in the DataFrame columns. 491 - "not_included": list of requested names not found in the DataFrame columns. 492 - "potential": list of names in the DataFrame that may be similar. 493 """ 494 495 names_upper = [str(x).upper() for x in names] 496 497 columns = set(data.columns) 498 499 names_in = [x for x in columns if x.upper() in names_upper] 500 names_in_upper = [x.upper() for x in names_in] 501 names_out_upper = [x for x in names_upper if x not in names_in_upper] 502 names_out = [x for x in names if x.upper() in names_out_upper] 503 similar_names = [ 504 idx for idx in columns if any(x in idx.upper() for x in names_out_upper) 505 ] 506 507 return {"included": names_in, "not_included": names_out, "potential": similar_names} 508 509 510def reduce_data(data: pd.DataFrame, features: list = [], names: list = []): 511 """ 512 Subset a DataFrame based on selected features (rows) and/or names (columns). 513 514 Parameters 515 ---------- 516 data : pandas.DataFrame 517 Input DataFrame with features as rows and names as columns. 518 519 features : list 520 List of features to include (rows). Default is an empty list. 521 If empty, all rows are returned. 522 523 names : list 524 List of names to include (columns). Default is an empty list. 525 If empty, all columns are returned. 526 527 Returns 528 ------- 529 pandas.DataFrame 530 Subset of the input DataFrame containing only the selected rows 531 and/or columns. 532 533 Raises 534 ------ 535 ValueError 536 If both `features` and `names` are empty. 537 """ 538 539 if len(features) > 0 and len(names) > 0: 540 fet = find_features(data=data, features=features) 541 542 nam = find_names(data=data, names=names) 543 544 data_to_return = data.loc[fet["included"], nam["included"]] 545 546 elif len(features) > 0 and len(names) == 0: 547 fet = find_features(data=data, features=features) 548 549 data_to_return = data.loc[fet["included"], :] 550 551 elif len(features) == 0 and len(names) > 0: 552 553 nam = find_names(data=data, names=names) 554 555 data_to_return = data.loc[:, nam["included"]] 556 557 else: 558 559 raise ValueError("features and names have zero length!") 560 561 return data_to_return 562 563 564def make_unique_list(lst): 565 """ 566 Generate a list where duplicate items are renamed to ensure uniqueness. 567 568 Each duplicate is appended with a suffix ".n", where n indicates the 569 occurrence count (starting from 1). 570 571 Parameters 572 ---------- 573 lst : list 574 Input list of items (strings or other hashable types). 575 576 Returns 577 ------- 578 list 579 List with unique values. 580 581 Examples 582 -------- 583 >>> make_unique_list(["A", "B", "A", "A"]) 584 ['A', 'B', 'A.1', 'A.2'] 585 """ 586 seen = {} 587 result = [] 588 for item in lst: 589 if item not in seen: 590 seen[item] = 0 591 result.append(item) 592 else: 593 seen[item] += 1 594 result.append(f"{item}.{seen[item]}") 595 return result 596 597 598def get_color_palette(variable_list, palette_name="tab10"): 599 n = len(variable_list) 600 cmap = plt.get_cmap(palette_name) 601 colors = [cmap(i % cmap.N) for i in range(n)] 602 return dict(zip(variable_list, colors)) 603 604 605def features_dot( 606 expression_data: pd.DataFrame, 607 occurence_data: pd.DataFrame | None = None, 608 scale: bool = False, 609 scale_axis: Literal["x", "y"] = "y", 610 features: list | None = None, 611 metadata_list: list | None = None, 612 colors: str = "viridis", 613 hclust: str | None = "complete", 614 img_width: int = 8, 615 img_high: int = 5, 616 label_size: int = 10, 617 size_scale: int = 100, 618 y_lab: str = "Genes", 619 legend_lab: str = "log(CPM + 1)", 620 set_box_size: float | int = 5, 621 set_box_high: float | int = 5, 622 bbox_to_anchor_scale: int = 25, 623 bbox_to_anchor_perc: tuple = (0.91, 0.63), 624 bbox_to_anchor_group: tuple = (1.01, 0.4), 625): 626 """ 627 Create a dot plot of selected features across samples. 628 629 Each point represents a feature-sample pair, where the color encodes the 630 expression value and the size encodes occurrence or relative abundance. 631 Optionally, hierarchical clustering can be applied to order rows and columns. 632 633 Parameters 634 ---------- 635 expression_data : pandas.DataFrame 636 Expression values (mean) with features as rows and samples as columns derived from average() function. 637 638 occurence_data : pandas.DataFrame or None 639 DataFrame with occurrence/frequency values (same shape as `expression_data`) derived from occurrence() function. 640 If None, bubble sizes are based on expression values. 641 642 scale: bool, default False 643 If True, expression_data (features) will be scaled (0–1) across the colums (sample) with Maximum Absolute Scaling method. 644 645 scale_axis : {'x', 'y'}, default 'y' 646 The axis along which to scale the expression data to the [0, 1] range: 647 - 'y': Scales across columns (per sample / cell). 648 - 'x': Scales across rows (per feature / gene). 649 Only effective if `scale=True`. 650 651 features : list or None 652 List of features (rows) to display. If None, all features are used. 653 654 metadata_list : list or None, optional 655 Metadata grouping for samples (same length as number of columns). 656 Used to add group colors and separators in the plot. 657 658 colors : str, default='viridis' 659 Colormap for expression values. 660 661 hclust : str or None, default='complete' 662 Linkage method for hierarchical clustering. If None, no clustering 663 is performed. 664 665 img_width : int or float, default=8 666 Width of the plot in inches. 667 668 img_high : int or float, default=5 669 Height of the plot in inches. 670 671 label_size : int, default=10 672 Font size for axis labels and ticks. 673 674 size_scale : int or float, default=100 675 Scaling factor for bubble sizes. 676 677 y_lab : str, default='Genes' 678 Label for the x-axis. 679 680 legend_lab : str, default='log(CPM + 1)' 681 Label for the colorbar legend. 682 683 bbox_to_anchor_scale : int, default=25 684 Vertical scale (percentage) for positioning the colorbar. 685 686 bbox_to_anchor_perc : tuple, default=(0.91, 0.63) 687 Anchor position for the size legend (percent bubble legend). 688 689 bbox_to_anchor_group : tuple, default=(1.01, 0.4) 690 Anchor position for the group legend. 691 692 Returns 693 ------- 694 matplotlib.figure.Figure 695 The generated dot plot figure. 696 697 Raises 698 ------ 699 ValueError 700 If `metadata_list` is provided but its length does not match 701 the number of columns in `expression_data`. 702 703 Notes 704 ----- 705 - Colors represent expression values normalized to the colormap. 706 - Bubble sizes represent occurrence values (or expression values if 707 `occurence_data` is None). 708 - If `metadata_list` is given, groups are indicated with colors and 709 dashed vertical separators. 710 """ 711 if scale and scale_axis not in ["x", "X", "y", "Y"]: 712 raise ValueError("Wrong axis to scale. scale_axis must be 'x' or 'y'.") 713 714 scatter_df = expression_data.copy() 715 716 metadata = {} 717 718 metadata["primary_names"] = [str(x) for x in scatter_df.columns] 719 720 if metadata_list is not None: 721 metadata["sets"] = metadata_list 722 723 if len(metadata["primary_names"]) != len(metadata["sets"]): 724 725 raise ValueError( 726 "Metadata list and DataFrame columns must have the same length." 727 ) 728 729 else: 730 731 metadata["sets"] = [""] * len(metadata["primary_names"]) 732 733 metadata = pd.DataFrame(metadata) 734 if features is not None: 735 scatter_df = scatter_df.loc[ 736 find_features(data=scatter_df, features=features)["included"], 737 ] 738 scatter_df.columns = metadata["primary_names"] + "#" + metadata["sets"] 739 740 if occurence_data is not None: 741 if features is not None: 742 occurence_data = occurence_data.loc[ 743 find_features(data=occurence_data, features=features)["included"], 744 ] 745 occurence_data.columns = metadata["primary_names"] + "#" + metadata["sets"] 746 747 # check duplicated names 748 749 tmp_columns = scatter_df.columns 750 751 new_cols = make_unique_list(list(tmp_columns)) 752 753 scatter_df.columns = new_cols 754 755 # scaling 756 if scale: 757 758 legend_lab = "Scaled\n" + legend_lab 759 760 column_max = scatter_df.max() 761 scatter_df = ( 762 scatter_df.div(column_max).replace([np.inf, -np.inf], np.nan).fillna(0) 763 ) 764 scatter_df = pd.DataFrame( 765 scatter_df, index=scatter_df.index, columns=scatter_df.columns 766 ) 767 768 if scale_axis.upper() == "X": 769 scatter_df = scatter_df.T 770 771 column_max = scatter_df.max() 772 scatter_df = ( 773 scatter_df.div(column_max).replace([np.inf, -np.inf], np.nan).fillna(0) 774 ) 775 scatter_df = pd.DataFrame( 776 scatter_df, index=scatter_df.index, columns=scatter_df.columns 777 ) 778 779 if scale_axis.upper() == "X": 780 scatter_df = scatter_df.T 781 782 if hclust is not None and len(expression_data.index) != 1: 783 784 Z = linkage(scatter_df, method=hclust) 785 786 # Get the order of features based on the dendrogram 787 order_of_features = dendrogram(Z, no_plot=True)["leaves"] 788 789 indexes_sort = list(scatter_df.index) 790 sorted_list_rows = [] 791 for n in order_of_features: 792 sorted_list_rows.append(indexes_sort[n]) 793 794 scatter_df = scatter_df.transpose() 795 796 Z = linkage(scatter_df, method=hclust) 797 798 # Get the order of features based on the dendrogram 799 order_of_features = dendrogram(Z, no_plot=True)["leaves"] 800 801 indexes_sort = list(scatter_df.index) 802 sorted_list_columns = [] 803 for n in order_of_features: 804 sorted_list_columns.append(indexes_sort[n]) 805 806 scatter_df = scatter_df.transpose() 807 808 scatter_df = scatter_df.loc[sorted_list_rows, sorted_list_columns] 809 810 if occurence_data is not None: 811 occurence_data = occurence_data.loc[sorted_list_rows, sorted_list_columns] 812 813 metadata["sets"] = [re.sub(".*#", "", x) for x in scatter_df.columns] 814 815 scatter_df.columns = [re.sub("#.*", "", x) for x in scatter_df.columns] 816 817 if occurence_data is not None: 818 occurence_data.columns = [re.sub("#.*", "", x) for x in occurence_data.columns] 819 820 fig, ax = plt.subplots(figsize=(img_width, img_high)) 821 822 norm = plt.Normalize(0, np.max(scatter_df)) 823 824 cmap = plt.get_cmap(colors) 825 826 # dot-plot 827 for i, _ in enumerate(scatter_df.index): 828 for j, _ in enumerate(scatter_df.columns): 829 if occurence_data is not None: 830 value_e = scatter_df.iloc[i, j] 831 value_o = occurence_data.iloc[i, j] 832 ax.scatter( 833 j, 834 i, 835 s=value_o * size_scale, 836 c=[cmap(norm(value_e))], 837 edgecolors="k", 838 linewidths=0.3, 839 ) 840 else: 841 value = scatter_df.iloc[i, j] 842 ax.scatter( 843 j, 844 i, 845 s=value * size_scale, 846 c=[cmap(norm(value))], 847 edgecolors="k", 848 linewidths=0.3, 849 ) 850 851 ax.set_yticks(range(len(scatter_df.index))) 852 ax.set_yticklabels(scatter_df.index, fontsize=label_size * 0.8) 853 ax.set_ylabel(y_lab, fontsize=label_size) 854 ax.set_xticks(range(len(scatter_df.columns))) 855 ax.set_xticklabels(scatter_df.columns, fontsize=label_size * 0.8, rotation=90) 856 857 ax_pos = ax.get_position() 858 859 width_fig = 0.01 860 height_fig = ax_pos.height * (bbox_to_anchor_scale / 100) 861 left_fig = ax_pos.x1 + 0.01 862 bottom_fig = ax_pos.y1 - height_fig 863 864 cax = fig.add_axes([left_fig, bottom_fig, width_fig, height_fig]) 865 cb = mpl.colorbar.ColorbarBase(cax, cmap=cmap, norm=norm) 866 cb.set_label(legend_lab, fontsize=label_size * 0.65) 867 cb.ax.tick_params(labelsize=label_size * 0.7) 868 869 if metadata_list is not None: 870 871 metadata_list = list(metadata["sets"]) 872 group_colors = get_color_palette(list(set(metadata_list)), palette_name="tab10") 873 874 for i, group in enumerate(metadata_list): 875 ax.add_patch( 876 plt.Rectangle( 877 (i - 0.5, len(scatter_df.index) - 0.1 * set_box_high), 878 1, 879 0.1 * set_box_size, 880 color=group_colors[group], 881 transform=ax.transData, 882 clip_on=False, 883 ) 884 ) 885 886 for i in range(1, len(metadata_list)): 887 if metadata_list[i] != metadata_list[i - 1]: 888 ax.axvline(i - 0.5, color="black", linestyle="--", lw=1) 889 890 group_patches = [ 891 mpatches.Patch(color=color, label=label) 892 for label, color in group_colors.items() 893 ] 894 fig.legend( 895 handles=group_patches, 896 title="Group", 897 fontsize=label_size * 0.7, 898 title_fontsize=label_size * 0.7, 899 loc="center left", 900 bbox_to_anchor=bbox_to_anchor_group, 901 frameon=False, 902 ) 903 904 # second legend (size) 905 if occurence_data is not None: 906 size_values = [0.25, 0.5, 1] 907 legend2_handles = [ 908 plt.Line2D( 909 [], 910 [], 911 marker="o", 912 linestyle="", 913 markersize=np.sqrt(v * size_scale * 0.5), 914 color="gray", 915 alpha=0.6, 916 label=f"{v * 100:.1f}", 917 ) 918 for v in size_values 919 ] 920 921 fig.legend( 922 handles=legend2_handles, 923 title="Percent [%]", 924 fontsize=label_size * 0.7, 925 title_fontsize=label_size * 0.7, 926 loc="center left", 927 bbox_to_anchor=bbox_to_anchor_perc, 928 frameon=False, 929 ) 930 931 _, ymax = ax.get_ylim() 932 933 ax.set_xlim(-0.5, len(scatter_df.columns) - 0.5) 934 ax.set_ylim(-0.5, ymax + 0.5) 935 936 return fig 937 938 939def calc_DEG( 940 data, 941 metadata_list: list | None = None, 942 entities: str | list | dict | None = None, 943 sets: str | list | dict | None = None, 944 min_exp: int | float = 0, 945 min_pct: int | float = 0.1, 946 n_proc: int = 10, 947): 948 """ 949 Perform differential gene expression (DEG) analysis on gene expression data. 950 951 The function compares groups of cells or samples (defined by `entities` or 952 `sets`) using the Mann–Whitney U test. It computes p-values, adjusted 953 p-values, fold changes, standardized effect sizes, and other statistics. 954 955 Parameters 956 ---------- 957 data : pandas.DataFrame 958 Expression matrix with features (e.g., genes) as rows and samples/cells 959 as columns. 960 961 metadata_list : list or None, optional 962 Metadata grouping corresponding to the columns in `data`. Required for 963 comparisons based on sets. Default is None. 964 965 entities : list, str, dict, or None, optional 966 Defines the comparison strategy: 967 - list of sample names → compare selected cells to the rest. 968 - 'All' → compare each sample/cell to all others. 969 - dict → user-defined groups for pairwise comparison. 970 - None → must be combined with `sets`. 971 972 sets : str, dict, or None, optional 973 Defines group-based comparisons: 974 - 'All' → compare each set/group to all others. 975 - dict with two groups → perform pairwise set comparison. 976 - None → must be combined with `entities`. 977 978 min_exp : float | int, default=0 979 Minimum expression threshold for filtering features. 980 981 min_pct : float | int, default=0.1 982 Minimum proportion of samples within the target group that must express 983 a feature for it to be tested. 984 985 n_proc : int, default=10 986 Number of parallel processes to use for statistical testing. 987 988 Returns 989 ------- 990 pandas.DataFrame or dict 991 Results of the differential expression analysis: 992 - If `entities` is a list → dict with keys: 'valid_cells', 993 'control_cells', and 'DEG' (results DataFrame). 994 - If `entities == 'All'` or `sets == 'All'` → DataFrame with results 995 for all groups. 996 - If pairwise comparison (dict for `entities` or `sets`) → DataFrame 997 with results for the specified groups. 998 999 The results DataFrame contains: 1000 - 'feature': feature name 1001 - 'p_val': raw p-value 1002 - 'adj_pval': adjusted p-value (multiple testing correction) 1003 - 'pct_valid': fraction of target group expressing the feature 1004 - 'pct_ctrl': fraction of control group expressing the feature 1005 - 'avg_valid': mean expression in target group 1006 - 'avg_ctrl': mean expression in control group 1007 - 'sd_valid': standard deviation in target group 1008 - 'sd_ctrl': standard deviation in control group 1009 - 'esm': effect size metric 1010 - 'FC': fold change 1011 - 'log(FC)': log2-transformed fold change 1012 - 'norm_diff': difference in mean expression 1013 1014 Raises 1015 ------ 1016 ValueError 1017 - If `metadata_list` is provided but its length does not match 1018 the number of columns in `data`. 1019 - If neither `entities` nor `sets` is provided. 1020 1021 Notes 1022 ----- 1023 - Mann–Whitney U test is used for group comparisons. 1024 - Multiple testing correction is applied using a simple 1025 Benjamini–Hochberg-like method. 1026 - Features expressed below `min_exp` or in fewer than `min_pct` of target 1027 samples are filtered out. 1028 - Parallelization is handled by `joblib.Parallel`. 1029 1030 Examples 1031 -------- 1032 Compare a selected list of cells against all others: 1033 1034 >>> result = calc_DEG(data, entities=["cell1", "cell2", "cell3"]) 1035 1036 Compare each group to others (based on metadata): 1037 1038 >>> result = calc_DEG(data, metadata_list=group_labels, sets="All") 1039 1040 Perform pairwise comparison between two predefined sets: 1041 1042 >>> sets = {"GroupA": ["A1", "A2"], "GroupB": ["B1", "B2"]} 1043 >>> result = calc_DEG(data, sets=sets) 1044 """ 1045 offset = 1e-100 1046 1047 metadata = {} 1048 1049 metadata["primary_names"] = [str(x) for x in data.columns] 1050 1051 if metadata_list is not None: 1052 metadata["sets"] = metadata_list 1053 1054 if len(metadata["primary_names"]) != len(metadata["sets"]): 1055 1056 raise ValueError( 1057 "Metadata list and DataFrame columns must have the same length." 1058 ) 1059 1060 else: 1061 1062 metadata["sets"] = [""] * len(metadata["primary_names"]) 1063 1064 metadata = pd.DataFrame(metadata) 1065 1066 def stat_calc(choose, feature_name): 1067 target_values = choose.loc[choose["DEG"] == "target", feature_name] 1068 rest_values = choose.loc[choose["DEG"] == "rest", feature_name] 1069 1070 pct_valid = (target_values > 0).sum() / len(target_values) 1071 pct_rest = (rest_values > 0).sum() / len(rest_values) 1072 1073 avg_valid = np.mean(target_values) 1074 avg_ctrl = np.mean(rest_values) 1075 sd_valid = np.std(target_values, ddof=1) 1076 sd_ctrl = np.std(rest_values, ddof=1) 1077 esm = (avg_valid - avg_ctrl) / np.sqrt(((sd_valid**2 + sd_ctrl**2) / 2)) 1078 1079 if np.sum(target_values) == np.sum(rest_values): 1080 p_val = 1.0 1081 else: 1082 _, p_val = stats.mannwhitneyu( 1083 target_values, rest_values, alternative="two-sided" 1084 ) 1085 1086 return { 1087 "feature": feature_name, 1088 "p_val": p_val, 1089 "pct_valid": pct_valid, 1090 "pct_ctrl": pct_rest, 1091 "avg_valid": avg_valid, 1092 "avg_ctrl": avg_ctrl, 1093 "sd_valid": sd_valid, 1094 "sd_ctrl": sd_ctrl, 1095 "esm": esm, 1096 } 1097 1098 def prepare_and_run_stat(choose, valid_group, min_exp, min_pct, n_proc): 1099 1100 def safe_min_half(series): 1101 filtered = series[(series > ((2**-1074) * 2)) & (series.notna())] 1102 return filtered.min() / 2 if not filtered.empty else 0 1103 1104 tmp_dat = choose[choose["DEG"] == "target"] 1105 tmp_dat = tmp_dat.drop("DEG", axis=1) 1106 1107 counts = (tmp_dat > min_exp).sum(axis=0) 1108 1109 total_count = tmp_dat.shape[0] 1110 1111 info = pd.DataFrame( 1112 {"feature": list(tmp_dat.columns), "pct": list(counts / total_count)} 1113 ) 1114 1115 del tmp_dat 1116 1117 drop_col = info["feature"][info["pct"] <= min_pct] 1118 1119 if len(drop_col) + 1 == len(choose.columns): 1120 drop_col = info["feature"][info["pct"] == 0] 1121 1122 del info 1123 1124 choose = choose.drop(list(drop_col), axis=1) 1125 1126 results = Parallel(n_jobs=n_proc)( 1127 delayed(stat_calc)(choose, feature) 1128 for feature in tqdm(choose.columns[choose.columns != "DEG"]) 1129 ) 1130 1131 if len(results) > 0: 1132 df = pd.DataFrame(results) 1133 1134 df = df[(df["avg_valid"] > 0) | (df["avg_ctrl"] > 0)] 1135 1136 df["valid_group"] = valid_group 1137 df.sort_values(by="p_val", inplace=True) 1138 1139 num_tests = len(df) 1140 df["adj_pval"] = np.minimum( 1141 1, (df["p_val"] * num_tests) / np.arange(1, num_tests + 1) 1142 ) 1143 1144 valid_factor = safe_min_half(df["avg_valid"]) 1145 ctrl_factor = safe_min_half(df["avg_ctrl"]) 1146 1147 cv_factor = min(valid_factor, ctrl_factor) 1148 1149 if cv_factor == 0: 1150 cv_factor = max(valid_factor, ctrl_factor) 1151 1152 if not np.isfinite(cv_factor) or cv_factor == 0: 1153 cv_factor += offset 1154 1155 valid = df["avg_valid"].where( 1156 df["avg_valid"] != 0, df["avg_valid"] + cv_factor 1157 ) 1158 ctrl = df["avg_ctrl"].where(df["avg_ctrl"] != 0, df["avg_ctrl"] + cv_factor) 1159 1160 df["FC"] = valid / ctrl 1161 1162 df["log(FC)"] = np.log2(df["FC"]) 1163 df["norm_diff"] = df["avg_valid"] - df["avg_ctrl"] 1164 1165 else: 1166 columns = [ 1167 "feature", 1168 "valid_group", 1169 "p_val", 1170 "adj_pval", 1171 "avg_valid", 1172 "avg_ctrl", 1173 "FC", 1174 "log(FC)", 1175 "norm_diff", 1176 ] 1177 df = pd.DataFrame(columns=columns) 1178 return df 1179 1180 choose = data.T 1181 1182 final_results = [] 1183 1184 if isinstance(entities, list) and sets is None: 1185 print("\nAnalysis started...\nComparing selected cells to the whole set...") 1186 1187 if metadata_list is None: 1188 choose.index = metadata["primary_names"] 1189 else: 1190 choose.index = metadata["primary_names"] + " # " + metadata["sets"] 1191 1192 if "#" not in entities[0]: 1193 choose.index = metadata["primary_names"] 1194 print( 1195 "You provided 'metadata_list', but did not include the set info (name # set) " 1196 "in the 'entities' list. " 1197 "Only the names will be compared, without considering the set information." 1198 ) 1199 1200 labels = ["target" if idx in entities else "rest" for idx in choose.index] 1201 valid = list( 1202 set(choose.index[[i for i, x in enumerate(labels) if x == "target"]]) 1203 ) 1204 1205 choose["DEG"] = labels 1206 choose = choose[choose["DEG"] != "drop"] 1207 1208 result_df = prepare_and_run_stat( 1209 choose.reset_index(drop=True), 1210 valid_group=valid, 1211 min_exp=min_exp, 1212 min_pct=min_pct, 1213 n_proc=n_proc, 1214 ) 1215 1216 return {"valid": valid, "control": "rest", "DEG": result_df} 1217 1218 elif entities == "All" and sets is None: 1219 print("\nAnalysis started...\nComparing each type of cell to others...") 1220 1221 if metadata_list is None: 1222 choose.index = metadata["primary_names"] 1223 else: 1224 choose.index = metadata["primary_names"] + " # " + metadata["sets"] 1225 1226 unique_labels = set(choose.index) 1227 1228 for label in tqdm(unique_labels): 1229 print(f"\nCalculating statistics for {label}") 1230 labels = ["target" if idx == label else "rest" for idx in choose.index] 1231 choose["DEG"] = labels 1232 choose = choose[choose["DEG"] != "drop"] 1233 result_df = prepare_and_run_stat( 1234 choose.copy(), 1235 valid_group=label, 1236 min_exp=min_exp, 1237 min_pct=min_pct, 1238 n_proc=n_proc, 1239 ) 1240 final_results.append(result_df) 1241 1242 final_results = pd.concat(final_results, ignore_index=True) 1243 1244 if metadata_list is None: 1245 final_results["valid_group"] = [ 1246 re.sub(" # ", "", x) for x in final_results["valid_group"] 1247 ] 1248 1249 return final_results 1250 1251 elif entities is None and sets == "All": 1252 print("\nAnalysis started...\nComparing each set/group to others...") 1253 choose.index = metadata["sets"] 1254 unique_sets = set(choose.index) 1255 1256 for label in tqdm(unique_sets): 1257 print(f"\nCalculating statistics for {label}") 1258 labels = ["target" if idx == label else "rest" for idx in choose.index] 1259 1260 choose["DEG"] = labels 1261 choose = choose[choose["DEG"] != "drop"] 1262 result_df = prepare_and_run_stat( 1263 choose.copy(), 1264 valid_group=label, 1265 min_exp=min_exp, 1266 min_pct=min_pct, 1267 n_proc=n_proc, 1268 ) 1269 final_results.append(result_df) 1270 1271 return pd.concat(final_results, ignore_index=True) 1272 1273 elif entities is None and isinstance(sets, dict): 1274 print("\nAnalysis started...\nComparing groups...") 1275 choose.index = metadata["sets"] 1276 1277 group_list = list(sets.keys()) 1278 if len(group_list) != 2: 1279 print("Only pairwise group comparison is supported.") 1280 return None 1281 1282 labels = [ 1283 ( 1284 "target" 1285 if idx in sets[group_list[0]] 1286 else "rest" if idx in sets[group_list[1]] else "drop" 1287 ) 1288 for idx in choose.index 1289 ] 1290 choose["DEG"] = labels 1291 choose = choose[choose["DEG"] != "drop"] 1292 1293 result_df = prepare_and_run_stat( 1294 choose.reset_index(drop=True), 1295 valid_group=group_list[0], 1296 min_exp=min_exp, 1297 min_pct=min_pct, 1298 n_proc=n_proc, 1299 ) 1300 return result_df 1301 1302 elif isinstance(entities, dict) and sets is None: 1303 print("\nAnalysis started...\nComparing groups...") 1304 1305 if metadata_list is None: 1306 choose.index = metadata["primary_names"] 1307 else: 1308 choose.index = metadata["primary_names"] + " # " + metadata["sets"] 1309 if "#" not in entities[list(entities.keys())[0]][0]: 1310 choose.index = metadata["primary_names"] 1311 print( 1312 "You provided 'metadata_list', but did not include the set info (name # set) " 1313 "in the 'entities' dict. " 1314 "Only the names will be compared, without considering the set information." 1315 ) 1316 1317 group_list = list(entities.keys()) 1318 if len(group_list) != 2: 1319 print("Only pairwise group comparison is supported.") 1320 return None 1321 1322 labels = [ 1323 ( 1324 "target" 1325 if idx in entities[group_list[0]] 1326 else "rest" if idx in entities[group_list[1]] else "drop" 1327 ) 1328 for idx in choose.index 1329 ] 1330 1331 choose["DEG"] = labels 1332 choose = choose[choose["DEG"] != "drop"] 1333 1334 result_df = prepare_and_run_stat( 1335 choose.reset_index(drop=True), 1336 valid_group=group_list[0], 1337 min_exp=min_exp, 1338 min_pct=min_pct, 1339 n_proc=n_proc, 1340 ) 1341 1342 return result_df.reset_index(drop=True) 1343 1344 else: 1345 raise ValueError( 1346 "You must specify either 'entities' or 'sets'. None were provided, which is not allowed for this analysis." 1347 ) 1348 1349 1350def average(data): 1351 """ 1352 Compute the column-wise average of a DataFrame, aggregating by column names. 1353 1354 If multiple columns share the same name, their values are averaged. 1355 1356 Parameters 1357 ---------- 1358 data : pandas.DataFrame 1359 Input DataFrame with numeric values. Columns with identical names 1360 will be aggregated by their mean. 1361 1362 Returns 1363 ------- 1364 pandas.DataFrame 1365 DataFrame with the same rows as the input but with unique columns, 1366 where duplicate columns have been replaced by their mean values. 1367 """ 1368 1369 wide_data = data 1370 1371 aggregated_df = wide_data.T.groupby(level=0).mean().T 1372 1373 return aggregated_df 1374 1375 1376def occurrence(data): 1377 """ 1378 Calculate the occurrence frequency of features in a DataFrame. 1379 1380 Converts the input DataFrame to binary (presence/absence) and computes 1381 the proportion of non-zero entries for each feature, aggregating by 1382 column names if duplicates exist. 1383 1384 Parameters 1385 ---------- 1386 data : pandas.DataFrame 1387 Input DataFrame with numeric values. Each column represents a feature. 1388 1389 Returns 1390 ------- 1391 pandas.DataFrame 1392 DataFrame with the same rows as the input, where each value represents 1393 the proportion of samples in which the feature is present (non-zero). 1394 Columns with identical names are aggregated. 1395 """ 1396 1397 binary_data = (data > 0).astype(int) 1398 1399 counts = binary_data.columns.value_counts() 1400 1401 binary_data = binary_data.T.groupby(level=0).sum().T 1402 binary_data = binary_data.astype(float) 1403 1404 for i in counts.index: 1405 binary_data.loc[:, i] = (binary_data.loc[:, i] / counts[i]).astype(float) 1406 1407 return binary_data 1408 1409 1410def add_subnames(names_list: list, parent_name: str, new_clusters: list): 1411 """ 1412 Append sub-cluster names to a parent name within a list of names. 1413 1414 This function replaces occurrences of `parent_name` in `names_list` with 1415 a concatenation of the parent name and corresponding sub-cluster name 1416 from `new_clusters` (formatted as "parent.subcluster"). Non-matching names 1417 are left unchanged. 1418 1419 Parameters 1420 ---------- 1421 names_list : list 1422 Original list of names (e.g., column names or cluster labels). 1423 1424 parent_name : str 1425 Name of the parent cluster to which sub-cluster names will be added. 1426 Must exist in `names_list`. 1427 1428 new_clusters : list 1429 List of sub-cluster names. Its length must match the number of times 1430 `parent_name` occurs in `names_list`. 1431 1432 Returns 1433 ------- 1434 list 1435 Updated list of names with sub-cluster names appended to the parent name. 1436 1437 Raises 1438 ------ 1439 ValueError 1440 - If `parent_name` is not found in `names_list`. 1441 - If `new_clusters` length does not match the number of occurrences of 1442 `parent_name`. 1443 1444 Examples 1445 -------- 1446 >>> add_subnames(['A', 'B', 'A'], 'A', ['1', '2']) 1447 ['A.1', 'B', 'A.2'] 1448 """ 1449 1450 if str(parent_name) not in [str(x) for x in names_list]: 1451 raise ValueError( 1452 "Parent name is missing from the original dataset`s column names!" 1453 ) 1454 1455 if len(new_clusters) != len([x for x in names_list if str(x) == str(parent_name)]): 1456 raise ValueError( 1457 "New cluster names list has a different length than the number of clusters in the original dataset!" 1458 ) 1459 1460 new_names = [] 1461 ixn = 0 1462 for _, i in enumerate(names_list): 1463 if str(i) == str(parent_name): 1464 1465 new_names.append(f"{parent_name}.{new_clusters[ixn]}") 1466 ixn += 1 1467 1468 else: 1469 new_names.append(i) 1470 1471 return new_names 1472 1473 1474def development_clust( 1475 data: pd.DataFrame, method: str = "ward", img_width: int = 5, img_high: int = 5 1476): 1477 """ 1478 Perform hierarchical clustering on the columns of a DataFrame and plot a dendrogram. 1479 1480 Uses Ward's method to cluster the transposed data (columns) and generates 1481 a dendrogram showing the relationships between features or samples. 1482 1483 Parameters 1484 ---------- 1485 data : pandas.DataFrame 1486 Input DataFrame with features as rows and samples/columns to be clustered. 1487 1488 method : str 1489 Method for hierarchical clustering. Options include: 1490 - 'ward' : minimizes the variance of clusters being merged. 1491 - 'single' : uses the minimum of the distances between all observations of the two sets. 1492 - 'complete' : uses the maximum of the distances between all observations of the two sets. 1493 - 'average' : uses the average of the distances between all observations of the two sets. 1494 1495 img_width : int or float, default=5 1496 Width of the resulting figure in inches. 1497 1498 img_high : int or float, default=5 1499 Height of the resulting figure in inches. 1500 1501 Returns 1502 ------- 1503 matplotlib.figure.Figure 1504 The dendrogram figure. 1505 """ 1506 1507 z = linkage(data.T, method=method) 1508 1509 figure, ax = plt.subplots(figsize=(img_width, img_high)) 1510 1511 dendrogram(z, labels=data.columns, orientation="left", ax=ax) 1512 1513 return figure 1514 1515 1516def adjust_cells_to_group_mean(data, data_avg, beta=0.2): 1517 """ 1518 Adjust each cell's values towards the mean of its group (centroid). 1519 1520 This function moves each cell's values in `data` slightly towards the 1521 corresponding group mean in `data_avg`, controlled by the parameter `beta`. 1522 1523 Parameters 1524 ---------- 1525 data : pandas.DataFrame 1526 Original data with features as rows and cells/samples as columns. 1527 1528 data_avg : pandas.DataFrame 1529 DataFrame of group averages (centroids) with features as rows and 1530 group names as columns. 1531 1532 beta : float, default=0.2 1533 Weight for adjustment towards the group mean. 0 = no adjustment, 1534 1 = fully replaced by the group mean. 1535 1536 Returns 1537 ------- 1538 pandas.DataFrame 1539 Adjusted data with the same shape as the input `data`. 1540 """ 1541 1542 df_adjusted = data.copy() 1543 1544 for group_name in data_avg.columns: 1545 col_idx = [ 1546 i 1547 for i, c in enumerate(df_adjusted.columns) 1548 if str(c).startswith(group_name) 1549 ] 1550 if not col_idx: 1551 continue 1552 1553 centroid = data_avg.loc[df_adjusted.index, group_name].to_numpy()[:, None] 1554 1555 df_adjusted.iloc[:, col_idx] = (1 - beta) * df_adjusted.iloc[ 1556 :, col_idx 1557 ].to_numpy() + beta * centroid 1558 1559 return df_adjusted
21def load_sparse(path: str, name: str): 22 """ 23 Load a sparse matrix dataset along with associated gene 24 and cell metadata, and return it as a dense DataFrame. 25 26 This function expects the input directory to contain three files in standard 27 10x Genomics format: 28 - "matrix.mtx": the gene expression matrix in Matrix Market format 29 - "genes.tsv": tab-separated file containing gene identifiers 30 - "barcodes.tsv": tab-separated file containing cell barcodes / names 31 32 Parameters 33 ---------- 34 path : str 35 Path to the directory containing the matrix and annotation files. 36 37 name : str 38 Label or dataset identifier to be assigned to all cells in the metadata. 39 40 Returns 41 ------- 42 data : pandas.DataFrame 43 Dense expression matrix where rows correspond to genes and columns 44 correspond to cells. 45 metadata : pandas.DataFrame 46 Metadata DataFrame with two columns: 47 - "cell_names": the names of the cells (barcodes) 48 - "sets": the dataset label assigned to each cell (from `name`) 49 50 Notes 51 ----- 52 The function converts the sparse matrix into a dense DataFrame. This may 53 require a large amount of memory for datasets with many cells and genes. 54 """ 55 56 data = mmread(os.path.join(path, "matrix.mtx")) 57 data = pd.DataFrame(data.todense()) 58 genes = pd.read_csv(os.path.join(path, "genes.tsv"), header=None, sep="\t") 59 names = pd.read_csv(os.path.join(path, "barcodes.tsv"), header=None, sep="\t") 60 data.columns = [str(x) for x in names[0]] 61 data.index = list(genes[0]) 62 63 names = list(data.columns) 64 sets = [name] * len(names) 65 66 metadata = pd.DataFrame({"cell_names": names, "sets": sets}) 67 68 return data, metadata
Load a sparse matrix dataset along with associated gene and cell metadata, and return it as a dense DataFrame.
This function expects the input directory to contain three files in standard 10x Genomics format:
- "matrix.mtx": the gene expression matrix in Matrix Market format
- "genes.tsv": tab-separated file containing gene identifiers
- "barcodes.tsv": tab-separated file containing cell barcodes / names
Parameters
path : str Path to the directory containing the matrix and annotation files.
name : str Label or dataset identifier to be assigned to all cells in the metadata.
Returns
data : pandas.DataFrame
Dense expression matrix where rows correspond to genes and columns
correspond to cells.
metadata : pandas.DataFrame
Metadata DataFrame with two columns:
- "cell_names": the names of the cells (barcodes)
- "sets": the dataset label assigned to each cell (from name)
Notes
The function converts the sparse matrix into a dense DataFrame. This may require a large amount of memory for datasets with many cells and genes.
71def volcano_plot( 72 deg_data: pd.DataFrame, 73 p_adj: bool = True, 74 top: int = 25, 75 top_rank: str = "p_value", 76 p_val: float | int = 0.05, 77 lfc: float | int = 0.25, 78 rescale_adj: bool = True, 79 image_width: int = 12, 80 image_high: int = 12, 81): 82 """ 83 Generate a volcano plot from differential expression results. 84 85 A volcano plot visualizes the relationship between statistical significance 86 (p-values or standarized p-value) and log(fold change) for each gene, highlighting 87 genes that pass significance thresholds. 88 89 Parameters 90 ---------- 91 deg_data : pandas.DataFrame 92 DataFrame containing differential expression results from calc_DEG() function. 93 94 p_adj : bool, default=True 95 If True, use adjusted p-values. If False, use raw p-values. 96 97 top : int, default=25 98 Number of top significant genes to highlight on the plot. 99 100 top_rank : str, default='p_value' 101 Statistic used primarily to determine the top significant genes to highlight on the plot. ['p_value' or 'FC'] 102 103 p_val : float | int, default=0.05 104 Significance threshold for p-values (or adjusted p-values). 105 106 lfc : float | int, default=0.25 107 Threshold for absolute log fold change. 108 109 rescale_adj : bool, default=True 110 If True, rescale p-values to avoid long breaks caused by outlier values. 111 112 image_width : int, default=12 113 Width of the generated plot in inches. 114 115 image_high : int, default=12 116 Height of the generated plot in inches. 117 118 Returns 119 ------- 120 matplotlib.figure.Figure 121 The generated volcano plot figure. 122 123 """ 124 125 if top_rank.upper() not in ["FC", "P_VALUE"]: 126 raise ValueError("top_rank must be either 'FC' or 'p_value'") 127 128 if p_adj: 129 pv = "adj_pval" 130 else: 131 pv = "p_val" 132 133 deg_df = deg_data.copy() 134 135 shift = 0.25 136 137 p_val_scale = "-log(p_val)" 138 139 min_minus = min(deg_df[pv][(deg_df[pv] != 0) & (deg_df["log(FC)"] < 0)]) 140 min_plus = min(deg_df[pv][(deg_df[pv] != 0) & (deg_df["log(FC)"] > 0)]) 141 142 zero_p_plus = deg_df[(deg_df[pv] == 0) & (deg_df["log(FC)"] > 0)] 143 zero_p_plus = zero_p_plus.sort_values(by="log(FC)", ascending=False).reset_index( 144 drop=True 145 ) 146 zero_p_plus[pv] = [ 147 (shift * x) * min_plus for x in range(1, len(zero_p_plus.index) + 1) 148 ] 149 150 zero_p_minus = deg_df[(deg_df[pv] == 0) & (deg_df["log(FC)"] < 0)] 151 zero_p_minus = zero_p_minus.sort_values(by="log(FC)", ascending=True).reset_index( 152 drop=True 153 ) 154 zero_p_minus[pv] = [ 155 (shift * x) * min_minus for x in range(1, len(zero_p_minus.index) + 1) 156 ] 157 158 tmp_p = deg_df[ 159 ((deg_df[pv] != 0) & (deg_df["log(FC)"] < 0)) 160 | ((deg_df[pv] != 0) & (deg_df["log(FC)"] > 0)) 161 ] 162 163 del deg_df 164 165 deg_df = pd.concat([zero_p_plus, tmp_p, zero_p_minus], ignore_index=True) 166 167 deg_df[pv] = deg_df[pv].replace(0, 2**-1074) 168 169 deg_df[p_val_scale] = -np.log10(deg_df[pv]) 170 171 deg_df["top100"] = None 172 173 if rescale_adj: 174 175 deg_df = deg_df.sort_values(by=p_val_scale, ascending=False) 176 177 deg_df = deg_df.reset_index(drop=True) 178 179 eps = 1e-300 180 doubled = [] 181 ratio = [] 182 for n, i in enumerate(deg_df.index): 183 for j in range(1, 6): 184 if ( 185 n + j < len(deg_df.index) 186 and (deg_df[p_val_scale][n] + eps) 187 / (deg_df[p_val_scale][n + j] + eps) 188 >= 2 189 ): 190 doubled.append(n) 191 ratio.append( 192 (deg_df[p_val_scale][n + j] + eps) 193 / (deg_df[p_val_scale][n] + eps) 194 ) 195 196 df = pd.DataFrame({"doubled": doubled, "ratio": ratio}) 197 df = df[df["doubled"] < 100] 198 199 df["ratio"] = (1 - df["ratio"]) / 5 200 df = df.reset_index(drop=True) 201 202 df = df.sort_values("doubled") 203 204 if len(df["doubled"]) == 1 and 0 in df["doubled"]: 205 df = df 206 else: 207 doubled2 = [] 208 209 for l in df["doubled"]: 210 if l + 1 != len(doubled) and l + 1 - l == 1: 211 doubled2.append(l) 212 doubled2.append(l + 1) 213 else: 214 break 215 216 doubled2 = sorted(set(doubled2), reverse=True) 217 218 if len(doubled2) > 1: 219 df = df[df["doubled"].isin(doubled2)] 220 df = df.sort_values("doubled", ascending=False) 221 df = df.reset_index(drop=True) 222 for c in df.index: 223 deg_df.loc[df["doubled"][c], p_val_scale] = deg_df.loc[ 224 df["doubled"][c] + 1, p_val_scale 225 ] * (1 + df["ratio"][c]) 226 227 deg_df.loc[(deg_df["log(FC)"] <= 0) & (deg_df[pv] <= p_val), "top100"] = "red" 228 deg_df.loc[(deg_df["log(FC)"] > 0) & (deg_df[pv] <= p_val), "top100"] = "blue" 229 deg_df.loc[deg_df[pv] > p_val, "top100"] = "lightgray" 230 231 if lfc > 0: 232 deg_df.loc[ 233 (deg_df["log(FC)"] <= lfc) & (deg_df["log(FC)"] >= -lfc), "top100" 234 ] = "lightgray" 235 236 down_int = len( 237 deg_df["top100"][(deg_df["log(FC)"] <= lfc * -1) & (deg_df[pv] <= p_val)] 238 ) 239 up_int = len(deg_df["top100"][(deg_df["log(FC)"] > lfc) & (deg_df[pv] <= p_val)]) 240 241 deg_df_up = deg_df[deg_df["log(FC)"] > 0] 242 243 if top_rank.upper() == "P_VALUE": 244 deg_df_up = deg_df_up.sort_values([pv, "log(FC)"], ascending=[True, False]) 245 elif top_rank.upper() == "FC": 246 deg_df_up = deg_df_up.sort_values(["log(FC)", pv], ascending=[False, True]) 247 248 deg_df_up = deg_df_up.reset_index(drop=True) 249 250 n = -1 251 l = 0 252 while True: 253 n += 1 254 if deg_df_up["log(FC)"][n] > lfc and deg_df_up[pv][n] <= p_val: 255 deg_df_up.loc[n, "top100"] = "green" 256 l += 1 257 if l == top or deg_df_up[pv][n] > p_val: 258 break 259 260 deg_df_down = deg_df[deg_df["log(FC)"] <= 0] 261 262 if top_rank.upper() == "P_VALUE": 263 deg_df_down = deg_df_down.sort_values([pv, "log(FC)"], ascending=[True, True]) 264 elif top_rank.upper() == "FC": 265 deg_df_down = deg_df_down.sort_values(["log(FC)", pv], ascending=[True, True]) 266 267 deg_df_down = deg_df_down.reset_index(drop=True) 268 269 n = -1 270 l = 0 271 while True: 272 n += 1 273 if deg_df_down["log(FC)"][n] < lfc * -1 and deg_df_down[pv][n] <= p_val: 274 deg_df_down.loc[n, "top100"] = "yellow" 275 276 l += 1 277 if l == top or deg_df_down[pv][n] > p_val: 278 break 279 280 deg_df = pd.concat([deg_df_up, deg_df_down]) 281 282 que = ["lightgray", "red", "blue", "yellow", "green"] 283 284 deg_df = deg_df.sort_values( 285 by="top100", key=lambda x: x.map({v: i for i, v in enumerate(que)}) 286 ) 287 288 deg_df = deg_df.reset_index(drop=True) 289 290 fig, ax = plt.subplots(figsize=(image_width, image_high)) 291 292 plt.scatter( 293 x=deg_df["log(FC)"], y=deg_df[p_val_scale], color=deg_df["top100"], zorder=2 294 ) 295 296 tl = deg_df[p_val_scale][deg_df[pv] >= p_val] 297 298 if len(tl) > 0: 299 300 line_p = np.max(tl) 301 302 else: 303 line_p = np.min(deg_df[p_val_scale]) 304 305 plt.plot( 306 [max(deg_df["log(FC)"]) * -1.1, max(deg_df["log(FC)"]) * 1.1], 307 [line_p, line_p], 308 linestyle="--", 309 linewidth=3, 310 color="lightgray", 311 zorder=1, 312 ) 313 314 if lfc > 0: 315 plt.plot( 316 [lfc * -1, lfc * -1], 317 [-3, max(deg_df[p_val_scale]) * 1.1], 318 linestyle="--", 319 linewidth=3, 320 color="lightgray", 321 zorder=1, 322 ) 323 plt.plot( 324 [lfc, lfc], 325 [-3, max(deg_df[p_val_scale]) * 1.1], 326 linestyle="--", 327 linewidth=3, 328 color="lightgray", 329 zorder=1, 330 ) 331 332 plt.xlabel("log(FC)") 333 plt.ylabel(p_val_scale) 334 plt.title("Volcano plot") 335 336 plt.ylim(min(deg_df[p_val_scale]) - 5, max(deg_df[p_val_scale]) * 1.25) 337 338 texts = [ 339 ax.text(deg_df["log(FC)"][i], deg_df[p_val_scale][i], deg_df["feature"][i]) 340 for i in deg_df.index 341 if deg_df["top100"][i] in ["green", "yellow"] 342 ] 343 344 adjust_text(texts, arrowprops=dict(arrowstyle="-", color="gray", alpha=0.5)) 345 346 legend_elements = [ 347 Line2D( 348 [0], 349 [0], 350 marker="o", 351 color="w", 352 label="top-upregulated", 353 markerfacecolor="green", 354 markersize=10, 355 ), 356 Line2D( 357 [0], 358 [0], 359 marker="o", 360 color="w", 361 label="top-downregulated", 362 markerfacecolor="yellow", 363 markersize=10, 364 ), 365 Line2D( 366 [0], 367 [0], 368 marker="o", 369 color="w", 370 label="upregulated", 371 markerfacecolor="blue", 372 markersize=10, 373 ), 374 Line2D( 375 [0], 376 [0], 377 marker="o", 378 color="w", 379 label="downregulated", 380 markerfacecolor="red", 381 markersize=10, 382 ), 383 Line2D( 384 [0], 385 [0], 386 marker="o", 387 color="w", 388 label="non-significant", 389 markerfacecolor="lightgray", 390 markersize=10, 391 ), 392 ] 393 394 ax.legend(handles=legend_elements, loc="upper right") 395 ax.grid(visible=False) 396 397 ax.annotate( 398 f"\nmin {pv} = " + str(p_val), 399 xy=(0.025, 0.975), 400 xycoords="axes fraction", 401 fontsize=12, 402 ) 403 404 if lfc > 0: 405 ax.annotate( 406 "\nmin log(FC) = " + str(lfc), 407 xy=(0.025, 0.95), 408 xycoords="axes fraction", 409 fontsize=12, 410 ) 411 412 ax.annotate( 413 "\nDownregulated: " + str(down_int), 414 xy=(0.025, 0.925), 415 xycoords="axes fraction", 416 fontsize=12, 417 color="red", 418 ) 419 420 ax.annotate( 421 "\nUpregulated: " + str(up_int), 422 xy=(0.025, 0.9), 423 xycoords="axes fraction", 424 fontsize=12, 425 color="blue", 426 ) 427 428 plt.show() 429 430 return fig
Generate a volcano plot from differential expression results.
A volcano plot visualizes the relationship between statistical significance (p-values or standarized p-value) and log(fold change) for each gene, highlighting genes that pass significance thresholds.
Parameters
deg_data : pandas.DataFrame DataFrame containing differential expression results from calc_DEG() function.
p_adj : bool, default=True If True, use adjusted p-values. If False, use raw p-values.
top : int, default=25 Number of top significant genes to highlight on the plot.
top_rank : str, default='p_value' Statistic used primarily to determine the top significant genes to highlight on the plot. ['p_value' or 'FC']
p_val : float | int, default=0.05 Significance threshold for p-values (or adjusted p-values).
lfc : float | int, default=0.25 Threshold for absolute log fold change.
rescale_adj : bool, default=True If True, rescale p-values to avoid long breaks caused by outlier values.
image_width : int, default=12 Width of the generated plot in inches.
image_high : int, default=12 Height of the generated plot in inches.
Returns
matplotlib.figure.Figure The generated volcano plot figure.
433def find_features(data: pd.DataFrame, features: list): 434 """ 435 Identify features (rows) from a DataFrame that match a given list of features, 436 ignoring case sensitivity. 437 438 Parameters 439 ---------- 440 data : pandas.DataFrame 441 DataFrame with features in the index (rows). 442 443 features : list 444 List of feature names to search for. 445 446 Returns 447 ------- 448 dict 449 Dictionary with keys: 450 - "included": list of features found in the DataFrame index. 451 - "not_included": list of requested features not found in the DataFrame index. 452 - "potential": list of features in the DataFrame that may be similar. 453 """ 454 455 features_upper = [str(x).upper() for x in features] 456 457 index_set = set(data.index) 458 459 features_in = [x for x in index_set if x.upper() in features_upper] 460 features_in_upper = [x.upper() for x in features_in] 461 features_out_upper = [x for x in features_upper if x not in features_in_upper] 462 features_out = [x for x in features if x.upper() in features_out_upper] 463 similar_features = [ 464 idx for idx in index_set if any(x in idx.upper() for x in features_out_upper) 465 ] 466 467 return { 468 "included": features_in, 469 "not_included": features_out, 470 "potential": similar_features, 471 }
Identify features (rows) from a DataFrame that match a given list of features, ignoring case sensitivity.
Parameters
data : pandas.DataFrame DataFrame with features in the index (rows).
features : list List of feature names to search for.
Returns
dict Dictionary with keys: - "included": list of features found in the DataFrame index. - "not_included": list of requested features not found in the DataFrame index. - "potential": list of features in the DataFrame that may be similar.
474def find_names(data: pd.DataFrame, names: list): 475 """ 476 Identify names (columns) from a DataFrame that match a given list of names, 477 ignoring case sensitivity. 478 479 Parameters 480 ---------- 481 data : pandas.DataFrame 482 DataFrame with names in the columns. 483 484 names : list 485 List of names to search for. 486 487 Returns 488 ------- 489 dict 490 Dictionary with keys: 491 - "included": list of names found in the DataFrame columns. 492 - "not_included": list of requested names not found in the DataFrame columns. 493 - "potential": list of names in the DataFrame that may be similar. 494 """ 495 496 names_upper = [str(x).upper() for x in names] 497 498 columns = set(data.columns) 499 500 names_in = [x for x in columns if x.upper() in names_upper] 501 names_in_upper = [x.upper() for x in names_in] 502 names_out_upper = [x for x in names_upper if x not in names_in_upper] 503 names_out = [x for x in names if x.upper() in names_out_upper] 504 similar_names = [ 505 idx for idx in columns if any(x in idx.upper() for x in names_out_upper) 506 ] 507 508 return {"included": names_in, "not_included": names_out, "potential": similar_names}
Identify names (columns) from a DataFrame that match a given list of names, ignoring case sensitivity.
Parameters
data : pandas.DataFrame DataFrame with names in the columns.
names : list List of names to search for.
Returns
dict Dictionary with keys: - "included": list of names found in the DataFrame columns. - "not_included": list of requested names not found in the DataFrame columns. - "potential": list of names in the DataFrame that may be similar.
511def reduce_data(data: pd.DataFrame, features: list = [], names: list = []): 512 """ 513 Subset a DataFrame based on selected features (rows) and/or names (columns). 514 515 Parameters 516 ---------- 517 data : pandas.DataFrame 518 Input DataFrame with features as rows and names as columns. 519 520 features : list 521 List of features to include (rows). Default is an empty list. 522 If empty, all rows are returned. 523 524 names : list 525 List of names to include (columns). Default is an empty list. 526 If empty, all columns are returned. 527 528 Returns 529 ------- 530 pandas.DataFrame 531 Subset of the input DataFrame containing only the selected rows 532 and/or columns. 533 534 Raises 535 ------ 536 ValueError 537 If both `features` and `names` are empty. 538 """ 539 540 if len(features) > 0 and len(names) > 0: 541 fet = find_features(data=data, features=features) 542 543 nam = find_names(data=data, names=names) 544 545 data_to_return = data.loc[fet["included"], nam["included"]] 546 547 elif len(features) > 0 and len(names) == 0: 548 fet = find_features(data=data, features=features) 549 550 data_to_return = data.loc[fet["included"], :] 551 552 elif len(features) == 0 and len(names) > 0: 553 554 nam = find_names(data=data, names=names) 555 556 data_to_return = data.loc[:, nam["included"]] 557 558 else: 559 560 raise ValueError("features and names have zero length!") 561 562 return data_to_return
Subset a DataFrame based on selected features (rows) and/or names (columns).
Parameters
data : pandas.DataFrame Input DataFrame with features as rows and names as columns.
features : list List of features to include (rows). Default is an empty list. If empty, all rows are returned.
names : list List of names to include (columns). Default is an empty list. If empty, all columns are returned.
Returns
pandas.DataFrame Subset of the input DataFrame containing only the selected rows and/or columns.
Raises
ValueError
If both features and names are empty.
565def make_unique_list(lst): 566 """ 567 Generate a list where duplicate items are renamed to ensure uniqueness. 568 569 Each duplicate is appended with a suffix ".n", where n indicates the 570 occurrence count (starting from 1). 571 572 Parameters 573 ---------- 574 lst : list 575 Input list of items (strings or other hashable types). 576 577 Returns 578 ------- 579 list 580 List with unique values. 581 582 Examples 583 -------- 584 >>> make_unique_list(["A", "B", "A", "A"]) 585 ['A', 'B', 'A.1', 'A.2'] 586 """ 587 seen = {} 588 result = [] 589 for item in lst: 590 if item not in seen: 591 seen[item] = 0 592 result.append(item) 593 else: 594 seen[item] += 1 595 result.append(f"{item}.{seen[item]}") 596 return result
Generate a list where duplicate items are renamed to ensure uniqueness.
Each duplicate is appended with a suffix ".n", where n indicates the occurrence count (starting from 1).
Parameters
lst : list Input list of items (strings or other hashable types).
Returns
list List with unique values.
Examples
>>> make_unique_list(["A", "B", "A", "A"])
['A', 'B', 'A.1', 'A.2']
606def features_dot( 607 expression_data: pd.DataFrame, 608 occurence_data: pd.DataFrame | None = None, 609 scale: bool = False, 610 scale_axis: Literal["x", "y"] = "y", 611 features: list | None = None, 612 metadata_list: list | None = None, 613 colors: str = "viridis", 614 hclust: str | None = "complete", 615 img_width: int = 8, 616 img_high: int = 5, 617 label_size: int = 10, 618 size_scale: int = 100, 619 y_lab: str = "Genes", 620 legend_lab: str = "log(CPM + 1)", 621 set_box_size: float | int = 5, 622 set_box_high: float | int = 5, 623 bbox_to_anchor_scale: int = 25, 624 bbox_to_anchor_perc: tuple = (0.91, 0.63), 625 bbox_to_anchor_group: tuple = (1.01, 0.4), 626): 627 """ 628 Create a dot plot of selected features across samples. 629 630 Each point represents a feature-sample pair, where the color encodes the 631 expression value and the size encodes occurrence or relative abundance. 632 Optionally, hierarchical clustering can be applied to order rows and columns. 633 634 Parameters 635 ---------- 636 expression_data : pandas.DataFrame 637 Expression values (mean) with features as rows and samples as columns derived from average() function. 638 639 occurence_data : pandas.DataFrame or None 640 DataFrame with occurrence/frequency values (same shape as `expression_data`) derived from occurrence() function. 641 If None, bubble sizes are based on expression values. 642 643 scale: bool, default False 644 If True, expression_data (features) will be scaled (0–1) across the colums (sample) with Maximum Absolute Scaling method. 645 646 scale_axis : {'x', 'y'}, default 'y' 647 The axis along which to scale the expression data to the [0, 1] range: 648 - 'y': Scales across columns (per sample / cell). 649 - 'x': Scales across rows (per feature / gene). 650 Only effective if `scale=True`. 651 652 features : list or None 653 List of features (rows) to display. If None, all features are used. 654 655 metadata_list : list or None, optional 656 Metadata grouping for samples (same length as number of columns). 657 Used to add group colors and separators in the plot. 658 659 colors : str, default='viridis' 660 Colormap for expression values. 661 662 hclust : str or None, default='complete' 663 Linkage method for hierarchical clustering. If None, no clustering 664 is performed. 665 666 img_width : int or float, default=8 667 Width of the plot in inches. 668 669 img_high : int or float, default=5 670 Height of the plot in inches. 671 672 label_size : int, default=10 673 Font size for axis labels and ticks. 674 675 size_scale : int or float, default=100 676 Scaling factor for bubble sizes. 677 678 y_lab : str, default='Genes' 679 Label for the x-axis. 680 681 legend_lab : str, default='log(CPM + 1)' 682 Label for the colorbar legend. 683 684 bbox_to_anchor_scale : int, default=25 685 Vertical scale (percentage) for positioning the colorbar. 686 687 bbox_to_anchor_perc : tuple, default=(0.91, 0.63) 688 Anchor position for the size legend (percent bubble legend). 689 690 bbox_to_anchor_group : tuple, default=(1.01, 0.4) 691 Anchor position for the group legend. 692 693 Returns 694 ------- 695 matplotlib.figure.Figure 696 The generated dot plot figure. 697 698 Raises 699 ------ 700 ValueError 701 If `metadata_list` is provided but its length does not match 702 the number of columns in `expression_data`. 703 704 Notes 705 ----- 706 - Colors represent expression values normalized to the colormap. 707 - Bubble sizes represent occurrence values (or expression values if 708 `occurence_data` is None). 709 - If `metadata_list` is given, groups are indicated with colors and 710 dashed vertical separators. 711 """ 712 if scale and scale_axis not in ["x", "X", "y", "Y"]: 713 raise ValueError("Wrong axis to scale. scale_axis must be 'x' or 'y'.") 714 715 scatter_df = expression_data.copy() 716 717 metadata = {} 718 719 metadata["primary_names"] = [str(x) for x in scatter_df.columns] 720 721 if metadata_list is not None: 722 metadata["sets"] = metadata_list 723 724 if len(metadata["primary_names"]) != len(metadata["sets"]): 725 726 raise ValueError( 727 "Metadata list and DataFrame columns must have the same length." 728 ) 729 730 else: 731 732 metadata["sets"] = [""] * len(metadata["primary_names"]) 733 734 metadata = pd.DataFrame(metadata) 735 if features is not None: 736 scatter_df = scatter_df.loc[ 737 find_features(data=scatter_df, features=features)["included"], 738 ] 739 scatter_df.columns = metadata["primary_names"] + "#" + metadata["sets"] 740 741 if occurence_data is not None: 742 if features is not None: 743 occurence_data = occurence_data.loc[ 744 find_features(data=occurence_data, features=features)["included"], 745 ] 746 occurence_data.columns = metadata["primary_names"] + "#" + metadata["sets"] 747 748 # check duplicated names 749 750 tmp_columns = scatter_df.columns 751 752 new_cols = make_unique_list(list(tmp_columns)) 753 754 scatter_df.columns = new_cols 755 756 # scaling 757 if scale: 758 759 legend_lab = "Scaled\n" + legend_lab 760 761 column_max = scatter_df.max() 762 scatter_df = ( 763 scatter_df.div(column_max).replace([np.inf, -np.inf], np.nan).fillna(0) 764 ) 765 scatter_df = pd.DataFrame( 766 scatter_df, index=scatter_df.index, columns=scatter_df.columns 767 ) 768 769 if scale_axis.upper() == "X": 770 scatter_df = scatter_df.T 771 772 column_max = scatter_df.max() 773 scatter_df = ( 774 scatter_df.div(column_max).replace([np.inf, -np.inf], np.nan).fillna(0) 775 ) 776 scatter_df = pd.DataFrame( 777 scatter_df, index=scatter_df.index, columns=scatter_df.columns 778 ) 779 780 if scale_axis.upper() == "X": 781 scatter_df = scatter_df.T 782 783 if hclust is not None and len(expression_data.index) != 1: 784 785 Z = linkage(scatter_df, method=hclust) 786 787 # Get the order of features based on the dendrogram 788 order_of_features = dendrogram(Z, no_plot=True)["leaves"] 789 790 indexes_sort = list(scatter_df.index) 791 sorted_list_rows = [] 792 for n in order_of_features: 793 sorted_list_rows.append(indexes_sort[n]) 794 795 scatter_df = scatter_df.transpose() 796 797 Z = linkage(scatter_df, method=hclust) 798 799 # Get the order of features based on the dendrogram 800 order_of_features = dendrogram(Z, no_plot=True)["leaves"] 801 802 indexes_sort = list(scatter_df.index) 803 sorted_list_columns = [] 804 for n in order_of_features: 805 sorted_list_columns.append(indexes_sort[n]) 806 807 scatter_df = scatter_df.transpose() 808 809 scatter_df = scatter_df.loc[sorted_list_rows, sorted_list_columns] 810 811 if occurence_data is not None: 812 occurence_data = occurence_data.loc[sorted_list_rows, sorted_list_columns] 813 814 metadata["sets"] = [re.sub(".*#", "", x) for x in scatter_df.columns] 815 816 scatter_df.columns = [re.sub("#.*", "", x) for x in scatter_df.columns] 817 818 if occurence_data is not None: 819 occurence_data.columns = [re.sub("#.*", "", x) for x in occurence_data.columns] 820 821 fig, ax = plt.subplots(figsize=(img_width, img_high)) 822 823 norm = plt.Normalize(0, np.max(scatter_df)) 824 825 cmap = plt.get_cmap(colors) 826 827 # dot-plot 828 for i, _ in enumerate(scatter_df.index): 829 for j, _ in enumerate(scatter_df.columns): 830 if occurence_data is not None: 831 value_e = scatter_df.iloc[i, j] 832 value_o = occurence_data.iloc[i, j] 833 ax.scatter( 834 j, 835 i, 836 s=value_o * size_scale, 837 c=[cmap(norm(value_e))], 838 edgecolors="k", 839 linewidths=0.3, 840 ) 841 else: 842 value = scatter_df.iloc[i, j] 843 ax.scatter( 844 j, 845 i, 846 s=value * size_scale, 847 c=[cmap(norm(value))], 848 edgecolors="k", 849 linewidths=0.3, 850 ) 851 852 ax.set_yticks(range(len(scatter_df.index))) 853 ax.set_yticklabels(scatter_df.index, fontsize=label_size * 0.8) 854 ax.set_ylabel(y_lab, fontsize=label_size) 855 ax.set_xticks(range(len(scatter_df.columns))) 856 ax.set_xticklabels(scatter_df.columns, fontsize=label_size * 0.8, rotation=90) 857 858 ax_pos = ax.get_position() 859 860 width_fig = 0.01 861 height_fig = ax_pos.height * (bbox_to_anchor_scale / 100) 862 left_fig = ax_pos.x1 + 0.01 863 bottom_fig = ax_pos.y1 - height_fig 864 865 cax = fig.add_axes([left_fig, bottom_fig, width_fig, height_fig]) 866 cb = mpl.colorbar.ColorbarBase(cax, cmap=cmap, norm=norm) 867 cb.set_label(legend_lab, fontsize=label_size * 0.65) 868 cb.ax.tick_params(labelsize=label_size * 0.7) 869 870 if metadata_list is not None: 871 872 metadata_list = list(metadata["sets"]) 873 group_colors = get_color_palette(list(set(metadata_list)), palette_name="tab10") 874 875 for i, group in enumerate(metadata_list): 876 ax.add_patch( 877 plt.Rectangle( 878 (i - 0.5, len(scatter_df.index) - 0.1 * set_box_high), 879 1, 880 0.1 * set_box_size, 881 color=group_colors[group], 882 transform=ax.transData, 883 clip_on=False, 884 ) 885 ) 886 887 for i in range(1, len(metadata_list)): 888 if metadata_list[i] != metadata_list[i - 1]: 889 ax.axvline(i - 0.5, color="black", linestyle="--", lw=1) 890 891 group_patches = [ 892 mpatches.Patch(color=color, label=label) 893 for label, color in group_colors.items() 894 ] 895 fig.legend( 896 handles=group_patches, 897 title="Group", 898 fontsize=label_size * 0.7, 899 title_fontsize=label_size * 0.7, 900 loc="center left", 901 bbox_to_anchor=bbox_to_anchor_group, 902 frameon=False, 903 ) 904 905 # second legend (size) 906 if occurence_data is not None: 907 size_values = [0.25, 0.5, 1] 908 legend2_handles = [ 909 plt.Line2D( 910 [], 911 [], 912 marker="o", 913 linestyle="", 914 markersize=np.sqrt(v * size_scale * 0.5), 915 color="gray", 916 alpha=0.6, 917 label=f"{v * 100:.1f}", 918 ) 919 for v in size_values 920 ] 921 922 fig.legend( 923 handles=legend2_handles, 924 title="Percent [%]", 925 fontsize=label_size * 0.7, 926 title_fontsize=label_size * 0.7, 927 loc="center left", 928 bbox_to_anchor=bbox_to_anchor_perc, 929 frameon=False, 930 ) 931 932 _, ymax = ax.get_ylim() 933 934 ax.set_xlim(-0.5, len(scatter_df.columns) - 0.5) 935 ax.set_ylim(-0.5, ymax + 0.5) 936 937 return fig
Create a dot plot of selected features across samples.
Each point represents a feature-sample pair, where the color encodes the expression value and the size encodes occurrence or relative abundance. Optionally, hierarchical clustering can be applied to order rows and columns.
Parameters
expression_data : pandas.DataFrame Expression values (mean) with features as rows and samples as columns derived from average() function.
occurence_data : pandas.DataFrame or None
DataFrame with occurrence/frequency values (same shape as expression_data) derived from occurrence() function.
If None, bubble sizes are based on expression values.
scale: bool, default False If True, expression_data (features) will be scaled (0–1) across the colums (sample) with Maximum Absolute Scaling method.
scale_axis : {'x', 'y'}, default 'y'
The axis along which to scale the expression data to the [0, 1] range:
- 'y': Scales across columns (per sample / cell).
- 'x': Scales across rows (per feature / gene).
Only effective if scale=True.
features : list or None List of features (rows) to display. If None, all features are used.
metadata_list : list or None, optional Metadata grouping for samples (same length as number of columns). Used to add group colors and separators in the plot.
colors : str, default='viridis' Colormap for expression values.
hclust : str or None, default='complete' Linkage method for hierarchical clustering. If None, no clustering is performed.
img_width : int or float, default=8 Width of the plot in inches.
img_high : int or float, default=5 Height of the plot in inches.
label_size : int, default=10 Font size for axis labels and ticks.
size_scale : int or float, default=100 Scaling factor for bubble sizes.
y_lab : str, default='Genes' Label for the x-axis.
legend_lab : str, default='log(CPM + 1)' Label for the colorbar legend.
bbox_to_anchor_scale : int, default=25 Vertical scale (percentage) for positioning the colorbar.
bbox_to_anchor_perc : tuple, default=(0.91, 0.63) Anchor position for the size legend (percent bubble legend).
bbox_to_anchor_group : tuple, default=(1.01, 0.4) Anchor position for the group legend.
Returns
matplotlib.figure.Figure The generated dot plot figure.
Raises
ValueError
If metadata_list is provided but its length does not match
the number of columns in expression_data.
Notes
- Colors represent expression values normalized to the colormap.
- Bubble sizes represent occurrence values (or expression values if
occurence_datais None). - If
metadata_listis given, groups are indicated with colors and dashed vertical separators.
940def calc_DEG( 941 data, 942 metadata_list: list | None = None, 943 entities: str | list | dict | None = None, 944 sets: str | list | dict | None = None, 945 min_exp: int | float = 0, 946 min_pct: int | float = 0.1, 947 n_proc: int = 10, 948): 949 """ 950 Perform differential gene expression (DEG) analysis on gene expression data. 951 952 The function compares groups of cells or samples (defined by `entities` or 953 `sets`) using the Mann–Whitney U test. It computes p-values, adjusted 954 p-values, fold changes, standardized effect sizes, and other statistics. 955 956 Parameters 957 ---------- 958 data : pandas.DataFrame 959 Expression matrix with features (e.g., genes) as rows and samples/cells 960 as columns. 961 962 metadata_list : list or None, optional 963 Metadata grouping corresponding to the columns in `data`. Required for 964 comparisons based on sets. Default is None. 965 966 entities : list, str, dict, or None, optional 967 Defines the comparison strategy: 968 - list of sample names → compare selected cells to the rest. 969 - 'All' → compare each sample/cell to all others. 970 - dict → user-defined groups for pairwise comparison. 971 - None → must be combined with `sets`. 972 973 sets : str, dict, or None, optional 974 Defines group-based comparisons: 975 - 'All' → compare each set/group to all others. 976 - dict with two groups → perform pairwise set comparison. 977 - None → must be combined with `entities`. 978 979 min_exp : float | int, default=0 980 Minimum expression threshold for filtering features. 981 982 min_pct : float | int, default=0.1 983 Minimum proportion of samples within the target group that must express 984 a feature for it to be tested. 985 986 n_proc : int, default=10 987 Number of parallel processes to use for statistical testing. 988 989 Returns 990 ------- 991 pandas.DataFrame or dict 992 Results of the differential expression analysis: 993 - If `entities` is a list → dict with keys: 'valid_cells', 994 'control_cells', and 'DEG' (results DataFrame). 995 - If `entities == 'All'` or `sets == 'All'` → DataFrame with results 996 for all groups. 997 - If pairwise comparison (dict for `entities` or `sets`) → DataFrame 998 with results for the specified groups. 999 1000 The results DataFrame contains: 1001 - 'feature': feature name 1002 - 'p_val': raw p-value 1003 - 'adj_pval': adjusted p-value (multiple testing correction) 1004 - 'pct_valid': fraction of target group expressing the feature 1005 - 'pct_ctrl': fraction of control group expressing the feature 1006 - 'avg_valid': mean expression in target group 1007 - 'avg_ctrl': mean expression in control group 1008 - 'sd_valid': standard deviation in target group 1009 - 'sd_ctrl': standard deviation in control group 1010 - 'esm': effect size metric 1011 - 'FC': fold change 1012 - 'log(FC)': log2-transformed fold change 1013 - 'norm_diff': difference in mean expression 1014 1015 Raises 1016 ------ 1017 ValueError 1018 - If `metadata_list` is provided but its length does not match 1019 the number of columns in `data`. 1020 - If neither `entities` nor `sets` is provided. 1021 1022 Notes 1023 ----- 1024 - Mann–Whitney U test is used for group comparisons. 1025 - Multiple testing correction is applied using a simple 1026 Benjamini–Hochberg-like method. 1027 - Features expressed below `min_exp` or in fewer than `min_pct` of target 1028 samples are filtered out. 1029 - Parallelization is handled by `joblib.Parallel`. 1030 1031 Examples 1032 -------- 1033 Compare a selected list of cells against all others: 1034 1035 >>> result = calc_DEG(data, entities=["cell1", "cell2", "cell3"]) 1036 1037 Compare each group to others (based on metadata): 1038 1039 >>> result = calc_DEG(data, metadata_list=group_labels, sets="All") 1040 1041 Perform pairwise comparison between two predefined sets: 1042 1043 >>> sets = {"GroupA": ["A1", "A2"], "GroupB": ["B1", "B2"]} 1044 >>> result = calc_DEG(data, sets=sets) 1045 """ 1046 offset = 1e-100 1047 1048 metadata = {} 1049 1050 metadata["primary_names"] = [str(x) for x in data.columns] 1051 1052 if metadata_list is not None: 1053 metadata["sets"] = metadata_list 1054 1055 if len(metadata["primary_names"]) != len(metadata["sets"]): 1056 1057 raise ValueError( 1058 "Metadata list and DataFrame columns must have the same length." 1059 ) 1060 1061 else: 1062 1063 metadata["sets"] = [""] * len(metadata["primary_names"]) 1064 1065 metadata = pd.DataFrame(metadata) 1066 1067 def stat_calc(choose, feature_name): 1068 target_values = choose.loc[choose["DEG"] == "target", feature_name] 1069 rest_values = choose.loc[choose["DEG"] == "rest", feature_name] 1070 1071 pct_valid = (target_values > 0).sum() / len(target_values) 1072 pct_rest = (rest_values > 0).sum() / len(rest_values) 1073 1074 avg_valid = np.mean(target_values) 1075 avg_ctrl = np.mean(rest_values) 1076 sd_valid = np.std(target_values, ddof=1) 1077 sd_ctrl = np.std(rest_values, ddof=1) 1078 esm = (avg_valid - avg_ctrl) / np.sqrt(((sd_valid**2 + sd_ctrl**2) / 2)) 1079 1080 if np.sum(target_values) == np.sum(rest_values): 1081 p_val = 1.0 1082 else: 1083 _, p_val = stats.mannwhitneyu( 1084 target_values, rest_values, alternative="two-sided" 1085 ) 1086 1087 return { 1088 "feature": feature_name, 1089 "p_val": p_val, 1090 "pct_valid": pct_valid, 1091 "pct_ctrl": pct_rest, 1092 "avg_valid": avg_valid, 1093 "avg_ctrl": avg_ctrl, 1094 "sd_valid": sd_valid, 1095 "sd_ctrl": sd_ctrl, 1096 "esm": esm, 1097 } 1098 1099 def prepare_and_run_stat(choose, valid_group, min_exp, min_pct, n_proc): 1100 1101 def safe_min_half(series): 1102 filtered = series[(series > ((2**-1074) * 2)) & (series.notna())] 1103 return filtered.min() / 2 if not filtered.empty else 0 1104 1105 tmp_dat = choose[choose["DEG"] == "target"] 1106 tmp_dat = tmp_dat.drop("DEG", axis=1) 1107 1108 counts = (tmp_dat > min_exp).sum(axis=0) 1109 1110 total_count = tmp_dat.shape[0] 1111 1112 info = pd.DataFrame( 1113 {"feature": list(tmp_dat.columns), "pct": list(counts / total_count)} 1114 ) 1115 1116 del tmp_dat 1117 1118 drop_col = info["feature"][info["pct"] <= min_pct] 1119 1120 if len(drop_col) + 1 == len(choose.columns): 1121 drop_col = info["feature"][info["pct"] == 0] 1122 1123 del info 1124 1125 choose = choose.drop(list(drop_col), axis=1) 1126 1127 results = Parallel(n_jobs=n_proc)( 1128 delayed(stat_calc)(choose, feature) 1129 for feature in tqdm(choose.columns[choose.columns != "DEG"]) 1130 ) 1131 1132 if len(results) > 0: 1133 df = pd.DataFrame(results) 1134 1135 df = df[(df["avg_valid"] > 0) | (df["avg_ctrl"] > 0)] 1136 1137 df["valid_group"] = valid_group 1138 df.sort_values(by="p_val", inplace=True) 1139 1140 num_tests = len(df) 1141 df["adj_pval"] = np.minimum( 1142 1, (df["p_val"] * num_tests) / np.arange(1, num_tests + 1) 1143 ) 1144 1145 valid_factor = safe_min_half(df["avg_valid"]) 1146 ctrl_factor = safe_min_half(df["avg_ctrl"]) 1147 1148 cv_factor = min(valid_factor, ctrl_factor) 1149 1150 if cv_factor == 0: 1151 cv_factor = max(valid_factor, ctrl_factor) 1152 1153 if not np.isfinite(cv_factor) or cv_factor == 0: 1154 cv_factor += offset 1155 1156 valid = df["avg_valid"].where( 1157 df["avg_valid"] != 0, df["avg_valid"] + cv_factor 1158 ) 1159 ctrl = df["avg_ctrl"].where(df["avg_ctrl"] != 0, df["avg_ctrl"] + cv_factor) 1160 1161 df["FC"] = valid / ctrl 1162 1163 df["log(FC)"] = np.log2(df["FC"]) 1164 df["norm_diff"] = df["avg_valid"] - df["avg_ctrl"] 1165 1166 else: 1167 columns = [ 1168 "feature", 1169 "valid_group", 1170 "p_val", 1171 "adj_pval", 1172 "avg_valid", 1173 "avg_ctrl", 1174 "FC", 1175 "log(FC)", 1176 "norm_diff", 1177 ] 1178 df = pd.DataFrame(columns=columns) 1179 return df 1180 1181 choose = data.T 1182 1183 final_results = [] 1184 1185 if isinstance(entities, list) and sets is None: 1186 print("\nAnalysis started...\nComparing selected cells to the whole set...") 1187 1188 if metadata_list is None: 1189 choose.index = metadata["primary_names"] 1190 else: 1191 choose.index = metadata["primary_names"] + " # " + metadata["sets"] 1192 1193 if "#" not in entities[0]: 1194 choose.index = metadata["primary_names"] 1195 print( 1196 "You provided 'metadata_list', but did not include the set info (name # set) " 1197 "in the 'entities' list. " 1198 "Only the names will be compared, without considering the set information." 1199 ) 1200 1201 labels = ["target" if idx in entities else "rest" for idx in choose.index] 1202 valid = list( 1203 set(choose.index[[i for i, x in enumerate(labels) if x == "target"]]) 1204 ) 1205 1206 choose["DEG"] = labels 1207 choose = choose[choose["DEG"] != "drop"] 1208 1209 result_df = prepare_and_run_stat( 1210 choose.reset_index(drop=True), 1211 valid_group=valid, 1212 min_exp=min_exp, 1213 min_pct=min_pct, 1214 n_proc=n_proc, 1215 ) 1216 1217 return {"valid": valid, "control": "rest", "DEG": result_df} 1218 1219 elif entities == "All" and sets is None: 1220 print("\nAnalysis started...\nComparing each type of cell to others...") 1221 1222 if metadata_list is None: 1223 choose.index = metadata["primary_names"] 1224 else: 1225 choose.index = metadata["primary_names"] + " # " + metadata["sets"] 1226 1227 unique_labels = set(choose.index) 1228 1229 for label in tqdm(unique_labels): 1230 print(f"\nCalculating statistics for {label}") 1231 labels = ["target" if idx == label else "rest" for idx in choose.index] 1232 choose["DEG"] = labels 1233 choose = choose[choose["DEG"] != "drop"] 1234 result_df = prepare_and_run_stat( 1235 choose.copy(), 1236 valid_group=label, 1237 min_exp=min_exp, 1238 min_pct=min_pct, 1239 n_proc=n_proc, 1240 ) 1241 final_results.append(result_df) 1242 1243 final_results = pd.concat(final_results, ignore_index=True) 1244 1245 if metadata_list is None: 1246 final_results["valid_group"] = [ 1247 re.sub(" # ", "", x) for x in final_results["valid_group"] 1248 ] 1249 1250 return final_results 1251 1252 elif entities is None and sets == "All": 1253 print("\nAnalysis started...\nComparing each set/group to others...") 1254 choose.index = metadata["sets"] 1255 unique_sets = set(choose.index) 1256 1257 for label in tqdm(unique_sets): 1258 print(f"\nCalculating statistics for {label}") 1259 labels = ["target" if idx == label else "rest" for idx in choose.index] 1260 1261 choose["DEG"] = labels 1262 choose = choose[choose["DEG"] != "drop"] 1263 result_df = prepare_and_run_stat( 1264 choose.copy(), 1265 valid_group=label, 1266 min_exp=min_exp, 1267 min_pct=min_pct, 1268 n_proc=n_proc, 1269 ) 1270 final_results.append(result_df) 1271 1272 return pd.concat(final_results, ignore_index=True) 1273 1274 elif entities is None and isinstance(sets, dict): 1275 print("\nAnalysis started...\nComparing groups...") 1276 choose.index = metadata["sets"] 1277 1278 group_list = list(sets.keys()) 1279 if len(group_list) != 2: 1280 print("Only pairwise group comparison is supported.") 1281 return None 1282 1283 labels = [ 1284 ( 1285 "target" 1286 if idx in sets[group_list[0]] 1287 else "rest" if idx in sets[group_list[1]] else "drop" 1288 ) 1289 for idx in choose.index 1290 ] 1291 choose["DEG"] = labels 1292 choose = choose[choose["DEG"] != "drop"] 1293 1294 result_df = prepare_and_run_stat( 1295 choose.reset_index(drop=True), 1296 valid_group=group_list[0], 1297 min_exp=min_exp, 1298 min_pct=min_pct, 1299 n_proc=n_proc, 1300 ) 1301 return result_df 1302 1303 elif isinstance(entities, dict) and sets is None: 1304 print("\nAnalysis started...\nComparing groups...") 1305 1306 if metadata_list is None: 1307 choose.index = metadata["primary_names"] 1308 else: 1309 choose.index = metadata["primary_names"] + " # " + metadata["sets"] 1310 if "#" not in entities[list(entities.keys())[0]][0]: 1311 choose.index = metadata["primary_names"] 1312 print( 1313 "You provided 'metadata_list', but did not include the set info (name # set) " 1314 "in the 'entities' dict. " 1315 "Only the names will be compared, without considering the set information." 1316 ) 1317 1318 group_list = list(entities.keys()) 1319 if len(group_list) != 2: 1320 print("Only pairwise group comparison is supported.") 1321 return None 1322 1323 labels = [ 1324 ( 1325 "target" 1326 if idx in entities[group_list[0]] 1327 else "rest" if idx in entities[group_list[1]] else "drop" 1328 ) 1329 for idx in choose.index 1330 ] 1331 1332 choose["DEG"] = labels 1333 choose = choose[choose["DEG"] != "drop"] 1334 1335 result_df = prepare_and_run_stat( 1336 choose.reset_index(drop=True), 1337 valid_group=group_list[0], 1338 min_exp=min_exp, 1339 min_pct=min_pct, 1340 n_proc=n_proc, 1341 ) 1342 1343 return result_df.reset_index(drop=True) 1344 1345 else: 1346 raise ValueError( 1347 "You must specify either 'entities' or 'sets'. None were provided, which is not allowed for this analysis." 1348 )
Perform differential gene expression (DEG) analysis on gene expression data.
The function compares groups of cells or samples (defined by entities or
sets) using the Mann–Whitney U test. It computes p-values, adjusted
p-values, fold changes, standardized effect sizes, and other statistics.
Parameters
data : pandas.DataFrame Expression matrix with features (e.g., genes) as rows and samples/cells as columns.
metadata_list : list or None, optional
Metadata grouping corresponding to the columns in data. Required for
comparisons based on sets. Default is None.
entities : list, str, dict, or None, optional
Defines the comparison strategy:
- list of sample names → compare selected cells to the rest.
- 'All' → compare each sample/cell to all others.
- dict → user-defined groups for pairwise comparison.
- None → must be combined with sets.
sets : str, dict, or None, optional
Defines group-based comparisons:
- 'All' → compare each set/group to all others.
- dict with two groups → perform pairwise set comparison.
- None → must be combined with entities.
min_exp : float | int, default=0 Minimum expression threshold for filtering features.
min_pct : float | int, default=0.1 Minimum proportion of samples within the target group that must express a feature for it to be tested.
n_proc : int, default=10 Number of parallel processes to use for statistical testing.
Returns
pandas.DataFrame or dict
Results of the differential expression analysis:
- If entities is a list → dict with keys: 'valid_cells',
'control_cells', and 'DEG' (results DataFrame).
- If entities == 'All' or sets == 'All' → DataFrame with results
for all groups.
- If pairwise comparison (dict for entities or sets) → DataFrame
with results for the specified groups.
The results DataFrame contains:
- 'feature': feature name
- 'p_val': raw p-value
- 'adj_pval': adjusted p-value (multiple testing correction)
- 'pct_valid': fraction of target group expressing the feature
- 'pct_ctrl': fraction of control group expressing the feature
- 'avg_valid': mean expression in target group
- 'avg_ctrl': mean expression in control group
- 'sd_valid': standard deviation in target group
- 'sd_ctrl': standard deviation in control group
- 'esm': effect size metric
- 'FC': fold change
- 'log(FC)': log2-transformed fold change
- 'norm_diff': difference in mean expression
Raises
ValueError
- If metadata_list is provided but its length does not match
the number of columns in data.
- If neither entities nor sets is provided.
Notes
- Mann–Whitney U test is used for group comparisons.
- Multiple testing correction is applied using a simple Benjamini–Hochberg-like method.
- Features expressed below
min_expor in fewer thanmin_pctof target samples are filtered out. - Parallelization is handled by
joblib.Parallel.
Examples
Compare a selected list of cells against all others:
>>> result = calc_DEG(data, entities=["cell1", "cell2", "cell3"])
Compare each group to others (based on metadata):
>>> result = calc_DEG(data, metadata_list=group_labels, sets="All")
Perform pairwise comparison between two predefined sets:
>>> sets = {"GroupA": ["A1", "A2"], "GroupB": ["B1", "B2"]}
>>> result = calc_DEG(data, sets=sets)
1351def average(data): 1352 """ 1353 Compute the column-wise average of a DataFrame, aggregating by column names. 1354 1355 If multiple columns share the same name, their values are averaged. 1356 1357 Parameters 1358 ---------- 1359 data : pandas.DataFrame 1360 Input DataFrame with numeric values. Columns with identical names 1361 will be aggregated by their mean. 1362 1363 Returns 1364 ------- 1365 pandas.DataFrame 1366 DataFrame with the same rows as the input but with unique columns, 1367 where duplicate columns have been replaced by their mean values. 1368 """ 1369 1370 wide_data = data 1371 1372 aggregated_df = wide_data.T.groupby(level=0).mean().T 1373 1374 return aggregated_df
Compute the column-wise average of a DataFrame, aggregating by column names.
If multiple columns share the same name, their values are averaged.
Parameters
data : pandas.DataFrame Input DataFrame with numeric values. Columns with identical names will be aggregated by their mean.
Returns
pandas.DataFrame DataFrame with the same rows as the input but with unique columns, where duplicate columns have been replaced by their mean values.
1377def occurrence(data): 1378 """ 1379 Calculate the occurrence frequency of features in a DataFrame. 1380 1381 Converts the input DataFrame to binary (presence/absence) and computes 1382 the proportion of non-zero entries for each feature, aggregating by 1383 column names if duplicates exist. 1384 1385 Parameters 1386 ---------- 1387 data : pandas.DataFrame 1388 Input DataFrame with numeric values. Each column represents a feature. 1389 1390 Returns 1391 ------- 1392 pandas.DataFrame 1393 DataFrame with the same rows as the input, where each value represents 1394 the proportion of samples in which the feature is present (non-zero). 1395 Columns with identical names are aggregated. 1396 """ 1397 1398 binary_data = (data > 0).astype(int) 1399 1400 counts = binary_data.columns.value_counts() 1401 1402 binary_data = binary_data.T.groupby(level=0).sum().T 1403 binary_data = binary_data.astype(float) 1404 1405 for i in counts.index: 1406 binary_data.loc[:, i] = (binary_data.loc[:, i] / counts[i]).astype(float) 1407 1408 return binary_data
Calculate the occurrence frequency of features in a DataFrame.
Converts the input DataFrame to binary (presence/absence) and computes the proportion of non-zero entries for each feature, aggregating by column names if duplicates exist.
Parameters
data : pandas.DataFrame Input DataFrame with numeric values. Each column represents a feature.
Returns
pandas.DataFrame DataFrame with the same rows as the input, where each value represents the proportion of samples in which the feature is present (non-zero). Columns with identical names are aggregated.
1411def add_subnames(names_list: list, parent_name: str, new_clusters: list): 1412 """ 1413 Append sub-cluster names to a parent name within a list of names. 1414 1415 This function replaces occurrences of `parent_name` in `names_list` with 1416 a concatenation of the parent name and corresponding sub-cluster name 1417 from `new_clusters` (formatted as "parent.subcluster"). Non-matching names 1418 are left unchanged. 1419 1420 Parameters 1421 ---------- 1422 names_list : list 1423 Original list of names (e.g., column names or cluster labels). 1424 1425 parent_name : str 1426 Name of the parent cluster to which sub-cluster names will be added. 1427 Must exist in `names_list`. 1428 1429 new_clusters : list 1430 List of sub-cluster names. Its length must match the number of times 1431 `parent_name` occurs in `names_list`. 1432 1433 Returns 1434 ------- 1435 list 1436 Updated list of names with sub-cluster names appended to the parent name. 1437 1438 Raises 1439 ------ 1440 ValueError 1441 - If `parent_name` is not found in `names_list`. 1442 - If `new_clusters` length does not match the number of occurrences of 1443 `parent_name`. 1444 1445 Examples 1446 -------- 1447 >>> add_subnames(['A', 'B', 'A'], 'A', ['1', '2']) 1448 ['A.1', 'B', 'A.2'] 1449 """ 1450 1451 if str(parent_name) not in [str(x) for x in names_list]: 1452 raise ValueError( 1453 "Parent name is missing from the original dataset`s column names!" 1454 ) 1455 1456 if len(new_clusters) != len([x for x in names_list if str(x) == str(parent_name)]): 1457 raise ValueError( 1458 "New cluster names list has a different length than the number of clusters in the original dataset!" 1459 ) 1460 1461 new_names = [] 1462 ixn = 0 1463 for _, i in enumerate(names_list): 1464 if str(i) == str(parent_name): 1465 1466 new_names.append(f"{parent_name}.{new_clusters[ixn]}") 1467 ixn += 1 1468 1469 else: 1470 new_names.append(i) 1471 1472 return new_names
Append sub-cluster names to a parent name within a list of names.
This function replaces occurrences of parent_name in names_list with
a concatenation of the parent name and corresponding sub-cluster name
from new_clusters (formatted as "parent.subcluster"). Non-matching names
are left unchanged.
Parameters
names_list : list Original list of names (e.g., column names or cluster labels).
parent_name : str
Name of the parent cluster to which sub-cluster names will be added.
Must exist in names_list.
new_clusters : list
List of sub-cluster names. Its length must match the number of times
parent_name occurs in names_list.
Returns
list Updated list of names with sub-cluster names appended to the parent name.
Raises
ValueError
- If parent_name is not found in names_list.
- If new_clusters length does not match the number of occurrences of
parent_name.
Examples
>>> add_subnames(['A', 'B', 'A'], 'A', ['1', '2'])
['A.1', 'B', 'A.2']
1475def development_clust( 1476 data: pd.DataFrame, method: str = "ward", img_width: int = 5, img_high: int = 5 1477): 1478 """ 1479 Perform hierarchical clustering on the columns of a DataFrame and plot a dendrogram. 1480 1481 Uses Ward's method to cluster the transposed data (columns) and generates 1482 a dendrogram showing the relationships between features or samples. 1483 1484 Parameters 1485 ---------- 1486 data : pandas.DataFrame 1487 Input DataFrame with features as rows and samples/columns to be clustered. 1488 1489 method : str 1490 Method for hierarchical clustering. Options include: 1491 - 'ward' : minimizes the variance of clusters being merged. 1492 - 'single' : uses the minimum of the distances between all observations of the two sets. 1493 - 'complete' : uses the maximum of the distances between all observations of the two sets. 1494 - 'average' : uses the average of the distances between all observations of the two sets. 1495 1496 img_width : int or float, default=5 1497 Width of the resulting figure in inches. 1498 1499 img_high : int or float, default=5 1500 Height of the resulting figure in inches. 1501 1502 Returns 1503 ------- 1504 matplotlib.figure.Figure 1505 The dendrogram figure. 1506 """ 1507 1508 z = linkage(data.T, method=method) 1509 1510 figure, ax = plt.subplots(figsize=(img_width, img_high)) 1511 1512 dendrogram(z, labels=data.columns, orientation="left", ax=ax) 1513 1514 return figure
Perform hierarchical clustering on the columns of a DataFrame and plot a dendrogram.
Uses Ward's method to cluster the transposed data (columns) and generates a dendrogram showing the relationships between features or samples.
Parameters
data : pandas.DataFrame Input DataFrame with features as rows and samples/columns to be clustered.
method : str Method for hierarchical clustering. Options include:
- 'ward' : minimizes the variance of clusters being merged.
- 'single' : uses the minimum of the distances between all observations of the two sets.
- 'complete' : uses the maximum of the distances between all observations of the two sets.
- 'average' : uses the average of the distances between all observations of the two sets.
img_width : int or float, default=5 Width of the resulting figure in inches.
img_high : int or float, default=5 Height of the resulting figure in inches.
Returns
matplotlib.figure.Figure The dendrogram figure.
1517def adjust_cells_to_group_mean(data, data_avg, beta=0.2): 1518 """ 1519 Adjust each cell's values towards the mean of its group (centroid). 1520 1521 This function moves each cell's values in `data` slightly towards the 1522 corresponding group mean in `data_avg`, controlled by the parameter `beta`. 1523 1524 Parameters 1525 ---------- 1526 data : pandas.DataFrame 1527 Original data with features as rows and cells/samples as columns. 1528 1529 data_avg : pandas.DataFrame 1530 DataFrame of group averages (centroids) with features as rows and 1531 group names as columns. 1532 1533 beta : float, default=0.2 1534 Weight for adjustment towards the group mean. 0 = no adjustment, 1535 1 = fully replaced by the group mean. 1536 1537 Returns 1538 ------- 1539 pandas.DataFrame 1540 Adjusted data with the same shape as the input `data`. 1541 """ 1542 1543 df_adjusted = data.copy() 1544 1545 for group_name in data_avg.columns: 1546 col_idx = [ 1547 i 1548 for i, c in enumerate(df_adjusted.columns) 1549 if str(c).startswith(group_name) 1550 ] 1551 if not col_idx: 1552 continue 1553 1554 centroid = data_avg.loc[df_adjusted.index, group_name].to_numpy()[:, None] 1555 1556 df_adjusted.iloc[:, col_idx] = (1 - beta) * df_adjusted.iloc[ 1557 :, col_idx 1558 ].to_numpy() + beta * centroid 1559 1560 return df_adjusted
Adjust each cell's values towards the mean of its group (centroid).
This function moves each cell's values in data slightly towards the
corresponding group mean in data_avg, controlled by the parameter beta.
Parameters
data : pandas.DataFrame Original data with features as rows and cells/samples as columns.
data_avg : pandas.DataFrame DataFrame of group averages (centroids) with features as rows and group names as columns.
beta : float, default=0.2 Weight for adjustment towards the group mean. 0 = no adjustment, 1 = fully replaced by the group mean.
Returns
pandas.DataFrame
Adjusted data with the same shape as the input data.