How DataFrames.jl helps fighting piracy
Introduction
During JuliaCon 2022 I gave a tutorial on DataFrames.jl. You can find its recording on YouTube and all source code on GitHub.
This post is a follow up to one of the questions that I got during the workshop. The topic of the discussion was applying the same function to many columns of a data frame. Since the question is quite technical I will first give you a brief introduction to the topic and next dive deep into the issue.
I hope that this will be a useful material even for people that do not use DataFrames.jl as we will explore the consequences of the avoid type piracy rule that the Julia Manual recommends.
The post was written under Julia 1.7.2, DataFrames.jl 1.3.4.
How can one apply a function to multiple columns in DataFrames.jl?
Let us create a sample data frame first:
julia> using DataFrames
julia> df = DataFrame(a1=1:2, b1=3:4, a2=5:6)
2×3 DataFrame
Row │ a1 b1 a2
│ Int64 Int64 Int64
─────┼─────────────────────
1 │ 1 3 5
2 │ 2 4 6
Next I define a simple function that allows us to inspect what arguments it received:
julia> inspect(x...) = Ref(x)
inspect (generic function with 1 method)
In this function I wrap a tuple of arguments in Ref
as in DataFrames.jl
Ref
protects the wrapped value against being expanded.
Let us check this function in action on some simple examples of combine
transformation (if you do not know the operation specification syntax please
check my tutorial on DataFrames.jl I have linked above for an
introduction):
julia> combine(df,
:a1 => inspect,
r"a" => inspect,
Cols(endswith("1")) => inspect)
1×3 DataFrame
Row │ a1_inspect a1_a2_inspect a1_b1_inspect
│ Tuple… Tuple… Tuple…
─────┼────────────────────────────────────────────────
1 │ ([1, 2],) ([1, 2], [5, 6]) ([1, 2], [3, 4])
julia> combine(df,
AsTable(:a1) => inspect,
AsTable(r"a") => inspect,
AsTable(Cols(endswith("1"))) => inspect)
1×3 DataFrame
Row │ a1_inspect a1_a2_inspect a1_b1_inspect
│ Tuple… Tuple… Tuple…
────┼─────────────────────────────────────────────────────────────────────
1 │ ((a1=[1, 2],),) ((a1=[1, 2], a2=[5, 6]),) ((a1=[1, 2], b1=[3, 4]),)
What we can see in these examples is the following:
r"a"
picks all columns whose names match this regular expression (in this case contain"a"
);Cols(endswith("1"))
picks all columns whose names meet theendswith("1")
predicate (that is, end with"1"
);- by default the selected columns are passed as multiple positional arguments to the executed function;
- if you wrap the selected columns in
AsTable
then they get passed a single positional argument in aNamedTuple
.
It is crucially important to understand at this point that r"a"
and
Cols(endswith("1"))
column selectors do not get resolved before being passed
to combine
:
julia> r"a" => inspect
r"a" => inspect
julia> Cols(endswith("1")) => inspect
Cols{Tuple{Base.Fix2{typeof(endswith), String}}}((Base.Fix2{typeof(endswith), String}(endswith, "1"),)) => inspect
What I mean by resolved is that the expression will get its meaning (i.e. will
determine which columns it actually selects, inside the combine
function in
the context of the data frame that is passed as a first argument to combine
).
Having seen these basic examples, let us check how one can apply a given function to multiple columns individually. You can do it e.g. like this:
julia> combine(df, :a1 => inspect, :a2 => inspect)
1×2 DataFrame
Row │ a1_inspect a2_inspect
│ Tuple… Tuple…
─────┼────────────────────────
1 │ ([1, 2],) ([5, 6],)
but there is an easier way. You can do it like this:
julia> combine(df, [:a1, :a2] .=> inspect)
1×2 DataFrame
Row │ a1_inspect a2_inspect
│ Tuple… Tuple…
─────┼────────────────────────
1 │ ([1, 2],) ([5, 6],)
The point is that combine
(and other functions in DataFrames.jl) accept
vectors and matrices of operation specification syntax expressions and above
I create such a vector using broadcasting with .=>
.
Let us check:
julia> [:a1, :a2] .=> inspect
2-element Vector{Pair{Symbol, typeof(inspect)}}:
:a1 => inspect
:a2 => inspect
julia> combine(df, [:a1 => inspect, :a2 => inspect])
1×2 DataFrame
Row │ a1_inspect a2_inspect
│ Tuple… Tuple…
─────┼────────────────────────
1 │ ([1, 2],) ([5, 6],)
Having the information I have shared above we are now ready to face the pirates.
Using column selectors to pick columns to which we apply the function
We have seen above that r"a"
and Cols(endswith("1"))
do not get resolved
before they get passed to combine
. Therefore how can we apply the inspect
function to all columns selected by them?
The basic approach is to use the names
function like this:
julia> names(df, r"a")
2-element Vector{String}:
"a1"
"a2"
julia> names(df, r"a") .=> inspect
2-element Vector{Pair{String, typeof(inspect)}}:
"a1" => inspect
"a2" => inspect
julia> combine(df, names(df, r"a") .=> inspect)
1×2 DataFrame
Row │ a1_inspect a2_inspect
│ Tuple… Tuple…
─────┼────────────────────────
1 │ ([1, 2],) ([5, 6],)
This method works, but it is a bit heavy-handed, as it requires us to use
the names
function that needs the df
as its first argument. This duplication
of information is not optimal.
Therefore we are tempted to skip the names(df, r"a")
and just write
r"a" .=> inspect
. Let us see what it gives us:
julia> combine(df, r"a" .=> inspect)
1×1 DataFrame
Row │ a1_a2_inspect
│ Tuple…
─────┼──────────────────
1 │ ([1, 2], [5, 6])
julia> r"a" .=> inspect
r"a" => inspect
Unfortunately this is not what we expected. The reason is that, as you can see,
r"a"
is treated by broadcasting as a scalar. Here we need to note that
the r"a" .=> inspect
is resolved before the value of this expression is
passed to combine
, so evaluation of this expression cannot be made by Julia
in the context of the df
data frame.
Let us check what happens if we use the same approach with
Cols(endswith("1")) .=> inspect
:
julia> combine(df, Cols(endswith("1")) .=> inspect)
1×2 DataFrame
Row │ a1_inspect b1_inspect
│ Tuple… Tuple…
─────┼────────────────────────
1 │ ([1, 2],) ([3, 4],)
This time we got what we wanted. It seems that this expression had to be
resolved only after it got passed to combine
. Let us inspect it:
julia> Cols(endswith("1")) .=> inspect
DataAPI.BroadcastedSelector{Cols{Tuple{Base.Fix2{typeof(endswith), String}}}}(Cols{Tuple{Base.Fix2{typeof(endswith), String}}}((Base.Fix2{typeof(endswith), String}(endswith, "1"),))) => inspect
We see some strange DataAPI.BroadcastedSelector
type. Its role is exactly to
delay the final resolution of broadcasted operation only after the expression
is processed in combine
. When combine
sees a value of type
DataAPI.BroadcastedSelector
it does its post-processing in the context
of the df
data frame to give us the desired result.
So how does this relate to type piracy? The answer is:
- we could have made in DataFrames.jl
Cols(endswith("1")) .=> inspect
to have a delayed broadcasting behavior because theCols
selector is defined in DataAPI.jl so we can customize how it is handled in broadcasting; - we could not do the same for
r"a"
, because theRegex
type is defined in Base Julia, and it has a defined broadcasting behavior. Packages, like DataFrames.jl should not change howr"a"
is handled by broadcasting because it would be type piracy.
Conclusions
So what do we learn from the today’s post?
- for DataFrames.jl users: remember that using a regular expression as a column
selector, while convenient, does not have the nice broadcasting support as
other selectors, like
Cols
,Not
,Between
, andAll
, have; - for general audience: Julia is really flexible and allows packages to customize almost everything (in our case how broadcasting works); the only limitation is that such customization should be done on the types that you define yourself; changing the behavior of types defined in external packages is not recommended and it is called type piracy.
If you want to learn what exactly happens when you pass
Cols(endswith("1")) .=> inspect
to combine
you can check it here and here.