blog.Mainzer Datenfabrik

Anonymisierung von Produktionsdaten

cover image of blog article 'Anonymisierung von Produktionsdaten'

Domänenverteilung

Um die Verteilung der Domänen aufrecht zu erhalten, müssen im ersten Durchgang die Daten eine Zuordnung von einer echten Domäne zu einer gefälschten Domäne herstellen. Domains wie gmail.com können zudem auf die weiße Liste gesetzt werden und z.B. direkt zugeordnet werden. Für diesen Fall brauchen wir beispielsweise nur einen gefälschten Benutzernamen.

Um eine Domänenzuordnung oder Whitelist zu erstellen, muss ein Objekt mittels CSV erstellt oder importiert werden, dass eine Whitelist von einer Festplatte oder aus dem ursprünglichen Datensatz generieren kann. Dafür kann folgendes Skript verwendet werden:

import csv
import json
from faker import Factory
from collections import Counter
from collections import MutableMapping
class DomainMapping(MutableMapping):
    @classmethod
    def load(cls, fobj):
        """
        Load the mapping from a JSON file on disk.
        """
        data = json.load(fobj)
        return cls(**data)
    @classmethod
    def generate(cls, emails):
        """
        Pass through a list of emails and count domains to whitelist.
        """
        # Count all the domains in each email address
        counts  = Counter([
            email.split("@")[-1] for email in emails
        ])
        # Create a domain mapping
        domains = cls()
        # Ask the user what domains to whitelist based on frequency
        for idx, (domain, count) in enumerate(counts.most_common())):
            prompt = "{}/{}: Whitelist {} ({} addresses)?".format(
                idx+1, len(counts), domain, count
            )
            print prompt
            ans = raw_input("[y/n/q] > ").lower()
            if ans.startswith('y'):
                # Whitelist the domain
                domains[domain] = domain
            elif ans.startswith('n'):
                # Create a fake domain
                domains[domain]
            elif ans.startswith('q'):
                break
            else:
                continue
        return domains  
    def __init__(self, whitelist=[], mapping={}):
        # Create the domain mapping properties
        self.fake    = Factory.create()
        self.domains = mapping
        # Add the whitelist as a mapping to itself.
        for domain in whitelist:
            self.domains[domain] = domain
    def dump(self, fobj):
        """
        Dump the domain mapping whitelist/mapping to JSON.
        """
        whitelist = []
        mapping   = self.domains.copy()
        for key in mapping.keys():
            if key == mapping[key]:
                whitelist.append(mapping.pop(key))
        json.dump({
            'whitelist': whitelist,
            'mapping': mapping
        }, fobj, indent=2)
    def __getitem__(self, key):
        """
        Get a fake domain for a real domain.
        """
        if key not in self.domains:
            self.domains[key] = self.fake.domain_name()
        return self.domains[key]
    def __setitem__(self, key, val):
        self.domains[key] = val
    def __delitem__(self, key):
        del self.domains[key]
    def __iter__(self):
        for key in self.domains:
            yield key

Wir wollen nun den Code zur Einfachheit etwas aufschlüsseln.

  • Durch MutableMapping wird die Klasse erweitert, die eine abstrakte Basisklasse in einem collections Modul ist.
  • Die Basisklasse gibt uns die Möglichkeit, sich wie ein dict Objekt zu verhalten. Dafür müssen wir lediglich entsprechende Methoden bereitstellen, wie z.B. getitem oder Wörterbuchmethoden wie setitem, delitem, iter ,pop values domains.
  • Die getitem Methode verhält sich sehr ähnlich zur defaultdict Methode, die mittels eines Schlüssels versuchen wird, Daten abzurufen, die keine Zuordnung besitzen. Somit werden gefälschte Daten in ihrem Namen generiert und alle Domains, die nicht auf der Whitelist sitzen, anonymisiert.

Anschließend laden wir mit load die Dumpdaten in eine JSON Datei auf die Festplatte. Damit erhalten wir die Zuordnung der Anonymisierungsläufe. Diese Methode ist recht simpel, da sie sich ein offenes, dateiähnliches Objekt heraussucht, mit dem JSON Modul kombiniert und die Domänenzuordnung instanziert und zurückgibt. Dafür ist die dump-Methode etwas komplexer. Sie teilt die Whitelist und das Mapping in separate Objekte auf, damit die Daten auf der Festplatte geändert werden können. Beide Methoden ermöglichen uns jedoch, das Mapping in eine JSON Datei zu laden und zu speichern:

{ 
    „whitelist“ : [ 
        „ gmail.com “, 
        „ yahoo.com “ 
    ], 
    „mapping“ : { 
        „ districtdatalabs.com “ : „fadel.org“, 
        „umd.edu“ : „ferrystanton.org“ 
    } 
}

Mit der generate-Methode wird es ermöglicht, einen ersten Durchgang durch die Email Liste zu starten, die Anzahl der Domains zu zählen und diese dann dem User in der Reihenfolge der Häufigkeit vorzuschlagen. Der User hat dann die Option diese zur Whitelist hinzuzufügen. Dies sieht dann wie folgt aus:

