👬

文字列や単語の類似度を測る6つの手法

2023/02/13に公開

単語の類似度を計算するプログラムを書いていたところ、「文字列の類似度」や「単語の類似度」という表現を見て、何が違うんだと感じたので調べた結果をまとめます。

調べた結果、筆者の認識では「文字列の類似度は(手法は問わず)文字列の一致度」、「単語の類似度は(手法は問わず)文章中の文脈に基づいた意味の一致度」という認識になりました。さらに、単語の類似度だけでなく、文章の類似度を計算する手法も見つけましたので合わせて整理します。自分が知りたかったのは単語ベクトルに変換した後での類似度の計算でしたので後者、加えて文章の類似度であることがわかりました。

いずれの手法も処理対象が文字列であったり単語であったり文章であったり異なるため、解析したい目的に合わせて使用しましょう。

動作環境

  • Ubuntu20.04
  • Python3.8.10

環境構築

以下のライブラリをインストールします。

requirements.txt
numpy==1.23.5
gensim==4.2.0
sudachipy==0.6.6
sudachidict_core==20221021
scikit-learn==1.2.0
Levenshtein==0.20.9
scipy==1.9.3

検証データ:日本語単語類似度・関連度データセット

日本語の単語ペア(2145ペア)に対する類似度と関連度を人手で評価したデータセットが以下にて公開されています。今回はこのデータセットの類似度の評価値を正解として、各手法で単語の類似度を予測し正解と比較します。今回はページに記載の通り、評価に推奨されているJWSAN1400を使用します。
http://www.utm.inf.uec.ac.jp/JWSAN/
人手による評価値と予測する単語類似度はスケールが異なるため、評価指標にはスピアマンの順位相関係数を使用する場合が論文でよく見られます。本来であれば今回もスピアマンの順位相関係数を計算してどの程度定量的に類似度を表現できているか示すべきですが、今回出力する類似度は、人手で評価した類似度と性質が異なるためこの評価は割愛します。実装は載せますがいずれの手法も結果はほぼ無相関となりました。

実装

与えられた単語ペアの類似度を6つの手法で計算するSimilarityMeasureクラスを実装します。

全体実装 Similarity Measure (188行)
import ot
import time
import yaml
import numpy as np

import gensim
import sudachipy
import sklearn.metrics

import difflib
import Levenshtein

from numpy import dot 
from numpy.linalg import norm
from scipy.stats import spearmanr


