Datenqualität

Datenqualität
Data Wrangling
R
Python
Plausibilität, fehlende Werte, Duplikate und uneinheitliche Kategorien.

Kernideen

  • Plausibilität zuerst: Was gar nicht sein kann, ist kein Ausreisser, sondern ein Fehler oder ein Platzhalter.
  • Ein Platzhalter wie -1 oder 9999 ist gefährlicher als eine leere Zelle, weil er mitrechnet.
  • Bei fehlenden Werten zählt nicht die Anzahl, sondern das Muster: Hängt das Fehlen mit anderen Merkmalen zusammen, verzerrt jedes Weglassen.
  • Duplikate entstehen leise, meist beim Zusammenführen, und verschieben jede Kennzahl.
  • Kategorien sind uneinheitlich geschrieben, bevor jemand sie vereinheitlicht: Bern, bern, Bern sind drei Gruppen.
  • Ziffernhäufung verrät gerundete oder geschätzte Werte.

Erklärung

Vorwissen: Data Wrangling für die Handgriffe und Tidy Data für die Form. Woher die Fehler kommen, steht unter Datenimport; die systematische Sichtung insgesamt unter explorative Datenanalyse.

Die Reihenfolge der Prüfungen

Schritt Frage Werkzeug
1. Struktur Stimmen Zeilenzahl und Spaltentypen? str(), df.dtypes
2. Plausibilität Was kann gar nicht sein? summary(), describe(), Minimum und Maximum
3. Fehlende Werte Wie viele, und wo? is.na(), isna().sum(), Vergleich der Gruppen
4. Duplikate Gibt es Zeilen doppelt, gibt es Schlüssel doppelt? duplicated(), .duplicated()
5. Kategorien Wie viele Ausprägungen, und sind es wirklich so viele? table(), value_counts()
6. Verteilung Häufen sich Werte an runden Stellen? letzte Ziffer auszählen

Erst danach beginnt die Auswertung. Jeder Schritt wird im Code festgehalten, nicht von Hand in der Datei korrigiert: Sonst ist die Bereinigung beim nächsten Datenstand weg und niemand kann sie nachvollziehen.

Fehlende Werte: drei Muster

Muster heisst Folge
rein zufällig das Fehlen hängt von nichts ab Weglassen kostet nur Fälle
von beobachteten Merkmalen abhängig Ältere antworten seltener Weglassen verzerrt; die Abhängigkeit gehört ins Modell
vom fehlenden Wert selbst abhängig wer viel verdient, nennt das Einkommen nicht aus den Daten allein nicht erkennbar

Die zweite Sorte lässt sich aufdecken, indem man die Fälle mit und ohne Lücke vergleicht. Genau das macht Beispiel 2.

Der Umgang mit Lücken

Vorgehen wann Vorsicht
Fall weglassen wenige Fälle, zufälliges Fehlen bei Abhängigkeit verzerrt es
Mittelwert einsetzen nie ohne Not verkleinert die Streuung, täuscht Genauigkeit vor
Modellbasiert ersetzen wenn viele Fälle betroffen sind aufwendig, und die Unsicherheit muss mitgeführt werden
als eigene Kategorie führen kategoriale Merkmale oft die ehrlichste Lösung

In jedem Fall gehört die Anzahl der weggelassenen oder ersetzten Werte in den Bericht.

Kurz nachgeschlagen

Aufgabe R Python
fehlende Werte je Spalte colSums(is.na(df)) df.isna().sum()
Platzhalter auf NA setzen df$x[df$x < 0] <- NA df.loc[df["x"] < 0, "x"] = np.nan
vollständige Fälle df[complete.cases(df), ] df.dropna()
exakte Duplikate sum(duplicated(df)) df.duplicated().sum()
doppelte Schlüssel sum(duplicated(df$id)) df["id"].duplicated().sum()
Duplikate entfernen distinct(df) df.drop_duplicates()
Leerzeichen entfernen trimws(x) s.str.strip()
Gross- und Kleinschreibung tolower(x) s.str.lower()
Ausprägungen zählen table(x) s.value_counts()

Beispiele

Frage und Datenlage

402 Datensätze mit Gewicht, Alter, Standort und Blutdruck. Der Blick auf die Kennzahlen des Gewichts zeigt ein Minimum, das es nicht geben kann.

