a 还是 an:我把 3 万个单词跑了一遍,只有 129 个需要例外

英语 a/an 取决于读音而非字母:unicorn 用 a、hour 用 an。作者用 cmudict 对比首音与首字母规则,从 32,455 个词里挑出例外,最终用一段 25 行的 Python 规则解决。

中文
复制
一个词前两个字母的树状可视化:黑=总是辅音音,蓝=总是元音音,红=两者皆有

英语里有一个不定冠词 a,可以放在词前,比如 a raccoon。但对某些词我们要用 an,比如 an apple。我们使用的规则是以元音开头的词用 an。

做程序化文本生成时,我想要一个函数 a_or_an("apple") 来告诉我该用哪个冠词。这看起来很简单:检查首字母是不是元音就行。但这条规则作用于口语的词,而不是书面的词。比如我们说的是 a unicorn 而不是 an unicorn,因为 <unicorn> 这个词开头是辅音(「yoo-ni-korn」)。

要实现正确的「首音」规则,我们需要一部发音词典。我很好奇错误的「首字母」规则到底差多远,于是下载了 cmudict(https://github.com/cmusphinx/cmudict),把「首音规则」和「首字母规则」做了对比测试。结果出现了大量不一致。我把它们列出来,并决定其中一些不纳入分析:

  1. 专有名词(3133 个)。名字的麻烦在于,很多并不是英语词汇,因此不必遵循英语的发音规则。这不只是人名,还有像 <nvidia> 这样的词——它以元音音开头,但英语里没有以 <nv> 开头的普通单词。虽然它们可以出现在句子里(an nvidia employee),我还是决定它们不值得研究,可以放进例外清单。
  2. 单个字母(26 个)。字母表里每个字母都有读音,比如 <h> 读作「aitch」。虽然它们可以出现在句子里(an H was on his shirt),我认为最好用一份硬编码清单处理。
  3. 次要读音(9114 个)。很多词有多种读音,cmudict 会把每个都列出来。我看了哪些会对 a/an 产生影响,最主要的是 <herb> 在美式英语和英式英语里读音不同。
  4. 标点(8574 个)。主要是所有格(<arm's reach>)和缩略(<don't>)。虽然我本可以处理,但我想用一个按 26 个字母索引的简单数据结构,所以跳过了它们。
  5. 首字母缩略词(10 个)。在 cmudict 里这些是按字母逐个拼读的。例如 <eu> 拼作「ee yoo」(cmudict 记作 IY UW,ipa /i u/)。这类不多,我认为它们在某些方面与专有名词类似。
  6. 手动排除(2 个):<nth><urman>
  7. 其他(81852 个)。这些是 cmudict 里但不在 BSD 词典里的词,原因各异。有些是像 <aesop> 这样的名字,有些是像 <lobsters> 这样的词形变化,有些是像 <jr> 这样的缩写。我没找到简单的方法进一步区分,于是全部排除。不过如果我要更新这个项目,我会更仔细地看这些词。

最后剩下 32455 个词。我用 d3/ObservableHQ 做了一个关于前两个字母可视化:如果前两个字母总是辅音音,线条是黑色;总是元音音则画蓝色;有时元音有时辅音,则画红色:

一个词前两个字母的树状可视化:黑=总是辅音,蓝=总是元音,红=两者皆有

展示「一个词的前两个字母是否足以判断该用 a 还是 an」的可视化

我还试着做了一张展示例外出现在哪里的可视化:

树状可视化,展示我们需要向前看多少个字母才能决定用 a 还是 an

展示「我们需要看多少个字母才能决定 a 或 an」的可视化

我本想找出一个巧妙的算法,把它压缩成一棵最小的决策树——我想那会和 DFA 最小化有关。但我没能想出来,于是手工写了一套规则:

VOWELS = "aeiou"

def starts_with_vowel(word):
    if word.startswith("eu"): return False
    if word.startswith("ew"): return False
    if word.startswith("heir"): return True
    if word.startswith("herb") and not word.startswith("herbiv") and word != "herbarium" and word != "herbicide": return True
    if word.startswith("homage"): return True
    if word.startswith("hones"): return True
    if word.startswith("hono"): return True
    if word.startswith("hour"): return True
    if word.startswith("once"): return False
    if word.startswith("one") and not word.startswith("onerous"): return False
    if word.startswith("ula"): return False
    if word.startswith("unani"): return False
    if word.startswith("uni") and word[3:4] in "cfloqstv" and word != "unissued": return False
    if word.startswith("ub"): return False
    if word.startswith("uk"): return False
    if word.startswith("ur") and word[2:3] in VOWELS and word != "ur": return False
    if word.startswith("us") and word not in ("us", "usher"): return False
    if word.startswith("ut") and not (word.startswith("utm") or word.startswith("utt")): return False
    if word.startswith("uva"): return False
    if word.startswith("ytt"): return True
    return word[0] in VOWELS

一个沾边的话题:重新切分(rebracketing)——在大多数人还不会阅读的年代,人们听到 <a napron> 会误听成 <an apron>,听到 <an ewt> 会误听成 <a newt>。我们说 <an orange>,而在西班牙语里是 <naranja>,因为那个词原本就带一个前置的 <n>

来源:preprocess-cmudict.py

来源: Red Blob Games← 返回首页