Text Generation
PEFT
Safetensors
GGUF
gemma4
unsloth
lora
qlora
fine-tuning
hackathon
gemma-4-good-hackathon
kaggle
translation
speech-recognition
accessibility
on-device
conversational
Instructions to use bradduy/banhmi-gemma4-e4b with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use bradduy/banhmi-gemma4-e4b with PEFT:
from peft import PeftModel from transformers import AutoModelForCausalLM base_model = AutoModelForCausalLM.from_pretrained("unsloth/gemma-4-E4B-it-unsloth-bnb-4bit") model = PeftModel.from_pretrained(base_model, "bradduy/banhmi-gemma4-e4b") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use bradduy/banhmi-gemma4-e4b with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf bradduy/banhmi-gemma4-e4b:Q3_K_S # Run inference directly in the terminal: llama cli -hf bradduy/banhmi-gemma4-e4b:Q3_K_S
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf bradduy/banhmi-gemma4-e4b:Q3_K_S # Run inference directly in the terminal: llama cli -hf bradduy/banhmi-gemma4-e4b:Q3_K_S
Build from source code
git clone https://github.com/ggml-org/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-app # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama serve -hf bradduy/banhmi-gemma4-e4b:Q3_K_S # Run inference directly in the terminal: ./build/bin/llama cli -hf bradduy/banhmi-gemma4-e4b:Q3_K_S
Use Docker
docker model run hf.co/bradduy/banhmi-gemma4-e4b:Q3_K_S
- Llama new
- LM Studio
- Jan
- vLLM
How to use bradduy/banhmi-gemma4-e4b with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "bradduy/banhmi-gemma4-e4b" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "bradduy/banhmi-gemma4-e4b", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/bradduy/banhmi-gemma4-e4b:Q3_K_S
- Ollama
How to use bradduy/banhmi-gemma4-e4b with Ollama:
ollama run hf.co/bradduy/banhmi-gemma4-e4b:Q3_K_S
- Unsloth Desktop
- Docker Model Runner
How to use bradduy/banhmi-gemma4-e4b with Docker Model Runner:
docker model run hf.co/bradduy/banhmi-gemma4-e4b:Q3_K_S
- Lemonade
How to use bradduy/banhmi-gemma4-e4b with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull bradduy/banhmi-gemma4-e4b:Q3_K_S
Run and chat with the model
lemonade run user.banhmi-gemma4-e4b-Q3_K_S
List all available models
lemonade list
- Atomic Chat
Add Unsloth training pipeline (train, evaluate, export, prepare_data, training_logger)
Browse files- scripts/evaluate.py +167 -0
- scripts/export_model.py +124 -0
- scripts/prepare_data.py +139 -0
- scripts/train.py +294 -0
- scripts/training_logger.py +314 -0
scripts/evaluate.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Evaluate a fine-tuned Gemma 4 model.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python scripts/evaluate.py --model checkpoints/finetuned/lora_adapter \
|
| 7 |
+
--eval-data data/processed/train_eval.jsonl
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import json
|
| 12 |
+
import time
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
from unsloth import FastModel
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_args():
|
| 19 |
+
parser = argparse.ArgumentParser(description="Evaluate fine-tuned Gemma 4")
|
| 20 |
+
parser.add_argument("--model", type=str, required=True,
|
| 21 |
+
help="Path to fine-tuned LoRA adapter or model name")
|
| 22 |
+
parser.add_argument("--base-model", type=str, default=None,
|
| 23 |
+
help="Base model name (if loading LoRA adapter separately)")
|
| 24 |
+
parser.add_argument("--eval-data", type=str, required=True,
|
| 25 |
+
help="Path to evaluation JSONL file")
|
| 26 |
+
parser.add_argument("--max-samples", type=int, default=100)
|
| 27 |
+
parser.add_argument("--max-new-tokens", type=int, default=512)
|
| 28 |
+
parser.add_argument("--max-seq-length", type=int, default=2048)
|
| 29 |
+
parser.add_argument("--temperature", type=float, default=0.0,
|
| 30 |
+
help="0.0 for greedy (deterministic)")
|
| 31 |
+
return parser.parse_args()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def load_eval_data(path, max_samples):
|
| 35 |
+
"""Load evaluation data from JSONL."""
|
| 36 |
+
data = []
|
| 37 |
+
with open(path) as f:
|
| 38 |
+
for line in f:
|
| 39 |
+
item = json.loads(line)
|
| 40 |
+
if "messages" in item and len(item["messages"]) >= 2:
|
| 41 |
+
data.append(item)
|
| 42 |
+
if len(data) >= max_samples:
|
| 43 |
+
break
|
| 44 |
+
return data
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def exact_match(prediction, expected):
|
| 48 |
+
"""Simple exact match after normalization."""
|
| 49 |
+
pred_clean = prediction.strip().lower()
|
| 50 |
+
exp_clean = expected.strip().lower()
|
| 51 |
+
return pred_clean == exp_clean
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def contains_match(prediction, expected):
|
| 55 |
+
"""Check if expected answer is contained in prediction."""
|
| 56 |
+
pred_clean = prediction.strip().lower()
|
| 57 |
+
exp_clean = expected.strip().lower()
|
| 58 |
+
return exp_clean in pred_clean
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def main():
|
| 62 |
+
args = parse_args()
|
| 63 |
+
|
| 64 |
+
print("=" * 60)
|
| 65 |
+
print("Gemma 4 Evaluation")
|
| 66 |
+
print("=" * 60)
|
| 67 |
+
print(f"Model: {args.model}")
|
| 68 |
+
print(f"Eval data: {args.eval_data}")
|
| 69 |
+
print(f"Max samples: {args.max_samples}")
|
| 70 |
+
print("=" * 60)
|
| 71 |
+
|
| 72 |
+
# Load model
|
| 73 |
+
print("\nLoading model...")
|
| 74 |
+
model, tokenizer = FastModel.from_pretrained(
|
| 75 |
+
model_name=args.model,
|
| 76 |
+
max_seq_length=args.max_seq_length,
|
| 77 |
+
load_in_4bit=True,
|
| 78 |
+
)
|
| 79 |
+
FastModel.for_inference(model)
|
| 80 |
+
|
| 81 |
+
# Load eval data
|
| 82 |
+
eval_data = load_eval_data(args.eval_data, args.max_samples)
|
| 83 |
+
print(f"Loaded {len(eval_data)} evaluation examples")
|
| 84 |
+
|
| 85 |
+
# Evaluate
|
| 86 |
+
results = []
|
| 87 |
+
total_tokens = 0
|
| 88 |
+
start_time = time.time()
|
| 89 |
+
|
| 90 |
+
for i, sample in enumerate(eval_data):
|
| 91 |
+
messages = sample["messages"]
|
| 92 |
+
|
| 93 |
+
# Use all messages except the last (expected answer) as input
|
| 94 |
+
input_messages = messages[:-1]
|
| 95 |
+
expected = messages[-1]["content"]
|
| 96 |
+
|
| 97 |
+
# Tokenize
|
| 98 |
+
inputs = tokenizer.apply_chat_template(
|
| 99 |
+
input_messages,
|
| 100 |
+
tokenize=True,
|
| 101 |
+
add_generation_prompt=True,
|
| 102 |
+
return_tensors="pt",
|
| 103 |
+
).to(model.device)
|
| 104 |
+
|
| 105 |
+
# Generate
|
| 106 |
+
with torch.no_grad():
|
| 107 |
+
outputs = model.generate(
|
| 108 |
+
input_ids=inputs,
|
| 109 |
+
max_new_tokens=args.max_new_tokens,
|
| 110 |
+
temperature=args.temperature if args.temperature > 0 else None,
|
| 111 |
+
do_sample=args.temperature > 0,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# Decode only the new tokens
|
| 115 |
+
new_tokens = outputs[0][inputs.shape[1]:]
|
| 116 |
+
prediction = tokenizer.decode(new_tokens, skip_special_tokens=True)
|
| 117 |
+
total_tokens += len(new_tokens)
|
| 118 |
+
|
| 119 |
+
# Score
|
| 120 |
+
em = exact_match(prediction, expected)
|
| 121 |
+
cm = contains_match(prediction, expected)
|
| 122 |
+
|
| 123 |
+
results.append({
|
| 124 |
+
"idx": i,
|
| 125 |
+
"exact_match": em,
|
| 126 |
+
"contains_match": cm,
|
| 127 |
+
"prediction_len": len(prediction),
|
| 128 |
+
"expected_len": len(expected),
|
| 129 |
+
})
|
| 130 |
+
|
| 131 |
+
if i < 3:
|
| 132 |
+
print(f"\n--- Example {i+1} ---")
|
| 133 |
+
print(f"Input: {input_messages[-1]['content'][:100]}...")
|
| 134 |
+
print(f"Expected: {expected[:100]}...")
|
| 135 |
+
print(f"Got: {prediction[:100]}...")
|
| 136 |
+
print(f"EM: {em} | Contains: {cm}")
|
| 137 |
+
|
| 138 |
+
if (i + 1) % 10 == 0:
|
| 139 |
+
print(f" Evaluated {i+1}/{len(eval_data)}...")
|
| 140 |
+
|
| 141 |
+
# Compute metrics
|
| 142 |
+
elapsed = time.time() - start_time
|
| 143 |
+
n = len(results)
|
| 144 |
+
exact_match_acc = sum(r["exact_match"] for r in results) / n if n else 0
|
| 145 |
+
contains_match_acc = sum(r["contains_match"] for r in results) / n if n else 0
|
| 146 |
+
avg_pred_len = sum(r["prediction_len"] for r in results) / n if n else 0
|
| 147 |
+
tokens_per_sec = total_tokens / elapsed if elapsed > 0 else 0
|
| 148 |
+
|
| 149 |
+
print("\n" + "=" * 60)
|
| 150 |
+
print("Results")
|
| 151 |
+
print("=" * 60)
|
| 152 |
+
print(f" Samples evaluated: {n}")
|
| 153 |
+
print(f" Exact match: {exact_match_acc:.4f} ({sum(r['exact_match'] for r in results)}/{n})")
|
| 154 |
+
print(f" Contains match: {contains_match_acc:.4f} ({sum(r['contains_match'] for r in results)}/{n})")
|
| 155 |
+
print(f" Avg prediction len: {avg_pred_len:.0f} chars")
|
| 156 |
+
print(f" Inference speed: {tokens_per_sec:.1f} tokens/sec")
|
| 157 |
+
print(f" Total time: {elapsed:.1f}s")
|
| 158 |
+
|
| 159 |
+
# Parseable metrics line for AutoResearch
|
| 160 |
+
print(f"\nMETRICS: exact_match={exact_match_acc:.4f} "
|
| 161 |
+
f"contains_match={contains_match_acc:.4f} "
|
| 162 |
+
f"tokens_per_sec={tokens_per_sec:.1f} "
|
| 163 |
+
f"eval_time={elapsed:.1f}")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
if __name__ == "__main__":
|
| 167 |
+
main()
|
scripts/export_model.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Export fine-tuned Gemma 4 model to various formats.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
# Export LoRA adapter to merged model + GGUF
|
| 7 |
+
python scripts/export_model.py --model checkpoints/finetuned/lora_adapter
|
| 8 |
+
|
| 9 |
+
# Push to HuggingFace Hub
|
| 10 |
+
python scripts/export_model.py --model checkpoints/finetuned/lora_adapter \
|
| 11 |
+
--push-to-hub username/gemma4-finetuned
|
| 12 |
+
|
| 13 |
+
# Export specific GGUF quantization
|
| 14 |
+
python scripts/export_model.py --model checkpoints/finetuned/lora_adapter \
|
| 15 |
+
--gguf-quant q8_0
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
from unsloth import FastModel
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def parse_args():
|
| 25 |
+
parser = argparse.ArgumentParser(description="Export fine-tuned Gemma 4 model")
|
| 26 |
+
parser.add_argument("--model", type=str, required=True,
|
| 27 |
+
help="Path to fine-tuned LoRA adapter")
|
| 28 |
+
parser.add_argument("--max-seq-length", type=int, default=2048)
|
| 29 |
+
parser.add_argument("--output-dir", type=str, default="checkpoints/finetuned",
|
| 30 |
+
help="Base output directory")
|
| 31 |
+
|
| 32 |
+
# Export options
|
| 33 |
+
parser.add_argument("--no-merged", action="store_true",
|
| 34 |
+
help="Skip merged 16-bit export")
|
| 35 |
+
parser.add_argument("--no-gguf", action="store_true",
|
| 36 |
+
help="Skip GGUF export")
|
| 37 |
+
parser.add_argument("--gguf-quant", type=str, default="q4_k_m",
|
| 38 |
+
choices=["q4_k_m", "q8_0", "f16"],
|
| 39 |
+
help="GGUF quantization method")
|
| 40 |
+
parser.add_argument("--push-to-hub", type=str, default=None,
|
| 41 |
+
help="HuggingFace Hub repo to push to (e.g. username/model-name)")
|
| 42 |
+
|
| 43 |
+
return parser.parse_args()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def main():
|
| 47 |
+
args = parse_args()
|
| 48 |
+
|
| 49 |
+
print("=" * 60)
|
| 50 |
+
print("Gemma 4 Model Export")
|
| 51 |
+
print("=" * 60)
|
| 52 |
+
print(f"Model: {args.model}")
|
| 53 |
+
print(f"Output dir: {args.output_dir}")
|
| 54 |
+
print("=" * 60)
|
| 55 |
+
|
| 56 |
+
# Load model
|
| 57 |
+
print("\nLoading model...")
|
| 58 |
+
model, tokenizer = FastModel.from_pretrained(
|
| 59 |
+
model_name=args.model,
|
| 60 |
+
max_seq_length=args.max_seq_length,
|
| 61 |
+
load_in_4bit=True,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# Export merged model
|
| 65 |
+
if not args.no_merged:
|
| 66 |
+
merged_path = os.path.join(args.output_dir, "merged")
|
| 67 |
+
print(f"\nExporting merged 16-bit model to {merged_path}...")
|
| 68 |
+
model.save_pretrained_merged(
|
| 69 |
+
merged_path,
|
| 70 |
+
tokenizer,
|
| 71 |
+
save_method="merged_16bit",
|
| 72 |
+
)
|
| 73 |
+
print(f" Done! Size: {get_dir_size(merged_path)}")
|
| 74 |
+
|
| 75 |
+
# Export GGUF
|
| 76 |
+
if not args.no_gguf:
|
| 77 |
+
gguf_path = os.path.join(args.output_dir, f"gguf_{args.gguf_quant}")
|
| 78 |
+
print(f"\nExporting GGUF ({args.gguf_quant}) to {gguf_path}...")
|
| 79 |
+
model.save_pretrained_gguf(
|
| 80 |
+
gguf_path,
|
| 81 |
+
tokenizer,
|
| 82 |
+
quantization_method=args.gguf_quant,
|
| 83 |
+
)
|
| 84 |
+
print(f" Done! Size: {get_dir_size(gguf_path)}")
|
| 85 |
+
|
| 86 |
+
# Push to Hub
|
| 87 |
+
if args.push_to_hub:
|
| 88 |
+
print(f"\nPushing to HuggingFace Hub: {args.push_to_hub}...")
|
| 89 |
+
|
| 90 |
+
# Push LoRA adapter
|
| 91 |
+
model.push_to_hub(args.push_to_hub, tokenizer)
|
| 92 |
+
print(" Pushed LoRA adapter")
|
| 93 |
+
|
| 94 |
+
# Push GGUF
|
| 95 |
+
model.push_to_hub_gguf(
|
| 96 |
+
args.push_to_hub,
|
| 97 |
+
tokenizer,
|
| 98 |
+
quantization_method=args.gguf_quant,
|
| 99 |
+
)
|
| 100 |
+
print(f" Pushed GGUF ({args.gguf_quant})")
|
| 101 |
+
|
| 102 |
+
print("\nExport complete!")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def get_dir_size(path):
|
| 106 |
+
"""Get human-readable directory size."""
|
| 107 |
+
total = 0
|
| 108 |
+
if os.path.isdir(path):
|
| 109 |
+
for dirpath, _, filenames in os.walk(path):
|
| 110 |
+
for f in filenames:
|
| 111 |
+
fp = os.path.join(dirpath, f)
|
| 112 |
+
total += os.path.getsize(fp)
|
| 113 |
+
elif os.path.isfile(path):
|
| 114 |
+
total = os.path.getsize(path)
|
| 115 |
+
|
| 116 |
+
for unit in ["B", "KB", "MB", "GB"]:
|
| 117 |
+
if total < 1024:
|
| 118 |
+
return f"{total:.1f} {unit}"
|
| 119 |
+
total /= 1024
|
| 120 |
+
return f"{total:.1f} TB"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
if __name__ == "__main__":
|
| 124 |
+
main()
|
scripts/prepare_data.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Dataset preparation for Gemma 4 fine-tuning.
|
| 4 |
+
Converts raw datasets into Gemma 4 chat format and saves to data/processed/.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python scripts/prepare_data.py --dataset <name> --output data/processed/train.jsonl
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
from datasets import load_dataset
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def convert_to_gemma4_chat(example, system_prompt=None):
|
| 17 |
+
"""Convert a single example to Gemma 4 chat format.
|
| 18 |
+
|
| 19 |
+
Gemma 4 uses "model" (not "assistant") as the role name.
|
| 20 |
+
"""
|
| 21 |
+
messages = []
|
| 22 |
+
|
| 23 |
+
if system_prompt:
|
| 24 |
+
messages.append({"role": "system", "content": system_prompt})
|
| 25 |
+
|
| 26 |
+
# Handle different dataset formats
|
| 27 |
+
if "conversations" in example:
|
| 28 |
+
for turn in example["conversations"]:
|
| 29 |
+
role = turn.get("role", turn.get("from", ""))
|
| 30 |
+
content = turn.get("content", turn.get("value", ""))
|
| 31 |
+
# Normalize roles
|
| 32 |
+
if role in ("assistant", "gpt", "bot"):
|
| 33 |
+
role = "model"
|
| 34 |
+
elif role in ("human", "user"):
|
| 35 |
+
role = "user"
|
| 36 |
+
messages.append({"role": role, "content": content})
|
| 37 |
+
|
| 38 |
+
elif "messages" in example:
|
| 39 |
+
for msg in example["messages"]:
|
| 40 |
+
role = msg["role"]
|
| 41 |
+
if role == "assistant":
|
| 42 |
+
role = "model"
|
| 43 |
+
messages.append({"role": role, "content": msg["content"]})
|
| 44 |
+
|
| 45 |
+
elif "instruction" in example:
|
| 46 |
+
user_content = example["instruction"]
|
| 47 |
+
if example.get("input"):
|
| 48 |
+
user_content += f"\n\nInput: {example['input']}"
|
| 49 |
+
messages.append({"role": "user", "content": user_content})
|
| 50 |
+
messages.append({"role": "model", "content": example["output"]})
|
| 51 |
+
|
| 52 |
+
elif "question" in example and "answer" in example:
|
| 53 |
+
messages.append({"role": "user", "content": example["question"]})
|
| 54 |
+
messages.append({"role": "model", "content": example["answer"]})
|
| 55 |
+
|
| 56 |
+
elif "prompt" in example and "response" in example:
|
| 57 |
+
messages.append({"role": "user", "content": example["prompt"]})
|
| 58 |
+
messages.append({"role": "model", "content": example["response"]})
|
| 59 |
+
|
| 60 |
+
else:
|
| 61 |
+
raise ValueError(f"Unknown dataset format. Keys: {list(example.keys())}")
|
| 62 |
+
|
| 63 |
+
return {"messages": messages}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def load_and_convert(dataset_name, split="train", system_prompt=None, max_samples=None):
|
| 67 |
+
"""Load a HuggingFace dataset and convert to Gemma 4 format."""
|
| 68 |
+
print(f"Loading dataset: {dataset_name} (split={split})")
|
| 69 |
+
|
| 70 |
+
if max_samples:
|
| 71 |
+
dataset = load_dataset(dataset_name, split=f"{split}[:{max_samples}]")
|
| 72 |
+
else:
|
| 73 |
+
dataset = load_dataset(dataset_name, split=split)
|
| 74 |
+
|
| 75 |
+
print(f"Loaded {len(dataset)} examples")
|
| 76 |
+
|
| 77 |
+
converted = []
|
| 78 |
+
errors = 0
|
| 79 |
+
for i, example in enumerate(dataset):
|
| 80 |
+
try:
|
| 81 |
+
converted.append(convert_to_gemma4_chat(example, system_prompt))
|
| 82 |
+
except ValueError as e:
|
| 83 |
+
if errors == 0:
|
| 84 |
+
print(f" Warning: {e}")
|
| 85 |
+
errors += 1
|
| 86 |
+
|
| 87 |
+
if errors:
|
| 88 |
+
print(f" Skipped {errors} examples due to format errors")
|
| 89 |
+
|
| 90 |
+
print(f"Converted {len(converted)} examples to Gemma 4 chat format")
|
| 91 |
+
return converted
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def save_jsonl(data, output_path):
|
| 95 |
+
"""Save data as JSONL file."""
|
| 96 |
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
| 97 |
+
with open(output_path, "w") as f:
|
| 98 |
+
for item in data:
|
| 99 |
+
f.write(json.dumps(item) + "\n")
|
| 100 |
+
print(f"Saved {len(data)} examples to {output_path}")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def main():
|
| 104 |
+
parser = argparse.ArgumentParser(description="Prepare dataset for Gemma 4 fine-tuning")
|
| 105 |
+
parser.add_argument("--dataset", type=str, required=True,
|
| 106 |
+
help="HuggingFace dataset name (e.g., 'mlabonne/FineTome-100k')")
|
| 107 |
+
parser.add_argument("--split", type=str, default="train",
|
| 108 |
+
help="Dataset split to use")
|
| 109 |
+
parser.add_argument("--output", type=str, default="data/processed/train.jsonl",
|
| 110 |
+
help="Output JSONL file path")
|
| 111 |
+
parser.add_argument("--system-prompt", type=str, default=None,
|
| 112 |
+
help="System prompt to prepend to every conversation")
|
| 113 |
+
parser.add_argument("--max-samples", type=int, default=None,
|
| 114 |
+
help="Maximum number of samples to use")
|
| 115 |
+
parser.add_argument("--eval-split", type=float, default=0.05,
|
| 116 |
+
help="Fraction of data to hold out for evaluation (0 to disable)")
|
| 117 |
+
args = parser.parse_args()
|
| 118 |
+
|
| 119 |
+
data = load_and_convert(
|
| 120 |
+
args.dataset,
|
| 121 |
+
split=args.split,
|
| 122 |
+
system_prompt=args.system_prompt,
|
| 123 |
+
max_samples=args.max_samples,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
if args.eval_split > 0 and len(data) > 20:
|
| 127 |
+
eval_size = max(1, int(len(data) * args.eval_split))
|
| 128 |
+
train_data = data[:-eval_size]
|
| 129 |
+
eval_data = data[-eval_size:]
|
| 130 |
+
|
| 131 |
+
save_jsonl(train_data, args.output)
|
| 132 |
+
eval_path = args.output.replace(".jsonl", "_eval.jsonl")
|
| 133 |
+
save_jsonl(eval_data, eval_path)
|
| 134 |
+
else:
|
| 135 |
+
save_jsonl(data, args.output)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
if __name__ == "__main__":
|
| 139 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Bánh mì chuyển ngữ — Gemma 4 Unsloth fine-tuning pipeline.
|
| 4 |
+
|
| 5 |
+
Submission to the Gemma 4 Good Hackathon, Unsloth $10K track:
|
| 6 |
+
"best fine-tuned Gemma 4 model created using Unsloth, optimized for a
|
| 7 |
+
specific, impactful task" — short-utterance translation.
|
| 8 |
+
|
| 9 |
+
Defaults match the winning config (exp08) — training loss 2.916 -> 0.0115
|
| 10 |
+
(-99.6%, ~250x), achieved on a single NVIDIA L4 in ~12 h with checkpoint
|
| 11 |
+
resume. Running the script with no flags reproduces that run end-to-end.
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
# Reproduce the submission run (defaults = exp08 winning config)
|
| 15 |
+
python scripts/train.py
|
| 16 |
+
|
| 17 |
+
# Override individual hyperparameters
|
| 18 |
+
python scripts/train.py --lora-rank 128 --learning-rate 5e-5
|
| 19 |
+
|
| 20 |
+
# Use a custom local dataset
|
| 21 |
+
python scripts/train.py --dataset data/processed/train.jsonl
|
| 22 |
+
|
| 23 |
+
# Use the pinned YAML directly
|
| 24 |
+
python scripts/train.py $(python -c "import yaml,sys; [print(f'--{k.replace(\"_\",\"-\")}', v) for k,v in yaml.safe_load(open('configs/train_config.yaml')).items() if not isinstance(v,bool) or v]")
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
# IMPORTANT: import unsloth FIRST before other ML libraries
|
| 28 |
+
import unsloth
|
| 29 |
+
import argparse
|
| 30 |
+
import json
|
| 31 |
+
import os
|
| 32 |
+
|
| 33 |
+
import torch
|
| 34 |
+
from unsloth import FastModel
|
| 35 |
+
from unsloth.chat_templates import get_chat_template, train_on_responses_only
|
| 36 |
+
from datasets import load_dataset, Dataset
|
| 37 |
+
from trl import SFTTrainer, SFTConfig
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def parse_args():
|
| 41 |
+
parser = argparse.ArgumentParser(description="Fine-tune Gemma 4 with Unsloth")
|
| 42 |
+
|
| 43 |
+
# Model — winning config: Unsloth Gemma 4 E4B 4-bit
|
| 44 |
+
parser.add_argument("--model", type=str,
|
| 45 |
+
default="unsloth/gemma-4-E4B-it-unsloth-bnb-4bit",
|
| 46 |
+
help="Pretrained model name (default = exp08)")
|
| 47 |
+
parser.add_argument("--max-seq-length", type=int, default=2048)
|
| 48 |
+
parser.add_argument("--load-4bit", action="store_true", default=True,
|
| 49 |
+
help="QLoRA (4-bit) — default on for the exp08 reproduction")
|
| 50 |
+
parser.add_argument("--load-16bit", action="store_true", help="bf16 LoRA (for MoE)")
|
| 51 |
+
|
| 52 |
+
# LoRA — winning config: r=64 with RSLoRA
|
| 53 |
+
parser.add_argument("--lora-rank", type=int, default=64,
|
| 54 |
+
help="LoRA rank (default = exp08 winning value)")
|
| 55 |
+
parser.add_argument("--lora-alpha", type=int, default=None,
|
| 56 |
+
help="LoRA alpha (defaults to lora-rank)")
|
| 57 |
+
parser.add_argument("--lora-dropout", type=float, default=0.0)
|
| 58 |
+
parser.add_argument("--use-rslora", action="store_true", default=True,
|
| 59 |
+
help="Rank-stabilized LoRA (default on, required for r>=64)")
|
| 60 |
+
|
| 61 |
+
# Data — winning config: 10k FineTome-100k samples
|
| 62 |
+
parser.add_argument("--dataset", type=str, default="mlabonne/FineTome-100k",
|
| 63 |
+
help="Dataset name or path to local JSONL")
|
| 64 |
+
parser.add_argument("--max-samples", type=int, default=10000,
|
| 65 |
+
help="Dataset sample cap (default = exp08 winning value)")
|
| 66 |
+
parser.add_argument("--system-prompt", type=str, default=None)
|
| 67 |
+
|
| 68 |
+
# Training — winning config: lr=7e-5, 5 epochs, grad_accum=8
|
| 69 |
+
parser.add_argument("--batch-size", type=int, default=1)
|
| 70 |
+
parser.add_argument("--grad-accum", type=int, default=8,
|
| 71 |
+
help="Gradient accumulation steps (default = exp08)")
|
| 72 |
+
parser.add_argument("--learning-rate", type=float, default=7e-5,
|
| 73 |
+
help="Learning rate (default = exp08 winning value)")
|
| 74 |
+
parser.add_argument("--max-steps", type=int, default=None,
|
| 75 |
+
help="Set only if num-epochs is None")
|
| 76 |
+
parser.add_argument("--num-epochs", type=int, default=5,
|
| 77 |
+
help="Training epochs (default = exp08 winning value)")
|
| 78 |
+
parser.add_argument("--warmup-steps", type=int, default=50,
|
| 79 |
+
help="LR warmup steps (default = exp08)")
|
| 80 |
+
parser.add_argument("--weight-decay", type=float, default=0.01,
|
| 81 |
+
help="Weight decay (default = exp08)")
|
| 82 |
+
parser.add_argument("--save-steps", type=int, default=250,
|
| 83 |
+
help="Checkpoint every N steps (default = exp08, crash-safe)")
|
| 84 |
+
parser.add_argument("--save-total-limit", type=int, default=3,
|
| 85 |
+
help="Keep only the last N checkpoints")
|
| 86 |
+
parser.add_argument("--scheduler", type=str, default="cosine",
|
| 87 |
+
choices=["cosine", "linear", "constant"])
|
| 88 |
+
parser.add_argument("--seed", type=int, default=3407)
|
| 89 |
+
|
| 90 |
+
# Output
|
| 91 |
+
parser.add_argument("--output-dir", type=str, default="outputs")
|
| 92 |
+
parser.add_argument("--save-path", type=str, default="checkpoints/finetuned/lora_adapter")
|
| 93 |
+
parser.add_argument("--logging-steps", type=int, default=1)
|
| 94 |
+
parser.add_argument("--resume-from", type=str, default=None,
|
| 95 |
+
help="Resume training from a checkpoint dir (e.g. outputs/exp06/checkpoint-2500)")
|
| 96 |
+
|
| 97 |
+
# Post-training pipeline
|
| 98 |
+
parser.add_argument("--experiment-name", type=str, default="experiment",
|
| 99 |
+
help="Name for this experiment (used in logs/reports)")
|
| 100 |
+
return parser.parse_args()
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def load_local_jsonl(path, max_samples=None):
|
| 104 |
+
"""Load a local JSONL dataset."""
|
| 105 |
+
data = []
|
| 106 |
+
with open(path) as f:
|
| 107 |
+
for line in f:
|
| 108 |
+
data.append(json.loads(line))
|
| 109 |
+
if max_samples and len(data) >= max_samples:
|
| 110 |
+
break
|
| 111 |
+
return Dataset.from_list(data)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def main():
|
| 115 |
+
args = parse_args()
|
| 116 |
+
|
| 117 |
+
if args.lora_alpha is None:
|
| 118 |
+
args.lora_alpha = args.lora_rank
|
| 119 |
+
|
| 120 |
+
# Determine loading mode
|
| 121 |
+
if not args.load_4bit and not args.load_16bit:
|
| 122 |
+
args.load_4bit = True # Default to QLoRA
|
| 123 |
+
|
| 124 |
+
print("=" * 60)
|
| 125 |
+
print("Gemma 4 Fine-Tuning with Unsloth")
|
| 126 |
+
print("=" * 60)
|
| 127 |
+
print(f"Model: {args.model}")
|
| 128 |
+
print(f"Quantization: {'4-bit (QLoRA)' if args.load_4bit else '16-bit (bf16 LoRA)'}")
|
| 129 |
+
print(f"LoRA rank: {args.lora_rank}")
|
| 130 |
+
print(f"LoRA alpha: {args.lora_alpha}")
|
| 131 |
+
print(f"Learning rate: {args.learning_rate}")
|
| 132 |
+
print(f"Max steps: {args.max_steps}")
|
| 133 |
+
print(f"Dataset: {args.dataset}")
|
| 134 |
+
print(f"Max samples: {args.max_samples}")
|
| 135 |
+
print("=" * 60)
|
| 136 |
+
|
| 137 |
+
# ---- Load Model ----
|
| 138 |
+
print("\n[1/5] Loading model...")
|
| 139 |
+
model, tokenizer = FastModel.from_pretrained(
|
| 140 |
+
model_name=args.model,
|
| 141 |
+
max_seq_length=args.max_seq_length,
|
| 142 |
+
load_in_4bit=args.load_4bit,
|
| 143 |
+
load_in_16bit=args.load_16bit if not args.load_4bit else False,
|
| 144 |
+
full_finetuning=False,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# ---- Configure LoRA ----
|
| 148 |
+
print("\n[2/5] Configuring LoRA adapters...")
|
| 149 |
+
model = FastModel.get_peft_model(
|
| 150 |
+
model,
|
| 151 |
+
finetune_vision_layers=False,
|
| 152 |
+
finetune_language_layers=True,
|
| 153 |
+
finetune_attention_modules=True,
|
| 154 |
+
finetune_mlp_modules=True,
|
| 155 |
+
r=args.lora_rank,
|
| 156 |
+
lora_alpha=args.lora_alpha,
|
| 157 |
+
lora_dropout=args.lora_dropout,
|
| 158 |
+
bias="none",
|
| 159 |
+
random_state=args.seed,
|
| 160 |
+
use_rslora=args.use_rslora,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
# Print trainable params
|
| 164 |
+
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 165 |
+
total = sum(p.numel() for p in model.parameters())
|
| 166 |
+
print(f" Trainable: {trainable:,} / {total:,} ({100 * trainable / total:.2f}%)")
|
| 167 |
+
|
| 168 |
+
# ---- Setup Chat Template ----
|
| 169 |
+
print("\n[3/5] Setting up chat template...")
|
| 170 |
+
tokenizer = get_chat_template(tokenizer, chat_template="gemma-4")
|
| 171 |
+
|
| 172 |
+
# ---- Load & Format Dataset ----
|
| 173 |
+
print("\n[4/5] Loading and formatting dataset...")
|
| 174 |
+
|
| 175 |
+
if args.dataset.endswith(".jsonl") and os.path.exists(args.dataset):
|
| 176 |
+
dataset = load_local_jsonl(args.dataset, args.max_samples)
|
| 177 |
+
# Dataset is already in messages format, apply chat template
|
| 178 |
+
def format_messages(examples):
|
| 179 |
+
texts = []
|
| 180 |
+
for messages in examples["messages"]:
|
| 181 |
+
text = tokenizer.apply_chat_template(
|
| 182 |
+
messages, tokenize=False, add_generation_prompt=False
|
| 183 |
+
).removeprefix("<bos>")
|
| 184 |
+
texts.append(text)
|
| 185 |
+
return {"text": texts}
|
| 186 |
+
dataset = dataset.map(format_messages, batched=True)
|
| 187 |
+
else:
|
| 188 |
+
# Load from HuggingFace
|
| 189 |
+
from unsloth.chat_templates import standardize_data_formats
|
| 190 |
+
|
| 191 |
+
if args.max_samples:
|
| 192 |
+
dataset = load_dataset(args.dataset, split=f"train[:{args.max_samples}]")
|
| 193 |
+
else:
|
| 194 |
+
dataset = load_dataset(args.dataset, split="train")
|
| 195 |
+
|
| 196 |
+
dataset = standardize_data_formats(dataset)
|
| 197 |
+
|
| 198 |
+
def formatting_prompts_func(examples):
|
| 199 |
+
convos = examples["conversations"]
|
| 200 |
+
texts = [
|
| 201 |
+
tokenizer.apply_chat_template(
|
| 202 |
+
convo, tokenize=False, add_generation_prompt=False
|
| 203 |
+
).removeprefix("<bos>")
|
| 204 |
+
for convo in convos
|
| 205 |
+
]
|
| 206 |
+
return {"text": texts}
|
| 207 |
+
|
| 208 |
+
dataset = dataset.map(formatting_prompts_func, batched=True)
|
| 209 |
+
|
| 210 |
+
print(f" Dataset size: {len(dataset)} examples")
|
| 211 |
+
|
| 212 |
+
# ---- Setup Logger ----
|
| 213 |
+
from training_logger import TrainingLogger
|
| 214 |
+
log_dir = os.path.join(args.output_dir, "logs")
|
| 215 |
+
training_logger = TrainingLogger(
|
| 216 |
+
output_dir=log_dir,
|
| 217 |
+
experiment_name=args.experiment_name,
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# ---- Train ----
|
| 221 |
+
print("\n[5/5] Starting training...")
|
| 222 |
+
|
| 223 |
+
training_kwargs = dict(
|
| 224 |
+
dataset_text_field="text",
|
| 225 |
+
per_device_train_batch_size=args.batch_size,
|
| 226 |
+
gradient_accumulation_steps=args.grad_accum,
|
| 227 |
+
warmup_steps=args.warmup_steps,
|
| 228 |
+
learning_rate=args.learning_rate,
|
| 229 |
+
logging_steps=args.logging_steps,
|
| 230 |
+
optim="adamw_8bit",
|
| 231 |
+
weight_decay=args.weight_decay,
|
| 232 |
+
lr_scheduler_type=args.scheduler,
|
| 233 |
+
seed=args.seed,
|
| 234 |
+
output_dir=args.output_dir,
|
| 235 |
+
report_to="none",
|
| 236 |
+
save_strategy="steps",
|
| 237 |
+
save_steps=args.save_steps,
|
| 238 |
+
save_total_limit=args.save_total_limit,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
if args.num_epochs:
|
| 242 |
+
training_kwargs["num_train_epochs"] = args.num_epochs
|
| 243 |
+
else:
|
| 244 |
+
training_kwargs["max_steps"] = args.max_steps
|
| 245 |
+
|
| 246 |
+
trainer = SFTTrainer(
|
| 247 |
+
model=model,
|
| 248 |
+
tokenizer=tokenizer,
|
| 249 |
+
train_dataset=dataset,
|
| 250 |
+
args=SFTConfig(**training_kwargs),
|
| 251 |
+
callbacks=[training_logger],
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
# Train on responses only (mask user/system tokens)
|
| 255 |
+
trainer = train_on_responses_only(
|
| 256 |
+
trainer,
|
| 257 |
+
instruction_part="<|turn>user\n",
|
| 258 |
+
response_part="<|turn>model\n",
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
trainer_stats = trainer.train(resume_from_checkpoint=args.resume_from)
|
| 262 |
+
|
| 263 |
+
# ---- Save ----
|
| 264 |
+
print(f"\nTraining complete!")
|
| 265 |
+
print(f" Runtime: {trainer_stats.metrics['train_runtime']:.1f}s")
|
| 266 |
+
print(f" Final loss: {trainer_stats.metrics.get('train_loss', 'N/A')}")
|
| 267 |
+
|
| 268 |
+
print(f"\nSaving LoRA adapter to {args.save_path}...")
|
| 269 |
+
os.makedirs(args.save_path, exist_ok=True)
|
| 270 |
+
model.save_pretrained(args.save_path)
|
| 271 |
+
tokenizer.save_pretrained(args.save_path)
|
| 272 |
+
|
| 273 |
+
# Save training logs
|
| 274 |
+
training_logger.save_summary(
|
| 275 |
+
trainer_stats,
|
| 276 |
+
config={
|
| 277 |
+
"model_name": args.model,
|
| 278 |
+
"dataset_name": args.dataset,
|
| 279 |
+
"dataset_size": len(dataset),
|
| 280 |
+
"lora_rank": args.lora_rank,
|
| 281 |
+
},
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
print(f"\nMETRICS: loss={trainer_stats.metrics.get('train_loss', -1):.4f} "
|
| 285 |
+
f"runtime={trainer_stats.metrics['train_runtime']:.1f} "
|
| 286 |
+
f"samples={len(dataset)} lora_rank={args.lora_rank} lr={args.learning_rate}")
|
| 287 |
+
|
| 288 |
+
print("\nDone! Next steps:")
|
| 289 |
+
print(f" 1. Evaluate: python scripts/evaluate.py --model {args.save_path}")
|
| 290 |
+
print(f" 2. Export: python scripts/export_model.py --model {args.save_path}")
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
if __name__ == "__main__":
|
| 294 |
+
main()
|
scripts/training_logger.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Training logger — structured logging for fine-tuning runs.
|
| 4 |
+
|
| 5 |
+
Provides a TrainerCallback that streams training metrics (loss, LR, memory,
|
| 6 |
+
throughput) to structured files during training. Works with SFTTrainer.
|
| 7 |
+
|
| 8 |
+
Files produced in {output_dir}/logs/:
|
| 9 |
+
- training_log.csv — every logging step (loss, lr, memory, tokens/s)
|
| 10 |
+
- training_summary.json — final summary with config + results
|
| 11 |
+
- loss_curve.csv — simplified loss-only for plotting
|
| 12 |
+
|
| 13 |
+
Usage as callback:
|
| 14 |
+
from training_logger import TrainingLogger
|
| 15 |
+
|
| 16 |
+
logger = TrainingLogger(output_dir="outputs/logs", experiment_name="exp09")
|
| 17 |
+
trainer = SFTTrainer(..., callbacks=[logger])
|
| 18 |
+
trainer.train()
|
| 19 |
+
logger.save_summary(trainer_stats, args_dict)
|
| 20 |
+
|
| 21 |
+
Usage standalone (parse existing trainer logs):
|
| 22 |
+
python scripts/training_logger.py --log-dir outputs/ --output outputs/logs/
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import csv
|
| 27 |
+
import json
|
| 28 |
+
import os
|
| 29 |
+
import time
|
| 30 |
+
from dataclasses import dataclass, field, asdict
|
| 31 |
+
from datetime import datetime
|
| 32 |
+
from typing import Optional
|
| 33 |
+
|
| 34 |
+
import torch
|
| 35 |
+
from transformers import TrainerCallback, TrainerState, TrainerControl, TrainingArguments
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class StepMetrics:
|
| 40 |
+
step: int
|
| 41 |
+
epoch: float
|
| 42 |
+
loss: float
|
| 43 |
+
learning_rate: float
|
| 44 |
+
grad_norm: float = 0.0
|
| 45 |
+
gpu_memory_mb: float = 0.0
|
| 46 |
+
gpu_memory_pct: float = 0.0
|
| 47 |
+
tokens_per_sec: float = 0.0
|
| 48 |
+
elapsed_sec: float = 0.0
|
| 49 |
+
timestamp: str = ""
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class TrainingSummary:
|
| 54 |
+
experiment_name: str
|
| 55 |
+
model_name: str = ""
|
| 56 |
+
dataset_name: str = ""
|
| 57 |
+
dataset_size: int = 0
|
| 58 |
+
lora_rank: int = 0
|
| 59 |
+
learning_rate: float = 0.0
|
| 60 |
+
num_epochs: float = 0.0
|
| 61 |
+
total_steps: int = 0
|
| 62 |
+
batch_size: int = 0
|
| 63 |
+
grad_accum: int = 0
|
| 64 |
+
weight_decay: float = 0.0
|
| 65 |
+
warmup_steps: int = 0
|
| 66 |
+
final_loss: float = 0.0
|
| 67 |
+
min_loss: float = float("inf")
|
| 68 |
+
min_loss_step: int = 0
|
| 69 |
+
total_runtime_sec: float = 0.0
|
| 70 |
+
peak_gpu_memory_mb: float = 0.0
|
| 71 |
+
avg_tokens_per_sec: float = 0.0
|
| 72 |
+
total_tokens_trained: int = 0
|
| 73 |
+
timestamp: str = ""
|
| 74 |
+
status: str = "running" # running, completed, crashed
|
| 75 |
+
# Loss at epoch boundaries
|
| 76 |
+
epoch_losses: dict = field(default_factory=dict)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class TrainingLogger(TrainerCallback):
|
| 80 |
+
"""
|
| 81 |
+
HuggingFace TrainerCallback that logs structured training metrics.
|
| 82 |
+
|
| 83 |
+
Attach to SFTTrainer via callbacks=[TrainingLogger(...)].
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
def __init__(self, output_dir: str = "outputs/logs",
|
| 87 |
+
experiment_name: str = "experiment"):
|
| 88 |
+
self.output_dir = output_dir
|
| 89 |
+
self.experiment_name = experiment_name
|
| 90 |
+
self.start_time = None
|
| 91 |
+
self.step_metrics: list[StepMetrics] = []
|
| 92 |
+
self.summary = TrainingSummary(experiment_name=experiment_name)
|
| 93 |
+
self._csv_writer = None
|
| 94 |
+
self._csv_file = None
|
| 95 |
+
self._last_epoch = 0.0
|
| 96 |
+
|
| 97 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 98 |
+
|
| 99 |
+
def on_train_begin(self, args: TrainingArguments, state: TrainerState,
|
| 100 |
+
control: TrainerControl, **kwargs):
|
| 101 |
+
self.start_time = time.time()
|
| 102 |
+
self.summary.timestamp = datetime.now().isoformat()
|
| 103 |
+
self.summary.total_steps = state.max_steps
|
| 104 |
+
self.summary.batch_size = args.per_device_train_batch_size
|
| 105 |
+
self.summary.grad_accum = args.gradient_accumulation_steps
|
| 106 |
+
self.summary.learning_rate = args.learning_rate
|
| 107 |
+
self.summary.weight_decay = args.weight_decay
|
| 108 |
+
self.summary.warmup_steps = args.warmup_steps
|
| 109 |
+
self.summary.num_epochs = args.num_train_epochs
|
| 110 |
+
|
| 111 |
+
# Open CSV for streaming writes
|
| 112 |
+
csv_path = os.path.join(self.output_dir, "training_log.csv")
|
| 113 |
+
self._csv_file = open(csv_path, "w", newline="")
|
| 114 |
+
fieldnames = [
|
| 115 |
+
"step", "epoch", "loss", "learning_rate", "grad_norm",
|
| 116 |
+
"gpu_memory_mb", "gpu_memory_pct", "tokens_per_sec",
|
| 117 |
+
"elapsed_sec", "timestamp",
|
| 118 |
+
]
|
| 119 |
+
self._csv_writer = csv.DictWriter(self._csv_file, fieldnames=fieldnames)
|
| 120 |
+
self._csv_writer.writeheader()
|
| 121 |
+
|
| 122 |
+
print(f"[TrainingLogger] Logging to {self.output_dir}/")
|
| 123 |
+
|
| 124 |
+
def on_log(self, args: TrainingArguments, state: TrainerState,
|
| 125 |
+
control: TrainerControl, logs: dict = None, **kwargs):
|
| 126 |
+
if logs is None or "loss" not in logs:
|
| 127 |
+
return
|
| 128 |
+
|
| 129 |
+
elapsed = time.time() - self.start_time if self.start_time else 0
|
| 130 |
+
epoch = logs.get("epoch", state.epoch or 0)
|
| 131 |
+
|
| 132 |
+
# GPU memory
|
| 133 |
+
gpu_mem_mb = 0.0
|
| 134 |
+
gpu_mem_pct = 0.0
|
| 135 |
+
if torch.cuda.is_available():
|
| 136 |
+
gpu_mem_mb = torch.cuda.max_memory_allocated() / (1024 ** 2)
|
| 137 |
+
gpu_total = torch.cuda.get_device_properties(0).total_mem / (1024 ** 2)
|
| 138 |
+
gpu_mem_pct = (gpu_mem_mb / gpu_total * 100) if gpu_total > 0 else 0
|
| 139 |
+
|
| 140 |
+
metrics = StepMetrics(
|
| 141 |
+
step=state.global_step,
|
| 142 |
+
epoch=round(epoch, 4),
|
| 143 |
+
loss=round(logs.get("loss", 0), 6),
|
| 144 |
+
learning_rate=logs.get("learning_rate", 0),
|
| 145 |
+
grad_norm=logs.get("grad_norm", 0),
|
| 146 |
+
gpu_memory_mb=round(gpu_mem_mb, 1),
|
| 147 |
+
gpu_memory_pct=round(gpu_mem_pct, 1),
|
| 148 |
+
tokens_per_sec=round(
|
| 149 |
+
state.global_step * args.per_device_train_batch_size
|
| 150 |
+
* args.gradient_accumulation_steps / max(elapsed, 1), 1
|
| 151 |
+
),
|
| 152 |
+
elapsed_sec=round(elapsed, 1),
|
| 153 |
+
timestamp=datetime.now().isoformat(),
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
self.step_metrics.append(metrics)
|
| 157 |
+
|
| 158 |
+
# Stream to CSV
|
| 159 |
+
if self._csv_writer:
|
| 160 |
+
self._csv_writer.writerow(asdict(metrics))
|
| 161 |
+
self._csv_file.flush()
|
| 162 |
+
|
| 163 |
+
# Track min loss
|
| 164 |
+
if metrics.loss < self.summary.min_loss:
|
| 165 |
+
self.summary.min_loss = metrics.loss
|
| 166 |
+
self.summary.min_loss_step = metrics.step
|
| 167 |
+
|
| 168 |
+
# Track epoch boundary losses
|
| 169 |
+
current_epoch_int = int(epoch)
|
| 170 |
+
last_epoch_int = int(self._last_epoch)
|
| 171 |
+
if current_epoch_int > last_epoch_int and current_epoch_int > 0:
|
| 172 |
+
self.summary.epoch_losses[str(last_epoch_int + 1)] = metrics.loss
|
| 173 |
+
self._last_epoch = epoch
|
| 174 |
+
|
| 175 |
+
# Track peak GPU memory
|
| 176 |
+
if gpu_mem_mb > self.summary.peak_gpu_memory_mb:
|
| 177 |
+
self.summary.peak_gpu_memory_mb = gpu_mem_mb
|
| 178 |
+
|
| 179 |
+
def on_train_end(self, args: TrainingArguments, state: TrainerState,
|
| 180 |
+
control: TrainerControl, **kwargs):
|
| 181 |
+
if self._csv_file:
|
| 182 |
+
self._csv_file.close()
|
| 183 |
+
self._csv_file = None
|
| 184 |
+
|
| 185 |
+
self.summary.status = "completed"
|
| 186 |
+
|
| 187 |
+
def save_summary(self, trainer_stats=None, config: dict = None):
|
| 188 |
+
"""
|
| 189 |
+
Save final training summary. Call after trainer.train() completes.
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
trainer_stats: Return value from trainer.train()
|
| 193 |
+
config: Dict of training config (model name, dataset, etc.)
|
| 194 |
+
"""
|
| 195 |
+
if trainer_stats:
|
| 196 |
+
metrics = trainer_stats.metrics
|
| 197 |
+
self.summary.final_loss = metrics.get("train_loss", 0)
|
| 198 |
+
self.summary.total_runtime_sec = metrics.get("train_runtime", 0)
|
| 199 |
+
self.summary.total_steps = metrics.get("train_steps", self.summary.total_steps)
|
| 200 |
+
|
| 201 |
+
if config:
|
| 202 |
+
self.summary.model_name = config.get("model_name", "")
|
| 203 |
+
self.summary.dataset_name = config.get("dataset_name", "")
|
| 204 |
+
self.summary.dataset_size = config.get("dataset_size", 0)
|
| 205 |
+
self.summary.lora_rank = config.get("lora_rank", 0)
|
| 206 |
+
|
| 207 |
+
if self.step_metrics:
|
| 208 |
+
total_elapsed = self.step_metrics[-1].elapsed_sec
|
| 209 |
+
if total_elapsed > 0:
|
| 210 |
+
self.summary.avg_tokens_per_sec = round(
|
| 211 |
+
self.step_metrics[-1].tokens_per_sec, 1
|
| 212 |
+
)
|
| 213 |
+
# Final epoch loss
|
| 214 |
+
last = self.step_metrics[-1]
|
| 215 |
+
epoch_int = int(last.epoch)
|
| 216 |
+
if str(epoch_int) not in self.summary.epoch_losses:
|
| 217 |
+
self.summary.epoch_losses[str(epoch_int)] = last.loss
|
| 218 |
+
|
| 219 |
+
# Save JSON summary
|
| 220 |
+
summary_path = os.path.join(self.output_dir, "training_summary.json")
|
| 221 |
+
with open(summary_path, "w") as f:
|
| 222 |
+
json.dump(asdict(self.summary), f, indent=2, default=str)
|
| 223 |
+
print(f"[TrainingLogger] Summary: {summary_path}")
|
| 224 |
+
|
| 225 |
+
# Save simplified loss curve CSV
|
| 226 |
+
loss_path = os.path.join(self.output_dir, "loss_curve.csv")
|
| 227 |
+
with open(loss_path, "w", newline="") as f:
|
| 228 |
+
writer = csv.writer(f)
|
| 229 |
+
writer.writerow(["step", "epoch", "loss"])
|
| 230 |
+
for m in self.step_metrics:
|
| 231 |
+
writer.writerow([m.step, m.epoch, m.loss])
|
| 232 |
+
print(f"[TrainingLogger] Loss curve: {loss_path}")
|
| 233 |
+
|
| 234 |
+
# Print summary
|
| 235 |
+
print(f"\n{'=' * 60}")
|
| 236 |
+
print(f"Training Summary: {self.experiment_name}")
|
| 237 |
+
print(f"{'=' * 60}")
|
| 238 |
+
print(f" Final loss: {self.summary.final_loss:.6f}")
|
| 239 |
+
print(f" Min loss: {self.summary.min_loss:.6f} "
|
| 240 |
+
f"(step {self.summary.min_loss_step})")
|
| 241 |
+
print(f" Runtime: {self.summary.total_runtime_sec:.1f}s")
|
| 242 |
+
print(f" Peak GPU: {self.summary.peak_gpu_memory_mb:.0f} MB")
|
| 243 |
+
print(f" Avg throughput: {self.summary.avg_tokens_per_sec:.1f} steps/s")
|
| 244 |
+
if self.summary.epoch_losses:
|
| 245 |
+
print(f" Epoch losses: {self.summary.epoch_losses}")
|
| 246 |
+
print(f" Status: {self.summary.status}")
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def parse_existing_logs(log_dir: str, output_dir: str):
|
| 250 |
+
"""Parse trainer_state.json from an existing training run."""
|
| 251 |
+
state_path = os.path.join(log_dir, "trainer_state.json")
|
| 252 |
+
if not os.path.exists(state_path):
|
| 253 |
+
# Try to find it in a checkpoint
|
| 254 |
+
import glob
|
| 255 |
+
candidates = glob.glob(os.path.join(log_dir, "checkpoint-*/trainer_state.json"))
|
| 256 |
+
if candidates:
|
| 257 |
+
state_path = sorted(candidates)[-1] # latest checkpoint
|
| 258 |
+
else:
|
| 259 |
+
print(f"No trainer_state.json found in {log_dir}")
|
| 260 |
+
return
|
| 261 |
+
|
| 262 |
+
print(f"Parsing: {state_path}")
|
| 263 |
+
with open(state_path) as f:
|
| 264 |
+
state = json.load(f)
|
| 265 |
+
|
| 266 |
+
log_history = state.get("log_history", [])
|
| 267 |
+
if not log_history:
|
| 268 |
+
print("No log history found.")
|
| 269 |
+
return
|
| 270 |
+
|
| 271 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 272 |
+
|
| 273 |
+
# Write CSV
|
| 274 |
+
csv_path = os.path.join(output_dir, "training_log.csv")
|
| 275 |
+
with open(csv_path, "w", newline="") as f:
|
| 276 |
+
writer = csv.writer(f)
|
| 277 |
+
writer.writerow(["step", "epoch", "loss", "learning_rate", "grad_norm"])
|
| 278 |
+
for entry in log_history:
|
| 279 |
+
if "loss" in entry:
|
| 280 |
+
writer.writerow([
|
| 281 |
+
entry.get("step", 0),
|
| 282 |
+
entry.get("epoch", 0),
|
| 283 |
+
entry.get("loss", 0),
|
| 284 |
+
entry.get("learning_rate", 0),
|
| 285 |
+
entry.get("grad_norm", 0),
|
| 286 |
+
])
|
| 287 |
+
|
| 288 |
+
# Write loss curve
|
| 289 |
+
loss_path = os.path.join(output_dir, "loss_curve.csv")
|
| 290 |
+
with open(loss_path, "w", newline="") as f:
|
| 291 |
+
writer = csv.writer(f)
|
| 292 |
+
writer.writerow(["step", "epoch", "loss"])
|
| 293 |
+
for entry in log_history:
|
| 294 |
+
if "loss" in entry:
|
| 295 |
+
writer.writerow([
|
| 296 |
+
entry.get("step", 0),
|
| 297 |
+
entry.get("epoch", 0),
|
| 298 |
+
entry.get("loss", 0),
|
| 299 |
+
])
|
| 300 |
+
|
| 301 |
+
print(f"Parsed {len(log_history)} log entries -> {output_dir}/")
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
if __name__ == "__main__":
|
| 305 |
+
parser = argparse.ArgumentParser(
|
| 306 |
+
description="Parse existing trainer logs into structured format"
|
| 307 |
+
)
|
| 308 |
+
parser.add_argument("--log-dir", type=str, required=True,
|
| 309 |
+
help="Directory containing trainer_state.json")
|
| 310 |
+
parser.add_argument("--output", type=str, default="outputs/logs",
|
| 311 |
+
help="Output directory for parsed logs")
|
| 312 |
+
args = parser.parse_args()
|
| 313 |
+
|
| 314 |
+
parse_existing_logs(args.log_dir, args.output)
|