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 "" not in modified_text, (
+ "Only one tag should appear"
+ )
diff --git a/stable_pretraining/data/spurious_corr/tests/test_item_injection.py b/stable_pretraining/data/spurious_corr/tests/test_item_injection.py
new file mode 100644
index 000000000..2611dd7ad
--- /dev/null
+++ b/stable_pretraining/data/spurious_corr/tests/test_item_injection.py
@@ -0,0 +1,69 @@
+import pytest
+from stable_pretraining.data.transforms import ItemInjection
+
+
+@pytest.mark.unit
+def test_injection_proportion():
+ text = "this is a test sentence with eight tokens"
+ token_count = len(text.split())
+
+ for proportion in [0.1, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 0.9, 1.0]:
+ modifier = ItemInjection.from_list(
+ ["X"], token_proportion=proportion, location="end", seed=42
+ )
+ modified_text, label = modifier(text, "original_label")
+ injected_count = modified_text.count("X")
+ expected_count = max(1, int(token_count * proportion))
+ assert injected_count == expected_count, (
+ f"Expected {expected_count}, got {injected_count}"
+ )
+ assert label == "original_label"
+
+
+@pytest.mark.unit
+def test_injection_single_token():
+ """Test the injection of a single token into a text string."""
+ text = "this is a test sentence with eight tokens"
+
+ modifier = ItemInjection.from_list(
+ ["X"], token_proportion=0, location="random", seed=42
+ )
+ modified_text, label = modifier(text, "original_label")
+ injected_count = modified_text.count("X")
+ expected_count = 1
+ assert injected_count == expected_count, (
+ f"Expected {expected_count}, got {injected_count}"
+ )
+ assert label == "original_label"
+
+
+@pytest.mark.unit
+def test_injection_location_beginning():
+ text = "hello world"
+ modifier = ItemInjection.from_list([""], 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"?([a-zA-Z][a-zA-Z0-9]*)[^>]*>")
+ stack = []
+ for match in tag_regex.finditer(text):
+ tag_str = match.group(0)
+ tag_name = match.group(1)
+ if not tag_str.startswith(""):
+ stack.append((tag_name, match.end()))
+ else:
+ if stack:
+ open_tag, start_index = stack.pop()
+ if len(stack) == level - 1:
+ return (start_index, match.start())
+ return None
+
+ def __call__(self, text: str, label):
+ if self.level is None:
+ return self._inject(text, self.location), label
+ elif self.level == 0:
+ opening, closing = self._choose_tag()
+ if closing:
+ return f"{opening}{text}{closing}", label
+ else:
+ return f"{opening}{text}{opening}", label
+ else:
+ span = self._find_level_span(text, self.level)
+ if span is None:
+ return self._inject(text, self.location), label
+ start, end = span
+ target = text[start:end]
+ injected = self._inject(target, self.location)
+ return text[:start] + injected + text[end:], label
diff --git a/stable_pretraining/tests/unit/test_transforms.py b/stable_pretraining/tests/unit/test_transforms.py
index 90d277f51..1fa7ca5c8 100644
--- a/stable_pretraining/tests/unit/test_transforms.py
+++ b/stable_pretraining/tests/unit/test_transforms.py
@@ -87,7 +87,107 @@ def test_transform_params_initialization(self):
transforms.RandomResizedCrop(size=(32, 32)),
transforms.RandomSolarize(threshold=0.5, p=0.2),
transforms.RandomRotation(degrees=90),
+ transforms.AddSampleIdx(),
+ transforms.AddColorTint(),
+ transforms.AddBorder(),
+ transforms.ClassConditionalInjector(
+ transformation=transforms.AddPatch(
+ patch_size=0.1, color=(1.0, 0.0, 0.0), position="center"
+ ),
+ total_samples=10000,
+ ),
]
for t in transforms_to_test:
assert t is not None
+
+ # ---------------------------
+ # Spurious correlation tests
+ # ---------------------------
+
+ def test_add_sample_idx_transform(self):
+ """Test that AddSampleIdx correctly increments indices."""
+ transform = transforms.AddSampleIdx()
+ x1 = {"image": torch.zeros(3, 32, 32)}
+ x2 = {"image": torch.zeros(3, 32, 32)}
+ out1 = transform(x1)
+ out2 = transform(x2)
+ assert out1["idx"] == 0
+ assert out2["idx"] == 1
+
+ def test_add_patch_transform(self):
+ """Test that AddPatch overlays a colored patch."""
+ img = torch.zeros(3, 32, 32)
+ data = {"image": img.clone()}
+ transform = transforms.AddPatch(
+ patch_size=0.25, color=(1.0, 0.0, 0.0), position="top_left_corner"
+ )
+ result = transform(data)
+ # Top-left corner should now contain red pixels
+ patch_area = result["image"][:, :8, :8]
+ assert torch.allclose(patch_area[0], torch.ones_like(patch_area[0]), atol=1e-3)
+ assert torch.allclose(
+ patch_area[1:], torch.zeros_like(patch_area[1:]), atol=1e-3
+ )
+
+ def test_add_color_tint_transform(self):
+ """Test AddColorTint applies an additive tint."""
+ img = torch.zeros(3, 16, 16)
+ data = {"image": img}
+ transform = transforms.AddColorTint(tint=(1.0, 0.5, 0.5), alpha=0.5)
+ result = transform(data)
+ # Image should not be all zeros anymore
+ assert torch.any(result["image"] > 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