troy-cli 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- troy/__init__.py +3 -0
- troy/chat.py +57 -0
- troy/cli.py +274 -0
- troy/config.py +82 -0
- troy/data.py +163 -0
- troy/export.py +81 -0
- troy/hardware.py +68 -0
- troy/serve.py +45 -0
- troy/templates.py +84 -0
- troy/train_dpo.py +174 -0
- troy/train_sft.py +113 -0
- troy_cli-0.1.2.dist-info/METADATA +112 -0
- troy_cli-0.1.2.dist-info/RECORD +15 -0
- troy_cli-0.1.2.dist-info/WHEEL +4 -0
- troy_cli-0.1.2.dist-info/entry_points.txt +2 -0
troy/__init__.py
ADDED
troy/chat.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Interactive chat with a trained model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from mlx_lm.generate import stream_generate
|
|
8
|
+
from mlx_lm.sample_utils import make_sampler
|
|
9
|
+
from mlx_lm.utils import load
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run_chat(
|
|
13
|
+
model_path: str,
|
|
14
|
+
adapter_path: Optional[str] = None,
|
|
15
|
+
max_tokens: int = 512,
|
|
16
|
+
temperature: float = 0.7,
|
|
17
|
+
prompt: Optional[str] = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
print(f"Loading {model_path} ...")
|
|
20
|
+
model, tokenizer = load(model_path, adapter_path=adapter_path)
|
|
21
|
+
sampler = make_sampler(temp=temperature)
|
|
22
|
+
history = []
|
|
23
|
+
|
|
24
|
+
def respond(user_text: str) -> None:
|
|
25
|
+
history.append({"role": "user", "content": user_text})
|
|
26
|
+
templated = tokenizer.apply_chat_template(
|
|
27
|
+
history, add_generation_prompt=True, return_dict=False
|
|
28
|
+
)
|
|
29
|
+
reply = ""
|
|
30
|
+
for response in stream_generate(
|
|
31
|
+
model, tokenizer, templated, max_tokens=max_tokens, sampler=sampler
|
|
32
|
+
):
|
|
33
|
+
print(response.text, end="", flush=True)
|
|
34
|
+
reply += response.text
|
|
35
|
+
print()
|
|
36
|
+
history.append({"role": "assistant", "content": reply})
|
|
37
|
+
|
|
38
|
+
if prompt is not None: # one-shot mode
|
|
39
|
+
respond(prompt)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
print("Chat started. Type /exit to quit, /clear to reset history.\n")
|
|
43
|
+
while True:
|
|
44
|
+
try:
|
|
45
|
+
user_text = input(">> ").strip()
|
|
46
|
+
except (EOFError, KeyboardInterrupt):
|
|
47
|
+
print()
|
|
48
|
+
break
|
|
49
|
+
if not user_text:
|
|
50
|
+
continue
|
|
51
|
+
if user_text == "/exit":
|
|
52
|
+
break
|
|
53
|
+
if user_text == "/clear":
|
|
54
|
+
history.clear()
|
|
55
|
+
print("(history cleared)")
|
|
56
|
+
continue
|
|
57
|
+
respond(user_text)
|
troy/cli.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Troy CLI: fine-tune LLMs on your MacBook with one YAML file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="troy",
|
|
19
|
+
help="Fine-tune LLMs on your MacBook with one YAML file. Built for Apple Silicon.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
add_completion=False,
|
|
22
|
+
)
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _require_apple_silicon() -> None:
|
|
27
|
+
if platform.system() != "Darwin" or platform.machine() != "arm64":
|
|
28
|
+
console.print(
|
|
29
|
+
"[red]Troy runs on Apple Silicon Macs only (M1 or later).[/red]\n"
|
|
30
|
+
f"Detected: {platform.system()} / {platform.machine()}"
|
|
31
|
+
)
|
|
32
|
+
raise typer.Exit(1)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _version_callback(value: bool) -> None:
|
|
36
|
+
if value:
|
|
37
|
+
console.print(f"troy {__version__}")
|
|
38
|
+
raise typer.Exit()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.callback()
|
|
42
|
+
def _main(
|
|
43
|
+
version: bool = typer.Option(
|
|
44
|
+
False, "--version", "-V", help="Show version.",
|
|
45
|
+
callback=_version_callback, is_eager=True,
|
|
46
|
+
),
|
|
47
|
+
) -> None:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def init(
|
|
53
|
+
template: str = typer.Option(
|
|
54
|
+
"chat", help="Template: chat (SFT) or dpo (preference tuning)."
|
|
55
|
+
),
|
|
56
|
+
path: Path = typer.Option(Path("troy.yaml"), help="Where to write the config."),
|
|
57
|
+
force: bool = typer.Option(False, "--force", help="Overwrite an existing config."),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Create a troy.yaml config (plus sample data) to start from."""
|
|
60
|
+
from .templates import TEMPLATES
|
|
61
|
+
|
|
62
|
+
if template not in TEMPLATES:
|
|
63
|
+
console.print(f"[red]Unknown template `{template}`.[/red] Options: {', '.join(TEMPLATES)}")
|
|
64
|
+
raise typer.Exit(1)
|
|
65
|
+
if path.exists() and not force:
|
|
66
|
+
console.print(f"[red]{path} already exists.[/red] Use --force to overwrite.")
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
|
|
69
|
+
config_text, data_name, sample = TEMPLATES[template]
|
|
70
|
+
path.write_text(config_text)
|
|
71
|
+
|
|
72
|
+
data_dir = path.parent / "data"
|
|
73
|
+
data_file = data_dir / data_name
|
|
74
|
+
if not data_file.exists():
|
|
75
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
with open(data_file, "w") as f:
|
|
77
|
+
for record in sample:
|
|
78
|
+
f.write(json.dumps(record) + "\n")
|
|
79
|
+
console.print(f"Wrote sample data to [bold]{data_file}[/bold] — replace it with yours.")
|
|
80
|
+
|
|
81
|
+
console.print(f"Created [bold]{path}[/bold] ({template} template).")
|
|
82
|
+
console.print("Next: edit the config, then run [bold]troy train[/bold].")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command()
|
|
86
|
+
def doctor() -> None:
|
|
87
|
+
"""Check this Mac's readiness for local fine-tuning."""
|
|
88
|
+
from .hardware import detect, model_guidance
|
|
89
|
+
|
|
90
|
+
hw = detect()
|
|
91
|
+
table = Table(title="troy doctor", show_header=False)
|
|
92
|
+
table.add_column(style="bold")
|
|
93
|
+
table.add_column()
|
|
94
|
+
|
|
95
|
+
ok = "[green]OK[/green]"
|
|
96
|
+
fail = "[red]FAIL[/red]"
|
|
97
|
+
|
|
98
|
+
table.add_row("Chip", hw.chip)
|
|
99
|
+
table.add_row(
|
|
100
|
+
"Apple Silicon", ok if hw.is_apple_silicon else f"{fail} ({hw.arch})"
|
|
101
|
+
)
|
|
102
|
+
table.add_row("Unified memory", f"{hw.memory_gb:.0f} GB")
|
|
103
|
+
table.add_row("macOS", hw.macos)
|
|
104
|
+
table.add_row("Free disk", f"{hw.free_disk_gb:.0f} GB")
|
|
105
|
+
table.add_row("Python", platform.python_version())
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
import mlx.core as mx
|
|
109
|
+
|
|
110
|
+
table.add_row("MLX", f"{ok} (v{mx.__version__})")
|
|
111
|
+
gpu_ok = mx.default_device().type == mx.DeviceType.gpu
|
|
112
|
+
table.add_row("Metal GPU", ok if gpu_ok else f"{fail} (default device is CPU)")
|
|
113
|
+
except ImportError:
|
|
114
|
+
table.add_row("MLX", f"{fail} (not installed — `pip install mlx-lm`)")
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
import mlx_lm
|
|
118
|
+
|
|
119
|
+
table.add_row("mlx-lm", f"{ok} (v{mlx_lm.__version__})")
|
|
120
|
+
except ImportError:
|
|
121
|
+
table.add_row("mlx-lm", f"{fail} (not installed)")
|
|
122
|
+
|
|
123
|
+
table.add_row("Fine-tunable models", model_guidance(hw.memory_gb))
|
|
124
|
+
console.print(table)
|
|
125
|
+
|
|
126
|
+
if not hw.is_apple_silicon:
|
|
127
|
+
raise typer.Exit(1)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command()
|
|
131
|
+
def train(
|
|
132
|
+
config: Path = typer.Option(Path("troy.yaml"), "--config", "-c", help="Config file."),
|
|
133
|
+
) -> None:
|
|
134
|
+
"""Fine-tune a model from a troy.yaml config."""
|
|
135
|
+
_require_apple_silicon()
|
|
136
|
+
from .config import load_config
|
|
137
|
+
from .data import load_and_prepare
|
|
138
|
+
|
|
139
|
+
cfg = load_config(config)
|
|
140
|
+
train_records, valid_records, fmt = load_and_prepare(
|
|
141
|
+
cfg.data, cfg.task, cfg.training.seed
|
|
142
|
+
)
|
|
143
|
+
console.print(
|
|
144
|
+
f"Data: {len(train_records)} train / {len(valid_records)} valid "
|
|
145
|
+
f"(format: {fmt})"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if cfg.task == "sft":
|
|
149
|
+
from .train_sft import run_sft
|
|
150
|
+
|
|
151
|
+
run_sft(cfg, train_records, valid_records)
|
|
152
|
+
else:
|
|
153
|
+
from .train_dpo import run_dpo
|
|
154
|
+
|
|
155
|
+
run_dpo(cfg, train_records, valid_records)
|
|
156
|
+
|
|
157
|
+
console.print(
|
|
158
|
+
f"\nTry it: [bold]troy chat[/bold] | "
|
|
159
|
+
f"Export it: [bold]troy export[/bold]"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def chat(
|
|
165
|
+
config: Path = typer.Option(Path("troy.yaml"), "--config", "-c", help="Config file."),
|
|
166
|
+
model: Optional[str] = typer.Option(
|
|
167
|
+
None, help="Model path or HF repo (defaults to the config's base + trained adapter)."
|
|
168
|
+
),
|
|
169
|
+
base_only: bool = typer.Option(False, help="Chat with the base model, no adapter."),
|
|
170
|
+
max_tokens: int = typer.Option(512),
|
|
171
|
+
temperature: float = typer.Option(0.7),
|
|
172
|
+
prompt: Optional[str] = typer.Option(None, "--prompt", "-p", help="One-shot prompt (no REPL)."),
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Chat with your fine-tuned model."""
|
|
175
|
+
_require_apple_silicon()
|
|
176
|
+
from .chat import run_chat
|
|
177
|
+
|
|
178
|
+
adapter: Optional[str] = None
|
|
179
|
+
if model is None:
|
|
180
|
+
from .config import load_config
|
|
181
|
+
|
|
182
|
+
cfg = load_config(config)
|
|
183
|
+
model = cfg.base
|
|
184
|
+
if not base_only:
|
|
185
|
+
adapter_file = cfg.adapter_path / "adapters.safetensors"
|
|
186
|
+
if adapter_file.exists():
|
|
187
|
+
adapter = str(cfg.adapter_path)
|
|
188
|
+
else:
|
|
189
|
+
console.print(
|
|
190
|
+
"[yellow]No trained adapter found — chatting with the base model.[/yellow]"
|
|
191
|
+
)
|
|
192
|
+
run_chat(model, adapter, max_tokens, temperature, prompt)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@app.command()
|
|
196
|
+
def serve(
|
|
197
|
+
config: Path = typer.Option(Path("troy.yaml"), "--config", "-c", help="Config file."),
|
|
198
|
+
model: Optional[str] = typer.Option(
|
|
199
|
+
None, help="Model path or HF repo (defaults to the config's base + trained adapter)."
|
|
200
|
+
),
|
|
201
|
+
base_only: bool = typer.Option(False, help="Serve the base model, no adapter."),
|
|
202
|
+
host: str = typer.Option("127.0.0.1"),
|
|
203
|
+
port: int = typer.Option(8080),
|
|
204
|
+
max_tokens: int = typer.Option(512, help="Default max tokens per response."),
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Serve your model over an OpenAI-compatible API."""
|
|
207
|
+
_require_apple_silicon()
|
|
208
|
+
from .serve import run_serve
|
|
209
|
+
|
|
210
|
+
adapter: Optional[str] = None
|
|
211
|
+
if model is None:
|
|
212
|
+
from .config import load_config
|
|
213
|
+
|
|
214
|
+
cfg = load_config(config)
|
|
215
|
+
model = cfg.base
|
|
216
|
+
if not base_only:
|
|
217
|
+
adapter_file = cfg.adapter_path / "adapters.safetensors"
|
|
218
|
+
if adapter_file.exists():
|
|
219
|
+
adapter = str(cfg.adapter_path)
|
|
220
|
+
else:
|
|
221
|
+
console.print(
|
|
222
|
+
"[yellow]No trained adapter found — serving the base model.[/yellow]"
|
|
223
|
+
)
|
|
224
|
+
run_serve(model, adapter, host, port, max_tokens)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.command()
|
|
228
|
+
def export(
|
|
229
|
+
config: Path = typer.Option(Path("troy.yaml"), "--config", "-c", help="Config file."),
|
|
230
|
+
fmt: str = typer.Option("mlx", "--format", "-f", help="Export format: mlx or gguf."),
|
|
231
|
+
save_path: Optional[Path] = typer.Option(None, help="Output directory (default: <output>/fused)."),
|
|
232
|
+
dequantize: bool = typer.Option(False, help="Dequantize when fusing a quantized base."),
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Merge the trained adapter into the base model and export it."""
|
|
235
|
+
_require_apple_silicon()
|
|
236
|
+
if fmt not in ("mlx", "gguf"):
|
|
237
|
+
console.print("[red]--format must be `mlx` or `gguf`.[/red]")
|
|
238
|
+
raise typer.Exit(1)
|
|
239
|
+
from .config import load_config
|
|
240
|
+
from .export import run_export
|
|
241
|
+
|
|
242
|
+
cfg = load_config(config)
|
|
243
|
+
adapter_file = cfg.adapter_path / "adapters.safetensors"
|
|
244
|
+
if not adapter_file.exists():
|
|
245
|
+
console.print(f"[red]No adapter at {adapter_file}. Run `troy train` first.[/red]")
|
|
246
|
+
raise typer.Exit(1)
|
|
247
|
+
out = save_path or (cfg.output_path / "fused")
|
|
248
|
+
run_export(cfg.base, str(cfg.adapter_path), str(out), fmt, dequantize)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@app.command()
|
|
252
|
+
def data(
|
|
253
|
+
action: str = typer.Argument(help="Action: inspect"),
|
|
254
|
+
path: Path = typer.Argument(help="Dataset file (.jsonl, .json, .csv)."),
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Inspect a dataset: record count, detected format, sizes."""
|
|
257
|
+
if action != "inspect":
|
|
258
|
+
console.print("[red]Only `troy data inspect <path>` is supported.[/red]")
|
|
259
|
+
raise typer.Exit(1)
|
|
260
|
+
from .data import inspect_stats
|
|
261
|
+
|
|
262
|
+
stats = inspect_stats(str(path))
|
|
263
|
+
table = Table(title=str(path), show_header=False)
|
|
264
|
+
table.add_column(style="bold")
|
|
265
|
+
table.add_column()
|
|
266
|
+
table.add_row("Records", str(stats["records"]))
|
|
267
|
+
table.add_row("Detected format", stats["format"])
|
|
268
|
+
table.add_row("Avg record size", f"{stats['avg_chars']:.0f} chars")
|
|
269
|
+
table.add_row("Max record size", f"{stats['max_chars']} chars")
|
|
270
|
+
console.print(table)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
app()
|
troy/config.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Troy YAML config schema and loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal, Optional, Union
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LoraConfig(BaseModel):
|
|
13
|
+
r: int = 8
|
|
14
|
+
alpha: float = 16.0
|
|
15
|
+
dropout: float = 0.0
|
|
16
|
+
layers: Union[int, Literal["all"]] = 16 # decoder layers to adapt
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def scale(self) -> float:
|
|
20
|
+
return self.alpha / self.r
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DpoConfig(BaseModel):
|
|
24
|
+
beta: float = 0.1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DataConfig(BaseModel):
|
|
28
|
+
train: str
|
|
29
|
+
valid: Optional[str] = None
|
|
30
|
+
format: Literal[
|
|
31
|
+
"auto", "alpaca", "sharegpt", "chat", "completions", "text", "preference"
|
|
32
|
+
] = "auto"
|
|
33
|
+
val_split: float = Field(0.1, ge=0.0, lt=1.0)
|
|
34
|
+
mask_prompt: bool = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TrainingConfig(BaseModel):
|
|
38
|
+
epochs: Optional[float] = None # translated to iters from dataset size
|
|
39
|
+
iters: Optional[int] = None # takes precedence over epochs
|
|
40
|
+
lr: float = 1e-5
|
|
41
|
+
batch_size: Union[int, Literal["auto"]] = "auto"
|
|
42
|
+
seq_len: int = 2048
|
|
43
|
+
lora: LoraConfig = LoraConfig()
|
|
44
|
+
dpo: DpoConfig = DpoConfig()
|
|
45
|
+
grad_checkpoint: bool = False
|
|
46
|
+
grad_accumulation_steps: int = 1
|
|
47
|
+
save_every: int = 100
|
|
48
|
+
seed: int = 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TroyConfig(BaseModel):
|
|
52
|
+
base: str
|
|
53
|
+
task: Literal["sft", "dpo"] = "sft"
|
|
54
|
+
data: DataConfig
|
|
55
|
+
training: TrainingConfig = TrainingConfig()
|
|
56
|
+
output: str = "./output"
|
|
57
|
+
|
|
58
|
+
@field_validator("base")
|
|
59
|
+
@classmethod
|
|
60
|
+
def _non_empty(cls, v: str) -> str:
|
|
61
|
+
if not v.strip():
|
|
62
|
+
raise ValueError("`base` must be a HuggingFace repo id or local path")
|
|
63
|
+
return v
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def output_path(self) -> Path:
|
|
67
|
+
return Path(self.output).expanduser()
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def adapter_path(self) -> Path:
|
|
71
|
+
return self.output_path / "adapter"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_config(path: Union[str, Path]) -> TroyConfig:
|
|
75
|
+
path = Path(path)
|
|
76
|
+
if not path.exists():
|
|
77
|
+
raise FileNotFoundError(
|
|
78
|
+
f"Config not found: {path}. Run `troy init` to create one."
|
|
79
|
+
)
|
|
80
|
+
with open(path) as f:
|
|
81
|
+
raw = yaml.safe_load(f) or {}
|
|
82
|
+
return TroyConfig.model_validate(raw)
|
troy/data.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Dataset loading, format auto-detection, and conversion to mlx-lm formats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from .config import DataConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read_records(path: Path) -> List[Dict[str, Any]]:
|
|
15
|
+
suffix = path.suffix.lower()
|
|
16
|
+
if suffix == ".jsonl":
|
|
17
|
+
with open(path) as f:
|
|
18
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
19
|
+
if suffix == ".json":
|
|
20
|
+
with open(path) as f:
|
|
21
|
+
data = json.load(f)
|
|
22
|
+
if not isinstance(data, list):
|
|
23
|
+
raise ValueError(f"{path}: JSON file must contain a list of records")
|
|
24
|
+
return data
|
|
25
|
+
if suffix == ".csv":
|
|
26
|
+
with open(path, newline="") as f:
|
|
27
|
+
return list(csv.DictReader(f))
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Unsupported file type: {path.suffix} (use .jsonl, .json, or .csv)"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def detect_format(sample: Dict[str, Any]) -> str:
|
|
34
|
+
keys = set(sample)
|
|
35
|
+
if {"prompt", "chosen", "rejected"} <= keys:
|
|
36
|
+
return "preference"
|
|
37
|
+
if "messages" in keys:
|
|
38
|
+
return "chat"
|
|
39
|
+
if "conversations" in keys:
|
|
40
|
+
return "sharegpt"
|
|
41
|
+
if {"instruction", "output"} <= keys:
|
|
42
|
+
return "alpaca"
|
|
43
|
+
if {"prompt", "completion"} <= keys:
|
|
44
|
+
return "completions"
|
|
45
|
+
if "text" in keys:
|
|
46
|
+
return "text"
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Could not detect data format from keys: {sorted(keys)}. "
|
|
49
|
+
"Set `data.format` explicitly in troy.yaml."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_SHAREGPT_ROLES = {
|
|
54
|
+
"human": "user",
|
|
55
|
+
"user": "user",
|
|
56
|
+
"gpt": "assistant",
|
|
57
|
+
"assistant": "assistant",
|
|
58
|
+
"system": "system",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _to_messages(record: Dict[str, Any], fmt: str) -> Dict[str, Any]:
|
|
63
|
+
"""Normalize an SFT record to mlx-lm chat format ({"messages": [...]})."""
|
|
64
|
+
if fmt == "chat":
|
|
65
|
+
return {"messages": record["messages"]}
|
|
66
|
+
if fmt == "sharegpt":
|
|
67
|
+
messages = [
|
|
68
|
+
{"role": _SHAREGPT_ROLES[m["from"].lower()], "content": m["value"]}
|
|
69
|
+
for m in record["conversations"]
|
|
70
|
+
]
|
|
71
|
+
return {"messages": messages}
|
|
72
|
+
if fmt == "alpaca":
|
|
73
|
+
user = record["instruction"]
|
|
74
|
+
if record.get("input"):
|
|
75
|
+
user = f"{user}\n\n{record['input']}"
|
|
76
|
+
messages = []
|
|
77
|
+
if record.get("system"):
|
|
78
|
+
messages.append({"role": "system", "content": record["system"]})
|
|
79
|
+
messages += [
|
|
80
|
+
{"role": "user", "content": user},
|
|
81
|
+
{"role": "assistant", "content": record["output"]},
|
|
82
|
+
]
|
|
83
|
+
return {"messages": messages}
|
|
84
|
+
if fmt in ("completions", "text"):
|
|
85
|
+
return record # already an mlx-lm native format
|
|
86
|
+
raise ValueError(f"Unexpected format: {fmt}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _normalize_preference(record: Dict[str, Any]) -> Dict[str, str]:
|
|
90
|
+
"""Normalize a DPO record to string prompt/chosen/rejected."""
|
|
91
|
+
|
|
92
|
+
def text_of(v: Any) -> str:
|
|
93
|
+
if isinstance(v, str):
|
|
94
|
+
return v
|
|
95
|
+
if isinstance(v, list): # message-list style
|
|
96
|
+
return "\n".join(m.get("content", "") for m in v)
|
|
97
|
+
raise ValueError(f"Cannot interpret preference field: {v!r}")
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
"prompt": text_of(record["prompt"]),
|
|
101
|
+
"chosen": text_of(record["chosen"]),
|
|
102
|
+
"rejected": text_of(record["rejected"]),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_and_prepare(
|
|
107
|
+
cfg: DataConfig, task: str, seed: int = 0
|
|
108
|
+
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], str]:
|
|
109
|
+
"""Load train/valid records normalized for the task.
|
|
110
|
+
|
|
111
|
+
Returns (train_records, valid_records, detected_format).
|
|
112
|
+
"""
|
|
113
|
+
train_path = Path(cfg.train).expanduser()
|
|
114
|
+
records = _read_records(train_path)
|
|
115
|
+
if not records:
|
|
116
|
+
raise ValueError(f"No records found in {train_path}")
|
|
117
|
+
|
|
118
|
+
fmt = cfg.format if cfg.format != "auto" else detect_format(records[0])
|
|
119
|
+
|
|
120
|
+
if task == "dpo" and fmt != "preference":
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Task `dpo` needs preference data (prompt/chosen/rejected); "
|
|
123
|
+
f"detected format `{fmt}`."
|
|
124
|
+
)
|
|
125
|
+
if task == "sft" and fmt == "preference":
|
|
126
|
+
raise ValueError("Preference data detected — set `task: dpo` in troy.yaml.")
|
|
127
|
+
|
|
128
|
+
if fmt == "preference":
|
|
129
|
+
records = [_normalize_preference(r) for r in records]
|
|
130
|
+
else:
|
|
131
|
+
records = [_to_messages(r, fmt) for r in records]
|
|
132
|
+
|
|
133
|
+
if cfg.valid:
|
|
134
|
+
valid = _read_records(Path(cfg.valid).expanduser())
|
|
135
|
+
valid = (
|
|
136
|
+
[_normalize_preference(r) for r in valid]
|
|
137
|
+
if fmt == "preference"
|
|
138
|
+
else [_to_messages(r, fmt) for r in valid]
|
|
139
|
+
)
|
|
140
|
+
return records, valid, fmt
|
|
141
|
+
|
|
142
|
+
# Split off validation
|
|
143
|
+
rng = random.Random(seed)
|
|
144
|
+
indices = list(range(len(records)))
|
|
145
|
+
rng.shuffle(indices)
|
|
146
|
+
n_val = int(len(records) * cfg.val_split)
|
|
147
|
+
val_idx = set(indices[:n_val])
|
|
148
|
+
train = [r for i, r in enumerate(records) if i not in val_idx]
|
|
149
|
+
valid = [r for i, r in enumerate(records) if i in val_idx]
|
|
150
|
+
return train, valid, fmt
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def inspect_stats(path: str) -> Dict[str, Any]:
|
|
154
|
+
"""Lightweight dataset statistics for `troy data inspect`-style output."""
|
|
155
|
+
records = _read_records(Path(path).expanduser())
|
|
156
|
+
fmt = detect_format(records[0]) if records else "unknown"
|
|
157
|
+
lengths = [len(json.dumps(r)) for r in records]
|
|
158
|
+
return {
|
|
159
|
+
"records": len(records),
|
|
160
|
+
"format": fmt,
|
|
161
|
+
"avg_chars": sum(lengths) / max(len(lengths), 1),
|
|
162
|
+
"max_chars": max(lengths, default=0),
|
|
163
|
+
}
|
troy/export.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Merge adapters and export to deployment formats (MLX, GGUF)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _fuse(base: str, adapter_path: str, save_path: str, dequantize: bool) -> None:
|
|
11
|
+
cmd = [
|
|
12
|
+
sys.executable,
|
|
13
|
+
"-m",
|
|
14
|
+
"mlx_lm",
|
|
15
|
+
"fuse",
|
|
16
|
+
"--model",
|
|
17
|
+
base,
|
|
18
|
+
"--adapter-path",
|
|
19
|
+
adapter_path,
|
|
20
|
+
"--save-path",
|
|
21
|
+
save_path,
|
|
22
|
+
]
|
|
23
|
+
if dequantize:
|
|
24
|
+
cmd.append("--dequantize")
|
|
25
|
+
print("Fusing adapter into base model ...")
|
|
26
|
+
result = subprocess.run(cmd)
|
|
27
|
+
if result.returncode != 0:
|
|
28
|
+
raise SystemExit(result.returncode)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _export_gguf(save_path: Path) -> Path:
|
|
32
|
+
"""Convert a fused MLX model directory to GGUF (llama/mistral/mixtral archs)."""
|
|
33
|
+
import json
|
|
34
|
+
|
|
35
|
+
import mlx.core as mx
|
|
36
|
+
from mlx_lm import gguf as gguf_mod
|
|
37
|
+
|
|
38
|
+
with open(save_path / "config.json") as f:
|
|
39
|
+
config = json.load(f)
|
|
40
|
+
|
|
41
|
+
weights = {}
|
|
42
|
+
for part in sorted(save_path.glob("*.safetensors")):
|
|
43
|
+
weights.update(mx.load(str(part)))
|
|
44
|
+
|
|
45
|
+
# mlx-lm's converter permutes attention weights into non-contiguous views,
|
|
46
|
+
# which save_gguf rejects — force contiguity on its output.
|
|
47
|
+
orig_permute = gguf_mod.permute_weights
|
|
48
|
+
gguf_mod.permute_weights = lambda *a, **k: mx.contiguous(orig_permute(*a, **k))
|
|
49
|
+
try:
|
|
50
|
+
out = save_path / "ggml-model-f16.gguf"
|
|
51
|
+
gguf_mod.convert_to_gguf(save_path, weights, config, str(out))
|
|
52
|
+
finally:
|
|
53
|
+
gguf_mod.permute_weights = orig_permute
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_export(
|
|
58
|
+
base: str,
|
|
59
|
+
adapter_path: str,
|
|
60
|
+
save_path: str,
|
|
61
|
+
fmt: str = "mlx",
|
|
62
|
+
dequantize: bool = False,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Fuse a LoRA adapter into the base model; optionally convert to GGUF.
|
|
65
|
+
|
|
66
|
+
fmt: "mlx" (fused MLX weights) or "gguf" (also writes ggml-model-f16.gguf
|
|
67
|
+
for llama.cpp / Ollama / LM Studio; llama, mistral and mixtral
|
|
68
|
+
architectures, unquantized base).
|
|
69
|
+
"""
|
|
70
|
+
if not Path(base).exists():
|
|
71
|
+
# mlx-lm's training path downloads a partial snapshot; fuse checks for
|
|
72
|
+
# a complete one. Top it up before fusing.
|
|
73
|
+
from huggingface_hub import snapshot_download
|
|
74
|
+
|
|
75
|
+
snapshot_download(base)
|
|
76
|
+
|
|
77
|
+
_fuse(base, adapter_path, save_path, dequantize)
|
|
78
|
+
print(f"Fused model: {Path(save_path).resolve()}")
|
|
79
|
+
if fmt == "gguf":
|
|
80
|
+
out = _export_gguf(Path(save_path))
|
|
81
|
+
print(f"GGUF: {out.resolve()}")
|
troy/hardware.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Apple Silicon hardware detection and guidance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sysctl(key: str) -> str:
|
|
12
|
+
try:
|
|
13
|
+
return subprocess.run(
|
|
14
|
+
["sysctl", "-n", key], capture_output=True, text=True, timeout=5
|
|
15
|
+
).stdout.strip()
|
|
16
|
+
except Exception:
|
|
17
|
+
return ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Hardware:
|
|
22
|
+
chip: str
|
|
23
|
+
memory_gb: float
|
|
24
|
+
macos: str
|
|
25
|
+
arch: str
|
|
26
|
+
free_disk_gb: float
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_apple_silicon(self) -> bool:
|
|
30
|
+
return self.arch == "arm64" and platform.system() == "Darwin"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def detect() -> Hardware:
|
|
34
|
+
mem_bytes = int(_sysctl("hw.memsize") or 0)
|
|
35
|
+
total, used, free = shutil.disk_usage("/")
|
|
36
|
+
return Hardware(
|
|
37
|
+
chip=_sysctl("machdep.cpu.brand_string") or "unknown",
|
|
38
|
+
memory_gb=mem_bytes / 2**30,
|
|
39
|
+
macos=platform.mac_ver()[0] or "unknown",
|
|
40
|
+
arch=platform.machine(),
|
|
41
|
+
free_disk_gb=free / 2**30,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# (min unified memory GB, guidance) — QLoRA 4-bit fine-tuning headroom.
|
|
46
|
+
MODEL_GUIDANCE = [
|
|
47
|
+
(128, "up to ~70B (4-bit QLoRA), e.g. Llama-3.3-70B, Qwen2.5-72B"),
|
|
48
|
+
(64, "up to ~32B (4-bit QLoRA), e.g. Qwen2.5-32B, Gemma-2-27B"),
|
|
49
|
+
(36, "up to ~14B (4-bit QLoRA), e.g. Qwen2.5-14B, Phi-4"),
|
|
50
|
+
(24, "up to ~8B (4-bit QLoRA), e.g. Llama-3.1-8B, Qwen3-8B"),
|
|
51
|
+
(16, "up to ~4B (4-bit QLoRA), e.g. Qwen3-4B, Phi-3.5-mini, Gemma-3-4B"),
|
|
52
|
+
(8, "up to ~1.5B (4-bit QLoRA), e.g. Qwen3-0.6B, Llama-3.2-1B"),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def model_guidance(memory_gb: float) -> str:
|
|
57
|
+
for min_gb, text in MODEL_GUIDANCE:
|
|
58
|
+
if memory_gb >= min_gb:
|
|
59
|
+
return text
|
|
60
|
+
return "very small models only (<1B)"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def auto_batch_size(memory_gb: float, seq_len: int) -> int:
|
|
64
|
+
"""Conservative batch size from unified memory and sequence length."""
|
|
65
|
+
budget = max(memory_gb - 8, 1) # leave room for the OS + model weights
|
|
66
|
+
per_seq = seq_len / 2048 # rough scaling
|
|
67
|
+
bs = int(budget // (4 * per_seq))
|
|
68
|
+
return max(1, min(bs, 8))
|
troy/serve.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""OpenAI-compatible local API server (wraps the mlx-lm HTTP server)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_serve(
|
|
11
|
+
model: str,
|
|
12
|
+
adapter_path: Optional[str],
|
|
13
|
+
host: str,
|
|
14
|
+
port: int,
|
|
15
|
+
max_tokens: int,
|
|
16
|
+
) -> None:
|
|
17
|
+
cmd = [
|
|
18
|
+
sys.executable,
|
|
19
|
+
"-m",
|
|
20
|
+
"mlx_lm",
|
|
21
|
+
"server",
|
|
22
|
+
"--model",
|
|
23
|
+
model,
|
|
24
|
+
"--host",
|
|
25
|
+
host,
|
|
26
|
+
"--port",
|
|
27
|
+
str(port),
|
|
28
|
+
"--max-tokens",
|
|
29
|
+
str(max_tokens),
|
|
30
|
+
]
|
|
31
|
+
if adapter_path:
|
|
32
|
+
cmd += ["--adapter-path", adapter_path]
|
|
33
|
+
|
|
34
|
+
print(f"Serving {model}" + (f" + adapter {adapter_path}" if adapter_path else ""))
|
|
35
|
+
print(f"OpenAI-compatible API at http://{host}:{port}/v1")
|
|
36
|
+
print(
|
|
37
|
+
"Try it:\n"
|
|
38
|
+
f' curl http://{host}:{port}/v1/chat/completions \\\n'
|
|
39
|
+
' -H "Content-Type: application/json" \\\n'
|
|
40
|
+
' -d \'{"messages": [{"role": "user", "content": "Hello"}]}\'\n'
|
|
41
|
+
)
|
|
42
|
+
try:
|
|
43
|
+
raise SystemExit(subprocess.run(cmd).returncode)
|
|
44
|
+
except KeyboardInterrupt:
|
|
45
|
+
print("\nServer stopped.")
|
troy/templates.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Config and sample-data templates for `troy init`."""
|
|
2
|
+
|
|
3
|
+
CHAT_TEMPLATE = """\
|
|
4
|
+
# Troy config — supervised fine-tuning (SFT)
|
|
5
|
+
# Run with: troy train
|
|
6
|
+
|
|
7
|
+
base: mlx-community/Qwen3-0.6B-4bit # any MLX model on the HF Hub, or a local path
|
|
8
|
+
task: sft
|
|
9
|
+
|
|
10
|
+
data:
|
|
11
|
+
train: ./data/train.jsonl # alpaca, sharegpt, chat, completions, or text — auto-detected
|
|
12
|
+
format: auto
|
|
13
|
+
val_split: 0.1
|
|
14
|
+
|
|
15
|
+
training:
|
|
16
|
+
epochs: 3
|
|
17
|
+
lr: 1e-5
|
|
18
|
+
batch_size: auto # sized from your Mac's unified memory
|
|
19
|
+
seq_len: 2048
|
|
20
|
+
lora:
|
|
21
|
+
r: 8
|
|
22
|
+
alpha: 16
|
|
23
|
+
|
|
24
|
+
output: ./output
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
DPO_TEMPLATE = """\
|
|
28
|
+
# Troy config — preference tuning (DPO)
|
|
29
|
+
# Run with: troy train
|
|
30
|
+
|
|
31
|
+
base: mlx-community/Qwen3-0.6B-4bit
|
|
32
|
+
task: dpo
|
|
33
|
+
|
|
34
|
+
data:
|
|
35
|
+
train: ./data/preferences.jsonl # {"prompt": ..., "chosen": ..., "rejected": ...}
|
|
36
|
+
format: preference
|
|
37
|
+
val_split: 0.05
|
|
38
|
+
|
|
39
|
+
training:
|
|
40
|
+
epochs: 1
|
|
41
|
+
lr: 5e-6
|
|
42
|
+
batch_size: auto
|
|
43
|
+
seq_len: 2048
|
|
44
|
+
lora:
|
|
45
|
+
r: 8
|
|
46
|
+
alpha: 16
|
|
47
|
+
dpo:
|
|
48
|
+
beta: 0.1
|
|
49
|
+
|
|
50
|
+
output: ./output
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
SAMPLE_SFT_DATA = [
|
|
54
|
+
{
|
|
55
|
+
"instruction": "What is the capital of France?",
|
|
56
|
+
"output": "The capital of France is Paris.",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"instruction": "Write a haiku about the ocean.",
|
|
60
|
+
"output": "Endless blue expanse\nWaves whisper against the shore\nSalt hangs in the air",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"instruction": "Explain what a LoRA adapter is in one sentence.",
|
|
64
|
+
"output": "A LoRA adapter is a small set of low-rank matrices trained alongside a frozen model so it can be specialized cheaply.",
|
|
65
|
+
},
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
SAMPLE_DPO_DATA = [
|
|
69
|
+
{
|
|
70
|
+
"prompt": "What is the capital of France?",
|
|
71
|
+
"chosen": "The capital of France is Paris.",
|
|
72
|
+
"rejected": "I think it might be Lyon, but I'm not sure.",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"prompt": "Summarize photosynthesis in one sentence.",
|
|
76
|
+
"chosen": "Photosynthesis is the process by which plants convert sunlight, water, and carbon dioxide into glucose and oxygen.",
|
|
77
|
+
"rejected": "Plants eat sunlight.",
|
|
78
|
+
},
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
TEMPLATES = {
|
|
82
|
+
"chat": (CHAT_TEMPLATE, "train.jsonl", SAMPLE_SFT_DATA),
|
|
83
|
+
"dpo": (DPO_TEMPLATE, "preferences.jsonl", SAMPLE_DPO_DATA),
|
|
84
|
+
}
|
troy/train_dpo.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Direct Preference Optimization on Apple Silicon.
|
|
2
|
+
|
|
3
|
+
Memory trick: with LoRA, the frozen reference model is the policy model with
|
|
4
|
+
every adapter's scale set to 0 — so DPO needs no second copy of the weights,
|
|
5
|
+
which matters on unified memory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, List, Tuple
|
|
12
|
+
|
|
13
|
+
import mlx.core as mx
|
|
14
|
+
import mlx.nn as nn
|
|
15
|
+
import mlx.optimizers as optim
|
|
16
|
+
from mlx.utils import tree_flatten
|
|
17
|
+
from mlx_lm.tuner.trainer import grad_checkpoint
|
|
18
|
+
from mlx_lm.tuner.utils import print_trainable_parameters
|
|
19
|
+
from mlx_lm.utils import load
|
|
20
|
+
|
|
21
|
+
from .config import TroyConfig
|
|
22
|
+
from .train_sft import apply_lora, resolve_batch_size, resolve_iters, save_adapter_config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _lora_modules(model) -> list:
|
|
26
|
+
return [
|
|
27
|
+
m
|
|
28
|
+
for _, m in model.named_modules()
|
|
29
|
+
if hasattr(m, "lora_a") and hasattr(m, "scale")
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _ReferenceMode:
|
|
34
|
+
"""Context manager: zero every LoRA scale so the model acts as the frozen base."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, model):
|
|
37
|
+
self.modules = _lora_modules(model)
|
|
38
|
+
self.saved: List[float] = []
|
|
39
|
+
|
|
40
|
+
def __enter__(self):
|
|
41
|
+
self.saved = [m.scale for m in self.modules]
|
|
42
|
+
for m in self.modules:
|
|
43
|
+
m.scale = 0.0
|
|
44
|
+
|
|
45
|
+
def __exit__(self, *exc):
|
|
46
|
+
for m, s in zip(self.modules, self.saved):
|
|
47
|
+
m.scale = s
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _encode_pair(tokenizer, prompt: str, completion: str, max_len: int) -> Tuple[List[int], int]:
|
|
51
|
+
"""Tokenize prompt+completion with the chat template; return (tokens, prompt_len)."""
|
|
52
|
+
messages = [{"role": "user", "content": prompt}]
|
|
53
|
+
prompt_tokens = tokenizer.apply_chat_template(
|
|
54
|
+
messages, add_generation_prompt=True, return_dict=False
|
|
55
|
+
)
|
|
56
|
+
full = messages + [{"role": "assistant", "content": completion}]
|
|
57
|
+
full_tokens = tokenizer.apply_chat_template(full, return_dict=False)
|
|
58
|
+
full_tokens = full_tokens[:max_len]
|
|
59
|
+
prompt_len = min(len(prompt_tokens), len(full_tokens) - 1)
|
|
60
|
+
return full_tokens, prompt_len
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _batch(
|
|
64
|
+
pairs: List[Tuple[List[int], int]], pad_id: int
|
|
65
|
+
) -> Tuple[mx.array, mx.array]:
|
|
66
|
+
"""Pad a list of (tokens, prompt_len) into (tokens, completion_mask) arrays."""
|
|
67
|
+
max_len = max(len(t) for t, _ in pairs)
|
|
68
|
+
tokens, mask = [], []
|
|
69
|
+
for t, plen in pairs:
|
|
70
|
+
pad = max_len - len(t)
|
|
71
|
+
tokens.append(list(t) + [pad_id] * pad)
|
|
72
|
+
# mask over positions whose *target* token is part of the completion
|
|
73
|
+
m = [1.0 if plen <= j < len(t) else 0.0 for j in range(1, max_len)]
|
|
74
|
+
mask.append(m)
|
|
75
|
+
return mx.array(tokens), mx.array(mask)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _sequence_logps(model, tokens: mx.array, mask: mx.array) -> mx.array:
|
|
79
|
+
"""Sum of per-token log-probs over the completion for each sequence."""
|
|
80
|
+
logits = model(tokens[:, :-1])
|
|
81
|
+
targets = tokens[:, 1:]
|
|
82
|
+
logps = -nn.losses.cross_entropy(logits, targets, reduction="none")
|
|
83
|
+
return (logps * mask).sum(axis=-1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def run_dpo(
|
|
87
|
+
config: TroyConfig,
|
|
88
|
+
train_records: List[Dict[str, Any]],
|
|
89
|
+
valid_records: List[Dict[str, Any]],
|
|
90
|
+
) -> None:
|
|
91
|
+
mx.random.seed(config.training.seed)
|
|
92
|
+
print(f"Loading {config.base} ...")
|
|
93
|
+
model, tokenizer = load(config.base)
|
|
94
|
+
|
|
95
|
+
batch_size = resolve_batch_size(config)
|
|
96
|
+
# DPO runs chosen+rejected per example: halve the auto batch size
|
|
97
|
+
if config.training.batch_size == "auto":
|
|
98
|
+
batch_size = max(1, batch_size // 2)
|
|
99
|
+
iters = resolve_iters(config, len(train_records), batch_size)
|
|
100
|
+
num_layers = apply_lora(model, config)
|
|
101
|
+
save_adapter_config(config, num_layers)
|
|
102
|
+
if config.training.grad_checkpoint:
|
|
103
|
+
grad_checkpoint(model.layers[0])
|
|
104
|
+
|
|
105
|
+
beta = config.training.dpo.beta
|
|
106
|
+
max_len = config.training.seq_len
|
|
107
|
+
pad_id = tokenizer.pad_token_id or tokenizer.eos_token_id or 0
|
|
108
|
+
|
|
109
|
+
encoded = [
|
|
110
|
+
(
|
|
111
|
+
_encode_pair(tokenizer, r["prompt"], r["chosen"], max_len),
|
|
112
|
+
_encode_pair(tokenizer, r["prompt"], r["rejected"], max_len),
|
|
113
|
+
)
|
|
114
|
+
for r in train_records
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
def loss_fn(model, tc, mc, tr, mr, ref_c, ref_r):
|
|
118
|
+
pol_c = _sequence_logps(model, tc, mc)
|
|
119
|
+
pol_r = _sequence_logps(model, tr, mr)
|
|
120
|
+
logits = beta * ((pol_c - ref_c) - (pol_r - ref_r))
|
|
121
|
+
loss = -nn.log_sigmoid(logits).mean()
|
|
122
|
+
reward_acc = (logits > 0).mean()
|
|
123
|
+
return loss, reward_acc
|
|
124
|
+
|
|
125
|
+
loss_and_grad = nn.value_and_grad(model, loss_fn)
|
|
126
|
+
opt = optim.Adam(learning_rate=config.training.lr)
|
|
127
|
+
|
|
128
|
+
print(
|
|
129
|
+
f"Training: task=dpo beta={beta} batch_size={batch_size} iters={iters} "
|
|
130
|
+
f"lr={config.training.lr} pairs={len(encoded)}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
config.adapter_path.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
adapter_file = config.adapter_path / "adapters.safetensors"
|
|
135
|
+
n = len(encoded)
|
|
136
|
+
losses, accs = [], []
|
|
137
|
+
start = time.time()
|
|
138
|
+
|
|
139
|
+
for it in range(iters):
|
|
140
|
+
idx = [(it * batch_size + k) % n for k in range(batch_size)]
|
|
141
|
+
chosen = [encoded[i][0] for i in idx]
|
|
142
|
+
rejected = [encoded[i][1] for i in idx]
|
|
143
|
+
tc, mc = _batch(chosen, pad_id)
|
|
144
|
+
tr, mr = _batch(rejected, pad_id)
|
|
145
|
+
|
|
146
|
+
with _ReferenceMode(model):
|
|
147
|
+
ref_c = mx.stop_gradient(_sequence_logps(model, tc, mc))
|
|
148
|
+
ref_r = mx.stop_gradient(_sequence_logps(model, tr, mr))
|
|
149
|
+
mx.eval(ref_c, ref_r)
|
|
150
|
+
|
|
151
|
+
(loss, acc), grads = loss_and_grad(model, tc, mc, tr, mr, ref_c, ref_r)
|
|
152
|
+
opt.update(model, grads)
|
|
153
|
+
mx.eval(model.parameters(), opt.state, loss)
|
|
154
|
+
losses.append(loss.item())
|
|
155
|
+
accs.append(acc.item())
|
|
156
|
+
|
|
157
|
+
if (it + 1) % 10 == 0 or it == iters - 1:
|
|
158
|
+
speed = (it + 1) / (time.time() - start)
|
|
159
|
+
print(
|
|
160
|
+
f"Iter {it + 1}/{iters}: loss {sum(losses)/len(losses):.4f}, "
|
|
161
|
+
f"reward acc {sum(accs)/len(accs):.3f}, {speed:.2f} it/s"
|
|
162
|
+
)
|
|
163
|
+
losses, accs = [], []
|
|
164
|
+
|
|
165
|
+
if (it + 1) % config.training.save_every == 0:
|
|
166
|
+
_save(model, adapter_file)
|
|
167
|
+
|
|
168
|
+
_save(model, adapter_file)
|
|
169
|
+
print(f"\nDone. Adapter saved to {config.adapter_path}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _save(model, adapter_file) -> None:
|
|
173
|
+
adapter_weights = dict(tree_flatten(model.trainable_parameters()))
|
|
174
|
+
mx.save_safetensors(str(adapter_file), adapter_weights)
|
troy/train_sft.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Supervised fine-tuning via the mlx-lm tuner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import types
|
|
8
|
+
from typing import Any, Dict, List
|
|
9
|
+
|
|
10
|
+
import mlx.core as mx
|
|
11
|
+
import mlx.optimizers as optim
|
|
12
|
+
from mlx_lm.tuner.datasets import CacheDataset, create_dataset
|
|
13
|
+
from mlx_lm.tuner.trainer import TrainingArgs, train
|
|
14
|
+
from mlx_lm.tuner.utils import linear_to_lora_layers, print_trainable_parameters
|
|
15
|
+
from mlx_lm.utils import load
|
|
16
|
+
|
|
17
|
+
from .config import TroyConfig
|
|
18
|
+
from .hardware import auto_batch_size, detect
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_batch_size(config: TroyConfig) -> int:
|
|
22
|
+
if config.training.batch_size == "auto":
|
|
23
|
+
return auto_batch_size(detect().memory_gb, config.training.seq_len)
|
|
24
|
+
return int(config.training.batch_size)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_iters(config: TroyConfig, n_train: int, batch_size: int) -> int:
|
|
28
|
+
t = config.training
|
|
29
|
+
if t.iters:
|
|
30
|
+
return t.iters
|
|
31
|
+
epochs = t.epochs or 3
|
|
32
|
+
return max(1, math.ceil(epochs * n_train / batch_size))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def apply_lora(model, config: TroyConfig):
|
|
36
|
+
model.freeze()
|
|
37
|
+
lora = config.training.lora
|
|
38
|
+
n_model_layers = len(model.layers)
|
|
39
|
+
num_layers = (
|
|
40
|
+
n_model_layers if lora.layers == "all" else min(int(lora.layers), n_model_layers)
|
|
41
|
+
)
|
|
42
|
+
linear_to_lora_layers(
|
|
43
|
+
model,
|
|
44
|
+
num_layers,
|
|
45
|
+
{"rank": lora.r, "scale": lora.scale, "dropout": lora.dropout},
|
|
46
|
+
)
|
|
47
|
+
print_trainable_parameters(model)
|
|
48
|
+
return num_layers
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save_adapter_config(config: TroyConfig, num_layers: int) -> None:
|
|
52
|
+
"""Write the adapter_config.json mlx-lm needs to re-load the adapter."""
|
|
53
|
+
lora = config.training.lora
|
|
54
|
+
config.adapter_path.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
adapter_config = {
|
|
56
|
+
"fine_tune_type": "lora",
|
|
57
|
+
"num_layers": num_layers,
|
|
58
|
+
"lora_parameters": {
|
|
59
|
+
"rank": lora.r,
|
|
60
|
+
"scale": lora.scale,
|
|
61
|
+
"dropout": lora.dropout,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
with open(config.adapter_path / "adapter_config.json", "w") as f:
|
|
65
|
+
json.dump(adapter_config, f, indent=2)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_sft(
|
|
69
|
+
config: TroyConfig,
|
|
70
|
+
train_records: List[Dict[str, Any]],
|
|
71
|
+
valid_records: List[Dict[str, Any]],
|
|
72
|
+
) -> None:
|
|
73
|
+
mx.random.seed(config.training.seed)
|
|
74
|
+
print(f"Loading {config.base} ...")
|
|
75
|
+
model, tokenizer = load(config.base)
|
|
76
|
+
|
|
77
|
+
if not valid_records: # trainer always evaluates; give it something tiny
|
|
78
|
+
valid_records = train_records[:1]
|
|
79
|
+
ds_config = types.SimpleNamespace(mask_prompt=config.data.mask_prompt)
|
|
80
|
+
train_set = create_dataset(train_records, tokenizer, ds_config)
|
|
81
|
+
valid_set = create_dataset(valid_records, tokenizer, ds_config)
|
|
82
|
+
|
|
83
|
+
batch_size = resolve_batch_size(config)
|
|
84
|
+
batch_size = max(1, min(batch_size, len(train_records), len(valid_records)))
|
|
85
|
+
iters = resolve_iters(config, len(train_records), batch_size)
|
|
86
|
+
num_layers = apply_lora(model, config)
|
|
87
|
+
save_adapter_config(config, num_layers)
|
|
88
|
+
|
|
89
|
+
args = TrainingArgs(
|
|
90
|
+
batch_size=batch_size,
|
|
91
|
+
iters=iters,
|
|
92
|
+
max_seq_length=config.training.seq_len,
|
|
93
|
+
adapter_file=str(config.adapter_path / "adapters.safetensors"),
|
|
94
|
+
grad_checkpoint=config.training.grad_checkpoint,
|
|
95
|
+
grad_accumulation_steps=config.training.grad_accumulation_steps,
|
|
96
|
+
steps_per_save=config.training.save_every,
|
|
97
|
+
steps_per_eval=min(200, max(1, iters // 2)),
|
|
98
|
+
val_batches=min(25, max(1, len(valid_records) // batch_size)),
|
|
99
|
+
)
|
|
100
|
+
opt = optim.Adam(learning_rate=config.training.lr)
|
|
101
|
+
|
|
102
|
+
print(
|
|
103
|
+
f"Training: task=sft batch_size={batch_size} iters={iters} "
|
|
104
|
+
f"lr={config.training.lr} seq_len={config.training.seq_len}"
|
|
105
|
+
)
|
|
106
|
+
train(
|
|
107
|
+
model=model,
|
|
108
|
+
optimizer=opt,
|
|
109
|
+
train_dataset=CacheDataset(train_set),
|
|
110
|
+
val_dataset=CacheDataset(valid_set),
|
|
111
|
+
args=args,
|
|
112
|
+
)
|
|
113
|
+
print(f"\nDone. Adapter saved to {config.adapter_path}")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: troy-cli
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Fine-tune LLMs on your MacBook with one YAML file. Built for Apple Silicon.
|
|
5
|
+
Author: Troy
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: apple-silicon,dpo,fine-tuning,llm,lora,mlx
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: mlx-lm>=0.30
|
|
10
|
+
Requires-Dist: pydantic>=2.5
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
12
|
+
Requires-Dist: rich>=13.0
|
|
13
|
+
Requires-Dist: typer>=0.12
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Troy
|
|
17
|
+
|
|
18
|
+
**Fine-tune LLMs on your MacBook with one YAML file.**
|
|
19
|
+
|
|
20
|
+
Troy is a command-line tool for fine-tuning and preference-tuning language
|
|
21
|
+
models locally on Apple Silicon. No CUDA, no cloud, no training pipeline —
|
|
22
|
+
write a config, run one command, and train on the machine you already own.
|
|
23
|
+
|
|
24
|
+
Built on [MLX](https://github.com/ml-explore/mlx) and
|
|
25
|
+
[mlx-lm](https://github.com/ml-explore/mlx-lm), Apple's ML framework for
|
|
26
|
+
Apple Silicon. Unified memory means a 36 GB MacBook fine-tunes models that
|
|
27
|
+
need a workstation GPU anywhere else.
|
|
28
|
+
|
|
29
|
+
## Requirements
|
|
30
|
+
|
|
31
|
+
- Apple Silicon Mac (M1 or later)
|
|
32
|
+
- macOS 14+
|
|
33
|
+
- Python 3.10–3.12
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
brew install avirajkhare00/troy/troy
|
|
39
|
+
# or from source: pip install ./cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
troy doctor # check your Mac: chip, memory, MLX, what you can train
|
|
46
|
+
troy init # create troy.yaml + sample data
|
|
47
|
+
troy train # fine-tune (LoRA/QLoRA via MLX)
|
|
48
|
+
troy chat # talk to the result
|
|
49
|
+
troy serve # OpenAI-compatible API at localhost:8080/v1
|
|
50
|
+
troy export -f gguf # ship it to llama.cpp / Ollama / LM Studio
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## The config is the interface
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
base: mlx-community/Qwen3-0.6B-4bit
|
|
57
|
+
task: sft # or: dpo
|
|
58
|
+
|
|
59
|
+
data:
|
|
60
|
+
train: ./data/train.jsonl # alpaca, sharegpt, chat, completions, text — auto-detected
|
|
61
|
+
val_split: 0.1
|
|
62
|
+
|
|
63
|
+
training:
|
|
64
|
+
epochs: 3
|
|
65
|
+
lr: 1e-5
|
|
66
|
+
batch_size: auto # sized from your Mac's unified memory
|
|
67
|
+
lora:
|
|
68
|
+
r: 8
|
|
69
|
+
alpha: 16
|
|
70
|
+
|
|
71
|
+
output: ./output
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Commands
|
|
75
|
+
|
|
76
|
+
| Command | Purpose |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `troy init` | Create a config from a template (`chat`, `dpo`) |
|
|
79
|
+
| `troy doctor` | Hardware + dependency check, with model-size guidance |
|
|
80
|
+
| `troy train` | LoRA fine-tuning: SFT or DPO |
|
|
81
|
+
| `troy chat` | Interactive REPL (or `-p` for one-shot) with your adapter |
|
|
82
|
+
| `troy serve` | OpenAI-compatible API server for your model |
|
|
83
|
+
| `troy export` | Fuse the adapter; export MLX or GGUF |
|
|
84
|
+
| `troy data inspect` | Dataset stats and format detection |
|
|
85
|
+
|
|
86
|
+
## What Troy can train on your Mac
|
|
87
|
+
|
|
88
|
+
| Unified memory | Max model (4-bit QLoRA) |
|
|
89
|
+
|---|---|
|
|
90
|
+
| 8 GB | ~1.5B |
|
|
91
|
+
| 16 GB | ~4B |
|
|
92
|
+
| 24 GB | ~8B |
|
|
93
|
+
| 36 GB | ~14B |
|
|
94
|
+
| 64 GB | ~32B |
|
|
95
|
+
| 128 GB | ~70B |
|
|
96
|
+
|
|
97
|
+
## DPO without a second model
|
|
98
|
+
|
|
99
|
+
DPO normally keeps a frozen reference copy of the model in memory. Troy
|
|
100
|
+
zeroes the LoRA scales to recover the reference model from the policy model
|
|
101
|
+
itself — no second copy, which matters on unified memory.
|
|
102
|
+
|
|
103
|
+
## Data formats
|
|
104
|
+
|
|
105
|
+
Auto-detected from the first record: Alpaca (`instruction`/`output`),
|
|
106
|
+
ShareGPT (`conversations`), chat (`messages`), `prompt`/`completion`,
|
|
107
|
+
plain `text`, and preference pairs (`prompt`/`chosen`/`rejected`) for DPO.
|
|
108
|
+
Files: `.jsonl`, `.json`, `.csv`.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
Apache-2.0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
troy/__init__.py,sha256=mCMSFHPxMwZ7O0QyBuh719jLqU_5B2qnFuTtTv6uWC8,86
|
|
2
|
+
troy/chat.py,sha256=SLpGGmsgyV_wMRqx6mzZhrFD5YgyiBm6ezGgYeYKRyk,1693
|
|
3
|
+
troy/cli.py,sha256=yqY3_DeTb8kdB5VAtiLP6Uy_WZIvVA6XbsksBfvBYec,9170
|
|
4
|
+
troy/config.py,sha256=3XOR3dtvkisVuWFJuqx6Cge_plGyz4KcYqwGAcick7U,2160
|
|
5
|
+
troy/data.py,sha256=KGhwB-RuOficxZiDP_vdqItkZ2_7yiKGMqnYm7rKeoM,5382
|
|
6
|
+
troy/export.py,sha256=4k50lcL-CrUcg6f3ibbourpQHr44qj7kkT16SJtqUAs,2424
|
|
7
|
+
troy/hardware.py,sha256=-c5mdZr7SpQk1qo7Eib9nxRSknzE5dxfvyuU18wr-zg,2022
|
|
8
|
+
troy/serve.py,sha256=1Xq3FJ1NlYUo1UD1E7xd2D1ttq46lwIgfpRDjiQ0PmE,1122
|
|
9
|
+
troy/templates.py,sha256=fwfdbG2xKg9kSpT4fKx6poyqpc9eMpW29VXD1wSUaXg,2101
|
|
10
|
+
troy/train_dpo.py,sha256=-Zg53-8rst3mqV5cvr_Yyc9AnSYRP562hDiPMIMBsU4,6126
|
|
11
|
+
troy/train_sft.py,sha256=u3S6WwZrCxwFQfArXu3kNbqXG65L9FZH_J773IK983s,3825
|
|
12
|
+
troy_cli-0.1.2.dist-info/METADATA,sha256=K5OH1etNCT0AfBn9Xv-ZD1x0gQ9N1ZjmCIsWfnV_I4s,3124
|
|
13
|
+
troy_cli-0.1.2.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
14
|
+
troy_cli-0.1.2.dist-info/entry_points.txt,sha256=jz-AaAmupNovrRxChIqPcfiKm36WkSR8WV2Of0ajlqM,38
|
|
15
|
+
troy_cli-0.1.2.dist-info/RECORD,,
|