Imported from NVIDIA-NeMo/Anonymizer (
skills/anonymizer/SKILL.md). Install upstream withnpx skills add NVIDIA-NeMo/Anonymizer --skill anonymizer. Copyright stays with the author (Apache-2.0).
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_recordsis 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. Seedocs/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, notSubstitute.Substituteis consistent within a row only. - In Replace mode, default to
Substituteif the user hasn't specified a strategy. It's the most general-purpose choice and matches the bulk of production usage. Annotateis 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(), callanonymizer.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: ReplaceSubstituteadds three quality judges (type fidelity, relational consistency, attribute fidelity); Rewrite adds the holistic privacy/quality/style judge. Detection validity is opt-in viaEvaluateConfig(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 inDEFAULT_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_LABELSis a tuple, so unpack it into a list). Match the snake_case convention ofDEFAULT_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.instructionsis a dead field today — it exists on the model but the rewrite engine never reads it. Do not use it. Put rewriter guidance inprivacy_goal.protect/privacy_goal.preserveinstead.risk_toleranceonly applies to Rewrite mode, not Replace.PrivacyGoal.protectand.preservemust 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]inmodels.yamlif 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 withpython tools/serve_gliner.py. The server is not installed bypip install nemo-anonymizer. Add a provider withendpoint: http://localhost:8001/v1, then routeentity_detectorthrough agliner-pii-detectoralias withprovider: local-glinerandskip_health_check: true. Match any custom--portor--hostin the provider endpoint.model_configsis a complete model pool, not an overlay. Copysrc/anonymizer/config/default_model_configs/models.yamland change only the detector entry, keepinggpt-oss-120bandnemotron-30b-thinking. Seedocs/concepts/self-hosting-gliner.mdor 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 theevaluatesection ofmodels.yaml. They are not consumed bypreview()/run(), so a config that anonymizes fine can still fail validation atevaluate()if those roles are unset. Defaults ship insrc/anonymizer/config/default_model_configs/evaluate.yaml(entity_coverage_judgedefaults tonemotron-super). - Verdict columns are null when the judge was unavailable —
Nonemeans "unscored", never a pass.entity_coverageis a0–1float (1.0= no missed candidate values or no PII found) orNone;missed_entitieslists unique candidate values the anonymizer failed to detect. Replace verdict columns (type_fidelity_valid, etc.) areTrue/False/None. Rewritedetection_validis a0–1float fraction (orNoneif unscored). Inspect verdicts per record withevaluated.display_record(i). EvaluateConfighas one knob today:compute_detection_validity(defaultFalse). Plainanonymizer.evaluate(result)runs entity coverage + the mode's quality judges; passEvaluateConfig(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.mdor the published strategy guide for choosing a mode, replacement strategy, risk tolerance, privacy goal, and detection settings.docs/troubleshooting.mdor the published troubleshooting guide for dropped rows, leakage, low utility, and pipeline failures. Read the relevant section when a symptom appears.docs/concepts/detection.mdor the published detection guide for GLiNER threshold semantics, entity labels, augmentation, and validation.docs/concepts/evaluation.mdor the published evaluation guide for Replace and Rewrite evaluation, judge roles, result columns, and saved-result evaluation.docs/concepts/models.mdor the published models guide for model roles and validator pools.docs/concepts/self-hosting-gliner.mdor the published self-hosting guide for the localentity_detectorserver, 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.
anonymizernot installed: Tell the usernemo-anonymizeris 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 bundledmodels.yamlandproviders.yaml(seesrc/anonymizer/config/default_model_configs/). For the default path, confirmNVIDIA_API_KEYis set. Pass custommodel_configsormodel_providersonly for non-default endpoints or model pools. Seedocs/concepts/models.mdor 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 butpreviewerrors at LLM call" or the published troubleshooting guide. - Local / on-prem GLiNER: Clone or download
tools/serve_gliner.pyfrom the Anonymizer repo, start the server, add a provider withendpoint: http://localhost:8001/v1, and pointgliner-pii-detectoratprovider: local-glinerwithskip_health_check: true. Preflight errors about missing aliases usually meanmodel_configslists only the detector. Include the full default pool. A wrong endpoint or stopped server surfaces as a detection failure during preview. Seedocs/concepts/self-hosting-gliner.mdor 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()