LIVESENSE Data Analytics Blog

リブセンスのデータ分析、機械学習、分析基盤に関する取り組みをご紹介するブログです。

LivesenseDataAnalyticsBlog

MENU

機械学習システムの信頼性エンジニアリング

こんにちは、データプラットフォームグループの田中 (@yubessy) です。機械学習基盤のプロダクトマネージャをしています。

いきなりの質問で恐縮ですが、機械学習(ML)システムの開発プロジェクトでこんな経験をされた方はいらっしゃいますか?

  • MLエンジニア: Webエンジニアに「コードの品質が低くシステムを本番運用できない」と言われた
  • Webエンジニア: MLエンジニアに「開発と本番でデータが違って精度検証ができない」と言われた

どちらかに心当たりがあるなら、同じチームにもう一方に当てはまる人がいるかもしれません。 MLシステム開発には様々な経験をもつメンバーが関わるため、システムの信頼性について重視する観点が分かれることは珍しくありません。

システム開発において信頼性は重要な要素です。 特にMLシステムには高い不確実性がつきまとうため、通常にくらべて信頼性の実現がより困難になります。 この記事では、MLシステムが安定して価値を提供するために求められる信頼性と、それに対するシステム開発・運用の観点からの扱いについて考察し、実際にリブセンスで行っている取り組みをご紹介します。

MLシステムの信頼性をどう捉えるか

システムの信頼性についてはサイト信頼性エンジニアリング (SRE) の考え方がとても参考になります。 SREの原則の中でもサービスレベルの概念は特に有用です。 サービスレベルに基づく信頼性エンジニアリングでは、信頼性をサービスレベル指標 (SLI) によって定義し、保証すべきサービスレベル目標 (SLO) を定め、SLIとSLOに基づいて方法論を展開します。 例えばWebサービスならリクエストに対するレイテンシやエラー率、バッチジョブならスループットや復旧時間などでSLIを定義し、その目標値をSLOとして設定します。 この手法はWeb開発で注目されていますが、広く一般のシステムにも適用できると考えられます。

MLシステムにサービスレベルの概念を導入するには、まずその信頼性を定義する必要があります。 通常のシステムでは人が書くコードが入出力を決定する関数となりますが、MLシステムではデータを入力とする学習アルゴリズムがこの関数(モデル)を生成します。 このためシステムに変更がなく正常に動作しているように見えても、データの変化により内部的に精度が下がっている、といったことがMLでは起こり得ます。 このようにMLシステムはデータに強い依存性をもつため、通常と異なる種類の信頼性についても考慮する必要があります。 私自身の経験から、MLシステムの信頼性は次のように区分すると捉えやすいと考えています。

  1. 通常と同じ指標で定義でき、同じ手段で実現できるもの
    • システムを構成するML以外のコンポーネントの信頼性
    • アプリケーションやインフラのセキュリティ
  2. 通常と同じ指標で定義できるが、ML特有の手段が必要なもの
    • 予測モデルを内包するWeb APIのレイテンシやモデル学習のジョブの復旧時間
    • MLを利用する機能のエンドポイントに対するリクエストのエラー率
  3. ML特有の指標と手段が必要なもの
    • 分類モデルの誤り率や回帰モデルの誤差などの精度指標
    • ノイズに対する頑健性や学習を繰り返した場合の結果再現性

このうち2については1と同一視されがちなため、特に注意が必要です。 簡単な例としてWeb APIのレイテンシの向上を図る場合、一般的なアプリケーションならばI/O処理の効率化などが有効ですが、MLモデルではパラメータ数の調整による計算量の削減といった手段が必要なこともあります。

MLシステムの信頼性が問題になるとき

さて、これらの信頼性はどんなときに問題になるのでしょうか? その説明のため、個人的な経験をもとに架空のケースを考えてみました。 以下では求人情報サービスにおける次のようなシステムを想定としています。

  • 各求人のCTRをロジスティック回帰で予測し、求人の検索結果の並び替えに利用する
  • モデルの学習と結果の予測は一連のバッチジョブとして1日1回夜間に実行される
  • 出力データはアプリケーションのDBに格納され、最後に成功したジョブの結果が常に利用される

ケース1: エラーに対する過剰な反応

ある日、予測精度の改善のためにパラメータの調整を行いました。 オフライン検証ではこの調整により平均的な精度は向上するものの安定性が多少低下することがわかりました。 そこでジョブの学習ステップの後に検証ステップを追加し、精度が以前のモデルより低い場合にはジョブを途中終了させて予測とデータ更新が行われないようにしました。

リリースから数日後のジョブ実行で、精度が以前のモデルを下回りジョブが途中終了しました。 これをジョブ管理システムが異常終了として検知し、運用担当者にアラートが通知されました。 運用担当者は直ちにジョブの再実行を試みましたが、ジョブは再び途中終了しました。 最終的に運用担当者はシステムを開発したMLエンジニアに連絡を取り、アプリケーションからは前回の予測結果が利用されるため数日間ジョブが失敗しても問題ないことを知らされました。

