1

I am trying to generate a number that follows a cosine curve with an increased period. The goal is to generate a number between 0 and 1, but be able to supply an amplitude/factor and a period/interval.

For example, given the following:

10_000.times.map do
  num = randcos(factor: 5, intervals: 3)
  (num * 13).floor # Group in 12 groups for graphing visibility
end

And then graphing those points based on frequency, I would want a chart that looks something like this:

desired graph

Because we set intervals: 3, we have 3 "spikes" where those groups of numbers are more likely. The low points still occur, but are approximately 1/5 as likely because we set factor: 5.

I've been trying to use Math.cos to do this, but I'm getting stuck when trying to map those higher numbers to a likelihood of generating a new set. I can get it to show the one "spike" just using Math.sin/Math.cos, but I want to be able to customize how many of them show up.

Here is an example of my most recent iteration, although it just has a descending chart:

def randcos(factor:, intervals:)
  max_scale = intervals*Math::PI
  min_scale = -max_scale

  # Grab a random index and apply it to the squished cos
  x = min_scale + (2 * max_scale * rand) 
  num = (Math.cos(x)+1)/2 # Normalize 0..1 instead of -1..1

  # Trying to use the `num` from above to then multiply by the factor to give us 
  # more likely high numbers when the cos is nearer to 1
  rand * num * factor 
end
Rockster160
  • 1,579
  • 1
  • 15
  • 30

1 Answers1

2

This sounded like a fun problem, so I gave it a go. This code is in javascript, both because I'm more familiar with it than ruby and because it can then be embedded on S.O., but the principles are the same and you should be able to translate it.

I did this in 3 parts.

1. The cosine formula

We want a cosine function with a given amplitude between the most likely numbers and the least likely numbers. You named this factor.

We also want that function to start and end at the minimum value and reach the maximum value a certain number of times. You named this intervals.

The function for this looks like:

enter image description here

Where a represents intervals and b represents factor. In this example, I used your factor of 5 and intervals of 3 to produce this graph.

enter image description here

This function allows me to enter any x to get its relative probability.

That function translated to JS code looks like this:

const func = (x) => (1 + ((factor - 1)*(1 - Math.cos(2*Math.PI*intervals*x))/2))/(factor);

2. Precalculating Probability Weights.

Now that we have this function, we can determine how likely any number is to appear. Math.random() always returns a number between 0 and 1, and we want to map that value to the range of possible values. In the case of your example, that is 0-12, or 13 possible values.

The domain of our function we defined above is [0, 1], so we will divide that into 13 equal steps to calculate the probability of each number.

const stepLength = 1 / (definitionSteps-1);
//cosine function
const func = (x) => (1 + ((factor - 1)*(1 - Math.cos(2*Math.PI*intervals*x))/2))/(factor);
  
let weights = [];
for(let i = 0; i < definitionSteps; i++)
{
  weights.push(func(i * stepLength));
}

We will then cumulatively sum each weight so that each value in our weights array is equal to itself, plus the sum of all the preceding weights. This gives us a range of values for each index, but the scale is off. To fix this, we just divide each value in the array by the maximum value (or the last value).

3. Mapping the values.

Now that we have an array of weight ranges, we can simply call Math.random() to get a random number and map it to our possible values based on our weight ranges.

Say I have these weights: [0.1, 0.3, 0.6, 1]

If I call Math.random() and get 0.4, that would map to index 2, since 0.4 is between 0.3 and 0.6. We do this same thing for our precalculated cosine weights.

The actual weight array for n=13, factor=5, intervals=3 looks like:

[0.025, 0.096, 0.223, 0.318, 0.350, 0.398, 0.513, 0.628, 0.676, 0.707, 0.902, 0.929, 1]
  • So any number between 0 and 0.025 will be mapped to 0.
  • Any number between 0.025 and 0.096 will be mapped to 1.
  • and so on...

The last part of the code just calculates 10,000 random numbers, sorts them, and maps them to their respective indexes. I did it this way for performance to simulate 10,000 runs, but using the weights array, you can directly map any random number between [0, 1] to its corresponding weight.

The final output is printed to the console, and it looks very similar to your mockup graph in your question. In my case, I get:

[
  261,  //0
  783,  //1
  1387, //2
  810,  //3
  272,  //4
  801,  //5
  1375, //6
  833,  //7
  302,  //8
  800,  //9
  1309, //10
  795,  //11
  272   //12
]

const definitionSteps = 13;
const factor = 5;
const intervals = 3;
const count = 10000;

function calculateCosineWeights(factor, intervals)
{
  const stepLength = 1 / (definitionSteps-1);
  //cosine function
  const func = (x) => (1 + ((factor - 1)*(1 - Math.cos(2*Math.PI*intervals*x))/2))/(factor);
  
  let weights = [];
  for(let i = 0; i < definitionSteps; i++)
  {
    weights.push(func(i * stepLength));
  }
  
  weights.forEach((e, i) => {
    if(i > 0)
      weights[i] += weights[i - 1];
  });
  
  let max = weights[weights.length - 1];
  weights = weights.map(e => e / max);
  
  return weights;
}

let precalculatedWeights = calculateCosineWeights(factor, intervals);

function randcos(n)
{
  let vals = [];
  while(n > 0)
  {
    vals.push(Math.random());
    n--;
  }
  
  vals.sort((a, b) => a-b);
  
  let index = 0;
  let runningIndex = 0;
  let output = [];
  while(index < definitionSteps)
  {
    let x = 0;
    for(let i = runningIndex; i < vals.length && vals[i] < precalculatedWeights[index]; i++)
    {
      x++;
    }
    
    runningIndex += x;
    output.push(x);
    index++;
  }
  
  return output;
}

console.log(randcos(count));
Liftoff
  • 24,717
  • 13
  • 66
  • 119
  • Wow! Very impressive! This is SO close to what I need. Unfortunately, the `definition_steps` are NOT supposed to be part of the equation- I grouped them into 13 groups after the fact as a way to demonstrate that the values were near each other- but the values are supposed to still be "random" between 0 and 1. I hugely appreciate the answer though! I'll definitely be digging through it and trying to modify it to fit my needs. – Rockster160 Jun 17 '23 at 06:05
  • In that case you could precalculate a larger array of weights, say 1000, or 10000, and then when you want to visualize your data, you can map those weights to a smaller set. The definitionSteps just defines the "resolution" of the weight array -- you can make it as large or small as you want. – Liftoff Jun 17 '23 at 06:10
  • 2
    Rockster160 : Given that your density function is bounded on an interval, a simpler way to proceed would be the rejection sampling method, which I discuss at: https://stackoverflow.com/questions/66874819/random-numbers-with-user-defined-continuous-probability-distribution/66875719#66875719 . In this case the PDF is `(1 + ((factor - 1)*(1 - Math.cos(2*Math.PI*intervals*x))/2))/(factor)` and the maximum value of that PDF is `1`. – Peter O. Jun 17 '23 at 06:24
  • @PeterO. That worked perfectly! It's spitting out exactly what I expect. Thank you! – Rockster160 Jun 17 '23 at 06:37