mitudrudutta commited on
Commit
5674079
·
1 Parent(s): 1f49d52

fix(notebook): pin accelerate==1.0.1 to keep huggingface-hub at 0.26.x

Browse files

Latest accelerate (1.13+) hard-requires huggingface-hub 1.x. Without
the accelerate pin, STEP 2b's only-if-needed install resolved a newer
accelerate that in turn upgraded hub past 0.26.5, breaking peft 0.14
and trl 0.21 imports. Pin accelerate to the last 0.x-hub-compatible
version (1.0.1) and pull it into STEP 2a alongside the rest of the
training stack.

Files changed (1) hide show
  1. notebooks/train_merchant_agent.ipynb +601 -19
notebooks/train_merchant_agent.ipynb CHANGED
@@ -4,13 +4,29 @@
4
  "cell_type": "markdown",
5
  "id": "intro-md",
6
  "metadata": {},
7
- "source": "# ChargebackOps Merchant Agent - outcome-based RL on Qwen2.5-3B (fp16 LoRA)\n\nEnd-to-end pipeline for a single Colab T4 (or Kaggle T4):\n\n1. **Phase A - JSON SFT** on heuristic rollouts. Teaches the model the env action schema.\n2. **Phase B - GRPO with outcome reward**. Reward = terminal `$` PnL after the model's action plus heuristic tail-rollout. The merchant policy is pushed toward actions that *win money* against the scripted Issuer + arbitration, not actions that match the heuristic. This is real RLVR (verifiable rewards) - the verifier is the dispute outcome.\n3. **Eval** every checkpoint against the heuristic baseline + naive baseline.\n\n**Why outcome reward over heuristic-match**: heuristic-match training is supervised distillation in disguise - the model can never beat the teacher and the reward is gameable by mimicry. Outcome reward is dollar-denominated, adversarially-verified by the Issuer, and ungameable: the only way to earn it is to actually win disputes.\n\n**Theme alignment**: Multi-Agent (merchant vs scripted Issuer) primary; Professional Tasks (B2B chargeback workflow) and Long-Horizon (multi-round arbitration) secondary.\n\nModel: `Qwen/Qwen2.5-3B-Instruct` fp16 + LoRA r=16. Fits T4 with no bitsandbytes."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  },
9
  {
10
  "cell_type": "markdown",
11
  "id": "setup-md",
12
  "metadata": {},
13
- "source": "## 0. Setup - install deps + clone repo"
 
 
14
  },
15
  {
16
  "cell_type": "code",
@@ -18,7 +34,7 @@
18
  "id": "setup-code",
19
  "metadata": {},
20
  "outputs": [],
21
- "source": "# GPU + repo setup for Colab T4 (also runs on Kaggle T4).\n# Pin the training stack to the combo that supports both:\n# - SFTTrainer.compute_loss without the shape-bug regression\n# - vanilla GRPOTrainer with reward_funcs returning per-completion floats\n# Upper bounds matter: Colab/Kaggle pre-install transformers 5.x + tokenizers\n# 0.22+ + huggingface-hub 1.x which break peft 0.14 and trl 0.21.\nimport os\nimport shutil\nimport subprocess\n\nif os.path.isdir('/content'):\n WORK_DIR = '/content'\nelif os.path.isdir('/kaggle/working'):\n WORK_DIR = '/kaggle/working'\nelse:\n raise RuntimeError('Notebook expects Colab or Kaggle. Set WORK_DIR manually otherwise.')\nos.chdir(WORK_DIR)\nDRIVE_ROOT = '/content/drive/MyDrive'\nPERSIST_ROOT = (\n os.path.join(DRIVE_ROOT, 'chargebackops-artifacts')\n if os.path.isdir(DRIVE_ROOT) else WORK_DIR\n)\nos.makedirs(PERSIST_ROOT, exist_ok=True)\nprint('work dir:', WORK_DIR)\nprint('artifact root:', PERSIST_ROOT)\nprint(subprocess.check_output(['nvidia-smi', '-L']).decode())\n\n# STEP 1 - torch trio for cu128. Turing T4 supports cu118+; cu128 wheels work.\nsubprocess.run(\n ['pip', 'install', '-q', '--no-cache-dir',\n 'torch==2.10.0', 'torchvision==0.25.0', 'torchaudio==2.10.0',\n '--index-url', 'https://download.pytorch.org/whl/cu128'],\n check=True, cwd=WORK_DIR,\n)\n\n# STEP 2a - force exact pins for the training stack. `--no-deps` keeps torch\n# untouched. tokenizers + huggingface-hub MUST be pinned explicitly because\n# transformers 4.51 declares ranges that resolve up to incompatible majors\n# when Colab/Kaggle has 0.22+ / 1.x preinstalled.\nsubprocess.run(\n ['pip', 'install', '-q', '--no-cache-dir', '--force-reinstall', '--no-deps',\n 'transformers==4.51.3',\n 'trl==0.21.0',\n 'peft==0.14.0',\n 'tokenizers==0.21.4',\n 'huggingface-hub==0.26.5'],\n check=True, cwd=WORK_DIR,\n)\n\n# STEP 2b - supporting libs with only-if-needed so torch + the pinned stack\n# stay put. accelerate >=1.0 plays nice with trl 0.21.\nsubprocess.run(\n ['pip', 'install', '-q', '--upgrade-strategy=only-if-needed',\n 'accelerate>=1.0,<2.0',\n 'datasets>=2.20,<4.0',\n 'matplotlib>=3.8',\n 'pydantic>=2.10',\n 'openenv-core>=0.2.2'],\n check=True, cwd=WORK_DIR,\n)\n\n# Clone repo (always fresh) so the editable install matches main.\nREPO_DIR = os.path.join(WORK_DIR, 'chargebackops')\nif os.path.isdir(REPO_DIR):\n shutil.rmtree(REPO_DIR)\nsubprocess.run(\n ['git', 'clone', '--depth', '1',\n 'https://github.com/MitudruDutta/ChargeBackOps.git', REPO_DIR],\n check=True, cwd=WORK_DIR,\n)\nos.chdir(REPO_DIR)\nprint('cwd:', os.getcwd())\n\n# Editable install with --no-deps so pyproject's app/server deps do not alter\n# the training stack we just pinned.\nsubprocess.run(['pip', 'install', '-q', '-e', '.', '--no-deps'],\n check=True, cwd=REPO_DIR)\n\n# Verify all critical pins land at the exact requested versions.\nimport importlib.metadata as md_\nprint('torch ', md_.version('torch'))\nprint('torchvision ', md_.version('torchvision'))\nprint('transformers ', md_.version('transformers'), '(want 4.51.3)')\nprint('tokenizers ', md_.version('tokenizers'), '(want 0.21.4)')\nprint('huggingface-hub', md_.version('huggingface-hub'), '(want 0.26.5)')\nprint('trl ', md_.version('trl'), '(want 0.21.0)')\nprint('peft ', md_.version('peft'), '(want 0.14.0)')\nprint('accelerate ', md_.version('accelerate'))\nprint('openenv-core ', md_.version('openenv-core'))\nassert md_.version('transformers') == '4.51.3', 'transformers pin failed'\nassert md_.version('trl') == '0.21.0', 'trl pin failed'\nassert md_.version('peft') == '0.14.0', 'peft pin failed'\nassert md_.version('tokenizers').startswith('0.21'), 'tokenizers pin failed'\nassert md_.version('huggingface-hub').startswith('0.26'), 'huggingface-hub pin failed'"
22
  },
