Amazonの「よく一緒に購入されている商品」をSQLで再現する
Amazonの商品ページには必ず「よく一緒に購入されている商品」という欄がある。あれを見て「うちでもやりたいけど、どうやって計算するんだろう」と思ったことはないだろうか。
あの仕組みの根幹にあるのがアソシエーション分析だ。「Aを買った人はBも買う傾向がある」という共起関係をデータから掘り出す手法で、機械学習のライブラリを使わなくても、SQLだけで実装できる。
この記事では、同じ注文の中に一緒に入っていた商品の組み合わせを集計する方法を段階的に解説する。クロスセル施策の対象商品選定、バンドル企画の根拠出し、レコメンドエンジンへのデータ供給など、通販現場で即使える内容だ。
アソシエーション分析の3つの指標
SQLを書く前に、アソシエーション分析の基本的な指標を理解しておこう。知らずに実装すると「なんとなくよく出てくる商品の組み合わせ」を出すだけになってしまい、精度が低い。
支持度(Support)
Support(A→B) = AとBが同じ注文に入っている注文数 ÷ 全注文数その組み合わせが全体の中でどれくらいの頻度で出てくるかを示す。支持度が低すぎると、偶然の一致に過ぎない可能性がある。
信頼度(Confidence)
Confidence(A→B) = AとBが同時に出る注文数 ÷ Aが出る注文数「Aを買った注文のうち、Bも一緒に入っていた割合」だ。信頼度が高いほど「AがあればBもある」という関係が強い。
リフト値(Lift)
Lift(A→B) = Confidence(A→B) ÷ Support(B)リフト値は「AがあることでBが買われる確率が、Bの通常購買確率の何倍になるか」を示す。リフト値 > 1 なら正の相関(一緒に買われやすい)、= 1 なら無関係、< 1 なら負の相関(一緒に買われにくい)だ。
支持度と信頼度だけでは「そもそも人気商品は何と組み合わせても数値が高く出る」という問題がある。リフト値を使うことで「本当に偶然以上の相関があるか」を判断できる。
使用するテーブル
今回は注文明細テーブル order_items を使う。1行が「1注文の中の1商品」を表す。
-- order_items テーブル
-- order_id : 注文ID
-- product_id : 商品ID
-- product_name : 商品名
-- quantity : 注文数量
-- unit_price : 単価| order_id | product_id | product_name | quantity | unit_price |
|---|---|---|---|---|
| 1001 | P001 | シャンプー | 1 | 1800 |
| 1001 | P003 | コンディショナー | 1 | 1600 |
| 1001 | P008 | ヘアオイル | 1 | 2400 |
| 1002 | P001 | シャンプー | 2 | 1800 |
| 1002 | P005 | トリートメント | 1 | 2200 |
| 1003 | P003 | コンディショナー | 1 | 1600 |
| 1003 | P012 | 洗顔フォーム | 1 | 900 |
| … | … | … | … | … |
1001番の注文にはシャンプー・コンディショナー・ヘアオイルの3商品が入っている。このような「同一注文内に複数商品がある」データが分析の素材だ。
STEP 1 ― 商品ペアを作る(自己JOIN)
「同じ注文の中にある商品の組み合わせ」を出すには、order_items テーブルを自分自身にJOINする。これが自己JOINだ。
同じ注文の中で商品A・商品Bをすべての組み合わせで並べると、「A×B」と「B×A」の両方が出てくる。また「A×A」という同じ商品同士のペアも出てくる。これらを除くために a.product_id < b.product_id という条件を加える。
-- STEP1: 同一注文内の商品ペアを生成
WITH item_pairs AS (
SELECT
a.order_id,
a.product_id AS product_id_a,
a.product_name AS product_name_a,
b.product_id AS product_id_b,
b.product_name AS product_name_b
FROM order_items a
JOIN order_items b
ON a.order_id = b.order_id -- 同じ注文
AND a.product_id < b.product_id -- A < B にすることで重複・同一商品ペアを排除
)
SELECT *
FROM item_pairs
ORDER BY order_id, product_id_a, product_id_b;出力イメージ
| order_id | product_id_a | product_name_a | product_id_b | product_name_b |
|---|---|---|---|---|
| 1001 | P001 | シャンプー | P003 | コンディショナー |
| 1001 | P001 | シャンプー | P008 | ヘアオイル |
| 1001 | P003 | コンディショナー | P008 | ヘアオイル |
| 1002 | P001 | シャンプー | P005 | トリートメント |
| … | … | … | … | … |
<でなく<>ではダメな理由a.product_id <> b.product_id(等しくない)にすると、「A×B」と「B×A」の両方が出てきてしまい、集計するとペアの出現回数が2倍になる。<(より小さい)にすることでIDの辞書順で常に小さいほうをAにする一方向のペアだけが生成される。
STEP 2 ― ペアの出現回数を集計する(支持度の計算)
ペアが揃ったので、「このペアは全体で何回の注文に同時に入っていたか」を集計する。
-- STEP2: ペアの共起回数と支持度の計算
WITH item_pairs AS (
SELECT
a.order_id,
a.product_id AS product_id_a,
a.product_name AS product_name_a,
b.product_id AS product_id_b,
b.product_name AS product_name_b
FROM order_items a
JOIN order_items b
ON a.order_id = b.order_id
AND a.product_id < b.product_id
),
total_orders AS (
-- 全注文数(支持度の分母)
SELECT COUNT(DISTINCT order_id) AS total_order_count
FROM order_items
),
pair_stats AS (
SELECT
product_id_a,
product_name_a,
product_id_b,
product_name_b,
COUNT(DISTINCT order_id) AS co_occurrence_count
FROM item_pairs
GROUP BY
product_id_a,
product_name_a,
product_id_b,
product_name_b
)
SELECT
ps.product_id_a,
ps.product_name_a,
ps.product_id_b,
ps.product_name_b,
ps.co_occurrence_count,
-- 支持度(%表示)
ROUND(ps.co_occurrence_count * 100.0 / t.total_order_count, 2) AS support_pct
FROM pair_stats ps
CROSS JOIN total_orders t
ORDER BY co_occurrence_count DESC;
出力イメージ
| product_name_a | product_name_b | co_occurrence_count | support_pct |
|---|---|---|---|
| シャンプー | コンディショナー | 412 | 8.24 |
| シャンプー | トリートメント | 287 | 5.74 |
| 洗顔フォーム | 化粧水 | 241 | 4.82 |
| コンディショナー | トリートメント | 198 | 3.96 |
| … | … | … | … |
シャンプーとコンディショナーが全注文の8.24%で一緒に買われている。これが「よく一緒に買われている」の根拠になる。
STEP 3 ― 信頼度とリフト値を計算する(完成版)
支持度だけでは「人気商品同士はなんでも高く出る」問題が残る。信頼度とリフト値を加えて、本当に相関のあるペアを炙り出す。
-- STEP3: 信頼度・リフト値まで含めた完成クエリ
WITH item_pairs AS (
SELECT
a.order_id,
a.product_id AS product_id_a,
a.product_name AS product_name_a,
b.product_id AS product_id_b,
b.product_name AS product_name_b
FROM order_items a
JOIN order_items b
ON a.order_id = b.order_id
AND a.product_id < b.product_id
),
total_orders AS (
SELECT COUNT(DISTINCT order_id) AS total_order_count
FROM order_items
),
-- 各商品が単独で登場する注文数(信頼度・リフトの計算に使う)
product_order_count AS (
SELECT
product_id,
COUNT(DISTINCT order_id) AS order_count
FROM order_items
GROUP BY product_id
),
pair_stats AS (
SELECT
product_id_a,
product_name_a,
product_id_b,
product_name_b,
COUNT(DISTINCT order_id) AS co_occurrence_count
FROM item_pairs
GROUP BY
product_id_a, product_name_a,
product_id_b, product_name_b
)
SELECT
ps.product_name_a,
ps.product_name_b,
ps.co_occurrence_count,
-- 支持度
ROUND(ps.co_occurrence_count * 100.0 / t.total_order_count, 2)
AS support_pct,
-- 信頼度(A→B): AとBが同時に出る注文数 ÷ Aが出る注文数
ROUND(ps.co_occurrence_count * 100.0 / pa.order_count, 1)
AS confidence_a_to_b_pct,
-- 信頼度(B→A): AとBが同時に出る注文数 ÷ Bが出る注文数
ROUND(ps.co_occurrence_count * 100.0 / pb.order_count, 1)
AS confidence_b_to_a_pct,
-- リフト値: 信頼度(A→B) ÷ BのSupport
-- 1より大きければ「AがあることでBが通常より多く売れる」
ROUND(
(ps.co_occurrence_count * 1.0 / pa.order_count)
/ (pb.order_count * 1.0 / t.total_order_count)
, 2) AS lift
FROM pair_stats ps
CROSS JOIN total_orders t
JOIN product_order_count pa ON ps.product_id_a = pa.product_id
JOIN product_order_count pb ON ps.product_id_b = pb.product_id
-- 最低限の出現回数フィルタ(ノイズを除去)
WHERE ps.co_occurrence_count >= 10
ORDER BY lift DESC, co_occurrence_count DESC;出力イメージ
| product_name_a | product_name_b | co_occurrence | support_pct | conf_A→B | conf_B→A | lift |
|---|---|---|---|---|---|---|
| ヘアオイル | ヘアミルク | 89 | 1.78 | 62.2 | 54.9 | 4.81 |
| 洗顔フォーム | 化粧水 | 241 | 4.82 | 48.3 | 39.1 | 3.24 |
| シャンプー | コンディショナー | 412 | 8.24 | 41.5 | 38.7 | 2.93 |
| トリートメント | ヘアパック | 112 | 2.24 | 38.4 | 45.2 | 2.71 |
| … | … | … | … | … | … | … |
リフト値でソートすると、単純な共起回数ランキングとは違う商品ペアが上位に来ることがある。「ヘアオイルとヘアミルク」はそれほど多くの注文に出てくるわけではないが、ヘアオイルを買った人の62%がヘアミルクも買っており、リフト値4.81と突出して高い。これはクロスセルの有力候補だ。
結果の解釈と施策への落とし込み
信頼度の「方向性」に注目する
confidence_a_to_b(AがあればBも)と confidence_b_to_a(BがあればAも)が非対称なケースは施策上重要だ。
たとえば洗顔フォーム→化粧水の信頼度が48%、化粧水→洗顔フォームが39%なら、「洗顔フォームを買うカートに化粧水をレコメンドする」ほうが効果が高い。ページ上のレコメンド表示はこの方向性を意識して設計する。
リフト値の目安
| リフト値 | 解釈 | 施策判断 |
|---|---|---|
| 3.0以上 | 強い正の相関 | 積極的にバンドル・レコメンド |
| 1.5〜3.0 | 中程度の相関 | カート内レコメンドで様子を見る |
| 1.0〜1.5 | 弱い相関 | 参考程度 |
| 1.0以下 | 相関なし〜負の相関 | 施策対象にしない |
最低出現回数フィルタを必ず入れる
WHERE co_occurrence_count >= 10 のフィルタは必須だ。出現回数が少ないペアは偶然の一致である可能性が高く、リフト値が異常に高く出やすい。自社のデータ規模に合わせて閾値を調整してほしい。注文数が少ない場合は5件以上、大規模ECなら50件以上を目安にするとよい。
実務での運用ヒント
① カテゴリ内に限定する
全商品の組み合わせを出すと行数が膨大になる。「ヘアケアカテゴリ内だけ」「スキンケアカテゴリ内だけ」というようにWHERE句でカテゴリを絞ると、より精度の高い分析ができる。
-- カテゴリ限定の例
FROM order_items a
JOIN order_items b
ON a.order_id = b.order_id
AND a.product_id < b.product_id
AND a.category = 'haircare' -- カテゴリを絞る
AND b.category = 'haircare'② 「異カテゴリ間」の組み合わせも面白い
「ヘアケアを買う人がスキンケアも買う」という跨ぎ買いの傾向が分かると、メルマガのレコメンドや同梱チラシの設計に使える。カテゴリを限定しない全商品ペアから、カテゴリが異なるペアだけを抽出するフィルタをかけると見えてくる。
③ Treasure Dataでの実行注意
商品数が多い場合、自己JOINで生成されるペアの行数は商品数の二乗に比例して膨大になる(商品1,000種なら最大50万ペア)。Treasure DataのPrestoクエリは並列実行されるため問題になりにくいが、初回実行時はSTEP1の出力行数を LIMIT 1000 で確認してからフル実行するのを推奨する。
まとめ
今回のクエリの骨格はシンプルだ。
order_itemsを自己JOIN(a.product_id < b.product_id)して同一注文内のペアを生成するCOUNT(DISTINCT order_id)でペアの共起回数を集計し、全注文数で割って支持度を計算する- 各商品の単独注文数を使って信頼度(A→BとB→Aの両方向)を計算する
- 信頼度を商品Bの単独支持度で割ってリフト値を出し、本当に相関のあるペアを選別する
リフト値が高いペアが施策対象の有力候補だ。クロスセルのメルマガ、カート内レコメンド、バンドル商品の企画、同梱チラシの設計など、このクエリ1本がさまざまな施策の起点になる。