100本ノック / 機械学習 / 実務機械学習100本ノック
製造業の異常検知入門|ZスコアからIsolation Forest、アラート設計まで
設備異常を「止まる前」に捉える:製造現場の異常検知とアラート設計 10 本ノック
本稿では、製造設備の温度・振動・電流・圧力を題材に、異常の定義、統計的な検知、機械学習による多変量検知、現場で運用できるアラート設計を No.071〜No.080 の 10 本で一気通貫に確認します。目的はアルゴリズムを試すことではなく、保全担当者が「いつ、何を、どの優先度で確認するか」を判断できる仕組みに落とすことです。
[!NOTE] 本資料は、数理工房 (もしくは代表である和山個人) が過去に企業研修において使用した notebook を企業様の許可を得て再構成・編集のうえ公開しています。
掲載データはすべて架空のものであり、実在する企業・工場・数値とは一切関係ありません。
はじめに:この記事で扱う製造業の実務課題
設備停止を減らすには、故障後の復旧だけでなく、状態変化を早期に捉える予知保全が重要です。一方、しきい値を厳しくすればアラートが増え、緩くすれば重大異常を見逃します。本稿では、架空の加工設備 1 台について 30 日分・720 時間のセンサーデータを生成し、検知精度と対応コストの両面から実装を考えます。
現場でよくある状況
- 通常の負荷変動でも温度や電流が上がり、固定しきい値では誤検知が多い
- 瞬間的なスパイク、徐々に進む劣化、複数センサーの組み合わせ異常が混在する
- アラート件数だけが増え、誰がいつ確認するか決まっていない
- 正解ラベルが少なく、精度を評価しにくい
なぜこの問題は判断が難しいのか
「外れた値」と「設備として危険な状態」は同じではありません。製品品種、負荷、立ち上げ・停止、周囲温度によって正常範囲が変わります。また、誤検知は点検工数を浪費し、見逃しは停止損失や安全リスクにつながるため、単純な正解率では評価できません。検知モデル、業務ルール、対応フローを一体で設計する必要があります。
今回扱うノックの全体像
| No. | テーマ | 判断に使うポイント |
|---|---|---|
| 071 | 異常値の種類 | 点・文脈・集合異常を区別する |
| 072–074 | 統計的検知 | 単変量、頑健統計、時間変化を使い分ける |
| 075–077 | 機械学習 | 正解ラベルが少ない状況で境界を学習する |
| 078–079 | 多変量・可視化 | センサー間関係と現場確認を支援する |
| 080 | アラート設計 | 誤検知・見逃しをコストで調整する |
Python 環境の準備
numpy と pandas でデータを扱い、matplotlib で可視化します。異常検知モデルには scikit-learn を用います。再現性のため乱数シードを固定し、外部データには依存しません。
import sys
import warnings
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import japanize_matplotlib
from sklearn.ensemble import IsolationForest
from sklearn.neighbors import LocalOutlierFactor
from sklearn.svm import OneClassSVM
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import confusion_matrix, precision_score, recall_score, f1_score
warnings.filterwarnings("ignore", category=UserWarning)
SEED = 42
rng = np.random.default_rng(SEED)
pd.set_option("display.max_columns", 20)
pd.set_option("display.float_format", "{:.2f}".format)
print("Python :", sys.version.split()[0])
print("numpy :", np.__version__)
print("pandas :", pd.__version__)
print("matplotlib :", matplotlib.__version__)
Python : 3.13.1
numpy : 2.5.1
pandas : 3.0.3
matplotlib : 3.11.0
架空データの作成
加工設備 M-01 の 1 時間ごとの状態を作ります。load_pct(負荷率)に連動して、軸受温度、振動、主軸電流、油圧が変化します。正解確認用に、次の人工的な異常を混ぜます。
- 点異常:一時的な温度・振動スパイク
- 文脈異常:負荷に対して電流だけが過大
- 集合異常:数十時間かけて温度と振動が悪化
- 多変量異常:個々の値は極端でないが、温度・振動・圧力の組み合わせが不自然
実運用では is_anomaly は未知です。本稿では手法比較のためだけに保持します。
n = 24 * 30
ts = pd.date_range("2025-04-01", periods=n, freq="h")
hour = np.arange(n)
load = np.clip(68 + 15 * np.sin(2 * np.pi * hour / 24) + rng.normal(0, 6, n), 25, 100)
df = pd.DataFrame({
"timestamp": ts,
"load_pct": load,
"temperature_c": 41 + 0.20 * load + rng.normal(0, 1.1, n),
"vibration_mm_s": 0.9 + 0.018 * load + rng.normal(0, 0.14, n),
"current_a": 8 + 0.34 * load + rng.normal(0, 1.2, n),
"pressure_mpa": 5.5 - 0.006 * load + rng.normal(0, 0.08, n),
"anomaly_type": "normal",
})
def mark(idx, kind):
df.loc[idx, "anomaly_type"] = kind
point_idx = np.array([95, 188, 422, 611])
df.loc[point_idx, "temperature_c"] += [9, 12, 10, 11]
df.loc[point_idx, "vibration_mm_s"] += [1.1, 1.5, 1.2, 1.4]
mark(point_idx, "point")
context_idx = np.arange(270, 278)
df.loc[context_idx, "current_a"] += 9
mark(context_idx, "contextual")
collective_idx = np.arange(500, 536)
ramp = np.linspace(0.5, 7.5, len(collective_idx))
df.loc[collective_idx, "temperature_c"] += ramp
df.loc[collective_idx, "vibration_mm_s"] += ramp * 0.11
mark(collective_idx, "collective")
multi_idx = np.arange(650, 660)
df.loc[multi_idx, "temperature_c"] += 4.0
df.loc[multi_idx, "vibration_mm_s"] += 0.55
df.loc[multi_idx, "pressure_mpa"] -= 0.28
mark(multi_idx, "multivariate")
df["is_anomaly"] = (df["anomaly_type"] != "normal").astype(int)
print("データ形状:", df.shape)
display(df.head())
display(df.groupby("anomaly_type").size().rename("hours").to_frame())
データ形状: (720, 8)
| timestamp | load_pct | temperature_c | vibration_mm_s | current_a | pressure_mpa | anomaly_type | is_anomaly | |
|---|---|---|---|---|---|---|---|---|
| 0 | 2025-04-01 00:00:00 | 69.83 | 54.86 | 2.06 | 31.12 | 5.11 | normal | 0 |
| 1 | 2025-04-01 01:00:00 | 65.64 | 55.37 | 2.13 | 29.19 | 5.18 | normal | 0 |
| 2 | 2025-04-01 02:00:00 | 80.00 | 54.49 | 2.15 | 36.76 | 5.08 | normal | 0 |
| 3 | 2025-04-01 03:00:00 | 84.25 | 56.20 | 2.62 | 37.41 | 5.09 | normal | 0 |
| 4 | 2025-04-01 04:00:00 | 69.28 | 53.84 | 2.13 | 32.28 | 5.08 | normal | 0 |
| hours | |
|---|---|
| anomaly_type | |
| collective | 36 |
| contextual | 8 |
| multivariate | 10 |
| normal | 662 |
| point | 4 |
No.071:異常値の種類を整理する
実務での意味
異常の種類を区別しないと、単発スパイクに強い手法で緩やかな劣化を探すなど、目的と手段がずれます。点異常はセンサー故障や衝撃、文脈異常は負荷・品種との不整合、集合異常は連続的な劣化として扱います。
分析・モデル化の考え方
各異常区間を、継続時間と最大温度で要約します。「いつもの範囲」を超えたかだけでなく、どの程度続いたかが停止判断に影響します。
Pythonで確認する
type_summary = (df.groupby("anomaly_type")
.agg(hours=("timestamp", "size"),
max_temperature_c=("temperature_c", "max"),
max_vibration_mm_s=("vibration_mm_s", "max"),
mean_current_a=("current_a", "mean"))
.sort_values("hours", ascending=False))
display(type_summary.round(2))
| hours | max_temperature_c | max_vibration_mm_s | mean_current_a | |
|---|---|---|---|---|
| anomaly_type | ||||
| normal | 662 | 62.85 | 2.94 | 30.96 |
| collective | 36 | 65.47 | 3.45 | 31.51 |
| multivariate | 10 | 63.64 | 3.11 | 35.36 |
| contextual | 8 | 60.68 | 2.53 | 42.62 |
| point | 4 | 65.48 | 3.45 | 28.00 |
結果の読み取り
点異常は継続時間が短い一方でピークが大きく、集合異常は 36 時間継続します。前者には即時確認、後者には傾向監視と計画保全というように、異常タイプごとに対応期限を変える判断材料になります。
No.072:Zスコアで異常検知する
実務での意味
Z スコアは、現在値が過去の平均から標準偏差何個分離れているかを示し、説明しやすい一次スクリーニングになります。
分析・モデル化の考え方
温度 、平均 、標準偏差 に対し、 とします。ここでは を異常候補にします。ただし、平均と標準偏差自体が外れ値の影響を受ける点に注意が必要です。
Pythonで確認する
mu = df["temperature_c"].mean()
sigma = df["temperature_c"].std(ddof=0)
df["z_score"] = (df["temperature_c"] - mu) / sigma
df["pred_z"] = (df["z_score"].abs() > 3).astype(int)
z_result = df.loc[df["pred_z"] == 1,
["timestamp", "temperature_c", "z_score", "anomaly_type"]]
print(f"平均={mu:.2f}℃, 標準偏差={sigma:.2f}℃, 検知={len(z_result)}件")
display(z_result.head(10).round(2))
平均=54.86℃, 標準偏差=2.99℃, 検知=4件
| timestamp | temperature_c | z_score | anomaly_type | |
|---|---|---|---|---|
| 531 | 2025-04-23 03:00:00 | 63.96 | 3.05 | collective |
| 533 | 2025-04-23 05:00:00 | 65.47 | 3.55 | collective |
| 535 | 2025-04-23 07:00:00 | 65.07 | 3.42 | collective |
| 611 | 2025-04-26 11:00:00 | 65.48 | 3.56 | point |
結果の読み取り
大きな温度スパイクは少ないルールで拾えます。一方、負荷に連動する正常な高温や、電流だけの文脈異常は評価できません。Z スコアは単一センサーの明確な逸脱を監視するベースラインとして有効です。
No.073:IQRで異常検知する
実務での意味
突発異常が平均や標準偏差を押し上げる場合、中央値に近い考え方である IQR は、頑健な固定しきい値として使えます。
分析・モデル化の考え方
第 1 四分位数を 、第 3 四分位数を とし、 とします。 の外側を候補にします。
Pythonで確認する
q1, q3 = df["vibration_mm_s"].quantile([0.25, 0.75])
iqr = q3 - q1
lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
df["pred_iqr"] = ((df["vibration_mm_s"] < lower) |
(df["vibration_mm_s"] > upper)).astype(int)
print(f"IQR={iqr:.3f}, 下限={lower:.3f}, 上限={upper:.3f}, 検知={df['pred_iqr'].sum()}件")
display(df.loc[df["pred_iqr"] == 1,
["timestamp", "vibration_mm_s", "anomaly_type"]].head(10).round(3))
IQR=0.375, 下限=1.368, 上限=2.869, 検知=19件
| timestamp | vibration_mm_s | anomaly_type | |
|---|---|---|---|
| 176 | 2025-04-08 08:00:00 | 2.94 | normal |
| 188 | 2025-04-08 20:00:00 | 3.29 | point |
| 422 | 2025-04-18 14:00:00 | 3.32 | point |
| 509 | 2025-04-22 05:00:00 | 2.97 | collective |
| 529 | 2025-04-23 01:00:00 | 3.17 | collective |
| 530 | 2025-04-23 02:00:00 | 2.90 | collective |
| 531 | 2025-04-23 03:00:00 | 2.91 | collective |
| 532 | 2025-04-23 04:00:00 | 3.08 | collective |
| 533 | 2025-04-23 05:00:00 | 3.45 | collective |
| 535 | 2025-04-23 07:00:00 | 3.39 | collective |
結果の読み取り
振動の極端な上昇を、分布の歪みに比較的影響されず検知できます。ただし負荷による振動変化を考慮していないため、品種や稼働条件が大きく異なる現場では条件別に基準を持つ必要があります。
No.074:移動平均からの乖離で異常を検出する
実務での意味
設備ごとの基準値が異なっても、「直近の自分自身」からの変化なら監視できます。徐々に変わる状態や局所的な変化に向く方法です。
分析・モデル化の考え方
過去 24 時間の移動平均・移動標準偏差を、現在値を含めずに計算します。現在値を基準計算に含めないことで、異常がしきい値を同時に押し上げる影響を抑えます。
Pythonで確認する
past_temp = df["temperature_c"].shift(1)
df["temp_ma24"] = past_temp.rolling(24, min_periods=12).mean()
df["temp_sd24"] = past_temp.rolling(24, min_periods=12).std()
df["rolling_score"] = ((df["temperature_c"] - df["temp_ma24"]) /
df["temp_sd24"].clip(lower=0.3))
df["pred_rolling"] = (df["rolling_score"] > 3).astype(int)
fig, ax = plt.subplots(figsize=(12, 4))
window = df.iloc[470:550]
ax.plot(window["timestamp"], window["temperature_c"], label="温度", linewidth=1.5)
ax.plot(window["timestamp"], window["temp_ma24"], label="過去24時間平均", linewidth=2)
flag = window[window["pred_rolling"] == 1]
ax.scatter(flag["timestamp"], flag["temperature_c"], color="crimson", label="検知", zorder=3)
ax.set_title("集合異常区間における温度と移動平均")
ax.set_xlabel("日時")
ax.set_ylabel("軸受温度(℃)")
ax.grid(True, alpha=0.3)
ax.legend()
fig.tight_layout()
plt.show()
print("検知件数:", int(df["pred_rolling"].sum()))

検知件数: 2
結果の読み取り
劣化開始時の立ち上がりを検知できますが、上昇が長く続くと移動平均も追随し、検知しにくくなります。短期の逸脱検知と、長期トレンドの傾き監視を併用するのが実務的です。
No.075:Isolation Forestを使う
実務での意味
複数センサーの組み合わせを、人手で多数のしきい値に分解せず監視できます。異常ラベルが少ない立ち上げ段階でも利用可能です。
分析・モデル化の考え方
Isolation Forest はランダムな分割で少数・孤立した観測を短い経路で分離します。ここでは 4 センサーと負荷率を使い、異常率の想定を contamination=0.08 と置きます。この値は「真の異常率」ではなく、点検可能件数との兼ね合いで調整します。
Pythonで確認する
features = ["load_pct", "temperature_c", "vibration_mm_s", "current_a", "pressure_mpa"]
X = df[features]
iso = IsolationForest(n_estimators=300, contamination=0.08, random_state=SEED)
iso.fit(X)
df["score_iso"] = -iso.score_samples(X)
df["pred_iso"] = (iso.predict(X) == -1).astype(int)
iso_metrics = {
"検知件数": int(df["pred_iso"].sum()),
"適合率": precision_score(df["is_anomaly"], df["pred_iso"]),
"再現率": recall_score(df["is_anomaly"], df["pred_iso"]),
"F1": f1_score(df["is_anomaly"], df["pred_iso"]),
}
display(pd.Series(iso_metrics, name="Isolation Forest").to_frame().T.round(3))
| 検知件数 | 適合率 | 再現率 | F1 | |
|---|---|---|---|---|
| Isolation Forest | 58.00 | 0.52 | 0.52 | 0.52 |
結果の読み取り
単変量ルールでは捉えにくいセンサーの組み合わせを検知できます。再現率だけでなく適合率も確認し、点検担当者が処理できる件数かを判断します。学習データに異常が多く混ざると境界が歪むため、安定稼働期間での学習が望まれます。
No.076:Local Outlier Factorを使う
実務での意味
通常状態が複数の運転モードに分かれる場合、データ全体ではなく近傍との密度差を見る LOF が有効です。
分析・モデル化の考え方
LOF は近傍点に比べて局所密度が低い観測を異常とします。距離を使うため標準化が必須です。n_neighbors が小さすぎるとノイズに敏感、大きすぎると局所構造を見失います。
Pythonで確認する
Xs = StandardScaler().fit_transform(X)
lof = LocalOutlierFactor(n_neighbors=30, contamination=0.08)
df["pred_lof"] = (lof.fit_predict(Xs) == -1).astype(int)
df["score_lof"] = -lof.negative_outlier_factor_
display(pd.DataFrame({
"手法": ["LOF"],
"検知件数": [int(df["pred_lof"].sum())],
"適合率": [precision_score(df["is_anomaly"], df["pred_lof"])],
"再現率": [recall_score(df["is_anomaly"], df["pred_lof"])],
"F1": [f1_score(df["is_anomaly"], df["pred_lof"])],
}).round(3))
| 手法 | 検知件数 | 適合率 | 再現率 | F1 | |
|---|---|---|---|---|---|
| 0 | LOF | 58 | 0.71 | 0.71 | 0.71 |
結果の読み取り
LOF は局所的に珍しい状態を拾いますが、異常区間が互いに密集すると「異常同士が近い」ため正常と判定する場合があります。新規データを逐次判定するには novelty=True で正常学習データと推論データを分けます。
No.077:One-Class SVMを使う
実務での意味
正常稼働のサンプルから柔軟な境界を作り、「正常らしさ」から外れた状態を検知します。正常データを明確に確保できる設備に向きます。
分析・モデル化の考え方
RBF カーネルで非線形な正常領域を表現します。nu は境界外となる学習点割合の上限に関係し、gamma は境界の細かさを決めます。ここでは既知の正常データで学習しますが、実務では保全記録に基づく安定期間を選びます。
Pythonで確認する
scaler_oc = StandardScaler()
X_normal = scaler_oc.fit_transform(df.loc[df["is_anomaly"] == 0, features])
X_all = scaler_oc.transform(X)
ocsvm = OneClassSVM(kernel="rbf", nu=0.05, gamma="scale")
ocsvm.fit(X_normal)
df["pred_ocsvm"] = (ocsvm.predict(X_all) == -1).astype(int)
df["score_ocsvm"] = -ocsvm.decision_function(X_all)
display(pd.DataFrame({
"手法": ["One-Class SVM"],
"検知件数": [int(df["pred_ocsvm"].sum())],
"適合率": [precision_score(df["is_anomaly"], df["pred_ocsvm"])],
"再現率": [recall_score(df["is_anomaly"], df["pred_ocsvm"])],
"F1": [f1_score(df["is_anomaly"], df["pred_ocsvm"])],
}).round(3))
| 手法 | 検知件数 | 適合率 | 再現率 | F1 | |
|---|---|---|---|---|---|
| 0 | One-Class SVM | 77 | 0.57 | 0.76 | 0.65 |
結果の読み取り
正常期間を選べると高い検知力を期待できますが、境界はスケーリングとパラメータに敏感です。運転モード変更後は正常分布も変わるため、再学習条件を決めておく必要があります。
No.078:多変量データの異常を検出する
実務での意味
温度・振動・電流・圧力がそれぞれ管理限界内でも、組み合わせとして不自然な場合があります。多変量検知は、この「単独では正常、関係性では異常」を捉えます。
分析・モデル化の考え方
Isolation Forest の異常スコア上位を異常タイプ別に比較します。スコアは絶対確率ではなく、同じモデル・同じ母集団内の優先順位として使います。
Pythonで確認する
score_by_type = (df.groupby("anomaly_type")["score_iso"]
.agg(["count", "mean", "median", "max"])
.sort_values("mean", ascending=False))
display(score_by_type.round(3))
top_multivariate = df.nlargest(12, "score_iso")[["timestamp", "anomaly_type", *features, "score_iso"]]
display(top_multivariate.round(3))
| count | mean | median | max | |
|---|---|---|---|---|
| anomaly_type | ||||
| point | 4 | 0.65 | 0.64 | 0.70 |
| multivariate | 10 | 0.60 | 0.59 | 0.65 |
| contextual | 8 | 0.53 | 0.52 | 0.62 |
| collective | 36 | 0.51 | 0.52 | 0.64 |
| normal | 662 | 0.44 | 0.42 | 0.65 |
| timestamp | anomaly_type | load_pct | temperature_c | vibration_mm_s | current_a | pressure_mpa | score_iso | |
|---|---|---|---|---|---|---|---|---|
| 188 | 2025-04-08 20:00:00 | point | 46.80 | 62.44 | 3.29 | 25.71 | 5.38 | 0.70 |
| 652 | 2025-04-28 04:00:00 | multivariate | 91.00 | 63.65 | 3.11 | 38.44 | 4.78 | 0.65 |
| 377 | 2025-04-16 17:00:00 | normal | 41.49 | 49.84 | 1.78 | 20.16 | 5.38 | 0.65 |
| 535 | 2025-04-23 07:00:00 | collective | 86.31 | 65.07 | 3.39 | 38.49 | 4.99 | 0.64 |
| 611 | 2025-04-26 11:00:00 | point | 73.29 | 65.48 | 3.45 | 33.61 | 4.98 | 0.64 |
| 498 | 2025-04-21 18:00:00 | normal | 41.05 | 47.90 | 1.54 | 21.74 | 5.18 | 0.64 |
| 533 | 2025-04-23 05:00:00 | collective | 85.20 | 65.47 | 3.45 | 36.53 | 4.96 | 0.64 |
| 655 | 2025-04-28 07:00:00 | multivariate | 84.41 | 62.28 | 3.08 | 36.05 | 4.61 | 0.64 |
| 631 | 2025-04-27 07:00:00 | normal | 97.78 | 61.17 | 2.46 | 41.12 | 4.80 | 0.63 |
| 422 | 2025-04-18 14:00:00 | point | 56.93 | 60.19 | 3.32 | 27.25 | 5.23 | 0.63 |
| 126 | 2025-04-06 06:00:00 | normal | 91.78 | 62.85 | 2.39 | 40.04 | 4.79 | 0.63 |
| 654 | 2025-04-28 06:00:00 | multivariate | 86.52 | 62.28 | 3.02 | 37.43 | 4.74 | 0.63 |
結果の読み取り
異常タイプ別のスコアを比較すると、モデルがどの異常を優先しているか確認できます。多変量異常のスコアが高ければ関係性を利用できています。低い場合は特徴量追加、運転モード別モデル、学習期間の見直しが候補になります。
No.079:異常検知結果を可視化する
実務での意味
現場ではスコアだけでなく、どのセンサーがどう変化したかを時系列で確認できることが重要です。可視化はモデルの説明、一次切り分け、保全記録との照合を支援します。
分析・モデル化の考え方
上段に温度、下段にモデルスコアを描き、検知時刻と真の異常区間を重ねます。しきい値は上位 8% に相当するスコア分位点です。
Pythonで確認する
threshold_iso = df["score_iso"].quantile(0.92)
fig, axes = plt.subplots(2, 1, figsize=(13, 7), sharex=True)
axes[0].plot(df["timestamp"], df["temperature_c"], color="tab:blue", linewidth=1)
actual = df[df["is_anomaly"] == 1]
axes[0].scatter(actual["timestamp"], actual["temperature_c"], s=14,
color="orange", label="確認済み異常", zorder=3)
axes[0].set_title("設備温度と確認済み異常")
axes[0].set_xlabel("日時")
axes[0].set_ylabel("軸受温度(℃)")
axes[0].grid(True, alpha=0.3)
axes[0].legend()
axes[1].plot(df["timestamp"], df["score_iso"], color="tab:purple", linewidth=1)
detected = df[df["pred_iso"] == 1]
axes[1].scatter(detected["timestamp"], detected["score_iso"], s=15,
color="crimson", label="モデル検知", zorder=3)
axes[1].axhline(threshold_iso, color="black", linestyle="--", label="アラートしきい値")
axes[1].set_title("Isolation Forest の異常スコア")
axes[1].set_xlabel("日時")
axes[1].set_ylabel("異常スコア")
axes[1].grid(True, alpha=0.3)
axes[1].legend()
fig.tight_layout()
plt.show()

結果の読み取り
検知点が連続する区間は単発ノイズより優先度が高く、保全記録や生産条件と照合すべき候補です。温度では見えない文脈異常もスコア側に現れるため、複数段の表示が判断を助けます。
No.080:誤検知・見逃しを考慮したアラート設計を行う
実務での意味
モデル精度が高くても、アラートが多すぎれば無視されます。点検 1 件の工数、見逃した場合の停止損失、対応可能件数を明示し、しきい値を業務として決めます。
分析・モデル化の考え方
誤検知を 、見逃しを 、それぞれのコストを とすると、評価コストは
です。ここでは誤検知 1 件を 5,000 円、見逃し 1 時間を 200,000 円と仮定し、スコア分位点ごとのコストを比較します。さらに、単発検知は「注意」、3 時間窓で 2 回以上なら「警告」としてアラート疲れを抑えます。
Pythonで確認する
rows = []
for q in np.arange(0.80, 0.991, 0.01):
threshold = df["score_iso"].quantile(q)
pred = (df["score_iso"] >= threshold).astype(int)
tn, fp, fn, tp = confusion_matrix(df["is_anomaly"], pred).ravel()
rows.append({
"分位点": q, "しきい値": threshold, "TP": tp, "FP": fp, "FN": fn,
"適合率": precision_score(df["is_anomaly"], pred, zero_division=0),
"再現率": recall_score(df["is_anomaly"], pred),
"推定コスト_万円": (fp * 5_000 + fn * 200_000) / 10_000,
})
cost_table = pd.DataFrame(rows)
best = cost_table.loc[cost_table["推定コスト_万円"].idxmin()]
display(cost_table.nsmallest(5, "推定コスト_万円").round(3))
best_pred = (df["score_iso"] >= best["しきい値"]).astype(int)
recent_hits = best_pred.rolling(3, min_periods=1).sum()
df["alert_level"] = np.select(
[recent_hits >= 2, best_pred == 1], ["警告", "注意"], default="正常"
)
print(f"採用候補: 分位点={best['分位点']:.2f}, 推定コスト={best['推定コスト_万円']:.1f}万円")
display(df["alert_level"].value_counts().rename("hours").to_frame())
fig, ax = plt.subplots(figsize=(9, 4))
ax.plot(cost_table["分位点"], cost_table["推定コスト_万円"], marker="o", markersize=3)
ax.scatter(best["分位点"], best["推定コスト_万円"], color="crimson", s=70, label="最小コスト候補")
ax.set_title("アラートしきい値と推定業務コスト")
ax.set_xlabel("異常スコアの分位点(高いほど厳しい)")
ax.set_ylabel("推定コスト(万円)")
ax.grid(True, alpha=0.3)
ax.legend()
fig.tight_layout()
plt.show()
| 分位点 | しきい値 | TP | FP | FN | 適合率 | 再現率 | 推定コスト_万円 | |
|---|---|---|---|---|---|---|---|---|
| 1 | 0.81 | 0.47 | 45 | 92 | 13 | 0.33 | 0.78 | 306.00 |
| 0 | 0.80 | 0.47 | 45 | 99 | 13 | 0.31 | 0.78 | 309.50 |
| 2 | 0.82 | 0.47 | 44 | 86 | 14 | 0.34 | 0.76 | 323.00 |
| 5 | 0.85 | 0.49 | 43 | 65 | 15 | 0.40 | 0.74 | 332.50 |
| 4 | 0.84 | 0.48 | 43 | 73 | 15 | 0.37 | 0.74 | 336.50 |
採用候補: 分位点=0.81, 推定コスト=306.0万円
| hours | |
|---|---|
| alert_level | |
| 正常 | 565 |
| 警告 | 85 |
| 注意 | 70 |

