| # LFED Training Playbook |
|
|
| > **Last updated:** 2026-06-08 |
| > **Status:** Training actively running on Modal A10G |
|
|
| --- |
|
|
| ## 1. How the Training Pipeline Works |
|
|
| ### Architecture Overview |
|
|
| ``` |
| generate_synthetic.py → train.jsonl (1,289 pairs) |
| ↓ |
| train.py → lora-adapter/ (QLoRA weights) |
| ↓ |
| export_gguf.py → GGUF Q4_K_M → HF Hub |
| ``` |
|
|
| All three steps run on **Modal** (cloud GPU platform) orchestrated by `modal_app.py`. |
|
|
| ### Step-by-Step Commands |
|
|
| ```bash |
| cd /Users/flucido/projects/build-small-hackathon/Kasualdad_LFED |
| source .venv/bin/activate |
| |
| # Full pipeline (all 3 steps sequentially) |
| modal run modal_train/modal_app.py |
| |
| # Or deploy as persistent app (survives client disconnect) |
| modal deploy modal_train/modal_app.py |
| |
| # Monitor progress |
| modal app logs <app-id> |
| |
| # Check running apps |
| modal app list |
| ``` |
|
|
| ### Prerequisites |
|
|
| | Requirement | How | Status | |
| |---|---|---| |
| | Modal account | Sign up at modal.com | ✅ `flucido` | |
| | Modal credits | Hackathon-provided | ✅ | |
| | HF_TOKEN secret | `modal secret create huggingface HF_TOKEN=<token>` | ✅ Created | |
| | HF Hub repo | Auto-created by export_gguf.py | ⏳ After training | |
| |
| ### Training Hyperparameters (Current) |
| |
| | Param | Value | Notes | |
| |---|---|---| |
| | Base model | `unsloth/Qwen2.5-Coder-7B-Instruct` | 7B params, passed 7/7 sanity | |
| | Quantization | 4-bit QLoRA (bitsandbytes) | Fits A10G 24GB | |
| | LoRA rank (r) | 16 | | |
| | LoRA alpha | 16 | Equal to r = stable | |
| | Target modules | q, k, v, o, gate, up, down proj | All attention + MLP | |
| | Max seq length | 2048 | | |
| | Batch size | 4 | | |
| | Grad accumulation | 4 | Effective batch = 16 | |
| | Learning rate | 2e-4 | Standard for QLoRA | |
| | Warmup ratio | 0.1 | | |
| | Epochs | 3 | | |
| | Optimizer | adamw_8bit | | |
| | Total steps | 243 | (1,289 / 16) × 3 | |
| | Training speed | ~1.7s/step | ~7 min total on A10G | |
| | Loss at step 72 | 0.1008 | Good — converged fast | |
|
|
| --- |
|
|
| ## 2. What Went Right |
|
|
| ### Model Selection |
| - Qwen2.5-Coder-7B passed **7/7 sanity checks** for DuckDB SQL generation |
| - GGUF Q4_K_M quantization (4.4 GB) fits in HF Spaces free tier |
| - Good at code/SQL tasks out of the box |
|
|
| ### Architecture |
| - Modular codebase: app.py / data_engine.py / model_inference.py / prompts.py |
| - Clean separation of concerns |
| - 81 tests passing |
|
|
| ### Synthetic Data Generation |
| - Template-based approach works well for narrow domains |
| - 32 templates with weighted sampling |
| - Questions are parameterized (school names, years, thresholds) for variety |
| - Dedup prevents exact duplicate questions |
| - Each template generates matched Q/S pairs — no hallucinated SQL |
|
|
| ### Training Execution |
| - Unsloth provides 2x speedup + 50% VRAM reduction |
| - Training converged fast (loss 0.1008 by step 72/243) |
| - QLoRA at 0.53% trainable params = cheap, fast, effective |
|
|
| ### Key Fix: Fire-and-Forget via deploy + spawn |
| - **Problem:** 5 training runs crashed because `modal run` keeps a client connection open. When the local CLI times out or disconnects, Modal cancels the running function. `--detach` didn't help because cancellation arrives before detach. |
| - **Solution:** `modal deploy` creates a persistent app with zero client connection. Call `fn.spawn()` from Python — truly fire-and-forget. |
| - **Result:** Training completed all 3 epochs (243 steps), loss converged to 0.07. |
| - **EXACT COMMANDS USED:** |
| ```bash |
| modal deploy modal_train/modal_app.py |
| python3 -c " |
| import modal |
| fn = modal.Function.from_name('kasualdad-lfed-train', 'run_full_pipeline') |
| fn.spawn() |
| " |
| |
| --- |
| |
| ## 3. What Went Wrong — Complete Error Catalog |
| |
| ### CRITICAL: Training Client Disconnects (⚠️ 5 runs crashed) |
| - **Symptom:** Training ran ~20-130 steps, then stopped with `Received a cancellation signal` |
| - **Root cause:** `modal run` keeps a gRPC connection. When local terminal exits (timeout, sleep, Ctrl+C), Modal cancels the remote function. `--detach` delay-implies detachment — the cancellation signal arrives before detach takes effect. |
| - **Fix:** `modal deploy` + call `fn.spawn()` from Python. Spawn fires with no client connection — the function runs to completion independently. |
| - **Commands:** |
| ```bash |
| modal deploy modal_train/modal_app.py |
| python3 -c " |
| import modal |
| fn = modal.Function.from_name('kasualdad-lfed-train', 'export_and_push') |
| fn.spawn() |
| " |
| ``` |
| - **Lesson for future training runs:** NEVER use `modal run` for GPU work >60 seconds. ALWAYS deploy + spawn. |
| |
| ### CRITICAL: Files Not Available in Container (2 crashes) |
| - **Symptom:** `FileNotFoundError: '/root/generate_synthetic.py'` — only `modal_app.py` was uploaded. |
| - **Root cause:** Modal only uploads the entry-point file. Sibling scripts must be explicitly mounted. |
| - **Fix:** Add `.add_local_dir()` to the Modal image definition: |
| ```python |
| train_image = modal.Image.debian_slim(...) |
| .add_local_dir(Path(__file__).parent, remote_path="/root") |
| ``` |
| - **Failed attempts:** `modal.Mount` (deprecated, doesn't exist in v1.4.3), `condition=` kwarg (not supported on `add_local_dir`) |
| |
| ### CRITICAL: Cross-Device Link Error |
| - **Symptom:** `OSError: [Errno 18] Invalid cross-device link: '/root/train.jsonl' -> '/data/train.jsonl'` |
| - **Root cause:** `Path.rename()` / `os.rename()` fails across mount points (root mount ≠ volume mount) |
| - **Fix:** Use `shutil.move()` instead of `.rename()` |
| |
| ### CRITICAL: Pickle Error on Model Save (2 crashes) |
| - **Symptom:** `PicklingError: Can't pickle <class 'trl.trainer.sft_config.SFTConfig'>` |
| - **Root cause:** SFTTrainer calls `save_model()` which tries to pickle training args. SFTConfig from nested module can't be pickled. |
| - **Fix (3 layers):** |
| 1. `save_strategy="no"` — prevent auto-saves during training |
| 2. `try/except` around `trainer.train()` to catch pickle errors |
| 3. Manual save: `model.save_pretrained()` directly (bypasses trainer) |
| |
| ### CRITICAL: CUDA Out of Memory During Merge/Export (3 crashes) |
| - **Symptom:** `torch.OutOfMemoryError: Tried to allocate 14.23 GiB` during `load_adapter()` |
| - **Root cause:** Training leaves model in GPU. Loading 16-bit base model for merge needs 14GB, exceeding A10G 22GB when combined. |
| - **Fix:** |
| 1. Free GPU after training: `del model; gc.collect(); torch.cuda.empty_cache()` |
| 2. Run `export_and_push` via `.remote()` not `.local()` — fresh container with clean GPU |
| - **Failed attempts:** `load_in_4bit=True` + `merge_and_unload()` (NotImplementedError on quantized models) |
| |
| ### CRITICAL: merge_and_unload() Not Implemented |
| - **Symptom:** `NotImplementedError` when calling `merge_and_unload()` on quantized PEFT model |
| - **Fix:** Load base model in FP16 via `AutoModelForCausalLM`, then apply adapter via `PeftModel.from_pretrained(base_model, adapter_path)`, THEN merge. The key insight: load base in FP16 first, apply adapter separately. |
| ```python |
| base_model = AutoModelForCausalLM.from_pretrained(BASE_MODEL, torch_dtype=torch.float16) |
| model = PeftModel.from_pretrained(base_model, str(LORA_DIR)) |
| model = model.merge_and_unload() |
| ``` |
| |
| ### GGUF Conversion: Missing Module (3 crashes) |
| - **Symptom:** `No module named 'llama_cpp.convert'` |
| - **Root cause:** llama-cpp-python doesn't have a built-in converter |
| - **Fix:** Clone llama.cpp repo + install `gguf` package. Use `convert_hf_to_gguf.py` from the repo. |
| - **Failed attempts:** Downloading single converter file (needs companion modules), `pip install gguf` in image (not picked up by cached image) |
|
|
| ### Quantization: llama-quantize Missing (2 attempts) |
| - **Symptom:** `llama-quantize not found, using FP16 GGUF` → 15.2 GB model |
| - **Root cause:** llama.cpp quantize binary not built |
| - **Fix:** `llama_quantize()` from llama-cpp-python (v0.3.26+ has it built-in) |
| ```python |
| from llama_cpp import llama_quantize |
| llama_quantize(input_path=f16_gguf, output_path=q4_gguf, output_type="q4_k_m") |
| ``` |
| - **Failed attempts:** cmake build (dependency issues, `-j$(nproc)` shell expansion fails with subprocess) |
|
|
| ### HF Repo Push: Namespace Permission Error |
| - **Symptom:** `403 Forbidden: You don't have the rights to create a model under the namespace "kasualdad"` |
| - **Root cause:** HF API is case-sensitive. `kasualdad` ≠ `Kasualdad`. The token belongs to user `Kasualdad`. |
| - **Fix:** Use correct case: `HF_USERNAME = "Kasualdad"` |
|
|
| ### Fine-Tuned Model Outputs Nothing (0 chars) |
| - **Symptom:** Model loads but generates 0 tokens for every query |
| - **Root cause:** Training used Qwen2.5 chat template (`<|im_start|>system...<|im_end|>`), but inference sent plain text. Model doesn't recognize the format. |
| - **Fix:** Update `build_prompt()` in `prompts.py` to use Qwen2.5 chat template: |
| ```python |
| prompt = ( |
| f"<|im_start|>system\n{system_prompt}<|im_end|>\n" |
| f"<|im_start|>user\nQuestion: {question}<|im_end|>\n" |
| f"<|im_start|>assistant\n" |
| ) |
| ``` |
| - **Also:** Add `<|im_end|>` and `<|im_start|>` to stop sequences in `model_inference.py` |
|
|
| ### CUDA Wheel on CPU Spaces |
| - **Symptom:** `libcuda.so.1: cannot open shared object file` on CPU Space |
| - **Root cause:** `requirements.txt` had `--extra-index-url cu121` which installs CUDA-linked llama-cpp-python |
| - **Fix:** Remove the CUDA wheel index. Standard PyPI wheel works on CPU + GPU. |
|
|
| ### Local Dev: Missing spaces Module |
| - **Symptom:** `ModuleNotFoundError: No module named 'spaces'` when running locally |
| - **Root cause:** `spaces` is an HF infrastructure-only package |
| - **Fix:** Try/except import with no-op fallback: |
| ```python |
| try: |
| import spaces |
| _gpu_decorator = spaces.GPU |
| except ImportError: |
| _gpu_decorator = lambda fn: fn |
| ``` |
|
|
| ### HF Space: Zero GPU Daily Limit |
| - **Symptom:** "You've hit your daily Zero GPU limit" |
| - **Root cause:** Free tier has limited daily GPU quota |
| - **Workaround:** Switch Space hardware to CPU (model still works, just slower) |
| - **Alternative:** $9/month PRO account for 8x quota |
|
|
| ### Volume Cache Serves Stale Files |
| - **Symptom:** Code changes don't take effect, old errors repeat |
| - **Root cause:** Modal volumes persist `.pyc` files from old runs. `import` picks up cached bytecode. |
| - **Fix:** Clear module cache before importing: |
| ```python |
| for key in list(sys.modules.keys()): |
| if "script_name" in key: |
| del sys.modules[key] |
| importlib.reload(module) |
| ``` |
|
|
| --- |
|
|
| ## 4. Post-Training Checklist (VERIFIED WORKING) |
|
|
| ```bash |
| # 1. Verify GGUF pushed to Hub |
| # Check https://huggingface.co/build-small-hackathon/lfed-qwen2.5-coder-7b-sql-gguf |
| from huggingface_hub import HfApi |
| api = HfApi(token='hf_...') |
| for f in api.list_repo_tree('build-small-hackathon/lfed-qwen2.5-coder-7b-sql-gguf'): |
| print(f' {f.path} ({f.size/1e9:.2f} GB)') |
| # Expected: lfed-qwen2.5-coder-7b-sql-Q4_K_M.gguf (4.68 GB) |
| |
| # 2. Update model_inference.py |
| # Set lines 101-102: |
| # HF_REPO_ID = "build-small-hackathon/lfed-qwen2.5-coder-7b-sql-gguf" |
| # HF_MODEL_FILE = "lfed-qwen2.5-coder-7b-sql-Q4_K_M.gguf" |
| |
| # 3. Fix prompts.py: Qwen2.5 chat template (see Issue "Outputs Nothing") |
| # MUST use <|im_start|>system/user/assistant<|im_end|> format |
| |
| # 4. Fix model_inference.py stop sequences: |
| # STOP_SEQUENCES = ["\n\n", "Question:", "User:", "<|im_end|>", "<|im_start|>"] |
| |
| # 5. Fix app.py: spaces.GPU made optional for local dev |
| # try/except ImportError with lambda:fn fallback |
| |
| # 6. Test locally |
| cd Kasualdad_LFED && source .venv/bin/activate |
| python -c " |
| from model_inference import load_model, generate_sql |
| from data_engine import create_session, execute_safe |
| llm = load_model() # downloads Q4_K_M from Hub (~3s on Mac) |
| raw, _ = generate_sql('How many students were chronically absent in 2023-2024?', llm=llm) |
| conn = create_session() |
| sql, df = execute_safe(conn, raw) |
| print(df) # Should show: chronic_count = 435 |
| " |
| |
| # 7. Run tests |
| pytest tests/ -v |
| |
| # 8. Commit + push to Space |
| git add -A && git commit -m "feat: fine-tuned Q4_K_M model" && git push space main |
| |
| # 9. Verify Space |
| # Open https://huggingface.co/spaces/build-small-hackathon/Kasualdad_LFED |
| # Click first example query — should return 435 |
| ``` |
|
|
| --- |
|
|
| ## 6. Verified Results (2026-06-08) |
|
|
| ### Training |
| | Metric | Value | |
| |---|---| |
| | Model | Qwen2.5-Coder-7B-Instruct → QLoRA fine-tuned | |
| | Training data | 1,289 NL→SQL pairs, 32 templates | |
| | Epochs | 3 (243 steps) | |
| | Final loss | 0.07 (converged from 2.6) | |
| | Training time | ~7 minutes on A10G | |
| | GGUF output | Q4_K_M, 4.68 GB | |
|
|
| ### Inference (3/3 test queries pass) |
| | Query | SQL | Result | |
| |---|---|---| |
| | "How many students were chronically absent in 2023-2024?" | `SELECT COUNT(*) ... WHERE is_chronically_absent = TRUE` | **435** ✅ | |
| | "Show total enrollment per school for 2024-2025, sorted highest first." | `SELECT school_name, SUM(student_count) ... ORDER BY total_enrollment DESC` | Correct ranking ✅ | |
| | "What percentage of students at Lincoln Elementary were chronically absent?" | `ROUND(COUNT(CASE WHEN ...) * 100.0 / COUNT(*), 1)` | **13.7%** ✅ | |
|
|
| ### Deployed URLs |
| - **HF Space:** https://huggingface.co/spaces/build-small-hackathon/Kasualdad_LFED |
| - **Model repo:** https://huggingface.co/build-small-hackathon/lfed-qwen2.5-coder-7b-sql-gguf |
| - **Modal app:** https://modal.com/apps/flucido/main/deployed/kasualdad-lfed-train |
| |
| ```bash |
| # 1. Clone |
| cd /Users/flucido/projects/build-small-hackathon |
| # (or clone from HF: git clone https://huggingface.co/spaces/build-small-hackathon/Kasualdad_LFED) |
|
|
| # 2. Setup |
| cd Kasualdad_LFED |
| python3.12 -m venv .venv |
| source .venv/bin/activate |
| pip install -r requirements.txt |
| modal token set # authenticate |
| |
| # 3. Create HF secret for Modal |
| modal secret create huggingface HF_TOKEN=$(cat ~/.huggingface/token) |
|
|
| # 4. Run training |
| modal deploy modal_train/modal_app.py |
| # (use deploy, not run — see Issue 1 above) |
|
|
| # 5. Monitor |
| modal app list |
| modal app logs <app-id> |
| ``` |
| |