23
  {
24
  "cell_type": "code",
@@ -26,13 +42,51 @@
26
  "id": "patch-code",
27
  "metadata": {},
28
  "outputs": [],
29
- "source": "# Path + module-cache flush so the editable install resolves before any other import.\nimport os, sys, importlib, logging, torch\nos.environ['TOKENIZERS_PARALLELISM'] = 'false'\n\n# Silence transformers per-layer \"Caching is incompatible...\" spam at scale\n# (every GRPO rollout fires it once per layer otherwise, hiding loss logs).\nimport transformers\ntransformers.logging.set_verbosity_error()\nlogging.getLogger('transformers').setLevel(logging.ERROR)\nlogging.getLogger('transformers.models.qwen2.modeling_qwen2').setLevel(logging.ERROR)\n\nREPO_DIR = globals().get('REPO_DIR') or (\n '/content/chargebackops' if os.path.isdir('/content/chargebackops')\n else '/kaggle/working/chargebackops'\n)\nPERSIST_ROOT = globals().get('PERSIST_ROOT') or (\n os.path.join('/content/drive/MyDrive', 'chargebackops-artifacts')\n if os.path.isdir('/content/drive/MyDrive') else os.path.dirname(REPO_DIR)\n)\nos.makedirs(PERSIST_ROOT, exist_ok=True)\nif not os.path.isdir(REPO_DIR):\n raise FileNotFoundError(f'Repository checkout missing at {REPO_DIR}. Run setup first.')\nsys.path.insert(0, REPO_DIR)\nimportlib.invalidate_caches()\nfor mod in list(sys.modules):\n if mod.startswith(('scenarios', 'training', 'evaluation', 'server', 'core', 'runners', 'connectors')):\n del sys.modules[mod]\nprint('torch', torch.__version__, '| cuda', torch.cuda.is_available(),\n torch.cuda.get_device_name(0) if torch.cuda.is_available() else '')\nprint('repo:', REPO_DIR)\nprint('artifact root:', PERSIST_ROOT)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  },
31
  {
32
  "cell_type": "markdown",
33
  "id": "model-md",
34
  "metadata": {},
35
- "source": "## 1. Load Qwen2.5-3B-Instruct fp16 + attach LoRA\n\n* `dtype=torch.float16` - T4 = Turing sm_75, no bf16 hardware.\n* LoRA r=16 on q/k/v/o + gate/up/down. ~9M trainable params.\n* `gradient_checkpointing` + `enable_input_require_grads` keeps activations off VRAM during backward."
 
 
 
 
 
 
36
  },
37
  {
38
  "cell_type": "code",
@@ -40,13 +94,55 @@
40
  "id": "model-code",
41
  "metadata": {},
42
  "outputs": [],
43
- "source": "from transformers import AutoModelForCausalLM, AutoTokenizer\nfrom peft import LoraConfig, get_peft_model\n\nMODEL_ID = os.environ.get('MODEL_ID', 'Qwen/Qwen2.5-3B-Instruct')\nprint('MODEL_ID:', MODEL_ID)\n\ntokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)\nif tokenizer.pad_token is None:\n tokenizer.pad_token = tokenizer.eos_token\ntokenizer.padding_side = 'left' # required for GRPO generation\n\nbase_model = AutoModelForCausalLM.from_pretrained(\n MODEL_ID,\n torch_dtype=torch.float16,\n device_map='auto',\n trust_remote_code=True,\n)\nbase_model.gradient_checkpointing_enable()\nbase_model.enable_input_require_grads()\n\nlora_target_modules = ['q_proj', 'k_proj', 'v_proj', 'o_proj',\n 'gate_proj', 'up_proj', 'down_proj']\nlora_rank = 16\nlora_alpha = 32\n\nlora_config = LoraConfig(\n r=lora_rank,\n lora_alpha=lora_alpha,\n target_modules=lora_target_modules,\n lora_dropout=0.05,\n bias='none',\n task_type='CAUSAL_LM',\n)\nmodel = get_peft_model(base_model, lora_config)\nmodel.print_trainable_parameters()\nprint(f'VRAM allocated: {torch.cuda.memory_allocated()/1e9:.2f} GB | '\n f'free: {torch.cuda.mem_get_info()[0]/1e9:.2f} GB')"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  },
45
  {
46
  "cell_type": "markdown",
47
  "id": "sft-md",
48
  "metadata": {},
49
- "source": "## 2. Phase A - SFT on heuristic rollouts\n\nBuilds (prompt, oracle_completion) pairs by rolling the scripted heuristic on every headline + generated task. Wraps in the Qwen chat template so the model learns the same prompt format used at inference. After this phase the model emits valid JSON with the right `action_type` per state - solves the *always emit `select_case`* collapse before GRPO ever runs."
 
 
 
 
50
  },
51
  {
52
  "cell_type": "code",
@@ -54,7 +150,79 @@
54
  "id": "sft-data-code",
55
  "metadata": {},
56
  "outputs": [],
57
- "source": "from datasets import Dataset\nfrom scenarios.simulation import list_tasks, get_task\nfrom training.sft_dataset import build_sft_dataset\nfrom collections import Counter\n\n# Synthetic pool. Default 10k rows for T4 speed; override via env var.\nSFT_TARGET_ROWS = int(os.environ.get('SFT_TARGET_ROWS', '10000'))\nSFT_MAX_ROWS = int(os.environ.get('SFT_MAX_ROWS', str(SFT_TARGET_ROWS)))\nSFT_SEED_START = int(os.environ.get('SFT_SEED_START', '1000'))\nSFT_SEED_BATCH = int(os.environ.get('SFT_SEED_BATCH', '128'))\nSFT_MAX_STATES_PER_TASK = int(os.environ.get('SFT_MAX_STATES_PER_TASK', '24'))\nGRPO_SEED_COUNT = int(os.environ.get('GRPO_SEED_COUNT', '160'))\n\n# Holdout seeds excluded from training so eval is defensible.\nHOLDOUT_SEEDS_BY_DIFF = {\n 'easy': {42},\n 'medium': {17, 99},\n 'hard': {7, 53},\n 'nightmare': {31, 77},\n}\nDIFFICULTIES = ['easy', 'medium', 'hard', 'nightmare']\n\nheadline_task_ids = [t.task_id for t in list_tasks()]\ntask_ids = list(headline_task_ids)\nraw_sft = build_sft_dataset(headline_task_ids, max_states_per_task=SFT_MAX_STATES_PER_TASK)\ngenerated_train_task_ids = []\n\nseed_cursor = SFT_SEED_START\nwhile len(raw_sft) < SFT_TARGET_ROWS:\n batch_task_ids = []\n for diff in DIFFICULTIES:\n blocked = HOLDOUT_SEEDS_BY_DIFF.get(diff, set())\n for seed in range(seed_cursor, seed_cursor + SFT_SEED_BATCH):\n if seed in blocked:\n continue\n tid = f'generated_{diff}_s{seed}'\n get_task(tid)\n batch_task_ids.append(tid)\n raw_sft.extend(build_sft_dataset(batch_task_ids, max_states_per_task=SFT_MAX_STATES_PER_TASK))\n generated_train_task_ids.extend(batch_task_ids)\n task_ids.extend(batch_task_ids)\n seed_cursor += SFT_SEED_BATCH\n print(f'generated SFT rows: {len(raw_sft):,} / target {SFT_TARGET_ROWS:,}')\n\nif len(raw_sft) > SFT_MAX_ROWS:\n raw_sft = raw_sft[:SFT_MAX_ROWS]\n\n# Seed list for GRPO state-action curriculum (smaller than SFT pool because\n# GRPO rollouts are slower than SFT forward passes).\nseeds = list(range(SFT_SEED_START, SFT_SEED_START + GRPO_SEED_COUNT))\n\ndef to_chat_text(prompt, completion):\n return tokenizer.apply_chat_template(\n [\n {'role': 'user', 'content': prompt},\n {'role': 'assistant', 'content': completion},\n ],\n tokenize=False,\n add_generation_prompt=False,\n )\n\nsft_rows = [{'text': to_chat_text(s['prompt'], s['completion'])} for s in raw_sft]\nsft_dataset = Dataset.from_list(sft_rows)\n\natype_counts = Counter(s['action_type'] for s in raw_sft)\nprint(f'SFT samples: {len(sft_dataset):,}, unique tasks: {len(set(s[\"task_id\"] for s in raw_sft)):,}')\nprint(f'headline tasks: {len(headline_task_ids)}, generated train tasks used: {len(generated_train_task_ids):,}')\nprint(f'excluded generated holdout seeds: {HOLDOUT_SEEDS_BY_DIFF}')\nprint(f'action_type distribution: {dict(atype_counts)}')\nprint('sample (first 500 chars):')\nprint(sft_rows[0]['text'][:500])"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  },
59
  {
60
  "cell_type": "code",
@@ -62,13 +230,70 @@
62
  "id": "sft-train-code",
63
  "metadata": {},
64
  "outputs": [],
65
- "source": "from trl import SFTConfig, SFTTrainer\n\nOUT_ROOT = PERSIST_ROOT\nSFT_DIR = os.path.join(OUT_ROOT, 'sft-merchant-agent')\nGRPO_DIR = os.path.join(OUT_ROOT, 'grpo-merchant-agent')\n\nSFT_FINAL_DIR = os.path.join(SFT_DIR, 'final')\nRUN_SFT_TRAIN = os.environ.get('RUN_SFT_TRAIN', 'auto').strip().lower()\nTRAIN_SFT = RUN_SFT_TRAIN in {'1', 'true', 'yes', 'y', 'on'} or (\n RUN_SFT_TRAIN == 'auto' and not os.path.isdir(SFT_FINAL_DIR)\n)\nSFT_EPOCHS = float(os.environ.get('SFT_EPOCHS', '1'))\nSFT_LR = float(os.environ.get('SFT_LR', '1e-4'))\nSFT_MAX_STEPS = int(os.environ.get('SFT_MAX_STEPS', '800'))\n\nif not TRAIN_SFT:\n print(f'Skipping SFT train; using existing adapter at {SFT_FINAL_DIR}')\nelse:\n sft_config = SFTConfig(\n output_dir=SFT_DIR,\n per_device_train_batch_size=1,\n gradient_accumulation_steps=8,\n num_train_epochs=SFT_EPOCHS,\n max_steps=SFT_MAX_STEPS,\n learning_rate=SFT_LR,\n logging_steps=10,\n save_steps=500,\n save_total_limit=2,\n bf16=False,\n fp16=True,\n gradient_checkpointing=True,\n gradient_checkpointing_kwargs={'use_reentrant': False},\n max_length=1024,\n dataset_text_field='text',\n report_to='none',\n optim='adamw_torch',\n warmup_ratio=0.03,\n )\n print(f'SFT config: rows={len(sft_dataset):,}, epochs={SFT_EPOCHS}, lr={SFT_LR}, max_steps={SFT_MAX_STEPS}')\n if hasattr(model, 'config'):\n model.config.use_cache = False\n sft_trainer = SFTTrainer(\n model=model,\n args=sft_config,\n train_dataset=sft_dataset,\n processing_class=tokenizer,\n )\n sft_trainer.train()\n sft_trainer.save_model(SFT_FINAL_DIR)\n del sft_trainer\n torch.cuda.empty_cache()\n print(f'PEAK VRAM (SFT): {torch.cuda.max_memory_allocated()/1e9:.2f} GB')"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  },
67
  {
68
  "cell_type": "markdown",
69
  "id": "merge-md",
70
  "metadata": {},
71
- "source": "## 2.5. Merge SFT LoRA into base, attach fresh LoRA for GRPO\n\n`accelerate.unwrap_model_for_generation` calls `merge_adapter()` + `unmerge_adapter()` around generation. With fp16 LoRA the round-trip can lose enough precision that completions degrade. Fix: bake SFT into base via `merge_and_unload()`, then attach a fresh zero-initialized LoRA. The fresh adapter starts as identity, so generation emits SFT-quality output regardless of TRL adapter toggling. GRPO then trains the fresh adapter on top."
 
 
 
 
72
  },
73
  {
74
  "cell_type": "code",
@@ -76,13 +301,84 @@
76
  "id": "merge-code",
77
  "metadata": {},
78
  "outputs": [],
79
- "source": "# Reload saved SFT LoRA into a fresh base, merge it, then attach a fresh Phase B LoRA.\nfrom peft import PeftModel\nimport gc\n\nif not os.path.isdir(SFT_FINAL_DIR):\n raise FileNotFoundError(f'Missing SFT adapter: {SFT_FINAL_DIR}. Run Phase A or upload the adapter first.')\n\nfor name in ['model', 'base_model', 'merged_base']:\n if name in globals():\n del globals()[name]\ngc.collect()\ntorch.cuda.empty_cache()\nprint(f'before SFT reload: VRAM {torch.cuda.memory_allocated()/1e9:.2f} GB')\n\nfresh_base = AutoModelForCausalLM.from_pretrained(\n MODEL_ID,\n torch_dtype=torch.float16,\n device_map='auto',\n trust_remote_code=True,\n)\nsft_model = PeftModel.from_pretrained(fresh_base, SFT_FINAL_DIR)\nmerged_base = sft_model.merge_and_unload()\ndel sft_model, fresh_base\ngc.collect()\ntorch.cuda.empty_cache()\nprint(f'after SFT merge: VRAM {torch.cuda.memory_allocated()/1e9:.2f} GB')\nmerged_base.enable_input_require_grads()\n\n# Sanity: SFT-baked base should emit clean JSON deterministically.\nfrom training.env_adapter import build_prompt\nfrom server.chargeback_ops_environment import ChargebackOpsEnvironment\nenv = ChargebackOpsEnvironment()\nobs = env.reset(task_id='goods_not_received_easy')\nchat = tokenizer.apply_chat_template(\n [{'role': 'user', 'content': build_prompt(obs.model_dump())}],\n tokenize=False, add_generation_prompt=True,\n)\ninp = tokenizer(chat, return_tensors='pt').to(merged_base.device)\nmerged_base.eval()\nwith torch.no_grad():\n out = merged_base.generate(\n **inp,\n max_new_tokens=160,\n do_sample=False,\n pad_token_id=tokenizer.eos_token_id,\n eos_token_id=tokenizer.eos_token_id,\n )\nprint('merged-base gen:', repr(tokenizer.decode(out[0][inp.input_ids.shape[1]:], skip_special_tokens=False)))\nmerged_base.train()\n\n# Attach fresh Phase B LoRA. lora_dropout=0 so generation can't be polluted by\n# random adapter contributions while train() mode is active.\nlora_phase_b = LoraConfig(\n r=lora_rank,\n lora_alpha=lora_alpha,\n target_modules=lora_target_modules,\n lora_dropout=0.0,\n bias='none',\n task_type='CAUSAL_LM',\n)\nmodel = get_peft_model(merged_base, lora_phase_b)\nmodel.enable_input_require_grads()\nmodel.print_trainable_parameters()\nprint(f'after fresh Phase B LoRA: VRAM {torch.cuda.memory_allocated()/1e9:.2f} GB')"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  },
81
  {
82
  "cell_type": "markdown",
83
  "id": "grpo-md",
84
  "metadata": {},
85
- "source": "## 3. Phase B - GRPO with outcome reward (RLVR, not distillation)\n\nReward source: terminal `$` PnL after the model's action plus heuristic tail-rollout. The merchant earns positive reward only when its action leads to a winning packet against the scripted Issuer or arbitration. A second format-shaping reward provides dense early-training signal so GRPO has gradient before the policy can produce winning packets.\n\nThis is the core of the v2 pivot: the trainer no longer optimizes \"match the heuristic.\" It optimizes \"win the dispute.\" The Issuer + arbitration are the verifier - they cannot be tricked because reward is dollar-denominated outcome."
 
 
 
 
 
 
86
  },
87
  {
88
  "cell_type": "code",
@@ -90,7 +386,62 @@
90
  "id": "grpo-data-code",
91
  "metadata": {},
92
  "outputs": [],
93
- "source": "from training.reward_adapter import build_state_action_dataset\n\n# State-action samples: (task_id, state_step, prompt) tuples captured by\n# rolling the heuristic forward on each task. The model will be asked to\n# pick the next action at each captured state.\nPHASE_B_MAX_STATES_PER_TASK = int(os.environ.get('PHASE_B_MAX_STATES_PER_TASK', '10'))\nGRPO_DIFFICULTIES = tuple(\n d.strip()\n for d in os.environ.get('GRPO_DIFFICULTIES', 'easy,medium,hard,nightmare').split(',')\n if d.strip()\n)\ncurriculum_task_ids = [\n t.task_id for t in list_tasks()\n if t.difficulty in GRPO_DIFFICULTIES\n]\nfor diff in GRPO_DIFFICULTIES:\n blocked = HOLDOUT_SEEDS_BY_DIFF.get(diff, set())\n for s in seeds:\n if s in blocked:\n continue\n tid = f'generated_{diff}_s{s}'\n try:\n get_task(tid)\n if tid not in curriculum_task_ids:\n curriculum_task_ids.append(tid)\n except Exception:\n pass\n\nraw_grpo = build_state_action_dataset(\n curriculum_task_ids, max_states_per_task=PHASE_B_MAX_STATES_PER_TASK,\n)\n\ndef to_chat_prompt(prompt):\n return tokenizer.apply_chat_template(\n [{'role': 'user', 'content': prompt}],\n tokenize=False, add_generation_prompt=True,\n )\n\ngrpo_rows = []\nfor sample in raw_grpo:\n chat_prompt = to_chat_prompt(sample['prompt'])\n n_tokens = len(tokenizer(chat_prompt, add_special_tokens=False)['input_ids'])\n if n_tokens <= 1000:\n grpo_rows.append({\n 'prompt': chat_prompt,\n 'task_id': sample['task_id'],\n 'state_step': int(sample['state_step']),\n })\n\ngrpo_dataset = Dataset.from_list(grpo_rows)\nunique_tasks = len({row['task_id'] for row in grpo_rows})\nprint(f'GRPO state-action samples: {len(grpo_dataset)}, '\n f'unique tasks: {unique_tasks}, difficulties={GRPO_DIFFICULTIES}, '\n f'max_states_per_task={PHASE_B_MAX_STATES_PER_TASK}')"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  },
95
  {
96
  "cell_type": "code",
@@ -98,13 +449,102 @@
98
  "id": "grpo-train-code",
99
  "metadata": {},
100
  "outputs": [],
101
- "source": "from trl import GRPOConfig, GRPOTrainer\nfrom training.outcome_reward import compute_outcome_reward, compute_format_reward\nimport torch.nn as nn\n\n# Re-arm hooks and disable cache/noisy stochastic modules.\nmodel.enable_input_require_grads()\nif hasattr(model, 'config'):\n model.config.use_cache = False\nmodel.config.eos_token_id = tokenizer.eos_token_id\nmodel.config.pad_token_id = tokenizer.pad_token_id\nif hasattr(model, 'generation_config'):\n model.generation_config.eos_token_id = tokenizer.eos_token_id\n model.generation_config.pad_token_id = tokenizer.pad_token_id\n\n# Belt-and-suspenders dropout zeroing in case any new Dropout slipped in.\nfor m in model.modules():\n if isinstance(m, nn.Dropout):\n m.p = 0.0\n\ndef outcome_reward_fn(prompts, completions, **kwargs):\n task_ids = kwargs.get('task_id') or kwargs.get('task_ids')\n state_steps = kwargs.get('state_step') or kwargs.get('state_steps')\n return compute_outcome_reward(\n prompts, completions,\n task_ids=task_ids, state_steps=state_steps,\n )\n\ndef format_reward_fn(prompts, completions, **kwargs):\n return compute_format_reward(prompts, completions)\n\n# 3B fp16 + LoRA + batch 1 x 8 grad-accum + num_generations=4 fits T4. Disabling\n# grad-checkpoint roughly halves per-step time at the cost of more activation\n# VRAM; SFT peaked at <8GB so there is headroom.\ngrpo_config = GRPOConfig(\n output_dir=GRPO_DIR,\n per_device_train_batch_size=1,\n gradient_accumulation_steps=8,\n num_generations=4,\n max_prompt_length=1024,\n max_completion_length=192,\n learning_rate=float(os.environ.get('GRPO_LR', '5e-6')),\n max_steps=int(os.environ.get('GRPO_MAX_STEPS', '120')),\n logging_steps=5,\n save_steps=40,\n save_total_limit=3,\n bf16=False,\n fp16=True,\n max_grad_norm=0.5,\n gradient_checkpointing=False,\n report_to='none',\n beta=0.0,\n # Tightened sampling defaults: TRL 0.21 GRPOConfig defaults to\n # top_p=1.0, top_k=0, repetition_penalty=1.0 - i.e. pure temperature\n # sampling on Qwen's 152K vocab. Even when the SFT policy puts 99% mass\n # on <|im_end|>, the 1% tail across 150K tokens is fat enough that\n # temp=0.7 sampling can pick a junk token. These match Qwen2.5's own\n # generation_config defaults.\n temperature=0.7,\n top_p=0.9,\n top_k=50,\n repetition_penalty=1.05,\n use_vllm=False,\n log_completions=True,\n num_completions_to_print=2,\n optim='adamw_torch',\n lr_scheduler_type='constant',\n)\n\nRUN_GRPO = os.environ.get('RUN_GRPO', '1').strip().lower() not in {'0', 'false', 'no'}\nif RUN_GRPO:\n grpo_trainer = GRPOTrainer(\n model=model,\n processing_class=tokenizer,\n reward_funcs=[outcome_reward_fn, format_reward_fn],\n args=grpo_config,\n train_dataset=grpo_dataset,\n )\n grpo_trainer.train()\n grpo_trainer.save_model(os.path.join(GRPO_DIR, 'final'))\n del grpo_trainer\n torch.cuda.empty_cache()\nelse:\n print('RUN_GRPO=0: skipped GRPO training')\nprint(f'PEAK VRAM (GRPO): {torch.cuda.max_memory_allocated()/1e9:.2f} GB')"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  },
103
  {
104
  "cell_type": "markdown",
105
  "id": "eval-md",
106
  "metadata": {},
107
- "source": "## 4. Per-checkpoint eval - overall + per-difficulty\n\nLoads each saved adapter, plays full episodes across the headline catalog, and plots the curve. Stall detection in `run_episode_with_text_policy` ensures degenerate checkpoints reach grading instead of returning 0."
 
 
 
 
108
  },
109
  {
110
  "cell_type": "code",
@@ -112,13 +552,107 @@
112
  "id": "eval-code",
113
  "metadata": {},
114
  "outputs": [],
115
- "source": "import glob, re\nfrom peft import PeftModel\nfrom training.curve import (\n evaluate_checkpoint, evaluate_checkpoint_by_family,\n plot_training_curve, plot_training_curve_by_family,\n)\n\ndef make_text_policy(adapter_path, adapter_kind='base'):\n base = AutoModelForCausalLM.from_pretrained(\n MODEL_ID, torch_dtype=torch.float16, device_map='auto', trust_remote_code=True,\n )\n if adapter_kind == 'grpo':\n sft = PeftModel.from_pretrained(base, SFT_FINAL_DIR)\n base = sft.merge_and_unload()\n m = PeftModel.from_pretrained(base, adapter_path)\n elif adapter_path is not None:\n m = PeftModel.from_pretrained(base, adapter_path)\n else:\n m = base\n m.eval()\n tok = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)\n if tok.pad_token is None:\n tok.pad_token = tok.eos_token\n tok.padding_side = 'left'\n\n def policy(prompt):\n chat = tok.apply_chat_template(\n [{'role': 'user', 'content': prompt}],\n tokenize=False, add_generation_prompt=True,\n )\n inputs = tok(chat, return_tensors='pt', truncation=True, max_length=1024).to(m.device)\n with torch.no_grad():\n out = m.generate(\n **inputs, max_new_tokens=192, do_sample=False,\n pad_token_id=tok.eos_token_id, eos_token_id=tok.eos_token_id,\n )\n return tok.decode(out[0][inputs['input_ids'].shape[1]:], skip_special_tokens=True)\n return policy, m\n\n# Catalog of checkpoints: untrained base, SFT final, every saved GRPO checkpoint.\nckpt_specs = [('base', None, 0, 'base'),\n ('sft', SFT_FINAL_DIR, 1, 'sft')]\ngrpo_dirs = sorted(\n glob.glob(os.path.join(GRPO_DIR, 'checkpoint-*')),\n key=lambda p: int(re.search(r'checkpoint-(\\d+)', p).group(1)),\n)\nfor d in grpo_dirs:\n step = int(re.search(r'checkpoint-(\\d+)', d).group(1))\n ckpt_specs.append((f'grpo-{step}', d, 1 + step, 'grpo'))\nfinal_grpo = os.path.join(GRPO_DIR, 'final')\nif os.path.isdir(final_grpo):\n ckpt_specs.append(('grpo-final', final_grpo, 1 + (max(int(re.search(r'checkpoint-(\\d+)', d).group(1)) for d in grpo_dirs) if grpo_dirs else 0) + 1, 'grpo'))\n\noverall = []\ngrouped = []\nfor label, path, step, kind in ckpt_specs:\n print(f'eval {label} from {path}')\n pol, m_ckpt = make_text_policy(path, kind)\n overall.append(evaluate_checkpoint(step=step, policy=pol))\n grouped.append(evaluate_checkpoint_by_family(step=step, policy=pol))\n del m_ckpt\n torch.cuda.empty_cache()\n\nprint('\\nOVERALL CURVE:')\nfor c in overall:\n print(f' step={c.step:4d} mean={c.mean_score:.4f}')\n\nprint('\\nPER-FAMILY CURVE:')\nfor g in grouped:\n line = f' step={g.step:4d}'\n for fam in sorted(g.by_family.keys()):\n line += f' {fam}={g.by_family[fam].mean_score:.3f}'\n print(line)\n\nfrom runners.benchmark_runner import run_policy_sweep\nsweep = run_policy_sweep()\nheur_overall = next(s.mean_score for s in sweep.policies if s.policy == 'heuristic')\n\nFIG_DIR = os.path.join(REPO_DIR, 'docs', 'figures')\nos.makedirs(FIG_DIR, exist_ok=True)\nplot_training_curve(\n overall, os.path.join(FIG_DIR, 'training_curve.png'),\n baseline_scores={'heuristic': heur_overall, 'naive': 0.0},\n)\nplot_training_curve_by_family(\n grouped, os.path.join(FIG_DIR, 'training_curve_by_family.png'),\n family_order=['easy', 'medium', 'hard', 'nightmare'],\n)\nprint(f'\\nfigures saved to {FIG_DIR}/')"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  },
117
  {
118
  "cell_type": "markdown",
119
  "id": "diag-md",
120
  "metadata": {},
121
- "source": "## 5. Diagnose final checkpoint\n\nPrint the trained checkpoint's completion vs. the heuristic oracle on three representative tasks (easy, hard, nightmare). Verifies the model emits valid JSON and shows what the trained policy actually does."
 
 
 
 
122
  },
123
  {
124
  "cell_type": "code",
@@ -126,13 +660,61 @@
126
  "id": "diag-code",
127
  "metadata": {},
128
  "outputs": [],
129
- "source": "from training.env_adapter import build_prompt, parse_completion\nfrom training.outcome_reward import compute_outcome_reward\nfrom server.chargeback_ops_environment import ChargebackOpsEnvironment\nfrom runners.benchmark_runner import heuristic_policy\n\nfinal_adapter = (\n os.path.join(GRPO_DIR, 'final')\n if os.path.isdir(os.path.join(GRPO_DIR, 'final'))\n else (grpo_dirs[-1] if grpo_dirs else SFT_FINAL_DIR)\n)\nfinal_kind = 'grpo' if 'grpo-merchant-agent' in final_adapter else 'sft'\nprint(f'diagnose adapter: {final_adapter}')\npolicy, m = make_text_policy(final_adapter, final_kind)\n\nfor tid in ['goods_not_received_easy', 'queue_optimization_hard', 'generated_nightmare_s31']:\n env = ChargebackOpsEnvironment()\n obs = env.reset(task_id=tid)\n raw = build_prompt(obs.model_dump())\n completion = policy(raw)\n parsed = parse_completion(completion)\n oracle = heuristic_policy(obs.model_dump())\n pnl = compute_outcome_reward(['x'], [completion], task_ids=[tid], state_steps=[0])[0]\n print(f'\\n=== {tid} ===')\n print(f'oracle: {oracle.action_type} case={oracle.case_id}')\n print(f'completion (first 200): {repr(completion[:200])}')\n print(f'parsed: {parsed}')\n print(f'outcome PnL (normalized): {pnl:+.3f}')\ndel m\ntorch.cuda.empty_cache()"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  },
131
  {
132
  "cell_type": "markdown",
133
  "id": "done-md",
134
  "metadata": {},
135
- "source": "## Done\n\nArtifacts written to `docs/figures/`:\n* `training_curve.png` - overall mean rubric score across SFT + GRPO checkpoints, with heuristic baseline.\n* `training_curve_by_family.png` - per-difficulty curves (easy / medium / hard / nightmare).\n\nAdapter weights (under `PERSIST_ROOT`):\n* `sft-merchant-agent/final/` - Phase A output.\n* `grpo-merchant-agent/final/` - Phase B output (GRPO with outcome reward).\n\nTo use the trained model:\n```python\nfrom peft import PeftModel\nsft_model = PeftModel.from_pretrained(base, 'sft-merchant-agent/final')\nmerged = sft_model.merge_and_unload()\ntrained = PeftModel.from_pretrained(merged, 'grpo-merchant-agent/final')\n```"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  }
137
  ],
