Featured image of post コードディスパッチャー【Drift Watch】「生きているかどうかと、うまくやれているかは、別の盤です」〜静かな劣化は、測っていなければ気づけない〜

コードディスパッチャー【Drift Watch】「生きているかどうかと、うまくやれているかは、別の盤です」〜静かな劣化は、測っていなければ気づけない〜

設備点検日報の異常検知は動いてもエラーも出ません。先月、見逃しが1件だけ見つかりました。フラグ率をEWMAで継続監視するDrift Watchで、静かな品質劣化を可視化します。

第1幕: 持ち込み ── 困っている、とは言わない相談

指令所の空調が、低い音を保ったまま夜を送っていた。表示盤の前でサカキが灯りの列を目で追っている。私(トウヤ)は当直の引き継ぎを終えたばかりで、記録帳の白いページを開いたところだった。

ドアが開く。戸口に立っていたのはイマイさん一人だった。会釈のあとに続いた最初の一言が、これまで記録してきたどの来訪とも異なる響きを持っていた。

「相談というより、確認したいことがあって来ました」

私は「来訪」の欄にペンを走らせながら、この言葉遣いをどう記せばいいのか迷った。困っている人特有の性急さは無い。かといって、世間話に立ち寄ったふうでもない。困っている、と一度も言わずに始まる相談は、これまで無かった。

サカキが顎で椅子を勧める。イマイさんは腰を下ろすと、鞄から一枚の報告書を取り出し、テーブルの上へ静かに置いた。指先が、その端を一度だけなぞる。

「どのようなことでしょうか」

サカキが訊く。イマイさんは、姿勢を正してから話し始めた。

「私の部署では、複数の工場から届く設備の点検日報を、システムに読ませています。異音や異臭、振動といった異常の兆候が書かれていないかを拾わせて、必要なものだけを『経過観察』として上げる仕組みです。以前は全件を人手で確認していましたが、今はその手前をシステムに任せています」

イマイさんは、一言ずつ確かめるように話した。日に百件近く届く日報を、以前は保全チームの数名で手分けして読んでいたこと。人手が足りず、読み落としが出始めていたこと。今の仕組みに切り替えてから、その負担がほとんど無くなったこと。誇るでも謝るでもない、淡々とした口調だった。

私はその内容を欄に書き入れながら、これまで扱ってきたどの路線とも同じ骨格だと感じていた。無人運転の列車が、決まった手順で走っている——ここまでは、いつもの持ち込みと変わらない。

「先月、うちのベテランの作業員が、たまたま気づいたんです」

イマイさんの声の調子が、そこで少し変わった。

「先週の日報に、『経過観察が必要。軸受け付近に異音、要継続確認』と書かれていました。ですが、システムの処理では『問題なし』として扱われていました。作業員が定期巡回の途中で、たまたま『あの記述、まだ確認してませんよね』と口にしなければ、誰も気づかないままだったと思います」

私は記録のペンを止め、今の一言を頭の中でもう一度なぞった。止まってもいないし、エラーも出ていない仕組みの中で、そんなことが起きるのか。これまで記録してきた相談は、いずれも「動かなくなった」「変な値が返ってきた」という、目に見える不調から始まっていた。今回は違う。動いてはいる。エラーも出ていない。それでも、拾うべきものを拾えていなかった。

「見つかったのは、その一件だけです」

イマイさんの指が、報告書の端で止まった。

「他にもあるのか、これから増えるのか、まったく分かりません。上には『これは氷山の一角なのか、たまたまなのか』と訊かれましたが、答えられませんでした」

私はその言葉をそのまま記録の欄に書き取った。1件という数字だけを渡されて、それが何を意味するのか判断できない相談は、これまでのどれとも違っていた。多いのか少ないのか、増えているのか減っているのか、当のイマイさん自身にも言えない。

サカキが、報告書には目を通さないまま訊いた。

「その点検日報を処理する仕組み、止まったことはありますか。エラーが出たことは」

「一度もありません。ずっと動いています」

「分かりました。今夜のところは、まだ何も判定しません」

サカキはそう言うと、席を立ち、運行表示盤のほうへ歩いていった。イマイさんは、その背中を目で追った。何を見に行ったのか、まだ分からない様子だった。

第2幕: 照合 ── 別の盤を、見ていなかった

サカキが示したのは、指令所の壁一面を占める運行表示盤だった。無数の路線が縦横に走り、それぞれの脇に小さな灯りが点滅している。イマイさんの点検日報システムに相当する系統の表示も、その一枚の中に埋もれるように映っていた。

「これが、今の運行表示盤です。あなたの仕組みの、応答が返っているか、エラーが出ていないかを見る盤です」

