こんにちは、Gunosy Tech Lab の山田です。
Gunosy で開発しているニュースアプリ、ニュースパスでは「多くの人が知っておくべき」と判断されるニュースが出た時、即座にそれをユーザにプッシュ通知でお知らせする速報プッシュ機能があります。 例えば誰もが知るような有名人の結婚や、多くの死傷者が出てしまったような事件などが起こったときに速報が送られます。
しかし「多くの人が知っておくべきとまではいかないが、この話題に興味がある人は知っておいたほうが良さそう」なニュースも多くあります。 例えばスポーツ業界内でのニュースや、株価の大幅変動といったニュースなどがこれに当たると考えています。 そのようなニュースを全ユーザに送っても興味がないユーザが殆どですし、そのようなユーザからするととても邪魔な通知になってしまいます。 実際、以前のオリンピックの際などは速報を送りすぎてしまったのが原因で速報通知機能をOFFにするユーザが多くいました。
そこでニュースパスで、このような速報を興味がありそうな人だけに送るターゲティングプッシュシステムを作りました。 今回はそれについての話になります。
ニュースパスの速報通知
実はニュースパスには既に一部の地域のユーザや、一部のバージョンのアプリ使っている人にだけプッシュ通知を送信する仕組みがあります。 それらはユーザ情報を保管している DB(MySQL) に対して、該当ユーザを抽出するクエリを予め準備しておくことで実現しています。 例えば「居住地を北海道に設定しているユーザを抽出するクエリ」や「1.xx.x以下のバージョンのアプリを使っているユーザを抽出するクエリ」を予め作っておいて、通知送信時はそのクエリを選択するだけで一部のユーザだけに通知を送信することができます。
実装コスト削減のため、今回作った機能もこの仕組みの上に乗せるようにしています。 したがって「特定のジャンルに興味がありそうな人」を抽出するクエリを作ることができればほぼ目的は達成されることになりますが、元々DBにはユーザの興味関心に関する情報は入っていないので、いかにそれらをユーザ情報と JOIN できる形で DB 上に格納するか、というのが今回の話のキモになります。
「興味がありそうな人」とは
ニュースパスでは個人個人にパーソナライズされた記事リストを配信するため、記事やユーザを同一のベクトル空間に埋め込む仕組みが存在します。 これについて興味がある方は下記のスライドに詳しく載っているのでぜひ読んでみてください。
今回はこの埋め込まれた記事やユーザのベクトルを使って「興味がありそうな人」を抽出します。 本当はアドホックに「記事ベクトルとユーザベクトルの距離が近い人」を抽出できれば良かったのですが、負荷的な理由と、上記の速報システム上に乗せるのに都合が悪いという理由で断念しました。
そこで、予めいくつかのジャンルのタグ(「スポーツ」「野球」「経済」など)とその埋め込みベクトルを作っておき、それと近いベクトルを持つユーザが「そのジャンルに興味があるユーザ」であるとしました。
ターゲティングプッシュシステム
ターゲティングプッシュシステムは大雑把に以下のような構成になっています。
順を追って説明していきます。
タグベクトル生成
まずはタグベクトルを作る必要があります。 タグベクトルには、そのタグに関連づく複数の タグ関連記事 の重心ベクトルを使用します。 それぞれのタグに対するタグ関連記事は予め人手で選んでおき、その情報を RDB に格納しています。
タグベクトル生成バッチは先程格納したタグ、タグ関連記事に加えて記事ベクトルを取得してタグベクトルを計算し、タグベクトルを S3 に Put しています。 タグやタグ関連記事が更新された際は再度バッチを動かし、タグベクトルを更新するようにしています(タグベクトル生成バッチ自体は管理画面から動かせるようにしています)。
タグベクトル - ユーザベクトル間の距離計算
次にタグベクトル - ユーザベクトル間の距離を計算していきます。 MySQL 上でこれが計算できればそれで終わりなんですが、上記の通り負荷的な理由で断念しました。 代わりに深夜のうちにバッチで計算し、その結果を MySQL に格納するようにしています。
バッチ処理のスケジューリングにはワークフローエンジンの Digdag*1 を使用しています。
ニュースパスにはログの ETL 等に使用している Digdag サーバが元々存在しているので、それを利用しています。
また、ベクトル演算には分散クエリエンジンの Presto を使用しています。
Presto ではやたらと配列を扱う関数が充実しているので、下記のようなクエリ1つで簡単にベクトル間のユークリッド距離が計算できます。
SELECT user.id, tag.id, reduce( zip_with( user.vector, tag.vector, (x1, x2) -> pow(x1 - x2, 2) ), cast(0 as double), (x1, x2) -> (x1 + x2), (x) -> sqrt(x) ) distance, FROM user, tag
Presto のクエリ結果を MySQL に挿入するのには Embulk を使用しています。 Embulk は Plugin 機構によって多種多様なデータソースを扱うことができ、ここでもそれぞれ Presto と MySQL に対応した Plugin を使っています。
また Presto の分散処理によってある程度スケールするとはいえ毎回全ユーザに対してこの処理を行うのはやはり厳しいので、前日にユーザベクトルが変化したユーザ(=前日に記事を1クリック以上したユーザ)分のみを処理して結果を挿入 or 更新するようにしています。
対象ユーザの抽出
これでDB上にユーザの興味関心に関するデータを格納することができたので、あとは各タグ毎に「タグベクトル - ユーザベクトル間の距離が近いユーザ」を抽出するクエリを作るだけです。 どこまでを「近い」とするかの閾値をよしなに決める仕組みは残念ながら無いので、実際にプッシュ通知が送られた際の開封率などを見ながら調整しています。
これでターゲティングプッシュの準備はできました。
結果
実際にこのシステム上で打たれたプッシュ通知はこれまでと比べても開かれやすく、興味がある人にのみ送る通知の有効性を確認できました。
今後の改善
今回作成したターゲティングプッシュシステムは実装スピードを優先する分、既存のシステム上に載せるために妥協した点がいくつかありました。 特に大きかったのは 予めタグを作っておく必要がある 点です。 このために結局タグ及びタグ関連記事選定の工数がかかってしまったり、いざターゲティングプッシュすべき記事が出てきても適切なタグが無く打てなかった、といった状況が発生してしまいました。
これらのニュースパスでのターゲティングプッシュ運用結果を元に、現在グノシーで上記の問題を解決した新たなターゲティングプッシュシステムを構築して試験運用しています。 その結果次第でそのうちニュースパスにも導入されるでしょう。
おわりに
今回はニュースパスでのターゲティングプッシュシステム構築についてのお話をしました。 元々ニュースパスではデータ基盤が非常に整っていたので、高速に実装から本番導入まで行うことができました。 先人たちに感謝です。
Gunosy では各種プロダクトの記事配信ロジックの改善を日々進めています。 興味がありましたら、ランチなど気軽にお声がけください。