mixalime 2.29.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mixalime/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ __version__ = '2.29.4'
2
+ import importlib
3
+
4
+
5
+ __min_reqs__ = [
6
+ 'pip>=23.0',
7
+ 'typer>=0.6.1',
8
+ 'numpy>=1.21.0',
9
+ 'jax<0.9',
10
+ 'jaxlib<0.9',
11
+ 'matplotlib>=3.5.1',
12
+ 'pandas>=1.4.1',
13
+ 'scipy<1.16',
14
+ 'statsmodels>=0.13.2',
15
+ 'betanegbinfit>=1.10.1',
16
+ 'datatable>=1.0.0' ,
17
+ 'dill>=0.3.7',
18
+ 'rich>=12.6.0',
19
+ 'portion>=2.3.0',
20
+ 'pysam>=0.22.0'
21
+ ]
22
+
23
+ def versiontuple(v):
24
+ return tuple(map(int, (v.split("."))))
25
+
26
+ def check_packages():
27
+ for req in __min_reqs__:
28
+ try:
29
+ module, ver = req.split(' @').split('>=')
30
+ ver = versiontuple(ver)
31
+ v = versiontuple(importlib.import_module(module).__version__)
32
+ except (AttributeError, ValueError):
33
+ continue
34
+ if v < ver:
35
+ raise ImportError(f'Version of the {module} package should be at least {ver} (found: {v}).')
mixalime/combine.py ADDED
@@ -0,0 +1,466 @@
1
+ # -*- coding: utf-8 -*-
2
+ from scipy import stats as st
3
+ import numpy as np
4
+ import dill
5
+ import os
6
+ import re
7
+ from .utils import get_init_file, openers, select_filenames
8
+ from multiprocessing import cpu_count, Pool, Manager
9
+ from collections import defaultdict
10
+ from functools import partial
11
+ from itertools import islice
12
+ from statsmodels.stats import multitest
13
+ import logging
14
+
15
+
16
+ def combine_p_values_logit(pvalues):
17
+ pvalues = np.array(list(filter(lambda x: x < 1, pvalues)))
18
+ k = len(pvalues)
19
+ if k == 0:
20
+ return 1.0
21
+ elif k == 1:
22
+ return float(pvalues[0])
23
+ # statistic = float(-sum(map(log, pvalues)) + sum(map(lambda x: log1p(-x), pvalues)))
24
+ statistic = -np.log(pvalues).sum() + np.log1p(-pvalues).sum()
25
+ nu = np.int_(5 * k + 4)
26
+ approx_factor = np.sqrt(np.int_(3) * nu / (np.int_(k) * np.square(np.pi) * (nu - np.int_(2))))
27
+ pval = st.distributions.t.sf(statistic * approx_factor, nu)
28
+ if pval == 0:
29
+ statistic = -2 * np.log(pvalues).sum()
30
+ # statistic = float(-2 * sum(map(log, pvalues)))
31
+ pval = st.distributions.chi2.sf(statistic, 2 * k)
32
+ return pval
33
+
34
+ def combine_es(es, pvalues, uniform_weights=False):
35
+ pvalues = np.array(list(map(float, pvalues)))
36
+ es = np.array(es)
37
+ weights = -np.log10(pvalues)
38
+ inds = np.isfinite(weights)
39
+ if not inds.sum():
40
+ return np.mean(es)
41
+ weights = weights[inds]
42
+ if uniform_weights:
43
+ weights[:] = 1.0
44
+ es = es[inds]
45
+ s = weights.sum()
46
+ if s == 0:
47
+ weights = 1 / len(weights)
48
+ else:
49
+ weights /= s
50
+ return np.sum(weights * es)
51
+
52
+ def estimate_min_coverage(test, es_cutoff: float = 1, pval_cutoff: float = 0.5):
53
+ res = defaultdict(dict)
54
+ for allele in test:
55
+ for bad in test[allele]:
56
+ it = test[allele][bad]
57
+ counts = np.array(list(it.keys()))
58
+ coverage = counts.sum(axis=1)
59
+ stop = False
60
+ for cov in np.unique(coverage):
61
+ ind = coverage == cov
62
+ pairs = counts[ind]
63
+ for t in pairs:
64
+ pvalue, es = it[tuple(t)]
65
+ if (es > es_cutoff) and (pvalue < pval_cutoff):
66
+ stop = True
67
+ break
68
+ if stop:
69
+ break
70
+ res[allele][bad] = cov if stop else float('inf')
71
+ return res
72
+
73
+ def combine_stats(inds, snvs, stats, groups, min_cnt_sum=20, uniform_weights=False):
74
+ pvalues = list()
75
+ es = list()
76
+ ks = list()
77
+ for i in inds:
78
+ k, lt = snvs[i]
79
+ lt = lt[1:]
80
+ if groups:
81
+ lt = filter(lambda x: x[0] in groups, lt)
82
+ lt = [t[1:] for t in lt]
83
+ if not lt or not max(sum(t[:-1]) >= min_cnt_sum[t[-1]] for t in lt):
84
+ ref = alt = ref_es = alt_es = np.nan
85
+ k = None
86
+ else:
87
+ ref_pvals, ref_es = zip(*[stats['ref'][t[-1]][t[:-1]] for t in lt])
88
+ alt_pvals, alt_es = zip(*[stats['alt'][t[-1]][t[:-1]] for t in lt])
89
+ ref = combine_p_values_logit(ref_pvals)
90
+ alt = combine_p_values_logit(alt_pvals)
91
+ ref_es = combine_es(ref_es, ref_pvals, uniform_weights=uniform_weights)
92
+ alt_es = combine_es(alt_es, alt_pvals, uniform_weights=uniform_weights)
93
+ pvalues.append((ref, alt))
94
+ es.append((ref_es, alt_es))
95
+ ks.append(k)
96
+ return pvalues, es, ks
97
+
98
+ def batched(iterable, n):
99
+ it = iter(iterable)
100
+ while (batch := tuple(islice(it, n))):
101
+ yield batch
102
+
103
+ def combine(name: str, group_files=None, alpha=0.05, min_cnt_sum=20, adaptive_min_cover=False, adaptive_es=1.0, adaptive_pval=0.05,
104
+ uniform_weights=False, filter_id=None, filter_chr=None, subname=None, n_jobs=1, save_to_file=True):
105
+ if filter_chr is not None:
106
+ filter_chr = re.compile(filter_chr)
107
+ if filter_id is not None:
108
+ filter_id = re.compile(filter_id)
109
+ n_jobs = cpu_count() - 1 if n_jobs == -1 else n_jobs
110
+ filename = get_init_file(name)
111
+ compressor = filename.split('.')[-1]
112
+ open = openers[compressor]
113
+ with open(filename, 'rb') as f:
114
+ init = dill.load(f)
115
+ snvs = init['snvs']
116
+ scorefiles = init['scorefiles']
117
+ del init
118
+ if group_files is None:
119
+ group_files = list()
120
+ else:
121
+ group_files = select_filenames(group_files, scorefiles)
122
+ if not group_files:
123
+ raise SyntaxError('No files found for given pattern(s).')
124
+ filename = f'{name}.test.{compressor}'
125
+ with open(filename, 'rb') as f:
126
+ stats = dill.load(f)
127
+ groups = set()
128
+ for file in group_files:
129
+ try:
130
+ i = scorefiles.index(file)
131
+ groups.add(i)
132
+ except ValueError:
133
+ logging.error(f'Unknown scorefile {file}')
134
+ if adaptive_min_cover:
135
+ adaptive_coverage = estimate_min_coverage(stats, es_cutoff=adaptive_es, pval_cutoff=adaptive_pval)
136
+ min_coverage = {bad: min(adaptive_coverage['ref'][bad], adaptive_coverage['alt'][bad]) for bad in adaptive_coverage['ref']}
137
+ else:
138
+ adaptive_coverage = None
139
+ min_coverage = {bad: min_cnt_sum for bad in stats['ref']}
140
+ del scorefiles
141
+ ref_comb_pvals = list()
142
+ alt_comb_pvals = list()
143
+ ref_comb_es = list()
144
+ alt_comb_es = list()
145
+ comb_names = list()
146
+ its = snvs.items()
147
+ del snvs
148
+ its = list(its)
149
+ if filter_id:
150
+ its = list(filter(lambda x: x[1][0][1] and filter_id.match(x[1][0][1]), its))
151
+ if filter_chr:
152
+ its = list(filter(lambda x: filter_chr.match(x[0]), its))
153
+ with Manager() as manager:
154
+ if n_jobs > 1:
155
+ its = manager.list(its)
156
+ with Pool(n_jobs) as p:
157
+ f = partial(combine_stats, snvs=its, stats=stats, groups=groups, min_cnt_sum=min_coverage,
158
+ uniform_weights=uniform_weights)
159
+ if n_jobs > 1:
160
+ sz = int(np.ceil(len(its) / n_jobs))
161
+ inds = batched(range(len(its)), sz)
162
+ iterate = p.map(f, inds, chunksize=1)
163
+ else:
164
+ iterate = map(f, [list(range(len(its)))])
165
+ for pvals, es, ks in iterate:
166
+ for (ref, alt), (ref_es, alt_es), k in zip(pvals, es, ks):
167
+ if k is None:
168
+ continue
169
+ ref_comb_pvals.append(ref)
170
+ alt_comb_pvals.append(alt)
171
+ ref_comb_es.append(ref_es)
172
+ alt_comb_es.append(alt_es)
173
+ comb_names.append(k)
174
+ ref_comb_pvals = np.array(ref_comb_pvals)
175
+ inds = ref_comb_pvals == 0.0
176
+ if np.any(inds):
177
+ ref_comb_pvals[inds] = ref_comb_pvals[~inds].min()
178
+ alt_comb_pvals = np.array(alt_comb_pvals)
179
+ inds = alt_comb_pvals == 0.0
180
+ if np.any(inds):
181
+ alt_comb_pvals[inds] = alt_comb_pvals[~inds].min()
182
+ _, ref_fdr_pvals, _, _ = multitest.multipletests(ref_comb_pvals, alpha=alpha, method='fdr_bh')
183
+ _, alt_fdr_pvals, _, _ = multitest.multipletests(alt_comb_pvals, alpha=alpha, method='fdr_bh')
184
+ res = dict()
185
+ for i in range(len(comb_names)):
186
+
187
+ res[comb_names[i]] = ((ref_comb_pvals[i], alt_comb_pvals[i]),
188
+ (ref_comb_es[i], alt_comb_es[i]),
189
+ (ref_fdr_pvals[i], alt_fdr_pvals[i]))
190
+ res = {'groups': groups, 'snvs': res}
191
+ res = {subname: res}
192
+ if os.path.isfile(f'{name}.comb.{compressor}'):
193
+ with open(f'{name}.comb.{compressor}', 'rb') as f:
194
+ print('eba')
195
+ r = dill.load(f)
196
+ print('gat')
197
+ for t in r:
198
+ if t not in res:
199
+ res[t] = r[t]
200
+ if save_to_file:
201
+ with open(f'{name}.comb.{compressor}', 'wb') as f:
202
+ dill.dump(res, f)
203
+ return res, adaptive_coverage
204
+
205
+
206
+ def polymbine_stats(inds, snvs, stats_list, groups_list, min_cnt_sums_list, uniform_weights=False):
207
+ """
208
+ Worker function for polymbine.
209
+ Aggregates statistics across multiple projects for the given indices of SNVs.
210
+ """
211
+ pvalues = list()
212
+ es = list()
213
+ ks = list()
214
+
215
+ for i in inds:
216
+ k, lt = snvs[i]
217
+ # lt[0] is metadata (id, ref, alt), lt[1:] are replicates
218
+ # Replicates in polymbine have an extra element at the end: project_index
219
+ replicates = lt[1:]
220
+
221
+ valid_pvals_ref = []
222
+ valid_es_ref = []
223
+ valid_pvals_alt = []
224
+ valid_es_alt = []
225
+
226
+ has_valid_data = False
227
+
228
+ for t in replicates:
229
+ # t structure: (file_index, ref_cnt, alt_cnt, ..., bad, project_index)
230
+ p_idx = t[-1]
231
+ bad = t[-2]
232
+ counts = t[:-2] # (file_index, ref, alt, ...)
233
+ file_ind = counts[0]
234
+ cnt_signature = counts[1:] # (ref, alt) usually
235
+
236
+ # Check if this file is in the allowed groups for this specific project
237
+ if groups_list[p_idx] and file_ind not in groups_list[p_idx]:
238
+ continue
239
+
240
+ # Check coverage using the project-specific threshold
241
+ min_cov = min_cnt_sums_list[p_idx].get(bad, 20)
242
+ if sum(cnt_signature) < min_cov:
243
+ continue
244
+
245
+ # Retrieve stats from the specific project's dictionary
246
+ # stats structure: stats_list[p_idx]['ref'][bad][(ref, alt)] -> (pval, es)
247
+ try:
248
+ # The original code expects count tuple without file_index for lookup
249
+ lookup_counts = cnt_signature
250
+
251
+ # Get Reference Allele Stats
252
+ r_pval, r_es = stats_list[p_idx]['ref'][bad][lookup_counts]
253
+ valid_pvals_ref.append(r_pval)
254
+ valid_es_ref.append(r_es)
255
+
256
+ # Get Alternative Allele Stats
257
+ a_pval, a_es = stats_list[p_idx]['alt'][bad][lookup_counts]
258
+ valid_pvals_alt.append(a_pval)
259
+ valid_es_alt.append(a_es)
260
+
261
+ has_valid_data = True
262
+ except KeyError:
263
+ # Should not happen if data integrity is maintained, but safe to skip
264
+ continue
265
+
266
+ if not has_valid_data:
267
+ ref = alt = ref_es = alt_es = np.nan
268
+ k = None
269
+ else:
270
+ ref = combine_p_values_logit(valid_pvals_ref)
271
+ alt = combine_p_values_logit(valid_pvals_alt)
272
+ ref_es = combine_es(valid_es_ref, valid_pvals_ref, uniform_weights=uniform_weights)
273
+ alt_es = combine_es(valid_es_alt, valid_pvals_alt, uniform_weights=uniform_weights)
274
+
275
+ pvalues.append((ref, alt))
276
+ es.append((ref_es, alt_es))
277
+ ks.append(k)
278
+
279
+ return pvalues, es, ks
280
+
281
+
282
+ def polymbine(output_name: str, names: list[str], group_files=None, alpha=0.05, min_cnt_sum=20,
283
+ adaptive_min_cover=False, adaptive_es=1.0, adaptive_pval=0.05,
284
+ uniform_weights=False, filter_id=None, filter_chr=None, subname=None,
285
+ n_jobs=1, save_to_file=True):
286
+ if filter_chr is not None:
287
+ filter_chr = re.compile(filter_chr)
288
+ if filter_id is not None:
289
+ filter_id = re.compile(filter_id)
290
+ n_jobs = cpu_count() - 1 if n_jobs == -1 else n_jobs
291
+
292
+ # Lists to store per-project data
293
+ all_stats = []
294
+ all_groups = []
295
+ all_min_coverage = []
296
+ adaptive_coverage = None
297
+
298
+ # Unified dictionary to store merged SNVs
299
+ # Key: (chr, pos, etc.) -> Value: [metadata, (data_p1...), (data_p2...), ...]
300
+ combined_snvs = defaultdict(lambda: [None])
301
+
302
+ # 1. Load data from all projects
303
+ for p_idx, name in enumerate(names):
304
+ filename = get_init_file(name)
305
+ if not filename:
306
+ raise FileNotFoundError(f"Init file not found for project: {name}")
307
+
308
+ compressor = filename.split('.')[-1]
309
+ opener = openers[compressor]
310
+
311
+ with opener(filename, 'rb') as f:
312
+ init = dill.load(f)
313
+ snvs = init['snvs']
314
+ scorefiles = init['scorefiles']
315
+ del init
316
+
317
+ # Determine groups for this project
318
+ current_groups = set()
319
+ if group_files:
320
+ if len(group_files) == len(names):
321
+ g_files = [group_files[p_idx]]
322
+ else:
323
+ g_files = group_files
324
+
325
+ selected_files = select_filenames(g_files, scorefiles)
326
+ for file in selected_files:
327
+ try:
328
+ i = scorefiles.index(file)
329
+ current_groups.add(i)
330
+ except ValueError:
331
+ continue
332
+ else:
333
+ # If no groups specified, all files in the project are valid,
334
+ # but usually empty set implies "all" in existing logic (or explicit logic handles it).
335
+ # Looking at combine_stats: "if groups: lt = filter(...)".
336
+ # So empty set means "don't filter", i.e., use all.
337
+ pass
338
+
339
+ all_groups.append(current_groups)
340
+
341
+ # Load stats
342
+ filename_test = f'{name}.test.{compressor}'
343
+ with opener(filename_test, 'rb') as f:
344
+ stats = dill.load(f)
345
+
346
+ # Calculate coverage thresholds
347
+ if adaptive_min_cover:
348
+ adaptive_coverage = estimate_min_coverage(stats, es_cutoff=adaptive_es, pval_cutoff=adaptive_pval)
349
+ min_coverage = {bad: min(adaptive_coverage['ref'][bad], adaptive_coverage['alt'][bad]) for bad in adaptive_coverage['ref']}
350
+ else:
351
+ min_coverage = {bad: min_cnt_sum for bad in stats['ref']}
352
+
353
+ all_stats.append(stats)
354
+ all_min_coverage.append(min_coverage)
355
+ del scorefiles
356
+
357
+ # Merge SNVs
358
+ # We append the project index to the tuple so the worker knows which stats to use
359
+ for k, v in snvs.items():
360
+ if combined_snvs[k][0] is None:
361
+ combined_snvs[k][0] = v[0] # Copy metadata (chr, id, etc) from the first project that has it
362
+
363
+ # v[1:] are the data tuples: (file_ind, ref, alt, ..., bad)
364
+ # We append p_idx to make it: (file_ind, ref, alt, ..., bad, p_idx)
365
+ for replicate in v[1:]:
366
+ combined_snvs[k].append(replicate + (p_idx,))
367
+
368
+ del snvs
369
+
370
+ # 2. Filter SNVs
371
+ its = combined_snvs.items()
372
+ del combined_snvs
373
+ its = list(its)
374
+
375
+ if filter_id:
376
+ its = list(filter(lambda x: x[1][0][1] and filter_id.match(x[1][0][1]), its))
377
+ if filter_chr:
378
+ its = list(filter(lambda x: filter_chr.match(x[0]), its))
379
+
380
+ # 3. Parallel Processing
381
+ ref_comb_pvals = list()
382
+ alt_comb_pvals = list()
383
+ ref_comb_es = list()
384
+ alt_comb_es = list()
385
+ comb_names = list()
386
+
387
+ with Manager() as manager:
388
+ if n_jobs > 1:
389
+ its = manager.list(its)
390
+ # We treat stats as read-only. Passing large dicts via Manager is slow.
391
+ # Passing them as regular arguments to partial relies on pickle.
392
+ # If stats are huge, this might be memory intensive, but standard for this pattern.
393
+
394
+ with Pool(n_jobs) as p:
395
+ f = partial(polymbine_stats, snvs=its, stats_list=all_stats,
396
+ groups_list=all_groups, min_cnt_sums_list=all_min_coverage,
397
+ uniform_weights=uniform_weights)
398
+
399
+ if n_jobs > 1:
400
+ sz = int(np.ceil(len(its) / n_jobs))
401
+ inds_gen = batched(range(len(its)), sz)
402
+ iterate = p.map(f, inds_gen, chunksize=1)
403
+ else:
404
+ iterate = map(f, [list(range(len(its)))])
405
+
406
+ for pvals, es, ks in iterate:
407
+ for (ref, alt), (ref_es, alt_es), k in zip(pvals, es, ks):
408
+ if k is None:
409
+ continue
410
+ ref_comb_pvals.append(ref)
411
+ alt_comb_pvals.append(alt)
412
+ ref_comb_es.append(ref_es)
413
+ alt_comb_es.append(alt_es)
414
+ comb_names.append(k)
415
+
416
+ # 4. Post-processing (FDR & Saving)
417
+ # The logic below is identical to combine()
418
+ ref_comb_pvals = np.array(ref_comb_pvals)
419
+ inds = ref_comb_pvals == 0.0
420
+ if np.any(inds):
421
+ ref_comb_pvals[inds] = ref_comb_pvals[~inds].min() if np.any(~inds) else 1.0
422
+
423
+ alt_comb_pvals = np.array(alt_comb_pvals)
424
+ inds = alt_comb_pvals == 0.0
425
+ if np.any(inds):
426
+ alt_comb_pvals[inds] = alt_comb_pvals[~inds].min() if np.any(~inds) else 1.0
427
+
428
+ # Handle empty results case
429
+ if len(ref_comb_pvals) > 0:
430
+ _, ref_fdr_pvals, _, _ = multitest.multipletests(ref_comb_pvals, alpha=alpha, method='fdr_bh')
431
+ _, alt_fdr_pvals, _, _ = multitest.multipletests(alt_comb_pvals, alpha=alpha, method='fdr_bh')
432
+ else:
433
+ ref_fdr_pvals = []
434
+ alt_fdr_pvals = []
435
+
436
+ res = dict()
437
+ for i in range(len(comb_names)):
438
+ res[comb_names[i]] = ((ref_comb_pvals[i], alt_comb_pvals[i]),
439
+ (ref_comb_es[i], alt_comb_es[i]),
440
+ (ref_fdr_pvals[i], alt_fdr_pvals[i]))
441
+
442
+ # Structure result
443
+ # We store the Union of all valid groups used across projects
444
+ # Note: groups here is slightly ambiguous in merged context, we store list of sets
445
+ res = {'groups': all_groups, 'snvs': res}
446
+ res = {subname: res}
447
+
448
+ # Saving logic
449
+ # compressor = 'gz' # Default or derived from last file
450
+ out_filename = f'{output_name}.comb.{compressor}'
451
+
452
+ if os.path.isfile(out_filename):
453
+ try:
454
+ with openers[compressor](out_filename, 'rb') as f:
455
+ r = dill.load(f)
456
+ for t in r:
457
+ if t not in res:
458
+ res[t] = r[t]
459
+ except Exception as e:
460
+ logging.warning(f"Could not merge with existing file {out_filename}: {e}")
461
+
462
+ if save_to_file:
463
+ with openers[compressor](out_filename, 'wb') as f:
464
+ dill.dump(res, f)
465
+
466
+ return res, adaptive_coverage