mirror of https://github.com/razor-ai/soup.git
v0.10.1: Fix 6 bugs from manual testing report
- BUG-001: Replace Unicode arrows/dashes with ASCII in all console output to fix UnicodeEncodeError on Windows cp1252 (~10 commands affected) - BUG-002: PPO trainer uses inspect.signature to detect trl parameter names (ppo_epochs vs num_ppo_epochs) for trl 0.28.0 compatibility - BUG-003: Add get_compute_dtype() - uses float32 on CPU, bfloat16/float16 on CUDA. Fixes dtype mismatch in reward model and all trainers - BUG-004: Add warning when using quantization on CPU - BUG-005: Fix dtype -> torch_dtype in diff.py model loading - BUG-006: Pin wandb<0.18.0 to avoid trl import conflict, add runtime guard 13 new tests (624 total), ruff clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ccccb25817
commit
428c0f09a4
|
|
@ -182,3 +182,4 @@ Test suite lives in `tests/`:
|
|||
| `test_ui.py` | Web UI command, FastAPI endpoints, static files, config validation |
|
||||
| `test_vllm_serve.py` | vLLM backend detection, engine creation, serve --backend flag, FastAPI app |
|
||||
| `test_ppo.py` | PPO config, reward model config, data prep, RLHF template, routing, sweep |
|
||||
| `test_bugfixes.py` | v0.10.1 bugfixes: Windows Unicode, PPO params, compute dtype, diff dtype, wandb pin |
|
||||
|
|
|
|||
130
plan.md
130
plan.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Repo:** https://github.com/MakazhanAlpamys/Soup
|
||||
**PyPI:** https://pypi.org/project/soup-cli/ (`pip install soup-cli`)
|
||||
**Version:** v0.10.0 | 611 tests | CI green
|
||||
**Version:** v0.10.1 | 624 tests | CI green
|
||||
|
||||
### How to publish
|
||||
|
||||
|
|
@ -16,9 +16,7 @@ git push --tags
|
|||
|
||||
---
|
||||
|
||||
## Completed (v0.1.0 – v0.10.0)
|
||||
|
||||
All core CLI functionality is shipped:
|
||||
## Completed (v0.1.0 – v0.10.1)
|
||||
|
||||
- **CLI:** init, train, chat, push, merge, export, eval, serve, sweep, diff, doctor, quickstart, ui, version
|
||||
- **Data:** inspect, validate, convert, merge, dedup, stats, generate
|
||||
|
|
@ -28,23 +26,104 @@ All core CLI functionality is shipped:
|
|||
- **Tracking:** SQLite experiment tracker, runs list/show/compare/delete
|
||||
- **Export:** GGUF (Ollama/llama.cpp), LoRA merge
|
||||
- **Web UI:** Dashboard, New Training, Data Explorer, Model Chat (FastAPI + SPA)
|
||||
- **UX:** friendly errors, --verbose, confirmation prompts, Rich progress bars
|
||||
- **Community:** CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, examples/, FUNDING.yml, issue/PR templates, GitHub Discussions
|
||||
- **Tests:** 611 tests, 40 files, ruff lint, CI on Python 3.9/3.11/3.12
|
||||
- **UX:** friendly errors, --verbose, Rich progress bars, confirmation prompts
|
||||
- **Community:** CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, examples/, FUNDING.yml
|
||||
- **Tests:** 624 tests, 41 files, ruff lint, CI on Python 3.9/3.11/3.12
|
||||
- **v0.10.1 bugfixes:** Windows UnicodeEncodeError, PPO trl compat, compute dtype for CPU, diff torch_dtype, wandb version pin
|
||||
|
||||
---
|
||||
|
||||
## Next
|
||||
## Roadmap
|
||||
|
||||
- [ ] First post on Reddit (r/LocalLLaMA, r/MachineLearning)
|
||||
- [ ] Community building, content, marketing
|
||||
### P0 — Critical (v1.0–v1.1)
|
||||
|
||||
#### Community & Marketing
|
||||
- [ ] Discord server
|
||||
- [ ] First Reddit post (r/LocalLLaMA, r/MachineLearning)
|
||||
- [ ] HuggingFace models showcase ("trained-with-soup" tag)
|
||||
- [ ] 3-5 YouTube tutorials / blog posts with benchmarks (Soup vs LLaMA-Factory vs Axolotl)
|
||||
- [ ] Supported models page in README (Llama 4, Gemma 3, Qwen 2.5/3, Phi-4, DeepSeek R1/V3, Mistral, Mixtral)
|
||||
|
||||
#### Advanced PEFT methods
|
||||
- [ ] DoRA (`peft_type: dora`) — improved LoRA with magnitude decomposition
|
||||
- [ ] GaLore — memory-efficient full-parameter training on consumer GPUs
|
||||
- [ ] LoRA+ — different learning rates for A and B matrices
|
||||
|
||||
#### More alignment methods
|
||||
- [ ] KTO (Kahneman-Tversky Optimization) — `task: kto`, doesn't need paired data
|
||||
- [ ] ORPO (Odds Ratio Preference Optimization) — `task: orpo`, no reference model needed
|
||||
- [ ] SimPO — `task: simpo`, simple preference optimization
|
||||
- [ ] IPO (Identity Preference Optimization) — `task: ipo`
|
||||
|
||||
#### Pre-training / Continued pre-training
|
||||
- [ ] `task: pretrain` — continued pre-training on raw text
|
||||
- [ ] Support for plain text / tokenized datasets
|
||||
|
||||
#### Cloud GPU providers
|
||||
- [ ] `soup cloud run --provider runpod --gpu a100 --config soup.yaml`
|
||||
- [ ] RunPod, Vast.ai, Lambda Labs, Modal integration
|
||||
- [ ] Auto-setup: upload data → rent GPU → train → return adapter → teardown
|
||||
- [ ] Cost estimator and budget auto-stop
|
||||
|
||||
---
|
||||
|
||||
## Future (when there's demand)
|
||||
### P1 — Important (v1.2–v1.3)
|
||||
|
||||
- **Cloud Mode (BYOG)** — Modal/RunPod/Vast integration, cost estimator
|
||||
- **Managed Platform (SaaS)** — app.soup.dev, monetization (when 300+ stars)
|
||||
#### MoE support
|
||||
- [ ] Explicit MoE model support (Qwen3 30B-A3B, Mixtral, DeepSeek V3)
|
||||
- [ ] ScatterMoE LoRA for efficient MoE fine-tuning
|
||||
- [ ] Documentation and examples for MoE training
|
||||
|
||||
#### Long-context training
|
||||
- [ ] Sequence parallelism via Ring FlashAttention
|
||||
- [ ] 128k+ context fine-tuning across multiple GPUs
|
||||
- [ ] Neat packing (contamination-free) for long sequences
|
||||
|
||||
#### Embedding models
|
||||
- [ ] `task: embedding` — fine-tune sentence transformers, BGE, E5
|
||||
- [ ] `soup init --template embedding`
|
||||
- [ ] Contrastive loss, triplet loss support
|
||||
|
||||
#### Advanced distributed training
|
||||
- [ ] FSDP2 support alongside DeepSpeed
|
||||
- [ ] Multi-node training via torchrun / Ray
|
||||
- [ ] Tensor Parallelism + Context Parallelism combined
|
||||
|
||||
#### Performance optimizations
|
||||
- [ ] Liger Kernel integration (fused operations)
|
||||
- [ ] FlashAttention-3/4 auto-detection
|
||||
- [ ] SageAttention support
|
||||
|
||||
---
|
||||
|
||||
### P2 — Nice to have (v1.4+)
|
||||
|
||||
#### Export formats
|
||||
- [ ] ONNX export (`soup export --format onnx`)
|
||||
- [ ] TensorRT-LLM export
|
||||
- [ ] SGLang backend for serving
|
||||
|
||||
#### Integrations
|
||||
- [ ] MLflow tracking (enterprise alternative to W&B)
|
||||
- [ ] TensorBoard integration
|
||||
- [ ] `uv` package manager support
|
||||
|
||||
#### Serving improvements
|
||||
- [ ] Speculative decoding (`soup serve --speculative-decoding`)
|
||||
- [ ] Batch inference mode (`soup infer --input prompts.jsonl --output results.jsonl`)
|
||||
|
||||
#### Data improvements
|
||||
- [ ] Multi-agent synthetic data generation (GraphGen-style)
|
||||
- [ ] Quality filters for generated data (perplexity, coherence scoring)
|
||||
- [ ] Local model as data generation provider (not just OpenAI API)
|
||||
|
||||
#### Audio modality
|
||||
- [ ] `modality: audio` — Qwen2-Audio, Whisper fine-tuning
|
||||
- [ ] Audio dataset formats and inspection
|
||||
|
||||
#### Smart suggestions
|
||||
- [ ] Auto-detect hardware and recommend optimal config
|
||||
- [ ] "You have 2×H100 — recommend FSDP2 + Unsloth" style hints
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -55,3 +134,28 @@ All core CLI functionality is shipped:
|
|||
3. **Fail fast, fail loud** — bad data or missing GPU = immediate, clear error
|
||||
4. **Open source core** — CLI is always free; monetize via managed service
|
||||
5. **Test-driven** — every feature has tests, written alongside the code
|
||||
|
||||
---
|
||||
|
||||
## Competitive positioning
|
||||
|
||||
| Feature | Soup | LLaMA-Factory | Axolotl | Unsloth |
|
||||
|---|---|---|---|---|
|
||||
| One-command training | **Yes** | Partial | No | Notebook |
|
||||
| Web UI | **Yes** | Yes | No | No |
|
||||
| Experiment tracking | **Built-in SQLite** | W&B only | W&B only | No |
|
||||
| Hyperparam sweep | **Yes + early-stop** | No | No | No |
|
||||
| Data toolkit (7 tools) | **Yes** | Basic | No | No |
|
||||
| Model diff | **Yes** | No | No | No |
|
||||
| GGUF export | **Yes** | Yes | No | Yes |
|
||||
| GRPO + custom rewards | **Yes** | Yes | Yes | Yes |
|
||||
| Full RLHF pipeline | **Yes** | Yes | Yes | No |
|
||||
| Cloud GPU | No | SageMaker | RunPod templates | No |
|
||||
| MoE training | No | Yes | Yes | **12x faster** |
|
||||
| DoRA/GaLore | No | Yes | Yes | Partial |
|
||||
| KTO/ORPO/SimPO | No | Yes | Yes | No |
|
||||
| Pre-training | No | Yes | Yes | Yes |
|
||||
| 100+ model day-0 | No | **Yes** | Partial | Yes |
|
||||
|
||||
**Soup's edge:** best UX, most integrated toolkit, lowest barrier to entry.
|
||||
**Gap to close:** cloud, advanced PEFT, more alignment methods, model breadth.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "soup-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
description = "Fine-tune LLMs in one command. No SSH, no config hell."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
|
@ -40,7 +40,7 @@ dependencies = [
|
|||
[project.optional-dependencies]
|
||||
eval = ["lm-eval>=0.4.0"]
|
||||
data = ["datasketch>=1.6.0"]
|
||||
wandb = ["wandb>=0.15.0"]
|
||||
wandb = ["wandb>=0.15.0,<0.18.0"]
|
||||
dev = ["pytest>=7.0", "ruff>=0.1.0", "pytest-cov>=4.0", "httpx>=0.24.0"]
|
||||
ui = ["fastapi>=0.104.0", "uvicorn>=0.24.0"]
|
||||
serve = ["fastapi>=0.104.0", "uvicorn>=0.24.0"]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Soup CLI — Fine-tune LLMs in one command."""
|
||||
|
||||
__version__ = "0.10.0"
|
||||
__version__ = "0.10.1"
|
||||
|
|
|
|||
|
|
@ -92,9 +92,9 @@ def chat(
|
|||
)
|
||||
|
||||
console.print("[bold green]Model loaded![/] Type your message. Commands:")
|
||||
console.print(" [dim]/quit[/] — exit chat")
|
||||
console.print(" [dim]/clear[/] — reset conversation history")
|
||||
console.print(" [dim]/system <text>[/] — set system prompt")
|
||||
console.print(" [dim]/quit[/] - exit chat")
|
||||
console.print(" [dim]/clear[/] - reset conversation history")
|
||||
console.print(" [dim]/system <text>[/] - set system prompt")
|
||||
console.print()
|
||||
|
||||
# Chat loop
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ def convert(
|
|||
console.print("[red]Cannot convert DPO format (preference pairs are not conversations).[/]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Convert: source → messages → target
|
||||
# Convert: source -> messages -> target
|
||||
converted = []
|
||||
failed = 0
|
||||
for row in data:
|
||||
|
|
@ -164,7 +164,7 @@ def convert(
|
|||
_write_jsonl(out_path, converted)
|
||||
|
||||
console.print(
|
||||
f"[green]Converted {len(converted)} rows:[/] {src_fmt} → {to}\n"
|
||||
f"[green]Converted {len(converted)} rows:[/] {src_fmt} -> {to}\n"
|
||||
f"Output: [bold]{out_path}[/]"
|
||||
)
|
||||
if failed > 0:
|
||||
|
|
@ -295,7 +295,7 @@ def dedup(
|
|||
_write_jsonl(out_path, unique_data)
|
||||
|
||||
console.print(
|
||||
f"[green]Dedup complete:[/] {len(data)} → {len(unique_data)} rows "
|
||||
f"[green]Dedup complete:[/] {len(data)} -> {len(unique_data)} rows "
|
||||
f"([red]-{removed}[/] duplicates)\n"
|
||||
f"Output: [bold]{out_path}[/]"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ def _load_model(model_path: str, base_model: Optional[str], device: str):
|
|||
base_model,
|
||||
trust_remote_code=True,
|
||||
device_map="auto",
|
||||
dtype=torch.float16,
|
||||
torch_dtype=torch.float16,
|
||||
)
|
||||
model_obj = PeftModel.from_pretrained(base, model_path)
|
||||
else:
|
||||
|
|
@ -239,7 +239,7 @@ def _load_model(model_path: str, base_model: Optional[str], device: str):
|
|||
model_path,
|
||||
trust_remote_code=True,
|
||||
device_map="auto",
|
||||
dtype=torch.float16,
|
||||
torch_dtype=torch.float16,
|
||||
)
|
||||
|
||||
model_obj.eval()
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ DEPS = [
|
|||
|
||||
def doctor():
|
||||
"""Check system dependencies, GPU, and compatibility."""
|
||||
console.print("[bold]Soup Doctor[/] — checking your environment...\n")
|
||||
console.print("[bold]Soup Doctor[/] - checking your environment...\n")
|
||||
|
||||
# System info
|
||||
console.print(
|
||||
|
|
@ -93,7 +93,7 @@ def doctor():
|
|||
table.add_row(
|
||||
pkg_name,
|
||||
"yes" if required else "optional",
|
||||
"—",
|
||||
"-",
|
||||
f">={min_ver}",
|
||||
status,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ def _display_results(results: dict, benchmarks: list[str]) -> None:
|
|||
for benchmark in benchmarks:
|
||||
bench_data = task_results.get(benchmark, {})
|
||||
if not bench_data:
|
||||
table.add_row(benchmark, "—", "[red]not found[/]")
|
||||
table.add_row(benchmark, "-", "[red]not found[/]")
|
||||
continue
|
||||
|
||||
# Try common metric names
|
||||
|
|
@ -156,7 +156,7 @@ def _display_results(results: dict, benchmarks: list[str]) -> None:
|
|||
table.add_row(benchmark, metric_name, f"{val:.4f}")
|
||||
break
|
||||
else:
|
||||
table.add_row(benchmark, "—", "[yellow]no numeric result[/]")
|
||||
table.add_row(benchmark, "-", "[yellow]no numeric result[/]")
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ def export(
|
|||
merge_dir = None
|
||||
|
||||
if is_adapter:
|
||||
console.print("[yellow]LoRA adapter detected — merging with base model first...[/]")
|
||||
console.print("[yellow]LoRA adapter detected - merging with base model first...[/]")
|
||||
base_model = base or _detect_base_model(adapter_config_path)
|
||||
if not base_model:
|
||||
console.print(
|
||||
|
|
@ -152,7 +152,7 @@ def export(
|
|||
shutil.rmtree(merge_dir, ignore_errors=True)
|
||||
|
||||
if not output_path.exists():
|
||||
console.print("[red]Export failed — output file not created.[/]")
|
||||
console.print("[red]Export failed - output file not created.[/]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
file_size = output_path.stat().st_size
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ def quickstart(
|
|||
console.print(f"[green]Created:[/] {config_path}")
|
||||
|
||||
if dry_run:
|
||||
console.print("\n[yellow]Dry run — files created, skipping training.[/]")
|
||||
console.print("\n[yellow]Dry run - files created, skipping training.[/]")
|
||||
console.print(f"To train: [bold]soup train --config {config_path}[/]")
|
||||
raise typer.Exit()
|
||||
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ def list_runs(
|
|||
# Format loss
|
||||
loss_str = ""
|
||||
if run.get("initial_loss") and run.get("final_loss"):
|
||||
loss_str = f"{run['initial_loss']:.3f} → {run['final_loss']:.3f}"
|
||||
loss_str = f"{run['initial_loss']:.3f} -> {run['final_loss']:.3f}"
|
||||
|
||||
# Format duration
|
||||
duration_str = ""
|
||||
|
|
@ -117,7 +117,7 @@ def show(
|
|||
status_str = "[yellow]running[/]"
|
||||
|
||||
# Format duration
|
||||
duration_str = "—"
|
||||
duration_str = "-"
|
||||
if run.get("duration_secs"):
|
||||
secs = run["duration_secs"]
|
||||
hours = int(secs // 3600)
|
||||
|
|
@ -127,19 +127,19 @@ def show(
|
|||
# Build info panel
|
||||
info_lines = [
|
||||
f"Run ID: [bold]{run['run_id']}[/]",
|
||||
f"Name: {run.get('experiment_name') or '—'}",
|
||||
f"Name: {run.get('experiment_name') or '-'}",
|
||||
f"Status: {status_str}",
|
||||
f"Date: {run['created_at'][:19].replace('T', ' ')}",
|
||||
"",
|
||||
f"Model: [bold]{run.get('base_model') or '—'}[/]",
|
||||
f"Task: {run.get('task') or '—'}",
|
||||
f"Device: {run.get('device_name') or '—'} ({run.get('device') or '—'})",
|
||||
f"GPU Memory: {run.get('gpu_memory') or '—'}",
|
||||
f"Model: [bold]{run.get('base_model') or '-'}[/]",
|
||||
f"Task: {run.get('task') or '-'}",
|
||||
f"Device: {run.get('device_name') or '-'} ({run.get('device') or '-'})",
|
||||
f"GPU Memory: {run.get('gpu_memory') or '-'}",
|
||||
"",
|
||||
f"Loss: {_fmt_loss(run)}",
|
||||
f"Steps: {run.get('total_steps') or '—'}",
|
||||
f"Steps: {run.get('total_steps') or '-'}",
|
||||
f"Duration: {duration_str}",
|
||||
f"Output: {run.get('output_dir') or '—'}",
|
||||
f"Output: {run.get('output_dir') or '-'}",
|
||||
]
|
||||
console.print(Panel("\n".join(info_lines), title="Run Details"))
|
||||
|
||||
|
|
@ -197,14 +197,14 @@ def compare(
|
|||
table.add_column(r2["run_id"][:20], justify="right")
|
||||
|
||||
rows = [
|
||||
("Name", r1.get("experiment_name") or "—", r2.get("experiment_name") or "—"),
|
||||
("Model", r1.get("base_model") or "—", r2.get("base_model") or "—"),
|
||||
("Task", r1.get("task") or "—", r2.get("task") or "—"),
|
||||
("Device", r1.get("device_name") or "—", r2.get("device_name") or "—"),
|
||||
("Status", r1.get("status") or "—", r2.get("status") or "—"),
|
||||
("Name", r1.get("experiment_name") or "-", r2.get("experiment_name") or "-"),
|
||||
("Model", r1.get("base_model") or "-", r2.get("base_model") or "-"),
|
||||
("Task", r1.get("task") or "-", r2.get("task") or "-"),
|
||||
("Device", r1.get("device_name") or "-", r2.get("device_name") or "-"),
|
||||
("Status", r1.get("status") or "-", r2.get("status") or "-"),
|
||||
("Initial Loss", _fmt_float(r1.get("initial_loss")), _fmt_float(r2.get("initial_loss"))),
|
||||
("Final Loss", _fmt_float(r1.get("final_loss")), _fmt_float(r2.get("final_loss"))),
|
||||
("Steps", str(r1.get("total_steps") or "—"), str(r2.get("total_steps") or "—")),
|
||||
("Steps", str(r1.get("total_steps") or "-"), str(r2.get("total_steps") or "-")),
|
||||
("Duration", _fmt_duration(r1.get("duration_secs")),
|
||||
_fmt_duration(r2.get("duration_secs"))),
|
||||
]
|
||||
|
|
@ -223,19 +223,19 @@ def compare(
|
|||
training1 = c1.get("training", {})
|
||||
training2 = c2.get("training", {})
|
||||
rows.extend([
|
||||
("Epochs", str(training1.get("epochs", "—")), str(training2.get("epochs", "—"))),
|
||||
("Learning Rate", str(training1.get("lr", "—")), str(training2.get("lr", "—"))),
|
||||
("Batch Size", str(training1.get("batch_size", "—")),
|
||||
str(training2.get("batch_size", "—"))),
|
||||
("Quantization", str(training1.get("quantization", "—")),
|
||||
str(training2.get("quantization", "—"))),
|
||||
("Epochs", str(training1.get("epochs", "-")), str(training2.get("epochs", "-"))),
|
||||
("Learning Rate", str(training1.get("lr", "-")), str(training2.get("lr", "-"))),
|
||||
("Batch Size", str(training1.get("batch_size", "-")),
|
||||
str(training2.get("batch_size", "-"))),
|
||||
("Quantization", str(training1.get("quantization", "-")),
|
||||
str(training2.get("quantization", "-"))),
|
||||
])
|
||||
|
||||
lora1 = training1.get("lora", {})
|
||||
lora2 = training2.get("lora", {})
|
||||
rows.extend([
|
||||
("LoRA r", str(lora1.get("r", "—")), str(lora2.get("r", "—"))),
|
||||
("LoRA alpha", str(lora1.get("alpha", "—")), str(lora2.get("alpha", "—"))),
|
||||
("LoRA r", str(lora1.get("r", "-")), str(lora2.get("r", "-"))),
|
||||
("LoRA alpha", str(lora1.get("alpha", "-")), str(lora2.get("alpha", "-"))),
|
||||
])
|
||||
|
||||
for label, val1, val2 in rows:
|
||||
|
|
@ -272,25 +272,25 @@ def delete(
|
|||
|
||||
|
||||
def _fmt_loss(run: dict) -> str:
|
||||
"""Format loss as 'initial → final'."""
|
||||
"""Format loss as 'initial -> final'."""
|
||||
init = run.get("initial_loss")
|
||||
final = run.get("final_loss")
|
||||
if init is not None and final is not None:
|
||||
return f"{init:.4f} → {final:.4f}"
|
||||
return "—"
|
||||
return f"{init:.4f} -> {final:.4f}"
|
||||
return "-"
|
||||
|
||||
|
||||
def _fmt_float(val: Optional[float]) -> str:
|
||||
"""Format a float or return '—'."""
|
||||
if val is not None:
|
||||
return f"{val:.4f}"
|
||||
return "—"
|
||||
return "-"
|
||||
|
||||
|
||||
def _fmt_duration(secs: Optional[float]) -> str:
|
||||
"""Format duration in seconds to human-readable string."""
|
||||
if secs is None:
|
||||
return "—"
|
||||
return "-"
|
||||
if secs >= 3600:
|
||||
return f"{secs / 3600:.1f}h"
|
||||
if secs >= 60:
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ def sweep(
|
|||
console.print(param_table)
|
||||
|
||||
if dry_run:
|
||||
console.print("[yellow]Dry run — no training will be executed.[/]")
|
||||
console.print("[yellow]Dry run - no training will be executed.[/]")
|
||||
raise typer.Exit()
|
||||
|
||||
if not yes:
|
||||
|
|
@ -402,13 +402,13 @@ def _display_summary(results: list[dict], sweep_params: dict[str, list]):
|
|||
for idx, res in enumerate(sorted_results):
|
||||
status_style = "green" if res["status"] == "completed" else "red"
|
||||
param_vals = [str(res["params"].get(k, "")) for k in sweep_params]
|
||||
loss_str = f"{res['final_loss']:.4f}" if res["final_loss"] else "—"
|
||||
loss_str = f"{res['final_loss']:.4f}" if res["final_loss"] else "-"
|
||||
best_marker = " [bold yellow]*[/]" if idx == 0 and res["status"] == "completed" else ""
|
||||
table.add_row(
|
||||
res["name"],
|
||||
*param_vals,
|
||||
f"{loss_str}{best_marker}",
|
||||
res.get("duration", "—"),
|
||||
res.get("duration", "-"),
|
||||
f"[{status_style}]{res['status']}[/]",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,13 @@ def train(
|
|||
except ImportError:
|
||||
console.print(
|
||||
"[red]wandb not installed.[/]\n"
|
||||
"Run: [bold]pip install wandb[/]"
|
||||
"Run: [bold]pip install 'soup-cli[wandb]'[/]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
except Exception as wandb_err:
|
||||
console.print(
|
||||
f"[red]wandb import error:[/] {wandb_err}\n"
|
||||
"Try: [bold]pip install 'wandb>=0.15.0,<0.18.0'[/]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
|
@ -101,6 +107,13 @@ def train(
|
|||
device, device_name = detect_device()
|
||||
gpu_info = get_gpu_info()
|
||||
|
||||
# Warn about quantization on CPU
|
||||
if device == "cpu" and cfg.training.quantization in ("4bit", "8bit"):
|
||||
console.print(
|
||||
f"[yellow]Warning: {cfg.training.quantization} quantization on CPU "
|
||||
"may cause errors. Consider using quantization: none for CPU.[/]"
|
||||
)
|
||||
|
||||
backend_label = cfg.backend
|
||||
if cfg.backend == "unsloth":
|
||||
backend_label = "unsloth [green](fast mode)[/]"
|
||||
|
|
@ -150,7 +163,7 @@ def train(
|
|||
raise typer.Exit()
|
||||
|
||||
if dry_run:
|
||||
console.print("[yellow]Dry run — validating data...[/]")
|
||||
console.print("[yellow]Dry run - validating data...[/]")
|
||||
dataset = load_dataset(cfg.data)
|
||||
console.print(f"[green]Data OK:[/] {len(dataset['train'])} train samples")
|
||||
if "val" in dataset:
|
||||
|
|
@ -236,7 +249,7 @@ def train(
|
|||
# Report
|
||||
console.print(
|
||||
Panel(
|
||||
f"Loss: [bold]{result['initial_loss']:.4f} → {result['final_loss']:.4f}[/]\n"
|
||||
f"Loss: [bold]{result['initial_loss']:.4f} -> {result['final_loss']:.4f}[/]\n"
|
||||
f"Duration: [bold]{result['duration']}[/]\n"
|
||||
f"Output: [bold]{result['output_dir']}[/]\n"
|
||||
f"Run ID: [bold]{run_id}[/]\n\n"
|
||||
|
|
|
|||
|
|
@ -46,10 +46,10 @@ def ui(
|
|||
Panel(
|
||||
f"URL: [bold]{url}[/]\n\n"
|
||||
f"Pages:\n"
|
||||
f" [bold]Dashboard[/] — View experiments, loss charts, system info\n"
|
||||
f" [bold]New Training[/] — Create config from templates, start training\n"
|
||||
f" [bold]Data Explorer[/] — Browse and inspect datasets\n"
|
||||
f" [bold]Model Chat[/] — Chat with a running inference server\n\n"
|
||||
f" [bold]Dashboard[/] - View experiments, loss charts, system info\n"
|
||||
f" [bold]New Training[/] - Create config from templates, start training\n"
|
||||
f" [bold]Data Explorer[/] - Browse and inspect datasets\n"
|
||||
f" [bold]Model Chat[/] - Chat with a running inference server\n\n"
|
||||
f"Press [bold]Ctrl+C[/] to stop.",
|
||||
title="[bold green]Soup Web UI[/]",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ def load_config(path: Path) -> SoupConfig:
|
|||
except ValidationError as e:
|
||||
console.print("[red bold]Config validation error:[/]\n")
|
||||
for err in e.errors():
|
||||
loc = " → ".join(str(part) for part in err["loc"])
|
||||
loc = " -> ".join(str(part) for part in err["loc"])
|
||||
console.print(f" [red]{loc}:[/] {err['msg']}")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
|
@ -46,6 +46,6 @@ def load_config_from_string(yaml_str: str) -> SoupConfig:
|
|||
except ValidationError as exc:
|
||||
errors = []
|
||||
for err in exc.errors():
|
||||
loc = " → ".join(str(part) for part in err["loc"])
|
||||
loc = " -> ".join(str(part) for part in err["loc"])
|
||||
errors.append(f"{loc}: {err['msg']}")
|
||||
raise ValueError("; ".join(errors))
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ FORMAT_SIGNATURES = {
|
|||
def detect_format(data: list[dict]) -> str:
|
||||
"""Auto-detect dataset format from first few rows."""
|
||||
if not data:
|
||||
raise ValueError("Empty dataset — cannot detect format")
|
||||
raise ValueError("Empty dataset - cannot detect format")
|
||||
|
||||
sample = data[0]
|
||||
keys = set(sample.keys())
|
||||
|
|
|
|||
|
|
@ -150,12 +150,12 @@ class DPOTrainerWrapper:
|
|||
|
||||
bnb_config = None
|
||||
if tcfg.quantization == "4bit":
|
||||
import torch
|
||||
from soup_cli.utils.gpu import get_compute_dtype
|
||||
|
||||
bnb_config = BitsAndBytesConfig(
|
||||
load_in_4bit=True,
|
||||
bnb_4bit_quant_type="nf4",
|
||||
bnb_4bit_compute_dtype=torch.bfloat16,
|
||||
bnb_4bit_compute_dtype=get_compute_dtype(),
|
||||
bnb_4bit_use_double_quant=True,
|
||||
)
|
||||
elif tcfg.quantization == "8bit":
|
||||
|
|
|
|||
|
|
@ -159,12 +159,12 @@ class GRPOTrainerWrapper:
|
|||
|
||||
bnb_config = None
|
||||
if tcfg.quantization == "4bit":
|
||||
import torch
|
||||
from soup_cli.utils.gpu import get_compute_dtype
|
||||
|
||||
bnb_config = BitsAndBytesConfig(
|
||||
load_in_4bit=True,
|
||||
bnb_4bit_quant_type="nf4",
|
||||
bnb_4bit_compute_dtype=torch.bfloat16,
|
||||
bnb_4bit_compute_dtype=get_compute_dtype(),
|
||||
bnb_4bit_use_double_quant=True,
|
||||
)
|
||||
elif tcfg.quantization == "8bit":
|
||||
|
|
|
|||
|
|
@ -108,17 +108,40 @@ class PPOTrainerWrapper:
|
|||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# --- PPO config ---
|
||||
ppo_config = PPOConfig(
|
||||
output_dir=str(output_dir),
|
||||
per_device_train_batch_size=batch_size,
|
||||
gradient_accumulation_steps=tcfg.gradient_accumulation_steps,
|
||||
learning_rate=tcfg.lr,
|
||||
ppo_epochs=tcfg.ppo_epochs,
|
||||
cliprange=tcfg.ppo_clip_ratio,
|
||||
init_kl_coef=tcfg.ppo_kl_penalty,
|
||||
log_with=self.report_to if self.report_to != "none" else None,
|
||||
optimize_cuda_cache=self.device == "cuda",
|
||||
)
|
||||
# Build kwargs, handling trl version differences
|
||||
ppo_kwargs = {
|
||||
"output_dir": str(output_dir),
|
||||
"per_device_train_batch_size": batch_size,
|
||||
"gradient_accumulation_steps": tcfg.gradient_accumulation_steps,
|
||||
"learning_rate": tcfg.lr,
|
||||
}
|
||||
|
||||
# trl renamed ppo_epochs -> num_ppo_epochs in newer versions
|
||||
import inspect
|
||||
|
||||
ppo_params = inspect.signature(PPOConfig).parameters
|
||||
if "num_ppo_epochs" in ppo_params:
|
||||
ppo_kwargs["num_ppo_epochs"] = tcfg.ppo_epochs
|
||||
elif "ppo_epochs" in ppo_params:
|
||||
ppo_kwargs["ppo_epochs"] = tcfg.ppo_epochs
|
||||
|
||||
if "cliprange" in ppo_params:
|
||||
ppo_kwargs["cliprange"] = tcfg.ppo_clip_ratio
|
||||
if "init_kl_coef" in ppo_params:
|
||||
ppo_kwargs["init_kl_coef"] = tcfg.ppo_kl_penalty
|
||||
|
||||
# Optional params that may not exist in all trl versions
|
||||
if "log_with" in ppo_params:
|
||||
ppo_kwargs["log_with"] = (
|
||||
self.report_to if self.report_to != "none" else None
|
||||
)
|
||||
elif "report_to" in ppo_params:
|
||||
ppo_kwargs["report_to"] = self.report_to
|
||||
|
||||
if "optimize_cuda_cache" in ppo_params:
|
||||
ppo_kwargs["optimize_cuda_cache"] = self.device == "cuda"
|
||||
|
||||
ppo_config = PPOConfig(**ppo_kwargs)
|
||||
|
||||
# --- Build reward functions list for PPOTrainer ---
|
||||
reward_funcs = []
|
||||
|
|
@ -177,12 +200,12 @@ class PPOTrainerWrapper:
|
|||
|
||||
bnb_config = None
|
||||
if tcfg.quantization == "4bit":
|
||||
import torch
|
||||
from soup_cli.utils.gpu import get_compute_dtype
|
||||
|
||||
bnb_config = BitsAndBytesConfig(
|
||||
load_in_4bit=True,
|
||||
bnb_4bit_quant_type="nf4",
|
||||
bnb_4bit_compute_dtype=torch.bfloat16,
|
||||
bnb_4bit_compute_dtype=get_compute_dtype(),
|
||||
bnb_4bit_use_double_quant=True,
|
||||
)
|
||||
elif tcfg.quantization == "8bit":
|
||||
|
|
|
|||
|
|
@ -157,12 +157,12 @@ class RewardModelTrainerWrapper:
|
|||
|
||||
bnb_config = None
|
||||
if tcfg.quantization == "4bit":
|
||||
import torch
|
||||
from soup_cli.utils.gpu import get_compute_dtype
|
||||
|
||||
bnb_config = BitsAndBytesConfig(
|
||||
load_in_4bit=True,
|
||||
bnb_4bit_quant_type="nf4",
|
||||
bnb_4bit_compute_dtype=torch.bfloat16,
|
||||
bnb_4bit_compute_dtype=get_compute_dtype(),
|
||||
bnb_4bit_use_double_quant=True,
|
||||
)
|
||||
elif tcfg.quantization == "8bit":
|
||||
|
|
|
|||
|
|
@ -162,12 +162,12 @@ class SFTTrainerWrapper:
|
|||
# Quantization
|
||||
bnb_config = None
|
||||
if tcfg.quantization == "4bit":
|
||||
import torch
|
||||
from soup_cli.utils.gpu import get_compute_dtype
|
||||
|
||||
bnb_config = BitsAndBytesConfig(
|
||||
load_in_4bit=True,
|
||||
bnb_4bit_quant_type="nf4",
|
||||
bnb_4bit_compute_dtype=torch.bfloat16,
|
||||
bnb_4bit_compute_dtype=get_compute_dtype(),
|
||||
bnb_4bit_use_double_quant=True,
|
||||
)
|
||||
elif tcfg.quantization == "8bit":
|
||||
|
|
@ -233,12 +233,12 @@ class SFTTrainerWrapper:
|
|||
# Quantization
|
||||
bnb_config = None
|
||||
if tcfg.quantization == "4bit":
|
||||
import torch
|
||||
from soup_cli.utils.gpu import get_compute_dtype
|
||||
|
||||
bnb_config = BitsAndBytesConfig(
|
||||
load_in_4bit=True,
|
||||
bnb_4bit_quant_type="nf4",
|
||||
bnb_4bit_compute_dtype=torch.bfloat16,
|
||||
bnb_4bit_compute_dtype=get_compute_dtype(),
|
||||
bnb_4bit_use_double_quant=True,
|
||||
)
|
||||
elif tcfg.quantization == "8bit":
|
||||
|
|
|
|||
|
|
@ -113,3 +113,18 @@ def model_size_from_name(model_name: str) -> float:
|
|||
return size
|
||||
|
||||
return 7.0 # default guess
|
||||
|
||||
|
||||
def get_compute_dtype():
|
||||
"""Return the best compute dtype for the current device.
|
||||
|
||||
Uses bfloat16 on CUDA GPUs that support it, float16 otherwise.
|
||||
On CPU, uses float32 to avoid dtype mismatch errors.
|
||||
"""
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
if torch.cuda.is_bf16_supported():
|
||||
return torch.bfloat16
|
||||
return torch.float16
|
||||
return torch.float32
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
"""Tests for v0.10.1 bug fixes - Windows Unicode, PPO params, dtype, diff."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from soup_cli.config.schema import SoupConfig
|
||||
|
||||
# --- BUG-001: Windows UnicodeEncodeError (no Unicode arrows/dashes in output) ---
|
||||
|
||||
|
||||
class TestNoUnicodeInOutput:
|
||||
"""Verify user-facing output uses only ASCII-safe characters."""
|
||||
|
||||
def test_config_loader_error_uses_ascii_arrow(self):
|
||||
"""Config validation errors should use -> not Unicode arrow."""
|
||||
from soup_cli.config.loader import load_config_from_string
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
load_config_from_string("base: x\ntask: invalid_task\n")
|
||||
# Error message should use -> not the Unicode arrow
|
||||
msg = str(exc_info.value)
|
||||
assert "\u2192" not in msg # no Unicode right arrow
|
||||
|
||||
def test_loss_format_uses_ascii(self):
|
||||
"""Loss formatting in runs should use -> not Unicode arrow."""
|
||||
from soup_cli.commands.runs import _fmt_loss
|
||||
|
||||
run = {"initial_loss": 1.5, "final_loss": 0.5}
|
||||
result = _fmt_loss(run)
|
||||
assert "->" in result
|
||||
assert "\u2192" not in result # no Unicode right arrow
|
||||
|
||||
def test_loss_format_missing_returns_ascii(self):
|
||||
"""Missing loss should return ASCII dash, not em dash."""
|
||||
from soup_cli.commands.runs import _fmt_loss
|
||||
|
||||
result = _fmt_loss({})
|
||||
assert result == "-"
|
||||
assert "\u2014" not in result # no em dash
|
||||
|
||||
def test_fmt_float_missing_returns_ascii(self):
|
||||
"""Missing float should return ASCII dash."""
|
||||
from soup_cli.commands.runs import _fmt_float
|
||||
|
||||
result = _fmt_float(None)
|
||||
assert result == "-"
|
||||
assert "\u2014" not in result
|
||||
|
||||
def test_fmt_duration_missing_returns_ascii(self):
|
||||
"""Missing duration should return ASCII dash."""
|
||||
from soup_cli.commands.runs import _fmt_duration
|
||||
|
||||
result = _fmt_duration(None)
|
||||
assert result == "-"
|
||||
assert "\u2014" not in result
|
||||
|
||||
def test_formats_empty_dataset_error_ascii(self):
|
||||
"""Empty dataset error should use ASCII dash."""
|
||||
from soup_cli.data.formats import detect_format
|
||||
|
||||
with pytest.raises(ValueError, match="Empty dataset"):
|
||||
detect_format([])
|
||||
|
||||
|
||||
# --- BUG-002: PPO ppo_epochs parameter compatibility ---
|
||||
|
||||
|
||||
class TestPPOParamCompat:
|
||||
"""Test PPO trainer handles trl version differences."""
|
||||
|
||||
def test_ppo_config_uses_inspect(self):
|
||||
"""PPO setup should use inspect to detect valid parameter names."""
|
||||
from soup_cli.trainer.ppo import PPOTrainerWrapper
|
||||
|
||||
cfg = SoupConfig(
|
||||
base="test-model",
|
||||
task="ppo",
|
||||
data={"train": "./data.jsonl"},
|
||||
training={
|
||||
"ppo_epochs": 3,
|
||||
"ppo_clip_ratio": 0.15,
|
||||
"ppo_kl_penalty": 0.03,
|
||||
},
|
||||
)
|
||||
wrapper = PPOTrainerWrapper(cfg, device="cpu")
|
||||
assert wrapper.config.training.ppo_epochs == 3
|
||||
assert wrapper.config.training.ppo_clip_ratio == pytest.approx(0.15)
|
||||
assert wrapper.config.training.ppo_kl_penalty == pytest.approx(0.03)
|
||||
|
||||
|
||||
# --- BUG-003: Reward Model dtype mismatch ---
|
||||
|
||||
|
||||
class TestComputeDtype:
|
||||
"""Test get_compute_dtype returns correct dtype for device."""
|
||||
|
||||
def test_cpu_returns_float32(self):
|
||||
"""CPU should use float32, not bfloat16."""
|
||||
import torch
|
||||
|
||||
from soup_cli.utils.gpu import get_compute_dtype
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=False):
|
||||
dtype = get_compute_dtype()
|
||||
assert dtype == torch.float32
|
||||
|
||||
def test_cuda_with_bf16_returns_bfloat16(self):
|
||||
"""CUDA with bf16 support should use bfloat16."""
|
||||
import torch
|
||||
|
||||
from soup_cli.utils.gpu import get_compute_dtype
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True), \
|
||||
patch("torch.cuda.is_bf16_supported", return_value=True):
|
||||
dtype = get_compute_dtype()
|
||||
assert dtype == torch.bfloat16
|
||||
|
||||
def test_cuda_without_bf16_returns_float16(self):
|
||||
"""CUDA without bf16 support should fall back to float16."""
|
||||
import torch
|
||||
|
||||
from soup_cli.utils.gpu import get_compute_dtype
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True), \
|
||||
patch("torch.cuda.is_bf16_supported", return_value=False):
|
||||
dtype = get_compute_dtype()
|
||||
assert dtype == torch.float16
|
||||
|
||||
|
||||
# --- BUG-005: diff dtype -> torch_dtype ---
|
||||
|
||||
|
||||
class TestDiffModelLoading:
|
||||
"""Test diff command uses correct parameter names."""
|
||||
|
||||
def test_load_model_uses_torch_dtype(self):
|
||||
"""_load_model should pass torch_dtype, not dtype."""
|
||||
import inspect
|
||||
|
||||
from soup_cli.commands.diff import _load_model
|
||||
|
||||
source = inspect.getsource(_load_model)
|
||||
assert "torch_dtype=" in source
|
||||
assert "dtype=" not in source or "torch_dtype=" in source
|
||||
|
||||
|
||||
# --- BUG-006: wandb version pin ---
|
||||
|
||||
|
||||
class TestWandbVersionPin:
|
||||
"""Test wandb dependency is version-pinned."""
|
||||
|
||||
def test_wandb_upper_bound_in_pyproject(self):
|
||||
"""pyproject.toml should pin wandb below 0.18.0."""
|
||||
pyproject = Path(__file__).parent.parent / "pyproject.toml"
|
||||
content = pyproject.read_text(encoding="utf-8")
|
||||
assert "<0.18.0" in content or "< 0.18.0" in content
|
||||
|
||||
|
||||
# --- BUG-004: CPU quantization warning ---
|
||||
|
||||
|
||||
class TestCPUQuantWarning:
|
||||
"""Test that CPU + quantization produces a warning."""
|
||||
|
||||
def test_train_source_has_cpu_quant_warning(self):
|
||||
"""train.py should warn about quantization on CPU."""
|
||||
import inspect
|
||||
|
||||
from soup_cli.commands import train
|
||||
|
||||
source = inspect.getsource(train)
|
||||
assert "quantization on CPU" in source
|
||||
Loading…
Reference in New Issue