I extend and redo the analysis with side-by-side comparisons across R (base), R (tidyverse), Python (pandas), Python (polars), and Julia (TidierData.jl), using the newest versions of each at the time of writing.
To showcase the difference between them we use a concrete example, where we have to make small changes throughout.
Note
For pandas, we write our code using the fluent method chaining API rather than the more “imperative” style, which tends to repeat df over and over in statements like df[df["this"] == "that"] = calc_some(df["other_thing"]). We also keep all the data in the data frame proper rather than pushing it into the index, since data stored in the index tends to get in the way when processing it further or plotting it.
The task
Say we have a CSV of purchases with a country, an amount, and a discount:
(Note that this method, pandas.Series.sum(), is not the same as pandas.DataFrame.sum(), or numpy.sum(), or the built-in sum function, each of which has different arguments and behaviors.)
purchases_pl["amount"].sum()
10782.310000000001
sum(purchases_jl.amount)
10782.31
At this point there is little to distinguish the APIs.
aggregate() gets us most of the way there, but it leaves the sum column named after the original variable, so we have to rename it ourselves. The _ placeholder lets us route purchases into the data argument:
purchases |>aggregate(amount ~ country, data = _, FUN = sum) |>setNames(c("country", "total"))
country total
1 France 1477.51
2 Germany 1435.82
3 Sweden 2626.99
4 USA 5241.99
# A tibble: 4 × 2
country total
<chr> <dbl>
1 France 1478.
2 Germany 1436.
3 Sweden 2627.
4 USA 5242.
Okay, easy enough right?
(purchases .groupby("country")["amount"] .sum())
country
France 1477.51
Germany 1435.82
Sweden 2626.99
USA 5241.99
Name: amount, dtype: float64
Oh no, what happened? The output has now turned into a pandas.Series, not a data frame, and country got moved to the index. We can solve this by using .reset_index(). Also, we’re not happy with the amount column name, but .sum() does not allow us to specify a different name. Instead of .sum() we can use the .agg() method to get around this.
shape: (4, 2)
┌─────────┬─────────┐
│ country ┆ total │
│ --- ┆ --- │
│ str ┆ f64 │
╞═════════╪═════════╡
│ Germany ┆ 1435.82 │
│ France ┆ 1477.51 │
│ Sweden ┆ 2626.99 │
│ USA ┆ 5241.99 │
└─────────┴─────────┘
The structure mirrors tidyverse/TidierData.jl closely, but each expression needs to wrap column references in pl.col() as a string, so not as nice as those.
Note that polars doesn’t preserve ordering. You need to explicitly set maintain_order=True in .group_by() for that.
4×2 DataFrame
Row │ country total
│ String7 Float64
─────┼──────────────────
1 │ France 1477.51
2 │ Germany 1435.82
3 │ Sweden 2626.99
4 │ USA 5241.99
Already a bit annoying in pandas.
“And I guess I should deduct the discount.” As mentioned in the Python (pandas) block above I will from now on use #👈/👆/👇 to mark lines that changed/moved:
# A tibble: 4 × 2
country total
<chr> <dbl>
1 France 1404.
2 Germany 1364.
3 Sweden 976.
4 USA 4980.
Following the naive approach you end up with something ugly like this, since a DataFrameGroupBy object doesn’t have a .query() or boolean-indexing shortcut of its own, so filtering within groups needs .apply() again, and the surrounding pipeline has to be rebuilt around it:
(purchases .groupby("country") #👈 .apply(lambda df: df[df["amount"] <= df["amount"].median() *10]) #👈 .reset_index(level=0) #👈 pandas now drops "country" here, so it has to be pulled back out of the index .reset_index(drop=True) #👈 .assign(net=lambda df: df["amount"] - df["discount"]) .groupby("country")["net"] .sum() .reset_index(name="total"))
country total
0 France 1403.63
1 Germany 1364.02
2 Sweden 975.63
3 USA 4979.91
A more pandas idiomatic solution is the following:
4×2 DataFrame
Row │ country total
│ String7 Float64
─────┼──────────────────
1 │ France 1403.63
2 │ Germany 1364.02
3 │ Sweden 975.63
4 │ USA 4979.91
Conclusion
As we add grouping, transformations, filtering, and group-wise calculations, I want to be able to make a small change without having to restructure the rest of the pipeline. For this particular kind of analysis my ranking would be, as I tried to highlight above:
tidyverse and TidierData.jl. They make small changes in the analysis trivial to implement, and no need for strings when referencing columns.
base R and polars. They require a few more changes, but are still very reasonable.
pandas. Last by a large margin. None of the individual pandas lines are unreasonable in isolation, but stringing together this analysis is far less smooth than in any of the alternatives.
Tip
But polars is much faster than dplyr!
Not what this blogpost is about, but you can find a tidy-syntax interface to most backends, e.g. tidytable for data.table backend.
Versions used
Versions used in this post: R 4.6.1, dplyr (grammar backend for tidyverse) 1.2.1; Python 3.12.3, pandas 3.0.5, polars 1.43.2; Julia 1.12.7, TidierData.jl 0.17.2.