Szótárak egyesítése az azonos kulcsokhoz tartozó értékek összegyűjtésével

Tegyük fel, hogy egy kérdőívet a megkérdezettek különböző helyszíneken töltenek ki. A kérdéseket és a válaszokat fájlba (pl. JSON fájlba) mentik. Ezeket kell összegyűjteni, és az egyes kérdésekre adott összes választ feldolgozni (például a számszerű értékeléseket átlagolni vagy más statisztikai jellemzőt számolni).

Ezt meg lehet úgy oldani, hogy a fájlok tartalmát szótárakba olvassuk be, ahol azok kulcsa az adott kérdés, vagy annak azonosítója, a kulcshoz tartozó érték pedig a válasz (pl. egy szám). Ahhoz, hogy az adott kérdésre adott összes választ könnyen feldolgozhassuk e szótárakat kell összesíteni olyan módon, hogy az eredményül kapott szótárban minden kulcs szerepeljen, és egy adott kulcshoz tartozó válaszok egy, a kulcshoz értékként társított listában legyenek felsorolva. Ha ez rendelkezésre áll, akkor bármelyik kérdéshez (kulcshoz) tartozó adatsor egyszerűen kikérhető és feldolgozható.

Másik példaként vegyünk egy járműflotta kezelő rendszert, amelybe naponta több sofőr is bejegyezheti a megállásait. Ennek alapján meg kell állapítani, hogy egy adott helyszín (például egy elosztó központ) hány alkalommal és mikor volt érintett. Ebben az esetben a helyszín nevét és annak látogatás idejét, egy szótárban lehet tárolni. Utána ezeket kell megfelelően összesíteni, majd az aggregált szótárból a kívánt értéksorozatokat kinyerni és feldolgozni.

Elvonatkoztatva a konkrét példáktól, a feladat tehát az, hogy készítsünk egy olyan függvényt, amely az argumentumként átadott szótárak alapján egy olyan új szótárt állít elő, amely a bemeneti szótárak minden kulcsát tartalmazza, úgy, hogy minden kulcshoz tartozó érték egy lista, amelyben a bemeneti szótárak adott kulcshoz tartozó értékei szerepelnek.

Ha a bemeneti szótárak mindegyike olyan egyedi kulccsokkal rendelkezne, amelyek a többi szótárban nem található, akkor a feladat megoldása könnyű lenne, mert csak egyszerűen egyesíteni kellene a szótárakat, és a listákra se lenne szükség, hiszen egy adott kulcshoz egy adott érték tartozna. De esetünkben nem ez a helyzet, mert a bementeti szótárak kulcsai részben vagy teljesen megegyeznek.

A feladat megoldását jelentő függvény több módon is megvalósítható. Ezek definíciói láthatók alább:

Az első változatban először létrehozunk egy, a függvény által majd visszaadott aggregáló szótárat, ami kezdetben üres. Kikérjük az argumentumként felsorolt szótárak kulcs-érték párjait, és ellenőrizzük, hogy az éppen aktuális kulcs szerepel-e az aggregáló szótárban. Ha nem, akkor e kulcsot egy hozzá rendelt üres listával együtt felvesszük mint szótárelemet, majd a listához hozzáadjuk az aktuális bemeneti szótár aktuális kulcsához tartozó értékét. Ha az összes, argumentumként felsorolt szótárral végeztünk, akkor az aggregáló szótárat visszaadja a függvény.

A függvény második és harmadik változatainak logikája nagyon hasonló az előzőhöz azzal az eltéréssel, hogy a kulcstartalmazás ellenőrzést, a lista létrehozását és az értékek listákhoz adását a második változatban a setdefault() metódus használatával, a harmadik változatban pedig a szabványos könyvtár collections moduljában elérhető defaultdict() alkalmazásával egyszerűsítettük. Ez utóbbi esetben ahhoz, hogy dict típust adjon vissza a függvény, a defaultdict() szótárat dict típusra kell konvertálni.

A negyedik függvénynél a lényegi változás az előzőekhez képest, hogy az eredményként visszaadandó szótárt az előtt felépítjük, hogy a kulcsokhoz tartozó értékeket a listákba helyeznénk. Ehhez össze kell gyűjteni az összes lehetséges kulcsot. Az ismétlések kiküszöböléséhez ezt egy halmazban tesszük meg. Ha ismerjük a lehetséges kulcsokat, akkor az aggregáló szótárat létre tudjuk hozni úgy, hogy minden kulcs egy hozzá társított üres listával szerepel benne. Ezt követően a listák értékekkel történő feltöltése a harmadik változathoz hasonlóan történhet.

A ötödik változat alapelve hasonló a negyedikéhez azzal a különbséggel, hogy a lehetséges kulcsok összegyűjtését most a collections modul ChainMap konténerével hajtjuk végre. Ehhez létrehozzuk a ChainMap egy példányát a szótárakkal. Tesszük ezt azért, mert kihasználjuk a ChainMap azon tulajdonságát, hogy ha a példányára a keys() metódust meghívjuk, akkor egyetlen halmazszerű objektumban megkapjuk mindazon kulcsot, amelyek a bemeneti szótárak bármelyikében előfordul. A további kódok az előző változatéval egyeznek.

