1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PuLP から linopy へ ― xarray ネイティブな最適化ライブラリの実力

1
Last updated at Posted at 2026-06-08

はじめに

PuLP を使って LP/MIP を解いてきたけれど、変数や制約が多くなるとモデル構築が遅い、あるいはメモリが気になる、という経験はないでしょうか。

本記事では、そうした悩みに応える linopy を紹介します。PuLP との具体的なコード比較と、ローリングホライゾン生産計画でのベンチマーク結果(速度・メモリ)を通じて、linopy の実力を体感していただきます。

主な対象読者: PuLP などのインターフェースを用いて LP/MIP を解いたことがある方

再現用リンク


1. なぜ今 linopy か

Python の LP/MIP ライブラリには長い歴史があります。

時期 ライブラリ 状況
2010年代 PuLP COIN-OR 発。現在も活発に開発中
2019年頃 python-mip Gurobi/CBC 対応で登場、現在は開発停滞中
2022年〜 linopy PyPSA チームが開発、活発に更新中

linopy は 2022 年に JOSS(Journal of Open Source Software)に論文が掲載され1、電力システム最適化フレームワーク PyPSA のバックエンドとして実用されています。solver サポートも HiGHS・Gurobi・CPLEX・Xpress・GLPK・SCIP と幅広く、開発ペースも今が一番旺盛です。

なぜ PuLP でなく linopy か

PuLP が優れていないという話ではありません。PuLP 3.x では pulp.HiGHS クラスが追加され、インメモリ実行にも対応しました。今でも十分実用的です。

linopy の差別化ポイントは 変数・制約の定義方法にあります。

  • PuLP: 変数は Python 辞書、制約は for ループで 1 本ずつ追加
  • linopy: 変数・制約を xarray の DataArray として扱い、配列操作で一括定義

この違いが、多変数・多制約モデルの構築速度に直結します。


2. linopy の基本

インストール

pip install linopy "highspy!=1.14.0"

