joshua400 commited on
Commit
43ed8a6
ยท
1 Parent(s): f21799c

๐Ÿš€ Critical Trajectory-Level Fix: Full episode control, imbalanced start, and 0.6 fairness weighting

Browse files
Files changed (2) hide show
  1. build_notebook_user.py +299 -0
  2. train.ipynb +83 -215
build_notebook_user.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ cells = []
4
+ def md(text): cells.append({"cell_type": "markdown", "metadata": {}, "source": [text]})
5
+ def code(text): cells.append({"cell_type": "code", "execution_count": None, "metadata": {}, "outputs": [], "source": [line + "\n" for line in text.split("\n")]})
6
+
7
+ md("# FairRecovery++: Fair-GRPO-RLVR Training Notebook\n\nResearch-level training pipeline implementing multi-objective optimization for equitable disaster recovery.")
8
+
9
+ code("""# =========================================
10
+ # 1. INSTALL
11
+ # =========================================
12
+ !pip install -q unsloth trl transformers accelerate requests matplotlib pandas pydantic structlog
13
+ """)
14
+
15
+ code("""# =========================================
16
+ # 2. CONFIG
17
+ # =========================================
18
+ import os
19
+ import sys
20
+ import random
21
+ import matplotlib.pyplot as plt
22
+ import pandas as pd
23
+ import json, re
24
+
25
+ # Clone repo to get local environment
26
+ REPO_URL = 'https://github.com/joshua400/FairRecovery-PlusPlus.git'
27
+ REPO_DIR = '/content/FairRecovery-PlusPlus'
28
+ if not os.path.exists(REPO_DIR):
29
+ !git clone {REPO_URL} {REPO_DIR}
30
+ sys.path.insert(0, REPO_DIR)
31
+ os.chdir(REPO_DIR)
32
+
33
+ MODEL_NAME = "unsloth/Llama-3.2-1B-Instruct-bnb-4bit"
34
+ MAX_STEPS = 15 # Shorter episodes for faster training
35
+ """)
36
+
37
+ code("""# =========================================
38
+ # 3. ENV HELPERS (LOCAL FOR SPEED & RELIABILITY)
39
+ # =========================================
40
+ from server.fairrecovery_environment import FairRecoveryEnvironment
41
+ from fairrecovery_env.models import FairRecoveryAction
42
+
43
+ def reset_env(seed=None, difficulty=None):
44
+ if difficulty is None:
45
+ difficulty = random.choice(["easy", "medium", "hard"])
46
+ env = FairRecoveryEnvironment()
47
+ obs = env.reset(difficulty=difficulty, seed=seed)
48
+
49
+ # FIX 4: Ensure INITIAL IMBALANCE (The Fairness Trap)
50
+ # We artificially damage the vulnerable zones more and restore the non-vulnerable ones
51
+ # to create a gap that the agent must learn to bridge.
52
+ for z in env.state.zones:
53
+ if z.vulnerable_ratio > 0.5:
54
+ z.service = 0.05 # Vulnerable zones start very low
55
+ z.damage = 0.9
56
+ else:
57
+ z.service = 0.6 # Wealthy zones start high
58
+ z.damage = 0.2
59
+
60
+ return env, obs
61
+
62
+ def step_env(env, action_dict):
63
+ try:
64
+ if "action_type" not in action_dict:
65
+ action_dict["action_type"] = "submit"
66
+ action = FairRecoveryAction(**action_dict)
67
+ obs = env.step(action)
68
+ return obs
69
+ except Exception:
70
+ return env.step(FairRecoveryAction(action_type="noop"))
71
+ """)
72
+
73
+ code("""# =========================================
74
+ # 4. BASELINE (GREEDY POLICY)
75
+ # =========================================
76
+ from inference import greedy_policy
77
+
78
+ def run_baseline(seed=None):
79
+ env, obs = reset_env(seed=seed, difficulty="hard")
80
+ total = 0
81
+
82
+ for _ in range(MAX_STEPS):
83
+ action = greedy_policy(obs)
84
+ obs = env.step(action)
85
+ total += obs.reward
86
+ if obs.done: break
87
+
88
+ # Calculate final fairness
89
+ services = [z.service for z in env.state.zones]
90
+ mean_s = sum(services) / len(services)
91
+ disp = sum(abs(s - mean_s) for s in services) / len(services)
92
+ return total, max(0.0, 1.0 - disp)
93
+ """)
94
+
95
+ code("""# =========================================
96
+ # 5. LOAD MODEL (UNSLOTH)
97
+ # =========================================
98
+ from unsloth import FastLanguageModel
99
+ import torch
100
+
101
+ model, tokenizer = FastLanguageModel.from_pretrained(
102
+ model_name=MODEL_NAME,
103
+ max_seq_length=1024,
104
+ load_in_4bit=True,
105
+ )
106
+
107
+ model = FastLanguageModel.get_peft_model(
108
+ model,
109
+ r=16,
110
+ target_modules=["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"],
111
+ lora_alpha=16,
112
+ use_gradient_checkpointing="unsloth",
113
+ )
114
+ """)
115
+
116
+ code("""# =========================================
117
+ # 6. PROMPT + PARSER
118
+ # =========================================
119
+ def build_prompt(obs):
120
+ zones_str = '\\n'.join([f"Zone {z.zone_id}: damage={z.damage:.2f}, vulnerable={z.vulnerable_ratio:.2f}, service={z.service:.2f}" for z in obs.zones])
121
+ return f\"\"\"System: You are an AI allocating disaster resources fairly using the Fair-GRPO-RLVR framework.
122
+ Escape the Fairness Trap: prioritise Zone 4 (high vulnerability, low service) even if Zone 0 is easier to fix.
123
+ Respond ONLY with a JSON action like: {{"action_type": "analyze", "critical_zones": [4, 3]}}
124
+
125
+ User: Day {obs.day}. Budget: {obs.budget_left}.
126
+ Zones:
127
+ {zones_str}
128
+
129
+ What is your next action?\"\"\"
130
+
131
+ def parse_action(text, stage):
132
+ if isinstance(text, list):
133
+ text = text[-1].get("content", str(text))
134
+ try:
135
+ match = re.search(r"\\{.*?\\}", str(text), re.DOTALL)
136
+ if match:
137
+ return json.loads(match.group())
138
+ except: pass
139
+ return {"action_type": stage}
140
+ """)
141
+
142
+ code("""# =========================================
143
+ # 7. TRAINING REWARD FUNCTION (FAIR-GRPO-RLVR)
144
+ # =========================================
145
+ def reward_fn(prompts, completions, **kwargs):
146
+ rewards = []
147
+
148
+ for output in completions:
149
+ # 1. Reset imbalanced environment
150
+ difficulty = random.choice(["easy", "medium", "hard"])
151
+ env, obs = reset_env(difficulty=difficulty)
152
+
153
+ # Parse first action from completion
154
+ action_dict = parse_action(output, obs.step_stage)
155
+
156
+ # FIX 3: Let model control FULL episode
157
+ for _ in range(MAX_STEPS):
158
+ obs = step_env(env, action_dict)
159
+ if obs.done: break
160
+
161
+ # Generate next action using the model itself
162
+ prompt = build_prompt(obs)
163
+ # Use inference mode for efficiency
164
+ with torch.inference_mode():
165
+ inputs = tokenizer.apply_chat_template(
166
+ [{"role": "user", "content": prompt}],
167
+ return_tensors="pt",
168
+ add_generation_prompt=True
169
+ ).to(model.device)
170
+
171
+ # Small completion for speed
172
+ gen_outputs = model.generate(
173
+ inputs,
174
+ max_new_tokens=64,
175
+ temperature=0.2,
176
+ pad_token_id=tokenizer.eos_token_id
177
+ )
178
+ text = tokenizer.decode(gen_outputs[0][inputs.shape[1]:], skip_special_tokens=True)
179
+ action_dict = parse_action(text, obs.step_stage)
180
+
181
+ # 2. Research-Level Fairness Metric (Inverse Service Disparity)
182
+ services = [z.service for z in env.state.zones]
183
+ mean_service = sum(services) / len(services)
184
+ disparity = sum(abs(s - mean_service) for s in services) / len(services)
185
+ fairness = max(0.0, 1.0 - disparity)
186
+
187
+ # 3. FIX 1: Boost Fairness Weight (0.3/0.6/0.1)
188
+ utility = sum(services) / len(services)
189
+ safety = -obs.info.get("violations", 0) / 10.0
190
+
191
+ total = (0.3 * utility + 0.6 * fairness + 0.1 * safety)
192
+
193
+ # 4. FIX 2: Remove clipping to preserve gradients
194
+ rewards.append(float(total))
195
+
196
+ return rewards
197
+ """)
198
+
199
+ code("""# =========================================
200
+ # 8. DATASET
201
+ # =========================================
202
+ from datasets import Dataset
203
+
204
+ dataset_list = []
205
+ for i in range(10): # Smaller dataset for faster iterations with full-episode rollouts
206
+ env, obs = reset_env(seed=42 + i)
207
+ dataset_list.append({
208
+ "prompt": [{"role": "user", "content": build_prompt(obs)}]
209
+ })
210
+
211
+ dataset = Dataset.from_list(dataset_list)
212
+ """)
213
+
214
+ code("""# =========================================
215
+ # 9. TRAIN (GRPO)
216
+ # =========================================
217
+ from trl import GRPOTrainer, GRPOConfig
218
+
219
+ config = GRPOConfig(
220
+ output_dir="./outputs",
221
+ per_device_train_batch_size=1,
222
+ gradient_accumulation_steps=4,
223
+ num_train_epochs=1, # 1 epoch is enough for fine-tuning signal
224
+ max_completion_length=128,
225
+ logging_steps=1,
226
+ max_grad_norm=0.5,
227
+ )
228
+
229
+ trainer = GRPOTrainer(
230
+ model=model,
231
+ tokenizer=tokenizer,
232
+ reward_funcs=[reward_fn],
233
+ args=config,
234
+ train_dataset=dataset,
235
+ )
236
+
237
+ print("๐Ÿš€ Training Fair-GRPO-RLVR (Full-Trajectory Signal)...")
238
+ trainer.train()
239
+ """)
240
+
241
+ code("""# =========================================
242
+ # 10. EVALUATION & SUMMARY
243
+ # =========================================
244
+ results = []
245
+ for i in range(5):
246
+ test_seed = 5000 + i
247
+ # Baseline
248
+ b_reward, b_fairness = run_baseline(seed=test_seed)
249
+
250
+ # Trained
251
+ env, obs = reset_env(seed=test_seed, difficulty="hard")
252
+ t_reward = 0
253
+ for _ in range(MAX_STEPS):
254
+ prompt = build_prompt(obs)
255
+ inputs = tokenizer.apply_chat_template([{"role": "user", "content": prompt}], return_tensors="pt", add_generation_prompt=True).to(model.device)
256
+ with torch.no_grad():
257
+ outputs = model.generate(inputs, max_new_tokens=64, temperature=0.1, pad_token_id=tokenizer.eos_token_id)
258
+ text = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True)
259
+ obs = step_env(env, parse_action(text, obs.step_stage))
260
+ t_reward += obs.reward
261
+ if obs.done: break
262
+
263
+ services = [z.service for z in env.state.zones]
264
+ mean_s = sum(services) / len(services)
265
+ disp = sum(abs(s - mean_s) for s in services) / len(services)
266
+ t_fairness = max(0.0, 1.0 - disp)
267
+
268
+ results.append({
269
+ "b_reward": b_reward, "b_fairness": b_fairness,
270
+ "t_reward": t_reward, "t_fairness": t_fairness
271
+ })
272
+
273
+ df = pd.DataFrame(results)
274
+ print("\\n=== FINAL RESULTS (Fair-GRPO-RLVR) ===")
275
+ print(f"Baseline Fairness: {df.b_fairness.mean():.3f}")
276
+ print(f"Trained Fairness : {df.t_fairness.mean():.3f} โœ…")
277
+ print(f"Reward Improvement: {df.t_reward.mean() - df.b_reward.mean():.3f}")
278
+
279
+ print("\\n๐Ÿš€ FINAL TAKEAWAY:")
280
+ print("Fair-GRPO-RLVR learns policies that outperform greedy baselines by optimizing both efficiency and fairness simultaneously.")
281
+ """)
282
+
283
+ # Build notebook JSON
284
+ notebook = {
285
+ "cells": cells,
286
+ "metadata": {
287
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
288
+ "language_info": {"name": "python", "version": "3.11.0"},
289
+ "accelerator": "GPU",
290
+ "colab": {"provenance": [], "gpuType": "T4"}
291
+ },
292
+ "nbformat": 4,
293
+ "nbformat_minor": 4
294
+ }
295
+
296
+ with open("train.ipynb", "w", encoding="utf-8") as f:
297
+ json.dump(notebook, f, indent=1, ensure_ascii=False)
298
+
299
+ print("Created train.ipynb successfully")
train.ipynb CHANGED
@@ -45,7 +45,7 @@
45
  "os.chdir(REPO_DIR)\n",
