Skip to content

Transcript-flow alluvial + post-hoc endpoints view (neighboring-cell class) - #5

Open
atuldeshpande wants to merge 38 commits into
mainfrom
feature/endpoints-alluvial-view
Open

Transcript-flow alluvial + post-hoc endpoints view (neighboring-cell class)#5
atuldeshpande wants to merge 38 commits into
mainfrom
feature/endpoints-alluvial-view

Conversation

@atuldeshpande

Copy link
Copy Markdown
Member

Summary

Adds the transcript-flow alluvial/Sankey visualization plus a new post-hoc endpoints view that plots only initial→final transcript proportions, and surfaces a first-class "neighboring cell" entity class.

This branch bundles the underlying feature/transcript-flow-sankey infrastructure (not previously on upstream) with the endpoints feature on top.

Endpoints feature (the new work, on top of the Sankey base)

  • sankey_log.classify_endpoints(df, *, orig_id_col, label_col, etype_col=None) — computes (initial, final) entity-class codes from any TRACER partition's original cell_id + final assignment label. Pure post-hoc — no pipeline snapshots required.
  • flow_plot.plot_endpoints_flow(df, *, orig_id_col="cell_id", label_col=None, ...) — public two-column alluvial (Initial → Final) with tracked crossing ribbons.
  • CLASS_MAIN_NEIGHBOR (code 5, "neighboring cell") — a transcript assigned to a different base cell_id than its origin. Rules: "etype wins" (-tr- partials stay partial); originally-unassigned tx are not neighbors; the 3-class grouping folds neighbor back into original.
  • Renderer: reusable phase_labels override, lone-boundary drop_unchanged guard, colorblind-safe (Okabe-Ito) neighbor color.
  • pdac runner gains a post-hoc endpoints render.

Design + plan: docs/superpowers/specs/2026-06-17-endpoints-alluvial-view-design.md, docs/superpowers/plans/2026-06-17-endpoints-alluvial-view.md.

Test Plan

  • pytest tests/test_flow_plot.py tests/test_sankey_log.py tests/test_sankey_integration.py → 76 passed, 1 skipped
  • Endpoints classification edge cases unit-tested (moved/stayed/partial-of-other/unassigned-origin; etype-present and etype-derived paths)
  • Both matplotlib and plotly backends smoke-tested
  • Reviewer: spot-check a rendered pdac_full_endpoints.html on real output
  • Note: tests/test_etype.py has 9 pre-existing failures from missing torch_geometric / unbuilt _cy_prune (environmental, unrelated to this PR)

🤖 Generated with Claude Code

atuldeshpande and others added 30 commits June 9, 2026 12:16
Adds the design for a Sankey/alluvial plot of per-transcript assignment
flow through SEG (14 phases) and NOSEG (8 phases) pipelines. Specifies the
5-class vocabulary (collapsible to 3 for the headline view), the
snapshot_phase logging hook (one int8 column per phase, ~140 MB at 10M tx),
hook insertion sites per pipeline, conservation invariants, and the
plot_transcript_flow API with plotly + matplotlib backends.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the all-14-phases default with three nested tiers:
- collapsed (Tier A, 5 SEG nodes): phase1+rescue, group+rescue,
  stitch+demote+rescue, finalize. Display-time only; no new snapshots.
- default (Tier B, 9 SEG / 8 NOSEG): phase1 collapsed, group + demote
  visible. Hooks run here unless overridden.
- verbose (Tier C, 14 SEG): expands Phase-1 sub-mutations (prune,
  reassign_1c, split_p1, rerank, qc_p1, maha_remerge) — opt-in via
  snapshot_level="verbose".

Updates §5.1 (phase-key constants + collapse maps), §5.2 (hook table with
tier markers), §5.4 (cost: ~90 MB default / ~140 MB verbose at 10M tx),
§6 (adds view parameter to plot_transcript_flow), §9 (scope recap),
§10 (open Q on runner kwarg name).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add PHASE_DISPLAY_LABELS table mapping internal keys to user-facing
Sankey node labels. Tier B 'phase1' renders as "Prune" since pruning
is the headline Phase-1 operation. Tier C verbose 'prune' sub-step
also renders as "Prune" — no collision since they never appear in the
same view. All other phases title-cased for consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec fix: remove references to a fictitious _pipeline_runner.py — this
codebase has no central runner; each tutorial composes phases by hand.
Hook sites now point at tutorials/lung_cancer/run_lung_cancer.py as the
canonical SEG demo driver.

