After exploring several approaches to make Python->Julia variable passing more ergonomic while maintaining proper scoping, here are a couple more ideas. I think I agree with @Benny that the context manager approach is probably best avoided as it introduces too much “spooky action at a distance” and is not as transparent.
0. Current approach using closures:
np_weights = # ...
elementwise_loss = jl.seval(
"""
weights -> let
function my_weighted_loss(predict, target)
return sum(
i -> weights[i] * abs2(predict[i] - target[i]),
eachindex(predict, target, weights)
)
end
end
"""
)(np_weights)
While this works, it has several drawbacks: (1) it’s unintuitive to Python users, (2) it reduces code readability, and (3) it requires writing a closure for every variable.
Some alternatives:
1. Easier closure
Using the new general Fix{n}
from Create `Base.Fix` as general `Fix1`/`Fix2` for partially-applied functions by MilesCranmer · Pull Request #54653 · JuliaLang/julia · GitHub (also available in Compat
), we could avoid the GIL problems created from a Python lambda
, and readability issues from a chained jl.seval
->
closure. This would look like the following:
jl.seval("""
function my_weighted_loss(predict, target, weights)
return sum(
i -> weights[i] * abs2(predict[i] - target[i]),
eachindex(predict, target, weights)
)
end
""")
elementwise_loss = jl.Fix[3](jl.my_weighted_loss, np_weights)
The nice part about this is that the Fix{3}
is a Julia function, so (1) it would immediately do a one-time conversion of np_weights
into Julia, rather than performing the conversion at each call, and (2) this would avoid the GIL issue mentioned above. In other words, this would open a nice route for @stevengj’s recommended approach above.
There are some downsides that I see: (1) is that users might reflexively try to use lambda
and hit these problems, (2) jl.Fix[3]
indexes the 3rd argument, whereas a Python user might expect it to be the 4th with their 0-indexed brain, (3) there isn’t a multi-arg or keyword-arg jl.Fix
yet, and (4) using [n]
to pass an argument sorta breaks all Python intuition, sadly.
Maybe a special jl.lambda
could be created to simplify this? e.g., like jl.lambda(f, {1: x, 3: y, "kwarg": z})
which would return a Julia type.
2. Directly interpolating keywords to jl.seval
(or make a new function jl.teval
)
elementwise_loss = jl.seval(
"""
function my_weighted_loss(predict, target)
return sum(
i -> $(weights)[i] * abs2(predict[i] - target[i]),
eachindex(predict, target, $(weights))
)
end
""",
weights=np_weights
)
Where $(weights)
is used to interpolate from the passed keyword arguments. You can’t interpolate directly since it results in a string. Also, the reason for the $()
as discussed previously, is that {}
conflicts with Julia type annotations. I guess $
could also be problematic if there are @eval
calls in the evaluated code, so maybe an alternative syntax is needed.
Relatedly, @Benny found that $
is actually used in some other Python stdlib for string interpolation, so it could be a good option for that reason. That string.Template("...").substitute(d)
could be used as the first stage of processing.
3. Chained calls with Namespace:
elementwise_loss = (
jl.Namespace(weights=np_weights)
.seval("""
function my_weighted_loss(predict, target)
return sum(
i -> $(weights)[i] * abs2(predict[i] - target[i]),
eachindex(predict, target, $(weights))
)
end
""")
)
I chose Namespace
as this is more Pythonic as its used for argparse.Namespace
in the standard library. (noting the comment from the PythonCall.jl README: “the Python code looks like Python and the Julia code looks like Julia.”)
We could also let this be a mutable object:
scope = jl.Namespace()
scope.weights = np_weights
# ^ Stores both `"weights"` and `np_weights`
elementwise_loss = scope.seval("""
function my_weighted_loss(predict, target)
return sum(
i -> $(weights)[i] * abs2(predict[i] - target[i]),
eachindex(predict, target, $(weights))
)
end
""")
In general I do think it’s good to explicitly indicate interpolation, rather than just writing out weights
here. By being more explicit, we can throw errors if the user forgets to pass the variable (which string.Template("...").substitute(d)
handles already). This also avoids potential issues if weights
is already in the global namespace and no UndefVarError is raised!