Rechnung

nrow(roh)
[1] 402
summary(roh$gewicht)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
  -1.00   72.72   80.80   80.41   88.80  117.70 
sum(roh$gewicht < 0)
[1] 3
head(roh[roh$gewicht < 0, ], 3)
    gewicht alter standort blutdruck
12       -1    73     Chur       140
88       -1    45     Chur       144
301      -1    18     Bern       123
sauber <- roh
sauber$gewicht[sauber$gewicht < 0] <- NA
round(c(vorher = mean(roh$gewicht), nachher = mean(sauber$gewicht, na.rm = TRUE)), 2)
 vorher nachher 
  80.41   81.02 
print(len(roh))
402
print(roh["gewicht"].describe().round(2).to_dict())
{'count': 402.0, 'mean': 80.41, 'std': 13.49, 'min': -1.0, '25%': 72.72, '50%': 80.8, '75%': 88.8, 'max': 117.7}
print("negativ:", int((roh["gewicht"] < 0).sum()))
negativ: 3
print(roh[roh["gewicht"] < 0].head(3).to_string(index=False))
 gewicht  alter standort  blutdruck
    -1.0     73     Chur      140.0
    -1.0     45     Chur      144.0
    -1.0     18     Bern      123.0
sauber = roh.copy()
sauber.loc[sauber["gewicht"] < 0, "gewicht"] = np.nan
print(round(roh["gewicht"].mean(), 2), round(sauber["gewicht"].mean(), 2))
80.41 81.02

Output Zeile für Zeile

Grösse Wert Erklärung
Zeilen 402
Minimum Gewicht −1.0 Ein Gewicht von minus eins ist kein Ausreisser, sondern ein Code.
Maximum 117.7 plausibel
Anzahl negativer Werte 3 Drei Erfassungen mit Platzhalter.
Mittelwert vorher 80.41 mit den drei Platzhaltern
Mittelwert nachher 81.02 nach dem Ersetzen durch NA, also 0.61 höher

Interpretation und Ergebnissatz

Der Unterschied von 0.61 wirkt harmlos und ist es nur, weil es drei von 402 Fällen sind. Derselbe Platzhalter bei zehn Prozent der Werte, oder ein Code wie 9999 statt −1, verschöbe das Ergebnis massiv. Der eigentliche Punkt ist nicht die Grösse der Verschiebung, sondern dass sie ohne jeden Hinweis entsteht: Eine leere Zelle wird als NA gelesen und von jeder Rechnung sichtbar behandelt, ein Platzhalter rechnet mit.

Drei der 402 Gewichte sind mit dem Platzhalter −1 erfasst. Ersetzt man sie durch NA, steigt der Mittelwert von 80.41 auf 81.02.

Frage und Datenlage

Beim Blutdruck fehlen Werte. Wie viele sind es, und unterscheiden sich die Fälle mit Lücke von denen ohne?

Rechnung

c(fehlend = sum(is.na(sauber$blutdruck)),
  anteil  = round(mean(is.na(sauber$blutdruck)) * 100, 1))
fehlend  anteil 
     28       7 
sauber |>
  mutate(fehlt = is.na(blutdruck)) |>
  summarise(.by = fehlt, n = n(), mittleres_alter = round(mean(alter), 1))
  fehlt   n mittleres_alter
1 FALSE 374            43.4
2  TRUE  28            70.0
sauber |>
  mutate(gruppe = cut(alter, c(17, 40, 65, 100),
                      labels = c("18-40", "41-65", "66+"))) |>
  summarise(.by = gruppe,
            anteil_fehlend = round(mean(is.na(blutdruck)) * 100, 1)) |>
  arrange(gruppe)
  gruppe anteil_fehlend
1  18-40              0
2  41-65              0
3    66+             50
round(mean(sauber$blutdruck, na.rm = TRUE), 2)
[1] 129.06
print("fehlend:", int(sauber["blutdruck"].isna().sum()),
      round(sauber["blutdruck"].isna().mean() * 100, 1))
