1

I need to generate all 3-dimensional arrays whose values sum up to 1.0, i.e., they are convex combinations.

Let's assume that each element can be one of [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]. Hence, combinations like [0.0,0.4,0.6] or [0.2,0.6,0.2] are correct, as they sum up to 1.0, but a combination like [1.0,0.4,0.2] would be incorrect as it sums up to 1.6.

From this question and answer, I know how to generate all combinations of given arrays. Hence, I could do:

ratios = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]
result = np.stack(np.meshgrid(ratios, ratios, ratios), -1).reshape(-1, 3)

and then I could simply filter only those that do sum up to 1.0:

result[np.isclose(np.sum(result, axis=1), 1.0)]

However, this quickly becomes computationally intensive for highly-dimensional scenarios that may easily have billions of combinations, but only a tiny portion (less than 1/1000) satisfies the convex condition.

There is also a similar problem of finding all possible combinations of numbers to reach a given sum. However, in that scenario, the dimension is not fixed, hence [1.0] or [0.2,0.2,0.2,0.2,0.2] would both be valid solutions.

Is there a more efficient way, which assumes a fixed sum and fixed dimension?

TomsonTom
  • 742
  • 7
  • 22
  • 3
    This is a [subset sum problem](https://en.wikipedia.org/wiki/Subset_sum_problem)/[multiple subset sum problem](https://en.wikipedia.org/wiki/Multiple_subset_sum). – mozway Feb 17 '23 at 18:07
  • 3
    In your example, the possible values are evenly spaced. Will that be true in general? – Warren Weckesser Feb 17 '23 at 19:37
  • 1
    You're aware, I hope, of the standard issues with floating-point arithmetic here? – Karl Knechtel Feb 17 '23 at 20:14
  • Thanks for the great pointers. Indeed, the samples can be assumed to be evenly spaced. @KarlKnechtel, you raised a great point, which was also brought up by chrslg in the itertools answer's comment! – TomsonTom Feb 18 '23 at 14:17
  • 2
    If the samples can be assumed to be evenly spaced, then there is an equivalent problem in integers (elements are chosen from `range(6)` and must sum to 5) that will avoid the floating-point issues. It's also somewhat faster in my testing. – Karl Knechtel Feb 18 '23 at 14:33
  • @KarlKnechtel yes indeed. But that is not just a problem of float vs integers. Way more importantly it is a far simpler problem. Because it is just a static pattern of indexes to find (if the number of weight to find is fixed, like 3 here, it is just 3 nested loops, with a restriction on their indexes). Not comparable at all to the sort of knapsack problem that was the original question. – chrslg Feb 18 '23 at 15:33
  • Thanks everyone for great answers! I have to apologize, you are right that I forgot to clarify what is meant by combinations / permutations / etc. In my specific case, `[0.4,0.4,0.2]` and `[0.2,0.4,0.4]` are _different_ results, because the position in the array is important. Back when I asked, I did not realize that this would change the algorithms! – TomsonTom Feb 19 '23 at 10:41

3 Answers3

3

Branch & Bound

Another (almost academic) solution is to use branch&bound.

Branch&bound is a way to explore all possible combinations recursively, and keep the correct ones. But because it is done recursively, those possible combinations are explored in an implicit tree, giving the opportunity to discard whole subtrees without the need to explore them.

The ideal situation for branch&bound is when you can say "there are 2 kinds of solutions. Those, with xxx, and those without. And among those solutions, there are 2 kinds of solutions. Those with yyy, and those without. Etc.". Here "there are 2 kinds of solutions. Those with 0.0, and those without 0.0. And among the first, there are 2 kinds, those with another 0.0, and those with only one. Among the second, 2 kinds, those with 0.2, and those without. Etc."

In your case the code could be

import math

eps=1e-5

ratios = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]

def convbb(a, t=1.0, nc=3, p=[]):
    if nc==0:
        if t<eps:
            return [p]
        return []
    if len(a)==0:
        return []
    sols=[]
    if a[0]<=t+eps:
        sols = convbb(a, t-a[0], nc-1, p+[a[0]])
    return sols + convbb(a[1:], t, nc, p)

