Build Journal · LegalBench Agent · Phase 1 + 2

Fine-tuning
a 7 Billion
Parameter
Legal AI

7.6BParameters
0.13%Actually Trained
$2Total GPU Cost
49mTrain Time

This is the real story. Every error, every "what does that mean?", every GPU crash. We started knowing LangChain but nothing about training. Here's what we built and what we learned.

modal run train.py Creating app "legalbench-training"... Spinning up A10G GPU... Model loaded: 7,615,616,512 params trainable params: 10,092,544 (0.13%) 100%|████████| 468/468 [48:47] Training complete! Adapter saved. ✓
Chapter 01 · The Dataset

What is LegalBench
and why does it exist?

Before a single line of training code, we needed to understand the data. This is always step one — in RAG pipelines, in fine-tuning, in anything. You look at the raw material first.

Real conversation · Getting started
M
How to set up. Assume I don't know much about ML, just know basics.
C
Perfect. I'll drive.

Step 1 — understand what LegalBench data looks like. Think of it exactly like the documents you'd feed into a RAG pipeline — except instead of retrieving answers, we're using them to teach the model.

LegalBench is a Stanford dataset of 162 legal reasoning tasks. We only needed 4. The first step was just opening it up and looking at what's inside — like exploring a new database before writing any queries.

exploration.py · Google Colab
from datasets import load_dataset

# Wrong path first (nguyen-brat/legalbench) → DatasetNotFoundError
# Correct path is the Stanford author's account:
ds = load_dataset("nguha/legalbench", "contract_nli_confidentiality_of_agreement")

print(ds['test'][0])
# Output:
# {'index': 0, 'answer': 'Yes',
#  'text': "Confidential Information: means all confidential
#           information disclosed by a Party...",
#  'document_name': 'Aspiegel_NDA_template.pdf'}
💡 What we found

All 4 tasks are essentially the same structure: Read this legal text → Answer Yes or No.

Task 1 — Contract NLI: "Is this a confidentiality clause?"
Task 2 — Contract QA: "Does this clause discuss X?"
Task 3 — CUAD Governing Law: "Does this specify which state's laws apply?"
Task 4 — Corporate Lobbying: "Would this company lobby for this bill?"

The data had text and answer: "Yes". Clean. Simple. But that format alone can't be used for training — we needed to convert it into something called DPO format. More on that next.

Chapter 02 · The Format

What is DPO and why
do we need it?

Raw data just says "the answer is Yes." But to actually teach a model, you need to show it two answers and say: this one is good, that one is bad. Learn the difference.

Real conversation · Understanding DPO
M
I don't understand any of the jargons here. Blindly pasting the code and looking at the output is not useful for a beginner.
C
You're right. Let me explain what just happened before we write a single line more.

In RAG, you give the model a document and it retrieves the best answer. In DPO, you give the model two answers and teach it which one is better. Like training a retriever to rank good chunks higher than bad ones — except here you're training the model's actual reasoning.

That's it. The trainer does the rest.

DPO stands for Direct Preference Optimisation. Every training sample needs exactly 3 things:

Interactive · Click "Train Step" to see what the model learns
📝 Prompt — given to model
You are a legal expert reviewing contracts.
Clause: "Confidential Information means all information disclosed by a Party that is marked as confidential..."
Does this clause establish a confidentiality obligation? Answer Yes or No.
↓ model sees both options ↓
✅ CHOSEN — teach model THIS
"Yes, this is a confidentiality clause because it explicitly defines confidential information and restricts its disclosure between parties."
❌ REJECTED — avoid THIS
"No, this is not a confidentiality clause."
What just happened inside the model:
The model computed the probability of generating chosen vs rejected. It then adjusted its weights to make the chosen answer more likely and the rejected answer less likely. This is DPO — no complex reward model needed, just preference pairs.

In our case, since all tasks were Yes/No, we made it simple: chosen = correct answer, rejected = opposite answer. The conversion function handled all 4 task types differently based on their structure.

prepare_data.py · The key conversion
def convert_to_dpo_format(sample, task_name):
    # contract_qa has a 'question' field, others don't
    if task_name == "contract_qa":
        prompt = f"""You are a legal expert reviewing contracts.

Clause: {sample['text']}

Question: {sample['question']}
Answer only Yes or No."""
    else:
        prompt = f"""You are a legal expert reviewing contracts.

Clause: {sample['text']}

Does this clause satisfy the legal condition?
Answer only Yes or No."""

    correct = sample["answer"].strip()
    wrong = "No" if correct == "Yes" else "Yes"

    return {
        "prompt": prompt,
        "chosen": correct,    # ✅ correct answer
        "rejected": wrong,    # ❌ wrong answer
        "task": task_name,
    }

