4

Like the title explains, my program always returns the initial guess.

For context, the program is trying to find the best way to allocate some product across multiple stores. Each stores has a forecast of what they are expected to sell in the following days (sales_data). This forecast does not necessarily have to be integers, or above 1 (it rarely is), it is an expectation in the statistical sense. So, if a store has sales_data = [0.33, 0.33, 0.33] the it is expected that after 3 days, they would sell 1 unit of product.

I want to minimize the total time it takes to sell the units i am allocating (i want to sell them the fastest) and my constraints are that I have to allocate the units that I have available, and I cannot allocate a negative number of product to a store. I am ok having non-integer allocations for now. For my initial allocations i am dividing the units I have available equally among all stores.

Below is a shorter version of my code where I am having the problem:

import numpy, random
from scipy.optimize import curve_fit, minimize

unitsAvailable = 50
days = 15

class Store:

    def __init__(self, num):
        self.num = num

        self.sales_data = []


stores = []
for i in range(10):
    # Identifier
    stores.append(Store(random.randint(1000, 9999)))
    # Expected units to be sold that day (It's unlikey they will sell 1 every day)
    stores[i].sales_data = [random.randint(0, 100) / 100 for i in range(days)]
    print(stores[i].sales_data)


def days_to_turn(alloc, store):
    day = 0

    inventory = alloc
    while (inventory > 0 and day < days):
        inventory -= store.sales_data[day]
        day += 1
    return day

def time_objective(allocations):
    time = 0
    for i in range(len(stores)):
        time += days_to_turn(allocations[i], stores[i])
    return time

def constraint1(allocations):

    return unitsAvailable - sum(allocations)

def constraint2(allocations):

    return min(allocations) - 1

cons = [{'type':'eq', 'fun':constraint1}, {'type':'ineq', 'fun':constraint2}]
guess_allocs = []

for i in range(len(stores)):
    guess_allocs.append(unitsAvailable / len(stores))

guess_allocs = numpy.array(guess_allocs)

print('Optimizing...')

time_solution = minimize(time_objective, guess_allocs, method='SLSQP', constraints=cons, options={'disp':True, 'maxiter': 500})

time_allocationsOpt = [max([a, 0]) for a in time_solution.x]

unitsUsedOpt = sum(time_allocationsOpt)
unitsDaysProjected = time_solution.fun

for i in range(len(stores)):
    print("----------------------------------")
    print("Units to send to Store %s: %s" % (stores[i].num, time_allocationsOpt[i]))
    print("Time to turn allocated: %d" % (days_to_turn(time_allocationsOpt[i], stores[i])))

print("----------------------------------")
print("Estimated days to be sold: " + str(unitsDaysProjected))
print("----------------------------------")
print("Total units sent: " + str(unitsUsedOpt))
print("----------------------------------")

The optimization finishes successfully, with only 1 iteration, and no matter how i change the parameters, it always returns the initial guess_allocs.

Any advice?

Marco
  • 465
  • 2
  • 7
  • 12
  • Your objective function is discrete valued. This is a problem because it has zero gradient but the optimizer needs the gradient to improve the solution. Try an optimization method that does not depend on the gradient (such as nelder-mead, simulated annealing, or differential evolution). – MB-F Aug 08 '19 at 21:42
  • @kazemakase Thanks for your reply! What do you mean by discrete valued? each allocation is allowed to be non-integers. Or do you mean that the input to the objective function is an array of multiple values when it should be just 1 for SLSQP? Also, do those other methods allow for constraintes to make sure that i am not allocating a negative number of units and that I am using all the units available? – Marco Aug 08 '19 at 21:49
  • I was referring to the *output* of the objective function, which returns discrete days. – MB-F Aug 09 '19 at 07:28

1 Answers1

6

The objective function does not have a gradient because it returns discrete multiples of days. This is easily visualized:

import numpy as np
import matplotlib.pyplot as plt

y = []
x = np.linspace(-4, 4, 1000)
for i in x:
    a = guess_allocs + [i, -i, 0, 0, 0, 0, 0, 0, 0, 0]    
    y.append(time_objective(a))

plt.plot(x, y)
plt.xlabel('relative allocation')
plt.ylabel('objective')
plt.show()

enter image description here

If you want to optimize such a function you cannot use gradient based optimizers. There are two options: 1) Find a way to make the objective function differentiable. 2) Use a different optimizer. The first is hard. For the second, let's try dual annealing. Unfortunately, it does not allow constraints so we need to modify the objective function.

Constraining N numbers to a constant sum is the same as having N-1 unconstrained numbers and setting the Nth number to constant - sum.

import scipy.optimize as spo

bounds = [(0, unitsAvailable)] * (len(stores) - 1)

def constrained_objective(partial_allocs):
    if np.sum(partial_allocs) > unitsAvailable:
        # can't sell more than is available, so make the objective infeasible
        return np.inf
    # Partial_alloc contains allocations to all but one store.
    # The final store gets allocated the remaining units.
    allocs = np.append(partial_allocs, unitsAvailable - np.sum(partial_allocs))
    return time_objective(allocs)

time_solution = spo.dual_annealing(constrained_objective, bounds, x0=guess_allocs[:-1])
print(time_solution)

This is a stochastic optimization method. You may want to run it multiple times to see if it can do better, or play with the optional parameters...

Finally, I think there is a problem with the objective function:

for i in range(len(stores)):
    time += days_to_turn(allocations[i], stores[i])

This says that the stores do not sell at the same time but only one after another. Does each store wait with selling until the previous store runs out of items? I think not. Instead, they will sell simultaneously and the time it takes for all units to be sold is the time of the store that takes longest. Try this instead:

for i in range(len(stores)):
    time = max(time, days_to_turn(allocations[i], stores[i]))
MB-F
  • 22,770
  • 4
  • 61
  • 116
  • Wow thats a great answer thanks! But I am running into an issue. When I ran this it still gives me wrong allocations (it returns guess_allocs) and the message from the solution says that it reaches the maximum iteration limit. When I increase the limit, then I get an error message saying "Stopping algorithm because function create NaN or (+/-) infinity values even with trying new random parameters". Any ideas? – Marco Aug 09 '19 at 15:33
  • @marco I guess this happens because the optimizer tries more or less random guesses. We changed the objective function to return `inf` if the constraints are violated. If all the starting guesses violate the constraints the optimizer thinks there is no solution. I had this problem when I tried `differential_evolution` first, but `dual_annealing` worked pretty reliable on my setup... – MB-F Aug 09 '19 at 18:50