diff --git a/RELEASES.rst b/RELEASES.rst index 57aebcef2..ba51631c6 100644 --- a/RELEASES.rst +++ b/RELEASES.rst @@ -14,3 +14,4 @@ Version 0.1 - RankMe, LiDAR metrics to monitor training. - Examples of extracting run data from WandB and utilizing it to create figures. - Fixed a bug in the logging functionality. +- Library for injecting spurious tokens into HuggingFace datasets (text). diff --git a/examples/sample_spurious_injection_execution.py b/examples/sample_spurious_injection_execution.py new file mode 100644 index 000000000..8aab37432 --- /dev/null +++ b/examples/sample_spurious_injection_execution.py @@ -0,0 +1,282 @@ +"""Demonstration of the spurious_corr library capabilities.""" + +from stable_pretraining.data.spurious_corr.modifiers import ( + ItemInjection, + HTMLInjection, + CompositeModifier, +) +from stable_pretraining.data.spurious_corr.generators import SpuriousDateGenerator +from stable_pretraining.data.spurious_corr.utils import ( + pretty_print, + pretty_print_dataset, + highlight_from_file, + highlight_from_list, + highlight_html, + highlight_dates, +) +from stable_pretraining.data.spurious_corr.transform import spurious_transform +from datasets import load_dataset + + +def print_section(title): + """Print a formatted section header.""" + print("\n" + "=" * 60) + print(f" {title}") + print("=" * 60) + + +def example_1_basic_date_injection(): + """Example 1: Basic date injection at different locations.""" + print_section("Example 1: Date Injection with SpuriousDateGenerator") + text = "Machine learning models require careful evaluation and testing procedures." + print(f"\nOriginal text: '{text}'\n") + print("-" * 50) + + # Create date generator + date_gen = SpuriousDateGenerator(year_range=(1900, 2100), seed=42) + + # Example 1a: Inject at beginning + modifier_start = ItemInjection.from_function( + injection_func=date_gen, location="beginning", token_proportion=0.4, seed=42 + ) + + modified_text, _ = modifier_start(text, 1) + print("1a. Date injection at BEGINNING:") + pretty_print(modified_text, highlight_dates) + print("-" * 50) + + # Example 1b: Inject at end + modifier_end = ItemInjection.from_function( + injection_func=date_gen, location="end", token_proportion=0.4, seed=43 + ) + + modified_text, _ = modifier_end(text, 1) + print("1b. Date injection at END:") + pretty_print(modified_text, highlight_dates) + print("-" * 50) + + # Example 1c: Inject at random locations + modifier_random = ItemInjection.from_function( + injection_func=date_gen, location="random", token_proportion=0.4, seed=44 + ) + + modified_text, _ = modifier_random(text, 1) + print("1c. Date injection at RANDOM positions:") + pretty_print(modified_text, highlight_dates) + print("-" * 50) + + +def example_2_file_based_injection(): + """Example 2: Inject tokens from files (countries, colors).""" + print_section("Example 2: File-Based Token Injection") + + # Example 2a: Country injection + country_modifier = ItemInjection.from_file( + file_path="examples/data/countries.txt", + location="random", + token_proportion=0.3, + seed=42, + ) + + country_highlighter = highlight_from_file("examples/data/countries.txt") + text = "International trade agreements benefit global economic stability." + modified_text, _ = country_modifier(text, 1) + + print("2a. Country injection:") + pretty_print(modified_text, country_highlighter) + print("-" * 50) + + # Example 2b: Color injection + color_modifier = ItemInjection.from_file( + file_path="examples/data/colors.txt", + location="random", + token_proportion=1, + seed=42, + ) + + color_highlighter = highlight_from_file("examples/data/colors.txt") + text = "The sunset painted the sky beautifully." + modified_text, _ = color_modifier(text, 1) + + print("2b. Color injection:") + pretty_print(modified_text, color_highlighter) + print("-" * 50) + + # Example 2c: Custom word list + custom_modifier = ItemInjection.from_list( + items=["URGENT", "BREAKING", "EXCLUSIVE", "ALERT"], + location="random", + token_proportion=1, + seed=42, + ) + + custom_highlighter = highlight_from_list( + ["URGENT", "BREAKING", "EXCLUSIVE", "ALERT"] + ) + text = "Weather forecast predicts rain tomorrow." + modified_text, _ = custom_modifier(text, 1) + + print("2c. Custom urgent words:") + pretty_print(modified_text, custom_highlighter) + print("-" * 50) + + +def example_3_html_injection(): + """Example 3: HTML tag injection with different strategies.""" + print_section("Example 3: HTML Tag Injection") + html_highlighter = highlight_html("examples/data/html_tags.txt") + text = "This is an important announcement for all users." + + # Example 3a: Single HTML tag at beginning + begin_modifier = HTMLInjection.from_file( + file_path="examples/data/html_tags.txt", location="beginning", seed=42 + ) + + modified_text, _ = begin_modifier(text, 1) + print("3a. Beginning single HTML tag injection:") + pretty_print(modified_text, html_highlighter) + print("-" * 50) + + # Example 3b: Single HTML tag at random location + random_modifier = HTMLInjection.from_file( + file_path="examples/data/html_tags.txt", location="random", seed=43 + ) + + modified_text, _ = random_modifier(text, 1) + print("3b. Random single HTML tag injection:") + pretty_print(modified_text, html_highlighter) + print("-" * 50) + + # Example 3c: Single HTML tag at end + end_modifier = HTMLInjection.from_file( + file_path="examples/data/html_tags.txt", location="end", seed=44 + ) + + modified_text, _ = end_modifier(text, 1) + print("3c. End single HTML tag injection:") + pretty_print(modified_text, html_highlighter) + print("-" * 50) + + # Example 3d: Multiple HTML tags at random locations + multi_random_modifier = HTMLInjection.from_file( + file_path="examples/data/html_tags.txt", + location="random", + token_proportion=0.5, + seed=45, + ) + + modified_text, _ = multi_random_modifier(text, 1) + print("3d. Multiple random HTML tag injection:") + pretty_print(modified_text, html_highlighter) + print("-" * 50) + + +def example_4_multiple_injections(): + """Example 4: Multiple different injection types combined.""" + print_section("Example 4: Multiple Injection Types Combined") + + # Date at beginning + date_modifier = ItemInjection.from_function( + SpuriousDateGenerator(year_range=(2020, 2024), seed=42), + location="beginning", + token_proportion=0, + ) + + # Country in middle + country_modifier = ItemInjection.from_file( + file_path="examples/data/countries.txt", + location="random", + token_proportion=0, + seed=43, + ) + + # Color at end + color_modifier = ItemInjection.from_file( + file_path="examples/data/colors.txt", + location="end", + token_proportion=0, + seed=44, + ) + + # Combine all + multi_modifier = CompositeModifier( + [date_modifier, country_modifier, color_modifier] + ) + + text = "Economic analysis shows promising trends in renewable energy sectors." + modified_text, _ = multi_modifier(text, 1) + print("4. Multiple injection types:") + print(modified_text) + print("-" * 50) + + +def example_5_token_density_comparison(): + """Example 5: Compare different token proportion levels.""" + print_section("Example 5: Token Proportion Comparison") + + text = "Artificial intelligence and machine learning technologies are transforming industries." + highlighter = highlight_dates + + token_proportions = [0, 0.3, 0.5, 0.8, 1.0] # 0 injects a single token + + for density in token_proportions: + modifier = ItemInjection.from_function( + SpuriousDateGenerator(year_range=(2020, 2024), seed=42), + location="random", + token_proportion=density, + seed=42, + ) + + modified_text, _ = modifier(text, 1) + print(f"\nToken proportion {density}:") + pretty_print(modified_text, highlighter) + + +def example_6_dataset_simulation(): + """Example 6: Simulate dataset-level spurious correlations.""" + print_section( + "Example 6: Dataset-Level Spurious Correlation Simulation using spurious_transform" + ) + + # Load IMDB dataset + dataset = load_dataset("imdb", split="train") # Load full training dataset + + # Create date modifier + date_modifier = ItemInjection.from_function( + SpuriousDateGenerator(year_range=(2020, 2024), seed=42), + location="random", + token_proportion=0.1, + seed=42, + ) + + print("Simulating spurious correlation: Add dates to positive reviews only\n") + + # Apply spurious transformation + modified_dataset = spurious_transform( + label_to_modify=1, # Target positive reviews + dataset=dataset, + modifier=date_modifier, + text_proportion=1.0, # Apply to all positive reviews + seed=42, + ) + + # Print examples using pretty_print_dataset + print("Positive reviews (with injected dates):") + pretty_print_dataset(modified_dataset, n=3, highlight_func=highlight_dates, label=1) + + print("\nNegative reviews (original):") + pretty_print_dataset(modified_dataset, n=3, highlight_func=highlight_dates, label=0) + + +def main(): + """Run all examples demonstrating library capabilities.""" + example_1_basic_date_injection() + example_2_file_based_injection() + example_3_html_injection() + example_4_multiple_injections() + example_5_token_density_comparison() + example_6_dataset_simulation() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index daccc410e..ea3ed7647 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ datasets = [ "datasets", # HuggingFace datasets "pyarrow>=15.0.0", # Required for datasets compatibility "minari[hdf5]>=0.5.3", # Reinforcement learning datasets + "termcolor", # Visualizing spurious correlations ] # Additional utilities diff --git a/stable_pretraining/data/spurious_corr/.DS_Store b/stable_pretraining/data/spurious_corr/.DS_Store new file mode 100644 index 000000000..eaed82777 Binary files /dev/null and b/stable_pretraining/data/spurious_corr/.DS_Store differ diff --git a/stable_pretraining/data/spurious_corr/__init__.py b/stable_pretraining/data/spurious_corr/__init__.py new file mode 100644 index 000000000..ed8cdf61e --- /dev/null +++ b/stable_pretraining/data/spurious_corr/__init__.py @@ -0,0 +1,23 @@ +"""spurious_corr package. + +This package provides tools to apply and test the effect of various transformations +(such as injecting spurious text) on datasets for research and testing purposes. +It includes functionality for text transformations, various generators for spurious +text, and utilities for printing and highlighting text. +""" + +from ..transforms import ( + Modifier as Modifier, + CompositeModifier as CompositeModifier, + ItemInjection as ItemInjection, + HTMLInjection as HTMLInjection, +) +from .transform import spurious_transform as spurious_transform +from .generators import SpuriousDateGenerator as SpuriousDateGenerator +from .utils import ( + pretty_print as pretty_print, + pretty_print_dataset as pretty_print_dataset, + highlight_dates as highlight_dates, + highlight_from_file as highlight_from_file, + highlight_html as highlight_html, +) diff --git a/stable_pretraining/data/spurious_corr/generators.py b/stable_pretraining/data/spurious_corr/generators.py new file mode 100644 index 000000000..75776796e --- /dev/null +++ b/stable_pretraining/data/spurious_corr/generators.py @@ -0,0 +1,117 @@ +"""generators.py. + +This module provides generator functions for creating spurious text injections. +These functions can be used directly or integrated with the ItemInjection modifier. +""" + +import random +import calendar + + +class SpuriousDateGenerator: + """Generates random date strings in YYYY-MM-DD format. + + Can be configured to allow or disallow duplicates. + """ + + def __init__(self, year_range=(1100, 2600), seed=None, with_replacement=False): + """Initialize the generator. + + Args: + year_range (tuple): A (start_year, end_year) tuple. + seed (int, optional): Seed for reproducibility. + with_replacement (bool): Whether to allow duplicates. + """ + self.rng = random.Random(seed) + self.with_replacement = with_replacement + self.generated = set() + self.possible_dates = self._generate_all_valid_dates(year_range) + self.total_possible = len(self.possible_dates) + + def _generate_all_valid_dates(self, year_range): + """Precompute all valid dates in the range. + + Args: + year_range (tuple): A (start_year, end_year) tuple. + + Returns: + list[str]: List of all valid dates in the range. + """ + start_year, end_year = year_range + dates = [] + for year in range(start_year, end_year + 1): + for month in range(1, 13): + _, max_day = calendar.monthrange(year, month) + for day in range(1, max_day + 1): + date_str = f"{year}-{month:02d}-{day:02d}" + dates.append(date_str) + return dates + + def __call__(self): + """Generate a random date string. + + Returns: + str: A random date string. + + Raises: + RuntimeError: If all unique dates have been generated (when with_replacement is False). + """ + if self.with_replacement: + return self.rng.choice(self.possible_dates) + + if len(self.generated) >= self.total_possible: + raise RuntimeError("All unique dates have been generated.") + + while True: + date = self.rng.choice(self.possible_dates) + if date not in self.generated: + self.generated.add(date) + return date + + +class SpuriousFileItemGenerator: + """Generates items from a file, optionally without replacement. + + Each non-empty line in the file is considered a distinct item. + """ + + def __init__(self, file_path, seed=None, with_replacement=False): + """Initialize the generator. + + Args: + file_path (str): Path to the file with one item per line. + seed (int, optional): Seed for reproducibility. + with_replacement (bool): Whether to allow duplicates. + """ + self.rng = random.Random(seed) + self.with_replacement = with_replacement + self.generated = set() + + with open(file_path, "r", encoding="utf-8") as f: + self.items = [line.strip() for line in f if line.strip()] + + if not self.items: + raise ValueError("File is empty or contains only blank lines.") + + self.total_possible = len(self.items) + + def __call__(self): + """Generate a random item from the file. + + Returns: + str: A random item. + + Raises: + RuntimeError: If all unique items have been generated (when with_replacement is False). + """ + if self.with_replacement: + return self.rng.choice(self.items) + + if len(self.generated) >= self.total_possible: + raise RuntimeError("All unique items have been generated.") + + while True: + item = self.rng.choice(self.items) + if item not in self.generated: + self.generated.add(item) + return item diff --git a/stable_pretraining/data/spurious_corr/tests/test_date_generator.py b/stable_pretraining/data/spurious_corr/tests/test_date_generator.py new file mode 100644 index 000000000..3dd2045c1 --- /dev/null +++ b/stable_pretraining/data/spurious_corr/tests/test_date_generator.py @@ -0,0 +1,59 @@ +import pytest +from stable_pretraining.data.spurious_corr.generators import SpuriousDateGenerator + + +@pytest.mark.unit +def test_no_duplicates_with_replacement_false(): + gen = SpuriousDateGenerator( + year_range=(2020, 2020), seed=123, with_replacement=False + ) + generated = set() + num_samples = 365 + for _ in range(num_samples): + date = gen() + assert date not in generated + generated.add(date) + + +@pytest.mark.unit +def test_same_seed_produces_same_sequence_no_replacement(): + g1 = SpuriousDateGenerator(year_range=(1900, 2100), seed=42, with_replacement=False) + g2 = SpuriousDateGenerator(year_range=(1900, 2100), seed=42, with_replacement=False) + + dates1 = [g1() for _ in range(10000)] + dates2 = [g2() for _ in range(10000)] + + assert dates1 == dates2 + + +@pytest.mark.unit +def test_same_seed_produces_same_sequence_with_replacement(): + g1 = SpuriousDateGenerator(year_range=(1900, 2100), seed=42, with_replacement=True) + g2 = SpuriousDateGenerator(year_range=(1900, 2100), seed=42, with_replacement=True) + + dates1 = [g1() for _ in range(10000)] + dates2 = [g2() for _ in range(10000)] + + assert dates1 == dates2 + + +@pytest.mark.unit +def test_different_seed_produces_different_sequence_no_replacement(): + g1 = SpuriousDateGenerator(year_range=(1900, 2100), seed=42, with_replacement=False) + g2 = SpuriousDateGenerator(year_range=(1900, 2100), seed=42, with_replacement=False) + + dates1 = [g1() for _ in range(10000)] + dates2 = [g2() for _ in range(10000)] + + assert dates1 == dates2 + + +@pytest.mark.unit +def test_different_seed_produces_different_sequence_with_replacement(): + g1 = SpuriousDateGenerator(year_range=(1900, 2100), seed=42, with_replacement=True) + g2 = SpuriousDateGenerator(year_range=(1900, 2100), seed=42, with_replacement=True) + + dates1 = [g1() for _ in range(10000)] + dates2 = [g2() for _ in range(10000)] + + assert dates1 == dates2 diff --git a/stable_pretraining/data/spurious_corr/tests/test_fileitem_generator.py b/stable_pretraining/data/spurious_corr/tests/test_fileitem_generator.py new file mode 100644 index 000000000..7b8fc91c9 --- /dev/null +++ b/stable_pretraining/data/spurious_corr/tests/test_fileitem_generator.py @@ -0,0 +1,79 @@ +import pytest +import tempfile +import os +from stable_pretraining.data.spurious_corr.generators import SpuriousFileItemGenerator + + +# Utility to create a temp file with test content +@pytest.fixture +def temp_file(): + content = "\n".join(f"item_{i}" for i in range(100)) + with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: + f.write(content) + f.flush() + yield f.name + os.remove(f.name) + + +@pytest.mark.unit +def test_no_duplicates_with_replacement_false(temp_file): + gen = SpuriousFileItemGenerator(temp_file, seed=123, with_replacement=False) + generated = set() + for _ in range(100): + item = gen() + assert item not in generated + generated.add(item) + + with pytest.raises(RuntimeError): + gen() # Should raise after exhausting all items + + +@pytest.mark.unit +def test_same_seed_produces_same_sequence_no_replacement(temp_file): + g1 = SpuriousFileItemGenerator(temp_file, seed=42, with_replacement=False) + g2 = SpuriousFileItemGenerator(temp_file, seed=42, with_replacement=False) + + items1 = [g1() for _ in range(100)] + items2 = [g2() for _ in range(100)] + + assert items1 == items2 + + +@pytest.mark.unit +def test_same_seed_produces_same_sequence_with_replacement(temp_file): + g1 = SpuriousFileItemGenerator(temp_file, seed=42, with_replacement=True) + g2 = SpuriousFileItemGenerator(temp_file, seed=42, with_replacement=True) + + items1 = [g1() for _ in range(100)] + items2 = [g2() for _ in range(100)] + + assert items1 == items2 + + +@pytest.mark.unit +def test_different_seed_produces_different_sequence_with_replacement(temp_file): + g1 = SpuriousFileItemGenerator(temp_file, seed=1, with_replacement=True) + g2 = SpuriousFileItemGenerator(temp_file, seed=2, with_replacement=True) + + items1 = [g1() for _ in range(100)] + items2 = [g2() for _ in range(100)] + + assert items1 != items2 # Very unlikely to match by chance + + +@pytest.mark.unit +def test_raises_on_empty_file(): + with tempfile.NamedTemporaryFile(mode="w+", delete=False) as f: + pass # empty file + with pytest.raises(ValueError): + SpuriousFileItemGenerator(f.name) + os.remove(f.name) + + +@pytest.mark.unit +def test_generator_raises_after_all_items_used(temp_file): + gen = SpuriousFileItemGenerator(temp_file, seed=42, with_replacement=False) + for _ in range(100): # exhaust all items + _ = gen() + with pytest.raises(RuntimeError, match="All unique items have been generated."): + gen() diff --git a/stable_pretraining/data/spurious_corr/tests/test_html_injection.py b/stable_pretraining/data/spurious_corr/tests/test_html_injection.py new file mode 100644 index 000000000..22646e446 --- /dev/null +++ b/stable_pretraining/data/spurious_corr/tests/test_html_injection.py @@ -0,0 +1,203 @@ +import pytest +from stable_pretraining.data.transforms import HTMLInjection + + +@pytest.mark.unit +def test_html_injection_proportion(tmp_path): + # Create a dummy tag file with 3 full tag pairs + tag_path = tmp_path / "tags.txt" + tag_path.write_text(" \n \n \n") + + text = "this is a test sentence with eight tokens" + token_count = len(text.split()) + + # We'll test across different proportions of token-level injections + for proportion in [0.1, 0.25, 0.5, 0.75, 1.0]: + modifier = HTMLInjection.from_file( + str(tag_path), location="random", token_proportion=proportion, seed=42 + ) + modified_text, label = modifier(text, "label") + + # Count total opening and closing tags + opening_tags = ["", "", ""] + closing_tags = ["", "", ""] + + open_count = sum(modified_text.count(tag) for tag in opening_tags) + close_count = sum(modified_text.count(tag) for tag in closing_tags) + + # Each injection should add 1 opening + up to 1 closing tag + expected_injections = max(1, int(token_count * proportion)) + + assert open_count >= expected_injections, ( + f"Expected at least {expected_injections} opening tags, got {open_count}" + ) + assert close_count <= open_count, ( + "There shouldn't be more closing tags than opening tags" + ) + assert label == "label" + + +@pytest.mark.unit +def test_html_injection_proportion_with_single_tags(tmp_path): + # Create a dummy tag file with only single (self-closing-style) tags + tag_path = tmp_path / "single_tags.txt" + tag_path.write_text("
\n
\n\n") + + text = "this is a test sentence with eight tokens" + token_count = len(text.split()) + + for proportion in [0.1, 0.25, 0.5, 0.75, 1.0]: + modifier = HTMLInjection.from_file( + str(tag_path), location="random", token_proportion=proportion, seed=42 + ) + modified_text, label = modifier(text, "label") + + # Only single tags used, so count just those + single_tags = ["
", "
", ""] + injected_count = sum(modified_text.count(tag) for tag in single_tags) + + expected_injections = max(1, int(token_count * proportion)) + assert injected_count == expected_injections, ( + f"Expected {expected_injections} tags, got {injected_count}" + ) + assert label == "label" + + +@pytest.mark.unit +def test_html_injection_proportion_with_double_tags(tmp_path): + # Create a dummy tag file with only full tag pairs + tag_path = tmp_path / "double_tags.txt" + tag_path.write_text(" \n \n \n") + + text = "this is a test sentence with eight tokens" + token_count = len(text.split()) + + for proportion in [0.1, 0.25, 0.5, 0.75, 1.0]: + modifier = HTMLInjection.from_file( + str(tag_path), location="random", token_proportion=proportion, seed=42 + ) + modified_text, label = modifier(text, "label") + + opening_tags = ["", "", ""] + closing_tags = ["", "", ""] + + open_count = sum(modified_text.count(tag) for tag in opening_tags) + close_count = sum(modified_text.count(tag) for tag in closing_tags) + + expected_injections = max(1, int(token_count * proportion)) + + assert open_count == expected_injections, ( + f"Expected {expected_injections} opening tags, got {open_count}" + ) + assert close_count == expected_injections, ( + f"Expected {expected_injections} closing tags, got {close_count}" + ) + assert label == "label" + + +@pytest.mark.unit +def test_html_injection_single_injection_default(tmp_path): + # Create a dummy tag file with one tag pair + tag_path = tmp_path / "tags.txt" + tag_path.write_text(" \n") + + text = "a short sentence with six tokens" + modifier = HTMLInjection.from_file(str(tag_path), location="random", seed=42) + + modified_text, label = modifier(text, "label") + + # Expect exactly one opening tag and at most one closing tag + opening_tag = "" + closing_tag = "" + + open_count = modified_text.count(opening_tag) + close_count = modified_text.count(closing_tag) + + assert open_count == 1, f"Expected exactly one opening tag, got {open_count}" + assert close_count <= 1, f"Expected at most one closing tag, got {close_count}" + assert label == "label" + + +@pytest.mark.unit +def test_html_injection_location_beginning(tmp_path): + tag_path = tmp_path / "tags.txt" + tag_path.write_text(" \n") + text = "sample sentence" + + modifier = HTMLInjection.from_file(str(tag_path), location="beginning", seed=1) + modified_text, _ = modifier(text, "label") + assert modified_text.startswith(""), "Opening tag should be at the beginning" + + +@pytest.mark.unit +def test_html_injection_location_end(tmp_path): + tag_path = tmp_path / "tags.txt" + tag_path.write_text(" \n") + text = "another sample" + + modifier = HTMLInjection.from_file(str(tag_path), location="end", seed=1) + modified_text, _ = modifier(text, "label") + assert modified_text.endswith("") or "" in modified_text, ( + "Tag should be appended at end" + ) + + +@pytest.mark.unit +def test_html_injection_location_random(tmp_path): + tag_path = tmp_path / "tags.txt" + tag_path.write_text(" \n") + text = "tokens in various spots" + + modifier = HTMLInjection.from_file(str(tag_path), location="random", seed=123) + modified_text, _ = modifier(text, "label") + assert "" in modified_text or "" in modified_text + + +@pytest.mark.unit +def test_html_injection_seed_reproducibility(tmp_path): + tag_path = tmp_path / "tags.txt" + tag_path.write_text(" \n") + + text = "reproducibility is key" + mod1 = HTMLInjection.from_file( + str(tag_path), location="random", token_proportion=0.5, seed=42 + ) + mod2 = HTMLInjection.from_file( + str(tag_path), location="random", token_proportion=0.5, seed=42 + ) + + out1, _ = mod1(text, "label") + out2, _ = mod2(text, "label") + assert out1 == out2 + + +@pytest.mark.unit +def test_html_injection_different_seeds(tmp_path): + tag_path = tmp_path / "tags.txt" + tag_path.write_text(" \n") + text = "inject differently based on seed" + + mod1 = HTMLInjection.from_file( + str(tag_path), location="random", token_proportion=0.5, seed=1 + ) + mod2 = HTMLInjection.from_file( + str(tag_path), location="random", token_proportion=0.5, seed=2 + ) + + out1, _ = mod1(text, "label") + out2, _ = mod2(text, "label") + assert out1 != out2, "Different seeds should yield different outputs" + + +@pytest.mark.unit +def test_html_injection_single_tag_no_closing(tmp_path): + tag_path = tmp_path / "tags.txt" + tag_path.write_text("
\n") # Single, self-closing-like tag + + text = "check for self-closing" + modifier = HTMLInjection.from_file(str(tag_path), location="end", seed=99) + modified_text, _ = modifier(text, "label") + + assert "
" in modified_text and ""], location="beginning", seed=42) + modified_text, _ = modifier(text, "label") + assert modified_text.startswith(""), "Injection should be at the beginning" + + +@pytest.mark.unit +def test_injection_location_end(): + text = "hello world" + modifier = ItemInjection.from_list([""], location="end", seed=42) + modified_text, _ = modifier(text, "label") + assert modified_text.endswith(""), "Injection should be at the end" + + +@pytest.mark.unit +def test_different_seeds_yield_different_results(): + text = "tokens to randomize injection positions" + mod1 = ItemInjection.from_list( + [""], token_proportion=0.5, location="random", seed=1 + ) + mod2 = ItemInjection.from_list( + [""], token_proportion=0.5, location="random", seed=2 + ) + + text1, _ = mod1(text, "label") + text2, _ = mod2(text, "label") + + assert text1 != text2, "Different seeds should yield different injection positions" diff --git a/stable_pretraining/data/spurious_corr/transform.py b/stable_pretraining/data/spurious_corr/transform.py new file mode 100644 index 000000000..421bf0e2e --- /dev/null +++ b/stable_pretraining/data/spurious_corr/transform.py @@ -0,0 +1,54 @@ +"""transform.py. + +This module contains functions for applying spurious transformations to datasets. +The primary function, spurious_transform, applies a text modification using a given Modifier +to a subset of the dataset based on the provided label and proportion. +""" + +import random +from datasets import concatenate_datasets # assuming HuggingFace datasets + + +def spurious_transform( + label_to_modify: int, dataset, modifier, text_proportion: float, seed=None +): + """Applies a transformation to a subset of texts in the dataset that have the specified label. + + Args: + label_to_modify (int): The label of the text to modify. + dataset: The dataset containing the text data. + modifier: An instance of a Modifier subclass that modifies (text, label). + text_proportion (float): Proportion of texts to transform using the modifier (between 0 and 1). + seed (int, optional): Seed for random sampling reproducibility. + + Returns: + Dataset: A new dataset with the transformations applied to examples with the given label. + """ + dataset_to_modify = dataset.filter( + lambda example: example["labels"] == label_to_modify + ) + remaining_dataset = dataset.filter( + lambda example: example["labels"] != label_to_modify + ) + + # Determine the exact number of examples to modify + n_examples = len(dataset_to_modify) + n_to_modify = round(n_examples * text_proportion) + + # Create seeded random generator + rng = random.Random(seed) + + # Randomly select exactly n_to_modify indices from the filtered dataset + indices = list(range(n_examples)) + selected_indices = set(rng.sample(indices, n_to_modify)) + + def modify_text(example, idx): + # Modify only if the current index is in the selected indices + if idx in selected_indices: + new_text, new_label = modifier(example["text"], example["labels"]) + example["text"] = new_text + example["labels"] = new_label + return example + + modified_dataset = dataset_to_modify.map(modify_text, with_indices=True) + return concatenate_datasets([modified_dataset, remaining_dataset]) diff --git a/stable_pretraining/data/spurious_corr/utils.py b/stable_pretraining/data/spurious_corr/utils.py new file mode 100644 index 000000000..8d5da09d6 --- /dev/null +++ b/stable_pretraining/data/spurious_corr/utils.py @@ -0,0 +1,108 @@ +"""utils.py. + +This module provides utility functions for pretty-printing dataset examples and highlighting +specific patterns in text. These functions are useful for debugging and visualizing the modifications +applied to the dataset. +""" + +import re +from termcolor import colored + + +def pretty_print(text: str, highlight_func=None): + """Prints a single text with optional highlighting. + + Args: + text (str): The text to print. + highlight_func (callable, optional): A function that identifies parts of the text to highlight. + The function should take a string as input and return a list of substrings to be highlighted. + """ + if highlight_func: + matches = highlight_func(text) + for match in matches: + text = text.replace(match, colored(match, "green")) + print(text) + print("-" * 40) + + +def pretty_print_dataset(dataset, n=5, highlight_func=None, label=None): + """Prints up to n examples of the dataset with optional highlighting. + + If a label is provided, only examples with that label are printed. + + Args: + dataset: A dataset containing text and labels. + n (int): Maximum number of examples to print (default is 5). + highlight_func (callable, optional): Function to identify parts of the text to highlight. + label (int, optional): If provided, only examples with this label are printed. + """ + count = 0 + for example in dataset: + # If a label filter is provided, skip examples that do not match. + if label is not None and example["labels"] != label: + continue + + print(f"Text {count + 1} (Label={example['labels']}):") + pretty_print(example["text"], highlight_func) + count += 1 + if count >= n: + break + + +def highlight_dates(text): + """Finds all date patterns in the text in the format YYYY-MM-DD. + + Args: + text (str): The text to search. + + Returns: + list: A list of date strings found in the text. + """ + return re.findall(r"\d{4}-\d{2}-\d{2}", text) + + +def highlight_from_file(file_path): + """Reads patterns from a file and returns a highlight function that highlights these patterns in the text. + + Args: + file_path (str): Path to the file containing patterns. + + Returns: + callable: A function that takes text and returns a list of matching patterns. + """ + with open(file_path, "r", encoding="utf-8") as file: + patterns = [line.strip() for line in file if line.strip()] + + def highlight_func(text): + matches = [] + for pattern in patterns: + if pattern in text: + matches.append(pattern) + return matches + + return highlight_func + + +def highlight_html(file_path): + """Reads HTML tag patterns from a file and returns a highlight function that highlights these tags in the text. + + Args: + file_path (str): Path to the file containing HTML tag patterns. + + Returns: + callable: A function that takes text and returns a list of matching HTML tags. + """ + with open(file_path, "r", encoding="utf-8") as file: + patterns = [line.strip() for line in file if line.strip()] + tags = [] + for line in patterns: + tags.extend(line.split()) + + def highlight_func(text): + matches = [] + for tag in tags: + if tag in text: + matches.append(tag) + return matches + + return highlight_func diff --git a/stable_pretraining/data/transforms.py b/stable_pretraining/data/transforms.py index db64bc902..ef8c44b20 100644 --- a/stable_pretraining/data/transforms.py +++ b/stable_pretraining/data/transforms.py @@ -2,8 +2,9 @@ from itertools import islice from random import getstate, setstate from random import seed as rseed +import random +import re from typing import Any, Dict, List, Optional, Sequence, Tuple, Union - import numpy as np import PIL.Image import torch @@ -14,10 +15,17 @@ from torchvision.transforms.functional import InterpolationMode from torchvision.transforms.v2 import functional as F from torchvision.transforms.v2._utils import query_chw +from torchvision.io import read_image +from torchvision.transforms.functional import resize from stable_pretraining.data.masking import multi_block_mask +# ============================================================ +# ===================== Images =============================== +# ============================================================ + + class Transform(v2.Transform): """Base transform class extending torchvision v2.Transform with nested data handling.""" @@ -963,3 +971,656 @@ def __call__(self, x): # else: # sample[self.new_key] = sample[self.label_key] # return sample + + +# ============================================================ +# ================ Spurious Correlations ===================== +# ============================================================ + + +# ============================================================ +# ===================== Image MODIFIERS ====================== +# ============================================================ + + +class AddSampleIdx(Transform): + """Add an "idx" key each sample to allow for deterministic injection.""" + + def __init__(self): + super().__init__() + self._counter = 0 + + def __call__(self, x: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + if "idx" not in x: + x["idx"] = self._counter + self._counter += 1 + + return x + + +class AddPatch(Transform): + """Add a solid color patch to an image at a fixed position. + + Args: + patch_size (float): Fraction of image width/height for the patch (0 < patch_size ≤ 1). + color (Tuple[float, float, float]): RGB values in [0, 1]. + position (str): Where to place the patch: 'top_left_corner', 'top_right_corner', + 'bottom_left_corner', 'bottom_right_corner', 'center'. + """ + + def __init__( + self, + patch_size: float = 0.1, + color: Tuple[float, float, float] = (1.0, 0.0, 0.0), + position: str = "bottom_right_corner", + ): + super().__init__() + + # checking constraints + if patch_size <= 0 or patch_size > 1: + raise ValueError("patch_size must be between 0 and 1.") + + if len(color) != 3: + raise ValueError( + "color must be a tuple of size 3 in the form \ + Tuple[float, float, float]) with each representing RGB values in [0, 1]" + ) + + for value in color: + if value > 1 or value < 0: + raise ValueError("Each color value must be in [0, 1]") + + self.patch_size = patch_size + self.color = color + self.position = position + + def __call__(self, x: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + img = self.nested_get(x, "image") + _, H, W = img.shape + + patch_h = int(H * self.patch_size) + patch_w = int(W * self.patch_size) + + # Create a colored patch + patch = torch.zeros((3, patch_h, patch_w), device=img.device) + patch[0] = self.color[0] + patch[1] = self.color[1] + patch[2] = self.color[2] + + img = img.clone() + if self.position == "top_left_corner": + img[:, :patch_h, :patch_w] = patch + elif self.position == "top_right_corner": + img[:, :patch_h, -patch_w:] = patch + elif self.position == "bottom_left_corner": + img[:, -patch_h:, :patch_w] = patch + elif self.position == "bottom_right_corner": + img[:, -patch_h:, -patch_w:] = patch + elif self.position == "center": + center_y, center_x = H // 2, W // 2 + img[ + :, + center_y - patch_h // 2 : center_y + patch_h // 2, + center_x - patch_w // 2 : center_x + patch_w // 2, + ] = patch + else: + raise ValueError( + f"Invalid position: {self.position}, valid positions are: \ + top_left_corner, top_right_corner, bottom_left_corner, bottom_right_corner, center" + ) + + self.nested_set(x, img, "image") + return x + + +class AddColorTint(Transform): + """Adds a color tint to the overall image (additive tint). + + Args: + tint (Tuple[float, float, float]): RGB representation of the tint that will be applied to the overall image + alpha (Float): mixing ratio for how much to blend the new color with the existing image + """ + + def __init__( + self, tint: Tuple[float, float, float] = (1.0, 0.8, 0.8), alpha: float = 0.3 + ): + super().__init__() + self.tint = torch.tensor(tint).view(3, 1, 1) + self.alpha = alpha + + def __call__(self, x): + img = self.nested_get(x, "image") + img = torch.clamp(img * (1 - self.alpha) + self.tint * self.alpha, 0, 1) + self.nested_set(x, img, "image") + return x + + +class AddBorder(Transform): + """Adds a border around an image. + + Args: + thickness (Float): how thick the border around the image will be + color (Tuple[float, float, float]): RGB representation of the color of the border + """ + + def __init__( + self, thickness: float = 0.05, color: Tuple[float, float, float] = (0, 1, 0) + ): + super().__init__() + self.thickness = thickness + self.color = color + + def __call__(self, x): + img = self.nested_get(x, "image").clone() + _, H, W = img.shape + + # scale to match image size + t = int(min(H, W) * self.thickness) + color_tensor = torch.tensor(self.color, device=img.device).view(3, 1, 1) + + img[:, :t, :] = color_tensor + img[:, -t:, :] = color_tensor + img[:, :, :t] = color_tensor + img[:, :, -t:] = color_tensor + self.nested_set(x, img, "image") + + return x + + +class AddWatermark(Transform): + """Overlay another image (logo, emoji, etc.) onto the base image. + + Args: + watermark_path (str): Path to the watermark image (e.g. 'smile.png'). + size (float): Fraction of base image size to scale watermark. + position (str): One of ['top_left', 'top_right', 'bottom_left', 'bottom_right', 'center']. + alpha (float): Opacity of watermark (0-1). + """ + + def __init__(self, watermark_path, size=0.2, position="bottom_right", alpha=0.8): + super().__init__() + # [C,H,W] tensor in [0,1] + self.watermark = read_image(watermark_path).float() / 255.0 + self.size = size + self.position = position + self.alpha = alpha + + def __call__(self, x): + img = self.nested_get(x, "image").clone() + _, H, W = img.shape + + # Resize watermark + w_h, w_w = self.watermark.shape[1:] + target_h = int(H * self.size) + target_w = int(w_w / w_h * target_h) + wm = resize(self.watermark, [target_h, target_w]) + + # Compute position + if self.position == "top_left": + y0, x0 = 0, 0 + elif self.position == "top_right": + y0, x0 = 0, W - target_w + elif self.position == "bottom_left": + y0, x0 = H - target_h, 0 + elif self.position == "bottom_right": + y0, x0 = H - target_h, W - target_w + elif self.position == "center": + y0, x0 = (H - target_h) // 2, (W - target_w) // 2 + else: + raise ValueError(f"Unknown position: {self.position}") + + background_region = img[:, y0 : y0 + target_h, x0 : x0 + target_w] + img[:, y0 : y0 + target_h, x0 : x0 + target_w] = ( + background_region * (1 - self.alpha) + wm * self.alpha + ) + + self.nested_set(x, img, "image") + return x + + +class ClassConditionalInjector(Transform): + """Applies transformations conditionally based on sample label. + + Args: + transformation (Transform): Transform to apply to the image. + label_key (str): Key for label in the sample dict. + target_labels (Union[int, list[int]]): Which labels to modify. + proportion (float): Fraction of samples with matching labels to modify (0-1). + total_samples (int, optional): Dataset size (for deterministic mask). + seed (int): Seed for randomization to determine which samples transformation is applied to + """ + + def __init__( + self, + transformation: Transform, + label_key: str = "label", + target_labels: Union[int, list[int]] = 0, + proportion: float = 0.5, + total_samples: Optional[int] = None, + seed: int = 42, + ): + super().__init__() + self.transformation = transformation + self.label_key = label_key + self.target_labels = ( + [target_labels] if isinstance(target_labels, int) else target_labels + ) + self.proportion = proportion + self.total_samples = total_samples + self.seed = seed + + # Precompute deterministic mask if dataset size known + if total_samples is not None: + num_to_transform = int(total_samples * proportion) + rng = torch.Generator().manual_seed(seed) + self.indices_to_transform = set( + torch.randperm(total_samples, generator=rng)[:num_to_transform].tolist() + ) + else: + self.indices_to_transform = None + + def __call__(self, x: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + label = self.nested_get(x, self.label_key) + + # Determine if we apply the transformation + should_transform = False + idx = self.nested_get(x, "idx") + if label in self.target_labels: + if self.indices_to_transform is not None: + should_transform = idx in self.indices_to_transform + else: + should_transform = random.random() < self.proportion + + if should_transform: + x = self.transformation(x) + + return x + + +# ============================================================ +# ===================== TEXT MODIFIERS ======================= +# ============================================================ + + +class Modifier: + """Base class for applying modifications/corruptions to text-label pairs. + + Subclasses must implement the __call__ method to define specific transformations. + + Example: + class MyModifier(Modifier): + def __call__(self, text: str, label: Any) -> tuple[str, Any]: + # custom transformation here + return transformed_text, transformed_label + """ + + def __call__(self, text: str, label): + """Apply the transformation to a single text-label pair. + + Args: + text (str): The input text to transform. + label: The associated label. + + Returns: + tuple: (transformed_text, transformed_label) + """ + raise NotImplementedError("Subclasses must implement __call__") + + +class CompositeModifier: + """CompositeModifier chains multiple Modifier instances together. + + Each modifier from the list is applied sequentially to the text. This enables + the combination of various transformations or injections into one composite operation. + """ + + def __init__(self, modifiers: list): + """Initialize a CompositeModifier instance. + + Args: + modifiers (list): A list of modifier instances (subclasses of Modifier) + to be applied sequentially. + """ + self.modifiers = modifiers + + def __call__(self, text: str, label): + """Apply all modifiers in sequence to the given (text, label). + + Args: + text (str): The input text. + label: The associated label. + + Returns: + tuple: The modified (text, label) pair after all transformations. + """ + for modifier in self.modifiers: + text, label = modifier(text, label) + return text, label + + +class ItemInjection(Modifier): + """A Modifier that injects items into text. + + This class supports creation via three different approaches: + - from_list: Using a predefined list of injection items. + - from_file: Reading injection items from a file. + - from_function: Using a custom function to generate injections. + """ + + def __init__( + self, + injection_source, + location: str = "random", + token_proportion: float = 0.1, + seed=None, + _rng=None, + ): + """Initialize an ItemInjection instance. + + Args: + injection_source (callable): A function that returns an injection token. + location (str): Where to inject the token ("beginning", "random", "end"). + token_proportion (float): Proportion of tokens in the text to be affected. + seed (int, optional): Seed for reproducibility. + """ + assert callable(injection_source), "injection_source must be callable" + self.injection_source = injection_source + self.location = location + self.token_proportion = token_proportion + self.rng = _rng or random.Random(seed) + + assert 0 <= token_proportion <= 1, "token_proportion must be between 0 and 1" + assert location in {"beginning", "random", "end"}, ( + "location must be 'beginning', 'random', or 'end'" + ) + + def __call__(self, text: str, label): + """Inject tokens into the text at specified locations. + + Args: + text (str): The input text to modify. + label: The original label (unchanged). + + Returns: + tuple: The modified text and the original label. + """ + words = text.split() + num_tokens = len(words) + + # Ensure at least one token is injected + num_to_inject = max(1, int(num_tokens * self.token_proportion)) + + injections = [self.injection_source() for _ in range(num_to_inject)] + + if self.location == "beginning": + words = injections + words + elif self.location == "end": + words = words + injections + elif self.location == "random": + for injection in injections: + pos = self.rng.randint(0, len(words)) + words.insert(pos, injection) + + return " ".join(words), label # return modified text and unchanged label + + @classmethod + def from_list( + cls, + items: list, + location: str = "random", + token_proportion: float = 0.1, + seed=None, + ): + """Create an ItemInjection instance using a predefined list of tokens. + + Args: + items (list): List of token strings to choose from. + location (str): Where to inject tokens ("beginning", "random", "end"). + token_proportion (float): Proportion of text tokens to be affected. + seed (int, optional): Seed for reproducibility. + + Returns: + ItemInjection: Configured instance. + """ + rng = random.Random(seed) + + def injection_source(): + return rng.choice(items) + + return cls( + injection_source, + location=location, + token_proportion=token_proportion, + seed=seed, + _rng=rng, + ) + + @classmethod + def from_file( + cls, + file_path: str, + location: str = "random", + token_proportion: float = 0.1, + seed=None, + ): + """Create an ItemInjection instance using tokens read from a file. + + Each non-empty line becomes a potential injection item. + + Args: + file_path (str): Path to the file with one token per line. + location (str): Where to inject tokens. + token_proportion (float): Proportion of tokens to inject. + seed (int, optional): Seed for reproducibility. + + Returns: + ItemInjection: Configured instance. + """ + with open(file_path, "r", encoding="utf-8") as file: + items = [line.strip() for line in file if line.strip()] + + rng = random.Random(seed) + + def injection_source(): + return rng.choice(items) + + return cls( + injection_source, + location=location, + token_proportion=token_proportion, + _rng=rng, + ) + + @classmethod + def from_function( + cls, + injection_func, + location: str = "random", + token_proportion: float = 0.1, + seed=None, + ): + """Create an ItemInjection instance using a custom function to generate injections. + + Args: + injection_func (callable): Function that returns a new injection token each time. + location (str): Where to inject tokens. + token_proportion (float): Proportion of text to inject into. + seed (int, optional): Seed for reproducibility (used only for insertion position). + + Returns: + ItemInjection: Configured instance. + """ + assert callable(injection_func), "injection_func must be callable" + return cls( + injection_func, + location=location, + token_proportion=token_proportion, + seed=seed, + ) + + +class HTMLInjection(Modifier): + """A Modifier that injects html into text. + + This class supports creation via two different approaches: + - from_list: Using a predefined list of injection items. + - from_file: Reading injection items from a file. + """ + + def __init__( + self, + file_path: str, + location: str = "random", + level: int = None, + token_proportion: float = None, + seed=None, + ): + with open(file_path, "r", encoding="utf-8") as f: + self.tags = [line.strip() for line in f if line.strip()] + self.location = location + self.level = level + self.token_proportion = token_proportion + self.rng = random.Random(seed) + + if token_proportion is not None: + assert 0 < token_proportion <= 1, "token_proportion must be between 0 and 1" + + @classmethod + def from_file( + cls, + file_path: str, + location: str = "random", + level: int = None, + token_proportion: float = None, + seed=None, + ): + return cls( + file_path, + location=location, + level=level, + token_proportion=token_proportion, + seed=seed, + ) + + @classmethod + def from_list( + cls, + tags: list, + location: str = "random", + level: int = None, + token_proportion: float = None, + seed=None, + ): + instance = cls.__new__(cls) + instance.tags = tags + instance.location = location + instance.level = level + instance.token_proportion = token_proportion + instance.rng = random.Random(seed) + + if token_proportion is not None: + assert 0 < token_proportion <= 1, "token_proportion must be between 0 and 1" + + return instance + + def _choose_tag(self): + """Randomly choose a tag from the loaded list. + + Returns: + tuple: (opening_tag, closing_tag or None) + """ + line = self.rng.choice(self.tags) + parts = line.split() + if len(parts) >= 2: + return parts[0], parts[1] + else: + return parts[0], None + + def _inject_into_tokens(self, tokens, location): + tokens = tokens[:] + n = len(tokens) + + if self.token_proportion is None: + opening, closing = self._choose_tag() + return self._inject_with_tags(tokens, opening, closing, location) + + # Otherwise, inject up to token_proportion of total tokens + num_insertions = max(1, int(n * self.token_proportion)) + for _ in range(num_insertions): + opening, closing = self._choose_tag() + tokens = self._inject_with_tags(tokens, opening, closing, location) + return tokens + + def _inject_with_tags(self, tokens, opening, closing, location): + if location == "beginning": + new_tokens = [opening] + tokens + if closing: + pos = self.rng.randint(1, len(new_tokens)) + new_tokens.insert(pos, closing) + return new_tokens + + elif location == "end": + new_tokens = tokens[:] + pos = self.rng.randint(0, len(new_tokens)) + new_tokens.insert(pos, opening) + if closing: + new_tokens.append(closing) + return new_tokens + + elif location == "random": + new_tokens = tokens[:] + pos_open = self.rng.randint(0, len(new_tokens)) + new_tokens.insert(pos_open, opening) + if closing: + pos_close = self.rng.randint(pos_open + 1, len(new_tokens)) + new_tokens.insert(pos_close, closing) + return new_tokens + + return tokens + + def _inject(self, text, location): + tokens = text.split() + new_tokens = self._inject_into_tokens(tokens, location) + return " ".join(new_tokens) + + def _find_level_span(self, text, level): + """Find the first span inside the desired HTML nesting level. + + Args: + text (str): Input HTML text. + level (int): Desired nesting level. + + Returns: + tuple or None: (start, end) of the content region, or None if not found. + """ + tag_regex = re.compile(r"]*>") + stack = [] + for match in tag_regex.finditer(text): + tag_str = match.group(0) + tag_name = match.group(1) + if not tag_str.startswith(" 0) + + def test_add_border_transform(self): + """Test AddBorder draws a colored border.""" + img = torch.zeros(3, 20, 20) + data = {"image": img} + transform = transforms.AddBorder(thickness=0.1, color=(0, 1, 0)) + result = transform(data) + # Corners should have green (0,1,0) + assert torch.allclose(result["image"][1, 0, 0], torch.tensor(1.0), atol=1e-3) + assert torch.allclose(result["image"][0, 0, 0], torch.tensor(0.0), atol=1e-3) + + def test_add_watermark_transform(self, tmp_path): + """Test AddWatermark overlays another image.""" + # Create a dummy watermark (white square) + wm_path = tmp_path / "wm.png" + from torchvision.utils import save_image + + save_image(torch.ones(3, 8, 8), wm_path) + data = {"image": torch.zeros(3, 32, 32)} + transform = transforms.AddWatermark( + str(wm_path), size=0.25, position="center", alpha=1.0 + ) + result = transform(data) + # There should be a bright region in the center + center = result["image"][:, 12:20, 12:20] + assert torch.mean(center) > 0.5 + + def test_class_conditional_injector(self): + """Test ClassConditionalInjector applies transform to correct labels only.""" + base_transform = transforms.AddPatch(color=(0, 1, 0)) + injector = transforms.ClassConditionalInjector( + transformation=base_transform, + target_labels=[1], + proportion=1.0, + total_samples=5, + seed=42, + ) + + # Prepare samples with idx + label + samples = [ + {"image": torch.zeros(3, 16, 16), "label": torch.tensor(label), "idx": idx} + for idx, label in enumerate([0, 1, 1, 0, 1]) + ] + + outputs = [injector(s) for s in samples] + + # Check that only samples with label of 1 were modified + for s_in, s_out in zip(samples, outputs): + mean_pixel = s_out["image"].mean().item() + if s_in["label"] == 1: + assert mean_pixel > 0 # patch added + else: + assert mean_pixel == 0 # unchanged