Python 感情極性対応表とjanomeを使って日本語で良いニュースと悪いニュースの分類を試みる

日本語のニュース文章を、感情極性対応表とjanomeを使って、良いニュース・悪いニュースで分類してみます。

livedoorニュースコーパスのロード

今回は以下で提供されているlivedoorニュースコーパスの内、トピックニュースをデータセットとして使います。

ダウンロード - 株式会社ロンウイット

ldcc-20140209.tar.gzをダウンロード・解凍すると、textディレクトリ以下に9つのディレクトリが展開されます。
livedoorトピックニュースの記事はtopic-newsディレクトリ配下にあります。 1記事1ファイルとなっており、770ファイルが収録されています。

各ファイルは、1行目に記事のURL、2行目に投稿日時、3行目に記事タイトル、4行目以降は記事本文となっており、HTMLタグなどは含まないものとなっています(以下はtext/topic-news/topic-news-5936721.txtから転載)。

http://news.livedoor.com/article/detail/5933217/
2011-10-13T11:16:00+0900
iOS5アップデートで大混乱
 日本時間13日未明(米国時間12日)、iOS4を大型アップデートしたiOS5が発表された。しかし、このiOS5にアップロード出来ない人が続出し、13日未明から大混乱が発生している。

 10月13日11時現在で、twitterのトレンドにもiOS5アップデートでエラーが出た際に表示される「Error 3200」が出現するほど騒ぎになっている。

・#iOS5 アップデートのエラー(3200,-5000,3014,復元)と対処法

 twitter上でも、エラー報告や対処法を教えるツイートで盛り上がっている。「時間をおいてやれば出来る」そうなので、しばらく待ってから更新するのがいいのかもしれない。

・iOS 5へのアップデート前に必ず準備したい4つのアドバイス

 iOS5をアップデートする助けとして、話題になっているブログでは「準備をしてからスタート」するのを勧めている。このiOS5のアップデートにまつわる混乱はすぐ収まるのだろうか。

■関連記事
・iOS5の新機能とは - アップル
・KDDIがAndroidとiPhone、Windows Phoneを扱う唯一のキャリアになるまでの戦略を振り返ってみた
・iPhone 4Sに買い替えたい6つの理由

このlivedoorトピックニュースの文章をPythonでロードします。

まずは、textディレクトリを、今回作成するPythonファイルと同じディレクトリにコピーしておきます。
次に、以下のコードでstringリスト(texts)オブジェクトにロードします。

  • 先頭2行は不要なメタ情報のため、削除しています(記事タイトルは文章の一部と捉え、残しています)
  • 文章の後ろにある"【関連記事】"や"■関連リンク"などのリンクも不要なので削除しています
import glob
import re


# livedoorトピックニュースの文章リスト
texts = []

# livedoorトピックニュースのファイル名一覧を取得する
paths = glob.glob('./text/topic-news/topic-news-*.txt')

for path in paths:
    with open(path, 'r') as f:
        original_text = f.read()
        
        # 先頭2行は不要なメタ情報のため、削除
        text = re.sub(r'^.*\n.*\n', '', original_text) 
        # "【関連"や"■関連"以降は削除
        result = re.search(r'(【|■)関連', text)
        if result is not None:
            text = text[:result.start()]
            
        texts.append(text)

# 最初の1件を表示
print(texts[0])

これで、ヘッダや関連記事へのリンクが削除されたピュアな文章がtextsにロードできます。

janomeを使った形態素解析

日本語の文章は、英語などの言語と異なり、語が区切られずに連続しているという特徴があります。 そのため、構文解析や意味解析の前に、まずは分かち書きを行う必要があります。

この分かち書き機械的に行う処理を形態素解析と言いますが、日本語の場合はMeCabJUMAN++などのエンジンを使うことで、高い精度で形態素解析ができます。

今回は、Pythonからこれら形態素解析エンジンを使うために、打田氏が開発されているjanomeというパッケージを導入します。
MeCabPythonで利用できるようになるまではMeCabのインストール、辞書のダウンロード、Pythonのラッパーの実装・組み込みなどを行う必要があって大変なのですが、janomeはコマンド一発でインストールでき、簡単にPythonから形態素解析ができるようになります(辞書もmecab-ipadic-2.7.0-20070801が内包されています)。

$ pip install janome

github.com

janome.tokenizer.Tokenizerを使うと、日本語の文章「新幹線に乗り、友人・我孫子に会いに行く。」が分かち書きされ、それぞれに品詞のラベル付けが行われていることがわかります。

from janome.tokenizer import Tokenizer


t = Tokenizer()
for token in t.tokenize('新幹線に乗り、友人・我孫子に会いに行く。'):
    print(token)
# 新幹線    名詞,一般,*,*,*,*,新幹線,シンカンセン,シンカンセン
# に  助詞,格助詞,一般,*,*,*,に,ニ,ニ
# 乗り   動詞,自立,*,*,五段・ラ行,連用形,乗る,ノリ,ノリ
# 、  記号,読点,*,*,*,*,、,、,、
# 友人   名詞,一般,*,*,*,*,友人,ユウジン,ユージン
# ・  記号,一般,*,*,*,*,・,・,・
# 我孫子    名詞,固有名詞,人名,姓,*,*,我孫子,アビコ,アビコ
# に  助詞,格助詞,一般,*,*,*,に,ニ,ニ
# 会い   動詞,自立,*,*,五段・ワ行促音便,連用形,会う,アイ,アイ
# に  助詞,格助詞,一般,*,*,*,に,ニ,ニ
# 行く   動詞,自立,*,*,五段・カ行促音便,基本形,行く,イク,イク
# 。  記号,句点,*,*,*,*,。,。,。

それでは先程ロードしたlivedoorトピックニュースの文章をjanome形態素解析します。

  • プロパティのみを持つCorpusElementというクラスを、入れ物用に作成しています
class CorpusElement:
    def __init__(self, text='', tokens=[], pn_scores=[]):
        self.text = text # テキスト本文
        self.tokens = tokens # 構文木解析されたトークンのリスト
        self.pn_scores = pn_scores # 感情極性値(後述)


# CorpusElementのリスト
naive_corpus = []

naive_tokenizer = Tokenizer()

for text in texts:
    tokens = naive_tokenizer.tokenize(text)
    element = CorpusElement(text, tokens)
    naive_corpus.append(element)

# 最初の1文章の形態素解析結果を表示
for token in naive_corpus[0].tokens:
    print(token)

