Skip to content

feat: pwm.grad and pwm.grad.ism per-bp gradient vtracks - #111

Open
aviezerl wants to merge 15 commits into
masterfrom
feat/pwm-grad
Open

feat: pwm.grad and pwm.grad.ism per-bp gradient vtracks#111
aviezerl wants to merge 15 commits into
masterfrom
feat/pwm-grad

Conversation

@aviezerl

@aviezerl aviezerl commented May 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two new sequence-based virtual tracks expose per-bp gradient / saliency of the PWM aggregate (LSE or MAX) at the iterator interval start:

  • pwm.grad — linearized gradient (DeepLIFT-style). For LSE: g(p) = w_p * (M[0, b_p] - min_b M[0, b]) where w_p = exp(score_p - f_LSE). For MAX: same diff if argmax is the head anchor, else 0.
  • pwm.grad.ism — in-silico mutagenesis: g(p) = f(actual) - min_b' f(seq with seq[p] := b'). Computed in O(1) per pivot using pre-scanned best_no_head / lse_no_head (only the head anchor's per-anchor score changes under a flip).

Both support aggregate = "lse" | "max", all strand modes (bidirect / fwd / rc), spatial weighting, prior, and extend. Pivot is fixed at the iterator interval start for v1.

Spec: dev/notes/features/2026-05-07_pwm-gradient-vtrack-design.md
Plan: dev/notes/features/2026-05-07_pwm-gradient-vtrack-plan.md

Test plan

  • R-side oracle (tests/testthat/helper-pwm-grad-oracle.R) for both linearized and ISM, validated on trivial inputs.
  • Engine tests for all 4 modes × strand combinations against the oracle (65 tests in tests/testthat/test-vtrack-pwm-grad.R).
  • (B)-vs-(A) divergence test on a periodic test sequence where flipping the head base doesn't drop the max.
  • Spatial weighting (spat_factor, spat_bin) + invariance under all-1 spat.
  • NA / multi-chromosome / interval-shorter-than-L edge cases.
  • Composition with pwm, gscreen, gsummary.
  • Regression: 1439 PWM-related tests pass; existing pwm, pwm.max, pwm.count, sliding-window paths unchanged.
  • Vignette vignettes/PWM-Functions.Rmd builds (verified via rmarkdown::render; full devtools::build_vignettes to be exercised by CI).
  • Performance smoke: benchmark script in dev/benchmarks/2026-05-07_pwm-grad-bench.R to be run on a real genome DB.

Notes

  • Sliding-window cache is intentionally disabled for grad modes (each pivot does a fresh scan). Could be optimized later if profiling shows it's hot.
  • For strand=-1, the iterator-position spat_factor index follows the rc'd-target order (matches existing engine convention; same for pwm.count).

@aviezerl

Copy link
Copy Markdown
Collaborator Author

Code review

Found 2 issues:

  1. New gvtrack.filter tests are missing for pwm.grad / pwm.grad.ism. The new-vtrack skill at dev/skills/adding-new-vtrack.md step 10 explicitly requires: "Filters: gvtrack.filter restricts data correctly". test-vtrack-lse.R includes such tests; the new test-vtrack-pwm-grad.R has none. This matters because of issue 2 below.

tests/testthat/test-vtrack-pwm-grad.R

  1. Multi-part filter aggregation falls through to additive summation for the new PWM_GRAD / PWM_GRAD_ISM modes in src/SequenceVarProcessor.cpp. When gvtrack.filter splits an iterator interval into multiple parts, each part is independently re-scored with head_idx re-anchored to that part's start (since score_grad uses target[0] / target[tlen-1] as the pivot). Summing per-bp gradients computed at different anchor positions is not meaningful. The switch handles PWM_MAX, PWM_MAX_POS, PWM_COUNT explicitly; the gradient modes need either an explicit branch (e.g. score only the part containing the original interval.start, or return NaN when the pivot is masked) or a deliberate documented choice. The missing test in (1) is why this slipped through.

src/SequenceVarProcessor.cpp#L420-L440

aviezerl added 15 commits May 12, 2026 13:50
Add API skeleton (Task 1 of the PWM gradient vtrack plan):
- R: .vtrack_params_pwm_grad validator (rejects score.thresh, defaults
  aggregate to 'lse', validates 'lse'|'max'); register pwm.grad and
  pwm.grad.ism in dispatch list and sourceless funcs; doc table rows.
- C++ PWMScorer: 4 new ScoringMode entries (GRAD_LSE, GRAD_MAX,
  GRAD_LSE_ISM, GRAD_MAX_ISM); early-out stub in score_interval throws
  rdb::verror until subsequent tasks implement them.
- C++ Track_var: PWM_GRAD / PWM_GRAD_ISM enum entries + matching
  FUNC_NAMES strings; is_seq_variable, is_sequence_based_function and
  is_pwm_function extended so SequenceVarProcessor classifies the new
  vtracks under the PWM path and routes through pwm_scorer.
- C++ add_vtrack_var: dispatch parses pwm.grad / pwm.grad.ism (with
  optional 'aggregate' rparam) and constructs PWMScorer with the right
  ScoringMode.

End state: gvtrack.create('g', NULL, 'pwm.grad', pssm = ..., aggregate = 'lse')
parses, gvtrack.ls() shows the vtrack, validators reject score.thresh and
bad aggregate values, and gextract on a real DB throws
"PWMScorer: gradient modes not yet implemented" until Tasks 3-9 land.
Implements the linearized MAX-mode gradient (Task 3 of the pwm.grad plan)
for the simplest case: bidirect=FALSE, strand=1, no spatial weighting.

- Precompute m_worst_col0_fwd / m_worst_col_last_fwd at construction.
- Route GRAD_MAX through a dedicated score_grad_max() that scans the
  iterator-clamped anchor range [i_min, i_max] (matching pwm.max's
  argmax convention) and returns M[0, b_p] - worst_col0 at the head
  anchor, 0 elsewhere.
- Reject bidirect=TRUE / strand=-1 with verror BEFORE the try/catch in
  score_interval so the error reaches R instead of being squashed to NaN.
- Skip the sliding-window dispatch for all four gradient modes; defer
  spatial weighting to Task 9 (NaN for now under m_use_spat).

Tests verify both head=argmax (positive gradient matching the oracle) and
head!=argmax (engine returns 0, oracle agrees), plus the two error paths.
Unifies the GRAD_MAX and GRAD_LSE answer paths under score_grad_linearized,
using score_forward_original / score_reverse_original so the rc'd target
(when strand_mode == -1) is canonicalized to fwd-genome semantics. Bidirect
combines per-strand head scores via softmax. Tie-break in argmax favors the
head anchor to match the oracle's R which.max convention.
Extends score_grad to handle ism=true: at the head anchor, only the head's
per-anchor score changes under a base flip, so 3 alternative aggregates can
be computed in O(1) using the pre-scanned best_no_head and lse_no_head.
Covers both LSE and MAX aggregations and all strand modes (bidirect, fwd,
rc). Renames score_grad_linearized to score_grad and unifies the four grad
modes through one function.

Tests: ISM-vs-linearized divergence (argmax shift on a periodic test seq),
ISM bidirect on asymmetric PSSMs, ISM strand=-1, both LSE and MAX.
Per-anchor spat_log is added to each anchor's score in the scan; ISM uses
the head's spat_log to keep the flipped per-anchor score on the same scale
as best_no_head and lse_no_head. Tests: spat_factor=1 invariance and
non-trivial spat with both lin and ism.
New vignettes/PWM-Functions.Rmd covering the existing PWM aggregating
tracks (pwm, pwm.max, pwm.max.pos, pwm.count), edit-distance variants,
and the new per-position attribution tracks pwm.grad / pwm.grad.ism.
Math sections evaluate as pure-R demos (no genome DB needed); genome
examples use eval=FALSE.

Adds an articles: section to _pkgdown.yml listing the new vignette
alongside the existing ones.
Cache per-anchor (fwd, rc) raw scores in a deque. On consecutive iterator
steps with matching chrom/strand/window, slide the deque (pop trailing,
score+push leading) instead of doing a full O(W*L) rescan. The aggregate is
still recomputed from the deque per pivot (O(W)), so ISM_MAX which needs
best_no_head doesn't require a second monotonic deque.

Bench (HOMER.CTCF, mm10 chr1:3M, 100 kb, W=500, iter=1):
  pwm        0.11s  (1.00x)
  pwm.grad   1.47s (13.25x; was 66.58x)

Full O(1) running aggregates (RunningLogSumExp + dual RunningMaxDeque) would
buy another ~5-10x but require a sliding-window 'max excluding the head' deque
that's tricky for ISM_MAX.
- Add a dedicated 'Strands and bidirect' section that lays out the
  per-anchor score formulas, the bidirect=TRUE strand-union, and the
  position-reporting convention (pwm.max.pos sign, grad pivot at
  interval.start in fwd-genome coords).
- Drop the 'Why LSE rather than just MAX?' subsection.
- Trim gradient-directed framing in the intro and pwm/pwm.max sections.
- Shorten the gradient section's motivation; one-line strand handling
  reference points to the new section.
The PWM is a linear log-likelihood model, so the 'gradient' is just a
PSSM column lookup; integrated-gradients / DeepLIFT contribution rules
don't apply (they collapse to gradient × input for linear models).
ISM is standard in motif analysis (e.g. motifbreakR for SNPs).

Renaming the user-facing description to 'per-bp PSSM-column
contribution' (with explicit 'softmax-weighted' for LSE and
'argmax-conditioned' for MAX) is more accurate than 'gradient /
saliency / DeepLIFT-style attribution'. The tracks themselves stay
named pwm.grad / pwm.grad.ism.
PWM_GRAD / PWM_GRAD_ISM are anchored at seq_interval.start, so the
multi-part filter aggregation that summed scores across disconnected
unmasked parts mixed gradients at different genomic anchors. The single-
part fallback had the same problem when the mask covered the pivot.

Score only the unmasked part that starts at seq_interval.start; return
NaN when the pivot is masked. Add filter tests for both pwm.grad and
pwm.grad.ism (mask pivot, multi-part mask, non-intersecting mask).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant