0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「エラー率はターンが進むほど下がる」を検定したら、下がっていたのはセッションの生存率だった

0
Posted at

3行で

  • セッションログ 9,964 件のツール結果をターン位置で分けたら、エラー率が 5.8% → 1.6% ときれいに単調減少した。「エージェントは会話が進むほど安定する」という結論が書けそうだった。
  • **書けなかった。**最後のバケットに寄与しているセッションは 333 本中 14 本しかなく、その 68.5% はうち 3 本分だった。
  • 同じセッションの中で前半と後半を比べると、後半が低い 26 本 / 高い 20 本、符号検定 p = 0.46。下がっているのはセッション内のエラー率ではなく、エラーの多いセッションが早く終わっているだけだった。

きれいなグラフが出たとき

エージェントの挙動を測っていて、「ターンが進むにつれてエラー率がどう変わるか」を見た。結果がこれ。

① 素朴なバケット(全セッションをプールし、ターン位置で分ける)
      1-10:  134/2329   =   5.8%
     11-30:   57/2306   =   2.5%
     31-60:   28/1360   =   2.1%
    61-100:   16/771    =   2.1%
      101+:   51/3198   =   1.6%

単調減少。サンプル数もそれなりにある(一番少ないバケットで 771 件)。「序盤は探索的なので失敗が多く、文脈が溜まると安定する」という説明もすぐ思いつく。

「長い会話ほどエージェントは劣化する」という通説とは逆の結果でもあるので、そこそこ面白い。このまま書けば記事になる。

バケットごとに、何本のセッションが寄与しているかを数えるまでは、そう思っていた。

分母に何が入っているか

      1-10:  134/2329   =   5.8%   寄与セッション 333
     11-30:   57/2306   =   2.5%   寄与セッション 175
     31-60:   28/1360   =   2.1%   寄与セッション 68
    61-100:   16/771    =   2.1%   寄与セッション 24
      101+:   51/3198   =   1.6%   寄与セッション 14

333 → 175 → 68 → 24 → 14

一番右のバケットは 3,198 件と、一番多くのデータを持っている。だがそれは 14 本のセッションから出ている。しかもそのうち 68.5% は上位 3 本(ツール結果 927 件・839 件・671 件の3本)が出したものだった。

つまりこの 1.6% は「101 手目以降のエラー率」ではなく、**「特定の 3 本のセッションのエラー率」**だ。

そして左右のバケットは、そもそも違うセッションの集合を見ている。左端は 333 本全部、右端は 14 本。同じ母集団の時間変化ではない。

なぜ左が高いのか

バケット 1-10 が 5.8% と高いのは、「序盤だから」なのか。

セッションの長さ別に、そのセッション全体のエラー率を見るとこうなる。

② セッションの長さ別に、そのセッション『全体』のエラー率を見る
      1-10手   158本  平均 10.92%  中央値  0.00%
     11-20手    68本  平均  4.00%  中央値  0.00%
     21-40手    54本  平均  1.17%  中央値  0.00%
    41-100手    39本  平均  3.48%  中央値  2.86%
      101+手    14本  平均  1.59%  中央値  1.50%

10 手以内で終わったセッションは、全体を通してエラー率 10.92%。

そしてこの 158 本は、バケット 1-10 にしか現れない。11 手目が存在しないからだ。

つまり①の勾配は、こう読める。

  • バケット 1-10 には、エラー率の高い短命なセッション 158 本が丸ごと入っている。
  • 右のバケットに行くほど、それらは脱落していく。
  • 残るのは、最後まで走り切ったセッションだけ。

エラー率が下がっているのではなく、エラー率の低いセッションだけが残っている。 生存者バイアスそのものだった。

(平均 10.92% に対して中央値 0.00% なのは、短いセッションの大半はエラー 0 で、一部が「1手打って失敗して終わり」= 100% になっているため。分母が小さいと比率は跳ねる。)

同じセッションの中で比べる

正しい問いは「ターン位置とエラー率に関係があるか」ではなく、**「同じセッションの中で、後半のほうがエラー率が低いか」**だ。セッションをまたいで比べる限り、脱落の影響は消えない。

40 手以上のセッション 54 本について、前半 20 手と 21 手目以降をセッションごとに対応させて比べた。

③ 同じセッションの中で前半20手と後半を比べる(40手以上の 54 本)
   プールすると  前半 3.98%  後半 2.08%
   対応をとると  前半 中央値 5.00%  後半 中央値 1.78%
   後半が低い 26 / 高い 20 / 同じ 8   符号検定 p = 0.4614