1/245: Whitelist "gmail.com" (817 addresses)?
[y/n/q] >

Beachten Sie, dass die Eingabeaufforderung eine Fortschrittsanzeige enthält. Diese ist wichtig für große Datensätze mit eindeutigen Domains. Beenden Sie den Vorgang, werden weiterhin Domains gefälscht und der Benutzer sieht allerdings nur die häufigsten Beispiele der Whitelist. Die Intention dahinter ist, dass dieser Mechanismus die CSV Datei liest, daraus die Whitelist erstellt und diese auf der Festplatte speichert, damit sie als Standard für die Anonymisierung verwendet werden kann.

Realistische Profile

Um realistische Profile erstellen zu können ist en entsprechender Anbieter notwendig, der die Domainkarte verwendet und gefälschte Daten für jede Kombination generiert. Dieser Anbieter bietet auch die Möglichkeit, mehrere Namen und Email-Adressen einem einzelnen Profil zuzuordnen. Um diesen Anbieter zu erstellen, nutzen wir folgenden Code:

class Profile(object):
    def __init__(self, domains):
        self.domains = domains
        self.generator = Factory.create()
    def fuzzy_profile(self, name=None, email=None):
        """
        Return a profile that allows for fuzzy names and emails.
        """
        parts = self.fuzzy_name_parts()
        return {
            "names": {name: self.fuzzy_name(parts, name)},
            "emails": {email: self.fuzzy_email(parts, email)},
        }
    def fuzzy_name_parts(self):
        """
        Returns first, middle, and last name parts
        """
        return (
            self.generator.first_name(),
            self.generator.first_name(),
            self.generator.last_name()
        )
    def fuzzy_name(self, parts, name=None):
        """
        Creates a name that has a similar case to the passed in name.
        """
        # Extract the first, initial, and last name from the parts.
        first, middle, last = parts
        # Create the name, with chance of middle or initial included.
        chance = self.generator.random_digit()
        if chance < 2:
            fname = u"{} {}. {}".format(first, middle[0], last)
        elif chance < 4:
            fname = u"{} {} {}".format(first, middle, last)
        else:
            fname = u"{} {}".format(first, last)
        if name is not None:
            # Match the capitalization of the name
            if name.isupper(): return fname.upper()
            if name.islower(): return fname.lower()
        return fname
    def fuzzy_email(self, parts, email=None):
        """
        Creates an email similar to the name and original email.
        """
        # Extract the first, initial, and last name from the parts.
        first, middle, last = parts
        # Use the domain mapping to identify the fake domain.
        if email is not None:
            domain = self.domains[email.split("@")[-1]]
        else:
            domain = self.generator.domain_name()
        # Create the username based on the name parts
        chance = self.generator.random_digit()
        if chance < 2:
            username = u"{}.{}".format(first, last)
        elif chance < 3:
            username = u"{}.{}.{}".format(first, middle[0], last)
        elif chance < 6:
            username = u"{}{}".format(first[0], last)
        elif chance < 8:
            username = last
        else:
            username = u"{}{}".format(
                first, self.generator.random_number()
            )
        # Match the case of the email
        if email is not None:
            if email.islower(): username = username.lower()
            if email.isupper(): username = username.upper()
        else:
            username = username.lower()
        return u"{}@{}".format(username, domain)

Auch hier sind wir wieder mit einer Menge Code konfrontiert, den wir kurz aufschlüsseln werden:

  • Ein Profil ist zunächst eine Kombination der Zuordnung von original Datensatz zu gefälschtem Datensatz.
  • Die Zuordnung erfolgt über die Domain zur ursprünglichen Email Domain

So wird zum Beispiel jede Email-Domain mit der Endung @madafa.de derselben gefälschten Domain zugeordnet.

Um auf die Beziehung von Namen zu Emails zurückgreifen zu können, müssen wir direkter auf die Namen zugreifen können. Dafür nutzen wir den “Namensteile-Generator”, der gefälschte Vor. & Nachnamen generiert. Darauf basierend kann die Email-Adresse sich entsprechend anpassen und verschiedene Formen annehmen. Damit erhalten wir realistischere Profile:

>>> fake.fuzzy_profile()
{'names': {None: u'Zaire Ebert'}, 'emails': {None: u'ebert@von.com'}}
>>> fake.fuzzy_profile(
...    name='Daniel Webster', email='dictionaryguy@gmail.com')
{'names': {'Daniel Webster': u'Georgia McDermott'},
 'emails': {'dictionaryguy@gmail.com': u'georgia9@gmail.com'}}

Hierbei ist es wichtig, dieses Profilobjekt so einzurichten, dass es mehrere Namen und Email-Adressen demselben Objekt zuordnen kann. Diesen Vorgang nennen wir Fuzzy-Matching.

Fuzzy Matching gefälschter Daten

In unserem ursprünglichen Datensatz haben wir Entitäten dupliziert, mit derselben Email-Adresse aber einem anderem Namen. Hierbei ist es ganz egal, ob Sie den Zweitnamen verwenden oder einen Spitznamen. Auch können geschäftliche Email-Adressen dafür verwendet werden. Damit erstellen wir unscharfe (fuzzy) Profilobjekte. Diese ermöglichen uns die Zuordnung aller vorhandenen Namensteile zu falschen Namen. Wichtig ist es, dass wir diese Duplikate erkennen können. Dafür nutzen wir das fuzzywuzzy Modul:

$ pip install fuzzywuzzy python-Levenshtein

Analog zu unserem Domain-Mapping gehen wir auch hier vor und suchen nach ähnlichen Namens- & Email Paaren, um sie dem User vorzuschlagen. Denkt jetzt der User, dass es sich um ein Duplikat handelt, führen wir das Profilobjekt zu einem einzigen Profil zusammen und verwenden die Zuordnung der Anonymisierung.

Zunächst müssen wir exakte Duplikate entfernen. Dazu erstellen wir eine hashfähige Datenstruktur für unsere Profile mit einer namedtuple:

from collections import namedtuple
from itertools import combinations
Person = namedtuple('Person', 'name, email')
def pairs_from_rows(rows):
    """
    Expects rows of dictionaries with name and email keys.
    """
    # Create a set of person tuples (no exact duplicates)
    people = set([
        Person(row['name'], row['email']) for row in rows
    ])
    # Yield ordered pairs of people objects without replacement
    for pair in combinations(people, 2):
        yield pair

Das namedtuple ist eine kompakte und unveränderliche Datenstruktur, die auf Namen und Eigenschaften zugreifen kann. Da sie unveränderlich ist, ist sie im Gegensatz zu Dictionaries auch hashbar. Mit der pairs_from_rows Funktion eliminieren wir dann die exakten Duplikate. In diesem Vorgang werden einige Tupel erstellt, die dann die combinations Funktion verwenden, um in intertools Paare zu erzeugen.

Im nächsten Schritt möchten wir herausfinden, wie hoch die Ähnlichkeit der Paare tatsächlich ist. Dafür greifen wir auf die fuzzywuzzy Bibliothek zurück, um einen partiellen Mittelwert zu ermitteln.

from fuzzywuzzy import fuzz
from functools import partial
def normalize(value, email=False):
    """
    Make everything lowercase and remove spaces.
    If email, only take the username portion to compare.
    """
    if email:
        value = value.split("@")[0]
    return value.lower().replace(" ", "")
def person_similarity(pair):
    """
    Returns the mean of the normalized partial ratio scores.
    """
    # Normalize the names and the emails
    names = map(normalize, [p.name for p in pair])
    email = map(
        partial(normalize, email=True), [p.email for p in pair]
    )
    # Compute the partial ratio scores for both names and emails
    scores = [
        fuzz.partial_ratio(a, b) for a, b in [names, emails]
    ]
    # Return the mean score of the pair
    return float(sum(scores)) / len(scores)

Die Ergebniswerte liegen dann entweder zwischen 0 (keine Übereinstimmung) bis 100 (exakte Übereinstimmung). Ein Wert von 100 ist jedoch sehr unwahrscheinlich, da wir bereits in den oberen Schritten die exakten Übereinstimmungen eliminiert haben sollten. Hier ein Beispiel:

>>> person_similarity([ 
... Person('John Lennon', 'john.lennon@gmail.com'), 
... Person('J. Lennon', 'jlennon@example.org') 
... ] )
80.5

Mit dem Fuzzing-Prozess durchlaufen wir unseren kompletten Datensatz, erstellen Personenpaare und berechnen die Ähnlichkeitswerte. Damit können wir zusätzlich nach Prozentpunkten filtern und die Ergebnisse unseren Anforderungen gemäß bewerten. Wurde ein Duplikat gefunden, kann dieses mit einem Profilobjekt zusammengeführt werden und eine Namens- & Email-Adressen Zuordnung stattfinden.

Fazit

Wie bereits zu Beginn erwähnt, erfinden wir mit dieser Methode nicht das Rad neu. Sie hilft uns lediglich dabei, Datensätze entsprechend zu verschleiern und damit vor Missbrauch zu schützen. Trotz allem können auch bei den recht simplen Vorgängen diverse Probleme aufkommen, sodass wir immer dazu raten, sich Experten zur Seite zu holen. Als zertifizierte Experten sind wir Ihre passenden Ansprechpartner und treiben mit Ihnen gemeinsam den Bereich Datenwissenschaft weiter voran. Kontaktieren Sie uns gerne bei Bedarf oder Rückfragen hier.

Seitennavigation

Zur Artikel Übersicht

Auf dieser Seite

SQL Server 2014 Migration SupportNEU
Im Sommer 2024 endet der Extended Support des Microsoft SQL Server 2014 SP3. Erfahren sie wie wir Sie bei Ihrer Migration unterstützen können! mehr erfahren