Skip to content

Commit 44f99fb

Browse files
authored
Merge pull request #12 from CostaLab/copilot/speedup-montecarlo-simulation
speedup: vectorise Monte Carlo permutation test in fisher_test_cci
2 parents b519471 + 119290e commit 44f99fb

13 files changed

Lines changed: 67 additions & 61 deletions
387 Bytes
Binary file not shown.
200 Bytes
Binary file not shown.
36.8 KB
Binary file not shown.
Binary file not shown.
6.94 KB
Binary file not shown.
321 Bytes
Binary file not shown.
3.05 KB
Binary file not shown.
2.2 KB
Binary file not shown.
31.3 KB
Binary file not shown.

pycrosstalker/tools/utils.py

Lines changed: 67 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
import re
1414
import json
1515

16+
# Small epsilon for floating-point tolerance when comparing p-values in Monte Carlo test
17+
_PVAL_TOL = 1e-10
18+
1619
#'Ranking the most interactive gene (ligand or receptor)
1720
#'
1821
#'@param data lrobject
@@ -404,6 +407,67 @@ def add_node_type(df):
404407
return df
405408

406409

410+
def _pairwise_stats(joined, B=1000, rng=None):
411+
"""Compute Fisher's exact test and Monte Carlo permutation p-values for each cell pair.
412+
413+
Parameters
414+
----------
415+
joined : pandas.DataFrame
416+
DataFrame with columns 'cellpair' (str), 'measure_ctr' (numeric count for the
417+
control condition), and 'measure_exp' (numeric count for the experimental condition).
418+
B : int
419+
Number of Monte Carlo permutations.
420+
rng : numpy.random.Generator, optional
421+
Random number generator for reproducibility; created with seed 42 if None.
422+
423+
Returns
424+
-------
425+
pandas.DataFrame
426+
DataFrame with columns 'cellpair', 'p_value', 'perm_p_value', 'lodds'
427+
"""
428+
if rng is None:
429+
rng = np.random.default_rng(42)
430+
431+
measure_ctr_sum = joined['measure_ctr'].sum()
432+
measure_exp_sum = joined['measure_exp'].sum()
433+
pvals = []
434+
435+
for _, row in joined.iterrows():
436+
ctotal = measure_ctr_sum - row['measure_ctr']
437+
etotal = measure_exp_sum - row['measure_exp']
438+
matrix = np.array([[row['measure_exp'], etotal], [row['measure_ctr'], ctotal]])
439+
440+
odds_ratio, p_value = fisher_exact(matrix, alternative="two-sided")
441+
442+
# Monte Carlo permutation test:
443+
# np.tile creates B copies of the 4-element flat matrix; rng.permuted then
444+
# independently shuffles each row (permutation), giving a (B, 4) array that
445+
# is reshaped into B individual 2×2 matrices.
446+
flat = matrix.flatten()
447+
permuted = rng.permuted(np.tile(flat, (B, 1)), axis=1).reshape(B, 2, 2)
448+
# Deduplicate: with only 4 values there are at most 4!=24 unique arrangements,
449+
# so we call fisher_exact only for each unique matrix instead of all B copies.
450+
unique_perms, inverse = np.unique(permuted.reshape(B, 4), axis=0, return_inverse=True)
451+
unique_pvals = np.array([
452+
fisher_exact(p.reshape(2, 2), alternative="two-sided")[1]
453+
for p in unique_perms
454+
])
455+
perm_pvals = unique_pvals[inverse]
456+
count = (perm_pvals <= p_value + _PVAL_TOL).sum()
457+
perm_p_value = (count + 1) / (B + 1)
458+
459+
lodds = np.log2(odds_ratio) if odds_ratio > 0 else None
460+
461+
pvals.append({
462+
'cellpair': row['cellpair'],
463+
'p_value': p_value,
464+
'perm_p_value': perm_p_value,
465+
'lodds': lodds,
466+
})
467+
468+
return pd.DataFrame(pvals)
469+
470+
407471
def fisher_test_cci(annData, measure, out_path, comparison=None):
408472
"""
409473
Evaluate Differences in the edge proportion
@@ -423,6 +487,7 @@ def fisher_test_cci(annData, measure, out_path, comparison=None):
423487
424488
"""
425489
data = annData.uns['pycrosstalker']['results']
490+
rng = np.random.default_rng(42)
426491

