mybook

ユースケース:EC・小売 — パーソナライゼーションエンジンの導入

「次の案件、EC だ」

カイが社内チャットに投稿したのは、月曜朝の 9 時過ぎだった。ソウタがコーヒーを手にデスクについた瞬間だ。

「ShopSphere って知ってるか?」

「もちろんです。月間アクティブユーザー 500 万人の EC プラットフォーム。ファッションから家電まで扱っている」

「そう。で、そのShopSphere の CTO から直接連絡が来た。レコメンデーションを何とかしたいと」

ソウタは ShopSphere のサイトを開いた。トップページには「今週の人気アイテム」「新着商品」が並んでいる。ログインしてもしなくても、表示される商品は同じだった。

「パーソナライゼーションがゼロ……ですか」

「ゼロだ。SKU 200 万あるのに、全員同じ商品を見せている。CVR は 1.8%。業界平均の 2.5% を大きく下回っている。ここに Arclight AI のパーソナライゼーションエンジンを入れる。おまえがリードしろ」

ソウタの背筋が伸びた。これまでのプロジェクトで培ったスキルの集大成になる案件だ。


顧客プロフィール -- ShopSphere の現状

ShopSphere は 2018 年創業の総合 EC プラットフォームだ。急成長の裏側で、技術基盤は創業時のモノリスがそのまま膨張していた。

指標
SKU 数200 万
月間アクティブユーザー500 万人
CVR(コンバージョン率)1.8%
カート放棄率72%
平均注文額(AOV)4,800 円
レコメンデーション人気順のみ
Loading diagram...

INFO

モノリスは悪ではない。ShopSphere の課題は「モノリスであること」ではなく、ユーザー行動データを活用する仕組みがまったく存在しないことだ。FDE はアーキテクチャの全面刷新ではなく、既存基盤に最小限の変更でパーソナライゼーションを載せることを目指す。

ソウタは初回ミーティングの前に ShopSphere のサービスを徹底的に使い込んだ。10 カテゴリで商品を閲覧し、3 回購入した。そこで気づいたことをメモにまとめた。

  • 閲覧履歴に基づくレコメンデーションが一切ない
  • 検索結果の並び順が「登録日順」で、関連性が低い
  • カートに入れた後の「あわせ買い」提案がない
  • メールマガジンは全ユーザーに同一内容を配信

「これは伸びしろしかないですね」とソウタはカイに報告した。

「そうだ。だからこそ慎重にやれ。EC は数字がすべてを語る世界だ。感覚ではなくデータで動け」


ディスカバリーフェーズ -- EC の「買わない理由」を掘る

初回ミーティングは ShopSphere 本社の会議室で行われた。CTO の田代さん、マーケティング部長の林さん、EC 事業部長の中川さん、データサイエンスチームのリーダー佐々木さんが出席した。

ソウタは製品デモから入るのではなく、質問から始めた。ディスカバリーの鉄則だ。

「まず、購買ファネルの各ステップでの離脱率を教えていただけますか」

田代さんが画面を共有した。

Loading diagram...

「サイト訪問者の 32% が商品を一つも閲覧せずに離脱しています」と佐々木さんが補足した。

「それは、探している商品が見つからないということですか? それとも、見るべき商品が提示されていないということですか?」

林さんが身を乗り出した。「おそらく後者です。トップページのコンテンツは週一回、私のチームが手動で選んでいます。500 万人に同じものを見せている状態です」

ソウタは頷いた。「カート追加から購入完了までの離脱 -- カート放棄率 72% について、主な原因は把握されていますか?」

「送料が高い、決済手段が少ない、という声はカスタマーサポートに寄せられています。ただ、定量的な分析はできていません」と中川さんが答えた。

「セッション中のユーザー行動データ -- 閲覧履歴、滞在時間、スクロール深度など -- は記録されていますか?」

田代さんが首を横に振った。「Google Analytics は入れていますが、ユーザー単位の行動データをアプリケーション側で記録する仕組みはありません」

