1

I want to solve the following problem using R, and I am struggling to find a good way to do so.

I have a sales forecasts for two products (Product 1 & Product 2), of which there are 2 variations each (A & B).

Picture of dataframe of forecast sales

dat_forecast <- data.frame(
  product = c(1,1,2,2),
  variation = c("A", "B", "A", "B"),
  forecast_sales = c(612,238,741,455),
  ratio = c(0.72,0.28,0.6195652,0.3804348)
)

and I have data frame containing the current units in stock:

Picture of dataframe of units in stock

dat_stock <- data.frame(
  product = c(1,1,2,2),
  variation = c("A", "B", "A", "B"),
  current_stock = c(400,268,341,155),
  ratio = c(0.5988024,0.4011976,0.6875,0.3125)
)

Assume we wanted to produce another 100 units of Product 1 and another 200 units of Product 2. The task is to allocate the produced units of product to the different variations (A & B) in such a way, that the ratio of units in stock (highlighted in green) gets as close as possible to the ratio in the original forecast (highlighted in blue).

dat_to_be_produced <- data.frame(
  product = c(1,2),
  units = c(100,200)
)

What is the easiest way to solve this problem? Please note, in case for Product 1, there cannot be a precise solution as there is already more stock of Product 1 - Variation B than forecast (238 forecast, 268 in stock), so obviously one wouldn't allocate any more units to variation B in this case.

Any help as to how to solve this in R would be greatly appreciated.

AirSquid
  • 10,214
  • 2
  • 7
  • 31
Johnny
  • 751
  • 12
  • 24
  • This can be described as a linear program. Have you set up any kind of LP in `r` before? – AirSquid Jan 11 '23 at 19:17
  • I thought it might but in answer to your question, no I haven't set up an LP problem in R before. If you could show me how, I would be incredibly grateful. – Johnny Jan 11 '23 at 19:20
  • 1
    I'm not currently a regular `r` user, but I know there is an LP framework that you can use and teach yourself. This is a good example: https://towardsdatascience.com/linear-programming-in-r-444e9c199280 – AirSquid Jan 11 '23 at 19:22
  • 1
    I can give you some hints on how to set up the *math problem* below and you can work on the translation, which shouldn't be too daunting, and you can re-post for help if you get something kinda working... – AirSquid Jan 11 '23 at 19:23
  • How to set up the math problem would already be very helpful. Thank you also for providing the link article on the *lpSolve* package. – Johnny Jan 11 '23 at 19:25
  • Are you still looking for more on this in addition to my answer below? And if so, are you open to using a framework other than `R`? This can be more clearly expressed in other frameworks such as `python`. – AirSquid Jan 22 '23 at 02:31
  • Hi @AirSquid, actually yes, that would be great and sure, if you think that Python provides greater flexibility in terms of expressing the problem, then I am happy to switch to python on this one.. – Johnny Jan 25 '23 at 10:51
  • See edits in my previous answer... – AirSquid Jan 25 '23 at 20:54

2 Answers2

2

The equations you describe can be translated into a Linear Program (LP), most likely you will need to make it a Mixed Integer Linear Program (MILP) because the variables for how many of something to make should be integers. So there are a handful of online references to search/review on that, including on this site.

So, as a starting place, I'd set out to tackle one of the products at a time. You'll clearly need a couple variables:

QuantityA:  an integer for how many of type-A to produce
QuantityB:  ... type-B

and some logical constraints such as:

QuantityA + QuantityB == 100

Those are the key variables you want to determine, obviously. You will also need a variable for the "error" or the difference between the target ratio and the best you can do. Your optimization should have as the objective function to min(error).

The error part here is a little tricky and you'll have to think through it. Perhaps you can hit 1 ratio exactly, but the other would be 0.1 off, or such. It seems a logical starting point to minimize the maximum error across both target ratios. Or:

min(error) = min(error_A, error_B)
or...
min(|tgtA - resultA|, |tgtB - resultB|)

absolute values are non-linear so you will need 4 equations to constrain the error to be greater than the +/- of these two quantities.... that is 4 constraints on the error variable in your program.

Something like:

error >= tgtA - resultA
error >= resultA - tgtA
.... same for B

and you can express resultA and resultB as functions of your variables and the fixed inputs.

Give it a whirl...

EDIT: Below is a full solution in python using the pyomo package to express the problem and the separately available (free) cbc solver:

CODE

# restock

import pyomo.environ as pyo

### DATA

current_stock = { (1, 'A') : 400,
                  (1, 'B') : 268,
                  (2, 'A') : 341,
                  (2, 'B') : 155
                }

production_size = { 1: 100,
                    2: 200 }

# we could "do a little math" and compute these, but I'll just plug in...
target_ratios = { (1, 'A') : 0.72,
                  (1, 'B') : 0.28,
                  (2, 'A') : 0.6195652,
                  (2, 'B') : 0.3804348}