138
  "metadata": {
 
4
  "cell_type": "markdown",
5
  "id": "intro-md",
6
  "metadata": {},
7
+ "source": [
8
+ "# ChargebackOps Merchant Agent - outcome-based RL on Qwen2.5-3B (fp16 LoRA)\n",
9
+ "\n",
10
+ "End-to-end pipeline for a single Colab T4 (or Kaggle T4):\n",
11
+ "\n",
12
+ "1. **Phase A - JSON SFT** on heuristic rollouts. Teaches the model the env action schema.\n",
13
+ "2. **Phase B - GRPO with outcome reward**. Reward = terminal `$` PnL after the model's action plus heuristic tail-rollout. The merchant policy is pushed toward actions that *win money* against the scripted Issuer + arbitration, not actions that match the heuristic. This is real RLVR (verifiable rewards) - the verifier is the dispute outcome.\n",
14
+ "3. **Eval** every checkpoint against the heuristic baseline + naive baseline.\n",
15
+ "\n",
16
+ "**Why outcome reward over heuristic-match**: heuristic-match training is supervised distillation in disguise - the model can never beat the teacher and the reward is gameable by mimicry. Outcome reward is dollar-denominated, adversarially-verified by the Issuer, and ungameable: the only way to earn it is to actually win disputes.\n",
17
+ "\n",
18
+ "**Theme alignment**: Multi-Agent (merchant vs scripted Issuer) primary; Professional Tasks (B2B chargeback workflow) and Long-Horizon (multi-round arbitration) secondary.\n",
19
+ "\n",
20
+ "Model: `Qwen/Qwen2.5-3B-Instruct` fp16 + LoRA r=16. Fits T4 with no bitsandbytes."
21
+ ]
22
  },
