kaedeasset記事 · Notes霧走町 流氷に眠る

検査の文が答えの辞書に写っていないかを、文字 3 個の重なりで見張る

Watching for test sentences copied into the answer dictionary, by 3-character overlap

 D22-3-T43-shingling-jaccard-contamination・技術・日本語入力でプレイする仕組み

確かめ方w-shinglingJaccard coefficient (Jaccard index)train-test contaminationbenchmark leakage detectioncontainmentclaude自然言語処理

照合を測る問いを、照合の辞書にそのまま書き写せば、通る率はいくらでも上がります。
それを止めるために、検査の文と辞書の 2,845 文を、3 文字ずつの窓の重なりで全部突き合わせ、0.8 以上を lint のエラーにしています。

T34 の lint の 6 つの群のうち、⑤「検査の文の写し込み」の回です。
T39 では、言い換えた入力が同じ証言に届くかを書きました。今日は、その検査の文そのものが答えの側に漏れていないかを見る仕組みを書きます。
写し込みで率が上がってしまった、うちの失敗の話は T50 に回します。今日は見つけ方までです。

この回の位置と、前後の回#

区切り: T34〜T49 確かめ方(16 回のうち 10 回目)
先に読むとよい回: T34(台本を実行せずに検査する)・T39(言い換えても着地するか)
次に読む回: T44(二つの作品を突き合わせる)・T50(通じ率 23% が 100% になった日)

名前 — w-shingling#

正式名
w-shingling(シングリング)と Jaccard coefficient(Jaccard index・ジャッカード係数)
略称
—
日本語での呼び名
シングリング、ジャッカード係数
L18 が見つけるものの名前
train-test contamination(評価データの汚染)・benchmark leakage detection(ベンチマークの漏れの検出)

初出:

  • w-shingling: Andrei Z. Broder, "On the resemblance and containment of documents", Compression and Complexity of SEQUENCES 1997(IEEE, pp. 21–29)。同じ年に Broder, Glassman, Manasse, Zweig, "Syntactic Clustering of the Web"(WWW6)で、Web 全体の文書の近似重複の検出に使われました。
  • Jaccard 係数: Paul Jaccard, "Étude comparative de la distribution florale dans une portion des Alpes et des Jura"(Bulletin de la Société Vaudoise des Sciences Naturelles 37, 1901)。植物の分布の比較のための係数で、Jaccard 自身は coefficient de communauté と呼んでいました。
  • train-test contamination・benchmark leakage という語の初出は確かめられませんでした。大規模な言語モデルで汚染を測った例としてよく引かれるのは、Brown ほか "Language Models are Few-Shot Learners"(arXiv:2005.14165, 2020)の、13-gram の重なりで評価データを洗った調べです。

一般の解説 — w-shingling#

w-shingling(シングリング)とは、文書を長さ w の窓で切り出した shingle の集合にして、二つの文書がほぼ同じかを、集合どうしの重なり(Jaccard 係数)で答えるやり方です。

  • w-shingling

文書を、正規化したトークンの列にします(書式・大文字小文字などを捨てる)。
長さ w の窓を 1 つずつずらして切り出したものが shingle で、その集合を S(D, w) と書きます。重複は数えません。
「この 2 つの文書はほぼ同じか」を、集合どうしの重なりで答えます。順番の情報は、窓の中にだけ残ります。

Broder は二つの量を分けました。
resemblance(似ている度合い) r(A, B) = |S(A) ∩ S(B)| / |S(A) ∪ S(B)|
containment(含まれている度合い) c(A, B) = |S(A) ∩ S(B)| / |S(A)|
前者が Jaccard 係数そのものです。後者は「A がほぼ B の中に入っているか」で、A が短く B が長いときに効きます。

  • 規模を扱う工夫

文書が多いと、全部の対で集合を比べるのは重すぎます。
Broder の論文の要点は、集合の代わりに小さなスケッチを取ることでした。ランダムな置換をかけて最小の要素を取る min-hash で、二つの文書の min-hash が一致する確率は、ちょうど Jaccard 係数になります。これを何本か並べれば、固定長のスケッチから resemblance が見積もれる。containment は、文書の長さに比例するスケッチが要ります。
さらに LSH(locality-sensitive hashing)で、似ていそうな対だけを候補に絞るのが、近似重複の検出の定番の組み合わせです。

  • 汚染の検出

