commit d6e932a1d3e97e75a1721c0b178e66dcc1d0e616 Author: Alpamys Date: Fri Feb 20 16:14:56 2026 +0500 Initial project setup: CLI skeleton + config + trainer + data pipeline - Typer CLI: soup init, soup train, soup data inspect/validate - Pydantic config schema with YAML loader and validation - Data pipeline: JSONL/JSON/CSV/Parquet + HuggingFace datasets - Format detection: Alpaca, ShareGPT, ChatML (auto-detect) - SFT trainer wrapper over transformers + peft + trl - QLoRA/LoRA support with auto batch size estimation - GPU detection (CUDA/MPS/CPU) and memory calculation - Rich live terminal dashboard for training monitoring - Config templates: chat, code, medical - Tests (pytest) + GitHub Actions CI - MIT license Co-Authored-By: Claude Opus 4.6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d5b9e0d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install ruff + - run: ruff check soup_cli/ tests/ + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e ".[dev]" + - run: pytest tests/ -v --tb=short diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..115e2ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +*.egg +.eggs/ + +# Virtual env +.venv/ +venv/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Training outputs +output/ +checkpoints/ +wandb/ + +# Soup local data +.soup/ +*.db + +# Secrets +.env +*.key diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7a5f985 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Soup Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8724c0 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# 🍜 Soup + +**Fine-tune LLMs in one command. No SSH, no config hell.** + +Soup turns the pain of LLM fine-tuning into a simple workflow. One config, one command, done. + +```bash +pip install soup-cli +soup init --template chat +soup train +``` + +## Why Soup? + +Training LLMs is still painful. Even experienced teams spend 30-50% of their time fighting infrastructure instead of improving models. Soup fixes that. + +- **Zero SSH.** Never SSH into a broken GPU box again. +- **One config.** A simple YAML file is all you need. +- **Auto everything.** Batch size, GPU detection, quantization β€” handled. +- **Works locally.** Train on your own GPU with QLoRA. No cloud required. + +## Quick Start + +### 1. Install + +```bash +pip install soup-cli +``` + +### 2. Create config + +```bash +# Interactive wizard +soup init + +# Or use a template +soup init --template chat # conversational fine-tune +soup init --template code # code generation +soup init --template medical # domain expert +``` + +### 3. Train + +```bash +soup train --config soup.yaml +``` + +That's it. Soup handles LoRA setup, quantization, batch size, monitoring, and checkpoints. + +### 4. Test your model + +```bash +soup chat --model ./output +``` + +### 5. Push to HuggingFace + +```bash +soup push --model ./output --repo your-username/my-model +``` + +## Config Example + +```yaml +base: meta-llama/Llama-3.1-8B-Instruct +task: sft + +data: + train: ./data/train.jsonl + format: alpaca + val_split: 0.1 + +training: + epochs: 3 + lr: 2e-5 + batch_size: auto + lora: + r: 64 + alpha: 16 + quantization: 4bit + +output: ./output +``` + +## Data Formats + +Soup supports these formats (auto-detected): + +**Alpaca:** +```json +{"instruction": "Explain gravity", "input": "", "output": "Gravity is..."} +``` + +**ShareGPT:** +```json +{"conversations": [{"from": "human", "value": "Hi"}, {"from": "gpt", "value": "Hello!"}]} +``` + +**ChatML:** +```json +{"messages": [{"role": "user", "content": "Hi"}, {"role": "assistant", "content": "Hello!"}]} +``` + +## Data Tools + +```bash +# Inspect a dataset +soup data inspect ./data/train.jsonl + +# Validate format +soup data validate ./data/train.jsonl --format alpaca +``` + +## Features + +| Feature | Status | +|---|---| +| LoRA / QLoRA fine-tuning | βœ… | +| SFT (Supervised Fine-Tune) | βœ… | +| DPO (Direct Preference Optimization) | πŸ”œ | +| Auto batch size | βœ… | +| Auto GPU detection (CUDA/MPS/CPU) | βœ… | +| Live terminal dashboard | βœ… | +| Alpaca / ShareGPT / ChatML formats | βœ… | +| HuggingFace datasets support | βœ… | +| Experiment tracking | πŸ”œ | +| Web dashboard | πŸ”œ | +| Cloud mode (BYOG) | πŸ”œ | + +## Requirements + +- Python 3.9+ +- GPU with CUDA (recommended) or Apple Silicon (MPS) or CPU (slow) +- 8 GB+ VRAM for 7B models with QLoRA + +## Development + +```bash +git clone https://github.com/MakazhanAlpamys/Soup.git +cd Soup +pip install -e ".[dev]" +pytest tests/ -v +``` + +## License + +MIT diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..31b1387 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "soup-cli" +version = "0.1.0" +description = "Fine-tune LLMs in one command. No SSH, no config hell." +readme = "README.md" +license = "MIT" +requires-python = ">=3.9" +authors = [ + { name = "Soup Team" }, +] +keywords = ["llm", "fine-tuning", "lora", "qlora", "machine-learning"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] +dependencies = [ + "typer>=0.9.0", + "rich>=13.0.0", + "pydantic>=2.0.0", + "pyyaml>=6.0", + "torch>=2.0.0", + "transformers>=4.36.0", + "peft>=0.7.0", + "trl>=0.7.0", + "datasets>=2.14.0", + "bitsandbytes>=0.41.0", + "accelerate>=0.25.0", +] + +[project.optional-dependencies] +eval = ["lm-eval>=0.4.0"] +dev = ["pytest>=7.0", "ruff>=0.1.0", "pytest-cov>=4.0"] +ui = ["fastapi>=0.104.0", "uvicorn>=0.24.0"] + +[project.scripts] +soup = "soup_cli.cli:app" + +[project.urls] +Homepage = "https://github.com/MakazhanAlpamys/Soup" +Repository = "https://github.com/MakazhanAlpamys/Soup" +Issues = "https://github.com/MakazhanAlpamys/Soup/issues" + +[tool.ruff] +target-version = "py39" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/soup_cli/__init__.py b/soup_cli/__init__.py new file mode 100644 index 0000000..cca05c1 --- /dev/null +++ b/soup_cli/__init__.py @@ -0,0 +1,3 @@ +"""Soup CLI β€” Fine-tune LLMs in one command.""" + +__version__ = "0.1.0" diff --git a/soup_cli/__main__.py b/soup_cli/__main__.py new file mode 100644 index 0000000..85c1a5d --- /dev/null +++ b/soup_cli/__main__.py @@ -0,0 +1,5 @@ +"""Allow running as `python -m soup_cli`.""" + +from soup_cli.cli import app + +app() diff --git a/soup_cli/cli.py b/soup_cli/cli.py new file mode 100644 index 0000000..0f33e1e --- /dev/null +++ b/soup_cli/cli.py @@ -0,0 +1,33 @@ +"""Main CLI entry point β€” all commands registered here.""" + +import typer +from rich.console import Console + +from soup_cli import __version__ +from soup_cli.commands import data, init, train + +console = Console() + +app = typer.Typer( + name="soup", + help="Fine-tune LLMs in one command. No SSH, no config hell.", + no_args_is_help=True, + rich_markup_mode="rich", +) + +# Register sub-commands +app.command()(init.init) +app.command()(train.train) +app.add_typer(data.app, name="data", help="Dataset tools: inspect, convert, validate.") + + +@app.command() +def version(): + """Show Soup CLI version.""" + console.print(f"[bold green]soup[/] v{__version__}") + + +@app.callback(invoke_without_command=True) +def main(ctx: typer.Context): + """Soup β€” fine-tune LLMs in one command.""" + pass diff --git a/soup_cli/commands/__init__.py b/soup_cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/soup_cli/commands/data.py b/soup_cli/commands/data.py new file mode 100644 index 0000000..ba1100c --- /dev/null +++ b/soup_cli/commands/data.py @@ -0,0 +1,78 @@ +"""soup data β€” dataset inspection and tools.""" + +from pathlib import Path + +import typer +from rich.console import Console +from rich.table import Table + +from soup_cli.data.loader import load_raw_data +from soup_cli.data.validator import validate_and_stats + +console = Console() + +app = typer.Typer(no_args_is_help=True) + + +@app.command() +def inspect( + path: str = typer.Argument(..., help="Path to dataset file (jsonl, csv, parquet)"), + rows: int = typer.Option(5, "--rows", "-r", help="Number of sample rows to show"), +): + """Inspect a dataset: show stats and sample rows.""" + file_path = Path(path) + if not file_path.exists(): + console.print(f"[red]File not found: {file_path}[/]") + raise typer.Exit(1) + + console.print(f"[dim]Inspecting {file_path}...[/]\n") + data = load_raw_data(file_path) + stats = validate_and_stats(data) + + # Print stats + stats_table = Table(title="Dataset Stats") + stats_table.add_column("Metric", style="bold") + stats_table.add_column("Value") + stats_table.add_row("Total samples", str(stats["total"])) + stats_table.add_row("Columns", ", ".join(stats["columns"])) + stats_table.add_row("Avg length (chars)", str(stats["avg_length"])) + stats_table.add_row("Min length", str(stats["min_length"])) + stats_table.add_row("Max length", str(stats["max_length"])) + stats_table.add_row("Empty fields", str(stats["empty_fields"])) + stats_table.add_row("Duplicates", str(stats["duplicates"])) + console.print(stats_table) + + # Print sample rows + if rows > 0 and len(data) > 0: + console.print(f"\n[bold]Sample rows ({min(rows, len(data))}):[/]") + sample_table = Table(show_lines=True) + for col in stats["columns"][:5]: # max 5 columns + sample_table.add_column(col, max_width=60) + for row in data[: min(rows, len(data))]: + values = [str(row.get(col, ""))[:60] for col in stats["columns"][:5]] + sample_table.add_row(*values) + console.print(sample_table) + + +@app.command() +def validate( + path: str = typer.Argument(..., help="Path to dataset file"), + format: str = typer.Option("alpaca", "--format", "-f", help="Expected format: alpaca, sharegpt, chatml"), +): + """Validate dataset format and report issues.""" + file_path = Path(path) + if not file_path.exists(): + console.print(f"[red]File not found: {file_path}[/]") + raise typer.Exit(1) + + data = load_raw_data(file_path) + stats = validate_and_stats(data, expected_format=format) + + if stats["issues"]: + console.print("[yellow]Issues found:[/]") + for issue in stats["issues"]: + console.print(f" [yellow]![/] {issue}") + else: + console.print("[bold green]Dataset is valid![/]") + + console.print(f"\n[green]{stats['valid_rows']}/{stats['total']} rows valid for {format} format[/]") diff --git a/soup_cli/commands/init.py b/soup_cli/commands/init.py new file mode 100644 index 0000000..3042d82 --- /dev/null +++ b/soup_cli/commands/init.py @@ -0,0 +1,95 @@ +"""soup init β€” interactive project setup wizard.""" + +from pathlib import Path + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Prompt + +from soup_cli.config.schema import TEMPLATES + +console = Console() + + +def init( + template: str = typer.Option( + None, + "--template", + "-t", + help="Use a template: chat, code, medical", + ), + output: str = typer.Option( + "soup.yaml", + "--output", + "-o", + help="Output config file path", + ), +): + """Create a new soup.yaml config interactively or from a template.""" + output_path = Path(output) + + if output_path.exists(): + overwrite = typer.confirm(f"{output_path} already exists. Overwrite?") + if not overwrite: + raise typer.Exit() + + if template: + if template not in TEMPLATES: + console.print(f"[red]Unknown template: {template}[/]") + console.print(f"Available: {', '.join(TEMPLATES.keys())}") + raise typer.Exit(1) + config_text = TEMPLATES[template] + console.print(f"[green]Using template:[/] {template}") + else: + config_text = _interactive_wizard() + + output_path.write_text(config_text, encoding="utf-8") + console.print( + Panel( + f"[bold green]Config saved to {output_path}[/]\n\n" + f"Next step: [bold]soup train --config {output_path}[/]", + title="Ready!", + ) + ) + + +def _interactive_wizard() -> str: + """Walk user through config creation.""" + console.print(Panel("[bold]Soup Config Wizard[/]", subtitle="Let's set up your training")) + + base_model = Prompt.ask( + "Base model", + default="meta-llama/Llama-3.1-8B-Instruct", + ) + task = Prompt.ask("Task", choices=["sft", "dpo"], default="sft") + data_path = Prompt.ask("Training data path", default="./data/train.jsonl") + data_format = Prompt.ask("Data format", choices=["alpaca", "sharegpt", "chatml"], default="alpaca") + epochs = Prompt.ask("Epochs", default="3") + use_qlora = Prompt.ask("Use QLoRA (4-bit)?", choices=["yes", "no"], default="yes") + + quantization = "4bit" if use_qlora == "yes" else "none" + + return f"""# Soup training config +# Docs: https://github.com/MakazhanAlpamys/Soup + +base: {base_model} +task: {task} + +data: + train: {data_path} + format: {data_format} + val_split: 0.1 + +training: + epochs: {epochs} + lr: 2e-5 + batch_size: auto + lora: + r: 64 + alpha: 16 + target_modules: auto + quantization: {quantization} + +output: ./output +""" diff --git a/soup_cli/commands/train.py b/soup_cli/commands/train.py new file mode 100644 index 0000000..2cb5448 --- /dev/null +++ b/soup_cli/commands/train.py @@ -0,0 +1,97 @@ +"""soup train β€” the main training command.""" + +from pathlib import Path + +import typer +from rich.console import Console +from rich.panel import Panel + +from soup_cli.config.loader import load_config +from soup_cli.data.loader import load_dataset +from soup_cli.monitoring.display import TrainingDisplay +from soup_cli.trainer.sft import SFTTrainerWrapper +from soup_cli.utils.gpu import detect_device, get_gpu_info + +console = Console() + + +def train( + config: str = typer.Option( + "soup.yaml", + "--config", + "-c", + help="Path to soup.yaml config file", + ), + name: str = typer.Option( + None, + "--name", + "-n", + help="Experiment name (auto-generated if not set)", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Validate config and data without training", + ), +): + """Start training from a soup.yaml config.""" + config_path = Path(config) + if not config_path.exists(): + console.print(f"[red]Config not found: {config_path}[/]") + console.print("Run [bold]soup init[/] to create one.") + raise typer.Exit(1) + + # Load & validate config + console.print(f"[dim]Loading config from {config_path}...[/]") + cfg = load_config(config_path) + + # Detect hardware + device, device_name = detect_device() + gpu_info = get_gpu_info() + console.print( + Panel( + f"Device: [bold]{device_name}[/]\n" + f"Memory: [bold]{gpu_info['memory_total']}[/]\n" + f"Model: [bold]{cfg.base}[/]\n" + f"Task: [bold]{cfg.task}[/]\n" + f"LoRA: [bold]r={cfg.training.lora.r}, alpha={cfg.training.lora.alpha}[/]\n" + f"Quant: [bold]{cfg.training.quantization}[/]", + title="Training Setup", + ) + ) + + if dry_run: + 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: + console.print(f"[green]Val:[/] {len(dataset['val'])} samples") + console.print("[green]Config valid. Ready to train![/]") + raise typer.Exit() + + # Load data + console.print("[dim]Loading dataset...[/]") + dataset = load_dataset(cfg.data) + console.print(f"[green]Loaded:[/] {len(dataset['train'])} train samples") + + # Build trainer + console.print("[dim]Setting up model + trainer...[/]") + trainer_wrapper = SFTTrainerWrapper(cfg, device=device) + trainer_wrapper.setup(dataset) + + # Train with live display + display = TrainingDisplay(cfg, device_name=device_name) + console.print("[bold green]Training started![/]\n") + result = trainer_wrapper.train(display=display) + + # Report + console.print( + Panel( + 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\n" + f"Quick test: [bold]soup chat --model {result['output_dir']}[/]\n" + f"Push to HF: [bold]soup push --model {result['output_dir']}[/]", + title="[bold green]Training Complete![/]", + ) + ) diff --git a/soup_cli/config/__init__.py b/soup_cli/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/soup_cli/config/loader.py b/soup_cli/config/loader.py new file mode 100644 index 0000000..f1072cb --- /dev/null +++ b/soup_cli/config/loader.py @@ -0,0 +1,31 @@ +"""Load and validate soup.yaml configs.""" + +from pathlib import Path + +import yaml +from pydantic import ValidationError +from rich.console import Console + +from soup_cli.config.schema import SoupConfig + +console = Console() + + +def load_config(path: Path) -> SoupConfig: + """Load a soup.yaml file and return validated SoupConfig.""" + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + + if raw is None: + console.print("[red]Config file is empty[/]") + raise SystemExit(1) + + try: + config = SoupConfig(**raw) + except ValidationError as e: + console.print("[red bold]Config validation error:[/]\n") + for err in e.errors(): + loc = " β†’ ".join(str(l) for l in err["loc"]) + console.print(f" [red]{loc}:[/] {err['msg']}") + raise SystemExit(1) + + return config diff --git a/soup_cli/config/schema.py b/soup_cli/config/schema.py new file mode 100644 index 0000000..8259f5d --- /dev/null +++ b/soup_cli/config/schema.py @@ -0,0 +1,137 @@ +"""Pydantic schemas for soup.yaml config β€” single source of truth.""" + +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +class LoraConfig(BaseModel): + r: int = Field(default=64, description="LoRA rank") + alpha: int = Field(default=16, description="LoRA alpha") + dropout: float = Field(default=0.05, description="LoRA dropout") + target_modules: str | list[str] = Field( + default="auto", + description="Target modules for LoRA. 'auto' = let peft decide.", + ) + + +class DataConfig(BaseModel): + train: str = Field(..., description="Path to training data or HF dataset name") + format: Literal["alpaca", "sharegpt", "chatml", "auto"] = Field( + default="auto", + description="Data format", + ) + val_split: float = Field(default=0.1, ge=0.0, le=0.5, description="Validation split ratio") + max_length: int = Field(default=2048, description="Max sequence length in tokens") + + +class TrainingConfig(BaseModel): + epochs: int = Field(default=3, ge=1, description="Number of training epochs") + lr: float = Field(default=2e-5, gt=0, description="Learning rate") + batch_size: int | Literal["auto"] = Field( + default="auto", + description="Batch size. 'auto' = find max that fits in memory.", + ) + gradient_accumulation_steps: int = Field(default=4, ge=1) + warmup_ratio: float = Field(default=0.03, ge=0.0, le=0.5) + weight_decay: float = Field(default=0.01, ge=0.0) + max_grad_norm: float = Field(default=1.0, gt=0) + lora: LoraConfig = Field(default_factory=LoraConfig) + quantization: Literal["4bit", "8bit", "none"] = Field( + default="4bit", + description="Quantization: 4bit (QLoRA), 8bit, or none (full precision)", + ) + optimizer: str = Field(default="adamw_torch", description="Optimizer name") + scheduler: str = Field(default="cosine", description="LR scheduler type") + save_steps: int = Field(default=100, description="Save checkpoint every N steps") + logging_steps: int = Field(default=10, description="Log metrics every N steps") + + +class SoupConfig(BaseModel): + """Root config for soup.yaml.""" + + base: str = Field(..., description="Base model name or path (HF model ID)") + task: Literal["sft", "dpo"] = Field(default="sft", description="Training task type") + data: DataConfig + training: TrainingConfig = Field(default_factory=TrainingConfig) + output: str = Field(default="./output", description="Output directory for trained model") + experiment_name: Optional[str] = Field(default=None, description="Experiment name for tracking") + + +# --- Built-in templates --- + +TEMPLATES: dict[str, str] = { + "chat": """# Soup template: Chat Assistant +# Fine-tune a model for conversational chat + +base: meta-llama/Llama-3.1-8B-Instruct +task: sft + +data: + train: ./data/train.jsonl + format: alpaca + val_split: 0.1 + max_length: 2048 + +training: + epochs: 3 + lr: 2e-5 + batch_size: auto + lora: + r: 64 + alpha: 16 + target_modules: auto + quantization: 4bit + +output: ./output +""", + "code": """# Soup template: Code Model +# Fine-tune a model for code generation / completion + +base: codellama/CodeLlama-7b-Instruct-hf +task: sft + +data: + train: ./data/code_train.jsonl + format: alpaca + val_split: 0.1 + max_length: 4096 + +training: + epochs: 2 + lr: 1e-5 + batch_size: auto + lora: + r: 128 + alpha: 32 + target_modules: auto + quantization: 4bit + +output: ./output +""", + "medical": """# Soup template: Medical / Domain Expert +# Fine-tune a model with domain-specific knowledge + +base: meta-llama/Llama-3.1-8B-Instruct +task: sft + +data: + train: ./data/medical_train.jsonl + format: alpaca + val_split: 0.15 + max_length: 2048 + +training: + epochs: 5 + lr: 1e-5 + batch_size: auto + gradient_accumulation_steps: 8 + lora: + r: 128 + alpha: 32 + target_modules: auto + quantization: 4bit + +output: ./output +""", +} diff --git a/soup_cli/data/__init__.py b/soup_cli/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/soup_cli/data/formats.py b/soup_cli/data/formats.py new file mode 100644 index 0000000..e26106c --- /dev/null +++ b/soup_cli/data/formats.py @@ -0,0 +1,92 @@ +"""Dataset format detection and conversion. + +Supported formats: +- alpaca: {"instruction": ..., "input": ..., "output": ...} +- sharegpt: {"conversations": [{"from": "human", "value": ...}, ...]} +- chatml: {"messages": [{"role": "user", "content": ...}, ...]} +""" + +from typing import Optional + +from rich.console import Console + +console = Console() + +# Required keys per format +FORMAT_SIGNATURES = { + "alpaca": {"instruction", "output"}, + "sharegpt": {"conversations"}, + "chatml": {"messages"}, +} + + +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") + + sample = data[0] + keys = set(sample.keys()) + + for fmt, required_keys in FORMAT_SIGNATURES.items(): + if required_keys.issubset(keys): + return fmt + + raise ValueError( + f"Cannot detect format. Keys found: {keys}. " + f"Expected one of: alpaca (instruction, output), " + f"sharegpt (conversations), chatml (messages)" + ) + + +def format_to_messages(row: dict, fmt: str) -> Optional[dict]: + """Convert any format to unified messages format for training. + + Returns: {"messages": [{"role": ..., "content": ...}, ...]} + """ + try: + if fmt == "chatml": + return _convert_chatml(row) + elif fmt == "alpaca": + return _convert_alpaca(row) + elif fmt == "sharegpt": + return _convert_sharegpt(row) + else: + raise ValueError(f"Unknown format: {fmt}") + except (KeyError, TypeError, IndexError): + return None + + +def _convert_alpaca(row: dict) -> dict: + instruction = row["instruction"] + input_text = row.get("input", "") + output = row["output"] + + user_content = f"{instruction}\n{input_text}".strip() if input_text else instruction + + messages = [ + {"role": "user", "content": user_content}, + {"role": "assistant", "content": output}, + ] + + if row.get("system"): + messages.insert(0, {"role": "system", "content": row["system"]}) + + return {"messages": messages} + + +def _convert_sharegpt(row: dict) -> dict: + conversations = row["conversations"] + role_map = {"human": "user", "gpt": "assistant", "system": "system"} + + messages = [] + for turn in conversations: + role = role_map.get(turn["from"], turn["from"]) + messages.append({"role": role, "content": turn["value"]}) + + return {"messages": messages} + + +def _convert_chatml(row: dict) -> dict: + # Already in the right format + return {"messages": row["messages"]} diff --git a/soup_cli/data/loader.py b/soup_cli/data/loader.py new file mode 100644 index 0000000..4f7bd40 --- /dev/null +++ b/soup_cli/data/loader.py @@ -0,0 +1,146 @@ +"""Data loading from local files and HuggingFace.""" + +import json +from pathlib import Path + +from rich.console import Console + +from soup_cli.config.schema import DataConfig +from soup_cli.data.formats import detect_format, format_to_messages + +console = Console() + +# File extensions we support +SUPPORTED_EXTENSIONS = {".jsonl", ".json", ".csv", ".parquet"} + + +def load_raw_data(path: Path) -> list[dict]: + """Load raw data from a file into list of dicts.""" + if not path.exists(): + raise FileNotFoundError(f"Data file not found: {path}") + + ext = path.suffix.lower() + if ext not in SUPPORTED_EXTENSIONS: + raise ValueError(f"Unsupported file format: {ext}. Supported: {SUPPORTED_EXTENSIONS}") + + if ext == ".jsonl": + return _load_jsonl(path) + elif ext == ".json": + return _load_json(path) + elif ext == ".csv": + return _load_csv(path) + elif ext == ".parquet": + return _load_parquet(path) + + raise ValueError(f"Unsupported format: {ext}") + + +def _load_jsonl(path: Path) -> list[dict]: + data = [] + with open(path, encoding="utf-8") as f: + for i, line in enumerate(f): + line = line.strip() + if not line: + continue + try: + data.append(json.loads(line)) + except json.JSONDecodeError as e: + console.print(f"[yellow]Warning: invalid JSON on line {i + 1}: {e}[/]") + return data + + +def _load_json(path: Path) -> list[dict]: + with open(path, encoding="utf-8") as f: + raw = json.load(f) + if isinstance(raw, list): + return raw + raise ValueError("JSON file must contain a list of objects") + + +def _load_csv(path: Path) -> list[dict]: + import csv + + with open(path, encoding="utf-8") as f: + reader = csv.DictReader(f) + return list(reader) + + +def _load_parquet(path: Path) -> list[dict]: + try: + import pandas as pd + except ImportError: + raise ImportError("Install pandas to read parquet files: pip install pandas pyarrow") + df = pd.read_parquet(path) + return df.to_dict(orient="records") + + +def load_dataset(data_config: DataConfig) -> dict: + """Load dataset for training. Returns dict with 'train' and optionally 'val' keys. + + Supports: + - Local files (.jsonl, .json, .csv, .parquet) + - HuggingFace dataset names (auto-detected if no file extension) + """ + train_path = data_config.train + + # Check if it's a HuggingFace dataset + if not Path(train_path).suffix: + return _load_hf_dataset(train_path, data_config) + + # Local file + path = Path(train_path) + raw_data = load_raw_data(path) + + # Detect or use specified format + fmt = data_config.format + if fmt == "auto": + fmt = detect_format(raw_data) + console.print(f"[dim]Auto-detected format: {fmt}[/]") + + # Convert to standard message format + formatted = [format_to_messages(row, fmt) for row in raw_data] + formatted = [r for r in formatted if r is not None] # filter failed rows + + # Split into train/val + if data_config.val_split > 0: + split_idx = int(len(formatted) * (1 - data_config.val_split)) + return { + "train": formatted[:split_idx], + "val": formatted[split_idx:], + } + + return {"train": formatted} + + +def _load_hf_dataset(name: str, data_config: DataConfig) -> dict: + """Load a dataset from HuggingFace Hub.""" + try: + from datasets import load_dataset as hf_load + except ImportError: + raise ImportError("Install datasets: pip install datasets") + + console.print(f"[dim]Loading from HuggingFace: {name}[/]") + ds = hf_load(name) + + if "train" not in ds: + raise ValueError(f"Dataset {name} has no 'train' split") + + raw_data = [dict(row) for row in ds["train"]] + fmt = data_config.format + if fmt == "auto": + fmt = detect_format(raw_data) + + formatted = [format_to_messages(row, fmt) for row in raw_data] + formatted = [r for r in formatted if r is not None] + + if data_config.val_split > 0 and "validation" not in ds: + split_idx = int(len(formatted) * (1 - data_config.val_split)) + return {"train": formatted[:split_idx], "val": formatted[split_idx:]} + + result = {"train": formatted} + if "validation" in ds: + val_data = [dict(row) for row in ds["validation"]] + val_formatted = [format_to_messages(row, fmt) for row in val_data] + result["val"] = [r for r in val_formatted if r is not None] + + return result diff --git a/soup_cli/data/validator.py b/soup_cli/data/validator.py new file mode 100644 index 0000000..07dda39 --- /dev/null +++ b/soup_cli/data/validator.py @@ -0,0 +1,74 @@ +"""Dataset validation and statistics.""" + +from collections import Counter + +from soup_cli.data.formats import FORMAT_SIGNATURES + + +def validate_and_stats(data: list[dict], expected_format: str | None = None) -> dict: + """Compute stats and validate dataset.""" + if not data: + return { + "total": 0, + "columns": [], + "avg_length": 0, + "min_length": 0, + "max_length": 0, + "empty_fields": 0, + "duplicates": 0, + "issues": ["Dataset is empty"], + "valid_rows": 0, + } + + columns = list(data[0].keys()) + + # Compute text lengths (join all string values) + lengths = [] + empty_count = 0 + for row in data: + text = " ".join(str(v) for v in row.values() if v) + lengths.append(len(text)) + for v in row.values(): + if v is None or (isinstance(v, str) and not v.strip()): + empty_count += 1 + + # Detect duplicates by stringifying rows + row_strs = [str(sorted(row.items())) for row in data] + dup_count = len(row_strs) - len(set(row_strs)) + + # Validate format + issues = [] + valid_rows = len(data) + if expected_format and expected_format in FORMAT_SIGNATURES: + required = FORMAT_SIGNATURES[expected_format] + invalid = 0 + for row in data: + if not required.issubset(row.keys()): + invalid += 1 + valid_rows = len(data) - invalid + if invalid > 0: + issues.append( + f"{invalid} rows missing required keys for '{expected_format}' format: {required}" + ) + + if dup_count > 0: + issues.append(f"{dup_count} duplicate rows found") + if empty_count > 0: + issues.append(f"{empty_count} empty fields found") + + # Check for very short samples + short = sum(1 for l in lengths if l < 10) + if short > 0: + issues.append(f"{short} samples are very short (<10 chars)") + + return { + "total": len(data), + "columns": columns, + "avg_length": round(sum(lengths) / len(lengths)), + "min_length": min(lengths), + "max_length": max(lengths), + "empty_fields": empty_count, + "duplicates": dup_count, + "issues": issues, + "valid_rows": valid_rows, + } diff --git a/soup_cli/experiment/__init__.py b/soup_cli/experiment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/soup_cli/experiment/tracker.py b/soup_cli/experiment/tracker.py new file mode 100644 index 0000000..c6289fa --- /dev/null +++ b/soup_cli/experiment/tracker.py @@ -0,0 +1,5 @@ +"""Experiment tracking β€” stores runs in local SQLite. (Phase 2)""" + +# Placeholder for Phase 2 implementation. +# Will store: config, metrics, hardware info, checkpoints per run. +# Storage: ~/.soup/experiments.db (SQLite) diff --git a/soup_cli/monitoring/__init__.py b/soup_cli/monitoring/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/soup_cli/monitoring/callback.py b/soup_cli/monitoring/callback.py new file mode 100644 index 0000000..f6467df --- /dev/null +++ b/soup_cli/monitoring/callback.py @@ -0,0 +1,44 @@ +"""HuggingFace Trainer callback that feeds metrics to our display.""" + +from transformers import TrainerCallback, TrainerControl, TrainerState, TrainingArguments + +from soup_cli.monitoring.display import TrainingDisplay + + +class SoupTrainerCallback(TrainerCallback): + """Bridges HF Trainer events to Soup's Rich live display.""" + + def __init__(self, display: TrainingDisplay): + self.display = display + + def on_train_begin(self, args: TrainingArguments, state: TrainerState, control: TrainerControl, **kwargs): + self.display.start(total_steps=state.max_steps) + + def on_log(self, args: TrainingArguments, state: TrainerState, control: TrainerControl, logs=None, **kwargs): + if logs is None: + return + + # Try to get GPU memory + gpu_mem = "" + try: + import torch + + if torch.cuda.is_available(): + used = torch.cuda.memory_allocated() / (1024**3) + total = torch.cuda.get_device_properties(0).total_mem / (1024**3) + gpu_mem = f"{used:.1f}/{total:.1f} GB" + except Exception: + pass + + self.display.update( + step=state.global_step, + epoch=state.epoch or 0, + loss=logs.get("loss", 0.0), + lr=logs.get("learning_rate", 0.0), + grad_norm=logs.get("grad_norm", 0.0), + speed=logs.get("train_steps_per_second", 0.0), + gpu_mem=gpu_mem, + ) + + def on_train_end(self, args: TrainingArguments, state: TrainerState, control: TrainerControl, **kwargs): + self.display.stop() diff --git a/soup_cli/monitoring/display.py b/soup_cli/monitoring/display.py new file mode 100644 index 0000000..aeb9a3b --- /dev/null +++ b/soup_cli/monitoring/display.py @@ -0,0 +1,80 @@ +"""Rich live training dashboard in the terminal.""" + +from rich.console import Console +from rich.live import Live +from rich.panel import Panel +from rich.progress import BarColumn, Progress, TextColumn, TimeRemainingColumn +from rich.table import Table +from rich.text import Text + +from soup_cli.config.schema import SoupConfig + +console = Console() + + +class TrainingDisplay: + """Live-updating terminal dashboard for training progress.""" + + def __init__(self, config: SoupConfig, device_name: str = ""): + self.config = config + self.device_name = device_name + self.current_step = 0 + self.total_steps = 0 + self.current_epoch = 0 + self.loss = 0.0 + self.lr = 0.0 + self.grad_norm = 0.0 + self.gpu_mem = "" + self.speed = 0.0 + self._live: Live | None = None + + def start(self, total_steps: int): + """Start the live display.""" + self.total_steps = total_steps + self._live = Live(self._render(), console=console, refresh_per_second=2) + self._live.start() + + def update(self, step: int, epoch: float, loss: float, lr: float, **kwargs): + """Update display with new metrics.""" + self.current_step = step + self.current_epoch = epoch + self.loss = loss + self.lr = lr + self.grad_norm = kwargs.get("grad_norm", 0.0) + self.speed = kwargs.get("speed", 0.0) + self.gpu_mem = kwargs.get("gpu_mem", "") + + if self._live: + self._live.update(self._render()) + + def stop(self): + """Stop the live display.""" + if self._live: + self._live.stop() + + def _render(self) -> Panel: + """Render the dashboard panel.""" + progress_pct = (self.current_step / self.total_steps * 100) if self.total_steps > 0 else 0 + bar_width = 30 + filled = int(bar_width * progress_pct / 100) + bar = "β–ˆ" * filled + "β–‘" * (bar_width - filled) + + lines = [] + lines.append(f"Epoch {self.current_epoch:.1f}/{self.config.training.epochs} [{bar}] {progress_pct:.0f}%") + lines.append(f"Step: {self.current_step}/{self.total_steps}") + lines.append(f"Loss: {self.loss:.4f} LR: {self.lr:.2e}") + + if self.speed > 0: + lines.append(f"Speed: {self.speed:.2f} it/s") + if self.gpu_mem: + lines.append(f"GPU: {self.gpu_mem}") + if self.grad_norm > 0: + lines.append(f"Grad: {self.grad_norm:.4f}") + + content = "\n".join(lines) + return Panel( + content, + title=f"[bold green]Soup Training: {self.config.experiment_name or self.config.base}[/]", + subtitle=f"[dim]{self.device_name}[/]", + border_style="green", + ) diff --git a/soup_cli/trainer/__init__.py b/soup_cli/trainer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/soup_cli/trainer/sft.py b/soup_cli/trainer/sft.py new file mode 100644 index 0000000..a22c110 --- /dev/null +++ b/soup_cli/trainer/sft.py @@ -0,0 +1,185 @@ +"""SFT (Supervised Fine-Tuning) trainer β€” wraps HuggingFace transformers + peft + trl.""" + +import time +from pathlib import Path +from typing import Optional + +from rich.console import Console + +from soup_cli.config.schema import SoupConfig +from soup_cli.utils.gpu import estimate_batch_size, model_size_from_name + +console = Console() + + +class SFTTrainerWrapper: + """High-level wrapper that sets up model + tokenizer + trainer from SoupConfig.""" + + def __init__(self, config: SoupConfig, device: str = "cuda"): + self.config = config + self.device = device + self.model = None + self.tokenizer = None + self.trainer = None + + def setup(self, dataset: dict): + """Load model, tokenizer, apply LoRA, create trainer.""" + from datasets import Dataset + from peft import LoraConfig, TaskType, get_peft_model, prepare_model_for_kbit_training + from transformers import ( + AutoModelForCausalLM, + AutoTokenizer, + BitsAndBytesConfig, + TrainingArguments, + ) + from trl import SFTTrainer + + cfg = self.config + tcfg = cfg.training + + # --- Tokenizer --- + console.print(f"[dim]Loading tokenizer: {cfg.base}[/]") + self.tokenizer = AutoTokenizer.from_pretrained(cfg.base, trust_remote_code=True) + if self.tokenizer.pad_token is None: + self.tokenizer.pad_token = self.tokenizer.eos_token + + # --- Quantization --- + bnb_config = None + if tcfg.quantization == "4bit": + import torch + + bnb_config = BitsAndBytesConfig( + load_in_4bit=True, + bnb_4bit_quant_type="nf4", + bnb_4bit_compute_dtype=torch.bfloat16, + bnb_4bit_use_double_quant=True, + ) + elif tcfg.quantization == "8bit": + bnb_config = BitsAndBytesConfig(load_in_8bit=True) + + # --- Model --- + console.print(f"[dim]Loading model: {cfg.base}[/]") + model_kwargs = {"trust_remote_code": True, "device_map": "auto"} + if bnb_config: + model_kwargs["quantization_config"] = bnb_config + + self.model = AutoModelForCausalLM.from_pretrained(cfg.base, **model_kwargs) + + if tcfg.quantization in ("4bit", "8bit"): + self.model = prepare_model_for_kbit_training(self.model) + + # --- LoRA --- + target_modules = tcfg.lora.target_modules + if target_modules == "auto": + target_modules = None # peft will auto-detect + + lora_config = LoraConfig( + r=tcfg.lora.r, + lora_alpha=tcfg.lora.alpha, + lora_dropout=tcfg.lora.dropout, + target_modules=target_modules, + task_type=TaskType.CAUSAL_LM, + bias="none", + ) + self.model = get_peft_model(self.model, lora_config) + trainable, total = self.model.get_nb_trainable_parameters() + pct = 100 * trainable / total + console.print(f"[green]LoRA applied:[/] {trainable:,} trainable / {total:,} total ({pct:.2f}%)") + + # --- Batch size --- + batch_size = tcfg.batch_size + if batch_size == "auto": + from soup_cli.utils.gpu import get_gpu_info + + gpu_info = get_gpu_info() + model_size = model_size_from_name(cfg.base) + batch_size = estimate_batch_size( + model_params_b=model_size, + seq_length=cfg.data.max_length, + gpu_memory_bytes=gpu_info["memory_total_bytes"], + quantization=tcfg.quantization, + lora_r=tcfg.lora.r, + ) + console.print(f"[green]Auto batch size:[/] {batch_size}") + + # --- Dataset --- + def format_row(example): + text = self.tokenizer.apply_chat_template( + example["messages"], tokenize=False, add_generation_prompt=False + ) + return {"text": text} + + train_ds = Dataset.from_list(dataset["train"]).map(format_row) + eval_ds = None + if "val" in dataset and dataset["val"]: + eval_ds = Dataset.from_list(dataset["val"]).map(format_row) + + # --- Output dir --- + output_dir = Path(cfg.output) + if cfg.experiment_name: + output_dir = output_dir / cfg.experiment_name + output_dir.mkdir(parents=True, exist_ok=True) + + # --- Training args --- + training_args = TrainingArguments( + output_dir=str(output_dir), + num_train_epochs=tcfg.epochs, + per_device_train_batch_size=batch_size, + gradient_accumulation_steps=tcfg.gradient_accumulation_steps, + learning_rate=tcfg.lr, + warmup_ratio=tcfg.warmup_ratio, + weight_decay=tcfg.weight_decay, + max_grad_norm=tcfg.max_grad_norm, + optim=tcfg.optimizer, + lr_scheduler_type=tcfg.scheduler, + logging_steps=tcfg.logging_steps, + save_steps=tcfg.save_steps, + save_total_limit=3, + bf16=self.device == "cuda", + report_to="none", + remove_unused_columns=False, + ) + + # --- Trainer --- + self.trainer = SFTTrainer( + model=self.model, + args=training_args, + train_dataset=train_ds, + eval_dataset=eval_ds, + processing_class=self.tokenizer, + ) + + self._output_dir = str(output_dir) + + def train(self, display: Optional[object] = None) -> dict: + """Run training and return results summary.""" + start = time.time() + + # Add callback for live display + if display: + from soup_cli.monitoring.callback import SoupTrainerCallback + + self.trainer.add_callback(SoupTrainerCallback(display)) + + result = self.trainer.train() + duration = time.time() - start + + # Save final model (LoRA adapter) + self.trainer.save_model(self._output_dir) + self.tokenizer.save_pretrained(self._output_dir) + + # Extract metrics + logs = self.trainer.state.log_history + train_losses = [l["loss"] for l in logs if "loss" in l] + + hours = int(duration // 3600) + minutes = int((duration % 3600) // 60) + duration_str = f"{hours}h {minutes}m" if hours > 0 else f"{minutes}m" + + return { + "initial_loss": train_losses[0] if train_losses else 0, + "final_loss": train_losses[-1] if train_losses else 0, + "duration": duration_str, + "output_dir": self._output_dir, + "total_steps": self.trainer.state.global_step, + } diff --git a/soup_cli/utils/__init__.py b/soup_cli/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/soup_cli/utils/constants.py b/soup_cli/utils/constants.py new file mode 100644 index 0000000..f72ce9c --- /dev/null +++ b/soup_cli/utils/constants.py @@ -0,0 +1,8 @@ +"""Global constants and defaults.""" + +APP_NAME = "soup" +CONFIG_FILE = "soup.yaml" +SOUP_DIR = ".soup" +EXPERIMENTS_DB = "experiments.db" + +DEFAULT_CHAT_TEMPLATE = """{% for message in messages %}{% if message['role'] == 'system' %}{{ message['content'] + '\\n' }}{% elif message['role'] == 'user' %}{{ 'User: ' + message['content'] + '\\n' }}{% elif message['role'] == 'assistant' %}{{ 'Assistant: ' + message['content'] + '\\n' }}{% endif %}{% endfor %}""" diff --git a/soup_cli/utils/gpu.py b/soup_cli/utils/gpu.py new file mode 100644 index 0000000..929feba --- /dev/null +++ b/soup_cli/utils/gpu.py @@ -0,0 +1,115 @@ +"""GPU detection, memory calculation, and auto batch size.""" + +import math + + +def detect_device() -> tuple[str, str]: + """Detect available device. Returns (device_string, human_name).""" + try: + import torch + + if torch.cuda.is_available(): + name = torch.cuda.get_device_name(0) + return "cuda", name + if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + return "mps", "Apple Silicon (MPS)" + except ImportError: + pass + + return "cpu", "CPU (no GPU detected)" + + +def get_gpu_info() -> dict: + """Get GPU memory info.""" + try: + import torch + + if torch.cuda.is_available(): + total = torch.cuda.get_device_properties(0).total_mem + total_gb = total / (1024**3) + return { + "memory_total": f"{total_gb:.1f} GB", + "memory_total_bytes": total, + "gpu_count": torch.cuda.device_count(), + } + if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + # MPS doesn't expose memory easily, estimate from system + return { + "memory_total": "shared (Apple Silicon)", + "memory_total_bytes": 0, + "gpu_count": 1, + } + except ImportError: + pass + + return { + "memory_total": "N/A (CPU mode)", + "memory_total_bytes": 0, + "gpu_count": 0, + } + + +def estimate_batch_size( + model_params_b: float, + seq_length: int, + gpu_memory_bytes: int, + quantization: str = "4bit", + lora_r: int = 64, +) -> int: + """Estimate max batch size that fits in GPU memory. + + Conservative estimate β€” better to start smaller and gradient accumulate. + """ + if gpu_memory_bytes == 0: + return 1 # CPU fallback + + gpu_gb = gpu_memory_bytes / (1024**3) + + # Rough memory per param based on quantization + bytes_per_param = {"4bit": 0.5, "8bit": 1.0, "none": 2.0} # FP16 + bpp = bytes_per_param.get(quantization, 2.0) + + # Model memory (static) + model_mem_gb = model_params_b * bpp + + # LoRA trainable params (usually ~1-3% of total) + lora_ratio = min(lora_r * 2 / 4096, 0.05) # rough estimate + trainable_mem_gb = model_params_b * 2 * lora_ratio # FP16 for trainable + + # Optimizer states (Adam: 2x params) + optimizer_mem_gb = trainable_mem_gb * 2 + + # Available for activations + overhead_gb = 1.5 # CUDA overhead, fragmentation + available_gb = gpu_gb - model_mem_gb - trainable_mem_gb - optimizer_mem_gb - overhead_gb + + if available_gb <= 0: + return 1 + + # Rough activation memory per sample per token + # ~2 bytes per hidden dim per layer per token for a transformer + activation_per_sample_gb = (seq_length * model_params_b * 0.001) # very rough + activation_per_sample_gb = max(activation_per_sample_gb, 0.5) # minimum 0.5 GB + + batch_size = max(1, int(available_gb / activation_per_sample_gb)) + # Clamp to power of 2 (common practice) + batch_size = 2 ** int(math.log2(batch_size)) if batch_size > 1 else 1 + + return min(batch_size, 32) # cap at 32 + + +def model_size_from_name(model_name: str) -> float: + """Guess model size in billions from model name.""" + name_lower = model_name.lower() + + size_markers = [ + ("70b", 70), ("65b", 65), ("34b", 34), ("33b", 33), + ("13b", 13), ("8b", 8), ("7b", 7), ("3b", 3), + ("1.5b", 1.5), ("1b", 1), ("0.5b", 0.5), + ] + + for marker, size in size_markers: + if marker in name_lower: + return size + + return 7.0 # default guess diff --git a/templates/chat.yaml b/templates/chat.yaml new file mode 100644 index 0000000..dadf5b3 --- /dev/null +++ b/templates/chat.yaml @@ -0,0 +1,24 @@ +# Soup template: Chat Assistant +# Fine-tune a model for conversational chat +# Usage: soup init --template chat + +base: meta-llama/Llama-3.1-8B-Instruct +task: sft + +data: + train: ./data/train.jsonl + format: alpaca + val_split: 0.1 + max_length: 2048 + +training: + epochs: 3 + lr: 2e-5 + batch_size: auto + lora: + r: 64 + alpha: 16 + target_modules: auto + quantization: 4bit + +output: ./output diff --git a/templates/code.yaml b/templates/code.yaml new file mode 100644 index 0000000..aa52017 --- /dev/null +++ b/templates/code.yaml @@ -0,0 +1,24 @@ +# Soup template: Code Model +# Fine-tune a model for code generation +# Usage: soup init --template code + +base: codellama/CodeLlama-7b-Instruct-hf +task: sft + +data: + train: ./data/code_train.jsonl + format: alpaca + val_split: 0.1 + max_length: 4096 + +training: + epochs: 2 + lr: 1e-5 + batch_size: auto + lora: + r: 128 + alpha: 32 + target_modules: auto + quantization: 4bit + +output: ./output diff --git a/templates/medical.yaml b/templates/medical.yaml new file mode 100644 index 0000000..3bc768f --- /dev/null +++ b/templates/medical.yaml @@ -0,0 +1,25 @@ +# Soup template: Medical / Domain Expert +# Fine-tune a model with domain-specific knowledge +# Usage: soup init --template medical + +base: meta-llama/Llama-3.1-8B-Instruct +task: sft + +data: + train: ./data/medical_train.jsonl + format: alpaca + val_split: 0.15 + max_length: 2048 + +training: + epochs: 5 + lr: 1e-5 + batch_size: auto + gradient_accumulation_steps: 8 + lora: + r: 128 + alpha: 32 + target_modules: auto + quantization: 4bit + +output: ./output diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..30b34b3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,55 @@ +"""Shared test fixtures.""" + +import json +from pathlib import Path + +import pytest + + +@pytest.fixture +def tmp_data_dir(tmp_path: Path) -> Path: + """Create a temp directory with sample training data.""" + data_dir = tmp_path / "data" + data_dir.mkdir() + return data_dir + + +@pytest.fixture +def sample_alpaca_data(tmp_data_dir: Path) -> Path: + """Create a sample alpaca-format JSONL file.""" + path = tmp_data_dir / "train.jsonl" + samples = [ + {"instruction": "What is Python?", "input": "", "output": "Python is a programming language."}, + {"instruction": "Explain gravity", "input": "", "output": "Gravity is a fundamental force."}, + {"instruction": "Translate hello to Spanish", "input": "hello", "output": "hola"}, + ] + with open(path, "w", encoding="utf-8") as f: + for s in samples: + f.write(json.dumps(s) + "\n") + return path + + +@pytest.fixture +def sample_config(tmp_path: Path, sample_alpaca_data: Path) -> Path: + """Create a sample soup.yaml config.""" + config_path = tmp_path / "soup.yaml" + config_path.write_text( + f"""base: meta-llama/Llama-3.1-8B-Instruct +task: sft +data: + train: {sample_alpaca_data} + format: alpaca + val_split: 0.1 +training: + epochs: 1 + lr: 2e-5 + batch_size: 1 + lora: + r: 8 + alpha: 16 + quantization: 4bit +output: {tmp_path / 'output'} +""", + encoding="utf-8", + ) + return config_path diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d685e3b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,29 @@ +"""Tests for CLI commands.""" + +from typer.testing import CliRunner + +from soup_cli.cli import app + +runner = CliRunner() + + +def test_version(): + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert "0.1.0" in result.output + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Fine-tune" in result.output + + +def test_init_unknown_template(): + result = runner.invoke(app, ["init", "--template", "nonexistent"]) + assert result.exit_code == 1 + + +def test_train_missing_config(): + result = runner.invoke(app, ["train", "--config", "nonexistent.yaml"]) + assert result.exit_code == 1 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3132350 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,53 @@ +"""Tests for config loading and validation.""" + +from pathlib import Path + +import pytest + +from soup_cli.config.loader import load_config +from soup_cli.config.schema import SoupConfig + + +def test_load_valid_config(sample_config: Path): + """Valid config should parse without errors.""" + cfg = load_config(sample_config) + assert isinstance(cfg, SoupConfig) + assert cfg.base == "meta-llama/Llama-3.1-8B-Instruct" + assert cfg.task == "sft" + assert cfg.training.epochs == 1 + assert cfg.training.lora.r == 8 + assert cfg.training.quantization == "4bit" + + +def test_config_defaults(): + """Config should fill in defaults for optional fields.""" + cfg = SoupConfig( + base="some-model", + data={"train": "./data.jsonl"}, + ) + assert cfg.task == "sft" + assert cfg.training.epochs == 3 + assert cfg.training.lr == 2e-5 + assert cfg.training.batch_size == "auto" + assert cfg.training.lora.r == 64 + assert cfg.training.quantization == "4bit" + assert cfg.output == "./output" + + +def test_config_invalid_task(): + """Invalid task should raise validation error.""" + with pytest.raises(Exception): + SoupConfig( + base="some-model", + task="invalid_task", + data={"train": "./data.jsonl"}, + ) + + +def test_config_val_split_bounds(): + """val_split must be between 0 and 0.5.""" + with pytest.raises(Exception): + SoupConfig( + base="some-model", + data={"train": "./data.jsonl", "val_split": 0.9}, + ) diff --git a/tests/test_data.py b/tests/test_data.py new file mode 100644 index 0000000..a275a7d --- /dev/null +++ b/tests/test_data.py @@ -0,0 +1,71 @@ +"""Tests for data loading, format detection, and validation.""" + +from pathlib import Path + +from soup_cli.data.formats import detect_format, format_to_messages +from soup_cli.data.loader import load_raw_data +from soup_cli.data.validator import validate_and_stats + + +def test_load_jsonl(sample_alpaca_data: Path): + data = load_raw_data(sample_alpaca_data) + assert len(data) == 3 + assert "instruction" in data[0] + assert "output" in data[0] + + +def test_detect_alpaca_format(): + data = [{"instruction": "test", "input": "", "output": "result"}] + assert detect_format(data) == "alpaca" + + +def test_detect_sharegpt_format(): + data = [{"conversations": [{"from": "human", "value": "hi"}]}] + assert detect_format(data) == "sharegpt" + + +def test_detect_chatml_format(): + data = [{"messages": [{"role": "user", "content": "hi"}]}] + assert detect_format(data) == "chatml" + + +def test_convert_alpaca(): + row = {"instruction": "Explain AI", "input": "", "output": "AI is..."} + result = format_to_messages(row, "alpaca") + assert result is not None + assert len(result["messages"]) == 2 + assert result["messages"][0]["role"] == "user" + assert result["messages"][1]["role"] == "assistant" + + +def test_convert_alpaca_with_input(): + row = {"instruction": "Translate", "input": "hello", "output": "ΠΏΡ€ΠΈΠ²Π΅Ρ‚"} + result = format_to_messages(row, "alpaca") + assert "hello" in result["messages"][0]["content"] + + +def test_convert_sharegpt(): + row = { + "conversations": [ + {"from": "human", "value": "What is 2+2?"}, + {"from": "gpt", "value": "4"}, + ] + } + result = format_to_messages(row, "sharegpt") + assert result["messages"][0]["role"] == "user" + assert result["messages"][1]["role"] == "assistant" + + +def test_validate_stats(sample_alpaca_data: Path): + data = load_raw_data(sample_alpaca_data) + stats = validate_and_stats(data) + assert stats["total"] == 3 + assert "instruction" in stats["columns"] + assert stats["avg_length"] > 0 + + +def test_validate_with_format(sample_alpaca_data: Path): + data = load_raw_data(sample_alpaca_data) + stats = validate_and_stats(data, expected_format="alpaca") + assert stats["valid_rows"] == 3 + assert len(stats["issues"]) == 0 # no issues for valid data diff --git a/tests/test_gpu.py b/tests/test_gpu.py new file mode 100644 index 0000000..73b500c --- /dev/null +++ b/tests/test_gpu.py @@ -0,0 +1,24 @@ +"""Tests for GPU utils.""" + +from soup_cli.utils.gpu import estimate_batch_size, model_size_from_name + + +def test_model_size_detection(): + assert model_size_from_name("meta-llama/Llama-3.1-8B-Instruct") == 8 + assert model_size_from_name("meta-llama/Llama-3.1-70B") == 70 + assert model_size_from_name("codellama/CodeLlama-7b-hf") == 7 + assert model_size_from_name("some-unknown-model") == 7.0 # default + + +def test_batch_size_cpu(): + """CPU (0 memory) should return batch_size=1.""" + bs = estimate_batch_size(7.0, 2048, 0, "4bit", 64) + assert bs == 1 + + +def test_batch_size_24gb(): + """24 GB GPU with 8B model QLoRA should fit batch > 1.""" + mem = 24 * (1024**3) + bs = estimate_batch_size(8.0, 2048, mem, "4bit", 64) + assert bs >= 1 + assert bs <= 32