数理最適化をプロダクトに組み込んでいく場合、クラスとして部品化しておくと、再利用性や保守性が高まったりする。
数理最適化のクラス設計に関しては、書籍『Pythonではじめる数理最適化』の著者である岩永さんが次のような記事を書かれている:
この記事はクラスってどう定義したらいいのか分からないって人には指針を与えてくれるのでとてもいいと思う。 Python使ってる人って自分でクラスを書いたりせずにコード書いてる人もけっこう多いので。
ただ、とりあえずの一歩としてはいいけど、コードをより堅牢にするという観点ではちょっと問題も多かったり。 このあたりはオブジェクト指向についての経験値も要求されるからね。
ということで、よりよいクラス設計について書いてみたい。
これは数理最適化 Advent Calendar 2024の5日目の記事です。
前述のクラス設計の問題点
まず、前述のクラス設計の問題点を具体的に示していきたい。
- 引数の型、データ構造が分からない
- 引数の妥当な値が分からない、不適切な値を渡せてしまう
- オブジェクトの状態を気にしたメソッド呼び出しが必要
- オブジェクトのデータを破壊できてしまう
引数の型、データ構造が分からない
クラスを使うときに見るのが、メソッドのシグネチャ(どんな引数を渡すか)。 コンストラクタのシグネチャを見ると、次のようになっている:
class ProdPlan: def __init__(self, P, M, m2s, p2g, pm2r): ...
ただ、これだけ見ても何のデータを渡せばいいのか、さっぱり分からない。 実装を追っていったり、呼び出し例を見てやる必要がある。 これはけっこう大変。
対策として、Pythonでは型ヒントをつけたりdocstringで説明したりができる:
class ProdPlan: def __init__( self, P: list[str], M: list[str], m2s: dict[str, int], p2g: dict[str, int], pm2r: dict[tuple[str, str], int], ) -> None: """ Parameters ---------- P : list[str] 製品の一覧 M : list[str] 原料の一覧 m2s : dict[str, int] 在庫量(キーは原料) p2g : dict[str, int] 利得(キーは製品) pm2r : dict[tuple[str, str], int] 必要量(キーは製品と原料のペア) """ ...
型ヒントやdocstringがあるだけで、使いやすさがだいぶ変わるのが分かるかと思う。 正直、書くのはかなり面倒だけど、プロダクトで使う場合は必須に近い。
引数の妥当な値が分からない、不適切な値を渡せてしまう
ただ、これでもまだ不十分で、というのもリストや辞書といったデータ構造では値に対する「縛り」がないため、引数として妥当な値が分からなかったり、それゆえ不適切な値も簡単に渡せてしまうから。
たとえば、次のようなコードを書けてしまったりする:
P = ["p1", "p2", "p3", "p3"] # 同名の製品がある M = ["m1", "p2", "m3"] # m2をp2とタイポ(原料のキーと製品のキーで区別がつかなくなる) m2s = {"m1": 35, "m2": -10} # m2の在庫が負、m3の在庫が未定義 p2g = {"p1": 3, "p2": 4, "p3": 4, "p4": -5, "m1": 10} # p4の利得が負、キーにm1も含まれる pm2r = { ("p1", "m1"): 2, ("p1", "m2"): 0, # P×Mの網羅ができてない ("hoge", "huga"): -2, # 変なキーがある、必要量が負 } prod_plan = ProdPlan(P, M, m2s, p2g, pm2r)
上記だと型チェックでエラーは検出されないし、実行時にもエラーは出てこない。
prod_plan.modeling()
やprod_plan.solve()
を呼び出したときに、運がよければエラーになるかも。
ただ、最悪エラーも出ずに、答えがおかしくなってることに気づかないまま使ってしまう可能性もある(エラーが出るのを嫌がる人も多いけど、問題があるのにエラーが出ないというのは、かなり有害;ガンがあるのに検査で引っかからないようなものなので)。
オブジェクトの状態を気にしたメソッド呼び出しが必要
このクラスはオブジェクトの状態を気にしてメソッド呼び出しをしないといけないというのもけっこう難点。
たとえば、このオブジェクトを何かしらの関数で引数として受け取ったとする。 最適化の結果を知りたいとして、どうしたらいいだろうか?
# 最適化の結果の表示 def print_result(prod_plan: ProdPlan) -> None: # prod_planに対して、何をすればいい? ...
答えを知りたいんだからprod_plan.x
を見ればいいんでしょ?って思うかもしれないけど、必ずしもそうとは限らない。
だって、modeling()
を呼び出してないとx
はNone
だし、solve()
を呼んでないとx
は最適解になってないから。
使う側が状態を気にしながら必要なメソッドを呼び出してやる必要がある。
# 最適化の結果の表示 def print_result(prod_plan: ProdPlan) -> None: if prod_plan.x is None: prod_plan.modeling() if prod_plan.status is None: prod_plan.solve() print({p: prod_plan.x[p].value() for p in prod_plan.P})
一応、modeling()
とsolve()
を常に呼び出すようにすれば、状態の確認は不要になるけど、すでに解いてた場合は無駄に計算してることになるし、「この引数では解いたあとの状態のオブジェクトを常に渡すよ」と取り決めておけばこういったチェックは不要になるけど、その約束を知ってか知らずか破ってしまってバグを引き起こすというのもよくある話。
あるメソッドを呼び出し済みかどうかなんて、そこに至るパスを全部チェックしてやらないと、抜け漏れは普通に発生するし。
オブジェクトのデータを破壊できてしまう
このクラス設計はデータ構造が丸見えなので、オブジェクトのデータを簡単に破壊できてしまう問題もある。
たとえばこんな感じ:
# 以下のようにしたらエラーが出なくなったのでヨシ! prod_plan.x = pulp.LpVariable.dicts("x", prod_plan.P) prod_plan.status = pulp.LpStatusOptimal print_result(prod_plan)
そんなアホなことするかいなと思うかもしれないけど、やったとしてもエラーは出てこないので、やってないことを確認するにはソースコードを全部チェックしないと分からないんよね。
問題への対応策
上記のような問題を防ぐには、以下のようなことをやってあげるといい:
- 型ヒントやdocstringを書く(言及済み)
- 異なるものには異なる型のクラスを用意する
- 不適切なデータが存在できないようにする
- できることだけをメソッドにする
異なるものには異なる型のクラスを用意する
製品や原料は異なるものなので、これらが混ざってしまうのはよくない。 けど、単に文字列で識別を行なっているのだと、型としては同じなので区別がつかず、混ざってしまう可能性がある。
こういうときには異なる型を用意しておくとよくて、そうすると混ざってしまうのを型チェックで防げるようになる:
from dataclasses import dataclass @dataclass(frozen=True) class Product: """製品""" name: str @dataclass(frozen=True) class Material: """原料""" name: str products: list[Product] = [ Product("p1"), Product("p2"), Product("p3") ] materials: list[Material] = [ Product("m1"), Material("m2") # これは型チェックするとエラーが検出される ]
また、こうしておくと、辞書でキーになる要素がなんなのかも分かりやすくなったり:
# 元々は以下だった; # キーと値が何を意味してるか、よく分からない m2s = {"m1": 35, "m2": 22, "m3": 27} # 以下のようにすると分かりやすい stocks: dict[Material, int] = { Material("m1"): 35, Material("m2"): 22, Material("m3"): 27, }
不適切なデータが存在できないようにする
扱ってるデータには、いくつか制約があったりする:
- 製品の集合に同名の製品が含まれるのはNG
- 原料の集合に同名の原料が含まれるのはNG
- 原料の在庫は各原料に対して値があり、0以上の値を取る
- 製品の利得は各製品に対して値があり、0以上の値を取る
- 製品に対する原料の必要量は、各製品と各原料のペアに対して値があり、0以上の値をとる
こういったのはドメイン知識と呼ばれるもので、これらを洗い出し、コードとして表現しておけると、あとになって「えっ、そうだったの?」というのを防げたり。 そして、こういった制約を破る不適切なデータが存在できないようにしておくと、不適切なデータが紛れて気づかないうちに問題が発生していたというのを防げるようになる。
具体的には扱うデータや概念をクラスとして表現して、不適切なオブジェクトが作られないようにするといい(不適切になる場合は例外を投げる)。
たとえば、製品の一覧の実装例は以下:
class ProductList: """製品の一覧""" def __init__(self, items: list[Product]) -> None: """ Parameters ---------- items : list[Product] 製品の一覧 Raises ------ ValueError 重複した製品があった場合 """ # 重複チェック n_items = len(items) n_unique_items = len(set(items)) if n_items != n_unique_items: raise ValueError("製品が重複してます") self.__items = list(items) @property def items(self) -> list[Product]: return list(self.__items) def __eq__(self, obj: object) -> bool: return ( isinstance(obj, ProductList) and (set(self.__items) == set(obj.items)) )
ポイントはコンストラクタで重複チェックをしてること。 こうやってデータを作るときに制約が守られているかのチェックを入れておくと、制約が守られてない不適切なデータはプログラム内に存在できなくなる:
# 次のコードは型チェックで弾かれる product_list = ProductList([Product("p1"), Material("m1")]) # 次のコードは実行時にValueErrorが出る product_list = ProductList([Product("p1"), Product("p2"), Product("p1")])
ちなみに、次の項目とも関連するけど、このProuctList
に含まれる製品の一覧は、参照はできても変更はできないようになっている:
product_list = ProductList([...]) # 参照はできる for product in product_list.items: print(product) # 代入はできない product_list.items = [Product("p3"), Product("p4")] # エラー
これはオブジェクトの持つデータをプライベートにして外からはアクセスできないようにし、プロパティとして参照だけできるようにしてるから。 こうすることで、適切だったデータが途中で不適切なデータに変わってしまうのを防いでいる。
製品の利得の実装例も示しておくと、以下:
class Gain: """製品の利得""" def __init__(self, gains: dict[Product, int]) -> None: """ Parameters ---------- gains : dict[Product, int] 製品の利得 値は0以上であること Raises ------ ValueError 値が0未満の場合 """ for value in gains.values(): if value < 0: raise ValueError("値が0未満です") self.__gains = dict(gains) @property def product_list(self) -> ProductList: return ProductList(list(self.__gains.keys())) def for_product(self, product: Product) -> int: """ 指定された製品に対する利得を返す Parameters ---------- product : Product 対象の製品 Returns ------- int 指定された製品に対する利得 Raises ------ ValueError 指定された製品に対する値がない場合 """ if product not in self.__gains: raise ValueError(f"{product}に対する値がありません") return self.__gains[product]
できることだけをメソッドにする
上記でデータを参照はできても変更はできないようにしたのと同様で、できることだけをメソッドとして公開するといい。
たとえば、元の設計だとProdPlan
ができることは次のようになっている:
けど、これは公開しすぎで、前述のように簡単にデータを壊せてしまうし、使う側も状態を意識する必要があって使いにくい。
実際には次のことができれば十分:
- 各属性の参照
- 問題を解いて解を得る
気をつけたいのは、「モデリングする」「解を求める」「解の値を得る」というのを個別のメソッドにしないこと。 なぜなら、それぞれだけやるということは普通あり得ないから。 下手に分けてしまうと、内部の状態に気をつけながら必要なメソッドを順に叩いていく必要が出てしまう。 内部的に処理を分けて書きたい場合は、プライベートなメソッドとして書くといい。
具体的には、次のような感じになる:
from typing import Optional class ProductionPlan: """生産計画""" def __init__(self, plan: dict[Product, float]) -> None: # 制約とか同様に表現する ... # 省略 class ProductionProblem: """生産問題""" def __init__( self, product_list: ProductList, material_list: MaterialList, stock: Stock, gain: Gain, require: Require, ) -> None: # docstring省略 if stock.material_list != material_list: raise ValueError("在庫の情報が不正です") if gain.product_list != product_list: raise ValueError("利得の情報が不正です") if require.product_list != product_list: raise ValueError("必要量の情報が不正です") if require.material_list != material_list: raise ValueError("必要量の情報が不正です") self.__product_list = product_list self.__material_list = material_list self.__stock = stock self.__gain = gain self.__require = require @property def product_list(self) -> ProductList: return self.__product_list # 他のプロパティも同様;省略 def solve(self) -> tuple[int, Optional[ProductionPlan]]: """ 生産問題を解いてステータスと解を返す Returns ------- tuple[int, Optional[ProductionPlan]] 解を解いたステータスと解のペア 最適解が得られなかった場合、解はNoneを返す """ x, model = self.__modeling() status = self.__solve(model) if status != pulp.LpStatusOptimal: return status, None plan = ProductionPlan({ product: x[product].value() for product in self.__product_list.items }) return status, plan def __modeling(self) -> tuple[dict[Product, pulp.LpVariable], pulp.LpProblem] # 変数 x = pulp.LpVariable.dicts( "x", self.__product_list.items, cat="Continuous", lowBound=0 ) # 数理モデル作成 model = pulp.LpProblem("ProductionProblem", pulp.LpMaximize) # 制約式 for material in self.__material_list.items: required = pulp.lpSum( self.__require.for_pair(product, material) for product in self.__product_list.items ) model += required <= self.__stock.for_material(material) # 目的関数 total_gain = pulp.lpSum( self.__gain.for_product(product) * x[product] for product in self.__product_list.items ) model += total_gain return x, model def __solve(self, model: pulp.LpProblem) -> int: return model.solve()
こうしておくと状態を気にする必要もなくなるのでかなり使いやすくなる。
かなり長くなったけど、プロダクトレベルで使える安全で堅牢なコードを書こうとすると、元のコードからかなり手を入れる必要がある。 正直かなりメンドイし、さらには単体テストも書く必要があって、実際にはもう少し手を抜いたりもするけど。
ただ、こうしておくと型チェックで問題に気付けたり、異常なデータが紛れ込むのを防げたり、オブジェクトの状態を気にせずに使えたりと、メリットも大きい。 型の情報でコードの読みやすさも上がってるし。
プロダクトを作っていくエンジニアは、数理最適化の知識だけでなく、こういったソフトウェア工学の知識も身につけておきたいよね。
今日はここまで!