SCIP/PySCIPOpt で日能研の広告問題を解きなおす(非線形制約をそのまま書く)
前々回の記事では、PuLP + CBC で日能研の広告の時間割パズルをモデル化し、含意制約をどうにか補助変数を用いて線形化することで解くということをやりました。
今回は SCIP (PySCIPOpt) を使い、非線形制約をそのまま書いて同じ問題を解きます。
PySCIPOpt のインストール
SCIP の Python バインディングは PySCIPOpt パッケージとして提供されています。 Python から SCIP の各種制約(線形、非線形、論理、指示制約など)を直接書けるのが強みです。
PySCIPOpt は pip
で簡単に入れられます。バージョン 4.4.0
以降では、バイナリ wheel に SCIP 本体も付属するようになり、環境構築が楽になったようです。
pip3 install pyscipopt
詳細は Installation Guide にあります。
問題の定義(おさらい)
SCIP 版のモデリング
問1を解きます。
SCIP/PySCIPOpt では、変数や線形式の積もそのまま制約として addCons
に渡せます。
from pyscipopt import Model, quicksum
days = list(range(5)) # 月〜金曜日
periods = [1, 2, 3, 4] # 1限〜4限
subjects = ["算数", "国語", "社会", "理科"]
# モデル初期化(モデル名の指定は任意)
m = Model("Timetable")
if False:
# ログ非表示(任意)
m.hideOutput()
# 目的関数はダミー(可行解が欲しいだけ)
# SCIP は目的関数は線形のみ可、定数でもよい
m.setObjective(0)
# 0-1 のバイナリ変数(型指定は省略形 vtype="B" も可)
# 曜日 d の p 時限目に教科 s を割り当てるなら 1 とする
# PuLP のような辞書変数作成の特殊関数は使わない
x = {(d, p, s): m.addVar(vtype="Binary", name=f"x[{d},{p},{s}]")
for d in days for p in periods for s in subjects}
# 各コマに必ずちょうど 1 科目を割り当てる(基本ルール)
for d in days:
for p in periods:
m.addCons(quicksum(x[d, p, s] for s in subjects) == 1)
# 設問の制約条件を追加
# 1. 月曜日の時間割を与えられたもので固定
m.addCons(x[0, 1, "算数"] == 1)
m.addCons(x[0, 2, "国語"] == 1)
m.addCons(x[0, 3, "算数"] == 1)
m.addCons(x[0, 4, "国語"] == 1)
# 2. 算数は毎日ちょうど 2 コマ
for d in days:
m.addCons(quicksum(x[d, p, "算数"] for p in periods) == 2)
# 3. 国語、理科、社会は、それぞれ 5 日間で 3 時間以上ある
for s in ["国語", "理科", "社会"]:
m.addCons(quicksum(x[d, p, s] for d in days for p in periods) >= 3)
# 4. 水曜日に国語と社会がある
for s in ["国語", "社会"]:
m.addCons(quicksum(x[2, p, s] for p in periods) >= 1)
# 5. 理科を置くなら 4 限のみ(1〜3 限の理科は禁止)
for d in days:
for p in [1, 2, 3]:
m.addCons(x[d, p, "理科"] == 0)
# 6. 国語と社会は、それぞれ 1 日 2 時間以上あったとき、その教科は次の日にはない
# (SCIP の長所による改善)
# 「その日に 2 コマ以上 ⇒ 翌日はなし」の非線形な含意表現を直接表現:
# a = 日 d の s のコマ数, b = 日 d+1 の s のコマ数
# (a - 1) * b <= 0 で a >= 2 なら b == 0 を強制 (a, b は非負)
for s in ["国語", "社会"]:
for d in range(4):
a = quicksum(x[d, p, s] for p in periods)
b = quicksum(x[d + 1, p, s] for p in periods)
m.addCons((a - 1) * b <= 0) # 非線形制約をそのまま書く
# 7. 同じ科目は連続禁止
for d in days:
for p in [1, 2, 3]:
for s in subjects:
m.addCons(x[d, p, s] + x[d, p + 1, s] <= 1)
# 求解
m.optimize()
# 解の表示(火曜日の時間割)
for p in periods:
chosen = next(s for s in subjects if m.getVal(x[1, p, s]))
print(f"{p} 限: {chosen}")
1 限: 算数
2 限: 社会
3 限: 算数
4 限: 理科
非線形制約の許容により、6 番目の制約「国語と社会は、それぞれ 1 日 2 時間以上あったとき、その教科は次の日にはない」は PuLP のときとは違い、素直に記述できます。 PySCIPOpt の Non-Linear Expressions チュートリアルには、二乗、平方根、絶対値などを素直に書く例が並んでいます。
今回の含意は積の形で書け簡潔でしたが、SCIP には AND/OR/XOR や Indicator(指示)制約もあります。addConsOr
, addConsAnd
, addConsIndicator
などです。論理そのものを変数で表したい場合はこれらが有用です。前回記事のような自前の線形化手法を取らずに済みます。
SCIP では目的関数を非線形にすることはできないので、Indicator は非線形なコンディションを表現する補助変数として目的関数内で機能します。
全可行解の列挙
PuLP 版と同様、解を 1 つ出すたびにその割当てを禁止する制約を足して回すと、全部の可行解を列挙できます。再割り当ての前に freeTransform()
を呼ぶことを忘れないようにしてください。
solutions = []
# 解けなくなるまで繰り返す
while True:
m.optimize()
if m.getStatus() != "optimal":
break
bs = m.getBestSol()
sol = {
(d, p): next(s for s in subjects if bs[x[(d, p, s)]])
for d in days for p in periods
}
solutions.append(sol)
# 再最適化したいときは、既存の解決をクリアする必要がある
m.freeTransform()
# 同じ割当てを禁止する制約を追加
m.addCons(
quicksum(x[d, p, s] for (d, p), s in sol.items())
<= len(days) * len(periods) - 1
)
print("可行解数 =", len(solutions))
可行解数 = 18
所感
非線形制約をそのまま書けるのはやはり強力です。モデリング API もオブジェクトベースでわかりやすくまとまっているという印象で、今後使っていくのが楽しみです。
付録:ソルバーに設定できるパラメータ列挙
SCIP ソルバーには、モデルオブジェクトの setParam
メソッドを介して時間制限などを設定できますが、writeParams
メソッドを使用すると、ソルバーに設定できる全パラメータを列挙することができます。
m.writeParams(
"params_default.txt",
onlychanged=False,
)
デフォルトから変えたものだけ列挙
m.writeParams(
"params_default.txt",
onlychanged=True,
)