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
Last updated at Posted at 2026-10-04

言語 / Language: 日本語 · English

テストが全部緑でも、門は嘘をつける ―― 画像計測ライブラリで門が見逃したものと、見逃さない門の作り方

画像処理と計測の関数(以下 op)を約 2,200 本持つ自作ライブラリ Fullseye を育てています。op が正しいかを確かめる検査を、ここでは門と呼びます。pytest のテストも、生成物が最新かを見る検査も、CI も、全部「通さないものを通さない」ための門です。

門は 7,000 を超える関数で 1 万 5 千件ほど走り、全部緑です。それでも、門が緑のまま結果が嘘だったことが何度もありました。この記事は、その嘘を 4 つの型に分けて、それぞれ実例と「どう門を作り直したか」を置いたものです。型は Fullseye に固有ではなく、テストを書く人なら誰でも踏みます。

先に結論

型 何が起きるか 作り直し方
門は在るのに、事故の場所で走っていない 開発機の checkout では緑、配布物や Linux では赤 事故の起きる場所(wheel・Linux・CI)で数える
緑なのに中身が空 空の出力・空のリスト・捨てられた判定を「一致」と数える 中身の量を別に数える。門自身を壊して発火を確かめる
全数を回したのに覆っていない 入力 1 枚・パラメータ 1 組で「全部見た」と思う 構造の違う入力を足す。免除台帳が長ければまず門を疑う
自分の仕様と合っているだけ 自分で書いた式で答え合わせをしている 独立な真値(公表値・第 2 実装・変換に対する不変性)を外から持ち込む

用語(先に読むと楽)

  • 門 —— 結果を通すか止めるかの検査。テスト関数・生成物の差分検査・CI のジョブ。
  • 真値 —— 門が答え合わせに使う、正しいと分かっている値。定理から出る閉じた式、規格に印刷された計算例、別の言語で書いた第 2 の実装の出力など。
  • fail-closed —— 検査に失敗したら通さず、はっきり拒む設計。直せないものを黙って直さない。
  • drift(ずれ) —— 生成物(索引・ヘルプ・翻訳)が元のソースと食い違うこと。
  • 探針(probe) —— 全 op を一斉に呼ぶための試験入力。

型 1: 門は在るのに、事故の起きる場所で走っていない

wheel から op の 26 % が消えていた。 配布用の wheel を作り直したとき、pyproject.toml の py-modules にモジュールを書き忘れ、wheel を入れた環境では 224 本の op が消えていました。「読み込みに失敗した backend はゼロ」を確かめる門は在りました。ただしその門は、開発用に checkout したソースの上でしか走っていませんでした。checkout には全部のファイルが在るので、原理的にこの事故を再現できません。

作り直しは「wheel を実際に作り、別の仮想環境に入れ、checkout と wheel の op の集合を突き合わせる」ジョブを CI に置くことでした。後日、同じ書き忘れ(backends_texture)をしたときは、このジョブが即座に赤になり、1 行で直りました。

同じ型をあと 3 つ踏んでいます。

  • .gitignore に当たった生成物: 台帳のカテゴリ名 build が .gitignore の build/ に当たり、ノート 3 枚が commit されていませんでした。手元は全部緑で、CI だけ 10 件が赤。「ファイルが在るか」を見る門は、そのファイルが git に載っているかを見ていませんでした。→ 生成物のパスが ignore に当たらないことを検査する門を足しました。
  • ディレクトリを歩く順番: highpass.md が 2 次元用と 1 次元用の両方に在り、Windows は 2 次元を、Linux は 1 次元を先に見つけて、CI でだけ門が落ちました。os.walk の順番はファイルシステム次第です。同じ探し方を 4 つの門と 1 つの道具がコピーしていました。
  • 1 件も走っていないのに exit 0: テストファイルの読み込み時に assert が落ちると、pytest はその時点で全体を止めます。37 分かかるはずのスイートが 21 秒で終わり、終了コードは 0 でした。

教訓: 門を書いたら「この門は、事故が起きる環境で走っているか」を確かめる。開発機で緑なのは、直った証拠になりません。

