Python – A pandas szépségei (2. rész)
Ez a bejegyzés az egy héttel ezelőtti Python – A pandas szépségei (1. rész) poszt folytatása. Túl sok felvezető / összefűző szöveget nem szeretnék ejteni, úgyhogy íme a folytatás:
Az előző részt ide kattintva éred el.
import csv, pandas as pd, numpy as np
pd.read_csv('f:/Datasets/teszt_01.csv',sep=';',quotechar='"',header=None\
,names=['from_node_id','to_node_id','length','ts'])\
.assign(timestamp_type=lambda x: pd.to_datetime(x['ts'],unit='s')\
.apply(lambda y: 1 if (4<y.dayofweek) else 0))\
.groupby('from_node_id',as_index=False).agg({'ts':np.size\
,'to_node_id':pd.Series.nunique,'timestamp_type':np.max})\
.rename(columns={'from_node_id':'node_id','to_node_id':'from_count_uniq'\
,'ts':'from_count_all','timestamp_type':'timestamp_type_from'})\
.join(pd.read_csv('f:/Datasets/teszt_01.csv',sep=';',quotechar='"',header=None\
,names=['from_node_id','to_node_id','length','ts'])\
.assign(timestamp_type=lambda x: pd.to_datetime(x['ts'],unit='s')\
.apply(lambda y: 1 if (4<y.dayofweek) else 0))\
.groupby('to_node_id')['timestamp_type'].agg({'to_count_all':np.size\
,'timestamp_type_to':np.max})\
.reset_index().rename(columns={'to_node_id':'node_id'})\
.set_index('node_id'),how='outer',on='node_id')\
.assign(timestamp_type=lambda x: pd.DataFrame({'a':x['timestamp_type_from']\
,'b':x['timestamp_type_to']}).max(1))\
.fillna(0).astype(long).sort('node_id')\
.reset_index(drop=True).reset_index().rename(columns={'index':'id_new'})\
[['node_id','from_count_all','to_count_all','from_count_uniq'\
,'timestamp_type','id_new']]\
.to_csv('f:/Datasets/teszt_01_output.csv',sep=';'\
,index=False,mode='w',header=1,quoting=csv.QUOTE_NONE,quotechar='"')
Érdekel az adatelemzés? Elsajátítanád a data science alapjait? Akkor a Dataskool Data science képzésünket neked találtuk ki!
Indulj el a data scientistté válás útján: tanuld meg a data science technikai alapjait valós üzleti problémák megoldása során és lesd el a legjobb gyakorlatokat tapasztalt profiktól.
A df.assign() és a df.apply() függvények
Ha már az előzőekben említésre került az ‘if-then-else’, fontos megemlíteni, hogy sajnos az .apply() függvényen belül csak ilyen egyszerű feltételrendszer alkalmazható, ‘elif’ ágak nem. Ez nem túl kellemes (az egysoros kódok szempontjából semmiképpen sem), de pl. ilyen esetben írhatunk teljesen egyedi függvényt, amiben olyan összetett kifejezésünk lehet, amilyet csak szeretnénk (pl. .apply(lambda x: my_func(x),axis=1)). Arra ügyeljünk, hogy a függvényünknek átadott ‘x’ változó a függvény bemeneti dataframe-ének egy-egy series objektuma (sor vagy oszlop), jelen esetben a dataframe egy-egy sora, így ha ennek egy konkrét elemét szeretnénk elérni a my_func(x) függvényben, akkor pontosan arra az elemre kell hivatkozni (pl. az x[0] jelöli az aktuális series nulladik elemét). Ami még szintén fontos, hogy nemcsak az ‘x’ típusa series, hanem a visszatérési értéknek is annak kell lennie. Kezdő pandas programozóként ez nem feltétlenül egyértelmű, pláne ha eleinte az .apply()-al csak 1-1 attribútumot, vagyis egyszerre csak egy értéket generálunk, mert ilyen esetben használhatjuk nyugodtan pl. az alábbi kódot: df[‘new_attr’]=df[[‘attr_1′,’attr_2’]].apply(lambda x: 1 if x[0]>10 else x[1],axis=1). Ha viszont egy lépésben több attribútumot szeretnénk legenerálni (egy vagy több, már létező attribútum felhasználásával), akkor mindenképpen series objektummal kell visszatérjen az .apply(). Adott esetben egy lista, mint visszatérési objektum is működhet még, de igencsak limitált és elsőre nem túl egyértelmű formában, kérdéses ugyanis, hogy a lista, mint egy darab objektum kerül-e be a dataframe egy cellájába, vagy a listaelemek egy-egy külön cellába kerülnek (ez a dataframe oszlopainak típusaitól függ), úgyhogy inkább ezt mellőzzük. Egy helyes, series példa: df[[‘new_attr_1′,’new_attr_2’]]=df[[‘attr_1′,’attr_2′]].apply(lambda x: pd.Series([1,’ok’]) if x[0]>10 else pd.Series([x[1],’fail’]),axis=1).
Ilyen esetekben azonban felmerül a kérdés, hogy oké, a visszatérési objektum egy series, de mi a benne lévő elemek típusa? Ez ugyanis nagyon fontos lehet a létrehozandó dataframe attribútumok típusait illetően. (Persze azokat utólag bármikor megváltoztathatjuk egy .astype(<adattípus>) függvénnyel.) Sajnos – illetve hát természetesen – egy series objektumnak csak egyféle, objektum szintű típusa lehet: ha pl. minden értéke szám, akkor ‘float’ típusú series objektumról beszélhetünk, de ha van benne legalább egy karaktersorozat, akkor ‘object’ típusúról. Az, hogy az .apply() függvényben visszatérő series objektumnak csak egyféle típusa lehet, teljesen triviális, ha az .apply()-al oszlopokat, mint visszatérési értékeket generálunk (axis=0), de kevésbé kényelmes, ha sorokat. Szerencsére azonban a pandas van annyira okos, hogy automatikusan elvégzi nekünk a típuskonverziót (kivéve amikor nem, lásd később). Tehát amikor az .apply() a dataframe minden sorára visszatér egy (‘object’ típusú) series objektummal, ami tartalmaz számokat és sztringeket egyaránt (mint a fenti példában egyet-egyet), akkor a dataframe-ben a csupa számot tartalmazó attribútum típusa a művelet végrehajtása után ‘float’ lesz, míg a legalább egy szöveges elemet tartalmazó attribútumé ‘object’.
Dátumok kezelése Python-ban
Ezen a ponton szükség van egy kis kitérőre, sajnos. Eddig ugyanis az időbélyegből a hétköznap-hétvége kinyerésekor nagyvonalúan figyelmen kívül hagytuk az időzónát, ami adott esetben nem kis hiba. És most adott eset van, ugyanis nekem közép-európai időzóna szerinti timestamp-jeim vannak. Vizsgáljuk meg tehát kicsit jobban a pd.to_datetime(x[‘ts’],unit=’s’) átalakítást. Ez a kódrészlet az időbélyegből dátumot készít, igen ám, de UTC időzóna szerintit. Ez nem is lenne túl nagy baj, azonban ennek a megváltoztatására nincs lehetőség, amit nagyon-nagyon nehezemre esik elhinni, de nem találtam rá módszert (ha valaki ismer egyet, nyugodtan írja meg kommentbe). Mit lehet tenni? Lokalizálhatjuk a dátumunkat, majd ráküldhetünk egy időzóna konverziót: .assign(timestamp_type=lambda x: pd.to_datetime(x[‘ts’], unit=’s’).dt.tz_localize(‘UTC’).dt.tz_convert(‘Europe/Budapest’).dt.dayofweek/5)\. Ez így nagyon szépnek tűnik, azonban a futási idő, amit kapunk, finoman szólva is förtelmes: a korábbi 2 millió soron kapott előző, két pd.read_csv()-s ill. két ‘timestamp_type’ átalakítást megvalósító 24-26 másodperc 270-320 sec-re nő, amire így hirtelen nehéz szavakat találni. Az csak külön poén, hogy a Python 2-ben megszokott egész osztás helyett itt bizony megkapjuk a törtrészt is :), tehát pl. a hét 3-dik napja esetén nem 0-át, hanem 0.6-ot, vagyis még egy int() konverzió is szükséges lenne, vagy inkább az egész osztás operátor (// művelet).
Itt megjegyzendő, hogy ha az átalakítást nem egy series objektummal, hanem csak egy timestamp-el végezzük, akkor a .tz_localize(‘UTC’) hívás el is hagyható, ha helyette használjuk a pd.to_datetime() függvényben az utc=True paramétert. Próbáljuk meg / térjünk vissza tehát az eredeti .apply() megoldásra, hátha olcsóbb elemszinten a pd.to_datetime(), mint series szinten a .dt.tz_localize(‘UTC’): .assign(timestamp_type=lambda x: x[‘ts’].apply( pd.to_datetime(y,unit=’s’, utc=True).tz_convert(‘Europe/Budapest’).dayofweek/5))\. Hát természetesen nem lett olcsóbb, sőt, már a 6-7 perces tartományba értem. Mit csináljunk akkor? Talán hagyjuk a fenébe a pandas datetime okosságát, és használjuk a különálló, Python core datetime modult. Az ebben lévő függvények sajnos series szinten nem hívhatók, csak elem szinten, így egy .apply()-ba kell beletenni a lényeget: .assign(timestamp_type=lambda x: x[‘ts’].apply(lambda y: dt.datetime.fromtimestamp(y).weekday()/5)\ (előtte persze be kell importálni a ‘datetime’ modult ‘dt’-ként). Most mindenki tippelhet egyet a futási időre (lesni nem ér 🙂 19-20 sec! Hoppá, valami nagyon nem jól működik a pandas-os dátum kezelésnél (persze a tévedés jogát azért én is fenntartom :), mert ez a (nem helyes) pandas-os 24-26 másodperctől is 20%-kal alacsonyabb érték, az .apply()-os pandas verzióhoz képest meg méginkább. Gyorsan fel is riadhatunk, hogy de itt nem foglalkoztunk sem az időbélyeg pontosságával (sec, millisec), sem az időzónával. Hát azért nem, mert egyrészt milliszekundumos időbélyeget nemigazán kezel a core Python datetime, másrészt viszont az időzónát automatikusan helyesen beállítja (ez persze igazából inkább hátrány). Az eredmény (2 millió soron) természetesen (szerencsére) ugyanaz a pandas-os és a datetime-os változatnál. És akkor ahogy korábban is, úgy most is tovább csökkenthető a futási idő újabb 3-4 másodperccel az egyszeri CSV beolvasásnak és ‘timestamp_type’ attribútum előállításnak köszönhetően. Ez így azért nem kis különbség az 5-6 perchez képest!
A datetime modul további előnyei a jelenlegi pandas-os társához képest például, hogy itt a dátumok formázott verzióban is kinyerhetők az .strftime(‘%<x>’) típusú függvényeknek köszönhetően (hogy ezt miért nem adoptálták a pandas-ba, nem értem). Így például megkapható egy dátum napjának a neve (pl. ‘Monday’ stb.). Itt meg is jegyezném, hogy ha az a feladatunk, hogy egy időbélyegből kinyerjük mondjuk a dátumot és a nap nevét, akkor bizony a korábban említett dolgok miatt picit bajban lehetünk. Az ugyanis nem fog működni, hogy egy .apply()-ban egyszer végigiterálunk a ‘ts’ attribútumon, és visszatérünk egy dátum és egy object típusú értékkel, ugyanis a visszatérési értéknek egy series objektumnak kell lennie, aminek az adattípusát ha nem definiáljuk külön, és egyik értéke egy dátum, akkor datetime lesz: df[[‘datetime’,’day_name’]]=df[‘ts’].apply(lambda x: pd.Series([dt.datetime.fromtimestamp(x),dt.datetime.fromtimestamp(x).strftime(‘%A’)])). Tehát akármilyen időbélyeg esetén, ha az adott nap pl. ‘Friday’, akkor a létrehozandó ‘day_name’ attribútumunkba nem a fenti sztring kerül, hanem az aktuális, a szkript futtatásának dátuma + 5 nap (nem 4). Tehát ebben az esetben vagy külön kell legenerálnunk a két attribútumot (mondjuk az .assign() függvénnyel amúgy sem lehet egy lépésben két attribútumot létrehozni), vagy be kell állítani a visszatérési series objektum adattípusát ‘object’-re: pd.Series([<adat>],dtype=object). És akkor ellenőrizzük le most a dataframe-ünk attribútumainak típusait (print df.dtypes), bár nagy meglepetés már nem érhet minket a korábban részletezettek miatt: a ‘datetime’ oszlop csodák csodájára ‘datetime64’ típusú lesz, míg a ‘day_name’ oszlop ‘object’, és nem dátumokat, hanem a napok neveit fogja tartalmazni. Ez nagyon szuper, de mi a helyzet, ha egy harmadik attribútumot is létre szeretnénk hozni, méghozzá a ‘date’-et, amiben tehát nincs benne a ‘time’ rész? Hát itt bizony elhasal az automatikus típuskonverzió, ugyanis akár a dt.datetime.fromtimestamp(x).date(), akár a dt.datetime.fromtimestamp(x).strftime(‘%Y-%m-%d’) kódot használjuk, a ‘date’ oszlop típusa ‘object’ lesz (de legalább az értéke dátum). Ha a visszatérési series objektumra nem állítjuk be az ‘object’ adattípust, akkor pedig, bár a ‘date’ oszlop ‘datetime64’ típusú lesz, de a ‘day_name’ viszont ‘object’ helyett a fenti hibával ‘datetime64’ (sőt, a .date() változat esetén a ‘date’ oszlop így is, úgy is ‘object’ lesz).
Hát, jól látható, hogy egyrészt a dátum típusok önmagukban megértek volna egy külön posztot, másrészt pedig soha semmit ne bízzunk a véletlenre, és mindig mindent ellenőrizzünk illetve teszteljünk le a legkülönfélébb bemenetekkel.
A df.groupby() és a dfgb.agg() függvények
És akkor térjünk vissza a megoldásunk elemzéséhez. A következő lépésben a .groupby() illetve az .agg() függvényekkel létrehozzuk a ‘from_node_id’ attribútum szerinti aggregátumokat, vagyis a ‘from_count_all’, a ‘from_count_uniq’ és a ‘timestamp_type_from’ oszlopokat. Ezeket az elnevezéseket a következő, .rename() függgvényben rögzítjük, ahol a csoportosítás alapját képező ‘from_node_id’ mezőt is átnevezzük ‘node_id’ névre, de hogy ez utóbbit megtehessük, előbb be kell állítani, hogy a csoportosítás során létrejövő új dataframe-nek ez az oszlop az alapértelmezéssel ellentétben ne az indexe legyen, hanem csak egy normál attribútuma. Ez megtehető egy .reset_index() függvénnyel is, de sokkal egyszerűbb a .groupby() belsejében az as_index=False beállítás használata. Természetesen akár több oszlop szerint is csoportosíthatunk, ha az adott feladat úgy kívánja. Az aggregálást illetően látható, hogy jelen pillanatban a metódus bemenete a teljes dataframe, nem pedig annak egy vagy csak néhány oszlopa. Néhány sorral lejjebb a másik groupby-nál (amikor az élek végpontjaira csoportosítunk) már csak a ‘timestamp_type’ oszlopra aggregálunk. A kettő közti nagyon fontos különbség az, hogy az utóbbinál a bemenet tehát már nem egy dataframe objektum, hanem “csak” egy series (ami majdnem olyan, mint egy egyoszlopos dataframe, de mégis más), így az .agg() függvény belsejében lévő szótárban, – ahol a kulcs-érték párok érték részei az aggregációt megvalósító függvények -, a kulcsok a kimeneti objektum oszlopnevei lesznek, hiszen nincs szükség a bemeneti oszlop direkt azonosítására, mivel csak egy van belőle. A szótár kulcsa(i) tehát, ha csak egy aggregátumot hozunk létre, akkor a létrejövő series objektum neve lesz (ez itt kicsit pontatlan megfogalmazás, lásd később), ha pedig több aggregátumot generálunk, akkor a létrejövő dataframe oszlopainak a nevei. Ennek megfelelően a második .groupby() illetve az azt követő .agg() függvény után nincs szükség .rename() függvényre. Ha viszont az .agg() függvény bemenete egy dataframe (vagy a teljes, vagy annak néhány oszlopa), akkor a függvény belsejében lévő szótár kulcsai láthatóan nem a kimeneti, hanem a bemeneti oszlopok nevei, hiszen ebben az esetben szükség van azok egyértelmű azonosítására, hogy tudjuk, mikor melyik oszlopon aggregálunk. A dataframe-en való aggregációkor a kimeneti adattábla oszlopainak nevét nincs lehetőség megadni, így azok megegyeznek a bemeneti attribútumok neveivel.
A szótár elemeinek értékek részében adjuk meg tehát az adott aggregátum létrehozásához felhasználandó numpy vagy pandas függvények neveit: a ‘ts’:np.size kulcs-érték párral minden él kezdőpontjához (‘from_node_id’) megszámoljuk a végpontokat, vagyis összegezzük azok szomszédos pontjait. Ezzel megkapjuk a kezdőpontokhoz a belőlük kimenő élek darabszámát. Mivel egy-egy élet a bemeneti CSV egy-egy sora reprezentál, így a ‘ts’ attribútumon kívül bármelyik másik oszlopot is használhattuk volna kulcsként (kivéve természetesen a csoportosítás alapját képező ‘from_node_id’-t). Ekkor persze nemcsak a szótár ezen kulcsát kellene módosítanunk, hanem az értékeket is. Azért a többes szám, mert ha azonos attribútumon szeretnénk különböző aggregációkat végrehajtani, akkor elvileg több ilyen nevű kulcsunk, így több értékünk lenne. Előbbi természetesen nem megengedett, hiszen egy szótárban minden kulcsnak egyedinek kell lennie. De egy kulcshoz nemcsak egy darab érték tartozhat, hanem több is, például egy listába (vagy tuple-be) gyűjtve. Magyarán, ha pl. a ‘to_node_id’ attribútumon szeretnénk ezt és a következő aggregációt is végrehajtani, akkor az alábbi kódra lesz szükségünk: .groupby(‘from_node_id’,as_index=False).agg({‘to_node_id’:[np.size, pd.Series.nunique],’timestamp_type’:np.max})\. Ami még fontos, hogy ilyen esetekben az oszlopok automatikusan hierarchikus indexelést (elnevezést) kapnak, hiszen (elvileg) nem lehet(ne) két azonos nevű attribútum egy dataframe-ben (gyakorlatilag igen). Így tehát a .rename() függvényt is módosítanunk kell, ami viszont többszintű fejlécnél olyannyira nem egyértelmű, hogy majd csak később részletezem azt (sajnos itt is akad még némi hiányossága a pandas-nak (vagy nekem :)).
Visszatérve a feladathoz, a ‘to_node_id’:pd.Series.nunique kulcs-értékpárral a kezdőpontokhoz tartozó egyedi végpontokat számoljuk össze (az ‘n’ az ‘nunique’-ban a number-t jelöli), a ‘timestamp_type’:np.max kulcs-érték párral pedig minden kezdő csomóponthoz meghatározzuk a rá illeszkedő élek közül a maximális hétköznap-hétvége azonosítószámmal rendelkezőt: ‘1’ a hétvégén létrejövő él, és a feladat kiírása szerint azt keressük, hogy található-e (legalább egy) hétvégén létrejövő él a csomópontra illeszkedő kimenő élek között.
Megjegyzendő, hogy amennyiben csak egy aggregáló függvényt szeretnénk alkalmazni, úgy az .agg() függvény el is hagyható, és helyette használható közvetlenül az adott aggregációt megvalósító függvény. Pl. a .groupby(‘from_node_id’,as_index=False).size() kódsorral a groupby-al létrejövő csoportok darabszáma kapható meg. Fontos, hogy ebben az esetben, mivel csak egy egy-aggregátumos függvényt használunk, így “csak” egy series objektum keletkezik, nem pedig egy egyoszlopos dataframe (és akkor itt a pontosítás a fenti elnagyolásra), és a series-re vonatkozóan az as_index=False beállítás hatástalan (értelmetlen). Helyette, ha szeretnénk a series ‘from_node_id’ indexéből normál attribútumot kapni, a .reset_index() függvény használandó, a series objektumra, vagyis a .size() utáni részre (előtte ugyanis még csak egy DataFrameGroupBy objektumunk van, nem pedig series). Természetesen ekkor a series objektumból dataframe objektum keletkezik, ami már tárolni tudja az eredeti oszlopot és a series indexéből létrehozott oszlopot is, illetve az új indexet (tehát a .reset_index() függvény képes típuskonverzióra, míg az as_index=False beállítás sajnos nem). Ha a használt aggregáló függvény nem a csoportosítás alapjául szolgáló attribútumra, hanem az aggregálható oszlopokra vonatkozik, mint pl. a .max() függvény, – illetve eleve nem egy egy-aggregátumos függvény -, akkor természetesen ezzel az egy darab aggregációval is egy dataframe lesz a kimenet, vagyis minden bemeneti oszlopra meghatározzuk a maximális értéket csoportonként (.groupby(‘from_node_id’,as_index=False).max()), és így már használható (hatásos) az as_index=False opció. Fontos hangsúlyozni, hogy ez nemcsak akkor igaz, ha a bemenet egy dataframe, hanem akkor is, ha annak csak egy oszlopa, vagyis csak egy series az input, és ennek megfelelően a kimenet is csak egy darab oszlop lesz, de ebben az esetben ez az egy oszlop nem egy series objektum, hanem egy dataframe (!!!). Tehát ezt a különbséget, hogy mi a kimeneti objektum típusa, csak az aggregáló függvény befolyásolja, a bemeneti oszlopok vagy a létrehozandó aggregátumok darabszáma nem.
Láthatóan a .groupby() és az .agg() függvények alapos ismerete és begyakorlása szintén egy kiemelten fontos dolog mind a Python mind pedig az adatelemzés világában.
Szünet 2
Ezen a ponton szükséges egy újabb szünet, mert a második rész is hosszú lenne, ha mindent ide írnék. A folytatás szintén pár nap múlva érkezik!