piyush-mk commited on
Commit
4cd2128
·
verified ·
1 Parent(s): 231d76f

Upload folder using huggingface_hub

Browse files
.env.example ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -- InvoiceGuard Environment Variables --------------------------
2
+ #
3
+ # Copy this file to .env and fill in your values:
4
+ # cp .env.example .env
5
+ #
6
+ # These are read by inference.py via python-dotenv.
7
+ # Never commit .env to git (it's already in .gitignore).
8
+ # ----------------------------------------------------------------
9
+
10
+ # 1. API_BASE_URL -- The LLM API endpoint.
11
+ # OpenAI: https://api.openai.com/v1
12
+ # HF Inference: https://router.huggingface.co/v1
13
+ # Local/custom: http://localhost:8080/v1
14
+ API_BASE_URL=https://api.openai.com/v1
15
+
16
+ # 2. MODEL_NAME -- The model identifier for inference.
17
+ # OpenAI: gpt-4.1-mini, gpt-5.4-mini, etc.
18
+ # HF: Qwen/Qwen2.5-72B-Instruct, meta-llama/Llama-3.1-8B-Instruct, etc.
19
+ MODEL_NAME=gpt-4.1-mini
20
+
21
+ # 3. HF_TOKEN -- Your Hugging Face / API key.
22
+ # This is the PRIMARY key used by the inference script for LLM calls.
23
+ # For OpenAI models: put your OpenAI key here (sk-...)
24
+ # For HF models: put your HF token here (hf_...)
25
+ # Get HF token at: https://huggingface.co/settings/tokens
26
+ HF_TOKEN=
27
+
28
+ # 5. LOCAL_IMAGE_NAME -- Docker image name for containerized mode.
29
+ # Only needed if running inference against a Docker container
30
+ # instead of the local environment directly.
31
+ # Build first: docker build -t invoiceguard .
32
+ # LOCAL_IMAGE_NAME=invoiceguard
inference.py CHANGED
@@ -168,6 +168,39 @@ def strip_think_blocks(text: str) -> str:
168
  return _THINK_RE.sub("", text).strip()
169
 
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  def parse_llm_response(response_text: str) -> dict:
172
  """Extract a JSON object from the LLM response."""
173
  text = strip_think_blocks(response_text).strip()
@@ -190,6 +223,10 @@ def parse_llm_response(response_text: str) -> dict:
190
  except json.JSONDecodeError:
191
  continue
192
 
 
 
 
 
193
  return {"action_type": "summarize_findings"}
194
 
195
 
 
168
  return _THINK_RE.sub("", text).strip()
169
 
170
 
171
+ def _extract_first_json_object(text: str) -> dict | None:
172
+ """Find the first balanced {...} in text and parse it."""
173
+ start = text.find("{")
174
+ if start == -1:
175
+ return None
176
+ depth = 0
177
+ in_str = False
178
+ escape = False
179
+ for i in range(start, len(text)):
180
+ c = text[i]
181
+ if escape:
182
+ escape = False
183
+ continue
184
+ if c == "\\":
185
+ escape = True
186
+ continue
187
+ if c == '"':
188
+ in_str = not in_str
189
+ continue
190
+ if in_str:
191
+ continue
192
+ if c == "{":
193
+ depth += 1
194
+ elif c == "}":
195
+ depth -= 1
196
+ if depth == 0:
197
+ try:
198
+ return json.loads(text[start : i + 1])
199
+ except json.JSONDecodeError:
200
+ return None
201
+ return None
202
+
203
+
204
  def parse_llm_response(response_text: str) -> dict:
205
  """Extract a JSON object from the LLM response."""
206
  text = strip_think_blocks(response_text).strip()
 
223
  except json.JSONDecodeError:
224
  continue
225
 
226
+ obj = _extract_first_json_object(text)
227
+ if obj is not None:
228
+ return obj
229
+
230
  return {"action_type": "summarize_findings"}
231
 
232
 