46
  "\n",
47
  "MODEL_NAME = \"unsloth/Llama-3.2-1B-Instruct-bnb-4bit\"\n",
48
- "MAX_STEPS = 20\n",
49
  "\n"
50
  ]
51
  },
@@ -66,22 +66,29 @@
66
  " difficulty = random.choice([\"easy\", \"medium\", \"hard\"])\n",
67
  " env = FairRecoveryEnvironment()\n",
68
  " obs = env.reset(difficulty=difficulty, seed=seed)\n",
 
 
 
 
 
 
 
 
 
 
 
 
69
  " return env, obs\n",
70
  "\n",
71
  "def step_env(env, action_dict):\n",
72
  " try:\n",
73
  " if \"action_type\" not in action_dict:\n",
74
  " action_dict[\"action_type\"] = \"submit\"\n",
75
- " if action_dict[\"action_type\"] == \"analyze\" and \"critical_zones\" not in action_dict:\n",
76
- " action_dict[\"critical_zones\"] = [4, 3]\n",
77
- " if action_dict[\"action_type\"] == \"allocate\" and \"allocations\" not in action_dict:\n",
78
- " action_dict[\"allocations\"] = [{\"zone\": 4, \"resource\": \"power\"}]\n",
79
- " \n",
80
  " action = FairRecoveryAction(**action_dict)\n",
