Window functions
Window functions are expressions with superpowers. They allow you to perform aggregations on groups
within the context select. Let's get a feel for what that means.
First, we load a PokΓ©mon dataset:
import polars as pl
types = (
"Grass Water Fire Normal Ground Electric Psychic Fighting Bug Steel "
"Flying Dragon Dark Ghost Poison Rock Ice Fairy".split()
)
type_enum = pl.Enum(types)
# then let's load some csv data with information about pokemon
pokemon = pl.read_csv(
"docs/assets/data/pokemon.csv",
).cast({"Type 1": type_enum, "Type 2": type_enum})
print(pokemon.head())
CsvReader Β· Available on feature csv
use polars::prelude::*;
use reqwest::blocking::Client;
let data: Vec<u8> = Client::new()
.get("https://gist.githubusercontent.com/ritchie46/cac6b337ea52281aa23c049250a4ff03/raw/89a957ff3919d90e6ef2d34235e6bf22304f3366/pokemon.csv")
.send()?
.text()?
.bytes()
.collect();
let file = std::io::Cursor::new(data);
let df = CsvReadOptions::default()
.with_has_header(true)
.into_reader_with_file_handle(file)
.finish()?;
println!("{}", df.head(Some(5)));
shape: (5, 13)
βββββββ¬ββββββββββββββββββββββββ¬βββββββββ¬βββββββββ¬ββββ¬ββββββββββ¬ββββββββ¬βββββββββββββ¬ββββββββββββ
β # β Name β Type 1 β Type 2 β β¦ β Sp. Def β Speed β Generation β Legendary β
β --- β --- β --- β --- β β --- β --- β --- β --- β
β i64 β str β enum β enum β β i64 β i64 β i64 β bool β
βββββββͺββββββββββββββββββββββββͺβββββββββͺβββββββββͺββββͺββββββββββͺββββββββͺβββββββββββββͺββββββββββββ‘
β 1 β Bulbasaur β Grass β Poison β β¦ β 65 β 45 β 1 β false β
β 2 β Ivysaur β Grass β Poison β β¦ β 80 β 60 β 1 β false β
β 3 β Venusaur β Grass β Poison β β¦ β 100 β 80 β 1 β false β
β 3 β VenusaurMega Venusaur β Grass β Poison β β¦ β 120 β 80 β 1 β false β
β 4 β Charmander β Fire β null β β¦ β 50 β 65 β 1 β false β
βββββββ΄ββββββββββββββββββββββββ΄βββββββββ΄βββββββββ΄ββββ΄ββββββββββ΄ββββββββ΄βββββββββββββ΄ββββββββββββ
Operations per group
Window functions are ideal when we want to perform an operation within a group. For instance,
suppose we want to rank our PokΓ©mon by the column βSpeedβ. However, instead of a global ranking, we
want to rank the speed within each group defined by the column βType 1β. We write the expression to
rank the data by the column βSpeedβ and then we add the function over to specify that this should
happen over the unique values of the column βType 1β:
result = pokemon.select(
pl.col("Name", "Type 1"),
pl.col("Speed").rank("dense", descending=True).over("Type 1").alias("Speed rank"),
)
print(result)
let result = df
.clone()
.lazy()
.select([
col("Name"),
col("Type 1"),
col("Speed")
.rank(
RankOptions {
method: RankMethod::Dense,
descending: true,
},
None,
)
.over(["Type 1"])?
.alias("Speed rank"),
])
.collect()?;
println!("{result}");
shape: (163, 3)
βββββββββββββββββββββββββ¬ββββββββββ¬βββββββββββββ
β Name β Type 1 β Speed rank β
β --- β --- β --- β
β str β enum β u32 β
βββββββββββββββββββββββββͺββββββββββͺβββββββββββββ‘
β Bulbasaur β Grass β 6 β
β Ivysaur β Grass β 3 β
β Venusaur β Grass β 1 β
β VenusaurMega Venusaur β Grass β 1 β
β Charmander β Fire β 7 β
β β¦ β β¦ β β¦ β
β Moltres β Fire β 5 β
β Dratini β Dragon β 3 β
β Dragonair β Dragon β 2 β
β Dragonite β Dragon β 1 β
β Mewtwo β Psychic β 2 β
βββββββββββββββββββββββββ΄ββββββββββ΄βββββββββββββ
To help visualise this operation, you may imagine that Polars selects the subsets of the data that share the same value for the column βType 1β and then computes the ranking expression only for those values. Then, the results for that specific group are projected back to the original rows and Polars does this for all of the existing groups. The diagram below highlights the ranking computation for the PokΓ©mon with βType 1β equal to βGrassβ.
Note how the row for the PokΓ©mon βGolbatβ has a βSpeedβ value of 90, which is greater than the
value 80 of the PokΓ©mon βVenusaurβ, and yet the latter was ranked 1 because βGolbatβ and βVenusarβ
do not share the same value for the column βType 1β.
The function over accepts an arbitrary number of expressions to specify the groups over which to
perform the computations. We can repeat the ranking above, but over the combination of the columns
βType 1β and βType 2β for a more fine-grained ranking:
shape: (163, 4)
βββββββββββββββββββββββββ¬ββββββββββ¬βββββββββ¬βββββββββββββ
β Name β Type 1 β Type 2 β Speed rank β
β --- β --- β --- β --- β
β str β enum β enum β u32 β
βββββββββββββββββββββββββͺββββββββββͺβββββββββͺβββββββββββββ‘
β Bulbasaur β Grass β Poison β 6 β
β Ivysaur β Grass β Poison β 3 β
β Venusaur β Grass β Poison β 1 β
β VenusaurMega Venusaur β Grass β Poison β 1 β
β Charmander β Fire β null β 7 β
β β¦ β β¦ β β¦ β β¦ β
β Moltres β Fire β Flying β 2 β
β Dratini β Dragon β null β 2 β
β Dragonair β Dragon β null β 1 β
β Dragonite β Dragon β Flying β 1 β
β Mewtwo β Psychic β null β 2 β
βββββββββββββββββββββββββ΄ββββββββββ΄βββββββββ΄βββββββββββββ
In general, the results you get with the function over can also be achieved with
an aggregation followed by a call to the function explode, although the rows
would be in a different order:
result = (
pokemon.group_by("Type 1")
.agg(
pl.col("Name"),
pl.col("Speed").rank("dense", descending=True).alias("Speed rank"),
)
.select(pl.col("Name"), pl.col("Type 1"), pl.col("Speed rank"))
.explode("Name", "Speed rank")
)
print(result)
// Contribute the Rust translation of the Python example by opening a PR.
shape: (163, 3)
βββββββββββββββββββββ¬βββββββββ¬βββββββββββββ
β Name β Type 1 β Speed rank β
β --- β --- β --- β
β str β enum β u32 β
βββββββββββββββββββββͺβββββββββͺβββββββββββββ‘
β Gastly β Ghost β 4 β
β Haunter β Ghost β 3 β
β Gengar β Ghost β 2 β
β GengarMega Gengar β Ghost β 1 β
β Jynx β Ice β 1 β
β β¦ β β¦ β β¦ β
β Ponyta β Fire β 5 β
β Rapidash β Fire β 1 β
β Magmar β Fire β 4 β
β Flareon β Fire β 7 β
β Moltres β Fire β 5 β
βββββββββββββββββββββ΄βββββββββ΄βββββββββββββ
This shows that, usually, group_by and over produce results of different shapes:
group_byusually produces a resulting dataframe with as many rows as groups used for aggregating; andoverusually produces a dataframe with the same number of rows as the original.
The function over does not always produce results with the same number of rows as the original
dataframe, and that is what we explore next.
Mapping results to dataframe rows
The function over accepts a parameter mapping_strategy that determines how the results of the
expression over the group are mapped back to the rows of the dataframe.
group_to_rows
The default behaviour is "group_to_rows": the result of the expression over the group should be
the same length as the group and the results are mapped back to the rows of that group.
If the order of the rows is not relevant, the option "explode" is more performant. Instead of
mapping the resulting values to the original rows, Polars creates a new dataframe where values from
the same group are next to each other. To help understand the distinction, consider the following
dataframe:
shape: (6, 3)
βββββββββββ¬ββββββββββ¬βββββββ
β athlete β country β rank β
β --- β --- β --- β
β str β str β i64 β
βββββββββββͺββββββββββͺβββββββ‘
β A β PT β 6 β
β B β NL β 1 β
β C β NL β 5 β
β D β PT β 4 β
β E β PT β 2 β
β F β NL β 3 β
βββββββββββ΄ββββββββββ΄βββββββ
We can sort the athletes by rank within their own countries. If we do so, the Dutch athletes were in the second, third, and sixth, rows, and they will remain there. What will change is the order of the names of the athletes, which goes from βBβ, βCβ, and βFβ, to βBβ, βFβ, and βCβ:
shape: (6, 3)
βββββββββββ¬βββββββ¬ββββββββββ
β athlete β rank β country β
β --- β --- β --- β
β str β i64 β str β
βββββββββββͺβββββββͺββββββββββ‘
β E β 2 β PT β
β B β 1 β NL β
β F β 3 β NL β
β D β 4 β PT β
β A β 6 β PT β
β C β 5 β NL β
βββββββββββ΄βββββββ΄ββββββββββ
The diagram below represents this transformation:
explode
If we set the parameter mapping_strategy to "explode", then athletes of the same country are
grouped together, but the final order of the rows β with respect to the countries β will not be the
same, as the diagram shows:
Because Polars does not need to keep track of the positions of the rows of each group, using
"explode" is typically faster than "group_to_rows". However, using "explode" also requires
more care because it implies reordering the other columns that we wish to keep. The code that
produces this result follows
shape: (6, 3)
βββββββββββ¬ββββββββββ¬βββββββ
β athlete β country β rank β
β --- β --- β --- β
β str β str β i64 β
βββββββββββͺββββββββββͺβββββββ‘
β E β PT β 2 β
β D β PT β 4 β
β A β PT β 6 β
β B β NL β 1 β
β F β NL β 3 β
β C β NL β 5 β
βββββββββββ΄ββββββββββ΄βββββββ
join
Another possible value for the parameter mapping_strategy is "join", which aggregates the
resulting values in a list and repeats the list over all rows of the same group:
shape: (6, 3)
βββββββββββ¬ββββββββββ¬ββββββββββββ
β athlete β country β rank β
β --- β --- β --- β
β str β str β list[i64] β
βββββββββββͺββββββββββͺββββββββββββ‘
β A β PT β [2, 4, 6] β
β B β NL β [1, 3, 5] β
β C β NL β [1, 3, 5] β
β D β PT β [2, 4, 6] β
β E β PT β [2, 4, 6] β
β F β NL β [1, 3, 5] β
βββββββββββ΄ββββββββββ΄ββββββββββββ
Windowed aggregation expressions
In case the expression applied to the values of a group produces a scalar value, the scalar is broadcast across the rows of the group:
result = pokemon.select(
pl.col("Name", "Type 1", "Speed"),
pl.col("Speed").mean().over(pl.col("Type 1")).alias("Mean speed in group"),
)
print(result)
let result = df
.clone()
.lazy()
.select([
col("Name"),
col("Type 1"),
col("Speed"),
col("Speed")
.mean()
.over(["Type 1"])?
.alias("Mean speed in group"),
])
.collect()?;
println!("{result}");
shape: (163, 4)
βββββββββββββββββββββββββ¬ββββββββββ¬ββββββββ¬ββββββββββββββββββββββ
β Name β Type 1 β Speed β Mean speed in group β
β --- β --- β --- β --- β
β str β enum β i64 β f64 β
βββββββββββββββββββββββββͺββββββββββͺββββββββͺββββββββββββββββββββββ‘
β Bulbasaur β Grass β 45 β 54.230769 β
β Ivysaur β Grass β 60 β 54.230769 β
β Venusaur β Grass β 80 β 54.230769 β
β VenusaurMega Venusaur β Grass β 80 β 54.230769 β
β Charmander β Fire β 65 β 86.285714 β
β β¦ β β¦ β β¦ β β¦ β
β Moltres β Fire β 90 β 86.285714 β
β Dratini β Dragon β 50 β 66.666667 β
β Dragonair β Dragon β 70 β 66.666667 β
β Dragonite β Dragon β 80 β 66.666667 β
β Mewtwo β Psychic β 130 β 99.25 β
βββββββββββββββββββββββββ΄ββββββββββ΄ββββββββ΄ββββββββββββββββββββββ
More examples
For more exercises, below are some window functions for us to compute:
- sort all PokΓ©mon by type;
- select the first
3PokΓ©mon per type as"Type 1"; - sort the PokΓ©mon within a type by speed in descending order and select the first
3as"fastest/group"; - sort the PokΓ©mon within a type by attack in descending order and select the first
3as"strongest/group"; and - sort the PokΓ©mon within a type by name and select the first
3as"sorted_by_alphabet".
result = pokemon.sort("Type 1").select(
pl.col("Type 1").head(3).over("Type 1", mapping_strategy="explode"),
pl.col("Name")
.sort_by(pl.col("Speed"), descending=True)
.head(3)
.over("Type 1", mapping_strategy="explode")
.alias("fastest/group"),
pl.col("Name")
.sort_by(pl.col("Attack"), descending=True)
.head(3)
.over("Type 1", mapping_strategy="explode")
.alias("strongest/group"),
pl.col("Name")
.sort()
.head(3)
.over("Type 1", mapping_strategy="explode")
.alias("sorted_by_alphabet"),
)
print(result)
let result = df
.lazy()
.select([
col("Type 1")
.head(Some(3))
.over_with_options(Some(["Type 1"]), None, WindowMapping::Explode)?
.explode(ExplodeOptions {
empty_as_null: false,
keep_nulls: false,
}),
col("Name")
.sort_by(
["Speed"],
SortMultipleOptions::default().with_order_descending(true),
)
.head(Some(3))
.over_with_options(Some(["Type 1"]), None, WindowMapping::Explode)?
.explode(ExplodeOptions {
empty_as_null: false,
keep_nulls: false,
})
.alias("fastest/group"),
col("Name")
.sort_by(
["Attack"],
SortMultipleOptions::default().with_order_descending(true),
)
.head(Some(3))
.over_with_options(Some(["Type 1"]), None, WindowMapping::Explode)?
.explode(ExplodeOptions {
empty_as_null: false,
keep_nulls: false,
})
.alias("strongest/group"),
col("Name")
.sort(Default::default())
.head(Some(3))
.over_with_options(Some(["Type 1"]), None, WindowMapping::Explode)?
.explode(ExplodeOptions {
empty_as_null: false,
keep_nulls: false,
})
.alias("sorted_by_alphabet"),
])
.collect()?;
println!("{result:?}");
shape: (43, 4)
ββββββββββ¬ββββββββββββββββββββββββ¬ββββββββββββββββββββββββ¬ββββββββββββββββββββββββββ
β Type 1 β fastest/group β strongest/group β sorted_by_alphabet β
β --- β --- β --- β --- β
β enum β str β str β str β
ββββββββββͺββββββββββββββββββββββββͺββββββββββββββββββββββββͺββββββββββββββββββββββββββ‘
β Grass β Venusaur β Victreebel β Bellsprout β
β Grass β VenusaurMega Venusaur β VenusaurMega Venusaur β Bulbasaur β
β Grass β Victreebel β Exeggutor β Exeggcute β
β Water β Starmie β GyaradosMega Gyarados β Blastoise β
β Water β Tentacruel β Kingler β BlastoiseMega Blastoise β
β β¦ β β¦ β β¦ β β¦ β
β Rock β Kabutops β Kabutops β Geodude β
β Ice β Jynx β Articuno β Articuno β
β Ice β Articuno β Jynx β Jynx β
β Fairy β Clefable β Clefable β Clefable β
β Fairy β Clefairy β Clefairy β Clefairy β
ββββββββββ΄ββββββββββββββββββββββββ΄ββββββββββββββββββββββββ΄ββββββββββββββββββββββββββ