100本ノック / 数理モデリング / 数理モデリング100本ノック

設備・顧客・在庫の「次の状態」を予測する

設備・顧客・在庫の「次の状態」を予測する

産業設備メーカーで学ぶ状態遷移モデル No.071〜No.080

本記事では、産業設備メーカーのアフターサービスを題材に、設備の健康状態、顧客の稼働・休眠・離反、補修部品在庫、営業ファネルを状態変数として表します。遷移確率、マルコフモデル、故障・保全、施策前後の将来状態をPythonで確認します。

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

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

設備メーカーは、納入設備60台の週次健康状態を監視しています。異常設備へ予防保全を実施すると故障は減りますが、保全費と停止時間が発生します。また、交換部品を購入する顧客は稼働・休眠・離反の間を移り、営業案件も複数段階を進みます。

単月の件数だけでなく「今の状態から次にどこへ移るか」をモデル化し、将来構成と施策効果を評価します。

現場でよくある状況

  • 正常台数だけを集計し、注意から異常へ進む速度を見ていない
  • センサーしきい値と保全行動が結びついていない
  • 故障後修理と予防保全の回復確率を比較していない
  • 顧客離反を最終結果だけで見て、休眠という前兆を追わない
  • 在庫不足と過剰が翌週にどう残るか分からない
  • 営業ファネルの各段階を独立KPIとして管理している

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

状態は時点間で引き継がれます。異常台数は新たに悪化した台数だけでなく、前週の異常から回復・故障した台数にも左右されます。

状態遷移モデルでは、状態集合を相互排他的に定義し、遷移確率の各行が1になることを確認します。マルコフモデルは「次の状態が現在状態に依存する」という単純化であり、経過時間や履歴が重要なら状態を細分化します。

今回扱うノックの全体像

No.テーマ実務での判断
071状態変数何を状態として保持するか
072顧客状態稼働・休眠・離反をどう定義するか
073遷移確率実績から確率行列をどう作るか
074マルコフモデル数週先の状態構成をどう予測するか
075顧客離反休眠対策は離反をどれだけ減らすか
076設備状態センサー値を正常・注意・異常へ変える
077故障と保全保全有無で回復・故障確率はどう違うか
078在庫状態過剰・適正・不足の持続性をどう測るか
079購買ファネル商談段階の将来人数をどう予測するか
080施策評価予防保全の費用と故障削減をどう比較するか

Python 環境の準備

外部データは使いません。乱数シードを固定し、NumPy・pandas・matplotlibで状態系列と遷移行列を生成します。

%matplotlib inline
%config InlineBackend.figure_format = 'svg'
import platform,sys
import matplotlib, matplotlib.pyplot as plt
from matplotlib import font_manager
import numpy as np, pandas as pd
from IPython.display import display
SEED=42; rng=np.random.default_rng(SEED)
fonts={f.name for f in font_manager.fontManager.ttflist}; plot_font=next((f for f in ["Hiragino Sans","Yu Gothic","Noto Sans CJK JP"] if f in fonts),"sans-serif")
plt.rcParams["font.family"]=plot_font; plt.rcParams["axes.unicode_minus"]=False
print(f"Python {sys.version.split()[0]} / NumPy {np.__version__} / pandas {pd.__version__} / matplotlib {matplotlib.__version__}")
print(f"font {plot_font} / seed {SEED} / {platform.platform()}")
Python 3.13.1 / NumPy 2.5.1 / pandas 3.0.3 / matplotlib 3.11.0
font Hiragino Sans / seed 42 / macOS-26.3-arm64-arm-64bit-Mach-O

架空データの作成

設備60台を52週間追跡し、正常・注意・異常・故障の間を遷移させます。異常時の一部には予防保全を実施し、遷移確率を改善します。同時に顧客400社・18か月の稼働/休眠/離反状態を生成します。

