Pseudonimizacja polega na zastąpieniu danych identyfikujących osobę tokenami (np. [PESEL_1]) i zapisaniu osobnej tabeli mapowania token → oryginał, dzięki której tekst można później odtworzyć. Różni się od anonimizacji tym, że jest odwracalna, więc dane nadal pozostają danymi osobowymi, a tabelę mapowania trzeba chronić osobno. W Pythonie robisz to, zastępując trafienia od końca tekstu (żeby nie przesunąć pozycji) i przypisując ten sam token tej samej wartości.Masz tekst z numerem PESEL, e-mailem i telefonem klienta i chcesz go wysłać do narzędzia, które tych danych nie powinno widzieć: do modelu językowego, do zewnętrznego serwisu albo do kolegi, który ma tylko przejrzeć treść zgłoszenia. Najprostsze rozwiązanie to wykreślić dane. Problem w tym, że wtedy tracisz możliwość powrotu: nie wiesz, który „[USUNIĘTE]” był czyim e-mailem. Pseudonimizacja rozwiązuje to, zastępując dane tokenami, a oryginały odkładając do osobnej tabeli.
W tym tekście napiszemy taki mechanizm w Pythonie, na bazie wykrywania danych wrażliwych wyrażeniami regularnymi, czyli listy trafień z pozycjami w tekście. Używamy tylko biblioteki standardowej.
Czym różni się pseudonimizacja od anonimizacji?
Oba pojęcia dotyczą ukrywania danych, ale mają inne skutki:
| Cecha | Pseudonimizacja | Anonimizacja |
|---|---|---|
| Czy można odtworzyć dane? | Tak, jeśli masz tabelę mapowania | Nie, nieodwracalnie |
| Co zostaje w tekście | Token ([PESEL_1]) | Usunięcie lub ogólnik („klient”) |
| Czy to nadal dane osobowe? | Tak, bo ktoś może je odtworzyć | Nie, jeśli osoby nie da się zidentyfikować |
| Co trzeba chronić | Tabelę mapowania, osobno od tekstu | Nic dodatkowego |
W definicji RODO (art. 4 pkt 5) pseudonimizacja oznacza przetwarzanie danych w taki sposób, że nie można ich już przypisać konkretnej osobie bez użycia dodatkowych informacji, pod warunkiem że te informacje są przechowywane osobno i objęte zabezpieczeniami. To właśnie rola tabeli mapowania. Ten tekst opisuje technikę, a nie ocenę prawną: czy konkretny system spełnia wymagania, zależy od całości jego działania.
Szerszy kontekst prawny (AI Act, prawa autorskie, RODO i odpowiedzialność za szkody wyrządzone przez sztuczną inteligencję w pracy) omawia w naszym podcaście prawniczka Paula Skrzypecka:
Jak powinien wyglądać token?
Dobry token ma trzy cechy:
- Mówi, czym był (
[PESEL_1],[EMAIL_2]), dzięki czemu model lub czytelnik rozumie sens zdania. - Jest unikalny dla wartości: ten sam e-mail w całym tekście dostaje ten sam token, a inny e-mail inny numer. Wtedy zdanie „napisał z
[EMAIL_1], a potem znowu z[EMAIL_1]” zachowuje informację, że to ten sam adres. - Nie zdradza wartości: nie zawiera żadnej części oryginału, nawet ostatnich cyfr.
Numerujemy osobno dla każdego rodzaju danych, więc pierwszy e-mail to [EMAIL_1], a pierwszy PESEL też [PESEL_1].
Jak zastąpić dane tokenami w Pythonie?
Wynik pseudonimizacji to dwie rzeczy: tekst z tokenami i mapowanie. Zapisujemy je razem w jednej niemutowalnej strukturze. Wejściem jest tekst i lista Finding z poprzedniej części (rodzaj, wartość, początek, koniec):
import re
from dataclasses import dataclass
from detectors import Finding
TOKEN = re.compile(r"\[(?:EMAIL|PESEL|NIP|PHONE)_\d+\]")
@dataclass(frozen=True)
class Pseudonymized:
text: str
mapping: dict[str, str] # token -> oryginalna wartość
def drop_overlaps(findings: list[Finding]) -> list[Finding]:
kept: list[Finding] = []
for finding in sorted(findings, key=lambda f: (f.start, f.start - f.end)):
if kept and finding.start < kept[-1].end:
continue
kept.append(finding)
return kept
def pseudonymize(text: str, findings: list[Finding]) -> Pseudonymized:
findings = drop_overlaps(findings)
token_for: dict[tuple[str, str], str] = {}
counters: dict[str, int] = {}
mapping: dict[str, str] = {}
for finding in findings:
key = (finding.kind, finding.value)
if key not in token_for:
counters[finding.kind] = counters.get(finding.kind, 0) + 1
token = f"[{finding.kind.upper()}_{counters[finding.kind]}]"
token_for[key] = token
mapping[token] = finding.value
for finding in reversed(findings):
token = token_for[(finding.kind, finding.value)]
text = text[: finding.start] + token + text[finding.end :]
return Pseudonymized(text, mapping)
Trzy rzeczy wymagają wyjaśnienia:
drop_overlaps: dwa wzorce potrafią trafić w te same znaki. W adresie600123456@example.comdetektor z poprzedniej części znajduje i e-mail, i „telefon”600123456. Bez usunięcia nakładek podmiana zniszczyłaby tekst, więc zostawiamy trafienie zaczynające się najwcześniej (a przy tym samym początku dłuższe), a kolejne, które na nie nachodzi, pomijamy. Sprawdziłem to na tym adresie: wynik toPisz na [EMAIL_1], a w mapowaniu jest tylko e-mail.- Klucz
(rodzaj, wartość): dzięki niemu powtórzony e-mail dostaje ten sam token. - Pętla
reversed(findings): podmieniamy od końca tekstu. Gdybyśmy zaczęli od początku, token o innej długości niż oryginał przesunąłby wszystkie pozycje dalszych trafień i podmiana trafiałaby w złe miejsca. Od końca pozycje wcześniejszych trafień zostają prawdziwe.
Jak to działa na przykładzie?
Tekst z PESEL, NIP, e-mailem (powtórzonym) i telefonem:
Klient (PESEL 44051401359, NIP 987-654-32-10) napisał z jan@example.com i +48 600 123 456. Ponownie z jan@example.com: proszę o fakturę.
Po pseudonymize(text, find_sensitive(text)) dostajemy:
Klient (PESEL [PESEL_1], NIP [NIP_1]) napisał z [EMAIL_1] i [PHONE_1]. Ponownie z [EMAIL_1]: proszę o fakturę.
I tabelę mapowania:
{'[PESEL_1]': '44051401359', '[NIP_1]': '987-654-32-10', '[EMAIL_1]': 'jan@example.com', '[PHONE_1]': '+48 600 123 456'}
Zwróć uwagę, że drugi jan@example.com dostał ten sam token [EMAIL_1], a w tabeli jest tylko jeden wpis. Tekst z tokenami możesz teraz bezpiecznie przekazać dalej. Tabela zostaje u Ciebie.
Jak odtworzyć oryginalny tekst?
Odwracanie polega na znalezieniu tokenów w tekście i podstawieniu wartości z mapowania. Odpowiedź z zewnątrz (np. od modelu) też może zawierać tokeny, więc ta sama funkcja nadaje się do odtwarzania zarówno oryginału, jak i odpowiedzi:
def restore(text: str, mapping: dict[str, str]) -> str:
return TOKEN.sub(lambda m: mapping.get(m.group(), m.group()), text)
Dwie ważne decyzje: funkcja zamienia tylko tokeny pasujące do wzorca TOKEN, więc nie dotknie zwykłego tekstu w nawiasach kwadratowych, a nieznany token zostawia bez zmian (mapping.get(token, token)), zamiast zgłaszać błąd lub wstawiać pusty napis. Sprawdziłem to na odpowiedzi, w której model użył istniejących i nieistniejących tokenów:
Dziękujemy, [EMAIL_1]. Wyślemy fakturę na NIP [NIP_1]. Numer [PESEL_9] nieznany.
Po restore:
Dziękujemy, jan@example.com. Wyślemy fakturę na NIP 987-654-32-10. Numer [PESEL_9] nieznany.
Token [PESEL_9] nie istnieje w tabeli, więc zostaje na swoim miejscu. To dobre zachowanie: widzisz, że w odpowiedzi pojawiło się coś, czego nie wysłałeś, i możesz to sprawdzić. Pełny obieg restore(result.text, result.mapping) == text zwraca True.
Jakie są ograniczenia takiej pseudonimizacji?
Zanim użyjesz tego na prawdziwych danych, znaj trzy ograniczenia:
| Ograniczenie | Skutek | Co zrobić |
|---|---|---|
| Tekst zawiera już tokeny | Odtwarzanie podstawi cudze wartości | Sprawdź tekst przed pseudonimizacją albo użyj unikalnego prefiksu |
| Tabela mapowania leży obok tekstu | Pseudonimizacja traci sens | Przechowuj mapowanie osobno i zaszyfrowane |
| Wykrywanie jest niepełne | Imiona i nazwiska zostają w tekście | Traktuj wynik jako pomoc, nie gwarancję |
Pierwszy wiersz sprawdziłem na tekście, który już na wejściu zawiera token: "Pole formularza to [EMAIL_1], a mail to x@y.pl". Po pseudonimizacji dostajemy Pole formularza to [EMAIL_1], a mail to [EMAIL_1] i mapowanie {'[EMAIL_1]': 'x@y.pl'}, a po odtworzeniu Pole formularza to x@y.pl, a mail to x@y.pl. Oryginalny napis [EMAIL_1] został zamieniony na e-mail, którego tam nie było. Wniosek: ten mechanizm zakłada, że tokeny nie występują w tekście źródłowym.
Drugie ograniczenie rozwiązujemy w kolejnych artykułach cyklu: tabelę mapowania zaszyfrujemy, a klucz będzie przechowywany poza tabelą. Słownik z mapowaniem jest tu tylko rozwiązaniem na czas przykładu.
Jak przetestować pseudonimizację w pytest?
Testujemy numerację, spójność tokenów, pełny obieg i przypadki brzegowe (w tym telefon wewnątrz adresu e-mail):
from detectors import Finding, find_sensitive
from pseudonymize import drop_overlaps, pseudonymize, restore
def run(text, only=None):
findings = find_sensitive(text)
if only is not None:
findings = [f for f in findings if f.kind in only]
return pseudonymize(text, findings)
def test_each_kind_gets_its_own_counter():
result = run("a@b.pl 44051401359 c@d.pl")
assert result.text == "[EMAIL_1] [PESEL_1] [EMAIL_2]"
def test_same_value_gets_the_same_token():
result = run("a@b.pl pisze do a@b.pl")
assert result.text == "[EMAIL_1] pisze do [EMAIL_1]"
assert result.mapping == {"[EMAIL_1]": "a@b.pl"}
def test_only_passed_findings_are_replaced():
result = run("a@b.pl i 44051401359", only={"pesel"})
assert result.text == "a@b.pl i [PESEL_1]"
def test_no_findings_returns_text_unchanged():
result = pseudonymize("Dzień dobry", [])
assert result.text == "Dzień dobry"
assert result.mapping == {}
def test_roundtrip_restores_original_text():
text = "Mail a@b.pl, PESEL 44051401359, tel. 600 123 456."
result = run(text)
assert restore(result.text, result.mapping) == text
def test_restore_leaves_unknown_token_untouched():
assert restore("Numer [PESEL_9]", {}) == "Numer [PESEL_9]"
def test_overlapping_findings_keep_the_first():
findings = [Finding("phone", "600123456", 0, 9), Finding("nip", "x", 5, 12)]
assert drop_overlaps(findings) == [findings[0]]
def test_replacement_goes_from_the_end_so_positions_stay_valid():
text = "a@b.pl x c@d.pl"
result = run(text)
assert result.text == "[EMAIL_1] x [EMAIL_2]"
def test_phone_inside_email_is_not_replaced_separately():
result = run("Pisz na 600123456@example.com")
assert result.text == "Pisz na [EMAIL_1]"
assert result.mapping == {"[EMAIL_1]": "600123456@example.com"}
Test test_only_passed_findings_are_replaced jest ważny dla całego cyklu: funkcja zastępuje dokładnie te trafienia, które jej przekażesz. Dzięki temu będziemy mogli przekazać tylko te, które zatwierdzi operator.
Czy ten sam token powinien oznaczać tę samą wartość?
Tak, zwłaszcza gdy tekst trafia do modelu, który ma coś z nim zrobić, na przykład podsumować wątek korespondencji. Model, który widzi [EMAIL_1] w trzech miejscach, wie, że to jedna osoba, a nie trzy. Gdybyś każdemu wystąpieniu nadał nowy numer, utraciłby tę informację i mógłby opisać jedną osobę jako kilka.
Co dalej?
Mamy tekst z tokenami i mapowanie w zwykłym słowniku. W kolejnych tekstach cyklu opakujemy te kroki w graf w LangGraph, zapiszemy jego stan tak, żeby dało się go wznowić, dodamy zatrzymanie na akceptację operatora i zaszyfrujemy mapowanie. Całą ścieżkę Pythona znajdziesz na stronie ścieżki rozwoju w Pythonie.

