troy-cli 0.1.2__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: troy-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Fine-tune LLMs on your MacBook with one YAML file. Built for Apple Silicon.
5
5
  Author: Troy
6
6
  License: Apache-2.0
@@ -36,7 +36,7 @@ need a workstation GPU anywhere else.
36
36
 
37
37
  ```bash
38
38
  brew install avirajkhare00/troy/troy
39
- # or from source: pip install ./cli
39
+ # or: pipx install troy-cli
40
40
  ```
41
41
 
42
42
  ## Quickstart
@@ -54,7 +54,7 @@ troy export -f gguf # ship it to llama.cpp / Ollama / LM Studio
54
54
 
55
55
  ```yaml
56
56
  base: mlx-community/Qwen3-0.6B-4bit
57
- task: sft # or: dpo
57
+ task: sft # or: dpo, orpo
58
58
 
59
59
  data:
60
60
  train: ./data/train.jsonl # alpaca, sharegpt, chat, completions, text — auto-detected
@@ -77,10 +77,12 @@ output: ./output
77
77
  |---|---|
78
78
  | `troy init` | Create a config from a template (`chat`, `dpo`) |
79
79
  | `troy doctor` | Hardware + dependency check, with model-size guidance |
80
- | `troy train` | LoRA fine-tuning: SFT or DPO |
80
+ | `troy train` | LoRA fine-tuning: SFT, DPO, or ORPO |
81
81
  | `troy chat` | Interactive REPL (or `-p` for one-shot) with your adapter |
82
+ | `troy eval` | Base-vs-tuned val loss, perplexity, side-by-side samples |
82
83
  | `troy serve` | OpenAI-compatible API server for your model |
83
84
  | `troy export` | Fuse the adapter; export MLX or GGUF |
85
+ | `troy push` | Upload adapter or fused model to the Hugging Face Hub |
84
86
  | `troy data inspect` | Dataset stats and format detection |
85
87
 
86
88
  ## What Troy can train on your Mac
@@ -21,7 +21,7 @@ need a workstation GPU anywhere else.
21
21
 
22
22
  ```bash
23
23
  brew install avirajkhare00/troy/troy
24
- # or from source: pip install ./cli
24
+ # or: pipx install troy-cli
25
25
  ```
26
26
 
27
27
  ## Quickstart
@@ -39,7 +39,7 @@ troy export -f gguf # ship it to llama.cpp / Ollama / LM Studio
39
39
 
40
40
  ```yaml
41
41
  base: mlx-community/Qwen3-0.6B-4bit
42
- task: sft # or: dpo
42
+ task: sft # or: dpo, orpo
43
43
 
44
44
  data:
45
45
  train: ./data/train.jsonl # alpaca, sharegpt, chat, completions, text — auto-detected
@@ -62,10 +62,12 @@ output: ./output
62
62
  |---|---|
63
63
  | `troy init` | Create a config from a template (`chat`, `dpo`) |
64
64
  | `troy doctor` | Hardware + dependency check, with model-size guidance |
65
- | `troy train` | LoRA fine-tuning: SFT or DPO |
65
+ | `troy train` | LoRA fine-tuning: SFT, DPO, or ORPO |
66
66
  | `troy chat` | Interactive REPL (or `-p` for one-shot) with your adapter |
67
+ | `troy eval` | Base-vs-tuned val loss, perplexity, side-by-side samples |
67
68
  | `troy serve` | OpenAI-compatible API server for your model |
68
69
  | `troy export` | Fuse the adapter; export MLX or GGUF |
70
+ | `troy push` | Upload adapter or fused model to the Hugging Face Hub |
69
71
  | `troy data inspect` | Dataset stats and format detection |
70
72
 
