scraps

De novo RT priming site discovery

Use validated poly(A) sites for quantitative analyses. scraps quantifies poly(A) usage against curated, validated sites (polyAdb) by default, and this remains the recommended reference for all downstream quantitative work (per-cell counting, PSI, differential poly(A) usage). The discovery module described here is for hypothesis generation and novel-site nomination. Its output is a set of candidate sites that require orthogonal validation, and includes sites with a possible internal-priming signature that cannot be resolved computationally and should be interpreted with caution.

Motivation

scraps maps reverse-transcription (RT) priming sites at single-base resolution. By default it quantifies these priming events only at validated polyAdb cleavage/polyadenylation (C/PA) sites, because internal priming (RT priming off genomically-encoded A-rich stretches rather than a true poly(A) tail) is a major artifact of oligo-dT priming and is genuinely hard to identify from sequence alone.

The default approach is safe but underuses a key strength of scraps: its single-base resolution makes it possible to discover priming sites directly from the data. This module calls RT priming sites de novo and then annotates each one, separating validated/candidate C/PA sites from likely artifacts.

What it does

  1. Pseudobulk, strand-aware, single-base priming signal. For the chosen alignment mode (DISCOVERY.read), the featureCounts output BAM (temp/{sample}_{mode}_assigned.bam: all mapped reads, not only polyAdb-assigned ones) is collapsed by inst/scripts/pileup_sites.py into single-base, strand-aware priming counts. Each count is the number of distinct (cell barcode, UMI) pairs at that priming position (exact UMI match); the opposite/fragmentation end is ignored, so PCR duplicates of one molecule count once. Reads used: R2 = mapped reads; R1 = mapped reads (read1 of a proper pair if the BAM is paired); paired = read1 of proper pairs; bam = the primary single-end records left by filter_bam. Cell identity is intentionally collapsed — discovery operates on pseudobulk.

    The piled-up read end can be offset per mode via {mode}_priming_offset (resolved like any other key: sample → platform → chemistry → DEFAULTS; e.g. R2_priming_offset: 5 for ultima_fastq, whose reads end 5 bp short of the priming site, or bam_priming_offset for the bam mode): the stranded_bed rule passes it as pileup_sites.py --shift N, moving the counted end N bp outward past the read end (the read’s 3’ direction for 3’-end modes, its 5’ direction for 5’-end modes), the same shift assign_sites_R2 / assign_sites_bam apply in featureCounts (--readShiftType downstream / upstream), so discovery and quantitation use one site coordinate. Only R2 and bam honour an offset; the R1/paired count rules apply no shift, so R1_/paired_priming_offset are ignored.

    The emitted strand is the transcript strand, not the read’s own aligned strand. pileup_sites.py keys each read by its aligned strand and prints the transcript strand given by --label-plus/--label-minus (set per mode in rules/discovery.snake, _disc_labels), converted per alignment mode to match the validated featureCounts strandedness used for quantitation (rules/count.snake): R2 is same-strand (-s 1), so the read-aligned strand already equals the transcript strand; R1 and paired are reverse-stranded (-s 2), so the read-aligned strand is the opposite of the transcript strand and is flipped when the stranded bed is written (rules/discovery.snake, stranded_bed). The pre-aligned bam mode follows the sample’s bam_strand: same behaves like R2 (3’ end, no flip), reverse like R1/paired (5’ end, flipped). All downstream steps (KDE per (chrom, strand), polyAdb matching, sequence-context scanning, SAF slop) are strand-aware and consume this transcript strand.

  2. Discovery modes. DISCOVERY.read selects which alignment mode(s) feed discovery and accepts a single mode or a list (e.g. [R2, paired]). A sample runs only the modes it was actually aligned in (its alignments), and an individual sample can override the global list via SAMPLES.<name>.discovery_read. If read is unset, the sample’s own alignments are used. Each (sample, mode) produces an independent discovery track named <sample>_<mode> (e.g. AEG1_R2_sites.tsv.gz).

  3. Optional pooling. Samples can be pooled into named groups (summed across member samples per shared mode -> <group>_<mode> tracks). Ungrouped samples are processed individually. For arbitrary aggregation, DISCOVERY.units pools an explicit list of (sample, mode) beds into one named track — across samples and/or modes. Because stranded beds share a common transcript-oriented coordinate system, any set can be summed. Caveat: pooling multiple modes derived from the same underlying reads double-counts UMI support (one molecule counted under each mode); only pool modes that are independent measurements.

  4. Kernel density estimation (KDE). Per (chromosome, strand) track, the UMI-weighted per-base signal is smoothed with a Gaussian kernel (kde_bandwidth, in bp). Local maxima of the density are called as RT priming sites; maxima within peak_merge_dist are merged. Summit placement within a merged cluster is controlled by summit_method (default downstream): 3’-end pileups have a hard cleavage boundary and tail upstream, so the summit is the most-downstream (transcript-3’) base whose depth is >= summit_frac * cluster peak depth, snapping it onto the cleavage site rather than the smoothed centre of mass (kde reproduces the legacy density-maximum summit). (inst/scripts/kde_peaks.py)

    Because discovery is pseudobulk — UMIs are deduplicated per cell, then summed across all cells and (with groups) across pooled samples — a raw UMI count of 1–2 at a base is background, not signal. Each merged peak (cluster) owns a disjoint extent: the run of occupied bases chained within peak_merge_dist that contains its density maxima, split from a neighbouring cluster in the same run at the lowest-density base between them. umi_support is the raw UMI sum over that extent, the summit is searched within it (so summits are unique per track), and a peak is kept only if its support clears

    umi_support >= max(min_umi, min_umi_frac * T)
    

    where T is the total UMI count across the whole (pooled) input. The primary control on peak count is the absolute floor min_umi. The depth-relative term min_umi_frac * T is off by default (min_umi_frac: 0): because it scales linearly with total depth, on deep or pooled data it imposes a very high absolute bar (e.g. 1e-6 * 1e8 = 100 UMIs/peak) that silently drops usable mid-abundance peaks. Set a small positive min_umi_frac only if you deliberately want the bar to scale with depth. min_density is the shape gate: by default it is derived from kde_bandwidth (≈ 2 UMIs concentrated within one bandwidth) so that isolated single-UMI bumps cannot form a called maximum; set a positive value to override. (Density is evaluated sparsely, only at occupied bases.)

  5. Annotation into three categories. Each summit is classified (inst/scripts/annotate_sites.py):

    Status Meaning Use
    known_pas Summit falls within the strand-aware match_window of a validated polyAdb cleavage point Validated
    suspected_internal_priming Downstream genomic A-content / A-run: either oligo-dT priming off a templated A-stretch or a genuine cleavage site in an A-tract (indistinguishable from sequence) Ambiguous — interpret with caution; do not discard by default
    potential_novel_pas No A-rich signature and a canonical poly(A) signal (PAS) motif present upstream Candidate — validate before use
    potential_novel_no_pas No A-rich signature and no PAS motif upstream Lower-confidence candidate — validate before use

