🎵

日本語の響きの近さを測る

2023/04/16に公開

これはなに?

  • 二つの日本語の単語がどれくらい似て聞こえるかを測る方法を考えました。
  • それを応用して、特定の辞書に含まれる単語だけを使った替え歌の歌詞を生成するプログラムを作成しました。(umimi氏との共同プロジェクト)

デモ

難しい話の前に、まずは聞いてください。

Magic: The Gathering[1]のカード名で「勇気100%」歌ってもらった
https://youtu.be/rN7jr8p2b-U

成果物

今回の手法を用いた替え歌制作の支援ツールをStreamlit Cloudで公開しています。
https://mondegreen-search.streamlit.app
アプリは一定期間アクセスがないと休眠状態になります。その場合はYes, get this app back up!をクリックして起こしてください。

また、開発したPythonプログラムをGitHubで公開しています。
https://github.com/fura2/mondegreen-distance

動機

このプロジェクトは、みっちー氏の動画シリーズ「マジック:ザ・ギャザリングのカード名だけで歌ってみた」にインスパイアされて生まれました。

https://youtu.be/8WzX91E3skI

彼の動画はどれも高いクオリティに仕上げられています。特に、「紅蓮の弓矢」で、歌詞◯◯に◯◯[2]をあてているのは非常にうまく[3]、痺れました。(ネタバレを避けるため伏せ字)

一方で、Magic: The Gatheringには24,000種類を超える膨大な数のカードがあるので、コンピュータの力を借りればもっと "それっぽい" 歌詞を当てはめられるんじゃないかと思い、この課題に興味を持ちました。

解説

二つの日本語の単語 s_1, s_2 に対して、それらが「どれくらい似て聞こえるか」(より正確には、「s_2 の代わりに s_1 と発音することがどの程度自然に聞こえるか」) を定量化する方法を考えました。替え歌への応用では、s_1 を辞書に含まれる単語 (カード名)、s_2 を原曲の歌詞のフレーズとして、「s_2 の代わりに s_1 のように歌うことがどの程度自然に聞こえるか」を定量化することに相当します。

以下では、このような意味をもつ (と直感的に思えるような) ある距離関数 d(s_1,s_2) を定義する方法について解説します。これはレーベンシュタイン距離 (Levenshtein distance) を基にして作られます。

1. レーベンシュタイン距離

二つの文字列の類似度を測る方法として、レーベンシュタイン距離は基本的です。

文字列 s_1s_2 のレーベンシュタイン距離は「s_1 から始めて、次の 3 種類の操作を (任意の順番で任意の回数だけ) 適用して s_2 を作るために必要な最小の操作回数」として定義されます。

  • 挿入:任意の箇所に 1 文字を挿入する
  • 削除:任意の 1 文字を削除する
  • 置換:任意の 1 文字を任意の 1 文字に置換する

レーベンシュタイン距離は、二次元配列

\mathrm{dp}[x, y]=(s_1[:x]\,\text{と}\,s_2[:y]\,\text{のレーベンシュタイン距離})

(0\le x\le\left|s_1\right|,\ 0\le y\le\left|s_2\right|)[4] を漸化式

\begin{align*} &\mathrm{dp}[0, 0]=0,\\ &\mathrm{dp}[0, y+1]=\mathrm{dp}[0, y] + 1,\\ &\mathrm{dp}[x+1, 0]=\mathrm{dp}[x, 0] + 1,\\ &\mathrm{dp}[x+1, y+1]=\min\{\mathrm{dp}[x+1, y]+1, \mathrm{dp}[x, y+1]+1,\mathrm{dp}[x, y]+1-\delta_{s_1[x],s_2[y]}\} \end{align*}

にしたがって埋めていくことによって、O(|s_1|\cdot|s_2|) 時間で計算できます (動的計画法)。ここで、

\delta_{c,c'}=\begin{cases} 1&\text{if}\,\,c=c',\\ 0&\text{otherwise} \end{cases}

と置きました。漸化式 4 行目の右辺の三つの項は、それぞれ挿入、削除、置換操作による遷移を表しています。

2. レーベンシュタイン距離の一般化

レーベンシュタイン距離は次のように一般化できます。

各操作にかかるコストを

  • 文字 c を挿入するコスト:C_\mathrm{I}(c)
  • 文字 c を削除するコスト:C_\mathrm{D}(c)
  • 文字 cc' に置換するコスト:C_\mathrm{R}(c, c')

