基于百分比加权的选择

29

我有一组值,每个值都有一个对应的百分比:

a: 70% 的概率
b: 20% 的概率
c: 10% 的概率

我想根据给定的百分比概率选择一个值(a、b、c)。

我该如何处理?


到目前为止,我的尝试看起来像这样:

r = random.random()
if r <= .7:
    return a
elif r <= .9:
    return b
else: 
    return c

我陷入了一个算法难题,如何处理这个问题,使其可以处理更大的数据集,而不仅仅是将if-else流链接在一起。


(使用伪代码或Python、C#实现的解释都可以。)


我曾经遇到过这个问题,最终建立了一个库:https://github.com/kinetiq/Ether.WeightedSelector - Brian MacKay
这里有一个非常好的、简单的C#实现: http://www.vcskicks.com/random-element.php - Roboblob
13个回答

38

以下是C#的完整解决方案:

public class ProportionValue<T>
{
    public double Proportion { get; set; }
    public T Value { get; set; }
}

public static class ProportionValue
{
    public static ProportionValue<T> Create<T>(double proportion, T value)
    {
        return new ProportionValue<T> { Proportion = proportion, Value = value };
    }

    static Random random = new Random();
    public static T ChooseByRandom<T>(
        this IEnumerable<ProportionValue<T>> collection)
    {
        var rnd = random.NextDouble();
        foreach (var item in collection)
        {
            if (rnd < item.Proportion)
                return item.Value;
            rnd -= item.Proportion;
        }
        throw new InvalidOperationException(
            "The proportions in the collection do not add up to 1.");
    }
}

使用方法:

var list = new[] {
    ProportionValue.Create(0.7, "a"),
    ProportionValue.Create(0.2, "b"),
    ProportionValue.Create(0.1, "c")
};

// Outputs "a" with probability 0.7, etc.
Console.WriteLine(list.ChooseByRandom());

出现了错误,不得不更改ChooseByRandom的定义为:public static T ChooseByRandom<T>(this System.Collections.Generic.IEnumerable<ProportionValue<T>> collection) - Jonny
此外,如果它可以接受任何值而不仅仅是0.3等,那将非常好。它应该累加所有的值并自动计算百分比,这样用户就不必关心了。例如,值400和1600最终会变成0.2和0.8等。 - Jonny
@Jonny 你的第二个建议很容易实现:1)制作一个接收值映射的函数,将映射的键设置为概率。2)求出所有键(概率)的值之和。在你的例子中是2000。3)将每个键(概率)除以总数,结果将是该键相对于总数的比例,在0到1之间。在这种情况下,就像你的例子一样,分别是0.2和0.8。 - CosmicGiant
@Timwi,你能告诉我这个算法的名字是什么吗? - Zeeshan Ali Khan
很好,重要的是要提到上面的解决方案并不是线程安全的方法。 - Ben

9

对于Python:

>>> import random
>>> dst = 70, 20, 10
>>> vls = 'a', 'b', 'c'
>>> picks = [v for v, d in zip(vls, dst) for _ in range(d)]
>>> for _ in range(12): print random.choice(picks),
... 
a c c b a a a a a a a a
>>> for _ in range(12): print random.choice(picks),
... 
a c a c a b b b a a a a
>>> for _ in range(12): print random.choice(picks),
... 
a a a a c c a c a a c a
>>> 

概述:创建一个列表,其中每个项目重复的次数与其应该具有的概率成比例;使用random.choice随机选择一个(均匀分布),这将符合您所需的概率分布。如果您的概率以奇怪的方式表示(例如,70,20,10会创建一个包含100个元素的列表,而7,2,1只会创建一个完全相同行为的10个元素的列表),那么可能会浪费一些内存,但是如果您认为这在特定应用场景下很重要,可以将概率列表中的所有计数除以它们的最大公因数。

除了内存消耗问题外,这应该是最快的解决方案-每个所需输出结果只需要生成一个随机数,并且从该随机数中进行最快可能的查找,不需要比较等操作。如果您的可能概率非常奇怪(例如,需要将浮点数与许多有效数字匹配),则可能会优先考虑其他方法;-)。


