bradduy commited on
Commit
4942b80
·
verified ·
1 Parent(s): b3cdfcd

Add Unsloth training pipeline (train, evaluate, export, prepare_data, training_logger)

Browse files
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)