課題(第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

今回はこれをプログラムに翻訳していく。まずはライブラリを読み込む。

In [ ]:
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 に解かせてみる。)

In [ ]:
# 問題を作る(最小化問題)
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 文で作ってリストに入れる。

In [ ]:
# 変数 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$ という制約になる。

これで道具はそろった。

Part B. p-median 問題を実装する¶

B-1. データ¶

練習用の地域データである。需要点(街)12個と、練習用の配置点4か所、店舗数 $k=2$ を使う。 まずはこのデータのまま実装を完成させること(そのまま実行)。

In [ ]:
# 需要点(街)のデータ:各行が [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 文だけなので読めるはずである)。

In [ ]:
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 で計算できる
In [ ]:
# 距離 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 に追加していく
In [ ]:
# 変数の定義
# 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 である
In [ ]:
# 最小化問題として問題を作る
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. 解いて検証する¶

準備ができたので解く。次のセルはそのまま実行すること。

In [ ]:
# 求解
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)。 次のセルはそのまま実行すること。

In [ ]:
# 変数の値(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. 自分の地域で解いてレポートを作る¶

実装が正しいことを確認できたので、自分の問題を解く。

  1. B-1 のセルに戻って、pfls(配置点)と n_stores(店舗数 $k$)を自分の設定に書き換える
    • 配置点は2〜6個、座標は x 座標、y 座標とも1〜10 の範囲
    • $k$ は配置点の数より少ない値にする(同じだと全部に店舗が置かれてしまい、最適化の意味がなくなる)
    • demands(需要点)は変更しない
    • 練習用と同じ配置点のまま提出しないこと
  2. B-1 から B-5 までのセルを上から順にもう一度実行する
  3. 結果をレポート雛形(学籍番号_氏名_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)