Add interactive Figure 4 feature views
Browse files- app.py +516 -182
- assets/styles.css +47 -5
- data/feature_example_raster.csv +0 -0
- data/release_manifest.json +5 -1
- validate_data.py +64 -1
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
|
| 1406 |
-
"Spearman’s
|
| 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
|
| 1526 |
-
|
| 1527 |
-
|
| 1528 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1529 |
|
| 1530 |
|
| 1531 |
-
def
|
| 1532 |
dataset: str,
|
| 1533 |
model: str | None,
|
| 1534 |
-
|
| 1535 |
-
) ->
|
| 1536 |
-
frame = feature_attribution_frame(dataset, model)
|
| 1537 |
if frame.empty:
|
| 1538 |
-
|
| 1539 |
-
|
| 1540 |
-
|
| 1541 |
-
|
| 1542 |
-
return empty_figure(
|
| 1543 |
-
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
-
|
| 1547 |
-
|
| 1548 |
-
|
| 1549 |
-
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1560 |
)
|
| 1561 |
validation_hover = (
|
| 1562 |
-
"<br>
|
| 1563 |
if dataset == "allen_neuropixels"
|
| 1564 |
else ""
|
| 1565 |
)
|
| 1566 |
-
|
| 1567 |
-
|
| 1568 |
-
|
| 1569 |
-
|
| 1570 |
-
|
| 1571 |
-
|
| 1572 |
-
|
| 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 |
-
|
| 1589 |
-
|
| 1590 |
-
|
| 1591 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1592 |
mode="markers",
|
| 1593 |
-
name=
|
| 1594 |
-
showlegend=
|
| 1595 |
-
marker=
|
| 1596 |
-
|
| 1597 |
-
size=14,
|
| 1598 |
-
line=dict(color="#172938", width=2.2),
|
| 1599 |
-
),
|
| 1600 |
hovertemplate=(
|
| 1601 |
-
|
| 1602 |
-
|
| 1603 |
-
|
| 1604 |
-
"<
|
|
|
|
|
|
|
| 1605 |
),
|
| 1606 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1607 |
)
|
| 1608 |
-
|
| 1609 |
-
|
| 1610 |
-
|
| 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 |
-
|
| 1629 |
-
|
|
|
|
|
|
|
| 1630 |
continue
|
| 1631 |
-
|
| 1632 |
-
|
| 1633 |
-
|
| 1634 |
-
|
| 1635 |
-
|
| 1636 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1668 |
-
|
| 1669 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1670 |
|
| 1671 |
-
|
| 1672 |
-
|
| 1673 |
-
|
| 1674 |
-
|
| 1675 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1676 |
)
|
| 1677 |
-
|
| 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("
|
| 2712 |
-
dcc.
|
| 2713 |
-
id="feature-
|
| 2714 |
-
|
| 2715 |
-
|
| 2716 |
-
|
| 2717 |
-
|
| 2718 |
-
|
| 2719 |
-
|
| 2720 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2721 |
),
|
| 2722 |
],
|
| 2723 |
-
className="control
|
| 2724 |
),
|
| 2725 |
],
|
| 2726 |
-
className="inline-controls
|
| 2727 |
),
|
| 2728 |
html.Div(id="feature-selection-detail", className="feature-selection-detail"),
|
| 2729 |
html.Div(
|
| 2730 |
[
|
| 2731 |
graph_box(
|
| 2732 |
-
"feature-
|
| 2733 |
-
"
|
| 2734 |
),
|
| 2735 |
graph_box(
|
| 2736 |
-
"feature-
|
| 2737 |
-
"
|
| 2738 |
),
|
| 2739 |
],
|
| 2740 |
-
className="chart-grid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2741 |
),
|
| 2742 |
-
|
| 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-
|
| 3037 |
-
|
| 3038 |
-
|
| 3039 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3040 |
Input("dataset-filter", "value"),
|
| 3041 |
Input("feature-method", "value"),
|
| 3042 |
-
|
| 3043 |
)
|
| 3044 |
-
def
|
| 3045 |
dataset: str,
|
| 3046 |
method: str | None,
|
| 3047 |
-
|
| 3048 |
):
|
| 3049 |
-
|
| 3050 |
-
|
| 3051 |
-
|
| 3052 |
-
|
| 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-
|
| 3064 |
)
|
| 3065 |
-
def
|
|
|
|
| 3066 |
dataset: str,
|
| 3067 |
method: str | None,
|
| 3068 |
-
|
| 3069 |
):
|
| 3070 |
-
|
| 3071 |
-
|
| 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
|
| 3091 |
-
"and each unit’s
|
| 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(
|
| 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 |
-
.
|
| 627 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 628 |
}
|
| 629 |
|
| 630 |
-
.
|
| 631 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 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[
|
|
|
|
|
|
|
| 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"]
|