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

固有名詞は辞書で引く。「はるばる」が女将を呼んだ

Proper nouns come from a dictionary: how "harubaru" summoned the landlady

 D05-4-T10-gazetteer-entities・技術・日本語入力でプレイする仕組み

入力を解いて返すgazetteerdictionary-based named entity recognitionAho–Corasick algorithmclaude自然言語処理

港の船長に「はるばる札幌から来ました」と入力すると、船長が「番屋を出た時刻の食い違い、そういう話か?」と聞き返してきます。
「はるばる」の「はる」が、食堂の女将ハルの名前として辞書に当たったからです。
この回は、その辞書の作りと、名前が当たったときの加点の決まりを書きます。

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

区切り: T03〜T24 日本語入力を解いて返すまで(22 回のうち 8 回目)
先に読むとよい回: T03(文字の正規化)・T09(RRF を実装して測り負けた話)
次に読む回: T11(日付・時刻・時間帯)

名前 — gazetteer#

正式名
gazetteer(ガゼッティア)。辞書で引く固有表現抽出は dictionary-based named entity recognition とも呼ぶ
略称
なし(NER は named entity recognition の略)
日本語
固有表現辞書、地名辞典(もとの意味)
初出
語としての初出は確かめられませんでした。NER で gazetteer の要否を正面から測った古典に Mikheev, Moens, Grover, "Named Entity Recognition without Gazetteers"(EACL 1999)があり、ニューラル NER に辞書を足す近年の例に Song, Lawrie, Finin, Mayfield, "Improving Neural Named Entity Recognition with Gazetteers"(arXiv 2020)があります。辞書の語を一度に引く定番のアルゴリズムは Aho & Corasick, "Efficient String Matching: An Aid to Bibliographic Search"(CACM 1975)です。

gazetteer とは#

もとは地名辞典のことです。
NER では、人名・地名・組織名などの「名前の一覧」を指します。
文の中で一覧の語が現れた所を拾い、その語の種類(人・場所・組織)を付けます。

ふつうの使われ方は二つあります。

一つは、辞書の一致そのものを答えにするやり方です。
語の一覧を Aho–Corasick のような多パターン照合の機械にして、文を 1 回なめて全部の出現を取ります。
重なりは「最長一致」で解くのが普通です。
「岬の高台」と「岬」が両方当たったら、長い方を採ります。

もう一つは、辞書の一致を特徴量にして、統計モデルに渡すやり方です。
CRF の時代には「この語は人名辞書にある」が強い特徴でした。
ニューラルの時代にも、辞書の一致を入力に足すと、辞書にある珍しい名前で効く、と報告されています(上の 2020 年の論文)。

よく知られた弱点は四つです。

  • 曖昧さ。同じ綴りが人名にも地名にもなる。英語なら Washington、日本語なら「岬」。
  • 網羅。辞書に無い名前は一つも取れない。別名・略称・誤記を全部書くことはできない。
  • 短い語の誤爆。2 文字の名前は、普通の語の中に部分として現れる。
  • 文脈を見ない。「〜さん、」と呼びかけた名前も、話題にした名前も、同じ一致になる。

1999 年の Mikheev らは、MUC-7 のデータで辞書の大きさと種類を変えて測り、「よく知られた名前の小さな辞書」でもほぼ同じくらい取れる、と書いています。
大きな辞書を足すより、文脈の規則の方が効く場面がある、という結果です。

うちの辞書の形#

作品フォルダの entities.json です。
第一章の人物・場所・一般語から、4 件だけ写します。

{ "id": "hal", "aliases": ["ハル", "女将", "おかみ", "ハルさん"], "person": true },
{ "id": "domoto", "aliases": ["堂本", "巡査", "駐在", "どうもと"], "person": true },
{ "id": "banya", "aliases": ["番屋", "食堂", "店"] },
{ "id": "alibi", "aliases": ["アリバイ", "昨日の夜", "昨日の晩", "昨晩", "夕べ", "事件の夜", "あの晩", "どこにいた", "居場所", "所在", "足取り", …] }

今日数えると、対象は 61、別名は 453 です(2026-10-29 時点)。
person(人物)が 11、proper(聞いて初めて知る名前)が 12、revealedBy(このフラグが立つまで世界がその名を口にしてはいけない)が 2。
意図 248 のうち 155 が、自分に関係する対象を entities で持っています。

