【最適解を求めるのは諦める】整数計画問題を線形緩和してソルバーで解いて、緩和問題の解を確率と解釈して許容解を求められる?

この記事のキーワード
  • 整数計画問題をソルバーで解くと時間がかかる…
  • 短時間でまあまあ良い解が欲しいな…
  • 線形緩和問題の解を確率として解釈するってなに?


こんにちは!しゅんです!

最近ある問題をソルバーで解いてたときにこう思いました。

別に最適解を求める必要はないから、短時間である程度良い解を求めたいな~

特に実務で最適化問題を扱うときはこう考える場合が多いんじゃないでしょうか。ということでこの記事では、線形緩和問題の最適解から整数解を構成する手法を試してみたいと思います!

それではやっていきましょう!

普段は組合せ最適化の記事を書いてたりします。
ぜひ他の記事も読んでみてください!



このブログの簡単な紹介はこちらに書いてあります。
興味があったら見てみてください。

このブログでは経営工学を勉強している現役理系大学生が、経営工学に関することを色々話していきます!


ぼくが経営工学を勉強している中で感じたことや、興味深かったことを皆さんと共有出来たら良いなと思っています。


そもそも経営工学とは何なのでしょうか。Wikipediaによると

経営工学(けいえいこうがく、英: engineering management)は、人・材料・装置・情報・エネルギーを総合したシステムの設計・改善・確立に関する活動である。そのシステムから得られる結果を明示し、予測し、評価するために、工学的な分析・設計の原理・方法とともに、数学、物理および社会科学の専門知識と経験を利用する。

引用元 : 経営工学 – Wikipedia

長々と書いてありますが、要は経営、経済の課題を理系的な観点から解決する学問です。



今回用いる手法の説明

線形計画問題は短時間で解ける


あるあるですが、整数計画問題は解くのに時間がかかりがちです。特に問題が大規模になると全然最適解が得られないことがあります。一方で整数計画問題と比べると、線形計画問題は解くのに時間がかかりません。

ということで今回用いる手法は

解きたい0-1整数計画問題を線形緩和して、線形計画問題を解いてしまおう


というものです。


線形緩和したら整数解が得られるか分からない


というもののこの手法には1つ大きな課題があります。それは

緩和問題の最適解が整数解とは限らない


ということです。元問題の解は0か1しか取れないですが、線形緩和した問題の解は0以上1以下の任意の実数を取る可能性があります。例えば緩和問題の解に0.4が含まれていたら整数解ではなくなってしまいます。従って元問題の許容解ではなくなってしまうということです。

この課題を解決しなければいけません。


整数じゃない解は確率として考える


この問題を解決する1つの策が

解を確率として考える


ということです。


例えば上のように緩和問題の最適解が

\(x_1 = 1\)
\(x_2 = 0\)
\(x_3 = 0.3\)
\(x_4 = 0.8\)
\(x_5 = 0.4\)

だとします。これを確率とみなします。つまり

\(x_3\)は\(0.3\)の確率で1を取る。
\(x_4\)は\(0.8\)の確率で1を取る。
\(x_5\)は\(0.4\)の確率で1を取る。

と言う風に解釈します。こうして全ての変数の値を0か1に丸めていくことで、最終的に整数解を得ようと思います。

もっと深堀り!


このような手法を乱択丸め法(randomized rounding)と言います。


今回の記事ではこの乱択丸め法を使って「集合被覆問題」「シフトスケジューリング問題」を解いてみたいと思います!


今回解く問題

集合被覆問題


集合 \(U=\{e_1,e_2,…,e_n\}\) と \(U\) の部分集合族 \(\mathcal S = \{S_1,S_2,…,S_m\}\) について考えます。各集合 \(S_k\) にはコスト \(c_{S_{k}}\) が与えられています。

例)
\(U = \{1,2,3,4,5,6\}\)

\(\mathcal S = \{S_1,S_2,S_3,S_4\}\)
\(S_1=\{1,2,3\}, \; c_{S_{1}}=3\)
\(S_2=\{3,5\}, \; c_{S_{2}}=1\)
\(S_3=\{4,5,6\}, \; c_{S_{3}}=2\)
\(S_4=\{4,6\}, \; c_{S_{4}}=2\)