How sites are classified

Reference SAF format

The polyAdb reference supplied as POLYA_SITES (and the de novo SAF emitted by discovery) is a tab-separated table with header GeneID Chr Start End Strand. The GeneID encodes seven fields:

gene_symbol{D}refseq_gene_id{D}ensembl_id{D}chrom{D}pos{D}strand{D}pas_type
Field Index Meaning
gene_symbol 1 gene symbol (or NA)
refseq_gene_id 2 RefSeq/Entrez gene id (or NA)
ensembl_id 3 Ensembl gene id (or NA)
chrom 4 chromosome
pos 5 1-based single-base cleavage position (used for matching)
strand 6 + / -
pas_type 7 site class (passed through in POLYA_SITES; not used by discovery)

Mouse _ delimiter caveat: RefSeq IDs (e.g. NR_152944) and scaffold chromosome names (e.g. chrUn_GL456...) contain underscores, so a naive split of a mouse GeneID over-counts fields. scraps parses these robustly by anchoring on the SAF Chr/Strand columns and reading the trailing chrom/pos/strand/class fields from the right; the leading gene/refseq/ensembl group is passthrough metadata only.

PAS type vocabulary differs by release (labels are passed through verbatim by the converters): polyAdb 3.2 uses values like 3'UTR(M), 3'UTR(L), Intron, intergenic, Pseudogene, LncRNA(FANTOM5); polyAdb 4 main uses 3'UTR, Intron, Intergenic, 5'UTR, Downstream, Upstream, 3'-most exon, Single exon; polyAdb 4 max has no PAS type (NA). Code that filters by class (e.g. parse_saf(types=...)) must account for this when mixing releases.

