RNA editing discovery is a tiny-signal, huge-noise problem. A mismatch between RNA and the reference genome can be an ADAR edit, an APOBEC-like edit, a genomic SNP, a mapping artifact, a splice-junction alignment problem, a repeat-induced mirage, or a low-quality sequencing burp wearing a lab coat. The best filtering strategy is not just a sieve. It is a chain of cross-examinations.
This post explains the motivation behind each filter in a REDItools-style RNA editing workflow, shows how to implement the filters, summarizes one example run, and ends with a fully embedded, well-commented Python implementation. The implementation is included directly in the post so readers can inspect the logic without hunting for a separate script.
Example run at a glance
The sample summary used here contained 95,009,169 candidate rows. After filtering, 897 sites passed all filters, a pass rate of 0.000944%. The pipeline filtered 95,008,272 rows, or 99.999056% of the input.
The two largest attrition points were not glamorous but they were decisive:
66,320,554 rows had no observed non-reference RNA base after parsing.
28,170,388 rows had a mismatch between the REDItools reference base and the indexed FASTA reference.
Together, those two categories explain almost the entire candidate collapse. That is not necessarily bad. It means the pipeline is catching broad table-level or reference-level issues before expensive BAM pileups begin.
Filter-by-filter motivation and implementation
1. Parse defensively
REDItools tables can be tab-delimited, copied into whitespace-rendered formats, gzipped, or missing some genomic DNA fields. A fragile parser can create false edits before biology even enters the room. The implementation below validates numeric fields, parses `[A,C,G,T]` base-count vectors, records missing DNA evidence, and emits parse errors as explicit failure reasons.
Implementation principle: never silently coerce malformed rows into plausible-looking sites.
2. Normalize contig names
Reference FASTA files, BAMs, GTFs, and repeat annotations often disagree about names such as `chr1` versus `1`. The script builds contig alias maps so sites do not fail simply because one file speaks UCSC and another speaks Ensembl.
Implementation principle: resolve aliases at every interface, then report truly unresolved contigs.
3. Validate the reference base
For each candidate, the script fetches the FASTA base at the candidate coordinate and compares it with the REDItools reference base. If they differ, the candidate is rejected unless the user deliberately allows reference mismatches.
Why it matters: if the reference base is wrong, every substitution label downstream can be wrong. In the sample run, the complement-like mismatch classes A/T, T/A, G/C, and C/G were abundant, which is a useful warning flag for reference-build or strand-orientation auditing.
4. Require a real RNA alternate allele
The script chooses the most abundant non-reference RNA base as the candidate alternate allele. Editing level is calculated as:
```text
RNA editing level = RNA alternate-base count / total RNA A+C+G+T count
```
Rows with no non-reference RNA base are filtered as `no_rna_substitution`.
Implementation principle: use base counts, not only preformatted frequency strings. Keep secondary alternate alleles for QC, but classify the candidate using the major non-reference allele.
5. Enforce minimum RNA evidence
The example run required at least 10 RNA reads, at least 3 RNA alternate reads, at least 1% editing frequency, and RNA mean base quality of at least 25.
Why it matters: low-depth and low-quality sites are where sequencing errors put on tiny fake mustaches. Use both alternate-read count and editing fraction; either alone is incomplete.
6. Use matched DNA to reject genomic variants
If the same alternate allele appears strongly in DNA, the site is more likely to be a SNP or genomic variant than RNA editing. The example run rejected candidates with DNA alternate frequency above 5% or DNA alternate count above 3.
Implementation principle: matched DNA is the strongest ordinary shield against calling genomic variation as RNA editing. If REDItools DNA fields are absent, count DNA bases directly from the DNA BAM with quality thresholds.
7. Filter repeat-overlapping sites carefully
Repeats create alignment ambiguity. Short reads from similar repeat copies can pile up at the wrong locus and create false mismatches. However, repeats are also biologically important, especially for ADAR editing in primate Alu elements.
Implementation principle: for conservative discovery, filter repeats. For ADAR biology, stratify repeats instead of discarding them wholesale: unique-region sites, repeat-associated known sites, and repeat-associated novel candidates.
8. Filter homopolymers
Homopolymer runs are error-prone sequence contexts. The implementation measures the same-base run around the candidate directly from the reference FASTA.
Implementation principle: keep the threshold configurable and platform-aware. A long-read nanopore workflow and a short-read Illumina workflow do not have identical context-error profiles.
9. Remove splice-site-proximal candidates
Splice junctions are alignment origami. Split reads, clipped bases, and imperfect junction annotation can produce local mismatches. The implementation builds splice-site windows from transcript exon boundaries.
Implementation principle: choose the flank based on aligner quality and annotation confidence. The example used a 2 bp flank.
10. Require gene-model overlap
The implementation filters sites that do not overlap gene models. This keeps the final set focused on transcribed regions.
Implementation principle: make the feature choice explicit. Gene spans, exons, transcripts, UTRs, CDS, and introns answer different biological questions.
11. Check mapping quality in RNA and DNA BAMs
The script computes pileup depth, mean MAPQ, and the fraction of reads meeting the MAPQ threshold. Reads that are unmapped, secondary, supplementary, duplicate, or QC-fail are excluded by default.
Implementation principle: calculate mapping quality at the candidate position, not only from global alignment summaries.
12. Check nearby indels
Nearby insertions or deletions can shift alignments and produce false mismatches. The implementation scans a window around the candidate in RNA and DNA BAMs and records the worst indel burden observed.
Implementation principle: filter sites with too many indel-supporting reads or excessive indel fraction in the local window.
13. Measure local mismatch density
A candidate surrounded by many unrelated mismatches is suspicious. The script scans flanking bases, excludes the candidate itself, and calculates local mismatch density. It can ignore neighboring mismatches that match the same transcript-oriented edit type, which avoids punishing genuine editing clusters.
Implementation principle: do not treat all clusters as artifacts. ADAR editing often clusters, so same-event neighbors deserve different treatment from random mismatch confetti.
14. Classify in transcript orientation
A genomic `T>C` on a minus-strand gene corresponds to transcript `A>G`. The implementation uses overlapping gene strand to report transcript-oriented substitutions and assigns canonical `A>G` to ADAR and canonical `C>T` to APOBEC. Ambiguous strand cases are preserved rather than forced.
Implementation principle: always report both genomic and transcript-oriented substitutions.
What the result figures show
The pipeline generates two main figures.
The event-class count figure shows the number of retained sites by transcript-oriented substitution type. It answers: what survived the filter gauntlet? In this sample, the retained table contained the following event classes:
Event type Retained sites
`T>C` 436
`G>A` 88
`A>G` 83
`C>T` 53
`T>A` 34
`T>G` 30
`A>T` 29
`C>G` 28
`C>A` 25
`G>C` 24
`G>T` 23
`A>C` 22
`ambiguous_T>C` 9
`ambiguous_A>G` 6
`ambiguous_G>T` 3
`ambiguous_A>T` 1
`ambiguous_C>G` 1
`ambiguous_G>A` 1
`ambiguous_G>C` 1
The editing-level histogram figure shows the distribution of RNA alternate fractions for each retained event type. It answers: how strong is the RNA evidence among survivors? A suspicious event class with unusual editing-level shape is a prompt to inspect mapping context, DNA evidence, strand assignment, and repeat overlap.
Detailed filter summary from the sample run
Filter reason Count % of input
`no_rna_substitution` 66,320,554 69.804372%
`reference_mismatch_fasta:A_reditools:T` 7,726,490 8.132362%
`reference_mismatch_fasta:T_reditools:A` 7,722,575 8.128242%
`reference_mismatch_fasta:G_reditools:C` 6,406,203 6.742721%
`reference_mismatch_fasta:C_reditools:G` 6,315,120 6.646853%
`low_rna_alt_count` 451,033 0.474726%
`low_editing_frequency` 170,622 0.179585%
`low_rna_coverage` 130,207 0.137047%
`high_dna_alt_count_possible_snv` 87,270 0.091854%
`high_dna_alt_frequency_possible_snv` 79,901 0.084098%
`low_rna_mean_base_quality` 57,599 0.060625%
`no_gene_model_overlap` 42,551 0.044786%
`repeat_overlap` 27,414 0.028854%
`splice_site_proximity` 11,929 0.012556%
`low_dna_coverage` 2,172 0.002286%
`rna_too_many_indel_reads` 1,566 0.001648%
`rna_high_column_mismatch_fraction` 545 0.000574%
`rna_high_indel_fraction` 464 0.000488%
`dna_too_many_indel_reads` 366 0.000385%
`rna_no_usable_bam_reads` 334 0.000352%
`rna_high_window_mismatch_fraction` 240 0.000253%
`dna_low_good_mapq_fraction` 75 0.000079%
`dna_low_mean_mapq` 67 0.000071%
`low_dna_mean_base_quality` 7 0.000007%
`dna_high_indel_fraction` 5 0.000005%
These counts are not a perfectly exclusive waterfall. A site can receive multiple filter reasons within a filtering phase, so the filter-reason total can exceed the number of failed rows. That is useful: it tells us which kinds of badness co-occur.
Future improvements and recent developments
Move from hard thresholds to calibrated scores
Hard filters are transparent, but they create cliffs. A candidate with DNA alternate frequency 0.049 passes while 0.051 fails. A future version should keep the interpretable filters but also emit a calibrated probability or confidence tier based on RNA support, DNA evidence, mapping quality, local sequence context, repeat annotation, splice distance, and replicate concordance.
Add known-editing annotations
REDIportal V3.0 reports 15,680,833 sites from GTEx RNA-seq data and TCGA RNA-seq data, plus mouse nascent RNA-seq sites, and includes gene-view and dsRNA modules. Known-site annotation should not rescue a poor-quality site automatically, but it can help prioritize candidates for validation.
Use ensemble and replicate-aware callers
REDItools2/HPC-REDItools improves large-scale RNA editing detection through parallelization and faster interval processing. JACUSA2 can process BAM data from platforms including Illumina and ONT Nanopore. A strong future workflow could run multiple callers, then label sites as consensus, caller-specific, or discordant.
Support direct RNA sequencing and modification-aware basecalling
Nanopore direct RNA sequencing is becoming more relevant for RNA modification analysis because it observes native RNA molecules without cDNA conversion. Recent reports note that modification-aware basecallers now include inosine among supported modifications, but independent validation and compatibility with existing tools remain important. Synthetic benchmarking datasets for RNA modified bases, including inosine, are also becoming available.
Treat repeats as biology, not just noise
A mature pipeline should stratify repeat contexts. For example: unique-region sites, known repeat-associated edits, novel repeat-associated candidates, and low-mappability repeat artifacts. This avoids throwing away real ADAR biology with the artifact bathwater.
Add validation-first outputs
The output should include a validation priority score: recoding potential, known-site status, conservation, disease relevance, differential editing, replicate support, and enzyme-signature compatibility. The best pipeline does not merely say “passed.” It says “this one is worth chasing.”
Sources for the new-developments section
REDIportal V3.0 homepage and Nucleic Acids Research update.
REDItools2 GitHub documentation and HPC-REDItools publication.
JACUSA2 Genome Biology publication.
2026 review/assessment of nanopore direct RNA sequencing modification detection.
Oxford Nanopore/EPI2ME RNA modified-base benchmarking dataset.
---
Embedded, well-commented Python implementation
The script below is embedded so the blog post is self-contained. Comments have been added around the major design decisions, filter stages, and output logic.
<details>
<summary>Click to expand the annotated RNA editing filtering script</summary>
```python
#!/usr/bin/env python3
"""
Filter and classify candidate RNA editing sites from REDItools output.
Inputs:
* REDItools table
* RepeatMasker output, BED, or GFF-like repeat intervals
* Genome FASTA, indexed with samtools faidx
* RNA BAM, indexed
* DNA BAM, indexed
* GTF annotation
Main filters:
* Repeat overlap
* Homopolymer runs in the reference FASTA
* Low RNA or DNA coverage and base quality
* Possible genomic variants seen in the DNA counts
* Poor mapping quality in RNA or DNA BAM pileups
* Splice-site proximity from GTF exons
* Indels in flanking regions from RNA or DNA BAM pileups
* Absence from annotated gene models
Classification:
* Uses the strand of overlapping gene models.
* Reports the exact transcript-oriented substitution type, for example A>G, C>T, G>A.
* Colors all possible ADAR-compatible changes (A>G / T>C) in blue,
all APOBEC-compatible changes (C>T / G>A) in orange,
and all remaining change types in grey.
Example:
python filter_classify_rna_edits.py \
--reditools sample.redi.tsv \
--repeatmasker genome.fa.out \
--genome genome.fa \
--rna-bam rna.sorted.bam \
--dna-bam dna.sorted.bam \
--gtf annotation.gtf \
--out-prefix results/sample
Dependencies:
Python 3.6+
pip install pysam matplotlib
"""
# Compatibility: tested to parse with Python 3.6 grammar; no future annotations import.
import argparse
import csv
import gzip
import logging
import math
import os
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Dict, Generator, Iterable, List, Optional, Sequence, Tuple
try:
import pysam
except ImportError as exc: # pragma: no cover
raise SystemExit(
"Missing dependency: pysam. Install it with: pip install pysam"
) from exc
# -----------------------------------------------------------------------------
# Core nucleotide conventions
# -----------------------------------------------------------------------------
# Everything downstream assumes a fixed A/C/G/T order. This makes REDItools
# base-count vectors deterministic and prevents accidental allele swaps.
BASES = ("A", "C", "G", "T")
BASE_TO_I = {b: i for i, b in enumerate(BASES)}
COMPLEMENT = str.maketrans("ACGTNacgtn", "TGCANtgcan")
TRANSCRIPT_SUBSTITUTION_ORDER = [
"A>G", # canonical ADAR signal
"C>T", # canonical APOBEC signal
"A>C",
"A>T",
"C>A",
"C>G",
"G>A",
"G>C",
"G>T",
"T>A",
"T>C",
"T>G",
]
REDITOOLS_COLUMNS = [
"Region",
"Position",
"Reference",
"Strand",
"Coverage-q30",
"MeanQ",
"BaseCount[A,C,G,T]",
"AllSubs",
"Frequency",
"gCoverage-q30",
"gMeanQ",
"gBaseCount[A,C,G,T]",
"gAllSubs",
"gFrequency",
]
NUMBER_TOKEN = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+|nan|NaN|NA|-|\.)"
FREQ_TOKEN = r"(?:-|\.|[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[,;][+-]?(?:\d+(?:\.\d*)?|\.\d+))*)"
COUNT_TOKEN = r"(?:-|\[[^\]]+\])"
REDITOOLS_SPACE_RE = re.compile(
r"^(\S+)\s+" # Region
r"(\d+)\s+" # Position
r"([ACGTNacgtn])\s+" # Reference
r"(\S+)\s+" # Strand
r"(\d+|-)\s+" # Coverage-q30
r"(" + NUMBER_TOKEN + r")\s+" # MeanQ
r"(" + COUNT_TOKEN + r")\s+" # BaseCount
r"(.+?)\s+" # AllSubs
r"(" + FREQ_TOKEN + r")\s+" # Frequency
r"(\d+|-)\s+" # gCoverage-q30
r"(" + NUMBER_TOKEN + r")\s+" # gMeanQ
r"(" + COUNT_TOKEN + r")\s+" # gBaseCount
r"(.+?)\s+" # gAllSubs
r"(" + FREQ_TOKEN + r")\s*$" # gFrequency
)
class EditPipelineError(RuntimeError):
"""Raised for user-fixable pipeline errors."""
# -----------------------------------------------------------------------------
# Lightweight interval indexing
# -----------------------------------------------------------------------------
# Repeat intervals, gene models, and splice windows are all represented as
# genomic intervals. The small binned index below supports fast point queries
# without requiring an external interval-tree dependency.
class Interval:
def __init__(self, start, end, data):
self.start = start
self.end = end
self.data = data
class BinnedIntervalIndex:
"""A small point-query interval index based on fixed-size genomic bins."""
def __init__(self, bin_size: int = 10000) -> None:
if bin_size <= 0:
raise ValueError("bin_size must be positive")
self.bin_size = bin_size
self._bins: Dict[str, Dict[int, List[Interval]]] = defaultdict(lambda: defaultdict(list))
self.interval_count = 0
def add(self, chrom: str, start: int, end: int, data: Optional[dict] = None) -> None:
if start < 0:
start = 0
if end <= start:
return
interval = Interval(start=start, end=end, data=data or {})
first_bin = start // self.bin_size
last_bin = (end - 1) // self.bin_size
for bin_id in range(first_bin, last_bin + 1):
self._bins[chrom][bin_id].append(interval)
self.interval_count += 1
def query_point(self, chrom: str, pos0: int) -> List[Interval]:
if pos0 < 0:
return []
bin_id = pos0 // self.bin_size
hits = []
for iv in self._bins.get(chrom, {}).get(bin_id, []):
if iv.start <= pos0 < iv.end:
hits.append(iv)
return hits
def __len__(self) -> int:
return self.interval_count
# -----------------------------------------------------------------------------
# Contig-name normalization
# -----------------------------------------------------------------------------
# Genomes, BAMs, GTFs, and repeat files often disagree about chromosome names
# such as 'chr1' versus '1'. The resolver maps common aliases so biologically
# identical contigs are not treated as missing.
class ContigResolver:
"""Resolve common contig naming differences such as chr1 versus 1."""
def __init__(self, names: Sequence[str]) -> None:
self.names = set(names)
self.alias: Dict[str, str] = {}
for name in names:
self.alias[name] = name
if name.startswith("chr"):
self.alias.setdefault(name[3:], name)
else:
self.alias.setdefault("chr" + name, name)
if "MT" in self.names:
self.alias.setdefault("M", "MT")
self.alias.setdefault("chrM", "MT")
if "M" in self.names:
self.alias.setdefault("MT", "M")
self.alias.setdefault("chrM", "M")
if "chrM" in self.names:
self.alias.setdefault("M", "chrM")
self.alias.setdefault("MT", "chrM")
def resolve(self, chrom: str) -> Optional[str]:
return self.alias.get(chrom)
class BamContext:
def __init__(self, label, bam, resolver):
self.label = label
self.bam = bam
self.resolver = resolver
class GtfData:
def __init__(self, genes, splice_sites, loaded_gene_intervals, loaded_splice_intervals):
self.genes = genes
self.splice_sites = splice_sites
self.loaded_gene_intervals = loaded_gene_intervals
self.loaded_splice_intervals = loaded_splice_intervals
class Evaluation:
def __init__(self, passed, reasons, record, event_class=None, editing_level=None, event_group=None):
self.passed = passed
self.reasons = reasons
self.record = record
self.event_class = event_class
self.editing_level = editing_level
self.event_group = event_group
# -----------------------------------------------------------------------------
# Robust input parsing helpers
# -----------------------------------------------------------------------------
# The pipeline accepts plain text, gzipped text, and stdin. REDItools files are
# sometimes exported or pasted in slightly different layouts, so parsing is
# deliberately defensive.
def open_text(path: str):
"""Open plain text, gzip text, or stdin."""
if path == "-":
return sys.stdin
if str(path).endswith(".gz"):
return gzip.open(path, "rt", encoding="utf-8", errors="replace")
return open(path, "rt", encoding="utf-8", errors="replace")
def is_int_string(value: str) -> bool:
try:
int(value)
return True
except ValueError:
return False
def is_number_string(value: str) -> bool:
try:
float(value)
return True
except ValueError:
return False
MISSING_FIELD_VALUES = {"", "-", ".", "NA", "N/A", "nan", "NaN"}
def is_missing_field(value: str) -> bool:
return value is None or str(value).strip() in MISSING_FIELD_VALUES
def parse_int(value: str, field: str, line_no: int, missing_as=None) -> int:
if is_missing_field(value):
if missing_as is not None:
return missing_as
raise ValueError(f"line {line_no}: missing integer field {field}={value!r}")
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"line {line_no}: could not parse integer field {field}={value!r}") from exc
def parse_float(value: str, field: str, line_no: int) -> float:
value = str(value).strip()
if is_missing_field(value):
return math.nan
try:
return float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"line {line_no}: could not parse float field {field}={value!r}") from exc
def parse_base_count(value: str, line_no: int, missing_as_zero: bool = False) -> List[int]:
value = str(value).strip()
if is_missing_field(value):
if missing_as_zero:
return [0, 0, 0, 0]
raise ValueError(f"line {line_no}: missing base-count field {value!r}")
if not (value.startswith("[") and value.endswith("]")):
raise ValueError(f"line {line_no}: malformed base-count field {value!r}")
parts = [x.strip() for x in value[1:-1].split(",")]
if len(parts) != 4:
raise ValueError(f"line {line_no}: expected 4 base counts but found {len(parts)}")
try:
counts = [int(x) for x in parts]
except ValueError as exc:
raise ValueError(f"line {line_no}: base counts must be integers: {value!r}") from exc
if any(x < 0 for x in counts):
raise ValueError(f"line {line_no}: base counts cannot be negative: {value!r}")
return counts
def canonical_reditools_name(name):
"""Normalize REDItools column names while keeping the public output names stable."""
key = str(name).strip().lstrip("#").strip().lower()
key = re.sub(r"\s+", "", key)
key = key.replace("_", "-")
aliases = {
"region": "Region",
"chrom": "Region",
"chromosome": "Region",
"seqid": "Region",
"position": "Position",
"pos": "Position",
"reference": "Reference",
"ref": "Reference",
"strand": "Strand",
"coverage-q30": "Coverage-q30",
"coverage": "Coverage-q30",
"meanq": "MeanQ",
"basecount[a,c,g,t]": "BaseCount[A,C,G,T]",
"basecount": "BaseCount[A,C,G,T]",
"allsubs": "AllSubs",
"frequency": "Frequency",
"gcov": "gCoverage-q30",
"gcoverage": "gCoverage-q30",
"gcoverage-q30": "gCoverage-q30",
"gmeanq": "gMeanQ",
"gbasecount[a,c,g,t]": "gBaseCount[A,C,G,T]",
"gbasecount": "gBaseCount[A,C,G,T]",
"gallsubs": "gAllSubs",
"gfrequency": "gFrequency",
}
return aliases.get(key, str(name).strip())
def looks_like_reditools_header(line):
low = line.lower()
return ("region" in low and "position" in low and "reference" in low and
("basecount" in low or "base count" in low))
def split_delimited_reditools_line(line, delimiter):
stripped = line.rstrip("\n\r")
if delimiter == "\t":
return [x.strip() for x in stripped.split("\t")]
if delimiter == ",":
reader = csv.reader([stripped])
return [x.strip() for x in next(reader)]
return None
def split_reditools_line(line, delimiter=None):
"""
Split a REDItools row.
REDItools tables are usually tab-delimited, but rendered examples are often
pasted as whitespace-delimited text. BaseCount fields contain spaces inside
brackets, so a naive line.split() corrupts the columns. This function first
respects an explicit delimiter from the header, then falls back to a regex
that treats bracketed base-count vectors as single fields.
"""
stripped = line.rstrip("\n\r")
if delimiter in ("\t", ","):
parts = split_delimited_reditools_line(stripped, delimiter)
if parts and len(parts) >= 5:
return parts
if "\t" in stripped:
parts = [x.strip() for x in stripped.split("\t")]
if len(parts) >= 5:
return parts
if looks_like_reditools_header(stripped):
parts = re.split(r"\s+", stripped.strip())
if len(parts) >= 5:
return parts
match = REDITOOLS_SPACE_RE.match(stripped.strip())
if match:
return list(match.groups())
return None
def build_reditools_header_map(header_parts):
"""Return canonical column positions for a REDItools header line."""
mapping = {}
for i, name in enumerate(header_parts):
canonical = canonical_reditools_name(name)
if canonical in REDITOOLS_COLUMNS and canonical not in mapping:
mapping[canonical] = i
required = ["Region", "Position", "Reference", "Coverage-q30", "MeanQ", "BaseCount[A,C,G,T]"]
missing = [c for c in required if c not in mapping]
if missing:
raise EditPipelineError(
"REDItools header is missing required column(s): " + ", ".join(missing)
)
return mapping
def row_from_parts(parts, header_map=None):
"""Build a canonical REDItools row dict from split fields."""
if header_map is not None:
row = {}
for col in REDITOOLS_COLUMNS:
idx = header_map.get(col)
row[col] = parts[idx].strip() if idx is not None and idx < len(parts) else "-"
return row
# No usable header was seen. Fall back to the classic 14-column REDItools order.
if len(parts) < len(REDITOOLS_COLUMNS):
return None
return dict(zip(REDITOOLS_COLUMNS, [x.strip() for x in parts[:len(REDITOOLS_COLUMNS)]]))
def validate_reditools_row_fields(row, line_no):
"""Parse numeric/count fields in place and annotate missing DNA evidence."""
row["Position"] = parse_int(row["Position"], "Position", line_no)
row["Coverage-q30"] = parse_int(row["Coverage-q30"], "Coverage-q30", line_no, missing_as=0)
row["MeanQ"] = parse_float(row["MeanQ"], "MeanQ", line_no)
row["BaseCount[A,C,G,T]"] = parse_base_count(row["BaseCount[A,C,G,T]"], line_no)
# REDItools sometimes writes '-' for genomic DNA fields. Treat those as missing
# instead of malformed: coverage/counts become zero and quality becomes NA.
row["_missing_gCoverage_q30"] = is_missing_field(row["gCoverage-q30"])
row["_missing_gMeanQ"] = is_missing_field(row["gMeanQ"])
row["_missing_gBaseCount"] = is_missing_field(row["gBaseCount[A,C,G,T]"])
row["gCoverage-q30"] = parse_int(row["gCoverage-q30"], "gCoverage-q30", line_no, missing_as=0)
row["gMeanQ"] = parse_float(row["gMeanQ"], "gMeanQ", line_no)
row["gBaseCount[A,C,G,T]"] = parse_base_count(row["gBaseCount[A,C,G,T]"], line_no, missing_as_zero=True)
row["Reference"] = str(row["Reference"]).strip().upper()
row["Region"] = str(row["Region"]).strip()
row["Strand"] = str(row.get("Strand", ".")).strip()
row["AllSubs"] = str(row.get("AllSubs", "-")).strip()
row["Frequency"] = str(row.get("Frequency", "-")).strip()
row["gAllSubs"] = str(row.get("gAllSubs", "-")).strip()
row["gFrequency"] = str(row.get("gFrequency", "-")).strip()
return row
# -----------------------------------------------------------------------------
# REDItools parser
# -----------------------------------------------------------------------------
# This function yields one parsed row at a time. It validates numeric fields,
# converts base-count strings into integer vectors, and records missing DNA
# evidence explicitly instead of pretending it is high-quality zero evidence.
def parse_reditools(path):
"""
Yield parsed REDItools rows as dictionaries.
This parser is header-aware. The attached example uses the canonical order:
Region, Position, Reference, Strand, Coverage-q30, MeanQ,
BaseCount[A,C,G,T], AllSubs, Frequency, gCoverage-q30, gMeanQ,
gBaseCount[A,C,G,T], gAllSubs, gFrequency.
"""
header_map = None
delimiter = None
warned_no_header = False
with open_text(path) as handle:
for line_no, line in enumerate(handle, start=1):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if looks_like_reditools_header(stripped):
if "\t" in line:
delimiter = "\t"
elif "," in line and "BaseCount[A,C,G,T]" not in line:
delimiter = ","
else:
delimiter = None
header_parts = split_reditools_line(line, delimiter=delimiter)
if header_parts is None:
raise EditPipelineError("Could not parse REDItools header at line %s" % line_no)
header_map = build_reditools_header_map(header_parts)
logging.info(
"Detected REDItools columns from header at line %s: %s",
line_no,
", ".join([c for c in REDITOOLS_COLUMNS if c in header_map])
)
continue
parts = split_reditools_line(line, delimiter=delimiter)
if parts is None:
logging.warning("Skipping malformed REDItools line %s", line_no)
yield line_no, {"_parse_error": "malformed_reditools_line", "_raw": stripped}
continue
if header_map is None and not warned_no_header:
warned_no_header = True
logging.warning(
"REDItools header was not detected before data line %s; assuming classic 14-column order.",
line_no
)
row = row_from_parts(parts, header_map=header_map)
if row is None:
logging.warning("Skipping malformed REDItools line %s: expected at least %s columns, found %s", line_no, len(REDITOOLS_COLUMNS), len(parts))
yield line_no, {"_parse_error": "malformed_reditools_line", "_raw": stripped}
continue
try:
validate_reditools_row_fields(row, line_no)
except ValueError as exc:
logging.warning("Skipping REDItools line %s: %s", line_no, exc)
yield line_no, {"_parse_error": str(exc), "_raw": stripped}
continue
yield line_no, row
def parse_gtf_attributes(attr_text: str) -> dict:
attrs = {}
for field in attr_text.strip().rstrip(";").split(";"):
field = field.strip()
if not field:
continue
if "=" in field and " " not in field.split("=", 1)[0]:
key, value = field.split("=", 1)
else:
parts = field.split(None, 1)
if len(parts) == 1:
key, value = parts[0], ""
else:
key, value = parts
attrs[key.strip()] = value.strip().strip('"')
return attrs
# -----------------------------------------------------------------------------
# Gene-model and splice-site annotation
# -----------------------------------------------------------------------------
# The GTF contributes two biological filters: gene overlap and splice-junction
# proximity. Gene overlap keeps candidates in transcribed models; splice windows
# remove sites most likely to be affected by split-read alignment uncertainty.
def load_gtf(path: str, resolver: ContigResolver, overlap_feature: str, splice_flank: int) -> GtfData:
genes = BinnedIntervalIndex()
splice_sites = BinnedIntervalIndex()
exon_by_transcript: Dict[str, List[Tuple[str, int, int, str, str, str]]] = defaultdict(list)
exon_by_gene: Dict[str, List[Tuple[str, int, int, str, str]]] = defaultdict(list)
skipped_contigs = Counter()
gene_interval_count = 0
with open_text(path) as handle:
for line_no, line in enumerate(handle, start=1):
if not line.strip() or line.startswith("#"):
continue
fields = line.rstrip("\n").split("\t")
if len(fields) != 9:
logging.warning("Skipping malformed GTF line %s", line_no)
continue
seqid, source, feature, start_s, end_s, score, strand, frame, attrs_s = fields
chrom = resolver.resolve(seqid)
if chrom is None:
skipped_contigs[seqid] += 1
continue
try:
start0 = int(start_s) - 1
end = int(end_s)
except ValueError:
logging.warning("Skipping GTF line %s with invalid coordinates", line_no)
continue
if start0 < 0 or end <= start0:
logging.warning("Skipping GTF line %s with invalid interval", line_no)
continue
attrs = parse_gtf_attributes(attrs_s)
gene_id = (
attrs.get("gene_id")
or attrs.get("gene")
or attrs.get("ID")
or attrs.get("Parent")
or f"unknown_gene_line_{line_no}"
)
gene_name = attrs.get("gene_name") or attrs.get("Name") or gene_id
transcript_id = attrs.get("transcript_id") or attrs.get("transcript") or attrs.get("Parent")
if feature == overlap_feature:
genes.add(
chrom,
start0,
end,
{
"gene_id": gene_id,
"gene_name": gene_name,
"strand": strand,
"feature": feature,
},
)
gene_interval_count += 1
if feature == "exon":
exon_by_gene[gene_id].append((chrom, start0, end, strand, gene_name))
if transcript_id:
exon_by_transcript[transcript_id].append((chrom, start0, end, strand, gene_id, gene_name))
if gene_interval_count == 0:
logging.warning(
"No GTF feature=%r intervals found. Falling back to gene spans built from exon records.",
overlap_feature,
)
for gene_id, exons in exon_by_gene.items():
by_chrom_strand: Dict[Tuple[str, str, str], List[Tuple[int, int]]] = defaultdict(list)
for chrom, start0, end, strand, gene_name in exons:
by_chrom_strand[(chrom, strand, gene_name)].append((start0, end))
for (chrom, strand, gene_name), intervals in by_chrom_strand.items():
start0 = min(x[0] for x in intervals)
end = max(x[1] for x in intervals)
genes.add(
chrom,
start0,
end,
{
"gene_id": gene_id,
"gene_name": gene_name,
"strand": strand,
"feature": "exon_span_fallback",
},
)
gene_interval_count += 1
if splice_flank > 0:
for transcript_id, exons in exon_by_transcript.items():
if len(exons) < 2:
continue
exons_sorted = sorted(exons, key=lambda x: (x[0], x[1], x[2]))
for left, right in zip(exons_sorted, exons_sorted[1:]):
left_chrom, left_start, left_end, strand, gene_id, gene_name = left
right_chrom, right_start, right_end, _, _, _ = right
if left_chrom != right_chrom:
continue
for boundary in (left_end, right_start):
splice_sites.add(
left_chrom,
max(0, boundary - splice_flank),
boundary + splice_flank,
{
"gene_id": gene_id,
"gene_name": gene_name,
"transcript_id": transcript_id,
"strand": strand,
},
)
if skipped_contigs:
top = ", ".join(f"{k}:{v}" for k, v in skipped_contigs.most_common(5))
logging.warning("Skipped GTF records on contigs absent from FASTA: %s", top)
if len(genes) == 0:
raise EditPipelineError("No usable gene intervals were loaded from the GTF file.")
return GtfData(
genes=genes,
splice_sites=splice_sites,
loaded_gene_intervals=len(genes),
loaded_splice_intervals=len(splice_sites),
)
def parse_repeatmasker_interval(parts: List[str], fmt: str) -> Optional[Tuple[str, int, int, str]]:
"""Return chrom, start0, end, repeat_name or None.
Supported repeat inputs:
* BED: chrom start0 end [name ...]
* GFF/GTF-like: chrom source feature start1 end1 ... attributes
* RepeatMasker .out:
SW perc perc perc query begin end (left) strand repeat class/family ... ID
RepeatMasker coordinates are 1-based closed, so begin is converted to
0-based half-open for internal overlap tests.
"""
requested_rmout = fmt in {"auto", "rmout", "repeatmasker"}
if fmt in {"auto", "bed"} and len(parts) >= 3 and is_int_string(parts[1]) and is_int_string(parts[2]):
chrom = parts[0]
start0 = int(parts[1])
end = int(parts[2])
name = parts[3] if len(parts) >= 4 else "repeat"
return chrom, start0, end, name
if fmt in {"auto", "gff"} and len(parts) >= 9 and is_int_string(parts[3]) and is_int_string(parts[4]):
chrom = parts[0]
start0 = int(parts[3]) - 1
end = int(parts[4])
attrs = parse_gtf_attributes(parts[8])
name = attrs.get("Name") or attrs.get("Target") or parts[2]
return chrom, start0, end, name
# Standard RepeatMasker .out row, e.g.:
# 1435 15.5 11.8 2.8 NC_046332.1 2 365 (117953686) C DR0009509 LTR/ERVL (535) 397 2 1
# 18 14.7 0.0 0.0 NC_046332.1 2065 2094 (117951957) + (T)n Simple_repeat 1 30 (0) 2
# A trailing asterisk can be present in some .out files, so only the
# coordinate/name columns needed here are required.
if requested_rmout and len(parts) >= 11 and is_number_string(parts[0]):
chrom = parts[4]
if not (is_int_string(parts[5]) and is_int_string(parts[6])):
return None
start1 = int(parts[5])
end1 = int(parts[6])
if start1 <= 0 or end1 <= 0:
return None
start0 = min(start1, end1) - 1
end = max(start1, end1)
repeat_name = parts[9]
repeat_class = parts[10] if len(parts) > 10 else "repeat"
name = f"{repeat_name}|{repeat_class}"
return chrom, start0, end, name
return None
# -----------------------------------------------------------------------------
# Repeat annotation loader
# -----------------------------------------------------------------------------
# RepeatMasker .out, BED, and GFF-like repeat files are normalized into the
# same 0-based half-open coordinate system used internally by Python/pysam.
def load_repeats(path: str, resolver: ContigResolver, fmt: str) -> BinnedIntervalIndex:
repeats = BinnedIntervalIndex()
skipped = Counter()
malformed = 0
with open_text(path) as handle:
for line_no, line in enumerate(handle, start=1):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if stripped.lower().startswith(("sw ", "score ", "there were", "repeatmasker")):
continue
parts = stripped.split()
parsed = parse_repeatmasker_interval(parts, fmt)
if parsed is None:
malformed += 1
if malformed <= 5:
logging.warning("Skipping unrecognized repeat line %s", line_no)
continue
chrom_raw, start0, end, name = parsed
chrom = resolver.resolve(chrom_raw)
if chrom is None:
skipped[chrom_raw] += 1
continue
if end <= start0:
malformed += 1
continue
repeats.add(chrom, start0, end, {"repeat_name": name})
if skipped:
top = ", ".join(f"{k}:{v}" for k, v in skipped.most_common(5))
logging.warning("Skipped repeat records on contigs absent from FASTA: %s", top)
logging.info("Loaded %s repeat intervals", len(repeats))
return repeats
# -----------------------------------------------------------------------------
# File and indexed reference/BAM validation
# -----------------------------------------------------------------------------
# These checks fail early with user-fixable messages: missing FASTA index,
# missing BAM index, or absent input files should not become cryptic pileup errors.
def validate_file(path: str, label: str, allow_stdin: bool = False) -> None:
if allow_stdin and path == "-":
return
if not path:
raise EditPipelineError(f"Missing path for {label}.")
if not os.path.exists(path):
raise EditPipelineError(f"{label} does not exist: {path}")
if not os.path.isfile(path):
raise EditPipelineError(f"{label} is not a regular file: {path}")
def open_fasta(path: str) -> pysam.FastaFile:
try:
fasta = pysam.FastaFile(path)
except Exception as exc:
raise EditPipelineError(
f"Could not open genome FASTA {path!r}. Make sure it is indexed with: samtools faidx {path}"
) from exc
if not fasta.references:
raise EditPipelineError("The genome FASTA has no references.")
return fasta
def open_bam(path: str, label: str) -> pysam.AlignmentFile:
try:
bam = pysam.AlignmentFile(path, "rb")
except Exception as exc:
raise EditPipelineError(f"Could not open {label} BAM {path!r}.") from exc
try:
has_index = bam.has_index()
except Exception:
has_index = False
if not has_index:
raise EditPipelineError(
f"{label} BAM is not indexed or the index is not readable: {path}. Run: samtools index {path}"
)
return bam
def fasta_base(fasta: pysam.FastaFile, chrom: str, pos0: int) -> Optional[str]:
try:
if pos0 < 0 or pos0 >= fasta.get_reference_length(chrom):
return None
seq = fasta.fetch(chrom, pos0, pos0 + 1).upper()
except Exception:
return None
return seq if len(seq) == 1 else None
# -----------------------------------------------------------------------------
# Sequence-context filter: homopolymers
# -----------------------------------------------------------------------------
# Homopolymer runs are error-prone sequence contexts. The function measures the
# same-base run containing the candidate directly from the FASTA.
def homopolymer_run_length(
fasta: pysam.FastaFile,
chrom: str,
pos0: int,
threshold: int,
) -> int:
if threshold <= 1:
return 1
contig_len = fasta.get_reference_length(chrom)
start = max(0, pos0 - threshold + 1)
end = min(contig_len, pos0 + threshold)
seq = fasta.fetch(chrom, start, end).upper()
idx = pos0 - start
if idx < 0 or idx >= len(seq):
return 0
base = seq[idx]
if base not in BASE_TO_I:
return 0
run = 1
left = idx - 1
while left >= 0 and seq[left] == base:
run += 1
left -= 1
right = idx + 1
while right < len(seq) and seq[right] == base:
run += 1
right += 1
return run
def usable_alignment(aln, ignore_duplicates: bool, ignore_qcfail: bool) -> bool:
if aln.is_unmapped or aln.is_secondary or aln.is_supplementary:
return False
if ignore_duplicates and aln.is_duplicate:
return False
if ignore_qcfail and aln.is_qcfail:
return False
return True
# -----------------------------------------------------------------------------
# BAM site-level mapping-quality filter
# -----------------------------------------------------------------------------
# A real edit should not be supported mainly by ambiguously mapped reads. This
# function computes mean MAPQ and the fraction of reads above the MAPQ threshold.
def site_mapq_stats(
bam_ctx: BamContext,
chrom: str,
pos0: int,
min_mapq: int,
min_mean_mapq: float,
min_good_frac: float,
max_depth: int,
ignore_duplicates: bool,
ignore_qcfail: bool,
) -> Tuple[bool, dict, str]:
bam_chrom = bam_ctx.resolver.resolve(chrom)
if bam_chrom is None:
return False, {}, "contig_not_found_in_bam"
total = 0
good = 0
mapq_sum = 0
try:
pileup_iter = bam_ctx.bam.pileup(
bam_chrom,
pos0,
pos0 + 1,
truncate=True,
stepper="all",
max_depth=max_depth,
min_base_quality=0,
)
for col in pileup_iter:
if col.reference_pos != pos0:
continue
for pr in col.pileups:
aln = pr.alignment
if not usable_alignment(aln, ignore_duplicates, ignore_qcfail):
continue
if pr.is_del or pr.is_refskip:
continue
total += 1
mq = int(aln.mapping_quality)
mapq_sum += mq
if mq >= min_mapq:
good += 1
except ValueError as exc:
return False, {}, f"bam_pileup_failed:{exc}"
except Exception as exc:
return False, {}, f"bam_pileup_failed:{type(exc).__name__}"
if total == 0:
return False, {"depth": 0, "mean_mapq": math.nan, "good_mapq_fraction": math.nan}, "no_usable_bam_reads"
mean_mapq = mapq_sum / total
good_frac = good / total
stats = {"depth": total, "mean_mapq": mean_mapq, "good_mapq_fraction": good_frac}
if mean_mapq < min_mean_mapq:
return False, stats, "low_mean_mapq"
if good_frac < min_good_frac:
return False, stats, "low_good_mapq_fraction"
return True, stats, "pass"
# -----------------------------------------------------------------------------
# DNA BAM fallback genotype counter
# -----------------------------------------------------------------------------
# If REDItools lacks DNA counts, the pipeline can genotype the same position
# from the DNA BAM using MAPQ and base-quality thresholds.
def bam_base_counts_at_site(
bam_ctx: BamContext,
chrom: str,
pos0: int,
min_mapq: int,
min_baseq: int,
max_depth: int,
ignore_duplicates: bool,
ignore_qcfail: bool,
) -> Tuple[bool, dict, str]:
"""Count A/C/G/T bases in a BAM at one site for DNA fallback genotyping."""
bam_chrom = bam_ctx.resolver.resolve(chrom)
if bam_chrom is None:
return False, {}, "contig_not_found_in_bam"
counts = [0, 0, 0, 0]
qual_sum = 0
try:
pileup_iter = bam_ctx.bam.pileup(
bam_chrom,
pos0,
pos0 + 1,
truncate=True,
stepper="all",
max_depth=max_depth,
min_base_quality=0,
)
for col in pileup_iter:
if col.reference_pos != pos0:
continue
for pr in col.pileups:
aln = pr.alignment
if not usable_alignment(aln, ignore_duplicates, ignore_qcfail):
continue
if int(aln.mapping_quality) < min_mapq:
continue
if pr.is_del or pr.is_refskip or pr.query_position is None:
continue
seq = aln.query_sequence
if not seq or pr.query_position >= len(seq):
continue
base = seq[pr.query_position].upper()
if base not in BASE_TO_I:
continue
q = 0
if aln.query_qualities is not None and pr.query_position < len(aln.query_qualities):
q = int(aln.query_qualities[pr.query_position])
if q < min_baseq:
continue
counts[BASE_TO_I[base]] += 1
qual_sum += q
except ValueError as exc:
return False, {}, f"bam_pileup_failed:{exc}"
except Exception as exc:
return False, {}, f"bam_pileup_failed:{type(exc).__name__}"
depth = sum(counts)
if depth == 0:
return False, {"counts": counts, "coverage": 0, "mean_baseq": math.nan}, "no_usable_dna_bases"
return True, {"counts": counts, "coverage": depth, "mean_baseq": qual_sum / depth}, "pass"
# -----------------------------------------------------------------------------
# Local alignment-instability filter: nearby indels
# -----------------------------------------------------------------------------
# Indels near a candidate can shift alignments and create false mismatches. The
# filter scans a small window and records the worst indel burden observed.
def indel_stats_in_window(
bam_ctx: BamContext,
chrom: str,
pos0: int,
flank: int,
max_indel_frac: float,
max_indel_reads: int,
max_depth: int,
ignore_duplicates: bool,
ignore_qcfail: bool,
) -> Tuple[bool, dict, str]:
if flank <= 0:
return True, {"max_indel_fraction": 0.0, "max_indel_reads": 0}, "pass"
bam_chrom = bam_ctx.resolver.resolve(chrom)
if bam_chrom is None:
return False, {}, "contig_not_found_in_bam"
start = max(0, pos0 - flank)
end = pos0 + flank + 1
max_frac_seen = 0.0
max_indel_seen = 0
columns_seen = 0
try:
pileup_iter = bam_ctx.bam.pileup(
bam_chrom,
start,
end,
truncate=True,
stepper="all",
max_depth=max_depth,
min_base_quality=0,
)
for col in pileup_iter:
if not (start <= col.reference_pos < end):
continue
columns_seen += 1
depth = 0
indel_reads = 0
for pr in col.pileups:
aln = pr.alignment
if not usable_alignment(aln, ignore_duplicates, ignore_qcfail):
continue
depth += 1
if pr.indel != 0 or pr.is_del or pr.is_refskip:
indel_reads += 1
if depth > 0:
frac = indel_reads / depth
max_frac_seen = max(max_frac_seen, frac)
max_indel_seen = max(max_indel_seen, indel_reads)
except ValueError as exc:
return False, {}, f"bam_pileup_failed:{exc}"
except Exception as exc:
return False, {}, f"bam_pileup_failed:{type(exc).__name__}"
stats = {"max_indel_fraction": max_frac_seen, "max_indel_reads": max_indel_seen, "columns_seen": columns_seen}
if max_indel_seen > max_indel_reads:
return False, stats, "too_many_indel_reads"
if max_frac_seen > max_indel_frac:
return False, stats, "high_indel_fraction"
return True, stats, "pass"
# -----------------------------------------------------------------------------
# Local mismatch-density filter
# -----------------------------------------------------------------------------
# A candidate surrounded by unrelated mismatches is suspicious. The candidate
# base itself is excluded, and same-event neighboring edits can be ignored so
# genuine editing clusters are not automatically punished.
def mismatch_stats_in_window(
bam_ctx: BamContext,
fasta: pysam.FastaFile,
chrom: str,
pos0: int,
flank: int,
min_mapq: int,
min_baseq: int,
max_window_mismatch_fraction: float,
max_column_mismatch_fraction: float,
max_high_mismatch_columns: int,
max_depth: int,
ignore_duplicates: bool,
ignore_qcfail: bool,
gene_strand: str,
ignore_event_type: str,
count_same_event_mismatches: bool,
) -> Tuple[bool, dict, str]:
"""Detect locally mismapped regions by measuring mismatch density around a site.
The candidate position itself is excluded so a genuine edit does not count
against the window. By default, adjacent mismatches matching the same
transcript-oriented substitution are not counted, which keeps true local
A>G or C>T clusters from looking like mapping artefacts. A site fails when
adjacent columns show too many other bases that disagree with the reference FASTA.
"""
if flank <= 0:
return True, {
"window_mismatch_fraction": 0.0,
"max_mismatch_fraction": 0.0,
"high_mismatch_columns": 0,
"mismatch_columns_seen": 0,
"mismatch_bases": 0,
"window_bases": 0,
}, "pass"
bam_chrom = bam_ctx.resolver.resolve(chrom)
if bam_chrom is None:
return False, {}, "contig_not_found_in_bam"
try:
contig_len = fasta.get_reference_length(chrom)
except Exception:
return False, {}, "fasta_reference_length_failed"
start = max(0, pos0 - flank)
end = min(contig_len, pos0 + flank + 1)
if end <= start:
return False, {}, "empty_mismatch_window"
try:
ref_seq = fasta.fetch(chrom, start, end).upper()
except Exception as exc:
return False, {}, "fasta_fetch_failed:%s" % type(exc).__name__
total_bases = 0
mismatch_bases = 0
max_frac_seen = 0.0
high_columns = 0
columns_seen = 0
try:
pileup_iter = bam_ctx.bam.pileup(
bam_chrom,
start,
end,
truncate=True,
stepper="all",
max_depth=max_depth,
min_base_quality=0,
)
for col in pileup_iter:
ref_pos = col.reference_pos
if not (start <= ref_pos < end):
continue
if ref_pos == pos0:
continue
idx = ref_pos - start
if idx < 0 or idx >= len(ref_seq):
continue
ref_base = ref_seq[idx]
if ref_base not in BASE_TO_I:
continue
depth = 0
mismatches = 0
for pr in col.pileups:
aln = pr.alignment
if not usable_alignment(aln, ignore_duplicates, ignore_qcfail):
continue
if int(aln.mapping_quality) < min_mapq:
continue
if pr.is_del or pr.is_refskip or pr.query_position is None:
continue
seq = aln.query_sequence
if not seq or pr.query_position >= len(seq):
continue
base = seq[pr.query_position].upper()
if base not in BASE_TO_I:
continue
q = 0
if aln.query_qualities is not None and pr.query_position < len(aln.query_qualities):
q = int(aln.query_qualities[pr.query_position])
if q < min_baseq:
continue
depth += 1
total_bases += 1
if base != ref_base:
if (not count_same_event_mismatches) and gene_strand in {"+", "-"} and ignore_event_type:
try:
local_change = transcript_change(ref_base, base, gene_strand)
except Exception:
local_change = ""
if local_change == ignore_event_type:
continue
mismatches += 1
mismatch_bases += 1
if depth > 0:
columns_seen += 1
frac = mismatches / depth
max_frac_seen = max(max_frac_seen, frac)
if frac > max_column_mismatch_fraction:
high_columns += 1
except ValueError as exc:
return False, {}, "bam_pileup_failed:%s" % exc
except Exception as exc:
return False, {}, "bam_pileup_failed:%s" % type(exc).__name__
window_frac = (mismatch_bases / total_bases) if total_bases > 0 else 0.0
stats = {
"window_mismatch_fraction": window_frac,
"max_mismatch_fraction": max_frac_seen,
"high_mismatch_columns": high_columns,
"mismatch_columns_seen": columns_seen,
"mismatch_bases": mismatch_bases,
"window_bases": total_bases,
}
if total_bases == 0:
return True, stats, "pass"
if window_frac > max_window_mismatch_fraction:
return False, stats, "high_window_mismatch_fraction"
if max_frac_seen > max_column_mismatch_fraction:
return False, stats, "high_column_mismatch_fraction"
if high_columns > max_high_mismatch_columns:
return False, stats, "too_many_high_mismatch_columns"
return True, stats, "pass"
# -----------------------------------------------------------------------------
# RNA alternate-allele selection and editing-level calculation
# -----------------------------------------------------------------------------
# The major non-reference RNA base becomes the candidate edit allele. Editing
# level is simply alternate RNA reads divided by total A/C/G/T RNA reads.
def major_rna_alt(ref: str, counts: List[int]) -> Tuple[Optional[str], int, float, List[str]]:
if ref not in BASE_TO_I:
return None, 0, 0.0, []
total = sum(counts)
if total <= 0:
return None, 0, 0.0, []
ref_i = BASE_TO_I[ref]
alt_items = [(base, counts[i]) for i, base in enumerate(BASES) if i != ref_i and counts[i] > 0]
if not alt_items:
return None, 0, 0.0, []
alt_items.sort(key=lambda x: (-x[1], x[0]))
alt_base, alt_count = alt_items[0]
secondary = [f"{base}:{count}" for base, count in alt_items[1:]]
return alt_base, alt_count, alt_count / total, secondary
def transcript_change(ref: str, alt: str, strand: str) -> str:
if strand == "+":
return f"{ref}>{alt}"
if strand == "-":
tref = ref.translate(COMPLEMENT).upper()
talt = alt.translate(COMPLEMENT).upper()
return f"{tref}>{talt}"
return "NA"
# -----------------------------------------------------------------------------
# Transcript-oriented event classification
# -----------------------------------------------------------------------------
# ADAR/APOBEC labels only make biological sense in transcript orientation. A
# genomic T>C on a minus-strand gene is transcript A>G, for example.
def classify_event(ref: str, alt: str, gene_hits: List[Interval]) -> Tuple[str, str, str, str, str, str]:
"""Classify by transcript-oriented substitution without collapsing Other types.
Returns:
event_group: ADAR, APOBEC, or Other
event_type: transcript-oriented change such as A>G or C>T
event_label: label used in plots. Usually same as event_type; ambiguous
strand cases are labelled ambiguous_REF>ALT.
gene_ids, gene_names, gene_strand_used
"""
gene_ids = sorted({iv.data.get("gene_id", "NA") for iv in gene_hits})
gene_names = sorted({iv.data.get("gene_name", "NA") for iv in gene_hits})
strands = sorted({iv.data.get("strand", ".") for iv in gene_hits if iv.data.get("strand") in {"+", "-"}})
if len(strands) != 1:
genomic_change = f"{ref}>{alt}"
event_type = "ambiguous_" + genomic_change
return "Other", event_type, event_type, ",".join(gene_ids), ",".join(gene_names), "ambiguous_or_missing_gene_strand"
strand = strands[0]
event_type = transcript_change(ref, alt, strand)
if event_type == "A>G":
event_group = "ADAR"
elif event_type == "C>T":
event_group = "APOBEC"
else:
event_group = "Other"
return event_group, event_type, event_type, ",".join(gene_ids), ",".join(gene_names), strand
def fmt_float(value: float, digits: int = 6) -> str:
if value is None or (isinstance(value, float) and math.isnan(value)):
return "NA"
return f"{value:.{digits}f}"
# -----------------------------------------------------------------------------
# The filtering heart of the pipeline
# -----------------------------------------------------------------------------
# evaluate_site() applies filters from cheap and deterministic checks first
# toward expensive BAM pileups later. This ordering keeps the run efficient and
# keeps failure reasons interpretable.
def evaluate_site(
row: dict,
line_no: int,
fasta: pysam.FastaFile,
fasta_resolver: ContigResolver,
repeats: BinnedIntervalIndex,
gtf_data: GtfData,
bam_contexts: List[BamContext],
args,
) -> Evaluation:
# 1. Reject malformed rows before doing any biological interpretation.
if "_parse_error" in row:
return Evaluation(False, ["parse_error"], None)
reasons: List[str] = []
# 2. Harmonize chromosome names and convert REDItools' 1-based position
# into the 0-based coordinate system used by FASTA/BAM queries.
chrom_raw = str(row["Region"])
chrom = fasta_resolver.resolve(chrom_raw)
if chrom is None:
return Evaluation(False, ["contig_not_found_in_fasta"], None)
pos1 = int(row["Position"])
pos0 = pos1 - 1
ref = str(row["Reference"]).upper()
if ref not in BASE_TO_I:
return Evaluation(False, ["non_acgt_reference"], None)
# 3. Check that REDItools and the reference FASTA agree on the reference
# base. Reference mismatches usually indicate build, strand, or input
# problems, so they are filtered by default.
genome_ref = fasta_base(fasta, chrom, pos0)
if genome_ref is None:
return Evaluation(False, ["position_out_of_range_or_fasta_fetch_failed"], None)
if genome_ref != ref and not args.allow_ref_mismatch:
return Evaluation(False, [f"reference_mismatch_fasta:{genome_ref}_reditools:{ref}"], None)
rna_counts: List[int] = row["BaseCount[A,C,G,T]"]
gdna_counts: List[int] = list(row["gBaseCount[A,C,G,T]"])
# 4. Choose the major non-reference RNA base and quantify editing level.
alt, alt_count, edit_freq, secondary = major_rna_alt(ref, rna_counts)
if alt is None:
return Evaluation(False, ["no_rna_substitution"], None)
rna_count_depth = sum(rna_counts)
gdna_count_depth = sum(gdna_counts)
rna_cov = int(row["Coverage-q30"])
gdna_cov = int(row["gCoverage-q30"])
rna_meanq = float(row["MeanQ"])
gdna_meanq = float(row["gMeanQ"])
missing_reditools_dna = bool(row.get("_missing_gCoverage_q30") or row.get("_missing_gBaseCount"))
dna_count_source = "reditools"
if row.get("_missing_gCoverage_q30") and gdna_count_depth > 0:
gdna_cov = gdna_count_depth
if missing_reditools_dna:
dna_count_source = "missing_reditools_dna"
if args.use_dna_bam_counts_if_reditools_dna_missing:
dna_ctx = None
for bam_ctx in bam_contexts:
if bam_ctx.label == "dna":
dna_ctx = bam_ctx
break
if dna_ctx is not None:
ok, dna_stats, dna_reason = bam_base_counts_at_site(
bam_ctx=dna_ctx,
chrom=chrom,
pos0=pos0,
min_mapq=args.min_mapq,
min_baseq=args.dna_variant_min_baseq,
max_depth=args.max_pileup_depth,
ignore_duplicates=not args.include_duplicates,
ignore_qcfail=not args.include_qcfail,
)
if ok:
gdna_counts = list(dna_stats["counts"])
gdna_count_depth = sum(gdna_counts)
gdna_cov = int(dna_stats["coverage"])
gdna_meanq = float(dna_stats["mean_baseq"])
dna_count_source = "dna_bam_fallback"
else:
dna_count_source = "dna_bam_fallback_failed:" + dna_reason
# 5. Basic evidence filters: enough coverage, enough alternate reads,
# sufficient editing fraction, and adequate base quality in RNA/DNA.
if rna_cov < args.min_rna_coverage:
reasons.append("low_rna_coverage")
if gdna_cov < args.min_dna_coverage:
reasons.append("low_dna_coverage")
if not math.isnan(rna_meanq) and rna_meanq < args.min_rna_meanq:
reasons.append("low_rna_mean_base_quality")
if not math.isnan(gdna_meanq) and gdna_meanq < args.min_dna_meanq:
reasons.append("low_dna_mean_base_quality")
if alt_count < args.min_alt_count:
reasons.append("low_rna_alt_count")
if edit_freq < args.min_edit_freq:
reasons.append("low_editing_frequency")
# 6. Matched-DNA filter: the RNA alternate allele should not be strongly
# present in genomic DNA, otherwise the candidate may be a SNP or other
# genomic variant rather than an RNA edit.
alt_i = BASE_TO_I[alt]
gdna_alt_count = gdna_counts[alt_i]
gdna_alt_freq = (gdna_alt_count / gdna_count_depth) if gdna_count_depth > 0 else 0.0
if gdna_alt_freq > args.max_dna_alt_freq:
reasons.append("high_dna_alt_frequency_possible_snv")
if gdna_alt_count > args.max_dna_alt_count:
reasons.append("high_dna_alt_count_possible_snv")
# 7. Sequence and annotation filters: repeats, homopolymers, splice-site
# proximity, and gene-model overlap. These remove common artifact zones.
repeat_hits = repeats.query_point(chrom, pos0)
if repeat_hits:
reasons.append("repeat_overlap")
hp_len = homopolymer_run_length(fasta, chrom, pos0, args.homopolymer_length)
if args.homopolymer_length > 1 and hp_len >= args.homopolymer_length:
reasons.append("homopolymer_run")
splice_hits = gtf_data.splice_sites.query_point(chrom, pos0) if args.splice_flank > 0 else []
if splice_hits:
reasons.append("splice_site_proximity")
gene_hits = gtf_data.genes.query_point(chrom, pos0)
if not gene_hits:
reasons.append("no_gene_model_overlap")
# If a cheap filter already failed, stop before expensive pileup work.
if reasons:
return Evaluation(False, reasons, None)
# 8. Classify the candidate before mismatch-density filtering so same-type
# neighboring edits can be ignored when desired.
event_group, event_type, event_label, gene_ids, gene_names, gene_strand = classify_event(ref, alt, gene_hits)
# 9. Pileup-based filters. These are deliberately later because they are
# computationally expensive compared with simple table/interval checks.
mapq_output = {}
if not args.skip_bam_mapq_filter:
for bam_ctx in bam_contexts:
ok, stats, reason = site_mapq_stats(
bam_ctx=bam_ctx,
chrom=chrom,
pos0=pos0,
min_mapq=args.min_mapq,
min_mean_mapq=args.min_mean_mapq,
min_good_frac=args.min_good_mapq_fraction,
max_depth=args.max_pileup_depth,
ignore_duplicates=not args.include_duplicates,
ignore_qcfail=not args.include_qcfail,
)
mapq_output[f"{bam_ctx.label}_bam_depth"] = stats.get("depth", "NA")
mapq_output[f"{bam_ctx.label}_mean_mapq"] = fmt_float(stats.get("mean_mapq", math.nan), 3)
mapq_output[f"{bam_ctx.label}_good_mapq_fraction"] = fmt_float(stats.get("good_mapq_fraction", math.nan), 3)
if not ok:
reasons.append(f"{bam_ctx.label}_{reason}")
indel_output = {}
if not args.skip_bam_indel_filter:
for bam_ctx in bam_contexts:
ok, stats, reason = indel_stats_in_window(
bam_ctx=bam_ctx,
chrom=chrom,
pos0=pos0,
flank=args.indel_flank,
max_indel_frac=args.max_indel_fraction,
max_indel_reads=args.max_indel_reads,
max_depth=args.max_pileup_depth,
ignore_duplicates=not args.include_duplicates,
ignore_qcfail=not args.include_qcfail,
)
indel_output[f"{bam_ctx.label}_max_indel_fraction"] = fmt_float(
stats.get("max_indel_fraction", math.nan), 3
)
indel_output[f"{bam_ctx.label}_max_indel_reads"] = stats.get("max_indel_reads", "NA")
if not ok:
reasons.append(f"{bam_ctx.label}_{reason}")
mismatch_output = {}
if not args.skip_bam_mismatch_filter:
for bam_ctx in bam_contexts:
if args.mismatch_bams != "both" and args.mismatch_bams != bam_ctx.label:
continue
ok, stats, reason = mismatch_stats_in_window(
bam_ctx=bam_ctx,
fasta=fasta,
chrom=chrom,
pos0=pos0,
flank=args.mismatch_flank,
min_mapq=args.min_mapq,
min_baseq=args.mismatch_min_baseq,
max_window_mismatch_fraction=args.max_window_mismatch_fraction,
max_column_mismatch_fraction=args.max_column_mismatch_fraction,
max_high_mismatch_columns=args.max_high_mismatch_columns,
max_depth=args.max_pileup_depth,
ignore_duplicates=not args.include_duplicates,
ignore_qcfail=not args.include_qcfail,
gene_strand=gene_strand,
ignore_event_type=event_type,
count_same_event_mismatches=args.count_same_event_mismatches,
)
mismatch_output[f"{bam_ctx.label}_window_mismatch_fraction"] = fmt_float(
stats.get("window_mismatch_fraction", math.nan), 4
)
mismatch_output[f"{bam_ctx.label}_max_mismatch_fraction"] = fmt_float(
stats.get("max_mismatch_fraction", math.nan), 4
)
mismatch_output[f"{bam_ctx.label}_high_mismatch_columns"] = stats.get("high_mismatch_columns", "NA")
mismatch_output[f"{bam_ctx.label}_mismatch_columns_seen"] = stats.get("mismatch_columns_seen", "NA")
mismatch_output[f"{bam_ctx.label}_mismatch_bases"] = stats.get("mismatch_bases", "NA")
mismatch_output[f"{bam_ctx.label}_mismatch_window_bases"] = stats.get("window_bases", "NA")
if not ok:
reasons.append(f"{bam_ctx.label}_{reason}")
if reasons:
return Evaluation(False, reasons, None)
# 10. Passed sites are written with both raw evidence and filter-derived
# diagnostics so downstream QC does not need to recompute pileups.
record = {
"chrom": chrom,
"position_1based": pos1,
"position_0based": pos0,
"reference": ref,
"genome_reference": genome_ref,
"alt_base": alt,
"genomic_edit": f"{ref}>{alt}",
"transcript_edit": event_type,
"event_type": event_type,
"event_group": event_group,
"event_label": event_label,
"event_class": event_label,
"gene_ids": gene_ids,
"gene_names": gene_names,
"gene_strand_used": gene_strand,
"rna_coverage_q30": rna_cov,
"rna_count_depth": rna_count_depth,
"rna_alt_count": alt_count,
"editing_level": fmt_float(edit_freq, 6),
"secondary_rna_alts": ";".join(secondary) if secondary else "-",
"rna_mean_base_quality": fmt_float(rna_meanq, 3),
"dna_coverage_q30": gdna_cov,
"dna_count_depth": gdna_count_depth,
"dna_alt_count_same_base": gdna_alt_count,
"dna_alt_frequency_same_base": fmt_float(gdna_alt_freq, 6),
"dna_mean_base_quality": fmt_float(gdna_meanq, 3),
"dna_count_source": dna_count_source,
"homopolymer_length": hp_len,
"reditools_allsubs": row.get("AllSubs", "NA"),
"reditools_frequency": row.get("Frequency", "NA"),
"reditools_gallsubs": row.get("gAllSubs", "NA"),
"reditools_gfrequency": row.get("gFrequency", "NA"),
}
record.update(mapq_output)
record.update(indel_output)
record.update(mismatch_output)
return Evaluation(True, [], record, event_class=event_label, editing_level=edit_freq, event_group=event_group)
def event_label_sort_key(label: str):
if label in TRANSCRIPT_SUBSTITUTION_ORDER:
return (0, TRANSCRIPT_SUBSTITUTION_ORDER.index(label), label)
if str(label).startswith("ambiguous_"):
return (2, 0, label)
return (1, 0, label)
def sorted_event_labels(class_counts: Counter) -> List[str]:
observed = [label for label, count in class_counts.items() if count > 0]
return sorted(observed, key=event_label_sort_key)
def _extract_change_from_label(label: str) -> str:
"""Return the substitution pattern embedded in a plot/event label.
Examples:
A>G -> A>G
ambiguous_T>C -> T>C
genomic_G>A -> G>A
"""
if not label:
return ""
if ">" in label:
candidate = label.split("_")[-1]
if len(candidate) == 3 and candidate[1] == ">":
return candidate
return label
ADAR_CHANGES = {"A>G", "T>C"}
APOBEC_CHANGES = {"C>T", "G>A"}
def event_group_from_label(label: str) -> str:
change = _extract_change_from_label(label)
if change in ADAR_CHANGES:
return "ADAR"
if change in APOBEC_CHANGES:
return "APOBEC"
return "Other"
def event_color(label: str, args) -> str:
group = event_group_from_label(label)
if group == "ADAR":
return args.adar_color
if group == "APOBEC":
return args.apobec_color
return args.other_color
# -----------------------------------------------------------------------------
# Result figures
# -----------------------------------------------------------------------------
# The bar plot shows how many sites survived in each substitution class. The
# histogram plot shows the editing-level distribution for each class.
def make_bar_plot(class_counts: Counter, out_path: Path, args) -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
labels = sorted_event_labels(class_counts)
if not labels:
labels = ["no_passed_sites"]
values = [class_counts.get(label, 0) for label in labels]
colors = [event_color(label, args) for label in labels]
fig_width = max(7.0, 0.55 * len(labels) + 3.0)
fig, ax = plt.subplots(figsize=(fig_width, 4.8))
bars = ax.bar(labels, values, color=colors, edgecolor="black", linewidth=0.6)
ax.set_ylabel("Number of filtered editing sites")
ax.set_title("RNA editing event types")
ax.set_ylim(0, max(values + [1]) * 1.18)
ax.tick_params(axis="x", rotation=45)
for tick in ax.get_xticklabels():
tick.set_ha("right")
for bar, value in zip(bars, values):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), str(value), ha="center", va="bottom", fontsize=8)
fig.tight_layout()
fig.savefig(out_path, dpi=args.plot_dpi)
plt.close(fig)
def make_histogram_plot(edit_levels: Dict[str, List[float]], out_path: Path, args) -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
labels = sorted([label for label, values in edit_levels.items() if values], key=event_label_sort_key)
if not labels:
labels = ["no_passed_sites"]
bins = [i / args.hist_bins for i in range(args.hist_bins + 1)]
fig_height = max(3.0, 2.45 * len(labels))
fig, axes = plt.subplots(len(labels), 1, figsize=(7.5, fig_height), sharex=True)
if len(labels) == 1:
axes = [axes]
for ax, label in zip(axes, labels):
data = edit_levels.get(label, [])
ax.hist(data, bins=bins, color=event_color(label, args), edgecolor="black", linewidth=0.5)
ax.set_ylabel(f"{label}\ncount")
ax.text(0.98, 0.86, f"n={len(data)}", transform=ax.transAxes, ha="right", va="top")
ax.set_xlim(0, 1)
axes[-1].set_xlabel("Editing level")
fig.suptitle("Editing-level distributions by event type", y=0.995)
fig.tight_layout()
fig.savefig(out_path, dpi=args.plot_dpi)
plt.close(fig)
# -----------------------------------------------------------------------------
# Machine-readable summary
# -----------------------------------------------------------------------------
# The summary TSV is intentionally simple: total rows, pass/fail counts, event
# counts, filter-reason counts, and every parameter used in the run.
def write_summary(path: Path, stats: Counter, class_counts: Counter, group_counts: Counter, filter_counts: Counter, args) -> None:
with open(path, "w", newline="") as handle:
writer = csv.writer(handle, delimiter="\t")
writer.writerow(["metric", "value"])
for key in [
"input_rows",
"parse_error",
"failed_rows",
"passed_rows",
]:
writer.writerow([key, stats.get(key, 0)])
writer.writerow(["event_group_counts", ""])
for label in ["ADAR", "APOBEC", "Other"]:
writer.writerow([f"event_group_{label}", group_counts.get(label, 0)])
writer.writerow(["event_type_counts", ""])
for label in sorted_event_labels(class_counts):
writer.writerow([f"event_type_{label}", class_counts.get(label, 0)])
writer.writerow(["filter_reason_counts", ""])
for reason, count in filter_counts.most_common():
writer.writerow([f"filter_{reason}", count])
writer.writerow(["parameters", ""])
for key, value in sorted(vars(args).items()):
writer.writerow([f"param_{key}", value])
# -----------------------------------------------------------------------------
# Command-line interface
# -----------------------------------------------------------------------------
# Every biological choice is exposed as a parameter so the methods section can
# report exact thresholds and users can tune the pipeline for different datasets.
def parse_args(argv: Optional[Sequence[str]] = None):
parser = argparse.ArgumentParser(
description="Filter REDItools candidate RNA editing sites and classify exact event types with ADAR/APOBEC grouping.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
required = parser.add_argument_group("required inputs")
required.add_argument("--reditools", required=True, help="REDItools output table. Use '-' for stdin.")
required.add_argument("--repeatmasker", required=True, help="RepeatMasker .out, BED, or GFF-like repeat intervals.")
required.add_argument("--genome", required=True, help="Indexed genome FASTA.")
required.add_argument("--rna-bam", required=True, help="Indexed RNA-seq BAM.")
required.add_argument("--dna-bam", required=True, help="Indexed DNA-seq BAM.")
required.add_argument("--gtf", required=True, help="GTF annotation file.")
required.add_argument("--out-prefix", required=True, help="Output prefix, for example results/sample.")
parser.add_argument("--repeat-format", choices=["auto", "rmout", "repeatmasker", "bed", "gff"], default="auto", help="Repeat interval format. Use rmout/repeatmasker for standard RepeatMasker .out files.")
parser.add_argument("--overlap-feature", default="gene", help="GTF feature used for gene-model overlap. Falls back to exon spans if absent.")
filters = parser.add_argument_group("site filters")
filters.add_argument("--min-rna-coverage", type=int, default=10)
filters.add_argument("--min-dna-coverage", type=int, default=10)
filters.add_argument("--min-rna-meanq", type=float, default=25.0, help="Minimum REDItools RNA MeanQ.")
filters.add_argument("--min-dna-meanq", type=float, default=25.0, help="Minimum REDItools DNA gMeanQ.")
filters.add_argument("--use-dna-bam-counts-if-reditools-dna-missing", action="store_true", help="When REDItools genomic DNA fields are '-', count DNA bases directly from the DNA BAM instead of treating DNA evidence as zero coverage.")
filters.add_argument("--dna-variant-min-baseq", type=int, default=20, help="Minimum DNA BAM base quality used by --use-dna-bam-counts-if-reditools-dna-missing.")
filters.add_argument("--min-alt-count", type=int, default=3)
filters.add_argument("--min-edit-freq", type=float, default=0.01)
filters.add_argument("--max-dna-alt-freq", type=float, default=0.05)
filters.add_argument("--max-dna-alt-count", type=int, default=3)
filters.add_argument("--homopolymer-length", type=int, default=5, help="Exclude sites inside same-base runs at least this long. Use 0 to disable.")
filters.add_argument("--splice-flank", type=int, default=2, help="Exclude positions within this many bases of exon junction boundaries. Use 0 to disable.")
filters.add_argument("--allow-ref-mismatch", action="store_true", help="Do not fail sites when REDItools reference differs from FASTA.")
bam_filters = parser.add_argument_group("BAM mapping and indel filters")
bam_filters.add_argument("--min-mapq", type=int, default=30, help="Per-read MAPQ threshold used to compute good MAPQ fraction.")
bam_filters.add_argument("--min-mean-mapq", type=float, default=30.0, help="Minimum mean MAPQ at the site.")
bam_filters.add_argument("--min-good-mapq-fraction", type=float, default=0.80, help="Minimum fraction of usable reads with MAPQ >= min-mapq.")
bam_filters.add_argument("--indel-flank", type=int, default=5, help="Check indels in this many bases on each side of the site.")
bam_filters.add_argument("--max-indel-fraction", type=float, default=0.02, help="Maximum allowed indel-read fraction at any flanking pileup column.")
bam_filters.add_argument("--max-indel-reads", type=int, default=2, help="Maximum allowed indel-supporting reads at any flanking pileup column.")
bam_filters.add_argument("--max-pileup-depth", type=int, default=100000)
bam_filters.add_argument("--include-duplicates", action="store_true", help="Include duplicate reads in BAM pileup filters.")
bam_filters.add_argument("--include-qcfail", action="store_true", help="Include QC-fail reads in BAM pileup filters.")
bam_filters.add_argument("--skip-bam-mapq-filter", action="store_true", help="Skip mapping-quality checks from BAMs.")
bam_filters.add_argument("--skip-bam-indel-filter", action="store_true", help="Skip flanking-indel checks from BAMs.")
bam_filters.add_argument("--mismatch-flank", type=int, default=10, help="Check local reference-discordant mismatch density this many bases on each side of the candidate. The candidate base itself is excluded.")
bam_filters.add_argument("--mismatch-bams", choices=["rna", "dna", "both"], default="rna", help="Which BAMs to use for the local mismatch-density filter. RNA is usually most useful for IGV red-read artefacts.")
bam_filters.add_argument("--mismatch-min-baseq", type=int, default=20, help="Minimum base quality used by the local mismatch-density filter.")
bam_filters.add_argument("--max-window-mismatch-fraction", type=float, default=0.08, help="Maximum total mismatch fraction across the flanking window.")
bam_filters.add_argument("--max-column-mismatch-fraction", type=float, default=0.30, help="Maximum mismatch fraction allowed at any one flanking reference column.")
bam_filters.add_argument("--max-high-mismatch-columns", type=int, default=2, help="Maximum number of flanking columns whose mismatch fraction is above --max-column-mismatch-fraction.")
bam_filters.add_argument("--skip-bam-mismatch-filter", action="store_true", help="Skip local mismatch-density checks for possible mismapped reads.")
bam_filters.add_argument("--count-same-event-mismatches", action="store_true", help="Count nearby mismatches that match the candidate's transcript-oriented edit type in the local mismatch-density filter. By default these same-type changes are ignored to avoid penalizing real edited clusters.")
output = parser.add_argument_group("output and plots")
output.add_argument("--plot-format", choices=["png", "pdf", "svg"], default="png")
output.add_argument("--plot-dpi", type=int, default=180)
output.add_argument("--hist-bins", type=int, default=20)
output.add_argument("--adar-color", default="#3B82F6")
output.add_argument("--apobec-color", default="#F97316")
output.add_argument("--other-color", default="#9CA3AF")
output.add_argument("--write-failures", action="store_true", help="Write a TSV of filtered-out sites and their reasons. This can be large.")
output.add_argument("--max-sites", type=int, default=0, help="Process at most this many REDItools rows. Use 0 for all rows.")
output.add_argument("--progress-every", type=int, default=100000, help="Log progress every N rows. Use 0 to disable.")
output.add_argument("--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR"], default="INFO")
args = parser.parse_args(argv)
if args.homopolymer_length < 0:
parser.error("--homopolymer-length cannot be negative")
if args.splice_flank < 0:
parser.error("--splice-flank cannot be negative")
if args.hist_bins < 1:
parser.error("--hist-bins must be positive")
if not (0 <= args.min_edit_freq <= 1):
parser.error("--min-edit-freq must be between 0 and 1")
if not (0 <= args.max_dna_alt_freq <= 1):
parser.error("--max-dna-alt-freq must be between 0 and 1")
if args.dna_variant_min_baseq < 0:
parser.error("--dna-variant-min-baseq must be non-negative")
if not (0 <= args.max_indel_fraction <= 1):
parser.error("--max-indel-fraction must be between 0 and 1")
if not (0 <= args.min_good_mapq_fraction <= 1):
parser.error("--min-good-mapq-fraction must be between 0 and 1")
if args.mismatch_flank < 0:
parser.error("--mismatch-flank cannot be negative")
if args.mismatch_min_baseq < 0:
parser.error("--mismatch-min-baseq must be non-negative")
if not (0 <= args.max_window_mismatch_fraction <= 1):
parser.error("--max-window-mismatch-fraction must be between 0 and 1")
if not (0 <= args.max_column_mismatch_fraction <= 1):
parser.error("--max-column-mismatch-fraction must be between 0 and 1")
if args.max_high_mismatch_columns < 0:
parser.error("--max-high-mismatch-columns cannot be negative")
return args
# -----------------------------------------------------------------------------
# Pipeline driver
# -----------------------------------------------------------------------------
# run() validates inputs, loads annotations, streams candidate rows, writes the
# filtered table, accumulates summary counters, and generates the result figures.
def run(args) -> None:
validate_file(args.reditools, "REDItools input", allow_stdin=True)
validate_file(args.repeatmasker, "RepeatMasker input")
validate_file(args.genome, "genome FASTA")
validate_file(args.rna_bam, "RNA BAM")
validate_file(args.dna_bam, "DNA BAM")
validate_file(args.gtf, "GTF annotation")
out_prefix = Path(args.out_prefix)
out_prefix.parent.mkdir(parents=True, exist_ok=True)
fasta = open_fasta(args.genome)
fasta_resolver = ContigResolver(fasta.references)
rna_bam = open_bam(args.rna_bam, "RNA")
dna_bam = open_bam(args.dna_bam, "DNA")
bam_contexts = [
BamContext("rna", rna_bam, ContigResolver(rna_bam.references)),
BamContext("dna", dna_bam, ContigResolver(dna_bam.references)),
]
logging.info("Loading GTF annotation")
gtf_data = load_gtf(args.gtf, fasta_resolver, args.overlap_feature, args.splice_flank)
logging.info(
"Loaded %s gene intervals and %s splice-site intervals",
gtf_data.loaded_gene_intervals,
gtf_data.loaded_splice_intervals,
)
logging.info("Loading repeats")
repeats = load_repeats(args.repeatmasker, fasta_resolver, args.repeat_format)
passed_path = out_prefix.with_suffix(".filtered_edits.tsv")
summary_path = out_prefix.with_suffix(".summary.tsv")
failures_path = out_prefix.with_suffix(".filtered_out_sites.tsv")
bar_path = out_prefix.with_suffix(f".event_class_counts.{args.plot_format}")
hist_path = out_prefix.with_suffix(f".editing_level_histograms.{args.plot_format}")
stats = Counter()
filter_counts = Counter()
class_counts = Counter()
group_counts = Counter()
edit_levels: Dict[str, List[float]] = defaultdict(list)
output_fields = [
"chrom",
"position_1based",
"position_0based",
"reference",
"genome_reference",
"alt_base",
"genomic_edit",
"transcript_edit",
"event_type",
"event_group",
"event_label",
"event_class",
"gene_ids",
"gene_names",
"gene_strand_used",
"rna_coverage_q30",
"rna_count_depth",
"rna_alt_count",
"editing_level",
"secondary_rna_alts",
"rna_mean_base_quality",
"dna_coverage_q30",
"dna_count_depth",
"dna_alt_count_same_base",
"dna_alt_frequency_same_base",
"dna_mean_base_quality",
"dna_count_source",
"homopolymer_length",
"rna_bam_depth",
"rna_mean_mapq",
"rna_good_mapq_fraction",
"dna_bam_depth",
"dna_mean_mapq",
"dna_good_mapq_fraction",
"rna_max_indel_fraction",
"rna_max_indel_reads",
"dna_max_indel_fraction",
"dna_max_indel_reads",
"rna_window_mismatch_fraction",
"rna_max_mismatch_fraction",
"rna_high_mismatch_columns",
"rna_mismatch_columns_seen",
"rna_mismatch_bases",
"rna_mismatch_window_bases",
"dna_window_mismatch_fraction",
"dna_max_mismatch_fraction",
"dna_high_mismatch_columns",
"dna_mismatch_columns_seen",
"dna_mismatch_bases",
"dna_mismatch_window_bases",
"reditools_allsubs",
"reditools_frequency",
"reditools_gallsubs",
"reditools_gfrequency",
]
failure_fields = ["line_no", "chrom", "position_1based", "reference", "filter_reasons"]
with open(passed_path, "w", newline="") as passed_handle:
passed_writer = csv.DictWriter(passed_handle, delimiter="\t", fieldnames=output_fields, extrasaction="ignore")
passed_writer.writeheader()
failure_handle = None
failure_writer = None
if args.write_failures:
failure_handle = open(failures_path, "w", newline="")
failure_writer = csv.DictWriter(failure_handle, delimiter="\t", fieldnames=failure_fields, extrasaction="ignore")
failure_writer.writeheader()
try:
for line_no, row in parse_reditools(args.reditools):
stats["input_rows"] += 1
if args.max_sites and stats["input_rows"] > args.max_sites:
break
if args.progress_every and stats["input_rows"] % args.progress_every == 0:
logging.info(
"Processed %s rows, passed %s",
stats["input_rows"],
stats["passed_rows"],
)
evaluation = evaluate_site(
row=row,
line_no=line_no,
fasta=fasta,
fasta_resolver=fasta_resolver,
repeats=repeats,
gtf_data=gtf_data,
bam_contexts=bam_contexts,
args=args,
)
if evaluation.passed and evaluation.record:
passed_writer.writerow(evaluation.record)
stats["passed_rows"] += 1
class_counts[evaluation.event_class or "Other"] += 1
group_counts[evaluation.event_group or event_group_from_label(evaluation.event_class or "Other")] += 1
if evaluation.editing_level is not None:
edit_levels[evaluation.event_class or "Other"].append(evaluation.editing_level)
else:
stats["failed_rows"] += 1
if "parse_error" in evaluation.reasons:
stats["parse_error"] += 1
filter_counts.update(evaluation.reasons)
if failure_writer is not None:
failure_writer.writerow(
{
"line_no": line_no,
"chrom": row.get("Region", "NA"),
"position_1based": row.get("Position", "NA"),
"reference": row.get("Reference", "NA"),
"filter_reasons": ";".join(evaluation.reasons),
}
)
finally:
if failure_handle is not None:
failure_handle.close()
fasta.close()
rna_bam.close()
dna_bam.close()
write_summary(summary_path, stats, class_counts, group_counts, filter_counts, args)
make_bar_plot(class_counts, bar_path, args)
make_histogram_plot(edit_levels, hist_path, args)
logging.info("Wrote filtered edits: %s", passed_path)
logging.info("Wrote summary: %s", summary_path)
if args.write_failures:
logging.info("Wrote filtered-out sites: %s", failures_path)
logging.info("Wrote plot: %s", bar_path)
logging.info("Wrote plot: %s", hist_path)
def main(argv: Optional[Sequence[str]] = None) -> int:
args = parse_args(argv)
logging.basicConfig(
level=getattr(logging, args.log_level),
format="[%(asctime)s] %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
try:
run(args)
except KeyboardInterrupt:
logging.error("Interrupted by user.")
return 130
except EditPipelineError as exc:
logging.error("%s", exc)
return 2
except BrokenPipeError:
return 1
except Exception as exc:
logging.exception("Unexpected error: %s", exc)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
```
</details>
No comments:
Post a Comment