Spaces:
Sleeping
Sleeping
File size: 7,082 Bytes
ffb6dc9 bb9d913 ffb6dc9 | 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 | """
Populate backend/over_refusal_pairs.py from XSTest.
Run once, locally, by the researcher:
cd backend && python scripts/build_over_refusal_pairs.py [--n 50]
XSTest (Roettger et al., NAACL 2024, https://arxiv.org/abs/2308.01263) is
the standard dataset for over-refusal evaluation. It contains 250
hand-crafted benign prompts whose surface lexical cues (kill, shoot,
attack, hack, etc.) reliably trigger over-cautious refusal on
instruction-tuned chat models. The 200 "safe" prompts are exactly what
Maskey's decomposition needs to isolate the over-refusal direction; the
"unsafe" counterparts (used in the original benchmark for paired
contrast) are NOT included here — Maskey uses unpaired over-refusal
prompts against a harmless baseline (Alpaca), not paired against
genuinely-harmful ones.
The script:
1. Pulls XSTest via HuggingFace `natolambert/xstest-v2-copy` (the
community-mirrored version; the original repo is hand-curated
JSON at github.com/paul-rottger/exaggerated-safety).
2. Filters to the "safe" subset.
3. Length-filters on the Llama-3.2-1B-Instruct tokenizer so that
last-token-residual statistics are comparable to refusal_pairs.
4. Rewrites OVER_REFUSAL_PROMPTS in backend/over_refusal_pairs.py.
Requires: `pip install datasets transformers` (already in requirements.txt).
For Llama tokenizer access: export HF_TOKEN with a token that has
accepted Meta's Llama-3.2 license.
"""
from __future__ import annotations
import argparse
import os
import random
import sys
from pathlib import Path
from typing import List
try:
from datasets import load_dataset
except ImportError:
print("error: install datasets (pip install datasets)", file=sys.stderr)
sys.exit(1)
REPO_ROOT = Path(__file__).resolve().parents[2]
OVER_REFUSAL_PAIRS_PATH = REPO_ROOT / "backend" / "over_refusal_pairs.py"
XSTEST_DATASET_NAME = "natolambert/xstest-v2-copy"
LLAMA_TOKENIZER_NAME = "meta-llama/Llama-3.2-1B-Instruct"
FALLBACK_TOKENIZER_NAME = "gpt2" # if Llama is gated and no HF_TOKEN
# XSTest prompts are short by design (one sentence each). Use looser absolute
# bounds rather than the relative-tolerance length-matching that pairs need —
# we want a representative spread across XSTest's 10 over-refusal categories.
MIN_TOKENS = 4
MAX_TOKENS = 50
def load_xstest(n: int) -> List[str]:
"""
Pull XSTest safe prompts. Returns plain prompt strings.
XSTest v2 schema (verified against `natolambert/xstest-v2-copy`):
- "type": category name. Safe variants use bare names like
"homonyms", "figurative_language", "safe_targets", etc.
Unsafe variants are prefixed "contrast_", e.g. "contrast_homonyms".
- "prompt": the user-facing text.
We keep only the non-contrast (safe) rows — these are the
benign-but-edgy prompts that trigger over-refusal in safety-tuned
chat models.
"""
ds = load_dataset(XSTEST_DATASET_NAME, split="prompts")
if "type" not in ds.column_names or "prompt" not in ds.column_names:
raise RuntimeError(
f"xstest dataset missing expected columns; saw: {ds.column_names}"
)
prompts: List[str] = []
for row in ds:
type_label = str(row["type"])
# safe variants don't carry the "contrast_" prefix
if type_label.startswith("contrast_"):
continue
text = row["prompt"]
if text:
prompts.append(text.strip())
if not prompts:
raise RuntimeError(
"xstest: no safe prompts extracted; dataset schema may have changed"
)
random.shuffle(prompts)
return prompts[: n * 3] # over-pull to give length-filtering room
def get_tokenizer():
"""Try Llama first; fall back to gpt2 if gated and no token."""
try:
from transformers import AutoTokenizer
token = os.environ.get("HF_TOKEN")
if token:
return AutoTokenizer.from_pretrained(LLAMA_TOKENIZER_NAME, token=token)
return AutoTokenizer.from_pretrained(LLAMA_TOKENIZER_NAME)
except Exception as e:
print(
f"warning: couldn't load {LLAMA_TOKENIZER_NAME} ({e}); "
f"falling back to {FALLBACK_TOKENIZER_NAME}",
file=sys.stderr,
)
from transformers import AutoTokenizer
return AutoTokenizer.from_pretrained(FALLBACK_TOKENIZER_NAME)
def length_filter(prompts: List[str], tokenizer, n_target: int) -> List[str]:
"""
Absolute-bounds length filter. XSTest prompts are short by design; we
just clip the very-short ("kill") and very-long edge cases so the
last-token-residual statistics are stable.
"""
def tok_len(s: str) -> int:
return len(tokenizer.encode(s, add_special_tokens=False))
kept: List[str] = []
for p in prompts:
n_tokens = tok_len(p)
if MIN_TOKENS <= n_tokens <= MAX_TOKENS:
kept.append(p)
if len(kept) >= n_target:
break
return kept
def rewrite_over_refusal_pairs_file(prompts: List[str]) -> None:
"""Overwrite OVER_REFUSAL_PROMPTS in backend/over_refusal_pairs.py."""
lines = ["OVER_REFUSAL_PROMPTS: List[str] = ["]
for p in prompts:
lines.append(f" {p!r},")
lines.append("]")
new_block = "\n".join(lines)
src = OVER_REFUSAL_PAIRS_PATH.read_text()
# Replace existing OVER_REFUSAL_PROMPTS literal (greedy from declaration to ']\n')
import re
pattern = re.compile(
r"OVER_REFUSAL_PROMPTS: List\[str\] = \[.*?\n\]",
flags=re.DOTALL,
)
if not pattern.search(src):
raise RuntimeError(
"couldn't find OVER_REFUSAL_PROMPTS literal in over_refusal_pairs.py — "
"file structure may have changed"
)
# Callable replacement so re.sub doesn't process backslash escapes from
# repr() output (same trap as build_refusal_pairs.py).
OVER_REFUSAL_PAIRS_PATH.write_text(pattern.sub(lambda _m: new_block, src))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--n", type=int, default=50, help="target prompt count")
parser.add_argument("--seed", type=int, default=42)
args = parser.parse_args()
random.seed(args.seed)
print(f"pulling XSTest (over-pull for length filtering)…")
candidates = load_xstest(args.n)
print(f" got {len(candidates)} safe prompts")
print(f"loading tokenizer…")
tokenizer = get_tokenizer()
print(f"length-filtering ({MIN_TOKENS}..{MAX_TOKENS} tokens)…")
prompts = length_filter(candidates, tokenizer, args.n)
print(f" kept {len(prompts)} prompts out of target {args.n}")
if len(prompts) < args.n // 2:
print(
"warning: kept fewer than n/2 prompts. "
"Consider relaxing length bounds or over-pulling more.",
file=sys.stderr,
)
print(f"writing {OVER_REFUSAL_PAIRS_PATH}…")
rewrite_over_refusal_pairs_file(prompts)
print(f"done. {len(prompts)} prompts written.")
return 0
if __name__ == "__main__":
sys.exit(main())
|