DP-Embed-Invert: Practical Local Text Anonymization via Metric-DP on Inverter-Friendly Embeddings
1. Introduction
The "anonymize before sending" use case — strip PII from a document before pasting it into a third-party LLM, search engine, or message — is currently served by two largely disjoint families of tools:
- Heuristic redactors such as Microsoft Presidio, regex-based sanitizers (e.g. erguteb/local-text-anonymizer), and rule + small-NER pipelines. These are fast, fully local, and inspectable, but they have unbounded false-negative rate by construction: anything the rules miss is leaked verbatim.
- Neural redactors such as OpenAI's Privacy Filter (April 2026, 1.5B-parameter bidirectional token classifier with constrained Viterbi decoding). These are higher-precision in the categories they cover, but a third-party benchmark (Tonic.ai, April 2026) reports recall in the 10–38 % range across web, EHR, legal, and call-transcript domains, and OpenAI itself describes the tool as a "redaction aid, not a safety guarantee."
Neither family produces text with a formal (ε, δ)-DP guarantee. Metric-DP-on-embeddings work (Feyisetan et al., 2020, and follow-ups) does, but most published systems either (a) embed and release the embedding rather than re-decoding to text, or (b) use a small bag-of-words inverter that does not produce fluent natural language.
We ask a narrower, more practical question:
Can a fully-local artifact, built from off-the-shelf open-weight components, produce a rewritten version of the input text with an explicit per-document
(ε, δ)-DP guarantee for the rewriting layer, and at what cost to utility?
The artifact has three pieces stitched together:
- The bundled regex sanitizer to placeholder spans it can identify (
[EMAIL],[PERSON], …). - A metric-DP rewriting layer: per-chunk L2-clipped GTR encoder + analytic Gaussian mechanism (Balle & Wang, 2018).
- The published
jxm/gtr__nq__32__correctcorrector (Morris et al., 2023) to decode noisy embeddings back to text.
Our claim is restricted to practicality under the stated constraints; we make no claim of SOTA on PII detection or DP-text utility benchmarks.
2. Methodology
2.1 Pipeline
For an input string T:
- Run
regex_privacy_sanitizer.py→ list of(start, end, category, placeholder)detections. - Split
Tinto a sequence of alternating spans: PII placeholder, plain-text chunk, PII placeholder, …. LetK= number of plain-text chunks. - For each plain-text chunk
c_k(k = 1…K):- Embed:
e_k = mean_pool(GTR-t5-base.encoder(c_k))∈ ℝ⁷⁶⁸. Encoder only — no Dense projection, no L2-normalize. (This matches the distributionvec2textwas trained on; see § 4.1.) - Clip:
ē_k = e_k · min(1, C / ‖e_k‖₂). We useC = 1.5, justified by the empirical norm distribution‖e_k‖ ≈ 1.016 ± 0.111on NQ texts. - Add noise:
ẽ_k = ē_k + 𝒩(0, σ² I_{768})with σ from the analytic Gaussian mechanism for(ε_chunk, δ_chunk)-DP at L2 sensitivityΔ = 2C = 3.0. - Decode:
c̃_k = vec2text.invert(ẽ_k, num_steps=s).
- Embed:
- Re-stitch placeholders and rewritten chunks.
2.2 DP accounting
- Mechanism: analytic Gaussian (Balle & Wang, 2018, Algorithm 1). With
scipyavailable we compute σ exactly; otherwise we fall back to the classical boundσ = Δ √(2 ln(1.25/δ)) / ε, which is strictly larger and so still satisfies(ε, δ)-DP. - Composition: basic sequential —
ε_chunk = ε / K,δ_chunk = δ / K. This is conservative; advanced (RDP / GDP) accounting would tighten the per-chunk budget. - Default:
(ε, δ) = (16, 10⁻³)per document.
2.3 Threat model — what is and is not covered
| Component | Covered by formal DP? |
|---|---|
| Noised rewriting of plain-text chunks (steps 3a–3d) | Yes (analytic Gaussian + basic composition). |
| Placeholder substitution (step 1) | No. The regex layer is heuristic, deterministic, and may miss spans (FN) or over-redact (FP). Anything it misses is protected only by the rewriting layer. |
K (number of plain-text chunks) |
Treated as public. A fully sound treatment would either pad K to a fixed value or include it in the accountant. |
| Membership-inference / reconstruction attacks against the inverter outputs | Not evaluated. |
We make these gaps explicit because metric-DP (ε, δ) is not the same notion as the DP-SGD (ε, δ) familiar from training-data privacy. ε in metric DP measures indistinguishability from nearby points in the embedding metric, not from arbitrary inputs. A reader should not compare ε=16 here against ε=1 in DP-SGD literature without re-reading the metric-DP definition.
2.4 Opt-out (whitelist)
whitelist_categories=[c₁, …] causes detections of those categories to not be placeholdered; their content joins the embed/noise/invert path like any other plain text. Rationale: the rewriting layer offers a (weaker) protection even for un-redacted PII, and a user may legitimately want certain categories (e.g. "organization" in a public news article) to survive in some form. The cost is that exact strings can leak with a probability modulated by σ. The opt-out is documented, off by default.
3. Implementation
src/regex_privacy_sanitizer.py— vendored verbatim from erguteb/local-text-anonymizer; pure stdlib.src/dp_embed_invert.py— pipeline + analytic Gaussian σ + CLI; ~400 LOC.vec2text == 0.0.13,transformers == 4.44.2,torch 2.4.1+cu121.- Hardware verified: NVIDIA H100 80 GB, driver 535, CUDA 12.2.
4. Results
All numbers in this section were produced from the bundled scripts at seed 0 on this physical machine. None are imported from external benchmarks.
4.1 Sanity check: GTR embedding normalization
vec2text was trained on the un-normalized output of the T5 encoder + mean-pool path of sentence-transformers/gtr-t5-base (cf. vec2text/models/model_utils.py:143–149), not the canonical SentenceTransformer pipeline (which adds a Dense projection and L2-normalization). On 500 NQ-corpus texts (max-len 32):
| input | mode | BLEU | Tok-F1 | EM |
|---|---|---|---|---|
| un-normalized | inverter only (0 steps) | 51.13 | 75.13 | 11.6 |
| un-normalized | + corrector (20 steps) | 83.85 | 93.01 | 59.0 |
| L2-normalized | inverter only | 37.64 | 66.08 | 3.0 |
| L2-normalized | + corrector | 50.13 | 73.01 | 14.4 |
We therefore feed the inverter un-normalized vectors throughout the rest of this work.
4.2 Three-way comparison vs. heuristic baselines
Dataset: nvidia/Nemotron-PII, 50 examples, length ≤ 800 chars, seed 0. (The originally-targeted AI4Privacy pii-masking-200k is gated on Hugging Face and not loadable from this environment.)
| System | n | leak↓ | sem-sim↑ | len-ratio | sec/ex |
|---|---|---|---|---|---|
| Presidio (analyzer + anonymizer) | 50 | 0.313 | 0.871 | 0.99 | 0.04 |
| Regex sanitizer alone | 50 | 0.543 | 0.843 | 0.90 | 0.04 |
| DP-Embed-Invert (ε=16, δ=10⁻³, 20 steps) | 50 | 0.000 | 0.078 | 0.98 | 5.5 |
leak = fraction of gold PII surface strings that appear (case-insensitive substring) in the output. sem-sim = cosine similarity of all-MiniLM-L6-v2 embeddings of input vs. output (a deliberately different embedding model from GTR, to avoid favoring our own pipeline).
The pipeline trades almost all utility for a zero substring-leak. As § 4.3 below shows, the contributions of the regex layer and the noise layer to that zero are entangled.
4.3 Ablation — privacy budget ε (num_steps = 20, δ = 10⁻³)
| ε | leak↓ | sem-sim↑ | len-ratio | sec/ex |
|---|---|---|---|---|
| ∞ (no noise) | 0.203 | 0.763 | 0.70 | 5.59 |
| 100 | 0.000 | 0.073 | 1.10 | 5.57 |
| 32 | 0.002 | 0.080 | 1.00 | 5.60 |
| 16 (default) | 0.000 | 0.078 | 0.98 | 5.60 |
| 8 | 0.002 | 0.061 | 1.10 | 5.58 |
| 4 | 0.000 | 0.085 | 1.03 | 5.59 |
Two findings worth flagging:
- The DP noise layer is doing real work. At ε = ∞ (no noise, just regex placeholder + lossless embed/invert) the substring-leak is 20.3 % — the inverter, given an exact embedding of a chunk, can recover enough surface text to surface PII surface strings the regex missed. Adding any noise at ε ≤ 100 drives leak to ~0.
- The transition to the noise floor is sharp. Between ε = ∞ and ε = 100 sem-sim drops from 0.76 to 0.07 and then stays there for all smaller ε down to 4. We did not sweep between ε = ∞ and ε = 100 in this run; the entire useful-utility region appears to live in a band of ε we did not cover. (Future work: a finer log-scale sweep around ε ∈ {200 … 1000}.)
4.4 Ablation — corrector iterations (ε = 16, δ = 10⁻³)
| num_steps | leak↓ | sem-sim↑ | len-ratio | sec/ex |
|---|---|---|---|---|
| 0 | 0.000 | 0.097 | 0.83 | 0.57 |
| 1 | 0.000 | 0.097 | 0.83 | 0.57 |
| 5 | 0.000 | 0.069 | 0.96 | 1.63 |
| 10 | 0.000 | 0.077 | 0.98 | 2.95 |
| 20 | 0.000 | 0.078 | 0.98 | 5.57 |
| 50 | 0.000 | 0.078 | 0.98 | 13.45 |
At our DP budget, additional corrector steps do not improve substring-leak or semantic similarity. Wall-clock scales linearly with steps. This contrasts with the no-noise regime in Morris et al. (2023, Table 2), where 20 steps lifts EM from 11.6 to 59.0 — see § 4.1 above. The intuition is that the corrector minimizes the gap between the target embedding and the re-embedding of its current text hypothesis; when the target is heavily noised it does not correspond to any real text, so additional iterations either stay on the same plateau (steps 0/1 are already enough at this budget) or wander to a different plateau of similar embedding distance (no consistent improvement). We therefore lower the default to num_steps = 5 in the bundled demo for ~3× speedup with no measurable utility loss.
4.5 Verbal comparison: OpenAI Privacy Filter (April 2026)
We did not benchmark OpenAI Privacy Filter on the same data. The comparison below is verbal, drawn from OpenAI's blog post and Tonic.ai's third-party benchmark.
| Axis | OpenAI Privacy Filter | DP-Embed-Invert |
|---|---|---|
| Architecture | 1.5 B-param bidirectional token classifier (BIOES + Viterbi) | Regex + 220 M-param GTR encoder + 220 M-param T5 corrector |
| License | Apache-2.0, on HF (openai/privacy-filter) |
Open-weight components |
| Runs locally | Yes (laptop, browser) | Yes (GPU strongly recommended) |
| Action on detected PII | Replace span with mask | Replace span + rewrite the rest with DP noise |
| Formal privacy guarantee | None claimed; OpenAI describes it as a "redaction aid, not a safety guarantee." | (ε, δ)-DP for the rewriting layer under stated assumptions |
| Reported quality | OpenAI claims SOTA on PII-Masking-300k. Tonic.ai's third-party bench on 500+ real-world docs reports F1 0.18–0.65 with recall 10–38 % ("high precision but low recall"). | Our regex backbone has comparable or worse recall; the rewriting layer is the differentiator. |
| Failure modes | Conversational ("Visa ending in 4427"), non-standard formats, layout-dependent PII | Anything the regex layer misses is protected only by the metric-DP rewriting; § 4.3 shows the rewriting is meaningful but the (ε, δ) is per-document, not per-token. |
The two systems target different points on the (utility, leak, guarantee) frontier and are not strict substitutes. OpenAI Privacy Filter is a more capable detect-and-mask model and will be more useful when downstream utility matters and the user is willing to trust the model's coverage. Our pipeline is more useful when an explicit DP accounting is required and the user can tolerate near-total loss of downstream utility at the chosen ε.
5. Discussion and limitations
- Utility at ε = 16 is poor by construction. The rewritten text is essentially decorrelated from the input (sem-sim ≈ 0.08). Use of this pipeline for anything that needs the document's meaning preserved is misguided.
- The transition region of ε is undersampled. The interesting ε band lies between 100 and ∞ for our
K; we did not sweep finely there. Kis treated as public. A fully sound analysis would padKto a fixed value or include it in the accountant.- Composition is basic, not advanced. RDP / GDP would tighten σ at the same total ε for moderate
K. - Inverter OOD.
vec2textwas trained on NQ short passages (≤ 32 tokens). On clinical / legal / call-transcript text the distribution shift degrades inversion even before noise is added. - No empirical attack evaluation. A complete privacy story would include membership-inference and reconstruction attacks against the inverter outputs at various ε. We did not run those.
- Benchmark is small (N = 50) and substitute (Nemotron-PII rather than the gated AI4Privacy).
- OpenAI Privacy Filter comparison is verbal only.
6. Conclusion
dp-embed-invert is a small, honest research artifact: a fully-local hybrid that combines a heuristic regex redactor with a metric-DP rewriting layer built from off-the-shelf open-weight components, with explicit (ε, δ) accounting for the rewriting. It is not SOTA on PII detection and not a substitute for closed neural redactors when downstream utility matters. It is the simplest pipeline we know of that delivers a per-document (ε, δ)-DP guarantee for the rewriting of un-redacted text, runs on a single GPU, and is fully reproducible from a 10-file folder.
Two empirical findings deserve to be carried out of this note:
- The DP noise layer is not vacuous: at ε = ∞ the inverter alone leaks ≈ 20 % of gold PII surface strings (§ 4.3, row 1). Without the noise the pipeline would be strictly worse than Presidio.
- At our DP budget the corrector does not improve quality past 0–1 steps (§ 4.4). Future work that wants useful output should sit closer to ε = ∞ than to ε = 16, and revisit whether more corrector steps then matter.
7. Reproduction
pip install -r requirements.txt
# 3-example demo
python demo/run_demo.py
# 3-way bench (Presidio, regex, ours) — Table 4.2
BENCH_N=50 python bench/run_bench.py
# Both ablations — Tables 4.3 and 4.4
BENCH_N=50 python bench/run_ablation.pyNumbers in §§ 4.2–4.4 land in bench/results.json and bench/ablation_results.json and are deterministic at seed 0.
8. References
- Balle, B., & Wang, Y.-X. (2018). Improving the Gaussian Mechanism for Differential Privacy: Analytical Calibration and Optimal Denoising. ICML.
- Erguteb (2026).
local-text-anonymizer. https://github.com/erguteb/local-text-anonymizer - Feyisetan, O., Balle, B., Drake, T., & Diethe, T. (2020). Privacy- and Utility-Preserving Textual Analysis via Calibrated Multivariate Perturbations. WSDM.
- Mironov, I. (2017). Rényi Differential Privacy. CSF.
- Morris, J. X., Kuleshov, V., Shmatikov, V., & Rush, A. M. (2023). Text Embeddings Reveal (Almost) As Much As Text. EMNLP. arXiv:2310.06816. https://github.com/vec2text/vec2text
- OpenAI (April 2026). Introducing OpenAI Privacy Filter. https://openai.com/index/introducing-openai-privacy-filter/; weights at https://huggingface.co/openai/privacy-filter.
- Tonic.ai (April 2026). Benchmarking OpenAI's Privacy Filter. https://www.tonic.ai/blog/benchmarking-openai-privacy-filter-pii-detection
Reproducibility: Skill File
Use this skill file to reproduce the research with an AI agent.
---
name: mix-dp-anonymizer
description: Local privacy-preserving text rewriter — heuristic regex PII redactor + (ε,δ)-DP Gaussian noise on un-normalized GTR-base embeddings + vec2text inversion (Morris et al., 2023). Produces a rewritten document with explicit per-document differential-privacy accounting for the rewriting layer. Open-weight, fully local, single-GPU.
allowed-tools: Bash(git *), Bash(python *), Bash(pip *), Bash(curl *), Bash(./install.sh), Read, Write
---
# mix-dp-anonymizer (skill)
Read this whole document before running anything. Skip-ahead readers will
make incorrect privacy claims. The DP guarantee covers **only** the
rewriting layer; the regex layer is heuristic with FP/FN. See § 6
"Threat model".
> Companion document: `docs/research_note.md` in the same repo. It
> contains the formal DP analysis, the ablation tables, and a verbal
> comparison against OpenAI Privacy Filter (April 2026). Read it after
> this file.
---
## 0. What this skill does (one-screen overview)
**Input.** A natural-language string `T` (1 sentence to ~1 paragraph).
**Output.** A rewritten string `T'` such that:
1. PII spans the regex layer **detects** are replaced by category
placeholders (`[PERSON]`, `[EMAIL]`, …). These spans are *removed*,
not perturbed.
2. All other text is **rewritten** by: encode → L2-clip → add Gaussian
noise → decode back to text. The encode/decode pair is GTR-base +
`vec2text`. The noise is calibrated by the analytic Gaussian
mechanism (Balle & Wang, 2018) so that the rewriting of every
non-PII chunk is `(ε/K, δ/K)`-DP, and the document-level guarantee is
`(ε, δ)`-DP under basic sequential composition over `K` chunks.
**Defaults.** `ε = 16`, `δ = 10⁻³`, clip `C = 1.5`, `num_steps = 5`.
**Hardware.** Strongly recommended: a CUDA-capable GPU with ≥ 6 GB VRAM
and a CUDA driver ≥ 525 (CUDA ≥ 12.1). On CPU each chunk takes seconds.
Verified on NVIDIA H100 80 GB / driver 535 / CUDA 12.2 /
`torch 2.4.1+cu121`.
---
## 1. Pipeline diagram
```
input text T
│
▼
┌───────────────────────────────┐
│ regex_privacy_sanitizer.py │ → list of detections (start, end, category, placeholder)
└───────────────────────────────┘
│
▼
split T into [chunk₀, [PII], chunk₁, [PII], chunk₂, …, chunk_K]
│
│ ┌─────────────────────────────────────────────────────────┐
│ for │ e_k = mean_pool( GTR-t5-base.encoder(c_k) ) ∈ ℝ⁷⁶⁸ │
└─►each─┤ ē_k = e_k · min(1, C / ‖e_k‖₂) # L2 clip │
c_k │ ẽ_k = ē_k + 𝒩(0, σ² I₇₆₈) # analytic Gaussian│
│ c̃_k = vec2text.invert(ẽ_k, num_steps) │
└─────────────────────────────────────────────────────────┘
│
▼
re-stitch: [chunk₀ → c̃₀] [PII] [chunk₁ → c̃₁] [PII] … [chunk_K → c̃_K]
│
▼
output text T'
```
`σ` is computed from `(ε_chunk, δ_chunk) = (ε/K, δ/K)` via the
**analytic Gaussian mechanism** at L2 sensitivity `Δ = 2C`. With `scipy`
present we compute σ exactly; otherwise we fall back to the strictly-
larger classical bound `σ = Δ √(2 ln(1.25/δ)) / ε` (privacy still holds,
output more noised).
---
## 2. Installation — for AI agents
The repository contains all source. **Clone, then run the included
`install.sh`. Do not skip the verification step in § 2.3.**
### 2.1 Clone
```bash
git clone https://github.com/erguteb/mix-dp-anonymizer.git
cd mix-dp-anonymizer
```
After cloning you should have, at minimum:
```
mix-dp-anonymizer/
├── SKILL.md ← you are here
├── README.md
├── install.sh
├── requirements.txt
├── src/dp_embed_invert.py
├── src/regex_privacy_sanitizer.py
├── demo/run_demo.py
├── bench/run_bench.py
├── bench/run_ablation.py
└── docs/research_note.md
```
Verify:
```bash
test -f src/dp_embed_invert.py && test -f src/regex_privacy_sanitizer.py \
&& echo "OK clone" || echo "MISSING FILES — re-clone"
```
### 2.2 Run the bundled installer
```bash
./install.sh
```
This creates `.venv/` and installs the **pinned** dependency set we
verified on the reference machine. The pin set is deliberate — newer
torch wheels (≥ 2.6) require driver > 545, and `transformers ≥ 4.45`
breaks `vec2text 0.0.13`'s `from_pretrained` path.
| Component | Pin | Why |
|---|---|---|
| `torch` | `2.4.1+cu121` from `download.pytorch.org/whl/cu121` | Works with driver ≥ 525, compatible with `vec2text`. |
| `vec2text` | `0.0.13` | Published inverter/corrector entry-points used by this skill. |
| `transformers` | `4.44.2` | `vec2text 0.0.13` is incompatible with `transformers ≥ 4.45` (meta-device error in `from_pretrained`). |
| `accelerate` | `0.34.2` | Matches `transformers 4.44.2`. |
| `tokenizers` | `< 0.20` | Matches `transformers 4.44.2`. |
| `huggingface_hub` | `< 0.25` | Older API used by `vec2text 0.0.13`. |
| `sentence-transformers` | `3.0.1` | Utility-metric model in benchmarks. |
| `datasets` | `2.21.0` | Benchmark dataset loading. |
| `numpy` | `< 2` | Matches the rest of the pin set. |
| `scipy` | latest | **Optional** — exact analytic Gaussian σ. Falls back to a strict over-estimate if missing. |
| `presidio-analyzer` / `presidio-anonymizer` + `en_core_web_lg` spaCy model | latest | **Optional** — bench baseline only. Disable by setting `INSTALL_PRESIDIO=0`. |
Activate the venv before running anything below:
```bash
source .venv/bin/activate
```
### 2.3 Verify the install (mandatory)
Run **all four** checks. If any fails, jump to § 2.4.
```bash
# (a) torch + CUDA
python -c "import torch; assert torch.cuda.is_available(), 'No CUDA'; print('cuda OK', torch.version.cuda, torch.__version__)"
# (b) vec2text + corrector download (~600 MB, first run only)
python -c "import vec2text; vec2text.load_pretrained_corrector('gtr-base'); print('vec2text OK')"
# (c) GTR-base encoder download
python -c "from transformers import AutoModel; AutoModel.from_pretrained('sentence-transformers/gtr-t5-base').encoder; print('gtr OK')"
# (d) end-to-end: rewrite one short sentence
python src/dp_embed_invert.py --text "Contact Jane Doe at jane@example.com." --epsilon 16 --steps 5
```
Expected output of (d) is approximately:
```
=== ORIGINAL ===
Contact Jane Doe at jane@example.com.
=== OUTPUT ===
<some inverter-generated paraphrase> [PERSON] <something> [EMAIL] <something>
[DP] eps_total=16.0 delta_total=0.001 K=2 eps/chunk=8.0000 sigma=1.4992 clip=1.5
```
The exact wording of the rewritten parts will differ between runs (they
are noised samples). The placeholders, `K`, `eps/chunk`, and `sigma`
are deterministic functions of the input and the parameters and should
match those above for the same input.
### 2.4 Concrete fallbacks if install fails
These are listed in order of severity. Each includes a verification
command. Do **not** silently continue past a failure of (b) or (c) —
both are required for the DP rewriting layer.
**(F1) `pip install torch==2.4.1+cu121` fails (no GPU, or wrong driver).**
Fall back to the CPU-only torch wheel. The pipeline still runs, but
each example is ~30–60 s instead of ~5 s:
```bash
.venv/bin/pip install 'torch==2.4.1' # CPU wheel, no +cu121 suffix
python -c "import torch; print('cuda?', torch.cuda.is_available())" # expect False
python src/dp_embed_invert.py --text "Hello world." --epsilon 16 --steps 5
```
If your driver is ≥ 545 and you want a newer GPU wheel: stay on
`torch == 2.4.1` regardless. Newer torch (≥ 2.6) introduces a
meta-device guard that breaks `vec2text.load_pretrained_corrector`.
**(F2) `vec2text.load_pretrained_corrector('gtr-base')` fails or hangs.**
Most common cause: HF rate-limit or network instability during the
~600 MB download. Retry with the fast-transfer backend:
```bash
.venv/bin/pip install hf_transfer
HF_HUB_ENABLE_HF_TRANSFER=1 \
python -c "import vec2text; vec2text.load_pretrained_corrector('gtr-base')"
```
If a Hugging Face account is required, set `HF_TOKEN`:
```bash
export HF_TOKEN=hf_xxx # https://huggingface.co/settings/tokens
huggingface-cli login --token "$HF_TOKEN"
```
The inverter is **mandatory** for this skill. There is no offline
substitute. If the inverter cannot be loaded after the retries above,
the only honest fallback is **regex-only mode** (no DP rewriting):
```bash
python src/regex_privacy_sanitizer.py --text "..." --format json
```
In regex-only mode, **the DP guarantee no longer holds**. Document this
clearly in any downstream output and do not advertise the result as
"differentially private."
**(F3) `vec2text` import fails with a `from_pretrained` / meta-device
error like `RuntimeError: You are using from_pretrained with a meta
device context manager …`.**
This means `transformers ≥ 4.45` was pulled in by some other
dependency. Force the pin set:
```bash
.venv/bin/pip install 'transformers==4.44.2' 'accelerate==0.34.2' \
'tokenizers<0.20' 'huggingface_hub<0.25' --force-reinstall --no-deps
python -c "import vec2text; vec2text.load_pretrained_corrector('gtr-base'); print('OK')"
```
**(F4) `scipy` missing or fails to import.**
Not fatal. The pipeline prints a one-line warning and uses the classical
Gaussian bound, which is strictly larger than the analytic σ — output is
more noised, privacy still holds. To re-enable the exact bound:
```bash
.venv/bin/pip install scipy
```
**(F5) `presidio-*` or `en_core_web_lg` missing.**
Not fatal for the skill itself; only `bench/run_bench.py` uses Presidio.
The script auto-detects this and skips the Presidio baseline with a
`[skip] PRESIDIO unavailable: …` line.
**(F6) `nvidia/Nemotron-PII` (used in `bench/`) fails to load.**
The dataset is public and ungated as of 2026-04. If it becomes
unreachable, switch to a substitute:
```bash
BENCH_DATASET=gretelai/gretel-pii-masking-en-v1 BENCH_N=50 \
python bench/run_bench.py
```
`bench/run_bench.py` and `bench/run_ablation.py` both auto-detect span
schemas with keys `start/end/label`, `entity/types`, or `text/value`.
**(F7) Out of GPU memory.**
The default `max_len=32` and batch-implicit-by-K usage already keep
peak memory low (~3 GB on H100). If you OOM on a smaller GPU, lower
`max_len` (e.g. 24) or run on CPU per F1.
---
## 3. How to call the skill
### 3.1 CLI — single string
```bash
python src/dp_embed_invert.py \
--text "Contact Jane Doe at jane@example.com about the merger." \
--epsilon 16 --delta 1e-3 --steps 5 --seed 0
```
### 3.2 CLI — stdin → JSON
```bash
cat document.txt | python src/dp_embed_invert.py --epsilon 16 --json > out.json
```
### 3.3 Programmatic
```python
import sys; sys.path.insert(0, "src")
from dp_embed_invert import rewrite
res = rewrite(
"Contact Jane Doe at jane@example.com about the merger.",
epsilon=16.0,
delta=1e-3,
clip_radius=1.5,
num_steps=5,
whitelist_categories=None, # see § 3.5
max_len=32, # tokenizer max-len per chunk; matches vec2text training
device=None, # None = auto (CUDA if available)
seed=0,
)
print(res.output) # str — rewritten text
print(res.spans) # list[Span] — per-span trace, see § 4
print(res.sigma) # float — actual noise scale used
print(res.epsilon_per_chunk, res.delta_per_chunk, res.n_chunks)
```
### 3.4 Parameter reference
| Parameter | Type | Default | Meaning |
|---|---|---|---|
| `text` | `str` | — | Input string. |
| `epsilon` | `float` | `16.0` | **Document-level** ε. Pass `math.inf` for the no-noise sanity setting (no DP claim then). |
| `delta` | `float` | `1e-3` | Document-level δ. Splits as `δ/K` per chunk. |
| `clip_radius` | `float` | `1.5` | L2 clip radius `C`. The DP analysis assumes embeddings live in the ball of radius `C`. Default chosen so that ~all GTR-base mean-pooled embedding norms (empirically `1.016 ± 0.111` on NQ) fall inside. |
| `num_steps` | `int` | `5` | `vec2text` corrector iterations. At our default ε this has no measurable utility effect past 0–1; lower = faster. See `docs/research_note.md` § 4.4. |
| `whitelist_categories` | `list[str] \| None` | `None` | Detection categories that should **not** be placeholdered. See § 3.5. |
| `max_len` | `int` | `32` | Per-chunk tokenizer max-length. Matches `jxm/gtr__nq__32`'s training distribution. |
| `device` | `'cuda' \| 'cpu' \| None` | `None` | `None` = auto. |
| `seed` | `int \| None` | — | Seeds the Gaussian noise + corrector sampling. Set for reproducibility. |
### 3.5 Whitelist (opt-out of redacting)
`whitelist_categories=["organization", "location"]` causes those
detector categories to be left in place — they enter the embed/noise/
invert path like any other plain text. Useful when the user wants those
categories to *survive in some form* (perturbed) rather than be
replaced by `[ORG]` / `[LOCATION]`. Cost: their exact strings can still
leak with non-zero probability, modulated only by σ.
Category strings match what the regex sanitizer outputs (case-
insensitive): `full person name`, `email address`, `phone number`,
`organization`, `location`, `ip address`, `card number`, …. Run
`python src/regex_privacy_sanitizer.py --list-rules` for the full set.
---
## 4. Output format
### 4.1 Text mode (`python src/dp_embed_invert.py …`)
```
=== ORIGINAL ===
<input text verbatim>
=== OUTPUT ===
<rewritten text>
[DP] eps_total=16.0 delta_total=0.001 K=4 eps/chunk=4.0000 sigma=2.7196 clip=1.5
```
The trailing `[DP]` line is **not optional** — it is the privacy
receipt. A caller that stores or forwards the output without storing
this line is not recording the privacy parameters used.
### 4.2 JSON mode (`--json`)
```json
{
"original": "Contact Jane Doe at jane@example.com.",
"output": "<rewritten>",
"spans": [
{ "text": "Contact ", "is_pii": false, "category": null,
"placeholder": null, "rewritten": "<inverted chunk>", "chunk_idx": 0 },
{ "text": "Jane Doe", "is_pii": true, "category": "full person name",
"placeholder": "[PERSON]", "rewritten": null, "chunk_idx": null },
{ "text": " at ", "is_pii": false, "category": null,
"placeholder": null, "rewritten": "<inverted chunk>", "chunk_idx": 1 },
{ "text": "jane@example.com", "is_pii": true, "category": "email address",
"placeholder": "[EMAIL]", "rewritten": null, "chunk_idx": null },
{ "text": ".", "is_pii": false, "category": null, "placeholder": null,
"rewritten": "<inverted chunk>", "chunk_idx": 2 }
],
"epsilon_total": 16.0,
"delta_total": 0.001,
"epsilon_per_chunk": 5.333,
"delta_per_chunk": 0.000333,
"sigma": 2.012,
"clip_radius": 1.5,
"n_chunks": 3
}
```
`spans[*].rewritten` is the per-chunk inverter output **before**
re-stitching; comparing it to `spans[*].text` shows what the noise +
inverter did to that chunk specifically. `n_chunks` is the K used in
the basic-composition split.
### 4.3 Intermediate artifacts you can inspect
| Field | Type | What it tells you |
|---|---|---|
| `res.spans[k].text` (`is_pii=False`) | `str` | The original plain-text chunk fed to the encoder. |
| `res.spans[k].rewritten` | `str` | The inverter output for that chunk. |
| `res.spans[k].placeholder` (`is_pii=True`) | `str` | The category tag substituted into the output. |
| `res.sigma` | `float` | σ actually used (analytic Gaussian σ, or its classical fallback if `scipy` is missing). |
| `res.epsilon_per_chunk` | `float` | ε allocated to each chunk after composition. |
| `res.n_chunks` | `int` | K, the number of plain-text chunks. |
---
## 5. Demo
### 5.1 Three-example demo
```bash
python demo/run_demo.py
```
Runs three preset inputs at `(ε=16, δ=10⁻³, num_steps=5)`. Verbatim
output from one example on the reference machine (the rewritten text
will differ between runs — these are noised samples — but the bracketed
quantities are deterministic):
```
========= example 1 =========
--- input : Contact Jane Doe at jane@example.com or call 415-555-0188 about the merger. Our office is downtown.
--- output : at Albenius Palisade was Albe [PERSON] "The Matbos, remix mountain", [EMAIL] [PHONE] , and the genre limits the pagini
--- DP : K=4 eps/chunk=4.000 sigma=2.7196 clip=1.5
```
Read this output as:
- The placeholders (`[PERSON]`, `[EMAIL]`, `[PHONE]`) appear in the same
order as the corresponding PII spans in the input.
- Everything between placeholders is the *inverter's noised reconstruction*
of the corresponding plain-text chunk. At ε=16 split four ways
(K=4 → ε/chunk=4) the noise σ ≈ 2.7 dominates the embedding norm
(~1.0), so the rewritten chunks are fluent-but-unrelated to the
input. **This is the honest behavior of metric DP at this budget; it
is not a bug.** Higher utility requires larger ε; see § 4.3 of the
research note.
### 5.2 Step-by-step demo (showing intermediate artifacts)
```python
import sys; sys.path.insert(0, "src")
from dp_embed_invert import rewrite
res = rewrite(
"Contact Jane Doe at jane@example.com about the merger.",
epsilon=16.0, delta=1e-3, num_steps=5, seed=0,
)
print(f"K = {res.n_chunks}, ε/chunk = {res.epsilon_per_chunk:.3f}, σ = {res.sigma:.3f}")
for i, s in enumerate(res.spans):
tag = f"[PII:{s.category}]" if s.is_pii else f"[plain k={s.chunk_idx}]"
src = s.text
dst = s.placeholder if s.is_pii else s.rewritten
print(f" span {i} {tag:<28} {src!r:60} → {dst!r}")
print()
print("FINAL:", res.output)
```
The per-span listing makes it visible *which* PII categories the regex
caught (and which it missed — visible as PII strings still inside the
`text` of `is_pii=False` spans).
### 5.3 Reproducing the research-note tables
```bash
# Table 4.2 — three-way comparison (Presidio / regex / ours)
BENCH_N=50 python bench/run_bench.py
# → bench/results.json
# Table 4.3 — ε ablation (steps=20)
# Table 4.4 — num_steps ablation (ε=16)
BENCH_N=50 python bench/run_ablation.py
# → bench/ablation_results.json
```
Both scripts are deterministic at the seed they set internally
(`seed = 0`). On an H100, `run_bench.py` takes ~5 minutes;
`run_ablation.py` takes ~30 minutes (12 configs × 50 examples).
---
## 6. Threat model — read this before using
The privacy guarantee covers **only** the rewriting layer (steps
3a–3d of § 1) under the following assumptions:
| Assumption | Holds? |
|---|---|
| Embedding L2 norm ≤ `C` after clipping. | **Yes — by construction.** |
| `K` (number of plain-text chunks) is public. | **We treat it as such.** A fully sound treatment would pad `K` to a fixed value or include it in the accountant. |
| Adversary observes only the final output `T'`, not the intermediate embeddings or noise. | Standard. |
| The regex anonymizer's placeholder decisions don't leak. | **Does not hold.** The detector is heuristic with FP and FN. We make **no DP claim** over its decisions. Anything it misses is protected only by the rewriting layer. |
| ε at this scale (`16`/document) means the same as ε in DP-SGD literature. | **It does not.** Metric DP `(ε, δ)` measures indistinguishability from *nearby* points in the embedding metric, not from arbitrary inputs. Do not compare ε=16 here against ε=1 in DP-SGD literature. |
If you need stronger semantics (ε per-token, advanced composition,
empirical attack robustness), this skill is not sufficient. See
`docs/research_note.md` § 5 for the full limitations list.
---
## 7. References
Citations for the methods this skill stitches together; full BibTeX-style
entries are in `docs/research_note.md` § 8.
- Morris, J. X., Kuleshov, V., Shmatikov, V., & Rush, A. M. (2023).
*Text Embeddings Reveal (Almost) As Much As Text.* EMNLP. arXiv:2310.06816.
Code: <https://github.com/vec2text/vec2text>
- Balle, B., & Wang, Y.-X. (2018). *Improving the Gaussian Mechanism for
Differential Privacy: Analytical Calibration and Optimal Denoising.* ICML.
- Erguteb (2026). `local-text-anonymizer`.
<https://github.com/erguteb/local-text-anonymizer> — bundled as
`src/regex_privacy_sanitizer.py`.
---
## 8. Quick checklist for an AI agent invoking this skill
1. `git clone https://github.com/erguteb/mix-dp-anonymizer.git && cd mix-dp-anonymizer`
2. `./install.sh && source .venv/bin/activate`
3. Run **all four** verification commands in § 2.3.
4. If any of them fails, follow the matching fallback in § 2.4 **before**
attempting to rewrite real text.
5. For each input document, call `rewrite(text, epsilon=…, delta=…)`
(programmatic) or the CLI in § 3.1.
6. Persist the `[DP]` receipt line / the
`epsilon_total / delta_total / sigma / n_chunks` fields together with
the output. Without those, downstream consumers cannot audit the
privacy claim.
7. **Do not** describe the output as "private" without the qualifying
statements from § 6.