Before You Start
Do not explore the workspace first. The workflow's data-inspection step shows you what you need.
Goal
Anonymize a text dataset using NeMo Anonymizer in the way the user describes:
$ARGUMENTS
The output is a single runnable Python script that builds an AnonymizerConfig, previews results on a few rows, inspects failures and quality metrics, optionally scores output with LLM-as-judge evaluation (Replace and Rewrite modes), and (on user approval) runs the full pipeline. The script is the durable artifact — the user keeps it for re-runs, version control, and production.
Workflow
Read references/interactive.md and follow it. Anonymization is high-stakes,
so there is no autopilot mode. Even when the user says "you decide" or "be
opinionated", ask the minimum questions needed to choose risk_tolerance and
phrase privacy_goal. The user must make those choices based on their
regulatory and business context.
Rules
- Always preview before running the full pipeline. Preview is cheap; a full run can be expensive and slow.
- If
result.failed_records is non-empty after preview, fix that before tweaking strategy. Dropped rows are a model/provider/infra problem (rate limits, auth, etc.), not a config problem. Strategy knobs won't help. See docs/troubleshooting.md "Did the run actually complete cleanly?" or the published troubleshooting guide.
- Ask the user which mode. Briefly describe both: Replace detects entities and replaces each in place (faster, cheaper, keeps shape); Rewrite transforms the full text to also remove inferable identifiers (more expensive, may restructure). Use the data shape as a hint — free-text with implicit identifiers (clinical notes, biographies, depositions) leans Rewrite; structured records / log lines lean Replace — but the user picks.
- For cross-record consistency (same value → same replacement everywhere), use
Hash, not Substitute. Substitute is consistent within a row only.
- In Replace mode, default to
Substitute if the user hasn't specified a strategy. It's the most general-purpose choice and matches the bulk of production usage.
Annotate is for inspection, not production. Its output keeps the original entity text and is not privacy-safe. Use it during iteration to confirm detection is working, then switch.
- Evaluation is opt-in and runs as a separate step (Replace and Rewrite modes). After
preview() / run(), call anonymizer.evaluate(result) to score the output with LLM-as-judge. Entity coverage always runs in both modes — it reports detection recall over the judge's unique candidate values (entity_coverage + missed_entities). On top of that: Replace Substitute adds three quality judges (type fidelity, relational consistency, attribute fidelity); Rewrite adds the holistic privacy/quality/style judge. Detection validity is opt-in via EvaluateConfig(compute_detection_validity=True) (off by default). Evaluation is diagnostic — it scores quality, it does not change the anonymized output.
- Always set
AnonymizerInput.data_summary, even briefly. It is the single cheapest quality lever and it improves both detection and rewrite.
- Never claim privacy guarantees. Anonymizer is best-effort. Outputs may need human review depending on
risk_tolerance. Tell the user this when you finalize.
Usage Tips and Common Pitfalls
Detect.entity_labels=None (the default) is permissive — the augmenter LLM may invent labels not in DEFAULT_ENTITY_LABELS. Setting an explicit list switches to strict mode where only the listed labels are detected. To add domain labels, extend the default, don't replace it: entity_labels=[*DEFAULT_ENTITY_LABELS, "clinical_facility", ...] (DEFAULT_ENTITY_LABELS is a tuple, so unpack it into a list). Match the snake_case convention of DEFAULT_ENTITY_LABELS.
- GLiNER is zero-shot — entity labels are natural-language concept names (e.g.
"clinical_facility", "internal_project_codename"), not codes or enum values. Any concept you can name in English is a label GLiNER can detect.
Rewrite.instructions is a dead field today — it exists on the model but the rewrite engine never reads it. Do not use it. Put rewriter guidance in privacy_goal.protect / privacy_goal.preserve instead.
risk_tolerance only applies to Rewrite mode, not Replace.
PrivacyGoal.protect and .preserve must each be 10–1000 chars and at least 3 words. Be specific (categories, named identifiers, structural facets); avoid generic phrasing like "preserve meaning".
- Validator pool is the only model role with built-in load-spreading. Set
entity_validator: [a, b, c] in models.yaml if rate limits drop rows. Other roles (rewriter, evaluator, etc.) are single-alias.
- Self-hosted GLiNER: When detection must not call
build.nvidia.com (PHI on-prem, air-gapped, latency), run the reference server from a source checkout with python tools/serve_gliner.py. The server is not installed by pip install nemo-anonymizer. Add a provider with endpoint: http://localhost:8001/v1, then route entity_detector through a gliner-pii-detector alias with provider: local-gliner and skip_health_check: true. Match any custom --port or --host in the provider endpoint. model_configs is a complete model pool, not an overlay. Copy src/anonymizer/config/default_model_configs/models.yaml and change only the detector entry, keeping gpt-oss-120b and nemotron-30b-thinking. See docs/concepts/self-hosting-gliner.md or the published self-hosting guide.
- The evaluation judges use their own model roles (
entity_coverage_judge, detection_validity_judge, replace_type_fidelity_judge, replace_relational_consistency_judge, replace_attribute_fidelity_judge, rewrite_judge), configured in the evaluate section of models.yaml. They are not consumed by preview() / run(), so a config that anonymizes fine can still fail validation at evaluate() if those roles are unset. Defaults ship in src/anonymizer/config/default_model_configs/evaluate.yaml (entity_coverage_judge defaults to nemotron-super).
- Verdict columns are null when the judge was unavailable —
None means "unscored", never a pass. entity_coverage is a 0–1 float (1.0 = no missed candidate values or no PII found) or None; missed_entities lists unique candidate values the anonymizer failed to detect. Replace verdict columns (type_fidelity_valid, etc.) are True / False / None. Rewrite detection_valid is a 0–1 float fraction (or None if unscored). Inspect verdicts per record with evaluated.display_record(i).
EvaluateConfig has one knob today: compute_detection_validity (default False). Plain anonymizer.evaluate(result) runs entity coverage + the mode's quality judges; pass EvaluateConfig(compute_detection_validity=True) only to additionally score detection validity (an internal-facing tag-precision metric).
Reference Docs
The agent should consult these as it goes — do not try to enumerate field reference inline:
docs/concepts/choosing-a-strategy.md or the published strategy guide for choosing a mode, replacement strategy, risk tolerance, privacy goal, and detection settings.
docs/troubleshooting.md or the published troubleshooting guide for dropped rows, leakage, low utility, and pipeline failures. Read the relevant section when a symptom appears.
docs/concepts/detection.md or the published detection guide for GLiNER threshold semantics, entity labels, augmentation, and validation.
docs/concepts/evaluation.md or the published evaluation guide for Replace and Rewrite evaluation, judge roles, result columns, and saved-result evaluation.
docs/concepts/models.md or the published models guide for model roles and validator pools.
docs/concepts/self-hosting-gliner.md or the published self-hosting guide for the local entity_detector server, OpenAI-compatible contract, and YAML configuration.
Troubleshooting
This section covers environment-level issues. For quality and pipeline issues,
read docs/troubleshooting.md or the
published troubleshooting guide.
anonymizer not installed: Tell the user nemo-anonymizer is not in this Python environment (requires Python ≥ 3.11). Ask if they want you to install it (pip install nemo-anonymizer) or do it themselves. Do not install without permission.
- Model/provider setup: Plain
Anonymizer() ships with bundled models.yaml and providers.yaml (see src/anonymizer/config/default_model_configs/). For the default path, confirm NVIDIA_API_KEY is set. Pass custom model_configs or model_providers only for non-default endpoints or model pools. See docs/concepts/models.md or the published models guide.
- LLM calls failing at preview: Check for a missing or invalid API key, a network problem, or a wrong endpoint URL. See
docs/troubleshooting.md "Validation passed but preview errors at LLM call" or the published troubleshooting guide.
- Local / on-prem GLiNER: Clone or download
tools/serve_gliner.py from the Anonymizer repo, start the server, add a provider with endpoint: http://localhost:8001/v1, and point gliner-pii-detector at provider: local-gliner with skip_health_check: true. Preflight errors about missing aliases usually mean model_configs lists only the detector. Include the full default pool. A wrong endpoint or stopped server surfaces as a detection failure during preview. See docs/concepts/self-hosting-gliner.md or the published self-hosting guide.
Output Template
Write a Python script to the current directory. Name it after the dataset (for
example, anonymize_clinical_notes.py or anonymize_support_logs.py). Fill in
the TODO markers in this template and remove unused sections.
"""Anonymize <dataset> using NeMo Anonymizer.
Generated by the anonymizer agent skill.
Usage:
python <this_script>.py # preview on 5 rows (fast, cheap)
python <this_script>.py --full # run on the full dataset
python <this_script>.py --evaluate # preview 5 rows, then LLM-judge-score those rows
python <this_script>.py --full --evaluate # run full dataset, then score the full output
"""
from __future__ import annotations
import argparse
import sys
from anonymizer import (
Anonymizer,
AnonymizerConfig,
AnonymizerInput,
DEFAULT_ENTITY_LABELS,
Detect,
# Pick what you need:
# Replace mode:
Substitute, Redact, Annotate, Hash,
# Rewrite mode:
Rewrite, PrivacyGoal,
)
def build_config() -> tuple[AnonymizerInput, AnonymizerConfig]:
"""Single source of truth for what we anonymize and how."""
data = AnonymizerInput(
source="TODO: path to .csv / .parquet / .jsonl",
text_column="TODO: name of the text column",
data_summary="TODO: one-line description of the data (domain, genre, anything non-obvious)",
)
detect = Detect(
# Add domain labels by *extending* the default, not replacing it.
# entity_labels=[*DEFAULT_ENTITY_LABELS, "clinical_facility", "diagnosis_code"],
gliner_threshold=0.3, # default; lower (0.2) for recall, raise (0.5) for cost savings
)
# ---- Pick ONE of the two strategies below ----
# Replace mode (Substitute | Redact | Annotate | Hash):
# config = AnonymizerConfig(detect=detect, replace=Substitute(
# instructions="TODO: short hint about the domain (e.g. names should remain plausible "
# "for the original cultural context)",
# ))
# Rewrite mode (free-text de-identification with inferable-identifier suppression):
config = AnonymizerConfig(
detect=detect,
rewrite=Rewrite(
privacy_goal=PrivacyGoal(
protect="TODO: what must not appear in the output, even by inference",
preserve="TODO: what must be kept so the rewritten text is still useful",
),
risk_tolerance="low", # minimal | low | moderate | high
strict_entity_protection=False, # True = force every detected entity into a protective disposition
max_repair_iterations=3,
),
)
return data, config
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--full", action="store_true", help="Run on full dataset (default: preview 5 rows)")
parser.add_argument("--num-records", type=int, default=5, help="Rows to preview (ignored with --full)")
parser.add_argument(
"--evaluate",
action="store_true",
help="LLM-judge-score the output produced this run (preview rows, or full output with --full)",
)
args = parser.parse_args()
anonymizer = Anonymizer()
data, config = build_config()
if args.full:
result = anonymizer.run(config=config, data=data)
out_path = "output.parquet" # TODO: change path/format (.csv, .jsonl) as needed
result.dataframe.to_parquet(out_path)
print(f"Wrote {len(result.dataframe)} rows to {out_path}")
else:
result = anonymizer.preview(config=config, data=data, num_records=args.num_records)
print(f"Previewed {len(result.dataframe)} rows.")
# Save preview output so you can investigate without re-running.
# trace_dataframe is a superset of dataframe — it has the user-facing
# columns plus internal columns (validation decisions, sensitivity
# dispositions, etc.) that explain why entities were kept, dropped,
# or rewritten.
result.trace_dataframe.to_parquet("preview.parquet")
print("Saved: preview.parquet (load with pd.read_parquet)")
# Failure-first protocol: dropped rows are infra issues, not strategy issues.
if result.failed_records:
print(f"\n⚠️ {len(result.failed_records)} record(s) failed:")
for fr in result.failed_records[:3]:
print(f" - record_id={fr.record_id} step={fr.step} reason={fr.reason}")
print("\nFix dropped rows before tweaking strategy. See docs/troubleshooting.md or https://nvidia-nemo.github.io/Anonymizer/dev/troubleshooting/.")
sys.exit(1)
# Optional LLM-as-judge evaluation (Replace and Rewrite modes). Opt-in, separate
# step — scores quality without changing the anonymized output.
# Both modes: entity_coverage (judge-anchored recall) always runs.
# Replace: Substitute adds type fidelity, relational consistency, attribute fidelity.
# Rewrite: adds the holistic privacy/quality/style judge.
# Detection validity is opt-in (EvaluateConfig(compute_detection_validity=True)).
# Needs the `evaluate` model roles in models.yaml
# (see src/anonymizer/config/default_model_configs/evaluate.yaml).
if args.evaluate:
result = anonymizer.evaluate(result)
df = result.dataframe
# entity_coverage is a per-record 0–1 float (1.0 = no missed candidate values or no PII found by judge); aggregate mean shown below.
if "entity_coverage" in df.columns:
scored = int(df["entity_coverage"].notna().sum())
mean_cov = df["entity_coverage"].mean()
print(f"entity_coverage: mean={mean_cov:.2f} scored={scored}/{len(df)}")
if config.replace is not None:
for col in (
"type_fidelity_valid",
"relational_consistency_valid",
"attribute_fidelity_valid",
"detection_valid", # present only with compute_detection_validity=True
):
if col in df.columns:
passed = int(df[col].eq(True).sum()) # None = unscored, never a pass
scored = int(df[col].notna().sum())
print(f"{col}: {passed}/{scored} passed ({len(df) - scored} unscored)")
else:
# Rewrite: detection_valid is a 0–1 fraction (present only when opted in).
if "detection_valid" in df.columns:
scored = int(df["detection_valid"].notna().sum())
mean_val = df["detection_valid"].mean()
print(f"detection_valid: mean={mean_val:.2f} scored={scored}/{len(df)}")
if "judge_evaluation" in df.columns:
scored = int(df["judge_evaluation"].notna().sum())
print(f"judge_evaluation: {scored}/{len(df)} scored")
# In a notebook, inspect per-record verdicts visually:
# result.display_record(0)
# Rewrite-mode quality summary (skip for Replace mode).
if config.rewrite is not None:
df = result.dataframe
print(f"\nleakage_mass: mean={df['leakage_mass'].mean():.3f} max={df['leakage_mass'].max():.3f}")
print(f"utility_score: mean={df['utility_score'].mean():.3f} min={df['utility_score'].min():.3f}")
print(f"flagged for review: {int(df['needs_human_review'].sum())} / {len(df)}")
if __name__ == "__main__":
main()
1---2name: anonymizer3description: Use when the user wants to anonymize a text dataset, redact PII, de-identify free-text data, or rewrite text to remove sensitive or inferable identifying information. Produces a runnable Python script that calls the NeMo Anonymizer pipeline (detection → replace or rewrite).4license: Apache-2.05---67# Before You Start89Do not explore the workspace first. The workflow's data-inspection step shows you what you need.1011# Goal1213Anonymize a text dataset using NeMo Anonymizer in the way the user describes:1415$ARGUMENTS1617The output is a single runnable Python script that builds an `AnonymizerConfig`, previews results on a few rows, inspects failures and quality metrics, optionally scores output with LLM-as-judge evaluation (Replace and Rewrite modes), and (on user approval) runs the full pipeline. The script is the durable artifact — the user keeps it for re-runs, version control, and production.1819# Workflow2021Read `references/interactive.md` and follow it. Anonymization is high-stakes,22so there is no autopilot mode. Even when the user says "you decide" or "be23opinionated", ask the minimum questions needed to choose `risk_tolerance` and24phrase `privacy_goal`. The user must make those choices based on their25regulatory and business context.2627# Rules2829- **Always preview before running the full pipeline.** Preview is cheap; a full run can be expensive and slow.30- **If `result.failed_records` is non-empty after preview, fix that *before* tweaking strategy.** Dropped rows are a model/provider/infra problem (rate limits, auth, etc.), not a config problem. Strategy knobs won't help. See `docs/troubleshooting.md` "Did the run actually complete cleanly?" or the [published troubleshooting guide](https://nvidia-nemo.github.io/Anonymizer/dev/troubleshooting/).31- **Ask the user which mode.** Briefly describe both: Replace detects entities and replaces each in place (faster, cheaper, keeps shape); Rewrite transforms the full text to also remove inferable identifiers (more expensive, may restructure). Use the data shape as a hint — free-text with implicit identifiers (clinical notes, biographies, depositions) leans Rewrite; structured records / log lines lean Replace — but the user picks.32- **For cross-record consistency** (same value → same replacement everywhere), use `Hash`, not `Substitute`. `Substitute` is consistent within a row only.33- **In Replace mode, default to `Substitute`** if the user hasn't specified a strategy. It's the most general-purpose choice and matches the bulk of production usage.34- **`Annotate` is for inspection, not production.** Its output keeps the original entity text and is not privacy-safe. Use it during iteration to confirm detection is working, then switch.35- **Evaluation is opt-in and runs as a separate step** (Replace and Rewrite modes). After `preview()` / `run()`, call `anonymizer.evaluate(result)` to score the output with LLM-as-judge. **Entity coverage always runs** in both modes — it reports detection recall over the judge's unique candidate values (`entity_coverage` + `missed_entities`). On top of that: Replace `Substitute` adds three quality judges (type fidelity, relational consistency, attribute fidelity); Rewrite adds the holistic privacy/quality/style judge. Detection validity is **opt-in** via `EvaluateConfig(compute_detection_validity=True)` (off by default). Evaluation is diagnostic — it scores quality, it does not change the anonymized output.36- **Always set `AnonymizerInput.data_summary`**, even briefly. It is the single cheapest quality lever and it improves both detection and rewrite.37- **Never claim privacy guarantees.** Anonymizer is best-effort. Outputs may need human review depending on `risk_tolerance`. Tell the user this when you finalize.3839# Usage Tips and Common Pitfalls4041- **`Detect.entity_labels=None` (the default) is permissive** — the augmenter LLM may invent labels not in `DEFAULT_ENTITY_LABELS`. Setting an explicit list switches to **strict mode** where *only* the listed labels are detected. To add domain labels, *extend* the default, don't replace it: `entity_labels=[*DEFAULT_ENTITY_LABELS, "clinical_facility", ...]` (`DEFAULT_ENTITY_LABELS` is a tuple, so unpack it into a list). Match the snake_case convention of `DEFAULT_ENTITY_LABELS`.42- **GLiNER is zero-shot** — entity labels are natural-language concept names (e.g. `"clinical_facility"`, `"internal_project_codename"`), not codes or enum values. Any concept you can name in English is a label GLiNER can detect.43- **`Rewrite.instructions` is a dead field today** — it exists on the model but the rewrite engine never reads it. Do not use it. Put rewriter guidance in `privacy_goal.protect` / `privacy_goal.preserve` instead.44- **`risk_tolerance` only applies to Rewrite mode**, not Replace.45- **`PrivacyGoal.protect` and `.preserve` must each be 10–1000 chars and at least 3 words.** Be specific (categories, named identifiers, structural facets); avoid generic phrasing like "preserve meaning".46- **Validator pool is the only model role with built-in load-spreading.** Set `entity_validator: [a, b, c]` in `models.yaml` if rate limits drop rows. Other roles (rewriter, evaluator, etc.) are single-alias.47- **Self-hosted GLiNER:** When detection must not call `build.nvidia.com` (PHI on-prem, air-gapped, latency), run the reference server from a **source checkout** with `python tools/serve_gliner.py`. The server is not installed by `pip install nemo-anonymizer`. Add a provider with `endpoint: http://localhost:8001/v1`, then route `entity_detector` through a `gliner-pii-detector` alias with `provider: local-gliner` and `skip_health_check: true`. Match any custom `--port` or `--host` in the provider endpoint. `model_configs` is a **complete** model pool, not an overlay. Copy `src/anonymizer/config/default_model_configs/models.yaml` and change only the detector entry, keeping `gpt-oss-120b` and `nemotron-30b-thinking`. See [`docs/concepts/self-hosting-gliner.md`](../../docs/concepts/self-hosting-gliner.md) or the [published self-hosting guide](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/self-hosting-gliner/).48- **The evaluation judges use their own model roles** (`entity_coverage_judge`, `detection_validity_judge`, `replace_type_fidelity_judge`, `replace_relational_consistency_judge`, `replace_attribute_fidelity_judge`, `rewrite_judge`), configured in the `evaluate` section of `models.yaml`. They are **not** consumed by `preview()` / `run()`, so a config that anonymizes fine can still fail validation at `evaluate()` if those roles are unset. Defaults ship in `src/anonymizer/config/default_model_configs/evaluate.yaml` (`entity_coverage_judge` defaults to `nemotron-super`).49- **Verdict columns are null when the judge was unavailable** — `None` means "unscored", never a pass. `entity_coverage` is a `0–1` float (`1.0` = no missed candidate values or no PII found) or `None`; `missed_entities` lists unique candidate values the anonymizer failed to detect. Replace verdict columns (`type_fidelity_valid`, etc.) are `True` / `False` / `None`. Rewrite `detection_valid` is a `0–1` float fraction (or `None` if unscored). Inspect verdicts per record with `evaluated.display_record(i)`.50- **`EvaluateConfig` has one knob today: `compute_detection_validity`** (default `False`). Plain `anonymizer.evaluate(result)` runs entity coverage + the mode's quality judges; pass `EvaluateConfig(compute_detection_validity=True)` only to additionally score detection validity (an internal-facing tag-precision metric).5152# Reference Docs5354The agent should consult these as it goes — *do not* try to enumerate field reference inline:5556- [`docs/concepts/choosing-a-strategy.md`](../../docs/concepts/choosing-a-strategy.md) or the [published strategy guide](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/choosing-a-strategy/) for choosing a mode, replacement strategy, risk tolerance, privacy goal, and detection settings.57- [`docs/troubleshooting.md`](../../docs/troubleshooting.md) or the [published troubleshooting guide](https://nvidia-nemo.github.io/Anonymizer/dev/troubleshooting/) for dropped rows, leakage, low utility, and pipeline failures. Read the relevant section when a symptom appears.58- [`docs/concepts/detection.md`](../../docs/concepts/detection.md) or the [published detection guide](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/detection/) for GLiNER threshold semantics, entity labels, augmentation, and validation.59- [`docs/concepts/evaluation.md`](../../docs/concepts/evaluation.md) or the [published evaluation guide](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/evaluation/) for Replace and Rewrite evaluation, judge roles, result columns, and saved-result evaluation.60- [`docs/concepts/models.md`](../../docs/concepts/models.md) or the [published models guide](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/models/) for model roles and validator pools.61- [`docs/concepts/self-hosting-gliner.md`](../../docs/concepts/self-hosting-gliner.md) or the [published self-hosting guide](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/self-hosting-gliner/) for the local `entity_detector` server, OpenAI-compatible contract, and YAML configuration.6263# Troubleshooting6465This section covers environment-level issues. For quality and pipeline issues,66read `docs/troubleshooting.md` or the67[published troubleshooting guide](https://nvidia-nemo.github.io/Anonymizer/dev/troubleshooting/).6869- **`anonymizer` not installed:** Tell the user `nemo-anonymizer` is not in this Python environment (requires Python ≥ 3.11). Ask if they want you to install it (`pip install nemo-anonymizer`) or do it themselves. Do not install without permission.70- **Model/provider setup:** Plain `Anonymizer()` ships with bundled `models.yaml` and `providers.yaml` (see `src/anonymizer/config/default_model_configs/`). For the default path, confirm `NVIDIA_API_KEY` is set. Pass custom `model_configs` or `model_providers` only for non-default endpoints or model pools. See `docs/concepts/models.md` or the [published models guide](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/models/).71- **LLM calls failing at preview:** Check for a missing or invalid API key, a network problem, or a wrong endpoint URL. See `docs/troubleshooting.md` "Validation passed but `preview` errors at LLM call" or the [published troubleshooting guide](https://nvidia-nemo.github.io/Anonymizer/dev/troubleshooting/).72- **Local / on-prem GLiNER:** Clone or download `tools/serve_gliner.py` from the Anonymizer repo, start the server, add a provider with `endpoint: http://localhost:8001/v1`, and point `gliner-pii-detector` at `provider: local-gliner` with `skip_health_check: true`. Preflight errors about missing aliases usually mean `model_configs` lists only the detector. Include the full default pool. A wrong endpoint or stopped server surfaces as a detection failure during preview. See [`docs/concepts/self-hosting-gliner.md`](../../docs/concepts/self-hosting-gliner.md) or the [published self-hosting guide](https://nvidia-nemo.github.io/Anonymizer/dev/concepts/self-hosting-gliner/).7374# Output Template7576Write a Python script to the current directory. Name it after the dataset (for77example, `anonymize_clinical_notes.py` or `anonymize_support_logs.py`). Fill in78the TODO markers in this template and remove unused sections.7980```python81"""Anonymize <dataset> using NeMo Anonymizer.8283Generated by the anonymizer agent skill.8485Usage:86 python <this_script>.py # preview on 5 rows (fast, cheap)87 python <this_script>.py --full # run on the full dataset88 python <this_script>.py --evaluate # preview 5 rows, then LLM-judge-score those rows89 python <this_script>.py --full --evaluate # run full dataset, then score the full output90"""9192from __future__ import annotations9394import argparse95import sys9697from anonymizer import (98 Anonymizer,99 AnonymizerConfig,100 AnonymizerInput,101 DEFAULT_ENTITY_LABELS,102 Detect,103 # Pick what you need:104 # Replace mode:105 Substitute, Redact, Annotate, Hash,106 # Rewrite mode:107 Rewrite, PrivacyGoal,108)109110111def build_config() -> tuple[AnonymizerInput, AnonymizerConfig]:112 """Single source of truth for what we anonymize and how."""113 data = AnonymizerInput(114 source="TODO: path to .csv / .parquet / .jsonl",115 text_column="TODO: name of the text column",116 data_summary="TODO: one-line description of the data (domain, genre, anything non-obvious)",117 )118119 detect = Detect(120 # Add domain labels by *extending* the default, not replacing it.121 # entity_labels=[*DEFAULT_ENTITY_LABELS, "clinical_facility", "diagnosis_code"],122 gliner_threshold=0.3, # default; lower (0.2) for recall, raise (0.5) for cost savings123 )124125 # ---- Pick ONE of the two strategies below ----126127 # Replace mode (Substitute | Redact | Annotate | Hash):128 # config = AnonymizerConfig(detect=detect, replace=Substitute(129 # instructions="TODO: short hint about the domain (e.g. names should remain plausible "130 # "for the original cultural context)",131 # ))132133 # Rewrite mode (free-text de-identification with inferable-identifier suppression):134 config = AnonymizerConfig(135 detect=detect,136 rewrite=Rewrite(137 privacy_goal=PrivacyGoal(138 protect="TODO: what must not appear in the output, even by inference",139 preserve="TODO: what must be kept so the rewritten text is still useful",140 ),141 risk_tolerance="low", # minimal | low | moderate | high142 strict_entity_protection=False, # True = force every detected entity into a protective disposition143 max_repair_iterations=3,144 ),145 )146 return data, config147148149def main() -> None:150 parser = argparse.ArgumentParser(description=__doc__)151 parser.add_argument("--full", action="store_true", help="Run on full dataset (default: preview 5 rows)")152 parser.add_argument("--num-records", type=int, default=5, help="Rows to preview (ignored with --full)")153 parser.add_argument(154 "--evaluate",155 action="store_true",156 help="LLM-judge-score the output produced this run (preview rows, or full output with --full)",157 )158 args = parser.parse_args()159160 anonymizer = Anonymizer()161 data, config = build_config()162163 if args.full:164 result = anonymizer.run(config=config, data=data)165 out_path = "output.parquet" # TODO: change path/format (.csv, .jsonl) as needed166 result.dataframe.to_parquet(out_path)167 print(f"Wrote {len(result.dataframe)} rows to {out_path}")168 else:169 result = anonymizer.preview(config=config, data=data, num_records=args.num_records)170 print(f"Previewed {len(result.dataframe)} rows.")171172 # Save preview output so you can investigate without re-running.173 # trace_dataframe is a superset of dataframe — it has the user-facing174 # columns plus internal columns (validation decisions, sensitivity175 # dispositions, etc.) that explain why entities were kept, dropped,176 # or rewritten.177 result.trace_dataframe.to_parquet("preview.parquet")178 print("Saved: preview.parquet (load with pd.read_parquet)")179180 # Failure-first protocol: dropped rows are infra issues, not strategy issues.181 if result.failed_records:182 print(f"\n⚠️ {len(result.failed_records)} record(s) failed:")183 for fr in result.failed_records[:3]:184 print(f" - record_id={fr.record_id} step={fr.step} reason={fr.reason}")185 print("\nFix dropped rows before tweaking strategy. See docs/troubleshooting.md or https://nvidia-nemo.github.io/Anonymizer/dev/troubleshooting/.")186 sys.exit(1)187188 # Optional LLM-as-judge evaluation (Replace and Rewrite modes). Opt-in, separate189 # step — scores quality without changing the anonymized output.190 # Both modes: entity_coverage (judge-anchored recall) always runs.191 # Replace: Substitute adds type fidelity, relational consistency, attribute fidelity.192 # Rewrite: adds the holistic privacy/quality/style judge.193 # Detection validity is opt-in (EvaluateConfig(compute_detection_validity=True)).194 # Needs the `evaluate` model roles in models.yaml195 # (see src/anonymizer/config/default_model_configs/evaluate.yaml).196 if args.evaluate:197 result = anonymizer.evaluate(result)198 df = result.dataframe199 # entity_coverage is a per-record 0–1 float (1.0 = no missed candidate values or no PII found by judge); aggregate mean shown below.200 if "entity_coverage" in df.columns:201 scored = int(df["entity_coverage"].notna().sum())202 mean_cov = df["entity_coverage"].mean()203 print(f"entity_coverage: mean={mean_cov:.2f} scored={scored}/{len(df)}")204 if config.replace is not None:205 for col in (206 "type_fidelity_valid",207 "relational_consistency_valid",208 "attribute_fidelity_valid",209 "detection_valid", # present only with compute_detection_validity=True210 ):211 if col in df.columns:212 passed = int(df[col].eq(True).sum()) # None = unscored, never a pass213 scored = int(df[col].notna().sum())214 print(f"{col}: {passed}/{scored} passed ({len(df) - scored} unscored)")215 else:216 # Rewrite: detection_valid is a 0–1 fraction (present only when opted in).217 if "detection_valid" in df.columns:218 scored = int(df["detection_valid"].notna().sum())219 mean_val = df["detection_valid"].mean()220 print(f"detection_valid: mean={mean_val:.2f} scored={scored}/{len(df)}")221 if "judge_evaluation" in df.columns:222 scored = int(df["judge_evaluation"].notna().sum())223 print(f"judge_evaluation: {scored}/{len(df)} scored")224 # In a notebook, inspect per-record verdicts visually:225 # result.display_record(0)226227 # Rewrite-mode quality summary (skip for Replace mode).228 if config.rewrite is not None:229 df = result.dataframe230 print(f"\nleakage_mass: mean={df['leakage_mass'].mean():.3f} max={df['leakage_mass'].max():.3f}")231 print(f"utility_score: mean={df['utility_score'].mean():.3f} min={df['utility_score'].min():.3f}")232 print(f"flagged for review: {int(df['needs_human_review'].sum())} / {len(df)}")233234235if __name__ == "__main__":236 main()237```