ユースケース: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 円 |
| レコメンデーション | 人気順のみ |
INFO
モノリスは悪ではない。ShopSphere の課題は「モノリスであること」ではなく、ユーザー行動データを活用する仕組みがまったく存在しないことだ。FDE はアーキテクチャの全面刷新ではなく、既存基盤に最小限の変更でパーソナライゼーションを載せることを目指す。
ソウタは初回ミーティングの前に ShopSphere のサービスを徹底的に使い込んだ。10 カテゴリで商品を閲覧し、3 回購入した。そこで気づいたことをメモにまとめた。
- 閲覧履歴に基づくレコメンデーションが一切ない
- 検索結果の並び順が「登録日順」で、関連性が低い
- カートに入れた後の「あわせ買い」提案がない
- メールマガジンは全ユーザーに同一内容を配信
「これは伸びしろしかないですね」とソウタはカイに報告した。
「そうだ。だからこそ慎重にやれ。EC は数字がすべてを語る世界だ。感覚ではなくデータで動け」
ディスカバリーフェーズ -- EC の「買わない理由」を掘る
初回ミーティングは ShopSphere 本社の会議室で行われた。CTO の田代さん、マーケティング部長の林さん、EC 事業部長の中川さん、データサイエンスチームのリーダー佐々木さんが出席した。
ソウタは製品デモから入るのではなく、質問から始めた。ディスカバリーの鉄則だ。
「まず、購買ファネルの各ステップでの離脱率を教えていただけますか」
田代さんが画面を共有した。
「サイト訪問者の 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
endA/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
endINFO
PoC 段階のレコメンデーションは「完璧」を目指さない。協調フィルタリングはシンプルなカテゴリベースから始め、行動データが蓄積されたらベクトル埋め込みに進化させる。最初から高度なモデルを作り込むと、検証に時間がかかりすぎて PoC の意味がなくなる。
AWS アーキテクチャ -- PoC 構成
ソウタは Amazon Personalize を活用しつつ、Rails アプリ内のロジックで補完するアーキテクチャを選択した。Personalize で協調フィルタリングの重い計算を行い、コンテンツベースのフィルタリングとビジネスルール(在庫切れ除外、マージン率による重み付け)は Rails 側で処理する。
段階的ロールアウト -- 3 フェーズで攻める
PoC の結果を受けて、本番環境への段階的ロールアウトを計画した。
フィーチャーフラグの実装
段階的にロールアウトするために、フィーチャーフラグの仕組みを 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
endPhase 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
endPhase 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
endPhase 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
endWARNING
カート内クロスセルは「押し売り」にならないよう注意が必要だ。表示数は最大 4 つに制限し、カート合計額の 30% 以下の商品のみを提案する。ユーザーに「余計なものを売りつけられている」と感じさせたら逆効果だ。
成果測定 -- 数字で語る
12 週間のロールアウトが完了し、ソウタは全体の成果をまとめた。
| 指標 | Before | After | 改善率 |
|---|---|---|---|
| CVR | 1.8% | 3.1% | +72% |
| AOV(平均注文額) | 4,800 円 | 5,664 円 | +18% |
| カート放棄率 | 72% | 58% | -14pp |
| ホームページ直帰率 | 32% | 19% | -13pp |
| セッション平均閲覧数 | 3.2 | 6.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
endCxO への成果報告
ソウタは 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 スライド)
- 主要な学び(1 スライド)
- 次のステップ(1 スライド)
- リスクと対策(1 スライド)