josephsoo commited on
Commit
3a1d4e4
·
1 Parent(s): 1c16d59

Add interactive Figure 4 feature views

Browse files
app.py CHANGED
@@ -125,11 +125,6 @@ FEATURE_GROUP_COLORS = {
125
  "monkey": {"Recorded": "#0072B2", "Synthetic control": "#BDBDBD"},
126
  "speech": {"Recorded": "#009E73", "Synthetic control": "#BDBDBD"},
127
  "mc_pacman": {"Recorded": "#D55E00", "Synthetic control": "#BDBDBD"},
128
- "allen_neuropixels": {
129
- "High orientation selectivity": "#E69F00",
130
- "Intermediate orientation selectivity": "#F0E442",
131
- "Low orientation selectivity": "#BDBDBD",
132
- },
133
  "ratinabox": {
134
  "Place": "#CC79A7",
135
  "Head direction": "#56B4E9",
@@ -322,6 +317,7 @@ DOWNLOADABLE_FILES = {
322
  "dataset_overview.csv",
323
  "dataset_example_neural.csv",
324
  "dataset_example_targets.csv",
 
325
  "clean_prediction_summary.csv",
326
  "robustness_summary.csv",
327
  "consistency_summary.csv",
@@ -422,6 +418,7 @@ def load_historical_trajectories() -> pd.DataFrame:
422
  dataset_overview = load_csv("dataset_overview.csv")
423
  dataset_example_neural = load_csv("dataset_example_neural.csv")
424
  dataset_example_targets = load_csv("dataset_example_targets.csv")
 
425
  prediction = load_csv("clean_prediction_summary.csv")
426
  robustness = load_csv("robustness_summary.csv")
427
  consistency = load_csv("consistency_summary.csv")
@@ -1402,8 +1399,8 @@ def feature_spec(dataset: str) -> tuple[str, str, str, float | None]:
1402
  if dataset == "allen_neuropixels":
1403
  return (
1404
  "spearman_corr",
1405
- "Drifting-gratings orientation selectivity",
1406
- "Spearman’s r",
1407
  0.0,
1408
  )
1409
  if dataset == "ratinabox":
@@ -1522,161 +1519,459 @@ def feature_attribution_frame(dataset: str, model: str | None) -> pd.DataFrame:
1522
  return frame.sort_values("attribution_rank", kind="stable")
1523
 
1524
 
1525
- def rgba(hex_color: str, alpha: float) -> str:
1526
- value = hex_color.lstrip("#")
1527
- red, green, blue = (int(value[index : index + 2], 16) for index in (0, 2, 4))
1528
- return f"rgba({red},{green},{blue},{alpha})"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1529
 
1530
 
1531
- def feature_attribution_figures(
1532
  dataset: str,
1533
  model: str | None,
1534
- selected_rank: int | None = None,
1535
- ) -> tuple[go.Figure, go.Figure, str]:
1536
- frame = feature_attribution_frame(dataset, model)
1537
  if frame.empty:
1538
- message = "Select an available method to inspect feature-level attributions."
1539
- return empty_figure(message, height=460), empty_figure(message, height=460), ""
1540
- if frame["signed_attribution"].nunique(dropna=True) <= 1:
1541
- message = "Signed feature-attribution values are tied for this method and dataset."
1542
- return empty_figure(message, height=480), empty_figure(message, height=480), message
1543
-
1544
- rank_values = frame["attribution_rank"].astype(int)
1545
- if selected_rank is None or int(selected_rank) not in set(rank_values):
1546
- selected_rank = int(rank_values.min())
1547
- selected = frame[rank_values.eq(int(selected_rank))].iloc[0]
1548
- selected_group = str(selected["feature_group"])
1549
- selected_bin = str(selected["attribution_bin"])
1550
-
1551
- group_colors = FEATURE_GROUP_COLORS[dataset]
1552
- rank_figure = go.Figure()
1553
- for group, color in group_colors.items():
1554
- subset = frame[frame["feature_group"].astype(str).eq(group)]
1555
- if subset.empty:
1556
- continue
1557
- customdata = np.stack(
1558
- [subset["feature_index"], subset["attribution_rank"], subset["validation_value"]],
1559
- axis=-1,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1560
  )
1561
  validation_hover = (
1562
- "<br>Orientation selectivity=%{customdata[2]:.4f}"
1563
  if dataset == "allen_neuropixels"
1564
  else ""
1565
  )
1566
- rank_figure.add_trace(
1567
- go.Scatter(
1568
- x=subset["attribution_rank"],
1569
- y=subset["signed_attribution"],
1570
- mode="markers",
1571
- name=group,
1572
- marker=dict(
1573
- color=color,
1574
- size=5 if len(frame) > 300 else 7,
1575
- opacity=0.78,
1576
- line=dict(color="#FFFFFF", width=0.4),
1577
- ),
1578
- customdata=customdata,
1579
- hovertemplate=(
1580
- "Feature=%{customdata[0]:.0f}<br>Group="
1581
- + group
1582
- + "<br>Rank=%{customdata[1]:.0f}<br>Signed attribution=%{y:.5f}"
1583
- + validation_hover
1584
- + "<extra></extra>"
1585
- ),
1586
- )
1587
  )
1588
- rank_figure.add_trace(
1589
- go.Scatter(
1590
- x=[int(selected["attribution_rank"])],
1591
- y=[float(selected["signed_attribution"])],
 
 
 
 
 
 
 
 
 
 
 
1592
  mode="markers",
1593
- name="Selected feature",
1594
- showlegend=False,
1595
- marker=dict(
1596
- color=group_colors[selected_group],
1597
- size=14,
1598
- line=dict(color="#172938", width=2.2),
1599
- ),
1600
  hovertemplate=(
1601
- f"Feature index={int(selected['feature_index'])}<br>"
1602
- f"Group={selected_group}<br>Rank={int(selected['attribution_rank'])}<br>"
1603
- f"Signed attribution={float(selected['signed_attribution']):.5f}"
1604
- "<extra></extra>"
 
 
1605
  ),
1606
  )
 
 
 
 
 
 
 
 
1607
  )
1608
- rank_figure.add_hline(y=0, line_color="#71808D", line_dash="dash")
1609
- rank_figure.update_layout(title="Signed feature ranking")
1610
- rank_figure.update_xaxes(title="Attribution rank")
1611
- rank_figure.update_yaxes(title="Signed Kernel SHAP value")
1612
- figure_layout(rank_figure, height=480, legend_below=True)
1613
-
1614
- groups = [group for group in group_colors if group in set(frame["feature_group"])]
1615
- nodes = groups + ATTRIBUTION_BIN_ORDER
1616
- node_index = {label: index for index, label in enumerate(nodes)}
1617
- counts = (
1618
- frame.groupby(["feature_group", "attribution_bin"], observed=True)
1619
- .size()
1620
- .to_dict()
1621
- )
1622
- sources: list[int] = []
1623
- targets: list[int] = []
1624
- values: list[int] = []
1625
- link_colors: list[str] = []
1626
- for group in groups:
1627
  for rank_bin in ATTRIBUTION_BIN_ORDER:
1628
- count = int(counts.get((group, rank_bin), 0))
1629
- if count == 0:
 
 
1630
  continue
1631
- sources.append(node_index[group])
1632
- targets.append(node_index[rank_bin])
1633
- values.append(count)
1634
- is_selected_route = group == selected_group and rank_bin == selected_bin
1635
- link_colors.append(
1636
- rgba(group_colors[group], 0.78 if is_selected_route else 0.18)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1637
  )
1638
-
1639
- source_y = np.linspace(0.08, 0.92, len(groups)).tolist()
1640
- target_y = [0.12, 0.5, 0.88]
1641
- sankey = go.Figure(
1642
- go.Sankey(
1643
- arrangement="fixed",
1644
- node=dict(
1645
- label=groups + [f"{label} third" for label in ATTRIBUTION_BIN_ORDER],
1646
- color=[group_colors[group] for group in groups]
1647
- + [ATTRIBUTION_BIN_COLORS[label] for label in ATTRIBUTION_BIN_ORDER],
1648
- line=dict(color="#FFFFFF", width=0.8),
1649
- pad=18,
1650
- thickness=18,
1651
- x=[0.02] * len(groups) + [0.98] * len(ATTRIBUTION_BIN_ORDER),
1652
- y=source_y + target_y,
1653
- hovertemplate="%{label}<br>%{value:.0f} features<extra></extra>",
1654
- ),
1655
- link=dict(
1656
- source=sources,
1657
- target=targets,
1658
- value=values,
1659
- color=link_colors,
1660
- hovertemplate=(
1661
- "%{source.label} → %{target.label}<br>"
1662
- "%{value:.0f} features<extra></extra>"
1663
- ),
1664
- ),
1665
  )
1666
- )
1667
- sankey.update_layout(title="Feature groups by attribution rank")
1668
- figure_layout(sankey, height=480)
1669
- sankey.update_layout(margin=dict(l=28, r=28, t=62, b=30))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1670
 
1671
- detail = (
1672
- f"Feature index {int(selected['feature_index'])} · "
1673
- f"rank {int(selected['attribution_rank'])} of {len(frame)} · "
1674
- f"{selected_group} → {selected_bin.lower()} third · "
1675
- f"signed Kernel SHAP {float(selected['signed_attribution']):+.5f}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1676
  )
1677
- if dataset == "allen_neuropixels" and pd.notna(selected["validation_value"]):
1678
- detail += f" · orientation selectivity {float(selected['validation_value']):.4f}"
1679
- return rank_figure, sankey, detail
1680
 
1681
 
1682
  def trial_frame(dataset: str, models: Sequence[str] | None) -> pd.DataFrame:
@@ -2708,39 +3003,51 @@ app.layout = html.Div(
2708
  ),
2709
  html.Div(
2710
  [
2711
- html.Label("Feature rank", htmlFor="feature-rank"),
2712
- dcc.Slider(
2713
- id="feature-rank",
2714
- min=1,
2715
- max=2,
2716
- step=1,
2717
- value=1,
2718
- marks={1: "Highest", 2: "Lowest"},
2719
- disabled=True,
2720
- tooltip={"placement": "bottom"},
 
 
 
 
 
 
2721
  ),
2722
  ],
2723
- className="control feature-rank-control",
2724
  ),
2725
  ],
2726
- className="inline-controls feature-controls",
2727
  ),
2728
  html.Div(id="feature-selection-detail", className="feature-selection-detail"),
2729
  html.Div(
2730
  [
2731
  graph_box(
2732
- "feature-rank-plot",
2733
- "Signed neural-feature attributions ranked from highest to lowest.",
2734
  ),
2735
  graph_box(
2736
- "feature-sankey",
2737
- "Feature groups mapped to top, middle and bottom attribution-rank thirds.",
2738
  ),
2739
  ],
2740
- className="chart-grid two",
 
 
 
 
 
 
 
2741
  ),
2742
- source_link("neuron_attributions.csv", "Feature-level CSV"),
2743
- subtitle="Move through the signed Kernel SHAP ranking to highlight where each input feature falls. Ranks are within the selected method and dataset.",
2744
  class_name="axis-feature",
2745
  ),