highspy==1.14.0 には Windows でのクラッシュ(#646)と presolve による誤解答(#648)の 2 つのバグが報告されており、linopy の依存関係定義で除外されています。いずれも HiGHS 本体では修正済みで、1.15.0 以降には影響しません。

最小構成のモデル

import numpy as np
import xarray as xr
import linopy

# 変数の座標(インデックス)を xarray スタイルで定義
coords = {"product": np.arange(3), "day": np.arange(5)}

m = linopy.Model()
# 変数を一括追加(3製品 × 5日 = 15変数)
x = m.add_variables(lower=0, coords=coords, name="x")

# 目的関数:xarray ブロードキャスト
cost = xr.DataArray([1.0, 2.0, 3.0], dims=["product"])
m.add_objective((cost * x).sum())

# 制約:全製品・全日程を 1 行で
m.add_constraints(x.sum("product") <= 100, name="capacity")

m.solve(solver_name="highs", output_flag=False, log_to_console=False)
print(m.objective.value)
print(m.solution["x"])  # xarray Dataset として取得

PuLP との主な対応は次のとおりです。

PuLP linopy
LpVariable(...) m.add_variables(coords=..., name=...)
prob += expression (目的関数) m.add_objective(expression)
prob += constraint (制約) m.add_constraints(expression, name=...)
prob.solve(solver) m.solve(solver_name=..., ...)
pulp.value(var) m.solution["varname"] (xarray Dataset)

3. PuLP との Before/After コード比較

2 製品・3 日の生産計画問題で比べます。

変数:生産量 $x_{p,t}$、在庫量 $s_{p,t}$
制約:在庫バランス $s_{p,t} = s_{p,t-1} + x_{p,t} - d_{p,t}$、在庫上限 $s_{p,t} \le s^{\max}_p$

PuLP

import pulp

prob = pulp.LpProblem("production", pulp.LpMinimize)

# 変数を辞書で管理
x = {(p, t): pulp.LpVariable(f"x_{p}_{t}", lowBound=0)
     for p in range(N_P) for t in range(N_T)}
s = {(p, t): pulp.LpVariable(f"s_{p}_{t}", lowBound=0, upBound=max_inv[p])
     for p in range(N_P) for t in range(N_T)}

# 目的関数(lpSum より LpAffineExpression が高速)
prob += pulp.LpAffineExpression(
    [(x[p, t], prod_cost[p]) for p in range(N_P) for t in range(N_T)]
    + [(s[p, t], inv_cost[p]) for p in range(N_P) for t in range(N_T)]
)

# 制約:二重ループで 1 本ずつ追加
for p in range(N_P):
    for t in range(N_T):
        prev = init_inv[p] if t == 0 else s[p, t - 1]
        prob += s[p, t] == prev + x[p, t] - demand[p, t]

prob.solve(pulp.HiGHS(msg=False))
print(pulp.value(prob.objective))

linopy

import numpy as np, xarray as xr, linopy

coords = {"product": np.arange(N_P), "day": np.arange(N_T)}
m = linopy.Model()
x = m.add_variables(lower=0, coords=coords, name="x")
s = m.add_variables(
    lower=0,
    upper=xr.DataArray(max_inv, dims=["product"]),
    coords=coords,
    name="s",
)

da_d    = xr.DataArray(demand,   dims=["product", "day"])
da_init = xr.DataArray(init_inv, dims=["product"])
da_pc   = xr.DataArray(prod_cost, dims=["product"])
da_ic   = xr.DataArray(inv_cost,  dims=["product"])

# 目的関数:ブロードキャストで一括
m.add_objective((da_pc * x + da_ic * s).sum())

# day=0 の制約(1 本)
m.add_constraints(
    s.isel(day=0) - x.isel(day=0) == da_init - da_d.isel(day=0),
    name="balance_0",
)
# day=1..N_T-1 の制約:day 次元を揃えて 1 回で渡す
inner = np.arange(1, N_T)
s_cur  = s.isel(day=slice(1, None)).assign_coords(day=inner)
s_prev = s.isel(day=slice(0, -1)).assign_coords(day=inner)
x_cur  = x.isel(day=slice(1, None)).assign_coords(day=inner)
m.add_constraints(
    s_cur - s_prev - x_cur == -da_d.isel(day=slice(1, None)).assign_coords(day=inner),
    name="balance_inner",
)

m.solve(solver_name="highs", output_flag=False, log_to_console=False)
print(m.objective.value)

ループがなくなったのが一目でわかります。linopy の add_constraints は xarray の次元ブロードキャストを使って、複数の制約をまとめて処理します。

ポイント: add_constraintsfor ループで繰り返し呼ぶと遅くなります。day 次元を揃えて 1 回で渡すのが鍵です(後述のベンチマークで詳細)。


4. ローリングホライゾンで差が出る

問題設定

**ローリングホライゾン(Rolling Horizon)**は、大規模な時系列最適化問題を小さなウィンドウに分割して解く手法で、実務でよく使われます。

1 年分(365 日)の生産計画を 1 本の最適化問題として解くと変数・制約が膨大になります。代わりに「直近 N 日分だけ解く、M 日分を実行、また立て直す」を繰り返すことで計算量を現実的なサイズに収めます。

ここで注意が必要なのがエッジ効果です。たとえばスライド幅(M 日)ぴったりのウィンドウで解くと、最終日の計画はその先の需要を一切考慮しないため、在庫を不合理に使い切る・積み増すといった問題が起きます。これを防ぐために実行幅より長い look-ahead を設けます。スライド幅よりウィンドウを広くとることで、期末付近の計画も次期の需要を見越した合理的な判断ができます。

day:  0  1  2  3  4  5  6 │ 7  8   ← ウィンドウ: 実行 7 日 + look-ahead 2 日 = 9 日
      [=== 実行に採用 ====]│[先読み]
                            ↓ 7 日スライドして次のウィンドウ
      7  8  9 10 11 12 13 │14 15

look-ahead を長くするほど解の品質は上がりますが、1 回の計算が重くなります。スライド幅・ウィンドウ幅は問題の性質に応じて調整します。今回は例として次の設定を使います:

パラメータ
製品数 500
日数 365 日
ウィンドウ長 9 日
スライド幅 7 日
繰り返し回数 51 回
ソルバー HiGHS 1.13.1(両者共通)

PuLP の実装:毎回リビルド

PuLP ではウィンドウごとに LpProblem を丸ごと作り直します。

def solve_window_pulp(start, inventory_init):
    prob = pulp.LpProblem("production", pulp.LpMinimize)
    # 変数・制約・目的関数を毎回構築
    ...
    prob.solve(pulp.HiGHS(msg=False))
    return objective, next_inventory

linopy の実装:RHS 更新のみ

linopy の最大の強みは、モデル構造(変数・制約の構造)を使い回せる点です。ウィンドウが変わっても変わるのは需要データ(右辺値)だけ。build_model は 1 回だけ呼び、以降は update_model で RHS を書き換えます。

def build_model(inventory_init, start):
    m = linopy.Model()
    ...  # 変数・制約・目的関数を定義
    return m


def update_model(m, inventory_init, start):
    """需要が変わった分だけ RHS を書き換える。モデル再構築は不要。"""
    da_d    = xr.DataArray(demand[:, start:start + WINDOW], dims=["product", "day"])
    da_init = xr.DataArray(inventory_init, dims=["product"])
    m.constraints["balance_0"].rhs     = (da_init - da_d.isel(day=0)).values
    m.constraints["balance_inner"].rhs = (-da_d.isel(day=slice(1, None))).values


def run_rolling_horizon_linopy():
    inventory = initial_inventory.copy()
    m = build_model(inventory, start=0)  # 1 回だけ構築
    for i, start in enumerate(range(0, N_DAYS - WINDOW + 1, SLIDE)):
        if i > 0:
            update_model(m, inventory, start)  # RHS だけ更新
        m.solve(**SOLVER_OPTS, progress=False)
        inventory = np.maximum(m.solution["s"].isel(day=SLIDE - 1).values, 0)

ベンチマーク結果

                               PuLP     linopy    speedup
────────────────────────────────────────────────────────────
total wall time (s)            7.21       3.77       1.9x
build/update avg (ms)          65.3       17.5       3.7x
  first build (ms)             97.8       44.1       2.2x
  update avg (ms)              64.6       16.9       3.8x
solve avg (ms)                 72.7       55.8
  うちソルバー受け渡し (ms)     62.7       45.8       1.4x
  うち HiGHS 求解 (ms)          ≈10        ≈10       ≈1.0x
cumulative build time (s)      3.33       0.89       3.7x

モデル構築で 3.7 倍、合計実行時間で 1.9 倍の差がつきました。

first build はローリングホライゾンを使わず 1 回だけモデルを構築するケース(例:バッチ最適化)に相当します。linopy 44ms vs PuLP 98ms と 2.2 倍の差があります。

solve 時間の内訳を分析すると、HiGHS ソルバー自体の求解時間は両者ほぼ同じ(≈10ms) です。差の源泉は Python→HiGHS へのデータ受け渡しにかかるインターフェースのオーバーヘッドで、linopy は io_api="direct" により highspy Python API を直接呼び出すため、受け渡し時間で 1.4 倍速くなっています。

なお linopy v0.7.0 では制約の RHS を更新するたびに内部行列のキャッシュが再計算されるオーバーヘッドがあります(Issue #198)。将来のバージョンで freeze_constraints=True オプションが実装される予定で、さらなる高速化が期待されています。

補足: PuLP 4.0.0a9 も試した

執筆時点では、PuLP 4 系の alpha 版(4.0.0a9)も公開されています。内部に Rust core を持つため build 性能の改善を期待して試しました。

同じ問題での結果は次のとおりでした。

                               PuLP 3.3.2   PuLP 4.0.0a9
─────────────────────────────────────────────────────────
total wall time (s)                  6.83          12.17
build avg (ms)                       59.6           47.9
solve avg (ms)                       70.9          188.6

build は多少改善しましたが、solve call 全体はむしろ大きく悪化しました。これは alpha 版であり、今後改善される可能性はあります。

再現用リンク:

ただし、少なくとも現時点では Rust core が入っただけで劇的に速くなるわけではありません。モデル構築そのものは依然として Python から変数・制約を逐次追加するスタイルが基本であり、xarray で配列をまとめて渡せる linopy とは設計思想が異なるためです。

追記: actualSolve() の profiling も取ってみた

「どこが遅いのか」を確認するため、HiGHS.actualSolve() の内訳を PuLP 3.3.2 / 4.0.0a9 で profiling しました。結果は次のとおりです。

                               PuLP 3.3.2   PuLP 4.0.0a9
─────────────────────────────────────────────────────────
add_columns                         30.30         136.28
constraint_items                     2.08           5.19
add_rows                            16.05          16.07
call_solver                         15.78          14.36
total_actual_solve                  76.91         188.88

profiling の結果、支配的だったのは PuLP の汎用データ構造を solver ごとの内部形式へ変換する部分でした。特に各変数を 1 個ずつ addCol / addVar で追加する処理の比重が大きく、PuLP 4.0.0a9 ではこの列追加だけで PuLP 3.3.2 の 約 4.5 倍の時間がかかっていました。

また、HiGHS 本体の求解時間 (call_solver) はほぼ同じで、制約追加 (add_rows) も大きくは変わっていません。したがって今回の退行は「ファイル経由に戻っていたから」ではなく、PuLP モデルから solver API への変換・逐次追加の経路にあると考えられます。

さらに実装を見ると、この逐次追加スタイルは HiGHS 固有ではありません。PuLP の Gurobi / SCIP / COPT などの Python API バックエンドも、exported_variables() から各 solver の addVar/addConstr を順に呼び出す構造になっています。程度の差はあっても、逐次追加に起因するオーバーヘッドは HiGHS 固有というより、PuLP のモデリング層全体の性質と考えられます。

そのため、本記事の本比較は安定版の PuLP 3.3.2 を対象としています。

メモリ効率

                               peak MB
──────────────────────────────────────
PuLP (model build)               7.4
linopy (model build)             1.0
ratio                            7.5x

linopy は PuLP の 1/7.5 のメモリでモデルを構築できます。PuLP が変数・制約を個別の Python オブジェクトとして保持するのに対し、linopy は numpy/xarray 配列にまとめて格納するためです。大規模問題や繰り返し実行の多いケースでは大きな差になります。


5. パフォーマンスを引き出す 3 つのポイント

linopy で性能を最大化するには 3 つのことが重要です。

add_constraints は 1 回にまとめる

# ❌ ループで呼ぶと遅い(for 文 9 回 → 約 143ms)
for t in range(WINDOW):
    m.add_constraints(balance_eq[t], name=f"balance_{t}")

# ✅ day 次元を揃えて 1 回で渡す(約 41ms、3.5 倍速い)
m.add_constraints(all_balance_eq, name="balance_inner")

内部的には add_constraints を呼ぶたびにスパース行列の結合処理が走ります。1 回にまとめるだけで劇的に速くなります。

② ソルバーを io_api="direct" で呼ぶ

linopy のデフォルトは io_api="lp"(LP ファイル経由)ですが、io_api="direct" にすると highspy の Python API を直接呼び出し、ファイル I/O を完全に省けます。

m.solve(solver_name="highs", io_api="direct",
        output_flag=False, log_to_console=False)

500 製品・9 日ウィンドウで計測すると:

io_api=direct    avg= 57.7ms   total=1.15s
io_api=lp        avg= 92.4ms   total=1.85s  (direct の 1.6x 遅い)
io_api=mps       avg=106.3ms   total=2.13s  (direct の 1.8x 遅い)

1 回あたり約 35〜50ms の差が、繰り返し回数分そのまま積み上がります。

③ ローリングホライゾンではモデルを使い回す

# ❌ 毎回リビルド(PuLP と同じ)
for start in windows:
    m = build_model(inventory, start)
    m.solve(...)

# ✅ 変化した部分だけ更新(linopy の強み)
m = build_model(inventory, start=0)
for start in windows[1:]:
    update_model(m, inventory, start)
    m.solve(...)

linopy では求解のたびに全部作り直す必要はありません。変化した部分だけを書き換えられます。

更新対象 方法 コスト
制約の右辺値(RHS) m.constraints["name"].rhs = ... 軽い(b ベクトルのみ更新)
目的関数の係数 m.add_objective(..., overwrite=True) 軽い(c ベクトルのみ更新)
制約係数(LHS) remove_constraints + add_constraints 重い(A 行列の再計算が必要)

今回のローリングホライゾンでは「ウィンドウが変わるたびに変わるのは需要データ(RHS)だけ」という構造を活用しています。コストパラメータが変動する場合は目的関数係数の更新も同様に軽量です。PuLP にはこの「部分更新」の仕組みがなく、毎回フルリビルドになります。


まとめ

特長 PuLP linopy
変数・制約の定義 Python 辞書 + ループ xarray 配列(ループ不要)
モデル更新 毎回再構築 RHS・目的係数を部分更新可
モデル構築速度 baseline 3.7x 高速
ピークメモリ baseline 7.5x 少ない
合計実行時間 baseline 1.9x 高速

linopy は xarray を前提にした設計のため、pandas/xarray を使いこなしている方には自然に馴染むと思います。また電力・エネルギー分野での実績があり、PyPSA コミュニティを中心にドキュメントも整っています。

今回のベンチマークでは、モデル構築・ソルバー受け渡し・メモリ効率のすべての工程で linopy が上回る結果になりました。特に規模が大きく求解時間が短い問題(たとえばローリングホライゾンの各ウィンドウのように、数千変数でも HiGHS が数十 ms 以内に解ける場合)では、インターフェースのオーバーヘッドが全体に占める割合が大きくなるため、linopy への移行効果がより顕著です。PuLP で書いたコードは本記事の Before/After をほぼそのまま参考に書き換えられます。

PuLP を使っていて速度やメモリが気になり始めたら、linopy への移行を積極的に検討してみてください。


付録:コードと実行環境

実行環境

バージョン
Python 3.13.13
linopy 0.7.0
PuLP 3.3.2
highspy 1.13.1
OS Windows 11 Pro

おわりに

筆者は電力会社で発電計画のための数理最適化のモデリングやシステム開発に取り組んでいます。
会社の公式アカウントではないのであくまで個人としての宣伝ですが、2026年6月現在、私の職場で数理最適化をバックグランドとする方の中途採用を行っています。
2028年度新卒の学生向けには、2週間のインターンシップも行っています。

もし興味があれば、応募をご検討ください!
応募前に質問・相談したいことなどがあれば、私のXのアカウントにDMいただければ返信します。

  1. Hofmann, F. et al. (2023). linopy: Linear optimization with n-dimensional labeled variables. Journal of Open Source Software, 8(84), 4823.

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?