fehlend: 28 7.0
print(sauber.assign(fehlt=sauber["blutdruck"].isna())
      .groupby("fehlt")
      .agg(n=("alter", "size"), mittleres_alter=("alter", "mean"))
      .round(1).to_string())
         n  mittleres_alter
fehlt                      
False  374             43.4
True    28             70.0
gruppen = pd.cut(sauber["alter"], [17, 40, 65, 100],
                 labels=["18-40", "41-65", "66+"])
print(sauber.groupby(gruppen, observed=True)["blutdruck"]
      .apply(lambda s: round(s.isna().mean() * 100, 1)).to_dict())
{'18-40': 0.0, '41-65': 0.0, '66+': 50.0}
print(round(sauber["blutdruck"].mean(), 2))
129.06

Output Zeile für Zeile

Grösse Wert Erklärung
fehlende Werte 28 von 402, 7.0 Prozent Für sich genommen unauffällig.
mittleres Alter, Wert vorhanden 43.4 Jahre 374 Fälle
mittleres Alter, Wert fehlt 70.0 Jahre 28 Fälle. Der Unterschied von über 26 Jahren ist das Warnzeichen.
Anteil fehlend, 18 bis 40 0.0 Prozent
Anteil fehlend, 41 bis 65 0.0 Prozent
Anteil fehlend, 66 und älter 50.0 Prozent Jede zweite ältere Person hat keinen Blutdruckwert.
Mittelwert der vorhandenen Werte 129.06 Dieser Wert beschreibt eine Stichprobe, in der die Älteren fehlen.

Interpretation und Ergebnissatz

Sieben Prozent Lücken klingen nach einer Kleinigkeit, die man wegwirft. Der Vergleich zeigt das Gegenteil: Die Lücken liegen fast vollständig bei den über 65-Jährigen. Wer die Fälle weglässt, rechnet den Blutdruck einer jüngeren Bevölkerung aus, und weil Blutdruck mit dem Alter steigt, ist das Ergebnis zu niedrig. Der Anteil fehlender Werte je Altersgruppe gehört deshalb in den Bericht, und die Altersabhängigkeit ins Modell.

Von 402 Blutdruckwerten fehlen 28, also 7.0 Prozent, und zwar ausschliesslich bei den über 65-Jährigen, wo jeder zweite Wert fehlt. Die Fälle mit Lücke sind im Mittel 70.0 Jahre alt, die vollständigen 43.4.

Frage und Datenlage

Beim Zusammenführen zweier Exporte sind Zeilen doppelt in den Datensatz geraten. Wie viele, und was ändert sich, wenn man sie entfernt?

Rechnung

sum(duplicated(roh))
[1] 2
which(duplicated(roh))
[1] 401 402
ohne <- distinct(roh)
c(mit = nrow(roh), ohne = nrow(ohne))
 mit ohne 
 402  400 
round(c(mit = mean(roh$alter), ohne = mean(ohne$alter)), 3)
   mit   ohne 
45.221 45.138 
print("exakte Duplikate:", int(roh.duplicated().sum()))
exakte Duplikate: 2
print(roh.index[roh.duplicated()].tolist())
[400, 401]
ohne = roh.drop_duplicates()
print(len(roh), len(ohne))
402 400
print(round(roh["alter"].mean(), 3), round(ohne["alter"].mean(), 3))
45.221 45.138

Output Zeile für Zeile

Grösse Wert Erklärung
exakte Duplikate 2 Zeilen, die in allen Spalten mit einer früheren übereinstimmen.
Zeilen mit, ohne 402 gegen 400
mittleres Alter 45.221 gegen 45.138 Der Unterschied ist klein, weil es zwei von 402 sind. Er zeigt aber, dass jede Kennzahl betroffen ist.

duplicated() markiert das zweite und jedes weitere Vorkommen, nicht das erste. Für Schlüsselspalten prüft man zusätzlich duplicated(df$id): Zwei Zeilen mit derselben Kennung, aber verschiedenen Werten sind der schlimmere Fall, weil keine der beiden falsch aussieht.

Interpretation und Ergebnissatz

Exakte Duplikate sind leicht zu finden und meist harmlos zu beheben. Die eigentliche Gefahr liegt davor: Sie entstehen fast immer beim Verknüpfen mit einem nicht eindeutigen Schlüssel, siehe pandas, Beispiel 6. Deshalb ist die Zeilenzahl vor und nach jedem Join die billigste Kontrolle, die es gibt.

