Repository: JuliaAI/MLJBase.jl
Pull request #675 (2021-11-10 23:43:26)

Allow automatic access to `training_losses` in iterative models wrapped in a pipeline
Previously this didn't' work:

```julia
model = NeuralNetworkClassifier
pipe = Standardizer() |> model
mach = machine(pipe, (@load_iris)...) |> fit!

julia> training_losses(mach)
10-element Vector{Float64}:
 1.1617235772453105
 1.1391298992309888
 1.1353015649469138
 1.118214804032455
 1.0628404657351944
 1.0259886932644746
 1.0157579569196977
 0.9370889236524335
 0.9723838495187639
 0.9687408965584012
```

Also, the pipeline now knows the iteration parameter, which is now nested:

```julia
julia> iteration_parameter(pipe)
:(neural_network_classifier.epochs)
```

Which means pipelines play nicely with `IteratedModel`. 


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 173e5b30..f08325a4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,7 @@ on:
     branches:
       - master
       - dev
+      - for-0-point-19-release
   push:
     branches:
       - master
diff --git a/src/composition/models/pipelines2.jl b/src/composition/models/pipelines2.jl
index 576d5a8e..0d2e755d 100644
--- a/src/composition/models/pipelines2.jl
+++ b/src/composition/models/pipelines2.jl
@@ -87,7 +87,8 @@ for T_ex in SUPPORTED_TYPES_FOR_PIPELINES
     end |> eval
 end
 
-# hack an alias for the union type, `SomePipeline{N,operation}`:
+# hack an alias for the union type, `SomePipeline{N,operation}` (not
+# exported):
 const _TYPE_EXS = map(values(PIPELINE_TYPE_GIVEN_TYPE)) do P_ex
     :($P_ex{N,operation})
 end
@@ -96,10 +97,17 @@ quote
         Union{$(_TYPE_EXS...)}
 end |> eval
 
+# not exported:
+const SupervisedPipeline{N,operation} =
+    Union{DeterministicPipeline{N,operation},
+          ProbabilisticPipeline{N,operation},
+          IntervalPipeline{N,operation}}
+
 components(p::SomePipeline) = values(getfield(p, :named_components))
 names(p::SomePipeline) = keys(getfield(p, :named_components))
 operation(p::SomePipeline{N,O}) where {N,O} = O
 
+
 # # GENERIC CONSTRUCTOR
 
 const PRETTY_PREDICTION_OPTIONS =
@@ -545,3 +553,48 @@ end
 
 compose(p1, p2::FuzzyModel) = compose(Pipeline(p1), p2)
 compose(p1::FuzzyModel, p2) = compose(p1, Pipeline(p2))
