fix(engine): 整句联想按 P(读音|字) 压住多音字的生僻读音 (#1123)
tai'zhuan'qian'le 出「大赚钱了」:大 也读 tai,词库里这一行权重 4719,占 tai 的 0.47%,已有的 reading_prior(看 P(字|拼音),1% 免罚线)只扣 0.33,翻不过语言模型 对「大赚」约 1.6 的偏好。换个角度看,大 读 tai 只占它全部权重的 0.14%——这才是 「这个字几乎不这么读」,语言模型只认汉字,这一维要由读音补上。 词格跨度查询对单字行加一项 P(读音|字) 惩罚:占比低于 10% 的部分按 log10 扣分, 上限 2.5。长 chang、重 chong、行 hang 这类常见第二读音不扣;大 tai/dai、会 kuai、 都 du、给 ji 扣 1.5~2。只罚单字,词本身就是读音的证据(会计、成都、大夫)。多音字 各读音的权重和在第一次整句联想时扫一遍单字表载入,全拼与双拼(含直接辅助码)共用。 自建 111 句多音字评测从 79 提到 82,无回归;5% 档翻不过「大赚钱了」,20% 与 10% 结果相同,取保守的一边。test_engine_smoke 加固定权重的断言,句子评测集补两条。
This commit is contained in:
1 parent
dd018f7985
commit
a60e2948db
8 files changed
+172
-7
No files matched your search
@@ -1890,8 +1890,12 @@ void QuanpinDictionary::reset_cache()
|
||||
quanpin::WordLatticeLookup QuanpinDictionary::lattice_lookup(int limit, bool with_fuzzy)
|
||||
{
|
||||
const auto fuzzy = with_fuzzy ? fuzzy_pinyin_ : metasequoia::FuzzyPinyinOptions{};
|
||||
return [this, lookup = quanpin::make_lattice_db_lookup(db_, statement_cache_, limit, fuzzy), limit,
|
||||
fuzzy](const quanpin::Segments &span) {
|
||||
if (!char_reading_totals_)
|
||||
{
|
||||
char_reading_totals_ = quanpin::load_char_reading_totals(db_);
|
||||
}
|
||||
return [this, lookup = quanpin::make_lattice_db_lookup(db_, statement_cache_, limit, fuzzy, char_reading_totals_),
|
||||
limit, fuzzy](const quanpin::Segments &span) {
|
||||
// 超过上限整体清掉,免得一次长时间输入把内存撑大(同双拼的 direct_span_cache_)。
|
||||
constexpr std::size_t kLatticeSpanCacheLimit = 8192;
|
||||
std::string key = quanpin::join_segments(span);
|
||||
|
||||
@@ -204,6 +204,9 @@ class QuanpinDictionary
|
||||
CircularBuffer<std::string, std::optional<quanpin::LatticePath>> lattice_best_path_cache_;
|
||||
// 见 lattice_lookup。
|
||||
std::unordered_map<std::string, std::vector<quanpin::LatticeLexeme>> lattice_span_cache_;
|
||||
// 多音单字各读音的权重和,词格第一次查跨度时载入,此后不随 reset_cache 重载。
|
||||
// 见 quanpin::load_char_reading_totals。
|
||||
std::shared_ptr<const quanpin::CharReadingTotals> char_reading_totals_;
|
||||
sqlite3 *db_ = nullptr;
|
||||
sqlite3_int64 data_version_ = -1;
|
||||
metasequoia::RuntimePaths paths_;
|
||||
|
||||
@@ -1286,10 +1286,96 @@ std::vector<KeyedQueryItem> query_exact_segmentations_keyed_flat(
|
||||
return result;
|
||||
}
|
||||
|
||||
WordLatticeLookup make_lattice_db_lookup(sqlite3 *db, std::unordered_map<std::string, sqlite3_stmt *> &statement_cache,
|
||||
int span_limit, metasequoia::FuzzyPinyinOptions fuzzy)
|
||||
std::shared_ptr<const CharReadingTotals> load_char_reading_totals(sqlite3 *db)
|
||||
{
|
||||
return [db, &statement_cache, span_limit, fuzzy](const Segments &span) {
|
||||
auto totals = std::make_shared<CharReadingTotals>();
|
||||
if (db == nullptr)
|
||||
return totals;
|
||||
|
||||
std::vector<std::string> tables;
|
||||
sqlite3_stmt *statement = nullptr;
|
||||
if (sqlite3_prepare_v2(
|
||||
db, "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'tbl\\_1\\_%' ESCAPE '\\'", -1,
|
||||
&statement, nullptr) != SQLITE_OK)
|
||||
return totals;
|
||||
while (sqlite3_step(statement) == SQLITE_ROW)
|
||||
{
|
||||
const auto *name = reinterpret_cast<const char *>(sqlite3_column_text(statement, 0));
|
||||
if (name != nullptr)
|
||||
tables.emplace_back(name);
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
|
||||
// 先按字累计权重和读音数,再只留多音字:单读音的字占比恒为 1,存着只占内存。
|
||||
std::unordered_map<std::string, std::pair<std::int64_t, int>> sums;
|
||||
for (const auto &table : tables)
|
||||
{
|
||||
const std::string sql = "SELECT value, weight FROM " + table;
|
||||
if (sqlite3_prepare_v2(db, sql.c_str(), -1, &statement, nullptr) != SQLITE_OK)
|
||||
continue;
|
||||
while (sqlite3_step(statement) == SQLITE_ROW)
|
||||
{
|
||||
const auto *value = reinterpret_cast<const char *>(sqlite3_column_text(statement, 0));
|
||||
if (value == nullptr)
|
||||
continue;
|
||||
auto &[sum, readings] = sums[value];
|
||||
sum += (std::max)(sqlite3_column_int64(statement, 1), sqlite3_int64{0});
|
||||
++readings;
|
||||
}
|
||||
sqlite3_finalize(statement);
|
||||
}
|
||||
for (const auto &[value, sum] : sums)
|
||||
{
|
||||
if (sum.second > 1)
|
||||
totals->emplace(value, sum.first);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
// P(读音|字) 的免罚线与扣分上限(log10)。
|
||||
//
|
||||
// 已有的 reading_prior 看的是 P(字|拼音):这一行占该拼音全部行的权重比。它分得出权重为 0 的
|
||||
// 「卷」读 gun,却分不出「大」读 tai——大 的 tai 行有 4719,占 tai 的 0.47%,只扣 0.33,翻不过
|
||||
// 语言模型对「大赚」的偏好(差 1.6 左右),tai'zhuan'qian'le 于是出「大赚钱了」。同一行换个
|
||||
// 角度看,大 的 da 行有 327 万,读 tai 的只占 0.14%:这才是「这个字几乎不这么读」。语言模型
|
||||
// 只认汉字,这一维只能由读音补上(噪声信道里的 P(拼音|字))。
|
||||
//
|
||||
// 免罚线定在 10%:长 chang 42%、重 chong 20%、行 hang 15% 这类常见的第二读音一分不扣,
|
||||
// 觉 jiao、还 huan(8%~9%)只扣零点零几,着 zhao/zhuo、地 di(3%~4%)扣 0.4~0.5。真正的
|
||||
// 生僻读音——大 tai/dai、会 kuai、都 du、给 ji——都在 0.1%~0.3%,扣 1.5~2。了 liao(0.47%)
|
||||
// 扣 1.3,「受不了」「去不了」仍靠语言模型的上下文赢下 料/聊。5% 那一档还差一点翻不过
|
||||
// 「大赚钱了」,20% 在评测句上与 10% 结果相同,取保守的一边。上限只管权重为 0 的读音,那些
|
||||
// reading_prior 已经扣过一大截。
|
||||
//
|
||||
// 只罚单字:多音字的少见读音落在词里(会计、成都、大夫)时整词就是读音的证据。没有同音
|
||||
// 竞争的字(得 只有它一个读 dei)各条路径扣得一样多,排序不受影响。
|
||||
constexpr double kRareCharReadingShare = 0.1;
|
||||
constexpr double kRareCharReadingFloor = 2.5;
|
||||
|
||||
double rare_char_reading_penalty(const LatticeLexeme &lexeme, const CharReadingTotals *totals)
|
||||
{
|
||||
if (totals == nullptr)
|
||||
return 0.0;
|
||||
const auto found = totals->find(lexeme.value);
|
||||
if (found == totals->end() || found->second <= 0)
|
||||
return 0.0;
|
||||
const double weight = lexeme.weight > 0 ? static_cast<double>(lexeme.weight) : 0.0;
|
||||
const double share = (weight + 1.0) / (static_cast<double>(found->second) + 1.0);
|
||||
if (share >= kRareCharReadingShare)
|
||||
return 0.0;
|
||||
return (std::min)(-std::log10(share / kRareCharReadingShare), kRareCharReadingFloor);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
WordLatticeLookup make_lattice_db_lookup(sqlite3 *db, std::unordered_map<std::string, sqlite3_stmt *> &statement_cache,
|
||||
int span_limit, metasequoia::FuzzyPinyinOptions fuzzy,
|
||||
std::shared_ptr<const CharReadingTotals> char_totals)
|
||||
{
|
||||
return [db, &statement_cache, span_limit, fuzzy, char_totals = std::move(char_totals)](const Segments &span) {
|
||||
// ü 的两种写法归一到词库存的那一种。intact_pinyin_list() 两种都认,可词库只存
|
||||
// 一种:切出来的 jv、lue 这类音节一行都查不到,精确键查询查空,整个词格就断在
|
||||
// 这里——wo'men'lue'de 解不出任何路径,长句联想直接消失。这不是模糊音降级,lue
|
||||
@@ -1320,6 +1406,8 @@ WordLatticeLookup make_lattice_db_lookup(sqlite3 *db, std::unordered_map<std::st
|
||||
for (const auto &row : rows)
|
||||
{
|
||||
LatticeLexeme lexeme{row.key, row.value, row.weight};
|
||||
if (span.size() == 1)
|
||||
lexeme.penalty += rare_char_reading_penalty(lexeme, char_totals.get());
|
||||
if (keys.size() > 1)
|
||||
{
|
||||
// 每个与敲出读音不同的音节扣一份分:一个词里错两个音,比只错一个更不可能。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
@@ -85,13 +86,23 @@ std::vector<KeyedQueryItem> query_exact_segmentations_keyed_flat(
|
||||
const std::vector<Segments> &segmentations, sqlite3 *db,
|
||||
std::unordered_map<std::string, sqlite3_stmt *> &statement_cache, int limit = 128);
|
||||
|
||||
// 多音单字在各读音下的权重和(单字 → Σweight),只收词库里挂在两个以上读音下的字。
|
||||
// 词格用它算 P(读音|字),见 make_lattice_db_lookup。
|
||||
using CharReadingTotals = std::unordered_map<std::string, std::int64_t>;
|
||||
// 扫一遍全部单音节表(五万行量级)。词库被学习写入时不必重载:调频改动的权重相对
|
||||
// 出厂权重的量级可以忽略,这里只要分出「大」读 da 还是读 tai 这种数量级上的差别。
|
||||
std::shared_ptr<const CharReadingTotals> load_char_reading_totals(sqlite3 *db);
|
||||
|
||||
// 词格的跨度查询:只返回键与跨度完全相等的行,没有前缀区间与简拼降级——整句
|
||||
// 联想出来的每个词,读音都必须精确对上用户敲的那段拼音。因此也不需要 QuerySource:
|
||||
// 区分全拼/双拼的只有简拼那几级。
|
||||
// 开着模糊音时同时收跨度的模糊变体:行的 key 是词库里的真实读音,每个与敲出读音不同的音节
|
||||
// 在 penalty 上记一份 kFuzzyLatticePenaltyLog。整句的 canonical_pinyin 因此落在真实读音上,
|
||||
// 选中后造词不会把错音写进词库。
|
||||
// 给了 char_totals 时,单字行按这个字少见的读音在 penalty 上扣分(见 quanpin_query.cpp 的
|
||||
// rare_char_reading_penalty)。
|
||||
WordLatticeLookup make_lattice_db_lookup(sqlite3 *db, std::unordered_map<std::string, sqlite3_stmt *> &statement_cache,
|
||||
int span_limit, metasequoia::FuzzyPinyinOptions fuzzy = {});
|
||||
int span_limit, metasequoia::FuzzyPinyinOptions fuzzy = {},
|
||||
std::shared_ptr<const CharReadingTotals> char_totals = nullptr);
|
||||
|
||||
} // namespace quanpin
|
||||
@@ -1205,7 +1205,12 @@ void ShuangpinDictionary::set_fuzzy_pinyin(metasequoia::FuzzyPinyinOptions optio
|
||||
|
||||
quanpin::WordLatticeLookup ShuangpinDictionary::lattice_lookup(int limit)
|
||||
{
|
||||
auto lookup = quanpin::make_lattice_db_lookup(quanpin_db_, quanpin_statement_cache_, limit, fuzzy_pinyin_);
|
||||
if (!char_reading_totals_)
|
||||
{
|
||||
char_reading_totals_ = quanpin::load_char_reading_totals(quanpin_db_);
|
||||
}
|
||||
auto lookup = quanpin::make_lattice_db_lookup(quanpin_db_, quanpin_statement_cache_, limit, fuzzy_pinyin_,
|
||||
char_reading_totals_);
|
||||
if (!direct_span_cache_enabled_)
|
||||
{
|
||||
return lookup;
|
||||
|
||||
@@ -145,6 +145,8 @@ class ShuangpinDictionary
|
||||
bool direct_span_cache_enabled_ = false;
|
||||
metasequoia::FuzzyPinyinOptions fuzzy_pinyin_;
|
||||
std::unordered_map<std::string, std::vector<quanpin::LatticeLexeme>> direct_span_cache_;
|
||||
// 见 QuanpinDictionary::char_reading_totals_。
|
||||
std::shared_ptr<const quanpin::CharReadingTotals> char_reading_totals_;
|
||||
void reset_cache_if_database_changed();
|
||||
// 这个字能不能落在 helpcode 约束的音节上。
|
||||
bool accepts_syllable_char(const SyllableHelpcode &helpcode, const std::string &hanzi) const;
|
||||
|
||||
@@ -30,6 +30,8 @@ xianzaijiuqu 现在就去 plain
|
||||
ganxienidebangzhu 感谢你的帮助 plain
|
||||
anzhaoguiding 按照规定 plain
|
||||
tiananmenguangchang 天安门广场 plain
|
||||
taizhuanqianle 太赚钱了 plain 大 也读 tai,但只占它全部权重的 0.14%;语言模型偏爱「大赚」,靠 P(读音|字) 压回去
|
||||
womendouquguochengdu 我们都去过成都 plain 都 读 du 是生僻读音,「去过程都」靠同一条读音惩罚压回去
|
||||
zaijinuta 再激怒他 plain 贪心切分在 jin 上卡死的合法输入;再/在 两可,只盯切分不回退
|
||||
fangan 方案 word
|
||||
danganguan 档案馆 word
|
||||
|
||||
|
@@ -3,6 +3,7 @@
|
||||
#include "../../english/english_dictionary.h"
|
||||
#include "../../japanese/romaji_converter.h"
|
||||
#include "../../neural/neural_decoder.h"
|
||||
#include "../../quanpin/quanpin_query.h"
|
||||
#include "../../quanpin/word_lattice.h"
|
||||
#include "../../user_dictionary/user_dictionary_journal.h"
|
||||
#include "test_directory_cleanup.h"
|
||||
@@ -88,6 +89,55 @@ int run_test()
|
||||
throw std::runtime_error("Kana conversion changed during the platform port.");
|
||||
}
|
||||
|
||||
{
|
||||
// 多音字的生僻读音:大 读 tai 只占 大 全部权重的 0.14%,单字行要在 penalty 上扣分,
|
||||
// 否则 tai'zhuan'qian'le 会被语言模型带成「大赚钱了」。太/台 只有这一个读音,不扣;
|
||||
// 长 读 chang 占四成,是常见读音,也不扣。权重取自出厂词库。
|
||||
sqlite3 *db = nullptr;
|
||||
if (sqlite3_open(":memory:", &db) != SQLITE_OK)
|
||||
throw std::runtime_error("Failed to open the in-memory dictionary.");
|
||||
const char *fixture = "CREATE TABLE tbl_1_d(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||||
"CREATE TABLE tbl_1_t(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||||
"CREATE TABLE tbl_1_c(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||||
"CREATE TABLE tbl_1_z(key TEXT,jp TEXT,value TEXT,weight INTEGER);"
|
||||
"INSERT INTO tbl_1_d VALUES('da','d','大',3273479),('dai','d','大',4993);"
|
||||
"INSERT INTO tbl_1_t VALUES('tai','t','太',768757),('tai','t','台',51622),"
|
||||
"('tai','t','大',4719);"
|
||||
"INSERT INTO tbl_1_c VALUES('chang','c','长',340269);"
|
||||
"INSERT INTO tbl_1_z VALUES('zhang','z','长',462258);";
|
||||
if (sqlite3_exec(db, fixture, nullptr, nullptr, nullptr) != SQLITE_OK)
|
||||
throw std::runtime_error("Failed to build the polyphone fixture.");
|
||||
|
||||
const auto totals = quanpin::load_char_reading_totals(db);
|
||||
if (totals->size() != 2 || totals->count("大") != 1 || totals->count("长") != 1)
|
||||
throw std::runtime_error("Reading totals must keep exactly the polyphonic characters.");
|
||||
|
||||
std::unordered_map<std::string, sqlite3_stmt *> statements;
|
||||
const auto penalty_of = [&](const std::string &syllable, const std::string &word) {
|
||||
const auto lookup = quanpin::make_lattice_db_lookup(db, statements, 32, {}, totals);
|
||||
for (const auto &row : lookup({syllable}))
|
||||
{
|
||||
if (row.value == word)
|
||||
return row.penalty;
|
||||
}
|
||||
throw std::runtime_error("Polyphone fixture row missing: " + word);
|
||||
};
|
||||
const double rare = penalty_of("tai", "大");
|
||||
if (rare < 1.5 || penalty_of("tai", "太") != 0.0 || penalty_of("tai", "台") != 0.0 ||
|
||||
penalty_of("chang", "长") != 0.0 || penalty_of("da", "大") != 0.0)
|
||||
throw std::runtime_error("Rare polyphone readings must be penalized, common ones must not: " +
|
||||
std::to_string(rare));
|
||||
// 不给 totals 时保持旧行为,一分不扣。
|
||||
for (const auto &row : quanpin::make_lattice_db_lookup(db, statements, 32)({"tai"}))
|
||||
{
|
||||
if (row.penalty != 0.0)
|
||||
throw std::runtime_error("Lookup without reading totals must not penalize.");
|
||||
}
|
||||
for (auto &[sql, statement] : statements)
|
||||
sqlite3_finalize(statement);
|
||||
sqlite3_close(db);
|
||||
}
|
||||
|
||||
{
|
||||
// Neural-only mode still asks the lattice for twelve internal alternatives, but it must not
|
||||
// expose the lattice's own pick while the asynchronous neural result is pending.
|
||||
|
||||
Reference in new issue
Block a user