Der Datensatz enthält zwei exakte Duplikate; nach dem Entfernen bleiben 400 Zeilen, und das mittlere Alter verschiebt sich von 45.221 auf 45.138.

Frage und Datenlage

Der Standort ist als Text erfasst. Wie viele Ausprägungen gibt es, und wie viele sollten es sein?

Rechnung

sort(unique(roh$standort))
[1] "bern"  "Bern"  "Bern " "Chur"  "Sion" 
table(roh$standort)

 bern  Bern Bern   Chur  Sion 
    3   200     2   114    83 
normiert <- trimws(roh$standort)
normiert <- paste0(toupper(substr(normiert, 1, 1)),
                   tolower(substring(normiert, 2)))
table(normiert)
normiert
Bern Chur Sion 
 205  114   83 
print(sorted(roh["standort"].unique().tolist()))
['Bern', 'Bern ', 'Chur', 'Sion', 'bern']
print(roh["standort"].value_counts().to_dict())
{'Bern': 200, 'Chur': 114, 'Sion': 83, 'bern': 3, 'Bern ': 2}
normiert = roh["standort"].str.strip().str.capitalize()
print(normiert.value_counts().to_dict())
{'Bern': 205, 'Chur': 114, 'Sion': 83}

Output Zeile für Zeile

Ausprägung Anzahl Erklärung
Bern 200
Chur 114
Sion 83
bern 3 kleingeschrieben erfasst
Bern 2 mit angehängtem Leerzeichen, im Ausdruck unsichtbar
nach dem Normieren Bern 205, Chur 114, Sion 83 drei Ausprägungen statt fünf

Das angehängte Leerzeichen ist der tückischere Fall: In jeder Tabelle und jeder Grafik sieht Bern genau aus wie Bern, aber eine Gruppierung trennt beide, und ein Join findet die Zeilen nicht. Ein Blick auf die Anzahl der Ausprägungen (length(unique(x)) gegen die erwartete Zahl) findet das sofort.

Interpretation und Ergebnissatz

Fünf Gruppen statt drei bedeuten fünf Zeilen in jeder Auswertung, zwei davon mit winzigen Fallzahlen, und einen Mittelwert für “Bern”, der drei Prozent der Berner Fälle nicht enthält. Das Vereinheitlichen (Leerzeichen entfernen, Schreibweise angleichen) gehört vor jede Gruppierung; bei freiem Text kommt das Abgleichen gegen eine Referenzliste dazu.

Der Standort hat fünf Ausprägungen, obwohl es drei Orte sind: bern dreimal und Bern mit Leerzeichen zweimal. Nach dem Vereinheitlichen stehen 205 Berner Fälle statt 200.

Frage und Datenlage

Blutdruckwerte werden oft geschätzt oder auf “runde” Werte gerundet. Lässt sich das an den Daten selbst erkennen? Dazu die Häufigkeit der letzten Ziffer aller vorhandenen Werte.

Rechnung

bd <- sauber$blutdruck[!is.na(sauber$blutdruck)]
length(bd)
[1] 374
table(bd %% 10)

  0   1   2   3   4   5   6   7   8   9 
102  24  25  26  22  72  30  24  19  30 
round(mean(bd %% 5 == 0) * 100, 1)
[1] 46.5
bd = sauber["blutdruck"].dropna()
print(len(bd))
374
print((bd % 10).astype(int).value_counts().sort_index().to_dict())
{0: 102, 1: 24, 2: 25, 3: 26, 4: 22, 5: 72, 6: 30, 7: 24, 8: 19, 9: 30}
print(round(((bd % 5) == 0).mean() * 100, 1))
46.5

Output Zeile für Zeile

letzte Ziffer Anzahl Erwartung bei gleichmässiger Verteilung
0 102 rund 37
1 24
2 25
3 26
4 22
5 72 rund 37
6 30
7 24
8 19
9 30
Grösse Wert Erklärung
Werte insgesamt 374
Anteil auf 0 oder 5 46.5 Prozent Bei gleichmässiger Verteilung wären es 20 Prozent.

Interpretation und Ergebnissatz

