コネヒト開発者ブログ

コネヒト開発者ブログ

AWS CodeBuild を使った検証環境へのデプロイ改善

こんにちは! フロントエンドエンジニアのもりやです。

コロナの影響でコネヒトも3月からフルリモート体制が始まり、早4ヶ月が過ぎました。 流行に乗り遅れがちな私は、今になって自宅のリモートワーク環境を整えようと動き始めています。 まずはローテーブルを卒業しよう・・・。

さて、今回はそんなフルリモート下で発生した課題の1つを CodeBuild を使って解決したので紹介させていただきます。

コネヒトにおける検証環境のデプロイ方法について

コネヒトでは、本番リリース前のチェックや開発時にAWS環境で動作確認に使う検証環境を用意しています。

この検証環境にデプロイするには以下の2つの方法がありました。

  1. master ブランチにPRをマージする(本番リリース前に動作確認するため)
  2. 開発PCから直接デプロイする(開発時に検証環境で動作確認したい時など)

2. の方法をコネヒトでは「ローカルデプロイ」と呼んでいます。 このローカルデプロイを今回 CodeBuild を使って改善しました。

ローカルデプロイの問題点

主に以下の2つが問題点としてあげていました。

  1. ローカル環境に依存しているので Docker のバージョンなど、差異が出てしまう場合がある
  2. リモートワークの際に、各家庭のネット回線によって Docker プッシュに時間がかかる場合がある

特にコロナでフルリモート環境になって 2. が深刻な問題となりました。 自宅の通信速度が遅い場合、開発業務に影響が出るレベルの方もいました。

image.png

Docker プッシュ中に Zoom のミーティングの時間が来たので泣く泣く中断して、ミーティング後に再実行・・・、なんて最悪ですね。

これらの問題を改善するために、CodeBuild を使って検証環境へのデプロイを安定して行えるようにしました。

CodeBuild の選定理由

以下のような理由で CodeBuild を選びました。

  1. ママリはAWSで動いているので、CodeBuild との親和性も高い
  2. CLI/SDK などを使ってAPIですべての操作が可能(CodeBuild に限らずAWS全体に言える話ですが)
  3. コネヒトの一部のプロジェクトで使用実績があった
  4. 比較的安い従量課金で、並列度に制限がない

ちなみに無料枠で月100分(build.general1.small のみ)はずっと無料で使えるので、試しに使ってみても良いかもしれません。

名前をつける

もともと「ローカルデプロイ」という名前でしたが、CodeBuild を使った場合は違和感のある名前なので、まずは名前をつけることにしました。

Slack で名前を募集したところ、たくさんの案が出てきました。 ありがたや~。

slack.png

色々案が出ましたが、最終的に「ブランチデプロイ」に決まりました。 主に開発時に使い、開発時はブランチを作りそこからデプロイするので、イメージに近くて分かりやすい名前ということで決めました。

ブランチデプロイの仕組み

コネヒトのサービスは主に ECS で動いており、 Docker イメージは ECR に保存しています。 このような流れで動いています。

f:id:hyirm:20200729171800p:plain

CodeBuild 用の設定ファイルを作成

まず CodeBuild が実行時に参照する YAML の設定ファイルを作成して、リポジトリ内にコミットします。 こんな感じの YAML ファイルを用意しました。

version: 0.2

env:
  variables:
    DOCKER_BUILDKIT: "1" # Ref: https://tech.connehito.com/entry/2019/06/17/180404
  parameter-store:
    GITHUB_ACCESS_TOKEN: "/xxxxxxxxxxxx/GITHUB_ACCESS_TOKEN"

phases:
  build:
    commands:
      - /bin/bash .codebuild/dev_deploy.sh

実際のデプロイ処理は dev_deploy.sh というスクリプトにまとめて、設定ファイルはシンプルになるようにしています。 dev_deploy.sh の中身は省略しますが、主に Docker ビルド&プッシュと ecs-deploy を使ってECSにデプロイする処理をしています。

ビルド時には GitHub のトークン が必要なのですが、今回は パラメータストア に設定して、parameter-store: でキー名を設定するだけで自動的に環境変数に設定してくれます。 この辺りは同じAWSサービスならではの便利さですね。

CodeBuild のプロジェクト設定

公式ドキュメントなどを参考にAWSコンソールで設定しました。

特筆すべき設定はありません。 強いて言えば、自動実行はしないのでウェブフックイベントの設定はしないことぐらいでしょうか。 (CIだとプッシュなどのタイミングで自動実行する場合が多いと思いますが、今回はユーザーからのリクエストがあって動く仕組みなので)

codebuild.png

CodeBuild を起動するシェルスクリプトの作成

AWS CLI を使って簡単にブランチデプロイが実行できるように、以下のようなシェルスクリプトを作成しました。

#!/usr/bin/env bash
set -e

readonly GIT_ROOT="$(git rev-parse --show-toplevel)"

readonly REPOSITORY_NAME="(リポジトリ名)"
readonly PROJECT_NAME="(CodeBuildのプロジェクト名)"
readonly DEPLOY_TARGET="${1:-"HEAD"}"

readonly DEPLOY_COMMIT_HASH="$(git rev-parse "${DEPLOY_TARGET}")"
readonly DEPLOY_COMMIT_LOG="$(git log "${DEPLOY_COMMIT_HASH}" --max-count=1)"

# GitHub API を使って GitHub にコミットがプッシュ済みかをチェックする
if [[ "${GITHUB_ACCESS_TOKEN}" != "" ]]; then
  HTTP_STATUS_CODE="$(curl -H "Authorization: token ${GITHUB_ACCESS_TOKEN}" -o /dev/null -w '%{http_code}\n' -s \
                     "https://api.github.com/repos/Connehito/${REPOSITORY_NAME}/git/commits/${DEPLOY_COMMIT_HASH}")"
  if [[ "${HTTP_STATUS_CODE}" == "200" ]]; then
    printf "\e[34mINFO: GitHubにコミットが存在することが確認できました。\e[0m\n"
  else
    printf "\e[31mERROR: GitHubにコミットが存在するかをチェックしたところ、ステータスコード(${HTTP_STATUS_CODE})が返却されたためブランチデプロイを中止します。\e[0m\n" >&2
    exit 2
  fi
else
  printf "\e[31mWARNING: 環境変数 'GITHUB_ACCESS_TOKEN' が設定されていないため、GitHubにコミットが存在するかのチェックをスキップします。\e[0m\n"
fi

printf "\nこのコミットでブランチデプロイを実行しますか?\n-----\n%s\n\n-----\n" "${DEPLOY_COMMIT_LOG}"
printf "実行する場合は \e[34m'yes'\e[0m と入力してください: "
read ANSWER

if [[ "${ANSWER}" != "yes" ]]; then
  echo "ブランチデプロイを中止します。"
  exit 1
fi

echo "ブランチデプロイを開始します。"
aws codebuild start-build \
    --project-name "${PROJECT_NAME}" \
    --source-version "${DEPLOY_COMMIT_HASH}" \
    --output json