今いくつかの集合を取ってきて、\(U\)の全ての要素をカバーすることを考えます。

例)
\(\{S_1,S_3\}\)はOK(\(S_1 \cup S_3 = \{1,2,3,4,5,6\}\))
\(\{S_1,S_2\}\)はNG(\(S_1 \cup S_2 = \{1,2,3,5\}\))
\(\{S_1,S_2,S_4\}\)はOK(\(S_1 \cup S_2 \cup S_4 = \{1,2,3,4,5,6\}\))

この中でコストの合計が最小になるようなものを見つけるのが集合被覆問題です。

例)
\(\{S_1,S_3\}\)のコスト:\(c_{S_{1}}+c_{S_{3}}=3+2=5\)
\(\{S_1,S_2,S_4\}\)のコスト:\(c_{S_{1}}+c_{S_{2}}+c_{S_{4}}=3+1+2=6\)

この問題例だと最適解は\(\{S_1,S_3\}\)となります。これを整数計画問題として定式化すると下記のようになります。

パラメータ:
\(U\) : 集合
\(\mathcal{S} = \{S_1,S_2,…,S_m\}\) : \(U\) の部分集合族


変数:
\(x_{S} \in \{0,1\}\) : 部分集合 \(S \in \mathcal{S}\) を採用するなら1、そうでないなら0を取る0-1変数



定式化:
\( \min \;\;\; \sum\limits_{S \in \mathcal{S}}x_S\)
\(\;\text{s.t.}\;\;\;\sum\limits_{S:S\in\mathcal{S}, e \in S}x_{S} \geq 1 \;\;\; (\forall e \in U)\)
\(\;\;\;\;\;\;\;\;\; x_{S} \in \{0,1\} \;\;\; (\forall S \in \mathcal{S})\)


シフトスケジューリング問題


シフトスケジューリング問題は色々バリエーションがありますが、今回は下記の問題を扱おうと思います。

期間:
1カ月(28日間)のシフトを決める。

スタッフ:
10人いる。

シフト枠:
各日「早番」「日勤」「遅番」の3シフト、計84シフト(28日×3)。

要件:
各シフトには必ず1人配置しなければならない。

1人のスタッフが1日に2シフト入ることはない(早番、日勤、遅番の掛け持ち不可)。

スタッフによっては「出勤不可日」がある(たとえば従業員1は土日NGなど)。
各スタッフの総勤務回数は最大10回まで。

なるべく全員の勤務回数が偏らないようにしたい(公平性)。


これを整数計画問題として定式化すると下記のようになります。

パラメータ:
\(P\) : 従業員の集合
\(D\) : 日付の集合
\(S\) : 勤務の集合
\(a_{d}^p \in \{0,1\}\):従業員 \(p\) の日付 \(d\) が出勤不可日なら1、出勤可能日なら0を取る


変数:
\(x_{ds}^{p} \in \{0,1\}\) : 従業員\(p\)が日付\(d\)の勤務\(s\)に出勤するなら1、そうでないなら0を取る0-1変数

目的関数:
\(\min \;\; \max\sum\limits_{p \in P}\sum\limits_{d \in D}\sum\limits_{s \in S}x_{ds}^{p}\)

制約条件:
\(\sum\limits_{p \in P}x_{ds}^{p} = 1 \;\;\; (\forall d \in D, \; \forall s \in S)\)
\(\sum\limits_{s \in S}x_{ds}^{p} \leq 1 \;\;\; (\forall d \in D, \; \forall p \in P)\)
\(a_{d}^px_{ds}^{p}=0 \;\;\; (\forall d \in D, \; \forall s \in S, \; \forall p \in P)\)
\(\sum\limits_{d \in D}\sum\limits_{s \in S}x_{ds}^{p} \leq 10 \;\;\; (\forall p \in P)\)
\(x_{ds}^{p} \in \{0,1\} \;\;\; (\forall p \in P, \; \forall d \in D, \; \forall s \in S)\)


pythonで集合被覆問題を乱択丸め法で解いてみた


