Polars — tehokasta tietojenkäsittelyä Pythonilla

Jukka Katajamaki
SPxFiva Data Science
7 min readDec 18, 2023
Lähde: DALL-E 3

Pythonin datankäsittelymahdollisuudet ovat laajentuneet uusien kirjastojen myötä viime vuosina, ja yksi mielenkiintoinen nosto olemassa olevista vaihtoehdoista on Polars. Ritchie Vink kehitti Polarsin vuonna 2020, ja se on noussut varteenotettavaksi haastajaksi pitkään datatieteilijöiden suosiossa olleelle Pandas-kirjastolle. Tässä artikkelissa käsitellään Polarsin tarjoamia etuja verrattuna Pandasiin ja käydään läpi keskeisimpiä Polarsin käyttämiä tietorakenteita ja niiden ominaisuuksia.

Polars on suorituskykyinen tietojenkäsittelyyn tarkoitettu kirjasto, joka tarjoaa nopeita ja tehokkaita työkaluja datan analysointiin ja manipulointiin. Polars perustuu Rust-ohjelmointikieleen. Rustin standardikirjasto tukee rinnakkaislaskentaa suoraan, ilman erillisiä asennuksia tai mukautuksia.

Rinnakkaislaskenta tarkoittaa useiden laskutoimitusten suorittamista samanaikaisesti. Laskennan tehtävät jaetaan useisiin osiin ja ne suoritetaan yhtä aikaa useilla prosessoreilla tai ytimillä.

Rustin ominaisuudet tekevät Polarsista erittäin tehokkaan — Polars hyödyntää Rustin tarjoamaa rinnakkaislaskentaa suoraan taustalla, ilman että käyttäjän tarvitsee itse suorittaa mitään siihen liittyvää ohjelmointia.

Polarsin edut tulevat esiin erityisesti suurempien datamäärien käsittelyssä, varsinkin kun työskennellään sellaisten aineistojen parissa, jotka eivät mahdu tietokoneen keskusmuistiin. Polars on hyvä vaihtoehto silloin kun ei ole mahdollista käyttää ulkopuolista laskentaklusteria ja laskenta pitää suorittaa yksittäisellä koneella.

Yksi keskeinen kysymys uuden kirjaston valinnassa on sen suorituskyky ja tehokkuus. Finanssivalvonnan pääomamarkkinoiden valvontatyössä käytettävien aineistojen koko on kasvanut ja niiden monimuotoisuus on lisääntynyt, joten valvontatyön kannalta on tärkeää, että aineistoja pystytään analysoimaan ja käsittelemään mahdollisimman tehokkaasti.

Pandas vastaan Polars: datan lukeminen CSV-tiedostosta

Suoritetaan yksinkertainen vertailu Pandasin ja Polarsin välillä, jotta saadaan kuva siitä, mitä eroja kirjastojen välillä on resurssien käytön ja suoritusnopeuden suhteen. Testit suoritetaan kannettavalla tietokoneella, jossa on alla olevat ominaisuudet.

16,0 GB RAM

11th Gen Intel(R) Core(TM) i5–1145G7 @ 2.60GHz 2.61 GHz

Polarsin ja Pandasin käytettävät versiot ovat

Pandas==2.1.1.

Polars==0.19.12

Ensiksi kokeillaan, miten kirjastot suoriutuvat, kun käsitellään CSV-tiedostoa. Testiä varten käytetään vuoden 2021 New Yorkin taksimatkoihin liittyvää dataa[1]. Tiedostossa jokainen rivi edustaa yhtä matkaa keltaisella taksilla New Yorkissa vuonna 2021. Datasetin koko on n. 2,8GB ja siinä on n. 30,9 miljoonaa riviä.

Testissä mitataan tietokoneen resurssien käyttöä ja suoritusnopeutta, kun luetaan CSV-tiedosto ja tehdään yksinkertaisia suodatuksia kummallakin kirjastolla. Kyseinen datasetti ei ole kovinkaan suuri ja se mahtuu tietokoneen keskusmuistiin, mutta antaa hyvän kuvan kirjastojen välisistä suorituseroista.

Esimerkki CSV-tiedoston sisällöstä
Esimerkki CSV-tiedoston sisällöstä

Pandasin tulokset

Luetaan CSV-tiedosto Pandasilla ja tehdään suodatuksia dataan.

import pandas as pd

pd_df = pd.read_csv(path)

pd_df = pd_df[
(pd_df['trip_distance'] > 5) &
(pd_df['fare_amount'] > 20)
]

Alla olevissa kuvaajissa mem_usage kuvaa tietokoneen keskusmuistin käyttöastetta ajon aikana ja cpu_usage kuvaa vastaavasti prosessorin (Central Processing Unit, CPU) käyttöastetta ajon aikana. Kuvaajien x-akselilla aika on ilmoitettu sekunneissa.