型 2: 緑なのに中身が空

空の出力は、どんな比較にも「一致」する。 生成物が最新かを見る門(全部作り直して、作業ツリーが変わらないことを確かめる)は、生成器が何も出さなくなっても緑のままです。実際、索引が「op ノート 0 本」と書いたまま 6 言語で公開され、ヘルプは画像 0 枚・Markdown が生のまま 60 ページ、という状態を門は通しました。→ 中身の量(例の付いたノートの数、本文の文字数、空のセルがゼロであること)を別に数える門を足しました。

同じ型の 3 例です。

  • 判定を計算して捨てていた: PoC を実行する門は「終了コードは 0 だが PASS と印字していない」を正しく判定していました。ところがその判定を返り値にせず、直後の assert code == 0 が必ず通るので、assert を 1 つも持たない PoC が 3 本放置されていました。
  • 空振りする assert: assert all(x for x in xs) と for x in xs: assert ... は、xs が空なら無条件に通ります。探って締めても 126 件(84 ファイル)が残ったので、件数を台帳に固定し、増えたら赤にしました(減らす方向にだけ動くラチェット)。
  • 同じ図: 展示館の 318 枚の図のうち、中身が違うのは 317 枚でした。「有理」と「無理」という正反対の主張の GIF が、バイト単位で同一でした(投影の仕方で回転成分がちょうど 0 になっていた)。

教訓: 門には「この門は空を通さないか」を確かめる門が要る。いちばん確実なのは、門をわざと壊して、赤になるのを見ることです。Fullseye には「門が本当に全部の PoC を見ているか」「公開名の門が空でないか」を確かめるテストが、門ごとに付いています。

型 3: 全数を回したのに、覆っていない

探針 1 枚は、全数ではない。 全 op を試験入力で一斉に呼ぶ門は、正しい場所に立っていました。それでも、しきい値の op xsitk_huang_thresh は、sin/cos の縞の探針では 4 回中 4 回成功し、勾配・円・市松の探針では 4 回中 4 回失敗(出力が全部ゼロ)していました。探針を構造の違う 3 枚に増やすと、「この op は退化入力なので免除」とした台帳が 27 本から 12 本に縮みました。免除の多くは op の性質ではなく、探針 1 枚の盲点だったわけです。

パラメータにも同じことが起きます。画像をタイルに分けて処理しても結果が変わらない、と宣言した op 168 本を、門は tile=64, halo=16 の 1 組だけで検査していました。組を変えると 18 本が割れていました。

いちばん痛かったのは、差分ファザーが 60,000 ケースで 0 件を出したことです。ファザーは「物体がいくつ見つかったか」は比べていましたが、「どの順番で並んでいるか」を観測していませんでした。わざと並びを逆にする変異を入れても 3,000 ケースで 1 度も捕まえられず、観測を 1 行足すと 2 ケース目で捕まえました。0 件は頑健さではなく、見ていないことの印でした。

教訓: 「全部に対して走った」は「全部を覆った」ではない。免除の台帳が長いときは、op より先に探針と観測を疑う。ファザーの 0 件は、変異を入れて殺せるか確かめてから信じる。

型 4: 自分の仕様と合っているだけ

合成の門 52 件が緑のまま、規格の計算例が 5 件の欠陥を出した。 測定システム解析(ゲージ R&R)と不確かさ(GUM)の op 9 本を、合成データの門 52 件で確かめていました。全部緑です。そこへ規格に印刷されている計算例(worked example)を真値として足すと、5 件の欠陥が出ました。交互作用をプールする経路が無くて EV が 7.3 % ずれる、有効自由度の切り捨てが無くて包含係数が 2.1132(規格は 2.12)になる、など。5 件とも「仕様に書いていないことが無い」型で、自分の仕様から作った合成データでは原理的に見つかりません。

第 2 実装は、単一実装のテストの外を出す。 画像処理の C ABI を Rust で書き直したら、最初の 8 件は全部一致しました(これを「合格」でなく「探針が甘い」と読んだ)。仕様が黙っている所を狙った 9 件で、連結成分の op が numpy の経路では 4 連結、OpenCV の経路では 8 連結になっていることが分かりました。8×8 の市松模様で 32 個 対 1 個です。どちらの経路もそれぞれのテストには通っていました。

