100本ノック / マーケティングサイエンス / マーケティングサイエンス100本ノック

製造業の価格最適化をPythonで実践|価格弾力性から動的価格・強化学習まで

産業用フィルターの価格最適化:需要反応から継続運用まで(No.051〜No.060)

本記事では、産業用集じんフィルターを扱う架空の製造業を題材に、価格弾力性、顧客選好、利益最大化、動的価格設定、販促・クーポン、オンライン学習、ベイズ最適化、強化学習、そして実務実装までを一つの意思決定プロセスとして扱います。価格を「値上げ幅」だけで考えず、需要、粗利、生産能力、顧客関係、学習コストを同時に見ることが目的です。

[!NOTE] 本資料は、数理工房 (もしくは代表である和山個人) が過去に企業研修において使用した notebook を企業様の許可を得て再構成・編集のうえ公開しています。 掲載データはすべて架空のものであり、実在する企業・工場・数値とは一切関係ありません。

はじめに:この記事で扱う製造業の実務課題

原材料費と物流費が上昇する一方、営業部門には失注への不安があります。さらに、生産能力が逼迫する月と余裕のある月があり、全顧客・全期間に同じ価格を提示するだけでは利益機会を逃します。本記事では、営業・生産・経理が共通の数値で価格を議論できる最小構成を作ります。

現場でよくある状況

  • 値上げ後の数量減を経験則だけで置いている
  • 売上高を追う施策と限界利益を追う施策が混在している
  • 割引が「前年踏襲」で、増分利益を検証していない
  • 顧客別価格が属人化し、学習結果が組織に残らない
  • 需要を刺激しても、工場の供給能力がボトルネックになる

なぜこの問題は判断が難しいのか

価格は需要と粗利を逆方向に動かします。また、観測データには顧客構成、季節、営業努力が混ざるため、価格と受注量の相関をそのまま因果効果とはみなせません。短期利益だけでなく、長期契約、説明可能性、公平性、ブランド毀損も制約になります。

今回扱うノックの全体像

No.051〜No.053で価格反応と静的な利益最大化を学び、No.054〜No.056で時期・販促・顧客の違いを扱います。No.057〜No.059は運用中に学習する方法を比較し、No.060でKPI、承認、監視を含む実装案に統合します。

Python 環境の準備

外部データは使いません。乱数生成器を固定し、同じ結果を再現できるようにします。金額は特記がない限り円、数量は個/月です。

import sys
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import japanize_matplotlib
from scipy.optimize import minimize_scalar
from sklearn.linear_model import LinearRegression

SEED = 20260712
rng = np.random.default_rng(SEED)
pd.set_option("display.float_format", lambda x: f"{x:,.2f}")
print(f"Python {sys.version.split()[0]} / pandas {pd.__version__} / matplotlib {matplotlib.__version__}")
Python 3.13.1 / pandas 3.0.3 / matplotlib 3.11.0

架空データの作成

24か月、3顧客セグメントの商談データを生成します。価格は市況や月によって変動し、需要は価格、季節性、顧客規模の影響を受ける設定です。真の需要式は教材用のデータ生成にだけ使用し、分析側では観測データから推定します。

months = pd.date_range("2024-01-01", periods=24, freq="MS")
segments = {"大口": (320, -1.15), "中堅": (190, -1.55), "小口": (95, -2.05)}
rows = []
for t, month in enumerate(months):
    season = 1 + 0.10 * np.sin(2 * np.pi * t / 12)
    market = 1 + 0.025 * t / 12
    for segment, (base_qty, elasticity) in segments.items():
        price = 10_000 * market * rng.uniform(0.92, 1.10)
        expected = base_qty * season * (price / 10_000) ** elasticity
        quantity = max(1, int(round(expected + rng.normal(0, base_qty * 0.045))))
        rows.append((month, segment, price, quantity, 6_200 + 18 * t))