@Timwi,你有进行过“测量”吗?只需创建一次列表,然后从中生成许多随机数,你可能会惊讶于其表现如何。@Mark,我确实说过,如果你得到的是非常精确的浮点数,需要在预期的概率分布中匹配很多位数字,那么这并不是最优选择(当然,这不是一个明智的规范,但是,谁指定并支付代码的人并不总是一个明智的人,特别是当他们用别人的钱支付时...;-)。OP说“百分比”,而这些通常四舍五入到最近的百分之一,你知道吗? - Alex Martelli
@Alex,你说得没错,这确实符合规格要求。一旦解读picks生成器,它也非常容易理解。但是,我觉得有点难以推荐一个局限的解决方案,尤其当一个更通用的方案几乎同样简单时。 - Mark Ransom
@Mark,我的代码转换成函数后实际上比你的更简单 - 当条件满足时,性能可能会更好。 "picks generator"(实际上不是一个生成器 - 它是一个列表推导式)当然可以轻松地重构为循环 - 它只是一个_初步_(不在每个调用上执行,仅在所需概率_更改_的那些调用上执行),因此列表推导式或循环的性能在任何正常,有用,明智的情况下可能会被摊销掉。 - Alex Martelli
@Alex,你说服了我。对于我的不精确术语,我很抱歉。 - Mark Ransom
对于分数概率,尽可能地进行乘法运算。例如,32.443% --> 32443。如果您精确到0.001%,那只需要一个包含100k的列表,这并不是什么大问题。对于我处理的所有问题,0.001%的精度已经足够了。非常好的解决方案,谢谢。 - Pete
显示剩余3条评论

8

1
请参见https://dev59.com/PG445IYBdhLWcg3wH2vH - 这也被称为Vose的别名方法,由于此处对该方法(启动时间)的改进。 - BlueRaja - Danny Pflughoeft

6

将权重列表累加得到:70、70+20、70+20+10。随机选择一个大于或等于0且小于总和的数字。遍历项目并返回第一个值,其中权重的累积和大于此随机数:

def select( values ):
    variate = random.random() * sum( values.values() )
    cumulative = 0.0
    for item, weight in values.items():
        cumulative += weight
        if variate < cumulative:
            return item
    return item # Shouldn't get here, but just in case of rounding...

print select( { "a": 70, "b": 20, "c": 10 } )

这个解决方案实现后,应该能够处理分数权重和加起来等于任何非负数的权重。


当我第一次看到这个答案时,它里面没有任何代码。看起来我们同时忙着想出基本相同的代码。 - Mark Ransom

3
def weighted_choice(probabilities):
    random_position = random.random() * sum(probabilities)
    current_position = 0.0
    for i, p in enumerate(probabilities):
        current_position += p
        if random_position < current_position:
            return i
    return None

因为random.random总是返回小于 1.0的值,所以最终的return语句永远不会被执行。

注意:如果你的分布已经标准化了,那么 sum(probabilities) 就不是必要的了。这段代码也能正确地排除掉概率为0的选择。 - ninjagecko

3
  1. 令T为所有项目重量之和
  2. 令R为0到T之间的随机数
  3. 迭代项目列表,从R中减去每个项目的重量,并返回导致结果变成<= 0的项目。

+1 因为在我的版本中,我先对列表进行了排序,然后再进行迭代,而你让我意识到这是不必要的。 - Brian MacKay

2
今天,Python文档的更新提供了一个使用加权概率生成random.choice()的例子:
如果权重是小整数比率,则可以使用简单技巧构建具有重复项的样本集合:
>>> weighted_choices = [('Red', 3), ('Blue', 2), ('Yellow', 1), ('Green', 4)]
>>> population = [val for val, cnt in weighted_choices for i in range(cnt)]
>>> random.choice(population)
'Green'

更一般的方法是使用itertools.accumulate()将权重累积到累积分布中,然后使用bisect.bisect()定位随机值:

>>> choices, weights = zip(*weighted_choices)
>>> cumdist = list(itertools.accumulate(weights))
>>> x = random.random() * cumdist[-1]
>>> choices[bisect.bisect(cumdist, x)]
'Blue'

注意: itertools.accumulate() 需要使用 Python 3.2 或者等效版本。


2
import random

def selector(weights):
    i=random.random()*sum(x for x,y in weights)
    for w,v in weights:
        if w>=i:
            break
        i-=w
    return v

weights = ((70,'a'),(20,'b'),(10,'c'))
print [selector(weights) for x in range(10)] 

它同样适用于分数重量

weights = ((0.7,'a'),(0.2,'b'),(0.1,'c'))
print [selector(weights) for x in range(10)] 

如果你有很多权重要处理,可以使用二分法(bisect)来减少所需的迭代次数。

import random
import bisect

def make_acc_weights(weights):
    acc=0
    acc_weights = []
    for w,v in weights:
        acc+=w
        acc_weights.append((acc,v))
    return acc_weights

