| """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_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() |
|
|