Repository: JuliaML/TableTransforms.jl (julia)

Issue:
Allow specification of columns in Functional transform
It would be nice to assign functions to specific columns as in `Functional(:a => cos, :b => sin)`.

Pull request:
This PR redesigns `Functional` implementation to add support for applying functions to specific columns.
The following constructor has been added:
```julia
Functional(col₁ => func₁, col₂ => func₂, ..., colₙ => funcₙ)
```
closes #59

Patch:
diff --git a/src/transforms/functional.jl b/src/transforms/functional.jl
index 4c46baf9..2031711b 100644
--- a/src/transforms/functional.jl
+++ b/src/transforms/functional.jl
@@ -3,23 +3,34 @@
 # ------------------------------------------------------------------
 
 """
-    Functional(function)
+    Functional(func)
 
-The transform that applies a `function` elementwise.
+The transform that applies a `func` elementwise.
+
+    Functional(col₁ => func₁, col₂ => func₂, ..., colₙ => funcₙ)
+
+Apply the corresponding `funcᵢ` function to each `colᵢ` column.
 
 # Examples
 
 ```julia
 Functional(cos)
 Functional(sin)
+Functional(:a => cos, :b => sin)
+Functional("a" => cos, "b" => sin)
 ```
 """
-struct Functional{F} <: Colwise
+struct Functional{F} <: Stateless
   func::F
 end
 
-isrevertible(transform::Functional) =
-  !isnothing(inverse(transform.func))
+Functional(pairs::Pair{Symbol}...) =
+  Functional(NamedTuple(pairs))
+
+Functional(pairs::Pair{K}...) where {K<:AbstractString} =
+  Functional(NamedTuple(Symbol(k) => v for (k, v) in pairs))
+
+Functional() = throw(ArgumentError("Cannot create a Functional object without arguments."))
 
 # known invertible functions
 inverse(::typeof(log))  = exp
@@ -28,18 +39,54 @@ inverse(::typeof(cos))  = acos
 inverse(::typeof(acos)) = cos
 inverse(::typeof(sin))  = asin
 inverse(::typeof(asin)) = sin
+inverse(::typeof(cosd))  = acosd
+inverse(::typeof(acosd)) = cosd
+inverse(::typeof(sind))  = asind
+inverse(::typeof(asind)) = sind
+inverse(::typeof(identity)) = identity
 
 # fallback to nothing
 inverse(::Any) = nothing
 
-colcache(::Functional, x) = nothing
+isrevertible(transform::Functional) =
+  !isnothing(inverse(transform.func))
 
-function colapply(transform::Functional, x, c)
-  f = transform.func
-  f.(x)
+isrevertible(transform::Functional{<:NamedTuple}) =
+  all(!isnothing, inverse.(values(transform.func)))
+
+_functuple(func, names) = NamedTuple(nm => func for nm in names)
+_functuple(func::NamedTuple, names) = func
+
+function apply(transform::Functional, table) 
+  cols = Tables.columns(table)
+  names = Tables.columnnames(table)
+  funcs = _functuple(transform.func, names)
+  
+  ncols = map(names) do nm
+    x = Tables.getcolumn(cols, nm)
+    func = get(funcs, nm, identity)
+    func.(x)
+  end
+
+  𝒯 = (; zip(names, ncols)...)
+  newtable = 𝒯 |> Tables.materializer(table)
+  return newtable, nothing
 end
 
-function colrevert(transform::Functional, y, c)
-  g = inverse(transform.func)
-  g.(y)
-end
\ No newline at end of file
+function revert(transform::Functional, newtable, cache)
+  @assert isrevertible(transform) "Transform is not revertible."
+
+  cols = Tables.columns(newtable)
+  names = Tables.columnnames(newtable)
+  funcs = _functuple(transform.func, names)
+
+  ocols = map(names) do nm
+    x = Tables.getcolumn(cols, nm)
+    func = get(funcs, nm, identity)
+    invfunc = inverse(func)
+    invfunc.(x)
+  end
+
+  𝒯 = (; zip(names, ocols)...)
+  𝒯 |> Tables.materializer(newtable)
+end


Test patch:
diff --git a/test/transforms.jl b/test/transforms.jl
index 0d65191b..ce7025ea 100644
--- a/test/transforms.jl
+++ b/test/transforms.jl
@@ -966,6 +966,50 @@
     @test all(≥(1), n.x)
     @test all(≥(1), n.y)
     @test isrevertible(T) == false
+
+    # apply functions to specific columns
+    x = π*rand(1500)
+    y = π*(rand(1500) .- 0.5)
+    z = x + y
+    t = Table(; x, y, z)
+    T = Functional(:x => cos, :y => sin)
+    n, c = apply(T, t)
+    @test all(x -> -1 ≤ x ≤ 1, n.x)
+    @test all(y -> -1 ≤ y ≤ 1, n.y)
+    @test t.z == n.z
+    tₒ = revert(T, n, c)
+    @test Tables.matrix(t) ≈ Tables.matrix(tₒ)
+
+    x = 2*(rand(1500) .- 0.5)
+    y = 2*(rand(1500) .- 0.5)
+    z = x + y
+    t = Table(; x, y, z)
+    T = Functional("x" => acos, "y" => asin)
+    n, c = apply(T, t)
+    @test all(x -> 0 ≤ x ≤ π, n.x)
+    @test all(y -> -π/2 ≤ y ≤ π/2, n.y)
+    @test t.z == n.z
+    tₒ = revert(T, n, c)
+    @test Tables.matrix(t) ≈ Tables.matrix(tₒ)
+
+    T = Functional(:x => cos, :y => sin)
+    @test isrevertible(T) == true
+    T = Functional("x" => cos, "y" => sin)
+    @test isrevertible(T) == true
+    T = Functional(:x => abs, :y => sin)
+    @test isrevertible(T) == false
+    T = Functional("x" => abs, "y" => sin)
+    @test isrevertible(T) == false
+
+    # throws
+    @test_throws ArgumentError Functional()
+    t = Table(x = rand(15), y = rand(15))
+    T = Functional(Polynomial(1, 2, 3))
+    n, c = apply(T, t)
+    @test_throws AssertionError revert(T, n, c)
+    T = Functional(:x => abs, :y => sin)
+    n, c = apply(T, t)
+    @test_throws AssertionError revert(T, n, c)
   end
 
   @testset "EigenAnalysis" begin