ここが面白いところで、プールすると効果があるように見える(3.98% → 2.08% でほぼ半減)。中央値で見ても 5.00% → 1.78% で下がっている。

だがセッション 1 本を 1 票として数えると、26 対 20。8 本は変わらず。符号検定で p = 0.46。偶然と区別がつかない。

プールが効果を作っている理由も同じで、手数の多いセッションの後半が、後半側の分母を支配している。①でやったことを、規模を小さくしてもう一度やっているにすぎない。

中央値の差(5.00% → 1.78%)も見かけほど強くない。前半は 20 手しかないので、取りうる値が 0%, 5%, 10%… と 5% 刻みになる。「エラー 1 回」で 5% になる。後半は手数が多いぶん細かい値を取る。粒度の違う2つの中央値を比べている

結局、何が言えるのか

言えるのは、これだけ。

  • セッション内で、後半のエラー率が下がるとは言えない。(p = 0.46)
  • 短いセッションはエラー率が高い。(10 手以内で平均 10.92%、101 手以上で 1.59%)
  • ただしこれも因果ではない。失敗したから短く終わったのか、短く終わる用件だから失敗が目立つのかは、この計測では分けられない。

「長い会話でエージェントが劣化する/しない」という問いに対しては、このデータでは答えが出ない。それが正直な結論になる。

サブエージェントも同じ罠を持っている

同じコーパスで、もうひとつ分母の問題があった。

assistant 発話 18,462 / サブエージェント 5,373 = 29.1%
ツール結果   9,841 / サブエージェント 3,093 = 31.4%
  エラー率: 親 3.48%  サブ 1.45%

ツール結果の 31.4% はサブエージェントが出したもので、そのエラー率は親の 4 割強しかない。

サブエージェントは、仕様が固まったタスクを受け取って、それだけをやって終わる。探索がない。だからエラーが少ない。

これを混ぜたまま「全体のエラー率は 2.9%」と言うと、性質の違う 2 つの母集団を 1 つの数字にしていることになる。しかも混ぜると数字は下がる方向に動く。

短いセッションはエラー率が高く、サブエージェントはエラー率が低い。どちらも「全体」に混ざっている。「全体の何%」という数字を出す前に、その中に何本の、どういう性質のものが入っているかを数える。

確かめるスクリプト

3通りの数え方を並べて出す。①がきれいに見えて③で消えるなら、それは生存者バイアスだ。

#!/usr/bin/env python3
"""「ターンが進むほどエラー率が下がる」が本当かを、3通りの数え方で確かめる。

    python3 bucket_trap.py [~/.claude/projects]
"""
import sys, os, glob, json, collections, statistics
from math import comb

BUCKETS = [(1, 10), (11, 30), (31, 60), (61, 100), (101, 10 ** 9)]


def label(lo, hi):
    return f"{lo}-{hi}" if hi < 10 ** 9 else f"{lo}+"


def sessions(root):
    """1ファイル = 1つの流れ。ツール結果の成否を出た順に並べる。"""
    out = {}
    for path in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
        seq = []
        with open(path, encoding="utf-8", errors="replace") as fh:
            for line in fh:
                try:
                    ev = json.loads(line)
                except ValueError:
                    continue
                content = (ev.get("message") or {}).get("content")
                if not isinstance(content, list):
                    continue
                for blk in content:
                    if isinstance(blk, dict) and blk.get("type") == "tool_result":
                        seq.append(bool(blk.get("is_error")))
        if seq:
            out[path] = seq
    return out


def sign_test(pairs):
    dec = sum(1 for a, b in pairs if b < a)
    inc = sum(1 for a, b in pairs if b > a)
    m, k = dec + inc, min(dec, inc)
    p = sum(comb(m, i) for i in range(k + 1)) / 2 ** m * 2 if m else 1.0
    return dec, inc, len(pairs) - dec - inc, min(p, 1.0)