echo "ブランチデプロイを開始しました。実行結果はログを確認してください。"

ざっくりいうと、こんな流れで処理を進めています。

  1. 引数からコミットハッシュを取得
    • git rev-parse を使ってコミットハッシュを取得しています。
    • ブランチやタグ、省略されたコミットハッシュでも参照できます。
    • "${1:-"HEAD"}" で第1引数に指定がない場合は HEAD からコミットハッシュを取得します。
  2. GitHub API を使ってコミットハッシュが GitHub に存在しているか確認
    • これで CodeBuild 実行後に「コミットをプッシュし忘れた〜」と気づくトラブルを防いでいます。
    • 開発時に必要になるので、コネヒトでサーバー開発に携わるエンジニアは GITHUB_ACCESS_TOKEN という環境変数をセットしています。
    • 一応、環境変数が無くてもワーニングだけ出して実行はできるようにしています。
  3. ブランチデプロイを実行してよいかユーザーに確認
    • terraform に倣って、ユーザーが yes を入力しなければ実行されないようにしています。
    • コミットログも表示して、想定していたものに間違いないかを確認できるようにしています。
  4. AWS CLI を使って CodeBuild でのビルド&デプロイを実行。
    • ちなみに AWS CLIv1, v2 がありますが、どちらのバージョンも同じコマンド体系なのでどちらでも実行できます。(下記参照)

AWS CLI v1 の場合

$ aws --version
aws-cli/1.18.90 Python/3.8.4 Darwin/19.5.0 botocore/1.17.13

$ aws codebuild start-build help

NAME
       start-build -

DESCRIPTION
       Starts running a build.

       See also: AWS API Documentation

       See 'aws help' for descriptions of global parameters.

SYNOPSIS
            start-build
          --project-name <value>
          [--secondary-sources-override <value>]
          [--secondary-sources-version-override <value>]
          [--source-version <value>]
# (以下略)

AWS CLI v2 の場合

$ aws --version
aws-cli/2.0.24 Python/3.7.3 Linux/4.19.76-linuxkit botocore/2.0.0dev28

$ aws codebuild start-build help

NAME
       start-build -

DESCRIPTION
       Starts running a build.

       See also: AWS API Documentation

       See 'aws help' for descriptions of global parameters.

SYNOPSIS
            start-build
          --project-name <value>
          [--secondary-sources-override <value>]
          [--secondary-sources-version-override <value>]
          [--source-version <value>]
# (以下略)

少なくとも今回使っている範囲では同じコマンドで実行できることがわかります。

動かしてみる

先程作ったシェルスクリプトを実行するとこんな感じになります。 AWSへの認証情報がセットされた状態 で以下のコマンドを実行します。

$ ./branch-deploy.sh "(ブランチ名、コミットハッシュなど)"

こんな感じで表示されます。

image.png

後は CodeBuild の実行を待つだけです。

ちなみにコネヒトでは CodeBuild を実行すると、自動で Slack 通知が来る共通の仕組みがあるので、通知関連は今回実装しませんでした。

slack_notification.png

※ Slack で通知されます (ちなみに社内では検証環境を「dev」とか「dev 環境」と呼んでいます)

CloudWatch EventsCodeBuild のイベントを Lambda に送って Slack に通知しています。

このような感じで実行すれば非同期で動くので、ネット回線やPCの負荷を気にせず検証環境へのデプロイが安定して行えるようになりました。

おわりに

今回は好きなタイミングで任意のコミットを CodeBuild を使ってデプロイする、ということをやりました。 IAM の権限周りで少しハマりましたが、設定は難しくなくサクッとできるので、こういった活用もありだな〜、と思いました。

最後に、コネヒトでは開発環境の改善にもチャレンジしたいエンジニアを募集中です!

www.wantedly.com

CakePHP4.1.0がリリースされたので変更点を追ってみる

こんにちは。CTOの@itoshoです。 最近stand.fmをはじめたので、よかったら聴いてみてください。

今日は週末(7/4)にリリースされたCakePHP4.1.0の変更点をBakeryやGitHubを読んでみて、個人的に「おっ」と思ったことを中心にまとめてみたいと思います。*1

なお、全ての変更点は取り上げられないので網羅的に知りたい方は公式のリリースノートをご参照ください。また、内容についてはきちんとチェックしているつもりですが、もし間違いや分かりづらい点がありましたらご指摘いただければ幸いです。

新たに追加された機能

最初に新機能についていくつか紹介したいと思います。

共通テーブル式が利用出来るようになった

個人的には今回これが一番大きな機能かなと思うのですが、ORMで共通テーブル式(CTE)が使えるようになりました。MySQL8系や他のRDBMSを使っている方には朗報ですね。*2

WITH句は以下のようにwith()メソッドとCommonTableExpressionクラスを利用することで実現出来ます。

<?php
$con = ConnectionManager::get('default');
$query = $con
    ->newQuery()
    ->with(new CommonTableExpression('cte', function (ConnectionInterface $con) {
        return $con->newQuery()->select(['col' => 1]);
    }))
    ->select('col')
    ->from('cte');

生成されるSQLはWITH cte AS SELECT 1 AS col SELECT col FROM cte;といった感じになります。

Window関数が利用出来るようになった

こちらもMySQL8系ユーザー向けの機能ですが、Window関数も利用出来るようになりました。例えば、ROW_NUMBERを利用する場合は以下のようになります。

<?php
$function = new FunctionsBuilder()->rowNumber();

OrderAsc()メソッドとOrderDesc()メソッドにクロージャを渡せるようになった

クロージャを渡すことで、以下のようなCASE WHENを用いた複雑なソート条件を設定出来るようになります。

<?php
$query = ConnectionManager::get('default')->newQuery();
$query->select(['id'])
    ->from('children')
    ->orderAsc(function (QueryExpression $exp, Query $query) {
        return $exp->addCase(
            [$query->newExpr()->add(['user_id' => 1])],
            [1, $query->identifier('id')],
            ['integer', null]
        );
    });

ちなみに、データベース周りは他にもAggregateExpressionというクラスが追加されており、表現力がかなり強力になっているように感じます。

ログメッセージに{foo}形式のプレースホルダーが利用出来るようになった

上記の形式を利用すると$contextパラメーターの値から置き換えてくれるようになったので、以下のような書き方が可能になりました。

<?php
$context = [
    'id' => 1,
    'foo' => 'bar',
];
$this->log('error: {id}', LogLevel::NOTICE, $context);

配列なんかも渡せます。地味に便利ですね。

非推奨となった機能

次に4.1系からdeprecatedとなった機能を紹介します。ここに挙げられている機能はCake5.0で廃止される予定ですので、マイグレーションガイドを読みながら早めに対応したいなと思っています。*3

or_()メソッドとand_()メソッドの廃止

今後はor()メソッドとand()メソッドを利用する必要があります。Cake3系だとor_()メソッドは利用頻度がそれなりにあるかなと思うので、変更が必要なシステムも多いかもしれません。