ケース2: 効果的でない手段の適用

ある日、開発環境で精度検証を行ったところ、本番環境と全く異なる異常な予測値が得られました。 このままではモデルの改善に支障をきたします。 開発用DBの主なデータは日次で本番用DBと同期されていたためデータに大きな問題はないと考え、コードの中で環境条件が影響する箇所を確認することにしました。 しかしコードの中では到るところでデバッグ出力のON/OFFなどに環境情報が使用されていました。 そこで数日間かけて大規模なリファクタリングと単体テストの追加を行い、環境差異を局所化した上で再度精度検証を実行しましたが、やはり結果は異常なままでした。

後日改めて精度検証を実行してみると、今度は本番との差異が全く見られませんでした。 そこで改めてデータの同一性を厳密に確認したところ、次のことが判明しました。 開発用DBの初期化と本番からのデータ同期は毎朝行われ、その直後に精度検証を実行すれば本番と同じ結果が得られます。 ただし開発用DBはアプリケーション開発と共用であり、開発中に不自然なダミーデータが混じりこむと、それが学習データの中の外れ値となってモデルに破壊的な影響を及ぼすようです。

MLシステムの信頼性エンジニアリング

上のようなケースはMLシステムの開発に関わった方ならある程度馴染みがあるかと思います。 では、こうしたケースはどんな原因から発生し、どうすれば解決できるでしょうか? 様々な方策が考えられますが、私自身は以下のような方針を重視しています。

サービスレベルについての認識を関係者間で一致させる

信頼性エンジニアリングにおいては、過度の可用性や耐久性を追求するのではなく、求められる水準に対して必要十分な手段をとることが大切です。 特にMLシステムでは、信頼性を通常と同じ指標で定義できても、通常と同等の水準を保証するのが困難なこともあります。 このような場合にMLシステムを通常と同じように扱おうとして過度のコストをかけてしまわないよう、開発・運用に関わるメンバーが必要十分なサービスレベルを認識しておく必要があります。 場合によっては前述のような信頼性についてSLI/SLOを明示することも有効と考えられます。

ケース1では、MLエンジニアと運用担当者の間でサービスレベルの認識が一致していなかったと思われます。 MLエンジニアはモデルの安定性が低いことを考慮した上で、実際のCTRは数日では大きく変動しないため過去の予測結果が使われても問題ないと考えたかもしれません。 一方運用担当者はMLジョブのサービスレベルを売上計上ジョブなどと同じよう捉え、ジョブの途中終了がサービス上の損失に繋がると考えたかもしれません。 結果的にはアラートの仕組みやそれに対する対応は過剰なものでしたが、根本的な問題はジョブの復旧要否や復旧時間について共通認識を築けていなかったことです。

システムに求める信頼性に応じた手段を適用する

システムの信頼性を実現するには、そのシステムの性質に合った手段を選ぶ必要があります。 通常のシステムでは可読性やテスタビリティのようなコード品質が信頼性の実現に大きな役割を果たします。 MLシステムでもコード品質は重要ですが、前述の2,3番目のような信頼性をコード品質だけで担保するのは困難です。 例えばコードレビューが「人の目を利用して信頼性を実現する」手段だとすれば、MLシステムではオフライン検証などの「データを利用して信頼性を実現する」手段が有効になります。

ケース2では、コードのバグが原因であるという誤った仮説に基づいて、リファクタリングや単体テストの追加といったコード品質の向上に多くの労力が割かれました。 もちろん元々のコード品質が良好なら他の原因にいち早く気づけた可能性もあるため、これは決して無駄な行為ではありません。 しかし一度問題が発生した後では、数日間かけてコード品質を向上させるよりも、まずデータの同一性をより厳密にチェックするべきだったと考えられます。 特にMLの場合、通常の開発では問題にならない小さなデータの差異がモデルに大きな影響を及ぼすことがあるため、大部分のデータが一致しているだけでなく件数・値域・分布などあらゆる面からデータを検証することが必要です。

信頼性の実現手段を適用できる仕組みを整備する

目的に応じて手段を適用すべきとわかっていても、それが簡単にできるわけではありません。 複数のMLシステムが継続的に開発・運用されるような現場では、手段の適用を支える仕組みの構築にも注力すべきです。 例えばモデル開発に利用するデータは、一元的に管理され開発者が簡単に利用できることが理想的です。 さらに、同じシステムの一部であっても、MLモデルとデータ入出力のような各機能には異なる性質の信頼性が求められます。 これらを疎結合化し、独立して実行できるようにすれば、コードレビューの分担やモデルの精度検証もやりやすくなります。

ケース2でも、モデル検証に利用するDBとアプリケーション開発に利用するDBが異なっていれば問題自体が起きなかったかもしれません。 またMLモデルの部分が他と独立化されていれば、モデル学習の実行前にデータの異常を検知する処理を導入するなどの手段が取れたかもしれません。

リブセンスでの取り組み

