msq-plurality-gap / code /figures.py
adeerkhan's picture
MSQ: the unstated premise
78c5de6 verified
Raw History Blame Contribute Delete
13.2 kB
#!/usr/bin/env python3
"""Figures for the MSQ study. Reads results/metrics.json and results/raw/*.jsonl."""
from __future__ import annotations
import json
import pathlib
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import msq # noqa: E402
ROOT = pathlib.Path(__file__).resolve().parent.parent
RES = ROOT / "results"
FIG = ROOT / "figures"
INK = "#1f2933"
ACCENT = "#1d6fd0"
WARM = "#e85d4c"
TEAL = "#2a9d8f"
AMBER = "#e09f3e"
GREY = "#9aa5b1"
def style(ax):
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color(GREY)
ax.spines["bottom"].set_color(GREY)
ax.tick_params(colors=INK, labelsize=9)
ax.grid(axis="y", color="#e4e7eb", lw=0.8)
ax.set_axisbelow(True)
def load_metrics():
p = RES / "metrics.json"
return json.loads(p.read_text()) if p.exists() else {}
def load_raw(model):
p = RES / "raw" / f"{model}.jsonl"
if not p.exists():
return []
return [json.loads(l) for l in p.open(encoding="utf-8") if l.strip()]
def fig_prr_msq(metrics, primary):
"""PRR and MSQ by class, for every model: the three-symptom structure."""
models = [m for m in metrics if metrics[m].get("mcq")]
if not models:
return
classes = ["A", "B", "C", "E"]
labels = ["A\nfiqh\n(madhhab)", "B\nlive\ncontroversy", "C\njurisdiction",
"E\nPython\nversion"]
fig, axes = plt.subplots(1, 3, figsize=(13.0, 4.3))
width = 0.8 / max(len(models), 1)
def draw(ax, section, key, title, ylabel):
for k, m in enumerate(models):
vals = []
for c in classes:
v = metrics[m]["mcq"].get(section, {}).get(c, {}).get(key)
vals.append((v or 0) * 100)
xs = [i + (k - (len(models) - 1) / 2) * width for i in range(len(classes))]
ax.bar(xs, vals, width=width * 0.9,
color=ACCENT if m == primary else TEAL,
label=m if k == 0 else None)
ax.set_xticks(range(len(classes)))
ax.set_xticklabels(labels, fontsize=8)
ax.set_title(title, fontsize=10, color=INK, pad=10, loc="left")
ax.set_ylabel(ylabel)
ax.set_ylim(0, 108)
style(ax)
draw(axes[0], "mcq_neutral", "plurality_recognition_rate",
"Does it NOTICE the question is conditional?", "% choosing 'it depends'")
draw(axes[1], "mcq_specified", "school_adherence_rate",
"Does it USE the context it was given?", "% following the named context")
ax = axes[2]
for k, m in enumerate(models):
cr = metrics[m].get("context_response", {}).get("ALL", {})
v = (cr.get("mean_context_response_tv") or 0)
ax.bar([k], [v], width=0.5, color=ACCENT if m == primary else TEAL)
ax.text(k, v + 0.012, f"{v:.3f}", ha="center", fontsize=9, color=INK)
u = cr.get("argmax_unchanged_rate")
if u is not None:
ax.text(k, 0.012, f"{u*100:.0f}% unchanged", ha="center", fontsize=8,
color="white", rotation=90, va="bottom")
ax.set_xticks(range(len(models)))
ax.set_xticklabels([m.replace("-", "\n") for m in models], fontsize=8)
ax.set_title("How far does naming the context move the answer?",
fontsize=10, color=INK, pad=10, loc="left")
ax.set_ylabel("total-variation distance between the two conditions")
ax.set_ylim(0, 0.55)
style(ax)
handles = [plt.Rectangle((0, 0), 1, 1, color=ACCENT),
plt.Rectangle((0, 0), 1, 1, color=TEAL)]
fig.legend(handles, ["primary", "comparison models"], loc="upper right",
frameon=False, fontsize=9, ncol=2)
fig.tight_layout()
fig.savefig(FIG / "fig1_prr_msq.png", dpi=170, bbox_inches="tight")
plt.close(fig)
def fig_disclosure(metrics, primary):
if primary not in metrics:
return
ft = metrics[primary]["free_text"]
pc = metrics[primary].get("positions_contained", {})
probes = ["ft_default", "ft_repair", "ft_technical", "ft_two_schools", "ft_pair"]
labels = ["plain\nquestion", "one-line\nrepair", "technical\nregister",
"asked to\nenumerate", "asked for two\nnamed schools"]
series = [
("disclosure_rate", "signals that the answer is context-dependent", ACCENT, None),
("plural_attribution_rate", "names two or more competing schools", WARM, None),
("mean_positions_contained", "positions actually carried", TEAL, "pc"),
]
fig, axes = plt.subplots(1, 3, figsize=(12.4, 4.0), sharey=False)
for ax, (key, title, col, kind) in zip(axes, series):
vals = []
for p in probes:
if kind == "pc":
v = pc.get(p, {}).get("ALL", {}).get("mean_positions_contained")
else:
v = ft.get(p, {}).get("ALL", {}).get(key)
vals.append(v or 0)
if kind == "pc":
vals = [v * 2 for v in vals] # scale to a 0-2 "positions" axis
top = 2.1
else:
vals = [v * 100 for v in vals]
top = 105
bars = ax.bar(range(len(probes)), vals, color=col, width=0.62)
for b, v in zip(bars, vals):
ax.text(b.get_x() + b.get_width() / 2, v + top * 0.02,
f"{v:.2f}" if kind == "pc" else f"{v:.0f}%",
ha="center", fontsize=8.5, color=INK)
ax.set_xticks(range(len(probes)))
ax.set_xticklabels(labels, fontsize=7.6)
ax.set_title(title, fontsize=9.5, color=INK, pad=10, loc="left")
ax.set_ylim(0, top)
if kind == "pc":
ax.set_yticks([0, 1, 2])
ax.set_yticklabels(["0", "1", "2"])
style(ax)
axes[0].set_ylabel("% of answers")
fig.suptitle(f"Disclosure behaviour on contested questions · {primary}",
fontsize=11.5, color=INK, x=0.005, ha="left", y=1.02)
fig.tight_layout()
fig.savefig(FIG / "fig2_disclosure.png", dpi=170, bbox_inches="tight")
plt.close(fig)
def fig_confidence(metrics, primary):
if primary not in metrics:
return
mn = metrics[primary]["mcq"]["mcq_neutral"]
so = metrics[primary].get("specified_outcome", {}).get("mcq_specified", {})
cr = metrics[primary].get("context_response", {})
classes = ["A", "B", "C", "E"]
labels = ["A\nfiqh", "B\ncontroversy", "C\njurisdiction", "E\nPython\nversion"]
fig, axes = plt.subplots(1, 3, figsize=(13.4, 4.2))
# panel 1 -- what it picks once the context is named
ax = axes[0]
keys = [("named_school", "the context it was given", ACCENT),
("divided_anyway", "'it depends' anyway", AMBER),
("other_position", "some other position", GREY)]
bottoms = [0.0] * len(classes)
for key, name, col in keys:
vals = []
for c in classes:
v = so.get(c, {}).get(key)
vals.append((v or 0) * 100)
ax.bar(range(len(classes)), vals, bottom=bottoms, color=col, width=0.6,
label=name)
bottoms = [b + v for b, v in zip(bottoms, vals)]
ax.set_xticks(range(len(classes)))
ax.set_xticklabels(labels, fontsize=8.5)
ax.set_ylabel("% of items")
ax.set_title("Once the context IS named, what does it pick?", fontsize=10,
color=INK, pad=10, loc="left")
ax.set_ylim(0, 100)
ax.legend(fontsize=8, frameon=False, loc="upper center",
bbox_to_anchor=(0.5, -0.18), ncol=1)
style(ax)
# panel 2 -- how far the instruction moved the answer
ax = axes[1]
for c, col in zip(classes, [WARM, AMBER, TEAL, "#8e7dbe"]):
d = cr.get(c, {}).get("median_delta_on_named_position")
if d is None:
continue
ax.bar([classes.index(c)], [d], width=0.55, color=col)
ax.text(classes.index(c), d + 0.004, f"{d:.3f}", ha="center", fontsize=9,
color=INK)
ax.set_xticks(range(len(classes)))
ax.set_xticklabels(labels, fontsize=8.5)
ax.set_ylabel("median change in probability on the correct answer")
ax.set_title("Naming the context barely moves the answer", fontsize=10,
color=INK, pad=10, loc="left")
ax.set_ylim(0, 0.42)
style(ax)
# panel 3 -- confidence
ax = axes[2]
allc = classes + ["D"]
vals = [(mn.get(c, {}).get("mean_max_prob") or 0) * 100 for c in allc]
cols = [WARM, AMBER, TEAL, "#8e7dbe", GREY]
bars = ax.bar(range(len(allc)), vals, color=cols, width=0.6)
for b, v in zip(bars, vals):
ax.text(b.get_x() + b.get_width() / 2, v + 1.5, f"{v:.0f}%", ha="center",
fontsize=9.5, color=INK)
ax.set_xticks(range(len(allc)))
ax.set_xticklabels(["A\nfiqh", "B\ncontroversy", "C\njurisdiction",
"E\nPython", "D\ncontrol"], fontsize=8.5)
ax.set_ylabel("mean confidence in its own chosen option")
ax.set_title("Confidence never drops", fontsize=10, color=INK, pad=10, loc="left")
ax.set_ylim(0, 112)
style(ax)
fig.tight_layout()
fig.savefig(FIG / "fig3_confidence_prr.png", dpi=170, bbox_inches="tight")
plt.close(fig)
def fig_per_item(metrics, primary):
recs = load_raw(primary)
if not recs:
return
items = {i["id"]: i for i in msq.load_items()}
rows = [r for r in recs if r["probe"] == "mcq_neutral" and r["cls"] in ("A", "B")]
if not rows:
return
rows.sort(key=lambda r: r["item_id"])
fig, ax = plt.subplots(figsize=(9.6, 5.2))
xs, ys, cs = [], [], []
for r in rows:
it = items[r["item_id"]]
divided = max((i for i, o in enumerate(r["options"])
if o.lower().startswith(("the scholars are divided",
"this is a contested matter",
"it depends on the jurisdiction"))),
default=None)
if divided is None:
continue
xs.append(r["item_id"])
ys.append(r["probs"][divided] * 100)
arg = r["argmax_index"]
cs.append("#2a9d8f" if arg == divided else "#e85d4c")
ax.bar(range(len(xs)), ys, color=cs, width=0.7)
ax.set_xticks(range(len(xs)))
ax.set_xticklabels(xs, rotation=90, fontsize=7)
ax.set_ylabel("probability assigned to the 'divided' option")
ax.set_title("Per-item probability of the plurality answer (teal = the model "
"chose it; red = it chose one school)",
fontsize=10.5, color=INK, pad=10, loc="left")
style(ax)
fig.tight_layout()
fig.savefig(FIG / "fig4_per_item.png", dpi=170)
plt.close(fig)
def fig_tokenizer():
p = RES / "repr_probe.json"
if not p.exists():
return
rep = json.loads(p.read_text())
fert = rep.get("fertility_by_script", {})
names = {"en": "English", "zh": "Chinese", "ar_vocalized": "Arabic\n(vocalised)",
"ar_plain": "Arabic\n(plain)", "ur": "Urdu", "fa": "Persian",
"hi": "Hindi", "arabizi": "Romanised\nArabic"}
fig, axes = plt.subplots(1, 2, figsize=(10.8, 4.2))
keys = [k for k in names if k in fert]
ax = axes[0]
vals = [fert[k]["tokens_per_char"] * 100 for k in keys]
colors = []
for k in keys:
if k.startswith("ar"):
colors.append(WARM)
elif k in ("ur", "fa", "hi"):
colors.append(TEAL)
else:
colors.append(GREY)
bars = ax.bar(range(len(keys)), vals, color=colors, width=0.62)
for b, v in zip(bars, vals):
ax.text(b.get_x() + b.get_width() / 2, v + 0.6, f"{v:.1f}", ha="center",
fontsize=8.5, color=INK)
ax.set_xticks(range(len(keys)))
ax.set_xticklabels([names[k] for k in keys], fontsize=8)
ax.set_ylabel("tokens per 100 characters")
ax.set_title("Token cost by script", fontsize=10.5, color=INK, pad=10, loc="left")
style(ax)
ax = axes[1]
keys2 = [k for k in keys]
vals2 = [fert[k]["byte_level_rate"] * 100 for k in keys2]
bars = ax.bar(range(len(keys2)), vals2, color=colors, width=0.62)
for b, v in zip(bars, vals2):
ax.text(b.get_x() + b.get_width() / 2, v + 1.2, f"{v:.0f}%", ha="center",
fontsize=8.5, color=INK)
ax.set_xticks(range(len(keys2)))
ax.set_xticklabels([names[k] for k in keys2], fontsize=8)
ax.set_ylabel("% of tokens that are raw byte fragments")
ax.set_title("Where the vocabulary gives up", fontsize=10.5, color=INK, pad=10,
loc="left")
ax.set_ylim(0, 68)
style(ax)
fig.tight_layout()
fig.savefig(FIG / "fig5_tokenizer.png", dpi=170)
plt.close(fig)
def main():
FIG.mkdir(parents=True, exist_ok=True)
metrics = load_metrics()
primary = "minicpm5-2b" if "minicpm5-2b" in metrics else next(iter(metrics), None)
if metrics:
fig_prr_msq(metrics, primary)
fig_disclosure(metrics, primary)
fig_confidence(metrics, primary)
fig_per_item(metrics, primary)
fig_tokenizer()
print("figures:", sorted(p.name for p in FIG.glob("*.png")))
if __name__ == "__main__":
main()