Python - A pandas szépségei (3. rész) - Dmlab

Python – A pandas szépségei (3. rész)

Ragány Csaba

2015.07.29. • olvasási idő:

Ez a bejegyzés az egy héttel ezelőtti Python – A pandas szépségei (2. rész) poszt folytatása illetve egyben a sorozat lezárása. Túl sok felvezető szöveget itt sem szeretnék, úgyhogy íme a befejező epizód.

Az első részt ide, a második részt pedig ide kattintva éred el.

A dataframe oszlopai és indexei

A már többször is említett .reset_index() függvénnyel kapcsolatban megjegyzendő, hogy egyrészt ha nem az aktuális index normál attribútummá konvertálása a cél, hanem új indexek (sorszámozás) generálása, akkor használhatjuk a függvény belsejében a drop=True opciót, így automatikusan törlődik az aktuális index, vagyis nem kerül be a dataframe oszlopai közé. Másrészt pedig célszerűen (kötelezően-kényszerűen) a .reset_index() függvényt mindig követi egy .rename() függvény, ahol a létrejövő ‘index’ elnevezésű oszlopot átnevezzük valami értelmesebbre. (Kényelmesebb lenne, ha a .reset_index() függvény paramétereként megadható lenne a létrejövő oszlop neve, de egyelőre ez nem lehetséges.) Ha ezt nem tesszük meg, akkor egyrészt a következő .reset_index() hívás által létrejövő oszlop neve ‘level_0’ (illetve ‘level_1’ stb.) lesz, ami zavaró lehet, másrészt viszont az ‘index’ szó az egy foglalt név, így később bonyodalmakat okozhat. Ugyanez a helyzet pl. a ‘from’ kifejezéssel is, ezért is használtuk a bemeneti CSV beolvasásakor az első oszlop nevénél a ‘from_node_id’ elnevezést a sima ‘from’ helyett. És mikor okozhatnak ezek gondot? Fentebb már említettem, hogy egy dataframe esetében új oszlop létrehozása megoldható egy df[‘new_attr’]=<valami> típusú kódsorral, aminek egy ekvivalens alakja a df.new_attr=<valami> kódsor. Magyarán a dataframe oszlopai, mint ahogy állandóan hivatkozunk is rájuk, elérhetők az objektum attribútumaiként, már amennyiben normális, szóköz és egyéb sallang karakterek nélküli oszlopneveink vannak. No és mi a helyzet, ha van egy ‘index’ nevű oszlopunk, mit fog adni a print df.index sor? Hát természetesen nem az ‘index’ oszlop tartalmát, hanem a dataframe (sor) indexének a tartalmát. Hierarchikus indexeknél a szintek egymást követve adják a kívánt oszlopot, vagyis a df.level_0_attr_1.level_1_attr_1 kóddal érhető el egy attribútum, de sajnos itt nem működik az új oszlopok ilyen formában való létrehozása (ez azért elég “fura”). Tehát többszintű oszlopindexek esetén marad a hagyományos mód új attribútum generálására: df[(‘level_0_new_attr_1′,’level_1_new_attr_1’)]=<valami>.

Egyébként, ha szükségünk lenne egy dataframe indexének vagy oszlopneveinek az értékeire, az alábbi kódokkal megkaphatjuk azokat egy-egy listában: df.index.values ill. df.columns.values. Ha magára az index objektumokra van szükségünk, akkor a korábban már említett df.index és df.columns kódsorokat használjuk. A megfogalmazás nem véletlen, egy dataframe objektum ugyanis egy-egy sor- illetve oszlopindex objektummal rendelkezik, melyek típusa alapesetben, vagyis egyszintű indexek esetén ‘Index’ (illetve ennek módozatai numerikus vagy dátum típusú indexek esetén: ‘Int64Index’ ill. ‘DatateimIndex’). Többszintű indexek vagy oszlopnevek jelenlétekor a df.index.values vagy df.columns.values kódsorokkal visszakapott lista tuple-ket tartalmaz, ahol egy-egy n-es elemszáma az adott index hierarchia szintje szerint alakul, magának az index objektumoknak a típusa pedig már ‘MultiIndex’. Ha esetleg egyszintű fejlécek helyett kétszintűeket szeretnénk egy dataframe-ben, akkor az alábbi kóddal alakíthatjuk át pl. az oszlopindexeket: df.columns=pd.MultiIndex.from_tuples(multi_index), ahol a ‘multi_index’ változó egy pontosan annyi elemet tartalmazó lista, mint ahány oszlopa a dataframe-nek van, és minden listaelem egy tuple, pl. (‘level_0_attr_1′,’level_1_attr_1’). Vegyük észre, hogy itt nem a dataframe objektumot, hanem annak oszlopindex objektumát módosítottuk. Ugyanez az átalakítás a .rename() függvénnyel is működik, a dataframe objektumra (!): df.rename(columns={‘attr_1’:(‘level_0_attr_1′,’level_1_attr_1’)}, inplace=True).