それでは実際にpythonで集合被覆問題を解いてみましょう。


集合被覆問題を解くコード


import random
import pulp

def generate_set_cover_instance(n=10, m=20, p=0.3, cost_min=1, cost_max=5, seed=None):
    """
    【集合被覆問題のインスタンス生成】
    - n: ユニバース(要素)の数
    - m: 集合の数
    - p: 各集合が特定の要素を含む確率
    - cost_min, cost_max: 各集合のコストを生成する際の最小値・最大値
    - seed: 乱数シード(再現性が必要な場合に使用)

    戻り値: (U, S, cost)
      U: 要素を格納したリスト (例: [0,1,2,...])
      S: 各集合(部分集合)のリスト(S[j] は j番目の部分集合)
      cost: 各集合のコストを格納したリスト
    """
    if seed is not None:
        random.seed(seed)
    
    # ユニバース(要素)のリスト
    U = list(range(n))
    # 各集合を保持するリスト
    S = []
    # 各集合のコストを保持するリスト
    cost = []

    # 乱数を使って集合とコストを生成
    for j in range(m):
        subset = [i for i in U if random.random() < p]
        S.append(subset)
        cost.append(random.randint(cost_min, cost_max))
    
    return U, S, cost


def solve_lp_relaxation_and_get_xstar(U, S, cost):
    """
    【集合被覆問題の線形緩和をPuLPで解き、連続解 x_star を取得する関数】
    モデル:
      min ∑(cost[j] * x_j)
      s.t. ∑(x_j : i ∈ S_j) >= 1, すべての要素 i
           0 <= x_j <= 1 (連続変数)

    戻り値: 
      x_star: 各変数 x_j の連続解(list of float)
      obj_lp: 線形緩和での目的関数値(float)
      status: ソルバーの終了ステータス名(文字列)
    """
    n = len(U)   # 要素数
    m = len(S)   # 集合の個数
    
    # PuLPで問題を定義(最小化問題)
    prob = pulp.LpProblem("SetCoverLP", pulp.LpMinimize)
    
    # 変数 x_j を連続変数として定義 (0 <= x_j <= 1)
    x = pulp.LpVariable.dicts('x', range(m), lowBound=0, upBound=1, cat=pulp.LpContinuous)
    
    # 目的関数: Σ (コスト_j * x_j)
    prob += pulp.lpSum(cost[j] * x[j] for j in range(m)), "Objective"
    
    # 被覆制約: すべての要素 i が少なくとも1つの集合に含まれるようにする
    for i in range(n):
        prob += pulp.lpSum(x[j] for j in range(m) if i in S[j]) >= 1, f"CoverConstraint_{i}"
    
    # ソルバーで解を求める(CBCがデフォルト)
    result_status = prob.solve(pulp.PULP_CBC_CMD(msg=0))
    
    # 線形緩和の目的関数値
    obj_lp = pulp.value(prob.objective)
    # 連続解 x_star
    x_star = [pulp.value(x[j]) for j in range(m)]
    
    return x_star, obj_lp, pulp.LpStatus[result_status]


def randomized_rounding(x_star, cost, S):
    """
    【乱択丸め法】
    - 線形緩和で得た x_star[j] を採用確率とみなし、
      random.random() < x_star[j] により 0 or 1 を決定。
    - 得られた二値解における目的値と、
      「被覆できなかった要素(制約違反)の数」を返す。

    戻り値:
      obj_rounded: 丸め後の目的値
      violations: カバーできなかった要素の個数
      rounded_sol: 二値解(リスト)
    """
    m = len(S)
    # 要素数(Sが空の場合を回避するための簡易的な取得方法)
    if m == 0:
        n = 0
    else:
        # 最大の要素番号+1を要素数とみなす(厳密には要注意)
        n = max((max(s) for s in S if len(s) > 0), default=-1) + 1
    
    # 乱択丸めを実行
    rounded_sol = []
    for j in range(m):
        if random.random() < x_star[j]:
            rounded_sol.append(1)
        else:
            rounded_sol.append(0)
    
    # 丸め後の目的値を計算
    obj_rounded = sum(cost[j] * rounded_sol[j] for j in range(m))
    
    # 制約違反(カバーされなかった要素の数)をカウント
    violations = 0
    for i in range(n):
        # i を含む集合が一つも選ばれていなければカバー失敗
        covered = sum(rounded_sol[j] for j in range(m) if i in S[j])
        if covered < 1:
            violations += 1
    
    return obj_rounded, violations, rounded_sol


