Skip to content

Commit 3eba7dd

Browse files
tokuhiromclaude
andcommitted
1語候補の未知bigramペナルティ緩和をリランキングに追加
辞書登録された複合語(意思決定、南極観測船等)を1語で変換する場合、 BOS/EOS との bigram が必ず未知になりデフォルトエッジコストが2回加算 されるため、分割候補に対して構造的に不利だった。 リランキング段階で token_count==1 && unknown_bigram_count==2 の候補に 対して unknown_bigram_weight を single_token_unk_discount (デフォルト0.5) で割り引くことで改善。 評価結果: 再現率 93.27% → 93.33% (+0.06pt, Bad 3930→3910) 改善: 犬死、新潮新書、南極観測船、派生品、等閑視 等が1位に昇格 退行: いしのそつう(意志の疎通→石野疎通)1件 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6ce304f commit 3eba7dd

5 files changed

Lines changed: 221 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

akaza-data/src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,9 @@ struct CheckArgs {
245245
/// リランキング: skip-bigram コストの重み
246246
#[arg(long, default_value_t = 0.2)]
247247
skip_bigram_weight: f32,
248+
/// リランキング: 1語候補の未知 bigram 割引率
249+
#[arg(long, default_value_t = 0.5)]
250+
single_token_unk_discount: f32,
248251
}
249252

250253
/// 変換精度を評価する
@@ -273,6 +276,9 @@ struct EvaluateArgs {
273276
/// リランキング: skip-bigram コストの重み
274277
#[arg(long, default_value_t = 0.2)]
275278
skip_bigram_weight: f32,
279+
/// リランキング: 1語候補の未知 bigram 割引率
280+
#[arg(long, default_value_t = 0.5)]
281+
single_token_unk_discount: f32,
276282
}
277283