After running this across all 4 tasks: 1560 samples total, shuffled and split 80/10/10 into train/val/test. Phase 1 done.

Raw LegalBench4 tasks, HuggingFace
Explore Structuresee what fields exist
Convert to DPOprompt/chosen/rejected
Split 80/10/101248 / 156 / 156
Save to JSONtrain, val, test
Chapter 03 · ML Concept #1

What is a Tokenizer?
Models don't speak English.

This came up right before we loaded the model. You know how the OpenAI API charges you by "tokens" not characters? Here's what a token actually is.

Real conversation · Understanding Tokenizer
M
ok meanwhile can u go little deep on what is a tokeniser more pls??
C
A token is a chunk of text. Not always a full word — sometimes half a word, sometimes a few characters.

The model is literally a math function. It can only take numbers as input. It cannot read English.

So the pipeline is always:
Your text → Tokenizer (text→numbers) → Model (numbers→numbers) → Tokenizer (numbers→text) → Output text

Type any text below to see exactly how it gets tokenised — how many tokens it becomes and what numbers they map to:

Interactive Tokenizer — Try it yourself
💡 EOS and PAD tokens — two special ones

EOS (End of Sentence): The model keeps generating tokens until it produces EOS. Like a full stop — but for the model. Without it, the model would generate forever.

PAD token: When training, all samples in a batch must be the same length. Shorter ones get padded with PAD tokens to match the longest one. We set tokenizer.pad_token = tokenizer.eos_token because Qwen doesn't have a dedicated PAD token — so we reuse EOS. The model already knows to ignore EOS in the middle of sequences.

Chapter 04 · ML Concept #2

What is LoRA?
Training 0.13% of a model.

When Madhav asked about r and lora_alpha, this is what we covered. It's the most important concept in this whole project.

Real conversation · The r and alpha question
M
uhm i dont understand r and lora alpha. need more explanations man before moving ahead
C
Got it. Let me explain properly.

When a model learns something, it stores that knowledge in matrices — grids of numbers. The Qwen model has hundreds of these. Normally to fine-tune, you'd update all of them. Too expensive.

LoRA's trick: Instead of updating the big matrix directly, add two tiny matrices next to it. The two small matrices together approximate the change. Much cheaper, almost same result.
Interactive LoRA — Drag r and alpha to see what changes
🧊 Base Model
7,615,616,512 params
FROZEN — never touched
+
⚡ LoRA Adapter
r=16 → ~10M params
TRAINABLE — learns legal
=
🎯 Legal Expert
0.13% trained
Specialised model
16
32
With r=16, alpha=32: scaling factor = 32/16 = 2.0×. Adapter has ~10M params = 0.13% of model. This is the sweet spot — enough to learn legal patterns without blowing up the GPU.
💡 The chef analogy for r and alpha

Imagine you're a chef (base model) who knows all cuisines. You're learning to specialise in Indian food (fine-tuning).

r = how many new recipes you're memorising (adapter size). Too small = barely learnt anything. Too large = used too much brain space.

alpha = how much you apply that new knowledge when cooking (scaling). Too low alpha = you barely use what you learnt. Too high = you forget everything else and only cook Indian food.

r=16, alpha=32 (scaling=2.0) = balanced. Learnt enough, applied moderately.