Generating references: converters for polyAdb 3.2 (*.PAS.txt, with liftOver) and polyAdb 4 (*.PAS.{main,max}.tsv) live in inst/scripts/polyadb/; see its README for usage.

Caveats and limitations

Enabling discovery

In config.yaml:

GENOME_FASTA: "ref/genome.fa"     # must match the alignment reference; needs .fai

DISCOVERY:
  enabled: true
  read: R2                        # single mode OR a list, e.g. [R2, paired];
                                  # per-sample override: SAMPLES.<name>.discovery_read
                                  # unset -> use each sample's own `alignments`.
                                  # Each (sample, mode) -> track "<sample>_<mode>".
  groups:                         # optional; pool member samples PER shared mode
    groupA: [chromiumv2_test, dropseq_test]   # -> groupA_<mode> tracks
  units:                          # optional; aggregate arbitrary (sample, mode)
    cv2_allmodes:                 # beds (cross-sample and/or cross-mode)
      - [chromiumv2_test, R2]
      - [chromiumv2_test, paired]
  kde_bandwidth: 10
  min_umi: 10                     # primary absolute support floor
  min_umi_frac: 0.0               # optional depth-relative term, off by default
                                  # (support >= max(min_umi, min_umi_frac * total_UMIs))
  min_density: 0.0                # 0 / omit -> derived from kde_bandwidth
  peak_merge_dist: 24
  summit_method: downstream       # downstream (default) | kde
  summit_frac: 0.75               # downstream-method depth threshold
  match_window: [10, 5]           # [upstream, downstream] bp, transcript-oriented
  saf_slop: [10, 5]              # de novo SAF window; matches provided polyAdb slop
  pas_window: [-40, 0]
  a_content_window: [1, 20]
  a_content_max: 0.7

match_window and saf_slop are both transcript-oriented [upstream, downstream] pairs. match_window controls how a discovered summit is matched to a known polyAdb cleavage point (asymmetric, see above); saf_slop controls the window written around each summit in the output de novo SAF. The defaults [10, 5] reproduce the -10/+5 slop of the bundled polyAdb references, so a de novo SAF re-used as POLYA_SITES produces feature windows consistent with the originals.

GENOME_FASTA is required when DISCOVERY.enabled is true (the pipeline will error otherwise) and must be indexed:

samtools faidx ref/genome.fa

Then run as usual; discovery targets are added automatically:

snakemake -np --configfile config.yaml          # validate
snakemake -j 24 --configfile config.yaml --keep-going  # run (-j/--cores is required)

Outputs

For each discovery unit (<sample>_<mode>, <group>_<mode>, or an explicit DISCOVERY.units name) under {RESULTS}/discovery/:

Re-quantifying against discovered sites (advanced)

Because <unit>_denovo.saf.gz uses the standard scraps SAF encoding, it can be supplied as POLYA_SITES to re-run the normal per-cell quantitation against the discovered sites — no code changes required:

POLYA_SITES: "results/discovery/groupA_R2_denovo.saf.gz"   # units are mode-suffixed

Before doing this, decide how to treat each class. Keep or drop suspected_internal_priming features deliberately (ideally run both and compare), and treat potential_novel_pas and potential_novel_no_pas features as unvalidated. For any publication-grade quantitative analysis, prefer the curated polyAdb reference. Re-quantifying against de novo sites is appropriate for exploratory work and for evaluating nominated novel sites, not as a default replacement for validated sites.

Implementation

Component Location
Snakemake rules rules/discovery.snake
Unit resolution (DISC_UNITS, _sample_disc_modes, _priming_offset) Snakefile
Stranded single-base pileup (stranded_bed) inst/scripts/pileup_sites.py
KDE peak calling inst/scripts/kde_peaks.py
Annotation + SAF inst/scripts/annotate_sites.py
Config config.yaml (GENOME_FASTA, DISCOVERY); chemistry.yaml / samples ({mode}_priming_offset)