The best way to solve this in general is to reformulate this to an MILP. Although the reformulation itself is easy, it's also incredibly easy to make a mistake/forget to take something into account.
Since this is a toy problem, I will provide a solution that will work for any problem that has the structure $xy, y\in\{0,1\}$, including your actual problem.
The reformulation
$xy$ can be linearized by replacing $xy$ with an auxiliary variable and adding the following constraints:
\begin{aligned}
x_ly-w\le0\\
w-x_uy\le0\\
w-x+x_l(1-y)\le0\\
-w+x-x_u(1-y)\le0\\
xy_l\le w\le xy_u
\end{aligned}
where $x_l,x_u$ are parameters specifying the lower and upper bounds of $x$.
Reformulating automatically
The easiest way (and the only one that is free) to do this automatically for any problem where this structure is present, is to use our own Octeract Reformulator, which is free to use. It will perform the linearisation for every bilinear term even if your problem only partially has this structure and the rest is e.g. logarithms or cosines or other complicated functions.
You just need to install Octeract Engine (which is also free), grab the reformulation file from our GitHub repo, and copy-paste the following in a Python file:
from octeract import *
from LinearizeBilinearContBinary import LinearizeBilinearContBinaryMod
m = Model()
m.import_model_file('model.nl')
m.apply_mod(LinearizeBilinearContBinaryMod)
print(m)
m.write_problem_to_NL_file('/my/file/name.nl')
#m.write_problem_to_LP_file('/my/file/name.lp')
#m.write_problem_to_MPS_file('/my/file/name.mps')
#m.write_problem_to_GAMS_file('/my/file/name.gms')
This will produce a new file in your desired format which will contain the reformulated problem. You can then solve this with any solver/modelling language you want.
For example, the following Octeract Shell script creates a dummy model to try out the reformulation:
from octeract import *
# ==== Create a toy model ====
m = Model()
for i in range(1,4):
m.add_variable("y"+str(i),0,1,BIN)
m.minimize("x1")
m.set("x1*y1+x2*y2+x3*y3").to(4)
for i in range(1,4):
m.set_variable_bounds('x'+str(i),-1000,1000)
print(m)
m.write_problem_to_NL_file('model.nl')
# ============================
The print
command will give us:
Structure : MBQCQP
Convexity : nonconvex
------------------------------------------
var y1 binary >= 0, <= 1;
var y2 binary >= 0, <= 1;
var y3 binary >= 0, <= 1;
var x1 >= -1000, <= 1000;
var x3 >= -1000, <= 1000;
var x2 >= -1000, <= 1000;
minimize obj : +1*x1+0;
subject to
constraint : (x3*y3)+(x1*y1)+(x2*y2)+0.0 = 4;
------------------------------------------
and if we then run the very first script on the model.nl
file we just created, we get:
Structure : MILP
Convexity : convex
------------------------------------------
var x1 >= -1000, <= 1000;
var x2 >= -1000, <= 1000;
var x3 >= -1000, <= 1000;
var x4 binary >= 0, <= 1;
var x5 binary >= 0, <= 1;
var x6 binary >= 0, <= 1;
var w_xy_0 >= -1000, <= 1000;
var w_xy_1 >= -1000, <= 1000;
var w_xy_2 >= -1000, <= 1000;
minimize obj : +1*x1+0;
subject to
con1 : 1.0*w_xy_2+1.0*w_xy_1+1.0*w_xy_0+0.0 = 4;
constraint_0 : -1.0*w_xy_0+-1000.0*x6+0.0 <= 0;
constraint_1 : 1.0*w_xy_0+-1000.0*x6+0.0 <= 0;
constraint_2 : -1.0*x3+1000.0*x6+1.0*w_xy_0+0.0 <= 0;
constraint_3 : -1.0*w_xy_0+1000.0*x6+1.0*x3+0.0 <= 0;
constraint_4 : -1.0*w_xy_1+-1000.0*x4+0.0 <= 0;
constraint_5 : 1.0*w_xy_1+-1000.0*x4+0.0 <= 0;
constraint_6 : -1.0*x2+1000.0*x4+1.0*w_xy_1+0.0 <= 0;
constraint_7 : -1.0*w_xy_1+1.0*x2+1000.0*x4+0.0 <= 0;
constraint_8 : -1.0*w_xy_2+-1000.0*x5+0.0 <= 0;
constraint_9 : 1.0*w_xy_2+-1000.0*x5+0.0 <= 0;
constraint_10 : -1.0*x1+1.0*w_xy_2+1000.0*x5+0.0 <= 0;
constraint_11 : -1.0*w_xy_2+1000.0*x5+1.0*x1+0.0 <= 0;
------------------------------------------
What the reformulation file does
The file uses the Reformulator Python API to encapsulate the mathematical logic, so that the same logic can be applied to any problem. I have added comments in the mod to describe the steps:
from octeract import *
# Linearize bilinear term x*y where y is binary
# =============================================
# x_l*y-w<=0
# w-x_u*y<=0
# w-x+x_l*(1-y)<=0
# -w+x-x_u*(1-y)<=0
# xy_l<=w<=xy_u
# =============================================
# Define symbolic trigger
my_trigger = Match('V(x)*V(y)')
# Filter binaries and account for permutations - we bind binary to b and non-binary to nb
xbin_filter = (IsBinary('x') & ~ IsBinary('y') & Bind('x', 'b') & Bind('y', 'nb'))
ybin_filter = (IsBinary('y') & ~ IsBinary('x') & Bind('y', 'b') & Bind('x', 'nb'))
# Combine the filters
my_filter = (xbin_filter | ybin_filter)
# Specify how to change the model
# Add auxiliary variable with the same bounds as x*y
add_auxiliary_var = AddVariableSpan('w_xy','b*nb')
# Add parameters for the bounds of the continuous variable
add_parameters = AddParameter('nb_LB','nb','lb') + AddParameter('nb_UB','nb','ub')
substitute_term = SubWith('w_xy')
add_constraint0 = AddConstraint('nb_LB*b-w_xy <= 0')
add_constraint1 = AddConstraint('w_xy-nb_UB*b <= 0')
add_constraint2 = AddConstraint('w_xy-nb-nb_LB*(1-b) <= 0')
add_constraint3 = AddConstraint('-w_xy+nb-nb_UB*(1-b) <= 0')
add_constraints = add_constraint0 + add_constraint1 + add_constraint2 + add_constraint3
# Add all modifications
my_actions = add_auxiliary_var + add_parameters + substitute_term + add_constraints
# Create mod
LinearizeBilinearContBinaryMod = (my_trigger & my_filter).then(my_actions)
The best part about this approach is that you only need to figure out the reformulation logic once. You can then keep using the same mods on your problems or mixing and matching rules until the end of time.