本文へスキップ

栄養問題と感度分析 (潜在価格 Dual・スラック Slack) ​

問題クラスソルバー主な APIリンク
線形計画問題 (LP)HiGHS (標準)m.continuous(), m.constraint(), expr (dot), minimize

線形計画問題を解いた際、決定変数の最適値(var.value)だけでなく、**感度分析(Sensitivity Analysis)**の情報を取得できると実務での意思決定が格段に深まります。

  1. 潜在価格 (constr.dual / Shadow Price): どの制約条件がコストを押し上げているボトルネックなのか? その基準が 1 単位厳しくなると目的関数値がいくら変化するか。
  2. スラック (constr.slack / 余剰量): どの制約条件が上限・下限に張り付いており(Binding)、どの制約に余裕(Surplus)があるか。
  3. 被約費用 (var.reducedCost): 最適解で選ばれなかった選択肢(値 0)が、あと何ドル安くなれば最適解に採用されるか。

入力データ ​

6種類の食品から1日あたりの摂取量($0 \le x_i \le 4$)を決定し、4つの1日あたり最低栄養基準(カロリー Calories >= 2000 kcal、タンパク質 Protein >= 105 g、カルシウム Calcium >= 1000 mg、鉄分 Iron >= 18 mg)を満たしつつ食費合計を最小化します:

食品名1食あたりコストカロリー (kcal)タンパク質 (g)カルシウム (mg)鉄分 (mg)1日の上限 (食)
Oats(オートミール)$0.601505201.84.0
Chicken(鶏肉)$2.4022032151.24.0
Eggs(卵)$0.8014012501.74.0
Milk(牛乳)$0.9012082800.14.0
Spinach(ほうれん草)$1.504041203.54.0
Salmon(サーモン)$3.6020025200.84.0

WaSHI による実装 ​

typescript
import { Model, minimize, expr } from "@ohtaman/washi";

const foods = [
  { name: "Oats",    cost: 0.60, calories: 150, protein: 5,  calcium: 20,  iron: 1.8 },
  { name: "Chicken", cost: 2.40, calories: 220, protein: 32, calcium: 15,  iron: 1.2 },
  { name: "Eggs",    cost: 0.80, calories: 140, protein: 12, calcium: 50,  iron: 1.7 },
  { name: "Milk",    cost: 0.90, calories: 120, protein: 8,  calcium: 280, iron: 0.1 },
  { name: "Spinach", cost: 1.50, calories: 40,  protein: 4,  calcium: 120, iron: 3.5 },
  { name: "Salmon",  cost: 3.60, calories: 200, protein: 25, calcium: 20,  iron: 0.8 },
];

async function runDietSensitivity() {
  const m = new Model("DietSensitivity");

  // 各食品の1日あたり摂取量 (0 〜 最大 4 食分)
  const srv = m.continuous(foods.map((f) => f.name), { lb: 0, ub: 4 });

  const costs = Object.fromEntries(foods.map((f) => [f.name, f.cost]));
  const cals  = Object.fromEntries(foods.map((f) => [f.name, f.calories]));
  const prots = Object.fromEntries(foods.map((f) => [f.name, f.protein]));
  const calcs = Object.fromEntries(foods.map((f) => [f.name, f.calcium]));
  const irons = Object.fromEntries(foods.map((f) => [f.name, f.iron]));

  // 目的関数: 1日の食費合計を最小化 (expr 内の dot による内積)
  m.objective(minimize(expr`dot(${costs}, ${srv})`));

  // 栄養基準の制約: m.constraint と expr による数式定義
  const nutConstraints = [
    m.constraint(expr`dot(${cals}, ${srv}) >= 2000`, "min_calories"),
    m.constraint(expr`dot(${prots}, ${srv}) >= 105`,  "min_protein"),
    m.constraint(expr`dot(${calcs}, ${srv}) >= 1000`, "min_calcium"),
    m.constraint(expr`dot(${irons}, ${srv}) >= 18`,   "min_iron"),
  ];

  const status = await m.solve();

  console.log(`Status: ${status}, Minimum Daily Cost: $${m.objectiveValue?.toFixed(2)}`);
  console.log("--- Food Servings & Reduced Costs ---");
  for (const f of foods) {
    const v = srv[f.name];
    console.log(`  ${f.name.padEnd(8)}: value=${v.value?.toFixed(2)}, reducedCost=${v.reducedCost?.toFixed(4)}`);
  }
  console.log("--- Nutritional Constraints (Dual & Slack) ---");
  for (const c of nutConstraints) {
    console.log(`  ${c.name.padEnd(13)}: dual=$${c.dual?.toFixed(4)}, slack=${Math.abs(c.slack ?? 0).toFixed(2)}`);
  }
}

runDietSensitivity();
Playground で開く →

結果の解釈 ​

  • ボトルネック制約 (slack == 0, dual > 0): カロリー(2000kcal)と鉄分(18mg)が下限に張り付いており、鉄分の必要量を 1mg 増やすと食費が $0.3241 増加することが分かります。
  • 余剰のある制約 (slack > 0, dual == 0): タンパク質(+46.46g 余剰)とカルシウム(+483.02mg 余剰)はすでに十分満たされているため、潜在価格は $0 です。
  • 下限(不採用)での被約費用 (Salmon): 不採用(0.00)となった Salmon の被約費用は +1.5125 であり、1食あたり $1.51 以上値下がりすれば最適メニューに入ることが分かります。
  • 上限に張り付いた変数の負の被約費用 (Oats, Eggs): Oats(reducedCost = -0.2581)と Eggs(reducedCost = -0.0887)は1日の上限(ub = 4.0)に達しています。最小化問題において上限に張り付いた変数の負の被約費用は「上限 ub を 1 単位緩和したときに目的関数値がいくら改善(減少)するか」を表しており、Oats の上限を 1 食分引き上げれば食費がさらに $0.26/日 下がることが読み取れます。
  • 基底の安定範囲(局所的な微分係数): 潜在価格(dual)や被約費用(reducedCost)は、現在の最適基底(Optimal Basis)が変わらない範囲での限界変化率を表します。右辺(RHS)や境界を大きく変更して別の制約や変数が新たに境界に当たると、基底が切り替わり潜在価格も新しい傾きへ段階的に変化します。

Released under the MIT License.