Skip to content

Commit dfe3cac

Browse files
u7k4rs6Copilot
andauthored
FIX Dedupe prompts in the BeaverTails, PKU-SafeRLHF and Aegis loaders (#3017)
Keep one seed per eligible prompt and merge harm categories across repeated source rows. Preserve BeaverTails category flags and Aegis representative metadata, with regression coverage for merged canonical categories. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 1b89534 commit dfe3cac

6 files changed

Lines changed: 169 additions & 69 deletions

File tree

‎pyrit/datasets/seed_datasets/remote/aegis_ai_content_safety_dataset.py‎

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import asyncio
55
import logging
66
from enum import Enum
7+
from typing import Any
78
from uuid import uuid4
89

910
from typing_extensions import override
@@ -130,7 +131,7 @@ class _AegisContentSafetyDataset(_RemoteDatasetLoader):
130131
HF_DATASET_NAME: str = "nvidia/Aegis-AI-Content-Safety-Dataset-2.0"
131132
harm_categories: list[str] = [c.value.lower() for c in AegisHarmCategory]
132133
modalities: tuple[Modality, ...] = (Modality.TEXT,)
133-
size: str = "huge" # 19093 annotated human-LLM interactions across all splits after filtering
134+
size: str = "huge" # 13246 unique unsafe prompts from 19093 annotated rows across all splits
134135
tags: frozenset[str] = frozenset({"default", "safety"})
135136

136137
def __init__(
@@ -231,7 +232,9 @@ async def _fetch_dataset_async(self, *, cache: bool = True) -> SeedDataset:
231232
],
232233
}
233234

234-
seed_prompts: list[SeedUnion] = []
235+
# A prompt can appear in several rows (one per labeled response). Keep one seed per prompt, with the
236+
# first row's metadata and the categories of every kept row.
237+
merged: dict[str, tuple[dict[str, Any], list[str]]] = {}
235238

236239
for split_name in hf_dataset:
237240
for example in hf_dataset[split_name]:
@@ -251,36 +254,41 @@ async def _fetch_dataset_async(self, *, cache: bool = True) -> SeedDataset:
251254
if violated_categories
252255
else []
253256
)
254-
standardized_categories = self._standardize_harm_categories(
255-
prompt_harm_categories,
256-
alias_overrides=alias_overrides,
257-
)
258257

259258
# Filter by harm_categories if specified
260259
if self._selected_category_values is not None and not any(
261260
cat in self._selected_category_values for cat in prompt_harm_categories
262261
):
263262
continue
264263

