Begin Again

Begin Again

technology

自然言語処理で映画レコメンドする

f:id:Orihasam810:20201220213615j:plain 映画を探す手法の提案ーーー
ある映画から、特定のジャンルの要素を減らし、別のジャンルを足すことにより、自分が探している雰囲気に近い映画を提案しようと思う。なお、この記事で作成したレコメンドエンジンは、WEBアプリとして公開しているので、ぜひ触ってみて欲しい。

方針

上記目標をコンピュータによって実現するためには、まず、コンピュータが映画の内容やジャンルを理解することが必要である。残念ながら、映画の内容やジャンルを日本語(自然言語)で与えてもコンピュータには理解することができない。そこで映画の内容と映画のジャンルをベクトル(コンピュータでも理解可能な数値表現)で表す。
映画の内容とジャンルをベクトル化できれば、最終的に提案する映画は以下の式で算出できる。ここで太字はベクトルであることを表している。
 movie 1 + movie 2 = recommended movies
算出されたベクトルに近いベクトルを持つ映画を提案すれば、ユーザーの好みに近い映画を提案できるはずである。

映画内容のベクトル化

まずは、映画の内容のベクトル化(=分散表現を獲得)したいと思う。分散表現の獲得のためには当然ながら、映画の内容が記録されたデータを入手する必要がある。
今回は映画の内容を表すデータとして、Wikipediaの記事データを用いる。Wikipediaの各映画記事のあらすじをベクトルで表現すれば、映画の内容を表すベクトルとしてある程度信頼性の高いデータになると考えられる。さらに、テキストデータであれば一つ一つのデータ量も少なく、無料で手入可能なため、個人の実験用として適切なデータだと考えられる。

Wikipediaのデータを入手

まずはデータがなければ何も始められないので、Wikipediaのデータを入手しようと思う。Wikipedia日本語版の記事データは、https://dumps.wikimedia.org/jawiki/latest/で入手可能である。Pythonを使用している人であれば、スクレイピングによりデータを取得するという方法を思い浮かんだ人もいると思うが、Wikipediaでクローラによるデータ収集を行いサーバーに負荷をかけると、アクセス禁止措置や法的措置を取られる可能性がある。データ取得に際は必ず、上記リンクからデータをダウンロードすること。
Wikipedia日本語版の全記事データが、上記リンクのjawiki-latest-pages-articles.xml.bz2をダウンロードすることで入手できる。

Wikipediaのデータをテキストファイルに変換

https://dumps.wikimedia.org/jawiki/latest/からダウンロードしてきたデータはxmlファイルなので、これをそのままxmlパーサなどを用いて解析することで利用することもできるが、手間が掛かる。今回はOSSであるwikiextractorとwikiextractor2sqliteを用いてデータをテキストファイル化して扱いやすくする。
以下のコマンドで、wikiextractorをダウンロード、実行しテキストファイルを入手する。

$ git clone https://github.com/attardi/wikiextractor
$ python -m wikiextractor.WikiExtractor jawiki-latest-pages-articles.xml.bz2

不要なデータを削除

まず、得られたデータは各記事がというタグで囲まれたxmlのような形式となっている。このままでは、使いにくいため、xml parserを用いて各記事ごとのリストに変換した。

#open text file
with open(path) as f:
    s = f.read()
'''
xmlパーサは親タグが存在しないとパースできないため、親タグを付与する。
'''
    #add root tag
    s = '<wiki>' + s
    s = s + '</wiki>'

    #xml parse
    root = ET.fromstring(s)
    #list for store the wiki data
    doc = []

    #get data that tag is doc
    for l in root.iter('doc'):
        doc.append(l.text)

次に、映画に関する記事以外の記事データをすべて削除する。wikipediaのデータを観察すると、映画の記事には、映画という単語が記事内に含まれるとともに、あらすじ、ストーリーを紹介する節が存在する。そこで、テキスト中に、映画という単語が存在しかつ、見出しの「ストーリー」または「あらすじ」が存在すれば映画に関する記事であると推定し、データを作成した。

movie = [s for s in doc if '映画' in s and
        '\nあらすじ.\n' in s or '映画' in s and '\nストーリー.\n' in s]

映画のタイトルと、あらすじの取得は以下のコードで行った。

for m in movie:
    if '\nあらすじ.\n' in m:
        '''
        タイトルを取得
        タイトルのあと、改行が2つ挿入されているため、そこで分割。
        '''
        t = m.split('\n\n')
        t_txt = t[0]
        #邪魔な改行コードを削除
        t_txt = t_txt.replace('\n', '')
        title.append(t_txt)
        '''
        あらすじ部分のみ抽出
        あらすじ、で分割し、あらすじパート終了後で更に分割
         '''
        d1 = m.split('\nあらすじ.\n')
        d2 = re.split('\n.*\.\n', d1[1])
        s_txt = d2[0]#あらすじ
        story.append(s_txt)
    elif '\nストーリー.\n' in m:
        '''
        タイトルを取得
        タイトルのあと、改行が2つ挿入されているため、そこで分割。
        '''
        t = m.split('\n\n')
        t_txt = t[0]
        #邪魔な改行コードを削除
        t_txt = t_txt.replace('\n', '')
        title.append(t_txt)
        '''
        あらすじ部分のみ抽出
        あらすじ、で分割し、あらすじパート終了後で更に分割
        '''
        d1 = m.split('\nストーリー.\n')
        d2 = re.split('\n.*\.\n', d1[1])
        s_txt = d2[0]#あらすじ
        story.append(s_txt)

取得したデータをpandasのデータフレームに変換すると、最終的には18036行のデータベースが作成できた。コードの全体は、githubで公開している。

                         title                                              story
0      スポンティニアス・コンバッション/人体自然発火  1955年、1組の若い夫婦がネヴァダ砂漠で軍によって行われたある実験の実験台となった。それは...
1           レッド・ノーズ・デイ・アクチュアリー  以下では初放送された英国版のあらすじを述べる。作品は前作から13年後の2017年3月に設定さ...
2                    マンジル・マンジル  マルホトラ家の一人娘シーマ。父は娘の行く末を決め、彼女もそれを受け入れた。しかし彼女は旅先で...
3                      族譜 (映画)  日本統治時代の朝鮮、大地主の一族の長であるソル・ジニョン(薛鎮英)は、創氏改名に従って一族の...
4                     プール (映画)  ニューヨーク郊外の高校に通うベンは、仲のいい友人も理想的な恋人エイミーもいて、水泳部ではオリ...
...                        ...                                                ...
18031       サクリファイス (1986年の映画)  舞台はスウェーデンのゴトランド島。舞台俳優の名声を捨てたアレクサンデルは、妻アデライデと娘マ...
18032        ハッピーバースデー 命かがやく瞬間  あすかは母・静代の精神的虐待を受け続けていたが、それでも「母に愛されたい」と思っていた。しか...
18033            プライベート・ベンジャミン  甘やかされて育った金持ちの娘ジュディ・ベンジャミンは、新婚初夜に新郎に腹上死されるという不運...
18034       レ・ミゼラブル (1998年の映画)  囚人のジャン・バルジャンは、窃盗の罪で19年間に及ぶ重労働を課された後に保釈されたが、行く先...
18035                   終りなき戦い  超光速航法「コラプサー・ジャンプ」を発見した人類は、その活動の幅を宇宙へと大きく広げていた。...

[18036 rows x 2 columns]

テキストを整形

入手したあらすじテキストを扱いやすくするために、前処理を行い整形する。前処理では、改行コードの除去、数値データの削除が行われることが多い。数値データが重要でない場合、数字を一律0に変換する処理が行われる場合もあるが、映画のあらすじの場合、数字情報が時代設定等に影響を与えている可能性があるため、今回は、数値データに対する処理は行わず、改行コードの除去のみを行う。

for i in range(len(df)):
    text = df.at[i, 'story']
    #改行削除
    text = text.strip()

次に、テキストを形態要素解析により単語単位に分割する。形態要素解析にはMeCabというライブラリと、NEologdという辞書を用いる。
MeCabをインストールする。

$ sudo apt install mecab
$ sudo apt install libmecab-dev
$ sudo apt install mecab-ipadic-utf8

NEologdをインストールする。

$ git clone https://github.com/neologd/mecab-ipadic-neologd.git
$ cd mecab-ipadic-neologd
$ sudo bin/install-mecab-ipadic-neologd

python用のmecabライブラリのインストール

$ pip install mecab-python3

形態要素解析を実行する。

for i in range(len(df)):
    mec = MeCab.Tagger('-Owakati -d /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd')
    text = df.at[i, 'story']
    #形態要素解析実行
    df.at[i, 'story'] = mec.parse(text)

形態要素解析の結果を見てみると、かなり良い精度で分割できていることが分かる。

                         title                                              story
0      スポンティニアス・コンバッション/人体自然発火  1955年 、 1 組 の 若い 夫婦 が ネヴァダ 砂漠 で 軍 によって 行わ れ た ...
1           レッド・ノーズ・デイ・アクチュアリー  以下 で は 初 放送 さ れ た 英国 版 の あらすじ を 述べる 。 作品 は 前作 ...
2                    マンジル・マンジル  マルホトラ 家 の 一人娘 シーマ 。 父 は 娘 の 行く末 を 決め 、 彼女 も それ...
3                      族譜 (映画)  日本統治時代の朝鮮 、 大 地主 の 一族 の 長 で ある ソル ・ ジニョン ( 薛 鎮...
4                     プール (映画)  ニューヨーク 郊外 の 高校 に 通う ベン は 、 仲 の いい 友人 も 理想 的 な ...
...                        ...                                                ...
18031       サクリファイス (1986年の映画)  舞台 は スウェーデン の ゴトランド 島 。 舞台俳優 の 名声 を 捨て た アレクサン...
18032        ハッピーバースデー 命かがやく瞬間  あすか は 母 ・ 静代 の 精神的 虐待 を 受け 続け て い た が 、 それでも 「...
18033            プライベート・ベンジャミン  甘やかさ れ て 育っ た 金持ち の 娘 ジュディ ・ ベンジャミン は 、 新婚 初夜 ...
18034       レ・ミゼラブル (1998年の映画)  囚人 の ジャン・バルジャン は 、 窃盗 の 罪 で 19年間 に 及ぶ 重労働 を 課さ...
18035                   終りなき戦い  超光速航法 「 コラプサー ・ ジャンプ 」 を 発見 し た 人類 は 、 その 活動 の...

[18036 rows x 2 columns]

単語の分散表現の獲得

単語の分散表現の獲得のためには、様々な手法が考えられるが、今回はgensimというライブラリを用いてword2vecによる分散表現を生成しようと思う。
word2vecは、skip-gramまたはCBOWというモデルを用いて学習を行う手法である。CBOWのタスクは、目的単語の周辺にある単語から目的単語を推測するという訓練を行うニューラルネットワークである。一方、skip-gramのタスクは、ある単語から、その周辺にある単語を推測するという訓練を行うニューラルネットワークである。skip-gramのほうが困難な問題について訓練を行っていることから推測されるように、skip-gramのほうがCBOWよりも学習に時間がかかるが、より精度の高い分散表現を獲得できると言われている。
まずは、gensimライブラリをインストールしておく。

pip install gensim

gensimで文章を読み込み、学習するために全あらすじデータをつなげたテキストファイルを作成する。

import pandas as pd

#pandas dataframe読み込み
df = pd.read_pickle('./morpho_data.pkl')
#word2vec用のテキストデータ作成
sentence = ''
for i in range(len(df)):
    sentence += df.at[i, 'story']

#改行削除
sentence = sentence.replace('\n', '')

with open('./sentence.txt', mode='w') as f:
    f.write(sentence)

gensimでword2vecによる分散表現を算出するためのコードは非常に簡単である。

from gensim.models import word2vec
import pandas as pd

path = './sentence.txt'
#テキスト読み込み
sentences = word2vec.LineSentence(path)

#実行 
model = word2vec.Word2Vec(sentences, sg=1, size=200, min_count=1, window=3)
#ベクトル保存
model.save('./movie.model')

word2vec.Word2Vec()に渡す引数は、sentencesが読み込むテキストデータ、sg=1がskip-gramで学習することを示す(sg=0ならCBOW)、size=200がベクトルの次元数を200次元とすること、 min_count=1が1回未満登場する単語を破棄すること、window=3が学習時のwindow数(目的単語の周辺の何単語を推測するか)を表している。最終的な学習結果はmovie.modelというファイル名で保存される。

映画内容のベクトル化

次に単語ベクトルから文章のベクトルを作成する。今回は、単純に先程獲得した単語の分散表現を足し合わせた分散表現を、映画の内容を表すベクトルとしようと思う。
gensimで作成したモデルから単語のベクトルをロードするには、以下のようにモデルに引数を与えれば良い。

#ベクトルモデル読み込み
model   = word2vec.Word2Vec.load('movie.model')
model['こんにちは']#こんにちはのベクトル

文章のベクトルを作成するに当たっては以下のようなコードで作成した。

from gensim.models import word2vec
import pandas as pd
import numpy as np

#ベクトルモデル読み込み
model   = word2vec.Word2Vec.load('movie.model')

#pandas dataframe読み込み
df = pd.read_pickle('./morpho_data.pkl')

#データフレームにベクトル列を追加
df['vec'] = '' 

print(df)

for i in range(len(df)):
    #ベクトル用のndarray作成
    vec = np.zeros(200)

    #各ストーリーのセンテンスを読み込む
    story = df.at[i, 'story'].split()

    #各単語を読み込みベクトルを足し算していく
    for word in story:
        vec += model[word]

    vec = vec.tolist()
    df.at[i, 'vec'] = vec

#save as pickel file
df.to_pickle('./movie_vec.pkl')

これで映画のストーリーをベクトルで表現することができた。

レコメンデーションの検証

作成した映画あらすじベクトルを用いて映画のレコメンデーションを行うために、映画の類似度を測る指標としてコサイン類似度を使用した。コサイン類似度は2ベクトル間の角度を計算するもので、角度が小さいほど類似度が高く、cosθは1に近い値を示す。コサイン類似度の計算はnumpyを用いて簡単に計算することができる。

np.dot(vector1, vector2) / (np.linalg.norm(vector1) * np.linalg.norm(vector2))

今回は、作成したベクトルがどのような結果を示すのか検証するために、HEROKU上でwebアプリを公開した。フリーのdynoを利用しているため、初回起動に時間がかかる場合があるがご了承頂きたい。

recommendflix.herokuapp.com

バックトゥザフューチャーと、スターウォーズエピソード4のストーリーベクトルを足し合わせ、この合成ベクトルに近い値を持つ映画をレコメンドした。 f:id:Orihasam810:20210116212429p:plain 結果は下の画像のようになった。 f:id:Orihasam810:20210116212433p:plain スターウォーズエピソード5がレコメンデーションされたのは予測できたが、レイダース(インディ・ジョーンズ)やジュラシック ・ワールドなどがレコメンデーションされていた。スターウォーズのようなアクション映画特有のあらすじの書き方や、バックトゥザフューチャーの時代を股にかけるストーリーの特徴であるあらすじに年代が記載されるという特徴がこのような結果をもたらしたのかもしれない。 今回の結果を見てみると、あらすじの特徴をそれなりに反映した面白い結果が得られたように思う。また、分散表現の獲得方法をdoc2vecやBERTに変更することでより精度の良いレコメンデーションが可能だと思われる。これらの手法については今後試していこうと思う。

本記事の内容に間違いがあったり、よりよい手法をご存知の場合はコメントしていただけるとありがたい。