Course: Course 3 — LLM Fine-Tuning Masterclass Module: CAP1 — The Air-Gapped Domain Model Duration: ~120 minutes (the capstone build) Environment: Python 3.11+. A consumer GPU (RTX 4090 / 16–24GB) OR Apple Silicon (M-series, via Unsloth or MLX). ~20GB free disk. The whole pipeline runs locally — no cloud training, no API serving.
This lab IS the capstone. Seven phases, each with a deliverable and a verification step. The final deliverable is a reproducible GitHub README asset.
By the end of this capstone you will have:
Create the project structure:
mkdir capstone-airgapped-domain && cd capstone-airgapped-domain
git init
mkdir -p data scripts models eval serve
touch README.md requirements.txt Makefile
The Makefile will eventually chain all phases. Start it now with stubs:
.PHONY: pipeline data train eval export serve
pipeline: data train eval export serve
@echo "Pipeline complete. See README.md for results."
data:
python scripts/01_prepare_data.py
train:
python scripts/02_train_qlora.py
eval:
python scripts/03_evaluate.py
export:
python scripts/04_export_gguf.py
serve:
@echo "Run: cd serve && ollama create domain-model -f Modelfile && ollama run domain-model"
Pin dependencies in requirements.txt:
torch==2.4.0
transformers==4.44.2
peft==0.12.0
trl==0.9.6
accelerate==0.34.2
datasets==2.21.0
bitsandbytes==0.43.3 # CUDA only; omit on Apple Silicon
Document the Python version, OS, and CUDA/torch wheel in the README's reproducibility section from the start — you will need it later.
Deliverable: A DOMAIN.md (or README section) with the domain rationale and base choice, justified.
Choose a domain from: medical, legal, or security. Apply the FT00 three-outcome test:
Pick an open-data base:
Write one paragraph in DOMAIN.md: why this domain, why this base, what its training corpus covers, and the three-outcome-test result that confirms fine-tuning is the right intervention.
Verification: Your domain passes outcome 1 (the base produces the behavior unreliably). If it passes outcome 3, pick a different domain — fine-tuning won't help.
Deliverable: data/train.jsonl and data/held_out.jsonl with documented provenance in DATA.md.
No real PHI. Two acceptable paths:
Domain-appropriate open datasets:
Download, convert to chat-format JSONL ({"messages": [{"role": "user", "content": ...}, {"role": "assistant", "content": ...}]}).
Use a teacher model to generate domain examples (Distilabel or Magpie). Then apply dedup (MinHash, FT06) and decontamination (remove any example overlapping with the held-out set).
# scripts/01_prepare_data.py (sketch)
from datasets import load_dataset
import json, random
# Example: PubMedQA -> chat format
ds = load_dataset("pubmed_qa", "pqa_labeled", split="train")
examples = []
for row in ds:
examples.append({
"messages": [
{"role": "user", "content": f"Question: {row['question']}\nContext: {row['context']}"},
{"role": "assistant", "content": row["long_answer"]},
]
})
random.seed(42)
random.shuffle(examples)
split = int(len(examples) * 0.9)
train, held_out = examples[:split], examples[split:]
with open("data/train.jsonl", "w") as f:
for ex in train: f.write(json.dumps(ex) + "\n")
with open("data/held_out.jsonl", "w") as f:
for ex in held_out: f.write(json.dumps(ex) + "\n")
print(f"Train: {len(train)}, Held-out: {len(held_out)}")
Verification: DATA.md documents the source (e.g., "PubMedQA pqa_labeled split, 1k train / 100 held-out, seed=42, MinHash dedup applied, no PHI"). The held-out set must contain NO examples present in train.
Deliverable: models/merged/ (the adapter folded into the base).
# scripts/02_train_qlora.py (sketch)
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset
MODEL_ID = "openbmb/MiniCPM5-1B" # your open-data base
SEED = 42
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID, torch_dtype=torch.float16, device_map="auto",
trust_remote_code=True,
)
lora_config = LoraConfig(
r=16, lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05, bias="none", task_type="CAUSAL_LM",
)
dataset = load_dataset("json", data_files="data/train.jsonl", split="train")
training_args = TrainingArguments(
output_dir="models/checkpoints",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4,
warmup_ratio=0.03,
lr_scheduler_type="cosine",
logging_steps=10,
save_strategy="epoch",
seed=SEED,
fp16=True, # or bf16 on newer GPUs; on Apple Silicon, use MPS
)
trainer = SFTTrainer(
model=model, args=training_args,
train_dataset=dataset,
peft_config=lora_config,
max_seq_length=1024,
)
trainer.train()
# MERGE the adapter into the base (critical — GGUF export needs a merged model)
merged = trainer.model.merge_and_unload()
merged.save_pretrained("models/merged")
tokenizer.save_pretrained("models/merged")
print("Merged model saved to models/merged/")
Document the hyperparameters in TRAINING.md: rank, alpha, target modules, LR, epochs, batch size, warmup, scheduler. These are the knobs that determined your result.
Verification: models/merged/ contains config.json, pytorch_model.bin (or safetensors), and tokenizer files. Load it and generate on a sample prompt to confirm it produces the steered behavior.
Deliverable: eval/results.json with base score, fine-tuned score, lift, and forgetting.
Two evaluations:
# scripts/03_evaluate.py (sketch)
import torch, json
from transformers import AutoModelForCausalLM, AutoTokenizer
BASE_ID = "openbmb/MiniCPM5-1B"
FT_PATH = "models/merged"
def score_model(model_path, eval_set, metric="exact_match"):
model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype=torch.float16, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_path)
# ... run generation on eval_set, compute metric ...
return score
held_out = [json.loads(l) for l in open("data/held_out.jsonl")]
base_domain = score_model(BASE_ID, held_out)
ft_domain = score_model(FT_PATH, held_out)
lift = ft_domain - base_domain
# General benchmark for forgetting (use a small MMLU subset or GSM8K)
from datasets import load_dataset
mmlu = load_dataset("cais/mmlu", "all", split="test").select(range(200))
base_general = score_model(BASE_ID, mmlu)
ft_general = score_model(FT_PATH, mmlu)
forgetting = base_general - ft_general
results = {
"base_domain": base_domain, "ft_domain": ft_domain, "lift": lift,
"base_general": base_general, "ft_general": ft_general, "forgetting": forgetting,
}
json.dump(results, open("eval/results.json", "w"), indent=2)
print(json.dumps(results, indent=2))
Verification: eval/results.json shows a positive, non-trivial lift (target +3 to +15) and a small forgetting (target <5; ideally <2). If lift is ~0 or negative, revisit Phase 3 (more epochs, different LR, or the domain was outcome 3 not outcome 1). If forgetting is large (>5), your fine-tune was too aggressive — reduce epochs or LR.
Deliverable: serve/domain-model.gguf.
Requires llama.cpp:
# scripts/04_export_gguf.py or a shell step
git clone https://github.com/ggerganov/llama.cpp.git # do this once, locally
cd llama.cpp && pip install -r requirements.txt
# Convert HF -> GGUF (FP16 first)
python convert_hf_to_gguf.py ../models/merged --outfile ../serve/domain-model-f16.gguf
# Quantize to Q4_K_M (the default for local serve)
./llama-quantize ../serve/domain-model-f16.gguf ../serve/domain-model.gguf Q4_K_M
Verification: The .gguf file exists and is the expected size (~0.6GB for a 1B Q4, ~2GB for 3B Q4). Load it once with llama-cli -m serve/domain-model.gguf -p "test" to confirm it generates — a load failure usually means a chat-template mismatch (FT07).
Deliverable: A running model + latency measurement + telemetry-off confirmation.
cd serve
cat > Modelfile <<EOF
FROM ./domain-model.gguf
TEMPLATE """{{ .Prompt }}"""
PARAMETER stop "<|im_end|>"
EOF
ollama create domain-model -f Modelfile
time ollama run domain-model "Your domain prompt here"
./llama-server -m serve/domain-model.gguf --port 8080
# In another terminal:
curl -X POST http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Your domain prompt"}]}'
Verify the served model is YOUR fine-tune (not the base): run the same domain prompt through the served model and through the HF models/merged model. The outputs should exhibit the same steered behavior.
Verify no telemetry:
# Disconnect from the network (turn off Wi-Fi / unplug ethernet)
ollama run domain-model "test prompt"
# If it still responds, the air-gap holds.
# Or capture packets on a clean run:
sudo tcpdump -i any -n 'not port 22' &
ollama run domain-model "test prompt"
# Check the tcpdump output for ANY outbound connections. There should be none.
Record the latency (tok/s) and the telemetry result in the README.
Verification: Model responds with fine-tuned behavior; latency is usable (>5 tok/s interactive); zero outbound network calls confirmed.
Deliverable: A README that reads like a short technical report and a one-command pipeline.
# Air-Gapped [Domain] Model
## Domain rationale
[One paragraph: why this domain, why this base, three-outcome-test result]
## Pipeline
[The seven-phase diagram or list]
## Results
| Metric | Base | Fine-tuned | Delta |
|--------|------|------------|-------|
| Domain held-out | XX.X | XX.X | +X.X (lift) |
| General (MMLU/GSM8K) | XX.X | XX.X | -X.X (forgetting) |
| Serve latency | — | XX tok/s | Q4_K_M, [hardware] |
## Reproducibility
- Python 3.11.X, [OS], torch 2.4.0, CUDA 12.X / MPS
- `pip install -r requirements.txt`
- `make pipeline`
- Seeds set (42). Re-run should reproduce within ±2 pts.
## Data safety
[Source, de-identification, provenance. E.g.: "PubMedQA pqa_labeled, public, de-identified by construction. 1k train / 100 held-out, seed=42, MinHash dedup. No PHI. Recipe publishable."]
## Serve
cd serve && ollama create domain-model -f Modelfile && ollama run domain-model
Confirm make pipeline executes all phases in order (or fails loudly at the first broken phase). Test it on a clean clone if possible.
Verification: A reviewer reading the README sees the numbers, the methodology, the data-safety note, and the one-command run. They can clone, run, and compare.
Submit the GitHub repo. Required:
DOMAIN.md — domain rationale, base choice, three-outcome-test resultDATA.md — data provenance, de-identification, train/held-out split, no PHITRAINING.md — hyperparameters (rank, alpha, targets, LR, epochs, batch, scheduler, seed)eval/results.json — base, fine-tuned, lift, forgetting numbersserve/domain-model.gguf — the quantized modelREADME.md — results table, reproducibility section, data-safety note, serve instructionsMakefile — make pipeline chains all phasesrequirements.txt — pinned versions| Dimension | Your score (1-3) | Notes |
|---|---|---|
| Domain lift (>+5=3, positive=2, none/contaminated=1) | ||
| Forgetting control (reported + <2 or mitigated=3, reported=2, not measured=1) | ||
| Local serve (loads+behavior+no telemetry=3, loads+no telemetry check=2, needs network=1) | ||
| Reproducibility (one-command+pinned+numbers=3, mostly=2, notebook-only=1) | ||
| HIPAA-safety (open/de-id+provenance=3, open+unclear provenance=2, real PHI=1) |
Passing: 12/15+ with no dimension below 2. HIPAA-safety is a gate.
make pipeline, data-safety note.A submission at this shape passes. A submission missing the forgetting number, or with an unjustified closed base, or that phones home on serve, fails — regardless of how strong the lift is.
make data and make eval on push, failing if the held-out lift regresses below a threshold. (The portfolio asset, hardened.)# Lab Specification — Capstone CAP1: The Air-Gapped Domain Model
**Course**: Course 3 — LLM Fine-Tuning Masterclass
**Module**: CAP1 — The Air-Gapped Domain Model
**Duration**: ~120 minutes (the capstone build)
**Environment**: Python 3.11+. A consumer GPU (RTX 4090 / 16–24GB) OR Apple Silicon (M-series, via Unsloth or MLX). ~20GB free disk. The whole pipeline runs locally — no cloud training, no API serving.
> *This lab IS the capstone. Seven phases, each with a deliverable and a verification step. The final deliverable is a reproducible GitHub README asset.*
---
## Learning objectives
By the end of this capstone you will have:
1. **Chosen a domain and an open-data base**, justified in writing, with the steering thesis applied (capability present, behavior absent).
2. **Prepared HIPAA-safe training and held-out datasets** from open sources or synthetic generation, with documented provenance.
3. **Fine-tuned via QLoRA and merged** the adapter into the base, producing a standalone steered model.
4. **Evaluated** for domain lift AND general-benchmark forgetting, with both numbers reported.
5. **Exported to GGUF** and **served locally** via Ollama or llama.cpp, confirming the fine-tuned behavior and zero telemetry.
6. **Packaged** the whole thing as a reproducible pipeline (pinned deps, one-command run, numbers in README).
---
## Phase 0 — Repo scaffold (5 min)
Create the project structure:
```bash
mkdir capstone-airgapped-domain && cd capstone-airgapped-domain
git init
mkdir -p data scripts models eval serve
touch README.md requirements.txt Makefile
```
The `Makefile` will eventually chain all phases. Start it now with stubs:
```makefile
.PHONY: pipeline data train eval export serve
pipeline: data train eval export serve
@echo "Pipeline complete. See README.md for results."
data:
python scripts/01_prepare_data.py
train:
python scripts/02_train_qlora.py
eval:
python scripts/03_evaluate.py
export:
python scripts/04_export_gguf.py
serve:
@echo "Run: cd serve && ollama create domain-model -f Modelfile && ollama run domain-model"
```
Pin dependencies in `requirements.txt`:
```text
torch==2.4.0
transformers==4.44.2
peft==0.12.0
trl==0.9.6
accelerate==0.34.2
datasets==2.21.0
bitsandbytes==0.43.3 # CUDA only; omit on Apple Silicon
```
Document the Python version, OS, and CUDA/torch wheel in the README's reproducibility section from the start — you will need it later.
---
## Phase 1 — Choose domain and base (10 min)
**Deliverable**: A `DOMAIN.md` (or README section) with the domain rationale and base choice, justified.
Choose a domain from: medical, legal, or security. Apply the FT00 three-outcome test:
1. Prompt the base model directly with a domain task and a great system prompt.
2. Observe: does it produce the behavior unreliably or in the wrong format? (Outcome 1 → fine-tune with SFT. Good.) Does it refuse? (Outcome 2 → DPO/abliteration, but that's Capstone 2.) Does it have no idea? (Outcome 3 → wrong domain; pick another.)
Pick an **open-data base**:
- **MiniCPM5-1B or 3B** — fastest iteration, open weights + open data (UltraChat/UltraFeedback), Apache-2.0.
- **OLMo 2 / Tulu 3** — fully open (weights + data + code), larger.
- **SmolLM3 3B** — 11.2T tokens, full data mixture, Apache-2.0.
Write one paragraph in `DOMAIN.md`: why this domain, why this base, what its training corpus covers, and the three-outcome-test result that confirms fine-tuning is the right intervention.
**Verification**: Your domain passes outcome 1 (the base produces the behavior unreliably). If it passes outcome 3, pick a different domain — fine-tuning won't help.
---
## Phase 2 — HIPAA-safe data prep (20 min)
**Deliverable**: `data/train.jsonl` and `data/held_out.jsonl` with documented provenance in `DATA.md`.
No real PHI. Two acceptable paths:
### Path A — Open datasets
Domain-appropriate open datasets:
- **Medical**: PubMedQA, MedMCQA, MedQA (all public, de-identified by construction).
- **Legal**: LegalBench, CaseHOLD (public legal text).
- **Security**: security advisory corpora, CVE descriptions (public).
Download, convert to chat-format JSONL (`{"messages": [{"role": "user", "content": ...}, {"role": "assistant", "content": ...}]}`).
### Path B — Synthetic generation (FT05)
Use a teacher model to generate domain examples (Distilabel or Magpie). Then apply dedup (MinHash, FT06) and decontamination (remove any example overlapping with the held-out set).
```python
# scripts/01_prepare_data.py (sketch)
from datasets import load_dataset
import json, random
# Example: PubMedQA -> chat format
ds = load_dataset("pubmed_qa", "pqa_labeled", split="train")
examples = []
for row in ds:
examples.append({
"messages": [
{"role": "user", "content": f"Question: {row['question']}\nContext: {row['context']}"},
{"role": "assistant", "content": row["long_answer"]},
]
})
random.seed(42)
random.shuffle(examples)
split = int(len(examples) * 0.9)
train, held_out = examples[:split], examples[split:]
with open("data/train.jsonl", "w") as f:
for ex in train: f.write(json.dumps(ex) + "\n")
with open("data/held_out.jsonl", "w") as f:
for ex in held_out: f.write(json.dumps(ex) + "\n")
print(f"Train: {len(train)}, Held-out: {len(held_out)}")
```
**Verification**: `DATA.md` documents the source (e.g., "PubMedQA pqa_labeled split, 1k train / 100 held-out, seed=42, MinHash dedup applied, no PHI"). The held-out set must contain NO examples present in train.
---
## Phase 3 — QLoRA fine-tune and merge (30 min)
**Deliverable**: `models/merged/` (the adapter folded into the base).
```python
# scripts/02_train_qlora.py (sketch)
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset
MODEL_ID = "openbmb/MiniCPM5-1B" # your open-data base
SEED = 42
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID, torch_dtype=torch.float16, device_map="auto",
trust_remote_code=True,
)
lora_config = LoraConfig(
r=16, lora_alpha=32,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
lora_dropout=0.05, bias="none", task_type="CAUSAL_LM",
)
dataset = load_dataset("json", data_files="data/train.jsonl", split="train")
training_args = TrainingArguments(
output_dir="models/checkpoints",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4,
warmup_ratio=0.03,
lr_scheduler_type="cosine",
logging_steps=10,
save_strategy="epoch",
seed=SEED,
fp16=True, # or bf16 on newer GPUs; on Apple Silicon, use MPS
)
trainer = SFTTrainer(
model=model, args=training_args,
train_dataset=dataset,
peft_config=lora_config,
max_seq_length=1024,
)
trainer.train()
# MERGE the adapter into the base (critical — GGUF export needs a merged model)
merged = trainer.model.merge_and_unload()
merged.save_pretrained("models/merged")
tokenizer.save_pretrained("models/merged")
print("Merged model saved to models/merged/")
```
Document the hyperparameters in `TRAINING.md`: rank, alpha, target modules, LR, epochs, batch size, warmup, scheduler. These are the knobs that determined your result.
**Verification**: `models/merged/` contains `config.json`, `pytorch_model.bin` (or safetensors), and tokenizer files. Load it and generate on a sample prompt to confirm it produces the steered behavior.
---
## Phase 4 — Eval (domain held-out + general benchmark) (20 min)
**Deliverable**: `eval/results.json` with base score, fine-tuned score, lift, and forgetting.
Two evaluations:
```python
# scripts/03_evaluate.py (sketch)
import torch, json
from transformers import AutoModelForCausalLM, AutoTokenizer
BASE_ID = "openbmb/MiniCPM5-1B"
FT_PATH = "models/merged"
def score_model(model_path, eval_set, metric="exact_match"):
model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype=torch.float16, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_path)
# ... run generation on eval_set, compute metric ...
return score
held_out = [json.loads(l) for l in open("data/held_out.jsonl")]
base_domain = score_model(BASE_ID, held_out)
ft_domain = score_model(FT_PATH, held_out)
lift = ft_domain - base_domain
# General benchmark for forgetting (use a small MMLU subset or GSM8K)
from datasets import load_dataset
mmlu = load_dataset("cais/mmlu", "all", split="test").select(range(200))
base_general = score_model(BASE_ID, mmlu)
ft_general = score_model(FT_PATH, mmlu)
forgetting = base_general - ft_general
results = {
"base_domain": base_domain, "ft_domain": ft_domain, "lift": lift,
"base_general": base_general, "ft_general": ft_general, "forgetting": forgetting,
}
json.dump(results, open("eval/results.json", "w"), indent=2)
print(json.dumps(results, indent=2))
```
**Verification**: `eval/results.json` shows a positive, non-trivial lift (target +3 to +15) and a small forgetting (target <5; ideally <2). If lift is ~0 or negative, revisit Phase 3 (more epochs, different LR, or the domain was outcome 3 not outcome 1). If forgetting is large (>5), your fine-tune was too aggressive — reduce epochs or LR.
---
## Phase 5 — GGUF export (10 min)
**Deliverable**: `serve/domain-model.gguf`.
Requires `llama.cpp`:
```bash
# scripts/04_export_gguf.py or a shell step
git clone https://github.com/ggerganov/llama.cpp.git # do this once, locally
cd llama.cpp && pip install -r requirements.txt
# Convert HF -> GGUF (FP16 first)
python convert_hf_to_gguf.py ../models/merged --outfile ../serve/domain-model-f16.gguf
# Quantize to Q4_K_M (the default for local serve)
./llama-quantize ../serve/domain-model-f16.gguf ../serve/domain-model.gguf Q4_K_M
```
**Verification**: The `.gguf` file exists and is the expected size (~0.6GB for a 1B Q4, ~2GB for 3B Q4). Load it once with `llama-cli -m serve/domain-model.gguf -p "test"` to confirm it generates — a load failure usually means a chat-template mismatch (FT07).
---
## Phase 6 — Local serve (10 min)
**Deliverable**: A running model + latency measurement + telemetry-off confirmation.
### Via Ollama
```bash
cd serve
cat > Modelfile <<EOF
FROM ./domain-model.gguf
TEMPLATE """{{ .Prompt }}"""
PARAMETER stop "<|im_end|>"
EOF
ollama create domain-model -f Modelfile
time ollama run domain-model "Your domain prompt here"
```
### Via llama.cpp
```bash
./llama-server -m serve/domain-model.gguf --port 8080
# In another terminal:
curl -X POST http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Your domain prompt"}]}'
```
**Verify the served model is YOUR fine-tune** (not the base): run the same domain prompt through the served model and through the HF `models/merged` model. The outputs should exhibit the same steered behavior.
**Verify no telemetry**:
```bash
# Disconnect from the network (turn off Wi-Fi / unplug ethernet)
ollama run domain-model "test prompt"
# If it still responds, the air-gap holds.
# Or capture packets on a clean run:
sudo tcpdump -i any -n 'not port 22' &
ollama run domain-model "test prompt"
# Check the tcpdump output for ANY outbound connections. There should be none.
```
Record the latency (tok/s) and the telemetry result in the README.
**Verification**: Model responds with fine-tuned behavior; latency is usable (>5 tok/s interactive); zero outbound network calls confirmed.
---
## Phase 7 — Reproducibility package (15 min)
**Deliverable**: A README that reads like a short technical report and a one-command pipeline.
### README structure
```markdown
# Air-Gapped [Domain] Model
## Domain rationale
[One paragraph: why this domain, why this base, three-outcome-test result]
## Pipeline
[The seven-phase diagram or list]
## Results
| Metric | Base | Fine-tuned | Delta |
|--------|------|------------|-------|
| Domain held-out | XX.X | XX.X | +X.X (lift) |
| General (MMLU/GSM8K) | XX.X | XX.X | -X.X (forgetting) |
| Serve latency | — | XX tok/s | Q4_K_M, [hardware] |
## Reproducibility
- Python 3.11.X, [OS], torch 2.4.0, CUDA 12.X / MPS
- `pip install -r requirements.txt`
- `make pipeline`
- Seeds set (42). Re-run should reproduce within ±2 pts.
## Data safety
[Source, de-identification, provenance. E.g.: "PubMedQA pqa_labeled, public, de-identified by construction. 1k train / 100 held-out, seed=42, MinHash dedup. No PHI. Recipe publishable."]
## Serve
cd serve && ollama create domain-model -f Modelfile && ollama run domain-model
```
### One-command run
Confirm `make pipeline` executes all phases in order (or fails loudly at the first broken phase). Test it on a clean clone if possible.
**Verification**: A reviewer reading the README sees the numbers, the methodology, the data-safety note, and the one-command run. They can clone, run, and compare.
---
## Deliverables checklist
Submit the GitHub repo. Required:
- [ ] `DOMAIN.md` — domain rationale, base choice, three-outcome-test result
- [ ] `DATA.md` — data provenance, de-identification, train/held-out split, no PHI
- [ ] `TRAINING.md` — hyperparameters (rank, alpha, targets, LR, epochs, batch, scheduler, seed)
- [ ] `eval/results.json` — base, fine-tuned, lift, forgetting numbers
- [ ] `serve/domain-model.gguf` — the quantized model
- [ ] `README.md` — results table, reproducibility section, data-safety note, serve instructions
- [ ] `Makefile` — `make pipeline` chains all phases
- [ ] `requirements.txt` — pinned versions
---
## Evaluation rubric (self-assess before submitting)
| Dimension | Your score (1-3) | Notes |
| --- | --- | --- |
| Domain lift (>+5=3, positive=2, none/contaminated=1) | | |
| Forgetting control (reported + <2 or mitigated=3, reported=2, not measured=1) | | |
| Local serve (loads+behavior+no telemetry=3, loads+no telemetry check=2, needs network=1) | | |
| Reproducibility (one-command+pinned+numbers=3, mostly=2, notebook-only=1) | | |
| HIPAA-safety (open/de-id+provenance=3, open+unclear provenance=2, real PHI=1) | | |
**Passing: 12/15+ with no dimension below 2. HIPAA-safety is a gate.**
---
## Solution key (what a passing submission looks like)
- **Domain**: medical Q&A via PubMedQA. Base: MiniCPM5-1B (open-data, Apache-2.0).
- **Data**: PubMedQA pqa_labeled, 1000 train / 100 held-out, seed=42, MinHash dedup. No PHI.
- **Training**: QLoRA r=16, alpha=32, targets q/k/v/o_proj, LR 2e-4, 3 epochs, cosine, seed=42.
- **Results**: Base exact-match 32.0 → Fine-tuned 41.5 (lift +9.5). MMLU (200-subset) base 44.5 → 43.8 (forgetting -0.7).
- **Export**: Q4_K_M, 0.62GB GGUF.
- **Serve**: Ollama, 18 tok/s on M2 Pro. Verified no telemetry (ran with Wi-Fi off).
- **README**: full results table, reproducibility section with `make pipeline`, data-safety note.
- **Rubric**: lift 3, forgetting 3, serve 3, reproducibility 3, HIPAA 3 = 15/15. Pass.
A submission at this shape passes. A submission missing the forgetting number, or with an unjustified closed base, or that phones home on serve, fails — regardless of how strong the lift is.
---
## Stretch goals
1. **Add a second quant level.** Export Q8_0 and Q4_K_M, eval both on the domain held-out, report the quality/size trade-off. (FT19.)
2. **Run the pipeline on a second hardware target.** If you trained on CUDA, serve on Apple Silicon (or vice versa). Document any cross-platform variance. (Reproducibility stress test.)
3. **Publish the pipeline to GitHub with a CI check.** Add a GitHub Action that runs `make data` and `make eval` on push, failing if the held-out lift regresses below a threshold. (The portfolio asset, hardened.)
4. **Decontaminate against the held-out set formally.** Use an n-gram overlap check (FT06) and report the overlap percentage. A clean held-out set has <1% n-gram overlap with train.