ServerRequest::input()メソッドの廃止

生データを文字列で取得する場合は今後(string)$request->getBody()を利用する必要があります。

whitelistというワーディングを使った機能の廃止

これは昨今のBLM運動の流れを汲んだものになり、具体的にはPaginatorComponentwhitelistという名称のオプションがallowedParametersという名称に置き換えられるなどの変更が行われています。なお、BLM運動については賛成の立場でありつつ、日本にずっと暮らしている身からすると頭では理解しながらも肌感覚としては分からないことも正直多いのですが、分からないで終わらせず歴史的背景をきちんと勉強したり、傍観者にならずきちんと当事者意識を持って今後も動向を注視したりしていく必要があると考えています。

その他気になったこと

最後にアプリケーションテンプレートや今後のロードマップを読んで、気になったことを紹介します。

デバッガーの設定でエディターを指定出来るようになった

app.php内で以下のような設定が出来るようになりました。

<?php
'Debugger' => [
    'editor' => 'phpstorm',
 ]

ここでエディターを設定しておくと(デバッグモード時に)エラー画面で表示されるエラー箇所のリンクから各エディターをURLスキームで起動出来るようになったようです。ここではPHPStromを指定していますが、VSCodeやEmacsなども指定可能です。

ちなみにデフォルトの設定をPHPStormにしたときのPRのコメントが面白かったです。

CsrfProtectionMiddlewareの設定が移動した

新規のユーザーがこのミドルウェアがどこで設定しているか分かりづらいとの理由routes.phpから Application.php移動しています。routes.phpに設定するメリット(パスのスコープ毎に設定を切り替えられる)もありつつ、テンプレートとしてはApplication.phpに置く方が直感的だなと思いました。

DIコンテナの実装は持ち越しになった

以前、DIコンテナが4.1に導入されるかもしれないという記事を書いたのですが、ロードマップを読むと4.2へ持ち越しになったようです。個人的には期待していたのですが、やはりそれだけ影響範囲が多いということでしょうか。引き続き、状況をウォッチしていきたいなと思います。

まとめ

変更点をいくつか紹介しましたが、リリースノートをざっとみてもマイナーバージョンということもあり、細かなアップデートが中心になったかなと思います。もちろん、それが悪いことではなく、フレームワークは漸進的に確実に進んでいくことがDX(Developer Experience)の向上に繋がると思うので、CakePHPの本体のアップデートに負けないように自分たちのシステムも継続的にアップデートしていければと考えています。

というわけで最後に宣伝です!コネヒトでは新しいシステムをCakePHP4で実装するなど、積極的にCakePHPを利用しています。CakePHPを利用したアプリケーション開発や日本の家族を取り巻く社会課題の解決に興味があるエンジニアの方はオンラインで構いませんので、是非お気軽にカジュアルにお話出来ると嬉しいです!

www.wantedly.com

*1:なお現時点(7/6)で"composer create project"を実行すると既にに4.1.1がインストールされるようになっています。

*2:コネヒトではMySQL5系互換のAuroraを利用しているので、恩恵を受けられるのはもう少し先になりそうです。

*3:公式ドキュメントではRectorを使ったマイグレーション方法が紹介されています。

ECS×Fargate ターゲット追跡ServiceAutoScallingを使ったスパイク対策と費用削減

こんにちは。インフラエンジニアの永井(shnagai)です。

今回は、ターゲット追跡ServiceAutoScallingを使い、ECS×fargateで運用しているサービスのスパイク対策と費用削減に取り組んだのでその内容をまとめています。

内容はざっくり下記4項目について書いています。

  • 抱えていた課題
  • キャパシティプランニングに対する考え方
  • ECS ターゲット追跡ServiceAutoScallingとは何か?
  • どんな結果になったか?

抱えていた課題

コネヒトでのWebのアーキテクチャはほとんどがECS×Fargateの基盤で動かしています。 ECSのバックエンドをEC2からFargateに移行したタイミングで、大きく下記2点のメリットは享受していました。

  • EC2を意識しないことでの運用コスト削減
  • オートスケールの容易さ

ですが、サービス運用にあたりまだ下記のような課題がありました。

  • ①FargateのコストがEC2リザーブドインスタンスに比べて費用が高い
  • ②オートスケールは容易になったが、平均50sくらい起動にバッファが必要なので、瞬間的なスパイクに対する瞬発力が弱い

今回この2つの課題に対して、ターゲット追跡ServiceAutoScallingを本格導入することで解決することに成功したのでどのようなアプローチをしていったかについて紹介します。

キャパシティプランニングに対する考え方の変化

アプリケーションの実行環境としてクラウド利用やコンテナ化が進んできたことで、スケールアウトやスケールアップが容易になったことを背景として、昔のようなピークトラフィックに耐えうるキャパシティを事前に用意しておくようなアーキテクチャは、私達のような小・中規模のウェブサービスでは選択しなくてもよい時代になりました。

もちろんサービスの可用性を高く保つのはインフラエンジニアとしての本分なのでそこは保ちつつ、クラウドは従量課金なので、ユーザが多いときには多くのリソースを構え少なくなったらリソースを最小化するというより動的なキャパシティコントロールをすることがそのまま費用削減につながります。同じ品質のサービスを提供するのであれば費用は少ない方がいいに決まっており、費用削減もサービス運用に大きな意味を持つと思っています。

コネヒトの最近の話を少し振り返ると、下記のような流れをたどっています。

アーキテクチャ ポイント 費用
ECS×EC2時代 EC2バックエンド時はオートスケールが複雑だったので採用せずにピークトラフィックに*数倍に耐えうるEC2を用意 ReservedInstanceを利用し費用は圧縮
ECS×Fargate時代 EC2(RI)と比べると費用が高いので、ピークトラフィックに耐えうるタスク数までFargateの必要数をチューニングして減らす+想定外のスパイクはオートスケール利用(ちょっと遅いという課題感あり) SavingsPlansを利用し30%弱の費用を圧縮するが、タスク数減らしてEC2時代と同じくらいの費用感
ECS×Fargate(ターゲット追跡ServiceAutoScalling)時代 負荷に応じてタスク数を動的にコントロール。最小数を小さくして夜間とピーク時で3倍程のタスク数の差が出る。平均的なタスク数の削減に成功 従量課金を生かして2のパターンと比べて25%程の費用削減に成功

ECS ターゲット追跡ServiceAutoScallingとは何か?

ここからは、今回活用したターゲット追跡ServiceAutoScallingについて紹介します。

簡単に説明すると、CPU使用率/メモリ使用率/リクエスト数の3つから追跡するメトリクスと値を選択することで、ECS側でタスク必要数 (起動するコンテナ数)を動的にコントロールしてくれる機能です。

実はこれまでもスパイク時のオートスケール用には使っていたのですが、今回スケールインもこの機能に任せる設定を入れて、負荷が低い状態の時に費用削減するようなアプローチを取りました。

