Skip to content

Extract R analysis into modular backend abstraction - #153

Open
sanghoonio wants to merge 16 commits into
devfrom
modular-backend-r
Open

Extract R analysis into modular backend abstraction#153
sanghoonio wants to merge 16 commits into
devfrom
modular-backend-r

Conversation

@sanghoonio

@sanghoonio sanghoonio commented Mar 27, 2026

Copy link
Copy Markdown
Member

Summary

Refactor bedboss bedstat into a modular backend system. Zero behavior change — all existing R-based analysis works identically.

What changed

New package: bedboss/bedstat/backends/

  • base.py: StatBackend ABC with compute() and cleanup() methods, context manager protocol
  • r_backend.py: RStatBackend — extracts current R logic from bedstat.py (move, not rewrite)
  • __init__.py: create_backend(name) low-level factory, build_backend(name) high-level constructor that handles backend-specific prerequisites (e.g. starts RServiceManager for R)

Refactored orchestration:

  • bedstat.py: signature changed from bedstat(backend="r", r_service=..., ...) to bedstat(backend: StatBackend, ...) — takes an instance, not a string
  • bedboss.py, insert_pep, reprocess_all, upload_all: build one backend at batch-orchestrator level via build_backend(), pass through to bedstat() for each file, cleanup when done
  • cli.py: standalone bedstat command uses with block for automatic cleanup

Why this matters:
Backend instances hold resources across a batch (R keeps its subprocess alive, future backends can cache reference data). The old pattern reconstructed backends per file, which prevented cross-file amortization.

Constants

Test plan

  • All existing behavior preserved — R backend is a pure extraction
  • RServiceManager lifecycle unchanged (created in build_backend, cleaned up by caller)
  • Black formatted

🤖 Generated with Claude Code

Create bedboss/bedstat/backends/ package with StatBackend ABC and
RStatBackend implementation. Move R logic from bedstat.py into
r_backend.py (no rewrite, just extraction). bedstat() becomes a thin
dispatcher that delegates to the configured backend via factory.

bedboss.py reads backend from bbagent.config.config.analysis.backend
and passes it through. All R scripts and r_service.py untouched.
Identical behavior when backend="r" (the default).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
sanghoonio and others added 3 commits April 3, 2026 15:02
Move magic strings "r" and "gtars" to BACKEND_R / BACKEND_GTARS
constants in const.py. Also fixes black formatting in __init__.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New files:
- gtars_backend.py: GtarsStatBackend wrapping gtars genomicdist CLI
- compress_distributions.py: fixed-size compression (histograms, KDE, dense arrays)
- ref_utils.py: reference file resolution via refgenie + seqcol fallback

Changes:
- Register GtarsStatBackend in factory (replaces NotImplementedError)
- Only create RServiceManager when backend is "r"
- Skip R bedset plots when backend is "gtars"
- Add --backend CLI override for run_all and run_stats
- Add DEFAULT_PRECISION constant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reorder chrom.sizes resolution: local refgenie cache → seqcol API
(~50KB) → refgenie FASTA pull (~3GB). Avoids downloading a full
genome FASTA just to get chromosome sizes on cold start.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sanghoonio sanghoonio mentioned this pull request Apr 3, 2026
5 tasks
sanghoonio and others added 11 commits April 3, 2026 23:38
R stores partition percentages as fractions (0.0615), not percent
(6.082). Remove the * 100 multiplier so gtars output matches the
existing database convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Derives median absolute neighbor distance from the gtars CLI's raw
neighbor_distances list, stores it in the data dict so it gets written
to the new BedStats.median_neighbor_distance column (added in bbconf
PR #113).

Computed BEFORE compress_distributions replaces the flat list with a
KDE. The per-file KDE is still stored in the distributions JSONB blob
for single-file views; only the scalar is used for bedset aggregation
(mean ± sd across files).

Replaces what used to be an aggregated neighbor_distances KDE at the
bedset level — per earlier discussion, per-file KDE variance is low
within bedsets (assay type dominates), and a scalar median gives
enough signal at collection level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New 'gtars-py' backend that uses gtars Python bindings directly, no
subprocess. Exists alongside the existing 'gtars' CLI-subprocess
backend so we can benchmark both on real data and keep only the
better performer before merge.

Architecture:
- GtarsPyStatBackend in backends/gtars_py_backend.py
- GenomeRefs dataclass caches reference data per genome on the backend
  instance (FASTA, chrom_sizes, GeneModel, PartitionList, TssIndex,
  SignalMatrix). Loaded lazily on first compute() call for a given
  genome, reused for all subsequent files.
- Same compute() signature as GtarsStatBackend
- Same output dict shape (compress_distributions + DB insertion unchanged)
- Same pypiper integration for status tracking (pm.timestamp markers
  between Python steps, no subprocess wrapping)
- median_neighbor_distance scalar derived same way

Factory dispatch: create_backend("gtars-py") returns the new backend.
Extended bbconf's analysis.backend Literal to accept "gtars-py" (bbconf
commit on modular-backend-logic branch).