人物の別名に肩書き(女将・巡査・駐在)を入れているのは、刑事が名前より肩書きで呼ぶことが多いからです。
alibi は名前ですらありません。
「あの晩」「どこにいた」という、居場所を聞くときの言い回しを一つの対象にまとめています。

引き方#

起動時に、別名を全部 norm/1 で正規化して持ちます(正規化の中身は T03 の回)。AdvMatcher.hpp の build から写します。

EntityNorm.clear();
for (const auto& [Id, Aliases] : InEntities.aliases)
{
for (const std::string& A : Aliases)
{
EntityNorm[Id].push_back(NormalizeText(A));
}
}

照合のときは、入力も同じ正規化をかけ、別名が部分列として含まれるかを見ます。

static bool ContainsSub(const std::vector<char32_t>& Hay, const std::vector<char32_t>& Needle)
{
if (Needle.size() > Hay.size()) { return false; }
for (size_t i = 0; i + Needle.size() <= Hay.size(); ++i)
{
bool bOk = true;
for (size_t j = 0; j < Needle.size(); ++j)
{
if (Hay[i + j] != Needle[j]) { bOk = false; break; }
}
if (bOk) { return true; }
}
return false;
}

Aho–Corasick ではなく、素朴な二重ループです。
入力は 1 文、別名は 453 なので、1 手あたりの比較はたかが知れています。
正規化のおかげで、カタカナとひらがな、全角と半角、長音の有無は同じ語になります。
「ハールさんの店にいたのか」は「はるさんの店にいたのか」になり、hal と banya に当たりました。

入力の解析(AdvInput.hpp)も同じ辞書を引きますが、こちらは出現の位置を全部返します。
「岬の高台には誰がいましたか」は、hilltop(岬の高台)、misaki(岬)、hilltop(高台)の 3 件が重なったまま出ます。
最長一致で解いていません。
後段の主張の判定(T13 の回)が、この位置を使います。

加点の式#

意図ごとの加点は、その意図が持つ対象のうち、入力に当たったものの数で決まります。

auto BonusFor = [&](const Intent& It) -> double
{
int Hits = 0;
for (const std::string& Ent : It.entities)
{
const auto Found = EntityNorm.find(Ent);
if (Found == EntityNorm.end()) { continue; }
for (const std::vector<char32_t>& Alias : Found->second)
{
if (!Alias.empty() && ContainsSub(Norm, Alias)) { ++Hits; break; }
}
if (Hits >= 2) { break; }
}
return Th.entityBonus * Hits;
};

entityBonus は 0.15。
一つの対象は、別名がいくつ当たっても 1 回しか数えません(break)。
対象は 2 つまでで打ち切るので、加点の上限は 0.30 です。
並びは意図の entities に書いた順で、3 つ目以降は見ません。

意図の点は、表層と埋め込みを混ぜた値(0〜1)にこの加点を足したものです。
だから 1 を超えます。
駐在所で「検分の結果を教えてください」と入力すると、言い換えとほぼ同じ文なので混ぜた値は 1 に近く、検分の加点が乗って 1.150 で採用されました。

加点はもう一つ、段の選択に使います。
同じ話題の中で、「材料を挙げたときだけ入れる段」があります(段の話は T19 の回)。
港で佐伯に「2月15日の夜はどこにいましたか」と入力すると、ログにはこう出ます。

stage 突きつける材料(alibi)を挙げたので通した

点に足す加点と、段を開ける材料の判定は、同じ hasEntity の一致です。

既読の段は、加点で追い越せない#

加点には副作用があります。
同じ名前を何度も入力すると、一度聞いた話が加点で勝ち続けます。
そこで、既に再生した段に着地する候補からは、加点を打ち消します。

Cd.bSeen = ResolvesToSeenStage(*It);
if (Cd.bSeen) { Cd.RepeatPenalty = std::min(Th.repeatMargin, Cd.Bonus); Cd.Penalty += Cd.RepeatPenalty; }
Cd.Score = Cd.Cos + Cd.Bonus - Cd.Penalty;

repeatMargin は 0.3、つまり加点 2 つぶんです。
一律に引くのではなく、加点の分だけを戻すのが要点です。
意味(混ぜた値)で勝っている既読の候補は、そのまま勝ちます。
一律に 0.15 を引いたら、言い直した人に別の話を返す事故が出た、と設定ファイルの注記に残っています。