278284
/// インクリメンタル変換のベンチマーク
@@ -416,6 +422,7 @@ fn main() -> anyhow::Result<()> {
416422
length_weight: opt.length_weight,
417423
unknown_bigram_weight: opt.unknown_bigram_weight,
418424
skip_bigram_weight: opt.skip_bigram_weight,
425+
single_token_unk_discount: opt.single_token_unk_discount,
419426
},
420427
}),
421428
Commands::Evaluate(opt) => evaluate(
@@ -429,6 +436,7 @@ fn main() -> anyhow::Result<()> {
429436
length_weight: opt.length_weight,
430437
unknown_bigram_weight: opt.unknown_bigram_weight,
431438
skip_bigram_weight: opt.skip_bigram_weight,
439+
single_token_unk_discount: opt.single_token_unk_discount,
432440
},
433441
),
434442
Commands::Bench(opt) => bench(BenchOptions {

default-model/evaluate-history.tsv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ datetime commit branch corpus_stats good top5 bad recall
1212
2026-02-24 12:23 23307d65 main v2026.0216.0 6776 343 3946 93.26708
1313
2026-02-24 16:30 7c93f16d main v2026.0216.0 6780 343 3942 93.27101
1414
2026-02-24 17:05 7c93f16d fix/suki-bos-overfit v2026.0216.0 6784 343 3938 93.29374
15+
2026-02-27 01:27 6ce304fd fix/ishi-bos-bigram v2026.0216.0 6805 350 3910 93.33365
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# 1語候補の未知 bigram ペナルティ緩和
2+
3+
## 背景
4+
5+
辞書に登録された複合語(例: 「意思決定」「サイドバー」)を1語として変換する場合、
6+
BOS/EOS との bigram が必ず未知(unk_bi)になり、デフォルトエッジコスト(約14.3)が
7+
2回加算される。このため、分割された候補(例: 「医師/決定」)に対して構造的に不利になる。
8+
9+
### 具体例: 「いしけってい」
10+
11+
```
12+
[1] 医師/決定 (viterbi: 19.1, unk_bi: 0.0, tokens: 2)
13+
[2] 意思決定 (viterbi: 28.4, unk_bi: 28.7, tokens: 1)
14+
```
15+
16+
「意思決定」のコスト内訳:
17+
```
18+
BOS →[default: 14.3]→ 意思決定 →[default: 14.3]→ EOS
19+
Viterbi = 14.3 + (-0.25) + 14.3 = 28.35
20+
^^^^ ^^^^
21+
BOS との unk_bi EOS との unk_bi
22+
```
23+
24+
「医師/決定」は語同士の既知 bigram でエッジコストが下がるため、
25+
unigram コストが高くても合計で勝ってしまう。
26+
27+
## 問題の本質
28+
29+
- BOS/EOS ノードは `word_id_and_score = None` のため、どの語との bigram も必ずデフォルトコストになる
30+
- 1語候補は BOS と EOS の両方に接するので、unk_bi ペナルティを必ず2回受ける
31+
- 2語以上の候補は語同士の既知 bigram でコストを下げられるが、1語候補にはその機会がない
32+
- コーパスで bigram を学習しても BOS/EOS との組み合わせは学習されにくい
33+
34+
## 提案: リランキングでの1語候補 unk_bi 割引
35+
36+
### 方針
37+
38+
Viterbi 本体には触れず、リランキング段階で1語候補の unk_bi ペナルティを割り引く。
39+
40+
### 判定条件
41+
42+
- `token_count == 1`(BOS/EOS を除くトークンが1つ)
43+
- `unknown_bigram_count == 2`(BOS→語、語→EOS の2回が未知)
44+
45+
この条件を満たす候補は「辞書に登録された複合語を1語で変換しようとしている」ケースに限定される。
46+
47+
### 実装案
48+
49+
`libakaza/src/graph/reranking.rs` を変更:
50+
51+
```rust
52+
pub struct ReRankingWeights {
53+
pub bigram_weight: f32,
54+
pub length_weight: f32,
55+
pub unknown_bigram_weight: f32,
56+
pub skip_bigram_weight: f32,
57+
pub single_token_unk_discount: f32, // 新規: デフォルト 0.5
58+
}
59+
60+
impl ReRankingWeights {
61+
pub fn rerank(&self, paths: &mut [KBestPath]) {
62+
for path in paths.iter_mut() {
63+
let unk_weight = if path.token_count == 1 && path.unknown_bigram_count == 2 {
64+
self.unknown_bigram_weight * self.single_token_unk_discount
65+
} else {
66+
self.unknown_bigram_weight
67+
};
68+
69+
path.rerank_cost = path.unigram_cost
70+
+ self.bigram_weight * path.bigram_cost
71+
+ unk_weight * path.unknown_bigram_cost
72+
+ self.length_weight * path.token_count as f32
73+
+ self.skip_bigram_weight * path.skip_bigram_cost;
74+
}
75+
paths.sort_by(|a, b| a.rerank_cost.total_cmp(&b.rerank_cost));
76+
}
77+
}
78+
```
79+
80+
### 期待される効果
81+
82+
discount=0.5 の場合の「いしけってい」:
83+
84+
```
85+
[Before]
86+
医師/決定: rerank = 8.33 + 18.35 + 1.0*0.0 + 2.0*2 = 30.68
87+
意思決定: rerank = -0.25 + 1.0*0.0 + 1.0*28.67 + 2.0*1 = 30.42
88+
89+
[After] discount=0.5
90+
医師/決定: rerank = 30.68 (変化なし)
91+
意思決定: rerank = -0.25 + 0.0 + 0.5*28.67 + 2.0 = 16.08
92+
→ 意思決定が1位になる
93+
```
94+
95+
※ 上記は概算。実際の値はモデルに依存。
96+
97+
### 影響範囲
98+
99+
- **対象**: 1語候補 かつ BOS/EOS bigram が両方未知のケースのみ
100+
- **非対象**: 2語以上の候補、既知 bigram を持つ1語候補
101+
- Viterbi の候補生成には影響しない(k-best に含まれている候補の順位のみ変更)
102+
103+
## 退行リスク
104+
105+
### リスク1: 本来分割すべき入力で1語候補が不当に勝つ
106+
107+
例えば「さいど」(再度) が辞書に1語として登録されている場合、
108+
「再/度」と分割される候補に対して不当に有利になる可能性がある。
109+
110+
**対策**: `token_count == 1 && unknown_bigram_count == 2` の条件により、
111+
辞書登録された複合語に限定される。一般的な単語は unigram に登録されており
112+
BOS/EOS との bigram も学習済みのケースが多い。
113+
114+
### リスク2: discount の値が不適切
115+
116+
discount が小さすぎると1語候補が常に勝ち、大きすぎると効果がない。
117+
118+
**対策**: evaluate コーパスで grid search して最適値を探索。
119+
デフォルト値(0.5)は保守的な出発点。
120+
121+
## 検証手順
122+
123+
1. 実装後、デフォルト値で `cargo test --all` が pass することを確認
124+
2. `akaza-data evaluate` で退行がないことを確認
125+
3. 以下の代表的なケースで手動確認:
126+
- `いしけってい` → 意思決定(1位になることを期待)
127+
- `さいどばー` → サイドバー(1位になることを期待)
128+
- `いし` → 医師 or 石(退行しないこと)
129+
- 通常の2語以上の変換が退行しないこと
130+
4. discount を 0.3〜0.7 で変えて evaluate の精度変化を観察

libakaza/src/graph/reranking.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ pub struct ReRankingWeights {
1515
/// skip-bigram コストの重み(デフォルト 0.0 = 無効)
1616
#[serde(default)]
1717
pub skip_bigram_weight: f32,
18+
/// 1語候補(BOS/EOS bigram が両方未知)の unk_bi 割引率(デフォルト 0.5)
19+
#[serde(default = "default_single_token_unk_discount")]
20+
pub single_token_unk_discount: f32,
21+
}
22+
23+
fn default_single_token_unk_discount() -> f32 {
24+
0.5
1825
}
1926

2027
impl Default for ReRankingWeights {
@@ -24,6 +31,7 @@ impl Default for ReRankingWeights {
2431
length_weight: 2.0,
2532
unknown_bigram_weight: 1.0,
2633
skip_bigram_weight: 0.2,
34+
single_token_unk_discount: 0.5,
2735
}
2836
}
2937
}
@@ -32,9 +40,15 @@ impl ReRankingWeights {
3240
/// パスの rerank_cost を再計算し、スコア昇順にソートする。
3341
pub fn rerank(&self, paths: &mut [KBestPath]) {
3442
for path in paths.iter_mut() {
43+
let unk_weight = if path.token_count == 1 && path.unknown_bigram_count == 2 {
44+
self.unknown_bigram_weight * self.single_token_unk_discount
45+
} else {
46+
self.unknown_bigram_weight
47+
};
48+
3549
path.rerank_cost = path.unigram_cost
3650
+ self.bigram_weight * path.bigram_cost
37-
+ self.unknown_bigram_weight * path.unknown_bigram_cost
51+
+ unk_weight * path.unknown_bigram_cost
3852
+ self.length_weight * path.token_count as f32
3953
+ self.skip_bigram_weight * path.skip_bigram_cost;
4054
}
@@ -105,6 +119,7 @@ mod tests {
105119
length_weight: 0.0,
106120
unknown_bigram_weight: 0.1,
107121
skip_bigram_weight: 0.0,
122+
single_token_unk_discount: 1.0, // no discount
108123
};
109124

110125
// path A: unigram=3, bigram=2, unknown=10 → 3 + 0.5*2 + 0.1*10 = 5.0
@@ -126,6 +141,7 @@ mod tests {
126141
length_weight: 2.0,
127142
unknown_bigram_weight: 1.0,
128143
skip_bigram_weight: 0.0,
144+
single_token_unk_discount: 1.0,
129145
};
130146

131147
// path A: unigram=3, bigram=2, unknown=1, tokens=5 → 3+2+1+2*5 = 16
@@ -140,6 +156,64 @@ mod tests {
140156
assert!((paths[1].rerank_cost - 16.0).abs() < f32::EPSILON);
141157
}
142158

159+
#[test]
160+
fn test_single_token_unk_discount_applied() {
161+
let weights = ReRankingWeights {
162+
bigram_weight: 1.0,
163+
length_weight: 2.0,
164+
unknown_bigram_weight: 1.0,
165+
skip_bigram_weight: 0.0,
166+
single_token_unk_discount: 0.5,
167+
};
168+
169+
// 1語候補: token_count=1, unknown_bigram_count=2 → discount 適用
170+
// rerank = 1.0 + 1.0*0.0 + (1.0*0.5)*28.0 + 2.0*1 = 17.0
171+
let mut paths = vec![make_path(29.0, 1.0, 0.0, 28.0, 2, 1)];
172+
weights.rerank(&mut paths);
173+
assert!(
174+
(paths[0].rerank_cost - 17.0).abs() < f32::EPSILON,
175+
"got {}",
176+
paths[0].rerank_cost
177+
);
178+
}
179+
180+
#[test]
181+
fn test_single_token_unk_discount_not_applied_to_multi_token() {
182+
let weights = ReRankingWeights {
183+
bigram_weight: 1.0,
184+
length_weight: 2.0,
185+
unknown_bigram_weight: 1.0,
186+
skip_bigram_weight: 0.0,
187+
single_token_unk_discount: 0.5,
188+
};
189+
190+
// 2語候補: token_count=2 → discount 不適用
191+
// rerank = 3.0 + 1.0*2.0 + 1.0*5.0 + 2.0*2 = 14.0
192+
let mut paths = vec![make_path(10.0, 3.0, 2.0, 5.0, 1, 2)];
193+
weights.rerank(&mut paths);
194+
assert!(
195+
(paths[0].rerank_cost - 14.0).abs() < f32::EPSILON,
196+
"got {}",
197+
paths[0].rerank_cost
198+
);
199+
}
200+
201+
#[test]
202+
fn test_single_token_discount_changes_ranking() {
203+
let weights = ReRankingWeights::default(); // discount=0.5
204+
205+
// 1語候補 (複合語): unigram=-0.25, bigram=0, unk_bi=28.67, tokens=1
206+
let single = make_path(28.4, -0.25, 0.0, 28.67, 2, 1);
207+
// 2語候補: unigram=8.33, bigram=10.0, unk_bi=0.0, tokens=2
208+
let multi = make_path(19.1, 8.33, 10.0, 0.0, 0, 2);
209+
210+
let mut paths = vec![multi, single];
211+
weights.rerank(&mut paths);
212+
213+
// 1語候補が1位になるはず
214+
assert_eq!(paths[0].token_count, 1);
215+
}
216+
143217
#[test]
144218
fn test_is_default() {
145219
assert!(ReRankingWeights::default().is_default());
@@ -148,6 +222,7 @@ mod tests {
148222
length_weight: 0.0,
149223
unknown_bigram_weight: 1.0,
150224
skip_bigram_weight: 0.0,
225+
single_token_unk_discount: 0.5,
151226
}
152227
.is_default());
153228
}

0 commit comments

Comments
 (0)