33from typing import TYPE_CHECKING , Literal , Optional
44
55import numpy as np
6+ import pandas as pd
67import scanpy as sc
78import scipy .cluster .hierarchy as sch
89
@@ -165,6 +166,37 @@ def _test_pair(
165166 return (min (p1 , p2 ), p1 , p2 , False )
166167
167168
169+ def _enrich_genes (
170+ gene_list : list [str ],
171+ background : list [str ],
172+ gene_sets : dict [str , list [str ]],
173+ ) -> tuple [Optional [str ], float ]:
174+ """Run gseapy enrichment for *gene_list* against *background*.
175+
176+ Used to evaluate a hypothetical merged cluster's enrichment against the
177+ global protein background.
178+
179+ Returns
180+ -------
181+ tuple[Optional[str], float]
182+ ``(top_term, Adjusted P-value)``. Returns ``(None, 1.0)`` when no
183+ terms are returned by gseapy.
184+ """
185+ import gseapy
186+
187+ er = gseapy .enrich (
188+ gene_list = gene_list ,
189+ gene_sets = gene_sets ,
190+ background = background ,
191+ outdir = None ,
192+ ).results
193+ if len (er ) == 0 :
194+ return None , 1.0
195+ er = pd .DataFrame (er )
196+ top = er .sort_values ('Adjusted P-value' , ascending = True ).iloc [0 ]
197+ return top ['Term' ], float (top ['Adjusted P-value' ])
198+
199+
168200def _should_merge (p1 : float , p2 : float , terms_agree : bool , adjusted_cutoff : float ) -> bool :
169201 """Return True if a pair should be merged based on Fisher test results.
170202
@@ -400,6 +432,7 @@ def _one_merge_round_dendrogram(
400432 compartment_col : str ,
401433 verbose : bool ,
402434 merge_log : Optional [list ] = None ,
435+ require_improved_enrichment : bool = False ,
403436) -> tuple [dict [str , str ], int ]:
404437 """One round of dendrogram flat-group pair testing and greedy merging.
405438
@@ -430,7 +463,13 @@ def _one_merge_round_dendrogram(
430463 Print per-pair decisions.
431464 merge_log
432465 If provided, a dict entry is appended for every merged pair, recording
433- ``c1``, ``c2``, ``min_p``, ``p1``, ``p2``, and ``terms_agree``.
466+ ``c1``, ``c2``, ``min_p``, ``p1``, ``p2``, ``terms_agree``,
467+ ``merged_term``, and ``merged_p``.
468+ require_improved_enrichment
469+ If True, compute the gseapy enrichment p-value of the merged cluster
470+ against the global protein background and reject the merge unless this
471+ p-value is strictly lower than the better of the two split clusters'
472+ stored enrichment p-values.
434473
435474 Returns
436475 -------
@@ -469,9 +508,41 @@ def _one_merge_round_dendrogram(
469508 )
470509
471510 if _should_merge (p1 , p2 , terms_agree , pv_cutoff_adjusted ):
511+ merged_term : Optional [str ] = None
512+ merged_p : Optional [float ] = None
513+ best_split_p : Optional [float ] = None
514+ if require_improved_enrichment :
515+ genes_merged = list (
516+ _gene_set (adata , cluster_col , c1 , gene_name_key )
517+ | _gene_set (adata , cluster_col , c2 , gene_name_key )
518+ )
519+ background = adata .obs [gene_name_key ].tolist ()
520+ merged_term , merged_p = _enrich_genes (genes_merged , background , gene_sets )
521+
522+ pval_col = f'{ compartment_col } _Adjusted P-value'
523+ p_c1 = float (adata .obs .loc [adata .obs [cluster_col ] == c1 , pval_col ].iloc [0 ])
524+ p_c2 = float (adata .obs .loc [adata .obs [cluster_col ] == c2 , pval_col ].iloc [0 ])
525+ best_split_p = min (p_c1 , p_c2 )
526+
527+ if merged_p >= best_split_p :
528+ if verbose :
529+ print (
530+ f' → merged({ merged_term } , p={ merged_p :.3g} )'
531+ f' not better than best split p={ best_split_p :.3g} ,'
532+ f' skip'
533+ )
534+ continue
535+
472536 if verbose :
473537 reason = 'terms agree' if terms_agree else 'both non-significant'
474- print (f' → { reason } , merge candidate' )
538+ if require_improved_enrichment :
539+ print (
540+ f' → { reason } ,'
541+ f' merged({ merged_term } , p={ merged_p :.3g} '
542+ f' < split p={ best_split_p :.3g} ), merge candidate'
543+ )
544+ else :
545+ print (f' → { reason } , merge candidate' )
475546 candidates .append (
476547 dict (
477548 c1 = c1 ,
@@ -481,6 +552,8 @@ def _one_merge_round_dendrogram(
481552 p1 = p1 ,
482553 p2 = p2 ,
483554 terms_agree = terms_agree ,
555+ merged_term = merged_term ,
556+ merged_p = merged_p ,
484557 )
485558 )
486559 elif verbose :
@@ -508,15 +581,21 @@ def _one_merge_round_dendrogram(
508581 'p1' : cand ['p1' ],
509582 'p2' : cand ['p2' ],
510583 'terms_agree' : cand ['terms_agree' ],
584+ 'merged_term' : cand .get ('merged_term' ),
585+ 'merged_p' : cand .get ('merged_p' ),
511586 }
512587 )
513588 if verbose :
514589 t1 = _best_term (adata , cluster_col , c1 , compartment_col )
515590 t2 = _best_term (adata , cluster_col , c2 , compartment_col )
516591 reason = 'terms agree' if cand ['terms_agree' ] else f"min_p={ cand ['min_p' ]:.3g} "
592+ extra = ''
593+ if cand .get ('merged_p' ) is not None :
594+ extra = f" merged({ cand .get ('merged_term' )} ," f" p={ cand ['merged_p' ]:.3g} )"
517595 print (
518596 f' { c1 } ({ t1 } ) vs { c2 } ({ t2 } ): conn={ cand ["conn_val" ]:.3f} '
519- f' p1={ cand ["p1" ]:.3g} p2={ cand ["p2" ]:.3g} → MERGE ({ reason } )'
597+ f' p1={ cand ["p1" ]:.3g} p2={ cand ["p2" ]:.3g} { extra } '
598+ f' → MERGE ({ reason } )'
520599 )
521600
522601 return mapping , len (mapping )
@@ -877,6 +956,7 @@ def merge_clusters_go(
877956 verbose : bool = True ,
878957 plot_iterations : bool = False ,
879958 plot_dendrogram : bool = False ,
959+ require_improved_enrichment : bool = False ,
880960) -> None :
881961 """Iteratively merge overclustered Leiden clusters using PAGA and GO enrichment.
882962
@@ -940,6 +1020,13 @@ def merge_clusters_go(
9401020 plot_dendrogram
9411021 If True, plot the initial PAGA dendrogram after convergence with leaf
9421022 lines colored by compartment term and merge nodes colored by p-value.
1023+ require_improved_enrichment
1024+ If True, before each merge run a gseapy enrichment of the merged
1025+ cluster's gene list against the global protein background and only
1026+ merge when the merged cluster's best ``Adjusted P-value`` is strictly
1027+ lower than the better of the two split clusters' stored p-values.
1028+ Adds the merged cluster's top term and p-value to the verbose log
1029+ and to ``merge_log`` entries.
9431030 """
9441031 gene_sets = _load_gmt (gene_sets_path , species = species )
9451032
@@ -1003,6 +1090,7 @@ def merge_clusters_go(
10031090 compartment_col = compartment_col ,
10041091 verbose = verbose ,
10051092 merge_log = merge_log if plot_dendrogram else None ,
1093+ require_improved_enrichment = require_improved_enrichment ,
10061094 )
10071095
10081096 if n_merges == 0 :
0 commit comments