メモ帳

python, juliaで機械学習をやっていく

cosine similarityの重み付けを使った、パーソナライズできるrecommender systemのベースライン

アイテム情報とユーザー情報を組み合わせた、パーソナライズされた推薦を行う基本的なシステムを紹介します。重み付けしたcosine similarity (コサイン類似度)によるシンプルな手法です。いわゆるcontent-basedなrecommendになっています。 機械学習を使った推薦システムでは、metric learningやautoencoderなどで高尚な特徴量に変換し、類似度の大きさを指標としたものが派手な話題性の高い手法だと思います。しかし、これらの手法は直感的でないので、うまく学習できないと全く使えないものになると思います。そこで、保守的な、ベースラインとして使う想定の手法を紹介したいと思います。

推薦システム

まずは、recommendの概要を簡単にまとめます。 recommnedは主に以下の2種類のアルゴリズムが使われます。

また、これらのハイブリットな手法も用いられます。

content-based recommendation

あるグループに属する商品を好むユーザーはそのグループ内の他の商品も好むという思想です。 item情報から特徴量抽出を行い、特徴量分布が似ているitemを推薦します。

例: 書籍A, 書籍B, 書籍Cは機械学習グループに属しているとする。書籍Aを買ったユーザーは同じグループの書籍Bや書籍Cも買う確率が高いという評価をする。

欠点:

  • 同じグループに属する商品が多い場合に意味をなさない。
  • systemを作る側のitemの理解 (特徴量設計)に精度が大きく依存するのでitemに対する十分な調査が要求される。
  • 誰にどれだけ売れるか知るための一番単純な方法は実際に売ってみること、つまり、collaborative filteringだと思われる

colaborative filtering

ユーザーの行動履歴などのログを用いた推薦。動向が似ているユーザーは同じようなものを好むという思想です。

例: 本A, 本B, 本Cを買ったユーザーXがいた場合、本A, 本Bを買ったユーザーYは次に本Cを買う確率が高いという評価をする。

欠点:

  • コールドスタート問題: itemの導入直後はどのユーザーも購買行動をとれないので似ているユーザ(学習データ)がおらず、推薦されない
  • 同様に、新規ユーザーも似たユーザーが見つからないので全く機能しない。完全にrecommendに頼ったシステムの場合、新たな商品は全く表示されないこともありうる。
  • 特徴量が疎 (sparse)になるので、データが無駄にでかくなる。

類似度の評価

特徴量分布の近さや行動履歴の類似度を評価する指標はいくつかあります。

  • cosine similarity (今回はこちらを使用)
  • euclid distance
  • Mahalanobis' Distance

他にもたくさんあります。こちらにまとまっています。

今回紹介するアルゴリズム

  • 手法: (content-based recommendation) 特徴量抽出を行い、特徴量分布の類似度が大きいものを推薦。アイテム情報とユーザー情報の優先度を変更できるようにする
  • 特徴量:
    • アイテムのテキストから得たTF-IDFベクトル
    • ユーザープロフィールのone-hot表現
  • 類似度: コサイン類似度

f:id:atelier-0213:20191103191716p:plain
アルゴリズム概要図

パーソナライズの度合いを調節できるようにするために、類似度の算出の際にテキスト情報とuser profile情報に重み付けできるようにします。

重み付けをしないとプロフィール情報でソートしているのとほとんどかわらなくなります。 tf-idfは0から1の連続な値を取りますが、one-hot表現は0か1の2値だけしか取らないので、one-hot表現のほうが類似度に大きな影響を与えてしまいます。なので、ソート以上のことをしたければ特徴量ごとに重み付けする必要があります。

特徴量の結合時に重み付けしてもよいですが、重みの調節時に前処理まで戻るのは気持ちが悪く、類似度の算出と重み付けがまとまっている方が直感的だと感じたので、結合後に重み付けするようにしています。

Dataset

以下のカラムをもつテストデータを使います。 (アルゴリズムの紹介に留めるので意味のあるデータセットではないです。適宜置き換えて試していただければと思います。)

  • text: テキスト情報 (アイテム情報: 商品説明のようなもの)
  • age: 年齢層 (ユーザー情報)
  • gender: 性別 (ユーザー情報)
  • place: 居住地域 (ユーザー情報)

