When generating sample data, it’s not uncommon to need data with some sort of weighted distribution. For such sampling, Math.random() (or your language’s random number function) can be a bit too random. Today, we’ll be taking a look at a simple and extensible method for generating weighted random numbers in JavaScript.

Before we examine any code, let’s take a look at what the main WeightedRandom function expects as its sole argument:

[
  {"value": 1, "weight": 1},
  {"value": 2, "weight": 1},
  {"value": 3, "weight": 2}
]

Nothing too complicated, at least at first glance. Just an array of objects, each one having value and weight properties. weight is expected to be an integer. value can be anything we want, but for the purposes of the demonstrations below, we will just use integers or functions that return integers. It’s important to note that either value or weight can be a function. If weight is a function, it will be evaluated when we create a new WeightedRandom object, and likewise if value is a function, it will be evaluated whenever next is called.

Now that we know what to give WeightedRandom, let’s take a look at the function itself:

function WeightedRandom(shape) {
  var weightedRandom, valueFunction, weight, i, j;

  weightedRandom = this;

  weightedRandom.samples = [];

  for (i = 0; i < shape.length; i += 1) {
    if (shape[i] && shape[i].hasOwnProperty("value") && shape[i].hasOwnProperty("weight")) {
      valueFunction = getValueFunction(shape[i].value);

      weight = getWeight(shape[i].weight);

      for (j = 0; j < weight; j += 1) {
        weightedRandom.samples.push(valueFunction);
      }
    }
  }
}

For each element in the shape array, WeightedRandom calls getValueFunction, passing in the value. If it’s a function, it returns itself; otherwise, it gets a wrapper function (we’ll see why when we look at the Next function) that returns the value. After that, the weight of each value is calculated (if necessary) by getWeight. This allows us to pass a function as the weight. Finally, the valueFunction is added to the samples array weight number of times. That last part is the most important piece of this method of generating weighted random numbers. The sample array ends up containing the values from which we want to take a sample, but the number of times each appears in the array is equal to its weight. Relatively “heavier” values appear more times, and “lighter” values appear fewer times. This brings us to the next function, which we use to actually get our random numbers.

WeightedRandom.prototype.next = function() {
  var weightedRandom;

  weightedRandom = this;

  return weightedRandom.samples[Math.floor(Math.random() * weightedRandom.samples.length)]();
};

Since all the interesting work has already been done, Next just randomly selects a sample value function and then calls it to get our sample value. This is why WeightedRandom created a wrapper function, if necessary, for each value. By doing so, we can keep next simple.

The entire source for WeightedRandom can be seen here.

Unweighted Random Number Graphs

Unweighted Example

Here is a graph of the distribution of 1,000 random integers between 1 and 10. Since there’s no weighting to the randomness, you can see that the distribution is mostly flat. You can expect some variation, but that’s simply because randomness is clumpy.

Results (1,000 Samples):


Weighted Random Number Graphs

Weighted Example 1

This example shows how we can use WeightedRandom to generate a bimodal distribution.

Shape:

shape = [
  {"value": 10, "weight": 1},
  {"value": 20, "weight": 2},
  {"value": 30, "weight": 3},
  {"value": 40, "weight": 2},
  {"value": 50, "weight": 1},
  {"value": 60, "weight": 1},
  {"value": 70, "weight": 1},
  {"value": 80, "weight": 3},
  {"value": 90, "weight": 4},
  {"value": 100, "weight": 2}
];

Results (1,000 Samples):


Weighted Example 2

Below is a slightly more complex shape for our random numbers. The values toward the ends of the list are weighted more strongly, but 0, the value in the middle, is weighted using a function. This function returns the current time in milliseconds since the Unix Epoch modulo 12. This means that the weight of the value 0 varies with time. Try pressing the “Resample” button many times to see the number of samples that end up being 0 jump around.

Shape:

shape = [
  {"value": -9, "weight": 10},
  {"value": -8, "weight": 9},
  {"value": -7, "weight": 8},
  {"value": -6, "weight": 7},
  {"value": -5, "weight": 6},
  {"value": -4, "weight": 5},
  {"value": -3, "weight": 4},
  {"value": -2, "weight": 3},
  {"value": -1, "weight": 2},
  {"value": 0, "weight": function() { return Date.now() % 12; }},
  {"value": 1, "weight": 2},
  {"value": 2, "weight": 3},
  {"value": 3, "weight": 4},
  {"value": 4, "weight": 5},
  {"value": 5, "weight": 6},
  {"value": 6, "weight": 7},
  {"value": 7, "weight": 8},
  {"value": 8, "weight": 9},
  {"value": 9, "weight": 10}
];

Results (1,000 Samples):


Weighted Example 3

Finally, the last example lets you create your own weighted distribution. Just click “Add Value,” enter an integer for the value and an integer for the weight, and then click “Resample.”

Shape:


References

  1. Randomness Is Clumpy.” Dotmaths.com.
  2. Unix Time.” Wikipedia.com.