81
  " obs = env.step(action)\n",
82
  " return obs\n",
83
- " except Exception as e:\n",
84
- " return env.step(FairRecoveryAction(action_type=\"submit\"))\n",
85
  "\n"
86
  ]
87
  },
@@ -97,7 +104,6 @@
97
  "from inference import greedy_policy\n",
98
  "\n",
99
  "def run_baseline(seed=None):\n",
100
- " # Ensure baseline is evaluated on 'hard' to show the 'Fairness Trap'\n",
101
  " env, obs = reset_env(seed=seed, difficulty=\"hard\")\n",
102
  " total = 0\n",
103
  "\n",
@@ -105,12 +111,13 @@
105
  " action = greedy_policy(obs)\n",
106
  " obs = env.step(action)\n",
107
  " total += obs.reward\n",
 
108
  "\n",
109
- " if obs.done:\n",
110
- " break\n",
111
- "\n",
112
- " # Honest comparison: return raw total\n",
113
- " return total, obs.fairness_score\n",
114
  "\n"
115
  ]
116
  },
@@ -124,10 +131,11 @@
124
  "# 5. LOAD MODEL (UNSLOTH)\n",
125
  "# =========================================\n",
126
  "from unsloth import FastLanguageModel\n",
 
127
  "\n",
128
  "model, tokenizer = FastLanguageModel.from_pretrained(\n",
129
  " model_name=MODEL_NAME,\n",
130
- " max_seq_length=512,\n",
131
  " load_in_4bit=True,\n",
132
  ")\n",
133
  "\n",
@@ -151,31 +159,25 @@
151
  "# 6. PROMPT + PARSER\n",
152
  "# =========================================\n",
153
  "def build_prompt(obs):\n",
154
- " zones_str = '\\n'.join([f\"Zone {z.zone_id}: damage={z.damage:.2f}, vulnerable={z.vulnerable_ratio:.2f}\" for z in obs.zones])\n",
155
  " return f\"\"\"System: You are an AI allocating disaster resources fairly using the Fair-GRPO-RLVR framework.\n",
156
- "Prioritize Zone 4 (high damage, high vulnerability) over Zone 0 (low damage).\n",
157
  "Respond ONLY with a JSON action like: {{\"action_type\": \"analyze\", \"critical_zones\": [4, 3]}}\n",
158
  "\n",
159
  "User: Day {obs.day}. Budget: {obs.budget_left}. \n",
160
  "Zones:\n",
161
  "{zones_str}\n",
162
- "Fairness Score: {obs.fairness_score}\n",
163
  "\n",
164
  "What is your next action?\"\"\"\n",
165
  "\n",
166
  "def parse_action(text, stage):\n",
167
  " if isinstance(text, list):\n",
168
  " text = text[-1].get(\"content\", str(text))\n",
169
- " \n",
170
  " try:\n",
171
  " match = re.search(r\"\\{.*?\\}\", str(text), re.DOTALL)\n",
172
  " if match:\n",
173
- " data = json.loads(match.group())\n",
174
- " if \"action_type\" not in data:\n",
175
- " data[\"action_type\"] = stage\n",
176
- " return data\n",
177
- " except:\n",
178
- " pass\n",
179
  " return {\"action_type\": stage}\n",
180
  "\n"
181
  ]
@@ -193,38 +195,52 @@
193
  " rewards = []\n",
194
  "\n",
195
  " for output in completions:\n",
196
- " # 1. Scenario Variation (Curriculum Learning)\n",
197
  " difficulty = random.choice([\"easy\", \"medium\", \"hard\"])\n",
198
  " env, obs = reset_env(difficulty=difficulty)\n",
199
  " \n",
 
200
  " action_dict = parse_action(output, obs.step_stage)\n",
201
  "\n",
 
202
  " for _ in range(MAX_STEPS):\n",
203
  " obs = step_env(env, action_dict)\n",
204
  " if obs.done: break\n",
205
- " from inference import fairness_aware_policy\n",
206
- " action_dict = fairness_aware_policy(obs).model_dump()\n",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  "\n",
208
  " # 2. Research-Level Fairness Metric (Inverse Service Disparity)\n",
209
  " services = [z.service for z in env.state.zones]\n",
210
  " mean_service = sum(services) / len(services)\n",
211
  " disparity = sum(abs(s - mean_service) for s in services) / len(services)\n",
212
- " fairness = 1.0 - disparity # Higher = Better Equity\n",
213
  "\n",
214
- " # 3. Multi-objective Components\n",
215
  " utility = sum(services) / len(services)\n",
216
  " safety = -obs.info.get(\"violations\", 0) / 10.0\n",
217
  " \n",
218
- " # 4. Total Reward with Curriculum Scaling\n",
219
- " total = (0.4 * utility + 0.4 * fairness + 0.2 * safety)\n",
220
- " if difficulty == \"hard\":\n",
221
- " total *= 1.2\n",
222
- " elif difficulty == \"easy\":\n",
223
- " total *= 0.8\n",
224
- " \n",
225
- " # 5. Stronger Normalization (Preserves Policy Differences)\n",
226
- " final_score = max(0.0, min(1.0, total))\n",
227
- " rewards.append(float(final_score))\n",
228
  "\n",
229
  " return rewards\n",
230
  "\n"
@@ -242,14 +258,13 @@
242
  "from datasets import Dataset\n",
243
  "\n",
244
  "dataset_list = []\n",
245
- "for i in range(15):\n",
246
  " env, obs = reset_env(seed=42 + i) \n",
247
  " dataset_list.append({\n",
248
  " \"prompt\": [{\"role\": \"user\", \"content\": build_prompt(obs)}]\n",
249
  " })\n",
250
  "\n",
251
  "dataset = Dataset.from_list(dataset_list)\n",
252
- "print(f\"Dataset created with {len(dataset)} scenarios.\")\n",
253
  "\n"
254
  ]
255
  },
@@ -267,8 +282,8 @@
267
  "config = GRPOConfig(\n",
268
  " output_dir=\"./outputs\",\n",
269
  " per_device_train_batch_size=1,\n",
270
- " gradient_accumulation_steps=2,\n",
271
- " num_train_epochs=2,\n",
272
  " max_completion_length=128,\n",
273
  " logging_steps=1,\n",
274
  " max_grad_norm=0.5,\n",
@@ -282,9 +297,8 @@
282
  " train_dataset=dataset,\n",
283
  ")\n",
284
  "\n",
285
- "print(\"๐Ÿš€ Training Fair-GRPO-RLVR method...\")\n",
286
  "trainer.train()\n",
287
- "print(\"โœ… Training done\")\n",
288
  "\n"
289
  ]
290
  },
@@ -294,189 +308,43 @@
294
  "metadata": {},
295
  "outputs": [],