具体的にどのような動きをするか

現在は、ECSサービスの平均CPU使用率 を使っているのですがこの挙動が少し理解出来なかったので検証時に調べてみました。

端的に言うと、指定した値に収束するようにタスク数をコントロールするような挙動になります。 例えば、CPU 40%という値を設定したとすると具体的に下記のような挙動になります。(これが中々わかりにくかった)

  • 平常時に10タスク起動で、CPU40%
  • リクエスト数が増え負荷が上がり、CPU55%に
  • タスク数が10→13にスケールアウトしCPU40%に収束
  • 深夜になり、リクエスト数が減りCPU20%に
  • タスク数が13→5にスケールインしCPU40%に収束

裏側では、CloudWatchAlarmがセットされて下記のような設定がされていました。(このアラームの設定は変更負荷)

  • 【スケールアウト判定】 3分間連続しきい値違反
  • 【スケールイン判定】15分間連続しきい値-3%に収束

設定のポイント

ここから設定のポイントを書いていきます。

スケールの数を設定

サービス内のタスクのスケールイン/アウトの幅をまず設定します。

項目 内容
Minimum number of tasks (タスクの最小数) スケールインの最小値なのでこの数以下にはタスク数は減らない
タスクの必要数 サービスで設定しているタスクの必要数が入ります
Maximum number of tasks (タスクの最大数) スケールアウトの最大値なのでこの数以上にはタスク数は増えない
ターゲット追跡するメトリクスを指定

スケーリングポリシーには、「ターゲット追跡」と「ステップスケーリング(任意のCWAを指定)」があり、今回は「ターゲット追跡」を使う場合の設定について書きます。

ターゲット追跡の対象と出来るのは下記3つです。

ECSサービスの平均CPU使用率 ECSサービスの平均メモリ使用率 ECSサービスに紐づくALBのリクエスト数

また、スケーリングの設定として

項目 内容 ポイント
ターゲット値 追跡したメトリクスでのしきい値 サービスの性質により検証しながら適切な閾値を入れる
スケールアウトクールダウン期間 スケールアウトした後の待機時間(この間は連続でスケールアウトは発動しない) コネヒトでは60sにしています
スケールインクールダウン期間 スケールインした後の待機時間(この間は連続でスケールインは発動しない) コネヒトでは900sにしています(ゆっくり縮退してほしいので)
スケールインの無効化 On/Offでスケールインを行うかどうか 今回の目的だとOff

どんな結果になったか?

最後にこのターゲット追跡ServiceAutoScallingを導入してどのような結果が出たかを紹介します。

①FargateのコストがEC2リザーブドインスタンスに比べて費用が高いという課題に対しては、1日平均のタスク起動数が25%程削減出来ました!!

従量課金なので、Fargateの利用料金が25%削減しています。

f:id:nagais:20200610114745p:plain

②オートスケールは容易になったが、平均50sくらい起動にバッファが必要なので、瞬間的なスパイクに対する瞬発力が弱いという課題に対しても解決策を見つけることが出来ました。

どうゆうことかというと、ターゲット追跡のしきい値を緩めたことでリソースに余裕のある状態でスケールアウトが走るので、例えばpush通知で急激にリクエストスパイクする際もレイテンシ悪化なくスケールアウト出来るようになりました。

これまでは、CPU60%超えで発動というような強めの設定をしていたので、スケールアウトする際にはすでにリソースがかつかつで既存のタスクが死んでいくというような状況だったので、スケールアウトで収束するまでに時間が少しかかっていたということに改めて気づきました。

実は、スケールアウト時に間に合わずレスポンスタイム悪化すると使えないなと思っていたのですが、レイテンシに全く影響のでない抑えめのしきい値の最適値を見つけ、現在はこのような波形で動的にタスク数が制御されています。(設定後一度もアラートは鳴ってません)

ちなみに、導入時は、ピーク時間帯が毎日不安で、最適なタスク数と追跡するしきい値1週間は張り付いてリソース状況を観察していました。

f:id:nagais:20200610114809p:plain

最後に宣伝です! コネヒトでは一緒に成長中のサービスを支えるために働く仲間を探しています。 少しでも興味もたれた方は、是非気軽にオンラインでカジュアルにお話出来るとうれしいです。

人の生活になくてはならないサービスをつくる、インフラエンジニア募集! - コネヒト株式会社のインフラエンジニアの求人 - Wantedly www.wantedly.com

カテゴリ類推の機械学習モデルをプロダクションに導入した話

こんにちは。インフラエンジニアの永井(shanagi)です。

今回は、昨年から取り組んでいる機械学習の分野で、自分の作ったモデルをサービスに本番リリースする機会があったので、PoCで終わりがちな機械学習をプロダクト導入にこぎつけるためにどのようなプロセスを歩んだのかとそこで得た自分なりの知見を中心にご紹介できればと思います。

機械学習のプロダクト導入に向けては歩むステップが非常に多く、個々の詳細な内容について細かく書くと非常にボリューミーな内容になってしまうので、詳細は割愛し機会があればまたどこかでご紹介出来ればと思います。

内容は、ざっくり下記2つに絞りました。

  • どんな機能をリリースしたのか?
  • 導入までの全体アプローチ

ちなみに、なぜインフラ畑の自分が機械学習をやっているのかについては、昨年末に下記ブログにまとめたのでもし興味がある方がいれば読んでみてもらえればと思います。

kobitosan.hatenablog.com

どんな機能をリリースしたのか?

まずは今回どのような機能をリリースしたのかを紹介します。 ママリはママ達の悩みや疑問を解決するQAプラットフォームなので、毎日ユーザであるママさん達から多くの質問投稿があります。

ママリ内のカテゴリとしては、「妊娠・出産」「育児・グッズ」「お金・保険」などがあり、質問文の内容に合わせて投稿時にユーザに選択してもらっています。 今回は、この質問投稿時のカテゴリ選択において、質問本文の内容から適切なカテゴリを類推してユーザに推薦する「カテゴリ類推」エンジンをリリースしました。

↓質問の本文からカテゴリを類推している様子です!カテゴリを考え中の時にカテゴリ類推のAPIが呼ばれています。 f:id:nagais:20200512144003p:plain

詳しくは、この後の章で触れるのですがこのリリースによって、下記の指標にプラスの影響を出すことが出来ました。

- 回答率の向上
- 質問投稿時のCVRの向上

機械学習の導入は、導入時はマイナス影響なしの状態で入れてそこからモデルアップデートを通して数値を上げていくものという期待値調整をするので、初期モデルである程度プラスの成果が出たのは運がよかった面もあるな思っています。

導入までの全体アプローチ

前提として昨年からコネヒトでは、機械学習を活用したプロダクトの改善を組織として推し進めていくという取り組みを行っています。組織から機械学習導入を期待されているというのは、説明コストが大幅に下がること(PoCまでのハードルが下がる)を意味しており今回のカテゴリ類推導入に向けても大きな後ろ盾になりました。

全体のステップ

今回カテゴリ類推を本番リリースするにあたり大まかに下記のステップをたどりました。

①仮説作りのためのデータ分析
↓
②仮説を裏付けるためのデータ分析
↓
③モデル作成
↓
④オフライン検証と品質チェック
↓
⑤デモ作成(AWSとAndroidアプリ)
↓
⑥A/Bテスト(PoC)と効果測定
↓
⑦本リリース

それぞれどんなことをしたのかとそこで得た知見ベースで振り返っていこうと思います。

進める上で気をつけた点として、ある程度の精度はオフライン検証で出来るが、実際のユーザの行動は出してみないとわからない部分が大きいのでできるだけ早くA/Bテストまで持っていくことを意識しました。

①仮説作りのためのデータ分析

機械学習導入を期待されているといっても闇雲に機械学習を入れればいいというわけではもちろんありません。 サービスが抱える課題を解くためのツールとして機械学習が適切であればそれを使います。

その課題を可視化して理解し、仮説(機械学習で解決すべき課題)に落とし込むのにはデータ分析が必要です。 解くべき課題に関しては、主にサービスのKPIやそれを細分化したものの達成を設定することが多いと思います。今回のカテゴリ類推においても、当時のサービスKPIの一つになっていた回答率を分析することからはじめました。

データ分析を進める上での工夫として、属人化しがちなデータ分析の知見を気軽に共有するために、GitHubのissueでデータ分析のログを残すようにしています。 仮説を裏付ける際や簡単なモデルの試作品を作る際に使うjupyter notebookのソースも同じリポジトリで管理するようにしており、後からでも過去のプロジェクトを気軽に参照出来るようにしています。

f:id:nagais:20200512143537p:plain

可視化したデータは下記のようなダッシュボードにまとめておくことで、以後のA/Bテスト時の結果計測にも使えるような形にしておきました。

f:id:nagais:20200511205800p:plain

②仮説を裏付けるためのデータ分析

①のデータ分析の結果、質問に対して適切なカテゴリが選択されると回答がつく確率が上がりそうなことがわかりました。 このカテゴリ選択を適切にすることで回答率を上げられそうという仮説を裏付けるためのデータ分析に入ります。 同時に、どのようにして投稿のカテゴリを適切に設定するかを検討しました。その中で、今回採用する機械学習を使った質問投稿時の「カテゴリ類推」を導入すれば、ユーザが入力する投稿内容を元に適切なカテゴリを設定出来てUXも上がりそうという仮説が最終的に出来上がりました。

この仮説を裏付けるために更にカテゴリと回答に絞った分析を行い下記のような事実が浮かび上がってきました。(一部抜粋)

これらは質問文の内容から適切なカテゴリが入ることで回答を増やしてくれそうという仮説の裏付けになりました。

  • 特定のカテゴリに偏りがある(質問内容とは関係なくカテゴリを選択している質問が多くある)
  • ユーザは好きだったり興味のあるカテゴリを回遊しながら回答してくれていることが多そう

③モデル作成

続いて、ローカルのjupyter notebookで「カテゴリ類推」のモデル作成を行いました。 ざっくりメモですが、どんなことをやったか羅列しておきます。

  • 学習データの作成
    • 過去の膨大な質問データを使えるので加工等は不要
    • 1ヶ月分のデータを取ってpandasで分布みたりしてデータ件数を揃える
  • 質問文を機械学習で使える形に前処理する
    • 小文字化とテキストから不要な文字を削除や置換
    • neologdnを使った正規化とUnicode正規化
    • MeCabを使ったトークナイズ(どの品詞を使うと一番精度高いか色々と試したり)
  • 学習データとテストデータの分割
    • scikit-learnのtrain_test_splitを使う
    • 最初層化抽出してなくて、層化抽出することで大きく精度が伸びたのはなつかしい
  • 特徴抽出
    • 文字列をベクトルに変換する手法
    • Bag of WordsとTF-IDFを試す
    • 最終的に、TF-IDF(min_df,max_dfでストップワード兼ねる)を使った
  • アルゴリズム選定
    • scikit-learnを使っていたので、SVM,ロジスティック回帰,ランダムフォレストを試す
    • GridSearchしながら同じ条件でどのアルゴリズム一番精度が出るかを繰り返した
    • 恥ずかしながら当時はニューラルネットワークはまだ手をつけてなかったので試してない(今後検討)

手元である程度の精度が出るモデルが出来たのでオフライン検証と品質チェックに進みます。

④オフライン検証と品質チェック

オフライン検証として、学習データに含まれないN日分のデータからの正答率を出して指標としました。

オフライン検証の結果、ある程度精度の出る(ユーザに違和感なく提供出来そうな)モデルが出来ました。 ただ、機械的に正答率が高くても、ユーザにとって不快な推薦をしてしまうとサービス価値の毀損につながります。ママリはコミュニティサービスなので、せっかく質問しようとしたユーザにカテゴリの推薦で不快な思いをさせてしまっては機会損失につながります。

ここの部分の不安を解消するために、CSチームと連携して、オフライン検証の結果間違ったデータに関して品質チェックを行ってもらいました。 結果、ユーザに致命的に不快感を与えるような予測はないという裏付けをもらってから次のステップに進みました。

⑤デモ作成(AWSとAndroidアプリ)

ディレクターや社内で説明するのに動いているものを見せるのが一番説得力が増すと考えて、Androidエンジニアに協力してもらいデモ用のAPIを叩くデモアプリを作ってもらいました。 今回このデモの果たしてくれた役割は大きく、やはり動くものをサクッと作るのは大事だなと思いました。

⑥A/Bテスト(PoC)と効果測定

デモやデータ分析と品質チェックの結果をディレクターに説明して、A/BテストへのGoサインを無事こぎつけました。

A/Bテストを行う上では、当たり前ですがトラッキングするためのダッシュボードを事前に作っておくことが重要でした。 今回は①で作ったダッシュボード+mixpanelでアプリver毎のイベントをトラッキングすることで効果測定を行いました。 結果指標として掲げていた項目で明らかな優位が見られました!

- 回答率の向上
- 質問投稿時のCVRの向上

裏話でもないですが、ユーザに直接届くものを作ってリリースする機会はこれまであまりなかったので、結果を毎時ドキドキ見ていたのを思い出します。 興奮してこんなツイートもしてました。

⑦本リリース

効果測定の結果、明らかな優位が見られたので簡単なレポートをまとめ関係者に共有し本リリースが決まりました。 ここでのレポーティングは事前にデータ分析する時に効果測定時のレポートを意識しながら可視化したのが役立ちサクッと作りました。

その後、A/Bテスト時は実現までのスピードを重視し手動作成したモデルを使っていたので、前処理とモデル作成のバッチ処理を作成し自動化しました。バッチはAWS上でECS+Fargateベースで動かしています。

最後にどんなアーキテクチャで動いているのかを載せておきます。