def main():
    root = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else "~/.claude/projects")
    S = sessions(root)
    if not S:
        print("ツール結果が見つからない。パスを確認する。")
        return 1
    seqs = list(S.values())
    print(f"セッション {len(seqs)} 本 / ツール結果 {sum(len(v) for v in seqs):,}\n")

    # ① 素朴なバケット。全セッションを混ぜて、ターン位置で分ける。
    print("① 素朴なバケット(全セッションをプールし、ターン位置で分ける)")
    tot = collections.Counter()
    err = collections.Counter()
    contrib = collections.Counter()
    for seq in seqs:
        for i, e in enumerate(seq, 1):
            for lo, hi in BUCKETS:
                if lo <= i <= hi:
                    tot[(lo, hi)] += 1
                    err[(lo, hi)] += e
                    break
        for lo, hi in BUCKETS:
            if len(seq) >= lo:
                contrib[(lo, hi)] += 1
    for lo, hi in BUCKETS:
        k = (lo, hi)
        if tot[k]:
            print(f"   {label(lo, hi):>7}: {err[k]:>4}/{tot[k]:<6} = {err[k] / tot[k] * 100:>5.1f}%"
                  f"   寄与セッション {contrib[k]}")
    print("   → 右のバケットほど、寄与しているセッションが減っていく。")

    # ② セッションの長さと、そのセッション全体のエラー率
    print("\n② セッションの長さ別に、そのセッション『全体』のエラー率を見る")
    for lo, hi in [(1, 10), (11, 20), (21, 40), (41, 100), (101, 10 ** 9)]:
        g = [sum(v) / len(v) for v in seqs if lo <= len(v) <= hi]
        if g:
            print(f"   {label(lo, hi):>7}{len(g):>4}本  平均 {statistics.mean(g) * 100:>5.2f}%"
                  f"  中央値 {statistics.median(g) * 100:>5.2f}%")
    print("   → 短いセッションほどエラー率が高い。そして短いセッションは")
    print("      左のバケットにしか現れない。①の勾配はこれで説明がつく。")

    # ③ 同じセッションの中で、前半と後半を比べる(対応あり)
    HALF = 20
    long_ = [v for v in seqs if len(v) >= HALF * 2]
    if long_:
        pairs = [(sum(v[:HALF]) / HALF, sum(v[HALF:]) / (len(v) - HALF)) for v in long_]
        dec, inc, tie, p = sign_test(pairs)
        E = [a for a, _ in pairs]
        L = [b for _, b in pairs]
        pe = sum(sum(v[:HALF]) for v in long_) / (HALF * len(long_))
        pl = (sum(sum(v[HALF:]) for v in long_)
              / sum(len(v) - HALF for v in long_))
        print(f"\n③ 同じセッションの中で前半{HALF}手と後半を比べる"
              f"{HALF * 2}手以上の {len(long_)} 本)")
        print(f"   プールすると  前半 {pe * 100:.2f}%  後半 {pl * 100:.2f}%")
        print(f"   対応をとると  前半 中央値 {statistics.median(E) * 100:.2f}%"
              f"  後半 中央値 {statistics.median(L) * 100:.2f}%")
        print(f"   後半が低い {dec} / 高い {inc} / 同じ {tie}   符号検定 p = {p:.4f}")
        print("   → p が大きければ、セッションの中で下がっているとは言えない。")
    return 0


if __name__ == "__main__":
    sys.exit(main())

この記事の限界

  • 私1人のコーパスで、セッション 333 本・ツール結果 9,964 件。40 手以上のセッションは 54 本しかない。検定の力が弱く、p = 0.46 は「効果が無い」の証明ではない。「このデータでは言えない」までしか言えない。
  • **符号検定は効果量を見ない。**方向だけを数えている。効果があっても小さければ検出しにくい。
  • セッションの区切りは1ファイル単位にした。再開や分岐で1つの作業が複数ファイルに分かれると、そこは正しく数えられていない。
  • 「エラー」の定義は is_error フラグそのまま。別の記事で書いたとおり、このフラグは承認ゲートも「出力はあるのに exit≠0」も同じ 1 として数えるので、意味の異なるものが混ざっている。

まとめ

きれいな単調減少が出たら、各点に何本のセッションが寄与しているかを数える

  • 減っていたのはエラー率ではなく、生き残っているセッションの本数だった(333 → 14)。
  • 一番データの多いバケットが、一番少ないセッションから出ていた。しかもその 68.5% は 3 本分。
  • 同じセッションの中で対応をとると、26 対 20、p = 0.46。効果は消えた。
  • プールした比較は、手数の多い個体に支配される。個体を 1 票として数える。

グラフの縦軸を疑う前に、横軸の各点の分母を見る。


関連

「規約を入れたらエラーが減った」も、同じ形で崩れます。入れた前後で母集団が入れ替わっていれば、効果はいくらでも出せます。

測定結果をどう読むかと、規約が効かなくなる4つのパターンをまとめた本があります。

AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)

第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?