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

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

Ragány Csaba

2015.07.15. • olvasási idő:

Megint eltelt pár hónap, mire megszületett ez a bejegyzés, s közben több dolog is történt a Python-t illetően.

Egyrészt lezárult a KDNuggets idei éves szoftverhasználati felmérése, melyen a Python úgy lett a negyedik helyezett, hogy az előtte végző 3 másik eszközzel szemben – immár második éve – jelentősen magasabb növekedést ért el az előző évi saját eredményéhez képest, mint a többiek, illetve csupán bő 1%-nyi (33 db) szavazattal maradt csak le az R mögötti második helyezésről, szóval tényleg érdemes megtanulni a két nyelv közül legalább az egyiket. Másrészt sorra érkeznek a Python illetve az egyes Python csomagok újabb verziói: bő másfél hónapja publikálták a 2.7.10-es verziót, de egyre érik a végleges 3.5.0 Python is a jelenlegi 3.4.3 után, a csomagokat illetően pedig pl. a pandas 0.16.2 is egy hónapja jelent meg és július végén érkezik a 0.17.0-ás verzió. Illetve időközben kijött az Anaconda 2.3.0-ás verziója is, ami már tartalmazza többek között ezeket a frissítéseket is. Harmadrészt pedig itt a blogon az előző, Python bevezetőről szóló poszt a leglátogatottabbak egyike lett, ami külön öröm számomra, úgyhogy szakítottam végre egy “kis” időt, hogy a pandas rejtelmeiről is írjak ezt-azt. Remélem a Python és a pandas alapjaival már mindenki tisztában van (de ha még nem, akkor itt a remek alkalom), így egy nagyobbat ugorva a fókuszt egyből azokra a pandas-os apró, elsőre nem triviális működési mechanizmusokra, lehetőségekre, érdekességekre és adott esetben korlátokra illetve hiányosságokra helyezem, melyekről máshol csak elvétve írnak, pedig kezdő Python adatelemzőként könnyen belefuthat hasonlókba az ember. Hogy a történet kicsit izgalmas is legyen, kitaláltam egy jó fél oldalas feladatot a mondanivaló köré, így ez a poszt sem lesz valami rövid (sőt-sőt!!!). Ha kíváncsi vagy, hogy hogyan és milyen futási idő mellett lehet Python-ban ilyen terjedelmű problémákat akár 1, azaz egy darab (!) kódsorral megoldani, olvass tovább!

É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

A feladat

Tegyük fel, hogy van egy pár százmillió sort tartalmazó adathalmazunk, ami nem más, mint egy éllistával megadott gráf, vagyis két oszlopa, a ‘from’ és ‘to’ a gráf numerikus azonosítókkal bíró csomópontjait tartalmazza. Legyen elérhető minden sorhoz egy időbélyeg is, mely az adott él létrejöttét dokumentálja. A bemeneti adatok egy CSV formátumú fájlban vannak, melynek mérete nagyságrendileg 10 GB (egy-egy sor elfér kb. 20-40 bájtban). A továbbiakban az adathalmazra nem mint gráfra, hanem mint egy általános adattáblára gondolunk, tehát nem speciális gráfalgoritmusokat fogunk futtatni rajta, hanem egyszerű adatmanipulációs lépéseket végrehajtani (pl. új attribútumok létrehozása, groupby, join stb.). Akinek nincs a tarsolyában egy ilyen gráf, pl. innen letölthet egyet (én más adathalmazzal dolgoztam) vagy akár generálni is lehet egy random adattáblát (illetve a letöltött táblákhoz az időbélyeget).

Első körben szeretnénk minden csomóponthoz megkapni a ki- és bemenő élek számát, az egyedi szomszédok darabszámát a kimenő élekre vonatkozóan (párhuzamos élek jócskán előfordulhatnak), illetve szeretnénk a csomópontokhoz egy kategória számot rendelni a rá illeszkedő (vagyis a ki- és bemenő) élek keletkezési ideje alapján aszerint, hogy található-e köztük hétvégén létrejövő él vagy sem. A kimeneti állomány tartalmazzon tehát egy ‘node_id’, egy ’from_count_all’, egy ’to_count_all’, egy ’from_count_uniq’ és egy ‘timestamp_type’ attribútumot. Tegyük fel, hogy a csomópontok eredeti azonosítószámának keletkezési mechanizmusa számunkra ismeretlen, így ha a későbbiekben mi szeretnénk újabb éleket illetve csomópontokat felvenni az adattáblába (és miért ne szeretnénk ;-), akkor szükségünk lesz egyedi, még használatban nem lévő azonosítószámokra. Ehhez a legegyszerűbb, ha új, sorfolytonos azonosítószámokat generálunk a már meglévő adathalmazra, így újabb csomópontok felvételekor az aktuális maximális azonosítószám alapján generálhatjuk az újonnan felvett pontok azonosítószámait, elkerülve ezzel a duplikációk lehetőségét. Végül az eredményt mentsük el a bemeneti állománnyal egyező formátumban, ugyanabba a mappába.