結果の読み取り
見逃しコストを高く置くと、多少の誤検知を許容して再現率を優先するしきい値が選ばれます。ただし金額は仮定なので、設備停止時間、安全影響、点検工数を現場と合意して更新します。連続判定、クールダウン時間、設備重要度による段階化まで決めることで、初めて運用可能なアラートになります。
対象ノックを通して見える実務上の示唆
- 異常の定義が先、手法選定が後:点・文脈・集合・多変量異常では有効な手法が異なります。
- 単純な統計手法は捨てない:Z スコアや IQR は説明性が高く、モデル監視の基準にもなります。
- 運転条件を特徴量に入れる:負荷や品種を考慮しないと、正常な高負荷運転を誤検知します。
- スコアを確率と誤解しない:同一モデル内の順位として扱い、点検可能件数やコストからしきい値を決めます。
- 可視化と対応フローが成果を左右する:検知だけでなく、原因確認・記録・再学習につなげます。
実務導入する場合に必要なこと
- センサー校正、欠測、通信断、保全後の値変化を含むデータ品質管理
- 設備 ID、品種、負荷、段取り、保全履歴を結び付けるデータ基盤
- 設備重要度と故障モードごとの誤検知・見逃しコストの定義
- アラートの通知先、一次確認、エスカレーション、対応期限の明文化
- 現場フィードバックを正解ラベルとして蓄積する仕組み
- データ分布・アラート件数・検知後の成果を定期確認する運用 KPI
段階導入では、まず 1 設備・1 故障モードでシャドー運用し、実際の点検結果を蓄積してから通知を本番化するとリスクを抑えられます。
まとめ
No.071〜No.080 では、異常タイプの整理から統計的検知、教師なし学習、多変量評価、可視化、コストに基づくアラート設計までを確認しました。最良のアルゴリズムを一つ選ぶのではなく、説明しやすいルールと多変量モデルを組み合わせ、現場の対応能力に合わせて運用することが重要です。
法人向けのご相談
数理工房では、製造データの整備、設備異常検知の PoC、既存保全フローへの組み込み、現場向け研修まで、課題とデータ成熟度に合わせてご支援します。
📩 お問い合わせ: surikobo.co.jp/contact
まずはお気軽にご相談ください。