faizath commited on
Commit
ab4499e
·
verified ·
1 Parent(s): 3fd3c01

feat(training): add the colab finetuning pipeline

Browse files

Reproduces these weights end to end, and carries the chat template the
adapter cannot work without.

train_sahabatai.py imports its data helpers from train_qwen.py because
that is what ran on the VM; the sibling adapter's repo ships the same
file. check_template.py proves the template off-GPU -- it fails by
producing plausible text rather than raising, so catching it after a
five-hour run is the expensive way to find out.

training/check_template.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validate the Llama-3 tool template against the real corpus, off-GPU.
2
+
3
+ Everything here runs on jinja2 + tokenizers alone -- no torch, no VM -- so the
4
+ template is proven before any GPU time is spent. It checks the two things that
5
+ fail silently rather than loudly:
6
+
7
+ * tool calls and tool schemas survive rendering (the stock Sahabat-AI
8
+ template drops both, and an assistant tool-call turn has `content: ""`, so
9
+ the damage looks like an ordinary empty reply);
10
+ * response-only masking covers exactly the assistant turns. A tool result
11
+ rendered under a header the instruction delimiter does not match would sit
12
+ inside the loss and teach the model to write its own forecasts.
13
+
14
+ python3 training/check_template.py --data train.parquet \
15
+ --tokenizer tokenizer.json
16
+ """
17
+
18
+ import argparse
19
+ import json
20
+ import statistics
21
+ from pathlib import Path
22
+
23
+ import jinja2.ext
24
+ import pyarrow.parquet as pq
25
+ from jinja2.sandbox import ImmutableSandboxedEnvironment
26
+
27
+ TEMPLATE = Path(__file__).resolve().parent / "llama3_tools.jinja"
28
+
29
+ BOS = "<|begin_of_text|>"
30
+ INSTRUCTION_PART = "<|start_header_id|>user<|end_header_id|>\n\n"
31
+ RESPONSE_PART = "<|start_header_id|>assistant<|end_header_id|>\n\n"
32
+
33
+
34
+ def build_env():
35
+ """Reproduce transformers' template environment closely enough to trust."""
36
+ env = ImmutableSandboxedEnvironment(
37
+ trim_blocks=True, lstrip_blocks=True, extensions=[jinja2.ext.loopcontrols])
38
+ env.filters["tojson"] = lambda x, **kw: json.dumps(
39
+ x, ensure_ascii=kw.get("ensure_ascii", False), indent=kw.get("indent"))
40
+ env.globals["raise_exception"] = lambda m: (_ for _ in ()).throw(Exception(m))
41
+ return env
42
+
43
+
44
+ def hydrate(row):
45
+ messages = []
46
+ for message in row["messages"]:
47
+ clean = {k: v for k, v in message.items() if v is not None}
48
+ if "tool_calls" in clean:
49
+ clean["tool_calls"] = json.loads(clean["tool_calls"])
50
+ messages.append(clean)
51
+ tools = row.get("tools")
52
+ return messages, (json.loads(tools) if tools else None)
53
+
54
+
55
+ def trained_spans(text):
56
+ """The character spans train_on_responses_only would keep in the loss.
57
+
58
+ unsloth trains from each response delimiter to the next *instruction*
59
+ delimiter, or to the end. Working in characters rather than token ids is
60
+ equivalent here and keeps the check tokenizer-free.
61
+ """
62
+ spans, cursor = [], 0
63
+ while (start := text.find(RESPONSE_PART, cursor)) != -1:
64
+ start += len(RESPONSE_PART)
65
+ stop = text.find(INSTRUCTION_PART, start)
66
+ stop = len(text) if stop == -1 else stop
67
+ spans.append((start, stop))
68
+ cursor = stop
69
+ return spans
70
+
71
+
72
+ def main():
73
+ parser = argparse.ArgumentParser()
74
+ parser.add_argument("--data", required=True,
75
+ help="train.parquet from prepare_data.py")
76
+ parser.add_argument("--tokenizer", help="path to a tokenizer.json, for length stats")
77
+ parser.add_argument("--rows", type=int, default=4000)
78
+ args = parser.parse_args()
79
+
80
+ template = build_env().from_string(TEMPLATE.read_text(encoding="utf-8"))
81
+ rows = pq.read_table(args.data).slice(0, args.rows).to_pylist()
82
+
83
+ calls = schemas = leaked = empty_mask = 0
84
+ tool_rows = 0
85
+ lengths = []
86
+ tokenizer = None
87
+ if args.tokenizer:
88
+ from tokenizers import Tokenizer
89
+ tokenizer = Tokenizer.from_file(args.tokenizer)
90
+
91
+ for row in rows:
92
+ messages, tools = hydrate(row)
93
+ text = template.render(messages=messages, tools=tools, bos_token=BOS,
94
+ add_generation_prompt=False)
95
+
96
+ spans = trained_spans(text)
97
+ if not spans:
98
+ empty_mask += 1
99
+ trained = "".join(text[a:b] for a, b in spans)
100
+
101
+ # A tool result inside the trained span is the failure this exists for.
102
+ leaked += trained.count("<tool_response>")
103
+
104
+ for message in messages:
105
+ for call in message.get("tool_calls") or []:
106
+ tool_rows += 1
107
+ needle = f'{{"name": "{call["function"]["name"]}", "parameters": '
108
+ calls += needle in text
109
+ # ...and the call itself MUST be trained on, or the model never
110
+ # learns to emit one.
111
+ calls -= needle not in trained
112
+ if tools:
113
+ schemas += all(t["function"]["description"][:40] in text for t in tools)
114
+
115
+ if tokenizer:
116
+ lengths.append(len(tokenizer.encode(text, add_special_tokens=False).ids))
117
+
118
+ print(f"rows {len(rows)}")
119
+ print(f"tool calls {calls}/{tool_rows} rendered and inside the loss")
120
+ print(f"tool schemas {schemas} conversations carry the offered schema")
121
+ print(f"tool results leaked {leaked} (must be 0)")
122
+ print(f"empty label masks {empty_mask} (must be 0)")
123
+
124
+ if lengths:
125
+ lengths.sort()
126
+ n = len(lengths)
127
+ print(f"tokens mean={statistics.mean(lengths):.0f} "
128
+ f"p50={lengths[n // 2]} p90={lengths[int(n * .9)]} "
129
+ f"p99={lengths[int(n * .99)]} max={lengths[-1]} "
130
+ f"over_4096={sum(1 for x in lengths if x > 4096)}")
131
+
132
+ if leaked or empty_mask or calls != tool_rows:
133
+ raise SystemExit("template check FAILED")
134
+ print("template check OK")
135
+
136
+
137
+ if __name__ == "__main__":
138
+ main()
training/drive.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Drive a Colab VM over the REST API, independently of the CLI session record.
2
+
3
+ ~/.local/share/uv/tools/google-colab-cli/bin/python colab/drive.py <cmd> ...
4
+
5
+ Run it with the colab-cli tool env's own interpreter; a system python3 fails on
6
+ pydantic_core.
7
+
8
+ Why this exists: the runtime proxy token has a 1-hour TTL. When it expires the
9
+ CLI prunes the local session record and kills the keep-alive daemon with it, and
10
+ the VM is reclaimed roughly 40 minutes later -- long after the event that doomed
11
+ it. `colab upload` then reports `File or directory not found` for a remote path
12
+ that is perfectly fine; that error is the symptom of the prune, not evidence
13
+ about the VM. So once a job is launched, nothing here routes through
14
+ `colab -s <name>`: every call re-mints credentials, calls keep_alive before it
15
+ does anything else, and depends on no local state.
16
+
17
+ Do NOT run `colab new` to recover an orphaned session -- it provisions a second
18
+ billable GPU rather than reattaching to the first.
19
+
20
+ keepalive touch the VM and report what is assigned
21
+ ls <remote_dir> list a directory
22
+ cat <remote_file> [--tail N] read a text file
23
+ get <remote_file> <local> download (handles base64 payloads)
24
+ put <local> <remote> upload
25
+ run <local_py> [--timeout N] execute a local script on the VM
26
+ watch <remote_log> --done M poll a log, refreshing keep-alive each pass
27
+ """
28
+
29
+ import argparse
30
+ import base64
31
+ import os
32
+ import shutil
33
+ import sys
34
+ import time
35
+
36
+ import requests
37
+
38
+ CONFIG = os.path.expanduser("~/.colab-cli-oauth-config.json")
39
+ MATCH = os.environ.get("COLAB_MATCH", "a100")
40
+
41
+
42
+ def connect():
43
+ """Mint fresh credentials, keep the VM alive, return its proxy info."""
44
+ from colab_cli.auth import AuthProvider, get_credentials
45
+ from colab_cli.client import Client, Prod
46
+
47
+ try:
48
+ creds = get_credentials(CONFIG, provider=AuthProvider.ADC)
49
+ except Exception:
50
+ creds = get_credentials(CONFIG, provider=AuthProvider.OAUTH2)
51
+
52
+ client = Client(Prod(), creds)
53
+ assignment = next(
54
+ (a for a in client.list_assignments() if MATCH.lower() in a.endpoint.lower()),
55
+ None,
56
+ )
57
+ if assignment is None:
58
+ raise SystemExit(f"no assignment matching {MATCH!r} -- the VM may be gone")
59
+
60
+ # Before anything else. Reading a log does not reset the idle timer.
61
+ client.keep_alive_assignment(assignment.endpoint)
62
+ return assignment, assignment.runtime_proxy_info
63
+
64
+
65
+ def contents(proxy, path, method="GET", payload=None, params=None):
66
+ query = {"authuser": "0", "colab-runtime-proxy-token": proxy.token}
67
+ query.update(params or {})
68
+ url = f"{proxy.url}/api/contents/{path.lstrip('/')}"
69
+ response = requests.request(method, url, params=query, json=payload, timeout=300)
70
+ response.raise_for_status()
71
+ return response.json() if response.content else {}
72
+
73
+
74
+ def read_text(proxy, path):
75
+ """Return a remote file as text, whatever format the API chose."""
76
+ payload = contents(proxy, path, params={"content": "1"})
77
+ if payload.get("format") == "base64":
78
+ return base64.b64decode(payload["content"]).decode("utf-8", "replace")
79
+ return payload.get("content", "")
80
+
81
+
82
+ def cmd_keepalive(args, assignment, proxy):
83
+ print(f"assignment {assignment.endpoint}")
84
+ print(f"proxy {proxy.url}")
85
+ print("keep-alive ok")
86
+
87
+
88
+ def cmd_ls(args, assignment, proxy):
89
+ listing = contents(proxy, args.path, params={"content": "1"})
90
+ for entry in sorted(listing.get("content", []), key=lambda c: c["name"]):
91
+ size = entry.get("size")
92
+ print(f" {entry['type']:<9} {str(size if size is not None else '-'):>12} "
93
+ f"{entry['name']}")
94
+
95
+
96
+ def cmd_cat(args, assignment, proxy):
97
+ text = read_text(proxy, args.path)
98
+ lines = text.splitlines()
99
+ for line in (lines[-args.tail:] if args.tail else lines):
100
+ print(line)
101
+
102
+
103
+ def download(proxy, remote, local):
104
+ """Stream a remote file to disk.
105
+
106
+ Jupyter's /files/ endpoint serves raw bytes. The contents API would wrap a
107
+ 170 MB checkpoint in base64 inside a JSON document -- a third larger on the
108
+ wire and fully buffered in memory at both ends -- so that is only the
109
+ fallback.
110
+ """
111
+ os.makedirs(os.path.dirname(os.path.abspath(local)) or ".", exist_ok=True)
112
+ query = {"authuser": "0", "colab-runtime-proxy-token": proxy.token}
113
+ url = f"{proxy.url}/files/{remote.lstrip('/')}"
114
+
115
+ try:
116
+ with requests.get(url, params=query, stream=True, timeout=900) as response:
117
+ response.raise_for_status()
118
+ written = 0
119
+ with open(local, "wb") as handle:
120
+ for chunk in response.iter_content(1 << 20):
121
+ handle.write(chunk)
122
+ written += len(chunk)
123
+ return written
124
+ except requests.HTTPError:
125
+ payload = contents(proxy, remote, params={"content": "1"})
126
+ blob = (base64.b64decode(payload["content"])
127
+ if payload.get("format") == "base64"
128
+ else payload.get("content", "").encode())
129
+ with open(local, "wb") as handle:
130
+ handle.write(blob)
131
+ return len(blob)
132
+
133
+
134
+ def cmd_get(args, assignment, proxy):
135
+ written = download(proxy, args.remote, args.local)
136
+ print(f"{args.remote} -> {args.local} ({written} bytes)")
137
+
138
+
139
+ def newest_checkpoint(proxy, remote_dir):
140
+ listing = contents(proxy, remote_dir, params={"content": "1"})
141
+ checkpoints = [
142
+ (int(c["name"].split("-")[1]), c["name"])
143
+ for c in listing.get("content", [])
144
+ if c["type"] == "directory" and c["name"].startswith("checkpoint-")
145
+ and c["name"].split("-")[-1].isdigit()
146
+ ]
147
+ return max(checkpoints) if checkpoints else (None, None)
148
+
149
+
150
+ def cmd_pull(args, assignment, proxy):
151
+ """Copy the newest checkpoint down, so a VM reclaim costs minutes not hours.
152
+
153
+ Checkpoints live only on the VM. Keep-alive makes a reclaim unlikely, not
154
+ impossible, and save_total_limit prunes the VM's own copies as it goes.
155
+ """
156
+ deadline = time.time() + args.max_seconds if args.max_seconds else None
157
+ os.makedirs(args.out_dir, exist_ok=True)
158
+ pulled = set()
159
+
160
+ while True:
161
+ try:
162
+ if pulled:
163
+ assignment, proxy = connect()
164
+
165
+ step, name = newest_checkpoint(proxy, args.remote_dir)
166
+ log = read_text(proxy, args.log) if args.log else ""
167
+
168
+ if name and name not in pulled:
169
+ target = os.path.join(args.out_dir, name)
170
+ listing = contents(proxy, f"{args.remote_dir}/{name}",
171
+ params={"content": "1"})
172
+ total = 0
173
+ for entry in listing.get("content", []):
174
+ if entry["type"] != "file":
175
+ continue
176
+ got = download(proxy, f"{args.remote_dir}/{name}/{entry['name']}",
177
+ os.path.join(target, entry["name"]))
178
+ if entry.get("size") and got != entry["size"]:
179
+ raise IOError(f"{entry['name']}: got {got} of {entry['size']}")
180
+ total += got
181
+ pulled.add(name)
182
+ print(f"[pull] {time.strftime('%H:%M:%S')} {name} "
183
+ f"-> {target} ({total/1e6:.0f} MB)", flush=True)
184
+
185
+ # Keep only the newest few; each is ~277 MB.
186
+ local = sorted(
187
+ (int(d.split("-")[1]), d) for d in os.listdir(args.out_dir)
188
+ if d.startswith("checkpoint-") and d.split("-")[-1].isdigit())
189
+ for _, old in local[:-args.keep]:
190
+ shutil.rmtree(os.path.join(args.out_dir, old), ignore_errors=True)
191
+ print(f"[pull] pruned local {old}", flush=True)
192
+ else:
193
+ print(f"[pull] {time.strftime('%H:%M:%S')} newest={name} "
194
+ f"already held, keep-alive ok", flush=True)
195
+
196
+ if args.done and args.done in log:
197
+ print(f"[pull] saw {args.done}", flush=True)
198
+ return 0
199
+ except Exception as exc:
200
+ print(f"[pull] retry: {type(exc).__name__}: {str(exc)[:140]}", flush=True)
201
+
202
+ if deadline and time.time() > deadline:
203
+ return 0
204
+ time.sleep(args.interval)
205
+
206
+
207
+ # A single base64 PUT of a large file is accepted and then silently dropped:
208
+ # 170 MB of weights became a 227 MB JSON body and never landed, while the call
209
+ # returned success. Anything above this goes up in parts and is verified.
210
+ PUT_CHUNK = 24 * 1024 * 1024
211
+
212
+
213
+ def remote_size(proxy, path):
214
+ try:
215
+ return contents(proxy, path, params={"content": "0"}).get("size")
216
+ except requests.HTTPError:
217
+ return None
218
+
219
+
220
+ def put_blob(proxy, blob, remote):
221
+ contents(proxy, remote, method="PUT", payload={
222
+ "type": "file", "format": "base64",
223
+ "content": base64.b64encode(blob).decode(),
224
+ })
225
+
226
+
227
+ def cmd_put(args, assignment, proxy):
228
+ blob = open(args.local, "rb").read()
229
+
230
+ if len(blob) <= PUT_CHUNK:
231
+ put_blob(proxy, blob, args.remote)
232
+ got = remote_size(proxy, args.remote)
233
+ if got != len(blob):
234
+ raise SystemExit(f"upload verify failed: remote {got} of {len(blob)} bytes")
235
+ print(f"{args.local} -> {args.remote} ({len(blob)} bytes, verified)")
236
+ return
237
+
238
+ parts = [blob[i:i + PUT_CHUNK] for i in range(0, len(blob), PUT_CHUNK)]
239
+ for index, part in enumerate(parts):
240
+ name = f"{args.remote}.part{index:03d}"
241
+ assignment, proxy = connect() # each part re-mints and keeps alive
242
+ put_blob(proxy, part, name)
243
+ got = remote_size(proxy, name)
244
+ if got != len(part):
245
+ raise SystemExit(f"part {index} failed: remote {got} of {len(part)} bytes")
246
+ print(f" part {index + 1}/{len(parts)} ok ({len(part)} bytes)", flush=True)
247
+
248
+ print(f"{args.local} -> {args.remote} ({len(blob)} bytes in {len(parts)} parts)")
249
+ print(" now run: drive.py join <remote> <nparts> <expected_bytes>")
250
+
251
+
252
+ def cmd_join(args, assignment, proxy):
253
+ """Concatenate uploaded parts on the VM and verify the result."""
254
+ from colab_cli.runtime import ColabRuntime
255
+
256
+ code = (
257
+ "import os\n"
258
+ f"target = {args.remote!r}\n"
259
+ f"n = {args.nparts}\n"
260
+ "with open(target, 'wb') as out:\n"
261
+ " for i in range(n):\n"
262
+ " p = f'{target}.part{i:03d}'\n"
263
+ " with open(p, 'rb') as fh:\n"
264
+ " out.write(fh.read())\n"
265
+ " os.remove(p)\n"
266
+ )
267
+ runtime = ColabRuntime(url=proxy.url, token=proxy.token)
268
+ try:
269
+ runtime.execute_code(code, timeout=args.timeout)
270
+ except Exception as exc:
271
+ print(f"[join] execute returned {type(exc).__name__}; verifying anyway", flush=True)
272
+
273
+ deadline = time.time() + 300
274
+ while time.time() < deadline:
275
+ assignment, proxy = connect()
276
+ got = remote_size(proxy, args.remote)
277
+ if got == args.expect:
278
+ print(f"{args.remote} joined and verified ({got} bytes)")
279
+ return 0
280
+ time.sleep(10)
281
+
282
+ raise SystemExit(f"join failed: remote {remote_size(proxy, args.remote)} "
283
+ f"of {args.expect} bytes")
284
+
285
+
286
+ def cmd_run(args, assignment, proxy):
287
+ from colab_cli.runtime import ColabRuntime
288
+
289
+ runtime = ColabRuntime(url=proxy.url, token=proxy.token)
290
+ code = open(args.script, encoding="utf-8").read()
291
+ for reply in runtime.execute_code(code, timeout=args.timeout):
292
+ for key in ("text", "traceback", "evalue"):
293
+ if key in reply:
294
+ value = reply[key]
295
+ print("".join(value) if isinstance(value, list) else value, end="")
296
+ print()
297
+
298
+
299
+ def cmd_watch(args, assignment, proxy):
300
+ """Poll a log. Every pass reconnects, so no single expiry ends the watch."""
301
+ deadline = time.time() + args.max_seconds if args.max_seconds else None
302
+ seen = 0
303
+
304
+ while True:
305
+ try:
306
+ if seen: # first pass already connected
307
+ assignment, proxy = connect()
308
+ text = read_text(proxy, args.path)
309
+ except Exception as exc:
310
+ # An unreadable poll is a retry, not a failure. The job outlives it.
311
+ print(f"[watch] retry: {type(exc).__name__}: {str(exc)[:120]}", flush=True)
312
+ time.sleep(args.interval)
313
+ continue
314
+
315
+ seen += 1
316
+ lines = text.splitlines()
317
+ print(f"--- {time.strftime('%H:%M:%S')} {len(lines)} lines keep-alive ok",
318
+ flush=True)
319
+ for line in lines[-args.tail:]:
320
+ print(line, flush=True)
321
+
322
+ for marker in filter(None, [args.done, args.fail]):
323
+ if marker in text:
324
+ print(f"[watch] saw {marker}", flush=True)
325
+ return 0 if marker == args.done else 1
326
+
327
+ if deadline and time.time() > deadline:
328
+ print("[watch] time budget reached; the job is unaffected", flush=True)
329
+ return 0
330
+ time.sleep(args.interval)
331
+
332
+
333
+ def main():
334
+ parser = argparse.ArgumentParser()
335
+ sub = parser.add_subparsers(dest="cmd", required=True)
336
+
337
+ sub.add_parser("keepalive")
338
+
339
+ p = sub.add_parser("ls"); p.add_argument("path", nargs="?", default="/content")
340
+ p = sub.add_parser("cat"); p.add_argument("path"); p.add_argument("--tail", type=int, default=0)
341
+ p = sub.add_parser("get"); p.add_argument("remote"); p.add_argument("local")
342
+ p = sub.add_parser("put"); p.add_argument("local"); p.add_argument("remote")
343
+ p = sub.add_parser("run"); p.add_argument("script"); p.add_argument("--timeout", type=float, default=300)
344
+
345
+ p = sub.add_parser("join")
346
+ p.add_argument("remote"); p.add_argument("nparts", type=int)
347
+ p.add_argument("expect", type=int); p.add_argument("--timeout", type=float, default=120)
348
+
349
+ p = sub.add_parser("pull")
350
+ p.add_argument("--remote-dir", default="/content/outputs/qwen3_5_4b_fairleap")
351
+ p.add_argument("--out-dir", default="checkpoints")
352
+ p.add_argument("--log", default="/content/logs/train.log")
353
+ p.add_argument("--interval", type=int, default=300)
354
+ p.add_argument("--keep", type=int, default=2)
355
+ p.add_argument("--done", default="TRAINING_COMPLETE")
356
+ p.add_argument("--max-seconds", type=int, default=0)
357
+
358
+ p = sub.add_parser("watch")
359
+ p.add_argument("path")
360
+ p.add_argument("--tail", type=int, default=20)
361
+ p.add_argument("--interval", type=int, default=60)
362
+ p.add_argument("--done", default="TRAINING_COMPLETE")
363
+ p.add_argument("--fail", default="")
364
+ p.add_argument("--max-seconds", type=int, default=0)
365
+
366
+ args = parser.parse_args()
367
+ assignment, proxy = connect()
368
+ handler = globals()[f"cmd_{args.cmd}"]
369
+ return handler(args, assignment, proxy) or 0
370
+
371
+
372
+ if __name__ == "__main__":
373
+ sys.exit(main())
training/llama3_tools.jinja ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {#- Tool-capable Llama-3 chat template for the Fairleap corpus.
2
+
3
+ The stock Sahabat-AI template renders every message as
4
+ `content | trim` and ignores both `tool_calls` and the `tools` argument.
5
+ On this corpus that is silent data loss, not an error: an assistant
6
+ tool-call turn carries `content: ""`, so it renders as an *empty*
7
+ assistant reply and the call disappears. ~10% of the corpus is shaped
8
+ that way.
9
+
10
+ Two things this adds:
11
+ 1. assistant `tool_calls` render as the one-line JSON the model is
12
+ meant to emit, and `role: tool` results come back as a `user` turn
13
+ wrapping `<tool_response>` so response-only masking still works
14
+ (see the note on that branch);
15
+ 2. the `tools` schema list is folded into the system turn, so
16
+ "tool offered" and "tool not offered" are distinguishable in
17
+ context. Without that the model cannot learn when *not* to call.
18
+ -#}
19
+ {{- bos_token }}
20
+ {%- set has_system = messages and messages[0]['role'] == 'system' %}
21
+ {%- set tool_header = 'Kamu punya akses ke fungsi berikut. Untuk memanggil fungsi, balas HANYA dengan satu baris JSON berbentuk {"name": <nama fungsi>, "parameters": <objek argumen>}, tanpa teks lain. Panggil fungsi hanya jika pertanyaan driver memang membutuhkannya.\n\nFungsi yang tersedia:' %}
22
+ {%- if tools and not has_system %}
23
+ {{- '<|start_header_id|>system<|end_header_id|>\n\n' + tool_header }}
24
+ {%- for tool in tools %}
25
+ {{- '\n' }}{{- tool['function'] | tojson }}
26
+ {%- endfor %}
27
+ {{- '<|eot_id|>' }}
28
+ {%- endif %}
29
+ {%- for message in messages %}
30
+ {%- if message['role'] == 'system' %}
31
+ {{- '<|start_header_id|>system<|end_header_id|>\n\n' + message['content'] | trim }}
32
+ {%- if tools and loop.first %}
33
+ {{- '\n\n' + tool_header }}
34
+ {%- for tool in tools %}
35
+ {{- '\n' }}{{- tool['function'] | tojson }}
36
+ {%- endfor %}
37
+ {%- endif %}
38
+ {{- '<|eot_id|>' }}
39
+ {%- elif message['role'] == 'assistant' and message.get('tool_calls') %}
40
+ {{- '<|start_header_id|>assistant<|end_header_id|>\n\n' }}
41
+ {%- for tool_call in message['tool_calls'] %}
42
+ {#- Emit each piece separately: `tojson` returns Markup, and
43
+ concatenating a plain string with it HTML-escapes the quotes. #}
44
+ {{- '{"name": "' }}{{- tool_call['function']['name'] }}{{- '", "parameters": ' }}
45
+ {{- tool_call['function']['arguments'] | tojson }}{{- '}' }}
46
+ {%- endfor %}
47
+ {{- '<|eot_id|>' }}
48
+ {%- elif message['role'] == 'tool' %}
49
+ {#- Deliberately a `user` turn wrapping `<tool_response>`, not Llama-3.1's
50
+ `ipython` header. `train_on_responses_only` masks from the response
51
+ delimiter to the next *instruction* delimiter, and an `ipython`
52
+ header matches neither -- the tool result would land inside the loss
53
+ and teach the model to invent forecasts. This is also byte-for-byte
54
+ the shape Qwen's own template uses, so both Fairleap adapters take
55
+ tool results in the same form. #}
56
+ {{- '<|start_header_id|>user<|end_header_id|>\n\n<tool_response>\n' + message['content'] | trim + '\n</tool_response><|eot_id|>' }}
57
+ {%- else %}
58
+ {{- '<|start_header_id|>' + message['role'] + '<|end_header_id|>\n\n' + message['content'] | trim + '<|eot_id|>' }}
59
+ {%- endif %}
60
+ {%- endfor %}
61
+ {%- if add_generation_prompt %}
62
+ {{- '<|start_header_id|>assistant<|end_header_id|>\n\n' }}
63
+ {%- endif %}
training/prepare_data.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prepare the Fairleap corpus for a Colab finetuning run.
2
+
3
+ Truncates conversations at their first canned follow-up answer, then writes
4
+ Parquet with an explicit schema. Parquet because the JSONL is 286 MiB and the
5
+ upload crosses a metered link; the explicit schema because only ~10% of records
6
+ carry `tools`/`tool_calls` and letting Arrow infer that from a first block is
7
+ what makes `load_dataset("json", ...)` fail on a corpus shaped like this.
8
+
9
+ python3 colab/prepare_data.py
10
+
11
+ Writes colab/data/{train,validation}.parquet. train_qwen.py reads them back.
12
+ """
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ import pyarrow as pa
18
+ import pyarrow.parquet as pq
19
+
20
+ ROOT = Path(__file__).resolve().parent.parent
21
+ SPLIT_DIR = ROOT / "data" / "splits"
22
+ OUT_DIR = Path(__file__).resolve().parent / "data"
23
+
24
+ SPLITS = ("train", "validation")
25
+
26
+ # 79.8% of offline follow-up turns are this one sentence, varying only by
27
+ # honorific, and it acknowledges rather than answers. Training on it teaches a
28
+ # model to do the same. See the dataset card, Limitations.
29
+ CANNED_FOLLOWUP = "Kalau ada bagian yang mau saya rinci lagi"
30
+
31
+ MESSAGE_TYPE = pa.struct([
32
+ ("role", pa.string()),
33
+ ("content", pa.string()),
34
+ ("tool_calls", pa.string()),
35
+ ("name", pa.string()),
36
+ ("tool_call_id", pa.string()),
37
+ ])
38
+ SCHEMA = pa.schema([
39
+ ("messages", pa.list_(MESSAGE_TYPE)),
40
+ ("tools", pa.string()),
41
+ ("scenario", pa.string()),
42
+ ("language", pa.string()),
43
+ ("source", pa.string()),
44
+ ])
45
+
46
+
47
+ def truncate_canned(record, min_messages=3):
48
+ """Cut the conversation before its first canned follow-up.
49
+
50
+ Deleting the turn in place would leave a driver's question standing
51
+ unanswered, so everything from that turn onward goes and the remainder is
52
+ trimmed back to end on an assistant reply. Returns None if too little is
53
+ left to be a conversation.
54
+ """
55
+ messages = record["messages"]
56
+ cut = next(
57
+ (i for i, m in enumerate(messages)
58
+ if m["role"] == "assistant" and CANNED_FOLLOWUP in (m.get("content") or "")),
59
+ None,
60
+ )
61
+ if cut is None:
62
+ return record
63
+
64
+ trimmed = messages[:cut]
65
+ while trimmed and trimmed[-1]["role"] != "assistant":
66
+ trimmed.pop()
67
+ if len(trimmed) < min_messages:
68
+ return None
69
+
70
+ return {**record, "messages": trimmed}
71
+
72
+
73
+ def flatten(record):
74
+ messages = []
75
+ for message in record["messages"]:
76
+ messages.append({
77
+ "role": message["role"],
78
+ "content": message.get("content"),
79
+ "tool_calls": (json.dumps(message["tool_calls"], ensure_ascii=False)
80
+ if "tool_calls" in message else None),
81
+ "name": message.get("name"),
82
+ "tool_call_id": message.get("tool_call_id"),
83
+ })
84
+
85
+ tools = record.get("tools")
86
+ meta = record["meta"]
87
+ return {
88
+ "messages": messages,
89
+ "tools": None if tools is None else json.dumps(tools, ensure_ascii=False),
90
+ "scenario": meta["scenario"],
91
+ "language": meta["language"],
92
+ "source": meta["source"],
93
+ }
94
+
95
+
96
+ def main():
97
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
98
+
99
+ for split in SPLITS:
100
+ source = SPLIT_DIR / f"fairleap_{split}.jsonl"
101
+ target = OUT_DIR / f"{split}.parquet"
102
+
103
+ rows, seen, truncated, dropped = [], 0, 0, 0
104
+ with open(source, encoding="utf-8") as handle:
105
+ for line in handle:
106
+ if not line.strip():
107
+ continue
108
+ seen += 1
109
+ record = json.loads(line)
110
+ before = len(record["messages"])
111
+
112
+ record = truncate_canned(record)
113
+ if record is None:
114
+ dropped += 1
115
+ continue
116
+ if len(record["messages"]) != before:
117
+ truncated += 1
118
+
119
+ rows.append(flatten(record))
120
+
121
+ pq.write_table(pa.Table.from_pylist(rows, schema=SCHEMA), target,
122
+ compression="zstd")
123
+
124
+ size = target.stat().st_size
125
+ print(f"{split:<11} {seen:>6} read {len(rows):>6} written "
126
+ f"{truncated:>5} truncated {dropped:>3} dropped "
127
+ f"{size/1e6:>6.1f} MB")
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
training/train_qwen.py ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """QLoRA finetune of Qwen3.5-4B on the Fairleap chat corpus. Runs on the VM.
2
+
3
+ Launch detached so the job outlives the websocket, the proxy token and the
4
+ local session record:
5
+
6
+ nohup python -u train_qwen.py > /content/logs/train.log 2>&1 &
7
+
8
+ Chat templates are applied here, on the VM, into a single `text` column.
9
+ Handing SFTTrainer the raw nested `messages` instead would make Arrow infer a
10
+ schema from the first block, and only ~10% of records carry `tools` or
11
+ `tool_calls` -- that mismatch is a mid-run crash, not a load-time error.
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import re
18
+ import time
19
+ from pathlib import Path
20
+
21
+ MODEL_ID = "Qwen/Qwen3.5-4B"
22
+
23
+ # ChatML delimiters. Everything outside the assistant turns is masked out of
24
+ # the loss: without this the model also learns to reproduce the ~4,400-char
25
+ # stuffed system prompt and the driver's own messages.
26
+ INSTRUCTION_PART = "<|im_start|>user\n"
27
+ RESPONSE_PART = "<|im_start|>assistant\n"
28
+
29
+ SEED = 20260819
30
+
31
+
32
+ def parse_args():
33
+ p = argparse.ArgumentParser()
34
+ p.add_argument("--data-dir", default="/content/data")
35
+ p.add_argument("--output-dir", default="/content/outputs/qwen3_5_4b_fairleap")
36
+ p.add_argument("--adapter-dir", default="/content/fairleap-v1-clm-qwen3.5-4b-adapter")
37
+ p.add_argument("--epochs", type=float, default=2.0)
38
+ p.add_argument("--max-seq", type=int, default=4096)
39
+ p.add_argument("--batch", type=int, default=2)
40
+ p.add_argument("--accum", type=int, default=8)
41
+ p.add_argument("--lr", type=float, default=2e-4)
42
+ p.add_argument("--save-steps", type=int, default=400)
43
+ p.add_argument("--eval-steps", type=int, default=200)
44
+ p.add_argument("--limit", type=int, default=0, help="cap records, for smoke runs")
45
+ p.add_argument("--sample", type=int, default=0,
46
+ help="stratified subsample of train, preserving the mix")
47
+ p.add_argument("--group-by-length", action="store_true",
48
+ help="batch similar lengths together to cut padding waste")
49
+ p.add_argument("--no-grad-checkpoint", action="store_true",
50
+ help="trade VRAM for speed; only if the card has headroom")
51
+ p.add_argument("--resume", action="store_true", help="continue from newest checkpoint")
52
+ p.add_argument("--check", action="store_true", help="render and report, do not train")
53
+ return p.parse_args()
54
+
55
+
56
+ def get_tokenizer(processor):
57
+ """Qwen3.5-4B is a vision-language model, so from_pretrained hands back a
58
+ Qwen3VLProcessor rather than a tokenizer. Its __call__ reads the first
59
+ positional argument as an image source, which turns every training string
60
+ into `Incorrect image source`. The inner text tokenizer is what the corpus
61
+ needs, and it carries the chat template too."""
62
+ tokenizer = getattr(processor, "tokenizer", processor)
63
+ probe = tokenizer("ping")
64
+ ids = probe["input_ids"] if hasattr(probe, "keys") else probe
65
+ if ids and isinstance(ids[0], list):
66
+ raise SystemExit("tokenizer returns batched ids -- still a processor, not a tokenizer")
67
+ return tokenizer
68
+
69
+
70
+ def render(tokenizer, messages, tools):
71
+ """Render one conversation. Thinking is disabled so the corpus, which has
72
+ no reasoning traces, does not teach the model to open a <think> block on
73
+ some turns and not others."""
74
+ try:
75
+ return tokenizer.apply_chat_template(
76
+ messages, tools=tools, tokenize=False,
77
+ add_generation_prompt=False, enable_thinking=False)
78
+ except TypeError:
79
+ return tokenizer.apply_chat_template(
80
+ messages, tools=tools, tokenize=False, add_generation_prompt=False)
81
+
82
+
83
+ def hydrate(row):
84
+ """Turn a Parquet row back into native chat-message dicts."""
85
+ messages = []
86
+ for message in row["messages"]:
87
+ clean = {k: v for k, v in message.items() if v is not None}
88
+ if "tool_calls" in clean:
89
+ clean["tool_calls"] = json.loads(clean["tool_calls"])
90
+ messages.append(clean)
91
+
92
+ tools = row.get("tools")
93
+ return messages, (json.loads(tools) if tools else None)
94
+
95
+
96
+ def stratified_sample(rows, n, seed=SEED):
97
+ """Take n rows preserving the scenario x language x source mix.
98
+
99
+ A head slice would over-represent whatever the split happened to order
100
+ first; proportional allocation with largest-remainder keeps every one of
101
+ the 17 scenarios and both minority languages present at their real rate.
102
+ """
103
+ import random
104
+ from collections import defaultdict
105
+
106
+ if not n or n >= len(rows):
107
+ return rows
108
+
109
+ buckets = defaultdict(list)
110
+ for index, row in enumerate(rows):
111
+ buckets[(row["scenario"], row["language"], row["source"])].append(index)
112
+
113
+ exact = {k: len(v) * n / len(rows) for k, v in buckets.items()}
114
+ quota = {k: int(v) for k, v in exact.items()}
115
+ for key in sorted(exact, key=lambda k: exact[k] - quota[k], reverse=True)[
116
+ :n - sum(quota.values())]:
117
+ quota[key] += 1
118
+
119
+ rng = random.Random(seed)
120
+ picked = []
121
+ for key, indices in buckets.items():
122
+ picked.extend(rng.sample(indices, min(quota[key], len(indices))))
123
+
124
+ picked.sort()
125
+ return [rows[i] for i in picked]
126
+
127
+
128
+ def build_dataset(tokenizer, path, limit=0, sample=0):
129
+ """Render every conversation to one string, so the column type is uniform."""
130
+ import pyarrow.parquet as pq
131
+ from datasets import Dataset
132
+
133
+ rows = pq.read_table(path).to_pylist()
134
+ if sample:
135
+ rows = stratified_sample(rows, sample)
136
+ if limit:
137
+ rows = rows[:limit]
138
+
139
+ texts = [render(tokenizer, *hydrate(row)) for row in rows]
140
+ return Dataset.from_dict({"text": texts})
141
+
142
+
143
+ def newest_checkpoint(output_dir):
144
+ path = Path(output_dir)
145
+ if not path.is_dir():
146
+ return None
147
+ checkpoints = [
148
+ (int(m.group(1)), str(d))
149
+ for d in path.iterdir()
150
+ if d.is_dir() and (m := re.fullmatch(r"checkpoint-(\d+)", d.name))
151
+ ]
152
+ return max(checkpoints)[1] if checkpoints else None
153
+
154
+
155
+ def main():
156
+ args = parse_args()
157
+
158
+ import torch
159
+ from unsloth import FastLanguageModel
160
+ from unsloth.chat_templates import train_on_responses_only
161
+ from trl import SFTConfig, SFTTrainer
162
+
163
+ bf16 = torch.cuda.is_bf16_supported()
164
+ name = torch.cuda.get_device_name(0)
165
+ vram = torch.cuda.get_device_properties(0).total_memory / 1e9
166
+ print(f"[env] gpu={name} vram={vram:.0f}GB bf16={bf16} torch={torch.__version__}",
167
+ flush=True)
168
+
169
+ model, processor = FastLanguageModel.from_pretrained(
170
+ model_name=MODEL_ID,
171
+ max_seq_length=args.max_seq,
172
+ dtype=None,
173
+ load_in_4bit=True,
174
+ )
175
+ tokenizer = get_tokenizer(processor)
176
+ print(f"[env] processor={type(processor).__name__} "
177
+ f"tokenizer={type(tokenizer).__name__}", flush=True)
178
+
179
+ model = FastLanguageModel.get_peft_model(
180
+ model,
181
+ r=32,
182
+ lora_alpha=32,
183
+ lora_dropout=0.0,
184
+ bias="none",
185
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
186
+ "gate_proj", "up_proj", "down_proj"],
187
+ use_gradient_checkpointing=False if args.no_grad_checkpoint else "unsloth",
188
+ random_state=SEED,
189
+ use_rslora=False,
190
+ )
191
+ model.print_trainable_parameters()
192
+
193
+ # The corpus is text-only, so no adapter may land on the vision tower. The
194
+ # projection names are shared across both stacks, so assert the scoping
195
+ # rather than trusting that the tower happens to be named differently.
196
+ adapted = {n.split(".lora_")[0] for n, _ in model.named_parameters() if ".lora_" in n}
197
+ stray = sorted(n for n in adapted if "language_model" not in n)
198
+ if stray:
199
+ raise SystemExit(f"LoRA landed outside the text decoder: {stray[:5]}")
200
+ print(f"[lora] {len(adapted)} modules adapted, all under language_model", flush=True)
201
+
202
+ data_dir = Path(args.data_dir)
203
+ train_ds = build_dataset(tokenizer, data_dir / "train.parquet",
204
+ args.limit, args.sample)
205
+ eval_ds = build_dataset(tokenizer, data_dir / "validation.parquet",
206
+ min(args.limit, 200) if args.limit else 0)
207
+ print(f"[data] train={len(train_ds)} validation={len(eval_ds)}", flush=True)
208
+
209
+ def n_tokens(text):
210
+ encoded = tokenizer(text)
211
+ ids = encoded["input_ids"] if hasattr(encoded, "keys") else encoded
212
+ return len(ids)
213
+
214
+ lengths = sorted(n_tokens(t) for t in train_ds["text"][:2000])
215
+ over = sum(1 for n in lengths if n > args.max_seq)
216
+ print(f"[data] tokens p50={lengths[len(lengths)//2]} "
217
+ f"p90={lengths[int(len(lengths)*0.9)]} max={lengths[-1]} "
218
+ f"over_max_seq={over}/{len(lengths)}", flush=True)
219
+
220
+ if args.check:
221
+ print("[check] sample render:\n" + train_ds["text"][0][:800], flush=True)
222
+ print("[check] done, not training", flush=True)
223
+ return
224
+
225
+ # trl renamed max_seq_length -> max_length and tokenizer -> processing_class
226
+ # between 0.11 and 1.x, and Colab resolves whatever is current. Pick by
227
+ # signature so a version bump is not another lost run.
228
+ import inspect
229
+
230
+ cfg_params = set(inspect.signature(SFTConfig.__init__).parameters)
231
+ seq_key = "max_length" if "max_length" in cfg_params else "max_seq_length"
232
+ trainer_params = set(inspect.signature(SFTTrainer.__init__).parameters)
233
+ tok_key = ("processing_class" if "processing_class" in trainer_params
234
+ else "tokenizer")
235
+ print(f"[api] trl seq_key={seq_key} tokenizer_key={tok_key}", flush=True)
236
+
237
+ cfg = SFTConfig(
238
+ **{seq_key: args.max_seq},
239
+ output_dir=args.output_dir,
240
+ per_device_train_batch_size=args.batch,
241
+ gradient_accumulation_steps=args.accum,
242
+ warmup_ratio=0.03,
243
+ num_train_epochs=args.epochs,
244
+ learning_rate=args.lr,
245
+ logging_steps=20,
246
+ optim="adamw_8bit",
247
+ weight_decay=0.01,
248
+ lr_scheduler_type="cosine",
249
+ seed=SEED,
250
+ bf16=bf16,
251
+ fp16=not bf16,
252
+ packing=False, # conversations must stay intact
253
+ eval_strategy="steps",
254
+ eval_steps=args.eval_steps,
255
+ save_strategy="steps",
256
+ save_steps=args.save_steps,
257
+ save_total_limit=2,
258
+ report_to="none",
259
+ dataset_num_proc=2,
260
+ dataset_text_field="text",
261
+ group_by_length=args.group_by_length,
262
+ )
263
+
264
+ trainer = SFTTrainer(
265
+ model=model,
266
+ train_dataset=train_ds,
267
+ eval_dataset=eval_ds,
268
+ args=cfg,
269
+ **{tok_key: tokenizer},
270
+ )
271
+ trainer = train_on_responses_only(
272
+ trainer,
273
+ instruction_part=INSTRUCTION_PART,
274
+ response_part=RESPONSE_PART,
275
+ )
276
+
277
+ example = trainer.train_dataset[0]
278
+ trained = [t for t in example["labels"] if t != -100]
279
+ print(f"[mask] total={len(example['input_ids'])} trained={len(trained)} "
280
+ f"({100*len(trained)/max(1,len(example['input_ids'])):.1f}%)", flush=True)
281
+ if not trained:
282
+ raise SystemExit("label mask is empty -- the response delimiter did not match")
283
+
284
+ resume = newest_checkpoint(args.output_dir) if args.resume else None
285
+ print(f"[train] starting{f' from {resume}' if resume else ''}", flush=True)
286
+
287
+ start = time.time()
288
+ stats = trainer.train(resume_from_checkpoint=resume)
289
+ print(f"[train] finished in {(time.time()-start)/60:.1f} min", flush=True)
290
+ print(f"[train] metrics {stats.metrics}", flush=True)
291
+
292
+ os.makedirs(args.adapter_dir, exist_ok=True)
293
+ model.save_pretrained(args.adapter_dir)
294
+ # Save the processor, not just the inner tokenizer: reloading the adapter
295
+ # against a VL base needs the preprocessor config to be present.
296
+ processor.save_pretrained(args.adapter_dir)
297
+ print(f"[save] adapter -> {args.adapter_dir}", flush=True)
298
+ print("[done] TRAINING_COMPLETE", flush=True)
299
+
300
+
301
+ if __name__ == "__main__":
302
+ main()
training/train_sahabatai.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """QLoRA finetune of Sahabat-AI (Llama-3 8B) on the Fairleap chat corpus.
2
+
3
+ Runs on the VM. Launch detached so the job outlives the websocket, the proxy
4
+ token and the local session record:
5
+
6
+ nohup python -u train_sahabatai.py > /content/logs/train.log 2>&1 &
7
+
8
+ The data pipeline, sampling and checkpoint handling are shared with
9
+ `train_qwen.py` -- they are base-model agnostic. What is *not* shared, and what
10
+ this file exists for, is everything Llama-3 gets wrong on this corpus:
11
+
12
+ 1. **The stock chat template silently destroys the tool data.** Sahabat-AI ships
13
+ the plain Llama-3 template: it renders `content | trim` for every message and
14
+ ignores `tool_calls` and `tools` entirely. An assistant tool-call turn carries
15
+ `content: ""`, so ~10% of the corpus would render as blank assistant replies
16
+ with no error anywhere. `fairleap_data/templates/llama3_tools.jinja` replaces
17
+ it and is verified off-GPU by `check_template.py`.
18
+ 2. **`model_max_length` is 2048** in the tokenizer config while the model itself
19
+ has 8192 positions. Left alone it truncates over a third of the corpus.
20
+ 3. **`generation_config.json` says eos is `<|end_of_text|>` (128001)** while the
21
+ chat template ends every turn with `<|eot_id|>` (128009). Training does not
22
+ care; inference does, and never stops without the override.
23
+ """
24
+
25
+ import argparse
26
+ import os
27
+ import time
28
+ from pathlib import Path
29
+
30
+ from train_qwen import (SEED, build_dataset, get_tokenizer, newest_checkpoint,
31
+ stratified_sample) # noqa: F401 (re-exported for parity)
32
+
33
+ MODEL_ID = "GoToCompany/llama3-8b-cpt-sahabatai-v1-instruct"
34
+
35
+ TEMPLATE = Path(__file__).resolve().parent / "llama3_tools.jinja"
36
+
37
+ # Llama-3 header delimiters. Tool results are rendered as `user` turns wrapping
38
+ # <tool_response> precisely so this instruction delimiter matches them and they
39
+ # stay out of the loss -- see the template.
40
+ INSTRUCTION_PART = "<|start_header_id|>user<|end_header_id|>\n\n"
41
+ RESPONSE_PART = "<|start_header_id|>assistant<|end_header_id|>\n\n"
42
+
43
+ EOT_TOKEN = "<|eot_id|>"
44
+
45
+
46
+ def parse_args():
47
+ p = argparse.ArgumentParser()
48
+ p.add_argument("--data-dir", default="/content/data")
49
+ p.add_argument("--output-dir", default="/content/outputs/sahabatai_8b_fairleap")
50
+ p.add_argument("--adapter-dir", default="/content/fairleap-v1-clm-sahabatai-8b-adapter")
51
+ p.add_argument("--template", default=str(TEMPLATE))
52
+ p.add_argument("--epochs", type=float, default=2.0)
53
+ p.add_argument("--max-seq", type=int, default=4096)
54
+ p.add_argument("--batch", type=int, default=2)
55
+ p.add_argument("--accum", type=int, default=8)
56
+ p.add_argument("--lr", type=float, default=2e-4)
57
+ p.add_argument("--save-steps", type=int, default=150)
58
+ p.add_argument("--eval-steps", type=int, default=150)
59
+ p.add_argument("--limit", type=int, default=0, help="cap records, for smoke runs")
60
+ p.add_argument("--sample", type=int, default=0,
61
+ help="stratified subsample of train, preserving the mix")
62
+ p.add_argument("--max-steps", type=int, default=0, help="stop early, for smoke runs")
63
+ p.add_argument("--group-by-length", action="store_true",
64
+ help="batch similar lengths together to cut padding waste")
65
+ p.add_argument("--no-grad-checkpoint", action="store_true",
66
+ help="trade VRAM for speed; only if the card has headroom")
67
+ p.add_argument("--resume", action="store_true", help="continue from newest checkpoint")
68
+ p.add_argument("--check", action="store_true", help="render and report, do not train")
69
+ return p.parse_args()
70
+
71
+
72
+ def install_template(tokenizer, path, max_seq):
73
+ """Replace the stock template and undo the two config traps."""
74
+ tokenizer.chat_template = Path(path).read_text(encoding="utf-8")
75
+
76
+ # 2048 in tokenizer_config.json against 8192 real positions. SFTTrainer
77
+ # honours model_max_length, so leaving it truncates most conversations.
78
+ if tokenizer.model_max_length < max_seq:
79
+ print(f"[fix] model_max_length {tokenizer.model_max_length} -> {max_seq}", flush=True)
80
+ tokenizer.model_max_length = max_seq
81
+
82
+ # Config ships padding_side=left, which is the inference setting.
83
+ tokenizer.padding_side = "right"
84
+
85
+ eot = tokenizer.convert_tokens_to_ids(EOT_TOKEN)
86
+ print(f"[fix] eos={tokenizer.eos_token!r} id={tokenizer.eos_token_id} "
87
+ f"eot_id={eot} pad={tokenizer.pad_token!r}", flush=True)
88
+ if tokenizer.eos_token_id != eot:
89
+ raise SystemExit(
90
+ f"eos is {tokenizer.eos_token!r}, not {EOT_TOKEN} -- the template ends "
91
+ f"turns with {EOT_TOKEN} and the model would never learn to stop")
92
+ return tokenizer
93
+
94
+
95
+ def assert_text_only_lora(model):
96
+ """Every adapted module must be a decoder-layer projection.
97
+
98
+ Cheap insurance against a target list that quietly picks up an embedding or
99
+ an lm_head copy, which would multiply the adapter size and change what
100
+ "adapter-only" means for the repo.
101
+ """
102
+ adapted = {n.split(".lora_")[0] for n, _ in model.named_parameters() if ".lora_" in n}
103
+ stray = sorted(n for n in adapted if ".layers." not in n)
104
+ if stray:
105
+ raise SystemExit(f"LoRA landed outside the decoder layers: {stray[:5]}")
106
+ print(f"[lora] {len(adapted)} modules adapted, all decoder projections", flush=True)
107
+
108
+
109
+ def main():
110
+ args = parse_args()
111
+
112
+ import torch
113
+ from unsloth import FastLanguageModel
114
+ from unsloth.chat_templates import train_on_responses_only
115
+ from trl import SFTConfig, SFTTrainer
116
+
117
+ bf16 = torch.cuda.is_bf16_supported()
118
+ name = torch.cuda.get_device_name(0)
119
+ vram = torch.cuda.get_device_properties(0).total_memory / 1e9
120
+ print(f"[env] gpu={name} vram={vram:.0f}GB bf16={bf16} torch={torch.__version__}",
121
+ flush=True)
122
+
123
+ model, processor = FastLanguageModel.from_pretrained(
124
+ model_name=MODEL_ID,
125
+ max_seq_length=args.max_seq,
126
+ dtype=None,
127
+ load_in_4bit=True,
128
+ )
129
+ tokenizer = install_template(get_tokenizer(processor), args.template, args.max_seq)
130
+ print(f"[env] tokenizer={type(tokenizer).__name__} vocab={len(tokenizer)}", flush=True)
131
+
132
+ model = FastLanguageModel.get_peft_model(
133
+ model,
134
+ r=32,
135
+ lora_alpha=32,
136
+ lora_dropout=0.0,
137
+ bias="none",
138
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
139
+ "gate_proj", "up_proj", "down_proj"],
140
+ use_gradient_checkpointing=False if args.no_grad_checkpoint else "unsloth",
141
+ random_state=SEED,
142
+ use_rslora=False,
143
+ )
144
+ model.print_trainable_parameters()
145
+ assert_text_only_lora(model)
146
+
147
+ data_dir = Path(args.data_dir)
148
+ train_ds = build_dataset(tokenizer, data_dir / "train.parquet",
149
+ args.limit, args.sample)
150
+ eval_ds = build_dataset(tokenizer, data_dir / "validation.parquet",
151
+ min(args.limit, 200) if args.limit else 0)
152
+ print(f"[data] train={len(train_ds)} validation={len(eval_ds)}", flush=True)
153
+
154
+ def n_tokens(text):
155
+ encoded = tokenizer(text)
156
+ ids = encoded["input_ids"] if hasattr(encoded, "keys") else encoded
157
+ return len(ids)
158
+
159
+ lengths = sorted(n_tokens(t) for t in train_ds["text"][:2000])
160
+ over = sum(1 for n in lengths if n > args.max_seq)
161
+ print(f"[data] tokens p50={lengths[len(lengths)//2]} "
162
+ f"p90={lengths[int(len(lengths)*0.9)]} max={lengths[-1]} "
163
+ f"over_max_seq={over}/{len(lengths)}", flush=True)
164
+
165
+ # The template is the single highest-risk piece: it fails by producing
166
+ # plausible text, not by raising. Prove a tool call survived the render.
167
+ rendered = "\n".join(train_ds["text"][:2000])
168
+ emitted = rendered.count('{"name": "predict_earnings"')
169
+ offered = rendered.count("Fungsi yang tersedia:")
170
+ results = rendered.count("<tool_response>")
171
+ print(f"[template] tool calls rendered={emitted} schemas offered={offered} "
172
+ f"tool results={results}", flush=True)
173
+ if offered and not emitted:
174
+ raise SystemExit("tools offered but no call rendered -- the template dropped them")
175
+
176
+ if args.check:
177
+ print("[check] sample render:\n" + train_ds["text"][0][:1200], flush=True)
178
+ print("[check] done, not training", flush=True)
179
+ return
180
+
181
+ # trl renamed max_seq_length -> max_length and tokenizer -> processing_class
182
+ # between 0.11 and 1.x, and Colab resolves whatever is current. Pick by
183
+ # signature so a version bump is not another lost run.
184
+ import inspect
185
+
186
+ cfg_params = set(inspect.signature(SFTConfig.__init__).parameters)
187
+ seq_key = "max_length" if "max_length" in cfg_params else "max_seq_length"
188
+ trainer_params = set(inspect.signature(SFTTrainer.__init__).parameters)
189
+ tok_key = ("processing_class" if "processing_class" in trainer_params
190
+ else "tokenizer")
191
+ print(f"[api] trl seq_key={seq_key} tokenizer_key={tok_key}", flush=True)
192
+
193
+ cfg = SFTConfig(
194
+ **{seq_key: args.max_seq},
195
+ output_dir=args.output_dir,
196
+ per_device_train_batch_size=args.batch,
197
+ gradient_accumulation_steps=args.accum,
198
+ warmup_ratio=0.03,
199
+ num_train_epochs=args.epochs,
200
+ max_steps=args.max_steps or -1,
201
+ learning_rate=args.lr,
202
+ logging_steps=10,
203
+ optim="adamw_8bit",
204
+ weight_decay=0.01,
205
+ lr_scheduler_type="cosine",
206
+ seed=SEED,
207
+ bf16=bf16,
208
+ fp16=not bf16,
209
+ packing=False, # conversations must stay intact
210
+ eval_strategy="steps",
211
+ eval_steps=args.eval_steps,
212
+ save_strategy="steps",
213
+ save_steps=args.save_steps,
214
+ save_total_limit=2,
215
+ report_to="none",
216
+ dataset_num_proc=2,
217
+ dataset_text_field="text",
218
+ group_by_length=args.group_by_length,
219
+ )
220
+
221
+ trainer = SFTTrainer(
222
+ model=model,
223
+ train_dataset=train_ds,
224
+ eval_dataset=eval_ds,
225
+ args=cfg,
226
+ **{tok_key: tokenizer},
227
+ )
228
+ trainer = train_on_responses_only(
229
+ trainer,
230
+ instruction_part=INSTRUCTION_PART,
231
+ response_part=RESPONSE_PART,
232
+ )
233
+
234
+ example = trainer.train_dataset[0]
235
+ trained = [t for t in example["labels"] if t != -100]
236
+ print(f"[mask] total={len(example['input_ids'])} trained={len(trained)} "
237
+ f"({100*len(trained)/max(1,len(example['input_ids'])):.1f}%)", flush=True)
238
+ if not trained:
239
+ raise SystemExit("label mask is empty -- the response delimiter did not match")
240
+
241
+ # A tool result inside the loss would teach the model to write its own
242
+ # forecasts. Check the masked labels of a tool-bearing example, not the text.
243
+ for row in trainer.train_dataset:
244
+ kept = tokenizer.decode([t for t in row["labels"] if t != -100])
245
+ if "predict_earnings" in kept:
246
+ if "<tool_response>" in kept:
247
+ raise SystemExit("tool results are inside the loss -- masking is wrong")
248
+ print("[mask] tool call trained, tool result masked out", flush=True)
249
+ break
250
+
251
+ resume = newest_checkpoint(args.output_dir) if args.resume else None
252
+ print(f"[train] starting{f' from {resume}' if resume else ''}", flush=True)
253
+
254
+ start = time.time()
255
+ stats = trainer.train(resume_from_checkpoint=resume)
256
+ print(f"[train] finished in {(time.time()-start)/60:.1f} min", flush=True)
257
+ print(f"[train] metrics {stats.metrics}", flush=True)
258
+
259
+ os.makedirs(args.adapter_dir, exist_ok=True)
260
+ model.save_pretrained(args.adapter_dir)
261
+ tokenizer.save_pretrained(args.adapter_dir)
262
+ print(f"[save] adapter -> {args.adapter_dir}", flush=True)
263
+ print("[done] TRAINING_COMPLETE", flush=True)
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()