豪鬼メモ

一瞬千撃

Kindle辞書における掲載語義選定の改善

WordNetWiktionaryを統合した辞書を作って、それからKindle用の辞書を生成している。その際には、WordNet由来の語義とWiktionaryの語義の両方を掲載すると長くなりすぎるので、WordNetの語義があればそれを使い、そうでない場合にのみWiktionaryの語義を使っている。この現状から一歩踏み込んで、ユーザ満足度を最大化する表示方法を検討する。
f:id:fridaynight:20211222154925j:plain


WordNetは基本語彙の基本語義を十分にカバーしてくれて素晴らしい。Wiktionaryはマイナーな語彙のマイナーな語義までをカバーしてくれて素晴らしい。しかし、その2つを切り替える方法だと、基本語彙のマイナーな語義が漏れてしまうという問題がある。例えば、「set forth」という見出し語には以下のような情報がついている。

WordNetの語義だけをKindle辞書に収録するなら、「leave」「state」のみが使われることになる。Wiktionaryには、「start」と「propose」を意味する語義も収録されているが、それは捨てられてしまうことになる。

WordNetの語義説明は非常に簡潔で読みやすい傾向にあるが、時に簡潔すぎて物足りなかったり、時に異常に詳しすぎて冗長に感じることがある。一方でWiktionaryは非常に詳細な語義まで含める傾向にあるが、時に手抜きだったり、読みにくかったりすることがある。WordNetWiktionaryのどちらが良いかは一概には言えず、場合によるとしか言いようがない。しかし、15万語以上もある見出し語を実際に見て判断することはできない。そこで、簡便法として、分量がちょうど良いものを選ぶという作戦を思いついた。

Kindle実機(Papaerwhite 2021年版)のポップアップ辞書は、冒頭に挙げた写真のように8行の表示範囲を持つ。スクロールすれば何行でも閲覧できるが、初見で必要な情報を網羅するのが望ましいので、最初の8行に最も有用な情報を詰め込むというのを目標にする。

統合英和辞書においては、見出し語と発音で1行使い、訳語のリストに1行使う。多読速読の際にはこの二つの情報こそが重要なので、この部分は譲れない。となると、残りは6行だ。半分くらいの語義は2行にまたがることを考えると、4項目からなる語義が最善ということになる。また、個々の項目の語義説明は長すぎても短すぎても良くない。1行にちょうど収まるくらいが読みやすいので、8単語くらいが最善だと思われる。

以上の情報をパラメータとして、「読みにくさ」をコスト関数として表現して、それが最小のものを選ぶことにしよう。以下のようなコードになるかな。

item_cost = abs(log(4) - log(len(items))
length_cost = sum(items, key=lambda x: log(8) - log(num_words(x))) / len(items)
quality_cost = 1.0 if is_wordnet() else 1.2
cost = (item_cost + 0.5) * (length_cost + 1.0)

項目数のコストは、対数尺度での4からのずれに基づいて算出する。つまり、1項目なら1.38、2なら0.69、3なら0.28、4なら0.00、5なら0.22、6なら0.40、7なら0.55、8なら0.69といった具合だ。少なすぎる場合のコストの方が多すぎる場合のコストよりも厳しくなりやすい。単語数のコストは、各項目の語義説明の単語数の対数尺度での8からのずれの算術平均に基づいて算出する。これも短すぎる場合のコストの方が大きくなる。最後に、品質コストというのを定義する。研究者が作ったので多少信用のおけるWordNetを贔屓するための設定であり、WordNet以外の場合にコストが少し上がるようになっている。そして、項目数のコストと単語数のコストの各々にスムーザを足してから、各要素を乗算した値が最終的なコストとなる。

上述した「set forth」の例を考えよう。WordNetは2項目なので、項目数のコストは0.69だ。単語数のコストは(2.07+2.07) / 2 = 2.07だ。品質コストは1.0だ。最終的なコストは (0.67 + 0.5) * (2.07 + 1.0) * 1.0 = 3.59だ。Wiktionaryは4項目なので、項目数のコストは0.00だ。単語数のコストは (0.13 + 0.28 + 0.28 + 0.38) / 4 = 1.07だ。品質コストは1.2だ。最終的なコストは (0 + 0.5) * (1.07 + 1.0) * 1.2 = 1.24だ。つまり、Wiktionaryのコストの方が低いので、Wiktionaryを選ぶことになる。細かいパラメータに調整の余地はあるが、だいたい直感に沿ったアルゴリズムになっていると思う。

結果として、WordNetから58093語、Wiktionaryから119053語の見出し語の語義を採用することになった。WordNetWiktionaryが同じ見出し語で重複して勝負した場合の採用数は、WordNetが43235件、Wikipediaが99132件だったので、Wiktionaryが70%くらいの確率で勝つらしい。だいたい予想通りだ。なお、品質コストを抜いた場合のWiktionaryの勝率は78%くらいであった。

WordNetWiktionaryのうちのマシな方を選べるようにはなったが、各々が1項目とかしかない場合には両方載せても良さそうなもんだ。例えば「maharaja」という語の語義は以下のようになる。

  • Wiktionary: A Hindu monarch ranking above a raja, an emperor.
  • WordNet: a great raja; a Hindu prince or king in India ranking above a raja

WiktionaryWordNetで似たような説明をしているので冗長ではあるが、多少付加的な情報もあるので、場所が余っているなら二番目の語義説明も載せておいても損はない。ただし、語義が二つあるわけではなく、同じ語義に関して別の言い方をしているだけであるから、普通に並列に表示するのは違和感がある。なので、並び替えで負けた方の表示は「おまけ情報」だと分かるように「・」を前置して表示しよう。以下のような構造になる。

maharaja  /ˌmɑ.hɑˈɹɑː.dʒə/
大王, 藩王, マハラジャ, マハーラージャ
[名] A Hindu monarch ranking above a raja, an emperor.
・[名] a great raja; a Hindu prince or king in India ranking above a raja

選択した辞書から採用された語義が3個以下の場合、6個になるまで二番目以降の辞書からも参考情報として語義を補うことにする。しかし、ほとんど似たような語義を2回表示するのは忍びない。例えば「aerolite」の語義は以下のようになる。

  • WordNet: a stony meteorite consisting of silicate minerals
  • Wiktionary: A meteorite consisting of silicate minerals

2つの語義は「stony」の有無以外は同じ文であり、両方表示されるとさすがにイラッとする。そこで、二番目以降の辞書の語義に対しては、既出ものと似すぎている場合は省略するという処理が求められる。

さて、文の類似度を測るといえばBLEUスコアである。Pythonで実装するなら以下のようになる。入力はトークン文字列のリストとして与えるが、トークナイズの方法は外部に任せる。パラメータcandidateとして候補の文のトークンのリストを与え、パラメータreferencesとしてリファレンス文のトークンのリストのリストを与える。リファレンス文は複数与えられるようにしてある。また、ngram判定の最大トークン数もパラメータとして指定するが、普通は4を使うらしい。4語未満の候補文を調べる場合にはその語数に合わせることが多い。

def ComputeBLEUScore(candidate, references, ngram):
  if not candidate or not references: return 0.0
  ngram = min(ngram, len(candidate))
  def GetNGramMap(tokens, n):
    result = collections.defaultdict(int)
    for i in range(0, len(tokens) - n + 1):
      phrase = "\0".join(tokens[i:i + n])
      result[phrase] += 1
    return result
  sum_log = 0.0
  for n in range(1, ngram + 1):
    cand_map = GetNGramMap(candidate, n)
    merged_ref_map = {}
    for reference in references:
      for phrase, count in GetNGramMap(reference, n).items():
        merged_ref_map[phrase] = max(merged_ref_map.get(phrase) or 0, count)
    total_count = 0
    total_match_count = 0
    for phrase, count in cand_map.items():
      match_count = min(merged_ref_map.get(phrase) or 0, count)
      total_count += count
      total_match_count += match_count
    if not total_match_count: return 0.0
    sum_log += math.log(total_match_count / total_count)
  mean_precision = math.exp(sum_log / n)
  cand_len = len(candidate)
  ref_lens = [len(x) for x in references]
  ref_len = min(ref_lens, key=lambda x: (abs(x - cand_len), x))
  brevity_penalty = min(1.0, math.exp(1.0 - ref_len / cand_len))
  return mean_precision * brevity_penalty

「aerolite」の例の2番目の文を候補文とし、1番目の文をリファレンス文としてBLEUスコアを算出すると、0.61となる。単文のBLEUが0.3とかを超えると、そこそこ似てる感じと言って差し支えないので、2番目の文は捨てても良さそうだ。

ここで、怖い人達からは突っ込みが来るだろう。BLEUスコアはコーパス単位で多数のサンプルの平均として算出しないと意味がないと。実際、そうなのだ。4グラムまでのトークン精度の幾何平均を使っているので、4グラムの一致が一つもないサンプルはBLEUスコアがゼロになってしまう。例えば、候補分の「consisting」を「composed」に変えただけでゼロになってしまう。つまり、めっちゃ似ていないとまともなスコアが出ない傾向にあるので、たくさんの文例を読ませて平均を取らないと意味のある判定にならない。幾何平均を取る前にスムーザを加えるというのも一般的にやる方法だが、具体的な値を決めるのがまた難しい。

翻訳精度を測りたいわけじゃないので、適当に評価関数を改変してしまおう。まず、幾何平均を算術平均に変える。これで1グラムから3グラムまでの結果がもっと物を言うようになるし、3グラムや4グラムが一致しなくてもスコアが出るようになる。また、リファレンス文より短い文を罰するbrevity_penaltyを省略する。重複を抑止することが目的である場合、候補文が短いと高いスコアが出るというのはむしろ歓迎すべき性質だからだ。ということで、以下のような実装になる。

def ComputeNGramPresision(candidate, references, ngram):
  if not candidate or not references: return 0.0
  ngram = min(ngram, len(candidate))
  def GetNGramMap(tokens, n):
    result = collections.defaultdict(int)
    for i in range(0, len(tokens) - n + 1):
      phrase = "\0".join(tokens[i:i + n])
      result[phrase] += 1
    return result
  sum_precision = 0.0
  for n in range(1, ngram + 1):
    cand_map = GetNGramMap(candidate, n)
    merged_ref_map = {}
    for reference in references:
      for phrase, count in GetNGramMap(reference, n).items():
        merged_ref_map[phrase] = max(merged_ref_map.get(phrase) or 0, count)
    total_count = 0
    total_match_count = 0
    for phrase, count in cand_map.items():
      match_count = min(merged_ref_map.get(phrase) or 0, count)
      total_count += count
      total_match_count += match_count
    sum_precision += total_match_count / total_count
  return sum_precision / n

これを適用すると、「aerolite」の例のスコアは0.62になり、「consisting」を「composed」に変えた場合でも0.27になる。さらに、語義説明は文が短い傾向にあるので、N-gram判定を3までにする。そうすると、元の候補文で0.66、1語改変した場合で0.36になる。0.25あたりを閾値にして類似文を捨てれば、冗長性を回避しつつ語義説明を補強できそうだ。

類似判定の前処理となるトークナイズの際には、いくつかの正規化を行う。動詞の語義の先頭にある「to」を削除し、全ての屈折形は原形に戻し、冠詞を削除し、英字を小文字に統一し、英字以外の記号は削除する。例えば「to give an apple to better Japanese boys.」は「give apple to good japanese boy」になる。

語義を補足するためだけにここまで面倒くさいことを実装する羽目になるとは自分でも思わなかったが、実際にどのような語義が補足されるのかを見てみよう。

jabberwocky  /ˈdʒæbɚˌwɔki/
無意味, ナンセンス, 無意味な
[名] invented or meaningless language; nonsense
[形] meaningless, worthless
[形] absurd, nonsense, nonsensical
・ [名] nonsensical language (according to Lewis Carroll)
jackfruit
ジャックフルーツ, 波羅蜜, ジャック, パラミツ, 波羅密
[名] A tree, Artocarpus heterophyllus, of the Moraceae family, which produces edible fruit.
[名] The large fruit from this tree.
・ [名] immense East Indian fruit resembling breadfruit; it contains an edible pulp and
nutritious seeds that are commonly roasted
・ [名] East Indian tree cultivated for its immense edible fruit and seeds
[複数] jackfruits
GA
神経ガスの一種, 遺伝的アルゴリズム, 国連総会
[名] General American
[名] Georgia
[名] US designation for the nerve gas tabun.
・ [名] the first known nerve agent, synthesized by German chemists in 1936; a highly toxic combustible liquid that is soluble in organic solvents and is used as a nerve gas in chemical warfare
・ [名] a state in southeastern United States; one of the Confederate states during the American Civil War
gabble  /ˈɡæbəl/
お喋り, おしゃべり, しゃべる, ぺらぺらしゃべる, 喋る, 漏らす
[動] To talk fast, idly, foolishly, or without meaning.
[動] To utter inarticulate sounds with rapidity.
[名] Confused or unintelligible speech.
・ [動] speak (about unimportant matters) rapidly and incessantly
・ [名] rapid and indistinct speech
[三単] gabbles [現分] gabbling [過去] gabbled [過分] gabbled

同じ語について語っているから内容的には似通ったものになるわけだが、表現としては多様性に富んでいる気がする。言い換えれば、冗長にならない範囲で補足情報を付加することに成功している。

ちなみに、類似度判定で捨てられたパターンは以下のような感じである。ちゃんと重複しているのが捨てられていることが確認できる。

[SCORE] 0.3188552188552189
[CAN] Of, relating to, affecting, or designed for use with two ears.
[REF] relating to or having or hearing with two ears
[SCORE] 0.43333333333333335
[CAN] Of or pertaining to biosystematics
[REF] of or relating to biosystematics
[SCORE] 1.0
[CAN] the black grouse.
[REF] male black grouse
[SCORE] 0.38888888888888884
[CAN] Relating to a blastema.
[REF] of or relating to blastemata
[SCORE] 1.0
[CAN] A pupil who lives at school during term time.
[REF] a tenant in someone's house
[REF] a pupil who lives at school during term time
[REF] someone who forces their way aboard ship
[SCORE] 0.5
[CAN] haul with a tackle
[REF] To drink excessively and socially; to carouse.
[REF] A carouse; a drinking bout; a booze.
[REF] To haul or hoist with a tackle.
[SCORE] 0.35000000000000003
[CAN] The Dravidian language of this people.
[REF] an isolated Dravidian language spoken by the Brahui in Pakistan
[REF] a member of a Dravidian people living in Pakistan

結果として得られたKindle辞書はこれである。自分で使ってみている限り、前のバージョンよりも多少は使いやすくなっているように感じる。


まとめ。複数の辞書データを統合するにあたり、語義説明の分量からマシな方を選択するアルゴリズムを実装した。また、分量が少なすぎる場合、BLEUスコアを改変したN-gram精度で冗長性を判定しながら、表示項目を増やす処理も実装した。これらによって、ポップアップ辞書のファーストビューでの情報量を最大化するという目標に近づくことができた。