convbb(ratios[::-1])
#[[1.0, 0.0, 0.0], [0.8, 0.2, 0.0], [0.6, 0.4, 0.0], [0.6, 0.2, 0.2], [0.4, 0.4, 0.2]]

The advantage of this over itertools method is not obvious for your example. On my machine, it is twice slower. It explores less combinations (itertools method explore all of them ; this one obviously never explore any solution starting with [1.0, 0.2], without even bothering to choose a 3rd value). But because it is pure python (plus recursion, which is comfortable, but not optimal ; plus my usage of list concatenation, that should be replaced by a static list ; plus other non-optimal coding), it is slower.

Its advantage shows for bigger number ratios. For example, for 150 values, this code is twice faster.

And, more importantly, it shows for bigger number of values in result. With 3, advantage of cutting exploration before the end is never obvious, since I need to have at least 2 values in a partial combination to see that there is no need to choose a 3rd. So, all I can cut, is the choice of a 3rd solution.

But if you use, for example, this to find combinations of 6 values, then, itertools method is explosively expansive. When this one remains manageable. On my computer, is is 5 seconds for this solution, vs 900 seconds for itertools one. For 7 values, this solutions is less than 10 seconds. Itertools one stand no chance to finish before the day (whatever timezone you live in :D)

Evenly spaced ratios

But while I was typing that, you said in a comment that ratios were evenly spaced. That changes everything! Because if they are evenly spaced, from 0 to 1, then we know in advance all the working solutions. They are

