diff --git a/src/FeatureTransforms.jl b/src/FeatureTransforms.jl index 03e3623..5e81e15 100644 --- a/src/FeatureTransforms.jl +++ b/src/FeatureTransforms.jl @@ -9,6 +9,7 @@ export Transform, transform, transform! export HoD, LinearCombination, OneHotEncoding, Periodic, Power export AbstractScaling, IdentityScaling, MeanStdScaling, StandardScaling export LogTransform, InverseHyperbolicSine +export Composite include("utils.jl") include("traits.jl") @@ -24,6 +25,7 @@ include("periodic.jl") include("power.jl") include("scaling.jl") include("temporal.jl") +include("composite.jl") include("test_utils.jl") diff --git a/src/composite.jl b/src/composite.jl new file mode 100644 index 0000000..8fd2144 --- /dev/null +++ b/src/composite.jl @@ -0,0 +1,54 @@ +""" + Composite <: Transform + +A `Composite` transform is a composition of `Transform`s, currently limited to `OneToOne()` +cardinality. It can be fit and applied in a single step. + +The transforms in `Composite([t1, t2, t3])` are applied in `t1`, `t2`, `t3` order, where +the output of `t1` is the input to `t2` etc. When using `∘` to create transforms, the order +is `t3 ∘ t2 ∘ t1`, as in function composition. + +```jldoctest composite +julia> id = IdentityScaling(); + +julia> power = Power(2.0); + +julia> id ∘ power == Composite([power, id]) +true +``` +""" +struct Composite <: Transform + transforms::Tuple{Vararg{Transform}} + + function Composite(transforms::Tuple{Vararg{Transform}}) + all(==(OneToOne()), map(cardinality, transforms)) && return new(transforms) + throw(ArgumentError("Only OneToOne() transforms are supported.")) + end +end + +cardinality(c::Composite) = ∘(map(cardinality, c.transforms)...) + +function fit!(c::Composite, data; kwargs...) + for t in c.transforms + fit!(t, data; kwargs...) + data = t(data) + end + return c +end + +function _apply(x, c::Composite; kwargs...) + data = deepcopy(x) + for t in c.transforms + data = _apply(data, t; kwargs...) + end + return data +end + +# creating composite transforms: reverse the order so that c.transforms[1] is the first +# transforms that gets applied +Base.:(∘)(f::Transform, g::Transform) = Composite((g, f)) +Base.:(∘)(c::Composite, t::Transform) = Composite((t, c.transforms...)) +Base.:(∘)(t::Transform, c::Composite) = Composite((c.transforms..., t)) +Base.:(∘)(c::Composite, c2::Composite) = Composite((c2.transforms..., c.transforms...)) + +Base.:(==)(c::Composite, d::Composite) = return all(map(==, c.transforms, d.transforms)) diff --git a/src/traits.jl b/src/traits.jl index 7872985..1a03510 100644 --- a/src/traits.jl +++ b/src/traits.jl @@ -46,3 +46,15 @@ struct ManyToMany <: Cardinality end Returns the [`Cardinality`](@ref) of the `transform`. """ function cardinality end + +Base.:(∘)(::OneToOne, ::OneToOne) = OneToOne() +Base.:(∘)(::OneToMany, ::OneToOne) = OneToMany() +Base.:(∘)(::ManyToOne, ::OneToMany) = OneToOne() +Base.:(∘)(::ManyToMany, ::OneToMany) = OneToMany() +Base.:(∘)(::OneToOne, ::ManyToOne) = ManyToOne() +Base.:(∘)(::OneToMany, ::ManyToOne) = ManyToMany() +Base.:(∘)(::ManyToOne, ::ManyToMany) = ManyToOne() +Base.:(∘)(::ManyToMany, ::ManyToMany) = ManyToMany() +function Base.:(∘)(c2::Cardinality, c1::Cardinality) + return throw(ArgumentError("Cannot compose cardinalities: $c2 ∘ $c1.")) +end diff --git a/test/composite.jl b/test/composite.jl new file mode 100644 index 0000000..c998580 --- /dev/null +++ b/test/composite.jl @@ -0,0 +1,45 @@ +@testset "composite.jl" begin + @testset "constructor" begin + id = IdentityScaling() + power = Power(3.0) + logt = LogTransform() + @test id ∘ id == Composite((id, id)) + @test id ∘ id ∘ power == Composite((power, id, id)) + @test power ∘ id ∘ power == Composite((power, id, power)) + + @test power ∘ (id ∘ logt) == Composite((logt, id, power)) + @test (power ∘ id) ∘ logt == Composite((logt, id, power)) + @test (power ∘ id) ∘ (logt ∘ id) == Composite((id, logt, id, power)) + + @test_throws ArgumentError id ∘ LinearCombination([1, 2, 3]) + @test_throws ArgumentError OneHotEncoding([1, 2]) ∘ id + end + + @testset "apply" begin + p = Power(4.0) + c = Power(2.0) ∘ Power(2.0) ∘ IdentityScaling() + x = [1, 2, 3] + @test FeatureTransforms.apply(x, p) == FeatureTransforms.apply(x, c) + @test p(x) == c(x) + end + + @testset "fit!" begin + s = StandardScaling() + c = StandardScaling() ∘ IdentityScaling() ∘ StandardScaling() + x = rand(10) + x_copy = deepcopy(x) + + fit!(s, x) + fit!(c, x) + + @test c(x) ≈ s(x) + + # did not change the input data + @test x_copy == x + + # but make sure that it is fit and transformed on the already transformed data, in + # this case leaving the second scaling redundant, i.e. centered at 0.0 and std = 1.0 + @test isapprox(0.0, c.transforms[3].μ; atol=1e-15) + @test isapprox(1.0, c.transforms[3].σ; atol=1e-15) + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 61a1d55..f4f2fb4 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -25,6 +25,7 @@ using TimeZones include("scaling.jl") include("temporal.jl") include("traits.jl") + include("composite.jl") include("test_utils.jl") include("types/tables.jl") diff --git a/test/traits.jl b/test/traits.jl index ce28e22..fb4df3c 100644 --- a/test/traits.jl +++ b/test/traits.jl @@ -2,4 +2,26 @@ for t in (OneToOne(), OneToMany(), ManyToOne(), ManyToMany()) @test t isa FeatureTransforms.Cardinality end + + @testset "composite" begin + @test OneToOne() == OneToOne() ∘ OneToOne() + @test OneToMany() == OneToMany() ∘ OneToOne() + @test_throws ArgumentError ManyToOne() ∘ OneToOne() + @test_throws ArgumentError ManyToMany() ∘ OneToOne() + + @test ManyToOne() == OneToOne() ∘ ManyToOne() + @test ManyToMany() == OneToMany() ∘ ManyToOne() + @test_throws ArgumentError ManyToOne() ∘ ManyToOne() + @test_throws ArgumentError ManyToMany() ∘ ManyToOne() + + @test_throws ArgumentError OneToOne() ∘ OneToMany() + @test_throws ArgumentError OneToMany() ∘ OneToMany() + @test OneToOne() == ManyToOne() ∘ OneToMany() + @test OneToMany() == ManyToMany() ∘ OneToMany() + + @test_throws ArgumentError OneToOne() ∘ ManyToMany() + @test_throws ArgumentError OneToMany() ∘ ManyToMany() + @test ManyToOne() == ManyToOne() ∘ ManyToMany() + @test ManyToMany() == ManyToMany() ∘ ManyToMany() + end end