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.
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()