要約
- 数理最適化エンジニアが, 技術よりの言葉で設計をすると変更容易性がなくなるよ
- 「ユーザーの言葉」で設計しよう
対象読者
- エンジニア
- 特に数理最適化を仕事で使っている人
導入
背景
数理最適化エンジニアとして働く私の周りでは, 「エンジニアの言葉」で設計する同僚がちらほらと見受けられます.
「エンジニアの言葉」で設計すると変更が容易でなくなるのでやめた方がいいよ, こういう設計した方がいいよ, というのを伝えたいのですが, なかなか「数理最適化エンジニア向けのコード設計/クラス設計」に関する記事は, 数が少ないです.あるにはあるのですが, 基本的に「最適化モデリング周りの実装」に焦点が当てられており, 実際の業務で扱う「業務ロジックの実装」というところまでは語られていないように感じています.
そのため, 「エンジニアの言葉」で設計するのはやめた方がいいよ, というのを広めるべきだと実感したので, 本記事にて議論したいと思います.
「エンジニアの言葉で設計する」とは?
本題に入る前に, 本記事において「エンジニアの言葉で設計する」とはどういった意味を表すのか定義します.
例えば, 配送計画(どの荷物を, どの車両で, どこからどこへ運ぶのかを決める)を作成するシステムを作るとします.
この時、「エンジニアの言葉」でディレクトリを切ると、こんな感じになります.
src/
└── planner/
├── io.py
├── optimizer.py
├── postprocessor.py
└── data/
├── dto.py
└── ...
上記を見れば,
-
io.pyでデータを入力し, -
optimizer.pyで最適化を実行, -
postprocessor.pyで後処理して配送計画を作成し, -
io.pyでデータを出力する
という流れが見えると思います. エンジニアとしては, どのモジュールで何をするのかがわかりますね.
ここで, optimizer.py に注目してみましょう. なぜ optimizer.py というようなファイルがあるのでしょうか?
optimizeを訳すと「最適化」という単語になります. おそらく, 配送計画を数理最適化を用いて作成するコードが記載されているのだと考えられます. これはあくまでエンジニアが「数理最適化」という技術を使うから, 配送計画を作成するモジュールとしてoptimizerという名前が付与されているわけですね.
ただし, 実際に配送計画を作成するユーザーは, 配送計画を作成する際に 「最適化」などという単語は使わないはずです. 「配送計画を作成する」と表現するのではないでしょうか.
この「配送計画を作成する」の中に,
- 車両の稼働時間は何時から何時で
- 荷物はどこに集積されていて
- なるべく多くの荷物を届ける
といった内容を含んでいるのです.
このように, エンジニア, つまり技術側の都合で命名されたりディレクトリ切られたりクラス構造が決定されていることを, 「エンジニアの言葉で設計する」と呼びたいと思います.
本論
「エンジニアの言葉」で設計することの問題点
「エンジニアの言葉」で設計することの何が問題なのでしょうか?
簡単に言うと, 変更しにくいコードになります.
例えば, 先の例で optimizer.py に定義されている Optimizer クラスが, 配送計画を最適化するものとして実装されていたとします.
class Optimizer:
def build_model(self, input):
"""最適化モデルを構築する処理"""
...
def optimize(self) -> OptimizedResult:
...
def solve(self, input) -> OptimizedResult:
"""最適化を実行し, 配送計画を出力する処理"""
# 入力から最適化モデルを構築
self.build_model(input)
# 最適化実行, 結果を出力
return self.optimize()
Optimizer.optimize が, 引数に io.py によって読み込んだ入力, 出力として最適化の結果が返ってくるものと考えてください.
OptimizedResult クラスを, postprocessor.py に記載の処理によって配送計画として成形するものとします.
from .data.dto import Plan
def postprocess(result: OptimizedResult) -> Plan | None:
# 最適化の結果が実行不可能であれば, None を返す
if result.is_infeasible:
return None
# 実行可能であれば出力を成形
...
ここで追加要件として,
- リアルタイムな計画は1分以内に, 1か月分の計画は24時間以内に作成されること
- 最適化ソルバーで解かせる場合, リアルタイムな計画では時間がかかり1分以内に計画を作成できない場合がある. そのため, リアルタイムな計画の時はより早く計算できるヒューリスティック1で計算する
- ただし, ヒューリスティックでは厳密解を得ることができない. 1か月分の計画の場合には時間がかかってもより精緻な計画を出すことが好ましいため, 最適化ソルバーを用いる
ということになったとします. この場合はどのような変更が必要になるでしょうか?
Optimizer は, どの種類の最適化を実施するかで判断させるようにすればよさそうです.
class Optimizer:
def __init__(self, plan_type: int):
self._plan_type = plan_type
def build_model(self, input):
"""最適化モデルを構築する処理"""
...
def optimize(self) -> OptimizedResult:
# リアルタイム計画である場合, ヒューリスティックによる計算
if self._plan_type == 1:
...
# そうでなければ, 最適化ソルバーによる計算
else:
...
def solve(self, input) -> OptimizedResult:
"""最適化を実行し, 配送計画を出力する処理"""
# 入力から最適化モデルを構築
if self._plan_type != 1:
self.build_model(input)
# 最適化実行, 結果を出力
return self.optimize()
ここで, 条件分岐(if文)が2つも増えています. 条件分岐は可読性を下げることはよく知られた事実なので, あまり好ましくないですね. 今後も似たような形で条件分岐が増えることが想定され, 非常に読みにくいコードになる予感がします.
変更は optimizer.py だけには及びません. postprocessor.py への引数となる OptimizedResult が問題となります.
OptimizedResult には最適化結果によるステータス(最適解, 実行不可能, 非有界など)も含まれています.
ヒューリスティックの場合はこのステータスが存在しないため, postprocess において以下のように変更するかもしれません.
def postprocess(result: OptimizedResult, plan_type: int) -> Plan | None:
# 最適化ソルバーによる実行, かつ最適化の結果が実行不可能であれば, None を返す
if plan_type != 1 and result.is_infeasible:
return None
# 実行可能であれば出力を成形
...
条件分岐で見るべき条件が1つ増えてしまいました. plan_type が 1 であればリアルタイムな場合の計画, すなわちヒューリスティックによる計算である, という知識も漏れてしまっています.
また, 最適化ソルバーによる解かヒューリスティックによるものかで, postprocess が想定外の挙動を起こす可能性もあります. 決定変数が最適化モデルで解く場合とヒューリスティックで解く場合で異なる場合, postprocess で決定変数に問い合わせする際に対応するものが存在しないとして, エラーが吐かれるかもしれません. エラーならまだいい方で, 同じ名前の決定変数でも表すものが異なる場合, 何事もなく動くこともあります. こうなるとデバッグが地獄です.
運よく上記の追加要件を満たすコードができたとしましょう. しかし, 最適化による配送計画の結果に不満があるユーザーから, さらに追加要件が出てきました.
- ユーザーごとにKPI(車両数, 稼働時間など)の優先度が異なるので, 計画作成時のKPIの優先度はユーザーごとに変えられるようにしたい
- 優先度を入力とし, 辞書式最適化2を実行できるようにする
- KPIにかかる目的関数の重みを変更することも考えられるが, 数理最適化について知らないユーザーが設定するのは非現実的
さあ, どう対応しましょう?
変更しにくいことは問題なのか?
上記の例では, ユーザーからすると「配送計画作成のやり方をちょっと変えただけ」なので大した変更に見えないと思います. またそれは, エンジニアにとってもそうであるべきです.
にも関わらず, 変更が多数に波及してしまいました. 上記コードが壊れていないかの保証も, 条件分岐が増えたことでやりにくく, 変更容易性の低いコードになり始めています.
最適化プロジェクトにおいて, 変更容易性は重要です. 最適化プロジェクトは何が難しいかというと, ユーザーが満足する解を出力できるかがあやふやなことです. そもそも「満足」の基準もなかなか決められないので, ユーザーと何回もやり取りして, 満足いく解を出せるやり方を模索していくしかありません3.
そのため, 解を出すやり方は頻繁に変えられるようにすべきなのです.
何故「エンジニアの言葉」で設計してしまうのか?
上述のような設計は, なぜ起こってしまうのでしょうか?
端的に言えば, エンジニアが普段技術の言葉を使って会話しているからだと推察します. ネットミームでしか会話しない人は, ネットミーム以外の言葉を話せなくなる4のと同じですね.
「ユーザーの言葉」で設計しよう
ここまで, 「エンジニアの言葉」で設計をすると何が問題になるのか(とその原因)について述べてきました. じゃあその問題を解決するためにどうしたらよいのか, というのがここからです.
筆者の回答としては, 「ユーザーの言葉」で設計する, です. 「エンジニアの言葉」で設計するのではなく.
「ユーザーの言葉で設計する」とは?
先の例で用いた配送計画システムを用いて, 具体的に説明します.
配送計画システムを使用するユーザーがすること(What)は, 「配送計画を作成する」です. 決して「配送計画を最適化する」ではないはずです.
ただ, 「配送計画を作成する」やり方(How)は無数にあります.
- 最適化モデリングして, 最適化ソルバーを使って解く(いわゆる「最適化する」)
- ヒューリスティックで解く
- SaaS などで提供されている既存のサービスを使って解く
これらがやること(What)は共通しており, 「配送計画を作成する」となるわけです.
上記の考えをもとにディレクトリを切りなおすと, 以下が一案として出てきます5.
src
├── planner/
│ ├── planner.py
│ ├── io.py
│ └── data/
│ ├── dto.py
│ └── ...
└── app/
├── solver_planner.py
└── ...
そして, 各クラス定義は以下のように分けられます6.
import abc
from .data.dto import Plan
class Planner(abc.ABC):
@abc.abstractmethod
def run(self, input) -> Plan:
...
from ..planner.data.dto import Plan
from ..planner.planner import Planner
class SolverPlanner(Planner):
def __init__(self, ...):
# 最適化ソルバーで解く際の設定
...
def run(self, input) -> Plan:
# 最適化ソルバーで解く際の前処理
...
# 最適化モデリング
...
# 求解実行
...
# 最適化ソルバーで解いた際の後処理
...
「ユーザーの言葉」で設計すると何がよいのか?
「ユーザーの言葉」で設計すると, 業務と実装についての複雑さを分離でき, 結果として変更しやすいコードになります.
改善した例において, 先ほどの追加要件について再度考えてみましょう.
- リアルタイムな計画は1分以内に, 1か月分の計画は24時間以内に作成されること
- ユーザーごとにKPI(車両数, 稼働時間など)の優先度が異なるので, 計画作成時のKPIの優先度はユーザーごとに変えられるようにしたい
1 はヒューリスティックでも解けるようにする, 2 は辞書式最適化の導入により対応できるようにする, というので対応可能そうでしたね.
ヒューリスティックで解けるようにする場合, 新たに heuristic_planner.py を src/app 配下に追加し, 以下のクラスを呼び出し側が使用すればよさそうです.
from ..planner.data.dto import Plan
from ..planner.planner import Planner
class HeuristicPlanner(Planner):
def __init__(self, ...):
# ヒューリスティックで解く際の設定
...
def run(self, input) -> Plan:
# ヒューリスティックで解く際の前処理
...
# 最適化モデリングは不要
# 求解実行
...
# ヒューリスティックで解いた際の後処理
...
「ヒューリスティックで解く場合はどういうロジックか」という実装において, 条件分岐がなくなり, 明確になっていますね.
辞書式最適化の場合も, 新たに DictionarySolvingPlanner などのクラスを追加することで, 対応できそうですね. input に優先順位に関する情報を追加する必要はあるかと思いますが, 少なくとも, 「エンジニアの言葉」で設計していたときより実装の方針が見えてくるかと思います.
このように, 「ユーザーの言葉」で設計すると, 業務と実装における複雑さを分離できるようになります. 1 の追加要件でいうと, 業務における複雑さは「リアルタイムの場合, 1分以内に計算するため, ヒューリスティックを用いて計画作成する」であり, 実装における複雑さは「ヒューリスティックで解く際の, 後処理含めたアルゴリズム」です.
「エンジニアの言葉」で設計していた時は, 実装における複雑さの中に業務における複雑さ(plan_type == 1)が紛れ込んでいたために, 複雑さが混ざってしまっていました. 「ユーザーの言葉」で設計すると, HeuristicPlanner に実装における複雑さのみ押し込めることで, 業務における複雑さから分離できています.
業務と実装の複雑さを分離することで, 結果として変更のしやすさにつながります.
アルゴリズム(実装の複雑さ)を変更する場合, src/app 配下の .py ファイルのみ変更すればよく, src/planner は変更不要となります. 複数人で開発している場合, 特に最適化モデルやアルゴリズムといった実装の複雑さに関しては, 専門のメンバーが参画することが多いです. そのメンバーが変更するのは src/app 配下だけで良くなるわけですね.
逆に, 業務ロジック(業務の複雑さ)が変更する場合, src/app 配下へ変更不要です. すなわち, 変更が局所化されるため, 変更に対して柔軟に, スケーラブルになり, 結果として変更が容易となるわけです.
結論
というわけで,
- 数理最適化エンジニアが「エンジニアの言葉」で設計してしまうと, 最適化プロジェクトに必要な変更容易性を損なうようになってしまうこと
- エンジニアが「エンジニアの言葉」で設計してしまうのは, エンジニアがエンジニアの使いやすい言葉を使って設計しているから
- 「ユーザーの言葉」で設計することで, 変更容易性を担保するコードベースに近づく
といった旨の内容を記述してきました.
つい「エンジニアの言葉」で設計してしまう, その気持ちはわかります. 普段使っている言葉の方がなじみやすいですし. ただ, それによって将来変更が入った際に苦しむことを考えれば, 多少苦労してでも, 「ユーザーの言葉」に慣れ親しみ, 「ユーザーの言葉」で設計する方がリターンは大きいのではないかな, と筆者は考えています.
ご一読いただきありがとうございました.
余談
「エンジニアの言葉」で設計することは常に悪なのか?
本記事では、「エンジニアの言葉」で設計することは変更容易性を損ねるのでやめた方がいい, という話をしてきました.
では常にやめた方がいいかというと, そうとも言い切れない状況は, あるにはあります. 筆者は, 以下の条件をすべて満たすのであれば, 無理に「ユーザーの言葉」で設計しなくてもよいかなと思っています.
- 1,2か月間だけ開発し, 期間が終われば捨てる
- ユーザーがどんな言葉を使うかわからない, ユーザーとの接点がない
- 一人で開発する
おそらく, 世の中の大多数の PoC(概念実証)プロジェクトは上記を満たすかもしれません.
ただし注意すべきなのは 1 で, 「本当に1,2か月のPoC期間だけ使用するコードなのか」 というところです. 本来あるべき姿としては,
PoC の実施
↓
PoC の結果, システム化すれば効果が出ることがわかる
↓
システム開発
となります. この時, PoC の時に開発したコードをそのままシステム開発へ持ち越せれば, プロジェクトとしては同じような内容を書き直すコストが浮いてうれしいですね. そういった背景を考えれば, PoC で書き捨てて終わり, というコードは本来的にはないはずです.
あるとすれば, 開発者が開発開始時点から「絶対効果がでない, 将来システム化する訳がない」と考えながらやっているわけで, そんなこと考えるぐらいなら PoC も中止すべきですよね7.
そういうことで, 私の見解では, 上記 1~3 を満たすようなプロジェクトはないと考えます. つまり, みんな最初から綺麗に設計しようね, ということです8.
より詳しく学ぶために
本記事で定義した「ユーザーの言葉」というのは, 『ドメイン駆動設計』でいうところの ユビキタス言語 に等しい, と筆者は解釈しています. ぜひドメイン駆動設計も一緒に学んでもらえますと幸いです.
ドメイン駆動設計初学者の方は, 『ドメイン駆動設計をはじめよう』から読み始めるのをお勧めします.
また, 『良いコード/悪いコードで学ぶ設計入門』にて語られている 目的駆動名前設計 も, 本記事の「ユーザーの言葉」で設計する, という表現と近しいものであると思います. こちらも名著ですので, ぜひ読んでみてください.
-
厳密な最適解が出る保証はないが, 計算時間を大幅に短縮できることがベストプラクティス的に知られる最適化問題の解き方 ↩
-
最初に, 一番優先度の高い目的関数のみで最適化問題を解く. 出てきた目的関数とその値を制約として追加し, 二番目に優先度の高い目的関数のみで最適化問題を解き...というように, 目的関数の優先度順に最大/最小となるように最適化を実施する手法のこと ↩
-
リクルートの梅谷先生の記事に詳しく記載されておりますので, ぜひご一読ください. ↩
-
筆者の黒歴史 ↩
-
本当であれば
io.pyやdto.pyなんかも「エンジニアの言葉」な気がするので設計しなおすべきとは思います. しかし, 本記事の対象読者は「数理最適化エンジニア」なため, 数理最適化に関連する部分のみに焦点を当てます. ↩ -
提示した例は, オブジェクト指向デザインパターンでいうところの, Strategy パターンに相当します. データサイエンティストが Strategy パターンを実践することに関する利点はこちらの記事で紹介されておりますので, ぜひご一読ください. ↩
-
やむにやまれぬ事情で, 開始時点から効果が出ないとわかりきっている PoC を完遂させなければならない場合があることも重々承知しております. 資源の無駄使いだと思いますが. ↩
-
researcher と engineer では求められる役割が違うので, 一概に皆綺麗に書け, というのは横暴な話だとは思います. が, researcher だから綺麗に書かなくてよい, と researcher が言うのは度が過ぎた自己弁護だとも思います. ↩