Edited at

PDLを用いた協調フィルタリングで、あなたにおすすめのお寿司を提案する

この記事は

「DeNA IPプラットフォーム事業部 Advent Calendar 2017」

11日目の記事です。

こんにちは。 @kaneU です。

マンガボックスという、スマホ向け電子書籍サービスのサーバーサイド/フロントエンドの担当をしています。

実はマンガボックスは、最近12月4日でサービス開始から4周年を迎えることができました。

ありがたやありがたや:pray:


はじめに

深層学習や機械学習に関する記事や書籍を読むと、Pythonを使ったものが多いかと思います。

Python周りのライブラリの充実度を考えるとこのような流れは当然なのですが、

ここで僕は思うわけです。

「Pythonで出来て、Perlで出来ないわけはない!」

と。

実際、PerlにもPDLというNumPyに相当するモジュールが存在しています。

ちょこっと使う分にとても便利だし、もっと知られてほしいなと思っていたりします。

そこで今回は皆様に、

「Perlだって出来るんだぞ!反射的にPythonの門戸を叩く必要は無いぞ!」

というところをお伝え出来ればと思っています。


:warning: 注意事項


  • PDL人口は極めて少なく、知見も資料も驚くほどにみつかりません



  • 「Pythonで良くね」は禁句です


    • Perl愛が試されます



  • Perlベースのプロジェクトに、ちょこっと混ぜ込んでみたいなケースで使用するのがオススメです


    • ご利用は計画的に




おしながき


  1. 簡単なレコメンドを実装して、PDLの使い方について学ぼう

  2. サンプルデータを使って「あなたにおすすめのおすし」を教えてもらおう

の2本立てでお送りします。


1. 簡単なレコメンドを実装して、PDLについて学ぼう


問題

お寿司屋さんを経営しているボクは、来店してくれた4人組に食後にお寿司の感想を聞きました。

この時、以下のルールで答えてもらったことにします。

- 評価方法は★☆☆☆☆ ~ ★★★★★の5段階

- 半分星はないものとし、☆☆☆☆☆は未評価とする

その結果は、以下のような回答となりました。(未評価値は0としている)

まぐろ
いくら
ぶり
納豆巻
あじ
サーモン
甘エビ

しんたろう
1
2
0
4
4
2
4

りゅう
2
4
0
1
2
3
2

はるか
5
5
5
0
0
5
0

つばき
1
3
2
0
5
0
4

それから数日後に来店したあきらに、食中アンケートを実施したところ以下のような回答に。

まぐろ
いくら
ぶり
納豆巻
あじ
サーモン
甘エビ

あきら
0
4
0
2
3
5
0

さて、この時点であきらの未評価ネタとして

「まぐろ」「ぶり」「甘エビ」

の3食材があるが、以前来店した4名のアンケートを踏まえ、

〆の1つとしてなにをオススメするのが適切でしょうか?


考え方


Step1. 「あきら」とその他4人それぞれとの類似度を計算する

「あきら」に推薦するには、「あきら」と似たような舌を持った人の食レポを参考にするのが良さそうですよね。

往々にして、濃い味好きと薄味好きは相容れないものです。

さて。そのためには「あきら」とその他の人とで、どれくらい似ているかを数値化してあげる必要があります。

例として「あきら」と「つばき」の類似度を求めてみることにします。

類似度を求めるためには、先程の評価値も計算できる形にしてあげる必要がありますね。

そこで、先程の評価値を以下のように行列化してあげます。


  • あきら: [ 0 4 0 2 3 5 0 ]

  • つばき: [ 1 3 2 0 5 0 4 ]

そして、この行列同士の類似度を計算してあげれば、似た舌を持っているかどうか分かりそうです。

類似度を求める手法自体はいくつもあるのですが、今回はPearsonの相関係数というものを使って計算してみます。

この相関係数には以下のような特徴があります。


  • -1から+1の値を取る

  • -1に近いほど負の相関が強く、+1に近いほど正の相関が強い

  • 0は相関が無いという意味を持ちます。

この値を求めるのは簡単で、PDL::Stats::Basiccorrを使ってあげるだけです。


correlation_coefficient.pl

use PDL;

use PDL::Stats::Basic;

my $akira = pdl [ 0, 4, 0, 2, 3, 5, 0 ];
my $tsubaki = pdl [ 1, 3, 2, 0, 5, 0, 4 ];

print $akira->corr($tsubaki); # -0.123061898121355


これによると、類似度は-0.123061898121355と求めることができました。