評価データが学習データ(や、照合の辞書)に入り込むと、評価は「覚えているか」を測るだけになります。
見つけ方の基本は、n-gram の重なりです。Brown ほか(2020)は、評価の例が学習データと 13-gram を共有していれば汚れとして外し、汚れを除いた部分集合でも測り直しました。
よく知られた弱点もあります。

  • 言い換えられた写しは、表層の重なりでは見えない
  • n や閾値の選び方で、見逃しと誤検出の釣り合いが変わる
  • 短い文は窓の数が少なく、1 文字の違いで値が大きく揺れる

うちの事情#

照合の辞書は、意図ごとの言い換えの文です。今日の作品データで、意図 248・言い換え 2,845 文。
入力された日本語は、この辞書の文と文字 n-gram と埋め込みで比べて、どの意図に当てるかが決まります(T04・T05・T08)。
検査の文が辞書に写っていれば、その検査はほぼ必ず通ります。検査が測っているのは「辞書に同じ文があるか」になり、遊ぶ人が初めて入力する言い方に届くかは測れません。

うちには検査の文が三種類あります。どれにも同じ規則を掛けます。

  • L4: 凍結した問い(golden の dev と holdout。T38・T42)
  • L18: 封印の問い(runs/ の名前に probe を含む台本。雑談の下限を見るもの)
  • L37: 探りの問い(probes.json の generic)

封印の問いの台本の頭には、こう書いてあります。

# **この文を言い換え辞書に写さないこと。** 写した時点で測定ではなくなる。

注記だけでは守れないので、機械で見張ります。

うちの実装 — w-shingling#

正規化は、照合のコアの関数を使います。lint の側で書き直すと、コアの正規化を変えたときに、汚染の検出だけが静かにずれるからです。
ただし、照合と同じ正規化(norm/1)ではなく、敬体を常体へ畳まない版を使います。

// 畳まない正規化。**書き写しの検出(lint L4/L18/L37)専用。**
// 敬体を畳んでから比べると、「見ませんでしたか」と「見なかったか」が同一文字列になり、
// **別々に書いた文を「書き写し」と誤検出する**(2026-08-19 に holdout 1件で実際に出た)。
// 照合は畳んだ側で、重複検査は畳まない側で見る。
inline std::vector<char32_t> NormalizeTextRaw(const std::string& Utf8)
{
	std::vector<char32_t> Out;
	for (char32_t C : DecodeUtf8ToU32(Utf8))
	{
		if (C >= 0xFF01 && C <= 0xFF5E) { C -= 0xFEE0; }
		if (NormIsDropped(C)) { continue; }
		if (C >= U'A' && C <= U'Z') { C += 32; }
		if (C >= 0x30A1 && C <= 0x30F6) { C -= 0x60; }
		Out.push_back(C);
	}
	return Out;
}

全角英数字を半角へ、ASCII の記号と空白・読点・句点・かぎかっこ・中黒・三点リーダ・長音を落とし、英字を小文字に、カタカナをひらがなにします(T03 の norm/1 の前半と同じ)。

shingle は文字 3 個の窓です。3 文字を 21 ビットずつ詰めて、1 つの 64 ビットの数にします。

static std::set<uint64_t> Trigrams(const std::vector<char32_t>& Chars)
{
	std::set<uint64_t> Out;
	if (Chars.size() < 3)
	{
		uint64_t Key = 1; // 空・短文どうしの偶発一致を避けるため 1 起点
		for (char32_t C : Chars) { Key = (Key << 21) | (uint64_t)C; }
		Out.insert(Key);
		return Out;
	}
	for (size_t i = 0; i + 2 < Chars.size(); ++i)
	{
		Out.insert(((uint64_t)Chars[i] << 42) | ((uint64_t)Chars[i + 1] << 21) | (uint64_t)Chars[i + 2]);
	}
	return Out;
}

static double Jaccard(const std::set<uint64_t>& A, const std::set<uint64_t>& B)
{
	if (A.empty() || B.empty()) { return 0.0; }
	size_t Inter = 0;
	for (uint64_t K : A)
	{
		if (B.count(K)) { ++Inter; }
	}
	return (double)Inter / (double)(A.size() + B.size() - Inter);
}