灯りは、すべて平常を示す色だった。エラー率はゼロ。応答時間も、いつもの範囲に収まっている。緑色の点滅が、規則正しい呼吸のように続いていた。イマイさんが、それをしばらく見つめてから言った。

「これを見る限りは、正常なんですよね」

「盤の上は、そう見えます」

サカキの返事は、肯定でも否定でもなかった。イマイさんが、続きを待つように黙る。

「動いているかどうかと、判定が正しく機能しているかどうかは、別のことです。今の盤が見ているのは、前者だけです」

「生きているかどうかと、うまくやれているかは、別の盤です」

イマイさんが少し間を置いた。「……別の盤、ですか」

私の独白。動いているかどうかを見る目と、うまくやれているかどうかを見る目は、同じではなかった。これまで指令所で扱ってきた盤は、ずっと前者だった。止まったこと、遅れたこと、エラーが出たこと——形のある異変を映す盤だった。今回、サカキが口にしたのは、その隣にあるはずの、もう一枚の盤についてだった。

「この盤に映っていないからといって、何も起きていないとは限りません」

サカキが、表示盤全体を一度見渡してから続けた。

「映っていないものは、無いのではなく、見えていないだけです」

イマイさんは、その言葉を頭の中で確かめるように、少し目を伏せた。私はその一言を、そのまま記録に書き取った。これまで指令所の表示盤が繰り返し映してきたのは、いつも「見えている」側の異変だった。今夜初めて、「見えていない」側の存在そのものが話題になった。

サカキが、イマイさんのほうへ向き直った。

「先月の一件だけでは、増えているかどうか判断できません。ただ、これまでの点検日報は、記録として残っていますね」

「はい。ここ数か月分は、システムのログとして残っています」

「それを、今夜の材料にしていいですか」

イマイさんが頷いた。サカキが、過去8週分の日報データを呼び出した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 週ごとの点検日報バッチ。1件あたりのflag_rate(「異常あり」判定の比率)は
# コメント欄の目安値。実測値はコード側で計算する。
HISTORICAL_BATCHES = [
    InspectionBatch(period_label="week-1", reports=(...,)),  # flag_rate ≈ 0.13(平常時の基準値)
    InspectionBatch(period_label="week-2", reports=(...,)),  # flag_rate ≈ 0.12
    InspectionBatch(period_label="week-3", reports=(...,)),  # flag_rate ≈ 0.11
    InspectionBatch(period_label="week-4", reports=(
        "5号機、経過観察が必要。軸受け付近に異音、要継続確認。",
        # ...他の日報...
    )),  # flag_rate ≈ 0.10 ← 先月、ベテラン作業員が偶然の一件を見つけた週
    InspectionBatch(period_label="week-5", reports=(...,)),  # flag_rate ≈ 0.09
    InspectionBatch(period_label="week-6", reports=(...,)),  # flag_rate ≈ 0.07
    InspectionBatch(period_label="week-7", reports=(...,)),  # flag_rate ≈ 0.06
    InspectionBatch(period_label="week-8", reports=(...,)),  # flag_rate ≈ 0.05 ← 今夜
]

「week-4に、あの一件が入っています」

サカキが、週の一つを指で示した。イマイさんが、報告書の日付と照らし合わせて頷く。「……はい、この週です」

「まず、今の仕組みのまま、8週分を流してみます」

1
2
3
for batch in HISTORICAL_BATCHES:
    results = run_inspection_batch(batch, client)
    # ここで何も記録されない。8週分流しても、傾きに気づく手段が無い

処理は、8週分すべてを問題なく終えた。エラーは一つも出ない。だが、終わったあとに残るものは何もなかった。判定結果はその場で返るだけで、どこにも蓄積されない。8週分の傾向を、あとから見返す手段が、今の仕組みにはどこにも無かった。

「これが、今の仕組みの限界です。何が起きていても、あとからは分かりません」

サカキが、続けて別のコードを示した。今度は、判定結果を継続して測る仕組みを通したものだった。「閾値は、まだ仮の値です。傾きが見えるかどうかを、先に確かめます」。

1
2
3
4
5
6
sink = RecordingSink()
monitor = QualityDriftMonitor(sink=sink, alpha=0.3, threshold=0.08)

for batch in HISTORICAL_BATCHES:
    flag_rate = compute_flag_rate(batch, client)
    monitor.observe(flag_rate, batch.period_label)

出力が、記録の末尾から順に並んだ。

1
2
3
week-6  flag_rate=0.07  ewma=0.096  is_alert=False
week-7  flag_rate=0.06  ewma=0.085  is_alert=False
week-8  flag_rate=0.05  ewma=0.075  is_alert=True   ← ここで初めて記録に警報が立つ

イマイさんが、その数字の並びを目で追った。week-1から緩やかに下がり続け、week-8で初めて「異常あり」の側に倒れている。

私はその数字を、記録帳の余白に線として引き直してみた。八週ぶんの点を横一列に置き、生のフラグ率の高さと、それを均したEWMAの高さを、同じ軸の上に重ねる。閾値の高さも、途切れない一本の線で添えておく。

week-1からweek-7までは生のフラグ率が緩やかに下がり続けてもEWMA(指数加重移動平均)は閾値0.08を上回ったままで、week-8になって初めてEWMA≈0.075が閾値を下回りis_alert=Trueと記録される、8週分の推移を横一列に並べた運行図インフォグラフィック

私の独白。1件だけを見ていたときは、たまたまとしか言えなかった。8週分を並べ直すと、それはたまたまではなく、ゆっくり下がり続けていた線の、途中の1点だった。

「じゃあ、あの一件は、たまたまじゃなかったんですか」

イマイさんの声が、少しだけ低くなった。

「増えていたことは、これで言えます。ただ、なぜ増えたのかは、今夜はまだ分かりません」

サカキの返事は、そこで止まった。原因には踏み込まない。イマイさんが、小さく息をついた。安堵ではなく、事実を受け止める間だった。

第3幕: 指令の一手 ── 止めずに、知らせる盤を作る

「フラグ率を継続して見張る、という考え方には名前があります」

サカキが、テーブルに一枚のメモを置いた。走り書きの図が一つ、右肩下がりの折れ線として描かれている。

Prediction Drift——出力側のドリフト、と呼ばれます。入力側の変化を見るData Driftとは別の考え方で、正解が手元に無い、あるいは遅れて届くときの、性能の代理として使われます」

「正解が無い、というのは」

イマイさんが訊く。

「今回でいえば、『この日報は本当に異常だったのか』という正解は、時間が経たないと分かりません。もっと言えば、分からないままのことも多い。それでも、『異常ありと判定される比率』そのものの動きは、その場で測れます。Evidently AIやArize AIといった、この分野を扱う会社のドキュメントでは、正解ラベルが遅れて届く場面での代理指標として、この考え方が明示的に推奨されています」

イマイさんが、初めてメモを取った。手が止まる。

「比率が下がったのが、見逃しのせいだとは、まだ言い切れませんよね。単純に、その週の設備が静かだった、というだけかもしれない」

「はい。今の測り方だけでは、そこは区別できません。工場の稼働状況や点検の対象が急に変わっていないという前提で、傾きを見ています。その前提が崩れていないかは、こちらでは分かりません」

「以前も、こういう仕組みを組んだことがあるんですか」

「似た形のものはあります」

サカキは、言葉を選ぶように一拍置いてから続けた。

「原資が尽きる前に止める仕組みを組んだことがあります。あれは、閾値に届く前に、処理そのものを止める仕組みでした。今回は、止めません」

「止めない、というのは」

「閾値を割ったことを、記録に残すだけです。処理は、そのまま続けます」

サカキが、その理由を続けた。

「システムが自動で直せないときは、人が調べて、判断する。そういう考え方があります。守るために止める仕組みと、知らせて人に委ねる仕組みは、目的が違います」

イマイさんが、少し不安げな顔をした。「記録に残るだけでは、また今回みたいに、誰も見ないまま終わるんじゃないですか」

「今夜組んだのは、傾きを記録に残すところまでです。誰が、どんな頻度でそれを確認するかは、今夜の範囲には入っていません。記録が残っていても、見る人がいなければ、今回と同じことが起きます」

私はその言い分を書き取りながら、以前の相談を思い出していた。あのときは、使い切ってしまえば取り返しがつかない原資が相手だった。原資は、一度尽きれば元には戻らない。今回は違う。判定そのものは、間違っていても、その一件だけなら取り返しがつかないわけではない。取り返しがつかなくなるのは、気づかれないまま何週も続いたときだ——止める仕組みと知らせる仕組みが分かれる理由は、そこにあった。

サカキが、コードを示した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from dataclasses import dataclass
from typing import Protocol


@dataclass(frozen=True)
class QualityObservation:
    period_label: str
    flag_rate: float
    ewma: float
    is_alert: bool


class MetricsSink(Protocol):
    def record(self, observation: QualityObservation) -> None: ...


class QualityDriftMonitor:
    def __init__(self, sink: MetricsSink, alpha: float, threshold: float) -> None:
        self._sink = sink
        self._alpha = alpha
        self._threshold = threshold
        self._ewma: float | None = None

    def observe(self, flag_rate: float, period_label: str) -> None:
        self._ewma = (
            flag_rate
            if self._ewma is None
            else self._alpha * flag_rate + (1 - self._alpha) * self._ewma
        )
        self._sink.record(
            QualityObservation(
                period_label=period_label,
                flag_rate=flag_rate,
                ewma=self._ewma,
                is_alert=self._ewma < self._threshold,
            )
        )

「値そのものと、傾向と、警報かどうかを、ひとつの記録にまとめます」

イマイさんが、コードを覗き込んだ。「盤に映すものが、増えるんですね」

「はい。ただし、映すだけです。止めません」

「記録の送り先は、一つだけでいいんですか」

イマイさんの質問に、サカキが頷いた。

「値を渡す先と、警報を知らせる先を、分けませんでした。以前の仕組みは、数字を一つだけ渡すだけの、単純な呼び出しでした。今回は、その値と一緒に、警報かどうかも一緒に運びます」

私は、MetricsSinkという一行を見ながら、その意味を頭の中で言い直した。技術語で言えば——記録の送り先はProtocolという一つの窓口で、そこへ渡す中身が、生の値と傾向値と警報の3つをまとめた器になっている。窓口を増やさず、渡すものを増やした形だった。

「傾向、というのは、このewmaのことですか」

イマイさんが訊く。

「はい。直近の値ほど重く効かせて、傾きを均します」

サカキが、数式を紙に書いた。

1
ewma_t = α × 今回の値 + (1 - α) × 前回までの傾向

「前の値も、少しは効くんですね」

「はい。急な一件だけで慌てないためです。一件の逸脱と、続く傾きを、分けて見られるようにします」

「単純な平均で計算するのと、何が違うんですか」

「単純な平均は、古い値も新しい値も同じ重みで扱います。こちらは、直近の値により大きな重みを置きます。どちらが優れているというより、性質が違います」

サカキは、そこで言葉を止めた。単純な平均より優れている、とは言わなかった。私はその慎重さを、そのまま記録に残した。

observeは、時刻を受け取りません」

サカキが続けた。

「以前、期限で判定する場面では、時計を偽物に差し替えて確かめました。今回は、時計を見ていません。数えるのは、時間ではなく、バッチの回数です」

イマイさんが、「回数、ですか」と聞き返す。

「日報のまとまりが届くたびに、1回として数えます。届く間隔が空いても詰まっても、傾向の測り方は変わりません」

サカキが、最後に閾値の話に移った。

「どこまでの下がり方を許すかは、指令所の側では決められません」

「定時運行率が、どこまでの遅れを許すかを先に決めた数字であるように、これも先に決めていただく数字です」

イマイさんが少し考えた。

「これまでの基準値の、6割程度まで落ちたら知らせてほしいです」

「分かりました」

続けてサカキが配線を示した。

1
2
def build_quality_monitor(sink: MetricsSink) -> QualityDriftMonitor:
    return QualityDriftMonitor(sink=sink, alpha=0.3, threshold=0.08)

「この数字は、値として持たせます。次に見直すときも、書き換えるのはここ一箇所で済みます」

「判定そのものは、変わらないんですよね」

イマイさんの問いに、サカキが頷いた。

「はい。異常あり・なしの判定ロジックは、こちらが決めるものではありません。こちらが用意したのは、その判定が積み重なったときの傾きを見る側だけです」

第4幕: シミュレータと引き渡し ── 安心しきらずに、閉じる

「もう一度、シミュレータを流します」

サカキが同じ8週分のデータを、今度は最初から最後まで通した。テストは6本。私が読み上げる番だった。

「フラグ率はバッチ内の異常判定件数の比率として計算される、通過」 「安定した台本ではEWMAが閾値を割らずアラートが記録されない、通過」 「劣化する台本ではEWMAが閾値を下回った時点でアラート付きの記録がSinkに残る、通過」 「異常判定そのものの結果はBeforeとAfterで全バッチにわたって完全に一致する、通過」 「EWMAは直近の値により大きな重みで更新される、通過」 「閾値を下回った後に持ち直すとアラートは解除された記録として残る、通過」

特に、3本目と6本目を、サカキが噛み砕いた。

「劣化する台本を通すと、途中までは警報が立たず、下がりきったところで初めて立ちます。急に鳴るわけではありません」

「6本目は、逆側の確認です。いったん警報が立ったあと、フラグ率が実際に持ち直す台本を流しています。傾きが戻れば、警報も解除されます。警報が立ちっぱなしにならないことは、これで確かめられます」