Fast die Hälfte aller Werte endet auf 0 oder 5. Das kommt in der Natur nicht vor, es kommt aus dem Erfassungsvorgang: geschätzte oder gerundete Ablesungen. Für die Auswertung heisst das zweierlei. Die Werte sind gröber, als ihre Nachkommastellen vortäuschen, und eine Kennzahl sollte nicht mehr Stellen nennen, als die Erfassung hergibt. Und wenn nur ein Teil der Erhebung so zustande kam, unterscheiden sich die Teile systematisch, was vor jedem Gruppenvergleich zu klären ist.

46.5 Prozent der 374 Blutdruckwerte enden auf 0 oder 5, erwartet wären 20 Prozent; allein die Endziffer 0 kommt 102-mal statt rund 37-mal vor. Die Werte sind also zu einem erheblichen Teil gerundet erfasst.

Verständnisfragen

In den Daten steht bei drei Personen ein Gewicht von minus eins. Warum ist so ein Platzhalter gefährlicher als eine leere Zelle?

Er ist nicht gefährlicher, nur unschöner
Der Unterschied ist, ob ein Fehler auffällt oder nicht.
Er sieht wie ein gültiger Wert aus und rechnet stillschweigend mit
Richtig. Eine leere Zelle wird als NA eingelesen und von jeder Rechnung sichtbar behandelt; −1 verschiebt den Mittelwert ohne Warnung, hier um 0.61.
Er lässt sich nicht in NA umwandeln
Umwandeln lässt er sich gut, man muss ihn nur zuerst bemerken.

Bei sieben Prozent der Fälle fehlt der Blutdruck. Diese Fälle sind im Mittel 70 Jahre alt, die übrigen 43. Was folgt daraus?

Sieben Prozent sind wenig, die Fälle können weg
Die Anzahl ist nicht das Kriterium, sondern das Muster.
Das Fehlen hängt vom Alter ab, Weglassen verzerrt das Ergebnis nach unten
Richtig. Übrig bleibt eine jüngere Stichprobe, und da der Blutdruck mit dem Alter steigt, ist das Ergebnis zu niedrig.
Die Messgeräte waren defekt
Möglich, aber das ändert nichts an der Verzerrung.

Eine Auswertung nach Standort zeigt fünf Gruppen, obwohl es drei Standorte gibt. Was ist zuerst zu prüfen?

Schreibweise und unsichtbare Leerzeichen
Richtig. Bern, bern und Bern sind drei verschiedene Zeichenketten, und das Leerzeichen sieht man im Ausdruck nicht. trimws beziehungsweise .str.strip() und eine einheitliche Schreibweise lösen es.
Ob zwei Standorte neu eröffnet wurden
Möglich, aber die Fallzahlen von 3 und 2 sprechen dagegen.
Ob die Gruppierung falsch programmiert ist
Die Gruppierung tut genau, was sie soll; die Werte unterscheiden sich wirklich.

46.5 Prozent der Blutdruckwerte enden auf 0 oder 5. Was sagt das über die Daten?

Ein grosser Teil wurde gerundet oder geschätzt erfasst
Richtig. Bei gleichmässiger Verteilung der Endziffern wären es 20 Prozent. Die Werte sind gröber, als sie aussehen.
Die Messgeräte sind besonders genau
Genaue Geräte erzeugen keine Häufung auf runden Werten.
Es ist Zufall
Bei 374 Werten ist eine Abweichung von 20 auf 46.5 Prozent kein Zufall.

Nach dem Entfernen von zwei Duplikaten ändert sich das mittlere Alter von 45.221 auf 45.138. Ist der Aufwand gerechtfertigt?

Ja, denn die Grösse der Änderung ist Zufall der Anzahl
Richtig. Bei zwei von 402 Zeilen ist sie klein; entstünden die Duplikate durch einen Join mit nicht eindeutigem Schlüssel, wären es schnell Hunderte.
Nein, die dritte Nachkommastelle ist belanglos
Belanglos ist die Verschiebung hier, nicht die Ursache.
Nein, Duplikate sind immer echte Beobachtungen
Zwei identische Zeilen können echt sein; dann braucht es eine Kennung, die sie unterscheidet.

Verlinkte Ressourcen