Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add more functionlenses #46

Merged
merged 1 commit into from
Jun 6, 2022
Merged

add more functionlenses #46

merged 1 commit into from
Jun 6, 2022

Conversation

aplavin
Copy link
Member

@aplavin aplavin commented Feb 11, 2022

Motivation for these lenses, as I mentioned in another recent PR #45:

Suppose there is a function that modifies the value located by an optic:

function f(xs, o)
    vals = o.(xs)
    ... compute new_vals ...
    set.(xs, o, new_vals)
end

Then, one can call it like f([(a=1,), (a=2,), ...], @optic _.a).

This PR goes further.
Want to operate on logarithms? f([...], @optic log(_.a)).
Scaled values? f([...], @optic _.a * 1e10).
Sigmoid transformation? f([...], @optic 1/(1 + exp(-_.a)))
Only consider and modify angles of complex values? f([...], @optic angle(_.a))
and so on.

I should note that these set definitions are basically inverses, and it feels somewhat weird to define inverses for Base functions here. I don't have a better solution myself, so opening this for discussion.
https://github.com/JuliaMath/InverseFunctions.jl exists though...

Another unfortunate shortcoming is that set works out of the box with @optic 1/(1 + exp(-_.a)), but not with f(x) = 1 / (1 + exp(-x)); @optic f(_.a). Not sure if this is possible to overcome at all.

As checked in the testsuite, all those set methods infer correctly, even in composition. For larger compositions the compiler will give up at some point, but for now the main usecase is short inline functions anyways.

@jw3126
Copy link
Member

jw3126 commented Feb 11, 2022

Nice, thanks a lot @aplavin .

I don't have a better solution myself, so opening this for discussion.
https://github.com/JuliaMath/InverseFunctions.jl exists though...

I think it makes sense to ask maintainers of InverseFunctions.jl if they are willing to include the inverses you defined here. If so I would be happy to add InverseFunctions.jl as a dependency.

Another unfortunate shortcoming is that set works out of the box with @optic 1/(1 + exp(-.a)), but not with f(x) = 1 / (1 + exp(-x)); @optic f(.a). Not sure if this is possible to overcome at all.

One can do const f = @optic 1/(1+exp(-_)) 😄

@oschulz
Copy link

oschulz commented Feb 11, 2022

If we add those inverses to InverseFunctions, could you maybe even have a default definition

Accessors.set(obj, lens, val) = InverseFunctions.inverse(lens)(val)

in Accessors, even?

@oschulz
Copy link

oschulz commented Feb 11, 2022

With JuliaMath/InverseFunctions.jl#10 we could define things like

InverseFunctions.right_inverse(::Accessors.PropertyLens{field}) where field = x -> NamedTuple{(field,)}((x,))

This would give us behavior like

julia> right_inverse(@optic _.a.b.c)(42)
(a = (b = (c = 42,),),)

CC @devmotion

@aplavin
Copy link
Member Author

aplavin commented Feb 11, 2022

If we add those inverses to InverseFunctions, could you maybe even have a default definition
Accessors.set(obj, lens, val) = InverseFunctions.inverse(lens)(val)

While possible, this would make error messages less helpful. Eg, this won't trigger anymore:

Accessors.jl/src/optics.jl

Lines 163 to 166 in 5854cda

error("""
This should be unreachable. You probably need to overload
`Accessors.set(obj, ::$Optic, val)
""")

Can we somehow keep that error message without explicitly constraining

Accessors.set(obj, lens, val) = InverseFunctions.inverse(lens)(val)

to a specific list (Union) of lens types?

@aplavin
Copy link
Member Author

aplavin commented Feb 11, 2022

We'd get fun stuff like
julia> right_inverse(@optic _.a.b.c)(42)
(a = (b = (c = 42,),),)

Do you have a real(ish) example of how this could be useful?

@oschulz
Copy link

oschulz commented Feb 11, 2022

Do you have a real(ish) example of how this could be useful?

I think it's elegant, having a constructor as the right-inverse of an optic.

As for real world use, one could merge the inverses of projectors/optics to get the original value or so?

@jw3126
Copy link
Member

jw3126 commented Feb 12, 2022

If we add those inverses to InverseFunctions, could you maybe even have a default definition