「そして、判定結果そのものは、今回の仕組みを組み込んでも変わりません」

イマイさんが訊いた。「監視を付けても、判定は同じままなんですか」

「はい。今回の仕組みは、判定を変える仕組みではありません。判定の傾向を、横で見ているだけです」

私はその一言を書き取りながら、run_inspection_batchの呼び出しを思い返した。監視を配線する前も後も、そこから返ってくるlist[bool]の中身は、一つも変わっていない。変わるのは、その脇にもう一本増えた経路——QualityObservationMetricsSinkへ流れていくかどうか、それだけだった。

監視なしと監視ありの両方でrun_inspection_batch()から返る判定結果list[bool]は同じ経路に合流して一致する一方、監視ありの側だけflag_rateからQualityDriftMonitor.observe()を経てQualityObservation(flag_rate/ewma/is_alert)がMetricsSink.record()へ流れる、もう一本の経路が増えるだけであることを示す構造図

増えたのは、答えそのものではなく、答えの脇に敷かれた記録の線路だった。

サカキが、保証すること、保証しないことを、順番に言葉にした。

「保証するのは、フラグ率の傾きを継続して測り、閾値を割った時点で記録に残すことです。保証しないのは、なぜ品質が落ちたのか、その原因です。今夜できたのは、傾きに気づく仕組みを作るところまでです」

イマイさんが、頷いた。すぐには言葉が出てこない様子だった。

サカキが席を立ち、指令所の奥にある別の端末へ向かった。今夜のシミュレータを流した席とは別の、本番の系統を組む場所だった。キーを叩く音が、しばらく規則正しく続く。画面に、RemoteMetricsSinkという名前が現れる。今夜のシミュレータで使ったものとは、違う名前だった。

私はその文字を目で追って、思わず尋ねた。

「これは、指令所の外に置くものなんですか」

サカキは手を止めないまま、短く答えた。

「ここには、置きません」

それだけだった。私はそれ以上尋ねず、サカキの言葉を短く記録に書き留めた。何のためかは、分からないままだった。

イマイさんが、表示盤をもう一度見た。

「これで、次からは気づけるんですね」

「傾きには、気づけます。ただ、また似たようなことが起きないとは言い切れません」

イマイさんが、小さく頷いた。

「それでも、偶然の目撃だけに頼らずに済むのは、大きいです」

イマイさんは、報告書を鞄にしまい、椅子から立ち上がった。来たときと同じ、静かな足取りで出口へ向かう。ドアの前で一度だけ振り返り、小さく頭を下げてから、指令所を出ていった。

指令所には、また空調の低い音だけが残った。表示盤の灯りは、来たときと同じ速さで流れている。私は記録の最後にもう一行だけ足した。仕組みが手に入ったという事実と、それでも消えない小さな不安の、両方を短く書き添えた一行だった。ペンを置いたあとも、その一行はしばらく私の目の前にあった。


🚦 本日の指令記録(Dispatch Log)

  • 指令の定石(パターン名): Drift Watch(品質の常時監視)── フラグ率をEWMAで継続的に測り、閾値を割ったら記録に残す。止めない・事後観測
  • 申告された症状: 「先月、ベテラン作業員が偶然気づいた、システムが見逃していた異音の記述が1件だけあった。増えているのか、昔からなのか分からない」
  • 今のダイヤの問題: 判定結果の傾向を継続して測る仕組みが無く、死活監視(動いているか・エラーが出ているか)だけでは品質の劣化に気づけない
  • 打った一手: フラグ率(Prediction Drift相当)をEWMAで平滑化し、閾値を割った時点で記録を残すQualityDriftMonitorを導入した。判定結果そのものは変えない
  • 配線した場所: 判定ロジック自体は事業者の指示書が決める。どこまでの下がり方を許すか(threshold)は事業者が数値として決め、組み込みの形は指令所側が用意する
  • 保証しないこと: なぜ品質が落ちたのか、原因までは分からない。傾きに気づく仕組みができただけで、同じことが二度起きない保証にはならない
  • シミュレータ結果: 6本。劣化する台本の途中でアラートが立つこと、判定結果そのものは監視を挟んでも同じままであることを確認した
  • 次の当直への申し送り: 今夜測れたのは、過去8週分の履歴データだけ。本番で常時これを回し続ける仕組みは、まだこれから

「止まっていないから、大丈夫」と言い切れずにいるなら、その言い切れなさごと、持ち込んでください。どこを測ればいいかを、一緒に決めます。

ご相談はこちら

comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource
Hugo で構築されています。
テーマ StackJimmy によって設計されています。