training/merge_adapter.py CHANGED
@@ -1,82 +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()
 
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/rollout.py CHANGED
@@ -133,6 +133,12 @@ def rollout_episode(
133
  if torch.cuda.is_available():
134
  torch.cuda.empty_cache()
135
 
 
 
 
 
 
 
136
  messages.append({"role": "assistant", "content": completion_text})
137
 
138
  params = parse_llm_response(completion_text)
 
133
  if torch.cuda.is_available():
134
  torch.cuda.empty_cache()
135
 
136
+ if traj.n_steps < 2:
137
+ print(f"[rollout-diag] task={task_id.value} step={traj.n_steps} "
138
+ f"gen_tokens={len(completion_ids)} "
139
+ f"raw_text={repr(raw_text[:300])} "
140
+ f"completion_text={repr(completion_text[:200])}", flush=True)
141
+
142
  messages.append({"role": "assistant", "content": completion_text})
143
 
144
  params = parse_llm_response(completion_text)
training/train_grpo.py CHANGED
@@ -355,11 +355,16 @@ def run_format_warmup(
355
  truncation=True,
356
  max_length=cfg.max_prompt_tokens,
357
  ).input_ids[0]
358
- completion_ids = tokenizer(
359
  completion_text,
360
  return_tensors="pt",
361
  add_special_tokens=False,
362
  ).input_ids[0]
 
 
 
 
 
363
  lp = _completion_logprobs(policy, prompt_ids, completion_ids, device)
364
  loss = -lp / max(int(completion_ids.shape[0]), 1)
365
  loss.backward()
 
355
  truncation=True,
356
  max_length=cfg.max_prompt_tokens,
357
  ).input_ids[0]
358
+ comp_enc = tokenizer(
359
  completion_text,
360
  return_tensors="pt",
361
  add_special_tokens=False,
362
  ).input_ids[0]
363
+ eos_id = tokenizer.convert_tokens_to_ids("<|im_end|>")
364
+ if eos_id is not None and eos_id != tokenizer.unk_token_id:
365
+ completion_ids = torch.cat([comp_enc, torch.tensor([eos_id])])
366
+ else:
367
+ completion_ids = comp_enc
368
  lp = _completion_logprobs(policy, prompt_ids, completion_ids, device)
369
  loss = -lp / max(int(completion_ids.shape[0]), 1)
370
  loss.backward()
