moose-classifier 0.10__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.
- __init__.py +6 -0
- classify.py +1204 -0
- lineages.py +170 -0
- moose_classifier-0.10.dist-info/LICENSE +674 -0
- moose_classifier-0.10.dist-info/METADATA +1111 -0
- moose_classifier-0.10.dist-info/RECORD +9 -0
- moose_classifier-0.10.dist-info/WHEEL +5 -0
- moose_classifier-0.10.dist-info/entry_points.txt +2 -0
- moose_classifier-0.10.dist-info/top_level.txt +3 -0
classify.py
ADDED
|
@@ -0,0 +1,1204 @@
|
|
|
1
|
+
# This file is part of classifier
|
|
2
|
+
#
|
|
3
|
+
# classifier is free software: you can redistribute it and/or modify
|
|
4
|
+
# it under the terms of the GNU General Public License as published by
|
|
5
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
6
|
+
# (at your option) any later version.
|
|
7
|
+
#
|
|
8
|
+
# classifier is distributed in the hope that it will be useful,
|
|
9
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
10
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
11
|
+
# GNU General Public License for more details.
|
|
12
|
+
#
|
|
13
|
+
# You should have received a copy of the GNU General Public License
|
|
14
|
+
# along with classifier. If not, see <http://www.gnu.org/licenses/>.
|
|
15
|
+
"""Classify sequences by grouping alignment output with taxonomy names
|
|
16
|
+
|
|
17
|
+
Optional grouping by specimen and query sequences
|
|
18
|
+
|
|
19
|
+
Positional arguments
|
|
20
|
+
++++++++++++++++++++
|
|
21
|
+
|
|
22
|
+
alignments
|
|
23
|
+
==========
|
|
24
|
+
|
|
25
|
+
A csv file with columns **qseqid**, **sseqid**, **pident**, and optional
|
|
26
|
+
columns **qstart**, **qend**, **qlen**, **qcovs** or **mismatch**. With
|
|
27
|
+
column **qcovs** will be appended or replaced when the **qstart**,
|
|
28
|
+
**qend** and **qlen** columns are present. The **mismatch** column is used
|
|
29
|
+
with the ``--best-n-hits`` switch. Additional columns may be present if a
|
|
30
|
+
header is provided and will automatically be appended to detailed output.
|
|
31
|
+
|
|
32
|
+
.. note:: If no header present user must specify one of the alignment input
|
|
33
|
+
header-less options.
|
|
34
|
+
|
|
35
|
+
seq_info
|
|
36
|
+
========
|
|
37
|
+
|
|
38
|
+
A csv file with minimum columns **seqname** and **tax_id**. Additional
|
|
39
|
+
columns will be included in the detailed output.
|
|
40
|
+
|
|
41
|
+
lineages
|
|
42
|
+
========
|
|
43
|
+
|
|
44
|
+
A csv file with columns **tax_id**, **rank** and **tax_name**, plus at least
|
|
45
|
+
one additional rank column(s) creating a taxonomic tree such as **species**,
|
|
46
|
+
**genus**, **family**, **class**, **pylum**, **kingdom** and/or **root**.
|
|
47
|
+
The rank columns also give an order of specificity from right to left,
|
|
48
|
+
least specific to most specific respectively.
|
|
49
|
+
|
|
50
|
+
Optional input
|
|
51
|
+
++++++++++++++
|
|
52
|
+
|
|
53
|
+
rank-thresholds
|
|
54
|
+
===============
|
|
55
|
+
|
|
56
|
+
TODO
|
|
57
|
+
|
|
58
|
+
copy-numbers
|
|
59
|
+
============
|
|
60
|
+
|
|
61
|
+
Below is an *example* copy numbers csv with the required columns:
|
|
62
|
+
|
|
63
|
+
====== ==================== ======
|
|
64
|
+
tax_id tax_name count
|
|
65
|
+
====== ==================== ======
|
|
66
|
+
155977 Acaryochloris 2.00
|
|
67
|
+
155978 Acaryochloris marina 2.00
|
|
68
|
+
434 Acetobacter 5.00
|
|
69
|
+
433 Acetobacteraceae 3.60
|
|
70
|
+
====== ==================== ======
|
|
71
|
+
|
|
72
|
+
weights
|
|
73
|
+
=======
|
|
74
|
+
|
|
75
|
+
Headerless file containing two columns specifying the seqname (clustername) and
|
|
76
|
+
weight (or number of sequences in the cluster).
|
|
77
|
+
|
|
78
|
+
Output
|
|
79
|
+
++++++
|
|
80
|
+
|
|
81
|
+
out
|
|
82
|
+
===
|
|
83
|
+
|
|
84
|
+
A csv with columns and headers as in the example below:
|
|
85
|
+
|
|
86
|
+
=========== =============== ======================================
|
|
87
|
+
specimen assignment_id assignment
|
|
88
|
+
=========== =============== ======================================
|
|
89
|
+
039_3 0 Pseudomonas mendocina;Pseudonocardia
|
|
90
|
+
039_3 1 Rhizobiales
|
|
91
|
+
039_3 2 Alcaligenes faecalis*
|
|
92
|
+
039_3 3 [no blast result]
|
|
93
|
+
=========== =============== ======================================
|
|
94
|
+
|
|
95
|
+
======= ============= =============
|
|
96
|
+
low max_percent min_percent
|
|
97
|
+
======= ============= =============
|
|
98
|
+
95.00 99.02 95.74
|
|
99
|
+
95.00 98.91 95.31
|
|
100
|
+
99.00 100.00 99.00
|
|
101
|
+
|
|
102
|
+
======= ============= =============
|
|
103
|
+
|
|
104
|
+
================= ======= =========== ===========
|
|
105
|
+
condensed_rank reads pct_reads clusters
|
|
106
|
+
================= ======= =========== ===========
|
|
107
|
+
species 6 35.29 1
|
|
108
|
+
genus 5 29.41 1
|
|
109
|
+
species 5 29.41 1
|
|
110
|
+
1 5.88 1
|
|
111
|
+
================= ======= =========== ===========
|
|
112
|
+
|
|
113
|
+
details-out
|
|
114
|
+
===========
|
|
115
|
+
|
|
116
|
+
Original alignment input plus breakdown of assignments.
|
|
117
|
+
|
|
118
|
+
Internal functions
|
|
119
|
+
------------------
|
|
120
|
+
|
|
121
|
+
Known bugs
|
|
122
|
+
----------
|
|
123
|
+
|
|
124
|
+
Tax_ids of valid alignment (that meet their rank thresholds) may be
|
|
125
|
+
assigned tax_ids of a higher threshold that *could* represent invalid tax_ids
|
|
126
|
+
(tax_ids that may *not* have passed the rank threshold).
|
|
127
|
+
|
|
128
|
+
TODO: generate rank thresholds based on lineages input
|
|
129
|
+
"""
|
|
130
|
+
import argparse
|
|
131
|
+
import bz2
|
|
132
|
+
import csv
|
|
133
|
+
import gzip
|
|
134
|
+
import itertools
|
|
135
|
+
from src import __version__, lineages
|
|
136
|
+
import logging
|
|
137
|
+
import lzma
|
|
138
|
+
import math
|
|
139
|
+
import numpy
|
|
140
|
+
import pandas as pd
|
|
141
|
+
import operator
|
|
142
|
+
import os
|
|
143
|
+
import sys
|
|
144
|
+
import tarfile
|
|
145
|
+
|
|
146
|
+
ASSIGNMENT_TAX_ID = 'assignment_tax_id'
|
|
147
|
+
|
|
148
|
+
ALIGNMENT_DTYPES = {
|
|
149
|
+
'accession': str,
|
|
150
|
+
'assignment_id': str,
|
|
151
|
+
'assignment_rank': str,
|
|
152
|
+
'assignment_tax_id': str,
|
|
153
|
+
'assignment_tax_name': str,
|
|
154
|
+
'bitscore': float,
|
|
155
|
+
'condensed_id': str,
|
|
156
|
+
'evalue': float,
|
|
157
|
+
'gapopen': int,
|
|
158
|
+
'pident': float,
|
|
159
|
+
'length': int,
|
|
160
|
+
'mismatch': float,
|
|
161
|
+
'pident': float,
|
|
162
|
+
'qaccver': float,
|
|
163
|
+
'qcovhsp': float,
|
|
164
|
+
'qcovs': float,
|
|
165
|
+
'qend': int,
|
|
166
|
+
'qlen': int,
|
|
167
|
+
'qseqid': str,
|
|
168
|
+
'qstart': int,
|
|
169
|
+
'rank': str,
|
|
170
|
+
'saccver': str,
|
|
171
|
+
'send': int,
|
|
172
|
+
'specimen': str,
|
|
173
|
+
'sseqid': str,
|
|
174
|
+
'sstart': int,
|
|
175
|
+
'staxid': str,
|
|
176
|
+
'tax_id': str,
|
|
177
|
+
'tax_name': str
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
ALIGNMENT_CONVERT = {
|
|
181
|
+
'qaccver': 'qseqid',
|
|
182
|
+
'saccver': 'sseqid'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
OUTPUT_COLS = [
|
|
186
|
+
'specimen', 'assignment_id', 'assignment',
|
|
187
|
+
'best_rank', 'max_percent', 'min_percent',
|
|
188
|
+
'min_threshold', 'reads', 'clusters', 'pct_reads']
|
|
189
|
+
|
|
190
|
+
DETAILS_COLS = [
|
|
191
|
+
'specimen', 'assignment_id', 'tax_name', 'rank', 'assignment_tax_name',
|
|
192
|
+
'assignment_rank', 'pident', 'tax_id', ASSIGNMENT_TAX_ID,
|
|
193
|
+
'condensed_id', 'qseqid', 'sseqid', 'starred', 'assignment_threshold']
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def setup_logging(namespace):
|
|
197
|
+
"""
|
|
198
|
+
"""
|
|
199
|
+
log = opener(namespace.log, 'a')
|
|
200
|
+
loglevel = {
|
|
201
|
+
0: logging.ERROR,
|
|
202
|
+
1: logging.WARNING,
|
|
203
|
+
2: logging.INFO,
|
|
204
|
+
3: logging.DEBUG,
|
|
205
|
+
}.get(namespace.verbosity, logging.DEBUG)
|
|
206
|
+
if namespace.verbosity > 1:
|
|
207
|
+
logformat = '%(levelname)s classifier %(message)s'
|
|
208
|
+
else:
|
|
209
|
+
logformat = 'classifier %(message)s'
|
|
210
|
+
logging.basicConfig(stream=log, format=logformat, level=loglevel)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main(argv=sys.argv[1:]):
|
|
214
|
+
namespace = build_parser().parse_args(args=argv)
|
|
215
|
+
setup_logging(namespace)
|
|
216
|
+
return action(namespace)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def action(args):
|
|
220
|
+
output_cols = list(OUTPUT_COLS)
|
|
221
|
+
details_cols = list(DETAILS_COLS)
|
|
222
|
+
logging.info('loading alignments ' + str(args.alignments))
|
|
223
|
+
apath = args.alignments
|
|
224
|
+
if os.path.isfile(apath) and os.stat(apath).st_size > 0:
|
|
225
|
+
with opener(apath) as alignments:
|
|
226
|
+
header = next(alignments)
|
|
227
|
+
else:
|
|
228
|
+
header = ''
|
|
229
|
+
if '\t' in header:
|
|
230
|
+
sep = '\t'
|
|
231
|
+
else:
|
|
232
|
+
sep = ','
|
|
233
|
+
|
|
234
|
+
# if alignments contains a header row, set header=0 for read_csv
|
|
235
|
+
if 'pident' in header:
|
|
236
|
+
header_row = 0
|
|
237
|
+
else:
|
|
238
|
+
header_row = None
|
|
239
|
+
|
|
240
|
+
if args.columns:
|
|
241
|
+
conv = ALIGNMENT_CONVERT
|
|
242
|
+
names = [conv.get(c, c) for c in args.columns.split(sep)]
|
|
243
|
+
elif all(c in header for c in ['qseqid', 'sseqid', 'pident']):
|
|
244
|
+
names = None
|
|
245
|
+
else:
|
|
246
|
+
# blast std
|
|
247
|
+
names = ['qseqid', 'sseqid', 'pident', 'length',
|
|
248
|
+
'mismatch', 'gapopen', 'qstart', 'qend',
|
|
249
|
+
'sstart', 'send', 'evalue', 'bitscore']
|
|
250
|
+
aligns = pd.read_csv(
|
|
251
|
+
args.alignments,
|
|
252
|
+
dtype=ALIGNMENT_DTYPES,
|
|
253
|
+
header=header_row,
|
|
254
|
+
names=names,
|
|
255
|
+
sep=sep)
|
|
256
|
+
|
|
257
|
+
# specimen grouping
|
|
258
|
+
if args.specimen_map:
|
|
259
|
+
spec_map = pd.read_csv(
|
|
260
|
+
args.specimen_map,
|
|
261
|
+
names=['specimen', 'qseqid', 'weight'],
|
|
262
|
+
usecols=['specimen', 'qseqid', 'weight'],
|
|
263
|
+
dtype={'qseqid': str, 'specimen': str, 'weight': float},
|
|
264
|
+
header=None)
|
|
265
|
+
spec_map = spec_map.drop_duplicates().set_index('qseqid')
|
|
266
|
+
aligns = aligns.join(spec_map, on='qseqid', how='outer')
|
|
267
|
+
# reset index to handle qseqids representing multiple clusters
|
|
268
|
+
aligns = aligns.reset_index(drop=True)
|
|
269
|
+
aligns['weight'] = aligns['weight'].fillna(1.0)
|
|
270
|
+
imissing = aligns['specimen'].isna()
|
|
271
|
+
aligns.loc[imissing, 'specimen'] = aligns[imissing]['qseqid']
|
|
272
|
+
elif args.specimen:
|
|
273
|
+
# all sequences are of one specimen
|
|
274
|
+
aligns['specimen'] = args.specimen
|
|
275
|
+
aligns['weight'] = 1.
|
|
276
|
+
else:
|
|
277
|
+
# each qseqid is its own specimen
|
|
278
|
+
aligns['specimen'] = aligns['qseqid'] # by qseqid
|
|
279
|
+
aligns['weight'] = 1.
|
|
280
|
+
|
|
281
|
+
if aligns.empty:
|
|
282
|
+
'''
|
|
283
|
+
Return just the output headers if no data exists
|
|
284
|
+
'''
|
|
285
|
+
pd.DataFrame(columns=output_cols).to_csv(args.out, index=False)
|
|
286
|
+
if args.details_out:
|
|
287
|
+
pd.DataFrame(columns=details_cols).to_csv(
|
|
288
|
+
args.details_out,
|
|
289
|
+
index=False)
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
# get a set of qseqids for identifying [no blast hits] after filtering
|
|
293
|
+
qseqids = aligns[['specimen', 'qseqid', 'weight']].drop_duplicates()
|
|
294
|
+
|
|
295
|
+
'''
|
|
296
|
+
Remove query sequences with no alignment information.
|
|
297
|
+
These will be added back later but we do not want to
|
|
298
|
+
confuse these with alignment results filtered by joins.
|
|
299
|
+
'''
|
|
300
|
+
aligns = aligns[aligns['sseqid'].notnull()]
|
|
301
|
+
|
|
302
|
+
'''
|
|
303
|
+
Load seq_info as a bridge to the sequence lineages. Additional
|
|
304
|
+
columns can be specified to be included in the details-out file
|
|
305
|
+
such as accession number
|
|
306
|
+
'''
|
|
307
|
+
if 'staxid' in aligns.columns:
|
|
308
|
+
aligns = aligns.rename(columns={'staxid': 'tax_id'})
|
|
309
|
+
elif args.seq_info:
|
|
310
|
+
logging.info('reading ' + args.seq_info)
|
|
311
|
+
seq_info = read_seqinfo(args.seq_info, set(aligns['sseqid'].tolist()))
|
|
312
|
+
# TODO: make a note that sseqid is a required column in the alignments!
|
|
313
|
+
aligns_len = len(aligns)
|
|
314
|
+
logging.info('joining')
|
|
315
|
+
aligns = aligns.join(seq_info, on='sseqid', how='inner')
|
|
316
|
+
len_diff = aligns_len - len(aligns)
|
|
317
|
+
if len_diff:
|
|
318
|
+
logging.warning('{} subject sequences dropped without '
|
|
319
|
+
'records in seq_info file'.format(len_diff))
|
|
320
|
+
else:
|
|
321
|
+
raise ValueError('missing either staxid column or seq_info.csv file')
|
|
322
|
+
|
|
323
|
+
'''
|
|
324
|
+
load the full lineages table. Rank specificity as ordered from
|
|
325
|
+
left (less specific) to right (more specific)
|
|
326
|
+
'''
|
|
327
|
+
if args.lineages:
|
|
328
|
+
logging.info('reading ' + args.lineages)
|
|
329
|
+
lineages = read_lineages(args.lineages, set(aligns['tax_id'].tolist()))
|
|
330
|
+
else:
|
|
331
|
+
tis = set(aligns['tax_id'].tolist())
|
|
332
|
+
tree = build_lineages(tis, args.taxdump, args.tax_url)
|
|
333
|
+
if args.no_rank_suffix:
|
|
334
|
+
tree.expand_ranks(args.no_rank_suffix)
|
|
335
|
+
else: # we will want root (no rank) no matter what
|
|
336
|
+
tree.include_root()
|
|
337
|
+
lineages = pd.DataFrame(
|
|
338
|
+
data=tree.root.get_lineages(tree.ranks),
|
|
339
|
+
columns=['tax_id', 'tax_name', 'rank'] + tree.ranks)
|
|
340
|
+
lineages = lineages.set_index('tax_id').dropna(axis='columns', how='all')
|
|
341
|
+
|
|
342
|
+
ranks = lineages.columns.tolist()
|
|
343
|
+
ranks = ranks[ranks.index('root'):]
|
|
344
|
+
cat_ranks = pd.api.types.CategoricalDtype(
|
|
345
|
+
categories=ranks[::-1], ordered=True)
|
|
346
|
+
lineages['rank'] = lineages['rank'].astype(cat_ranks)
|
|
347
|
+
|
|
348
|
+
if args.lineages_out:
|
|
349
|
+
lineages.to_csv(args.lineages_out)
|
|
350
|
+
|
|
351
|
+
# now combine just the rank columns to the alignment results
|
|
352
|
+
aligns_len = len(aligns)
|
|
353
|
+
logging.info('joining with alignments')
|
|
354
|
+
aligns = aligns.join(
|
|
355
|
+
lineages[['tax_name', 'rank'] + ranks], on='tax_id', how='inner')
|
|
356
|
+
len_diff = aligns_len - len(aligns)
|
|
357
|
+
if len_diff:
|
|
358
|
+
msg = '{} subject sequences dropped without records in lineages file'
|
|
359
|
+
logging.warning(msg.format(len_diff))
|
|
360
|
+
|
|
361
|
+
if args.rank_thresholds:
|
|
362
|
+
rank_thresholds = load_rank_thresholds(args.rank_thresholds, ranks)
|
|
363
|
+
rank_thresholds = rank_thresholds.groupby(level=0).last()
|
|
364
|
+
# remove ranks not in thresholds
|
|
365
|
+
ranks = [r for r in ranks if r in rank_thresholds.columns]
|
|
366
|
+
else:
|
|
367
|
+
if 'species' in ranks:
|
|
368
|
+
ispecies = ranks.index('species')
|
|
369
|
+
else:
|
|
370
|
+
ispecies = len(ranks) - 1
|
|
371
|
+
rank_thresholds = {}
|
|
372
|
+
for i, r in enumerate(ranks):
|
|
373
|
+
if i <= ispecies:
|
|
374
|
+
rank_thresholds[r] = 0.0
|
|
375
|
+
else:
|
|
376
|
+
rank_thresholds[r] = 100.0
|
|
377
|
+
rank_thresholds = pd.DataFrame(
|
|
378
|
+
data=rank_thresholds, columns=ranks, index=['1'])
|
|
379
|
+
rank_thresholds.index.name = 'tax_id'
|
|
380
|
+
|
|
381
|
+
rank_thresholds_cols = []
|
|
382
|
+
passed_cols = []
|
|
383
|
+
for c in rank_thresholds.columns:
|
|
384
|
+
rank_thresholds_cols.append('{}_threshold'.format(c))
|
|
385
|
+
passed_cols.append('{}_passed'.format(c))
|
|
386
|
+
rank_thresholds.columns = rank_thresholds_cols
|
|
387
|
+
|
|
388
|
+
# joining rank thresholds file
|
|
389
|
+
aligns = join_thresholds(
|
|
390
|
+
aligns, rank_thresholds, ranks[::-1])
|
|
391
|
+
|
|
392
|
+
# assign assignment tax ids based on pident and thresholds
|
|
393
|
+
logging.info('selecting best hits for classification')
|
|
394
|
+
aligns[passed_cols] = aligns[rank_thresholds_cols].lt(
|
|
395
|
+
aligns['pident'], axis='rows')
|
|
396
|
+
for i, r in enumerate(ranks):
|
|
397
|
+
passed = aligns[r + '_passed'] & ~aligns[r].isna()
|
|
398
|
+
# always set ASSIGNMENT_TAX_ID in case a tax_id is missing at a rank
|
|
399
|
+
aligns.loc[passed, ASSIGNMENT_TAX_ID] = aligns[r]
|
|
400
|
+
thresh = aligns[r + '_threshold']
|
|
401
|
+
aligns.loc[passed, 'assignment_level'] = i
|
|
402
|
+
aligns.loc[passed, 'assignment_level_threshold'] = thresh
|
|
403
|
+
aligns.loc[aligns[r + '_passed'], 'threshold_level'] = i
|
|
404
|
+
aligns.loc[aligns[r + '_passed'], 'threshold_level_threshold'] = thresh
|
|
405
|
+
# see select_best_hits for how *_level are used
|
|
406
|
+
best_hits = aligns[~aligns['threshold_level'].isna()]
|
|
407
|
+
if not best_hits.empty:
|
|
408
|
+
spec_group = best_hits.groupby(
|
|
409
|
+
by=['specimen', 'qseqid'], group_keys=False)
|
|
410
|
+
sub_cols = [
|
|
411
|
+
'threshold_level', 'assignment_level',
|
|
412
|
+
'threshold_level_threshold', 'assignment_level_threshold']
|
|
413
|
+
return_cols = ['assignment_threshold', 'best_hit']
|
|
414
|
+
best_hits[return_cols] = spec_group[sub_cols].apply(select_best_hits)
|
|
415
|
+
best_hits = best_hits[best_hits['best_hit']]
|
|
416
|
+
|
|
417
|
+
if args.hits_below_threshold:
|
|
418
|
+
"""
|
|
419
|
+
Store all the hits to append to aligns details later
|
|
420
|
+
"""
|
|
421
|
+
hits_below_threshold = aligns[~aligns.index.isin(best_hits.index)]
|
|
422
|
+
|
|
423
|
+
aligns = best_hits
|
|
424
|
+
|
|
425
|
+
# drop unneeded tax and threshold columns to free memory
|
|
426
|
+
# TODO: see if this is necessary anymore since latest Pandas release
|
|
427
|
+
for c in ranks + rank_thresholds_cols + passed_cols:
|
|
428
|
+
aligns = aligns.drop(c, axis='columns')
|
|
429
|
+
|
|
430
|
+
if not aligns.empty:
|
|
431
|
+
aligns_post_len = len(aligns)
|
|
432
|
+
msg = '{} alignments selected for assignment'
|
|
433
|
+
logging.info(msg.format(aligns_post_len))
|
|
434
|
+
|
|
435
|
+
# join with lineages for tax_name and rank
|
|
436
|
+
aligns = aligns.join(
|
|
437
|
+
lineages[['tax_name', 'rank']],
|
|
438
|
+
rsuffix='_assignment',
|
|
439
|
+
on=ASSIGNMENT_TAX_ID)
|
|
440
|
+
aligns = aligns.rename(
|
|
441
|
+
columns={'tax_name_assignment': 'assignment_tax_name',
|
|
442
|
+
'rank_assignment': 'assignment_rank'})
|
|
443
|
+
|
|
444
|
+
# fillna() does not work with categorical dtypes
|
|
445
|
+
tax_dict = lineages.drop('rank', axis='columns')
|
|
446
|
+
tax_dict = tax_dict.fillna('').to_dict(orient='index')
|
|
447
|
+
|
|
448
|
+
# create condensed assignment hashes by qseqid
|
|
449
|
+
msg = 'condensing group tax_ids to size {}'.format(args.max_group_size)
|
|
450
|
+
logging.info(msg)
|
|
451
|
+
spec_grp = aligns.groupby(by=['specimen', 'qseqid'])
|
|
452
|
+
aligns['condensed_id'] = spec_grp[ASSIGNMENT_TAX_ID].transform(
|
|
453
|
+
condense_ids,
|
|
454
|
+
tax_dict,
|
|
455
|
+
ranks,
|
|
456
|
+
floor_rank=ranks[-1],
|
|
457
|
+
ceiling_rank=ranks[0],
|
|
458
|
+
max_size=args.max_group_size)
|
|
459
|
+
if args.threshold_assignments:
|
|
460
|
+
col = ASSIGNMENT_TAX_ID
|
|
461
|
+
else:
|
|
462
|
+
col = 'condensed_id'
|
|
463
|
+
aligns['assignment_hash'] = spec_grp[col].transform(assignment_hash)
|
|
464
|
+
|
|
465
|
+
aligns = aligns.join(
|
|
466
|
+
lineages[['rank', 'tax_name']],
|
|
467
|
+
on='condensed_id',
|
|
468
|
+
rsuffix='_condensed')
|
|
469
|
+
aligns = aligns.rename(
|
|
470
|
+
columns={
|
|
471
|
+
'rank_condensed': 'condensed_rank',
|
|
472
|
+
'tax_name_condensed': 'condensed_tax_name'})
|
|
473
|
+
|
|
474
|
+
# star condensed ids if one hit meets star threshold
|
|
475
|
+
by = ['specimen', 'assignment_hash', 'condensed_id']
|
|
476
|
+
pgrp = aligns.groupby(by=by)
|
|
477
|
+
aligns['starred'] = pgrp['pident'].transform(star, args.starred)
|
|
478
|
+
|
|
479
|
+
# assign names to assignment_hashes
|
|
480
|
+
logging.info('creating compound assignments')
|
|
481
|
+
name_grp = aligns.groupby(
|
|
482
|
+
by=['specimen', 'assignment_hash'], group_keys=False)
|
|
483
|
+
name_grp = name_grp[['condensed_tax_name', 'starred']]
|
|
484
|
+
aligns[['assignment']] = name_grp.apply(assign)
|
|
485
|
+
|
|
486
|
+
# set this as str to handle na values for [no blast result]s
|
|
487
|
+
aligns['starred'] = aligns['starred'].astype(str)
|
|
488
|
+
|
|
489
|
+
# Foreach ref rank:
|
|
490
|
+
# - merge with lineages, extract rank_id, rank_name
|
|
491
|
+
for rank in args.include_ref_rank:
|
|
492
|
+
aligns[rank + '_id'] = aligns.merge(
|
|
493
|
+
lineages, left_on='tax_id',
|
|
494
|
+
right_index=True,
|
|
495
|
+
how='left')[rank].fillna(0)
|
|
496
|
+
aligns[rank + '_name'] = aligns.merge(
|
|
497
|
+
lineages,
|
|
498
|
+
left_on=rank + '_id',
|
|
499
|
+
right_index=True,
|
|
500
|
+
how='left')['tax_name_y']
|
|
501
|
+
|
|
502
|
+
# assign seqs that had no results to [no blast_result]
|
|
503
|
+
qseqids = qseqids[~qseqids['qseqid'].isin(aligns['qseqid'])]
|
|
504
|
+
qseqids['assignment'] = '[no blast result]'
|
|
505
|
+
qseqids['assignment_hash'] = 0
|
|
506
|
+
qseqids['assignment_tax_name'] = ''
|
|
507
|
+
qseqids['assignment_rank'] = ''
|
|
508
|
+
qseqids['assignment_threshold'] = numpy.nan
|
|
509
|
+
qseqids['condensed_id'] = ''
|
|
510
|
+
qseqids['condensed_rank'] = ''
|
|
511
|
+
qseqids['starred'] = ''
|
|
512
|
+
|
|
513
|
+
if aligns.empty:
|
|
514
|
+
dtypes = qseqids.dtypes
|
|
515
|
+
else:
|
|
516
|
+
dtypes = aligns.dtypes
|
|
517
|
+
|
|
518
|
+
# add back qseqids that have no hits back into aligns
|
|
519
|
+
if not qseqids.empty:
|
|
520
|
+
aligns = pd.concat([aligns, qseqids])
|
|
521
|
+
aligns = aligns.astype(dtypes)
|
|
522
|
+
|
|
523
|
+
# concludes our alignment details, on to output summary
|
|
524
|
+
logging.info('summarizing output')
|
|
525
|
+
|
|
526
|
+
# index by specimen and assignment_hash and add assignment column
|
|
527
|
+
index = ['specimen', 'assignment_hash']
|
|
528
|
+
output = aligns[index + ['assignment']].drop_duplicates()
|
|
529
|
+
output = output.set_index(index)
|
|
530
|
+
|
|
531
|
+
# assignment level stats
|
|
532
|
+
assignment_stats = aligns.groupby(by=index)
|
|
533
|
+
output['max_percent'] = assignment_stats['pident'].max()
|
|
534
|
+
output['min_percent'] = assignment_stats['pident'].min()
|
|
535
|
+
output['min_threshold'] = assignment_stats['assignment_threshold'].min()
|
|
536
|
+
output['best_rank'] = assignment_stats['condensed_rank'].min()
|
|
537
|
+
|
|
538
|
+
# qseqid cluster stats
|
|
539
|
+
weights = aligns[
|
|
540
|
+
['qseqid', 'specimen', 'assignment_hash',
|
|
541
|
+
'assignment_threshold', 'weight']]
|
|
542
|
+
weights = weights.drop_duplicates().set_index('qseqid')
|
|
543
|
+
|
|
544
|
+
cluster_stats = weights[['specimen', 'assignment_hash', 'weight']]
|
|
545
|
+
cluster_stats = cluster_stats.reset_index().drop_duplicates()
|
|
546
|
+
cluster_stats = cluster_stats.groupby(by=['specimen', 'assignment_hash'])
|
|
547
|
+
|
|
548
|
+
output['reads'] = cluster_stats['weight'].sum()
|
|
549
|
+
output['clusters'] = cluster_stats.size()
|
|
550
|
+
|
|
551
|
+
# specimen level stats
|
|
552
|
+
specimen_stats = output.groupby(level='specimen')
|
|
553
|
+
output['pct_reads'] = specimen_stats['reads'].transform(pct)
|
|
554
|
+
|
|
555
|
+
# copy number corrections
|
|
556
|
+
if args.copy_numbers:
|
|
557
|
+
corrections = copy_corrections(args.copy_numbers, aligns)
|
|
558
|
+
output['corrected'] = output['reads'] / corrections
|
|
559
|
+
# reset corrected counts to int before calculating pct_corrected
|
|
560
|
+
output['corrected'] = output['corrected'].transform(math.ceil)
|
|
561
|
+
output['corrected'] = output['corrected'].fillna(1).astype(int)
|
|
562
|
+
# create pct_corrected column
|
|
563
|
+
output['pct_corrected'] = specimen_stats['corrected'].transform(pct)
|
|
564
|
+
output['pct_corrected'] = output['pct_corrected'].map(round_up)
|
|
565
|
+
output_cols.extend(['corrected', 'pct_corrected'])
|
|
566
|
+
|
|
567
|
+
# round reads for output
|
|
568
|
+
output['reads'] = output['reads'].transform(round).astype(int)
|
|
569
|
+
output['pct_reads'] = output['pct_reads'].map(round_up)
|
|
570
|
+
|
|
571
|
+
# sort output by:
|
|
572
|
+
# 1) specimen -- Data Frame is already grouped by specimen
|
|
573
|
+
# 2) read/corrected count
|
|
574
|
+
# 3) cluster count
|
|
575
|
+
# 4) alpha assignment
|
|
576
|
+
columns = ['corrected'] if args.copy_numbers else ['reads']
|
|
577
|
+
columns += ['clusters', 'assignment']
|
|
578
|
+
output = output.sort_values(by=columns, ascending=False)
|
|
579
|
+
output = output.reset_index(level='assignment_hash')
|
|
580
|
+
|
|
581
|
+
# one last groupby and sort on the assignment ids by specimen
|
|
582
|
+
output = output.groupby(level="specimen").apply(assignment_id)
|
|
583
|
+
|
|
584
|
+
# output to details.csv.bz2
|
|
585
|
+
if args.details_out:
|
|
586
|
+
# Annotate details with classification columns
|
|
587
|
+
aligns = aligns.merge(output.reset_index(), how='left')
|
|
588
|
+
|
|
589
|
+
if not args.details_full:
|
|
590
|
+
"""
|
|
591
|
+
by using the assignment_threshold we will get multiple 'largest'
|
|
592
|
+
centroids for --max-group-size combined assignments
|
|
593
|
+
"""
|
|
594
|
+
# groupby will drop NA values so we must fill them with 0
|
|
595
|
+
weights['assignment_threshold'] = weights[
|
|
596
|
+
'assignment_threshold'].fillna(0)
|
|
597
|
+
largest = weights.groupby(
|
|
598
|
+
by=['specimen', 'assignment_hash', 'assignment_threshold'])
|
|
599
|
+
largest = largest['weight'].nlargest(1)
|
|
600
|
+
largest = largest.reset_index()
|
|
601
|
+
# assignment_threshold will conflict with aligns NA values
|
|
602
|
+
largest = largest.drop('assignment_threshold', axis=1)
|
|
603
|
+
aligns = aligns.merge(largest)
|
|
604
|
+
|
|
605
|
+
ref_rank_columns = [rank + '_id' for rank in args.include_ref_rank]
|
|
606
|
+
ref_rank_columns += [rank + '_name' for rank in args.include_ref_rank]
|
|
607
|
+
details_cols += ref_rank_columns
|
|
608
|
+
|
|
609
|
+
if args.hits_below_threshold:
|
|
610
|
+
"""
|
|
611
|
+
append assignment_thresholds and append to --details-out
|
|
612
|
+
"""
|
|
613
|
+
deets_cols = hits_below_threshold.columns
|
|
614
|
+
deets_cols = deets_cols.intersection(set(details_cols))
|
|
615
|
+
hits_below_threshold = hits_below_threshold[list(deets_cols)]
|
|
616
|
+
threshold_cols = ['specimen', 'qseqid', 'assignment_threshold']
|
|
617
|
+
assignment_thresholds = aligns[threshold_cols]
|
|
618
|
+
assignment_thresholds = assignment_thresholds.drop_duplicates()
|
|
619
|
+
hits_below_threshold = hits_below_threshold.merge(
|
|
620
|
+
assignment_thresholds, how='left')
|
|
621
|
+
aligns = pd.concat(
|
|
622
|
+
[aligns, hits_below_threshold],
|
|
623
|
+
ignore_index=True)
|
|
624
|
+
|
|
625
|
+
"""sort details for consistency and ease of viewing.
|
|
626
|
+
[no blast results] may have irregular aligns.columns
|
|
627
|
+
"""
|
|
628
|
+
aligns = aligns.sort_values(by=details_cols)
|
|
629
|
+
|
|
630
|
+
aligns.to_csv(
|
|
631
|
+
args.details_out,
|
|
632
|
+
columns=details_cols,
|
|
633
|
+
header=True,
|
|
634
|
+
index=False,
|
|
635
|
+
float_format='%.2f')
|
|
636
|
+
|
|
637
|
+
# was required to merge with details above but not needed now
|
|
638
|
+
output = output.drop('assignment_hash', axis=1)
|
|
639
|
+
|
|
640
|
+
# output results
|
|
641
|
+
output.to_csv(
|
|
642
|
+
args.out,
|
|
643
|
+
columns=[c for c in output_cols if c in output.columns],
|
|
644
|
+
index=True,
|
|
645
|
+
float_format='%.2f')
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def assign(df):
|
|
649
|
+
"""
|
|
650
|
+
Create taxonomic names based on 'assignments', which are a set of
|
|
651
|
+
two-tuples: {(tax_name, is_starred), ...} where each tax_id is a key
|
|
652
|
+
into taxdict, and is_starred is a boolean indicating whether at
|
|
653
|
+
least one reference sequence had a parirwise alignment identity
|
|
654
|
+
score meeting some thresholed. 'taxdict' is a dictionary keyed by
|
|
655
|
+
tax_id and returning a dict of taxonomic data corresponding to a
|
|
656
|
+
row from the lineages file. If 'include_stars' is False, ignore
|
|
657
|
+
the second element of each tuple in 'assignments' and do not
|
|
658
|
+
include asterisks in the output names.
|
|
659
|
+
assignments = [(tax_id, is_starred),...]
|
|
660
|
+
lineages = {taxid:lineages}
|
|
661
|
+
Functionality: see format_lineages
|
|
662
|
+
"""
|
|
663
|
+
ids_stars = df.drop_duplicates()
|
|
664
|
+
ids_stars = zip(*ids_stars.values.tolist())
|
|
665
|
+
df['assignment'] = format_lineages(*ids_stars, asterisk='*')
|
|
666
|
+
return df[['assignment']]
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def assignment_id(df):
|
|
670
|
+
"""Resets and drops the current dataframe's
|
|
671
|
+
index and sets it to the assignment_hash
|
|
672
|
+
|
|
673
|
+
assignment_id is treated as a string identifier to account
|
|
674
|
+
for hits in details with no assignment or assignment_id
|
|
675
|
+
"""
|
|
676
|
+
df = df.reset_index(drop=True) # specimen is retained in the group key
|
|
677
|
+
df.index.name = 'assignment_id'
|
|
678
|
+
df.index = df.index.astype(str)
|
|
679
|
+
return df
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def test():
|
|
683
|
+
return build_parser()
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def build_lineages(tax_ids, tar, url):
|
|
687
|
+
if tar is None or not os.path.isfile(tar):
|
|
688
|
+
tar = lineages.get_taxdmp(url)
|
|
689
|
+
with tarfile.open(name=tar, mode='r:gz') as taxdmp:
|
|
690
|
+
logging.info('building lineages from NCBI')
|
|
691
|
+
nodes, names = lineages.get_data(taxdmp)
|
|
692
|
+
tree = lineages.Tree(nodes, names)
|
|
693
|
+
tree.root.prune(tax_ids)
|
|
694
|
+
return tree
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def build_parser():
|
|
698
|
+
parser = argparse.ArgumentParser()
|
|
699
|
+
# required inputs
|
|
700
|
+
parser.add_argument(
|
|
701
|
+
'alignments',
|
|
702
|
+
default=sys.stdin,
|
|
703
|
+
nargs='?',
|
|
704
|
+
help=('alignment file with query and '
|
|
705
|
+
'subject sequence hits and optional header'))
|
|
706
|
+
parser.add_argument(
|
|
707
|
+
'--seq-info',
|
|
708
|
+
metavar='',
|
|
709
|
+
help='map file seqname to tax_id')
|
|
710
|
+
|
|
711
|
+
package_parser = parser.add_argument_group(
|
|
712
|
+
title='logging and version options')
|
|
713
|
+
package_parser.add_argument(
|
|
714
|
+
'-V', '--version',
|
|
715
|
+
action='version',
|
|
716
|
+
version=__version__,
|
|
717
|
+
help='Print the version number and exit')
|
|
718
|
+
package_parser.add_argument(
|
|
719
|
+
'-l', '--log',
|
|
720
|
+
metavar='',
|
|
721
|
+
default=sys.stdout,
|
|
722
|
+
help='Send logging to a file')
|
|
723
|
+
package_parser.add_argument(
|
|
724
|
+
'-v', '--verbose',
|
|
725
|
+
action='count',
|
|
726
|
+
dest='verbosity',
|
|
727
|
+
default=0,
|
|
728
|
+
help='Increase verbosity of screen output '
|
|
729
|
+
'(eg, -v is verbose, -vv more so)')
|
|
730
|
+
package_parser.add_argument(
|
|
731
|
+
'-q', '--quiet',
|
|
732
|
+
action='store_const',
|
|
733
|
+
dest='verbosity',
|
|
734
|
+
const=0,
|
|
735
|
+
help='Suppress output')
|
|
736
|
+
|
|
737
|
+
align_parser = parser.add_argument_group(
|
|
738
|
+
title='alignment input header-less options',
|
|
739
|
+
description=('will detect if header with '
|
|
740
|
+
'qseqid,sseqid,pident else blast6 columns'))
|
|
741
|
+
columns_parser = align_parser.add_mutually_exclusive_group(required=False)
|
|
742
|
+
columns_parser.add_argument(
|
|
743
|
+
'--columns', '-c',
|
|
744
|
+
metavar='',
|
|
745
|
+
help=('specify columns for header-less comma-seperated values'))
|
|
746
|
+
|
|
747
|
+
selection_parser = parser.add_argument_group('selection options')
|
|
748
|
+
selection_parser = selection_parser.add_mutually_exclusive_group(
|
|
749
|
+
required=False)
|
|
750
|
+
selection_parser.add_argument(
|
|
751
|
+
'--rank-thresholds',
|
|
752
|
+
metavar='',
|
|
753
|
+
help='Columns [tax_id,ranks...] [%(default)s]')
|
|
754
|
+
selection_parser.add_argument(
|
|
755
|
+
'--top-n-pct',
|
|
756
|
+
metavar='',
|
|
757
|
+
default=25,
|
|
758
|
+
type=float,
|
|
759
|
+
help=('top percent hits sorted by pident, evalue '
|
|
760
|
+
'or bitscore per taxonomy group'))
|
|
761
|
+
|
|
762
|
+
assignment_parser = parser.add_argument_group('assignment options')
|
|
763
|
+
assignment_parser.add_argument(
|
|
764
|
+
'--starred',
|
|
765
|
+
metavar='',
|
|
766
|
+
default=100.0,
|
|
767
|
+
type=float,
|
|
768
|
+
help=('Names of organisms for which at least one reference '
|
|
769
|
+
'sequence has pairwise identity with a query sequence of at '
|
|
770
|
+
'least PERCENT will be marked with an asterisk [%(default)s]'))
|
|
771
|
+
assignment_parser.add_argument(
|
|
772
|
+
'--max-group-size',
|
|
773
|
+
metavar='',
|
|
774
|
+
default=3,
|
|
775
|
+
type=int,
|
|
776
|
+
help=('group multiple target-rank assignments that excede a '
|
|
777
|
+
'threshold to a higher rank [%(default)s]'))
|
|
778
|
+
assignment_parser.add_argument(
|
|
779
|
+
'--split-condensed-assignments',
|
|
780
|
+
action='store_true',
|
|
781
|
+
dest='threshold_assignments',
|
|
782
|
+
help=('Group final assignment classifications '
|
|
783
|
+
'before assigning condensed taxonomic ids'))
|
|
784
|
+
|
|
785
|
+
# optional inputs
|
|
786
|
+
opts_parser = parser.add_argument_group('other input options')
|
|
787
|
+
opts_parser.add_argument(
|
|
788
|
+
'--copy-numbers',
|
|
789
|
+
metavar='',
|
|
790
|
+
help=('Estimated 16s rRNA gene copy number for each tax_ids '
|
|
791
|
+
'(CSV file with columns: tax_id, count)'))
|
|
792
|
+
opts_group = opts_parser.add_mutually_exclusive_group(required=False)
|
|
793
|
+
opts_group.add_argument(
|
|
794
|
+
'--specimen',
|
|
795
|
+
metavar='',
|
|
796
|
+
help='Single group label for reads')
|
|
797
|
+
opts_group.add_argument(
|
|
798
|
+
'--specimen-map',
|
|
799
|
+
metavar='',
|
|
800
|
+
help='Three column headerless csv file specimen,qseqid,weight')
|
|
801
|
+
|
|
802
|
+
# lineage taxonomy source data
|
|
803
|
+
lineage_parser = parser.add_argument_group(
|
|
804
|
+
'lineage and ncbi source options')
|
|
805
|
+
lineage_parser.add_argument(
|
|
806
|
+
'--lineages',
|
|
807
|
+
help='Table defining taxonomic lineages for each taxonomy id')
|
|
808
|
+
lineage_parser.add_argument(
|
|
809
|
+
'--taxdump',
|
|
810
|
+
default='taxdump.tar.gz',
|
|
811
|
+
help='lineages source data [%(default)s]')
|
|
812
|
+
lineage_parser.add_argument(
|
|
813
|
+
'--tax-url',
|
|
814
|
+
default='ftp://ftp.ncbi.nlm.nih.gov/pub/taxonomy/taxdump.tar.gz',
|
|
815
|
+
help='url for downloading taxdump file [%(default)s]')
|
|
816
|
+
lineage_parser.add_argument(
|
|
817
|
+
'--no-rank-suffix',
|
|
818
|
+
help='expand and include no rank taxonomies '
|
|
819
|
+
'with appended string suffix')
|
|
820
|
+
|
|
821
|
+
outs_parser = parser.add_argument_group('output options')
|
|
822
|
+
outs_parser.add_argument(
|
|
823
|
+
'--details-full',
|
|
824
|
+
action='store_true',
|
|
825
|
+
help=('do not limit out_details to largest '
|
|
826
|
+
'cluster per assignment [%(default)s]'))
|
|
827
|
+
outs_parser.add_argument(
|
|
828
|
+
'--include-ref-rank',
|
|
829
|
+
action='append',
|
|
830
|
+
default=[],
|
|
831
|
+
metavar='',
|
|
832
|
+
help=('Given a single rank (species,genus,etc), '
|
|
833
|
+
'include each reference '
|
|
834
|
+
'sequence\'s tax_id as ${rank}_id and its taxonomic name as '
|
|
835
|
+
'${rank}_name in details output'))
|
|
836
|
+
outs_parser.add_argument(
|
|
837
|
+
'--hits-below-threshold',
|
|
838
|
+
action='store_true',
|
|
839
|
+
help=('Hits that were below the best-rank threshold '
|
|
840
|
+
'will be included in the details'))
|
|
841
|
+
outs_parser.add_argument(
|
|
842
|
+
'--details-out',
|
|
843
|
+
metavar='',
|
|
844
|
+
help='Optional details of taxonomic assignments')
|
|
845
|
+
outs_parser.add_argument(
|
|
846
|
+
'--lineages-out',
|
|
847
|
+
metavar='',
|
|
848
|
+
help='Output blast input specific lineages file')
|
|
849
|
+
outs_parser.add_argument(
|
|
850
|
+
'-o', '--out',
|
|
851
|
+
metavar='',
|
|
852
|
+
default=sys.stdout,
|
|
853
|
+
help="classification results [default: stdout]")
|
|
854
|
+
return parser
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def calculate_pct_references(df, pct_reference):
|
|
858
|
+
'''
|
|
859
|
+
Not used yet. Given a total number of reference sequences this function
|
|
860
|
+
will divide the sseqids by the reference sequence
|
|
861
|
+
count for a pct_reference.
|
|
862
|
+
'''
|
|
863
|
+
reference_count = df[['tax_id']].drop_duplicates()
|
|
864
|
+
reference_count = reference_count.join(pct_reference, on='tax_id')
|
|
865
|
+
reference_count = reference_count['count'].sum()
|
|
866
|
+
sseqid_count = float(len(df['sseqid'].drop_duplicates()))
|
|
867
|
+
df['pct_reference'] = sseqid_count / reference_count
|
|
868
|
+
return df
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def condense_ids(tax_ids,
|
|
872
|
+
lineages,
|
|
873
|
+
ranks,
|
|
874
|
+
floor_rank=None,
|
|
875
|
+
ceiling_rank=None,
|
|
876
|
+
max_size=3,
|
|
877
|
+
rank_thresholds={}):
|
|
878
|
+
"""
|
|
879
|
+
assignments = [tax_ids...]
|
|
880
|
+
lineages = {taxid:lineages}
|
|
881
|
+
|
|
882
|
+
Functionality: Group items into taxonomic groups given max rank sizes.
|
|
883
|
+
"""
|
|
884
|
+
floor_rank = floor_rank or ranks[-1]
|
|
885
|
+
ceiling_rank = ceiling_rank or ranks[0]
|
|
886
|
+
|
|
887
|
+
# set rank to ceiling
|
|
888
|
+
try:
|
|
889
|
+
lis = {a: lineages[a][ceiling_rank] for a in tax_ids}
|
|
890
|
+
except KeyError as e:
|
|
891
|
+
error = ('tax id not found in lineages. ' + e)
|
|
892
|
+
raise KeyError(error)
|
|
893
|
+
|
|
894
|
+
def walk_taxtree(groups, ceiling_rank=ceiling_rank, max_size=max_size):
|
|
895
|
+
new_groups = {}
|
|
896
|
+
|
|
897
|
+
for a, r in groups.items():
|
|
898
|
+
new_groups[a] = lineages[a][ceiling_rank] or r
|
|
899
|
+
|
|
900
|
+
num_groups = len(set(new_groups.values()))
|
|
901
|
+
|
|
902
|
+
if rank_thresholds.get(ceiling_rank, max_size) < num_groups:
|
|
903
|
+
return groups
|
|
904
|
+
|
|
905
|
+
groups = new_groups
|
|
906
|
+
|
|
907
|
+
# return if we hit the floor
|
|
908
|
+
if ceiling_rank == floor_rank:
|
|
909
|
+
return groups
|
|
910
|
+
|
|
911
|
+
# else move down a rank
|
|
912
|
+
ceiling_rank = ranks[ranks.index(ceiling_rank) + 1]
|
|
913
|
+
|
|
914
|
+
# recurse each branch down the tax # tree
|
|
915
|
+
branches = sorted(groups.items(), key=operator.itemgetter(1))
|
|
916
|
+
for _, g in itertools.groupby(branches, operator.itemgetter(1)):
|
|
917
|
+
g = walk_taxtree(
|
|
918
|
+
dict(g), ceiling_rank, max_size - num_groups + 1)
|
|
919
|
+
groups.update(g)
|
|
920
|
+
|
|
921
|
+
return groups
|
|
922
|
+
|
|
923
|
+
return tax_ids.replace(walk_taxtree(lis))
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def assignment_hash(s):
|
|
927
|
+
"""
|
|
928
|
+
By taking a hash of the set (frozenset) of ids the qseqid is given a
|
|
929
|
+
unique identifier (the hash). Later, we will use this hash and
|
|
930
|
+
assign an assignment name based on either the set of condensed_ids or
|
|
931
|
+
assignment_tax_ids. The reason for using a hash rather than
|
|
932
|
+
the actual assignment text for grouping is that the assignment text
|
|
933
|
+
can contains extra annotations that are independent of which
|
|
934
|
+
assignment group a qseqid belongs to such as a 100% id star.
|
|
935
|
+
"""
|
|
936
|
+
return hash(frozenset(s.unique()))
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def copy_corrections(copy_numbers, aligns, user_file=None):
|
|
940
|
+
copy_numbers = pd.read_csv(
|
|
941
|
+
copy_numbers,
|
|
942
|
+
dtype=dict(tax_id=str, count=float),
|
|
943
|
+
usecols=['tax_id', 'count']).set_index('tax_id')
|
|
944
|
+
|
|
945
|
+
# get root out (taxid: 1) and set it as the default correction value
|
|
946
|
+
|
|
947
|
+
# set index nan (no blast result) to the default value
|
|
948
|
+
default = copy_numbers.at['1', 'count']
|
|
949
|
+
default_entry = pd.DataFrame(default, index=[None], columns=['count'])
|
|
950
|
+
copy_numbers = pd.concat([copy_numbers, default_entry])
|
|
951
|
+
|
|
952
|
+
# do our copy number correction math
|
|
953
|
+
corrections = aligns[
|
|
954
|
+
[ASSIGNMENT_TAX_ID, 'specimen', 'assignment_hash']]
|
|
955
|
+
corrections = corrections.drop_duplicates()
|
|
956
|
+
corrections = corrections.set_index(ASSIGNMENT_TAX_ID)
|
|
957
|
+
corrections = corrections.join(copy_numbers)
|
|
958
|
+
# any tax_id not present will receive default tax_id
|
|
959
|
+
corrections['count'] = corrections['count'].fillna(default)
|
|
960
|
+
corrections = corrections.groupby(by=['specimen', 'assignment_hash'])
|
|
961
|
+
corrections = corrections['count'].mean()
|
|
962
|
+
return corrections
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def format_lineages(names, selectors, asterisk='*'):
|
|
966
|
+
"""
|
|
967
|
+
Create a friendly formatted string of lineages names. Names will
|
|
968
|
+
have an asterisk value appended *only* if the cooresponding
|
|
969
|
+
element in the selectors evaluates to True.
|
|
970
|
+
"""
|
|
971
|
+
names = itertools.zip_longest(names, selectors)
|
|
972
|
+
names = ((n, asterisk if s else '')
|
|
973
|
+
for n, s in names) # add asterisk to selected names
|
|
974
|
+
names = set(names)
|
|
975
|
+
names = sorted(names) # sort by the name plus asterisk
|
|
976
|
+
# group by just the names
|
|
977
|
+
names = itertools.groupby(names, key=operator.itemgetter(0))
|
|
978
|
+
# prefer asterisk names which will be at the bottom
|
|
979
|
+
names = (list(g)[-1] for _, g in names)
|
|
980
|
+
names = (n + a for n, a in names) # combine names with asterisks
|
|
981
|
+
# assume species names have exactly two words
|
|
982
|
+
|
|
983
|
+
def is_species(s):
|
|
984
|
+
return len(s.split()) == 2
|
|
985
|
+
|
|
986
|
+
names = sorted(names, key=is_species)
|
|
987
|
+
names = itertools.groupby(names, key=is_species)
|
|
988
|
+
|
|
989
|
+
tax = []
|
|
990
|
+
|
|
991
|
+
for species, assigns in names:
|
|
992
|
+
if species:
|
|
993
|
+
# take the species word and combine them with a '/'
|
|
994
|
+
assigns = (a.split() for a in assigns)
|
|
995
|
+
# group by genus name
|
|
996
|
+
assigns = itertools.groupby(assigns, key=operator.itemgetter(0))
|
|
997
|
+
assigns = ((k, map(operator.itemgetter(1), g))
|
|
998
|
+
for k, g in assigns) # get a list of the species names
|
|
999
|
+
assigns = ('{} {}'.format(k, '/'.join(g))
|
|
1000
|
+
for k, g in assigns) # combine species names with '/'
|
|
1001
|
+
|
|
1002
|
+
tax.extend(assigns)
|
|
1003
|
+
|
|
1004
|
+
return ';'.join(sorted(tax))
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def join_thresholds(df, thresholds, ranks):
|
|
1008
|
+
"""Thresholds are matched to thresholds by rank id.
|
|
1009
|
+
|
|
1010
|
+
If a rank id is not present in the thresholds then the next specific
|
|
1011
|
+
rank id is used all the way up to `root'. If the root id still does
|
|
1012
|
+
not match then a warning is issued with the taxname and the alignment
|
|
1013
|
+
is dropped.
|
|
1014
|
+
"""
|
|
1015
|
+
with_thresholds = []
|
|
1016
|
+
for r in ranks:
|
|
1017
|
+
at_rank = df.join(thresholds, on=r, how='inner')
|
|
1018
|
+
with_thresholds.append(at_rank)
|
|
1019
|
+
df = df.drop(at_rank.index, axis='rows')
|
|
1020
|
+
|
|
1021
|
+
# issue warning messages for everything that did not join
|
|
1022
|
+
if len(df) > 0:
|
|
1023
|
+
tax_names = df['tax_name'].drop_duplicates()
|
|
1024
|
+
msg = ('dropping alignment `{}\', no valid '
|
|
1025
|
+
'taxonomic threshold information found')
|
|
1026
|
+
tax_names.apply(lambda x: logging.warning(msg.format(x)))
|
|
1027
|
+
|
|
1028
|
+
with_thresholds = pd.concat(with_thresholds)
|
|
1029
|
+
if with_thresholds.empty:
|
|
1030
|
+
with_thresholds = df.iloc[0:0]
|
|
1031
|
+
return with_thresholds
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def load_rank_thresholds(path, ranks):
|
|
1035
|
+
"""
|
|
1036
|
+
Load a rank-thresholds file. If no argument is specified the default
|
|
1037
|
+
rank_threshold_defaults.csv file will be loaded.
|
|
1038
|
+
"""
|
|
1039
|
+
dtypes = {'tax_id': str}
|
|
1040
|
+
dtypes.update(zip(ranks, [float] * len(ranks)))
|
|
1041
|
+
rank_thresholds = pd.read_csv(path, comment='#', dtype=dtypes)
|
|
1042
|
+
rank_thresholds = rank_thresholds.set_index('tax_id')
|
|
1043
|
+
drop = [col for col in rank_thresholds.columns if col not in ranks]
|
|
1044
|
+
return rank_thresholds.drop(drop, axis=1)
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def round_up(x):
|
|
1048
|
+
"""round up any x < 0.01
|
|
1049
|
+
"""
|
|
1050
|
+
return max(0.01, x)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def select_best_hits(df):
|
|
1054
|
+
"""Return threshold passing hits.
|
|
1055
|
+
|
|
1056
|
+
Example 1:
|
|
1057
|
+
|
|
1058
|
+
sseqid | pident | genus | species_group | species
|
|
1059
|
+
| | | |
|
|
1060
|
+
| | | |
|
|
1061
|
+
---------------------------------------------------
|
|
1062
|
+
S0001 | 98.56 | 274591 | G0001 | 288436
|
|
1063
|
+
S0002 | 97.32 | 28100 | | 314236
|
|
1064
|
+
S0003 | 99.01 | 561 | | 562
|
|
1065
|
+
|
|
1066
|
+
| genus_ | species_ | species_
|
|
1067
|
+
| threshold | group_ | threshold
|
|
1068
|
+
| | threshold |
|
|
1069
|
+
-------------------------------------
|
|
1070
|
+
| 97.0 | 98.0 | 99.0
|
|
1071
|
+
| 97.0 | 98.0 | 99.0
|
|
1072
|
+
| 97.0 | 98.0 | 99.0
|
|
1073
|
+
|
|
1074
|
+
Just hit S0003 is returned as the best available hit a the species_group
|
|
1075
|
+
rank level.
|
|
1076
|
+
|
|
1077
|
+
Example 2:
|
|
1078
|
+
|
|
1079
|
+
sseqid | pident | genus | species_group | species
|
|
1080
|
+
| | | |
|
|
1081
|
+
| | | |
|
|
1082
|
+
---------------------------------------------------
|
|
1083
|
+
S0001 | 98.56 | 274591 | G0001 | 288436
|
|
1084
|
+
S0002 | 97.32 | 28100 | | 314236
|
|
1085
|
+
S0003 | 98.01 | 561 | | 562
|
|
1086
|
+
|
|
1087
|
+
| genus_ | species_ | species_
|
|
1088
|
+
| threshold | group_ | threshold
|
|
1089
|
+
| | threshold |
|
|
1090
|
+
------------------------------------
|
|
1091
|
+
| 97.0 | 98.0 | 99.0
|
|
1092
|
+
| 97.0 | 98.0 | 99.0
|
|
1093
|
+
| 97.0 | 98.0 | 99.0
|
|
1094
|
+
|
|
1095
|
+
Now S0003 has a pident of 98.01 so both S001 and S003 are selected as the
|
|
1096
|
+
best hits. Because S003 has no species_group tax_id the ASSIGNMENT_TAX_ID
|
|
1097
|
+
is set to the genus level tax_id.
|
|
1098
|
+
|
|
1099
|
+
Example 3:
|
|
1100
|
+
|
|
1101
|
+
sseqid | pident | genus | species_group | species
|
|
1102
|
+
| | | |
|
|
1103
|
+
| | | |
|
|
1104
|
+
---------------------------------------------------
|
|
1105
|
+
S0001 | 97.56 | 274591 | G0001 | 288436
|
|
1106
|
+
S0002 | 97.32 | 28100 | | 314236
|
|
1107
|
+
S0003 | 98.01 | 561 | | 562
|
|
1108
|
+
|
|
1109
|
+
| genus_ | species_ | species_
|
|
1110
|
+
| threshold | group_ | threshold
|
|
1111
|
+
| | threshold |
|
|
1112
|
+
------------------------------------
|
|
1113
|
+
| 97.0 | 98.0 | 99.0
|
|
1114
|
+
| 97.0 | 98.0 | 99.0
|
|
1115
|
+
| 97.0 | 98.0 | 99.0
|
|
1116
|
+
|
|
1117
|
+
Even though S003 is the best threshold passing hit the species_group tax_id
|
|
1118
|
+
does not exist therefore the genus level threshold is used to select the
|
|
1119
|
+
best hits in this example and all hits are returned with the
|
|
1120
|
+
ASSIGNMENT_TAX_IDs set at the genus level.
|
|
1121
|
+
"""
|
|
1122
|
+
tlevel = df['threshold_level'].max()
|
|
1123
|
+
alevel = df['assignment_level'].max()
|
|
1124
|
+
df['best_hit'] = False
|
|
1125
|
+
if alevel < tlevel:
|
|
1126
|
+
df.loc[df['assignment_level'] == alevel, 'best_hit'] = True
|
|
1127
|
+
df['assignment_threshold'] = df['assignment_level_threshold']
|
|
1128
|
+
else:
|
|
1129
|
+
df.loc[df['threshold_level'] == tlevel, 'best_hit'] = True
|
|
1130
|
+
df['assignment_threshold'] = df['threshold_level_threshold']
|
|
1131
|
+
return df[['assignment_threshold', 'best_hit']]
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def pct(s):
|
|
1135
|
+
"""Calculate series pct something
|
|
1136
|
+
"""
|
|
1137
|
+
return s / s.sum() * 100
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def read_seqinfo(path, ids):
|
|
1141
|
+
"""
|
|
1142
|
+
Iterates through seq_info file only including
|
|
1143
|
+
necessary seqname to tax_id mappings
|
|
1144
|
+
"""
|
|
1145
|
+
with opener(path) as infofile:
|
|
1146
|
+
seq_info = csv.reader(infofile)
|
|
1147
|
+
header = next(seq_info)
|
|
1148
|
+
seqname, tax_id = header.index('seqname'), header.index('tax_id')
|
|
1149
|
+
seq_info = (row for row in seq_info if row[seqname] in ids)
|
|
1150
|
+
seq_info = {row[seqname]: row[tax_id] for row in seq_info}
|
|
1151
|
+
return pd.Series(data=seq_info, name='tax_id')
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def read_lineages(path, ids):
|
|
1155
|
+
"""
|
|
1156
|
+
Iterates through lineages file only including necessary lineages
|
|
1157
|
+
|
|
1158
|
+
FIXME: tax_ids after first pass can disappear if tax_id rank not in columns
|
|
1159
|
+
"""
|
|
1160
|
+
with opener(path) as taxfile:
|
|
1161
|
+
taxcsv = csv.reader(taxfile)
|
|
1162
|
+
header = next(taxcsv)
|
|
1163
|
+
tax_id, root = header.index('tax_id'), header.index('root')
|
|
1164
|
+
taxcsv = (r for r in taxcsv if r[tax_id] in ids)
|
|
1165
|
+
tax_ids = set(i for r in taxcsv for i in r[root:] if i != '')
|
|
1166
|
+
with opener(path) as taxfile:
|
|
1167
|
+
taxcsv = csv.reader(taxfile)
|
|
1168
|
+
header = next(taxcsv)
|
|
1169
|
+
taxcsv = (r for r in taxcsv if r[tax_id] in tax_ids)
|
|
1170
|
+
taxcsv = (map(lambda x: x if x else numpy.nan, r) for r in taxcsv)
|
|
1171
|
+
data = [dict(zip(header, r)) for r in taxcsv]
|
|
1172
|
+
return pd.DataFrame(data=data, columns=header)
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def opener(f, mode='rt'):
|
|
1176
|
+
"""Factory for creating file objects
|
|
1177
|
+
|
|
1178
|
+
Keyword Arguments:
|
|
1179
|
+
- mode -- A string indicating how the file is to be opened. Accepts the
|
|
1180
|
+
same values as the builtin open() function.
|
|
1181
|
+
- bufsize -- The file's desired buffer size. Accepts the same values as
|
|
1182
|
+
the builtin open() function.
|
|
1183
|
+
"""
|
|
1184
|
+
stream = None
|
|
1185
|
+
if f is sys.stdout or f is sys.stdin:
|
|
1186
|
+
stream = f
|
|
1187
|
+
elif f == '-':
|
|
1188
|
+
stream = sys.stdin if 'r' in mode else sys.stdout
|
|
1189
|
+
elif f.endswith('.bz2'):
|
|
1190
|
+
stream = bz2.open(f, mode)
|
|
1191
|
+
elif f.endswith('.gz'):
|
|
1192
|
+
stream = gzip.open(f, mode)
|
|
1193
|
+
elif f.endswith('.xz'):
|
|
1194
|
+
stream = lzma.open(f, mode)
|
|
1195
|
+
else:
|
|
1196
|
+
stream = open(f, mode)
|
|
1197
|
+
return stream
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def star(s, starred):
|
|
1201
|
+
"""Assign boolean if any items in the
|
|
1202
|
+
dataframe are above the star threshold.
|
|
1203
|
+
"""
|
|
1204
|
+
return s.apply(lambda x: x >= starred).any()
|