Allocate items according to an approximate ratio in Python

前端 未结 2 546
挽巷
挽巷 2021-01-13 16:43

See update below...

I\'m writing a Python simulation that assigns an arbitrary number of imaginary players one goal from an arbitrary pool of goals.

相关标签:
2条回答
  • 2021-01-13 16:49

    Some time ago (okay, two and a half years) I asked a question that I think would be relevant here. Here's how I think you could use this: first, build a list of the priorities assigned to each goal. In your example, where the first half of the goal pool (rounded down) gets priority 3 and the rest get priority 1, one way to do this is

    priorities = [3] * len(goals) / 2 + [1] * (len(goals) - len(goals) / 2)
    

    Of course, you can create your list of priorities in any way you want; it doesn't have to be half 3s and half 1s. The only requirement is that all the entries be positive numbers.

    Once you have the list, normalize it to have a sum equal to the number of players:

    # Assuming num_players is already defined to be the number of players
    normalized_priorities = [float(p) / sum(priorities) * num_players
                                 for p in priorities]
    

    Then apply one of the algorithms from my question to round these floating-point numbers to integers representing the actual allocations. Among the answers given, there are only two algorithms that do the rounding properly and satisfy the minimum variance criterion: adjusted fractional distribution (including the "Update" paragraph) and minimizing roundoff error. Conveniently, both of them appear to work for non-sorted lists. Here are my Python implementations:

    import math, operator
    from heapq import nlargest
    from itertools import izip
    item1 = operator.itemgetter(1)
    
    def floor(f):
        return int(math.floor(f))
    def frac(f):
        return math.modf(f)[0]
    
    def adjusted_fractional_distribution(fn_list):
        in_list = [floor(f) for f in fn_list]
        loss_list = [frac(f) for f in fn_list]
        fsum = math.fsum(loss_list)
        add_list = [0] * len(in_list)
        largest = nlargest(int(round(fsum)), enumerate(loss_list),
                     key=lambda e: (e[1], e[0]))
        for i, loss in largest:
            add_list[i] = 1
        return [i + a for i,a in izip(in_list, add_list)]
    
    def minimal_roundoff_error(fn_list):
        N = int(math.fsum(fn_list))
        temp_list = [[floor(f), frac(f), i] for i, f in enumerate(fn_list)]
        temp_list.sort(key = item1)
        lower_sum = sum(floor(f) for f in fn_list)
        difference = N - lower_sum
        for i in xrange(len(temp_list) - difference, len(temp_list)):
            temp_list[i][0] += 1
        temp_list.sort(key = item2)
        return [t[0] for t in temp_list]
    

    In all my tests, both these methods are exactly equivalent, so you can pick either one to use.


    Here's a usage example:

    >>> goals = 'ABCDE'
    >>> num_players = 17
    >>> priorities = [3,3,1,1,1]
    >>> normalized_priorities = [float(p) / sum(priorities) * num_players
                                     for p in priorities]
    [5.666666..., 5.666666..., 1.888888..., 1.888888..., 1.888888...]
    >>> minimal_roundoff_error(normalized_priorities)
    [5, 6, 2, 2, 2]
    

    If you want to allocate the extra players to the first goals within a group of equal priority, rather than the last, probably the easiest way to do this is to reverse the list before and after applying the rounding algorithm.

    >>> def rlist(l):
    ...     return list(reversed(l))
    >>> rlist(minimal_roundoff_error(rlist(normalized_priorities)))
    [6, 5, 2, 2, 2]
    

    Now, this may not quite match the distributions you expect, because in my question I specified a "minimum variance" criterion that I used to judge the result. That might not be appropriate for you case. You could try the "remainder distribution" algorithm instead of one of the two I mentioned above and see if it works better for you.

    def remainder_distribution(fn_list):
        N = math.fsum(fn_list)
        rn_list = [int(round(f)) for f in fn_list]
        remainder = N - sum(rn_list)
        first = 0
        last = len(fn_list) - 1
        while remainder > 0 and last >= 0:
            if abs(rn_list[last] + 1 - fn_list[last]) < 1:
                rn_list[last] += 1
                remainder -= 1
            last -= 1
        while remainder < 0 and first < len(rn_list):
            if abs(rn_list[first] - 1 - fn_list[first]) < 1:
                rn_list[first] -= 1
                remainder += 1
            first += 1
        return rn_list
    
    0 讨论(0)
  • 2021-01-13 16:55

    Rather than try to get the fractions right, I'd just allocate the goals one at a time in the appropriate ratio. Here the 'allocate_goals' generator assigns a goal to each of the low-ratio goals, then to each of the high-ratio goals (repeating 3 times). Then it repeats. The caller, in allocate cuts off this infinite generator at the required number (the number of players) using itertools.islice.

    import collections
    import itertools
    import string
    
    def allocate_goals(prop_low, prop_high):
        prop_high3 = prop_high * 3
        while True:
            for g in prop_low:
                yield g
            for g in prop_high3:
                yield g
    
    def allocate(goals, players):
        letters = string.ascii_uppercase[:goals]
        high_count = goals // 2
        prop_high, prop_low = letters[:high_count], letters[high_count:]
        g = allocate_goals(prop_low, prop_high)
        return collections.Counter(itertools.islice(g, players))
    
    for goals in xrange(2, 9):
        print goals, sorted(allocate(goals, 8).items())
    

    It produces this answer:

    2 [('A', 6), ('B', 2)]
    3 [('A', 4), ('B', 2), ('C', 2)]
    4 [('A', 3), ('B', 3), ('C', 1), ('D', 1)]
    5 [('A', 3), ('B', 2), ('C', 1), ('D', 1), ('E', 1)]
    6 [('A', 2), ('B', 2), ('C', 1), ('D', 1), ('E', 1), ('F', 1)]
    7 [('A', 2), ('B', 1), ('C', 1), ('D', 1), ('E', 1), ('F', 1), ('G', 1)]
    8 [('A', 1), ('B', 1), ('C', 1), ('D', 1), ('E', 1), ('F', 1), ('G', 1), ('H', 1)]
    

    The great thing about this approach (apart from, I think, that it's easy to understand) is that it's quick to turn it into a randomized version.

    Just replace allocate_goals with this:

    def allocate_goals(prop_low, prop_high):
        all_goals = prop_low + prop_high * 3
        while True:
            yield random.choice(all_goals)
    
    0 讨论(0)
提交回复
热议问题