コンピュータはことばを理解できる?
コンピュータと人間が自然な会話ができるようになるとしたらどんなことができるようになるでしょうか?2022年11月に公開されたChatGPTというAIシステムはその夢を現実に近づけました。ChatGPTは自然言語処理(Natural Language Processing, NLP)という技術を使っています。自然言語処理とは人間が使う言葉(自然言語)をコンピュータで処理する技術のことです。このブログ記事では自然言語処理の仕組みについて何回かに分けて解説していきたいと思います。
前回の記事
自然言語処理の常識を覆した会話型生成AIへの道のり[1] “文字とコンピュータ:人類が築き上げた情報のエコシステム”
文字からことばへ
文字とは言語を表現するための最小単位です。文字は音声や筆記などの様々な形式で表現できますが、コンピュータでは文字エンコーディングや文字コードに基づくことでようやくバイナリデータ(つまり0と1の組み合わせのデータ)として表現することができるようになります。これによってコンピュータは文字を読み書きしたり検索したり比較したりすることができるのですが、文字を単なるバイナリデータとして扱うだけではコンピュータは言語の豊かさや多様性といった人間的な感性を理解することができません。また、言語は文字だけではなく単語や文や段落などのより大きな単位で構成されていますし、言語は文法や意味や文脈などのより高次の情報を持っています。これらの情報は人間にとっては自然に理解できるものなのですが、コンピュータにとっては理解しにくいものなのです。
例えば「猫」という存在は「ねこ」というひらがな2文字や「ネコ」というカタカナ2文字でも「猫」という漢字1文字でも「cat」というアルファベット3文字でも「🐱」という絵文字でも表現できます。これらは同じ意味を持つ別の形式の表現です。また、「猫」という存在を表す文字と「犬」という存在を表す文字は「動物」というカテゴリーや「ペット」という属性を持っていますが、「猫」や「犬」という文字自体を眺めても同様の属性を持っていることは明確ではありません。さらに「猫が魚を食べた」という文は「魚が猫に食べられた」という文と同じ事実を表していますが、主語と目的語が入れ替わっています。このように言語は、文字だけでなく、より大きな単位の階層構造を持っているだけでなく、文脈や文法、意味論等の複雑な要素が絡み合って成り立っているのです。
こうした情報や関係を扱うためには、言語を記録したテキストを単なる文字の列としてではなく、意味や構造を持つ言語表現として扱う必要があります。そのためには、テキストを構成する文字や記号を識別し、区切りや結合などの操作を行って、単語や文などの単位に分割したり結合することで構造化する必要があります。
例えば「猫が魚を食べた」と「ネコが魚を食べた」という2つの文を考えてみましょう。これらが同じ意味を持つ文であることを調べるにはどうしたらよいでしょうか。
文字
編集距離(Levenshtein Distance)
1つ目は文字単位で比較することが考えられます。2つの文字列を文字単位で比較したときにどの程度異なっているかを示す指標を編集距離(edit distance、または考案者の名にちなんでLevenshtein distance)[注釈1]と呼びます。具体的には、ある文字列を別の文字列へ変換するために必要な挿入・削除・置換操作の最小編集操作数のことを指します。下記のlevenshtein_distance.pyは、2つの文字列「猫が魚を食べた」「ネコが魚を食べた」の編集距離を計算するPythonスクリプトです。
''' Levenshtein distanceを計算するPythonスクリプト levenshtein_distance.py ''' def levenshtein_distance(s1, s2): ''' この関数は、2つの文字列間の編集距離(レーベンシュタイン距離)を計算します。 編集距離とは、ある文字列を別の文字列へ変換するために必要な最小編集操作数です。 編集操作には、挿入、削除、置換があります。 :param s1: 文字列1 :param s2: 文字列2 :return: 編集距離 ''' m, n = len(s1), len(s2) # d[i][j]は、s1の最初のi文字とs2の最初のj文字間の編集距離を表します。 d = [[0] * (n + 1) for _ in range(m + 1)] # 初期化します。 d[0] = list(range(n + 1)) for i in range(1, m + 1): d[i][0] = i for j in range(1, n + 1): # 置換コストを計算します。 substitution_cost = 0 if s1[i - 1] == s2[j - 1] else 1 # d[i][j]を計算するための漸化式です。 d[i][j] = min( d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + substitution_cost ) return d[m][n] if __name__ == '__main__': print('レーベンシュタイン距離は ' + str(levenshtein_distance('猫が魚を食べた', 'ネコが魚を食べた')) + ' です。')
このスクリプトを実行すると編集距離が2であることが分かります。同じ意味を持つ2つの文字列の編集距離が2というのはどのように解釈すればよいのでしょうか。このままでは少し分かりにくいので、文字列の長さで編集距離を正規化すると、文字列の類似度(similarity)を定義することができます。
ここで、 です。また、 は、文字列 と の編集距離(レーベンシュタイン距離)を表します。類似度は0から1の範囲であり、0は全く似ていない、1は完全に同一であることを意味します。ちなみに、翻訳支援ツールで翻訳メモリを検索したときのマッチ率は似たような技術で計算していると思われます。
この式を使うと2つの文字列「猫が魚を食べた」「ネコが魚を食べた」の類似度は0.75です。同じ意味を持つ文字列同士の意味的類似度としてはまあまあ高く想定していた通りです。
別の文字列を試してみましょう。1つ目の文字列はそのまま、2つ目の文字列の「ネコ」を「猫ちゃん」に変えて、「猫が魚を食べた」と「猫ちゃんが魚を食べた」の編集距離と類似度を計算するとどうなるでしょうか。先ほどのスクリプトの文言を変えて実行すると編集距離は3、類似度は0.7でした。表記は変わったとは言え、同じ意味を持つ文字列同士なのに類似度は下がってしまいました。
もう1つ別の文字列を試してみましょう。1つ目の文字列はそのまま、2つ目の文字列の「ネコ」を「熊」に変えて、「猫が魚を食べた」と「熊が魚を食べた」の間の編集距離と類似度を計算するとどうなるでしょうか。先ほどのスクリプトの文言を変えて実行すると編集距離は1、類似度は0.86(小数点以下第2位で四捨五入)でした。2つの文字列は全く異なる状況を表しており、意味が異なるにも関わらず、類似度は随分と高くなってしまいました。どうやら編集距離では2つの文字列の意味的類似度を計算することは難しいということが分かります。
最長共通部分文字列(Longest Common Substring)
そこで別のアプローチを考えてみます。2つの文字列に共通して現れる文字列を特定しその長さを数えれば2つの文字列がどの程度似ているのかを計算できそうです。2つの文字列に共通して現れる文字列を最長共通部分文字列[注釈2]と呼びます。下記のlongest_common_substring.pyは、2つの文字列「猫が魚を食べた」「ネコが魚を食べた」の最長共通部分文字列を計算するPythonスクリプトです。
''' Longest Common Substringを計算するPythonスクリプト longest_common_substring.py ''' def longest_common_substring(s1, s2): ''' この関数は、2つの文字列の最長共通部分文字列(Longest Common Substring)を求めます。 最長共通部分文字列とは、2つの文字列に共通する部分文字列のうち、最も長いものです。 :param s1: 文字列1 :param s2: 文字列2 :return: 最長共通部分文字列 ''' m, n = len(s1), len(s2) result = 0 end = 0 # length[i][j]は、s1の最初のi文字とs2の最初のj文字が終わる最長共通部分文字列の長さを表します。 length = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if s1[i - 1] == s2[j - 1]: length[i][j] = length[i - 1][j - 1] + 1 if length[i][j] > result: result = length[i][j] end = i - 1 return s1[end - result + 1:end + 1] if __name__ == '__main__': print('最長共通部分文字列は\n' + longest_common_substring('猫が魚を食べた', 'ネコが魚を食べた') + '\nです。')
このスクリプトを実行すると、最長共通部分文字列が「が魚を食べた」であることが分かります。編集距離の場合と同様に類似度を定義すると、その式は次のようになります。
ここで、 です。また、 は、文字列 と の最長共通部分文字列(Longest Common Substring)を表します。
この式を使うと2つの文字列「猫が魚を食べた」「ネコが魚を食べた」の類似度は0.75、「猫が魚を食べた」と「猫ちゃんが魚を食べた」の類似度は0.6、「猫が魚を食べた」と「熊が魚を食べた」の類似度は0.86でした。最長共通部分文字列でも2つの文字列の意味的類似度を計算することは難しいということが分かります。
Jaccard係数、Dice係数、Simpson係数
もっと別のアプローチではどうでしょうか。文字列を1文字ずつに分解して文字の集合とみなし、2つの文字列の類似度を2つの集合の積と和を使って計算する方法が考えられます。つまり「猫が魚を食べた」という文字列を「猫」「が」「魚」「を」「食」「べ」「た」という7文字の集合とみなすわけです。Jaccard係数[注釈3]は2つの集合の積の大きさを2つの集合の和の大きさで割った値、Dice係数[注釈4]は2つの集合の積の大きさを2倍しそれをそれぞれの集合の大きさの和で割った値、Simpson係数[注釈5]は2つの集合の積の大きさをそれぞれの集合の大きさの最小値で割った値ととして定義されます。下記のset_similarity.pyは、2つの文字列「猫が魚を食べた」「ネコが魚を食べた」の類似度をJaccard係数とDice係数とSimpson係数を使用して計算するPythonスクリプトです。
''' 集合を使って類似度を計算するPythonスクリプト set_similarity.py ''' def jaccard_similarity(s1, s2): ''' この関数は、Jaccard係数を使用して、2つの文字列の類似度を計算します。 Jaccard係数は、2つの集合の積の大きさを2つの集合の和の大きさで割った値として定義されます。 :param s1: 文字列1 :param s2: 文字列2 :return: 類似度 ''' set1 = set(s1) set2 = set(s2) intersection = set1 & set2 union = set1 | set2 if len(union) == 0: return 1.0 return float(len(intersection)) / len(union) def dice_similarity(s1, s2): ''' この関数は、Dice係数を使用して、2つの文字列の類似度を計算します。 Dice係数は、2つの集合の積の大きさを2倍し、それをそれぞれの集合の大きさの和で割った値として定義されます。 :param s1: 文字列1 :param s2: 文字列2 :return: 類似度 ''' set1 = set(s1) set2 = set(s2) intersection = set1 & set2 if len(set1) + len(set2) == 0: return 1.0 return 2.0 * float(len(intersection)) / (len(set1) + len(set2)) def simpson_similarity(s1, s2): """ この関数は、Simpson係数を使用して、2つの文字列の類似度を計算します。 Simpson係数は、2つの集合の積の大きさを、それぞれの集合の大きさの最小値で割った値として定義されます。 :param s1: 文字列1 :param s2: 文字列2 :return: 類似度 """ set1 = set(s1) set2 = set(s2) intersection = set1 & set2 min_len = min(len(set1), len(set2)) if min_len == 0: return 1.0 return float(len(intersection)) / min_len if __name__ == '__main__': print('Jaccard係数による類似度は ' + str(jaccard_similarity('猫が魚を食べた', 'ネコが魚を食べた')) + ' です。') print('Dice係数による類似度は ' + str(dice_similarity('猫が魚を食べた', 'ネコが魚を食べた')) + ' です。') print('Simpson係数による類似度は ' + str(simpson_similarity('猫が魚を食べた', 'ネコが魚を食べた')) + ' です。')
このスクリプトを実行すると、Jaccard係数による類似度は0.67、Dice係数による類似度は0.8、Simpson係数による類似度は0.86となりました。編集距離や最長共通部分文字列を使用して計算された類似度と比べると、Jaccard係数による類似度は低く、Simpson係数による類似度は高くなりました。
別の2つの文字列「猫が魚を食べた」「猫ちゃんが魚を食べた」で類似度を計算すると、Jaccard係数による類似度は0.7、Dice係数による類似度は0.82、Simpson係数による類似度は1.0で、編集距離や最長共通部分文字列を使用して計算された類似度と比べると、Jaccard係数とSimpson係数の類似度は高く、うまく機能しているように見えます。またもう1つ別の2つの文字列「猫が魚を食べた」「熊が魚を食べた」で類似度を計算すると、Jaccard係数による類似度は0.75、Dice係数による類似度は0.86、Simpson係数による類似度は0.86となってしまいました。このことから、集合を使っても2つの文字列の意味的類似度を計算することは難しいということが分かります。
次の表はこれまでの3パターンの文字列の組み合わせに対する類似度と、追加で「猫が魚を食べた」と「魚が猫を食べた」の組み合わせに対して計算した類似度をまとめたものです。
文字列のペア | 望ましい 類似度 |
編集距離 | 最長共通 部分文字列 |
Jaccard 係数 |
Dice係数 | Simpson 係数 |
---|---|---|---|---|---|---|
猫が魚を食べた ネコが魚を食べた |
1.0 | 0.75 | 0.75 | 0.66 | 0.8 | 0.85 |
猫が魚を食べた 猫ちゃんが魚を食べた |
1.0 | 0.7 | 0.6 | 0.41 | 0.58 | 0.71 |
猫が魚を食べた 熊が魚を食べた |
0.0[注釈6] | 0.86 | 0.86 | 0.75 | 0.85 | 0.85 |
猫が魚を食べた 魚が猫を食べた |
0.0[注釈7] | 0.71 | 0.57 | 1.0 | 1.0 | 1.0 |
次回へ続く
自然言語処理の常識を覆した会話型生成AIへの道のり[3] “単語が織り成す多次元の世界”
出典
- https://ja.wikipedia.org/wiki/レーベンシュタイン距離
- https://en.wikipedia.org/wiki/Longest_common_substring
- https://en.wikipedia.org/wiki/Jaccard_index
- https://en.wikipedia.org/wiki/Sørensen–Dice_coefficient
- https://en.wikipedia.org/wiki/Overlap_coefficient
- 猫と熊をリンネ式階層分類で比較すると猫も熊も動物界脊索動物門哺乳鋼食肉目に属しますが、猫はネコ科、熊はクマ科に属するので、望ましい類似度が0.0は低すぎる値かもしれません。「猫が魚を食べた」と「熊が魚を食べた」の意味的類似度と、「猫が魚を食べた」と「車が魚を食べた」の意味的類似度を比較すると、前者は生物が生物を食べたという事象、後者は無生物が生物を食べたという事象であり、前者の意味的類似度は後者の意味的類似度より高く設定すべきといえるからです。ですが意味的類似度の基準はコンテキストによって変わると思われるのでこれ以上は踏み込まないことにします。
- 後者の文は前者の文の主語と述語が逆転しており2つの文の示す状況は明らかに異なりますが、先程と同様に意味的類似度をどう設定するかは難しいのでこれ以上は踏み込まないことにします。
この記事を書いた人
亀谷 展
株式会社サン・フレアのリサーチサイエンティスト。
深層学習による自然言語処理やビッグデータ処理を担当。