neuroscope-api / scripts /build_refusal_pairs.py
lymnal's picture
sync: Wave 1+2+3 backend + 6 techniques + populated refusal/over-refusal data
ffb6dc9 verified
Raw
History Blame Contribute Delete
6.19 kB
"""
Populate backend/refusal_pairs.py from JailbreakBench + Alpaca.
Run once, locally, by the researcher:
cd backend && python scripts/build_refusal_pairs.py [--n 50]
This is human-in-the-loop intentionally — automated curation of jailbreak
prompts hits safety filters, and the standard research workflow for
refusal-direction work (Arditi 2024 onward) sources prompts from
published datasets with documented provenance.
The script:
1. Pulls JBB-Behaviors `behaviors.csv` from HuggingFace.
2. Pulls a slice of Alpaca instruction-only entries.
3. Length-matches on the Llama-3.2-1B-Instruct tokenizer (within 20%).
4. Rewrites REFUSAL_PAIRS in backend/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, Tuple
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]
REFUSAL_PAIRS_PATH = REPO_ROOT / "backend" / "refusal_pairs.py"
LLAMA_TOKENIZER_NAME = "meta-llama/Llama-3.2-1B-Instruct"
FALLBACK_TOKENIZER_NAME = "gpt2" # if Llama is gated and no HF_TOKEN
LENGTH_TOLERANCE = 0.20 # accept pairs within 20% token-count delta
def load_jailbreakbench(n: int) -> List[str]:
"""Pull JBB-Behaviors harmful behaviors. Returns plain prompt strings."""
ds = load_dataset("JailbreakBench/JBB-Behaviors", "behaviors", split="harmful")
prompts = [row["Goal"] for row in ds]
random.shuffle(prompts)
return prompts[: n * 2] # over-pull, we'll filter after length-matching
def load_alpaca(n: int) -> List[str]:
"""Pull Alpaca instruction-only entries."""
ds = load_dataset("tatsu-lab/alpaca", split="train")
prompts = [
row["instruction"]
for row in ds
if not row.get("input", "").strip()
]
random.shuffle(prompts)
return prompts[: n * 4] # heavily over-pull to give length-matching 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_match(
harmful: List[str],
harmless: List[str],
tokenizer,
n_target: int,
) -> List[Tuple[str, str]]:
"""
Greedy length-matching: for each harmful prompt, find an unused harmless
prompt whose token count is within LENGTH_TOLERANCE.
"""
def tok_len(s: str) -> int:
return len(tokenizer.encode(s, add_special_tokens=False))
harmful_with_len = [(p, tok_len(p)) for p in harmful]
harmless_with_len = [(p, tok_len(p)) for p in harmless]
used_harmless = set()
pairs: List[Tuple[str, str]] = []
for hp, hl in harmful_with_len:
if len(pairs) >= n_target:
break
best = None
best_delta = float("inf")
for j, (sp, sl) in enumerate(harmless_with_len):
if j in used_harmless:
continue
delta = abs(sl - hl) / max(hl, 1)
if delta < best_delta:
best_delta = delta
best = j
if best is not None and best_delta <= LENGTH_TOLERANCE:
pairs.append((hp, harmless_with_len[best][0]))
used_harmless.add(best)
return pairs
def rewrite_refusal_pairs_file(pairs: List[Tuple[str, str]]) -> None:
"""Overwrite REFUSAL_PAIRS in backend/refusal_pairs.py."""
lines = ["REFUSAL_PAIRS: List[Tuple[str, str]] = ["]
for harmful, harmless in pairs:
h = repr(harmful)
s = repr(harmless)
lines.append(f" ({h}, {s}),")
lines.append("]")
new_block = "\n".join(lines)
src = REFUSAL_PAIRS_PATH.read_text()
# Replace existing REFUSAL_PAIRS literal (greedy from declaration to ']\n')
import re
pattern = re.compile(
r"REFUSAL_PAIRS: List\[Tuple\[str, str\]\] = \[.*?\n\]",
flags=re.DOTALL,
)
if not pattern.search(src):
raise RuntimeError(
"couldn't find REFUSAL_PAIRS literal in refusal_pairs.py — "
"file structure may have changed"
)
REFUSAL_PAIRS_PATH.write_text(pattern.sub(new_block, src))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--n", type=int, default=50, help="target pair count")
parser.add_argument("--seed", type=int, default=42)
args = parser.parse_args()
random.seed(args.seed)
print(f"pulling JailbreakBench (over-pull for length matching)…")
harmful = load_jailbreakbench(args.n)
print(f" got {len(harmful)} harmful prompts")
print(f"pulling Alpaca instruction-only…")
harmless = load_alpaca(args.n)
print(f" got {len(harmless)} harmless prompts")
print(f"loading tokenizer…")
tokenizer = get_tokenizer()
print(f"length-matching (tolerance {LENGTH_TOLERANCE*100:.0f}%)…")
pairs = length_match(harmful, harmless, tokenizer, args.n)
print(f" matched {len(pairs)} pairs out of target {args.n}")
if len(pairs) < args.n // 2:
print(
"warning: matched fewer than n/2 pairs. "
"Consider relaxing LENGTH_TOLERANCE or over-pulling more.",
file=sys.stderr,
)
print(f"writing {REFUSAL_PAIRS_PATH}…")
rewrite_refusal_pairs_file(pairs)
print(f"done. {len(pairs)} pairs written.")
return 0
if __name__ == "__main__":
sys.exit(main())