と置きます。ただし、C_\mathrm{I}(c),C_\mathrm{D}(c),C_\mathrm{R}(c,c')\ge0 と仮定します。

このとき、

\begin{align*} &d(s_1,s_2)=(s_1\,\text{から始めて、上記の}\,3\,{種類の操作を適用して}^{※}\\ &\hphantom{d(s_1,s_2)=(}s_2\,\text{を作るために必要な合計コストの最小値}) \end{align*}

(※ ただし、同じ文字に適用できる操作は高々 1 回まで[5]) と定めると、これはレーベンシュタイン距離の一つの一般化になっています[6][7]。実際、

C_\mathrm{I}(c)=C_\mathrm{D}(c)=1,\quad C_\mathrm{R}(c,c')=1-\delta_{c,c'}

のとき、d はレーベンシュタイン距離に一致します。

レーベンシュタイン距離と同様にして、d は二次元配列

\mathrm{dp}[x, y]=d(s_1[:x], s_2[:y])

(0\le x\le\left|s_1\right|,\ 0\le y\le\left|s_2\right|) を漸化式

\begin{align*} &\mathrm{dp}[0, 0]=0,\\ &\mathrm{dp}[0, y+1]=\mathrm{dp}[0, y] + C_\mathrm{I}(s_2[y]),\\ &\mathrm{dp}[x+1, 0]=\mathrm{dp}[x, 0] + C_\mathrm{D}(s_1[x]),\\ &\mathrm{dp}[x+1, y+1]=\min\{\mathrm{dp}[x+1, y]+C_\mathrm{I}(s_2[y]),\\ &\hphantom{\mathrm{dp}[x+1, y+1]=\min\{}\mathrm{dp}[x, y+1]+C_\mathrm{D}(s_1[x]),\\ &\hphantom{\mathrm{dp}[x+1, y+1]=\min\{}\mathrm{dp}[x, y]+C_\mathrm{R}(s_1[x],s_2[y])\} \end{align*}

にしたがって埋めていくことによって、O(|s_1|\cdot|s_2|) 時間で計算できます。

次の第 3 - 4 節では、d(s_1,s_2) が「s_1s_2 がどれくらい似て聞こえるか」 を表すように C_\mathrm{I},C_\mathrm{D},C_\mathrm{R} をヒューリスティックに定める方法について述べます。

3. 拍

d の計算に現れる「文字」の単位として、今回は、音の響きを比較するのに適した拍 (モーラ) (参考1, 参考2)を採用することにします[8]

基本的にはひらがな 1 文字に 1 拍が対応しますが、「ゃ」「ゅ」「ょ」などは直前のひらがなと合わせて一つの拍を作ります。また、「ん」「っ」「ー」の三つは特殊拍と呼ばれ、これらもそれ単独で一つの拍を作ります。

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. コスト関数の決定

この節では、第 2 節で導入した三つのコスト (挿入コスト C_\mathrm{I}(c)、削除コスト C_\mathrm{D}(c)、置換コスト C_\mathrm{R}(c,c')) をどのように定めたかについて説明します。

s_1 を実際に発音する (辞書に含まれる) 単語、s_2 をターゲットとなるフレーズ (替え歌の文脈では原曲の歌詞の 1 フレーズ) として、我々は「s_2 の代わりに s_1 と発音することの自然さ」を評価したかったのでした。このとき、d(s_1, s_2) の計算において

  • 文字 c を挿入する操作は、c を発音しないことに相当する (字足らず)
  • 文字 c を削除する操作は、c を余分に発音することに相当する (字余り)
  • 文字 cc' に置換する操作は、(s_2 に含まれる) c' の代わりに (s_1 に含まれる) c と発音することに相当する

ことに注意します。

以下の議論は、問題の性質上、筆者個人の感性によるところが大きく、極めてヒューリスティックなものになっています。他にも様々なアプローチがありうると思います。

4.1 挿入コスト

実際にいくつかのケースで発音してみると、字足らずは非常に不自然に聞こえました。そこで、挿入コストは後述の削除コストと置換コストよりもある程度大きい値に設定することにしました。ただし、長音 H についてのみ、字足らずがあってもほとんど気にならなかったので、コストを非常に小さく設定しました。

今回は

C_\mathrm{I}(c)=\begin{cases} 0.1&\text{if}\,\,c=\text{H},\\ 20&\text{otherwise} \end{cases}

と定めました。

4.2 削除コスト

意外なことに、字余りは字足らずよりも比較的違和感なく発音できるように感じました。また、N, Q, H については、他の拍に比べて、字余りとして追加されたときの違和感がさらに少なく思えました。

今回は

C_\mathrm{D}(c)=\begin{cases} 0.5&\text{if}\,\,c=\text{N},\\ 0.3&\text{if}\,\,c=\text{Q},\\ 0.1&\text{if}\,\,c=\text{H},\\ 5&\text{otherwise} \end{cases}

と定めました。

H の扱いについて少し補足しておきます。今回採用した「H の挿入・削除コストを小さく設定する」方法の他にも、「前処理で H を直前の拍の母音に置き換えておく」というやり方が考えられます。しかし、そうした場合には、たとえば 「かん」と「かーん」の距離を小さくするためには C_\mathrm{I}(c)C_\mathrm{D}(c) を文脈に依存して (c だけでなくその直前の文字も使って) 決める必要があり、定式化が複雑になってしまいます。

4.3 置換コスト

C_\mathrm{R}(c,c') の値は、[0,1] の範囲で、cc' の発音が感覚的に近いほど小さく、遠いほど大きくなるように調整しました。

今回は、cc' の少なくとも一方が特殊拍のとき

C_\mathrm{R}(c,c')=1-\delta_{c,c'},

両方が特殊拍でないとき

C_\mathrm{R}(c,c')=0.3\,C_\mathrm{R,C}(c_\mathrm{C},c_\mathrm{C}') +0.1\,C_\mathrm{R,S}(c_\mathrm{S},c_\mathrm{S}') +0.6\,C_\mathrm{R,V}(c_\mathrm{V},c_\mathrm{V}')

と定めました。ここで、c_\mathrm{C},c_\mathrm{S},c_\mathrm{V} はそれぞれ c の子音部分、半母音部分、母音部分を表すものとします (c' についても同様)。C_\mathrm{R,C},C_\mathrm{R,S},C_\mathrm{R,V} はそれぞれ子音間、半母音間、母音間の置換コストで、次の 4.3.1 - 4.3.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

この表を基にして子音間の置換コスト C_\mathrm{R,C} を計算する手順を、(文章で書くよりすっきりするので) Pythonのプログラムで示します。コード中のvoicedは有声音、nasalは鼻音、lateralは側面音、obstruentは阻害音 (破裂音、破擦音、摩擦音の総称)、plosiveは破裂音、affiricateは破擦音、fricativeは摩擦音をそれぞれ意味します。C_\mathrm{R,C} の値は常に [0,1] の範囲に収まっていることに注意してください。

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

たとえば、

C_\mathrm{R, C}(\text{t}, \text{t})=0,\quad C_\mathrm{R, C}(\text{t}, \text{k})=0.2,\quad C_\mathrm{R, C}(\text{t}, \text{c})=0.3,\\ C_\mathrm{R, C}(\text{t}, \text{s})=0.4,\quad C_\mathrm{R, C}(\text{c}, \text{s})=0.3,\quad C_\mathrm{R, C}(\text{h}, \text{r})=0.8

などと計算されます。

4.3.2 半母音間の置換コスト

半母音間の置換コストは、次のように定めました。

C_\mathrm{R,S}(c_\mathrm{S},c_\mathrm{S}')=1-\delta_{c_\mathrm{S},c'_\mathrm{S}}

4.3.3 母音間の置換コスト

a と e はなんとなく近い音、a と i はなんとなく遠い音、といった直感を反映させるために、今回は、IPAの母音チャート (参考1, 参考2) を使いました。

この図の上でのユークリッド距離が最大となるペアに対して値が 1 となるように正規化した距離を C_\mathrm{R,V} としました。具体的な数値を次の表に示します。

c_\mathrm{V} \ c'_\mathrm{V} a i u e o
a 0 0.973 0.699 0.534 0.507
i 0.973 0 0.753 0.438 1
u 0.699 0.753 0 0.603 0.356
e 0.534 0.438 0.603 0 0.699
o 0.507 1 0.356 0.699 0

この定め方にどの程度妥当性があるかは疑問が残るところですが、実際に計算させてみるとある程度それらしい結果が得られたので、とりあえずは許容できるかなというくらいの感覚です。

韻を踏むオプション

距離関数 d(s_1,s_2) を替え歌制作支援ツールに応用するには、素朴には、s_2 をユーザーの入力 (原曲の歌詞) として、s_1 に辞書の単語を順に当てはめていき、d(s_1,s_2) の値が小さいものを出力するのが良さそうです。しかし、実際にいくらか試してみると、d(s_1,s_2) の値に加えて、次の二つの要素が発音したときの気持ちよさに大きく影響していることに気づきました。

  • s_1s_2 の先頭の音が韻を踏んでいる、または一致している
  • s_1s_2 の末尾の音が韻を踏んでいる、または一致している

そこで、次の 4 つのパラメータ h_1,t_1,h_2,t_2 を導入し、今回開発したWebアプリではこれらの値を自由に指定して検索できるようにしました[9]

  • s_2 の先頭 h_1 拍で韻を踏むことを強制する
  • s_2 の末尾 t_1 拍で韻を踏むことを強制する
  • s_2 の先頭 h_2 拍が一致することを強制する
  • s_2 の末尾 t_2 拍が一致することを強制する

h_1=t_1=h_2=t_2=0 のときが (何も条件を課さない) 通常の検索に相当します。おすすめの設定は h_1=0,\ t_1=2,\ h_2=1,\ t_2=0 で、このとき、ちょうどいいカード名が見つかりやすいように感じました。いずれにせよ、一つのパラメータで固定するのではなく、納得いくカード名が見つかるまで色々パラメータを変えて試すのがよいように思います。

関連する研究

こんなニッチなことをやってる人は他にいないだろうと見切り発車で始めたプロジェクトだったのですが、この記事を書くにあたって少し調べてみたところ、近いトピックを扱っている学術研究がいくつか存在していることが分かりました。これらについて言及しておきます。

  • 北條弘.日本語子音音素の類似性 -INDSCALと林の数量化理論第I類による分析−.The Japanese Journal of Psychology (1982).
  • 河野宏志,城塚音也,高木徹.国際音声記号を用いた発音類似度算出アルゴリズムの検討.第13回情報科学技術フォーラム (2014).
  • 島谷二郎,中村泰.Soramimic - 限定された単語による空耳日本語文自動生成システムの開発.情報処理学会研究報告 (2018).

今後の課題

この話の続きとして、次のトピックに興味を持っています。

  • 入力に対して、辞書の単語一つだけではなく、複数の単語からなる列を割り当てる
    • 入力をどこで区切るかを決める問題になる。コスト (距離) の総和を最小にする区切り方を求めるだけなら、素朴な動的計画法で実現できるのであまり難しくない。しかし、実際にこれを実装しても、短い単語ばかりが何度もヒットしてしまい、おもしろみに欠けることが予想される。これを回避するためには、たとえば次のような方針が考えられる。
      • 長い単語ほど優先的にマッチさせる。たとえば、単語が短いほど値が大きくなるようなペナルティ項をコストに加える。
      • インスパイア元のみっちー氏がそうしているように、同じ単語は一回までしか使えない (ハイランダー) という条件を課す。これはより複雑な組合せ最適化の問題になり、効率的に解けるかどうか現時点でよく分かっていない。
    • ポケモンの名前などの、短い単語のみを含む辞書を使う場合には、特にこれをやる効果が高い。
  • 日本語の仮名に対して発音が一意に定まらない問題
    • 日本語では、「う」を「お」と発音したり、「い」を「え」と発音することがありうる。たとえば、「栄光」は「えいこう」とも「えーこー (ええこお)」とも発音される。この効果を取り入れるには、辞書の単語それぞれが複数の発音 (拍の列) を持ちうる、とするのが素直だと思う。一方で、たとえば「お段+う」の並びを常に「お段+お」と発音していい訳ではないことに注意しなければいけない。MTGの例では、「毒気のウーズ (どくけのうーず)」は「どくけのおーず」とは発音されない。つまり、正しいアノテーションのためには文脈を知る必要があり、これが辞書の作成の難しさにつながっている。
    • 母音の無声化についても考える余地がある。
  • より大域的な情報を踏まえた距離
  • 多言語対応
    • 考えることが多くて大変そう。でもできたらとてもおもしろい。
脚注
  1. 1993年にWizards of the Coast社から発売されたトレーディングカードゲーム。世界中で遊ばれており、現在では26,000種類以上のカードが存在している。 ↩︎

  2. 「獲物を屠る《狩人(イェーガー)》」、「氷結する火炎、エーガー」 ↩︎

  3. 巨人というフレーバーまで合わせている、という点で、単に韻を踏んでいる以上のうまさがあります。 ↩︎

  4. Pythonなどで用いられる文字列スライスの表記法を使っています。 ↩︎

  5. この条件は、(1) 文字を直接削除する代わりに、(削除コストが低い) 別の文字に置換してから削除する (2) 文字を直接挿入する代わりに、(挿入コストが低い) 別の文字を挿入してそれを置換する、といった編集手順を除外するために必要です。オリジナルのレーベンシュタイン距離では、そのような手順は最適ではないので、この条件が自動的に満たされています。 ↩︎

  6. 負のコストを許すと、一般には、合計コストをいくらでも小さくできうるので d は ill-defined になります。 ↩︎

  7. 一般に、d は三角不等式を満たしますが、非対称 d(s_1,s_2)\ne d(s_2,s_1) なので、厳密な意味での距離にはなりません。 ↩︎

  8. 記事の一部に音声学に基づく議論が出てきます。筆者はその分野については全くの素人なので、おかしい点などがありましたらどんどん指摘していただけると嬉しいです。 ↩︎

  9. ここは少し注意が必要です。一例として 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