Finding Optimal Locations for New Stores¶

This notebook is an example of how Decision Optimization can help to prescribe decisions for a complex constrained problem.

When you finish this notebook, you'll have a foundational knowledge of Prescriptive Analytics.

This notebook requires the Commercial Edition of CPLEX engines. This notebook runs on Python and DO.

Table of contents:

  • Describe the business problem
  • How Decision Optimization can help
  • Use Decision Optimization
    • Step 1: Import the DOcplex package
    • Step 2: Model the data
    • Step 3: Prepare the data
    • Step 4: Set up the prescriptive model
      • Define the decision variables
      • Express the business constraints
      • Express the objective
      • Solve with the Decision Optimization solve service
    • Step 5: Investigate the solution and run an example analysis
  • Summary

Describe the business problem¶

  • A fictional Coffee Company plans to open N shops in the near future and needs to determine where they should be located knowing the following:
    • Most of the customers of this coffee brewer enjoy reading and borrowing books, so the goal is to locate those shops in such a way that all the city public libraries are within minimal walking distance.
  • We use Chicago open data for this example.
  • We implement a K-Median model to get the optimal location of our future shops.

How Decision Optimization can help¶

  • Prescriptive analytics (Decision Optimization) technology recommends actions that are based on desired outcomes. It takes into account specific scenarios, resources, and knowledge of past and current events. With this insight, your organization can make better decisions and have greater control of business outcomes.

  • Prescriptive analytics is the next step on the path to insight-based actions. It creates value through synergy with predictive analytics, which analyzes data to predict future outcomes.

  • Prescriptive analytics takes that insight to the next level by suggesting the optimal way to handle that future situation. Organizations that can act fast in dynamic conditions and make superior decisions in uncertain environments gain a strong competitive advantage.


With prescriptive analytics, you can:

  • Automate the complex decisions and trade-offs to better manage your limited resources.
  • Take advantage of a future opportunity or mitigate a future risk.
  • Proactively update recommendations based on changing events.
  • Meet operational goals, increase customer loyalty, prevent threats and fraud, and optimize business processes.

Use Decision Optimization¶

Step 1: Import the DOcplex package¶

This package is presintalled on IBM Cloud Pak for Data.

In [1]:
import sys
import docplex.mp

Note that the more global package docplex contains another subpackage docplex.cp that is dedicated to Constraint Programming, another branch of optimization.

Step 2: Model the data¶

  • The data for this problem is quite simple: it is composed of the list of public libraries and their geographical locations.
  • The data is acquired from Chicago open data as a JSON file, which is in the following format:
data" : [ [ 1, "13BFA4C7-78CE-4D83-B53D-B57C60B701CF", 1, 1441918880, "885709", 1441918880, "885709", null, "Albany Park", "M, W: 10AM-6PM; TU, TH: 12PM-8PM; F, SA: 9AM-5PM; SU: Closed", "Yes", "Yes ", "3401 W. Foster Avenue", "CHICAGO", "IL", "60625", "(773) 539-5450", [ "http://www.chipublib.org/locations/1/", null ], [ null, "41.975456", "-87.71409", null, false ] ] This code snippet represents library "**3401 W. Foster Avenue**" located at **41.975456, -87.71409**

Step 3: Prepare the data¶

We need to collect the list of public libraries locations and keep their names, latitudes, and longitudes.

In [2]:
# Store longitude, latitude and street crossing name of each public library location.
class XPoint(object):
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __str__(self):
        return "P(%g_%g)" % (self.x, self.y)

class NamedPoint(XPoint):
    def __init__(self, name, x, y):
        XPoint.__init__(self, x, y)
        self.name = name
    def __str__(self):
        return self.name

Define how to compute the earth distance between 2 points¶

To easily compute distance between 2 points, we use the Python package geopy

In [3]:
try:
    import geopy.distance
except:
    if hasattr(sys, 'real_prefix'):
        #we are in a virtual env.
        !pip install geopy 
    else:
        !pip install --user geopy
Collecting geopy
  Downloading geopy-2.4.1-py3-none-any.whl.metadata (6.8 kB)
Collecting geographiclib<3,>=1.52 (from geopy)
  Downloading geographiclib-2.0-py3-none-any.whl (40 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 40.3/40.3 kB 3.3 MB/s eta 0:00:00
Downloading geopy-2.4.1-py3-none-any.whl (125 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 125.4/125.4 kB 11.5 MB/s eta 0:00:00
Installing collected packages: geographiclib, geopy
Successfully installed geographiclib-2.0 geopy-2.4.1
In [4]:
# Simple distance computation between 2 locations.
from geopy.distance import great_circle
 
def get_distance(p1, p2):
    return great_circle((p1.y, p1.x), (p2.y, p2.x)).miles

Declare the list of libraries¶

Parse the JSON file to get the list of libraries and store them as Python elements.

In [5]:
def build_libraries_from_url(url, name_pos, lat_long_pos):
    import requests
    import json

    r = requests.get(url)
    myjson = json.loads(r.text, parse_constant='utf-8')
    myjson = myjson['data']

    libraries = []
    k = 1
    for location in myjson:
        uname = location[name_pos]
        try:
            latitude = float(location[lat_long_pos][1])
            longitude = float(location[lat_long_pos][2])
        except TypeError:
            latitude = longitude = None
        try:
            name = str(uname)
        except:
            name = "???"
        name = "P_%s_%d" % (name, k)
        if latitude and longitude:
            cp = NamedPoint(name, longitude, latitude)
            libraries.append(cp)
            k += 1
    return libraries
In [6]:
libraries = build_libraries_from_url('https://data.cityofchicago.org/api/views/x8fc-8rcq/rows.json?accessType=DOWNLOAD',
                                   name_pos=10,
                                   lat_long_pos=16)
In [7]:
print("There are %d public libraries in Chicago" % (len(libraries)))
There are 81 public libraries in Chicago

Define number of shops to open¶

Create a constant that indicates how many coffee shops we would like to open.

In [8]:
nb_shops = 5
print("We would like to open %d coffee shops" % nb_shops)
We would like to open 5 coffee shops

Validate the data by displaying them¶

We will use the folium library to display a map with markers.

In [9]:
try:
    import folium
except:
    if hasattr(sys, 'real_prefix'):
        #we are in a virtual env.
        !pip install folium 
    else:
        !pip install folium
Collecting folium
  Downloading folium-0.15.1-py2.py3-none-any.whl.metadata (3.4 kB)
Collecting branca>=0.6.0 (from folium)
  Downloading branca-0.7.0-py3-none-any.whl.metadata (1.5 kB)
Requirement already satisfied: jinja2>=2.9 in /opt/conda/envs/Python-RT23.1-Premium/lib/python3.10/site-packages (from folium) (3.1.2)
Requirement already satisfied: numpy in /opt/conda/envs/Python-RT23.1-Premium/lib/python3.10/site-packages (from folium) (1.23.5)
Requirement already satisfied: requests in /opt/conda/envs/Python-RT23.1-Premium/lib/python3.10/site-packages (from folium) (2.31.0)
Requirement already satisfied: xyzservices in /opt/conda/envs/Python-RT23.1-Premium/lib/python3.10/site-packages (from folium) (2022.9.0)
Requirement already satisfied: MarkupSafe>=2.0 in /opt/conda/envs/Python-RT23.1-Premium/lib/python3.10/site-packages (from jinja2>=2.9->folium) (2.1.1)
Requirement already satisfied: charset-normalizer<4,>=2 in /opt/conda/envs/Python-RT23.1-Premium/lib/python3.10/site-packages (from requests->folium) (2.0.4)
Requirement already satisfied: idna<4,>=2.5 in /opt/conda/envs/Python-RT23.1-Premium/lib/python3.10/site-packages (from requests->folium) (3.4)
Requirement already satisfied: urllib3<3,>=1.21.1 in /opt/conda/envs/Python-RT23.1-Premium/lib/python3.10/site-packages (from requests->folium) (1.26.18)
Requirement already satisfied: certifi>=2017.4.17 in /opt/conda/envs/Python-RT23.1-Premium/lib/python3.10/site-packages (from requests->folium) (2023.7.22)
Downloading folium-0.15.1-py2.py3-none-any.whl (97 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 97.0/97.0 kB 9.9 MB/s eta 0:00:00
Downloading branca-0.7.0-py3-none-any.whl (25 kB)
Installing collected packages: branca, folium
Successfully installed branca-0.7.0 folium-0.15.1
In [10]:
import folium
map_osm = folium.Map(location=[41.878, -87.629], zoom_start=11)
for library in libraries:
    lt = library.y
    lg = library.x
    folium.Marker([lt, lg]).add_to(map_osm)
map_osm
Out[10]:
Make this Notebook Trusted to load map: File -> Trust Notebook

After running the above code, the data is displayed but it is impossible to determine where to ideally open the coffee shops by just looking at the map.

Let's set up DOcplex to write and solve an optimization model that will help us determine where to locate the coffee shops in an optimal way.

Step 4: Set up the prescriptive model¶

In [11]:
from docplex.mp.environment import Environment
env = Environment()
env.print_information()
* system is: Linux 64bit
* Python version 3.10.13, located at: /opt/conda/envs/Python-RT23.1-Premium/bin/python
* docplex is present, version is 2.25.236
* CPLEX library is present, version is 22.1.1.0, located at: /opt/conda/envs/Python-RT23.1-Premium/lib/python3.10/site-packages
* pandas is present, version is 1.5.3

Create the DOcplex model¶

The model contains all the business constraints and defines the objective.

In [12]:
from docplex.mp.model import Model

mdl = Model("coffee shops")

Define the decision variables¶

In [13]:
BIGNUM = 999999999

# Ensure unique points
libraries = set(libraries)
# For simplicity, let's consider that coffee shops candidate locations are the same as libraries locations.
# That is: any library location can also be selected as a coffee shop.
coffeeshop_locations = libraries

# Decision vars
# Binary vars indicating which coffee shop locations will be actually selected
coffeeshop_vars = mdl.binary_var_dict(coffeeshop_locations, name="is_coffeeshop")
#
# Binary vars representing the "assigned" libraries for each coffee shop
link_vars = mdl.binary_var_matrix(coffeeshop_locations, libraries, "link")

Express the business constraints¶

First constraint: if the distance is suspect, it needs to be excluded from the problem.

In [14]:
for c_loc in coffeeshop_locations:
    for b in libraries:
        if get_distance(c_loc, b) >= BIGNUM:
            mdl.add_constraint(link_vars[c_loc, b] == 0, "ct_forbid_{0!s}_{1!s}".format(c_loc, b))

Second constraint: each library must be linked to a coffee shop that is open.

In [15]:
mdl.add_constraints(link_vars[c_loc, b] <= coffeeshop_vars[c_loc]
                   for b in libraries
                   for c_loc in coffeeshop_locations)
mdl.print_information()
Model: coffee shops
 - number of variables: 6642
   - binary=6642, integer=0, continuous=0
 - number of constraints: 6561
   - linear=6561
 - parameters: defaults
 - objective: none
 - problem type is: MILP

Third constraint: each library is linked to exactly one coffee shop.

In [16]:
mdl.add_constraints(mdl.sum(link_vars[c_loc, b] for c_loc in coffeeshop_locations) == 1
                   for b in libraries)
mdl.print_information()
Model: coffee shops
 - number of variables: 6642
   - binary=6642, integer=0, continuous=0
 - number of constraints: 6642
   - linear=6642
 - parameters: defaults
 - objective: none
 - problem type is: MILP

Fourth constraint: there is a fixed number of coffee shops to open.

In [17]:
# Total nb of open coffee shops
mdl.add_constraint(mdl.sum(coffeeshop_vars[c_loc] for c_loc in coffeeshop_locations) == nb_shops)

# Print model information
mdl.print_information()
Model: coffee shops
 - number of variables: 6642
   - binary=6642, integer=0, continuous=0
 - number of constraints: 6643
   - linear=6643
 - parameters: defaults
 - objective: none
 - problem type is: MILP

Express the objective¶

The objective is to minimize the total distance from libraries to coffee shops so that a book reader always gets to our coffee shop easily.

In [18]:
# Minimize total distance from points to hubs
total_distance = mdl.sum(link_vars[c_loc, b] * get_distance(c_loc, b) for c_loc in coffeeshop_locations for b in libraries)
mdl.minimize(total_distance)

Solve with the Decision Optimization solve service¶

Solve the model.

In [19]:
print("# coffee shops locations = %d" % len(coffeeshop_locations))
print("# coffee shops           = %d" % nb_shops)

assert mdl.solve(), "!!! Solve of the model fails"
# coffee shops locations = 81
# coffee shops           = 5

Step 5: Investigate the solution and run an example analysis¶

The solution can be analyzed by displaying the location of the coffee shops on a map.

In [20]:
total_distance = mdl.objective_value
open_coffeeshops = [c_loc for c_loc in coffeeshop_locations if coffeeshop_vars[c_loc].solution_value == 1]
not_coffeeshops = [c_loc for c_loc in coffeeshop_locations if c_loc not in open_coffeeshops]
edges = [(c_loc, b) for b in libraries for c_loc in coffeeshop_locations if int(link_vars[c_loc, b]) == 1]

print("Total distance = %g" % total_distance)
print("# coffee shops  = {0}".format(len(open_coffeeshops)))
for c in open_coffeeshops:
    print("new coffee shop: {0!s}".format(c))
Total distance = 207.152
# coffee shops  = 5
new coffee shop: P_4455 N. Lincoln Ave._27
new coffee shop: P_6 S. Hoyne Ave._13
new coffee shop: P_2111 W. 47th St._20
new coffee shop: P_6100 W. Irving Park Rd._76
new coffee shop: P_9525 S. Halsted St._77

Displaying the solution¶

Coffee shops are highlighted in red.

In [21]:
import folium
map_osm = folium.Map(location=[41.878, -87.629], zoom_start=11)
for coffeeshop in open_coffeeshops:
    lt = coffeeshop.y
    lg = coffeeshop.x
    folium.Marker([lt, lg], icon=folium.Icon(color='red',icon='info-sign')).add_to(map_osm)
    
for b in libraries:
    if b not in open_coffeeshops:
        lt = b.y
        lg = b.x
        folium.Marker([lt, lg]).add_to(map_osm)
    

for (c, b) in edges:
    coordinates = [[c.y, c.x], [b.y, b.x]]
    map_osm.add_child(folium.PolyLine(coordinates, color='#FF0000', weight=5))

map_osm
Out[21]:
Make this Notebook Trusted to load map: File -> Trust Notebook

Summary¶

You have learned how to set up, formulate and solve an optimization model using Decision Optimization in IBM Cloud Pak for Data.

References¶

  • Decision Optimization CPLEX Modeling for Python documentation
  • IBM Cloud Pak for Data as a Service documentation
  • IBM watsonx.ai documentation

Copyright © 2017-2024 IBM. This notebook and its source code are released under the terms of the MIT License.