Unicode の符号位置は 21 ビットに収まるので、3 文字で 63 ビット。衝突はありません。
2 文字以下の文は、窓が作れないので、文字を全部詰めた 1 個の鍵にします。先頭を 1 にしておくと、「あ」と「NUL あ」のような偶然の一致が起きません。

L18 の本体はこうです(写す。表示の文は一部を省きます)。

if (!ProbeDir.empty())
{
	// 辞書の全文の 3-gram の集合を先に作る
	for (const kaede::adv::Intent& It : Intents.intents)
		for (const std::string& P : It.paraphrases)
			Corpus.push_back({ &It, Trigrams(NormalizeTextRaw(P)) });

	for (const fs::directory_entry& De : fs::directory_iterator(ProbeDir, Ec))
	{
		const std::string Name = De.path().filename().string();
		if (Name.find("probe") == std::string::npos) { continue; }
		if (De.path().extension() != ".txt") { continue; }
		…
		while (std::getline(Ss, Line))
		{
			++No;
			const std::string L = Trim(Line);
			// コメント・移動(>)・話者(@)・問い合わせ(?) は発話ではない
			if (L.empty() || L[0] == '#' || L[0] == '>' || L[0] == '@' || L[0] == '?') { continue; }
			const std::set<uint64_t> Tri = Trigrams(NormalizeTextRaw(L));
			for (const ParaTri2& P : Corpus)
			{
				const double J = Jaccard(Tri, P.Tri);
				if (J >= 0.8)
				{
					std::printf("L18: probe '%s' (%s:%d) overlaps corpus of %s (jaccard=%.2f)\n", …);
					++Errors;
					break;
				}
			}
		}
	}
}
  • 検査の文 1 本ごとに、辞書の全文と総当たりで Jaccard を出す。スケッチも LSH も使わない
  • 0.8 以上の文が 1 つでもあれば、その場でエラーを 1 件数えて次の文へ(break)
  • どの意図の辞書かは問わない。別の意図の文に写っていても落とす
  • L4 は同じ計算を golden に掛け、holdout の行は文を表示せず、ファイルと行番号だけ出す(作る側が holdout の文を見ないため。T42)
  • lint は 1 件でもエラーがあれば終了コード 1。毎周の回帰テスト(Scripts/adv-loop.ps1)は lint に --probes <作品>/runs を渡して回すので、写しは回帰テストの赤になる

数で見る(第一章の例)#

「戸倉さんの人柄」は 5 窓です(戸倉さ・倉さん・さんの・んの人・の人柄)。
辞書のハルの意図(戸倉の人柄を聞く)に「戸倉さんの人柄は」があり、こちらは 6 窓。共通が 5 なので、

J = 5 / (5 + 6 − 5) = 0.83

で、0.8 を越えます。1 文字の違いは写しと見なす、という線です。

「昨日の夜はどこにいた」は、佐伯のアリバイの意図の辞書にそのまま入っている文です(J = 1.00)。
岬の夜釣りの意図にある「あの夜はどこにいた」とは J = 0.67。冒頭の 2 文字が違うだけで、写しの線の下に来ます。

逆に、短い問いが長い辞書の文の中に入っている場合は、Jaccard では低く出ます。
「昨夜の行動は」(4 窓)と「あの夜の行動を話してくれ」(10 窓)は、共通 2 で J = 0.17。Broder の containment で言えば 2 / 4 = 0.50 です。

今日の値です(作品データの今の版・adv-sim は 2026-10-27 のビルド)。

lint: 0 error(s), 1 warning(s) — locations=10 intents=248 flags=102 entities=61

L4・L18・L37 の行は 1 件も出ませんでした。1.0 秒です。
それぞれ、辞書でいちばん近い文との Jaccard を出して並べると、こうなりました(同じ計算を Python に写して出した値。holdout は計算していません)。

組 文の数 中央値 最大 0.8 以上
封印の問い(L18) 77 0.26 0.78 0
golden/dev(L4) 176 0.26 0.75 0

封印の問いの最大は 0.78 で、線のすぐ下にあります。