Az utolsó, hatodik függvényváltozat teljesen más megközelítéssel működik. Először előállítjuk a bemeneti szótárak kulcs-érték párjait kételemű tuple objektumok formájában. Ezt követően kulcsok szerint rendezzük azokat. Az így rendezett tuple sorozatot a kulcsok szerint csoportosítjuk az itertools modul groupby() iterátorával. Ez olyan kételemű iterálható objektumokat szolgáltat, amelynek első eleme a csoportosítási kulcs, a második eleme pedig egy iterátor, amely a csoportosítási kulcsnak megfelelő objektumokat (jelen esetben a kételemű tuple konténereket) adja ki. Ezekből szótárépítő kifejezéssel állítjuk elő az aggregáló szótárat mint visszatérési értéket.

Az egyes függvényváltozatok működését a következő kódsorokkal teszteljük.

A kiírt eredményekből megállapítható, hogy mindegyik függvény helyesen működik. Az, hogy az egyes visszaadott szótárakban a kulcsok sorrendje eltérő lehet nem gond, hiszen szótárak esetén az elemek sorrendje elvben nem számít. Ami fontos, hogy a végeredményként kapott szótárban az összetevő szótárak minden kulcsa szerepel, és az egyes kulcsokhoz tartozó értékek a listákban megjelennek.

Amikor egy adott cél eléréséhez több megoldás is rendelkezésre áll, felmerül a kérdés, hogy ezek közül melyiket használjuk. Választási szempontként gyakran a futási időt és a kód olvashatóságát (mennyire könnyű értelmezni) szokták venni.

Egy kód olvashatóságának megítélése természetesen bizonyos mértékben szubjektív, mert függ a nyelvben való jártasságtól és a tapasztalattól. Mindenesetre a bemutatott hat változat közül az első négy viszonylag könnyen értelmezhető. Az ötödikhez a ChainMap működésének behatóbb ismerete szükséges. És mivel a ChainMap nem tartozik a gyakran használt nyelvi eszközök közé, így a kód nem biztos, hogy elsőre teljesen világos. Mégis a legkevésbé könnyen olvasható az utolsó változat, mert itt a megoldási logika és annak kódbeli leképzése egyaránt összetett.

A futási idő méréséhez első lépésben szükségünk van egy olyan függvényre, amely az aggregálni kívánt szótárakat szolgáltatja. Ennek argumentumként meg kell tudni adni, hogy hány szótárt akarunk létrehozni, valamint azt, hogy egy szótárban hány kulcs legyen. Mivel a tesztelni kívánt hat függvénynek valójában akkor van értelme és haszna, ha a szótárak kulcsai részben vagy egészben fedik egymást, ezért a szótárakat előállító függvényünknek azt is meg kell adni, hogy a szótárakban milyen arányban legyenek azonosak a kulcsok. Az egyszerűség kedvéért a kulcsok csak karakterláncok lehetnek. Ezek hosszát és az alkalmazható karakterek készletét szintén meg lehet adni. Ez utóbbi alapértelmezetten legyen az ASCII kis- és nagybetűk halmaza. E követelményeknek megfelelő, create_dictionaries nevű függvény definícióját láthatjuk alább a generate_unique_keys() segédfüggvénnyel együtt, amely adott számú egymástól eltérő, egyedi karakterláncokat szolgáltat. A függvények működésének megértését a részletes kommentek segítik.

A create_dictionaries() függvényt felhasználó, a futási időket mérő test_dict_aggregation_times() függvény definíciója alább látható. Ennek első paramétere egy karakterláncot fogad, amely a tesztfeltételek kiírására használható. A többi paraméter jelentése megegyezik a create_dictionaries()függvénynél szereplőkkel.

A tesztelést négy esetre végeztük. Ezekből kettő az, amikor az egyesítendő szótárak száma kicsit, és a szótárak kevés vagy sok kulcsot tartalmaznak. A másik két esetben a szótárak szintén kevés vagy sok kulccsal rendelkeznek, de az aggregálni kívánt szótárak száma viszonylag nagy. Az eredmények a következők:

Az eredménykiírásokból látható, hogy amikor az egyesíteni kívánt szótárak száma kevés, akkor a setdefault() metódust használó függvény bizonyul a leggyorsabbnak, és a függvények végrehajtási idő szerinti sorrendje független a kulcsok számától. Ezzel szemben, ha sok szótárt kívánunk aggregálni, akkor a defaultdict()-et alkalmazó függvény a leggyorsabb. Továbbá, a második és harmadik helyezetteknél van változás attól függően, hogy sok vagy kevés kulcsot tartalmaznak a szótárak.

Azt is megfigyelhetjük, hogy sok szótár esetén a groupby()-t használó megoldás nagyságrenddel rosszabb futási idővel rendelkezik, mint a többi. És mivel ez is volt a legkevésbé olvasható, ezért ez a függvényváltozat ehhez az esethez biztosan nem ajánlott.

Ebben a bejegyzésben a szótárak voltak a fókuszban. Ezekről részleteiben, példákkal magyarázva a Python tudásépítés lépésről lépésre című e-könyvben a „Beépített konténerobjektumok”, a „Konténerekkel végezhető műveletek”, „Beépített típusok nyilvános metódusai” fejezetekben, valamint a „Készétel fogyasztás – a szabványos könyvtár moduljainak használata” fejezeten belül a „Speciális konténer típusok” című alfejezetben lehet olvasni. A futási idő meghatározáshoz pedig a „A programvégrehajtás felfüggesztése és a futási idő mérése” alfejezetet érdemes átnézni.

Érdekel a Python tudásépítés lépésről lépésre az alapoktól az első asztali alkalmazásig című e-könyv.