Output shape normalization: the gtars Python binding returns
calc_partitions as {partition: [names], count: [counts], total: n} but
the CLI JSON schema has {counts: [[name, count], ...], total: n}.
_normalize_partitions() converts between them so downstream code sees
the same shape regardless of backend.

Parity verified on a 126K-region hg38 ENCODE BED file:
- Scalars identical (number_of_regions, mean_region_width,
  median_tss_dist, median_neighbor_distance, gc_content)
- All 14 partition fields (frequency+percentage for 7 categories) match
- widths, tss, neighbor_distances, region_distribution bins byte-match
  after compress_distributions

Performance (5 ENCODE files, hg38):
- gtars (CLI):    total 11.15s, median per-file 2.55s
- gtars-py:       total 5.71s,  median per-file 1.15s
- Speedup:        ~2x batch, ~2.2x per-file median
- Biggest wins: FASTA loaded once (not per file), no subprocess startup,
  no JSON round-trip to disk

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Architecture change: batch orchestrators build a stats backend once and
pass it through the call chain as a StatBackend instance, instead of
creating a new backend per file inside bedstat().

Before: bedstat(backend="r", r_service=..., ...) reconstructed the
backend on every call. Fine for R because r_service was passed IN and
the RStatBackend constructor was cheap, but broken for future backends
that need per-instance caches (GtarsPyStatBackend's reference cache
would be wiped between files).

After: callers build one backend for their batch, pass it to bedstat()
for each file, and cleanup() at the end. Backend-internal resources
(R service, cached references) are held on the backend instance and
amortized across the whole batch.

Changes:
- bedstat(): signature takes `backend: StatBackend` instead of
  `backend: str + r_service`; no longer calls create_backend internally
- Add build_backend(name) helper to backends/__init__.py that hides
  backend-specific prerequisites (e.g. starts RServiceManager for "r")
- StatBackend base: add __enter__/__exit__ so backends work as context
  managers (with build_backend("r") as backend: ...)
- run_all / insert_pep / reprocess_all / upload_all / _upload_gse:
  build backend at batch-orchestrator level, pass through, cleanup
  when done
- cli.py `bedstat` standalone command: build+use via `with` block
- run_all accepts optional backend=None for single-file standalone use;
  builds + cleans up locally if caller didn't provide one

This is a breaking change — no backward compat shim. The backend
abstraction is pre-merge (PR #153).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Before: GtarsStatBackend was "mixed" — called gtars CLI for most stats
but used gtars Python bindings for two things inside the backend:
1. RegionSet(bedfile) to compute bed_digest fallback
2. calc_gc_content(rs, assembly, ...) for GC content

This blurred the line between the "CLI" and "Python bindings" backends.

After: GtarsStatBackend is a pure CLI subprocess backend.
- Pass --fasta to gtars CLI (uses PR #249's --fasta flag that outputs
  gc_content in the JSON). Read gc_mean + per_region GC from the CLI
  output instead of calling gtars Python bindings.
- Require bed_digest from the caller. Backends no longer parse BED files.
  Resolution moves up to bedstat() (the orchestration layer), which
  computes the digest via gtars Python bindings once when absent.
- Remove imports of gtars.models.RegionSet and calculate_gc_content.
- Add get_fasta_path() helper to ref_utils.py (pure refgenie, no gtars
  dependency at import time) so the backend can resolve FASTA paths for
  the --fasta flag.

The three backends now have cleanly separated execution domains:
- RStatBackend: R subprocess (+ Python bindings helpers for file loading
  and GC content — kept as-is, R's native GC calc is slow)
- GtarsStatBackend: gtars CLI subprocess ONLY
- GtarsPyStatBackend: gtars Python bindings ONLY (no subprocess)

Parity verified: pure-CLI gtars output matches gtars-py output exactly
on all 19 scalar + partition fields for a 126K-region hg38 file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove GtarsPyStatBackend (gtars CLI with .fab is faster and simpler)
- Remove BACKEND_GTARS_PY constant and all references
- Add get_fab_path() to ref_utils: auto-compiles .fab from FASTA on
  first use via gtars prep --fasta, cached forever
- GtarsStatBackend prefers .fab over plain .fa for GC content
- Fix reprocess_bedset: pass backend from config to run_bedbuncher
- Update CLI help text and bedbuncher docstrings

Two backends remain: "r" (RStatBackend) and "gtars" (GtarsStatBackend).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
compress_region_distribution now takes chrom_sizes to compute
correct per-chromosome array lengths (longest chr = n_bins,
shorter chrs proportionally fewer). Without this, arrays were
sized by max observed rid which varied by region coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@khoroshevskyi
khoroshevskyi changed the base branch from main to dev August 31, 2026 17:17
Nothing consumes it. BedStatsModel has never declared the field, so
extra="ignore" discarded the value before it could reach bed_stats, and the
bbconf aggregation that was meant to read it has been dropped along with the
collection-level histogram it fed.

Removing the derivation also drops a median over the full neighbor_distances
list on every file. The list itself is untouched and still reaches the
distributions blob, which is what the per-file density plot renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PRGhWjGiUXFoygd35QsKQ7
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.

2 participants