pandas/pulp 解説マニュアル:時間割編成問題の実装に向けて¶
本マニュアルは、卒業研究において「時間割編成問題」を制約充足問題として定式化し、PuLP を用いて線形計画法で解くために必要な Python ツールの基礎知識を解説するものである。具体的には以下の3部構成とする。
- pandas によるデータの読み込みと整形
- PuLP による線形モデルの記述と求解
- 実装と検証における学習手順と補足
なお、1.のpandasの解説は簡易的なものにとどまる。細かな使い方は別添のpandas_manual.ipynbを参考にすること。
1. pandas の基礎知識¶
1.1 pandasとは¶
pandasは、Pythonで表形式(行と列からなるデータ)を扱うための代表的なライブラリである。CSVファイルの読み書き、列ごとの抽出、条件による行のフィルタリング、グループ集計、欠損値処理など、多くの基本的かつ実用的な機能を備えている。
たとえば学校の時間割のように「学年」「クラス」「科目」「教員」「時限」といった情報を列として持つデータを操作するには、pandasの知識が不可欠である。この節では、pandasを用いてCSV形式の時間割データを読み込み、必要な情報を抽出して変換し、最終的に最適化モデルへ渡すための中間データを作成できるようになることを目標とする。
まず、pandasを使用する際には次のようにライブラリを読み込む。
import pandas as pd
この import 文により、pandasの各種機能(関数やメソッド)を pd. という接頭辞を通じて使用できるようになる。
練習問題(1.1)¶
- pandasライブラリの用途について、自然言語で説明すること。特に、時間割のような表構造のデータに対してどのような操作が可能かを2つ以上挙げること。
- 以下のコードはどのような目的で使用されるものか。また、実行する前提として必要な準備は何か。
import pandas as pd
1.2 CSVファイルの読み込みと確認¶
CSV(Comma-Separated Values)は、カンマで区切られたテキスト形式の表データであり、時間割のような行×列構造の情報を簡単に格納して交換できる形式である。pandasにはCSVファイルを読み込むための read_csv() 関数が用意されており、これを用いてデータを DataFrame(pandasの中心的なデータ構造)として読み込むことができる。
DataFrame は2次元の表構造を持ち、行や列にインデックスやラベルを持つ点が特徴である。読み込み直後には head() メソッドを使って先頭数行を確認し、columns 属性で列名を確認して、どのようなデータが含まれているか把握する。
df = pd.read_csv('timetable.csv', encoding='utf-8')
print(df.head()) # 先頭5行を表示
print(df.columns) # 列ラベル一覧を表示
encoding='utf-8' は、ファイルの文字コードがUTF-8で保存されていることを指定している。日本語が含まれるファイルでは、適切なエンコーディングの指定が重要である。
練習問題(1.2)¶
pd.read_csv()関数の主な目的は何か。また、その引数encoding='utf-8'はなぜ指定する必要があるのか説明すること。df.head(10)と書いた場合、何が表示されるか。コードの意味を含めて説明すること。
1.3 列の抽出と条件指定¶
CSVファイルを読み込んだあとは、目的に応じて特定の列や条件を満たす行を取り出す操作が必要になる。たとえば、「教員」列だけを抜き出したり、「学年が2年」のデータだけを抽出したりといった処理が該当する。
列の抽出は df['列名'] の形式で行い、条件指定は df[条件式] を使って行う。複数条件を組み合わせる場合は、&(かつ)や |(または)を使うが、それぞれの条件は必ず括弧で囲む必要がある。
以下のコードは、教員列を抽出し、学年が2年の行のみを取得する例である。
df['教員']
df[df['学年'] == 2]
このような部分抽出は、モデルに必要なデータだけを取り出す中間処理として非常に重要である。
練習問題(1.3)¶
- 「クラス」列を抽出するにはどのようなコードを書くか。結果の型は何になるか。
- 「教室」列が '301' の行だけを抽出したいとき、適切なコードとその意味を説明すること。
1.4 グループ集計と集約¶
pandasを使えば、カテゴリごとにデータを集約する処理も簡単に行うことができる。たとえば、教員ごとの担当コマ数、教室ごとの使用頻度、学年ごとの授業数などを把握することで、モデル設計の妥当性を確認したり、特定の制約条件を設ける根拠にしたりできる。
value_counts() は、ある列に含まれる各値の出現回数を集計するメソッドである。これにより、偏りのあるデータや分布の傾向が一目でわかる。より柔軟な集約が必要な場合は groupby() を使い、カテゴリごとの合計や平均、件数を求めることができる。
df['教員'].value_counts()
このような集計を活用し、たとえば「特定の教員が過剰に授業を担当していないか」といったチェックを行うこともできる。
練習問題(1.4)¶
- 「科目」列に含まれる各科目の出現回数を確認したいとき、どのメソッドを使えばよいか。またその理由を説明すること。
- 「学年」列に対して、学年ごとの授業数(行数)をカウントするにはどうすればよいか。
1.5 教員が複数担当する形式の分解¶
時間割データにおいて、1つの授業を複数の教員が共同で担当するケースは珍しくない。このような場合、CSVファイルでは「教員1/教員2」や「教員A/教員B」といった文字列が1つのセルに含まれていることがある。しかしこのままでは、教員ごとの担当コマ数を数える、教員IDと科目の対応関係を作る、といった解析やモデル化に不都合が生じる。
このような文字列をpandasで処理するためには、まず str.split('/') メソッドを使って「/」で区切られた文字列をリスト形式に変換する。さらに、1行に複数の教員がいる状態ではリスト形式のままでは扱いにくいため、explode() メソッドを使って各教員を個別の行に展開する。これにより、後続処理(集計や変数定義)の整合性が保たれる。
df['教員'] = df['教員'].str.split('/') # リスト形式に分割
df = df.explode('教員') # 各教員を個別の行に展開
このような前処理を行うことで、PuLP に渡す「教員ID × 科目ID」などの対応表を構築しやすくなる。時間割データが現実的であればあるほど、こうしたデータの正規化(整形)は重要である。
# Sub = []
# for i in range(1, 5):
# Sub.append(i)
[i for i in range(1, 5)]
[1, 2, 3, 4]
import pulp
prob = pulp.LpProblem("timetable", pulp.LpMinimize)
Sub = [i for i in range(1, 5)]
Tch = [i for i in range(1, 3)]
Wk = [i for i in range(1, 6)]
Prd = [i for i in range(1, 5)]
x = {}
for s in Sub:
for t in Tch:
for w in Wk:
for p in Prd:
var_name = f"x_{s}_{t}_{w}_{p}"
x[(s, t, w, p)] = pulp.LpVariable(var_name, cat='Binary')
for t in Tch:
for w in Wk:
for p in Prd:
# pulp だと1行
prob += pulp.lpSum(x[(s, t, w, p)] for s in Sub) <= 1
prob.solve(pulp.PULP_CBC_CMD(msg=False))
1
R1 = [(t, s) for t in Tch for s in Sub]
R1
[(1, 1), (1, 2), (1, 3), (1, 4), (2, 1), (2, 2), (2, 3), (2, 4)]
$$ \sum_{s\in \text{Sub}} x_{s, t, w, p} \leq 1 \quad \forall t, \forall w, \forall p $$
prob
timetable: MINIMIZE None SUBJECT TO _C1: x_1_1_1_1 + x_2_1_1_1 + x_3_1_1_1 + x_4_1_1_1 <= 1 _C2: x_1_1_1_2 + x_2_1_1_2 + x_3_1_1_2 + x_4_1_1_2 <= 1 _C3: x_1_1_1_3 + x_2_1_1_3 + x_3_1_1_3 + x_4_1_1_3 <= 1 _C4: x_1_1_1_4 + x_2_1_1_4 + x_3_1_1_4 + x_4_1_1_4 <= 1 _C5: x_1_1_2_1 + x_2_1_2_1 + x_3_1_2_1 + x_4_1_2_1 <= 1 _C6: x_1_1_2_2 + x_2_1_2_2 + x_3_1_2_2 + x_4_1_2_2 <= 1 _C7: x_1_1_2_3 + x_2_1_2_3 + x_3_1_2_3 + x_4_1_2_3 <= 1 _C8: x_1_1_2_4 + x_2_1_2_4 + x_3_1_2_4 + x_4_1_2_4 <= 1 _C9: x_1_1_3_1 + x_2_1_3_1 + x_3_1_3_1 + x_4_1_3_1 <= 1 _C10: x_1_1_3_2 + x_2_1_3_2 + x_3_1_3_2 + x_4_1_3_2 <= 1 _C11: x_1_1_3_3 + x_2_1_3_3 + x_3_1_3_3 + x_4_1_3_3 <= 1 _C12: x_1_1_3_4 + x_2_1_3_4 + x_3_1_3_4 + x_4_1_3_4 <= 1 _C13: x_1_1_4_1 + x_2_1_4_1 + x_3_1_4_1 + x_4_1_4_1 <= 1 _C14: x_1_1_4_2 + x_2_1_4_2 + x_3_1_4_2 + x_4_1_4_2 <= 1 _C15: x_1_1_4_3 + x_2_1_4_3 + x_3_1_4_3 + x_4_1_4_3 <= 1 _C16: x_1_1_4_4 + x_2_1_4_4 + x_3_1_4_4 + x_4_1_4_4 <= 1 _C17: x_1_1_5_1 + x_2_1_5_1 + x_3_1_5_1 + x_4_1_5_1 <= 1 _C18: x_1_1_5_2 + x_2_1_5_2 + x_3_1_5_2 + x_4_1_5_2 <= 1 _C19: x_1_1_5_3 + x_2_1_5_3 + x_3_1_5_3 + x_4_1_5_3 <= 1 _C20: x_1_1_5_4 + x_2_1_5_4 + x_3_1_5_4 + x_4_1_5_4 <= 1 _C21: x_1_2_1_1 + x_2_2_1_1 + x_3_2_1_1 + x_4_2_1_1 <= 1 _C22: x_1_2_1_2 + x_2_2_1_2 + x_3_2_1_2 + x_4_2_1_2 <= 1 _C23: x_1_2_1_3 + x_2_2_1_3 + x_3_2_1_3 + x_4_2_1_3 <= 1 _C24: x_1_2_1_4 + x_2_2_1_4 + x_3_2_1_4 + x_4_2_1_4 <= 1 _C25: x_1_2_2_1 + x_2_2_2_1 + x_3_2_2_1 + x_4_2_2_1 <= 1 _C26: x_1_2_2_2 + x_2_2_2_2 + x_3_2_2_2 + x_4_2_2_2 <= 1 _C27: x_1_2_2_3 + x_2_2_2_3 + x_3_2_2_3 + x_4_2_2_3 <= 1 _C28: x_1_2_2_4 + x_2_2_2_4 + x_3_2_2_4 + x_4_2_2_4 <= 1 _C29: x_1_2_3_1 + x_2_2_3_1 + x_3_2_3_1 + x_4_2_3_1 <= 1 _C30: x_1_2_3_2 + x_2_2_3_2 + x_3_2_3_2 + x_4_2_3_2 <= 1 _C31: x_1_2_3_3 + x_2_2_3_3 + x_3_2_3_3 + x_4_2_3_3 <= 1 _C32: x_1_2_3_4 + x_2_2_3_4 + x_3_2_3_4 + x_4_2_3_4 <= 1 _C33: x_1_2_4_1 + x_2_2_4_1 + x_3_2_4_1 + x_4_2_4_1 <= 1 _C34: x_1_2_4_2 + x_2_2_4_2 + x_3_2_4_2 + x_4_2_4_2 <= 1 _C35: x_1_2_4_3 + x_2_2_4_3 + x_3_2_4_3 + x_4_2_4_3 <= 1 _C36: x_1_2_4_4 + x_2_2_4_4 + x_3_2_4_4 + x_4_2_4_4 <= 1 _C37: x_1_2_5_1 + x_2_2_5_1 + x_3_2_5_1 + x_4_2_5_1 <= 1 _C38: x_1_2_5_2 + x_2_2_5_2 + x_3_2_5_2 + x_4_2_5_2 <= 1 _C39: x_1_2_5_3 + x_2_2_5_3 + x_3_2_5_3 + x_4_2_5_3 <= 1 _C40: x_1_2_5_4 + x_2_2_5_4 + x_3_2_5_4 + x_4_2_5_4 <= 1 VARIABLES __dummy = 0 Continuous 0 <= x_1_1_1_1 <= 1 Integer 0 <= x_1_1_1_2 <= 1 Integer 0 <= x_1_1_1_3 <= 1 Integer 0 <= x_1_1_1_4 <= 1 Integer 0 <= x_1_1_2_1 <= 1 Integer 0 <= x_1_1_2_2 <= 1 Integer 0 <= x_1_1_2_3 <= 1 Integer 0 <= x_1_1_2_4 <= 1 Integer 0 <= x_1_1_3_1 <= 1 Integer 0 <= x_1_1_3_2 <= 1 Integer 0 <= x_1_1_3_3 <= 1 Integer 0 <= x_1_1_3_4 <= 1 Integer 0 <= x_1_1_4_1 <= 1 Integer 0 <= x_1_1_4_2 <= 1 Integer 0 <= x_1_1_4_3 <= 1 Integer 0 <= x_1_1_4_4 <= 1 Integer 0 <= x_1_1_5_1 <= 1 Integer 0 <= x_1_1_5_2 <= 1 Integer 0 <= x_1_1_5_3 <= 1 Integer 0 <= x_1_1_5_4 <= 1 Integer 0 <= x_1_2_1_1 <= 1 Integer 0 <= x_1_2_1_2 <= 1 Integer 0 <= x_1_2_1_3 <= 1 Integer 0 <= x_1_2_1_4 <= 1 Integer 0 <= x_1_2_2_1 <= 1 Integer 0 <= x_1_2_2_2 <= 1 Integer 0 <= x_1_2_2_3 <= 1 Integer 0 <= x_1_2_2_4 <= 1 Integer 0 <= x_1_2_3_1 <= 1 Integer 0 <= x_1_2_3_2 <= 1 Integer 0 <= x_1_2_3_3 <= 1 Integer 0 <= x_1_2_3_4 <= 1 Integer 0 <= x_1_2_4_1 <= 1 Integer 0 <= x_1_2_4_2 <= 1 Integer 0 <= x_1_2_4_3 <= 1 Integer 0 <= x_1_2_4_4 <= 1 Integer 0 <= x_1_2_5_1 <= 1 Integer 0 <= x_1_2_5_2 <= 1 Integer 0 <= x_1_2_5_3 <= 1 Integer 0 <= x_1_2_5_4 <= 1 Integer 0 <= x_2_1_1_1 <= 1 Integer 0 <= x_2_1_1_2 <= 1 Integer 0 <= x_2_1_1_3 <= 1 Integer 0 <= x_2_1_1_4 <= 1 Integer 0 <= x_2_1_2_1 <= 1 Integer 0 <= x_2_1_2_2 <= 1 Integer 0 <= x_2_1_2_3 <= 1 Integer 0 <= x_2_1_2_4 <= 1 Integer 0 <= x_2_1_3_1 <= 1 Integer 0 <= x_2_1_3_2 <= 1 Integer 0 <= x_2_1_3_3 <= 1 Integer 0 <= x_2_1_3_4 <= 1 Integer 0 <= x_2_1_4_1 <= 1 Integer 0 <= x_2_1_4_2 <= 1 Integer 0 <= x_2_1_4_3 <= 1 Integer 0 <= x_2_1_4_4 <= 1 Integer 0 <= x_2_1_5_1 <= 1 Integer 0 <= x_2_1_5_2 <= 1 Integer 0 <= x_2_1_5_3 <= 1 Integer 0 <= x_2_1_5_4 <= 1 Integer 0 <= x_2_2_1_1 <= 1 Integer 0 <= x_2_2_1_2 <= 1 Integer 0 <= x_2_2_1_3 <= 1 Integer 0 <= x_2_2_1_4 <= 1 Integer 0 <= x_2_2_2_1 <= 1 Integer 0 <= x_2_2_2_2 <= 1 Integer 0 <= x_2_2_2_3 <= 1 Integer 0 <= x_2_2_2_4 <= 1 Integer 0 <= x_2_2_3_1 <= 1 Integer 0 <= x_2_2_3_2 <= 1 Integer 0 <= x_2_2_3_3 <= 1 Integer 0 <= x_2_2_3_4 <= 1 Integer 0 <= x_2_2_4_1 <= 1 Integer 0 <= x_2_2_4_2 <= 1 Integer 0 <= x_2_2_4_3 <= 1 Integer 0 <= x_2_2_4_4 <= 1 Integer 0 <= x_2_2_5_1 <= 1 Integer 0 <= x_2_2_5_2 <= 1 Integer 0 <= x_2_2_5_3 <= 1 Integer 0 <= x_2_2_5_4 <= 1 Integer 0 <= x_3_1_1_1 <= 1 Integer 0 <= x_3_1_1_2 <= 1 Integer 0 <= x_3_1_1_3 <= 1 Integer 0 <= x_3_1_1_4 <= 1 Integer 0 <= x_3_1_2_1 <= 1 Integer 0 <= x_3_1_2_2 <= 1 Integer 0 <= x_3_1_2_3 <= 1 Integer 0 <= x_3_1_2_4 <= 1 Integer 0 <= x_3_1_3_1 <= 1 Integer 0 <= x_3_1_3_2 <= 1 Integer 0 <= x_3_1_3_3 <= 1 Integer 0 <= x_3_1_3_4 <= 1 Integer 0 <= x_3_1_4_1 <= 1 Integer 0 <= x_3_1_4_2 <= 1 Integer 0 <= x_3_1_4_3 <= 1 Integer 0 <= x_3_1_4_4 <= 1 Integer 0 <= x_3_1_5_1 <= 1 Integer 0 <= x_3_1_5_2 <= 1 Integer 0 <= x_3_1_5_3 <= 1 Integer 0 <= x_3_1_5_4 <= 1 Integer 0 <= x_3_2_1_1 <= 1 Integer 0 <= x_3_2_1_2 <= 1 Integer 0 <= x_3_2_1_3 <= 1 Integer 0 <= x_3_2_1_4 <= 1 Integer 0 <= x_3_2_2_1 <= 1 Integer 0 <= x_3_2_2_2 <= 1 Integer 0 <= x_3_2_2_3 <= 1 Integer 0 <= x_3_2_2_4 <= 1 Integer 0 <= x_3_2_3_1 <= 1 Integer 0 <= x_3_2_3_2 <= 1 Integer 0 <= x_3_2_3_3 <= 1 Integer 0 <= x_3_2_3_4 <= 1 Integer 0 <= x_3_2_4_1 <= 1 Integer 0 <= x_3_2_4_2 <= 1 Integer 0 <= x_3_2_4_3 <= 1 Integer 0 <= x_3_2_4_4 <= 1 Integer 0 <= x_3_2_5_1 <= 1 Integer 0 <= x_3_2_5_2 <= 1 Integer 0 <= x_3_2_5_3 <= 1 Integer 0 <= x_3_2_5_4 <= 1 Integer 0 <= x_4_1_1_1 <= 1 Integer 0 <= x_4_1_1_2 <= 1 Integer 0 <= x_4_1_1_3 <= 1 Integer 0 <= x_4_1_1_4 <= 1 Integer 0 <= x_4_1_2_1 <= 1 Integer 0 <= x_4_1_2_2 <= 1 Integer 0 <= x_4_1_2_3 <= 1 Integer 0 <= x_4_1_2_4 <= 1 Integer 0 <= x_4_1_3_1 <= 1 Integer 0 <= x_4_1_3_2 <= 1 Integer 0 <= x_4_1_3_3 <= 1 Integer 0 <= x_4_1_3_4 <= 1 Integer 0 <= x_4_1_4_1 <= 1 Integer 0 <= x_4_1_4_2 <= 1 Integer 0 <= x_4_1_4_3 <= 1 Integer 0 <= x_4_1_4_4 <= 1 Integer 0 <= x_4_1_5_1 <= 1 Integer 0 <= x_4_1_5_2 <= 1 Integer 0 <= x_4_1_5_3 <= 1 Integer 0 <= x_4_1_5_4 <= 1 Integer 0 <= x_4_2_1_1 <= 1 Integer 0 <= x_4_2_1_2 <= 1 Integer 0 <= x_4_2_1_3 <= 1 Integer 0 <= x_4_2_1_4 <= 1 Integer 0 <= x_4_2_2_1 <= 1 Integer 0 <= x_4_2_2_2 <= 1 Integer 0 <= x_4_2_2_3 <= 1 Integer 0 <= x_4_2_2_4 <= 1 Integer 0 <= x_4_2_3_1 <= 1 Integer 0 <= x_4_2_3_2 <= 1 Integer 0 <= x_4_2_3_3 <= 1 Integer 0 <= x_4_2_3_4 <= 1 Integer 0 <= x_4_2_4_1 <= 1 Integer 0 <= x_4_2_4_2 <= 1 Integer 0 <= x_4_2_4_3 <= 1 Integer 0 <= x_4_2_4_4 <= 1 Integer 0 <= x_4_2_5_1 <= 1 Integer 0 <= x_4_2_5_2 <= 1 Integer 0 <= x_4_2_5_3 <= 1 Integer 0 <= x_4_2_5_4 <= 1 Integer
2.3 変数の定義¶
この節では、モデルにおける「何を変数として定義するか」を明確にする。ここで用いる変数 $x_{a,b}$ は、教員 $a$ が科目 $b$ を担当するかどうかを示す二値変数である。
この問題では「教員 a が科目 b を担当するかどうか」を表す変数 $x_{a,b}$ を用いて定式化する。
$$ x_{a,b} = \begin{cases} 1 & \text{教員 } a \text{ が科目 } b \text{ を担当する} \\ 0 & \text{それ以外} \end{cases} $$
PuLP による定義(直感的な書き方)。
x = {}
for a in teacher_ids:
for b in subject_ids:
var_name = f"x_{a}_{b}"
x[(a, b)] = LpVariable(var_name, cat='Binary')
2.4 制約の定義¶
次に、定義した変数に対して現実的なルール(制約)を加えていく。以下の数式は、教員 $a$ が担当する科目の数が最大3つであるという条件を表している。
たとえば「教員 a が担当できる科目数は最大3つまで」といった制約は以下のように書ける。
$$ \sum_{b \in B} x_{a,b} \leq 3 \quad \forall a $$
PuLP での記述例。
for a in teacher_ids:
prob += lpSum(x[(a, b)] for b in subject_ids) <= 3
このように、数式で表したルールをそのまま Python コードに変換していく形になる。
2.5 目的関数の定義¶
目的関数は「どのような解を良いとみなすか」を数式で表すものである。ここでは、全体として割り当てられる授業の数(担当数)を最小化することを目標とする。
たとえば「担当する科目の数をできるだけ少なくしたい」という目的を定式化する場合は次のようになる。
$$ \min \sum_{a \in A} \sum_{b \in B} x_{a,b} $$
PuLP では以下のように書く。
prob += lpSum(x[(a, b)] for a in teacher_ids for b in subject_ids)
目的関数は「何を最小化するか、最大化するか」を表現する部分で、モデル全体の評価基準を定める。
2.6 3次元以上の変数定義の例¶
本節では、より複雑な時間割編成モデルにおいて必要となる「3次元以上のインデックスを持つ変数」の定義例を紹介する。
たとえば、教員 a が科目 b を教室 c で担当するかどうかを表す変数 x_{a,b,c} は次のように定義される。
x_{a,b,c} =
1 教員 a が科目 b を教室 c で担当する
0 それ以外
PuLP での変数定義例を示す。
x = {}
for a in teacher_ids:
for b in subject_ids:
for c in classroom_ids:
name = f"x_{a}_{b}_{c}"
x[(a, b, c)] = LpVariable(name, cat='Binary')
このような変数を用いると、
- 同一教室に同時刻に複数授業が入らないようにする制約
- 教員が担当する教室の割当制限
- 教室ごとの収容人数や専門設備との対応
など、現実的で詳細な制約をモデル化することが可能になる。
3. 学習の進め方と実装指針¶
3.1 小規模モデルから始める¶
- 教員1人、科目3つ、曜日2日、時限2つでの割り当てモデル
- 制約1~2個だけの単純な例
3.2 変数、制約、目的の関係を可視化¶
変数と制約の意味が複雑に感じるときは、Excelや図で「変数が何を意味していて、どこで制約されているか」を整理すると効果的である。
3.3 ログと出力結果を確認¶
LpStatus:解が最適かどうかsolutionTime:計算にかかった時間variables()の出力:想定通りに授業が配置されているか
3.4 よくあるエラーと対策¶
| エラー内容 | 原因と対処 |
|---|---|
KeyError: (a,b) |
変数定義時のインデックスと一致していない |
ValueError: NoneType |
解が得られていない(制約が強すぎて infeasible) |
TypeError: unsupported operand |
lpSum() 内の加算に無効な値が含まれている |