Plan: 10 TDD tasks with frequent commits.
1.  Classifier + display labels (sankey_log.py)
2.  Snapshot round-trip + conservation tests
3.  Tidy transition DataFrame data-prep (flow_plot.py)
4.  View resolution + auto-pipeline detect
5.  plot_transcript_flow + matplotlib backend
6.  Plotly backend (lazy import)
7.  Wire exports in tracer.__init__
8.  Snapshot calls in lung_cancer SEG driver
9.  Integration smoke + plot artifact
10. plotly as optional [viz] extra in pyproject.toml

Self-review: spec coverage verified, no placeholders, types consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds src/tracer/sankey_log.py with:
- _classify_etype_vec (int8 codes: main/partial/component/unassigned/dropped)
- snapshot_phase(df, phase, *, id_col) hook
- PHASE_KEYS_{SEG,NOSEG}_{DEFAULT,VERBOSE,COLLAPSED} tier tables
- COLLAPSE_{SEG,NOSEG} display-time group maps
- PHASE_DISPLAY_LABELS (phase1 → 'Prune')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds src/tracer/flow_plot.py with _prepare_flow_data and _collapse_classes.
Tidy schema: (phase_from, phase_to, class_from, class_to, n).
Supports 5↔3 class grouping, min_flow_frac filtering, strict conservation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_resolve_view picks a phase list given:
- pipeline (seg/noseg/auto — auto sniffs which etype_at_* cols are present)
- view (default/collapsed/verbose)
collapsed view maps the display-label group to its end-of-group source column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the public plot_transcript_flow function. Matplotlib backend draws
flow polygons via PathPatch — no interactivity but works headless and
in any matplotlib env. Default palette covers 5 + 3 class groupings.
drop_unchanged collapses identity-only phase boundaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
backend='plotly' (default) renders an interactive go.Sankey with per-link
hover (count + percent). plotly imported lazily inside _render_plotly;
flow_plot module import has no plotly requirement. HTML output via
fig.write_html.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds snapshot_phase calls after each Stage 1/2/3/5 boundary in the
canonical SEG demo driver, with id_col tracking the active assignment
column at each stage. Drives the transcript-flow Sankey artifact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Synthetic 9-phase SEG snapshot df → both backends render without error,
artifacts save to disk, plotly file contains a Sankey trace, class
grouping three/five produce sensible node counts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PDAC 500µm ROI verification (2026-06-09) showed the modern SEG pipeline
does not emit 'component' (absorbed by Stitch + Final-Rescue) and does
not emit 'dropped' at finalize (leftover -1 routed into nearest entity,
not DROP sentinel). The five-class vocabulary is retained for backward-
compat with saved runs and NOSEG cascade; empty buckets render as
zero-height bands. The default 3-class grouping makes this visually
invisible. Documented in §4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UX changes:
- Tier A node labels renamed from compound names ('Prune + Rescue',
  'Group + Rescue', 'Stitch + Demote + Rescue', 'Cascade + Rescue') to
  the principal earlier-stage name ('Prune', 'Group', 'Stitch',
  'Cascade'). The post-stage rescues/demotes are folded silently.
- New display_label_for(phase_key, *, pipeline, view) helper inverts
  COLLAPSE_{SEG,NOSEG} so Tier A renders the right collapsed label
  when reading a source-column snapshot.
- Phase labels moved from in-situ text annotations to proper axis labels
  (matplotlib xticks rotated 20°; plotly annotations along the top).
- Class legend added below the X axis (matplotlib: Patch handles,
  ncol=len(classes); plotly: invisible scatter traces with horizontal
  legend orientation).
- Plotly Sankey gets explicit node.x/node.y pinning + arrangement='snap'
  so column-header annotations align with the actual node positions.

