日本語の文字化けを自動で見つける検出器を、いくつか作って比べていた。UTF-8のバイト列をCP932として読んでしまう、あの化け方が対象。
比較用のテストデータは自分で作った。正常な日本語を用意して、それをわざと壊して、正解ラベルを付ける。よくあるやり方だと思う。
その自作データでの結果はこうだった。
自作の合成データ
検出器A 99.5%
検出器B 34.4%
Aの圧勝に見えた。Bを捨ててAを伸ばすつもりでいた。
ところが、GitHubから実際に文字化けした状態でコミットされているファイルを集めて当て直すと、こうなった。
実データ
検出器B 89.6%
検出器A 71.2%
順位が入れ替わっていた。
しかもこのあと、テストデータの作り方を直して作り直したところ、順位はまた変わった。今度は実データと一致した。
つまり自分は、検出器ではなく検出器を評価するデータの方を間違えていた。この記事は、それをどう見つけて、どう確かめ直したかの話。
何が起きていたのか
一言でいうと、壊し方が違っていた。
同じ「UTF-8をCP932で読む」でも、読む側の実装によって、変換表に無いバイトの並びの扱いが変わる。ある実装は置換文字(U+FFFD)を出す。別の実装は出さずに、他の文字に落とす。
自分の合成データは前者の挙動で作られていた。手元のスクリプト言語でデコードするとき、エラーを置換文字に置き換えるオプションを使っていたからだ。
だが実データで多かったのは後者だった。Windows環境で生まれた化けが大半で、そこには置換文字がほとんど出てこない。
置換文字に強く反応する判定は、合成データでは最強に見えて、実データでは効かない。 これが順位を入れ替えていた正体だった。
そして厄介なのは、この状態は合成データの中を見ている限り絶対に気づけないこと。検出器Aは何度測っても99.5%のままだから。
どう確かめ直したか
ここからは方法の話。同じ罠は文字コード以外にもあると思うので、手順として書く。
1. 正解ラベルを、評価対象の検出器から独立に作る
一番最初に決めたのがここ。検出器が「化けている」と判定した行を正解にすると、その検出器の性能は測れない。 自分で採点した答案になる。
なので原理を変えた。
その行を、化ける前の状態に書き戻してみる。
書き戻した結果が、妥当なUTF-8の日本語として読めるなら、それは化けている。
推測ではなく可逆性で決める。「化けっぽい見た目かどうか」を一切見ない。
2. ラベラ自身を検証する
ラベラも道具なので、精度を測らないと使えない。正解が分かっているデータを作って当て、偽陽性と偽陰性を両方数えた。
結果、偽陽性はゼロにできたが、偽陰性は残った。復元の途中で情報が落ちすぎて、証明できない行がある。
なので運用ルールを決めた。陽性は信用する。陰性は「正常」と扱わない。 数えた化け行の数は常に下限として扱う。
これを最初に決めておくと、あとで数字の解釈がぶれない。
3. 検証用データを、最後まで開けない
集めたファイルを開発用と検証用に分けた。ポイントはファイル単位ではなくオーナー単位で切ること。
forkや同じ人の別リポジトリが両側にまたがると、実質的に同じデータを開発でも検証でも見ることになる。文字化けの場合、同じ人・同じ環境なら同じ壊れ方をするので、この漏れは効く。
分割は乱数を使わず、オーナー名のハッシュで決定的にした。何度走らせても同じ結果になる。
そして検証用は、全ファイルのハッシュを連結してさらにハッシュした値を記録し、開封するまで中身を一度も見ない。
分割を決める前に中身を見てしまっていたファイルがあったので、そのオーナーは強制的に開発側に固定した。「見ていない」と言えないものを検証側に入れない。
4. 正常側を、異常側と同じ規模で別に集める
異常側だけ集めても誤検出は測れない。「日本語を含む」という条件だけで、別に十数万行を集めた。
ここで測れたことが1つある。この集合に往復ラベラをかけると、証明できる化けを含むファイルはごく一部だった。文字化けはそこら中にあるわけではない。この感触は、あとで検出器の誤検出率を解釈するときに効いてくる。
5. 合成データの正しさを、絶対値ではなく順位で確かめる
ここが一番言いたいところ。
作り直した合成データが「正しい」かどうかは、検出率の絶対値では判定できない。実データと数字が近いかどうかを見ても意味がない。実データと同じ順位を出せるかどうかでしか確かめられなかった。
実際、壊し方を直した新しい合成データは、実データと検出器の順位が完全に一致した。旧データだけが、ほぼ逆順になっていた。
合成データの検証基準としては、これが一番使いやすいと思う。
途中で外した仮説
参考までに、測って否定したものを挙げておく。どれも「これが原因だろう」と思ったものばかりだった。
- 一度壊れたものを、もう一度壊した行が多いのでは → 深さを数えたら、ほぼ全部が1段だった
- 元のデータがすでに汚れているのでは → わざと汚しても数字は下がらなかった。むしろ上がった
- 使っている変換表に欠けがあるのでは → 主要な範囲の欠けはゼロだった
- 私用領域の文字が強い手がかりになるのでは → 正常なファイルでも普通に使われていて、特徴量としては使えなかった
- 古い日本語文書には特有の書き方が残っているのでは → 探したが、想定した文体はほとんど見つからなかった
最後のは失敗として書いておく価値がある。探して見つからなかったことは、無いことの証明ではない。ただし「希少である」ことの測定にはなる。
まとめ
- 合成テストデータは、現実の壊れ方を写していないことがある
- しかも写せていないことは、そのテストデータの中を見ても分からない
- 検出率の絶対値ではなく、検出器の順位が実データと一致するかで合成データを検証する
- 正解ラベルは、評価対象から独立に作る。そしてラベラ自身も検証する
- 検証用データはオーナー単位で切り、ハッシュで固定して、最後まで開けない
自分の場合、この作業をやったあとに実データの見逃しを分類して、特徴を1つ足した。
開発用データ(dev) 89.6% → 97.1%
検証用データ(holdout) 86.6% → 94.6%
holdoutは、特徴を足すまでの間一度も開けていない。検出器を凍結してハッシュを記録してから開封した。
つまりこれは、開発用データに対してだけ効いた改善ではない。未知のデータにも改善が転移した。
ただし1つ限定を付けておく。この数字は今回集めた集合に対するものであって、一般の日本語ファイルに対する検出率ではない。 devもholdoutも「化けを含んでいそうなファイル」を狙って集めた集合なので、母集団を代表していない。正しくは「構築したholdout集合の中で、可逆的に化けと確認できた行に対して94.6%」と読む。
その特徴が何だったか、なぜその閾値にしたか、検証用を開けたときに型ごとに何が起きたか、そして何が測れなかったかについては、実験記録として別にまとめている。
→ https://brain-market.com/u/yoggydev/a/b1ETMzYjMgoTZsNWa0JXY
測ってほしいことがあれば
手元で再現できるものなら、実際に動かして数字で返します。コメントに環境と再現手順を書いてもらえると早いです。結果がどちらに転んでも、そのまま出します。