実際のMLシステム開発の現場では、それぞれの環境に応じて試行錯誤が行われていると思います。 リブセンスでのMLシステム開発においても、数年間にわたり成功と失敗が積み重ねられてきました。 そのような経験をもとに、現在社内で実践しているいくつかの取り組みについてご紹介します。

システムの開発段階に応じたフェーズの導入

サービスレベルの認識を合わせることはとても重要ですが、ひとつひとつのシステムに細かく運用上のルールを定めるのもそれはそれで手間がかかります。 そこでMLチームでは、システムに対して求められる信頼性と試行錯誤の自由度を把握しやすいよう、実環境で運用されるMLシステムを次のいずれかのフェーズに区分しています。

フェーズ 開発・運用の目的 サービスレベル 信頼性エンジニアリングの注力点
コンセプト検証 コンセプトの実現性の検証 SLI/SLOを定めない インフラやデータの整備と提供
技術検証 最適な技術手段の検証 個別に判断 システム設計やトラブル対応の協力
実運用 継続的な価値提供 SLI/SLOを定める 安定性・メンテナンス性の維持

例えば前述の予測システムならば、「CVRによる検索結果の並び替えがユーザ行動に影響するか」といった検証を目的とする段階では、コンセプト検証フェーズとしてSLI/SLOを定めず一部のユーザ・求人に限定してリリースすることが考えられます。 コンセプト検証がうまくいったとしても、精度が安定せずユーザへの価値提供に懸念があるような場合、技術検証フェーズではフェールオーバー方式の導入など運用課題の解決を目的とした検証と改善を行います。 こうして継続的に価値を提供できるようになれば、実運用フェーズに移行してSLI/SLOを設定し、ジョブの復旧体制やアラートルールを整備するといった要領です。

細かい条件にはまだ考慮の余地がありますが、目安としてのフェーズを決めることで、個別にルールを定める手間を省きつつサービスレベルの共通認識を形成しやすくなります。 これにより運用負荷を抑えつつ試行錯誤のプロセスを効率化しやすくなると感じています。

データ分析基盤・ML基盤を活用した再現性の担保

リブセンスでは全社データ分析基盤としてLivesense Analyticsを開発・運用しており、MLシステムの入力ソースは本番・開発ともできる限りここに一元化しています。 このため環境毎にデータが異なり再現性が担保できないといった問題は起こりにくくなっています。

また最近ではML基盤をGoogle Kubernetes Engine (GKE)上に構築し、各MLシステムを移行・集約しています。 この基盤では開発環境でも本番と同スペックのプリエンプティブルインスタンスを活用するなど、コンピューティング環境の差異もなるべく小さくなるよう工夫しています。

この話題については以下の記事でも解説しています。 analytics.livesense.co.jp

コンテナ技術によるコンポーネント化の推進

リブセンスでは現在、新しいシステムや既存システムのリプレース実装を開発する際に、MLのモデル部分をアプリケーションから独立したコンポーネントとして切り出すようにしています。 各コンポーネントは単一のコンテナとして動作するよう設計しており、データはファイルとして共有ボリュームやクラウドストレージを経由して受け渡しています。

この話題については以下の記事でも解説しています。 analytics.livesense.co.jp

各コンポーネントが独立していると、それらをまとめるワークフローエンジンが必要になります。 そこで最近ではKubernetesのCustom Resourceとして動作するワークフローエンジンであるArgo Workflowを試験的に導入しています。 Argo Workflowを用いると複数コンテナから構成されるワークフローが構築しやすく、上記のような構成の利点を活かせると考えています。 この話題については今後機会があればご紹介したいと思います。

Open source container-native workflow engine for Kubernetes | Argo

さいごに

リブセンスでは数年前に機械学習をサービスに導入し始めました。 初期の模索の段階を経て、現在ではMLシステムの信頼性を支える仕組みが徐々に整ってきています。 今後もこの中で得られた知見を共有していきたいと思います。

参考資料

分析基盤のバッチ処理構成を考える

データプラットフォームチームの橋本です。 日頃は分析基盤LivesenseAnalytics(LA)の保守・運用を担当しているエンジニアです。最近は専ら、バッチ処理のリファクタリングを懇々と進めていました。今回はその内容をまとめてみます。

分析基盤のバッチ処理

LAのバッチ処理基盤についてはこのへんを見ていただくとして、私が着手しているのはその中でもairflow-workerと呼称しているバッチ処理の集まりになります。その昔、Airflow導入以前に、cronでバッチ処理を運用していた頃から使い続けているrakeタスクの集まりで、類似の分析基盤を保守しているプロジェクトではよくある感じのバッチ処理集になっています。AirflowはDAGで設定された定時、ないしは任意のタイミング(ユーザーからのキック)で、airflow-workerのrakeタスクを起動します。この時、起動されるrakeタスクのIOは、概ね以下のようになります。

f:id:livesense-analytics:20180611112817p:plain