23
  {
24
  "cell_type": "markdown",
25
  "id": "setup-md",
26
  "metadata": {},
27
+ "source": [
28
+ "## 0. Setup - install deps + clone repo"
29
+ ]
30
  },
31
  {
32
  "cell_type": "code",
 
34
  "id": "setup-code",
35
  "metadata": {},
36
  "outputs": [],
37
+ "source": "# GPU + repo setup for Colab T4 (also runs on Kaggle T4).\n# Pin the training stack to the combo that supports both:\n# - SFTTrainer.compute_loss without the shape-bug regression\n# - vanilla GRPOTrainer with reward_funcs returning per-completion floats\n# Upper bounds matter: Colab/Kaggle pre-install transformers 5.x + tokenizers\n# 0.22+ + huggingface-hub 1.x which break peft 0.14 and trl 0.21.\nimport os\nimport shutil\nimport subprocess\n\nif os.path.isdir('/content'):\n WORK_DIR = '/content'\nelif os.path.isdir('/kaggle/working'):\n WORK_DIR = '/kaggle/working'\nelse:\n raise RuntimeError('Notebook expects Colab or Kaggle. Set WORK_DIR manually otherwise.')\nos.chdir(WORK_DIR)\nDRIVE_ROOT = '/content/drive/MyDrive'\nPERSIST_ROOT = (\n os.path.join(DRIVE_ROOT, 'chargebackops-artifacts')\n if os.path.isdir(DRIVE_ROOT) else WORK_DIR\n)\nos.makedirs(PERSIST_ROOT, exist_ok=True)\nprint('work dir:', WORK_DIR)\nprint('artifact root:', PERSIST_ROOT)\nprint(subprocess.check_output(['nvidia-smi', '-L']).decode())\n\n# STEP 1 - torch trio for cu128. Turing T4 supports cu118+; cu128 wheels work.\nsubprocess.run(\n ['pip', 'install', '-q', '--no-cache-dir',\n 'torch==2.10.0', 'torchvision==0.25.0', 'torchaudio==2.10.0',\n '--index-url', 'https://download.pytorch.org/whl/cu128'],\n check=True, cwd=WORK_DIR,\n)\n\n# STEP 2a - force exact pins for the training stack AND accelerate. `--no-deps`\n# keeps torch untouched. accelerate must be pinned here too because the latest\n# accelerate (1.13+) hard-requires huggingface-hub 1.x which breaks peft 0.14\n# and trl 0.21. accelerate 1.0.1 is the last version compatible with hub 0.26.x.\nsubprocess.run(\n ['pip', 'install', '-q', '--no-cache-dir', '--force-reinstall', '--no-deps',\n 'transformers==4.51.3',\n 'trl==0.21.0',\n 'peft==0.14.0',\n 'accelerate==1.0.1',\n 'tokenizers==0.21.4',\n 'huggingface-hub==0.26.5'],\n check=True, cwd=WORK_DIR,\n)\n\n# STEP 2b - supporting libs. `--upgrade-strategy=only-if-needed` keeps the\n# stack we just pinned in place (datasets/matplotlib/pydantic/openenv-core\n# don't pull hub or accelerate up).\nsubprocess.run(\n ['pip', 'install', '-q', '--upgrade-strategy=only-if-needed',\n 'datasets>=2.20,<4.0',\n 'matplotlib>=3.8',\n 'pydantic>=2.10',\n 'openenv-core>=0.2.2'],\n check=True, cwd=WORK_DIR,\n)\n\n# Clone repo (always fresh) so the editable install matches main.\nREPO_DIR = os.path.join(WORK_DIR, 'chargebackops')\nif os.path.isdir(REPO_DIR):\n shutil.rmtree(REPO_DIR)\nsubprocess.run(\n ['git', 'clone', '--depth', '1',\n 'https://github.com/MitudruDutta/ChargeBackOps.git', REPO_DIR],\n check=True, cwd=WORK_DIR,\n)\nos.chdir(REPO_DIR)\nprint('cwd:', os.getcwd())\n\n# Editable install with --no-deps so pyproject's app/server deps do not alter\n# the training stack we just pinned.\nsubprocess.run(['pip', 'install', '-q', '-e', '.', '--no-deps'],\n check=True, cwd=REPO_DIR)\n\n# Verify all critical pins land at the exact requested versions.\nimport importlib.metadata as md_\nprint('torch ', md_.version('torch'))\nprint('torchvision ', md_.version('torchvision'))\nprint('transformers ', md_.version('transformers'), '(want 4.51.3)')\nprint('tokenizers ', md_.version('tokenizers'), '(want 0.21.4)')\nprint('huggingface-hub', md_.version('huggingface-hub'), '(want 0.26.5)')\nprint('trl ', md_.version('trl'), '(want 0.21.0)')\nprint('peft ', md_.version('peft'), '(want 0.14.0)')\nprint('accelerate ', md_.version('accelerate'), '(want 1.0.1)')\nprint('openenv-core ', md_.version('openenv-core'))\nassert md_.version('transformers') == '4.51.3', 'transformers pin failed'\nassert md_.version('trl') == '0.21.0', 'trl pin failed'\nassert md_.version('peft') == '0.14.0', 'peft pin failed'\nassert md_.version('accelerate') == '1.0.1', 'accelerate pin failed'\nassert md_.version('tokenizers').startswith('0.21'), 'tokenizers pin failed'\nassert md_.version('huggingface-hub').startswith('0.26'), 'huggingface-hub pin failed'\n"
38
  },
