From 428c0f09a43dbeea1b7b17a914a399f221ff5588 Mon Sep 17 00:00:00 2001 From: Alpamys Date: Tue, 24 Mar 2026 12:31:37 +0500 Subject: [PATCH] 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) --- CLAUDE.md | 1 + plan.md | 130 ++++++++++++++++++++--- pyproject.toml | 4 +- soup_cli/__init__.py | 2 +- soup_cli/commands/chat.py | 6 +- soup_cli/commands/data.py | 6 +- soup_cli/commands/diff.py | 4 +- soup_cli/commands/doctor.py | 4 +- soup_cli/commands/eval.py | 4 +- soup_cli/commands/export.py | 4 +- soup_cli/commands/quickstart.py | 2 +- soup_cli/commands/runs.py | 56 +++++----- soup_cli/commands/sweep.py | 6 +- soup_cli/commands/train.py | 19 +++- soup_cli/commands/ui.py | 8 +- soup_cli/config/loader.py | 4 +- soup_cli/data/formats.py | 2 +- soup_cli/trainer/dpo.py | 4 +- soup_cli/trainer/grpo.py | 4 +- soup_cli/trainer/ppo.py | 49 ++++++--- soup_cli/trainer/reward_model.py | 4 +- soup_cli/trainer/sft.py | 8 +- soup_cli/utils/gpu.py | 15 +++ tests/test_bugfixes.py | 175 +++++++++++++++++++++++++++++++ 24 files changed, 426 insertions(+), 95 deletions(-) create mode 100644 tests/test_bugfixes.py diff --git a/CLAUDE.md b/CLAUDE.md index 96721d7..4784e71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/plan.md b/plan.md index 44380de..5f40747 100644 --- a/plan.md +++ b/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. diff --git a/pyproject.toml b/pyproject.toml index 8d25063..69eee1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/soup_cli/__init__.py b/soup_cli/__init__.py index f79b004..899aec4 100644 --- a/soup_cli/__init__.py +++ b/soup_cli/__init__.py @@ -1,3 +1,3 @@ """Soup CLI — Fine-tune LLMs in one command.""" -__version__ = "0.10.0" +__version__ = "0.10.1" diff --git a/soup_cli/commands/chat.py b/soup_cli/commands/chat.py index 37975b7..9097e24 100644 --- a/soup_cli/commands/chat.py +++ b/soup_cli/commands/chat.py @@ -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 [/] — set system prompt") + console.print(" [dim]/quit[/] - exit chat") + console.print(" [dim]/clear[/] - reset conversation history") + console.print(" [dim]/system [/] - set system prompt") console.print() # Chat loop diff --git a/soup_cli/commands/data.py b/soup_cli/commands/data.py index 853e40d..86a0796 100644 --- a/soup_cli/commands/data.py +++ b/soup_cli/commands/data.py @@ -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}[/]" ) diff --git a/soup_cli/commands/diff.py b/soup_cli/commands/diff.py index 0a3e45c..7fde86f 100644 --- a/soup_cli/commands/diff.py +++ b/soup_cli/commands/diff.py @@ -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() diff --git a/soup_cli/commands/doctor.py b/soup_cli/commands/doctor.py index 558e3ad..0c11387 100644 --- a/soup_cli/commands/doctor.py +++ b/soup_cli/commands/doctor.py @@ -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, ) diff --git a/soup_cli/commands/eval.py b/soup_cli/commands/eval.py index f1b4e92..c6ed224 100644 --- a/soup_cli/commands/eval.py +++ b/soup_cli/commands/eval.py @@ -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) diff --git a/soup_cli/commands/export.py b/soup_cli/commands/export.py index 1f467de..aa7612b 100644 --- a/soup_cli/commands/export.py +++ b/soup_cli/commands/export.py @@ -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 diff --git a/soup_cli/commands/quickstart.py b/soup_cli/commands/quickstart.py index 0cbd228..08422b0 100644 --- a/soup_cli/commands/quickstart.py +++ b/soup_cli/commands/quickstart.py @@ -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() diff --git a/soup_cli/commands/runs.py b/soup_cli/commands/runs.py index c17f9fe..b1ae757 100644 --- a/soup_cli/commands/runs.py +++ b/soup_cli/commands/runs.py @@ -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: diff --git a/soup_cli/commands/sweep.py b/soup_cli/commands/sweep.py index d35bdee..1364f28 100644 --- a/soup_cli/commands/sweep.py +++ b/soup_cli/commands/sweep.py @@ -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']}[/]", ) diff --git a/soup_cli/commands/train.py b/soup_cli/commands/train.py index 18682a2..4011200 100644 --- a/soup_cli/commands/train.py +++ b/soup_cli/commands/train.py @@ -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" diff --git a/soup_cli/commands/ui.py b/soup_cli/commands/ui.py index 7c4c6b2..f08a518 100644 --- a/soup_cli/commands/ui.py +++ b/soup_cli/commands/ui.py @@ -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[/]", ) diff --git a/soup_cli/config/loader.py b/soup_cli/config/loader.py index 1ebb592..c6fa537 100644 --- a/soup_cli/config/loader.py +++ b/soup_cli/config/loader.py @@ -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)) diff --git a/soup_cli/data/formats.py b/soup_cli/data/formats.py index 255ed4c..6a50115 100644 --- a/soup_cli/data/formats.py +++ b/soup_cli/data/formats.py @@ -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()) diff --git a/soup_cli/trainer/dpo.py b/soup_cli/trainer/dpo.py index 12ec93c..c4eef31 100644 --- a/soup_cli/trainer/dpo.py +++ b/soup_cli/trainer/dpo.py @@ -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": diff --git a/soup_cli/trainer/grpo.py b/soup_cli/trainer/grpo.py index 3905ba6..515017f 100644 --- a/soup_cli/trainer/grpo.py +++ b/soup_cli/trainer/grpo.py @@ -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": diff --git a/soup_cli/trainer/ppo.py b/soup_cli/trainer/ppo.py index bcc9aec..cc81c74 100644 --- a/soup_cli/trainer/ppo.py +++ b/soup_cli/trainer/ppo.py @@ -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": diff --git a/soup_cli/trainer/reward_model.py b/soup_cli/trainer/reward_model.py index 40dc7fe..89ce24f 100644 --- a/soup_cli/trainer/reward_model.py +++ b/soup_cli/trainer/reward_model.py @@ -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": diff --git a/soup_cli/trainer/sft.py b/soup_cli/trainer/sft.py index ec03658..c2f22a4 100644 --- a/soup_cli/trainer/sft.py +++ b/soup_cli/trainer/sft.py @@ -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": diff --git a/soup_cli/utils/gpu.py b/soup_cli/utils/gpu.py index 210d970..ec8ccf8 100644 --- a/soup_cli/utils/gpu.py +++ b/soup_cli/utils/gpu.py @@ -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 diff --git a/tests/test_bugfixes.py b/tests/test_bugfixes.py new file mode 100644 index 0000000..8c95274 --- /dev/null +++ b/tests/test_bugfixes.py @@ -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