web-dev-qa-db-fra.com

Sélectionnez k éléments aléatoires dans une liste dont les éléments ont une pondération

La sélection sans poids (probabilités égales) est magnifiquement décrite ici .

Je me demandais s’il existait un moyen de convertir cette approche en approche pondérée.

Je suis également intéressé par d'autres approches.

Mise à jour: échantillonnage sans remplacement

67
nimcap

Je sais que c’est une très vieille question, mais je pense qu’il est très astucieux de le faire dans O(n) temps si vous appliquez un peu de calcul!

La distribution exponentielle a deux propriétés très utiles.

  1. Pour n échantillons de différentes distributions exponentielles avec différents paramètres de taux, la probabilité qu'un échantillon donné soit le minimum est égale à son paramètre de taux divisé par la somme de tous les paramètres de taux.

  2. C'est "sans mémoire". Donc, si vous connaissez déjà le minimum, alors la probabilité que l'un des éléments restants soit du 2e au minimum est la même que la probabilité que si le minimum réel avait été supprimé (et jamais généré), cet élément aurait été le nouvel élément. min. Cela semble évident, mais je pense qu'en raison de problèmes de probabilité conditionnelle, cela pourrait ne pas être le cas d'autres distributions.

En utilisant le fait 1, nous savons que le choix d’un seul élément peut être effectué en générant ces échantillons de distribution exponentielle avec un paramètre de taux égal au poids, puis en choisissant celui dont la valeur est minimale.

En utilisant le fait 2, nous savons qu'il n'est pas nécessaire de générer à nouveau les échantillons exponentiels. Au lieu de cela, générez-en un pour chaque élément et prenez les k éléments avec les échantillons les plus bas.

Trouver le k le plus bas peut être fait dans O (n). Utilisez l’algorithme Quickselect pour trouver le k-ème élément, puis effectuez simplement une autre passe à travers tous les éléments et affichez-le tous plus bas que le k-e.

Remarque utile: si vous n'avez pas immédiatement accès à une bibliothèque pour générer des exemples de distribution exponentielle, vous pouvez le faire facilement par: -ln(Rand())/weight

23
Joe K

Si l'échantillonnage est avec remplacement, vous pouvez utiliser cet algorithme (implémenté ici en 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

C'est O (n + m) où m est le nombre d'éléments.

Pourquoi ça marche? Il est basé sur l'algorithme suivant:

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

La fonction weighted_sample est simplement cet algorithme fusionné avec un parcours de la liste items pour sélectionner les éléments sélectionnés par ces nombres aléatoires.

Cela fonctionne à son tour car la probabilité que n nombres aléatoires 0 ..v soient tous inférieurs à z est P = (z/v)n. Résoudre pour z, et vous obtenez z = vP1/n. Substituer un nombre aléatoire à P sélectionne le plus grand nombre avec la distribution correcte; et nous pouvons simplement répéter le processus pour sélectionner tous les autres nombres.

Si l'échantillonnage est sans remplacement, vous pouvez placer tous les éléments dans un segment binaire, où chaque nœud met en cache le total des poids de tous les éléments de ce sous-segment. La construction du tas est O (m). La sélection d'un élément aléatoire dans le tas, en respectant les poids, est O (log m). Supprimer cet élément et mettre à jour les totaux mis en cache est également O (journal m). Ainsi, vous pouvez choisir n éléments en temps O (m + n log m).