És ezen a ponton visszatérnék a korábbi elvarratlan szállra, vagyis arra, hogy mi a helyzet akkor, amikor a .groupby() illetve .agg() metódusnál egy attribútumra több aggregációt is végrehajtunk: ekkor sajnos többszintű indexelést kap a kimeneti dataframe, és ennek paraméterben való módosítására illetve beállítására (jelenleg) nincs lehetőség (pl. a .join() esetén megadhatók eltérő oszlopnév-végződések az azonos oszlopnevekre, miáltal egyszintű fejlécekben is megférnek az összekapcsolt dataframe-ek oszlopai). A még nagyobb probléma, hogy a .rename() függvény sem képes elvégezni a visszafelé konverziót, vagyis míg ‘Index’ típusú oszlopindexről működik az átnevezés ‘MultiIndex’ típusúra, fordítva már nem. A  df.rename(columns={(‘level_0_attr_1′,’level_1_attr_1′):’attr_1’},inplace=True) kódsorra ugyan hibaüzenetet nem kapunk, ellenben nem is történik semmi változás a fejléceket illetően. A kívánt átnevezéshez tehát már nem elegendő a dataframe elérése illetve módosítása, hanem közvetlenül az oszlopindex objektumnak a megváltoztatása szükséges. Pl. a df.columns=df.columns.droplevel(1) kóddal az első szintű fejléc törölhető a dataframe-ből, miáltal kétszintű indexek esetén a fejléc típusa újra ‘Index’ típusú lesz (pl. három szintű indexeknél természetesen még nem), és így már alkalmazható a szokásos .rename() függvény. Persze vigyázni kell a hierarchia szint törlésével keletkező azonos nevű oszlopokkal, hiszen átnevezéskor az azonos nevű attribútumok mindegyike ugyanazon új értéket kapja. Ilyen esetben célszerű még a hierarchia szint törlése előtt egyedi névvel ellátni az oszlopokat, minden szinten.

Megjegyzendő még, hogy egy dataframe oszlopainak átnevezése az alábbi kóddal is megoldható: df.columns=[új oszlopnevek listája]. Itt fontos, hogy a lista elemszáma megegyezzen az oszlopok számával. Ha az oszlopok sorrendjét akarjuk megváltoztatni, akkor nem az oszlopindex objektumot, hanem a dataframe-et kell módosítanunk, szintén egy lista segítségével: df=df[új oszlopsorrend lista]. Ennek a listának az elemszáma már lehet kevesebb, mint a dataframe oszlopainak a száma, de több nem, illetve csak olyan értékeket tartalmazhat, mint ami az oszlopok neve. Így akár el is hagyhatók bizonyos attribútumok, melyek neve nem szerepel ebben a listában. Ugyanez, vagyis a fentiek egésze igaz az indexekre is, tehát átnevezhetők a sorindexek a df.index=[új sorindexek listája] kódsorral, de a df.rename(index={‘0. sor régi névű’:’0. sor új név’,…}) utasítással is. Ha csak egy vagy néhány sort szeretnénk átnevezni, akkor a .rename(index={}) szótárában csak ezek az elemek szerepeljenek, vagy a df.index=[sorindexek listája] utasítás listájában az át nem nevezendő sorokra a régi értékeket adjuk meg. Ez utóbbi módszer azért nem túl kényelmes, mert a lista elemszáma itt is meg kell egyezzen a sorok számával, így az összes régi nevű indexet is bele kell venni, míg a .rename(index={}) függvénynél elég csak az átnevezendő sor(oka)t megjelöli. Persze a régi sorindexek listája egy kódsorral megkapható, és ezen lista megfelelő értéke(i) szintén egy kódsorral módosítható(k).