f:id:nagais:20200512101636p:plain

今回は技術的な内容には踏み込まずにデータ分析から機械学習のプロダクト導入に至るまでの道筋についてご紹介しました。 コネヒトでは、これからも今回の事例のようにテクノロジーの力でプロダクトを伸ばしていってくれる仲間を絶賛募集しています。ご興味ある方は是非一度お話だけでもさせてもらえるとうれしいです。

www.wantedly.com

ママリでAWSを使った動画配信をはじめました。

こんにちは! フロントエンドエンジニアのもりやです。

ママリではよりユーザーにとってわかりやすく情報を伝えるべく、4/22 からママリ内の人気記事などを動画にし配信する取り組みをはじめました。

news.connehito.com

今回、動画配信にあたって AWS 上で動画変換と配信を行ったので、使用したサービスやシステム構成などを解説しようと思います。

システムの全体像

AWSサービスを使って、以下のような構成で作りました。

20200511084620.png

CloudCraft

※この図は動画の配信のみに絞っています。動画の情報をアプリに配信するAPIなどは別にあります。

大まかに説明するとこんな感じです。

  • CloudFront + S3 を使ってHTTP (HLS) で配信
  • MediaConvert を使ってMP4形式の動画をHLS 形式へ変換
  • API Gateway + Lambda を使って動画変換のAPIを提供
    • DynamoDB を使って動画の情報を保存

以下、詳しく説明します。

動画のフォーマット

配信にあたり、まず動画をどのように配信するかを決めました。 今回配信する動画は、数十秒程度がメインなのでそのまま動画ファイルを配信する方法も検討しました。 ただ、1分を超える動画もありますし、全部ダウンロードしないと再生できないというのは体験的に良くないのでストリーミングでの配信に決めました。

動画のストリーミング配信は全く知見がなかったので色々調べたところ、以下の2つが主流のようです。

  • HLS (HTTP Live Streaming)
  • MPEG-DASH(ISO/IEC 23009)

あまり詳細な仕様までは調べきれていないですが、いろいろ調べた結果、HLSの方がシンプル、MPEG-DASHはDRMなどの複雑な要件にも対応できるがその分仕様も複雑、という印象でした。

今回の配信要件はシンプルで、iOS/Android でもネイティブのエンジニアの方から再生できることが可能であることを確認したのでHLSを採用することにしました。 ちなみに、HLSはAppleが提唱している規格なので、iOSは当然対応していますし、HLS形式の動画はSafariで直接開くことも可能です。(他のブラウザは対応していません)

CloudFront + S3

HLSは配信にあたって静的なHTTPサーバーにファイルを配置するだけで良いので、よくある CloudFront + S3 で配信しています。

MediaConvert

S3に置いてある動画ファイルを変換して、変換した動画ファイルをS3に出力してくれるサービスです。 今回の動画配信で最も重要なサービスでした。

ジョブの作成

MediaConvert は「ジョブ」という単位で変換を実行します。 ジョブは、以下のような入力と出力を設定して、変換の詳細を設定します。

変換の全体像

入力が一つに対して、出力が複数あることが分かるかと思います。 例えば、1つの動画からHLSとMPEG-DASHの2つを同時に変換して出力することができます。

ちなみに入力も複数作れるようなのですが、今回複数入力を使う用途はなく私にも用途が分からないので、説明は割愛します。 (動画の結合とか、複数の動画を横に並べて結合したりできるんでしょうか?)

入力の設定

20200511084801.png

入力は S3 に配置した動画ファイルのパスを指定します。 他にもたくさん設定項目がありますが、今回はここの設定以外は特に使っていません。 動画は奥が深い・・・。

出力の設定(動画)

20200511084926.png

出力先の S3 プレフィックスを入力します。 (※プレフィックスであり、このままの名前で出力されるわけではありません。)

また、下の方の「出力」欄を見ると、2つの出力があるのが分かるかと思います。 例えばHLSであれば、複数のビットレートの動画を1つの配信に含め、クライアントがネットワークの状況に応じて切り替えることができます。 そういった場合に、1つの出力グループに対して、複数の出力を設定することができます。 (ちなみに、今回のママリの配信では諸事情により1つの出力にしています)

20200511084953.png

各出力に対して、動画の詳細を設定できます。 こちらも大量の設定項目がありますが、主に使ったのは以下の2つです。

  • ビデオコーデックと解像度
  • ビットレート

他は特にいじらなくても、デフォルトのままで特に問題ありませんでした。

出力の設定(キャプチャ画像)

今回、動画に配信する際にサムネイル用の画像も一緒に作りたいということで、動画内から1秒おきにキャプチャ画像を作り、その中から適切な画像を選んで配信する、という方法を取りました。

例えば、30秒の動画なら、30枚のJPEG画像が出力され、その中からサムネイルとして良さそうなものを選ぶ、という感じです。

20200511085029.png

こんな感じの出力設定をします。 画質にあまり拘らなければ、フレームレートを調整すれば大丈夫です。 1/1 で1秒に1枚、という設定になります。

変換の実行

設定が完了したら、変換を実行します。 以下のようにジョブリストに表示され「COMPLETE」と表示されれば完了です。 ちなみに設定にエラーがある場合は「ERROR」と表示されます。

20200511085044.png

変換が完了すると S3 にはこのように出力されます。

20200511085108.png

※ジョブ作成時に設定したプレフィックスなどによって出力先は変わります。

出力先を CloudFront で配信している S3 バケットにしておくと、変換完了後にすぐインターネットからアクセスできるので便利でした。

API Gateway + Lambda

AWS SDK でも実行できるのですが、curl やスクリプト、管理画面などから使いやすいように、シンプルなAPIを提供するようにしました。

  1. アップロードの情報を提供するAPI
    • S3 の Pre-Signed URL を使って、アップロードに必要な一時URLを生成して返却するAPIです。
    • アップロード自体は、アクセス元の責務にしてます。
  2. 変換をリクエストするAPI
    • MediaConvert にジョブを作るAPIです。
    • ジョブIDなどは DynamoDB で管理しています。
  3. 動画情報を取得するAPI
    • MediaConvert に問い合わせて、ジョブの情報を取得して返却します。
    • 変換が完了している場合は、インターネットからアクセスできるURLなども返却します。

なお、API Gateway は API Key でアクセスを制限しています。

変換作業

今回は、1本辺り数十秒程度がメインの動画を200本以上、再生時間だと2時間以上の動画ファイルをアップロード&変換しました。 curl などで手動アップロードも可能ですが、時間がかかる、ヒューマンエラーが発生しやすい、設定ミスなどで再変換したい場合でも再実行しやすくしたい、といった目的のため Node.js でスクリプトを書きました。

詳細は省きますが Node.js を使いアップロードを最大10並列で実行するスクリプトを書いて実行しました。 大体変換完了まで16分ほどで終わったので、かなり早くできました。 MediaConvert は複数実行すると並列に変換してくれるので、単に2時間の動画を変換するよりも早く終わります。便利ですね!