ちゃんと許容解になっているか調べる


上のコードを使って乱択丸め法の出力解がどれだけ許容解を出力するか、どれだけ制約違反をしているのかを調査したいと思います。

調査方法:

上のコードを100回実行して下記データを計算する。

・乱択丸め法によって許容解が得られた回数
・制約違反数(カバーされなかった要素数)の平均値


2つ目に関しては、例えば100回実行してそのうち95回許容解が得られ、5回実行可能でない解が得られたとします。そして5回の実行可能でない解のカバーされなかった要素数がそれぞれ5個、1個、2個、3個、1個のとき、2つ目の値は

\((5+1+2+3+1) \div 5 = 2.4\)

と計算されます。

それでは実際に調査してみましょう。

def experience(num_trials = 100):
    
    feasible_count = 0     # 制約違反が0(完全被覆)の回数
    total_violations = 0   # 違反要素数の合計
    total_obj = 0          # 丸め後の目的値の合計
    
    for _ in range(num_trials):
        #========================================================================
        # 1. 問題インスタンスを生成(集合被覆問題)
        #========================================================================
        #   n=10個の要素、m=20個の集合、p=0.3で「その要素が集合に含まれる確率」を設定。
        #   seed=0 でランダムを固定化して再現性を確保。
        #========================================================================
        U, S, cost = generate_set_cover_instance(n=10, m=20, p=0.3, seed=None)
        n = len(U)
        m = len(S)
    
        #========================================================================
        # 2. 線形緩和を解き、連続変数 x_star を取得
        #========================================================================
        x_star, obj_lp, status = solve_lp_relaxation_and_get_xstar(U, S, cost)
    
        #========================================================================
        # 3. 乱択丸め法を複数回(例:100回)実行して結果を集計
        #========================================================================
        _, violations, _ = randomized_rounding(x_star, cost, S)
        total_violations += violations
        
        # カバーできなかった要素が0なら「許容解」とみなす
        if violations == 0:
            feasible_count += 1
    
    # 乱択丸め法の結果を統計的にまとめる
    avg_violations = total_violations / (num_trials - feasible_count)
    
    #========================================================================
    # 4. 実験結果を表示
    #========================================================================
    print(f"=== 乱択丸め法を {num_trials} 回試行した結果 ===")
    print(f" - 許容解(制約違反0)の回数 : {feasible_count} / {num_trials}")
    print(f" - 制約違反数(被覆漏れ要素数)の平均値 : {avg_violations:.2f}")

# 実行する
experience(num_trials = 100)



ということで上図の結果が得られました。結果としては許容解が得られた回数は80回許容解が得られなかった回数は20回でした。また20回のうち、カバーされなかった要素数の平均値は3.25個となりました。

もちろんランダムを使用しているので実行するたびに答えは変わりますが、やはり100%許容解が得られるわけではなかったです。


パラメータを変更して結果を比較する


上の調査ではパラメータの値を

集合 \(U\) の要素数:10
集合 \(U\) の部分集合の数:20

で問題を解きました。今度はパラメータを

集合 \(U\) の要素数:20
集合 \(U\) の部分集合の数:40

と変更して同じように調査してみたいと思います。コードはパラメータの設定以外は先ほどと同じなので省略します。


ということで上図の結果が得られました。許容解が得られた回数は39回で、さっきよりも悪化してしまいました。

もっと深堀り!


なぜパラメータの値を増やしたら許容解の回数が減ったのかを考えましたが、LP解が分散して1個1個の値が小さくなったのが原因なのではないかと思いました。例えば要素 \(e\) に関する制約は

\(\sum\limits_{S:S\in\mathcal{S}, e \in S}x_{S} \geq 1\)

