Current section
Files
Jump to
Current section
Files
lib/approximate_histogram.ex
defmodule ApproximateHistogram do
@type t :: %__MODULE__{
bins: [bin],
options: options
}
@type bin :: {value, count}
@type value :: number()
@type count :: non_neg_integer()
@type options :: %{max_bins: pos_integer()}
defstruct [
:bins,
:options,
]
@default_size 50
@spec new(pos_integer()) :: t
def new(size \\ @default_size) do
%__MODULE__{
bins: [],
options: %{max_bins: size},
}
end
@spec size(t) :: non_neg_integer()
def size(%__MODULE__{} = histo) do
Enum.reduce(histo.bins, 0, fn {_, count}, total -> total + count end)
end
@spec max_bins(t) :: non_neg_integer()
def max_bins(%__MODULE__{} = histo) do
histo.options.max_bins
end
@spec add(t, value) :: t
def add(%__MODULE__{} = histo, value) do
if at_capacity?(histo) do
# Split the list into:
# [before] | closest | [after]
# Use a weighted average to merge the value and increment count correctly into a new middle bin
{bef, closest, aft} = split(histo.bins, value)
new_value =
((bin_value(closest) * bin_count(closest)) + value * 1) / (bin_count(closest) + 1)
new_bin = {new_value, bin_count(closest) + 1}
new_bins = bef ++ [new_bin] ++ aft
%{histo | bins: new_bins}
else
# Split the list into:
# [before] | closest | [after]
# Based on closest, come up with a 1 or 2 element list in the middle, then concat all 3 lists.
# [before] [closest, new] [after] <-- value is bigger than the closest
# [before] [new, closest] [after] <-- value is smaller than the closest
# [before] [new] [after] <-- First element and identical value cases
float_value = value / 1
{bef, closest, aft} = split(histo.bins, float_value)
middle = cond do
closest == nil ->
[{float_value, 1}]
bin_value(closest) == float_value ->
[{float_value, bin_count(closest) + 1}]
bin_value(closest) < float_value ->
[closest, {float_value, 1}]
bin_value(closest) > float_value ->
[{float_value, 1}, closest]
end
new_bins = bef ++ middle ++ aft
%{histo | bins: new_bins}
end
end
def bin_value({value, _}), do: value
def bin_count({_, count}), do: count
def bins_used(%__MODULE__{} = histo) do
Enum.count(histo.bins)
end
@spec to_list(t) :: list(bin)
def to_list(%__MODULE__{} = histo) do
histo.bins
end
def percentile(%__MODULE__{} = histo, percentile) do
target = size(histo) * (percentile / 100)
Enum.reduce_while(
histo.bins,
target,
fn {value, count}, remaining ->
next = remaining - count
if next <= 0 do
{:halt, value}
else
{:cont, next}
end
end
)
end
# Figure out which percentile this value would slot into
def percentile_for_value(%__MODULE__{} = histo, target) do
found_at = Enum.reduce_while(
histo.bins,
0,
fn {bin_val, bin_count}, count ->
if bin_val > target do
{:halt, count}
else
{:cont, count + bin_count}
end
end
)
# Protect against div by 0
s = size(histo)
if s == 0 do
0
else
found_at / size(histo) * 100
end
end
@spec at_capacity?(t) :: boolean()
defp at_capacity?(%__MODULE__{} = histo) do
histo.options.max_bins == Enum.count(histo.bins)
end
# returns three-tuple: {[before], closest, [after]}
# before and after may be empty lists
defp split(bins, value) do
{bef, aft} = Enum.split_while(bins, fn {bin_val, _} -> value > bin_val end)
bef_closest = List.last(bef)
bef_rest = Enum.drop(bef, -1)
aft_closest = List.first(aft)
aft_rest = Enum.drop(aft, 1)
cond do
bef_closest == nil ->
{[], aft_closest, aft_rest}
aft_closest == nil ->
{bef_rest, bef_closest, []}
true ->
dist_to_bef = value - bin_value(bef_closest)
dist_to_aft = value - bin_value(aft_closest)
if dist_to_bef < dist_to_aft do
{bef_rest, bef_closest, aft}
else
{bef, aft_closest, aft_rest}
end
end
end
end