71
73
  ## What Troy can train on your Mac
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "troy-cli"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "Fine-tune LLMs on your MacBook with one YAML file. Built for Apple Silicon."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,3 +1,3 @@
1
1
  """Troy: fine-tune LLMs on your MacBook with one YAML file."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.0"
@@ -6,7 +6,7 @@ import json
6
6
  import platform
7
7
  import sys
8
8
  from pathlib import Path
9
- from typing import Optional
9
+ from typing import List, Optional
10
10
 
11
11
  import typer
12
12
  from rich.console import Console
@@ -149,6 +149,10 @@ def train(
149
149
  from .train_sft import run_sft
150
150
 
151
151
  run_sft(cfg, train_records, valid_records)
152
+ elif cfg.task == "orpo":
153
+ from .train_orpo import run_orpo
154
+
155
+ run_orpo(cfg, train_records, valid_records)
152
156
  else:
153
157
  from .train_dpo import run_dpo
154
158
 
@@ -248,6 +252,46 @@ def export(
248
252
  run_export(cfg.base, str(cfg.adapter_path), str(out), fmt, dequantize)
249
253
 
250
254
 
255
+ @app.command()
256
+ def eval(
257
+ config: Path = typer.Option(Path("troy.yaml"), "--config", "-c", help="Config file."),
258
+ prompts: Optional[List[str]] = typer.Option(
259
+ None, "--prompt", "-p",
260
+ help="Prompt for side-by-side base-vs-tuned generation (repeatable).",
261
+ ),
262
+ max_tokens: int = typer.Option(200),
263
+ ) -> None:
264
+ """Compare the trained adapter against the base model (loss, ppl, samples)."""
265
+ _require_apple_silicon()
266
+ from .config import load_config
267
+ from .data import load_and_prepare
268
+ from .evaluate import run_eval
269
+
270
+ cfg = load_config(config)
271
+ _, valid_records, _ = load_and_prepare(cfg.data, cfg.task, cfg.training.seed)
272
+ run_eval(cfg, valid_records, list(prompts) if prompts else None, max_tokens)
273
+
274
+
275
+ @app.command()
276
+ def push(
277
+ repo: str = typer.Argument(help="Hub repo id, e.g. username/my-model."),
278
+ config: Path = typer.Option(Path("troy.yaml"), "--config", "-c", help="Config file."),
279
+ fused: bool = typer.Option(False, help="Push the fused model instead of the adapter."),
280
+ public: bool = typer.Option(False, help="Make the Hub repo public."),
281
+ ) -> None:
282
+ """Upload your trained adapter (or fused model) to the Hugging Face Hub."""
283
+ from .config import load_config
284
+ from .push import run_push
285
+
286
+ cfg = load_config(config)
287
+ folder = (cfg.output_path / "fused") if fused else cfg.adapter_path
288
+ if not folder.exists():
289
+ what = "troy export" if fused else "troy train"
290
+ console.print(f"[red]{folder} not found. Run `{what}` first.[/red]")
291
+ raise typer.Exit(1)
292
+ run_push(folder, repo, private=not public)
293
+
294
+
251
295
  @app.command()
252
296
  def data(
253
297
  action: str = typer.Argument(help="Action: inspect"),
@@ -24,6 +24,11 @@ class DpoConfig(BaseModel):
24
24
  beta: float = 0.1
25
25
 
26
26
 
27
+ class OrpoConfig(BaseModel):
28
+ lam: float = Field(0.1, alias="lambda")
29
+ model_config = {"populate_by_name": True}
30
+
31
+
27
32
  class DataConfig(BaseModel):
28
33
  train: str
29
34
  valid: Optional[str] = None
@@ -42,6 +47,7 @@ class TrainingConfig(BaseModel):
42
47
  seq_len: int = 2048
43
48
  lora: LoraConfig = LoraConfig()
44
49
  dpo: DpoConfig = DpoConfig()
50
+ orpo: OrpoConfig = OrpoConfig()
45
51
  grad_checkpoint: bool = False
46
52
  grad_accumulation_steps: int = 1
47
53
  save_every: int = 100
@@ -50,7 +56,7 @@ class TrainingConfig(BaseModel):
50
56
 
51
57
  class TroyConfig(BaseModel):
52
58
  base: str
53
- task: Literal["sft", "dpo"] = "sft"
59
+ task: Literal["sft", "dpo", "orpo"] = "sft"
54
60
  data: DataConfig
55
61
  training: TrainingConfig = TrainingConfig()
56
62
  output: str = "./output"
@@ -117,13 +117,15 @@ def load_and_prepare(
117
117
 
118
118
  fmt = cfg.format if cfg.format != "auto" else detect_format(records[0])
119
119
 
120
- if task == "dpo" and fmt != "preference":
120
+ if task in ("dpo", "orpo") and fmt != "preference":
121
121
  raise ValueError(
122
- f"Task `dpo` needs preference data (prompt/chosen/rejected); "
122
+ f"Task `{task}` needs preference data (prompt/chosen/rejected); "
123
123
  f"detected format `{fmt}`."
124
124
  )
125
125
  if task == "sft" and fmt == "preference":
126
- raise ValueError("Preference data detected — set `task: dpo` in troy.yaml.")
126
+ raise ValueError(
127
+ "Preference data detected — set `task: dpo` (or `task: orpo`) in troy.yaml."
128
+ )
127
129
 
128
130
  if fmt == "preference":
129
131
  records = [_normalize_preference(r) for r in records]
@@ -0,0 +1,88 @@
1
+ """Evaluate a trained adapter: base vs tuned perplexity and side-by-side samples.
2
+
3
+ The base-model numbers reuse the zero-scale trick: with every LoRA scale set
4
+ to 0 the tuned model *is* the base model, so both sides come from one load.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import types
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import mlx.core as mx
14
+ from mlx_lm.generate import generate
15
+ from mlx_lm.sample_utils import make_sampler
16
+ from mlx_lm.tuner.datasets import CacheDataset, create_dataset
17
+ from mlx_lm.tuner.trainer import default_loss, evaluate, iterate_batches
18
+ from mlx_lm.utils import load
19
+
20
+ from .config import TroyConfig
21
+ from .train_dpo import _ReferenceMode
22
+
23
+
24
+ def _val_loss(model, dataset, batch_size: int, seq_len: int) -> float:
25
+ return float(
26
+ evaluate(
27
+ model=model,
28
+ dataset=CacheDataset(dataset),
29
+ batch_size=batch_size,
30
+ num_batches=-1,
31
+ max_seq_length=seq_len,
32
+ loss=default_loss,
33
+ iterate_batches=iterate_batches,
34
+ )
35
+ )
36
+
37
+
38
+ def run_eval(
39
+ config: TroyConfig,
40
+ valid_records: List[Dict[str, Any]],
41
+ sample_prompts: Optional[List[str]] = None,
42
+ max_tokens: int = 200,
43
+ ) -> None:
44
+ adapter_file = config.adapter_path / "adapters.safetensors"
45
+ if not adapter_file.exists():
46
+ raise SystemExit(f"No adapter at {adapter_file}. Run `troy train` first.")
47
+
48
+ print(f"Loading {config.base} + adapter ...")
49
+ model, tokenizer = load(config.base, adapter_path=str(config.adapter_path))
50
+
51
+ results = {}
52
+ if valid_records and config.task == "sft":
53
+ ds_config = types.SimpleNamespace(mask_prompt=config.data.mask_prompt)
54
+ dataset = create_dataset(valid_records, tokenizer, ds_config)
55
+ bs = max(1, min(4, len(valid_records)))
56
+
57
+ tuned = _val_loss(model, dataset, bs, config.training.seq_len)
58
+ with _ReferenceMode(model):
59
+ base = _val_loss(model, dataset, bs, config.training.seq_len)
60
+ results = {
61
+ "val records": len(valid_records),
62
+ "base loss": f"{base:.3f}",
63
+ "tuned loss": f"{tuned:.3f}",
64
+ "base ppl": f"{math.exp(base):.1f}",
65
+ "tuned ppl": f"{math.exp(tuned):.1f}",
66
+ }
67
+ for k, v in results.items():
68
+ print(f" {k:>12}: {v}")
69
+ delta = base - tuned
70
+ print(f" {'Δ loss':>12}: {delta:+.3f} ({'tuned better' if delta > 0 else 'base better'})")
71
+ elif config.task != "sft":
72
+ print("(loss comparison is defined for task: sft — showing generations only)")
73
+
74
+ if sample_prompts:
75
+ sampler = make_sampler(temp=0.0)
76
+ print("\n--- side-by-side generations (temperature 0) ---")
77
+ for prompt in sample_prompts:
78
+ templated = tokenizer.apply_chat_template(
79
+ [{"role": "user", "content": prompt}],
80
+ add_generation_prompt=True,
81
+ return_dict=False,
82
+ )
83
+ tuned_out = generate(model, tokenizer, templated, max_tokens=max_tokens, sampler=sampler)
84
+ with _ReferenceMode(model):
85
+ base_out = generate(model, tokenizer, templated, max_tokens=max_tokens, sampler=sampler)
86
+ print(f"\n>> {prompt}")
87
+ print(f"[base] {base_out.strip()[:400]}")
88
+ print(f"[tuned] {tuned_out.strip()[:400]}")
@@ -0,0 +1,25 @@
1
+ """Upload a trained adapter or fused model to the Hugging Face Hub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def run_push(folder: Path, repo: str, private: bool = True) -> str:
9
+ from huggingface_hub import HfApi
10
+
11
+ api = HfApi()
12
+ try:
13
+ who = api.whoami()
14
+ except Exception:
15
+ raise SystemExit(
16
+ "Not logged in to Hugging Face. Run: hf auth login "
17
+ "(or set HF_TOKEN) and retry."
18
+ )
19
+ print(f"Logged in as {who['name']}. Uploading {folder} -> {repo} "
20
+ f"({'private' if private else 'public'}) ...")
21
+ api.create_repo(repo, exist_ok=True, private=private)
22
+ api.upload_folder(folder_path=str(folder), repo_id=repo)
23
+ url = f"https://huggingface.co/{repo}"
24
+ print(f"Done: {url}")
25
+ return url
@@ -0,0 +1,119 @@
1
+ """ORPO: Odds Ratio Preference Optimization on Apple Silicon.
2
+
3
+ ORPO needs no reference model at all — the loss combines a standard SFT term
4
+ on the chosen response with an odds-ratio penalty pushing chosen above
5
+ rejected. One model in memory, one pass per completion.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any, Dict, List
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.utils import load
19
+
20
+ from .config import TroyConfig
21
+ from .train_dpo import _batch, _encode_pair
22
+ from .train_sft import apply_lora, resolve_batch_size, resolve_iters, save_adapter_config
23
+
24
+
25
+ def _mean_logps(model, tokens: mx.array, mask: mx.array):
26
+ """Per-sequence mean log-prob over completion tokens (and the sum + count)."""
27
+ logits = model(tokens[:, :-1])
28
+ targets = tokens[:, 1:]
29
+ logps = -nn.losses.cross_entropy(logits, targets, reduction="none")
30
+ total = (logps * mask).sum(axis=-1)
31
+ count = mx.maximum(mask.sum(axis=-1), 1)
32
+ return total / count, total, count
33
+
34
+
35
+ def run_orpo(
36
+ config: TroyConfig,
37
+ train_records: List[Dict[str, Any]],
38
+ valid_records: List[Dict[str, Any]],
39
+ ) -> None:
40
+ mx.random.seed(config.training.seed)
41
+ print(f"Loading {config.base} ...")
42
+ model, tokenizer = load(config.base)
43
+
44
+ batch_size = resolve_batch_size(config)
45
+ if config.training.batch_size == "auto":
46
+ batch_size = max(1, batch_size // 2) # chosen + rejected per example
47
+ iters = resolve_iters(config, len(train_records), batch_size)
48
+ num_layers = apply_lora(model, config)
49
+ save_adapter_config(config, num_layers)
50
+ if config.training.grad_checkpoint:
51
+ grad_checkpoint(model.layers[0])
52
+
53
+ lam = config.training.orpo.lam
54
+ max_len = config.training.seq_len
55
+ pad_id = tokenizer.pad_token_id or tokenizer.eos_token_id or 0
56
+
57
+ encoded = [
58
+ (
59
+ _encode_pair(tokenizer, r["prompt"], r["chosen"], max_len),
60
+ _encode_pair(tokenizer, r["prompt"], r["rejected"], max_len),
61
+ )
62
+ for r in train_records
63
+ ]
64
+
65
+ def loss_fn(model, tc, mc, tr, mr):
66
+ mean_c, total_c, count_c = _mean_logps(model, tc, mc)
67
+ mean_r, _, _ = _mean_logps(model, tr, mr)
68
+ # log odds: log(p/(1-p)) with p = exp(mean logp)
69
+ log_odds = (mean_c - mean_r) - (
70
+ mx.log1p(-mx.exp(mx.minimum(mean_c, -1e-6)))
71
+ - mx.log1p(-mx.exp(mx.minimum(mean_r, -1e-6)))
72
+ )
73
+ or_loss = -nn.log_sigmoid(log_odds).mean()
74
+ sft_loss = -(total_c.sum() / count_c.sum())
75
+ reward_acc = (mean_c > mean_r).mean()
76
+ return sft_loss + lam * or_loss, reward_acc
77
+
78
+ loss_and_grad = nn.value_and_grad(model, loss_fn)
79
+ opt = optim.Adam(learning_rate=config.training.lr)
80
+
81
+ print(
82
+ f"Training: task=orpo lambda={lam} batch_size={batch_size} iters={iters} "
83
+ f"lr={config.training.lr} pairs={len(encoded)}"
84
+ )
85
+
86
+ config.adapter_path.mkdir(parents=True, exist_ok=True)
87
+ adapter_file = config.adapter_path / "adapters.safetensors"
88
+ n = len(encoded)
89
+ losses, accs = [], []
90
+ start = time.time()
91
+
92
+ for it in range(iters):
93
+ idx = [(it * batch_size + k) % n for k in range(batch_size)]
94
+ tc, mc = _batch([encoded[i][0] for i in idx], pad_id)
95
+ tr, mr = _batch([encoded[i][1] for i in idx], pad_id)
96
+
97
+ (loss, acc), grads = loss_and_grad(model, tc, mc, tr, mr)
98
+ opt.update(model, grads)
99
+ mx.eval(model.parameters(), opt.state, loss)
100
+ losses.append(loss.item())
101
+ accs.append(acc.item())
102
+
103
+ if (it + 1) % 10 == 0 or it == iters - 1:
104
+ speed = (it + 1) / (time.time() - start)
105
+ print(
106
+ f"Iter {it + 1}/{iters}: loss {sum(losses)/len(losses):.4f}, "
107
+ f"reward acc {sum(accs)/len(accs):.3f}, {speed:.2f} it/s"
108
+ )
109
+ losses, accs = [], []
110
+
111
+ if (it + 1) % config.training.save_every == 0:
112
+ _save(model, adapter_file)
113
+
114
+ _save(model, adapter_file)
115
+ print(f"\nDone. Adapter saved to {config.adapter_path}")
116
+
117
+
118
+ def _save(model, adapter_file) -> None:
119
+ mx.save_safetensors(str(adapter_file), dict(tree_flatten(model.trainable_parameters())))
@@ -49,3 +49,11 @@ def test_missing_file_message(tmp_path):
49
49
  def test_adapter_path_under_output():
50
50
  cfg = TroyConfig.model_validate({**BASE, "output": "/tmp/run1"})
51
51
  assert str(cfg.adapter_path) == "/tmp/run1/adapter"
52
+
53
+
54
+ def test_orpo_config():
55
+ cfg = TroyConfig.model_validate(
56
+ {**BASE, "task": "orpo", "training": {"orpo": {"lambda": 0.2}}}
57
+ )
58
+ assert cfg.task == "orpo"
59
+ assert cfg.training.orpo.lam == 0.2
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes