人工無脳は考える
Google Could Platformでチャットボットを動かそう

アルゴリズムの分解(2)

2018/03/28,4/21

実装

1-3までで数学的な吟味が必要な部分はひと段落しましたので、実装してみたいと思います。 実はtf-idfを計算してくれるライブラリはsklearnにTfidfVectorizer として存在しています。それを使えばよかったのでは?となるわけですが、GAEスタンダード環境にはsklearnをインストールしても動作しません。 それにtfidfもcosθ類似度も単純なアルゴリズムですので、この機会に触っておいてもよいかと思います。

実装1. コーパスの各行をベクトルに変換する - corpus_to_tfidf

まずはコーパスからtfidf行列を計算する関数をEchoクラスの中に実装します。準備として__init__()でcorpusを読み、リスト内包表記で行が'#'で始まるコメント行を除去します。
class Echo:
    def __init__(self,corpus_path):

        with codecs.open(corpus_path,'r','utf-8') as f:
            self.corpus = f.readlines()
            """" コメント行の除去 """
            self.corpus = [x for x in self.corpus if not x.startswith('#')]

ここから corpus_to_tfidf()の中身です。

コーパスはタブ区切りテキスト形式になっており、line = line.split()を実行するとline = [発言者名,発言内容]というリストが得られます。 このリストのうち1番目の要素だけを使用します。line[1]がそれにあたるのですが、省略して line.split()[1] としています。 得られた発言内容は Segmenter.segment(line) で分かち書きし、["アボカド","の","サラダ","美味しいね","。"]のようなリストにします。 行を無視して単語を一つのリストに全て並べたものがwords、行で分けたものがcorpus_splittedです。

大きなコーパスを一つのリストにしているのですから、wordsには同じ単語が色々な場所に重複して含まれるようになります。 v = Counter(words) は得られたwordsをCounterにキャストし、これにより単語がいくつあるかが集計されます。 v.keys()によってコーパスに現れる全種類の単語のsetが得られます。これをlistにキャストしたものをtfidf行列の列インデックスとして使います。

    Segmenter = TinySegmenter()
    words = []
    corpus_splitted = []
    for line in self.corpus:
        """ corpusの一列目は発言者名。二列目の発言内容のみ処理する """
        line = line.split()[1]
        l = Segmenter.segment(line)
        words.extend(l)
        corpus_splitted.append(l)

    v = Counter(words)

    self.feat = list(v.keys())

ここからはnumpyを使ってwvとtfを計算します。wvには各行に出現する単語の数を単語ごとに集計します。つまり出現回数行列です。 np.zeros()は指定したサイズのゼロ行列を生成します。つづくforループでは先ほど用意したcorpus_splittedを一行ずつ取り出しwvに変換しています。 ここでもCounterを利用しています。

"""
Term Frequency: 各行内での単語の出現頻度
tf(t,d) = (ある単語tの行d内での出現回数)/(行d内の全ての単語の出現回数の和)
"""
import numpy as np

wv = np.zeros((len(self.corpus),len(self.feat)),dtype=np.float32)
tf = np.zeros((len(self.corpus),len(self.feat)),dtype=np.float32)

i=0
for line in corpus_splitted:
    v = Counter(line)
    for word,count in v.items():
        j = self.feat.index(word)
        wv[i,j] = count

    i+=1
tfは前回次のように定義しました。
その実装は以下のようになります。wvは単語の出現回数行列、np.sum(wv,axis=1)はwvを行ごとに集計したもので、集計の結果次元が一つなくなってベクトルになっています。 コードを見ると、元の数式が一行のpythonコードにそのまま対応しているのがわかると思います。これがnumpyを使う利点で、短いコードでも内容的には全ページで眺めたような巨大な行列の 演算をしてくれています。しかもnumpyの内部はC言語やFORTRANベースで記述されており、pythonを使いながらも高速な計算の恩恵を受けられます。
tf = wv / np.sum(wv,axis=1) # tfの定義そのままの記述!

さらにコードの続きを見ていきましょう。次はdfの計算です。 dfの定義は

でした。コードのnp.apply_along_axisは行または列方向に計算を行うという関数で、 計算の中身はnp.count_nonzeroつまり非ゼロの成分の個数を数えています。これをwvについて、列方向(axis=0)に集計します。 さらに計算結果をtf.shape[0]で割っていますが、これは行列tfの行数、つまり全文書数を表します。 以下、idf、tfidfともに定義をそのまま記述しているのがわかると思います。


以上のように、numpyを使ったtfidfの計算はとてもすっきり記述することができます。

"""
Inverse Document Frequency: 各単語が現れる行の数の割合
df(t) = ある単語tが出現する行の数 / 全行数
idf(t) = log(1+1/df(t))
"""
df = np.apply_along_axis(np.count_nonzero,axis=0,arr=wv) / tf.shape[0]
idf = np.log(1+1/df)

tfidf = tf*idf

self.corpus_df = df
self.corpus_tfidf = tfidf

参照:numpy.apply_along_axis (NumPy v1.6)

実装2 ユーザ入力を同じ方法でベクトルに変換する - speech_to_tfidf

speech_to_tfidf()はユーザ入力文字列をtfidfベクトルに変換する関数です。 処理はこれまで述べてきた内容をほぼなぞるものです。

def speech_to_tfidf(self,speech):
    """
    与えられた文字列speechをcorpusと同じ方法でtfidfベクターに変換する。
    ただしspeechはcorpusに全く現れない単語だけの場合がある。
    この場合tfidfは計算できないため、Noneを返す

    """

    """ 分かち書き """
    speech = Segmenter.segment(speech)


    """ tf """
    wv = np.zeros((len(self.feat)))
    tf = np.zeros((len(self.feat)))

ここで、以下入力文字列の中に self.feat の単語がひとつも見つからなかった場合は None を返しています。

    v = Counter(speech)
    for word,count in v.items():
        if word in self.feat:
            j = self.feat.index(word)
            """
            self.featに含まれない言葉がユーザ発言に含まれる場合、
            現状無視しているが、相手に聞き返すなどの対処がほしい
            """
            wv[j] = count

    nd = np.sum(wv)
    if nd == 0:
        """
        corpusと何も一致しない文字列の場合
        Noneを返す
        """
        return None

以降の処理は同じです。self.corpus_dfは corpus_tfidf()で計算したものがありますので、それを使います。


    """ tfidf """

    tf = wv / nd
    df = self.corpus_df/tf.shape[0]
    idf = np.log(1+1/df)
    tfidf = tf*idf

    return tfidf

実装3 コーパス各行のベクトルとユーザ入力のベクトルについて類似度を計算する - retrieve前半

コーパス各行のベクトルとユーザ入力のベクトルについてcos類似度を計算します。 関数の引数のctはコーパスのtdidf行列、vtはユーザ入力文字列のtfidfベクトルです。 ここからctの0行目とvtとの間の類似度、ctの1行目とvtとの間の類似度・・・を計算して、 ctの全行についvtの類似度を求めます。

  def retrieve(self,ct,vt):
          """
          corpusのtfidfとspeechのtdidfでcos類似度ベクターを生成し、
          ベクターの成分は類似度で、それを降順に並び替えたindexリストを返す

          cos類似度 = ct・vt / |ct||vt|
          """
          inner = np.inner(ct,vt)
          norm_ct = np.apply_along_axis(np.linalg.norm,axis=1,arr=ct)
          norm_vt = np.linalg.norm(vt)

          cossim = inner / (norm_ct*norm_vt)

cosの定義で示したように、cosθの計算には内積とノルムが必要です。numpyにはそれぞれnp.inner()とnp.linalg.norm() という関数がすでに用意されていますので、それらをそのまま使うことにします。 まず内積を計算します。np.inner(ct,vt)ではctはサイズが単語数×コーパス行数の行列で、vtはサイズが単語数のベクトルです。 このように次元が異なる行列やベクトルの間ではnumpyのbroadcastingというメカニズムが、可能ならば適用されます。 この場合はctの各行とvtとの内積を計算します。

次にnorm_ctつまりctの各行についてのノルムを計算します。ここで直接np.linalg.norm(ct)を実行すると2次元のベクトル全体ノルムが返ってきてしまい、NGです。 それを行ごとに実行させるための関数がnp.apply_along_axis()です。なお、より新しいバージョンのnumpyの場合はnp.linalg.norm()にaxisオプションがあるので、np.apply_along_axis()は必要ありません。 一方vtはベクターなので、そのままnp.linalg.norm()を使えます。用意ができたところで定義そのままの式によりcossimすなわちこs類似度を計算します。ここでもnorm_ct*norm_vtの計算にbroadcastingが適用されます。

実装4 スコアの高い順にコーパスを並べ替える - retrieveの続き

のこりはあっという間です。スコアの高い順でのコーパスの並べ替えはretrieve関数の末尾で処理します。np.argsort()は値が昇順になるように並び替えたインデックス値のリストを返します。 つまり最も類似度の高い行の番号がこのリストの最下行に格納されているわけです。今回はこのリストを逆順に並び替えて返します。


      return np.argsort(cossim, axis=0)[::-1]

実装5 スコアの最も高い行の行番号 i を調べる - reply前半

以降、ユーザの入力に対して返事を生成するreply()関数の中で記述しています。おさらいになりますが、ユーザから受け取った文字列user_speechは、まずspeech_to_tfidf()でtfidfベクターに変換されます。 retrieve()でcorpusをユーザ入力に似ている順に並び替えたインデックス値のリストを得ます。その0行目が最も似ている行を示しています。

def reply(self,user_speech):

        user_tfidf = self.speech_to_tfidf(user_speech)
        if user_tfidf is not None:
            """
            コーパス中で最も似ていた行を探す
            """
            pos = self.retrieve(self.corpus_tfidf,user_tfidf)
            pos = pos[0]

実装6 コーパスの i+1 行目の内容を返答として返す -reply後半

返答の行はそのままself.corpus[pos+1]で得ることができます。コーパスのフォーマットはタブ区切りテキストで一列目が発言者の名前、二列目が発言内容でした。 そこで得られたコーパスの行をsplit('\t')で分割し、その[1]要素を返します。なお、コーパスのi+1行目を返すということは、もしコーパスの最終行がユーザ入力に最も近かった場合エラーになるということです。 会話的に対処する方法はいくつか考えられますが、とりあえずこのバージョンでは素直にエラーを出力しています。

            if pos < len(self.corpus):
                """
                コーパス中で最も似ていた行の、次の行を返答として返す。
                コーパスはカンマ区切りテキスト形式で、
                一列目は名前、二列目は発言内容である。二列目を返答として返す
                """
                reply = self.corpus[pos+1]

                return reply.split('\t')[1]


        return self.__class__.__name__+": reply not found"

以上で最もシンプルなチャットボットの本体部分の説明は終わりです。次回はこれをGoogle Cloud Platformでデプロイするあたりを説明します。

HOME ◀ 3 PREV

人工無脳は考える by 加藤真一 2016-2018