Accessors.set(obj, lens, val) = InverseFunctions.inverse(lens)(val)

in Accessors, even?

That is an interesting idea and it is nice to automatically have Accessors support for invertible functions. OTOH nice error messages are also important and invertible lenses are the exception rather than the rule. If I have to choose one or the other I am in favor of the nice errors.

@jw3126
Copy link
Member

jw3126 commented Feb 12, 2022

As for real world use, one could merge the inverses of projectors/optics to get the original value or so?

I think I don't fully get what you mean. Can you make a very explicit example?

@jw3126
Copy link
Member

jw3126 commented Feb 12, 2022

Can we somehow keep that error message without explicitly constraining

Being invertible is much stronger than being just a lens. I don't know if there is an optics name for this. It would be nice to reflect this in the type system by a InvertibleLens <: OpticStyle trait. Such a trait would allow for nice errors. OTOH it is not really practical to expect people overloading inverse to also overload a trait.

@oschulz
Copy link

oschulz commented Feb 12, 2022

I think I don't fully get what you mean. Can you make a very explicit example?

I thought @optic _.a.b.c could be seen as a kind of "slice projection" operation. So it's right_inverse would restore the original slice. When you create the set of all "slice projections", "deep-merging" the right_inverse of all set members would restore (something like, property-wise) the original object.

right_inverse would also be the pullback for PropertyLens (and other access-like lenses), just has to be wrapped in a ChainRulesCore.Tangent:

Given

right_inverse(f::ComposedFunction) = Base.ComposedFunction(right_inverse(f.inner), right_inverse(f.outer))

using Accessors
right_inverse(::Accessors.PropertyLens{field}) where field = x -> NamedTuple{(field,)}((x,))

we could define

using ChainRulesCore
function ChainRulesCore.rrule(f::Accessors.PropertyLens, x)
    lens_pullback(Δy) = NoTangent(), (Δx = right_inverse(f)(Δy); Tangent{typeof(x),typeof(Δx)}(Δx))
    f(x), lens_pullback
end

and get (as expected)

julia> using Zygote
julia> Zygote.gradient(@optic(_.b.c), (a = 1, b = (c = 2, d = 3), e = 4))
((a = nothing, b = (c = 1.0, d = nothing), e = nothing),)

right_inverse for lenses may not be absolutely necessary, but if right- and left-inverses get added to InverseFunctions it would be elegent to have, I think.

@oschulz
Copy link

oschulz commented Feb 12, 2022

Being invertible is much stronger than being just a lens

True! I find the connection intriguing, though, in that set is kind of a "partial inverse" of lens application.

@jw3126
Copy link
Member

jw3126 commented Feb 15, 2022

With the following trick, any function has a right inverse:

left(x) = 0  # what is the preimage of `1`? Does not look like this function has a right inverse

struct right
    x
end
left(o::right) = o.x
@assert left(right(1)) === 1 # yeah we found a right inverse 

But this right inverse is not very useful. Because we changed the domains of our functions.
Naively if I have code like

y = left(x)
x2 = right_inverse(y)

I expect x1 and x2 to have the same type. Or roughly the same type, like both are AbstractArrays of the same shape. I might be surprised if x::MyCoolStruct and x2::NamedTuple{very degenerate}.

So I am not sure how useful the proposed inverse for property lens is. More examples might convince me.

@oschulz
Copy link

oschulz commented Feb 15, 2022

With the following trick, any function has a right inverse ... So I am not sure how useful the proposed inverse for property lens is

I think that's a bit different, though, since in your example you specialize left(o::right). What I proposed doesn't change how PropertyLens operates.

I expect x1 and x2 to have the same type. Or roughly the same type

Well, I would argue that a struct and a NamedTuple that's kind of a "slice" of that struct could fit that requirement.

More examples might convince me.

I can't claim to have a "must have this feature" example right now, and we don't have a InverseFunctions.right_inverse yet. But if/when we do, and if Accessors depends on InverseFunctions by then anyway, I think defining such right_inverses would be natural.

@aplavin
Copy link
Member Author

aplavin commented Jun 6, 2022

Will merge soon if no objections

@aplavin aplavin merged commit f714d61 into JuliaObjects:master Jun 6, 2022
aplavin added a commit to aplavin/Accessors.jl that referenced this pull request Jun 8, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants