galexdav commited on
Commit
b20815d
·
verified ·
1 Parent(s): 95ad4ca

Add Excel redaction demo kit

Browse files
Files changed (4) hide show
  1. README.md +84 -65
  2. clean_excel.py +369 -0
  3. dummy_procurement_data.xlsx +0 -0
  4. requirements.txt +6 -0
README.md CHANGED
@@ -14,23 +14,21 @@ pipeline_tag: token-classification
14
 
15
  # privacy-filter-finetuned
16
 
17
- A finetuned version of [openai/privacy-filter](https://huggingface.co/openai/privacy-filter) with three additional detection categories on top of the original eight — built for procurement, finance, and sales datasets where company names, prices, and business reference IDs are as sensitive as personal data.
18
 
19
- Runs entirely locally. No data leaves the device during inference.
20
 
21
- ---
22
-
23
- ## New Categories
24
 
25
- This checkpoint adds three categories to the base model's original eight:
26
 
27
  | Category | What it detects | Examples |
28
  |---|---|---|
29
  | `company_name` | Supplier names, client account names, corporate entities | `Meridian Logistics Ltd`, `Apex Manufacturing PLC`, `TechStart Solutions Inc` |
30
- | `price` | Monetary values with currency symbols or codes | `£4,250.00`, `$12,500`, `5,000`, `2,500 GBP` |
31
  | `id_number` | Purchase orders, invoice numbers, reference IDs, case numbers, internal codes | `PO-00442`, `INV/2024/00567`, `REF-2024-001`, `CASE-20240315-001`, `ORD-78542` |
32
 
33
- ## Full Label Space (11 categories)
34
 
35
  ```json
36
  {
@@ -52,95 +50,116 @@ This checkpoint adds three categories to the base model's original eight:
52
  }
53
  ```
54
 
55
- ---
56
-
57
- ## Usage
58
 
59
- Install the official `opf` package from the [openai/privacy-filter](https://github.com/openai/privacy-filter) repo:
60
 
61
  ```bash
62
- git clone https://github.com/openai/privacy-filter
63
- cd privacy-filter
64
- pip install -e .
65
  ```
66
 
67
- Download this checkpoint:
68
 
69
  ```python
70
  from huggingface_hub import snapshot_download
 
 
 
 
71
 
72
- snapshot_download(
73
- repo_id="galexdav/privacy-filter-finetuned",
74
- local_dir="./privacy-filter-finetuned"
75
- )
76
  ```
77
 
78
- Run inference:
79
 
80
- ```python
81
- from opf import OPF
 
82
 
83
- model = OPF(model="./privacy-filter-finetuned", device="cpu")
84
- result = model.redact("PO-00442 raised for Meridian Logistics Ltd, total £4,250.00.")
85
 
86
- for span in result.detected_spans:
87
- print(f"{span.label}: '{span.text}'")
 
 
 
 
 
 
 
88
 
89
- # id_number: 'PO-00442'
90
- # company_name: 'Meridian Logistics Ltd'
91
- # price: '£4,250.00'
92
  ```
93
 
94
- Or via the CLI:
95
 
96
  ```bash
97
- opf --checkpoint ./privacy-filter-finetuned --device cpu \
98
- "Invoice INV/2024/00567 from Apex Manufacturing PLC — amount £9,600.00"
99
  ```
100
 
101
- ---
102
 
103
- ## Training Details
 
 
104
 
105
- | | |
106
- |---|---|
107
- | Base model | openai/privacy-filter |
108
- | Training examples | ~830 |
109
- | Validation examples | ~130 |
110
- | Epochs | 3 |
111
- | Hardware | 1× NVIDIA L4 (24 GB) via Hugging Face Jobs |
112
- | Training time | ~9 minutes |
113
 
114
- Training data was generated programmatically using a template × entity cross-product approach, covering all 11 categories including the original 8 (to prevent catastrophic forgetting).
 
 
115
 
116
- ---
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
- ## Test Results
119
 
120
- 22/24 tests passing on the held-out test suite:
121
 
122
- | Category | Result |
 
 
 
 
 
 
 
 
 
 
 
 
123
  |---|---|
124
- | company_name (Ltd, PLC, Inc, LLP) | All pass |
125
- | price (£, $, €, written codes) | All pass |
126
- | id_number (PO, INV, REF, CASE, ORD) | All pass |
127
- | Multi-category (company + id + price in one sentence) | Pass |
128
- | private_person, private_email, private_phone | Pass |
129
- | private_address, account_number, private_date | Pass |
130
- | secret (API keys) | ⚠️ Occasional bleed into id_number |
131
- | Negative (no PII) | ⚠️ Occasional company_name false positive |
132
 
133
- ---
134
 
135
  ## Known Limitations
136
 
137
- - **Company names in isolated cells** the model relies on surrounding sentence context to classify noun phrases as company names. A bare cell value with no surrounding text may not trigger detection.
138
- - **Price span boundaries** currency symbols are occasionally left outside the detected span boundary in some formats.
139
- - **secret vs id_number** — API keys with alphanumeric patterns (e.g. `sk-live-...`) can be tagged as `id_number` rather than `secret`.
140
- - **English only** inherited from the base model; performance on non-English text and regional naming conventions is limited.
141
-
142
- ---
143
 
144
- ## Licence
145
 
146
- Apache 2.0 same as the base model. Commercial use permitted.
 
14
 
15
  # privacy-filter-finetuned
16
 
17
+ A finetuned version of [openai/privacy-filter](https://huggingface.co/openai/privacy-filter) with three additional detection categories for procurement, finance, and sales datasets where company names, prices, and business reference IDs can be sensitive.
18
 
19
+ The model runs locally for inference. Input data is processed on the user's machine when using the included Excel demo script.
20
 
21
+ ## Added Categories
 
 
22
 
23
+ This checkpoint keeps the original OPF privacy labels and adds:
24
 
25
  | Category | What it detects | Examples |
26
  |---|---|---|
27
  | `company_name` | Supplier names, client account names, corporate entities | `Meridian Logistics Ltd`, `Apex Manufacturing PLC`, `TechStart Solutions Inc` |
28
+ | `price` | Monetary values with currency symbols or codes | `GBP 4,250.00`, `$12,500`, `EUR 5,000`, `2,500 GBP` |
29
  | `id_number` | Purchase orders, invoice numbers, reference IDs, case numbers, internal codes | `PO-00442`, `INV/2024/00567`, `REF-2024-001`, `CASE-20240315-001`, `ORD-78542` |
30
 
31
+ ## Label Space
32
 
33
  ```json
34
  {
 
50
  }
51
  ```
52
 
53
+ ## Quick Inference Demo
 
 
54
 
55
+ Install dependencies:
56
 
57
  ```bash
58
+ pip install git+https://github.com/openai/privacy-filter.git huggingface_hub
 
 
59
  ```
60
 
61
+ Download and run the checkpoint:
62
 
63
  ```python
64
  from huggingface_hub import snapshot_download
65
+ from opf import OPF
66
+
67
+ checkpoint = snapshot_download("galexdav/privacy-filter-finetuned")
68
+ model = OPF(model=checkpoint, device="cpu")
69
 
70
+ result = model.redact("PO-00442 raised for Meridian Logistics Ltd, total GBP 4,250.00.")
71
+ for span in result.detected_spans:
72
+ print(f"{span.label}: {span.text}")
 
73
  ```
74
 
75
+ CLI example:
76
 
77
+ ```bash
78
+ opf --checkpoint ./privacy-filter-finetuned --device cpu "Invoice INV/2024/00567 from Apex Manufacturing PLC, amount GBP 9,600.00"
79
+ ```
80
 
81
+ ## Excel Redaction Demo
 
82
 
83
+ The included `clean_excel.py` script:
84
+
85
+ 1. Loads an Excel workbook.
86
+ 2. Detects sensitive spans in selected text columns.
87
+ 3. Replaces each detected value with synthetic data.
88
+ 4. Reuses the same synthetic value when the same original value appears again in the run.
89
+ 5. Writes a new workbook with `_clean` columns and a `PII_Audit_Log` sheet.
90
+
91
+ Install the demo requirements locally:
92
 
93
+ ```bash
94
+ pip install -r requirements.txt
 
95
  ```
96
 
97
+ Run with the included sample workbook:
98
 
99
  ```bash
100
+ python clean_excel.py dummy_procurement_data.xlsx --device cpu
 
101
  ```
102
 
103
+ Run on your own workbook:
104
 
105
+ ```bash
106
+ python clean_excel.py your_data.xlsx --device cpu
107
+ ```
108
 
109
+ Choose specific columns:
 
 
 
 
 
 
 
110
 
111
+ ```bash
112
+ python clean_excel.py your_data.xlsx --columns Supplier Notes ContactEmail --device cpu
113
+ ```
114
 
115
+ Process every sheet:
116
+
117
+ ```bash
118
+ python clean_excel.py your_data.xlsx --all-sheets --device cpu
119
+ ```
120
+
121
+ The script auto-downloads this checkpoint from Hugging Face when `--checkpoint` is not supplied. To use a local checkpoint explicitly:
122
+
123
+ ```bash
124
+ python clean_excel.py your_data.xlsx --checkpoint ./models/privacy-filter-finetuned --device cpu
125
+ ```
126
+
127
+ Use `--device cuda` if you have a working CUDA GPU setup.
128
 
129
+ ## Output Workbook
130
 
131
+ For an input called `dummy_procurement_data.xlsx`, the script creates:
132
 
133
+ ```text
134
+ dummy_procurement_data_cleaned.xlsx
135
+ ```
136
+
137
+ The output includes:
138
+
139
+ - Original columns unchanged.
140
+ - New `<column>_clean` columns containing synthetic replacements.
141
+ - `PII_Audit_Log` with label, original value, replacement value, sheet, row, and column.
142
+
143
+ ## Training Details
144
+
145
+ | Field | Value |
146
  |---|---|
147
+ | Base model | `openai/privacy-filter` |
148
+ | Training examples | about 830 |
149
+ | Validation examples | about 130 |
150
+ | Epochs | 3 |
151
+ | Hardware | 1x NVIDIA L4, 24 GB, via Hugging Face Jobs |
152
+ | Training time | about 41 minutes |
 
 
153
 
154
+ Training data was generated programmatically using templates and entity cross-products, covering all 11 categories including the original OPF categories to reduce catastrophic forgetting.
155
 
156
  ## Known Limitations
157
 
158
+ - Company names in isolated cells may be harder to detect without sentence context.
159
+ - Price span boundaries may occasionally exclude a currency symbol.
160
+ - API-key-like values can occasionally be classified as `id_number` rather than `secret`.
161
+ - English only, inherited from the base model.
 
 
162
 
163
+ ## License
164
 
165
+ Apache 2.0, same as the base model. Commercial use is permitted under the license terms.
clean_excel.py ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ clean_excel.py - Run OpenAI Privacy Filter over an Excel file.
3
+ Replaces detected PII with consistent synthetic fake data using Faker.
4
+
5
+ Usage:
6
+ python clean_excel.py input.xlsx
7
+ python clean_excel.py input.xlsx --columns name email notes
8
+ python clean_excel.py input.xlsx --checkpoint /path/to/model
9
+ python clean_excel.py input.xlsx --sheet "Sheet2"
10
+ python clean_excel.py input.xlsx --all-sheets
11
+ """
12
+
13
+ import argparse
14
+ import os
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+ from faker import Faker
19
+ from openpyxl import load_workbook
20
+ from openpyxl.styles import PatternFill, Font
21
+ from tqdm import tqdm
22
+ import pandas as pd
23
+ from opf import OPF
24
+
25
+ fake = Faker("en_GB")
26
+
27
+ DEFAULT_HF_MODEL_REPO = "galexdav/privacy-filter-finetuned"
28
+ DEFAULT_LOCAL_CHECKPOINT = Path("models") / "privacy-filter-finetuned"
29
+
30
+ # Colour constants for the audit sheet
31
+ HIGHLIGHT = PatternFill("solid", start_color="FFF2CC", end_color="FFF2CC")
32
+ HEADER_FILL = PatternFill("solid", start_color="D9E1F2", end_color="D9E1F2")
33
+ BOLD = Font(bold=True)
34
+
35
+ # Consistency map: same real value maps to same fake value across entire run
36
+ _replacement_cache: dict[str, str] = {}
37
+
38
+
39
+ def _checkpoint_has_weights(path: Path) -> bool:
40
+ return (
41
+ path.is_dir()
42
+ and (path / "config.json").is_file()
43
+ and any(path.glob("*.safetensors"))
44
+ )
45
+
46
+
47
+ def resolve_checkpoint(checkpoint: str | None) -> str:
48
+ """
49
+ Resolve the model checkpoint to use.
50
+
51
+ Priority:
52
+ 1. --checkpoint argument
53
+ 2. OPF_CHECKPOINT environment variable
54
+ 3. local models/privacy-filter-finetuned directory
55
+ 4. auto-download from the Hugging Face model repo
56
+ """
57
+ if checkpoint:
58
+ return str(Path(checkpoint).expanduser())
59
+
60
+ env_checkpoint = os.environ.get("OPF_CHECKPOINT")
61
+ if env_checkpoint:
62
+ return str(Path(env_checkpoint).expanduser())
63
+
64
+ if _checkpoint_has_weights(DEFAULT_LOCAL_CHECKPOINT):
65
+ return str(DEFAULT_LOCAL_CHECKPOINT)
66
+
67
+ try:
68
+ from huggingface_hub import snapshot_download
69
+ except ImportError:
70
+ print(
71
+ "huggingface_hub is required to auto-download the model. "
72
+ "Install demo dependencies with: pip install -r requirements.txt"
73
+ )
74
+ sys.exit(1)
75
+
76
+ print(f"Checkpoint not found locally. Downloading {DEFAULT_HF_MODEL_REPO}...")
77
+ return snapshot_download(
78
+ repo_id=DEFAULT_HF_MODEL_REPO,
79
+ repo_type="model",
80
+ local_dir=str(DEFAULT_LOCAL_CHECKPOINT),
81
+ )
82
+
83
+
84
+ def _random_digits(length: int) -> str:
85
+ return "".join(str(fake.random_int(min=0, max=9)) for _ in range(length))
86
+
87
+
88
+ def fake_company_name(original: str) -> str:
89
+ suffix_match = re.search(
90
+ r"\b(Ltd|Limited|PLC|LLP|Inc|Corp|Corporation)\.?$",
91
+ original.strip(),
92
+ flags=re.IGNORECASE,
93
+ )
94
+ if not suffix_match:
95
+ return fake.company()
96
+
97
+ suffix = suffix_match.group(1)
98
+ company = re.sub(
99
+ r"\s+(Ltd|Limited|PLC|LLP|Inc|Corp|Corporation)\.?$",
100
+ "",
101
+ fake.company(),
102
+ flags=re.IGNORECASE,
103
+ ).strip(" ,.")
104
+ return f"{company} {suffix}"
105
+
106
+
107
+ def fake_price(original: str) -> str:
108
+ text = original.strip()
109
+ has_decimals = "." in text
110
+ whole = fake.random_int(min=100, max=999_999)
111
+ amount = f"{whole:,}"
112
+ if has_decimals:
113
+ amount = f"{amount}.{fake.random_int(min=0, max=99):02d}"
114
+
115
+ pound = "\u00a3"
116
+ euro = "\u20ac"
117
+ if pound in text:
118
+ return f"{pound}{amount}"
119
+ if "$" in text:
120
+ return f"${amount}"
121
+ if euro in text:
122
+ return f"{euro}{amount}"
123
+
124
+ currency_code = re.search(r"\b(GBP|USD|EUR)\b", text, flags=re.IGNORECASE)
125
+ if currency_code:
126
+ code = currency_code.group(1).upper()
127
+ if text.upper().startswith(code):
128
+ return f"{code} {amount}"
129
+ return f"{amount} {code}"
130
+
131
+ return amount
132
+
133
+
134
+ def fake_id_number(original: str) -> str:
135
+ return re.sub(r"\d+", lambda match: _random_digits(len(match.group(0))), original)
136
+
137
+
138
+ def get_fake(label: str, original: str) -> str:
139
+ """
140
+ Return a synthetic replacement for a detected PII span.
141
+ The same original value always maps to the same fake value (within a run).
142
+ """
143
+ key = f"{label}:{original}"
144
+ if key in _replacement_cache:
145
+ return _replacement_cache[key]
146
+
147
+ generators = {
148
+ "private_person": fake.name,
149
+ "private_email": fake.email,
150
+ "private_phone": fake.phone_number,
151
+ "private_address": lambda: fake.address().replace("\n", ", "),
152
+ "account_number": fake.bban,
153
+ "private_url": fake.url,
154
+ "private_date": lambda: fake.date(pattern="%d/%m/%Y"),
155
+ "secret": lambda: "[REDACTED]",
156
+ "company_name": lambda: fake_company_name(original),
157
+ "price": lambda: fake_price(original),
158
+ "id_number": lambda: fake_id_number(original),
159
+ }
160
+
161
+ fn = generators.get(label, lambda: f"[{label.upper()}]")
162
+ synthetic = fn()
163
+ _replacement_cache[key] = synthetic
164
+ return synthetic
165
+
166
+
167
+ def load_model(checkpoint: str | None, device: str) -> OPF:
168
+ path = resolve_checkpoint(checkpoint)
169
+ print(f"Loading model from: {path}")
170
+ model = OPF(model=path, device=device)
171
+ print("Model ready.\n")
172
+ return model
173
+
174
+
175
+ def detect_text_columns(df: pd.DataFrame) -> list[str]:
176
+ """Auto-detect columns containing meaningful text (not IDs or numbers)."""
177
+ text_cols = []
178
+ for col in df.columns:
179
+ sample = df[col].dropna().astype(str)
180
+ if len(sample) == 0:
181
+ continue
182
+ long_strings = sample[sample.str.len() > 5]
183
+ if len(long_strings) / len(sample) > 0.5:
184
+ text_cols.append(col)
185
+ return text_cols
186
+
187
+
188
+ def apply_synthetic(text: str, result) -> tuple[str, list[dict]]:
189
+ """
190
+ Replace detected spans in text with consistent synthetic values.
191
+ Works character by character from the end so offsets stay valid.
192
+ """
193
+ spans = sorted(result.detected_spans, key=lambda s: s.start, reverse=True)
194
+ synthetic_text = text
195
+ replacements = []
196
+
197
+ for span in spans:
198
+ original_value = text[span.start:span.end]
199
+ fake_value = get_fake(span.label, original_value)
200
+ synthetic_text = synthetic_text[:span.start] + fake_value + synthetic_text[span.end:]
201
+ replacements.append({
202
+ "label": span.label,
203
+ "original": original_value,
204
+ "replacement": fake_value,
205
+ })
206
+
207
+ return synthetic_text, replacements
208
+
209
+
210
+ def clean_sheet(
211
+ df: pd.DataFrame,
212
+ model: OPF,
213
+ columns: list[str],
214
+ sheet_name: str,
215
+ ) -> tuple[pd.DataFrame, list[dict]]:
216
+ audit_log = []
217
+ df_clean = df.copy()
218
+
219
+ for col in columns:
220
+ if col not in df.columns:
221
+ print(f" Warning: column '{col}' not found in sheet '{sheet_name}', skipping.")
222
+ continue
223
+
224
+ clean_col = f"{col}_clean"
225
+ df_clean[clean_col] = df[col].astype(str)
226
+
227
+ print(f" Processing column: '{col}'")
228
+ for idx, cell_value in tqdm(df[col].items(), total=len(df), leave=False):
229
+ if pd.isna(cell_value) or str(cell_value).strip() == "":
230
+ continue
231
+
232
+ text = str(cell_value)
233
+ result = model.redact(text)
234
+
235
+ if not result.detected_spans:
236
+ df_clean.at[idx, clean_col] = text
237
+ continue
238
+
239
+ synthetic_text, replacements = apply_synthetic(text, result)
240
+ df_clean.at[idx, clean_col] = synthetic_text
241
+
242
+ for r in replacements:
243
+ audit_log.append({
244
+ "sheet": sheet_name,
245
+ "row": idx + 2,
246
+ "column": col,
247
+ "pii_type": r["label"],
248
+ "original_value": r["original"],
249
+ "replaced_with": r["replacement"],
250
+ "original_text": text[:120],
251
+ "synthetic_text": synthetic_text[:120],
252
+ })
253
+
254
+ return df_clean, audit_log
255
+
256
+
257
+ def write_output(
258
+ input_path: Path,
259
+ sheet_data: dict[str, pd.DataFrame],
260
+ audit_log: list[dict],
261
+ output_path: Path,
262
+ ):
263
+ with pd.ExcelWriter(output_path, engine="openpyxl") as writer:
264
+ for sheet_name, df in sheet_data.items():
265
+ df.to_excel(writer, sheet_name=sheet_name, index=False)
266
+
267
+ if audit_log:
268
+ audit_df = pd.DataFrame(audit_log)
269
+ audit_df.to_excel(writer, sheet_name="PII_Audit_Log", index=False)
270
+
271
+ _format_audit_sheet(output_path)
272
+ print(f"\nSaved: {output_path}")
273
+
274
+
275
+ def _format_audit_sheet(output_path: Path):
276
+ wb = load_workbook(output_path)
277
+ if "PII_Audit_Log" not in wb.sheetnames:
278
+ wb.save(output_path)
279
+ return
280
+
281
+ ws = wb["PII_Audit_Log"]
282
+
283
+ for cell in ws[1]:
284
+ cell.font = BOLD
285
+ cell.fill = HEADER_FILL
286
+
287
+ headers = [cell.value for cell in ws[1]]
288
+ for col_name in ("original_value", "replaced_with"):
289
+ if col_name in headers:
290
+ col_idx = headers.index(col_name) + 1
291
+ for row in ws.iter_rows(min_row=2, min_col=col_idx, max_col=col_idx):
292
+ for cell in row:
293
+ cell.fill = HIGHLIGHT
294
+
295
+ for col_cells in ws.columns:
296
+ max_len = max((len(str(c.value or "")) for c in col_cells), default=10)
297
+ ws.column_dimensions[col_cells[0].column_letter].width = min(max_len + 4, 60)
298
+
299
+ wb.save(output_path)
300
+
301
+
302
+ def main():
303
+ parser = argparse.ArgumentParser(description="Clean PII from Excel files using OPF + Faker.")
304
+ parser.add_argument("input", help="Path to input .xlsx file")
305
+ parser.add_argument("--columns", nargs="+", help="Column names to process (default: auto-detect)")
306
+ parser.add_argument("--sheet", help="Process a specific sheet only")
307
+ parser.add_argument("--all-sheets", action="store_true", help="Process all sheets")
308
+ parser.add_argument("--checkpoint", help="Path to model checkpoint directory")
309
+ parser.add_argument("--device", default="cuda", choices=["cuda", "cpu"])
310
+ parser.add_argument("--output", help="Output file path (default: input_cleaned.xlsx)")
311
+ args = parser.parse_args()
312
+
313
+ input_path = Path(args.input)
314
+ if not input_path.exists():
315
+ print(f"Error: file not found: {input_path}")
316
+ sys.exit(1)
317
+
318
+ output_path = Path(args.output) if args.output else input_path.with_stem(input_path.stem + "_cleaned")
319
+
320
+ model = load_model(args.checkpoint, args.device)
321
+
322
+ all_sheets = pd.read_excel(input_path, sheet_name=None)
323
+
324
+ if args.all_sheets:
325
+ sheets_to_process = list(all_sheets.keys())
326
+ elif args.sheet:
327
+ if args.sheet not in all_sheets:
328
+ print(f"Error: sheet '{args.sheet}' not found. Available: {list(all_sheets.keys())}")
329
+ sys.exit(1)
330
+ sheets_to_process = [args.sheet]
331
+ else:
332
+ sheets_to_process = [list(all_sheets.keys())[0]]
333
+
334
+ cleaned_sheets = {}
335
+ full_audit_log = []
336
+
337
+ for sheet_name in sheets_to_process:
338
+ df = all_sheets[sheet_name]
339
+ print(f"\nSheet: '{sheet_name}' - {len(df)} rows, {len(df.columns)} columns")
340
+
341
+ cols = args.columns if args.columns else detect_text_columns(df)
342
+ if not args.columns:
343
+ print(f" Auto-detected text columns: {cols}")
344
+
345
+ if not cols:
346
+ print(" No text columns found, skipping.")
347
+ cleaned_sheets[sheet_name] = df
348
+ continue
349
+
350
+ df_clean, audit_log = clean_sheet(df, model, cols, sheet_name)
351
+ cleaned_sheets[sheet_name] = df_clean
352
+ full_audit_log.extend(audit_log)
353
+
354
+ write_output(input_path, cleaned_sheets, full_audit_log, output_path)
355
+
356
+ print(f"\n{'-'*50}")
357
+ print(f"PII spans detected and replaced: {len(full_audit_log)}")
358
+ if full_audit_log:
359
+ from collections import Counter
360
+ counts = Counter(entry["pii_type"] for entry in full_audit_log)
361
+ for label, count in counts.most_common():
362
+ print(f" {label:<25} {count}")
363
+ print(f"{'-'*50}")
364
+ print(f"Clean file: {output_path}")
365
+ print(f"Unique PII values replaced: {len(_replacement_cache)}")
366
+
367
+
368
+ if __name__ == "__main__":
369
+ main()
dummy_procurement_data.xlsx ADDED
Binary file (13.7 kB). View file
 
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ git+https://github.com/openai/privacy-filter.git
2
+ huggingface_hub
3
+ pandas
4
+ openpyxl
5
+ faker
6
+ tqdm