課題(第2回):p-median 問題を pulp で実装する¶
前回(第1回)は p-median 問題の数理モデルを自分で作った。 今回はその数理モデルを pulp というライブラリを使って自分で実装し、実際に解く。
進め方は次の3部構成である。
- Part A:pulp の使い方を小さな例で覚える(読んで実行するだけ)
- Part B:練習用の地域で p-median 問題を実装する(
...の部分を自分で書く)。 正しく実装できたかどうかは、資料に書いてある正解の最適値と一致するかで確認できる。 - Part C:自分で地域を作って解き、レポートにまとめる
レポートは雛形(学籍番号_氏名_pmedian.docx)の設問1〜5に記入して提出すること。
| レポートの設問 | 対応する場所 |
|---|---|
| 設問1 作成した地域(配置点の座標と店舗数) | Part C |
| 設問2 数理モデル | 第1回の解答を自分の設定($n, m, k$)に合わせて書き直す |
| 設問3 最適値(重み付き総移動距離) | Part C |
| 設問4 需要点の割り当て | Part C |
| 設問5 結果の図示と考察 | Part C |
ルール¶
- コードのうち、
...と書いてある部分だけを書き換えること。それ以外のセルはそのまま実行する。 - 分からないときは、まず第1回の数理モデルとコードのコメントを見比べること。
0. p-median 問題の数理モデル(第1回の答え合わせ)¶
第1回で作った p-median 問題の数理モデルはこうなる(レポート設問2でもこの節を参考にすること)。
$$ \begin{align} z = &\sum_{i = 1}^{n} \sum_{j = 1}^{m} p_i \cdot d_{ij} \cdot x_{ij} \rightarrow \min, \\ \text{s.t. } \ \ &\sum_{j=1}^{m} x_{ij} = 1, \quad \forall i \in \{1, 2, \cdots, n\}, \\ &x_{ij} \leq y_{j}, \quad \forall i \in \{1, 2, \cdots, n\}, \ \forall j \in \{1, 2, \cdots, m\}, \\ &\sum_{j=1}^{m} y_{j} = k, \\ &x_{ij} \in \{0, 1\}, \quad y_{j} \in \{0, 1\}. \end{align} $$
| 記号 | 意味 |
|---|---|
| $n$ | 需要点の数 |
| $m$ | 配置点の数 |
| $k$ | 配置する店舗の数 |
| $p_i$ | 需要点 $i$ の人口 |
| $d_{ij}$ | 需要点 $i$ と配置点 $j$ の距離 |
| $x_{ij}$ | 需要点 $i$ の住民が配置点 $j$ の店舗を利用するなら1 |
| $y_j$ | 配置点 $j$ に店舗を配置するなら1 |
今回はこれをプログラムに翻訳していく。まずはライブラリを読み込む。
from matplotlib import pyplot as plt
import numpy as np
import pulp
Part A. pulp 入門(10分)¶
pulp は最適化問題を解くためのライブラリである。使い方は5つだけ覚えれば十分である。
| やること | 書き方 |
|---|---|
| 問題を作る | prob = pulp.LpProblem('名前', pulp.LpMinimize) |
| 変数を作る | a = pulp.LpVariable('a', cat='Binary')(0か1しか取らない変数) |
| 目的関数を追加する | prob += 式 (最初の1回目の +=) |
| 制約条件を追加する | prob += 式 == 値 など(2回目以降の +=) |
| 解く/値を取り出す | prob.solve() / pulp.value(a) |
p-median とは関係のない小さな例で動きを見てみよう。
例題:0か1を取る変数 $a, b, c$ がある。$a + b + c = 2$ という条件のもとで $3a + 2b + 5c$ を最小化せよ。
(答えは考えるまでもなく「安い $a$ と $b$ を1にして、高い $c$ を0にする」、最小値は5である。 これを pulp に解かせてみる。)
# 問題を作る(最小化問題)
prob = pulp.LpProblem('example', pulp.LpMinimize)
# 0-1変数を3つ作る
a = pulp.LpVariable('a', cat='Binary')
b = pulp.LpVariable('b', cat='Binary')
c = pulp.LpVariable('c', cat='Binary')
# 目的関数を追加する(最初の += は目的関数と解釈される)
prob += 3 * a + 2 * b + 5 * c
# 制約条件を追加する(2回目以降の += は制約条件と解釈される)
prob += a + b + c == 2
# 解く
prob.solve(pulp.PULP_CBC_CMD(msg=False))
print('求解結果:', pulp.LpStatus[prob.status])
# 変数の値と目的関数の値を取り出す
print('a =', pulp.value(a))
print('b =', pulp.value(b))
print('c =', pulp.value(c))
print('最小値 =', pulp.value(prob.objective))
予想通り $a = b = 1, c = 0$、最小値 5 になったはずである。
あと2つ、p-median の実装で使う道具を紹介する。
変数はリストに入れられる:変数がたくさんあるときは、for 文で作ってリストに入れる。
# 変数 v_1, v_2, ..., v_5 を作ってリストに入れる
v = []
for j in range(5):
v.append(pulp.LpVariable(f'v_{j+1}', cat='Binary'))
print(v)
リストの中身の合計は pulp.lpSum:数理モデルの $\sum$ に対応する。
例えば pulp.lpSum(v) == 2 は $\sum_{j=1}^{5} v_j = 2$ という制約になる。
これで道具はそろった。
# 需要点(街)のデータ:各行が [x座標, y座標, 人口]
demands = [
[2, 9, 500],
[5, 9, 300],
[8, 9, 200],
[1, 6, 400],
[4, 6, 700],
[7, 6, 300],
[9, 7, 100],
[2, 3, 600],
[5, 3, 200],
[8, 3, 400],
[3, 1, 100],
[7, 1, 300],
]
# 配置点の座標:[x座標, y座標](Part B ではこのまま使う)
pfls = [
[2, 8],
[5, 3],
[7, 7],
[9, 2],
]
# 配置する店舗の数 k
n_stores = 2
n_demands = len(demands) # 需要点の数 n
n_pfls = len(pfls) # 配置点の数 m
地域を描画する関数はこちらで用意した。次のセルはそのまま実行すること (中身は読まなくても課題はできるが、for 文と if 文だけなので読めるはずである)。
def plot_region(demands, pfls, x=None, y=None):
plt.figure(figsize=(8, 8))
plt.xlim(0, 11)
plt.ylim(0, 11)
# 需要点と店舗を結ぶ線(x が与えられたときだけ描く)
if x is not None:
for i in range(len(demands)):
for j in range(len(pfls)):
if x[i][j] == 1:
plt.plot([demands[i][0], pfls[j][0]],
[demands[i][1], pfls[j][1]], color='gray')
# 需要点(青い丸)と、その番号と人口
for i in range(len(demands)):
plt.scatter(demands[i][0], demands[i][1], color='royalblue', s=400)
plt.text(demands[i][0], demands[i][1], str(i + 1),
color='white', ha='center', va='center')
plt.text(demands[i][0] + 0.25, demands[i][1] + 0.25,
str(demands[i][2]), color='royalblue')
# 配置点(赤い丸)と、その番号。店舗が置かれた点(y[j]=1)は黄色にする
for j in range(len(pfls)):
if y is not None and y[j] == 1:
plt.scatter(pfls[j][0], pfls[j][1], color='gold', s=400)
else:
plt.scatter(pfls[j][0], pfls[j][1], color='tomato', s=400)
plt.text(pfls[j][0], pfls[j][1], str(j + 1),
color='white', ha='center', va='center')
plt.show()
plot_region(demands, pfls)
B-2. 距離 $d_{ij}$ を計算する【実装1】¶
需要点 $i$(座標 $(x_i, y_i)$)と配置点 $j$(座標 $(x_j, y_j)$)の距離は、三平方の定理で
$$d_{ij} = \sqrt{(x_i - x_j)^2 + (y_i - y_j)^2}$$
と計算できる。... の部分を埋めて、距離の表 distance を完成させること。
demands[i][0]が需要点 $i$ の x座標、demands[i][1]が y座標であるpfls[j][0]が配置点 $j$ の x座標、pfls[j][1]が y座標である- 平方根は
np.sqrt( )、2乗は** 2で計算できる
# 距離 d_ij の計算:distance[i][j] = 需要点i と配置点j の距離
distance = np.zeros((n_demands, n_pfls))
for i in range(n_demands):
for j in range(n_pfls):
dx = ... # x座標の差
dy = ... # y座標の差
distance[i][j] = ...
print(distance)
確認:distance の左上(1行1列目)は需要点1 (2, 9) と配置点1 (2, 8) の距離なので、
ちょうど1.0 になるはずである。距離の単位は [km] とする。
B-3. 変数 $x_{ij}, y_j$ を作る【実装2】¶
Part A の「変数をリストに入れる」を使って、変数を作る。
yは $y_1, \cdots, y_m$ のリスト(Part A のvとほぼ同じ形)xは「リストのリスト」にする:x[i][j]が $x_{ij}$ に対応するように、 需要点 $i$ ごとに1行分のリストrowを作ってxに追加していく
# 変数の定義
# x[i][j]: 需要点i の住民が配置点j の店舗を利用するなら1
x = []
for i in range(n_demands):
row = []
for j in range(n_pfls):
row.append(pulp.LpVariable(f'x_{i+1}_{j+1}', cat=...))
x.append(row)
# y[j]: 配置点j に店舗を置くなら1
y = []
for j in range(n_pfls):
y.append(...)
変数を Binary で作れば、数理モデルの変数制約($x_{ij} \in \{0,1\}, y_j \in \{0,1\}$)は
自動的に満たされる。
B-4. 目的関数と制約条件を書く【実装3】¶
いよいよ数理モデルの本体である。冒頭の数理モデルと1行ずつ見比べながら、... を埋めること。
- 目的関数 $\sum_i \sum_j p_i \cdot d_{ij} \cdot x_{ij}$:
二重の for 文で「人口 × 距離 × 変数」を
objに足し込んでいく。 人口 $p_i$ はdemands[i][2]である - 制約条件1 $\sum_j x_{ij} = 1$:
x[i]は需要点 $i$ の行(変数のリスト)なのでpulp.lpSumが使える - 制約条件2 $x_{ij} \leq y_j$:Python では $\leq$ は
<=と書く - 制約条件3 $\sum_j y_j = k$:店舗数は
n_storesである
# 最小化問題として問題を作る
prob = pulp.LpProblem('p-median', pulp.LpMinimize)
# 目的関数:重み付き総移動距離(人口 × 距離 × x_ij の総和)
obj = 0
for i in range(n_demands):
for j in range(n_pfls):
obj += ...
prob += obj
# 制約条件1: 各需要点の住民はちょうど1つの店舗を利用する
for i in range(n_demands):
prob += ...
# 制約条件2: 店舗が置かれていない配置点は利用できない(x_ij <= y_j)
for i in range(n_demands):
for j in range(n_pfls):
prob += ...
# 制約条件3: 配置する店舗の数はちょうど n_stores 個
prob += ...
B-5. 解いて検証する¶
準備ができたので解く。次のセルはそのまま実行すること。
# 求解
prob.solve(pulp.PULP_CBC_CMD(msg=False))
print('求解結果:', pulp.LpStatus[prob.status])
# 最適値(重み付き総移動距離)
f = pulp.value(prob.objective)
print(f'重み付き総移動距離(最適値): {f:.2f} [人/km]')
ここが検証ポイントである。 練習用データ(配置点4か所、$k=2$)で正しく実装できていれば、
求解結果: Optimal 重み付き総移動距離(最適値): 11318.28 [人/km]
になる。値が違う場合はどこかの実装が間違っている。よくある間違い:
- 最適値が明らかに大きい、小さい場合は目的関数に人口(
demands[i][2])を掛け忘れていないか - 「Infeasible」と表示される場合は制約条件の
==や<=を取り違えていないか - エラーが出る場合は
...を埋め忘れていないか
一致したら、解の中身を取り出す。変数の値は pulp.value() で取り出せたのであった(Part A)。
次のセルはそのまま実行すること。
# 変数の値(0 か 1)を取り出してリストにする
x_val = []
for i in range(n_demands):
row = []
for j in range(n_pfls):
row.append(int(pulp.value(x[i][j])))
x_val.append(row)
y_val = []
for j in range(n_pfls):
y_val.append(int(pulp.value(y[j])))
print('店舗が置かれた配置点 (y):', y_val)
# 各需要点が利用する配置点の一覧(レポート設問4 の表に対応)
print()
print('需要点 ID | 利用する配置点 ID')
for i in range(n_demands):
for j in range(n_pfls):
if x_val[i][j] == 1:
print(f'{i + 1:>8} | {j + 1}')
# 結果の図示(黄色 = 店舗が置かれた配置点、灰色の線 = 誰がどの店舗を使うか)
plot_region(demands, pfls, x=x_val, y=y_val)
図を見て、割り当てが直感に合っているか(住民はちゃんと近い店舗に割り当てられているか)確認しよう。
Part C. 自分の地域で解いてレポートを作る¶
実装が正しいことを確認できたので、自分の問題を解く。
- B-1 のセルに戻って、
pfls(配置点)とn_stores(店舗数 $k$)を自分の設定に書き換える- 配置点は2〜6個、座標は x 座標、y 座標とも1〜10 の範囲
- $k$ は配置点の数より少ない値にする(同じだと全部に店舗が置かれてしまい、最適化の意味がなくなる)
demands(需要点)は変更しない- 練習用と同じ配置点のまま提出しないこと
- B-1 から B-5 までのセルを上から順にもう一度実行する
- 結果をレポート雛形(学籍番号_氏名_pmedian.docx)の設問1〜5にまとめる
- 設問2の数理モデルは、第1回の解答の $n, m, k$ を自分の設定の具体的な数値に置き換えて書く
- 設問5の図は保存(またはスクリーンショット)して貼り付ける
考察のヒント(設問5)¶
そのまま写すのではなく、自分の結果に即して書くこと。
- 店舗が選ばれた配置点と選ばれなかった配置点の違いは何か(人口の多い需要点との位置関係など)
- 各需要点はどのような基準で店舗に割り当てられているか
- 店舗の数 $k$ を増やしたり減らしたりすると、最適値はどう変わりそうか(余裕があれば実際に試してみるとよい)
提出前チェックリスト¶
- 練習用データで最適値 11318.28 [人/km] が再現できた
- 配置点は2〜6個で、座標がすべて1〜10の範囲に収まっている(設問1)
- 店舗の数 $k$ が配置点の数より少ない(設問1)
- 数理モデルの $n, m, k$ が自分の設定と一致している(設問2)
- 最適値に単位 [人/km] が付いている(設問3)
- 割り当て表が図や最適解と一致している(設問4)
- 図が貼り付けてあり、考察が自分の結果に基づいて書かれている(設問5)