piyush-mk commited on
Commit
25ee43d
·
verified ·
1 Parent(s): 90c05cf

Add InvoiceGuard SFT and merge tooling

Browse files
training/eval_adapter.py CHANGED
@@ -1,184 +1,184 @@
1
- #!/usr/bin/env python3
2
- # /// script
3
- # requires-python = ">=3.10"
4
- # dependencies = [
5
- # "torch>=2.2",
6
- # "transformers>=4.46",
7
- # "peft>=0.13",
8
- # "accelerate>=1.0",
9
- # "bitsandbytes>=0.43; platform_system != 'Darwin'",
10
- # "huggingface_hub>=0.26",
11
- # "openenv-core[core]>=0.2.1",
12
- # "pydantic>=2.6",
13
- # "pydantic-settings>=2.0",
14
- # "fastapi>=0.115",
15
- # "uvicorn>=0.30",
16
- # "python-dotenv",
17
- # "openai>=1.40",
18
- # ]
19
- # ///
20
- """Evaluate a LoRA adapter on InvoiceGuard tasks and upload JSON artifacts."""
21
-
22
- from __future__ import annotations
23
-
24
- import argparse
25
- import json
26
- import os
27
- import sys
28
- from datetime import datetime, timezone
29
- from pathlib import Path
30
- from typing import Optional
31
-
32
-
33
- def _hf_token() -> Optional[str]:
34
- return os.environ.get("HF_TOKEN") or os.environ.get("API_TOKEN_HF")
35
-
36
-
37
- def _bootstrap_invoice_guard_path() -> Path:
38
- code_dir = os.environ.get("INVOICEGUARD_CODE_DIR")
39
- if code_dir and Path(code_dir).is_dir():
40
- sys.path.insert(0, code_dir)
41
- return Path(code_dir)
42
-
43
- repo = os.environ.get("INVOICEGUARD_CODE_REPO")
44
- if repo:
45
- from huggingface_hub import snapshot_download
46
-
47
- local = snapshot_download(repo_id=repo, repo_type="model", token=_hf_token())
48
- sys.path.insert(0, local)
49
- return Path(local)
50
-
51
- here = Path(__file__).resolve().parent.parent
52
- sys.path.insert(0, str(here))
53
- return here
54
-
55
-
56
- _CODE_ROOT = _bootstrap_invoice_guard_path()
57
-
58
- import torch
59
- from huggingface_hub import HfApi
60
- from peft import PeftModel
61
- from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
62
-
63
- from server.invoice_guard_environment import InvoiceGuardEnvironment # type: ignore
64
- from tasks import HARD_TASK_LIST, TASK_LIST # type: ignore
65
- from training.rollout import rollout_episode # type: ignore
66
-
67
-
68
- def _task_slice(name: str):
69
- if name == "canonical":
70
- return list(TASK_LIST)
71
- if name == "hard":
72
- return list(HARD_TASK_LIST)
73
- return list(TASK_LIST) + list(HARD_TASK_LIST)
74
-
75
-
76
- def main() -> None:
77
- p = argparse.ArgumentParser()
78
- p.add_argument("--base-model", default=os.environ.get("BASE_MODEL", "Qwen/Qwen3-4B-Instruct-2507"))
79
- p.add_argument("--adapter-repo", required=True)
80
- p.add_argument("--slice", choices=["canonical", "hard", "all"], default="all")
81
- p.add_argument("--max-tasks", type=int, default=None)
82
- p.add_argument("--max-new-tokens", type=int, default=96)
83
- p.add_argument("--max-prompt-tokens", type=int, default=2048)
84
- p.add_argument("--artifact-dir", default="/tmp/invoiceguard-adapter-eval")
85
- args = p.parse_args()
86
-
87
- token = _hf_token()
88
- if not token:
89
- raise RuntimeError("HF_TOKEN/API_TOKEN_HF is required for adapter eval upload.")
90
-
91
- print(f"[setup] code_root={_CODE_ROOT}", flush=True)
92
- print(f"[setup] base_model={args.base_model}", flush=True)
93
- print(f"[setup] adapter_repo={args.adapter_repo}", flush=True)
94
- print(f"[setup] cuda available={torch.cuda.is_available()}", flush=True)
95
-
96
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
97
- dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
98
- quant_cfg = None
99
- if torch.cuda.is_available():
100
- quant_cfg = BitsAndBytesConfig(
101
- load_in_4bit=True,
102
- bnb_4bit_quant_type="nf4",
103
- bnb_4bit_use_double_quant=True,
104
- bnb_4bit_compute_dtype=dtype,
105
- )
106
-
107
- tokenizer = AutoTokenizer.from_pretrained(args.base_model, use_fast=True, token=token)
108
- if tokenizer.pad_token is None:
109
- tokenizer.pad_token = tokenizer.eos_token
110
-
111
- base = AutoModelForCausalLM.from_pretrained(
112
- args.base_model,
113
- torch_dtype=dtype,
114
- device_map="auto" if torch.cuda.is_available() else None,
115
- low_cpu_mem_usage=True,
116
- quantization_config=quant_cfg,
117
- token=token,
118
- )
119
- base.config.pad_token_id = tokenizer.pad_token_id
120
- base.config.use_cache = False
121
- model = PeftModel.from_pretrained(base, args.adapter_repo, token=token)
122
- model.eval()
123
-
124
- tasks = _task_slice(args.slice)
125
- if args.max_tasks is not None:
126
- tasks = tasks[: args.max_tasks]
127
-
128
- env = InvoiceGuardEnvironment()
129
- rows = []
130
- for i, task_id in enumerate(tasks, 1):
131
- print(f"[eval] {i}/{len(tasks)} {task_id.value}", flush=True)
132
- traj = rollout_episode(
133
- model,
134
- tokenizer,
135
- env,
136
- task_id,
137
- temperature=0.0001,
138
- top_p=1.0,
139
- max_new_tokens=args.max_new_tokens,
140
- max_prompt_tokens=args.max_prompt_tokens,
141
- device=device,
142
- )
143
- rows.append({
144
- "task_id": task_id.value,
145
- "grader_score": traj.grader_score,
146
- "cumulative_reward": traj.cumulative_reward,
147
- "success": traj.success,
148
- "n_steps": traj.n_steps,
149
- "terminal_decision": traj.terminal_decision,
150
- "actions": [step.completion_text for step in traj.steps],
151
- "step_rewards": [step.reward for step in traj.steps],
152
- })
153
-
154
- summary = {
155
- "run_finished_at": datetime.now(timezone.utc).isoformat(),
156
- "base_model": args.base_model,
157
- "adapter_repo": args.adapter_repo,
158
- "slice": args.slice,
159
- "n_tasks": len(rows),
160
- "avg_grader_score": sum(r["grader_score"] for r in rows) / max(len(rows), 1),
161
- "avg_cumulative_reward": sum(r["cumulative_reward"] for r in rows) / max(len(rows), 1),
162
- "success_rate": sum(1.0 if r["success"] else 0.0 for r in rows) / max(len(rows), 1),
163
- "avg_steps": sum(r["n_steps"] for r in rows) / max(len(rows), 1),
164
- }
165
-
166
- out_dir = Path(args.artifact_dir)
167
- out_dir.mkdir(parents=True, exist_ok=True)
168
- (out_dir / "adapter_eval_results.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
169
- (out_dir / "adapter_eval_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
170
- print(json.dumps(summary, indent=2), flush=True)
171
-
172
- HfApi(token=token).upload_folder(
173
- folder_path=str(out_dir),
174
- repo_id=args.adapter_repo,
175
- repo_type="model",
176
- path_in_repo=f"eval_artifacts/{args.slice}",
177
- token=token,
178
- commit_message=f"Add InvoiceGuard adapter eval results ({args.slice})",
179
- )
180
- print(f"[push] eval artifacts uploaded to https://huggingface.co/{args.adapter_repo}", flush=True)
181
-
182
-
183
- if __name__ == "__main__":
184
- main()
 
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = [
5
+ # "torch>=2.2",
6
+ # "transformers>=4.46",
7
+ # "peft>=0.13",
8
+ # "accelerate>=1.0",
9
+ # "bitsandbytes>=0.43; platform_system != 'Darwin'",
10
+ # "huggingface_hub>=0.26",
11
+ # "openenv-core[core]>=0.2.1",
12
+ # "pydantic>=2.6",
13
+ # "pydantic-settings>=2.0",
14
+ # "fastapi>=0.115",
15
+ # "uvicorn>=0.30",
16
+ # "python-dotenv",
17
+ # "openai>=1.40",
18
+ # ]
19
+ # ///
20
+ """Evaluate a LoRA adapter on InvoiceGuard tasks and upload JSON artifacts."""
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+
33
+ def _hf_token() -> Optional[str]:
34
+ return os.environ.get("HF_TOKEN") or os.environ.get("API_TOKEN_HF")
35
+
36
+
37
+ def _bootstrap_invoice_guard_path() -> Path:
38
+ code_dir = os.environ.get("INVOICEGUARD_CODE_DIR")
39
+ if code_dir and Path(code_dir).is_dir():
40
+ sys.path.insert(0, code_dir)
41
+ return Path(code_dir)
42
+
43
+ repo = os.environ.get("INVOICEGUARD_CODE_REPO")
44
+ if repo:
45
+ from huggingface_hub import snapshot_download
46
+
47
+ local = snapshot_download(repo_id=repo, repo_type="model", token=_hf_token())
48
+ sys.path.insert(0, local)
49
+ return Path(local)
50
+
51
+ here = Path(__file__).resolve().parent.parent
52
+ sys.path.insert(0, str(here))
53
+ return here
54
+
55
+
56
+ _CODE_ROOT = _bootstrap_invoice_guard_path()
57
+
58
+ import torch
59
+ from huggingface_hub import HfApi
60
+ from peft import PeftModel
61
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
62
+
63
+ from server.invoice_guard_environment import InvoiceGuardEnvironment # type: ignore
64
+ from tasks import HARD_TASK_LIST, TASK_LIST # type: ignore
65
+ from training.rollout import rollout_episode # type: ignore
66
+
67
+
68
+ def _task_slice(name: str):
69
+ if name == "canonical":
70
+ return list(TASK_LIST)
71
+ if name == "hard":
72
+ return list(HARD_TASK_LIST)
73
+ return list(TASK_LIST) + list(HARD_TASK_LIST)
74
+
75
+
76
+ def main() -> None:
77
+ p = argparse.ArgumentParser()
78
+ p.add_argument("--base-model", default=os.environ.get("BASE_MODEL", "Qwen/Qwen3-4B-Instruct-2507"))
79
+ p.add_argument("--adapter-repo", required=True)
80
+ p.add_argument("--slice", choices=["canonical", "hard", "all"], default="all")
81
+ p.add_argument("--max-tasks", type=int, default=None)
82
+ p.add_argument("--max-new-tokens", type=int, default=96)
83
+ p.add_argument("--max-prompt-tokens", type=int, default=2048)
84
+ p.add_argument("--artifact-dir", default="/tmp/invoiceguard-adapter-eval")
85
+ args = p.parse_args()
86
+
87
+ token = _hf_token()
88
+ if not token:
89
+ raise RuntimeError("HF_TOKEN/API_TOKEN_HF is required for adapter eval upload.")
90
+
91
+ print(f"[setup] code_root={_CODE_ROOT}", flush=True)
92
+ print(f"[setup] base_model={args.base_model}", flush=True)
93
+ print(f"[setup] adapter_repo={args.adapter_repo}", flush=True)
94
+ print(f"[setup] cuda available={torch.cuda.is_available()}", flush=True)
95
+
96
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
97
+ dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
98
+ quant_cfg = None
99
+ if torch.cuda.is_available():
100
+ quant_cfg = BitsAndBytesConfig(
101
+ load_in_4bit=True,
102
+ bnb_4bit_quant_type="nf4",
103
+ bnb_4bit_use_double_quant=True,
104
+ bnb_4bit_compute_dtype=dtype,
105
+ )
106
+
107
+ tokenizer = AutoTokenizer.from_pretrained(args.base_model, use_fast=True, token=token)
108
+ if tokenizer.pad_token is None:
109
+ tokenizer.pad_token = tokenizer.eos_token
110
+
111
+ base = AutoModelForCausalLM.from_pretrained(
112
+ args.base_model,
113
+ torch_dtype=dtype,
114
+ device_map="auto" if torch.cuda.is_available() else None,
115
+ low_cpu_mem_usage=True,
116
+ quantization_config=quant_cfg,
117
+ token=token,
118
+ )
119
+ base.config.pad_token_id = tokenizer.pad_token_id
120
+ base.config.use_cache = False
121
+ model = PeftModel.from_pretrained(base, args.adapter_repo, token=token)
122
+ model.eval()
123
+
124
+ tasks = _task_slice(args.slice)
125
+ if args.max_tasks is not None:
126
+ tasks = tasks[: args.max_tasks]
127
+
128
+ env = InvoiceGuardEnvironment()
129
+ rows = []
130
+ for i, task_id in enumerate(tasks, 1):
131
+ print(f"[eval] {i}/{len(tasks)} {task_id.value}", flush=True)
132
+ traj = rollout_episode(
133
+ model,
134
+ tokenizer,
135
+ env,
136
+ task_id,
137
+ temperature=0.0001,
138
+ top_p=1.0,
139
+ max_new_tokens=args.max_new_tokens,
140
+ max_prompt_tokens=args.max_prompt_tokens,
141
+ device=device,
142
+ )
143
+ rows.append({
144
+ "task_id": task_id.value,
145
+ "grader_score": traj.grader_score,
146
+ "cumulative_reward": traj.cumulative_reward,
147
+ "success": traj.success,
148
+ "n_steps": traj.n_steps,
149
+ "terminal_decision": traj.terminal_decision,
150
+ "actions": [step.completion_text for step in traj.steps],
151
+ "step_rewards": [step.reward for step in traj.steps],
152
+ })
153
+
154
+ summary = {
155
+ "run_finished_at": datetime.now(timezone.utc).isoformat(),
156
+ "base_model": args.base_model,
157
+ "adapter_repo": args.adapter_repo,
158
+ "slice": args.slice,
159
+ "n_tasks": len(rows),
160
+ "avg_grader_score": sum(r["grader_score"] for r in rows) / max(len(rows), 1),
161
+ "avg_cumulative_reward": sum(r["cumulative_reward"] for r in rows) / max(len(rows), 1),
162
+ "success_rate": sum(1.0 if r["success"] else 0.0 for r in rows) / max(len(rows), 1),
163
+ "avg_steps": sum(r["n_steps"] for r in rows) / max(len(rows), 1),
164
+ }
165
+
166
+ out_dir = Path(args.artifact_dir)
167
+ out_dir.mkdir(parents=True, exist_ok=True)
168
+ (out_dir / "adapter_eval_results.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
169
+ (out_dir / "adapter_eval_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8")
170
+ print(json.dumps(summary, indent=2), flush=True)
171
+
172
+ HfApi(token=token).upload_folder(
173
+ folder_path=str(out_dir),
174
+ repo_id=args.adapter_repo,
175
+ repo_type="model",
176
+ path_in_repo=f"eval_artifacts/{args.slice}",
177
+ token=token,
178
+ commit_message=f"Add InvoiceGuard adapter eval results ({args.slice})",
179
+ )
180
+ print(f"[push] eval artifacts uploaded to https://huggingface.co/{args.adapter_repo}", flush=True)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()
training/merge_adapter.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = [
5
+ # "torch>=2.2",
6
+ # "transformers>=4.46",
7
+ # "peft>=0.13",
8
+ # "accelerate>=1.0",
9
+ # "huggingface_hub>=0.26",
10
+ # "safetensors>=0.4",
11
+ # ]
12
+ # ///
13
+ """Merge a PEFT LoRA adapter into its base model and push the merged model."""
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import os
19
+ from typing import Optional
20
+
21
+ import torch
22
+ from huggingface_hub import create_repo
23
+ from peft import PeftModel
24
+ from transformers import AutoModelForCausalLM, AutoTokenizer
25
+
26
+
27
+ def _hf_token() -> Optional[str]:
28
+ return os.environ.get("HF_TOKEN") or os.environ.get("API_TOKEN_HF")
29
+
30
+
31
+ def main() -> None:
32
+ p = argparse.ArgumentParser()
33
+ p.add_argument("--base-model", default=os.environ.get("BASE_MODEL", "Qwen/Qwen3-4B-Instruct-2507"))
34
+ p.add_argument("--adapter-repo", required=True)
35
+ p.add_argument("--merged-repo", required=True)
36
+ args = p.parse_args()
37
+
38
+ token = _hf_token()
39
+ if not token:
40
+ raise RuntimeError("HF_TOKEN/API_TOKEN_HF is required to push merged model.")
41
+
42
+ print(f"[setup] base_model={args.base_model}", flush=True)
43
+ print(f"[setup] adapter_repo={args.adapter_repo}", flush=True)
44
+ print(f"[setup] merged_repo={args.merged_repo}", flush=True)
45
+ print(f"[setup] cuda available={torch.cuda.is_available()}", flush=True)
46
+
47
+ dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
48
+ base = AutoModelForCausalLM.from_pretrained(
49
+ args.base_model,
50
+ torch_dtype=dtype,
51
+ device_map="auto" if torch.cuda.is_available() else None,
52
+ low_cpu_mem_usage=True,
53
+ token=token,
54
+ )
55
+ tokenizer = AutoTokenizer.from_pretrained(args.base_model, use_fast=True, token=token)
56
+ if tokenizer.pad_token is None:
57
+ tokenizer.pad_token = tokenizer.eos_token
58
+ base.config.pad_token_id = tokenizer.pad_token_id
59
+
60
+ peft_model = PeftModel.from_pretrained(base, args.adapter_repo, token=token)
61
+ merged = peft_model.merge_and_unload()
62
+
63
+ create_repo(repo_id=args.merged_repo, repo_type="model", exist_ok=True, private=False, token=token)
64
+ print("[push] pushing merged model", flush=True)
65
+ merged.push_to_hub(
66
+ args.merged_repo,
67
+ private=False,
68
+ safe_serialization=True,
69
+ token=token,
70
+ commit_message="Save merged InvoiceGuard model",
71
+ )
72
+ tokenizer.push_to_hub(
73
+ args.merged_repo,
74
+ private=False,
75
+ token=token,
76
+ commit_message="Save merged InvoiceGuard tokenizer",
77
+ )
78
+ print(f"[push] done -> https://huggingface.co/{args.merged_repo}", flush=True)
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
training/train_sft.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # dependencies = [
5
+ # "torch>=2.2",
6
+ # "transformers>=4.46",
7
+ # "peft>=0.13",
8
+ # "accelerate>=1.0",
9
+ # "bitsandbytes>=0.43; platform_system != 'Darwin'",
10
+ # "huggingface_hub>=0.26",
11
+ # "trackio>=0.1.4",
12
+ # "openenv-core[core]>=0.2.1",
13
+ # "pydantic>=2.6",
14
+ # "pydantic-settings>=2.0",
15
+ # "fastapi>=0.115",
16
+ # "uvicorn>=0.30",
17
+ # "python-dotenv",
18
+ # "openai>=1.40",
19
+ # ]
20
+ # ///
21
+ """InvoiceGuard supervised trace fine-tuning backup run.
22
+
23
+ This is intentionally separate from GRPO. It trains a LoRA adapter on
24
+ environment-generated expert traces so we have a deterministic supervised
25
+ fallback artifact while online RL jobs are running.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import os
33
+ import random
34
+ import sys
35
+ import time
36
+ from dataclasses import dataclass
37
+ from datetime import datetime, timezone
38
+ from pathlib import Path
39
+ from typing import Optional
40
+
41
+
42
+ def _hf_token() -> Optional[str]:
43
+ return os.environ.get("HF_TOKEN") or os.environ.get("API_TOKEN_HF")
44
+
45
+
46
+ def _bootstrap_invoice_guard_path() -> Path:
47
+ code_dir = os.environ.get("INVOICEGUARD_CODE_DIR")
48
+ if code_dir and Path(code_dir).is_dir():
49
+ sys.path.insert(0, code_dir)
50
+ return Path(code_dir)
51
+
52
+ repo = os.environ.get("INVOICEGUARD_CODE_REPO")
53
+ if repo:
54
+ from huggingface_hub import snapshot_download
55
+
56
+ local = snapshot_download(repo_id=repo, repo_type="model", token=_hf_token())
57
+ sys.path.insert(0, local)
58
+ return Path(local)
59
+
60
+ here = Path(__file__).resolve().parent.parent
61
+ sys.path.insert(0, str(here))
62
+ return here
63
+
64
+
65
+ _CODE_ROOT = _bootstrap_invoice_guard_path()
66
+
67
+ import torch
68
+ import torch.nn.functional as F
69
+ from huggingface_hub import HfApi, create_repo
70
+ from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
71
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
72
+
73
+ from inference import SYSTEM_PROMPT, build_action, build_observation_prompt # type: ignore
74
+ from models import TaskID # type: ignore
75
+ from server.invoice_guard_environment import InvoiceGuardEnvironment # type: ignore
76
+ from tasks import HARD_TASK_LIST, TASK_LIST # type: ignore
77
+ from training.rollout import rollout_episode # type: ignore
78
+
79
+
80
+ @dataclass
81
+ class SftConfig:
82
+ base_model: str = os.environ.get("BASE_MODEL", "Qwen/Qwen3-4B-Instruct-2507")
83
+ hub_username: Optional[str] = os.environ.get("HF_USERNAME")
84
+ hub_model_id: str = os.environ.get("HUB_MODEL_ID", "invoiceguard-qwen3-4b-sft")
85
+ trackio_project: str = os.environ.get("TRACKIO_PROJECT", "invoiceguard-round2")
86
+ trackio_run_name: str = os.environ.get("TRACKIO_RUN_NAME", "qwen3-4b-sft")
87
+ artifact_dir: str = os.environ.get("ARTIFACT_DIR", "/tmp/invoiceguard-sft-artifacts")
88
+
89
+ seed: int = 42
90
+ num_epochs: int = 4
91
+ max_train_tasks: Optional[int] = None
92
+ eval_holdout_canonical: int = 3
93
+ eval_holdout_hard: int = 3
94
+ eval_every_epoch: bool = True
95
+
96
+ lr: float = 5e-5
97
+ grad_clip: float = 1.0
98
+ max_prompt_tokens: int = 2048
99
+ max_new_tokens: int = 96
100
+ bf16: bool = torch.cuda.is_available()
101
+ use_4bit: bool = True
102
+ gradient_checkpointing: bool = True
103
+
104
+ lora_r: int = 16
105
+ lora_alpha: int = 32
106
+ lora_dropout: float = 0.05
107
+ lora_target_modules: tuple = ("q_proj", "k_proj", "v_proj", "o_proj")
108
+
109
+ push_to_hub: bool = True
110
+
111
+
112
+ def split_tasks(cfg: SftConfig) -> tuple[list[TaskID], list[TaskID]]:
113
+ rng = random.Random(cfg.seed)
114
+ canonical = list(TASK_LIST)
115
+ hard = list(HARD_TASK_LIST)
116
+ rng.shuffle(canonical)
117
+ rng.shuffle(hard)
118
+ eval_tasks = (
119
+ canonical[: cfg.eval_holdout_canonical]
120
+ + hard[: cfg.eval_holdout_hard]
121
+ )
122
+ train_tasks = (
123
+ canonical[cfg.eval_holdout_canonical:]
124
+ + hard[cfg.eval_holdout_hard:]
125
+ )
126
+ if cfg.max_train_tasks is not None:
127
+ train_tasks = train_tasks[: cfg.max_train_tasks]
128
+ return train_tasks, eval_tasks
129
+
130
+
131
+ def _expert_actions(env: InvoiceGuardEnvironment, task_id: TaskID) -> list[dict]:
132
+ case = getattr(env, "_case", None)
133
+ if case is None:
134
+ env.reset(task_id=task_id.value)
135
+ case = getattr(env, "_case", None)
136
+ assert case is not None
137
+ gt = case.ground_truth
138
+ evidence = list(dict.fromkeys([
139
+ "inspect_purchase_order",
140
+ "inspect_goods_receipt_note",
141
+ "inspect_invoice_line_items",
142
+ "inspect_vendor_profile",
143
+ "compare_quantity",
144
+ "compare_price",
145
+ "compare_totals",
146
+ "check_for_duplicate_invoice",
147
+ "inspect_policy_rules",
148
+ *gt.acceptable_evidence,
149
+ ]))
150
+ return [
151
+ {"action_type": "inspect_purchase_order"},
152
+ {"action_type": "inspect_goods_receipt_note"},
153
+ {"action_type": "inspect_invoice_line_items"},
154
+ {"action_type": "inspect_vendor_profile"},
155
+ {"action_type": "compare_quantity"},
156
+ {"action_type": "compare_price"},
157
+ {"action_type": "compare_totals"},
158
+ {"action_type": "check_for_duplicate_invoice"},
159
+ {"action_type": "inspect_policy_rules"},
160
+ {
161
+ "action_type": "submit_final_resolution",
162
+ "final_decision": gt.correct_decision.value,
163
+ "exception_type": gt.correct_exception_type.value,
164
+ "evidence_references": evidence,
165
+ "explanation": "Key findings: " + "; ".join(gt.key_findings[:3]),
166
+ "confidence": 0.9,
167
+ },
168
+ ]
169
+
170
+
171
+ def build_sft_examples(
172
+ tokenizer,
173
+ env: InvoiceGuardEnvironment,
174
+ tasks: list[TaskID],
175
+ max_prompt_tokens: int,
176
+ ) -> list[dict]:
177
+ examples: list[dict] = []
178
+ for task_id in tasks:
179
+ obs = env.reset(task_id=task_id.value)
180
+ messages: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
181
+ for action_dict in _expert_actions(env, task_id):
182
+ user_msg = build_observation_prompt(obs, is_first=(len(messages) == 1))
183
+ messages.append({"role": "user", "content": user_msg})
184
+ prompt_text = tokenizer.apply_chat_template(
185
+ messages,
186
+ tokenize=False,
187
+ add_generation_prompt=True,
188
+ )
189
+ completion_text = json.dumps(action_dict, ensure_ascii=False)
190
+ prompt_ids = tokenizer(
191
+ prompt_text,
192
+ return_tensors="pt",
193
+ add_special_tokens=False,
194
+ truncation=True,
195
+ max_length=max_prompt_tokens,
196
+ ).input_ids[0]
197
+ completion_ids = tokenizer(
198
+ completion_text,
199
+ return_tensors="pt",
200
+ add_special_tokens=False,
201
+ ).input_ids[0]
202
+ examples.append({
203
+ "task_id": task_id.value,
204
+ "action_type": action_dict["action_type"],
205
+ "prompt_ids": prompt_ids,
206
+ "completion_ids": completion_ids,
207
+ "completion_text": completion_text,
208
+ })
209
+ messages.append({"role": "assistant", "content": completion_text})
210
+ obs = env.step(build_action(action_dict))
211
+ if obs.done:
212
+ break
213
+ return examples
214
+
215
+
216
+ def completion_loss(model, prompt_ids: torch.Tensor, completion_ids: torch.Tensor, device: torch.device) -> torch.Tensor:
217
+ input_ids = torch.cat([prompt_ids, completion_ids], dim=0).unsqueeze(0).to(device)
218
+ attention_mask = torch.ones_like(input_ids)
219
+ out = model(input_ids=input_ids, attention_mask=attention_mask, use_cache=False)
220
+ logits = out.logits[0, :-1, :]
221
+ targets = input_ids[0, 1:]
222
+ logprobs = F.log_softmax(logits.float(), dim=-1)
223
+ token_lp = logprobs.gather(-1, targets.unsqueeze(-1)).squeeze(-1)
224
+ comp_len = completion_ids.shape[0]
225
+ return -token_lp[-comp_len:].mean()
226
+
227
+
228
+ def main() -> None:
229
+ cfg = _parse_args()
230
+ token = _hf_token()
231
+ if cfg.push_to_hub and (not token or not cfg.hub_username):
232
+ raise RuntimeError("HF_TOKEN and HF_USERNAME are required when pushing SFT output.")
233
+
234
+ print(f"[setup] code_root={_CODE_ROOT}", flush=True)
235
+ print(f"[setup] base_model={cfg.base_model}", flush=True)
236
+ print(f"[setup] cuda available={torch.cuda.is_available()}", flush=True)
237
+
238
+ random.seed(cfg.seed)
239
+ torch.manual_seed(cfg.seed)
240
+ if torch.cuda.is_available():
241
+ torch.cuda.manual_seed_all(cfg.seed)
242
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
243
+ dtype = torch.bfloat16 if cfg.bf16 else torch.float32
244
+
245
+ artifact_dir = Path(cfg.artifact_dir)
246
+ artifact_dir.mkdir(parents=True, exist_ok=True)
247
+ metrics_path = artifact_dir / "sft_metrics.jsonl"
248
+ summary_path = artifact_dir / "sft_summary.json"
249
+
250
+ tokenizer = AutoTokenizer.from_pretrained(cfg.base_model, use_fast=True, token=token)
251
+ if tokenizer.pad_token is None:
252
+ tokenizer.pad_token = tokenizer.eos_token
253
+
254
+ quant_cfg = None
255
+ if cfg.use_4bit and torch.cuda.is_available():
256
+ quant_cfg = BitsAndBytesConfig(
257
+ load_in_4bit=True,
258
+ bnb_4bit_quant_type="nf4",
259
+ bnb_4bit_use_double_quant=True,
260
+ bnb_4bit_compute_dtype=dtype,
261
+ )
262
+ base = AutoModelForCausalLM.from_pretrained(
263
+ cfg.base_model,
264
+ torch_dtype=dtype,
265
+ device_map="auto" if torch.cuda.is_available() else None,
266
+ low_cpu_mem_usage=True,
267
+ quantization_config=quant_cfg,
268
+ token=token,
269
+ )
270
+ base.config.pad_token_id = tokenizer.pad_token_id
271
+ base.config.use_cache = False
272
+ if cfg.gradient_checkpointing:
273
+ base = prepare_model_for_kbit_training(base, use_gradient_checkpointing=True)
274
+ base.gradient_checkpointing_enable()
275
+
276
+ lora_cfg = LoraConfig(
277
+ r=cfg.lora_r,
278
+ lora_alpha=cfg.lora_alpha,
279
+ lora_dropout=cfg.lora_dropout,
280
+ target_modules=list(cfg.lora_target_modules),
281
+ bias="none",
282
+ task_type="CAUSAL_LM",
283
+ )
284
+ model = get_peft_model(base, lora_cfg)
285
+ model.print_trainable_parameters()
286
+
287
+ optimizer = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=cfg.lr)
288
+ env = InvoiceGuardEnvironment()
289
+ train_tasks, eval_tasks = split_tasks(cfg)
290
+ examples = build_sft_examples(tokenizer, env, train_tasks, cfg.max_prompt_tokens)
291
+ print(f"[setup] train_tasks={len(train_tasks)} eval_tasks={len(eval_tasks)} examples={len(examples)}", flush=True)
292
+
293
+ tracker = None
294
+ try:
295
+ import trackio
296
+ tracker = trackio.init(
297
+ project=cfg.trackio_project,
298
+ name=cfg.trackio_run_name,
299
+ config={
300
+ "base_model": cfg.base_model,
301
+ "hub_model_id": cfg.hub_model_id,
302
+ "num_epochs": cfg.num_epochs,
303
+ "n_train_tasks": len(train_tasks),
304
+ "n_eval_tasks": len(eval_tasks),
305
+ "n_examples": len(examples),
306
+ "lr": cfg.lr,
307
+ "lora_r": cfg.lora_r,
308
+ },
309
+ )
310
+ print("[setup] trackio initialised", flush=True)
311
+ except Exception as e:
312
+ print(f"[setup] trackio disabled: {e}", flush=True)
313
+
314
+ def log(row: dict) -> None:
315
+ with metrics_path.open("a", encoding="utf-8") as f:
316
+ f.write(json.dumps({"time": datetime.now(timezone.utc).isoformat(), **row}) + "\n")
317
+ print(" | ".join(f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}" for k, v in row.items()), flush=True)
318
+ if tracker is not None:
319
+ try:
320
+ trackio.log(row, step=int(row.get("step", 0)))
321
+ except Exception:
322
+ pass
323
+
324
+ def evaluate(epoch: int) -> dict:
325
+ model.eval()
326
+ scores, rewards, successes, steps = [], [], [], []
327
+ for task_id in eval_tasks:
328
+ traj = rollout_episode(
329
+ model,
330
+ tokenizer,
331
+ env,
332
+ task_id,
333
+ temperature=0.0001,
334
+ top_p=1.0,
335
+ max_new_tokens=cfg.max_new_tokens,
336
+ max_prompt_tokens=cfg.max_prompt_tokens,
337
+ device=device,
338
+ )
339
+ scores.append(traj.grader_score)
340
+ rewards.append(traj.cumulative_reward)
341
+ successes.append(1.0 if traj.success else 0.0)
342
+ steps.append(traj.n_steps)
343
+ model.train()
344
+ return {
345
+ "step": epoch,
346
+ "eval/avg_grader_score": sum(scores) / max(len(scores), 1),
347
+ "eval/avg_cum_reward": sum(rewards) / max(len(rewards), 1),
348
+ "eval/success_rate": sum(successes) / max(len(successes), 1),
349
+ "eval/avg_steps": sum(steps) / max(len(steps), 1),
350
+ }
351
+
352
+ t_start = time.time()
353
+ global_step = 0
354
+ for epoch in range(cfg.num_epochs):
355
+ random.shuffle(examples)
356
+ total_loss = 0.0
357
+ model.train()
358
+ for i, ex in enumerate(examples, 1):
359
+ loss = completion_loss(model, ex["prompt_ids"], ex["completion_ids"], device)
360
+ loss.backward()
361
+ total_loss += float(loss.detach().item())
362
+ torch.nn.utils.clip_grad_norm_([p for p in model.parameters() if p.requires_grad], cfg.grad_clip)
363
+ optimizer.step()
364
+ optimizer.zero_grad(set_to_none=True)
365
+ global_step += 1
366
+ if i % 25 == 0:
367
+ log({"step": global_step, "train/epoch": epoch + 1, "train/example": i, "train/loss": total_loss / i})
368
+ log({"step": global_step, "train/epoch": epoch + 1, "train/loss": total_loss / max(len(examples), 1)})
369
+ if cfg.eval_every_epoch:
370
+ log(evaluate(epoch + 1))
371
+
372
+ summary = {
373
+ "run_finished_at": datetime.now(timezone.utc).isoformat(),
374
+ "base_model": cfg.base_model,
375
+ "hub_model_id": cfg.hub_model_id,
376
+ "num_epochs": cfg.num_epochs,
377
+ "train_tasks": [t.value for t in train_tasks],
378
+ "eval_tasks": [t.value for t in eval_tasks],
379
+ "n_examples": len(examples),
380
+ "wall_clock_s": round(time.time() - t_start, 2),
381
+ }
382
+ summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
383
+
384
+ if cfg.push_to_hub and cfg.hub_username:
385
+ assert token is not None
386
+ repo_id = f"{cfg.hub_username}/{cfg.hub_model_id}"
387
+ create_repo(repo_id=repo_id, repo_type="model", exist_ok=True, private=False, token=token)
388
+ print(f"[push] pushing SFT adapter to {repo_id}", flush=True)
389
+ model.push_to_hub(repo_id, private=False, token=token, commit_message="Save InvoiceGuard SFT adapter")
390
+ tokenizer.push_to_hub(repo_id, private=False, token=token, commit_message="Save InvoiceGuard SFT tokenizer")
391
+ HfApi(token=token).upload_folder(
392
+ folder_path=str(artifact_dir),
393
+ repo_id=repo_id,
394
+ repo_type="model",
395
+ path_in_repo="sft_artifacts",
396
+ token=token,
397
+ commit_message="Add InvoiceGuard SFT artifacts",
398
+ )
399
+ print(f"[push] done -> https://huggingface.co/{repo_id}", flush=True)
400
+
401
+
402
+ def _parse_args() -> SftConfig:
403
+ p = argparse.ArgumentParser()
404
+ p.add_argument("--model-name", dest="base_model", default=None)
405
+ p.add_argument("--hub-model-id", default=None)
406
+ p.add_argument("--num-epochs", type=int, default=None)
407
+ p.add_argument("--max-train-tasks", type=int, default=None)
408
+ p.add_argument("--eval-holdout-canonical", type=int, default=None)
409
+ p.add_argument("--eval-holdout-hard", type=int, default=None)
410
+ p.add_argument("--lr", type=float, default=None)
411
+ p.add_argument("--max-new-tokens", type=int, default=None)
412
+ p.add_argument("--max-prompt-tokens", type=int, default=None)
413
+ p.add_argument("--no-push", action="store_true")
414
+ args = p.parse_args()
415
+
416
+ cfg = SftConfig()
417
+ if args.base_model:
418
+ cfg.base_model = args.base_model
419
+ if args.hub_model_id:
420
+ cfg.hub_model_id = args.hub_model_id
421
+ if args.num_epochs is not None:
422
+ cfg.num_epochs = args.num_epochs
423
+ if args.max_train_tasks is not None:
424
+ cfg.max_train_tasks = args.max_train_tasks
425
+ if args.eval_holdout_canonical is not None:
426
+ cfg.eval_holdout_canonical = args.eval_holdout_canonical
427
+ if args.eval_holdout_hard is not None:
428
+ cfg.eval_holdout_hard = args.eval_holdout_hard
429
+ if args.lr is not None:
430
+ cfg.lr = args.lr
431
+ if args.max_new_tokens is not None:
432
+ cfg.max_new_tokens = args.max_new_tokens
433
+ if args.max_prompt_tokens is not None:
434
+ cfg.max_prompt_tokens = args.max_prompt_tokens
435
+ if args.no_push:
436
+ cfg.push_to_hub = False
437
+ return cfg
438
+
439
+
440
+ if __name__ == "__main__":
441
+ main()