oddSNP 0.1.0__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.
- oddSNP/__init__.py +14 -0
- oddSNP/__main__.py +33 -0
- oddSNP/cpsnpic.py +332 -0
- oddSNP/downsampling.py +261 -0
- oddSNP/genotype.py +128 -0
- oddSNP/plotting.py +209 -0
- oddSNP/snpic.py +404 -0
- oddSNP/utils.py +299 -0
- oddsnp-0.1.0.dist-info/METADATA +124 -0
- oddsnp-0.1.0.dist-info/RECORD +15 -0
- oddsnp-0.1.0.dist-info/WHEEL +5 -0
- oddsnp-0.1.0.dist-info/entry_points.txt +2 -0
- oddsnp-0.1.0.dist-info/licenses/LICENSE +176 -0
- oddsnp-0.1.0.dist-info/licenses/NOTICE +16 -0
- oddsnp-0.1.0.dist-info/top_level.txt +1 -0
oddSNP/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""oddSNP is an open-source framework for the calculation of SNP-Information
|
|
2
|
+
Content (SNP-IC), a quantitative metric computable from simple unpooled pilot
|
|
3
|
+
data that accurately predicts the success of genotype-based demultiplexing;
|
|
4
|
+
and its equivalent for genotype-free approaches, cell-paired SNP-Information
|
|
5
|
+
Content (cpSNP-IC)."""
|
|
6
|
+
|
|
7
|
+
__version__ = '0.1.0'
|
|
8
|
+
__author__ = 'Rodolfo Allendes'
|
|
9
|
+
__email__ = 'rodolfo.allendes.prime@osaka-u.ac.jp'
|
|
10
|
+
|
|
11
|
+
# make oddSNP available if someone imports
|
|
12
|
+
from .__main__ import oddSNP as main
|
|
13
|
+
|
|
14
|
+
__all__ = ['main']
|
oddSNP/__main__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright (C) 2025 Nemoto-lab, Osaka University
|
|
2
|
+
# email: rodolfo.allendes.prime@osaka-u.ac.jp
|
|
3
|
+
# email: nemoto.takahiro.prime@osaka-u.ac.jp
|
|
4
|
+
#
|
|
5
|
+
# This program is free software; you can redistribute it and/or modify it under
|
|
6
|
+
# the terms of the GNU General Public License as published by the Free Software
|
|
7
|
+
# Foundation; either version 2 of the License, or (at your option) any later
|
|
8
|
+
# version.
|
|
9
|
+
# This program is distributed in the hope that it will be useful, but WITHOUT
|
|
10
|
+
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
11
|
+
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
12
|
+
|
|
13
|
+
# You should have received a copy of the GNU General Public License along with
|
|
14
|
+
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
|
|
15
|
+
# Place, Suite 330, Boston, MA 02111-1307 USA
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
from . import utils
|
|
19
|
+
from . import downsampling
|
|
20
|
+
from . import genotype
|
|
21
|
+
from . import snpic
|
|
22
|
+
from . import cpsnpic
|
|
23
|
+
|
|
24
|
+
@click.group()
|
|
25
|
+
@click.version_option(version='0.1.0', prog_name='oddSNP')
|
|
26
|
+
def oddSNP():
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
oddSNP.add_command(utils.utils)
|
|
30
|
+
oddSNP.add_command(downsampling.downsample)
|
|
31
|
+
oddSNP.add_command(genotype.genotype)
|
|
32
|
+
oddSNP.add_command(snpic.snpic)
|
|
33
|
+
oddSNP.add_command(cpsnpic.cpsnpic)
|
oddSNP/cpsnpic.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# Copyright 2025-2026 Nemoto-lab, The University of Osaka
|
|
2
|
+
# email: rodolfo.allendes.prime@osaka-u.ac.jp
|
|
3
|
+
# email: nemoto.takahiro.prime@osaka-u.ac.jp
|
|
4
|
+
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
|
|
17
|
+
# ---
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
import traceback
|
|
21
|
+
import math
|
|
22
|
+
import pickle, gzip
|
|
23
|
+
import itertools, more_itertools
|
|
24
|
+
import joblib
|
|
25
|
+
import tqdm
|
|
26
|
+
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
import pandas as pd
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
from . import genotype
|
|
32
|
+
from . import plotting
|
|
33
|
+
|
|
34
|
+
@click.group()
|
|
35
|
+
def cpsnpic():
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def matchingSNPs(snps1, snps2):
|
|
39
|
+
""" Calculate the sum of min OTH+DP for matching SNPs between two cells.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
snps1: DataFrame with SNPs for cell 1
|
|
43
|
+
snps2: DataFrame with SNPs for cell 2
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A list with [cell1_bcode, cell2_bcode, sum_min_OTH+DP]
|
|
47
|
+
"""
|
|
48
|
+
tmp = pd.concat([snps1,snps2], axis=1, keys=[1,2]).dropna()
|
|
49
|
+
if tmp.shape[0]==0: #empty intersection means no matching SNPs
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
# flat the column names and calculate the min OTH+DP for each SNP in the intersection
|
|
53
|
+
tmp.columns = [ '{}_{}'.format(b,a) for (a,b) in tmp.columns.to_flat_index() ]
|
|
54
|
+
tmp['sum_1'] = tmp['DP_1']+tmp['OTH_1']
|
|
55
|
+
tmp['sum_2'] = tmp['DP_2']+tmp['OTH_2']
|
|
56
|
+
tmp['min_sum'] = tmp[['sum_1','sum_2']].min(axis=1)
|
|
57
|
+
|
|
58
|
+
return [
|
|
59
|
+
snps1.bcode.iat[0],
|
|
60
|
+
snps2.bcode.iat[0],
|
|
61
|
+
tmp.min_sum.sum() # sum of minimum across matching SNPs
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
@cpsnpic.command
|
|
65
|
+
@click.argument('inpath', type=click.Path(exists=True))
|
|
66
|
+
@click.argument('oupath', type=str)
|
|
67
|
+
@click.option('--nproc', type=int, default=20, help='Number of parallel processes to use')
|
|
68
|
+
@click.option('--batch_size', type=int, default=100000, help='Number of cell pairs to process in each batch.')
|
|
69
|
+
@click.option('--force', is_flag=True, help='Override target file.')
|
|
70
|
+
def calculate_cpsnpic(inpath, oupath, nproc, batch_size, force):
|
|
71
|
+
""" Calculate the cpSNP-IC for pairs of cells. Returns the name of the file
|
|
72
|
+
where the DataFrame with the cpSNP-IC counts per cell pair is stored.
|
|
73
|
+
|
|
74
|
+
Arguments:
|
|
75
|
+
|
|
76
|
+
INPATH: Path to the folder containing pileup related files. The folder should
|
|
77
|
+
contain at least the following files: ``cellSNP.samples.tsv``,
|
|
78
|
+
``cellSNP.tag.AD/DP`` and ``OTH`` matrices
|
|
79
|
+
|
|
80
|
+
OUPATH: Path to the output folder where results will be saved.
|
|
81
|
+
"""
|
|
82
|
+
# only regenerate if the file does not exist or if the force flag is set
|
|
83
|
+
tgt = Path('{}/cpsnpic_counts.pkl.gz'.format(oupath))
|
|
84
|
+
if tgt.is_file() and not force:
|
|
85
|
+
print('cpSNP-IC counts found at: {}'.format(tgt))
|
|
86
|
+
return tgt
|
|
87
|
+
|
|
88
|
+
# calculate or fetch the aggregated genotype file
|
|
89
|
+
ag = genotype.call_aggregate_pileup(
|
|
90
|
+
inpath=inpath,
|
|
91
|
+
oupath=oupath,
|
|
92
|
+
force=force
|
|
93
|
+
)
|
|
94
|
+
# remove cellid
|
|
95
|
+
ag.reset_index('Cell', drop=True, inplace=True)
|
|
96
|
+
|
|
97
|
+
# calculate or fetch the groups of SNPs by cell barcode
|
|
98
|
+
# as this is not a DF, we need to save/load it using pickle + gzip
|
|
99
|
+
spc = Path('{}/snps_per_cell.pkl.gz'.format(oupath))
|
|
100
|
+
if not spc.is_file() or force:
|
|
101
|
+
grps = ag.groupby('bcode')
|
|
102
|
+
if not Path(oupath).exists(): Path(oupath).mkdir(parents=True)
|
|
103
|
+
print('Saving SNPs per cell to {}'.format(spc))
|
|
104
|
+
with gzip.open(spc, 'wb') as f:
|
|
105
|
+
pickle.dump(grps, f)
|
|
106
|
+
else:
|
|
107
|
+
print('Loading SNPs per cell from {}'.format(spc))
|
|
108
|
+
with gzip.open(spc, 'rb') as f:
|
|
109
|
+
grps = pickle.load(f)
|
|
110
|
+
|
|
111
|
+
# generate all cell pairs
|
|
112
|
+
cells = list(grps.groups.keys())
|
|
113
|
+
print('Generating cell pairs for {} cells'.format(len(cells)))
|
|
114
|
+
pairs = itertools.combinations(cells, 2)
|
|
115
|
+
npairs = (len(cells) * (len(cells) - 1)) // 2
|
|
116
|
+
|
|
117
|
+
# Perform the acutal matching of snps and counts calculation
|
|
118
|
+
# Given that the cells share SNPs, we can obtain a series of statistics
|
|
119
|
+
# associated to them, in particular, what is the minimum Allele frequency
|
|
120
|
+
# or the number of times (DP+DP) that the SNP was found for each cell
|
|
121
|
+
full_results = []
|
|
122
|
+
total_batches= npairs // batch_size
|
|
123
|
+
print('total pairs {}, total batches {}'.format(npairs, total_batches))
|
|
124
|
+
|
|
125
|
+
for i,batch in enumerate(tqdm.tqdm(
|
|
126
|
+
more_itertools.ichunked(pairs, batch_size),
|
|
127
|
+
total=total_batches+1,
|
|
128
|
+
desc="Batch progress")):
|
|
129
|
+
|
|
130
|
+
results = joblib.Parallel(
|
|
131
|
+
n_jobs=nproc
|
|
132
|
+
)(
|
|
133
|
+
tqdm.tqdm([
|
|
134
|
+
joblib.delayed(matchingSNPs)(grps.get_group(a), grps.get_group(b)
|
|
135
|
+
) for a,b in batch
|
|
136
|
+
],
|
|
137
|
+
total=batch_size if i!=total_batches else npairs % batch_size,
|
|
138
|
+
desc="Parallel jobs"
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
results = [r for r in results if r is not None] # filter out None results
|
|
142
|
+
full_results.extend(results)
|
|
143
|
+
|
|
144
|
+
# Create a DataFrame from the list of results
|
|
145
|
+
df = pd.DataFrame(results, columns=['cell1','cell2','min_sum'])
|
|
146
|
+
# save the resulting file
|
|
147
|
+
print('Saving results to {}'.format(tgt))
|
|
148
|
+
df.to_pickle(tgt, compression='gzip')
|
|
149
|
+
return tgt
|
|
150
|
+
|
|
151
|
+
def call_calculate_cpsnpic(inpath,oupath,nproc,batch_size,force):
|
|
152
|
+
"""Python API wrapper for :func:`calculate_cpsnpic`.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
inpath: Path to the folder containing pileup related files. The folder should
|
|
156
|
+
contain at least the following files: ``cellSNP.samples.tsv``,
|
|
157
|
+
``cellSNP.tag.AD/DP`` and ``OTH`` matrices
|
|
158
|
+
oupath: Path to the output folder where results will be saved.
|
|
159
|
+
nproc: Number of parallel processes to use
|
|
160
|
+
batch_size: Number of cell pairs to process in each batch.
|
|
161
|
+
force: If ``True``, override the target file.
|
|
162
|
+
"""
|
|
163
|
+
return calculate_cpsnpic.callback(
|
|
164
|
+
inpath=inpath,
|
|
165
|
+
oupath=oupath,
|
|
166
|
+
nproc=nproc,
|
|
167
|
+
batch_size=batch_size,
|
|
168
|
+
force=force
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@cpsnpic.command()
|
|
172
|
+
@click.argument('cpfile', type=str)
|
|
173
|
+
@click.argument('oupath', type=str)
|
|
174
|
+
@click.option('--force', is_flag=True, help='Override target file.')
|
|
175
|
+
def generate_histogram(cpfile, oupath, force):
|
|
176
|
+
""" Generate a histogram of cpSNP-IC values from a cpSNP-IC counts file.
|
|
177
|
+
Returns the name of the file where the histogram DataFrame is stored.
|
|
178
|
+
|
|
179
|
+
Arguments:
|
|
180
|
+
|
|
181
|
+
CPFILE: Path to the cpSNP-IC counts file (pkl.gz) generated by calculate_cpsnpic.
|
|
182
|
+
|
|
183
|
+
OUPATH: Path to the folder where to store the histogram file.
|
|
184
|
+
"""
|
|
185
|
+
# only regenerate if the file does not exist or if the force flag is set
|
|
186
|
+
tgt = Path('{}/cpsnpic_histogram.pkl'.format(oupath))
|
|
187
|
+
if tgt.is_file() and not force:
|
|
188
|
+
print('cpSNP-IC histogram file found at: {}'.format(tgt))
|
|
189
|
+
return tgt
|
|
190
|
+
|
|
191
|
+
# load the cpSNP-IC counts file
|
|
192
|
+
print('Loading cpSNP-IC counts from: {}'.format(cpfile))
|
|
193
|
+
agdf = pd.read_pickle(cpfile)
|
|
194
|
+
|
|
195
|
+
# determine the maximum value to set the histogram bins
|
|
196
|
+
m = math.ceil(agdf.min_sum.max())
|
|
197
|
+
nb = 100 if m>100 else m # fix the maximum number of bins
|
|
198
|
+
step = m//nb
|
|
199
|
+
bin_edges = np.arange(start=0,stop=m+step, step=step) # make sure the bins have int-edges
|
|
200
|
+
bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2 # midpoint of each bin
|
|
201
|
+
|
|
202
|
+
bin_counts, _ = np.histogram(
|
|
203
|
+
agdf.min_sum.dropna().values,
|
|
204
|
+
bins=bin_edges,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
hist = pd.DataFrame({
|
|
208
|
+
'centers': bin_centers,
|
|
209
|
+
'counts': bin_counts
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
# save the resulting file
|
|
213
|
+
print('Saving cpSNP-IC histogram to: {}'.format(tgt))
|
|
214
|
+
Path(oupath).mkdir(parents=True, exist_ok=True)
|
|
215
|
+
hist.to_pickle(tgt)
|
|
216
|
+
|
|
217
|
+
# return the name of the file where the histogram is stored
|
|
218
|
+
return tgt
|
|
219
|
+
|
|
220
|
+
def call_generate_histogram(cpfile, oupath, force):
|
|
221
|
+
""" Python API wrapper for :func:`generate_histogram`.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
cpfile: Path to the cpSNP-IC counts file (pkl.gz)
|
|
225
|
+
oupath: Path to the folder where to store the histogram file
|
|
226
|
+
force: If ``True``, override the target file.
|
|
227
|
+
"""
|
|
228
|
+
return generate_histogram.callback(
|
|
229
|
+
cpfile=cpfile,
|
|
230
|
+
oupath=oupath,
|
|
231
|
+
force=force
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
@cpsnpic.command()
|
|
235
|
+
@click.argument('histofile', type=str)
|
|
236
|
+
@click.argument('output', type=str)
|
|
237
|
+
@click.option('--force', is_flag=True, help='Override target file.')
|
|
238
|
+
def save_cpsnpic_plot(histofile, output, force=False):
|
|
239
|
+
""" Save a cpSNP-IC histogram figure
|
|
240
|
+
|
|
241
|
+
Arguments:
|
|
242
|
+
|
|
243
|
+
HISTOFILE The file where histogram data is stored
|
|
244
|
+
|
|
245
|
+
OUTPUT The output file where to save the figure (with extension .html)
|
|
246
|
+
"""
|
|
247
|
+
tgt = Path(output)
|
|
248
|
+
if tgt.is_file() and not force:
|
|
249
|
+
print('Figure file {} already exists, skipping...'.format(tgt))
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
fig = plotting.generate_cpsnpic_plot(histofile)
|
|
253
|
+
print('Saving cpSNP-IC figure to: {}'.format(tgt))
|
|
254
|
+
plotting.save_plot(
|
|
255
|
+
figure=fig,
|
|
256
|
+
output=output,
|
|
257
|
+
force=force
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def call_save_cpsnpic_plot(histofile, output, force):
|
|
261
|
+
""" Python API wrapper for :func:`save_cpsnpic_plot`.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
histofile: The file where histogram data is stored
|
|
265
|
+
output: The output file where to save the figure (with extension .html)
|
|
266
|
+
force: If ``True``, override the target file.
|
|
267
|
+
"""
|
|
268
|
+
return save_cpsnpic_plot.callback(
|
|
269
|
+
histofile=histofile,
|
|
270
|
+
output=output,
|
|
271
|
+
force=force
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
@cpsnpic.command()
|
|
275
|
+
@click.argument('inpath', type=str)
|
|
276
|
+
@click.argument('oupath', type=str)
|
|
277
|
+
@click.option('--nproc', type=int, default=20, help='Number of parallel processes to use')
|
|
278
|
+
@click.option('--batch_size', type=int, default=100000, help='Number of cell pairs to process in each batch.')
|
|
279
|
+
@click.option('--force', is_flag=True, help='Override target files.')
|
|
280
|
+
def run_all(inpath, oupath, nproc, batch_size, force):
|
|
281
|
+
""" Run all cpSNP-IC calculation steps.
|
|
282
|
+
|
|
283
|
+
Arguments:
|
|
284
|
+
|
|
285
|
+
INPATH: Path to the folder containing pileup related files. The folder should
|
|
286
|
+
contain at least the following files: cellSNP.samples.tsv,
|
|
287
|
+
cellSNP.tag.AD/DP and OTH matrices
|
|
288
|
+
|
|
289
|
+
OUPATH: Path to the output folder where results will be saved.
|
|
290
|
+
"""
|
|
291
|
+
# step 1: calculate cpSNP-IC counts
|
|
292
|
+
cpfile = calculate_cpsnpic.callback(
|
|
293
|
+
inpath=inpath,
|
|
294
|
+
oupath=oupath,
|
|
295
|
+
nproc=nproc,
|
|
296
|
+
batch_size=batch_size,
|
|
297
|
+
force=force
|
|
298
|
+
)
|
|
299
|
+
# step 2: generate histogram
|
|
300
|
+
histfile = generate_histogram.callback(
|
|
301
|
+
cpfile=cpfile,
|
|
302
|
+
oupath=oupath,
|
|
303
|
+
force=force
|
|
304
|
+
)
|
|
305
|
+
# step 3: plot histogram
|
|
306
|
+
fig = plotting.generate_cpsnpic_plot(histfile)
|
|
307
|
+
plotting.save_plot(
|
|
308
|
+
figure=fig,
|
|
309
|
+
output='{}/cpsnpic_figure.html'.format(oupath),
|
|
310
|
+
force=force
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
def call_run_all(inpath, oupath, nproc, batch_size, force):
|
|
314
|
+
""" Python API wrapper for :func:`run_all`.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
inpath: Path to the folder containing pileup related files.
|
|
318
|
+
oupath: Path to the output folder where results will be saved.
|
|
319
|
+
nproc: Number of parallel processes to use.
|
|
320
|
+
batch_size: Number of cell pairs to process in each batch.
|
|
321
|
+
force: If ``True``, override target files.
|
|
322
|
+
"""
|
|
323
|
+
return run_all.callback(
|
|
324
|
+
inpath=inpath,
|
|
325
|
+
oupath=oupath,
|
|
326
|
+
nproc=nproc,
|
|
327
|
+
batch_size=batch_size,
|
|
328
|
+
force=force
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if __name__ == '__main__':
|
|
332
|
+
cpsnpic()
|
oddSNP/downsampling.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# Copyright 2025-2026 Nemoto-lab, The University of Osaka
|
|
2
|
+
# email: rodolfo.allendes.prime@osaka-u.ac.jp
|
|
3
|
+
# email: nemoto.takahiro.prime@osaka-u.ac.jp
|
|
4
|
+
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
|
|
17
|
+
# ---
|
|
18
|
+
import click
|
|
19
|
+
import traceback
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import os
|
|
23
|
+
import pysam, pysam.bcftools
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from tqdm import tqdm
|
|
27
|
+
import pandas as pd
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
from . import utils
|
|
31
|
+
|
|
32
|
+
@click.group()
|
|
33
|
+
def downsample():
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@downsample.command()
|
|
37
|
+
@click.argument('bam', type=click.Path(exists=True, dir_okay=False))
|
|
38
|
+
@click.argument('reads', type=float)
|
|
39
|
+
@click.argument('oupath', type=str)
|
|
40
|
+
@click.option('--celltag', default='XC', help='Tag used inside the bam file for cell barcodes')
|
|
41
|
+
@click.option('--seed', type=int, default=1234, help='A seed to make the selection of reads deterministic')
|
|
42
|
+
@click.option('--nproc', type=int, default=1, help='Number of processes to use')
|
|
43
|
+
@click.option('--force', is_flag=True, help='Override previous results')
|
|
44
|
+
def reads_downsampling(bam, reads, oupath, celltag, seed, nproc, force):
|
|
45
|
+
""" Downsample BAM file by extracting a given percentage of reads.
|
|
46
|
+
It saves both the filtered BAM file and a TSV file with the cell barcodes to
|
|
47
|
+
the output directory.
|
|
48
|
+
|
|
49
|
+
Arguments:
|
|
50
|
+
|
|
51
|
+
BAM: The input BAM file
|
|
52
|
+
|
|
53
|
+
READS: The fraction of reads to keep (between 0 and 1)
|
|
54
|
+
|
|
55
|
+
OUTDIR: The path to the directory where to store the filtered BAM file
|
|
56
|
+
"""
|
|
57
|
+
# remove possible trailing slash
|
|
58
|
+
oupath = oupath.rstrip('/') if len(oupath)>1 else oupath
|
|
59
|
+
|
|
60
|
+
# only perform calculation if file doesnt exist or if force flag is set
|
|
61
|
+
tgt = Path('{}/{}_{:.2f}.bam'.format(oupath, Path(bam).stem, reads)) # resulting file
|
|
62
|
+
if tgt.is_file() and not force:
|
|
63
|
+
print('Filtered file {} already exists, skipping...'.format(tgt))
|
|
64
|
+
return tgt
|
|
65
|
+
|
|
66
|
+
os.makedirs(oupath, exist_ok=True)
|
|
67
|
+
# get total number of lines in the file
|
|
68
|
+
totallines= utils.call_count_lines(bam=bam, nproc=nproc)
|
|
69
|
+
print('Total number of reads in {}: {:,}'.format(bam, totallines))
|
|
70
|
+
|
|
71
|
+
# generate a list of as many true/false values as needed
|
|
72
|
+
keep = round(totallines*reads)
|
|
73
|
+
arr = np.concatenate((
|
|
74
|
+
np.ones(keep, dtype=bool),
|
|
75
|
+
np.zeros(totallines-keep, dtype=bool)
|
|
76
|
+
))
|
|
77
|
+
# and shuffle the values to randoly determine the lines to keep
|
|
78
|
+
rng = np.random.default_rng(seed)
|
|
79
|
+
rng.shuffle(arr)
|
|
80
|
+
|
|
81
|
+
# process the file, keeping the predetermined lines and dropping the
|
|
82
|
+
# remaining ones
|
|
83
|
+
inp = pysam.AlignmentFile('{}'.format(bam), 'rb')
|
|
84
|
+
out = pysam.AlignmentFile(
|
|
85
|
+
'{}'.format(tgt),
|
|
86
|
+
'wb', template=inp)
|
|
87
|
+
|
|
88
|
+
# re-map all reads in the original file
|
|
89
|
+
bcodes = set()
|
|
90
|
+
for i,read in tqdm(enumerate(inp.fetch(until_eof=True)),
|
|
91
|
+
total=totallines,
|
|
92
|
+
desc='Filtering reads'):
|
|
93
|
+
if arr[i]:
|
|
94
|
+
try:
|
|
95
|
+
out.write(read) # make sure to copy the read first
|
|
96
|
+
bcodes.add(read.get_tag(tag=celltag)) # and then try to fetch the cell bcode
|
|
97
|
+
except KeyError:
|
|
98
|
+
continue
|
|
99
|
+
# close the files after finishing
|
|
100
|
+
inp.close()
|
|
101
|
+
out.close()
|
|
102
|
+
|
|
103
|
+
#convert bcodes to DataFrame and save to file
|
|
104
|
+
bcodes_df = pd.DataFrame.from_dict(data={'cells': list(bcodes)})
|
|
105
|
+
bname = '{}/{}_{:.2f}_barcodes.tsv'.format(oupath, Path(bam).stem, reads)
|
|
106
|
+
bcodes_df.to_csv(bname,
|
|
107
|
+
index=False,
|
|
108
|
+
header=False
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return tgt
|
|
112
|
+
|
|
113
|
+
def call_reads_downsampling(bam, reads, oupath, celltag, seed, nproc, force):
|
|
114
|
+
""" Python wrapper for :func:`reads_downsampling`.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
bam: The input BAM file
|
|
118
|
+
reads: The percentage of reads to keep (between 0 and 1)
|
|
119
|
+
oupath: The path to the directory where to store the filtered BAM file
|
|
120
|
+
celltag: The tag used inside the bam file for cell barcodes
|
|
121
|
+
seed: A seed to make the selection of reads deterministic
|
|
122
|
+
nproc: Number of processes to use
|
|
123
|
+
force: Override previous results
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
The path to the filtered BAM file
|
|
127
|
+
"""
|
|
128
|
+
return reads_downsampling.callback(
|
|
129
|
+
bam=bam,
|
|
130
|
+
reads=reads,
|
|
131
|
+
oupath=oupath,
|
|
132
|
+
celltag=celltag,
|
|
133
|
+
seed=seed,
|
|
134
|
+
nproc=nproc,
|
|
135
|
+
force=force
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@downsample.command()
|
|
139
|
+
@click.argument('bam', type=click.File('rb'))
|
|
140
|
+
@click.argument('barcodes', type=click.File('r'))
|
|
141
|
+
@click.argument('oupath', type=str)
|
|
142
|
+
@click.option('--celltag', default='XC', help='Tag used inside the bam file for cell barcodes')
|
|
143
|
+
@click.option('--nproc', type=int, default=1, help='Number of processes to use')
|
|
144
|
+
@click.option('--force', is_flag=True, help='Override previous results')
|
|
145
|
+
def barcode_downsampling(bam, barcodes, oupath, celltag, nproc, force):
|
|
146
|
+
""" Downsample BAM file by extracting selected barcodes.
|
|
147
|
+
Given a list of cell barcodes, filter a BAM file in order to only include
|
|
148
|
+
reads associated to those barcodes.
|
|
149
|
+
|
|
150
|
+
Arguments:
|
|
151
|
+
|
|
152
|
+
BAM: Path to the BAM file to filter.
|
|
153
|
+
|
|
154
|
+
BARCODES: A file with the list of cell barcodes to include.
|
|
155
|
+
|
|
156
|
+
OUPATH: The path to the directory where to store the filtered BAM file.
|
|
157
|
+
"""
|
|
158
|
+
# remove possible trailing slash
|
|
159
|
+
oupath = oupath.rstrip('/') if len(oupath)>1 else oupath
|
|
160
|
+
|
|
161
|
+
# only perform filtering if the output file doesnt exist or if force flag is set
|
|
162
|
+
tgt = Path('{}/{}_filtered.bam'.format(oupath, Path(bam.name).stem)) # resulting file
|
|
163
|
+
if tgt.is_file() and not force:
|
|
164
|
+
print('Filtered file {} already exists, skipping...'.format(tgt))
|
|
165
|
+
return tgt
|
|
166
|
+
|
|
167
|
+
# create output directory if it does not exist
|
|
168
|
+
os.makedirs(oupath, exist_ok=True)
|
|
169
|
+
|
|
170
|
+
# process the files in parallel for read extraction
|
|
171
|
+
pysam.samtools.view(
|
|
172
|
+
'-bh',
|
|
173
|
+
'-D', '{}:{}'.format(celltag, barcodes.name),
|
|
174
|
+
'-o', str(tgt),
|
|
175
|
+
'--threads', str(nproc),
|
|
176
|
+
bam.name,
|
|
177
|
+
catch_stdout=False
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return tgt
|
|
181
|
+
|
|
182
|
+
def call_barcode_downsampling(bam, barcodes, oupath, celltag, nproc, force):
|
|
183
|
+
""" Python wrapper for :func:`barcode_downsampling`.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
bam: Path to the BAM file to filter.
|
|
187
|
+
barcodes: A file with the list of cell barcodes to include.
|
|
188
|
+
oupath: The path to the directory where to store the filtered BAM file.
|
|
189
|
+
celltag: The tag used inside the bam file for cell barcodes
|
|
190
|
+
nproc: Number of processes to use
|
|
191
|
+
force: If `True` override previous results
|
|
192
|
+
"""
|
|
193
|
+
return barcode_downsampling.callback(
|
|
194
|
+
bam=bam,
|
|
195
|
+
barcodes=barcodes,
|
|
196
|
+
oupath=oupath,
|
|
197
|
+
celltag=celltag,
|
|
198
|
+
nproc=nproc,
|
|
199
|
+
force=force
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@downsample.command()
|
|
203
|
+
@click.argument('vcf', type=click.Path(exists=True, dir_okay=False))
|
|
204
|
+
@click.argument('regions', type=click.Path(exists=True, dir_okay=False))
|
|
205
|
+
@click.argument('out', type=str)
|
|
206
|
+
@click.option('--nproc', type=int, default=1, help='Number of processes to use')
|
|
207
|
+
@click.option('--force', is_flag=True, help='Override previous results')
|
|
208
|
+
def vcf_downsampling(vcf, regions, out, nproc, force):
|
|
209
|
+
""" Filter a reference VCF file to only include variants in given regions.
|
|
210
|
+
|
|
211
|
+
Arguments:
|
|
212
|
+
VCF: Path to the VCF file to filter.
|
|
213
|
+
|
|
214
|
+
REGIONS: A file with the list of regions to include.
|
|
215
|
+
|
|
216
|
+
OUT: The path to the directory where to store the filtered VCF file.
|
|
217
|
+
"""
|
|
218
|
+
# only perform calculation if file doesnt exist or if force flag is set
|
|
219
|
+
tgt = Path(out) # resulting file
|
|
220
|
+
if tgt.is_file() and not force:
|
|
221
|
+
print('Filtered file {} already exists, skipping...'.format(tgt))
|
|
222
|
+
return tgt
|
|
223
|
+
|
|
224
|
+
# make sure the vcf file has an index
|
|
225
|
+
if not os.path.isfile(f'{vcf}.csi'):
|
|
226
|
+
print(f"Index file for {vcf} not found. Generating index.")
|
|
227
|
+
utils.generate_vcf_index(vcf, nproc)
|
|
228
|
+
|
|
229
|
+
# create output directory if it does not exist
|
|
230
|
+
os.makedirs(Path(out).parent, exist_ok=True)
|
|
231
|
+
|
|
232
|
+
pysam.bcftools.view(
|
|
233
|
+
"-R", regions,
|
|
234
|
+
"-Oz", "-o", out,
|
|
235
|
+
"--threads", str(nproc),
|
|
236
|
+
vcf,
|
|
237
|
+
catch_stdout=False
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return tgt
|
|
241
|
+
|
|
242
|
+
def call_vcf_downsampling(vcf, regions, out, nproc, force):
|
|
243
|
+
""" Python wrapper for :func:`vcf_downsampling`.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
vcf: Path to the VCF file to filter.
|
|
247
|
+
regions: A file with the list of regions to include.
|
|
248
|
+
out: The path to the directory where to store the filtered VCF file.
|
|
249
|
+
nproc: Number of processes to use
|
|
250
|
+
force: If `True` override previous results
|
|
251
|
+
"""
|
|
252
|
+
return vcf_downsampling.callback(
|
|
253
|
+
vcf=vcf,
|
|
254
|
+
regions=regions,
|
|
255
|
+
out=out,
|
|
256
|
+
nproc=nproc,
|
|
257
|
+
force=force
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if __name__ == '__main__':
|
|
261
|
+
downsample()
|