となります。例えばパラメータの値が小さくしたら左辺のシグマの条件に当てはまる \(x_S\) の数が3個だけになったとします。そしたらその3つの和が1以上になるので変数1個の値はなんとなく0.33くらいかな~と考えられます。一方でパラメータの値を大きくした場合、左辺のシグマの条件に当てはまる \(x_S\) の数は増えることが想像できます。仮に10個だとしたら、その10個の和が1以上になれば良いので変数1個の値はなんとなく0.1くらいかな~と考えられます。そうなると変数が1になる確率が小さくなるので、結果的に許容解を得られる可能性が低くなるんじゃないかな~と思いました。

詳しい解析はしていないので、より詳細な情報を知っている方がいたらぜひコメントお願いします!!!


pythonでシフトスケジューリング問題を乱択丸め法で解いてみた


それでは実際にpythonでシフトスケジューリング問題を解いてみましょう。


シフトスケジューリング問題を解くコード


import random
import pulp

def generate_shift_scheduling_instance(numP=10, numD=28, numS=3, unavail_prob=0.2, seed=None):
    """
    【シフトスケジューリング問題のインスタンス生成】
    - numP: 従業員数 (|P|)
    - numD: 日数 (|D|)
    - numS: シフト数 (|S|)
    - unavail_prob: 出勤不可の確率(1なら出勤不可、0なら出勤可能)
    - seed: 乱数シード(再現性のため)
    
    戻り値: (P, D, S, availability)
      P: 従業員のリスト(例: [0,1,2,...])
      D: 日付のリスト(例: [0,1,...])
      S: シフトのリスト(例: [0,1,...])
      availability: 辞書 {(p,d): 1なら従業員pが日dに出勤不可、0なら出勤可能}
    """
    if seed is not None:
        random.seed(seed)
    
    P = list(range(numP))
    D = list(range(numD))
    S = list(range(numS))
    
    availability = {}
    for p in P:
        for d in D:
            # 1: 出勤不可, 0: 出勤可能
            availability[(p, d)] = 1 if random.random() < unavail_prob else 0
    
    return P, D, S, availability

def solve_lp_relaxation_shift_scheduling(P, D, S, availability):
    """
    【シフトスケジューリング問題の線形緩和をPuLPで解いて連続解 x_star を取得する関数】
    モデル:
      変数 x_{d,s}^p ∈ [0,1](連続変数)
      補助変数 t : 各従業員の総出勤回数の上限(最大出勤回数を t とする)
      
      目的関数:
        min t
      制約条件:
        (1) ∀d∈D, ∀s∈S:  ∑_{p∈P} x_{d,s}^p = 1
        (2) ∀d∈D, ∀p∈P:  ∑_{s∈S} x_{d,s}^p ≤ 1
        (3) ∀p∈P, ∀d∈D, ∀s∈S:  x_{d,s}^p ≤ 1 - a_d^p
        (4) ∀p∈P:  ∑_{d∈D, s∈S} x_{d,s}^p ≤ 10
        (5) ∀p∈P:  ∑_{d∈D, s∈S} x_{d,s}^p ≤ t   (各従業員の出勤回数の上限を t で表す)
    
    戻り値:
      x_star: 辞書 {(p,d,s): 連続解値}
      t_star: LPの目的関数値(tの値)
      status: ソルバー終了ステータス(文字列)
    """
    # 問題の定義(最小化問題)
    model = pulp.LpProblem("ShiftSchedulingLP", pulp.LpMinimize)
    
    # 変数 x_{d,s}^p (連続変数, 0 <= x <= 1)
    x = {}
    for p in P:
        for d in D:
            for s in S:
                x[(p, d, s)] = pulp.LpVariable(f"x_{p}_{d}_{s}", lowBound=0, upBound=1, cat=pulp.LpContinuous)
    
    # 補助変数 t
    t = pulp.LpVariable("t", lowBound=0, cat=pulp.LpContinuous)
    
    # 目的関数: t を最小化(各従業員の出勤回数の最大値を小さくする)
    model += t, "Minimize_maximum_workload"
    
    # 制約1: 各日 d, 各シフト s に対して、必ず1人の従業員が割り当てられる
    for d in D:
        for s in S:
            model += pulp.lpSum(x[(p, d, s)] for p in P) == 1, f"Cover_day{d}_shift{s}"
    
    # 制約2: 各日 d, 各従業員 p に対して、その日に複数のシフトに入らない
    for d in D:
        for p in P:
            model += pulp.lpSum(x[(p, d, s)] for s in S) <= 1, f"OneShiftPerDay_p{p}_d{d}"
    
    # 制約3: 出勤不可日の従業員には勤務を割り当てない
    for p in P:
        for d in D:
            for s in S:
                model += x[(p, d, s)] <= 1 - availability[(p, d)], f"Availability_p{p}_d{d}_s{s}"
    
    # 制約4: 各従業員の総出勤回数が10回以内
    for p in P:
        model += pulp.lpSum(x[(p, d, s)] for d in D for s in S) <= 10, f"MaxShifts_p{p}"
    
    # 制約5: 各従業員の総出勤回数が t 以下(t = 各従業員の出勤回数の上限)
    for p in P:
        model += pulp.lpSum(x[(p, d, s)] for d in D for s in S) <= t, f"Define_t_p{p}"
    
    # LP を解く
    model.solve(pulp.PULP_CBC_CMD(msg=0))
    t_star = pulp.value(model.objective)
    
    # 連続解 x_star を辞書に格納
    x_star = {}
    for p in P:
        for d in D:
            for s in S:
                x_star[(p, d, s)] = pulp.value(x[(p, d, s)])
    
    return x_star, t_star, pulp.LpStatus[model.status]