sales = pd.DataFrame(rows, columns=["month", "segment", "price", "quantity", "unit_cost"])
sales["revenue"] = sales.price * sales.quantity
sales["gross_profit"] = (sales.price - sales.unit_cost) * sales.quantity
display(sales.head(6))
print(f"行数: {len(sales):,}、売上高: {sales.revenue.sum()/1e6:,.1f}百万円")
month segment price quantity unit_cost revenue gross_profit
0 2024-01-01 大口 10,446.52 314 6200 3,280,205.73 1,333,405.73
1 2024-01-01 中堅 10,764.81 168 6200 1,808,488.43 766,888.43
2 2024-01-01 小口 9,260.98 113 6200 1,046,490.19 345,890.19
3 2024-02-01 大口 10,916.25 300 6218 3,274,875.57 1,409,475.57
4 2024-02-01 中堅 9,544.64 228 6218 2,176,176.96 758,472.96
5 2024-02-01 小口 9,751.53 105 6218 1,023,911.01 371,021.01
行数: 72、売上高: 144.8百万円

No.051:価格弾力性

実務での意味

価格弾力性は、価格を1%変えたとき需要量が何%変わるかを表します。絶対値が1を超える市場では価格変更に需要が大きく反応します。ただし、弾力性だけで値上げ可否は決まらず、原価と能力制約を合わせて判断します。

分析・モデル化の考え方

定弾力性モデルを Q=APεQ=A P^{\varepsilon} とすると、対数を取って

logQ=α+εlogP\log Q=\alpha+\varepsilon\log P

となり、回帰係数 ε\varepsilon が価格弾力性です。ここでは季節変動の影響を抑えるため月ダミーも加えます。観測研究では内生性が残るため、本番では価格実験や操作変数も検討します。

Pythonで確認する

elasticity_rows = []
for segment, g in sales.groupby("segment"):
    X = pd.concat([np.log(g.price).rename("log_price"), pd.get_dummies(g.month.dt.month, prefix="m", drop_first=True)], axis=1)
    model = LinearRegression().fit(X, np.log(g.quantity))
    elasticity_rows.append((segment, model.coef_[0], model.score(X, np.log(g.quantity))))
elasticity_df = pd.DataFrame(elasticity_rows, columns=["segment", "estimated_elasticity", "R2"])
display(elasticity_df)

fig, ax = plt.subplots(figsize=(7, 4))
ax.bar(elasticity_df.segment, elasticity_df.estimated_elasticity, color=["#4472C4", "#70AD47", "#ED7D31"])
ax.axhline(-1, color="black", linestyle="--", label="単位弾力性 (-1)")
ax.set_title("顧客セグメント別の推定価格弾力性")
ax.set_xlabel("顧客セグメント"); ax.set_ylabel("価格弾力性")
ax.grid(axis="y", alpha=.3); ax.legend(); plt.tight_layout(); plt.show()
segment estimated_elasticity R2
0 中堅 -2.07 0.96
1 大口 -1.15 0.98
2 小口 -2.09 0.93

png

結果の読み取り

小口ほど絶対値が大きく、価格比較が起きやすい設定を回帰でも捉えています。大口には一律割引ではなく供給安定や技術支援を含む価値提案、小口には競合価格の監視が重要です。推定値は施策対象期間、信頼区間、価格決定過程を確認してから意思決定に使います。

No.052:コンジョイント分析

実務での意味

B2B製品では、単価だけでなく寿命、納期、保守契約が選定を左右します。コンジョイント分析は、顧客が製品全体を比較した選択データから各属性の相対価値を推定し、仕様と価格を同時に設計する方法です。

分析・モデル化の考え方

効用を U=β0+βpP+kβkxkU=\beta_0+\beta_p P+\sum_k\beta_k x_k と置き、今回は架空の選択スコアから線形回帰で部分効用を確認します。属性の支払意思額は、属性係数を価格係数の絶対値で割って求めます。本番の離散選択にはロジットモデルや階層ベイズが適します。

Pythonで確認する