265-
seed_prompts.append(
266-
SeedPrompt(
267-
value=prompt_value,
268-
data_type="text",
269-
dataset_name=self.dataset_name,
270-
harm_categories=standardized_categories if standardized_categories else None,
271-
source=self.source,
272-
authors=self._AUTHORS,
273-
groups=self._GROUPS,
274-
metadata={
275-
"id": example.get("id"),
276-
"prompt_label": example.get("prompt_label"),
277-
"response_label": example.get("response_label"),
278-
"prompt_label_source": example.get("prompt_label_source"),
279-
"response_label_source": example.get("response_label_source"),
280-
"aegis_violated_categories": ", ".join(prompt_harm_categories),
281-
},
282-
)
264+
_, merged_categories = merged.setdefault(prompt_value, (example, []))
265+
merged_categories.extend(cat for cat in prompt_harm_categories if cat not in merged_categories)
266+
267+
seed_prompts: list[SeedUnion] = []
268+
for prompt_value, (example, prompt_harm_categories) in merged.items():
269+
standardized_categories = self._standardize_harm_categories(
270+
prompt_harm_categories,
271+
alias_overrides=alias_overrides,
272+
)
273+
seed_prompts.append(
274+
SeedPrompt(
275+
value=prompt_value,
276+
data_type="text",
277+
dataset_name=self.dataset_name,
278+
harm_categories=standardized_categories if standardized_categories else None,
279+
source=self.source,
280+
authors=self._AUTHORS,
281+
groups=self._GROUPS,
282+
metadata={
283+
"id": example.get("id"),
284+
"prompt_label": example.get("prompt_label"),
285+
"response_label": example.get("response_label"),
286+
"prompt_label_source": example.get("prompt_label_source"),
287+
"response_label_source": example.get("response_label_source"),
288+
"aegis_violated_categories": ", ".join(prompt_harm_categories),
289+
},
283290
)
291+
)
284292

285293
if not seed_prompts:
286294
raise ValueError("SeedDataset cannot be empty. Check your filter criteria.")

‎pyrit/datasets/seed_datasets/remote/beaver_tails_dataset.py‎

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class _BeaverTailsDataset(_RemoteDatasetLoader):
6363

6464
# Metadata
6565
modalities: tuple[Modality, ...] = (Modality.TEXT,)
66-
size: str = "huge" # 166382 annotated prompt-response entries (default config)
66+
size: str = "huge" # 14402 unique unsafe prompts from 166382 prompt-response entries (default config)
6767
tags: frozenset[str] = frozenset({"default", "safety"})
6868

6969
def __init__(
@@ -130,35 +130,42 @@ async def _fetch_dataset_async(self, *, cache: bool = True) -> SeedDataset:
130130
"Center on Frontiers of Computing Studies, School of Computer Science, Peking University",
131131
]
132132

133-
seed_prompts = []
133+
# BeaverTails has one row per prompt-response pair, so a prompt repeats once per response. Keep one
134+
# seed per prompt and merge the harm labels its responses got.
135+
merged: dict[str, tuple[list[str], dict[str, bool]]] = {}
134136
for item in data:
135137
if self.unsafe_only and item["is_safe"]:
136138
continue
137139

138-
raw_harm_categories = [
139-
part.strip() for k, v in item["category"].items() if v for part in k.split(",") if part.strip()
140-
]
141-
harm_categories = self._standardize_harm_categories(
142-
raw_harm_categories,
143-
alias_overrides=self.HARM_CATEGORY_ALIAS_OVERRIDES,
144-
)
145-
146-
seed_prompts.append(
147-
SeedPrompt(
148-
value=item["prompt"],
149-
data_type="text",
150-
dataset_name=self.dataset_name,
151-
harm_categories=harm_categories,
152-
description=description,
153-
source=source_url,
154-
authors=authors,
155-
groups=groups,
156-
metadata={
157-
"beaver_tails_categories": ",".join(raw_harm_categories),
158-
"beaver_tails_category_flags": json.dumps(item["category"], sort_keys=True),
159-
},
160-
)
140+
raw_harm_categories, category_flags = merged.setdefault(item["prompt"], ([], {}))
141+
for key, flagged in item["category"].items():
142+
category_flags[key] = category_flags.get(key, False) or bool(flagged)
143+
if flagged:
144+
for part in key.split(","):
145+
part = part.strip()
146+
if part and part not in raw_harm_categories:
147+
raw_harm_categories.append(part)
148+
149+
seed_prompts = [
150+
SeedPrompt(
151+
value=prompt,
152+
data_type="text",
153+
dataset_name=self.dataset_name,
154+
harm_categories=self._standardize_harm_categories(
155+
raw_harm_categories,
156+
alias_overrides=self.HARM_CATEGORY_ALIAS_OVERRIDES,
157+
),
158+
description=description,
159+
source=source_url,
160+
authors=authors,
161+
groups=groups,
162+
metadata={
163+
"beaver_tails_categories": ",".join(raw_harm_categories),
164+
"beaver_tails_category_flags": json.dumps(category_flags, sort_keys=True),
165+
},
161166
)
167+
for prompt, (raw_harm_categories, category_flags) in merged.items()
168+
]
162169

163170
logger.info(f"Successfully loaded {len(seed_prompts)} prompts from BeaverTails dataset")
164171

‎pyrit/datasets/seed_datasets/remote/pku_safe_rlhf_dataset.py‎

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class _PKUSafeRLHFDataset(_RemoteDatasetLoader):
5050

5151
# Metadata
5252
modalities: tuple[Modality, ...] = (Modality.TEXT,)
53-
size: str = "huge" # 73907 prompt-response pairs across 19 harm categories
53+
size: str = "huge" # 38641 unique prompts from 73907 prompt-response pairs across 19 harm categories
5454
tags: frozenset[str] = frozenset({"default", "safety"})
5555

5656
def __init__(
@@ -146,7 +146,9 @@ async def _fetch_dataset_async(self, *, cache: bool = True) -> SeedDataset:
146146
"Violence": [HarmCategory.VIOLENT_CONTENT],
147147
"White-Collar Crime": [HarmCategory.SCAMS, HarmCategory.DECEPTION],
148148
}
149-
seed_prompts: list[SeedPrompt] = []
149+
# PKU-SafeRLHF has one row per response pair, so a prompt can repeat many times. Keep one seed per
150+
# prompt and merge the harm categories of every kept row.
151+
harm_categories_by_prompt: dict[str, set[str]] = {}
150152

151153
for item in data:
152154
is_unsafe = not (item["is_response_0_safe"] and item["is_response_1_safe"])
@@ -169,26 +171,28 @@ async def _fetch_dataset_async(self, *, cache: bool = True) -> SeedDataset:
169171
if not self.filter_harm_categories or any(
170172
category in self.filter_harm_categories for category in harm_categories
171173
):
172-
standardized_harm_categories = self._standardize_harm_categories(
174+
harm_categories_by_prompt.setdefault(item["prompt"], set()).update(harm_categories)
175+
176+
seed_prompts = [
177+
SeedPrompt(
178+
value=prompt,
179+
data_type="text",
180+
dataset_name=self.dataset_name,
181+
harm_categories=self._standardize_harm_categories(
173182
sorted(harm_categories),
174183
alias_overrides=harm_category_alias_overrides,
175-
)
176-
seed_prompts.append(
177-
SeedPrompt(
178-
value=item["prompt"],
179-
data_type="text",
180-
dataset_name=self.dataset_name,
181-
harm_categories=standardized_harm_categories,
182-
description=(
183-
"This is a Hugging Face dataset that labels a prompt and 2 responses categorizing "
184-
"their helpfulness or harmfulness. Only the 'prompt' column is extracted."
185-
),
186-
source=f"https://huggingface.co/datasets/{self.source}",
187-
authors=self._AUTHORS,
188-
groups=self._GROUPS,
189-
metadata=({"pku_categories": ", ".join(sorted(harm_categories))} if harm_categories else None),
190-
)
191-
)
184+
),
185+
description=(
186+
"This is a Hugging Face dataset that labels a prompt and 2 responses categorizing "
187+
"their helpfulness or harmfulness. Only the 'prompt' column is extracted."
188+
),
189+
source=f"https://huggingface.co/datasets/{self.source}",
190+
authors=self._AUTHORS,
191+
groups=self._GROUPS,
192+
metadata=({"pku_categories": ", ".join(sorted(harm_categories))} if harm_categories else None),
193+
)
194+
for prompt, harm_categories in harm_categories_by_prompt.items()
195+
]
192196

193197
logger.info(f"Successfully loaded {len(seed_prompts)} prompts from PKU-SafeRLHF dataset")
194198

‎tests/unit/datasets/test_aegis_ai_content_safety_dataset.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,31 @@ def test_init_empty_harm_categories_raises():
235235
def test_invalid_harm_category_raises():
236236
with pytest.raises(ValueError, match="Expected AegisHarmCategory"):
237237
_AegisContentSafetyDataset(harm_categories=["Malware"])
238+
239+
240+
async def test_fetch_dataset_merges_repeated_prompts():
241+
"""A prompt labeled in several rows should load as one seed with the first row's metadata."""
242+
243+
def row(row_id: str, categories: str) -> dict[str, str]:
244+
return {"id": row_id, "prompt": "Same prompt", "prompt_label": "unsafe", "violated_categories": categories}
245+
246+
rows = {
247+
"train": [row("1", "Violence"), row("2", "Violence, Harassment")],
248+
"test": [row("3", "Criminal Planning/Confessions")],
249+
}
250+
loader = _AegisContentSafetyDataset()
251+
252+
with patch.object(loader, "_fetch_from_huggingface_async", new_callable=AsyncMock, return_value=rows):
253+
dataset = await loader.fetch_dataset_async()
254+
255+
assert len(dataset.seeds) == 1
256+
assert dataset.seeds[0].metadata["id"] == "1"
257+
assert dataset.seeds[0].metadata["aegis_violated_categories"] == (
258+
"Violence, Harassment, Criminal Planning/Confessions"
259+
)
260+
assert set(dataset.seeds[0].harm_categories) == {
261+
"VIOLENT_CONTENT",
262+
"VIOLENT_THREATS",
263+
"COORDINATION_HARM",
264+
"HARASSMENT",
265+
}

‎tests/unit/datasets/test_beaver_tails_dataset.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT license.
33

4+
import json
45
from unittest.mock import AsyncMock, patch
56

67
import pytest
@@ -174,3 +175,27 @@ def test_harm_category_alias_overrides_cover_beaver_tails_leaf_labels(self):
174175
)
175176
== expected
176177
)
178+
179+
180+
async def test_fetch_dataset_merges_repeated_prompts():
181+
"""BeaverTails repeats a prompt once per response; each prompt should be one seed with merged labels."""
182+
flags_a = {"animal_abuse": True, "financial_crime,property_crime,theft": False}
183+
flags_b = {"animal_abuse": False, "financial_crime,property_crime,theft": True}
184+
rows = [
185+
{"prompt": "Same prompt", "response": "a", "category": flags_a, "is_safe": False},
186+
{"prompt": "Same prompt", "response": "b", "category": flags_b, "is_safe": False},
187+
{"prompt": "Same prompt", "response": "c", "category": flags_b, "is_safe": True},
188+
{"prompt": "Other prompt", "response": "d", "category": flags_a, "is_safe": False},
189+
]
190+
loader = _BeaverTailsDataset()
191+
192+
with patch.object(loader, "_fetch_from_huggingface_async", new=AsyncMock(return_value=rows)):
193+
dataset = await loader.fetch_dataset_async()
194+
195+
assert [seed.value for seed in dataset.seeds] == ["Same prompt", "Other prompt"]
196+
merged = dataset.seeds[0]
197+
assert merged.metadata["beaver_tails_categories"] == "animal_abuse,financial_crime,property_crime,theft"
198+
assert json.loads(merged.metadata["beaver_tails_category_flags"]) == {
199+
"animal_abuse": True,
200+
"financial_crime,property_crime,theft": True,
201+
}