(Remarque: "poids" signifie ici que chaque fois qu'un élément est sélectionné, les possibilités restantes sont choisies avec une probabilité proportionnelle à leur poids. Cela ne signifie pas que les éléments apparaissent dans la sortie avec une vraisemblance proportionnelle à leur poids.)

Voici une implémentation de cela, abondamment commentée:

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.
65
Jason Orendorff

Si l'échantillonnage est avec remplacement, utilisez la technique sélection de la roue (souvent utilisée dans les algorithmes génétiques):

  1. trier les poids
  2. calculer les poids cumulés
  3. choisir un nombre aléatoire dans [0,1]*totalWeight
  4. trouver l'intervalle dans lequel ce nombre tombe
  5. sélectionner les éléments avec l'intervalle correspondant
  6. répéter k fois

alt text

Si l'échantillonnage est sans remplacement, vous pouvez adapter la technique ci-dessus en supprimant l'élément sélectionné de la liste après chaque itération, puis en normalisant les pondérations de sorte que leur somme soit égale à 1 (fonction de distribution de probabilité valide)

41
Amro

J'ai fait ça en 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

Si vous souhaitez générer de grands tableaux d'entiers aléatoires avec remplacement , vous pouvez utiliser une interpolation linéaire par morceaux. Par exemple, en utilisant 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)

Ce n'est pas efficace si vous voulez échantillonner sans remplacement.

1
chairmanK

Si vous voulez choisir x éléments dans un ensemble pondéré sans remplacement de telle sorte que les éléments soient choisis avec une probabilité proportionnelle à leur poids:

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

Voici une implémentation Go de geodns :

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
Ask Bjørn Hansen

Dans la question à laquelle vous avez lié la solution, la solution de Kyle fonctionnerait avec une généralisation triviale… .. Parcourez la liste et additionnez les poids totaux. Alors la probabilité de choisir un élément devrait être:

1 - (1 - (# nécessaire/(poids restant)))/(poids en n). Après avoir visité un nœud, soustrayez son poids du total. De plus, si vous avez besoin de n et qu'il vous reste n, vous devez vous arrêter explicitement.

Vous pouvez vérifier cela avec tout ce qui a le poids 1, cela simplifie la solution de kyle.

Edité: (dû repenser ce que deux fois plus probable signifiait)

0
Kyle Butt

Celui-ci fait exactement cela avec O(n) et aucune utilisation excessive de mémoire. Je pense que c'est une solution intelligente et efficace, facile à transférer dans toutes les langues. Les deux premières lignes servent uniquement à renseigner des exemples de données dans 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

Je mets ici une solution simple pour choisir 1 élément, vous pouvez facilement l’étendre pour k éléments (style 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

Échantillonnage sans remplacement avec récursivité - solution élégante et très courte en C #

// combien de façons nous pouvons choisir 4 étudiants sur 60, de sorte que chaque fois que nous choisissons 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

J'ai implémenté un algorithme similaire à celui de Jason Orendorff dans Rust ici . Ma version prend également en charge les opérations en bloc: insérer et supprimer (lorsque vous souhaitez supprimer un ensemble d'éléments donnés par leurs identifiants, et non par le chemin de sélection pondéré) de la structure de données dans O(m + log n) temps où m correspond au nombre d'éléments à supprimer et n le nombre d'éléments stockés.

0
kirillkh

Je viens de passer quelques heures à essayer de comprendre les algorithmes sous-jacents de échantillonnage sans remplacement et ce sujet est plus complexe que je ne le pensais au départ. C'est excitant! Pour le bénéfice des futurs lecteurs (bonne journée!), Je documente ici mes idées incluant une fonction prête à l’emploi qui respecte les données probabilités d’inclusion plus loin. Vous trouverez ici un aperçu mathématique simple et rapide des différentes méthodes: Tillé: Algorithmes d’échantillonnage à probabilités égales ou inégales . Par exemple, la méthode de Jason se trouve à la page 46. L'avertissement de sa méthode est que les poids sont non proportionnels aux probabilités d'inclusion, comme l'indique également le document. En fait, les i _ ème probabilités d'inclusion peuvent être calculées de manière récursive comme suit:

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

Et nous pouvons vérifier la validité de la fonction ci-dessus en comparant

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})

Une façon - également suggérée dans le document ci-dessus - de spécifier les probabilités d'inclusion est de calculer les poids à partir de celles-ci. Toute la complexité de la question à l’examen découle du fait que l’on ne peut pas le faire directement car il faut fondamentalement inverser la formule de récurrence, symboliquement, je prétends que cela est impossible. Numériquement, cela peut être fait en utilisant toutes sortes de méthodes, par exemple: La méthode de Newton. Cependant, la complexité d'inverser le jacobien à l'aide de Python brut devient rapidement insupportable, je recommande vivement de regarder dans numpy.random.choice dans ce cas.

Heureusement, il existe une méthode utilisant du Python brut qui peut être ou ne pas être suffisamment performante pour vos besoins, elle fonctionne très bien s'il n'y a pas autant de poids différents. Vous trouverez l’algorithme aux pages 75 et 76. Cela fonctionne en divisant le processus d’échantillonnage en parties ayant les mêmes probabilités d’inclusion, c’est-à-dire que nous pouvons utiliser à nouveau random.sample! Je ne vais pas expliquer le principe ici car les bases sont bien présentées à la page 69. Voici le code avec, espérons-le, suffisamment de commentaires:

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,

Exemple:

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})

Les poids totalisent 10, d'où les probabilités d'inclusion calculées: a → 20%, b → 40%, c → 60%, d → 80%. (Somme: 200% = k.) Cela fonctionne!

Juste un mot de prudence pour l'utilisation productive de cette fonction, il peut être très difficile de détecter des entrées illégales pour les poids. Un exemple illégal évident est

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

b ne peut pas apparaître deux fois plus souvent que a car les deux doivent être toujours être sélectionnés. Il y a des exemples plus subtils. Pour éviter une exception en production, utilisez simplement best_effort = True, qui rééquilibre la masse de la probabilité d'inclusion de sorte que nous ayons toujours une distribution valide. Évidemment, cela pourrait changer les probabilités d'inclusion.

0
Yasin Zähringer