ところで、このairflow-worker。かつて、cronで運用されていた時代から引き継いだタスクの集まりで、チーム内では追加・修正の頻度が最も多いコードの1つなのですが、その分歴史と修正の積み重ねも厚く、多くの問題を抱えるようになっていました。具体的には、

  • 処理がrake直書きになっていて、テストが書けない
  • コンポーネントの配置やディレクトリ構造、命名に一貫性がなく、見通しが悪い
    • コードリーディングで繰り返される grep -r
    • 違う名前で機能が重複する実装
  • DRYじゃない・繰り返されるコード
    • task_env = ENV["task_env"] || "development"
    • エラーハンドリングと通知
  • dryrunできない・実装を逐一書かねばならない

などなどです。もともと素朴な定時処理から始まっているコードであるがゆえ、リリースの即時性に重きをおいて運用してきたため無理もないのですが、このまま可読性・保守性を犠牲にして拡張を続けると、

  • 新規参入エンジニアの立ち上がりコスト増加・属人化
  • 拡張コストの増加
  • バグの温床化

といったデメリットが考えられたので、思い切って大規模なリファクタリングを敢行しました。

リファクタリングの方針と改善のポイント

まず次の2点を大まかな方針としました。

  • 細かい問題点はたくさんあるが、大きな問題を優先して期間内に終わらせる
  • 理想形には近づけるが、完璧にしない

リファクタリングの作業自体は重要な仕事ですが、理想形を目指し続けると際限がないので、とにかく大きな問題点をザックリ改善し、今後の継続的なコード改善への足がかりにする方針としました。 上記のIOの図の通り、定時のバッチ処理は概ね

  • 日時やタスク名、スキーマ名などのパラメータを受けてタスクがキックされる
  • テンプレートからクエリを発行したり、日付の範囲をとったAPIに投げるパラメータを作る
  • 実際に処理を行うマネージドサービスのAPIを呼び出す、あるいはクエリを投入する

の3ステップで構成されています。そこで、バッチ処理のコンポーネントを図のように3つに分割しました。

f:id:livesense-analytics:20180611124506p:plain

改善のポイントは

  • コンポーネントの構成を整理
  • rakeタスクの処理を別のクラスへ委譲:rakeタスクとLATaskクラス
    • バッチ処理のインターフェースとロジックを分離する
    • ロジックに対してユニットテストを導入する余地を作る
  • APIアクセスをFacadeにまとめる
    • 可能な限りAPIアクセスをdryrunできるようにする
  • 分割しにくい部分や広域なスコープで共通化された処理等は、思い切ってUtilsとして切り出す

としました。以下、その内容をもう少し具体的に見ていきます。

クラスとパッケージの構成

f:id:livesense-analytics:20180530163538p:plain

クラス構成はザックリ上記の様な感じにしました。モジュールは大きく4つに分けて、以下の構成にしました。

  • API
    • API呼び出しを行うFacadeのモジュール
    • dryrunの機能をここに集約する
    • このパッケージのクラスは状態を保持しないようにする
  • LATask
    • 処理の実装を持つクラスのモジュール
    • 親クラスにエラーハンドリングやロギング、環境変数の参照などを集約する
  • rakeタスク
    • rakeタスクは各々のタスクに対応するLATaskクラスへ処理を委譲する
    • LATaskのインスタンスを呼び出し、パラメータを渡して処理を実行するだけ
  • Utils
    • バッチ処理全体で頻発する処理や、分割しきれなかった塊をとりあえず置いておくモジュール
    • 状態を保持しない、関数の集まりにしておく

以下、rakeタスクからの処理フローを、サンプルのソースコード上で追いかけてみます。

rakeタスク

require 'la_tasks/foo/foo_task'

namespace :foo do
  desc 'Foo関連のタスク'
  task :foo_task, %i(schema table column) do |task, args|
    LATask::Foo::FooTask.new(task).foo_task(
      args[:schema],
      args[:table],
      args[:column]
    )   
  end 
end

rakeタスク上では引数のチェックすらせず、LATaskクラスに処理を丸投げします。この様にrakeタスクの名前と実装を切り離しておくと、後から実装の差し替えや改名等がしやすくなります。

LATaskクラス

LATask - 親クラス

require 'path/to/messenger'

module LATask
  class Base
    def initialize(task_name)
      @messenger = Messenger.new
      @task_env = ENV['TASK_ENV'] || 'development'
      fail "Wrong TASK_ENV: #{@task_env}" unless ['production', 'development'].include? @task_env
      @task_name = "#{task_name}:#{@task_env}"
    end
        
    protected
        
    def exec
        notify("Task Start - #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}")
        yield
        notify("Task End - #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}")
      rescue => e 
        error_action(e)
        @messenger.alert("#{@task_name}: #{e}", e)
    end
        
    def error_action(e)
      # 何かあればoverrideして使う
    end 
        
    def alert(message, e)
      @messenger.alert("#{@task_name}: #{message}", e)
    end
  end
end

LATaskクラスの親クラスには、ロギングやエラーハンドリング、通知、環境変数チェック等、全タスク共通で頻発する処理をシンプルにまとめておきました。これにより、子クラスの実装がかなりスッキリしました。

LATask - 子クラス