def selector(acc_weights):
    i=random.random()*sum(x for x,y in weights)
    return weights[bisect.bisect(acc_weights, (i,))][1]

weights = ((70,'a'),(20,'b'),(10,'c'))
acc_weights = make_acc_weights(weights)    
print [selector(acc_weights) for x in range(100)]

对于分数权重也可以正常工作

weights = ((0.7,'a'),(0.2,'b'),(0.1,'c'))
acc_weights = make_acc_weights(weights)    
print [selector(acc_weights) for x in range(100)]

1

如果你真的想快速生成随机值,而且已经掌握了相关技能,那么mcdowella在https://dev59.com/wnA65IYBdhLWcg3wvhXi#3655773中提到的Walker算法几乎是最好的选择(对于random()函数,时间复杂度为O(1),对于预处理,时间复杂度为O(N))。

对于任何感兴趣的人,这里是我自己实现的PHP版本:

/**
 * Pre-process the samples (Walker's alias method).
 * @param array key represents the sample, value is the weight
 */
protected function preprocess($weights){

    $N = count($weights);
    $sum = array_sum($weights);
    $avg = $sum / (double)$N;

    //divide the array of weights to values smaller and geq than sum/N 
    $smaller = array_filter($weights, function($itm) use ($avg){ return $avg > $itm;}); $sN = count($smaller); 
    $greater_eq = array_filter($weights, function($itm) use ($avg){ return $avg <= $itm;}); $gN = count($greater_eq);

    $bin = array(); //bins

    //we want to fill N bins
    for($i = 0;$i<$N;$i++){
        //At first, decide for a first value in this bin
        //if there are small intervals left, we choose one
        if($sN > 0){  
            $choice1 = each($smaller); 
            unset($smaller[$choice1['key']]);
            $sN--;
        } else{  //otherwise, we split a large interval
            $choice1 = each($greater_eq); 
            unset($greater_eq[$choice1['key']]);
        }

        //splitting happens here - the unused part of interval is thrown back to the array
        if($choice1['value'] >= $avg){
            if($choice1['value'] - $avg >= $avg){
                $greater_eq[$choice1['key']] = $choice1['value'] - $avg;
            }else if($choice1['value'] - $avg > 0){
                $smaller[$choice1['key']] = $choice1['value'] - $avg;
                $sN++;
            }
            //this bin comprises of only one value
            $bin[] = array(1=>$choice1['key'], 2=>null, 'p1'=>1, 'p2'=>0);
        }else{
            //make the second choice for the current bin
            $choice2 = each($greater_eq);
            unset($greater_eq[$choice2['key']]);

            //splitting on the second interval
            if($choice2['value'] - $avg + $choice1['value'] >= $avg){
                $greater_eq[$choice2['key']] = $choice2['value'] - $avg + $choice1['value'];
            }else{
                $smaller[$choice2['key']] = $choice2['value'] - $avg + $choice1['value'];
                $sN++;
            }

            //this bin comprises of two values
            $choice2['value'] = $avg - $choice1['value'];
            $bin[] = array(1=>$choice1['key'], 2=>$choice2['key'],
                           'p1'=>$choice1['value'] / $avg, 
                           'p2'=>$choice2['value'] / $avg);
        }
    }

    $this->bins = $bin;
}

/**
 * Choose a random sample according to the weights.
 */
public function random(){
    $bin = $this->bins[array_rand($this->bins)];
    $randValue = (lcg_value() < $bin['p1'])?$bin[1]:$bin[2];        
}

1

这是我适用于任何 IList 并规范化权重的版本。它基于 Timwi 的解决方案:基于百分比加权的选择

/// <summary>
/// return a random element of the list or default if list is empty
/// </summary>
/// <param name="e"></param>
/// <param name="weightSelector">
/// return chances to be picked for the element. A weigh of 0 or less means 0 chance to be picked.
/// If all elements have weight of 0 or less they all have equal chances to be picked.
/// </param>
/// <returns></returns>
public static T AnyOrDefault<T>(this IList<T> e, Func<T, double> weightSelector)
{
    if (e.Count < 1)
        return default(T);
    if (e.Count == 1)
        return e[0];
    var weights = e.Select(o => Math.Max(weightSelector(o), 0)).ToArray();
    var sum = weights.Sum(d => d);

    var rnd = new Random().NextDouble();
    for (int i = 0; i < weights.Length; i++)
    {
        //Normalize weight
        var w = sum == 0
            ? 1 / (double)e.Count
            : weights[i] / sum;
        if (rnd < w)
            return e[i];
        rnd -= w;
    }
    throw new Exception("Should not happen");
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接