Ami még hátramaradt az átnevezéseket illetően az, hogy hogyan nevezzük el/át magát a sor- vagy oszlopindex objektumot. Íme: df.index.names=’sorindex_neve’ ill. df.columns.names=’oszlopindex_neve’. Szerencsére nem gond, ha előtte még nem volt neve ezeknek az objektumoknak. Ha ezután vesszük magát a sor- vagy oszlopindex objektumot, akkor ez már rendelkezni fog a megadott névvel (pl. Index([<oszlopok nevei>], dtype=’object’, names=<oszlopindex neve>)).

A dataframe illetve series objektumok indexelése is az alapvető elsajátítandó dolgok közé tartozik pandas-ban.

É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.

Dataskool – Data science képzés

További teendők

És térjünk vissza újra a megoldásunk értelmezéséhez. A .join() függvénynél járunk, ami a .merge() egy kényelmesebben és egyben korlátoltabban használható változata. Itt túl sok varázslat nincs a kódban, így nem részletezném a működést. Annyit érdemes megjegyezni a .join()-ról, hogy az összeillesztés kulcsának a második dataframe esetén kötelezően indexnek kell lennie, ezért itt szerepel egy .set_index(‘node_id’) hívás is. Az eredeti feladat egysoros megoldásánál a .join() belsejében olvassuk be újra a bemeneti CSV adathalmazt, és az így létrejövő új (másik) dataframe-ben ugyanúgy létrehozzuk a ‘timestamp_type’ attribútumot, mint az első beolvasáskor, elvégezzük a már kivesézett aggregációt (az élek végpontjaira csoportosítva), illetve átnevezzük a ‘to_node_id’-t ‘node_id’-ra, hogy megtörténhessen az összekapcsolás. A szemfülesek fel is kaphatják a fejüket (illetve már az első  .groupby() ill. .agg() utáni .rename() függvénynél is tehették ezt), hogy miért van erre az átnevezésre szükség, ugyanis ahogy korábban már említettem, a CSV beolvasásakor is megadható lett volna a kívánt ‘node_id’ oszlopnév (első beolvasáskor a ‘from_node_id’ helyett, a mostani beolvasáskor pedig a ‘to_node_id’ helyett). De így talán jobban rögzül…

Az adattáblák összekapcsolása után létrejövő dataframe tehát a ‘node_id’ oszlop soraiban tartalmazza a normál csomópontokon kívül a forrásokat és nyelőket is, (melyeknek nincs bemenő vagy kimenő élük), valamint egy-egy oszlopban a kiszámított aggregátumokat. Egy (plusz még egy) dolog maradt már csak hátra, mégpedig hogy meghatározzuk a végleges ‘timestamp_type’ értékeket a ‘timestamp_type_from’ és ‘timestamp_type_to’ értékek segítségével. Előbbi jelöli a csomópontból kimenő élek alapján a hétköznap-hétvége kategória számot, utóbbi pedig ugyanezt a bemenő élek szerint. A végleges érték 1-es, ha volt legalább egy darab hétvégén létrejövő él a be- illetve kimenő élek között, így a két oszlop és egy .assign() függvény használatával elvégezhető a feladat: egy lambda függvény ‘x’ dataframe-ének fenti két oszlopából létrehozunk egy ideiglenes dataframe-et, amire alkalmazható a .max(1) függvény, ami visszaad egy series objektumot, ami soronként a nagyobb (maximális) értékeket tartalmazza. A maximális értékek sorszintű kinyerését az ‘1’-es paraméterrel állítottuk be a .max() függvényben (a ‘0’ értékkel oszlopszintű végrehajtás történik). Természetesen itt is alkalmazhatnánk egy .apply() függvényt (mint ahogy előzőleg kétszer is a ‘timestamp_type’ előállításánál), amiben sorszinten meghívnánk a lambda y: max(y[0],y[1]) függvényt, de mint korábban már látható volt, az első megoldás jóval hatékonyabb. (A működési mechanizmust illetően itt tehát teljesen hasonló a helyzet, mint fentebb a dayofweek esetén.) Az y[<x>] kóddal hivatkozhatunk a lambda függvénynek átadott ‘y’ series nulladik, első stb. elemére, amennyiben sorszintű érvényességgel bír az .apply(), vagyis az axis=1 van beállítva (df.apply(lambda y: max(y[0],y[1]),axis=1)).

