File size: 8,806 Bytes
994182c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | #!/usr/bin/env python3
"""Build held-out scoring sets for eval_endpoint.py.
Two modes:
vuln_detection -- from the reserved `eval_split` (test) of the training sources.
Each row -> {messages: [system, user], gold_label, cwe}. The model
is asked the same detection question used in training; the gold label
comes straight from the dataset's ground truth.
mcq -- from an eval-only MCQ source (disjoint from training).
Each row -> {question, choices, gold_index, gold_letter}.
These sets are never used for training and must be passed through decontaminate.py's
eval side, not its train side.
Examples:
# 1) download the held-out test splits first:
python training/scripts/hf_download.py --key primevul --eval
python training/scripts/hf_download.py --key megavul --eval
python training/scripts/build_eval_sets.py --mode vuln_detection \
--out data/eval/vuln_detection_test.jsonl
# 2) MCQ eval (download the disjoint config, then build):
python training/scripts/hf_download.py --hf-id theelderemo/pentesting-explanations \
--config mitre_attack --split train --out data/download/pentest_mcq_eval/raw.jsonl
python training/scripts/build_eval_sets.py --mode mcq \
--mcq-input data/download/pentest_mcq_eval/raw.jsonl \
--out data/eval/knowledge_mcq.jsonl
"""
from __future__ import annotations
import argparse
import json
import random
import sys
from pathlib import Path
from typing import Any
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parent))
from sft_adapters import apply_adapter, coerce_list, get_first # noqa: E402
def read_yaml(path: str | Path) -> dict[str, Any]:
with Path(path).open("r", encoding="utf-8") as fh:
return yaml.safe_load(fh) or {}
def read_jsonl(path: Path):
with path.open("r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line:
yield json.loads(line)
def drop_final_assistant(messages: list[dict[str, str]]) -> list[dict[str, str]]:
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "assistant":
return messages[:i]
return messages
def build_vuln_detection(manifest: dict[str, Any], raw_dir: Path, scan_cap: int,
target_per_class: int, seed: int) -> tuple[list[dict], list[str]]:
"""Class-balanced vuln-detection set.
These test splits are heavily majority `not_vulnerable`, so an unbalanced sample
lets a model score high by always answering "not vulnerable" (accuracy high, F1=0).
We collect both classes across sources, then take an equal number of each so
accuracy and F1 are meaningful.
"""
pos: list[dict] = [] # vulnerable
neg: list[dict] = [] # not_vulnerable
notes: list[str] = []
for key, source in manifest.get("sources", {}).items():
if not source.get("eval_split"):
continue
raw = raw_dir / key / "eval.jsonl"
if not raw.is_file():
notes.append(f"{key}: missing {raw} (run hf_download.py --key {key} --eval)")
continue
scanned = 0
for i, raw_row in enumerate(read_jsonl(raw)):
if scanned >= scan_cap:
break
scanned += 1
for ex in apply_adapter(source["adapter"], raw_row, source.get("params", {}) or {}):
if not ex.verify or ex.verify.get("mode") != "label":
continue
row = {
"id": f"{source['hf_id']}:eval:{i}",
"source": source["hf_id"],
"kind": "vuln_detection",
"messages": drop_final_assistant(ex.messages),
"gold_label": ex.verify["expected"],
"cwe": ex.verify.get("cwe", []),
}
(pos if ex.verify["expected"] == "vulnerable" else neg).append(row)
break
rng = random.Random(seed)
rng.shuffle(pos)
rng.shuffle(neg)
n = min(target_per_class, len(pos), len(neg))
notes.append(f"collected vulnerable={len(pos)} not_vulnerable={len(neg)} -> balanced {n}+{n}")
if n == 0:
notes.append("WARNING: one class is empty; download more rows (hf_download --eval --max-rows N)")
rows = pos[:n] + neg[:n]
rng.shuffle(rows)
return rows, notes
def build_mcq(mcq_input: Path, source_label: str, cap: int, seed: int) -> tuple[list[dict], list[str]]:
"""MCQ set with choices shuffled so the correct answer isn't positionally biased.
The raw source lists the correct choice first for most rows (gold ~76% 'A'), which
a model can game by always answering 'A'. We permute choices per question and remap
the gold index.
"""
rng = random.Random(seed)
rows: list[dict] = []
notes: list[str] = []
for i, raw in enumerate(read_jsonl(mcq_input)):
if len(rows) >= cap:
break
question = get_first(raw, ["question", "Question", "prompt"])
choices = raw.get("choices") or raw.get("options")
if not question or not isinstance(choices, list) or not choices:
continue
gold_index = raw.get("answer_idx", raw.get("correct_idx"))
# MMLU stores the answer as an int index in `answer`.
if gold_index is None and isinstance(raw.get("answer"), int):
gold_index = raw.get("answer")
gold_letter = raw.get("correct_letter") or raw.get("answer")
if gold_index is None and isinstance(gold_letter, str) and len(gold_letter) == 1:
gold_index = ord(gold_letter.upper()) - ord("A")
if gold_index is None:
correct_choice = get_first(raw, ["correct_choice"])
if correct_choice and correct_choice in choices:
gold_index = choices.index(correct_choice)
if gold_index is None:
continue
gold_index = int(gold_index)
if not 0 <= gold_index < len(choices):
continue
# shuffle choices, track where the correct one lands
correct_text = str(choices[gold_index])
shuffled = [str(c) for c in choices]
rng.shuffle(shuffled)
new_gold = shuffled.index(correct_text)
rows.append(
{
"id": f"{source_label}:eval:{i}",
"source": source_label,
"kind": "mcq",
"question": str(question),
"choices": shuffled,
"gold_index": new_gold,
"gold_letter": chr(ord("A") + new_gold),
}
)
from collections import Counter
notes.append("gold_letter dist: " + str(dict(Counter(r["gold_letter"] for r in rows))))
return rows, notes
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--mode", choices=["vuln_detection", "mcq"], required=True)
parser.add_argument("--manifest", default="training/configs/datasets.yaml")
parser.add_argument("--raw-dir", default="data/download")
parser.add_argument("--out", required=True)
parser.add_argument("--scan-cap", type=int, default=8000, help="Rows to read per source (vuln mode).")
parser.add_argument("--target-per-class", type=int, default=60, help="Balanced count per class (vuln mode).")
parser.add_argument("--mcq-cap", type=int, default=200, help="Max MCQ rows.")
parser.add_argument("--seed", type=int, default=1337)
parser.add_argument("--mcq-input", help="Raw MCQ JSONL (for --mode mcq).")
parser.add_argument("--mcq-source", default="pentest_mcq_eval")
return parser.parse_args()
def main() -> int:
args = parse_args()
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
notes: list[str] = []
if args.mode == "vuln_detection":
manifest = read_yaml(args.manifest)
rows, notes = build_vuln_detection(manifest, Path(args.raw_dir), args.scan_cap,
args.target_per_class, args.seed)
else:
if not args.mcq_input:
print("--mcq-input is required for --mode mcq", file=sys.stderr)
return 2
rows, notes = build_mcq(Path(args.mcq_input), args.mcq_source, args.mcq_cap, args.seed)
with out_path.open("w", encoding="utf-8") as out:
for row in rows:
out.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
summary = {"mode": args.mode, "rows": len(rows), "out": str(out_path)}
if notes:
summary["notes"] = notes
print(json.dumps(summary, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
|