Adapter trained on 45,000 CaseHOLD questions, 85.92% on test
Browse files- README.md +115 -0
- adapter.safetensors +3 -0
- adapter_config.json +13 -0
- data.py +39 -0
- lora.py +70 -0
README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
base_model: Qwen/Qwen2.5-0.5B
|
| 4 |
+
datasets:
|
| 5 |
+
- coastalcph/lex_glue
|
| 6 |
+
language:
|
| 7 |
+
- en
|
| 8 |
+
tags:
|
| 9 |
+
- lora
|
| 10 |
+
- legal
|
| 11 |
+
- multiple-choice
|
| 12 |
+
- casehold
|
| 13 |
+
model-index:
|
| 14 |
+
- name: casehold-lora
|
| 15 |
+
results:
|
| 16 |
+
- task:
|
| 17 |
+
type: multiple-choice
|
| 18 |
+
name: Multiple choice QA
|
| 19 |
+
dataset:
|
| 20 |
+
name: CaseHOLD (LexGLUE)
|
| 21 |
+
type: coastalcph/lex_glue
|
| 22 |
+
config: case_hold
|
| 23 |
+
split: test
|
| 24 |
+
metrics:
|
| 25 |
+
- type: accuracy
|
| 26 |
+
value: 85.92
|
| 27 |
+
name: Accuracy
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
# casehold-lora
|
| 31 |
+
|
| 32 |
+
A LoRA adapter for [Qwen/Qwen2.5-0.5B](https://huggingface.co/Qwen/Qwen2.5-0.5B), trained on CaseHOLD: given a
|
| 33 |
+
passage from a US court opinion with one cited holding removed, pick that holding from five candidates.
|
| 34 |
+
|
| 35 |
+
**85.92% on the LexGLUE test split** (3,093 of 3,600).
|
| 36 |
+
|
| 37 |
+
Code, every number with the command that printed it, and the decisions behind the build:
|
| 38 |
+
[github.com/epochzero-0/casehold-lora](https://github.com/epochzero-0/casehold-lora).
|
| 39 |
+
Demo: [epochzero-0/casehold-lora-demo](https://huggingface.co/spaces/epochzero-0/casehold-lora-demo).
|
| 40 |
+
|
| 41 |
+
## Results
|
| 42 |
+
|
| 43 |
+
| Model | CaseHOLD test | Source |
|
| 44 |
+
|---|---|---|
|
| 45 |
+
| TF-IDF + SVM | 22.4 | LexGLUE |
|
| 46 |
+
| Qwen2.5-0.5B, zero-shot | 38.4 (validation) | this work |
|
| 47 |
+
| BERT | 70.8 | LexGLUE |
|
| 48 |
+
| RoBERTa-large | 74.4 | LexGLUE |
|
| 49 |
+
| Legal-BERT | 75.3 | LexGLUE |
|
| 50 |
+
| CaseLaw-BERT | 75.4 | LexGLUE |
|
| 51 |
+
| **Qwen2.5-0.5B + this adapter** | **85.9** | this work |
|
| 52 |
+
|
| 53 |
+
Validation accuracy by training size: 75.28% at 5,000 questions, 80.33% at 15,000, 87.05% at 45,000.
|
| 54 |
+
|
| 55 |
+
One seed and no hyperparameter tuning, so there is no error bar. The model was chosen on validation and scored
|
| 56 |
+
on test once. Qwen2.5-0.5B (494M parameters) is larger than BERT and Legal-BERT (about 110M).
|
| 57 |
+
|
| 58 |
+
## Training
|
| 59 |
+
|
| 60 |
+
- LoRA written by hand (`lora.py`, about 30 lines), r = 16, alpha = 32, on q/k/v/o of all 24 layers:
|
| 61 |
+
2,162,688 trained parameters, 0.436% of the model. Checked against `peft` with the same weights: the logits
|
| 62 |
+
match with a max difference of 0.0.
|
| 63 |
+
- One epoch over the 45,000 train questions, plain PyTorch loop, AdamW, lr 2e-4, 16 questions per step, bf16
|
| 64 |
+
autocast.
|
| 65 |
+
- 62 minutes (1.04 GPU-hours) on an RTX 4050 Laptop GPU, peak 4.3 GiB of VRAM.
|
| 66 |
+
|
| 67 |
+
## How it answers
|
| 68 |
+
|
| 69 |
+
One prompt per question: an instruction, the passage, the five holdings lettered A to E, then `Answer:`. The
|
| 70 |
+
answer is whichever of the tokens ` A` to ` E` has the highest logit at the last position. Prompts over 640
|
| 71 |
+
tokens lose passage text from the left; the holdings are never cut.
|
| 72 |
+
|
| 73 |
+
## Use
|
| 74 |
+
|
| 75 |
+
This is not a `peft` checkpoint. `adapter.safetensors` holds the A and B matrices, and `lora.py` in this repo
|
| 76 |
+
wraps the base model with them.
|
| 77 |
+
|
| 78 |
+
```python
|
| 79 |
+
import json, torch
|
| 80 |
+
from huggingface_hub import hf_hub_download
|
| 81 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 82 |
+
|
| 83 |
+
repo = "epochzero-0/casehold-lora"
|
| 84 |
+
for f in ("lora.py", "data.py"):
|
| 85 |
+
hf_hub_download(repo, f, local_dir=".")
|
| 86 |
+
from data import encode, letter_ids
|
| 87 |
+
from lora import load_adapter
|
| 88 |
+
|
| 89 |
+
cfg = json.load(open(hf_hub_download(repo, "adapter_config.json")))
|
| 90 |
+
tok = AutoTokenizer.from_pretrained(cfg["base_model"])
|
| 91 |
+
model = AutoModelForCausalLM.from_pretrained(cfg["base_model"], dtype=torch.float32)
|
| 92 |
+
load_adapter(model, hf_hub_download(repo, "adapter.safetensors"), cfg["r"], cfg["alpha"]).eval()
|
| 93 |
+
|
| 94 |
+
question = {"context": "... (<HOLDING>) ...", "endings": ["holding that ...", "...", "...", "...", "..."]}
|
| 95 |
+
ids = torch.tensor([encode(question, tok, cfg["max_len"])])
|
| 96 |
+
with torch.no_grad():
|
| 97 |
+
final = model.model(input_ids=ids, use_cache=False).last_hidden_state[0, -1]
|
| 98 |
+
probs = model.lm_head(final)[letter_ids(tok)].softmax(-1)
|
| 99 |
+
print(dict(zip("ABCDE", probs.tolist())))
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
## Limits
|
| 103 |
+
|
| 104 |
+
It picks among five given holdings and nothing else. It is trained on US case law only, it is not legal
|
| 105 |
+
advice, and it has not been tested on holdings written in any other style.
|
| 106 |
+
|
| 107 |
+
## Licences and citations
|
| 108 |
+
|
| 109 |
+
Adapter: Apache-2.0, as is Qwen2.5-0.5B. CaseHOLD was released by its authors under Apache-2.0; the LexGLUE
|
| 110 |
+
copy is CC BY 4.0.
|
| 111 |
+
|
| 112 |
+
- Zheng, Guha, Anderson, Henderson, Ho. *When Does Pretraining Help? Assessing Self-Supervised Learning for
|
| 113 |
+
Law and the CaseHOLD Dataset.* ICAIL 2021. arXiv:2104.08671
|
| 114 |
+
- Chalkidis et al. *LexGLUE: A Benchmark Dataset for Legal Language Understanding in English.* ACL 2022.
|
| 115 |
+
arXiv:2110.00976
|
adapter.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8452f6110b3f44d94d8c6b38129266526630d9be50904db6bd426b7b7f73922b
|
| 3 |
+
size 8670408
|
adapter_config.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"base_model": "Qwen/Qwen2.5-0.5B",
|
| 3 |
+
"r": 16,
|
| 4 |
+
"alpha": 32,
|
| 5 |
+
"targets": [
|
| 6 |
+
"q_proj",
|
| 7 |
+
"k_proj",
|
| 8 |
+
"v_proj",
|
| 9 |
+
"o_proj"
|
| 10 |
+
],
|
| 11 |
+
"max_len": 640,
|
| 12 |
+
"trained_on": 45000
|
| 13 |
+
}
|
data.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CaseHOLD as one prompt per question: instruction, citing context, holdings A-E, "Answer:"."""
|
| 2 |
+
MODEL = "Qwen/Qwen2.5-0.5B"
|
| 3 |
+
LETTERS = "ABCDE"
|
| 4 |
+
HEAD = (
|
| 5 |
+
"The passage below is from a US court opinion. <HOLDING> marks a missing holding. "
|
| 6 |
+
"Pick the holding that fits.\n\n"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def load():
|
| 11 |
+
from datasets import load_dataset # here, so the demo can import the prompt code without it
|
| 12 |
+
|
| 13 |
+
return load_dataset("coastalcph/lex_glue", "case_hold")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def tail(ex):
|
| 17 |
+
"""The five holdings and the answer cue. Never truncated."""
|
| 18 |
+
lines = "\n".join(f"{letter}: {e}" for letter, e in zip(LETTERS, ex["endings"]))
|
| 19 |
+
return f"\n\n{lines}\nAnswer:"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def encode(ex, tok, max_len=None):
|
| 23 |
+
"""Token ids for one question. Over max_len, the context loses tokens from its left end:
|
| 24 |
+
the citation with <HOLDING> sits at the right end, and the holdings stay whole."""
|
| 25 |
+
head = tok(HEAD)["input_ids"]
|
| 26 |
+
ctx = tok(ex["context"])["input_ids"]
|
| 27 |
+
end = tok(tail(ex))["input_ids"]
|
| 28 |
+
if max_len is not None:
|
| 29 |
+
room = max(max_len - len(head) - len(end), 0)
|
| 30 |
+
if len(ctx) > room:
|
| 31 |
+
ctx = ctx[len(ctx) - room:]
|
| 32 |
+
return head + ctx + end
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def letter_ids(tok):
|
| 36 |
+
"""The token id of ' A' ... ' E', the five possible next tokens after 'Answer:'."""
|
| 37 |
+
ids = [tok(f" {letter}")["input_ids"] for letter in LETTERS]
|
| 38 |
+
assert all(len(i) == 1 for i in ids), f"a letter is not one token: {ids}"
|
| 39 |
+
return [i[0] for i in ids]
|
lora.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LoRA, written out: a frozen linear layer plus a trained low-rank correction."""
|
| 2 |
+
import math
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
|
| 7 |
+
TARGETS = ("q_proj", "k_proj", "v_proj", "o_proj")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class LoRALinear(nn.Module):
|
| 11 |
+
"""y = base(x) + (alpha / r) * B A x
|
| 12 |
+
|
| 13 |
+
base is the pretrained layer, frozen. A is (r, in), B is (out, r), and only they train.
|
| 14 |
+
B starts at zero, so at step 0 the correction is zero and the model is the pretrained one.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, base: nn.Linear, r: int, alpha: float):
|
| 18 |
+
super().__init__()
|
| 19 |
+
self.base = base
|
| 20 |
+
for p in self.base.parameters():
|
| 21 |
+
p.requires_grad = False
|
| 22 |
+
w = base.weight
|
| 23 |
+
# A and B stay float32 whatever the base is stored in: AdamW's small updates vanish in bf16
|
| 24 |
+
self.A = nn.Parameter(torch.empty(r, base.in_features, dtype=torch.float32, device=w.device))
|
| 25 |
+
self.B = nn.Parameter(torch.zeros(base.out_features, r, dtype=torch.float32, device=w.device))
|
| 26 |
+
nn.init.kaiming_uniform_(self.A, a=math.sqrt(5))
|
| 27 |
+
self.scale = alpha / r
|
| 28 |
+
|
| 29 |
+
def forward(self, x):
|
| 30 |
+
delta = (x.to(self.A.dtype) @ self.A.T @ self.B.T) * self.scale
|
| 31 |
+
return self.base(x) + delta.to(x.dtype)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def inject(model, r=16, alpha=32, targets=TARGETS):
|
| 35 |
+
"""Freeze every weight, then wrap the attention projections of every layer.
|
| 36 |
+
Returns {"layers.3.q_proj": LoRALinear, ...}."""
|
| 37 |
+
for p in model.parameters():
|
| 38 |
+
p.requires_grad = False
|
| 39 |
+
wrapped = {}
|
| 40 |
+
for i, layer in enumerate(model.model.layers):
|
| 41 |
+
for name in targets:
|
| 42 |
+
lora = LoRALinear(getattr(layer.self_attn, name), r, alpha)
|
| 43 |
+
setattr(layer.self_attn, name, lora)
|
| 44 |
+
wrapped[f"layers.{i}.{name}"] = lora
|
| 45 |
+
return wrapped
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def lora_state(model):
|
| 49 |
+
"""Only the trained tensors: this is the whole adapter."""
|
| 50 |
+
return {k: v.detach().cpu() for k, v in model.state_dict().items() if k.endswith((".A", ".B"))}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def load_adapter(model, path, r=16, alpha=32):
|
| 54 |
+
"""Wrap the model and load trained A and B into it."""
|
| 55 |
+
inject(model, r, alpha)
|
| 56 |
+
if str(path).endswith(".safetensors"):
|
| 57 |
+
from safetensors.torch import load_file
|
| 58 |
+
|
| 59 |
+
state = load_file(path)
|
| 60 |
+
else:
|
| 61 |
+
state = torch.load(path, map_location="cpu")
|
| 62 |
+
missing, unexpected = model.load_state_dict(state, strict=False)
|
| 63 |
+
assert not unexpected and not [k for k in missing if k.endswith((".A", ".B"))]
|
| 64 |
+
return model
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def count(model):
|
| 68 |
+
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 69 |
+
total = sum(p.numel() for p in model.parameters())
|
| 70 |
+
return trainable, total
|