値だけからだとイメージしづらいという方もいるかもしれないので、恣意的な例でも見てみましょう。


sample.pl

use PDL;

use PDL::Stats::Basic;

my $junpei = pdl [ 5, 1, 4, 4, 2 ];

my $azu = pdl [ 4, 1, 5, 4, 1 ]; # じゅんぺいと同じような評価値
my $rina = pdl [ 2, 5, 2, 2, 5 ]; # じゅんぺいとは逆のような評価値

print $junpei->corr($azu); # 0.894575066869489 → 強い正の相関
print $junpei->corr($rina); # -0.944444444444445 → 強い負の相関


Pearsonの相関係数による行列間類似度についてはこれでイメージできたでしょうか。

これを使って、すべての人との似ている度をそれぞれ求めてあげれば良さそうですね!

実装例を以下に示します。


calc_similarity.pl

use PDL;

use PDL::NiceSlice;
use PDL::Stats::Basic;

my $akira = pdl [ 0, 4, 0, 2, 3, 5, 0 ];

my $member = pdl [
[ 1, 2, 0, 4, 4, 2, 4 ], # しんたろう
[ 2, 4, 0, 1, 2, 3, 2 ], # りゅう
[ 5, 5, 5, 0, 0, 5, 0 ], # はるか
[ 1, 3, 2, 0, 5, 0, 4 ], # つばき
];

# 次元数を取得
my $member_num = $member->getdim(1);

for my $compare_user_index (0 .. $member_num - 1) {

# あきらとの類似度を出す相手の評価値行列を取得
my $compare_user = $member(, ($compare_user_index));

# 相関係数による類似度を算出
my $similarity = $akira->corr($compare_user);

print $similarity;
}


ここで新たに出てきたPDL::NiceSliceですが、これは行列の要素にアクセスするために用いられるナイススライス記法を使うために読み込んでいます。

プログラム上では$member(, ($compare_user_index));が該当しています。

本当はこれについても書きたいのですが、かなりボリューミーな内容になってしまうので割愛します。

コマンドライン上でPDLのインタラクティブな動作確認ができますので、これで実験してみるのがオススメです。


Step2. 評価値×類似度の重み付けスコアを計算し、重み付けスコア行列を求める

さて、1で求めた類似度ですが、これを元に似た舌を持った人の評価をより重んじていきたいですよね。

往々にして、うなぎの蒸し焼き派と直焼き派は相容れないものです。

これを実現するためには、評価値の重み付け作業が必要になってきます。

そこで「あきら」と「つばき」の類似度を元に、類似度による評価値の重み付けされた値を求めてみましょう。

これも簡単で、評価値行列の各値に類似度を掛け合わせてあげるだけです。

例えば、「あきら」と「つばき」の類似度を-0.12とすると、つばきの重み付けスコアは

まぐろ
いくら
ぶり
納豆巻
あじ
サーモン
甘エビ

評価値行列
1
3
2
0
5
0
4

あきらとの類似度
× -0.12
× -0.12
× -0.12
× -0.12
× -0.12
× -0.12
× -0.12

重み付けスコア
-0.12
-0.36
-0.24
0
-0.6
0
-0.48

のような感じです。

これを実装すると以下のようになります。


calc_similarity.pl

use PDL;

use PDL::Stats::Basic;

my $akira = pdl [ 0, 4, 0, 2, 3, 5, 0 ];
my $tsubaki = pdl [ 1, 3, 2, 0, 5, 0, 4 ];

my $similarity = $akira->corr($tsubaki); # あきらとつばきのユーザ類似度

print $tsubaki x $similarity; # つばきの重み付けスコアの算出

# [
# [ -0.1230619 -0.36918569 -0.2461238 -0 -0.61530949 -0 -0.49224759]
# ]


$tsubaki x $similarity;

のように、PDLを使うことで評価値行列に対して一括で類似度の掛け算ができます。

こうして、重み付けスコアを各人それぞれ算出し、1つの行列としてあげることで、重み付けスコア行列を求めることが出来ます。

コードとしては以下のように書くことができます。


calc_predict_score.pl

use PDL;

use PDL::NiceSlice;
use PDL::Stats::Basic;

my $akira = pdl [ 0, 4, 0, 2, 3, 5, 0 ];

my $member = pdl [
[ 1, 2, 0, 4, 4, 2, 4 ], # しんたろう
[ 2, 4, 0, 1, 2, 3, 2 ], # りゅう
[ 5, 5, 5, 0, 0, 5, 0 ], # はるか
[ 1, 3, 2, 0, 5, 0, 4 ], # つばき
];

