SaitejaDubbas commited on
Commit
3cb0c0f
·
0 Parent(s):

Initial commit: Phase 1 (data prep) and Phase 2 (QLoRA fine-tuning) complete

Browse files

- data/prepare_data.py builds a balanced LEDGAR clause-classification split
- training/finetune_qlora.ipynb fine-tunes Llama-3.2-1B with QLoRA, measuring
baseline vs fine-tuned accuracy (20.00% -> 94.33%)

.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+ venv/
6
+ env/
7
+ .env
8
+ .ipynb_checkpoints/
9
+
10
+ # Generated data (regenerable with data/prepare_data.py) -- keep labels.json
11
+ data/train.jsonl
12
+ data/val.jsonl
13
+ data/test.jsonl
14
+
15
+ # Model weights / adapters (stored on the Hugging Face Hub instead)
16
+ *.bin
17
+ *.safetensors
18
+ adapters/
19
+ outputs/
20
+ checkpoints/
21
+
22
+ # Hugging Face cache
23
+ .cache/
24
+
25
+ # Claude Code local-only settings
26
+ .claude/settings.local.json
CLAUDE.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md — Project guide for Claude Code
2
+
3
+ ## What we are building
4
+ **Compliance Copilot**: an automated contract-clause classifier and compliance-review
5
+ tool. It reads a contract, splits it into clauses, classifies each clause by type,
6
+ checks it against compliance rules, and produces a review report.
7
+
8
+ This is a portfolio project. It must be showcaseable on a resume, on GitHub, and as a
9
+ live deployed demo. Everything must use free / open-source tools (no paid services
10
+ beyond Claude Code itself).
11
+
12
+ ## The three components (each maps to one resume line)
13
+ 1. **Brain** — `Llama-3.2-1B-Instruct` fine-tuned with **QLoRA (PEFT)** to classify
14
+ contract clauses. (Resume line: "fine-tuned Llama-3 with QLoRA/PEFT, +X% accuracy".)
15
+ 2. **Front desk** — a **FastAPI** service with **async** endpoints, packaged in **Docker**.
16
+ (Resume line: "scalable asynchronous pipelines with FastAPI and Docker".)
17
+ 3. **Manager** — a **LangChain agent** that runs the end-to-end review workflow.
18
+ (Resume line: "automated compliance workflows with LangChain agents".)
19
+
20
+ ## Tech + free-tool choices (do not change without asking the user)
21
+ - Dataset: **LEDGAR** (public contract-clause dataset) via HuggingFace `datasets`.
22
+ - Model: `meta-llama/Llama-3.2-1B-Instruct` (small enough for free Colab GPU + free CPU serving).
23
+ - Fine-tuning: QLoRA (4-bit) + PEFT + HuggingFace `transformers` / `trl`, run in **Google Colab** (free T4).
24
+ - Adapter storage: **HuggingFace Hub** (free; LoRA adapter is a few MB).
25
+ - Serving: FastAPI + Uvicorn, async endpoints.
26
+ - Workflow: LangChain agent + tools.
27
+ - Deploy: **HuggingFace Spaces** (free Docker Space, CPU inference).
28
+ - Repo: GitHub.
29
+
30
+ ## Roadmap (build phase by phase; explain each step simply before coding)
31
+ - [x] **Phase 1 — Data prep** (`data/prepare_data.py`): subset LEDGAR to top-10 clause
32
+ types, balance classes, write train/val/test JSONL in chat format. DONE.
33
+ - [x] **Phase 2 — Fine-tune the brain** (`training/finetune_qlora.ipynb`): QLoRA fine-tune,
34
+ AND measure baseline (zero-shot) vs fine-tuned accuracy so the "+X%" number is REAL.
35
+ DONE — ran successfully in Colab. Accuracy 20.00% -> 94.33% (+74.33 pp), macro-F1
36
+ 0.1013 -> 0.9439 (+84.26 pp). Adapter pushed to
37
+ `SaitejaDubbas/compliance-copilot-llama32-1b-lora`.
38
+ - [ ] **Phase 3 — Front desk** (`app/model.py`, `app/main.py`): load base model + adapter,
39
+ async FastAPI `/classify` endpoint.
40
+ - [ ] **Phase 4 — Manager** (`app/agent.py`): LangChain agent that splits a document into
41
+ clauses, classifies each via the model, applies simple compliance rules, writes a report.
42
+ - [ ] **Phase 5 — Docker** (`Dockerfile`): containerize the app.
43
+ - [ ] **Phase 6 — Deploy**: HuggingFace Spaces (Docker SDK), get a live public link.
44
+ - [ ] **Phase 7 — Polish**: README results table with real numbers, demo GIF, resume wording.
45
+
46
+ ## Conventions
47
+ - The system prompt + user-prompt template live in `data/prepare_data.py`
48
+ (`SYSTEM_PROMPT`, `build_user_prompt`). Training AND serving must reuse the EXACT same
49
+ text, or the model gets confused. If serving code needs them, import/copy them verbatim.
50
+ - Keep the deployed model runnable on CPU (this is why we use the 1B model).
51
+ - The "+X% accuracy" claim must always be a measured number (base zero-shot vs fine-tuned
52
+ on the held-out test split), never invented.
53
+ - Explain each new step in plain language before writing code (the user likes concrete
54
+ analogies and step-by-step reasoning).
55
+
56
+ ## Current status
57
+ Phase 1 is done (note: the LEDGAR dataset id changed from `lex_glue` to
58
+ `coastalcph/lex_glue` — the old unnamespaced alias no longer resolves on the Hub;
59
+ `data/prepare_data.py` and `training/finetune_qlora.ipynb` both use the new id).
60
+
61
+ Phase 2 is done. HF username: `SaitejaDubbas`. Adapter repo:
62
+ `SaitejaDubbas/compliance-copilot-llama32-1b-lora`. Three fixes were needed live in
63
+ Colab and are now baked into `training/finetune_qlora.ipynb` so a fresh run works
64
+ end to end: (1) TRL's `SFTConfig` renamed `max_seq_length` -> `max_length`; (2) the
65
+ T4 computes in bfloat16, so training must use `bf16=True, fp16=False` (fp16 crashed
66
+ with a BFloat16 gradient-unscale error); (3) the save/push cell now derives
67
+ `HF_USERNAME` via `huggingface_hub.whoami()` right before pushing instead of trusting
68
+ the placeholder in the config cell, so it can't 403 against a nonexistent namespace.
69
+ See `docs/PROGRESS_LOG.md` for details.
70
+
71
+ Next action: build Phase 3 — `app/model.py` + `app/main.py`, loading
72
+ `unsloth/Llama-3.2-1B-Instruct` plus the `SaitejaDubbas/compliance-copilot-llama32-1b-lora`
73
+ adapter behind an async FastAPI `/classify` endpoint.
README.md ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Compliance Copilot
2
+
3
+ **Automated contract-clause classification and compliance-review workflows, powered by a fine-tuned Llama-3 model.**
4
+
5
+ Compliance Copilot reads a contract, breaks it into individual clauses, classifies each clause by type, checks it against a set of compliance rules, and produces a review report — all through a single API call or a web demo.
6
+
7
+ > Built entirely with free and open-source tools. Trained on the public **LEDGAR** contract-clause dataset (no private data used).
8
+
9
+ ---
10
+
11
+ ## What it does
12
+
13
+ Three components work together, like a small compliance team:
14
+
15
+ | Component | Role | Tech |
16
+ |-----------|------|------|
17
+ | **The brain** | Reads a clause and predicts its type | Llama-3.2-1B fine-tuned with QLoRA (PEFT) |
18
+ | **The front desk** | Serves predictions fast, handles many requests at once | FastAPI (async) + Docker |
19
+ | **The manager** | Runs the end-to-end review workflow and writes the report | LangChain agent |
20
+
21
+ ```
22
+ LangChain Agent -> FastAPI service -> Fine-tuned Llama-3
23
+ (workflow) (async API) (QLoRA adapter)
24
+ ```
25
+
26
+ ## Results
27
+
28
+ Measured on a held-out test split of LEDGAR (600 clauses, 10 clause types). Fine-tuning raised accuracy from 20% to 94%:
29
+
30
+ | Setup | Accuracy | Macro-F1 |
31
+ |-------|----------|----------|
32
+ | Base Llama-3.2-1B (zero-shot) | 20.00% | 0.1013 |
33
+ | Fine-tuned with QLoRA | 94.33% | 0.9439 |
34
+ | **Improvement** | **+74.33 pp** | **+84.26 pp** |
35
+
36
+ *(Numbers are measured on the held-out test split, not estimated. Adapter: [SaitejaDubbas/compliance-copilot-llama32-1b-lora](https://huggingface.co/SaitejaDubbas/compliance-copilot-llama32-1b-lora).)*
37
+
38
+ ## Tech stack
39
+
40
+ - **Model / training:** Llama-3.2-1B-Instruct, QLoRA (4-bit), PEFT, Hugging Face `transformers` + `trl`
41
+ - **Serving:** FastAPI, Uvicorn, async endpoints
42
+ - **Workflow:** LangChain agent + tools
43
+ - **Packaging & deploy:** Docker, Hugging Face Spaces (free tier)
44
+ - **Data:** LEDGAR (via the `datasets` library)
45
+
46
+ ## Project structure
47
+
48
+ ```
49
+ compliance-copilot/
50
+ ├── data/
51
+ │ └── prepare_data.py # Phase 1: build train/val/test from LEDGAR
52
+ ├── training/
53
+ │ └── finetune_qlora.ipynb # Phase 2: QLoRA fine-tuning + accuracy eval (added next)
54
+ ├── app/
55
+ │ ├── model.py # loads model + adapter, runs inference (added next)
56
+ │ ├── main.py # FastAPI async endpoints (added next)
57
+ │ ├── agent.py # LangChain compliance-review agent (added next)
58
+ │ └── ui.py # simple web demo (added next)
59
+ ├── Dockerfile # Phase 5 (added next)
60
+ ├── requirements.txt
61
+ └── README.md
62
+ ```
63
+
64
+ ## Quickstart
65
+
66
+ ```bash
67
+ # 1. Build the dataset
68
+ pip install -U datasets
69
+ python data/prepare_data.py
70
+
71
+ # 2. Fine-tune (in Google Colab, free GPU) -- see training/finetune_qlora.ipynb
72
+
73
+ # 3. Run the API locally
74
+ pip install -r requirements.txt
75
+ uvicorn app.main:app --reload
76
+
77
+ # 4. Try it
78
+ curl -X POST localhost:8000/classify -H "Content-Type: application/json" \
79
+ -d '{"clause": "The parties agree to keep all shared information confidential."}'
80
+ ```
81
+
82
+ ## Live demo
83
+
84
+ Deployed on Hugging Face Spaces: `link added after Phase 6`
85
+
86
+ ## License
87
+
88
+ Code released under the MIT License. LEDGAR data belongs to its original authors; the Llama-3.2 model is used under Meta's community license.
data/labels.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "Governing Laws",
3
+ "Notices",
4
+ "Counterparts",
5
+ "Entire Agreements",
6
+ "Severability",
7
+ "Survival",
8
+ "Amendments",
9
+ "Assignments",
10
+ "Expenses",
11
+ "Terms"
12
+ ]
data/prepare_data.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Compliance Copilot -- Phase 1: Data Preparation
3
+ ================================================
4
+
5
+ Goal of this file (one sentence):
6
+ Take a big public dataset of real contract clauses and turn it into small,
7
+ clean files we can feed to our model in Phase 2.
8
+
9
+ Why LEDGAR?
10
+ LEDGAR is a public dataset of contract provisions ("clauses"), where each
11
+ clause is labelled with its type (e.g. "Confidentiality", "Governing Laws",
12
+ "Terminations"). This is exactly the "what kind of clause is this / is it
13
+ compliant" problem a compliance team faces every day. It is public and
14
+ license-friendly, so we can safely showcase it (unlike private company data).
15
+
16
+ Where to run this:
17
+ In Google Colab (free) or on your own laptop. It needs internet the first
18
+ time so it can download the dataset from the Hugging Face Hub.
19
+
20
+ Install the one thing it needs:
21
+ pip install -U datasets
22
+
23
+ Output (all written into this ./data folder):
24
+ labels.json -> the list of clause types we keep
25
+ train.jsonl -> training examples (chat format, used to teach the model)
26
+ val.jsonl -> validation examples (chat format, used to watch progress)
27
+ test.jsonl -> held-out examples (raw text + label, used to score accuracy)
28
+ """
29
+
30
+ import json
31
+ import random
32
+ from collections import Counter
33
+ from pathlib import Path
34
+
35
+ from datasets import load_dataset
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Settings you are allowed to play with
39
+ # ---------------------------------------------------------------------------
40
+ TOP_K_LABELS = 10 # keep only the K most common clause types
41
+ MAX_PER_LABEL_TRAIN = 400 # cap training examples per label (keeps training fast + free)
42
+ MAX_PER_LABEL_EVAL = 60 # cap validation/test examples per label
43
+ SEED = 42
44
+ OUT_DIR = Path(__file__).resolve().parent # writes next to this script (the data/ folder)
45
+
46
+ # This is the "job description" we hand the model. IMPORTANT: the SAME text must
47
+ # be used again when we serve the model in Phase 3, otherwise it gets confused.
48
+ # That is why we keep it here in one place and reuse it everywhere.
49
+ SYSTEM_PROMPT = (
50
+ "You are a compliance assistant. You read a single contract clause and reply "
51
+ "with the one clause type it belongs to, and nothing else."
52
+ )
53
+
54
+
55
+ def build_user_prompt(clause_text: str, labels: list) -> str:
56
+ """Turn a clause + the menu of allowed answers into the question we ask the model."""
57
+ label_menu = ", ".join(labels)
58
+ return (
59
+ f"Classify the following contract clause into exactly one of these types: "
60
+ f"{label_menu}.\n\n"
61
+ f"Clause:\n{clause_text}\n\n"
62
+ f"Clause type:"
63
+ )
64
+ # ---------------------------------------------------------------------------
65
+
66
+ random.seed(SEED)
67
+
68
+
69
+ def main() -> None:
70
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
71
+
72
+ print("Step 1/5 Downloading LEDGAR from the Hugging Face Hub ...")
73
+ ds = load_dataset("coastalcph/lex_glue", "ledgar")
74
+
75
+ # The dataset stores labels as numbers (0, 1, 2, ...). This list turns a
76
+ # number back into a human name, e.g. 12 -> "Confidentiality".
77
+ label_names = ds["train"].features["label"].names
78
+ print(f" The full dataset has {len(label_names)} clause types.")
79
+
80
+ print("Step 2/5 Finding the most common clause types ...")
81
+ counts = Counter(ds["train"]["label"]) # how often each label appears
82
+ top_ids = [label_id for label_id, _ in counts.most_common(TOP_K_LABELS)]
83
+ kept_labels = [label_names[i] for i in top_ids] # the names we keep
84
+ id_to_name = {i: label_names[i] for i in top_ids}
85
+ print(f" Keeping the top {TOP_K_LABELS}: {kept_labels}")
86
+
87
+ # Save the label list so every other part of the project agrees on it.
88
+ (OUT_DIR / "labels.json").write_text(json.dumps(kept_labels, indent=2))
89
+
90
+ def collect(split_name: str, cap_per_label: int):
91
+ """Grab up to cap_per_label examples for each label so classes stay balanced."""
92
+ buckets = {i: [] for i in top_ids}
93
+ for row in ds[split_name]:
94
+ lid = row["label"]
95
+ if lid in buckets and len(buckets[lid]) < cap_per_label:
96
+ buckets[lid].append(row["text"])
97
+ pairs = [(text, id_to_name[lid]) for lid, texts in buckets.items() for text in texts]
98
+ random.shuffle(pairs)
99
+ return pairs
100
+
101
+ print("Step 3/5 Selecting + balancing examples ...")
102
+ train_pairs = collect("train", MAX_PER_LABEL_TRAIN)
103
+ val_pairs = collect("validation", MAX_PER_LABEL_EVAL)
104
+ test_pairs = collect("test", MAX_PER_LABEL_EVAL)
105
+ print(f" train={len(train_pairs)} val={len(val_pairs)} test={len(test_pairs)}")
106
+
107
+ print("Step 4/5 Writing training + validation files (chat format) ...")
108
+
109
+ def write_chat(path: Path, pairs):
110
+ with path.open("w") as f:
111
+ for text, label in pairs:
112
+ example = {
113
+ "messages": [
114
+ {"role": "system", "content": SYSTEM_PROMPT},
115
+ {"role": "user", "content": build_user_prompt(text, kept_labels)},
116
+ {"role": "assistant", "content": label},
117
+ ]
118
+ }
119
+ f.write(json.dumps(example) + "\n")
120
+
121
+ write_chat(OUT_DIR / "train.jsonl", train_pairs)
122
+ write_chat(OUT_DIR / "val.jsonl", val_pairs)
123
+
124
+ print("Step 5/5 Writing the test file (raw text + label, for scoring) ...")
125
+ with (OUT_DIR / "test.jsonl").open("w") as f:
126
+ for text, label in test_pairs:
127
+ f.write(json.dumps({"text": text, "label": label}) + "\n")
128
+
129
+ print("\nDone. Files written into:", OUT_DIR)
130
+ print("Next: open the Phase 2 fine-tuning notebook.")
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
docs/PROGRESS_LOG.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Progress Log
2
+
3
+ ## Phase 2 — Fine-tune the brain (QLoRA)
4
+
5
+ Ran `training/finetune_qlora.ipynb` end to end in Google Colab (T4 GPU).
6
+
7
+ **Result:** fine-tuning raised accuracy from 20% to 94% on the 600-clause held-out
8
+ test split.
9
+
10
+ | Metric | Baseline (zero-shot) | Fine-tuned | Improvement |
11
+ |--------|----------------------|------------|-------------|
12
+ | Accuracy | 20.00% | 94.33% | +74.33 pp |
13
+ | Macro-F1 | 0.1013 | 0.9439 | +84.26 pp |
14
+
15
+ Adapter pushed to: [`SaitejaDubbas/compliance-copilot-llama32-1b-lora`](https://huggingface.co/SaitejaDubbas/compliance-copilot-llama32-1b-lora)
16
+
17
+ ### Fixes needed live in Colab
18
+
19
+ The notebook as originally written didn't run clean on a fresh Colab session. Three
20
+ issues surfaced and were fixed in the saved notebook so future runs work end to end:
21
+
22
+ 1. **`SFTConfig` argument rename.** Current TRL removed `max_seq_length` in favor of
23
+ `max_length`. Fixed in the training-args cell.
24
+ 2. **bf16/fp16 mismatch on the T4.** Training crashed with
25
+ `NotImplementedError: _amp_foreach_non_finite_check_and_unscale_cuda not
26
+ implemented for 'BFloat16'` because the model computes in bfloat16 but the
27
+ trainer was configured for fp16 mixed precision. Fixed by setting `bf16=True,
28
+ fp16=False` in `SFTConfig`.
29
+ 3. **Push-to-hub 403.** The save/push cell relied on a hardcoded `HF_USERNAME`
30
+ placeholder, which didn't match the logged-in account and got rejected. Fixed by
31
+ deriving the username from `huggingface_hub.whoami()` immediately before pushing,
32
+ so the push always targets the account actually logged into the notebook.
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime dependencies for the FastAPI app + LangChain agent.
2
+ # (Training-only tools like bitsandbytes, trl, and datasets are installed
3
+ # inside the Colab notebook in Phase 2, so they are not needed here.)
4
+
5
+ fastapi
6
+ uvicorn[standard]
7
+ pydantic
8
+ transformers
9
+ torch
10
+ peft
11
+ accelerate
12
+ langchain
13
+ langchain-community
14
+ gradio
15
+ huggingface-hub
training/finetune_qlora.ipynb ADDED
@@ -0,0 +1,515 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Compliance Copilot \u2014 Phase 2: Fine-tune the Brain\n",
8
+ "\n",
9
+ "This notebook is **self-contained**: it does not read any files from the rest of\n",
10
+ "the repo. You can upload just this one file to a fresh Google Colab, run it top\n",
11
+ "to bottom, and it will rebuild the training data itself, fine-tune the model,\n",
12
+ "and push the result to the Hugging Face Hub.\n",
13
+ "\n",
14
+ "**Before running:** In Colab, go to `Runtime > Change runtime type` and select a\n",
15
+ "**T4 GPU** (free tier). Everything below assumes a GPU is available.\n",
16
+ "\n",
17
+ "**What this notebook proves (for the resume line):** we measure accuracy on the\n",
18
+ "same 600 held-out contract clauses *before* fine-tuning (zero-shot) and *after*\n",
19
+ "fine-tuning (QLoRA), so the \"+X% accuracy\" number is real, not invented."
20
+ ]
21
+ },
22
+ {
23
+ "cell_type": "markdown",
24
+ "metadata": {},
25
+ "source": [
26
+ "## Step 1 \u2014 Install the libraries\n",
27
+ "\n",
28
+ "Think of this as gathering tools before starting a job: `transformers` loads the\n",
29
+ "model, `peft` + `bitsandbytes` let us load and train it in 4-bit (so it fits on a\n",
30
+ "free GPU), `trl` gives us a ready-made trainer for chat-style fine-tuning, and\n",
31
+ "`datasets` / `scikit-learn` handle the data and the accuracy scoring."
32
+ ]
33
+ },
34
+ {
35
+ "cell_type": "code",
36
+ "execution_count": null,
37
+ "metadata": {},
38
+ "outputs": [],
39
+ "source": [
40
+ "!pip install -q -U transformers peft trl bitsandbytes accelerate datasets scikit-learn\n"
41
+ ]
42
+ },
43
+ {
44
+ "cell_type": "code",
45
+ "execution_count": null,
46
+ "metadata": {},
47
+ "outputs": [],
48
+ "source": [
49
+ "import torch\n",
50
+ "\n",
51
+ "print(\"CUDA available:\", torch.cuda.is_available())\n",
52
+ "if torch.cuda.is_available():\n",
53
+ " print(\"GPU:\", torch.cuda.get_device_name(0))\n",
54
+ "else:\n",
55
+ " print(\"No GPU detected. Go to Runtime > Change runtime type > select a GPU (T4), then re-run.\")\n"
56
+ ]
57
+ },
58
+ {
59
+ "cell_type": "markdown",
60
+ "metadata": {},
61
+ "source": [
62
+ "## Step 2 \u2014 Log in to Hugging Face\n",
63
+ "\n",
64
+ "We need a Hugging Face account token for two reasons: to download the base\n",
65
+ "model, and \u2014 at the very end \u2014 to upload (\"push\") our trained adapter back to\n",
66
+ "the Hub so Phase 3 can load it. Running the cell below shows a login widget;\n",
67
+ "paste a token with **write** access (create one at\n",
68
+ "huggingface.co/settings/tokens)."
69
+ ]
70
+ },
71
+ {
72
+ "cell_type": "code",
73
+ "execution_count": null,
74
+ "metadata": {},
75
+ "outputs": [],
76
+ "source": [
77
+ "from huggingface_hub import notebook_login\n",
78
+ "\n",
79
+ "notebook_login()\n"
80
+ ]
81
+ },
82
+ {
83
+ "cell_type": "markdown",
84
+ "metadata": {},
85
+ "source": [
86
+ "## Step 3 \u2014 Configuration\n",
87
+ "\n",
88
+ "Everything you might want to change lives in one place. `BASE_MODEL` is the\n",
89
+ "small 1-billion-parameter model we fine-tune \u2014 it's an open, ungated mirror of\n",
90
+ "Llama-3.2-1B-Instruct, so it downloads without needing Meta's access-request\n",
91
+ "form. Set `HF_USERNAME` to your Hugging Face username; the trained adapter gets\n",
92
+ "uploaded to `<HF_USERNAME>/compliance-copilot-llama32-1b-lora`."
93
+ ]
94
+ },
95
+ {
96
+ "cell_type": "code",
97
+ "execution_count": null,
98
+ "metadata": {},
99
+ "outputs": [],
100
+ "source": [
101
+ "import random\n",
102
+ "from collections import Counter\n",
103
+ "\n",
104
+ "import numpy as np\n",
105
+ "from datasets import load_dataset, Dataset\n",
106
+ "from transformers import (\n",
107
+ " AutoModelForCausalLM,\n",
108
+ " AutoTokenizer,\n",
109
+ " BitsAndBytesConfig,\n",
110
+ ")\n",
111
+ "from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training\n",
112
+ "from trl import SFTConfig, SFTTrainer\n",
113
+ "from sklearn.metrics import accuracy_score, f1_score\n",
114
+ "from tqdm.auto import tqdm\n",
115
+ "\n",
116
+ "# --- Things you are likely to change ---\n",
117
+ "BASE_MODEL = \"unsloth/Llama-3.2-1B-Instruct\" # ungated open copy of Llama-3.2-1B-Instruct\n",
118
+ "HF_USERNAME = \"SaitejaDubbas\" # <-- your Hugging Face username\n",
119
+ "ADAPTER_REPO = f\"{HF_USERNAME}/compliance-copilot-llama32-1b-lora\"\n",
120
+ "\n",
121
+ "# --- Data settings copied from data/prepare_data.py (must match Phase 1 exactly) ---\n",
122
+ "DATASET_ID = \"coastalcph/lex_glue\"\n",
123
+ "DATASET_CONFIG = \"ledgar\"\n",
124
+ "TOP_K_LABELS = 10\n",
125
+ "MAX_PER_LABEL_TRAIN = 400\n",
126
+ "MAX_PER_LABEL_EVAL = 60\n",
127
+ "SEED = 42\n",
128
+ "\n",
129
+ "random.seed(SEED)\n"
130
+ ]
131
+ },
132
+ {
133
+ "cell_type": "markdown",
134
+ "metadata": {},
135
+ "source": [
136
+ "## Step 3b \u2014 Rebuild the dataset (same recipe as `data/prepare_data.py`)\n",
137
+ "\n",
138
+ "This notebook can't read files from the repo, so we rebuild the exact same\n",
139
+ "train/val/test split here, using the identical logic, seed, and prompt wording\n",
140
+ "as `data/prepare_data.py`. That match matters: if the wording here drifted from\n",
141
+ "what Phase 3 uses to serve the model, the fine-tuned model would see unfamiliar\n",
142
+ "phrasing at inference time and get confused \u2014 like practicing for an interview\n",
143
+ "with one set of questions and then being asked completely different ones on the\n",
144
+ "day.\n",
145
+ "\n",
146
+ "We download LEDGAR, keep the 10 most common clause types, and balance each\n",
147
+ "split so no single clause type dominates training."
148
+ ]
149
+ },
150
+ {
151
+ "cell_type": "code",
152
+ "execution_count": null,
153
+ "metadata": {},
154
+ "outputs": [],
155
+ "source": [
156
+ "# Copied verbatim from data/prepare_data.py so training and serving never\n",
157
+ "# disagree on the wording the model was taught with.\n",
158
+ "SYSTEM_PROMPT = (\n",
159
+ " \"You are a compliance assistant. You read a single contract clause and reply \"\n",
160
+ " \"with the one clause type it belongs to, and nothing else.\"\n",
161
+ ")\n",
162
+ "\n",
163
+ "\n",
164
+ "def build_user_prompt(clause_text: str, labels: list) -> str:\n",
165
+ " label_menu = \", \".join(labels)\n",
166
+ " return (\n",
167
+ " f\"Classify the following contract clause into exactly one of these types: \"\n",
168
+ " f\"{label_menu}.\\n\\n\"\n",
169
+ " f\"Clause:\\n{clause_text}\\n\\n\"\n",
170
+ " f\"Clause type:\"\n",
171
+ " )\n",
172
+ "\n",
173
+ "\n",
174
+ "print(\"Downloading LEDGAR ...\")\n",
175
+ "ds = load_dataset(DATASET_ID, DATASET_CONFIG)\n",
176
+ "\n",
177
+ "label_names = ds[\"train\"].features[\"label\"].names\n",
178
+ "counts = Counter(ds[\"train\"][\"label\"])\n",
179
+ "top_ids = [label_id for label_id, _ in counts.most_common(TOP_K_LABELS)]\n",
180
+ "kept_labels = [label_names[i] for i in top_ids]\n",
181
+ "id_to_name = {i: label_names[i] for i in top_ids}\n",
182
+ "print(f\"Keeping the top {TOP_K_LABELS} labels: {kept_labels}\")\n",
183
+ "\n",
184
+ "\n",
185
+ "def collect(split_name: str, cap_per_label: int):\n",
186
+ " buckets = {i: [] for i in top_ids}\n",
187
+ " for row in ds[split_name]:\n",
188
+ " lid = row[\"label\"]\n",
189
+ " if lid in buckets and len(buckets[lid]) < cap_per_label:\n",
190
+ " buckets[lid].append(row[\"text\"])\n",
191
+ " pairs = [(text, id_to_name[lid]) for lid, texts in buckets.items() for text in texts]\n",
192
+ " random.shuffle(pairs)\n",
193
+ " return pairs\n",
194
+ "\n",
195
+ "\n",
196
+ "train_pairs = collect(\"train\", MAX_PER_LABEL_TRAIN)\n",
197
+ "val_pairs = collect(\"validation\", MAX_PER_LABEL_EVAL)\n",
198
+ "test_pairs = collect(\"test\", MAX_PER_LABEL_EVAL)\n",
199
+ "print(f\"train={len(train_pairs)} val={len(val_pairs)} test={len(test_pairs)}\")\n"
200
+ ]
201
+ },
202
+ {
203
+ "cell_type": "markdown",
204
+ "metadata": {},
205
+ "source": [
206
+ "## Step 4 \u2014 Baseline (zero-shot) evaluation\n",
207
+ "\n",
208
+ "Before we teach the model anything, let's see how well it does \"cold\" \u2014 just\n",
209
+ "handed the clause and the list of possible types, with no training on our\n",
210
+ "data. This is the \"before\" photo. We load the base model in 4-bit precision (a\n",
211
+ "compressed format that trades a little precision for using ~4x less GPU\n",
212
+ "memory, which is what makes this fit on a free T4), then ask it to classify\n",
213
+ "all 600 held-out test clauses."
214
+ ]
215
+ },
216
+ {
217
+ "cell_type": "code",
218
+ "execution_count": null,
219
+ "metadata": {},
220
+ "outputs": [],
221
+ "source": [
222
+ "tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL)\n",
223
+ "if tokenizer.pad_token is None:\n",
224
+ " tokenizer.pad_token = tokenizer.eos_token\n",
225
+ "tokenizer.padding_side = \"left\" # required for correct batched generation\n",
226
+ "\n",
227
+ "bnb_config = BitsAndBytesConfig(\n",
228
+ " load_in_4bit=True,\n",
229
+ " bnb_4bit_quant_type=\"nf4\",\n",
230
+ " bnb_4bit_compute_dtype=torch.float16,\n",
231
+ " bnb_4bit_use_double_quant=True,\n",
232
+ ")\n",
233
+ "\n",
234
+ "model = AutoModelForCausalLM.from_pretrained(\n",
235
+ " BASE_MODEL,\n",
236
+ " quantization_config=bnb_config,\n",
237
+ " device_map=\"auto\",\n",
238
+ ")\n",
239
+ "model.config.pad_token_id = tokenizer.pad_token_id\n"
240
+ ]
241
+ },
242
+ {
243
+ "cell_type": "code",
244
+ "execution_count": null,
245
+ "metadata": {},
246
+ "outputs": [],
247
+ "source": [
248
+ "import difflib\n",
249
+ "\n",
250
+ "\n",
251
+ "def parse_prediction(raw_text: str, labels: list) -> str:\n",
252
+ " \"\"\"Turn whatever text the model generated into the closest known label.\"\"\"\n",
253
+ " cleaned = raw_text.strip()\n",
254
+ " for label in labels:\n",
255
+ " if cleaned.lower() == label.lower():\n",
256
+ " return label\n",
257
+ " for label in labels:\n",
258
+ " if label.lower() in cleaned.lower():\n",
259
+ " return label\n",
260
+ " close = difflib.get_close_matches(cleaned, labels, n=1, cutoff=0.0)\n",
261
+ " return close[0] if close else labels[0]\n",
262
+ "\n",
263
+ "\n",
264
+ "@torch.no_grad()\n",
265
+ "def evaluate(model, tokenizer, pairs, labels, batch_size=16, max_new_tokens=8):\n",
266
+ " model.eval()\n",
267
+ " preds, golds = [], []\n",
268
+ " for i in tqdm(range(0, len(pairs), batch_size)):\n",
269
+ " batch = pairs[i : i + batch_size]\n",
270
+ " prompts = [\n",
271
+ " tokenizer.apply_chat_template(\n",
272
+ " [\n",
273
+ " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
274
+ " {\"role\": \"user\", \"content\": build_user_prompt(text, labels)},\n",
275
+ " ],\n",
276
+ " tokenize=False,\n",
277
+ " add_generation_prompt=True,\n",
278
+ " )\n",
279
+ " for text, _ in batch\n",
280
+ " ]\n",
281
+ " inputs = tokenizer(\n",
282
+ " prompts, return_tensors=\"pt\", padding=True, truncation=True, max_length=1024\n",
283
+ " ).to(model.device)\n",
284
+ " out = model.generate(\n",
285
+ " **inputs,\n",
286
+ " max_new_tokens=max_new_tokens,\n",
287
+ " do_sample=False,\n",
288
+ " pad_token_id=tokenizer.pad_token_id,\n",
289
+ " )\n",
290
+ " new_tokens = out[:, inputs[\"input_ids\"].shape[1] :]\n",
291
+ " decoded = tokenizer.batch_decode(new_tokens, skip_special_tokens=True)\n",
292
+ " for (text, gold), raw in zip(batch, decoded):\n",
293
+ " preds.append(parse_prediction(raw, labels))\n",
294
+ " golds.append(gold)\n",
295
+ " acc = accuracy_score(golds, preds)\n",
296
+ " f1 = f1_score(golds, preds, labels=labels, average=\"macro\", zero_division=0)\n",
297
+ " return acc, f1, preds, golds\n"
298
+ ]
299
+ },
300
+ {
301
+ "cell_type": "code",
302
+ "execution_count": null,
303
+ "metadata": {},
304
+ "outputs": [],
305
+ "source": [
306
+ "print(\"Running BASELINE (zero-shot) evaluation on the 600 held-out test clauses ...\")\n",
307
+ "baseline_acc, baseline_f1, _, _ = evaluate(model, tokenizer, test_pairs, kept_labels)\n",
308
+ "print(f\"\\nBaseline accuracy: {baseline_acc:.4f} macro-F1: {baseline_f1:.4f}\")\n"
309
+ ]
310
+ },
311
+ {
312
+ "cell_type": "markdown",
313
+ "metadata": {},
314
+ "source": [
315
+ "## Step 5 \u2014 Fine-tune with QLoRA\n",
316
+ "\n",
317
+ "Instead of retraining all 1 billion parameters (slow, memory-hungry, and\n",
318
+ "unnecessary), QLoRA freezes the base model and trains small \"adapter\"\n",
319
+ "matrices bolted onto the attention layers \u2014 like adding sticky notes to a\n",
320
+ "textbook instead of rewriting it. `r=16` / `alpha=32` control how much\n",
321
+ "capacity those sticky notes have. We train for 2 passes (epochs) over the\n",
322
+ "4,000 training examples."
323
+ ]
324
+ },
325
+ {
326
+ "cell_type": "code",
327
+ "execution_count": null,
328
+ "metadata": {},
329
+ "outputs": [],
330
+ "source": [
331
+ "model = prepare_model_for_kbit_training(model)\n",
332
+ "\n",
333
+ "lora_config = LoraConfig(\n",
334
+ " r=16,\n",
335
+ " lora_alpha=32,\n",
336
+ " lora_dropout=0.05,\n",
337
+ " target_modules=[\"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\"],\n",
338
+ " task_type=\"CAUSAL_LM\",\n",
339
+ " bias=\"none\",\n",
340
+ ")\n",
341
+ "\n",
342
+ "model = get_peft_model(model, lora_config)\n",
343
+ "model.print_trainable_parameters()\n"
344
+ ]
345
+ },
346
+ {
347
+ "cell_type": "code",
348
+ "execution_count": null,
349
+ "metadata": {},
350
+ "outputs": [],
351
+ "source": [
352
+ "def to_chat_examples(pairs):\n",
353
+ " return [\n",
354
+ " {\n",
355
+ " \"messages\": [\n",
356
+ " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
357
+ " {\"role\": \"user\", \"content\": build_user_prompt(text, kept_labels)},\n",
358
+ " {\"role\": \"assistant\", \"content\": label},\n",
359
+ " ]\n",
360
+ " }\n",
361
+ " for text, label in pairs\n",
362
+ " ]\n",
363
+ "\n",
364
+ "\n",
365
+ "def add_text_field(example):\n",
366
+ " return {\"text\": tokenizer.apply_chat_template(example[\"messages\"], tokenize=False)}\n",
367
+ "\n",
368
+ "\n",
369
+ "train_ds = Dataset.from_list(to_chat_examples(train_pairs)).map(add_text_field)\n",
370
+ "val_ds = Dataset.from_list(to_chat_examples(val_pairs)).map(add_text_field)\n"
371
+ ]
372
+ },
373
+ {
374
+ "cell_type": "code",
375
+ "execution_count": null,
376
+ "metadata": {},
377
+ "outputs": [],
378
+ "source": [
379
+ "tokenizer.padding_side = \"right\" # standard for training\n",
380
+ "\n",
381
+ "sft_config = SFTConfig(\n",
382
+ " output_dir=\"qlora-out\",\n",
383
+ " num_train_epochs=2,\n",
384
+ " per_device_train_batch_size=4,\n",
385
+ " gradient_accumulation_steps=4,\n",
386
+ " learning_rate=2e-4,\n",
387
+ " bf16=True,\n",
388
+ " fp16=False,\n",
389
+ " logging_steps=20,\n",
390
+ " eval_strategy=\"epoch\",\n",
391
+ " save_strategy=\"no\",\n",
392
+ " report_to=\"none\",\n",
393
+ " dataset_text_field=\"text\",\n",
394
+ " max_length=1024,\n",
395
+ " packing=False,\n",
396
+ ")\n",
397
+ "\n",
398
+ "trainer = SFTTrainer(\n",
399
+ " model=model,\n",
400
+ " train_dataset=train_ds,\n",
401
+ " eval_dataset=val_ds,\n",
402
+ " args=sft_config,\n",
403
+ ")\n",
404
+ "\n",
405
+ "trainer.train()\n"
406
+ ]
407
+ },
408
+ {
409
+ "cell_type": "markdown",
410
+ "metadata": {},
411
+ "source": [
412
+ "## Step 6 \u2014 Fine-tuned evaluation\n",
413
+ "\n",
414
+ "Now we run the *exact same* evaluation function, on the *exact same* 600 test\n",
415
+ "clauses, but with the trained adapter active. This is the \"after\" photo \u2014\n",
416
+ "directly comparable to Step 4 because nothing else changed."
417
+ ]
418
+ },
419
+ {
420
+ "cell_type": "code",
421
+ "execution_count": null,
422
+ "metadata": {},
423
+ "outputs": [],
424
+ "source": [
425
+ "tokenizer.padding_side = \"left\" # back to left-padding for generation\n",
426
+ "print(\"Running FINE-TUNED evaluation on the same 600 held-out test clauses ...\")\n",
427
+ "finetuned_acc, finetuned_f1, _, _ = evaluate(model, tokenizer, test_pairs, kept_labels)\n",
428
+ "print(f\"\\nFine-tuned accuracy: {finetuned_acc:.4f} macro-F1: {finetuned_f1:.4f}\")\n"
429
+ ]
430
+ },
431
+ {
432
+ "cell_type": "markdown",
433
+ "metadata": {},
434
+ "source": [
435
+ "## Step 7 \u2014 Results: baseline vs fine-tuned\n",
436
+ "\n",
437
+ "This is the number that goes on the resume. It's measured, not invented \u2014\n",
438
+ "both rows below ran on the identical held-out test set."
439
+ ]
440
+ },
441
+ {
442
+ "cell_type": "code",
443
+ "execution_count": null,
444
+ "metadata": {},
445
+ "outputs": [],
446
+ "source": [
447
+ "print(\"=\" * 50)\n",
448
+ "print(f\"{'Metric':<12}{'Baseline':>12}{'Fine-tuned':>14}{'Improvement':>14}\")\n",
449
+ "print(\"-\" * 50)\n",
450
+ "print(\n",
451
+ " f\"{'Accuracy':<12}{baseline_acc:>12.4f}{finetuned_acc:>14.4f}\"\n",
452
+ " f\"{(finetuned_acc - baseline_acc) * 100:>13.2f}pp\"\n",
453
+ ")\n",
454
+ "print(\n",
455
+ " f\"{'Macro-F1':<12}{baseline_f1:>12.4f}{finetuned_f1:>14.4f}\"\n",
456
+ " f\"{(finetuned_f1 - baseline_f1) * 100:>13.2f}pp\"\n",
457
+ ")\n",
458
+ "print(\"=\" * 50)\n"
459
+ ]
460
+ },
461
+ {
462
+ "cell_type": "markdown",
463
+ "metadata": {},
464
+ "source": [
465
+ "## Step 8 \u2014 Save and publish the adapter\n",
466
+ "\n",
467
+ "The LoRA adapter is tiny (a few MB, since we only trained the sticky notes,\n",
468
+ "not the whole textbook), so we save it locally and push it to your Hugging\n",
469
+ "Face Hub account. Phase 3's FastAPI service will load the base model plus\n",
470
+ "this adapter by its Hub repo id \u2014 `<HF_USERNAME>/compliance-copilot-llama32-1b-lora`.\n",
471
+ "\n",
472
+ "The cell below re-derives `HF_USERNAME` from your logged-in token via `whoami()` right before pushing, so a stale placeholder value in Step 3 can never cause a push to the wrong (nonexistent) namespace."
473
+ ]
474
+ },
475
+ {
476
+ "cell_type": "code",
477
+ "execution_count": null,
478
+ "metadata": {},
479
+ "outputs": [],
480
+ "source": [
481
+ "from huggingface_hub import whoami\n",
482
+ "\n",
483
+ "HF_USERNAME = whoami()[\"name\"]\n",
484
+ "ADAPTER_REPO = f\"{HF_USERNAME}/compliance-copilot-llama32-1b-lora\"\n",
485
+ "\n",
486
+ "LOCAL_ADAPTER_DIR = \"compliance-copilot-lora-adapter\"\n",
487
+ "\n",
488
+ "model.save_pretrained(LOCAL_ADAPTER_DIR)\n",
489
+ "tokenizer.save_pretrained(LOCAL_ADAPTER_DIR)\n",
490
+ "\n",
491
+ "print(f\"Pushing adapter + tokenizer to https://huggingface.co/{ADAPTER_REPO} ...\")\n",
492
+ "model.push_to_hub(ADAPTER_REPO)\n",
493
+ "tokenizer.push_to_hub(ADAPTER_REPO)\n",
494
+ "print(\"Done. Use this repo id in Phase 3 (app/model.py) to load the adapter.\")\n"
495
+ ]
496
+ }
497
+ ],
498
+ "metadata": {
499
+ "accelerator": "GPU",
500
+ "colab": {
501
+ "provenance": [],
502
+ "gpuType": "T4"
503
+ },
504
+ "kernelspec": {
505
+ "display_name": "Python 3",
506
+ "language": "python",
507
+ "name": "python3"
508
+ },
509
+ "language_info": {
510
+ "name": "python"
511
+ }
512
+ },
513
+ "nbformat": 4,
514
+ "nbformat_minor": 5
515
+ }