class SimilarityMeasure:

    def __init__(self, opt: dict):
        self.opt = opt
        dict = sudachipy.Dictionary()
        self.tokenizer = dict.create()

        self.vec = gensim.models.KeyedVectors.load(opt["path"]["chive"])
        self.vocab = self.vec.index_to_key
        return


    def tokenize(self, word: str) -> str:
        return self.tokenizer.tokenize(word)


    def load_dataset(self, path: str) -> list:

        with open(path, mode="r", encoding="utf-8") as f:
            lines = f.readlines()[1:]
        
        words = list()
        human_evaluations = list()

        for line in lines:
            tokens = line.split(",")

            word1 = self.tokenize(tokens[1])
            word2 = self.tokenize(tokens[2])

            human_evaluations.append(tokens[4])

            if len(word1) > 1 or len(word2) > 1:
                continue
            else:
                words.append((word1[0].normalized_form(), word2[0].normalized_form()))

        return words, human_evaluations


    def grab_gestalt(self, word1: str, word2: str) -> float:
        return difflib.SequenceMatcher(None, word1, word2).ratio()


    def grab_levenshtein(self, word1: str, word2: str) -> float:
        return 1 - Levenshtein.ratio(word1, word2)


    def grab_jaro_winkler(self, word1: str, word2: str) -> float:
        return Levenshtein.jaro_winkler(word1, word2)


    def grab_cosine_similarity(self, word1: str, word2: str) -> float:

        vec1 = self.vec[word1].tolist()
        vec2 = self.vec[word2].tolist()

        return dot(vec1, vec2) / (norm(vec1) * norm(vec2)) 


    def grab_word_movers_distance(self, word1: str, word2: str) -> float:

        vec1 = np.array([self.vec[word1]])
        vec2 = np.array([self.vec[word2]])

        word1_dist = np.ones(len([word1])) / len([word1])
        word2_dist = np.ones(len([word2])) / len([word2])

        transport_cost = sklearn.metrics.pairwise_distances(
            vec1, vec2, metric="euclidean")

        return ot.emd2(word1_dist, word2_dist, transport_cost)


    def get_wordvector(self, word):
        return [np.array(self.vec[kw]) for kw in [word] if kw in self.vocab]


    def get_sum_norm(self, wordvector):
        sum_norm = 0
        for w_i in wordvector:
            sum_norm += norm(w_i)
        return sum_norm


    def get_cosine_similarity(self, v1, v2):
        return np.dot(v1, v2) / (norm(v1) * norm(v2))


    def grab_word_rotators_distance(self, word1: str, word2: str) -> float:

        vec1 = [np.array(self.vec[kw]) for kw in [word1] if kw in self.vocab]
        vec2 = [np.array(self.vec[kw]) for kw in [word2] if kw in self.vocab]

        word1_dist = [norm(w1_i) / self.get_sum_norm(vec1) for w1_i in vec1]
        word2_dist = [norm(w1_i) / self.get_sum_norm(vec2) for w1_i in vec2]

        transport_cost = sklearn.metrics.pairwise_distances(
            vec1, vec2, metric="cosine")

        return 1 - ot.emd2(word1_dist, word2_dist, transport_cost)


    def spearmansr(self, human_evaluations, word_similarities, name):
        correlation, pvalue = spearmanr(human_evaluations, word_similarities)
        print(f"R of {name}: {correlation} with {pvalue}")
        return


    def run(self):
        """単語同士の類似度計算
        """
        path = self.opt["path"]["jwsan1400"]

        words, humans = self.load_dataset(path)

        gestalts = list()
        levenshteins = list()
        jaro_winklers = list()
        cosine_similarities = list()
        word_movers_distances = list()
        word_rotators_distances = list()
        human_evaluations = list()
        metrics = list()
        for (word1, word2), human in zip(words, humans):

            if word1 not in self.vocab or word2 not in self.vocab:
                continue

            human_evaluations.append(human)
            gestalt = self.grab_gestalt(word1, word2)
            levenshtein = self.grab_levenshtein(word1, word2)
            jaro_winkler = self.grab_jaro_winkler(word1, word2)
            cosine_similarity = self.grab_cosine_similarity(word1, word2)
            word_movers_distance = self.grab_word_movers_distance(word1, word2)
            word_rotators_distance=self.grab_word_rotators_distance(word1,word2)

            metric = f"{word1},{word2},{human},{gestalt},{levenshtein},{jaro_winkler},{cosine_similarity},{word_movers_distance},{word_rotators_distance}"
            metrics.append(metric)

            gestalts.append(gestalt)
            levenshteins.append(levenshtein)
            jaro_winklers.append(jaro_winkler)
            cosine_similarities.append(cosine_similarity)
            word_movers_distances.append(word_movers_distance)
            word_rotators_distances.append(word_rotators_distance)


        with open(self.opt["path"]["out"], mode="w", encoding="utf-8") as o:
            o.write(f"word1,word2,human_evaluation,gestalt,levenshtein,jaro_winkler,cosine_similarity,word_movers_distance,word_rotators_distance\n")
            o.writelines("\n".join(metrics))

        self.spearmansr(human_evaluations, gestalts, "gestalt")
        self.spearmansr(human_evaluations, levenshteins, "levenshtein")
        self.spearmansr(human_evaluations, jaro_winklers, "jaro_winkler")
        self.spearmansr(human_evaluations, cosine_similarities, "cosine_sim")
        self.spearmansr(human_evaluations, word_movers_distances, "wmd")
        self.spearmansr(human_evaluations, word_rotators_distances, "wrd")

        return


if __name__ == "__main__":
    # config.yamlの読込
    with open('config.yaml', 'r') as yml:
        opt = yaml.safe_load(yml)
    
    sm = SimilarityMeasure(opt)
    sm.run()

文字列や単語の類似度を測るために実装した6つの手法を説明します。


1. ゲシュタルトパターンマッチング

※文字列同士の類似度を測るための指標です。

与えられた2つの文字列に対して、一致した文字数の2倍を各文字列の長さの和で割って類似度を表現する手法です。入力する文字列の順番を入れ替えると出力が一致しなくなる非可換性という性質を持つので使用時にはどとらの文字列に対して類似度を計算したいのか注意して使用しましょう。

同じ文字列同士であれば類似度は1、1文字も一致していない文字列同士であれば類似度は0です。実装は以下です。

import difflib

def grab_gestalt(self, word1: str, word2: str) -> float:
    return difflib.SequenceMatcher(None, word1, word2).ratio()

詳細な解説は以下に記載されています。
https://qiita.com/shoku-pan/items/8185dba860bb7b7aed67


2. Levenshtein距離 (最小編集距離)

※文字列同士の類似度を測るための指標です。

与えられた2つの文字列に対して、「挿入」「削除」「置換」の3つの編集処理から計算される最小編集コストを計算し、文字列の長さで割ることで類似度を表現する手法です。

同じ文字列同士であれば類似度は0、1文字も一致していない文字列同士であれば類似度は1です。編集の要否をコストにしているので似ている場合は0に近づく点に注意して使用しましょう。実装は以下です。

import Levenshtein

def grab_levenshtein(self, word1: str, word2: str) -> float:
    return 1 - Levenshtein.ratio(word1, word2)

図解付きの解説は以下に記載されています。
https://mieruca-ai.com/ai/levenshtein_jaro-winkler_distance/


3. Jaro-winkler距離

※文字列同士の類似度を測るための指標です。

与えられた2つの文字列に対して、窓幅(英語のデフォルトでは4文字)内で一致する文字数と置換の要否からJaro距離を計算し、先頭から一致している文字数を使ってJaro距離を修正することで類似度を表現する手法です。

同じ文字列同士であれば類似度は1、1文字も一致していない文字列同士であれば類似度は0です。実装は以下です。

import Levenshtein

def grab_jaro_winkler(self, word1: str, word2: str) -> float:
    return Levenshtein.jaro_winkler(word1, word2)

図解付きの解説は以下に記載されています。
https://mieruca-ai.com/ai/levenshtein_jaro-winkler_distance/


4. 単語ベクトル間のcosine類似度

※単語間の類似度を測るための指標です。

まず初めに、与えられた2つの文字列を、Word2Vecに代表されるような単語分散表現に置き換えます。単語の分散表現の図解付きの解説は以下に記載されています。

https://qiita.com/Hironsan/items/11b388575a058dc8a46a

2つの文字列は、分散表現によってベクトルに変換されますから、あとは2つのベクトルがなす角のcosine値で類似度を表現する手法です。

同じ文字列同士であれば類似度は1、全く意味の異なる文字列同士であれば類似度は−1です。学習した分散表現の品質に依存して類似度が決まるため、使用する分散表現に注意して使用しましょう。実装は以下です。

from numpy import dot
from numpy.linalg import norm

def grab_cosine_similarity(self, word1: str, word2: str) -> float:

    vec1 = self.vec[word1].tolist()
    vec2 = self.vec[word2].tolist()

    return dot(vec1, vec2) / (norm(vec1) * norm(vec2)) 

図解付きの解説は以下に記載されています。
https://atmarkit.itmedia.co.jp/ait/articles/2112/08/news020.html