eq_states=["Normal","Caution","Abnormal","Failure"]
P_eq=np.array([[.90,.08,.015,.005],[.18,.65,.13,.04],[.05,.20,.55,.20],[.65,.05,0,.30]])
P_maint=np.array([.75,.20,.04,.01])
eq_rows=[]
for machine in range(60):
    state="Normal"
    for week in range(52):
        action=(state=="Abnormal" and rng.random()<.55)
        probs=P_maint if action else P_eq[eq_states.index(state)]
        nxt=rng.choice(eq_states,p=probs)
        vib_mean={"Normal":1.2,"Caution":2.1,"Abnormal":3.2,"Failure":4.5}[state]
        eq_rows.append({"machine":machine+1,"week":week,"state":state,"next_state":nxt,"maintenance":action,"vibration":max(0,rng.normal(vib_mean,.22))})
        state=nxt
equipment=pd.DataFrame(eq_rows)

cust_states=["Active","Dormant","Churned"]
P_cust=np.array([[.87,.10,.03],[.20,.63,.17],[0,0,1]])
cust_rows=[]
for customer in range(400):
    state="Active"
    for month in range(18):
        nxt=rng.choice(cust_states,p=P_cust[cust_states.index(state)])
        cust_rows.append({"customer":customer+1,"month":month,"state":state,"next_state":nxt})
        state=nxt
customers=pd.DataFrame(cust_rows)
print(f"設備遷移: {len(equipment):,}件 / 顧客遷移: {len(customers):,}件")
display(equipment.head(8).style.format({"vibration":"{:.2f}"}))
設備遷移: 3,120件 / 顧客遷移: 7,200件
  machine week state next_state maintenance vibration
0 1 0 Normal Normal False 0.97
1 1 1 Normal Normal False 1.41
2 1 2 Normal Normal False 0.91
3 1 3 Normal Normal False 1.13
4 1 4 Normal Normal False 1.01
5 1 5 Normal Normal False 1.37
6 1 6 Normal Normal False 1.45
7 1 7 Normal Normal False 1.01
eq_share=pd.crosstab(equipment["week"],equipment["state"],normalize="index").reindex(columns=eq_states,fill_value=0)
cust_share=pd.crosstab(customers["month"],customers["state"],normalize="index").reindex(columns=cust_states,fill_value=0)
fig,axes=plt.subplots(1,2,figsize=(11,4.2))
eq_share.plot.area(ax=axes[0],stacked=True); axes[0].set_title("設備状態構成の週次推移"); axes[0].set_xlabel("週"); axes[0].set_ylabel("構成比"); axes[0].grid(True,alpha=.3)
cust_share.plot.area(ax=axes[1],stacked=True); axes[1].set_title("顧客状態構成の月次推移"); axes[1].set_xlabel("月"); axes[1].set_ylabel("構成比"); axes[1].grid(True,alpha=.3)
plt.tight_layout(); plt.show()

svg


No.071:状態変数の考え方を理解する

実務での意味

状態変数は、次時点の結果を決めるために引き継ぐ情報です。設備なら健康状態、在庫なら過不足区分が該当します。

分析・モデル化の考え方

St{正常,注意,異常,故障}S_t\in\{正常,注意,異常,故障\} と定義し、時点ごとに必ず一つの状態を持たせます。状態定義は相互排他的・網羅的にします。

Pythonで確認する

sample=equipment.query("machine==1")
state_num={"Normal":0,"Caution":1,"Abnormal":2,"Failure":3}
fig,ax=plt.subplots(); ax.step(sample["week"],sample["state"].map(state_num),where="post",color="#2c7fb8"); ax.set_yticks(range(4),["正常","注意","異常","故障"]); ax.set_title("設備1号機の状態変数"); ax.set_xlabel("週"); ax.set_ylabel("設備状態"); ax.grid(True,alpha=.3); plt.tight_layout(); plt.show()
print(sample["state"].value_counts().to_string())

svg

state
Normal     47
Caution     5

結果の読み取り

状態系列により、注意の継続、異常への悪化、回復を同じ軸で追えます。状態に滞在期間を加えると、長期異常を優先できます。


No.072:顧客状態をモデル化する

実務での意味

交換部品顧客を稼働・休眠・離反に分けると、売上減少の前兆と再活性化を管理できます。

分析・モデル化の考え方

稼働は直近購入あり、休眠は一定期間購入なし、離反は契約終了など、観測可能なルールで定義します。

Pythonで確認する

customer_counts=pd.crosstab(customers["month"],customers["state"]).reindex(columns=cust_states,fill_value=0)
display(customer_counts.tail(8))
fig,ax=plt.subplots(); customer_counts.plot(ax=ax,marker="o"); ax.set_title("顧客状態別の社数推移"); ax.set_xlabel("月"); ax.set_ylabel("顧客数(社)"); ax.grid(True,alpha=.3); plt.tight_layout(); plt.show()
state Active Dormant Churned
month
10 144 66 190
11 145 57 198
12 147 48 205
13 142 38 220
14 128 41 231
15 129 34 237
16 115 33 252
17 114 26 260

svg

結果の読み取り

離反は累積し、稼働顧客が減少します。休眠顧客数は将来離反の先行指標として、再購入施策の対象になります。


No.073:状態遷移確率をモデル化する

実務での意味

遷移確率は、現在状態ごとに次の状態が何割かを表し、悪化・回復の速度を比較できます。

分析・モデル化の考え方

pij=P(St+1=jSt=i)p_{ij}=P(S_{t+1}=j\mid S_t=i)。件数クロス表を行方向に正規化し、各行和が1か確認します。

Pythonで確認する

no_action=equipment.query("not maintenance")
P_hat=pd.crosstab(no_action["state"],no_action["next_state"],normalize="index").reindex(index=eq_states,columns=eq_states,fill_value=0)
display(P_hat.style.format("{:.1%}")); print("行和:",P_hat.sum(axis=1).round(6).to_dict())
fig,ax=plt.subplots(); im=ax.imshow(P_hat,cmap="Blues",vmin=0,vmax=1); ax.set_xticks(range(4),["正常","注意","異常","故障"]); ax.set_yticks(range(4),["正常","注意","異常","故障"]); ax.set_title("設備状態の推定遷移確率"); ax.set_xlabel("次週状態"); ax.set_ylabel("現在状態"); ax.grid(False); plt.colorbar(im,ax=ax,label="遷移確率"); plt.tight_layout(); plt.show()
next_state Normal Caution Abnormal Failure
state        
Normal 91.4% 6.4% 1.4% 0.7%
Caution 18.5% 65.6% 12.4% 3.5%
Abnormal 0.0% 20.0% 55.0% 25.0%
Failure 72.7% 0.0% 0.0% 27.3%
行和: {'Normal': 1.0, 'Caution': 1.0, 'Abnormal': 1.0, 'Failure': 1.0}


svg

結果の読み取り

対角成分は状態継続、右側は悪化、左側は回復を表します。件数の少ない状態は推定誤差が大きいため、信頼区間や階層化を検討します。


No.074:マルコフモデルの考え方を理解する

実務での意味

現在の状態構成と遷移行列から、数週先の正常・注意・異常・故障台数を予測できます。

分析・モデル化の考え方

状態分布ベクトル πt\pi_tπt+k=πtPk\pi_{t+k}=\pi_tP^k で更新します。現在状態だけで次を決めるマルコフ性を仮定します。

Pythonで確認する

pi0=np.array([.80,.15,.04,.01]); forecasts=[]
for k in range(27): forecasts.append({"週":k,**dict(zip(eq_states,pi0@np.linalg.matrix_power(P_hat.to_numpy(),k)))})
markov=pd.DataFrame(forecasts)
display(markov.iloc[::5].style.format({s:"{:.1%}" for s in eq_states}))
fig,ax=plt.subplots();
for s in eq_states: ax.plot(markov["週"],markov[s]*60,label=s)
ax.set_title("マルコフモデルによる設備状態予測"); ax.set_xlabel("将来週"); ax.set_ylabel("期待設備台数(台)"); ax.grid(True,alpha=.3); ax.legend(); plt.tight_layout(); plt.show()
  Normal Caution Abnormal Failure
0 0 80.0% 15.0% 4.0% 1.0%
5 5 72.4% 17.1% 6.7% 3.8%
10 10 71.6% 17.4% 7.0% 4.0%
15 15 71.5% 17.5% 7.0% 4.0%
20 20 71.5% 17.5% 7.0% 4.0%
25 25 71.5% 17.5% 7.0% 4.0%

svg

結果の読み取り

初期構成から長期構成へ収束します。保全履歴や状態滞在期間が次状態へ効く場合は、状態を「注意1週目」などへ拡張します。


