Instructions to use fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter with PEFT:
from peft import PeftModel from transformers import AutoModelForCausalLM base_model = AutoModelForCausalLM.from_pretrained("GoToCompany/llama3-8b-cpt-sahabatai-v1-instruct") model = PeftModel.from_pretrained(base_model, "fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter") - Transformers
How to use fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter") messages = [ {"role": "user", "content": "Who are you?"}, ] pipe(messages)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter", device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter
- SGLang
How to use fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }' - Unsloth Desktop
- Docker Model Runner
How to use fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter with Docker Model Runner:
docker model run hf.co/fairleap-ai/fairleap-v1-clm-sahabatai-8b-adapter
feat(training): add the colab finetuning pipeline
Browse filesReproduces 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 +138 -0
- training/drive.py +373 -0
- training/llama3_tools.jinja +63 -0
- training/prepare_data.py +131 -0
- training/train_qwen.py +302 -0
- training/train_sahabatai.py +267 -0
|
@@ -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()
|
|
@@ -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())
|
|
@@ -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 %}
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -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()
|