Dataframe attribútumainak adattípusai

Ezután következik egy “csinosítónak” tűnő, de valójában kényszerűen szükséges lépés: az összekapcsolás során létrejövő NULL értékeket a 0 értékkel helyettesítjük, amivel kapcsolatban megjegyzendő, hogy ha a kimenetben egész számokat szeretnénk, – és mi azokat szeretnénk, lévén csomópont azonosítókról és időbélyegekről beszélünk -, akkor alkalmazni kell egy .astype(long) függvényt, ami “nemigazán kezeli jól” a NULL értékeket. Egész konkrétan itt (is) bizony elhasal a Python (2.x), így ez elé minden esetben tennünk kell egy .fillna(0) függvényt. Elsőre picit furának tűnhet, hogy a ‘long’ karaktersorozatot kell alkalmaznunk annak ellenére, hogy a Python ‘int64’-ként jelöli az így létrejövő dataframe oszloptípusokat. Ez a Python-pandas “típuskonverzió” (megfeleltetés) miatt van. Az ‘int64’ (pandas) sztring tehát nem lesz jó az .astype() belsejében, de ami még kellemetlenebb, hogy Python 3-ban a ‘long’ sem 🙂 Ez azért van, mert a 3-as Python-ban már csak long típusú ‘int’ létezik, viszont ez az ‘int’ kulcsszó alatt szerepel. Tehát az ‘int’ nyugodtan használható az .astype() függvényben Python 3 esetén. Ekkor a dataframe-ünk oszlopaira Python 3-ban az ‘int32’ pandas típust kapjuk vissza, ami valójában long Python típust jelent. Itt nem valami jó a Python-pandas típusmegfeleltetés, mármint ami a jelölést illeti, mert maga a konkrét típus az természetesen long az ‘int32’ jelölés ellenére. Ha mégis ragaszkodunk az ‘int64’ típusjelöléshez, akkor használhatjuk a numpy típusokat a konverziónál: df[‘attr_1’].astype(np.int64). Ekkor már Python 3-ban is ‘int64’ lesz a pandas-os típusjelölés. A méret mindkét esetben (core Python illetve numpy) természetesen ugyanaz: [-9223372036854775808;+9223372036854775807].

A CSV beolvasásának taglalásakor kimaradt ugyan, de a kitartó (visszatérő) olvasók most megtudhatják, hogy a pd.read_csv() függvénynél van lehetőség megadni a beolvasott adatok típusait is a dtype=<adattípus> paraméterrel. Ilyet mi nem adtunk meg, de ilyen esetben szerencsére a pandas helyesen fogja beolvasni az adatokat (az időbélyeg mindenképpen ‘long’ típusú lesz, de adott esetben a csomópont azonosítók is). A ‘long’ adattípusok pedig a csoportosítások során “alakultak át” ‘float’-okká, amiket tehát most alakítottunk vissza ‘long’-á. (A pandas elsőszámú, alapértelmezett numerikus típusa a ‘float’.)