39
  {
40
  "cell_type": "code",
 
42
  "id": "patch-code",
43
  "metadata": {},
44
  "outputs": [],
45
+ "source": [
46
+ "# Path + module-cache flush so the editable install resolves before any other import.\n",
47
+ "import os, sys, importlib, logging, torch\n",
48
+ "os.environ['TOKENIZERS_PARALLELISM'] = 'false'\n",
49
+ "\n",
50
+ "# Silence transformers per-layer \"Caching is incompatible...\" spam at scale\n",
51
+ "# (every GRPO rollout fires it once per layer otherwise, hiding loss logs).\n",
52
+ "import transformers\n",
53
+ "transformers.logging.set_verbosity_error()\n",
54
+ "logging.getLogger('transformers').setLevel(logging.ERROR)\n",
55
+ "logging.getLogger('transformers.models.qwen2.modeling_qwen2').setLevel(logging.ERROR)\n",
56
+ "\n",
57
+ "REPO_DIR = globals().get('REPO_DIR') or (\n",
58
+ " '/content/chargebackops' if os.path.isdir('/content/chargebackops')\n",
59
+ " else '/kaggle/working/chargebackops'\n",
60
+ ")\n",
61
+ "PERSIST_ROOT = globals().get('PERSIST_ROOT') or (\n",
62
+ " os.path.join('/content/drive/MyDrive', 'chargebackops-artifacts')\n",
63
+ " if os.path.isdir('/content/drive/MyDrive') else os.path.dirname(REPO_DIR)\n",
64
+ ")\n",
65
+ "os.makedirs(PERSIST_ROOT, exist_ok=True)\n",
66
+ "if not os.path.isdir(REPO_DIR):\n",
67
+ " raise FileNotFoundError(f'Repository checkout missing at {REPO_DIR}. Run setup first.')\n",
68
+ "sys.path.insert(0, REPO_DIR)\n",
69
+ "importlib.invalidate_caches()\n",
70
+ "for mod in list(sys.modules):\n",
71
+ " if mod.startswith(('scenarios', 'training', 'evaluation', 'server', 'core', 'runners', 'connectors')):\n",
72
+ " del sys.modules[mod]\n",
73
+ "print('torch', torch.__version__, '| cuda', torch.cuda.is_available(),\n",
74
+ " torch.cuda.get_device_name(0) if torch.cuda.is_available() else '')\n",
75
+ "print('repo:', REPO_DIR)\n",
76
+ "print('artifact root:', PERSIST_ROOT)"
77
+ ]
78
  },
79
  {
80
  "cell_type": "markdown",
81
  "id": "model-md",
82
  "metadata": {},
83
+ "source": [
84
+ "## 1. Load Qwen2.5-3B-Instruct fp16 + attach LoRA\n",
85
+ "\n",
86
+ "* `dtype=torch.float16` - T4 = Turing sm_75, no bf16 hardware.\n",
87
+ "* LoRA r=16 on q/k/v/o + gate/up/down. ~9M trainable params.\n",
88
+ "* `gradient_checkpointing` + `enable_input_require_grads` keeps activations off VRAM during backward."
89
+ ]
90
  },
91
  {
92
  "cell_type": "code",
 
94
  "id": "model-code",
95
  "metadata": {},
96
  "outputs": [],
97
+ "source": [
98
+ "from transformers import AutoModelForCausalLM, AutoTokenizer\n",
99
+ "from peft import LoraConfig, get_peft_model\n",
100
+ "\n",
101
+ "MODEL_ID = os.environ.get('MODEL_ID', 'Qwen/Qwen2.5-3B-Instruct')\n",
102
+ "print('MODEL_ID:', MODEL_ID)\n",
103
+ "\n",
104
+ "tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)\n",
105
+ "if tokenizer.pad_token is None:\n",
106
+ " tokenizer.pad_token = tokenizer.eos_token\n",
107
+ "tokenizer.padding_side = 'left' # required for GRPO generation\n",
108
+ "\n",
109
+ "base_model = AutoModelForCausalLM.from_pretrained(\n",
110
+ " MODEL_ID,\n",
111
+ " torch_dtype=torch.float16,\n",
112
+ " device_map='auto',\n",
113
+ " trust_remote_code=True,\n",
114
+ ")\n",
115
+ "base_model.gradient_checkpointing_enable()\n",
116
+ "base_model.enable_input_require_grads()\n",
117
+ "\n",
118
+ "lora_target_modules = ['q_proj', 'k_proj', 'v_proj', 'o_proj',\n",
119
+ " 'gate_proj', 'up_proj', 'down_proj']\n",
120
+ "lora_rank = 16\n",
121
+ "lora_alpha = 32\n",
122
+ "\n",
123
+ "lora_config = LoraConfig(\n",
124
+ " r=lora_rank,\n",
125
+ " lora_alpha=lora_alpha,\n",
126
+ " target_modules=lora_target_modules,\n",
127
+ " lora_dropout=0.05,\n",
128
+ " bias='none',\n",
129
+ " task_type='CAUSAL_LM',\n",
130
+ ")\n",
131
+ "model = get_peft_model(base_model, lora_config)\n",
132
+ "model.print_trainable_parameters()\n",
133
+ "print(f'VRAM allocated: {torch.cuda.memory_allocated()/1e9:.2f} GB | '\n",
134
+ " f'free: {torch.cuda.mem_get_info()[0]/1e9:.2f} GB')"
135
+ ]
136
  },
137
  {
138
  "cell_type": "markdown",
139
  "id": "sft-md",
140
  "metadata": {},
141
+ "source": [
142
+ "## 2. Phase A - SFT on heuristic rollouts\n",
143
+ "\n",
144
+ "Builds (prompt, oracle_completion) pairs by rolling the scripted heuristic on every headline + generated task. Wraps in the Qwen chat template so the model learns the same prompt format used at inference. After this phase the model emits valid JSON with the right `action_type` per state - solves the *always emit `select_case`* collapse before GRPO ever runs."
145
+ ]
146
  },
