問いを二つ続けて入力すると、船長は「どちらを先に」と聞き返す
Type two questions in a row and the captain asks "which one first?"
D06-4-T12-split-into-sentences・技術・日本語入力でプレイする仕組み
港の船長に「昨日の夜はどこにいましたか。組合長と何を揉めていたんですか」と入力すると、どちらの話を先に確かめるかを選ばせます。
句点を読点に替えて一続きにすると、居場所の証言だけが返り、二つ目の問いは黙って消えます。
この回は、入力をどこで割り、割った後に何をしているかを書きます。
この回の位置と、前後の回#
区切り: T03〜T24 日本語入力を解いて返すまで(22 回のうち 10 回目)
先に読むとよい回: T08(二つの点を混ぜる)
次に読む回: T13(否定の掛かり先)
名前 — clause identification#
正式名(三つ。どれも名前どおりではないので、後で違いを書きます)
- clause identification(節の同定)。初出の評価: Tjong Kim Sang & Déjean, "Introduction to the CoNLL-2001 Shared Task: Clause Identification"(CoNLL-2001)
- sentence boundary detection(文境界の検出。sentence boundary disambiguation、sentence segmentation とも)。代表的な論文: Palmer & Hearst, "Adaptive Multilingual Sentence Boundary Disambiguation"(Computational Linguistics, 1997。前身は 1994 年の ANLP)
- multiple intent detection(一つの発話に複数の意図があるときの検出。multi-intent detection とも)。例: Gangadharaiah & Narayanaswamy, "Joint Multiple Intent Detection and Slot Labeling for Goal-Oriented Dialog"(NAACL 2019)
日本語: 節の切り出し、文分割、複数意図の検出
三つの名前の、ふつうの意味#
節の同定(clause identification)とは、文の中の節(主語と述語を持つ単位)の始まりと終わりを見つける仕事です。
CoNLL-2001 の共通課題では、Penn Treebank の英文に節の括弧を付けるのが課題でした。
節は入れ子になります。
「彼が来たと言った」の中には「彼が来た」という節が埋まっています。
だから単純な区切りではなく、括弧の構造を当てる問題です。
評価は、節の始まり、節の終わり、節の括弧全体の 3 つを当てる部分課題に分け、F 値で比べています。
文境界の検出は、もっと手前の仕事です。
英語ではピリオドが文末か略語(Mr. や U.S.)かが曖昧なので、ここに研究がありました。
Palmer & Hearst は、句点の前後の語の品詞を推して、句点が文末かを分類します。
日本語の句点「。」は略語に使わないので、英語ほど難しくはありません。
難しいのは、句点を付けない文、括弧の中の句点、改行で区切った文です。
複数意図の検出は、対話システムの言葉です。
「明日の東京の天気と、ついでにアラームを 7 時に」のように、一つの発話に二つの依頼が入る場合です。
意図の分類を多ラベルにして、どの語がどの意図に属するかも同時に当てる、という作り方が主流です。
三つに共通する弱点は、日本語の読点です。
読点は節の境目にも、ただの息継ぎにも、列挙にも置かれます。
「昨日の夜はどこにいましたか、それと組合長と…」の読点は問いの区切りですが、「網の修理、大変ですね」の読点は区切りではありません。
記号だけでは決まりません。
うちは二回割っている#
入力は、別々の目的で二回割っています。
二つの規則は違います。
一回目は、入力の解析(AdvInput.hpp)での「文」です。
型の名前は InputClause ですが、中身は文末の記号で区切った文です。
// ---- 文 ------------------------------------------------------------------
{
const auto Ends = DecodeAll(L.sentenceEnds);
std::size_t Start = 0; // S の添字
auto Flush = [&](std::size_t EndIdx)
{
// 先頭と末尾の空白・文末記号を除いた区間
std::size_t a = Start, b = EndIdx;
while (a < b && (S[a].c == U' ' || S[a].c == 0x3000 || S[a].c == U'\n' || S[a].c == U'\r')) { ++a; }
while (b > a && (S[b - 1].c == U' ' || S[b - 1].c == 0x3000 || S[b - 1].c == U'\r')) { --b; }
if (a < b) { InputClause C; C.source = { S[a].b, S[b - 1].e }; A.clauses.push_back(C); }
};
for (std::size_t i = 0; i < S.size(); ++i)
{
if (InQuote(S[i].b)) { continue; }
std::size_t Len = 0;
if (StartsAny(S, i, Ends, &Len))
{
Flush(i + Len);
Start = i + Len;
i += Len - 1;
}
}
Flush(S.size());
}
文末の記号は共通の言語資源から読みます。
"sentenceEnds": ["。", "!", "?", "!", "?", "\n"],
読点は入りません。
引用(「」『』“”)の中の句点では割りません。
閉じない括弧は引用として扱わないので、その中の句点では割れます。
区間は原文の byte の位置で持ち、文末の記号を含みます(「昨日の夜はどこにいましたか。」)。
文ごとに、質問の手掛かりと主張の手掛かりを見ます。
質問は「?」「どこ」「いつ」「誰」「ですか」「教えて」などの語(引用の外にあるもの)と、文末の「か」「かな」「かね」「かい」。
15 文字までの短い文が「は」「って」で終われば、話題だけを挙げた問いと見なします(「担当者の名前は」)。
「誰か」「何も」のような疑問詞を含むだけの語は除きます。
「〜のではないでしょうか」のような念押しで終わる文は、質問にしません。
主張は「根拠は」「決め手は」「間違いない」などの語です。
二回目は、照合(AdvMatcher.hpp)での「節」です。
埋め込みを、入力全体に加えて、句読点で割った各片でも引き、言い換えとのコサインの最大を採ります。
static std::vector<std::string> SplitSegments(const std::string& Utf8)
{
static const char* Seps[] = { "。", ".", "!", "?", "、", "," };
std::vector<std::string> Out;
std::string Cur;
for (size_t i = 0; i < Utf8.size();)
{
const unsigned char C = (unsigned char)Utf8[i];
int N = 1;
if (C >= 0xF0) { N = 4; }
else if (C >= 0xE0) { N = 3; }
else if (C >= 0xC0) { N = 2; }
if (i + N > Utf8.size()) { break; }
const std::string Ch = Utf8.substr(i, N);
bool bSep = (Ch == "." || Ch == "!" || Ch == "?" || Ch == ",");
for (const char* S : Seps) { if (Ch == S) { bSep = true; break; } }
if (bSep)
{
if (DecodeUtf8ToU32(Cur).size() >= 4) { Out.push_back(Cur); }
Cur.clear();
}
else
{
Cur += Ch;
}
i += N;
}
if (DecodeUtf8ToU32(Cur).size() >= 4) { Out.push_back(Cur); }
// 全体と同じものは除く(呼び出し側で別途引いているため)
std::vector<std::string> Filtered;
for (const std::string& S : Out) { if (S != Utf8) { Filtered.push_back(S); } }
return Filtered;
}
こちらは読点でも割ります。
4 文字未満の片は捨てます。
改行と引用は見ません。
割った片は、bake のときに検査入力の分も焼いておき、キャッシュに無い片は実行時に計算します。
なぜ片ごとに引くのか。
ソースの注記の例は「佐伯が古い話を認めました。当時の記録を出してください」です。
埋め込みは文全体を平均するので、前置きに引っ張られて話題を取り違えます。
後ろの片だけを引けば、「記録を出して」の言い換えに近くなります。
片の最大を採るのは、どの片が本題かを決めずに済ませるためです。
表層の tf–idf のコサインは、割らずに入力全体で取ります。
表層と埋め込みを混ぜた値と表層だけの値の良い方を採る、という式(T08 の回)の中で、片ごとの最大は埋め込みの側だけに効きます。
文が二つ以上あるときの選び方#
文の区切りは、問いが二つ並んだときに効きます。
入力を処理する順は、応答契約 → 未解決の指示語 → 聞き返しへの返事 → 直前の話題への一言 → 日時の確認 → 複数の問い → 照合、です。
複数の問いの所を写します(AdvSession.hpp の tryMultiQuestion)。
bool tryMultiQuestion(const std::string& input, AdvTurn& Turn)
{
const InputAnalysis& A = CurAnalysis.analysis;
if (A.clauses.size() < 2) { return false; }
std::vector<PendingClarification::Option> Opts;
int Seen = 0;
for (const InputClause& Cl : A.clauses)
{
if (++Seen > 4) { break; }
if (!Cl.explicitQuestion || Cl.explicitAssertion) { continue; }
const std::string Text = A.slice(Cl.source);
const MatchResult Rc = Matcher.query(Text, Engine.state, Engine.data, Th, &PlayedStages);
if (Rc.Kind != MatchResult::EKind::Accepted || !Rc.IntentPtr || Rc.IntentPtr->bSmalltalk || Rc.Stage < 0) { continue; }
bool bWouldProgress = false;
for (const std::string& F : Rc.IntentPtr->stages[Rc.Stage].sets)
{
if (!Engine.state.has(F)) { bWouldProgress = true; break; }
}
if (!bWouldProgress) { continue; }
bool bDup = false;
for (const auto& O : Opts) { if (O.intentId == Rc.IntentPtr->id) { bDup = true; break; } }
if (bDup) { continue; }
Opts.push_back({ truncateQuote(Text), Text, Rc.IntentPtr->id, std::string() });
}
if (Opts.size() < 2) { return false; }
// 全文としては1つの推理の主張が成り立っているなら、問いの列ではない
// (この後、全文の照合で主張が成立する候補があれば false を返す所と、why の記録は省略)
presentChoice("questions", input, Opts, Turn);
return true;
}
条件を並べ直します。
- 文は先頭から 4 つまで見る。
- 質問の手掛かりがあり、主張の手掛かりが無い文だけ。
- その文だけで照合して採用され、雑談ではなく、段が決まる。
- その段が、まだ立っていないフラグを立てる(話を進める)。
- 同じ意図に着地した文は 1 つにまとめる。
- 残った候補が 2 つ以上なら、選ばせる。1 つ以下なら、全文で照合し直す。
選択肢の文は原文の区間から切り出し、40 文字を超えたら「…」で切ります。
番号で答えると、その文だけを評価して進みます。
同じ話題の言い足しや、もう聞いた話への問いは、選ばせずに全文で受けます。
確認は「黙って片方を捨てると困るときだけ」足す、という決まりです。
今日の実測です(2026-10-29 時点)。
源三から揉め事を聞いた後、港で佐伯に「昨日の夜はどこにいましたか。組合長と何を揉めていたんですか」。
佐伯「どちらの話を先に確かめますか。一つずつ挙げてください」
1: 『昨日の夜はどこにいましたか。』
2: 『組合長と何を揉めていたんですか』
「2」と入力すると、揉め事の段に進みました。
同じ二つの問いを、揉め事を聞く前に入力すると、選ばせずに居場所の証言(0.969)が返りました。
揉め事の段は、その時点ではまだ開いていないので、二つ目の文は「話を進める問い」に数えられません。
前置きつきの「網の修理、大変ですね。昨日の夜はどこにいましたか」も、1 つ目の文が問いではないので、全文で居場所の証言(0.961)です。
名前どおりでない所 — clause identification#
三つとも、名前どおりではありません。
- 節の同定ではありません。構文を見ず、入れ子も扱いません。InputClause という型の名前は、中身からすると「文」と呼ぶべきものです。
- 文境界の検出としては、記号の一覧で切る規則です。曖昧な記号を分類する所はありません。日本語の句点は略語に使わないので、それで足りる範囲で作っています。
- 照合の SplitSegments は、文でも節でもなく「句読点で切った片」です。目的は区切ることではなく、埋め込みの平均が前置きに薄められるのを避けることです。
- 複数意図の検出としては、多ラベルの分類ではなく、文ごとに 1 つずつ分類して、意図が 2 つ以上出たら選ばせる形です。一つの文の中に二つの依頼がある場合は見ません。
近い名前はあるけれど、どれでもない。
限定した表層規則として読んでください。
弱点と、採らなかった案 — clause identification#
今日入力して出た弱点は、冒頭の読点の版です。
「昨日の夜はどこにいましたか、組合長と何を揉めていたんですか」→ 文は 1 つ。居場所の証言(0.969)だけが返る。
一回目の区切りが読点で割らないので、問いは一つと見なされます。
照合の側は読点で割って片ごとに引きますが、こちらは最大の 1 つを採るだけで、二つ目の問いを覚えておきません。
プレイヤーから見ると、二つ聞いたのに一つしか答えが来ない。
しかも、答えなかったことをゲームは言いません。
採らなかった案です。
- 一回目の区切りにも読点を入れる。「網の修理、大変ですね」も二つの文に割れます。片方が問いでなければ選択は出ないので害は小さいのですが、主張の判定(T13 の回)は文の中で語の並びを見るので、読点で割ると「事故ではなく、殺された」のような一つの主張が二つに割れます。
- 二つ目の問いを覚えておき、一つ目に答えた後に「もう一つの話は」と促す。会話としては自然ですが、促す台詞は作品の人物の言い方で書く必要があり、エンジンの中立な文では浮きます。
- 二つとも続けて答える。一手で二つの段を進めると、手帳に二行入り、フラグが二つ立ちます。どちらかが前提の段を飛ばすと、推理の順序が崩れます。採りません。
- 構文解析で節を取る。形態素解析を入れない方針(T04 の回)と同じ理由で採っていません。
もう一つ、ソースを読んで気づいた小さな差です。
SplitSegments の上の注記は「build_vectors.py の segments_of と同じ規則にすること」と書いていますが、Python の版は片の前後の空白を落とし、C++ の版は落としません。
いまは bake も C++ で焼くので照合には効かないと読んでいますが、確かめてはいません。
割り方は二つ、目的も二つです。
同じ「節」という言葉で呼んでいたせいで、違う規則だと気づくのが遅れました。
検索語#
clause identification CoNLL-2001
sentence boundary disambiguation
multiple intent detection
multi-intent spoken language understanding
二つの問いを、句点で区切って一度に入力してみてください。船長がどちらを先に答えるかを聞いてきます。『霧走町 流氷に眠る』の第一章はブラウザで、プロフィールのリンクから遊べます。