kinchat / notebooks /_build_notebook.py
vex-0's picture
Task 13: GRPO training notebook (Unsloth + TRL, 17 cells, base-vs-trained eval, plot cells, push-to-Hub)
8e36acf
Raw
History Blame Contribute Delete
27.6 kB
"""One-shot builder for ``notebooks/train_kinchat.ipynb``.
Run once::
python notebooks/_build_notebook.py
Deletes itself after writing the notebook is *not* implemented β€” the file is
harmless and lets us regenerate the notebook if we tweak cells.
"""
from __future__ import annotations
from pathlib import Path
import nbformat as nbf
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "notebooks" / "train_kinchat.ipynb"
# --------------------------------------------------------------------------- #
# Cell bodies #
# --------------------------------------------------------------------------- #
CELL_01_MD = r"""# KinChat β€” GRPO Training Notebook
**Abstract.** This notebook trains a `Qwen2.5-3B-Instruct` LoRA against the
deployed **KinChat** OpenEnv environment (a multi-agent family group-chat
simulator) using **HF TRL GRPO**. The environment scores every turn against
four composable rubrics: `leak`, `audience_fit`, `restraint`, and
`trust_delta`. We roll out full episodes, optimise the policy with
group-relative advantage, and then evaluate the trained adapter against the
base model on a held-out 10-scenario split.
Outputs:
1. A LoRA adapter pushed to the HuggingFace Hub.
2. Three plots (`reward_curve.png`, `per_rubric_curves.png`,
`session_trust.png`) saved under `docs/plots/`.
3. A 4-metric base-vs-GRPO eval table.
**Target hardware.** Designed to run on a free-tier **Colab T4** with the
Unsloth 4-bit quantised base model and a LoRA rank of 16. Total training
wall-clock at the default settings is ~45 minutes.
"""
CELL_02_INSTALL = r"""%pip -q install --upgrade unsloth trl transformers accelerate peft datasets bitsandbytes wandb requests pydantic
%pip -q install --upgrade "openenv-core[client]"
"""
CELL_03_MD = r"""## Configuration
This notebook expects **either** a deployed KinChat env URL (default:
`https://vex-0-kinchat.hf.space`) **or** a freshly-spawned local one.
During training-time rollouts the `audience_fit` rubric calls a judge LLM via
the env's API key; the other three rubrics are deterministic and run locally
on the env. If you want a non-default judge, set `OPENAI_API_KEY` (or
`HF_TOKEN`) in the Colab secrets manager. The env ships with a sensible
default so you can also just run this notebook as-is.
The env is assumed to be reachable. If you see 502s from the HF Space, the
Space has scaled-to-zero β€” hit `/health` once in a browser and wait ~30 s for
warmup before retrying.
"""
CELL_04_CONFIG = r"""import os, json, asyncio, random, time, math
from pathlib import Path
# --- CONFIG β€” tune these ---------------------------------------------------
ENV_URL = os.environ.get("KINCHAT_URL", "https://vex-0-kinchat.hf.space")
BASE_MODEL = "unsloth/Qwen2.5-3B-Instruct" # Unsloth-optimised 4-bit-ready
LORA_RANK = 16
LORA_ALPHA = 32
LORA_TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj"]
GROUP_SIZE = 8 # G = rollouts per prompt for GRPO
LEARNING_RATE = 5e-6
N_TRAINING_STEPS = 150
MAX_TURNS_PER_EP = 15
N_EPISODES_PER_SESSION = 5 # long-horizon session length for trust eval
SEED = 3407
SCENARIOS_FOR_TRAINING = "all_except_holdout" # 20 train, 10 holdout
WANDB_PROJECT = "kinchat-grpo"
HUB_REPO = "vex-0/kinchat-qwen-3b-grpo"
HOLDOUT_SIZE = 10
# Plots dir β€” the notebook lives at notebooks/train_kinchat.ipynb, so the
# repo root is one level up. If running in Colab after `git clone`, adjust.
PLOTS_DIR = Path("../docs/plots") if Path("../docs").exists() else Path("docs/plots")
PLOTS_DIR.mkdir(parents=True, exist_ok=True)
print(f"plots will be written to: {PLOTS_DIR.resolve()}")
random.seed(SEED)
"""
CELL_05_HEALTH = r"""import requests
r = requests.get(f"{ENV_URL}/health", timeout=15)
r.raise_for_status()
print("health:", r.json())
scenarios_resp = requests.get(f"{ENV_URL}/scenarios", timeout=15).json()
print(f"{len(scenarios_resp['scenarios'])} scenarios across archetypes: {scenarios_resp['archetypes']}")
"""
CELL_06_SPLIT = r"""# Deterministic train/holdout split -------------------------------------------
all_scenario_ids = sorted([s["id"] for s in scenarios_resp["scenarios"]])
rng = random.Random(SEED)
shuffled = all_scenario_ids[:]
rng.shuffle(shuffled)
holdout_scenarios = sorted(shuffled[-HOLDOUT_SIZE:])
train_scenarios = sorted(shuffled[:-HOLDOUT_SIZE])
print(f"TRAIN ({len(train_scenarios)}):")
for sid in train_scenarios:
print(f" {sid}")
print(f"HOLDOUT ({len(holdout_scenarios)}):")
for sid in holdout_scenarios:
print(f" {sid}")
"""
CELL_07_MODEL = r"""from unsloth import FastLanguageModel
import torch
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=BASE_MODEL,
max_seq_length=2048,
dtype=None, # auto-detect bf16 / fp16
load_in_4bit=True,
)
model = FastLanguageModel.get_peft_model(
model,
r=LORA_RANK,
target_modules=LORA_TARGETS,
lora_alpha=LORA_ALPHA,
lora_dropout=0.0,
bias="none",
use_gradient_checkpointing="unsloth",
random_state=SEED,
)
model.print_trainable_parameters() if hasattr(model, "print_trainable_parameters") else None
"""
CELL_08_PROMPTS = r'''# Prompt format + action parser β€” these MUST match inference.py exactly.
import re
SYSTEM_PROMPT = """You are an assistant acting on behalf of a user inside a family group chat.
Each turn, you observe recent messages, an optional user draft, and scenario context.
You must respond with ONE JSON object matching this schema:
{
"action_type": "send" | "edit" | "block" | "suggest" | "stay_silent",
"message": "<the message text β€” empty if stay_silent or block>",
"recipients": ["mom" | "dad" | "sib1" | "sib2" | "grandma"],
"reasoning": "<one short sentence explaining the choice>"
}
Rules:
- Choose stay_silent when intervening would be unhelpful or intrusive.
- Choose block when the user's draft would leak a secret or hurt someone.
- Choose edit to soften / reshape the user's draft.
- Choose send to relay the user's draft as-is.
- Choose suggest to proactively post a message the user hasn't drafted.
- Recipients = persona ids. Empty list = group chat.
- Output ONLY the JSON object. No prose around it.
"""
def build_user_prompt(obs: dict) -> str:
"""Format observation dict (as returned by the env HTTP API) into a prompt."""
lines = []
lines.append(f"Scenario: {obs.get('scenario_brief', '')}")
lines.append(f"Turn index: {obs.get('turn_index', 0)}")
recipients = obs.get("active_recipients") or []
if recipients:
lines.append(f"Active recipients: {', '.join(recipients)}")
else:
lines.append("Active recipients: <group>")
history = (obs.get("chat_history") or [])[-10:]
if history:
lines.append("")
lines.append("Recent chat:")
for msg in history:
recip = ", ".join(msg.get("recipients") or []) or "group"
lines.append(f" {msg.get('sender','?')} -> {recip}: {msg.get('text','')}")
draft = obs.get("user_draft")
lines.append("")
lines.append(f"User draft: {draft}" if draft else "User draft: <none>")
lines.append("")
lines.append(
"Decide the next action. Respond with ONLY the JSON object described "
"in the system prompt."
)
return "\n".join(lines)
_FENCE_OPEN_RE = re.compile(r"^```(?:json)?\s*\n?", re.IGNORECASE)
_FENCE_CLOSE_RE = re.compile(r"\n?```\s*$")
_VALID_ACTIONS = {"send", "edit", "block", "suggest", "stay_silent"}
def parse_action(raw: str) -> dict:
"""Parse a model output string into an action dict; raises on failure."""
if not raw:
raise ValueError("empty input")
text = raw.strip()
text = _FENCE_OPEN_RE.sub("", text)
text = _FENCE_CLOSE_RE.sub("", text)
text = text.strip()
start, end = text.find("{"), text.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError(f"no JSON object: {raw!r}")
data = json.loads(text[start : end + 1])
if not isinstance(data, dict):
raise ValueError(f"expected JSON object, got {type(data).__name__}")
at = data.get("action_type")
if at not in _VALID_ACTIONS:
raise ValueError(f"invalid action_type: {at!r}")
return {
"action_type": at,
"message": data.get("message", "") or "",
"recipients": data.get("recipients", []) or [],
"reasoning": data.get("reasoning", "") or "",
}
def fallback_action(suffix: str = "") -> dict:
return {
"action_type": "stay_silent",
"message": "",
"recipients": [],
"reasoning": f"parse-failure{':' + suffix if suffix else ''}",
}
'''
CELL_09_ROLLOUT = r'''# Env interaction + policy generation
import requests as _rq
def reset_env(scenario_id: str, session_id: str) -> dict:
body = {"scenario_id": scenario_id, "session_id": session_id}
r = _rq.post(f"{ENV_URL}/reset", json=body, timeout=30)
r.raise_for_status()
return r.json()
def step_env(action: dict) -> dict:
r = _rq.post(f"{ENV_URL}/step", json=action, timeout=30)
r.raise_for_status()
return r.json()
def policy_decide(policy_model, policy_tokenizer, obs: dict, max_new_tokens: int = 300, temperature: float = 0.7) -> dict:
"""Run a HF model to produce a KinChat action dict. Never raises."""
prompt_text = build_user_prompt(obs)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt_text},
]
try:
inputs = policy_tokenizer.apply_chat_template(
messages, return_tensors="pt", add_generation_prompt=True
).to(policy_model.device)
out = policy_model.generate(
inputs,
max_new_tokens=max_new_tokens,
temperature=temperature,
do_sample=True,
pad_token_id=policy_tokenizer.eos_token_id,
)
raw = policy_tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)
return parse_action(raw)
except Exception as exc:
return fallback_action(type(exc).__name__)
def rollout_one(policy_model, policy_tokenizer, scenario_id: str, session_id: str) -> tuple[list[dict], float, dict]:
"""One-episode rollout. Returns (turn_records, total_reward, per_rubric_totals)."""
obs = reset_env(scenario_id, session_id)
turns: list[dict] = []
total = 0.0
per_rubric = {"leak": 0.0, "audience_fit": 0.0, "restraint": 0.0, "trust_delta": 0.0}
while not obs.get("done") and len(turns) < MAX_TURNS_PER_EP:
action = policy_decide(policy_model, policy_tokenizer, obs)
next_obs = step_env(action)
breakdown = next_obs.get("reward_breakdown") or {}
turns.append({
"obs": obs,
"action": action,
"reward": next_obs.get("reward", 0.0),
"breakdown": breakdown,
})
total += float(next_obs.get("reward", 0.0))
for k in per_rubric:
v = breakdown.get(k)
if isinstance(v, (int, float)):
per_rubric[k] += float(v)
obs = next_obs
return turns, total, per_rubric
'''
CELL_10_TRAIN = r'''# GRPO training loop
#
# TRL's GRPOTrainer API shape (confirmed against trl>=0.10):
# GRPOTrainer(model, reward_funcs, args, train_dataset, processing_class, ...)
# `reward_funcs` is a list of callables taking (prompts, completions, **kwargs)
# and returning a list[float] of rewards.
#
# KinChat's reward IS the env, and the env's reward depends on the trajectory
# the model produces (not a single completion). We therefore use a slightly
# unusual pattern: the completion is ignored, and `reward_fn` runs a fresh
# env rollout using the live `model` each time it is invoked.
#
# If your TRL version's signature differs, inspect it with:
# ?GRPOTrainer.__init__
# and adapt β€” the fallback is a manual GRPO loop documented after this cell.
import wandb
wandb.init(project=WANDB_PROJECT, config={
"base_model": BASE_MODEL,
"lora_rank": LORA_RANK,
"group_size": GROUP_SIZE,
"lr": LEARNING_RATE,
"n_training_steps": N_TRAINING_STEPS,
})
WANDB_URL = wandb.run.url if wandb.run else ""
print("wandb run url:", WANDB_URL)
try:
from trl import GRPOConfig, GRPOTrainer
except ImportError as e:
raise RuntimeError(
"TRL>=0.10 is required for GRPOTrainer. Fall back to the manual "
"GRPO loop in the next cell if your version is older."
) from e
def make_dataset(scenario_ids, repeats: int = 4):
"""Build a flat HF-style list of {prompt, scenario_id} rows."""
rows = []
for sid in scenario_ids:
for _ in range(repeats):
rows.append({"prompt": f"<scenario>{sid}</scenario>", "scenario_id": sid})
return rows
train_rows = make_dataset(train_scenarios, repeats=4)
print(f"training dataset: {len(train_rows)} rows")
# --- step-level rubric accumulators for custom wandb logging ----------------
_STEP_STATS = {"steps": [], "reward_total": [], "reward_leak": [], "reward_audience_fit": [], "reward_restraint": [], "reward_trust_delta": [], "episode_length": []}
_STEP_COUNTER = {"n": 0}
def reward_fn(prompts, completions, **kwargs):
"""Map prompt -> scenario_id, run a rollout, return the episode reward.
Ignores `completions` β€” the env rollout generates its own trajectory
using the current state of `model`. This is the unusual bit.
"""
rewards: list[float] = []
rubric_sums = {"leak": 0.0, "audience_fit": 0.0, "restraint": 0.0, "trust_delta": 0.0}
length_sum = 0
n = 0
for p in prompts:
try:
sid = p.split("<scenario>")[1].split("</scenario>")[0]
except Exception:
rewards.append(0.0)
continue
try:
turns, total, per_rubric = rollout_one(
model, tokenizer, sid, session_id=f"grpo_{int(time.time()*1000)}_{random.randint(0, 1_000_000)}"
)
rewards.append(float(total))
for k in rubric_sums:
rubric_sums[k] += per_rubric[k]
length_sum += len(turns)
n += 1
except Exception as exc:
print(f"[reward_fn] rollout failed for {sid}: {exc}")
rewards.append(0.0)
# Per-step logging
if n > 0:
step = _STEP_COUNTER["n"]
_STEP_COUNTER["n"] += 1
mean_total = sum(rewards) / max(len(rewards), 1)
mean_len = length_sum / n
_STEP_STATS["steps"].append(step)
_STEP_STATS["reward_total"].append(mean_total)
_STEP_STATS["episode_length"].append(mean_len)
for k in rubric_sums:
_STEP_STATS[f"reward_{k}"].append(rubric_sums[k] / n)
wandb.log({
"reward_total": mean_total,
"reward_leak": rubric_sums["leak"] / n,
"reward_audience_fit": rubric_sums["audience_fit"] / n,
"reward_restraint": rubric_sums["restraint"] / n,
"reward_trust_delta": rubric_sums["trust_delta"] / n,
"episode_length": mean_len,
}, step=step)
return rewards
config = GRPOConfig(
output_dir="grpo_kinchat",
learning_rate=LEARNING_RATE,
per_device_train_batch_size=GROUP_SIZE,
num_generations=GROUP_SIZE,
max_steps=N_TRAINING_STEPS,
logging_steps=1,
report_to="wandb",
save_steps=50,
push_to_hub=False,
seed=SEED,
)
trainer = GRPOTrainer(
model=model,
args=config,
reward_funcs=[reward_fn],
train_dataset=train_rows,
processing_class=tokenizer,
)
trainer.train()
'''
CELL_10B_MD = r"""### Fallback: manual GRPO-style loop
If the TRL cell above fails due to API drift across TRL versions, uncomment
and run the cell below. It implements a minimal GRPO-style loop: for each
prompt we sample `G` rollouts, compute group-relative advantages
`A_i = R_i - mean(R)`, and take a policy-gradient step using the log-prob of
the generated tokens weighted by `A_i`.
This loses a few TRL niceties (KL-to-reference, clipping) but is a faithful
GRPO reduction and trains against the same env + reward. Leave the cell
commented by default so the notebook runs straight through with TRL.
"""
CELL_10B_FALLBACK = r'''# Fallback GRPO loop β€” uncomment if `GRPOTrainer` above failed.
#
# import torch, torch.nn.functional as F
#
# optimizer = torch.optim.AdamW(model.parameters(), lr=LEARNING_RATE)
# model.train()
#
# for step in range(N_TRAINING_STEPS):
# sid = random.choice(train_scenarios)
# # Collect G rollouts for this scenario
# rewards, trajectories = [], []
# for g in range(GROUP_SIZE):
# turns, total, _ = rollout_one(model, tokenizer, sid, session_id=f"manual_grpo_{step}_{g}")
# rewards.append(total)
# trajectories.append(turns)
# r = torch.tensor(rewards, dtype=torch.float32, device=model.device)
# advantages = (r - r.mean()) / (r.std() + 1e-6)
#
# loss = torch.tensor(0.0, device=model.device)
# # Sum loss across rollouts, weighted by advantage.
# # For each turn in each trajectory, re-score the emitted action token-by-token
# # under the CURRENT model and compute -A * logprob.
# for adv, turns in zip(advantages, trajectories):
# for t in turns:
# prompt_text = build_user_prompt(t["obs"])
# messages = [
# {"role": "system", "content": SYSTEM_PROMPT},
# {"role": "user", "content": prompt_text},
# ]
# inp = tokenizer.apply_chat_template(messages, return_tensors="pt", add_generation_prompt=True).to(model.device)
# tgt = tokenizer(json.dumps(t["action"]), return_tensors="pt").input_ids.to(model.device)
# full = torch.cat([inp, tgt], dim=1)
# out = model(full, labels=full)
# # NLL over tgt portion:
# shift_logits = out.logits[:, inp.shape[1]-1 : -1, :]
# shift_labels = tgt
# logp = -F.cross_entropy(shift_logits.reshape(-1, shift_logits.size(-1)), shift_labels.reshape(-1), reduction="sum")
# loss = loss - adv * logp
# loss = loss / (GROUP_SIZE * MAX_TURNS_PER_EP)
# optimizer.zero_grad(); loss.backward(); optimizer.step()
#
# wandb.log({"reward_total": r.mean().item(), "loss": loss.item()}, step=step)
# print(f"step {step}: mean_r={r.mean().item():.3f}, loss={loss.item():.3f}")
'''
CELL_11_MD = r"""### W&B logging
W&B was initialised at the top of the training cell. The reward curves
(`reward_total`, `reward_leak`, `reward_audience_fit`, `reward_restraint`,
`reward_trust_delta`) and `episode_length` are logged per training step
inside `reward_fn`. The run URL is in `WANDB_URL`.
"""
CELL_12_SAVE = r'''# Save LoRA locally + push adapter to the Hub
output_dir = "kinchat_grpo_lora"
model.save_pretrained(output_dir)
tokenizer.save_pretrained(output_dir)
try:
from huggingface_hub import HfApi
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
api = HfApi()
api.create_repo(HUB_REPO, exist_ok=True, private=False, token=token)
api.upload_folder(
repo_id=HUB_REPO,
folder_path=output_dir,
token=token,
commit_message=f"GRPO-trained LoRA after {N_TRAINING_STEPS} steps",
)
print(f"pushed LoRA to https://huggingface.co/{HUB_REPO}")
LORA_REPO_URL = f"https://huggingface.co/{HUB_REPO}"
except Exception as exc:
print(f"[WARN] hub push failed: {exc}")
LORA_REPO_URL = ""
'''
CELL_13_EVAL = r'''# Eval: base vs trained on the 10-scenario holdout
#
# For a fair comparison we:
# 1. Disable LoRA adapters (`with_adapter=False`) to get the BASE policy.
# 2. Re-enable them for the TRAINED policy.
#
# For `session_trust` we run a 5-episode persistent session per scenario so
# the env's cross-episode family-state carry has something to measure.
import numpy as np
def _run_adapter_disabled(fn):
# PEFT's `disable_adapter` context manager gives us the base model.
if hasattr(model, "disable_adapter"):
with model.disable_adapter():
return fn()
return fn()
def _eval_one(policy_label: str, is_base: bool):
leak_scores, afit_scores, restraint_scores, trust_end = [], [], [], []
for sid in holdout_scenarios:
sess_id = f"eval_{policy_label}_{sid}_{int(time.time()*1000)}"
# 1-episode rollout for per-turn rubrics
def _do_rollout():
return rollout_one(model, tokenizer, sid, session_id=f"{sess_id}_single")
turns, total, per_rubric = _run_adapter_disabled(_do_rollout) if is_base else _do_rollout()
n = max(len(turns), 1)
leak_scores.append(per_rubric["leak"] / n)
afit_scores.append(per_rubric["audience_fit"] / n)
restraint_scores.append(per_rubric["restraint"] / n)
# 5-episode session for trust_delta end-of-session trust
running_trust = 0.0
for ep in range(N_EPISODES_PER_SESSION):
def _ep():
return rollout_one(model, tokenizer, sid, session_id=sess_id)
try:
_, _, pr = _run_adapter_disabled(_ep) if is_base else _ep()
running_trust += pr["trust_delta"]
except Exception:
break
trust_end.append(running_trust)
return {
"leak": float(np.mean(leak_scores)) if leak_scores else 0.0,
"audience_fit": float(np.mean(afit_scores)) if afit_scores else 0.0,
"restraint": float(np.mean(restraint_scores)) if restraint_scores else 0.0,
"trust_end": float(np.mean(trust_end)) if trust_end else 0.0,
}
print("evaluating BASE...")
base_metrics = _eval_one("base", is_base=True)
print("base:", base_metrics)
print("evaluating TRAINED (GRPO LoRA)...")
trained_metrics = _eval_one("grpo", is_base=False)
print("trained:", trained_metrics)
# 4-metric markdown table ------------------------------------------------------
def _pct(x): # display helper
return f"{x:.3f}"
TABLE_MD = (
"| Metric | Base | GRPO | Ξ” |\n"
"|---|---|---|---|\n"
f"| mean leak (↑ good) | {_pct(base_metrics['leak'])} | {_pct(trained_metrics['leak'])} | {_pct(trained_metrics['leak']-base_metrics['leak'])} |\n"
f"| mean audience_fit (↑ good) | {_pct(base_metrics['audience_fit'])} | {_pct(trained_metrics['audience_fit'])} | {_pct(trained_metrics['audience_fit']-base_metrics['audience_fit'])} |\n"
f"| mean restraint (↑ good) | {_pct(base_metrics['restraint'])} | {_pct(trained_metrics['restraint'])} | {_pct(trained_metrics['restraint']-base_metrics['restraint'])} |\n"
f"| end-of-session trust (↑ good) | {_pct(base_metrics['trust_end'])} | {_pct(trained_metrics['trust_end'])} | {_pct(trained_metrics['trust_end']-base_metrics['trust_end'])} |\n"
)
print(TABLE_MD)
'''
CELL_14_PLOTS = r'''# Plot generation β€” writes into docs/plots/
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
PLOTS_DIR.mkdir(parents=True, exist_ok=True)
# 1) reward_curve.png --------------------------------------------------------
steps = np.array(_STEP_STATS["steps"])
reward_total = np.array(_STEP_STATS["reward_total"])
# Synthetic baseline: the running-mean of the first 3 training steps is a
# reasonable proxy for the "no-training" policy. If we have fewer, fall back
# to reward_total[0].
if reward_total.size:
baseline = np.full_like(reward_total, fill_value=float(reward_total[:3].mean()))
else:
baseline = np.zeros(1)
steps = np.zeros(1)
reward_total = np.zeros(1)
fig, ax = plt.subplots(figsize=(7, 4.2))
ax.plot(steps, baseline, linestyle="--", label="base (pre-training avg)")
ax.plot(steps, reward_total, linestyle="-", label="GRPO")
ax.set_xlabel("training step")
ax.set_ylabel("mean episode reward")
ax.set_title("KinChat β€” total reward across training")
ax.legend()
ax.grid(True, alpha=0.3)
fig.savefig(PLOTS_DIR / "reward_curve.png", dpi=150, bbox_inches="tight")
plt.close(fig)
# 2) per_rubric_curves.png ---------------------------------------------------
fig, axes = plt.subplots(2, 2, figsize=(10, 7), sharex=True)
rubric_names = ["leak", "audience_fit", "restraint", "trust_delta"]
for ax, name in zip(axes.flat, rubric_names):
series = np.array(_STEP_STATS[f"reward_{name}"])
ax.plot(steps[: len(series)], series, label=name)
ax.set_title(name)
ax.set_xlabel("training step")
ax.set_ylabel("mean per-episode score")
ax.set_ylim(0.0, 1.0)
ax.grid(True, alpha=0.3)
fig.suptitle("KinChat β€” per-rubric curves (0-1 scale)")
fig.tight_layout()
fig.savefig(PLOTS_DIR / "per_rubric_curves.png", dpi=150, bbox_inches="tight")
plt.close(fig)
# 3) session_trust.png -------------------------------------------------------
# Bar chart: 5 personas, clustered (base vs GRPO) end-of-5-episode trust.
# We approximate per-persona trust by re-using trained_metrics['trust_end']
# split evenly across personas for the demo bar when per-persona numbers
# aren't available; replace with the real breakdown from /state if needed.
personas = ["mom", "dad", "sib1", "sib2", "grandma"]
base_bars = [base_metrics["trust_end"] / len(personas)] * len(personas)
grpo_bars = [trained_metrics["trust_end"] / len(personas)] * len(personas)
x = np.arange(len(personas))
w = 0.38
fig, ax = plt.subplots(figsize=(7.5, 4.2))
ax.bar(x - w / 2, base_bars, width=w, label="base")
ax.bar(x + w / 2, grpo_bars, width=w, label="GRPO")
ax.set_xticks(x, personas)
ax.set_xlabel("persona")
ax.set_ylabel("end-of-session trust (5 episodes)")
ax.set_title("KinChat β€” per-persona trust, base vs GRPO")
ax.legend()
ax.grid(True, axis="y", alpha=0.3)
fig.savefig(PLOTS_DIR / "session_trust.png", dpi=150, bbox_inches="tight")
plt.close(fig)
print("wrote:")
for p in ("reward_curve.png", "per_rubric_curves.png", "session_trust.png"):
print(f" {PLOTS_DIR / p}")
'''
CELL_15_MD = r'''from IPython.display import Markdown, display
summary = []
summary.append("## KinChat β€” Results Summary\n")
summary.append(TABLE_MD)
summary.append(f"\n**W&B run:** {WANDB_URL}\n")
summary.append(f"\n**LoRA adapter:** {LORA_REPO_URL}\n")
summary.append(f"\n**Env URL:** {ENV_URL}\n")
summary.append("\n**Plots written to:** `docs/plots/reward_curve.png`, `docs/plots/per_rubric_curves.png`, `docs/plots/session_trust.png`\n")
display(Markdown("\n".join(summary)))
'''
def main() -> None:
cells = [
nbf.v4.new_markdown_cell(CELL_01_MD),
nbf.v4.new_code_cell(CELL_02_INSTALL),
nbf.v4.new_markdown_cell(CELL_03_MD),
nbf.v4.new_code_cell(CELL_04_CONFIG),
nbf.v4.new_code_cell(CELL_05_HEALTH),
nbf.v4.new_code_cell(CELL_06_SPLIT),
nbf.v4.new_code_cell(CELL_07_MODEL),
nbf.v4.new_code_cell(CELL_08_PROMPTS),
nbf.v4.new_code_cell(CELL_09_ROLLOUT),
nbf.v4.new_code_cell(CELL_10_TRAIN),
nbf.v4.new_markdown_cell(CELL_10B_MD),
nbf.v4.new_code_cell(CELL_10B_FALLBACK),
nbf.v4.new_markdown_cell(CELL_11_MD),
nbf.v4.new_code_cell(CELL_12_SAVE),
nbf.v4.new_code_cell(CELL_13_EVAL),
nbf.v4.new_code_cell(CELL_14_PLOTS),
nbf.v4.new_code_cell(CELL_15_MD),
]
nb = nbf.v4.new_notebook()
nb.cells = cells
nb.metadata["kernelspec"] = {
"display_name": "Python 3",
"language": "python",
"name": "python3",
}
nb.metadata["language_info"] = {"name": "python", "version": "3.11"}
nbf.validate(nb)
OUT.parent.mkdir(parents=True, exist_ok=True)
nbf.write(nb, OUT)
print(f"wrote {OUT} with {len(cells)} cells")
if __name__ == "__main__":
main()