T9 拼音是如何实现的?

发布于 约 4 分钟

Rime 本质上是 PC 输入法引擎,它有许多配置都基于实体按键。

key_binder:
  bindings:
    # 空格键选第一候选词
    - { when: has_menu, accept: space, send: 1 }
    # 回车键提交输入编码
    - { when: composing, accept: Return, send: Return }
    # 退格键删除
    - { when: composing, accept: BackSpace, send: BackSpace }
    //...

Xime 输入法安卓版在实现的时候,大部分配置都在安卓层实现。 Rime 大多数配置被无视,只提供了基本的输入法按键映射。

九宫格拼音诞生于早期的实体按键手机。现在使用九宫格拼音的用户基本来自安卓之前的实体智能手机时代。

这意味着,Rime 是不会有九宫格拼音的按键映射的,也就是 Rime 天生不支持九宫格拼音。

难点

从信息论的角度来看,26键到9键,每按键的信息熵都在降低。由于多个字母映射到同一个数字键,按键序列携带的信息量减少(熵降低)。用户输入相同长度的拼音时,9键产生的按键序列总信息熵低于26键序列。为了弥补这种信息损失,输入法需要借助词典、语言模型、上下文消歧等机制来推测用户意图。

其他项目做法

通过雨燕输入法和 rime-ice 的的配置来看,似乎是把转换逻辑全程交给 Rime,通过一些规则配置来实现(当然,我没细看,不一定对)。

我基于类似的原理做了一个初版,效果比较差,于是放弃这个方案。

微信输入法是一个很好的观察对象,通过逆向分析了发现,微信输入法的逻辑基本是C++实现的,非常复杂,所有逻辑都集成在一个叫 libwxhld.so 中。并使用了大量 Augmenter 来处理各种模糊音、方言等输入问题。

仔细分析

既然 Rime 不支持 T9 拼音,那有什么是可以复用的呢? 答案是全键盘拼音。

正常的全键盘输入,一般是链路如下:

输入事件 → Composition(编码区) → Speller(拼写计算) → Translators(翻译器链)-> 返回候选

我们只需要实现一个转换器(T9Decoder),把 T9 拼音的数字码转换成可能的拼音组合,然后送入到 Rime 处理,就做到了复用全键盘拼音方案。

转换器(T9Decoder)

想象一下,T9 输入就像在一条有很多条岔路(歧义)的路径上,我们需要找到概率最高的那一条路线。

比如 64426, 6 出现了3条路径,mno, 4 同样有3条路径:ghi

如果从6走到4,按概率算的话,靠前的排名可能有 mini。如果是全拼,基本64 就这2个组合,其他的都是无效路径。如果要支持简拼和模糊音,则其他组合也必须是有效的,只是排名不会靠前。

我们需要把用户输入的数字串(如 64426 )看作一个地图,算法要找出最有可能对应的拼音串(如nihao)。

这个算法叫 维特比(Viterbi)解码算法

整体路径:

数字输入 "64426"

T9Decoder.decode("64426")     ← Viterbi DP + Bigram LM

List<Path>  eg. [ni+hao,mi+han ...]
    ↓  UI 选择音节
"ni" + "hao"  →  "nihao"
    ↓  rimeEngine.processKey() 逐字符
Rime  →  汉字候选项

Viterbi DP 解码

把数字串切分成拼音序列,要求:

  1. 每个切分片段对应一个合法拼音(或其模糊变体)
  2. 相邻拼音之间有合理的二元转移概率
  3. 长拼音获得正奖励(鼓励合并成完整音节)

DP 定义:

dp[i][pinyin] = 到位置 i、最后一个拼音是 pinyin 的最大 log 概率
back[i][pinyin] = (前一个位置, 前一个拼音)  // 回溯用

初始化:dp[0]["<BOS>"] = 0.0,其中 BOS 表示句子开始。 对于数字串中的每个位置 i,算法尝试三种方式往前走:

方式一:精确匹配 + 模糊匹配

从位置 i 开始,尝试长度为 1 到 6(最长拼音长度)的编码段 code = digits[i..i+len],在预先构建的索引中查找匹配的拼音(同时包含精确匹配和模糊音扩展的结果)。对于每一个匹配到的拼音 pinyin,计算转移得分:

newScore = lastScore 
         + lm.transitionLogProb(lastPinyin, pinyin)   // 语言模型得分
         + max(0, (len(pinyin) - 2) * 0.3)            // 长音节奖励
         - (模糊匹配 ? 0.5 : 0.0)                      // 模糊音轻微惩罚

“长音节奖励”的用意是:算法倾向于把数字合并成完整的音节(如 26 → an),而不是拆成多个单字母(如 2→a + 6→n),因为完整音节往往对应真实的拼音。

方式二:键盘邻居容错

考虑到用户可能在九宫格键盘上按偏,每个数字键都有其邻居键集合。例如 5 的邻居包括附近的 2、4、6、8 等。算法会尝试把编码段中的某一位替换成邻居数字,再查拼音:

“64” → 尝试 “54”、“34”、“84”、“94” → 找到 “li”、“di”、“ti”、“yi” 邻居匹配的得分惩罚更大(-1.5),因为它本身就是一种容错兜底。

方式三:单字母回退

每个数字对应的字母都可以作为单独的“拼音”进入 DP 状态。比如 7 可以变成 p、q、r、s。虽然单字母不是合法拼音,但它们确保了 DP 图始终连通,不会出现死胡同。语言模型会对不合理的序列自动给出低分,从而在最终排序中被淘汰。

PinyinBigramLM

PinyinBigramLM 是打分的基石,通过词库文件生成。

T9Decoder 完全依赖 PinyinBigramLM 的 transitionLogProb 来区分“合理”和“不合理”的拼音序列。单字母回退路径虽然数量多,但因为 LM 给的单字母转移概率极低,自然会被 DP 剪枝掉。

查询逻辑如下:

fun transitionLogProb(prev: String, cur: String): Double {
    // 优先查二元概率
    // 查不到就退回 cur 的一元概率(backoff)
    // 连一元也没有 → 全局最小概率(-20)
}