変換に対する不変性は、真値が無くても門になる。 「回転しても変わらない」と主張する点群の記述子は、下請けの法線の符号が任意なせいで、90 度回すと 42.4 % の点で符号が反転していました。主張ごとに「12 通りのランダム回転で変わらないか」の門を立てると、真値の値を 1 つも知らなくても嘘が出ます。

教訓: 自分の書いた式で答え合わせをしない。真値は外から持ち込む —— 公表値、別言語の第 2 実装、変換に対する不変性やスケール則(k 倍すると k の p 乗で変わる、など)。

この回で門が捕まえたもの(2026-09-27)

この記事を書いている日に、線虫のコネクトーム(神経の配線図)を測る op と PoC を足しました。その 1 日で門が捕まえたものを、型と並べて置きます。

  • 回転不変の門が 1 ulp の境界を捕まえた(型 4 の門が、型 3 の穴を塞いだ)。 神経の木の Sholl 解析(中心から同心球を広げ、枝と交わる数を数える)は、3 次元では回転で整数が 1 つも動かないはずです。ところがテストが赤になりました。既定の半径の上端を「いちばん遠い節点までの距離」ちょうどにしていたため、その節点が殻の上に乗り、回転で浮動小数が 1 ulp 動いただけで「内側/外側」が反転して、交点が 1 から 0 になっていました。既定の殻を区間の中点に置いて直しました。
  • 訳が黙って読まれていなかった(型 2)。 英語の説明しかない op に日本語の要約を足しました。指紋も合っているのに、日本語のヘルプが生成されません。訳を置く場所が、決められた鍵の 1 段下ではなく最上位だったので、読み手が見つけられなかったのです。再生成は終了コード 0 で、何も言いませんでした。
  • 論文との 9 ポイントの差を、分類表と 1 本ずつ突き合わせて全部分けた(型 4)。 同じ遺伝子の線虫 8 匹で「7 匹以上に在る結合」を数えると成虫の結合の 34 % で、論文の約 43 % と合いませんでした。合わせにいかず、著者が公開している結合ごとの分類表と照合しました。論文は「左右の対でまとめた結合が 7 匹以上に在れば、対に属する結合 1 本 1 本に stable の札を付ける」数え方をしていて、それだけで論文の stable 792 本のうち 789 本を再現しました。どちらも間違いではなく、何を 1 本と数えたかの違いでした。
  • 図のギザギザは丸めの跡だった。 2 匹の平均シナプス数を丸めて度数分布にすると、奇数と偶数で交互に上下しました。numpy の round は偶数丸め(2.5 → 2)なので、半整数が偶数の階級に寄ります。生物の周期性に見えるので危ないところでした。合計(整数)のまま数えると消えました。
  • 著者の欄をそのまま真値にしかけた(型 4)。 線虫 8 匹の神経の骨格で、fullseye の op が出したケーブル長を、著者がファイルに書いた「長さ」の欄と突き合わせる門を立てかけました。ところが 1,586 本中 1,360 本が合いません。その欄は線分の長さの合計ではなく(中央値 0.89 倍)、書き出し元の定義で計算されたもので、確かめようがありません。代わりに、節点ごとの「根からの距離」の欄を使いました。ただしこの欄には座標の無い節点も混ざっていたので、座標のある節点だけで比べると、1,586 本すべてで相対 1.75e-9 まで一致しました。外から持ち込んだ真値も、その定義を確かめるまでは真値ではありません。
  • 第 2 実装の側の名前が入れ替わっていた(型 4)。 分割の採点量(CREMI の adapted Rand error)を scikit-image と突き合わせると、誤差の値は一致したのに、precision と recall が入れ替わっていました。scikit-image のコードは precision を正解側の対の数で割っていて、docstring の「候補の画像の対の数で割る」と逆です。F 値は対称なので誤差そのものは正しく、precision と recall を別々に使うときだけ食い違います。第 2 実装は自分の欠陥だけでなく、相手の欠陥も出します。
  • 採点の前処理ひとつで結論が逆になった(型 2)。 電子顕微鏡の神経の切り出しを VOI の「分けすぎ」と「まとめすぎ」で採点すると、最初はどのしきい値でも「まとめすぎ」でした。原因は、膜の画素を背景(ラベル 0)のまま残していたことです。背景全体が 1 つの巨大な領域として数えられ、まとめすぎが 2.4 倍に膨らんでいました。膜の画素を最寄りの細胞へ割り振ると、しきい値 65 % と 70 % の間で「分けすぎ」から「まとめすぎ」へきれいに入れ替わりました。

門の種類(Fullseye で使っているもの)

種類 真値 例(テスト)
閉形式・恒等式 定理・解析解 次数の和 = 辺の数 / 極小曲面の平均曲率 = 0
第 2 実装 別言語・別 backend・総当たり Rust と Python の一致 / 3 サイクルの総当たり / 生成した C とビット一致
公表値 規格の計算例・規格の図 ゲージ R&R の worked example / GenICam の画素形式 59 種
fail-closed 契約外の入力は拒む 未知の型名・非正の σ・位置合わせ失敗を黙って単位行列にしない
不変性・スケール則 変換で変わらない/決まった指数で変わる 12 回転で不変 / k 倍で k^p
対照(ヌル) 次数を保って組み替えた偶然 各ヌル標本で次数列が厳密に保存
退化・一様入力 空・定数・1 画素 一様な画像が全面検出にならない
探針 全 op を構造の違う入力で呼ぶ fallback ゼロ / つまみが効く
drift 全部作り直して作業ツリーが変わらない regen_all --check
登録面の数え上げ 実物から数えた数 README・索引・__all__ の件数
配布物の側で数える wheel・py-modules 公開経路の無い関数が増えない
翻訳の指紋 原文の指紋 原文を直したら訳が古いと分かる
成果物を実行する 別プロセスで exit 0 + PASS PoC 150 本以上・docs のコード例
環境・git tracked・ローカルパス無し・順番 ignore に当たらない / 私的パスが公開物に無い
門の門 門自身が発火する 空を通さない / 壊すと赤になる / 空振りする assert の台帳

規模の数字は、見出しではなく参考として置きます。テストファイル 420 本、テスト関数 7,192 個、実行件数は約 1 万 5 千件で、全体スイートは約 35 分です。CI は Python 3.10 / 3.11 / 3.12 × 4 分割の 12 ジョブに、wheel を実際に作って数えるジョブ、生成物の drift 検査、週 1 回の順序シャッフルを足した構成で、リリースはその commit の CI が緑でないと出せません。

門を足すときの手順

  1. 真値をどこから持ってくるかを先に決める。自分の書いた式で答え合わせをしない。
  2. 事故が起きる場所で走るかを確かめる(配布物・Linux・CI)。
  3. 門を壊して、赤になるのを見る。赤にならない門は門ではない。
  4. 空を通さないかを別に数える(出力の量・見た件数)。
  5. 入力とパラメータを 1 つにしない。構造の違う入力を最低 2 つ。
  6. 免除は理由つきの台帳に書き、減らす方向にだけ動くラチェットにする。
  7. 合否だけでなく中身を残す。長い検査の全出力はファイルへ。合否だけだと、次にもう 1 回同じ時間を払う。

向かないこと・限界

  • 門を増やすと時間がかかります。全体スイートの 80 % の時間は上位 80 段(PoC を別プロセスで走らせる門など)が使っていました。速くするときは、測ってから直します。
  • 門は「知っている嘘」しか止めません。この記事の実例の多くは、門が在ったのに通したものです。新しい型の嘘は、また別の門を足すまで通ります。
  • 公表値は規格や論文が在る分野でしか使えません。無い所では、第 2 実装と不変性が頼りです。

作り手について

問いと方向は私が決め、実装・門の追加・敵対的な見直しは Claude Code に回しました。この記事の実例の多くは、その見直しの記録です。

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?