Tutorial
Read this tutorial to learn how to implement new measures using StatisticalMeasuresBase.jl. Refer to Implementing New Measures and other sections for technical details, bells and whistles.
The goal of this tutorial is to define a Huber loss function, HuberLoss(; delta=1)
, that consumes vectors of real ground truth observations/predictions, with support for weights, class weights and missing values; and a second measure MultitargetHuberLoss(; delta=1)
that consumes matrices, tables or multidimensional arrays. To do so requires only that we implement a single Huber loss for scalars and apply appropriate measure wrappers.
As we'll see, the most important wrapper to understand is the multimeasure
wrapper.
The basics
Let's start by defining a scalar Huber loss function:
valley(a, delta=1) = abs(a) <= delta ? a^2/2 : delta*(abs(a) - delta/2)
huber(ŷ, y, delta=1) = valley(ŷ - y, delta)
huber (generic function with 2 methods)
Huber loss type for scalar input
Since the Huber loss has a parameter, we create a struct, and make instances callable:
using StatisticalMeasuresBase
import StatisticalMeasuresBase as API
struct HuberLossOnScalars{T<:Real}
delta::T
end
(measure::HuberLossOnScalars)(ŷ, y) = huber(ŷ, y, measure.delta)
API.is_measure(::HuberLossOnScalars) = true # recommended if `HuberLossOnScalars` is public-facing
measure = HuberLossOnScalars(0.5)
@assert measure(7, 4) == huber(7, 4, 0.5)
Huber loss for vector input
To get a Huber loss for vectors, we wrap the scalar measure using multimeasure
, which broadcasts over MLUtils.eachobs((ŷ, y))
in call
invocations. We also include a preliminary wrapping to support missing
values:
HuberLoss(delta) =
multimeasure(supports_missings_measure(HuberLossOnScalars(delta)))
HuberLoss(; delta=1) = HuberLoss(delta)
HuberLoss (generic function with 2 methods)
By default, a new measure has Mean()
as its external aggregation mode, which you can inspect using the trait StatisticalMeasuresBase.external_aggregation_mode(measure)
. Wrappers, like support_missings_measure
, forward such traits to the wrapped measure. The multimeasure
wrapper uses MLUtils.jl's eachobs
method to broadcast the atomic measure over oberservations and, by default, aggregates the result using the atomic measure's aggregation mode.
Demonstration
using Statistics
ŷ = [1, 2, missing]
y = [7, 4, 6]
weights = [1, 3, 2]
class_weights = Dict(7=>3.0, 4=>4.0, 6=>2.0)
wts = weights .* (class_weights[η] for η in y)
@assert HuberLoss(1)(ŷ, y, weights, class_weights) ≈
sum(wts[1:2] .* huber.(ŷ[1:2], y[1:2])) / length(wts)
Note the divisor length(weights)
on the last line: The weighted measurement is not literally the weighted mean but the weighted mean scaled by the average value of the weights. To get the bone fide weighted mean, use multimeasure(..., mode=IMean())
instead. Another option is Sum()
; see StatisticalMeasuresBase.AggregationMode
for other options.
Multi-target Huber loss
Here's the Huber loss for multi-targets (matrices or tables) which includes strict argument checks, and works for higher dimensional arrays as well (argument checks can be removed with unfussy(measure)
):
MultitargetHuberLoss(args...; kwargs...) =
multimeasure(HuberLoss(args...; kwargs...), transform = vec∘collect) |> fussy_measure
MultitargetHuberLoss (generic function with 1 method)
Demonstration
Multi-targets (as matrices):
y = rand(3, 20)
ŷ = rand(3, 20)
weights = rand(20)
ms = weights .* vec(mean(huber.(ŷ, y), dims=1))
@assert measurements(MultitargetHuberLoss(), ŷ, y, weights) ≈ ms
@assert MultitargetHuberLoss()(ŷ, y, weights) ≈ sum(ms)/length(weights)
Note the use of the measurements
method, to return one measurement per observation, instead of an aggregate.
Mutli-targets (as tables):
using DataFrames
df = DataFrame(y', :auto)
df̂ = DataFrame(ŷ', :auto)
@assert MultitargetHuberLoss()(df̂, df, weights) ≈ MultitargetHuberLoss()(df̂, df, weights)
Multi-dimensional arrays:
y = rand(2, 3, 20)
ŷ = rand(2, 3, 20)
weights = rand(20)
ms = weights .* vec(mean(huber.(ŷ, y), dims=[1, 2]))
@assert measurements(MultitargetHuberLoss(), ŷ, y, weights) ≈ ms
@assert MultitargetHuberLoss()(ŷ, y, weights) ≈ sum(ms)/length(weights)
Invalid arguments:
class_weights = Dict()
MultitargetHuberLoss()(ŷ, y, class_weights)
ERROR: ArgumentError: Class pool or value set is not compatible with the class_weight dictionary keys.
Overloading traits
Here's how to overload measure traits to make additional promises of behavior.
using ScientificTypesBase
@trait HuberLossOnScalars orientation=Loss()
typeof(HuberLoss())
StatisticalMeasuresBase.Multimeasure{StatisticalMeasuresBase.SupportsMissingsMeasure{Main.HuberLossOnScalars{Int64}}, Nothing, Mean, typeof(identity)}
const HuberLossType = API.Multimeasure{
<:API.SupportsMissingsMeasure{
<:HuberLossOnScalars
}}
@trait(
HuberLossType,
observation_scitype = Union{Continuous,Missing},
human_name = "Huber loss",
)
API.observation_scitype(HuberLoss())
Union{Missing, ScientificTypesBase.Continuous}
const MultitargetHuberLossType = API.FussyMeasure{<:API.Multimeasure{<:HuberLossType}}
@trait(
MultitargetHuberLossType,
observation_scitype = AbstractArray{<:Union{Continuous,Missing}},
can_consume_tables = true,
human_name = "multi-target Huber loss",
)
While most wrappers forward atomic measure traits appropriately, StatisticalMeasuresBase.observation_scitype(measure)
(default=Union{}
) and StatisticalMeasuresBase.can_consume_tables(measure)
(default=false
) must be explicitly overloaded for measures wrapped using multimeasure
.
Improving display of measures
Because most useful measures are wraps of atomic measures, they don't display well:
HuberLoss()
multimeasure(Main.HuberLossOnScalars{Int64}(1))
This can be improved using the @fix_show
macro:
@fix_show HuberLoss::HuberLossType
HuberLoss()
HuberLoss(
delta = 1)
Macro shortcut (experimental)
The entire workflow above is equivalent to the code block below, except that HuberLoss
is also fussy and MultitargetHuberLoss
gets an additional keyword argument atomic_weights
, for specifying weights per component of the vector-valued target (per column if y
is a table). The loss functions also accept nothing
for weights or class weights.
You will need to try this in a new Julia session or change the loss name. See @combination
for details.
valley(a, delta=1) = abs(a) <= delta ? a^2/2 : delta*(abs(a) - delta/2)
huber(ŷ, y, delta=1) = valley(ŷ - y, delta)
using StatisticalMeasuresBase
import StatisticalMeasuresBase as API
using ScientificTypesBase
@combination(
HuberLoss(; delta=1) = multimeasure(huber),
orientation = Loss(),
observation_scitype = Continuous,
human_name = "Huber loss",
)
Demonstration
n = 10
y = vcat(fill(1, n)', fill(1, n)', fill(1, n)')
ŷ = vcat(fill(1, n)', fill(2, n)', fill(3, n)')
ŷ - y
3×10 Matrix{Int64}:
0 0 0 0 0 0 0 0 0 0
1 1 1 1 1 1 1 1 1 1
2 2 2 2 2 2 2 2 2 2
delta = 10
valley(1, delta)
0.5
valley(2, delta)
2.0
measure = MultitargetHuberLoss(delta=10, atomic_weights=[5, 4, 1])
@assert measure(ŷ, y, fill(100, n)) ≈ (5*0 + 4*0.5 + 1*2.0)/3*100
API.observation_scitype(measure)
AbstractArray{<:Union{Missing, ScientificTypesBase.Continuous}}