豪鬼メモ

一瞬千撃

Word Wise風の英文自動和訳注釈サービス

長い英文を読みたいが、語彙力が足りない。未知語に会う度にいちいち辞書を引いていたら時間がかかって仕方ない。そんなあなたのために、英文の中の全ての単語とフレーズの意味を辞書で予め調べて注釈をつけるサービスを作った。これで赤毛のアンやトムソーヤの冒険を原文で読めるはず。
f:id:fridaynight:20210129133312p:plain


まずはこのデモサイトを使ってみて欲しい。適当な英文(複数でも可。一度に256KBまで処理可能)を入れて「注釈」ボタンを押すと、解析結果が表示される。文中に現れる英単語や熟語の全ての組み合わせを全て辞書で調べて、その情報をツールチップとして埋め込んでくれる。該当の単語やフレーズの上にカーソルを乗せると、その語の語義が付箋のように表示される。また、事前に設定した難易度(想定獲得年齢)よりも高い語に関しては、和訳がルビとして振られる。これらによって、原文と辞書サイトを行ったり来たりすることなく、同一ページ内で語の意味を調べつつ、英文読解を進めることができる。

似たような機能として、Amazon KindleのWord Wiseという機能がある。これは英文中の難解語に自動的にルビを振って平易な言い換えを表示してくれるものだ。ただし、英語に対して英語の言い換えが出てくるので、日本人にとっては厳しい。一方で、Google翻訳などの自動翻訳の結果を原文と並べながら読んで読解を進めるという手もある。これは英語学習としても有効な手段だと思うが、全てを日本語に翻訳されてしまうと、もはや英文読解とは言えなくなってしまう。それらの折衷的な位置づけなのが、今回の和訳注釈サービスである。基本的には英文だけを追って読解を進めるが、未知語に遭遇した時だけはルビや付箋から和訳の情報を得るのだ。

ルビに関しては「注釈想定年齢」を変化させることで、濃度を調整することができる。デフォルトは12歳で、ネイティブ英語話者が12歳までに覚えているであろう語にはルビが振られない。すなわち、13歳以降に覚えるであろう語にのみルビが振られる。これを3歳とかに設定するとほぼ全ての語にルビが振られるし、20歳に設定するとルビが全く振られなくなる。

似たようなサービスやアプリケーションやブラウザ機能拡張は既に世の中にあるのだが、自分なりにチューニングして作りたくなったのだ。UIにはまだまだ改善の余地はあるが、そこそこ実用的なものができたと思う。辞書検索システムと学習支援システムとしての拡張性を持たせているところがちょっと格好いい気がする。あと、辞書データもプログラムも公開されているというのはそこそこ意義深いかな。

試しに、赤毛のアンの冒頭を読解してみてほしい。比喩が多かったり、100年前の英語だったりするので、それなりの英文読解力が求められる作品だ。しかし、注釈をつけると、語彙の問題がほぼ解消されるので、基礎的な文法さえ知っていれば理解できるはずだ。「for」が「because」の代わりに使われるのとか、倒置などの文法には慣れが必要だが、多少難しい言い回しでも、未知語を潰していけば何とかなる。というか、語彙の心配がなくなるだけで、英文の構造に意識が集中できるので、一見難解な英文もすらすら読めるようになる。私個人としては、ポインタをホバーさせると付箋で語義が見られるのが小気味良くて気に入っている。Project Gutenbergにある名作を読むもよし、ブログやニュースの英文を読むも良しだ。試しに赤毛のアンの全文トムソーヤの冒険の全文は事前に処理してHTML化しておいたので、これを機にぜひ原文を読んでいただきたい。マシューがアンに語る "I’d rather have you than a dozen boys" のフラグとその後の展開は、分かっていても涙を禁じ得ない。


実装の工夫について述べる。英和辞書には、例によって、今まで作ってきたWordNet-Wiktionary統合辞書を使っている。単語だけでなく、複数語からなる熟語や定型句や複合名詞も見出し語として収録されている。各見出し語には、主に日本語WordNetとWiktionry日本語版を由来とする和訳がつけられている。今回は実装したのは、英文中の各語に和訳をルビとして振るとともに、その他の語義情報を付箋として貼り付けるという機能だ。

英文とは、英単語のリストである。単語と複数語からなるフレーズを両方同時に扱うとなると、フレーズの候補をどうやって切り出すかが問題となる。今回は、基本的には、最左最長一致の候補を選ぶ方針をとった。以下のような手順を踏む。

  • 元テキストからラテン文字の連続を切り出して、セグメントのリストを抽出する
    • ラテン文字のセグメントは、単語として認識される
    • ラテン文字でないセグメントは、区切り文字列として認識される
    • "He said, she loves you." -> ("He", " ", "said", ", ", "she", " ", "loves", " ", "you", ".")
  • 空白だけのセグメントを挟んで単語が並んでいる場合、単語N-gramとして3グラムまで検索対象とする
    • "He", "He said", "she", "she loves", "she loves you", "loves you", "you"
  • 各単語セグメントについて、それを先頭とする見出し語のリストを記憶させる。
    • リスト内の語を重要度でソートする。
      • 重要度は主に出現率と獲得年齢から算出する。
      • 複数語からなる場合、非常に強いボーナスを与える。結果としてほぼ最長一致になる。
  • 各単語セグメントの最重要語が、ルビ表示条件に合えば、ルビを表示する。
    • ルビを表示している区間は新たなルビの発生を認めない。結果として最左一致になる。
  • 各単語セグメントの最重要語の範囲で付箋を作り、その範囲の辞書情報を全て付箋に入れる。
    • "come up with" -> ("come up with", "come up", "come", "up", "with")

この単純なアルゴリズムでは、コンテキストを全く考慮しないで、単に辞書を引いているだけだ。もしこれが機械翻訳であれば、品詞情報とか言語モデルとかを駆使して尤もらしい語やその語義の選択するところだが、実装が面倒なのと、動作がやたら遅くなりそうなので、今のところやっていない。多分、将来的にもやらない。あくまで辞書サービスなので、コンテキストに最適な訳語を選択するよりは、コンセプトの中心となる語義を表示することに尽力したい。そしてその努力は統合辞書を作る際に既にしている。

細かい工夫をたくさんしている。「japan」とか「china」とか「polish」とか、大文字と小文字で意味が違う単語の場合、文中に出現した形式に合わせて語義を選択している。「I like China.」の場合は「中国」であり、「I like the china.」の場合は「磁器」である。その語が文頭に来た場合は大文字と小文字の区別ができないので、出現率が優勢な方を選択する。屈折形もちゃんと対応している。「came up with」で「come up with」にちゃんとヒットする。「sang」は「アメリカ人参」という意味もあるらしいが、ちゃんと「song」の過去形「sang」として「song」の訳語「歌う」に差し替えてくれる。「I planted the sang.」みたいに本当に野菜として扱っている場合には問題となるが、そこは割り切って確率の高い方を救うことにする。付箋には第二義として「アメリカ人参」も表示されるので、解釈に疑義がある場合には付箋を参照してもらえばよいだろう。

屈折形を取得するリスクの別の例もある。「was」の正規形が「be」の過去形「was」として「be」を取得すると同時に、「WA」の複数形「WAs」として「WA」(ワシントン州)を取得してしまう。なぜワシントン州に複数形があるのかは謎だが、Wiktionaryにそう書いてあるのだから仕方ない。とはいえ、重要度でソートしたら「be」の方が選ばれるので、重要語がちゃんと取得できてさえいれば、多少のノイズを拾ってしまうのは問題ない。どんな単語にも「be」は勝つが、もしそうでなくても、「was」と「WAs」のケースが一致せずに強いペナルティがかかるので、略語の複数形が別の基本語を差し置いてトップになる確率はほぼない。逆にいえば、本文中に「WAs」と書いてあっても「be」が第一義になるという潜在的な問題はある。いずれにせよ、一致する可能性のある候補を全て付箋に入れるので、第一義の選択が間違った場合にも救済できるし、屈折形の語義も含めて一覧できるのが便利だ。「well-established」の付箋には、「well-established」「well」「establish」「established」の全てが入る。「can't」の付箋に「can」と「not」が入るとか、「I've」の付箋に「I」と「have」が入るとかのルールも書いている。

HTMLを出力する際には、語義が獲得できた全てのフレーズにルビを振っている。ルビはHTML標準のruby要素を使って表現している。ルビを出力する際に獲得年齢を属性としてつけておく。ページがロードされた直後には、JavaScriptで各要素を回って、獲得年齢が指定値よりも低いものを隠している。よって、指定値を変更した際には、ページをリロードすることなく、表示の変更を行うことができる。ところで、以前に書いた単語の獲得年齢の記事でも述べたが、獲得年齢のカバー率は完全ではない。よって、獲得年齢がない場合には推定値を適用するしかない。今回はその語の出現率を使って、log(p) * -1 + 3.5 という式を使った。

さらに細かい話だが、少なくとも現行のChromeブラウザには、ruby要素に関して表示上の癖がある。line-heightを200%とかの十分な大きさにしないと、ルビがある行の行間だけが広がって表示がダサくなるのだが、行間を広げるとルビが行間の真ん中に来てしまって間抜けな感じになってしまう。回避策としては、ルビを含む親のテキスト(p要素とか)のline-heightは200%にしつつ、ruby要素のline-heightは150%とか120%とかの小さい値にする方法がある。そうするとルビが下側に寄ってくれる。

英文中の語句にポインタを合わせるとポップアップで付箋を表示するということは、その語句からポインタが外れると付箋は消えるということだ。つまり、付箋にポインタを合わせることはできない。しかし、付箋の中の文字列を選択したり、リンクをクリックしたりするには、なんとかして付箋にポインタを合わせる必要がある。そのために、語句をクリックすると、対応する付箋が貼り付けられて消えなくなるようにした。付箋内にポインタを入れてからまた外せば、その付箋は消える。モバイル機器の場合、タップとホバーが同義で、しかもポインタが跳躍できるので、この付箋固定機能は無効化する。ちょっとトリッキーな操作体系ではあるが、試行錯誤した中ではこれが一番マシだった。付箋の中にある見出し語の各々をクリックすれば、辞書の検索ページに飛んで、詳細な語義を見ることができる。


解析対象の文章を入力欄にいちいちコピペするのは面倒臭い。Web上にあるコンテンツなら、自動的に読み込んで注釈がつけられたら便利だ。よって、検索語の入力欄にURLを入力すると、その内容を取得して処理する機能をつけた。プレーンテキストかHTMLで記述されたコンテンツならば、どんなものでも扱えるので、任意のWebサイトやブログやニュースを読むのに都合が良い。HTMLのコンテンツなら元のHTMLの構造を残したままルビと付箋の挿入をすることも考えたのだが、使い勝手の維持とかセキュリティのこととか考えると面倒なので、HTMLからプレーンテキストを抜き出して処理することにした。

アンとトムのデータをなぜわざわざ静的なHTMLにしたかといえば、注釈処理の負荷が凄まじいからだ。3グラムで各単語を処理して、さらに屈折形の処理も行うので、単語が出現する度に6回以上のデータベースアクセスと、付箋とルビの生成が行われる。アンとトムはそれぞれ5万語強および7万語強の作品なので、それぞれ30万回以上と42万回以上のデータベースアクセスが発生する。高速データベースライブラリTkrzwの本領を発揮するシーンであり、1分かからずに処理を終えるのは圧巻ですらあるが、ネットに繋いで不特定多数に起動されると負荷が高まりすぎてサーバが落ちかねない。よって、予め生成したデータをHTMLとして保存して紹介したのだ。生成されるHTMLのサイズも莫大であり、それぞれ150MBと100MBくらいだ。ということは1語あたり1500バイトくらいというのが期待値になる。効率がやたら悪いが、小説作品に現れる全ての単語を見出しにして辞書を生成するようなものだから、是非もなし。

重い処理に外部リンクが張れる状態にするとクローラ等にも爆撃されてまずいので、URL指定の注釈機能はPOSTでのみ発動するようにした。それでも、何らかのプログラムを書いてPOSTリクエストを乱発すれば簡単にDoS攻撃が成立してしまうが、こんな個人サイトを狙って喜ぶ輩もそうそういないだろうから良しとする。リンクを貼っただけで攻撃が成立するよりは大分マシだ。入力データ量の最大値はHTMLで512KB、プレーンテキストで256KBに制限し、さらに8192バイトを上限とする行ごとにストリーミング処理を行うことで、サーバ側のメモリ使用量が増えすぎないように工夫している。ただ、アンやトムの全体を一度に処理しようとすると入力データ量が1MBを超えてしまうし、出力されたHTMLの全体をブラウザに読み込ませるのも重すぎる。よって、章ごとにファイルを分けて出力することにした。そのためのコマンドラインも整備した。以下のように実行すればOK。

$ mkdir annot-anne
$ union_search.py --query_file anne.txt --index annot --output_prefix annot-anne

HTMLからプレーンテキストを取り出す処理はほとんど正規表現で済ませている。改行をスペースに変換し、HTMLのコメントセクションを削り、body開始タグの前を削り、body終了タグの後ろを削り、script要素とstyle要素の中身を削り、h1からh6までとpとdivとbrを改行に変換し、最後に実体参照を復元するだけ。一方で、元々のプレーンテキストにも整形を加えている。というのも、段落末以外でも改行を入れて行を折り返す文化がまだ残っているからだ。RFCとか。そういった文書では空行で段落を区切るので、つまり空行を構成する二連続の改行文字でない改行文字は無視すればOK。

コマンドラインで章ごとに複数ファイルを書き分けるとして、コマンドを何度も実行するのは面倒くさいし、書き分けられたファイルの目次やページめくり用のリンクのHTMLを手で編集するのも面倒臭い。入力ファイルを一つにして、一連の処理を全自動でやってほしい。となると、一つの入力データの中で章の区切りをどうやって判断するのかという話になる。そこで、プレーンテキストを基盤にして、以下のようなデータ形式を想定する。

====[META]====
[title]: The adventure of Tom Sawyer
by Mark Twain 1876
====[PAGE]====
[head1]: Chapter 1
“TOM!”
No answer.
...
====[PAGE]====
[head1]: Chapter 2
SATURDAY morning was come, and all the summer world was ...
Tom appeared on the sidewalk with a bucket of whitewash and a ...
...

メタデータやページの区切り文字列が存在しない場合には、全体を単一のページにする。したがって、特に前処理をしていないプレーンテキストも普通に処理できる。メタデータやページ区切りを制御したい場合には、エディタで開いて任意の位置に区切り文字を挿入すれば良い。HTMLを処理する場合も単一のページにする。h1とh2とh3はヘッダ扱いになる。

以上の工夫により、普通のWebサイトはURLを指定するだけで注釈付きで読めるようになり、Project Gutenburgとかにある長大な小説なども、タウンロードして静的HTMLに加工する手間をかければ注釈付きで読めるようになった。


まとめ。WordNet+Wiktionaryの統合英和辞書を応用して、Word Wise風の自動和訳注釈サービスを作った。辞書を引くのに時間がかかりすぎて英文が読めないという問題は、この自動注釈サービスを使えば緩和されるだろう。いわゆるポップアップ辞書でも似たような使用感にはなるのだが、一度ページのレンダリングが済んでしまえば通信の必要すらないので、非常にテンポ良く語義を閲覧し、思考の速度を落とすことなく集中して読解に取り組める。さらに、難易度に応じてルビ表示ができるので、辞書を引いているという感覚すらなく読める。

この機能を作って初めて確認できたのだが、WordNet-Wiktionary統合辞書の訳語のカバー率は、固有名詞を除けばほぼ完全と言えるものだった。これで、「俺が考える最強のフリー英和辞書」に欲しい機能と性能は全て揃ったと言える。やり切った感がありすぎて死亡フラグが立ちそうで怖い。いや、英和辞書は英文を読むためにある道具に過ぎないので、これ使って英文読まなきゃ意味がないんだけども。そういえば、娘が「あしながおじさん」という小説が好きで何度も繰り返して読んでいるのだが、いつかその原文(Daddy-Long-Legs)をこれで読んでくれたらなぁと思う。