Upload routing agent adapters and source code
Browse files- .gitattributes +1 -0
- Deterministic_Action_Space_GRPO.pdf +3 -0
- adapters/router_grpo_epoch_1.pt +3 -0
- adapters/router_grpo_epoch_10.pt +3 -0
- adapters/router_grpo_epoch_11.pt +3 -0
- adapters/router_grpo_epoch_12.pt +3 -0
- adapters/router_grpo_epoch_13.pt +3 -0
- adapters/router_grpo_epoch_14.pt +3 -0
- adapters/router_grpo_epoch_15.pt +3 -0
- adapters/router_grpo_epoch_16.pt +3 -0
- adapters/router_grpo_epoch_17.pt +3 -0
- adapters/router_grpo_epoch_18.pt +3 -0
- adapters/router_grpo_epoch_19.pt +3 -0
- adapters/router_grpo_epoch_2.pt +3 -0
- adapters/router_grpo_epoch_20.pt +3 -0
- adapters/router_grpo_epoch_3.pt +3 -0
- adapters/router_grpo_epoch_4.pt +3 -0
- adapters/router_grpo_epoch_5.pt +3 -0
- adapters/router_grpo_epoch_6.pt +3 -0
- adapters/router_grpo_epoch_7.pt +3 -0
- adapters/router_grpo_epoch_8.pt +3 -0
- adapters/router_grpo_epoch_9.pt +3 -0
- benchmark_evaluation.py +735 -0
- eval_with_timing.py +52 -0
- generate_paper_results.py +281 -0
- grpo_trainer.py +840 -0
- main.py +120 -0
- model.py +43 -0
- policy.py +83 -0
- ppo_trainer.py +772 -0
- router_checkpoint.pt +3 -0
- router_checkpoint_after_grpo.pt +3 -0
- router_checkpoint_pt_after_ppo.pt +3 -0
- router_checkpoint_pt_after_ppo_40_epoch_v1.pt +3 -0
- run_grpo.py +201 -0
- run_inference.py +112 -0
- run_ppo.py +63 -0
- sft_trainer.py +46 -0
.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}%")
|