良いニュースキュレーションアプリを目指して

この記事は[『ドワンゴ AdventCalendar 2020』](https://qiita.com/advent-calendar/2020/dwango2)の17日目の記事です。

今回は[Menthas](https://menthas.com/)というニュースキュレーションアプリを運用して得られた知見について書こうと思います。Menthasは5年前のアドベントカレンダーで話題になった後も定期的にリニューアルを行っており、現在月に約4000人ほどのユーザが安定的に使っている状況のようです[^7]。数年ごとの大きなリニューアル以外はあまり手を入れることはしていないので、ここまでの人に使ってもらえるのはありがたい限りです。

これまで何年もニュースアプリを作り続けてきたのもあって、それなりにニュースアプリで重要となる要素を把握できたように思います。この記事では、前半は今月の上旬に行った三度目のリニューアルの内容を解説し、後半ではこれまでの内容を振り返りつつ自分なりのニュースアプリの哲学についてまとめていきます。

![スクリーンショット 2020-12-16 2.31.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51540/916168fd-454b-08e0-58c5-662092cfc2aa.png)

### コンテンツを考慮する~プログラマ向けのニュースをいかに配信するか
Menthasではニュースをキュレーターのブックマークから収集していて、複数人がブックマークするか、対象となるニュースがキュレーターが属するカテゴリの種類のものでありそうなら配信するという仕組みになっています[^6]。そのためプログラミングに関係のないニュースでも大きな話題になったものは配信されてしまうという問題や、カテゴリに属してるかどうかを単純なキーワードマッチングのみで判別していたので重要なニュースを取り逃がしている問題が散見されていました。

形態素解析を入れるとシステムの複雑度が上がってしまうのでこれまでは避けていたのですが、配信するニュースの品質を向上させるには仕方ないということで、[Mecab](https://taku910.github.io/mecab/)による形態素解析と、ニュースをベクトル化しカテゴリとの類似度を算出するシステムを追加で入れることにしました。図にすると以下のような仕組みになります。

![スクリーンショット 2020-12-16 23.50.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51540/e591ca61-1540-5850-4eb9-e3709b101030.png)


#### Graph Embeddingによる類似語対策
上記のような処理の流れは情報検索や推薦システムを実装する際には一般的な形式ですが、工夫があるとすればニュースをベクトル化する際にBag-of-WordsでなくGraphEmbeddingで得られた分散表現を使用している点があります。

一般に分散表現ではWord2Vecが有名ですが、Graph Embeddingはグラフ構造をベクトル空間に落とし混む手法で、DeepWalk[^2]やNode2Vec[^3]といった手法が知られています。色々と試した感じではWord2VecでEmbeddingするよりもGraphEmbeddingの方が単語間の関係を反映したベクトルが得られる感じがしています。Menthasでは日本語版のWikipediaからコンピュータやIT関連のキーワードを含む13万件の記事のリンク構造をDeepWalkでEmbeddingしたものを使用しました。

この対応により従来の単純なキーワードマッチでは取り逃がしていた関連語を含むニュースを配信したり、プログラミングカテゴリに関係のないニュースのスコアを下げることができるようになりました。

#### アンサンブルはここでも有効
分散表現によるベクトル化を採用したとき元々あった単純なキーワードマッチングによるスコアは廃止する予定でした。しかしiOSのベクトルとAndroidのベクトルの類似度が近くなったり、AR系の単語とVR系の単語の類似度が低いため、AR/VRカテゴリが全く更新されなくなるという問題が起きました。

iOSのカテゴリにAndroidの記事が入り込むとノイズと見なされる傾向があると過去のフィードバックからわかっていたので、従来のナイーブな単語一致のアルゴリズムとアンサンブルさせるアプローチを取っています。単純に2つのアルゴリズムから得られたスコアを若干重み付けして足し合わせているだけなんですが、良い塩梅で動いているようです。アンサンブル学習的な考えはここでも有効なんだと実感できました。

### フロントエンドの細かい工夫
上記のようなアルゴリズムの工夫がニュースの質に直結するのはわかりやすいですが、フロントエンドの一見地味に見える工夫もユーザ体験には重要だったりします。以下の二点は自分が思っている以上に効果があったもので、細かい内容になりますが紹介します。

#### Moment.jsからDayjsへの移行
Menthasではニュースの経過時間をフロントエンドやバックエンドで使用するために以前はMoment.jsを使用していたのですが、これを[Dayjs](https://github.com/iamkun/dayjs)に移行しました。というのもHerokuのデプロイ時にJSサイズが487KiBと大きく、警告が出ていたため調査したところ、Moment.jsが大きなウェイトを占めていたためです。

DayjsはMoment.jsとAPIの互換性を持っているので簡単に移行することができます。移行によりJSのサイズを487KiB->137KiBと約70%ほどのサイズ削減ができ、ローディングの待ち時間も体感できるくらいに減りました。Moment.js自体がメンテナンスモードへ移行しており代替ライブラリへの移行を公式も勧めているようなので[^4]、まだやられていないようなら移行をオススメします。

#### ローディングを出す&ProgressTimeLatchを使ってチラつきを抑える
細かいですがページロード時にローディングを表示するようにしました。ライブラリは[vue-loading](https://github.com/jkchao/vue-loading)を使用しています。Vuexを採用していたこともあり導入は楽でした。
しかしAPIの値がCloudFrontにキャッシュされている場合、一瞬でロードが終わるため画面がちらついてしまう問題が起きました。

##### ローディングによるちらつきの例
<a href="https://gyazo.com/43356f2c0240a69af13d52ebd1892395"><img src="https://i.gyazo.com/43356f2c0240a69af13d52ebd1892395.gif" alt="Image from Gyazo" width="400"/></a>

そのためProgressTimeLatchを導入し対応を行いました。CloudFrontにキャッシュがある場合は50ms程度で値が返るので100ms以上経過した場合にローディングを表示するようにしています。実装はVuexのAcitonでAPI通信を行う直前に`setTimeout`を設定し一定時間経過後にloadingのflagを`true`にし、APIの値が返ったときにflagを`false`にしつつ`clearTimeout`を行うことでProgressTimeLatchの機能を実現しています。

※ProgressTimeLatchについては以下のエントリがよくまとまっています。
https://qiita.com/tsumuchan/items/38346528da5ebb8d8e64

### 考察とまとめ
最後に個人的にニュースアプリで重要だと思う点とMenthasの哲学について書きます。

#### 多様かつ新しいものを提供し続けること
これまでのリニューアルの中で一番品質に効いているのは、キュレーター選出部分を除けば、トップの記事を多様化することと、スコアを時間減衰させることで新しいニュースを入れ替わりで表示する部分だと思います。

トップページのニュースに偏りがある場合、例えば全てJavaScriptに関連するものだったり同じサイトからの記事だったりすると、これは自分で使っていても良いキュレーションとは言いづらいです。Menthasではそのような場面に遭遇するとアルゴリズムを調整しています。またトップページを新しいニュースで入れ替えることの有効性は自明な気がしますが、人間はやはり新しいニュースや記事の方が好きなようです[^8]。

あと最近思うのが、Googleニュースにしてもはてなブックマークにしても、どこもニュースの配信内容が似通ってきている傾向があるように思います。なので今後はサイトごとでいかに独自性を出せるかも鍵だと思っています。ニュースの内容が同じだとそのサイトをチェックする必要性がなくなってしまうためです[^9]。

#### そもそもニュースの品質とは何なのか
良いニュースキュレーションアプリを作るときに大きな問題となるのは何をもって「良い」と定義するかだと思っています[^5]。なぜなら良さを定義できなければ、アルゴリズムを改善するどころかそもそもアルゴリズムを設計することすらできないためです。

Menthasではクリック数やPV数を使ったアルゴリズムを取り入れることは今のところしていません。フィードバックを取り入れるとシステムが複雑化しすぎてしまうというのもありますが、CTRの最適化に多腕バンディット問題を応用するといったよくあるアプローチで達成される内容にそもそも懐疑的だからです。

例えばニュースサイトで単純にPVを稼ぐなら猫の写真を出せば良いというのは、猫コンテンツ問題と呼ばれるらしいですが[^1]、これはプログラマ向けのニュースアプリでも例外ではなくPVやCTRを優先するならゴシップや煽り系の記事を配信すれば良いということになってしまうでしょう。なので自分なりに納得できるKPIを設定できるまではCTRを使ったインタラクティブなアルゴリズムは導入しない予定です。

#### なぜ海外のニュースもキュレーションするのか
Menthasは海外のニュース記事もキュレーションすることがあります。以前このような海外ニュースがどれくらいクリックされているかを調べたことがあるのですが、日本語の記事と比べても軒並み低い値でした。

PV数やCTRを優先するなら、海外のニュースは取り除いた方が良いことになります。しかし自分も含めて、海外の記事を日々目にすることで何かのきっかけでふと読んでみようと思うことはあるでしょうし、そういう創発的な可能性を軽視したくないという思いがあります。やはりソフトウェア開発においては一次情報は海外のものになるし、その入り口として機能をアフォードし続けたい。一見硬派に見えるブログ記事や論文はクリックされにくいかもしれないが、その架け橋ではあり続けたい。これはMenthasの哲学といえるかもしれません。

突き詰めるとニュースアプリにおける「良さ」を求めるなら、自身の哲学を数値的に表現する独自の指標を作り上げることになるのでしょう。本屋のセンスや嗜好は平台でわかるという話があるようですが、今後も良いニュースをキュレーションできるようなアルゴリズムを探求していきたいと思います。

[^1]: ネットメディア覇権戦争 第7章 猫とジャーナリズムと偽ニュース, https://www.kobunsha.com/shelf/book/isbn/9784334039660
[^2]: Bryan Perozzi, Rami Al-Rfou, and Steven Skiena. 2014. DeepWalk: online learning of social representations. In Proceedings of the 20th ACM SIGKDD international conference on Knowledge discovery and data mining (KDD '14). Association for Computing Machinery, New York, NY, USA, 701–710.
[^3]: Aditya Grover and Jure Leskovec. 2016. Node2vec: Scalable Feature Learning for Networks. In Proceedings of the 22nd ACM SIGKDD International Conference on Knowledge Discovery and Data Mining (KDD '16). Association for Computing Machinery, New York, NY, USA, 855–864.
[^4]: Moment.jsがメンテナンスモードへーー新機能開発は行わず, https://mag.osdn.jp/20/09/18/113400
[^5]: 何をもって良いニュースとするかについては自分自身もまだ答えを出せていない。Menthasでは強いていうなら、キュレーターが選んだニュースの中でもトピックに関連するものを良いニュースとしている
[^6]: キュレーターの選び方は過去記事を参照..https://qiita.com/ytanaka/items/6cfad69a4c000c05be40
[^7]: Menthasにはアカウントがないため、ここではGoogleAnalyticsのユーザ数を使用しています
[^8]: 新しいニュースやブログの方が好まれる理由についてはよくわかっていません。心理学などの分野では学術的な知見があったりするんでしょうか
[^9]: 実際にMenthasでなくGoogleニュースで満足している時期もあったのですが、だんだん満足できなくなってきたのもあり、それが今回のリニューアルに繋がっています。