exploitintel commited on
Commit
16d4233
·
verified ·
1 Parent(s): 6419ff7

Add evaluation script

Browse files
Files changed (1) hide show
  1. evaluate.py +194 -0
evaluate.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate a fine-tuned CVE -> CWE model on the held-out test split.
3
+
4
+ Reports exact-match accuracy plus micro/macro multi-label F1, stratified into
5
+ "easy" (the weakness is named in the description) vs "hard" (it must be inferred),
6
+ so you see real-world performance instead of one flattered average.
7
+
8
+ Loads with plain transformers. Newer architectures (e.g. model_type ``gemma4``,
9
+ used by gemma-4-E4B) need **transformers >= 5.5** -- older versions raise
10
+ ``KeyError: 'gemma4'``. Note: do NOT load gemma4 through unsloth in a Studio env
11
+ whose transformers was upgraded -- the upgrade pulls ``huggingface_hub`` 1.x,
12
+ which breaks ``unsloth_zoo``'s config lookup. Plain transformers is the clean path.
13
+
14
+ python evaluate.py --model "C:\\path\\to\\exported\\merged_model" --limit 500
15
+ python evaluate.py --model "C:\\path\\to\\exported\\merged_model"
16
+
17
+ Needs: transformers>=5.5, torch, datasets, accelerate.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import re
24
+
25
+ import torch
26
+ from datasets import load_dataset
27
+ from transformers import AutoModelForCausalLM, AutoTokenizer
28
+
29
+ CWE_RE = re.compile(r"CWE-\d+")
30
+
31
+ # A row is "easy" if the description literally names the weakness (the model can
32
+ # keyword-match); "hard" rows require inferring the CWE from the prose.
33
+ EASY_KW = [
34
+ "sql injection",
35
+ "cross-site scripting",
36
+ "cross site scripting",
37
+ "xss",
38
+ "buffer overflow",
39
+ "use after free",
40
+ "use-after-free",
41
+ "path traversal",
42
+ "command injection",
43
+ "out-of-bounds",
44
+ "out of bounds",
45
+ "race condition",
46
+ "deserialization",
47
+ "ssrf",
48
+ "server-side request forgery",
49
+ "csrf",
50
+ "cross-site request forgery",
51
+ "open redirect",
52
+ "integer overflow",
53
+ ]
54
+
55
+
56
+ def parse_cwes(text: str) -> set[str]:
57
+ return set(CWE_RE.findall(text))
58
+
59
+
60
+ def is_easy(description: str) -> bool:
61
+ return any(k in description.lower() for k in EASY_KW)
62
+
63
+
64
+ def prf(tp: int, fp: int, fn: int) -> tuple[float, float, float]:
65
+ p = tp / (tp + fp) if (tp + fp) else 0.0
66
+ r = tp / (tp + fn) if (tp + fn) else 0.0
67
+ f = 2 * p * r / (p + r) if (p + r) else 0.0
68
+ return p, r, f
69
+
70
+
71
+ def build_prompt(tok, messages: list[dict]) -> str:
72
+ """Prompt = everything up to (but not including) the assistant answer."""
73
+ convo = messages[:-1]
74
+ try:
75
+ return tok.apply_chat_template(convo, tokenize=False, add_generation_prompt=True)
76
+ except Exception:
77
+ # Some chat templates (e.g. Gemma) reject a separate "system" role;
78
+ # fold the system text into the user turn instead.
79
+ sys_txt = next((m["content"] for m in convo if m["role"] == "system"), "")
80
+ usr_txt = next((m["content"] for m in convo if m["role"] == "user"), "")
81
+ folded = [{"role": "user", "content": f"{sys_txt}\n\n{usr_txt}".strip()}]
82
+ return tok.apply_chat_template(folded, tokenize=False, add_generation_prompt=True)
83
+
84
+
85
+ def score(truths: list[set[str]], preds: list[set[str]], easies: list[bool]) -> None:
86
+ micro = [0, 0, 0] # tp, fp, fn
87
+ per_label: dict[str, list[int]] = {}
88
+ exact = 0
89
+ strata = {"easy": [0, 0, 0, 0, 0], "hard": [0, 0, 0, 0, 0]} # tp,fp,fn,exact,n
90
+
91
+ for true, pred, easy in zip(truths, preds, easies):
92
+ tp, fp, fn = len(pred & true), len(pred - true), len(true - pred)
93
+ micro[0] += tp
94
+ micro[1] += fp
95
+ micro[2] += fn
96
+ ex = int(pred == true)
97
+ exact += ex
98
+ for lab in true | pred:
99
+ d = per_label.setdefault(lab, [0, 0, 0])
100
+ if lab in true and lab in pred:
101
+ d[0] += 1
102
+ elif lab in pred:
103
+ d[1] += 1
104
+ else:
105
+ d[2] += 1
106
+ s = strata["easy" if easy else "hard"]
107
+ s[0] += tp
108
+ s[1] += fp
109
+ s[2] += fn
110
+ s[3] += ex
111
+ s[4] += 1
112
+
113
+ n = len(truths)
114
+ micro_f1 = prf(*micro)[2]
115
+ macro_f1 = sum(prf(*v)[2] for v in per_label.values()) / len(per_label) if per_label else 0.0
116
+
117
+ print("\n=== CVE -> CWE evaluation ===")
118
+ print(f"examples : {n}")
119
+ print(f"exact-match accuracy : {exact / n:.3f} (predicted CWE set == true set)")
120
+ print(f"micro-F1 : {micro_f1:.3f}")
121
+ print(f"macro-F1 : {macro_f1:.3f} (unweighted mean over {len(per_label)} CWEs)")
122
+ print("\n-- by difficulty --")
123
+ for name, label in (("easy", "easy (weakness named)"), ("hard", "hard (must infer) ")):
124
+ tp, fp, fn, ex, m = strata[name]
125
+ if m:
126
+ print(f" {label:22s} n={m:5d} exact={ex / m:.3f} micro-F1={prf(tp, fp, fn)[2]:.3f}")
127
+
128
+
129
+ def main() -> None:
130
+ ap = argparse.ArgumentParser(description="Evaluate a CVE->CWE model on the test split.")
131
+ ap.add_argument("--model", required=True, help="path or HF id of the fine-tuned (merged) model")
132
+ ap.add_argument("--dataset", default="exploitintel/cve-cwe-consensus")
133
+ ap.add_argument("--split", default="test")
134
+ ap.add_argument(
135
+ "--limit", type=int, default=None, help="evaluate only the first N rows (quick check)"
136
+ )
137
+ ap.add_argument("--batch-size", type=int, default=16)
138
+ ap.add_argument("--max-new-tokens", type=int, default=32)
139
+ args = ap.parse_args()
140
+
141
+ print(f"loading model: {args.model}")
142
+ try:
143
+ tok = AutoTokenizer.from_pretrained(args.model)
144
+ except (AttributeError, TypeError):
145
+ # Some Gemma tokenizer configs store `extra_special_tokens` as a list, which
146
+ # trips a transformers bug ('list' object has no attribute 'keys').
147
+ tok = AutoTokenizer.from_pretrained(args.model, extra_special_tokens={})
148
+ tok.padding_side = "left" # decoder-only batched generation needs left padding
149
+ if tok.pad_token is None:
150
+ tok.pad_token = tok.eos_token
151
+ device = "cuda" if torch.cuda.is_available() else "cpu"
152
+ try:
153
+ model = AutoModelForCausalLM.from_pretrained(args.model, dtype="auto").to(device)
154
+ except TypeError:
155
+ # `dtype` is the transformers 5.x name; older releases use `torch_dtype`.
156
+ model = AutoModelForCausalLM.from_pretrained(args.model, torch_dtype="auto").to(device)
157
+ model.eval()
158
+
159
+ ds = load_dataset(args.dataset, split=args.split)
160
+ if args.limit:
161
+ ds = ds.select(range(min(args.limit, len(ds))))
162
+
163
+ prompts, truths, easies = [], [], []
164
+ for ex in ds:
165
+ msgs = ex["messages"]
166
+ prompts.append(build_prompt(tok, msgs))
167
+ truths.append(parse_cwes(msgs[-1]["content"]))
168
+ usr = next((m["content"] for m in msgs if m["role"] == "user"), "")
169
+ easies.append(is_easy(usr))
170
+
171
+ preds: list[set[str]] = []
172
+ for i in range(0, len(prompts), args.batch_size):
173
+ batch = prompts[i : i + args.batch_size]
174
+ enc = tok(batch, return_tensors="pt", padding=True, truncation=True, max_length=1024).to(
175
+ device
176
+ )
177
+ with torch.no_grad():
178
+ out = model.generate(
179
+ **enc,
180
+ max_new_tokens=args.max_new_tokens,
181
+ do_sample=False, # greedy = deterministic
182
+ pad_token_id=tok.pad_token_id,
183
+ )
184
+ new_tokens = out[:, enc["input_ids"].shape[1] :] # drop the prompt, keep the answer
185
+ for row in new_tokens:
186
+ preds.append(parse_cwes(tok.decode(row, skip_special_tokens=True)))
187
+ print(f" {min(i + args.batch_size, len(prompts))}/{len(prompts)}", end="\r")
188
+ print()
189
+
190
+ score(truths, preds, easies)
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()