Skip to content

Commit b8d458a

Browse files
authored
Add hsm_mode: Half-Sample Mode for continuous data (#984)
1 parent eafd935 commit b8d458a

2 files changed

Lines changed: 145 additions & 8 deletions

File tree

src/scalarstats.jl

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,66 @@ end
4646

4747
# compute mode, given the range of integer values
4848
"""
49-
mode(a, [r])
50-
mode(a::AbstractArray, wv::AbstractWeights)
49+
mode(a; method=:frequency)
50+
mode(a::AbstractArray, wv::AbstractWeights; method=:frequency)
51+
mode(a::AbstractArray{T}, r::UnitRange{T}; method=:frequency) where T<:Integer
52+
53+
Return the mode (most common value) of `a`, optionally
54+
over a specified range `r` or weighted via a vector
55+
`wv`.
56+
57+
The `method` keyword argument selects the estimation method:
58+
59+
- `:frequency`: Frequency-based mode. Counts occurrences and returns the most common
60+
value. If several modes exist, the first one (in order of appearance) is returned.
61+
This is appropriate for discrete data.
62+
63+
- `:halfsample`: Half-sample mode (HSM). A robust estimator of the mode for
64+
continuous data. Repeatedly finds the contiguous half-sample with the smallest
65+
range until at most 2 points remain, then returns their midpoint. The return value
66+
is always a floating-point number and may not be an element of `a`.
67+
Throws an `ArgumentError` if `a` contains any non-finite values (`NaN`, `Inf`,
68+
or `-Inf`).
69+
This method currently does not support `r` and `wv` arguments.
70+
71+
# References
72+
- D.R. Bickel, R. Fruehwirth (2006). On a fast, robust estimator of the mode.
73+
Computational Statistics & Data Analysis, 50(12), 3500-3530.
74+
- T. Robertson, J.D. Cryer (1974). An iterative procedure for estimating the mode.
75+
Journal of the American Statistical Association, 69(348), 1012-1016.
5176
52-
Return the mode (most common number) of an array, optionally
53-
over a specified range `r` or weighted via a vector `wv`.
54-
If several modes exist, the first one (in order of appearance) is returned.
77+
# Examples
78+
julia> mode([1, 2, 2, 3, 3, 3, 4])
79+
3
80+
81+
julia> mode([1, 2, 2, 3, 3, 3, 4], method=:frequency)
82+
3
83+
84+
julia> mode([1.0, 1.1, 1.2, 5.0, 5.1], method=:halfsample)
85+
1.1
86+
```
5587
"""
56-
function mode(a::AbstractArray{T}, r::UnitRange{T}) where T<:Integer
88+
function mode(a; method::Symbol=:frequency)
89+
if method === :halfsample
90+
return _hsm_mode(a)
91+
elseif method === :frequency
92+
return _frequency_mode(a)
93+
else
94+
throw(ArgumentError(LazyString("`method` must be `:frequency` or `:halfsample`, got `:", method, "`")))
95+
end
96+
end
97+
98+
function mode(a::AbstractArray{T}, r::UnitRange{T}; method::Symbol=:frequency) where T<:Integer
99+
if method === :halfsample
100+
throw(ArgumentError("The `:halfsample` method does not support a range argument. Call `mode(a, method=:halfsample)` without a range."))
101+
elseif method === :frequency
102+
return _frequency_mode(a, r)
103+
else
104+
throw(ArgumentError(LazyString("`method` must be `:frequency` or `:halfsample`, got `:", method, "`")))
105+
end
106+
end
107+
108+
function _frequency_mode(a::AbstractArray{T}, r::UnitRange{T}) where T<:Integer
57109
isempty(a) && throw(ArgumentError("mode is not defined for empty collections"))
58110
len = length(a)
59111
r0 = r[1]
@@ -107,8 +159,7 @@ function modes(a::AbstractArray{T}, r::UnitRange{T}) where T<:Integer
107159
return ms
108160
end
109161

110-
# compute mode over arbitrary iterable
111-
function mode(a)
162+
function _frequency_mode(a)
112163
isempty(a) && throw(ArgumentError("mode is not defined for empty collections"))
113164
cnts = Dict{eltype(a),Int}()
114165
# first element
@@ -204,6 +255,44 @@ function modes(a::AbstractVector, wv::AbstractWeights{T}) where T <: Real
204255
return [x for (x, w) in weights if w == mw]
205256
end
206257

258+
# Internal implementation of the Half-Sample Mode (HSM) estimator.
259+
function _hsm_mode(a)
260+
isempty(a) && throw(ArgumentError("mode is not defined for empty collections"))
261+
if !all(x -> x isa Real, a)
262+
throw(ArgumentError("mode with `method=:halfsample` is only defined " *
263+
"for collections containing real numbers"))
264+
end
265+
# Filter NaN values and sort
266+
if !all(isfinite, a)
267+
throw(ArgumentError("mode with `method=:halfsample` is not defined " *
268+
"for collections containing non-finite values"))
269+
end
270+
filteredv = sort!(collect(a))
271+
len = length(filteredv)
272+
273+
len == 1 && return middle(filteredv[1], filteredv[1])
274+
len == 2 && return middle(filteredv[1], filteredv[2])
275+
276+
# Iteratively find the half-sample with the smallest range
277+
filteredv = @view filteredv[1:end]
278+
while len > 2
279+
half = cld(len, 2)
280+
best_i = 1
281+
best_width = filteredv[half] - filteredv[1]
282+
for i in 2:(len - half + 1)
283+
w = filteredv[i + half - 1] - filteredv[i]
284+
if w < best_width
285+
best_width = w
286+
best_i = i
287+
end
288+
end
289+
filteredv = @view filteredv[best_i:(best_i + half - 1)]
290+
len = length(filteredv)
291+
end
292+
293+
return middle(filteredv[1], filteredv[len])
294+
end
295+
207296
#############################
208297
#
209298
# quantile and friends

test/scalarstats.jl

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,54 @@ wv = weights([0.1:0.1:0.7; 0.1])
6363
@test_throws ArgumentError mode([1, 2, 3], weights([0.1, 0.3]))
6464
@test_throws ArgumentError modes([1, 2, 3], weights([0.1, 0.3]))
6565

66+
## mode with method=:frequency (explicit, same as default)
67+
@test mode([1, 1, 1, 2, 3, 4], method=:frequency) == 1
68+
@test mode((x for x in [1, 1, 1, 2, 3, 4]), method=:frequency) == 1
69+
70+
# Check no type inference regression on the default/frequency path
71+
@test @inferred(mode([1]))::Int == 1
72+
my_frequency_mode(x) = mode(x, method=:frequency)
73+
@test @inferred(my_frequency_mode([1]))::Int == 1
74+
75+
# Check type inference on halfsample path
76+
my_hsm_mode(x) = mode(x, method=:halfsample)
77+
@test @inferred(my_hsm_mode([1]))::Float64 == 1.0
78+
79+
## mode with method=:halfsample (half-sample mode)
80+
@test mode([10], method=:halfsample)::Float64 == 10.0
81+
@test mode([1, 5], method=:halfsample)::Float64 == 3.0 # midpoint of two elements
82+
@test mode([1, 2, 2, 3, 4, 4, 4, 5], method=:halfsample)::Float64 == 4.0
83+
@test mode([1.0, 1.1, 1.2, 5.0, 5.1], method=:halfsample) == 1.15
84+
@test mode([1.0, 2.0, 3.0, 4.0, 10.0, 11.0, 12.0, 13.0], method=:halfsample) == 1.5
85+
@test mode([1.0, 2.0, 10.0, 10.1, 10.2, 10.3], method=:halfsample) == 10.05
86+
87+
# Robustness to outliers
88+
@test mode([1.0, 1.01, 1.011, 100.0, 200.0], method=:halfsample) == 1.0105
89+
90+
# Non-real values throw ArgumentError
91+
@test_throws ArgumentError mode(["a", "b", "c"], method=:halfsample)
92+
@test_throws ArgumentError mode([1+1im, 1+2im], method=:halfsample)
93+
94+
# Non-finite values throw ArgumentError
95+
@test_throws ArgumentError mode([1.0, NaN, 2.0, 2.0, Inf], method=:halfsample)
96+
@test_throws ArgumentError mode([1.0, NaN, Inf, -Inf], method=:halfsample)
97+
@test_throws ArgumentError mode([NaN, Inf, -Inf], method=:halfsample)
98+
@test_throws ArgumentError mode([Inf, -Inf], method=:halfsample)
99+
@test_throws ArgumentError mode([NaN], method=:halfsample)
100+
@test_throws ArgumentError mode([NaN, NaN, NaN], method=:halfsample)
101+
102+
# Edge cases
103+
@test_throws ArgumentError mode(Float64[], method=:halfsample)
104+
105+
# Invalid method throws
106+
@test_throws ArgumentError mode([1, 2, 3], method=:invalid)
107+
108+
## mode with range argument
109+
@test mode([1, 2, 2, 3, 4, 4, 4, 5], 2:4, method=:frequency) == 4
110+
@test_throws ArgumentError mode([1, 2, 2, 3, 4, 4, 4, 5], 2:4, method=:halfsample)
111+
@test_throws ArgumentError mode([1, 2, 2, 3, 4, 4, 4, 5], 2:4, method=:invalid)
112+
113+
@test mode([1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 5.0, 5.1, 5.2, 5.3], method=:halfsample) == 1.15
66114
## zscores
67115

68116
@test zscore([-3:3;], 1.5, 0.5) == [-9.0:2.0:3.0;]

0 commit comments

Comments
 (0)