296
  "source": [
297
- "import torch\n",
298
- "\n",
299
  "# =========================================\n",
300
- "# 10. TRAINED MODEL RUNNER\n",
301
  "# =========================================\n",
302
- "def run_trained(seed=None):\n",
303
- " env, obs = reset_env(seed=seed, difficulty=\"hard\")\n",
304
- " total_reward = 0\n",
 
 
305
  " \n",
306
- " # Tracking components\n",
307
- " utilities = []\n",
308
- " fairness_scores = []\n",
309
- "\n",
310
  " for _ in range(MAX_STEPS):\n",
311
  " prompt = build_prompt(obs)\n",
312
- " # Use higher temperature for better exploration during evaluation\n",
313
  " inputs = tokenizer.apply_chat_template([{\"role\": \"user\", \"content\": prompt}], return_tensors=\"pt\", add_generation_prompt=True).to(model.device)\n",
314
- " outputs = model.generate(\n",
315
- " inputs, \n",
316
- " max_new_tokens=100, \n",
317
- " temperature=0.3, # Increased for exploration\n",
318
- " top_p=0.9,\n",
319
- " pad_token_id=tokenizer.eos_token_id\n",
320
- " )\n",
321
  " text = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True)\n",
322
- " action_dict = parse_action(text, obs.step_stage)\n",
323
- "\n",
324
- " obs = step_env(env, action_dict)\n",
325
- " total_reward += obs.reward\n",
326
- " \n",
327
- " # Track disparity-based fairness (clamped to non-negative)\n",
328
- " services = [z.service for z in env.state.zones]\n",
329
- " mean_s = sum(services) / len(services)\n",
330
- " disp = sum(abs(s - mean_s) for s in services) / len(services)\n",
331
- " fairness_scores.append(max(0.0, 1.0 - disp))\n",
332
- " utilities.append(mean_s)\n",
333
- "\n",
334
  " if obs.done: break\n",
335
- "\n",
336
- " return {\n",
337
- " \"reward\": total_reward,\n",
338
- " \"fairness\": fairness_scores[-1],\n",
339
- " \"utility\": sum(utilities) / len(utilities)\n",
340
- " }\n",
341
- "\n"
342
- ]
343
- },
344
- {
345
- "cell_type": "code",
346
- "execution_count": null,
347
- "metadata": {},
348
- "outputs": [],
349
- "source": [
350
- "# =========================================\n",
351
- "# 11. RUN COMPARISON\n",
352
- "# =========================================\n",
353
- "results = []\n",
354
- "\n",
355
- "for i in range(5):\n",
356
- " test_seed = 2000 + i\n",
357
- " # Baseline\n",
358
- " env_b, obs_b = reset_env(seed=test_seed, difficulty=\"hard\")\n",
359
- " b_reward = 0\n",
360
- " for _ in range(MAX_STEPS):\n",
361
- " from inference import greedy_policy\n",
362
- " action = greedy_policy(obs_b)\n",
363
- " obs_b = env_b.step(action)\n",
364
- " b_reward += obs_b.reward\n",
365
- " if obs_b.done: break\n",
366
- " \n",
367
- " services_b = [z.service for z in env_b.state.zones]\n",
368
- " mean_b = sum(services_b) / len(services_b)\n",
369
- " disp_b = sum(abs(s - mean_b) for s in services_b) / len(services_b)\n",
370
- " b_fairness = max(0.0, 1.0 - disp_b)\n",
371
- " b_utility = mean_b\n",
372
- "\n",
373
- " # Trained\n",
374
- " t_res = run_trained(seed=test_seed)\n",
375
  "\n",
376
  " results.append({\n",
377
- " \"baseline_reward\": b_reward,\n",
378
- " \"baseline_fairness\": b_fairness,\n",
379
- " \"baseline_utility\": b_utility,\n",
380
- " \"trained_reward\": t_res[\"reward\"],\n",
381
- " \"trained_fairness\": t_res[\"fairness\"],\n",
382
- " \"trained_utility\": t_res[\"utility\"]\n",
383
  " })\n",
384
  "\n",
385
  "df = pd.DataFrame(results)\n",
386
- "print(df)\n",
387
- "\n"
388
- ]
389
- },
390
- {
391
- "cell_type": "code",
392
- "execution_count": null,
393
- "metadata": {},
394
- "outputs": [],
395
- "source": [
396
- "# =========================================\n",
397
- "# 12. PLOTS (MULTI-COMPONENT)\n",
398
- "# =========================================\n",
399
- "os.makedirs(\"plots\", exist_ok=True)\n",
400
- "\n",
401
- "fig, ax1 = plt.subplots(figsize=(10, 6))\n",
402
- "\n",
403
- "ax1.plot(df[\"baseline_reward\"], label=\"Baseline Reward\", color=\"red\", linestyle=\"--\", marker=\"o\")\n",
404
- "ax1.plot(df[\"trained_reward\"], label=\"Trained Total Reward\", color=\"green\", marker=\"o\")\n",
405
- "ax1.set_xlabel(\"Episode\")\n",
406
- "ax1.set_ylabel(\"Total Reward\")\n",
407
- "ax1.legend(loc=\"upper left\")\n",
408
- "\n",
409
- "ax2 = ax1.twinx()\n",
410
- "ax2.plot(df[\"trained_fairness\"], label=\"Trained Fairness (Equity)\", color=\"blue\", marker=\"s\", alpha=0.6)\n",
411
- "ax2.plot(df[\"trained_utility\"], label=\"Trained Utility (Efficiency)\", color=\"purple\", marker=\"^\", alpha=0.6)\n",
412
- "ax2.set_ylabel(\"Metric Score\")\n",
413
- "ax2.legend(loc=\"upper right\")\n",
414
- "\n",
415
- "plt.title(\"Fair-GRPO-RLVR: Research-Level Performance Metrics\")\n",
416
- "plt.grid(alpha=0.3)\n",
417
- "plt.savefig(\"plots/reward_vs_episode.png\", dpi=150, bbox_inches=\"tight\")\n",
418
- "plt.show()\n",
419
- "\n",
420
- "# Fairness Improvement Plot\n",
421
- "plt.figure(figsize=(8,5))\n",
422
- "plt.plot(df[\"baseline_fairness\"], label=\"Baseline (Greedy)\", color=\"crimson\", marker=\"o\")\n",
423
- "plt.plot(df[\"trained_fairness\"], label=\"Trained LLM (Fair-GRPO-RLVR)\", color=\"forestgreen\", marker=\"o\")\n",
424
- "plt.title(\"Fairness Improvement (Inverse Service Disparity)\")\n",
425
- "plt.xlabel(\"Episode\")\n",
426
- "plt.ylabel(\"Fairness Score (higher = better equity)\")\n",
427
- "plt.axhline(0, color='k', linestyle=':', alpha=0.5)\n",
428
- "plt.legend()\n",
429
- "plt.grid(alpha=0.3)\n",
430
- "plt.savefig(\"plots/fairness_vs_episode.png\", dpi=150, bbox_inches=\"tight\")\n",
431
- "plt.show()\n",
432
- "\n"
433
- ]
434
- },
435
- {
436
- "cell_type": "code",
437
- "execution_count": null,
438
- "metadata": {},
439
- "outputs": [],
440
- "source": [
441
- "# =========================================\n",
442
- "# 13. SUMMARY\n",
443
- "# =========================================\n",
444
  "print(\"\\n=== FINAL RESULTS (Fair-GRPO-RLVR) ===\")\n",
445
- "print(\"๐Ÿง  Method: Fair-GRPO-RLVR\")\n",
446
- "print(\"Multi-objective RL with fairness, safety, and utility optimization\")\n",
447
- "\n",
448
- "b_r = df['baseline_reward'].mean()\n",
449
- "t_r = df['trained_reward'].mean()\n",
450
- "b_f = df['baseline_fairness'].mean()\n",
451
- "t_f = df['trained_fairness'].mean()\n",
452
- "\n",
453
- "print(f\"\\nReward:\")\n",
454
- "print(f\"Baseline: {b_r:.3f}\")\n",
455
- "print(f\"Trained : {t_r:.3f}\")\n",
456
- "\n",
457
- "print(f\"\\nFairness (1 - Disparity):\")\n",
458
- "print(f\"Baseline: {b_f:.3f}\")\n",
459
- "print(f\"Trained : {t_f:.3f}\")\n",
460
- "\n",
461
- "improvement_r = t_r - b_r\n",
462
- "percent_r = (improvement_r / (abs(b_r) + 1e-5)) * 100\n",
463
- "improvement_f = t_f - b_f\n",
464
- "percent_f = (improvement_f / (abs(b_f) + 1e-5)) * 100\n",
465
- "\n",
466
- "print(f\"\\n๐Ÿ“Š Relative Improvement:\")\n",
467
- "print(f\"Reward Gain: +{improvement_r:.2f} ({percent_r:.1f}%)\")\n",
468
- "print(f\"Fairness Gain: +{improvement_f:.2f} ({percent_f:.1f}%)\")\n",
469
- "\n",
470
- "print(\"\\n๐Ÿšจ BASELINE ISSUE (GREEDY):\")\n",
471
- "print(\"Greedy policy prioritizes low-risk Zone 0, ignoring vulnerable populations in Zone 4.\")\n",
472
- "\n",
473
- "print(\"\\nโœ… MODEL IMPROVEMENT (FAIR-GRPO-RLVR):\")\n",
474
- "print(\"Trained model balances recovery speed with equity, ensuring vulnerable zones are prioritized.\")\n",
475
- "\n",
476
- "print(\"\\n๐Ÿ† Key Insight:\")\n",
477
- "print(\"Optimizing for fairness improves long-term recovery efficiency.\")\n",
478
- "\n",
479
- "print(f\"\\nโœ… Total Improvement: +{improvement_r:.3f} Reward | +{improvement_f:.3f} Fairness\")\n",
480
  "\n",
481
  "print(\"\\n๐Ÿš€ FINAL TAKEAWAY:\")\n",
482
  "print(\"Fair-GRPO-RLVR learns policies that outperform greedy baselines by optimizing both efficiency and fairness simultaneously.\")\n",
 
45
  "os.chdir(REPO_DIR)\n",
46
  "\n",
47
  "MODEL_NAME = \"unsloth/Llama-3.2-1B-Instruct-bnb-4bit\"\n",
48
+ "MAX_STEPS = 15 # Shorter episodes for faster training\n",
49
  "\n"
50
  ]
51
  },
 
