In softwareontwikkeling zijn datatransformatie en het genereren van gegevens uit andere gegevens veelvoorkomende taken. Alle programmeertalen pakken dit op een andere manier aan, elk met hun eigen troeven en minpunten. Afhankelijk van het probleem, zijn sommige manieren meer aangewezen dan andere. In deze blog ontdek je eenvoudige maar toch krachtige methodes om gegevens te genereren en te transformeren in Python.
Voordat we een complexer geval bespreken, beginnen we met een basisvoorbeeld. Stel je voor dat we een paar winkels hebben en elke winkel heeft zijn eigen database met items die zijn toegevoegd door werknemers. Sommige velden zijn optioneel, wat betekent dat werknemers niet altijd alles invullen. Naarmate we groeien, kan het moeilijk worden om een duidelijk overzicht te krijgen van alle items in onze winkels. Daarom ontwikkelen we een Python-script dat de verschillende items uit de databases van onze winkels verzamelt in één enkele database.
store_1.get_items() retourneert een generator van items. Generators zullen een belangrijke rol spelen in deze blogpost. Met generatoren kunnen we een complexe keten van transformaties opzetten over enorme hoeveelheden gegevens zonder dat we zonder geheugen komen te zitten, terwijl onze code beknopt en schoon blijft. Als je nog niet bekend bent met Python:
Twee dingen zijn hier belangrijk. Ten eerste, het aanroepen van een generator zal geen data teruggeven; het zal een iterator teruggeven. Ten tweede worden waarden op verzoek geproduceerd. Een meer diepgaande uitleg kan hier worden gevonden.
Er zijn twee manieren om generatoren te maken. De eerste lijkt op een normale Python functie, maar heeft een yield statement in plaats van een return statement. De andere is beknopter, maar kan snel ingewikkeld worden als de logica complexer wordt. Het heet de Python generator expressie syntaxis en wordt voornamelijk gebruikt voor eenvoudigere generatoren.
Om het eenvoudig te houden, voeren we het script aan het eind van de dag één keer uit, zodat we een complete database hebben met alle items uit alle winkels.
Voor dit gebruik is dit prima. Maar als de complexiteit groeit en er meer winkels worden toegevoegd, kan het snel onoverzichtelijk worden. Gelukkig heeft Python geweldige ingebouwde tools om onze code te vereenvoudigen.
Eén module in Python heet itertools. Volgens de Python-documenten "standaardiseert de module een kernset van snelle, geheugenefficiënte hulpmiddelen die op zichzelf of in combinatie nuttig zijn. Samen vormen ze een "iterator algebra", waardoor het mogelijk wordt om gespecialiseerde tools beknopt en efficiënt in pure Python te bouwen."
Een geweldige functie is itertools.chain(). Deze wordt gebruikt om meerdere iterables aan elkaar te 'ketenen' alsof ze één zijn. We kunnen het gebruiken om onze generatoren aan elkaar te koppelen.
Laten we nu aannemen dat ons item een tupel is met vijf velden: naam, merk, leverancier, kosten en het aantal stuks in de winkel. Het heeft de volgende signatuur: tuple[str,str,str,int,int]. Als we de totale waarde van de artikelen in de winkel willen, hoeven we alleen maar het aantal artikelen met de kosten te vermenigvuldigen.
Nu ziet het er zo uit: tuple[str, str, str, int]. Maar we willen het uitvoeren als JSON. Daarvoor kunnen we gewoon een generator maken die een woordenboek teruggeeft en daar json.dumps() op aanroepen. Laten we aannemen dat we een iterator van dicts kunnen doorgeven aan de add_or_update() functie en dat deze automatisch json.dumps() aanroept.
Nu we meer logica hebben, laten we eens kijken hoe we die kunnen samenvoegen. Een geweldig ding over generatoren is hoe duidelijk en beknopt het is om ze te gebruiken. We kunnen een functie maken voor elke processtap en de gegevens er doorheen laten lopen.
Om de stappen die we hebben genomen te laten zien, heb ik alles opgesplitst. Er zijn nog een paar dingen die verbeterd kunnen worden. Kijk eens naar de functie calc_total_val(). Dit is een perfect voorbeeld van een situatie waarin een generatoruitdrukking kan worden gebruikt.
Om het nog netter te maken, kunnen we al onze functies in een aparte module plaatsen. Op deze manier bevat ons hoofdbestand alleen de stappen die de gegevens doorlopen. Als we beschrijvende namen gebruiken voor onze generatoren, kunnen we meteen zien wat de code zal doen. Nu hebben we dus een pijplijn voor de gegevens gemaakt. Hoewel dit slechts een eenvoudig voorbeeld is, kan het ook worden gebruikt voor meer gecompliceerde workflows.
Alles wat we in het bovenstaande voorbeeld hebben gedaan, kan eenvoudig worden toegepast op een Data Product. Als je niet bekend bent met gegevensproducten, is hier een geweldige tekst over gegevensmazen.
Stel je voor dat we een gegevensproduct hebben dat gegevens samenvoegt. Het heeft meerdere ingangen met verschillende soorten gegevens. Elk van die ingangen moet worden gefilterd, getransformeerd en opgeschoond voordat we ze kunnen samenvoegen tot één uitvoer. De klant vereist dat de uitvoer een enkel JSON-bestand is dat wordt opgeslagen in een S3-bucket. De bestaande infrastructuur staat slechts 500 Mb RAM toe voor de containers. Laten we nu alle gegevens laden, wat transformaties doen, alles samenvoegen en het in een JSON-bestand parsen.
Hoewel dit een uitstekende oplossing lijkt die het werk doet en eenvoudig te begrijpen is, crasht onze container plotseling door een OutOfMemory-fout. Na wat lokaal testen op onze machine, zien we dat het een 834Mb bestand heeft geproduceerd dat niet kan werken met slechts 500 MB RAM voor de container. Het probleem met de bovenstaande code is dat we alles eerst in een lijst bewaren, zodat alles in het geheugen wordt opgeslagen.
Laten we het nog eens proberen. Voor S3 kunnen we MultipartUpload gebruiken. Dit betekent dat we niet het hele bestand in het geheugen hoeven te bewaren. En natuurlijk moeten we onze lijsten vervangen door generatoren.
Omdat we nu maar één onderdeel tegelijk in het geheugen hebben, gebruikt dit veel minder geheugen dan de eerdere oplossing met bijna geen extra werk. Echter, het sturen van een postverzoek naar S3 voor elk item kan een beetje veel zijn. Vooral als we 300.000 items hebben. Maar er is nog een probleem ...
De 'part size' moet tussen de 5MiB en 5GiB zijn. Om dit op te lossen, kunnen we meerdere onderdelen groeperen voordat we ze parseren. Maar als we er teveel groeperen, bereiken we opnieuw de geheugenlimiet. De grootte van de chunk moet daarom afhangen van hoe groot de individuele delen van je data zijn. Laten we, om dit te demonstreren, een grootte van 1.000 gebruiken. Hoe groter de chunkgrootte, hoe meer geheugen er wordt gebruikt, maar hoe minder verzoeken aan S3. We geven er dus de voorkeur aan dat onze chunks zo groot mogelijk zijn zonder dat het geheugen opraakt.
Dit is alles wat er hoeft te gebeuren. Het is genoeg om grote hoeveelheden gegevens te transformeren en op te slaan in een S3-bucket, zelfs als de bronnen schaars zijn.
Als je berekeningen rekenintensief zijn, is het eenvoudig om ze parallel uit te voeren. Met slechts een paar extra regels kunnen we onze transformers op meerdere cores laten draaien.
Het beste hieraan? We hoeven verder niets te veranderen omdat imap kan worden geïtereerd om resultaten te krijgen, net als elke andere generator. Laten we nu alles bij elkaar gooien. Dit is alles wat we nodig hebben voor rekenintensieve transformaties, over grote hoeveelheden gegevens, met gebruik van meerdere cores.
Generatoren worden vaak verkeerd begrepen door nieuwe ontwikkelaars, maar ze kunnen een uitstekend hulpmiddel zijn. Of het nu gaat om een eenvoudige transformatie of iets geavanceerder zoals een gegevensproduct, Python is een goede keuze vanwege het gebruiksgemak en de overvloed aan tools die beschikbaar zijn in de standaardbibliotheek.