products = list({k[0] for k in current_stock.keys()})  # Sets are appropriate, but pyomo gives warning w/ set initializations
variants = list({k[1] for k in current_stock.keys()})   


### MODEL

m = pyo.ConcreteModel()

# SETS
m.P = pyo.Set(initialize=products)
m.V = pyo.Set(initialize=variants)

# PARAMETERS
m.stock   = pyo.Param(m.P, m.V, initialize=current_stock)
m.target  = pyo.Param(m.P, m.V, initialize=target_ratios)
m.produce = pyo.Param(m.P, initialize=production_size)

# VARIABLES
m.make  = pyo.Var(m.P, m.V, domain=pyo.NonNegativeIntegers)
m.error = pyo.Var(m.P, domain=pyo.NonNegativeReals)

# OBJECTIVE
# minimize the sum of the two errors.  They are independent, so this works fine
m.obj = pyo.Objective(expr = sum(m.error[p] for p in m.P))

# CONSTRAINTS
# Make exactly the production run...
@m.Constraint(m.P)
def production_run(m, p):
    return sum(m.make[p, v] for v in m.V) == m.produce[p]

# constrain the "positive" and "negative" error for each product line.  
# e >= |ratio - target| translates into 2 constraints to linearize
#      e >= ratio - target
#      e >= target - ratio
@m.Constraint(m.P, m.V)
def pos_error(m, p, v):
    return m.error[p] >= (m.make[p, v] + m.stock[p, v]) / (sum(m.stock[p, vv] for vv in m.V) + m.produce[p]) - m.target[p, v]

@m.Constraint(m.P, m.V)
def neg_error(m, p, v):
    return m.error[p] >= -(m.make[p, v] + m.stock[p, v]) / (sum(m.stock[p, vv] for vv in m.V) + m.produce[p]) + m.target[p, v]

m.pprint()  # to QA everything...

# solve
solver = pyo.SolverFactory('cbc')
result = solver.solve(m)

print(result)  # must check status = optimal

print('production plan')
for idx in sorted(m.make.index_set()):
    print(f'make {m.make[idx].value:3.0f} of {idx}')

OUTPUT

    7 Set Declarations
    P : Size=1, Index=None, Ordered=Insertion
        Key  : Dimen : Domain : Size : Members
        None :     1 :    Any :    2 : {1, 2}
    V : Size=1, Index=None, Ordered=Insertion
        Key  : Dimen : Domain : Size : Members
        None :     1 :    Any :    2 : {'A', 'B'}
    make_index : Size=1, Index=None, Ordered=True
        Key  : Dimen : Domain : Size : Members
        None :     2 :    P*V :    4 : {(1, 'A'), (1, 'B'), (2, 'A'), (2, 'B')}
    neg_error_index : Size=1, Index=None, Ordered=True
        Key  : Dimen : Domain : Size : Members
        None :     2 :    P*V :    4 : {(1, 'A'), (1, 'B'), (2, 'A'), (2, 'B')}
    pos_error_index : Size=1, Index=None, Ordered=True
        Key  : Dimen : Domain : Size : Members
        None :     2 :    P*V :    4 : {(1, 'A'), (1, 'B'), (2, 'A'), (2, 'B')}
    stock_index : Size=1, Index=None, Ordered=True
        Key  : Dimen : Domain : Size : Members
        None :     2 :    P*V :    4 : {(1, 'A'), (1, 'B'), (2, 'A'), (2, 'B')}
    target_index : Size=1, Index=None, Ordered=True
        Key  : Dimen : Domain : Size : Members
        None :     2 :    P*V :    4 : {(1, 'A'), (1, 'B'), (2, 'A'), (2, 'B')}

3 Param Declarations
    produce : Size=2, Index=P, Domain=Any, Default=None, Mutable=False
        Key : Value
          1 :   100
          2 :   200
    stock : Size=4, Index=stock_index, Domain=Any, Default=None, Mutable=False
        Key      : Value
        (1, 'A') :   400
        (1, 'B') :   268
        (2, 'A') :   341
        (2, 'B') :   155
    target : Size=4, Index=target_index, Domain=Any, Default=None, Mutable=False
        Key      : Value
        (1, 'A') :      0.72
        (1, 'B') :      0.28
        (2, 'A') : 0.6195652
        (2, 'B') : 0.3804348

2 Var Declarations
    error : Size=2, Index=P
        Key : Lower : Value : Upper : Fixed : Stale : Domain
          1 :     0 :  None :  None : False :  True : NonNegativeReals
          2 :     0 :  None :  None : False :  True : NonNegativeReals
    make : Size=4, Index=make_index
        Key      : Lower : Value : Upper : Fixed : Stale : Domain
        (1, 'A') :     0 :  None :  None : False :  True : NonNegativeIntegers
        (1, 'B') :     0 :  None :  None : False :  True : NonNegativeIntegers
        (2, 'A') :     0 :  None :  None : False :  True : NonNegativeIntegers
        (2, 'B') :     0 :  None :  None : False :  True : NonNegativeIntegers