No.075:顧客離反を状態遷移として表現する

実務での意味

休眠顧客への施策で再稼働確率を上げ、離反確率を下げたときの将来顧客数を試算できます。

分析・モデル化の考え方

基準行列と施策行列を12乗し、初期顧客400社の状態分布を比較します。

Pythonで確認する

P_base=P_cust.copy(); P_action=P_cust.copy(); P_action[1]=[.32,.58,.10]
initial=np.array([1.,0,0]); rows=[]
for name,P in [("現状",P_base),("休眠再活性化",P_action)]:
    dist=initial@np.linalg.matrix_power(P,12); rows.append({"施策":name,**{s:dist[i]*400 for i,s in enumerate(cust_states)}})
churn_eval=pd.DataFrame(rows); display(churn_eval.style.format({s:"{:.1f}社" for s in cust_states}))
fig,ax=plt.subplots(); x=np.arange(2); ax.bar(x-.2,churn_eval["Active"],.4,label="稼働"); ax.bar(x+.2,churn_eval["Churned"],.4,label="離反"); ax.set_xticks(x,churn_eval["施策"]); ax.set_title("12か月後の顧客状態:休眠施策比較"); ax.set_xlabel("施策"); ax.set_ylabel("期待顧客数(社)"); ax.grid(True,axis="y",alpha=.3); ax.legend(); plt.tight_layout(); plt.show()
  施策 Active Dormant Churned
0 現状 148.0社 48.3社 203.7社
1 休眠再活性化 188.2社 50.1社 161.6社

svg

結果の読み取り

休眠から稼働への復帰率を上げると、12か月後の稼働顧客が増え、離反が減ります。施策費と追加粗利を合わせて判断します。


No.076:設備状態を正常・注意・異常で表現する

実務での意味

連続センサー値を行動可能な状態へ変換すると、監視・再測定・点検のルールを統一できます。

分析・モデル化の考え方

振動値を正常 v<1.8v<1.8、注意 1.8v<2.81.8\le v<2.8、異常 v2.8v\ge2.8 と分類します。しきい値は見逃しと誤警報のバランスで設定します。

Pythonで確認する

equipment["sensor_state"]=pd.cut(equipment["vibration"],[-np.inf,1.8,2.8,np.inf],labels=["正常","注意","異常"],right=False)
sensor_counts=pd.crosstab(equipment["state"],equipment["sensor_state"]); display(sensor_counts)
sample=equipment.query("machine==2")
fig,ax=plt.subplots(); ax.plot(sample["week"],sample["vibration"],marker="o",markersize=3); ax.axhline(1.8,color="#f0a202",linestyle="--"); ax.axhline(2.8,color="#de2d26",linestyle="--"); ax.set_title("設備2号機の振動値と状態しきい値"); ax.set_xlabel("週"); ax.set_ylabel("振動速度(mm/s)"); ax.grid(True,alpha=.3); plt.tight_layout(); plt.show()
sensor_state 正常 注意 異常
state
Abnormal 0 4 123
Caution 57 434 0
Failure 0 0 66
Normal 2425 11 0

svg

結果の読み取り

しきい値により異常候補を抽出できます。センサー状態と実際の故障・点検結果を比較し、設備型式別に更新します。


No.077:故障と保全を状態遷移として表現する

実務での意味

異常設備へ保全した場合としない場合の回復・故障確率を比較し、予防保全の効果を説明できます。

分析・モデル化の考え方

異常状態を起点に、保全有無で次週状態の条件付き確率を推定します。選択バイアスがあるため因果効果の断定には実験設計が必要です。

Pythonで確認する

abn=equipment.query("state=='Abnormal'"); maint_transition=pd.crosstab(abn["maintenance"],abn["next_state"],normalize="index").reindex(index=[False,True],columns=eq_states,fill_value=0); maint_transition.index=["保全なし","予防保全"]
display(maint_transition.style.format("{:.1%}"))
fig,ax=plt.subplots(); maint_transition[["Normal","Failure"]].plot.bar(ax=ax,color=["#2ca25f","#de2d26"]); ax.set_title("異常設備の次週状態:保全有無"); ax.set_xlabel("保全方針"); ax.set_ylabel("遷移確率"); ax.grid(True,axis="y",alpha=.3); plt.xticks(rotation=0); plt.tight_layout(); plt.show()
next_state Normal Caution Abnormal Failure
保全なし 0.0% 20.0% 55.0% 25.0%
予防保全 79.1% 19.4% 1.5% 0.0%