2746
  panel(
@@ -3033,43 +3340,70 @@ def update_feature_selector(
3033
 
3034
 
3035
  @app.callback(
3036
- Output("feature-rank", "max"),
3037
- Output("feature-rank", "value"),
3038
- Output("feature-rank", "marks"),
3039
- Output("feature-rank", "disabled"),
 
 
 
 
 
 
3040
  Input("dataset-filter", "value"),
3041
  Input("feature-method", "value"),
3042
- State("feature-rank", "value"),
3043
  )
3044
- def update_feature_rank_slider(
3045
  dataset: str,
3046
  method: str | None,
3047
- current_rank: int | None,
3048
  ):
3049
- frame = feature_attribution_frame(dataset or DATASETS[0], method)
3050
- if frame.empty or frame["signed_attribution"].nunique(dropna=True) <= 1:
3051
- return 2, 1, {1: "Highest", 2: "Lowest"}, True
3052
- maximum = int(frame["attribution_rank"].max())
3053
- value = int(current_rank) if current_rank and int(current_rank) <= maximum else 1
3054
- return maximum, value, {1: "Highest", maximum: "Lowest"}, False
3055
 
3056
 
3057
  @app.callback(
3058
- Output("feature-rank-plot", "figure"),
3059
- Output("feature-sankey", "figure"),
3060
  Output("feature-selection-detail", "children"),
 
3061
  Input("dataset-filter", "value"),
3062
  Input("feature-method", "value"),
3063
- Input("feature-rank", "value"),
3064
  )
3065
- def update_feature_attributions(
 
3066
  dataset: str,
3067
  method: str | None,
3068
- selected_rank: int | None,
3069
  ):
3070
- return feature_attribution_figures(
3071
- dataset or DATASETS[0], method, selected_rank
3072
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3073
 
3074
 
3075
  @app.callback(
@@ -3087,8 +3421,8 @@ def update_feature(dataset: str, models: list[str] | None):
3087
  _column, _target, metric, _reference = feature_spec(dataset)
3088
  if dataset == "allen_neuropixels":
3089
  definition = (
3090
- "Spearman’s r measures association between feature-attribution values "
3091
- "and each unit’s drifting-gratings orientation selectivity."
3092
  )
3093
  elif dataset == "ratinabox":
3094
  definition = (
 
125
  "monkey": {"Recorded": "#0072B2", "Synthetic control": "#BDBDBD"},
126
  "speech": {"Recorded": "#009E73", "Synthetic control": "#BDBDBD"},
127
  "mc_pacman": {"Recorded": "#D55E00", "Synthetic control": "#BDBDBD"},
 
 
 
 
 
128
  "ratinabox": {
129
  "Place": "#CC79A7",
130
  "Head direction": "#56B4E9",
 
317
  "dataset_overview.csv",
318
  "dataset_example_neural.csv",
319
  "dataset_example_targets.csv",
320
+ "feature_example_raster.csv",
321
  "clean_prediction_summary.csv",
322
  "robustness_summary.csv",
323
  "consistency_summary.csv",
 
418
  dataset_overview = load_csv("dataset_overview.csv")
419
  dataset_example_neural = load_csv("dataset_example_neural.csv")
420
  dataset_example_targets = load_csv("dataset_example_targets.csv")
421
+ feature_example_raster = load_csv("feature_example_raster.csv")
422
  prediction = load_csv("clean_prediction_summary.csv")
423
  robustness = load_csv("robustness_summary.csv")
424
  consistency = load_csv("consistency_summary.csv")
 
1399
  if dataset == "allen_neuropixels":
1400
  return (
1401
  "spearman_corr",
1402
+ "Drifting-gratings gOSI",
1403
+ "Spearman’s ρ",
1404
  0.0,
1405
  )
1406
  if dataset == "ratinabox":
 
1519
  return frame.sort_values("attribution_rank", kind="stable")
1520
 
1521
 
1522
+ def feature_raster_figure(dataset: str) -> go.Figure:
1523
+ frame = feature_example_raster[
1524
+ feature_example_raster["dataset"].astype(str).eq(dataset)
1525
+ ].copy()
1526
+ if frame.empty:
1527
+ return empty_figure("No example raster is available.", height=560)
1528
+
1529
+ numeric_columns = [
1530
+ "trial_index",
1531
+ "display_index",
1532
+ "feature_index",
1533
+ "validation_value",
1534
+ "group_order",
1535
+ "n_time",
1536
+ "t0_index",
1537
+ "bin_ms",
1538
+ ]
1539
+ for column in numeric_columns:
1540
+ frame[column] = pd.to_numeric(frame[column], errors="coerce")
1541
+ if dataset == "allen_neuropixels":
1542
+ frame = frame.sort_values(
1543
+ ["validation_value", "feature_index"], ascending=[False, True]
1544
+ )
1545
+ else:
1546
+ frame = frame.sort_values(["group_order", "display_index"])
1547
+
1548
+ n_time = int(frame["n_time"].iloc[0])
1549
+ value_columns = [f"value_{index:03d}" for index in range(n_time)]
1550
+ raw = frame[value_columns].apply(pd.to_numeric, errors="coerce").to_numpy(dtype=float)
1551
+ vmax = max(float(np.nanpercentile(raw, 99.5)), 1.0)
1552
+ display = np.sqrt(np.clip(raw, 0.0, vmax) / vmax)
1553
+ time_ms = (
1554
+ np.arange(n_time) - int(frame["t0_index"].iloc[0])
1555
+ ) * float(frame["bin_ms"].iloc[0])
1556
+ feature_ids = frame["feature_index"].to_numpy(dtype=int)
1557
+ customdata = np.empty((len(frame), n_time, 2), dtype=object)
1558
+ customdata[:, :, 0] = feature_ids[:, None]
1559
+ customdata[:, :, 1] = raw
1560
+
1561
+ figure = make_subplots(
1562
+ rows=1,
1563
+ cols=2,
1564
+ shared_yaxes=True,
1565
+ column_widths=[0.035, 0.965],
1566
+ horizontal_spacing=0.012,
1567
+ )
1568
+ if dataset == "allen_neuropixels":
1569
+ strip_values = frame["validation_value"].to_numpy(dtype=float)[:, None]
1570
+ strip_custom = np.empty((len(frame), 1, 2), dtype=object)
1571
+ strip_custom[:, :, 0] = feature_ids[:, None]
1572
+ strip_custom[:, :, 1] = strip_values
1573
+ strip = go.Heatmap(
1574
+ z=strip_values,
1575
+ y=np.arange(len(frame)),
1576
+ customdata=strip_custom,
1577
+ colorscale="Cividis",
1578
+ zmin=float(np.nanmin(strip_values)),
1579
+ zmax=float(np.nanmax(strip_values)),
1580
+ showscale=False,
1581
+ hovertemplate=(
1582
+ "Feature=%{customdata[0]:.0f}<br>"
1583
+ "gOSI=%{customdata[1]:.4f}<extra></extra>"
1584
+ ),
1585
+ )
1586
+ strip_title = "gOSI"
1587
+ else:
1588
+ group_colors = FEATURE_GROUP_COLORS[dataset]
1589
+ groups = [group for group in group_colors if group in set(frame["feature_group"])]
1590
+ codes = frame["feature_group"].map({group: index for index, group in enumerate(groups)})
1591
+ scale: list[list[float | str]] = []
1592
+ for index, group in enumerate(groups):
1593
+ lower = index / len(groups)
1594
+ upper = (index + 1) / len(groups)
1595
+ scale.extend([[lower, group_colors[group]], [upper, group_colors[group]]])
1596
+ strip_values = codes.to_numpy(dtype=float)[:, None]
1597
+ strip_custom = np.asarray(
1598
+ [[[int(feature), str(group)]] for feature, group in zip(feature_ids, frame["feature_group"])],
1599
+ dtype=object,
1600
+ )
1601
+ strip = go.Heatmap(
1602
+ z=strip_values,
1603
+ y=np.arange(len(frame)),
1604
+ customdata=strip_custom,
1605
+ colorscale=scale,
1606
+ zmin=0,
1607
+ zmax=max(len(groups), 1),
1608
+ showscale=False,
1609
+ hovertemplate=(
1610
+ "Feature=%{customdata[0]:.0f}<br>"
1611
+ "Group=%{customdata[1]}<extra></extra>"
1612
+ ),
1613
+ )
1614
+ strip_title = "Group"
1615
+ figure.add_trace(strip, row=1, col=1)
1616
+
1617
+ count_ticks = np.linspace(0.0, vmax, 5)
1618
+ figure.add_trace(
1619
+ go.Heatmap(
1620
+ z=display,
1621
+ x=time_ms,
1622
+ y=np.arange(len(frame)),
1623
+ customdata=customdata,
1624
+ colorscale=[[0.0, "#FFFFFF"], [1.0, "#263238"]],
1625
+ zmin=0,
1626
+ zmax=1,
1627
+ colorbar=dict(
1628
+ title="Count",
1629
+ thickness=11,
1630
+ tickvals=np.sqrt(count_ticks / vmax),
1631
+ ticktext=[f"{value:g}" for value in count_ticks],
1632
+ ),
1633
+ hovertemplate=(
1634
+ "Feature=%{customdata[0]:.0f}<br>Time=%{x:.0f} ms<br>"
1635
+ "Count=%{customdata[1]:.0f}<extra></extra>"
1636
+ ),
1637
+ ),
1638
+ row=1,
1639
+ col=2,
1640
+ )
1641
+ figure.add_vline(x=0, line_color="#D55E00", line_dash="dash", row=1, col=2)
1642
+ if dataset != "allen_neuropixels":
1643
+ for group in frame["feature_group"].drop_duplicates().astype(str):
1644
+ indices = np.flatnonzero(frame["feature_group"].astype(str).eq(group))
1645
+ if len(indices):
1646
+ figure.add_annotation(
1647
+ x=0.01,
1648
+ xref="paper",
1649
+ y=float(indices.mean()),
1650
+ yref="y2",
1651
+ text=group,
1652
+ showarrow=False,
1653
+ xanchor="left",
1654
+ bgcolor="rgba(255,255,255,0.82)",
1655
+ font=dict(size=10, color="#334957"),
1656
+ )
1657
+ figure.add_hline(
1658
+ y=float(indices[-1]) + 0.5,
1659
+ line_color="#FFFFFF",
1660
+ line_width=1.2,
1661
+ row=1,
1662
+ col=2,
1663
+ )
1664
+ figure_layout(figure, height=560)
1665
+ figure.update_layout(
1666
+ title="Example neural activity",
1667
+ margin=dict(l=18, r=24, t=66, b=58),
1668
+ )
1669
+ figure.add_annotation(
1670
+ x=0.013,
1671
+ y=1.035,
1672
+ xref="paper",
1673
+ yref="paper",
1674
+ text=strip_title,
1675
+ showarrow=False,
1676
+ font=dict(size=10, color=MUTED_COLOR),
1677
+ )
1678
+ figure.update_xaxes(visible=False, row=1, col=1)
1679
+ figure.update_yaxes(visible=False, autorange="reversed", row=1, col=1)
1680
+ figure.update_xaxes(title="Time from scoring onset (ms)", row=1, col=2)
1681
+ figure.update_yaxes(visible=False, autorange="reversed", row=1, col=2)
1682
+ return figure
1683
 
1684
 
1685
+ def feature_sorter_figure(
1686
  dataset: str,
1687
  model: str | None,
1688
+ arrangement: str = "ranked",
1689
+ ) -> go.Figure:
1690
+ frame = feature_attribution_frame(dataset, model).reset_index(drop=True)
1691
  if frame.empty:
1692
+ return empty_figure("Select an available method to inspect feature attributions.", height=560)
1693
+ ranked = arrangement == "ranked"
1694
+ has_ranking = frame["signed_attribution"].nunique(dropna=True) > 1
1695
+ if ranked and not has_ranking:
1696
+ return empty_figure(
1697
+ "Signed feature-attribution values are tied for this method and dataset.",
1698
+ height=560,
1699
+ )
1700
+
1701
+ n_features = len(frame)
1702
+ values = frame["signed_attribution"].to_numpy(dtype=float)
1703
+ ranks = frame["attribution_rank"].to_numpy(dtype=float)
1704
+ y_low = min(float(np.nanmin(values)), 0.0)
1705
+ y_high = max(float(np.nanmax(values)), 0.0)
1706
+ y_span = max(y_high - y_low, 1e-6)
1707
+ final_x = (ranks - 1.0) / max(n_features - 1, 1)
1708
+ final_y = (values - y_low) / y_span
1709
+ display_range = [-0.035, 1.055]
1710
+
1711
+ group_colors = FEATURE_GROUP_COLORS.get(dataset, {})
1712
+ if dataset == "allen_neuropixels":
1713
+ validation = frame["validation_value"].to_numpy(dtype=float)
1714
+ vmin = float(np.nanmin(validation))
1715
+ vmax = float(np.nanmax(validation))
1716
+ scale = max(vmax - vmin, 1e-9)
1717
+ start_x = (validation - vmin) / scale
1718
+ feature_ids = frame["feature_index"].to_numpy(dtype=int)
1719
+ start_y = 0.5 + (
1720
+ ((feature_ids * 37) % 101) / 100.0 - 0.5
1721
+ ) * 0.42
1722
+ trace_groups = ["gOSI"]
1723
+ trace_indices = [np.arange(n_features)]
1724
+ else:
1725
+ trace_groups = [
1726
+ group for group in group_colors if group in set(frame["feature_group"])
1727
+ ]
1728
+ trace_indices = [
1729
+ np.flatnonzero(frame["feature_group"].astype(str).eq(group))
1730
+ for group in trace_groups
1731
+ ]
1732
+ centers = np.linspace(0.7, 0.3, len(trace_groups))
1733
+ start_x = np.zeros(n_features, dtype=float)
1734
+ start_y = np.zeros(n_features, dtype=float)
1735
+ for center, indices in zip(centers, trace_indices):
1736
+ ordered = indices[
1737
+ np.argsort(frame.loc[indices, "feature_index"].to_numpy(), kind="stable")
1738
+ ]
1739
+ start_x[ordered] = np.linspace(0.0, 1.0, len(ordered))
1740
+ lane_jitter = ((np.arange(len(ordered)) % 7) - 3) * 0.006
1741
+ start_y[ordered] = center + lane_jitter
1742
+
1743
+ marker_size = 6 if n_features > 300 else (8 if n_features > 120 else 10)
1744
+
1745
+ def trace_for(indices: np.ndarray, group: str, x: np.ndarray, y: np.ndarray) -> go.Scatter:
1746
+ subset = frame.iloc[indices]
1747
+ customdata = np.asarray(
1748
+ [
1749
+ [
1750
+ int(row.feature_index),
1751
+ int(row.attribution_rank),
1752
+ float(row.signed_attribution),
1753
+ float(row.validation_value),
1754
+ str(row.feature_group),
1755
+ str(row.attribution_bin),
1756
+ ]
1757
+ for row in subset.itertuples(index=False)
1758
+ ],
1759
+ dtype=object,
1760
  )
1761
  validation_hover = (
1762
+ "<br>gOSI=%{customdata[3]:.4f}"
1763
  if dataset == "allen_neuropixels"
1764
  else ""
1765
  )
1766
+ group_hover = (
1767
+ "" if dataset == "allen_neuropixels" else "<br>Group=%{customdata[4]}"
1768
+ )
1769
+ marker: dict = dict(
1770
+ size=marker_size,
1771
+ opacity=0.82,
1772
+ line=dict(color="#FFFFFF", width=0.45),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1773
  )
1774
+ if dataset == "allen_neuropixels":
1775
+ marker.update(
1776
+ color=subset["validation_value"],
1777
+ colorscale="Cividis",
1778
+ cmin=float(frame["validation_value"].min()),
1779
+ cmax=float(frame["validation_value"].max()),
1780
+ colorbar=dict(title="gOSI", thickness=12),
1781
+ )
1782
+ else:
1783
+ marker["color"] = group_colors[group]
1784
+ rank_hover = "<br>Rank=%{customdata[1]:.0f}" if ranked and has_ranking else ""
1785
+ return go.Scatter(
1786
+ x=x[indices],
1787
+ y=y[indices],
1788
+ ids=[f"{dataset}:{int(value)}" for value in subset["feature_index"]],
1789
  mode="markers",
1790
+ name=group,
1791
+ showlegend=dataset != "allen_neuropixels",
1792
+ marker=marker,
1793
+ customdata=customdata,
 
 
 
1794
  hovertemplate=(
1795
+ "Feature=%{customdata[0]:.0f}"
1796
+ + group_hover
1797
+ + rank_hover
1798
+ + "<br>Signed Kernel SHAP=%{customdata[2]:+.5f}"
1799
+ + validation_hover
1800
+ + "<extra></extra>"
1801
  ),
1802
  )
1803
+
1804
+ x = final_x if ranked else start_x
1805
+ y = final_y if ranked else start_y
1806
+ figure = go.Figure(
1807
+ data=[
1808
+ trace_for(indices, group, x, y)
1809
+ for group, indices in zip(trace_groups, trace_indices)
1810
+ ]
1811
  )
1812
+ shapes: list[dict] = []
1813
+ annotations: list[dict] = []
1814
+ if ranked:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1815
  for rank_bin in ATTRIBUTION_BIN_ORDER:
1816
+ bin_ranks = frame.loc[
1817
+ frame["attribution_bin"].astype(str).eq(rank_bin), "attribution_rank"
1818
+ ].to_numpy(dtype=float)
1819
+ if not len(bin_ranks):
1820
  continue
1821
+ bin_x = (bin_ranks - 1.0) / max(n_features - 1, 1)
1822
+ shapes.append(
1823
+ dict(
1824
+ type="rect",
1825
+ x0=float(bin_x.min()) - 0.5 / max(n_features - 1, 1),
1826
+ x1=float(bin_x.max()) + 0.5 / max(n_features - 1, 1),
1827
+ y0=display_range[0],
1828
+ y1=display_range[1],
1829
+ fillcolor=ATTRIBUTION_BIN_COLORS[rank_bin],
1830
+ opacity=0.045,
1831
+ line_width=0,
1832
+ layer="below",
1833
+ )
1834
+ )
1835
+ annotations.append(
1836
+ dict(
1837
+ x=float(bin_x.mean()),
1838
+ xref="x",
1839
+ y=1.035,
1840
+ yref="paper",
1841
+ text=f"{rank_bin} third",
1842
+ showarrow=False,
1843
+ font=dict(size=10, color="#334957"),
1844
+ )
1845
+ )
1846
+ shapes.append(
1847
+ dict(
1848
+ type="line",
1849
+ x0=display_range[0],
1850
+ x1=display_range[1],
1851
+ y0=(0.0 - y_low) / y_span,
1852
+ y1=(0.0 - y_low) / y_span,
1853
+ line=dict(color="#71808D", dash="dash", width=1.3),
1854
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1855
  )
1856
+ elif dataset != "allen_neuropixels":
1857
+ centers = np.linspace(0.7, 0.3, len(trace_groups))
1858
+ for center, group in zip(centers, trace_groups):
1859
+ shapes.append(
1860
+ dict(
1861
+ type="rect",
1862
+ x0=display_range[0],
1863
+ x1=display_range[1],
1864
+ y0=center - 0.055,
1865
+ y1=center + 0.055,
1866
+ fillcolor=group_colors[group],
1867
+ opacity=0.08,
1868
+ line_width=0,
1869
+ layer="below",
1870
+ )
1871
+ )
1872
+ annotations.append(
1873
+ dict(
1874
+ x=0.012,
1875
+ xref="paper",
1876
+ y=center,
1877
+ yref="y",
1878
+ text=group,
1879
+ showarrow=False,
1880
+ xanchor="left",
1881
+ bgcolor="rgba(255,255,255,0.85)",
1882
+ font=dict(size=11, color="#334957"),
1883
+ )
1884
+ )
1885
 
1886
+ title = (
1887
+ "Signed Kernel SHAP vs. rank"
1888
+ if ranked
1889
+ else "Continuous gOSI"
1890
+ if dataset == "allen_neuropixels"
1891
+ else "Features by reference group"
1892
+ )
1893
+ figure_layout(figure, height=560)
1894
+ if ranked:
1895
+ rank_ticks = np.unique(
1896
+ np.rint(np.linspace(1, n_features, 5)).astype(int)
1897
+ )
1898
+ rank_tickvals = (rank_ticks - 1.0) / max(n_features - 1, 1)
1899
+ value_ticks = np.linspace(y_low, y_high, 5)
1900
+ value_tickvals = (value_ticks - y_low) / y_span
1901
+ figure.update_xaxes(
1902
+ visible=True,
1903
+ range=display_range,
1904
+ tickmode="array",
1905
+ tickvals=rank_tickvals,
1906
+ ticktext=[str(value) for value in rank_ticks],
1907
+ title="Attribution rank",
1908
+ )
1909
+ figure.update_yaxes(
1910
+ visible=True,
1911
+ range=display_range,
1912
+ tickmode="array",
1913
+ tickvals=value_tickvals,
1914
+ ticktext=[f"{value:.3g}" for value in value_ticks],
1915
+ title="Signed Kernel SHAP value",
1916
+ )
1917
+ elif dataset == "allen_neuropixels":
1918
+ ticks = np.linspace(vmin, vmax, 5)
1919
+ tickvals = (ticks - vmin) / max(vmax - vmin, 1e-9)
1920
+ figure.update_xaxes(
1921
+ visible=True,
1922
+ range=display_range,
1923
+ tickmode="array",
1924
+ tickvals=tickvals,
1925
+ ticktext=[f"{value:.2f}" for value in ticks],
1926
+ title="gOSI",
1927
+ showgrid=False,
1928
+ )
1929
+ figure.update_yaxes(
1930
+ visible=True,
1931
+ range=display_range,
1932
+ showticklabels=False,
1933
+ ticks="",
1934
+ title=" ",
1935
+ showgrid=False,
1936
+ zeroline=False,
1937
+ )
1938
+ else:
1939
+ figure.update_xaxes(
1940
+ visible=True,
1941
+ range=display_range,
1942
+ showticklabels=False,
1943
+ ticks="",
1944
+ title=" ",
1945
+ showgrid=False,
1946
+ zeroline=False,
1947
+ )
1948
+ figure.update_yaxes(
1949
+ visible=True,
1950
+ range=display_range,
1951
+ showticklabels=False,
1952
+ ticks="",
1953
+ title=" ",
1954
+ showgrid=False,
1955
+ zeroline=False,
1956
+ )
1957
+ figure.update_xaxes(automargin=False)
1958
+ figure.update_yaxes(automargin=False)
1959
+ figure.update_layout(
1960
+ title=title,
1961
+ shapes=shapes,
1962
+ annotations=annotations,
1963
+ margin=dict(l=58, r=28, t=78, b=100),
1964
+ uirevision=f"feature-sorter:{dataset}:{model}:{arrangement}",
1965
+ legend=dict(
1966
+ orientation="h",
1967
+ yanchor="bottom",
1968
+ y=1.04,
1969
+ xanchor="left",
1970
+ x=0,
1971
+ font=dict(size=10),
1972
+ ),
1973
  )
1974
+ return figure
 
 
1975
 
1976
 
1977
  def trial_frame(dataset: str, models: Sequence[str] | None) -> pd.DataFrame:
 
3003
  ),
3004
  html.Div(
3005
  [
3006
+ html.Label("Arrange features", htmlFor="feature-arrangement"),
3007
+ dcc.RadioItems(
3008
+ id="feature-arrangement",
3009
+ options=[
3010
+ {
3011
+ "label": "Reference annotation",
3012
+ "value": "reference",
3013
+ },
3014
+ {
3015
+ "label": "Signed SHAP ranking",
3016
+ "value": "ranked",
3017
+ },
3018
+ ],
3019
+ value="ranked",
3020
+ inline=True,
3021
+ className="arrangement-toggle",
3022
  ),
3023
  ],
3024
+ className="control",
3025
  ),
3026
  ],
3027
+ className="inline-controls",
3028
  ),
3029
  html.Div(id="feature-selection-detail", className="feature-selection-detail"),
3030
  html.Div(
3031
  [
3032
  graph_box(
3033
+ "feature-raster",
3034
+ "Example neural activity raster ordered by reference annotation.",
3035
  ),
3036
  graph_box(
3037
+ "feature-rank-plot",
3038
+ "Interactive input-feature views showing reference annotations and signed Kernel SHAP score versus rank.",
3039
  ),
3040
  ],
3041
+ className="chart-grid feature-story-grid",
3042
+ ),
3043
+ html.Div(
3044
+ [
3045
+ source_link("feature_example_raster.csv", "Example raster CSV"),
3046
+ source_link("neuron_attributions.csv", "Feature-level CSV"),
3047
+ ],
3048
+ className="download-grid panel-downloads",
3049
  ),
3050
+ subtitle="Each dot is one input feature. Switch views to follow it from the reference annotation into the signed Kernel SHAP score-versus-rank curve.",
 
3051
  class_name="axis-feature",
3052
  ),
3053
  panel(
 
3340
 
3341
 
3342
  @app.callback(
3343
+ Output("feature-raster", "figure"),
3344
+ Input("dataset-filter", "value"),
3345
+ )
3346
+ def update_feature_raster(dataset: str):
3347
+ return feature_raster_figure(dataset or DATASETS[0])
3348
+
3349
+
3350
+ @app.callback(
3351
+ Output("feature-rank-plot", "figure"),
3352
+ Output("feature-rank-plot", "clickData"),
3353
  Input("dataset-filter", "value"),
3354
  Input("feature-method", "value"),
3355
+ Input("feature-arrangement", "value"),
3356
  )
3357
+ def update_feature_attributions(
3358
  dataset: str,
3359
  method: str | None,
3360
+ arrangement: str,
3361
  ):
3362
+ sorter = feature_sorter_figure(
3363
+ dataset or DATASETS[0], method, arrangement or "ranked"
3364
+ )
3365
+ return sorter, None
 
 
3366
 
3367
 
3368
  @app.callback(
 
 
3369
  Output("feature-selection-detail", "children"),
3370
+ Input("feature-rank-plot", "clickData"),
3371
  Input("dataset-filter", "value"),
3372
  Input("feature-method", "value"),
3373
+ Input("feature-arrangement", "value"),
3374
  )
3375
+ def update_feature_selection_detail(
3376
+ click_data: dict | None,
3377
  dataset: str,
3378
  method: str | None,
3379
+ arrangement: str,
3380
  ):
3381
+ instruction = (
3382
+ "Switch views, then hover or click a dot to inspect its feature index, "
3383
+ "signed Kernel SHAP value and reference annotation."
3384
+ )
3385
+ if not click_data or not click_data.get("points"):
3386
+ return instruction
3387
+ custom = click_data["points"][0].get("customdata")
3388
+ if not custom or len(custom) < 6:
3389
+ return instruction
3390
+ frame = feature_attribution_frame(dataset or DATASETS[0], method)
3391
+ feature_index = int(float(custom[0]))
3392
+ selected = frame[
3393
+ frame["feature_index"].astype(int).eq(feature_index)
3394
+ ]
3395
+ if selected.empty:
3396
+ return instruction
3397
+ row = selected.iloc[0]
3398
+ detail = f"Feature index {feature_index}"
3399
+ if arrangement == "ranked" and frame["signed_attribution"].nunique(dropna=True) > 1:
3400
+ detail += f" · rank {int(row['attribution_rank'])} of {len(frame)}"
3401
+ detail += f" · signed Kernel SHAP {float(row['signed_attribution']):+.5f}"
3402
+ if dataset == "allen_neuropixels" and pd.notna(row["validation_value"]):
3403
+ detail += f" · gOSI {float(row['validation_value']):.4f}"
3404
+ else:
3405
+ detail += f" · {row['feature_group']}"
3406
+ return detail
3407
 
3408
 
3409
  @app.callback(
 
3421
  _column, _target, metric, _reference = feature_spec(dataset)
3422
  if dataset == "allen_neuropixels":
3423
  definition = (
3424
+ "Spearman’s ρ measures association between feature-attribution values "
3425
+ "and each unit’s gOSI measured from drifting gratings."
3426
  )
3427
  elif dataset == "ratinabox":
3428
  definition = (
assets/styles.css CHANGED
@@ -609,7 +609,7 @@ h2 {
609
 
610
  .inline-controls {
611
  display: grid;
612
- grid-template-columns: minmax(240px, 420px) minmax(200px, 300px);
613
  gap: 14px;
614
  align-items: end;
615
  margin-bottom: 12px;
@@ -623,12 +623,50 @@ h2 {
623
  grid-template-columns: minmax(240px, 420px);
624
  }
625
 
626
- .feature-controls {
627
- grid-template-columns: minmax(240px, 360px) minmax(320px, 1fr);
 
 
 
 
 
 
628
  }
629
 
630
- .feature-rank-control {
631
- padding: 0 7px 7px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
632
  }
633
 
634
  .feature-selection-detail {
@@ -820,6 +858,10 @@ h2 {
820
  .chart-grid.two {
821
  grid-template-columns: 1fr;
822
  }
 
 
 
 
823
  }
824
 
825
  @media (max-width: 820px) {
 
609
 
610
  .inline-controls {
611
  display: grid;
612
+ grid-template-columns: minmax(240px, 420px) minmax(260px, 380px);
613
  gap: 14px;
614
  align-items: end;
615
  margin-bottom: 12px;
 
623
  grid-template-columns: minmax(240px, 420px);
624
  }
625
 
626
+ .arrangement-toggle {
627
+ display: flex;
628
+ width: 100%;
629
+ max-width: 380px;
630
+ overflow: hidden;
631
+ border: 1px solid #cbd5dd;
632
+ border-radius: 7px;
633
+ background: #ffffff;
634
  }
635
 
636
+ .arrangement-toggle label {
637
+ position: relative;
638
+ flex: 1 1 0;
639
+ min-width: 0;
640
+ margin: 0;
641
+ padding: 9px 13px;
642
+ border-right: 1px solid #dce3e8;
643
+ color: #5a6c79;
644
+ cursor: pointer;
645
+ font-size: 12px;
646
+ font-weight: 700;
647
+ letter-spacing: 0;
648
+ line-height: 1.55;
649
+ text-transform: none;
650
+ text-align: center;
651
+ white-space: normal;
652
+ }
653
+
654
+ .arrangement-toggle label:last-child {
655
+ border-right: 0;
656
+ }
657
+
658
+ .arrangement-toggle input {
659
+ position: absolute;
660
+ opacity: 0;
661
+ }
662
+
663
+ .arrangement-toggle label:has(input:checked) {
664
+ background: #eee9f6;
665
+ color: #4d3880;
666
+ }
667
+
668
+ .feature-story-grid {
669
+ grid-template-columns: minmax(320px, 0.78fr) minmax(0, 1.42fr);
670
  }
671
 
672
  .feature-selection-detail {
 
858
  .chart-grid.two {
859
  grid-template-columns: 1fr;
860
  }
861
+
862
+ .feature-story-grid {
863
+ grid-template-columns: 1fr;
864
+ }
865
  }
866
 
867
  @media (max-width: 820px) {
data/feature_example_raster.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/release_manifest.json CHANGED
@@ -6,7 +6,7 @@
6
  "manuscript_working_version": "manuscript_v7",
7
  "schema_version": 1,
8
  "source": "paper/results, active consistency and feature-attribution artifacts, benchmark dataset arrays, and Figure 5 prediction sidecars",
9
- "source_git_revision": "80def770609a52f45a835c6b62500b04cdc2b548",
10
  "source_repository": "https://github.com/TangLab-UBC/behavior_benchmarking",
11
  "source_worktree_dirty": true,
12
  "tables": {
@@ -30,6 +30,10 @@
30
  "rows": 5,
31
  "sha256": "b5fc3f2db54c30d4a7c038e5df869c3f3f3a9e609b84cea72400942f94d7605e"
32
  },
 
 
 
 
33
  "latent_samples.csv": {
34
  "rows": 31140,
35
  "sha256": "bb484c47b78bd7a7d0a2bfea94296e726aa80034f87368b32302aaba0c5aa622"
 
6
  "manuscript_working_version": "manuscript_v7",
7
  "schema_version": 1,
8
  "source": "paper/results, active consistency and feature-attribution artifacts, benchmark dataset arrays, and Figure 5 prediction sidecars",
9
+ "source_git_revision": "8524efc5a3a40f6ec43c3ab626424ac4597f49cf",
10
  "source_repository": "https://github.com/TangLab-UBC/behavior_benchmarking",
11
  "source_worktree_dirty": true,
12
  "tables": {
 
30
  "rows": 5,
31
  "sha256": "b5fc3f2db54c30d4a7c038e5df869c3f3f3a9e609b84cea72400942f94d7605e"
32
  },
33
+ "feature_example_raster.csv": {
34
+ "rows": 1057,
35
+ "sha256": "f905653af836026122424e4bc7984417512f4755412301e2c98803de5ab15215"
36
+ },
37
  "latent_samples.csv": {
38
  "rows": 31140,
39
  "sha256": "bb484c47b78bd7a7d0a2bfea94296e726aa80034f87368b32302aaba0c5aa622"
validate_data.py CHANGED
@@ -54,6 +54,11 @@ REQUIRED_COLUMNS = {
54
  "dataset", "trial_index", "time_index", "time_ms", "target_0",
55
  "target_1", "target_label",
56
  },
 
 
 
 
 
57
  "clean_prediction_summary.csv": {
58
  "model", "dataset", "status", "metric", "score", "decoder",
59
  },
@@ -114,6 +119,7 @@ UNIQUE_KEYS = {
114
  "dataset_overview.csv": ["dataset"],
115
  "dataset_example_neural.csv": ["dataset", "time_index", "feature_display_index"],
116
  "dataset_example_targets.csv": ["dataset", "time_index"],
 
117
  "clean_prediction_summary.csv": ["model", "dataset"],
118
  "robustness_summary.csv": ["model", "dataset"],
119
  "scalability_summary.csv": ["model", "dataset"],
@@ -337,6 +343,7 @@ def validate_local(data_dir: Path) -> dict[str, pd.DataFrame]:
337
  "dataset_overview.csv",
338
  "dataset_example_neural.csv",
339
  "dataset_example_targets.csv",
 
340
  ):
341
  if name in frames:
342
  observed = set(frames[name]["dataset"].dropna().astype(str))
@@ -375,13 +382,41 @@ def validate_local(data_dir: Path) -> dict[str, pd.DataFrame]:
375
  "dataset targets: expected one trial per dataset",
376
  errors,
377
  )
378
- classification = targets[targets["dataset"].isin({"allen_neuropixels", "speech"})]
 
 
379
  _require(
380
  len(classification) == 2 and classification["target_label"].notna().all(),
381
  "dataset targets: classification labels are missing",
382
  errors,
383
  )
384
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  if all(
386
  name in frames
387
  for name in (
@@ -466,6 +501,34 @@ def validate_local(data_dir: Path) -> dict[str, pd.DataFrame]:
466
  "neuron attributions: feature counts differ from summary",
467
  errors,
468
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
 
470
  if "trial_shapley_summary.csv" in frames:
471
  trial = frames["trial_shapley_summary.csv"]
 
54
  "dataset", "trial_index", "time_index", "time_ms", "target_0",
55
  "target_1", "target_label",
56
  },
57
+ "feature_example_raster.csv": {
58
+ "dataset", "trial_index", "display_index", "feature_index",
59
+ "feature_group", "validation_value", "group_order", "n_time",
60
+ "t0_index", "bin_ms",
61
+ },
62
  "clean_prediction_summary.csv": {
63
  "model", "dataset", "status", "metric", "score", "decoder",
64
  },
 
119
  "dataset_overview.csv": ["dataset"],
120
  "dataset_example_neural.csv": ["dataset", "time_index", "feature_display_index"],
121
  "dataset_example_targets.csv": ["dataset", "time_index"],
122
+ "feature_example_raster.csv": ["dataset", "feature_index"],
123
  "clean_prediction_summary.csv": ["model", "dataset"],
124
  "robustness_summary.csv": ["model", "dataset"],
125
  "scalability_summary.csv": ["model", "dataset"],
 
343
  "dataset_overview.csv",
344
  "dataset_example_neural.csv",
345
  "dataset_example_targets.csv",
346
+ "feature_example_raster.csv",
347
  ):
348
  if name in frames:
349
  observed = set(frames[name]["dataset"].dropna().astype(str))
 
382
  "dataset targets: expected one trial per dataset",
383
  errors,
384
  )
385
+ classification = targets[
386
+ targets["dataset"].isin({"allen_neuropixels", "speech"})
387
+ ]
388
  _require(
389
  len(classification) == 2 and classification["target_label"].notna().all(),
390
  "dataset targets: classification labels are missing",
391
  errors,
392
  )
393
 
394
+ if "feature_example_raster.csv" in frames:
395
+ raster = frames["feature_example_raster.csv"]
396
+ value_columns = sorted(
397
+ column for column in raster.columns if column.startswith("value_")
398
+ )
399
+ _require(
400
+ len(value_columns) == 300,
401
+ "feature raster: expected 300 time-value columns",
402
+ errors,
403
+ )
404
+ for dataset, group in raster.groupby("dataset"):
405
+ n_time = pd.to_numeric(group["n_time"], errors="coerce")
406
+ _require(
407
+ n_time.notna().all() and n_time.nunique() == 1,
408
+ f"feature raster: inconsistent time length for {dataset}",
409
+ errors,
410
+ )
411
+ if n_time.notna().all():
412
+ active_columns = value_columns[: int(n_time.iloc[0])]
413
+ active = group[active_columns].apply(pd.to_numeric, errors="coerce")
414
+ _require(
415
+ active.notna().all().all()
416
+ and np.isfinite(active.to_numpy(dtype=float)).all(),
417
+ f"feature raster: nonfinite activity for {dataset}",
418
+ errors,
419
+ )
420
  if all(
421
  name in frames
422
  for name in (
 
501
  "neuron attributions: feature counts differ from summary",
502
  errors,
503
  )
504
+ if "feature_example_raster.csv" in frames:
505
+ raster = frames["feature_example_raster.csv"]
506
+ raster_counts = raster.groupby("dataset")["feature_index"].nunique()
507
+ attribution_counts = features.groupby("dataset")["feature_index"].nunique()
508
+ _require(
509
+ raster_counts.equals(attribution_counts.reindex(raster_counts.index)),
510
+ "feature raster: feature counts differ from attributions",
511
+ errors,
512
+ )
513
+ raster_groups = raster[["dataset", "feature_index", "feature_group"]]
514
+ attribution_groups = features[
515
+ ["dataset", "feature_index", "feature_group"]
516
+ ].drop_duplicates()
517
+ merged = raster_groups.merge(
518
+ attribution_groups,
519
+ on=["dataset", "feature_index"],
520
+ how="outer",
521
+ suffixes=("_raster", "_attribution"),
522
+ indicator=True,
523
+ )
524
+ _require(
525
+ merged["_merge"].eq("both").all()
526
+ and merged["feature_group_raster"].eq(
527
+ merged["feature_group_attribution"]
528
+ ).all(),
529
+ "feature raster: group labels differ from attributions",
530
+ errors,
531
+ )
532
 
533
  if "trial_shapley_summary.csv" in frames:
534
  trial = frames["trial_shapley_summary.csv"]