147
  {
148
  "cell_type": "code",
 
150
  "id": "sft-data-code",
151
  "metadata": {},
152
  "outputs": [],
153
+ "source": [
154
+ "from datasets import Dataset\n",
155
+ "from scenarios.simulation import list_tasks, get_task\n",
156
+ "from training.sft_dataset import build_sft_dataset\n",
157
+ "from collections import Counter\n",
158
+ "\n",
159
+ "# Synthetic pool. Default 10k rows for T4 speed; override via env var.\n",
160
+ "SFT_TARGET_ROWS = int(os.environ.get('SFT_TARGET_ROWS', '10000'))\n",
161
+ "SFT_MAX_ROWS = int(os.environ.get('SFT_MAX_ROWS', str(SFT_TARGET_ROWS)))\n",
162
+ "SFT_SEED_START = int(os.environ.get('SFT_SEED_START', '1000'))\n",
163
+ "SFT_SEED_BATCH = int(os.environ.get('SFT_SEED_BATCH', '128'))\n",
164
+ "SFT_MAX_STATES_PER_TASK = int(os.environ.get('SFT_MAX_STATES_PER_TASK', '24'))\n",
165
+ "GRPO_SEED_COUNT = int(os.environ.get('GRPO_SEED_COUNT', '160'))\n",
166
+ "\n",
167
+ "# Holdout seeds excluded from training so eval is defensible.\n",
168
+ "HOLDOUT_SEEDS_BY_DIFF = {\n",
169
+ " 'easy': {42},\n",
170
+ " 'medium': {17, 99},\n",
171
+ " 'hard': {7, 53},\n",
172
+ " 'nightmare': {31, 77},\n",
173
+ "}\n",
174
+ "DIFFICULTIES = ['easy', 'medium', 'hard', 'nightmare']\n",
175
+ "\n",
176
+ "headline_task_ids = [t.task_id for t in list_tasks()]\n",
177
+ "task_ids = list(headline_task_ids)\n",
178
+ "raw_sft = build_sft_dataset(headline_task_ids, max_states_per_task=SFT_MAX_STATES_PER_TASK)\n",
179
+ "generated_train_task_ids = []\n",
180
+ "\n",
181
+ "seed_cursor = SFT_SEED_START\n",
182
+ "while len(raw_sft) < SFT_TARGET_ROWS:\n",
183
+ " batch_task_ids = []\n",
184
+ " for diff in DIFFICULTIES:\n",
185
+ " blocked = HOLDOUT_SEEDS_BY_DIFF.get(diff, set())\n",
186
+ " for seed in range(seed_cursor, seed_cursor + SFT_SEED_BATCH):\n",
187
+ " if seed in blocked:\n",
188
+ " continue\n",
189
+ " tid = f'generated_{diff}_s{seed}'\n",
190
+ " get_task(tid)\n",
191
+ " batch_task_ids.append(tid)\n",
192
+ " raw_sft.extend(build_sft_dataset(batch_task_ids, max_states_per_task=SFT_MAX_STATES_PER_TASK))\n",
193
+ " generated_train_task_ids.extend(batch_task_ids)\n",
194
+ " task_ids.extend(batch_task_ids)\n",
195
+ " seed_cursor += SFT_SEED_BATCH\n",
196
+ " print(f'generated SFT rows: {len(raw_sft):,} / target {SFT_TARGET_ROWS:,}')\n",
197
+ "\n",
198
+ "if len(raw_sft) > SFT_MAX_ROWS:\n",
199
+ " raw_sft = raw_sft[:SFT_MAX_ROWS]\n",
200
+ "\n",
201
+ "# Seed list for GRPO state-action curriculum (smaller than SFT pool because\n",
202
+ "# GRPO rollouts are slower than SFT forward passes).\n",
203
+ "seeds = list(range(SFT_SEED_START, SFT_SEED_START + GRPO_SEED_COUNT))\n",
204
+ "\n",
205
+ "def to_chat_text(prompt, completion):\n",
206
+ " return tokenizer.apply_chat_template(\n",
207
+ " [\n",
208
+ " {'role': 'user', 'content': prompt},\n",
209
+ " {'role': 'assistant', 'content': completion},\n",
210
+ " ],\n",
211
+ " tokenize=False,\n",
212
+ " add_generation_prompt=False,\n",
213
+ " )\n",
214
+ "\n",
215
+ "sft_rows = [{'text': to_chat_text(s['prompt'], s['completion'])} for s in raw_sft]\n",
216
+ "sft_dataset = Dataset.from_list(sft_rows)\n",
217
+ "\n",
218
+ "atype_counts = Counter(s['action_type'] for s in raw_sft)\n",
219
+ "print(f'SFT samples: {len(sft_dataset):,}, unique tasks: {len(set(s[\"task_id\"] for s in raw_sft)):,}')\n",
220
+ "print(f'headline tasks: {len(headline_task_ids)}, generated train tasks used: {len(generated_train_task_ids):,}')\n",
221
+ "print(f'excluded generated holdout seeds: {HOLDOUT_SEEDS_BY_DIFF}')\n",
222
+ "print(f'action_type distribution: {dict(atype_counts)}')\n",
223
+ "print('sample (first 500 chars):')\n",
224
+ "print(sft_rows[0]['text'][:500])"
225
+ ]
226
  },
227
  {
228
  "cell_type": "code",
 
230
  "id": "sft-train-code",
231
  "metadata": {},
232
  "outputs": [],
233
+ "source": [
234
+ "from trl import SFTConfig, SFTTrainer\n",
235
+ "\n",
236
+ "OUT_ROOT = PERSIST_ROOT\n",
237
+ "SFT_DIR = os.path.join(OUT_ROOT, 'sft-merchant-agent')\n",
238
+ "GRPO_DIR = os.path.join(OUT_ROOT, 'grpo-merchant-agent')\n",
239
+ "\n",
240
+ "SFT_FINAL_DIR = os.path.join(SFT_DIR, 'final')\n",
241
+ "RUN_SFT_TRAIN = os.environ.get('RUN_SFT_TRAIN', 'auto').strip().lower()\n",
242
+ "TRAIN_SFT = RUN_SFT_TRAIN in {'1', 'true', 'yes', 'y', 'on'} or (\n",
243
+ " RUN_SFT_TRAIN == 'auto' and not os.path.isdir(SFT_FINAL_DIR)\n",
244
+ ")\n",
245
+ "SFT_EPOCHS = float(os.environ.get('SFT_EPOCHS', '1'))\n",
246
+ "SFT_LR = float(os.environ.get('SFT_LR', '1e-4'))\n",
247
+ "SFT_MAX_STEPS = int(os.environ.get('SFT_MAX_STEPS', '800'))\n",
248
+ "\n",
249
+ "if not TRAIN_SFT:\n",
250
+ " print(f'Skipping SFT train; using existing adapter at {SFT_FINAL_DIR}')\n",
251
+ "else:\n",
252
+ " sft_config = SFTConfig(\n",
253
+ " output_dir=SFT_DIR,\n",
254
+ " per_device_train_batch_size=1,\n",
255
+ " gradient_accumulation_steps=8,\n",
256
+ " num_train_epochs=SFT_EPOCHS,\n",
257
+ " max_steps=SFT_MAX_STEPS,\n",
258
+ " learning_rate=SFT_LR,\n",
259
+ " logging_steps=10,\n",
260
+ " save_steps=500,\n",
261
+ " save_total_limit=2,\n",
262
+ " bf16=False,\n",
263
+ " fp16=True,\n",
264
+ " gradient_checkpointing=True,\n",
265
+ " gradient_checkpointing_kwargs={'use_reentrant': False},\n",
266
+ " max_length=1024,\n",
267
+ " dataset_text_field='text',\n",
268
+ " report_to='none',\n",
269
+ " optim='adamw_torch',\n",
270
+ " warmup_ratio=0.03,\n",
271
+ " )\n",
272
+ " print(f'SFT config: rows={len(sft_dataset):,}, epochs={SFT_EPOCHS}, lr={SFT_LR}, max_steps={SFT_MAX_STEPS}')\n",
273
+ " if hasattr(model, 'config'):\n",
274
+ " model.config.use_cache = False\n",
275
+ " sft_trainer = SFTTrainer(\n",
276
+ " model=model,\n",
277
+ " args=sft_config,\n",
278
+ " train_dataset=sft_dataset,\n",
279
+ " processing_class=tokenizer,\n",
280
+ " )\n",
281
+ " sft_trainer.train()\n",
282
+ " sft_trainer.save_model(SFT_FINAL_DIR)\n",
283
+ " del sft_trainer\n",
284
+ " torch.cuda.empty_cache()\n",
285
+ " print(f'PEAK VRAM (SFT): {torch.cuda.max_memory_allocated()/1e9:.2f} GB')"
286
+ ]
287
  },
288
  {
289
  "cell_type": "markdown",
290
  "id": "merge-md",
291
  "metadata": {},
292
+ "source": [
293
+ "## 2.5. Merge SFT LoRA into base, attach fresh LoRA for GRPO\n",
294
+ "\n",
295
+ "`accelerate.unwrap_model_for_generation` calls `merge_adapter()` + `unmerge_adapter()` around generation. With fp16 LoRA the round-trip can lose enough precision that completions degrade. Fix: bake SFT into base via `merge_and_unload()`, then attach a fresh zero-initialized LoRA. The fresh adapter starts as identity, so generation emits SFT-quality output regardless of TRL adapter toggling. GRPO then trains the fresh adapter on top."
296
+ ]
297
  },
298
  {
299
  "cell_type": "code",
 
301
  "id": "merge-code",
302
  "metadata": {},
303
  "outputs": [],
304
+ "source": [
305
+ "# Reload saved SFT LoRA into a fresh base, merge it, then attach a fresh Phase B LoRA.\n",
306
+ "from peft import PeftModel\n",
307
+ "import gc\n",
308
+ "\n",
309
+ "if not os.path.isdir(SFT_FINAL_DIR):\n",
310
+ " raise FileNotFoundError(f'Missing SFT adapter: {SFT_FINAL_DIR}. Run Phase A or upload the adapter first.')\n",
311
+ "\n",
312
+ "for name in ['model', 'base_model', 'merged_base']:\n",
313
+ " if name in globals():\n",
314
+ " del globals()[name]\n",
315
+ "gc.collect()\n",
316
+ "torch.cuda.empty_cache()\n",
317
+ "print(f'before SFT reload: VRAM {torch.cuda.memory_allocated()/1e9:.2f} GB')\n",
318
+ "\n",
319
+ "fresh_base = AutoModelForCausalLM.from_pretrained(\n",
320
+ " MODEL_ID,\n",
321
+ " torch_dtype=torch.float16,\n",
322
+ " device_map='auto',\n",
323
+ " trust_remote_code=True,\n",
324
+ ")\n",
325
+ "sft_model = PeftModel.from_pretrained(fresh_base, SFT_FINAL_DIR)\n",
326
+ "merged_base = sft_model.merge_and_unload()\n",
327
+ "del sft_model, fresh_base\n",
328
+ "gc.collect()\n",
329
+ "torch.cuda.empty_cache()\n",
330
+ "print(f'after SFT merge: VRAM {torch.cuda.memory_allocated()/1e9:.2f} GB')\n",
331
+ "merged_base.enable_input_require_grads()\n",
332
+ "\n",
333
+ "# Sanity: SFT-baked base should emit clean JSON deterministically.\n",
334
+ "from training.env_adapter import build_prompt\n",
335
+ "from server.chargeback_ops_environment import ChargebackOpsEnvironment\n",
336
+ "env = ChargebackOpsEnvironment()\n",
337
+ "obs = env.reset(task_id='goods_not_received_easy')\n",
338
+ "chat = tokenizer.apply_chat_template(\n",
339
+ " [{'role': 'user', 'content': build_prompt(obs.model_dump())}],\n",
340
+ " tokenize=False, add_generation_prompt=True,\n",
341
+ ")\n",
342
+ "inp = tokenizer(chat, return_tensors='pt').to(merged_base.device)\n",
343
+ "merged_base.eval()\n",
344
+ "with torch.no_grad():\n",
345
+ " out = merged_base.generate(\n",
346
+ " **inp,\n",
347
+ " max_new_tokens=160,\n",
348
+ " do_sample=False,\n",
349
+ " pad_token_id=tokenizer.eos_token_id,\n",
350
+ " eos_token_id=tokenizer.eos_token_id,\n",
351
+ " )\n",
352
+ "print('merged-base gen:', repr(tokenizer.decode(out[0][inp.input_ids.shape[1]:], skip_special_tokens=False)))\n",
353
+ "merged_base.train()\n",
354
+ "\n",
355
+ "# Attach fresh Phase B LoRA. lora_dropout=0 so generation can't be polluted by\n",
356
+ "# random adapter contributions while train() mode is active.\n",
357
+ "lora_phase_b = LoraConfig(\n",
358
+ " r=lora_rank,\n",
359
+ " lora_alpha=lora_alpha,\n",
360
+ " target_modules=lora_target_modules,\n",
361
+ " lora_dropout=0.0,\n",
362
+ " bias='none',\n",
363
+ " task_type='CAUSAL_LM',\n",
364
+ ")\n",
365
+ "model = get_peft_model(merged_base, lora_phase_b)\n",
366
+ "model.enable_input_require_grads()\n",
367
+ "model.print_trainable_parameters()\n",
368
+ "print(f'after fresh Phase B LoRA: VRAM {torch.cuda.memory_allocated()/1e9:.2f} GB')"
369
+ ]
370
  },
