AttrLLM / visualization /plotting /interaction_focus.py
Qingpeng Kong
clean initial state
3e72399
"""
Pure-HTML interaction explorer that works without custom JavaScript.
We rely on hidden radio inputs + CSS to simulate focus mode:
• Default radio (id=...-none) keeps the full chip/sentence list visible.
• Clicking any token is just selecting its radio (via <label for="...">).
• CSS rules tied to :checked radios fade the base list and reveal the
matching overlay panel (center node + neighbors + radial SVG).
• Clicking the translucent backdrop or Reset button toggles the “none”
radio, restoring the original layout.
"""
from __future__ import annotations
import math
import uuid
from collections import defaultdict
from html import escape
from typing import Dict, List, Tuple
_FOCUS_VIEW_STYLE = """
<style>
.interaction-focus-root {
--border-color: #d8d1f0;
--bg-panel: #fbf8ff;
--text-color: #241d35;
border: 1px solid var(--border-color);
border-radius: 18px;
padding: 18px;
background: var(--bg-panel);
font-family: "Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
position: relative;
overflow: hidden;
}
.interaction-focus-root .focus-radio {
display: none;
}
.interaction-header {
font-size: 14px;
font-weight: 600;
color: #4a3972;
margin-bottom: 12px;
}
.interaction-hint {
font-size: 12px;
color: #736892;
margin-bottom: 18px;
}
.token-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.token-chip {
border-radius: 14px;
border: 1px solid currentColor;
padding: 10px 14px;
background: white;
min-width: 120px;
display: inline-flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--text-color);
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.token-chip:hover {
transform: translateY(-2px);
box-shadow: 0 14px 32px rgba(73, 39, 112, 0.15);
}
.token-chip__value {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
}
.token-chip__label {
font-weight: 600;
white-space: nowrap;
}
.sentence-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.sentence-item {
border-radius: 16px;
border: 1px solid currentColor;
background: white;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
transition: box-shadow 0.15s ease, border 0.15s ease;
}
.sentence-item:hover {
box-shadow: 0 18px 36px rgba(51, 26, 92, 0.12);
}
.sentence-item__header {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
font-weight: 600;
color: #2a1f44;
}
.sentence-item__text {
font-size: 13px;
color: #2d2441;
line-height: 1.4;
white-space: normal;
}
.sentence-item__links {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 11px;
}
.sentence-item__badge {
background: rgba(112, 72, 232, 0.1);
border-radius: 999px;
padding: 2px 10px;
color: #5c3fd9;
font-weight: 600;
}
.interaction-focus-overlay {
position: absolute;
inset: 0;
background: rgba(249, 247, 255, 0.96);
display: flex;
flex-direction: column;
gap: 12px;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
padding: 20px;
}
.focus-overlay-bg {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.0001);
}
.focus-panels {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.focus-panel {
display: none;
flex-direction: column;
gap: 12px;
border-radius: 20px;
background: white;
border: 1px solid rgba(112, 72, 232, 0.18);
padding: 18px;
box-shadow: 0 18px 38px rgba(66, 40, 122, 0.12);
min-height: 340px;
}
.focus-panel__title {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.focus-panel__value {
font-size: 18px;
font-weight: 800;
color: #472f88;
}
.focus-panel__label {
font-size: 14px;
font-weight: 600;
color: #2d1f44;
}
.focus-svg {
width: 100%;
height: 280px;
}
.focus-links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 12px;
}
.focus-links li {
background: rgba(112, 72, 232, 0.08);
border-radius: 999px;
padding: 4px 10px;
font-weight: 600;
color: #5c3fd9;
}
.focus-empty-msg {
font-size: 12px;
color: #7a6d94;
}
.focus-reset {
align-self: flex-end;
width: fit-content;
border-radius: 999px;
padding: 6px 14px;
font-weight: 600;
background: #f0eaff;
color: #5a3dc0;
cursor: pointer;
}
.focus-reset:hover {
background: #e0d4ff;
}
</style>
"""
def _token_colors(value: float, max_abs: float) -> Tuple[str, str]:
if max_abs <= 0:
return "#f0eef7", "#d5cdea"
norm = max(-1.0, min(1.0, value / max_abs))
if norm >= 0:
base = (231, 94, 71)
else:
base = (71, 123, 231)
norm = -norm
neutral = (245, 242, 252)
r = int(round(neutral[0] + (base[0] - neutral[0]) * norm))
g = int(round(neutral[1] + (base[1] - neutral[1]) * norm))
b = int(round(neutral[2] + (base[2] - neutral[2]) * norm))
return f"rgba({r}, {g}, {b}, 0.35)", f"rgb({r}, {g}, {b})"
def create_interaction_token_view(
features: List[str],
feature_values: List[float],
pairwise: List[Tuple[Tuple[str, ...], float]],
*,
method: str = "shapley",
layout: str = "token",
max_links: int = 6,
) -> str:
if not features:
return "<div class='interaction-hint'>No tokens available.</div>"
values = list(feature_values) if feature_values else []
if len(values) < len(features):
values.extend([0.0] * (len(features) - len(values)))
layout = (layout or "token").lower()
if layout not in {"token", "sentence"}:
layout = "token"
adjacency: Dict[str, List[Tuple[str, float]]] = defaultdict(list)
for feats, val in pairwise:
if len(feats) != 2:
continue
a, b = feats
adjacency[a].append((b, float(val)))
adjacency[b].append((a, float(val)))
if not adjacency and len(features) > 1:
for idx in range(len(features) - 1):
score = 0.5 * (values[idx] + values[idx + 1])
adjacency[features[idx]].append((features[idx + 1], float(score)))
adjacency[features[idx + 1]].append((features[idx], float(score)))
for key in adjacency:
adjacency[key].sort(key=lambda item: abs(item[1]), reverse=True)
view_id = f"interaction-focus-{uuid.uuid4().hex[:10]}"
none_radio = f"{view_id}-focus-none"
radios = [f'<input type="radio" name="{view_id}-focus" id="{none_radio}" class="focus-radio" checked>']
radio_ids: List[str] = []
for idx in range(len(features)):
rid = f"{view_id}-focus-{idx}"
radio_ids.append(rid)
radios.append(f'<input type="radio" name="{view_id}-focus" id="{rid}" class="focus-radio">')
base_html = _render_base_listing(features, values, adjacency, layout, radio_ids)
panels = _build_focus_panels(features, values, adjacency, radio_ids, max_links)
css_rules = [
f"#{none_radio}:checked ~ .interaction-base {{opacity:1; filter:none; pointer-events:auto;}}",
f"#{none_radio}:checked ~ .interaction-focus-overlay {{opacity:0; pointer-events:none;}}",
]
for idx, rid in enumerate(radio_ids):
css_rules.append(
f"#{rid}:checked ~ .interaction-base {{opacity:0.08; filter:blur(1px); pointer-events:none;}}"
)
css_rules.append(
f"#{rid}:checked ~ .interaction-focus-overlay {{opacity:1; pointer-events:auto;}}"
)
css_rules.append(
f"#{rid}:checked ~ .interaction-focus-overlay .focus-panel[data-node='{idx}'] {{display:flex;}}"
)
return "".join(
[
_FOCUS_VIEW_STYLE,
"<style>",
"\n".join(css_rules),
"</style>",
f'<div class="interaction-focus-root" id="{view_id}">',
"".join(radios),
f'<div class="interaction-header">{escape(method.title())} pairwise interactions</div>',
f'<div class="interaction-hint">点击任意{"句子" if layout == "sentence" else "单词"}即可聚焦并查看其与其它片段的互动。</div>',
'<div class="interaction-base">',
base_html,
"</div>",
'<div class="interaction-focus-overlay" aria-hidden="true">',
f'<label class="focus-overlay-bg" for="{none_radio}" title="点击恢复"></label>',
'<div class="focus-panels">',
"".join(panels),
f'<label class="focus-reset" for="{none_radio}">重置 / 返回</label>',
"</div>",
"</div>",
"</div>",
]
)
def _render_base_listing(
features: List[str],
values: List[float],
adjacency: Dict[str, List[Tuple[str, float]]],
layout: str,
radio_ids: List[str],
) -> str:
max_abs = max((abs(v) for v in values), default=0.0) or 1.0
chunks: List[str] = []
if layout == "sentence":
for idx, token in enumerate(features):
bg, border = _token_colors(values[idx], max_abs)
badges = []
for partner, score in adjacency.get(token, [])[:2]:
sign = "+" if score >= 0 else ""
badges.append(
f"<span class='sentence-item__badge'>{escape(partner)} {sign}{score:.2f}</span>"
)
badge_html = ""
if badges:
badge_html = (
"<div class='sentence-item__links'>"
+ "".join(badges)
+ "</div>"
)
chunks.append(
f'<label for="{radio_ids[idx]}" class="sentence-item" '
f'style="color:{border}; border-color:{border}; background:{bg};">'
f'<div class="sentence-item__header"><span>{values[idx]:+0.2f}</span>'
f"<span>#{idx + 1}</span></div>"
f'<div class="sentence-item__text">{escape(token)}</div>'
f"{badge_html}"
"</label>"
)
return '<div class="sentence-list">' + "".join(chunks) + "</div>"
for idx, token in enumerate(features):
bg, border = _token_colors(values[idx], max_abs)
chunks.append(
f'<label for="{radio_ids[idx]}" class="token-chip" '
f'style="background:{bg}; color:{border}; border-color:{border};">'
f'<span class="token-chip__value">{values[idx]:+0.2f}</span>'
f'<span class="token-chip__label">{escape(token)}</span>'
"</label>"
)
return '<div class="token-grid">' + "".join(chunks) + "</div>"
def _build_focus_panels(
features: List[str],
values: List[float],
adjacency: Dict[str, List[Tuple[str, float]]],
radio_ids: List[str],
max_links: int,
) -> List[str]:
panels: List[str] = []
value_max = max((abs(v) for v in values), default=0.0) or 1.0
for idx, token in enumerate(features):
neighbors = adjacency.get(token, [])[:max_links]
svg = _render_focus_svg(token, values[idx], neighbors, value_max)
if neighbors:
links_html = "<ul class='focus-links'>" + "".join(
f"<li>{escape(partner)} {score:+.2f}</li>" for partner, score in neighbors
) + "</ul>"
else:
links_html = "<div class='focus-empty-msg'>暂无相关连接</div>"
panels.append(
f'<div class="focus-panel" data-node="{idx}">'
f'<div class="focus-panel__title">'
f'<span class="focus-panel__value">{values[idx]:+0.3f}</span>'
f'<span class="focus-panel__label">{escape(token)}</span>'
"</div>"
f"{svg}"
f"{links_html}"
"</div>"
)
return panels
def _render_focus_svg(
label: str,
value: float,
neighbors: List[Tuple[str, float]],
max_abs_value: float,
) -> str:
width = 420.0
height = 300.0
cx, cy = width / 2.0, height / 2.0
radius = min(width, height) * 0.35
center_fill, center_border = _token_colors(value, max_abs_value)
svg_parts = [
f'<svg viewBox="0 0 {width:.0f} {height:.0f}" class="focus-svg">'
f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="34" fill="{center_fill}" stroke="{center_border}" stroke-width="2.5" />'
f'<text x="{cx:.1f}" y="{cy - 4:.1f}" text-anchor="middle" font-size="13" font-weight="700" fill="#2a1f44">{value:+.2f}</text>'
f'<text x="{cx:.1f}" y="{cy + 16:.1f}" text-anchor="middle" font-size="12" fill="#675f82">{escape(label[:42])}</text>'
]
if not neighbors:
svg_parts.append("</svg>")
return "".join(svg_parts)
max_edge = max((abs(val) for _, val in neighbors), default=0.0) or 1.0
step = (2 * math.pi) / max(len(neighbors), 1)
for i, (partner, score) in enumerate(neighbors):
angle = -math.pi / 2 + i * step
x = cx + radius * math.cos(angle)
y = cy + radius * math.sin(angle)
color = "#d35400" if score >= 0 else "#3867d6"
stroke_width = 1.4 + 4.0 * (abs(score) / max_edge)
svg_parts.append(
f'<line x1="{cx:.1f}" y1="{cy:.1f}" x2="{x:.1f}" y2="{y:.1f}" '
f'stroke="{color}" stroke-width="{stroke_width:.2f}" stroke-linecap="round" opacity="0.9" />'
)
svg_parts.append(
f'<text x="{(cx + x) / 2:.1f}" y="{(cy + y) / 2 - 6:.1f}" text-anchor="middle" '
f'font-size="11" font-weight="600" fill="#4a3b80">{score:+.2f}</text>'
)
node_fill, node_border = _token_colors(score, max_edge)
svg_parts.append(
f'<circle cx="{x:.1f}" cy="{y:.1f}" r="18" fill="{node_fill}" stroke="{node_border}" stroke-width="2" />'
)
svg_parts.append(
f'<text x="{x:.1f}" y="{y + 28:.1f}" text-anchor="middle" font-size="11" fill="#2a1f44">'
f"{escape(partner[:32])}</text>"
)
svg_parts.append("</svg>")
return "".join(svg_parts)