web-dev-qa-db-ja.com

要素に重みがあるリストからk個のランダムな要素を選択します

重みなしの選択(等しい確率)は、美しく説明されています ここ

このアプローチを加重アプローチに変換する方法があるかどうか疑問に思っていました。

他のアプローチにも興味があります。

更新:サンプリングwithout置換

69
nimcap

私はこれが非常に古い質問であることを知っていますが、少しの数学を適用する場合、O(n)時間でこれを行うには巧妙なトリックがあると思います!

指数分布 には2つの非常に便利なプロパティがあります。

  1. 異なるレートパラメーターを持つ異なる指数分布からn個のサンプルが与えられた場合、特定のサンプルが最小である確率は、そのレートパラメーターをすべてのレートパラメーターの合計で割ったものに等しくなります。

  2. 「メモリレス」です。したがって、すでに最小値がわかっている場合、残りの要素のいずれかが2番目から最小になる確率は、真の最小値が削除される(生成されない)場合、その要素が新しい要素になる確率と同じです分これは明白に思えますが、条件付き確率の問題があるため、他の分布には当てはまらないかもしれません。

ファクト1を使用すると、重みに等しいレートパラメーターでこれらの指数分布サンプルを生成し、最小値を持つものを選択することにより、単一の要素を選択できることがわかります。

事実2を使用すると、指数サンプルを再生成する必要がないことがわかります。代わりに、要素ごとに1つを生成し、最も低いサンプルでk個の要素を取得します。

最小のkを見つけるには、O(n)を使用します。 Quickselect アルゴリズムを使用してk番目の要素を見つけてから、すべての要素をもう一度通過して、k番目よりも低いすべての出力を取得します。

便利なメモ:指数分布サンプルを生成するためにライブラリにすぐにアクセスできない場合は、-ln(Rand())/weightで簡単に実行できます。

25
Joe K

サンプリングに置換がある場合このアルゴリズムを使用できます(Pythonで実装されています):

import random

items = [(10, "low"),
         (100, "mid"),
         (890, "large")]

def weighted_sample(items, n):
    total = float(sum(w for w, v in items))
    i = 0
    w, v = items[0]
    while n:
        x = total * (1 - random.random() ** (1.0 / n))
        total -= x
        while x > w:
            x -= w
            i += 1
            w, v = items[i]
        w -= x
        yield v
        n -= 1

これはO(n + m)です。ここでmはアイテムの数です。

なぜこれが機能するのか?以下のアルゴリズムに基づいています:

def n_random_numbers_decreasing(v, n):
    """Like reversed(sorted(v * random() for i in range(n))),
    but faster because we avoid sorting."""
    while n:
        v *= random.random() ** (1.0 / n)
        yield v
        n -= 1

関数 weighted_sampleは、このアルゴリズムとitemsリストのウォークを融合して、それらの乱数によって選択されたアイテムを選択するだけです。