Nos, a fenti feladatleírás nagyjából meg is lett egy fél oldal, és most tegyük fel, hogy van egy elegendően sok, mondjuk minimum 16 GB memóriával rendelkező számítógépünk, amiben kényelmesen elfér a bemeneti adat. Az adathalmaz darabonkénti (chunk) és multiprocessing feldolgozásáról majd egy következő bejegyzésben írok (itt a kód már nem egy sor lesz). Természetesen a program írásakor ne a teljes adathalmazzal dolgozzunk, hanem annak csak egy kis részével illetve akkor se, ha nincs kéznél ennyi RAM. Egyébiránt minden Python (illetve más) kód írásakor ajánlott oprendszer szinten a Python alkalmazás CPU és IO (!) prioritásának legalacsonyabb szintekre állítása arra az esetre, ha elnéznénk valamit 😉

Az egy soros megoldás

És most pedig a legjobb az lenne, ha ezen a ponton legalább a kezdők abbahagynák az olvasást (én meg az írást), és elővennék kedvenc Python (vagy R vagy bármi egyéb) fejlesztőeszközüket, és önállóan oldanák meg a feladatot. De mivel elég ritkán posztolok, így nem szeretném, ha hetekig kellene várni a beharangozott egy (plusz egy) soros megoldásra, úgyhogy íme:

                                            
                                                
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='"')

Huhh, ez tényleg csak két sor? A válasz természetesen “nem”, mert van egy üres sor az import után. Azután pedig számtalan fizikai sor, melyek egy kódsort alkotnak a ‘\’ sortörő karakternek köszönhetően. Első ránézésre nem kizárt, hogy nehezen olvashatónak tűnik a kód, pláne, hogy a blog.hu miatt sokkal jobban meg kellett tördelni a sorokat, mint ahogy szerettem volna: a 6, 8, 10, 14, 16, 18, 20, 24-es sorok törése nem volt betervezve, így célszerű őket visszafűzni az előttük lévő sorokra. Hogy érthető legyen a kód illetve a működés, rögtön jön is a nagyon részletes magyarázat (kezdőknek hajrá!). Ha igazán Python stílusú (ún. pythonic) kódot szeretnénk írni, akkor ezt az oldalt érdemes alaposabban átböngészni.

Ja igen, ezen a ponton újra érdemes lenne abbahagyni az olvasást (a poszt amúgy sem egy reggeli kávé hosszúságú), hogy mindenki maga vegye észre a fenti kódban megbúvó apróbb, optimalizációs (vagy bármi egyéb) szempontból kérdéses részeket. Az sem kizárt, hogy olyan elemek is akadnak benne, melyekről nem írok; – ezek nyugodtan jöhetnek kommentbe! (Én sem vagyok “orákulum”, és egyrészt mindig tanul az ember valami újat, másrészt el is nézhetek dolgokat.)

A magyarázat

És akkor értelmezzük, hogy mi is történik odafent. Nulladik lépésben beimportáljuk a pandas és a numpy csomagokat (a csv modulra igazából nincs nagy szükség, csak hát a szokás hatalma…). Ezután a pd.read_csv() függvénnyel beolvassuk a bemeneti CSV adathalmazt, – kétszer is. Tehát az egysoros kódunk kapásból nem nevezhető hatékonynak, úgyhogy egy végleges változatban semmiképpen se így oldjuk meg a feladatot, hanem helyette egy pandas dataframe-be egyszer olvassuk be a CSV-t, amin szintén csak egyszer végezzük el a ‘timtestamp_type’ oszlop létrehozását. A CSV beolvasásakor megadhatjuk az oszlopok neveit, ami főként akkor hasznos, ha nincs fejlécünk (header=None), vagy ha a beolvasást követő első lépésünk egy oszlopátnevezés lenne (.rename(columns={})). Számos egyéb opció is megadható, pl. a chunksize=<x>, amivel ‘x’ méretű (sorszámú) blokkonként olvasható be a CSV, ha az túl nagy lenne, és nem férne el a memóriában (erről remélhetőleg majd legközelebb írok).

Az df.assign() és a df.apply() függvények

