epochzero-0 commited on
Commit
fd5a7ee
·
verified ·
1 Parent(s): 3c10706

Adapter trained on 45,000 CaseHOLD questions, 85.92% on test

Browse files
Files changed (5) hide show
  1. README.md +115 -0
  2. adapter.safetensors +3 -0
  3. adapter_config.json +13 -0
  4. data.py +39 -0
  5. 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