Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

【Techの道も一歩から】第13回「文書や単語をどうやって表現するかコサイン類似度で学ぶ」

f:id:s_yuka:20180928134040j:plain

こんにちは。 DSOC R&D グループの高橋寛治です。

前回のTF-IDFで、使ってはいたけれど触れなかった文書や単語の表現方法について述べます。 実際に文書をベクトルとして表現し、コサイン類似度を計算することで理解を深めます。

scikit-learnを使わずにできる限りnumpyを利用してコードを記述してみます。 ノートブックはこちらにあります。

ベクトルで取り扱うと計算しやすい

自然言語を取り扱う研究では、文書や単語といったように自然言語を何らかの単位で取り扱います。 自然言語処理でも活用される機械学習手法は、数式で表現されるものであり、データやその演算はベクトルで取り扱われています。

自然言語処理を機械学習で取り扱うために、文書や単語をベクトル化します。 前回紹介したTF-IDFの場合は、文書を単語で構成されたベクトルとしていました。

ベクトル化する単語や文書とは何かを述べていきます。

単語と文書

「単語」と簡単に言いますが、何が単語かを厳密に定義するのは難しいです。 英語の場合はスペース区切りがあるため、それで十分に思うかもしれません。 しかし活用形があったり、複合名詞があったりと何らかの定義を定めて取り扱うのは困難です。

日本語をはじめとしたアジア言語の場合は単語区切りがないため、単語分割が前処理に適用されます。 単語を定めるのは難しく、さまざまな観点からの分割単位となっており、それぞれの観点に基づいた形態素解析辞書が開発されています。 たとえば検索向けには、再現率向上のために分割単位の細かな辞書を用い、固有表現抽出には固有名詞が大量に登録された辞書を用います。

厳密な単語の定義はさておきとして、何かしらの1語を単語と呼びます。 トークンとも呼ばれます。

文書は、単語列で構成されたひとかたまりの単位となります。 たとえばブログ記事だと1記事1文書となります。 タスクにより文書の単位は異なりますが、単語列で構成されたものであることには変わりません。

文書や文をベクトルで表現

自然言語をベクトル化することで機械学習を適用します。

文書や文をどうやってベクトルで表現するといいでしょうか。 前に説明した単語をベクトルの1要素として取り扱うことで表現します。 次に示す例文Aをベクトルで表現してみましょう。

文A「今日 の 夕飯 は 揚げたて の 天丼 だ 。」をベクトル化します。 ここで、単語はスペース区切りで与えられているものとします。

今日 夕飯 揚げたて 天丼
文A 1 2 1 1 1 1 1 1

このように1要素が1単語に対応します。数値は単語が何回出現したかを示します。 数式では次のように示されます。

$$ 文A = [1, 2, 1, 1, 1, 1, 1, 1] $$

このようにベクトル化したものを bag-of-words と呼びます。 単語を袋詰めにしたというもので、語順情報が消失していますが、単語がどれだけ含まれているかということを表現するものです。

語順を捨てることはやや乱暴に思えますが、取り扱いやすい表現であるため広く利用されています。 前の記事で紹介したTF-IDFでもbag-of-wordsを利用しています。

文書の場合も同様に、ある文書に対してどれくらい単語が出現したかをbag-of-wordsで表現します。

bag-of-words間のコサイン類似度の考え方

ベクトルで表現することにより、ベクトルでのさまざまな手法が適用可能となります。 言語処理でよく利用されるベクトル間の類似度計算の手法に、コサイン類似度があります。

コサイン類似度とは、ベクトルのなす角が0に近づく(≒一致する)ほど値が1に近づくコサインの性質を利用して類似度を計算する手法です。 コサイン類似度は次の式で示されます。

$$ cos(A, B) = \frac{A \cdot B}{|A||B|}$$

分母はAとBの大きさの積、分子はAとBの内積をとります。 ここで、各ベクトルを正規化することで分母は1となります。 すなわち正規化(ノルムを1に)したそれぞれのベクトルの内積をとるだけとなります。

やや回りくどいですが、正規化したベクトルの大きさが1となることを確認します。

>>> import numpy as np
# 適当なベクトルを作る
>>> a1 = np.array([1, 0, 2, 3])
# ベクトルの正規化のためのノルムを算出
>>> a1_norm = np.linalg.norm(a1)
# ベクトルの正規化
>>> np.linalg.norm(a1 / a1_norm)
1.0

実際の文書でコサイン類似度を計算

実際の文書に適用してみましょう。 名詞のみを対象として、コサイン類似度を計算します。

入力文書の単語分割

形態素解析にはPure Pythonのjanomeを利用します。

import glob

import numpy as np
from scipy import sparse
from scipy.sparse import linalg as spsolve

from janome.analyzer import Analyzer
from janome.tokenizer import Tokenizer
from janome.tokenfilter import POSKeepFilter, CompoundNounFilter

# 少数第3位まで表示
np.set_printoptions(formatter={'float': '{: 0.3f}'.format})

# 複合名詞は複合名詞化し、名詞のみを抽出する
a = Analyzer(token_filters=[CompoundNounFilter(), POSKeepFilter("名詞")])

# 単語分割を行い、スペース区切りの単語列を1文書とする
docs = []
for f in glob.glob("../tfidf/docs/*.txt"):
    with open(f, "r", encoding="utf-8") as fin:
        doc = []
        for line in fin:
            line = line.strip()
            if not line:
                continue
            doc.append(" ".join([tok.surface for tok in a.analyze(line)]))
        docs.append(" ".join(doc))

単語列からBag-of-words表現を取得

スペース区切りの文のリストを引数にとり、bag-of-wordsに変換するクラスを作ります。

スペース区切りの文例

["Python requestsモジュール 文字コード対策 編集 Webスクレイピング", "..."]

from collections import defaultdict, Counter


class CountVectorizer:
    def __init__(self):
        self.vocablary = defaultdict(lambda: len(self.vocablary))
    
    def fit(self, X):
        for words in self.__iter_words(X):
            [self.vocablary[word] for word in words]
        return self
    
    def transform(self, X):
        s = sparse.dok_matrix((len(X), len(self.vocablary)), dtype=np.uint8)
        for i, words in enumerate(self.__iter_words(X)):
            v = Counter([self.vocablary[word] for word in words])
            for k, freq in v.items():
                s[i, k] = freq
        return s
    
    def fit_transform(self, X, y=None):
        return self.fit(X).transform(X)
    
    def __iter_words(self, docs):
        for doc in docs:
            yield doc.split(" ")

docs を実際にベクトル化します。

count_vectorizer = CountVectorizer()
vecs = count_vectorizer.fit_transform(docs)

ベクトルの正規化処理

計算を簡単化する正規化処理も実装してみましょう。

def normalize(v):
    # 各ベクトルで正規化。スライスにより除算を適用可能な形に変形。
    return v / spsolve.norm(v, axis=1)[:, np.newaxis]

コサイン類似度の計算

正規化しているため、ベクトル間の内積をとるだけとなります。

$$ cos(A, B) = A \cdot B $$

normalized_vecs = normalize(vecs)
cossim = np.dot(normalized_vecs, normalized_vecs.T)

計算があっているかどうかを確かめるために、対角成分が1になっているか、numpy.diag を用いて確認します。 (厳密には誤差があるため、1に限りなく近い値となります。)

>>> print(np.diag(cossim))
[ 1.000  1.000  1.000  1.000  1.000  1.000  1.000  1.000  1.000]

問題ないようです。 今回は入力文書数も少ないため、類似度すべてを表示してみましょう。 n行m列のベクトルは、文書nに文書mとの類似度を表します( n,m∈文書数dn,m∈文書数d )

>>> print(cossim)
[[ 1.000  0.282  0.222  0.144  0.189  0.158  0.150  0.218  0.160]
 [ 0.282  1.000  0.183  0.150  0.130  0.100  0.126  0.236  0.083]
 [ 0.222  0.183  1.000  0.119  0.107  0.089  0.108  0.135  0.081]
 [ 0.144  0.150  0.119  1.000  0.113  0.099  0.251  0.151  0.063]
 [ 0.189  0.130  0.107  0.113  1.000  0.195  0.195  0.141  0.041]
 [ 0.158  0.100  0.089  0.099  0.195  1.000  0.403  0.082  0.119]
 [ 0.150  0.126  0.108  0.251  0.195  0.403  1.000  0.153  0.092]
 [ 0.218  0.236  0.135  0.151  0.141  0.082  0.153  1.000  0.044]
 [ 0.160  0.083  0.081  0.063  0.041  0.119  0.092  0.044  1.000]]

文書0のそれぞれの文書に対するコサイン類似度を確認します。

>>> print(cossim[0])
[[ 1.000  0.282  0.222  0.144  0.189  0.158  0.150  0.218  0.160]]

文書1、文書7が大きいことがわかります。 numpy.argsort を利用してコサイン類似度の降順に文書番号を取得します。 numpy.argsort には並び替えのオプションがないため、正負を反転させた値を与えます。

>>> print(np.argsort(-cossim[0])
[[0 1 2 7 4 8 5 6 3]]

実際の文書を見て、類似度がどのようなものか確認しましょう。 入力文書と類似度の高い文書、低い文書をそれぞれ300文字まで表示します。 前処理後の文書ですので、名詞の分かち書きとなります。

# 入力文書
>>> docs[0][:300]
'Xonsh Xonsh Advent Calendar 2017 13日目 記事 Xonsh 話 これ Xonsh the xonsh shell ~ こちらトム少佐 Xonsh地上管制 ~ Xonsh Python 動作 クロスプラットフォーム Unix よう シェル言語 コマンドプロンプト 言語 Python 3.4+ 上位互換 Bash IPython 基本的 シェル命令 追加 もの Linux Mac OSX Windows メジャー システム上 動作 Xonsh 普段使い 上級者 初級者 よう よう 勢い xonshトップページ 冒頭 This is major Tom to gro'

# 最も類似度が高い文書
>>> docs[1][:300]
'Python requestsモジュール 文字コード対策 編集 Webスクレイピング Advent Calendar 2017 4日目 記事 Python requestsモジュール Requests 人 よう 設計 Python Apache2 Licensed ベース HTTPライブラリ 公式サイト1文目 記述 HTTPライブラリ requestsモジュール 日本語HTML 対象 取得 際 文字化け こと 対策 原因 備忘録 対策まとめ モジュール バージョン レスポンスヘッダ 文字エンコード情報 ため 文字化け 文字化け 原因 対策 大量 ページ ダウンロード とき cChardet B'

# 類似度が一番低い文書
>>> docs[5][:300]
'サポーターズ勉強会 文書分類 ハンズオン 7月31日 文書分類 自然言語処理 タイトル 講師 よう 機会 記事 題目 理由 講演 文書分類 自然言語処理 テーマ の 勉強会 講師 上 自分 こと 題目 何 専門 自然言語処理 実装 得意 ため テーマ 1度 勉強会 時間的制約 中 タスク 観点 文書分類 自然言語処理 魅力的 トピック たくさん 説明 実装 大変 新聞 雑誌 カテゴリ分け 文書分類 テーマ 短時間 最適 講義 一捻り 思い 得意 実装 反映 ハンズオン形式 こと の 以下 スライド資料 以下 ハンズオン 使用 Jupyter Notebook https://colab.res'

類似度の高い文書は、Pythonに関するテーマを述べているため合っているように見えます。 類似度が一番低い文書は、勉強会登壇の話でPythonというテーマではあるものの違う文書ということで良さそうです。 簡単な方法ですが、それらしい類似度計算ができていることがわかります。

古典的な理論を実装して確かめる

文や文書をベクトル化し、その恩恵を実装して確かめました。 コサイン類似度による類似度計算は非常に簡単ですが強力です。 また、ベクトルとして文書を取り扱ういい練習となると思います。

トピックモデルはまだ説明するほどは理解できていないので、さらに勉強し、後日記事を書きたいと思います。

参考文献

  • 高村 大也, 言語処理のための機械学習入門 (自然言語処理シリーズ) , 2010

執筆者プロフィール

高橋寛治 Sansan株式会社 Data Strategy & Operation Center R&Dグループ研究員

阿南工業高等専門学校卒業後に、長岡技術科学大学に編入学。同大学大学院電気電子情報工学専攻修了。在学中は、自然言語処理の研究に取り組み、解析ツールの開発や機械翻訳に関連する研究を行う。大学院を卒業後、2017年にSansan株式会社に入社。現在はキーワード抽出など自然言語処理を生かした研究に取り組んでいる。

© Sansan, Inc.