427492
if comparison is not None:
428493
for pair in comparison:
@@ -434,38 +499,8 @@ def fisher_test_cci(annData, measure, out_path, comparison=None):
434499

435500
joined = pd.merge(c, e, on='cellpair', how='outer', suffixes=('_ctr', '_exp'))
436501
joined.fillna(0, inplace=True)
437-
pvals = []
438-
measure_ctr_sum = joined['measure_ctr'].sum()
439-
measure_exp_sum = joined['measure_exp'].sum()
440-
for idx, row in joined.iterrows():
441-
ctotal = joined['measure_ctr'].sum() - row['measure_ctr']
442-
etotal = joined['measure_exp'].sum() - row['measure_exp']
443-
matrix = np.array([[row['measure_exp'], etotal], [row['measure_ctr'], ctotal]])
444-
445-
odds_ratio, p_value = fisher_exact(matrix, alternative="two-sided")
446-
447-
# Monte Carlo resampling (if B > 0)
448-
B = 1000
449-
np.random.seed(42) # For reproducibility
450-
if B > 0:
451-
count = 0
452-
for _ in range(B):
453-
shuffled = np.random.permutation(matrix.flatten()).reshape(matrix.shape)
454-
if fisher_exact(shuffled, alternative="two-sided")[1] <= p_value:
455-
count += 1
456-
perm_p_value = (count + 1) / (B + 1) # Avoid zero probability
457-
else:
458-
perm_p_value = None
459-
460-
lodds = np.log2(odds_ratio) if odds_ratio > 0 else None # Compute log odds ratio
461502

462-
pvals.append({
463-
'cellpair': row['cellpair'],
464-
'p_value': p_value,
465-
'lodds': lodds
466-
})
467-
468-
pval_df = pd.DataFrame(pvals)
503+
pval_df = _pairwise_stats(joined, rng=rng)
469504
data['stats'][f'{exp_name}_x_{ctr_name}'] = pval_df
470505

471506
# with open(os.path.join(out_path, "LR_data_final.pkl"), "wb") as f:
@@ -486,37 +521,8 @@ def fisher_test_cci(annData, measure, out_path, comparison=None):
486521
# Merge control and experimental data on 'cellpair'
487522
joined = pd.merge(c, e, on='cellpair', how='inner', suffixes=('_ctr', '_exp'))
488523
joined.fillna(0, inplace=True)
489-
pvals = []
490-
measure_ctr_sum = joined['measure_ctr'].sum()
491-
measure_exp_sum = joined['measure_exp'].sum()
492-
for idx, row in joined.iterrows():
493-
ctotal = measure_ctr_sum - row['measure_ctr']
494-
etotal = measure_exp_sum - row['measure_exp']
495-
matrix = np.array([[row['measure_exp'], etotal], [row['measure_ctr'], ctotal]])
496-
odds_ratio, p_value = fisher_exact(matrix, alternative="two-sided")
497-
498-
# Monte Carlo resampling (if B > 0)
499-
B = 1000
500-
np.random.seed(42) # For reproducibility
501-
if B > 0:
502-
count = 0
503-
for _ in range(B):
504-
shuffled = np.random.permutation(matrix.flatten()).reshape(matrix.shape)
505-
if fisher_exact(shuffled, alternative="two-sided")[1] <= p_value:
506-
count += 1
507-
perm_p_value = (count + 1) / (B + 1) # Avoid zero probability
508-
else:
509-
perm_p_value = None
510-
511-
lodds = np.log2(odds_ratio) if odds_ratio > 0 else None # Compute log odds ratio
512-
513-
pvals.append({
514-
'cellpair': row['cellpair'],
515-
'p_value': p_value,
516-
'lodds': lodds
517-
})
518524

519-
pval_df = pd.DataFrame(pvals)
525+
pval_df = _pairwise_stats(joined, rng=rng)
520526
data['stats'][f'{list(data["tables"].keys())[i]}_x_{list(data["tables"].keys())[0]}'] = pval_df
521527

522528
# with open(os.path.join(out_path, "LR_data_final.pkl"), "wb") as f:

0 commit comments

Comments
 (0)