Implement a weighted shuffle in Ruby

In my previous post I’ve implemented a Fisher-Yates shuffle algoritm in Ruby. Today I want to try somenthing complex: a weighted (or priority) based shuffle.

Given an array of hashes like this:

weighted_array = [
  {value: "HIGH 1", weight: 4},  
  {value: "HIGH 2", weight: 4},  
  {value: "MID 1", weight: 2},  
  {value: "MID 2", weight: 2},  
  {value: "LOW 1", weight: 1},  
  {value: "LOW 2", weight: 1},  
]

I want to get an array of 10 elements randomly picked across it based on their weights; something like this:

result = ["HIGH 1", "MID 2", "HIGH 2", "HIGH 1", "LOW 2", "MID 1", "HIGH 2", "HIGH 1", "MID 1", "HIGH 2"]

To do this I need to calculate the probability of any weight present in the first array. In order to achieve this I have to calculate the sums of all distinct weights.

def sum_of_weights weighted
  # .to_f for decimals in probability calculation
  weighted.map{ |w| w[:weight] }.uniq.inject(:+).to_f
end

Now I can call sum = sum_of_weights(weighted_array) and get 7, with the sum I can get the probability of each weight, simply with probability = weighted_array[0][:weight] / sum and so on

Now I can define the cut offs associated to each weight using the probability calculator below.

def probabilities weighted_array
  sum_of_weights = sum_of_weights(weighted_array)

  # initial probability
  probability = 0.0

  weights = weighted_array.map{ |w| w[:weight] }.uniq.sort
  weights.map do |w|
    current_probability = (w / sum_of_weights).round(3)
    # sum the current_probability with the previous
    probability += current_probability
    { weight: w, probability: probability }
  end
end

Given the weighted_array the above function returns an array like this:

probabilities = [
  { weight: 1, probability: 0.143 },
  { weight: 2, probability: 0.429 },
  { weight: 4, probability: 1.0 },
]

The next step is to generate a random number to select a weight based on its probability. Conceptually I need a series of if..elsif, but given the probabilities array I can use this function:

def get_weight probabilities
  r = rand
  probabilities.each do |p|
    return p[:weight] if r < p[:probability]
  end
end

Once I get the weight randomly, I can pick an element from weighted_array, filtered by the selected weight. With the ruby Array#select method:

def by_weight a, weight
  a.select{ |el| el[:weight] == weight }
end

At this point I can use the Array#sample method and get a random element from the filtered array. By repeating this 10 times I get the desidered result!

result = []
10.times do
  weight = get_weight probabilities
  result << by_weight(weighted_array, weight).sample[:value]
end

You can find all the code and little more here

During my research I have discovered various methods to achieve the desidered result. What I presented here is what I think has the best trade off between difficulty and result.

I hope to explore in more detail this sort of thing in my next post.

Bye and see you soon!

Leave a Reply

wpDiscuz