66
  " difficulty = random.choice([\"easy\", \"medium\", \"hard\"])\n",
67
  " env = FairRecoveryEnvironment()\n",
68
  " obs = env.reset(difficulty=difficulty, seed=seed)\n",
69
+ " \n",
70
+ " # FIX 4: Ensure INITIAL IMBALANCE (The Fairness Trap)\n",
71
+ " # We artificially damage the vulnerable zones more and restore the non-vulnerable ones\n",
72
+ " # to create a gap that the agent must learn to bridge.\n",
73
+ " for z in env.state.zones:\n",
74
+ " if z.vulnerable_ratio > 0.5:\n",
75
+ " z.service = 0.05 # Vulnerable zones start very low\n",
76
+ " z.damage = 0.9\n",
77
+ " else:\n",
78
+ " z.service = 0.6 # Wealthy zones start high\n",
79
+ " z.damage = 0.2\n",
80
+ " \n",
81
  " return env, obs\n",
82
  "\n",
83
  "def step_env(env, action_dict):\n",
84
  " try:\n",
85
  " if \"action_type\" not in action_dict:\n",
86
  " action_dict[\"action_type\"] = \"submit\"\n",
 
 
 
 
 
87
  " action = FairRecoveryAction(**action_dict)\n",
88
  " obs = env.step(action)\n",