先程ロードした文章の一つ(元の文章は、 http://news.livedoor.com/lite/article_detail/5903225/ )を形態素解析した結果は以下の通りになります。 固有名詞も判別できていることがわかります。

悪評   名詞,一般,*,*,*,*,悪評,アクヒョウ,アクヒョー
が 助詞,格助詞,一般,*,*,*,が,ガ,ガ
次 名詞,一般,*,*,*,*,次,ツギ,ツギ
から  助詞,格助詞,一般,*,*,*,から,カラ,カラ
次 名詞,一般,*,*,*,*,次,ツギ,ツギ
へ 助詞,格助詞,一般,*,*,*,へ,ヘ,エ
と 助詞,格助詞,引用,*,*,*,と,ト,ト
溢れ  動詞,自立,*,*,一段,連用形,溢れる,アフレ,アフレ
出る  動詞,自立,*,*,一段,基本形,出る,デル,デル
川越  名詞,固有名詞,人名,姓,*,*,川越,カワゴエ,カワゴエ
シェフ   名詞,一般,*,*,*,*,シェフ,シェフ,シェフ
・・・

感情極性対応表を使った良いニュース・悪いニュースの判定

最後にその文章が良いニュースなのか悪いニュースなのかを判定します。

単語には良いイメージ(positive)のものと悪いイメージ(negative)のものがあります。 日本語であれば、「生」「幸福」「優秀」などはポジティブ、「死」「不幸」「劣悪」などはネガティブな単語と言えるでしょう。

こうしたイメージ(極性)を数値化して単語ごとに割り当てた辞書が、東工大の奥村・高村研究室から提供されています。

PN Table

  • 「岩波国語辞書(岩波書店)」がソースとなっており、名詞49,002語、動詞4,254語、形容詞665語、副詞1,207語について数値化されています
  • 出典論文
    • 高村大也, 乾孝司, 奥村学 "スピンモデルによる単語の感情極性抽出", 情報処理学会論文誌ジャーナル, Vol.47 No.02 pp. 627--637, 2006.
    • Hiroya Takamura, Takashi Inui, Manabu Okumura, "Extracting Semantic Orientations of Words using Spin Model", In Proceedings of the 43rd Annual Meeting of the Association for Computational Linguistics (ACL2005) , pages 133--140, 2005.

pn_ja.dicをダウンロードして、上位と下位を5件ずつ見てみると、例えば「優れる」「良い」には1に近いポジティブな語、「死ぬ」「悪い」は-1に近いネガティブな語となっていることがわかります。

優れる:すぐれる:動詞:1
良い:よい:形容詞:0.999995
喜ぶ:よろこぶ:動詞:0.999979
褒める:ほめる:動詞:0.999979
めでたい:めでたい:形容詞:0.999645
・・・
ない:ない:助動詞:-0.999997
酷い:ひどい:形容詞:-0.999997
病気:びょうき:名詞:-0.999998
死ぬ:しぬ:動詞:-0.999999
悪い:わるい:形容詞:-1

ダウンロードしたpn_ja.dicをPythonファイルと同じフォルダに配置して、以下のプログラムでlivedoorトピックニュースの極性値を算出します。

  • load_pn_dict関数で、単語をキー、極性値を値とする辞書を作ります
    • pn_dic['良い'] の値は 0.999995
    • pn_ja.dicはシフトJISなので、codecsをインポートしています
  • get_pn_scores関数は、janome.tokenizer.Tokenリストから対応する極性値リストを返します
    • Token.part_of_speechで 名詞,一般,*,* などの品詞情報が取得できます(後は","で分けて0番目の要素を取得すれば、品詞がわかります)
    • また、活用形による違いを吸収して対応表にヒットさせやすいようにするため、Token.base_formプロパティで原型を取り出しています
    • pn_ja.dicは名詞、動詞、形容詞、副詞のみのため、それ以外の品詞は除外しています
import codecs


# pn_ja.dicファイルから、単語をキー、極性値を値とする辞書を得る
def load_pn_dict():
    dic = {}
    
    with codecs.open('./pn_ja.dic', 'r', 'shift_jis') as f:
        lines = f.readlines()
        
        for line in lines:
            # 各行は"良い:よい:形容詞:0.999995"
            columns = line.split(':')
            dic[columns[0]] = float(columns[3])
            
    return dic


# トークンリストから極性値リストを得る
def get_pn_scores(tokens, pn_dic):
    scores = []
    
    for surface in [t.surface for t in tokens if t.part_of_speech.split(',')[0] in ['動詞','名詞', '形容詞', '副詞']]:
        if surface in pn_dic:
            scores.append(pn_dic[surface])
        
    return scores


# 感情極性対応表のロード
pn_dic = load_pn_dict()
print(pn_dic['良い'])
# 0.999995

# 各文章の極性値リストを得る
for element in naive_corpus:
    element.pn_scores = get_pn_scores(element.tokens, pn_dic)

# 1件目の文章の極性値を表示する
print(naive_corpus[0].pn_scores)

先程の文章の極性値を表示すると、以下のように出力されます。
"悪評"は-0.994383、"次"は-0.823776、"シェフ"は"-0.0752967"、・・・と全体的にネガティブな単語が使われていることが伺えます。

[-0.994383, -0.823776, -0.823776, 0.928266, -0.0752967, -0.0125929, -0.141334, -0.585529, -0.0752967, -0.238801, -0.0856497, -0.0752967, -0.233205, -0.190665, -0.21501, 0.998699, -0.234015, -0.880775, -0.0588248, -0.288049, -0.529611, -0.637501, -0.547431, -0.312861, -0.664354, -0.0752967, -0.994383, 0.360515, -0.333928, -0.0125929, -0.595207, -0.557421, -0.496628]

極性値から文章全体のポジティブとネガティブを判定する方法します。 今回は単純に平均値を使って計算し、上位10件と下位10件を表示します。

import io


# 平均値が最も高い5件を表示
for element in sorted(naive_corpus, key=lambda e: sum(e.pn_scores)/len(e.pn_scores), reverse=True)[:10]:
    print('Average: {:.3f}'.format(sum(element.pn_scores)/len(element.pn_scores)))
    print('Title: {}'.format(io.StringIO(element.text).readline()))

# 平均値が最も低い5件を表示
for element in sorted(naive_corpus, key=lambda e: sum(e.pn_scores)/len(e.pn_scores))[:10]:
    print('Average: {:.3f}'.format(sum(element.pn_scores)/len(element.pn_scores)))
    print('Title: {}'.format(io.StringIO(element.text).readline()))

まずは平均値の高い10件の記事タイトルを見てみると、上位2件は結婚報道で納得できる感じですが、7番目は離婚報道で違和感があります。 10番目も内容はネガティブです。
また770件の記事があるにも関わらずそもそもマイナスからスタートしているのも気になります。 辞書に含まれる語の内、ポジティブな語が5122語に対して、ネガティブな語が49983語と10倍近く差があるため、ネガティブ方向に判定されやすくなっているようにも思えます。

-0.122: 結婚した亀田興毅の評価がネット掲示板で急上昇
-0.207: ゆずの北川悠仁、結婚後初のメディア出演!!大勢のリスナーからの祝福メールに大感謝!!
-0.237: 2011年・大学ミスコンの美女たちを一挙公開
-0.253: 慶應大学だけ書籍の売れ筋が「おかしい」と話題に
-0.258: 【韓国ニュース】「ワンピース」のディズニー風イラストが韓国で話題に
-0.259:  ドイツ人「日本人って“青い目で金髪”が好きなんでしょ」
-0.271: 西岡剛選手と離婚報道の徳澤直子に「笑いがとまらないだろう」
-0.275:  爆笑問題・田中裕二も驚く「ひるおび!」での恵俊彰の“天然”ぶり
-0.284: 加護亜依さんが入籍と妊娠を発表!
-0.288: 悪評が次から次へと溢れ出る川越シェフ

平均値の低い10件は以下のようになりました。 こちらは全体的に納得できる結果のようです。

-0.694: 「東電からの賠償額がわずか333円」に非難の嵐
-0.681: フジテレビの姿勢に非難殺到…番組で″火渡り″させ、老人が歩行不能の大火傷
-0.670: “金総書記死亡”に対する韓国での反応
-0.669: “すき家のバイト”が「嘔吐物を鍋に入れた」とツイートし炎上
-0.668: 渋谷駅の駅ビルで通り魔
-0.666: 大相撲で行司が土俵から転落し起き上がらず騒然
-0.664: 元光GENJI・大沢 子どもが死産するも、そのブログに「やりすぎ」の声
-0.661: 黒田勇樹のDV騒動 ネット掲示板では冷ややかな声も
-0.658: リンカーンで放送事故、ツイッター騒然
-0.654: AKB48出演の「ぷっちょ」CMに「悪寒」「気まずい」とツイッターユーザーが困惑

今回は、形態素解析で文章を構成する語を切り出し、語の持つポジティブとネガティブの極性から、文章全体の極性の判定を試みました。 結果として、ネガティブなニュースについては概ね見分けることができましたが、ポジティブなニュースについては正しく分類できていないケースもありました。

否定や反語表現にも対応するために、構文解析・意味解析も行う必要がありそうです。

「Context-Aware Recommender Systems」まとめ

今回はRecommender Systems Handbook(第1版)の第7章「Context-Aware Recommender Systems」について読んでまとめました。 第1版第7章の内容は以下で公開されています。

http://ids.csom.umn.edu/faculty/gedas/nsfcareer/

現在は第2版がリリースされています。

Recommender Systems Handbook

Recommender Systems Handbook

なぜこれを読んだのか

これまでユーザベースとアイテムベースの協調フィルタリング(9/229/29の投稿)、コンテンツベースフィルタリング(10/13の投稿)について学んできました。
それらはユーザやアイテムの類似や過去の評価に基いて推薦しています。

しかし、いずれも"今のユーザの状況(コンテキスト)"を反映する枠組みが無く、実際の利用にあたってはギャップを感じます。
例えば、このユーザはいつもならAの居酒屋を好むが、「"今回は昼食時に探している"ので、Bの蕎麦屋を推薦する」「"今回は家族との外食先"を探しているので、Bのファミリーレストランを推薦する」といった考慮ができるべきかと思います。

そのため、推薦システムにコンテキストの概念を説明しているこの本を読み、ギャップを埋めることにしました。
サーベイ論文っぽい内容のため、細かいところまで立ち入らず、「最良かどうかはわからないけども、こうすればこんな状況を考慮した推薦が実装できそうだ」とイメージできることを重視しながら読みました。

1 Introduction and Motivation

これまでの推薦システムの多くは、ユーザとアイテムに関する情報のみがインプットとなっていました。

予測精度を上げるために、ユーザとアイテムの情報に加えて、コンテキスト(時間、場所、一緒にいる人など)の情報を導入したContext-aware recommender systems(以降、CARS)について説明します。

2 Context in Recommender Systems

いろいろな分野の"コンテキスト"

  • データマイニングの分野では、顧客のライフステージ(結婚・離婚、出産、退職など)が重要なコンテキストで、ライフステージが変われば嗜好、ステータス、価値観が変わる
  • Eコマースでは、ユーザやアイテム以外に時間や同行者、天気などの情報をコンテキストとして使う研究もある
    • 特にモバイル環境では、各ユーザの現在位置やその周辺の地理情報、気温などをコンテキストとして扱うことができる
  • 情報検索では、検索ワードと潜在的に関連するトピックのこと
    • 例えば"scikit-learn"や"ロジスティック回帰"、"L2正則化"などで検索されていれば、潜在的なトピックは"機械学習"と予想することができます
    • 検索によって解決したい問題(時間軸が短い)と興味・嗜好(時間軸が長い)をどうやって検索結果に反映させるかが焦点

CARSのモデル

評価値を予測するモデル(ここではRとしています)を定義していきます。

協調フィルタリングなどの従来の推薦アルゴリズムは、ユーザとアイテムによって評価値が定まる、いわば2次元のモデルでした。

  • User: ユーザの属性で、ユーザID、名前、年齢、性別、職業など
  • Item: アイテムの属性で、映画を例にすると、映画ID、タイトル、リリース年月日、監督、ジャンルなど


R:User \times Item → Rating

CARSではそれにコンテキストが加わった3次元のモデルになります。


R:User \times Item \times Context → Rating

映画を例にあげると、コンテキストは以下のような情報です。

  • 映画館: 映画館ID、住所、収容人数など
  • 時間: 日付、曜日、時刻など
  • 同行者: 一人、友達と、恋人と、家族となど

User、Item、Contextはそれぞれ異なる次元数で属性値を持ちます。
下図(原文Fig.2の抜粋)の通り、コンテキスト(ここでは奥行方向のTime)が3次元(Weekday, Weekend, Holiday)になっており、時間帯によって評価値が異なるモデルが表現されています。

f:id:ohke:20171014120811p:plain

コンテキスト情報を取得する

こうしたコンテキストはどうやって得られるものでしょうか。 アプローチは3つあります。

  • アンケートなどでユーザから明示的に取得する
  • 暗黙的に取得する
    • 商品の購入履歴から購入時間を得る、デバイスのGPSから位置情報を得る、など
  • 推論から得る
    • おむつと幼児用ミルクの購入履歴から子供がいると推定する、など
    • 推論にはナイーブベイズ分類やベイジアンネットワークが使われます

3 Paradigms for Incorporating Context in Recommender Systems

従来のユーザとアイテムのみの2次元の推薦システムによる推薦は、大きく3つのプロセスで行われます。

  1. これまでの購買履歴などの元データから、評価データ(U×I×R)を取得する
  2. 評価データから、関数(U×I→R)を構築する
  3. 特定のユーザの情報(u)を受け取り、関数を使って推薦するアイテムのリスト(i_1, i_2, i_3, ...)を出力する

CARSでは、コンテキスト情報を追加した評価データ(U×I×C×R)を入力として受け取り、出力は同じく推薦するアイテムのリストになります。
コンテキスト情報を適用するタイミングによって、システムの構成は下図(原文Fig.4抜粋)の通りプリフィルタリング(下図a)、ポストフィルタリング(下図b)、コンテキストモデリング(下図c)の3パターンに分類します。

f:id:ohke:20171014124914p:plain

プリフィルタリング

コンテキスト情報を評価データの選択に使い、関数の構築や推薦(上記プロセスの2.以降)はこれまで通り行う方法です。
つまり、土曜日に見る映画を検索しているなら、土曜日の評価のみを入力データとして使うというイメージです。

コンテキストとして時間(Time)を使うと、評価値の計算はUser、Item、Timeの3値で決まります。 Dは(user, item, time, rating)からなるレコードです。


R^{D}_{User \times Item \times Time}:U \times I \times T → Rating

例えば土曜日だけの場合は以下のように、土曜日だけのデータを入力にして評価値を計算します。


R^{D(User, Item, Time=Saturday, Rating)}_{User \times Item}(u, i)

入力データを前段で絞るだけのため、従来の協調フィルタリングなどと容易に組み合わせられます。

しかし、コンテキストの情報が増えすぎる(細かすぎる)と2つの問題を抱えることになります。

  • あまり意味がないかもしれない(映画を見るのに土曜日と日曜日では大差ないかもしれない)
  • 疎になって十分なデータ数が得られないかもしれない

ポストフィルタリング

評価データの取得と関数の構築(上記プロセスの2.以前)はこれまで通り行い、得られた推薦アイテムリストに対して、コンテキスト情報を使って調整(一部を除外したり、順番を入れ替えたり)する方法です。
例えば、土曜日に見る映画を探していて、その人が土曜日にコメディ映画のみを見ることがわかっているなら、推薦リストからコメディ映画以外を除外する、といったイメージです。

一般化すると、このアプローチではコンテキスト情報(映画を見るのは土曜日)から好まれるアイテムのパターン(土曜日はコメディ映画のみを見る)を見つけ、そのパターンに従って調整する(コメディ映画以外を除外する)アプローチで、大きく2つの方法に分類できます。

  • ユーザとコンテキストの情報とアイテムの属性を利用するヒューリスティックな方法
    • 好みの出演俳優が0人の映画は除外する、好みの出演俳優の数が多い映画を上位に移す、など
  • 特定のコンテキストにおける特定のアイテムをユーザが好む確率を計算するモデルベースの方法

プリフィルタリングと同様に、従来の協調フィルタリングなどと容易に組み合わせることができます。

コンテキストモデリング

コンテキスト情報を含めた関数(U×I×C→R)を構築し、ユーザの情報とコンテキスト情報(c)から、推薦するアイテムリストを出力する方法です。

ヒューリスティックなアプローチ

ユーザやアイテムの情報にコンテキスト情報を加えたn次元のベクトルを作り、(類似度の計算に代わって)ベクトル同士の距離から予測する方式です。

User×Item×Timeの推薦システムを例にとると、予測値r_{u,i,t}は下式で計算します。
Wは重みで、(u,i,t)と(u',i',t')の距離の逆数のため、2つの距離が近いほど重みが大きくなります。 つまり、距離が近い評価値ほど、予測値に大きな影響を与えます。 距離の計算には、ごく一般的なマンハッタン距離やユークリッド距離が使われます。

モデルベースのアプローチ

従来の2次元の推薦システムで使ってきたモデルを多次元に拡張し、マルコフ連鎖モンテカルロ法などの確率的モデルや、決定木やSVMなどで予測するアプローチです。

4 Combining Multiple Approaches

複数のフィルタを使って1つの推薦リストを得る方法について考えていきます。

プリフィルタリングであれば、下図(原文Fig.6抜粋)のようになります。
例えば、(Girlfriend, Theater, Saturday)というコンテキストであれば、c{1}=(Friend, AnyPlace, Saturday)やc{2}=(NotAlone, Theater, AnyTime)などの複数のコンテキストと組み合わせて結果を出すこともできます。
Recommender Aggregatorで組み合わせる方法は2つ考えられます。

  • 特定のコンテキストにおいて、最も適したプリフィルタ1つを選択する
  • 複数のプリフィルタの結果を多数決などで統合する

f:id:ohke:20171015111118p:plain

ただし、コンテキストによって適したフィルタが異なる可能性はあります。
時間であればプリフィルタが適しています(お昼ごはんを食べるお店を探しているのに、17時から営業開始するお店が推薦リストに入ってきたらNGですよね)。 一方、天気はポストフィルタの方が望ましいでしょう(近くのレジャー施設を探しているとすると、雨が降っていたとしても、推薦リストの下位に屋外球場が入っていても良いかと思います)。

プリフィルタを組み合わせる

大きく2ステップのアルゴリズムになります。 なお、組み合わせて使う協調フィルタリングなどのアルゴリズムをAとしています。

  1. トレーニングデータ(ユーザの既知の評価値)を使い、アルゴリズムA単体よりも優れたプリフィルタを選ぶ
  2. 特定のコンテキストにおいて最も性能の高いプリフィルタを選択し、そのプリフィルタを使って学習されたアルゴリズムAを適用して予測する
    • 入力するコンテキストに該当するプリフィルタが無い場合は、全てのデータでトレーニングされたアルゴリズムAを使う(コンテキストを全く考慮しない従来方法と同じ)

ステップ1はさらに3ステップからなります。

  1. 考えうる全てのプリフィルタから、閾値N以上の十分なデータ数を持つコンテキストのセグメントを選別する
    • コンテキストが細かすぎてデータ数が不足するという問題への対策
  2. 選別された各プリフィルタに対してアルゴリズムAを使って予測精度を計測し、プリフィルタを使っていない場合の精度よりも、精度が低いプリフィルタは除外します
    • 予測精度には、F値(適合率と再現率の調和平均)などが使われます
  3. 残ったプリフィルタから、より一般的で、かつ、精度の高いプリフィルタのみをさらに選択する
    • 「土曜日」のコンテキストに基づくプリフィルタと、「週末」のコンテキストに基づいたプリフィルタが存在した場合、
    • 「週末」のプリフィルタの方が「土曜日」のプリフィルタよりも精度が高ければ、
    • 「土曜日」は「週末」に包含されるため「土曜日」のプリフィルタは冗長とみなして除外する

論文では、62人の学生を対象に、時間(weekday, weekend, don't remember)・場所(in a movie theater, at home, don't remember)・同行者(alone, with friends, with boyfriend/girlfriend, with family, others)のコンテキストを含む1457の評価値(映画は202種類)を採取し、通常の協調フィルタリングとプリフィルタを使った協調フィルタリングで、予測値の比較を行っていました。
F値で結果を比較すると、トータルで0.063、プリフィルタを適用できたケースのみで0.095の改善が見られました。

5 Additional Issues in Context-Aware Recommender Systems

最後にCARSの課題を挙げます。

  • 3つのモデルのどれを選択すべきか
  • CARSのコンテキストの情報が加わるため単純な協調フィルタリングに比べると複雑になりがち
  • CARSはコンテキスト情報をユーザから取得する必要があるため、高いインタラクティブ性(要するにグッドなUI)を求められる
  • アーキテクチャまでスコープに入れたハイパフォーマンスなCARSの構築

まとめ

自身の所感についてまとめておきます。

プリフィルタリングを重点的に説明されていますが、協調フィルタリングなどと簡単に組み合わせられ、現実的に実装もできそうです。
一方でモデルベースのCARSについては、自身の理解が追いつかず、ちょっとイメージできませんでした。 具体的な実装を行っている文献を読む必要がありそうです。

また、コンテキストにはどういった情報を使うのが有効的なのかを探すのは大変そうです。
闇雲に細かい分類はいくらでもできてしまいますし、次元数を増やせば学習にかかるコストが簡単に高くなってしまいます。 scikit-learnのGridSearchのように、トライアンドエラーが簡単にできる仕組みなどが必要かもしれません。

Pythonでレコメンドシステムを作る(コンテンツベースフィルタリング)

今回はコンテンツベースフィルタリングで、ジョークをお薦めするシステムを作ります。

コンテンツベースフィルタリングとは

コンテンツベースフィルタリングは、アイテムそのものの特徴を利用して、推薦したいユーザがこれまで高評価したアイテムと類似するアイテムを推薦する方法です。
例えば、過去に「ハリー・ポッターと賢者の石」と「指輪物語 1」を高評価しているユーザは、おそらく「小説」「ファンタジー」を嗜好しているため、「ナルニア国物語 1」などを薦める、といった感じです。

ユーザの評価値のみを使って推薦する協調フィルタリングと比較すると、以下の違いがあります。

  • アイテムのコンテンツを入力する処理が必要
  • アイテムのコンテンツから特徴ベクトルを抽出する処理が必要
  • 多様性・新規性に欠ける(過去に評価したアイテムと類似するため)
  • システム全体のユーザ数・評価数が少ない状況でも、予測精度は下がらない
  • まだどのユーザからも評価されていないアイテムの評価値も予測できる

コンテンツベースフィルタリングを実装する

今回はJester Datasetを使ってユーザにジョークをお薦めするシステムを実装してみます。

コンテンツベースフィルタリングでは、大きく3ステップで推薦します。

  1. アイテムの特徴ベクトルを抽出する
  2. アイテム間の特徴ベクトルの類似度を算出する
  3. 過去に高く評価したアイテムと類似度が高いアイテムが上位になるようにランキング化する

Jester Dataset

今回は、英語のジョーク集の評価を集めたJester Collaborative Filtering Datasetを使います。

これは150種類のジョークに対して50692人が-10〜+10で評価しています。

Jester Datasets

本家サイトのは評価値がxlsファイルだったり、ジョーク本文をhtmlからパースする必要があったりするため、非常に扱いにくいものになっています。
そこで、csv/tsvファイルに加工されてkaggleで提供されているデータセットがありますので、そこから jesterfinal151cols.csv (評価値) と jester_items.tsv (ジョーク本文)をダウンロードして、これから作るPythonファイルと同じディレクトリに配置してください。

www.kaggle.com

ただし、そのkaggleのjester_items.tsvは150番目のジョークが抜けているので、本家から150番目のジョークをダウンロードして以下の1行を追加します。

150: In an interview with David Letterman, Carter passed along an anecdote of a translation problem in Japan. Carter was speaking at a business lunch in Tokyo, where he decided to open his speech with a brief joke. He told the joke, then waited for the translator to announce the Japanese version. Even though the story was quite short, Carter was surprised by how quickly the interpreter was able to re-tell it. Even more impressive was the reaction from the crowd. Carter thought the story was cute, but not outright hilarious, yet the crowd broke right up. Carter was very flattered. After the speech, Carter wanted to meet the translator to ask him how he told the joke. Perhaps there is better way to tell the joke? When Carter asked how the joke had been told in Japanese, the translator responded, "I told them, 'President Carter has told a very funny joke. Please laugh now.'"

特徴ベクトルの抽出

まずはjester_items.tsvからジョーク本文をDataFrameに読み込みます。

import pandas as pd
pd.set_option("display.max_colwidth", 1000)


jokes = pd.read_csv('jester_items.tsv', sep='\t', names=['id', 'joke'])
jokes.drop('id', axis=1, inplace=True)
print('Joke {0}: {1}'.format(0, jokes.ix[0, 'joke']))
# Joke 0: A man visits the doctor. The doctor says, "I have bad news for you. You have cancer and Alzheimer's disease". The man replies, "Well, thank God I don't have cancer!"

f:id:ohke:20170927092221p:plain

次にTF-IDFで特徴ベクトルを抽出します。
以前の投稿も参考にしてください。

  • Porterでステミングすることで、時制や単複の違いを吸収しています
  • 語の出現頻度による足切りは行いません(min_df=1)

scikit-learnでスパムメッセージを分類する(TfidfVectorizer + PorterStemmer + BernoulliNB) - け日記

from nltk import word_tokenize
from nltk.stem.porter import PorterStemmer
from sklearn.feature_extraction.text import TfidfVectorizer


class PorterTokenizer(object):
    def __init__(self):
        self.porter = PorterStemmer()
        
    def __call__(self, doc):
        return [self.porter.stem(w) for w in word_tokenize(doc)]
    

# Porterでステミングして、TF-IDFで特徴ベクトルを構築する
vectorizer = TfidfVectorizer(norm='l2', min_df=1, tokenizer=PorterTokenizer())
vectorizer.fit(jokes['joke'])

print(vectorizer.transform(jokes.ix[0]))
#   (0, 1877)  0.136130990259
#   (0, 1809)  0.143046021434
#   (0, 1779)  0.21261619802
#   ...
print(vectorizer.vocabulary_)
# {'a': 98, 'man': 1049, 'visit': 1779, 'the': 1672, 'doctor': 555, '.': 26, ...

類似度の算出

得られた特徴ベクトル同士のコサイン類似度を計算します。 こちらも以前の投稿で同様の計算をしていますね。

scikit-learnでスパムメッセージを分類する(TfidfVectorizer + PorterStemmer + BernoulliNB) - け日記

実装上の留意点が2点あります。

  • 類似度行列は対称行列になります
    • similarities[0][2]とsimilarities[2][0]はいずれ0番目と2番目のジョークの類似度を表しており、同じ値です
  • 類似度行列の対角成分はnp.nanと置いています
  • vectorizer.transformで得られる特徴ベクトルはscipy.sparse.csr.csr_matrix(疎行列用のデータ型)となっており扱いにくいので、toarrayでndarrayに変換しています

得られた類似度行列の最大値は0.978で、112番目と132番目のジョークでした。 2つを比較してみると、", walking by,"が入っているかどうかの違いだけで、同じ内容となっていました。

import numpy as np


def get_similarities(words_vector):
    """
    BoWベクトル同士のコサイン類似度行列を計算する
    """
    similarities = np.zeros((words_vector.shape[0], words_vector.shape[0]))
    
    for i1, v1 in enumerate(words_vector):
        for i2, v2 in enumerate(words_vector):
            if i1 == i2:
                similarities[i1][i2] = np.nan
            else:
                similarities[i1][i2] = np.dot(v1, v2.T) / (np.linalg.norm(v1) * np.linalg.norm(v2))

    return similarities


similarities = get_similarities(vectorizer.transform(jokes['joke']).toarray())

print('Max similarity: {0:.3f}'.format(np.nanmax(similarities), np.nanargmax(similarities)))
# Max similarity: 0.978
print('Joke {0}:\n{1}'.format(np.nanargmax(similarities) // similarities.shape[0], 
                              jokes.ix[np.nanargmax(similarities) // similarities.shape[0], 'joke']))
print('Joke {0}:\n{1}'.format(np.nanargmax(similarities) % similarities.shape[0], 
                              jokes.ix[np.nanargmax(similarities) % similarities.shape[0], 'joke']))
# Max similarity: 0.978
# Joke 112:
# The new employee stood before the paper shredder looking confused. "Need some help?" a secretary asked. "Yes," he replied. "How does this thing work?" "Simple," she said, taking the fat report from his hand and feeding it into the shredder. "Thanks, but where do the copies come out?"
# Joke 132:
# The new employee stood before the paper shredder looking confused. "Need some help?" a secretary, walking by, asked. "Yes," he replied, "how does this thing work?" "Simple," she said, taking the fat report from his hand and feeding it into the shredder. "Thanks, but where do the copies come out?"

評価値の予測

評価値をロードして、任意のユーザとアイテムの評価値を予測します。

まずjesterfinal151cols.csvファイルをndarrayにロードします。

  • 評価値は-10.0〜+10.0の連続値となっており、未評価は99です
  • csvファイルに値が入っていない列があるため、filling_valuesで未評価(99)で補完しています
    • numpy.loadtxtではこうした補完ができずエラーになってしまうので、genfromtxtを使っています

次に、評価値を予測する関数predict_ratingを実装します。
ユーザuのアイテムaに対する評価値を、以下の式で予測しています。 sim(a,b)はアイテムaとbの類似度、Iは評価済みアイテム集合、r_bはアイテムbの評価値です。
他のユーザの評価値を使わずに、対象ユーザの評価値とアイテムの特徴ベクトルの類似度から計算していることに着目してください(協調フィルタリングと異なる点ですね)。

0番目のユーザの0番目のアイテムに対する評価値を予測すると、3.391となりました。

# 評価値のロード
ratings = np.genfromtxt('jesterfinal151cols.csv', delimiter=',', filling_values=(99))
ratings = np.delete(ratings, 0, axis=1)

print('Rating 0:\n{}'.format(ratings[0]))
# Rating 0:
# [  9.90000000e+01   9.90000000e+01   9.90000000e+01   9.90000000e+01
#   2.18750000e-01   9.90000000e+01  -9.28125000e+00  -9.28125000e+00
# ...


def predict_rating(rating, item_index, similarities):
    """
    評価値を予測する
    """
    numerator = 0.0
    enumerator = 0.0
    
    for i, r in enumerate(rating):
        if r != 99:
            numerator += similarities[item_index][i] * r
            enumerator += similarities[item_index][i]
            
    return numerator / enumerator if enumerator != 0 else np.nan


# 評価値の予測
print('Predicted rating: {:.3f}'.format(predict_rating(ratings[0], 0, similarities)))
# Predicted rating: 3.391

ランキング

最後におすすめすべきアイテムをランキングにします。

関数rank_itemsは、未評価のアイテム全てについて評価値を予測して降順で返します。
0番目のユーザの場合、139番目、42番目、76番目のジョークの評価値が高いと予測されており(それぞれ4.89、4.03、3.99)、この順番で薦めるべきことがわかります。

def rank_items(ratings, user_index, similarities):
    """
    (アイテムのインデックス, 予測した評価値)のタプルリストをランキング(評価値の降順ソート)で返す
    """
    ranking = [ ]
    rating = ratings[user_index]
    
    for i, r in enumerate(rating):
        if r != 99:
            continue
            
        ranking.append((i, predict_rating(rating, i, similarities)))
        
    return sorted(ranking, key=lambda r: r[1], reverse=True)


print('Ranking(User 0):\n{}'.format(rank_items(ratings, 0, similarities)))
# Ranking(User 0):
# [(139, 4.8879743288985411), (42, 4.0260000988334159), (76, 3.9892937363894996), ...

情報推薦システム入門 -理論と実践-

情報推薦システム入門 -理論と実践-