VityaVitalich Claude Opus 4.7 (1M context) commited on
Commit
0567eb0
·
1 Parent(s): 6a7aa48

dashboard: tabs layout + extraction-generations view

Browse files

Layout: per-question cards now use tabs instead of side-by-side
columns, so each answer renders full-width and reads naturally.
Applies to baseline-vs-steered cards and to the
extraction-method-comparison view.

New view: "extraction generations (pos vs neg)" — walks
data/model_responses/extract/<model>/<rev>/<vec>_{pos,neg}_instruct.csv
and shows the persona+ vs persona- generations that defined each
vector, sortable by pos-neg trait gap. Includes the 4 evil
extraction-method pairs on Olmo-3-1025-7B/main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

analysis/dashboard.py CHANGED
@@ -28,6 +28,7 @@ import streamlit as st
28
 
29
  REPO = Path(__file__).resolve().parents[1]
30
  EVAL_ROOT = REPO / "data/model_responses/eval"
 
31
 
32
  # Known persona-vector trait prefixes. Judge-score columns inside CSVs
33
  # take the form "<prefix>_character_neutral_q", "<prefix>_stories_neutral_q",
@@ -313,6 +314,64 @@ def load_csv(path: Path) -> pd.DataFrame:
313
  return pd.read_csv(path)
314
 
315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  def _safe_mean(s: pd.Series) -> float:
317
  return float(s.dropna().mean()) if len(s.dropna()) else float("nan")
318
 
@@ -324,54 +383,70 @@ def _sparse_tail(path: Path | None) -> str:
324
  return "…/" + "/".join(parts[-5:]) if len(parts) >= 5 else str(path)
325
 
326
 
 
 
 
 
 
 
 
 
 
 
 
327
  def _render_pair(question: str, base_row: pd.Series | None, steer_row: pd.Series,
328
  trait_col: str) -> None:
 
329
  st.markdown(f"**Q:** {question}")
330
- c1, c2 = st.columns(2)
331
  bt = base_row.get(trait_col, float("nan")) if base_row is not None else float("nan")
332
  bc = base_row.get("coherence", float("nan")) if base_row is not None else float("nan")
333
  st_t = steer_row.get(trait_col, float("nan"))
334
  st_c = steer_row.get("coherence", float("nan"))
335
- with c1:
336
- st.markdown("*baseline*")
 
 
 
 
 
337
  if base_row is None:
338
  st.info("no baseline row for this question_id")
339
  else:
340
- st.caption(f"trait={bt:.1f} coh={bc:.1f}")
341
- st.write(str(base_row.get("answer", "")).strip() or "_(empty)_")
342
- with c2:
343
- dt = (st_t - bt) if pd.notna(bt) else float("nan")
344
- st.markdown("*steered*")
345
- dt_str = f" (Δ={dt:+.1f})" if pd.notna(dt) else ""
346
- st.caption(f"trait={st_t:.1f}{dt_str} coh={st_c:.1f}")
347
- st.write(str(steer_row.get("answer", "")).strip() or "_(empty)_")
348
  st.divider()
349
 
350
 
351
  def _render_compare(question: str, base_row: pd.Series | None,
352
  method_runs: list[tuple[str, pd.Series]],
353
  trait_col: str) -> None:
354
- """Side-by-side rendering: baseline + one column per extraction method."""
355
  st.markdown(f"**Q:** {question}")
356
- columns = st.columns(1 + len(method_runs))
357
  bt = base_row.get(trait_col, float("nan")) if base_row is not None else float("nan")
358
  bc = base_row.get("coherence", float("nan")) if base_row is not None else float("nan")
359
- with columns[0]:
360
- st.markdown("*baseline*")
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  if base_row is None:
362
  st.info("no baseline row")
363
  else:
364
- st.caption(f"trait={bt:.1f} coh={bc:.1f}")
365
- st.write(str(base_row.get("answer", "")).strip() or "_(empty)_")
366
- for col, (method, s_row) in zip(columns[1:], method_runs):
367
- with col:
368
- st_t = s_row.get(trait_col, float("nan"))
369
- st_c = s_row.get("coherence", float("nan"))
370
- dt = (st_t - bt) if pd.notna(bt) else float("nan")
371
- dt_str = f" (Δ={dt:+.1f})" if pd.notna(dt) else ""
372
- st.markdown(f"*{method}*")
373
- st.caption(f"trait={st_t:.1f}{dt_str} coh={st_c:.1f}")
374
- st.write(str(s_row.get("answer", "")).strip() or "_(empty)_")
375
  st.divider()
