nonhuman-screen 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,63 @@
1
+ """nonhuman-screen: classify sequencing reads by non-human taxonomic content.
2
+
3
+ Wraps kraken2's LCA classification and reduces per-read verdicts to a
4
+ non-human fraction (NHF) plus per-domain breakdowns, with a human-homology
5
+ guard and UniVec-Core exclusion. Sample-agnostic: it classifies whatever
6
+ named reads you hand it, so it works equally on a proband, a parent, a tumour,
7
+ or a plain FASTQ.
8
+
9
+ Two levels of API:
10
+
11
+ * Engine (stdlib only) — ``Kraken2Runner.classify_sequences({name: seq})``.
12
+ * BAM/allele helpers (needs the ``[bam]`` extra) — ``classify_variant_alt_reads``
13
+ and ``classify_variants_alt_reads`` compute the non-human fraction of the
14
+ reads supporting a variant's ALT allele.
15
+ """
16
+
17
+ from nonhuman_screen.engine import (
18
+ ClassificationResult,
19
+ Kraken2Runner,
20
+ _ARCHAEA_TAXID,
21
+ _BACTERIA_TAXID,
22
+ _EUKARYOTA_TAXID,
23
+ _FUNGI_TAXID,
24
+ _HUMAN_TAXID,
25
+ _METAZOA_TAXID,
26
+ _UNIVEC_CORE_TAXID,
27
+ _VIRIDIPLANTAE_TAXID,
28
+ _VIRUSES_TAXID,
29
+ )
30
+ from nonhuman_screen.alleles import read_supports_alt
31
+ from nonhuman_screen.result import TaxonomicFractions, VariantNHF
32
+ from nonhuman_screen.votes import parse_kmer_votes
33
+
34
+ __version__ = "0.1.0"
35
+
36
+ __all__ = [
37
+ "Kraken2Runner",
38
+ "ClassificationResult",
39
+ "TaxonomicFractions",
40
+ "VariantNHF",
41
+ "read_supports_alt",
42
+ "parse_kmer_votes",
43
+ "__version__",
44
+ ]
45
+
46
+ # BAM/allele helpers require pysam ([bam] extra). Expose them at the top level
47
+ # when available, but never make importing the core engine depend on pysam.
48
+ try:
49
+ from nonhuman_screen.bam import ( # noqa: F401
50
+ classify_reads_from_bam,
51
+ classify_variant_alt_reads,
52
+ classify_variants_alt_reads,
53
+ reads_supporting_alt,
54
+ )
55
+
56
+ __all__ += [
57
+ "classify_reads_from_bam",
58
+ "classify_variant_alt_reads",
59
+ "classify_variants_alt_reads",
60
+ "reads_supporting_alt",
61
+ ]
62
+ except ImportError:
63
+ pass
@@ -0,0 +1,85 @@
1
+ """Allele-support determination for reads at a variant locus.
2
+
3
+ Pure-Python helpers that decide whether an aligned read carries a given
4
+ alternate allele. They operate on a pysam ``AlignedSegment`` passed in by
5
+ the caller but do **not** import pysam themselves, so this module has no
6
+ third-party dependency.
7
+ """
8
+
9
+
10
+ def _is_symbolic(allele):
11
+ """Return True if *allele* is a symbolic VCF allele with no literal sequence.
12
+
13
+ Symbolic alleles include ``<DEL>``, ``<INS>``, ``<DUP>``, breakend
14
+ notation containing ``[`` or ``]``, and the overlapping-deletion
15
+ marker ``*``.
16
+ """
17
+ if not allele:
18
+ return True
19
+ return allele[0] == "<" or allele == "*" or "[" in allele or "]" in allele
20
+
21
+
22
+ def read_supports_alt(
23
+ read, variant_pos, ref, alt, min_baseq=0, *,
24
+ aligned_pairs=None, seq=None, quals=None,
25
+ ):
26
+ """Return True if *read* carries the alternate allele at *variant_pos*.
27
+
28
+ Extracts the exact read sequence aligned to the reference span of the
29
+ variant and compares it strictly to the candidate alternate allele.
30
+ Handles SNPs, MNPs, insertions, deletions, and complex indels natively.
31
+
32
+ Returns ``False`` for symbolic alleles or when *alt* is ``None``.
33
+
34
+ Args:
35
+ min_baseq: Minimum base quality threshold for bases considered as
36
+ alt support.
37
+ aligned_pairs: Optional pre-computed result of
38
+ ``read.get_aligned_pairs(matches_only=False)``. Computed from
39
+ *read* when not provided.
40
+ seq: Optional pre-decoded ``read.query_sequence``. Decoded from
41
+ *read* when not provided.
42
+ quals: Optional pre-decoded ``read.query_qualities``. Decoded from
43
+ *read* only when ``min_baseq > 0`` and not provided.
44
+ """
45
+ if alt is None or _is_symbolic(alt):
46
+ return False
47
+
48
+ if seq is None:
49
+ seq = read.query_sequence
50
+ if seq is None:
51
+ return False
52
+ if min_baseq > 0 and quals is None:
53
+ quals = read.query_qualities
54
+
55
+ if aligned_pairs is None:
56
+ aligned_pairs = read.get_aligned_pairs(matches_only=False)
57
+
58
+ extracted_seq = []
59
+ in_variant_region = False
60
+
61
+ for qpos, rpos in aligned_pairs:
62
+ # Stop collecting once we reach or pass the end of the reference allele span
63
+ if rpos is not None and rpos >= variant_pos + len(ref):
64
+ break
65
+
66
+ # Start collecting when we hit the exact start of the variant
67
+ if rpos == variant_pos:
68
+ in_variant_region = True
69
+
70
+ if in_variant_region:
71
+ # qpos is None for deleted bases (skip), otherwise append the read base
72
+ if qpos is not None:
73
+ if (
74
+ min_baseq > 0 and quals is not None
75
+ and quals[qpos] < min_baseq
76
+ ):
77
+ return False
78
+ extracted_seq.append(seq[qpos])
79
+
80
+ # If the variant region was skipped entirely due to read boundaries
81
+ if not in_variant_region:
82
+ return False
83
+
84
+ return "".join(extracted_seq).upper() == alt.upper()
85
+
nonhuman_screen/bam.py ADDED
@@ -0,0 +1,216 @@
1
+ """BAM-aware entry points: classify reads, and classify per-variant allele support.
2
+
3
+ This module is the convenient, high-level surface most callers want. It pulls
4
+ read sequences out of a BAM/CRAM and hands them to the pysam-free
5
+ :class:`~nonhuman_screen.engine.Kraken2Runner`, so no consumer has to
6
+ re-implement the "which reads support this allele, and how non-human are they"
7
+ machinery.
8
+
9
+ Requires the ``[bam]`` extra (pysam). Import errors here are deliberately
10
+ actionable.
11
+
12
+ Coordinate convention: every ``pos`` is **0-based** (pysam reference
13
+ coordinates), and variant keys are ``"{chrom}:{pos}:{ref}:{alt}"`` to match the
14
+ internal conventions of consuming pipelines.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ try:
20
+ import pysam
21
+ except ImportError as exc: # pragma: no cover - import-guard
22
+ raise ImportError(
23
+ "nonhuman_screen.bam requires pysam. Install with: "
24
+ "pip install 'nonhuman-screen[bam]'"
25
+ ) from exc
26
+
27
+ from nonhuman_screen.alleles import read_supports_alt
28
+ from nonhuman_screen.engine import Kraken2Runner
29
+ from nonhuman_screen.result import TaxonomicFractions, VariantNHF
30
+
31
+
32
+ def _as_variant(v):
33
+ """Normalize a variant into a ``(chrom, pos, ref, alt)`` tuple (pos 0-based)."""
34
+ if isinstance(v, (tuple, list)):
35
+ chrom, pos, ref, alt = v
36
+ return (chrom, int(pos), ref, alt)
37
+ if isinstance(v, dict):
38
+ return (v["chrom"], int(v["pos"]), v["ref"], v["alt"])
39
+ return (v.chrom, int(v.pos), v.ref, v.alt)
40
+
41
+
42
+ def _open(bam_path, ref_fasta):
43
+ return pysam.AlignmentFile(
44
+ bam_path, reference_filename=ref_fasta if ref_fasta else None
45
+ )
46
+
47
+
48
+ def _alt_support_seqs(bam, chrom, pos, ref, alt, *, min_baseq=0):
49
+ """Return ``{read_name: sequence}`` for reads supporting *alt* at 0-based *pos*.
50
+
51
+ Fragments are de-duplicated by read name (first supporting mate wins), so a
52
+ paired-end fragment contributes at most one sequence — consistent with how
53
+ fragment-level fractions are computed downstream.
54
+ """
55
+ out = {}
56
+ span = pos + max(len(ref) if ref else 1, 1)
57
+ for read in bam.fetch(chrom, pos, span):
58
+ name = read.query_name
59
+ if name in out:
60
+ continue
61
+ seq = read.query_sequence
62
+ if not seq:
63
+ continue
64
+ if read_supports_alt(read, pos, ref, alt, min_baseq):
65
+ out[name] = seq
66
+ return out
67
+
68
+
69
+ def reads_supporting_alt(
70
+ bam_path, chrom, pos, ref, alt, *, ref_fasta=None, min_baseq=0
71
+ ):
72
+ """Return the set of read names supporting *alt* at 0-based *pos*.
73
+
74
+ A thin, classification-free helper for callers that only need the
75
+ allele-support read set (e.g. to feed their own logic).
76
+ """
77
+ with _open(bam_path, ref_fasta) as bam:
78
+ return set(_alt_support_seqs(bam, chrom, pos, ref, alt, min_baseq=min_baseq))
79
+
80
+
81
+ def classify_reads_from_bam(
82
+ bam_path,
83
+ db_path,
84
+ *,
85
+ read_names=None,
86
+ loci=None,
87
+ ref_fasta=None,
88
+ confidence=0.0,
89
+ threads=1,
90
+ memory_mapping=False,
91
+ tmpdir=None,
92
+ ):
93
+ """Classify a set of reads from a BAM/CRAM and return a ``ClassificationResult``.
94
+
95
+ Args:
96
+ read_names: Set of read names to classify. When *loci* is not given,
97
+ the whole file is scanned for these names.
98
+ loci: Optional ``{(chrom, pos): {read_name, ...}}`` mapping (0-based
99
+ ``pos``) to restrict fetching to specific loci — far cheaper than a
100
+ whole-file scan. When both are given, *read_names* acts as an
101
+ additional filter on the fetched reads.
102
+ """
103
+ if not read_names and not loci:
104
+ return Kraken2Runner.Result()
105
+
106
+ sequences = {}
107
+ with _open(bam_path, ref_fasta) as bam:
108
+ if loci:
109
+ name_filter = set(read_names) if read_names else None
110
+ for (chrom, pos), names in sorted(loci.items()):
111
+ targets = set(names)
112
+ if name_filter is not None:
113
+ targets &= name_filter
114
+ if not targets:
115
+ continue
116
+ for read in bam.fetch(chrom, pos, pos + 1):
117
+ n = read.query_name
118
+ if n in targets and read.query_sequence and n not in sequences:
119
+ sequences[n] = read.query_sequence
120
+ else:
121
+ wanted = set(read_names)
122
+ for read in bam.fetch(until_eof=True):
123
+ n = read.query_name
124
+ if n in wanted and read.query_sequence and n not in sequences:
125
+ sequences[n] = read.query_sequence
126
+
127
+ if not sequences:
128
+ return Kraken2Runner.Result()
129
+
130
+ runner = Kraken2Runner(
131
+ db_path, confidence=confidence, threads=threads,
132
+ memory_mapping=memory_mapping,
133
+ )
134
+ return runner.classify_sequences(sequences, tmpdir=tmpdir)
135
+
136
+
137
+ def classify_variants_alt_reads(
138
+ bam_path,
139
+ db_path,
140
+ variants,
141
+ *,
142
+ ref_fasta=None,
143
+ min_baseq=0,
144
+ confidence=0.0,
145
+ threads=1,
146
+ memory_mapping=False,
147
+ tmpdir=None,
148
+ ):
149
+ """Compute the allele-based non-human fraction for many variants at once.
150
+
151
+ For each variant, the reads supporting its ALT allele are gathered; the
152
+ union of those reads is classified with **a single kraken2 invocation**
153
+ (kraken2's database load dominates runtime, so batching matters), then the
154
+ result is split back per variant by read-name intersection.
155
+
156
+ Args:
157
+ variants: Iterable of variants, each a ``(chrom, pos, ref, alt)`` tuple
158
+ (0-based ``pos``), a mapping with those keys, or any object with
159
+ ``chrom``/``pos``/``ref``/``alt`` attributes.
160
+
161
+ Returns:
162
+ A list of :class:`~nonhuman_screen.result.VariantNHF`, in input order.
163
+ """
164
+ normalized = [_as_variant(v) for v in variants]
165
+
166
+ sequences = {}
167
+ per_variant_names = []
168
+ with _open(bam_path, ref_fasta) as bam:
169
+ for chrom, pos, ref, alt in normalized:
170
+ vs = _alt_support_seqs(bam, chrom, pos, ref, alt, min_baseq=min_baseq)
171
+ per_variant_names.append(set(vs))
172
+ for name, seq in vs.items():
173
+ sequences.setdefault(name, seq)
174
+
175
+ runner = Kraken2Runner(
176
+ db_path, confidence=confidence, threads=threads,
177
+ memory_mapping=memory_mapping,
178
+ )
179
+ result = runner.classify_sequences(sequences, tmpdir=tmpdir) if sequences \
180
+ else Kraken2Runner.Result()
181
+
182
+ out = []
183
+ for (chrom, pos, ref, alt), names in zip(normalized, per_variant_names):
184
+ out.append(
185
+ VariantNHF(
186
+ chrom=chrom, pos=pos, ref=ref, alt=alt,
187
+ supporting_read_names=frozenset(names),
188
+ fractions=TaxonomicFractions.over_reads(result, names),
189
+ )
190
+ )
191
+ return out
192
+
193
+
194
+ def classify_variant_alt_reads(
195
+ bam_path, db_path, chrom, pos, ref, alt, *,
196
+ ref_fasta=None, min_baseq=0, confidence=0.0, threads=1,
197
+ memory_mapping=False, tmpdir=None,
198
+ ):
199
+ """Allele-based non-human fraction for a single variant.
200
+
201
+ Convenience wrapper over :func:`classify_variants_alt_reads`.
202
+
203
+ Example::
204
+
205
+ vnhf = classify_variant_alt_reads(
206
+ "sample.bam", "kraken2_db", "chr1", 12345, "A", "T",
207
+ ref_fasta="ref.fa",
208
+ )
209
+ vnhf.nonhuman_fraction # 0.0-1.0 over ALT-supporting reads
210
+ vnhf.fractions.bacterial # per-domain breakdown
211
+ """
212
+ return classify_variants_alt_reads(
213
+ bam_path, db_path, [(chrom, pos, ref, alt)],
214
+ ref_fasta=ref_fasta, min_baseq=min_baseq, confidence=confidence,
215
+ threads=threads, memory_mapping=memory_mapping, tmpdir=tmpdir,
216
+ )[0]
nonhuman_screen/cli.py ADDED
@@ -0,0 +1,187 @@
1
+ """Command-line interface: ``nonhuman-screen``.
2
+
3
+ Subcommands
4
+ -----------
5
+ ``classify`` — screen reads from a BAM/CRAM for non-human content. With
6
+ ``--variants`` it computes the allele-based non-human fraction for every ALT
7
+ allele (the reads supporting each ALT are classified); otherwise it classifies
8
+ all mapped reads and reports a single batch summary.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import logging
16
+ import shutil
17
+ import sys
18
+
19
+ from nonhuman_screen import __version__
20
+ from nonhuman_screen.alleles import _is_symbolic
21
+
22
+
23
+ def _build_parser():
24
+ parser = argparse.ArgumentParser(
25
+ prog="nonhuman-screen",
26
+ description="Classify sequencing reads by non-human taxonomic content.",
27
+ )
28
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
29
+ sub = parser.add_subparsers(dest="command", required=True)
30
+
31
+ c = sub.add_parser(
32
+ "classify",
33
+ help="Screen reads from a BAM/CRAM for non-human content.",
34
+ )
35
+ c.add_argument("--bam", required=True, help="Input BAM/CRAM.")
36
+ c.add_argument(
37
+ "--kraken2-db", required=True,
38
+ help="Path to a kraken2 database directory.",
39
+ )
40
+ c.add_argument(
41
+ "--ref-fasta", default=None,
42
+ help="Reference FASTA (required for CRAM).",
43
+ )
44
+ c.add_argument(
45
+ "--variants", default=None,
46
+ help="VCF/BCF of variants. When given, compute the allele-based "
47
+ "non-human fraction for every ALT allele instead of a whole-BAM "
48
+ "summary.",
49
+ )
50
+ c.add_argument(
51
+ "--min-baseq", type=int, default=0,
52
+ help="Minimum base quality for a read to count as ALT support "
53
+ "(default: 0).",
54
+ )
55
+ c.add_argument(
56
+ "--out-prefix", default=None,
57
+ help="Write outputs to <prefix>.variant_nhf.tsv / <prefix>.summary.json. "
58
+ "When omitted, results are printed to stdout.",
59
+ )
60
+ c.add_argument("--threads", type=int, default=1, help="kraken2 threads.")
61
+ c.add_argument(
62
+ "--confidence", type=float, default=0.0,
63
+ help="kraken2 confidence threshold 0.0-1.0 (default: 0.0).",
64
+ )
65
+ c.add_argument(
66
+ "--memory-mapping", action="store_true", default=False,
67
+ help="Pass --memory-mapping to kraken2 to reduce RAM.",
68
+ )
69
+ return parser
70
+
71
+
72
+ def _read_vcf_variants(path):
73
+ """Yield ``(chrom, pos0, ref, alt)`` for every concrete ALT allele."""
74
+ import pysam
75
+
76
+ with pysam.VariantFile(path) as vcf:
77
+ for rec in vcf:
78
+ if rec.alts is None:
79
+ continue
80
+ for alt in rec.alts:
81
+ # Skip symbolic (<DEL>), breakend (N[chr2:321[), and the
82
+ # spanning-deletion (*) alleles — none has a literal sequence to
83
+ # match reads against.
84
+ if _is_symbolic(alt):
85
+ continue
86
+ yield (rec.chrom, rec.start, rec.ref, alt)
87
+
88
+
89
+ _TSV_COLUMNS = (
90
+ "variant_key", "supporting_reads", "nonhuman_fraction",
91
+ "bacterial", "archaeal", "fungal", "protist", "viral", "univec_core",
92
+ "human_lineage", "unclassified",
93
+ )
94
+
95
+
96
+ def _classify_variants(args):
97
+ from nonhuman_screen.bam import classify_variants_alt_reads
98
+
99
+ variants = list(_read_vcf_variants(args.variants))
100
+ logging.info("Classifying ALT-supporting reads for %d alleles", len(variants))
101
+ results = classify_variants_alt_reads(
102
+ args.bam, args.kraken2_db, variants,
103
+ ref_fasta=args.ref_fasta, min_baseq=args.min_baseq,
104
+ confidence=args.confidence, threads=args.threads,
105
+ memory_mapping=args.memory_mapping,
106
+ )
107
+
108
+ lines = ["\t".join(_TSV_COLUMNS)]
109
+ for v in results:
110
+ f = v.fractions
111
+ lines.append("\t".join(str(x) for x in (
112
+ v.variant_key, v.supporting_reads, v.nonhuman_fraction,
113
+ f.bacterial, f.archaeal, f.fungal, f.protist, f.viral,
114
+ f.univec_core, f.human_lineage, f.unclassified,
115
+ )))
116
+ tsv = "\n".join(lines) + "\n"
117
+
118
+ if args.out_prefix:
119
+ tsv_path = f"{args.out_prefix}.variant_nhf.tsv"
120
+ with open(tsv_path, "w") as fh:
121
+ fh.write(tsv)
122
+ with open(f"{args.out_prefix}.summary.json", "w") as fh:
123
+ json.dump([v.to_dict() for v in results], fh, indent=2)
124
+ logging.info("Wrote %s", tsv_path)
125
+ else:
126
+ sys.stdout.write(tsv)
127
+ return 0
128
+
129
+
130
+ def _classify_all(args):
131
+ import pysam
132
+
133
+ from nonhuman_screen.engine import Kraken2Runner
134
+
135
+ sequences = {}
136
+ with pysam.AlignmentFile(
137
+ args.bam, reference_filename=args.ref_fasta or None
138
+ ) as bam:
139
+ for read in bam.fetch(until_eof=True):
140
+ n = read.query_name
141
+ if read.query_sequence and n not in sequences:
142
+ sequences[n] = read.query_sequence
143
+
144
+ runner = Kraken2Runner(
145
+ args.kraken2_db, confidence=args.confidence, threads=args.threads,
146
+ memory_mapping=args.memory_mapping,
147
+ )
148
+ result = runner.classify_sequences(sequences)
149
+ summary = {
150
+ "reads": result.total,
151
+ "classified": result.classified,
152
+ "taxonomy_available": result.taxonomy_available,
153
+ "nonhuman_fraction": result.nonhuman_fraction,
154
+ "fractions": result.fractions().to_dict(),
155
+ }
156
+ out = json.dumps(summary, indent=2)
157
+ if args.out_prefix:
158
+ with open(f"{args.out_prefix}.summary.json", "w") as fh:
159
+ fh.write(out + "\n")
160
+ else:
161
+ sys.stdout.write(out + "\n")
162
+ logging.info("%s", result.summary())
163
+ return 0
164
+
165
+
166
+ def main(argv=None):
167
+ logging.basicConfig(
168
+ level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s",
169
+ )
170
+ args = _build_parser().parse_args(argv)
171
+
172
+ if args.command == "classify":
173
+ if shutil.which("kraken2") is None:
174
+ sys.stderr.write(
175
+ "error: kraken2 not found on PATH. Install kraken2 "
176
+ "(see docs/database.md) and try again.\n"
177
+ )
178
+ return 2
179
+ if args.variants:
180
+ return _classify_variants(args)
181
+ return _classify_all(args)
182
+
183
+ return 1 # pragma: no cover - argparse requires a subcommand
184
+
185
+
186
+ if __name__ == "__main__": # pragma: no cover
187
+ raise SystemExit(main())