f:id:atelier-0213:20191102045039p:plain

特徴量抽出

TF-IDF vector

テキストからtf-idf vectorを抽出するために以下の行程をふみます。sklearnの関数を使っていきます。

  1. 単語のBoW表現に変換: CountVectorizer使用
  2. TFを算出: TfidfTransformer使用

実用を考えると、以下のような機能に分けられます。

  • fit: 事前準備 (マスターデータを使ったBoWの辞書の定義、TFの計算)
  • save: 事前準備したインスタンスのsave
  • load: 事前準備したインスタンスのload
  • transform: TF-IDF vectorへの変換

これらを備えたクラスは以下のように書けます。

from typing import List
import pickle
import os
from sklearn.feature_extraction.text import TfidfTransformer
from sklearn.feature_extraction.text import CountVectorizer


class TextVectorizer():
    """
    text vectorizer class
    """
    def __init__(self, *args, **kwargs):
        self.cv = None
        self.tfidf_transformer = None
        return super().__init__(*args, **kwargs)

    def fit(self, docs: List[str]):
        """
        countvectorizer and tfidfTransformerをfitする
        fitされたinstanceはself.cvとself.tfidf_transformerに格納される
        """
        # BoW表現の辞書を定義し、tfidf用にマスターデータをBoW表現に変換
        self.cv = CountVectorizer()
        word_count_vector = self.cv.fit_transform(docs)

        # マスターデータのTFを計算
        self.tfidf_transformer = TfidfTransformer(smooth_idf=True, use_idf=True)
        self.tfidf_transformer.fit(word_count_vector)

    def save(self, directory, countvectorizer_pkl_name='cv.pkl', tfidf_pkl_name='tfidftrans.pkl'):
        """fitしたcountvectorizerとtfidfTransformerをpickleで保存します。"""
        if not os.path.exists(directory):
            os.makedirs(directory)

        with open(os.path.join(directory, countvectorizer_pkl_name), 'wb') as f:
            pickle.dump(self.cv, f)

        with open(os.path.join(directory, tfidf_pkl_name), 'wb') as f:
            pickle.dump(self.tfidf_transformer, f)

    def load(self, directory, countvectorizer_pkl_name='cv.pkl', tfidf_pkl_name='tfidftrans.pkl'):
        """fitされたcountvectorizerとtfidfTransformerをloadします。"""
        with open(os.path.join(directory, countvectorizer_pkl_name), 'rb') as f:
            self.cv = pickle.load(f)

        with open(os.path.join(directory, tfidf_pkl_name), 'rb') as f:
            self.tfidf_transformer = pickle.load(f)

    def transform(self, docs):
        """テキストをTF-IDF vectorに変換する"""
        # convert word into BoW vector
        count_vector = self.cv.transform(docs)

        # calculate tf-idf scores
        tf_idf_vector = self.tfidf_transformer.transform(count_vector)
        return tf_idf_vector

使用法

使用法を説明します。

まず、マスターデータを使って単語の辞書作成とTFの算出を先にやっておく必要があります。

# マスターデータ
master_docs = ['Be excellent to the data when no-one is looking because you should! Bwahahaha!',
 'Act like an excellent Programmer', ...]

vect = TextVectorizer()

# 単語の辞書作成とTFの算出
vect.fit(master_docs)
# 保存
save_dir = 'weights/'
vect.save(save_dir)

以下のようなpickleファイルが作られます。

└── weights # pickleを保存するディレクトリ
    ├── cv.pkl # textのBoW表現の辞書
    └── tfidftrans.pkl # textのTF

これさえやっておけば、実際に使うときは以下の用にloadして、変換するだけです。

vect = TextVectorizer()
# 読み込み: 保存したdirectory先を指定
vect.load(save_dir)

# TF-IDF vectorの算出
tfidf_vector = vect.transform(docs)

出力形式について (sparse matrix)

tfidf_vectorはscipyのsparse matrixになっています。toarrayメソッドをつかうとnumpyに変換されて中身が見られます。

tfidf_vector
# >>> <1x637 sparse matrix of type '<class 'numpy.float64'>'
# >>> with 17 stored elements in Compressed Sparse Row format>

tfidf_vecotr.toarray()
# >>> array([[0., 0., 0., ..., 0., 0., 0.]])