Pandasin resurssien käyttö

Pandasilla prosessorin käyttö jää pieneksi, koska Pandas ei pysty hyödyntämään tietokoneen kaikkia prosessorin ytimiä samanaikaisesti, toisin kuin Polars. Pandasilla keskusmuistin käyttö nousee hitaasti suorituksen aikana.

Polarsin tulokset

Luetaan CSV-tiedosto Polarsilla ja tehdään suodatuksia dataan.

import polars as pl

pl_df = pl.read_csv(path)

pl_df = pl_df.filter(
(pl.col('trip_distance') > 5) &
(pl.col('fare_amount') > 20)
)
Polarsin resurssien käyttö

Polars pystyy hyödyntämään paljon tehokkaammin prosessoria ja keskusmuistia heti ajon alusta asti.

Alla olevassa viiksilaatikossa vertaillaan Pandasin ja Polarsin välisiä suoritusnopeuksia, kun luetaan CSV-tiedosto (Read), ja kun luetaan CSV-tiedosto ja tehdään siihen samat suodatukset kuin aikaisemmissa esimerkeissä (Read&filter). Molemmilla kirjastoilla tulokset on toistettu 20 kertaa. Kuvaajissa vihreä kolmio edustaa keskiarvoa. Polarsin ja Pandasin välillä on huomattavan suuri ero suoritusnopeudessa jo pelkästään tiedoston sisäänluvussa. Suodatuksen jälkeen datassa on hieman yli 4 miljoonaa riviä.

Pandasin ja Polarsin suoritusajat

Seuraavaksi perehdytään tarkemmin Polarsiin ja sen käyttämiin tietorakenteisiin.

Mitä ovat Polars Eager DataFrame ja Lazy DataFrame?

Eager DataFrame ja Lazy DataFrame ovat Polarsin käyttämiä tietorakenteita, jotka eroavat toisistaan niiden suoritusstrategioiden perusteella.

Riippuen käytettävästä tietorakenteesta, esimerkiksi CSV-tiedosto luetaan Polarsilla eri tavalla.

Eager DataFrame: read_csv()

Lazy DataFrame: scan_csv()

Eager DataFrame suorittaa operaatiot heti määriteltäessä, kun taas Lazy DataFrame tallentaa operaatiot laiskana laskentakaavana ja suorittaa ne vasta myöhemmin, kun lopullista tulosta tarvitaan. Aikaisemmassa testissä CSV-tiedosto luettiin Polarsilla käyttäen Eager DataFramea.

Laiska laskenta tarkoittaa, että funktion arvo lasketaan vasta silloin kun sitä tarvitaan.

Lazy DataFrame voi tuoda merkittäviä etuja suorituskyvyn ja resurssien käytön suhteen, koska Polars optimoi laskentakaavojen käytön. Lazy DataFramen avulla käyttäjä voi ketjuttaa useita operaatioita ilman, että niitä suoritetaan välittömästi. Tämä tekee koodista joustavaa ja tehokasta.

Lazy DataFramen lisäetuna on sen mahdollisuus suorittaa operaatiota käyttäen Streaming-tilaa (Streaming mode). Streaming-tila mahdollistaa sen, että Polars voi ladata käsiteltävän datan pienemmissä erissä keskusmuistiin ja suorittaa operaatiot pienemmissä erissä sen sijaan, että käsiteltäisiin koko datasetti kerralla (ts. datasettiä ei tarvitse ladata kokonaan koneen keskusmuistiin).

Streaming-tilan käyttö on lähes välttämätöntä, kun käsitellään sellaisia datasettejä, jotka eivät mahdu kerralla tietokoneen keskusmuistiin. Kuitenkin on hyvä huomioida, että Streaming-tilaa kehitetään edelleen, joten kaikkia Lazy DataFramen operaatiota ei ole mahdollista suorittaa käyttäen Streaming-tilaa.

Streaming-tilan saa kytkettyä päälle antamalla collect-funktiolle argumentin streaming=True.

Eager DataFrame vastaan Lazy DataFrame: datan lukeminen CSV-tiedostosta

Seuraavaksi vertaillaan, miten Eager DataFramen ja Lazy DataFramen suoritusnopeudet eroavat toisistaan, kun datalle tehdään yksinkertaisia operaatioita.

Ensiksi suoritetaan operaatioita Eager DataFramella (käytetään pl.read_csv). Esimerkissä datalle tehdään tarvittavat formatoinnit, lasketaan taksimatkan kesto ja lopuksi suodatetaan datasta elokuun taksimatkat.

# Lueataan data Eager DataFrameen
df = pl.read_csv(path)

# Muutetaan sarakkeet datetimeksi
df = df.with_columns(
pl.col('tpep_pickup_datetime').str.to_datetime("%m/%d/%Y %I:%M:%S %p"),
pl.col('tpep_dropoff_datetime').str.to_datetime("%m/%d/%Y %I:%M:%S %p"),
)