require 'api/aws/sqs'

class LATask::Foo::FooTask < LATask::Base
  def initialize(task_name)
    super(task_name)
    @sqs = API::AWS::SQS.new
  end

   def foo_task(schema, table, column)
    exec do
      # ここに処理本体を記述
      alert("task_env is #{@task_env}")
      @sqs.send_msg(@task_name)
    end
  end

  def bar_task
    exec do
      # ここに処理本体を記述
    end
  end

  private

  def private_func
    # このタスク限定の処理など
  end
end

LATaskの子クラスに、タスクの実装を書きます。この時、execに渡すプロック内で全てのタスクの処理を記述することにより、ロギング、エラーハンドリングを親クラスの実装に一元化できます。パラメータのチェックを行う場合も、ここに記述します。また、タスクのロジックをrakeタスクにベタ書きせず、クラスに切り出した事で、各々の関数に対してテストが書けるようになりました。

APIクラス

API - 親クラス

require 'utils/utils'                                                                                                 

module API                                                                                                             
  include Utils
  def check_dry_run_then(param)                                                                                        
    if dry_run?                                                                                                        
      puts "### DRY_RUN ###"
      pp param
    else
      yield
    end
  end                                                                                                                  
end
equire 'api/api'

module API 
  module AWS 
    include API 
    require 'aws-sdk-core'
  end 
end

APIの親クラスに、dryrunの処理を実装しています。こうすることで、LATaskのロジック本体からdryrunに関連する処理を取り除けるようになり、コードが簡潔になりました。

API - 子クラス

require 'api/aws'                                                                                                     
require 'yaml'                                                                                                         
                                                                                                                       
class API::AWS::SQS                                                                                                    
  include API::AWS                                                                                                     
                                                                                                                       
  def initialize(region = AWS_REGION)                                                                                  
    @sqs = Aws::SQS::Client.new(                                                                                       
      region: region,                                                                                                  
      access_key_id: AWS_ACCESS_KEY_ID,                                                                         
      secret_access_key: AWS_SECRET_ACCESS_KEY                                                          
    )                                                                                                                  
    @conf = YAML.load_file('config/aws.yml')[task_env][:sqs]                                                    
  end                                                                                                                  
                                                                                                                       
  def send_msg(data)                                                                                                   
    check_dry_run_then("[sqs.send_msg] #{data}") do                                                                     
      @sqs.send_message(data)                                                      
    end                                                                                                                
  end                                                                                                                  

  private

  AWS_REGION = "ap-northeast-1"
end

check_dry_run_thenのブロックで、APIにアクセスしています。dryrunの際にはこのブロックの処理が実行されず、代わりにcheck_dry_run_thenの引数で与えられる文字列が表示されます。他のAPIアクセスについても同様に記述しておくことで、簡単にdryrunが実装できます。

Utilクラス

module Utils
  def dry_run?
    !ENV['DRY_RUN'].nil?
  end
end

システム横断でよく使われる処理をまとめた関数群ですが、なるべく使わないようにしています。

リファクタリングを途中までやってみて

下記の様なメリットを感じつつあります。

  • 全体が一貫性を持った構成、書き方で統一できた(可読性・保守性の向上)
  • テストを書く余地ができた
  • ディレクトリ構成でコンポーネントが一目瞭然(もうgrepしない)
  • LATaskクラスにシンプルなロジックを記述するだけでも、概ね全部のタスクにdryrunモードが実装できそう
  • エラーハンドリングとロギングが集約できた

当初、課題として挙げていた部分はまずまず解決できそうな見込みです。一方で、

  • まだイマイチな実装が細々とたくさん残ってる
  • 本当は思い切って名前を変えて、タスクの分類体系も再編したい
  • 想定通りとは言え、分解しきれずUtilsに残ってしまった塊

といった点が、課題として見えてきました。

まとめ

サンプルにある通りかなりシンプルな実装ですが、コードの見通しの良さを保ったまま課題としていた事を概ね克服しつつあるので、良かったと感じています。

  • 実装の難易よりも、課題をシンプルに解決できる事が大切
  • 課題の本質を、しっかり見定めることが同様に大切

という学びとリファクタリングの経験を得られました。新しいことも奇抜なこともないリファクタリングでしたが、よい学びと経験が得られたと感じています。

BigData-JAWS勉強会でAirflowのことを話してきました

データプラットフォームチームのよしたけです。

さて先日のBigData-JAWS 勉強会 #12にて、「リブセンスのデータ分析基盤を支えるRedshiftとAirflow」というタイトルで発表させていただきました。

Airflowについては、このブログでも Airflow を用いたデータフロー分散処理 でご紹介させていただきましたが、今回、弊社での活用事例を交えてお話させていただきました。

発表後のQAや懇親会で、Airflowの導入を検討しているが実際どういうところが大変なのか? とか運用上のつらみ、とかそういうところを気にされている方が多かった印象でした。

