背景
- 日本語のフリガナ(読み)予測モデルを作る過程で、訓練データのバグを数え切れないほど直してきた
- 感覚としては百回近く直している気がするが、量が多すぎて自分でも何が起こって何を直したのかを把握しきれなくなっていた
- そこで、実際に手を動かしたプロジェクトの記録を洗い出し、遭遇した問題を体系的に整理し直した
データの問題
遭遇した問題は、大きく「データそのものの中身の問題」と「それを扱う仕組み側の問題」に分けられる。まずは前者から整理する。
文字・表記レベル
これらは比較的機械的に検出・修正できる問題が多い。
全角/半角の不統一
- 数字・記号・英字が全角と半角で混在して記録され、同じ内容が別表記として扱われてしまう
- 特に電話番号や日付のような定型的な文字列で起こりやすい
1
2
| 03-1234-5678
03-1234-5678
|
異体字・旧字体/新字体の不統一
- 同じ文字が複数の字体で記録され、見た目は近くても別のUnicodeコードポイントとして扱われる
- 姓名や地名に多く、機械的な正規化だけでは対応しきれないことがある
1
2
3
4
| 高 (通常の「高」)
髙 (はしごだかの異体字)
辺 (新字体)
邊 (旧字体)
|
旧仮名遣い/現代仮名遣いの混在
- 歴史的仮名遣いが現代仮名遣いに正規化されないまま残っている
- 古い文献由来のデータで特に目立つ
1
2
| てふてふ (歴史的仮名遣い)
ちょうちょう (現代仮名遣い)
|
Unicode正規化の不備
- 見た目は同じ文字でも、結合文字と合字などUnicode上の内部表現が異なる場合がある
- 文字列としての一致判定が正しく行われず、同じ語が別物として扱われる
1
2
| ガ (単一の合字、U+30AC)
ガ (「カ」+結合濁点、U+30AB U+3099)
|
大字(旧漢数字)の扱い漏れ
- 壱・弐・参のような大字(旧漢数字)が、通常の漢数字と別表記として扱われ、正規化から漏れる
- 契約書や領収書のような改竄防止目的の文書に多い
英字・頭字語の表記統一の限界
- 頭字語の大文字/小文字やハイフンの有無が統一されていない
- アルファベット部分の読み方自体も語ごとに慣用が異なり、一般化できない
- 実世界の使用自体が割れているケースを無理に一つの正解に決めてしまうと、それ自体が新たなノイズになる
1
2
| K-POP → ケーポップ
J-WAVE → ジェイウェーブ
|
読み表記レベル
カタカナ/ひらがな表記の不統一
- 読みをカタカナで統一するかひらがなで統一するかが、データソースによって異なる
促音・拗音の表記ゆれ
- 特にOCR由来のデータで、小書きの「っ」「ゃ」などが通常サイズの文字として誤認識されることがある
長音符の表記ゆれ
- 長音符の表記が二重・三重に壊れて記録されることがある
1
2
| 経営 → ケイエイ (正)
経営 → ケーエイ/ケイエー (表記ゆれ)
|
濁点・半濁点の脱落
- OCR由来のデータで、濁点・半濁点が読み取れず脱落することがある
意味・文脈レベル
多義語・同綴異訓語の曖昧性
- 同じ表記でも文脈によって読みが変わる語が数多く存在する
1
2
3
| 方 → カタ(読み方)/ホウ(方法)
何時 → ナンジ(何時に行く)/ナンドキ(何時だと思ってる)
家 → イエ(家を建てる)/ウチ(家に帰る)
|
熟字訓と通常の音訓読みの混同
- 熟語全体で一つの読みが決まる熟字訓が、文字単位の音訓読みと混同され、誤って分解されることがある
1
2
| 今日 → キョウ (熟字訓、正)
今日 → コン+ジツ (文字単位に誤分解)
|
固有名詞の読み
- 人名・地名は、そもそも一意に決まらないことが珍しくない
機械的なルールで決まる読みと、判断が必要な読みの混同
- 音韻規則として機械的に決まる読みと、単なる固有名詞混入による統計的パターンを混同すると、誤ったルール化をしてしまう
- 頻度だけを根拠に一般化すると、後者を誤ってルール化してしまう
1
2
| ◯分間 → 直前の数字に応じて機械的に決まる音韻規則
右衛門 → 「良右衛門」という一人の人名が繰り返し出現しているだけの統計的パターン
|
曖昧性そのものの種類分け
- 曖昧に見える語でも原因は一様ではなく、少なくとも3種類に分けて考える必要がある
- この3つを区別しないと、本質的多義型の語に対して「もっとデータを増やせば解決する」という誤った対処をしてしまう
1
2
3
| 候補漏れ型: 正解の読みがそもそも候補集合に含まれていない
本質的多義型: 文脈をいくら与えても読みが一意に定まらない(英字読みの慣用差を含む)
文脈依存型: 「市場」のように、文脈さえ与えられれば一意に定まる
|
分割・アライメントレベル
形態素解析の構造的な分割・融合ミス
- 辞書の語彙不足とは別種の、境界(トークン分割)の問題であり、頻繁に発生した
1
2
| 何時 → 何 + 時 (意図しない分割)
二十 → 二 + 十 (複数桁数詞の結合失敗)
|
ラベル位置のアライメントの甘さ
- 正解の読みを部分文字列の一致だけで判定すると、対応箇所を取り違えることがある
- 「読みそのものが間違っている」バグとは別軸の、「読みは合っているが対応箇所がズレている」バグ
1
2
3
| 今日は今日で忙しい
^^^^ 1回目(正解ラベルの本来の対象)
^^^^ 2回目(部分文字列一致で誤って紐付けられる)
|
ソース・アノテーション品質レベル
複数の辞書・コーパス間での読みの不一致
- 辞書やコーパスによって、同じ語に対して異なる読みが採用されていることがある
1
2
| 私 → ワタシ (辞書A)
私 → ワタシ/ワタクシ (辞書B)
|
人手アノテーションの入力ミス
- 単純なタイプミスや読み間違いが、そのままラベルとして記録されてしまう
ソースの信頼度によるGold/Silverの区別漏れ
- 単純な二値の区分だけでは不十分で、実際には信頼度を段階的なティアに分けて管理する方が扱いやすい
- 機械的なクロスチェックを先に流し、意見が割れたものを優先的に人手レビューに回す、という段階的な運用と組み合わせる
1
2
3
| gold_verified 読みが一意で人手検証済み
gold_free_variation 本質的に複数の読みがありうる
silver_ambiguous まだ判断がついていない
|
自動ラベリングツールのバイアスの向きが語や構文によって不規則
- ツール全体に対する一般的な補正ルールを当てはめるのではなく、語ごとに確認する必要がある
1
2
| 語A: 文語的な読みに寄る
語B: 特定の言い回しへの過学習で逆方向に寄る
|
ルビと原文の対応のズレ
- ルビの範囲が実際の熟語の境界とズレて記録されることがある
1
2
3
| 原文: 山田太郎
ルビ範囲(誤): 山田太[ろう]
ルビ範囲(正): 山田[たろう]
|
分布・統計レベル
クラス不均衡・ロングテール分布による少数派読みの学習バイアス
- 出現頻度の差が大きいと、少数派の読みが十分に学習されなくなる
1
2
| 方 → ホウ(多数派)
方 → カタ(少数派、学習不足)
|
「候補生成漏れ」と「ランカーの多数派バイアス」の混同
- 前者は辞書へのエントリ追加で解決できるが、後者は正解が候補集合に含まれているにもかかわらず、学習不足な文脈で統計的に優勢な読みへ倒れてしまう問題で、辞書追加だけでは解決しない
1
2
| 候補生成漏れ: candidates = [ホウ] → 辞書にカタを追加すれば直る
ランカーのバイアス: candidates = [ホウ, カタ] → 追加しても依然ホウを選んでしまう
|
候補の並び順が暗黙の既定値として使われてしまう問題
- 呼び出し側が判断に迷った際に先頭の候補へフォールバックする実装は多いが、候補の並び順が実際の多数派の読みと系統的に食い違っていることがある
- フォールバック処理そのものを直すより、候補テーブルの並び順自体を実際の頻度に合わせて直す方が、影響範囲が広く効果的
1
2
| candidates[0] = ホウ (テーブル上の並び順)
実際の多数派 = カタ
|
診断データセットの設計・重複データの扱い
- ランダムサンプリングだけでは、少数派の意味的カテゴリを十分にカバーできないため、意図的にカテゴリを設計した診断データセットが必要になる
1
2
3
| diagnostic_set:
熟字訓カテゴリ: 意図的に一定数を含める
固有名詞カテゴリ: 意図的に一定数を含める
|
実装・インフラレベル
実装ドリフト
- 同じ正規化ロジックが複数箇所に個別実装され、一方だけ修正されてもう一方が取り残され、時間とともに挙動が乖離していく
- いわば「データのバグ」ではなく「バグを生み出す仕組み側のバグ」
1
2
| normalize_choon() # モジュールAで修正済み
normalize_choon() # モジュールBは未修正のまま残存
|
派生データの陳腐化
- 「コードを直した」ことと「データが直った」ことは別で、複数の修正が反映されないまま蓄積すると、影響範囲の把握そのものが難しくなる
1
| 生成元コード修正 → train.jsonl は自動更新されない → 再生成するまで古いラベルのまま
|
評価インフラ自体のバグ・母集団ドリフト
- 訓練データだけでなく、物差しである評価パイプライン自体が壊れていないかも確認が必要
- 意外に大きい数字を見たときほど、まず測定方法自体を疑うべき
1
2
3
| 「候補に正解が見当たらない」という見た目の大きな問題
→ 実際の原因のほとんどは、評価セットとモデル出力の間の長音符表記のズレ
→ 実質的な問題はごく一部にすぎなかった
|
カンニング防止の射程
- 評価データそのものを学習に混入させない、という原則は当然守るべきだが、その射程は評価ファイル本体だけにとどまらない
1
2
| eval_set.jsonl 直接の混入対象
tool_docs/schema.md 具体例が埋め込まれた、見落としやすい混入経路
|
データを直す方法論
個別のバグの分類以上に汎用性が高いと感じたのは、バグをどう見つけてどう直すかという方法論そのもの。
盲検サンプリングとWilson信頼区間
- 少数のサンプルを見て「綺麗そう」と判断するのは危険で、区間推定をせずに小さいnの結果を信用しないようにする
1
| n=100, 誤り=0件 → 「誤り率0%」ではなく、信頼区間の上限を踏まえて判断する
|
ソース別の層化監査
- 複数のソースを混ぜてランダムサンプリングすると、量の多いソースに埋もれて、量の少ないソースの汚染を見落とす
- ソースごとに独立したサンプルと信頼区間を持つことで、健全に見える全体平均の中に汚染された一つのソースが隠れているケースを検出できる
1
2
| ソースA(大量): n=500, エラー率 0.5%
ソースB(少量): n=20, エラー率 15% ← 混ぜた平均では見えない
|
層化サンプリングとランダムサンプリングの往復
- 既知のリスクがある語を狙い撃ちする層化サンプリングは、既知パターンの再発を効率よく見つけられるが、想定していなかった問題は見つけられない
- 純粋なランダムサンプリングと交互に行い、両方の死角を補い合う
1
2
| 層化サンプリング: 過去に問題のあった語を優先的に再チェック
ランダムサンプリング: 誰も疑っていない箇所を無作為にチェック
|
修正のたびに監査をやり直す
- 一度きりの監査ではなく、何かを見つけて直すたびに、ゼロからサンプルを取り直して確認する
- 修正が実際に効いたか、そして新たな問題を持ち込んでいないかを、この再監査で確かめる
「検証済み」も定期的に再監査する
- 一度「検証済み」に分類したデータも、時間をおいて再度サンプル監査する
- 「検証済み」は保証ではなく、あくまである時点での確信度に過ぎない
何もなくても定期的に健全性を確認する
- 既知の問題が特に見つかっていない期間でも、一定のペースでサンプル監査を続ける
- 何かのトリガーに反応するだけでなく、確信度を維持するための定期点検として運用する
網羅的なシグネチャスキャンとランダムサンプリングの併用
- 既知のパターンに一致するものを網羅的に検出する方法と、未知のパターンを見つけるためのランダムサンプリングは、どちらか一方では不十分で、両方を使い分ける
1
2
| 既知パターン(濁点脱落など) → 全件シグネチャスキャン
未知パターン → ランダムサンプリング
|
独立したクロスチェックソースの利用
- 単一の辞書・コーパスだけを信頼せず、複数の独立したソースを突き合わせる
1
| 辞書A + 辞書B + コーパス頻度 → 一致しないものだけを優先的にレビュー
|
自分自身のノイズフロアを測る
- 何かを変更してスコアが動いたとき、それが本当に変更の効果なのか、単なる実行間のばらつき(ノイズ)なのかを、同一設定での再実行によって確認する
- この確認を怠ると、ノイズを実質的な改善・劣化と誤認し、的外れな判断を積み重ねてしまう
1
2
3
| 変更あり: score = 0.893
変更なし(再実行1): score = 0.891
変更なし(再実行2): score = 0.895
|
内部矛盾スキャン
- 同じ表記に対して複数の異なる読みが記録されているケースだけを機械的に抽出し、その組だけをレビューする
- 全体をランダムサンプリングするより、ずっと高い効率で誤りを見つけられる
1
| surface="方" → readings={ホウ, カタ} # レビュー対象として抽出
|
一括訂正の副作用に注意する
- ある語の読みが間違っていたと分かったとき、その語の全出現箇所を一括で訂正すると効率は良いが、別の箇所では実は正しかった、という文脈依存のケースまで巻き込んで壊してしまうことがある
- 訂正自体が新しい誤りの発生源になりうる
1
2
| 「方」の誤ラベルを一括でカタ→ホウに訂正
→ 本来カタが正しかった別の出現箇所まで巻き込んで書き換えてしまう
|
検証ツール自体を疑う
- 検証ツールは「正しい前提」として扱いがちだが、それ自体もコードである以上、同じ種類のバグを持ちうる
1
2
| cross_check_script.py に normalize_choon() と同じバグが混入
→ 大量の偽陽性を生成
|
表記のズレを見つけたら生データソースまで遡る
- 訓練データの中で表記が壊れているのを見つけたとき、それが下流の処理で壊れたのか、元の生データの時点でそう記録されていたのかを、まず切り分ける
- 話し言葉の書き起こしとして元々そう記録されていた場合、それは「バグ」ではなく、正しい実態を反映した表記ゆれということもある
1
2
3
| raw_source → 正常
↓ 前処理
train.jsonl → 破損 → 前処理側のバグと判明
|
コードが物語る大変さ
ここまではbacklog・docs・タスク記録という「言語化された記録」をもとに整理してきたが、実際のコードとテストを読むと、記録だけでは伝わらない大変さが見えてくる。
一つ一つが個別に勝ち取られた事実の集合
- 補正関数やルックアップテーブルの各エントリは、まとめて取り込んだものではなく、それぞれ個別の調査によって発見・検証された事実
- 過去の修正が広すぎた、あるいは狭すぎたと後から判明し、範囲を絞り直された跡も残っている
1
2
| counter_word_sound_change_table:
# エントリ1件ごとに、それを発見した具体的な調査の痕跡が残っている
|
正例と負例をペアにしたテスト
- ほぼすべての修正に対して、狭いケースが正しく処理されることを確認するテストと、似ているが別のケースには意図的に手を付けていないことを確認するテストが、ペアで存在する
- 難しさの本質は「誤りを見つけること」ではなく「他を壊さないように修正の範囲を絞り込むこと」にあり、そのスコープの判断が回帰テストとして恒久的に固定化されている
1
2
| test_counter_word_change_applies_to_specific_pattern()
test_counter_word_change_does_not_apply_to_similar_pattern()
|
先送りにされた妥協がない
- TODO・HACK・FIXMEのようなショートカットの印が実質存在しない
- 見つかった問題は先送りにされず、スコープを絞った修正として実装まで持ち込まれている
モジュールのdocstringが実験ノートとして機能している
- ドキュメントというより、試して失敗したアプローチの記録として機能している
- 複数の補正戦略が試され、悪化したために元に戻された、という記述も残っている
記録は「何を発見したか」を語るが、コード自体は「それぞれの発見を、他を壊さずに正しく直すことの方が、発見そのものよりはるかに労力のかかる半分の作業だった」ことを物語っている。
まとめ
- 日本語のフリガナデータで実際に遭遇した問題を整理すると、文字・表記、読み表記、意味・文脈、分割・アライメント、ソース・アノテーション品質、分布・統計、実装・インフラという複数のレベルにまたがっていた
- 特に、形態素解析の分割・融合ミスや、実装ドリフト、評価インフラ自体のバグは、訓練データそのものの中身の問題とは異なる、仕組み側の問題として区別して扱う必要がある
- 個別のバグより汎用性が高いのは、盲検サンプリング・ソース別の層化監査・Wilson信頼区間・独立クロスチェック・ノイズフロアの計測といった、データを直す際の方法論そのものだった
- コードとテストを読むと、誤りを見つけることより、他を壊さずに正しく直すことの方がはるかに労力のかかる作業だったことが分かる
- 「データが完璧なら性能が出る」という単純な話ではなく、どのレベルで何が壊れているかを見極める継続的な作業そのものが、データを正しくするということの実態だと感じている
参考文献