料金

CloudFront での配信料金

これは動画に限らないですが CloudFront からインターネットへの転送は 1GB あたり 0.114 USD かかります。 $1 = 107円で、100GB の配信をしたとすると 0.114USD * 100GB * 107円 = 約1,220円 かかります。

https://aws.amazon.com/jp/cloudfront/pricing/

MediaConvert での変換料金

今回の肝である MediaConvert での変換にはどのぐらいの料金がかかるのか紹介します。 最近のスマホは解像度が高くなっているので、MediaConvert の算定方法だと 4K に該当する画質で変換しました。

https://aws.amazon.com/jp/mediaconvert/pricing/

フレームレートは <30fps でやったので、 1分間の変換料金は 0.034USD になります。 $1 = 107円で、60分の動画を変換した場合、 0.034USD * 60分 * 107円 = 約218円 になります。 めっちゃ安いですね!

VODやってる会社さんとかはわかりませんが、今回のように小規模な動画配信を行う場合、 動画変換の開発や設定、サーバー維持費など考慮すればめちゃくちゃコスパいいサービスだな、と感じました。

その他

S3 API Gateway Lambda DynamoDB などの料金などがありますが、CloudFront での配信料金に比べると微々たるもの、もしくは無料枠で収まる程度でしかなかったので、省略します。

おわりに

今回初めての動画配信でしたが、HLS で配信し、AWS のサービスを使うことでかなり容易に構築や配信準備を行うことができました。 動画配信は敷居が高いイメージでしたが、今回のようなシンプルな配信と AWS のサービスを組み合わせる場合は割と楽にできます。

未知の領域に踏み込んでみるのは楽しいですね。 コネヒトでは、新しいことにどんどんチャレンジしたいエンジニアを募集中です!

www.wantedly.com www.wantedly.com www.wantedly.com

BigQueryのスケジューリングクエリで日付別シャード化テーブルを作成する

こんにちは!テクノロジー推進グループでエンジニアをやってるaboです。

この記事では、Google BigQueryのスケジューリングクエリを使って日付別シャード化テーブルを作成する方法を紹介します。

日付別シャード化テーブルはBigQueryにおけるテーブル分割の方法の1つです。弊社では費用と計算コスト減を目的にテーブル分割を行なっており、その中でも日付別シャード化テーブルは以下のようなメリットがあり、採用しています。

  • where句でテーブル名の絞り込みが出来るのでスキャン量が調整出来て便利
  • テーブルが日付ごとに分かれるので、バッチ失敗時のリランやテーブル再生成が気軽に行える

cloud.google.com

では実際に日付別シャード化テーブルを作成する手順に移ります。

手順

1. クエリを用意して「スケジュールされたクエリを新規作成」を選択

このクエリの結果がそのままテーブルの中身になります。下記の画像の例では、抽出対象となるテーブルが日付別シャード化テーブルになっており、前日分のデータを抽出しようとしています。

f:id:aboy_perry:20200422193705p:plain
クエリを書いて、スケジュールを新規作成

2. クエリ結果の書き込み先のテーブル名をシャード化したい日付にあわせて入力

ここが本題ですが、テーブル名の指定には、文字列のほか、run_daterun_time という2つのパラメータが使えます。特にrun_timeパラメータを使うと前日の日付でシャード化することができたりします。抽出対象とするデータが前日分だからシャード化する日付も前日のものにしたい、というケースで活躍します。

以下が入力例です。

例1)テーブルの日付をバッチ実行日にしたい

table名_{run_date}

例2)テーブルの日付をバッチ実行日の前日にしたい

table名_{run_time-24h|"%Y%m%d"}

f:id:aboy_perry:20200422203355p:plain
スケジュール設定画面

ハマりポイント

run_daterun_timeUTCになるので注意が必要です!つまりスケジュールをJST 09:00以前にした場合、run_daterun_timeはJSTの実行日の前日を示します。例2ではそこからさらに-24hしているため最終的にJSTの実行日の前々日でシャード化されることになります ԅ( ˘ω˘ԅ)

他の入力例や詳細は下記をご覧ください。

cloud.google.com

3. 残りの入力欄に適当な値を設定して、「スケジュール」を実行

f:id:aboy_perry:20200422225501p:plain
スケジュールを実行

これでスケジューリングしたクエリが実行されたら、日付別シャード化テーブルができてるはずです!👏 下の画像のように、日付別シャード化テーブルはBigQueryのGUI上で日付ごとに選択できたり、見やすくなっています。

f:id:aboy_perry:20200422231227p:plain
日付別シャード化テーブル

また、「スケジュールされたクエリ」画面では、スケジュールの詳細を見ることができ、次に実行される予定の時間を確認したり、クエリやスケジュールの変更を行うことができます。

f:id:aboy_perry:20200422224520p:plain
スケジュールされたクエリの一覧

日付別シャード化テーブルからデータを抽出する

できた日付別シャード化テーブルは、WHERE句で _table_suffix を用いて日付をまたいで検索することができます。

以下はhoge_events_{日付}という日付別シャード化テーブルから、過去3日分のデータを抽出する例です。

SELECT *
FROM
    `dataset_name.hoge_events_*` as events
WHERE
    _table_suffix BETWEEN FORMAT_DATE("%Y%m%d", DATE_SUB(CURRENT_DATE("Asia/Tokyo"), INTERVAL 3 DAY)) AND FORMAT_DATE("%Y%m%d", DATE_SUB(CURRENT_DATE("Asia/Tokyo"), INTERVAL 1 DAY))

また、今月分のデータを指定する場合はLIKEを使って以下のようにもできます。

SELECT *
FROM
    `dataset_name.hoge_events_*` as events
WHERE
    _table_suffix LIKE FORMAT_DATE("%Y%m%%", CURRENT_DATE("Asia/Tokyo"))

おわりに

UTCである点はハマりポイントかなと思いますが、簡単に日付別シャード化テーブルを作ることができます。テーブル分割を取り入れ、コストを削減していきましょう〜。


コネヒトでは、データを活用してプロダクトを成長させたい!機械学習やりたい!といった方を募集中です!

www.wantedly.com

www.wantedly.com

www.wantedly.com

レコメンドエンジン導入までの取り組みとアーキテクチャについて

こんにちは!MLエンジニアのたかぱい(@takapy)です。

今回は、ママリのアプリ内にレコメンドエンジンを導入したので、導入までの取り組みやアーキテクチャについてご紹介できればと思います。


目次


ママリ内での課題

ママリはサービスとして6年目を迎え、サービスの成長とともにアプリ内の記事数も増えており、それに伴いユーザーが本来欲しい情報にたどり着くことも難しくなってきました。

加えて「子育て層のユーザー」という切り口1つとっても、0才児のママと1才児のママでは悩みや欲しい情報がまったく異なります。

このような背景から、これまで人的に行っていたルールベースでの記事配信に対し課題を感じており、機械学習で解決できないか、ということで取り組み始めました。

