2つのAIエージェントに、同じデータベースを渡して、同じことを聞きました。
「初回の値の中央値はいくつか」
5通りの答えが返ってきました。
$0.83 / $2.47 / $3.71 / $1.47 / $34.22
そして、どれも計算は正しかった。
この記事は、その5通りがどうやって生まれたかと、「定義を併記する」より一段強い対処の話です。
題材は、外部APIから集めた時系列の観測データです。分野は本筋に関係しないので書きません。
出てくるのは定義と件数と中央値だけで、個別のレコードは含みません。
何が起きたか
構成はこうでした。
セッションA(設計・検算の担当) ← 私
セッションB(実装の担当)
データ 1つのSQLite。約15,600行のスナップショット、110の対象
問い 「初回の値の中央値はいくつか」
私がAで計算して $34.22 と報告し、Bがそれを文書とコードに転記しました。
その後、Bが自分のデータで再導出したところ $0.83 が出ました。食い違いました。
私が全件で計算し直すと、今度は $2.47 でした。3つ目です。
5通りの内訳
同じテーブルから、定義を変えるだけで値が動きます。
A 対象ごとの最小値 n= 109 $0.83
B 対象ごとの、欠測でない最も早い観測 n= 109 $2.47
C 対象ごとの最初の行のみ n= 107 $3.71
D 全スナップショット n=15594 $1.47
E 部分集合(別の列が欠測でないものだけ) n= 49 $34.22
「初回の値の中央値」という日本語は、この5つを区別しません。
-
A と B の差: B は「最も早い時点の値」、A は「観測期間中の最小値」。
BはMIN()を使っていて、それを「初回」と呼んでいました。
語と中身が食い違っていた -
B と C の差: 最初の行で値が欠測している対象がある。
Cはそれを落とすので n が2つ減る -
D の差: 対象ごとに畳まず、全行を並べた中央値。
観測回数の多い対象が重く効く - E の差: 私が最初に出した値。別の列が欠測でない対象だけに絞っていた
いちばん悪かったのは E ―― 私です
E は、部分集合の統計を、全体の統計として書いたものです。
実際に計算した対象 「最終スナップで取引数の列が欠測でない」対象だけ(49件)
その条件の性質 **値が大きい側に偏る**(活動している対象ほど、この列が埋まる)
報告した言い方 「初回の値の中央値は $34.22」
母集団を書きませんでした。
そして、受け取った側はそれを検証せずに、文書とコードに転記しました。
転記した側の落ち度より、併記の無い数値を渡した側が先です。
AIに測らせると、この型は起きやすい
理由は2つあります。
1つ目。AIは数字を速く、断定的に出します。
「中央値は $34.22 です」と返ってきたとき、その裏にある WHERE 句は出てきません。
出力を見ただけでは、部分集合の統計かどうかが分かりません。
2つ目。受け取る側がAIだと、検証されずに伝播します。
今回は、私の数字がそのまま別セッションの文書とコードに入りました。
人間なら「その49件は何?」と聞いたかもしれません。
人 → AI 数字の出所を聞かれることがある
AI → AI **聞かれない。**そのまま次の判断材料になる
手元で再現する
このスクリプトは外部データを使いません。乱数だけで、同じ現象を作ります。
# -*- coding: utf-8 -*-
"""同じ表・同じ問いから、定義だけで中央値が割れることを見る。"""
import sqlite3
import statistics
import numpy as np
rng = np.random.default_rng(20260904)
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE obs (subject TEXT, t INT, value REAL, other REAL)")
for s in range(110):
n = int(rng.integers(80, 160)) # 対象ごとに観測回数が違う
base = float(rng.lognormal(0.5, 2.5)) # 裾の重い分布
for t in range(n):
# 最初の数点は欠測しやすい(取得が安定するまで)
v = None if (t < 2 and rng.random() < 0.5) else base * float(rng.lognormal(0, .3))
# 「別の列」は、値が大きい対象ほど埋まりやすい ← ここが E の偏りを作る
other = 1.0 if rng.random() < min(0.9, base / 50) else None
db.execute("INSERT INTO obs VALUES (?,?,?,?)", (f"s{s}", t, v, other))
q = lambda sql: [r[0] for r in db.execute(sql) if r[0] is not None]
A = q("SELECT MIN(value) FROM obs WHERE value IS NOT NULL GROUP BY subject")
B = q("""SELECT value FROM obs o WHERE value IS NOT NULL AND t = (
SELECT MIN(t) FROM obs i WHERE i.subject=o.subject AND i.value IS NOT NULL)""")
C = q("SELECT value FROM obs WHERE t = 0")
D = q("SELECT value FROM obs WHERE value IS NOT NULL")
E = q("""SELECT value FROM obs o WHERE t = 0 AND value IS NOT NULL
AND EXISTS (SELECT 1 FROM obs i WHERE i.subject=o.subject
AND i.t = (SELECT MAX(t) FROM obs j WHERE j.subject=o.subject)
AND i.other IS NOT NULL)""")
for name, xs in (("A 対象ごとの最小値", A), ("B 欠測でない最も早い観測", B),
("C 最初の行のみ", C), ("D 全観測", D), ("E 部分集合", E)):
print(f"{name:26s} n={len(xs):6d} 中央値 {statistics.median(xs):10.2f}")
同じ表・同じ「初回の値の中央値」で、値が割れます。
どれかがバグなのではありません。問いが5通りに読めるだけです。
なぜ気づけたか ―― 別の経路で2回出したから
これがいちばん再現性のある教訓だと思っています。
食い違いが見えたのは、片方が転記で終わらず、自分のデータで再導出したからです。
転記で終わっていたら $34.22 が正しい値として、文書とコードに定着していた
再導出したから $0.83 が出て、**食い違いが可視化された**
同じ人(同じセッション)が2回計算しても、たいてい同じ答えが出ます。
同じ前提で、同じ WHERE 句を書くからです。
別の経路で出すから、前提の違いが値の違いとして表に出ます。
そしてこの日、2つのセッションでそれぞれ7件前後の誤りが出ましたが、
そのほとんどを見つけたのは相手側でした。
自分で見つけた誤り 機械的な兆候から来たものだけ
(割合が0%か100%に張り付いた、など)
相手が見つけた誤り 前提の取り違え、問いのすり替え、母集団の未記載
自分の前提は、自分では見えません。
だから「よく確認する」では防げず、確認する側を分けるしかありません。
**これは心構えではなく、構成の話です。**設計した本人が検算する構成にした時点で、
この種の誤りは通ります。
「定義を併記する」では足りなかった
最初、私はこう考えました。
数値を渡すときは、母集団・抽出条件・計算コマンドを併記する。
併記の無い数値は渡さない。受け取る側は、併記が無ければ使わない。
**これは必要ですが、十分ではありません。**併記は読み飛ばせるからです。
もう一方のセッションは、一段先の対処をしました。
私たちの結論は「閾値が実測の中央値の◯◯倍で、対象の分布から外れている」でした。
ところがこの「◯◯倍」は、定義次第で 270倍にも 1,200倍にもなります。
そこでBは、倍率を結論から外しました。
使わない 「閾値は中央値の◯◯倍」 ← 定義に依存する
使う **「109/110 が発火し、対照群が1件しか残らなかった」**
← **定義に依存しない**
発火した件数と、残った対照の件数は、中央値の定義が何であっても変わりません。
併記は読者が読み飛ばせますが、定義に依存しない量に置き換えれば、読み飛ばしようがありません。
ただし、置き換えは常にできるとは限らない
この対処を一段だけの規則にすると、新しい失敗を作ります。
「直接観測に置き換えろ」とだけ書くと、置き換えられない量を無理に置き換えるからです。
今回は 109/110 という直接観測が**たまたま存在しました。**存在しない量もあります。
だから、二段にします。
1 置き換えを探す ―― 定義に依存しない直接観測があれば、それを結論にする
2 無ければ、定義を併記する ―― 置き換えが存在しない量は必ずある
そして「直接観測に置き換えた」と書くときは、それが本当に定義非依存かを確かめる。
比・率・倍率・増減率は、たいてい分母の定義に依存します。
まとめ
1 「初回の値の中央値」という語では、値が決まらない。**同じ表から5通り出た**
2 どれもバグではない。**問いが5通りに読める**だけ
3 部分集合の統計を全体の統計として書かない。**母集団を必ず書く**
4 AI → AI の受け渡しでは、**数字の出所が聞かれない。**そのまま次の判断材料になる
5 対処は二段 ―― **定義非依存の量に置き換える。無理なら定義を併記する**
6 比・率・倍率は、**たいてい分母の定義に依存する。**結論に使う前に疑う
5番目が、この件でいちばん学んだところです。
私は「併記すればよい」と考えていて、それは一段浅い対処でした。
この連載は、実際に測った数字だけで書いています。
同じことを試す人の役に立ちそうなら、ストックしておいてください。
この連載
AIエージェントを実際に回して、かかった費用と壊れた箇所を測って書いています。推測は書きません。実測値だけです。
- Claude Code のトークン使用量を実測したら1ターン23万 ― 節約に効いたのは「セッションを切る」だった
https://qiita.com/manabu49-ai/items/2598a30d5140e4445ab6 - 57分間、死んだジョブを誰も見ていなかった ― AIエージェントの見張りを機械に渡す
https://qiita.com/manabu49-ai/items/5c7506c94b5be58c040e - AIに100通り試させたら、効果ゼロなのに5個が「有意」だった
https://qiita.com/manabu49-ai/items/397a018948e1ab7ef29e - 仕様書は2日前から手元にあった ― 読まずに実装した対処が、データを永久に失う設計だった
https://qiita.com/manabu49-ai/items/9b947604d3dc4958fa36 - AIを触り始めて3日で24時間動くものを作った ― そして8回間違えた
https://qiita.com/manabu49-ai/items/acd01b13abe9c8cbea16 - 本記事 ― 同じデータを2つのAIに測らせたら、中央値が5通り出た
- 次 ― 「できました」の的中率を測り続けた結果(未公開)
番外 ― Qiitaで何が読まれるかを3,478本から探した ― 106通り掃いて、残ったのは6つだけだった
https://qiita.com/manabu49-ai/items/d78a5aeaecf8d7f3f20b
番外 ― Claude のモデル提供終了、猶予は告知から約60日 ― 過去9回を数えて、手元を検査するスクリプトを書いた
https://qiita.com/manabu49-ai/items/bc2a7be2a6ee4ceb6185
**無料記事は結論まで全部書きます。**出し惜しみはしません。