89
  " return obs\n",
90
+ " except Exception:\n",
91
+ " return env.step(FairRecoveryAction(action_type=\"noop\"))\n",
92
  "\n"
93
  ]
94
  },
 
104
  "from inference import greedy_policy\n",
105
  "\n",
106
  "def run_baseline(seed=None):\n",
 
107
  " env, obs = reset_env(seed=seed, difficulty=\"hard\")\n",
108
  " total = 0\n",
109
  "\n",
 
111
  " action = greedy_policy(obs)\n",
112
  " obs = env.step(action)\n",
113
  " total += obs.reward\n",
114
+ " if obs.done: break\n",
115
  "\n",
116
+ " # Calculate final fairness\n",
117
+ " services = [z.service for z in env.state.zones]\n",
118
+ " mean_s = sum(services) / len(services)\n",
119
+ " disp = sum(abs(s - mean_s) for s in services) / len(services)\n",
120
+ " return total, max(0.0, 1.0 - disp)\n",
121
  "\n"
122
  ]
123
  },
 
131
  "# 5. LOAD MODEL (UNSLOTH)\n",
132
  "# =========================================\n",
133
  "from unsloth import FastLanguageModel\n",
134
+ "import torch\n",
135
  "\n",
136
  "model, tokenizer = FastLanguageModel.from_pretrained(\n",
137
  " model_name=MODEL_NAME,\n",
138
+ " max_seq_length=1024,\n",
139
  " load_in_4bit=True,\n",
140
  ")\n",
141
  "\n",
 
159
  "# 6. PROMPT + PARSER\n",
160
  "# =========================================\n",
161
  "def build_prompt(obs):\n",
162
+ " zones_str = '\\n'.join([f\"Zone {z.zone_id}: damage={z.damage:.2f}, vulnerable={z.vulnerable_ratio:.2f}, service={z.service:.2f}\" for z in obs.zones])\n",
163
  " return f\"\"\"System: You are an AI allocating disaster resources fairly using the Fair-GRPO-RLVR framework.\n",
164
+ "Escape the Fairness Trap: prioritise Zone 4 (high vulnerability, low service) even if Zone 0 is easier to fix.\n",
165
  "Respond ONLY with a JSON action like: {{\"action_type\": \"analyze\", \"critical_zones\": [4, 3]}}\n",
166
  "\n",
167
  "User: Day {obs.day}. Budget: {obs.budget_left}. \n",
168
  "Zones:\n",
169
  "{zones_str}\n",
 
170
  "\n",
171
  "What is your next action?\"\"\"\n",
172
  "\n",
173
  "def parse_action(text, stage):\n",
174
  " if isinstance(text, list):\n",
175
  " text = text[-1].get(\"content\", str(text))\n",
 
176
  " try:\n",
177
  " match = re.search(r\"\\{.*?\\}\", str(text), re.DOTALL)\n",
178
  " if match:\n",
179
+ " return json.loads(match.group())\n",
180
+ " except: pass\n",
 
 
 
 
181
  " return {\"action_type\": stage}\n",
182
  "\n"
183
  ]
 
195
  " rewards = []\n",
196
  "\n",
197
  " for output in completions:\n",
198
+ " # 1. Reset imbalanced environment\n",
199
  " difficulty = random.choice([\"easy\", \"medium\", \"hard\"])\n",
200
  " env, obs = reset_env(difficulty=difficulty)\n",
201
  " \n",
202
+ " # Parse first action from completion\n",
203
  " action_dict = parse_action(output, obs.step_stage)\n",
204
  "\n",
205
+ " # FIX 3: Let model control FULL episode\n",
206
  " for _ in range(MAX_STEPS):\n",
207
  " obs = step_env(env, action_dict)\n",
208
  " if obs.done: break\n",
209
+ " \n",
210
+ " # Generate next action using the model itself\n",
211
+ " prompt = build_prompt(obs)\n",
212
+ " # Use inference mode for efficiency\n",
213
+ " with torch.inference_mode():\n",
214
+ " inputs = tokenizer.apply_chat_template(\n",
215
+ " [{\"role\": \"user\", \"content\": prompt}],\n",
216
+ " return_tensors=\"pt\",\n",
217
+ " add_generation_prompt=True\n",
218
+ " ).to(model.device)\n",
219
+ " \n",
220
+ " # Small completion for speed\n",
221
+ " gen_outputs = model.generate(\n",
222
+ " inputs,\n",
223
+ " max_new_tokens=64,\n",
224
+ " temperature=0.2,\n",
225
+ " pad_token_id=tokenizer.eos_token_id\n",
226
+ " )\n",
227
+ " text = tokenizer.decode(gen_outputs[0][inputs.shape[1]:], skip_special_tokens=True)\n",
228
+ " action_dict = parse_action(text, obs.step_stage)\n",
229
  "\n",