376
 
377
 
@@ -649,34 +724,146 @@ def _render_compare_view(runs_c: list[Run]) -> None:
649
  _render_compare(question, b_row, method_rows, trait_col)
650
 
651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  def main() -> None:
653
  st.set_page_config(page_title="Steered-output explorer", layout="wide")
654
  st.title("Steered-output explorer")
655
 
656
  runs = discover()
657
- if not runs:
658
- st.error(f"No steering CSVs found under {EVAL_ROOT}")
 
659
  return
660
 
661
  st.sidebar.header("view")
662
- view_options: list[str] = ["checkpoint sweep"]
 
 
663
  if any(r.run_kind == "ablation" for r in runs):
664
  view_options.append("vector extraction ablation")
 
 
665
  view = st.sidebar.radio("data view", view_options, index=0,
666
  label_visibility="collapsed")
667
- view_kind = "ablation" if view.startswith("vector extraction") else "sweep"
668
-
669
- runs_v = [r for r in runs if r.run_kind == view_kind]
670
- if not runs_v:
671
- st.warning(f"No runs available for view '{view}'.")
672
- return
673
 
674
- if view_kind == "sweep":
 
675
  run = _sweep_sidebar(runs_v)
676
  if run is None:
677
  return
678
  _render_single(run)
679
- else:
 
680
  runs_c, compare = _ablation_sidebar(runs_v)
681
  if not runs_c:
682
  st.warning("No run matches the current filters.")
@@ -684,14 +871,18 @@ def main() -> None:
684
  if compare and len({r.extract_rev for r in runs_c}) > 1:
685
  _render_compare_view(runs_c)
686
  else:
687
- # single method (or only one method available) — fall back to the
688
- # single-run view, picking a deterministic representative
689
  if len(runs_c) > 1:
690
  run = st.sidebar.selectbox("run file (disambiguate)", runs_c,
691
  format_func=lambda r: _sparse_tail(r.steered_path))
692
  else:
693
  run = runs_c[0]
694
  _render_single(run)
 
 
 
 
 
 
695
 
696
 
697
  if __name__ == "__main__":
 
28
 
29
  REPO = Path(__file__).resolve().parents[1]
30
  EVAL_ROOT = REPO / "data/model_responses/eval"
31
+ EXTRACT_ROOT = REPO / "data/model_responses/extract"
32
 
33
  # Known persona-vector trait prefixes. Judge-score columns inside CSVs
34
  # take the form "<prefix>_character_neutral_q", "<prefix>_stories_neutral_q",
 
314
  return pd.read_csv(path)
315
 
316
 
317
+ @dataclass(frozen=True)
318
+ class Extract:
319
+ """One extraction-time pair (positive- and negative-persona generations
320
+ that defined a persona vector)."""
321
+ extract_model: str
322
+ extract_rev: str
323
+ trait: str
324
+ vec: str # extraction method id, e.g. evil_character_neutral_q
325
+ pos_path: Path
326
+ neg_path: Path
327
+ trait_col: str
328
+
329
+ def title(self) -> str:
330
+ return (f"extraction method: **{self.vec}** · "
331
+ f"**{self.extract_model}** @ **{self.extract_rev}**")
332
+
333
+
334
+ @st.cache_data(show_spinner="Discovering extraction CSVs…")
335
+ def discover_extracts() -> list[Extract]:
336
+ """Walk EXTRACT_ROOT for paired `<vec>_pos_instruct.csv` /
337
+ `<vec>_neg_instruct.csv` files and keep only those whose CSV header
338
+ contains a recognizable trait-score column."""
339
+ out: list[Extract] = []
340
+ if not EXTRACT_ROOT.exists():
341
+ return out
342
+ for model_dir in sorted(EXTRACT_ROOT.iterdir()):
343
+ if not model_dir.is_dir():
344
+ continue
345
+ for rev_dir in sorted(model_dir.iterdir()):
346
+ if not rev_dir.is_dir():
347
+ continue
348
+ pos_files = {p.name[:-len("_pos_instruct.csv")]: p
349
+ for p in rev_dir.glob("*_pos_instruct.csv")}
350
+ for vec, pos_path in pos_files.items():
351
+ neg_path = rev_dir / f"{vec}_neg_instruct.csv"
352
+ if not neg_path.exists():
353
+ continue
354
+ cols = _read_header(pos_path)
355
+ if cols is None:
356
+ continue
357
+ tc = find_trait_column(cols)
358
+ if tc is None:
359
+ continue
360
+ trait_col, trait = tc
361
+ if trait_col not in (_read_header(neg_path) or []):
362
+ continue
363
+ out.append(Extract(
364
+ extract_model=model_dir.name,
365
+ extract_rev=rev_dir.name,
366
+ trait=trait,
367
+ vec=vec,
368
+ pos_path=pos_path,
369
+ neg_path=neg_path,
370
+ trait_col=trait_col,
371
+ ))
372
+ return out
373
+
374
+
375
  def _safe_mean(s: pd.Series) -> float:
376
  return float(s.dropna().mean()) if len(s.dropna()) else float("nan")
377
 
 
383
  return "…/" + "/".join(parts[-5:]) if len(parts) >= 5 else str(path)
384
 
385
 
386
+ def _answer_text(row: pd.Series | None) -> str:
387
+ if row is None:
388
+ return "_(no row)_"
389
+ txt = str(row.get("answer", "")).strip()
390
+ return txt or "_(empty)_"
391
+
392
+
393
+ def _fmt_num(x) -> str:
394
+ return f"{x:.1f}" if pd.notna(x) else "—"
395
+
396
+
397
  def _render_pair(question: str, base_row: pd.Series | None, steer_row: pd.Series,
398
  trait_col: str) -> None:
399
+ """Per-question card: tabs for baseline / steered, full-width text."""
400
  st.markdown(f"**Q:** {question}")
 
401
  bt = base_row.get(trait_col, float("nan")) if base_row is not None else float("nan")
402
  bc = base_row.get("coherence", float("nan")) if base_row is not None else float("nan")
403
  st_t = steer_row.get(trait_col, float("nan"))
404
  st_c = steer_row.get("coherence", float("nan"))
405
+ dt = (st_t - bt) if pd.notna(bt) else float("nan")
406
+ dt_str = f" Δ={dt:+.1f}" if pd.notna(dt) else ""
407
+
408
+ base_label = f"baseline trait={_fmt_num(bt)} coh={_fmt_num(bc)}"
409
+ steer_label = f"steered trait={_fmt_num(st_t)}{dt_str} coh={_fmt_num(st_c)}"
410
+ tab_b, tab_s = st.tabs([base_label, steer_label])
411
+ with tab_b:
412
  if base_row is None:
413
  st.info("no baseline row for this question_id")
414
  else:
415
+ st.write(_answer_text(base_row))
416
+ with tab_s:
417
+ st.write(_answer_text(steer_row))
 
 
 
 
 
418
  st.divider()
419
 
420
 
421
  def _render_compare(question: str, base_row: pd.Series | None,
422
  method_runs: list[tuple[str, pd.Series]],
423
  trait_col: str) -> None:
424
+ """Tabs across baseline + each extraction method; full-width answer text."""
425
  st.markdown(f"**Q:** {question}")
 
426
  bt = base_row.get(trait_col, float("nan")) if base_row is not None else float("nan")
427
  bc = base_row.get("coherence", float("nan")) if base_row is not None else float("nan")
428
+
429
+ tab_labels: list[str] = [
430
+ f"baseline trait={_fmt_num(bt)} coh={_fmt_num(bc)}"
431
+ ]
432
+ for method, s_row in method_runs:
433
+ st_t = s_row.get(trait_col, float("nan"))
434
+ st_c = s_row.get("coherence", float("nan"))
435
+ dt = (st_t - bt) if pd.notna(bt) else float("nan")
436
+ dt_str = f" Δ={dt:+.1f}" if pd.notna(dt) else ""
437
+ tab_labels.append(
438
+ f"{method} trait={_fmt_num(st_t)}{dt_str} coh={_fmt_num(st_c)}"
439
+ )
440
+
441
+ tabs = st.tabs(tab_labels)
442
+ with tabs[0]:
443
  if base_row is None:
444
  st.info("no baseline row")
445
  else:
446
+ st.write(_answer_text(base_row))
447
+ for tab, (_method, s_row) in zip(tabs[1:], method_runs):
448
+ with tab:
449
+ st.write(_answer_text(s_row))
 
 
 
 
 
 
 
450
  st.divider()
451
 
452
 
 
724
  _render_compare(question, b_row, method_rows, trait_col)
725
 
726
 