def randomized_rounding_shift(x_star, P, D, S):
    """
    【乱択丸め法によるシフトスケジューリング問題の解生成】
    - LP の連続解 x_star を採用確率として、各変数 x_{d,s}^p を 0/1 に丸める。
    
    戻り値:
      x_round: 辞書 {(p,d,s): 0 または 1}
    """
    x_round = {}
    for p in P:
        for d in D:
            for s in S:
                x_round[(p, d, s)] = 1 if random.random() < x_star[(p, d, s)] else 0
    return x_round

def evaluate_solution(x_round, P, D, S, availability):
    """
    【乱択丸め後の解の評価】
    以下の各制約について、違反している回数を計算する。
    
    制約1: 各日 d, 各シフト s で、割り当てられている従業員数がちょうど1であること
    制約2: 各日 d, 各従業員 p で、その日のシフト割り当てが1回以内であること
    制約3: 出勤不可日の従業員に勤務が割り当てられていないこと
    制約4: 各従業員 p の総出勤回数が10回以内であること
    
    戻り値:
      viol1, viol2, viol3, viol4: 各制約の違反数
      max_workload: 各従業員の出勤回数の最大値(丸め後の目的値)
    """
    # 制約1評価
    viol1 = 0
    for d in D:
        for s in S:
            assigned = sum(x_round[(p, d, s)] for p in P)
            if assigned != 1:
                viol1 += 1

    # 制約2評価
    viol2 = 0
    for d in D:
        for p in P:
            shifts_assigned = sum(x_round[(p, d, s)] for s in S)
            if shifts_assigned > 1:
                viol2 += 1

    # 制約3評価
    viol3 = 0
    for p in P:
        for d in D:
            for s in S:
                if availability[(p, d)] == 1 and x_round[(p, d, s)] == 1:
                    viol3 += 1

    # 制約4評価
    viol4 = 0
    for p in P:
        total_shifts = sum(x_round[(p, d, s)] for d in D for s in S)
        if total_shifts > 10:
            viol4 += 1

    # 各従業員の出勤回数の最大値(目的値)
    max_workload = max(sum(x_round[(p, d, s)] for d in D for s in S) for p in P)
    
    return viol1, viol2, viol3, viol4, max_workload


ちゃんと許容解になっているか調べる


上のコードを使って乱択丸め法の出力解がどれだけ許容解を出力するか、どれだけ制約違反をしているのかを調査したいと思います。

調査方法:

上のコードを100回実行して下記データを計算する。

・乱択丸め法によって許容解が得られた回数
・制約違反数(カバーされなかった要素数)の平均値