svg

結果の読み取り

予防保全は正常への回復確率を高め、故障への遷移を減らします。保全費・計画停止と故障損失をNo.080で統合します。


No.078:在庫状態を過剰・適正・不足で表現する

実務での意味

在庫数量を状態へ変換すると、過剰・不足が翌週も続く確率や適正在庫への回復を管理できます。

分析・モデル化の考え方

在庫カバー日数を不足5日未満、適正5〜15日、過剰15日超とし、週次遷移を生成します。

Pythonで確認する

inv_states=["Short","Adequate","Excess"]; P_inv=np.array([[.45,.50,.05],[.12,.76,.12],[.04,.42,.54]])
inv_rows=[]
for item in range(100):
    s="Adequate"
    for week in range(30):
        nxt=rng.choice(inv_states,p=P_inv[inv_states.index(s)]); inv_rows.append((item,week,s,nxt)); s=nxt
inventory=pd.DataFrame(inv_rows,columns=["item","week","state","next_state"])
P_inv_hat=pd.crosstab(inventory["state"],inventory["next_state"],normalize="index").reindex(index=inv_states,columns=inv_states); display(P_inv_hat.style.format("{:.1%}"))
share=pd.crosstab(inventory["week"],inventory["state"],normalize="index").reindex(columns=inv_states,fill_value=0)
fig,ax=plt.subplots(); share.plot.area(ax=ax); ax.set_title("補修部品の在庫状態構成"); ax.set_xlabel("週"); ax.set_ylabel("構成比"); ax.grid(True,alpha=.3); plt.tight_layout(); plt.show()
next_state Short Adequate Excess
state      
Short 40.4% 54.4% 5.1%
Adequate 12.1% 74.4% 13.5%
Excess 4.2% 40.4% 55.3%

svg

結果の読み取り

不足・過剰の継続確率が高い品目は、一時的な例外ではなく発注点・ロット設定の見直し対象です。


No.079:購買ファネルを状態遷移として表現する

実務での意味

認知、問い合わせ、提案、受注、失注を状態として、将来受注件数とボトルネックを予測できます。

分析・モデル化の考え方

受注・失注を吸収状態とし、ファネル遷移行列を累乗します。戻りや停滞も表現できます。

Pythonで確認する

f_states=["認知","問合","提案","受注","失注"]
P_f=np.array([[.70,.18,0,0,.12],[0,.55,.30,0,.15],[0,.05,.50,.28,.17],[0,0,0,1,0],[0,0,0,0,1]])
initial=np.array([1000,0,0,0,0.]); funnel=[]
for k in range(9): funnel.append({"期間":k,**dict(zip(f_states,initial@np.linalg.matrix_power(P_f,k)))})
funnel=pd.DataFrame(funnel); display(funnel.style.format({s:"{:.1f}" for s in f_states}))
fig,ax=plt.subplots();
for s in f_states: ax.plot(funnel["期間"],funnel[s],marker="o",label=s)
ax.set_title("購買ファネルの状態遷移予測"); ax.set_xlabel("将来期間"); ax.set_ylabel("期待案件数"); ax.grid(True,alpha=.3); ax.legend(); plt.tight_layout(); plt.show()
  期間 認知 問合 提案 受注 失注
0 0 1000.0 0.0 0.0 0.0 0.0
1 1 700.0 180.0 0.0 0.0 120.0
2 2 490.0 225.0 54.0 0.0 231.0
3 3 343.0 214.7 94.5 15.1 332.7
4 4 240.1 184.5 111.6 41.6 422.2
5 5 168.1 150.3 111.2 72.8 497.6
6 6 117.6 118.5 100.7 104.0 559.2
7 7 82.4 91.4 85.9 132.2 608.2
8 8 57.6 69.4 70.4 156.2 646.4

svg

結果の読み取り

期間経過とともに受注・失注へ吸収されます。提案から受注への遷移改善が最終受注へどう波及するか試算できます。


