ROMANCE DAWN for the new world

Microsoft Azure を中心とした技術情報を書いています。

Azure Cosmos DB Gremlin API を使ってレコメンド機能を作ってみた

前回の記事では、Azure Cosmos DB Gremlin API のグラフデータを Linkurious Enterprise で可視化してみました。
gooner.hateblo.jp
グラフデータベースは、データの関連性を辿れる特徴を活用して、EC サイトなどで購入した商品と関連のある商品をレコメンドするケースにも使われます。
今回は、Azure Cosmos DB Gremlin API を使ってお勧めのアニメをレコメンドしてくれる機能を作ってみます。

データを用意する

今回は Kaggle 上で公開されている Anime Recommendations Database を利用することにしました。
このサイトから、anime.csv と rating.csv の2つのデータをダウンロードできます。

anime_id,name,genre,type,episodes,rating,members
32281,Kimi no Na wa.,"Drama, Romance, School, Supernatural",Movie,1,9.37,200630
5114,Fullmetal Alchemist: Brotherhood,"Action, Adventure, Drama, Fantasy, Magic, Military, Shounen",TV,64,9.26,793665
28977,Gintama°,"Action, Comedy, Historical, Parody, Samurai, Sci-Fi, Shounen",TV,51,9.25,114262
user_id,anime_id,rating
1,20,-1
1,24,-1
1,79,-1

その他にも、ユーザーのマスターデータが欲しかったので、Mockaroo のサイトで適当に 1,000 件を作りました。

f:id:TonyTonyKun:20220319185641p:plain

user_id,user_name
1,vmonni0
2,cdonaghie1
3,araymond2

グラフデータベースを作成する

Azure Portal から Gremlin API を選択して Azure Cosmos DB を作成し、適当な名前で新しい Graph を作成します。

f:id:TonyTonyKun:20220320141701p:plain

ノードを作成する

C# のコンソールアプリを作り、Gremlin.Net を利用してデータをアップロードします。
www.nuget.org

GremlinClient のインスタンスを生成して、ノードやエッジを操作していきます。インスタンスを生成するパラメータはあまり精査しておらず適当です。

var Host = "xxx.gremlin.cosmos.azure.com";
var PrimaryKey = "xxx";
var Database = "sample-database";
var Container = "recommend-graph";
var containerLink = "/dbs/" + Database + "/colls/" + Container;
var gremlinServer = new GremlinServer(Host, 443, enableSsl: true, username: containerLink, password: PrimaryKey);
var connectionPoolSettings = new ConnectionPoolSettings()
{
    MaxInProcessPerConnection = 10,
    PoolSize = 30,
    ReconnectionAttempts = 3,
    ReconnectionBaseDelay = TimeSpan.FromMilliseconds(500)
};
var webSocketConfiguration =
    new Action<ClientWebSocketOptions>(options =>
    {
        options.KeepAliveInterval = TimeSpan.FromSeconds(10);
    });

using (var gremlinClient = new GremlinClient(gremlinServer, new GraphSON2Reader(), new GraphSON2Writer(),
                                    "application/vnd.gremlin-v2.0+json", connectionPoolSettings, webSocketConfiguration))
{
    // ここにロジックを書いていく
}

anime.csv から読み取ったレコードでノードを作成します。12,295 件あります。
anime という label をもつ Vertex を登録し、property には name だけを設定します。

using (var reader = new StreamReader("anime.csv"))
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
{
    while (csv.Read())
    {
        var record = csv.GetRecord<Anime>();
        var query = $"g.addV('anime').property('id', '{record.anime_id}').property('title', '{record.name}').property('pk', '{record.anime_id}')";
        await gremlinClient.SubmitAsync<dynamic>(query);
    }
}

同じように、user.csv から読み取ったレコードでノードを作成します。
user という label をもつ Vertex を登録し、property には name だけを設定します。

using (var reader = new StreamReader("user.csv"))
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
{
    while (csv.Read())
    {
        var record = csv.GetRecord<User>();
        var query = $"g.addV('user').property('id', 'user{record.user_id}').property('name', '{record.user_name}').property('pk', 'user{record.user_id}')";
        await gremlinClient.SubmitAsync<dynamic>(query);
    }
}

エッジを作成する

rating-demo.csv から読み取ったレコードでエッジを作成することで、ユーザーが評価するアニメのレイティングを表現します。元データは 780 万件あったので、1,000 人分のレイティング(96,480 件)に減らしました。
rates という Edge を登録し、useranime の関係を表します。

using (var reader = new StreamReader("rating-demo.csv"))
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
{
    while (csv.Read())
    {
        var record = csv.GetRecord<Rating>();

        try
        {
            var query = $"g.V().hasLabel('user').has('id', 'user{record.user_id}').addE('rates').property('weight', {record.rating}).to(g.V().has('id', '{record.anime_id}'))";
            await gremlinClient.SubmitAsync<dynamic>(query);
        }
        catch (ArgumentOutOfRangeException ex) when (ex.ParamName == "ValueKind")
        {
            // ↓ の例外が発生するが登録自体は成功しているため、正常終了とする
            // JSON type not supported. (Parameter 'ValueKind')
            // Actual value was Number.
        }
    }
}

動作確認用のデータを登録する

レコメンドさせたいユーザーを登録します。

g.addV('user').property('id', 'user9999').property('name', 'test9999').property('pk', 'user9999')

anime.csv には ONE PIECE の作品がいくつか登録されています。

21,One Piece,"Action, Adventure, Comedy, Drama, Fantasy, Shounen, Super Power",TV,Unknown,8.58,504862
4155,One Piece Film: Strong World,"Action, Adventure, Comedy, Drama, Fantasy, Shounen",Movie,1,8.42,85020
12859,One Piece Film: Z,"Action, Adventure, Comedy, Drama, Fantasy, Shounen",Movie,1,8.39,76051
31490,One Piece Film: Gold,"Action, Adventure, Comedy, Drama, Fantasy, Shounen",Movie,1,8.32,18642
16287,One Piece: Romance Dawn,"Action, Comedy, Fantasy, Shounen, Super Power",OVA,1,7.60,8326

このユーザーには、ONE PIECE の関連作品を高評価するエッジを登録します。

g.V().hasLabel('user').has('id', 'user9999').addE('rates').property('weight', 10).to(g.V().has('id', '21'))
g.V().hasLabel('user').has('id', 'user9999').addE('rates').property('weight', 10).to(g.V().has('id', '4155'))
g.V().hasLabel('user').has('id', 'user9999').addE('rates').property('weight', 10).to(g.V().has('id', '12859'))
g.V().hasLabel('user').has('id', 'user9999').addE('rates').property('weight', 10).to(g.V().has('id', '31490'))
g.V().hasLabel('user').has('id', 'user9999').addE('rates').property('weight', 10).to(g.V().has('id', '16287'))

レコメンドする

この ONE PIECE が好きなユーザーに、お勧めの作品をレコメンドする Gremlin クエリを組み立てていきます。

まず、このユーザーが高評価している作品を取得するクエリ。

g.V().hasLabel('user').has('id', 'user9999').outE('rates').has('weight', gte(8)).inV().as('exclude')

つぎに、このユーザーが高評価している作品を高評価しているユーザーを取得するクエリ。

g.V().hasLabel('user').has('id', 'user9999').outE('rates').has('weight', gte(8)).inV().as('exclude')
    .inE('rates').has('weight', gte(8)).outV()

さらに、このユーザーが高評価している作品を高評価しているユーザーが高評価している作品(このユーザーが観た作品は除く)を取得するクエリ。

g.V().hasLabel('user').has('id', 'user9999').outE('rates').has('weight', gte(8)).inV().as('exclude')
    .inE('rates').has('weight', gte(8)).outV()
    .outE('rates').has('weight', gte(8)).inV().where(neq('exclude'))

さいごに、作品の重複を除外して、評価順にソートするクエリ。

g.V().hasLabel('user').has('id', 'user9999').outE('rates').has('weight', gte(8)).inV().as('exclude')
    .inE('rates').has('weight', gte(8)).outV()
    .outE('rates').has('weight', gte(8)).inV().where(neq('exclude'))
    .dedup().order().by(inE('rates').count(), decr).limit(5).values('title')

さいごの Gremlin クエリを実行した結果が、こちらです。

f:id:TonyTonyKun:20220320150304p:plain

レコメンドの精度を判定するのは難しいですが、幅広い作品が登録されている中で、ONE PIECE と同じ週刊少年ジャンプの「デスノート」や「進撃の巨人」、アクション・アドベンチャー系の「ソードアート・オンライン」がランクインしており、わりと妥当な結果な気がします。

まとめ

Azure Cosmos DB Gremlin API を使って、お勧めのアニメをレコメンドしてくれる機能を作ってみました。アニメのジャンルやタイプを関連づけることで、より精度の高いレコメンドができると思います。
Gremlin.Net を使ってデータをアップロードするコードは、こちらのリポジトリで公開しています。
github.com