🕵️ Rewriting Biographies¶
Instead of replacing entities with tokens, rewrite mode generates a
privacy-safe transformation of the entire text. The run() / preview() pipeline:
- Detects entities (same as replace mode, plus latent entity detection)
- Classifies the domain and assigns sensitivity dispositions
- Generates a rewritten version that obscures sensitive entities
- Evaluates quality (utility) and privacy (leakage) with an automated repair loop
Afterward, a separate optional evaluate() call runs LLM judges for
detection validity and holistic privacy, quality, and style scores.
📚 What you'll learn¶
- Configure rewrite mode with
PrivacyGoalto specify what to protect and what to preserve - Set evaluation criteria and risk tolerance for automated quality checks
- Preview rewritten text and inspect utility / leakage scores
- Triage flagged records with
needs_human_review - Run
evaluate()for detection validity and holistic judge scores (privacy, quality, style)
Tip: First time running notebooks? Start with setup instructions.
⚙️ Setup¶
- Install the notebook extra, then provide credentials for the configured external LLM providers.
create_anonymizer()starts pinned GLiNER2 locally and selects CUDA, MPS, or CPU automatically.- The default external LLM models currently use OpenRouter; its terms and privacy practices apply.
Data boundary: GLiNER2 detection runs locally in this notebook environment. LLM-assisted validation, augmentation, replacement, rewriting, repair, and evaluation use configured external hosts and may send them original or tagged input text. Do not treat this configuration as an all-local privacy boundary.
configure_logging(LoggingConfig.default())keeps logs at INFO. Switch toLoggingConfig.debug()when troubleshooting.
import getpass
import os
import subprocess
import sys
package_spec = os.getenv("ANONYMIZER_NOTEBOOK_PACKAGE", "nemo-anonymizer[notebooks]")
subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", package_spec])
0
from anonymizer.notebooks import required_api_key_environment_variables
for variable in required_api_key_environment_variables():
key = getpass.getpass(f"Enter {variable}: ").strip()
if not key:
raise RuntimeError(f"{variable} is required by the configured external model providers.")
os.environ[variable] = key
from anonymizer import (
AnonymizerConfig,
AnonymizerInput,
LoggingConfig,
PrivacyGoal,
Rewrite,
configure_logging,
)
from anonymizer.notebooks import create_anonymizer, stop_local_runtime
configure_logging(LoggingConfig.default())
anonymizer = create_anonymizer()
[00:33:02] [INFO] 🔧 Anonymizer initialized with 5 model configs
[00:33:02] [INFO] |-- 🔎 detector: local-gliner2-pii
[00:33:02] [INFO] |-- ✅ validator: gpt-oss-120b
[00:33:02] [INFO] |-- 🧩 augmenter: gpt-oss-120b
GLiNER2 ready: model=fastino/gliner2-privacy-filter-PII-multi revision=59894c087cb2923b01f337d4ee72f6ff84d5bdd6 device=mps endpoint=http://127.0.0.1:55416/v1
📦 Input data¶
- Same biographies dataset used in earlier notebooks -- familiar data makes it easy to compare rewrite output against replace output.
input_data = AnonymizerInput(
source="https://raw.githubusercontent.com/NVIDIA-NeMo/Anonymizer/refs/heads/main/docs/data/NVIDIA_synthetic_biographies.csv",
text_column="biography",
data_summary="Biographical profiles",
)
🎛️ Configure¶
PrivacyGoalspells out what to protect and what to preserve -- this gives the rewriter clear, domain-specific guidance.risk_tolerance(default"low") andmax_repair_iterations(default3) control the automated quality gate -- see Risk tolerance for presets.
config = AnonymizerConfig(
rewrite=Rewrite(
privacy_goal=PrivacyGoal(
protect="All direct identifiers and quasi-identifier combinations (names, locations, employers, dates)",
preserve="Career trajectory, educational background, and professional accomplishments",
),
risk_tolerance="low",
max_repair_iterations=3,
),
)
👁️ Preview¶
preview()runs on a small sample so you can iterate on privacy goals and evaluation criteria before committing to a full run.
preview = anonymizer.preview(
config=config,
data=input_data,
num_records=3,
)
preview.display_record(0)
[00:33:02] [INFO] 👀 Preview mode: 📂 Loaded 3 records from https://raw.githubusercontent.com/NVIDIA-NeMo/Anonymizer/refs/heads/main/docs/data/NVIDIA_synthetic_biographies.csv (column: 'biography')
[00:33:02] [INFO] 🔍 Running entity detection on 3 records
[00:33:02] [INFO] detection labels in scope: (default: 65 labels; see anonymizer.DEFAULT_ENTITY_LABELS for list)
[00:35:00] [INFO] |-- 📋 Detection complete — 86 entities found across 3 records (0 failed) [117.8s]
[00:35:00] [INFO] |-- labels: first_name=23, organization_name=7, occupation=6, age=5, field_of_study=5, company_name=5, city=4, degree=4, university=4, last_name=3, state=3, language=3, political_view=3, place_name=2, race_ethnicity=2, street_address=2, religious_belief=2, nationality=1, date_of_birth=1, landmark=1
[00:35:00] [INFO] ✏️ Running rewrite pipeline
[00:39:23] [INFO] Evaluate-repair loop: all rows pass at iteration 0
[00:39:23] [INFO] |-- 📋 Rewrite complete (0 failed) [262.9s]
[00:39:23] [INFO] 🎉 Pipeline complete — 3 records processed, 0 total failures
preview.display_record(1)
How to interpret leakage: Leakage is measured against the sensitivity disposition. Details marked
leave_as_ismay remain without increasingleakage_mass. If an output retains something you expected the privacy goal to protect, inspect the Entity Disposition table.
🚀 Full run¶
result.dataframehas user-facing columns: rewritten text, scores, and the review flag.result.trace_dataframehas every intermediate column for debugging.
result = anonymizer.run(config=config, data=input_data)
result.dataframe.head()
[00:39:24] [INFO] 📂 Loaded 25 records from https://raw.githubusercontent.com/NVIDIA-NeMo/Anonymizer/refs/heads/main/docs/data/NVIDIA_synthetic_biographies.csv (column: 'biography')
[00:39:24] [INFO] 🔍 Running entity detection on 25 records
[00:39:24] [INFO] detection labels in scope: (default: 65 labels; see anonymizer.DEFAULT_ENTITY_LABELS for list)
[00:45:34] [INFO] |-- 📋 Detection complete — 697 entities found across 25 records (0 failed) [370.3s]
[00:45:34] [INFO] |-- labels: first_name=153, field_of_study=50, occupation=47, organization_name=46, company_name=44, city=40, university=35, degree=27, last_name=26, age=26, state=25, political_view=25, religious_belief=24, street_address=23, language=19, race_ethnicity=18, place_name=17, nationality=10, county=10, date_of_birth=9, employment_status=7, education_level=6, date=5, landmark=1, event_name=1, country=1, sexuality=1, postcode=1
[00:45:34] [INFO] ✏️ Running rewrite pipeline
[00:46:43] [WARNING] Replacement map repaired synthetic-original collision entries for record e5b7b47e3bf55ba0af4d43c7fd6ad509; repaired=1 (repaired_by_label={'state': 1})
[00:48:55] [WARNING] Required rewrite replacement was unavailable; marking rewritten text unavailable.
[00:49:21] [WARNING] Entity 23 (label='first_name'): combined_risk_level='low' conflicts with protection_method_suggestion='remove'; promoting risk to 'medium'.
[00:49:21] [WARNING] Entity 23 (label='first_name'): combined_risk_level='low' conflicts with protection_method_suggestion='remove'; promoting risk to 'medium'.
[00:49:21] [WARNING] Entity 23 (label='first_name'): combined_risk_level='low' conflicts with protection_method_suggestion='remove'; promoting risk to 'medium'.
[00:49:21] [WARNING] Entity 24 (label='first_name'): combined_risk_level='low' conflicts with protection_method_suggestion='remove'; promoting risk to 'medium'.
[00:49:21] [WARNING] Entity 24 (label='first_name'): combined_risk_level='low' conflicts with protection_method_suggestion='remove'; promoting risk to 'medium'.
[00:49:21] [WARNING] Entity 24 (label='first_name'): combined_risk_level='low' conflicts with protection_method_suggestion='remove'; promoting risk to 'medium'.
[00:58:08] [INFO] Evaluate-repair loop iteration 0: 5/25 rows need repair
[00:59:05] [INFO] Evaluate-repair loop iteration 1: 2/25 rows need repair
[00:59:45] [INFO] Evaluate-repair loop iteration 2: 2/25 rows need repair
[01:00:17] [INFO] |-- 📋 Rewrite complete (0 failed) [883.0s]
[01:00:17] [INFO] 🎉 Pipeline complete — 25 records processed, 0 total failures
| biography | biography_rewritten | utility_score | leakage_mass | weighted_leakage_rate | any_high_leaked | needs_human_review | |
|---|---|---|---|---|---|---|---|
| 0 | Bobby Watford, a 40‑year‑old Mexican veterinar... | Ethan Hawthorne, an early‑40s Mexican veterina... | 0.98 | 0.0 | 0.0 | False | False |
| 1 | Idilio Bell is a 37‑year‑old astronomer living... | Luca Hawthorne is an astronomer in his late 30s... | 0.908333 | 0.0 | 0.0 | False | False |
| 2 | Jodi Allison, 36, lives at 204 Bluegrass in Cl... | Leah Harper, 36, lives at 312 Maple Lane in Cl... | 0.945455 | 0.0 | 0.0 | False | False |
| 3 | James Mills is a 69‑year‑old paramedic who liv... | Robert Harper is a paramedic in his late 60s w... | 0.87 | 0.0 | 0.0 | False | False |
| 4 | Nancy Burton is a 21‑year‑old cashier who live... | Aisha Khan is a 21‑year‑old cashier who lives ... | 0.975 | 0.0 | 0.0 | False | False |
result.dataframe[["biography_rewritten", "utility_score", "leakage_mass", "needs_human_review"]].head()
| biography_rewritten | utility_score | leakage_mass | needs_human_review | |
|---|---|---|---|---|
| 0 | Ethan Hawthorne, an early‑40s Mexican veterina... | 0.98 | 0.0 | False |
| 1 | Luca Hawthorne is an astronomer in his late 30s... | 0.908333 | 0.0 | False |
| 2 | Leah Harper, 36, lives at 312 Maple Lane in Cl... | 0.945455 | 0.0 | False |
| 3 | Robert Harper is a paramedic in his late 60s w... | 0.87 | 0.0 | False |
| 4 | Aisha Khan is a 21‑year‑old cashier who lives ... | 0.975 | 0.0 | False |
result.trace_dataframe.columns.tolist()
['biography', '_anonymizer_record_id', '_raw_detected_entities', '_seed_entities', '_tag_notation', '_seed_validation_candidates', '_seed_tagged_text', '_validated_entities', '_seed_entities_json', '_initial_tagged_text', '_validated_seed_entities', '_augmented_entities', '_merged_entities', '_merged_tagged_text', '_validation_candidates', '_detected_entities', 'biography_with_spans', '_latent_entities', 'final_entities', '_entities_by_value', '_replacement_map', '_replacement_map_source', '_domain', '_domain_supplement', '_domain_supplement_privacy', '_sensitivity_disposition', '_privacy_qa', '_rewrite_disposition_block', '_sensitivity_disposition_block', '_replacement_map_for_prompt', '_rewrite_tagged_text', '_replacement_application', '_rewrite_replacement_ready', '_rewrite_baseline_text', '_meaning_units', '_meaning_units_serialized', '_full_rewrite', 'biography_rewritten', '_quality_qa', '_repair_iterations', '_quality_qa_reanswer', '_privacy_qa_reanswer', '_quality_qa_compare', 'utility_score', 'leakage_mass', 'weighted_leakage_rate', 'any_high_leaked', '_needs_repair', '_leaked_privacy_items', '_rewritten_text__next', 'needs_human_review']
🚩 Filter by review flag¶
- Records where automated metrics exceed thresholds are flagged for manual review.
needs_human_reviewis threshold-based, so a record can have small nonzero leakage without being flagged.- Use this to prioritize human attention on the records that need it most.
- See Working with flagged records for guidance on diagnosing and resolving flagged records.
df = result.dataframe
flagged = df[df["needs_human_review"] == True] # noqa: E712
print(f"{len(flagged)} of {len(df)} records flagged for human review")
flagged.head()
1 of 25 records flagged for human review
| biography | biography_rewritten | utility_score | leakage_mass | weighted_leakage_rate | any_high_leaked | needs_human_review | |
|---|---|---|---|---|---|---|---|
| 22 | Mildred Johnson is an 62‑year‑old bartender wh... | <NA> | 0.0 | 0.0 | 0.0 | False | True |
🔬 Evaluate (optional)¶
Call evaluate() to run LLM-as-judge scoring on the rewrite result — detection validity and three quality rubrics (privacy, quality, style).
Evaluation makes additional LLM calls per record. For larger datasets, evaluate
a preview first; this tutorial evaluates all 25 rows to demonstrate the complete workflow.
This holistic judge is independent of pipeline leakage scoring, so their assessments may differ.
See Evaluation for details.
evaluated = anonymizer.evaluate(result)
[01:00:18] [INFO] 🧪 Running rewrite evaluation on 25 records
[01:00:18] [INFO] |-- ⚖️ Running rewrite judges
[01:01:06] [INFO] |-- 📋 Rewrite judges complete [48.1s]
[01:01:06] [INFO] |-- 🔎 Running entity coverage
[01:03:32] [INFO] |-- 📋 Entity coverage complete [146.3s]
[01:03:32] [INFO] 🎉 Evaluation complete — 25 records processed [194.4s]
evaluated.display_record(0)
⏭️ Next steps¶
- ⚖️ Rewriting Legal Documents -- rewrite legal text with custom entity labels and domain-specific privacy goals.
- 📊 Evaluation -- learn about the detection validity and rewrite quality judges in detail.
- 🎯 Choosing a Replacement Strategy -- compare Redact, Annotate, Hash, and Substitute if you prefer token-level replacement.
- 🔍 Inspecting Detected Entities -- debug what the detection pipeline found before rewriting.
stop_local_runtime()