シフトスケジューリング問題は4種類の制約があるので、制約ごとに違反数の平均値を計算したいと思います。

それでは実際に調査してみましょう。

def experience_shift(num_trials=100):
    """
    【シフトスケジューリング問題の乱択丸め法による試行実験】
    - インスタンスを生成し、LP 線形緩和を解いて連続解 x_star を取得
    - 乱択丸め法により 0-1 解を生成し、各制約違反数と最大出勤回数を評価
    - 複数回試行し、各違反数の平均と許容解(全制約違反0)の回数を出力
    """
    total_viol1 = 0
    total_viol2 = 0
    total_viol3 = 0
    total_viol4 = 0
    total_max_workload = 0
    feasible_count = 0  # 全制約違反がゼロの試行回数

    # 乱択丸め法の試行を複数回実施
    for _ in range(num_trials):
    
        # インスタンス生成(例: 従業員10名、日28日、シフト3種類、出勤不可確率0.2)
        P, D, S, availability = generate_shift_scheduling_instance(numP=10, numD=28, numS=3, unavail_prob=0.2, seed=None)
    
        # LP 線形緩和を解いて連続解を取得
        x_star, t_star, status = solve_lp_relaxation_shift_scheduling(P, D, S, availability)
    
    
        x_round_trial = randomized_rounding_shift(x_star, P, D, S)
        viol1, viol2, viol3, viol4, max_w = evaluate_solution(x_round_trial, P, D, S, availability)
        
        total_viol1 += viol1
        total_viol2 += viol2
        total_viol3 += viol3
        total_viol4 += viol4
        total_max_workload += max_w
        
        if viol1 == 0 and viol2 == 0 and viol3 == 0 and viol4 == 0:
            feasible_count += 1
    
    avg_viol1 = total_viol1 / num_trials
    avg_viol2 = total_viol2 / num_trials
    avg_viol3 = total_viol3 / num_trials
    avg_viol4 = total_viol4 / num_trials
    avg_max_workload = total_max_workload / num_trials
    
    print("=== 乱択丸め法を複数回試行した結果 ===")
    print(f"試行回数: {num_trials}")
    print(f"許容解(全制約違反0)の回数: {feasible_count}")
    print(f"制約1(各日・シフトに1人割り当て)違反の平均: {avg_viol1:.2f}")
    print(f"制約2(各日・従業員は1シフト以内)違反の平均: {avg_viol2:.2f}")
    print(f"制約3(出勤不可日の割り当て禁止)違反の平均: {avg_viol3:.2f}")
    print(f"制約4(各従業員の総出勤回数<=10)違反の平均: {avg_viol4:.2f}")
    print(f"最大出勤回数の平均: {avg_max_workload:.2f}")

experience_shift(num_trials=100)


ということで上図の結果が得られました。結果としては1回も許容解を得ることが出来ませんでした。各制約の違反具合を見てみると制約1と制約2はいくつかの違反がありましたが、制約3と制約4は1回も違反していないそうです。

つまりこの手法でシフトを作成すると、平均して3~4個のシフトに対してちょうど1人の従業員が割り当てられておらず、平均して0.3人の従業員は1日に2シフト以上を担当しているということが判明しました。


乱択丸め法によって得られた解がどうなってるかを詳しく調べる


乱択丸め法によって得られた解が実際にどんなシフトを表現しているかが気になったので調べてみました。

import matplotlib.pyplot as plt
import matplotlib.patches as patches

