Walker's Alias Method — weighted random sampling in constant time.
A WAM table is built once from a set of values and their weights, and every
draw afterwards costs one integer index, one comparison and at most two tuple
reads. Neither the number of values nor the spread of the weights changes that
cost, which is what the structure exists for: sampling from tens of thousands
of weighted values as cheaply as from three.
Installation
Add wam to the dependencies in mix.exs:
def deps do
[
{:wam, "~> 0.3.0"}
]
endBuilding a table
new/1 takes the values and their weights together, in whichever shape the
caller already holds them — a map, or a list of {value, weight} pairs:
wam = WAM.new(%{a: 10, b: 4, c: 5})
wam = WAM.new([{:a, 10}, {:b, 4}, {:c, 5}])new/2 takes them as two lists of equal length, paired by position:
wam = WAM.new([:a, :b, :c], [10, 4, 5])Weights are relative and the caller does not normalise them: %{a: 10, b: 4, c: 5}
and %{a: 10 / 19, b: 4 / 19, c: 5 / 19} describe the same distribution and build
interchangeable tables. Integers and floats mix freely.
Building is O(n) and allocates four tuples. It is meant to happen once, at load
time, and the resulting struct to be held and read many times.
Drawing
A draw needs two numbers from the caller: a bucket index in [0, size) and a
random float in [0, 1).
index = :rand.uniform(wam.size) - 1
rng = :rand.uniform()
{:ok, value} = WAM.fetch(wam, index, rng)Both come from outside on purpose. The library holds no generator state and never
calls one, so the caller chooses the source — :rand, a fixed sequence in a test,
or any other random number generator — and the table stays a plain immutable term
that can live in ETS, be shared between processes and be drawn from concurrently
without a lock.
fetch/3 answers {:ok, value}, get/4 answers the value or a default, and
index/3 answers the drawn index instead of the value — useful when the values are
large, or are held elsewhere and only their position is needed.
How it works
The alias method reshapes an arbitrary distribution over n values into n
equal-sized buckets, each holding at most two of them. Every weight is compared
against the mean weight; a value lighter than the mean cannot fill its own bucket
on its own, so the rest of that bucket is given to a value heavier than the mean,
which is recorded as the bucket's alias. The pairing repeats — the heavy value's
leftover weight goes back to whichever side it now belongs to — until every bucket
is full.
That leaves two tuples of n entries. aliases[i] is the index the bucket falls
back to, and probs[i] is the share of bucket i that belongs to value i. A draw
picks a bucket uniformly, then decides between the bucket's own value and its alias
with a single comparison. The uniform bucket choice and the split inside the bucket
together reconstruct the original weights, which is why both numbers have to be
drawn and neither may be derived from the other.
The probability table
probs[i] is the probability of keeping bucket i, so a draw reads
if rng < probs[i], do: i, else: aliases[i]This orientation is what makes a weight of 0 unreachable by construction rather
than merely improbable. A zero-weight value is always paired away, so its probs
entry is exactly 0.0, and rng < 0.0 is false for any rng >= 0 — the
guarantee holds whatever the generator's upper bound turns out to be and whatever a
caller passes. Code that mutes content by giving it a weight of 0 can rely on it:
wam = WAM.new([{:a, 10}, {:muted, 0}, {:c, 5}])
# no index and no rng produce :mutedprobs and aliases are public so a table can be inspected, but nothing outside
this module should draw by reading them. Reading a slot without drawing is at/3
and fetch_at/2, never get/4 with an rng picked to defeat the comparison —
that trick depends on how probs is oriented and breaks silently when it changes.
Weights that sum to zero
The muting above works because the muted slot is paired away with a slot heavier
than the mean. When the weights sum to 0 there is no heavy slot to pair with:
every slot lands below the mean, the pairing loop rewrites no alias, and each slot
keeps the identity alias it started with. A slot whose alias is itself is returned
by the draw for any value of probs — rng < probs[i] picks i, and
rng >= probs[i] picks aliases[i], which is also i.
Unreachability is therefore not a state this structure can express, and new/1
raises ArgumentError rather than handing back a table that cannot honour what its
weights say:
WAM.new(%{a: 0, b: 0})
** (ArgumentError) cannot build a WAM whose weights sum to 0: ...Before 0.3.0 that case answered a table of probs filled with 1.0, which drew
uniformly over values every one of which had been muted. That was a defect, not
a policy. The clause also stood on a literal 0, which does not match 0.0, so
float zeros fell through and died with an ArithmeticError instead — one meaning,
two incompatible behaviours. The guard is now sum == 0, and both reach the same
refusal.
Summary
Functions
Reads the value stored at index without drawing, answering default where
fetch_at/2 answers an error tuple. default is nil unless given.
Draws a value.
Reads the value stored at index, without drawing.
Draws a value, answering default where fetch/3 answers an error tuple.
Draws an index rather than a value.
Builds a table from values and weights held together.
Builds a table from two lists of equal length, paired by position.
Types
@type t() :: %WAM{ aliases: tuple(), probs: tuple(), size: non_neg_integer(), values: tuple() }
A built sampling table.
values— the values, in the order they were given, indexed by bucketprobs— per bucket, the probability of keeping its own value rather than its aliasaliases— per bucket, the index the draw falls back tosize— the number of buckets, equal to the number of values
Build it with new/1 or new/2. The fields are public for inspection, not for
drawing by hand.
Functions
@spec at(t(), non_neg_integer(), term()) :: term()
Reads the value stored at index without drawing, answering default where
fetch_at/2 answers an error tuple. default is nil unless given.
Examples
iex> wam = WAM.new([{:a, 10}, {:b, 4}])
iex> WAM.at(wam, 0)
:a
iex> WAM.at(wam, 2, :none)
:none
@spec fetch(t(), non_neg_integer(), float()) :: {:ok, term()} | {:error, :invalid_index}
Draws a value.
index is a bucket, expected uniform in [0, size); rng is a float, expected
uniform in [0, 1). Both come from the caller's own generator — see Drawing.
Answers {:error, :invalid_index} for anything that is not an integer in
[0, size).
Examples
iex> wam = WAM.new([{:a, 10}, {:b, 4}, {:c, 5}])
iex> WAM.fetch(wam, 1, 0.99)
{:ok, :a}
iex> WAM.fetch(wam, 3, 0.5)
{:error, :invalid_index}
iex> WAM.fetch(wam, -1, 0.5)
{:error, :invalid_index}
@spec fetch_at(t(), non_neg_integer()) :: {:ok, term()} | {:error, :invalid_index}
Reads the value stored at index, without drawing.
Use this instead of calling get/4 with an rng chosen to defeat the alias —
that trick depends on how probs is oriented and breaks silently when it changes.
Answers {:error, :invalid_index} for anything that is not an integer in
[0, size).
Examples
iex> wam = WAM.new([{:a, 10}, {:b, 4}])
iex> WAM.fetch_at(wam, 1)
{:ok, :b}
iex> WAM.fetch_at(wam, 2)
{:error, :invalid_index}
@spec get(t(), non_neg_integer(), float(), term()) :: term()
Draws a value, answering default where fetch/3 answers an error tuple.
The arguments are fetch/3's. default is nil unless given.
Examples
iex> wam = WAM.new([{:a, 10}, {:b, 4}])
iex> WAM.get(wam, 5, 0.5)
nil
iex> WAM.get(wam, 5, 0.5, :none)
:none
@spec index(t(), non_neg_integer(), float()) :: {:ok, non_neg_integer()} | {:error, :invalid_index}
Draws an index rather than a value.
The draw fetch/3 performs, stopping one step earlier. Reach for it when the values
are large, or are held elsewhere and only their position is wanted.
Examples
iex> wam = WAM.new([{:a, 10}, {:b, 4}, {:c, 5}])
iex> WAM.index(wam, 1, 0.99)
{:ok, 0}
iex> WAM.index(wam, 3, 0.5)
{:error, :invalid_index}
iex> WAM.index(wam, -1, 0.5)
{:error, :invalid_index}
Builds a table from values and weights held together.
Accepts a map of value => weight, or a list of {value, weight} pairs. Weights
are relative: they need not sum to 1, and integers and floats mix.
A weight of 0 mutes its value — see The probability table. Weights that sum to
0 have no honest table and raise ArgumentError — see Weights that sum to zero.
Examples
iex> wam = WAM.new(%{a: 10, b: 4, c: 5})
iex> wam.size
3
iex> wam = WAM.new([{:a, 10}, {:b, 4}, {:c, 5}])
iex> WAM.at(wam, 0)
:a
iex> WAM.new(%{a: 0, b: 0})
** (ArgumentError) cannot build a WAM whose weights sum to 0: with no slot above the mean every alias points at its own slot, so no value is unreachable whatever `probs` holds and nothing is drawable. A weight of 0 mutes its slot only beside at least one positive weight.
Builds a table from two lists of equal length, paired by position.
A length mismatch raises FunctionClauseError rather than truncating to the
shorter list: either list being the wrong length means the caller's pairing is not
what it thinks it is.
Examples
iex> wam = WAM.new([:a, :b, :c], [10, 4, 5])
iex> wam.size
3
iex> WAM.new([:a, :b, :c], [10, 4])
** (FunctionClauseError) no function clause matching in WAM.new/2