アーキテクチャ概要

全体のアーキテクチャは以下のようになっています。

アプリのログはBigQueryに蓄積されているため、そこからデータを取得・構造化した後、推薦記事を計算してDynamoDBに保存しています。

APIはFlaskで構築し、APIのログはAthena経由でRedashから参照できるようにしています。
このRedashはBigQueryのデータも参照できるので、ここでAPIのログとBigQueryの行動ログデータを突き合わせて、各種指標(CTRなど)をモニタリングしています。

f:id:taxa_program:20200330195638p:plain
アーキテクチャ

EDAとアルゴリズムについて

まずはデータの理解を深めるために、簡単なEDAを実施しました。
ママリの記事にはカテゴリ*1がついているのですが、あるユーザー属性別にカテゴリ毎の閲覧数を見てみると、大きな偏りがあることが分かりました。
(例えば、ユーザー属性Aの人は、「子育て・家族カテゴリ」の記事をよく見ているが、ユーザー属性Bの人は「住まいカテゴリ」の記事をよく見ている、など)

f:id:taxa_program:20200330184036p:plain
特定カテゴリにおけるユーザー属性別の記事閲覧数例

この結果を元に、ユーザーを数十種類のクラスタにハードクラスタリングし、「クラスタ単位」と「クラスタ×ユーザー単位」の2種類に分け、推薦記事を計算しました。

「クラスタ単位」とは、以下のようにクラスタ粒度でRatingテーブルを作成して推薦記事を計算するイメージです。こうすることで、新規ユーザーに対してもある程度嗜好性を考慮した記事を推薦してくれることを期待しています。

f:id:taxa_program:20200330184949p:plain
クラスタ単位の推薦記事計算例

「クラスタ×ユーザー単位」とは、ユーザーごとにそれぞれのクラスタ内で推薦記事を計算するイメージです。

f:id:taxa_program:20200330185134p:plain
クラスタ×ユーザー単位の推薦記事計算例

また、今回は「0→1」での導入だったため、レコメンドの精度よりは実装までのスピードを意識して取り組みました。
そのため、推薦アルゴリズムには業界内での成功事例があり、かつ比較的実装コストも低い協調フィルタリングと、Matrix Factorization(MF)を採用しようと決め、この2つのアルゴリズムでオフライン検証に進みました。

オフライン検証の失敗と学び

全ユーザーの直近3週間の行動ログ(アプリの閲覧履歴、ユーザーのアクションを含む)をBigQueryから取得し、構造化しました。
そこからデータを時系列に、古い2週間と直近1週間に分割し、前者を学習データ、後者をテストデータとしてオフライン検証を行いました。

オフライン検証では、推薦したアイテムをクリックするか否かを評価するため、Recallという指標を用いました。Recallとはユーザーが実際に嗜好したアイテムのうち、推薦したアイテムでカバーできたものの割合です。

結果、このオフライン検証では良い数値がでませんでした。

そもそもオフラインでの評価は、我々がおすすめしようとするまいと、ユーザーが嗜好(クリック)したものを予測しているので、それを正確に予測できることにどれだけ意味があるのか、という議論ポイントはあると思っています。
そんな中でオフライン検証ではあまり良い予測ができないケースもある(良い予測ができても、それがそのままオンラインでの精度にならない)ということを学べたのは、今回の収穫の1つであったと思います。

とはいえ、PoCばかり進めていても仕方ないよね、ということから、未知の部分は多いもののプロダクションでどういう動きをするか見てみよう、ということでA/Bテストを実施しました。

A/Bテストについて

冒頭で説明したアーキテクチャは最終的なもので、A/Bテスト時の構成はかなりシンプルにしました。

今回、オフライン検証で良い数値が出た訳でもないため、現状の表示条件(ルールベース)よりCTRが下がる恐れがありました。
そんな中で全体のアーキテクチャを先に設計・構築してしまうと、実装までのスピードが遅くなったり、仮に指標が下がってしまった場合の時間/金銭的コストが大きくなってしまいます。

そのため、下図のような最小限の構成としました。

f:id:taxa_program:20200330191934p:plain
A/Bテスト時のアーキテクチャ

結果的に、A/BテストではCTRが7ポイント向上し、検定を行いそのCTRに有意差が確認できたことから、プロダクションへ本格導入することに決定しました。

レコメンドアルゴリズムについて

今回試した2つのアルゴリズムについて簡単にご紹介します。
(詳細な実装方法などはWeb上に優良な記事がありますので、ここでは概要の説明に留めます)

強調フィルタリング(アイテムベース)

協調フィルタリングは大別して「ユーザーベース」と「アイテムベース」の手法があります。今回実装したのはアイテムベースの協調フィルタリングです。

まず、上記Rating行列から全てのアイテム間の類似度を計算して、類似度行列を生成します。この時の類似度計算方法には「ユークリッド距離」や「コサイン類似度」を使用できます。今回はコサイン類似度を使用しました。

f:id:taxa_program:20200330192217p:plain
類似度行列作成例

次に、推薦アイテムを計算したいユーザーのベクトルと、この類似度行列の積をとります。
下記例では、user Dに対して推薦したいアイテムを計算しています。計算結果からすでに評価しているitem 2を除外すると、item 4の値が一番高いため、user Dにはitem 4を推薦すればよい、ということになります。

f:id:taxa_program:20200330192410p:plain
userDに対する推薦記事計算例

Matrix Factorization

Matrix Factorizationはその名前の通り、Rating行列をuserの特徴量行列(P)とitemの特徴量行列(Q)に分解します。
例えば、m人のユーザーとn個のアイテムを考えたときに、m > k > 0であるk次元に次元削減して変換することを目的とします。
これは、評価値を表すRating行列(R)を、ユーザー要素を表すk × mの行列(P)と、アイテム要素を表すk × nの行列(Q)に近似することです。


R \approx PQ^T

図にすると以下のようなイメージです。

f:id:taxa_program:20200330193600p:plain
行列分解例

そして、分解された2つの行列の積をとると、新しいuser×itemのRating行列が生成されます。このRating行列は密な行列となるので、値の高いアイテムをそのまま推薦すればよいことになります。
例えば、user Aに対してはすでに評価しているitem 1とitem 2を除外すると、item 3を推薦すればよい、ということになります。

f:id:taxa_program:20200330193324p:plain
最終的な行列

最後に

今回は0からレコメンドエンジンを構築するまでの取り組みや、そのアーキテクチャについてお話しました。

私自身、レコメンドエンジンの構築はMovieLensのデータセットなどを使ってやってみた経験しかなかったので、実サービスに対して取り組むことで、実際にユーザーの反応を見ることができたり、安定して配信するための基盤作りに携われたり、とても面白かったです。

今回のレコメンドエンジンはまだまだ課題もあり、これからユーザーの反応を見ながらアップデートしていく予定なので、その時はまた取り組みをご紹介できればと思います。

*1:「子育て・家族」「病院」「住まい」など