← Terug naar de site

Pseudonimisering, technische implementatie

De laag die namen door tokens vervangt voordat er iets naar de AI gaat, en ze daarna weer terugzet. In code, op onze eigen infra.

In het kort

Wat deze laag doet

Voor verzending naar het AI-model worden namen, bedrijven, adressen en bedragen vervangen door consistente, omkeerbare tokens, overal in de deal dezelfde: ⟦PERSOON_1⟧, ⟦BEDRIJF_2⟧, ⟦BEDRAG_3⟧. Het model ziet dus geen identificerende gegevens. In het antwoord worden de tokens weer teruggezet naar de echte waarden.

De vertaaltabel (token ↔ echte waarde) is het kroonjuweel: die blijft binnen de vertrouwensgrens, versleuteld met de dossiersleutel, en gaat nooit mee naar buiten.

De belangrijke nuance

"Kan dit niet met AI?" Jawel, maar alleen lokaal

Als je een externe AI de tekst laat pseudonimiseren, heb je exact hetzelfde lek: de ruwe data verlaat de grens. De oplossing is dat de detectie lokaal draait, op onze eigen infra. Drie smaken, die je combineert:

De kern van het verschil

Niet "AI ja/nee", maar "verlaat de data de grens ja/nee". Een lokaal herkenningsmodel binnen onze omgeving is prima; een externe LLM-API is precies wat je hier wilt vermijden.

De cyclus

Tokeniseren, verwerken, terugzetten

Document
ontsleuteld in geheugen
Detecteer
regex + lijst + lokaal NER
Tokeniseer
mapping in de kluis
AI (Scaleway)
ziet geen namen
Detokeniseer
tokens terug

De mapping bestaat alleen binnen de grens. Alles links en rechts van "AI (Scaleway)" gebeurt op onze eigen infra.

Code, het idee

Van scratch: een consistente, omkeerbare tokenizer

De kern zonder frameworks. Belangrijk: dezelfde instantie hergebruiken voor alle documenten in één deal, dan krijgt "Acme Holding B.V." overal hetzelfde token en blijven kruisverwijzingen kloppen.

pythonpseudonymizer.py
KNOWN = { "Jan de Vries": "PERSOON", "Acme Holding B.V.": "BEDRIJF" }

import re
PATTERNS = {
    "IBAN":   re.compile(r"\bNL\d{2}[A-Z]{4}\d{10}\b"),
    "EMAIL":  re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"),
    "BEDRAG": re.compile(r"€\s?\d[\d.,]*"),
    "KVK":    re.compile(r"\bKvK[- ]?\d{8}\b"),
}

class Pseudonymizer:
    def __init__(self):
        self.forward = {}   # echte waarde  -> token
        self.reverse = {}   # token         -> echte waarde  (= de kluis)
        self.n = {}

    def _token(self, value, kind):
        if value in self.forward:            # zelfde waarde -> zelfde token
            return self.forward[value]
        self.n[kind] = self.n.get(kind, 0) + 1
        tok = f"⟦{kind}_{self.n[kind]}⟧"
        self.forward[value] = tok
        self.reverse[tok] = value
        return tok

    def pseudonymize(self, text):
        for value, kind in sorted(KNOWN.items(), key=lambda kv: -len(kv[0])):
            text = text.replace(value, self._token(value, kind))
        for kind, pat in PATTERNS.items():
            text = pat.sub(lambda m: self._token(m.group(0), kind), text)
        return text

    def restore(self, text):                 # tokens in het AI-antwoord terugzetten
        for tok, value in self.reverse.items():
            text = text.replace(tok, value)
        return text

De reverse-tabel is wat je per deal versleuteld opslaat. Wat hier nog ontbreekt: vrije-tekst namen die je niet vooraf kent. Daarvoor komt het lokale NER-model erbij.

Code, productie

Met Presidio: regex + lokaal NER + omkeerbare mapping

Microsoft Presidio is de open-source standaard. De analyzer combineert regex, context en een lokaal spaCy/transformer NER-model (ook Nederlands); de anonymizer vervangt via operators; de deanonymizer zet terug zolang de mapping bewaard is. Alles draait lokaal.