# 重み付けスコアを保存するために、同じ形のゼロ行列として複製
my $predict_ratings = zeroes $member;

# 次元数を取得
my $member_num = $member->getdim(1);

for my $compare_user_index (0 .. $member_num - 1) {

# あきらとの類似度を出す相手の評価値行列を取得
my $compare_user = $member(, ($compare_user_index));

# 相関係数による類似度を算出
my $similarity = $akira->corr($compare_user);

# 重み付けスコアを算出
my $predict_rate = $compare_user x $similarity;

# 重み付けスコア行列に格納
$predict_ratings(,($compare_user_index)) .= $predict_rate;
}

print $predict_ratings;

# [
# [ 0.19789098 0.39578196 0 0.79156391 0.79156391 0.39578196 0.79156391]
# [ 1.3643821 2.7287642 0 0.68219104 1.3643821 2.0465731 1.3643821]
# [ 0.74893086 0.74893086 0.74893086 0 0 0.74893086 0]
# [ -0.1230619 -0.36918569 -0.2461238 -0 -0.61530949 -0 -0.49224759]
# ]



Step3. 重み付けスコア行列を元に、ネタ毎の総和を求める

あとは簡単です。ネタ毎に重み付けスコアを単純に足し合わせてしまいましょう。

これもPDLのdsumoverを使ってやれば一発で完了です。

行列の形にだけ気をつけてくださいね。


predict_score.pl

use PDL;

use PDL::NiceSlice;
use PDL::Stats::Basic;

my $akira = pdl [ 0, 4, 0, 2, 3, 5, 0 ];

my $member = pdl [
[ 1, 2, 0, 4, 4, 2, 4 ], # しんたろう
[ 2, 4, 0, 1, 2, 3, 2 ], # りゅう
[ 5, 5, 5, 0, 0, 5, 0 ], # はるか
[ 1, 3, 2, 0, 5, 0, 4 ], # つばき
];

# 重み付けスコアを保存するためにゼロ行列として複製
my $predict_ratings = zeroes $member;

my $member_num = $member->getdim(1); # 次元数を取得

for my $compare_user_index (0 .. $member_num - 1) {

# あきらとの類似度を出す相手を取得
my $compare_user = $member(, ($compare_user_index));

# 相関係数による類似度を算出
my $similarity = $akira->corr($compare_user);

# 重み付けスコアを算出
my $predict_rate = $compare_user x $similarity;

# 重み付けスコア行列に格納
$predict_ratings(,($compare_user_index)) .= $predict_rate;
}

# 計算のため、転置行列にする
my $predict_ratings_transpose = $predict_ratings->transpose;

print $predict_ratings_transpose;

# [
# [ 0.19789098 1.3643821 0.74893086 -0.1230619] # まぐろ
# [ 0.39578196 2.7287642 0.74893086 -0.36918569] # いくら
# [ 0 0 0.74893086 -0.2461238] # ぶり
# [ 0.79156391 0.68219104 0 -0] # 納豆巻
# [ 0.79156391 1.3643821 0 -0.61530949] # あじ
# [ 0.39578196 2.0465731 0.74893086 -0] # サーモン
# [ 0.79156391 1.3643821 0 -0.49224759] # 甘エビ
# ]

# ネタ毎に重み付けスコアを足し合わせる

print $predict_ratings_transpose->dsumover;

# [ 2.188142 3.5042913 0.50280707 1.473755 1.5406365 3.1912859 1.6636984]



Step4. 算出したスコアをもとに、オススメする食材を決める

ここでもしかすると忘れているであろう、当初の目的に立ち返ってみましょう。


さて、この時点であきらの未評価ネタとして

「まぐろ」「ぶり」「甘エビ」

の3食材があるが、〆の1つとしてなにをオススメするのが適切でしょうか?


という問題でしたね。これを解くために、3で求めたスコアを整理すると

ネタ
スコア

まぐろ
2.188142

ぶり
0.50280707

甘エビ
1.6636984

という結果になりました。

計算過程的にも、単純に高いスコアのものを推薦すればよいので、

Ans. 「まぐろ」

ということが、過去ユーザの情報を元に言えるということがわかりました。


2. サンプルデータを使って「あなたにおすすめのおすし」を教えてもらおう

1.では4名の情報を元にしたレコメンドを行いました。

ただお察しの通り、元にするデータの母数が少なすぎて信憑性もあったもんではありません。

そこで、2.では配布されているデータセットを元に、より現実に即したレコメンドを実装してみることにします。

とはいえ、やっていることの本質は1.となんら変わりはないです。


