Studio Terabyte is een fullstack web development studio die oplossingen vindt en bouwt passend bij uw project
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.
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.
De tests zijn uitgevoerd op een MacBook Pro uit 2015 met de volgende relevante specificaties:
Alle testtijden zijn gemiddeld 3 opeenvolgende runs.
De volgende tests zijn uitgevoerd om de praktijk na te bootsen:
id
kolomtrip_duration
om het getal door 60 te delen om van secondes naar minuten te gaanDe 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)
}
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:
Task | Python Pandas | Rust Polars | Difference |
---|---|---|---|
Open het bestand en toon de vorm van de DataFrame | 274,75 Seconds | 38,97 Seconds | 235,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.
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:
id | vendor_id | pickup_datetime | ... | trip_duration |
---|---|---|---|---|
id2875421 | 2 | 2016-03-14 17:24:55 | ... | 455 |
id2377394 | 1 | 2016-06-12 00:43:35 | ... | 663 |
id3858529 | 2 | 2016-01-19 11:35:24 | ... | 2124 |
id3504673 | 2 | 2016-04-06 19:32:31 | ... | 429 |
id2181028 | 2 | 2016-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 |
---|---|---|---|---|
id2875421 | 2 | 2016-03-14 17:24:55 | ... | 455 |
id2377394 | 1 | 2016-06-12 00:43:35 | ... | 663 |
id3858529 | 2 | 2016-01-19 11:35:24 | ... | 2124 |
id3504673 | 2 | 2016-04-06 19:32:31 | ... | 429 |
id2181028 | 2 | 2016-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:
Task | Python Pandas | Rust Polars | Difference |
---|---|---|---|
Het bestand openen en de eerste 5 rijen tonen | 250,52 Seconds | 39,98 Seconds | 210,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.
id
kolomNu 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:
id | vendor_id | pickup_datetime | ... | vendor_id_length |
---|---|---|---|---|
id2875421 | 2 | 2016-03-14 17:24:55 | ... | 9 |
id2377394 | 1 | 2016-06-12 00:43:35 | ... | 9 |
id3858529 | 2 | 2016-01-19 11:35:24 | ... | 9 |
id3504673 | 2 | 2016-04-06 19:32:31 | ... | 9 |
id2181028 | 2 | 2016-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 |
---|---|---|---|---|
id2875421 | 2 | 2016-03-14 17:24:55 | ... | 9 |
id2377394 | 1 | 2016-06-12 00:43:35 | ... | 9 |
id3858529 | 2 | 2016-01-19 11:35:24 | ... | 9 |
id3504673 | 2 | 2016-04-06 19:32:31 | ... | 9 |
id2181028 | 2 | 2016-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:
Task | Python Pandas | Rust Polars | Difference |
---|---|---|---|
Opening the file and show the shape of the DataFrame | 262,33 Seconds | 39,75 Seconds | 222,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.
Task | Python Pandas | Rust Polars | Difference |
---|---|---|---|
Open het bestand en bereken de lengte van alle strings in de id kolom | 16,83 Seconds | 1,77 Seconds | 14,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.
trip_duration
om het getal door 60 te delen om van secondes naar minuten te gaanOké, 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.
id | vendor_id | ... | trip_duration | trip_duration_minutes |
---|---|---|---|---|
id2875421 | 2 | ... | 455 | 7.583333 |
id2377394 | 1 | ... | 663 | 11.050000 |
id3858529 | 2 | ... | 2124 | 35.400000 |
id3504673 | 2 | ... | 429 | 7.150000 |
id2181028 | 2 | ... | 435 | 7.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 |
---|---|---|---|---|
id2875421 | 2 | ... | 455 | 7.583333 |
id2377394 | 1 | ... | 663 | 11.05 |
id3858529 | 2 | ... | 2124 | 35.4 |
id3504673 | 2 | ... | 429 | 7.15 |
id2181028 | 2 | ... | 435 | 7.25 |
Nu de performance. Laten we beginnen met de test waarbij we het openen van de CSV bestand meenmen:
Task | Python Pandas | Rust Polars | Difference |
---|---|---|---|
Open het bestand en pas een functie toe om van secondes naar minuten te gaan | 280,09 Seconds | 38,6 Seconds | 241,49 seconds |
En de timing van alleen de data manipulatie zonder het openen van het bestand te tellen:
Task | Python Pandas | Rust Polars | Difference |
---|---|---|---|
Pas een functie toe om van secondes naar minuten te gaan | 14,07 Seconds | 1,12 Seconds | 12,95 seconds |
Zoals verwacht verslaat Polars Pandas ook in deze test.
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.
id | vendor_id | pickup_datetime | ... | trip_duration |
---|---|---|---|---|
id2377394 | 1 | 2016-06-12 00:43:35 | ... | 663 |
id3858529 | 2 | 2016-01-19 11:35:24 | ... | 2124 |
id1324603 | 2 | 2016-05-21 07:54:58 | ... | 1551 |
id0012891 | 2 | 2016-03-10 21:45:01 | ... | 1225 |
id1436371 | 2 | 2016-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 |
---|---|---|---|---|
id2377394 | 1 | 2016-06-12 00:43:35 | ... | 663 |
id3858529 | 2 | 2016-01-19 11:35:24 | ... | 2124 |
id1324603 | 2 | 2016-05-21 07:54:58 | ... | 1551 |
id0012891 | 2 | 2016-03-10 21:45:01 | ... | 1225 |
id1436371 | 2 | 2016-05-10 22:08:41 | 1274 |
Nu voor de performance inclusief het openen van het bestand:
Task | Python Pandas | Rust Polars | Difference |
---|---|---|---|
Opening the file and filtering out all rows with a trip duration shorther than 500 seconds | 314,89 Seconds | 27,34 Seconds | 287,55 seconds |
En zonder het bestand te openen:
Task | Python Pandas | Rust Polars | Difference |
---|---|---|---|
Filtering out all rows with a trip duration shorther than 500 seconds | 51,34 Seconds | 15,01 Seconds | 36,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.
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:
id | trip_duration |
---|---|
id0000001 | 1105.0 |
id0000003 | 1046.0 |
id0000005 | 368.0 |
id0000008 | 303.0 |
id0000009 | 547.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 |
---|---|
id0000001 | 1105.0 |
id0000003 | 1046.0 |
id0000005 | 368.0 |
id0000008 | 303.0 |
id0000009 | 547.0 |
De performance voor deze use cases zijn in lijn met wat we hebben gezien. Timing inclusief openen van het bestand:
Task | Python Pandas | Rust Polars | Difference |
---|---|---|---|
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 | 330,75 Seconds | 18,30 Seconds | 287,55 seconds |
En zonder het bestand te openen:
Task | Python Pandas | Rust Polars | Difference |
---|---|---|---|
Filter alle rijen uit met een 'Y'-waarde in de kolom 'store_and_fwd_flag', groepeer op ID en bereken de gemiddelde duur | 88,40 Seconden | 7,22 Seconden | 287,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.
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.
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.
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.
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!