AirflowはPythonでデータフローを記述するため、柔軟に何でもできるという強みがあり、反面、やりすぎると改修が大変になり運用しづらくなるデメリットもあるように思います。 私たちはDAG側にはロジックを盛り込まずRake側でロジックを組み上げていくような運用をしているため、比較的変更、修正はカジュアルに回せていると思いますが、 反面、Airflowの豊富なオペレータや機能が使いこなせていないというジレンマもあったりします。 このあたりの加減は今後も改善を進めていきたいなと思っています。

競争優位性構築のための人間中心機械学習〜CVRからUXへ〜 

テクノロジカルマーケティング部データマーケティンググループにてデータサイエンティスト兼UXアーキテクトをしている新保と申します。普段は機械学習を中心としたデータ活用の推進や新規機能のユーザ体験の設計をしています。ここ1年程リブセンスではサービスの戦略レイヤーや主要機能と結合度の高い領域に対して機械学習を適用していくことに挑戦しており、今回はそれらを実際にどのように行っているかをご紹介したいと思います。

パッチ型機械学習の成功体験とその限界

 本題に入る前にリブセンスのデータ活用の歴史について少しお話しします。以前に別のメンバーが投稿した記事に詳しい説明がありますがリブセンスでは2014年にデータ活用の専門組織を立ち上げてから現在に至るまで機械学習のビジネス活用を継続的に行っています。初期の頃は既存サービスの枠組みの中でサービスとの結合度が出来る限り低く、かつ利益インパクトが大きい領域に機械学習を適用することからスタートし、比較的早い段階で成功を収めています。2015年にはレコメンドシステムやアトリビューションモデルなどが主要サービスに本格導入され、当時の全社売上50億円規模に対して安定的に年間1〜2億円程度の利益インパクトを残せるようになりました。一方で成功を収めたことでより高次の新しい課題が見え始めたのもこの時期です。課題の1つは機械学習の開発環境の整備です。こちらについては過去の記事に詳しく解説されていますので

マルチコンテナ構成による機械学習アルゴリズムとアプリケーションの疎結合化 - LIVESENSE Data Analytics Blog

などを御覧ください。

もう1つの課題はデータ活用(機械学習含む)によってCVR向上を通して利益貢献に結びけることに成功した一方で、既存サービスにパッチを当てるような機械学習の使い方ではその成果がサービスの競争優位性構築に結びつけるまでには至らなかったことです。そこで利益貢献を維持しながらデータ活用によってCVR向上よりも大きな成果、サービス自体の競争優位性構築に貢献することが2016年以降のグループのテーマになっていきます。これは現時点で100のサービスをを105にするためではなく中長期的に200、300に延ばしていくために機械学習を始めとするデータ活用を進めて行くということです。

パッチからデザインへ

この方針のもと2016年以降はアルゴリズムの洗練化
を継続しつつ、ユーザ体験の設計段階まで踏み込むプロジェクトを継続的に立ち上げています。これは機械学習がサービスと切り離された機能として提供されるのではなく、サービスの中核を担う行動デザインとインタラクションデザインのレイヤーから関わることを意味します。少し前にGoogle Design blogの記事「Humuman-centerd Machine learning と類似した取組みをリブセンスにおいても約1年前から行っており、次のようなプロセスでプロジェクトを回しています。

f:id:livesense-analytics:20180319193618p:plain

このプロセスのポイントは「既存のデータから機械学習で出来ることを考える」から「コンセプトを先に決めて必要なデータをすべて取る」という方式に変更した点です。Webサービスにおいて機械学習はあくまでユーザへの価値提供を実現するための強力な選択肢の1つであると考え、機械学習ありきではなくユーザへの価値提供を最上位におき、提供価値の具体化と行動デザインのフェーズで機械学習の活用を検討している点で人間中心な機械学習と言えるでしょう。