No.080:状態遷移モデルを施策評価に使う

実務での意味

予防保全で遷移確率を変えたとき、将来故障台数・停止損失・保全費を比較します。

分析・モデル化の考え方

現状行列と、注意・異常から回復しやすい施策行列を26週先まで更新します。費用は故障台週×150万円と保全費で概算します。

Pythonで確認する

P_policy=P_hat.to_numpy().copy(); P_policy[1]=[.30,.62,.07,.01]; P_policy[2]=[.55,.30,.12,.03]
pi=np.array([.80,.15,.04,.01]); rows=[]
for name,P,maint_cost in [("現状",P_hat.to_numpy(),0),("予防保全強化",P_policy,6_500_000)]:
    dist=pi.copy(); failure_weeks=0
    for _ in range(26): dist=dist@P; failure_weeks+=dist[3]*60
    loss=failure_weeks*1_500_000+maint_cost
    rows.append({"施策":name,"26週後正常台":dist[0]*60,"26週後故障台":dist[3]*60,"累積故障台週":failure_weeks,"保全費":maint_cost,"総期待費用":loss})
policy=pd.DataFrame(rows); display(policy.style.format({"26週後正常台":"{:.1f}","26週後故障台":"{:.1f}","累積故障台週":"{:.1f}","保全費":{:,.0f}","総期待費用":{:,.0f}"}))
fig,axes=plt.subplots(1,2,figsize=(11,4.2)); axes[0].bar(policy["施策"],policy["26週後故障台"],color="#de2d26"); axes[0].set_title("施策別の26週後故障台数"); axes[0].set_xlabel("施策"); axes[0].set_ylabel("期待故障台数"); axes[0].grid(True,axis="y",alpha=.3); axes[1].bar(policy["施策"],policy["総期待費用"]/1e6,color="#2c7fb8"); axes[1].set_title("施策別の総期待費用"); axes[1].set_xlabel("施策"); axes[1].set_ylabel("期待費用(百万円/26週)"); axes[1].grid(True,axis="y",alpha=.3); plt.tight_layout(); plt.show()
print(f"期待費用削減: ¥{policy.loc[0,'総期待費用']-policy.loc[1,'総期待費用']:,.0f}")
  施策 26週後正常台 26週後故障台 累積故障台週 保全費 総期待費用
0 現状 42.9 2.4 59.6 ¥0 ¥89,474,389
1 予防保全強化 48.4 0.7 17.8 ¥6,500,000 ¥33,155,592

svg

期待費用削減: ¥56,318,797

結果の読み取り

予防保全強化は保全費を増やしますが、故障台週と総期待費用を下げる可能性があります。遷移確率の推定誤差を感度分析し、現場で小規模検証します。


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

状態変数は時点間で引き継ぐ情報です。遷移行列は悪化・回復・継続を同時に表し、行列累乗で将来構成を予測できます。設備、顧客、在庫、営業を同じ考え方で扱えますが、状態定義、吸収状態、履歴依存、施策選択バイアスを確認する必要があります。

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

1. 状態定義と判定時刻を統一する

正常・注意・異常、稼働・休眠などを観測可能なルールで定義します。

2. 個体IDで履歴を保持する

設備、顧客、品目、案件の時点間データを結びます。

3. 遷移件数と不確実性を確認する

少数状態の確率を過信せず、信頼区間と更新頻度を決めます。

4. 履歴依存を状態へ取り込む

異常継続週数、顧客経過月数、保全後日数を必要に応じて状態へ加えます。

5. 施策効果を因果的に検証する

保全対象選定の偏りを考慮し、比較群や段階導入で検証します。

6. 費用・制約へ接続する

故障、離反、欠品、失注の影響額と施策費を合わせて判断します。

まとめ

No.071〜080では、設備・顧客・在庫・購買ファネルを状態遷移として表し、遷移確率、マルコフ予測、保全・離反施策の評価へつなげました。現在件数だけでなく、状態がどの速度で悪化・回復するかを見ることが重要です。

法人向けのご相談

数理工房では、設備状態・顧客離反・在庫状態・営業ファネルの遷移分析、予防保全や施策評価、状態KPIの運用設計を支援しています。

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