ソウタはメモを取りながら、頭の中でプロジェクトの全体像を組み立てていた。

WARNING

EC のディスカバリーで最も重要な質問は「なぜ買わないのか」だ。「なぜ買うのか」は既に起きている事実だが、「なぜ買わないのか」は未実現の売上を示している。離脱率の数字を深掘りすることで、パーソナライゼーションが最もインパクトを持つポイントを特定できる。

ミーティング後、ソウタはカイに電話した。

「カイさん、ShopSphere のデータ基盤はほぼゼロです。ユーザー行動の記録から始めないとレコメンデーションが作れません」

「それは想定内だ。で、どうする?」

「二段構えで行きます。Phase 1 でユーザー行動トラッキングを仕込みつつ、既存の購買データだけで動くシンプルなレコメンデーションを PoC として見せます。行動データが溜まった Phase 2 で本格的なパーソナライゼーションに切り替えます」

「いいな。既存データで小さな勝利を見せてから、本丸に進む。FDE の定石だ」


PoC フェーズ -- レコメンデーションエンジンの構築

ユーザー行動トラッキング

まず、ユーザーの行動を記録する仕組みを Rails アプリに組み込んだ。

class UserBehaviorTracker
  BEHAVIOR_TYPES = %w[view cart_add cart_remove purchase search wishlist].freeze
 
  def initialize(user:, session_id:)
    @user = user
    @session_id = session_id
    @redis = Redis.current
  end
 
  def track(event_type, product_id:, metadata: {})
    raise ArgumentError, "Unknown event: #{event_type}" unless BEHAVIOR_TYPES.include?(event_type)
 
    event = {
      user_id: @user&.id,
      session_id: @session_id,
      event_type: event_type,
      product_id: product_id,
      metadata: metadata,
      occurred_at: Time.current.iso8601
    }
 
    # リアルタイム処理用に Redis に書き込み(TTL 24時間)
    redis_key = "behavior:#{@session_id}:#{Time.current.to_i}"
    @redis.setex(redis_key, 86_400, event.to_json)
 
    # 分析用に PostgreSQL にも非同期で保存
    UserBehaviorEvent.insert(event.except(:metadata).merge(
      metadata: metadata.to_json
    ))
  end
 
  def recent_views(limit: 20)
    UserBehaviorEvent
      .where(user_id: @user.id, event_type: "view")
      .order(occurred_at: :desc)
      .limit(limit)
      .pluck(:product_id)
  end
 
  def viewed_categories
    Product
      .where(id: recent_views(limit: 50))
      .distinct
      .pluck(:category_id)
  end
end

A/B テストフレームワーク

レコメンデーションの効果を正確に計測するため、A/B テストの基盤を整えた。

class AbTest < ApplicationRecord
  has_many :ab_test_assignments, dependent: :delete_all
  has_many :ab_test_conversions, dependent: :delete_all
 
  enum :status, { draft: 0, running: 1, completed: 2, cancelled: 3 }
 
  validates :name, presence: true, uniqueness: true
  validates :traffic_percentage, inclusion: { in: 1..100 }
 
  def assign_variant(user)
    # 一貫性のあるハッシュで同一ユーザーには常に同じバリアントを返す
    existing = ab_test_assignments.find_by(user_id: user.id)
    return existing.variant if existing
 
    hash_value = Digest::SHA256.hexdigest("#{id}:#{user.id}").to_i(16)
    variant = (hash_value % 100) < traffic_percentage ? "variant_b" : "variant_a"
 
    ab_test_assignments.create!(user_id: user.id, variant: variant)
    variant
  end
 
  def conversion_rate(variant)
    assigned = ab_test_assignments.where(variant: variant).count
    return 0.0 if assigned.zero?
 
    converted = ab_test_conversions
      .joins(:ab_test_assignment)
      .where(ab_test_assignments: { variant: variant })
      .select(:ab_test_assignment_id)
      .distinct
      .count
 
    (converted.to_f / assigned * 100).round(2)
  end
 
  def statistical_significance?
    rate_a = conversion_rate("variant_a")
    rate_b = conversion_rate("variant_b")
    n_a = ab_test_assignments.where(variant: "variant_a").count
    n_b = ab_test_assignments.where(variant: "variant_b").count
 
    return false if [n_a, n_b].min < 100
 
    # カイ二乗検定で有意差を判定(p < 0.05)
    conv_a = (rate_a / 100.0 * n_a).round
    conv_b = (rate_b / 100.0 * n_b).round
 
    observed = [[conv_a, n_a - conv_a], [conv_b, n_b - conv_b]]
    total = n_a + n_b
    total_conv = conv_a + conv_b
 
    expected_rate = total_conv.to_f / total
    chi_squared = observed.each_with_index.sum do |row, i|
      n = i.zero? ? n_a : n_b
      expected_conv = (expected_rate * n).round(2)
      expected_non = n - expected_conv
 
      ((row[0] - expected_conv)**2 / expected_conv) +
        ((row[1] - expected_non)**2 / expected_non)
    end
 
    chi_squared > 3.841 # 自由度1、p < 0.05 の臨界値
  end
end

ハイブリッドレコメンデーションエンジン

ソウタは複数の戦略を組み合わせたハイブリッドエンジンを設計した。

class RecommendationEngine
  def initialize(user)
    @user = user
    @strategies = [
      CollaborativeFilteringStrategy.new(weight: 0.4),
      ContentBasedStrategy.new(weight: 0.35),
      TrendingStrategy.new(weight: 0.15),
      PersonalTrendStrategy.new(weight: 0.1)
    ]
  end
 
  def recommend(limit: 20, context: :home)
    scores = Hash.new(0.0)
 
    @strategies.each do |strategy|
      candidates = strategy.candidates(@user, context: context)
      candidates.each do |product_id, score|
        scores[product_id] += score * strategy.weight
      end
    end
 
    # 既に購入済みの商品を除外
    purchased_ids = @user.orders
      .where("created_at > ?", 90.days.ago)
      .joins(:order_items)
      .pluck("order_items.product_id")
 
    scores.except(*purchased_ids)
      .sort_by { |_, score| -score }
      .first(limit)
      .map(&:first)
  end
end
 
class CollaborativeFilteringStrategy
  attr_reader :weight
 
  def initialize(weight:)
    @weight = weight
  end
 
  def candidates(user, context: :home)
    # 類似ユーザーの購買履歴からレコメンド
    similar_users = find_similar_users(user, limit: 50)
 
    OrderItem
      .where(order: Order.where(user_id: similar_users))
      .group(:product_id)
      .count
      .transform_values { |count| count.to_f / similar_users.size }
  end
 
  private
 
  def find_similar_users(user, limit:)
    # ユーザーの購買カテゴリベクトルのコサイン類似度で上位を取得
    user_categories = user.orders
      .joins(order_items: :product)
      .group("products.category_id")
      .count
 
    return [] if user_categories.empty?
 
    User.find_by_sql([<<~SQL, user.id, limit])
      WITH user_vector AS (
        SELECT products.category_id, COUNT(*) as cnt
        FROM orders
        JOIN order_items ON order_items.order_id = orders.id
        JOIN products ON products.id = order_items.product_id
        WHERE orders.user_id = ?
        GROUP BY products.category_id
      )
      SELECT u.id
      FROM users u
      JOIN orders o ON o.user_id = u.id
      JOIN order_items oi ON oi.order_id = o.id
      JOIN products p ON p.id = oi.product_id
      GROUP BY u.id
      ORDER BY COUNT(DISTINCT p.category_id) DESC
      LIMIT ?
    SQL
      .map(&:id)
  end
end
 
class ContentBasedStrategy
  attr_reader :weight
 
  def initialize(weight:)
    @weight = weight
  end
 
  def candidates(user, context: :home)
    tracker = UserBehaviorTracker.new(user: user, session_id: nil)
    viewed_categories = tracker.viewed_categories
 
    return {} if viewed_categories.empty?
 
    Product
      .where(category_id: viewed_categories)
      .where("created_at > ?", 30.days.ago)
      .order(rating: :desc)
      .limit(100)
      .pluck(:id)
      .each_with_index
      .to_h { |id, i| [id, 1.0 - (i * 0.01)] }
  end
end

INFO

PoC 段階のレコメンデーションは「完璧」を目指さない。協調フィルタリングはシンプルなカテゴリベースから始め、行動データが蓄積されたらベクトル埋め込みに進化させる。最初から高度なモデルを作り込むと、検証に時間がかかりすぎて PoC の意味がなくなる。

AWS アーキテクチャ -- PoC 構成

Loading diagram...

ソウタは Amazon Personalize を活用しつつ、Rails アプリ内のロジックで補完するアーキテクチャを選択した。Personalize で協調フィルタリングの重い計算を行い、コンテンツベースのフィルタリングとビジネスルール(在庫切れ除外、マージン率による重み付け)は Rails 側で処理する。


段階的ロールアウト -- 3 フェーズで攻める

PoC の結果を受けて、本番環境への段階的ロールアウトを計画した。

Loading diagram...

フィーチャーフラグの実装

段階的にロールアウトするために、フィーチャーフラグの仕組みを Rails に組み込んだ。

class FeatureFlag
  CACHE_TTL = 5.minutes
 
  def self.enabled?(flag_name, user:)
    config = Rails.cache.fetch("feature_flag:#{flag_name}", expires_in: CACHE_TTL) do
      FeatureFlagConfig.find_by(name: flag_name)&.attributes || {}
    end
 
    return false if config.empty?
    return false unless config["enabled"]
 
    # ロールアウト率に基づく判定(一貫性のあるハッシュ)
    percentage = config["rollout_percentage"] || 0
    hash = Digest::MD5.hexdigest("#{flag_name}:#{user.id}").to_i(16) % 100
    hash < percentage
  end
end

Phase 1: ホームページのレコメンデーション(2 週間)

最初のフェーズでは、ログインユーザーのホームページに「あなたへのおすすめ」セクションを表示した。

class HomeController < ApplicationController
  def index
    @trending_products = Product.trending(limit: 12)
 
    if current_user && FeatureFlag.enabled?("home_recommendation", user: current_user)
      engine = RecommendationEngine.new(current_user)
      @recommended_products = Product.where(id: engine.recommend(limit: 12))
      @recommendation_variant = "personalized"
    else
      @recommended_products = @trending_products
      @recommendation_variant = "popular"
    end
 
    track_impression(@recommendation_variant)
  end
 
  private
 
  def track_impression(variant)
    UserBehaviorTracker.new(
      user: current_user,
      session_id: session.id
    ).track("view", product_id: nil, metadata: {
      page: "home",
      variant: variant
    })
  end
end

Phase 1 の結果は 1 週間で見え始めた。

  • ロールアウト率: 10% → 30% → 100%
  • パーソナライズ群の CVR: 2.1%(対照群 1.8%、+16.7%)
  • クリック率: パーソナライズ群が 2.3 倍

田代さんから電話が来た。「ソウタさん、数字が動いています。Phase 2 を急ぎましょう」

Phase 2: 商品詳細ページ「この商品を見た人は」(4 週間)

class ProductRecommendationService
  def related_products(product, user:, limit: 8)
    candidates = []
 
    # 同じ商品を見た人が買った商品(協調フィルタリング)
    co_purchased = OrderItem
      .where(product_id: co_viewed_product_ids(product))
      .where.not(product_id: product.id)
      .group(:product_id)
      .order("COUNT(*) DESC")
      .limit(limit * 2)
      .pluck(:product_id)
 
    candidates.concat(co_purchased)
 
    # 同じカテゴリの高評価商品(コンテンツベース)
    same_category = Product
      .where(category_id: product.category_id)
      .where.not(id: product.id)
      .order(rating: :desc)
      .limit(limit)
      .pluck(:id)
 
    candidates.concat(same_category)
 
    # ユーザーの嗜好で重み付け
    if user
      scored = score_by_user_preference(candidates.uniq, user)
      scored.first(limit)
    else
      candidates.uniq.first(limit)
    end
  end
 
  private
 
  def co_viewed_product_ids(product)
    UserBehaviorEvent
      .where(product_id: product.id, event_type: "view")
      .where("occurred_at > ?", 30.days.ago)
      .distinct
      .pluck(:session_id)
      .then do |session_ids|
        UserBehaviorEvent
          .where(session_id: session_ids, event_type: "view")
          .where.not(product_id: product.id)
          .group(:product_id)
          .order("COUNT(*) DESC")
          .limit(50)
          .pluck(:product_id)
      end
  end
 
  def score_by_user_preference(product_ids, user)
    tracker = UserBehaviorTracker.new(user: user, session_id: nil)
    preferred_categories = tracker.viewed_categories
 
    Product.where(id: product_ids).sort_by do |product|
      preferred_categories.include?(product.category_id) ? 0 : 1
    end.map(&:id)
  end
end

Phase 3: カート内クロスセル + パーソナライズドメール(6 週間)

Phase 3 では、カートページでのクロスセル提案と、パーソナライズドメールの配信を開始した。

class CartCrossSellService
  def suggestions(cart, user:, limit: 4)
    cart_product_ids = cart.cart_items.pluck(:product_id)
    cart_categories = Product.where(id: cart_product_ids).pluck(:category_id)
 
    # カート内商品とよく一緒に購入される商品
    frequently_bought = Order
      .joins(:order_items)
      .where(order_items: { product_id: cart_product_ids })
      .joins("JOIN order_items oi2 ON oi2.order_id = orders.id")
      .where.not("oi2.product_id" => cart_product_ids)
      .group("oi2.product_id")
      .order("COUNT(*) DESC")
      .limit(limit * 2)
      .pluck("oi2.product_id")
 
    # 補完カテゴリの商品を優先(例:シャツ → ネクタイ)
    complementary = ComplementaryCategory
      .where(source_category_id: cart_categories)
      .pluck(:target_category_id)
 
    scored = frequently_bought.sort_by do |pid|
      product = Product.find(pid)
      complementary.include?(product.category_id) ? 0 : 1
    end
 
    scored.first(limit)
  end
end

WARNING

カート内クロスセルは「押し売り」にならないよう注意が必要だ。表示数は最大 4 つに制限し、カート合計額の 30% 以下の商品のみを提案する。ユーザーに「余計なものを売りつけられている」と感じさせたら逆効果だ。


成果測定 -- 数字で語る

12 週間のロールアウトが完了し、ソウタは全体の成果をまとめた。

指標BeforeAfter改善率
CVR1.8%3.1%+72%
AOV(平均注文額)4,800 円5,664 円+18%
カート放棄率72%58%-14pp
ホームページ直帰率32%19%-13pp
セッション平均閲覧数3.26.8+112%
class RecommendationMetrics
  def self.weekly_report
    period = 1.week.ago..Time.current
 
    orders = Order.where(created_at: period)
    sessions = UserBehaviorEvent
      .where(occurred_at: period)
      .select(:session_id)
      .distinct
      .count
 
    cvr = orders.count.to_f / sessions * 100
    aov = orders.average(:total_amount)&.round || 0
 
    cart_created = UserBehaviorEvent
      .where(event_type: "cart_add", occurred_at: period)
      .select(:session_id).distinct.count
    cart_purchased = orders.count
    cart_abandonment = ((1 - cart_purchased.to_f / cart_created) * 100).round(1)
 
    # レコメンデーション経由の売上
    rec_revenue = OrderItem
      .joins(:order)
      .where(orders: { created_at: period })
      .where(source: "recommendation")
      .sum("order_items.price * order_items.quantity")
 
    {
      period: period,
      total_sessions: sessions,
      total_orders: orders.count,
      cvr: cvr.round(2),
      aov: aov,
      cart_abandonment_rate: cart_abandonment,
      recommendation_revenue: rec_revenue,
      recommendation_contribution: (rec_revenue.to_f / orders.sum(:total_amount) * 100).round(1)
    }
  end