n = 240
profiles = pd.DataFrame({
    "price_k": rng.choice([9.5, 10.5, 11.5, 12.5], n),
    "life_18m": rng.integers(0, 2, n),
    "delivery_3d": rng.integers(0, 2, n),
    "remote_support": rng.integers(0, 2, n),
})
true_beta = np.array([-0.85, 1.50, 0.95, 0.65])
profiles["preference_score"] = profiles[["price_k", "life_18m", "delivery_3d", "remote_support"]].to_numpy() @ true_beta + rng.normal(0, .65, n)
conjoint = LinearRegression().fit(profiles.drop(columns="preference_score"), profiles.preference_score)
coef = pd.Series(conjoint.coef_, index=profiles.columns[:-1], name="部分効用")
wtp = (coef.drop("price_k") / abs(coef["price_k"]) * 1000).rename("支払意思額_円")
display(pd.concat([coef, wtp], axis=1))

fig, ax = plt.subplots(figsize=(7, 4))
wtp.sort_values().plot.barh(ax=ax, color="#5B9BD5")
ax.set_title("追加属性に対する推定支払意思額")
ax.set_xlabel("支払意思額(円/個)"); ax.set_ylabel("追加属性")
ax.grid(axis="x", alpha=.3); plt.tight_layout(); plt.show()
部分効用 支払意思額_円
price_k -0.81 NaN
life_18m 1.60 1,972.24
delivery_3d 0.87 1,074.14
remote_support 0.59 720.21

png

結果の読み取り

長寿命化の支払意思額が最も大きく、短納期、遠隔支援が続きます。追加原価が支払意思額を下回る属性は高付加価値版の候補です。ただし平均値だけでは顧客差を隠すため、購買関与者や用途別に設計・分析します。

No.053:利益最大化

実務での意味

売上最大化と利益最大化は一致しません。価格を下げれば数量は増えますが、1個当たり限界利益が縮小します。価格別の需要予測と原価をつなぎ、営業利益への感度を可視化します。

分析・モデル化の考え方

予測需要を Q(P)=A(P/P0)εQ(P)=A(P/P_0)^\varepsilon、単位変動費を cc とすると、限界利益は

Π(P)=(Pc)Q(P)\Pi(P)=(P-c)Q(P)

です。ここでは能力上限も入れ、販売可能数量を min(Q(P),K)\min(Q(P),K) とします。

Pythonで確認する

price_grid = np.arange(7_500, 14_001, 100)
base_demand, base_price, eps, unit_cost, capacity = 620, 10_000, -1.55, 6_500, 700
demand = base_demand * (price_grid / base_price) ** eps
sold = np.minimum(demand, capacity)
profit = (price_grid - unit_cost) * sold
profit_table = pd.DataFrame({"price": price_grid, "demand": demand, "sold": sold, "gross_profit": profit})
best = profit_table.loc[profit_table.gross_profit.idxmax()]
display(best.to_frame("最適点"))

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(price_grid, profit / 1e6, label="限界利益", color="#4472C4")
ax.axvline(best.price, color="#C00000", linestyle="--", label=f"最適価格 {best.price:,.0f}円")
ax.set_title("価格と月間限界利益の関係(能力上限込み)")
ax.set_xlabel("価格(円/個)"); ax.set_ylabel("月間限界利益(百万円)")
ax.grid(alpha=.3); ax.legend(); plt.tight_layout(); plt.show()
最適点
price 14,000.00
demand 368.04
sold 368.04
gross_profit 2,760,288.64

png

結果の読み取り

利益曲線の頂点付近は比較的平らであり、数十円単位の「一点最適」に固執する意味は大きくありません。現場では許容価格帯を定め、競合、契約、能力、推定誤差を加味して採用価格を選ぶ方が堅牢です。

No.054:動的価格設定

実務での意味

需要と生産余力が月ごとに違うなら、固定価格は機会損失を生みます。ただし既存契約を恣意的に変えるのではなく、スポット注文、短納期料金、閑散期割引など、説明可能な価格ルールとして設計します。

