Ugas commited on
Commit
975dbdd
·
verified ·
1 Parent(s): 66340b0

Upload routing agent adapters and source code

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ Deterministic_Action_Space_GRPO.pdf filter=lfs diff=lfs merge=lfs -text
Deterministic_Action_Space_GRPO.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3671c1a7060dd74216760d76d7fe15649addd9c725df5e821c5317126fa8d403
3
+ size 1003880
adapters/router_grpo_epoch_1.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:82defe5a768a836545176fc4c19759ff4f5de39fc31e18bbac2d35053f9952b2
3
+ size 741423
adapters/router_grpo_epoch_10.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b4fde8546c2beb8f9c0af61feafd7835417549433d31b9b628de67a8642e08e7
3
+ size 741593
adapters/router_grpo_epoch_11.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6ee3dcc8dcc885a4cdb538878e398b300681bd7c7e5f26bfd697bf47302b2750
3
+ size 741593
adapters/router_grpo_epoch_12.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f15932966200969a4950c4b8157f67ce06e81db36062fd2dde512b50f8562e48
3
+ size 741593
adapters/router_grpo_epoch_13.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6ea6d2cbbd6371e603a292606a82d7dfab3c5a2a23cacb09c083018bcab0c8f5
3
+ size 741593
adapters/router_grpo_epoch_14.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1a4680d4923c2b55ddc8905a9048fba7fd0a66de585e6a09fa06be645532f6ca
3
+ size 741593
adapters/router_grpo_epoch_15.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d8ceb15f07681860b398181a20c82f02a4dacd8cbcb53061d74ee643949c30b0
3
+ size 741593
adapters/router_grpo_epoch_16.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1d16c1b921a4c39b36266f1ffab0ff5be0bf1b14cc9e9d1577db73c3d830d00c
3
+ size 741593
adapters/router_grpo_epoch_17.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c2958e857269bdffb584d99aebd71f7e3ec9a1539bcbb4cebabb268ecb9d2804
3
+ size 741593
adapters/router_grpo_epoch_18.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ebb8538a30f10af1f1236f955ae31e590737fcc6a0ee0f6362e5a2bcd668878e
3
+ size 741593
adapters/router_grpo_epoch_19.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a6a1f132eb63e286ad598e0875e543b5c277b6c5063ae3ab3509bec2508f1bfe
3
+ size 741593
adapters/router_grpo_epoch_2.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ac6f190372b0e60167d15a7a1b57c5a1501ff8110359d1f1d6c566b5aa2c15ac
3
+ size 741423
adapters/router_grpo_epoch_20.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:740562659427d570ebffed75ca38994722042b487b8cd1d65a8fa606449b8d6f
3
+ size 741593
adapters/router_grpo_epoch_3.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5d90746091add53d56728c0e7ed42e66febf858dc445b0859569ec1be21d5903
3
+ size 741423
adapters/router_grpo_epoch_4.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9dcbfb3f72c8853bb4e99669e58a37c855d85555c674afc5b79f8f3b1c00332d
3
+ size 741423
adapters/router_grpo_epoch_5.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0f4c6f4e1c929d77932244105065f30090eacff5b8f36e98ba7a0aa6ac7d0421
3
+ size 741423
adapters/router_grpo_epoch_6.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8a3afa0288f4374e62aa8fd11899afd41987958e61760dfc60cd6f018ed14b58
3
+ size 741551
adapters/router_grpo_epoch_7.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fb00b41a4151b0f02797bf659f439d690388fc6922b96a14001efedb46a6df0c
3
+ size 741551
adapters/router_grpo_epoch_8.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4846278c2b838dd126063cde2af02acd2ca826ea78e517455f37e27a0ab7f907
3
+ size 741551
adapters/router_grpo_epoch_9.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:04cf97b6a22b1954c0020e2e482a47a6d931c935f311ce906e7fbfb477e8954e
3
+ size 741551
benchmark_evaluation.py ADDED
@@ -0,0 +1,735 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ benchmark_evaluation.py — Comparative evaluation suite for RL router vs baseline.
4
+
5
+ Evaluates two routing approaches on held-out evaluation sets:
6
+ 1. Baseline: backend's own LLM router (Qwen3-14B) — no search_sources passed
7
+ 2. GRPO: local RL router — explicit search_sources passed to backend
8
+
9
+ For each query the suite:
10
+ - Routes via the assigned strategy
11
+ - Executes the backend RAG pipeline
12
+ - Compares predicted action to ground-truth action_label
13
+ - Records accuracy, latency, and raw outputs
14
+
15
+ Outputs:
16
+ - Per-strategy detailed CSV results
17
+ - Aggregated comparison tables (CSV + LaTeX-ready)
18
+ - Statistical summaries
19
+ """
20
+
21
+ import os
22
+ import sys
23
+ import time
24
+ import json
25
+ import csv
26
+ import uuid
27
+ import logging
28
+ import argparse
29
+ import statistics
30
+ from datetime import datetime
31
+ from pathlib import Path
32
+ from typing import List, Dict, Tuple, Optional
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Path / env setup
36
+ # ---------------------------------------------------------------------------
37
+ ROOT = Path(__file__).resolve().parent
38
+ if str(ROOT) not in sys.path:
39
+ sys.path.insert(0, str(ROOT))
40
+
41
+ REPO_ROOT = ROOT.parent
42
+ sys.path.append(str(REPO_ROOT))
43
+ sys.path.append(str(REPO_ROOT / "backend"))
44
+
45
+ import torch
46
+ import pandas as pd
47
+ import requests
48
+ from sentence_transformers import SentenceTransformer
49
+ from scipy import stats as scipy_stats
50
+
51
+ from model import RouterNetwork
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Logging
55
+ # ---------------------------------------------------------------------------
56
+ logger = logging.getLogger("benchmark_eval")
57
+ if not logger.handlers:
58
+ logger.setLevel(logging.INFO)
59
+ fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
60
+ ch = logging.StreamHandler()
61
+ ch.setFormatter(fmt)
62
+ logger.addHandler(ch)
63
+ logs_dir = REPO_ROOT / "logs"
64
+ try:
65
+ os.makedirs(logs_dir, exist_ok=True)
66
+ fh = logging.FileHandler(logs_dir / "benchmark_evaluation.log")
67
+ fh.setFormatter(fmt)
68
+ logger.addHandler(fh)
69
+ except Exception:
70
+ pass
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Constants
74
+ # ---------------------------------------------------------------------------
75
+ NUM_ACTIONS = 3
76
+ ACTION_TO_TOOL = {0: "guidelines", 1: "web-search", 2: "course-registration"}
77
+ ACTION_TO_NAME = {0: "Guidelines", 1: "Web-Search", 2: "CBNU Database"}
78
+
79
+ BACKEND_CHAT_URL = os.getenv("BACKEND_CHAT_URL", "http://localhost:2000/api/chat")
80
+
81
+ BASELINE_LABEL = "Baseline-LLM"
82
+
83
+ TOP_GRPO_CHECKPOINTS = [
84
+ {
85
+ "id": "GRPO-E14",
86
+ "label": "GRPO-E14",
87
+ "path": ROOT / "adapters" / "router_grpo_epoch_14.pt",
88
+ "epoch": 14,
89
+ "train_avg_reward": 0.4262,
90
+ },
91
+ {
92
+ "id": "GRPO-E12",
93
+ "label": "GRPO-E12",
94
+ "path": ROOT / "adapters" / "router_grpo_epoch_12.pt",
95
+ "epoch": 12,
96
+ "train_avg_reward": 0.4189,
97
+ },
98
+ ]
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Helpers
102
+ # ---------------------------------------------------------------------------
103
+ def detect_language(text: str) -> str:
104
+ if any("\uac00" <= char <= "\ud7a3" for char in text):
105
+ return "ko"
106
+ elif any("\u4e00" <= char <= "\u9fff" for char in text):
107
+ return "zh"
108
+ return "en"
109
+
110
+
111
+ def truncate(s: str, n: int = 500) -> str:
112
+ if s is None:
113
+ return ""
114
+ s = str(s)
115
+ return s if len(s) <= n else s[: n - 1] + "\u2026"
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Backend RAG pipeline
120
+ # ---------------------------------------------------------------------------
121
+ def simulate_rag_pipeline(query: str, search_sources: Optional[List[str]] = None, max_retries: int = 3) -> Tuple[str, str]:
122
+ """Call the backend chat API.
123
+
124
+ If search_sources is None, the backend's own LLM router decides which
125
+ tool(s) to use (baseline behavior).
126
+ If search_sources is provided, the backend is forced to use those tools
127
+ (GRPO behavior).
128
+
129
+ Includes automatic retry with exponential backoff on 429 responses.
130
+ """
131
+ url = BACKEND_CHAT_URL
132
+ headers = {"accept": "application/json", "Content-Type": "application/json"}
133
+ language = detect_language(query)
134
+
135
+ payload = {
136
+ "message": query,
137
+ "language": language,
138
+ "session_id": f"benchmark-{uuid.uuid4().hex}",
139
+ }
140
+ if search_sources is not None:
141
+ payload["search_sources"] = search_sources
142
+
143
+ last_exception = None
144
+ for attempt in range(max_retries):
145
+ try:
146
+ response = requests.post(url, headers=headers, json=payload, timeout=60)
147
+ if response.status_code == 429:
148
+ wait_time = (2 ** attempt) * 2 # 2s, 4s, 8s
149
+ logger.warning(f"Rate limited (429). Retrying in {wait_time}s... (attempt {attempt + 1}/{max_retries})")
150
+ time.sleep(wait_time)
151
+ continue
152
+ response.raise_for_status()
153
+ data = response.json()
154
+ answer = data.get("message", "")
155
+ context_parts = []
156
+ for src in data.get("sources", []):
157
+ if isinstance(src, dict) and src.get("success"):
158
+ for res in src.get("data", {}).get("results", []):
159
+ if "content" in res:
160
+ context_parts.append(res["content"])
161
+ elif "snippet" in res:
162
+ context_parts.append(res["snippet"])
163
+ context = "\n".join(context_parts)
164
+ return context, answer
165
+ except Exception as e:
166
+ last_exception = e
167
+ if attempt < max_retries - 1:
168
+ wait_time = (2 ** attempt) * 2
169
+ logger.warning(f"Request failed: {e}. Retrying in {wait_time}s...")
170
+ time.sleep(wait_time)
171
+ else:
172
+ break
173
+
174
+ logger.error(f"simulate_rag_pipeline failed after {max_retries} attempts: {last_exception}")
175
+ return "", ""
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # GRPO router inference
180
+ # ---------------------------------------------------------------------------
181
+ def load_grpo_checkpoint(path: Path, device: str = "cpu"):
182
+ embedder = SentenceTransformer("all-MiniLM-L6-v2")
183
+ embed_dim = embedder.get_sentence_embedding_dimension()
184
+ model = RouterNetwork(embedding_dim=embed_dim, num_classes=3).to(device)
185
+
186
+ sd = torch.load(str(path), map_location="cpu")
187
+ if isinstance(sd, dict) and "model_state_dict" in sd:
188
+ model.load_state_dict(sd["model_state_dict"], strict=False)
189
+ else:
190
+ model.load_state_dict(sd, strict=False)
191
+ model.eval()
192
+ return model, embedder
193
+
194
+
195
+ def grpo_route_query(model: RouterNetwork, embedder: SentenceTransformer, query: str, device: str = "cpu") -> int:
196
+ """Route query using GRPO model, return action_idx."""
197
+ with torch.no_grad():
198
+ emb = embedder.encode(query, convert_to_tensor=True).to(device)
199
+ action, _log_prob, _value = model.get_action(emb)
200
+ return int(action.item())
201
+
202
+
203
+ def action_to_search_sources(action_idx: int) -> List[str]:
204
+ if action_idx == 0:
205
+ return ["guidelines"]
206
+ elif action_idx == 1:
207
+ return ["web-search"]
208
+ else:
209
+ return ["course-registration", "department-info"]
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Evaluation runner
214
+ # ---------------------------------------------------------------------------
215
+ def evaluate_strategy(
216
+ name: str,
217
+ queries: List[str],
218
+ ground_truths: List[int],
219
+ route_fn,
220
+ call_tool: bool = True,
221
+ force_search_sources: bool = True,
222
+ delay: float = 1.0,
223
+ ) -> pd.DataFrame:
224
+ """Run a routing strategy over all queries and return a results DataFrame.
225
+
226
+ Args:
227
+ force_search_sources: If True, pass explicit search_sources derived from
228
+ the routed action_idx. If False, pass search_sources=None so the
229
+ backend's own router decides (baseline behavior).
230
+ delay: Delay in seconds between consecutive API calls to avoid rate limiting.
231
+ """
232
+ results = []
233
+ for idx, (q, gt) in enumerate(zip(queries, ground_truths)):
234
+ row = {"question": q, "ground_truth": gt}
235
+ try:
236
+ # Routing decision
237
+ t0 = time.perf_counter()
238
+ action_idx = route_fn(q)
239
+ t1 = time.perf_counter()
240
+ row["predicted_label"] = action_idx
241
+ row["routing_time_sec"] = t1 - t0
242
+ row["routing_error"] = ""
243
+
244
+ # Tool execution
245
+ context, answer = "", ""
246
+ if call_tool:
247
+ search_sources = action_to_search_sources(action_idx) if force_search_sources else None
248
+ t2 = time.perf_counter()
249
+ context, answer = simulate_rag_pipeline(q, search_sources=search_sources)
250
+ t3 = time.perf_counter()
251
+ row["tool_time_sec"] = t3 - t2
252
+ row["end_to_end_time_sec"] = t3 - t0
253
+ row["context"] = truncate(context, 1000)
254
+ row["answer"] = truncate(answer, 1000)
255
+ else:
256
+ row["tool_time_sec"] = None
257
+ row["end_to_end_time_sec"] = row["routing_time_sec"]
258
+ row["context"] = ""
259
+ row["answer"] = ""
260
+ except Exception as e:
261
+ logger.error(f"Error on query: {truncate(q, 80)} — {e}")
262
+ row["routing_error"] = str(e)
263
+ row["predicted_label"] = None
264
+ row["routing_time_sec"] = None
265
+ row["tool_time_sec"] = None
266
+ row["end_to_end_time_sec"] = None
267
+ row["context"] = ""
268
+ row["answer"] = ""
269
+
270
+ row["correct"] = 1 if row.get("predicted_label") == gt else 0
271
+ results.append(row)
272
+
273
+ # Delay between queries to avoid rate limiting
274
+ if delay > 0 and idx < len(queries) - 1:
275
+ time.sleep(delay)
276
+
277
+ return pd.DataFrame(results)
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Metrics computation
282
+ # ---------------------------------------------------------------------------
283
+ def compute_metrics(df: pd.DataFrame) -> Dict:
284
+ """Compute accuracy, latency stats from a results DataFrame."""
285
+ valid = df.dropna(subset=["predicted_label", "ground_truth"])
286
+ accuracy = float((valid["predicted_label"] == valid["ground_truth"]).mean()) if len(valid) else 0.0
287
+
288
+ e2e_valid = df.dropna(subset=["end_to_end_time_sec"])
289
+ e2e_mean = float(e2e_valid["end_to_end_time_sec"].mean()) if len(e2e_valid) else 0.0
290
+ e2e_std = float(e2e_valid["end_to_end_time_sec"].std()) if len(e2e_valid) > 1 else 0.0
291
+
292
+ routing_valid = df.dropna(subset=["routing_time_sec"])
293
+ routing_mean = float(routing_valid["routing_time_sec"].mean()) if len(routing_valid) else 0.0
294
+ routing_std = float(routing_valid["routing_time_sec"].std()) if len(routing_valid) > 1 else 0.0
295
+
296
+ tool_valid = df.dropna(subset=["tool_time_sec"])
297
+ tool_mean = float(tool_valid["tool_time_sec"].mean()) if len(tool_valid) else 0.0
298
+ tool_std = float(tool_valid["tool_time_sec"].std()) if len(tool_valid) > 1 else 0.0
299
+
300
+ # Per-class precision / recall / F1
301
+ classes = sorted(valid["ground_truth"].unique().tolist())
302
+ per_class = {}
303
+ for cls in classes:
304
+ tp = int(((valid["predicted_label"] == cls) & (valid["ground_truth"] == cls)).sum())
305
+ fp = int(((valid["predicted_label"] == cls) & (valid["ground_truth"] != cls)).sum())
306
+ fn = int(((valid["predicted_label"] != cls) & (valid["ground_truth"] == cls)).sum())
307
+ prec = tp / (tp + fp) if (tp + fp) > 0 else 0.0
308
+ rec = tp / (tp + fn) if (tp + fn) > 0 else 0.0
309
+ f1 = 2 * prec * rec / (prec + rec) if (prec + rec) > 0 else 0.0
310
+ per_class[int(cls)] = {"precision": prec, "recall": rec, "f1": f1, "support": int((valid["ground_truth"] == cls).sum())}
311
+
312
+ # Confusion matrix
313
+ conf_mat = {}
314
+ for gt_cls in classes:
315
+ for pred_cls in classes:
316
+ conf_mat[(int(gt_cls), int(pred_cls))] = int(
317
+ ((valid["ground_truth"] == gt_cls) & (valid["predicted_label"] == pred_cls)).sum()
318
+ )
319
+
320
+ # Action distribution
321
+ action_dist = valid["predicted_label"].value_counts().to_dict()
322
+ action_dist = {int(k): int(v) for k, v in action_dist.items()}
323
+
324
+ return {
325
+ "num_queries": int(len(df)),
326
+ "num_valid": int(len(valid)),
327
+ "num_errors": int(len(df) - len(valid)),
328
+ "accuracy": accuracy,
329
+ "e2e_latency_mean": e2e_mean,
330
+ "e2e_latency_std": e2e_std,
331
+ "routing_latency_mean": routing_mean,
332
+ "routing_latency_std": routing_std,
333
+ "tool_latency_mean": tool_mean,
334
+ "tool_latency_std": tool_std,
335
+ "per_class": per_class,
336
+ "confusion_matrix": conf_mat,
337
+ "action_distribution": action_dist,
338
+ }
339
+
340
+
341
+ # ---------------------------------------------------------------------------
342
+ # Statistical tests
343
+ # ---------------------------------------------------------------------------
344
+ def paired_t_test_latency(baseline_latencies: List[float], treatment_latencies: List[float]) -> Dict:
345
+ """Paired t-test between baseline and treatment latency vectors."""
346
+ if len(baseline_latencies) != len(treatment_latencies) or len(baseline_latencies) < 2:
347
+ return {"t_stat": None, "p_value": None, "significant": None}
348
+
349
+ t_stat, p_value = scipy_stats.ttest_rel(treatment_latencies, baseline_latencies)
350
+ return {
351
+ "t_stat": float(t_stat),
352
+ "p_value": float(p_value),
353
+ "significant": p_value < 0.05,
354
+ }
355
+
356
+
357
+ def mean_confidence_interval(data: List[float], confidence: float = 0.95) -> Tuple[float, float, float]:
358
+ """Return (mean, lower, upper) for CI."""
359
+ if not data:
360
+ return 0.0, 0.0, 0.0
361
+ n = len(data)
362
+ mean = statistics.mean(data)
363
+ if n < 2:
364
+ return mean, mean, mean
365
+ se = statistics.stdev(data) / (n ** 0.5)
366
+ h = se * scipy_stats.t.ppf((1 + confidence) / 2.0, n - 1)
367
+ return mean, mean - h, mean + h
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # Table / report generation
372
+ # ---------------------------------------------------------------------------
373
+ def build_comparison_table(all_metrics: Dict[str, Dict], split_name: str) -> str:
374
+ """Build a Markdown comparison table for a dataset split."""
375
+ rows = []
376
+ rows.append(f"### {split_name} Evaluation Results\n")
377
+ rows.append("| Metric | Baseline-LLM | GRPO-E14 | GRPO-E12 |")
378
+ rows.append("|--------|-------------|---------|---------|")
379
+
380
+ rows.append(
381
+ f"| Routing Accuracy | {all_metrics['Baseline-LLM']['accuracy']:.2%} | "
382
+ f"{all_metrics['GRPO-E14']['accuracy']:.2%} | {all_metrics['GRPO-E12']['accuracy']:.2%} |"
383
+ )
384
+ rows.append(
385
+ f"| E2E Latency (mean ± std, s) | {all_metrics['Baseline-LLM']['e2e_latency_mean']:.4f} ± {all_metrics['Baseline-LLM']['e2e_latency_std']:.4f} | "
386
+ f"{all_metrics['GRPO-E14']['e2e_latency_mean']:.4f} ± {all_metrics['GRPO-E14']['e2e_latency_std']:.4f} | "
387
+ f"{all_metrics['GRPO-E12']['e2e_latency_mean']:.4f} ± {all_metrics['GRPO-E12']['e2e_latency_std']:.4f} |"
388
+ )
389
+ rows.append(
390
+ f"| Routing Latency (mean ± std, s) | {all_metrics['Baseline-LLM']['routing_latency_mean']:.4f} ± {all_metrics['Baseline-LLM']['routing_latency_std']:.4f} | "
391
+ f"{all_metrics['GRPO-E14']['routing_latency_mean']:.4f} ± {all_metrics['GRPO-E14']['routing_latency_std']:.4f} | "
392
+ f"{all_metrics['GRPO-E12']['routing_latency_mean']:.4f} ± {all_metrics['GRPO-E12']['routing_latency_std']:.4f} |"
393
+ )
394
+ rows.append(
395
+ f"| Tool Latency (mean ± std, s) | {all_metrics['Baseline-LLM']['tool_latency_mean']:.4f} ± {all_metrics['Baseline-LLM']['tool_latency_std']:.4f} | "
396
+ f"{all_metrics['GRPO-E14']['tool_latency_mean']:.4f} ± {all_metrics['GRPO-E14']['tool_latency_std']:.4f} | "
397
+ f"{all_metrics['GRPO-E12']['tool_latency_mean']:.4f} ± {all_metrics['GRPO-E12']['tool_latency_std']:.4f} |"
398
+ )
399
+ rows.append("")
400
+ return "\n".join(rows)
401
+
402
+
403
+ def build_latex_table(all_metrics: Dict[str, Dict], split_name: str) -> str:
404
+ """Build a LaTeX table suitable for direct inclusion in a journal paper."""
405
+ def bold_if_best(vals: List[float], idx: int, fmt_fn, higher_is_better: bool = True) -> str:
406
+ best_idx = 0
407
+ best_val = vals[0]
408
+ for i, v in enumerate(vals[1:], 1):
409
+ if (higher_is_better and v > best_val) or (not higher_is_better and v < best_val):
410
+ best_val = v
411
+ best_idx = i
412
+ if idx == best_idx:
413
+ return "\\textbf{" + fmt_fn(vals[idx]) + "}"
414
+ return fmt_fn(vals[idx])
415
+
416
+ b = all_metrics["Baseline-LLM"]
417
+ e14 = all_metrics["GRPO-E14"]
418
+ e12 = all_metrics["GRPO-E12"]
419
+
420
+ accs = [b["accuracy"], e14["accuracy"], e12["accuracy"]]
421
+ e2es = [b["e2e_latency_mean"], e14["e2e_latency_mean"], e12["e2e_latency_mean"]]
422
+ routings = [b["routing_latency_mean"], e14["routing_latency_mean"], e12["routing_latency_mean"]]
423
+ tools = [b["tool_latency_mean"], e14["tool_latency_mean"], e12["tool_latency_mean"]]
424
+
425
+ rows = []
426
+ rows.append("% Auto-generated by benchmark_evaluation.py")
427
+ rows.append("\\begin{table}[ht]")
428
+ rows.append("\\centering")
429
+ rows.append(f"\\caption{{Performance comparison on {split_name} evaluation set. "
430
+ f"Results reported as mean $\\pm$ standard deviation. "
431
+ f"Bold indicates best result.}}")
432
+ rows.append("\\label{tab:comparison_" + split_name.lower() + "}")
433
+ rows.append("\\begin{tabular}{lccc}")
434
+ rows.append("\\toprule")
435
+ rows.append("Metric & Baseline-LLM & GRPO-E14 & GRPO-E12 \\\\")
436
+ rows.append("\\midrule")
437
+
438
+ def acc_fmt(v): return f"{v:.2%}"
439
+ def lat_fmt(v): return f"{v:.3f}"
440
+
441
+ rows.append(
442
+ f"Routing Accuracy & {bold_if_best(accs, 0, acc_fmt, higher_is_better=True)} & "
443
+ f"{bold_if_best(accs, 1, acc_fmt, higher_is_better=True)} & "
444
+ f"{bold_if_best(accs, 2, acc_fmt, higher_is_better=True)} \\\\"
445
+ )
446
+ rows.append(
447
+ f"E2E Latency (s) & {bold_if_best(e2es, 0, lat_fmt, higher_is_better=False)}$\\pm${lat_fmt(b['e2e_latency_std'])} & "
448
+ f"{bold_if_best(e2es, 1, lat_fmt, higher_is_better=False)}$\\pm${lat_fmt(e14['e2e_latency_std'])} & "
449
+ f"{bold_if_best(e2es, 2, lat_fmt, higher_is_better=False)}$\\pm${lat_fmt(e12['e2e_latency_std'])} \\\\"
450
+ )
451
+ rows.append(
452
+ f"Routing Latency (s) & {bold_if_best(routings, 0, lat_fmt, higher_is_better=False)}$\\pm${lat_fmt(b['routing_latency_std'])} & "
453
+ f"{bold_if_best(routings, 1, lat_fmt, higher_is_better=False)}$\\pm${lat_fmt(e14['routing_latency_std'])} & "
454
+ f"{bold_if_best(routings, 2, lat_fmt, higher_is_better=False)}$\\pm${lat_fmt(e12['routing_latency_std'])} \\\\"
455
+ )
456
+ rows.append(
457
+ f"Tool Latency (s) & {bold_if_best(tools, 0, lat_fmt, higher_is_better=False)}$\\pm${lat_fmt(b['tool_latency_std'])} & "
458
+ f"{bold_if_best(tools, 1, lat_fmt, higher_is_better=False)}$\\pm${lat_fmt(e14['tool_latency_std'])} & "
459
+ f"{bold_if_best(tools, 2, lat_fmt, higher_is_better=False)}$\\pm${lat_fmt(e12['tool_latency_std'])} \\\\"
460
+ )
461
+
462
+ rows.append("\\bottomrule")
463
+ rows.append("\\end{tabular}")
464
+ rows.append("\\end{table}")
465
+ rows.append("")
466
+ return "\n".join(rows)
467
+
468
+
469
+ def build_confusion_table(conf_mat: Dict, split_name: str) -> str:
470
+ """Build a confusion matrix table in Markdown."""
471
+ classes = sorted({k[0] for k in conf_mat.keys()})
472
+ rows = []
473
+ rows.append(f"#### Confusion Matrix — {split_name}\n")
474
+ header = "| Pred \\ GT | " + " | ".join(f"Class {c}" for c in classes) + " |"
475
+ rows.append(header)
476
+ sep = "|" + "|".join(["---"] * (len(classes) + 1)) + "|"
477
+ rows.append(sep)
478
+ for gt in classes:
479
+ vals = [str(conf_mat.get((gt, pred), 0)) for pred in classes]
480
+ rows.append(f"| Class {gt} | " + " | ".join(vals) + " |")
481
+ rows.append("")
482
+ return "\n".join(rows)
483
+
484
+
485
+ def build_per_class_table(per_class: Dict, split_name: str) -> str:
486
+ """Build per-class precision/recall/F1 table."""
487
+ rows = []
488
+ rows.append(f"#### Per-Class Metrics — {split_name}\n")
489
+ rows.append("| Class | Support | Precision | Recall | F1 |")
490
+ rows.append("|-------|---------|-----------|--------|----|")
491
+ for cls_id in sorted(per_class.keys()):
492
+ m = per_class[cls_id]
493
+ rows.append(
494
+ f"| Class {cls_id} | {m['support']} | {m['precision']:.4f} | {m['recall']:.4f} | {m['f1']:.4f} |"
495
+ )
496
+ rows.append("")
497
+ return "\n".join(rows)
498
+
499
+
500
+ def build_action_distribution_table(action_dist: Dict, split_name: str) -> str:
501
+ """Build action distribution table."""
502
+ rows = []
503
+ rows.append(f"#### Action Distribution — {split_name}\n")
504
+ rows.append("| Action | Count | Percentage |")
505
+ rows.append("|--------|-------|------------|")
506
+ total = sum(action_dist.values()) if action_dist else 0
507
+ for action_id in sorted(action_dist.keys()):
508
+ count = action_dist[action_id]
509
+ pct = count / total if total > 0 else 0.0
510
+ rows.append(f"| {ACTION_TO_NAME.get(action_id, f'Action {action_id}')} | {count} | {pct:.2%} |")
511
+ rows.append("")
512
+ return "\n".join(rows)
513
+
514
+
515
+ def _stringify_keys(obj):
516
+ """Recursively convert dict keys to strings for JSON serialization."""
517
+ if isinstance(obj, dict):
518
+ return {str(k): _stringify_keys(v) for k, v in obj.items()}
519
+ elif isinstance(obj, list):
520
+ return [_stringify_keys(item) for item in obj]
521
+ return obj
522
+
523
+
524
+ # ---------------------------------------------------------------------------
525
+ # Main
526
+ # ---------------------------------------------------------------------------
527
+ def main():
528
+ parser = argparse.ArgumentParser(description="Benchmark evaluation for RL router")
529
+ parser.add_argument("--splits", nargs="+", default=["eng", "ko"], choices=["eng", "ko", "both"],
530
+ help="Which evaluation splits to run")
531
+ parser.add_argument("--output-dir", type=str, default=str(ROOT / "benchmark_results"),
532
+ help="Directory for outputs")
533
+ parser.add_argument("--call-tool", action="store_true", default=True,
534
+ help="Whether to call RAG tools (required for latency/accuracy measurement)")
535
+ parser.add_argument("--device", type=str, default="cpu",
536
+ help="Device for GRPO model inference")
537
+ parser.add_argument("--max-queries", type=int, default=None,
538
+ help="Limit queries per split (for smoke testing)")
539
+ parser.add_argument("--delay", type=float, default=1.0,
540
+ help="Delay in seconds between API calls to avoid rate limiting")
541
+ parser.add_argument("--backoff-on-429", action="store_true", default=True,
542
+ help="Enable exponential backoff when receiving 429 responses")
543
+ args = parser.parse_args()
544
+
545
+ output_dir = Path(args.output_dir)
546
+ os.makedirs(output_dir, exist_ok=True)
547
+
548
+ split_files = {
549
+ "eng": REPO_ROOT / "routing_agent" / "grpo_benchmark_en.csv",
550
+ "ko": REPO_ROOT / "routing_agent" / "grpo_benchmark_ko.csv",
551
+ }
552
+
553
+ splits_to_run = args.splits if "both" not in args.splits else ["eng", "ko"]
554
+ all_split_metrics = {}
555
+ all_detailed_dfs = {}
556
+
557
+ strategies = [BASELINE_LABEL] + [ckpt["label"] for ckpt in TOP_GRPO_CHECKPOINTS]
558
+
559
+ for split in splits_to_run:
560
+ csv_path = split_files[split]
561
+ if not csv_path.exists():
562
+ logger.error(f"Evaluation file not found: {csv_path}")
563
+ continue
564
+
565
+ df_eval = pd.read_csv(csv_path)
566
+ queries = df_eval["question"].tolist()
567
+ ground_truths = df_eval["action_label"].astype(int).tolist()
568
+
569
+ if args.max_queries:
570
+ queries = queries[: args.max_queries]
571
+ ground_truths = ground_truths[: args.max_queries]
572
+
573
+ logger.info(f"=== Evaluating {split} split: {len(queries)} queries ===")
574
+
575
+ # Pre-load GRPO models
576
+ grpo_models = {}
577
+ for ckpt in TOP_GRPO_CHECKPOINTS:
578
+ logger.info(f"Loading GRPO checkpoint: {ckpt['label']} from {ckpt['path']}")
579
+ model, embedder = load_grpo_checkpoint(ckpt["path"], device=args.device)
580
+ grpo_models[ckpt["label"]] = (model, embedder)
581
+
582
+ split_results = {}
583
+
584
+ # ---- Baseline: backend LLM router, no search_sources ----
585
+ logger.info(f"Running {BASELINE_LABEL} on {split}...")
586
+ def baseline_route(q):
587
+ # For baseline, we don't need local routing — backend decides.
588
+ # Return a dummy action; the actual routing happens inside backend.
589
+ return 0
590
+
591
+ df_base = evaluate_strategy(
592
+ name=BASELINE_LABEL,
593
+ queries=queries,
594
+ ground_truths=ground_truths,
595
+ route_fn=baseline_route,
596
+ call_tool=args.call_tool,
597
+ force_search_sources=False,
598
+ delay=args.delay,
599
+ )
600
+ split_results[BASELINE_LABEL] = df_base
601
+
602
+ # ---- GRPO checkpoints ----
603
+ for ckpt in TOP_GRPO_CHECKPOINTS:
604
+ label = ckpt["label"]
605
+ model, embedder = grpo_models[label]
606
+ logger.info(f"Running {label} on {split}...")
607
+
608
+ def make_route_fn(m, e):
609
+ def fn(q):
610
+ return grpo_route_query(m, e, q, device=args.device)
611
+ return fn
612
+
613
+ df_grpo = evaluate_strategy(
614
+ name=label,
615
+ queries=queries,
616
+ ground_truths=ground_truths,
617
+ route_fn=make_route_fn(model, embedder),
618
+ call_tool=args.call_tool,
619
+ force_search_sources=True,
620
+ delay=args.delay,
621
+ )
622
+ split_results[label] = df_grpo
623
+
624
+ # Save detailed CSV per strategy
625
+ for strategy, df_res in split_results.items():
626
+ out_csv = output_dir / f"{split}_{strategy}_detailed.csv"
627
+ df_res.to_csv(out_csv, index=False)
628
+ logger.info(f"Saved detailed results: {out_csv}")
629
+
630
+ # Compute metrics
631
+ metrics = {}
632
+ for strategy, df_res in split_results.items():
633
+ metrics[strategy] = compute_metrics(df_res)
634
+
635
+ # Statistical tests (baseline vs each GRPO) on E2E latency
636
+ base_latencies = split_results[BASELINE_LABEL]["end_to_end_time_sec"].dropna().tolist()
637
+ stat_tests = {}
638
+ for ckpt in TOP_GRPO_CHECKPOINTS:
639
+ label = ckpt["label"]
640
+ treat_latencies = split_results[label]["end_to_end_time_sec"].dropna().tolist()
641
+ stat_tests[label] = {
642
+ "paired_t": paired_t_test_latency(base_latencies, treat_latencies),
643
+ "baseline_latency_ci": mean_confidence_interval(base_latencies),
644
+ "treatment_latency_ci": mean_confidence_interval(treat_latencies),
645
+ }
646
+
647
+ all_split_metrics[split] = {"metrics": metrics, "stat_tests": stat_tests}
648
+ all_detailed_dfs[split] = split_results
649
+
650
+ # Print tables to console and save to markdown
651
+ md_tables = []
652
+ md_tables.append(build_comparison_table(metrics, split))
653
+ for strategy in [BASELINE_LABEL] + [ckpt["label"] for ckpt in TOP_GRPO_CHECKPOINTS]:
654
+ md_tables.append(build_confusion_table(metrics[strategy]["confusion_matrix"], split))
655
+ md_tables.append(build_per_class_table(metrics[strategy]["per_class"], split))
656
+ md_tables.append(build_action_distribution_table(metrics[strategy]["action_distribution"], split))
657
+
658
+ md_report = "\n".join(md_tables)
659
+ report_path = output_dir / f"{split}_report.md"
660
+ with open(report_path, "w", encoding="utf-8") as f:
661
+ f.write(md_report)
662
+ logger.info(f"Saved report: {report_path}")
663
+ print(md_report)
664
+
665
+ # Save LaTeX tables
666
+ latex_path = output_dir / f"{split}_tables.tex"
667
+ with open(latex_path, "w", encoding="utf-8") as f:
668
+ f.write(build_latex_table(metrics, split))
669
+ f.write("\n")
670
+ logger.info(f"Saved LaTeX tables: {latex_path}")
671
+
672
+ # Save JSON metrics for programmatic access
673
+ json_path = output_dir / f"{split}_metrics.json"
674
+ with open(json_path, "w", encoding="utf-8") as f:
675
+ json.dump(
676
+ _stringify_keys({
677
+ "metrics": metrics,
678
+ "stat_tests": {
679
+ k: {
680
+ t_label: (
681
+ {tk: tv for tk, tv in t_val.items()}
682
+ if isinstance(t_val, dict)
683
+ else list(t_val)
684
+ )
685
+ for t_label, t_val in stat_tests[k].items()
686
+ }
687
+ for k, t_val in stat_tests.items()
688
+ },
689
+ }),
690
+ f,
691
+ indent=2,
692
+ default=str,
693
+ )
694
+ logger.info(f"Saved JSON metrics: {json_path}")
695
+
696
+ # ---- Combined (both splits) aggregate ----
697
+ if len(splits_to_run) == 2:
698
+ logger.info("=== Building combined aggregate report ===")
699
+ combined_results = {}
700
+ for strategy in strategies:
701
+ combined_results[strategy] = pd.concat(
702
+ [all_detailed_dfs["eng"][strategy], all_detailed_dfs["ko"][strategy]], ignore_index=True
703
+ )
704
+ out_csv = output_dir / f"combined_{strategy}_detailed.csv"
705
+ combined_results[strategy].to_csv(out_csv, index=False)
706
+
707
+ combined_metrics = {s: compute_metrics(df) for s, df in combined_results.items()}
708
+
709
+ base_latencies_combined = combined_results[BASELINE_LABEL]["end_to_end_time_sec"].dropna().tolist()
710
+ combined_stat = {}
711
+ for ckpt in TOP_GRPO_CHECKPOINTS:
712
+ label = ckpt["label"]
713
+ treat = combined_results[label]["end_to_end_time_sec"].dropna().tolist()
714
+ combined_stat[label] = {
715
+ "paired_t": paired_t_test_latency(base_latencies_combined, treat),
716
+ }
717
+
718
+ md_combined = []
719
+ md_combined.append(build_comparison_table(combined_metrics, "Combined"))
720
+ report_path = output_dir / "combined_report.md"
721
+ with open(report_path, "w", encoding="utf-8") as f:
722
+ f.write("\n".join(md_combined))
723
+ logger.info(f"Saved combined report: {report_path}")
724
+ print("\n".join(md_combined))
725
+
726
+ latex_combined = output_dir / "combined_tables.tex"
727
+ with open(latex_combined, "w", encoding="utf-8") as f:
728
+ f.write(build_latex_table(combined_metrics, "Combined"))
729
+ logger.info(f"Saved combined LaTeX: {latex_combined}")
730
+
731
+ logger.info("Benchmark evaluation complete.")
732
+
733
+
734
+ if __name__ == "__main__":
735
+ main()
eval_with_timing.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import time
4
+ import traceback
5
+ import pandas as pd
6
+
7
+ # Ensure local package imports work
8
+ ROOT = os.path.dirname(__file__)
9
+ if ROOT not in sys.path:
10
+ sys.path.insert(0, ROOT)
11
+
12
+ import run_inference as ri
13
+
14
+
15
+ def predict_and_time(input_csv: str, output_csv: str):
16
+ df = pd.read_csv(input_csv)
17
+ results = []
18
+
19
+ for idx, row in df.iterrows():
20
+ q = row.get('question')
21
+ ground = row.get('action_label')
22
+ try:
23
+ start = time.perf_counter()
24
+ # fast routing-only prediction using loaded embedder + model
25
+ with ri.torch.no_grad():
26
+ emb = ri.embedder.encode(q, convert_to_tensor=True).to(ri.DEVICE)
27
+ action, log_prob, _ = ri.model.get_action(emb)
28
+ pred_idx = int(action.item())
29
+ end = time.perf_counter()
30
+ elapsed = end - start
31
+ except Exception:
32
+ pred_idx = None
33
+ elapsed = None
34
+ traceback.print_exc()
35
+
36
+ results.append({
37
+ 'question': q,
38
+ 'ground_truth': ground,
39
+ 'predicted_label': pred_idx,
40
+ 'time_took': elapsed,
41
+ })
42
+
43
+ out_df = pd.DataFrame(results)
44
+ out_df.to_csv(output_csv, index=False)
45
+ print(f"Wrote results to {output_csv} ({len(out_df)} rows)")
46
+
47
+
48
+ if __name__ == '__main__':
49
+ inp = os.path.join(ROOT, 'eval_queries.csv')
50
+ out = os.path.join(ROOT, 'eval_with_timing_results.csv')
51
+ print(f"Input: {inp}\nOutput: {out}\nDevice: {ri.DEVICE}")
52
+ predict_and_time(inp, out)
generate_paper_results.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ generate_paper_results.py — Generate publication-ready results from benchmark outputs.
4
+
5
+ Reads the per-split metrics JSON files produced by benchmark_evaluation.py and
6
+ writes:
7
+ - LaTeX tables (main results, per-class, latency comparisons)
8
+ - Statistical significance summary
9
+ - Plain-text summaries
10
+
11
+ Intended to be run AFTER benchmark_evaluation.py has completed.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import logging
17
+ import argparse
18
+ from pathlib import Path
19
+ from typing import Dict, List
20
+
21
+ logger = logging.getLogger("generate_paper_results")
22
+
23
+ STRATEGIES = ["Baseline-LLM", "GRPO-E14", "GRPO-E12"]
24
+
25
+
26
+ def load_json(path: Path) -> Dict:
27
+ with open(path, "r", encoding="utf-8") as f:
28
+ return json.load(f)
29
+
30
+
31
+ def fmt_pct(v: float) -> str:
32
+ return f"{v:.2%}"
33
+
34
+
35
+ def fmt_num(v: float, decimals: int = 4) -> str:
36
+ return f"{v:.{decimals}f}"
37
+
38
+
39
+ def best(values: List[float], higher_is_better: bool = True) -> int:
40
+ idx = 0
41
+ best_val = values[0]
42
+ for i, v in enumerate(values[1:], 1):
43
+ if (higher_is_better and v > best_val) or (not higher_is_better and v < best_val):
44
+ best_val = v
45
+ idx = i
46
+ return idx
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Table generators
51
+ # ---------------------------------------------------------------------------
52
+ def main_results_table(all_metrics: Dict[str, Dict]) -> str:
53
+ """Generate the primary results table for the Results section."""
54
+ lines = []
55
+ lines.append("% Auto-generated by generate_paper_results.py")
56
+ lines.append("\\begin{table}[ht]")
57
+ lines.append("\\centering")
58
+ lines.append("\\caption{Comparative evaluation of routing strategies. "
59
+ "Mean $\\pm$ standard deviation reported. "
60
+ "Best result per row is bolded.}")
61
+ lines.append("\\label{tab:main_results}")
62
+ lines.append("\\begin{tabular}{lccc}")
63
+ lines.append("\\toprule")
64
+ lines.append("Metric & Baseline-LLM (Qwen3-14B) & GRPO-E14 & GRPO-E12 \\\\")
65
+ lines.append("\\midrule")
66
+
67
+ # Routing Accuracy
68
+ accs = [all_metrics[s]["accuracy"] for s in STRATEGIES]
69
+ bi = best(accs, higher_is_better=True)
70
+ acc_strs = [fmt_pct(v) for v in accs]
71
+ acc_strs[bi] = "\\textbf{" + acc_strs[bi] + "}"
72
+ lines.append(f"Routing Accuracy & {acc_strs[0]} & {acc_strs[1]} & {acc_strs[2]} \\\\")
73
+
74
+ # E2E Latency
75
+ e2e_means = [all_metrics[s]["e2e_latency_mean"] for s in STRATEGIES]
76
+ e2e_stds = [all_metrics[s]["e2e_latency_std"] for s in STRATEGIES]
77
+ bi = best(e2e_means, higher_is_better=False)
78
+ cells = []
79
+ for i, s in enumerate(STRATEGIES):
80
+ cell = f"{fmt_num(e2e_means[i], 3)}$\\pm${fmt_num(e2e_stds[i], 3)}"
81
+ if i == bi:
82
+ cell = "\\textbf{" + cell + "}"
83
+ cells.append(cell)
84
+ lines.append(f"E2E Latency (s) & {cells[0]} & {cells[1]} & {cells[2]} \\\\")
85
+
86
+ # Routing Latency
87
+ r_means = [all_metrics[s]["routing_latency_mean"] for s in STRATEGIES]
88
+ r_stds = [all_metrics[s]["routing_latency_std"] for s in STRATEGIES]
89
+ bi = best(r_means, higher_is_better=False)
90
+ cells = []
91
+ for i, s in enumerate(STRATEGIES):
92
+ cell = f"{fmt_num(r_means[i], 4)}$\\pm${fmt_num(r_stds[i], 4)}"
93
+ if i == bi:
94
+ cell = "\\textbf{" + cell + "}"
95
+ cells.append(cell)
96
+ lines.append(f"Routing Latency (s) & {cells[0]} & {cells[1]} & {cells[2]} \\\\")
97
+
98
+ # Tool Latency
99
+ t_means = [all_metrics[s]["tool_latency_mean"] for s in STRATEGIES]
100
+ t_stds = [all_metrics[s]["tool_latency_std"] for s in STRATEGIES]
101
+ bi = best(t_means, higher_is_better=False)
102
+ cells = []
103
+ for i, s in enumerate(STRATEGIES):
104
+ cell = f"{fmt_num(t_means[i], 3)}$\\pm${fmt_num(t_stds[i], 3)}"
105
+ if i == bi:
106
+ cell = "\\textbf{" + cell + "}"
107
+ cells.append(cell)
108
+ lines.append(f"Tool Latency (s) & {cells[0]} & {cells[1]} & {cells[2]} \\\\")
109
+
110
+ lines.append("\\bottomrule")
111
+ lines.append("\\end{tabular}")
112
+ lines.append("\\end{table}")
113
+ lines.append("")
114
+ return "\n".join(lines)
115
+
116
+
117
+ def per_class_table(all_metrics: Dict[str, Dict]) -> str:
118
+ """Generate per-class precision/recall/F1 table."""
119
+ lines = []
120
+ lines.append("\\begin{table}[ht]")
121
+ lines.append("\\centering")
122
+ lines.append("\\caption{Per-class routing performance.}")
123
+ lines.append("\\label{tab:per_class}")
124
+ lines.append("\\begin{tabular}{lcccccc}")
125
+ lines.append("\\toprule")
126
+ lines.append("Strategy & Class & Support & Precision & Recall & F1 \\\\")
127
+ lines.append("\\midrule")
128
+ for s in STRATEGIES:
129
+ pc = all_metrics[s]["per_class"]
130
+ for cls_id in sorted(pc.keys()):
131
+ m = pc[cls_id]
132
+ lines.append(
133
+ f"{s} & {cls_id} & {m['support']} & "
134
+ f"{fmt_num(m['precision'])} & {fmt_num(m['recall'])} & {fmt_num(m['f1'])} \\\\"
135
+ )
136
+ lines.append("\\bottomrule")
137
+ lines.append("\\end{tabular}")
138
+ lines.append("\\end{table}")
139
+ lines.append("")
140
+ return "\n".join(lines)
141
+
142
+
143
+ def statistical_summary_table(stat_tests: Dict) -> str:
144
+ """Generate statistical significance summary."""
145
+ lines = []
146
+ lines.append("\\begin{table}[ht]")
147
+ lines.append("\\centering")
148
+ lines.append("\\caption{Paired statistical tests comparing each GRPO variant against the baseline. "
149
+ "$t$-statistic and $p$-value from paired $t$-test on per-query end-to-end latency.}")
150
+ lines.append("\\label{tab:statistical_tests}")
151
+ lines.append("\\begin{tabular}{lcc}")
152
+ lines.append("\\toprule")
153
+ lines.append("Comparison & $t$-statistic & $p$-value \\\\")
154
+ lines.append("\\midrule")
155
+ for label, tests in stat_tests.items():
156
+ t = tests.get("paired_t", {})
157
+ t_stat = t.get("t_stat")
158
+ p_val = t.get("p_value")
159
+ t_str = fmt_num(t_stat, 3) if t_stat is not None else "---"
160
+ p_str = fmt_num(p_val, 4) if p_val is not None else "---"
161
+ sig = " *" if p_val is not None and p_val < 0.05 else ""
162
+ lines.append(f"Baseline vs {label} & {t_str} & {p_str}{sig} \\\\")
163
+ lines.append("\\bottomrule")
164
+ lines.append("\\end{tabular}")
165
+ lines.append("\\end{table}")
166
+ lines.append("")
167
+ return "\n".join(lines)
168
+
169
+
170
+ def training_curve_table() -> str:
171
+ """Generate a table of training checkpoint performance."""
172
+ import torch
173
+ adapters_dir = Path(__file__).resolve().parent / "routing_agent" / "adapters"
174
+ rows = []
175
+ rows.append("\\begin{table}[ht]")
176
+ rows.append("\\centering")
177
+ rows.append("\\caption{GRPO training checkpoint performance (training reward).}")
178
+ rows.append("\\label{tab:training_curve}")
179
+ rows.append("\\begin{tabular}{lccc}")
180
+ rows.append("\\toprule")
181
+ rows.append("Epoch & Avg Reward & Avg Loss & KL Divergence \\\\")
182
+ rows.append("\\midrule")
183
+
184
+ checkpoints = []
185
+ for f in sorted(adapters_dir.glob("router_grpo_epoch_*.pt")):
186
+ ckpt = torch.load(str(f), map_location="cpu")
187
+ epoch = ckpt.get("epoch", int(f.stem.split("_")[-1]))
188
+ checkpoints.append((epoch, ckpt.get("avg_reward", 0), ckpt.get("avg_loss", 0), ckpt.get("kl_div", 0)))
189
+
190
+ # Show top-5 and last 5
191
+ checkpoints.sort(key=lambda x: x[1], reverse=True)
192
+ top5 = checkpoints[:5]
193
+ bottom5 = checkpoints[-5:] if len(checkpoints) > 5 else []
194
+ shown = top5 + bottom5
195
+
196
+ for epoch, reward, loss, kl in shown:
197
+ marker = " *" if epoch in [12, 14] else ""
198
+ rows.append(f"{epoch} & {fmt_num(reward, 4)} & {fmt_num(loss, 4)} & {fmt_num(kl, 6)}{marker} \\\\")
199
+
200
+ rows.append("\\bottomrule")
201
+ rows.append("\\end{tabular}")
202
+ rows.append("\\end{table}")
203
+ rows.append("")
204
+ return "\n".join(rows)
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Main
209
+ # ---------------------------------------------------------------------------
210
+ def main():
211
+ parser = argparse.ArgumentParser(description="Generate paper-ready results from benchmark JSONs")
212
+ parser.add_argument("--input-dir", type=str, default="benchmark_results",
213
+ help="Directory containing benchmark metrics JSONs")
214
+ parser.add_argument("--output-dir", type=str, default="paper_results",
215
+ help="Output directory for LaTeX tables")
216
+ parser.add_argument("--splits", nargs="+", default=["eng", "ko", "combined"],
217
+ help="Splits to include in aggregate")
218
+ args = parser.parse_args()
219
+
220
+ input_dir = Path(args.input_dir)
221
+ output_dir = Path(args.output_dir)
222
+ os.makedirs(output_dir, exist_ok=True)
223
+
224
+ # Load all metrics
225
+ all_metrics = {}
226
+ all_stat_tests = {}
227
+ for split in args.splits:
228
+ json_path = input_dir / f"{split}_metrics.json"
229
+ if not json_path.exists():
230
+ logger.warning(f"Missing metrics file: {json_path}")
231
+ continue
232
+ data = load_json(json_path)
233
+ for s, m in data["metrics"].items():
234
+ if s not in all_metrics:
235
+ all_metrics[s] = {}
236
+ for k, v in m.items():
237
+ if k not in all_metrics[s]:
238
+ all_metrics[s][k] = v
239
+ else:
240
+ # average across splits
241
+ if isinstance(v, (int, float)):
242
+ all_metrics[s][k] = (all_metrics[s][k] + v) / 2
243
+ for s, st in data["stat_tests"].items():
244
+ if s not in all_stat_tests:
245
+ all_stat_tests[s] = st
246
+
247
+ if not all_metrics:
248
+ print("No metrics loaded. Run benchmark_evaluation.py first.")
249
+ return
250
+
251
+ # Generate LaTeX
252
+ latex_content = []
253
+ latex_content.append(main_results_table(all_metrics))
254
+ latex_content.append(per_class_table(all_metrics))
255
+ latex_content.append(statistical_summary_table(all_stat_tests))
256
+ latex_content.append(training_curve_table())
257
+
258
+ latex_path = output_dir / "results_tables.tex"
259
+ with open(latex_path, "w", encoding="utf-8") as f:
260
+ f.write("\n".join(latex_content))
261
+ print(f"Generated LaTeX tables: {latex_path}")
262
+
263
+ # Generate plain-text summary
264
+ txt_path = output_dir / "results_summary.txt"
265
+ with open(txt_path, "w", encoding="utf-8") as f:
266
+ f.write("=" * 70 + "\n")
267
+ f.write("BENCHMARK RESULTS SUMMARY\n")
268
+ f.write("=" * 70 + "\n\n")
269
+ for s in STRATEGIES:
270
+ m = all_metrics[s]
271
+ f.write(f"[{s}]\n")
272
+ f.write(f" Accuracy: {fmt_pct(m['accuracy'])}\n")
273
+ f.write(f" E2E Latency (s): {fmt_num(m['e2e_latency_mean'], 3)} ± {fmt_num(m['e2e_latency_std'], 3)}\n")
274
+ f.write(f" Routing Lat (s): {fmt_num(m['routing_latency_mean'], 4)} ± {fmt_num(m['routing_latency_std'], 4)}\n")
275
+ f.write(f" Tool Lat (s): {fmt_num(m['tool_latency_mean'], 3)} ± {fmt_num(m['tool_latency_std'], 3)}\n")
276
+ f.write("\n")
277
+ print(f"Generated plain-text summary: {txt_path}")
278
+
279
+
280
+ if __name__ == "__main__":
281
+ main()
grpo_trainer.py ADDED
@@ -0,0 +1,840 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+ import torch.nn.functional as F
4
+ import logging
5
+ import csv
6
+ from datetime import datetime
7
+ from policy import RouterPolicy
8
+ import copy
9
+ import uuid
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Logger
13
+ # ---------------------------------------------------------------------------
14
+ logger = logging.getLogger("grpo_trainer")
15
+ if not logger.handlers:
16
+ logger.setLevel(logging.INFO)
17
+ fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
18
+ ch = logging.StreamHandler()
19
+ ch.setFormatter(fmt)
20
+ logger.addHandler(ch)
21
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
22
+ logs_dir = os.path.join(repo_root, "logs")
23
+ try:
24
+ os.makedirs(logs_dir, exist_ok=True)
25
+ except Exception:
26
+ logs_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
27
+ fh = logging.FileHandler(os.path.join(logs_dir, "grpo_trainer.log"))
28
+ fh.setFormatter(fmt)
29
+ logger.addHandler(fh)
30
+
31
+
32
+ def _truncate(s: str, n: int = 500) -> str:
33
+ if s is None:
34
+ return ""
35
+ s = str(s)
36
+ return s if len(s) <= n else s[: n - 1] + "\u2026"
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # RAG pipeline (unchanged from ppo_trainer)
41
+ # ---------------------------------------------------------------------------
42
+ def simulate_rag_pipeline(query, action_idx):
43
+ """
44
+ Maps action index to actual RAG modules via the backend API.
45
+ 0: Unstructured / guidelines
46
+ 1: Website / web-search
47
+ 2: Structured / CBNU database
48
+ """
49
+ import sys
50
+
51
+ project_root = os.path.abspath(os.path.join(os.getcwd(), ".."))
52
+ backend_path = os.path.join(project_root, "backend")
53
+ sys.path.append(project_root)
54
+ sys.path.append(backend_path)
55
+
56
+ import asyncio
57
+ import requests
58
+
59
+ def detect_language(text):
60
+ if any("\uac00" <= char <= "\ud7a3" for char in text):
61
+ return "ko"
62
+ elif any("\u4e00" <= char <= "\u9fff" for char in text):
63
+ return "zh"
64
+ else:
65
+ return "en"
66
+
67
+ async def _call_tool(q, act_idx):
68
+ language = detect_language(q)
69
+ print(f"Detected language: {language}")
70
+ if act_idx == 0:
71
+ search_sources = ["guidelines"]
72
+ elif act_idx == 1:
73
+ search_sources = ["web-search"]
74
+ else:
75
+ search_sources = ["course-registration", "department-info"]
76
+
77
+ url = "http://localhost:7000/api/chat"
78
+ headers = {"accept": "application/json", "Content-Type": "application/json"}
79
+ payload = {
80
+ "message": q,
81
+ "language": language,
82
+ "search_sources": search_sources,
83
+ "session_id": f"grpo_train-{uuid.uuid4().hex}",
84
+ }
85
+ try:
86
+ response = requests.post(url, headers=headers, json=payload)
87
+ response.raise_for_status()
88
+ data = response.json()
89
+ answer = data.get("message", "")
90
+ context_parts = []
91
+ for src in data.get("sources", []):
92
+ if isinstance(src, dict) and src.get("success"):
93
+ for res in src.get("data", {}).get("results", []):
94
+ if "content" in res:
95
+ context_parts.append(res["content"])
96
+ elif "snippet" in res:
97
+ context_parts.append(res["snippet"])
98
+ context = "\n".join(context_parts)
99
+ print("Tool Result fetched successfully via API.")
100
+ return context, answer
101
+ except Exception as e:
102
+ print(f"API call failed: {e}")
103
+ return "", ""
104
+
105
+ try:
106
+ ctx, ans = asyncio.run(_call_tool(query, int(action_idx)))
107
+ return ctx, ans
108
+ except Exception as e:
109
+ return "", f"simulate_rag_pipeline failed: {e}"
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # LLM Judge Rewards (Gemini + OpenAI) — identical prompts to ppo_trainer
114
+ # ---------------------------------------------------------------------------
115
+ def llm_judge_reward(query, context, answer, action_idx):
116
+ import re
117
+ import requests
118
+
119
+ try:
120
+ model_name = "gemini-2.5-flash"
121
+ gemini_url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent"
122
+ action_taken = f"Action {action_idx}"
123
+ gemini_key = "AIzaSyBoErDeUH1kRBbusI6qkMwISw5euyuXwZo"
124
+ gemini_token = os.getenv("GEMINI_API_TOKEN") or os.getenv("GCP_ACCESS_TOKEN")
125
+
126
+ Old_prompt = f"""You are an expert automated RAG evaluator.
127
+ Your task is to grade the retrieved Context and the generated Answer based on the user's Query and the Action taken by the routing agent.
128
+
129
+ ### STRICT ROUTING RULES:
130
+ The agent MUST select the correct action based on the query intent:
131
+ - Action 0 (guidelines): Required for academic information, university rules, visa, scholarships, graduation requirements, health insurance, and part-time job info.
132
+ - Action 1 (web_search): Required for university news, announcements, and external information.
133
+ - Action 2 (cbnu_database): Required for department info (contacts, phone, email, website) and course registration (course details, schedules, professors, credits).
134
+
135
+ ### GRADING RUBRIC:
136
+ 1. Context Relevance (R_rel):
137
+ - FATAL PENALTY: Did the agent choose an action that violates the STRICT ROUTING RULES above? If YES, you MUST score R_rel as 0.0 immediately, regardless of the context.
138
+ - 0.0: Violates routing rules OR context is completely irrelevant/useless.
139
+ - 0.5: Follows rules, but context contains only partial information.
140
+ - 1.0: Follows rules AND context contains all necessary information to fully answer the query.
141
+
142
+ 2. Answer Correctness (R_corr): Is the Answer fully supported by the Context? (IGNORE your internal knowledge. Grade strictly on the provided context).
143
+ - 0.0: Hallucinates information not in the context, or completely fails to answer the query.
144
+ - 0.5: Partially correct, but misses key details from the context or includes slight hallucinations.
145
+ - 1.0: Perfectly accurate, directly answers the query, and is 100% faithful to the context.
146
+
147
+ ### INPUTS:
148
+ Query: {query}
149
+ Action Taken: {action_taken}
150
+ Context: {context}
151
+ Answer: {answer}
152
+
153
+ ### OUTPUT FORMAT:
154
+ You must return ONLY a valid JSON object. Do not use markdown blocks (```json).
155
+ Output this exact structure:
156
+ {{
157
+ "relevance_reasoning": "Brief 1-sentence explanation of why the rule was followed/broken and if context is relevant.",
158
+ "R_rel": <float>,
159
+ "correctness_reasoning": "Brief 1-sentence explanation of answer correctness and faithfulness.",
160
+ "R_corr": <float>
161
+ }}
162
+ """
163
+
164
+ prompt = f"""You are an expert automated RAG evaluator.
165
+ Your task is to grade the retrieved Context and the generated Answer based on the user's Query.
166
+
167
+ ### GRADING RUBRIC:
168
+ 1. Context Relevance (R_rel): How relevant is the retrieved Context to the user's Query?
169
+ - 0.0: The context is completely irrelevant or useless for answering the query.
170
+ - 0.5: The context is somewhat relevant but only contains partial information needed to answer the query.
171
+ - 1.0: The context is highly relevant and contains all necessary information to fully answer the query.
172
+
173
+ 2. Answer Correctness (R_corr): Is the Answer fully supported by the Context? (IGNORE your internal knowledge. Grade strictly on the provided context).
174
+ - 0.0: Hallucinates information not in the context, or completely fails to answer the query.
175
+ - 0.5: Partially correct, but misses key details from the context or includes slight hallucinations.
176
+ - 1.0: Perfectly accurate, directly answers the query, and is 100% faithful to the context.
177
+
178
+ ### INPUTS:
179
+ Query: {query}
180
+ Context: {context}
181
+ Answer: {answer}
182
+
183
+ ### OUTPUT FORMAT:
184
+ You must return ONLY a valid JSON object. Do not use markdown blocks (```json).
185
+ Output this exact structure:
186
+ {{
187
+ "relevance_reasoning": "Brief 1-sentence explanation of why the context is or isn't relevant to the query.",
188
+ "R_rel": <float>,
189
+ "correctness_reasoning": "Brief 1-sentence explanation of answer correctness and faithfulness.",
190
+ "R_corr": <float>
191
+ }}
192
+ """
193
+ headers = {"Content-Type": "application/json"}
194
+ params = {}
195
+ if gemini_key:
196
+ params = {"key": gemini_key}
197
+ elif gemini_token:
198
+ headers["Authorization"] = f"Bearer {gemini_token}"
199
+
200
+ payload = {
201
+ "contents": [{"parts": [{"text": prompt}]}],
202
+ "generationConfig": {"temperature": 0.0, "responseMimeType": "application/json"},
203
+ }
204
+ resp = requests.post(gemini_url, params=params, json=payload, headers=headers, timeout=15)
205
+ resp.raise_for_status()
206
+ data = resp.json()
207
+ try:
208
+ text = data["candidates"][0]["content"]["parts"][0]["text"]
209
+ except (KeyError, IndexError):
210
+ text = resp.text
211
+ m_rel = re.search(r'"R_rel"\s*:\s*([0-9]*\.?[0-9]+)', text)
212
+ m_corr = re.search(r'"R_corr"\s*:\s*([0-9]*\.?[0-9]+)', text)
213
+ if m_rel and m_corr:
214
+ R_rel, R_corr = float(m_rel.group(1)), float(m_corr.group(1))
215
+ else:
216
+ nums = re.findall(r"([0-9]*\.?[0-9]+)", text)
217
+ R_rel, R_corr = (float(nums[0]), float(nums[1])) if len(nums) >= 2 else (0.8, 0.9)
218
+ except Exception as e:
219
+ print(f"LLM judge failed, using fallback reward. Error: {e}", flush=True)
220
+ R_rel, R_corr = 0.7, 0.7
221
+
222
+ alpha, beta = 0.7, 0.3
223
+ print(f"LLM Judge for '{query[:50]}...' Scores => R_rel: {R_rel}, R_corr: {R_corr}", flush=True)
224
+ return float(R_rel * (alpha + beta * R_corr))
225
+
226
+
227
+ def llm_judge_reward_openai(query, context, answer, action_idx):
228
+ import re
229
+ import requests
230
+
231
+ try:
232
+ model_name = "gpt-4o"
233
+ openai_url = "https://api.openai.com/v1/chat/completions"
234
+ action_taken = f"Action {action_idx}"
235
+ openai_key = "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"
236
+ if not openai_key:
237
+ print("OPENAI_API_KEY not found.", flush=True)
238
+ return 0.0
239
+
240
+ prompt = f"""You are an expert automated RAG evaluator.
241
+ Your task is to grade the retrieved Context and the generated Answer based on the user's Query and the Action taken by the routing agent.
242
+
243
+ ### STRICT ROUTING RULES:
244
+ The agent MUST select the correct action based on the query intent:
245
+ - Action 0 (guidelines): Required for academic information, university rules, visa, scholarships, graduation requirements, health insurance, and part-time job info.
246
+ - Action 1 (web_search): Required for university news, announcements, and external information.
247
+ - Action 2 (cbnu_database): Required for department info (contacts, phone, email, website) and course registration (course details, schedules, professors, credits).
248
+
249
+ ### GRADING RUBRIC:
250
+ 1. Context Relevance (R_rel):
251
+ - FATAL PENALTY: If the agent chose an action violating STRICT ROUTING RULES, score R_rel as 0.0 immediately.
252
+ - 0.0: Violates routing rules OR context is completely irrelevant/useless.
253
+ - 0.5: Follows rules, but context contains only partial information.
254
+ - 1.0: Follows rules AND context contains all necessary information.
255
+
256
+ 2. Answer Correctness (R_corr): Is the Answer fully supported by the Context?
257
+ - 0.0: Hallucinated or completely fails.
258
+ - 0.5: Partially correct with minor issues.
259
+ - 1.0: Perfectly accurate and faithful to context.
260
+
261
+ ### INPUTS:
262
+ Query: {query}
263
+ Action Taken: {action_taken}
264
+ Context: {context}
265
+ Answer: {answer}
266
+
267
+ ### OUTPUT FORMAT:
268
+ Return ONLY a valid JSON object (no markdown blocks):
269
+ {{
270
+ "relevance_reasoning": "...",
271
+ "R_rel": <float>,
272
+ "correctness_reasoning": "...",
273
+ "R_corr": <float>
274
+ }}
275
+ """
276
+ headers = {"Content-Type": "application/json", "Authorization": f"Bearer {openai_key}"}
277
+ payload = {
278
+ "model": model_name,
279
+ "messages": [{"role": "user", "content": prompt}],
280
+ "temperature": 0.0,
281
+ "response_format": {"type": "json_object"},
282
+ }
283
+ resp = requests.post(openai_url, json=payload, headers=headers, timeout=15)
284
+ resp.raise_for_status()
285
+ text = resp.json()["choices"][0]["message"]["content"]
286
+ m_rel = re.search(r'"R_rel"\s*:\s*([0-9]*\.?[0-9]+)', text)
287
+ m_corr = re.search(r'"R_corr"\s*:\s*([0-9]*\.?[0-9]+)', text)
288
+ if m_rel and m_corr:
289
+ R_rel, R_corr = float(m_rel.group(1)), float(m_corr.group(1))
290
+ else:
291
+ nums = re.findall(r"([0-9]*\.?[0-9]+)", text)
292
+ R_rel, R_corr = (float(nums[0]), float(nums[1])) if len(nums) >= 2 else (0.8, 0.9)
293
+ except Exception as e:
294
+ print(f"OpenAI LLM judge failed, using fallback reward. Error: {e}", flush=True)
295
+ R_rel, R_corr = 0.7, 0.7
296
+
297
+ alpha, beta = 0.7, 0.3
298
+ print(f"OpenAI Judge for '{query[:50]}...' Scores => R_rel: {R_rel}, R_corr: {R_corr}", flush=True)
299
+ return float(R_rel * (alpha + beta * R_corr))
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Checkpoint helpers (unchanged interface)
304
+ # ---------------------------------------------------------------------------
305
+ def load_router_policy(checkpoint_path: str, embedding_dim=None, device: str = "cpu") -> RouterPolicy:
306
+ return RouterPolicy.load(checkpoint_path, embedding_dim=embedding_dim, device=device)
307
+
308
+
309
+ def save_policy_checkpoint(policy: RouterPolicy, path: str, optimizer=None, meta=None):
310
+ return policy.save_checkpoint(path, optimizer=optimizer, meta=meta)
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # GRPO Core Update
315
+ # ---------------------------------------------------------------------------
316
+ def grpo_update(
317
+ model,
318
+ ref_model,
319
+ optimizer,
320
+ states, # Tensor [B, embed_dim]
321
+ actions, # Tensor [B]
322
+ old_log_probs, # Tensor [B] — log-probs from the sampling policy
323
+ group_advantages, # Tensor [B] — already normalised per group
324
+ current_epoch: int,
325
+ total_epochs: int,
326
+ clip_ratio: float = 0.2,
327
+ kl_coef: float = 0.04,
328
+ ):
329
+ """
330
+ GRPO (Group Relative Policy Optimisation) update step.
331
+
332
+ Key differences vs PPO:
333
+ - No value/critic network: advantages are computed as within-group reward
334
+ normalisation (mean/std over the G samples drawn for the same query).
335
+ - KL divergence against a frozen reference policy is added as a regulariser
336
+ instead of (or alongside) the clipped surrogate.
337
+ - Dynamic entropy annealing is preserved from the original PPO implementation
338
+ as a paper novelty.
339
+ """
340
+ model.train()
341
+ device = next(model.parameters()).device
342
+
343
+ states = states.to(device)
344
+ actions = actions.to(device)
345
+ old_log_probs = old_log_probs.to(device)
346
+ group_advantages = group_advantages.to(device)
347
+
348
+ # ---- Current policy forward pass (actor logits only) ----
349
+ # RouterNetwork.forward() returns (logits, value); we use only logits.
350
+ logits, _ = model(states)
351
+
352
+ # Temperature scaling — keeps the same paper novelty as in ppo_update
353
+ temperature = 1.5
354
+ probs = F.softmax(logits / temperature, dim=-1)
355
+ dist = torch.distributions.Categorical(probs)
356
+
357
+ new_log_probs = dist.log_prob(actions)
358
+ entropy = dist.entropy().mean()
359
+
360
+ # ---- Clipped surrogate objective (same as PPO) ----
361
+ ratios = torch.exp(new_log_probs - old_log_probs)
362
+ surr1 = ratios * group_advantages
363
+ surr2 = torch.clamp(ratios, 1.0 - clip_ratio, 1.0 + clip_ratio) * group_advantages
364
+ actor_loss = -torch.min(surr1, surr2).mean()
365
+
366
+ # ---- KL penalty against frozen reference policy ----
367
+ with torch.no_grad():
368
+ ref_logits, _ = ref_model(states)
369
+ ref_probs = F.softmax(ref_logits / temperature, dim=-1)
370
+
371
+ # KL(current || reference) — per-sample, then averaged
372
+ kl_div = F.kl_div(
373
+ F.log_softmax(logits / temperature, dim=-1),
374
+ ref_probs,
375
+ reduction="batchmean",
376
+ log_target=False,
377
+ )
378
+
379
+ # ---- Dynamic Entropy Annealing (paper novelty, preserved) ----
380
+ # Start high (0.2) to break SFT overconfidence, decay to 0.01 to exploit.
381
+ initial_entropy = 0.30#0.2
382
+ final_entropy = 0.02#0.01
383
+ decay_rate = current_epoch / max(1, total_epochs - 1)
384
+
385
+ ##Your entropy annealed from 0.20 → 0.01 over 20 epochs — that's too fast. With a better dataset and action 1 now viable, give the policy more room to explore early:
386
+
387
+ dynamic_entropy_coef = initial_entropy - decay_rate * (initial_entropy - final_entropy)
388
+
389
+ # ---- Total GRPO loss ----
390
+ # L = actor_loss + kl_penalty - entropy_bonus
391
+ # (No critic/value loss — this is the core GRPO distinction from PPO)
392
+ total_loss = actor_loss + kl_coef * kl_div - dynamic_entropy_coef * entropy
393
+
394
+ optimizer.zero_grad()
395
+ total_loss.backward()
396
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=0.5)
397
+ optimizer.step()
398
+
399
+ return total_loss.item(), dynamic_entropy_coef, kl_div.item()
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # GRPO Rollout
404
+ # ---------------------------------------------------------------------------
405
+ def run_grpo_rollout_old(
406
+ model,
407
+ embedder,
408
+ queries,
409
+ optimizer,
410
+ epochs: int = 40,
411
+ group_size: int = 4, # G — number of action samples per query
412
+ device: str = "cpu",
413
+ kl_coef: float = 0.04,
414
+ clip_ratio: float = 0.2,
415
+ ):
416
+ """
417
+ GRPO training loop for the RAG routing agent.
418
+
419
+ For each query we sample `group_size` (G) actions from the current policy,
420
+ collect a reward for each, then compute the within-group normalised advantage:
421
+
422
+ A_i = (r_i - mean(r_1..r_G)) / (std(r_1..r_G) + eps)
423
+
424
+ This replaces the PPO critic entirely — no value network is needed.
425
+
426
+ A frozen reference policy (snapshot of the model at the start of each epoch)
427
+ is used to compute the KL regularisation term in grpo_update().
428
+ """
429
+ import copy
430
+
431
+ model.train()
432
+
433
+ # ---- CSV debug log ----
434
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
435
+ csv_path = os.path.join(repo_root, "logs", "grpo_debug.csv")
436
+ os.makedirs(os.path.dirname(csv_path), exist_ok=True)
437
+ write_header = not os.path.exists(csv_path)
438
+ csv_file = open(csv_path, mode="a", newline="", encoding="utf-8")
439
+ csv_writer = csv.writer(csv_file)
440
+ if write_header:
441
+ csv_writer.writerow([
442
+ "timestamp", "epoch", "step", "query",
443
+ "sample_idx", "action", "action_log_prob", "reward",
444
+ "group_advantage", "context", "answer",
445
+ "epoch_avg_reward", "grpo_loss", "kl_div",
446
+ ])
447
+
448
+ for epoch in range(epochs):
449
+
450
+ # ---- Freeze reference policy at the start of each epoch ----
451
+ ref_model = copy.deepcopy(model).to(device)
452
+ ref_model.eval()
453
+ for p in ref_model.parameters():
454
+ p.requires_grad_(False)
455
+
456
+ # Accumulators for the full batch (all queries × group_size)
457
+ all_states = []
458
+ all_actions = []
459
+ all_log_probs = []
460
+ all_advantages = []
461
+ epoch_rewards = []
462
+
463
+ # ---- Rollout: collect G samples per query ----
464
+ for step_idx, query in enumerate(queries):
465
+ with torch.no_grad():
466
+ state_emb = embedder.encode(query, convert_to_tensor=True)
467
+ try:
468
+ state_emb = state_emb.clone().to(device)
469
+ except Exception:
470
+ state_emb = state_emb.clone().to(next(model.parameters()).device)
471
+
472
+ group_actions = []
473
+ group_log_probs = []
474
+ group_rewards = []
475
+ group_contexts = []
476
+ group_answers = []
477
+
478
+ for g in range(group_size):
479
+ with torch.no_grad():
480
+ # get_action returns (action, log_prob, value); ignore value in GRPO
481
+ action, log_prob, _value = model.get_action(state_emb)
482
+
483
+ context, answer = simulate_rag_pipeline(query, action.item())
484
+ reward = llm_judge_reward(query, context, answer, action.item())
485
+
486
+ group_actions.append(action)
487
+ group_log_probs.append(log_prob)
488
+ group_rewards.append(reward)
489
+ group_contexts.append(context)
490
+ group_answers.append(answer)
491
+
492
+ try:
493
+ lp_val = float(log_prob.item())
494
+ except Exception:
495
+ lp_val = None
496
+
497
+ logger.info(
498
+ "Epoch %d | Step %d | Sample %d | action=%d lp=%s reward=%.4f",
499
+ epoch, step_idx, g, int(action.item()), lp_val, float(reward),
500
+ )
501
+
502
+ # ---- Within-group advantage normalisation ----
503
+ rewards_t = torch.tensor(group_rewards, dtype=torch.float32)
504
+ if group_size > 1:
505
+ adv = (rewards_t - rewards_t.mean()) / (rewards_t.std(unbiased=False) + 1e-8)
506
+ else:
507
+ adv = rewards_t - rewards_t.mean()
508
+
509
+ epoch_rewards.extend(group_rewards)
510
+
511
+ for g in range(group_size):
512
+ all_states.append(state_emb)
513
+ all_actions.append(group_actions[g])
514
+ all_log_probs.append(group_log_probs[g])
515
+ all_advantages.append(adv[g])
516
+
517
+ try:
518
+ lp_val = float(group_log_probs[g].item())
519
+ except Exception:
520
+ lp_val = None
521
+
522
+ csv_writer.writerow([
523
+ datetime.utcnow().isoformat(), epoch, step_idx,
524
+ _truncate(query, 800), g, int(group_actions[g].item()),
525
+ lp_val, float(group_rewards[g]), float(adv[g]),
526
+ _truncate(group_contexts[g], 1000), _truncate(group_answers[g], 1000),
527
+ "", "", "",
528
+ ])
529
+
530
+ # ---- Build tensors for the optimisation phase ----
531
+ states_tensor = torch.stack(all_states).detach()
532
+ actions_tensor = torch.stack(all_actions).detach()
533
+ log_probs_tensor = torch.stack(all_log_probs).detach()
534
+ advantages_tensor = torch.tensor(
535
+ [a.item() if hasattr(a, "item") else float(a) for a in all_advantages],
536
+ dtype=torch.float32,
537
+ ).to(device).detach()
538
+
539
+ # Global normalisation across all groups in the epoch (optional, improves stability)
540
+ if advantages_tensor.numel() > 1:
541
+ advantages_tensor = (
542
+ (advantages_tensor - advantages_tensor.mean())
543
+ / (advantages_tensor.std(unbiased=False) + 1e-8)
544
+ )
545
+
546
+ # ---- GRPO inner optimisation loop ----
547
+ grpo_inner_epochs = 4
548
+ epoch_loss = 0.0
549
+ last_kl = 0.0
550
+
551
+ for _ in range(grpo_inner_epochs):
552
+ loss, ent_coef, kl = grpo_update(
553
+ model,
554
+ ref_model,
555
+ optimizer,
556
+ states_tensor,
557
+ actions_tensor,
558
+ log_probs_tensor,
559
+ advantages_tensor,
560
+ current_epoch=epoch,
561
+ total_epochs=epochs,
562
+ clip_ratio=clip_ratio,
563
+ kl_coef=kl_coef,
564
+ )
565
+ epoch_loss += loss
566
+ last_kl = kl
567
+
568
+ avg_reward = float(sum(epoch_rewards) / len(epoch_rewards)) if epoch_rewards else 0.0
569
+ avg_loss = epoch_loss / grpo_inner_epochs
570
+
571
+ logger.info(
572
+ "GRPO Epoch %d | Avg Reward: %.4f | Loss: %.4f | KL: %.6f | Ent Coef: %.4f",
573
+ epoch + 1, avg_reward, avg_loss, last_kl, ent_coef,
574
+ )
575
+ csv_writer.writerow([
576
+ datetime.utcnow().isoformat(), epoch, "", "", "", "", "", "",
577
+ "", "", "", avg_reward, avg_loss, last_kl,
578
+ ])
579
+ csv_file.flush()
580
+
581
+ print(
582
+ f"Epoch {epoch+1}/{epochs} | Avg Reward: {avg_reward:.4f} | "
583
+ f"Loss: {avg_loss:.4f} | KL: {last_kl:.6f} | Ent Coef: {ent_coef:.4f}"
584
+ )
585
+
586
+ try:
587
+ csv_file.close()
588
+ except Exception:
589
+ pass
590
+
591
+ NUM_ACTIONS = 3 # Action 0: guidelines | Action 1: web-search | Action 2: database
592
+ # model,
593
+ # ref_model,
594
+ # optimizer,
595
+ # states, # Tensor [B, embed_dim]
596
+ # actions, # Tensor [B]
597
+ # old_log_probs, # Tensor [B] — log-probs from the sampling policy
598
+ # group_advantages, # Tensor [B] — already normalised per group
599
+ # current_epoch: int,
600
+ # total_epochs: int,
601
+ # clip_ratio: float = 0.2,
602
+ # kl_coef: float = 0.04,
603
+
604
+ def run_grpo_rollout(
605
+ model,
606
+ embedder,
607
+ queries,
608
+ optimizer,
609
+ ref_policy=None, # ← added ref_policy argument to avoid re-creating inside trainer
610
+ epochs: int = 40,
611
+ start_epoch: int = 0,
612
+ device: str = "cpu",
613
+ kl_coef: float = 0.04,
614
+ clip_ratio: float = 0.2,
615
+ ):
616
+ """
617
+ GRPO training loop using EXHAUSTIVE group evaluation.
618
+
619
+ Why exhaustive instead of stochastic sampling?
620
+ -----------------------------------------------
621
+ Because the reward function is DETERMINISTIC:
622
+ same query + same action → same RAG output → same LLM-judge score.
623
+
624
+ Stochastic GRPO would keep drawing the same action from a confident policy,
625
+ producing std ≈ 0 → zero advantage → dead gradients.
626
+
627
+ Fix: for EVERY query, evaluate ALL 3 tools explicitly and use the reward
628
+ triplet [r0, r1, r2] as the group. Guaranteed non-zero variance as long
629
+ as any two tools score differently (almost always true for routing).
630
+
631
+ Group advantage formula (identical math to standard GRPO):
632
+ A_i = (r_i - mean(r0, r1, r2)) / (std(r0, r1, r2) + eps)
633
+
634
+ API calls per epoch: len(queries) × 3
635
+ """
636
+ model.train()
637
+
638
+ # ---- CSV debug log --------------------------------------------------- #
639
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
640
+ csv_path = os.path.join(repo_root, "logs", "grpo_debug.csv")
641
+ os.makedirs(os.path.dirname(csv_path), exist_ok=True)
642
+ write_header = not os.path.exists(csv_path)
643
+ csv_file = open(csv_path, mode="a", newline="", encoding="utf-8")
644
+ csv_writer = csv.writer(csv_file)
645
+ if write_header:
646
+ csv_writer.writerow([
647
+ "timestamp", "epoch", "step", "query",
648
+ "action", "reward", "group_advantage",
649
+ "context", "answer",
650
+ "epoch_avg_reward", "grpo_loss", "kl_div",
651
+ ])
652
+
653
+ for epoch in range(start_epoch, epochs):
654
+ # ── Reference policy setup ──────────────────────────────────────
655
+ if ref_policy is not None:
656
+ ref_model = ref_policy
657
+ elif epoch > 0 and epoch % 5 == 0:
658
+ # Refresh reference every 5 epochs to prevent KL explosion
659
+ ref_model = copy.deepcopy(model).to(device)
660
+ ref_model.eval()
661
+ for p in ref_model.parameters():
662
+ p.requires_grad_(False)
663
+ logger.info(f"Reference policy refreshed at epoch {epoch}")
664
+ else:
665
+ # First epoch OR non-refresh epoch — keep existing ref_model from previous epoch
666
+ if epoch == 0:
667
+ ref_model = copy.deepcopy(model).to(device)
668
+ ref_model.eval()
669
+ for p in ref_model.parameters():
670
+ p.requires_grad_(False)
671
+ #else: ref_model carries over from previous epoch, no action needed
672
+
673
+ # Batch accumulators (B = len(queries) × NUM_ACTIONS)
674
+ all_states = []
675
+ all_actions = []
676
+ all_log_probs = []
677
+ all_advantages = []
678
+ epoch_rewards = []
679
+
680
+ # ---- Rollout: evaluate ALL 3 actions for every query ------------- #
681
+ for step_idx, query in enumerate(queries):
682
+
683
+ # Embed the query ONCE — shared across all 3 action evaluations
684
+ with torch.no_grad():
685
+ state_emb = embedder.encode(query, convert_to_tensor=True)
686
+ try:
687
+ state_emb = state_emb.clone().to(device)
688
+ except Exception:
689
+ state_emb = state_emb.clone().to(next(model.parameters()).device)
690
+
691
+ group_rewards = []
692
+ group_contexts = []
693
+ group_answers = []
694
+
695
+ # Step 1: collect rewards for ALL 3 actions deterministically
696
+ for action_idx in range(NUM_ACTIONS):
697
+ context, answer = simulate_rag_pipeline(query, action_idx)
698
+ reward = llm_judge_reward(query, context, answer, action_idx)
699
+ #remove this hack once web-search tool is improved to not always score zero
700
+ if action_idx == 1 and reward == 0.0:
701
+ # Don't let web-search always score zero — check if at least the routing was correct
702
+ # Give a small non-zero signal to keep action 1 alive
703
+ reward = 0.05 # tiny floor, just enough to prevent full collapse
704
+ group_rewards.append(reward)
705
+ group_contexts.append(context)
706
+ group_answers.append(answer)
707
+
708
+ logger.info(
709
+ "Epoch %d | Step %d | action=%d reward=%.4f",
710
+ epoch, step_idx, action_idx, float(reward),
711
+ )
712
+
713
+ # Step 2: within-group advantage normalisation
714
+ rewards_t = torch.tensor(group_rewards, dtype=torch.float32)
715
+ if rewards_t.std(unbiased=False) > 1e-8:
716
+ # Normal case: tools scored differently → strong learning signal
717
+ adv = (rewards_t - rewards_t.mean()) / (rewards_t.std(unbiased=False) + 1e-8)
718
+ else:
719
+ # Edge case: all 3 tools scored identically → zero advantage.
720
+ # Correct behaviour — do not push the policy in any direction.
721
+ adv = torch.zeros_like(rewards_t)
722
+ logger.warning(
723
+ "Epoch %d | Step %d | All 3 actions equal reward=%.4f — zero gradient for this query.",
724
+ epoch, step_idx, float(rewards_t[0]),
725
+ )
726
+
727
+ epoch_rewards.extend(group_rewards)
728
+
729
+ # Step 3: read log-probs for all 3 actions from the current policy
730
+ # in a single forward pass (no sampling needed)
731
+ with torch.no_grad():
732
+ logits, _ = model(state_emb.unsqueeze(0))
733
+ temperature = 1.5
734
+ probs = F.softmax(logits / temperature, dim=-1).squeeze(0)
735
+ log_probs_all = torch.log(probs + 1e-10) # shape: [NUM_ACTIONS]
736
+
737
+ for action_idx in range(NUM_ACTIONS):
738
+ all_states.append(state_emb)
739
+ all_actions.append(torch.tensor(action_idx, dtype=torch.long))
740
+ all_log_probs.append(log_probs_all[action_idx])
741
+ all_advantages.append(adv[action_idx])
742
+
743
+ try:
744
+ lp_val = float(log_probs_all[action_idx].item())
745
+ except Exception:
746
+ lp_val = None
747
+
748
+ csv_writer.writerow([
749
+ datetime.utcnow().isoformat(), epoch, step_idx,
750
+ _truncate(query, 800),
751
+ action_idx, float(group_rewards[action_idx]),
752
+ float(adv[action_idx]),
753
+ _truncate(group_contexts[action_idx], 1000),
754
+ _truncate(group_answers[action_idx], 1000),
755
+ "", "", "",
756
+ ])
757
+
758
+ # ---- Build tensors for the optimisation phase -------------------- #
759
+ states_tensor = torch.stack(all_states).detach()
760
+ actions_tensor = torch.stack(all_actions).detach()
761
+ log_probs_tensor = torch.stack(all_log_probs).detach()
762
+ advantages_tensor = torch.tensor(
763
+ [a.item() if hasattr(a, "item") else float(a) for a in all_advantages],
764
+ dtype=torch.float32,
765
+ ).to(device).detach()
766
+
767
+ # Global re-normalisation across all (query × action) pairs in the epoch
768
+ if advantages_tensor.numel() > 1 and advantages_tensor.std(unbiased=False) > 1e-8:
769
+ advantages_tensor = (
770
+ (advantages_tensor - advantages_tensor.mean())
771
+ / (advantages_tensor.std(unbiased=False) + 1e-8)
772
+ )
773
+
774
+ # ---- GRPO inner optimisation loop (4 passes over same rollout) --- #
775
+ grpo_inner_epochs = 4
776
+ epoch_loss = 0.0
777
+ last_kl = 0.0
778
+ last_ent_coef = 0.0
779
+
780
+ for _ in range(grpo_inner_epochs):
781
+ loss, ent_coef, kl = grpo_update(
782
+ model,
783
+ ref_model,
784
+ optimizer,
785
+ states_tensor,
786
+ actions_tensor,
787
+ log_probs_tensor,
788
+ advantages_tensor,
789
+ current_epoch=epoch,
790
+ total_epochs=epochs,
791
+ clip_ratio=clip_ratio,
792
+ kl_coef=kl_coef,
793
+ )
794
+ epoch_loss += loss
795
+ last_kl = kl
796
+ last_ent_coef = ent_coef
797
+
798
+ avg_reward = float(sum(epoch_rewards) / len(epoch_rewards)) if epoch_rewards else 0.0
799
+ avg_loss = epoch_loss / grpo_inner_epochs
800
+
801
+ logger.info(
802
+ "GRPO Epoch %d | Avg Reward: %.4f | Loss: %.4f | KL: %.6f | Ent Coef: %.4f",
803
+ epoch + 1, avg_reward, avg_loss, last_kl, last_ent_coef,
804
+ )
805
+ csv_writer.writerow([
806
+ datetime.utcnow().isoformat(), epoch, "", "", "", "", "", "", "",
807
+ avg_reward, avg_loss, last_kl,
808
+ ])
809
+ csv_file.flush()
810
+
811
+ print(
812
+ f"Epoch {epoch+1}/{epochs} | "
813
+ f"Avg Reward: {avg_reward:.4f} | "
814
+ f"Loss: {avg_loss:.4f} | "
815
+ f"KL: {last_kl:.6f} | "
816
+ f"Ent Coef: {last_ent_coef:.4f}"
817
+ )
818
+ # ---- Save checkpoint after every epoch --------------------------- #
819
+ adapters_dir = os.path.join(os.path.dirname(__file__), "adapters")
820
+ os.makedirs(adapters_dir, exist_ok=True)
821
+ epoch_ckpt_path = os.path.join(adapters_dir, f"router_grpo_epoch_{epoch+1}.pt")
822
+ torch.save(
823
+ {
824
+ "epoch": epoch + 1,
825
+ "model_state_dict": model.state_dict(),
826
+ "optimizer_state_dict": optimizer.state_dict(),
827
+ "avg_reward": avg_reward,
828
+ "avg_loss": avg_loss,
829
+ "kl_div": last_kl,
830
+ "ent_coef": last_ent_coef,
831
+ },
832
+ epoch_ckpt_path,
833
+ )
834
+ logger.info("Saved checkpoint → %s", epoch_ckpt_path)
835
+ print(f" └─ Checkpoint saved: adapters/router_grpo_epoch_{epoch+1}.pt")
836
+
837
+ try:
838
+ csv_file.close()
839
+ except Exception:
840
+ pass
main.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import sys
4
+ import argparse
5
+ import csv
6
+ import torch
7
+
8
+ ROOT = os.path.dirname(__file__)
9
+
10
+ def build_dataset_from_csv(csv_path):
11
+ dataset = []
12
+ with open(csv_path, newline='', encoding='utf-8') as f:
13
+ reader = csv.DictReader(f)
14
+ for row in reader:
15
+ q = row.get('question') or row.get('Question')
16
+ lbl = row.get('label') or row.get('Label')
17
+ if q is None or lbl is None:
18
+ continue
19
+ try:
20
+ lbl = int(lbl)
21
+ except Exception:
22
+ continue
23
+ dataset.append({'question': q, 'label': lbl})
24
+ return dataset
25
+
26
+
27
+ class EmbedderWrapper:
28
+ """Wrap a SentenceTransformer instance to ensure tensors are on `device`.
29
+
30
+ The wrapper exposes `encode(texts, convert_to_tensor=True, **kwargs)` same as
31
+ the SentenceTransformer API and will move returned torch.Tensor to the device.
32
+ """
33
+ def __init__(self, embedder, device):
34
+ self.embedder = embedder
35
+ self.device = device
36
+
37
+ def encode(self, texts, convert_to_tensor=True, **kwargs):
38
+ emb = self.embedder.encode(texts, convert_to_tensor=convert_to_tensor, **kwargs)
39
+ # Move tensor outputs to device (if it's a torch.Tensor)
40
+ if convert_to_tensor and hasattr(emb, 'to'):
41
+ try:
42
+ emb = emb.to(self.device)
43
+ except Exception:
44
+ pass
45
+ return emb
46
+
47
+
48
+ def main():
49
+ parser = argparse.ArgumentParser(description='Run SFT training for RouterNetwork')
50
+ parser.add_argument('--csv', default='processed_dataset.csv', help='Path to CSV dataset')
51
+ parser.add_argument('--model_name', default='all-MiniLM-L6-v2', help='SentenceTransformer model name')
52
+ parser.add_argument('--epochs', type=int, default=5)
53
+ parser.add_argument('--lr', type=float, default=1e-3)
54
+ parser.add_argument('--checkpoint', default='router_checkpoint.pt', help='Checkpoint output path')
55
+ parser.add_argument('--device', default=None, help='Device to use (cpu or cuda). Auto-detect if unset')
56
+ args = parser.parse_args()
57
+
58
+ # Ensure routing_agent directory is importable such that its modules' imports work
59
+ routing_agent_path = os.path.join(ROOT, 'routing_agent')
60
+ if routing_agent_path not in sys.path:
61
+ sys.path.insert(0, routing_agent_path)
62
+
63
+ # Import modules from routing_agent (sft_trainer expects to import model as a top-level module)
64
+ try:
65
+ import sft_trainer
66
+ import model as model_mod
67
+ except Exception as e:
68
+ print('Failed importing routing_agent modules:', e)
69
+ sys.exit(1)
70
+
71
+ # Device selection
72
+ if args.device:
73
+ device = torch.device(args.device)
74
+ else:
75
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
76
+
77
+ # Build dataset
78
+ csv_path = os.path.join(ROOT, args.csv) if not os.path.isabs(args.csv) else args.csv
79
+ if not os.path.exists(csv_path):
80
+ print('Dataset CSV not found at', csv_path)
81
+ sys.exit(1)
82
+
83
+ dataset = build_dataset_from_csv(csv_path)
84
+ if len(dataset) == 0:
85
+ print('No valid rows found in dataset. Exiting.')
86
+ sys.exit(1)
87
+
88
+ # Initialize embedder
89
+ try:
90
+ from sentence_transformers import SentenceTransformer
91
+ except Exception as e:
92
+ print('sentence-transformers not installed or failed to import:', e)
93
+ sys.exit(1)
94
+
95
+ embedder = SentenceTransformer(args.model_name)
96
+ embedder_wrapper = EmbedderWrapper(embedder, device)
97
+
98
+ # Infer embedding dim and instantiate model from the same module sft_trainer expects
99
+ try:
100
+ embed_dim = embedder.get_sentence_embedding_dimension()
101
+ except Exception:
102
+ # fallback to common dim
103
+ embed_dim = 384
104
+
105
+ model = model_mod.RouterNetwork(embedding_dim=embed_dim, num_classes=3).to(device)
106
+
107
+ # Run SFT training
108
+ print(f"Starting SFT training on device={device} with model={args.model_name} | epochs={args.epochs} | lr={args.lr}")
109
+ sft_trainer.train_sft(model, embedder_wrapper, dataset, epochs=args.epochs, lr=args.lr)
110
+
111
+ # Save checkpoint
112
+ try:
113
+ torch.save(model.state_dict(), args.checkpoint)
114
+ print('Saved checkpoint to', args.checkpoint)
115
+ except Exception as e:
116
+ print('Failed to save checkpoint:', e)
117
+
118
+
119
+ if __name__ == '__main__':
120
+ main()
model.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.nn as nn
3
+ import torch.nn.functional as F
4
+ from sentence_transformers import SentenceTransformer
5
+
6
+ class RouterNetwork(nn.Module):
7
+ def __init__(self, embedding_dim = 384, hidden_dim = 128, num_classes = 3):
8
+ super(RouterNetwork, self).__init__()
9
+
10
+ # shared representation layer
11
+ self.shared_layer = nn.Sequential(
12
+ nn.Linear(embedding_dim, hidden_dim),
13
+ nn.ReLU(),
14
+ nn.LayerNorm(hidden_dim)
15
+ )
16
+
17
+ # Actor Head: outputs logits for the 3 modules
18
+ # 0: unstructured, 1: website, 2: structured
19
+ self.actor_head = nn.Sequential(
20
+ nn.Linear(hidden_dim, hidden_dim // 2),
21
+ nn.ReLU(),
22
+ nn.Linear(hidden_dim // 2, num_classes)
23
+ )
24
+
25
+ # Critic Head: outputs a single value for the state value estimation
26
+ self.critic_head = nn.Sequential(
27
+ nn.Linear(hidden_dim, hidden_dim // 2),
28
+ nn.ReLU(),
29
+ nn.Linear(hidden_dim // 2, 1)
30
+ )
31
+
32
+ def forward(self, x):
33
+ shared_features = self.shared_layer(x)
34
+ action_logits = self.actor_head(shared_features)
35
+ state_value = self.critic_head(shared_features)
36
+ return action_logits, state_value
37
+
38
+ def get_action(self, x):
39
+ action_logits, value = self.forward(x)
40
+ action_probs = F.softmax(action_logits, dim=-1)
41
+ dist = torch.distributions.Categorical(action_probs)
42
+ action = dist.sample()
43
+ return action, dist.log_prob(action), value
policy.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ from model import RouterNetwork
8
+
9
+
10
+ class RouterPolicy:
11
+ """Thin wrapper around RouterNetwork for loading, inference and checkpointing."""
12
+
13
+ def __init__(self, model: RouterNetwork, device: str = "cpu"):
14
+ self.model = model.to(device)
15
+ self.device = torch.device(device)
16
+ self.model.eval()
17
+
18
+ @classmethod
19
+ def load(cls, checkpoint_path: str, embedding_dim: int | None = None, device: str = "cpu"):
20
+ """Load RouterNetwork from checkpoint. If checkpoint contains container dict with
21
+ 'model_state_dict' will use that, otherwise will try to load state_dict directly.
22
+
23
+ embedding_dim may be required if checkpoint does not include meta info.
24
+ """
25
+ checkpoint_path = os.path.abspath(checkpoint_path)
26
+ if not os.path.exists(checkpoint_path):
27
+ raise FileNotFoundError(f"Router checkpoint not found: {checkpoint_path}")
28
+
29
+ sd = torch.load(checkpoint_path, map_location="cpu")
30
+
31
+ # Unpack common formats
32
+ if isinstance(sd, dict) and "model_state_dict" in sd:
33
+ model_state = sd["model_state_dict"]
34
+ meta = sd.get("meta", {})
35
+ else:
36
+ model_state = sd
37
+ meta = {}
38
+
39
+ # Infer embedding dim from meta if available
40
+ emb_dim = embedding_dim or meta.get("embedding_dim") or meta.get("embed_dim") or 384
41
+
42
+ model = RouterNetwork(embedding_dim=emb_dim)
43
+
44
+ missing, unexpected = model.load_state_dict(model_state, strict=False)
45
+ if missing or unexpected:
46
+ logger.info(f"RouterPolicy.load: missing keys={list(missing)} unexpected={list(unexpected)}")
47
+
48
+ return cls(model, device=device)
49
+
50
+ def predict_sync(self, emb_tensor: torch.Tensor):
51
+ """Synchronous prediction: accepts a 1-D or 2-D embedding tensor and returns
52
+ (action_idx:int, prob:float, value:float).
53
+ """
54
+ was_1d = False
55
+ if emb_tensor.dim() == 1:
56
+ emb_tensor = emb_tensor.unsqueeze(0)
57
+ was_1d = True
58
+
59
+ emb = emb_tensor.to(self.device)
60
+
61
+ with torch.no_grad():
62
+ logits, value = self.model(emb)
63
+ probs = torch.softmax(logits, dim=-1)
64
+ top_idx = int(probs.argmax(dim=-1)[0].item())
65
+ top_prob = float(probs[0, top_idx].item())
66
+ val = float(value.squeeze(1)[0].item()) if value is not None else 0.0
67
+
68
+ return top_idx, top_prob, val
69
+
70
+ def save_checkpoint(self, path: str, optimizer: torch.optim.Optimizer | None = None, meta: dict | None = None):
71
+ path = os.path.abspath(path)
72
+ payload = {
73
+ "model_state_dict": self.model.state_dict(),
74
+ "meta": meta or {}
75
+ }
76
+ if optimizer is not None:
77
+ try:
78
+ payload["optimizer_state_dict"] = optimizer.state_dict()
79
+ except Exception:
80
+ logger.warning("Could not include optimizer state in checkpoint")
81
+
82
+ torch.save(payload, path)
83
+ return path
ppo_trainer.py ADDED
@@ -0,0 +1,772 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import torch
3
+ import torch.optim as optim
4
+ import torch.nn as nn
5
+ import torch.nn.functional as F
6
+ import logging
7
+ import csv
8
+ from datetime import datetime
9
+ from policy import RouterPolicy
10
+
11
+
12
+ # Initialize module logger
13
+ logger = logging.getLogger("ppo_trainer")
14
+ if not logger.handlers:
15
+ logger.setLevel(logging.INFO)
16
+ fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
17
+ # Console handler
18
+ ch = logging.StreamHandler()
19
+ ch.setFormatter(fmt)
20
+ logger.addHandler(ch)
21
+ # File handler (append) - place logs directory at repository root `logs/`
22
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
23
+ logs_dir = os.path.join(repo_root, 'logs')
24
+ try:
25
+ os.makedirs(logs_dir, exist_ok=True)
26
+ except Exception:
27
+ logs_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
28
+
29
+ fh_path = os.path.join(logs_dir, 'ppo_trainer.log')
30
+ fh = logging.FileHandler(fh_path)
31
+ fh.setFormatter(fmt)
32
+ logger.addHandler(fh)
33
+
34
+
35
+ def _truncate(s: str, n: int = 500) -> str:
36
+ if s is None:
37
+ return ""
38
+ s = str(s)
39
+ return s if len(s) <= n else s[:n-1] + "\u2026"
40
+
41
+
42
+ def v1_ppo_update(model, optimizer, states, actions, old_log_probs, returns, advantages, clip_ratio=0.2):
43
+ """
44
+ Performs a single PPO update step.
45
+
46
+ states: Tensor of query embeddings
47
+ actions: Tensor of actions taken
48
+ returns: Tensor of discounted cumulative rewards
49
+ advantages: Tensor of advantages (Returns - State Values)
50
+ """
51
+ model.train()
52
+ device = next(model.parameters()).device
53
+
54
+ # ensure tensors live on the same device as the model
55
+ states = states.to(device)
56
+ actions = actions.to(device)
57
+ old_log_probs = old_log_probs.to(device)
58
+ returns = returns.to(device)
59
+ advantages = advantages.to(device)
60
+
61
+ #1. Get current policy probabilities and values
62
+ logits, current_values = model(states)
63
+ probs = F.softmax(logits, dim=-1)
64
+ dist = torch.distributions.Categorical(probs)
65
+
66
+ # calculate new log probabilities and entropy for the taken actions
67
+ new_log_probs = dist.log_prob(actions)
68
+ entropy = dist.entropy().mean()
69
+
70
+ #2. calculate the ratio: pi_theta(a|s) / pi_theta_old(a|s)
71
+ ratios = torch.exp(new_log_probs - old_log_probs)
72
+
73
+ #3. calculate surrogate losses
74
+ surr1 = ratios * advantages
75
+ surr2 = torch.clamp(ratios, 1.0 - clip_ratio, 1.0 + clip_ratio) * advantages
76
+
77
+ # calculate actor and critic losses
78
+ # PPO loss is the negative of the minimum surrogate loss plus value function loss
79
+ actor_loss = -torch.min(surr1, surr2).mean() # PPO objective (maximize the minimum of the surrogate losses)
80
+ critic_loss = F.mse_loss(current_values.squeeze(), returns.view_as(current_values.squeeze())) # Value function loss
81
+
82
+ #5. total Loss calculation (with entropy bonus to encourage exploration)
83
+ entropy_coef = 0.08 #increased after v1 experiment #0.01 # common weight for the entropy bonus
84
+ value_coef = 0.5 # common weight for the value loss
85
+ total_loss = actor_loss + (value_coef * critic_loss) - (entropy_coef * entropy) # Combine losses with appropriate weights
86
+
87
+ #6. backpropagation and optimization step
88
+ optimizer.zero_grad()
89
+ total_loss.backward()
90
+
91
+ # Gradient clipping (optional but common in PPO to stabilize training)
92
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=0.5)
93
+ optimizer.step()
94
+
95
+ return total_loss.item()
96
+
97
+ def ppo_update(model, optimizer, states, actions, old_log_probs, returns, advantages, current_epoch, total_epochs, clip_ratio=0.2):
98
+ model.train()
99
+
100
+ # 1. Forward pass
101
+ logits, current_values = model(states)
102
+
103
+ # Optional: Logit Temperature Scaling (helps soften the SFT spike)
104
+ temperature = 1.5
105
+ probs = F.softmax(logits / temperature, dim=-1)
106
+ dist = torch.distributions.Categorical(probs)
107
+
108
+ new_log_probs = dist.log_prob(actions)
109
+ entropy = dist.entropy().mean()
110
+
111
+ # 2. Calculate PPO Ratio
112
+ ratios = torch.exp(new_log_probs - old_log_probs)
113
+
114
+ # 3. Calculate Surrogate Losses
115
+ surr1 = ratios * advantages
116
+ surr2 = torch.clamp(ratios, 1.0 - clip_ratio, 1.0 + clip_ratio) * advantages
117
+
118
+ actor_loss = -torch.min(surr1, surr2).mean()
119
+ critic_loss = F.mse_loss(current_values.squeeze(), returns)
120
+
121
+ # ---------------------------------------------------------
122
+ # NOVELTY FOR THE PAPER: Dynamic Entropy Annealing
123
+ # Start high (0.2) to shatter SFT confidence, decay to low (0.01) to exploit
124
+ # ---------------------------------------------------------
125
+ initial_entropy = 0.2
126
+ final_entropy = 0.01
127
+ decay_rate = current_epoch / max(1, total_epochs - 1)
128
+ dynamic_entropy_coef = initial_entropy - decay_rate * (initial_entropy - final_entropy)
129
+
130
+ value_coef = 0.5
131
+ total_loss = actor_loss + (value_coef * critic_loss) - (dynamic_entropy_coef * entropy)
132
+
133
+ optimizer.zero_grad()
134
+ total_loss.backward()
135
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=0.5)
136
+ optimizer.step()
137
+
138
+ return total_loss.item(), dynamic_entropy_coef
139
+
140
+ def simulate_rag_pipeline(query, action_idx):
141
+ """
142
+ Dummy function mapping to your actual RAG modules.
143
+ 0: Unstructured, 1: Website, 2: Structured (SQL)
144
+ """
145
+ # Integrate with backend SimpleAgent to run the actual tools.
146
+ # This function will synchronously run async agent methods using asyncio.run
147
+ import sys
148
+ import os
149
+ import asyncio
150
+ import requests
151
+
152
+ # Ensure repo root is on sys.path so we can import backend package
153
+ # repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
154
+ # if repo_root not in sys.path:
155
+ # sys.path.insert(0, repo_root)
156
+
157
+ import sys
158
+ import os
159
+
160
+ project_root = os.path.abspath(os.path.join(os.getcwd(), ".."))
161
+ backend_path = os.path.join(project_root, "backend")
162
+
163
+ sys.path.append(project_root)
164
+ sys.path.append(backend_path)
165
+
166
+ try:
167
+ from backend.app.core.agent import SimpleAgent
168
+ except Exception:
169
+ # Fallback import path for different execution contexts
170
+ try:
171
+ from app.core.agent import SimpleAgent
172
+ except Exception as e:
173
+ return "", f"Failed importing SimpleAgent: {e}"
174
+ def detect_language(text):
175
+ #check if any character in the text is in the Korean Unicode range or chinese Unicode range or english Unicode range
176
+ if any('\uac00' <= char <= '\ud7a3' for char in text):
177
+ return "ko"
178
+ elif any('\u4e00' <= char <= '\u9fff' for char in text):
179
+ return "zh"
180
+ else:
181
+ return "en"
182
+
183
+ async def old_call_tool(q, act_idx):
184
+ agent = SimpleAgent()
185
+ language = detect_language(q)
186
+ if act_idx == 0:
187
+ res = await agent._execute_guidelines(q, language)
188
+ elif act_idx == 1:
189
+ res = await agent._execute_web_search(q, language)
190
+ else:
191
+ res = await agent._execute_cbnu_database(q, language)
192
+
193
+ # Build a short context from tool results
194
+ context = ""
195
+ try:
196
+ results = res.data.get("results", []) if res and res.data else []
197
+ snippets = []
198
+ for item in results[:3]:
199
+ snippet = item.get("snippet") or item.get("content") or item.get(f"answer_{language}") or str(item)
200
+ snippets.append(snippet)
201
+ context = "\n".join(snippets)
202
+ except Exception:
203
+ context = ""
204
+
205
+ # Use agent's generator to synthesize an answer from the single tool result
206
+ try:
207
+ answer = await agent._generate_response(q, [res], language)
208
+ except Exception:
209
+ answer = ""
210
+
211
+ return context, answer
212
+
213
+ async def _call_tool(q, act_idx):
214
+ language = detect_language(q)
215
+ print(f"Detected language: {language}")
216
+
217
+ # 0: guidelines, 1: web search, 2: database
218
+ if act_idx == 0:
219
+ search_sources = ["guidelines"]
220
+ elif act_idx == 1:
221
+ search_sources = ["web-search"]
222
+ else:
223
+ search_sources = ["course-registration", "department-info"]
224
+
225
+ url = 'http://localhost:7000/api/chat'
226
+ headers = {
227
+ 'accept': 'application/json',
228
+ 'Content-Type': 'application/json'
229
+ }
230
+ payload = {
231
+ "message": q,
232
+ "language": language,
233
+ "search_sources": search_sources,
234
+ "session_id": "ppo_train_session"
235
+ }
236
+
237
+ try:
238
+ response = requests.post(url, headers=headers, json=payload)
239
+ response.raise_for_status()
240
+ data = response.json()
241
+
242
+ # Extract context and answer from the API response
243
+ answer = data.get("message", "")
244
+
245
+ # Extract content securely from the sources
246
+ context_parts = []
247
+ sources = data.get("sources", [])
248
+ for src in sources:
249
+ if isinstance(src, dict) and src.get("success"):
250
+ src_data = src.get("data", {})
251
+ results = src_data.get("results", [])
252
+ for res in results:
253
+ if "content" in res:
254
+ context_parts.append(res["content"])
255
+ elif "snippet" in res:
256
+ context_parts.append(res["snippet"])
257
+
258
+ context = "\n".join(context_parts)
259
+
260
+ print(f"Tool Result fetched successfully via API.")
261
+ return context, answer
262
+
263
+ except Exception as e:
264
+ print(f"API call failed: {e}")
265
+ return "", ""
266
+ try:
267
+ ctx, ans = asyncio.run(_call_tool(query, int(action_idx)))
268
+ return ctx, ans
269
+ except Exception as e:
270
+ return "", f"simulate_rag_pipeline failed: {e}"
271
+
272
+ def llm_judge_reward(query, context, answer, action_idx):
273
+ import os
274
+ import requests
275
+ import re
276
+
277
+ try:
278
+ # 1. FIXED: Correct API version (v1beta) and method (:generateContent)
279
+ # Note: If gemini-2.5-flash still throws 404, change to gemini-2.0-flash or gemini-1.5-flash
280
+ model_name = "gemini-2.5-flash"
281
+ gemini_url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent"
282
+ action_taken = f"Action {action_idx}"
283
+ gemini_key = "AIzaSyBoErDeUH1kRBbusI6qkMwISw5euyuXwZo"
284
+ gemini_token = os.getenv("GEMINI_API_TOKEN") or os.getenv("GCP_ACCESS_TOKEN")
285
+
286
+ Old_prompt = f"""You are an expert automated RAG evaluator.
287
+ Your task is to grade the retrieved Context and the generated Answer based on the user's Query.
288
+
289
+ ### GRADING RUBRIC:
290
+ 1. Context Relevance (R_rel): Does the Context contain the necessary information to answer the Query?
291
+ - 0.0: Completely irrelevant or useless.
292
+ - 0.5: Contains partial information, but not enough for a complete answer.
293
+ - 1.0: Contains all necessary information to fully answer the query.
294
+
295
+ 2. Answer Correctness (R_corr): Is the Answer fully supported by the Context? (IGNORE your internal knowledge. Grade strictly on the provided context).
296
+ - 0.0: Hallucinates information not in the context, or completely fails to answer the query.
297
+ - 0.5: Partially correct, but misses key details from the context or includes slight hallucinations.
298
+ - 1.0: Perfectly accurate, directly answers the query, and is 100% faithful to the context.
299
+
300
+ ### INPUTS:
301
+ Query: {query}
302
+ Context: {context}
303
+ Answer: {answer}
304
+
305
+ ### OUTPUT FORMAT:
306
+ You must return ONLY a valid JSON object. Do not use markdown blocks (```json).
307
+ Output this exact structure:
308
+ {{
309
+ "relevance_reasoning": "Brief 1-sentence explanation of why the context is/isn't relevant.",
310
+ "R_rel": <float between 0.0 and 1.0>,
311
+ "correctness_reasoning": "Brief 1-sentence explanation of answer correctness and faithfulness.",
312
+ "R_corr": <float between 0.0 and 1.0>
313
+ }}
314
+ """
315
+
316
+ prompt = f"""You are an expert automated RAG evaluator.
317
+ Your task is to grade the retrieved Context and the generated Answer based on the user's Query and the Action taken by the routing agent.
318
+
319
+ ### STRICT ROUTING RULES:
320
+ The agent MUST select the correct action based on the query intent:
321
+ - Action 0 (guidelines): Required for academic information, university rules, visa, scholarships, graduation requirements, health insurance, and part-time job info.
322
+ - Action 1 (web_search): Required for university news, announcements, and external information.
323
+ - Action 2 (cbnu_database): Required for department info (contacts, phone, email, website) and course registration (course details, schedules, professors, credits).
324
+
325
+ ### GRADING RUBRIC:
326
+ 1. Context Relevance (R_rel):
327
+ - FATAL PENALTY: Did the agent choose an action that violates the STRICT ROUTING RULES above? If YES, you MUST score R_rel as 0.0 immediately, regardless of the context.
328
+ - 0.0: Violates routing rules OR context is completely irrelevant/useless.
329
+ - 0.5: Follows rules, but context contains only partial information.
330
+ - 1.0: Follows rules AND context contains all necessary information to fully answer the query.
331
+
332
+ 2. Answer Correctness (R_corr): Is the Answer fully supported by the Context? (IGNORE your internal knowledge. Grade strictly on the provided context).
333
+ - 0.0: Hallucinates information not in the context, or completely fails to answer the query.
334
+ - 0.5: Partially correct, but misses key details from the context or includes slight hallucinations.
335
+ - 1.0: Perfectly accurate, directly answers the query, and is 100% faithful to the context.
336
+
337
+ ### INPUTS:
338
+ Query: {query}
339
+ Action Taken: {action_taken}
340
+ Context: {context}
341
+ Answer: {answer}
342
+
343
+ ### OUTPUT FORMAT:
344
+ You must return ONLY a valid JSON object. Do not use markdown blocks (```json).
345
+ Output this exact structure:
346
+ {{
347
+ "relevance_reasoning": "Brief 1-sentence explanation of why the rule was followed/broken and if context is relevant.",
348
+ "R_rel": <float between 0.0 and 1.0>,
349
+ "correctness_reasoning": "Brief 1-sentence explanation of answer correctness and faithfulness.",
350
+ "R_corr": <float between 0.0 and 1.0>
351
+ }}
352
+ """
353
+
354
+ headers = {"Content-Type": "application/json"}
355
+ params = {}
356
+
357
+ if gemini_key:
358
+ params = {"key": gemini_key}
359
+ elif gemini_token:
360
+ headers["Authorization"] = f"Bearer {gemini_token}"
361
+
362
+ # 2. FIXED: Correct Gemini payload structure
363
+ payload = {
364
+ "contents": [{
365
+ "parts": [{"text": prompt}]
366
+ }],
367
+ "generationConfig": {
368
+ # "maxOutputTokens": 200,
369
+ "temperature": 0.0, # Deterministic output for consistent rewards
370
+ "responseMimeType": "application/json" # 3. ADDED: Enforces JSON output
371
+ }
372
+ }
373
+
374
+ resp = requests.post(gemini_url, params=params, json=payload, headers=headers, timeout=15)
375
+
376
+ if resp.status_code == 404:
377
+ print(f"Gemini endpoint returned 404. Check if '{model_name}' is available to your API key.", flush=True)
378
+ print(f"Tried URL: {gemini_url}", flush=True)
379
+ resp.raise_for_status()
380
+
381
+ resp.raise_for_status()
382
+
383
+ # 4. FIXED: Parsing the correct Gemini response path
384
+ data = resp.json()
385
+ text = ""
386
+ try:
387
+ text = data["candidates"][0]["content"]["parts"][0]["text"]
388
+ except (KeyError, IndexError):
389
+ text = resp.text # Fallback to raw text if structure is unexpected
390
+
391
+ # Extract floats using your existing regex
392
+ m_rel = re.search(r'"R_rel"\s*:\s*([0-9]*\.?[0-9]+)', text)
393
+ m_corr = re.search(r'"R_corr"\s*:\s*([0-9]*\.?[0-9]+)', text)
394
+
395
+ if m_rel and m_corr:
396
+ R_rel = float(m_rel.group(1))
397
+ R_corr = float(m_corr.group(1))
398
+ else:
399
+ nums = re.findall(r'([0-9]*\.?[0-9]+)', text)
400
+ if len(nums) >= 2:
401
+ R_rel = float(nums[0])
402
+ R_corr = float(nums[1])
403
+ else:
404
+ R_rel, R_corr = 0.8, 0.9
405
+
406
+ except Exception as e:
407
+ print("LLM judge failed, using fallback reward. Error details:", flush=True)
408
+ print(e, flush=True)
409
+ R_rel, R_corr = 0.7, 0.7
410
+
411
+ # The Reward Formulation: R_total = R_rel * (alpha + beta * R_corr)
412
+ alpha, beta = 0.7, 0.3
413
+ print(f"LLM Judge for '{query[:50]}...' Scores => R_rel: {R_rel}, R_corr: {R_corr}", flush=True)
414
+ reward = R_rel * (alpha + beta * R_corr)
415
+
416
+ return float(reward)
417
+
418
+
419
+ def llm_judge_reward_openai(query, context, answer, action_idx):
420
+ import os
421
+ import requests
422
+ import re
423
+
424
+ try:
425
+ model_name = "gpt-4o"
426
+ openai_url = "https://api.openai.com/v1/chat/completions"
427
+ action_taken = f"Action {action_idx}"
428
+ openai_key = "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"
429
+ if not openai_key:
430
+ print("OPENAI_API_KEY not found in environment.", flush=True)
431
+ return 0.0
432
+
433
+ old_prompt = f"""You are an expert automated RAG evaluator.
434
+ Your task is to grade the retrieved Context and the generated Answer based on the user's Query.
435
+
436
+ ### GRADING RUBRIC:
437
+ 1. Context Relevance (R_rel): Does the Context contain the necessary information to answer the Query?
438
+ - 0.0: Completely irrelevant or useless.
439
+ - 0.5: Contains partial information, but not enough for a complete answer.
440
+ - 1.0: Contains all necessary information to fully answer the query.
441
+
442
+ 2. Answer Correctness (R_corr): Is the Answer fully supported by the Context? (IGNORE your internal knowledge. Grade strictly on the provided context).
443
+ - 0.0: Hallucinates information not in the context, or completely fails to answer the query.
444
+ - 0.5: Partially correct, but misses key details from the context or includes slight hallucinations.
445
+ - 1.0: Perfectly accurate, directly answers the query, and is 100% faithful to the context.
446
+
447
+ ### INPUTS:
448
+ Query: {query}
449
+ Context: {context}
450
+ Answer: {answer}
451
+
452
+ ### OUTPUT FORMAT:
453
+ You must return ONLY a valid JSON object. Do not use markdown blocks (```json).
454
+ Output this exact structure:
455
+ {{
456
+ "relevance_reasoning": "Brief 1-sentence explanation of why the context is/isn't relevant.",
457
+ "R_rel": <float between 0.0 and 1.0>,
458
+ "correctness_reasoning": "Brief 1-sentence explanation of answer correctness and faithfulness.",
459
+ "R_corr": <float between 0.0 and 1.0>
460
+ }}
461
+ """
462
+ prompt = f"""You are an expert automated RAG evaluator.
463
+ Your task is to grade the retrieved Context and the generated Answer based on the user's Query and the Action taken by the routing agent.
464
+
465
+ ### STRICT ROUTING RULES:
466
+ The agent MUST select the correct action based on the query intent:
467
+ - Action 0 (guidelines): Required for academic information, university rules, visa, scholarships, graduation requirements, health insurance, and part-time job info.
468
+ - Action 1 (web_search): Required for university news, announcements, and external information.
469
+ - Action 2 (cbnu_database): Required for department info (contacts, phone, email, website) and course registration (course details, schedules, professors, credits).
470
+
471
+ ### GRADING RUBRIC:
472
+ 1. Context Relevance (R_rel):
473
+ - FATAL PENALTY: Did the agent choose an action that violates the STRICT ROUTING RULES above? If YES, you MUST score R_rel as 0.0 immediately, regardless of the context.
474
+ - 0.0: Violates routing rules OR context is completely irrelevant/useless.
475
+ - 0.5: Follows rules, but context contains only partial information.
476
+ - 1.0: Follows rules AND context contains all necessary information to fully answer the query.
477
+
478
+ 2. Answer Correctness (R_corr): Is the Answer fully supported by the Context? (IGNORE your internal knowledge. Grade strictly on the provided context).
479
+ - 0.0: Hallucinates information not in the context, or completely fails to answer the query.
480
+ - 0.5: Partially correct, but misses key details from the context or includes slight hallucinations.
481
+ - 1.0: Perfectly accurate, directly answers the query, and is 100% faithful to the context.
482
+
483
+ ### INPUTS:
484
+ Query: {query}
485
+ Action Taken: {action_taken}
486
+ Context: {context}
487
+ Answer: {answer}
488
+
489
+ ### OUTPUT FORMAT:
490
+ You must return ONLY a valid JSON object. Do not use markdown blocks (```json).
491
+ Output this exact structure:
492
+ {{
493
+ "relevance_reasoning": "Brief 1-sentence explanation of why the rule was followed/broken and if context is relevant.",
494
+ "R_rel": <float between 0.0 and 1.0>,
495
+ "correctness_reasoning": "Brief 1-sentence explanation of answer correctness and faithfulness.",
496
+ "R_corr": <float between 0.0 and 1.0>
497
+ }}
498
+ """
499
+
500
+ headers = {
501
+ "Content-Type": "application/json",
502
+ "Authorization": f"Bearer {openai_key}"
503
+ }
504
+
505
+ payload = {
506
+ "model": model_name,
507
+ "messages": [{"role": "user", "content": prompt}],
508
+ "temperature": 0.0,
509
+ "response_format": {"type": "json_object"}
510
+ }
511
+
512
+ resp = requests.post(openai_url, json=payload, headers=headers, timeout=15)
513
+ resp.raise_for_status()
514
+
515
+ data = resp.json()
516
+ text = data["choices"][0]["message"]["content"]
517
+
518
+ # Extract floats
519
+ m_rel = re.search(r'"R_rel"\s*:\s*([0-9]*\.?[0-9]+)', text)
520
+ m_corr = re.search(r'"R_corr"\s*:\s*([0-9]*\.?[0-9]+)', text)
521
+
522
+ if m_rel and m_corr:
523
+ R_rel = float(m_rel.group(1))
524
+ R_corr = float(m_corr.group(1))
525
+ else:
526
+ nums = re.findall(r'([0-9]*\.?[0-9]+)', text)
527
+ if len(nums) >= 2:
528
+ R_rel = float(nums[0])
529
+ R_corr = float(nums[1])
530
+ else:
531
+ R_rel, R_corr = 0.8, 0.9
532
+
533
+ except Exception as e:
534
+ print("OpenAI LLM judge failed, using fallback reward. Error details:", flush=True)
535
+ print(e, flush=True)
536
+ R_rel, R_corr = 0.7, 0.7
537
+
538
+ alpha, beta = 0.7, 0.3
539
+ print(f"OpenAI Judge for '{query[:50]}...' Scores => R_rel: {R_rel}, R_corr: {R_corr}", flush=True)
540
+ reward = R_rel * (alpha + beta * R_corr)
541
+
542
+ return float(reward)
543
+
544
+
545
+
546
+
547
+ def load_router_policy(checkpoint_path: str, embedding_dim: int | None = None, device: str = "cpu") -> RouterPolicy:
548
+ """Load a RouterPolicy wrapper from checkpoint path."""
549
+ return RouterPolicy.load(checkpoint_path, embedding_dim=embedding_dim, device=device)
550
+
551
+
552
+ def save_policy_checkpoint(policy: RouterPolicy, path: str, optimizer: torch.optim.Optimizer | None = None, meta: dict | None = None):
553
+ return policy.save_checkpoint(path, optimizer=optimizer, meta=meta)
554
+
555
+ def v1_run_ppo_rollout(model, embedder, queries, optimizer, epochs=5, device: str = "cpu"):
556
+ """
557
+ Executes the environment loop and updates the model.
558
+ """
559
+ model.train()
560
+ gamma = 0.99 # Discount factor
561
+
562
+ # Prepare CSV debug log (repo-level `logs/ppo_debug.csv`)
563
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
564
+ csv_path = os.path.abspath(os.path.join(repo_root, 'logs', 'ppo_debug.csv'))
565
+ os.makedirs(os.path.dirname(csv_path), exist_ok=True)
566
+ write_header = not os.path.exists(csv_path)
567
+ csv_file = open(csv_path, mode='a', newline='', encoding='utf-8')
568
+ csv_writer = csv.writer(csv_file)
569
+ if write_header:
570
+ csv_writer.writerow([
571
+ 'timestamp', 'epoch', 'step', 'query', 'action', 'action_log_prob', 'value', 'reward', 'context', 'answer', 'epoch_avg_reward', 'policy_loss'
572
+ ])
573
+
574
+ for epoch in range(epochs):
575
+ states, actions, log_probs, rewards, values = [], [], [], [], []
576
+
577
+ # 1. Rollout Phase: Gather Experience
578
+ for step_idx, query in enumerate(queries):
579
+ # Create embedding (SentenceTransformer may use inference_mode internally).
580
+ state_emb = embedder.encode(query, convert_to_tensor=True)
581
+
582
+ # Clone the embedding to ensure it's a regular tensor (not an "inference" tensor)
583
+ # and move it to the model/device.
584
+ try:
585
+ state_emb = state_emb.clone().to(device)
586
+ except Exception:
587
+ # fallback: try model parameter device
588
+ state_emb = state_emb.clone().to(next(model.parameters()).device)
589
+
590
+ # Agent selects a module
591
+ action, log_prob, value = model.get_action(state_emb)
592
+
593
+ # Execute RAG and get LLM Judge Score
594
+ context, answer = simulate_rag_pipeline(query, action.item())
595
+ # reward = llm_judge_reward(query, context, answer, action.item())
596
+ reward = llm_judge_reward_openai(query, context, answer, action.item())
597
+
598
+ # Log per-step details to logger and CSV for debugging
599
+ try:
600
+ log_prob_val = float(log_prob.item()) if hasattr(log_prob, 'item') else float(log_prob)
601
+ except Exception:
602
+ log_prob_val = None
603
+ try:
604
+ value_val = float(value.item()) if hasattr(value, 'item') else float(value)
605
+ except Exception:
606
+ value_val = None
607
+
608
+ logger.info("Step %d epoch %d | action=%s log_prob=%s value=%s reward=%.4f", step_idx, epoch, int(action.item()), log_prob_val, value_val, float(reward))
609
+ csv_writer.writerow([
610
+ datetime.utcnow().isoformat(), epoch, step_idx,
611
+ _truncate(query, 800), int(action.item()), log_prob_val, value_val, float(reward),
612
+ _truncate(context, 1000), _truncate(answer, 1000), '', ''
613
+ ])
614
+
615
+ # Store experience
616
+ states.append(state_emb)
617
+ actions.append(action)
618
+ log_probs.append(log_prob)
619
+ rewards.append(reward)
620
+ values.append(value)
621
+
622
+ # 2. Compute Returns and Advantages
623
+ # (Simplified for single-step routing: Return = Reward)
624
+ returns = torch.tensor(rewards, dtype=torch.float32).to(states[0].device)
625
+ values_tensor = torch.cat(values).squeeze()
626
+ advantages = returns - values_tensor.detach()
627
+
628
+ # Normalize advantages for training stability
629
+ if advantages.numel() > 1:
630
+ advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
631
+ else:
632
+ advantages = advantages - advantages.mean()
633
+
634
+ # 3. PPO Update Phase
635
+ # states_tensor = torch.stack(states)
636
+ # actions_tensor = torch.stack(actions)
637
+ # log_probs_tensor = torch.stack(log_probs)
638
+ states_tensor = torch.stack(states).detach()
639
+ actions_tensor = torch.stack(actions).detach()
640
+ log_probs_tensor = torch.stack(log_probs).detach()
641
+
642
+ # Ensure these are also detached (just to be safe)
643
+ returns = returns.detach()
644
+ advantages = advantages.detach()
645
+
646
+
647
+ # loss = ppo_update(
648
+ # model, optimizer, states_tensor, actions_tensor,
649
+ # log_probs_tensor, returns, advantages
650
+ # )
651
+
652
+ # NEW: Run multiple optimization passes over the same rollout data
653
+ ppo_inner_epochs = 4
654
+ for _ in range(ppo_inner_epochs):
655
+ loss = ppo_update(
656
+ model, optimizer, states_tensor, actions_tensor,
657
+ log_probs_tensor, returns, advantages
658
+ )
659
+
660
+
661
+ avg_reward = float(returns.mean())
662
+ logger.info("PPO Epoch %d | Avg Reward: %.4f | Policy Loss: %.4f", epoch+1, avg_reward, loss)
663
+ csv_writer.writerow([
664
+ datetime.utcnow().isoformat(), epoch, '', '', '', '', '', '', '', '', avg_reward, loss
665
+ ])
666
+
667
+ try:
668
+ csv_file.flush()
669
+ csv_file.close()
670
+ except Exception:
671
+ pass
672
+
673
+ print(f"PPO Epoch {epoch+1} | Avg Reward: {returns.mean():.4f} | Policy Loss: {loss:.4f}")
674
+
675
+
676
+ def run_ppo_rollout(model, embedder, queries, optimizer, epochs=40, device: str = "cpu"):
677
+ model.train()
678
+
679
+ # Prepare CSV debug log (repo-level `logs/ppo_debug.csv`)
680
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
681
+ csv_path = os.path.abspath(os.path.join(repo_root, 'logs', 'ppo_debug.csv'))
682
+ os.makedirs(os.path.dirname(csv_path), exist_ok=True)
683
+ write_header = not os.path.exists(csv_path)
684
+ csv_file = open(csv_path, mode='a', newline='', encoding='utf-8')
685
+ csv_writer = csv.writer(csv_file)
686
+ if write_header:
687
+ csv_writer.writerow([
688
+ 'timestamp', 'epoch', 'step', 'query', 'action', 'action_log_prob', 'value', 'reward', 'context', 'answer', 'epoch_avg_reward', 'policy_loss'
689
+ ])
690
+
691
+ for epoch in range(epochs):
692
+ states, actions, log_probs, rewards, values = [], [], [], [], []
693
+
694
+ # 1. Rollout (Data Gathering)
695
+ # for query in queries:
696
+ for step_idx, query in enumerate(queries):
697
+ with torch.no_grad():
698
+ state_emb = embedder.encode(query, convert_to_tensor=True)
699
+
700
+ # Clone and move to device
701
+ try:
702
+ state_emb = state_emb.clone().to(device)
703
+ except Exception:
704
+ state_emb = state_emb.clone().to(next(model.parameters()).device)
705
+
706
+ action, log_prob, value = model.get_action(state_emb)
707
+ context, answer = simulate_rag_pipeline(query, action.item())
708
+ # reward = llm_judge_reward(query, context, answer, action.item())
709
+ reward = llm_judge_reward_openai(query, context, answer, action.item())
710
+
711
+ # Log per-step details to logger and CSV for debugging
712
+ try:
713
+ log_prob_val = float(log_prob.item()) if hasattr(log_prob, 'item') else float(log_prob)
714
+ except Exception:
715
+ log_prob_val = None
716
+ try:
717
+ value_val = float(value.item()) if hasattr(value, 'item') else float(value)
718
+ except Exception:
719
+ value_val = None
720
+
721
+ logger.info("Step %d epoch %d | action=%s log_prob=%s value=%s reward=%.4f", step_idx, epoch, int(action.item()), log_prob_val, value_val, float(reward))
722
+ csv_writer.writerow([
723
+ datetime.utcnow().isoformat(), epoch, step_idx,
724
+ _truncate(query, 800), int(action.item()), log_prob_val, value_val, float(reward),
725
+ _truncate(context, 1000), _truncate(answer, 1000), '', ''
726
+ ])
727
+
728
+ states.append(state_emb)
729
+ actions.append(action)
730
+ log_probs.append(log_prob)
731
+ rewards.append(reward)
732
+ values.append(value)
733
+
734
+
735
+ returns = torch.tensor(rewards, dtype=torch.float32).to(states[0].device)
736
+ values_tensor = torch.cat(values).squeeze()
737
+ advantages = returns - values_tensor.detach()
738
+
739
+ # ---------------------------------------------------------
740
+ # CRITICAL FIX: Strict Advantage Normalization
741
+ # Prevents positive-only rewards from causing gradient collapse
742
+ # ---------------------------------------------------------
743
+ # advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
744
+ if advantages.numel() > 1:
745
+ advantages = (advantages - advantages.mean()) / (advantages.std(unbiased=False) + 1e-8)
746
+ else:
747
+ advantages = advantages - advantages.mean()
748
+
749
+ states_tensor = torch.stack(states).detach()
750
+ actions_tensor = torch.stack(actions).detach()
751
+ log_probs_tensor = torch.stack(log_probs).detach()
752
+ returns = returns.detach()
753
+ advantages = advantages.detach()
754
+
755
+ # 2. PPO Inner Optimization Loop
756
+ ppo_inner_epochs = 4
757
+ epoch_loss = 0
758
+
759
+ for _ in range(ppo_inner_epochs):
760
+ loss, current_ent_coef = ppo_update(
761
+ model, optimizer, states_tensor, actions_tensor,
762
+ log_probs_tensor, returns, advantages,
763
+ current_epoch=epoch, total_epochs=epochs
764
+ )
765
+ epoch_loss += loss
766
+ avg_reward = float(returns.mean())
767
+ logger.info("PPO Epoch %d | Avg Reward: %.4f | Policy Loss: %.4f", epoch+1, avg_reward, loss)
768
+ csv_writer.writerow([
769
+ datetime.utcnow().isoformat(), epoch, '', '', '', '', '', '', '', '', avg_reward, loss
770
+ ])
771
+
772
+ print(f"Epoch {epoch+1}/{epochs} | Avg Reward: {returns.mean():.4f} | Ent Coef: {current_ent_coef:.4f} | Loss: {epoch_loss/ppo_inner_epochs:.4f}")
router_checkpoint.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f751cb17c9b905d0476abe192990de3e154d8684d5321eca08338829d72ce14c
3
+ size 270451
router_checkpoint_after_grpo.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:35126ed03ef000e4bba3b8c938b4dc9c9e375612e21ba0c60374e3ed164fd4b4
3
+ size 270777
router_checkpoint_pt_after_ppo.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fe76dc575e8b36fe20b19309c060d0f9df5518dc5afe537b2cb8c3e267629548
3
+ size 270813
router_checkpoint_pt_after_ppo_40_epoch_v1.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b4d2fd917560dfe4398de00335dca1033614049029aa6be6ea55f8fc3be580c9
3
+ size 270813
run_grpo.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ run_grpo.py — GRPO training entry point for the RAG routing agent.
4
+
5
+ This is the GRPO equivalent of run_ppo-2.py.
6
+
7
+ Key differences from PPO:
8
+ - Uses run_grpo_rollout() instead of run_ppo_rollout()
9
+ - group_size (G) controls how many action samples are drawn per query
10
+ to compute within-group advantage normalisation (no value network needed)
11
+ - kl_coef controls the KL divergence penalty against the reference policy
12
+ - Checkpoint saved to router_checkpoint_after_grpo.pt
13
+ """
14
+ import os
15
+ # from shutil import copy
16
+ import sys
17
+ import copy
18
+ # from asyncssh import logger
19
+ import torch
20
+ import pandas as pd
21
+
22
+ # Ensure the routing_agent package is importable
23
+ ROOT = os.path.dirname(__file__)
24
+ if ROOT not in sys.path:
25
+ sys.path.insert(0, ROOT)
26
+
27
+ from grpo_trainer import run_grpo_rollout, load_router_policy
28
+ from model import RouterNetwork
29
+ from sentence_transformers import SentenceTransformer
30
+
31
+
32
+ def main_from_scratch():
33
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
34
+ print(f"Using device: {device}")
35
+
36
+ # ------------------------------------------------------------------ #
37
+ # Embedder + Model
38
+ # ------------------------------------------------------------------ #
39
+ embed_model_name = "all-MiniLM-L6-v2"
40
+ embedder = SentenceTransformer(embed_model_name)
41
+ embed_dim = embedder.get_sentence_embedding_dimension()
42
+
43
+ model = RouterNetwork(embedding_dim=embed_dim, num_classes=3).to(device)
44
+
45
+ # ------------------------------------------------------------------ #
46
+ # Optionally resume from an existing checkpoint
47
+ # ------------------------------------------------------------------ #
48
+ checkpoint_path = os.path.join(os.path.dirname(__file__), "router_checkpoint.pt")
49
+ if os.path.exists(checkpoint_path):
50
+ try:
51
+ sd = torch.load(checkpoint_path, map_location="cpu")
52
+ if isinstance(sd, dict) and "model_state_dict" in sd:
53
+ model.load_state_dict(sd["model_state_dict"], strict=False)
54
+ else:
55
+ model.load_state_dict(sd, strict=False)
56
+ print(f"Loaded checkpoint: {checkpoint_path}")
57
+ except Exception as e:
58
+ print(f"Failed loading checkpoint: {e}")
59
+ else:
60
+ print("No existing checkpoint found — training from scratch.")
61
+
62
+ # ------------------------------------------------------------------ #
63
+ # Optimizer
64
+ # ------------------------------------------------------------------ #
65
+ optimizer = torch.optim.Adam(model.parameters(), lr=3e-5)
66
+
67
+ # ------------------------------------------------------------------ #
68
+ # Training queries
69
+ # ------------------------------------------------------------------ #
70
+ df = pd.read_csv("df_queries.csv")
71
+ training_queries = df["question"].tolist()
72
+
73
+ # Uncomment to limit queries for a quick smoke-test:
74
+ # training_queries = training_queries[:2]
75
+
76
+ print(f"Training on {len(training_queries)} queries.")
77
+
78
+ # ------------------------------------------------------------------ #
79
+ # GRPO Hyperparameters
80
+ # ------------------------------------------------------------------ #
81
+ EPOCHS = 20#40 # Total training epochs (same as PPO run)
82
+ GROUP_SIZE = 4 # G: number of action samples drawn per query per epoch
83
+ # Higher G → better advantage estimates, but more API calls.
84
+ # Set to 1 to match the original PPO single-sample behaviour.
85
+ KL_COEF = 0.04 # Weight for KL divergence penalty (DeepSeek-R1 default: 0.04)
86
+ CLIP_RATIO = 0.2 # PPO-style clipping kept in GRPO surrogate
87
+
88
+ # ------------------------------------------------------------------ #
89
+ # Run GRPO rollouts
90
+ # ------------------------------------------------------------------ #
91
+ # run_grpo_rollout_old(
92
+ # model=model,
93
+ # embedder=embedder,
94
+ # queries=training_queries,
95
+ # optimizer=optimizer,
96
+ # epochs=EPOCHS,
97
+ # group_size=GROUP_SIZE,
98
+ # device=str(device),
99
+ # kl_coef=KL_COEF,
100
+ # clip_ratio=CLIP_RATIO,
101
+ # )
102
+ run_grpo_rollout(
103
+ model=model,
104
+ embedder=embedder,
105
+ queries=training_queries,
106
+ optimizer=optimizer,
107
+ epochs=EPOCHS,
108
+ device=str(device),
109
+ kl_coef=KL_COEF,
110
+ clip_ratio=CLIP_RATIO,
111
+ )
112
+
113
+ # ------------------------------------------------------------------ #
114
+ # Save updated checkpoint
115
+ # ------------------------------------------------------------------ #
116
+ out_path = os.path.join(os.path.dirname(__file__), "router_checkpoint_after_grpo.pt")
117
+ torch.save({"model_state_dict": model.state_dict()}, out_path)
118
+ print(f"Saved GRPO-updated checkpoint to: {out_path}")
119
+
120
+ def main():
121
+ LR = 3e-5
122
+ # ------------------------------------------------------------------ #
123
+ # GRPO Hyperparameters
124
+ # ------------------------------------------------------------------ #
125
+ EPOCHS = 20#40 # Total training epochs (same as PPO run)
126
+ GROUP_SIZE = 4 # G: number of action samples drawn per query per epoch
127
+ # Higher G → better advantage estimates, but more API calls.
128
+ # Set to 1 to match the original PPO single-sample behaviour.
129
+ KL_COEF = 0.01#0.04 # Weight for KL divergence penalty (DeepSeek-R1 default: 0.04)
130
+ #Your first run showed KL was essentially zero for the first 6 epochs — meaning kl_coef=0.04 was over-constraining the policy and slowing early learning. With the reference refresh now in place (point 1 above), you can lower this safely:
131
+
132
+ CLIP_RATIO = 0.2 # PPO-style clipping kept in GRPO surrogate
133
+
134
+ RESUME_FROM = "adapters/router_grpo_epoch_14.pt" # set to None to start fresh
135
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
136
+ print(f"Using device: {device}")
137
+
138
+ # EMBEDDER (always needed to encode queries)
139
+ embed_model_name = "all-MiniLM-L6-v2"
140
+ embedder = SentenceTransformer(embed_model_name)
141
+ embed_dim = embedder.get_sentence_embedding_dimension()
142
+
143
+ # ------------------------------------------------------------------ #
144
+ # MODEL — build the architecture shell only (no SFT loading)
145
+ # ------------------------------------------------------------------ #
146
+ model = RouterNetwork(embedding_dim=embed_dim, num_classes=3).to(device)
147
+ optimizer = torch.optim.Adam(model.parameters(), lr=LR)
148
+
149
+
150
+ start_epoch = 0
151
+ if RESUME_FROM and os.path.exists(RESUME_FROM):
152
+ ckpt = torch.load(RESUME_FROM, map_location=device)
153
+ model.load_state_dict(ckpt["model_state_dict"])
154
+ optimizer.load_state_dict(ckpt["optimizer_state_dict"])
155
+ start_epoch = ckpt["epoch"] # = 7 → loop starts at epoch 7
156
+ print(f"Resumed from {RESUME_FROM} — continuing from epoch {start_epoch}")
157
+ else:
158
+ print(f"No checkpoint found at {RESUME_FROM}, starting from scratch.")
159
+
160
+ # ------------------------------------------------------------------ #
161
+ # REF POLICY — snapshot AFTER loading checkpoint, BEFORE training loop
162
+ # This is the fixed reference the KL penalty measures distance from.
163
+ # ------------------------------------------------------------------ #
164
+ ref_policy = copy.deepcopy(model)
165
+ ref_policy.eval()
166
+ for p in ref_policy.parameters():
167
+ p.requires_grad_(False) # freeze completely — never updated
168
+
169
+ # ------------------------------------------------------------------ #
170
+ # Training queries
171
+ # ------------------------------------------------------------------ #
172
+ df = pd.read_csv("df_queries_filtered.csv")
173
+ training_queries = df["question"].tolist()
174
+
175
+ # Uncomment to limit queries for a quick smoke-test:
176
+ # training_queries = training_queries[:2]
177
+
178
+ print(f"Training on {len(training_queries)} queries.")
179
+
180
+ run_grpo_rollout(
181
+ model=model,
182
+ embedder=embedder,
183
+ queries=training_queries,
184
+ optimizer=optimizer,
185
+ ref_policy = ref_policy, # ← passed in, not re-created inside trainer
186
+ epochs=EPOCHS,
187
+ start_epoch=start_epoch,
188
+ device=str(device),
189
+ kl_coef=KL_COEF,
190
+ clip_ratio=CLIP_RATIO,
191
+ )
192
+
193
+ # ------------------------------------------------------------------ #
194
+ # Save updated checkpoint
195
+ # ------------------------------------------------------------------ #
196
+ out_path = os.path.join(os.path.dirname(__file__), "router_checkpoint_after_grpo.pt")
197
+ torch.save({"model_state_dict": model.state_dict()}, out_path)
198
+ print(f"Saved GRPO-updated checkpoint to: {out_path}")
199
+
200
+ if __name__ == "__main__":
201
+ main()
run_inference.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import time
4
+ import torch
5
+ from sentence_transformers import SentenceTransformer
6
+ import pandas as pd
7
+
8
+ # ── 1. Path setup (same pattern as run_ppo-2.py) ────────────────────────────
9
+ ROOT = os.path.dirname(__file__)
10
+ if ROOT not in sys.path:
11
+ sys.path.insert(0, ROOT)
12
+
13
+ from model import RouterNetwork
14
+ from grpo_trainer import simulate_rag_pipeline # reuse your existing tool caller
15
+
16
+ # ── 2. Config ────────────────────────────────────────────────────────────────
17
+ CHECKPOINT = os.path.join(ROOT, "adapters/router_grpo_epoch_16.pt") # ← change epoch here
18
+ EMBED_MODEL = "all-MiniLM-L6-v2"
19
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
20
+
21
+ ACTION_NAMES = {
22
+ 0: "Guidelines (academic rules / visa / scholarships)",
23
+ 1: "Web-Search (news / announcements)",
24
+ 2: "CBNU Database (courses / departments / contacts)",
25
+ }
26
+
27
+ # ── 3. Load embedder + model ─────────────────────────────────────────────────
28
+ embedder = SentenceTransformer(EMBED_MODEL)
29
+ embed_dim = embedder.get_sentence_embedding_dimension()
30
+ model = RouterNetwork(embedding_dim=embed_dim, num_classes=3).to(DEVICE)
31
+
32
+ # Load checkpoint (supports both raw state_dict and wrapped dict)
33
+ sd = torch.load(CHECKPOINT, map_location="cpu")
34
+ if isinstance(sd, dict) and "model_state_dict" in sd:
35
+ model.load_state_dict(sd["model_state_dict"], strict=False)
36
+ else:
37
+ model.load_state_dict(sd, strict=False)
38
+
39
+ model.eval()
40
+ print(f"✅ Loaded checkpoint: {CHECKPOINT}")
41
+ print(f" Device: {DEVICE}\n")
42
+
43
+ # ── 4. Single-query inference function ───────────────────────────────────────
44
+ def route_query(query: str, call_tool: bool = True):
45
+ """
46
+ Route one query through the loaded GRPO checkpoint.
47
+
48
+ Args:
49
+ query : The user question string.
50
+ call_tool : If True, also calls simulate_rag_pipeline and prints the answer.
51
+ Set False for routing-only / fast testing.
52
+
53
+ Returns:
54
+ action_idx (int), action_name (str), context (str), answer (str)
55
+ """
56
+ # Embed
57
+ with torch.no_grad():
58
+ emb = embedder.encode(query, convert_to_tensor=True).to(DEVICE)
59
+ action, log_prob, _ = model.get_action(emb)
60
+ action_idx = int(action.item())
61
+
62
+ action_name = ACTION_NAMES[action_idx]
63
+ print(f"Query : {query}")
64
+ print(f"→ Routed to Action {action_idx}: {action_name}")
65
+ print(f" log_prob = {log_prob.item():.4f}")
66
+
67
+ context, answer = "", ""
68
+ if call_tool:
69
+ print(" Calling tool …")
70
+ context, answer = simulate_rag_pipeline(query, action_idx)
71
+ print(f" Answer : {answer[:300]}")
72
+
73
+ return action_idx, action_name, context, answer
74
+
75
+
76
+ # ── 5. Main: test with one or more queries ───────────────────────────────────
77
+ if __name__ == "__main__":
78
+ test_queries = [
79
+ "What are the graduation requirements for international students?",
80
+ "How do I register for courses next semester?",
81
+ "What is the latest news from CBNU?",
82
+ ]
83
+ test_queries = [
84
+ "2학기 개강일은?",
85
+ "알바하고 싶은데 어떤 조건이 필요한가?",
86
+ "아르바이트 하려는데 조건은?",
87
+ "시간제 취업 방법은?",
88
+ "건강보험 어떻게 가입해?",
89
+ "성적확인 기간은?"
90
+ ]
91
+
92
+ eval_df = pd.read_csv('ko_eval_queries.csv')
93
+ test_queries = eval_df['question'].tolist()
94
+ results = []
95
+ for idx, row in eval_df.iterrows():
96
+ q = row.get('question')
97
+ ground = row.get('action_label')
98
+ start = time.perf_counter()
99
+ pred_idx, pred_name, context, answer = route_query(q, call_tool=False)
100
+ end = time.perf_counter()
101
+ elapsed = end - start
102
+ results.append({
103
+ 'question': q,
104
+ 'ground_truth': ground,
105
+ 'predicted_label': pred_idx,
106
+ 'time_took': elapsed,
107
+ })
108
+
109
+
110
+ out_df = pd.DataFrame(results)
111
+ out_df.to_csv('ko_eval_with_timing_results_epoch_16.csv', index=False)
112
+ print(f"Wrote results to eval_with_timing_results.csv ({len(out_df)} rows)")
run_ppo.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import sys
4
+ import torch
5
+ import pandas as pd
6
+
7
+ # Ensure routing_agent package is importable
8
+ ROOT = os.path.dirname(__file__)
9
+ if ROOT not in sys.path:
10
+ sys.path.insert(0, ROOT)
11
+
12
+ from ppo_trainer import run_ppo_rollout, load_router_policy
13
+ from model import RouterNetwork
14
+ from sentence_transformers import SentenceTransformer
15
+
16
+
17
+ def main():
18
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
19
+
20
+ # Prepare embedder and model
21
+ embed_model_name = 'all-MiniLM-L6-v2'
22
+ embedder = SentenceTransformer(embed_model_name)
23
+
24
+ embed_dim = embedder.get_sentence_embedding_dimension()
25
+ model = RouterNetwork(embedding_dim=embed_dim, num_classes=3).to(device)
26
+
27
+ # Optionally load existing checkpoint to continue training
28
+ checkpoint_path = os.path.join(os.path.dirname(__file__), 'router_checkpoint.pt')
29
+ if os.path.exists(checkpoint_path):
30
+ try:
31
+ sd = torch.load(checkpoint_path, map_location='cpu')
32
+ if isinstance(sd, dict) and 'model_state_dict' in sd:
33
+ model.load_state_dict(sd['model_state_dict'], strict=False)
34
+ else:
35
+ model.load_state_dict(sd, strict=False)
36
+ print('Loaded checkpoint', checkpoint_path)
37
+ except Exception as e:
38
+ print('Failed loading checkpoint:', e)
39
+
40
+ optimizer = torch.optim.Adam(model.parameters(), lr=3e-5)
41
+
42
+ # training_queries = [
43
+ # "What are the graduation requirements for the 2026 academic year?",
44
+ # "Show me the syllabus for the Advanced Database course.",
45
+ # "How do I apply for the on-campus dormitory?"
46
+ # ]
47
+ df = pd.read_csv('df_queries.csv')
48
+ training_queries = df['question'].tolist()
49
+
50
+ # training_queries = training_queries[:2] # Limit to first 200 queries for faster testing
51
+
52
+
53
+ # Run PPO rollouts (this uses simulate_rag_pipeline to call the agent tools)
54
+ run_ppo_rollout(model, embedder, training_queries, optimizer, epochs=40, device=str(device))
55
+
56
+ # Save the updated model
57
+ out_path = os.path.join(os.path.dirname(__file__), 'router_checkpoint_pt_after_ppo.pt')
58
+ torch.save({'model_state_dict': model.state_dict()}, out_path)
59
+ print('Saved PPO-updated checkpoint to', out_path)
60
+
61
+
62
+ if __name__ == '__main__':
63
+ main()
sft_trainer.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import torch.optim as optim
3
+ import torch.nn as nn
4
+ from model import RouterNetwork
5
+
6
+ def train_sft(model, embedder, dataset, epochs = 5, lr = 1e-3):
7
+ """
8
+ dataset: List of dicts [{'question': '...', 'label': 0|1|2}]
9
+ """
10
+ device = next(model.parameters()).device
11
+ class_weights = torch.tensor([1.0, 1.0, 10.0], device=device, dtype=torch.float)
12
+ optimizer = optim.Adam(model.parameters(), lr=lr)
13
+ criterion = nn.CrossEntropyLoss(weight=class_weights)
14
+
15
+ model.train()
16
+ for epoch in range(epochs):
17
+ total_loss = 0
18
+ correct_predictions = 0
19
+ for data in dataset:
20
+ # 1. Embed the query (state)
21
+ # Using torch.no_grad() for embedder so we only train the router MLP
22
+ with torch.no_grad():
23
+ query_emb = embedder.encode(data['question'], convert_to_tensor=True)
24
+
25
+ label = torch.tensor([data['label']], dtype=torch.long).to(query_emb.device)
26
+
27
+ # 2. Forward pass through the router network
28
+ optimizer.zero_grad()
29
+ action_logits, _ = model(query_emb) # only care about action_logits (Actor head) for SFT
30
+
31
+ # Calculate prediction and track accuracy for this epoch
32
+ predicted_class = torch.argmax(action_logits, dim=-1)
33
+ if predicted_class.item() == label.item():
34
+ correct_predictions += 1
35
+
36
+ # 3. Compute loss and backpropagate
37
+ # CrossEntropyLoss expects (N, C) for logits and (N) for labels, so we unsqueeze action_logits to add batch dimension
38
+ loss = criterion(action_logits.unsqueeze(0), label)
39
+ loss.backward()
40
+ optimizer.step()
41
+
42
+ total_loss += loss.item()
43
+
44
+ avg_loss = total_loss / len(dataset)
45
+ accuracy = (correct_predictions / len(dataset)) * 100
46
+ print(f"SFT Epoch {epoch+1} | Loss: {avg_loss:.4f} | Accuracy: {accuracy:.2f}%")