| """ |
| finetune_receipt.py — LoRA fine-tune Llama-3.2-3B-Instruct on receipt line-item extraction. |
| |
| Training data: data/finetune/receipt_examples.jsonl |
| Format: {"input": "<ocr_text>", "output": "<json_string>"} |
| |
| Preferred path: |
| scripts/modal_finetune_receipt.sh --synthetic-count 48 |
| |
| Optional local/HF path: |
| uv run python scripts/finetune_receipt.py --hf-repo-id your-org/receipt-lora |
| |
| Output: |
| ./llama-3.2-3b-receipt-lora/ (LoRA adapter, push this to HF Hub) |
| llama-3.2-3b-receipt-unsloth.Q4_K_M.gguf (for llama.cpp on port 8082) |
| |
| Uses Unsloth for 2x faster training with 60% less VRAM. |
| """ |
|
|
| import json |
| import os |
| import argparse |
| from pathlib import Path |
|
|
| DATASET_PATH = Path("data/finetune/receipt_examples.jsonl") |
| BASE_MODEL = "unsloth/Llama-3.2-3B-Instruct-bnb-4bit" |
| OUTPUT_DIR = "./llama-3.2-3b-receipt-lora" |
| GGUF_NAME = "llama-3.2-3b-receipt" |
|
|
| SYSTEM_PROMPT = ( |
| "You are a receipt parser for an Indian convenience store. " |
| "Extract all line items from the receipt text. " |
| "Return ONLY valid JSON, no markdown, no explanation." |
| ) |
|
|
| INSTRUCTION_TEMPLATE = """### Instruction: |
| {system} |
| |
| ### Input: |
| {input} |
| |
| ### Response: |
| {output}""" |
|
|
|
|
| def load_dataset(dataset_path: Path) -> list[dict]: |
| examples = [ |
| json.loads(line) |
| for line in dataset_path.read_text().splitlines() |
| if line.strip() |
| ] |
| print(f"Loaded {len(examples)} training examples from {dataset_path}") |
| return examples |
|
|
|
|
| def build_hf_dataset(examples: list[dict]): |
| from datasets import Dataset |
|
|
| records = [ |
| { |
| "text": INSTRUCTION_TEMPLATE.format( |
| system=SYSTEM_PROMPT, |
| input=ex["input"], |
| output=ex["output"], |
| ) |
| } |
| for ex in examples |
| ] |
| return Dataset.from_list(records) |
|
|
|
|
| def push_to_hub(model, tokenizer, gguf_path: Path, hf_repo_id: str | None) -> None: |
| if not hf_repo_id: |
| print("No --hf-repo-id provided; skipping Hub push.") |
| print(f" Local adapter: {gguf_path.parent}") |
| print(f" Local GGUF: {gguf_path}") |
| return |
|
|
| token = os.getenv("HF_TOKEN") |
| if not token: |
| print("HF_TOKEN not set — skipping Hub push. Run: huggingface-cli login") |
| print(f" Then: huggingface-cli upload {hf_repo_id} {gguf_path}") |
| return |
|
|
| from huggingface_hub import HfApi, create_repo |
|
|
| print(f"Pushing to HF Hub: {hf_repo_id}") |
| create_repo(hf_repo_id, repo_type="model", exist_ok=True, token=token) |
|
|
| |
| model.push_to_hub(hf_repo_id, token=token) |
| tokenizer.push_to_hub(hf_repo_id, token=token) |
| print(f" Adapter pushed → {hf_repo_id}") |
|
|
| |
| api = HfApi(token=token) |
| api.upload_file( |
| path_or_fileobj=str(gguf_path), |
| path_in_repo=gguf_path.name, |
| repo_id=hf_repo_id, |
| repo_type="model", |
| ) |
| print(f" GGUF uploaded → {hf_repo_id}/{gguf_path.name}") |
| print(f"\nSet in your .env: HF_RECEIPT_MODEL_REPO={hf_repo_id}") |
|
|
|
|
| def finetune( |
| dataset_path: Path, |
| output_dir: str, |
| gguf_name: str, |
| hf_repo_id: str | None, |
| num_train_epochs: int, |
| max_steps: int, |
| ) -> None: |
| from unsloth import FastLanguageModel |
| from trl import SFTTrainer |
| from transformers import TrainingArguments |
|
|
| print(f"Loading base model: {BASE_MODEL}") |
| model, tokenizer = FastLanguageModel.from_pretrained( |
| model_name=BASE_MODEL, |
| max_seq_length=2048, |
| dtype=None, |
| load_in_4bit=True, |
| ) |
|
|
| model = FastLanguageModel.get_peft_model( |
| model, |
| r=16, |
| target_modules=["q_proj", "k_proj", "v_proj", "o_proj", |
| "gate_proj", "up_proj", "down_proj"], |
| lora_alpha=16, |
| lora_dropout=0.05, |
| bias="none", |
| use_gradient_checkpointing=True, |
| ) |
|
|
| examples = load_dataset(dataset_path) |
| dataset = build_hf_dataset(examples) |
|
|
| trainer = SFTTrainer( |
| model=model, |
| tokenizer=tokenizer, |
| train_dataset=dataset, |
| dataset_text_field="text", |
| max_seq_length=2048, |
| args=TrainingArguments( |
| per_device_train_batch_size=2, |
| gradient_accumulation_steps=4, |
| num_train_epochs=num_train_epochs, |
| max_steps=max_steps, |
| learning_rate=2e-4, |
| fp16=True, |
| logging_steps=1, |
| output_dir=output_dir, |
| save_strategy="epoch", |
| warmup_steps=5, |
| optim="adamw_8bit", |
| ), |
| ) |
|
|
| trainer.train() |
| print("Training done. Saving LoRA adapter...") |
| model.save_pretrained(output_dir) |
| tokenizer.save_pretrained(output_dir) |
|
|
| print("Exporting merged GGUF (Q4_K_M) for llama.cpp...") |
| model.save_pretrained_gguf( |
| gguf_name, |
| tokenizer, |
| quantization_method="q4_k_m", |
| ) |
| gguf_path = Path(f"{gguf_name}-unsloth.Q4_K_M.gguf") |
| print(f" Adapter: {output_dir}/") |
| print(f" GGUF: {gguf_path}") |
|
|
| push_to_hub(model, tokenizer, gguf_path, hf_repo_id) |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser(description="Local/HF receipt LoRA fine-tuning.") |
| parser.add_argument("--dataset", type=Path, default=DATASET_PATH) |
| parser.add_argument("--output-dir", default=OUTPUT_DIR) |
| parser.add_argument("--gguf-name", default=GGUF_NAME) |
| parser.add_argument("--hf-repo-id", default=os.getenv("HF_RECEIPT_MODEL_REPO", "")) |
| parser.add_argument("--epochs", type=int, default=10) |
| parser.add_argument("--max-steps", type=int, default=-1) |
| args = parser.parse_args() |
|
|
| finetune( |
| dataset_path=args.dataset, |
| output_dir=args.output_dir, |
| gguf_name=args.gguf_name, |
| hf_repo_id=args.hf_repo_id or None, |
| num_train_epochs=args.epochs, |
| max_steps=args.max_steps, |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|