end

CxO への成果報告

ソウタは ShopSphere の取締役会で報告することになった。カイがプレゼンの構成をアドバイスした。

「CxO には技術の話をするな。ビジネスインパクトだけを語れ」

「でも、レコメンデーションエンジンの仕組みを説明しないと――」

「しなくていい。CxO が知りたいのは 3 つだけだ。いくら儲かったか。いくらかかったか。次は何をするか」

ソウタは報告の骨子をまとめた。

年間追加売上試算:

  • CVR 改善による追加売上: 月間 500 万セッション × CVR +1.3pp × AOV 5,664 円 = 月間約 3.7 億円
  • AOV 向上による追加売上: 既存注文の AOV +864 円 × 月間注文数 = 月間約 0.8 億円
  • 合計: 年間約 54 億円の売上増加ポテンシャル

INFO

FDE が CxO に報告する際は「ROI ストーリー」を組み立てる。投資額(Arclight AI のライセンス + 実装コスト)に対して何倍のリターンがあるかを一枚のスライドで示す。技術的な詳細は Appendix に回し、本編では数字とビジネスインパクトに絞る。

田代さんが報告後にソウタに声をかけた。

「取締役会で『次年度の IT 投資の最優先項目にする』と決まりました。ソウタさん、次のフェーズもよろしくお願いします」

カイにその報告をすると、カイは静かに笑った。

「おまえ、EC の案件で一番大事なことを学んだな」

「数字で語る、ですか?」

「それもある。でも一番大事なのは、小さく始めて、数字で証明して、大きく広げることだ。最初から全部やろうとしたら、今頃まだ PoC の企画書を書いていたはずだ」

WARNING

年間売上試算は「ポテンシャル」であって「確定値」ではない。季節変動、競合の動向、市場環境の変化を考慮し、保守的なケース(60%)、標準ケース(80%)、楽観ケース(100%)の 3 シナリオで提示するのが誠実な報告だ。


演習問題

演習 1: A/B テストの成功基準設計

あなたは EC サイトにレコメンデーションエンジンを導入する FDE だ。A/B テストの成功基準を 3 つ設計せよ。以下の条件を満たすこと:

  • 定量的に測定可能であること
  • 統計的有意差の判定基準を含むこと
  • プライマリ指標とガードレール指標を区別すること

ヒント: CVR の改善だけを追うと、「安い商品ばかりレコメンドして CVR は上がったが AOV が下がった」という罠にはまることがある。

演習 2: フォールバック戦略

レコメンデーションエンジンの精度が劣化した場合のフォールバック戦略を設計せよ。以下の観点を含めること:

  • 精度劣化をどう検知するか(監視指標とアラート閾値)
  • フォールバック先のレコメンデーションロジック
  • ユーザー体験を損なわずに切り替える方法
  • 本番復帰の判断基準

演習 3: CxO 報告プレゼン

CVR 1.8% → 3.1% の改善を CxO に報告するプレゼンテーションの骨子を作成せよ。以下の構成で 5 スライド以内にまとめること:

  1. エグゼクティブサマリー(1 スライド)
  2. 投資対効果(1 スライド)
  3. 主要な学び(1 スライド)
  4. 次のステップ(1 スライド)
  5. リスクと対策(1 スライド)