今日見つけた抜け: 作品フォルダの診断用の問い#

L18 が読むのは、--probes に渡したフォルダの直下で、名前に probe を含む .txt だけです。
一方、作品フォルダには、jsonl の形の診断用の問いが 5 組あります(probe-critical 244・probe-natural 67・probe-persona 120・probe-short 74・probe-smalltalk 67。件数は注記の行を除いた数)。頭の注記には「コーパスの文面は見ずに書いた」とあります。
この 5 組は、L4 にも L18 にも掛かっていません。

入力の欄だけを .txt に書き出し、本物の adv-sim の lint にそのまま掛けてみました。
L18 が 8 件出ました。Jaccard 1.00 が 7 件、0.83 が 1 件です。
第一章の例を挙げると、「係船柱を調べる」「今日は冷えますね」「検分の結果を教えてください」「戸倉さんの評判は」は、正規化したあとの 3 文字の窓が、辞書のどれかの文と全部一致しました(J = 1.00)。
git の履歴を見ると、雑談の問いの組は、雑談の意図と同じ commit で書かれていました。

この 5 組は合格条件ではなく、「拾えない量」を見るための診断用で、注記にも「凍結対象外」とあります。なので回帰テストは赤くなりません。
ただ、診断の数字としては、この 8 件ぶん甘く出ます。今日は直さず、事実だけを書いておきます。

名前どおりでない所 — w-shingling#

w-shingling と Jaccard 係数の使い方は、ほぼ名前どおりです。違うのは三つです。

一つ目、窓は単語ではなく文字です。日本語を形態素に割らずに照合しているので(T04)、写しの検出も同じ文字の単位にしました。3 文字にした理由は、ソースにも文書にも残っていません。同じ道具の別の所(焦点の文がそのまま次の入力に写されたかを見る所)では、「助詞込みで 3 文字そろう機会が少ない」として 2 文字の窓を使い、Jaccard ではなく焦点の側だけを分母にした割合を使っています。何を見つけたいかで、窓の長さと分母を変えている、ということです。
二つ目、スケッチを取りません。文の数が数千で、総当たりでも 1 秒で終わるので、見積もりではなく正確な Jaccard を出しています。
三つ目、containment を見ていません。短い問いが長い辞書の文に丸ごと入っていても、Jaccard は低く出ます。上の「昨夜の行動は」のような形です。

検出しているものの名前(train-test contamination)は、言語モデルの学習データの汚染の文脈で使われることが多い語です。うちの「学習」は辞書の手書きで、評価の文が辞書へ漏れるのを止めている、という意味で同じ形です。

弱点と、採らなかった案 — w-shingling#

  • 言い換えた写しは見えません。検査の文を読んでから、言い方を少し変えて辞書に足せば、L18 は黙ります。これは仕組みでは止められず、「検査の文を見ずに辞書を書く」「見たら記録する」(T42)という運用で止めています
  • 短い文は窓が少なく、1 文字で値が大きく動きます。「調書」のような 2 文字の問いは、辞書に同じ 2 文字があれば必ず 1.00 です
  • 逆向きの写し(辞書の文を見て、検査の文を書く)も、同じ値で落ちます。区別はしていません。どちらでも測定にはならないからです
  • 見張る範囲は、--probes に渡したフォルダの名前の規則で決まります。上の抜けはそこから来ています

採らなかった案:

  • min-hash と LSH。規模が小さく、正確な値を毎回出せる。見積もりの誤差を合否に持ち込む理由がない
  • containment での判定。短い問いが辞書の長い文の一部に当たるのは、日本語の短い問いでは普通のことで、線を引くと誤検出が多くなりそうだと判断しました(測ってはいません)
  • 埋め込みの近さでの判定。言い換えの写しは見えるようになりますが、照合そのものが埋め込みで当てているので、「照合で当たるほど近い文」を全部落とすことになり、検査の文が作れなくなります

検索語#

w-shingling Jaccard Broder 1997
MinHash resemblance containment near-duplicate detection
benchmark contamination n-gram overlap
train-test contamination detection

『霧走町 流氷に眠る』の第一章は、ブラウザで無料で遊べます。辞書に無い言い方で、どこまで届くか試してみてください。プロフィールのリンクから。

#claude #自然言語処理