5. 単語ベクトル間の単語運搬距離 (Word Mover's Distance)

※本来は文章間の距離を測るための指標です。

まず初めに、与えられた2つの文字列を、Word2Vecに代表されるような単語分散表現に置き換えます。与えられた文字列は例えば300次元のベクトルに置き換えられますから、次にこのベクトルを確率分布であるとみなして最適輸送問題を解きます。ある文字列を表現した確率分布を、他方の文字列を表現した確率分布に運搬する距離(輸送コスト)を定義し計算することで、類似度を表現する手法です。

同じ文字列同士であれば類似度は0、全く意味の異なる文字列同士であればあるほど類似度は0から離れた値で表現されます。[0,1]や[-1,1]のような決まった範囲で類似度が決まらないのはコストをユークリッド距離で定義しているためです。また、学習した分散表現の品質に依存して類似度が決まるため、こちらも使用する分散表現に注意して使用しましょう。

単語運搬距離では特徴空間中の輸送コストをユークリッド距離で定義します。実装は以下です。

import ot
import sklearn.metrics

def grab_word_movers_distance(self, word1: str, word2: str) -> float:

    vec1 = np.array([self.vec[word1]])
    vec2 = np.array([self.vec[word2]])

    word1_dist = np.ones(len([word1])) / len([word1])
    word2_dist = np.ones(len([word2])) / len([word2])

   transport_cost = sklearn.metrics.pairwise_distances(
        vec1, vec2, metric="euclidean")

    return ot.emd2(word1_dist, word2_dist, transport_cost)

詳細は以下の論文をご覧ください。
https://proceedings.mlr.press/v37/kusnerb15.html


6. 単語ベクトル間の単語回転距離 (Word Rotator's distance)

※本来は文章間の距離を測るための指標です。今回は単語間の距離を測るのに使用したため値はcosine類似度と一致しています。

まず初めに、与えられた2つの文字列を単語分散表現に置き換えます。次に、文字列から変換された単語ベクトルは、ノルムに単語の重要度が、方向ベクトルには単語の意味が、それぞれ分けてエンコードされていると仮定を置いて、各単語の重み(確率質量)を単語ベクトルのノルムから求めます。各単語ベクトルを入力文字列のノルムの総和で正規化することで、この重みは[0,1]の間で求められ、単語ベクトルは高次元(例えば300次元)上での単位円(超球面)上での回転で表現できます。後はノルムで正規化した単語ベクトル同士のcosine距離をコストに最適輸送コストで類似度を表現する手法です。

同じ文字列同士であれば類似度は1、全く意味の異なる文字列同士であれば類似度は0で表現されます。単語回転距離では特徴空間中の輸送コストをcosine距離で定義します。

筆者はcosine類似度の良さである、ベクトルの大きさに左右されないという点が、メリットでありながらベクトルの大きさを考慮できないというデメリットにもなり得る点だと考えていました。それを、ベクトルのノルムを基に最適輸送の確率質量に対応させて輸送コストとして計算することで、単語ベクトルの性質を効果的に捉えた類似度の評価指標になっていると解釈しています。kenta1984さんの実装を参考にさせていただきました。実装は以下です。
https://github.com/kenta1984/wrd

import ot
import numpy as np
import sklearn.metrics

def get_wordvector(self, word):
    return [np.array(self.vec[kw]) for kw in [word] if kw in self.vocab]


def get_sum_norm(self, wordvector):
    sum_norm = 0
    for w_i in wordvector:
    sum_norm += norm(w_i)
    return sum_norm


def grab_word_rotators_distance(self, word1: str, word2: str) -> float:

    vec1 = [np.array(self.vec[kw]) for kw in [word1] if kw in self.vocab]
    vec2 = [np.array(self.vec[kw]) for kw in [word2] if kw in self.vocab]

    word1_dist = [norm(w1_i) / self.get_sum_norm(vec1) for w1_i in vec1]
    word2_dist = [norm(w1_i) / self.get_sum_norm(vec2) for w1_i in vec2]

    transport_cost = sklearn.metrics.pairwise_distances(
        vec1, vec2, metric="cosine")

    return 1 - ot.emd2(word1_dist, word2_dist, transport_cost)

詳細は以下の論文をご覧ください。
https://www.anlp.jp/proceedings/annual_meeting/2020/pdf_dir/D1-2.pdf

実装結果

JWSAN1400の先頭の10単語対の類似度の結果を示します。スケールが異なるので数値を直接比較できない点にご注意ください。

単語1 単語2 人手評価 Ges Lev JW Cos WMD WRD
か細い 弱い 3.36 0.4 0.6 0 0.3892 3.995 0.3892
きつい 甚だしい 1.92 0.2857 0.7142 0.5277 0.1805 4.032 0.1805
きつい 悲しい 1.51 0.3333 0.6667 0.5556 0.3587 3.490 0.3587
けばけばしい どぎつい 3.64 0.2 0.8 0.4722 0.6860 3.083 0.6860
さもしい 醜い 2.33 0.3333 0.6667 0 0.5004 3.831 0.5004
とろい 鈍い 4.31 0.4 0.6 0 0.4407 3.723 0.4407
やばい 危ない 4.11 0.3333 0.6667 0.5556 0.6143 2.6261 0.6143
暗い 湿っぽい 2.38 0.3333 0.6667 0 0.5359 3.675 0.5359
暗い 重苦しい 2.69 0.3333 0.6667 0 0.5451 3.430 0.5451
暗い 物悲しい 2.18 0.3333 0.6667 0 0.4955 3.628 0.4955

ゲシュタルトパターンマッチング、Levenshtein距離、Jaro-winkler距離は文字列が一致している単語は類似度が高いと出力していることが見て取れます。一方Cosine類似度、WMD,WRDは文字列の一致度ではなく、学習した単語分散表現に基づいて、似た文脈で登場する単語の類似度が高くなるように出力されていることが見て取れます。

例えば、「けばけばしい」と「どきつい」は、文字列の一致度は低いためゲシュタルトパターンマッチング、Levenshtein距離、Jaro-winkler距離は類似度が低いと出力していますが、Consine類似度、WMD,WRDは類似度が高いと出力しています。他の例についても同じ傾向が見て取れるため、言葉の類似度には、大きく分けて「文字列の一致度」を計る指標と、「文章中の文脈に基づいた意味の一致度」を計る指標の2パターンに分けて考えることができそうです。

言葉の類似度を考える

言葉の類似度はもう少し分解して考えられそうです。例えば、人間は生活していく中で似ている言葉を知らぬ間に学習しています。例として、

  • 「犬」と「猫」
  • 「ラーメン」と「うどん」
  • 「怪我」と「負傷」
  • 「暗い」と「湿っぽい」
  • 「やばい」と「危機」

思いついた順に上げるだけでも色々な場面で似ている言葉は登場します。上の例だと「犬」と「猫」は動物だし、「ラーメン」と「うどん」は食べ物です。しかし、「犬」と「人間」は同じ動物ですが、犬と猫よりは似ていないし、「ラーメン」と「ケーキ」は同じ食べ物ですがラーメンとうどんより似ているとは言えません。

要は「ラーメン」と「うどん」は食べ物かつ麺類だから「ラーメン」と「ケーキ」よりも似ているのだし、「犬」と「猫」は四足歩行する動物だから「犬」と「人間」より似ている、と言う話なのですが、このあたりの考慮は今回実装した指標では含まれていないように思います。本来であれば単語同士の共通部分や、単語群を包含する大きな意味を持つ単語などが存在するように思います。このような言葉の構造を体系立てて整理して、言葉の類似度を計算すれば、より人間の理解と一致した言葉の類似度が計算できるのかもしれません。

まとめ

言葉の類似度を図る指標を6つ実装しました。結論簡単な類似度は表現できていますが、言葉の構造関係を捉えた類似度には今一歩及ばず、という印象でした。最近は大規模言語モデルが流行っているので、そのようなモデルに聞いてみて類似度を直接予測してもらうと案外良い値が返ってくるかもしれません。

参考

Keisuke Inohara & Akira Utsumi: JWSAN: Japanese word similarity and association norm. Language Resources & Evaluation, Vol.56, pp.109-137 (2022).

猪原 敬介,内海 彰: 日本語類似度・関連度データセットの作成,言語処理学会第24回年次大会発表論文集,pp.1011-1014 (2018).

Discussion