+
+
+# # TRAINING LOSSES
+
+# ## Helpers
+
+function supervised_component_name(pipe::SupervisedPipeline)
+    idx = findfirst(model -> model isa Supervised, components(pipe))
+    return names(pipe)[idx]
+end
+
+function supervised_component(pipe::SupervisedPipeline)
+    name = supervised_component_name(pipe)
+    named_components = getfield(pipe, :named_components)
+    return getproperty(named_components, name)
+end
+
+model_type(::Machine{M}) where M = M
+function supervised(machines)
+    model_types = model_type.(machines)
+    idx = findfirst(M -> M <: Supervised, model_types)
+    return machines[idx]
+end
+
+
+# ## Traits
+
+# We cannot provide the following trait at the level of types because
+# the pipeline type does not know the precise type of the supervised
+# component, only its `abstract_type`. See comment at top of page.
+supports_training_losses(pipe::SupervisedPipeline) =
+    supports_training_losses(getproperty(pipe, supervised_component_name(pipe)))
+
+function training_losses(pipe::SupervisedPipeline, pipe_report)
+    mach = supervised(pipe_report.machines)
+    _report = report(mach)
+    return training_losses(mach.model, _report)
+end
+
+# This trait cannot be defined at the level of types (see previous comment):
+function iteration_parameter(pipe::SupervisedPipeline)
+    model = supervised_component(pipe)
+    name =  supervised_component_name(pipe)
+    MLJBase.prepend(name, iteration_parameter(model))
+end
diff --git a/src/hyperparam/one_dimensional_ranges.jl b/src/hyperparam/one_dimensional_ranges.jl
index 0e3b098a..16961b34 100644
--- a/src/hyperparam/one_dimensional_ranges.jl
+++ b/src/hyperparam/one_dimensional_ranges.jl
@@ -52,16 +52,6 @@ function Base.show(stream::IO,
     return nothing
 end
 
-WARN_INFERRING_TYPE =
-    "Inferring the hyper-parameter type from the given "*
-    "model instance as the parameter is of type `Any`."
-
-ERROR_AMBIGUOUS_UNION =
-    "The inferred hyper-parameter type is ambiguous, because "*
-    "the union type contains multiple real subtypes. You can "*
-    "specify the correct type as first argument of `range`, as"*
-    " in the example, `range(Int, :dummy, lower=1, upper=10)`."
-
 """
     r = range(model, :hyper; values=nothing)
 
@@ -114,19 +104,13 @@ function Base.range(model::Union{Model, Type}, field::Union{Symbol,Expr};
                             "unit=..."))
 
     if model isa Model
-        T = recursive_getpropertytype(model, field)
-        if T === Any
-            @warn WARN_INFERRING_TYPE
-            T = typeof(recursive_getproperty(model, field))
-        end
+        value = recursive_getproperty(model, field)
+        T = typeof(value)
     else
         T = model
     end
-    possible_types = filter(t -> t <: Real, Base.uniontypes(T))
-    n_possible_types = length(possible_types)
-    if n_possible_types > 0 && values === nothing
-        n_possible_types > 1 && error(ERROR_AMBIGUOUS_UNION) 
-        return numeric_range(first(possible_types), D, field, lower, upper, origin, unit, scale)
+    if T <: Real && values === nothing
+        return numeric_range(T, D, field, lower, upper, origin, unit, scale)
     else
         return nominal_range(T, field, values)
     end
diff --git a/src/utilities.jl b/src/utilities.jl
index 200ce056..ff421437 100644
--- a/src/utilities.jl
+++ b/src/utilities.jl
@@ -46,6 +46,24 @@ function reduce_nested_field(ex)
     return (subex, field)
 end
 
+"""
+    prepend(s::Symbol, s::Symbol)
+
+For prepending symbols in expressions like `:(y.w)` and `:(x1.x2.x3)`.
+
+julia> compose(:x, :y)
+:(x.y)
+
+julia> compose(:x, :(y.z))
+:(x.y.z)
+
+julia> compose(:w, ans)
+:(w.x.y.z)
+
+"""
+prepend(s::Symbol, t::Symbol) = Expr(:(.), s, QuoteNode(t))
+prepend(s::Symbol, ex::Expr) = Expr(:(.), prepend(s, ex.args[1]), ex.args[2])
+
 """
     recursive_getproperty(object, nested_name::Expr)
 
@@ -63,21 +81,6 @@ function recursive_getproperty(obj, ex::Expr)
     return recursive_getproperty(recursive_getproperty(obj, subex), field)
 end
 
-recursive_getpropertytype(obj, property::Symbol) = typeof(getproperty(obj, property))
-recursive_getpropertytype(obj::T, property::Symbol) where T <: Model = begin
-    model_type = typeof(obj)
-    property_names = fieldnames(model_type)
-    property_types = model_type.types
-    for (t, n) in zip(property_types, property_names)
-        n == property && return t
-    end
-    error("Property $property not found")
-end
-function recursive_getpropertytype(obj, ex::Expr)
-    subex, field = reduce_nested_field(ex)
-    return recursive_getpropertytype(recursive_getproperty(obj, subex), field)
-end
-
 """
     recursively_setproperty!(object, nested_name::Expr, value)
 


Test patch:
diff --git a/test/composition/models/pipelines2.jl b/test/composition/models/pipelines2.jl
index 36e36ef4..3088045f 100644
--- a/test/composition/models/pipelines2.jl
+++ b/test/composition/models/pipelines2.jl
@@ -30,6 +30,9 @@ end
 MLJBase.fit(::MyDeterministic, args...) = nothing, nothing, nothing
 MLJBase.transform(m::MyDeterministic, ::Any, Xnew) = fill(:dt, nrows(Xnew))
 MLJBase.predict(m::MyDeterministic, ::Any, Xnew) = fill(:dp, nrows(Xnew))
+MLJBase.supports_training_losses(::Type{<:MyDeterministic}) = true
+MLJBase.iteration_parameter(::Type{<:MyDeterministic}) = :x
+MLJBase.training_losses(::MyDeterministic, report) = ones(3)
 
 mutable struct MyProbabilistic <: Probabilistic
     x::Symbol
@@ -324,6 +327,20 @@ doubler(y) = 2*y
 
 end
 
+@testset "training_losses" begin
+    model = MyDeterministic(:bla)
+    pipe = Standardizer() |> model
+
+    # test helpers:
+    @test MLJBase.supervised_component_name(pipe) == :my_deterministic
+    @test MLJBase.supervised_component(pipe) == model
+
+    @test supports_training_losses(pipe)
+    _, _, rp = MLJBase.fit(pipe, 0, X, y)
+    @test training_losses(pipe, rp) == ones(3)
+    @test iteration_parameter(pipe) == :(my_deterministic.x)
+end
+
 
 # # INTEGRATION TESTS
 
diff --git a/test/hyperparam/one_dimensional_ranges.jl b/test/hyperparam/one_dimensional_ranges.jl
index 29188570..dc559f23 100644
--- a/test/hyperparam/one_dimensional_ranges.jl
+++ b/test/hyperparam/one_dimensional_ranges.jl
@@ -11,10 +11,6 @@ end
 
 dummy_model = DummyModel(4, 9.5, 'k')
 
-mutable struct AnyModel <: Deterministic
-    any
-end
-
 mutable struct SuperModel <: Deterministic
     lambda::Float64
     model1::DummyModel
@@ -23,7 +19,6 @@ end
 
 dummy1 = DummyModel(1, 9.5, 'k')
 dummy2 = DummyModel(2, 9.5, 'k')
-any1 = AnyModel(1)
 super_model = SuperModel(0.5, dummy1, dummy2)
 
 @testset "constructors" begin
@@ -104,14 +99,5 @@ end
     q2 = range
 end
 
-@testset "warnings and errors" begin
-    # unambiguous union should work
-    @test range(Union{Nothing, Float64}, :any, lower=1, upper=10) == range(Float64, :any, lower=1, upper=10)
-    # ambiguous union should fail
-    @test_throws range(Union{Float32, Float64}, :any, lower=1, upper=10)
-    # untyped parameters should warn if inferred
-    @test_logs (:warn, MLJBase.WARN_INFERRING_TYPE) range(any1, :any, lower=1, upper=10)
-end
-
 end
 true
diff --git a/test/utilities.jl b/test/utilities.jl
index cef1e054..c9c23c5a 100644
--- a/test/utilities.jl
+++ b/test/utilities.jl
@@ -36,6 +36,10 @@ mutable struct A21
     a212
 end
 
+@testset "prepend" begin
+    MLJBase.prepend(:x, :(y.z.w)) == :(x.y.z.w)
+end
+
 @testset "recursive getproperty, setproperty!" begin
     m = (a1 = (a11 = 10, a12 = 20), a2 = (a21 = (a211 = 30, a212 = 40),))