bash
pip install presidio-analyzer presidio-anonymizer
python -m spacy download nl_core_news_lg      # Nederlands NER-model, lokaal
pythonpresidio_layer.py
from presidio_analyzer import AnalyzerEngine
from presidio_analyzer.nlp_engine import NlpEngineProvider
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.operators import Operator, OperatorType

# 1. Analyzer met een LOKAAL Nederlands NER-model (niets gaat naar buiten).
nlp = NlpEngineProvider(nlp_configuration={
    "nlp_engine_name": "spacy",
    "models": [{"lang_code": "nl", "model_name": "nl_core_news_lg"}],
}).create_engine()
analyzer = AnalyzerEngine(nlp_engine=nlp, supported_languages=["nl"])

# 2. Custom operator: geef elke unieke waarde een consistent token
#    en hou de mapping bij (dit is de kluis).
class Tokenize(Operator):
    def operate(self, text, params):
        mapping, n = params["mapping"], params["counter"]
        kind = params["entity_type"]
        if text in mapping["fwd"]:
            return mapping["fwd"][text]
        n[kind] = n.get(kind, 0) + 1
        tok = f"⟦{kind}_{n[kind]}⟧"
        mapping["fwd"][text] = tok
        mapping["rev"][tok] = text
        return tok
    def validate(self, params): pass
    def operator_name(self): return "tokenize"
    def operator_type(self): return OperatorType.Anonymize

anonymizer = AnonymizerEngine(); anonymizer.add_anonymizer(Tokenize)

def pseudonymize(text, mapping, counter):
    results = analyzer.analyze(text=text, language="nl")     # PERSON, ORG, IBAN, ...
    return anonymizer.anonymize(text=text, analyzer_results=results,
        operators={"DEFAULT": OperatorConfig("tokenize",
                    {"mapping": mapping, "counter": counter})}).text

def restore(text, mapping):                 # terugzetten met de bewaarde mapping
    for tok, value in mapping["rev"].items():
        text = text.replace(tok, value)
    return text

Presidio levert ook een kant-en-klaar pseudonymization-voorbeeld met mapping. Voeg custom recognizers toe voor NL-specifiek (BSN, KvK) en voor de bekende partijen uit het dossier.

Eerlijk

Nauwkeurigheid en veilig falen

Geen enkele detector is 100%. Een NER-model mist soms een naam (recall typisch 85 tot 95%). Wie 100% belooft, wordt betrapt. Zo kom je toch dicht bij 100% op wat telt:

Pseudonimisering is één laag, niet de garantie

De basis maakt het al veilig: encryptie (Jan's sleutel) + een dedicated EU-verwerker onder DPA die de data niet mag gebruiken. Pseudonimisering komt daar bovenop en verkleint de schade als er ooit iets doorheen glipt. Zelfs als de detector een naam mist, gaat die naar een contractueel gebonden EU-verwerker, niet het open internet op. Zo is die 85 tot 95% geen zwakte maar een extra slot.

Grenzen

Randgevallen en hoe je ze afdekt

RisicoAanpak
Gemiste entiteit (NER mist een naam)Bekende partijen via woordenlijst; conservatieve drempel; bij twijfel blokkeren of laten reviewen.
Over-maskeren breekt de betekenisType-behoudende tokens (PERSOON/BEDRIJF/BEDRAG) zodat de AI de rol nog snapt.
Bedragen nodig voor rekenchecksConsistente tokens behouden gelijk/ongelijk; voor echt rekenen die stap binnen de grens houden.
Gescande of handgeschreven documentenEerst lokaal OCR, daarna pas pseudonimiseren.
Taal en juridisch jargonNederlands spaCy-model + custom recognizers; testen op echte testdossiers.
GDPR-noot

Omkeerbare pseudonimisering blijft een persoonsgegeven onder de AVG, het is een waarborg, geen ontsnapping. De verwerkersovereenkomst met Scaleway blijft dus nodig. En een lek van de data en de mapping samen is meldingsplichtig. Bescherm de mapping-kluis als het gevoeligste onderdeel van het hele systeem.

Samengevat

Aanbevolen opzet

Hoort bij de technische architectuur. Concept ter bespreking.