[(ratios[i], ratios[j], ratios[-i-j-1]) for i in range(len(ratios)) for j in range(i, (len(ratios)-i+1)//2)]

Because in that case, your problem is equivalent as finding the sum of 3 integers in range [0, len(ratios)] whose total is len(ratios).

For more than nc=3 numbers, the same logic apply, with more recursion.

For an unknow nc, a recursive function can also be written, but far simpler than my previous one. It is just about finding integers a₀≤a₁≤...≤aₙ such as a₀+a₁+...+aₙ=n.

def recursFind(N, nc=3, i=0, t=0, p=[]):
    if nc==1:
        # No need to explore, last value is N-t
        if N-t>=i:
            yield p+[N-t]
        else:
            pass # p+[N-t] is a solution, but it has already been given in another order
    elif i*nc+t>N:
        return # impossible to find nc values>=i (there are some <i. But that would yield to already given solutions)
    else:
        for j in range(i, N):
            yield from recursFind(N, nc-1, j, t+j, p+[j])

[[ratios[i] for i in idx] for idx in recursFind(len(ratios)-1, nc)]
    

That's one possible implementation. It is really fast compared to others (again, because it is not the same problem, once you said ratios are evenly spaced). And it could be faster, because I was lazy (I could have could have computed upper bound instead of N for line for j in range(i,N), like it did for case nc=3, removing the need to cut for test i*nc+t>N). But it is fast enough to make my point.

tl;dr

If you have just a reasonable set of ratios, and searching for no more than 3 of them, then, itertools version is the way to go

If you have more ratios, and more importantly, may want to find combinations of more than 3 of thems, then branch&bound solution is the way to go.

Unless, as you implied in comment, ratios are evenly spaced from 0 to 1. In which case, it is a much simpler problem, and my last solution (or even better ones) are what you are looking for.

chrslg
  • 9,023
  • 5
  • 17
  • 31
  • 1
    Impressive. I didn't know the name for the technique. – Karl Knechtel Feb 18 '23 at 16:35
  • I love that you figured out a solution that is using the even spacing. However, it gives me an error when I run it, `IndexError: list index out of range` on the line `[[ratios[i] for i in idx] for idx in recursFind(len(ratios), nc)]`. The reason is that the index it returns is from 0 to len(ratios), so I suppose changing that argument to `len(ratios)-1` is a good fix? – TomsonTom Feb 19 '23 at 10:53
  • Would there be a way to efficiently extend this to unique permutations, instead of just combinations? I used the following naive implementation using sets: `[list(set(itertools.permutations([ratios[i] for i in idx]))) for idx in recursFind(len(ratios)-1, nc)]` and it is still way faster than the original solution, but I'm sure one can get even better than that! – TomsonTom Feb 19 '23 at 11:12
  • Yes, that argument should have been the index of the highest ratio, so, `len(ratos)-1` indeed. Corrected. – chrslg Feb 19 '23 at 14:49
  • And, if you want unicity, in the last solution, just replace `>=` by `>` in the first case (`N-t>i`), and `i*nc+t>N` by `i*nc+nc*(nc-1)//2+t>N` in the second case. So that instead of ensuring i₁≤i₂≤i₃... it ensures i₁ – chrslg Feb 19 '23 at 14:53
  • For the branch&bound solution, to ensure unicity, just change `convbb(a, t-a[0], nc-1, p+[a[0]])` by `convbb(a[1:], t-a[0], nc-1, p+[a[0]])` in the 2nd last line. As it is, that line means that `a[0]` is still available to use, even if we use it. While the new version means that if we use it in partial solution `p`, then recursive search can't use it again. – chrslg Feb 19 '23 at 14:58
  • @KarlKnechtel : B&B falls also in the category of "Divide & Conquer" algorithms. That last name is more poetic, and better known. But it is more ambiguous, since almost all recursive algorithms are Divide&Conquer algorithms (including quicksort or merge sort); b&b have in addition to the "divide&conquer" common features, that idea that some subcases can be solved instantly because we are able to say "no solution here" or "no better solution that the best one we have already found so far". Note that most of the time, it runs in exponential time. Just a faster exponential than exploring (...) – chrslg Feb 19 '23 at 15:04
  • (...) than exploring everything. So it is always better when we have a known algorithm for the problem (like Dijkstra). B&B s the fallback solution. But with a heuristic good enough (that says in which order to try solutions. Here, simply, using bigger coefficient first) it can even reduce complexity (I mean, not just computation time, not just divide exponential by 1 million, but turn exponential to polynomial). – chrslg Feb 19 '23 at 15:06
  • @chrslg I have accepted your answer! However, the proposal in your comment on how to fix the last solution to give permutations does not work for me, it gives either 0 solutions or a very small number of them. – TomsonTom Feb 20 '23 at 12:23
  • Yes, my bad. I forgot an important part. The last line, the recursive call, should read ` yield from recursFind(N, nc-1, j+1, t+j, p+[j])`. The 3rd argument, that was `j` before, is the minimum value of next elements that may be added to solution. When searching for combination, the fact that we just used `j` (our partial solution is the 5th argument `p+[j]`) doesn't forbid to use it again. So, that 3rd argument was `j` (not less neither, because allowing to add smaller values means exploring solution we've already explored. So restriction u₁≤u₂≤...≤uₙ is a way to ensure we don't have dups). – chrslg Feb 20 '23 at 12:56
  • @TomsonTom But now, we want unicity (I mean no coefficient used twice). So, one way to ensure both that, and the unicity of solutions (no duplicates solutions), is this time to enforce u₁ – chrslg Feb 20 '23 at 12:58
  • Note that once we do that, we don't need any other change. The first change I've proposed (`N-t>i` instead of `N-t>=i`) is redundant, and should not be added. Since now, `i` is already the minimum value, not just the last value used (we've already added 1 to it). So, now that we have changed `j` to `j+1` for the 3rd argument of recursion, we must keep `N-t>=i` as test for the 1st case). – chrslg Feb 20 '23 at 13:00
  • As for the second modification I suggested, it is just an optimisation anyway. So you can also keep that line (`i*nc+t>N`) unchanged. Or, adopt the change `i*nc+nc*(nc-1)//2+t>N`. It doesn't really matter. It just avoid a few useless recursions. – chrslg Feb 20 '23 at 13:02
  • @chrslg I still think there is something missing, your proposal does not work for me, it generates even less permutations than the original solution, which is clearly wrong. – TomsonTom Feb 21 '23 at 13:50
  • Maybe I did not understand what you meant by "unique permutations". But, my code with said modification works as intended by me; so if there is a difference, it is either because of differences in code (because I may have been fuzzy in explaining the modifications), or because of differences in objective (because I may have misunderstood the expected behaviour of the modification). See [complete modified code](https://www.online-python.com/OZJe5qD8mt). – chrslg Feb 21 '23 at 21:49
  • @TomsonTom Result are `N=6, nc=3 [[0, 1, 5], [0, 2, 4], [1, 2, 3]]`. I don't see what is missing here. `[0,3,3]`, if I understood correctly is not possible, since 3 is repeated. `[2,1,3]` is redundant since `[1,2,3]` is already here. `[(a,b,c) for (a,b,c) in itertools.combinations(range(6),3) if a+b+c==6]` gives the same result. `N=7, nc=3 [[0, 1, 6], [0, 2, 5], [0, 3, 4], [1, 2, 4]]` seems also logical. `N=7, nc=4 [[0, 1, 2, 4]]` is the unique possibility indeed. `N=8, nc=4 [[0, 1, 2, 5], [0, 1, 3, 4]]`, seems ok too. – chrslg Feb 21 '23 at 21:57
  • @chrslg I see, then we must have misunderstood each other. I posted above in a comment a short code that was using sets and `itertools.permutations` to modify your result into what I was looking for. In general, for example, `[1.0, 0.0, 0.0]` and `[0.0, 1.0, 0.0]` and `[0.0, 0.0, 1.0]` should be considered _different_ solutions, because the order does matter. In your original code, these are considered a single solution `[1.0, 0.0, 0.0]`. The trick is also to avoid duplicates, because standard `itertools.permutations` will generate some of these multiple times. – TomsonTom Feb 22 '23 at 11:07
  • The original code in my question does generate all the unique permutations, whereas your answer only generates combinations, so there are always less items generated by your code compared to my original code. – TomsonTom Feb 22 '23 at 11:10
  • Oh. It makes sense. Your usage of "combination" and "permutation" is the correct one! I was just so sure that you wouldn't have both (1,0,0) and (0,1,0), that I stupidly assumed that you were not using those words with their usual meaning (In my defense, all other answers assumed, as I did, that you wanted combination. They used `itertools.combination_with_replacement`. Which gives (in more CPU time) the same result. My patched solution is how to implement same with `itertools.combination`, that is without replacement, when you wanted an equivalent of `itertools.permutation`) – chrslg Feb 22 '23 at 12:34
  • Also in my defense, the fact that here, on SO, 90% of the question mentioning words like `combination` or `permutation`, do it with random implicit definition to those words :D. Some I am not used to the fact that when someone says "permutation" they literally mean "permutation". – chrslg Feb 22 '23 at 12:35
  • @TomsonTom All that being said, then modification is just to relax (instead of renforce) restriction to what are acceptable solutions. In my first answer (the one without "regularly spaced" hypothesis), that is more complicated, because, inherently. that branch&bound supposes that "there are 2 kind of solutions, those with x and those without", without considering x position in solution. So it has to be rewritten differently. For my 2nd answer (the one working with integers, and using them as index at the very end), remove the `if N-t>=i` test, and yield `p+[N-t]` without condition. – chrslg Feb 22 '23 at 12:41
  • Also, in recursive call, replace `j` (the minimum value of next value to pick) by `0`. `yield from recursFind(N, nc-1, 0, t+j, p+[j])`. So that next value can be any value, not just values ≥j. So no more constraint "values in ascending order". – chrslg Feb 22 '23 at 12:43
  • And, lastly, for the second test, you can't rely any more on the fact that all future values with be more than `i` (since that `i` is now always 0). So, just replace the test `i*nc+t>N` by `sum(p)>N` – chrslg Feb 22 '23 at 12:44
  • @chrslg Actually the title of my question was wrong, since it said "combinations" of "permutations". Anyway, I think your code still does not work after all the changes. For example, for `ratios=[0.0,1.0]` and `nc=5`, it only returns `[0.0,0.0,0.0,0.0,1.0]`, even though there are 5 permutations possible with different positions of the `1.0` value. – TomsonTom Feb 22 '23 at 17:37
  • See [here](https://www.online-python.com/1wQJl5SBG9) for updated code (note that I still added a condition to cut iterations. Just 't>N'. That is just an optimization: it t>N, the first condition couldn't work anyway. It just prevent some recursion to run if they have no chance to end on a working solution). – chrslg Feb 22 '23 at 18:57
  • @TomsonTom Note that for `ratios=[0,1]` and `nc=5` it does return `[[0, 0, 0, 0, 1], [0, 0, 0, 1, 0], [0, 0, 1, 0, 0], [0, 1, 0, 0, 0], [1, 0, 0, 0, 0]]` – chrslg Feb 22 '23 at 18:58
  • While I'm at it, [here](https://www.online-python.com/MKmqYR46uh) is how the first code, (the one that doesn't assume that ratios are evenly distributed between 0 and 1) would look like for permutations. It is still a branch&bound. But recursion is more severe, since when you've added 0.4 (for example) to p, you are still allowed to add any values to it. Not surprising: it produces more possible solution, so that must be with more recursive calls. Yet, there are still some "cut" to recursions: no need to continue to explore a partial solution if it has no chance to (...) – chrslg Feb 22 '23 at 19:14
  • (...) reach the objective (even filling the rest of `p` with max value won't reach the objective), or if it has no chance not to overtake the objective (even filling the rest of `p` with min value would add up to more than the objective t). – chrslg Feb 22 '23 at 19:15
  • You can see that `permbb([0,1])` returns `[0,0,1], [0,1,0], [1,0,0]` – chrslg Feb 22 '23 at 19:16
1

Have you given a look at the itertools module? Something like:

>>> [c for c in itertools.combinations_with_replacement([0.0, 0.2, 0.4, 0.6, 0.8, 1.0], r=3) if math.isclose(sum(c), 1.0)]
[(0.0, 0.0, 1.0), (0.0, 0.2, 0.8), (0.0, 0.4, 0.6), (0.2, 0.2, 0.6), (0.2, 0.4, 0.4)]

would also work.

From quick tests with timeit, it looks like using itertools is roughly 8 times faster:

python3 -m timeit '[c for c in itertools.combinations_with_replacement([0.0, 0.2, 0.4, 0.6, 0.8, 1.0], r=3) if sum(c) == 1.0]'
50000 loops, best of 5: 5.89 usec per loop

than numpy:

python3 -m timeit -s 'import numpy as np; ratios = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]' 'result = np.stack(np.meshgrid(ratios, ratios, ratios), -1).reshape(-1, 3); result[np.sum(result, axis=1) == 1.0]'
5000 loops, best of 5: 48 usec per loop
etauger
  • 173
  • 1
  • 6
  • 2
    +1. I don't get why the -1. But maybe it is because of one flaw in your answer, that was also present in OP's, and pointed out in comments: there are less correct combinations you would think with your code. Because of infamous `0.2+0.1==0.3 → False` (note that none of the correct combination in this example are impacted by this). So, you may want to replace `sum(c)==1.0` by `math.isclose(sum(c), 1.0)`. But for this problem (again, that was in OP question anyway), this is obviously an improvement from original code. – chrslg Feb 18 '23 at 14:01
  • The Numpy approach doesn't care about the order of the values, so it generates about 4x as many candidates (216 vs 56) which filter to a corresponding ratio (21 vs 5). That probably has something to do with the relative performance. – Karl Knechtel Feb 18 '23 at 14:30
  • 1
    @chrslg You're totally correct, wrote that too fast yesterday! Juste edited it to use `math.isclose`. Thanks for the constructive comment and your nice solutions to the problem as well! – etauger Feb 18 '23 at 19:48
  • @KarlKnechtel In fact, I think it is exactly _because_ the `numpy` solution gives importance to the order (`(0.0, 0.2, 0.8)` and `(0.2, 0.0, 0.8)` are not reduced to the same tuple) that this solution gives more candidates. But yes, this probably explains at least partly the difference in performance between the two approaches. Since the OP used the term **combination**, and not **permutation**, I went for the former in my solution. – etauger Feb 18 '23 at 20:02
  • Er, yes, that's (part of) what I meant. However, that only accounts for about a factor of 4; using Numpy is still paradoxically slower for this kind of manipulation, apparently. – Karl Knechtel Feb 18 '23 at 20:03
1

Since the values apparently are evenly spaced and start at 0, this is equivalent to a problem in integers: we want to find three ordered values from range(6) which sum to 5. Aside from avoiding floating-point inaccuracy, this offers a significant performance improvement in my testing (even without considering the overhead of math.isclose). I will show code for this problem, and trust that it can be adapted as needed.

For reference, integer implementations of the Numpy and itertools approaches:

import numpy as np
from itertools import combinations_with_replacement

def convex_itertools(count, total):
    candidates = combinations_with_replacement(range(total + 1), r=count)
    return [c for c in candidates if sum(c) == total]

def convex_numpy(count, total):
    mesh = np.meshgrid(*([range(total + 1)] * count))
    candidates = np.stack(mesh, -1).reshape(-1, count)
    return candidates[np.sum(candidates, axis=1) == total]

On my machine:

>>> from timeit import timeit
>>> timeit('convex_itertools(3, 5)', globals=globals(), number=10000)
0.08309134596493095
>>> timeit('convex_numpy(3, 5)', globals=globals(), number=10000)
0.8170730160200037

The primary problem here is algorithmic. Using meshgrid produces unordered results, and as such produces many more results than desired. But even using itertools.combinations_with_replacement, there is a lot of filtering to do. For the sample problem size, already 56 ordered candidates (216 unordered) are generated, which will filter down to only 5 (21, respectively).

Thus, it would be preferable to generate the desired results directly, rather than via filtering. For this, I will use a recursive generator:

def convex_manual(count, total):
    if count == 1:
        if total >= 0:
            yield [total]
        return
    for first in range((total // count) + 1):
        remainder = total - (first * count)
        for result in convex_manual(count - 1, remainder):
            yield [first] + [first+r for r in result]

Our base case is when the combinations consist of a single value: there is either a single solution (just use the full total, when it's non-negative and thus a legal value), or no solutions (previously chosen values in the recursion already add up to more than the total desired amount - though this should be impossible from how the recursion is constructed, except with bad initial values). For combinations of more than one value, we consider the possible first values to use (it cannot exceed the average value), recursively find the ways to distribute the excess within our limit (after accounting for the fact that each number must be at least as large as the first, or else the output will not be ordered), and translate those recursive findings into results for the current step.

Let's test it:

>>> list(convex_manual(3, 5))
[[0, 0, 5], [0, 1, 4], [0, 2, 3], [1, 1, 3], [1, 2, 2]]
>>> list(convex_manual(3, 10))
[[0, 0, 10], [0, 1, 9], [0, 2, 8], [0, 3, 7], [0, 4, 6], [0, 5, 5], [1, 1, 8], [1, 2, 7], [1, 3, 6], [1, 4, 5], [2, 2, 6], [2, 3, 5], [2, 4, 4], [3, 3, 4]]

Even for the small input example, and despite the overhead of assembling many lists and using a recursive generator, this is faster:

>>> timeit('list(convex_manual(3, 5))', globals=globals(), number=10000)
0.07173079502535984

Of course, considering how the recursion works gives some insight into improvements for the other approaches. In particular: there is only ever one possibility for the last value, so we should only generate candidates for the other values and then see if they lead to a legal combination. Thus:

import numpy as np
from itertools import combinations_with_replacement

def convex_itertools_opt(count, total):
    candidates = combinations_with_replacement(range(total + 1), r=count-1)
    return [
        c + (remainder,) for c in candidates
        if (remainder := total - sum(c)) >= c[-1]
    ]

def convex_numpy_opt(count, total):
    mesh = np.meshgrid(*([range(total + 1)] * (count-1)))
    candidates = np.stack(mesh, -1).reshape(-1, (count-1))
    # Reference for the technique here:
    # https://stackoverflow.com/questions/8486294
    candidates = np.c_[candidates, total - np.sum(candidates, axis=1)]
    return candidates[candidates[:, -1] >= 0]

Which does get better results for the itertools approach:

>>> timeit('convex_itertools_opt(3, 5)', globals=globals(), number=10000)
0.053549989010207355

but of course will break down for larger problem sizes:

>>> timeit('convex_itertools_opt(5, 10)', globals=globals(), number=10000)
1.7618601340218447
>>> timeit('list(convex_manual(5, 10))', globals=globals(), number=10000)
0.6665365709923208

and incurs too much overhead in Numpy:

>>> timeit('convex_numpy_opt(3, 5)', globals=globals(), number=10000)
0.9056216909666546

Contrary to what one might expect, trying to use lists and list comprehensions at every step (rather than using a recursive generator and explicitly creating a list from the top-level result) seems to have little effect on performance, regardless of the problem size. I tried using Numpy tools for gathering the results and performance got much, much worse and the code was also much harder to understand.

Karl Knechtel
  • 62,466
  • 11
  • 102
  • 153