これは、n乱数0 ..vがすべてzよりも小さい確率が[ 〜#〜] p [〜#〜] =(z/vnzを解くと、z = vPが得られます1/n[〜#〜] p [〜#〜]に乱数を代入すると、正しい分布の最大数が選択されます。他のすべての番号を選択するプロセスを繰り返すことができます。

サンプリングに置換がない場合すべてのアイテムをバイナリヒープに配置できます。各ノードは、そのサブヒープ内のすべてのアイテムの重みの合計をキャッシュします。ヒープの構築はO(m)です。重みを考慮して、ヒープからランダムなアイテムを選択するのはO(log m)です。そのアイテムを削除し、キャッシュされた合計を更新することもO(log m)です。そのため、O(m + n log m)時間でnアイテムを選択できます。

(注:ここでの「重み」とは、要素が選択されるたびに、残りの可能性がその重みに比例する確率で選択されることを意味します。

以下にその実装を示します。

import random

class Node:
    # Each node in the heap has a weight, value, and total weight.
    # The total weight, self.tw, is self.w plus the weight of any children.
    __slots__ = ['w', 'v', 'tw']
    def __init__(self, w, v, tw):
        self.w, self.v, self.tw = w, v, tw

def rws_heap(items):
    # h is the heap. It's like a binary tree that lives in an array.
    # It has a Node for each pair in `items`. h[1] is the root. Each
    # other Node h[i] has a parent at h[i>>1]. Each node has up to 2
    # children, h[i<<1] and h[(i<<1)+1].  To get this Nice simple
    # arithmetic, we have to leave h[0] vacant.
    h = [None]                          # leave h[0] vacant
    for w, v in items:
        h.append(Node(w, v, w))
    for i in range(len(h) - 1, 1, -1):  # total up the tws
        h[i>>1].tw += h[i].tw           # add h[i]'s total to its parent
    return h

def rws_heap_pop(h):
    gas = h[1].tw * random.random()     # start with a random amount of gas

    i = 1                     # start driving at the root
    while gas >= h[i].w:      # while we have enough gas to get past node i:
        gas -= h[i].w         #   drive past node i
        i <<= 1               #   move to first child
        if gas >= h[i].tw:    #   if we have enough gas:
            gas -= h[i].tw    #     drive past first child and descendants
            i += 1            #     move to second child
    w = h[i].w                # out of gas! h[i] is the selected node.
    v = h[i].v

    h[i].w = 0                # make sure this node isn't chosen again
    while i:                  # fix up total weights
        h[i].tw -= w
        i >>= 1
    return v

def random_weighted_sample_no_replacement(items, n):
    heap = rws_heap(items)              # just make a heap...
    for i in range(n):
        yield rws_heap_pop(heap)        # and pop n items off it.
67
Jason Orendorff

サンプリングが置換である場合、 ルーレットホイール選択 テクニックを使用します(遺伝的アルゴリズムでよく使用されます):

  1. 重みを並べ替える
  2. 累積重みを計算する
  3. [0,1]*totalWeightで乱数を選択します
  4. この数が入る間隔を見つける
  5. 対応する間隔の要素を選択します
  6. k回繰り返す

alt text

サンプリングに置換がない場合、各反復後にリストから選択した要素を削除し、合計が1になるように重みを再正規化することにより、上記の手法を適用できます(有効な確率分布関数)

42
Amro

Rubyでこれをやった

https://github.com/fl00r/pickup

require 'pickup'
pond = {
  "selmon"  => 1,
  "carp" => 4,
  "crucian"  => 3,
  "herring" => 6,
  "sturgeon" => 8,
  "gudgeon" => 10,
  "minnow" => 20
}
pickup = Pickup.new(pond, uniq: true)
pickup.pick(3)
#=> [ "gudgeon", "herring", "minnow" ]
pickup.pick
#=> "herring"
pickup.pick
#=> "gudgeon"
pickup.pick
#=> "sturgeon"
3
fl00r

ランダムな整数の大きな配列を生成する場合with replacement、区分的線形補間を使用できます。たとえば、NumPy/SciPyを使用する場合:

import numpy
import scipy.interpolate

def weighted_randint(weights, size=None):
    """Given an n-element vector of weights, randomly sample
    integers up to n with probabilities proportional to weights"""
    n = weights.size
    # normalize so that the weights sum to unity
    weights = weights / numpy.linalg.norm(weights, 1)
    # cumulative sum of weights
    cumulative_weights = weights.cumsum()
    # piecewise-linear interpolating function whose domain is
    # the unit interval and whose range is the integers up to n
    f = scipy.interpolate.interp1d(
            numpy.hstack((0.0, weights)),
            numpy.arange(n + 1), kind='linear')
    return f(numpy.random.random(size=size)).astype(int)

交換せずにサンプリングする場合、これは効果的ではありません。

1
chairmanK

要素が重みに比例する確率で選択されるように、置換せずに重み付きセットからx個の要素を選択する場合:

import random

def weighted_choose_subset(weighted_set, count):
    """Return a random sample of count elements from a weighted set.

    weighted_set should be a sequence of tuples of the form 
    (item, weight), for example:  [('a', 1), ('b', 2), ('c', 3)]

    Each element from weighted_set shows up at most once in the
    result, and the relative likelihood of two particular elements
    showing up is equal to the ratio of their weights.

    This works as follows:

    1.) Line up the items along the number line from [0, the sum
    of all weights) such that each item occupies a segment of
    length equal to its weight.

    2.) Randomly pick a number "start" in the range [0, total
    weight / count).

    3.) Find all the points "start + n/count" (for all integers n
    such that the point is within our segments) and yield the set
    containing the items marked by those points.

    Note that this implementation may not return each possible
    subset.  For example, with the input ([('a': 1), ('b': 1),
    ('c': 1), ('d': 1)], 2), it may only produce the sets ['a',
    'c'] and ['b', 'd'], but it will do so such that the weights
    are respected.

    This implementation only works for nonnegative integral
    weights.  The highest weight in the input set must be less
    than the total weight divided by the count; otherwise it would
    be impossible to respect the weights while never returning
    that element more than once per invocation.
    """
    if count == 0:
        return []

    total_weight = 0
    max_weight = 0
    borders = []
    for item, weight in weighted_set:
        if weight < 0:
            raise RuntimeError("All weights must be positive integers")
        # Scale up weights so dividing total_weight / count doesn't truncate:
        weight *= count
        total_weight += weight
        borders.append(total_weight)
        max_weight = max(max_weight, weight)

    step = int(total_weight / count)

    if max_weight > step:
        raise RuntimeError(
            "Each weight must be less than total weight / count")

    next_stop = random.randint(0, step - 1)

    results = []
    current = 0
    for i in range(count):
        while borders[current] <= next_stop:
            current += 1
        results.append(weighted_set[current][0])
        next_stop += step

    return results
1
ech

geodns のGoの実装を次に示します。

package foo

import (
    "log"
    "math/Rand"
)

type server struct {
    Weight int
    data   interface{}
}

func foo(servers []server) {
    // servers list is already sorted by the Weight attribute

    // number of items to pick
    max := 4

    result := make([]server, max)

    sum := 0
    for _, r := range servers {
        sum += r.Weight
    }

    for si := 0; si < max; si++ {
        n := Rand.Intn(sum + 1)
        s := 0

        for i := range servers {
            s += int(servers[i].Weight)
            if s >= n {
                log.Println("Picked record", i, servers[i])
                sum -= servers[i].Weight
                result[si] = servers[i]

                // remove the server from the list
                servers = append(servers[:i], servers[i+1:]...)
                break
            }
        }
    }

    return result
}
1

あなたがリンクした質問では、カイルのソリューションは些細な一般化で動作します。リストをスキャンして、総重量を合計します。次に、要素を選択する確率は次のとおりです。

1-(1-(#必要/(左の重量)))/(nの重量)。ノードにアクセスした後、合計からその重みを減算します。また、nが必要でnが残っている場合は、明示的に停止する必要があります。

ウェイトが1のすべてでこれを確認できます。これにより、kyleのソリューションが簡素化されます。

編集:(2倍の意味を再考する必要がありました)

0
Kyle Butt

これは、O(n)であり、過剰なメモリ使用量はありません。これは、任意の言語に簡単に移植できる賢明で効率的なソリューションであると思います。最初の2行は、 Drupalのデータ。

function getNrandomGuysWithWeight($numitems){
  $q = db_query('SELECT id, weight FROM theTableWithTheData');
  $q = $q->fetchAll();

  $accum = 0;
  foreach($q as $r){
    $accum += $r->weight;
    $r->weight = $accum;
  }

  $out = array();

  while(count($out) < $numitems && count($q)){
    $n = Rand(0,$accum);
    $lessaccum = NULL;
    $prevaccum = 0;
    $idxrm = 0;
    foreach($q as $i=>$r){
      if(($lessaccum == NULL) && ($n <= $r->weight)){
        $out[] = $r->id;
        $lessaccum = $r->weight- $prevaccum;
        $accum -= $lessaccum;
        $idxrm = $i;
      }else if($lessaccum){
        $r->weight -= $lessaccum;
      }
      $prevaccum = $r->weight;
    }
    unset($q[$idxrm]);
  }
  return $out;
}
0
jacmkno

ここでは、1つのアイテムを選択するための簡単なソリューションを示しますが、kアイテム(Javaスタイル)に簡単に拡張できます。

double random = Math.random();
double sum = 0;
for (int i = 0; i < items.length; i++) {
    val = items[i];
    sum += val.getValue();
    if (sum > random) {
        selected = val;
        break;
    }
}
0
shem

再帰による置換なしのサンプリング-C#のエレガントで非常に短いソリューション

// 60人の生徒のうち4人を選択する方法はいくつあるので、毎回異なる4人を選択する

class Program
{
    static void Main(string[] args)
    {
        int group = 60;
        int studentsToChoose = 4;

        Console.WriteLine(FindNumberOfStudents(studentsToChoose, group));
    }

    private static int FindNumberOfStudents(int studentsToChoose, int group)
    {
        if (studentsToChoose == group || studentsToChoose == 0)
            return 1;

        return FindNumberOfStudents(studentsToChoose, group - 1) + FindNumberOfStudents(studentsToChoose - 1, group - 1);

    }
}
0
Angel

Jason Orendorffのアイデアに似たアルゴリズムをRust here で実装しました。私のバージョンでは、一括操作をサポートしています:挿入と削除(多数のアイテムを削除する場合) O(m + log n) timeのデータ構造からの加重選択パスではなくIDで指定されます。ここで、mは削除するアイテムの数、nはstoredのアイテムの数です。

0
kirillkh

置換なしのサンプリングの基礎となるアルゴリズムに遅れを取ろうとして数時間を費やしただけです。このトピックは当初考えていたよりも複雑です。それは楽しい!将来の読者の利益のために(良い一日を!)ここに私の洞察を文書化します与えられた包含確率を尊重するすぐに使える関数を含むさらに以下。さまざまな方法の素晴らしく簡単な数学的概要は、ここにあります: Tillé:等しいまたは等しくない確率でのサンプリングのアルゴリズム 。たとえば、Jasonの方法は46ページにあります。彼の方法の注意点は、文書にも記載されているように、重みが包含確率にnot比例するということです。実際、i番目の包含確率は、次のように再帰的に計算できます。

def inclusion_probability(i, weights, k):
    """
        Computes the inclusion probability of the i-th element
        in a randomly sampled k-Tuple using Jason's algorithm
        (see https://stackoverflow.com/a/2149533/7729124)
    """
    if k <= 0: return 0
    cum_p = 0
    for j, weight in enumerate(weights):
        # compute the probability of j being selected considering the weights
        p = weight / sum(weights)

        if i == j:
            # if this is the target element, we don't have to go deeper,
            # since we know that i is included
            cum_p += p
        else:
            # if this is not the target element, than we compute the conditional
            # inclusion probability of i under the constraint that j is included
            cond_i = i if i < j else i-1
            cond_weights = weights[:j] + weights[j+1:]
            cond_p = inclusion_probability(cond_i, cond_weights, k-1)
            cum_p += p * cond_p
    return cum_p

そして、上記の関数の妥当性を比較することで確認できます

In : for i in range(3): print(i, inclusion_probability(i, [1,2,3], 2))
0 0.41666666666666663
1 0.7333333333333333
2 0.85

In : import collections, itertools
In : sample_tester = lambda f: collections.Counter(itertools.chain(*(f() for _ in range(10000))))
In : sample_tester(lambda: random_weighted_sample_no_replacement([(1,'a'),(2,'b'),(3,'c')],2))
Out: Counter({'a': 4198, 'b': 7268, 'c': 8534})

上記の文書でも提案されている1つの方法は、包含確率を指定するために、それらから重みを計算することです。手元の質問の全体的な複雑さは、基本的に再帰式を逆にしなければならないので、直接それを行うことができないという事実に由来します。数値的には、あらゆる種類の方法を使用して実行できます。ニュートンの方法。ただし、プレーンPythonを使用してヤコビ行列を反転させる複雑さはすぐに耐えられなくなるため、 numpy.random.choice この場合。

幸いなことに、プレーンPythonを使用するメソッドがあります。これは、目的に対して十分なパフォーマンスを発揮する場合もあれば、発揮しない場合もあります。それほど多くの異なるウェイトがなければ、うまく機能します。これは、サンプリングプロセスを同じ包含確率を持つ部分に分割することで機能します。つまり、random.sample再び!基本は69ページにきちんと説明されているので、ここでは原則を説明しません。十分な量のコメントがあるコードを以下に示します。

def sample_no_replacement_exact(items, k, best_effort=False, random_=None, ε=1e-9):
    """
        Returns a random sample of k elements from items, where items is a list of
        tuples (weight, element). The inclusion probability of an element in the
        final sample is given by
           k * weight / sum(weights).

        Note that the function raises if a inclusion probability cannot be
        satisfied, e.g the following call is obviously illegal:
           sample_no_replacement_exact([(1,'a'),(2,'b')],2)
        Since selecting two elements means selecting both all the time,
        'b' cannot be selected twice as often as 'a'. In general it can be hard to
        spot if the weights are illegal and the function does *not* always raise
        an exception in that case. To remedy the situation you can pass
        best_effort=True which redistributes the inclusion probability mass
        if necessary. Note that the inclusion probabilities will change
        if deemed necessary.

        The algorithm is based on the splitting procedure on page 75/76 in:
        http://www.eustat.eus/productosServicios/52.1_Unequal_prob_sampling.pdf
        Additional information can be found here:
        https://stackoverflow.com/questions/2140787/

        :param items: list of tuples of type weight,element
        :param k: length of resulting sample
        :param best_effort: fix inclusion probabilities if necessary,
                            (optional, defaults to False)
        :param random_: random module to use (optional, defaults to the
                        standard random module)
        :param ε: fuzziness parameter when testing for zero in the context
                  of floating point arithmetic (optional, defaults to 1e-9)
        :return: random sample set of size k
        :exception: throws ValueError in case of bad parameters,
                    throws AssertionError in case of algorithmic impossibilities
    """
    # random_ defaults to the random submodule
    if not random_:
        random_ = random

    # special case empty return set
    if k <= 0:
        return set()

    if k > len(items):
        raise ValueError("resulting Tuple length exceeds number of elements (k > n)")

    # sort items by weight
    items = sorted(items, key=lambda item: item[0])

    # extract the weights and elements
    weights, elements = list(Zip(*items))

    # compute the inclusion probabilities (short: π) of the elements
    scaling_factor = k / sum(weights)
    π = [scaling_factor * weight for weight in weights]

    # in case of best_effort: if a inclusion probability exceeds 1,
    # try to rebalance the probabilities such that:
    # a) no probability exceeds 1,
    # b) the probabilities still sum to k, and
    # c) the probability masses flow from top to bottom:
    #    [0.2, 0.3, 1.5] -> [0.2, 0.8, 1]
    # (remember that π is sorted)
    if best_effort and π[-1] > 1 + ε:
        # probability mass we still we have to distribute
        debt = 0.
        for i in reversed(range(len(π))):
            if π[i] > 1.:
                # an 'offender', take away excess
                debt += π[i] - 1.
                π[i] = 1.
            else:
                # case π[i] < 1, i.e. 'save' element
                # maximum we can transfer from debt to π[i] and still not
                # exceed 1 is computed by the minimum of:
                # a) 1 - π[i], and
                # b) debt
                max_transfer = min(debt, 1. - π[i])
                debt -= max_transfer
                π[i] += max_transfer
        assert debt < ε, "best effort rebalancing failed (impossible)"

    # make sure we are talking about probabilities
    if any(not (0 - ε <= π_i <= 1 + ε) for π_i in π):
        raise ValueError("inclusion probabilities not satisfiable: {}" \
                         .format(list(Zip(π, elements))))

    # special case equal probabilities
    # (up to fuzziness parameter, remember that π is sorted)
    if π[-1] < π[0] + ε:
        return set(random_.sample(elements, k))

    # compute the two possible lambda values, see formula 7 on page 75
    # (remember that π is sorted)
    λ1 = π[0] * len(π) / k
    λ2 = (1 - π[-1]) * len(π) / (len(π) - k)
    λ = min(λ1, λ2)

    # there are two cases now, see also page 69
    # CASE 1
    # with probability λ we are in the equal probability case
    # where all elements have the same inclusion probability
    if random_.random() < λ:
        return set(random_.sample(elements, k))

    # CASE 2:
    # with probability 1-λ we are in the case of a new sample without
    # replacement problem which is strictly simpler,
    # it has the following new probabilities (see page 75, π^{(2)}):
    new_π = [
        (π_i - λ * k / len(π))
        /
        (1 - λ)
        for π_i in π
    ]
    new_items = list(Zip(new_π, elements))

    # the first few probabilities might be 0, remove them
    # NOTE: we make sure that floating point issues do not arise
    #       by using the fuzziness parameter
    while new_items and new_items[0][0] < ε:
        new_items = new_items[1:]

    # the last few probabilities might be 1, remove them and mark them as selected
    # NOTE: we make sure that floating point issues do not arise
    #       by using the fuzziness parameter
    selected_elements = set()
    while new_items and new_items[-1][0] > 1 - ε:
        selected_elements.add(new_items[-1][1])
        new_items = new_items[:-1]

    # the algorithm reduces the length of the sample problem,
    # it is guaranteed that:
    # if λ = λ1: the first item has probability 0
    # if λ = λ2: the last item has probability 1
    assert len(new_items) < len(items), "problem was not simplified (impossible)"

    # recursive call with the simpler sample problem
    # NOTE: we have to make sure that the selected elements are included
    return sample_no_replacement_exact(
        new_items,
        k - len(selected_elements),
        best_effort=best_effort,
        random_=random_,
        ε=ε
    ) | selected_elements

例:

In : sample_no_replacement_exact([(1,'a'),(2,'b'),(3,'c')],2)
Out: {'b', 'c'}

In : import collections, itertools
In : sample_tester = lambda f: collections.Counter(itertools.chain(*(f() for _ in range(10000))))
In : sample_tester(lambda: sample_no_replacement_exact([(1,'a'),(2,'b'),(3,'c'),(4,'d')],2))
Out: Counter({'a': 2048, 'b': 4051, 'c': 5979, 'd': 7922})

重みの合計は10になるため、包含確率は次のように計算されます。a→20%、b→40%、c→60%、d →80%。 (合計:200%= k)動作します!

この関数を生産的に使用する場合の注意事項の1つですが、重みの不正入力を見つけるのは非常に困難です。明らかな違法な例は

In: sample_no_replacement_exact([(1,'a'),(2,'b')],2)
ValueError: inclusion probabilities not satisfiable: [(0.6666666666666666, 'a'), (1.3333333333333333, 'b')]

bは、両方ともalwaysを選択する必要があるため、aの2倍の頻度で表示することはできません。さらに微妙な例があります。実稼働環境での例外を回避するには、best_effort = Trueを使用します。これにより、有効な分布alwaysが含まれるように包含確率の質量が再調整されます。明らかにこれは包含確率を変えるかもしれません。

0