def plot_gantt(x_round, P, D, S, availability):
    """
    【ガントチャートによるシフトスケジュールの表示】
    各従業員(y軸)に対して、各日(x軸)の勤務をバーで表示する。
    - バーの色はシフトに対応(ここではシフト0,1,2に対して異なる色を設定)
    - 1日に複数シフト割り当てされている場合は、バーを分割し赤枠で表示(制約2違反)
    - 出勤不可日に割り当てがあれば、バー中央に赤い "X" を表示(制約3違反)
    - さらに、1日あたりの割り当てシフト数が |S| と合わない場合(制約1違反)は、
      その日付全体を赤い点線枠で囲む。
    """
    shift_colors = {0: 'skyblue', 1: 'lightgreen', 2: 'lightcoral'}
    bar_height = 0.8  # バーの高さ
    
    fig, ax = plt.subplots(figsize=(15, 6))
    
    # -----------------------------
    # 各従業員ごとのバーを描画
    # -----------------------------
    for p in P:
        for d in D:
            # 従業員 p の日 d に割り当てられたシフトリスト
            assigned_shifts = [s for s in S if x_round.get((p, d, s), 0) == 1]
            if assigned_shifts:
                multiple_assignment = (len(assigned_shifts) > 1)
                num_assigned = len(assigned_shifts)
                for idx, s in enumerate(assigned_shifts):
                    # 1日を単位幅とし、複数割り当ての場合は幅を分割して表示
                    width = 1.0 / num_assigned
                    x_start = d + idx * width
                    rect = patches.Rectangle(
                        (x_start, p - bar_height/2),  # 左下の座標
                        width,                       # 横幅
                        bar_height,                  # 縦幅
                        facecolor=shift_colors.get(s, 'gray'),
                        edgecolor='red' if multiple_assignment else 'black',
                        linewidth=2 if multiple_assignment else 1
                    )
                    ax.add_patch(rect)
                    
                    # 出勤不可日に割り当てがあれば "X" を表示(制約3違反)
                    if availability.get((p, d), 0) == 1:
                        ax.text(x_start + width/2, p, "X", color="red",
                                ha="center", va="center", fontsize=14, fontweight="bold")
    
    # -----------------------------
    # 各日付で割り当てシフト数をチェックし、制約1違反を可視化
    # -----------------------------
    for d in D:
        # 1日あたりの合計シフト割り当て数
        day_assigned = sum(x_round.get((p, d, s), 0) for p in P for s in S)
        # もし所定のシフト数 len(S) と異なれば、日付全体を赤い点線枠で囲む
        if day_assigned != len(S):
            # y方向は 従業員IDの最小 -0.5 から 最大 +0.5 まで
            rect_day = patches.Rectangle(
                (d, min(P) - 0.5),       # 左下の座標 (x, y)
                1,                       # 横幅(1日ぶん)
                len(P),                  # 縦幅(従業員全体)
                facecolor='none',
                edgecolor='red',
                linestyle='--',
                linewidth=2
            )
            ax.add_patch(rect_day)
    
    ax.set_xlim(0, max(D)+1)
    ax.set_ylim(min(P)-0.5, max(P)+0.5)
    ax.set_xlabel("Day")
    ax.set_ylabel("Worker (ID)")
    ax.set_yticks(P)
    ax.set_title("Gantt Chart")
    plt.gca().invert_yaxis()  # 従業員IDが小さい順に上部に表示
    plt.grid(True, axis='x', linestyle='--', alpha=0.5)
    plt.show()


青:早番
緑:日勤
赤:遅番

をそれぞれ表しています。制約違反には赤い印をつけています。詳しく見ると制約違反は下記のようになっています。

制約違反一覧:

1. 6日目従業員3に2つのシフトが割り当てられている。
2. 7日目は遅番がだれにも割り当てられていない。
3. 16日目は日勤が2人に割り当てられている。


3つ目の制約違反を解消する方法は簡単で、従業員3と従業員5のうちどちらかのシフト割り当てを削除すればOKです。(各従業員のシフト割当数の平準化が目的なので、より割当数が多い従業員の方のシフトを消せば良い?)

一方でで1,2つ目の制約違反は、割当を消すだけでなく、従業員の誰かに新たにシフトを割り当てる必要があります。今回は制約がそこまで複雑ではないので比較的簡単に割当ができそうですが、もっと制約が複雑なときは、この制約違反を解消する方法をちゃんと考える必要がありそうです。


おわりに


今回は集合被覆問題とシフトスケジューリング問題を例に挙げて、LP緩和解から乱択丸め法によって整数解を得られる手法を試してみました。

今後もこのような組合せ最適化に関する記事を書いていきます!

最後までこの記事を読んでくれてありがとうございました。



コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA