Anonymisierung von Produktionsdaten

cover image of blog article 'Anonymisierung von Produktionsdaten'

In der Softwareentwicklung ist es ein Standardvorgang, Daten aus dem Produktionssystem zu verwenden und diese in einer anderen Umgebung wiederherzustellen. Diese andere Umgebung ist in den meisten Fällen eine Test, Entwicklung oder Staging Umgebung. Sie werden verwendet, damit Entwicklerteams Probleme beheben können ohne direkt die Produktionsumgebung zu tangieren. Sie dienen quasi dazu, verschiedene Szenarien, Versionen und Funktionen zu testen. Natürlich erfinden wir hiermit jetzt nicht das Rad neu, denn die Idee, Tests in einer Entwicklungs- oder Staging Umgebung durchzuführen ist nichts Neues. Seit Jahrzehnten ist es Standard, Kopien von Produktionsdaten zu erstellen und diese in Nicht-Produktionsumgebungen wiederherzustellen. Doch vermehrt treten auch hier immer wieder Probleme auf. Sei es zum Beispiel bei großen Datenmengen, da diese erfahrungsgemäß eine enorme Zeit im Wiederherstellungsprozess beanspruchen. Sie blockieren damit auch die Netzwerkbandbreite und vermehren zudem die Kosten für Speicherung und Datenpflege.

Daten sind das wichtigste Gut Ihres Unternehmens!

Wenn Sie sich bemühen, die Produktion zu sperren aber dennoch Daten aus der besagten Produktion in weniger sicherere Umgebungen einschleusen, haben Sie diese Arbeit umsonst getan. Es werden jährlich Milliarden von Dollar in die Sicherung von physischen Rechenzentren gesteckt, nur damit Entwickler die Daten von einer Produktionsumgebung in unsicheres Terrain übertragen und wiederherstellen. Es gibt mittlerweile viele Möglichkeiten und Optionen, Daten in andere Umgebungen zu verschieben. Beispielsweise die Datenmaskierung, Datenverschleierung oder die Datenverschlüsselung. Die beste und sicherste Option ist trotz allem die Daten NICHT zu verschieben.

Mittlerweile ist man soweit, den Gedanken “nicht in der Produktionsumgebung zu testen” zu überdenken und zu hinterfragen. Mit einer CI/CD (Continuous Integration und Continuous Development) ist dies zum Beispiel sogar möglich. Dafür brauchen Sie keine echten Produktionsdaten. Sie brauchen Daten, die wie Produktionsdaten aussehen. Sogenannte Dummy-Daten. Das können beispielsweise Fake Kundennamen oder abgewandelte Adressen / Informationen sein. Mittels Simulationen können Sie Statistiken kreieren, die den Statistiken aus Ihrer Produktionsumgebung ähneln. Damit erreichen beispielsweise Abfragepläne die gleiche Form wie ursprünglich in der Produktion, ohne das tatsächliche Datenvolumen zu besitzen.

Einige Anonymisierungsvorgänge stellen wir Ihnen nachfolgend vor, damit Sie das Risiko nicht mehr eingehen müssen.

Anonymisierung von CSV-Daten

Bei der Anonymisierung von CSV-Daten werden lediglich zwei Felder explizit maskiert. Zum Einen ist das der vollständige Name zum Anderen die Email-Adresse. Im ersten Moment klingt das nach einer Kleinigkeit, allerdings besteht die Schwierigkeit in der Bewahrung der semantischen Beziehung und Verteilungen im Zieldatensatz. Da es auch oftmals in CSV Datensätzen zu mehreren Zeilen pro Nutzer kommen kann, müssen wir die Zuordnung von Profilinformationen pflegen.

In diesem Beispiel nutzen wir Python 2.7. und müssen entsprechend das unicodecsv Modul mit pip installieren. Weiterhin benötigen wir noch die Fake-Bibliothek zum Test. Nutzen Sie dafür folgenden Befehl:

$ pip install fake-factory unicodecsv

Mit dem folgenden Beispiel zeigen wir Ihnen, wie eine recht simple anonymize_rows Funktion die Zuordnung der Felder beibehält und unterdessen Fake Daten generiert. Dann gehen wir damit noch einen Schritt weiter und lesen Daten aus einer Quell CSV-Datei und schreiben anonymisierte Daten in die Ziel CSV-Datei. Das Ergebnis sollte dann eine Datei sein, die in Bezug auf Zeilenlänge, Felder und Co. sehr ähnlich ist. Lediglich Name und Emailadresse wurden ersetzt.

Verwenden Sie dafür folgenden Code:

import unicodecsv as csv
from faker import Factory
from collections import defaultdict
def anonymize_rows(rows):
    """
    Rows is an iterable of dictionaries that contain name and
    email fields that need to be anonymized.
    """
    # Load the faker and its providers
    faker  = Factory.create()
    # Create mappings of names & emails to faked names & emails.
    names  = defaultdict(faker.name)
    emails = defaultdict(faker.email)
    # Iterate over the rows and yield anonymized rows.
    for row in rows:
        # Replace the name and email fields with faked fields.
        row['name']  = names[row['name']]
        row['email'] = emails[row['email']]
        # Yield the row back to the caller
        yield row
def anonymize(source, target):
    """
    The source argument is a path to a CSV file containing data to anonymize,
    while target is a path to write the anonymized CSV data to.
    """
    with open(source, 'rU') as f:
        with open(target, 'w') as o:
            # Use the DictReader to easily extract fields
            reader = csv.DictReader(f)
            writer = csv.DictWriter(o, reader.fieldnames)
            # Read and anonymize data, writing to target file.
            for row in anonymize_rows(reader):
                writer.writerow(row)