分析・モデル化の考え方

各月 tt で需要係数 AtA_t と能力 KtK_t を予測し、候補価格から maxp(pct)min{Qt(p),Kt}\max_p (p-c_t)\min\{Q_t(p),K_t\} を選びます。将来への持ち越しや在庫が重要なら多期間最適化に拡張します。

Pythonで確認する

dynamic_rows = []
for t in range(12):
    season = 1 + .22 * np.sin(2 * np.pi * t / 12)
    cap = 650 + 70 * np.cos(2 * np.pi * t / 12)
    cost = 6_500 + 120 * np.sin(2 * np.pi * (t + 2) / 12)
    q = 620 * season * (price_grid / 10_000) ** -1.55
    gp = (price_grid - cost) * np.minimum(q, cap)
    i = np.argmax(gp)
    dynamic_rows.append((t + 1, season, cap, price_grid[i], gp[i]))
dynamic = pd.DataFrame(dynamic_rows, columns=["month", "demand_index", "capacity", "optimal_price", "gross_profit"])
display(dynamic.round(1))

fig, ax1 = plt.subplots(figsize=(8, 4))
ax1.plot(dynamic.month, dynamic.optimal_price, marker="o", color="#4472C4", label="推奨価格")
ax1.set_xlabel("月"); ax1.set_ylabel("推奨価格(円/個)")
ax2 = ax1.twinx(); ax2.plot(dynamic.month, dynamic.demand_index, marker="s", color="#ED7D31", label="需要指数")
ax2.set_ylabel("需要指数")
ax1.set_title("季節需要と月別推奨価格"); ax1.grid(alpha=.3)
lines = ax1.lines + ax2.lines; ax1.legend(lines, [x.get_label() for x in lines], loc="best")
plt.tight_layout(); plt.show()
month demand_index capacity optimal_price gross_profit
0 1 1.00 720.00 14000 2,722,041.00
1 2 1.10 710.60 14000 3,014,897.70
2 3 1.20 685.00 14000 3,240,659.40
3 4 1.20 650.00 14000 3,340,611.70
4 5 1.20 615.00 14000 3,286,194.30
5 6 1.10 589.40 14000 3,088,431.80
6 7 1.00 580.00 14000 2,798,536.30
7 8 0.90 589.40 14000 2,495,963.40
8 9 0.80 615.00 14000 2,265,343.50
9 10 0.80 650.00 14000 2,170,249.30
10 11 0.80 685.00 14000 2,234,383.00
11 12 0.90 710.60 14000 2,437,003.60

png

結果の読み取り

需要が強い月には推奨価格が上がり、能力不足による取りこぼしを価格で調整しています。実務では価格の変更頻度、上下限、顧客への事前通知を制約に入れ、緊急対応の対価など顧客が理解できる根拠を残します。

No.055:キャンペーン最適化

実務での意味

展示会、技術セミナー、サンプル提供は予算だけでなく営業・技術者の工数を使います。反応率ではなく増分限界利益を基準に、チャネルへの配分を決めます。

分析・モデル化の考え方

施策 jj の投下口数を xjx_j、限界的な増分利益を逓減関数 gj(xj)g_j(x_j) とします。総予算内で jgj(xj)cjxj\sum_j g_j(x_j)-c_jx_j を最大化します。ここでは理解しやすいよう、10万円単位を貪欲法で配分します。

Pythonで確認する

channels = {"展示会": (95, .16), "技術セミナー": (72, .12), "サンプル提供": (58, .09)}  # 初期増分利益(万円), 逓減率
budget_units = 18
allocation = {k: 0 for k in channels}
gain = {k: 0.0 for k in channels}
for _ in range(budget_units):
    marginal = {k: a * np.exp(-d * allocation[k]) for k, (a, d) in channels.items()}
    chosen = max(marginal, key=marginal.get)
    allocation[chosen] += 1; gain[chosen] += marginal[chosen]