training.py · LoRA Config
lora_config = LoraConfig(
    r=16,               # adapter size — 16 is sweet spot for T4/A10G
    lora_alpha=32,      # always set to 2× your r value
    lora_dropout=0.05,  # small regularisation
    target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
    # these are the attention layers — where reasoning happens
    task_type=TaskType.CAUSAL_LM,
    bias="none",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 10,092,544 || all params: 7,625,709,056 || trainable%: 0.1323
# ↑ only 0.13% of the model actually trains. Rest is frozen.
Chapter 05 · ML Concept #3

Training config —
every parameter explained.

These are all the config values that confused us. Every single one explained the same way — with an example, not a definition.

Real conversation · The config bouncer
M
these are all jargons. explain like u did for r and lora alpha please
C
Got it. Deep but simple. Let me break each one down properly.
Interactive · Batch Size & Gradient Accumulation

The model doesn't learn from one sample at a time — it learns from a group called a batch. Click the buttons below to see how batch size and gradient accumulation work together:

+ gradient accumulation steps:
batch_size=2, accum=8: Process 2 samples at a time (fits in GPU). Wait 8 batches before updating weights. Effective batch size = 2×8 = 16. The model gets more context before deciding how to adjust — more stable training.
💡 All 5 config params simply

epochs=3 — Like re-reading your notes before an exam. First pass: gets the gist. Second: connects patterns. Third: solidifies. 3 is safe.

batch_size=1 or 2 — How many papers a teacher grades at once before adjusting their rubric. Larger batch = more stable, but needs more GPU memory.

gradient_accumulation=8 — Read 8 chapters before highlighting anything. More context before making a decision. Workaround for small batch size.

beta=0.1 — How aggressively the model gets pushed away from rejected answers. Low = gentle preference. Keeps it flexible for nuanced legal reasoning.

fp16/bf16 — Half-precision math. Like storing pi as 3.14 instead of 3.14159265. Halves GPU memory usage with negligible quality loss.

Chapter 06 · The Crashes

Kaggle died.
Twice.

This is the part nobody writes about in tutorials. Things broke. Here's exactly what happened and how we fixed each one.

Error #1 — BFloat16 CUDA crash
NotImplementedError: "_amp_foreach_non_finite_check_and_unscale_cuda" not implemented for 'BFloat16'
Fix: fp16 and bfloat16 dtype conflict on T4 GPU. Change fp16=Truefp16=False, bf16=False. T4 doesn't support bf16 natively. Use A10G for bf16.
Error #2 — CUDA unspecified launch failure
AcceleratorError: CUDA error: unspecified launch failure Search for 'cudaErrorLaunchFailure'...
Fix: T4 ran out of memory during evaluation step. Three changes: batch_size 2→1, eval_strategy="no", max_length=512. But even then, Kaggle T4 was too flaky for a 7B model. Decision: move to Modal A10G.

After 2 crashes, we compared GPU providers properly:

CRASHED
Kaggle
2× Tesla T4 (16GB)
Cost: Free
Ease: Very Easy
Problem: Flaky for 7B models
OK
Colab Pro
A100 (40GB)
Cost: $10/mo
Ease: Easy
Problem: Subscription
USED THIS ✓
Modal
A10G (24GB)
Cost: ~$2 total
Ease: Very Easy
Free credits: $30 on signup
CHEAP
Vast.ai
Various
Cost: ~$0.20/hr
Ease: Hard
Problem: Server management
💡 Why Modal won

No server to manage. You just run a Python script from your laptop and it runs on a cloud GPU. Pay only for actual compute time used. Training the whole model cost under $2 — and they give you $30 free credits on signup, so it was effectively free for this project.

Chapter 07 · The Results

Training complete.
What the numbers mean.

After 49 minutes on Modal's A10G, training finished. Here's the actual output — and what each number actually tells us.

Simulated Training Run
Step 0/468
Loss: —
Epoch: —
Training complete! (real output)
'train_runtime': '2927s (~49 minutes)',
'train_loss': '0.6931', ← see note below
'rewards/accuracies': '0', ← known issue, see note
'rewards/margins': '0',
'epoch': '3'

Adapter saved to /data/data/legalbench-lora ✓
Known Issue — rewards/accuracies: 0
This means the model didn't learn to prefer chosen over rejected. The root cause: our chosen and rejected answers are just "Yes" and "No" — single word answers. Too short for DPO to find meaningful differences in reasoning patterns.

The Fix (Phase 2.1): Regenerate chosen/rejected with richer explanations. Instead of "Yes" → "Yes, this is a confidentiality clause because it explicitly defines what constitutes confidential information and who can access it." Use Claude API to auto-generate for all 1248 samples. Cost: ~$1.

Despite the rewards issue, the infrastructure is complete. Data pipeline works, model loads, LoRA attaches, training runs on Modal. The next step is fixing the data quality — then re-running training and comparing against GPT-4o mini and Claude Haiku.

✅ Done
Phase 1 — Data
LegalBench loaded, DPO format, 1560 samples, split saved.
✅ Done (needs fix)
Phase 2 — Training
Model trained on Modal A10G. LoRA adapter saved. Rewards issue identified, fix planned.
🔜 Phase 2.1
Richer DPO Data
Use Claude API to generate explanatory chosen/rejected instead of single-word Yes/No.
🔜 Phase 3
Evaluation
Benchmark fine-tuned model vs base Qwen vs GPT-4o mini vs Claude Haiku on 156 test samples.