727
+ def _extraction_sidebar(extracts: list[Extract]) -> Extract | None:
728
+ st.sidebar.header("model")
729
+ models = sorted({e.extract_model for e in extracts})
730
+ default_idx = (models.index("Olmo-3-1025-7B")
731
+ if "Olmo-3-1025-7B" in models else 0)
732
+ model = st.sidebar.selectbox("model", models, index=default_idx)
733
+ e_m = [e for e in extracts if e.extract_model == model]
734
+
735
+ revs = sorted({e.extract_rev for e in e_m}, key=_rev_sort_key)
736
+ if len(revs) > 1:
737
+ rev = st.sidebar.selectbox("checkpoint", revs, index=len(revs) - 1)
738
+ else:
739
+ rev = revs[0]
740
+ st.sidebar.caption(f"checkpoint **{rev}** (only option)")
741
+ e_r = [e for e in e_m if e.extract_rev == rev]
742
+
743
+ st.sidebar.header("persona vector")
744
+ traits = sorted({e.trait for e in e_r})
745
+ trait = st.sidebar.selectbox("trait", traits)
746
+ e_t = [e for e in e_r if e.trait == trait]
747
+
748
+ methods = sorted({e.vec for e in e_t})
749
+ if len(methods) > 1:
750
+ method = st.sidebar.selectbox("extraction method", methods)
751
+ else:
752
+ method = methods[0]
753
+ st.sidebar.caption(f"extraction method **{method}** (only option)")
754
+ matches = [e for e in e_t if e.vec == method]
755
+ return matches[0] if matches else None
756
+
757
+
758
+ def _render_extraction_view(extract: Extract) -> None:
759
+ st.markdown(extract.title())
760
+ st.caption(f"pos: `{_sparse_tail(extract.pos_path)}`"
761
+ f" · neg: `{_sparse_tail(extract.neg_path)}`")
762
+
763
+ pos_df = load_csv(extract.pos_path)
764
+ neg_df = load_csv(extract.neg_path)
765
+ trait_col = extract.trait_col
766
+
767
+ pos_mean = _safe_mean(pos_df[trait_col])
768
+ neg_mean = _safe_mean(neg_df[trait_col])
769
+ pos_coh = _safe_mean(pos_df["coherence"]) if "coherence" in pos_df else float("nan")
770
+ neg_coh = _safe_mean(neg_df["coherence"]) if "coherence" in neg_df else float("nan")
771
+ cols = st.columns(4)
772
+ cols[0].metric("pos persona trait", f"{pos_mean:.2f}")
773
+ cols[1].metric("neg persona trait", f"{neg_mean:.2f}",
774
+ delta=f"{neg_mean - pos_mean:+.2f}")
775
+ cols[2].metric("pos persona coherence", f"{pos_coh:.2f}")
776
+ cols[3].metric("neg persona coherence", f"{neg_coh:.2f}")
777
+
778
+ order = st.radio("sort per-question cards by",
779
+ ("pos − neg trait gap (most-polarizing first)",
780
+ "pos persona trait (descending)",
781
+ "neg persona trait (descending)",
782
+ "question text"),
783
+ horizontal=True)
784
+
785
+ # group all generations (pos & neg) by question text
786
+ questions = sorted(set(pos_df["question"]).union(neg_df["question"]))
787
+ cards: list[tuple[str, pd.DataFrame, pd.DataFrame, float, float]] = []
788
+ for q in questions:
789
+ p = pos_df[pos_df["question"] == q]
790
+ n = neg_df[neg_df["question"] == q]
791
+ pm = _safe_mean(p[trait_col]) if len(p) else float("nan")
792
+ nm = _safe_mean(n[trait_col]) if len(n) else float("nan")
793
+ cards.append((q, p, n, pm, nm))
794
+
795
+ def _gap(c):
796
+ _, _, _, pm, nm = c
797
+ if pd.isna(pm) or pd.isna(nm):
798
+ return 0.0
799
+ return float(pm - nm)
800
+
801
+ if order.startswith("pos − neg"):
802
+ cards.sort(key=lambda c: -_gap(c))
803
+ elif order.startswith("pos persona"):
804
+ cards.sort(key=lambda c: -(c[3] if pd.notna(c[3]) else float("-inf")))
805
+ elif order.startswith("neg persona"):
806
+ cards.sort(key=lambda c: -(c[4] if pd.notna(c[4]) else float("-inf")))
807
+ else:
808
+ cards.sort(key=lambda c: c[0])
809
+
810
+ top_k = st.slider("show top-N questions", 5, len(cards),
811
+ min(20, len(cards)))
812
+ for question, p, n, pm, nm in cards[:top_k]:
813
+ st.markdown(f"**Q:** {question}")
814
+ pos_label = f"pos persona trait={_fmt_num(pm)} ({len(p)} gens)"
815
+ neg_label = f"neg persona trait={_fmt_num(nm)} ({len(n)} gens)"
816
+ tab_p, tab_n = st.tabs([pos_label, neg_label])
817
+ with tab_p:
818
+ _render_gens(p, trait_col)
819
+ with tab_n:
820
+ _render_gens(n, trait_col)
821
+ st.divider()
822
+
823
+
824
+ def _render_gens(df: pd.DataFrame, trait_col: str) -> None:
825
+ if df.empty:
826
+ st.info("no generations for this question on this side")
827
+ return
828
+ df = df.sort_values(trait_col, ascending=False, na_position="last")
829
+ for i, (_, row) in enumerate(df.iterrows(), start=1):
830
+ t = row.get(trait_col, float("nan"))
831
+ c = row.get("coherence", float("nan"))
832
+ st.caption(f"gen {i} trait={_fmt_num(t)} coh={_fmt_num(c)}")
833
+ st.write(_answer_text(row))
834
+ if i < len(df):
835
+ st.markdown("---")
836
+
837
+
838
  def main() -> None:
839
  st.set_page_config(page_title="Steered-output explorer", layout="wide")
840
  st.title("Steered-output explorer")
841
 
842
  runs = discover()
843
+ extracts = discover_extracts()
844
+ if not runs and not extracts:
845
+ st.error(f"No CSVs found under {EVAL_ROOT} or {EXTRACT_ROOT}")
846
  return
847
 
848
  st.sidebar.header("view")
849
+ view_options: list[str] = []
850
+ if runs:
851
+ view_options.append("checkpoint sweep")
852
  if any(r.run_kind == "ablation" for r in runs):
853
  view_options.append("vector extraction ablation")
854
+ if extracts:
855
+ view_options.append("extraction generations (pos vs neg)")
856
  view = st.sidebar.radio("data view", view_options, index=0,
857
  label_visibility="collapsed")
 
 
 
 
 
 
858
 
859
+ if view == "checkpoint sweep":
860
+ runs_v = [r for r in runs if r.run_kind == "sweep"]
861
  run = _sweep_sidebar(runs_v)
862
  if run is None:
863
  return
864
  _render_single(run)
865
+ elif view == "vector extraction ablation":
866
+ runs_v = [r for r in runs if r.run_kind == "ablation"]
867
  runs_c, compare = _ablation_sidebar(runs_v)
868
  if not runs_c:
869
  st.warning("No run matches the current filters.")
 
871
  if compare and len({r.extract_rev for r in runs_c}) > 1:
872
  _render_compare_view(runs_c)
873
  else:
 
 
874
  if len(runs_c) > 1:
875
  run = st.sidebar.selectbox("run file (disambiguate)", runs_c,
876
  format_func=lambda r: _sparse_tail(r.steered_path))
877
  else:
878
  run = runs_c[0]
879
  _render_single(run)
880
+ else: # extraction generations
881
+ ext = _extraction_sidebar(extracts)
882
+ if ext is None:
883
+ st.warning("No extraction pair matches the current filters.")
884
+ return
885
+ _render_extraction_view(ext)
886
 
887
 
888
  if __name__ == "__main__":
data/model_responses/extract/Olmo-3-1025-7B/main/evil_character_neutral_q_neg_instruct.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/model_responses/extract/Olmo-3-1025-7B/main/evil_character_neutral_q_pos_instruct.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/model_responses/extract/Olmo-3-1025-7B/main/evil_combined_neg_instruct.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/model_responses/extract/Olmo-3-1025-7B/main/evil_combined_pos_instruct.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/model_responses/extract/Olmo-3-1025-7B/main/evil_dialogue_neutral_q_neg_instruct.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/model_responses/extract/Olmo-3-1025-7B/main/evil_dialogue_neutral_q_pos_instruct.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/model_responses/extract/Olmo-3-1025-7B/main/evil_stories_neutral_q_neg_instruct.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/model_responses/extract/Olmo-3-1025-7B/main/evil_stories_neutral_q_pos_instruct.csv ADDED
The diff for this file is too large to render. See raw diff