Layout robustness fix:
- _layout_nodes_mpl now computes per-phase totals from MAX(incoming,
  outgoing) edges. Caught on the real PDAC 500µm ROI: when
  drop_unchanged removes an identity-only boundary, the downstream
  phase had only outgoing edges in tidy; the old layout looked at
  incoming-only and produced an empty node set, then _draw_ribbons_mpl
  raised 'ribbon source node not in layout' (the defensive raise from
  the Task-5 review). Fix: layout uses max per-class so a phase with
  flow on either side renders correctly.

All 44 tests pass + 1 skip; validated end-to-end on PDAC 500µm
(204,931 transcripts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
By default, plot_transcript_flow stacks classes top-to-bottom by ascending
class code — for the canonical 5 codes this matches the semantic order
(main → partial → component → unassigned → dropped). Extended palettes
that add new codes (e.g. main_neighbor=5) used to land at the bottom,
visually adjacent to dropped instead of their semantic sibling main.

Adds class_order: Optional[Sequence[int]] (None = current behavior). User
supplies a top-to-bottom order; palette codes not listed are appended at
the bottom in sorted order so they're never silently dropped. Unknown
codes raise ValueError.

Threaded through both _render_matplotlib and _render_plotly via a single
_resolve_class_order helper. 4 new tests in TestResolveClassOrder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The default top-to-bottom visual order is now driven by the explicit
CLASS_SEMANTIC_ORDER list in sankey_log (main → partial → component →
unassigned → dropped), not by ascending int-code coincidence. Identical
behavior for the canonical 5 codes; extended palette codes still get
appended at the bottom by default (callers slot them next to their
semantic sibling via class_order).

Renames test_default_is_sorted_by_code → test_default_follows_canonical_
semantic_order, adds test_default_matches_semantic_order_for_full_5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both backends previously rendered a legend entry per palette code, which
produced dead swatches for codes the data never emitted (e.g. component
and dropped on modern SEG runs). Now the legend filters classes to those
that actually appear in the tidy transition df. Zero-count classes
remain in the palette (so an explicit class_order keeps working) but
don't clutter the legend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The plotly backend pinned both node.x and node.y for every (phase, class)
cell in the grid — including zero-count cells. With arrangement='snap',
that forced plotly to allocate vertical slots for empty bands and broke
the link-width allocator's ability to honor flow conservation: large
ribbons (e.g. main → unassigned at Phase 1) would visually disappear
because the source band's apparent height didn't match its actual mass.

Fix: keep node.x (so columns stay aligned with the column-header
annotations) but drop node.y. Plotly auto-balances y within each column
based on incoming + outgoing flow. The matplotlib backend already
handled this correctly via _layout_nodes_mpl's max(in, out) totals.

Caught on real PDAC 500µm data — input column's 176k main mass wasn't
visually flowing into the Phase 1 unassigned band.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Within each source node, outgoing ribbons were iterated in ascending
target-class CODE order — so a ribbon going to a top-of-column target
(e.g. neighboring_cell, code 5 in an extended palette ranked first in
class_order) would emerge from the bottom of the source. The Sankey
convention is to order outgoing ribbons by the target's vertical-position
rank, so top-of-column targets emerge from the top of their source.

Fix: in _draw_ribbons_mpl, sort the per-boundary ribbons by
(class_order rank of class_from, class_order rank of class_to) before
the stacking loop. This preserves vertical-order at both source and
target ends.

Also reverts the plotly node.y mass-weighted pinning (broke HTML on
real data) — plotly stays at x-pinned + y-auto-balanced for now.
matplotlib backend respects class_order; plotly auto-layout may not
honor it. Tradeoff documented in the source comment.

Caught on real PDAC 500µm: ribbon unassigned → neighboring cell at
post-rescue boundaries was emerging from the bottom of unassigned and
bending across the column to reach neighboring cell at the top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The classifier's _UNASSIGNED_SENTINELS was just {"-1"}, missing the
canonical post-finalize tokens. After finalize_unassigned normalizes
the leftover -1 tx to "UNASSIGNED" (modern SEG default), those rows
fell through to the default CLASS_PARTIAL fill — so the Sankey showed
0 unassigned at finalize and an erroneous bump in the partial count.

Fix: align _UNASSIGNED_SENTINELS with the canonical
tracer.spatial.UNASSIGNED_LABELS set so the classifier recognizes:
- "-1"               (pre-finalize sentinel)
- "UNASSIGNED"       (post-finalize canonical token)
- "nan"              (legacy null token)
- "prune_rejected"   (mid-pipeline stage rejection)
- "group_rejected"   (mid-pipeline stage rejection)

DROP and demote_rejected stay in _DROPPED_SENTINELS for backward
compat with older runs that emitted them.

Also adds label_target='ribbons'|'both' kwarg to plot_transcript_flow
(matplotlib): puts stage labels above the ribbons at midpoints between
columns, matching the Sankey semantic (columns = states, ribbons =
stages). Default 'columns' preserves existing behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The runner's Finalize step is just defensive normalization (collapses
lingering -1 / DROP / demote_rejected / UNASSIGNED_* into the canonical
"UNASSIGNED" token). After the classifier fix to recognize "UNASSIGNED"
as unassigned, the final_rescue and finalize snapshots are bit-identical
at the classifier level on modern SEG. The redundant column adds no
information and elongates the X axis.