Der Einstieg in diesen Code ist die anonymize Funktion selbst. Es wird der Pfad zu zwei Dateien verwendet. Einmal source , die als Quell CSV-Datei mit Originaldaten bestückt ist und einmal target, die als Ziel CSV-Datei fungieren soll und in den die Daten dann schlussendlich geschrieben werden. Beide Pfade sind zum Schreiben und Lesen geöffnet. Das unicodecsv Modul wird zum Lesen und Analysieren eingesetzt und wandelt die Ergebnisse dann in ein Python Dictionary um. Diese Dictionaries werden an die anonymize_rows Funktion übergeben, die mit yields jede Zeile entsprechend umwandelt und auf die Festplatte schreibt.

Mit dem Python Collection Modul erhalten Sie ein defaultdict, welches als Standard Dictionary fungiert, allerdings keine Schlüssel beinhaltet. Hier werden Standardwerte generiert und an die Instanz übergeben. Mit der Verwendung von defaultdict(faker.name), werden für jeden Schlüssel, der nicht im Dictionary steht, ein falscher Name erstellt. Damit kann eine Zuordnung von echten zu gefälschten Daten sichergestellt werden.

Generieren von gefälschten Daten

Um gefälschte Daten zu generieren gibt es mittlerweile einige Drittanbieter, die dabei unterstützen können. Diese sogenannten Faker bieten eine Anonymisierung von Benutzerprofildaten, die vollständig auf Instanz bezogener Basis generiert werden. Die primäre Schnittstelle der Faker sind Generatoren. Diese umschließen quasi eine Sammlung von Providern, die für die Formatierung der Zufallsdaten für eine bestimmte Domäne zuständig sind. Generatoren bieten zudem auch einen Wrapper um das Modul und ermöglichen damit einen Zufallsstartwert. Generatoren können mit unterschiedlichsten Methoden erstellt werden, als da wären fake.credit_card_number() , fake.military_ship() oder fake.hex_color(). Damit lässt sich recht schnell ein Schema entdecken, wie Sie Generatoren mittels diesen Methoden anpassen und gestalten können.

Wie bereits angemerkt, gibt es eine Vielzahl an Drittanbietern, die diverse Faker-Bibliotheken beinhalten und zur Verfügung stellen. Wir möchten jedoch keine Werbung für einen bestimmten Anbieter machen, deswegen empfehlen wir Ihnen, sollten Sie darüber nachdenken diesen Weg zu wählen, dass Sie sich auf GitHub über gängige Anbieter ein Bild machen und sich erkundigen.

Erstellen eines Anbieters

Wie bereits erwähnt, gibt es eine Vielzahl an Anbietern, die Faker-Bibliotheken anbieten. Theoretisch brauchen Sie zum Generieren von gefälschten Daten nur einen domänenspezifischen Datengenerator. Möchten Sie nun einen benutzerdefinierten Anbieter selbst erstellen, nutzen Sie die BaseProvider Faker-Methode. Sie können dafür folgenden Code verwenden:

from faker.providers import BaseProvider
class OceanProvider(BaseProvider):
    __provider__ = "ocean"
    __lang__     = "en_US"
    oceans = [
        u'Atlantic', u'Pacific', u'Indian', u'Arctic', u'Southern',
    ]
    @classmethod
    def ocean(cls):
        return cls.random_element(cls.oceans)

Um Dopplungen zu vermeiden, empfehlen wir in diesem Beispiel ocean zur Duplikatsliste hinzuzufügen. Fügen Sie dann im Nachgang Faker als Objekt hinzu.

>>> fake = Factory.create()
>>> fake.add_provider(OceanProvider)
>>> fake.ocean()
u'Indian'

In routinemäßigen Datenwrangling Operationen können Sie eine Paketstruktur mit ähnlicher Lokalisierung wie die eines Fakers erstellen und nach Bedarf laden.

Datenqualität aufrecht erhalten

Bisher haben wir die Vielfalt an gefälschten Daten beleuchtet und verstanden, wie wir diese entsprechend auch generieren können. Kehren wir zu unserem Beispiel zurück, erstellen wir zunächst Benutzerprofildaten, lediglich mit Namen und Email-Adresse. Beobachten wir die Ergebnisse der Anonymisierung können wir folgende Vor- & Nachteile feststellen:

Vorteile:
→ Duplikate, die exakt so aufgebaut wurden wie Ihre Quelle, werden über das Mapping beibehalten.
→ Die erstellten Benutzerprofile sind nun gefälscht worden und PII geschützt

Nachteile:
→ Duplikate werden anders angeordnet und stimmen nicht mehr exakt überein (J. Smith vs. John Smith)
→ Alle Domains sind von kostenlosen Providern wie Gmail
→ Name und Email-Adresse entsprechend nicht mehr dem Original und stimmen nicht mehr überein.

Da es jedoch unser Bestreben ist, Benutzerprofile zu verbessern, möchten wir beispielsweise sicherstellen, dass die Domänen für Emailadressen nicht mehr kostenloser Natur sind und entsprechend etwas realistischer rüberkommen. Weiterhin beziehen wir auch Aliase, Spitznamen oder Namensversionen mit ein. Dafür verwenden wir folgendes Skript:

>>> fake.simple_profile()
u'{ 
  "username": "autumn.weissnat", 
  "name": "Jalyn Crona", 
  "birthdate": "1981-01-29", 
  "sex": "F", 
  "address": "Unit 2875 Box 1477 \n DPO AE 18742-1954", 
  "mail": "zollie.schamberger@hotmail.com" 
}'

Im nachfolgenden Abschnitt werden wir uns dann mit dem Problem beschäftigen, dass wir unsere Datenmodifizierung so anpassen, dass realistischere Fake Profile erstellt werden können und diese mit dem ursprünglichen Datensatz übereinstimmen.

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.