Files
0cfa3aa60a chore: sync upstream 2026-03-27 (#1)
* update tech report

* update evaluation & readme

* Fix evaluation link in README.md

* update arxiv version of tech report

* update citation

---------

Co-authored-by: feihu.hf <[email protected]>
Co-authored-by: ChenhanYuan <[email protected]>
2026-05-29 19:59:01 -07:00

294 lines
14 KiB
Python
Executable File

import torch
import torch.nn.functional as F
from transformers import AutoTokenizer, AutoModel
import json
from tqdm import tqdm
import re
from typing import Dict, List, Tuple
from collections import Counter
import numpy as np
import argparse
from datasets import load_dataset
class ContentEvaluator:
def __init__(self, model_path: str):
self.device = "cuda" if torch.cuda.is_available() else "cpu"
self.model = AutoModel.from_pretrained(
model_path,
device_map="auto",
torch_dtype=torch.bfloat16,
trust_remote_code=True,
).eval()
self.tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
self.model.eval()
# Constants
self.category_map = {0:'"Violent"',1:"Sexual Content or Sexual Acts",2:"Suicide & Self-Harm",3:"Politically Sensitive Topics",4:"PII",5:"Copyright Violation",6:"Non-violent Illegal Acts",7:"Unethical Acts"}
self.prompt_category_map = {0:'"Violent"',1:"Sexual Content or Sexual Acts",2:"Suicide & Self-Harm",3:"Politically Sensitive Topics",4:"PII",5:"Copyright Violation",6:"Non-violent Illegal Acts",7:"Unethical Acts",8:"Jailbreak"}
def process_conversation_response(self, messages, thinking=False) -> Dict:
text = self.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False,enable_thinking=False)
model_inputs = self.tokenizer(text, return_tensors="pt").to(self.model.device)
input_ids = model_inputs.input_ids[0].tolist()
str_token_list_qwen3 = [self.tokenizer.decode([x]) for x in input_ids]
if thinking:
assistant_index_qwen3 = next((i for i in range(1, len(str_token_list_qwen3)) if str_token_list_qwen3[i-1] == '<|im_start|>' and str_token_list_qwen3[i] == 'assistant'), -1)
assert assistant_index_qwen3 != -1
split_idx = assistant_index_qwen3 + 4
else:
assistant_index_qwen3 = next((i for i in range(1, len(str_token_list_qwen3)) if str_token_list_qwen3[i-1] == '</think>' and str_token_list_qwen3[i] == '\n\n'), -1)
assert assistant_index_qwen3 != -1
split_idx = assistant_index_qwen3 + 1
eval_pred = self._get_model_response_predictions(model_inputs, split_idx, input_ids)
return eval_pred
def process_conversation_prompt(self, messages) -> Dict:
text = self.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False,enable_thinking=False)
model_inputs = self.tokenizer(text, return_tensors="pt").to(self.model.device)
input_ids = model_inputs.input_ids[0].tolist()
str_token_list_qwen3 = [self.tokenizer.decode([x]) for x in input_ids]
split_indices = self._find_last_user_content_index(str_token_list_qwen3)
assert split_indices is not None
return self._get_model_prompt_predictions(model_inputs, split_indices, input_ids)
def _get_model_response_predictions(self, model_inputs, split_idx, input_ids) -> Dict:
seq_length = model_inputs.input_ids.size(1)
causal_mask = torch.tril(torch.ones((seq_length, seq_length), device=self.model.device, dtype=torch.bool))
causal_mask = causal_mask.unsqueeze(0).unsqueeze(0)
attention_mask = model_inputs['attention_mask'].unsqueeze(1).unsqueeze(1).to(torch.bool)
causal_mask = causal_mask & attention_mask
model_inputs['attention_mask'] = causal_mask
with torch.no_grad():
outputs = self.model.forward(**model_inputs)
risk_level_logits = outputs.risk_level_logits.view(-1, 3)
category_logits = outputs.category_logits.view(-1, len(self.category_map))
risk_level_logits = risk_level_logits[split_idx:]
category_logits = category_logits[split_idx:]
risk_level_prob = F.softmax(risk_level_logits, dim=1)
risk_level_prob, pred_risk_level = torch.max(risk_level_prob, dim=1)
category_prob = F.softmax(category_logits, dim=1)
category_prob, pred_category = torch.max(category_prob, dim=1)
return {
"pred_risk_levels": [int(i) for i in pred_risk_level.cpu().tolist()],
"pred_categories": [self.category_map[int(i)] for i in pred_category.cpu().tolist()],
"pred_risk_prob": [float(i) for i in risk_level_prob.cpu().tolist()],
"input_ids": input_ids,
"split_idx_eval": split_idx
}
def _get_model_prompt_predictions(self, model_inputs, split_idx, input_ids) -> Dict:
seq_length = model_inputs.input_ids.size(1)
causal_mask = torch.tril(torch.ones((seq_length, seq_length), device=self.model.device, dtype=torch.bool))
causal_mask = causal_mask.unsqueeze(0).unsqueeze(0)
attention_mask = model_inputs['attention_mask'].unsqueeze(1).unsqueeze(1).to(torch.bool)
causal_mask = causal_mask & attention_mask
model_inputs['attention_mask'] = causal_mask
with torch.no_grad():
outputs = self.model.forward(**model_inputs)
risk_level_logits = outputs.query_risk_level_logits.view(-1, 3)
category_logits = outputs.query_category_logits.view(-1, len(self.prompt_category_map))
risk_level_logits = risk_level_logits[split_idx[0]:split_idx[1]+1]
category_logits = category_logits[split_idx[0]:split_idx[1]+1]
risk_level_prob = F.softmax(risk_level_logits, dim=1)
risk_level_prob, pred_risk_level = torch.max(risk_level_prob, dim=1)
category_prob = F.softmax(category_logits, dim=1)
category_prob, pred_category = torch.max(category_prob, dim=1)
return {
"pred_risk_levels": [int(i) for i in pred_risk_level.cpu().tolist()],
"pred_categories": [self.prompt_category_map[int(i)] for i in pred_category.cpu().tolist()],
"pred_risk_prob": [float(i) for i in risk_level_prob.cpu().tolist()],
"input_ids": input_ids
}
def _find_last_user_content_index(self, tokens_list):
n = len(tokens_list)
for i in range(n - 1, -1, -1):
if tokens_list[i] == '<|im_start|>':
if i + 1 < n and tokens_list[i+1] == 'user':
content_start_idx = i + 3
for j in range(content_start_idx, n):
if tokens_list[j] == '<|im_end|>':
return [content_start_idx, j]
return None
class SafetyEvaluator:
def __init__(self, classification_strategy="consecutive_unsafe"):
self.strategy_map = {
"consecutive_unsafe": self.consecutive_unsafe
}
self.classification_strategy = classification_strategy
self.label_map = {0:"Safe",1:"Unsafe",2:"Controversial"}
def consecutive_unsafe(self, pred_list: List[str]) -> Tuple[str, int]:
for i in range(len(pred_list)-1):
if pred_list[i] == "Unsafe" and pred_list[i+1] == "Unsafe":
return "Unsafe", i+1
for i in range(len(pred_list)-1):
if pred_list[i] == "Controversial" and pred_list[i+1] == "Controversial":
return "Controversial", i+1
return "Safe", -1
def calculate_metrics(self, predictions, targets):
"""Calculates precision, recall, and F1 for the 'Unsafe' category."""
category = "Unsafe"
tp = sum(1 for p, t in zip(predictions, targets) if p == category and t == category)
fp = sum(1 for p, t in zip(predictions, targets) if p == category and t != category)
fn = sum(1 for p, t in zip(predictions, targets) if p != category and t == category)
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0
f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0
return f1, precision, recall
def evaluate_f1(self, file_path: str, data_type: str):
labels = []
predictions_strict, predictions_loose = [], []
with open(file_path, 'r', encoding='utf-8') as reader:
for line in reader:
obj_json = json.loads(line)
labels.append(obj_json["label"])
pred_data = [self.label_map[i] for i in obj_json['pred_risk_levels']]
if data_type == "response":
prediction, _ = self.strategy_map[self.classification_strategy](pred_data)
if "Controversial" == prediction:
predictions_strict.append("Unsafe")
predictions_loose.append("Safe")
else:
predictions_strict.append(prediction)
predictions_loose.append(prediction)
else: # prompt
prediction = self.label_map[pred_data[-1]]
if "Controversial" == prediction:
predictions_strict.append("Unsafe")
predictions_loose.append("Safe")
else:
predictions_strict.append(prediction)
predictions_loose.append(prediction)
unsafe_f1_strict, unsafe_prec_strict, unsafe_recall_strict = self.calculate_metrics(predictions_loose, labels)
unsafe_f1_loose, unsafe_prec_loose, unsafe_recall_loose = self.calculate_metrics(predictions_strict, labels)
print(f"Unsafe F1 Score(strict): {unsafe_f1_strict:.4f}. precision(strict): {unsafe_prec_strict:.4f}. recall(strict): {unsafe_recall_strict:.4f}")
print(f"Unsafe F1 Score(loose): {unsafe_f1_loose:.4f}. precision(loose): {unsafe_prec_loose:.4f}. recall(loose): {unsafe_recall_loose:.4f}")
def evaluate_unsafe_latency(self, file_path: str):
def process_bins(numbers):
bins = {
'Ahead': 0,
'Hit': 0,
'1-32': 0,
'33-64':0,
'65-128':0,
'129-256': 0,
'>256':0,
'Safe': 0,
}
for num in numbers:
if num == '安全' or num == 'Safe':
bins['Safe'] += 1
else:
n = float(num)
if n == 0:
bins['Hit'] += 1
elif 1 <= n <= 32:
bins['1-32'] += 1
elif 33 <= n <= 64:
bins['33-64'] += 1
elif 65 <= n <= 128:
bins['65-128'] += 1
elif 129 <= n <= 256:
bins['129-256'] += 1
elif n > 256:
bins['>256'] += 1
else:
bins['Ahead'] += 1
return bins
unsafe_dist = []
count = 0
count_hit, count_128 = 0, 0
with open(file_path, 'r', encoding='utf-8') as reader:
for line in reader:
obj_json = json.loads(line)
if obj_json["label"] == "Unsafe":
count += 1
start_range, end_range = obj_json["unsafe_start_index"], obj_json["unsafe_end_index"]
pred_data = [self.label_map[i] for i in obj_json['pred_risk_levels']]
prediction, pred_idx = self.consecutive_unsafe(pred_data)
pred_idx += obj_json["split_idx_eval"]
if prediction != "Safe":
if pred_idx <= end_range and pred_idx >= start_range:
unsafe_dist.append(0)
count_hit += 1
elif pred_idx-end_range<=128:
count_128 += 1
if pred_idx>end_range:
unsafe_dist.append(pred_idx-end_range)
if pred_idx<start_range:
unsafe_dist.append(pred_idx-start_range)
else:
unsafe_dist.append("Safe")
bins = process_bins(unsafe_dist)
print(f"Processed {count} unsafe samples.")
print("Bins Count: ", bins)
print("First 128 tokens stop rate: ", (count_128+count_hit)/count)
print("Exact hit rate: ", count_hit/count)
def main():
parser = argparse.ArgumentParser(description="Evaluate content safety using Qwen3-stream guard model.")
parser.add_argument("--model_path", type=str, required=True, help="Path to the guard model.")
parser.add_argument("--input_path", type=str, required=True, help="Path to the input testset file.")
parser.add_argument("--output_path", type=str, required=True, help="Path to save the output testset file with predictions.")
parser.add_argument("--data_type", type=str, required=True, choices=['response', 'prompt'], help="Specify if the data is 'prompt' or 'response'.")
parser.add_argument("--split", type=str, required=True, help="determine which split of the dataset to be used in the evaluation")
parser.add_argument("--eval_unsafe_latency", action="store_true", help="If set, evaluate the unsafe detection latency for response data.")
parser.add_argument("--thinking", action="store_true", help="If set, guard model will detect the whole response, including thinking tags")
args = parser.parse_args()
# Run the model to get predictions
content_evaluator = ContentEvaluator(args.model_path)
dataset = load_dataset(args.input_path, split=args.split)
with open(args.output_path, "w", encoding='utf-8') as writer:
print("Running model predictions...")
for data in tqdm(dataset):
if args.data_type == "response":
result = content_evaluator.process_conversation_response(data["message"], thinking=args.thinking)
else: # prompt
result = content_evaluator.process_conversation_prompt(data["message"])
output = {**data, **result}
writer.write(json.dumps(output, ensure_ascii=False) + "\n")
# Perform evaluation on the output file
safety_evaluator = SafetyEvaluator()
print("\nCalculating F1 score...")
safety_evaluator.evaluate_f1(args.output_path, args.data_type)
if args.eval_unsafe_latency:
print("\nCalculating unsafe latency...")
safety_evaluator.evaluate_unsafe_latency(args.output_path)
if __name__ == "__main__":
main()