データセットの準備

お寿司に関するデータセットが

SUSHI Preference Data Sets

として公開されています。

この中には、寿司ネタの味の分析データから、アンケート結果まで色々含まれているのですが、今回は「5000人に対して寿司ネタ100種類に対する5段階評価のアンケート結果」が含まれているsushi3b.5000.10.scoreを使用します。


sushi3b.5000.10.score

$ head -n3 sushi3b.5000.10.score

-1 0 -1 4 2 -1 -1 -1 -1 -1 -1 -1 1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 4 -1 2 -1 -1 -1 -1 -1 -1 0 -1 -1 -1 -1 -1 -1 0 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 2 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 -1 -1 -1 -1 -1 0 -1 1 -1 -1 -1 0 -1 -1 -1 -1 0 -1 -1 -1 1 2 -1 0 -1 -1 -1 -1 -1 -1 1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 0 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
-1 3 4 -1 -1 -1 3 -1 -1 -1 -1 -1 -1 4 -1 4 -1 -1 -1 -1 -1 -1 -1 -1 -1 3 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 2 -1 -1 -1 -1 -1 -1 -1 -1 1 1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 2 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1

このデータは


  • 行がユーザで、列が寿司ネタの種類

  • 0~4の5段階評価

  • -1は欠損値(未回答)

という情報を表しています。

実装時には、このままのデータだと実装しづらいので、評価値をすべて+1する処理が入っています。


実装例

フィルタリングの精度に関わるような処理は今回含めていません。

コメントマシマシにしてありますので、ぜひ1.の内容を振り返りつつ読んで頂けたらと思います。

さてさて、どんな結果になるでしょうか...


sushi_recommender.pl

use strict;

use warnings;
use utf8;

use Encode qw/encode_utf8/;

main();

sub main {

my $sushi_recommender = SushiRecommender->new();

# レコメンド対象者の評価値
my $user_ratings = {
# ネタ名 => 評価値(1~5)
'いくら' => 4,
'納豆巻' => 2,
'あじ' => 3,
'サーモン' => 5,
};

my $recommend_items = $sushi_recommender->get_recommend_items($user_ratings);

for (@{$recommend_items}) {
print encode_utf8("$_->{name} score: $_->{predict_score} \n");
}
}

package SushiRecommender;
use strict;
use warnings;
use utf8;

use PDL;
use PDL::NiceSlice;
use PDL::Stats::Basic;

use Class::Accessor::Lite (
new => 0,
rw => [qw/
all_user_ratings
/
],
ro => [qw/
limit
sushi_neta_list
/
],
);

sub new {
my ($class, %args) = @_;

my $self = {
limit => $args{limit} // 5,
};

$self->{all_user_ratings} = $class->_load_all_user_ratings;
$self->{sushi_neta_list} = $class->_load_sushi_neta_list;

return bless $self, $class;
}

sub _load_all_user_ratings {
my $all_user_ratings = pdl(rcols './sushi3b.5000.10.score', { COLSEP => ' ' });

# 未評価値を0にし、1~5の5段階評価とする
$all_user_ratings += 1;

return $all_user_ratings;
}

sub _load_sushi_neta_list {
my @sushi_neta_list = qw/ えび 穴子 まぐろ いか うに たこ いくら 玉子 とろ 甘えび ほたて貝 たい 赤貝 はまち あわび サーモン 数の子 しゃこ さば 中とろ ひらめ あじ かに こはだ とり貝 うなぎ 鉄火巻 かんぱち みる貝 かっぱ巻 げそ かつお いわし ほっき貝 しま鯵 かにみそ えんがわ ねぎとろ 納豆巻 さより たくわん巻 ぼたんえび とびこ いなりずし めんたいこ サラダ すずき たらば蟹 梅しそ巻 子持ちこんぶ たらこ さざえ あおやぎ とろサーモン さんま はも なす 白魚 なっとう あんきも かんぴょう巻 ねぎとろ巻 牛さし はまぐり 馬さし ふぐ つぶ貝 穴きゅう巻 ひら貝 おくら 梅巻 サラダ巻 めんたいこ巻 ぶり しそ巻 いか納豆 づけ ひも 貝割れ くるまえび めかぶ くえ さわら ささみ くじら かも ひもきゅう巻 とびうお いしがきだい ままかり ほや バッテラ キャビア からすみ うにくらげ かれい ひらまさ なまこ ししゃも かき /;

return \@sushi_neta_list;
}