人間中心の機械学習の実施プロセス

 ここからはフェーズ①〜②においてリブセンスで実際に行っている具体的なプロセスをご紹介します。基本的にはISO9241-210にて定義されているHCDのプロセスをベースにしています。まだまだ試行錯誤の日々ですが現在我々のチームでは次のステップで進めています。

  1. ターゲットセグメントの決定
    まず最初にターゲットになりうるユーザを対象に幅広くデプスインタビュー行い、定性分析によるユーザセグメンテーションを行います。セグメントの軸には様々な方法がありますが我々のチームでは行動パターンの類似性で切り分けた後にメインターゲットとするセグメントを決定しています。

  2. ユーザの価値抽出
    ターゲットセグメントに属するユーザの価値感の抽出分析を行います。デプスインタビューで得た発言録からユーザの行為と意図を抽出して分析を行うことでユーザのもつ本質的欲求を分析します。手法はいくつかありますが、時間がないときは上位下位関係分析、余裕があるときはKJ法やKA法などを用いることが多いです。その後As-isのカスタマージャーニーマップを作成して現在のユーザ行動やタッチポイントごとの課題を可視化します。期間と予算の関係上分析のデータソースとしてはstep1で行ったデプスインタビューの結果を再利用することが多いです。

  3. コンセプト設計とユーザ体験の可視化
    提供するユーザ体験のコアとなるコンセプトをプロジェクトメンバー全員で出し合います。この段階では提供価値にフォーカスしたいため一旦技術的なことは忘れます。次にコンセプトアイディアを「ユーザへの提供価値の大きさ」と「機械学習を使うことによる付加価値大きさ」の二次元マップ上に配置します。ここで高い提供価値を与えるアイディアの機械学習依存度が高い場合はデータサイエンティストとや機械学習エンジニアと実現可能性について検討を行います。ただしこの時点での確認はアイディアが荒唐無稽すぎたり、AIがなんとかするといった抽象的すぎるものになってないかなどの確認であって、具体的なアルゴリズム検討には踏み込みません。また機械学習依存度の低いアイディアの提供価値が一番大きいと判断された場合や機械学習を使うことにより効果が小さい場合は機械学習を使わない選択をすることも多いです。 仮コンセプト確定後は構造化シナリオ法を用いてユーザのサイト上における行動シナリオを可視化します。

    f:id:livesense-analytics:20180320103257p:plain

  4. コンセプト評価とプロトタイピング
    ユーザインタビューによってコンセプト評価を行い、フィードバックを反映してコンセプトを修正していきます。例えば昨年度実施した転職会議の新機能のコンセプト設計では計3回のコンセプト修正と評価を行っています。定性だけで確信が持てない場合はコンセプトが固まった時点で定量アンケート調査で量的な担保を取る場合も多いです。また、プロトタイピングツールを使用してコンセプトをUIに落とし込んで実際にユーザ使ってもらった上での感想も聞いています。プロトの段階でアルゴリズムを開発するとコストが掛かりすぎるためレコメンドシステムであれば予めユーザに好みを聞いておいて人手で選んだ求人を見せたり、チャットボットであればUI上はボットに見せて裏側では人がやりとするようにして極力アルゴリズムを開発せずに擬似的な体験を提供してユーザ評価を行うようにします。   

  5. 開発プロジェクトへの展開
    サービスリリースに向けてデザインプロセスに関わったメンバーを中心に開発プロジェクトを立ち上げます。この段階で機械学習エンジニアをプロジェクトメンバーに本格的にアサインして具体的なアルゴリズムの検討を開始します。

このプロセスでは機械学習の利用を前提とせず提供価値に着目していますが、一方で最初から機械学習の活用アイディアがあるケースもよくあるかと思います。その場合は調査からスタートすると時間がかかりすぎるのでstep1〜step3をプラグマティックペルソナで代替して高速にアイディアの検証とピボットを繰り返していくことで効率的にプロジェクトを進めていく方法をとるのが良いのではないかと考えています。

以上がリブセンスで行っている機械学習を活用したおおまかな体験設計のプロセスです。直近の事例では就活会議での就活生と企業のマッチング機能をこのプロセスを採用して設計しています。アルゴリズム改善のみでマッチング精度を上げるのではなく、就活会議に訪れたユーザが適性な企業とのマッチングに至るまでに必要なユーザの体験設計を最初に行い、ユーザ体験がユーザに刺さるかどうかを事前検証後に必要な機能・アルゴリズム・データを収集・開発しています。就活会議における具体的なサービス設計の事例についてはまた別の機会にご紹介できればと思います。

技術×マーケでメールマーケティングを進めていくぞ!という気持ち

はじめに

こんにちは。テクノロジカルマーケティング部でアナリストをしております、高橋です。

さて、今回のテーマはメールマーケティングです。とその前に、我々の部署名の由来についてご紹介します。「テクノロジカルマーケティング」には、単なる技術開発にとどまることなく技術をビジネス価値に転換する、あるいは技術のチカラでお客様を幸せにするサービスを創る、そんな想いが込められております。

このような思想のもと、メールマガジンの在り方や運用を見直し、テクノロジーとマーケティングの両面から変革していこうとする取り組みが、今年の1月にスタートしました。本記事ではその概要と、何を目指していきたいかについてお話しできればと思います。これまで本ブログでも紹介してきました、LivesenseAnalytics(データウェアハウス、以降LA)やLivesenseBrain(機械学習プラットフォーム、以降LB)の実践事例となります。

背景

舞台となりますのは、弊社が運用している中途採用メディアです。求人情報を載せたメールを求職者様に配信し、開封、URLクリックを経て、応募までいけば無事コンバージョンとなります。同メディアにおけるメールマーケティングの歴史は比較的古く、以前から協調フィルタリングやコンテンツベースのレコメンドアルゴリズムも採用されていました。

ただ、こうしたアルゴリズムをもとにしたレコメンドメールは、既存のメールとは独立にラインナップに追加されてきており、既存メールの中には全配信に近いメールも存在していました。またレコメンドメール自体も、リリース後数年を経過して十分なメンテナンスが出来ておらず、当初ほどのパフォーマンスが出なくなっていました。他にも同じような形で個々にメールマーケティングのテコ入れが行われてきた結果として、メールの送りすぎや、それに起因すると思われるオプトアウトの増加が発生していたというのがプロジェクト発足時の状況です。

