Skip to content

推薦の本番計測 ​

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 率
パネル対象 Surfacemetricデータ source
1 位選択率 (投票率, daily)QuickMatchvoted_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 + SIMILARclick された assignment 数 / assignment 数 (recommendation_id で join)BQ (recommendation_assignments + recommendation_clicks)
assignment 流量 (補助, daily, surface 別)FOR_YOU + QuickMatch + SIMILARcount(*) from recommendation_assignments (分母 / sample size の文脈)BQ (recommendation_assignments)
model_version 別 1 位選択率 (補助)QuickMatch1 位選択率を 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 ​

Surfaceprimarylatency
QuickMatch1 位選択率 (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 ↔ DB quick_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 で版別の比較ができる。

sql
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 位選択率も同じ assignments WITH 句構造で組む (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 に計上される。

sql
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']。

sql
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 別、日次) ​

sql
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) ​

sql
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 に伝播する (再デプロイ不要)。

json
{
  "enabled": true,
  "pop_content_ratio": 0,
  "overrides": { "12345": "pop_content" }
}
フィールド意味
enabled実験 on/off。false なら全 user baseline・experiment_id / arm 未記録 (現行挙動)
pop_content_ratiopop_content に振る割合 [0,100] (整数)。残りが baseline。0 = ダーク投入 (全 baseline・arm は記録)
overridesuserID(string) → "baseline" | "pop_content"。bucket 判定より優先。QA / dogfooding 用

ロールアウト手順 (ダーク投入 → 段階ランプ):

設定は admin /appSettings で key recommendation_foryou_experiment を JSON 値として作成・編集できる (raw SQL より安全)。下記の uzuctl SQL でも可。

  1. { "enabled": true, "pop_content_ratio": 0 } を投入 → 全 user baseline。assignment に arm=baseline が載るので配線の健全性を admin metrics で確認。
  2. 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 が実順位を返すか) を確認する。
  3. 問題なければ pop_content_ratio を 10 → 50 と上げる。比率は上げる一方で運用する (下げ戻すと serving の arm がフリップして体験が混ざる)。
  4. 比率を上げると bucket 境界をまたぐ一部 user の serving arm が baseline → pop_content にフリップする。計測は ITT (初回曝露の arm で固定) なので分析 cohort は汚れない (下記「arm 別集計」)。ランプ直後の不安定期間を避けたいときは admin の since をランプ完了後に置く (burn-in 除外)。

値の更新例 (uzuctl で prd DB を直接編集):

bash
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 を分析から外せる。
sql
-- 作成参加率を 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