shreydan/rl-envs-101-notebooks / 02_lipogram_grpo.ipynb
shreydan's picture
download
raw
13.1 kB
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# \ud83d\udd24 Teach a Model a *Lipogram* \u2014 GRPO + reward design\n",
"\n",
"Train **Qwen3.5-2B** to answer questions **without ever using the letter \u201ce\u201d** \u2014 a *lipogram*.\n",
"Inspired by the [Thinking Machines \u201cInkling\u201d demo](https://thinkingmachines.ai/news/introducing-inkling/).\n",
"\n",
"Why this makes a great RL tutorial: the constraint is **verifiable** (just check for \u201ce\u201d), so the\n",
"reward is easy to write \u2014 and that's exactly why it's the perfect place to learn **reward design** and\n",
"the classic **reward-hacking** failure mode. A lazy reward gets gamed (the model spits out short,\n",
"letter-free gibberish). We'll *watch* that happen, then fix it.\n",
"\n",
"Unlike notebook 01 (which used an OpenEnv server for the reward), here the reward is a plain **Python\n",
"function you own** \u2014 the other way rewards enter GRPO. Only a **Hugging Face token** is needed."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Install"
]
},
{
"cell_type": "code",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": [
"%pip install -q -U \"trl>=0.19\" datasets accelerate trackio 2>/dev/null\n",
"print('\u2705 installed')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## \ud83d\udd11 Log in to Hugging Face"
]
},
{
"cell_type": "code",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": [
"from huggingface_hub import notebook_login, whoami\n",
"try:\n",
" who = whoami() # already logged in (HF_TOKEN in the job)\n",
"except Exception:\n",
" notebook_login()\n",
" who = whoami()\n",
"print('\ud83d\udc64 logged in as:', who['name'])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. Settings"
]
},
{
"cell_type": "code",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": [
"import os\n",
"os.environ['HF_HUB_ENABLE_HF_TRANSFER'] = '1'\n",
"USERNAME = whoami()['name']\n",
"\n",
"MODEL = 'Qwen/Qwen3.5-2B' # small text model; any chat LLM works\n",
"FORBIDDEN_LETTER = 'e' # the letter the model must avoid\n",
"DATASET = 'yahma/alpaca-cleaned' # open-ended instructions -> avoiding 'e' is genuinely hard\n",
"\n",
"NUM_TRAIN_SAMPLES = 2000\n",
"MAX_STEPS = 40 # short demo; bump to 300+ to really move it\n",
"NUM_GENERATIONS = 8\n",
"MAX_COMPLETION = 256\n",
"LEARNING_RATE = 1e-5\n",
"REWARD_K = 4.0 # steepness of the letter penalty (higher = harsher)\n",
"MIN_WORDS = 6 # anti-hack: answers below this score 0\n",
"\n",
"USE_TRACKIO = True\n",
"TRACKIO_PROJECT = 'lipogram-grpo'\n",
"TRACKIO_SPACE_ID = f'{USERNAME}/trackio-lipogram'\n",
"print(f'\ud83e\udde0 {MODEL} \u00b7 forbid \"{FORBIDDEN_LETTER}\" \u00b7 {MAX_STEPS} steps \u00d7 {NUM_GENERATIONS} gens')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. The reward \u2014 and how it gets *hacked* \ud83c\udfaf\n",
"\n",
"**This is the whole point of the notebook.** The obvious reward is \u201cpenalize the letter e.\u201d But if that's\n",
"*all* you reward, the model discovers it can win by producing **short, repetitive, letter-free gibberish**\n",
"\u2014 maximal reward, zero usefulness. That's **reward hacking**.\n",
"\n",
"So our reward is a **product of three terms**, so all must hold:\n",
"\n",
"```\n",
"reward = lipogram_score \u00d7 length_guard \u00d7 diversity_guard\n",
"```\n",
"- **lipogram_score** \u2014 steep `exp(-k \u00b7 e_per_word)`: 1.0 with zero \u201ce\u201d, dropping fast per \u201ce\u201d.\n",
"- **length_guard** \u2014 0 below `MIN_WORDS` (kills 1-word hacks), decays for rambling.\n",
"- **diversity_guard** \u2014 penalizes low unique-word ratio (kills \u201cno no no no\u201d).\n",
"\n",
"Run the cell \u2014 it scores a few hand-written answers so you can *see* the naive-vs-guarded difference."
]
},
{
"cell_type": "code",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": [
"import math\n",
"L = FORBIDDEN_LETTER.lower()\n",
"\n",
"def naive_reward(text):\n",
" \"\"\"The lazy reward: just 1 - fraction of words containing the letter. Gets hacked.\"\"\"\n",
" w = text.split(); wc = max(len(w), 1)\n",
" return 1.0 - sum(1 for x in w if L in x.lower()) / wc\n",
"\n",
"def guarded_reward(text, k=REWARD_K, min_words=MIN_WORDS, max_words=120, div_floor=0.4):\n",
" \"\"\"Composite reward: avoid the letter AND stay a real, varied answer.\"\"\"\n",
" w = text.split(); wc = len(w)\n",
" if wc < min_words: # empty / one-word letter-free hack\n",
" return 0.0\n",
" n = text.lower().count(L)\n",
" lipo = math.exp(-k * (n / wc)) # 1.0 at zero 'e', steep penalty per 'e'\n",
" length = 1.0 if wc <= max_words else max(0.0, 1 - (wc - max_words) / max_words)\n",
" div = min(1.0, (len(set(x.lower() for x in w)) / wc) / div_floor)\n",
" return lipo * length * div\n",
"\n",
"examples = {\n",
" 'normal answer (lots of e)': 'The weather here is really pleasant and the trees are green everywhere.',\n",
" 'HACK: one word, no e' : 'Ok.',\n",
" 'HACK: repetition, no e' : 'blah blah blah blah blah blah blah blah',\n",
" 'GOOD: real answer, no e' : 'My dog ran fast across a wide grassy hill on a warm sunny day, glad and calm.',\n",
"}\n",
"print(f\"{'completion':30s} {'naive':>7} {'guarded':>8}\")\n",
"for name, t in examples.items():\n",
" print(f'{name:30s} {naive_reward(t):7.2f} {guarded_reward(t):8.2f}')\n",
"print('\\n\ud83d\udc46 naive rewards the gibberish hacks ~1.0; guarded gives them ~0 and only the real e-free answer wins.')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### The GRPO reward function\n",
"GRPO calls a reward function with a batch of `completions` and returns one score each. We wrap the\n",
"guarded reward above."
]
},
{
"cell_type": "code",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": [
"SUFFIX = (f\"\\n\\nRules: (1) never use the letter '{FORBIDDEN_LETTER}' anywhere in your reply; \"\n",
" f\"(2) keep it brief and to the point.\")\n",
"\n",
"def lipogram_reward(completions, **kwargs):\n",
" return [guarded_reward((c[0]['content'] or '').strip()) for c in completions]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. The dataset\n",
"Open-ended alpaca instructions, each with the \u201cdon't use e\u201d rule appended. GRPO only needs the\n",
"**prompt** (the reward scores the model's own answer \u2014 there's no gold label to match)."
]
},
{
"cell_type": "code",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": [
"from datasets import load_dataset, Dataset\n",
"raw = load_dataset(DATASET, split=f'train[:{NUM_TRAIN_SAMPLES}]')\n",
"prompts = []\n",
"for r in raw:\n",
" q = (r.get('instruction') or '').strip()\n",
" inp = (r.get('input') or '').strip()\n",
" if inp: q = f'{q}\\n\\n{inp}'\n",
" if q and len(q) <= 600:\n",
" prompts.append([{'role':'user','content': q + SUFFIX}])\n",
"train_dataset = Dataset.from_dict({'prompt': prompts})\n",
"EVAL_QUESTIONS = [\n",
" 'What are the benefits of regular exercise?',\n",
" 'Explain how photosynthesis works.',\n",
" 'Give three tips for staying productive.',\n",
" 'Describe your favorite season and why you like it.',\n",
" 'How do airplanes stay in the air?',\n",
"]\n",
"print(f'\ud83d\udcda {len(train_dataset)} training prompts')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Baseline \u2014 how often is it e-free *now*?\n",
"Spoiler: almost never. Normal English is saturated with \u201ce\u201d."
]
},
{
"cell_type": "code",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": [
"import torch\n",
"from transformers import AutoModelForCausalLM, AutoTokenizer\n",
"tok = AutoTokenizer.from_pretrained(MODEL)\n",
"def load_model():\n",
" return AutoModelForCausalLM.from_pretrained(MODEL, dtype='bfloat16', device_map='cuda')\n",
"\n",
"def evaluate(model, questions=EVAL_QUESTIONS, show=True):\n",
" model.eval(); ok = 0; samples = []\n",
" for q in questions:\n",
" msgs = [{'role':'user','content': q + SUFFIX}]\n",
" text = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True, enable_thinking=False)\n",
" ins = tok(text, return_tensors='pt').to(model.device)\n",
" with torch.no_grad():\n",
" out = model.generate(**ins, max_new_tokens=MAX_COMPLETION, do_sample=False)\n",
" ans = tok.decode(out[0][ins['input_ids'].shape[1]:], skip_special_tokens=True).strip()\n",
" e = ans.lower().count(L); ok += (e == 0 and len(ans.split()) >= MIN_WORDS)\n",
" samples.append((q, ans, e))\n",
" rate = ok / len(questions)\n",
" if show:\n",
" print(f'lipogram success: {rate*100:.0f}% (answers with ZERO \"{FORBIDDEN_LETTER}\")')\n",
" for q, a, e in samples[:2]: print(f' Q: {q}\\n A: {a[:160]} [e={e}]\\n')\n",
" return rate\n",
"\n",
"base_model = load_model()\n",
"print('=== BEFORE training ==='); baseline = evaluate(base_model)\n",
"del base_model; torch.cuda.empty_cache()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 6. Train \ud83c\udfcb\ufe0f\n",
"`GRPOTrainer` samples `NUM_GENERATIONS` answers per prompt, scores them with `lipogram_reward`, and\n",
"nudges the model toward the higher-reward (e-free, real) ones. Short demo run."
]
},
{
"cell_type": "code",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": [
"from trl import GRPOConfig, GRPOTrainer\n",
"cfg = GRPOConfig(\n",
" output_dir='lipogram-grpo',\n",
" model_init_kwargs={'dtype':'bfloat16'},\n",
" learning_rate=LEARNING_RATE,\n",
" num_generations=NUM_GENERATIONS,\n",
" per_device_train_batch_size=NUM_GENERATIONS,\n",
" steps_per_generation=1, gradient_accumulation_steps=1,\n",
" max_steps=MAX_STEPS, max_completion_length=MAX_COMPLETION,\n",
" temperature=1.0,\n",
" chat_template_kwargs={'enable_thinking': False},\n",
" bf16=True, gradient_checkpointing=True, gradient_checkpointing_kwargs={'use_reentrant': False},\n",
" logging_steps=1, save_strategy='no',\n",
" report_to=('trackio' if USE_TRACKIO else 'none'),\n",
" project=TRACKIO_PROJECT, trackio_space_id=(TRACKIO_SPACE_ID if USE_TRACKIO else None),\n",
" run_name='lipogram-grpo',\n",
")\n",
"trainer = GRPOTrainer(model=MODEL, args=cfg, train_dataset=train_dataset, reward_funcs=lipogram_reward)\n",
"trainer.train()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 7. Did it learn to avoid \u201ce\u201d?"
]
},
{
"cell_type": "code",
"metadata": {},
"execution_count": null,
"outputs": [],
"source": [
"print('=== AFTER training ==='); after = evaluate(trainer.model)\n",
"print(f'\\nlipogram success: {baseline*100:.0f}% \u2192 {after*100:.0f}%')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Recap\n",
"- An RL **reward is just a function** of the model's output \u2014 here, a Python function you fully control.\n",
"- The **naive** reward was hackable (gibberish scored ~1.0); the **guarded** reward (letter \u00d7 length \u00d7\n",
" diversity) made the model produce *real, e-free* answers.\n",
"- Same GRPO machinery as notebook 01 \u2014 the only difference is **where the reward comes from**\n",
" (an OpenEnv server there, an in-process function here).\n",
"- 40 steps is a demo; a real lipogram model needs a few hundred steps + a stronger LR.\n",
"\n",
"**Takeaway for your own envs: the reward is the whole game.** Design it so the *only* way to score high\n",
"is to actually do the task."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

Xet Storage Details

Size:
13.1 kB
·
Xet hash:
09dc7d5729eca1d84c299cf6cbd5fab36c4afa3bd9ea7c6f2b5dfadb980c0f2c

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.