12-7-2022

Pandas vs Polar - Een vergelijking van performance

#code#data-pipelines#rust

Studio Terabyte is een fullstack web development studio die oplossingen vindt en bouwt passend bij uw project

Intro

Pandas is de bekendste Python library voor het werken met gestructureerde data. Het wordt overal gebruikt, van eenmalige analyses door wetenschappers tot volledige productie data pipelines om het opschonen, manipuleren en analyseren van data te automatiseren. De populariteit kan worden toegeschreven aan het gebruiksgemak en het feit dat het bovenop Python is gebouwd, een van de meest populaire programmeertalen met een relatief lage leercurve.

Nu Pandas zo populair is, is het niet verwonderlijk dat er overal alternatieven opduiken. Een van de interessantere is Polars. Het is bedoeld als een herkenbare library voor iedereen die ervaring heeft met Pandas, maar biedt betere prestaties omdat het bovenop Rust en Apache Arrow2 is gebouwd. Dit betekent ook dat het compatibel is met andere tools in het Arrow-ecosysteem, mogelijk interessant voor enterprise gebruikers. Het is beschikbaar als zowel een bibliotheek in Python als in Rust.

Het doel van deze blog is om te kijken naar enkele praktijkvoorbeelden om te zien welke library beter presteert. De gebruikte Pandas versie was 1.4.2 met Python versie 3.9.12. Voor Polars is gekozen voor de Rust versie in plaats van de Python versie om de meeste performance te krijgen. Specifiek versie 0.22.8 en Rustc versie 1.62.0. Als je moet beslissen welke te gebruiken in je data pipelines of big data-analyses, is performance immers wat je wilt.

Head to head

De data die gebruikt is voor alle tests is een aangepaste versie van de New York City Taxi Trip Duration data set van Kaggle. Deze bevat orgineel 1,462,644 rijen, welke 25 keer zijn gekopieerd om een grote data set te simuleren. Het uiteindelijke bestand bestaat uit 36,566,100 rijen wat neerkomt op ongeveer 5gb.

Hardware

De tests zijn uitgevoerd op een MacBook Pro uit 2015 met de volgende relevante specificaties:

  • 3,1 GHz dual-core Intel Core i7
  • 8 GB 1867 MHz DDR3

Alle testtijden zijn gemiddeld 3 opeenvolgende runs.

Use cases

De volgende tests zijn uitgevoerd om de praktijk na te bootsen:

  1. Open het bestand en toon de vorm van het DataFrame
  2. Het bestand openen en de eerste 5 rijen tonen
  3. Open het bestand en bereken de lengte van alle strings in de id kolom
  4. Open het bestand en pas een functie toe op de kolom trip_duration om het getal door 60 te delen om van secondes naar minuten te gaan
  5. Het bestand openen en alle rijen uitfilteren met een reisduur korter dan 500 seconden
  6. Open het bestand, filter alle rijen uit met een 'Y'-waarde in de kolom 'store_and_fwd_flag', groepeer op ID en bereken de gemiddelde duur

Timing code

De code die wordt gebruikt om de individuele use-cases te timen, ziet er als volgt uit:

Python Pandas:

def main():
 # Perform one of the above described use cases
 perform_use_case()
if __name__ == "__main__":
 # Start the time
 start_time = timer()
 
 # Call main function to perform use case
 main()
 
 # End the timer
 end_time = timer()
 
 # Print the elapsed time in seconds
 print(f"The program took {end_time - start_time} seconds to run")

Rust Polars:

fn main() {
 // Start the timer
 let start_time: Instant = Instant::now();
 
 // Perform one of the above described use cases
 perform_use_case();
 
 // End the timer
 let elpased_time: Duration = start_time.elapsed();
 
 // Print of the file time in seconds
 println!("The program took {:?} seconds to run", elpased_time)
}

1. Open het bestand en toon de vorm van de DataFrame