371
  {
372
  "cell_type": "markdown",
373
  "id": "grpo-md",
374
  "metadata": {},
375
+ "source": [
376
+ "## 3. Phase B - GRPO with outcome reward (RLVR, not distillation)\n",
377
+ "\n",
378
+ "Reward source: terminal `$` PnL after the model's action plus heuristic tail-rollout. The merchant earns positive reward only when its action leads to a winning packet against the scripted Issuer or arbitration. A second format-shaping reward provides dense early-training signal so GRPO has gradient before the policy can produce winning packets.\n",
379
+ "\n",
380
+ "It optimizes \"win the dispute.\" The Issuer + arbitration are the verifier - they cannot be tricked because reward is dollar-denominated outcome."
381
+ ]
382
  },
383
  {
384
  "cell_type": "code",
 
386
  "id": "grpo-data-code",
387
  "metadata": {},
388
  "outputs": [],
389
+ "source": [
390
+ "from training.reward_adapter import build_state_action_dataset\n",
391
+ "\n",
392
+ "# State-action samples: (task_id, state_step, prompt) tuples captured by\n",
393
+ "# rolling the heuristic forward on each task. The model will be asked to\n",
394
+ "# pick the next action at each captured state.\n",
395
+ "PHASE_B_MAX_STATES_PER_TASK = int(os.environ.get('PHASE_B_MAX_STATES_PER_TASK', '10'))\n",
396
+ "GRPO_DIFFICULTIES = tuple(\n",
397
+ " d.strip()\n",
398
+ " for d in os.environ.get('GRPO_DIFFICULTIES', 'easy,medium,hard,nightmare').split(',')\n",
399
+ " if d.strip()\n",
400
+ ")\n",
401
+ "curriculum_task_ids = [\n",
402
+ " t.task_id for t in list_tasks()\n",
403
+ " if t.difficulty in GRPO_DIFFICULTIES\n",
404
+ "]\n",
405
+ "for diff in GRPO_DIFFICULTIES:\n",
406
+ " blocked = HOLDOUT_SEEDS_BY_DIFF.get(diff, set())\n",
407
+ " for s in seeds:\n",
408
+ " if s in blocked:\n",
409
+ " continue\n",
410
+ " tid = f'generated_{diff}_s{s}'\n",
411
+ " try:\n",
412
+ " get_task(tid)\n",
413
+ " if tid not in curriculum_task_ids:\n",
414
+ " curriculum_task_ids.append(tid)\n",
415
+ " except Exception:\n",
416
+ " pass\n",
417
+ "\n",
418
+ "raw_grpo = build_state_action_dataset(\n",
419
+ " curriculum_task_ids, max_states_per_task=PHASE_B_MAX_STATES_PER_TASK,\n",
420
+ ")\n",
421
+ "\n",
422
+ "def to_chat_prompt(prompt):\n",
423
+ " return tokenizer.apply_chat_template(\n",
424
+ " [{'role': 'user', 'content': prompt}],\n",
425
+ " tokenize=False, add_generation_prompt=True,\n",
426
+ " )\n",
427
+ "\n",
428
+ "grpo_rows = []\n",
429
+ "for sample in raw_grpo:\n",
430
+ " chat_prompt = to_chat_prompt(sample['prompt'])\n",
431
+ " n_tokens = len(tokenizer(chat_prompt, add_special_tokens=False)['input_ids'])\n",
432
+ " if n_tokens <= 1000:\n",
433
+ " grpo_rows.append({\n",
434
+ " 'prompt': chat_prompt,\n",
435
+ " 'task_id': sample['task_id'],\n",
436
+ " 'state_step': int(sample['state_step']),\n",
437
+ " })\n",
438
+ "\n",
439
+ "grpo_dataset = Dataset.from_list(grpo_rows)\n",
440
+ "unique_tasks = len({row['task_id'] for row in grpo_rows})\n",
441
+ "print(f'GRPO state-action samples: {len(grpo_dataset)}, '\n",
442
+ " f'unique tasks: {unique_tasks}, difficulties={GRPO_DIFFICULTIES}, '\n",
443
+ " f'max_states_per_task={PHASE_B_MAX_STATES_PER_TASK}')"
444
+ ]
445
  },
446
  {
447
  "cell_type": "code",
 
449
  "id": "grpo-train-code",
450
  "metadata": {},
451
  "outputs": [],
452
+ "source": [
453
+ "from trl import GRPOConfig, GRPOTrainer\n",
454
+ "from training.outcome_reward import compute_outcome_reward, compute_format_reward\n",
455
+ "import torch.nn as nn\n",
456
+ "\n",
457
+ "# Re-arm hooks and disable cache/noisy stochastic modules.\n",
458
+ "model.enable_input_require_grads()\n",
459
+ "if hasattr(model, 'config'):\n",
460
+ " model.config.use_cache = False\n",
461
+ "model.config.eos_token_id = tokenizer.eos_token_id\n",
462
+ "model.config.pad_token_id = tokenizer.pad_token_id\n",
463
+ "if hasattr(model, 'generation_config'):\n",
464
+ " model.generation_config.eos_token_id = tokenizer.eos_token_id\n",
465
+ " model.generation_config.pad_token_id = tokenizer.pad_token_id\n",
466
+ "\n",
467
+ "# Belt-and-suspenders dropout zeroing in case any new Dropout slipped in.\n",
468
+ "for m in model.modules():\n",
469
+ " if isinstance(m, nn.Dropout):\n",
470
+ " m.p = 0.0\n",
471
+ "\n",
472
+ "def outcome_reward_fn(prompts, completions, **kwargs):\n",
473
+ " task_ids = kwargs.get('task_id') or kwargs.get('task_ids')\n",
474
+ " state_steps = kwargs.get('state_step') or kwargs.get('state_steps')\n",
475
+ " return compute_outcome_reward(\n",
476
+ " prompts, completions,\n",
477
+ " task_ids=task_ids, state_steps=state_steps,\n",
478
+ " )\n",
479
+ "\n",
480
+ "def format_reward_fn(prompts, completions, **kwargs):\n",
481
+ " return compute_format_reward(prompts, completions)\n",
482
+ "\n",
483
+ "# 3B fp16 + LoRA + batch 1 x 8 grad-accum + num_generations=4 fits T4. Disabling\n",
484
+ "# grad-checkpoint roughly halves per-step time at the cost of more activation\n",
485
+ "# VRAM; SFT peaked at <8GB so there is headroom.\n",
486
+ "grpo_config = GRPOConfig(\n",
487
+ " output_dir=GRPO_DIR,\n",
488
+ " per_device_train_batch_size=1,\n",
489
+ " gradient_accumulation_steps=8,\n",
490
+ " num_generations=4,\n",
491
+ " max_prompt_length=1024,\n",
492
+ " max_completion_length=192,\n",
493
+ " learning_rate=float(os.environ.get('GRPO_LR', '5e-6')),\n",
494
+ " max_steps=int(os.environ.get('GRPO_MAX_STEPS', '120')),\n",
495
+ " logging_steps=5,\n",
496
+ " save_steps=40,\n",
497
+ " save_total_limit=3,\n",
498
+ " bf16=False,\n",
499
+ " fp16=True,\n",
500
+ " max_grad_norm=0.5,\n",
501
+ " gradient_checkpointing=False,\n",
502
+ " report_to='none',\n",
503
+ " beta=0.0,\n",
504
+ " # Tightened sampling defaults: TRL 0.21 GRPOConfig defaults to\n",
505
+ " # top_p=1.0, top_k=0, repetition_penalty=1.0 - i.e. pure temperature\n",
506
+ " # sampling on Qwen's 152K vocab. Even when the SFT policy puts 99% mass\n",
507
+ " # on <|im_end|>, the 1% tail across 150K tokens is fat enough that\n",
508
+ " # temp=0.7 sampling can pick a junk token. These match Qwen2.5's own\n",
509
+ " # generation_config defaults.\n",
510
+ " temperature=0.7,\n",
511
+ " top_p=0.9,\n",
512
+ " top_k=50,\n",
513
+ " repetition_penalty=1.05,\n",
514
+ " use_vllm=False,\n",
515
+ " log_completions=True,\n",
516
+ " num_completions_to_print=2,\n",
517
+ " optim='adamw_torch',\n",
518
+ " lr_scheduler_type='constant',\n",
519
+ ")\n",
520
+ "\n",
521
+ "RUN_GRPO = os.environ.get('RUN_GRPO', '1').strip().lower() not in {'0', 'false', 'no'}\n",
522
+ "if RUN_GRPO:\n",
523
+ " grpo_trainer = GRPOTrainer(\n",
524
+ " model=model,\n",
525
+ " processing_class=tokenizer,\n",
526
+ " reward_funcs=[outcome_reward_fn, format_reward_fn],\n",
527
+ " args=grpo_config,\n",
528
+ " train_dataset=grpo_dataset,\n",
529
+ " )\n",
530
+ " grpo_trainer.train()\n",
531
+ " grpo_trainer.save_model(os.path.join(GRPO_DIR, 'final'))\n",
532
+ " del grpo_trainer\n",
533
+ " torch.cuda.empty_cache()\n",
534
+ "else:\n",
535
+ " print('RUN_GRPO=0: skipped GRPO training')\n",
536
+ "print(f'PEAK VRAM (GRPO): {torch.cuda.max_memory_allocated()/1e9:.2f} GB')"
537
+ ]
538
  },
