日本語の響きの近さを測る
これはなに?
- 二つの日本語の単語がどれくらい似て聞こえるかを測る方法を考えました。
- それを応用して、特定の辞書に含まれる単語だけを使った替え歌の歌詞を生成するプログラムを作成しました。(umimi氏との共同プロジェクト)
- 同氏の2023年2月3日の記事の続きにあたる内容ですが、この記事単体で完結しています。
デモ
難しい話の前に、まずは聞いてください。
Magic: The Gathering[1]のカード名で「勇気100%」歌ってもらった
成果物
今回の手法を用いた替え歌制作の支援ツールをStreamlit Cloudで公開しています。Yes, get this app back up!
をクリックして起こしてください。
また、開発したPythonプログラムをGitHubで公開しています。
動機
このプロジェクトは、みっちー氏の動画シリーズ「マジック:ザ・ギャザリングのカード名だけで歌ってみた」にインスパイアされて生まれました。
彼の動画はどれも高いクオリティに仕上げられています。特に、「紅蓮の弓矢」で、歌詞◯◯に◯◯[2]をあてているのは非常にうまく[3]、痺れました。(ネタバレを避けるため伏せ字)
一方で、Magic: The Gatheringには24,000種類を超える膨大な数のカードがあるので、コンピュータの力を借りればもっと "それっぽい" 歌詞を当てはめられるんじゃないかと思い、この課題に興味を持ちました。
解説
二つの日本語の単語
以下では、このような意味をもつ (と直感的に思えるような) ある距離関数
1. レーベンシュタイン距離
二つの文字列の類似度を測る方法として、レーベンシュタイン距離は基本的です。
文字列
- 挿入:任意の箇所に
文字を挿入する1 - 削除:任意の
文字を削除する1 - 置換:任意の
文字を任意の1 文字に置換する1
レーベンシュタイン距離は、二次元配列
(
にしたがって埋めていくことによって、
と置きました。漸化式
2. レーベンシュタイン距離の一般化
レーベンシュタイン距離は次のように一般化できます。
各操作にかかるコストを
- 文字
を挿入するコスト:c C_\mathrm{I}(c) - 文字
を削除するコスト:c C_\mathrm{D}(c) - 文字
をc に置換するコスト:c' C_\mathrm{R}(c, c')
と置きます。ただし、
このとき、
(※ ただし、同じ文字に適用できる操作は高々
のとき、
レーベンシュタイン距離と同様にして、
(
にしたがって埋めていくことによって、
次の第
3. 拍
基本的にはひらがな
Wikipedia (音素#日本語の音素) を参考にして、ここでは、日本語の拍は次のいずれかの形をしているものと考えました。
- (0または1つの子音) + (0または1つの半母音) + 母音
- 特殊拍
ただし、
- 子音は k, s, t, n, m, r, g, z, d, b, p, c, f, v のいずれか
- 半母音は y, w のいずれか
- 母音は a, i, u, e, o のいずれか
としました。
これらの規則をBNFで書くと、次のようになります。
<mora> ::= [<consonant>] [<semivowel>] <vowel> | <special mora>
<consonant> ::= k | s | t | n | m | r | g | z | d | b | p | c | f | v
<semivowel> ::= y | w
<vowel> ::= a | i | u | e | o
<special mora> ::= N | Q | H
ここで、c, f, v はそれぞれ 「つ」「ふぁ」「ゔ」の子音を、N, Q, H はそれぞれ「ん」「っ」「ー」を表すものとします。
ひらがなと拍との対応には次の表を用いました。いくつか標準的なローマ字表記と異なる割り当てがあるのは、できるだけ音に忠実な変換を試みたためです。
あ段 | い段 | う段 | え段 | お段 | あ段 | い段 | う段 | え段 | お段 | |
---|---|---|---|---|---|---|---|---|---|---|
あ | い/ゐ | う | え/ゑ | お/を | や | ゆ | いぇ | よ | ||
a | i | u | e | o | ya | yu | ye | yo | ||
わ | うぃ | うぇ | うぉ | |||||||
wa | wi | we | wo | |||||||
か | き | く | け | こ | きゃ | きゅ | きょ | |||
ka | ki | ku | ke | ko | kya | kyu | kyo | |||
さ | すぃ | す | せ | そ | しゃ | し | しゅ | しぇ | しょ | |
sa | si | su | se | so | sya | syi | syu | sye | syo | |
た | てぃ | とぅ | て | と | てゃ | てゅ | てょ | |||
ta | ti | tu | te | to | tya | tyu | tyo | |||
な | に | ぬ | ね | の | にゃ | にゅ | にょ | |||
na | ni | nu | ne | no | nya | nyu | nyo | |||
は | ひ | ふ | へ | ほ | ひゃ | ひゅ | ひょ | |||
ha | hi | hu | he | ho | hya | hyu | hyo | |||
ま | み | む | め | も | みゃ | みゅ | みょ | |||
ma | mi | mu | me | mo | mya | myu | myo | |||
ら | り | る | れ | ろ | りゃ | りゅ | りょ | |||
ra | ri | ru | re | ro | rya | ryu | ryo | |||
が | ぎ | ぐ | げ | ご | ぎゃ | ぎゅ | ぎょ | |||
ga | gi | gu | ge | go | gya | gyu | gyo | |||
ざ | ずぃ /づぃ |
ず/づ | ぜ | ぞ | じゃ /ぢゃ |
じ/ぢ | じゅ /ぢゅ |
じぇ /ぢぇ |
じょ /ぢょ |
|
za | zi | zu | ze | zo | zya | zyi | zyu | zye | zyo | |
だ | でぃ | どぅ | で | ど | でゃ | でゅ | でょ | |||
da | di | du | de | do | dya | dyu | dyo | |||
ば | び | ぶ | べ | ぼ | びゃ | びゅ | びょ | |||
ba | bi | bu | be | bo | bya | byu | byo | |||
ぱ | ぴ | ぷ | ぺ | ぽ | ぴゃ | ぴゅ | ぴょ | |||
pa | pi | pu | pe | po | pya | pyu | pyo | |||
つぁ | つぃ | つ | つぇ | つぉ | ちゃ | ち | ちゅ | ちぇ | ちょ | |
ca | ci | cu | ce | co | cya | cyi | cyu | cye | cyo | |
ふぁ | ふぃ | ふぇ | ふぉ | ふゃ | ふゅ | ふょ | ||||
fa | fi | fe | fo | fya | fyu | fyo | ||||
ゔぁ | ゔぃ | ゔ | ゔぇ | ゔぉ | ゔゃ | ゔゅ | ゔょ | |||
va | vi | vu | ve | vo | vya | vyu | vyo |
特殊拍 |
---|
ん |
N |
っ |
Q |
ー |
H |
4. コスト関数の決定
この節では、第
- 文字
を挿入する操作は、c を発音しないことに相当する (字足らず)c - 文字
を削除する操作は、c を余分に発音することに相当する (字余り)c - 文字
をc に置換する操作は、(c' に含まれる)s_2 の代わりに (c' に含まれる)s_1 と発音することに相当するc
ことに注意します。
以下の議論は、問題の性質上、筆者個人の感性によるところが大きく、極めてヒューリスティックなものになっています。他にも様々なアプローチがありうると思います。
4.1 挿入コスト
実際にいくつかのケースで発音してみると、字足らずは非常に不自然に聞こえました。そこで、挿入コストは後述の削除コストと置換コストよりもある程度大きい値に設定することにしました。ただし、長音 H についてのみ、字足らずがあってもほとんど気にならなかったので、コストを非常に小さく設定しました。
今回は
と定めました。
4.2 削除コスト
意外なことに、字余りは字足らずよりも比較的違和感なく発音できるように感じました。また、N, Q, H については、他の拍に比べて、字余りとして追加されたときの違和感がさらに少なく思えました。
今回は
と定めました。
H の扱いについて少し補足しておきます。今回採用した「H の挿入・削除コストを小さく設定する」方法の他にも、「前処理で H を直前の拍の母音に置き換えておく」というやり方が考えられます。しかし、そうした場合には、たとえば 「かん」と「かーん」の距離を小さくするためには
4.3 置換コスト
今回は、
両方が特殊拍でないとき
と定めました。ここで、
4.3.1 子音間の置換コスト
たとえば、k と t はなんとなく近い音、k と m はなんとなく遠い音のように思えます。この感覚を数値化するために、Wikipedia (子音#子音の分類) を参考にして、今回扱う子音をいくつかの基準で分類しました。分類した結果を次の表にまとめます。
k | s | t | n | h | m | r | g | z | d | b | p | c | f | v | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
有声音 | o | o | o | o | o | o | o | o | |||||||
破裂音 | o | o | o | o | o | o | |||||||||
破擦音 | o | ||||||||||||||
摩擦音 | o | o | o | o | o | ||||||||||
鼻音 | o | o | |||||||||||||
側面音 | o |
この表を基にして子音間の置換コスト
def consonant_cost(cons1: Optional[str], cons2: Optional[str]) -> float:
if cons1 == cons2:
return 0.0
if (cons1 is None) or (cons2 is None): # None means no consonant
return 0.8
cost = 0.2
if is_voiced(cons1) != is_voiced(cons2):
cost += 0.2
if is_nasal(cons1) != is_nasal(cons2):
cost += 0.2
if is_lateral(cons1) != is_lateral(cons2):
cost += 0.2
ob1 = is_obstruent(cons1)
ob2 = is_obstruent(cons2)
if ob1 != ob2:
cost += 0.2
elif ob1 and ob2:
def get_index(cons: str) -> int:
if is_plosive(cons):
return 0
if is_affiricate(cons):
return 1
if is_fricative(cons):
return 2
raise RuntimeError(f'Unexpected consonant: {cons}')
cost += [
[0.0, 0.1, 0.2],
[0.1, 0.0, 0.1],
[0.2, 0.1, 0.0],
][get_index(cons1)][get_index(cons2)]
return cost
たとえば、
などと計算されます。
4.3.2 半母音間の置換コスト
半母音間の置換コストは、次のように定めました。
4.3.3 母音間の置換コスト
a と e はなんとなく近い音、a と i はなんとなく遠い音、といった直感を反映させるために、今回は、IPAの母音チャート (参考1, 参考2) を使いました。
この図の上でのユークリッド距離が最大となるペアに対して値が
|
a | i | u | e | o |
---|---|---|---|---|---|
a | |||||
i | |||||
u | |||||
e | |||||
o |
この定め方にどの程度妥当性があるかは疑問が残るところですが、実際に計算させてみるとある程度それらしい結果が得られたので、とりあえずは許容できるかなというくらいの感覚です。
韻を踏むオプション
距離関数
-
とs_1 の先頭の音が韻を踏んでいる、または一致しているs_2 -
とs_1 の末尾の音が韻を踏んでいる、または一致しているs_2
そこで、次の
-
の先頭s_2 拍で韻を踏むことを強制するh_1 -
の末尾s_2 拍で韻を踏むことを強制するt_1 -
の先頭s_2 拍が一致することを強制するh_2 -
の末尾s_2 拍が一致することを強制するt_2
関連する研究
こんなニッチなことをやってる人は他にいないだろうと見切り発車で始めたプロジェクトだったのですが、この記事を書くにあたって少し調べてみたところ、近いトピックを扱っている学術研究がいくつか存在していることが分かりました。これらについて言及しておきます。
- 北條弘.日本語子音音素の類似性 -INDSCALと林の数量化理論第I類による分析−.The Japanese Journal of Psychology (1982).
- 河野宏志,城塚音也,高木徹.国際音声記号を用いた発音類似度算出アルゴリズムの検討.第13回情報科学技術フォーラム (2014).
- 島谷二郎,中村泰.Soramimic - 限定された単語による空耳日本語文自動生成システムの開発.情報処理学会研究報告 (2018).
今後の課題
この話の続きとして、次のトピックに興味を持っています。
- 入力に対して、辞書の単語一つだけではなく、複数の単語からなる列を割り当てる
- 入力をどこで区切るかを決める問題になる。コスト (距離) の総和を最小にする区切り方を求めるだけなら、素朴な動的計画法で実現できるのであまり難しくない。しかし、実際にこれを実装しても、短い単語ばかりが何度もヒットしてしまい、おもしろみに欠けることが予想される。これを回避するためには、たとえば次のような方針が考えられる。
- 長い単語ほど優先的にマッチさせる。たとえば、単語が短いほど値が大きくなるようなペナルティ項をコストに加える。
- インスパイア元のみっちー氏がそうしているように、同じ単語は一回までしか使えない (ハイランダー) という条件を課す。これはより複雑な組合せ最適化の問題になり、効率的に解けるかどうか現時点でよく分かっていない。
- ポケモンの名前などの、短い単語のみを含む辞書を使う場合には、特にこれをやる効果が高い。
- 入力をどこで区切るかを決める問題になる。コスト (距離) の総和を最小にする区切り方を求めるだけなら、素朴な動的計画法で実現できるのであまり難しくない。しかし、実際にこれを実装しても、短い単語ばかりが何度もヒットしてしまい、おもしろみに欠けることが予想される。これを回避するためには、たとえば次のような方針が考えられる。
- 日本語の仮名に対して発音が一意に定まらない問題
- 日本語では、「う」を「お」と発音したり、「い」を「え」と発音することがありうる。たとえば、「栄光」は「えいこう」とも「えーこー (ええこお)」とも発音される。この効果を取り入れるには、辞書の単語それぞれが複数の発音 (拍の列) を持ちうる、とするのが素直だと思う。一方で、たとえば「お段+う」の並びを常に「お段+お」と発音していい訳ではないことに注意しなければいけない。MTGの例では、「毒気のウーズ (どくけのうーず)」は「どくけのおーず」とは発音されない。つまり、正しいアノテーションのためには文脈を知る必要があり、これが辞書の作成の難しさにつながっている。
- 母音の無声化についても考える余地がある。
- より大域的な情報を踏まえた距離
- 「ふんいき」と「ふいんき」のレーベンシュタイン距離は
だが、これらは直感的にはもっと近い。このような文字の転倒を考慮することで、さらにそれらしい距離が作れそう。音位転換、ダメラウ・レーベンシュタイン距離、ジャロ・ウィンクラー距離あたりがキーワードになる。一部、umimi氏の2023年2月3日の記事でも触れられている。2
- 「ふんいき」と「ふいんき」のレーベンシュタイン距離は
- 多言語対応
- 考えることが多くて大変そう。でもできたらとてもおもしろい。
-
1993年にWizards of the Coast社から発売されたトレーディングカードゲーム。世界中で遊ばれており、現在では26,000種類以上のカードが存在している。 ↩︎
-
「獲物を屠る《狩人(イェーガー)》」、「氷結する火炎、エーガー」 ↩︎
-
巨人というフレーバーまで合わせている、という点で、単に韻を踏んでいる以上のうまさがあります。 ↩︎
-
Pythonなどで用いられる文字列スライスの表記法を使っています。 ↩︎
-
この条件は、(1) 文字を直接削除する代わりに、(削除コストが低い) 別の文字に置換してから削除する (2) 文字を直接挿入する代わりに、(挿入コストが低い) 別の文字を挿入してそれを置換する、といった編集手順を除外するために必要です。オリジナルのレーベンシュタイン距離では、そのような手順は最適ではないので、この条件が自動的に満たされています。 ↩︎
-
負のコストを許すと、一般には、合計コストをいくらでも小さくできうるので
は ill-defined になります。 ↩︎d -
一般に、
は三角不等式を満たしますが、非対称d なので、厳密な意味での距離にはなりません。 ↩︎d(s_1,s_2)\ne d(s_2,s_1) -
記事の一部に音声学に基づく議論が出てきます。筆者はその分野については全くの素人なので、おかしい点などがありましたらどんどん指摘していただけると嬉しいです。 ↩︎
-
ここは少し注意が必要です。一例として
について述べると、これは「h_2 の先頭s_2 拍を、(h_2 に対する挿入や置換操作で生まれた拍とマッチさせるのではなく) 最初からs_1 の部分列として存在しているs_1 拍とマッチさせる」ことを意味しています。h_2 として「s_1 と先頭s_2 拍が完全に一致するようなもの」のみを考えることとは異なります。たとえばh_2 のとき、h_2=3 に対して、s_2=\text{いのちから} はヒットする可能性がありますが、s_1=\text{いしのちから} はヒットしません。 ↩︎s_1=\text{ひのちから}
Discussion