De eerste use case is heel eenvoudig. Stel dat je een CSV-bestand ontvangt van een collega of als output van een ander proces en je wilt controleren of het ongeveer zo groot is als verwacht. Een eenvoudige manier om dit te doen, is door de vorm van het DataFrame te controleren (een DataFrame is wat Panda's en Polars gebruiken om de gegevens weer te geven. Zie het als een spreadsheet). Hiermee wordt het aantal kolommen en het aantal rijen getoont.

In Panda's ziet de code er als volgt uit:

# Utility function to read the CSV file and convert it to a DataFrame
def generate_df():
   df = pd.read_csv(test_file)
   return df
 
def print_df_shape():
   df = generate_df()
   print(df.shape)

Zoals je kunt zien is dit vrij eenvoudig. Bij het lezen van een CSV-bestand converteert Pandas het naar een DataFrame en geeft het shape als attribuut dat gebruikt kan worden om ons antwoord te krijgen. We krijgen het volgende resultaat:

(36466100, 11)

Ongeveer 36 miljoen rijen en 11 kolommen, precies zoals verwacht!

De Polars versie van de code er als volgt uit:

fn generate_lf() -> LazyFrame {
 LazyCsvReader::new(TEST_FILE.into())
  .has_header(true)
  .finish()
  .expect("Error opening file")
}
fn print_df_shape() -> () {
 let df = generate_lf().collect().expect("error");
 println!("{:?}", df.shape())
}

Zoals je kunt zien, ziet de functie generate_lf() er iets anders uit. Dit komt omdat Polars niet alleen DataFrames ondersteunt, maar ook LazyFrames. Deze zijn in wezen hetzelfde als DataFrames, behalve dat ze wachten om alle functies die je hebt aangeroepen tot de laatst mogelijke seconde uit te voeren. Zo weet de LazyFrame precies wat je ermee wilt doen en vindt het de meest optimale manier om dit te doen.

Afgezien daarvan heeft Polars ook een ingebouwde shape() functie die kan worden aangeroepen op een DataFrame. De resultaten zijn:

(36466100, 11)

Geweldig, hetzelfde resultaat van beide libraries! Laten we nu eens kijken naar het interessante deel, de performance:

TaskPython PandasRust PolarsDifference
Open het bestand en toon de vorm van de DataFrame274,75 Seconds38,97 Seconds235,78 seconds

Wauw! We hadden verwacht dat Polars sneller zou zijn, maar meer dan 7x sneller is best veel. Ik vermoed echter dat het lezen van het bestand zelf hier de bottleneck is. Dat gezegd hebbende, het is niet iets dat we zomaar kunnen negeren. De gegevens moeten ergens vandaan komen.

2. Het bestand openen en de eerste 5 rijen tonen

De tweede use case is ook vrij eenvoudig en gaat verder waar we gebleven waren. Je hebt nu geverifieerd dat het DataFrame het juiste aantal rijen en kolommen bevat en je wilt de gegevens waar je mee te maken heeft ook echt zien. Maar wetende dat we meer dan 36 miljoen rijen hebben, wil je alleen de eerste 5 rijen zien. Of met andere woorden: u wilt de head van het DataFrame zien.

Hier is hoe we dit zouden doen in Panda's:

def print_df_head():
 df = generate_df()
 print(df.head())

Welke de volgende resultaten zal laten zien:

idvendor_idpickup_datetime...trip_duration
id287542122016-03-14 17:24:55...455
id237739412016-06-12 00:43:35...663
id385852922016-01-19 11:35:24...2124
id350467322016-04-06 19:32:31...429
id218102822016-03-26 13:30:55...435

Ziet er goed uit. Voor Polars ziet de code er nu als volgt uit:

fn print_df_head() -> () {
 let df = generate_lf().collect().expect("error");
 println!("{}", df.head(Some(5)))
}

Wat op zijn beurt de volgende resultaten oplevert:

id
---
str
vendor_id
---
i64
pickup_datetime
---
str
...trip_duration
---
i64
id287542122016-03-14 17:24:55...455
id237739412016-06-12 00:43:35...663
id385852922016-01-19 11:35:24...2124
id350467322016-04-06 19:32:31...429
id218102822016-03-26 13:30:55...435

Dezelfde tabel als door Pandas laten zien, top! Maar als je goed kijkt zie je een klein verschil. Omdat we Rust gebruiken voor onze Polars voorbeelden, zijn types belangrijk. Rust is een staticly typed taal, wat betekent dat als een functie bijvoorbeeld een string type verwacht, en je geeft er een getal aan, Rust je een foutmelding geeft tijdens het compileren. De code wordt niet eens uitgevoerd. Python daarentegen is dynamically typed en zal tijdens runtime een error geven of in sommige gevallen zelfs geen error geven, maar onverwachte resultaten opleveren.

Beide hebben hun voor- en nadelen en zijn niet de focus van deze blog. Dit is wel de reden waarom de koppen in ons Polars-voorbeeld het type waarden in de kolom aangeven.

Nu, terug naar waar we voor kwamen. De performance:

TaskPython PandasRust PolarsDifference
Het bestand openen en de eerste 5 rijen tonen250,52 Seconds39,98 Seconds210,54 seconds

Pandas was een beetje sneller dan toen wij de vorm van het DataFrame gingen bekijken en Polars scoorde ongeveer hetzelfde. Polars wint weer.

3. Open het bestand en bereken de lengte van alle strings in de id kolom

Nu we de shape en de eerste paar rijen hebben verkend, gaan we data manipulatie doen. In dit geval maken we een nieuwe kolom met de lengtes van alle waarden in de id kolom.

Hier is hoe we dit zouden doen in Pandas:

def print_length_string_in_column():
 df = generate_df()
 df["vendor_id_length"] = df["id"].str.len()
 print(df.head())

Het resultaat ziet er alsvolgt uit:

idvendor_idpickup_datetime...vendor_id_length
id287542122016-03-14 17:24:55...9
id237739412016-06-12 00:43:35...9
id385852922016-01-19 11:35:24...9
id350467322016-04-06 19:32:31...9
id218102822016-03-26 13:30:55...9

Zoals je kunt zien is er aan het einde een nieuwe kolom toegevoegd: vendor_id_length waaruit blijkt dat ten minste voor de eerste 5 rijen die we zien de ID's allemaal 9 karaters lang zijn.

In Polars is de code in dit geval iets ingewikkelder dan Pandas:

fn print_length_strings_in_column() -> () {
 let lf = generate_lf();
 let df = lf
  .with_column(col("id").apply(
   |value| Ok(value.utf8()?
    .str_lengths()
    .into_series()), 
    GetOutput::from_type(DataType::Int32),)
    .alias("vendor_id_lengths"),
  )
  .collect()
  .unwrap();
 println!("{:?}", df.head(Some(5)))
}

Dat de code er ingewikkelder uitziet, heeft voornamelijk te maken met hoe Rust omgaat met ownership van variabelen om memory errors te voorkomen.

Als we nu naar het resultaat hier kijken, zien we het volgende:

id
---
str
vendor_id
---
i64
pickup_datetime
---
str
...vendor_id_length
---
u32
id287542122016-03-14 17:24:55...9
id237739412016-06-12 00:43:35...9
id385852922016-01-19 11:35:24...9
id350467322016-04-06 19:32:31...9
id218102822016-03-26 13:30:55...9

Polars heeft dezelfde kolom ook aan het einde van het DataFrame toegevoegd en zoals verwacht het correcte type 'u32' aan de kolom header toegevoegd.

Nu de performance:

TaskPython PandasRust PolarsDifference
Opening the file and show the shape of the DataFrame262,33 Seconds39,75 Seconds222,58 seconds

Weer wat we verwachten te zien. Pandas is veel trager dan Polars, maar dit kan meestal worden toegeschreven aan het openen van het CSV-bestand.

Laten we het openen van het CSV-bestand voor deze en de volgende use cases buiten beschouwing laten, aangezien we nu daadwerkelijk gegevens manipuleren en niet langer eenvoudige inspecties uitvoeren.

TaskPython PandasRust PolarsDifference
Open het bestand en bereken de lengte van alle strings in de id kolom16,83 Seconds1,77 Seconds14,61 seconds

Dat lijkt er meer op! Het openen van het bestand was inderdaad een bottleneck voor beide libraries. Nu krijgen we een beter idee van hoe lang het duurt om de data manipulatie onderdelen van de use case daadwerkelijk uit te voeren. Zoals we kunnen zien, blaast Polars Panda's nog steeds omver met een 9x tijdswinst.

4. Open het bestand en pas een functie toe op de kolom trip_duration om het getal door 60 te delen om van secondes naar minuten te gaan

Oké, volgende use-case. Een van de kolommen geeft de ritduur van de taxiritten in seconden weer. Laten we deze getallen delen door 60 zodat we een nieuwe kolom kunnen maken met de tijd in minuten.

In Pandas is dit hoe je het zou doen:

def convert_trip_duration_to_minutes():
df = generate_df()
df["trip_duration_minutes"] = df["trip_duration"].apply(
 lambda duration_seconds: duration_seconds / 60
)
print(df.head())

De resultaten laten inderdaad een nieuw toegevoegde kolom zien met de tijd in minuten zoals verwacht.

idvendor_id...trip_durationtrip_duration_minutes
id28754212...4557.583333
id23773941...66311.050000
id38585292...212435.400000
id35046732...4297.150000
id21810282...4357.250000

Nu Polars:

fn convert_trip_duration_to_minutes() -> () {
let lf = generate_lf();
let df = lf
 .with_column(
  col("trip_duration").apply(|t| { 
   Ok( 
   (t.cast(&DataType::Float64).unwrap().f64().unwrap() / 60 as f64)
    .into_series(),
   )
}, GetOutput::from_type(DataType::Float64),)
 .alias("trip_duration_minutes"),)
 .collect()
 .unwrap();
println!("{:?}", df.head(Some(5)))
}

Nogmaals, de code ziet er iets gecompliceerder uit dan het voorbeeld van Pandas, maar het is vooral te wijten aan hoe Rust werkt. De output is echter precies wat we verwachtten:

id
---
str
vendor_id
---
i64
...trip_duration
---
i64
trip_duration_minutes
---
f64
id28754212...4557.583333
id23773941...66311.05
id38585292...212435.4
id35046732...4297.15
id21810282...4357.25

Nu de performance. Laten we beginnen met de test waarbij we het openen van de CSV bestand meenmen:

TaskPython PandasRust PolarsDifference
Open het bestand en pas een functie toe om van secondes naar minuten te gaan280,09 Seconds38,6 Seconds241,49 seconds

En de timing van alleen de data manipulatie zonder het openen van het bestand te tellen:

TaskPython PandasRust PolarsDifference
Pas een functie toe om van secondes naar minuten te gaan14,07 Seconds1,12 Seconds12,95 seconds

Zoals verwacht verslaat Polars Pandas ook in deze test.

5. Het bestand openen en alle rijen uitfilteren met een reisduur korter dan 500 seconden

Oké, nu gaan we kijken naar het verkrijgen van een subset van de data uit onze volledige dataset. Dit is een veel voorkomende use case en iets dat meerdere keren voorkomt binnen data pipelines om verschillende doelen te bereiken.

In Panda's ziet de code er vrij eenvoudig uit:

def filter_out_trip_duration_500_seconds():
 df = generate_df()
 filtered_df = df[df["trip_duration"] >= 500]
 print(filtered_df.head())

En zoals we kunnen zien in de kolom trip_duration, zijn alle waarden hoger dan 500.

idvendor_idpickup_datetime...trip_duration
id237739412016-06-12 00:43:35...663
id385852922016-01-19 11:35:24...2124
id132460322016-05-21 07:54:58...1551
id001289122016-03-10 21:45:01...1225
id143637122016-05-10 22:08:41...1274

Deze keer is de Polars code ook vrij eenvoudig:

fn filter_out_trip_duration_500_seconds() -> () {
 let count: i64 = 500;
 let lf = generate_lf();
 let df = lf
  .filter(col("trip_duration").gt_eq(lit(count)))
  .collect()
  .unwrap();
 println!("{:?}", df.head(Some(5)));
}

De trip_duration kolom toont hier hetzelfde verwachte resultaat:

id
---
str
vendor_id
---
i64
pickup_datetime
---
str
...trip_duration
---
i64
id237739412016-06-12 00:43:35...663
id385852922016-01-19 11:35:24...2124
id132460322016-05-21 07:54:58...1551
id001289122016-03-10 21:45:01...1225
id143637122016-05-10 22:08:411274

Nu voor de performance inclusief het openen van het bestand:

TaskPython PandasRust PolarsDifference
Opening the file and filtering out all rows with a trip duration shorther than 500 seconds314,89 Seconds27,34 Seconds287,55 seconds

En zonder het bestand te openen:

TaskPython PandasRust PolarsDifference
Filtering out all rows with a trip duration shorther than 500 seconds51,34 Seconds15,01 Seconds36,33 seconds

Hier zien we een behoorlijk groot verschil tussen Polars en Pandas als we de timing bekijken inclusief het bestand te openen. Het gemiddelde van de Pandas ligt hoger dan de vorige use cases en dat van Polars lager. Waarom dit het geval is durf ik niet met zekerheid te zeggen, maar ik vermoed dat het te maken heeft met de manier waarop de libraries omgaan met het scannen van alle rijen in een DataFrame.

6. Open het bestand, filter alle rijen uit met een 'Y'-waarde in de kolom 'store_and_fwd_flag', groepeer op ID en bereken de gemiddelde duur

De laatste use case is de meest complexe binnen de context van waar we naar hebben gekeken. We filteren een subset van de data uit, groeperen op 'id' kolom en berekenen uiteindelijk de gemiddelde duur van de taxirit.

Zoals alle andere voorbeelden, maakt Pandas dit vrij eenvoudig:

def filter_group_and_mean():
 df = generate_df()
 df = df[df["store_and_fwd_flag"] != "Y"]
 df_mean = df.groupby(["id"])["trip_duration"].mean()
 print(df_mean.head())

Het resultaat ziet er ook uit zoals verwacht:

idtrip_duration
id00000011105.0
id00000031046.0
id0000005368.0
id0000008303.0
id0000009547.0

Deze keer maakt Polars dit ook vrij eenvoudig:

fn filter_group_and_mean() -> () {
 let lf = generate_lf();
 let df = lf
  .filter(col("store_and_fwd_flag").eq(lit("N")))
  .groupby([col("id")])
  .agg([col("trip_duration").mean()])
  .sort("id", Default::default())
  .collect()
  .unwrap();
 println!("{:?}", df.head(Some(5)))
}

En geeft dezelfde resultaten terug:

id
---
str
trip_duration
---
f64
id00000011105.0
id00000031046.0
id0000005368.0
id0000008303.0
id0000009547.0

De performance voor deze use cases zijn in lijn met wat we hebben gezien. Timing inclusief openen van het bestand:

TaskPython PandasRust PolarsDifference
Open het bestand, filter alle rijen uit met een 'Y'-waarde in de kolom 'store_and_fwd_flag', groepeer op ID en bereken de gemiddelde duur330,75 Seconds18,30 Seconds287,55 seconds

En zonder het bestand te openen:

TaskPython PandasRust PolarsDifference
Filter alle rijen uit met een 'Y'-waarde in de kolom 'store_and_fwd_flag', groepeer op ID en bereken de gemiddelde duur88,40 Seconden7,22 Seconden287,55 Seconden

Dus zelfs de meest complexe use-case die we hebben bekeken, is geen partij voor Polars. In feite is het een van de snellere use cases als we kijken naar de tijd die nodig is, inclusief het openen van het bestand.

Conclusie

We hebben gekeken naar zes verschillende use-cases die variëren in complexiteit. Elk maakte duidelijk dat Polars beter presteert dan Pandas. Maar dit is natuurlijk niet het hele verhaal.

Code complexiteit

Hoewel het werken met Polars vrij vergelijkbaar is met het werken met Pandas, er is veel functionaliteit die op dezelfde manier werkt, de code was bijna nooit zo eenvoudig als in Pandas. Dit heeft natuurlijk te maken met het feit dat we Rust in onze voorbeelden hebben gebruikt, wat simpelweg meer code vereist om zaken als variabel ownership correct af te handelen.

Dit komt met de toegevoegde bonus dat de code zelf robusteer is: de meeste fouten worden opgevangen tijdens buildtime, niet tijdens runtime wanneer de code mogelijk al in productie staat.

Ervarenheid met de library moet dus een overweging zijn voordat je een keuze maakt.

Pandas versnellen

Zoals sommige lezers misschien al hebben opgemerkt, gebruiken de Pandas voorbeelden meestal de standaard functies die gericht zijn op gebruiksgemak en niet op prestaties. Er zijn een aantal manieren om Pandas sneller te maken om zo dichter bij Polars te komen (of misschien zelfs te overtreffen). Je kunt de query's parallel uitvoeren met Dask, de ingebouwde functie to_numpy() gebruiken om een DataFrame naar een int64 NumPy object te converteren en Cython zelfs rechtstreeks gebruiken.

Hoewel er tal van opties zijn, is de realiteit dat vaak wanneer iets is gebouwd met de standaardfuncties, er niet vaak naar de code wordt gekeken.

Dit is wat Polars interessant maakt, je kunt de standaard functies gebruiken om goede performance te krijgen zonder gebruik te maken van trucjes.

Wat nu te kiezen?

Het hangt er van af.

Hoewel Polars betere performance biedt, is het kiezen ervan niet zo eenvoudig. Als je regelmatig een grote hoeveelheid data verwerkt, bijvoorbeeld sensordata, onderzoeksdata of financiële data, dan is het de moeite waard om Polars te gebruiken om de effectiviteit van je data pipelines te vergroten. Vooral in een zakelijke omgeving kan de runtime van een programma letterlijk worden vertaald naar het besparen van geld. Meestal omdat er minder cloud resources nodig zijn.

Aan de andere kant, als je een eenmalig script nodig hebt om een dataset te analyseren, zodat je door kunt gaan met ander werk, is het misschien niet de moeite waard om over te stappen op Polars. Vooral in de wetenschappelijke gemeenschap waar veel analyses slechts een of twee keer worden gedaan, is Pandas een geweldig hulpmiddel.

Er bestaat ook een compromie, namelijk de eerder benoemde Python versie van de Polars library. In een aankomende blog post zullen we hier in detail naar kijken.

Dus wat je ook kiest, ik zou sowieso aanraden om Polars te bekijken. Het is een interessant hulpmiddel en als je Rust onder de knie hebt, een plezier om mee te werken!