# Lasketaan taksimatkan kesto
df = df.with_columns(
(pl.col('tpep_dropoff_datetime') - pl.col('tpep_pickup_datetime')).alias('TripDuration')
)

# Suodatetaan elokuun data
df = df.filter(
pl.col('tpep_pickup_datetime').dt.month() == 8
)

Eager DataFramella kului ~44 sekuntia yllä olevien operaatioiden suorittamiseen.

Polars Eager DataFramen resurssien käyttö

Seuraavaksi tehdään samat operaatiot Lazy DataFramella. Lazy DataFramen avulla käyttäjä pystyy kirjoittamaan kyselyt ja operaatiot yhteen ketjuun, jolloin Polars hoitaa laskentakaavojen optimoinnin taustalla käyttäjän puolesta.

Luetaan data Lazy DataFrameen (huom. käytetään pl.scan_csv) ja tehdään samat muokkaukset sekä suodatukset kuin edellisessä esimerkissä. Lopuksi data ladataan muistiin käyttäen Streaming-tilaa. On hyvä huomata, että kaikki datalle tehtävät operaatiot suoritetaan vasta lopussa, kun q.collect(streaming=True) kutsutaan.

# Voidaan kirjoittaa myös yhtenä ketjuna
q = (
pl.scan_csv(path)
.with_columns(
pl.col('tpep_pickup_datetime').str.to_datetime("%m/%d/%Y %I:%M:%S %p"),
pl.col('tpep_dropoff_datetime').str.to_datetime("%m/%d/%Y %I:%M:%S %p"),
)
.with_columns(
(pl.col('tpep_dropoff_datetime') - pl.col('tpep_pickup_datetime')).alias('TripDuration')
)
.filter(
pl.col('tpep_pickup_datetime').dt.month() == 8
)
)

# Ladataan data
taxi_rides_august = q.collect(streaming=True)

Lazy DataFramella operaatioiden suorittamiseen kului ~11 sekuntia, joka on huomattavasti nopeampi kuin Eager DataFramella (44 sekuntia).

Polars Lazy DataFramen resurssien käyttö

Tässä esimerkissä käytetty ketju on suhteellisen yksinkertainen: formatoidaan sarakkeet oikeaan datatyyppiin, lasketaan matkan kesto ja lopuksi suodatetaan data. Monimutkaisten ketjujen kanssa optimoinnista saadaan vielä enemmän hyötyjä irti. Alla olevassa viiksilaatikossa on esitetty suoritusaikoja molemmilla tietorakenteilla, kun tulokset on toistettu 20 kertaa.

Polarsin suoritusajat

Polars ja iso datasetti

Seuraavaksi kokeillaan Polarsin suorituskykyä datasetillä, joka ei mahdu koneen keskusmuistiin. Testissä käytetty data[2] sisältää kaikkien Yhdysvaltojen sisällä lennettyjen kaupallisten lentojen saapumis- ja lähtötiedot lokakuusta 1987 huhtikuuhun 2008. Yksi rivi edustaa yhtä lentomatkaa. Datassa on n. 123 miljoonaa riviä ja datasetin koko on n. 11,2GB.

Esimerkki CSV-tiedoston sisällöstä

Koodista voidaan saada siistimpää ja helppolukuisempaa, jos kyselyissä ja datan manipuloinnissa käytettävien ketjujen osat kirjoitetaan omiksi funktioikseen ja hyödynnetään Polarsin pipe-funktiota.

Muutetaan käytetyt operaatiot omiksi funktioiksi:

# Muutetaan sarakkeen datatyyppi ajaksi
def cast_timestamp(df: pl.DataFrame, time_column: str, timefmt: str) -> pl.DataFrame:

df = df.with_columns(
pl.col(time_column).cast(pl.Utf8).str.zfill(4).str.to_time(timefmt, strict=False).alias('DepTimeStamp')
)

return df


# Suodatetaan halutut vuodet datasta
def filter_data(df: pl.DataFrame, start: int, end: int) -> pl.DataFrame:

df = df.filter(
pl.col('Year').is_between(start, end)
)

return df


# Lasketaan lennon keskinopeus mph (miles per hour)
def calculate_mph(df: pl.DataFrame) -> pl.DataFrame:

df = df.with_columns(
(pl.col('AirTime') / 60 ).alias('AirTimeHours')
).with_columns(
(pl.col('Distance') / pl.col('AirTimeHours')).round(2).alias('MilesPerHour')
)

return df


# Tehdään luokittelu, mihin aikaan päivästä lento on lähtenyt matkaan
def calculate_daytime(df: pl.DataFrame) -> pl.DataFrame:

df = df.with_columns(
pl.when(
pl.col('DepTimeStamp').dt.time().is_between(time.fromisoformat('06:00:00'), time.fromisoformat('10:59:59'))).then(pl.lit('1 - Morning')).otherwise(
pl.when(pl.col('DepTimeStamp').dt.time().is_between(time.fromisoformat('11:00:00'), time.fromisoformat('13:59:59'))).then(pl.lit('2 - Midday')).otherwise(
pl.when(pl.col('DepTimeStamp').dt.time().is_between(time.fromisoformat('14:00:00'), time.fromisoformat('17:59:59'))).then(pl.lit('3 - Afternoon')).otherwise(
pl.when(pl.col('DepTimeStamp').dt.time().is_between(time.fromisoformat('18:00:00'), time.fromisoformat('21:59:59'))).then(pl.lit('4 - Evening')).otherwise(
pl.lit('5 - Night'))))
).alias('DayTime')
)

return df


# Lasketaan vuosi- ja kuukausitasolla keskiarvoja
# lentojen myöhästymisille, lennon pituudelle, lentoajalle ja keskinopeudelle
def calculate_averages(df: pl.DataFrame) -> pl.DataFrame:

df = df.group_by(
[
'Origin',
'Year',
'Month',
'DayTime',
]
).agg(
[
pl.col('DepDelay').mean().round(2).alias('AverageDepartureDelay'),
pl.col('Cancelled').sum().alias('NumberOfCancellations'),
pl.count().alias('NumberOfFlights'),
pl.col('AirTime').mean().round(2).alias('AverageAirTime'),
pl.col('Distance').mean().round(2).alias('AverageDistance'),
pl.col('MilesPerHour').mean().round(2).alias('AverageSpeed'),
]
).with_columns(
pl.duration(minutes=pl.col('AverageDepartureDelay')).alias('AverageDepartureDelay'),
pl.duration(minutes=pl.col('AverageAirTime')).alias('AverageAirTime'),

).sort(
['Origin', 'Year', 'Month', 'DayTime']
).select([
'Origin',
'Year',
'Month',
'DayTime',
'NumberOfCancellations',
'NumberOfFlights',
'AverageDepartureDelay',
'AverageAirTime',
'AverageDistance',
'AverageSpeed'
])

return df

Suoritetaan operaatiot, ja tuloksena saadaan lopullinen DataFrame, jossa on vuoden 2001 lentomatkat.

flights_2001 = (
pl.scan_csv(path, null_values=['NA'], encoding='utf8-lossy')
.pipe(cast_timestamp, time_column='DepTime', timefmt='%H%M')
.pipe(filter_data, start=2001, end=2001)
.pipe(calculate_mph)
.pipe(calculate_daytime)
.pipe(calculate_averages)
).collect(streaming=True)

Lopputuloksena saadaan seuraava data:

Vuoden 2001 lentomatkat

Polars suoriutuu yllä olevasta operaatioiden ketjusta 4:19 minuutissa, joka on hyvä tulos näin isolla datasetillä ja kuitenkin ”vain” kannettavalla tietokoneella, jossa on 16gb keskusmuistia.

Polars Lazy DataFramen resurssienkäyttö

Loppupäätelmät

Mitä suurempi ja monimutkaisempi projekti on, sitä enemmän käyttäjän tulee harkita tehokkaiden työkalujen käyttöä. Polars tarjoaa tehokkaan ja nopean työvälineen, kun käsitellään isompia datasettejä, esimerkiksi kannettavalla tietokoneella, jossa on rajatut resurssit käytössä.

Polarsin käytöstä saadaan huomattavia hyötyjä, kun otetaan käyttöön Lazy DataFrame ja suoritetaan operaatiot ketjuttamalla ne yhteen. Polars hoitaa taustalla kyselyiden ja operaatioiden optimoinnin automaattisesti, mikä parantaa suorituskykyä ja resurssien tehokasta hallintaa. Yksi tärkeä ominaisuus Polarsissa on sen mahdollisuus käsitellä dataa pienemmissä osissa, jolloin koko datasettiä ei tarvitse ladata koneen keskusmuistiin. Tämä on mahdollista, kun käytetään Lazy DataFramea Streaming-tilassa.

Viimeisimmässä Finanssivalvonnan datan laadunvalvonnan projektissa otimme Polarsin käyttöön, koska projektissa käsiteltävien tiedostojen koot olivat niin suuria, että Pandasilla tiedostojen käsittely olisi kestänyt liian kauan. Tutkimme myös muita vaihtoehtoja, mutta lopulta päädyimme käyttämään Polarsia sen suorituskyvyn ja helppokäyttöisyyden takia, ja tähänastiset kokemukset sen käytöstä ovat olleet hyvin vakuuttavia.

[1] Data on julkisesti saatavilla

[2] Data on julkisesti saatavilla

--

--