Tunepac
Teach an open-source AI model your company's job — by writing a short settings file instead of writing training code.
pip install "tunepac[train]"
tunepac train --config my_model.yaml
Contents
New here? What problem does this solve? · What it does today · Quick start
Using it: How it works · Which adapter should I use? · Installation · CLI reference · Configuration reference · Dataset formats · Chat templates · Recipes · Troubleshooting
Contributing: Architecture · Development · Roadmap · License
For the non-engineer: what problem does this solve?
General-purpose AI models are generalists. They write decent email and know nothing about your refund policy, your product catalog, or your house style. Fine-tuning is the process of training a general model on your own examples until it behaves the way you need.
The catch is that fine-tuning normally means an ML engineer hand-writing a custom training program for every experiment. That is slow, hard to review, and easy to get subtly wrong — and mistakes are expensive, because you often don't discover them until hours of rented GPU time have already burned.
Tunepac replaces that program with a recipe: one readable text file that says which model, which examples, and which settings. Hand it to Tunepac and it runs the job.
| Without Tunepac | With Tunepac | |
|---|---|---|
| What an experiment is | A bespoke Python program | A ~30-line settings file |
| Who can review it | Only the engineer who wrote it | Anyone who can read a config |
| Repeating last month's run | Hope the script still exists | Re-run the same file |
| Typo in a setting | Discovered mid-run, or silently ignored | Rejected in under a second, before any GPU spend |
| Hardware needed (8B model) | Often a multi-GPU machine | One 24 GB gaming-class GPU |
The cost angle. Tunepac leans on a technique called QLoRA, which trains a small "adapter" layer instead of rewriting the whole model. The practical effect is that customizing an 8-billion-parameter model fits on a single high-end consumer graphics card rather than a rented cluster — and the resulting adapter is a file of tens of megabytes, not tens of gigabytes.
The honest caveat. This is young software (version 0.1), it is a tool for technical teams, and it does not collect your data or supply the examples — you still need a dataset and someone who understands what "good output" means for your use case.
What it does today
Same data, precisely — the low scores are the roadmap, not an oversight:
| Capability | Score | Where it stands |
|---|---|---|
| Config validation | 5/5 | Strict schema, unknown keys rejected, cross-field rules enforced before load |
| LoRA / QLoRA | 5/5 | Rank, alpha, dropout, target modules, NF4/FP4, double quantization |
| Dataset formats | 4/5 | alpaca · sharegpt · openai · completion. No multimodal, no streaming |
| Chat templates | 4/5 | chatml · llama3 · mistral · zephyr, or the tokenizer's own |
| Adapter export | 3/5 | Merge to a standalone model works. GGUF conversion not built |
| Multi-GPU | 3/5 | Runs under accelerate launch. No FSDP/DeepSpeed config passthrough |
| Preference tuning | 0/5 | No DPO/ORPO. SFT only |
Quick start
1. Install. The base install is tiny and needs no GPU — it only validates
configs. Add [train] when you actually want to train:
pip install tunepac # CLI + config validation, no CUDA, no torch
pip install "tunepac[train]" # + torch, TRL, PEFT, Accelerate, bitsandbytes
2. Write a config — save as my_model.yaml:
base_model:
name: meta-llama/Meta-Llama-3-8B
dataset:
path: yahma/alpaca-cleaned # a Hub dataset, or your own .jsonl file
format: alpaca
chat_template: llama3
training:
learning_rate: 2.0e-4
epochs: 3
batch_size: 2
gradient_accumulation_steps: 8
peft:
adapter: qlora # 4-bit base + trainable adapter
r: 32
hardware:
bf16: true
gradient_checkpointing: true
3. Check it before spending GPU time. This runs in a second, on a laptop, with no model download:
$ tunepac validate --config my_model.yaml
Config OK — schema validation passed.
Tunepac run summary
┌───────────────────┬──────────────────────────────────────┐
│ Base model │ meta-llama/Meta-Llama-3-8B │
│ Dataset │ yahma/alpaca-cleaned (alpaca) │
│ Chat template │ llama3 │
│ Adapter │ qlora (r=32, alpha=32) │
│ Precision │ bfloat16 │
│ Batch │ 2 x 8 accumulation = 16 effective │
│ Sequence length │ 2048 │
│ Output dir │ outputs │
└───────────────────┴──────────────────────────────────────┘
4. Train, then ship a standalone model:
tunepac train --config my_model.yaml # writes an adapter
tunepac export --config my_model.yaml # merges it into the base model
The merged model in outputs/merged/ loads with a plain
AutoModelForCausalLM.from_pretrained — no Tunepac or PEFT needed at inference
time.
Want to try it in 30 seconds instead? configs/test_smollm2.yaml fine-tunes
a 135M-parameter model on five dummy rows and runs on a CPU:
python scripts/run_test.py
# PASS: LoRA adapter saved to output_test_adapter (34 KiB) in 6.8s
How it works
flowchart LR
Y["config.yaml"] --> V{"Pydantic schema<br/>validation"}
V -->|"invalid"| E["lint-style error report<br/><i>exit 1, no GPU touched</i>"]
V -->|"valid"| T["tokenizer<br/>+ chat template"]
T --> D["dataset → messages → text"]
D --> M["base model<br/><i>4-bit if QLoRA</i>"]
M --> S["TRL SFTTrainer<br/><i>packing · LoRA</i>"]
S --> A["adapter/"]
A --> X["tunepac export"]
X --> G["merged model/<br/><i>loads without PEFT</i>"]
style E fill:#7c2d12,color:#fff
style G fill:#14532d,color:#fff
style V fill:#1e3a5f,color:#fff
The pipeline in words, for anywhere Mermaid doesn't render:
| Step | What happens | Fails fast if |
|---|---|---|
| 1. Validate | YAML → strict Pydantic schema | Unknown key, out-of-range value, contradictory settings |
| 2. Tokenizer | Load tokenizer, apply chat template | Template requested but the tokenizer has none |
| 3. Dataset | Records → role/content messages → rendered text | A record doesn't match the declared format |
| 4. Model | Load base model, 4-bit for QLoRA | Out of memory (reported with fixes to try) |
| 5. Train | TRL SFTTrainer with packing + LoRA |
Out of memory |
| 6. Export | Merge adapter into base, save | No adapter found at the path |
The design rule that matters: every invariant is checked in step 1. The
trainer never re-checks what the schema guarantees, and nothing heavy is
imported until the config is known good — which is why validate runs on a
laptop with no CUDA.
Which adapter should I use?
flowchart TD
Q1{"Deep knowledge change?"}
Q1 -->|"no — the usual case"| Q2{"Fits your GPU at 16-bit?"}
Q1 -->|"yes, and you<br/>have the hardware"| FULL["Full fine-tune<br/><i>omit the peft section</i>"]
Q2 -->|"yes"| LORA["peft.adapter: lora<br/><i>fastest, best quality</i>"]
Q2 -->|"no"| QLORA["peft.adapter: qlora<br/><i>4-bit base, 8B fits in 24 GB</i>"]
style FULL fill:#78350f,color:#fff
style LORA fill:#14532d,color:#fff
style QLORA fill:#1e3a5f,color:#fff
| Choice | Memory | Quality | When |
|---|---|---|---|
lora |
Moderate | Best of the three | The model already fits at 16-bit |
qlora |
Lowest | Very close to lora |
The model doesn't fit — the common case for 7B+ on one GPU |
(omit peft) |
Highest | Highest ceiling | Deep behavior change, and you have the hardware |
The economics in one line: validate costs nothing and takes a second;
train costs GPU hours. Everything the schema can catch is caught in the first
one, which is the whole reason the validation is strict.
Features
- One YAML file per run. Everything defining a fine-tune lives in one reviewable, diffable, version-controllable file.
- Strict validation, early. Every section rejects unknown keys, every field is typed and bounded, and cross-field rules (e.g. QLoRA requires half precision) are enforced in the schema. Errors read like a lint report, never a Python traceback.
- LoRA and QLoRA out of the box. Omit the
peftsection entirely for full-parameter fine-tuning. - Dataset flexibility. Four record formats, Hub datasets or local
.jsonl/.json/.csv/.parquet, automatic conversion to a common message form. - Token packing via TRL, on by default, so short examples don't waste sequence budget.
- Light by default. The base install needs only
typer,pydantic,PyYAMLandrich. Heavy dependencies live behind[train]and are imported lazily. - Fail fast, fail helpfully. Chat-template mismatches are caught before training; CUDA OOM is re-raised with ordered, concrete remedies.
- Typed and tested.
mypy --strictclean,py.typedshipped, 87 tests including real end-to-end training runs.
Installation
Requires Python 3.10+.
| Command | Gets you | Needs a GPU? |
|---|---|---|
pip install tunepac |
CLI, config validation, dataset checking | No |
pip install "tunepac[train]" |
Everything above + training and export | For real models, yes |
pip install "tunepac[dev]" |
Everything + pytest, ruff, mypy | No |
FlashAttention (
hardware.flash_attention: true) additionally requires theflash-attnpackage, which needs a CUDA toolchain to build. It is deliberately not in[train].
TRL version.
[train]pinstrl<1.14. TRL 1.14 introduced a Triton kernel in the SFT loss path with no CPU fallback, which breaks CPU training entirely. The pin will be lifted when upstream restores a non-Triton path.
CLI reference
All commands take --config / -c <path>.
| Command | Status | Purpose |
|---|---|---|
tunepac train |
working | Run the fine-tuning job described by the config |
tunepac validate |
working | Validate the config; optionally sample local datasets |
tunepac export |
working | Merge a trained adapter into its base model |
tunepac train --config <file>
Prints the run summary, then loads tokenizer, dataset and model and hands off to
TRL's SFTTrainer. Exit codes: 0 success · 1 config/dataset/template error,
OOM, or missing training extras.
tunepac validate --config <file> [--check-dataset | --no-check-dataset]
Validates against the schema and prints the run summary. With --check-dataset
(the default) local dataset files are opened and their first records checked
against the declared format — using only the standard library, so it works on
a base install. Hub dataset ids are noted and deferred to training time.
Exit codes: 0 valid · 1 schema or dataset problems, each listed.
tunepac export --config <file> [--adapter DIR] [--output DIR]
Merges a trained LoRA/QLoRA adapter into its base model, writing a standalone
model directory. --adapter defaults to training.output_dir; --output
defaults to <adapter>/merged. The tokenizer saved with the adapter is carried
over, so an overridden chat template survives the merge.
QLoRA note: even when the config says adapter: qlora, the base model is
loaded unquantized for the merge — merging LoRA weights into 4-bit NF4 weights
loses precision, so the merge happens in the config's compute dtype.
Exit codes: 0 merged · 1 no peft section, missing/invalid adapter, or
missing training extras. GGUF conversion is not implemented; see
Roadmap.
Global flags
tunepac --version prints the installed version.
Configuration reference
A config is a YAML mapping of up to five sections. base_model and dataset
are required; training and hardware have defaults; peft is optional
(omit it for full fine-tuning). Unknown keys anywhere are errors. Source of
truth: tunepac/config/schema.py.
base_model
| Field | Type | Default | Notes |
|---|---|---|---|
name |
str | required | Hub id (meta-llama/Meta-Llama-3-8B) or local path |
revision |
str | null | null |
Git revision (branch, tag, commit) of the Hub repo |
tokenizer_name |
str | null | null |
Load a different tokenizer than the model's own |
trust_remote_code |
bool | false |
Allow executing custom modeling code from the Hub repo |
dataset
| Field | Type | Default | Notes |
|---|---|---|---|
path |
str | required | Hub dataset id, or local .jsonl/.json/.csv/.parquet |
format |
enum | alpaca |
alpaca · sharegpt · openai · completion |
chat_template |
enum | null | tokenizer_default |
tokenizer_default · chatml · llama3 · mistral · zephyr · null |
split |
str | train |
Split to train on (Hub datasets) |
text_field |
str | text |
Column holding raw text; only for format: completion |
validation_split |
float | null | null |
Fraction held out for eval; 0 < x < 0.5 |
max_samples |
int | null | null |
Cap on records — handy for smoke tests; ≥ 1 |
shuffle |
bool | true |
Shuffle (seeded by training.seed) before training |
Rules: format: completion requires chat_template: null; the conversational
formats require a non-null chat_template.
training
| Field | Type | Default | Notes |
|---|---|---|---|
output_dir |
path | ./outputs |
Checkpoints, logs, final adapter/model |
learning_rate |
float | 2e-4 |
0 < x ≤ 1 |
epochs |
float | 1.0 |
Ignored when max_steps is set |
max_steps |
int | null | null |
Hard cap on optimizer steps; overrides epochs |
batch_size |
int | 1 |
Micro batch size per device |
gradient_accumulation_steps |
int | 1 |
Effective batch = batch_size × this, per device |
warmup_steps |
int | 0 |
Mutually exclusive with warmup_ratio |
warmup_ratio |
float | 0.0 |
Warmup as a fraction of total steps; 0 ≤ x ≤ 1 |
lr_scheduler |
enum | cosine |
linear · cosine · cosine_with_restarts · constant · constant_with_warmup |
optimizer |
enum | adamw_torch |
adamw_torch · adamw_torch_fused · adamw_8bit · paged_adamw_8bit · paged_adamw_32bit |
weight_decay |
float | 0.0 |
≥ 0 |
max_grad_norm |
float | 1.0 |
Gradient clipping; > 0 |
max_seq_length |
int | 2048 |
Tokens per sequence after packing/truncation; ≥ 8 |
packing |
bool | true |
Pack multiple short examples per window |
logging_steps |
int | 10 |
|
save_steps |
int | 500 |
|
eval_steps |
int | null | null |
Requires dataset.validation_split |
save_total_limit |
int | null | 3 |
Older checkpoints beyond this are deleted |
seed |
int | 42 |
Seeds shuffling, splitting, and the trainer |
resume_from_checkpoint |
path | null | null |
Checkpoint directory to resume from |
report_to |
enum | none |
wandb · tensorboard · none |
warmup_rationote. transformers 5 removed ratio-based warmup fromTrainingArguments, so Tunepac converts the ratio to a step count itself from the estimated total updates. Withpackingon, the estimate errs slightly long — acceptable for a warmup heuristic.
peft
Omit this whole section for full-parameter fine-tuning.
| Field | Type | Default | Notes |
|---|---|---|---|
adapter |
enum | required | lora (full-precision base) or qlora (4-bit base) |
r |
int | 16 |
LoRA rank; 1–1024 |
lora_alpha |
int | 32 |
Scaling numerator |
lora_dropout |
float | 0.05 |
0 ≤ x < 1 |
target_modules |
list[str] | "all-linear" |
all-linear |
Modules to wrap, or auto-detect linear layers |
bias |
enum | none |
none · all · lora_only |
use_rslora |
bool | false |
Rank-stabilized scaling (alpha / √r) |
quantization |
mapping | null | auto | 4-bit details; only valid with qlora |
quantization sub-fields: bnb_4bit_quant_type (nf4 or fp4, default nf4)
and bnb_4bit_use_double_quant (default true).
hardware
| Field | Type | Default | Notes |
|---|---|---|---|
bf16 |
bool | true |
bfloat16 (Ampere or newer) |
fp16 |
bool | false |
float16 (older GPUs). Mutually exclusive with bf16 |
flash_attention |
bool | false |
FlashAttention-2; needs the flash-attn package |
gradient_checkpointing |
bool | true |
Recompute activations backward to save memory |
tf32 |
bool | true |
TF32 matmuls on Ampere+ (no effect elsewhere) |
With both bf16 and fp16 false, training runs in float32.
Cross-section rules
Enforced at validation time, before anything loads:
| Rule | Why |
|---|---|
bf16 and fp16 cannot both be true |
They are different compute dtypes |
warmup_steps and warmup_ratio cannot both be set |
Ambiguous intent |
eval_steps requires dataset.validation_split |
Nothing to evaluate against |
qlora requires bf16 or fp16 |
4-bit needs a half-precision compute dtype |
quantization only with qlora |
Meaningless for an unquantized base |
format: completion ⇔ chat_template: null |
Raw text has no turns to render |
Dataset formats
Conversational formats are normalized to OpenAI-style [{role, content}], then
rendered through the chat template. completion data is used as-is.
| Format | Shape | Required keys |
|---|---|---|
alpaca |
{"instruction": …, "input": …, "output": …} |
instruction, output |
sharegpt |
{"conversations": [{"from": …, "value": …}]} |
conversations |
openai |
{"messages": [{"role": …, "content": …}]} |
messages |
completion |
{"text": "raw text"} |
the text_field column |
{"instruction": "Translate to German.", "input": "Good morning!", "output": "Guten Morgen!"}
{"conversations": [{"from": "human", "value": "Hi!"}, {"from": "gpt", "value": "Hello!"}]}
{"messages": [{"role": "user", "content": "Hi!"}, {"role": "assistant", "content": "Hello!"}]}
For alpaca, a non-empty input is appended to the instruction (blank line
between) to form the user turn. ShareGPT speakers map system→system,
human/user→user, gpt/assistant→assistant; unknown speakers are an error.
A path ending in .jsonl/.json/.csv/.parquet is a local file (.json
must be a list of records); anything else is a Hub dataset id.
Chat templates
| Value | Effect |
|---|---|
tokenizer_default |
Keep whatever template the tokenizer ships. Fails fast if it has none |
chatml / llama3 / mistral / zephyr |
Overwrite with a known Jinja template, making rendered text deterministic across base models |
Match the template to the model family when fine-tuning an instruct model, or choose deliberately when teaching a base model a chat format from scratch.
Recipes
QLoRA on Llama-3-8B, single 24 GB GPU
example_config.yaml in full: NF4 double-quantized
4-bit base, LoRA r=32 on all attention and MLP projections, paged 8-bit AdamW,
packing at 4096 tokens, bf16, FlashAttention-2, gradient checkpointing.
CPU smoke test, under 30 seconds
configs/test_smollm2.yaml — SmolLM2-135M with
LoRA r=8 on five dummy rows in float32. python scripts/run_test.py writes the
data, trains, and verifies the adapter.
Full fine-tune
Delete the peft section. Expect an order of magnitude more memory. (export
correctly refuses these — there is no adapter to merge.)
Multi-GPU
accelerate config # once, to describe your machine
accelerate launch -m tunepac.cli train --config my_model.yaml
batch_size and effective_batch_size are per device — multiply by world
size for the global batch.
Errors and troubleshooting
Config errors read like a lint report
$ tunepac validate --config bad.yaml
Invalid configuration (3 error(s)):
training.learning_rate: Input should be less than or equal to 1
peft.lora_droput: Extra inputs are not permitted
hardware: Value error, hardware.bf16 and hardware.fp16 are mutually exclusive
Every problem is listed with its section.field path; exit code 1. You never
get a Python traceback for a bad config. Field-level errors are reported first;
cross-field rules run once the fields themselves parse.
Out of memory
Caught and re-raised with ordered suggestions, cheapest first:
Ran out of GPU memory during training.
Suggestions:
- Lower training.batch_size (raise gradient_accumulation_steps to keep the effective size)
- Enable hardware.gradient_checkpointing
- Reduce training.max_seq_length
- Switch peft.adapter to 'qlora' to load the base model in 4-bit
- Use a paged optimizer (training.optimizer: paged_adamw_8bit)
Other common cases
| Symptom | Cause | Fix |
|---|---|---|
ships no chat template |
tokenizer_default on a base model without one |
Set an explicit template |
Training dependencies are not installed |
Base install | pip install "tunepac[train]" |
record 3: alpaca record is missing keys ['output'] |
Dataset doesn't match format |
Fix the record or the format |
Adapter directory not found |
export before train, or wrong --adapter |
Point at the training output |
does not look like a PEFT adapter |
Directory has no adapter_config.json |
Use a directory tunepac train produced |
Architecture
tunepac/
├── tunepac/
│ ├── cli.py # Typer entry point: train / validate / export
│ ├── config/
│ │ ├── schema.py # Pydantic v2 schema — the contract
│ │ └── loader.py # YAML → TunepacConfig, lint-style errors
│ ├── data/
│ │ ├── formatting.py # format registry, record → messages, cheap checks
│ │ └── chat_templates.py # named Jinja chat templates
│ ├── training/trainer.py # validated config → TRL SFTTrainer (lazy imports)
│ ├── export/merge.py # adapter → standalone merged model
│ ├── utils/errors.py # TunepacError hierarchy
│ └── py.typed # PEP 561
├── configs/test_smollm2.yaml # CPU smoke-test recipe
├── scripts/run_test.py # end-to-end smoke test
├── docs/ # README assets
├── tests/ # unit + integration suites
└── example_config.yaml # Llama-3-8B QLoRA recipe
Three principles:
- The schema is the contract. All invariants live in
schema.py. The trainer maps an already-valid config onto library objects and never re-validates. Runtime checks are reserved for what only a live tokenizer, dataset, or GPU can reveal. - Heavy imports are lazy.
torch/trl/peftare imported inside functions, keeping the CLI usable without them. Commands that need them check up front and print an install hint. - Deliberate errors are typed. Everything raised on purpose derives from
TunepacError, so the CLI catches one type and exits cleanly — while genuine bugs still produce a traceback.
Development
pip install -e ".[dev]"
pytest # unit tests; integration tests skip without the [train] extra
ruff check . # lint
mypy # strict type-checking
| Tier | Needs [train]? |
Covers |
|---|---|---|
| Unit | No | Schema invariants, YAML loading/errors, format conversion, chat-template rendering, CLI behavior |
| Integration | Yes | Builds a ~100k-parameter Llama checkpoint and BPE tokenizer at test time (no network) and drives the real run_training and merge_adapter end to end |
The merge test asserts that a LoRA-targeted weight changed while an untargeted one did not — so a merge that silently copied the base would fail.
CI runs ruff + strict mypy, the unit suite with coverage on Python 3.10/3.11/3.12, and the integration suite on CPU-only torch.
Contributions welcome — keep pytest, ruff check . and mypy green, add
tests for behavior changes, and treat schema changes as API changes.
Roadmap
| Item | Status |
|---|---|
| GGUF conversion for llama.cpp runtimes | Needs llama.cpp's converter, not a pip dependency |
| Preference tuning (DPO / ORPO) | Not started |
FSDP / DeepSpeed passthrough in hardware |
Not started |
| Richer W&B run configuration | Not started |
License
Metadata
Release files for tunepac 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tunepac-0.1.0.tar.gz | 47.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tunepac-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 79.0 kB
Release files / tunepac-0.1.0.tar.gz
| Download URL | tunepac-0.1.0.tar.gz |
|---|---|
| Size | 47.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
84e01f7a2c10e3737150ef1309b586a989ea295981d793cf6c341ece75f1d25d
|
|
BLAKE2b-256 checksum How to use checksums |
3108983157653825be8e48248c94fdd2bb48c7669a9fc318b8b654abb89fbb1e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.
Transparency logRelease files / tunepac-0.1.0-py3-none-any.whl
| Download URL | tunepac-0.1.0-py3-none-any.whl |
|---|---|
| Size | 31.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2fd73bc4751a56de70d6e8407db1a8b77280c1df16f3f86d042f342c009302b2
|
|
BLAKE2b-256 checksum How to use checksums |
bb8a1056776e97401ed2de71bd6c425e529e084e79a1daf979c34a682972ca21
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.
Transparency log