A CSV beolvasása utáni következő lépés a már említett ‘timtestamp_type’ attribútum létrehozása az .assign() függvénnyel. Ez a 0.16.0-s verziójú pandas-tól érhető el (nekem egyik nagy kedvenc újításom), és ugyanúgy új attribútumokat hozhatunk vele létre, mint a df[‘new_attr’]=<valami> típusú kódsorral. Itt az első lambda függvénnyel (ezzel a kulcsszóval hozunk létre inplace anonim függvényeket) a bemeneti ‘int64’ típusú ‘ts’ attribútumot ‘datetime64’ típusúvá alakítjuk, majd a második lambda függvénnyel egy .apply() függvényen keresztül vesszük az értékek dayofweek (vagy weekday) attribútumát, ami ha nagyobb mint 4, akkor hétvégéről beszélhetünk. Vegyük észre, hogy az első lambda függvénynél az ‘x’ belső változó a beolvasott dataframe objektumot reprezentálja (itt nem .apply() van), míg a másodiknál lévő ‘y’ változó egy series objektumnak (nevezetesen a dátum típusúvá alakított ‘ts’ attribútumnak) aktuálisan egy-egy elemét jelöli, így valójában az .apply() helyett egy .map() függvény hívódik meg, de ezt a Python ügyesen elfedi előlünk. Tehát az .apply() helyett írhattuk volna a .map()-et is, az eredmény ugyanaz. Ezek között a különbség az, hogy a .map() függvény elemszinten hajtódik végre egy series objektumon, az .apply() pedig sor- vagy oszlopszinten egy dataframe objektumon, attól függően, hogy axis=1 vagy axis=0 értéket állítunk-e be paraméterben. Hasonló esetekben a ‘0’ mindig az oszlopindexekre (df.columns), az ‘1’ pedig a sorindexekre (df.index) vonatkozik (lásd majd később is). Természetesen létezik egy .applymap() függvény is, ami szintén elemszinten működik, de már egy teljes dataframe objektumon. Ezeket a különbségeket alapvető fontosságú jól megérteni és begyakorolni, különös tekintettel az .apply()-ra, mivel egy data science Python kódban ez a leggyakrabban használt és legfontosabb függvények egyike (főleg ha a .map() helyett is mindig ezt írjuk). Visszatérve a kódhoz fontos megjegyezni, hogy a bemeneti CSV-ben másodperc pontosságú időbélyegeink vannak (nekem legalábbis), amit jelölni kell a konverziónál (pd.to_datetime(x[‘ts’],unit=’s’)). Természetesen ezredmásodperc (vagy bármi egyéb) pontosságú értékek is használhatók a unit=’ms’ paraméterrel.

Felmerülhet továbbá a kérdés, hogy miért nem elég egy lambda függvény, vagyis miért nem jó az alábbi kódrészlet: .assign(timestamp_type=lambda x: pd.to_datetime(x[‘ts’], unit=’s’).dayofweek/5)\? Nos azért, mert sajnos a hibaüzenet szerint a series objektumoknak nincs dayofweek attribútumuk (“AttributeError: ‘Series’ object has no attribute ‘dayofweek’”), az elemeknek viszont igen, így elemszinten kell azokat kinyerni. Érdemes azonban kicsit utána járni ennek a hibaüzenetnek, mert azért ez egy olyan kijelentés, ami a mai pandas verzióknál már eléggé nehezen hihető. És hát valóban, a series objektumoknak nincs közvetlenül elérhető dayofweek attribútumuk, de a .dt kiterjesztésen keresztül már igen. Vagyis az .assign(timestamp_type=lambda x: pd.to_datetime(x[‘ts’], unit=’s’).dt.dayofweek/5)\ működik, és pontosan azt az eredményt fogja visszaadni, mint amit az eredeti kóddal kaptunk. Fontos, hogy .dt kiterjesztése csak a series objektumnak van, magának a series egy-egy elemének (jelen esetben a timestamp objektumoknak) nincs, vagyis a .dt egy .apply()-on belül nem használható az alábbi formában: .assign(timestamp_type=lambda x: x[‘ts’].apply(lambda y: pd.to_datetime(y,unit=’s’).dt.dayofweek/5))\. (Itt a datetime típussá konvertálást az .apply()-on kívülre is tehettük volna.)

Csupán a fenti helyes .dt kódsorral egyébként nem kis mértékben csökkenthető a futási idő is: 2 millió bemeneti sor esetén az eredeti esetben a gépemen 51-55 sec körüli futási idő adódik, utóbbi esetben viszont csak 24-26 másodperc körüli idő! Vegyük észre, hogy a futási idő feleződése feltételezhetően nemcsak az .apply() iteráció elhagyásának köszönhető, hanem valamilyen minimális mértékben annak is, hogy a hétvégi él jelenlétének generálását egy feltételvizsgálat helyett itt egy egyszerű osztással oldottuk meg (mondjuk ez csak ~1 másodpercet hozott a konyhára 2 millió soron…). Természetesen a futási idő még tovább csökkenthető a csak egyszer alkalmazott pd.read_csv()-vel: így újabb 3-5 másodperc nyerhető nálam a 63 MB-os, 2 millió soros bemeneten (sima laptop HDD-n).

Szünet 1

Ezen a ponton muszáj beiktatni egy szünetet, mert a bejegyzés egy picit hosszú lett. Folytatása pár nap múlva következik!