Egy dataframe oszlopainak adattípusait a print df.dtypes sorral írathatjuk ki, egy objektum (pl. dataframe) típusát pedig a print type(df) sorral. Ide kapcsolódik még, hogy ha inicializálunk egy üres dataframe-et (df=pd.DataFrame()), melyhez láthatóan nem definiáltunk oszlopokat, akkor a nem létező oszlopok típusai sem léteznek, így ha pl. egy for ciklusban előállított eredményeinket szeretnénk egy ilyen üres dataframe-be iteratívan összegyűjteni (df=df.append(temp_df)), akkor a kimenet attribútumai az iterációnkénti eredménytáblákban lévő oszlopok típusaival azonosak lesznek. Azonban, ha egy dataframe inicializálásakor definiálunk oszlopokat is (df=pd.DataFrame(columns=[‘attr_1′,’attr_2’])), akkor azok típusa alapértelmezetten ‘object’ lesz, de itt is megadható pl. a dtype=float paraméter. A ‘long’ illetve Python 3-ban az ‘int’ itt sajnos nem fog működni, helyettük ‘object’ típust kapnak az oszlopok (ez sem túl “kellemes”…). Ilyen esetekben az iterációnkénti eredménytáblák ezen inicializált dataframe-hez való hozzáfűzésékor típuskonverzió történhet a “fő” dataframe típusaira, vagyis adott esetben a ‘temp_df’ ‘float’ (vagy ‘long’) típusú oszlopai ‘object’ (vagy ‘float’) típusúvá változhatnak. Erre mindig figyeljünk oda, és ha szükséges, alakítsuk vissza az attribútumokat a kívánt adattípusra.

Ahogy az itt is illetve az egész posztban (előző bejegyzésekben) látható, hogy mind Python-ban, mind pedig pandas-ban az objektumok illetve attribútumok típusainak megfelelő ismerete és kezelése szintén egy alapvető fontosságú dolog a szkriptek írása közben.

A még hátralévő teendők

A feladathoz ismételten visszatérve, az adattípusok beállítása után egy sima rendezés történik, majd pedig legyártjuk a csomópontokhoz az új azonosítószámokat. Ehhez semmilyen extra algoritmust nem használunk, csak simán az eredeti csomópont-azonosítón (‘node_id’) sorba rendezett adathalmazra küldünk két darab .reset_index() függvényt. Az elsővel eltávolítjuk a jelenlegi kesze-kusza, .groupby(), .join() illetve .sort() után létrejövő indexet, utóbbival pedig a már sorrendhelyes dataframe indexből generálunk egy ‘index’ nevű oszlopot, amit gyorsan át is nevezünk pl. ‘id_new’ névre, így meglesznek az eddig még hiányzó új azonosítószámaink a csomópontokhoz (miáltal új élek illetve csúcsok felvételénél nem ütközhetünk duplikált azonosítókba). Fontos, hogy egy .sort() után a legtöbb esetben mindig kell egy .reset_index() (értelem szerűen).

Végezetül pedig, mivel nem egy-két átalakítást hajtottunk végre ebben az egy darab kódsorban, így betesszük a kimeneti adathalmazban helyet foglaló attribútumok nevét egy listába (ahogy erről fentebb szó esett). Ezzel egyrészt egyértelműen láthatjuk, kvázi egyfajta interfészként, hogy mi a kimenet (mert ilyen kódokat inkább függvényként hívogatunk, mintsem csak úgy közvetlenül), másrészt pedig figyelmen kívül hagyjuk vele a nem kívánt oszlopokat (jelen esetben a ‘timestamp_type_from’ és ‘timestamp_type_to’ attribútumokat). Ebben az esetben tehát fizikailag nem töröljük a fenti két oszlopot, hanem csak nem “engedjük továbbhaladni”. Egyébiránt attribútumot törölni többféleképpen is lehet, az alábbi sorokkal: del df[‘attr_2’], df=df.drop(‘attr_2’,1) vagy df.drop(‘attr_2’,1,inplace=True) (az ‘1’-es itt is az oszlopokra utal). Utolsó lépésben pedig jöhet az adathalmaz kiírása egy CSV fájlba.

Zárszó

Hát ennyi lenne “egy sor” Python kód magyarázata “picit” részletesebben. Az első poszt bevezetőjében ígértem futási időket is, melyekből csak minimálisat illesztettem be, szóval a részletesebb infók a következő posztra maradnak, ott úgyis izgalmasabb lesz a multiprocessing miatt. A poszt egyébként is ultra-maratoni hosszúságú lett még így feldarabolva is (valahogy mindig mindegyik bejegyzés hosszabbra sikeredik az eggyel előzőtől), és hát akadt volna még a tarsolyban egy-két, a fentiekhez hasonló “nagyon fontos” dolog (pl. az adattábla pivotálás: df.pivot_table()) illetve érdekesség a pandas-t illetően. Ezekről talán majd legközelebb, addig is mindenkinek jó szórakozást illetve gyakorlást, és köszi a kitartást az olvasás során!