campaign = pd.DataFrame({"投資額_万円": {k: v * 10 for k, v in allocation.items()}, "増分限界利益_万円": gain})
campaign["ROI"] = campaign.増分限界利益_万円 / campaign.投資額_万円
display(campaign)

fig, ax = plt.subplots(figsize=(7, 4))
campaign.投資額_万円.plot.bar(ax=ax, color="#70AD47")
ax.set_title("最適化後のキャンペーン予算配分")
ax.set_xlabel("施策"); ax.set_ylabel("投資額(万円)")
ax.grid(axis="y", alpha=.3); plt.xticks(rotation=0); plt.tight_layout(); plt.show()
投資額_万円 増分限界利益_万円 ROI
展示会 60 396.50 6.61
技術セミナー 60 326.80 5.45
サンプル提供 60 281.18 4.69

png

結果の読み取り

初期効率だけで全額を一施策へ寄せず、逓減後の限界効果が均衡するよう配分されます。係数は過去の相関ではなく、地域や顧客群を分けた実験で増分効果として更新することが重要です。

No.056:クーポン設計

実務での意味

B2Bのクーポンは、保守部品の初回注文、休眠顧客の再開、オンライン発注への移行などに使えます。値引きした既存需要(カニバリゼーション)を含めて増分利益を評価します。

分析・モデル化の考え方

顧客 ii にクーポンを出す増分利益を

ΔΠi=(pdc)Pr(Yi(1)=1)(pc)Pr(Yi(0)=1)\Delta\Pi_i=(p-d-c)\Pr(Y_i(1)=1)-(p-c)\Pr(Y_i(0)=1)

とします。配布費も差し引き、正の顧客だけを対象とします。実務では uplift model とランダム化比較試験で2つの確率差を推定します。

Pythonで確認する

coupon = pd.DataFrame({
    "segment": ["高確率・低反応", "中確率・高反応", "休眠・中反応", "低粗利"],
    "p_without": [.72, .35, .08, .28], "p_with": [.76, .58, .22, .50],
    "price": [10_500, 10_500, 10_500, 8_000], "cost": [6_200, 6_200, 6_200, 6_900],
    "discount": [500, 500, 700, 500], "contact_cost": [80, 80, 120, 80],
})
coupon["incremental_profit"] = ((coupon.price-coupon.discount-coupon.cost)*coupon.p_with
                                - (coupon.price-coupon.cost)*coupon.p_without-coupon.contact_cost)
coupon["send"] = coupon.incremental_profit > 0
display(coupon[["segment", "p_without", "p_with", "incremental_profit", "send"]])

fig, ax = plt.subplots(figsize=(8, 4))
colors = np.where(coupon.send, "#70AD47", "#C00000")
ax.bar(coupon.segment, coupon.incremental_profit, color=colors)
ax.axhline(0, color="black", linewidth=1)
ax.set_title("セグメント別クーポンの増分利益")
ax.set_xlabel("顧客セグメント"); ax.set_ylabel("1件当たり期待増分利益(円)")
ax.grid(axis="y", alpha=.3); plt.xticks(rotation=15); plt.tight_layout(); plt.show()
segment p_without p_with incremental_profit send
0 高確率・低反応 0.72 0.76 -288.00 False
1 中確率・高反応 0.35 0.58 619.00 True
2 休眠・中反応 0.08 0.22 328.00 True
3 低粗利 0.28 0.50 -88.00 False

png

結果の読み取り

購入確率が高い顧客は反応しても既存売上の値引きが大きく、配布対象にならない場合があります。反応率の高い層ではなく、クーポンが行動を変え、かつ粗利を残す層を選ぶことが要点です。

No.057:バンディットアルゴリズム

実務での意味

複数価格を試すとき、固定A/Bテストは終了まで非効率な価格を出し続けます。多腕バンディットは、学習のための探索と、現時点で良い案を使う活用を逐次的に調整します。

分析・モデル化の考え方

Thompson Samplingでは価格案ごとの受注確率を θkBeta(ak,bk)\theta_k\sim\mathrm{Beta}(a_k,b_k) とし、事後分布から標本化した受注確率と受注時粗利の積が最大の案を提示します。価格の公平性や契約条件が同じ比較可能な案件に限定します。

Pythonで確認する

prices = np.array([9_500, 10_500, 11_500])
cost = 6_200
true_conv = np.array([.46, .39, .30])
a = np.ones(3); b = np.ones(3); counts = np.zeros(3, dtype=int); rewards = np.zeros(3)
for _ in range(600):
    sampled_value = rng.beta(a, b) * (prices - cost)
    arm = int(np.argmax(sampled_value))
    won = rng.random() < true_conv[arm]
    a[arm] += won; b[arm] += 1-won; counts[arm] += 1; rewards[arm] += won * (prices[arm]-cost)
bandit = pd.DataFrame({"price": prices, "trials": counts, "posterior_conversion": a/(a+b), "gross_profit": rewards})
display(bandit)

fig, ax = plt.subplots(figsize=(7, 4))
ax.bar(bandit.price.astype(str), bandit.trials, color="#4472C4")
ax.set_title("Thompson Samplingによる価格案の提示回数")
ax.set_xlabel("提示価格(円)"); ax.set_ylabel("提示回数")
ax.grid(axis="y", alpha=.3); plt.tight_layout(); plt.show()
price trials posterior_conversion gross_profit
0 9500 153 0.47 237,600.00
1 10500 109 0.40 184,900.00
2 11500 338 0.31 545,900.00

png

結果の読み取り

アルゴリズムは受注率だけでなく受注時粗利も考慮し、期待利益が高い価格へ配分を寄せます。一方で、案件難易度が価格案ごとに偏ると学習が歪むため、顧客属性を条件に入れる contextual bandit や適用除外ルールが必要です。

No.058:ベイズ最適化

実務での意味

価格検証に時間がかかり、試せる回数が少ない場合、総当たりは困難です。ベイズ最適化は、観測済みの価格と利益から未観測点を推定し、少ない試行で有望な価格を探します。

分析・モデル化の考え方

ここではガウス過程の考え方を簡略化し、RBFカーネル回帰で平均利益と距離に基づく不確実性を作ります。獲得関数を UCB(p)=μ(p)+κσ(p)\mathrm{UCB}(p)=\mu(p)+\kappa\sigma(p) として探索します。教材用の簡易実装であり、本番ではノイズ付きGPと制約付き獲得関数を使います。

Pythonで確認する

grid = np.linspace(8_000, 14_000, 121)
def objective(p):
    q = 620 * (p / 10_000) ** -1.55
    return (p - 6_500) * q + rng.normal(0, 55_000)

observed_x = [8_000., 11_000., 14_000.]
observed_y = [objective(x) for x in observed_x]
for _ in range(7):
    dist = (grid[:, None] - np.array(observed_x)[None, :]) / 900
    w = np.exp(-0.5 * dist**2) + 1e-9
    mean = (w @ np.array(observed_y)) / w.sum(axis=1)
    nearest = np.min(np.abs(grid[:, None] - np.array(observed_x)[None, :]), axis=1)
    uncertainty = 80_000 * (1 - np.exp(-nearest / 700))
    next_x = grid[np.argmax(mean + 1.4 * uncertainty)]
    observed_x.append(float(next_x)); observed_y.append(objective(next_x))
bo = pd.DataFrame({"price": observed_x, "observed_profit": observed_y})
display(bo.round(0))

true_curve = (grid - 6_500) * 620 * (grid / 10_000) ** -1.55
fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(grid, true_curve/1e6, label="潜在的な期待利益", color="#4472C4")
ax.scatter(bo.price, bo.observed_profit/1e6, color="#C00000", label="試行点", zorder=3)
ax.set_title("少数試行による価格探索")
ax.set_xlabel("価格(円/個)"); ax.set_ylabel("月間限界利益(百万円)")
ax.grid(alpha=.3); ax.legend(); plt.tight_layout(); plt.show()
price observed_profit
0 8,000.00 1,271,091.00
1 11,000.00 2,397,138.00
2 14,000.00 2,760,474.00
3 13,300.00 2,685,233.00
4 13,650.00 2,755,292.00
5 12,800.00 2,738,434.00
6 13,850.00 2,691,381.00
7 13,050.00 2,720,481.00
8 13,500.00 2,587,793.00
9 12,250.00 2,549,503.00

png

結果の読み取り

初期点から不確実な領域を試し、次第に高利益の価格帯へ試行が集まります。試行点が少ないほどモデル仮定の影響が強いため、価格上下限、最低受注率、顧客保護を制約として先に定義します。

No.059:強化学習

実務での意味

現在の価格が将来の在庫、顧客関係、学習に影響する場合、単月最適化では不十分です。強化学習は、状態を見て行動を選び、将来報酬まで含めた方策を学習します。

分析・モデル化の考え方

状態を在庫水準、行動を価格、報酬を限界利益と保管・欠品ペナルティの合計とします。Q学習は

Q(s,a)Q(s,a)+α[r+γmaxaQ(s,a)Q(s,a)]Q(s,a)\leftarrow Q(s,a)+\alpha[r+\gamma\max_{a'}Q(s',a')-Q(s,a)]

で更新します。需要モデルが明確な場合は、まず動的計画法やシミュレーション最適化を比較対象にします。

Pythonで確認する

inventory_levels = np.arange(0, 701, 100)
rl_prices = np.array([9_000, 10_500, 12_000])
Q = np.zeros((len(inventory_levels), len(rl_prices)))
alpha, gamma, epsilon = .12, .92, .15
for episode in range(5000):
    inv = 500
    for t in range(6):
        s = int(np.argmin(abs(inventory_levels-inv)))
        arm = rng.integers(3) if rng.random() < epsilon else int(np.argmax(Q[s]))
        p = rl_prices[arm]
        demand = max(0, int(rng.normal(110*(p/10_000)**-1.6, 12)))
        sold = min(inv, demand); next_inv = min(700, inv-sold+100)
        reward = (p-6_200)*sold - 250*next_inv - 1_500*max(demand-inv, 0)
        ns = int(np.argmin(abs(inventory_levels-next_inv)))
        Q[s, arm] += alpha*(reward + gamma*Q[ns].max()-Q[s, arm])
        inv = next_inv
policy = pd.DataFrame({"inventory": inventory_levels, "recommended_price": rl_prices[np.argmax(Q, axis=1)]})
display(policy)

fig, ax = plt.subplots(figsize=(7, 4))
ax.step(policy.inventory, policy.recommended_price, where="mid", color="#7030A0")
ax.set_title("学習した在庫水準別の推奨価格方策")
ax.set_xlabel("期首在庫(個)"); ax.set_ylabel("推奨価格(円/個)")
ax.grid(alpha=.3); plt.tight_layout(); plt.show()
inventory recommended_price
0 0 9000
1 100 9000
2 200 9000
3 300 9000
4 400 12000
5 500 12000
6 600 10500
7 700 9000

png

結果の読み取り

在庫が少ない状態では高価格で欠品を抑え、多い状態では価格を下げて滞留を減らす方策が基本になります。表の細部はシミュレーション設定に依存します。本番投入前にオフライン評価、保守的方策との比較、価格上下限、人による承認が不可欠です。

No.060:実務応用

実務での意味

優れたアルゴリズムだけでは価格最適化は定着しません。原価マスター、契約条件、営業例外、能力計画、承認責任、効果検証を一つの運用に組み込みます。

分析・モデル化の考え方

実務では「推奨価格を出す層」と「業務制約で検証する層」を分けます。推奨値と採用値の差、例外理由、実績を保存し、利益だけでなく受注率、継続率、供給遵守、公平性を監視します。まずは決定支援として開始し、自動化範囲を段階的に広げます。

Pythonで確認する

recommendations = pd.DataFrame({
    "customer": ["A工業", "B製作所", "C化学", "D精機", "E素材"],
    "segment": ["大口", "中堅", "小口", "中堅", "大口"],
    "model_price": [11_800, 11_200, 10_100, 11_500, 12_100],
    "contract_floor": [10_800, 10_500, 9_800, 10_700, 11_000],
    "contract_ceiling": [11_500, 11_400, 10_600, 11_300, 11_900],
    "capacity_risk": ["高", "低", "低", "中", "高"],
})
recommendations["guardrailed_price"] = recommendations.model_price.clip(
    lower=recommendations.contract_floor, upper=recommendations.contract_ceiling)
recommendations["needs_approval"] = (recommendations.model_price != recommendations.guardrailed_price) | recommendations.capacity_risk.eq("高")
display(recommendations)

kpi = pd.DataFrame({
    "KPI": ["限界利益/能力時間", "受注率", "推奨採用率", "価格例外率", "90日継続率"],
    "監視頻度": ["週次", "週次", "月次", "月次", "四半期"],
    "主担当": ["経理・生産", "営業", "営業企画", "営業企画", "営業"],
})
display(kpi)
customer segment model_price contract_floor contract_ceiling capacity_risk guardrailed_price needs_approval
0 A工業 大口 11800 10800 11500 11500 True
1 B製作所 中堅 11200 10500 11400 11200 False
2 C化学 小口 10100 9800 10600 10100 False
3 D精機 中堅 11500 10700 11300 11300 True
4 E素材 大口 12100 11000 11900 11900 True
KPI 監視頻度 主担当
0 限界利益/能力時間 週次 経理・生産
1 受注率 週次 営業
2 推奨採用率 月次 営業企画
3 価格例外率 月次 営業企画
4 90日継続率 四半期 営業

結果の読み取り

モデル価格は契約上下限で補正され、能力リスクが高い案件や補正が発生した案件は承認対象になります。このように「モデルが決める」のではなく、「モデルが根拠を揃え、人が例外を統制する」形から始めると、監査可能性と現場受容性を両立できます。

対象ノックを通して見える実務上の示唆

  1. 価格反応の推定、顧客価値、原価、能力を同じ利益指標へ接続する必要があります。
  2. 静的最適化は基準価格、バンディットやベイズ最適化は限定的な学習、強化学習は多期間問題という使い分けが重要です。
  3. 最適値よりも、推定誤差に耐える価格帯とガードレールの設計が実務価値を持ちます。
  4. 価格は顧客との約束です。説明可能性、公平性、契約遵守を目的関数と同列に扱います。

実務導入する場合に必要なこと

  • データ:見積提示、受注・失注、数量、原価、顧客属性、契約、納期、能力を案件IDで接続する
  • 検証:対象を限定した価格実験と、増分利益・継続率の事前評価計画を作る
  • 業務設計:上下限、変更頻度、適用除外、承認者、顧客説明文を定義する
  • システム:推奨値、採用値、上書き理由、実績を履歴として保存する
  • 監視:需要ドリフト、利益、失注、セグメント間の不公平、苦情を定期監視する

まとめ

価格最適化は単なる値上げ計算ではありません。需要を測り、顧客価値を設計し、供給制約を含む利益に変換し、安全に学習を続ける意思決定システムです。最初の一歩としては、主要1製品・1地域を対象に、静的な利益曲線と価格ガードレールを営業会議へ導入する方法が現実的です。

法人向けのご相談

数理工房では、価格弾力性の推定、価格・販促実験の設計、利益最適化、営業プロセスへの実装、担当者向け研修まで、企業のデータ成熟度に合わせて支援します。既存データで何が検証できるかという段階からご相談いただけます。

📩 お問い合わせ: surikobo.co.jp/contact まずはお気軽にご相談ください。