AQIT 0.1.0
Loading...
Searching...
No Matches
llm_lora.py
Go to the documentation of this file.
1# Copyright (c) 2025-present Aquin Labs Private Limited. All Rights Reserved.
2# This file is part of the Aquin Engine. Unauthorized copying, modification,
3# distribution, or use of this file, via any medium, is strictly prohibited.
4# Proprietary and confidential. See LICENSE for terms.
5
6"""LLM LoRA / QLoRA adapter on the same Recipe / Run spine."""
7
8from __future__ import annotations
9
10import json
11from pathlib import Path
12from typing import Any
13
14from aquin.recipe.schema import Checkpoint, EvalGate, Recipe, RecipeError
15from aquin.run import Run
16
17
18def _read_jsonl(path: Path) -> list[dict[str, Any]]:
19 rows: list[dict[str, Any]] = []
20 with path.open(encoding="utf-8") as fh:
21 for line in fh:
22 line = line.strip()
23 if not line:
24 continue
25 row = json.loads(line)
26 if not isinstance(row, dict):
27 raise RecipeError(f"jsonl rows must be objects, got {type(row).__name__}")
28 rows.append(row)
29 if not rows:
30 raise RecipeError(f"No rows in {path}")
31 return rows
32
33
34def _row_text(row: dict[str, Any], text_field: str) -> str:
35 if text_field in row and row[text_field] is not None:
36 return str(row[text_field])
37 if "prompt" in row and "completion" in row:
38 return f"{row['prompt']}\n{row['completion']}"
39 if "messages" in row and isinstance(row["messages"], list):
40 parts = []
41 for m in row["messages"]:
42 if isinstance(m, dict):
43 parts.append(f"{m.get('role', 'user')}: {m.get('content', '')}")
44 return "\n".join(parts)
45 raise RecipeError(
46 f"Each jsonl row needs {text_field!r}, or prompt+completion, or messages."
47 )
48
49
50def _overlap(pred: str, reference: str) -> float:
51 ref = [t for t in reference.lower().split() if t]
52 if not ref:
53 return 1.0
54 got = set(pred.lower().split())
55 return sum(1 for t in ref if t in got) / len(ref)
56
57
58def train_llm(recipe: Recipe, recorder: Run, out_dir: Path) -> dict[str, Any]:
59 try:
60 import torch
61 from datasets import Dataset
62 from peft import LoraConfig, TaskType, get_peft_model
63 from transformers import (
64 AutoModelForCausalLM,
65 AutoTokenizer,
66 DataCollatorForLanguageModeling,
67 Trainer,
68 TrainingArguments,
69 )
70 except ImportError as exc:
71 raise RecipeError(
72 "LLM LoRA train needs transformers, peft, datasets, and torch."
73 ) from exc
74
75 data_path = Path(str(recipe.data["path"]))
76 rows = _read_jsonl(data_path)
77 text_field = str(recipe.data.get("text_field") or "text")
78 texts = [_row_text(r, text_field) for r in rows]
79
80 base = str(recipe.train["base"])
81 rank = int(recipe.train.get("rank") or 8)
82 lora_alpha = int(recipe.train.get("lora_alpha") or 16)
83 lr = float(recipe.train.get("lr") or 2e-4)
84 epochs = float(recipe.train.get("epochs") or 1)
85 max_steps = recipe.train.get("max_steps")
86 max_seq = int(recipe.train.get("max_seq_len") or 512)
87 batch = int(recipe.train.get("batch_size") or 1)
88 accum = int(recipe.train.get("grad_accum") or 4)
89
90 tokenizer = AutoTokenizer.from_pretrained(base, use_fast=True)
91 if tokenizer.pad_token is None:
92 tokenizer.pad_token = tokenizer.eos_token
93
94 model = AutoModelForCausalLM.from_pretrained(base)
95 model.config.pad_token_id = tokenizer.pad_token_id
96 lora_kwargs: dict[str, Any] = dict(
97 task_type=TaskType.CAUSAL_LM,
98 r=rank,
99 lora_alpha=lora_alpha,
100 lora_dropout=float(recipe.train.get("dropout") or 0.05),
101 )
102 modules = recipe.train.get("target_modules")
103 if modules:
104 lora_kwargs["target_modules"] = modules
105 model = get_peft_model(model, LoraConfig(**lora_kwargs))
106
107 ds = Dataset.from_dict({"text": texts})
108
109 def tokenize(batch: dict[str, list[str]]) -> dict[str, Any]:
110 return tokenizer(
111 batch["text"],
112 truncation=True,
113 max_length=max_seq,
114 padding=False,
115 )
116
117 tokenized = ds.map(tokenize, batched=True, remove_columns=["text"])
118 ckpt_dir = out_dir / "checkpoints" / "lora"
119 ckpt_dir.mkdir(parents=True, exist_ok=True)
120
121 args = TrainingArguments(
122 output_dir=str(out_dir / "hf_trainer"),
123 per_device_train_batch_size=batch,
124 gradient_accumulation_steps=accum,
125 learning_rate=lr,
126 num_train_epochs=epochs,
127 max_steps=int(max_steps) if max_steps is not None else -1,
128 logging_steps=1,
129 save_strategy="no",
130 report_to=[],
131 fp16=bool(torch.cuda.is_available()),
132 bf16=False,
133 remove_unused_columns=False,
134 )
135 collator = DataCollatorForLanguageModeling(tokenizer, mlm=False)
136 losses: list[float] = []
137
138 class _Log(Trainer):
139 def log(self, logs: dict[str, float], *rest: Any, **kwargs: Any) -> None: # type: ignore[override]
140 super().log(logs, *rest, **kwargs)
141 if "loss" in logs:
142 losses.append(float(logs["loss"]))
143 recorder.log(int(self.state.global_step or 0), loss=float(logs["loss"]))
144
145 trainer = _Log(
146 model=model,
147 args=args,
148 train_dataset=tokenized,
149 data_collator=collator,
150 )
151 trainer.train()
152 model.save_pretrained(str(ckpt_dir))
153 tokenizer.save_pretrained(str(ckpt_dir))
154 recorder.signal(int(trainer.state.global_step or 1), "lora checkpoint saved")
155
156 mean_loss = float(sum(losses) / len(losses)) if losses else None
157 metrics: dict[str, Any] = {"train_loss": mean_loss, "n_rows": len(texts)}
158 inspect = {"n_rows": len(texts), "base": base, "rank": rank}
159
160 probes_path = recipe.eval.get("probes")
161 min_score = recipe.eval.get("min_score")
162 details: dict[str, Any] = {"train_loss": mean_loss}
163 if probes_path:
164 probes = _read_jsonl(Path(str(probes_path)))
165 model.eval()
166 scores: list[float] = []
167 rows_out: list[dict[str, Any]] = []
168 max_new = int(recipe.eval.get("max_tokens") or 32)
169 for p in probes:
170 prompt = str(p.get("prompt") or p.get("text") or "")
171 ref = str(p.get("reference") or p.get("completion") or "")
172 inputs = tokenizer(prompt, return_tensors="pt")
173 inputs = {k: v.to(model.device) for k, v in inputs.items()}
174 with torch.no_grad():
175 out = model.generate(**inputs, max_new_tokens=max_new, do_sample=False)
176 pred = tokenizer.decode(out[0][inputs["input_ids"].shape[1] :], skip_special_tokens=True)
177 sc = _overlap(pred, ref) if ref else 0.0
178 scores.append(sc)
179 rows_out.append({"prompt": prompt, "response": pred, "score": sc})
180 score = float(sum(scores) / len(scores)) if scores else 0.0
181 metric = str(recipe.eval.get("metric") or "overlap")
182 skipped = min_score is None
183 passed = True if skipped else score >= float(min_score)
184 details["probes"] = rows_out
185 gate = EvalGate(
186 passed=passed,
187 metric=metric,
188 score=round(score, 6),
189 min_score=None if min_score is None else float(min_score),
190 skipped=skipped,
191 details=details,
192 )
193 metrics["eval_overlap"] = score
194 else:
195 metric = "train_loss"
196 skipped = min_score is None
197 if skipped or mean_loss is None:
198 passed = True
199 score = mean_loss
200 else:
201 # lower is better when gating on train loss
202 score = mean_loss
203 passed = mean_loss <= float(min_score)
204 gate = EvalGate(
205 passed=passed,
206 metric=metric,
207 score=None if score is None else round(float(score), 6),
208 min_score=None if min_score is None else float(min_score),
209 skipped=skipped,
210 details=details,
211 )
212
213 return {
214 "checkpoint": Checkpoint(
215 path=str(ckpt_dir),
216 kind="peft",
217 step=int(trainer.state.global_step or 1),
218 extra={"base": base, "rank": rank},
219 ),
220 "gate": gate,
221 "metrics": metrics,
222 "inspect": inspect,
223 }
str _row_text(dict[str, Any] row, str text_field)
Definition llm_lora.py:38
float _overlap(str pred, str reference)
Definition llm_lora.py:54
list[dict[str, Any]] _read_jsonl(Path path)
Definition llm_lora.py:22
dict[str, Any] train_llm(Recipe recipe, Run recorder, Path out_dir)
Definition llm_lora.py:62