推薦の本番計測
QuickMatch / FOR_YOU / SIMILAR の本番推薦結果を「勘ではなくデータで」判断するための観測 surface と、BigQuery で叩く saved query 集。spec §8 の評価指標を実運用に落とし込んだもの。
観測 surface: admin /recommendation/metrics
データを BQ に流すだけでは結局誰も見ないため、admin の /recommendation/preview の隣に /recommendation/metrics を新設して常時開ける動線を作っている。
admin/
├── /recommendation/preview ← 「特定 user に何が出るか」確認用
└── /recommendation/metrics ← 「全体どうなってるか」観測用backend resolver は adminRecommendationMetrics(input: AdminRecommendationMetricsInput!) で、reqUser.IsAdmin でないと Forbidden。BigQuery + DB ミラー (uzu-prd.app_public.*) を errgroup で並列に叩いて各指標の集計を 1 リクエストで返す。
URL クエリ sync (since / until / surfaces / modelVersions) を持っているので、共有・再現が効く (UZU-4282 と同じパターン)。
パネル構成
成果指標を 作成参加率 1 本に統一し、surface ごとの重要指標を次に絞っている:
- for_you / similar: CTR (click率) + 作成参加率 (成果指標) + プレイ後 Good 率
- quick_match: 1 位選択率 (投票率) + プレイ後 Good 率
| パネル | 対象 Surface | metric | データ source |
|---|---|---|---|
| 1 位選択率 (投票率, daily) | QuickMatch | voted_scenario_id == display_order==0 の割合 | DB mirror (quick_match_*) |
| 作成参加率 (成果指標, daily, surface 別) | FOR_YOU + SIMILAR | 推薦された (user, scenario) が後で events.host_id (作成) または participants (参加) に至った割合 | BQ + DB mirror |
| プレイ後 Good 率 (daily, surface 別) | QuickMatch + FOR_YOU + SIMILAR | 推薦された作品を後で (経路問わず) プレイして EXCELLENT 評価した割合。評価 (participants.rating group / solo_events.rating solo) を (user, scenario) で推薦ログと突き合わせ、surface は推薦元から取る | DB mirror (participants + events / solo_events + scenario_revisions) + BQ (recommendation_assignments) |
| CTR (click率, daily, surface 別) | FOR_YOU + SIMILAR | click された assignment 数 / assignment 数 (recommendation_id で join) | BQ (recommendation_assignments + recommendation_clicks) |
| assignment 流量 (補助, daily, surface 別) | FOR_YOU + QuickMatch + SIMILAR | count(*) from recommendation_assignments (分母 / sample size の文脈) | BQ (recommendation_assignments) |
| model_version 別 1 位選択率 (補助) | QuickMatch | 1 位選択率を model_version で group by (experiment_arm IS NULL、version 比較) | BQ → DB join |
実験 arm 比較 (experimentId 指定時のみ) | FOR_YOU (A/B) | 作成参加率・プレイ後 Good 率を user 単位 / ITT (初回曝露 arm 固定) / 3日窓 + 成熟ゲート で experiment_arm 別集計 (baseline vs pop_content の判定) | BQ + DB mirror |
期間 default 過去 14 日 / 上限 90 日。1 リクエスト 2-3 秒許容。
experimentIdを指定すると arm 別 breakdown パネルが追加で出る (下記「FOR_YOU A/B 実験」)。
成果指標を「作成参加率」にした理由
マダミスのグループプレイは「推薦 → イベント作成 / 参加 → 日程調整 → プレイ開始」とリードタイムが長く (プレイ開始まで数週間)、プレイ開始を成果指標に置くと AB の判定が遅れ「作成済みだがプレイ前」を取りこぼす。推薦の直後に立つ「作成 / 参加」(= 遊ぶ意図の発現) を成果指標とする。
- 作成 =
events.host_id(主催)、参加 =participants行。両方を OR で「遊ぶ意図」として拾う - SOLO プレイ (
solo_events) は含めない。イベント funnel の意図発現に焦点を当て、FOR_YOU の SOLO start は実測 ~0 - QuickMatch は対象外。マッチング後にシステムがイベントを自動生成するため「作成 / 参加」がユーザーの選好シグナルにならない (QuickMatch の選好は 1 位選択率で測る)
北極星 metric
| Surface | primary | latency |
|---|---|---|
| QuickMatch | 1 位選択率 (voted == display_order==0) + プレイ後 Good 率 | 投票率は数時間 / Good 率は数日 |
| FOR_YOU | 作成参加率 (作成 / 参加に至った率) + プレイ後 Good 率 | 作成参加率は推薦直後〜数日 / Good 率は数日 |
| SIMILAR | 作成参加率 (作成 / 参加に至った率) + プレイ後 Good 率 | 同上 |
CTR (FOR_YOU / SIMILAR) は impression tracker (UZU-4278) で section view → tap の click 計測が流入して初めて有効になる。現状は client-side click ログが未流入のため 0 になりうる点に注意 (assignment は記録されているが click が来ていない)。
recommendation_id ↔ DB の連結
QuickMatch matchmake は Recommender.Recommend(QuickMatchRequest{...}) の戻り値 RecommendationID を quick_match_rooms.recommendation_id (UUID NULL) に保存する (UZU-4290 で導入)。これで:
- BigQuery
recommendation_assignments.recommendation_id↔ DBquick_match_rooms.recommendation_idで 3 件推薦のうちどれが選ばれたかを join で復元できる - matchmake 経路は同 issue で
SuppressLogging: falseに切替済み → BQ にログが流れている
推薦経由でないルームや過去ルームは recommendation_id = NULL。
saved query
各パネルは下記 SQL を BQ で叩く形に落としてある。手元で再現する時はそのままコピペ可能。クエリパラメータ @since / @until (TIMESTAMP)、@surfaces (ARRAY<STRING>)、@model_versions (ARRAY<STRING>) を埋めて実行する。
1 位選択率 (QuickMatch、日次)
QM 計測パネルは すべて assignment ログ経由 で推薦経由 room に限定する (純粋なモデル評価)。modelVersion filter で版別の比較ができる。
WITH assignments AS (
SELECT DISTINCT recommendation_id
FROM `uzu-prd.mobile_events.recommendation_assignments`
WHERE surface = 'quick_match'
AND assigned_at >= @since AND assigned_at < @until
AND (@model_versions IS NULL OR ARRAY_LENGTH(@model_versions) = 0
OR model_version IN UNNEST(@model_versions))
)
SELECT
FORMAT_DATE('%Y-%m-%d', DATE(p.updated_at)) AS date,
SAFE_DIVIDE(COUNTIF(p.voted_scenario_id = cand0.scenario_id), COUNT(*)) AS value,
COUNT(*) AS denominator
FROM assignments a
JOIN `uzu-prd.app_public.quick_match_rooms` r
ON CAST(r.recommendation_id AS STRING) = a.recommendation_id
JOIN `uzu-prd.app_public.quick_match_participants` p
ON p.quick_match_room_id = r.id
LEFT JOIN `uzu-prd.app_public.quick_match_scenario_candidates` cand0
ON cand0.quick_match_room_id = r.id AND cand0.display_order = 0
WHERE p.voted_scenario_id IS NOT NULL
AND p.updated_at >= @since AND p.updated_at < @until
GROUP BY date
ORDER BY date;model_version 別 1 位選択率も同じ
assignmentsWITH 句構造で組む (recommendation_id 経由 join で推薦経由 room に限定)。
プレイ後 Good 率 (Surface 別、日次)
「surface X で推薦された作品を、ユーザーが後で (経路問わず) プレイして EXCELLENT 評価した割合」。評価は participants.rating (group プレイ) と solo_events.rating (solo プレイ) の EXCELLENT / POOR enum で持っている (base-scoring §4.6)。reviews テーブルには rating カラムが無い点に注意。
プレイ経路 (solo / group) と推薦 surface は独立なので、評価は (user, scenario) だけで推薦ログと突き合わせ、surface は推薦ログ (assignment) 側から取る。FOR_YOU で推薦された作品を group で遊ぶ / SIMILAR を solo で遊ぶケースも、正しく推薦元 surface に計上される。
WITH assignments AS (
SELECT
CAST(user_id AS INT64) AS user_id,
surface,
CAST(sid AS INT64) AS scenario_id,
MIN(assigned_at) AS first_assigned_at
FROM `uzu-prd.mobile_events.recommendation_assignments`,
UNNEST(returned_scenario_ids) AS sid
WHERE surface IN UNNEST(@surfaces)
AND assigned_at >= TIMESTAMP_SUB(@since, INTERVAL 30 DAY)
AND assigned_at < @until
GROUP BY user_id, surface, scenario_id
),
ratings AS (
SELECT p.user_id, e.scenario_id, p.rating, p.updated_at AS rated_at
FROM `uzu-prd.app_public.participants` p
JOIN `uzu-prd.app_public.events` e ON e.id = p.event_id
WHERE p.rating IS NOT NULL AND p.updated_at >= @since AND p.updated_at < @until
UNION ALL
SELECT se.user_id, sr.scenario_id, se.rating, se.updated_at AS rated_at
FROM `uzu-prd.app_public.solo_events` se
JOIN `uzu-prd.app_public.scenario_revisions` sr ON sr.id = se.scenario_revision_id
WHERE se.rating IS NOT NULL AND se.updated_at >= @since AND se.updated_at < @until
),
joined AS (
SELECT a.surface, DATE(r.rated_at) AS date, r.rating
FROM ratings r
JOIN assignments a
ON a.user_id = r.user_id
AND a.scenario_id = r.scenario_id
AND a.first_assigned_at < r.rated_at
)
SELECT
surface,
FORMAT_DATE('%Y-%m-%d', date) AS date,
SAFE_DIVIDE(COUNTIF(rating = 'EXCELLENT'), COUNT(*)) AS value,
COUNT(*) AS denominator
FROM joined
GROUP BY surface, date
ORDER BY surface, date;@surfaces は ['for_you', 'quick_match', 'similar'] 等。first_assigned_at < rated_at の時系列条件で「推薦されてからプレイ → 評価」した行のみを残す。先 30 日 lookback は推薦から評価まで時間が空くケースを拾うため。assignment は (user, surface, scenario) で畳んでから join し、同じ作品を同 surface で複数回推薦しても分母が膨らまないようにする。
同じ
(user, scenario)が複数 surface で推薦されている場合、その 1 評価は各 surface に計上される。これは「各 surface 経由の推薦が結果的に高評価だったか」を独立に測る意図的な挙動。旧実装は評価の出所で surface を決め打ち (group→quick_match / solo→for_you) しa.surface = r.surfaceで join していたが、これは「surface = プレイ経路」を仮定するもので、両経路を持つ SIMILAR が必ず 0 件になり FOR_YOU も group プレイ分を取りこぼしていたため、推薦ログ側の surface で集計する方式に改めた。
作成参加率 (成果指標、Surface 別、日次)
推薦された (user, scenario) ペアが、推薦後に イベント作成 (events.host_id) または イベント参加 (participants) に至った割合。作成 / 参加の早いほうを first_action_at とし、assigned_at < first_action_at で「推薦されてから」のみ対象にする。events は scenario_id を直持ちするため scenario_revisions join は不要。@surfaces は ['for_you', 'similar']。
WITH assignments AS (
SELECT
CAST(user_id AS INT64) AS user_id,
surface,
CAST(sid AS INT64) AS scenario_id,
assigned_at
FROM `uzu-prd.mobile_events.recommendation_assignments`,
UNNEST(returned_scenario_ids) AS sid
WHERE surface IN UNNEST(@surfaces)
AND assigned_at >= @since AND assigned_at < @until
AND (@model_versions IS NULL OR ARRAY_LENGTH(@model_versions) = 0
OR model_version IN UNNEST(@model_versions))
),
host_actions AS (
SELECT e.host_id AS user_id, e.scenario_id, MIN(e.created_at) AS first_action_at
FROM `uzu-prd.app_public.events` e
WHERE e.created_at >= @since AND e.created_at < @until
GROUP BY e.host_id, e.scenario_id
),
join_actions AS (
SELECT p.user_id, e.scenario_id, MIN(p.created_at) AS first_action_at
FROM `uzu-prd.app_public.participants` p
JOIN `uzu-prd.app_public.events` e ON e.id = p.event_id
WHERE p.created_at >= @since AND p.created_at < @until
GROUP BY p.user_id, e.scenario_id
),
all_actions AS (
SELECT user_id, scenario_id, first_action_at FROM host_actions
UNION ALL
SELECT user_id, scenario_id, first_action_at FROM join_actions
),
first_action_by_user_scenario AS (
SELECT user_id, scenario_id, MIN(first_action_at) AS first_action_at
FROM all_actions
GROUP BY user_id, scenario_id
)
SELECT
a.surface,
FORMAT_DATE('%Y-%m-%d', DATE(a.assigned_at)) AS date,
SAFE_DIVIDE(COUNTIF(s.first_action_at IS NOT NULL), COUNT(*)) AS value,
COUNT(*) AS denominator
FROM assignments a
LEFT JOIN first_action_by_user_scenario s
ON s.user_id = a.user_id
AND s.scenario_id = a.scenario_id
AND s.first_action_at > a.assigned_at
GROUP BY a.surface, date
ORDER BY a.surface, date;assignment 流量 (Surface 別、日次)
SELECT
surface,
FORMAT_DATE('%Y-%m-%d', DATE(assigned_at)) AS date,
COUNT(*) AS value
FROM `uzu-prd.mobile_events.recommendation_assignments`
WHERE surface IN UNNEST(@surfaces)
AND assigned_at >= @since AND assigned_at < @until
GROUP BY surface, date
ORDER BY surface, date;model_version 別 1 位選択率 (QuickMatch、experiment_arm IS NULL)
WITH assignments AS (
SELECT DISTINCT recommendation_id, model_version
FROM `uzu-prd.mobile_events.recommendation_assignments`
WHERE surface = 'quick_match'
AND experiment_arm IS NULL
AND assigned_at >= @since AND assigned_at < @until
),
base AS (
SELECT
a.model_version,
p.voted_scenario_id,
cand0.scenario_id AS top_scenario_id
FROM `uzu-prd.app_public.quick_match_participants` p
JOIN `uzu-prd.app_public.quick_match_rooms` r ON r.id = p.quick_match_room_id
JOIN assignments a ON CAST(r.recommendation_id AS STRING) = a.recommendation_id
LEFT JOIN `uzu-prd.app_public.quick_match_scenario_candidates` cand0
ON cand0.quick_match_room_id = r.id AND cand0.display_order = 0
WHERE p.voted_scenario_id IS NOT NULL
AND p.updated_at >= @since AND p.updated_at < @until
)
SELECT
model_version,
SAFE_DIVIDE(COUNTIF(voted_scenario_id = top_scenario_id), COUNT(*)) AS value,
COUNT(*) AS denominator
FROM base
GROUP BY model_version
ORDER BY model_version;recommendation_id 経由で BQ assignment → DB quick_match_rooms を連結する典型例。experiment_arm IS NULL で実験対象外の行に限定し、arm の影響を除いた version 比較にしている。FOR_YOU の arm 別 breakdown は下記「FOR_YOU A/B 実験」を参照。
FOR_YOU A/B 実験 (baseline vs pop_content)
FOR_YOU surface で 2 つの Base Scoring recipe をオンライン比較する (UZU-4268)。
- arm
baseline… iALS (協調フィルタリング)。DotScorer。 - arm
pop_content… 人気 bias 順位 + content embedding cosine 順位 の RRF。PopularityContentScorer。 - experiment_id …
foryou_baseline_vs_pop_content_2026_06(recommendation.ForYouExperimentID)。
arm 割り当て
recommendation.AssignExperimentBucket(userID, experimentID, surface) が experiment_id + surface + user_id の FNV-1a hash を 100 で割った bucket [0,100) を返す。pop_content_ratio 未満なら pop_content、以上なら baseline。deterministic なので同一 user は比率固定中ずっと同じ arm に貼り付く。experiment_id / surface を salt に混ぜるので実験ごと・surface ごとに独立 split になる。
本番経路は usecase/scenarioscreen/recommended_for_you.go で arm を解決し、CommonOptions.Recipe (scorer 出し分け) と ExperimentArm / ExperimentID (assignment ログ) に詰める。pipeline は Recipe == pop_content のとき content scorer を使い、content cache 未ロード時は baseline に degrade する。
設定 (AppSetting 駆動・再デプロイ不要)
実験の可変部分は AppSetting の key recommendation_foryou_experiment (jsonb value) で持つ。バックエンドは 60s TTL のプロセス内キャッシュで読むので、値を書き換えれば最大 60s で全 Pod に伝播する (再デプロイ不要)。
{
"enabled": true,
"pop_content_ratio": 0,
"overrides": { "12345": "pop_content" }
}| フィールド | 意味 |
|---|---|
enabled | 実験 on/off。false なら全 user baseline・experiment_id / arm 未記録 (現行挙動) |
pop_content_ratio | pop_content に振る割合 [0,100] (整数)。残りが baseline。0 = ダーク投入 (全 baseline・arm は記録) |
overrides | userID(string) → "baseline" | "pop_content"。bucket 判定より優先。QA / dogfooding 用 |
ロールアウト手順 (ダーク投入 → 段階ランプ):
設定は admin /appSettings で key recommendation_foryou_experiment を JSON 値として作成・編集できる (raw SQL より安全)。下記の uzuctl SQL でも可。
{ "enabled": true, "pop_content_ratio": 0 }を投入 → 全 user baseline。assignment に arm=baseline が載るので配線の健全性を admin metrics で確認。- ramp 前の前提確認: pop_content scorer は prod の content embedding (
recommendation_content_embeddings、日次 CronJob で生成) に依存する。これが空だと pop_content arm は 人気順のみ / 同値に degrade して baseline でない別物になり、判定が不公平になる。pop_content_ratioを 0 より上げる前に prod の content cache が populated か (admin preview で pop_content が実順位を返すか) を確認する。 - 問題なければ
pop_content_ratioを10→50と上げる。比率は上げる一方で運用する (下げ戻すと serving の arm がフリップして体験が混ざる)。 - 比率を上げると bucket 境界をまたぐ一部 user の serving arm が baseline → pop_content にフリップする。計測は ITT (初回曝露の arm で固定) なので分析 cohort は汚れない (下記「arm 別集計」)。ランプ直後の不安定期間を避けたいときは admin の
sinceをランプ完了後に置く (burn-in 除外)。
値の更新例 (uzuctl で prd DB を直接編集):
uzuctl db exec --env prd --db app \
"INSERT INTO app_settings (key, value, created_at, updated_at)
VALUES ('recommendation_foryou_experiment', '{\"enabled\":true,\"pop_content_ratio\":10}'::jsonb, now(), now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()"arm 別集計 (作成参加率・プレイ後 Good 率) — user 単位 / ITT
admin /recommendation/metrics の experimentId フィルタに上記 experiment_id を指定すると「実験 arm 比較」パネルが出る。標準的な online A/B の作法に揃えている (metrics.go の creationParticipationRateByArmSQL / postPlayGoodRateByArmSQL):
- 単位は user。impression / (user, scenario) 単位だと擬似反復で分散を過小評価して偽陽性が出るため、user 単位の比率にする。
- arm は初回曝露 (first exposure) の arm で固定 (ITT)。ランプで serving arm がフリップしても分析上の arm は動かさないので、比率を上げる操作が cohort を汚さない。初回曝露は experiment_id 内の
MIN(assigned_at)。 - 作成参加率 (主判定): 初回曝露から 固定窓 W=3 日 (
forYouConversionWindowDays、初回設定。実データの lag 分布を見て再調整想定) 以内に、推薦された scenario を作成 / 参加した user を変換と数える。初回曝露が@until - 3日より後の user は 未成熟として除外 (maturation gate) → 右側 censoring を原理的に排除。@untilを「今」にしても直近の未成熟分が自動で外れる。 - プレイ後 Good 率 (遅延 KPI / 副次): プレイ→評価は数週間のリードタイムがあるため固定窓は課さず、「推薦作品を初回曝露後に評価した user のうち 1 つでも EXCELLENT を付けた割合」を directional に見る。
- burn-in 除外:
@sinceを「目標比率に到達した時点」に置けば、ランプ中の不安定な enroll を分析から外せる。
-- 作成参加率を arm 別に user 単位 + ITT + 3日窓 + 成熟ゲートで集計 (A/B 主判定)
WITH first_exposure AS (
SELECT user_id, arm, first_assigned_at FROM (
SELECT CAST(user_id AS INT64) AS user_id, experiment_arm AS arm, assigned_at AS first_assigned_at,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY assigned_at) AS rn
FROM `uzu-prd.mobile_events.recommendation_assignments`
WHERE experiment_id = @experiment_id AND experiment_arm IS NOT NULL
AND assigned_at >= TIMESTAMP_SUB(@since, INTERVAL 30 DAY) AND assigned_at < @until
) WHERE rn = 1
),
enrolled AS ( -- 初回曝露が [@since, @until - 3日) の user のみ (burn-in 除外 + 成熟ゲート)
SELECT * FROM first_exposure
WHERE first_assigned_at >= @since AND first_assigned_at < TIMESTAMP_SUB(@until, INTERVAL 3 DAY)
),
recommended AS (
SELECT DISTINCT CAST(user_id AS INT64) AS user_id, CAST(sid AS INT64) AS scenario_id
FROM `uzu-prd.mobile_events.recommendation_assignments`, UNNEST(returned_scenario_ids) AS sid
WHERE experiment_id = @experiment_id AND experiment_arm IS NOT NULL
AND assigned_at >= TIMESTAMP_SUB(@since, INTERVAL 30 DAY) AND assigned_at < @until
),
actions AS (
SELECT host_id AS user_id, scenario_id, created_at AS action_at
FROM `uzu-prd.app_public.events` WHERE created_at >= @since AND created_at < @until
UNION ALL
SELECT p.user_id, e.scenario_id, p.created_at AS action_at
FROM `uzu-prd.app_public.participants` p JOIN `uzu-prd.app_public.events` e ON e.id = p.event_id
WHERE p.created_at >= @since AND p.created_at < @until
),
converted AS (
SELECT DISTINCT en.user_id
FROM enrolled en
JOIN recommended r ON r.user_id = en.user_id
JOIN actions a ON a.user_id = en.user_id AND a.scenario_id = r.scenario_id
WHERE a.action_at > en.first_assigned_at
AND a.action_at <= TIMESTAMP_ADD(en.first_assigned_at, INTERVAL 3 DAY)
)
SELECT en.arm,
SAFE_DIVIDE(COUNT(DISTINCT c.user_id), COUNT(DISTINCT en.user_id)) AS value,
COUNT(DISTINCT en.user_id) AS denominator
FROM enrolled en LEFT JOIN converted c ON c.user_id = en.user_id
GROUP BY en.arm ORDER BY en.arm;サンプルサイズ試算 (成果指標 = 作成参加率 user 単位 16.3%、α=0.05・power=0.8): +30% 検出に 2 arm 計約 2,000 user (週 15k なら 2〜3 日)、+20% で約 4,300 (約 1 週間)、+10% で約 16,700 (約 2 週間)。
guardrail: SRM (実際の arm 配分が意図比率と一致するか) と novelty effect (新 recipe の目新しさで一時的に上振れ → 早期判定を避ける) も併せて見る。
SuppressLogging の運用
QuickMatch matchmake 経路は UZU-4290 で SuppressLogging: false に切替済み。本番 assignment log が BQ に流れている。
SuppressLogging: true のままにする経路:
adminForYouScenarios/adminQuickMatchScenarios/adminSimilarScenarios(admin の preview ページ) — 試行で本番統計を汚染しないため
新しい本番経路を追加するときは SuppressLogging を必ず false に保つ。preview 等の試行系だけ true にする。
関連
- spec: §8 評価指標・A/B 基盤、§8.5 計測に必要なデータ
- 本番投入経路:
backend/internal/usecase/quickmatch_matchmake.go - pipeline 入口:
backend/internal/usecase/recommendation/pipeline/recommender.go - BQ assignment logger:
backend/internal/usecase/recommendation/pipeline/logger.go - admin UI:
javascript/apps/admin/src/app/recommendation/metrics/page.tsx - backend resolver:
backend/internal/adapters/graph/recommendation_metrics.resolvers.go - backend usecase:
backend/internal/usecase/recommendation/metrics.go