Dnes se společně podíváme, jak to vypadá, když data nejsou tak úplně v pořádku. K analýze použijeme data o filmech z let 2016 až 2018 a jejich hodnocení z Internet movie database.
V datech máme opět náhodně upravené některé hodnoty, aby příklady hezky vycházely.
Data jsou k dispozici v následujících souborech:
import pandas as pd
%matplotlib inline
Data jsou distribuována ve vice souborech a tak je budeme muset načíst samostatně a pak z nich vytvořit jednu datovou sadu k dalšímu zpracování. To se stává nejčastěji u historických dat, kterých může být velké množství a často by bylo zbytečné stahovat celou historii. V této podobě si může analytik vybrat časové období, které ho zajímá nebo jen část dat, kterou potřebuje.
Začneme daty o filmech z roku 2016.
movies_2016 = pd.read_csv("static/movies_2016.tsv")
movies_2016
Koncovka tsv
naznačuje, že budeme potřebovat jiný způsob načtení. Zatímco u CSV je oddělovačem hodnot čárka, TSV používá tabulátor. Je to naštěstí také známý formát a tak jej umí pandas snadno zpracovat.
movies_2016 = pd.read_table("static/movies_2016.tsv")
movies_2016
Stejným způsobem si načteme i další soubory.
movies_2017 = pd.read_table("static/movies_2017.tsv")
movies_2017
movies_2018 = pd.read_table("static/movies_2018.tsv")
movies_2018
V poslední tabulce se nachází hodnocení filmů.
ratings = pd.read_table("static/ratings.tsv")
ratings
Nejprve budeme potřebovat spojit primární data o filmech do jedné datové sady. Naštěstí pro nás mají všechny zdroje stejné sloupce, což také nebývá pravidlem, především když se v čase mění způsob zpracování a publikace dat.
Náš příklad je však jednoduchý a tak i spojení více zdrojů do jednoho nevyžaduje žádné mezikroky.
movies = pd.concat((movies_2016, movies_2017, movies_2018))
movies
concat
umožňuje provádět i složitější magii a spojovat různé zdroje do jedné datové sady na základě různých kritérií. Složitost je nejčastěji závislá na společných prvcích jednotlivých zdrojů, jako jsou indexy či názvy sloupců. V základním nastavení spojuje zdroje do délky a protože náš index je automatický a nemá pro nás význam, je ignorován a ve výsledku přepočítán.
Nadešel čas k existující datové sadě připojit informace o hodnocení jednotlivých filmů. Každý film má jednoznačný identifikátor ve sloupci tconst, který je obsažen i v datech o hodnocení, takže bude snadné identifikovat, které hodnocení patří ke kterému filmu. Je tu ovšem jedna potíž, na které závisí naše další počínání.
movies.shape
ratings.shape
Zatímco hlavní sada obsahuje základní informace o více než 50 000 filmech, informaci o hodnocení máme jen pro cca 26 000 z nich. Teď se musíme rozhodnout pro jednu ze čtyř možných strategií spojování, která ovlivní výsledek. Kdo někdy spojoval tabulky v SQL dotazech, bude mít hned jasno.
Grafická reprezentace zmíněných čtyř možností pomocí Vénnových diagramů vypadá následovně:
Obrázek adaptován z Wikimedia (autor: Anthony Beck)
Inner vloží do výsledku jen ty řádky, které mají zastoupení v obou zdrojových tabulkách. V našem případě to znamená, že by ve výsledku byly jen ty filmy, pro které existuje hodnocení. Jinak řečeno se nad sloupcem pro identifikaci řádků, které k sobě patří, provede průnik a teprve pak dojde ke spojení.
pd.merge(movies, ratings, how="inner", left_on="tconst", right_on="tconst")
Parametry left_on
a right_on
říkáme, které sloupce slouží pro identifikaci k sobě patřících záznamů v levé a pravé tabulce. Použít pro to lze samozřejmě i index v kterékoli z nich.
Na množství záznamů ve výsledku je vidět, že touto strategií spojení jsme přišli o několik hodnocení, ke kterým se nepodařilo najít film, a spoustu filmů, pro které se nepodařilo najít hodnocení.
Outer způsobí, že se zdroje spojí dohromady v plné velikosti, protože se nad sloupcem pro identifikaci provede nejdříve sjednocení. Pokud jedna z tabulek nebude mít ve druhé řádek, se kterým se bude moci spojit, budou hodnoty nahrazeny nulovými hodnotami.
pd.merge(movies, ratings, how="outer", left_on="tconst", right_on="tconst")
V tomto případě nepřijdeme o žádné informace o filmech, ale u spousty z nich nebudeme mít informaci o hodnocení a u několika hodnocení zase budou chybět základní informace o filmech.
Strategie left a right berou jako základ pro spojení levou/pravou tabulku, která tedy zůstane v původní podobě. Druhá tabulka do dvojice se použije jen pro doplnění informací tam, kde je to možné.
pd.merge(movies, ratings, how="left", left_on="tconst", right_on="tconst")
Tady máme k dispozici všechny filmy a doplněné hodnocení tam, kde to bylo možné.
pd.merge(movies, ratings, how="right", left_on="tconst", right_on="tconst")
Tady máme naopak všechna hodnocení a informace o filmu jen tam, kde je bylo možné dohledat.
Finální strategie vždy záleží na tom, která data potřebujeme a která si můžeme dovolit vypustit. Více si toto rozebereme v dalších kapitolách. Pro tuto chvíli pro nás budou důležitější informace o filmech a tak zůstaneme u strategie, která použije filmy jako základ pro spojení.
data = pd.merge(movies, ratings, how="left", left_on="tconst", right_on="tconst")
Nulové (neboli chybějící) hodnoty jsou snadno identifikovatelné a znamenají v podstatě chybějící data v datasetu. Nejčastěji jsou označeny jako NaN
, což je ekvivalent pythoního None
a může být použit i přímo z knihovny numpy.
import numpy as np
np.nan
Pandas identifikuje chybějící hodnoty automaticky, pokud je buňka ve zdrojovém souboru zcela prázdná. Pokud zdrojová data obsahují nějakou jinou reprezentaci (např.: N/A
, \\N
a podobně). je potřeba je na np.nan
nejdříve převézt. Po převodu se nám bude s daty mnohem lépe pracovat.
Informace o chybějících hodnotách lze vyčíst z informací o datech, deskriptivních statistikách a pak také přímo jejich součtem napříč daty.
data.info()
data.describe()
data.isnull().sum()
Poslední možnost je sice nejdelší na psaní, ale výsledek je nejlépe vidět a nevyžaduje odečítání od celkového počtu záznamů.
Pro práci s nulovými hodnotami neexistuje jedna správná a univerzální cesta. V podstatě si musíme velmi uváženě vybrat jednu ze dvou možností:
Mazání je velmi jednoduché. Smazat můžeme celé sloupce nebo řádky, ale přijdeme tím i o potencionálně užitečná data a snížíme tím jejich reprezentativnost.
V našem případě můžeme smazat sloupec endYear, který neobsahuje ani jednu hodnotu a tak jeho smazáním o nic nepřijdeme.
data = data.drop(columns=["endYear"])
Pro mazání související přímo s chybějícími hodnotami máme k dispozici metodu dropna
, která umí mazat řádky či sloupce obsahující alespoň jednu chybějící hodnotu nebo zcela naplněné chybějícími hodnotami. Nic dalšího mazat nebudeme, ale výsledek jednotlivých strategií si přes to ukážeme.
data.shape
Aktuálně máme v datech 53096 záznamů. Takto by to vypadalo, pokud bychom nechali smazat všechny řádky, které obsahují alespoň jednu chybějící hodnotu.
data.dropna(how="any")
A takto, pokud bychom smazali řádky, které mají pouze chybějící hodnoty:
data.dropna(how="all")
Z počtu zbylých řádků je jasné, že se žádný nesmazal a každý řádek tedy obsahuje alespoň nějakou hodnotu.
Mazání by mohlo způsobit ztrátu potencionálně užitečných dat. Smazáním filmů bez hodnocení bychom přišli o polovinu filmů, což by pro analýzu některých jejich vlastností mohlo být nežádoucí.
Bez mazání musíme v dalších krocích buď počítat s tím, že máme v datech chybějící hodnoty, nebo je do dat nějak doplnit.
Doplnit data lze mnoha způsoby a záleží hlavně na proměnné, kterou budeme doplňovat. Je třeba mít také na paměti, že doplněná data nejsou reálná a měla by plnit jen pomocnou funkci. Možnosti jsou následující:
U více než 12 000 filmů nám chybí informace o jejich stopáži. Pojďme zkusit najít nejvhodnější cestu k doplnění.
data.runtimeMinutes.hist(bins=30);
Histogram trpí tím, že v datech máme i film dlouhý 36 hodin. Jak na to zareagují popisné statistiky?
data.runtimeMinutes.describe()
75 % filmů je kratších než 100 minut a medián s průměrem příliš nereflektují extrémní hodnoty, takže bychom jednu z těchto hodnot mohli použít. Pro úplnost se ještě podíváme, která hodnota se vyskytuje v datech úplně nejčastěji.
data.runtimeMinutes.mode()
data.runtimeMinutes.value_counts()
Vzhledem k malým rozdílům moc nesejde na tom, zda použijeme mean, medián nebo mode. Zkusme tedy medián.
data.runtimeMinutes = data.runtimeMinutes.fillna(data.runtimeMinutes.median())
Nový sloupec s doplněnými chybějícími hodnotami uložíme zpět do původních dat a máme hotovo.
data.isnull().sum()
U kategoriální proměnné genres nemáme tolik možností jako u numerických proměnných a navíc tento sloupec obsahuje různé kombinace žánrů. V neposlední řadě se s chybějícími hodnotami nepracuje u textových sloupců tak snadno.
data.genres.value_counts()[:]
Data obsahují celkem 814 unikátních kombinací žánrů a více než třetina filmů jsou dokumenty. Doplnit takovou proměnnou o nejčastější hodnotu by asi nebyl nejlepší nápad a tak budeme předpokládat, že chybějící hodnota znamená, že se film zkrátka nepodařilo zařadit do žádné z připravených škatulek.
data[data.genres.isnull()].shape
data[(data.genres.isnull()) & (data.averageRating.isnull())].shape
Navíc je vidět, že jen okolo 200 filmů z těch s chybějícím žánrem má nějaké hodnocení, což podporuje pocit, že se nejedná o běžné snímky. Nicméně je to stále jen pocit.
Chybějící hodnoty ve sloupci s originálním názvem nepůjde doplnit tak snadno a doplňovat hodnocení filmů jakýmkoli způsobem také není dobrý nápad, protože by to mohlo ovlivnit výsledky případné navazující analýzy.
Jak vidno, mazání či doplnění hodnot je vždy rizikové a takovému kroku by mělo předcházet dobré zvážení a zdůvodnění. Je také více než rozumné si na konci analýz sesumírovat své výsledky a zkusit odhadnout, jaký vliv na ně mohla mít strategie nakládání s chybějícími hodnotami.
Chybějící hodnoty na nás v ideálním případě vyskočí hned po načtení dat, případně je objevíme záhy při pohledu na jednotlivé proměnné. S odlehlými měřeními je to složitější, protože na ně v tom nejhorším případě nemusíme narazit vůbec a jejich výskyt ovlivní výsledky naší analýzy.
Že nám nějaká hodnota nesedí do zbytku dat, ještě nemusí nic zásadního znamenat a hlavně nemusí jít vždy o chybu. Stejně jako v případě chybějících hodnot je i zde před opravou či jiným zásahem třeba dobře zvážit jeho následky. Nejčastější příčiny výskytu odlehlých měření jsou:
Při hledání odlehlých měření se nejdříve zaměříme na jednotlivé proměnné a pak na jejich kombinace. Základní přehled a představu o tom, co můžeme dále očekávat nám poskytne známy krabicový graf.
První metodou pro detekci odlehlých měření je inter-quartile range, který jsme si popsali při analýze jedné proměnné u popisu krabicového grafu. Pro zopakování: vezme se rozsah mezi prvním a třetím quartilem a vynásobí se 1,5×. Tím se vytvoří horní a spodní hranice (vodorovné čárky v grafu) a co se mezi ně nevejde, je označeno jako odlehlé měření.
data.plot.box(figsize=(15,15), subplots=True, layout=(3,3));
S daty už umíme lépe pracovat, takže si můžeme zkusit vypočítat tuto metodu i ručně.
q1 = data.runtimeMinutes.quantile(0.25)
q3 = data.runtimeMinutes.quantile(0.75)
iqr = q3 - q1
dolni_hranice = q1-1.5*iqr
horni_hranice = q3+1.5*iqr
print(f"Q1: {q1}, Q3: {q3}, IQR: {iqr}, dolní hranice: {dolni_hranice}, horní hranice: {horni_hranice}")
Vypočtené hranice nám poslouží pro manuální filtraci dat, takže uvidíme i v tabulce to, co je vidět v grafu.
data[data.runtimeMinutes < dolni_hranice]
data[data.runtimeMinutes > horni_hranice]
Skoro 7 tisíc filmů je z pohledu své stopáže označeno jako odlehlá měření. Výpočet pomocí IQR nabízí jednoduchou cestu k výsledkům, ale není jednoduše možné si rozsah hodnot upravit podle svých představ. To je možné v druhé populární metodě detekce odlehlých měření - Z score.
z score je metoda detekce odlehlých měření definovaná jednoduchým vzorcem a závislá na směrodatné odchylce. Právě díky jednoduchému vzorečku a nastavitelné hranici je možné si určit, co pro nás znamená odlehlé měření. Vzorec vypadá následovně:
$$ z = \frac{x - \mu}{\sigma} $$
x
označuje konkrétní hodnotu proměnné, 𝜇
označuje průměr pro danou proměnnou a 𝜎
směrodatnou odchylku. Z score uvádí, kolik násobků směrodatné odchylky je daná hodnota vzdálená od průměru. Čím blíže bude zkoumaná hodnota průměru, tím blíže bude Z score nule. Výhodou je, že si pro Z score můžeme sami určit hranice pro detekci odlehlých měření a také to, že díky násobkům směrodatné odchylky, ve kterých se tato hranice stanovuje, víme, kolik procent záznamů máme pokryto. O směrodatné odchylce a normálním rozložení si budeme ještě povídat v lekci o reprezentativnosti dat.
U proměnných s normálním rozložením je nejvíce výskytů kolem průměru a s rostoucí vzdáleností od průměru klesá počet výskytů. Do vzdálenosti jedné směrodatné odchylky se vejde 68 % všech hodnot, dvojnásobek směrodatné odchylky pak pokrývá 95 % všech hodnot a trojnásobek 99 %.
Je čas to vyzkoušet:
abs(20 - data.runtimeMinutes.mean()) / data.runtimeMinutes.std()
abs(2 - data.runtimeMinutes.mean()) / data.runtimeMinutes.std()
Dvacetiminutový film je od průměrné hodnoty vzdálen přibližně dvojnásobek směrodatné odchylky. Striktně vzato by se nezařadil mezi 95 % nejčastějších hodnot.
abs(180 - data.runtimeMinutes.mean()) / data.runtimeMinutes.std()
Tři hodiny trvající film je od průměru vzdálen ještě o něco více to skoro o trojnásobek směrodatné odchylky.
Z score vypočtené pro všechny záznamy nám může také posloužit pro filtraci dat.
zs = abs((data.runtimeMinutes - data.runtimeMinutes.mean()) / data.runtimeMinutes.std())
data[zs > 3].sort_values(by="runtimeMinutes")
Z score nám na spodní hranici nedetekovalo žádná odlehlá měření a na té horní pak 180 filmů delších než tři hodiny.
Pro kompletní detekci odlehlých měření je ještě potřeba se na data podívat z pohledu více dimenzí. Kombinace více vlastností totiž může být zcela mimo očekávanou hodnotu, i když každá z vlastností samostatně vůbec nemusí vybočovat z řady.
Pro detekci takových případů použijeme stejně jako pro hledání trendů a korelací scatter plot.
from pandas.plotting import scatter_matrix
scatter_matrix(data, figsize=(20, 20));
Ve scatter plotech není na první pohled nic vidět, resp. jsou zde patrná odlehlá měření, která jsme již detekovali dříve.
Jeden zajímavý bod zde ale přeci jen máme. Je vidět v grafu závislosti celkové délky stopáže a počtu hodnocení.
data.plot.scatter(x="runtimeMinutes", y="numVotes", grid=True);
Že se v počtu hlasování objeví extrémně často hodnocené filmy, to je očekávané a víme to z předchozích kroků analýzy. Stejná je situace u extrémních hodnot délky filmů, kde se nějaké extrémy na obě strany dají očekávat. Kombinace obou těchto vlastností má také více méně očekávaný charakter a lidé nejčastěji hodnotí filmy s průměrnou délkou. Až na jednu výjimku, jak je patrné z grafu.
I když film mající 300 000 hodnocení není nijak výjimečný a délka 400 minut také není z nejextrémnějších, společná kombinace těchto hodnot je přinejmenším podezřelá a zasloužila by si v běžné analýze další zkoumání.
Stejně jako v předchozích krocích u IQR a Z score, můžeme i zde použít algoritmy pro detekci konkrétních odlehlých pozorování ve dvou a více dimenzích. Jejich rozbor a porovnání je mimo záběr této lekce, ale jeden jednoduchý a celkem populární si přeci jen ukážeme.
DBSCAN (Density-Based Spatial Clustering of Applications with Noise) je algoritmus, který dokáže shlukovat body v prostoru (o libovolném počtu dimenzí) k sobě do skupin podle toho, jak moc jsou body blízko u sebe. Jednoduše řečeno prohledá okolí bodů v prostoru a pokud najde dostatek sousedů, přidá je do společné skupinky. Pokud ovšem body nemají dostatek sousedů, jsou označeny jako šum (noise) a v našem případě je lze považovat za odlehlá měření, protože se zkrátka vyskytují příliš daleko od ostatních a není jich dost, aby si vytvořili vlastní skupinku.
Velikost prohledávaného okolí a nezbytný počet sousedů jsou nastavitelné parametry, takže i výsledky tohoto algoritmu budou stejně jako u Z score záviset na správném nastavení a interpretaci. Pro jednoduchost se budeme pohybovat jen v prostoru o dvou dimenzích, což nám umožní výsledky pěkně vizualizovat.
Celý proces bude trošku komplikovaný, ale podstatné pro nás je porozumnět všem krokům než si hned zapamatovat celý postup a umět jej naprogramovat.
Nejprve musíme z dat odebrat nulové hodnoty, protože s těmi si algoritmy často neumí poradit. Nechceme při tom zasahovat do originálních dat, takže si výsledek uložíme do nové proměnné. Při odstraňování chybějících dat jsme zvolili strategii any
takže stačí jedna nulová hodnota pro odstranění řádku a subset
nastavil sloupce, kam se bude pandas dívat, protože např. chybějící hodnota ve sloupci s originálním názvem nás aktuálně nezajímá a není třeba, aby způsobila odstranění celého řádku.
no_null_values = data.dropna(how="any", subset=["runtimeMinutes", "numVotes"])
no_null_values.isnull().sum()
Nyní potřebujeme upravit škálu našich dat. Problém je v tom, že délka filmu v minutách se pohybuje v dost odlišných cifrách než počet hlasujících, což by nám komplikovalo správné nastavení algoritmu a výpočet vzdálenosti mezi jednotlivými body. Více o škálování bude řeč v dalších lekcích, protože je to celkem běžná součást přípravy dat pro strojové učení. Škálování na menší hodnoty se provede pro oba sloupce najednou, takže vztah mezi nimi zůstane zachován.
from sklearn.preprocessing import StandardScaler
sc = StandardScaler()
transformed = sc.fit_transform(no_null_values[["runtimeMinutes", "numVotes"]])
transformed
Výsledkem je dvourozměrná matice, kterou si pro ověření můžeme také vizualizovat. Převodem z pandas DataFrame
na numpy matici jsme přišli o názvy sloupců, ale ke sloupcům samotným se dokážeme dostat díky rozšířené indexaci.
from matplotlib import pyplot as plt
plt.scatter(x=transformed[:, 0], y=transformed[:, 1]);
V následujícím kroku si vytvoříme model, který bude prohledávat okolí o velikosti 4 a za skupinku bude považovat shluk minimálně dvou bodů.
from sklearn.cluster import DBSCAN
model = DBSCAN(eps=4, min_samples=2)
Necháme model naučit se naše transformovaná data.
result = model.fit(transformed)
Výsledek obsahuje mimo jiné labels_
, což je seznam tzv. značek, kterými označil jednotlivé řádky z našich dat a tím je zařadil do skupin.
result.labels_
Převodem na množinu (set
) dokážeme zjistit, kolik takových skupinek vytvořil.
len(set(result.labels_))
A teď už k vizualizaci. K tomu budeme potřebovat vykreslit do scatter plotu různé barvy, abychom mezi skupinkami dokázali rozlišit. U takto malého počtu bychom si dokázali barvy určit i ručně, ale pomoci nám může barevná mapa, která hodnoty pro jednotlivé skupiny rozloží na vybrané barevné škále a my je tím pádem dokážeme rozeznat v grafu.
from matplotlib import cm
cmap = cm.get_cmap("cividis")
no_null_values.plot.scatter(x="runtimeMinutes", y="numVotes", c=result.labels_, cmap=cmap);
Šedá barva označuje skupinku číslo 0, která obsahuje nejvíce filmů. -1 označuje odlehlá měření, která jsou daleko a navíc jich je příliš málo na vytvoření skupiny. Žlutá je skupinka číslo 1, která není dost blízko ostatním, ale je dost početná na to, aby si vytvořila skupinku vlastní.
Takto může ve výsledku vypadat automatická detekce odlehlých měření i v mnoha dimenzích. Co se ale s odlehlými měřeními nakonec stane a jak ovlivní výsledek celé analýzy, to už záleží jen na analytikovi a zvolených postupech.
Dnešní lekce ukázala několik způsobů, jak detekovat a jak se vypořádat s chybějícími daty a odlehlými měřeními. Finální volba je vždy závislá na našich cílech. Jinak se budeme chovat k datům připravovaným pro explorativní analýzu a jinak k datům pro budoucí model strojového učení. Je také třeba být velmi opatrný s mazáním potencionálně užitečných dat či kalkulací s vymyšlenými daty.
Pokud máš po minulé lekci hotovou svou vlastní analýzu, můžeš do ní zkusit přidat dnes získané znalosti a ověřit, jak se to promítne do výsledků a závěrů.