新しいメールマーケティングの目指す姿

そこで、この取り組みでどういう状態にしていきたいかを検討し、以下の3点を目指す姿として定義しました。

1) 適切な求人

求人数はそれほど潤沢にあるわけでもないので、モデルの精度よりもルールベースに近い形でシンプルかつ柔軟に調整が効くロジックを採用。職種や勤務地、その他の条件(社風や給与など)ごとのマッチングを実現でき、メールのテーマに合わせてそれぞれのマッチングの強弱を変えることで、希望条件に合わせつつバリエーションを確保できる状態。どのメールにも何らかのマッチングロジックを適用し、全体の求人紹介精度を高い水準に保つ。

2) 適切な頻度

アクセスログや過去のメール反応をもとに、メールでのアプローチが有効と判断されるお客様には高頻度に、そうでない方にはあまり出しすぎないようにしながら、全体の配信数をコントロール可能。主力となるメールは優先的に高頻度に配信され、一方でお客様を飽きさせないよう様々な切り口から求人を紹介できるバリエーション豊かな構成。オプトアウトが低減され配信対象者数が増えることで、じわりじわりとCV期待値が上がっていく状態。

3) 適切な手段

顧客データの細かな分析結果(特に求人案件を閲覧するときの行動を中心に)に基づき、メールの件名・本文、配信する時間帯、文体やデザインのトンマナ、求人の数や掲載項目(属性)などをお客様ごとに変えていく、いわゆるパーソナライズがなされた状態。これらについては常にA/Bテストが走り、KPIを日々モニタリングしながら改善PDCAが並列超高速で回っている状態。パフォーマンスを上げるためのノウハウが蓄積され、将来的に他のメディアでの展開を見据える。

これらを仕組みとして用意することで、冒頭に述べたような技術とマーケの両輪でメール価値の最大化を実現できると考えています。メールマーケティング自体は比較的シンプルなマネタイズ構造ですので、ここをチューニングすることで早期のビジネスインパクトが見込めると判断しました。

取り組み内容

まだ一部実装が追いついていない箇所も含みますし、今後変更になる可能性も大いにあるのですが、少しだけ中身をお見せしたいと思います。

PLAN

週に一回の会議で、新規メールのアイデア出しをしたり、既存メールの改善点やA/Bテストテーマの検討を行います。新しいメールは最低でも週に一本投入するようにしていて、良好な結果が出ればそのままラインナップ入りとなります。改善やテストはスピードと数を重視していて、思いついたら即実行のスタンスで動いています。メディアの価値・信頼を毀損せず、なおかつ尖った内容の文面を書くというのは意外と大変なのですが、本文を少し変えただけでも結果が大きく変わるのがメールの面白いところです。現在は約60種類のメールが稼働しており、この数はさらに増えていく予定です。

DO

メール配信のキモである、リスト作成(誰にどの案件を紹介するか)はLBのインフラ上で実行されます。テーマに沿って求職者や求人案件をLAから抽出し、いくつかのマッチングやレスポンス予測を経て、リストが出力されるこの一連の処理は現在のところR言語で記述しています(試験フェーズのため)。使っているのはランダムフォレストやコサイン類似度などで、機械学習の初学者にも馴染みのあるものばかりです。これらの処理は全メールで共通のものになっていて、個々のマッチングを使うかどうかと、スコアの閾値設定を各メールで自由に設定できるようになっています。また、マッチングの際に算出されたスコアなどはLAに連携され、すぐに検証することが可能です。

CHECK

週に一回の会議で、データを見ながら翌週のラインナップを編成します。顧客セグメントごとに、曜日や時間帯などある程度決められた配信枠を設けてあり、そこにどのメールを当てはめていくかを検討します。ちょうど某アイドルグループの総選挙のような形で、神セブンと呼ばれるパフォーマンスTOP7メールがゴールデンタイムでの配信を勝ち取ります。改善の結果、頻繁に順位が入れ替わるので、総選挙は毎週行っています。正直を言うと、そのたびに設定を変えるのは面倒なところもあるのですが、飽きずに楽しむ秘訣と割り切って対応しています。

ラインナップ編成のイメージ
ラインナップ編成のイメージ

おわりに ~今後の展望~

始まったばかりの取り組みで今後の展望を語るのは時期尚早かもしれませんが、いくつか構想はあります。

まずは他のメディアへの横展開。テクマのような全社横断組織で運用している意義がここにあります。次に他のチャネルへの縦展開。メール以外でもお客様にアプローチできる手段は色々あるかと思いますので、それらを併用しながら相乗効果を狙っていくのが一つの目標です。また、営業との連携。メールマーケティングの取り組み自体をクライアントに興味を持ってもらうことで、求人案件を掲載したいと思っていただける状態を作りたい。そして最後に、利用してくださるお客様の幸せな転職の機会を少しでも増やせるようになればと思いながら、これから頑張っていきます。

それでは続きはまた次回に。最後までお読みいただき、ありがとうございました。