sparse matrixは行列の中で0でない要素の座標と値のみを含むデータ構造です。sparseな特徴量の場合こちらの方がメモリ効率と計算速度の観点から適しています。 こちらで詳しく比較されています。

User profile vector

以下の3つのプロフィールのone-hot表現を使います。

  • 年齢層: 10代、 20代前半、20代後半、など13段階
  • 性別: 女性、男性の2種
  • 地域: 47都道府県

countvectorizerを使用してone-hot表現に変換します。 まず、ベースのvectorizerが、以下の様に書けます。

class BaseProfileVectorizer():
    def __init__(self, name, *args, **kwargs):
        self.cv = None
        self.name = name
        self.filename = name + '.pkl'
        return super().__init__(*args, **kwargs)

    def fit(self, docs: List[str]):
        """fit countvectorizer"""
        self.cv = CountVectorizer()
        self.cv.fit(docs)

    def save(self, directory):
        """save countvectorizer with pickle format"""
        if not os.path.exists(directory):
            os.makedirs(directory)

        with open(os.path.join(directory, self.filename), 'wb') as f:
            pickle.dump(self.cv, f)

    def load(self, directory):
        """load countvectorizer ot pickle format"""
        with open(os.path.join(directory, self.filename), 'rb') as f:
            self.cv = pickle.load(f)

    def transform(self, docs):
        """transform a profile to ids"""
        count_vector = self.cv.transform(docs)
        return count_vector

ベースクラスを利用して複数のプロフィール項目から一度にone-hot表現が得られるように以下のclassを定義して使うことにします。

class ProfileVectorizers():
    """Vectorizers for some profile in once"""
    def __init__(self, pkl_dir, *args, **kwargs):
        self.pkl_dir = pkl_dir
        self.vects = None

    def fit_save_profile_vect(self, **docs):
        """fit and save profile vectorizers(countvectorizer) in pkl_dir"""
        for name, docs in docs.items():
            vectorizer = BaseProfileVectorizer(name=name)
            vectorizer.fit(docs)
            vectorizer.save(self.pkl_dir)

    def load(self, *names):
        """load countvectorizers (fot vectorizing profile) in self.vects"""
        self.vects = [ProfileVectorizer(name=name) for name in names]
        for vect in self.vects:
            vect.load(self.pkl_dir)

    def transform(self, **docs):
        """transform profile to vectors. return them in dictionary"""
        return {
            vect.name: vect.transform(docs.get(vect.name)) for vect in self.vects
        }

tf-idf vectorと同様にsparse matrixが返ってきます。

使用法

# マスターデータ
profiles = {
    'age': df['age'].tolist(),
    'gender': df['gender'].tolist(),
    'place': df['place'].fillna('').tolist(),
}

# BoWの辞書のsave
save_dir = 'weights/'
vecs = ProfileVectorizers(save_dir)
vecs.fit_save_profile_vect(**profiles)

すると、以下のようにpickleファイルがつくられます。

└── weights # pickleを保存するディレクトリ
    ├── age.pkl # ageのBoW表現の辞書
    ├── gender.pkl # genderのBoW表現の辞書
    └── place.pkl # placeのBoW表現の辞書

あとはTextVectorizerと同様に使えます。

vect = ProfileVectorizers(save_dir)
# 読み込み: 保存したfile名を指定
vect.load('age', 'gender', 'place')

# User profile vectorの算出
tfidf_vector = vect.transform(profiles)

1つのsparse matrixにまとめる

テキストから得られたTF-IDFベクトルとプロフィールから得られたone-hotベクトル (User profile vector)を1つにまとめます。

from scipy import sparse


def get_sparse_vector(weight_dir, docs, profiles, cols_order):
    vect_word = TextVectorizer()
    vect_word.load(weight_dir)
    tfidf_vector = vect_word.transform(docs)

    profile_vects = ProfileVectorizers(weight_dir)
    profile_vects.load(*list(profiles))
    profile_vectors = profile_vects.transform(**profiles)

    def concatenate_sparse_matrix(*args, **vecs):
        vectors = [vecs.get(f) for f in args]
        return sparse.hstack(vectors)

    vects = concatenate_sparse_matrix(*cols_order, word=tfidf_vecotr, **profile_vectors)
    vects_sparse = sparse.csr_matrix(vects)
    return vects_sparse

以下の様につかいます。

save_dir = 'weights'
docs = df['text'].tolist()[:1]

profiles = {
    'age': df['age'].tolist()[:1],
    'gender': df['gender'].tolist()[:1],
    'place': df['place'].fillna('').tolist()[:1],
}

alls_sparse = get_sparse_vector(save_dir, docs, profiles, ['age', 'gender', 'place', 'word'])

すると、age, gender, place, wordの順に連結されたsparse matrixが出力されます。

weighted cosine similarity

cosine similarityの定義は、


\mbox{(cosine similarity)} = \frac{A \cdot B}{|A| |B|}

cosine similarityはsklearnに高速で処理されるものがあるのでそれを使います。cythonで書かれており、変更しづらいので、重み付けは特徴量に手を加えることにします。重み付け用の対角行列を右からかけることで実現できます。

例:

### 1, 2列と3列目に1:2の重み付けを行う
# 重み付け用の対角行列
diag = sparse.csr_matrix([
        [0.5, 0. , 0.],
        [0. , 0.5 , 0.],
        [0. , 0. , 1. ]
])
# 重み付けしたいベクトル
vect = sparse.csr_matrix([
        [1, 1, 1],
        [2, 2, 2]
])

# numpyと違ってsparse matrixでは*で行列積の計算
weighted = vect*diag
print(weighted.toarray())
# >>> np.array([[0.5, 0.5, 1.],
# >>>                 [1., 1., 2.]])

関数

では、実際に使う関数を定義します。 sparse.diagsを使えば重みのリストをつくるだけで重み付け用の対角行列を作ってくれます。

def get_weight(names, shapes, weights):
    part_ide = []
    for name in names:
        part_ide += [weights.get(name)] * shapes.get(name)

    ide = sparse.diags(part_ide)
    return ide

以下の様に使用します。

weights = {
    'age': 0.5,
    'gender': 0.5,
    'place': 0.5,
    'word': 1.0,
}
shapes = {
    'age': 13,
    'gender': 2,
    'place': 47,
    'word': 637
}
names = ['age', 'gender', 'place', 'word']

weights_matrix = get_weight(names, shapes, weights)
weights_matrix.to_array()
# >>> array([[0.5, 0. , 0. , ..., 0. , 0. , 0. ],
# >>>        [0. , 0.5, 0. , ..., 0. , 0. , 0. ],
# >>>        [0. , 0. , 0.5, ..., 0. , 0. , 0. ],
# >>>        ...,
# >>>        [0. , 0. , 0. , ..., 1. , 0. , 0. ],
# >>>        [0. , 0. , 0. , ..., 0. , 1. , 0. ],
# >>>        [0. , 0. , 0. , ..., 0. , 0. , 1. ]])

あとは、ベクトルを重み付けした後で、sklearnのcosine_similarityを使う関数を定義しておけば目的は達成されます。

from sklearn.metrics.pairwise import cosine_similarity

def weighted_cosine_similarity(A, B, weights=None):
    # weightsはget_weight関数の出力
    if weights:
        A = A * weights
        B = B * weights
    sim = cosine_similarity(A, B)
    return sim[0]

cosine_similarityの出力のshapeは(入力user数, マスターデータ数)です。 入力は1user分であることを想定しているので0番目だけ返します。複数userをバッチ処理的に入力したいときは0番目だけ取得せずに、simを全て返せばよいです。

処理のまとめ

使用例を全部まとめて一通りの流れで表したいと思います。

  1. マスターデータを使って前準備
  2. マスターデータから特徴量抽出
  3. 入力データから特徴量抽出
  4. 重み付けされたcosine 類似度でスコアリング

テキストの方に比重が置かれるようになっているのがわかると思います。テキストは単にtf-idf vectorの類似度なので、似通った文章を推薦するシステムとなりました。

まとめ

itemのテキスト情報とuser profile情報を組合わせた recommendationの簡単な手法(パーソナライズされたitemの推薦)を紹介しました。 今回紹介した方法をベースラインにしてしまえば、metric learningやauto-encoderなどで高尚な特徴量を得ることに集中して、推薦の精度を上げていければ実用的かと思います。

ref