Skip to content

Commit f36d0f3

Browse files
committed
feat: option for only merging when improved enrichment
1 parent 708f65a commit f36d0f3

1 file changed

Lines changed: 91 additions & 3 deletions

File tree

grassp/tools/cluster_merging.py

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import TYPE_CHECKING, Literal, Optional
44

55
import numpy as np
6+
import pandas as pd
67
import scanpy as sc
78
import 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+
168200
def _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

Comments
 (0)