今日のログです。
港で佐伯に「あの晩、番屋にいたんですか」と入力すると、1 回目は「あの晩」と「番屋」が当たって加点 0.30、混ぜた値 0.500 と合わせて 0.800 で採用されます。
同じ文をもう一度入力すると、その候補は既読なので 0.300 が打ち消されて 0.500。
加点 0.15 の別の候補が 0.604 で上に来て、そちらが返りました。

この打ち消しは順位にだけ効き、「答えてよいか」の判定には効きません(順位と判定を分けた話は T09 の回)。
競う相手がいなければ、二度目でも言い直しを返します。

呼びかけと、目的語の例外#

名前の一致を、告発の名指しを数えるのにも使っています。
推理の段で別人の名前を挙げた見立ては、候補から外します。
ここに例外が二つあります。

一つは呼びかけです。
「堂本さん、これは殺しだ」の「堂本」は聞き手で、堂本を犯人と言っているのではありません。
正規化の norm/1 は読点を落とすので、この判定にだけ読点を残した列を作り、文頭が「〈名前〉さん/くん+読点」で、後ろに同じ名が出てこなければ呼びかけと見なします。
もう一つは目的語です。
「を殺/を襲/を殴」の前にだけ出てくる名前は、告発の相手に数えません。
どちらも、一般の構文解析ではない、限定した表層規則です。

名前どおりでない所 — gazetteer#

gazetteer の変形です。三つ違います。

  • 辞書に名前でない語が入っている。alibi(「あの晩」「どこにいた」)や、揉め事の言い回しの一覧も同じ entities.json にあります。gazetteer というより、キーワード辞書や概念辞書と呼ぶ方が近い部分です。
  • 出力が「種類つきの区間」ではなく「点への加点」と「段の材料」。NER の答えとして名前の区間を返すのが目的ではありません。
  • 最長一致で重なりを解いていない。加点は対象ごとに 1 回なので困らないのですが、解析の区間は重なったまま後段へ行きます。

revealedBy と proper(先出しの見張り)は、うちの制作上の印で、gazetteer とは関係ありません。

弱点と、採らなかった案 — gazetteer#

今日入力して出た弱点が二つあります。

一つ目は、冒頭の「はるばる」です。
女将の別名「ハル」は正規化で「はる」になり、「はるばる」の中に部分として現れます。
加点 0.15 で、佐伯の「番屋を出た時刻」の意図が 0.295 まで上がり、聞き返しの帯(0.27〜0.33)に入りました。
採用はされず、聞き返しで止まります。
ただ、刑事が自己紹介をしただけで、船長が時刻の食い違いの話を持ち出すのは変です。
2 文字の別名の誤爆は、辞書引きの教科書どおりの弱点です。

二つ目は、呼びかけです。
「堂本さん、これは殺しだ」と入力すると、今日は雑談(堂本の勤務の話)が 0.532 で勝ちました。
「これは殺しだ」だけなら、事故の見立てを押す意図が 0.373 で採用されます。
呼びかけの例外は名指しの数え方にしか効かず、埋め込みの入力からは「堂本さん」を外していません。
人名を含む文の埋め込みが、その人の身の上話の言い換えへ寄ったのだと読んでいます。

採らなかった案です。

  • 別名を全部、語の境界で切ってから当てる。日本語には空白が無いので、境界を知るには形態素解析が要ります。照合で形態素解析をしない方針(T04 の回)と衝突します。
  • 2 文字の別名を外す。「ハル」「佐伯」「番屋」「岬」が消えます。人名は 2 文字が普通です。
  • 誤爆する語を除外の一覧にする(「はるばる」「はるか」など)。一つ見つけるたびに足す形になり、足し忘れが起きます。今のところ聞き返しで止まっているので、入れていません。
  • 呼びかけを埋め込みの入力から切り取る。切り取り方を誤ると、名指しの推理まで名前を失います。今日の誤着地を見て、次に直すならここだと考えています。

辞書は書いたとおりにしか当たりません。
当たった理由が必ずログに出るのが、辞書のいちばんの取り柄です。

検索語#

gazetteer named entity recognition
dictionary-based NER
Named Entity Recognition without Gazetteers
Aho–Corasick algorithm

港で「はるばる札幌から来ました」と入力すると、船長が少しずれた聞き返しをします。『霧走町 流氷に眠る』の第一章はブラウザで、プロフィールのリンクから入力できます。

#claude #自然言語処理