sub get_recommend_items {
my ($class, $user_ratings_hash) = @_;

# hashを評価値行列に変換
my $user_ratings = $class->_convert_to_ratings_matrix($user_ratings_hash);

# 既存データにrecommendユーザの情報を新規追加する
my $target_user_index = $class->_append_user_rating($user_ratings);

# 重み付け評価値を計算する
my $predict_scores = $class->_calc_predict_scores($target_user_index);

# [predict_score neta_index] の対にする
my $neta_index = sequence $predict_scores->getdim(0);
$predict_scores = $predict_scores->transpose->append($neta_index->transpose);

# predict_scoreが高い順にソート
my $predict_scores_asc = qsortvec $predict_scores;
my $predict_scores_desc = $predict_scores_asc(-1:0,-1:0);

# 使いやすい形に整形する
my @sushi_neta_list = @{$class->sushi_neta_list};
my @recommend_items;
for my $index (0 .. (scalar @sushi_neta_list - 1)) {

my $neta_index = $predict_scores_desc->at(0, $index);
my $predict_score = $predict_scores_desc->at(1, $index);

my $sushi_neta = $sushi_neta_list[$neta_index];

# 評価済のネタはskip
next if $user_ratings_hash->{$sushi_neta};

push @recommend_items, {
name => $sushi_neta,
predict_score => $predict_score,
};

last if (scalar @recommend_items >= $class->limit);
}

return \@recommend_items;
}

sub _convert_to_ratings_matrix {
my ($class, $user_ratings_hash) = @_;

my @rating_array;
for (@{$class->sushi_neta_list}) {
if (my $rate = $user_ratings_hash->{$_}) {
push @rating_array, $rate;
} else {
push @rating_array, 0;
}
}

return pdl @rating_array;
}

sub _append_user_rating {
my ($class, $user_rating) = @_;

my $all_user_ratings = $class->all_user_ratings->append($user_rating->transpose);

$class->all_user_ratings($all_user_ratings);

# 追加したユーザのindexを返す
return $class->all_user_ratings->getdim(0) - 1;
}

sub _calc_predict_scores {
my ($class, $target_user_index) = @_;

my $all_user_ratings = $class->all_user_ratings;

# レコメンド対象者のスコアデータを取得
my $target_user_ratings = $all_user_ratings(($target_user_index),);

# 重み付け評価値を格納するための行列を複製
my $predict_scores = zeroes $all_user_ratings;

my $max_user_index = $all_user_ratings->getdim(0) - 1;
for my $compare_user_index (0 .. $max_user_index) {

# 自分同士の比較はskip
next if $target_user_index == $compare_user_index;

# 比較ユーザの評価値行列を取得
my $compare_user_rating = $all_user_ratings(($compare_user_index),);

# 相関係数によってユーザ類似度を算出
my $similarity = $target_user_ratings->corr($compare_user_rating);

# 評価値 × ユーザ類似度 で重み付け
$predict_scores(($compare_user_index),) .= $compare_user_rating x $similarity;

}

# 寿司の種類毎に重み付け評価値を加算
return $predict_scores->dsumover;
}


結果はいかに。


result

えび score: 452.420013385553

穴子 score: 409.049768291082
たこ score: 386.484253122512
まぐろ score: 372.750038178082
とろ score: 362.368370164416

:thinking:

精度としては改善の余地がありそうですが、推薦自体はできていそうですね

ちなみに、もしこのレコメンドそのものの精度について興味が沸いた場合は、

「推薦システムのアルゴリズム」を読むことをオススメします。

いろいろ調べるのもいいですが、基礎理論はここに集約されていると個人的には思っています。


おわりに

今回は「メモリベースの利用者間型(ユーザベース)協調フィルタリング」と呼ばれる推薦手法を、PDLを使用して実装してみました。


「Perlだって出来るんだぞ!反射的にPythonの門戸を叩く必要は無いぞ!」


というのは伝わりましたでしょうか。

次回は13日、僕です。

「俺的、もっと知られてほしいJSライブラリ」の紹介をする予定です。

お楽しみに:sushi:


宣伝

マンガボックスでは一緒に働くメンバーを募集しています。


  • 電子書籍に関する技術に興味あるよ!!

  • ユーザに近いところで仕事がしたいよ!!

  • 領域関係なくどんどん挑戦していきたい!!

という方はもちろん、

「ちょっと興味ありそう」「もっと色々聞いてみたい」

という方は、ここから応募頂くか、@yukagilまでゆるっとご連絡いただければと思います。

(特にサーバーサイドエンジニア、積極採用中です。一緒に働いてください。)

お待ちしております!!