File size: 8,790 Bytes
98188cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
"""Reproducible Enron preprocessing and PII evaluation-set construction."""

from __future__ import annotations

import json
import hashlib
from pathlib import Path
import re
import shutil
from typing import Any, Iterable

import numpy as np
from datasets import Dataset, DatasetDict, load_from_disk

from .train import load_spacy_model, load_text_dataset, load_tokenizer


EMAIL_RE = re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+")
PHONE_RE = re.compile(r"(?<!\d)(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)\d{3}[-.\s]\d{4}(?!\d)")
SPACY_PII_LABELS = {"PERSON", "ORG", "GPE", "LOC", "DATE"}


def clean_email_text(text: Any) -> str:
    """Normalize an email and remove a conventional RFC-like header block."""
    text = str(text or "").replace("\x00", " ").replace("\r\n", "\n").strip()
    head, separator, body = text.partition("\n\n")
    header_names = ("from:", "to:", "subject:", "date:", "message-id:", "mime-version:")
    header_lines = [line.strip().lower() for line in head.splitlines()[:30]]
    if separator and sum(any(line.startswith(name) for name in header_names) for line in header_lines) >= 2:
        text = body.strip()
    return re.sub(r"[ \t]+", " ", text)


def truncate_to_tokens(text: str, tokenizer, max_tokens: int) -> str:
    """Return the raw-text prefix represented by at most ``max_tokens`` tokens."""
    encoded = tokenizer(
        text,
        add_special_tokens=False,
        truncation=True,
        max_length=max_tokens,
        return_offsets_mapping=True,
    )
    offsets = encoded.get("offset_mapping", [])
    return text[: offsets[-1][1]] if offsets else ""


def detect_pii_spans(text: str, nlp_model) -> list[dict[str, Any]]:
    """Detect deduplicated PII spans with spaCy plus email/phone regexes."""
    spans: list[dict[str, Any]] = []
    for entity in nlp_model(text).ents:
        if entity.label_ in SPACY_PII_LABELS:
            spans.append(
                {"start": entity.start_char, "end": entity.end_char, "type": entity.label_, "value": entity.text}
            )
    for pattern, label in ((EMAIL_RE, "EMAIL"), (PHONE_RE, "PHONE")):
        for match in pattern.finditer(text):
            spans.append({"start": match.start(), "end": match.end(), "type": label, "value": match.group(0)})

    deduplicated: list[dict[str, Any]] = []
    seen: set[tuple[int, int, str]] = set()
    for span in sorted(spans, key=lambda item: (item["start"], -(item["end"] - item["start"]))):
        key = (int(span["start"]), int(span["end"]), str(span["type"]))
        if key not in seen and str(span["value"]).strip():
            seen.add(key)
            deduplicated.append(span)
    return deduplicated


def _split_indices(rows: list[dict[str, Any]], seed: int) -> tuple[list[int], list[int], list[int]]:
    """Create deterministic 80/10/10 splits, approximately stratified by email PII."""
    rng = np.random.default_rng(seed)
    partitions = {True: [], False: []}
    for index, row in enumerate(rows):
        partitions[bool(row["has_email"])].append(index)

    train: list[int] = []
    validation: list[int] = []
    test: list[int] = []
    for indices in partitions.values():
        rng.shuffle(indices)
        n_items = len(indices)
        n_train = int(n_items * 0.8)
        n_validation = int(n_items * 0.1)
        train.extend(indices[:n_train])
        validation.extend(indices[n_train : n_train + n_validation])
        test.extend(indices[n_train + n_validation :])
    rng.shuffle(train)
    rng.shuffle(validation)
    rng.shuffle(test)
    return train, validation, test


def _records(dataset: Dataset, indices: Iterable[int]) -> list[dict[str, Any]]:
    return [dataset[int(index)] for index in indices]


def build_pii_eval_records(
    test_dataset: Dataset,
    tokenizer,
    seen_pii: set[str],
    max_samples: int = 500,
    prefix_tokens: int = 50,
    source_split: str = "test",
) -> list[dict[str, Any]]:
    """Build prefix-completion attacks from real PII spans in held-out emails."""
    records: list[dict[str, Any]] = []
    for row in test_dataset:
        text = row["text"]
        spans = row["pii_spans"]
        if not spans:
            continue
        selected = None
        for span in spans:
            target = str(span["value"]).strip()
            before = text[: int(span["start"])]
            token_ids = tokenizer(before, add_special_tokens=False).input_ids[-prefix_tokens:]
            prefix = tokenizer.decode(token_ids, skip_special_tokens=True).strip()
            if prefix and target:
                selected = (span, target, prefix)
                break
        if selected is None:
            continue
        span, target, prefix = selected
        normalized = target.casefold().strip()
        records.append(
            {
                "prefix": prefix,
                "ground_truth_target": target,
                "pii_type": str(span["type"]),
                "seen_in_train": normalized in seen_pii,
                "source_split": source_split,
            }
        )
        if len(records) >= max_samples:
            break
    return records


def prepare_experiment_data(config: dict[str, Any], force: bool = False) -> tuple[DatasetDict, Path]:
    """Prepare datasets and write a non-placeholder PII extraction evaluation file."""
    prepared_dir = Path(config.get("prepared_data_dir", "data/processed"))
    eval_path = Path(config.get("pii_eval_path", "data/pii_eval.json"))
    if prepared_dir.exists() and eval_path.exists() and not force:
        config["text_column"] = "text"
        return load_from_disk(str(prepared_dir)), eval_path
    if force and prepared_dir.exists():
        shutil.rmtree(prepared_dir)

    tokenizer = load_tokenizer(config.get("model_name", "EleutherAI/pythia-160m"))
    nlp_model = load_spacy_model(config.get("spacy_model", "en_core_web_sm"))
    source = load_text_dataset(config)
    text_column = config["text_column"]
    max_tokens = int(config.get("max_seq_len", 512))
    max_prepared = int(config.get("max_prepared_samples", 6000))

    rows: list[dict[str, Any]] = []
    seen_text_hashes: set[str] = set()
    for source_index, row in enumerate(source):
        text = truncate_to_tokens(clean_email_text(row.get(text_column)), tokenizer, max_tokens)
        if not text:
            continue
        text_hash = hashlib.sha1(" ".join(text.casefold().split()).encode("utf-8")).hexdigest()
        if text_hash in seen_text_hashes:
            continue
        seen_text_hashes.add(text_hash)
        spans = detect_pii_spans(text, nlp_model)
        if not spans:
            continue
        rows.append(
            {
                "sample_id": int(source_index),
                "text": text,
                "pii_spans": spans,
                "pii_values": [str(span["value"]) for span in spans],
                "has_email": any(span["type"] == "EMAIL" for span in spans),
            }
        )
        if len(rows) >= max_prepared:
            break
    if len(rows) < 10:
        raise ValueError(f"Only {len(rows)} PII-containing samples were prepared; at least 10 are required.")

    all_dataset = Dataset.from_list(rows)
    train_idx, validation_idx, test_idx = _split_indices(rows, int(config.get("seed", 42)))
    splits = DatasetDict(
        {
            "train": Dataset.from_list(_records(all_dataset, train_idx)),
            "validation": Dataset.from_list(_records(all_dataset, validation_idx)),
            "test": Dataset.from_list(_records(all_dataset, test_idx)),
        }
    )
    prepared_dir.parent.mkdir(parents=True, exist_ok=True)
    splits.save_to_disk(str(prepared_dir))

    seen_pii = {
        value.casefold().strip()
        for row in splits["train"]
        for value in row["pii_values"]
        if value and value.strip()
    }
    max_eval_samples = int(config.get("max_eval_samples", 500))
    seen_eval_records = build_pii_eval_records(
        splits["train"],
        tokenizer,
        seen_pii,
        max_samples=max_eval_samples // 2,
        prefix_tokens=int(config.get("eval_prefix_tokens", 50)),
        source_split="train",
    )
    unseen_eval_records = build_pii_eval_records(
        splits["test"],
        tokenizer,
        seen_pii,
        max_samples=max_eval_samples - len(seen_eval_records),
        prefix_tokens=int(config.get("eval_prefix_tokens", 50)),
        source_split="test",
    )
    eval_records = seen_eval_records + unseen_eval_records
    eval_path.parent.mkdir(parents=True, exist_ok=True)
    eval_path.write_text(json.dumps(eval_records, indent=2, ensure_ascii=False), encoding="utf-8")
    if not eval_records:
        raise ValueError("No PII extraction records could be created from the test split.")
    config["text_column"] = "text"
    return splits, eval_path