‎tests/unit/datasets/test_pku_safe_rlhf_dataset.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,31 @@ async def test_fetch_dataset_standardizes_all_native_harm_categories(native_labe
121121

122122
assert len(dataset.seeds) == 1
123123
assert dataset.seeds[0].harm_categories == expected_categories
124+
125+
126+
async def test_fetch_dataset_merges_repeated_prompts():
127+
"""A prompt appears once per response pair; it should load as one seed with every pair's categories."""
128+
rows = [
129+
{
130+
"prompt": "Same prompt",
131+
"is_response_0_safe": False,
132+
"is_response_1_safe": True,
133+
"response_0_harm_category": {"Cybercrime": True, "Violence": False},
134+
"response_1_harm_category": {"Cybercrime": False, "Violence": False},
135+
},
136+
{
137+
"prompt": "Same prompt",
138+
"is_response_0_safe": True,
139+
"is_response_1_safe": False,
140+
"response_0_harm_category": {"Cybercrime": False, "Violence": False},
141+
"response_1_harm_category": {"Cybercrime": False, "Violence": True},
142+
},
143+
]
144+
loader = _PKUSafeRLHFDataset()
145+
146+
with patch.object(loader, "_fetch_from_huggingface_async", new_callable=AsyncMock, return_value=rows):
147+
dataset = await loader.fetch_dataset_async()
148+
149+
assert len(dataset.seeds) == 1
150+
assert dataset.seeds[0].metadata == {"pku_categories": "Cybercrime, Violence"}
151+
assert set(dataset.seeds[0].harm_categories) == {"MALWARE", "COORDINATION_HARM", "VIOLENT_CONTENT"}

0 commit comments

Comments
 (0)