1 Objective Declarations
    obj : Size=1, Index=None, Active=True
        Key  : Active : Sense    : Expression
        None :   True : minimize : error[1] + error[2]

3 Constraint Declarations
    neg_error : Size=4, Index=neg_error_index, Active=True
        Key      : Lower : Body                                           : Upper : Active
        (1, 'A') :  -Inf :      - (make[1,A] + 400)/768 + 0.72 - error[1] :   0.0 :   True
        (1, 'B') :  -Inf :      - (make[1,B] + 268)/768 + 0.28 - error[1] :   0.0 :   True
        (2, 'A') :  -Inf : - (make[2,A] + 341)/696 + 0.6195652 - error[2] :   0.0 :   True
        (2, 'B') :  -Inf : - (make[2,B] + 155)/696 + 0.3804348 - error[2] :   0.0 :   True
    pos_error : Size=4, Index=pos_error_index, Active=True
        Key      : Lower : Body                                         : Upper : Active
        (1, 'A') :  -Inf :      (make[1,A] + 400)/768 - 0.72 - error[1] :   0.0 :   True
        (1, 'B') :  -Inf :      (make[1,B] + 268)/768 - 0.28 - error[1] :   0.0 :   True
        (2, 'A') :  -Inf : (make[2,A] + 341)/696 - 0.6195652 - error[2] :   0.0 :   True
        (2, 'B') :  -Inf : (make[2,B] + 155)/696 - 0.3804348 - error[2] :   0.0 :   True
    production_run : Size=2, Index=P, Active=True
        Key : Lower : Body                  : Upper : Active
          1 : 100.0 : make[1,A] + make[1,B] : 100.0 :   True
          2 : 200.0 : make[2,A] + make[2,B] : 200.0 :   True

16 Declarations: P V stock_index stock target_index target produce make_index make error obj production_run pos_error_index pos_error neg_error_index neg_error

Problem: 
- Name: unknown
  Lower bound: 0.06927066
  Upper bound: 0.06927066
  Number of objectives: 1
  Number of constraints: 2
  Number of variables: 2
  Number of binary variables: 0
  Number of integer variables: 4
  Number of nonzeros: 1
  Sense: minimize
Solver: 
- Status: ok
  User time: -1.0
  System time: 0.0
  Wallclock time: 0.01
  Termination condition: optimal
  Termination message: Model was solved to optimality (subject to tolerances), and an optimal solution is available.
  Statistics: 
    Branch and bound: 
      Number of bounded subproblems: 0
      Number of created subproblems: 0
    Black box: 
      Number of iterations: 0
  Error rc: 0
  Time: 0.14908695220947266
Solution: 
- number of solutions: 0
  number of solutions displayed: 0

production plan
make 100 of (1, 'A')
make   0 of (1, 'B')
make  90 of (2, 'A')
make 110 of (2, 'B')
[Finished in 534ms]
AirSquid
  • 10,214
  • 2
  • 7
  • 31
  • Hi @AirSquid, many thanks for your detailed answer - this is really helpful!! I rewarded the bounty to you, but accepted Andrea Barghetti's answer as the answer to my original question as he provided it in R. I thought that was the fairest way in this situation.. again, many thanks.. – Johnny Jan 26 '23 at 18:44
2

In R you can use optimize() to find the minimum or max of a function.

Here is a function that use optimize to allocate N units of product to A and B, given the initial stocks and the forecast. It uses optimize() to find the best number of units to allocate to "A", so that the resulting ratio is the closest to the forecasted one.

allocate_units <- function(forecast,
                           stocks,
                           units) {
   
  get_error <- function(x) {
    target_ratio <- forecast[1]/sum(forecast)
    updated_stocks <- c(stocks[1]+x,stocks[2]+(units-x))
    ratio <- updated_stocks[1]/sum(updated_stocks)
    abs(target_ratio-ratio)
  }
  
  opt_As <- optimize(f = get_error, 
                     maximum = F, 
                     interval = c(0,units))
  
  As <- round(opt_As$minimum)
  Bs <- units - As
  
  c(A=As,B=Bs)
  
}

for example:

allocate_units(forecast = c(A=612,B=238),
               stocks = c(A=400,B=268),
               units = 100)

A = 100; B = 0

allocate_units(forecast = c(A=741,B=455),
               stocks = c(A=341,B=155),
               units = 200)

A = 90; B = 110

  • In general, the practice of rounding fractional numbers to convert them to integers in the context of optimization is dangerous. It may lead to infeasible results, or a sub-optimal answer. It works here, but is not a good general practice. – AirSquid Jan 26 '23 at 18:31