Professional Documents
Culture Documents
Mo Book Ampl Com en Latest
Mo Book Ampl Com en Latest
i
Hands-On Mathematical Optimization with AMPL in Python
Welcome to this repository of notebooks Hands-On Mathematical Optimization with AMPL in Python, also know as Data-
Driven Mathematical Optimization with AMPL in Python, or MO-Book With AMPL, a project currently under development
with completion expected by the end of 2023. This is the AMPL version of Hands-On Mathematical Optimization in
Python. These notebooks introduce the concepts and tools of mathematical optimization with examples from a range of
disciplines. The goals of these notebooks are to:
• provide a foundation for hands-on learning of mathematical optimization,
• demonstrate the tools and concepts of optimization with practical examples,
• help readers to develop the practical skills needed to build models and solving problems using state-of-the-art
modeling languages and solvers.
Getting started
The notebooks in this repository make extensive use of amplpy which is an interface that allows developers to access
the features of AMPL from within Python. AMPL (A Mathematical Programming Language) is an algebraic modeling
language to describe and solve high-complexity problems in large-scale optimization. Natural mathematical modeling
syntax lets you formulate optimization models the way you think about them. AMPL’s new Python ecosystem allows you
to collaborate, ideate, and prototype to build full optimization applications and deploy them to larger systems.
All notebooks in this repository can be opened and run in Google Colab. A launch icon appearing at the top of a page
(look for the rocket) indicates the notebook can be opened as an executable document. Selecting Colab will reopen the
notebook in Google Colab. Cells inside of the notebooks will perform any necessary installations of amplpy and solvers
needed to execute the code within the notebook.
Start your journey with the first chapter!
Help us!
We seek your feedback! If you encounter an issue or have suggestions on how to make these examples better, please open
an issue using the link at the top of every page (look for the Github cat icon).
About Us
We are a group of researchers and educators who came together with a common purpose of developing materials for use
in our classroom teaching. Hopefully, these materials will find use in other classrooms and, most importantly, by those
seeking entry into the world of building optimization models for data-rich applications.
• Krzysztof Postek, Boston Consulting Group (formerly TU Delft)
• Alessandro Zocca, VU Amsterdam
• Joaquim Gromicho, ORTEC and the University of Amsterdam
• Jeffrey Kantor, University of Notre Dame
Citation
CONTENTS 1
Hands-On Mathematical Optimization with AMPL in Python
@book{PostekZoccaAMPL2024,
title = "Hands-On Mathematical Optimization with AMPL in Python",
author = "Postek, Krzysztof and Zocca, Alessandro and Gromicho, Joaquim and Kantor,
↪ Jeffrey",
year = 2024,
publisher = "Online",
url = "https://ampl.com/mo-book"
}
CONTENTS 2
CHAPTER
ONE
1. MATHEMATICAL OPTIMIZATION
Mathematical optimization is a broad term describing a way of mathematically describing decision problems and then
solving them using dedicated algorithms. Optimization models consist of three parts:
• the decision variables, which correspond to actions or choices that we can make in our decision problem: whether
to open a new manufacturing facility, which supply routes to use, or for which prices we should sell our products.
• the objective function, which is used to evaluate a specific solution (i.e. a specific choice of values for the decision
variables introduced earlier). For the objective function, a “goal” should be specified, either maximize or minimize
it. In other applications, the objective can be to minimize operational costs or to maximize the number of satisfied
customers.
• the constraints that restrict the possible values of our decision variables, i.e., conditions that must be satisfied.
Other common examples of constraints are to require that a maximum allowed budget is satisfied, that all demand
of important customers is met, or that the capacities of warehouses are not exceeded.
Both the objective function and the constraints are expressed as functions of the decision variables. The constraints define
the feasible region of a model, i.e., the set of all candidate solutions that meet the constraints.
The goal of an optimization model is to find the best solution – the global optimum – among the values for the decision
variables that meet all the constraints.
Applied mathematical optimization requires three types of skills, which can be related to three fundamental questions:
• What to model? First, the modeler must translate a problem from the real world to an abstract mathematical
representation. Not every aspect of the real world can or should be taken into account by the model, so there are
many choices to be made in this first step, which typically have a significant impact on the model and solution
approach.
• How to model? There can be multiple equivalent model formulations. Conceptually, equivalent models solve the
same optimization problem, but the applicable solution approaches or computational complexity may differ.
• How to interpret the model’s solution? After solving the model, the solution has to be evaluated and translated
back to the original real-world problem
These three aspects should be treated as a continuous process, not as sequential steps. For example, if the final solution
turns out to be impractical, we need to adjust the model. If certain desired properties cannot be modeled efficiently,
perhaps we should re-evaluate what to include in the model. A mathematical model is a tool, not a goal (well, except for
mathematicians to study). A model is “always flawed” and our challenge is to make a model useful.
Mathematically, we can describe optimization problems as follows. Given an objective function 𝑓 ∶ 𝑋 → ℝ to be
minimized, with 𝑋 ⊆ ℝ𝑛 being the feasible region of candidate solutions, we seek to find 𝑥 ∈ 𝑋 satisfying the following
condition 𝑓(𝑦) ≥ 𝑓(𝑥) ∀ 𝑦 ∈ 𝑋, i.e., that the solution we find is at least as good as all other possible solutions. Similarly,
3
Hands-On Mathematical Optimization with AMPL in Python
we can define a maximization problem by changing the last condition into 𝑓(𝑦) ≤ 𝑓(𝑥) ∀ 𝑦 ∈ 𝑋. In both cases, we
refer to such solutions as optimal solutions. The general way to formulate a minimization problem is:
min 𝑓(𝑥)
s.t. 𝑥 ∈ 𝑋,
and similarly for a maximization problem. Different types of function 𝑓 and set 𝑋 lead to different types of optimization
problems and to different solution techniques.
In this introductory chapter, we present a simple example of optimization in the context of production planning, which
serves also as a tutorial introduction to optimization in the Python programming language and the AMPL optimization
modeling language.
Go to the next chapter about linear optimization.
A company produces two versions of a product. Each version is made from the same raw material that costs $10 per
gram, and each version requires two different types of specialized labor to finish.
• 𝑈 is the higher priced version of the product. 𝑈 sells for $270 per unit and requires 10 grams of raw material, one
hour of labor type 𝐴, two hours of labor type 𝐵. The market demand for 𝑈 is limited to forty units per week.
• 𝑉 is the lower priced version of the product with unlimited demand that sells for $210 per unit and requires 9 grams
of raw material, 1 hour of labor type 𝐴 and 1 hour of labor type 𝐵.
This data is summarized in the following table:
Version Raw Material required Labor A required Labor B required Market Demand Price
U 10 g 1 hr 2 hr ≤ 40 units $270
V 9g 1 hr 1 hr unlimited $210
Weekly production at the company is limited by the availability of labor and by the inventory of raw material. The raw
material has a shelf life of one week and must be ordered in advance. Any raw material left over at the end of the week
is discarded. The following table details the cost and availability of raw material and labor.
The problem statement above describes an optimization problem. Reformulating the problem statement as a mathematical
model involves three kinds of elements:
• decision variables
• objective function
• constraints
The starting point in developing a mathematical model is to list decision variables relevant to the problem at hand. Decision
variables are quantities that can be modified to achieve a desired outcome. While some decision variables introduced at
this stage may prove redundant later, the goal at this point is to create a comprehensive list of variables that will be useful
in expressing the problem’s objective and constraints.
For this problem statement, listed below are decision variables with symbols, descriptions, and any lower and upper bounds
that are known from the problem data.
The next step is to formulate an objective function describing that describes how we will measure the value of candidate
solutions to the problem. In this case, the value of the solution is measured by profit, which is to be maximized.Profit is
equal to the difference between total revenue from sales and total cost of operations:
Total revenue, in turn, is the sum of the revenues from the two products, and total cost is the sum of the costs of the three
resources:
revenue = 270𝑦𝑈 + 210𝑦𝑉
cost = 10𝑥𝑀 + 50𝑥𝐴 + 40𝑥𝐵
Putting these together, we have the following objective function expressing maximization of profit:
Finally, the decision variables 𝑦𝑈 , 𝑦𝑉 , 𝑥𝑀 , 𝑥𝐴 , 𝑥𝐵 need to satisfy the specific conditions given in the problem statement.
Constraints are mathematical relationships among decision variables or expressions, formulated either as equalities or
inequalities. In this problem, for each resource there is a requirement that the amount used in production of 𝑈 plus
the amount used in the production of 𝑉 must not exceed the amount available. These requirements can be expressed
mathematicaly as linear inequalities:
We are now ready to formulate the full mathematical optimization problem in the following canonical way: First we state
the objective function to be maximized (or minimized), then we list all the constraints, and lastly we list all the decision
This completes the mathematical description of this example of a production planning problem.
An optimal solution of this problem is any assignment of values to the decision variables that meets the constraints and
that achieves the maximum/minimum objective.
However, even for a simple problem like this one, it is not immediately clear what the optimal solution is. This is exactly
where mathematical optimization algorithms come into play. They are generic procedures that can find the optimal
solutions of problems as long as these problems can be formulated in a standardized fashion as above.
For a practitioner, mathematical optimization often boils down to formulating the problem as a model above, then passing
it over to one of the many software packages that can solve such a model regardless of what was the original story behind
the model. To do so, we need an interface of communication between the models and the algorithms. In this book, we
adopt an interface based on the AMPL modeling language and the Python programming language. AMPL is based on
the same mathematical formulation that practitioners use, but is standardized so that it can be read and translated by a
computer system. Python provides access to powerful tools for processing data and presenting results.
Thus the next step is to create the corresponding AMPL model.
AMPL is an algebraic modeling language for mathematical optimization that integrates with the Python programming
environment. It enables users to define optimization models consisting of decision variables, objective functions, and
constraints, and to compute solutions using a variety of open-source and commercial solvers.
This notebook introduces basic concepts of AMPL needed to formulate and solve the production planning problem
introduced in a companion notebook:
• Variables
• Objectives
• Constraints
• Solving
• Reporting the solution
The AMPL model shown in this notebook is a direct translation of the mathematical model into basic AMPL components.
In this approach, parameter values from the mathematical model are included directly in the AMPL model for simplicity.
This method works well for problems with a small number of decision variables and constraints, but it limits the reuse
of the model. Another notebook will demonstrate AMPL features for writing models for more generic, “data-driven”
applications.
We start by installing amplpy, the application programming interface (or API) that integrates the AMPL modeling lan-
guage with the Python programming language. Also we install two Python utilities, matplotlib and pandas, that will be
used in the parts of this notebook that display results.
These installations need to be done only once for each Python environment on a personal computer. A new installation
must be done for each new Colab session, however.
# install dependencies
%pip install -q amplpy matplotlib pandas
The first step for a new AMPL model is to import the needed components into the AMPL environment. The Python code
shown below is used at the beginning of every notebook. There are just two elements that may vary according to your
needs:
• The modules line lists the solvers that you intend to use. In the present notebook we list "cbc" and "highs"
to request the free CBC and HiGHS solvers.
• The license_uuid is a code that determines your permissions to use AMPL and commercial solvers. Here
the UUID is set to "default" which gets you full-featured versions of AMPL and most popular solvers, limited
only by the size of the problems that can be solved; this is sufficient for all of our small examples.
If you have obtained a different license for AMPL and one or more solvers, you can get its UUID from your account on
the AMPL Portal. If you are using these notebooks in a course, your instructor may give you a UUID to use.
This step also creates a new object, named ampl, that will be referenced when you want to refer to various components
and methods of the AMPL system within Python statements.
ampl = ampl_notebook(
modules=["cbc", "highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).
To define the decision variables of your model, you use AMPL var statements. Our basic production planning model
has 5 decision variables, so they can be defined by 5 var statements.
Each statement starts with the keyword var and a unique name for the variable. Then lower and upper bounds for the
variable are specified by >= and <= phrases. Bounds are optional; in this model, >= 0 is specified for all 5 variables, but
some of them have no specified upper bound.
The %%ampl_eval line at the beginning of this cell tells Python to send all of the statements in the cell to AMPL. To
add an AMPL comment line, put a # character at the beginning of the line.
%%ampl_eval
# define decision variables
reset;
var xM >= 0;
var xA >= 0, <= 80;
var xB >= 0, <= 100;
Since this is a maximization problem, an AMPL maximize statement specifies the objective function. (If it were a
minimization, a minimize statement would be used instead.)
The maximize keyword is followed by a name for the objective — we can call it Profit — and a colon (:) char-
acter. Then the expression for the objective function is written in AMPL just the same as we previously wrote it in the
mathematical formulation.
Notice that all AMPL statements are ended with a semicolon (;) character. A long statement can be spread over more
than one line, as in the case of our maximize statement for this model.
%%ampl_eval
# define objective function
maximize Profit:
270*yU + 210*yV - 10*xM - 50*xA - 40*xB;
AMPL specifies constraints in much the same way as the objective, with just a few differences:
• An AMPL constraint definition begins with the keywords subject to.
• The expression for a constraint contains a less-than (<=), greater-than (>=), or equals (=) operator.
Just as for the objective, each constraint has a name, and the expressions for the three constraints are the same as in the
mathematical formulation.
(You can abbreviate subject to as subj to or s.t. Or you can leave it out entirely; a statement that does not
begin with a keyword is assumed by AMPL to be a constraint definition.)
%%ampl_eval
# define constraints
With the model now fully specified, the next step is to compute optimal values for the decision variables. From this point
onward, our cells will contain Python statements and function calls, including calls to amplpy functions that manage the
integration of AMPL and Python.
There are many ways to assign values to the variables, so that all of the bounds and all of the constraints are satisfied.
Each of these ways gives a feasible solution for our model. A solution is optimal if it gives the highest possible value of
the objective function among all the feasible solutions.
To compute an optimal solution, we call a solver: a separate program that applies numerical algorithms to determine
optimal values for the variables. There are just a few steps:
• The AMPL commands show and expand display the model components and expressions, so that you can check
that they have been specified correctly. We use the amplpy.AMPL.eval function to run these commands in
AMPL.
• The amplpy.AMPL.option attribute sets the "solver" option to one of the solvers that we loaded in Step
2.
• The amplpy.AMPL.solve function invokes the chosen solver. AMPL takes care of converting the model to
the form that the solver requires for its computation, and converting the results back to a form that can be used in
Python.
To show how different solvers can be tested, we conclude by setting a different solver and solving again. As expected, the
two solvers report the same optimal objective value, 2400.
ampl.option["solver"] = "highs"
ampl.solve()
variables: xA xB xM yU yV
objective: Profit
maximize Profit:
-10*xM - 50*xA - 40*xB + 270*yU + 210*yV;
subject to raw_materials:
-xM + 10*yU + 9*yV <= 0;
subject to labor_A:
-xA + 2*yU + yV <= 0;
subject to labor_B:
-xB + yU + yV <= 0;
The final step in most applications is to report the solution in a suitable format. For this example, we demonstrate simple
tabular and graphic reports using the Pandas library. For an overview of other ways to report and visualize the solutions,
see also the appendix of the gasoline-distribution notebook.
The amplpy ampl.display function shows the values of one or more AMPL expressions, computed from the current
values of the variables. You can also apply this function to special expresions such as _var for the values of all the
variables, and _varname for the names of all the variables.
ampl.display("_varname", "_var")
Profit = 2400
: _varname _var :=
1 xM 720
2 xA 80
3 xB 80
4 yU 0
5 yV 80
;
After an optimal solution has been successfully computed, the value of an AMPL expression can be retrieved in Python
by use of the ampl.get_value function. When combined with Python f strings, ampl.get_value provides a
convenient means of creating formatted reports.
Profit = 2400.00
Revenue = 16800.00
Cost = 14400.00
Pandas, a freely available library for working with data in Python, is widely used in the data science community. Here
we use a Pandas Series() object to hold and display solution data. We can then visualize the data using the matplotlib
library, for instance with a bar chart.
import pandas as pd
raw_materials = pd.Series(
{
"A": ampl.get_value("xA"),
"B": ampl.get_value("xB"),
"M": ampl.get_value("xM"),
}
)
U 0.0
V 80.0
dtype: float64
A 80.0
B 80.0
M 720.0
dtype: float64
Appendix???
%%writefile production_planning_basic.mod
# decision variables
var x_M >= 0;
var x_A >= 0, <= 80;
var x_B >= 0, <= 100;
# auxiliary variables
var revenue = 270 * y_U + 210 * y_V;
var cost = 10 * x_M + 50 * x_A + 40 * x_B;
# objective
maximize profit: revenue - cost;
# constraints
s.t. raw_materials: 10 * y_U + 9 * y_V <= x_M;
s.t. labor_A: 2 * y_U + 1 * y_V <= x_A;
s.t. labor_B: 1 * y_U + 1 * y_V <= x_B;
Overwriting production_planning_basic.mod
In this notebook, we’ll revisit the production planning example. However, this time we’ll demonstrate how Python’s data
structures combine with AMPL’s ability to separate model and data, to create an optimization model that scales with the
size of the data tables. This enables the model to adjust to new products, varying prices, or changing demand. We refer
to this as “data-driven” modeling.
This notebook introduces two new AMPL model components that describe the data in a general way:
• Sets
• Parameters
These components enable the model to specify variables, constraints, and summations that are indexed over sets. The
combination of sets and indices is essential to building scalable and maintainable models for more complex applications.
We will begin this analysis by examining the production planning data sets to identify the underlying problem structure.
Then we will reformulate the mathematical model in a more general way that is valid for any data scenario. Finally
we show how the same formulation carries over naturally into AMPL, providing a clear, data-driven formulation of the
production planning application.
We begin by revisiting the data tables and mathematical model developed for the basic production planning problem
presented in the previous notebook. The original data values were given as follows:
Product Material required Labor A required Labor B required Market Demand Price
U 10 g 1 hr 2 hr ≤ 40 units $270
V 9g 1 hr 1 hr unlimited $210
Two distinct sets of objects are evident from these tables. The first is the set of products, comprising 𝑈 and 𝑉 . The
second is the set of resources used to produce those products, comprising raw materials and two labor types, which we
have abbreviated as 𝑀 , 𝐴, and 𝐵.
Having identified these sets, the data for this application be factored into three simple tables. The first two tables list
attributes of the products and attributes of the resources. The third table summarizes the processes used to create the
products from the resources, which requires providing a value for each combination of product and resource:
Table: Products
Table: Resources
Table: Processes
Product M A B
U 10 g 1 hr 2 hr
V 9g 1 hr 1 hr
How does a Python-based AMPL application work with this data? We can think of the data as being handled in three
steps:
1. Import the data into Python, in whatever form is convenient for the application.
2. Convert the data to the forms required by the optimization model.
3. Send the data to AMPL.
For our example, we implement step 1 by use of Python nested dictionaries that closely resemble the above three tables:
• In the products data structure, the product abbreviations serve as keys for outermost dictionary, and the product-
related attribute names (demand and price) as keys for the inner dictionaries.
• In the resources data structure, the resource abbreviations serve as keys for outermost dictionary, and the
resource-related attribute names (available and cost) as keys for the inner dictionaries.
• In the processes data structure, there is a value corresponding to each combination of a product and a resource;
the product abbreviations serve as keys for outermost dictionary, and resource abbreviations as keys for the inner
dictionaries.
Where demand or availability is “unlimited”, we use an expression that Python interprets as an infinite value.
You will see a variety of data representation in this book, chosen in each case to be most efficient and convenient for the
application at hand. Some will use Python packages, particularly numpy and pandas, that are designed for large-scale data
handling.
Inf = float("inf")
products = {
"U": {"demand": 40, "price": 270},
"V": {"demand": Inf, "price": 210},
}
resources = {
"M": {"available": Inf, "cost": 10},
"A": {"available": 80, "cost": 50},
"B": {"available": 100, "cost": 40},
}
processes = {
"U": {"M": 10, "A": 2, "B": 1},
"V": {"M": 9, "A": 1, "B": 1},
}
Once the problem data is rearranged into tables like this, the structure of the production planning problem becomes
evident. Along with the two sets, we have a variety of symbolic parameters that specify the model’s costs, limits, and
processes in a general way.Compared to the previous notebook, these abstractions allow us to create mathematical models
that can adapt and scale with the supplied data.
Let 𝑃 and 𝑅 be the set of products and resources, respectively, and let 𝑝 and 𝑟 be representative elements of those sets.
We use indexed decision variables 𝑥𝑟 to denote the amount of resource 𝑟 that is consumed in production, and 𝑦𝑝 to denote
the amount of product 𝑝 produced.
The model specifies lower and upper bounds on the values of the variables. We represent these as
0 ≤ 𝑥𝑟 ≤ 𝑏𝑟𝑥 ∀𝑟 ∈ 𝑅
0 ≤ 𝑦𝑝 ≤ 𝑏𝑝𝑦 ∀𝑝 ∈ 𝑃
where the upper bounds, 𝑏𝑟𝑥 and 𝑏𝑝𝑦 , are data taken from the tables of attributes.
The objective is given as before,
but now the expressions for revenue and cost are expressed more generally as sums over the product and resource sets,
revenue = ∑ 𝑐𝑝𝑦 𝑦𝑝
𝑝∈𝑃
cost = ∑ 𝑐𝑟𝑥 𝑥𝑟
𝑟∈𝑅
where parameters 𝑐𝑝𝑦 and 𝑐𝑟𝑥 represent the selling prices for products and the costs for resources, respectively. The limits
on available resources can be written as
∑ 𝑎𝑟𝑝 𝑦𝑝 ≤ 𝑥𝑟 ∀𝑟 ∈ 𝑅
𝑝∈𝑃
where 𝑎𝑟𝑝 is the amount of resource 𝑟 needed to make 1 unit of product 𝑝. Putting these pieces together, we have the
following symbolic model for the production planning problem.
subject to ∑ 𝑎𝑟𝑝 𝑦𝑝 ≤ 𝑥𝑟 ∀𝑟 ∈ 𝑅
𝑝∈𝑃
0 ≤ 𝑥𝑟 ≤ 𝑏𝑟𝑥 ∀𝑟 ∈ 𝑅
0 ≤ 𝑦𝑝 ≤ 𝑏𝑝𝑦 ∀𝑝 ∈ 𝑃
(1.3)
When formulated this way, the model can be applied to any problem with the same structure, regardless of the number
of products or resources. This flexibility is possible due to the use of sets to describe the products and resources for a
particular problem instance, indices like 𝑝 and 𝑟 to refer to elements of those sets, and data tables that hold the relevant
parameter values.
Generalizing mathematical models in this fashion is a feature of all large-scale optimization applications. Next we will
see how this type of generalization carries over naturally into formulating and solving the model in AMPL.
As before, we begin the construction of an AMPL model by importing the needed components into the AMPL environ-
ment.
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Next we use AMPL set statements to define the product and resource sets. Notice that at this point, we are only telling
AMPL about the two sets will be used in the model. The members of these sets will be sent from Python to AMPL later,
as part of the problem data.
In mathematical formulations, it is customary to keep the names of all components short. But when writing the model
in AMPL, we are free to use longer, more meaningful names that make the model statements easier to read. Thus, for
example, here we use PRODUCTS and RESOURCES as the AMPL names of the sets that are are called 𝑃 and 𝑅 in the
mathematical model.
%%ampl_eval
# define sets
set PRODUCTS;
set RESOURCES;
The next step is to introduce parameters that will be used as data in the objective function and the constraints.
A statement that defines and AMPL parameter begin with the param keyword and a unique name. Then between braces
{ and } it specifies the index sets for the parameter. For example:
• param demand {PRODUCTS} >= 0; states that there is a “product demanded” value for each member of
the set PRODUCTS.
• param need {RESOURCES,PRODUCTS} >= 0; states that there is a “resource needed” value for each
combination of a resource and a product.
At the end of each param statement, we specify that the values for the parameter must be nonnegative or positive, as
appropriate. These specifications will be used to later to check that the actual data values are appropriate for the problem.
There are 5 different param statements in all, corresponding to the 5 different kinds of data in tables, and the 5 different
symbolic parameters 𝑏𝑝𝑦 , 𝑐𝑝𝑦 , 𝑏𝑟𝑥 , 𝑐𝑟𝑥 , and 𝑎𝑟𝑝 in the mathematical model.
%%ampl_eval
# define parameters
AMPL defines the decision variables in much the same way as the parameters, but with var as the keyword starting the
statement. We name the variables Use for resource use, and Sell for product sales.
To express the bounds on the variables in the same way as the mathematical formulation, a more general form of the
AMPL statement is needed. In the case of the Use variables, for example:
• The indexing expression is written {r in RESOURCES} to say that there is a variable for each member of the
resource set, and also to associate the index r with members of the set for purpose of this statement. This is the
AMPL equivalent of ∀𝑟 ∈ 𝑅 in the mathematical statement.
• The upper bound is written <= available[r] to say that for each member r of the resource set, the variable’s
upper bound is given by the corresponding value from the availability table. This is the AMPL equivalent of ≤ 𝑏𝑟𝑥
in the mathematical statement.
An expression in brackets [...] is called an AMPL subscript because it plays the same role as a mathematical subscript
like 𝑟 in ≤ 𝑏𝑟𝑥 . Anywhere that the model refers to particular values of an indexed parameter or variables, you will see
subscript expressions. For example,
• need[r,p] will be the amount of resource r needed to make one unit of product p.
• Use[r] will be the total amount of resource r used.
%%ampl_eval
# define variables
Just as in the previous notebook, the AMPL statement for the objective function begins with maximize Profit. But
now, as in the mathematical formulation, AMPL uses general summation expressions:
• sum {p in PRODUCTS} price[p] * Sell[p] is the sum, over all products, of the price per unit time
the number sold. It corresponds to ∑𝑝∈𝑃 𝑐𝑝𝑦 𝑦𝑝 in the mathematical formulation.
• sum {r in RESOURCES} cost[r] * Use[r] is the sum, over all resources, of the cost per unit time
the amount used. It corresponds to ∑𝑟∈𝑅 𝑐𝑟𝑥 𝑥𝑟 in the mathematical formulation.
The full expression for the objective function is simply the first of these expressions minus the second one.
%%ampl_eval
# define objective function
maximize Profit:
sum {p in PRODUCTS} price[p] * Sell[p] -
sum {r in RESOURCES} cost[r] * Use[r];
The previous AMPL model had 3 constraints, each defined by a subject to statement. But the data-driven mathe-
matical formulation recognizes that there is only one different kind of constraint — resources needed must be less than
or equal to resources used — repeated 3 times, once for each resource. The AMPL version combines expressions that
have already appeared in earlier parts of the model:
• subject to ResourceLimit {r in RESOURCES} says that the model will have one constraint corre-
sponding to each member r of the resource set.
• sum {p in PRODUCTS} need[r,p] * Sell[p] <= Use[r] says that the total of resource r needed,
summed over all produces sold, must be <= the total of resource r used. This corresponds to ∑𝑝∈𝑃 𝑎𝑟𝑝 𝑦𝑝 ≤ 𝑥𝑟
in the mathematical formulation.
%%ampl_eval
# create indexed constraint
Now that the AMPL model is defined, we can carry out step 2 of data handling, which is to convert the data to the forms
that the model requires:
• For the two sets, Python lists of the set members.
• For the two parameters indexed over products, Python dictionaries whose keys are the product names.
• For the two parameters indexed over resources, Python dictionaries whose keys are the resource names.
• For the parameter indexed over resource-product pairs, a Python dictionary whose keys are tuples consisting of a
a resource and a product.
Using Python’s powerful expression forms, all of these lists and dictionaries are readily extracted from the nested dictio-
naries that our application set up in step 1. To avoid having too many different names, we assign each list and dictionary
to a Python program variable that has the same name as the corresponding AMPL set or parameter:
# set data
PRODUCTS = products.keys()
RESOURCES = resources.keys()
# product data
demand = {k: v["demand"] for k, v in products.items()}
price = {k: v["price"] for k, v in products.items()}
# resource data
available = {k: v["available"] for k, v in resources.items()}
cost = {k: v["cost"] for k, v in resources.items()}
print(PRODUCTS, RESOURCES)
print(demand, price)
print(available, cost)
print(need)
Now the Python data can be sent to AMPL, and AMPL can invoke a solver. For this simple model, we can make the
Python data correspond exactly to the AMPL data, and thus the statements for sending the data to AMPL are particularly
easy to write.
The statements for selecting a solver and for initiating the solver process are the same as we used with the basic production
planning example. When the solver is finished, it displays a few lines of output to confirm that a solution has been found.
It remains to retrieve the solution from AMPL, after which Python’s extensive features and ecosystem can be used to
present the results in any way desired. For this first example we use one of the simplest Python features, the print
statement.
An AMPL entity is referenced in Python code via its name in the AMPL model. For example, the objective function
Profit is ampl.obj['Profit'], and the collection of Sell variables is ampl.var['Sell'].
For an entity that is not indexed, the value() method returns the associated value. Thus the first print statement refers
to ampl.obj['Profit'].value().
For an indexed entity, we use the to_dict() method to return the values in a Python dictionary, with the set members
as keys. Then a for loop can use the items() method to iterate over the dictionary and print a line for each member.
print("\nProduction Report")
for product, Sell in ampl.var["Sell"].to_dict().items():
print(f" {product} produced = {Sell}")
print("\nResource Report")
for resource, Use in ampl.var["Use"].to_dict().items():
print(f" {resource} consumed = {Use}")
Profit = 2400.0
Production Report
U produced = 0
V produced = 80
Resource Report
A consumed = 80
B consumed = 80
M consumed = 720
Some readers of these notebooks may be more experienced Python developers who wish to apply AMPL in more special-
ized, data driven applications. The following cell shows how the AMPL class can be extended to create specialized model
classes. Here we create a subclass called ProductionModel that accepts a particular representation of the problem
data to produce a production model object. The production model object inherits all of the methods associated with any
AMPL model, such as .display() and .solve(), but can be extended with additional methods.
%%writefile production_planning.mod
# define sets
set PRODUCTS;
set RESOURCES;
# define parameters
param demand {PRODUCTS} >= 0;
param price {PRODUCTS} > 0;
param available {RESOURCES} >= 0;
param cost {RESOURCES} > 0;
param need {RESOURCES,PRODUCTS} >= 0;
# define variables
var Use {r in RESOURCES} >= 0, <= available[r];
var Sell {p in PRODUCTS} >= 0, <= demand[p];
Overwriting production_planning.mod
import pandas as pd
class ProductionModel(AMPL):
"""
A class representing a production model using AMPL.
"""
def load_data(self):
"""
Prepare the data and pass the information to AMPL.
"""
# convert the data dictionaries into pandas data frames
products = pd.DataFrame(self.products).T
resources = pd.DataFrame(self.resources).T
processes = pd.DataFrame(self.processes).T
"""
self.read("production_planning.mod")
self.load_data()
self.option["solver"] = solver
super(ProductionModel, self).solve()
self.solved = True
def report(self):
"""
Solve, if necessary, then report the model solution.
"""
if not self.solved:
self.solve()
print(f"Profit = {self.obj['Profit'].value()}")
print("\nProduction Report")
Sell = self.var["Sell"].to_pandas()
Sell.rename(columns={Sell.columns[0]: "produced"}, inplace=True)
Sell.index.rename("PRODUCTS", inplace=True)
display(Sell)
print("\nResource Report")
Use = self.var["Use"].to_pandas()
Use.rename(columns={Use.columns[0]: "consumed"}, inplace=True)
Use.index.rename("RESOURCES", inplace=True)
display(Use)
demand price
U 40.0 270.0
V inf 210.0
available cost
M inf 10.0
A 80.0 50.0
B 100.0 40.0
M A B
U 10 2 1
V 9 1 1
Production Report
produced
PRODUCTS
U 0
V 80
Resource Report
consumed
RESOURCES
A 80
B 80
M 720
TWO
2. LINEAR OPTIMIZATION
The simplest and most scalable class of optimization problems is the one where the objective function and the constraints
are formulated using the simplest possible type of functions – linear functions. A linear optimization (LO) is an opti-
mization problem of the form
min 𝑐⊤ 𝑥
s.t. 𝐴𝑥 ≤ 𝑏
𝑥 ≥ 0,
where the 𝑛 (decision) variables are grouped in a vector 𝑥 ∈ ℝ𝑛 , 𝑐 ∈ ℝ𝑛 are the objective coefficients, and the 𝑚 linear
constraints are described by the matrix 𝐴 ∈ ℝ𝑚×𝑛 and the vector 𝑏 ∈ ℝ𝑚 .
Linear problems can (i) be maximization problems, (ii) involve equality constraints and constraints of the form ≥, and
(iii) have unbounded or non-positive decision variables 𝑥𝑖 ’s. In fact, any LO problem with such features can be easily
converted to the “canonical” LO form above by adding/removing variables and/or multiplying specific inequalities by −1.
In this chapter, there is a number of examples with companion AMPL implementation that explore various modeling and
implementation aspects of LOs:
• A first LO example, modelling the microchip production problem of company BIM
• Least Absolute Deviation (LAD) Regression
• Mean Absolute Deviation (MAD) portfolio optimization
• Wine quality prediction problem using L1 regression
• A variant of BIM problem: maximizing the lowest possible profit
• Two variants of the BIM problem using fractional objective or additional fixed costs
• The BIM production problem using demand forecasts
• Extra material: Multi-product facility production
Go to the next chapter about mixed-integer linear optimization.
SOLVER = "highs"
23
Hands-On Mathematical Optimization with AMPL in Python
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
The simplest and most scalable class of optimization problems is the one where the objective function and the constraints
are formulated using the simplest possible type of functions - linear functions. A linear optimization (LO) is a problem
of the form
min 𝑐⊤ 𝑥
s.t. 𝐴𝑥 ≤ 𝑏
𝑥 ≥ 0,
where the 𝑛 (decision) variables are grouped in a vector 𝑥 ∈ ℝ𝑛 , 𝑐 ∈ ℝ𝑛 are the objective coefficients, and the 𝑚 linear
constraints are described by the matrix 𝐴 ∈ ℝ𝑚×𝑛 and the vector 𝑏 ∈ ℝ𝑚 .
Of course, linear problems could also (i) be maximization problems, (ii) involve equality constraints and constraints of
the form ≥, and (iii) have unbounded or non-positive decision variables 𝑥𝑖 ’s. In fact, any LP problem with such features
can be easily converted to the ‘canonical’ LP form by adding/removing variables and/or multiplying specific inequalities
by −1.
The company BIM (Best International Machines) produces two types of microchips, logic chips (1 g silicon, 1 g plastic, 4
g copper) and memory chips (1 g germanium, 1 g plastic, 2 g copper). Each of the logic chips can be sold for a 12 € profit,
and each of the memory chips for a 9 € profit. The current stock of raw materials is as follows: 1000 g silicon, 1500 g
germanium, 1750 g plastic, 4800 g of copper. How many microchips of each type should be produced to maximize the
profit while respecting the raw material stock availability?
Let 𝑥1 denote the number of logic chips and 𝑥2 that of memory chips. This decision can be reformulated as an optimization
problem of the following form:
The problem has 𝑛 = 2 decision variables and 𝑚 = 4 constraints. Using the standard notation introduced above, denote
𝑥
the vector of decision variables by 𝑥 = ( 1 ) and define the problem coefficients as
𝑥2
1 0 1000
12 ⎡0 1⎤ ⎛ 1500⎞
𝑐 = ( ), 𝐴=⎢ ⎥, and 𝑏=⎜
⎜
⎜1750⎟
⎟.
⎟
9 ⎢1 1⎥
⎣4 2⎦ ⎝ 4800 ⎠
This model can be implemented and solved in AMPL as follows.
%%ampl_eval
var x1 >= 0;
var x2 >= 0;
ampl.option["solver"] = SOLVER
ampl.solve()
One can construct bounds for the value of objective function by multiplying the constraints by non-negative numbers and
adding them to each other so that the left-hand side looks like the objective function, while the right-hand side is the
corresponding bound.
Let 𝜆1 , 𝜆2 , 𝜆3 , 𝜆4 be non-negative numbers. If we multiply each of these variables by one of the four constraints of the
original problem and sum all of them side by side to obtain the inequality
(𝜆1 + 𝜆3 + 4𝜆4 )𝑥1 + (𝜆2 + 𝜆3 + 2𝜆4 )𝑥2 ≤ 1000𝜆1 + 1500𝜆2 + 1750𝜆3 + 4800𝜆4 .
𝜆1 + 𝜆3 + 4𝜆4 ≥ 12,
𝜆2 + 𝜆3 + 2𝜆4 ≥ 9,
12𝑥1 + 9𝑥2 ≤ (𝜆1 + 𝜆3 + 4𝜆4 )𝑥1 + (𝜆2 + 𝜆3 + 2𝜆4 )𝑥2 ≤ 1000𝜆1 + 1500𝜆2 + 1750𝜆3 + 4800𝜆4 ,
where the first inequality follows from the fact that 𝑥1 , 𝑥2 ≥ 0, and the most right-hand expression becomes an upper
bound on the optimal value of the objective.
If we seek 𝜆1 , 𝜆2 , 𝜆3 , 𝜆4 ≥ 0 such that the upper bound on the RHS is as tight as possible, that means that we need to
minimize the expression 1000𝜆1 + 1500𝜆2 + 1750𝜆3 + 4800𝜆4 . This can be formulated as the following LP, which we
name the dual problem:
It is easy to solve and find the optimal solution (𝜆1 , 𝜆2 , 𝜆3 , 𝜆4 ) = (0, 0, 6, 1.5), for which the objective functions takes
the value 17700. Such a value is (the tightest) upper bound for the original problem. Here, we present the AMPL code
for this example.
%%writefile bim_dual.mod
var y1 >= 0;
var y2 >= 0;
var y3 >= 0;
var y4 >= 0;
Writing bim_dual.mod
ampl = AMPL()
ampl.read("bim_dual.mod")
ampl.option["solver"] = SOLVER
ampl.solve()
print(
f'y = ({ampl.var["y1"].value():.1f}, {ampl.var["y2"].value():.1f}, {ampl.var["y3
↪"].value():.1f}, {ampl.var["y4"].value():.1f})'
)
print(f'optimal value = {ampl.obj["obj"].value():.2f}')
Note that since the original LP is feasible and bounded, strong duality holds and the optimal value of the primal problem
coincides with the optimal value of the dual problem.
Linear regression is a supervised machine learning technique that produces a linear model predicting values of a dependent
variable from known values of one or more independent variables. Linear regression has a long history dating back to at
least the 19th century and is a mainstay of modern data analysis.
This notebook demonstrates a technique for linear regression based on LP that use the Least Absolute Deviation (LAD)
as the metric to quantify the goodness of the model prediction. The sum of absolute values of errors is the 𝐿1 norm which
is known to have favorable robustness characteristics in practical use. We follow closely this paper.
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Note: you may need to restart the kernel to use updated packages.
The Python scikit learn library for machine learning provides a full-featured collection of tools for regression. The
following cell uses make_regression from scikit learn to generate a synthetic data set for use in subsequent cells.
The data consists of a numpy array y containing n_samples of one dependent variable 𝑦, and an array X containing
n_samples observations of n_features independent explanatory variables.
n_features = 1
n_samples = 1000
noise = 30
Before going further, it is generally useful to prepare an initial visualization of the data. The following cell presents a
scatter plot of 𝑦 versus 𝑥 for the special case of one explanatory variable, and a histogram of the difference between 𝑦
and the mean value 𝑦.̄ This histogram will provide a reference against which to compare the residual error in 𝑦 after
regression.
if n_features == 1:
plt.scatter(X, y, alpha=0.3)
plt.xlabel("X")
plt.ylabel("y")
plt.grid(True)
plt.figure()
plt.hist(y - np.mean(y), bins=int(np.sqrt(len(y))))
plt.title("histogram y - mean(y)")
plt.ylabel("counts")
2.2.3 Model
Suppose we have a finite dataset consisting of 𝑛 points {(𝑋 (𝑖) , 𝑦(𝑖) )}𝑖=1,…,𝑛 with 𝑋 (𝑖) ∈ ℝ𝑘 and 𝑦(𝑖) ∈ ℝ. A linear
regression model assumes the relationship between the vector of 𝑘 regressors 𝑋 and the dependent variable 𝑦 is linear.
This relationship is modeled through an error or deviation term 𝑒𝑖 , which quantifies how much each of the data points
diverge from the model prediction and is defined as follows:
𝑘
(𝑖)
𝑒𝑖 ∶= 𝑦(𝑖) − 𝑚⊤ 𝑋 (𝑖) − 𝑏 = 𝑦(𝑖) − ∑ 𝑋𝑗 𝑚𝑗 − 𝑏, (2.7)
𝑗=1
In general, the appearance of an absolute value term indicates the problem is nonlinear and, worse, that the objective
function is not differentiable when any 𝑒𝑖 = 0. However, for this case where the objective is to minimize a sum of
absolute errors, one can reformulate the decision variables to transform this into a linear problem. More specifically,
+
introducing for every term 𝑒𝑖 two new variables 𝑒−
𝑖 , 𝑒𝑖 ≥ 0, we can rewrite the model as
𝑛
min ∑(𝑒+ −
𝑖 + 𝑒𝑖 ) (2.10)
𝑖=1
s.t. 𝑒+ −
𝑖 − 𝑒𝑖 = 𝑦(𝑖) − 𝑚⊤ 𝑋 (𝑖) − 𝑏 ∀ 𝑖 = 1,(2.11)
…,𝑛
𝑒+ −
𝑖 , 𝑒𝑖 ≥ 0 ∀ 𝑖 = 1,(2.12)
…,𝑛
%%writefile lad_regression.mod
# indexing sets
set I;
set J;
# parameters
param y{I};
param X{I, J};
# variables
var ep{I} >= 0;
var em{I} >= 0;
var m{J} >= 0;
(continues on next page)
# constraints
s.t. residuals {i in I}:
ep[i] - em[i] == y[i] - sum{j in J}(X[i, j] * m[j]) - b;
# objective
minimize sum_of_abs_errors: sum{i in I}(ep[i] + em[i]);
n, k = X.shape
ampl.param["y"] = y
ampl.param["X"] = X
ampl.option["solver"] = SOLVER
ampl.solve()
return ampl
m = lad_regression(X, y)
m.display("m")
m.display("b")
m_m = list(m.var["m"].to_dict().values())
m_b = m.var["b"].value()
if n_features == 1:
plt.scatter(X, y, alpha=0.3, label="data")
plt.plot(X, y_fit, "r", label="y_fit")
plt.xlabel("X")
plt.ylabel("y")
plt.grid(True)
plt.legend()
plt.figure()
plt.hist(y - np.mean(y), bins=int(np.sqrt(len(y))), alpha=0.5, label="y - mean(y)")
plt.hist(y - y_fit, bins=int(np.sqrt(len(y))), color="r", alpha=0.8, label="y - y_fit
↪")
plt.title("histogram of residuals")
plt.ylabel("counts")
plt.legend()
Portfolio optimization and modern portfolio theory has a long and important history in finance and investment. The
principal idea is to find a blend of investments in financial securities that achieves an optimal trade-off between financial
risk and return. The introduction of modern portfolio theory is generally attributed to the 1952 doctoral thesis of Harry
Markowitz who subsequently was award a share of the 1990 Nobel Memorial Prize in Economics for his fundamental
contributions to this field. The well-known “Markowitz Model” models measure risk using covariance of the portfolio
with respect to constituent assets, then solves a minimum variance problem by quadratic optimization problem subject to
constraints to allocate of wealth among assets.
In a remarkable 1991 paper, Konno and Yamazaki proposed a different approach using the mean absolute deviation
(MAD) in portfolio return as a measure of financial risk. The proposed implementation directly incorporates historical
price data into a large scale linear optimization problem.
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).
The following cells download daily trading data for a group of the stocks from Yahoo Finance. The trading data is stored in
a designated sub-directory (default ./data/stocks/) as individual .csv files for each stock. Subsequent notebooks
can read and consolidate the stock price data.
Run the cells in the notebook once to create data sets for use by other notebook, or to refresh a previously stored set of
data. The function will overwrite any existing data sets.
Installing yfinance
The notebook uses the yfinance module to read data from Yahoo Finance. Web interfaces for financial services are
notoriously fickle and subject to change, and a particular issue with Google Colaboratory.
Stocks to download
Edit the following cell to download a list of stock symbols from Yahoo Finance, n_years to change the historical period,
or change the data directory. The first step in this analysis is to load and consolidate the asset price information into a
single DataFrame named assets. The consolidated price information consists of the adjusted closing price reported by
Yahoo Finance which includes adjustments for stock splits and dividend distributions.
# number of years
n_years = 3.0
# historical period
end_date = datetime.datetime.today().date()
start_date = end_date - datetime.timedelta(round(n_years * 365))
assets.fillna(method="bfill", inplace=True)
assets.fillna(method="ffill", inplace=True)
[*********************100%%**********************] 30 of 30 completed
<matplotlib.legend.Legend at 0x7e07143165c0>
2.3.2 Daily return and Mean Absolute Deviation of historical asset prices
The historical prices are scaled to a value to have unit value at the start of the historical period. Scaling facilitates plotting
and subsequent calculations while preserving arithmetic and logarithmic returns needed for analysis.
<matplotlib.legend.Legend at 0x7e0714da3f10>
The scaled price of asset 𝑗 on trading day 𝑡 is designated 𝑆𝑗,𝑖 . The daily return is computed as
𝑆𝑗,𝑡 − 𝑆𝑗,𝑡−1
𝑟𝑗,𝑡 =
𝑆𝑗,𝑡−1
1 𝑇
𝑟𝑗̄ = ∑𝑟
𝑇 𝑡=1 𝑗,𝑡
The following cells compute and display the daily returns for all assets and displays as time series and histograms.
# daily returns
daily_returns = assets.diff()[1:] / assets.shift(1)[1:]
plt.tight_layout()
# distributions of returns
plt.tight_layout()
1 𝑇
MAD𝑗 = ∑ |𝑟 − 𝑟𝑗̄ |
𝑇 𝑡=1 𝑗,𝑡
where 𝑇 is the period under consideration. The mean daily return and the mean absolute deviation in daily return are
computed and plotted in the following cell. The side by side comparison provides a comparison of return vs volatility for
individual assets.
Return on a portfolio
Given a portfolio with value 𝑊𝑡 at time 𝑡, return on the portfolio at 𝑡𝑡+𝛿𝑡 is defined as
𝑊𝑡+𝛿𝑡 − 𝑊𝑡
𝑟𝑡+𝛿𝑡 =
𝑊𝑡
For the period from [𝑡, 𝑡 + 𝛿𝑡) we assume there are 𝑛𝑗,𝑡 shares of asset 𝑗 with a starting value of 𝑆𝑗,𝑡 per share. The initial
and final values of the portfolio are then
𝐽
𝑊𝑡 = ∑ 𝑛𝑗,𝑡 𝑆𝑗,𝑡
𝑗=1
𝐽
𝑊𝑡+𝛿𝑡 = ∑ 𝑛𝑗,𝑡 𝑆𝑗,𝑡+𝛿𝑡
𝑗=1
𝑊𝑡+𝛿𝑡 − 𝑊𝑡
𝑟𝑡+𝛿𝑡 =
𝑊𝑡
𝐽 𝐽
∑𝑗=1 𝑛𝑗,𝑡 𝑆𝑗,𝑡+𝛿𝑡 − ∑𝑗=1 𝑛𝑗,𝑡 𝑆𝑗,𝑡
=
𝑊𝑡
𝐽
∑𝑗=1 𝑛𝑗,𝑡 𝑆𝑗,𝑡 𝑟𝑗,𝑡+𝛿𝑡
=
𝑊𝑡
𝐽
𝑛𝑗,𝑡 𝑆𝑗,𝑡
=∑ 𝑟𝑗,𝑡+𝛿𝑡
𝑗=1
𝑊𝑡
The return on a portfolio of 𝐽 assets over a period of 𝑇 intervals with weights 𝑤𝑗 for asset 𝑗 is given by
𝐽
1 𝑇
MAD = ∑ ∣ ∑ 𝑤𝑗 (𝑟𝑡,𝑗 − 𝑟𝑗̄ )∣,
𝑇 𝑡=1 𝑗=1
where 𝑟𝑡,𝑗 is the return on asset 𝑗 at time 𝑡, 𝑟𝑗̄ is the mean return for asset 𝑗, and 𝑤𝑗 is the fraction of the total portfolio
that is invested in asset 𝑗. Note that due to the use of absolute values, MAD for the portfolio is not the weighted sum of
MAD𝑗 for individual assets
The portfolio optimization problem is to find an allocation of investments weights 𝑤𝑗 to minimize the portfolio measure
of risk subject to constraints on required return and any other constraints an investor wishes to impose. Assume that we
can make investment decisions at every trading day 𝑡 over a fixed time horizon ranging from 𝑡 = 1, … , 𝑇 and that there
is a set of 𝐽 assets in which we can choose to invest.”
𝐽
1 𝑇
MAD = min ∑ ∣ ∑ 𝑤𝑗 (𝑟𝑡,𝑗 − 𝑟𝑗̄ )∣
𝑇 𝑡=1 𝑗=1
𝐽
s.t. ∑ 𝑤𝑗 𝑟𝑗̄ ≥ 𝑅
𝑗=1
𝐽
∑ 𝑤𝑗 = 1
𝑗=1
𝑤𝑗 ≥ 0 ∀𝑗 ∈ 𝐽
𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 ∀ 𝑗 ∈ 𝐽.
where 𝑅 is the minimum required portfolio return. The lower bound 𝑤𝑗 ≥ 0 is a “no short sales” constraint. The upper
bound 𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 enforces a required level of diversification in the portfolio.
Defining two sets of auxiliary variables 𝑢𝑡 ≥ 0 and 𝑣𝑡 ≥ 0 for every 𝑡 = 1, … , 𝑇 , leads to a reformulation of the problem
as a linear optimization:
1 𝑇
MAD = min ∑(𝑢 + 𝑣𝑡 )
𝑇 𝑡=1 𝑡
𝐽
s.t. 𝑢𝑡 − 𝑣𝑡 = ∑ 𝑤𝑗 (𝑟𝑡,𝑗 − 𝑟𝑗̄ ) ∀𝑡 ∈ 1, … , 𝑇
𝑗=1
𝐽
∑ 𝑤𝑗 𝑟𝑗̄ ≥ 𝑅
𝑗=1
𝐽
∑ 𝑤𝑗 = 1
𝑗=1
𝑤𝑗 ≥ 0 ∀𝑗 ∈ 𝐽
𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 ∀𝑗 ∈ 𝐽
𝑢𝑡 , 𝑣𝑡 ≥ 0 𝑡 = 1, … , 𝑇 .
%%writefile mad_portfolio.mod
param R default 0;
param w_lb default 0;
param w_ub default 1;
set ASSETS;
set TIME;
var w{ASSETS};
var u{TIME} >= 0;
var v{TIME} >= 0;
Writing mad_portfolio.mod
def mad_portfolio(assets):
daily_returns = assets.diff()[1:] / assets.shift(1)[1:]
mean_return = daily_returns.mean()
daily_returns["Date"] = daily_returns.index.format()
daily_returns.set_index("Date", inplace=True)
ampl = AMPL()
ampl.read("mad_portfolio.mod")
ampl.set["ASSETS"] = list(assets.columns)
ampl.set["TIME"] = daily_returns.index.values
ampl.param["daily_returns"] = daily_returns
ampl.param["mean_return"] = mean_return
return ampl
)
print(f"Required portfolio daily return {m.param['R'].value():0.5f}")
print(f"Portfolio mean daily return {mean_portfolio_return:0.5f}")
print(f"Portfolio mean absolute deviation {m.obj['MAD'].value():0.5f}")
mean_absolute_deviation.plot(
kind="barh", ax=ax[2], title="asset mean absolute deviation"
)
ax[2].axvline(m.obj["MAD"].value(), ls="--", color="r")
ax[2].legend(["portfolio MAD"])
ax[2].invert_yaxis()
(continues on next page)
m = mad_portfolio(assets)
m.param["w_lb"] = 0
m.param["w_ub"] = 0.2
m.param["R"] = 0.001
m.option["solver"] = SOLVER
m.solve()
mad_visualization(assets, m)
The portfolio optimization problem has been formulated as the minimization of a risk measure, MAD, subject to a lower
bound 𝑅 on mean portfolio return. Increasing the required return for the portfolio therefore comes at the cost of tolerating
a higher level of risk. Finding the optimal trade off between risk and return is a central aspect of any investment strategy.
The following cell creates a plot of the risk/return trade off by solving the MAD portfolio optimization problem for in-
creasing values of required return 𝑅. This should be compared to the similar construction commonly used in presentations
of the portfolio optimization problem due to Markowitz.
m = mad_portfolio(assets)
for R in np.linspace(0, mean_return.max(), 20):
m.param["R"] = R
m.option["solver"] = SOLVER
m.solve()
mad_portfolio_weights = m.var["w"].to_pandas()
portfolio_returns = daily_returns.dot(mad_portfolio_weights)
portfolio_mean_return = portfolio_returns.mean()
portfolio_mean_absolute_deviation = abs(
portfolio_returns - portfolio_mean_return
).mean()
ax.plot(portfolio_mean_absolute_deviation, portfolio_mean_return, "ro", ms=10)
The option of a holding a risk-free asset as a component of investment can substantially reduce financial risk. The risk-
𝐽
free asset is designated as 𝑗 = 0 with a fixed return 𝑟0̄ . The fraction invested in asset 𝑗 = 0 will be 𝑤0 = 1 − ∑𝑗=1 𝑤𝑗 .
The optimization model becomes
𝐽
1 𝑇
MAD = min ∑ ∣ ∑ 𝑤𝑗 (𝑟𝑡,𝑗 − 𝑟𝑗̄ )∣
𝑇 𝑡=1 𝑗=1
𝐽
s.t. ∑ 𝑤𝑗 (𝑟𝑗̄ − 𝑟0̄ ) ≥ 𝑅 − 𝑟0̄
𝑗=1
𝐽
∑ 𝑤𝑗 ≤ 1
𝑗=1
𝑤𝑗 ≥ 0 ∀𝑗 ∈ 1, … , 𝐽
𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 ∀𝑗 ∈ 1, … , 𝐽
where 𝑅 is the minimum required portfolio return. The lower bound 𝑤𝑗 ≥ 0 is a “no short sales” constraint. The upper
bound 𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 enforces a required level of diversification in the portfolio.
Defining two sets of auxiliary variables 𝑢𝑡 ≥ 0 and 𝑣𝑡 ≥ 0 for every 𝑡 = 1, … , 𝑇 , leads to a reformulation of the problem
as an LO:
1 𝑇
MAD = min ∑(𝑢 + 𝑣𝑡 )
𝑇 𝑡=1 𝑡
𝐽
s.t. 𝑢𝑡 − 𝑣𝑡 = ∑ 𝑤𝑗 (𝑟𝑡,𝑗 − 𝑟𝑗̄ ) ∀𝑡 ∈ 1, … , 𝑇
𝑗=1
𝐽
∑ 𝑤𝑗 (𝑟𝑗̄ − 𝑟0̄ ) ≥ 𝑅 − 𝑟0̄
𝑗=1
𝐽
∑ 𝑤𝑗 ≤ 1
𝑗=1
𝑤𝑗 ≥ 0 ∀𝑗 ∈ 1, … , 𝐽
𝑤𝑗 ≤ 𝑤𝑗𝑢𝑏 ∀𝑗 ∈ 1, … , 𝐽
𝑢𝑡 , 𝑣𝑡 ≥ 0 ∀𝑡 ∈ 1, … , 𝑇 .
%%writefile mad_portfolio_cash.mod
param R default 0;
param rf default 0;
param w_lb default 0;
param w_ub default 1;
set ASSETS;
set TIME;
var w{ASSETS};
var u{TIME} >= 0;
var v{TIME} >= 0;
Writing mad_portfolio_cash.mod
def mad_portfolio_cash(assets):
daily_returns = assets.diff()[1:] / assets.shift(1)[1:]
(continues on next page)
daily_returns["Date"] = daily_returns.index.format()
daily_returns.set_index("Date", inplace=True)
ampl = AMPL()
ampl.read("mad_portfolio_cash.mod")
ampl.set["ASSETS"] = list(assets.columns)
ampl.set["TIME"] = daily_returns.index.values
ampl.param["daily_returns"] = daily_returns
ampl.param["mean_return"] = mean_return
return ampl
m = mad_portfolio_cash(assets)
m.param["w_lb"] = 0
m.param["w_ub"] = 0.2
m.param["R"] = 0.001
m.option["solver"] = SOLVER
m.solve()
mad_visualization(assets, m)
As above, it is instructive to plot the MAD risk versus required return 𝑅. The result is similar, but not exactly the same,
as the standard presentation from modern portfolio theory (MPT) for efficient frontier of investing, and the capital market
line. A careful look at the the plot below shows minor difference at very high levels of return and risk that departs from
the MPT analysis.
# plot return vs risk
daily_returns = assets.diff()[1:] / assets.shift(1)[1:]
mean_return = daily_returns.mean()
mean_absolute_deviation = abs(daily_returns - mean_return).mean()
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).
Regression is the task of fitting a model to data. If things go well, the model might provide useful predictions in response
to new data. This notebook shows how linear programming and least absolute deviation (LAD) regression can be used
to create a linear model for predicting wine quality based on physical and chemical properties. The example uses a well
known data set from the machine learning community.
Physical, chemical, and sensory quality properties were collected for a large number of red and white wines produced in
the Portugal then donated to the UCI machine learning repository (Cortez, Paulo, Cerdeira, A., Almeida, F., Matos, T.
& Reis, J.. (2009). Wine Quality. UCI Machine Learning Repository.) The following cell reads the data for red wines
directly from the UCI machine learning repository.
Cortez, P., Cerdeira, A., Almeida, F., Matos, T., & Reis, J. (2009). Modeling wine preferences by data mining from
physicochemical properties. Decision support systems, 47(4), 547-553. https://doi.org/10.1016/j.dss.2009.05.016
Let us first download the data
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
wines = pd.read_csv(
"https://archive.ics.uci.edu/ml/machine-learning-databases/wine-quality/
↪winequality-red.csv",
sep=";",
)
display(wines)
alcohol quality
0 9.4 5
1 9.8 5
2 9.8 5
(continues on next page)
Given 𝑛 repeated observations of a response variable 𝑦𝑖 (in this wine quality), the mean absolute deviation (MAD) of
𝑦𝑖 from the mean value 𝑦 ̄ is
1 𝑛
MAD (𝑦) = ∑ |𝑦 − 𝑦|̄
𝑛 𝑖=1 𝑖
def MAD(df):
return (df - df.mean()).abs().mean()
mad = MAD(wines["quality"])
ax.axhline(wines["quality"].mean() + mad, color="g", lw=3)
ax.axhline(wines["quality"].mean() - mad, color="g", lw=3)
ax.legend(["y", "mean", "mad"])
ax.set_xlabel("observation")
plt.show()
MAD = 0.6831779242889846
The data consists of 1,599 measurements of eleven physical and chemical characteristics plus an integer measure of
sensory quality recorded on a scale from 3 to 8. Histograms provides insight into the values and variability of the data set.
plt.tight_layout()
The art of regression is to identify the features that have explanatory value for a response of interest. This is where a person
with deep knowledge of an application area, in this case an experienced onenologist will have a head start compared to
the naive data scientist. In the absence of the experience, we proceed by examining the correlation among the variables
in the data set.
_ = wines.corr()["quality"].plot(kind="bar", grid=True)
Collectively, these figures suggest alcohol is a strong correlate of quality, and several additional factors as candidates
for explanatory variables..
An alternative approach is perform a series of single feature LAD regressions to determine which features have the largest
impact on reducing the mean absolute deviations in the residuals.
1
min ∑ |𝑦 − 𝑎𝑥𝑖 − 𝑏|
𝐼 𝑖∈𝐼 𝑖
%%writefile lad_fit_1.mod
set I;
param y{I};
param X{I};
var a;
var b;
Writing lad_fit_1.mod
m.set["I"] = df.index.values
m.param["y"] = df[y_col]
m.param["X"] = df[x_col]
m.option["solver"] = SOLVER
m.solve()
return m
print(
f'The mean absolute deviation for a single-feature regression is {m.obj["mean_
↪absolute_deviation"].value():0.5f}'
This calculation is performed for all variables to determine which variables are the best candidates to explain deviations
in wine quality.
mad = (wines["alcohol"] - wines["alcohol"].mean()).abs().mean()
vars = {}
for i in wines.columns:
m = lad_fit_1(wines, "quality", i)
vars[i] = m.obj["mean_absolute_deviation"].value()
fig, ax = plt.subplots()
pd.Series(vars).plot(kind="bar", ax=ax, grid=True)
ax.axhline(mad, color="r", lw=3)
ax.set_title("MADs for single-feature regressions")
plt.show()
Let us now perform a full multivariate 𝐿1 -regression on the wine dataset to predict the wine quality 𝑦 using the provided
wine features. We aim to find the coefficients 𝑚𝑗 ’s and 𝑏 that minimize the mean absolute deviation (MAD) by solving
the following problem:
1 𝑛
MAD (𝑦)̂ = min ∑ |𝑦 − 𝑦𝑖̂ |
𝑚, 𝑏 𝑛 𝑖=1 𝑖
𝐽
s. t. 𝑦𝑖̂ = ∑ 𝑥𝑖,𝑗 𝑚𝑗 + 𝑏 ∀𝑖 = 1, … , 𝑛,
𝑗=1
where 𝑥𝑖,𝑗 are values of ‘explanatory’ variables, i.e., the 11 physical and chemical characteristics of the wines. By taking
care of the absolute value appearing in the objective function, this can be implemented in AMPL as an LP as follows:
%%writefile l1_fit.mod
set I;
set J;
param y{I};
param X{I, J};
var a{J};
var b;
Writing l1_fit.mod
m.set["I"] = df.index.values
m.set["J"] = x_cols
m.param["y"] = df[y_col]
m.param["X"] = df[x_cols]
m.option["solver"] = SOLVER
m.solve()
return m
m = l1_fit(
wines,
(continues on next page)
for k, v in m.var["a"].get_values():
print(f"{k} {v}")
print("\n")
MAD = 0.49980
alcohol 0.3424249748641086
citric acid -0.2892764146613565
density -18.50082905729104
fixed acidity 0.06381837845852499
sulphates 0.9060911918549712
total sulfur dioxide -0.002187357846776872
volatile acidity -0.9806174627416928
A successful regression model would demonstrate a substantial reduction from MAD (𝑦) to MAD (𝑦). ̂ The value of
MAD (𝑦) sets a benchmark for the regression. The linear regression model clearly has some capability to explain the
observed deviations in wine quality. Tabulating the results of the regression using the MAD statistic we find
Regressors MAD
none 0.683
alcohol only 0.541
all 0.500
Are these models good enough to replace human judgment of wine quality? The reader can be the judge.
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Another class of seemingly complicated objective functions that can be easily rewritten as an LP are those stated as
maxima over several linear functions. Given a finite set of indices 𝐾 and a collection of vectors {𝑐𝑘 }𝑘∈𝐾 , the minimax
problem given by
General expressions like the latter can be linearized by introducing an auxiliary variable 𝑧 and setting
min 𝑧
s.t. 𝑐𝑘⊤ 𝑥 ≤ 𝑧 ∀ 𝑘 ∈ 𝐾.
This trick works because if all the quantities corresponding to different indices 𝑘 ∈ 𝐾 are below the auxiliary variable 𝑧,
then we are guaranteed that also their maximum is also below 𝑧 and vice versa. Note that the absolute value function can
be rewritten |𝑥𝑖 | = max{𝑥𝑖 , −𝑥𝑖 }, hence the linearization of the optimization problem involving absolute values in the
objective functions is a special case of this.
In the same way we can minimize a maximum like above, we can also maximize the minimum. Let us consider the
BIM microchip production problem, but suppose that there is uncertainty regarding the selling prices of the microchips.
Instead of just the nominal prices 12 € and 9 €, BIM estimates that the prices may more generally take the values
𝑃 = {(12, 9), (11, 10), (8, 11)}. The optimization problem for a production plan that achieves the maximum among the
lowest possible profits can be formulated using the trick mentioned above and can be implemented in AMPL as follows.
%%writefile bim_maxmin.mod
var x1 >= 0;
var x2 >= 0;
var z;
maximize profit: z;
Writing bim_maxmin.mod
def BIM_maxmin(costs):
ampl = AMPL()
ampl.read("bim_maxmin.mod")
ampl.set["costs"] = costs
return ampl
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
2.6.1 Two variants of the BIM problem: fractional objective and additional fixed
costs
Assume the pair (12, 9) reflects the sales price (revenues) in € and not the profits made per unit produced. We then
need to account for the production costs. Suppose that the production costs for (𝑥1 , 𝑥2 ) chips are equal to a fixed cost of
100 (independent of the number of units produced) plus 7/6𝑥1 plus 5/6𝑥2 . It is reasonable to maximize the difference
between the revenues and the costs. This approach yields the following linear model:
%%writefile BIM_with_revenues_minus_costs.mod
var x1 >= 0;
var x2 >= 0;
Writing BIM_with_revenues_minus_costs.mod
def BIM_with_revenues_minus_costs():
m = AMPL()
m.read("BIM_with_revenues_minus_costs.mod")
(continues on next page)
return m
BIM_linear = BIM_with_revenues_minus_costs()
BIM_linear.option["solver"] = SOLVER
BIM_linear.solve()
print(
"x=({:.1f},{:.1f}) value={:.3f} revenue={:.2f} cost={:.2f}".format(
BIM_linear.var["x1"].value(),
BIM_linear.var["x2"].value(),
BIM_linear.obj["profit"].value(),
BIM_linear.var["revenue"].value(),
BIM_linear.var["variable_cost"].value()
+ BIM_linear.param["fixed_cost"].value(),
)
)
This first model has the same optimal solution as the original BIM model, namely (650, 1100) with a revenue of 17700
and a cost of 1775.
Alternatively, we may aim to optimize the efficiency of the plan, expressed as the ratio between the revenues and the
costs:
12𝑥1 + 9𝑥2
max
7/6𝑥1 + 5/6𝑥2 + 100
s.t. 𝑥1 ≤ 1000 (silicon)
𝑥2 ≤ 1500 (germanium)
𝑥1 + 𝑥2 ≤ 1750 (plastic)
4𝑥1 + 2𝑥2 ≤ 4800 (copper)
𝑥1 , 𝑥2 ≥ 0.
In order to solve this second version we need to deal with the fraction appearing in the objective function by introducing
an auxiliary variable 𝑡 ≥ 0. More specifically, we reformulate the model as follows
Despite the change of variables, we can always recover the solution as (𝑥1 , 𝑥2 ) = (𝑦1 /𝑡, 𝑦2 /𝑡).
%%writefile BIM_with_revenues_over_costs.mod
var y1 >= 0;
var y2 >= 0;
var t >= 0;
(continues on next page)
Writing BIM_with_revenues_over_costs.mod
def BIM_with_revenues_over_costs():
m = AMPL()
m.read("BIM_with_revenues_over_costs.mod")
return m
BIM_fractional = BIM_with_revenues_over_costs()
BIM_fractional.option["solver"] = SOLVER
BIM_fractional.solve()
t = BIM_fractional.var["t"].value()
y1 = BIM_fractional.var["y1"].value()
y2 = BIM_fractional.var["y2"].value()
profit = BIM_fractional.obj["profit"].value()
variable_cost = BIM_fractional.var["variable_cost"].value()
fixed_cost = BIM_fractional.param["fixed_cost"].value()
revenue = BIM_fractional.var["revenue"].value()
print(
"x=({:.1f},{:.1f}) value={:.3f} revenue={:.2f} cost={:.2f}".format(
y1 / t,
y2 / t,
profit / (variable_cost + fixed_cost * t),
revenue / t,
variable_cost / t + fixed_cost,
)
)
The second model has optimal solution (250, 1500) with a revenue of 16500 and a cost of 1641.667.
The efficiency, measured as the ratio of revenue over costs for the optimal solution, is different for the two models. For
the first model the efficiency is equal to 17700
1775 = 9.972, which is strictly smaller than that of the second model, that is
16500
1641.667 = 10.051.
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).
2.7.1 The problem: Optimal material acquisition and production planning using
demand forecasts
This example is a continuation of the BIM chip production problem illustrated here. Recall hat BIM produces logic and
memory chips using copper, silicon, germanium, and plastic and that each chip requires the following quantities of raw
materials:
BIM needs to carefully manage the acquisition and inventory of these raw materials based on the forecasted demand for
the chips. Data analysis led to the following prediction of monthly demands:
chip Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
logic 88 125 260 217 238 286 248 238 265 293 259 244
memory 47 62 81 65 95 118 86 89 82 82 84 66
The company would like to have at least the following stock at the end of the year:
Each raw material can be acquired at each month, but the unit prices vary as follows:
product Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
copper 1 1 1 2 2 3 3 2 2 1 1 2
silicon 4 3 3 3 5 5 6 5 4 3 3 5
germanium 5 5 5 3 3 3 3 2 3 4 5 6
plastic 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1
The inventory is limited by a capacity of a total of 9000 units per month, regardless of the type of material of products
in stock. The holding costs of the inventory are 0.05 per unit per month regardless of the material type. Due to budget
constraints, BIM cannot spend more than 5000 per month on acquisition.
BIM aims at minimizing the acquisition and holding costs of the materials while meeting the required quantities for
production. The production is made to order, meaning that no inventory of chips is kept.
Let us model the material acquisition planning and solve it optimally based on the forecasted chip demand above.
Let us first import both the price and forecast chip demand as Pandas dataframes.
import numpy as np
import matplotlib.pyplot as plt
from io import StringIO
import pandas as pd
demand_data = """chip,Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec
logic,88,125,260,217,238,286,248,238,265,293,259,244
memory,47,62,81,65,95,118,86,89,82,82,84,66"""
price_data = """product,Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec
copper,1,1,1,2,2,3,3,2,2,1,1,2
silicon,4,3,3,3,5,5,6,5,4,3,3,5
germanium,5,5,5,3,3,3,3,2,3,4,5,6
plastic,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1"""
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
chip
logic 88 125 260 217 238 286 248 238 265 293 259 244
memory 47 62 81 65 95 118 86 89 82 82 84 66
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
product
copper 1.0 1.0 1.0 2.0 2.0 3.0 3.0 2.0 2.0 1.0 1.0 2.0
silicon 4.0 3.0 3.0 3.0 5.0 5.0 6.0 5.0 4.0 3.0 3.0 5.0
germanium 5.0 5.0 5.0 3.0 3.0 3.0 3.0 2.0 3.0 4.0 5.0 6.0
plastic 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1 0.1
We can also add a small dataframe with the consumptions and obtain the monthly demand for each raw material using a
simple matrix multiplication.
use = dict()
use["logic"] = {"silicon": 1, "plastic": 1, "copper": 4}
use["memory"] = {"germanium": 1, "plastic": 1, "copper": 2}
use = pd.DataFrame.from_dict(use).fillna(0).astype(int)
(continues on next page)
demand = use.dot(demand_chips)
demand
logic memory
silicon 1 0
plastic 1 1
copper 4 2
germanium 0 1
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
silicon 88 125 260 217 238 286 248 238 265 293 259 244
plastic 135 187 341 282 333 404 334 327 347 375 343 310
copper 446 624 1202 998 1142 1380 1164 1130 1224 1336 1204 1108
germanium 47 62 81 65 95 118 86 89 82 82 84 66
Define the set of raw material 𝑃 = {copper, silicon, germanium, plastic} and 𝑇 the set of the 12 months of the year. Let
• 𝑥𝑝𝑡 ≥ 0 be the variable describing the amount of raw material 𝑝 ∈ 𝑃 acquired in month 𝑡 ∈ 𝑇 ;
• 𝑠𝑝𝑡 ≥ 0 be the variable describing the amount of raw material 𝑝 ∈ 𝑃 left in stock at the end of month 𝑡 ∈ 𝑇 .
Note that these values are uniquely determined by the 𝑥 variables, but we keep these additional variables to ease
the modeling.
The total cost is the objective function of our optimal acquisition and production problem. If 𝜋𝑝𝑡 is the unit price of
product 𝑝 ∈ 𝑃 in month 𝑡 ∈ 𝑇 and ℎ𝑝𝑡 the unit holding costs (which happen to be constant) we can express the total cost
as:
∑ ∑ 𝜋𝑝𝑡 𝑥𝑝𝑡 + ∑ ∑ ℎ𝑝𝑡 𝑠𝑝𝑡 .
𝑝∈𝑃 𝑡∈𝑇 𝑝∈𝑃 𝑡∈𝑇
Let us now focus on the constraints. If 𝛽 ≥ 0 denotes the monthly acquisition budget, the budget constraint can be
expressed as:
∑ 𝜋𝑝𝑡 𝑥𝑝𝑡 ≤ 𝛽 ∀𝑡 ∈ 𝑇 .
𝑝∈𝑃
Next, we add another constraint to fix the value of the variables 𝑠𝑝𝑡 by balancing the acquired amounts with the previous
inventory and the demand 𝛿𝑝𝑡 which for each month is implied by the total demand for the chips of both types. Note that
𝑡 − 1 is defined as the initial stock when 𝑡 is the first period, that is \texttt{January}. This can be obtained with additional
variables 𝑠 made equal to those values or with a rule that specializes, as in the code below.
𝑥𝑝𝑡 + 𝑠𝑝,𝑡−1 = 𝛿𝑝𝑡 + 𝑠𝑝𝑡 ∀𝑝 ∈ 𝑃 , 𝑡 ∈ 𝑇 .
Finally, we capture the required minimum inventory levels in December with the constraint.
𝑠𝑝Dec ≥ Ω𝑝 ∀𝑝 ∈ 𝑃 ,
where (Ω𝑝 )𝑝∈𝑃 is the vector with the desired end inventories.
Here is the AMPL implementation of this LP.
%%writefile BIMProductAcquisitionAndInventory.mod
set T ordered;
set P;
Overwriting BIMProductAcquisitionAndInventory.mod
return df
def BIMProductAcquisitionAndInventory(
demand, acquisition_price, existing, desired, stock_limit, month_budget
):
m = AMPL()
m.read("BIMProductAcquisitionAndInventory.mod")
m.set["T"] = list(demand.columns)
m.set["P"] = demand.index.values
m.param["pi"] = acquisition_price
m.param["delta"] = demand
m.param["existing"] = existing
(continues on next page)
return m
We now can create an instance of the model using the provided data and solve it.
budget = 5000
m = BIMProductAcquisitionAndInventory(
demand,
price,
{"silicon": 1000, "germanium": 1500, "plastic": 1750, "copper": 4800},
{"silicon": 500, "germanium": 500, "plastic": 1000, "copper": 2000},
9000,
budget,
)
m.option["solver"] = SOLVER
m.solve()
T Jan Feb Mar Apr May Jun Jul Aug Sep Oct \
P
silicon 0.0 0.0 0.0 965.0 0.0 0.0 0.0 0.0 0.0 1078.1
plastic 0.0 0.0 0.0 0.0 0.0 0.0 266.0 327.0 347.0 375.0
copper 0.0 0.0 3548.0 0.0 0.0 0.0 0.0 0.0 962.0 1336.0
germanium 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
T Nov Dec
P
silicon 217.9 0.0
plastic 343.0 1310.0
copper 4312.0 0.0
germanium 0.0 0.0
Here is a different solution corresponding to the situation where the budget is much lower, namely 2000.
budget = 2000
m = BIMProductAcquisitionAndInventory(
demand,
price,
{"silicon": 1000, "germanium": 1500, "plastic": 1750, "copper": 4800},
{"silicon": 500, "germanium": 500, "plastic": 1000, "copper": 2000},
9000,
budget,
)
m.option["solver"] = SOLVER
m.solve()
Looking at the two optimal solutions corresponding to different budgets, we can note that:
• The budget is not limitative;
We can create a more parsimonious model with fewer variabels by getting rid of the auxiliary variables 𝑠𝑝𝑡 . Here is the
corresponding implementation in AMPL:
%%writefile BIMProductAcquisitionAndInventory_v2.mod
set T ordered;
set P;
param pi{P, T};
param h{P, T} default 0.05;
param delta{P, T};
param existing{P};
param desired{P};
param month_budget;
param stock_limit;
var x{P,T} >= 0;
param acquisition_price{P,T};
var s{p in P, t in T} = if t == first(T) then
existing[p] + x[p,t] - delta[p,t]
else
x[p,t] + s[p,prev(t)] - delta[p,t];
s.t. non_negative_stock {p in P, t in T}: s[p,t] >= 0;
var acquisition_cost = sum{p in P, t in T} pi[p,t] * x[p,t];
var inventory_cost = sum{p in P, t in T} h[p,t] * s[p,t];
minimize total_cost: acquisition_cost + inventory_cost;
s.t. finish {p in P}: s[p,last(T)] >= desired[p];
s.t. inventory {t in T}: sum{p in P} s[p,t] <= stock_limit;
s.t. budget {t in T}: sum{p in P} pi[p,t] * x[p,t] <= month_budget;
Overwriting BIMProductAcquisitionAndInventory_v2.mod
def BIMProductAcquisitionAndInventory_v2(
demand, acquisition_price, existing, desired, stock_limit, month_budget
):
m = AMPL()
m.read("BIMProductAcquisitionAndInventory_v2.mod")
m.set["T"] = list(demand.columns)
m.set["P"] = demand.index.values
m.param["pi"] = acquisition_price
m.param["delta"] = demand
m.param["existing"] = existing
m.param["desired"] = desired
m.param["month_budget"] = month_budget
m.param["stock_limit"] = stock_limit
return m
m = BIMProductAcquisitionAndInventory_v2(
demand,
price,
{"silicon": 1000, "germanium": 1500, "plastic": 1750, "copper": 4800},
{"silicon": 500, "germanium": 500, "plastic": 1000, "copper": 2000},
9000,
2000,
)
m.option["solver"] = SOLVER
m.solve()
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).
2.8.1 Maximizing the profit in the worst case for a multi-product facility
A common formulation for the problem of maximizing profit of a multi-product facility in a resource constrained envi-
ronment is given by the following LP
max profit = ∑ 𝑐𝑗 𝑥𝑗
𝑗∈𝐽
s.t. ∑ 𝑎𝑖𝑗 𝑥𝑗 ≤ 𝑏𝑖 ∀𝑖 ∈ 𝐼
𝑗∈𝐽
𝑥𝑗 ≥ 0 ∀ 𝑗 ∈ 𝐽,
where 𝑥𝑗 is the production of product 𝑗 ∈ 𝐽 , 𝑐𝑗 is the net profit from producing and selling one unit of product 𝑗, 𝑎𝑖,𝑗
is the amount of resource 𝑖 required to product a unit of product 𝑗, and 𝑏𝑖 is amount of resource 𝑖 ∈ 𝐼 available. If this
data is available, then the linear programming solution can provide a considerable of information regarding an optimal
production plan and the marginal value of additional resources.
But what if coefficients of the model are uncertain? What should be the objective then? Does uncertainty change the
production plan? Does the uncertainty change the marginal value assigned to resources? These are complex and thorny
questions that will be largely reserved for later chapters of this book. However, it is possible to consider a specific situation
within the current context.
Consider a situation where there are multiple plausible models for the net profit. These might be a result of marketing
studies or from considering plant operation under multiple scenarios, which we collect in a set 𝑆. The set of profit models
could be written
profit𝑠 = ∑ 𝑐𝑗𝑠 𝑥𝑗 ∀𝑠 ∈ 𝑆
𝑗
where 𝑠 indexes the set of possible scenarios. The scenarios are all deemed equal, no probabilistic interpretation is given.
One conservative criterion is to find maximize profit for the worst case. Letting 𝑧 denote the profit for the worst case, this
criterion requires finding a solution for 𝑥𝑗 for 𝑗 ∈ 𝐽 that satisfies
max 𝑧
𝑥𝑗
s.t. ∑ 𝑐𝑗𝑠 𝑥𝑗 ≥ 𝑧 ∀𝑠 ∈ 𝑆
𝑗∈𝐽
∑ 𝑎𝑖𝑗 𝑥𝑗 ≤ 𝑏𝑖 ∀𝑖 ∈ 𝐼
𝑗∈𝐽
𝑥𝑗 ≥ 0 ∀𝑗 ∈ 𝐽
2.8.2 Data
import pandas as pd
BIM_scenarios = pd.DataFrame(
[[12, 9], [11, 10], [8, 11]], columns=["product 1", "product 2"]
)
BIM_scenarios.index.name = "scenarios"
print("\nProfit scenarios")
display(BIM_scenarios)
BIM_resources = pd.DataFrame(
[
["silicon", 1000, 1, 0],
["germanium", 1500, 0, 1],
["plastic", 1750, 1, 1],
["copper", 4000, 1, 2],
],
columns=["resource", "available", "product 1", "product 2"],
)
BIM_resources = BIM_resources.set_index("resource")
Profit scenarios
product 1 product 2
scenarios
0 12 9
1 11 10
2 8 11
set I;
set J;
set S;
param a{I,J};
param b{I};
param c{S,J};
maximize profit: z;
s.t. scenario_profit {s in S}: z <= sum{j in J} c[s, j] * x[j];
s.t. resource_limits {i in I}: sum{j in J} a[i, j] * x[j] <= b[i];
Overwriting maxmin.mod
model = AMPL()
model.read("maxmin.mod")
model.set["I"] = resources.index.values
model.set["J"] = products
model.set["S"] = scenarios.index.values
return model
product 1 583.333333
product 2 1166.666667
Name: worst case, dtype: float64
Maximizing the worst case among all cases is a conservative planning outlook. It may be worth investigating alternative
planning outlooks.
The first step is to create a model to optimize a single scenario. Without repeating the mathematical description, the
following AMPL model is simply the maxmin model adapted to a single scenario.
%%writefile max_profit.mod
set I;
set J;
param a{I,J};
param b{I};
param c{J};
Overwriting max_profit.mod
model = AMPL()
model.read("max_profit.mod")
model.set["I"] = resources.index.values
model.set["J"] = products
return model
The next cell computes the optimal plan for the mean scenario.
mean_case_profit = mean_case.obj["profit"].value()
mean_case_plan = pd.Series(mean_case.var["x"].to_dict(), name="mean case")
The expected profit under the mean scenario if 17,833 which is 333 greater than for the worst case. Also note the
production plan is different.
Which plan should be preferred? The one that produces a guaranteed profit of 17,500 under all scenarios, or one that
produces expected profit of 17,833?
mean_case_outcomes = BIM_scenarios.dot(mean_case_plan)
mean_case_outcomes.name = "mean outcomes"
worst_case_outcomes = BIM_scenarios.dot(worst_case_plan)
worst_case_outcomes.name = "worst case outcomes"
(continues on next page)
2.8.6 Summary
Planning for the worst case reduces the penalty of a bad outcome. But it comes at the cost of reducing the expected
payout, the also the maximum payout should the most favorable scenario occur.
1. Which plan would you choose. Why?
2. Make a case for the other choice.
THREE
A peculiar feature of the linear optimization problems that we have seen so far was that the decision variables can take
any real value provided that all the constraints are satisfied. However, there are many situations in which it makes sense
to restrict the feasible set in a way that cannot be expressed using linear (in-)equality constraints. For example, some
numbers might need to be integers, such as the number of people to be assigned to a task. Another situation is where
certain constraints are to hold only if another constraint holds – for example, the amount of power generated in a coal
plant taking at least its minimum value only if the generator is turned on. None of these two examples can be formulated
using linear constraints alone. For such and many other situations, it is often possible that the problem can still be modeled
as an LP, yet with an extra restriction that some variables need to take integer values only.
A mixed-integer linear optimization (MILO) is an LP problem in which some variables are constrained to be integers.
Formally, it is defined as
min 𝑐⊤ 𝑥
s.t. 𝐴𝑥 ≤ 𝑏,
𝑥𝑖 ∈ ℤ, 𝑖 ∈ ℐ,
where ℐ ⊆ {1, … , 𝑛} is the subset of indices identifying the variables that take integer values. The remaining variables
are tacitly assumed to be real variables, that is 𝑥𝑖 ∈ ℝ for 𝑖 ∉ ℐ. Of course, if the decision variables are required to be
nonnegative, we could use the set ℕ instead of ℤ. A special case of integer variables are binary variables, which can take
only values in 𝔹 = {0, 1}.
In this chapter, there is a number of examples with companion AMPL implementation that explore various modeling and
implementation aspects of MILO:
• A basic MILO example modeling the BIM production with perturbed data
• A workforce shift scheduling problem
• A production problem using disjunctions
• A machine scheduling problem
• Optimal recharging strategy for an electric vehicle
• BIM production revisited
• Extra material: Cryptarithms puzzle
• Extra material: Strip packing
• Extra material: Job shop scheduling
• Extra material: Maintenance planning
Go to the next chapter about network optimization.
85
Hands-On Mathematical Optimization with AMPL in Python
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).
The company BIM realizes that a 1% fraction of the copper always gets wasted while producing both types of microchips,
more specifically 1% of the required amount. This means that it actually takes 4.04 gr of copper to produce a logic chip
and 2.02 gr of copper to produce a memory chip. If we rewrite the linear problem of the basic BIM problem and modify
accordingly the coefficients in the corresponding constraints, we obtain the following problem
If we solve it, we obtain a different optimal solution than the original one, namely (𝑥1 , 𝑥2 ) ≈ (626.238, 1123.762) and
an optimal value of roughly 17628.713. Note, in particular, that this new optimal solution is not integer, but on the other
hand in the linear optimization problem above there is no constraint requiring 𝑥1 and 𝑥2 to be such.
%%writefile BIM_perturbed_LO.mod
var x1 >= 0;
var x2 >= 0;
Overwriting BIM_perturbed_LO.mod
m = AMPL()
m.read("BIM_perturbed_LO.mod")
(continues on next page)
m.option["solver"] = SOLVER
m.solve()
print(
"x = ({:.3f}, {:.3f}), optimal value = {:.3f}".format(
m.var["x1"].value(), m.var["x2"].value(), m.obj["profit"].value()
)
)
In terms of production, of course, we would simply produce entire chips but it is not clear how to implement the fractional
solution (𝑥1 , 𝑥2 ) ≈ (626.238, 1123.762). Rounding down to (𝑥1 , 𝑥2 ) = (626, 1123) will intuitively yield a feasible
solution, but we might be giving away some profit and/or not using efficiently the available material. Rounding up to
(𝑥1 , 𝑥2 ) = (627, 1124) could possibly lead to an unfeasible solution for which the available material is not enough. We
can of course manually inspect by hand all these candidate integer solutions, but if the problem involved many more
decision variables or had a more complex structure, this would become much harder and possibly not lead to the true
optimal solution.
A much safer approach is to explicitly require the two decision variables to be non-negative integers, thus transforming
the original into the following mixed-integer linear optimization (MILO) problem:
max 12𝑥1 + 9𝑥2
s.t. 𝑥1 ≤ 1000 (silicon)
𝑥2 ≤ 1500 (germanium)
𝑥1 + 𝑥2 ≤ 1750 (plastic)
4.04𝑥1 + 2.02𝑥2 ≤ 4800 (copper with waste)
𝑥1 , 𝑥2 ∈ ℕ.
The optimal solution is (𝑥1 , 𝑥2 ) = (626, 1124) with a profit of 17628. Note that for this specific problem both the naive
rounding strategies outlined above would have not yielded the true optimal solution. The Python code for obtaining the
optimal solution using MILO solvers is given below.
%%writefile BIM_perturbed_MILO.mod
Overwriting BIM_perturbed_MILO.mod
m = AMPL()
m.read("BIM_perturbed_MILO.mod")
(continues on next page)
m.option["solver"] = SOLVER
m.solve()
print(
"x = ({:.3f}, {:.3f}), optimal value = {:.3f}".format(
m.var["x1"].value(), m.var["x2"].value(), m.obj["profit"].value()
)
)
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).
An article entitled “Modeling and optimization of a weekly workforce with Python and Pyomo” by Christian Carballo
Lozano posted on the Towards Data Science blog showed how to build a Pyomo model to schedule weekly shifts for a
small campus food store.
From the original article:
A new food store has been opened at the University Campus which will be open 24 hours a day, 7 days
a week. Each day, there are three eight-hour shifts. Morning shift is from 6:00 to 14:00, evening shift is
from 14:00 to 22:00 and night shift is from 22:00 to 6:00 of the next day. During the night there is only
one worker while during the day there are two, except on Sunday that there is only one for each shift. Each
worker will not exceed a maximum of 40 hours per week and have to rest for 12 hours between two shifts.
As for the weekly rest days, an employee who rests one Sunday will also prefer to do the same that Saturday.
In principle, there are available ten employees, which is clearly over-sized. The less the workers are needed,
the more the resources for other stores.
Here we revisit the example with a new model demonstrating how to use indexed sets in AMPL, and how to use the model
solution to create useful visualizations and reports for workers and managers.
Model sets
This problem requires assignment of an unspecified number of workers to a predetermined set of shifts. There are three
shifts per day, seven days per week. These observations suggest the need for three ordered sets:
• WORKERS with 𝑁 elements representing workers. 𝑁 is as input to a function creating an instance of the model.
• DAYS with labeling the days of the week.
• SHIFTS labeling the shifts each day.
The problem describes additional considerations that suggest the utility of several additional sets.
• SLOTS is an ordered set of (day, shift) pairs describing all of the available shifts during the week.
• BLOCKS is an ordered set of all overlapping 24 hour periods in the week. An element of the set contains the (day,
shift) period in the corresponding period. This set will be used to limit worker assignments to no more than one
for each 24 hour period.
• WEEKENDS is a the set of all (day, shift) pairs on a weekend. This set will be used to implement worker preferences
on weekend scheduling.
These additional sets improve the readability of the model.
Model parameters
𝑁 = number of workers
WorkersRequired𝑑,𝑠 = number of workers required for each day, shift pair (𝑑, 𝑠)
Model constraints
8 ∑ assign𝑤,𝑑,𝑠 ≤ 40 ∀𝑤 ∈ WORKERS
𝑑,𝑠∈ SLOTS
Do not assign any shift to a worker that is not needed during the week.
Do not assign any weekend shift to a worker that is not needed during the weekend.
Model objective
The model objective is to minimize the overall number of workers needed to fill the shift and work requirements while
also attempting to meet worker preferences regarding weekend shift assignments. This is formulated here as an objective
for minimizing a weighted sum of the number of workers needed to meet all shift requirements and the number of workers
assigned to weekend shifts. The positive weight 𝛾 determines the relative importance of these two measures of a desirable
shift schedule.
%%writefile shift_schedule.mod
# sets
# ordered set of avaiable workers
set WORKERS ordered;
# ordered sets of days and shifts
set DAYS ordered;
set SHIFTS ordered;
# set of day, shift time slots
set SLOTS within {DAYS, SHIFTS};
# set of 24 hour time blocks
set BLOCKS within {SLOTS, SLOTS, SLOTS};
# set of weekend shifts
set WEEKENDS within SLOTS;
# parameters
# number of workers required for each slot
param WorkersRequired{SLOTS};
# max hours per week per worker
param Hours;
# weight of the weekend component in the objective function
param gamma default 0.1;
# variables
# assign[worker, day, shift] = 1 assigns worker to a time slot
var assign{WORKERS, SLOTS} binary;
# weekend[worker] = 1 worker is assigned weekend shift
var weekend{WORKERS} binary;
# needed[worker] = 1
var needed{WORKERS} binary;
# constraints
# assign a sufficient number of workers for each time slot
s.t. required_workers {(day, shift) in SLOTS}:
WorkersRequired[day, shift] == sum{worker in WORKERS} assign[worker, day, shift];
# workers limited to forty hours per week assuming 8 hours per shift
s.t. forty_hour_limit {worker in WORKERS}:
8 * sum{(day, shift) in SLOTS} assign[worker, day, shift] <= Hours;
# workers are assigned no more than one time slot per 24 time block
s.t. required_rest {worker in WORKERS, (d1, s1, d2, s2, d3, s3) in BLOCKS}:
assign[worker, d1, s1] + assign[worker, d2, s2] + assign[worker, d3, s3] <= 1;
# determine if a worker is assigned to any shift
(continues on next page)
# weighted version: since we are minimizing the objective function a smaller weight␣
↪will give higher
minimize minimize_workers:
sum{worker in WORKERS} ord(worker) * needed[worker] +
gamma * sum{worker in WORKERS} ord(worker) * weekend[worker];
Overwriting shift_schedule.mod
m = AMPL()
m.read("shift_schedule.mod")
m.set["WORKERS"] = workers
m.set["DAYS"] = days
m.set["SHIFTS"] = shifts
m.set["SLOTS"] = slots
m.set["BLOCKS"] = blocks
m.set["WEEKENDS"] = weekends
m.param["WorkersRequired"] = workers_required
m.param["Hours"] = hours
m.option["solver"] = SOLVER
m.solve()
return m
(continues on next page)
m = shift_schedule(10, 40)
Scheduling applications generate a considerable amount of data to be used by the participants. The following cells demon-
strate the preparation of charts and reports that can be used to communicate scheduling information to the store manage-
ment and shift workers.
def visualize(m):
workers = m.set["WORKERS"].to_list()
slots = m.set["SLOTS"].to_list()
days = m.set["DAYS"].to_list()
assign = m.var["assign"].to_dict()
needed = m.var["needed"].to_dict()
weekend = m.var["weekend"].to_dict()
bw = 1.0
fig, ax = plt.subplots(1, 1, figsize=(12, 1 + 0.3 * len(workers)))
ax.set_title("Shift Schedule")
# x axis styling
ax.set_xlim(0, len(slots))
colors = ["teal", "gold", "magenta"]
for i in range(len(slots) + 1):
ax.axvline(i, lw=0.3)
ax.fill_between(
[i, i + 1], [0] * 2, [len(workers)] * 2, alpha=0.1, color=colors[i % 3]
)
for i in range(len(days) + 1):
ax.axvline(3 * i, lw=1)
ax.set_xticks([3 * i + 1.5 for i in range(len(days))])
ax.set_xticklabels(days)
ax.set_xlabel("Shift")
# y axis styling
ax.set_ylim(0, len(workers))
for j in range(len(workers) + 1):
ax.axhline(j, lw=0.3)
ax.set_yticks([j + 0.5 for j in range(len(workers))])
ax.set_yticklabels(workers)
ax.set_ylabel("Worker")
visualize(m)
Optimal planning models can generate large amounts of data that need to be summarized and communicated to individuals
for implementation.
The following cell creates a pandas DataFrame comprising all active assignments from the solved model. The data consists
of all (worker, day, shift) tuples for which the binary decision variable m.assign equals one.
The data is categorical consisting of a unique id for each worker, a day of the week, or the name of a shift. Each of the
categories has a natural ordering that should be used in creating reports. This is implemented using the Categori-
calDtype class.
import pandas as pd
# get the schedule from AMPL and convert to pandas DataFrame with defined columns
schedule = m.var["assign"].to_list()
schedule = pd.DataFrame(
[[s[0], s[1], s[2]] for s in schedule if s[3]], columns=["worker", "day", "shift"]
)
Each worker should receive a report detailing their shift assignments. The reports are created by sorting the master
schedule by worker, day, and shift, then grouping by worker.
# sort schedule by worker
schedule = schedule.sort_values(by=["worker", "day", "shift"])
The store managers need reports listing workers by assigned day and shift.
# sort by day, shift, worker
schedule = schedule.sort_values(by=["day", "shift", "worker"])
1. How many workers will be required to operate the food store if all workers are limited to four shifts per week, i.e.,
32 hours? How about 3 shifts or 24 hours per week?
2. Add a second class of workers called “manager”. There needs to be one manager on duty for every shift, that
morning shifts require 3 staff on duty, evening shifts 2, and night shifts 1.
3. Add a third class of workers called “part_time”. Part time workers are limited to no more than 30 hours per week.
4. Modify the problem formulation and objective to spread the shifts out amongst all workers, attempting to equalize
the total number of assigned shifts, and similar numbers of day, evening, night, and weekend shifts.
5. Find the minimum cost staffing plan assuming managers cost 30 euros per hour + 100 euros per week in fixed
benefits, regular workers cost 20 euros per hour plus 80 euros per week in fixed benefits, and part time workers cost
15 euros per week with no benefits.
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
3.3.1 Disjunctions
Disjunctions appear in applications where there is choice among discrete alternatives. Given two logical propositions 𝛼
and 𝛽, the “or” disjunction is denoted by ∨ and defined by the truth table
𝛼 𝛽 𝛼∨𝛽
False False False
True False True
False True True
True True True
𝛼 𝛽 𝛼⊻𝛽
False False False
True False True
False True True
True True False
This notebook shows how to express disjunctions in AMPL models using the Generalized Disjunctive Programming syntax
for a simple production model.
A small production facility produces two products, 𝑋 and 𝑌 . With current technology 𝛼, the facility is subject to the
following conditions and constraints:
• Product 𝑋 requires 1 hour of labor A, 2 hours of labor B, and 100€ of raw material. Product 𝑋 sells for 270€
per unit. The daily demand is limited to 40 units.
• Product 𝑌 requires 1 hour of labor A, 1 hour of labor B, and 90€ of raw material. Product 𝑌 sells for 210€ per
unit with unlimited demand.
• There are 80 hours per day of labor A available at a cost of 50€/hour.
• There are 100 hours per day of labor B available at a cost of 40€/hour.
Using the given data we see that the net profit for each unit of 𝑋 and 𝑌 is 40€ and 30€, respectively. The optimal product
strategy is the solution to a linear optimization
max profit
𝑥,𝑦≥0
%%writefile multi_product_factory.mod
# decision variables
var profit;
var production_x >= 0;
var production_y >= 0;
(continues on next page)
# profit objective
maximize maximize_profit: profit;
# constraints
s.t. profit_expr: profit == 40 * production_x + 30 * production_y;
s.t. demand: production_x <= 40;
s.t. laborA: production_x + production_y <= 80;
s.t. laborB: 2 * production_x + production_y <= 100;
Overwriting multi_product_factory.mod
m = AMPL()
m.read("multi_product_factory.mod")
m.option["solver"] = SOLVER
m.solve()
Now suppose a new technology 𝛽 is available that affects that lowers the cost of product 𝑋. With the new technology,
only 1.5 hours of labor B is required per unit of 𝑋.
The net profit for unit of product 𝑋 with technology 𝛼 is equal to 270 − 100 − 50 − 2 ⋅ 40 = 40€
The net profit for unit of product 𝑋 with technology 𝛽 is equal to 270 − 100 − 50 − 1.5 ⋅ 40 = 60€
The decision here is whether to use technology 𝛼 or 𝛽. There are several commonly used techniques for embedding
disjunctions into mixed-integer linear optimization problems. The “big-M” technique introduces a binary decision variable
for every exclusive-or disjunction between two constraints.
We can formulate this problem as the following mixed-integer linear optimization (MILO) problem:
max profit
𝑥,𝑦≥0,𝑧∈𝔹
s.t. 𝑥 ≤ 40 (demand)
𝑥 + 𝑦 ≤ 80 (labor A)
profit ≤ 40𝑥 + 30𝑦 + 𝑀 𝑧
profit ≤ 60𝑥 + 30𝑦 + 𝑀 (1 − 𝑧)
2𝑥 + 𝑦 ≤ 100 + 𝑀 𝑧
1.5𝑥 + 𝑦 ≤ 100 + 𝑀 (1 − 𝑧).
where the variable 𝑧 ∈ {0, 1} “activates” the constraints related to the old or new technology, respectively, and 𝑀 is a
big enough number. The corresponding AMPL implementation is given by:
%%writefile multi_product_factory_milo.mod
# decision variables
var profit;
var production_x >= 0;
var production_y >= 0;
var z binary;
param M = 10000;
# profit objective
maximize maximize_profit: profit;
# constraints
s.t. profit_constr_1: profit <= 40 * production_x + 30 * production_y + M * z;
s.t. profit_constr_2: profit <= 60 * production_x + 30 * production_y + M * (1 - z);
s.t. demand: production_x <= 40;
s.t. laborA: production_x + production_y <= 80;
s.t. laborB_1: 2 * production_x + production_y <= 100 + M * z;
s.t. laborB_2: 1.5 * production_x + production_y <= 100 + M * (1 - z);
Overwriting multi_product_factory_milo.mod
m = AMPL()
m.read("multi_product_factory_milo.mod")
m.option["solver"] = SOLVER
m.solve()
Alternatively, we can formulate our problem using a disjunction, preserving the logical structure, as follows:
max profit
𝑥,𝑦≥0
s.t. 𝑥 ≤ 40 (demand)
𝑥 + 𝑦 ≤ 80 (labor A)
profit = 40𝑥 + 30𝑦 profit = 60𝑥 + 30𝑦
[ ]⊻[ ]
2𝑥 + 𝑦 ≤ 100 1.5𝑥 + 𝑦 ≤ 100
This formulation, if allowed by the software at hand, has the benefit that the software can smartly divide the solution of this
problem into sub-possibilities depending on the disjunction. AMPL supports disjunctions, as illustrated in the following
implementation:
%%writefile multi_product_factory_milo_disjunctive.mod
Overwriting multi_product_factory_milo_disjunctive.mod
m = AMPL()
m.read("multi_product_factory_milo_disjunctive.mod")
m.option["solver"] = SOLVER
m.solve()
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
“Which job should be done next?” is a question one face in modern life, whether for a busy student working on course
assignments, a courier delivering packages, a server waiting on tables in a busy restaurant, a computer processing threads,
or a machine on a complex assembly line. There are empirical answers to this question, among them “first in, first out”,
“last in, first out”, or “shortest job first”.
What we consider in this notebook is the modeling finding solutions to this class of problem using optimization techniques.
This notebook demonstrates the formulation of a model for scheduling a single machine scheduling using disjunctive
programming in AMPL. The problem is to schedule a set of jobs on a single machine given the release time, duration,
and due time for each job. Date for the example problem is from Christelle Gueret, Christian Prins, Marc Sevaux,
“Applications of Optimization with Xpress-MP,” Chapter 5, Dash Optimization, 2000.
Job data
The problem is to schedule a sequence of jobs for a single machine. The problem data presented as a Pandas dataframe
where each row corresponds to a job. The dataframe in indexed the names of the jobs. The data for each job consists
of the time when it is released for machine processing, the duration of the job, and when the it is due. The objective is,
if possible, to schedule the jobs on the machine to satisfy the due dates. If that is not possible, then the objective is to
schedule the jobs to minimizing some measure of schedule “badness”.
import pandas as pd
jobs = pd.DataFrame(
{
"A": {"release": 2, "duration": 5, "due": 10},
"B": {"release": 5, "duration": 6, "due": 21},
"C": {"release": 4, "duration": 8, "due": 15},
"D": {"release": 0, "duration": 4, "due": 10},
"E": {"release": 0, "duration": 2, "due": 5},
"F": {"release": 8, "duration": 3, "due": 15},
"G": {"release": 9, "duration": 2, "due": 22},
}
).T
jobs
Schedule data
A schedule is also represented here by a Pandas dataframe indexed by job names. The columns indicate the start, finish,
and the amount by each job is past due. The following cell creates a schedule when the jobs are executed in the order
given by the jobs dataframe.
Args:
jobs (DataFrame): Job information with columns "release", "duration", and "due
↪".
seq (DataFrame): A list of job indices representing the sequence in which the␣
↪jobs will be scheduled.
Return:
A DataFrame containing schedule information, including "start", "finish", and
↪"past" due times.
"""
schedule = pd.DataFrame(index=jobs.index)
t = 0
for job in seq:
t = max(t, jobs.loc[job]["release"])
schedule.loc[job, "start"] = t
t += jobs.loc[job, "duration"]
schedule.loc[job, "finish"] = t
schedule.loc[job, "past"] = max(0, t - jobs.loc[job, "due"])
return schedule
Gantt chart
A traditional means of visualizing scheduling data in the form of a Gantt chart. The next cell presents a function gantt
that plots a Gantt chart given jobs and schedule information.
Args:
jobs (DataFrame): Contains job release times and due times.
schedule (DataFrame): Contains job start times, finish times, and past due␣
↪times.
title (str, optional): Title of the Gantt chart. Defaults to "Job Schedule".
"""
w = 0.25 # bar width
fig, ax = plt.subplots(1, 1, figsize=(10, 0.7 * len(jobs.index)))
# If past due
if f > d:
ax.plot([d] * 2, [-k + w, -k + 2 * w], lw=0.5, color="k")
ax.plot([f] * 2, [-k + w, -k + 2 * w], lw=0.5, color="k")
ax.plot([d, f], [-k + 1.5 * w] * 2, solid_capstyle="butt", lw=1, color="k
↪ ")
ax.text(
f + 0.5,
-k + 1.5 * w,
f"{schedule.loc[job, 'past']} past due",
va="center",
)
total_past_due = schedule["past"].sum()
ax.set_ylim(-len(jobs.index), 1)
(continues on next page)
custom_lines = [
Line2D([0], [0], c="k", lw=10, alpha=0.2),
Line2D([0], [0], c="g", lw=10, alpha=0.6),
Line2D([0], [0], c="r", lw=10, alpha=0.6),
]
ax.legend(
custom_lines,
["Job release/due window", "Completed on time", "Completed past due"],
)
To provide a comparison to scheduling with optimization models, the following cells implement two well known and
accepted empirical rules for scheduling jobs on a single machine:
• First in, first out (FIFO)
• Earliest due data (EDD)
One of the most common scheduling rules is to execute jobs in the order they are released for processing, in other words
“first-in, first-out” (FIFO). The following cell creates a Pandas dataframe is indexed by job names. The start time, finish
time, and, if past due, the amount by which the job is past due.
When due dates are known, a common scheduling rule is to prioritize jobs by due date. This strategy will be familiar to
any student deciding which homework assignment should to work on next.
Symbol Description
release𝑗 when job 𝑗 is available
duration𝑗 how long job 𝑗
due𝑗 when job 𝑗 is due
The essential decision variable is the time at which the job starts processing.
Symbol Description
start𝑗 when job 𝑗 starts
finish𝑗 when job 𝑗 finishes
past𝑗 how long job 𝑗 is past due
Depending on application and circumstances, various objectives can be considered. Suitable objectives include the total
number of late jobs, the longest past due interval, or the sum of all past due intervals. The following AMPL model
minimizes the sum of past due intervals, that is
min ∑ past𝑗
𝑗
Constraints describe the required logical relationships among the decision variables. For example, a job cannot start until
it is released for processing
start𝑗 ≥ release𝑗
Once started, machine processing continues until the job is finished. The finish time for each job is compared to the due
time, and any past due interval is stored the past𝑗 decision variable.
The final set of constraints require that no pair of jobs be operating on the same machine at the same time. For this
purpose, we consider each unique pair (𝑖, 𝑗) where the constraint 𝑖 < 𝑗 to imposed to avoid considering the same pair
twice. Then for any unique pair 𝑖 and 𝑗, either 𝑖 finishes before 𝑗 starts, or 𝑗 finishes before 𝑖 starts. This is expressed as
the family of disjunctions
set JOBS;
set PAIRS within{JOBS, JOBS};
param release{JOBS};
param duration{JOBS};
param due{JOBS};
Overwriting job_machine_scheduling.mod
def build_model(jobs):
m = AMPL()
m.read("job_machine_scheduling.mod")
m.set_data(jobs, "JOBS")
return m
model = build_model(jobs)
schedule = solve_model(model)
display(schedule)
Whether it is to visit family, take a sightseeing tour or call on business associates, planning a road trip is a familiar and
routine task. Here we consider a road trip on a pre-determined route for which need to plan rest and recharging stops.
This example demonstrates use of AMPL disjunctions to model the decisions on where to stop.
SOLVER = "cbc"
ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Given the current location 𝑥, battery charge 𝑐, and planning horizon 𝐷, the task is to plan a series of recharging and rest
stops. Data is provided for the location and the charging rate available at each charging stations. The objective is to drive
from location 𝑥 to location 𝑥 + 𝐷 in as little time as possible subject to the following constraints:
• To allow for unforeseen events, the state of charge should never drop below 20% of the maximum capacity.
• The the maximum charge is 𝑐𝑚𝑎𝑥 = 80 kWh.
• For comfort, no more than 4 hours should pass between stops, and that a rest stop should last at least 𝑡𝑟𝑒𝑠𝑡 .
• Any stop includes a 𝑡𝑙𝑜𝑠𝑡 = 10 minutes of “lost time”.
For this first model we make several simplifying assumptions that can be relaxed as a later time.
• Travel is at a constant speed 𝑣 = 100 km per hour and a constant discharge rate 𝑅 = 0.24 kWh/km
• The batteries recharge at a constant rate determined by the charging station.
• Only consider stops at the recharging stations.
3.5.2 Modeling
by
𝑑𝑒𝑝
𝑐𝑖𝑎𝑟𝑟 = 𝑐𝑖−1 − 𝑅(𝑑𝑖 − 𝑑𝑖−1 )
𝑑𝑒𝑝 𝑑 − 𝑑𝑖−1
𝑟𝑖𝑎𝑟𝑟 = 𝑟𝑖−1 + 𝑖
𝑣
𝑎𝑟𝑟 𝑑𝑒𝑝 𝑑𝑖 − 𝑑𝑖−1
𝑡𝑖 = 𝑡𝑖−1 +
𝑣
𝑐𝑖𝑑𝑒𝑝 ≤ 𝑐𝑚𝑎𝑥
𝑟𝑖𝑑𝑒𝑝 = 0
𝑐𝑖𝑑𝑒𝑝 − 𝑐𝑖𝑎𝑟𝑟
𝑡𝑑𝑒𝑝
𝑖 ≥ 𝑡𝑎𝑟𝑟
𝑖 + 𝑡𝑙𝑜𝑠𝑡 +
𝐶𝑖
𝑡𝑑𝑒𝑝
𝑖 ≥ 𝑡𝑎𝑟𝑟
𝑖 + 𝑡𝑟𝑒𝑠𝑡
which account for the battery charge, the lost time and time required for battery charging, and allows for a minimum rest
time. On the other hand, if a decision is make to skip the charging and rest opportunity,
𝑐𝑖𝑑𝑒𝑝 = 𝑐𝑖𝑎𝑟𝑟
𝑟𝑖𝑑𝑒𝑝 = 𝑟𝑖𝑎𝑟𝑟
𝑡𝑑𝑒𝑝
𝑖 = 𝑡𝑎𝑟𝑟
𝑖
The latter sets of constraints have an exclusive-or relationship. That is, either one or the other of the constraint sets hold,
but not both.
min 𝑡𝑎𝑟𝑟
𝑛+1
s.t. 𝑟𝑖𝑎𝑟𝑟 ≤ 𝑟𝑚𝑎𝑥 ∀𝑖 ∈ 𝐼
𝑐𝑖𝑎𝑟𝑟 ≥𝑐 𝑚𝑖𝑛
∀𝑖 ∈ 𝐼
𝑑𝑒𝑝
𝑐𝑖𝑎𝑟𝑟 = 𝑐𝑖−1
− 𝑅(𝑑𝑖 − 𝑑𝑖−1 ) ∀𝑖 ∈ 𝐼
𝑑𝑒𝑝 𝑑 − 𝑑𝑖−1
𝑟𝑖𝑎𝑟𝑟 = 𝑟𝑖−1 + 𝑖 ∀𝑖 ∈ 𝐼
𝑣
𝑎𝑟𝑟 𝑑𝑒𝑝 𝑑𝑖 − 𝑑𝑖−1
𝑡𝑖 = 𝑡𝑖−1 + ∀𝑖 ∈ 𝐼
𝑣
𝑐𝑖𝑑𝑒𝑝 ≤ 𝑐𝑚𝑎𝑥
⎡ 𝑑𝑒𝑝 ⎤ 𝑐𝑖𝑑𝑒𝑝 = 𝑐𝑖𝑎𝑟𝑟
⎢𝑟𝑖 = 0 ⎥ ⎡ 𝑑𝑒𝑝 ⎤
⎢ 𝑡𝑑𝑒𝑝 ≥ 𝑡𝑎𝑟𝑟 + 𝑡 𝑐𝑖𝑑𝑒𝑝 −𝑐𝑖𝑎𝑟𝑟 ⎥ ⊻ ⎢𝑟𝑖 = 𝑟𝑖𝑎𝑟𝑟 ⎥ ∀ 𝑖 ∈ 𝐼.
⎢ 𝑖 𝑖 𝑙𝑜𝑠𝑡 + 𝐶𝑖 ⎥ 𝑑𝑒𝑝 𝑎𝑟𝑟
𝑑𝑒𝑝 ⎣ 𝑡𝑖 = 𝑡𝑖 ⎦
⎣ 𝑡𝑖 ≥ 𝑡𝑎𝑟𝑟
𝑖 + 𝑡 𝑟𝑒𝑠𝑡 ⎦
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
name location kw
0 S_00 191.6 150
1 S_01 310.6 100
2 S_02 516.0 50
3 S_03 683.6 50
4 S_04 769.9 50
5 S_05 869.7 100
6 S_06 1009.1 150
7 S_07 1164.7 100
8 S_08 1230.8 100
9 S_09 1350.8 250
10 S_10 1508.4 100
11 S_11 1639.8 100
12 S_12 1809.4 150
13 S_13 1947.3 250
14 S_14 2145.2 150
15 S_15 2337.5 100
16 S_16 2415.6 100
17 S_17 2590.0 100
18 S_18 2691.2 100
19 S_19 2896.2 100
# planning horizon
D = 2000
# visualize
fig, ax = plt.subplots(1, 1, figsize=(15, 3))
ax.axhline(0)
ax.set_ylim(-50, 300)
ax.set_xlabel("Distance")
ax.set_ylabel("kw")
ax.set_title("charging stations")
ax.legend()
plot_stations(stations, x, D)
# lost time
t_lost = 10 / 60
t_rest = 10 / 60
# rest time
r_max = 3
%%writefile ev_plan.mod
param n;
param C{STATIONS};
param D;
param c_min;
param c_max;
param v;
param R;
param r_max;
param location{LOCATIONS};
param dist{SEGMENTS};
param t_lost;
# distance traveled
var x{LOCATIONS} >= 0, <= 10000;
Overwriting ev_plan.mod
n = len(on_route)
m.set["STATIONS"] = STATIONS
m.set["LOCATIONS"] = LOCATIONS
m.set["SEGMENTS"] = SEGMENTS
# load data
m.param["C"] = on_route["kw"]
m.param["location"] = location
m.param["D"] = D
m.param["n"] = n
m.param["c_min"] = c_min
m.param["c_max"] = c_max
m.param["r_max"] = r_max
m.param["t_lost"] = t_lost
m.param["v"] = v
m.param["R"] = R
m.param["dist"] = dist
# initial conditions
m.var["x"][0].fix(x)
m.var["t_dep"][0].fix(0.0)
m.var["r_dep"][0].fix(0.0)
m.var["c_dep"][0].fix(c)
return m
def get_results(model):
x = [(int(k), v) for k, v in model.var["x"].to_list()]
t_arr = [v for k, v in model.var["t_arr"].to_list()]
t_dep = [v for k, v in model.var["t_dep"].to_list()]
c_arr = [v for k, v in model.var["c_arr"].to_list()]
c_dep = [v for k, v in model.var["c_dep"].to_list()]
return results
m = ev_plan(stations, 0, 2000)
results = get_results(m)
display(results)
# visualize
results = get_results(m)
# plot stations
for station in stations.index:
xs = stations.loc[station, "location"]
ys = stations.loc[station, "kw"]
ax[0].plot([xs, xs], [0, ys], "b", lw=10, solid_capstyle="butt")
ax[0].text(xs, 0 - 30, stations.loc[station, "name"], ha="center")
# annotations
ax[0].axhline(0)
ax[0].set_ylim(-50, 300)
ax[0].set_ylabel("kw")
ax[0].set_title("charging stations")
ax[0].legend()
SEGMENTS = m.set["SEGMENTS"].to_list()
STATIONS = m.set["STATIONS"].to_list()
1. Does increasing the battery capacity 𝑐𝑚𝑎𝑥 significantly reduce the time required to travel 2000 km? Explain what
you observe.
2. “The best-laid schemes of mice and men go oft awry” (Robert Burns, “To a Mouse”). Modify this model so that it
can be used to update a plans in response to real-time measurements. How does the charging strategy change as a
function of planning horizon 𝐷?
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
(continues on next page)
We consider BIM raw material planning, but now with more sophisticated pricing and acquisition protocols. There are
now three suppliers, each of which can deliver the following materials:
• A: silicon, germanium and plastic
• B: copper
• C: all of the above
For the suppliers, the following conditions apply. Copper should be acquired in multiples of 100 gram, since it is delivered
in sheets of 100 gram. Unitary materials such as silicon, germanium and plastic may be acquired in any number, but the
price is in batches of 100. Meaning that 30 units of silicon with 10 units of germanium and 50 units of plastic cost as
much as 1 unit of silicon but half as much as 30 units of silicon with 30 units of germanium and 50 units of plastic.
Furthermore, supplier C sells all materials and offers a discount if purchased together: 100 gram of copper and a batch of
unitary material cost just 7. This set price is only applied to pairs, meaning that 100 gram of copper and 2 batches cost
13.
The summary of the prices in € is given in the following table:
Next, for stocked products inventory costs are incurred, whose summary is given in the following table:
Copper per 10 gram Silicon per unit Germanium per unit Plastic per unit
0.1 0.02 0.02 0.02
The holding price of copper is per 10 gram and the copper stocked is rounded up to multiples of 10 grams, meaning that
12 grams pay for 20.
The capacity limitations of the warehouse allow for a maximum of 10 kilogram of copper in stock at any moment, but
there are no practical limitations to the number of units of unitary products in stock.
Recall that BIM has the following stock at the beginning of the year:
The company would like to have at least the following stock at the end of the year:
The goal is to build an optimization model using the data above and solve it to minimize the acquisition and holding costs
of the products while meeting the required quantities for production. The production is made-to-order, meaning that no
inventory of chips is kept.
demand_data = """
chip, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec
logic, 88, 125, 260, 217, 238, 286, 248, 238, 265, 293, 259, 244
memory, 47, 62, 81, 65, 95, 118, 86, 89, 82, 82, 84, 66
"""
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
chip
logic 88 125 260 217 238 286 248 238 265 293 259 244
memory 47 62 81 65 95 118 86 89 82 82 84 66
use = dict()
use["logic"] = {"silicon": 1, "plastic": 1, "copper": 4}
use["memory"] = {"germanium": 1, "plastic": 1, "copper": 2}
use = pd.DataFrame.from_dict(use).fillna(0).astype(int)
display(use)
logic memory
silicon 1 0
plastic 1 1
copper 4 2
germanium 0 1
demand = use.dot(demand_chips)
display(demand)
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov \
silicon 88 125 260 217 238 286 248 238 265 293 259
plastic 135 187 341 282 333 404 334 327 347 375 343
copper 446 624 1202 998 1142 1380 1164 1130 1224 1336 1204
germanium 47 62 81 65 95 118 86 89 82 82 84
Dec
silicon 244
plastic 310
copper 1108
germanium 66
%%writefile BIMproduction_v1.mod
var acquisition_cost =
sum{t in periods}(
sum{s in supplying_batches} pi[s] * bb[t,s] +
sum{s in supplying_copper} kappa[s] * y[t,s] -
beta * pp[t]
);
var inventory_cost =
sum{t in periods}(
gamma['copper'] * rr[t] +
sum{p in unitary_products} gamma[p] * ss[p,t]
);
Overwriting BIMproduction_v1.mod
def BIMproduction_v1(
demand,
existing,
desired,
stock_limit,
supplying_copper,
supplying_batches,
price_copper_sheet,
price_batch,
discounted_price,
batch_size,
copper_sheet_mass,
copper_bucket_size,
unitary_products,
unitary_holding_costs,
):
m = AMPL()
m.read("BIMproduction_v1.mod")
m.set["periods"] = demand.columns
m.set["products"] = demand.index
m.param["delta"] = demand
m.set["supplying_batches"] = supplying_batches
m.param["pi"] = price_batch
m.set["supplying_copper"] = supplying_copper
m.param["kappa"] = price_copper_sheet
m.set["unitary_products"] = unitary_products
m.param["gamma"] = unitary_holding_costs
m.param["Alpha"] = existing
m.param["Omega"] = desired
m.param["batch_size"] = batch_size
m.param["copper_bucket_size"] = copper_bucket_size
m.param["stock_limit"] = stock_limit
m.param["copper_sheet_mass"] = copper_sheet_mass
return m
m1 = BIMproduction_v1(
demand=demand,
existing={"silicon": 1000, "germanium": 1500, "plastic": 1750, "copper": 4800},
desired={"silicon": 500, "germanium": 500, "plastic": 1000, "copper": 2000},
stock_limit=10000,
supplying_copper=["B", "C"],
supplying_batches=["A", "C"],
price_copper_sheet={"B": 300, "C": 400},
price_batch={"A": 500, "C": 600},
discounted_price=700,
batch_size=100,
(continues on next page)
m1.option["solver"] = SOLVER
m1.option["highs_options"] = "outlev=1"
m1.solve()
Solving report
Status Optimal
Primal bound 110216
Dual bound 110206.854428
Gap 0.0083% (tolerance: 0.01%)
Solution status feasible
110216 (objective)
0 (bound viol.)
2.30926389122e-14 (int. viol.)
0 (row viol.)
Timing 1.31 (total)
0.00 (presolve)
0.00 (postsolve)
Nodes 11
LP iterations 1510 (total)
453 (strong br.)
347 (separation)
466 (heuristics)
HiGHS 1.5.1: optimal solution; objective 110216
1510 simplex iterations
11 branching nodes
absmipgap=9.14557, relmipgap=8.29786e-05
stock = m1.var["ss"].to_pandas().unstack()
stock.columns = stock.columns.get_level_values(1)
stock = stock.reindex(index=demand.index, columns=demand.columns)
display(stock)
m1.var["pp"].to_pandas().T
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
pp.val 0.0 0.0 0.0 0.0 0.0 3.0 5.0 6.0 6.0 7.0 6.0 20.0
df = m1.var["uu"].to_pandas().unstack()
df.columns = df.columns.get_level_values(1)
df = df.reindex(index=demand.index, columns=demand.columns)
display(df)
b = m1.var["bb"].to_pandas().unstack().T
b.index = b.index.get_level_values(1)
b = b.reindex(columns=demand.columns)
b.index.names = [None]
display(b)
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
A 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
C 0.0 0.0 0.0 0.0 0.0 3.0 5.0 6.0 6.0 7.0 6.0 20.0
y = m1.var["y"].to_pandas().unstack().T
y.index = y.index.get_level_values(1)
y = y.reindex(columns=demand.columns)
y.index.names = [None]
display(y)
Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
B 0.0 0.0 0.0 0.0 0.0 7.0 7.0 5.0 7.0 6.0 6.0 11.0
C 0.0 0.0 0.0 0.0 0.0 3.0 5.0 6.0 6.0 7.0 6.0 20.0
x = m1.var["x"].to_pandas().unstack(1)
x.columns = x.columns.get_level_values(1)
x = x.reindex(columns=demand.columns)
x.index.names = ["materials", "supplier"]
display(x)
The July 1924 issue of the famous British magazine The Strand included a word puzzle by Henry E. Dudeney in his
regular contribution “Perplexities”. The puzzle is to assign a unique digit to each letter appearing in the equation
S E N D
+ M O R E
= M O N E Y
such that the arithmetic equation is satisfied, and the leading digit for M is non-zero. There are many more examples of
these puzzles, but this is perhaps the most well-known.
SOLVER = "cbc"
ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
The requirement that no two letters be assigned the same digit can be represented as a disjunction. Letting 𝑛𝑎 and 𝑛𝑏
denote the integers assigned to letters 𝑎 and 𝑏, the disjunction becomes
%%writefile cryptarithms.mod
set LETTERS;
set PAIRS within {LETTERS, LETTERS};
s.t. message:
1000*n['S'] + 100*n['E'] + 10*n['N'] + n['D']
+ 1000*n['M'] + 100*n['O'] + 10*n['R'] + n['E']
== 10000*n['M'] + 1000*n['O'] + 100*n['N'] + 10*n['E'] + n['Y'];
Overwriting cryptarithms.mod
m = AMPL()
m.read("cryptarithms.mod")
m.set["LETTERS"] = LETTERS
m.set["PAIRS"] = PAIRS
n = m.var["n"].to_dict()
def letters2num(s):
return " ".join(map(lambda s: f"{int(round(n[s], 0))}", list(s)))
1. AMPL includes Logic, Nonlinear & Constraint Programming Extensions. Rewrite the model with different com-
binations of logical operators and compare the performance with one obtained with the constraint solver gecode.
2. There are many more examples of cryptarithm puzzles. Refactor this code and create a function that can be used
to solve generic puzzles of this type.
Strip packing (SP) refers to the problem of packing rectangles onto a two dimensional strip of fixed width.
Many variants of this problem have been studied, the most basic is the pack a set of rectangles onto the shortest possible
strip without rotation. Other variants allow rotation of the rectangles, require a packing that allows cutting the rectangles
out of the strip with edge-to-edge cuts (guillotine packing), extends the strip to three dimensions, or extends the packing
to non-rectangular shapes.
The extensive study of strip packing problems is motivated by their many industrial applications including
• placement of macro cells in semiconductor layouts,
• wood and textile cutting operations,
• laying out workstations in manufacturing facilities,
• allocating communications bandwidth between two endpoints,
• planning and scheduling 𝐶𝑂2 utilization for enhanced oil recovery,
• scheduling allocations of a common resource.
Finding optimal solutions to strip packing problems is combinatorially difficult. Strip packing belongs to a class of prob-
lems called “NP-hard” for which known solution algorithms require effort that grows exponentially with problem size.
For that reason much research on strip packing has been directed towards practical heuristic algorithms for finding good,
though not optimal, for industrial applications.
Here we develop AMPL models to find optimal solutions to smaller but economically relevant problems. We use the
problem of packing boxes onto shortest possible shelf of fixed width.
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Imagine a collection of 𝑁 boxes that are to placed on shelf. The shelf depth is 𝐷, and the dimensions of the boxes are
(𝑤𝑖 , 𝑑𝑖 ) for 𝑖 = 0, … , 𝑁 − 1. The boxes can be rotated, if needed, to fit on the shelf. How wide of a shelf is needed?
We will start by creating a function to generate a table of 𝑁 boxes. For concreteness, we assume the dimensions are in
millimeters.
import random
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
# random seed
random.seed(1842)
# generate boxes
def generate_boxes(N, max_width=200, max_depth=200):
boxes = pd.DataFrame()
boxes["w"] = [random.randint(0.2 * max_width, max_width) for i in range(N)]
boxes["d"] = [random.randint(0.2 * max_depth, max_depth) for i in range(N)]
return boxes
N = 8
boxes = generate_boxes(8)
display(boxes)
w d
0 82 103
1 73 48
(continues on next page)
A lower bound on the shelf width is established by from the area required to place all boxes on the shelf.
1 𝑁−1
𝑊𝑙𝑏 = ∑𝑤𝑑
𝐷 𝑖=0 𝑖 𝑖
An upper bound is established by aligning the boxes along the front of the shelf without rotation. To set the stage for later
calculations, the position of the rectangle on the shelf is defined by bounding box (𝑥𝑖,1 , 𝑦𝑖,1 ) and (𝑥𝑖,2 , 𝑦𝑖,2 ) extending
from the lower left corner to the upper right corner.
𝑥𝑖,2 = 𝑥𝑖,1 + 𝑤𝑖
𝑦𝑖,2 = 𝑦𝑖,1 + 𝑑𝑖
An additional binary variable 𝑟𝑖 designates whether the rectangle has been rotated. The following cell performs these
calculations to create and display a data frame showing the bounding boxes.
def pack_boxes_V0(boxes):
soln = boxes.copy()
soln["x1"] = soln["w"].cumsum() - soln["w"]
soln["x2"] = soln["w"].cumsum()
soln["y1"] = 0
soln["y2"] = soln["d"]
soln["r"] = 0
return soln
)
(continues on next page)
soln = pack_boxes_V0(boxes)
display(soln)
show_boxes(soln, D)
w d x1 x2 y1 y2 r
0 82 103 0 82 0 103 0
1 73 48 82 155 0 48 0
2 171 53 155 326 0 53 0
3 73 99 326 399 0 99 0
4 167 85 399 566 0 85 0
5 151 172 566 717 0 172 0
6 54 130 717 771 0 130 0
7 126 94 771 897 0 94 0
At this point the reader may have some ideas on how to efficiently pack boxes on the shelf. For example, one might start
by placing the larger boxes to left edge of the shelf, then rotating and placing the smaller boxes with a goal of minimized
the occupied with of the shelf.
Modeling for optimization takes a different approach. The strategy is to describe constraints that must be satisfied for
any solution to the problem, then let the solver find the a choice for the decision variables that minimizes width. The
constraints include:
• The bounding boxes must fit within the boundaries of the shelf, and to the left of vertical line drawn at 𝑥 = 𝑊 .
• The boxes can be rotated 90 degrees.
• The boxes must not overlap in either the 𝑥 or 𝑦 dimensions.
For this first AMPL model we look to reproduce a lineup of the boxes on the shelf. In the case the problem is to minimize
𝑊 where
min 𝑊
subject to:
𝑥𝑖,2 = 𝑥𝑖,1 + 𝑤𝑖 ∀𝑖
𝑥𝑖,2 ≤ 𝑊 ∀𝑖
𝑥𝑖,1 , 𝑥𝑖,2 ≥ 0 ∀𝑖
This first model does not consider rotation or placement of the boxes in the 𝑦 dimension, so those decisions are not
included.
The disjunctive constraints specify relationships between 𝑥𝑖,1 and 𝑥𝑖,2 to prevent overlapping positions of boxes on the
shelf. The disjunctions require that either that box 𝑖 is to the left of box 𝑗 or that box 𝑗 is the left of box 𝑖. This is specified
as an exclusive or disjunction because both conditions can be true at the same time. This disjunctive relationship must
hold for every pair of boxes that are different from each other, but specifying 𝑖 doesn’t overlap with 𝑗 assures 𝑗 doesn’t
overlap 𝑖. So it is only necessary to specify disjunctions for all pairs 𝑖, 𝑗 where 𝑖 < 𝑗.
The corresponding AMPL model is a direct implementation of this model. One feature of the implementation is the use
of a set PAIRS to identify the disjunctions. Defining this set simplifies coding for the corresponding disjunction.
%%writefile pack_boxes_V1.mod
set BOXES;
set PAIRS within {BOXES,BOXES};
param w{BOXES};
param W_ub;
minimize minimize_width: W;
Overwriting pack_boxes_V1.mod
def pack_boxes_V1(boxes):
# a simple upper bound on shelf width
W_ub = boxes["w"].sum()
BOXES = list(boxes.index)
PAIRS = [(i, j) for i in BOXES for j in BOXES if i < j]
m = AMPL()
m.read("pack_boxes_V1.mod")
m.set["BOXES"] = BOXES
m.set["PAIRS"] = PAIRS
m.param["w"] = boxes["w"]
m.param["W_ub"] = int(W_ub)
m.option["solver"] = SOLVER
m.solve()
soln = boxes.copy()
soln["x1"] = m.var["x1"].to_dict()
soln["x2"] = m.var["x2"].to_dict()
soln["y1"] = 0
soln["y2"] = soln["y1"] + soln["d"]
soln["r"] = 0
return soln
soln = pack_boxes_V1(boxes)
display(soln)
show_boxes(soln, D)
w d x1 x2 y1 y2 r
0 82 103 0.0 82.0 0 103 0
1 73 48 770.0 843.0 0 48 0
2 171 53 432.0 603.0 0 53 0
(continues on next page)
Rotating the boxes is an option for packing the boxes more tightly on the shelf. The boxes can be placed either in
their original orientation or in a rotated orientation. This introduces a second exclusive or disjunction to the model that
determines the orientation of the bounding box. A boolean indicator variable 𝑟𝑖 tracks which boxes were rotated which
is used in the show_boxes function to show which boxes have been rotated.
min 𝑊
subject to:
𝑥𝑖,2 ≤ 𝑊 ∀𝑖
𝑥𝑖,1 , 𝑥𝑖,2 ≥ 0 ∀𝑖
𝑦𝑖,1 = 0 ∀𝑖
¬𝑟𝑖 𝑟𝑖
⎡𝑥 = 𝑥 + 𝑤 ⎤ ⊻ ⎡𝑥 = 𝑥 + 𝑑 ⎤ ∀𝑖 < 𝑗
⎢ 𝑖,2 𝑖,1 𝑖⎥ ⎢ 𝑖,2 𝑖,1 𝑖⎥
⎣ 𝑦𝑖,2 = 𝑦𝑖,1 + 𝑑𝑖 ⎦ ⎣𝑦𝑖,2 = 𝑦𝑖,1 + 𝑤𝑖 ⎦
For this version of the model the boxes will be lined up against the edge of the shelf with 𝑦𝑖,1 = 0. Decision variables
are now included in the model for rotation 𝑟 to the 𝑦 dimension of the bounding boxes.
%%writefile pack_boxes_V2.mod
set BOXES;
(continues on next page)
param w{BOXES};
param d{BOXES};
param W_ub;
minimize minimize_width: W;
Overwriting pack_boxes_V2.mod
def pack_boxes_V2(boxes):
W_ub = boxes["w"].sum()
BOXES = list(boxes.index)
PAIRS = [(i, j) for i in BOXES for j in BOXES if i < j]
m = AMPL()
m.read("pack_boxes_V2.mod")
m.set["BOXES"] = BOXES
m.set["PAIRS"] = PAIRS
m.param["w"] = boxes["w"]
m.param["d"] = boxes["d"]
m.param["W_ub"] = int(W_ub)
m.option["solver"] = SOLVER
m.solve()
soln = boxes.copy()
soln["x1"] = m.var["x1"].to_dict()
(continues on next page)
return soln
soln = pack_boxes_V2(boxes)
display(soln)
show_boxes(soln, D)
w d x1 x2 y1 y2 r
0 82 103 1.859995e+02 267.999502 0.0 103.0 0.0
1 73 48 5.300000e+01 101.000000 0.0 73.0 1.0
2 171 53 1.421085e-14 53.000000 0.0 171.0 1.0
3 73 99 5.669995e+02 639.999502 0.0 99.0 0.0
4 167 85 1.009995e+02 185.999502 0.0 167.0 1.0
5 151 172 3.619995e+02 512.999502 0.0 172.0 0.0
6 54 130 5.129995e+02 566.999502 0.0 130.0 0.0
7 126 94 2.679995e+02 361.999502 0.0 126.0 1.0
Obviously the packages can be packed closer together by allowing boxes to be stacked deeper into the shelf. New con-
straints are needed to maintain the bounding boxes within the shelf depth, and to avoid overlaps in the 𝑦 dimension.
min 𝑊
subject to:
𝑥𝑖,2 ≤ 𝑊 ∀𝑖
𝑦𝑖,2 ≤ 𝐷 ∀𝑖
𝑥𝑖,1 , 𝑥𝑖,2 ≥ 0 ∀𝑖
𝑦𝑖,1 , 𝑦𝑖,2 ≥ 0 ∀𝑖
¬𝑟𝑖 𝑟𝑖
⎡𝑥 = 𝑥 + 𝑤 ⎤ ⊻ ⎡𝑥 = 𝑥 + 𝑑 ⎤ ∀𝑖 < 𝑗
⎢ 𝑖,2 𝑖,1 𝑖⎥ ⎢ 𝑖,2 𝑖,1 𝑖⎥
⎣ 𝑦𝑖,2 = 𝑦𝑖,1 + 𝑑𝑖 ⎦ ⎣𝑦𝑖,2 = 𝑦𝑖,1 + 𝑤𝑖 ⎦
%%writefile pack_boxes_V3.mod
set BOXES;
set PAIRS within {BOXES,BOXES};
param w{BOXES};
param d{BOXES};
param D;
param W_ub;
minimize minimize_width: W;
Overwriting pack_boxes_V3.mod
BOXES = list(boxes.index)
PAIRS = [(i, j) for i in BOXES for j in BOXES if i < j]
m = AMPL()
m.read("pack_boxes_V3.mod")
m.set["BOXES"] = BOXES
m.set["PAIRS"] = PAIRS
m.param["w"] = boxes["w"]
m.param["d"] = boxes["d"]
m.param["W_ub"] = int(W_ub)
m.param["D"] = int(D)
m.option["solver"] = SOLVER
m.solve()
r = m.var["r"].to_dict()
r = {i: int(round(r[i])) for i in r}
soln = boxes.copy()
soln["x1"] = m.var["x1"].to_dict()
soln["x2"] = m.var["x2"].to_dict()
soln["y1"] = m.var["y1"].to_dict()
soln["y2"] = m.var["y2"].to_dict()
soln["r"] = r
return soln
soln = pack_boxes_V3(boxes, D)
display(soln)
show_boxes(soln, D)
w d x1 x2 y1 y2 r
0 82 103 0.000000 82.000000 151.0 254.0 0
1 73 48 217.999999 265.999999 126.0 199.0 1
2 171 53 94.999999 265.999999 205.0 258.0 0
3 73 99 0.000000 99.000000 271.0 344.0 1
4 167 85 99.000000 265.999999 258.0 343.0 0
5 151 172 0.000000 172.000000 0.0 151.0 1
6 54 130 82.000000 212.000000 151.0 205.0 1
7 126 94 171.999999 265.999999 0.0 126.0 1
One of the issues in combinatorial problem is the challenge of symmetries. A symmetry is a situation where a change in
solution configuration leaves the objective unchanged. Strip packing problems are especially susceptible to symmetries.
Symmetries can significantly increase the effort needed to find and and verify an optimal solution. Trespalacios and
Grossmann recently presented modification to the strip packing problem to reduce the number of symmetries. This is
described in the following paper and implemented in the accompanying AMPL model.
Trespalacios, F., & Grossmann, I. E. (2017). Symmetry breaking for generalized disjunctive programming formulation
of the strip packing problem. Annals of Operations Research, 258(2), 747-759.
%%writefile pack_boxes_V4.mod
set BOXES;
set PAIRS within {BOXES,BOXES};
param w{BOXES};
param d{BOXES};
param D;
param W_ub;
minimize minimize_width: W;
Overwriting pack_boxes_V4.mod
BOXES = list(boxes.index)
PAIRS = [(i, j) for i in BOXES for j in BOXES if i < j]
m = AMPL()
m.read("pack_boxes_V4.mod")
m.set["BOXES"] = BOXES
m.set["PAIRS"] = PAIRS
m.param["w"] = boxes["w"]
m.param["d"] = boxes["d"]
m.param["W_ub"] = int(W_ub)
m.param["D"] = int(D)
m.option["solver"] = SOLVER
m.solve()
r = m.var["r"].to_dict()
r = {i: int(round(r[i])) for i in r}
soln = boxes.copy()
soln["x1"] = m.var["x1"].to_dict()
soln["x2"] = m.var["x2"].to_dict()
soln["y1"] = m.var["y1"].to_dict()
soln["y2"] = m.var["y2"].to_dict()
soln["r"] = r
return soln
soln = pack_boxes_V4(boxes, D)
display(soln)
show_boxes(soln, D)
w d x1 x2 y1 y2 r
0 82 103 178.0 260.0 0.0 103.0 0
1 73 48 0.0 48.0 53.0 126.0 1
2 171 53 0.0 171.0 0.0 53.0 0
3 73 99 167.0 266.0 258.0 331.0 1
4 167 85 0.0 167.0 258.0 343.0 0
5 151 172 94.0 266.0 107.0 258.0 1
6 54 130 48.0 178.0 53.0 107.0 1
7 126 94 0.0 94.0 126.0 252.0 1
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
The following example of a job shop is from from Christelle Gueret, Christian Prins, Marc Sevaux, “Applications of
Optimization with Xpress-MP,” Dash Optimization, 2000.
In this example, there are three printed paper products that must pass through color printing presses in a particular order.
The given data consists of a flow sheet showing the order in which each job passes through the color presses
and a table of data showing, in minutes, the amount of time each job requires on each machine.
What is the minimum amount of time (i.e, what is the makespan) for this set of jobs?
The first step in the analysis is to decompose the process into a series of tasks. Each task c(job, machine) pair. Some
tasks cannot start until a prerequisite task is completed.
We convert this to a JSON style representation where tasks are denoted by (Job,Machine) tuples in Python. The task data
is stored in a Python dictionary indexed by (Job,Machine) tuples. The task data consists of a dictionary with duration
(‘dur’) and (Job,Machine) pair for any prerequisite task.
TASKS = {
("Paper_1", "Blue"): {"dur": 45, "prec": None},
("Paper_1", "Yellow"): {"dur": 10, "prec": ("Paper_1", "Blue")},
("Paper_2", "Blue"): {"dur": 20, "prec": ("Paper_2", "Green")},
("Paper_2", "Green"): {"dur": 10, "prec": None},
("Paper_2", "Yellow"): {"dur": 34, "prec": ("Paper_2", "Blue")},
("Paper_3", "Blue"): {"dur": 12, "prec": ("Paper_3", "Yellow")},
("Paper_3", "Green"): {"dur": 17, "prec": ("Paper_3", "Blue")},
("Paper_3", "Yellow"): {"dur": 28, "prec": None},
}
Each task is indexed by an ordered pair (𝑗, 𝑚) where 𝑗 refers to a job, and 𝑚 refers to a machine. Associated with each
task is data describing the time needed to perform the task, and a preceding task that must be completed before the index
task can start.
Parameter Description
dur𝑗,𝑚 Duration of task (𝑗, 𝑚)
prec𝑗,𝑚 A task (𝑘, 𝑛) = prec𝑗,𝑚 that must be completed before task (𝑗, 𝑚)
The choice of decision variables for this problem are key to modeling. We introduce 𝑚𝑎𝑘𝑒𝑠𝑝𝑎𝑛 as the time needed to
complete all tasks. 𝑚𝑎𝑘𝑒𝑠𝑝𝑎𝑛 is a candidate objective function. Variable 𝑠𝑡𝑎𝑟𝑡𝑗,𝑚 denotes the time when task (𝑗, 𝑚)
begins.
The constraints include lower bounds on the start and an upper bound on the completion of each task (𝑗, 𝑚)
start𝑗,𝑚 ≥ 0 (3.1)
start𝑗,𝑚 + dur𝑗,𝑚 ≤ makespan
(3.2)
Any preceding tasks must be completed before task (𝑗, 𝑚) can start.
Finally, for every task performed on machine 𝑚, there can be no overlap among those tasks. This leads to a set of pair-wise
disjunctive constraints for each machine.
The job shop scheduling problem is implemented below in AMPL. The implementation consists of a function job-
shop_model(TASKS) that accepts a dictionary of tasks and returns an AMPL model.
%%writefile jobshop_model.mod
set JOBS;
set MACHINES;
set TASKS within {JOBS, MACHINES};
set TASKORDER within {TASKS, TASKS};
set DISJUNCTIONS within {JOBS, JOBS, MACHINES};
param dur{TASKS};
param ub;
Overwriting jobshop_model.mod
def jobshop_model(TASKS):
m = AMPL()
m.read("jobshop_model.mod")
m.set["JOBS"] = jobs
m.set["MACHINES"] = machines
m.set["TASKS"] = tasks
m.set["TASKORDER"] = taskorder
m.set["DISJUNCTIONS"] = disjunctions
m.param["dur"] = dur
m.param["ub"] = ub
return m
jobshop_model(TASKS)
<amplpy.ampl.AMPL at 0x7f54c3021600>
def jobshop_solve(model):
model.option["solver"] = SOLVER
model.solve()
start = model.var["start"].to_dict()
dur = model.param["dur"].to_dict()
tasks = model.set["TASKS"].to_dict()
results = [
{
"Job": j,
"Machine": m,
"Start": start[j, m],
"Duration": dur[j, m],
"Finish": start[j, m] + dur[j, m],
}
for j, m in tasks
]
return results
def jobshop(TASKS):
return jobshop_solve(jobshop_model(TASKS))
results = jobshop(TASKS)
results
[{'Job': 'Paper_1',
'Machine': 'Blue',
'Start': 41.99999999999993,
'Duration': 45.0,
'Finish': 86.99999999999993},
{'Job': 'Paper_1',
'Machine': 'Yellow',
'Start': 86.99999999999993,
'Duration': 10.0,
'Finish': 96.99999999999993},
{'Job': 'Paper_2',
'Machine': 'Blue',
'Start': 9.999999999999972,
'Duration': 20.0,
'Finish': 29.99999999999997},
{'Job': 'Paper_2',
'Machine': 'Green',
'Start': 0.0,
'Duration': 10.0,
'Finish': 10.0},
{'Job': 'Paper_2',
'Machine': 'Yellow',
'Start': 29.99999999999997,
'Duration': 34.0,
'Finish': 63.99999999999997},
{'Job': 'Paper_3',
'Machine': 'Blue',
'Start': 29.99999999999997,
'Duration': 12.0,
'Finish': 41.99999999999997},
{'Job': 'Paper_3',
'Machine': 'Green',
'Start': 79.99999999999997,
'Duration': 17.0,
'Finish': 96.99999999999997},
{'Job': 'Paper_3',
'Machine': 'Yellow',
'Start': 1.999999999999972,
'Duration': 28.0,
'Finish': 29.99999999999997}]
import pandas as pd
schedule = pd.DataFrame(results)
print("\nSchedule by Job")
print(schedule.sort_values(by=["Job", "Start"]).set_index(["Job", "Machine"]))
print("\nSchedule by Machine")
(continues on next page)
Schedule by Job
Start Duration Finish
Job Machine
Paper_1 Blue 42.0 45.0 87.0
Yellow 87.0 10.0 97.0
Paper_2 Green 0.0 10.0 10.0
Blue 10.0 20.0 30.0
Yellow 30.0 34.0 64.0
Paper_3 Yellow 2.0 28.0 30.0
Blue 30.0 12.0 42.0
Green 80.0 17.0 97.0
Schedule by Machine
Start Duration Finish
Machine Job
Blue Paper_2 10.0 20.0 30.0
Paper_3 30.0 12.0 42.0
Paper_1 42.0 45.0 87.0
Green Paper_2 0.0 10.0 10.0
Paper_3 80.0 17.0 97.0
Yellow Paper_3 2.0 28.0 30.0
Paper_2 30.0 34.0 64.0
Paper_1 87.0 10.0 97.0
def visualize(results):
schedule = pd.DataFrame(results)
JOBS = sorted(list(schedule["Job"].unique()))
MACHINES = sorted(list(schedule["Machine"].unique()))
makespan = schedule["Finish"].max()
schedule.sort_values(by=["Job", "Start"])
schedule.set_index(["Job", "Machine"], inplace=True)
ax[0].set_title("Job Schedule")
ax[0].set_ylabel("Job")
ax[1].set_title("Machine Schedule")
ax[1].set_ylabel("Machine")
fig.tight_layout()
visualize(results)
We will now turn our attention to the application of the job shop scheduling problem to the short term scheduling of batch
processes. We illustrate these techniques using Example II from Dunn (2013).
Before going further, we create a function to streamline the generation of the TASKS dictionary.
recipeA = recipe_to_tasks(
"A", ["Mixer", "Reactor", "Separator", "Packaging"], [1, 5, 4, 1.5]
)
visualize(jobshop(recipeA))
Let’s now consider an optimal scheduling problem where we are wish to make two batches of Product A.
TASKS = recipe_to_tasks(
["A1", "A2", "A3", "A4"],
["Mixer", "Reactor", "Separator", "Packaging"],
[1, 5, 4, 1.5],
)
results = jobshop(TASKS)
visualize(results)
print("Makespan =", max([task["Finish"] for task in results]))
Earlier we found that it took 11.5 hours to produce one batch of product A. As we see here, we can produce a second
batch with only 5.0 additional hours because some of the tasks overlap. The overlapping of tasks is the key to gaining
efficiency in batch processing facilities.
Let’s next consider production of a single batch each of products A, B, and C.
for k, v in TASKS.items():
print(k, v)
results = jobshop(TASKS)
visualize(results)
print("Makespan =", max([task["Finish"] for task in results]))
The individual production of A, B, and C required 11.5, 5.5, and 9.5 hours, respectively, for a total of 25.5 hours. As we
see here, by scheduling the production simultaneously, we can get all three batches done in just 15 hours.
As we see below, each additional set of three products takes an additional 13 hours. So there is considerable efficiency
gained by scheduling over longer intervals whenever possible.
TASKS = recipe_to_tasks(
["A1", "A2"], ["Mixer", "Reactor", "Separator", "Packaging"], [1, 5, 4, 1.5]
)
TASKS.update(recipe_to_tasks(["B1", "B2"], ["Separator", "Packaging"], [4.5, 1]))
TASKS.update(
recipe_to_tasks(["C1", "C2"], ["Separator", "Reactor", "Packaging"], [5, 3, 1.5])
)
results = jobshop(TASKS)
visualize(results)
print("Makespan =", max([task["Finish"] for task in results]))
A common feature of batch unit operations is a requirement that equipment be cleaned prior to reuse.
In most cases the time needed for clean out would be specific to the equipment and product. But for the purposes this
notebook, we implement can implement a simple clean out policy with a single non-negative parameter 𝑡𝑐𝑙𝑒𝑎𝑛 ≥ 0 which,
if specified, requires a period no less than 𝑡𝑐𝑙𝑒𝑎𝑛 between the finish of one task and the start of another on every piece of
equipment.
This policy is implemented by modifying the usual disjunctive constraints to avoid machine conflicts to read
set JOBS;
set MACHINES;
set TASKS within {JOBS, MACHINES};
set TASKORDER within {TASKS, TASKS};
set DISJUNCTIONS within {JOBS, JOBS, MACHINES};
param dur{TASKS};
param ub;
Overwriting jobshop_model_clean.mod
m.set["JOBS"] = jobs
m.set["MACHINES"] = machines
m.set["TASKS"] = tasks
m.set["TASKORDER"] = taskorder
m.set["DISJUNCTIONS"] = disjunctions
m.param["dur"] = dur
m.param["ub"] = ub
return m
One of the issues in the use of job shop scheduling for chemical process operations are situations where there it is not
possible to store intermediate materials. If there is no way to store intermediates, either in the processing equipment or
in external vessels, then a zero-wait policy may be required.
A zero-wait policy requires subsequent processing machines to be available immediately upon completion of any task. To
implement this policy, the usual precident sequencing constraint of a job shop scheduling problem, i.e.,
is changed to
%%writefile jobshop_model_clean_zw.mod
set JOBS;
set MACHINES;
set TASKS within {JOBS, MACHINES};
set TASKORDER within {TASKS, TASKS};
set DISJUNCTIONS within {JOBS, JOBS, MACHINES};
param dur{TASKS};
(continues on next page)
Overwriting jobshop_model_clean_zw.mod
m.set["JOBS"] = jobs
m.set["MACHINES"] = machines
m.set["TASKS"] = tasks
m.set["TASKORDER"] = taskorder
m.set["DISJUNCTIONS"] = disjunctions
m.param["dur"] = dur
m.param["ub"] = ub
m.param["tclean"] = tclean
m.param["ZW"] = 1 if ZW else 0
m.option["solver"] = SOLVER
3.9.9 References
• Applegate, David, and William Cook. “A computational study of the job-shop scheduling problem.” ORSA Journal
on computing 3, no. 2 (1991): 149-156. pdf available
• Beasley, John E. “OR-Library: distributing test problems by electronic mail.” Journal of the operational research
society 41, no. 11 (1990): 1069-1072. OR-Library
• Guéret, Christelle, Christian Prins, and Marc Sevaux. “Applications of optimization with Xpress-MP.” contract
(1999): 00034.
• Manne, Alan S. “On the job-shop scheduling problem.” Operations Research 8, no. 2 (1960): 219-223.
3.9.10 Exercises
Clean out operations are often slow and time consuming. Further more, the time required to perform a clean out frequently
depends on the type of machine, and the task performed by the machine. For this exercise, create a data format to include
task-specific clean out times, and model the job shop model to accommodate this additional information.
Repeat the benchmark problem calculation, but with a zero-wait policy. Does the execution time increase or descrease
as a consequence of specifying zero-wait?
A process unit is operating over a maintenance planning horizon from 1 to 𝑇 days. On day 𝑡 the unit makes a profit 𝑐[𝑡]
which is known in advance. The unit needs to shut down for 𝑃 maintenance periods during the planning period. Once
started, a maintenance period takes 𝑀 days to finish.
Find a maintenance schedule that allows the maximum profit to be produced.
3.10.2 Imports
SOLVER = "cbc"
ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
The model is comprised of two sets of the binary variables indexed 1 to 𝑇 . Binary variables 𝑥𝑡 correspond to the operating
mode of the process unit, with 𝑥𝑡 = 1 indicating the unit is operating on day 𝑡 and able to earn a profit 𝑐𝑡 . Binary variable
𝑦𝑡 = 1 indicates the first day of a maintenance period during which the unit is not operating and earning 0 profit.
Objective
The planning objective is to maximize profit realized during the days the plant is operational.
𝑇
Profit = max ∑ 𝑐𝑡 𝑥𝑡
𝑥,𝑦
𝑡=1
Constraints
This last requirement could be modified if some period of time should occur between maintenance periods.
The unit must shut down for M days following a maintenance start.
𝑀−1
The final requirement is a disjunctive constraint that says either 𝑦𝑡 = 0 or the sum ∑𝑠 𝑥𝑡+𝑠 = 0, but not both.
Mathematically, this forms a set of constraints reading
𝑀−1
(𝑦𝑡 = 0) ⊻ ( ∑ 𝑥𝑡+𝑠 = 0) ∀𝑡 = 1, 2, … , 𝑇 − 𝑀 + 1
𝑠=0
Parameter values
# problem parameters
T = 90 # planning period from 1..T
M = 3 # length of maintenance period
P = 4 # number of maintenance periods
(continues on next page)
# daily profits
np.random.seed(0)
c = {k: np.random.uniform() for k in range(1, T + 1)}
AMPL model
The disjunctive constraints can be represented directly in AMPL using Logic, Nonlinear & Constraint Programming
Extensions. The extension transforms the disjunctive constraints to a mixed integer linear optimization (MILO) problem
using convex hull and cutting plane methods.
%%writefile maintenance_planning.mod
Writing maintenance_planning.mod
class MP(object):
def __init__(self, c, M, P):
self.T = len(c)
self.M = M
self.P = P
self.c = c
self.modfile = "maintenance_planning.mod"
self.ampl = AMPL()
self.ampl.option["solver"] = SOLVER
self.solved = False
def load_data(self):
self.ampl.param["T"] = self.T
self.ampl.param["P"] = self.P
(continues on next page)
def solve(self):
self.ampl.read(self.modfile)
self.load_data()
self.ampl.solve()
self.solved = True
def plot_schedule(self):
if not self.solved:
self.solve()
T = self.T
M = self.M
P = self.P
PH = list(range(1, T + 1))
Y = list(range(1, T - M + 2))
S = list(range(M))
x = self.ampl.var["x"].to_dict()
y = self.ampl.var["y"].to_dict()
plt.tight_layout()
model = MP(c, M, P)
model.plot_schedule()
Prior to maintenance shutdown, a large processing unit may take some time to safely ramp down from full production.
And then require more time to safely ramp back up to full production following maintenance. To provide for ramp-down
and ramp-up periods, we modify the problem formation in the following ways.
• The variable denoting unit operation, 𝑥𝑡 is changed from a binary variable to a continuous variable 0 ≤ 𝑥𝑡 ≤ 1
denoting the fraction of total capacity at which the unit is operating on day 𝑡.
+,max −,max
• Two new variable sequences, 0 ≤ 𝑢+ 𝑡 ≤ 𝑢𝑡 and 0 ≤ 𝑢− 𝑡 ≤ 𝑢𝑡 , are introduced which denote the fraction
increase or decrease in unit capacity to completed on day 𝑡.
• An additional sequence of equality constraints is introduced relating 𝑥𝑡 to 𝑢+ −
𝑡 and 𝑢𝑡 .
𝑥𝑡 = 𝑥𝑡−1 + 𝑢+ −
𝑡 − 𝑢𝑡
We begin the AMPL model by specifying the constraints, then modifying the Big-M formulation to add the features
described above.
%%writefile maintenance_planning_ramp.mod
param upos_max;
param uneg_max;
Writing maintenance_planning_ramp.mod
class MPRamp(MP):
def __init__(self, c, M, P, upos_max, uneg_max):
super(MPRamp, self).__init__(c, M, P)
super(MPRamp, self).set_modfile("maintenance_planning_ramp.mod")
self.upos_max = upos_max
self.uneg_max = uneg_max
def load_data(self):
super(MPRamp, self).load_data()
self.ampl.param["upos_max"] = self.upos_max
self.ampl.param["uneg_max"] = self.uneg_max
upos_max = 0.3334
uneg_max = 0.5000
Up to this point we have imposed no constraints on the frequency of maintenance periods. Without such constraints,
particularly when ramping constraints are imposed, is that maintenance periods will be scheduled back-to-back, which is
clearly not a useful result for most situations.
The next revision of the model is to incorporate a requirement that 𝑁 operational days be scheduled between any mainte-
nance periods. This does allow for maintenance to be postponed until the very end of the planning period. The disjunctive
constraints read
(𝑀+𝑁−1)∧(𝑡+𝑠≤𝑇 )
(𝑦𝑡 = 0) ⊻ ⎛
⎜ ∑ 𝑥𝑡+𝑠 = 0⎞
⎟ ∀𝑡 = 1, 2, … , 𝑇 − 𝑀 + 1
⎝ 𝑠=0 ⎠
where the upper bound on the summation is needed to handle the terminal condition.
Paradoxically, this is an example where the Big-M method provides a much faster solution.
(𝑀+𝑁−1)∧(𝑡+𝑠≤𝑇 )
∑ 𝑥𝑡+𝑠 ≤ (𝑀 + 𝑁 )(1 − 𝑦𝑡 ) ∀𝑡 = 1, 2, … , 𝑇 − 𝑀 + 1
𝑠=0
param upos_max;
param uneg_max;
# ramp constraint
s.t. ramp {t in PH: t > 1}:
x[t] == x[t-1] + upos[t] - uneg[t];
# required number P of maintenance starts
s.t. sumy: P == sum{t in Y} y[t];
# no more than one maintenance start in the period of length M
s.t. sprd {t in Y}:
sum{s in W: t + s <= T} y[t+s] <= 1;
Writing maintenance_planning_ramp_operational.mod
class MPRampOperational(MPRamp):
def __init__(self, c, M, P, upos_max, uneg_max, N):
super(MPRampOperational, self).__init__(c, M, P, upos_max, uneg_max)
super(MPRampOperational, self).set_modfile(
"maintenance_planning_ramp_operational.mod"
)
self.N = N
def load_data(self):
super(MPRampOperational, self).load_data()
self.ampl.param["N"] = self.N
3.10.7 Exercises
1. Rather than specify how many maintenance periods must be accommodated, modify the model so that the process
unit can operate no more than 𝑁 days without a maintenance shutdown. (Hint. You may to introduce an additional
set of binary variables, 𝑧𝑡 to denote the start of an operational period.)
2. Do a systematic comparison of the Big-M, Convex Hull, and Cutting Plane techniques for implementing the dis-
junctive constraints. Your comparison should include a measure of complexity (such as the number of decision
variables and constraints in the resulting transformed problems), computational effort, and the effect of solver (such
as HiGHS vs cbc).
FOUR
4. NETWORK OPTIMIZATION
In the previous chapters, we dealt with general problems by first, formulating all the necessary constraints, and then,
passing the problem to an LP or MILP solver. In a way, we were blind to the problem structure. However, it is often
worth studying the problem structure first, as exploiting it can give us ideas for better solution methods. In this chapter,
we consider a very general class of problems with such a special structure – the network flow problems.
In this chapter, there is a number of examples with companion AMPL implementation that explore various modeling and
implementation aspects of network optimization:
• A dinner seating arrangement problem
• A franchise gasoline distribution problem
• A cryptocurrency arbitrage problem
• Extra material: Energy dispatch problem
• Extra material: Forex arbitrage
Go to the next chapter about convex optimization.
SOLVER = "cbc"
ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
170
Hands-On Mathematical Optimization with AMPL in Python
Consider organizing a wedding dinner. Your objective is for guests from different families to mingle with each other. One
way to encourage mingling is to seat people at the tables so that no more people than 𝑘 members of the same family take
a seat at the same table.
How could we solve a problem like this? First, we need the problem data – for each family 𝑓 we need to know the number
of its members 𝑚𝑓 , and for each table 𝑡 we need to know its capacity 𝑐𝑡 . Using this data and the tools we learned so far,
we can formulate this problem as a linear optimization (LO).
We can use variable 𝑥𝑓𝑡 for the number of persons from family 𝑓 to be seated at table 𝑡. Since we were not provided with
any objective function, we can focus on finding a feasible solution by setting the objective function to be constant, say 0,
which means that we do not differentiate between feasible solutions.
The mathematical formulation of this seating problem is:
min 0
𝑥𝑓𝑡
s.t. ∑ 𝑥𝑓𝑡 ≤ 𝑐𝑡 ∀𝑡 ∈ 𝑇
𝑓
∑ 𝑥𝑓𝑡 = 𝑚𝑓 ∀𝑓 ∈ 𝐹
𝑡
0 ≤ 𝑥𝑓𝑡 ≤ 𝑘.
The constraints ensure that the seating capacity for each table is not exceeded, that each family is fully seated, and that
the number of elements of each family at each table does not exceed the threshold 𝑘.
4.1.2 Implementation
The problem statement will be satisfied by finding a feasible solution, if one exists. Because no specific objective has been
specified, the mathematical formulation uses a constant 0 as essentially a placeholder for the objective function. Some
optimization solvers, however, issue warning messages if they detect a constant objective, and others will fail to execute at
all. A simple work around for these cases is to replace the constant objective with a ‘dummy’ variable that doesn’t appear
elsewhere in the optimization problem.
%%writefile table_seat.mod
set F;
set T;
param M{F};
param C{T};
param k;
Overwriting table_seat.mod
m.set["F"] = range(len(members))
m.set["T"] = range(len(capacity))
m.param["M"] = members
m.param["C"] = capacity
m.param["k"] = k
m.option["solver"] = SOLVER
return m
def get_solution(model):
df = model.var["x"].to_pandas()
df.index.names = ["family", "table"]
df = df.unstack()
df.columns = df.columns.get_level_values(1)
return df.round(5)
Let us now consider and solve a specific instance of this problem with six families with sizes 𝑚 = (6, 8, 2, 9, 13, 1), five
tables with capacities 𝑐 = (8, 8, 10, 4, 9), and a threshold 𝑘 = 3 for the number of members of each family that can be
seated at the same table.
table 0 1 2 3 4
family
0 0.0 3.0 1.0 0.0 2.0
1 3.0 0.0 3.0 0.0 2.0
2 0.0 0.0 0.0 0.0 2.0
3 1.0 2.0 3.0 3.0 0.0
(continues on next page)
objective: 0.0
places at table: [8.0, 8.0, 10.0, 4.0, 9.0]
members seated: [6.0, 8.0, 2.0, 9.0, 13.0, 1.0]
A peculiar fact is that although we did not explicitly require that all variables 𝑥𝑓𝑡 be integer, the optimal solution turned
out to be integer anyway. This is no coincidence as it follows from a certain property of the problem we solve. This also
means we can solve larger versions of this problem with LO instead of MILO solvers to find integer solutions, gaining a
large computational advantage.
Our objective was that we make members of different families mingle as much as possible. Is 𝑘 = 3 the lowest possible
number for which a feasible table allocation exists or can we make the tables even more diverse by bringing this number
down?
In order to find out, we change the objective function and try to minimize 𝑘, obtaining the following problem:
%%writefile table_seat_minimize_max_group_at_table.mod
set F;
set T;
param M{F};
param C{T};
minimize goal: k;
Overwriting table_seat_minimize_max_group_at_table.mod
m.set["F"] = range(len(members))
m.set["T"] = range(len(capacity))
m.param["M"] = members
m.param["C"] = capacity
m.option["solver"] = SOLVER
return m
seatplan = table_seat_minimize_max_group_at_table(
members=[6, 8, 2, 9, 13, 1], capacity=[8, 8, 10, 4, 9]
)
%time results = seatplan.solve()
report(seatplan, results, type=float)
table 0 1 2 3 4
family
0 0.0 2.6 0.8 0.0 2.6
1 2.6 0.2 2.6 0.0 2.6
2 0.0 0.0 0.8 0.0 1.2
3 2.6 2.6 2.4 1.4 0.0
4 2.6 2.6 2.6 2.6 2.6
5 0.2 0.0 0.8 0.0 0.0
objective: 2.6
places at table: [8.0, 8.0, 10.0, 4.0, 9.0]
members seated: [6.0, 8.0, 2.0, 9.0, 13.0, 1.0]
Unfortunately, this solution is no longer integer. Mathematically, this is because the “structure” that previously ensured
integer solutions at no extra cost has been lost as a result of making 𝑘 a decision variable. To find the solution to this
problem we would need to impose that the variables are integers.
We now add an integer variable y to minimize the number of tables in our model. In order to better compare the
diferences between a LO and a MILO implementation, we introduce the AMPL option relax_integrality. When
the option relax_integrality is set to 1 all the integer variables in the model are treated as linear. Conversely, if
relax_integrality is set to 0 the integrality restrictions are restored in the model.
%%writefile table_seat_minimize_number_of_tables.mod
set F;
set T;
param M{F};
param C{T};
param k;
Overwriting table_seat_minimize_number_of_tables.mod
m.set["F"] = range(len(members))
m.set["T"] = range(len(capacity))
m.param["M"] = members
m.param["C"] = capacity
m.param["k"] = k
m.option["solver"] = SOLVER
if relax:
m.option["relax_integrality"] = 1
return m
print("\nMILO problem\n")
seatplan = table_seat_minimize_number_of_tables(members, capacity, k)
%time results = seatplan.solve()
report(seatplan, results, type=int)
print("\nRelaxed problem\n")
seatplan = table_seat_minimize_number_of_tables(members, capacity, k, relax=True)
%time results = seatplan.solve()
report(seatplan, results, type=float)
MILO problem
table 0 1 2 3 4
family
0 2 0 3 0 1
1 0 2 3 0 3
2 0 0 0 2 0
3 3 3 1 1 1
4 3 3 3 1 3
5 0 0 0 0 1
objective: 5.0
places at table: [8, 8, 10, 4, 9]
members seated: [6, 8, 2, 9, 13, 1]
table 0 1 2 3 4
family
0 0.0 2.0 1.0 0.0 3.0
1 0.0 2.0 3.0 0.0 3.0
2 1.0 0.0 0.0 1.0 0.0
3 3.0 3.0 3.0 0.0 0.0
4 3.0 1.0 3.0 3.0 3.0
5 1.0 0.0 0.0 0.0 0.0
objective: 5.0
places at table: [8.0, 8.0, 10.0, 4.0, 9.0]
members seated: [6.0, 8.0, 2.0, 9.0, 13.0, 1.0]
However, using an MILO solver is not necessarily the best approach for problems like this. Many real-life situations
(assigning people to work/course groups) require solving really large problems. There are existing algorithms that can
leverage the special network structure of the problem at hand and scale better than LO solvers. To see this we can
visualize the seating problem using a graph where:
• the nodes on the left-hand side stand for the families and the numbers next to them provide the family size
• the nodes on the left-hand side stand for the tables and the numbers next to them provide the table size
• each left-to-right arrow stand comes with a number denoting the capacity of arc (𝑓, 𝑡) – how many people of family
𝑓 can be assigned to table 𝑡.
If we see each family as a place of supply (people) and tables as places of demand (people), then we can see our original
problem as literally sending people from families 𝑓 to tables 𝑡 so that everyone is assigned to some table, the tables’
capacities are respected, and no table gets more than 𝑘 = 3 members of the same family.
An AMPL version of this model is given in the next cell. After that we will show how to reformulate the calculation using
network algorithms.
%%writefile table_seat_maximize_members_flow_to_tables.mod
set F;
set T;
param M{F};
param C{T};
param k;
Overwriting table_seat_maximize_members_flow_to_tables.mod
m.set["F"] = range(len(members))
m.set["T"] = range(len(capacity))
m.param["M"] = members
m.param["C"] = capacity
m.param["k"] = k
m.option["solver"] = SOLVER
if relax:
m.option["relax_integrality"] = 1
return m
print("\nMILO problem\n")
seatplan = table_seat_maximize_members_flow_to_tables(members, capacity, k)
%time results = seatplan.solve()
report(seatplan, results, type=int)
print("\nRelaxed problem\n")
seatplan = table_seat_maximize_members_flow_to_tables(members, capacity, k,␣
↪relax=True)
MILO problem
table 0 1 2 3 4
family
0 1 2 3 0 0
1 1 0 3 1 3
2 0 2 0 0 0
3 3 0 1 2 3
4 3 3 3 1 3
5 0 1 0 0 0
objective: 39.0
places at table: [8, 8, 10, 4, 9]
members seated: [6, 8, 2, 9, 13, 1]
Relaxed problem
(continues on next page)
table 0 1 2 3 4
family
0 0.0 3.0 0.0 0.0 3.0
1 1.0 1.0 3.0 0.0 3.0
2 0.0 0.0 2.0 0.0 0.0
3 3.0 3.0 2.0 1.0 0.0
4 3.0 1.0 3.0 3.0 3.0
5 1.0 0.0 0.0 0.0 0.0
objective: 39.0
places at table: [8.0, 8.0, 10.0, 4.0, 9.0]
members seated: [6.0, 8.0, 2.0, 9.0, 13.0, 1.0]
By adding two more nodes to the graph above, we can formulate the problem as a slightly different flow problem where all
the data is formulated as the arc capacity, see figure below. In a network like this, we can imagine a problem of sending
resources from the root node “door” to the sink node “seat”, subject to the restriction that for any node apart from 𝑠 and
𝑡, the sum of incoming and outgoing flows are equal (balance constraint). If there exists a flow in this new graph that
respects the arc capacities and the sum of outgoing flows at 𝑠 is equal to ∑𝑓∈𝐹 𝑚𝑓 = 39, it means that there exists a
family-to-table assignment that meets our requirements.
# add edges
G.add_edges_from(["door", f, {"capacity": n}] for f, n in zip(families, members))
G.add_edges_from([(f, t) for f in families for t in tables], capacity=k)
G.add_edges_from([t, "seat", {"capacity": n}] for t, n in zip(tables, capacity))
return G
pd.DataFrame(flow_dict).loc[tables, families].astype("int")
f0 f1 f2 f3 f4 f5
t0 2 0 0 2 3 1
t1 0 1 1 3 3 0
t2 1 3 0 3 3 0
t3 0 1 0 0 3 0
t4 3 3 1 1 1 0
Even for this very small example, we see that network algorithms generate a solution significantly faster.
This notebook presents a transportation model to optimally allocate the delivery of a commodity from multiple sources
to multiple destinations. The model invites a discussion of the pitfalls in optimizing a global objective for customers who
may have an uneven share of the resulting benefits, then through model refinement arrives at a group cost-sharing plan to
delivery costs.
Didactically, notebook presents techniques for AMPL modeling and reporting including:
• Accessing the duals (i.e., shadow prices)
• Methods for reporting the solution and duals.
– .display() method for AMPL entities
– Manually formatted reports
– Pandas
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).
import pandas as pd
from IPython.display import HTML, display
import matplotlib.pyplot as plt
YaYa Gas-n-Grub is franchiser and operator for a network of regional convenience stores selling gasoline and convenience
items in the United States. Each store is individually owned by a YaYa Gas-n-Grub franchisee who pays a fee to the
franchiser for services. Gasoline is delivered by truck from regional distribution terminals. Each delivery truck carries
8,000 gallons delivered at a fixed charge of 700€ per delivery, or 0.0875€ per gallon. The franchise owners are eager to
reduce delivery costs to boost profits. YaYa Gas-n-Grub decides to accept proposals from other distribution terminals, “A”
and “B”, to supply the franchise operators. Rather than a fixed fee per delivery, they proposed pricing based on location.
But they already have existing customers, “A” and “B” can only provide a limited amount of gasoline to new customers
totaling 100,000 and 80,000 gallons respectively. The only difference between the new suppliers and the current supplier
is the delivery charge.
The following chart shows the pricing of gasoline delivery in cents/gallon.
The operator of YaYa Gas-n-Grub has the challenge of allocating the gasoline delivery to minimize the cost to the franchise
owners. The following model will present a global objective to minimize the total cost of delivery to all franchise owners.
The decision variables for this example are labeled 𝑥𝑑,𝑠 where subscript 𝑑 ∈ 1, … , 𝑛𝑑 refers to the destination of the
delivery and subscript 𝑠 ∈ 1, … , 𝑛𝑠 to the source. The value of 𝑥𝑑,𝑠 is the volume of gasoline shipped to destination 𝑑
from source 𝑠. Given the cost rate 𝑟𝑑,𝑠 for shipping one unit of goods from 𝑑 to 𝑠, the objective is to minimize the total
cost of transporting gasoline from the sources to the destinations subject to meeting the demand requirements, 𝐷𝑑 , at all
destinations, and satisfying the supply constraints, 𝑆𝑠 , at all sources. The full mathematical formulation is:
𝑛𝑑 𝑛𝑠
min ∑ ∑ 𝑟𝑑,𝑠 𝑥𝑑,𝑠
𝑑=1 𝑠=1
𝑛𝑠
s.t. ∑ 𝑥𝑑,𝑠 = 𝐷𝑑 ∀ 𝑑 ∈ 1, … , 𝑛𝑑 (demand constraints)
𝑠=1
𝑛𝑑
∑ 𝑥𝑑,𝑠 ≤ 𝑆𝑠 ∀ 𝑠 ∈ 1, … , 𝑛𝑠 (supply constraints)
𝑑=1
The data is stored into Pandas DataFrame and Series objects. Note the use of a large rates to avoid assigning shipments
to destination-source pairs not allowed by the problem statement.
rates = pd.DataFrame(
[
["Alice", 8.3, 10.2, 8.75],
["Badri", 8.1, 12.0, 8.75],
["Cara", 8.3, 100.0, 8.75],
["Dan", 9.3, 8.0, 8.75],
["Emma", 10.1, 10.0, 8.75],
["Fujita", 9.8, 10.0, 8.75],
["Grace", 100, 8.0, 8.75],
["Helen", 7.5, 10.0, 8.75],
],
columns=["Destination", "Terminal A", "Terminal B", "Current Supplier"],
).set_index("Destination")
demand = pd.Series(
{
"Alice": 30000,
"Badri": 40000,
"Cara": 50000,
"Dan": 20000,
"Emma": 30000,
"Fujita": 45000,
"Grace": 80000,
"Helen": 18000,
},
name="demand",
)
supply = pd.Series(
{"Terminal A": 100000, "Terminal B": 80000, "Current Supplier": 500000},
name="supply",
)
<IPython.core.display.HTML object>
supply
Terminal A 100000
Terminal B 80000
Current Supplier 500000
<IPython.core.display.HTML object>
demand
Alice 30000
Badri 40000
Cara 50000
Dan 20000
Emma 30000
Fujita 45000
Grace 80000
Helen 18000
<IPython.core.display.HTML object>
The following AMPL model is an implementation of the mathematical optimization model described above. The sets
and indices have been designated with more descriptive symbols readability and maintenance.
%%writefile transport.mod
set SOURCES;
set DESTINATIONS;
minimize total_cost:
sum{dst in DESTINATIONS, src in SOURCES} Rates[dst, src] * x[dst, src];
Writing transport.mod
m.set["SOURCES"] = rates.columns
m.set["DESTINATIONS"] = rates.index
m.param["Rates"] = rates
m.param["supply"] = supply
m.param["demand"] = demand
m.option["solver"] = SOLVER
m.solve()
return m
def get_results(m):
results = m.var["x"].to_pandas().unstack()
results.columns = results.columns.get_level_values(1)
results = results.reindex(index=rates.index, columns=rates.columns)
results["current costs"] = 700 * demand / 8000
results["contract costs"] = m.var["cost_to_destination"].to_pandas()
results["savings"] = results["current costs"].round(1) - results[
"contract costs"
].round(1)
results["contract rate"] = round(results["contract costs"] / demand, 4)
results["marginal cost"] = m.con["demand_constraint"].to_pandas()
return results
model1_results = get_results(m)
model1_results.plot(y="savings", kind="bar")
<Axes: xlabel='Destination'>
Minimizing total costs provides no guarantee that individual franchise owners will benefit equally, or in fact benefit at all,
from minimizing total costs. In this example neither Emma or Fujita would save any money on delivery costs, and the
majority of savings goes to just two of the franchisees. Without a better distribution of the benefits there may be little
enthusiasm among the franchisees to adopt change. This observation motivates an attempt at a second model. In this case
the objective is minimize a common rate for the cost of gasoline distribution subject to meeting the demand and supply
constraints, 𝑆𝑠 , at all sources. The mathematical formulation of this different problem is as follows:
min 𝜌
𝑛𝑠
s.t. ∑ 𝑥𝑑,𝑠 = 𝐷𝑑 ∀ 𝑑 ∈ 1, … , 𝑛𝑑 (demand constraints)
𝑠=1
𝑛𝑑
∑ 𝑥𝑑,𝑠 ≤ 𝑆𝑠 ∀ 𝑠 ∈ 1, … , 𝑛𝑠 (supply constraints)
𝑑=1
𝑛𝑠
∑ 𝑟𝑑,𝑠 𝑥𝑑,𝑠 ≤ 𝜌𝐷𝑑 ∀𝑑 ∈ 1, … , 𝑛𝑑 (common cost rate)
𝑠=1
%%writefile transport2.mod
set SOURCES;
set DESTINATIONS;
Writing transport2.mod
m.set["SOURCES"] = rates.columns
m.set["DESTINATIONS"] = rates.index
m.param["Rates"] = rates
m.param["supply"] = supply
m.param["demand"] = demand
m.option["solver"] = SOLVER
m.solve()
return m
results.plot(y="savings", kind="bar")
plt.show()
The prior two models demonstrated some practical difficulties in realizing the benefits of a cost optimization plan. Model
1 will likely fail in a franchiser/franchisee arrangement because the realized savings would be fore the benefit of a few.
Model 2 was an attempt to remedy the problem by solving for an allocation of deliveries that would lower the cost rate that
would be paid by each franchisee directly to the gasoline distributors. Perhaps surprisingly, the resulting solution offered
no savings to any franchisee. Inspecting the data shows the source of the problem is that two franchisees, Emma and
Fujita, simply have no lower cost alternative than the current supplier. Therefore finding a distribution plan with direct
payments to the distributors that lowers everyone’s cost is an impossible task.
The third model addresses this problem with a plan to share the cost savings among the franchisees. In this plan, the
franchiser would collect delivery fees from the franchisees to pay the gasoline distributors. The optimization objective
returns to the problem to minimizing total delivery costs, but then adds a constraint that defines a common cost rate
to charge all franchisees. By offering a benefit to all parties, the franchiser offers incentive for group participation in
contracting for gasoline distribution services.
%%writefile transport3.mod
set SOURCES;
set DESTINATIONS;
minimize total_cost:
sum{dst in DESTINATIONS, src in SOURCES} Rates[dst, src] * x[dst, src];
s.t. allocate_costs:
sum{dst in DESTINATIONS} cost_to_destination[dst] == m.total_cost;
Writing transport3.mod
m.set["SOURCES"] = rates.columns
m.set["DESTINATIONS"] = rates.index
(continues on next page)
m.param["Rates"] = rates
m.param["supply"] = supply
m.param["demand"] = demand
m.option["solver"] = SOLVER
m.solve()
return m
model3_results.plot(y="savings", kind="bar")
<Axes: xlabel='Destination'>
The following charts demonstrate the difference in outcomes for Model 1 and Model 3 (Model 2 was left out as entirely
inadequate). The group cost-sharing arrangement produces the same group savings, but distributes the benefits in a manner
likely to be more acceptable to the majority of participants.
AMPL models can produce considerable amounts of data that must be summarized and presented for analysis and decision
making. In this application, for example, the individual franchise owners receive differing amounts of savings which is
certain to result in considerable discussion and possibly negotiation with the franchiser.
The following cells demonstrate techniques for extracting and displaying information generated by an AMPL model.
AMPL provides a default .display() method for AMPL entities. The default display is often sufficient for model
reporting requirements, particularly when initially developing a new application.
# display elements of sets
m.display("SOURCES")
m.display("DESTINATIONS")
set DESTINATIONS := Alice Badri Cara Dan Emma Fujita Grace Helen;
Rates [*,*]
: 'Current Supplier' 'Terminal A' 'Terminal B' :=
Alice 0.0875 0.083 0.102
Badri 0.0875 0.081 0.12
Cara 0.0875 0.083 1
Dan 0.0875 0.093 0.08
Emma 0.0875 0.101 0.1
Fujita 0.0875 0.098 0.1
Grace 0.0875 1 0.08
Helen 0.0875 0.075 0.1
;
shipped_to_destination [*] :=
Alice 30000
Badri 40000
Cara 50000
Dan 20000
Emma 30000
Fujita 45000
Grace 80000
Helen 18000
;
m.display("shipped_from_source")
shipped_from_source [*] :=
'Current Supplier' 133000
'Terminal A' 1e+05
'Terminal B' 80000
;
# display Objective
m.display("total_cost")
total_cost = 26113.5
)
m.display("demand_constraint")
supply_constraint [*] :=
'Current Supplier' 0
'Terminal A' -0.0045
'Terminal B' -0.0075
;
# $2 = supply_constraint.body
# $3 = supply_constraint.ub
# $4 = supply_constraint.dual
: supply_constraint.lb $2 $3 $4 :=
'Current Supplier' -Infinity 133000 5e+05 0
'Terminal A' -Infinity 1e+05 1e+05 -0.0045
'Terminal B' -Infinity 80000 80000 -0.0075
;
demand_constraint [*] :=
Alice 0.0875
Badri 0.0855
Cara 0.0875
Dan 0.0875
(continues on next page)
x [*,*]
: 'Current Supplier' 'Terminal A' 'Terminal B' :=
Alice 30000 0 0
Badri 0 40000 0
Cara 8000 42000 0
Dan 20000 0 0
Emma 30000 0 0
Fujita 45000 0 0
Grace 0 0 80000
Helen 0 18000 0
;
Following solution, the value associated with AMPL entities are returned by calling the entity as a function. The following
cell demonstrates the construction of a custom report using Python f-strings and AMPL methods.
# Objective report
print("\nObjective: cost")
print(f"cost = {m.obj['total_cost'].value()}")
SOURCES = m.set["SOURCES"].to_list()
DESTINATIONS = m.set["DESTINATIONS"].to_list()
# Constraint reports
supply_constraint_body = m.get_data("supply_constraint.body").to_dict()
supply_constraint_rc = m.con["supply_constraint"].to_dict()
print("\nConstraint: supply_constraint")
for src in SOURCES:
print(
f"{src:12s} {supply_constraint_body[src]:8.2f} {supply_constraint_rc[src]:8.
↪2f}"
demand_constraint_body = m.get_data("demand_constraint.body").to_dict()
demand_constraint_rc = m.con["demand_constraint"].to_dict()
print("\nConstraint: demand_constraint")
for dst in DESTINATIONS:
print(
f"{dst:12s} {demand_constraint_body[dst]:8.2f} {demand_constraint_rc[dst]:8.
↪2f}"
Objective: cost
cost = 26113.5
Constraint: supply_constraint
Terminal A 100000.00 -0.00
Terminal B 80000.00 -0.01
Current Supplier 133000.00 0.00
Constraint: demand_constraint
Alice 30000.00 0.09
Badri 40000.00 0.09
Cara 50000.00 0.09
Dan 20000.00 0.09
Emma 30000.00 0.09
Fujita 45000.00 0.09
Grace 80000.00 0.09
Helen 18000.00 0.08
Pandas
The Python Pandas library provides a highly flexible framework for data science applications. The next cell demonstrates
the translation of values of AMPL entities to Pandas DataFrames
suppliers = pd.DataFrame(supply)
suppliers["shipped"] = m.get_data("supply_constraint.body").to_pandas()
suppliers["sensitivity"] = m.con["supply_constraint"].to_pandas()
display(suppliers)
customers = pd.DataFrame(demand)
customers["shipped"] = m.get_data("demand_constraint.body").to_pandas()
customers["sensitivity"] = m.con["demand_constraint"].to_pandas()
display(customers)
shipments = m.var["x"].to_pandas()
shipments.index.names = ["destinations", "sources"]
shipments = shipments.unstack()
shipments.columns = shipments.columns.get_level_values(1)
display(shipments)
shipments.plot(kind="bar")
plt.show()
Graphviz
The graphviz utility is a collection of tools for visually graphs and directed graphs. Unfortunately, the package can
be troublesome to install on laptops in a way that is compatible with many JupyterLab installations. Accordingly, the
following cell is intended for use on Google Colab which provides a preinstalled version of graphviz.
import sys
if "google.colab" in sys.modules:
import graphviz
from graphviz import Digraph
dot = Digraph(
node_attr={"fontsize": "10", "shape": "rectangle", "style": "filled"},
edge_attr={"fontsize": "10"},
)
display(dot)
<graphviz.graphs.Digraph at 0x78e4901657e0>
Cryptocurrency exchanges are web services that enable the purchase, sale, and exchange of cryptocurrencies. These
exchanges provide liquidity for owners and establish the relative value of these currencies. Joining an exchange enables
a user to maintain multiple currencies in a digital wallet, buy and sell currencies, and use cryptocurrencies for financial
transactions.
In this example, we explore the efficiency of cryptocurrency exchanges by testing for arbitrage opportunities. An arbitrage
exists if a customer can realize a net profit through a sequence of risk-free trades. The efficient market hypothesis assumes
arbitrage opportunities are quickly identified and exploited by investors. As a result of their trading, prices reach a
new equilibrium so that any arbitrage opportunities would be small and fleeting in an efficient market. The question
here is whether it is possible, with real-time data and rapid execution, for a trader to profit from these fleeting arbitrage
opportunities.
First we install AMPL, solvers and necessary modules. This notebook uses the open-source library ccxt. ccxt supports
the real-time APIs of the largest and most common exchanges on which cryptocurrencies are traded.
SOLVER = "cbc"
ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
import os
import sys
from time import time
from timeit import default_timer as timer
import ccxt
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd
Here we use the ccxt library and list current exchanges supported by ccxt.
print("Available exchanges:\n")
for i, exchange in enumerate(ccxt.exchanges):
print(f"{i+1:3d}) {exchange.ljust(20)}", end="" if (i + 1) % 4 else "\n")
Available exchanges:
First, we need some terminology. Trading between two specific currencies is called a market, with each exchange hosting
multiple markets. ccxt labels each market with a symbol common across exchanges. The market symbol is an upper-
case string with abbreviations for a pair of traded currencies separated by a slash (/). The first abbreviation is the base
currency, the second is the quote currency. Prices for the base currency are denominated in units of the quote currency.
As an example, 𝐸𝑇 𝐻/𝐵𝑇 𝐶 refers to a market for the base currency Ethereum (ETH) quoted in units of the Bitcoin
(BTC). The same market symbol can refer to an offer to sell the base currency (a ‘bid’) or to an offer to sell the base
currency (an ‘ask’). For example, 𝑥 ETH/BTC means you can buy 𝑥 units of BTC with one unit of ETH.
An exchange can be represented by a directed graph constructed from the market symbols available on that exchange.
There, currencies correspond to nodes on the directed graph. Market symbols correspond to edges in the directed graph,
with the source indicating the quote currency and the destination indicating the base currency. The following code develops
such a sample graph.
"""
markets = exchange.load_markets()
symbols = markets.keys()
return dg
return plt.gca()
minimum_in_degree = 5
exchange_dg = get_exchange_dg(exchange, minimum_in_degree)
ax = draw_dg(exchange_dg, 0.01)
ax.set_title(
exchange.name + "\n" + f"Minimum in Degree (Base Currencies) = {minimum_in_degree}
↪"
The order book for a currency exchange is the real-time inventory of trading orders.
A bid is an offer to buy up to a specified amount of the base currency at the price not exceeding the ‘bid price’ in the quote
currency. An ask is an offer to sell up to a specified amount of the base currency at a price no less than a value specified
given in the quote currency.
The exchange attempts to match the bid to ask order at a price less than or equal to the bid price. If a transaction occurs,
the buyer will receive an amount of base currency less than or equal to the bid volume and the ask volume, at a price less
than or equal to the bid price and no less than the specified value.
The order book for currency exchange is the real-time inventory of orders. The exchange order book maintains a list of
all active orders for symbols traded on the exchange. Incoming bids above the lowest ask or incoming asks below the
highest bid will be immediately matched and transactions executed following the rules of the exchange.
The following cell reads and displays a previously saved order book. Cells at the end of this notebook demonstrate how
to retrieve an order book from an exchange and save it as a Pandas DataFrame.
if "google.colab" in sys.modules:
csv = "Binance_US_orderbook_20230313_152616.csv"
order_book = pd.read_csv(
"https://raw.githubusercontent.com/mobook/MO-book/main/notebooks/04/" + csv,
index_col=0,
)
else:
import glob
# read the last order book from the list of order books
print(f"\nReading: {fname}\n")
order_book = pd.read_csv(fname, index_col=0)
display(order_book)
Reading: Binance_US_orderbook_saved.csv
Our goal will be to find arbitrage opportunities, i.e., the possibility to start from a given currency and, through a sequence
of executed trades, arrive back at the same currency with a higher balance than at the beginning. We will model this
problem as a network one.
A bid appearing in the order book for market symbol 𝑏/𝑞 is an order from a prospective counter party to purchase an
amount of the base currency 𝑏 at a bid price given in a quote currency 𝑞. For a currency trader, a bid in the order book is
an opportunity to convert the base currency 𝑏 into the quote currency 𝑞.
The order book can be represented as a directed graph where nodes correspond to individual currencies. A directed edge
𝑏 → 𝑞 from node 𝑏 to node 𝑞 describes an opportunity for us to convert currency 𝑏 into units of currency 𝑞. Let 𝑉𝑏 and 𝑉𝑞
denote the amounts of each currency held by us, and let 𝑥𝑏→𝑞 denote the amount of currency 𝑏 exchanged for currency 𝑗.
Following the transaction 𝑥𝑏→𝑞 we have the following changes to the currency holdings
Δ𝑉𝑏 = −𝑥𝑏→𝑞
Δ𝑉𝑞 = 𝑎𝑏→𝑞 𝑥𝑏→𝑞 ,
where 𝑎𝑏→𝑞 is a conversion coefficient equal to the price of 𝑏 expressed in terms of currency 𝑞. The capacity 𝑐𝑏→𝑞 of an
trading along edge 𝑏 → 𝑞 is specified by a relationship
𝑥𝑏→𝑞 ≤ 𝑐𝑏→𝑞 .
Because the arcs in our graph correspond to two types of orders - bid and ask - we need to build a consistent way of
expressing them in our 𝑎𝑏→𝑞 , 𝑐𝑏→𝑞 notation. So now, imagine that we are the party that accepts the buy and ask bids
existing in the graph.
For bid orders, we have a chance to convert the base currency 𝑏 into the quote currency 𝑞, for which we will use the
following notation:
An ask order for symbol 𝑏/𝑞 is an order to sell the base currency at price not less than the ‘ask’ price given in terms of
the quote currency. The ask volume is the amount of base currency to be sold. For us, a sell order is an opportunity to
convert the quoted currency into the base currency such that
1
𝑎𝑞→𝑏 =
ask price
𝑐𝑞→𝑏 = ask volume × ask volume
The following cell creates a directed graph using data from an exchange order book. To distinguish between different
order types, we will highlight the big orders with green color, and ask orders with red color.
def order_book_to_dg(order_book):
"""
Convert an order book dataframe into a directed graph using the NetworkX library.
(continues on next page)
Parameters:
-----------
order_book : pandas.DataFrame
A dataframe containing the order book information.
Returns:
--------
dg_order_book : networkx.DiGraph
A directed graph representing the order book.
"""
# loop over each node in the graph and set the color attribute to "lightblue"
for node in dg_order_book.nodes():
dg_order_book.nodes[node]["color"] = "lightblue"
return dg_order_book
First, we simply print the content of the order book as a list of arcs.
draw_dg(order_book_dg, 0.05)
plt.show()
With the unified treatment of the bid and ask orders, we are ready to pose the mathematical problem of finding an arbitrage
opportunity. An arbitrage exists if it is possible to find a closed path and a sequence of transactions in the directed graph
resulting in a net increase in currency holdings. Given a path
𝑖0 → 𝑖1 → 𝑖2 → ⋯ → 𝑖𝑛−1 → 𝑖𝑛
the path is closed if 𝑖𝑛 = 𝑖0 . The path has finite capacity if each edge in the path has a non-zero capacity. For a sufficiently
small holding 𝑤𝑖0 of currency 𝑖0 (because of the capacity constraints), a closed path with 𝑖0 = 𝑖𝑛 represents an arbitrage
opportunity if
𝑛−1
∏ 𝑎𝑖𝑘 →𝑖𝑘+1 > 1.(4.1)
𝑘=0
If all we care about is simply finding an arbitrage cycle, regardless of the volume traded, we can use one of the many
shortest path algorithms from the networkx library. To convert the problem of finding a path meeting the above
condition into a sum-of-terms to be minimized, we can take the negative logarithm of both sides to obtain the condition:
𝑛−1 𝑛−1
− log( ∏ 𝑎𝑖𝑘 →𝑖𝑘+1 ) = − ∑ log(𝑎𝑖𝑘 →𝑖𝑘+1 ) < 0,
𝑘=0 𝑘=0
In other words, if we assign the negative logarithm as the weight of arcs in a graph, then our problem just became translated
into the problem of searching for a cycle with a total sum of weights along it to be negative.
A simple cycle is a closed path where no node appears twice. Simple cycles are distinct if they are not cyclic permutations
(essentially, rewriting the same path but with a different start=end point) of each other. One could check for arbitrage
opportunities by checking if there are any negative simple cycles in the graph.
However, looking for a negative-weight cycle through searching for an arbitrage opportunity can be a daunting task -
a brute-force search over all simple cycles has complexity (𝑛 + 𝑒)(𝑐 + 1) which is impractical for large-scale applica-
tions. A more efficient search based on the Bellman-Ford algorithm is embedded in the NetworkX function nega-
tive_edge_cycle that returns a logical True if a negative cycle exists in a directed graph.
order_book_dg = order_book_to_dg(order_book)
nx.negative_edge_cycle(order_book_dg, weight="weight", heuristic=True)
True
The function negative_edge_cycle is fast, but it only indicates if there is a negative cycle or not, and we don’t
even know what kind of a cycle is it so it would be hard to use that information to perform an arbitrage.
Luckily, the networkx library includes the function find_negative_cycle that locates a single negative edge
cycle if one exists. We can use this to demonstrate the existence of an arbitrage opportunity and to highlight that op-
portunity on the directed graph of all possible trades. The following cell reports the cycle found and the trading return
measured in basis points (1 bp = 0.01%), and marks it with thicker arcs in the graph.
order_book_dg = order_book_to_dg(order_book)
arb = nx.find_negative_cycle(order_book_dg, weight="weight", source="USD")[:-1]
bp = 10000 * (np.exp(-sum_weights(arb)) - 1)
)
plt.show()
Note this may or may not be the trading cycle with maximum return. There may be other cycles with higher or lower
returns, and that allow higher or lower trading volumes.
Not all arbitrage cycles are the same - some yield a higher relative return (per dollar invested) than the others, and some
yield a higher absolute return (maximum amount of money to be made risk-free) than others. This is because the amount
of money that flows through a negative cycle is upper bounded by the size of the smallest order in that cycle. Thus, if one
is looking for the best possible arbitrage sequence of trades, finding just ‘a cycle’ might not be enough.
A crude way to search for a good arbitrage opportunity would be to enumerate all possible simple cycles in a graph and
pick the one that’s best according to whatever criterion we pick. A brute force search over for all simple cycles has order
(𝑁𝑛𝑜𝑑𝑒𝑠 + 𝑁𝑒𝑑𝑔𝑒𝑠 )(𝑁𝑐𝑦𝑐𝑙𝑒𝑠 + 1) complexity, which is prohibitive for large order books. Nevertheless, we explore this
option here to better understand the problem of finding and valuing arbitrage opportunities.
In the following cell, we compute the loss function for all simple cycles that can be constructed within a directed graph
using the function simple_cycles from the networkx library to construct a dictionary of all distinct simple cycles
in the order book. Each cycle is represented by an ordered list of nodes. For each cycle, the financial return is computed,
and a histogram is constructed to show the distribution of potential returns. Several paths with the highest return are then
overlaid on the graph of the order book.
Again, note that no account is taken of the trading capacity available on each path.
# This cell iterates over all simple cycles in a directed graph. This
# can a long time for a large, well connected graph.
print(
f"There are {len(cycles)} distinct simple cycles in the order book, {len([cycle␣
↪for cycle in cycles if cycles[cycle] > 0])} of which have positive return."
# create histogram
plt.hist(cycles.values(), bins=int(np.sqrt(len(cycles))))
ax = plt.gca()
ax.set_ylabel("count")
ax.set_xlabel("Basis Points")
ax.set_title("Histogram of Returns for all Simple Cycles")
ax.grid(True)
ax.axvline(0, color="r")
plt.show()
There are 203147 distinct simple cycles in the order book, 974 of which have positive␣
↪return.
Next, we sort out the negative cycles from this list and present them along with their basis-points (1% is 100 basis points)
return.
arbitrage = [
cycle for cycle in sorted(cycles, key=cycles.get, reverse=True) if cycles[cycle] >
↪ 0
n_cycles_to_list = 5
print(f"Top {n_cycles_to_list}\n")
print(f"Basis Points Arbitrage Cycle")
for cycle in arbitrage[0 : min(n_cycles_to_list, len(arbitrage))]:
t = list(cycle)
t.append(cycle[0])
print(f"{cycles[cycle]:6.3f} {len(t)} trades: {' -> '.join(t)}")
Top 5
In the end, we draw an example arbitrage cycle on our graph to illustrate the route that the money must travel.
n_cycles_to_show = 1
ax = draw_dg(order_book_dg, rad=0.05)
t = list(cycle)
t.append(cycle[0])
ax.set_title(
f"Trading cycle with {len(t)} trades: {' -> '.join(t)}\n\n Return =
↪{cycles[cycle]:6.3f} basis points "
)
plt.savefig("crypto3.pdf", bbox_inches="tight")
plt.show()
The preceding analysis demonstrates some of the practical limitations of relying on generic implementations of network
algorithms:
• First of all, more than one negative cycle may exist, so more than one arbitrage opportunity may exist, i.e. an
optimal strategy consists of a combination of cycles.
• Secondly, simply searching for a negative cycle using shortest path algorithms does not account for capacity con-
straints, i.e., the maximum size of each of the exchanges. For that reason, one may end up with a cycle on which
a good `rate’ of arbitrage is available, but where the absolute gain need not be large due to the maximum amounts
The objective of the optimization model is to find a sequence of currency transactions that increase the holdings of a
reference currency. The solution is constrained by assuming the trader cannot short sell any currency. The resulting
model is
max 𝑣𝑈𝑆𝐷 (𝑇 )
𝑣𝑗 (𝑡 − 1) ≥ ∑ 𝑥𝑗→𝑘 (𝑡) ∀𝑗 ∈ 𝑁 , 𝑡 = 1, 2, … , 𝑇
𝑘∈𝑂𝑗
𝑇
∑ 𝑥𝑗→𝑘 (𝑡) ≤ 𝑐𝑗→𝑘 ∀(𝑗, 𝑘) ∈ 𝐸, 𝑡 = 1, 2, … , 𝑇
𝑡=1
𝑣𝑗 (𝑡), 𝑥𝑖→𝑗 (𝑡) ≥ 0 ∀𝑡
%%writefile crypto_model.mod
param T;
param c{EDGES};
param v0;
Overwriting crypto_model.mod
m.set["NODES"] = order_book_dg.nodes
m.set["EDGES"] = order_book_dg.edges
(continues on next page)
m.param["T"] = T
m.param["a"] = [
order_book_dg.edges[(src, dst)]["a"] for (src, dst) in order_book_dg.edges
]
m.param["c"] = [
order_book_dg.edges[(src, dst)]["capacity"]
for (src, dst) in order_book_dg.edges
]
m.param["v0"] = v0
m.option["solver"] = SOLVER
m.solve()
return m
Using this function, we are able to compute the optimal (absolute) return from an order book while respecting the order
capacities and optimally using all the arbitrage opportunities inside it.
order_book_dg = order_book_to_dg(order_book)
v0 = 10000.0
T = 8
m = crypto_model(order_book_dg, T=T, v0=v0)
vT = m.obj["wealth"].value()
To track the evolution of the trades throughout time, the script in the following cell illustrates, for each currency (rows)
the amount of money held in that currency at each of the time steps 𝑡 = 0, … , 8. It is visible from this scheme that the
sequence of trades is not a simple cycle, but rather a more sophisticated sequence of trades which we would not have
discovered with simple-cycle exploration alone, especially not when considering also the arc capacities.
NODES = m.set["NODES"].to_list()
T0 = m.set["T0"].to_list()
v = m.var["v"].to_dict()
To be even more specific, the following cell lists the sequence of transcations executed.
T1 = m.set["T1"].to_list()
EDGES = m.set["EDGES"].to_list()
x = m.var["x"].to_dict()
a = m.param["a"].to_dict()
print("\nTransaction Events")
for t in T1:
print(f"t = {t}")
for src, dst in EDGES:
if x[src, dst, t] > 1e-6:
print(
f" {src:8s} -> {dst:8s}: {x[src, dst, t]:14.6f} {a[src, dst] * x[src,␣
↪dst, t]:14.6f}"
)
print()
Transaction Events
t = 1
USD -> USDT : 9999.025765 9998.025962
USD -> USDC : 0.974235 0.974235
t = 2
USDT -> BUSD : 9998.025962 10002.026773
USDC -> USDT : 0.974235 0.974333
t = 3
USDT -> BUSD : 0.974333 0.974723
BUSD -> USDC : 10002.026773 10002.026773
(continues on next page)
t = 4
BUSD -> USDC : 0.974723 0.974723
USDC -> USDT : 10002.026773 10003.026976
t = 5
USDT -> BUSD : 10003.026976 10007.029787
USDC -> USDT : 0.974723 0.974820
t = 6
USDT -> ADA : 0.697500 2.000000
USDT -> TRX : 0.277320 4.000000
BUSD -> USDC : 10007.029787 10007.029787
t = 7
ADA -> BTC : 2.000000 0.000030
TRX -> BTC : 4.000000 0.000012
USDC -> USDT : 10007.029787 10008.030490
t = 8
BTC -> USD : 0.000042 0.976057
USDT -> USD : 10008.030490 10008.030490
We next illustrate the arbitrage strategy in the graph by marking all the corresponding arcs thicker.
# add comment in the text to remind the reader about bids and asks
# for each currency we took only one ask and one bid, this is why we are unique␣
↪between each pair of nodes
z = m.var["z"].to_dict()
draw_dg(order_book_dg, 0.05)
<Axes: >
If we want to be even more precise about the execution of the trading strategy, we can formulate a printout of the list of
orders that we, as the counter party to the orders stated in the order book, should issue for our strategy to take place.
if kind == "bid":
base = src
quote = dst
symbol = base + "/" + quote
price = order_book_dg.edges[(src, dst)]["a"]
volume = z[src, dst]
s += f"buy {volume:16.6f} {symbol:11s} at {price:12.6f}"
print(s)
ADA -> BTC bid 2.00000 2.00000 >>> buy 2.000000 ADA/BTC ␣
↪at 0.000015
TRX -> BTC bid 4.00000 4.00000 >>> buy 4.000000 TRX/BTC ␣
↪at 0.000003
USDT -> ADA ask 178.28100 0.69750 >>> sell 2.000000 ADA/USDT ␣
↪at 0.348750
USDT -> BUSD ask 317048.85971 20002.02727 >>> sell 20010.031283 BUSD/USDT ␣
↪at 0.999600
USDT -> TRX ask 750.05354 0.27732 >>> sell 4.000000 TRX/USDT ␣
↪at 0.069330
USDT -> USD bid 10407.87000 10008.03049 >>> buy 10008.030490 USDT/USD ␣
↪at 1.000000
BUSD -> USDC ask 279879.62000 20010.03128 >>> sell 20010.031283 USDC/BUSD ␣
↪at 1.000000
USDC -> USDT bid 307657.00000 20011.00552 >>> buy 20011.005518 USDC/USDT ␣
↪at 1.000100
USD -> USDT ask 954935.16397 9999.02576 >>> sell 9998.025962 USDT/USD ␣
↪at 1.000100
USD -> USDC ask 517682.17000 0.97424 >>> sell 0.974235 USDC/USD ␣
↪at 1.000000
In the end, we can illustrate the time-journey of our balances in different currencies using time-indexed bar charts.
balances.plot(
kind="bar",
subplots=True,
figsize=(8, 10),
xlabel="Transaction",
ylabel="Currency Units",
(continues on next page)
The previous notebook cells made certain assumptions that we need to consider. The first assumption was that there was
at most one bid and one ask order between any pair of currencies in an exchange. This assumption was based on the
number of orders we downloaded from the online database, but in reality, there may be more orders. How would the
presence of multiple orders per pair affect our graph formulation? How can we adjust the MILO formulation to account
for this?
Another assumption was that we only traded currencies within one exchange. However, in reality, we can trade across
multiple exchanges. How can we modify the graph-based problem formulation to accommodate this scenario?
Further, we have assigned no cost to the number of trades required to implement the strategy produced by optimization.
How can the modeled be modified to either minimize the number of trades, or to explicitly include trading costs?
Finally, a tool like this needs to operate in real time. How can this model be incorporated into, say, a streamlit application
that could be used to monitor for arbitrage opportunities in real time?
The goal of this notebook was to show how network algorithms and optimization can be utilized to detect arbitrage
opportunities within an order book that has been obtained from a cryptocurrency exchange.
The subsequent cell in the notebook utilizes ccxt.exchange.fetch_order_book to obtain the highest bid and
lowest ask orders from an exchange for market symbols that meet the criteria of having a minimum in-degree for their
base currencies.
import pandas as pd
return order_book[
[
"symbol",
(continues on next page)
minimum_in_degree = 5
# find trades
v0 = 10000.0
m = crypto_model(order_book_dg, T=12, v0=v0)
vT = m.obj["wealth"].value()
ask_price ask_volume
0 0.063512 0.01890
1 0.008156 3.84100
2 0.003156 4.00000
3 0.000011 40.10000
4 0.000274 13.96000
.. ... ...
171 25.790000 3.07000
172 0.316200 1135.70000
173 30091.810000 0.00172
174 1974.530000 0.01890
(continues on next page)
The following cell can be used to download additional order book data sets for testing.
# search time
search_time = 20
timeout = time.time() + search_time
v0 = 10000.0
m = crypto_model(order_book_dg, T=12, v0=10000)
vT = m.obj["wealth"].value()
bp = 10000 * (vT - v0) / vT
print(f"bp = {bp:0.3f}")
if bp >= arb_threshold:
print("arbitrage found!")
fname = f"{exchange} orderbook {datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.
↪csv".replace(
print("Search complete.")
To meet the energy demand, power plants run day and night across the country to produce electricity from a variety of
sources such as fossil fuels and renewable energy. On the short-time scale, the best operating levels for electric power
plants are derived every 15 minutes by solving the so-called Optimal Power Flow (OPF) model. The OPF model is an
optimization problem with the objective of minimizing the total energy dispatching cost, while ensuring that the generation
meets the total energy demand. Furthermore, the model takes into account many constraints, among which operational
and physical constraints.
SOLVER = "highs"
ampl = ampl_notebook(
modules=["highs"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import networkx as nx
We model the nation-wide transmission power network as a directed graph 𝐺 = (𝑉 , 𝐸), where 𝑉 represents the set of
nodes (e.g., cities, industrial districts, power plants) and 𝐸 denotes the set of directed edges (e.g., physical transmission
lines).
Each node 𝑖 ∈ 𝑉 has a power injection 𝑝𝑖 and demand 𝑑𝑖 . The set of nodes are separated into generator and load nodes.
The set of generators 𝒢 ⊆ 𝑉 corresponds to the nodes 𝑖 ∈ 𝑉 for which 𝑝𝑖 ≥ 0 and 𝑑𝑖 = 0. Each generator 𝑖 ∈ 𝒢 has
a minimum 𝑝𝑖min and maximum 𝑝𝑖max production capacity. The set of load nodes 𝒟 ⊆ 𝑉 corresponds to the nodes for
which 𝑝𝑖 = 0 and 𝑑𝑖 ≥ 0. The load nodes thus correspond to the places where electricity is being consumed, e.g., cities
and industrial districts. We say that supply and demand is matched if ∑𝑖∈𝑉 𝑝𝑖 − 𝑑𝑖 = 0. Since we cannot store electricity
in large volumes, supply must meet demand at all times, hence adjusting to it every 15 minutes by solving the OPF.
max
Each edge (𝑖, 𝑗) ∈ 𝐸 carries a power flow 𝑓𝑖𝑗 ∈ 𝑅 and has a capacity 𝑓𝑖𝑗 ≥ 0, i.e., the maximum power flow that it
may carry. Note that our choice to model a directed graph is to make the modeling of the network easier. In particular,
a directed edge (𝑖, 𝑗) may carry a ‘negative’ flow 𝑓𝑖𝑗 < 0, which implies that there is flow going from 𝑗 to 𝑖 where
𝑓𝑗𝑖 = −𝑓𝑖𝑗 . The capacity does not depend on the direction of the flow, implying that the flow capacity constraints are
max
given by |𝑓𝑖𝑗 | = |𝑓𝑗𝑖 | ≤ 𝑓𝑖𝑗 .
One crucial difference of power flow problems compared to typical network flow problems is that the power flows cannot
be controlled directly. Instead, as you might recall from high-school physics, the power flows are determined by the laws
of electricity, which we will now present as the power flow equations. Ignore for a moment the flow capacity constraints.
Let 𝜃𝑖 ∈ ℝ denote the phase angle of node 𝑖. For each edge (𝑖, 𝑗), let 𝑏𝑖𝑗 > 0 denote the line susceptance. Assuming that
𝑛
supply and demand is matched, i.e., ∑𝑖=1 𝑝𝑖 − 𝑑𝑖 = 0, the power flows f ∈ ℝ𝑚 and phase angles 2/7 ∈ ℝ𝑛 are obtained
by solving the following linear system of equations:
The first set of constraints ensures flow conservation and the second set of constrations captures the flow dependency on
susceptances and angle differences. The DC power flow equations admit a unique power flow solution f given matched
power injections p and demand d.
For a given matched supply and demand vector p and d, we can compute the power flows on the network by solving
the linear equations as described above. There are exactly |𝑉 | and |𝐸| equations for the 𝜃𝑖 variables and 𝑓𝑖𝑗 variables,
meaning that this system of equations admit a solution.
We assumed above that the power injections p were given. However, in practice, the power injections need to be deter-
mined for each generator in the power network, where some types of generators may be cheaper than others. Moreover,
we need to take into account operational constraints, such as the maximum flow and generator limits.
On the short-time scale, the power injections are calculated for each generator by solving the so-called Optimal Power
Flow (OPF) problem. The goal of the OPF problem is to determine a solution (p, f, 2/7) with minimal costs such that:
• Supply meets demand
• Line capacity constraints are met
• Generator capacity constraints are met
Let 𝑐𝑖 > 0 be the cost associated with the production of a unit energy by generator 𝑖. Then, the OPF problem can be
formulated as
min ∑𝑖∈𝑉 𝑐𝑖 𝑝𝑖
s.t. ∑𝑗∶(𝑖,𝑗)∈𝐸 𝑓𝑖𝑗 − ∑𝑗∶(𝑗,𝑖)∈𝐸 𝑓𝑗𝑖 = 𝑝𝑖 − 𝑑𝑖 ∀𝑖 ∈ 𝑉,
𝑓𝑖𝑗 = 𝑏𝑖𝑗 (𝜃𝑖 − 𝜃𝑗 ), ∀ (𝑖, 𝑗) ∈ 𝐸,
max
|𝑓𝑖𝑗 | ≤ 𝑓𝑖𝑗 ∀(𝑖, 𝑗) ∈ 𝐸,
(4.4)
𝑝𝑖 ≤ 𝑝𝑖 ≤ 𝑝𝑖max
min
∀𝑖 ∈ 𝑉 ,
𝑝𝑖 ∈ ℝ≥0 ∀𝑖 ∈ 𝑉 ,
𝜃𝑖 ∈ ℝ ∀𝑖 ∈ 𝑉 ,
𝑓𝑖𝑗 ∈ ℝ ∀(𝑖, 𝑗) ∈ 𝐸
For simplicity, you may assume that all load nodes do not produce energy, i.e., 𝑝𝑖 = 𝑝𝑖min = 𝑝𝑖max = 0 for all 𝑖 ∈ 𝒟. You
may therefore model 𝑝𝑖 as decision variables for all nodes (both generator and load nodes). Similarly, you may assume
that all generator nodes have no demand, i.e., 𝑑𝑖 = 0 for all 𝑖 ∈ 𝒢.
To summarize, the decision variables in the OPF problem are:
• 𝑝𝑖 power injections
• 𝜃𝑖 phase angles
• 𝑓𝑖𝑗 power flows
All the other quantities are instance dependent parameters.
4.5.3 Data
You will solve the OPF problem on the IEEE-118 power network, which is a standard test network consisting of 118
nodes and 179 edges. In the following, we will load the data into the notebook and provide a description of the data.
Data import
Network data
plt.figure(figsize=[12, 10])
g = nx.DiGraph(list(network["edges"].index))
pos = nx.layout.kamada_kawai_layout(g, weight=None)
color_mapping = {
"solar": "#ffcb36",
"wind": "white",
"hydro": "#a5efff",
"coal": "#686868",
"gas": "#00ab4e",
"": "#b6b6b6",
}
vertex2color = {
k: color_mapping[v] for k, v in network["nodes"]["energy_type"].items()
}
v2c_list = [vertex2color[i] for i in g.nodes] # Order based on networkx
nodes = nx.draw_networkx_nodes(
g,
pos,
node_size=250,
(continues on next page)
visualize_network(network)
example_nodes = network["nodes"]
example_nodes[example_nodes.is_generator]
For the load nodes, the only important parameter is the demand 𝑑𝑖 ≥ 0. All other parameters are either zero, False, or
an empty string ''.
example_nodes[~example_nodes.is_generator]
Edges
The IEEE-118 network has 179 edges. Each edge has the following parameters:
• node_id1: first node of a given edge
• node_id2: second node of a given edge
• b: the line susceptance, and
• f_max: the maximum edge capacity.
edges_df
b f_max
node_id1 node_id2
0 1 10.0100 271
2 23.5849 271
3 4 125.3133 316
2 4 9.2593 315
4 5 18.5185 316
... ... ...
64 65 28.9059 1427
67 68 28.9059 1427
80 79 28.9059 1427
86 85 4.8216 253
67 115 246.9136 12992
Instances
Since the OPF problem is solved every 15 minutes in practice, you will be given 24 * 4 = 96 network instances
that need to be solved, hence solving an entire day’s worth of OPF problems. The first instance thus corresponds to the
power generation within time window [00:00, 00:15], the second instance corresponds to [00:15, 00:30],
and so on. The data takes into account a realistic demand and (renewable energy) generation pattern. We assume that the
decisions in each time window are independent of the previous and subsequent time windows, so every 15 minutes a new
OPF instance is solved independently.
The network instances are stored in the variable I as a list and can be accessed using indexing, i.e., I[0] gives the first
network instance and I[95] gives the 96th network instance.
Observe that the stated OPF problem contains absolute decision values. We first rewrite the OPF problem into a linear
optimization problem.
min ∑𝑖∈𝑉 𝑐𝑖 𝑝𝑖
+ − + −
s.t. ∑𝑗∶(𝑖,𝑗)∈𝐸 𝑓𝑖𝑗 − 𝑓𝑖𝑗 − ∑𝑗∶(𝑗,𝑖)∈𝐸 𝑓𝑗𝑖 − 𝑓𝑗𝑖 = 𝑝 𝑖 − 𝑑𝑖 ∀𝑖 ∈ 𝑉,
+ −
𝑓𝑖𝑗 − 𝑓𝑖𝑗 = 𝑏𝑖𝑗 (𝜃𝑖 − 𝜃𝑗 ), ∀ (𝑖, 𝑗) ∈ 𝐸,
+ − max
𝑓𝑖𝑗 + 𝑓𝑖𝑗 ≤ 𝑓𝑖𝑗 ∀(𝑖, 𝑗) ∈ 𝐸,
(4.5)
min
𝑝𝑖 ≤ 𝑝𝑖 ≤ 𝑝𝑖max ∀𝑖 ∈ 𝑉 ,
𝑝𝑖 ∈ ℝ≥0 ∀𝑖 ∈ 𝑉 ,
𝜃𝑖 ∈ ℝ ∀𝑖 ∈ 𝑉 ,
+ −
𝑓𝑖𝑗 , 𝑓𝑖𝑗 ∈ℝ ∀(𝑖, 𝑗) ∈ 𝐸
We then implement the model using AMPL and solve it for all instances I[0] to I[95], reporting the average objective
value across all the 96 instances.
%%writefile opf1.mod
# Indexing sets
set NODES;
set EDGES within {NODES,NODES};
# Parameters
param d{NODES};
param p_min{NODES};
param p_max{NODES};
param c_var{NODES};
param is_generator{NODES};
param energy_type{NODES} symbolic;
param b{EDGES};
param f_max{EDGES};
# Constraints
s.t. flow_conservation{i in NODES}:
outgoing_flow[i] - incoming_flow[i] == p[i] - d[i];
Overwriting opf1.mod
def OPF1(network):
"""
Input:
- network: a dictionary containing:
- a dictionary of nodes with a dictionary of attributes
- a dictionary of edges with a dictionary of attributes
Output:
- total energy dispatching cost
"""
nodes = network["nodes"]
edges = network["edges"]
m = AMPL()
m.read("opf1.mod")
m.set_data(nodes, "NODES")
m.set_data(edges, "EDGES")
m.option["solver"] = SOLVER
m.solve()
return m.obj["energy_cost"].value()
Gas and coal plants emit CO2, while renewable energy sources are carbon neutral. For this reason, the Dutch government
has decided to constrain the number of active fossil-fuel-based power plants for the generation of electricity. More
specifically, a maximum of 2 gas plants and 1 coal plant may be active during a single OPF instance. Any plant that is set
inactive for a specific instance cannot produce any electricity.
We first write down the new model. To this end, we introduce new decision variables 𝑥𝑖 , 𝑖 ∈ 𝑉 to the model, which
indicate whether a generator 𝑖 is active or inactive.
min ∑𝑖∈𝑉 𝑐𝑖 𝑝𝑖
+ − + −
s.t. ∑𝑗∶(𝑖,𝑗)∈𝐸 𝑓𝑖𝑗 − 𝑓𝑖𝑗 − ∑𝑗∶(𝑗,𝑖)∈𝐸 𝑓𝑗𝑖 − 𝑓𝑗𝑖 = 𝑝 𝑖 − 𝑑𝑖 ∀𝑖 ∈ 𝑉,
+ −
𝑓𝑖𝑗 − 𝑓𝑖𝑗 = 𝑏𝑖𝑗 (𝜃𝑖 − 𝜃𝑗 ), ∀ (𝑖, 𝑗) ∈ 𝐸,
𝑎𝑏𝑠 + −
𝑓𝑖𝑗 = 𝑓𝑖𝑗 + 𝑓𝑖𝑗 , ∀ (𝑖, 𝑗) ∈ 𝐸,
𝑎𝑏𝑠 max
𝑓𝑖𝑗 ≤ 𝑓𝑖𝑗 ∀(𝑖, 𝑗) ∈ 𝐸,
𝑝𝑖min 𝑥𝑖 ≤ 𝑝𝑖 ≤ 𝑝𝑖max 𝑥𝑖 ∀𝑖 ∈ 𝑉 ,
(4.6)
∑𝑖∈𝒢 𝑥𝑖 ≤ 2
gas
∑𝑖∈𝒢 𝑥𝑖 ≤ 1
coal
𝑝𝑖 ∈ ℝ≥0 ∀𝑖 ∈ 𝑉 ,
𝜃𝑖 ∈ ℝ ∀𝑖 ∈ 𝑉 ,
𝑓𝑖𝑗 ∈ ℝ ∀(𝑖, 𝑗) ∈ 𝐸
𝑥𝑖 ∈ {0, 1} ∀𝑖 ∈ 𝑉
We then implement the new model using AMPL and solve it for all instances I[0] to I[95], reporting the average
objective value across the instances.
%%writefile opf2.mod
# Indexing sets
set NODES;
set EDGES within {NODES,NODES};
# Parameters
(continues on next page)
param b{EDGES};
param f_max{EDGES};
param max_gas_plants;
param max_coal_plants;
# Constraints
s.t. flow_conservation{i in NODES}:
outgoing_flow[i] - incoming_flow[i] == p[i] - d[i];
Overwriting opf2.mod
Output:
(continues on next page)
nodes = network["nodes"]
edges = network["edges"]
m = AMPL()
m.read("opf2.mod")
m.set_data(nodes, "NODES")
m.set_data(edges, "EDGES")
m.param["max_gas_plants"] = max_gas_plants
m.param["max_coal_plants"] = max_coal_plants
m.option["solver"] = SOLVER
m.solve()
return m.obj["energy_cost"].value()
max_gas_plants = 2
max_coal_plants = 1
OPF2_results = [OPF2(instance, max_gas_plants, max_coal_plants) for instance in I]
print(f"The average objective value over all instances is: {np.mean(OPF2_results):.2f}
↪")
The restriction on the number of gas and coal plants may pose a threat to the availability of electricity production when
renewable energy sources fail to deliver. For this reason, the grid operators have decided to slightly change the constraint
that was introduced for OPF2. If the total production of energy from renewable energy sources (i.e., solar, wind and hydro)
is above 1000, then the number of gas and coal plants is restricted to 2 and 1, respectively. Otherwise, the restriction on
the number of gas and coal plants is lifted. These constraints can be modeled as either-or constraints.
We first write down the new model, using big-𝑀 constraints to model the either-or constraint.
min ∑𝑖∈𝑉 𝑐𝑖 𝑝𝑖
+ − + −
s.t. ∑𝑗∶(𝑖,𝑗)∈𝐸 𝑓𝑖𝑗 − 𝑓𝑖𝑗 − ∑𝑗∶(𝑗,𝑖)∈𝐸 𝑓𝑗𝑖 − 𝑓𝑗𝑖 = 𝑝 𝑖 − 𝑑𝑖 ∀𝑖 ∈ 𝑉,
+ −
𝑓𝑖𝑗 − 𝑓𝑖𝑗 = 𝑏𝑖𝑗 (𝜃𝑖 − 𝜃𝑗 ), ∀ (𝑖, 𝑗) ∈ 𝐸,
𝑎𝑏𝑠 + −
𝑓𝑖𝑗 = 𝑓𝑖𝑗 + 𝑓𝑖𝑗 , ∀ (𝑖, 𝑗) ∈ 𝐸,
𝑎𝑏𝑠 max
𝑓𝑖𝑗 ≤ 𝑓𝑖𝑗 ∀(𝑖, 𝑗) ∈ 𝐸,
𝑝𝑖min 𝑥𝑖 ≤ 𝑝𝑖 ≤ 𝑝𝑖max 𝑥𝑖 ∀𝑖 ∈ 𝑉 ,
∑𝑖∈𝒢 𝑥𝑖 ≤ 2 + (1 − 𝑦)𝑀0
gas (4.7)
∑𝑖∈𝒢 𝑥𝑖 ≤ 1 + (1 − 𝑦)𝑀1
coal
∑𝑖∈𝒢 𝑝𝑖 + ∑𝑖∈𝒢 𝑝𝑖 + ∑𝑖∈𝒢 𝑝𝑖 ≤ 1000 + 𝑦𝑀2
solar wind hydro
𝑝𝑖 ∈ ℝ≥0 ∀𝑖 ∈ 𝑉 ,
𝜃𝑖 ∈ ℝ ∀𝑖 ∈ 𝑉 ,
𝑓𝑖𝑗 ∈ ℝ ∀(𝑖, 𝑗) ∈ 𝐸
𝑥𝑖 ∈ {0, 1} ∀𝑖 ∈ 𝑉
𝑦 ∈ {0, 1}
We now implement the new model using AMPL and solve it for all instances I[0] to I[95], reporting the average
objective value across the instances.
%%writefile opf3.mod
# Indexing sets
set NODES;
set EDGES within {NODES,NODES};
# Parameters
param d{NODES};
param p_min{NODES};
param p_max{NODES};
(continues on next page)
param b{EDGES};
param f_max{EDGES};
param max_gas_plants;
param max_coal_plants;
# Big-Ms
param M0;
param M1;
param M2;
# Constraints
s.t. flow_conservation{i in NODES}:
outgoing_flow[i] - incoming_flow[i] == p[i] - d[i];
s.t. renewable_energy_production:
sum{i in NODES: energy_type[i] in {'solar', 'wind', 'hydro'}} p[i] <= 1000 + y *␣
↪M2;
Overwriting opf3.mod
Output:
- total energy dispatching cost
"""
nodes = network["nodes"]
edges = network["edges"]
m = AMPL()
m.read("opf3.mod")
m.set_data(nodes, "NODES")
m.set_data(edges, "EDGES")
m.param["max_gas_plants"] = max_gas_plants
m.param["max_coal_plants"] = max_coal_plants
m.param["M0"] = 4
m.param["M1"] = 3
m.param["M2"] = nodes["p_max"].sum()
m.option["solver"] = SOLVER
m.solve()
return m.obj["energy_cost"].value()
For all three implemented models, we plot the objective values for all instances and explain the differences between the
objective values in view of the different feasible regions.
The goal of the OPF problem is to minimize the total costs of dispatching energy. OPF1 is the original OPF formulation,
whereas OPF2 and OPF3 are restricted versions of OPF1. This means that the feasible region of OPF1 is at least as large
as OPF2 and OPF3, where we may assume that 𝑥𝑖 = 1 for all the generators 𝑖.
If we let 𝐹1 , 𝐹2 , 𝐹3 denote the feasible region of OPF1, OPF2 and OPF3, then we observe that which explains that the
optimal objective value of OPF1 always remains below the optimal objectives values of OPF2 and OPF3.
The most important observation to make is that based on the problem descriptions, we would expect OPF2 to take on the
objective values of OPF1 or OPF3, depending on which of the either-or-constraints are activated.
• OPF3 uses expensive generators, and possibly not all the renewable energy
• OPF2 does only uses 1000 renewable energy power, because then it may keep using all the gas and coal generators.
• OPF1 uses all renewable energy at all times, because it has the flexibility to use all generators in order to mitigate
operational restrictions due to line flows.
This notebook presents an example of linear optimization on a network model for financial transactions. The goal is to
identify whether or not an arbitrage opportunity exists given a matrix of cross-currency exchange rates. Other treatments
of this problem and application are available, including the following links.
• Crypto Arbitrage Framework
• Crypto Trading and Arbitrage Identification Strategies
SOLVER = "cbc"
ampl = ampl_notebook(
modules=["cbc"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
import io
import networkx as nx
import numpy as np
import pandas as pd
4.10.1 Problem
Exchanging one currency for another is among the most common of all banking transactions. Currencies are normally
priced relative to each other.
At this moment of this writing, for example, the Japanese yen (symbol JPY) is priced at 0.00761 relative to the euro
(symbol EUR). At this price 100 euros would purchase 100/0.00761 = 13,140.6 yen. Conversely, EUR is priced at
131.585 yen. The ‘round-trip’ of 100 euros from EUR to JPY and back to EUR results in
1 JPY 1 EUR
100 EUR × ⟶ 12, 140.6 JPY × ⟶ 99.9954 EUR
0.00761 EUR 131.585 JPY
The small loss in this round-trip transaction is the fee collected by the brokers and banking system to provide these
services.
Needless to say, if a simple round-trip transaction like this reliably produced a net gain then there would many eager
traders ready to take advantage of the situation. Trading situations offering a net gain with no risk are called arbitrage,
and are the subject of intense interest by traders in the foreign exchange (forex) and crypto-currency markets around the
globe.
As one might expect, arbitrage opportunities involving a simple round-trip between a pair of currencies are almost non-
existent in real-world markets. When the do appear, they are easily detected and rapid and automated trading quickly
exploit the situation. More complex arbitrage opportunities, however, can arise when working with three more currencies
and a table of cross-currency exchange rates.
Entry 𝑎𝑚,𝑛 is the number units of currency 𝑚 received in exchange for one unit of currency 𝑛. We use the notation
𝑎𝑚,𝑛 = 𝑎𝑚←𝑛
as reminder of what the entries denote. For this data there are no two way arbitrage opportunities. We can check this by
explicitly computing all two-way currency exchanges
𝐼 →𝐽 →𝐼
by computing
𝑎𝑖←𝑗 × 𝑎𝑗←𝑖
This data set shows no net cost and no arbitrage for conversion from one currency to another and back again.
df = pd.DataFrame(
[[1.0, 0.5, 100], [2.0, 1.0, 1 / 0.0075], [0.01, 0.0075, 1.0]],
columns=["USD", "EUR", "JPY"],
index=["USD", "EUR", "JPY"],
).T
display(df)
1.0
1.0
1.0
Now consider a currency exchange comprised of three trades that returns back to the same currency.
𝐼 →𝐽 →𝐾→𝐼
By direct calculation we see there is a three-way triangular arbitrage opportunity for this data set that returns a 50%
increase in wealth.
I = "USD"
J = "JPY"
K = "EUR"
1.5
Our challenge is create a model that can identify complex arbitrage opportunities that may exist in cross-currency forex
markets.
4.10.3 Modeling
The cross-currency table 𝐴 provides exchange rates among currencies. Entry 𝑎𝑖,𝑗 in row 𝑖, column 𝑗 tells us how many
units of currency 𝑖 are received in exchange for one unit of currency 𝑗. We’ll use the notation 𝑎𝑖,𝑗 = 𝑎𝑖←𝑗 to remind
ourselves of this relationship.
We start with 𝑤𝑗 (0) units of currency 𝑗 ∈ 𝑁 , where 𝑁 is the set of all currencies in the data set. We consider a sequence
of trades 𝑡 = 1, 2, … , 𝑇 where 𝑤𝑗 (𝑡) is the amount of currency 𝑗 on hand after completing trade 𝑡.
Each trade is executed in two phases. In the first phase an amount 𝑥𝑖←𝑗 (𝑡) of currency 𝑗 is committed for exchange
to currency 𝑖. This allows a trade to include multiple currency transactions. After the commitment the unencumbered
balance for currency 𝑗 must satisfy trading constraints. Each trade consists of simultaneous transactions in one or more
currencies.
𝑤𝑗 (𝑡 − 1) − ∑ 𝑥𝑖←𝑗 (𝑡) ≥ 0
𝑖≠𝑗
Here a lower bound has been placed to prohibit short-selling of currency 𝑗. This constraint could be modified if leveraging
is allowed on the exchange.
The second phase of the trade is complete when the exchange credits all of the currency accounts according to
We assume all trading fees and costs are represented in the bid/ask spreads represented by 𝑎𝑗←𝑖
The goal of this calculation is to find a set of transactions 𝑥𝑖←𝑗 (𝑡) ≥ 0 to maximize the value of portfolio after a specified
number of trades 𝑇 .
%%writefile arbitrage.mod
set T0;
set T1;
# currency *nodes*
set NODES;
param T;
param R symbolic;
param a{NODES, NODES};
# no shorting constraint
s.t. max_trade {j in NODES, t in T1}:
w[j, t-1] >= sum{i in NODES: i != j} x[i, j, t];
(continues on next page)
Overwriting arbitrage.mod
T0 = list(range(0, T + 1))
T1 = list(range(1, T + 1))
NODES = df.index
ARCS = [(i, j) for i in NODES for j in NODES if i != j]
m.set["T0"] = T0
m.set["T1"] = T1
m.set["NODES"] = NODES
m.set["ARCS"] = ARCS
m.param["T"] = T
m.param["R"] = R
m.param["a"] = df
m.option["solver"] = SOLVER
m.solve()
x = m.var["x"].to_dict()
w = m.var["w"].to_dict()
for t in T0:
print(f"\nt = {t}\n")
if t >= 1:
for i, j in ARCS:
if x[i, j, t] > 0:
print(
f"{j} -> {i} Convert {x[i, j, t]} {j} to {df.loc[i,j]*x[i,j,
↪t]} {i}"
)
print()
for i in NODES:
print(f"w[{i},{t}] = {w[i, t]:9.2f} ")
return m
t = 0
w[USD,0] = 0.00
w[EUR,0] = 100.00
w[JPY,0] = 0.00
t = 1
w[USD,1] = 200.00
w[EUR,1] = -0.00
w[JPY,1] = 0.00
t = 2
w[USD,2] = 0.00
w[EUR,2] = 0.00
w[JPY,2] = 20000.00
t = 3
w[USD,3] = 0.00
w[EUR,3] = 150.00
w[JPY,3] = 0.00
100
150.0000000000001
def display_graph(m):
m_w = m.var["w"].to_dict()
m_x = m.var["x"].to_dict()
T0 = m.set["T0"].to_list()
T1 = m.set["T1"].to_list()
NODES = m.set["NODES"].to_list()
ARCS = m.set["ARCS"].to_list()
path = []
for t in T0:
for i in NODES:
if m_w[i, t] >= 1e-6:
path.append(f"{m_w[i, t]} {i}")
path = " -> ".join(path)
print("\n", path)
for t in T1:
for i, j in ARCS:
if m_x[i, j, t] > 0.1:
nodelist.add(i)
nodelist.add(j)
y = m_w[j, t - 1]
x = m_w[j, t]
G.add_edge(j, i)
edge_labels[(j, i)] = df.loc[i, j]
nodelist = list(nodelist)
pos = nx.spring_layout(G)
nx.draw(
G,
pos,
with_labels=True,
node_size=2000,
nodelist=nodelist,
node_color="lightblue",
node_shape="s",
arrowsize=20,
label=path,
)
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels)
display_graph(m)
100 EUR -> 200 USD -> 20000 JPY -> 150.0000000000001 EUR
https://www.bloomberg.com/markets/currencies/cross-rates
https://www.tradingview.com/markets/currencies/cross-rates-overview-prices/
# data extracted 2022-03-17
bloomberg = """
USD EUR JPY GBP CHF CAD AUD HKD
USD - 1.1096 0.0084 1.3148 1.0677 0.
↪7915 0.7376 0.1279
EUR 0.9012 - 0.0076 1.1849 0.9622 0.
↪7133 0.6647 0.1153
JPY 118.6100 131.6097 - 155.9484 126.
↪6389 93.8816 87.4867 15.1724
GBP 0.7606 0.8439 0.0064 - 0.8121 0.
↪6020 0.5610 0.0973
CHF 0.9366 1.0393 0.0079 1.2314 - 0.
↪7413 0.6908 0.1198
CAD 1.2634 1.4019 0.0107 1.6611 1.3489 -
↪ 0.9319 0.1616
AUD 1.3557 1.5043 0.0114 1.7825 1.4475 1.
↪0731 - 0.1734
(continues on next page)
t = 0
w[USD,0] = 100.00
w[EUR,0] = 0.00
w[JPY,0] = 0.00
w[GBP,0] = 0.00
w[CHF,0] = 0.00
w[CAD,0] = 0.00
w[AUD,0] = 0.00
w[HKD,0] = 0.00
t = 1
w[USD,1] = 0.00
w[EUR,1] = 0.00
w[JPY,1] = 11861.00
w[GBP,1] = 0.00
w[CHF,1] = 0.00
w[CAD,1] = 0.00
w[AUD,1] = 0.00
w[HKD,1] = 0.00
t = 2
t = 3
w[USD,3] = 100.45
w[EUR,3] = 0.00
w[JPY,3] = 0.00
w[GBP,3] = 0.00
w[CHF,3] = 0.00
w[CAD,3] = 0.00
w[AUD,3] = 0.00
w[HKD,3] = 0.00
display_graph(m)
100 USD -> 11861 JPY -> 126.9127 CAD -> 100.45140205 USD
FIVE
5. CONVEX OPTIMIZATION
Our aim so far has been to formulate a real-life decision problem as a (mixed-integer) linear optimization problem. The
reason was clear: linear functions are simple, so problems formulated with them should also be simple to solve. However,
the world of linear problems is sometimes not flexible enough to model well all real-life problems. In fact, numerous
optimization problems come naturally in nonlinear form and that this might lead to situations in which determining the
optimal solution is difficult.
Luckily, it will turn out that the real separation between simple and difficult problems is not whether they are linear vs.
nonlinear, but instead, whether they are convex or not. Convexity is a very desirable property of an optimization problem
that makes the optimization problem solvable to optimality in a reasonable time. For that reason, it plays a central role in
mathematical optimization.
In this chapter, there is a number of examples with companion AMPL implementation that explore various modeling and
implementation aspects of convex optimization:
• Milk pooling and blending
• Ordinary Least Squares (OLS) regression
• Markowitz portfolio optimization problem
• Support Vector Machines for binary classification
• Extra material: Refinery production
• Extra material: Cutting stock
Go to the next chapter about conic optimization.
Pooling and blending operations involve the “pooling” of various streams to create intermediate mixtures that are subse-
quently blended with other streams to meet final product specifications. These operations are common to the chemical
processing and petroleum sectors where limited tankage may be available, or when it is necessary to transport materials by
train, truck, or pipeline to remote blending terminals. Similar applications arise in agriculture, food, mining, wastewater
treatment, and other industries.
This notebook considers a simple example of a wholesale milk distributor to show how non-convexity arises in the
optimization of pooling and blending operations. Non-convexity is due to presence of bilinear terms that are the product
of two decision variables where one is a scale-dependent extensive quantity measuring the amount or flow of a product,
and the other is scale-independent intensive quantity such as product composition. The notebook then shows how to
develop and solve a convex approximation of the problem, and finally demonstrates solution the use of Couenne, a solver
specifically designed to find global solutions to mixed integer nonlinear optimization (MINLO) problems.
273
Hands-On Mathematical Optimization with AMPL in Python
SOLVER_LO = "cbc"
SOLVER_GNLO = "couenne"
ampl = ampl_notebook(
modules=["coin"], # modules to install
license_uuid="default", # license to use
) # instantiate AMPL object and register magics
Using default Community Edition License for Colab. Get yours at: https://ampl.com/ce
Licensed to AMPL Community Edition License for the AMPL Model Colaboratory (https://
↪colab.ampl.com).
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from itertools import product
A bulk distributor supplies custom blends of milk to several customers. Each customer has specified a minimum fat
content, a maximum price, and a maximum amount for the milk they wish to buy. The distributor sources raw milk from
local farms. Each farm produces a milk with a known fat content and cost.
The distributor has recently identified more affordable sources raw milk from several distant farms. These distant farms
produce milk grades that can be blend with milk from the local farms. However, the distributor only has one truck with
a single tank available for transporting milk from the distant farms. As a result, milk from the distant farms must be
combined in the tank before being transported to the blending station. This creates a “pool” of uniform composition
which to be blended with local milk to meet customer requirements.
The process is shown in the following diagram. The fat content and cost of raw milk is given for each farm. For each
customer, data is given for the required milk fat content, price, and the maximum demand. The arrows indicate pooling
and blending of raw milk supplies. Each arrow is labeled with the an amount of raw milk.
suppliers = pd.DataFrame(
{
"Farm A": {"fat": 0.045, "cost": 45.0, "location": "local"},
"Farm B": {"fat": 0.030, "cost": 42.0, "location": "local"},
"Farm C": {"fat": 0.033, "cost": 37.0, "location": "remote"},
"Farm D": {"fat": 0.050, "cost": 45.0, "location": "remote"},
},
).T
print("\nCustomers")
display(customers)
print("\nSuppliers")
display(suppliers)
Customers
Suppliers
The normal business of the milk distributor is to blend supplies from local farms to meet customer requirements. Let 𝐿
designate the set of local suppliers, and let 𝐶 designate the set of customers. Decision variable 𝑧𝑙,𝑐 is the amount of milk
from local supplier 𝑙 ∈ 𝐿 that is mixed into the blend sold to customer 𝑐 ∈ 𝐶.
The distributor’s objectives is to maximize profit
where (𝑙, 𝑐) ∈ 𝐿 × 𝐶 indicates a summation over the cross-product of two sets. Each term, (price𝑐 − cost𝑙 ), is the net
profit of including one unit of raw milk from supplier 𝑙 ∈ 𝐿 in the blend delivered to customer 𝑐 ∈ 𝐶.
The amount of milk delivered to each customer 𝑐 ∈ 𝐶 can not exceed the customer demand.
∑ 𝑧𝑙,𝑐 ≤ demand𝑐 ∀𝑐 ∈ 𝐶
𝑙∈𝐿
Let fat𝑙 denote the fat content of the raw milke produced by farm 𝑙, and let
model = AMPL()
model.eval(
"""
# define sources and customers
set L;
set C;
param price{C};
param demand{C};
param min_fat{C};
param cost{L};
param fat{L};
model.set_data(customers, "C")
model.set_data(local_suppliers.drop(columns=["location"]), "L")
model.option["solver"] = SOLVER_LO
model.get_output("solve;")
# report results
print(f"\nProfit = {model.obj['profit'].value():0.2f}\n")
print("Blending Plan")
Z = pd.DataFrame(
[[l, c, round(z[l, c].value(), 1)] for l in L.members() for c in C.members()],
columns=["supplier", "customer", ""],
)
Z = Z.pivot_table(index="customer", columns="supplier")
Z["Total"] = Z.sum(axis=1)
Z["fat content"] = [
sum(z[l, c].value() * suppliers.loc[l, "fat"] for l in L.members())
/ sum(z[l, c].value() for l in L.members())
for c in C.members()
]
Z["min_fat"] = customers["min_fat"]
display(Z)
Profit = 81000.00
Blending Plan
The distributor can earn a profit of 81,000 using only local suppliers. Is is possible earn a higher profit by also sourcing
raw milk from the remote suppliers?
Before considering pooling,the distributor may wish to know the maximum profit possible if raw milk from the remote
suppliers could be blended just like local suppliers. This would require acquiring and operating a separate transport truck
for each remote supplier,and is worth knowing if the additional profit would justify the additional expense.
The linear optimization model presented Option 1 extends the to include both local and remote suppliers.
model = AMPL()
model.eval(
"""
# define sources and customers
set S;
set C;
param price{C};
param demand{C};
param min_fat{C};
param cost{S};
param fat{S};
model.set_data(customers, "C")
model.set_data(suppliers.drop(columns=["location"]), "S")
model.option["solver"] = SOLVER_LO
model.get_output("solve;")
# report results
print(f"\nProfit = {model.obj['profit'].value():0.2f}\n")
print("Blending Plan")
Z = pd.DataFrame(
[[s, c, round(z[s, c].value(), 1)] for s in S.members() for c in C.members()],
columns=["supplier", "customer", ""],
)
Z = Z.pivot_table(index="customer", columns="supplier")
Z["Total"] = Z.sum(axis=1)
(continues on next page)
display(Z)
Profit = 122441.18
Blending Plan
Sourcing raw milk from the remote farms significantly increases profits. This blending,however,requires at least two trucks
to keep the sources of milk from the remote suppliers separated until they reach the blending facility. Note that the local
suppliers are completely replaced by the lower cost remote suppliers,even to the extent of providing “product giveaway”
by surpassing the minimum requirements of Customer 2.
Comparing Option 1 with Option 2 shows there is significantly more profit to be earned by purchasing raw milk from the
remote suppliers. But that option requires an additional truck to keep the supplies separated during transport.
Because only one truck with a single tank is available for transport from the remote farms,the pool and blending problem
is to combine purchases from the remote suppliers into a single pool of uniform composition,transport that pool to the
distribution facility,then blend with raw milk from local suppliers to meet individual customer requirements. Compared
to option 2,the profit potential may be reduced due to pooling,but without the need to acquire an additional truck.
Pooling problem
There are a several mathematical formulations of pooling problem in the academic literature. The formulation used here
is called the “p-parameterization” where the pool composition represents a new decision variable 𝑝. The other additional
decision variables are 𝑥𝑟 referring to the amount of raw milk purchased from remote supplier 𝑟 ∈ 𝑅,and 𝑦𝑐 which is the
amount of the pooled milk included in the blend delivered to customer 𝑐 ∈ 𝐶.
The profit objective is the difference between the income received for selling blended products and the cost of purchasing
raw milk from local and remote suppliers.