230
  " # 2. Research-Level Fairness Metric (Inverse Service Disparity)\n",
231
  " services = [z.service for z in env.state.zones]\n",
232
  " mean_service = sum(services) / len(services)\n",
233
  " disparity = sum(abs(s - mean_service) for s in services) / len(services)\n",
234
+ " fairness = max(0.0, 1.0 - disparity)\n",
235
  "\n",
236
+ " # 3. FIX 1: Boost Fairness Weight (0.3/0.6/0.1)\n",
237
  " utility = sum(services) / len(services)\n",
238
  " safety = -obs.info.get(\"violations\", 0) / 10.0\n",
239
  " \n",
240
+ " total = (0.3 * utility + 0.6 * fairness + 0.1 * safety)\n",
241
+ " \n",
242
+ " # 4. FIX 2: Remove clipping to preserve gradients\n",
243
+ " rewards.append(float(total))\n",
 
 
 
 
 
 
244
  "\n",
245
  " return rewards\n",
246
  "\n"
 
258
  "from datasets import Dataset\n",
259
  "\n",
260
  "dataset_list = []\n",
261
+ "for i in range(10): # Smaller dataset for faster iterations with full-episode rollouts\n",
262
  " env, obs = reset_env(seed=42 + i) \n",
263
  " dataset_list.append({\n",
264
  " \"prompt\": [{\"role\": \"user\", \"content\": build_prompt(obs)}]\n",
265
  " })\n",
266
  "\n",
267
  "dataset = Dataset.from_list(dataset_list)\n",
 
268
  "\n"
269
  ]
270
  },
 
282
  "config = GRPOConfig(\n",
283
  " output_dir=\"./outputs\",\n",
284
  " per_device_train_batch_size=1,\n",
285
+ " gradient_accumulation_steps=4,\n",
286
+ " num_train_epochs=1, # 1 epoch is enough for fine-tuning signal\n",
287
  " max_completion_length=128,\n",
288
  " logging_steps=1,\n",
289
  " max_grad_norm=0.5,\n",
 
297
  " train_dataset=dataset,\n",
298
  ")\n",
299
  "\n",
300
+ "print(\"๐Ÿš€ Training Fair-GRPO-RLVR (Full-Trajectory Signal)...\")\n",
301
  "trainer.train()\n",
 
302
  "\n"
303
  ]
304
  },
 
308
  "metadata": {},
309
  "outputs": [],
310
  "source": [
 
 
311
  "# =========================================\n",
312
+ "# 10. EVALUATION & SUMMARY\n",
313
  "# =========================================\n",
314
+ "results = []\n",
315
+ "for i in range(5):\n",
316
+ " test_seed = 5000 + i\n",
317
+ " # Baseline\n",
318
+ " b_reward, b_fairness = run_baseline(seed=test_seed)\n",
319
  " \n",
320
+ " # Trained\n",
321
+ " env, obs = reset_env(seed=test_seed, difficulty=\"hard\")\n",
322
+ " t_reward = 0\n",
 
323
  " for _ in range(MAX_STEPS):\n",
324
  " prompt = build_prompt(obs)\n",
 
325
  " inputs = tokenizer.apply_chat_template([{\"role\": \"user\", \"content\": prompt}], return_tensors=\"pt\", add_generation_prompt=True).to(model.device)\n",
326
+ " with torch.no_grad():\n",
327
+ " outputs = model.generate(inputs, max_new_tokens=64, temperature=0.1, pad_token_id=tokenizer.eos_token_id)\n",
 
 
 
 
 
328
  " text = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True)\n",
329
+ " obs = step_env(env, parse_action(text, obs.step_stage))\n",
330
+ " t_reward += obs.reward\n",
 
 
 
 
 
 
 
 
 
 
331
  " if obs.done: break\n",
332
+ " \n",
333
+ " services = [z.service for z in env.state.zones]\n",
334
+ " mean_s = sum(services) / len(services)\n",
335
+ " disp = sum(abs(s - mean_s) for s in services) / len(services)\n",
336
+ " t_fairness = max(0.0, 1.0 - disp)\n",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  "\n",
338
  " results.append({\n",
339
+ " \"b_reward\": b_reward, \"b_fairness\": b_fairness,\n",
340
+ " \"t_reward\": t_reward, \"t_fairness\": t_fairness\n",
 
 
 
 
341
  " })\n",
342
  "\n",
343
  "df = pd.DataFrame(results)\n",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  "print(\"\\n=== FINAL RESULTS (Fair-GRPO-RLVR) ===\")\n",
345
+ "print(f\"Baseline Fairness: {df.b_fairness.mean():.3f}\")\n",
346
+ "print(f\"Trained Fairness : {df.t_fairness.mean():.3f} โœ…\")\n",
347
+ "print(f\"Reward Improvement: {df.t_reward.mean() - df.b_reward.mean():.3f}\")\n",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  "\n",
349
  "print(\"\\n๐Ÿš€ FINAL TAKEAWAY:\")\n",
350
  "print(\"Fair-GRPO-RLVR learns policies that outperform greedy baselines by optimizing both efficiency and fairness simultaneously.\")\n",