training/train_sft.py CHANGED
@@ -1,445 +1,450 @@
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 = 384
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
- try:
185
- prompt_text = tokenizer.apply_chat_template(
186
- messages, tokenize=False, add_generation_prompt=True,
187
- enable_thinking=False,
188
- )
189
- except TypeError:
190
- prompt_text = tokenizer.apply_chat_template(
191
- messages, tokenize=False, add_generation_prompt=True,
192
- )
193
- completion_text = json.dumps(action_dict, ensure_ascii=False)
194
- prompt_ids = tokenizer(
195
- prompt_text,
196
- return_tensors="pt",
197
- add_special_tokens=False,
198
- truncation=True,
199
- max_length=max_prompt_tokens,
200
- ).input_ids[0]
201
- completion_ids = tokenizer(
202
- completion_text,
203
- return_tensors="pt",
204
- add_special_tokens=False,
205
- ).input_ids[0]
206
- examples.append({
207
- "task_id": task_id.value,
208
- "action_type": action_dict["action_type"],
209
- "prompt_ids": prompt_ids,
210
- "completion_ids": completion_ids,
211
- "completion_text": completion_text,
212
- })
213
- messages.append({"role": "assistant", "content": completion_text})
214
- obs = env.step(build_action(action_dict))
215
- if obs.done:
216
- break
217
- return examples
218
-
219
-
220
- def completion_loss(model, prompt_ids: torch.Tensor, completion_ids: torch.Tensor, device: torch.device) -> torch.Tensor:
221
- input_ids = torch.cat([prompt_ids, completion_ids], dim=0).unsqueeze(0).to(device)
222
- attention_mask = torch.ones_like(input_ids)
223
- out = model(input_ids=input_ids, attention_mask=attention_mask, use_cache=False)
224
- logits = out.logits[0, :-1, :]
225
- targets = input_ids[0, 1:]
226
- logprobs = F.log_softmax(logits.float(), dim=-1)
227
- token_lp = logprobs.gather(-1, targets.unsqueeze(-1)).squeeze(-1)
228
- comp_len = completion_ids.shape[0]
229
- return -token_lp[-comp_len:].mean()
230
-
231
-
232
- def main() -> None:
233
- cfg = _parse_args()
234
- token = _hf_token()
235
- if cfg.push_to_hub and (not token or not cfg.hub_username):
236
- raise RuntimeError("HF_TOKEN and HF_USERNAME are required when pushing SFT output.")
237
-
238
- print(f"[setup] code_root={_CODE_ROOT}", flush=True)
239
- print(f"[setup] base_model={cfg.base_model}", flush=True)
240
- print(f"[setup] cuda available={torch.cuda.is_available()}", flush=True)
241
-
242
- random.seed(cfg.seed)
243
- torch.manual_seed(cfg.seed)
244
- if torch.cuda.is_available():
245
- torch.cuda.manual_seed_all(cfg.seed)
246
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
247
- dtype = torch.bfloat16 if cfg.bf16 else torch.float32
248
-
249
- artifact_dir = Path(cfg.artifact_dir)
250
- artifact_dir.mkdir(parents=True, exist_ok=True)
251
- metrics_path = artifact_dir / "sft_metrics.jsonl"
252
- summary_path = artifact_dir / "sft_summary.json"
253
-
254
- tokenizer = AutoTokenizer.from_pretrained(cfg.base_model, use_fast=True, token=token)
255
- if tokenizer.pad_token is None:
256
- tokenizer.pad_token = tokenizer.eos_token
257
-
258
- quant_cfg = None
259
- if cfg.use_4bit and torch.cuda.is_available():
260
- quant_cfg = BitsAndBytesConfig(
261
- load_in_4bit=True,
262
- bnb_4bit_quant_type="nf4",
263
- bnb_4bit_use_double_quant=True,
264
- bnb_4bit_compute_dtype=dtype,
265
- )
266
- base = AutoModelForCausalLM.from_pretrained(
267
- cfg.base_model,
268
- torch_dtype=dtype,
269
- device_map="auto" if torch.cuda.is_available() else None,
270
- low_cpu_mem_usage=True,
271
- quantization_config=quant_cfg,
272
- token=token,
273
- )
274
- base.config.pad_token_id = tokenizer.pad_token_id
275
- base.config.use_cache = False
276
- if cfg.gradient_checkpointing:
277
- base = prepare_model_for_kbit_training(base, use_gradient_checkpointing=True)
278
- base.gradient_checkpointing_enable()
279
-
280
- lora_cfg = LoraConfig(
281
- r=cfg.lora_r,
282
- lora_alpha=cfg.lora_alpha,
283
- lora_dropout=cfg.lora_dropout,
284
- target_modules=list(cfg.lora_target_modules),
285
- bias="none",
286
- task_type="CAUSAL_LM",
287
- )
288
- model = get_peft_model(base, lora_cfg)
289
- model.print_trainable_parameters()
290
-
291
- optimizer = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=cfg.lr)
292
- env = InvoiceGuardEnvironment()
293
- train_tasks, eval_tasks = split_tasks(cfg)
294
- examples = build_sft_examples(tokenizer, env, train_tasks, cfg.max_prompt_tokens)
295
- print(f"[setup] train_tasks={len(train_tasks)} eval_tasks={len(eval_tasks)} examples={len(examples)}", flush=True)
296
-
297
- tracker = None
298
- try:
299
- import trackio
300
- tracker = trackio.init(
301
- project=cfg.trackio_project,
302
- name=cfg.trackio_run_name,
303
- config={
304
- "base_model": cfg.base_model,
305
- "hub_model_id": cfg.hub_model_id,
306
- "num_epochs": cfg.num_epochs,
307
- "n_train_tasks": len(train_tasks),
308
- "n_eval_tasks": len(eval_tasks),
309
- "n_examples": len(examples),
310
- "lr": cfg.lr,
311
- "lora_r": cfg.lora_r,
312
- },
313
- )
314
- print("[setup] trackio initialised", flush=True)
315
- except Exception as e:
316
- print(f"[setup] trackio disabled: {e}", flush=True)
317
-
318
- def log(row: dict) -> None:
319
- with metrics_path.open("a", encoding="utf-8") as f:
320
- f.write(json.dumps({"time": datetime.now(timezone.utc).isoformat(), **row}) + "\n")
321
- print(" | ".join(f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}" for k, v in row.items()), flush=True)
322
- if tracker is not None:
323
- try:
324
- trackio.log(row, step=int(row.get("step", 0)))
325
- except Exception:
326
- pass
327
-
328
- def evaluate(epoch: int) -> dict:
329
- model.eval()
330
- scores, rewards, successes, steps = [], [], [], []
331
- for task_id in eval_tasks:
332
- traj = rollout_episode(
333
- model,
334
- tokenizer,
335
- env,
336
- task_id,
337
- temperature=0.0001,
338
- top_p=1.0,
339
- max_new_tokens=cfg.max_new_tokens,
340
- max_prompt_tokens=cfg.max_prompt_tokens,
341
- device=device,
342
- )
343
- scores.append(traj.grader_score)
344
- rewards.append(traj.cumulative_reward)
345
- successes.append(1.0 if traj.success else 0.0)
346
- steps.append(traj.n_steps)
347
- model.train()
348
- return {
349
- "step": epoch,
350
- "eval/avg_grader_score": sum(scores) / max(len(scores), 1),
351
- "eval/avg_cum_reward": sum(rewards) / max(len(rewards), 1),
352
- "eval/success_rate": sum(successes) / max(len(successes), 1),
353
- "eval/avg_steps": sum(steps) / max(len(steps), 1),
354
- }
355
-
356
- t_start = time.time()
357
- global_step = 0
358
- for epoch in range(cfg.num_epochs):
359
- random.shuffle(examples)
360
- total_loss = 0.0
361
- model.train()
362
- for i, ex in enumerate(examples, 1):
363
- loss = completion_loss(model, ex["prompt_ids"], ex["completion_ids"], device)
364
- loss.backward()
365
- total_loss += float(loss.detach().item())
366
- torch.nn.utils.clip_grad_norm_([p for p in model.parameters() if p.requires_grad], cfg.grad_clip)
367
- optimizer.step()
368
- optimizer.zero_grad(set_to_none=True)
369
- global_step += 1
370
- if i % 25 == 0:
371
- log({"step": global_step, "train/epoch": epoch + 1, "train/example": i, "train/loss": total_loss / i})
372
- log({"step": global_step, "train/epoch": epoch + 1, "train/loss": total_loss / max(len(examples), 1)})
373
- if cfg.eval_every_epoch:
374
- log(evaluate(epoch + 1))
375
-
376
- summary = {
377
- "run_finished_at": datetime.now(timezone.utc).isoformat(),
378
- "base_model": cfg.base_model,
379
- "hub_model_id": cfg.hub_model_id,
380
- "num_epochs": cfg.num_epochs,
381
- "train_tasks": [t.value for t in train_tasks],
382
- "eval_tasks": [t.value for t in eval_tasks],
383
- "n_examples": len(examples),
384
- "wall_clock_s": round(time.time() - t_start, 2),
385
- }
386
- summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
387
-
388
- if cfg.push_to_hub and cfg.hub_username:
389
- assert token is not None
390
- repo_id = f"{cfg.hub_username}/{cfg.hub_model_id}"
391
- create_repo(repo_id=repo_id, repo_type="model", exist_ok=True, private=False, token=token)
392
- print(f"[push] pushing SFT adapter to {repo_id}", flush=True)
393
- model.push_to_hub(repo_id, private=False, token=token, commit_message="Save InvoiceGuard SFT adapter")
394
- tokenizer.push_to_hub(repo_id, private=False, token=token, commit_message="Save InvoiceGuard SFT tokenizer")
395
- HfApi(token=token).upload_folder(
396
- folder_path=str(artifact_dir),
397
- repo_id=repo_id,
398
- repo_type="model",
399
- path_in_repo="sft_artifacts",
400
- token=token,
401
- commit_message="Add InvoiceGuard SFT artifacts",
402
- )
403
- print(f"[push] done -> https://huggingface.co/{repo_id}", flush=True)
404
-
405
-
406
- def _parse_args() -> SftConfig:
407
- p = argparse.ArgumentParser()
408
- p.add_argument("--model-name", dest="base_model", default=None)
409
- p.add_argument("--hub-model-id", default=None)
410
- p.add_argument("--num-epochs", type=int, default=None)
411
- p.add_argument("--max-train-tasks", type=int, default=None)
412
- p.add_argument("--eval-holdout-canonical", type=int, default=None)
413
- p.add_argument("--eval-holdout-hard", type=int, default=None)
414
- p.add_argument("--lr", type=float, default=None)
415
- p.add_argument("--max-new-tokens", type=int, default=None)
416
- p.add_argument("--max-prompt-tokens", type=int, default=None)
417
- p.add_argument("--no-push", action="store_true")
418
- args = p.parse_args()
419
-
420
- cfg = SftConfig()
421
- if args.base_model:
422
- cfg.base_model = args.base_model
423
- if args.hub_model_id:
424
- cfg.hub_model_id = args.hub_model_id
425
- if args.num_epochs is not None:
426
- cfg.num_epochs = args.num_epochs
427
- if args.max_train_tasks is not None:
428
- cfg.max_train_tasks = args.max_train_tasks
429
- if args.eval_holdout_canonical is not None:
430
- cfg.eval_holdout_canonical = args.eval_holdout_canonical
431
- if args.eval_holdout_hard is not None:
432
- cfg.eval_holdout_hard = args.eval_holdout_hard
433
- if args.lr is not None:
434
- cfg.lr = args.lr
435
- if args.max_new_tokens is not None:
436
- cfg.max_new_tokens = args.max_new_tokens
437
- if args.max_prompt_tokens is not None:
438
- cfg.max_prompt_tokens = args.max_prompt_tokens
439
- if args.no_push:
440
- cfg.push_to_hub = False
441
- return cfg
442
-
443
-
444
- if __name__ == "__main__":
445
- 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
+ # "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 = 384
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
+ try:
185
+ prompt_text = tokenizer.apply_chat_template(
186
+ messages, tokenize=False, add_generation_prompt=True,
187
+ enable_thinking=False,
188
+ )
189
+ except TypeError:
190
+ prompt_text = tokenizer.apply_chat_template(
191
+ messages, tokenize=False, add_generation_prompt=True,
192
+ )
193
+ completion_text = json.dumps(action_dict, ensure_ascii=False)
194
+ prompt_ids = tokenizer(
195
+ prompt_text,
196
+ return_tensors="pt",
197
+ add_special_tokens=False,
198
+ truncation=True,
199
+ max_length=max_prompt_tokens,
200
+ ).input_ids[0]
201
+ comp_enc = tokenizer(
202
+ completion_text,
203
+ return_tensors="pt",
204
+ add_special_tokens=False,
205
+ ).input_ids[0]
206
+ eos_id = tokenizer.convert_tokens_to_ids("<|im_end|>")
207
+ if eos_id is not None and eos_id != tokenizer.unk_token_id:
208
+ completion_ids = torch.cat([comp_enc, torch.tensor([eos_id])])
209
+ else:
210
+ completion_ids = comp_enc
211
+ examples.append({
212
+ "task_id": task_id.value,
213
+ "action_type": action_dict["action_type"],
214
+ "prompt_ids": prompt_ids,
215
+ "completion_ids": completion_ids,
216
+ "completion_text": completion_text,
217
+ })
218
+ messages.append({"role": "assistant", "content": completion_text})
219
+ obs = env.step(build_action(action_dict))
220
+ if obs.done:
221
+ break
222
+ return examples
223
+
224
+
225
+ def completion_loss(model, prompt_ids: torch.Tensor, completion_ids: torch.Tensor, device: torch.device) -> torch.Tensor:
226
+ input_ids = torch.cat([prompt_ids, completion_ids], dim=0).unsqueeze(0).to(device)
227
+ attention_mask = torch.ones_like(input_ids)
228
+ out = model(input_ids=input_ids, attention_mask=attention_mask, use_cache=False)
229
+ logits = out.logits[0, :-1, :]
230
+ targets = input_ids[0, 1:]
231
+ logprobs = F.log_softmax(logits.float(), dim=-1)
232
+ token_lp = logprobs.gather(-1, targets.unsqueeze(-1)).squeeze(-1)
233
+ comp_len = completion_ids.shape[0]
234
+ return -token_lp[-comp_len:].mean()
235
+
236
+
237
+ def main() -> None:
238
+ cfg = _parse_args()
239
+ token = _hf_token()
240
+ if cfg.push_to_hub and (not token or not cfg.hub_username):
241
+ raise RuntimeError("HF_TOKEN and HF_USERNAME are required when pushing SFT output.")
242
+
243
+ print(f"[setup] code_root={_CODE_ROOT}", flush=True)
244
+ print(f"[setup] base_model={cfg.base_model}", flush=True)
245
+ print(f"[setup] cuda available={torch.cuda.is_available()}", flush=True)
246
+
247
+ random.seed(cfg.seed)
248
+ torch.manual_seed(cfg.seed)
249
+ if torch.cuda.is_available():
250
+ torch.cuda.manual_seed_all(cfg.seed)
251
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
252
+ dtype = torch.bfloat16 if cfg.bf16 else torch.float32
253
+
254
+ artifact_dir = Path(cfg.artifact_dir)
255
+ artifact_dir.mkdir(parents=True, exist_ok=True)
256
+ metrics_path = artifact_dir / "sft_metrics.jsonl"
257
+ summary_path = artifact_dir / "sft_summary.json"
258
+
259
+ tokenizer = AutoTokenizer.from_pretrained(cfg.base_model, use_fast=True, token=token)
260
+ if tokenizer.pad_token is None:
261
+ tokenizer.pad_token = tokenizer.eos_token
262
+
263
+ quant_cfg = None
264
+ if cfg.use_4bit and torch.cuda.is_available():
265
+ quant_cfg = BitsAndBytesConfig(
266
+ load_in_4bit=True,
267
+ bnb_4bit_quant_type="nf4",
268
+ bnb_4bit_use_double_quant=True,
269
+ bnb_4bit_compute_dtype=dtype,
270
+ )
271
+ base = AutoModelForCausalLM.from_pretrained(
272
+ cfg.base_model,
273
+ torch_dtype=dtype,
274
+ device_map="auto" if torch.cuda.is_available() else None,
275
+ low_cpu_mem_usage=True,
276
+ quantization_config=quant_cfg,
277
+ token=token,
278
+ )
279
+ base.config.pad_token_id = tokenizer.pad_token_id
280
+ base.config.use_cache = False
281
+ if cfg.gradient_checkpointing:
282
+ base = prepare_model_for_kbit_training(base, use_gradient_checkpointing=True)
283
+ base.gradient_checkpointing_enable()
284
+
285
+ lora_cfg = LoraConfig(
286
+ r=cfg.lora_r,
287
+ lora_alpha=cfg.lora_alpha,
288
+ lora_dropout=cfg.lora_dropout,
289
+ target_modules=list(cfg.lora_target_modules),
290
+ bias="none",
291
+ task_type="CAUSAL_LM",
292
+ )
293
+ model = get_peft_model(base, lora_cfg)
294
+ model.print_trainable_parameters()
295
+
296
+ optimizer = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=cfg.lr)
297
+ env = InvoiceGuardEnvironment()
298
+ train_tasks, eval_tasks = split_tasks(cfg)
299
+ examples = build_sft_examples(tokenizer, env, train_tasks, cfg.max_prompt_tokens)
300
+ print(f"[setup] train_tasks={len(train_tasks)} eval_tasks={len(eval_tasks)} examples={len(examples)}", flush=True)
301
+
302
+ tracker = None
303
+ try:
304
+ import trackio
305
+ tracker = trackio.init(
306
+ project=cfg.trackio_project,
307
+ name=cfg.trackio_run_name,
308
+ config={
309
+ "base_model": cfg.base_model,
310
+ "hub_model_id": cfg.hub_model_id,
311
+ "num_epochs": cfg.num_epochs,
312
+ "n_train_tasks": len(train_tasks),
313
+ "n_eval_tasks": len(eval_tasks),
314
+ "n_examples": len(examples),
315
+ "lr": cfg.lr,
316
+ "lora_r": cfg.lora_r,
317
+ },
318
+ )
319
+ print("[setup] trackio initialised", flush=True)
320
+ except Exception as e:
321
+ print(f"[setup] trackio disabled: {e}", flush=True)
322
+
323
+ def log(row: dict) -> None:
324
+ with metrics_path.open("a", encoding="utf-8") as f:
325
+ f.write(json.dumps({"time": datetime.now(timezone.utc).isoformat(), **row}) + "\n")
326
+ print(" | ".join(f"{k}={v:.4f}" if isinstance(v, float) else f"{k}={v}" for k, v in row.items()), flush=True)
327
+ if tracker is not None:
328
+ try:
329
+ trackio.log(row, step=int(row.get("step", 0)))
330
+ except Exception:
331
+ pass
332
+
333
+ def evaluate(epoch: int) -> dict:
334
+ model.eval()
335
+ scores, rewards, successes, steps = [], [], [], []
336
+ for task_id in eval_tasks:
337
+ traj = rollout_episode(
338
+ model,
339
+ tokenizer,
340
+ env,
341
+ task_id,
342
+ temperature=0.0001,
343
+ top_p=1.0,
344
+ max_new_tokens=cfg.max_new_tokens,
345
+ max_prompt_tokens=cfg.max_prompt_tokens,
346
+ device=device,
347
+ )
348
+ scores.append(traj.grader_score)
349
+ rewards.append(traj.cumulative_reward)
350
+ successes.append(1.0 if traj.success else 0.0)
351
+ steps.append(traj.n_steps)
352
+ model.train()
353
+ return {
354
+ "step": epoch,
355
+ "eval/avg_grader_score": sum(scores) / max(len(scores), 1),
356
+ "eval/avg_cum_reward": sum(rewards) / max(len(rewards), 1),
357
+ "eval/success_rate": sum(successes) / max(len(successes), 1),
358
+ "eval/avg_steps": sum(steps) / max(len(steps), 1),
359
+ }
360
+
361
+ t_start = time.time()
362
+ global_step = 0
363
+ for epoch in range(cfg.num_epochs):
364
+ random.shuffle(examples)
365
+ total_loss = 0.0
366
+ model.train()
367
+ for i, ex in enumerate(examples, 1):
368
+ loss = completion_loss(model, ex["prompt_ids"], ex["completion_ids"], device)
369
+ loss.backward()
370
+ total_loss += float(loss.detach().item())
371
+ torch.nn.utils.clip_grad_norm_([p for p in model.parameters() if p.requires_grad], cfg.grad_clip)
372
+ optimizer.step()
373
+ optimizer.zero_grad(set_to_none=True)
374
+ global_step += 1
375
+ if i % 25 == 0:
376
+ log({"step": global_step, "train/epoch": epoch + 1, "train/example": i, "train/loss": total_loss / i})
377
+ log({"step": global_step, "train/epoch": epoch + 1, "train/loss": total_loss / max(len(examples), 1)})
378
+ if cfg.eval_every_epoch:
379
+ log(evaluate(epoch + 1))
380
+
381
+ summary = {
382
+ "run_finished_at": datetime.now(timezone.utc).isoformat(),
383
+ "base_model": cfg.base_model,
384
+ "hub_model_id": cfg.hub_model_id,
385
+ "num_epochs": cfg.num_epochs,
386
+ "train_tasks": [t.value for t in train_tasks],
387
+ "eval_tasks": [t.value for t in eval_tasks],
388
+ "n_examples": len(examples),
389
+ "wall_clock_s": round(time.time() - t_start, 2),
390
+ }
391
+ summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
392
+
393
+ if cfg.push_to_hub and cfg.hub_username:
394
+ assert token is not None
395
+ repo_id = f"{cfg.hub_username}/{cfg.hub_model_id}"
396
+ create_repo(repo_id=repo_id, repo_type="model", exist_ok=True, private=False, token=token)
397
+ print(f"[push] pushing SFT adapter to {repo_id}", flush=True)
398
+ model.push_to_hub(repo_id, private=False, token=token, commit_message="Save InvoiceGuard SFT adapter")
399
+ tokenizer.push_to_hub(repo_id, private=False, token=token, commit_message="Save InvoiceGuard SFT tokenizer")
400
+ HfApi(token=token).upload_folder(
401
+ folder_path=str(artifact_dir),
402
+ repo_id=repo_id,
403
+ repo_type="model",
404
+ path_in_repo="sft_artifacts",
405
+ token=token,
406
+ commit_message="Add InvoiceGuard SFT artifacts",
407
+ )
408
+ print(f"[push] done -> https://huggingface.co/{repo_id}", flush=True)
409
+
410
+
411
+ def _parse_args() -> SftConfig:
412
+ p = argparse.ArgumentParser()
413
+ p.add_argument("--model-name", dest="base_model", default=None)
414
+ p.add_argument("--hub-model-id", default=None)
415
+ p.add_argument("--num-epochs", type=int, default=None)
416
+ p.add_argument("--max-train-tasks", type=int, default=None)
417
+ p.add_argument("--eval-holdout-canonical", type=int, default=None)
418
+ p.add_argument("--eval-holdout-hard", type=int, default=None)
419
+ p.add_argument("--lr", type=float, default=None)
420
+ p.add_argument("--max-new-tokens", type=int, default=None)
421
+ p.add_argument("--max-prompt-tokens", type=int, default=None)
422
+ p.add_argument("--no-push", action="store_true")
423
+ args = p.parse_args()
424
+
425
+ cfg = SftConfig()
426
+ if args.base_model:
427
+ cfg.base_model = args.base_model
428
+ if args.hub_model_id:
429
+ cfg.hub_model_id = args.hub_model_id
430
+ if args.num_epochs is not None:
431
+ cfg.num_epochs = args.num_epochs
432
+ if args.max_train_tasks is not None:
433
+ cfg.max_train_tasks = args.max_train_tasks
434
+ if args.eval_holdout_canonical is not None:
435
+ cfg.eval_holdout_canonical = args.eval_holdout_canonical
436
+ if args.eval_holdout_hard is not None:
437
+ cfg.eval_holdout_hard = args.eval_holdout_hard
438
+ if args.lr is not None:
439
+ cfg.lr = args.lr
440
+ if args.max_new_tokens is not None:
441
+ cfg.max_new_tokens = args.max_new_tokens
442
+ if args.max_prompt_tokens is not None:
443
+ cfg.max_prompt_tokens = args.max_prompt_tokens
444
+ if args.no_push:
445
+ cfg.push_to_hub = False
446
+ return cfg
447
+
448
+
449
+ if __name__ == "__main__":
450
+ main()