Redesign linked feature attribution explorer
Browse files- app.py +276 -610
- assets/feature_story.js +849 -0
- assets/styles.css +242 -34
app.py
CHANGED
|
@@ -680,7 +680,12 @@ def figure_layout(
|
|
| 680 |
plot_bgcolor="#FFFFFF",
|
| 681 |
margin=dict(l=54, r=28, t=58, b=58 if not legend_below else 105),
|
| 682 |
font=dict(family="Arial, Helvetica, sans-serif", size=13, color=TEXT_COLOR),
|
| 683 |
-
title=dict(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
legend=legend,
|
| 685 |
hoverlabel=dict(
|
| 686 |
align="left",
|
|
@@ -916,20 +921,32 @@ def dataset_example_figures(dataset: str) -> tuple[go.Figure, go.Figure, str]:
|
|
| 916 |
.to_numpy(dtype=int)
|
| 917 |
)
|
| 918 |
customdata = np.repeat(feature_ids[:, None], values.shape[1], axis=1)
|
| 919 |
-
upper = max(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 920 |
neural_figure = go.Figure(
|
| 921 |
go.Heatmap(
|
| 922 |
z=values.to_numpy(dtype=float),
|
| 923 |
x=time_values,
|
| 924 |
y=np.arange(len(feature_ids)),
|
| 925 |
customdata=customdata,
|
| 926 |
-
colorscale=[[0.0, "#
|
| 927 |
zmin=0,
|
| 928 |
zmax=upper,
|
| 929 |
-
colorbar=dict(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 930 |
hovertemplate=(
|
| 931 |
"Feature=%{customdata}<br>Time=%{x:.0f} ms<br>"
|
| 932 |
-
"
|
| 933 |
),
|
| 934 |
)
|
| 935 |
)
|
|
@@ -1348,7 +1365,7 @@ def compute_figures(
|
|
| 1348 |
title="Training and inference time",
|
| 1349 |
barmode="group",
|
| 1350 |
)
|
| 1351 |
-
runtime.update_xaxes(title="Elapsed time (
|
| 1352 |
runtime.update_yaxes(title="", showgrid=False)
|
| 1353 |
figure_layout(runtime, height=max(470, 27 * len(frame) + 155), legend_below=True)
|
| 1354 |
|
|
@@ -1519,15 +1536,14 @@ def feature_attribution_frame(dataset: str, model: str | None) -> pd.DataFrame:
|
|
| 1519 |
return frame.sort_values("attribution_rank", kind="stable")
|
| 1520 |
|
| 1521 |
|
| 1522 |
-
def
|
| 1523 |
frame = feature_example_raster[
|
| 1524 |
feature_example_raster["dataset"].astype(str).eq(dataset)
|
| 1525 |
].copy()
|
| 1526 |
if frame.empty:
|
| 1527 |
-
return
|
| 1528 |
|
| 1529 |
numeric_columns = [
|
| 1530 |
-
"trial_index",
|
| 1531 |
"display_index",
|
| 1532 |
"feature_index",
|
| 1533 |
"validation_value",
|
|
@@ -1547,487 +1563,67 @@ def feature_raster_figure(dataset: str) -> go.Figure:
|
|
| 1547 |
|
| 1548 |
n_time = int(frame["n_time"].iloc[0])
|
| 1549 |
value_columns = [f"value_{index:03d}" for index in range(n_time)]
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1553 |
time_ms = (
|
| 1554 |
np.arange(n_time) - int(frame["t0_index"].iloc[0])
|
| 1555 |
) * float(frame["bin_ms"].iloc[0])
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
-
|
| 1560 |
-
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
|
| 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 |
-
|
| 1615 |
-
|
| 1616 |
-
|
| 1617 |
-
|
| 1618 |
-
|
| 1619 |
-
|
| 1620 |
-
|
| 1621 |
-
|
| 1622 |
-
|
| 1623 |
-
|
| 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
|
| 1686 |
dataset: str,
|
| 1687 |
model: str | None,
|
| 1688 |
-
|
| 1689 |
-
|
| 1690 |
-
frame = feature_attribution_frame(dataset, model).reset_index(drop=True)
|
| 1691 |
if frame.empty:
|
| 1692 |
-
return
|
| 1693 |
-
|
| 1694 |
-
|
| 1695 |
-
|
| 1696 |
-
|
| 1697 |
-
"
|
| 1698 |
-
|
| 1699 |
-
|
| 1700 |
-
|
| 1701 |
-
|
| 1702 |
-
|
| 1703 |
-
|
| 1704 |
-
|
| 1705 |
-
|
| 1706 |
-
|
| 1707 |
-
|
| 1708 |
-
|
| 1709 |
-
|
| 1710 |
-
|
| 1711 |
-
|
| 1712 |
-
|
| 1713 |
-
|
| 1714 |
-
validation = frame["validation_value"].to_numpy(dtype=float)
|
| 1715 |
-
vmin = float(np.nanmin(validation))
|
| 1716 |
-
vmax = float(np.nanmax(validation))
|
| 1717 |
-
scale = max(vmax - vmin, 1e-9)
|
| 1718 |
-
start_x = (validation - vmin) / scale
|
| 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.72, 0.28, 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 |
-
n_columns = max(1, int(np.ceil(np.sqrt(len(ordered) * 4.0))))
|
| 1740 |
-
n_rows = max(1, int(np.ceil(len(ordered) / n_columns)))
|
| 1741 |
-
row_y = (
|
| 1742 |
-
np.asarray([center])
|
| 1743 |
-
if n_rows == 1
|
| 1744 |
-
else center + np.linspace(-0.075, 0.075, n_rows)
|
| 1745 |
-
)
|
| 1746 |
-
for row_index in range(n_rows):
|
| 1747 |
-
row = ordered[row_index * n_columns : (row_index + 1) * n_columns]
|
| 1748 |
-
if not len(row):
|
| 1749 |
-
continue
|
| 1750 |
-
if n_columns == 1:
|
| 1751 |
-
row_x = np.asarray([0.5])
|
| 1752 |
-
else:
|
| 1753 |
-
offset = (n_columns - len(row)) / 2.0
|
| 1754 |
-
row_x = (np.arange(len(row)) + offset) / (n_columns - 1)
|
| 1755 |
-
start_x[row] = 0.04 + 0.92 * row_x
|
| 1756 |
-
start_y[row] = row_y[row_index]
|
| 1757 |
-
|
| 1758 |
-
marker_size = 6 if n_features > 300 else (8 if n_features > 120 else 10)
|
| 1759 |
-
|
| 1760 |
-
def trace_for(indices: np.ndarray, group: str, x: np.ndarray, y: np.ndarray) -> go.Scatter:
|
| 1761 |
-
subset = frame.iloc[indices]
|
| 1762 |
-
customdata = np.asarray(
|
| 1763 |
-
[
|
| 1764 |
-
[
|
| 1765 |
-
int(row.feature_index),
|
| 1766 |
-
int(row.attribution_rank),
|
| 1767 |
-
float(row.signed_attribution),
|
| 1768 |
-
float(row.validation_value),
|
| 1769 |
-
str(row.feature_group),
|
| 1770 |
-
str(row.attribution_bin),
|
| 1771 |
-
]
|
| 1772 |
-
for row in subset.itertuples(index=False)
|
| 1773 |
-
],
|
| 1774 |
-
dtype=object,
|
| 1775 |
-
)
|
| 1776 |
-
validation_hover = (
|
| 1777 |
-
"<br>gOSI=%{customdata[3]:.4f}"
|
| 1778 |
-
if dataset == "allen_neuropixels"
|
| 1779 |
-
else ""
|
| 1780 |
-
)
|
| 1781 |
-
group_hover = (
|
| 1782 |
-
"" if dataset == "allen_neuropixels" else "<br>Group=%{customdata[4]}"
|
| 1783 |
-
)
|
| 1784 |
-
marker: dict = dict(
|
| 1785 |
-
size=marker_size,
|
| 1786 |
-
opacity=0.82,
|
| 1787 |
-
line=dict(color="#FFFFFF", width=0.45),
|
| 1788 |
-
)
|
| 1789 |
-
if dataset == "allen_neuropixels":
|
| 1790 |
-
marker.update(
|
| 1791 |
-
color=subset["validation_value"],
|
| 1792 |
-
colorscale="Cividis",
|
| 1793 |
-
cmin=float(frame["validation_value"].min()),
|
| 1794 |
-
cmax=float(frame["validation_value"].max()),
|
| 1795 |
-
colorbar=dict(title="gOSI", thickness=12),
|
| 1796 |
-
)
|
| 1797 |
-
else:
|
| 1798 |
-
marker["color"] = group_colors[group]
|
| 1799 |
-
rank_hover = "<br>Rank=%{customdata[1]:.0f}" if ranked and has_ranking else ""
|
| 1800 |
-
return go.Scatter(
|
| 1801 |
-
x=x[indices],
|
| 1802 |
-
y=y[indices],
|
| 1803 |
-
ids=[f"{dataset}:{int(value)}" for value in subset["feature_index"]],
|
| 1804 |
-
mode="markers",
|
| 1805 |
-
name=group,
|
| 1806 |
-
showlegend=ranked and dataset != "allen_neuropixels",
|
| 1807 |
-
marker=marker,
|
| 1808 |
-
customdata=customdata,
|
| 1809 |
-
hovertemplate=(
|
| 1810 |
-
"Feature=%{customdata[0]:.0f}"
|
| 1811 |
-
+ group_hover
|
| 1812 |
-
+ rank_hover
|
| 1813 |
-
+ "<br>Signed Kernel SHAP=%{customdata[2]:+.5f}"
|
| 1814 |
-
+ validation_hover
|
| 1815 |
-
+ "<extra></extra>"
|
| 1816 |
-
),
|
| 1817 |
-
)
|
| 1818 |
-
|
| 1819 |
-
x = final_x if ranked else start_x
|
| 1820 |
-
y = final_y if ranked else start_y
|
| 1821 |
-
figure = go.Figure(
|
| 1822 |
-
data=[
|
| 1823 |
-
trace_for(indices, group, x, y)
|
| 1824 |
-
for group, indices in zip(trace_groups, trace_indices)
|
| 1825 |
-
]
|
| 1826 |
-
)
|
| 1827 |
-
shapes: list[dict] = []
|
| 1828 |
-
if ranked:
|
| 1829 |
-
for rank_bin in ATTRIBUTION_BIN_ORDER:
|
| 1830 |
-
bin_ranks = frame.loc[
|
| 1831 |
-
frame["attribution_bin"].astype(str).eq(rank_bin), "attribution_rank"
|
| 1832 |
-
].to_numpy(dtype=float)
|
| 1833 |
-
if not len(bin_ranks):
|
| 1834 |
-
continue
|
| 1835 |
-
bin_x = (bin_ranks - 1.0) / max(n_features - 1, 1)
|
| 1836 |
-
shapes.append(
|
| 1837 |
-
dict(
|
| 1838 |
-
type="rect",
|
| 1839 |
-
x0=float(bin_x.min()) - 0.5 / max(n_features - 1, 1),
|
| 1840 |
-
x1=float(bin_x.max()) + 0.5 / max(n_features - 1, 1),
|
| 1841 |
-
y0=display_range[0],
|
| 1842 |
-
y1=display_range[1],
|
| 1843 |
-
fillcolor=ATTRIBUTION_BIN_COLORS[rank_bin],
|
| 1844 |
-
opacity=0.045,
|
| 1845 |
-
line_width=0,
|
| 1846 |
-
layer="below",
|
| 1847 |
-
)
|
| 1848 |
-
)
|
| 1849 |
-
shapes.append(
|
| 1850 |
-
dict(
|
| 1851 |
-
type="line",
|
| 1852 |
-
x0=display_range[0],
|
| 1853 |
-
x1=display_range[1],
|
| 1854 |
-
y0=(0.0 - y_low) / y_span,
|
| 1855 |
-
y1=(0.0 - y_low) / y_span,
|
| 1856 |
-
line=dict(color="#71808D", dash="dash", width=1.3),
|
| 1857 |
-
)
|
| 1858 |
-
)
|
| 1859 |
-
elif dataset != "allen_neuropixels":
|
| 1860 |
-
centers = np.linspace(0.7, 0.3, len(trace_groups))
|
| 1861 |
-
for center, group in zip(centers, trace_groups):
|
| 1862 |
-
shapes.append(
|
| 1863 |
-
dict(
|
| 1864 |
-
type="rect",
|
| 1865 |
-
x0=display_range[0],
|
| 1866 |
-
x1=display_range[1],
|
| 1867 |
-
y0=center - 0.095,
|
| 1868 |
-
y1=center + 0.095,
|
| 1869 |
-
fillcolor=group_colors[group],
|
| 1870 |
-
opacity=0.08,
|
| 1871 |
-
line_width=0,
|
| 1872 |
-
layer="below",
|
| 1873 |
-
)
|
| 1874 |
-
)
|
| 1875 |
-
|
| 1876 |
-
title = "Signed Kernel SHAP vs. rank" if ranked else None
|
| 1877 |
-
figure_layout(figure, height=560)
|
| 1878 |
-
if ranked:
|
| 1879 |
-
rank_ticks = np.unique(
|
| 1880 |
-
np.rint(np.linspace(1, n_features, 5)).astype(int)
|
| 1881 |
-
)
|
| 1882 |
-
rank_tickvals = (rank_ticks - 1.0) / max(n_features - 1, 1)
|
| 1883 |
-
value_ticks = np.linspace(y_low, y_high, 5)
|
| 1884 |
-
value_tickvals = (value_ticks - y_low) / y_span
|
| 1885 |
-
figure.update_xaxes(
|
| 1886 |
-
visible=True,
|
| 1887 |
-
range=display_range,
|
| 1888 |
-
tickmode="array",
|
| 1889 |
-
tickvals=rank_tickvals,
|
| 1890 |
-
ticktext=[str(value) for value in rank_ticks],
|
| 1891 |
-
title="Attribution rank",
|
| 1892 |
-
)
|
| 1893 |
-
figure.update_yaxes(
|
| 1894 |
-
visible=True,
|
| 1895 |
-
range=display_range,
|
| 1896 |
-
tickmode="array",
|
| 1897 |
-
tickvals=value_tickvals,
|
| 1898 |
-
ticktext=[f"{value:.3g}" for value in value_ticks],
|
| 1899 |
-
title="Signed Kernel SHAP value",
|
| 1900 |
-
)
|
| 1901 |
-
elif dataset == "allen_neuropixels":
|
| 1902 |
-
ticks = np.linspace(vmin, vmax, 5)
|
| 1903 |
-
tickvals = (ticks - vmin) / max(vmax - vmin, 1e-9)
|
| 1904 |
-
figure.update_xaxes(
|
| 1905 |
-
visible=True,
|
| 1906 |
-
range=display_range,
|
| 1907 |
-
tickmode="array",
|
| 1908 |
-
tickvals=tickvals,
|
| 1909 |
-
ticktext=[f"{value:.2f}" for value in ticks],
|
| 1910 |
-
title="gOSI",
|
| 1911 |
-
showgrid=False,
|
| 1912 |
-
)
|
| 1913 |
-
figure.update_yaxes(
|
| 1914 |
-
visible=True,
|
| 1915 |
-
range=display_range,
|
| 1916 |
-
showticklabels=False,
|
| 1917 |
-
ticks="",
|
| 1918 |
-
title=" ",
|
| 1919 |
-
showgrid=False,
|
| 1920 |
-
zeroline=False,
|
| 1921 |
-
)
|
| 1922 |
-
else:
|
| 1923 |
-
figure.update_xaxes(
|
| 1924 |
-
visible=True,
|
| 1925 |
-
range=display_range,
|
| 1926 |
-
showticklabels=False,
|
| 1927 |
-
ticks="",
|
| 1928 |
-
title="",
|
| 1929 |
-
showgrid=False,
|
| 1930 |
-
zeroline=False,
|
| 1931 |
-
)
|
| 1932 |
-
figure.update_yaxes(
|
| 1933 |
-
visible=True,
|
| 1934 |
-
range=display_range,
|
| 1935 |
-
tickmode="array",
|
| 1936 |
-
tickvals=centers,
|
| 1937 |
-
ticktext=trace_groups,
|
| 1938 |
-
tickfont=dict(size=10),
|
| 1939 |
-
ticks="",
|
| 1940 |
-
title="",
|
| 1941 |
-
showgrid=False,
|
| 1942 |
-
zeroline=False,
|
| 1943 |
-
)
|
| 1944 |
-
figure.update_xaxes(automargin=False)
|
| 1945 |
-
figure.update_yaxes(automargin=False)
|
| 1946 |
-
animation_controls: list[dict] = []
|
| 1947 |
-
if ranked:
|
| 1948 |
-
animated_traces = list(range(len(trace_indices)))
|
| 1949 |
-
|
| 1950 |
-
def coordinate_frame(
|
| 1951 |
-
name: str,
|
| 1952 |
-
x_values: np.ndarray,
|
| 1953 |
-
y_values: np.ndarray,
|
| 1954 |
-
) -> go.Frame:
|
| 1955 |
-
return go.Frame(
|
| 1956 |
-
name=name,
|
| 1957 |
-
traces=animated_traces,
|
| 1958 |
-
data=[
|
| 1959 |
-
go.Scatter(x=x_values[indices], y=y_values[indices])
|
| 1960 |
-
for indices in trace_indices
|
| 1961 |
-
],
|
| 1962 |
-
)
|
| 1963 |
-
|
| 1964 |
-
figure.frames = [
|
| 1965 |
-
coordinate_frame("feature-reference", start_x, start_y),
|
| 1966 |
-
coordinate_frame("feature-ranked", final_x, final_y),
|
| 1967 |
-
]
|
| 1968 |
-
animation_controls = [
|
| 1969 |
-
dict(
|
| 1970 |
-
type="buttons",
|
| 1971 |
-
direction="left",
|
| 1972 |
-
showactive=False,
|
| 1973 |
-
x=1.0,
|
| 1974 |
-
xanchor="right",
|
| 1975 |
-
y=-0.38,
|
| 1976 |
-
yanchor="top",
|
| 1977 |
-
pad=dict(t=4, r=0),
|
| 1978 |
-
bgcolor="#F3EFF8",
|
| 1979 |
-
bordercolor="#C8BBDD",
|
| 1980 |
-
borderwidth=1,
|
| 1981 |
-
font=dict(size=11, color="#4D3880"),
|
| 1982 |
-
buttons=[
|
| 1983 |
-
dict(
|
| 1984 |
-
label="↻ Replay sorting",
|
| 1985 |
-
method="animate",
|
| 1986 |
-
args=[
|
| 1987 |
-
["feature-reference", "feature-ranked"],
|
| 1988 |
-
{
|
| 1989 |
-
"mode": "immediate",
|
| 1990 |
-
"fromcurrent": False,
|
| 1991 |
-
"frame": [
|
| 1992 |
-
{"duration": 120, "redraw": False},
|
| 1993 |
-
{"duration": 900, "redraw": False},
|
| 1994 |
-
],
|
| 1995 |
-
"transition": [
|
| 1996 |
-
{"duration": 0},
|
| 1997 |
-
{
|
| 1998 |
-
"duration": 900,
|
| 1999 |
-
"easing": "cubic-in-out",
|
| 2000 |
-
},
|
| 2001 |
-
],
|
| 2002 |
-
},
|
| 2003 |
-
],
|
| 2004 |
-
)
|
| 2005 |
-
],
|
| 2006 |
-
)
|
| 2007 |
-
]
|
| 2008 |
-
if ranked:
|
| 2009 |
-
margin = dict(l=58, r=28, t=66, b=165)
|
| 2010 |
-
elif dataset == "allen_neuropixels":
|
| 2011 |
-
margin = dict(l=46, r=54, t=26, b=76)
|
| 2012 |
-
else:
|
| 2013 |
-
margin = dict(l=112, r=24, t=26, b=76)
|
| 2014 |
-
figure.update_layout(
|
| 2015 |
-
title=title,
|
| 2016 |
-
shapes=shapes,
|
| 2017 |
-
annotations=[],
|
| 2018 |
-
updatemenus=animation_controls,
|
| 2019 |
-
margin=margin,
|
| 2020 |
-
uirevision=f"feature-sorter:{dataset}:{model}:{arrangement}",
|
| 2021 |
-
legend=dict(
|
| 2022 |
-
orientation="h",
|
| 2023 |
-
yanchor="top",
|
| 2024 |
-
y=-0.17,
|
| 2025 |
-
xanchor="left",
|
| 2026 |
-
x=0,
|
| 2027 |
-
font=dict(size=10),
|
| 2028 |
-
),
|
| 2029 |
-
)
|
| 2030 |
-
return figure
|
| 2031 |
|
| 2032 |
|
| 2033 |
def trial_frame(dataset: str, models: Sequence[str] | None) -> pd.DataFrame:
|
|
@@ -2067,7 +1663,7 @@ def trial_detection_figure(dataset: str, models: Sequence[str] | None) -> go.Fig
|
|
| 2067 |
fig.update_layout(
|
| 2068 |
title="Corrupted-trial detection"
|
| 2069 |
)
|
| 2070 |
-
fig.update_xaxes(title="ROC-AUC
|
| 2071 |
fig.update_yaxes(title="", showgrid=False)
|
| 2072 |
return figure_layout(fig, height=max(430, 25 * len(frame) + 145))
|
| 2073 |
|
|
@@ -2300,7 +1896,7 @@ def trial_retrain_figures() -> tuple[go.Figure, go.Figure, go.Figure, pd.DataFra
|
|
| 2300 |
return removal, relation, historical_fig, round_numeric(table)
|
| 2301 |
|
| 2302 |
|
| 2303 |
-
def historical_trajectory_figure() -> go.Figure:
|
| 2304 |
frame = trial_historical_trajectories.copy()
|
| 2305 |
current_r2 = float(frame["current_only_r2"].iloc[0])
|
| 2306 |
historical_r2 = float(frame["historical_selected_r2"].iloc[0])
|
|
@@ -2321,12 +1917,20 @@ def historical_trajectory_figure() -> go.Figure:
|
|
| 2321 |
2.5,
|
| 2322 |
),
|
| 2323 |
]
|
| 2324 |
-
|
| 2325 |
-
|
| 2326 |
-
|
| 2327 |
-
|
| 2328 |
-
|
| 2329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2330 |
|
| 2331 |
direction_labels = dict(zip(DIRECTION_LEGEND_ORDER, DIRECTION_LEGEND_LABELS))
|
| 2332 |
for direction_rank, direction_index in enumerate(DIRECTION_LEGEND_ORDER):
|
|
@@ -2335,6 +1939,8 @@ def historical_trajectory_figure() -> go.Figure:
|
|
| 2335 |
for panel_index, (x_column, y_column, _title, opacity, width) in enumerate(
|
| 2336 |
panels, start=1
|
| 2337 |
):
|
|
|
|
|
|
|
| 2338 |
x_values: list[object] = []
|
| 2339 |
y_values: list[object] = []
|
| 2340 |
hover_values: list[list[object]] = []
|
|
@@ -2363,7 +1969,7 @@ def historical_trajectory_figure() -> go.Figure:
|
|
| 2363 |
name=direction_label,
|
| 2364 |
legendgroup=f"direction-{direction_index}",
|
| 2365 |
legendrank=direction_rank,
|
| 2366 |
-
showlegend=
|
| 2367 |
opacity=opacity,
|
| 2368 |
line=dict(color=DIRECTION_PALETTE[direction_index], width=width),
|
| 2369 |
customdata=hover_values,
|
|
@@ -2373,8 +1979,8 @@ def historical_trajectory_figure() -> go.Figure:
|
|
| 2373 |
"Time bin=%{customdata[2]}<br>x=%{x:.3f}<br>y=%{y:.3f}<extra></extra>"
|
| 2374 |
),
|
| 2375 |
),
|
| 2376 |
-
row=
|
| 2377 |
-
col=
|
| 2378 |
)
|
| 2379 |
|
| 2380 |
first_points = (
|
|
@@ -2383,6 +1989,8 @@ def historical_trajectory_figure() -> go.Figure:
|
|
| 2383 |
for panel_index, (x_column, y_column, _title, _opacity, _width) in enumerate(
|
| 2384 |
panels, start=1
|
| 2385 |
):
|
|
|
|
|
|
|
| 2386 |
fig.add_trace(
|
| 2387 |
go.Scatter(
|
| 2388 |
x=[float(first_points[x_column].mean())],
|
|
@@ -2392,8 +2000,8 @@ def historical_trajectory_figure() -> go.Figure:
|
|
| 2392 |
showlegend=False,
|
| 2393 |
hovertemplate="Mean trajectory origin<extra></extra>",
|
| 2394 |
),
|
| 2395 |
-
row=
|
| 2396 |
-
col=
|
| 2397 |
)
|
| 2398 |
|
| 2399 |
x_values = pd.concat(
|
|
@@ -2409,6 +2017,8 @@ def historical_trajectory_figure() -> go.Figure:
|
|
| 2409 |
x_range = [float(x_values.min() - 0.06 * x_span), float(x_values.max() + 0.06 * x_span)]
|
| 2410 |
y_range = [float(y_values.min() - 0.06 * y_span), float(y_values.max() + 0.06 * y_span)]
|
| 2411 |
for panel_index in range(1, 4):
|
|
|
|
|
|
|
| 2412 |
x_axis_id = "x" if panel_index == 1 else f"x{panel_index}"
|
| 2413 |
fig.update_xaxes(
|
| 2414 |
range=x_range,
|
|
@@ -2416,8 +2026,8 @@ def historical_trajectory_figure() -> go.Figure:
|
|
| 2416 |
zeroline=False,
|
| 2417 |
showticklabels=False,
|
| 2418 |
ticks="",
|
| 2419 |
-
row=
|
| 2420 |
-
col=
|
| 2421 |
)
|
| 2422 |
fig.update_yaxes(
|
| 2423 |
range=y_range,
|
|
@@ -2427,24 +2037,15 @@ def historical_trajectory_figure() -> go.Figure:
|
|
| 2427 |
ticks="",
|
| 2428 |
scaleanchor=x_axis_id,
|
| 2429 |
scaleratio=1,
|
| 2430 |
-
row=
|
| 2431 |
-
col=
|
| 2432 |
)
|
| 2433 |
|
| 2434 |
-
figure_layout(fig, height=
|
| 2435 |
fig.update_layout(
|
| 2436 |
title="Held-out trajectories · RNN",
|
| 2437 |
-
margin=dict(l=
|
| 2438 |
-
|
| 2439 |
-
orientation="h",
|
| 2440 |
-
yanchor="top",
|
| 2441 |
-
y=-0.10,
|
| 2442 |
-
xanchor="center",
|
| 2443 |
-
x=0.5,
|
| 2444 |
-
title="Reach direction",
|
| 2445 |
-
traceorder="normal",
|
| 2446 |
-
font=dict(size=11),
|
| 2447 |
-
),
|
| 2448 |
)
|
| 2449 |
fig.for_each_annotation(
|
| 2450 |
lambda annotation: annotation.update(font=dict(size=12, color=TEXT_COLOR))
|
|
@@ -2452,6 +2053,29 @@ def historical_trajectory_figure() -> go.Figure:
|
|
| 2452 |
return fig
|
| 2453 |
|
| 2454 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2455 |
def condition_sort_key(value: object) -> tuple[int, float | str]:
|
| 2456 |
try:
|
| 2457 |
return (0, float(value))
|
|
@@ -2519,7 +2143,13 @@ def add_latent_color_columns(df: pd.DataFrame, dataset: str, color_mode: str) ->
|
|
| 2519 |
return out
|
| 2520 |
|
| 2521 |
|
| 2522 |
-
def latent_space_figure(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2523 |
if dataset == "mc_pacman":
|
| 2524 |
return empty_figure(
|
| 2525 |
"Cross-recording consistency is not available for this dataset.",
|
|
@@ -2550,7 +2180,7 @@ def latent_space_figure(dataset: str, model: str | None, color_mode: str) -> go.
|
|
| 2550 |
trajectories["color_value"] = pd.Series(dtype=str)
|
| 2551 |
|
| 2552 |
sessions = list(dict.fromkeys(samples["session_label"].astype(str)))
|
| 2553 |
-
columns = 2 if len(sessions) > 1 else 1
|
| 2554 |
rows = int(np.ceil(len(sessions) / columns))
|
| 2555 |
fig = make_subplots(
|
| 2556 |
rows=rows,
|
|
@@ -2697,7 +2327,7 @@ def latent_space_figure(dataset: str, model: str | None, color_mode: str) -> go.
|
|
| 2697 |
title += f" · R² = {float(score.iloc[0]):.3f}"
|
| 2698 |
fig.update_layout(
|
| 2699 |
title=title,
|
| 2700 |
-
height=700 if rows > 1 else 500,
|
| 2701 |
paper_bgcolor="#FFFFFF",
|
| 2702 |
plot_bgcolor="#FFFFFF",
|
| 2703 |
margin=dict(l=8, r=8, t=72, b=88),
|
|
@@ -2711,11 +2341,63 @@ def latent_space_figure(dataset: str, model: str | None, color_mode: str) -> go.
|
|
| 2711 |
entrywidth=46,
|
| 2712 |
entrywidthmode="pixels",
|
| 2713 |
),
|
|
|
|
| 2714 |
)
|
| 2715 |
fig.for_each_annotation(lambda annotation: annotation.update(font=dict(size=12, color="#526171")))
|
| 2716 |
return fig
|
| 2717 |
|
| 2718 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2719 |
def consistency_frame(dataset: str, models: Sequence[str] | None) -> pd.DataFrame:
|
| 2720 |
frame = filter_models(active_rows(consistency), models)
|
| 2721 |
frame = frame[frame["dataset"].astype(str) == str(dataset)].copy()
|
|
@@ -2755,7 +2437,7 @@ def consistency_figures(
|
|
| 2755 |
)
|
| 2756 |
)
|
| 2757 |
bar_fig.update_layout(title="Latent consistency")
|
| 2758 |
-
bar_fig.update_xaxes(title="
|
| 2759 |
bar_fig.update_yaxes(title="", showgrid=False)
|
| 2760 |
figure_layout(bar_fig, height=max(400, 27 * len(bar) + 145))
|
| 2761 |
columns = ["method", "latent_consistency_r2"]
|
|
@@ -3021,7 +2703,17 @@ app.layout = html.Div(
|
|
| 3021 |
],
|
| 3022 |
className="inline-controls",
|
| 3023 |
),
|
| 3024 |
-
graph_box(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3025 |
html.Div(
|
| 3026 |
[
|
| 3027 |
graph_box("consistency-bars", "Latent-consistency R-squared for the selected dataset."),
|
|
@@ -3050,51 +2742,34 @@ app.layout = html.Div(
|
|
| 3050 |
"Feature-level attribution",
|
| 3051 |
html.Div(
|
| 3052 |
[
|
| 3053 |
-
html.
|
| 3054 |
-
|
| 3055 |
-
html.Label("Method", htmlFor="feature-method"),
|
| 3056 |
-
dcc.Dropdown(id="feature-method", clearable=False),
|
| 3057 |
-
],
|
| 3058 |
-
className="control",
|
| 3059 |
-
),
|
| 3060 |
-
html.Div(
|
| 3061 |
-
[
|
| 3062 |
-
html.Label("Feature view", htmlFor="feature-arrangement"),
|
| 3063 |
-
dcc.RadioItems(
|
| 3064 |
-
id="feature-arrangement",
|
| 3065 |
-
options=[
|
| 3066 |
-
{
|
| 3067 |
-
"label": "Reference annotation",
|
| 3068 |
-
"value": "reference",
|
| 3069 |
-
},
|
| 3070 |
-
{
|
| 3071 |
-
"label": "Signed SHAP ranking",
|
| 3072 |
-
"value": "ranked",
|
| 3073 |
-
},
|
| 3074 |
-
],
|
| 3075 |
-
value="ranked",
|
| 3076 |
-
inline=True,
|
| 3077 |
-
className="arrangement-toggle",
|
| 3078 |
-
),
|
| 3079 |
-
],
|
| 3080 |
-
className="control",
|
| 3081 |
-
),
|
| 3082 |
],
|
| 3083 |
-
className="
|
| 3084 |
),
|
| 3085 |
-
|
|
|
|
| 3086 |
html.Div(
|
| 3087 |
-
|
| 3088 |
-
|
| 3089 |
-
|
| 3090 |
-
|
| 3091 |
-
|
| 3092 |
-
|
| 3093 |
-
"
|
| 3094 |
-
"
|
| 3095 |
-
)
|
| 3096 |
-
|
| 3097 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3098 |
),
|
| 3099 |
html.Div(
|
| 3100 |
[
|
|
@@ -3103,7 +2778,7 @@ app.layout = html.Div(
|
|
| 3103 |
],
|
| 3104 |
className="download-grid panel-downloads",
|
| 3105 |
),
|
| 3106 |
-
subtitle="
|
| 3107 |
class_name="axis-feature",
|
| 3108 |
),
|
| 3109 |
panel(
|
|
@@ -3173,8 +2848,14 @@ app.layout = html.Div(
|
|
| 3173 |
graph_box(
|
| 3174 |
"trial-historical-trajectories",
|
| 3175 |
"Held-out RNN target-session trajectories for ground truth, current-session training, and nonnegative-valued historical-trial selection.",
|
| 3176 |
-
class_name="historical-trajectory-graph",
|
| 3177 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3178 |
details_table("View data", dataframe_table("trial-retrain-table", page_size=17)),
|
| 3179 |
html.Div(
|
| 3180 |
[
|
|
@@ -3333,6 +3014,8 @@ def update_consistency_selector(dataset: str, models: list[str] | None, current:
|
|
| 3333 |
|
| 3334 |
@app.callback(
|
| 3335 |
Output("latent-space", "figure"),
|
|
|
|
|
|
|
| 3336 |
Output("consistency-bars", "figure"),
|
| 3337 |
Output("consistency-heatmap", "figure"),
|
| 3338 |
Output("consistency-table", "columns"),
|
|
@@ -3350,8 +3033,11 @@ def update_consistency(
|
|
| 3350 |
):
|
| 3351 |
dataset = dataset or DATASETS[0]
|
| 3352 |
bars, heatmap, table = consistency_figures(dataset, models)
|
|
|
|
| 3353 |
return (
|
| 3354 |
-
latent_space_figure(dataset, method,
|
|
|
|
|
|
|
| 3355 |
bars,
|
| 3356 |
heatmap,
|
| 3357 |
column_defs(table.columns),
|
|
@@ -3396,70 +3082,48 @@ def update_feature_selector(
|
|
| 3396 |
|
| 3397 |
|
| 3398 |
@app.callback(
|
| 3399 |
-
Output("feature-raster", "
|
| 3400 |
Input("dataset-filter", "value"),
|
| 3401 |
)
|
| 3402 |
-
def
|
| 3403 |
-
return
|
| 3404 |
|
| 3405 |
|
| 3406 |
@app.callback(
|
| 3407 |
-
Output("feature-
|
| 3408 |
-
Output("feature-rank-plot", "clickData"),
|
| 3409 |
Input("dataset-filter", "value"),
|
| 3410 |
Input("feature-method", "value"),
|
| 3411 |
-
Input("feature-arrangement", "value"),
|
| 3412 |
)
|
| 3413 |
-
def
|
| 3414 |
dataset: str,
|
| 3415 |
method: str | None,
|
| 3416 |
-
arrangement: str,
|
| 3417 |
):
|
| 3418 |
-
|
| 3419 |
-
dataset or DATASETS[0], method, arrangement or "ranked"
|
| 3420 |
-
)
|
| 3421 |
-
return sorter, None
|
| 3422 |
|
| 3423 |
|
| 3424 |
-
|
| 3425 |
-
|
| 3426 |
-
|
| 3427 |
-
|
| 3428 |
-
|
| 3429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3430 |
)
|
| 3431 |
-
def update_feature_selection_detail(
|
| 3432 |
-
click_data: dict | None,
|
| 3433 |
-
dataset: str,
|
| 3434 |
-
method: str | None,
|
| 3435 |
-
arrangement: str,
|
| 3436 |
-
):
|
| 3437 |
-
instruction = (
|
| 3438 |
-
"Each dot is one input feature. In the ranked view, use ↻ Replay sorting "
|
| 3439 |
-
"to follow every feature into its signed Kernel SHAP rank."
|
| 3440 |
-
)
|
| 3441 |
-
if not click_data or not click_data.get("points"):
|
| 3442 |
-
return instruction
|
| 3443 |
-
custom = click_data["points"][0].get("customdata")
|
| 3444 |
-
if not custom or len(custom) < 6:
|
| 3445 |
-
return instruction
|
| 3446 |
-
frame = feature_attribution_frame(dataset or DATASETS[0], method)
|
| 3447 |
-
feature_index = int(float(custom[0]))
|
| 3448 |
-
selected = frame[
|
| 3449 |
-
frame["feature_index"].astype(int).eq(feature_index)
|
| 3450 |
-
]
|
| 3451 |
-
if selected.empty:
|
| 3452 |
-
return instruction
|
| 3453 |
-
row = selected.iloc[0]
|
| 3454 |
-
detail = f"Feature index {feature_index}"
|
| 3455 |
-
if arrangement == "ranked" and frame["signed_attribution"].nunique(dropna=True) > 1:
|
| 3456 |
-
detail += f" · rank {int(row['attribution_rank'])} of {len(frame)}"
|
| 3457 |
-
detail += f" · signed Kernel SHAP {float(row['signed_attribution']):+.5f}"
|
| 3458 |
-
if dataset == "allen_neuropixels" and pd.notna(row["validation_value"]):
|
| 3459 |
-
detail += f" · gOSI {float(row['validation_value']):.4f}"
|
| 3460 |
-
else:
|
| 3461 |
-
detail += f" · {row['feature_group']}"
|
| 3462 |
-
return detail
|
| 3463 |
|
| 3464 |
|
| 3465 |
@app.callback(
|
|
@@ -3512,6 +3176,7 @@ def update_feature(dataset: str, models: list[str] | None):
|
|
| 3512 |
Output("trial-recovery", "figure"),
|
| 3513 |
Output("trial-historical", "figure"),
|
| 3514 |
Output("trial-historical-trajectories", "figure"),
|
|
|
|
| 3515 |
Output("trial-retrain-table", "columns"),
|
| 3516 |
Output("trial-retrain-table", "data"),
|
| 3517 |
Input("dataset-filter", "value"),
|
|
@@ -3534,6 +3199,7 @@ def update_trial(dataset: str, models: list[str] | None):
|
|
| 3534 |
relation,
|
| 3535 |
historical,
|
| 3536 |
historical_trajectory_figure(),
|
|
|
|
| 3537 |
column_defs(retrain_table.columns),
|
| 3538 |
records(retrain_table),
|
| 3539 |
)
|
|
|
|
| 680 |
plot_bgcolor="#FFFFFF",
|
| 681 |
margin=dict(l=54, r=28, t=58, b=58 if not legend_below else 105),
|
| 682 |
font=dict(family="Arial, Helvetica, sans-serif", size=13, color=TEXT_COLOR),
|
| 683 |
+
title=dict(
|
| 684 |
+
font=dict(size=16, color=TEXT_COLOR),
|
| 685 |
+
x=0.02,
|
| 686 |
+
xanchor="left",
|
| 687 |
+
pad=dict(l=6, b=8),
|
| 688 |
+
),
|
| 689 |
legend=legend,
|
| 690 |
hoverlabel=dict(
|
| 691 |
align="left",
|
|
|
|
| 921 |
.to_numpy(dtype=int)
|
| 922 |
)
|
| 923 |
customdata = np.repeat(feature_ids[:, None], values.shape[1], axis=1)
|
| 924 |
+
upper = max(int(np.ceil(np.nanmax(values.to_numpy(dtype=float)))), 1)
|
| 925 |
+
if upper <= 4:
|
| 926 |
+
count_ticks = list(range(upper + 1))
|
| 927 |
+
else:
|
| 928 |
+
count_ticks = np.unique(
|
| 929 |
+
np.rint(np.linspace(0, upper, 4)).astype(int)
|
| 930 |
+
).tolist()
|
| 931 |
neural_figure = go.Figure(
|
| 932 |
go.Heatmap(
|
| 933 |
z=values.to_numpy(dtype=float),
|
| 934 |
x=time_values,
|
| 935 |
y=np.arange(len(feature_ids)),
|
| 936 |
customdata=customdata,
|
| 937 |
+
colorscale=[[0.0, "#F8FAFB"], [1.0, "#263238"]],
|
| 938 |
zmin=0,
|
| 939 |
zmax=upper,
|
| 940 |
+
colorbar=dict(
|
| 941 |
+
title="Count / bin",
|
| 942 |
+
thickness=13,
|
| 943 |
+
tickmode="array",
|
| 944 |
+
tickvals=count_ticks,
|
| 945 |
+
ticktext=[str(value) for value in count_ticks],
|
| 946 |
+
),
|
| 947 |
hovertemplate=(
|
| 948 |
"Feature=%{customdata}<br>Time=%{x:.0f} ms<br>"
|
| 949 |
+
"Count=%{z:.0f}<extra></extra>"
|
| 950 |
),
|
| 951 |
)
|
| 952 |
)
|
|
|
|
| 1365 |
title="Training and inference time",
|
| 1366 |
barmode="group",
|
| 1367 |
)
|
| 1368 |
+
runtime.update_xaxes(title="Elapsed time (s, log)", type="log")
|
| 1369 |
runtime.update_yaxes(title="", showgrid=False)
|
| 1370 |
figure_layout(runtime, height=max(470, 27 * len(frame) + 155), legend_below=True)
|
| 1371 |
|
|
|
|
| 1536 |
return frame.sort_values("attribution_rank", kind="stable")
|
| 1537 |
|
| 1538 |
|
| 1539 |
+
def feature_story_raster_payload(dataset: str) -> dict:
|
| 1540 |
frame = feature_example_raster[
|
| 1541 |
feature_example_raster["dataset"].astype(str).eq(dataset)
|
| 1542 |
].copy()
|
| 1543 |
if frame.empty:
|
| 1544 |
+
return {"dataset": dataset, "features": [], "counts": []}
|
| 1545 |
|
| 1546 |
numeric_columns = [
|
|
|
|
| 1547 |
"display_index",
|
| 1548 |
"feature_index",
|
| 1549 |
"validation_value",
|
|
|
|
| 1563 |
|
| 1564 |
n_time = int(frame["n_time"].iloc[0])
|
| 1565 |
value_columns = [f"value_{index:03d}" for index in range(n_time)]
|
| 1566 |
+
counts = (
|
| 1567 |
+
frame[value_columns]
|
| 1568 |
+
.apply(pd.to_numeric, errors="coerce")
|
| 1569 |
+
.to_numpy(dtype=float)
|
| 1570 |
+
)
|
| 1571 |
+
if not np.allclose(counts, np.rint(counts)):
|
| 1572 |
+
raise ValueError(f"Feature raster contains noninteger counts for {dataset}")
|
| 1573 |
+
counts = np.rint(counts).astype(int)
|
| 1574 |
time_ms = (
|
| 1575 |
np.arange(n_time) - int(frame["t0_index"].iloc[0])
|
| 1576 |
) * float(frame["bin_ms"].iloc[0])
|
| 1577 |
+
features = [
|
| 1578 |
+
{
|
| 1579 |
+
"feature_index": int(row.feature_index),
|
| 1580 |
+
"group": str(row.feature_group),
|
| 1581 |
+
"validation_value": (
|
| 1582 |
+
None
|
| 1583 |
+
if pd.isna(row.validation_value)
|
| 1584 |
+
else float(row.validation_value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1585 |
),
|
| 1586 |
+
}
|
| 1587 |
+
for row in frame.itertuples(index=False)
|
| 1588 |
+
]
|
| 1589 |
+
return {
|
| 1590 |
+
"dataset": dataset,
|
| 1591 |
+
"dataset_label": DATASET_LABELS[dataset],
|
| 1592 |
+
"features": features,
|
| 1593 |
+
"counts": counts.tolist(),
|
| 1594 |
+
"time_ms": time_ms.tolist(),
|
| 1595 |
+
"max_count": int(counts.max(initial=0)),
|
| 1596 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1597 |
|
| 1598 |
|
| 1599 |
+
def feature_story_attribution_payload(
|
| 1600 |
dataset: str,
|
| 1601 |
model: str | None,
|
| 1602 |
+
) -> dict:
|
| 1603 |
+
frame = feature_attribution_frame(dataset, model)
|
|
|
|
| 1604 |
if frame.empty:
|
| 1605 |
+
return {
|
| 1606 |
+
"dataset": dataset,
|
| 1607 |
+
"model": model,
|
| 1608 |
+
"method": "",
|
| 1609 |
+
"features": [],
|
| 1610 |
+
"tied": False,
|
| 1611 |
+
}
|
| 1612 |
+
values = pd.to_numeric(frame["signed_attribution"], errors="coerce")
|
| 1613 |
+
return {
|
| 1614 |
+
"dataset": dataset,
|
| 1615 |
+
"model": model,
|
| 1616 |
+
"method": dataset_model_label(str(model), dataset),
|
| 1617 |
+
"tied": values.nunique(dropna=True) <= 1,
|
| 1618 |
+
"features": [
|
| 1619 |
+
{
|
| 1620 |
+
"feature_index": int(row.feature_index),
|
| 1621 |
+
"rank": int(row.attribution_rank),
|
| 1622 |
+
"signed_attribution": float(row.signed_attribution),
|
| 1623 |
+
}
|
| 1624 |
+
for row in frame.itertuples(index=False)
|
| 1625 |
+
],
|
| 1626 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1627 |
|
| 1628 |
|
| 1629 |
def trial_frame(dataset: str, models: Sequence[str] | None) -> pd.DataFrame:
|
|
|
|
| 1663 |
fig.update_layout(
|
| 1664 |
title="Corrupted-trial detection"
|
| 1665 |
)
|
| 1666 |
+
fig.update_xaxes(title="Detection ROC-AUC")
|
| 1667 |
fig.update_yaxes(title="", showgrid=False)
|
| 1668 |
return figure_layout(fig, height=max(430, 25 * len(frame) + 145))
|
| 1669 |
|
|
|
|
| 1896 |
return removal, relation, historical_fig, round_numeric(table)
|
| 1897 |
|
| 1898 |
|
| 1899 |
+
def historical_trajectory_figure(*, stacked: bool = False) -> go.Figure:
|
| 1900 |
frame = trial_historical_trajectories.copy()
|
| 1901 |
current_r2 = float(frame["current_only_r2"].iloc[0])
|
| 1902 |
historical_r2 = float(frame["historical_selected_r2"].iloc[0])
|
|
|
|
| 1917 |
2.5,
|
| 1918 |
),
|
| 1919 |
]
|
| 1920 |
+
if stacked:
|
| 1921 |
+
fig = make_subplots(
|
| 1922 |
+
rows=3,
|
| 1923 |
+
cols=1,
|
| 1924 |
+
vertical_spacing=0.075,
|
| 1925 |
+
subplot_titles=[panel[2] for panel in panels],
|
| 1926 |
+
)
|
| 1927 |
+
else:
|
| 1928 |
+
fig = make_subplots(
|
| 1929 |
+
rows=1,
|
| 1930 |
+
cols=3,
|
| 1931 |
+
horizontal_spacing=0.045,
|
| 1932 |
+
subplot_titles=[panel[2] for panel in panels],
|
| 1933 |
+
)
|
| 1934 |
|
| 1935 |
direction_labels = dict(zip(DIRECTION_LEGEND_ORDER, DIRECTION_LEGEND_LABELS))
|
| 1936 |
for direction_rank, direction_index in enumerate(DIRECTION_LEGEND_ORDER):
|
|
|
|
| 1939 |
for panel_index, (x_column, y_column, _title, opacity, width) in enumerate(
|
| 1940 |
panels, start=1
|
| 1941 |
):
|
| 1942 |
+
subplot_row = panel_index if stacked else 1
|
| 1943 |
+
subplot_col = 1 if stacked else panel_index
|
| 1944 |
x_values: list[object] = []
|
| 1945 |
y_values: list[object] = []
|
| 1946 |
hover_values: list[list[object]] = []
|
|
|
|
| 1969 |
name=direction_label,
|
| 1970 |
legendgroup=f"direction-{direction_index}",
|
| 1971 |
legendrank=direction_rank,
|
| 1972 |
+
showlegend=False,
|
| 1973 |
opacity=opacity,
|
| 1974 |
line=dict(color=DIRECTION_PALETTE[direction_index], width=width),
|
| 1975 |
customdata=hover_values,
|
|
|
|
| 1979 |
"Time bin=%{customdata[2]}<br>x=%{x:.3f}<br>y=%{y:.3f}<extra></extra>"
|
| 1980 |
),
|
| 1981 |
),
|
| 1982 |
+
row=subplot_row,
|
| 1983 |
+
col=subplot_col,
|
| 1984 |
)
|
| 1985 |
|
| 1986 |
first_points = (
|
|
|
|
| 1989 |
for panel_index, (x_column, y_column, _title, _opacity, _width) in enumerate(
|
| 1990 |
panels, start=1
|
| 1991 |
):
|
| 1992 |
+
subplot_row = panel_index if stacked else 1
|
| 1993 |
+
subplot_col = 1 if stacked else panel_index
|
| 1994 |
fig.add_trace(
|
| 1995 |
go.Scatter(
|
| 1996 |
x=[float(first_points[x_column].mean())],
|
|
|
|
| 2000 |
showlegend=False,
|
| 2001 |
hovertemplate="Mean trajectory origin<extra></extra>",
|
| 2002 |
),
|
| 2003 |
+
row=subplot_row,
|
| 2004 |
+
col=subplot_col,
|
| 2005 |
)
|
| 2006 |
|
| 2007 |
x_values = pd.concat(
|
|
|
|
| 2017 |
x_range = [float(x_values.min() - 0.06 * x_span), float(x_values.max() + 0.06 * x_span)]
|
| 2018 |
y_range = [float(y_values.min() - 0.06 * y_span), float(y_values.max() + 0.06 * y_span)]
|
| 2019 |
for panel_index in range(1, 4):
|
| 2020 |
+
subplot_row = panel_index if stacked else 1
|
| 2021 |
+
subplot_col = 1 if stacked else panel_index
|
| 2022 |
x_axis_id = "x" if panel_index == 1 else f"x{panel_index}"
|
| 2023 |
fig.update_xaxes(
|
| 2024 |
range=x_range,
|
|
|
|
| 2026 |
zeroline=False,
|
| 2027 |
showticklabels=False,
|
| 2028 |
ticks="",
|
| 2029 |
+
row=subplot_row,
|
| 2030 |
+
col=subplot_col,
|
| 2031 |
)
|
| 2032 |
fig.update_yaxes(
|
| 2033 |
range=y_range,
|
|
|
|
| 2037 |
ticks="",
|
| 2038 |
scaleanchor=x_axis_id,
|
| 2039 |
scaleratio=1,
|
| 2040 |
+
row=subplot_row,
|
| 2041 |
+
col=subplot_col,
|
| 2042 |
)
|
| 2043 |
|
| 2044 |
+
figure_layout(fig, height=930 if stacked else 480)
|
| 2045 |
fig.update_layout(
|
| 2046 |
title="Held-out trajectories · RNN",
|
| 2047 |
+
margin=dict(l=24, r=24, t=76 if not stacked else 68, b=24),
|
| 2048 |
+
showlegend=False,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2049 |
)
|
| 2050 |
fig.for_each_annotation(
|
| 2051 |
lambda annotation: annotation.update(font=dict(size=12, color=TEXT_COLOR))
|
|
|
|
| 2053 |
return fig
|
| 2054 |
|
| 2055 |
|
| 2056 |
+
def historical_direction_legend() -> html.Div:
|
| 2057 |
+
labels = dict(zip(DIRECTION_LEGEND_ORDER, DIRECTION_LEGEND_LABELS))
|
| 2058 |
+
return html.Div(
|
| 2059 |
+
[
|
| 2060 |
+
html.Span("Reach direction", className="trajectory-legend-title"),
|
| 2061 |
+
*[
|
| 2062 |
+
html.Span(
|
| 2063 |
+
[
|
| 2064 |
+
html.Span(
|
| 2065 |
+
className="trajectory-legend-swatch",
|
| 2066 |
+
style={"backgroundColor": DIRECTION_PALETTE[index]},
|
| 2067 |
+
),
|
| 2068 |
+
labels[index],
|
| 2069 |
+
],
|
| 2070 |
+
className="trajectory-legend-item",
|
| 2071 |
+
)
|
| 2072 |
+
for index in DIRECTION_LEGEND_ORDER
|
| 2073 |
+
],
|
| 2074 |
+
],
|
| 2075 |
+
className="trajectory-legend",
|
| 2076 |
+
)
|
| 2077 |
+
|
| 2078 |
+
|
| 2079 |
def condition_sort_key(value: object) -> tuple[int, float | str]:
|
| 2080 |
try:
|
| 2081 |
return (0, float(value))
|
|
|
|
| 2143 |
return out
|
| 2144 |
|
| 2145 |
|
| 2146 |
+
def latent_space_figure(
|
| 2147 |
+
dataset: str,
|
| 2148 |
+
model: str | None,
|
| 2149 |
+
color_mode: str,
|
| 2150 |
+
*,
|
| 2151 |
+
stacked: bool = False,
|
| 2152 |
+
) -> go.Figure:
|
| 2153 |
if dataset == "mc_pacman":
|
| 2154 |
return empty_figure(
|
| 2155 |
"Cross-recording consistency is not available for this dataset.",
|
|
|
|
| 2180 |
trajectories["color_value"] = pd.Series(dtype=str)
|
| 2181 |
|
| 2182 |
sessions = list(dict.fromkeys(samples["session_label"].astype(str)))
|
| 2183 |
+
columns = 1 if stacked else (2 if len(sessions) > 1 else 1)
|
| 2184 |
rows = int(np.ceil(len(sessions) / columns))
|
| 2185 |
fig = make_subplots(
|
| 2186 |
rows=rows,
|
|
|
|
| 2327 |
title += f" · R² = {float(score.iloc[0]):.3f}"
|
| 2328 |
fig.update_layout(
|
| 2329 |
title=title,
|
| 2330 |
+
height=(max(520, 300 * rows + 110) if stacked else (700 if rows > 1 else 500)),
|
| 2331 |
paper_bgcolor="#FFFFFF",
|
| 2332 |
plot_bgcolor="#FFFFFF",
|
| 2333 |
margin=dict(l=8, r=8, t=72, b=88),
|
|
|
|
| 2341 |
entrywidth=46,
|
| 2342 |
entrywidthmode="pixels",
|
| 2343 |
),
|
| 2344 |
+
showlegend=not stacked,
|
| 2345 |
)
|
| 2346 |
fig.for_each_annotation(lambda annotation: annotation.update(font=dict(size=12, color="#526171")))
|
| 2347 |
return fig
|
| 2348 |
|
| 2349 |
|
| 2350 |
+
def latent_mobile_legend(dataset: str, color_mode: str) -> html.Div:
|
| 2351 |
+
frame = latent_samples[latent_samples["dataset"].astype(str).eq(dataset)].copy()
|
| 2352 |
+
frame = add_latent_color_columns(frame, dataset, color_mode)
|
| 2353 |
+
label = condition_axis_label(dataset, color_mode)
|
| 2354 |
+
if dataset == "ratinabox":
|
| 2355 |
+
colors = [RATINABOX_SCALE[0][1], RATINABOX_SCALE[len(RATINABOX_SCALE) // 2][1], RATINABOX_SCALE[-1][1]]
|
| 2356 |
+
return html.Div(
|
| 2357 |
+
[
|
| 2358 |
+
html.Span(label, className="latent-mobile-legend-title"),
|
| 2359 |
+
*[
|
| 2360 |
+
html.Span(
|
| 2361 |
+
className="latent-mobile-legend-dot",
|
| 2362 |
+
style={"backgroundColor": color},
|
| 2363 |
+
)
|
| 2364 |
+
for color in colors
|
| 2365 |
+
],
|
| 2366 |
+
],
|
| 2367 |
+
className="latent-mobile-legend",
|
| 2368 |
+
)
|
| 2369 |
+
|
| 2370 |
+
values = sorted(frame["color_value"].dropna().unique(), key=condition_sort_key)
|
| 2371 |
+
if dataset == "monkey":
|
| 2372 |
+
colors = {
|
| 2373 |
+
value: DIRECTION_PALETTE[int(value) % len(DIRECTION_PALETTE)]
|
| 2374 |
+
for value in values
|
| 2375 |
+
}
|
| 2376 |
+
elif dataset == "speech":
|
| 2377 |
+
colors = {value: SPEECH_PALETTE.get(value, "#777777") for value in values}
|
| 2378 |
+
else:
|
| 2379 |
+
colors = {value: ALLEN_PALETTE.get(value, "#777777") for value in values}
|
| 2380 |
+
return html.Div(
|
| 2381 |
+
[
|
| 2382 |
+
html.Span(label, className="latent-mobile-legend-title"),
|
| 2383 |
+
*[
|
| 2384 |
+
html.Span(
|
| 2385 |
+
[
|
| 2386 |
+
html.Span(
|
| 2387 |
+
className="latent-mobile-legend-dot",
|
| 2388 |
+
style={"backgroundColor": colors[value]},
|
| 2389 |
+
),
|
| 2390 |
+
condition_label(dataset, value, color_mode),
|
| 2391 |
+
],
|
| 2392 |
+
className="latent-mobile-legend-item",
|
| 2393 |
+
)
|
| 2394 |
+
for value in values
|
| 2395 |
+
],
|
| 2396 |
+
],
|
| 2397 |
+
className="latent-mobile-legend",
|
| 2398 |
+
)
|
| 2399 |
+
|
| 2400 |
+
|
| 2401 |
def consistency_frame(dataset: str, models: Sequence[str] | None) -> pd.DataFrame:
|
| 2402 |
frame = filter_models(active_rows(consistency), models)
|
| 2403 |
frame = frame[frame["dataset"].astype(str) == str(dataset)].copy()
|
|
|
|
| 2437 |
)
|
| 2438 |
)
|
| 2439 |
bar_fig.update_layout(title="Latent consistency")
|
| 2440 |
+
bar_fig.update_xaxes(title="Consistency R²", range=[0, 1.02])
|
| 2441 |
bar_fig.update_yaxes(title="", showgrid=False)
|
| 2442 |
figure_layout(bar_fig, height=max(400, 27 * len(bar) + 145))
|
| 2443 |
columns = ["method", "latent_consistency_r2"]
|
|
|
|
| 2703 |
],
|
| 2704 |
className="inline-controls",
|
| 2705 |
),
|
| 2706 |
+
graph_box(
|
| 2707 |
+
"latent-space",
|
| 2708 |
+
"Aligned latent representations for each recording.",
|
| 2709 |
+
class_name="latent-graph latent-space-desktop",
|
| 2710 |
+
),
|
| 2711 |
+
graph_box(
|
| 2712 |
+
"latent-space-mobile",
|
| 2713 |
+
"Aligned latent representations stacked for narrow screens.",
|
| 2714 |
+
class_name="latent-graph latent-space-mobile",
|
| 2715 |
+
),
|
| 2716 |
+
html.Div(id="latent-mobile-legend"),
|
| 2717 |
html.Div(
|
| 2718 |
[
|
| 2719 |
graph_box("consistency-bars", "Latent-consistency R-squared for the selected dataset."),
|
|
|
|
| 2742 |
"Feature-level attribution",
|
| 2743 |
html.Div(
|
| 2744 |
[
|
| 2745 |
+
html.Label("Method", htmlFor="feature-method"),
|
| 2746 |
+
dcc.Dropdown(id="feature-method", clearable=False),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2747 |
],
|
| 2748 |
+
className="control feature-method-control",
|
| 2749 |
),
|
| 2750 |
+
dcc.Store(id="feature-story-raster-data"),
|
| 2751 |
+
dcc.Store(id="feature-story-attribution-data"),
|
| 2752 |
html.Div(
|
| 2753 |
+
id="feature-story-viz",
|
| 2754 |
+
className="feature-story-viz",
|
| 2755 |
+
role="group",
|
| 2756 |
+
tabIndex=0,
|
| 2757 |
+
**{
|
| 2758 |
+
"aria-label": (
|
| 2759 |
+
"Linked example neural activity and signed "
|
| 2760 |
+
"Kernel SHAP feature ranking."
|
| 2761 |
+
)
|
| 2762 |
+
},
|
| 2763 |
+
),
|
| 2764 |
+
html.Span(
|
| 2765 |
+
id="feature-story-render-token",
|
| 2766 |
+
className="feature-story-render-token",
|
| 2767 |
+
),
|
| 2768 |
+
html.Div(
|
| 2769 |
+
"Hover or tap a raster row or attribution dot to trace the same feature.",
|
| 2770 |
+
id="feature-selection-detail",
|
| 2771 |
+
className="feature-selection-detail",
|
| 2772 |
+
**{"aria-live": "polite"},
|
| 2773 |
),
|
| 2774 |
html.Div(
|
| 2775 |
[
|
|
|
|
| 2778 |
],
|
| 2779 |
className="download-grid panel-downloads",
|
| 2780 |
),
|
| 2781 |
+
subtitle="Follow each input feature from an example trial to its signed Kernel SHAP rank.",
|
| 2782 |
class_name="axis-feature",
|
| 2783 |
),
|
| 2784 |
panel(
|
|
|
|
| 2848 |
graph_box(
|
| 2849 |
"trial-historical-trajectories",
|
| 2850 |
"Held-out RNN target-session trajectories for ground truth, current-session training, and nonnegative-valued historical-trial selection.",
|
| 2851 |
+
class_name="historical-trajectory-graph historical-trajectory-desktop",
|
| 2852 |
),
|
| 2853 |
+
graph_box(
|
| 2854 |
+
"trial-historical-trajectories-mobile",
|
| 2855 |
+
"Held-out RNN target-session trajectories, stacked for narrow screens.",
|
| 2856 |
+
class_name="historical-trajectory-graph historical-trajectory-mobile",
|
| 2857 |
+
),
|
| 2858 |
+
historical_direction_legend(),
|
| 2859 |
details_table("View data", dataframe_table("trial-retrain-table", page_size=17)),
|
| 2860 |
html.Div(
|
| 2861 |
[
|
|
|
|
| 3014 |
|
| 3015 |
@app.callback(
|
| 3016 |
Output("latent-space", "figure"),
|
| 3017 |
+
Output("latent-space-mobile", "figure"),
|
| 3018 |
+
Output("latent-mobile-legend", "children"),
|
| 3019 |
Output("consistency-bars", "figure"),
|
| 3020 |
Output("consistency-heatmap", "figure"),
|
| 3021 |
Output("consistency-table", "columns"),
|
|
|
|
| 3033 |
):
|
| 3034 |
dataset = dataset or DATASETS[0]
|
| 3035 |
bars, heatmap, table = consistency_figures(dataset, models)
|
| 3036 |
+
resolved_color_mode = color_mode or "condition"
|
| 3037 |
return (
|
| 3038 |
+
latent_space_figure(dataset, method, resolved_color_mode),
|
| 3039 |
+
latent_space_figure(dataset, method, resolved_color_mode, stacked=True),
|
| 3040 |
+
latent_mobile_legend(dataset, resolved_color_mode),
|
| 3041 |
bars,
|
| 3042 |
heatmap,
|
| 3043 |
column_defs(table.columns),
|
|
|
|
| 3082 |
|
| 3083 |
|
| 3084 |
@app.callback(
|
| 3085 |
+
Output("feature-story-raster-data", "data"),
|
| 3086 |
Input("dataset-filter", "value"),
|
| 3087 |
)
|
| 3088 |
+
def update_feature_story_raster(dataset: str):
|
| 3089 |
+
return feature_story_raster_payload(dataset or DATASETS[0])
|
| 3090 |
|
| 3091 |
|
| 3092 |
@app.callback(
|
| 3093 |
+
Output("feature-story-attribution-data", "data"),
|
|
|
|
| 3094 |
Input("dataset-filter", "value"),
|
| 3095 |
Input("feature-method", "value"),
|
|
|
|
| 3096 |
)
|
| 3097 |
+
def update_feature_story_attribution(
|
| 3098 |
dataset: str,
|
| 3099 |
method: str | None,
|
|
|
|
| 3100 |
):
|
| 3101 |
+
return feature_story_attribution_payload(dataset or DATASETS[0], method)
|
|
|
|
|
|
|
|
|
|
| 3102 |
|
| 3103 |
|
| 3104 |
+
app.clientside_callback(
|
| 3105 |
+
"""
|
| 3106 |
+
function(rasterData, attributionData, activeTab) {
|
| 3107 |
+
if (!rasterData || !attributionData ||
|
| 3108 |
+
activeTab !== "feature" ||
|
| 3109 |
+
rasterData.dataset !== attributionData.dataset ||
|
| 3110 |
+
!window.benchdashFeatureStory) {
|
| 3111 |
+
return window.dash_clientside.no_update;
|
| 3112 |
+
}
|
| 3113 |
+
window.benchdashFeatureStory.schedule(
|
| 3114 |
+
"feature-story-viz",
|
| 3115 |
+
"feature-selection-detail",
|
| 3116 |
+
rasterData,
|
| 3117 |
+
attributionData
|
| 3118 |
+
);
|
| 3119 |
+
return `${rasterData.dataset}:${attributionData.model}:feature`;
|
| 3120 |
+
}
|
| 3121 |
+
""",
|
| 3122 |
+
Output("feature-story-render-token", "children"),
|
| 3123 |
+
Input("feature-story-raster-data", "data"),
|
| 3124 |
+
Input("feature-story-attribution-data", "data"),
|
| 3125 |
+
Input("tabs", "value"),
|
| 3126 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3127 |
|
| 3128 |
|
| 3129 |
@app.callback(
|
|
|
|
| 3176 |
Output("trial-recovery", "figure"),
|
| 3177 |
Output("trial-historical", "figure"),
|
| 3178 |
Output("trial-historical-trajectories", "figure"),
|
| 3179 |
+
Output("trial-historical-trajectories-mobile", "figure"),
|
| 3180 |
Output("trial-retrain-table", "columns"),
|
| 3181 |
Output("trial-retrain-table", "data"),
|
| 3182 |
Input("dataset-filter", "value"),
|
|
|
|
| 3199 |
relation,
|
| 3200 |
historical,
|
| 3201 |
historical_trajectory_figure(),
|
| 3202 |
+
historical_trajectory_figure(stacked=True),
|
| 3203 |
column_defs(retrain_table.columns),
|
| 3204 |
records(retrain_table),
|
| 3205 |
)
|
assets/feature_story.js
ADDED
|
@@ -0,0 +1,849 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(function () {
|
| 2 |
+
"use strict";
|
| 3 |
+
|
| 4 |
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
| 5 |
+
const GROUP_COLORS = {
|
| 6 |
+
"Recorded": "#0072B2",
|
| 7 |
+
"Synthetic control": "#BDBDBD",
|
| 8 |
+
"Place": "#CC79A7",
|
| 9 |
+
"Head direction": "#56B4E9",
|
| 10 |
+
"Speed": "#E69F00",
|
| 11 |
+
};
|
| 12 |
+
const DATASET_RECORDED_COLORS = {
|
| 13 |
+
monkey: "#0072B2",
|
| 14 |
+
speech: "#009E73",
|
| 15 |
+
mc_pacman: "#D55E00",
|
| 16 |
+
};
|
| 17 |
+
const TEXT = "#17202A";
|
| 18 |
+
const MUTED = "#607080";
|
| 19 |
+
const GRID = "#E7ECEF";
|
| 20 |
+
const DARK_GRID = "#AAB6BE";
|
| 21 |
+
|
| 22 |
+
function clamp(value, low, high) {
|
| 23 |
+
return Math.max(low, Math.min(high, value));
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function svgElement(tag, attributes, text) {
|
| 27 |
+
const node = document.createElementNS(SVG_NS, tag);
|
| 28 |
+
Object.entries(attributes || {}).forEach(([name, value]) => {
|
| 29 |
+
node.setAttribute(name, String(value));
|
| 30 |
+
});
|
| 31 |
+
if (text !== undefined) node.textContent = text;
|
| 32 |
+
return node;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function htmlElement(tag, className, text) {
|
| 36 |
+
const node = document.createElement(tag);
|
| 37 |
+
if (className) node.className = className;
|
| 38 |
+
if (text !== undefined) node.textContent = text;
|
| 39 |
+
return node;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function mixColor(start, end, fraction) {
|
| 43 |
+
const t = clamp(fraction, 0, 1);
|
| 44 |
+
const parse = (hex) => [
|
| 45 |
+
parseInt(hex.slice(1, 3), 16),
|
| 46 |
+
parseInt(hex.slice(3, 5), 16),
|
| 47 |
+
parseInt(hex.slice(5, 7), 16),
|
| 48 |
+
];
|
| 49 |
+
const a = parse(start);
|
| 50 |
+
const b = parse(end);
|
| 51 |
+
const values = a.map((value, index) => Math.round(value + (b[index] - value) * t));
|
| 52 |
+
return `rgb(${values[0]}, ${values[1]}, ${values[2]})`;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function gosiColor(value, low, high) {
|
| 56 |
+
const span = Math.max(high - low, 1e-9);
|
| 57 |
+
return mixColor("#DCE7EC", "#245B78", (value - low) / span);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function featureColor(dataset, feature, validationDomain) {
|
| 61 |
+
if (dataset === "allen_neuropixels") {
|
| 62 |
+
return gosiColor(feature.validation_value || 0, validationDomain[0], validationDomain[1]);
|
| 63 |
+
}
|
| 64 |
+
if (feature.group === "Recorded" && DATASET_RECORDED_COLORS[dataset]) {
|
| 65 |
+
return DATASET_RECORDED_COLORS[dataset];
|
| 66 |
+
}
|
| 67 |
+
return GROUP_COLORS[feature.group] || "#6A51A3";
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function numericTicks(low, high, count) {
|
| 71 |
+
if (!Number.isFinite(low) || !Number.isFinite(high)) return [0];
|
| 72 |
+
if (Math.abs(high - low) < 1e-12) return [low];
|
| 73 |
+
return Array.from({ length: count }, (_, index) => low + (high - low) * index / (count - 1));
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function integerTicks(maximum) {
|
| 77 |
+
const max = Math.max(0, Math.round(maximum));
|
| 78 |
+
if (max <= 4) return Array.from({ length: max + 1 }, (_, index) => index);
|
| 79 |
+
const candidates = [0, Math.round(max / 3), Math.round(2 * max / 3), max];
|
| 80 |
+
return [...new Set(candidates)];
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function formatScore(value) {
|
| 84 |
+
if (Math.abs(value) >= 0.1) return value.toFixed(2).replace(/\.?0+$/, "");
|
| 85 |
+
if (Math.abs(value) >= 0.01) return value.toFixed(3).replace(/\.?0+$/, "");
|
| 86 |
+
return value.toPrecision(2).replace("e+", "e");
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function signedScore(value) {
|
| 90 |
+
const prefix = value > 0 ? "+" : "";
|
| 91 |
+
return `${prefix}${Number(value).toPrecision(4).replace("e+", "e")}`;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function bezier(start, controlOne, controlTwo, end, fraction) {
|
| 95 |
+
const t = clamp(fraction, 0, 1);
|
| 96 |
+
const u = 1 - t;
|
| 97 |
+
return {
|
| 98 |
+
x: u * u * u * start.x + 3 * u * u * t * controlOne.x +
|
| 99 |
+
3 * u * t * t * controlTwo.x + t * t * t * end.x,
|
| 100 |
+
y: u * u * u * start.y + 3 * u * u * t * controlOne.y +
|
| 101 |
+
3 * u * t * t * controlTwo.y + t * t * t * end.y,
|
| 102 |
+
};
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function groupSpans(features) {
|
| 106 |
+
const spans = [];
|
| 107 |
+
features.forEach((feature, index) => {
|
| 108 |
+
const previous = spans[spans.length - 1];
|
| 109 |
+
if (!previous || previous.group !== feature.group) {
|
| 110 |
+
spans.push({ group: feature.group, first: index, last: index });
|
| 111 |
+
} else {
|
| 112 |
+
previous.last = index;
|
| 113 |
+
}
|
| 114 |
+
});
|
| 115 |
+
return spans;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function buildState(container, detailNode, rasterData, attributionData) {
|
| 119 |
+
const attribution = new Map(
|
| 120 |
+
attributionData.features.map((feature) => [Number(feature.feature_index), feature])
|
| 121 |
+
);
|
| 122 |
+
const validationValues = rasterData.features
|
| 123 |
+
.map((feature) => Number(feature.validation_value))
|
| 124 |
+
.filter(Number.isFinite);
|
| 125 |
+
const validationDomain = validationValues.length
|
| 126 |
+
? [Math.min(...validationValues), Math.max(...validationValues)]
|
| 127 |
+
: [0, 1];
|
| 128 |
+
const features = rasterData.features.map((feature, row) => {
|
| 129 |
+
const featureIndex = Number(feature.feature_index);
|
| 130 |
+
const score = attribution.get(featureIndex);
|
| 131 |
+
if (!score) throw new Error(`Missing attribution for feature ${featureIndex}`);
|
| 132 |
+
return {
|
| 133 |
+
...feature,
|
| 134 |
+
...score,
|
| 135 |
+
feature_index: featureIndex,
|
| 136 |
+
row,
|
| 137 |
+
color: featureColor(rasterData.dataset, feature, validationDomain),
|
| 138 |
+
};
|
| 139 |
+
});
|
| 140 |
+
if (features.length !== attribution.size) {
|
| 141 |
+
throw new Error("Raster and attribution feature sets differ");
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
container.replaceChildren();
|
| 145 |
+
const visual = htmlElement("div", "feature-story-visual");
|
| 146 |
+
const canvas = htmlElement("canvas", "feature-story-canvas");
|
| 147 |
+
canvas.setAttribute("aria-hidden", "true");
|
| 148 |
+
const svg = svgElement("svg", {
|
| 149 |
+
class: "feature-story-overlay",
|
| 150 |
+
role: "img",
|
| 151 |
+
"aria-label": "Feature activity rows linked to signed Kernel SHAP ranks",
|
| 152 |
+
});
|
| 153 |
+
svg.append(
|
| 154 |
+
svgElement("title", {}, "Feature activity linked to signed Kernel SHAP ranking"),
|
| 155 |
+
svgElement(
|
| 156 |
+
"desc",
|
| 157 |
+
{},
|
| 158 |
+
"Each feature dot moves from its row in the example activity raster to its signed attribution rank."
|
| 159 |
+
)
|
| 160 |
+
);
|
| 161 |
+
visual.append(canvas, svg);
|
| 162 |
+
|
| 163 |
+
const playback = htmlElement("div", "feature-story-playback");
|
| 164 |
+
const sourceLabel = htmlElement("span", "feature-story-playback-label", "Example-trial order");
|
| 165 |
+
sourceLabel.classList.add("source");
|
| 166 |
+
const slider = htmlElement("input", "feature-story-slider");
|
| 167 |
+
slider.type = "range";
|
| 168 |
+
slider.min = "0";
|
| 169 |
+
slider.max = "1";
|
| 170 |
+
slider.step = "0.01";
|
| 171 |
+
slider.value = "1";
|
| 172 |
+
slider.setAttribute("aria-label", "Feature mapping progress from example-trial order to SHAP rank");
|
| 173 |
+
const targetLabel = htmlElement(
|
| 174 |
+
"span",
|
| 175 |
+
"feature-story-playback-label",
|
| 176 |
+
attributionData.tied ? "Feature order" : "SHAP rank"
|
| 177 |
+
);
|
| 178 |
+
targetLabel.classList.add("target");
|
| 179 |
+
const replay = htmlElement("button", "feature-story-replay", "Replay mapping");
|
| 180 |
+
replay.type = "button";
|
| 181 |
+
playback.append(sourceLabel, slider, targetLabel, replay);
|
| 182 |
+
container.append(visual, playback);
|
| 183 |
+
|
| 184 |
+
const state = {
|
| 185 |
+
signature: `${rasterData.dataset}:${attributionData.model}`,
|
| 186 |
+
container,
|
| 187 |
+
detailNode,
|
| 188 |
+
rasterData,
|
| 189 |
+
attributionData,
|
| 190 |
+
validationDomain,
|
| 191 |
+
features,
|
| 192 |
+
visual,
|
| 193 |
+
canvas,
|
| 194 |
+
svg,
|
| 195 |
+
slider,
|
| 196 |
+
replay,
|
| 197 |
+
progress: 1,
|
| 198 |
+
hovered: null,
|
| 199 |
+
locked: null,
|
| 200 |
+
keyboardRow: 0,
|
| 201 |
+
positions: new Map(),
|
| 202 |
+
circles: new Map(),
|
| 203 |
+
animationFrame: null,
|
| 204 |
+
resizeObserver: null,
|
| 205 |
+
abortController: new AbortController(),
|
| 206 |
+
geometry: null,
|
| 207 |
+
};
|
| 208 |
+
container.__featureStory = state;
|
| 209 |
+
return state;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
function addText(svg, x, y, text, options) {
|
| 213 |
+
const settings = options || {};
|
| 214 |
+
const node = svgElement("text", {
|
| 215 |
+
x,
|
| 216 |
+
y,
|
| 217 |
+
fill: settings.fill || TEXT,
|
| 218 |
+
"font-size": settings.size || 11,
|
| 219 |
+
"font-weight": settings.weight || 400,
|
| 220 |
+
"text-anchor": settings.anchor || "start",
|
| 221 |
+
"dominant-baseline": settings.baseline || "alphabetic",
|
| 222 |
+
class: settings.className || "",
|
| 223 |
+
}, text);
|
| 224 |
+
svg.appendChild(node);
|
| 225 |
+
return node;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function computeGeometry(state, width) {
|
| 229 |
+
const mobile = width < 720;
|
| 230 |
+
if (mobile) {
|
| 231 |
+
const rasterLabelWidth = state.rasterData.dataset === "allen_neuropixels" ? 10 : 74;
|
| 232 |
+
const raster = {
|
| 233 |
+
x: rasterLabelWidth,
|
| 234 |
+
y: 54,
|
| 235 |
+
width: Math.max(120, width - rasterLabelWidth - 18),
|
| 236 |
+
height: 244,
|
| 237 |
+
};
|
| 238 |
+
const chart = {
|
| 239 |
+
x: 46,
|
| 240 |
+
y: 394,
|
| 241 |
+
width: Math.max(130, width - 92),
|
| 242 |
+
height: 254,
|
| 243 |
+
};
|
| 244 |
+
return { mobile, width, height: 708, raster, chart };
|
| 245 |
+
}
|
| 246 |
+
const rasterLabelWidth = state.rasterData.dataset === "allen_neuropixels" ? 18 : 92;
|
| 247 |
+
const chartX = Math.round(width * 0.59);
|
| 248 |
+
const raster = {
|
| 249 |
+
x: rasterLabelWidth,
|
| 250 |
+
y: 54,
|
| 251 |
+
width: Math.max(220, chartX - rasterLabelWidth - 64),
|
| 252 |
+
height: 438,
|
| 253 |
+
};
|
| 254 |
+
const chart = {
|
| 255 |
+
x: chartX,
|
| 256 |
+
y: 54,
|
| 257 |
+
width: Math.max(220, width - chartX - 52),
|
| 258 |
+
height: 438,
|
| 259 |
+
};
|
| 260 |
+
return { mobile, width, height: 552, raster, chart };
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
function drawRaster(state) {
|
| 264 |
+
const { raster } = state.geometry;
|
| 265 |
+
const canvas = state.canvas;
|
| 266 |
+
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
| 267 |
+
canvas.width = Math.round(state.geometry.width * ratio);
|
| 268 |
+
canvas.height = Math.round(state.geometry.height * ratio);
|
| 269 |
+
canvas.style.width = `${state.geometry.width}px`;
|
| 270 |
+
canvas.style.height = `${state.geometry.height}px`;
|
| 271 |
+
const context = canvas.getContext("2d", { alpha: false });
|
| 272 |
+
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
| 273 |
+
context.fillStyle = "#FFFFFF";
|
| 274 |
+
context.fillRect(0, 0, state.geometry.width, state.geometry.height);
|
| 275 |
+
|
| 276 |
+
const rows = state.features.length;
|
| 277 |
+
const columns = state.rasterData.time_ms.length;
|
| 278 |
+
const rowHeight = raster.height / Math.max(rows, 1);
|
| 279 |
+
const columnWidth = raster.width / Math.max(columns, 1);
|
| 280 |
+
const maximum = Math.max(Number(state.rasterData.max_count), 1);
|
| 281 |
+
state.rasterData.counts.forEach((row, rowIndex) => {
|
| 282 |
+
row.forEach((value, columnIndex) => {
|
| 283 |
+
if (!value) return;
|
| 284 |
+
context.fillStyle = mixColor("#F8FAFB", "#263238", Number(value) / maximum);
|
| 285 |
+
const x0 = raster.x + columnIndex * columnWidth;
|
| 286 |
+
const x1 = raster.x + (columnIndex + 1) * columnWidth;
|
| 287 |
+
const y0 = raster.y + rowIndex * rowHeight;
|
| 288 |
+
const y1 = raster.y + (rowIndex + 1) * rowHeight;
|
| 289 |
+
context.fillRect(
|
| 290 |
+
Math.floor(x0),
|
| 291 |
+
Math.floor(y0),
|
| 292 |
+
Math.max(1, Math.ceil(x1) - Math.floor(x0)),
|
| 293 |
+
Math.max(1, Math.ceil(y1) - Math.floor(y0))
|
| 294 |
+
);
|
| 295 |
+
});
|
| 296 |
+
context.fillStyle = state.features[rowIndex].color;
|
| 297 |
+
context.fillRect(
|
| 298 |
+
raster.x + raster.width + 3,
|
| 299 |
+
raster.y + rowIndex * rowHeight,
|
| 300 |
+
5,
|
| 301 |
+
Math.max(1, rowHeight)
|
| 302 |
+
);
|
| 303 |
+
});
|
| 304 |
+
context.strokeStyle = "#CFD8DE";
|
| 305 |
+
context.lineWidth = 1;
|
| 306 |
+
context.strokeRect(raster.x - 0.5, raster.y - 0.5, raster.width + 1, raster.height + 1);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function drawCountLegend(state, y) {
|
| 310 |
+
const { svg, geometry } = state;
|
| 311 |
+
let ticks = integerTicks(state.rasterData.max_count);
|
| 312 |
+
if (geometry.width < 300 && ticks.length > 2) {
|
| 313 |
+
ticks = [0, Math.round(state.rasterData.max_count)];
|
| 314 |
+
}
|
| 315 |
+
const maximum = Math.max(Number(state.rasterData.max_count), 1);
|
| 316 |
+
const itemWidth = geometry.mobile ? 32 : 38;
|
| 317 |
+
const labelWidth = geometry.width < 300 ? 50 : 58;
|
| 318 |
+
const width = labelWidth + ticks.length * itemWidth;
|
| 319 |
+
const start = Math.max(geometry.raster.x, geometry.raster.x + geometry.raster.width - width);
|
| 320 |
+
addText(svg, start, y, "Count / bin", { size: 9, fill: MUTED, weight: 600 });
|
| 321 |
+
ticks.forEach((tick, index) => {
|
| 322 |
+
const x = start + labelWidth + index * itemWidth;
|
| 323 |
+
svg.appendChild(svgElement("rect", {
|
| 324 |
+
x,
|
| 325 |
+
y: y - 9,
|
| 326 |
+
width: 9,
|
| 327 |
+
height: 9,
|
| 328 |
+
rx: 1,
|
| 329 |
+
fill: mixColor("#F8FAFB", "#263238", tick / maximum),
|
| 330 |
+
stroke: "#CAD3D9",
|
| 331 |
+
"stroke-width": 0.6,
|
| 332 |
+
}));
|
| 333 |
+
addText(svg, x + 13, y, String(tick), { size: 9, fill: MUTED });
|
| 334 |
+
});
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
function drawReferenceLabels(state) {
|
| 338 |
+
const { svg, geometry, features } = state;
|
| 339 |
+
if (state.rasterData.dataset === "allen_neuropixels") {
|
| 340 |
+
addText(svg, geometry.raster.x + geometry.raster.width + 10, geometry.raster.y - 7, "gOSI", {
|
| 341 |
+
size: 9,
|
| 342 |
+
fill: MUTED,
|
| 343 |
+
anchor: "end",
|
| 344 |
+
});
|
| 345 |
+
return;
|
| 346 |
+
}
|
| 347 |
+
const rowHeight = geometry.raster.height / Math.max(features.length, 1);
|
| 348 |
+
groupSpans(features).forEach((span) => {
|
| 349 |
+
const y = geometry.raster.y + ((span.first + span.last + 1) / 2) * rowHeight;
|
| 350 |
+
addText(svg, geometry.raster.x - 8, y, span.group, {
|
| 351 |
+
size: geometry.mobile ? 9 : 10,
|
| 352 |
+
fill: MUTED,
|
| 353 |
+
anchor: "end",
|
| 354 |
+
baseline: "middle",
|
| 355 |
+
});
|
| 356 |
+
});
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
function drawFeatureLegend(state, y) {
|
| 360 |
+
const { svg, geometry, features } = state;
|
| 361 |
+
let x = geometry.chart.x;
|
| 362 |
+
if (state.rasterData.dataset === "allen_neuropixels") {
|
| 363 |
+
addText(svg, x, y, "gOSI", { size: 9, fill: MUTED, weight: 600 });
|
| 364 |
+
x += 31;
|
| 365 |
+
const values = numericTicks(state.validationDomain[0], state.validationDomain[1], 3);
|
| 366 |
+
values.forEach((value) => {
|
| 367 |
+
svg.appendChild(svgElement("circle", {
|
| 368 |
+
cx: x + 5,
|
| 369 |
+
cy: y - 3,
|
| 370 |
+
r: 4,
|
| 371 |
+
fill: gosiColor(value, state.validationDomain[0], state.validationDomain[1]),
|
| 372 |
+
stroke: "#FFFFFF",
|
| 373 |
+
"stroke-width": 0.6,
|
| 374 |
+
}));
|
| 375 |
+
addText(svg, x + 12, y, value.toFixed(2), { size: 9, fill: MUTED });
|
| 376 |
+
x += geometry.mobile ? 50 : 58;
|
| 377 |
+
});
|
| 378 |
+
return;
|
| 379 |
+
}
|
| 380 |
+
const groups = [...new Map(features.map((feature) => [feature.group, feature.color])).entries()];
|
| 381 |
+
groups.forEach(([group, color]) => {
|
| 382 |
+
svg.appendChild(svgElement("circle", {
|
| 383 |
+
cx: x + 4,
|
| 384 |
+
cy: y - 3,
|
| 385 |
+
r: 4,
|
| 386 |
+
fill: color,
|
| 387 |
+
stroke: "#FFFFFF",
|
| 388 |
+
"stroke-width": 0.6,
|
| 389 |
+
}));
|
| 390 |
+
addText(svg, x + 12, y, group, { size: 9, fill: MUTED });
|
| 391 |
+
x += 18 + group.length * 5.3;
|
| 392 |
+
});
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
function drawAxes(state) {
|
| 396 |
+
const { svg, geometry, features } = state;
|
| 397 |
+
const { raster, chart } = geometry;
|
| 398 |
+
addText(svg, raster.x, 20, "Example activity · one trial", { size: 13, weight: 700 });
|
| 399 |
+
drawCountLegend(state, 39);
|
| 400 |
+
drawReferenceLabels(state);
|
| 401 |
+
|
| 402 |
+
const time = state.rasterData.time_ms.map(Number);
|
| 403 |
+
const timeLow = time[0];
|
| 404 |
+
const timeHigh = time[time.length - 1];
|
| 405 |
+
const timeTicks = [timeLow];
|
| 406 |
+
if (timeLow < 0 && timeHigh > 0) timeTicks.push(0);
|
| 407 |
+
if (timeHigh !== timeLow) timeTicks.push(timeHigh);
|
| 408 |
+
svg.appendChild(svgElement("line", {
|
| 409 |
+
x1: raster.x,
|
| 410 |
+
x2: raster.x + raster.width,
|
| 411 |
+
y1: raster.y + raster.height,
|
| 412 |
+
y2: raster.y + raster.height,
|
| 413 |
+
stroke: DARK_GRID,
|
| 414 |
+
"stroke-width": 1,
|
| 415 |
+
}));
|
| 416 |
+
timeTicks.forEach((tick) => {
|
| 417 |
+
const fraction = (tick - timeLow) / Math.max(timeHigh - timeLow, 1e-9);
|
| 418 |
+
const x = raster.x + fraction * raster.width;
|
| 419 |
+
svg.appendChild(svgElement("line", {
|
| 420 |
+
x1: x,
|
| 421 |
+
x2: x,
|
| 422 |
+
y1: raster.y + raster.height,
|
| 423 |
+
y2: raster.y + raster.height + 4,
|
| 424 |
+
stroke: DARK_GRID,
|
| 425 |
+
}));
|
| 426 |
+
addText(svg, x, raster.y + raster.height + 16, `${Math.round(tick)}`, {
|
| 427 |
+
size: 9,
|
| 428 |
+
fill: MUTED,
|
| 429 |
+
anchor: "middle",
|
| 430 |
+
});
|
| 431 |
+
});
|
| 432 |
+
addText(svg, raster.x + raster.width / 2, raster.y + raster.height + 34, "Time from scoring onset (ms)", {
|
| 433 |
+
size: 10,
|
| 434 |
+
fill: MUTED,
|
| 435 |
+
anchor: "middle",
|
| 436 |
+
});
|
| 437 |
+
|
| 438 |
+
const chartTitleY = geometry.mobile ? 360 : 20;
|
| 439 |
+
const legendY = geometry.mobile ? 380 : 39;
|
| 440 |
+
addText(
|
| 441 |
+
svg,
|
| 442 |
+
chart.x,
|
| 443 |
+
chartTitleY,
|
| 444 |
+
state.attributionData.tied ? "Signed Kernel SHAP values" : "Signed Kernel SHAP ranking",
|
| 445 |
+
{ size: 13, weight: 700 }
|
| 446 |
+
);
|
| 447 |
+
drawFeatureLegend(state, legendY);
|
| 448 |
+
|
| 449 |
+
const scores = features.map((feature) => Number(feature.signed_attribution));
|
| 450 |
+
let scoreLow = Math.min(0, ...scores);
|
| 451 |
+
let scoreHigh = Math.max(0, ...scores);
|
| 452 |
+
let span = scoreHigh - scoreLow;
|
| 453 |
+
if (span < 1e-12) span = Math.max(Math.abs(scoreHigh), 1) * 0.1;
|
| 454 |
+
scoreLow -= span * 0.08;
|
| 455 |
+
scoreHigh += span * 0.08;
|
| 456 |
+
state.scoreDomain = [scoreLow, scoreHigh];
|
| 457 |
+
|
| 458 |
+
const xTicks = numericTicks(scoreLow, scoreHigh, geometry.mobile ? 3 : 5);
|
| 459 |
+
xTicks.forEach((tick) => {
|
| 460 |
+
const x = chart.x + (tick - scoreLow) / (scoreHigh - scoreLow) * chart.width;
|
| 461 |
+
svg.appendChild(svgElement("line", {
|
| 462 |
+
x1: x,
|
| 463 |
+
x2: x,
|
| 464 |
+
y1: chart.y,
|
| 465 |
+
y2: chart.y + chart.height,
|
| 466 |
+
stroke: Math.abs(tick) < span * 0.03 ? DARK_GRID : GRID,
|
| 467 |
+
"stroke-width": Math.abs(tick) < span * 0.03 ? 1.2 : 0.8,
|
| 468 |
+
}));
|
| 469 |
+
addText(svg, x, chart.y + chart.height + 17, formatScore(tick), {
|
| 470 |
+
size: 9,
|
| 471 |
+
fill: MUTED,
|
| 472 |
+
anchor: "middle",
|
| 473 |
+
});
|
| 474 |
+
});
|
| 475 |
+
addText(svg, chart.x + chart.width / 2, chart.y + chart.height + 36, "Signed Kernel SHAP value", {
|
| 476 |
+
size: 10,
|
| 477 |
+
fill: MUTED,
|
| 478 |
+
anchor: "middle",
|
| 479 |
+
});
|
| 480 |
+
|
| 481 |
+
const rankMaximum = features.length;
|
| 482 |
+
const rankTicks = [...new Set([1, Math.max(1, Math.round(rankMaximum / 2)), rankMaximum])];
|
| 483 |
+
addText(svg, chart.x + chart.width + 10, chart.y - 10, state.attributionData.tied ? "Feature" : "Rank", {
|
| 484 |
+
size: 9,
|
| 485 |
+
fill: MUTED,
|
| 486 |
+
weight: 600,
|
| 487 |
+
});
|
| 488 |
+
rankTicks.forEach((rank) => {
|
| 489 |
+
const y = chart.y + (rank - 1) / Math.max(rankMaximum - 1, 1) * chart.height;
|
| 490 |
+
svg.appendChild(svgElement("line", {
|
| 491 |
+
x1: chart.x + chart.width,
|
| 492 |
+
x2: chart.x + chart.width + 4,
|
| 493 |
+
y1: y,
|
| 494 |
+
y2: y,
|
| 495 |
+
stroke: DARK_GRID,
|
| 496 |
+
}));
|
| 497 |
+
addText(svg, chart.x + chart.width + 8, y, String(rank), {
|
| 498 |
+
size: 9,
|
| 499 |
+
fill: MUTED,
|
| 500 |
+
baseline: "middle",
|
| 501 |
+
});
|
| 502 |
+
});
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
function sourcePosition(state, feature) {
|
| 506 |
+
const { raster } = state.geometry;
|
| 507 |
+
return {
|
| 508 |
+
x: raster.x + raster.width + 6,
|
| 509 |
+
y: raster.y + (feature.row + 0.5) / state.features.length * raster.height,
|
| 510 |
+
};
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
function targetPosition(state, feature) {
|
| 514 |
+
const { chart } = state.geometry;
|
| 515 |
+
const [low, high] = state.scoreDomain;
|
| 516 |
+
const x = chart.x + (Number(feature.signed_attribution) - low) / Math.max(high - low, 1e-9) * chart.width;
|
| 517 |
+
const displayedRank = state.attributionData.tied ? feature.row + 1 : Number(feature.rank);
|
| 518 |
+
const y = chart.y + (displayedRank - 1) / Math.max(state.features.length - 1, 1) * chart.height;
|
| 519 |
+
return { x, y };
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
function featurePosition(state, feature, progress) {
|
| 523 |
+
const start = sourcePosition(state, feature);
|
| 524 |
+
const end = targetPosition(state, feature);
|
| 525 |
+
const mobile = state.geometry.mobile;
|
| 526 |
+
const controlOne = mobile
|
| 527 |
+
? { x: start.x, y: start.y + (end.y - start.y) * 0.42 }
|
| 528 |
+
: { x: start.x + (end.x - start.x) * 0.40, y: start.y };
|
| 529 |
+
const controlTwo = mobile
|
| 530 |
+
? { x: end.x, y: end.y - (end.y - start.y) * 0.34 }
|
| 531 |
+
: { x: end.x - (end.x - start.x) * 0.32, y: end.y };
|
| 532 |
+
return bezier(start, controlOne, controlTwo, end, progress);
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
function updateConnector(state) {
|
| 536 |
+
if (!state.connector || !state.rowHighlight) return;
|
| 537 |
+
const selectedId = state.locked !== null ? state.locked : state.hovered;
|
| 538 |
+
if (selectedId === null) {
|
| 539 |
+
state.connector.setAttribute("visibility", "hidden");
|
| 540 |
+
state.rowHighlight.setAttribute("visibility", "hidden");
|
| 541 |
+
state.circles.forEach((circle) => {
|
| 542 |
+
circle.setAttribute("opacity", "0.86");
|
| 543 |
+
circle.setAttribute("r", circle.dataset.baseRadius);
|
| 544 |
+
});
|
| 545 |
+
return;
|
| 546 |
+
}
|
| 547 |
+
const feature = state.features.find((item) => item.feature_index === selectedId);
|
| 548 |
+
if (!feature) return;
|
| 549 |
+
const start = sourcePosition(state, feature);
|
| 550 |
+
const current = state.positions.get(selectedId) || targetPosition(state, feature);
|
| 551 |
+
const mid = state.geometry.mobile
|
| 552 |
+
? `C ${start.x} ${(start.y + current.y) / 2}, ${current.x} ${(start.y + current.y) / 2}, ${current.x} ${current.y}`
|
| 553 |
+
: `C ${(start.x + current.x) / 2} ${start.y}, ${(start.x + current.x) / 2} ${current.y}, ${current.x} ${current.y}`;
|
| 554 |
+
state.connector.setAttribute("d", `M ${start.x} ${start.y} ${mid}`);
|
| 555 |
+
state.connector.setAttribute("stroke", feature.color);
|
| 556 |
+
state.connector.setAttribute("visibility", "visible");
|
| 557 |
+
const rowHeight = state.geometry.raster.height / state.features.length;
|
| 558 |
+
state.rowHighlight.setAttribute("x", state.geometry.raster.x);
|
| 559 |
+
state.rowHighlight.setAttribute("y", state.geometry.raster.y + feature.row * rowHeight);
|
| 560 |
+
state.rowHighlight.setAttribute("width", state.geometry.raster.width + 9);
|
| 561 |
+
state.rowHighlight.setAttribute("height", Math.max(rowHeight, 1.5));
|
| 562 |
+
state.rowHighlight.setAttribute("stroke", feature.color);
|
| 563 |
+
state.rowHighlight.setAttribute("visibility", "visible");
|
| 564 |
+
state.circles.forEach((circle, featureId) => {
|
| 565 |
+
const active = featureId === selectedId;
|
| 566 |
+
circle.setAttribute("opacity", active ? "1" : "0.16");
|
| 567 |
+
circle.setAttribute("r", active ? Number(circle.dataset.baseRadius) + 2.2 : circle.dataset.baseRadius);
|
| 568 |
+
});
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
function updatePositions(state, progress) {
|
| 572 |
+
state.progress = clamp(progress, 0, 1);
|
| 573 |
+
state.slider.value = String(state.progress);
|
| 574 |
+
state.features.forEach((feature) => {
|
| 575 |
+
const point = featurePosition(state, feature, state.progress);
|
| 576 |
+
state.positions.set(feature.feature_index, point);
|
| 577 |
+
const circle = state.circles.get(feature.feature_index);
|
| 578 |
+
if (circle) {
|
| 579 |
+
circle.setAttribute("cx", point.x);
|
| 580 |
+
circle.setAttribute("cy", point.y);
|
| 581 |
+
}
|
| 582 |
+
});
|
| 583 |
+
updateConnector(state);
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
function updateReplayPositions(state, progress) {
|
| 587 |
+
const globalProgress = clamp(progress, 0, 1);
|
| 588 |
+
state.progress = globalProgress;
|
| 589 |
+
state.slider.value = String(globalProgress);
|
| 590 |
+
state.features.forEach((feature) => {
|
| 591 |
+
const delay = state.features.length > 1
|
| 592 |
+
? feature.row / (state.features.length - 1) * 0.12
|
| 593 |
+
: 0;
|
| 594 |
+
const local = clamp((globalProgress - delay) / 0.88, 0, 1);
|
| 595 |
+
const eased = 1 - Math.pow(1 - local, 3);
|
| 596 |
+
const point = featurePosition(state, feature, eased);
|
| 597 |
+
state.positions.set(feature.feature_index, point);
|
| 598 |
+
const circle = state.circles.get(feature.feature_index);
|
| 599 |
+
if (circle) {
|
| 600 |
+
circle.setAttribute("cx", point.x);
|
| 601 |
+
circle.setAttribute("cy", point.y);
|
| 602 |
+
}
|
| 603 |
+
});
|
| 604 |
+
updateConnector(state);
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
function detailText(state, feature, cell) {
|
| 608 |
+
const noun = state.rasterData.dataset === "allen_neuropixels" ? "Unit" : "Feature";
|
| 609 |
+
const parts = [`${noun} ${feature.feature_index}`];
|
| 610 |
+
if (state.rasterData.dataset === "allen_neuropixels" && Number.isFinite(Number(feature.validation_value))) {
|
| 611 |
+
parts.push(`gOSI ${Number(feature.validation_value).toFixed(3)}`);
|
| 612 |
+
} else {
|
| 613 |
+
parts.push(feature.group);
|
| 614 |
+
}
|
| 615 |
+
if (!state.attributionData.tied) parts.push(`rank ${feature.rank} of ${state.features.length}`);
|
| 616 |
+
parts.push(`signed Kernel SHAP ${signedScore(Number(feature.signed_attribution))}`);
|
| 617 |
+
if (cell) parts.push(`${Math.round(cell.time)} ms`, `${cell.count} count${cell.count === 1 ? "" : "s"}`);
|
| 618 |
+
return parts.join(" · ");
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
function selectFeature(state, featureId, cell, lock) {
|
| 622 |
+
const feature = state.features.find((item) => item.feature_index === featureId);
|
| 623 |
+
if (!feature) return;
|
| 624 |
+
if (lock !== undefined) state.locked = lock ? featureId : null;
|
| 625 |
+
state.hovered = featureId;
|
| 626 |
+
state.keyboardRow = feature.row;
|
| 627 |
+
state.detailNode.textContent = detailText(state, feature, cell);
|
| 628 |
+
updateConnector(state);
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
function clearSelection(state, clearLock) {
|
| 632 |
+
if (clearLock) state.locked = null;
|
| 633 |
+
state.hovered = null;
|
| 634 |
+
if (state.locked === null) {
|
| 635 |
+
state.detailNode.textContent = "Hover or tap a raster row or attribution dot to trace the same feature.";
|
| 636 |
+
}
|
| 637 |
+
updateConnector(state);
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
function rasterCellAt(state, event) {
|
| 641 |
+
const bounds = state.visual.getBoundingClientRect();
|
| 642 |
+
const x = event.clientX - bounds.left;
|
| 643 |
+
const y = event.clientY - bounds.top;
|
| 644 |
+
const raster = state.geometry.raster;
|
| 645 |
+
if (x < raster.x || x > raster.x + raster.width || y < raster.y || y > raster.y + raster.height) {
|
| 646 |
+
return null;
|
| 647 |
+
}
|
| 648 |
+
const row = clamp(Math.floor((y - raster.y) / raster.height * state.features.length), 0, state.features.length - 1);
|
| 649 |
+
const column = clamp(Math.floor((x - raster.x) / raster.width * state.rasterData.time_ms.length), 0, state.rasterData.time_ms.length - 1);
|
| 650 |
+
return {
|
| 651 |
+
row,
|
| 652 |
+
column,
|
| 653 |
+
time: Number(state.rasterData.time_ms[column]),
|
| 654 |
+
count: Number(state.rasterData.counts[row][column]),
|
| 655 |
+
};
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
function replay(state) {
|
| 659 |
+
if (state.animationFrame !== null) cancelAnimationFrame(state.animationFrame);
|
| 660 |
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
| 661 |
+
updatePositions(state, 1);
|
| 662 |
+
return;
|
| 663 |
+
}
|
| 664 |
+
const pause = 130;
|
| 665 |
+
const duration = 980;
|
| 666 |
+
const startTime = performance.now();
|
| 667 |
+
updatePositions(state, 0);
|
| 668 |
+
const step = (now) => {
|
| 669 |
+
const elapsed = now - startTime;
|
| 670 |
+
if (elapsed <= pause) {
|
| 671 |
+
state.animationFrame = requestAnimationFrame(step);
|
| 672 |
+
return;
|
| 673 |
+
}
|
| 674 |
+
const raw = clamp((elapsed - pause) / duration, 0, 1);
|
| 675 |
+
updateReplayPositions(state, raw);
|
| 676 |
+
if (raw < 1) {
|
| 677 |
+
state.animationFrame = requestAnimationFrame(step);
|
| 678 |
+
} else {
|
| 679 |
+
state.animationFrame = null;
|
| 680 |
+
}
|
| 681 |
+
};
|
| 682 |
+
state.animationFrame = requestAnimationFrame(step);
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
function draw(state) {
|
| 686 |
+
const width = Math.floor(state.container.clientWidth);
|
| 687 |
+
if (width < 240) return;
|
| 688 |
+
state.geometry = computeGeometry(state, width);
|
| 689 |
+
state.visual.style.height = `${state.geometry.height}px`;
|
| 690 |
+
state.svg.setAttribute("viewBox", `0 0 ${state.geometry.width} ${state.geometry.height}`);
|
| 691 |
+
state.svg.setAttribute("width", String(state.geometry.width));
|
| 692 |
+
state.svg.setAttribute("height", String(state.geometry.height));
|
| 693 |
+
state.svg.replaceChildren(
|
| 694 |
+
svgElement("title", {}, "Feature activity linked to signed Kernel SHAP ranking"),
|
| 695 |
+
svgElement("desc", {}, "Hover or tap a feature to trace it from the example activity raster to its signed attribution rank.")
|
| 696 |
+
);
|
| 697 |
+
drawRaster(state);
|
| 698 |
+
drawAxes(state);
|
| 699 |
+
|
| 700 |
+
state.rowHighlight = svgElement("rect", {
|
| 701 |
+
fill: "rgba(255,255,255,0.05)",
|
| 702 |
+
stroke: "#6A51A3",
|
| 703 |
+
"stroke-width": 1.2,
|
| 704 |
+
visibility: "hidden",
|
| 705 |
+
"pointer-events": "none",
|
| 706 |
+
});
|
| 707 |
+
state.svg.appendChild(state.rowHighlight);
|
| 708 |
+
state.connector = svgElement("path", {
|
| 709 |
+
fill: "none",
|
| 710 |
+
stroke: "#6A51A3",
|
| 711 |
+
"stroke-width": 1.35,
|
| 712 |
+
opacity: 0.85,
|
| 713 |
+
visibility: "hidden",
|
| 714 |
+
"pointer-events": "none",
|
| 715 |
+
});
|
| 716 |
+
state.svg.appendChild(state.connector);
|
| 717 |
+
|
| 718 |
+
const dotGroup = svgElement("g", { class: "feature-story-dots" });
|
| 719 |
+
state.svg.appendChild(dotGroup);
|
| 720 |
+
state.circles = new Map();
|
| 721 |
+
const baseRadius = state.features.length > 300 ? 2.7 : state.features.length > 140 ? 3.2 : 4.1;
|
| 722 |
+
state.features.forEach((feature) => {
|
| 723 |
+
const circle = svgElement("circle", {
|
| 724 |
+
r: baseRadius,
|
| 725 |
+
fill: feature.color,
|
| 726 |
+
stroke: "#FFFFFF",
|
| 727 |
+
"stroke-width": 0.75,
|
| 728 |
+
opacity: 0.86,
|
| 729 |
+
"data-feature-id": feature.feature_index,
|
| 730 |
+
class: "feature-story-dot",
|
| 731 |
+
});
|
| 732 |
+
circle.dataset.baseRadius = String(baseRadius);
|
| 733 |
+
dotGroup.appendChild(circle);
|
| 734 |
+
state.circles.set(feature.feature_index, circle);
|
| 735 |
+
});
|
| 736 |
+
updatePositions(state, state.progress);
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
function bindInteractions(state) {
|
| 740 |
+
const signal = state.abortController.signal;
|
| 741 |
+
state.visual.addEventListener("pointermove", (event) => {
|
| 742 |
+
if (state.locked !== null) return;
|
| 743 |
+
const circle = event.target.closest && event.target.closest(".feature-story-dot");
|
| 744 |
+
if (circle) {
|
| 745 |
+
selectFeature(state, Number(circle.dataset.featureId), null);
|
| 746 |
+
return;
|
| 747 |
+
}
|
| 748 |
+
const cell = rasterCellAt(state, event);
|
| 749 |
+
if (cell) {
|
| 750 |
+
selectFeature(state, state.features[cell.row].feature_index, cell);
|
| 751 |
+
} else {
|
| 752 |
+
clearSelection(state, false);
|
| 753 |
+
}
|
| 754 |
+
}, { signal });
|
| 755 |
+
state.visual.addEventListener("pointerleave", () => {
|
| 756 |
+
if (state.locked === null) clearSelection(state, false);
|
| 757 |
+
}, { signal });
|
| 758 |
+
state.visual.addEventListener("click", (event) => {
|
| 759 |
+
const circle = event.target.closest && event.target.closest(".feature-story-dot");
|
| 760 |
+
const cell = circle ? null : rasterCellAt(state, event);
|
| 761 |
+
const featureId = circle
|
| 762 |
+
? Number(circle.dataset.featureId)
|
| 763 |
+
: cell
|
| 764 |
+
? state.features[cell.row].feature_index
|
| 765 |
+
: null;
|
| 766 |
+
if (featureId === null) {
|
| 767 |
+
clearSelection(state, true);
|
| 768 |
+
return;
|
| 769 |
+
}
|
| 770 |
+
const shouldLock = state.locked !== featureId;
|
| 771 |
+
selectFeature(state, featureId, cell, shouldLock);
|
| 772 |
+
if (!shouldLock) clearSelection(state, true);
|
| 773 |
+
}, { signal });
|
| 774 |
+
state.slider.addEventListener("input", () => {
|
| 775 |
+
if (state.animationFrame !== null) cancelAnimationFrame(state.animationFrame);
|
| 776 |
+
state.animationFrame = null;
|
| 777 |
+
updatePositions(state, Number(state.slider.value));
|
| 778 |
+
}, { signal });
|
| 779 |
+
state.replay.addEventListener("click", () => replay(state), { signal });
|
| 780 |
+
state.container.addEventListener("keydown", (event) => {
|
| 781 |
+
if (!["ArrowDown", "ArrowUp", "Home", "End", "Enter", "Escape"].includes(event.key)) return;
|
| 782 |
+
event.preventDefault();
|
| 783 |
+
if (event.key === "Escape") {
|
| 784 |
+
clearSelection(state, true);
|
| 785 |
+
return;
|
| 786 |
+
}
|
| 787 |
+
if (event.key === "Home") state.keyboardRow = 0;
|
| 788 |
+
if (event.key === "End") state.keyboardRow = state.features.length - 1;
|
| 789 |
+
if (event.key === "ArrowDown") state.keyboardRow = Math.min(state.features.length - 1, state.keyboardRow + 1);
|
| 790 |
+
if (event.key === "ArrowUp") state.keyboardRow = Math.max(0, state.keyboardRow - 1);
|
| 791 |
+
const feature = state.features[state.keyboardRow];
|
| 792 |
+
selectFeature(state, feature.feature_index, null, event.key === "Enter" ? true : undefined);
|
| 793 |
+
}, { signal });
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
function destroy(state) {
|
| 797 |
+
if (!state) return;
|
| 798 |
+
if (state.animationFrame !== null) cancelAnimationFrame(state.animationFrame);
|
| 799 |
+
if (state.resizeObserver) state.resizeObserver.disconnect();
|
| 800 |
+
if (state.abortController) state.abortController.abort();
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
function render(containerId, detailId, rasterData, attributionData) {
|
| 804 |
+
const container = document.getElementById(containerId);
|
| 805 |
+
const detailNode = document.getElementById(detailId);
|
| 806 |
+
if (!container || !detailNode || !rasterData.features || !attributionData.features) return "waiting";
|
| 807 |
+
const signature = `${rasterData.dataset}:${attributionData.model}`;
|
| 808 |
+
if (container.__featureStory && container.__featureStory.signature === signature) {
|
| 809 |
+
return signature;
|
| 810 |
+
}
|
| 811 |
+
destroy(container.__featureStory);
|
| 812 |
+
try {
|
| 813 |
+
const state = buildState(container, detailNode, rasterData, attributionData);
|
| 814 |
+
bindInteractions(state);
|
| 815 |
+
state.resizeObserver = new ResizeObserver(() => {
|
| 816 |
+
window.requestAnimationFrame(() => draw(state));
|
| 817 |
+
});
|
| 818 |
+
state.resizeObserver.observe(container);
|
| 819 |
+
window.requestAnimationFrame(() => draw(state));
|
| 820 |
+
detailNode.textContent = "Hover or tap a raster row or attribution dot to trace the same feature.";
|
| 821 |
+
return signature;
|
| 822 |
+
} catch (error) {
|
| 823 |
+
container.replaceChildren(htmlElement("div", "feature-story-error", "Feature visualization unavailable."));
|
| 824 |
+
detailNode.textContent = "Feature visualization unavailable.";
|
| 825 |
+
console.error(error);
|
| 826 |
+
return `error:${signature}`;
|
| 827 |
+
}
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
let scheduleGeneration = 0;
|
| 831 |
+
|
| 832 |
+
function schedule(containerId, detailId, rasterData, attributionData) {
|
| 833 |
+
scheduleGeneration += 1;
|
| 834 |
+
const generation = scheduleGeneration;
|
| 835 |
+
let attempts = 0;
|
| 836 |
+
const mount = () => {
|
| 837 |
+
if (generation !== scheduleGeneration) return;
|
| 838 |
+
if (document.getElementById(containerId) && document.getElementById(detailId)) {
|
| 839 |
+
render(containerId, detailId, rasterData, attributionData);
|
| 840 |
+
return;
|
| 841 |
+
}
|
| 842 |
+
attempts += 1;
|
| 843 |
+
if (attempts < 60) window.requestAnimationFrame(mount);
|
| 844 |
+
};
|
| 845 |
+
window.requestAnimationFrame(mount);
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
window.benchdashFeatureStory = { render, schedule };
|
| 849 |
+
})();
|
assets/styles.css
CHANGED
|
@@ -607,6 +607,76 @@ h2 {
|
|
| 607 |
margin-top: 14px !important;
|
| 608 |
}
|
| 609 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
.inline-controls {
|
| 611 |
display: grid;
|
| 612 |
grid-template-columns: minmax(240px, 420px) minmax(260px, 380px);
|
|
@@ -623,55 +693,122 @@ h2 {
|
|
| 623 |
grid-template-columns: minmax(240px, 420px);
|
| 624 |
}
|
| 625 |
|
| 626 |
-
.
|
| 627 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 628 |
width: 100%;
|
| 629 |
-
max-width: 380px;
|
| 630 |
overflow: hidden;
|
| 631 |
-
border: 1px solid #
|
| 632 |
-
border-radius:
|
| 633 |
background: #ffffff;
|
| 634 |
}
|
| 635 |
|
| 636 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
position: relative;
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
cursor: pointer;
|
| 645 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
font-weight: 700;
|
| 647 |
-
letter-spacing: 0;
|
| 648 |
-
|
| 649 |
-
text-transform: none;
|
| 650 |
-
text-align: center;
|
| 651 |
-
white-space: normal;
|
| 652 |
}
|
| 653 |
|
| 654 |
-
.
|
| 655 |
-
|
| 656 |
}
|
| 657 |
|
| 658 |
-
.
|
| 659 |
-
|
| 660 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
}
|
| 662 |
|
| 663 |
-
.
|
| 664 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
color: #4d3880;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
}
|
| 667 |
|
| 668 |
-
.feature-story-
|
| 669 |
-
|
|
|
|
|
|
|
| 670 |
}
|
| 671 |
|
| 672 |
.feature-selection-detail {
|
| 673 |
min-height: 36px;
|
| 674 |
-
margin:
|
| 675 |
padding: 9px 11px;
|
| 676 |
border-left: 3px solid var(--feature);
|
| 677 |
border-radius: 6px;
|
|
@@ -859,9 +996,6 @@ h2 {
|
|
| 859 |
grid-template-columns: 1fr;
|
| 860 |
}
|
| 861 |
|
| 862 |
-
.feature-story-grid {
|
| 863 |
-
grid-template-columns: 1fr;
|
| 864 |
-
}
|
| 865 |
}
|
| 866 |
|
| 867 |
@media (max-width: 820px) {
|
|
@@ -909,22 +1043,96 @@ h2 {
|
|
| 909 |
grid-template-columns: 1fr;
|
| 910 |
}
|
| 911 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 912 |
.modebar-container {
|
| 913 |
display: none !important;
|
| 914 |
}
|
| 915 |
|
| 916 |
-
.heatmap-graph
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
.latent-graph {
|
| 918 |
overflow-x: auto;
|
|
|
|
|
|
|
|
|
|
| 919 |
}
|
| 920 |
|
| 921 |
-
.
|
| 922 |
-
.latent-graph > div {
|
| 923 |
min-width: 660px;
|
| 924 |
}
|
| 925 |
}
|
| 926 |
|
| 927 |
@media (max-width: 560px) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 928 |
.site-nav {
|
| 929 |
align-items: flex-start;
|
| 930 |
padding: 15px 0;
|
|
|
|
| 607 |
margin-top: 14px !important;
|
| 608 |
}
|
| 609 |
|
| 610 |
+
.latent-space-mobile,
|
| 611 |
+
.latent-mobile-legend {
|
| 612 |
+
display: none;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.latent-mobile-legend {
|
| 616 |
+
gap: 7px 12px;
|
| 617 |
+
align-items: center;
|
| 618 |
+
margin: 8px 0 14px;
|
| 619 |
+
padding: 9px 12px;
|
| 620 |
+
border: 1px solid #e2e8ec;
|
| 621 |
+
border-radius: 7px;
|
| 622 |
+
background: #fafbfc;
|
| 623 |
+
color: #536672;
|
| 624 |
+
font-size: 10px;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
.latent-mobile-legend-title {
|
| 628 |
+
grid-column: 1 / -1;
|
| 629 |
+
font-weight: 750;
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
.latent-mobile-legend-item {
|
| 633 |
+
display: inline-flex;
|
| 634 |
+
gap: 5px;
|
| 635 |
+
align-items: center;
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
.latent-mobile-legend-dot {
|
| 639 |
+
display: inline-block;
|
| 640 |
+
width: 8px;
|
| 641 |
+
height: 8px;
|
| 642 |
+
border-radius: 50%;
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
.historical-trajectory-mobile {
|
| 646 |
+
display: none;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.trajectory-legend {
|
| 650 |
+
display: grid;
|
| 651 |
+
grid-template-columns: auto repeat(8, minmax(0, auto));
|
| 652 |
+
gap: 8px 13px;
|
| 653 |
+
align-items: center;
|
| 654 |
+
margin: 8px 0 14px;
|
| 655 |
+
padding: 9px 12px;
|
| 656 |
+
border: 1px solid #e2e8ec;
|
| 657 |
+
border-radius: 7px;
|
| 658 |
+
background: #fafbfc;
|
| 659 |
+
color: #536672;
|
| 660 |
+
font-size: 10px;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.trajectory-legend-title {
|
| 664 |
+
font-weight: 750;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.trajectory-legend-item {
|
| 668 |
+
display: inline-flex;
|
| 669 |
+
gap: 5px;
|
| 670 |
+
align-items: center;
|
| 671 |
+
white-space: nowrap;
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
.trajectory-legend-swatch {
|
| 675 |
+
width: 13px;
|
| 676 |
+
height: 3px;
|
| 677 |
+
border-radius: 3px;
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
.inline-controls {
|
| 681 |
display: grid;
|
| 682 |
grid-template-columns: minmax(240px, 420px) minmax(260px, 380px);
|
|
|
|
| 693 |
grid-template-columns: minmax(240px, 420px);
|
| 694 |
}
|
| 695 |
|
| 696 |
+
.feature-method-control {
|
| 697 |
+
max-width: 420px;
|
| 698 |
+
margin-bottom: 14px;
|
| 699 |
+
padding: 13px 14px;
|
| 700 |
+
border: 1px solid #e0e6ea;
|
| 701 |
+
border-radius: 9px;
|
| 702 |
+
background: #fafbfc;
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
.feature-story-viz {
|
| 706 |
width: 100%;
|
|
|
|
| 707 |
overflow: hidden;
|
| 708 |
+
border: 1px solid #dfe6ea;
|
| 709 |
+
border-radius: 9px;
|
| 710 |
background: #ffffff;
|
| 711 |
}
|
| 712 |
|
| 713 |
+
.feature-story-viz:focus-visible {
|
| 714 |
+
outline-offset: 3px !important;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.feature-story-visual {
|
| 718 |
position: relative;
|
| 719 |
+
width: 100%;
|
| 720 |
+
overflow: hidden;
|
| 721 |
+
background: #ffffff;
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
.feature-story-canvas,
|
| 725 |
+
.feature-story-overlay {
|
| 726 |
+
position: absolute;
|
| 727 |
+
inset: 0;
|
| 728 |
+
display: block;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.feature-story-overlay {
|
| 732 |
+
pointer-events: none;
|
| 733 |
+
font-family: Arial, Helvetica, sans-serif;
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
.feature-story-overlay text {
|
| 737 |
+
user-select: none;
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
.feature-story-dot {
|
| 741 |
cursor: pointer;
|
| 742 |
+
pointer-events: all;
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
.feature-story-playback {
|
| 746 |
+
display: grid;
|
| 747 |
+
grid-template-areas: "source slider target replay";
|
| 748 |
+
grid-template-columns: auto minmax(150px, 1fr) auto auto;
|
| 749 |
+
gap: 10px;
|
| 750 |
+
align-items: center;
|
| 751 |
+
min-height: 58px;
|
| 752 |
+
padding: 10px 14px;
|
| 753 |
+
border-top: 1px solid #edf1f3;
|
| 754 |
+
background: #fbfcfc;
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
.feature-story-playback-label {
|
| 758 |
+
color: #5b6d79;
|
| 759 |
+
font-size: 10px;
|
| 760 |
font-weight: 700;
|
| 761 |
+
letter-spacing: 0.02em;
|
| 762 |
+
white-space: nowrap;
|
|
|
|
|
|
|
|
|
|
| 763 |
}
|
| 764 |
|
| 765 |
+
.feature-story-playback-label.source {
|
| 766 |
+
grid-area: source;
|
| 767 |
}
|
| 768 |
|
| 769 |
+
.feature-story-playback-label.target {
|
| 770 |
+
grid-area: target;
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
.feature-story-slider {
|
| 774 |
+
grid-area: slider;
|
| 775 |
+
width: 100%;
|
| 776 |
+
accent-color: var(--feature);
|
| 777 |
+
cursor: ew-resize;
|
| 778 |
}
|
| 779 |
|
| 780 |
+
.feature-story-replay {
|
| 781 |
+
grid-area: replay;
|
| 782 |
+
min-height: 34px;
|
| 783 |
+
padding: 7px 12px;
|
| 784 |
+
border: 1px solid #b9accf;
|
| 785 |
+
border-radius: 6px;
|
| 786 |
+
background: #f4f1f8;
|
| 787 |
color: #4d3880;
|
| 788 |
+
cursor: pointer;
|
| 789 |
+
font: inherit;
|
| 790 |
+
font-size: 11px;
|
| 791 |
+
font-weight: 750;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
.feature-story-replay:hover {
|
| 795 |
+
border-color: #846fa6;
|
| 796 |
+
background: #eee9f5;
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
.feature-story-render-token {
|
| 800 |
+
display: none;
|
| 801 |
}
|
| 802 |
|
| 803 |
+
.feature-story-error {
|
| 804 |
+
padding: 40px 20px;
|
| 805 |
+
color: #607080;
|
| 806 |
+
text-align: center;
|
| 807 |
}
|
| 808 |
|
| 809 |
.feature-selection-detail {
|
| 810 |
min-height: 36px;
|
| 811 |
+
margin: 12px 0;
|
| 812 |
padding: 9px 11px;
|
| 813 |
border-left: 3px solid var(--feature);
|
| 814 |
border-radius: 6px;
|
|
|
|
| 996 |
grid-template-columns: 1fr;
|
| 997 |
}
|
| 998 |
|
|
|
|
|
|
|
|
|
|
| 999 |
}
|
| 1000 |
|
| 1001 |
@media (max-width: 820px) {
|
|
|
|
| 1043 |
grid-template-columns: 1fr;
|
| 1044 |
}
|
| 1045 |
|
| 1046 |
+
.feature-method-control {
|
| 1047 |
+
max-width: none;
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
.modebar-container {
|
| 1051 |
display: none !important;
|
| 1052 |
}
|
| 1053 |
|
| 1054 |
+
.heatmap-graph {
|
| 1055 |
+
overflow: visible;
|
| 1056 |
+
}
|
| 1057 |
+
|
| 1058 |
+
.heatmap-graph > div {
|
| 1059 |
+
width: 100% !important;
|
| 1060 |
+
min-width: 0;
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
.latent-graph {
|
| 1064 |
overflow-x: auto;
|
| 1065 |
+
padding-bottom: 7px;
|
| 1066 |
+
scrollbar-color: #a9b5bd #eef2f4;
|
| 1067 |
+
scrollbar-width: thin;
|
| 1068 |
}
|
| 1069 |
|
| 1070 |
+
.latent-space-desktop > div {
|
|
|
|
| 1071 |
min-width: 660px;
|
| 1072 |
}
|
| 1073 |
}
|
| 1074 |
|
| 1075 |
@media (max-width: 560px) {
|
| 1076 |
+
.heatmap-graph {
|
| 1077 |
+
display: none;
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
.latent-space-desktop {
|
| 1081 |
+
display: none;
|
| 1082 |
+
}
|
| 1083 |
+
|
| 1084 |
+
.latent-space-mobile {
|
| 1085 |
+
display: block;
|
| 1086 |
+
overflow: visible;
|
| 1087 |
+
padding-bottom: 0;
|
| 1088 |
+
}
|
| 1089 |
+
|
| 1090 |
+
.latent-space-mobile > div {
|
| 1091 |
+
width: 100% !important;
|
| 1092 |
+
min-width: 0;
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
.latent-mobile-legend {
|
| 1096 |
+
display: grid;
|
| 1097 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 1098 |
+
}
|
| 1099 |
+
|
| 1100 |
+
.historical-trajectory-desktop {
|
| 1101 |
+
display: none;
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
.historical-trajectory-mobile {
|
| 1105 |
+
display: block;
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
.trajectory-legend {
|
| 1109 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 1110 |
+
gap: 7px 12px;
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
.trajectory-legend-title {
|
| 1114 |
+
grid-column: 1 / -1;
|
| 1115 |
+
}
|
| 1116 |
+
|
| 1117 |
+
.feature-story-playback {
|
| 1118 |
+
grid-template-areas:
|
| 1119 |
+
"source target"
|
| 1120 |
+
"slider slider"
|
| 1121 |
+
"replay replay";
|
| 1122 |
+
grid-template-columns: 1fr 1fr;
|
| 1123 |
+
gap: 8px 12px;
|
| 1124 |
+
padding: 11px 12px 12px;
|
| 1125 |
+
}
|
| 1126 |
+
|
| 1127 |
+
.feature-story-playback-label.target {
|
| 1128 |
+
text-align: right;
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
.feature-story-replay {
|
| 1132 |
+
width: 100%;
|
| 1133 |
+
min-height: 40px;
|
| 1134 |
+
}
|
| 1135 |
+
|
| 1136 |
.site-nav {
|
| 1137 |
align-items: flex-start;
|
| 1138 |
padding: 15px 0;
|