Skip to content

Commit c5eef9d

Browse files
lucapinelloclaude
andcommitted
Causal IGV: apply shared floor-rescale so all reports have scaled tracks
The causal report's IGV renderer was writing signal tracks directly from raw oracle predictions with per-track autoscale, while the variant-report IGV was already running a PerTrackNormalizer floor-subtract + rescale to [0, 3.0] (1.0 = genome-wide p99 peak). That inconsistency meant two reports from the same run had incomparable y-axes. Extract the rescale step into ``_igv_report.apply_floor_rescale`` and call it from both ``build_igv_html`` and ``_build_causal_igv`` so every IGV panel in every report uses the same scaling by default. Users who want raw dynamics can opt out via ``CausalResult._igv_raw`` (mirrors the existing ``VariantReport._igv_raw`` knob). Regenerated the SORT1 causal example; 36 signal-track panels now use scaled (min=0, max=3) instead of raw autoscale. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f15d926 commit c5eef9d

7 files changed

Lines changed: 464 additions & 370 deletions

File tree

chorus/analysis/_igv_report.py

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,54 @@ def _ensure_igv_local() -> Path | None:
130130
_DISPLAY_MAX = 3.0
131131

132132

133+
def apply_floor_rescale(
134+
normalizer,
135+
oracle_name: str | None,
136+
assay_id: str,
137+
layer: str,
138+
ref_vals,
139+
alt_vals,
140+
):
141+
"""Floor-subtract + rescale a ref/alt value pair using the normalizer.
142+
143+
Returns ``(floor_ok, ref_scaled, alt_scaled)``. When ``floor_ok`` is
144+
``True`` the returned arrays are mapped to ``[0, _DISPLAY_MAX]`` with
145+
layer-aware thresholds (p95 / p99 for sharp signals, p90 / p99 for
146+
broad histone marks) — 1.0 on the y-axis then corresponds to the
147+
genome-wide p99 peak in that assay, making tracks comparable across
148+
assays *and* across reports. When False, callers should fall back to
149+
raw autoscale.
150+
151+
This helper is shared by the standard variant-report IGV
152+
(:func:`build_igv_html`) and the causal-report IGV
153+
(:func:`chorus.analysis.causal._build_causal_igv`) so both reports
154+
render the same "scaled-by-default" IGV tracks.
155+
"""
156+
if normalizer is None or oracle_name is None:
157+
return False, ref_vals, alt_vals
158+
from .normalization import PerTrackNormalizer
159+
if not isinstance(normalizer, PerTrackNormalizer):
160+
return False, ref_vals, alt_vals
161+
floor_p = _LAYER_FLOOR_PCTILE.get(layer, _DEFAULT_FLOOR_PCTILE)
162+
ref_fl = normalizer.perbin_floor_rescale_batch(
163+
oracle_name, assay_id, ref_vals,
164+
floor_pctile=floor_p,
165+
peak_pctile=_PEAK_PCTILE,
166+
max_value=_DISPLAY_MAX,
167+
)
168+
if ref_fl is None:
169+
return False, ref_vals, alt_vals
170+
alt_fl = normalizer.perbin_floor_rescale_batch(
171+
oracle_name, assay_id, alt_vals,
172+
floor_pctile=floor_p,
173+
peak_pctile=_PEAK_PCTILE,
174+
max_value=_DISPLAY_MAX,
175+
)
176+
if alt_fl is None:
177+
return False, ref_vals, alt_vals
178+
return True, ref_fl, alt_fl
179+
180+
133181
def build_igv_html(
134182
ref_pred,
135183
alt_pred,
@@ -231,25 +279,9 @@ def build_igv_html(
231279
# Apply layer-aware floor-subtract + rescale when available
232280
floor_ok = False
233281
if use_floor:
234-
from .normalization import PerTrackNormalizer
235-
if isinstance(normalizer, PerTrackNormalizer):
236-
floor_p = _LAYER_FLOOR_PCTILE.get(layer, _DEFAULT_FLOOR_PCTILE)
237-
ref_fl = normalizer.perbin_floor_rescale_batch(
238-
oracle_name, assay_id, ref_vals,
239-
floor_pctile=floor_p,
240-
peak_pctile=_PEAK_PCTILE,
241-
max_value=_DISPLAY_MAX,
242-
)
243-
if ref_fl is not None:
244-
alt_fl = normalizer.perbin_floor_rescale_batch(
245-
oracle_name, assay_id, alt_vals,
246-
floor_pctile=floor_p,
247-
peak_pctile=_PEAK_PCTILE,
248-
max_value=_DISPLAY_MAX,
249-
)
250-
ref_vals = ref_fl
251-
alt_vals = alt_fl
252-
floor_ok = True
282+
floor_ok, ref_vals, alt_vals = apply_floor_rescale(
283+
normalizer, oracle_name, assay_id, layer, ref_vals, alt_vals,
284+
)
253285

254286
ref_features = _downsample_to_features(
255287
ref_vals, variant_chrom, t_start, t_res, bin_size,

chorus/analysis/causal.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ class CausalResult:
9696
cell_types: list[str] = field(default_factory=list)
9797
nearby_genes: list[str] = field(default_factory=list)
9898
analysis_request: AnalysisRequest | None = None
99+
# When True, the IGV browser shows raw predicted signal with autoscale
100+
# instead of the layer-aware floor-rescale (1.0 = genome-wide p99).
101+
# Default False so tracks are cross-comparable by default, matching the
102+
# rest of Chorus; flip to True only when raw dynamics matter.
103+
_igv_raw: bool = field(default=False, repr=False)
99104

100105
def top_candidate(self) -> CausalVariantScore:
101106
"""Return the highest-ranked variant."""
@@ -973,7 +978,12 @@ def _build_causal_igv(result: CausalResult) -> str:
973978

974979
if ref_pred and alt_pred:
975980
from .scorers import classify_track_layer
976-
from ._igv_report import _downsample_to_features, _LAYER_COLORS, _REF_COLOR
981+
from ._igv_report import (
982+
_DISPLAY_MAX,
983+
_REF_COLOR,
984+
_downsample_to_features,
985+
apply_floor_rescale,
986+
)
977987

978988
assay_ids = list(ref_pred.keys())
979989
first_track = ref_pred[assay_ids[0]]
@@ -983,20 +993,47 @@ def _build_causal_igv(result: CausalResult) -> str:
983993
bin_size = max(1, window_bp // 3000)
984994
alt_rgb = _TOP_VARIANT_COLORS[vi]["r"] if vi < len(_TOP_VARIANT_COLORS) else "70,130,180"
985995

996+
# Route signal tracks through the shared floor-rescale helper
997+
# so the causal IGV looks like every other Chorus report:
998+
# 1.0 = genome-wide p99 peak. Users opt out via
999+
# CausalResult._igv_raw (set by the caller) or by building
1000+
# variant reports with ``igv_raw=True``.
1001+
normalizer = getattr(top_s._variant_report, "_normalizer", None)
1002+
oracle_name = getattr(top_s._variant_report, "oracle_name", None)
1003+
igv_raw = (
1004+
getattr(result, "_igv_raw", False)
1005+
or getattr(top_s._variant_report, "_igv_raw", False)
1006+
)
1007+
if igv_raw:
1008+
normalizer = None
1009+
9861010
for aid in assay_ids:
9871011
ref_t = ref_pred[aid]
9881012
alt_t = alt_pred[aid]
9891013
t_start = ref_t.prediction_interval.reference.start
9901014
t_res = ref_t.resolution
9911015

1016+
layer = classify_track_layer(ref_t)
1017+
floor_ok, ref_vals, alt_vals = apply_floor_rescale(
1018+
normalizer, oracle_name, aid, layer,
1019+
ref_t.values, alt_t.values,
1020+
)
1021+
9921022
ref_feats = _downsample_to_features(
993-
ref_t.values, chrom, t_start, t_res, bin_size,
1023+
ref_vals, chrom, t_start, t_res, bin_size,
1024+
skip_zeros=not floor_ok,
9941025
)
9951026
alt_feats = _downsample_to_features(
996-
alt_t.values, chrom, t_start, t_res, bin_size,
1027+
alt_vals, chrom, t_start, t_res, bin_size,
1028+
skip_zeros=not floor_ok,
9971029
)
9981030

9991031
group_id = f"{aid}_{top_s.variant_id}".replace(":", "_").replace(" ", "_")
1032+
if floor_ok:
1033+
scale_cfg = {"min": 0, "max": _DISPLAY_MAX, "autoscale": False}
1034+
else:
1035+
scale_cfg = {"autoscale": True, "autoscaleGroup": group_id}
1036+
10001037
rank_label = _TOP_VARIANT_COLORS[vi]["label"] if vi < len(_TOP_VARIANT_COLORS) else f"#{vi+1}"
10011038
tracks.append({
10021039
"name": f"{aid} ({rank_label} {top_s.variant_id})",
@@ -1007,16 +1044,14 @@ def _build_causal_igv(result: CausalResult) -> str:
10071044
"type": "wig",
10081045
"name": f"{aid} ref",
10091046
"color": f"rgb({_REF_COLOR})",
1010-
"autoscale": True,
1011-
"autoscaleGroup": group_id,
1047+
**scale_cfg,
10121048
"features": ref_feats,
10131049
},
10141050
{
10151051
"type": "wig",
10161052
"name": f"{aid} alt",
10171053
"color": f"rgb({alt_rgb})",
1018-
"autoscale": True,
1019-
"autoscaleGroup": group_id,
1054+
**scale_cfg,
10201055
"features": alt_feats,
10211056
},
10221057
],

0 commit comments

Comments
 (0)