Changes:
- PHASE_KEYS_{SEG,NOSEG}_DEFAULT drop "finalize" — default tier ends
  at "final_rescue", which is the effective pipeline output
- PHASE_KEYS_{SEG,NOSEG}_COLLAPSED drop "finalize" — Tier A also ends
  at final_rescue
- PHASE_KEYS_NOSEG_VERBOSE = NOSEG_DEFAULT + ["finalize"] — verbose tier
  keeps finalize for explicit inspection of the no-op step
- New _PHASE_COLUMN_OVERRIDES dict + display_label_for honors it: the
  "final_rescue" column reads "Finalize" (state name), while the ribbon
  entering it still reads "Final Rescue" (the action stage name)

Updated 4 tests (default-tier lengths, collapsed-tier source columns,
new test for verbose preserving finalize, new test for column override).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hover tooltip percentages were computed as 100 * n / tidy['n'].sum(),
but tidy['n'].sum() sums ribbon mass across every phase boundary —
since transcripts are conserved across boundaries, a tx is counted
n_boundaries times. On the PDAC 500µm run (8 phases / 7 boundaries),
percentages came out ~7x too small (a true 50% ribbon read as ~7%).

Fix: use the total mass in any single column as the denominator
(tidy[phase_from == phases[0]]['n'].sum()) — this is the actual
transcript count, conserved across columns. Matches what the
matplotlib backend already does via _layout_nodes_mpl.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-locates two end-to-end drivers in tutorials/pdac_io/ next to the
existing bootstrap-PMI CSVs in data/:

- run_pdac_full_pipeline.py: vanilla SEG sequential run on the full
  sample (adapted from benchmarks/bench_pdac_full_seq.py). Writes
  partition_sequential.parquet + summary.json to output/full_seq/.

- run_pdac_full_sankey.py: same pipeline + monkey-patched _record_stage
  for per-phase snapshot_phase calls + Tier A/B Sankey rendering.
  Writes partition (incl. all etype_at_<phase> snapshot cols) +
  summary.json + phase_counts.csv + Tier A/B HTML+PNG to
  output/full_sankey/.

Both default to the locally-saved pmi_bs_pdac_io_C_5_95.csv panel
(override via PANEL_CSV env var). Configurable runner knobs via
PMI_THR / RESCUE_MEAN_ADMIT / RESCUE_AGGREGATOR_PERCENTILE.

Expected scale: 3-5M tx, 30 min - 2 h wall, 20+ GB RSS peak — launch
with nohup. Output dir auto-created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coupled pieces on top of feature/transcript-flow-sankey:
- view="endpoints": plot only initial/final proportions ([first,last] phases)
- promote the runner's neighboring-cell prototype into core (sankey_log),
  classifying cell-etype tx whose assigned cell_id differs from origin as a
  first-class 'neighboring cell' (code 5)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r class

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
plot_endpoints_flow computes initial/final classes from any TRACER partition's
origin cell_id + final label; neighboring cell = whole-cell final base cell_id
!= origin. Drops the snapshot_phase wiring and runner monkey-patch dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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