539
  {
540
  "cell_type": "markdown",
541
  "id": "eval-md",
542
  "metadata": {},
543
+ "source": [
544
+ "## 4. Per-checkpoint eval - overall + per-difficulty\n",
545
+ "\n",
546
+ "Loads each saved adapter, plays full episodes across the headline catalog, and plots the curve. Stall detection in `run_episode_with_text_policy` ensures degenerate checkpoints reach grading instead of returning 0."
547
+ ]
548
  },
549
  {
550
  "cell_type": "code",
 
552
  "id": "eval-code",
553
  "metadata": {},
554
  "outputs": [],
555
+ "source": [
556
+ "import glob, re\n",
557
+ "from peft import PeftModel\n",
558
+ "from training.curve import (\n",
559
+ " evaluate_checkpoint, evaluate_checkpoint_by_family,\n",
560
+ " plot_training_curve, plot_training_curve_by_family,\n",
561
+ ")\n",
562
+ "\n",
563
+ "def make_text_policy(adapter_path, adapter_kind='base'):\n",
564
+ " base = AutoModelForCausalLM.from_pretrained(\n",
565
+ " MODEL_ID, torch_dtype=torch.float16, device_map='auto', trust_remote_code=True,\n",
566
+ " )\n",
567
+ " if adapter_kind == 'grpo':\n",
568
+ " sft = PeftModel.from_pretrained(base, SFT_FINAL_DIR)\n",
569
+ " base = sft.merge_and_unload()\n",
570
+ " m = PeftModel.from_pretrained(base, adapter_path)\n",
571
+ " elif adapter_path is not None:\n",
572
+ " m = PeftModel.from_pretrained(base, adapter_path)\n",
573
+ " else:\n",
574
+ " m = base\n",
575
+ " m.eval()\n",
576
+ " tok = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)\n",
577
+ " if tok.pad_token is None:\n",
578
+ " tok.pad_token = tok.eos_token\n",
579
+ " tok.padding_side = 'left'\n",
580
+ "\n",
581
+ " def policy(prompt):\n",
582
+ " chat = tok.apply_chat_template(\n",
583
+ " [{'role': 'user', 'content': prompt}],\n",
584
+ " tokenize=False, add_generation_prompt=True,\n",
585
+ " )\n",
586
+ " inputs = tok(chat, return_tensors='pt', truncation=True, max_length=1024).to(m.device)\n",
587
+ " with torch.no_grad():\n",
588
+ " out = m.generate(\n",
589
+ " **inputs, max_new_tokens=192, do_sample=False,\n",
590
+ " pad_token_id=tok.eos_token_id, eos_token_id=tok.eos_token_id,\n",
591
+ " )\n",
592
+ " return tok.decode(out[0][inputs['input_ids'].shape[1]:], skip_special_tokens=True)\n",
593
+ " return policy, m\n",
594
+ "\n",
595
+ "# Catalog of checkpoints: untrained base, SFT final, every saved GRPO checkpoint.\n",
596
+ "ckpt_specs = [('base', None, 0, 'base'),\n",
597
+ " ('sft', SFT_FINAL_DIR, 1, 'sft')]\n",
598
+ "grpo_dirs = sorted(\n",
599
+ " glob.glob(os.path.join(GRPO_DIR, 'checkpoint-*')),\n",
600
+ " key=lambda p: int(re.search(r'checkpoint-(\\d+)', p).group(1)),\n",
601
+ ")\n",
602
+ "for d in grpo_dirs:\n",
603
+ " step = int(re.search(r'checkpoint-(\\d+)', d).group(1))\n",
604
+ " ckpt_specs.append((f'grpo-{step}', d, 1 + step, 'grpo'))\n",
605
+ "final_grpo = os.path.join(GRPO_DIR, 'final')\n",
606
+ "if os.path.isdir(final_grpo):\n",
607
+ " ckpt_specs.append(('grpo-final', final_grpo, 1 + (max(int(re.search(r'checkpoint-(\\d+)', d).group(1)) for d in grpo_dirs) if grpo_dirs else 0) + 1, 'grpo'))\n",
608
+ "\n",
609
+ "overall = []\n",
610
+ "grouped = []\n",
611
+ "for label, path, step, kind in ckpt_specs:\n",
612
+ " print(f'eval {label} from {path}')\n",
613
+ " pol, m_ckpt = make_text_policy(path, kind)\n",
614
+ " overall.append(evaluate_checkpoint(step=step, policy=pol))\n",
615
+ " grouped.append(evaluate_checkpoint_by_family(step=step, policy=pol))\n",
616
+ " del m_ckpt\n",
617
+ " torch.cuda.empty_cache()\n",
618
+ "\n",
619
+ "print('\\nOVERALL CURVE:')\n",
620
+ "for c in overall:\n",
621
+ " print(f' step={c.step:4d} mean={c.mean_score:.4f}')\n",
622
+ "\n",
623
+ "print('\\nPER-FAMILY CURVE:')\n",
624
+ "for g in grouped:\n",
625
+ " line = f' step={g.step:4d}'\n",
626
+ " for fam in sorted(g.by_family.keys()):\n",
627
+ " line += f' {fam}={g.by_family[fam].mean_score:.3f}'\n",
628
+ " print(line)\n",
629
+ "\n",
630
+ "from runners.benchmark_runner import run_policy_sweep\n",
631
+ "sweep = run_policy_sweep()\n",
632
+ "heur_overall = next(s.mean_score for s in sweep.policies if s.policy == 'heuristic')\n",
633
+ "\n",
634
+ "FIG_DIR = os.path.join(REPO_DIR, 'docs', 'figures')\n",
635
+ "os.makedirs(FIG_DIR, exist_ok=True)\n",
636
+ "plot_training_curve(\n",
637
+ " overall, os.path.join(FIG_DIR, 'training_curve.png'),\n",
638
+ " baseline_scores={'heuristic': heur_overall, 'naive': 0.0},\n",
639
+ ")\n",
640
+ "plot_training_curve_by_family(\n",
641
+ " grouped, os.path.join(FIG_DIR, 'training_curve_by_family.png'),\n",
642
+ " family_order=['easy', 'medium', 'hard', 'nightmare'],\n",
643
+ ")\n",
644
+ "print(f'\\nfigures saved to {FIG_DIR}/')"
645
+ ]
646
  },
647
  {
648
  "cell_type": "markdown",
649
  "id": "diag-md",
650
  "metadata": {},
651
+ "source": [
652
+ "## 5. Diagnose final checkpoint\n",
653
+ "\n",
654
+ "Print the trained checkpoint's completion vs. the heuristic oracle on three representative tasks (easy, hard, nightmare). Verifies the model emits valid JSON and shows what the trained policy actually does."
655
+ ]
656
  },
657
  {
658
  "cell_type": "code",
 
660
  "id": "diag-code",
661
  "metadata": {},
662
  "outputs": [],
663
+ "source": [
664
+ "from training.env_adapter import build_prompt, parse_completion\n",
665
+ "from training.outcome_reward import compute_outcome_reward\n",
666
+ "from server.chargeback_ops_environment import ChargebackOpsEnvironment\n",
667
+ "from runners.benchmark_runner import heuristic_policy\n",
668
+ "\n",
669
+ "final_adapter = (\n",
670
+ " os.path.join(GRPO_DIR, 'final')\n",
671
+ " if os.path.isdir(os.path.join(GRPO_DIR, 'final'))\n",
672
+ " else (grpo_dirs[-1] if grpo_dirs else SFT_FINAL_DIR)\n",
673
+ ")\n",
674
+ "final_kind = 'grpo' if 'grpo-merchant-agent' in final_adapter else 'sft'\n",
675
+ "print(f'diagnose adapter: {final_adapter}')\n",
676
+ "policy, m = make_text_policy(final_adapter, final_kind)\n",
677
+ "\n",
678
+ "for tid in ['goods_not_received_easy', 'queue_optimization_hard', 'generated_nightmare_s31']:\n",
679
+ " env = ChargebackOpsEnvironment()\n",
680
+ " obs = env.reset(task_id=tid)\n",
681
+ " raw = build_prompt(obs.model_dump())\n",
682
+ " completion = policy(raw)\n",
683
+ " parsed = parse_completion(completion)\n",
684
+ " oracle = heuristic_policy(obs.model_dump())\n",
685
+ " pnl = compute_outcome_reward(['x'], [completion], task_ids=[tid], state_steps=[0])[0]\n",
686
+ " print(f'\\n=== {tid} ===')\n",
687
+ " print(f'oracle: {oracle.action_type} case={oracle.case_id}')\n",
688
+ " print(f'completion (first 200): {repr(completion[:200])}')\n",
689
+ " print(f'parsed: {parsed}')\n",
690
+ " print(f'outcome PnL (normalized): {pnl:+.3f}')\n",
691
+ "del m\n",
692
+ "torch.cuda.empty_cache()"
693
+ ]
694
  },
695
  {
696
  "cell_type": "markdown",
697
  "id": "done-md",
698
  "metadata": {},
699
+ "source": [
700
+ "## Done\n",
701
+ "\n",
702
+ "Artifacts written to `docs/figures/`:\n",
703
+ "* `training_curve.png` - overall mean rubric score across SFT + GRPO checkpoints, with heuristic baseline.\n",
704
+ "* `training_curve_by_family.png` - per-difficulty curves (easy / medium / hard / nightmare).\n",
705
+ "\n",
706
+ "Adapter weights (under `PERSIST_ROOT`):\n",
707
+ "* `sft-merchant-agent/final/` - Phase A output.\n",
708
+ "* `grpo-merchant-agent/final/` - Phase B output (GRPO with outcome reward).\n",
709
+ "\n",
710
+ "To use the trained model:\n",
711
+ "```python\n",
712
+ "from peft import PeftModel\n",
713
+ "sft_model = PeftModel.from_pretrained(base, 'sft-merchant-agent/final')\n",
714
+ "merged = sft_model.merge_and_unload()\n",
715
+ "trained = PeftModel.from_pretrained(merged, 'grpo-merchant-agent/final')\n",
716
+ "```"
717
+ ]
718
  }
719
  ],
720
  "metadata": {