レコメンデーション仕様
UZU バックエンドにおけるレコメンデーション機能の仕様本体。何を作るか・どう動かすかを記述する。技術選定の経緯・比較検討・採用しなかった代替案の議論は technology-choices 参照。
INFO
コード詳細は backend/internal/usecase/recommendation/ 配下を参照。
1. 現状の実装サマリ
UZU バックエンド(Go / Ent / gqlgen)に存在するレコメンデーション機能は、ルールベースのスコアリングと軽度な協調フィルタリングの組み合わせで構成されている。
1.1 シナリオランキング(人気順スコア)
- 実装:
backend/internal/usecase/update_ranking_rate.go - スコア式(概念):
score = ratingFactor × playCountFactor × daysBonus × priceBonus × playTimeFactor- 対象期間: 直近 21 日のイベント・ソロプレイ
- 更新頻度: 日次バッチ(21:00) —
backend/internal/jobs/nightly_batch_process/main.goからUpdateScenariosRantingRate()を呼び出し
1.2 シナリオスクリーンのセクション推薦
- 実装:
backend/internal/usecase/scenarioscreen/ - セクション種別: 初心者向け・新着(無料/有料)・ランキング・招募中・友人お気に入り・プレイ済み 等
- 実体は Ent の Where/Order を組み合わせたクエリ群(学習モデルではない)
1.3 ユーザー嗜好プロファイル
- 実装:
backend/internal/adapters/graph/schema/user_recommendation_profile_query.gql - タグごとのスコア・プレイ回数・有料プレイ数・公開イベント参加数を保持
- 現状は管理画面表示用途で、推薦そのものには組み込まれていない
1.4 未実装
- 行動ログ基盤(impression / click / detail view 等の計測)
- A/B テスト・CTR / CVR 計測
- ML モデル・Embedding
- イベント・作者・コレクション・ユーザー(友達)推薦
- セッション内リアルタイム化
- コンテキスト(日時・人数・予算・地域)による動的絞り込み
2. UZU バックエンドの世界
2.1 推薦対象になりうるエンティティ
| エンティティ | 位置付け | 現状 |
|---|---|---|
scenario | 作品(核) | 実装あり |
event | マルチプレイ回(募集中・開催予定) | 未実装 |
solo_events | ソロプレイ回 | 未実装 |
author | 作者 | 未実装 |
scenario_collection | シリーズ・公式まとめ | 未実装 |
user | 他プレイヤー(友達候補) | 未実装 |
additional_content | 追加コンテンツ(DLC 的) | 未実装 |
2.2 ユーザー行動シグナル(既に DB にあるもの)
| テーブル | シグナルの意味 |
|---|---|
participant / solo_events | プレイ履歴(implicit feedback) |
participant_locale_evaluation | 評価(Excellent / Good / Poor) |
review / favorite_review | レビューおよびレビューへの共感 |
user_scenario_status | プレイ済み・気になる 等のステータス |
user_subscribe_author | 作者フォロー |
user_subscribe_scenario_collection | コレクション購読 |
user_relationship | ユーザー同士の関係 |
tipping | 投げ銭(非常に強い positive signal) |
chatroom_user / chatroom_event | 共参加の共起グラフ |
TIP
現状のランキング式では tipping や favorite_review が未活用である。これらは「お金や手間をかけてまで支持している」という強いシグナルなので、学習データに組み込む価値が高い。
3. 推薦パイプラインのアーキテクチャ
UZU の推薦は Base Scoring + Surface Strategy の 2 層構造 で、Surface Strategy が業界標準の Multi-stage pipeline (Retrieval → Ranking → Re-ranking) を Surface ごとに組み立てる (設計経緯は strategy-decisions 2026-04-23)。
┌──────────────────────────────────────────────────────────┐
│ Base Scoring (全 Surface 共通) │
│ 学習済みモデル (iALS 内積) │
│ user × scenario のスコア行列を返す │
└──────────────────────────────────────────────────────────┘
│
▼ (matrix[viewer][scenario])
┌──────────────────────────────────────────────────────────┐
│ Surface Strategy (Surface ごとに 1 struct) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ PreFilter — Retrieval / Filtering │ │
│ │ ハード制約で候補を絞る │ │
│ ├────────────────────────────────────────────────────┤ │
│ │ Aggregate — Ranking │ │
│ │ Surface 固有の集約式で最終スコアを計算 │ │
│ ├────────────────────────────────────────────────────┤ │
│ │ PostRerank — Re-ranking │ │
│ │ ソフト調整・多様性確保 │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘3.1 2 層 + 3 段階の役割
| 区分 | 数 | 性質 | 業界用語 |
|---|---|---|---|
| Base Scoring | 抽象として 1 本 (user × scenario) | user × scenario のスコアを返す学習モデル群 | Base Scoring / Pre-ranking |
| Strategy.PreFilter | Surface ごとに 1 実装 | ハード制約で候補宇宙を絞る | Retrieval / Candidate Pruning |
| Strategy.Aggregate | Surface ごとに 1 実装 | Base Score を Surface 固有の式で集約 | Ranking / Contextual Scoring |
| Strategy.PostRerank | Surface ごとに 1 実装 | ソフト調整・多様性確保 | Re-ranking / Post-processing |
UZU 実装名 ↔ 業界標準語の対応
UZU の実装名 (Base Scoring / Strategy.PreFilter / Aggregate / PostRerank) は、recsys の業界標準語彙 (Retrieval → Ranking → Re-ranking + Base/embedding model) に 1:1 で対応する。外部の recsys 人材・コーディング AI が読み替えられるよう、対応表を橋として残す:
| UZU 実装名 | 業界標準語 | 実装上の配置 |
|---|---|---|
| Base Scoring | Base Scoring / Pre-ranking | basescoring/ パッケージ (全 Surface 共通) |
| Strategy.PreFilter | Retrieval / Candidate Pruning / Filtering | surfaces/<surface>.go の PreFilter method |
| Strategy.Aggregate | Ranking / Contextual Scoring | surfaces/<surface>.go の Aggregate method |
| Strategy.PostRerank | Re-ranking / Post-processing | surfaces/<surface>.go の PostRerank method |
本 spec 初版の「3 層モデル / L1・L2・L3」呼称は廃止した。実行順 (PreFilter → Base Scoring → Aggregate → PostRerank) と番号が逆で誤解を生むのが理由。外部資料との突き合わせ用にこの注記にのみ旧称を残す。
3.2 Base Scoring
Base Scoring は「user × scenario の関係をスコア化する抽象」であり、全 Surface 共通。Aggregate から見えるのは スカラーのスコアのみで、内部の user / scenario embedding ベクトルは外に出さない。
score(u, s) : (User, Scenario) → R # スカラー- 内部は行動ベース(iALS)+ 内容ベース(content embedding)の複数サブモデルで構成しうるが、外部契約は
score(u, s) → float1 本に固定する - この契約を固定することで、内部実装(iALS / LightFM / content-embedding / hybrid 等)を入れ替えても Aggregate / PreFilter / PostRerank / resolver は書き換え不要になる
コアモデルの内部実装(シグナル重み・negative confidence・サブモデル統合・学習・Cold-start・実装インフラ・学習データ)は Base Scoring(コアモデル) に分離した。本節は Aggregate / PreFilter / PostRerank から見える外部契約のみを定義する。
3.3 Aggregate (Ranking): Derived Scores
Base Scoring のスコアを集約するだけなので 新機能は式の追加で実現できる。合成式は以下 3 パターンの使い分けを標準とし、追加時もこのパターンに沿って設計する。
集約関数の使い分け
| 関数 | 意味 | 適用原則 |
|---|---|---|
| max | エンティティを「最良要素」で代表する | 構成要素が少数かつ独立に評価されるもの(event) |
| mean | エンティティを「全体通し体験」で代表する | 構成要素が少数で、セットとして消費されるもの(collection) |
| topK_mean (K=5) | 外れ値に頑健な代表値 | 構成要素が多く品質にばらつきがあるもの(author) |
標準合成式
| 推薦対象 | 合成式 |
|---|---|
event_score | max(score(u, s) for s in event.scenarios) × time_proximity |
author_score | topK_mean(score(u, s) for s in author.scenarios, k=5) |
collection_score | mean(score(u, s) for s in collection.scenarios) |
additional_content_score | 親シナリオの user × scenario スコアをそのまま利用 |
quick_match_score | 集合 G に対して α × mean + (1-α) × min の Least Misery 重み付き平均(集約は group 次元) |
friend_score | 別軸: user × user の共参加・共通高評価で独立に算出 |
Context Multiplier(時間・距離等)の扱い
- 乗算(ハード減衰):
base × factor。factor=0 で完全に消える。期限切れ・非対応言語など「落とすべき」文脈- 例:
event_scoreのtime_proximity
- 例:
- 加算(ソフト調整):
α × base + β × context。緩やかに順位調整- 例: 新作ボーナス、地域ブースト
ハードな条件は乗算、ソフトな順位調整は加算、が原則。ハードフィルタは PreFilter に寄せる(Aggregate で乗算→0 にするより、PreFilter で先に除外した方が計算効率が良い)。
WARNING
friend_score は user × scenario では表現できないため、Base Scoring にもう 1 本モデルが必要になる。Base Scoring を増やす判断基準は「既存の合成で表現できない新しいシグナル次元が必要になった時」に限定する。
3.4 PreFilter / PostRerank: Context Gate
学習も合成もしない、純粋なルールベースの制約層。Context Gate は 2 フェーズに分かれており、Base Scoring の前後に配置される。
Context Gate の 2 フェーズ
全シナリオ宇宙 (16.8K)
│
▼ PreFilter(候補生成 / Retrieval) ← フェーズ 1
候補集合 (数百〜数千件)
│
▼ Base Scoring: ScoreMatrix / ScoreBatch
スコア付き候補
│
▼ Aggregate(集約 / Ranking: max / mean / topK_mean / Least Misery 等)
集約スコア付き候補
│
▼ PostRerank(Post-filter / Re-ranking) ← フェーズ 2
top-K 最終結果| フェーズ | 役割 | 典型的な処理 |
|---|---|---|
| Pre-filter(候補生成) | ハード制約で宇宙を絞る | プレイ済み除外、言語・人数適合、開催予定、ネタバレ回避、GM 有無 |
| Post-filter / Rerank | スコア確定後の調整 | ソフトなデブースト(難易度ペナルティ)、多様性確保(MMR)、ビジネスブースト |
2 フェーズに分ける根拠(効率・正確性・surface 別候補空間)は technology-choices §7 参照。
Surface 別の候補生成ロジック
| Surface | Pre-filter で絞る条件 |
|---|---|
| ForYou | プレイ済み除外、言語適合(UZU 規模なら省略して全件スコアも可) |
| Event | 開催予定の event が紐付ける scenarios |
| Author | 対象 author に属する scenarios |
| Collection | 対象 collection に属する scenarios |
| QuickMatch | 人数適合 ∧ 全員未プレイ ∧ 言語適合 ∧ GM 条件 ∧ 2 周目+ 専用キャラ非含有 |
| SimilarScenario | 同カテゴリ or 同タグを持つ scenarios(絞り込みで高速化) |
「プレイ済み除外」のソース
ネタバレ回避目的のプレイ済み除外 (ForYou / QuickMatch 共通) は user_scenario_status.status = 'PLAYED' を単一ソースとする。実装は usecase.Usecase.PlayedScenarioIDs (backend/internal/usecase/played_scenario_ids.go)。
event.finished_at/solo_event.finished_atはここでは使わない。理由: 進行中のまま放置されたマルチイベントや canceled_at 入りのレコードはプレイ済みシグナルを漏らす。user_scenario_statusは event 開始時の自動マーク + ユーザー手動マークの両方を集約しており、ネタバレ済みの網羅性が最も高い。- 除外単位は
ScenarioSet。user_scenario_status はscenario_set_idを持つため、ScenarioSet 配下の全 scenario (再演・翻訳版を含む) を展開して一括除外する。 WATCHEDは現状プレイ済み除外の対象にしない (別 issue で扱う)。- base-scoring §4.3 の「プレイ完了定義 (30 分閾値)」は集計・分析用途の別軸の定義で、本フィルタとは別ソース。
4. 強化ロードマップ
| Phase | 内容 | 目的 |
|---|---|---|
| P1 | 行動ログ基盤の整備(impression / click / CVR) | 効果測定なしに前進できない。Base Scoring 導入効果を検証する前提条件 |
| P1 | Base Scoring: user × scenario の iALS + negative confidence 導入 | 現行の人気順(非パーソナライズ)を個人化へ置き換え。Poor を負の confidence として学習(base-scoring §1) |
| P2 | BPR / LogMF をオフライン評価 | iALS との NDCG@K 比較。有意差で勝てば切替検討 |
| P2 | Aggregate 展開(event / author / collection) | 既存 Base Scoring を流用して推薦対象を横展開 |
| P2 | Aggregate: Quick Match(集団レコメンド) | 集まった N 人にシナリオを推薦する新機能。Least Misery 集約 + PreFilter の全員未プレイ制約 |
| P2 | Base Scoring に内容ベースサブモデル追加(コンテンツ Embedding) | 新作シナリオで行動ベースが効かない問題を解消(Item cold-start、base-scoring §2.3) |
| P2 | User cold-start 対応(segment q̄ 事前計算 + fold-in) | 新規ユーザーで q_u が学習できない問題を解消(base-scoring §2.2)。ranking_rate の代替基盤 |
| P3 | PostRerank の多様性・再ランキング強化 | 「同じような本格系ばかり」の体感を改善 |
| P3 | A/B テスト・バンディット基盤 | アルゴリズムの自動最適化 |
| P4 | friend 推薦用の Base Scoring 追加(user × user) | ソーシャル軸の本格推薦 |
5. 設計上の原則
- Base Scoring の抽象を増やさない: Base Scoring は関係性(user × scenario 等)ごとの抽象として数える。同一抽象内にサブモデル(行動ベース・内容ベース等)を足すのは OK だが、新しい関係性を持ち出す前に Aggregate の合成で表現できないか検討する
- Context Gate (PreFilter / PostRerank) はロジックを持たない: スコアは作らず、フィルタと並び替えのみ
- 計測を最優先: ログ基盤(Phase 1)なしに他の改善を進めない
- 既存シグナルを活用:
tipping/favorite_review/user_recommendation_profileなど、DB にあるが活用されていないシグナルを優先的に入力へ組み込む ranking_rateは Base Scoring 完成後に廃止: 統計要素は ALS の scenario biasb_sに自動吸収される。廃止後の非パーソナライズランキングは base-scoring §2.2 の scenario-only スコア(Aggregate)で代替する
6. 未決定事項(Open Decisions)
実装着手前のチェックリスト。base-scoring §3 で基盤選定(iALS / BigQuery / GKE CronJob / AlloyDB pgvector / Go in-memory 推論)は確定済みなので、ここでは残りの合意事項を列挙する。各項目は合意され次第、本ドキュメントを更新する。
6.1 P1 着手前に決めるべき(必須合意)
P1 着手前の必須合意事項はすべて解消済み。残る事項は以下の「P1 実装中に決めれば良い」および「P2 以降」に分類される。
6.2 P1 実装中に決めれば良い(初期値仮置き可)
| # | 項目 | 仮置き |
|---|---|---|
| D8 | 全シグナルの重みテーブル(Poor 含む) | 仮値: tipping=+50 / Excellent=+30 / プレイ=+20 / 気になる=+15 / favorite_review=+10 / 閲覧=+3 / Poor=-30(base-scoring §4.2 の confidence 値、α=10 相当)。オフライン NDCG@K とオンライン KPI を見ながら検証で調整 |
| D9 | Quick Match の α(mean vs min 比重) | 0.5 |
| D10 | 初心者判定 N(PlayedCount < N) | 3 |
| D11 | 初心者ブースト β | 未定 |
| D12 | 難易度ペナルティ λ | 未定 |
| D13 | Item cold-start 閾値 | 20 プレイ |
| D14 | GraphQL スキーマ命名・設計 | 既存 similarScenarios との整合含む |
6.3 P2 以降で決めて良い
| # | 項目 |
|---|---|
| D16 | 内容ベースサブモデルの embedding 技術(OpenAI / Cohere / Sentence Transformers / 自前) |
| D17 | 新作ボーナス・地域ブーストの幅 |
| D18 | Explore / Exploit の多様性比率(MMR の λ) |
| D19 | A/B テスト基盤の技術選定(GrowthBook / 自前) |
| D20 | モデル drift 監視・可観測性の実装範囲 |
| D21 | LLM embedding コスト予算(base-scoring §2.3 / Item cold-start 導入時) |
| D22 | ranking_rate 併走期間とロールバック経路(base-scoring §2.4 の移行マッピングは定義済み、実運用での併走期間と段階的切替の計画。P1 では旧実装を残したまま新実装を並列導入 → P2 で切替・廃止判断) |
7. API 設計とレイヤー境界
Base Scoring と Surface Strategy はすべて同一 Go プロセス内の内部階層であり、関数呼び出しで連鎖する。GraphQL / gRPC はその境界層で、Strategy.PostRerank の出力だけを外部に公開する。Base Scoring の内積スコアや Strategy.Aggregate の中間合成値は外部に露出しない。
7.1 レイヤー境界
┌───────────────────────────────────────────────────┐
│ Client (Mobile / Web / Admin) │
└──────────────┬────────────────────────────────────┘
│ GraphQL / gRPC
▼
┌───────────────────────────────────────────────────┐
│ Go Backend (同一プロセス) │
│ ┌─────────────────────────────────────────────┐ │
│ │ adapters/graph: resolver │ │
│ │ adminForYouScenarios (FOR_YOU) │ │
│ │ adminQuickMatchScenarios (QuickMatch) │ │
│ │ adminSimilarScenarios (SIMILAR) │ │
│ └──────┬──────────────────────────────────────┘ │
│ ▼ 関数呼び出し │
│ ┌─────────────────────────────────────────────┐ │
│ │ usecase/recommendation/pipeline │ │
│ │ Recommender.Recommend(ctx, SurfaceRequest) │ │
│ │ (全 Surface を 1 入口に統一) │ │
│ │ ↓ registry.Build(req) で Strategy new │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ surfaces/ (Surface ごとに Strategy 1 個)│ │ │
│ │ │ PostRerank (Re-ranking) │ │ │
│ │ │ ↑ │ │ │
│ │ │ Aggregate (Ranking) │ │ │
│ │ │ ↑ │ │ │
│ │ │ Base Scoring (basescoring、共有) │ │ │
│ │ │ ↑ │ │ │
│ │ │ PreFilter (Retrieval / Filtering) │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘原則:
- Base Scoring はベクトルを外に出さない:
score(u, s) → floatの契約だけ公開(§3.2 参照) - Strategy.Aggregate は中間スコアを外に出さない: 最終スコア配列を PostRerank に渡すのみ
- GraphQL には Strategy.PostRerank の出力だけが見える: 推薦結果 + 説明理由(reasons)
- 将来 Base Scoring / 他 Surface Strategy の実装を差し替えても resolver / クライアント側は変更不要
7.2 Go 内部のモジュール構成
§3 の Base Scoring + Surface Strategy アーキテクチャは 共通 Core (basescoring/) + Surface 別 Strategy (surfaces/) として実装される。Strategy 内部で業界標準の Retrieval → Ranking → Re-ranking の 3 段階が PreFilter / Aggregate / PostRerank メソッドとして組み立てられる (詳細は strategy-decisions 2026-04-23)。新しい Surface を追加するときは surfaces/ に Strategy struct のファイルを 1 つ足し、Registry.Build の switch に登録するだけで済む。
backend/internal/usecase/recommendation/
├── types.go # 共通型 (Surface / SurfaceRequest / Response / Item / Reason / PerUserScore)
├── basescoring/ # Base Scoring (iALS 内積、scenario / user embedding、全 Surface 共通)
│ ├── scorer.go # Scorer interface (ScoreMatrix)
│ ├── score_matrix.go # DotScorer (pgvector 内積実装)
│ ├── cache.go # ScenarioCache + Snapshot (model_version 固定)
│ ├── store.go # Store (AlloyDB からの embedding 読み込み)
│ ├── pgvector.go # pgvector 文字列パース
│ └── ials/ # iALS 自前学習 (technology-choices §1.4)
├── surfaces/ # staged な PreFilter → Aggregate → PostRerank に乗る Surface 用 Strategy struct を 1 ファイル
│ ├── strategy.go # SurfaceStrategy interface + AggregateOutput + AlreadyPlayedOracle
│ ├── registry.go # Registry + Build(req) factory (QuickMatch は対象外)
│ ├── for_you.go # ForYouStrategy (FOR_YOU、Identity 集約)
│ ├── similar.go # SimilarStrategy (mean + half-half mix)
│ └── (event_detail.go / author.go / collection.go は各 Surface 実装 issue で追加)
├── pipeline/ # 入口。SurfaceRequest を見て surfaces.Registry または strategy/quick_match へ振り分ける
│ ├── recommender.go # Recommender.Recommend(ctx, SurfaceRequest)
│ │ # - QuickMatchRequest は strategy/quick_match.Recommend に early-dispatch
│ │ # - それ以外は registry.Build → PreFilter → Aggregate → PostRerank
│ └── logger.go # assignment ログ書き出し (N 行ログ)
├── strategy/ # staged な PreFilter→Aggregate→PostRerank に乗らない Surface 専用の統合戦略
│ └── quick_match/ # QUICK_MATCH 統合 greedy: utility = Aggregate + V/F ボーナス - P ペナルティ + 多様性 / N≥1 ハード制約
│ ├── strategy.go # Recommend(ctx, Deps, QuickMatchRequest) — PreFilter → Base Scoring → 履歴取得 → greedy 委譲
│ │ # preFilterForQuickMatch も同居 (人数 / 全員未プレイ / locale / category / GM / 90 分以内 / 2 周目+ 専用キャラ非含有)
│ └── greedy.go # selectTopK + utility 関数 (Least Misery + 履歴シグナル + MMR + N≥1)
├── schema/ # BigQuery assignment log スキーマ
└── training/ # 学習ジョブ (loader / run / eval / writer)Base Scoring の実装パッケージは basescoring/ (旧称 l1_core/ を UZU-5395 で rename)。配下の ials/ サブパッケージはそのまま。
入口 API (pipeline.Recommender)
resolver / usecase 層からの呼び出し入口は pipeline.Recommender.Recommend(ctx, SurfaceRequest) の 1 method に統一する (MOBL-4289 で B 折衷解消、C 案一貫化)。QuickMatch / SIMILAR も同じ入口で扱い、per-user 内訳は Item.PerUserScores で返す。
Surface 別の分岐は surfaces.Registry.Build(req) が concrete 型に応じた Strategy instance を request-scope で生成し、pipeline が PreFilter / Aggregate / PostRerank を順に呼ぶだけ。pipeline 本体には Surface if 文を持たない。
// ─── 共通型 (recommendation/types.go) ─────────────
type Surface string
const (
SurfaceForYou Surface = "for_you"
SurfaceEventDetail Surface = "event_detail"
SurfaceQuickMatch Surface = "quick_match"
SurfaceSimilar Surface = "similar"
SurfaceAuthor Surface = "author"
SurfaceCollection Surface = "collection"
)
// CommonOptions は 5 surface (FOR_YOU / EventDetail / Author / Collection /
// Similar) の Request 型で共有される汎用オプション。Surface 固有の入力
// (EventID / AuthorID / CollectionID / ScenarioID) は各 Request 型のフィールドに
// 置き、ここに混ぜない。
type CommonOptions struct {
K int
Candidates []int // nil → 全シナリオ、非 nil → 指定集合に絞る (PreFilter)
ExcludeAlreadyPlayed bool // PreFilter
ExperimentID string // A/B 割当 (assignment ログ用)
ExperimentArm string
SuppressLogging bool // admin preview 等、本番統計を汚染したくない呼び出し
}
// SurfaceRequest は 5 surface の Request 型が共通で実装する
// インターフェース (isSurfaceRequest が unexported でパッケージ外からは実装不能)。
// 必須パラメータを Surface ごとにコンパイル時強制するための入口型で、
// god struct (旧 `Request`) を置き換える。
type SurfaceRequest interface {
isSurfaceRequest()
GetSurface() Surface
GetViewerUserID() int // FOR_YOU は UserID、他 Surface は ViewerID
GetCommon() CommonOptions
}
// ─── Surface 別 Request 型 ────────────────────────
type ForYouRequest struct {
UserID int
Common CommonOptions
}
type EventDetailRequest struct {
EventID int
ViewerID int
Common CommonOptions
}
type AuthorRequest struct {
AuthorID int
ViewerID int
Common CommonOptions
}
type CollectionRequest struct {
CollectionID int
ViewerID int
Common CommonOptions
}
type SimilarRequest struct {
ScenarioID int
ViewerIDs []int // 複数 viewer 対応 (感想戦パーティ全員)
Common CommonOptions
}
// QuickMatchRequest も SurfaceRequest を実装する (MOBL-4289 で B 折衷廃止)。
type QuickMatchRequest struct {
GroupUserIDs []int
ScenarioCategoryIDs []int
Common CommonOptions
}
type Response struct {
RecommendationID string
ModelVersion string
Items []Item
}
type Item struct {
ScenarioID int
Score float64 // FOR_YOU は Base Score、QuickMatch は aggregate、SIMILAR は mean
Position int
Reasons []Reason
PerUserScores []PerUserScore // QuickMatch のみ値、FOR_YOU / SIMILAR は nil
}
type PerUserScore struct { UserID int; Score float64 }
// ─── 入口 (pipeline/recommender.go) ──────────────
// registry.Build(req) で Strategy を new して順に呼ぶだけ。
// Common.SuppressLogging=false なら viewer 数分の N 行 assignment ログを送出する。
func (r *Recommender) Recommend(ctx context.Context, req recommendation.SurfaceRequest) (*recommendation.Response, error)呼び元は concrete 型で明示する:
// FOR_YOU
r.Recommend(ctx, recommendation.ForYouRequest{
UserID: 42,
Common: recommendation.CommonOptions{K: 10, ExcludeAlreadyPlayed: true},
})
// QuickMatch (集団推薦、viewer 軸が N 人)
r.Recommend(ctx, recommendation.QuickMatchRequest{
GroupUserIDs: []int{1, 2, 3},
ScenarioCategoryIDs: []int{5, 7},
Common: recommendation.CommonOptions{K: 3},
})
// SIMILAR (感想戦、viewer 集団で mean)
r.Recommend(ctx, recommendation.SimilarRequest{
ScenarioID: 100,
ViewerIDs: []int{1, 2, 3},
Common: recommendation.CommonOptions{K: 10, ExcludeAlreadyPlayed: true},
})technology-choices §9 の god struct 回避方針はこの構造で満たしている:
- 全 Surface が
SurfaceRequest共通入口で吸収され、共通処理 (version 固定 / assignment ログ / ポジション採番) を 1 箇所に集約。 - 複数 viewer を伴う Surface (QuickMatch / SIMILAR) は
GetViewerUserIDs()とItem.PerUserScoresの拡張で吸収。B 折衷の独立 method は MOBL-4289 で廃止済み。 - 「全 Surface 共通 default filter」を持ち込まない (strategy-decisions 2026-04-23 の真のデフォルト不在論)。各 Strategy が必要な制約を明示的に組み立てる。
Base Scoring の契約 (Scorer)
Base Scoring はスカラーだけを返し、user / scenario の embedding ベクトルは外に出さない(§3.2)。
// basescoring/scorer.go
type Scorer interface {
// matrix[i][j] = <userVec(userIDs[i]), scenarios[j].Vector> + scenarios[j].Bias
// FOR_YOU (len(userIDs)=1) と QUICK_MATCH (複数 user) の両方を吸収する
ScoreMatrix(ctx context.Context, modelVersion string, userIDs []int, scenarios []ScenarioEmbedding) ([][]float64, error)
// <p_context, p_candidate> を candidates と同順で返す scenario-to-scenario 類似度。
// SIMILAR Surface の Aggregate で viewer 非依存の scoring に使う。context scenario
// が未学習なら ErrScenarioEmbeddingNotFound を返し、Strategy は viewer mean に退化。
ScenarioSimilarityBatch(ctx context.Context, modelVersion string, contextScenarioID int, candidates []ScenarioEmbedding) ([]float64, error)
}実装は DotScorer (pgvector 内積) 1 本。ScenarioCache.Snapshot() が返す (modelVersion, scenarios) を呼び出し側で固定し、version を Scorer の外から渡すことで、推論中に cache.Refresh() が走っても scenarios と user embedding の version が乖離しないようにしている。ScenarioSimilarityBatch のみ context scenario の embedding を ID で引く必要があるため、DotScorer 内部で ScenarioCache を参照する (Snapshot ではなく Get 単発引きで、version 整合は引数 modelVersion と snapshot.ModelVersion() の一致チェックで担保)。
spec §7.2 初版で計画していた他メソッドは、現状必要分が出揃うまで未実装。必要になった時点で Scorer に追加する:
| メソッド | 追加タイミング |
|---|---|
ScoreForColdStartSegment(segment, candidates) | base-scoring §2.2 の segment-based cold-start (新規ユーザー) を導入するとき |
Surface ごとの Strategy (surfaces パッケージ)
各 Surface は SurfaceStrategy interface を実装する struct として 1 ファイルにまとまる。Aggregate / PreFilter / PostRerank の独立 interface は廃止し、Surface 固有のロジックと request-scope state を struct 内部に閉じ込める (strategy-decisions 2026-04-23)。
// surfaces/strategy.go
type SurfaceStrategy interface {
PreFilter(ctx, req, scenarios) ([]basescoring.ScenarioEmbedding, error)
Aggregate(ctx, matrix, scenarios, req) (AggregateOutput, error)
PostRerank(items []recommendation.Item, req) []recommendation.Item
}
type AggregateOutput struct {
Scores []float64 // Scenarios と同順の集約スコア
PerUserScores [][]recommendation.PerUserScore // 複数 viewer Surface のみ nil 以外
}Strategy 実装は request-scope で new されるため、PreFilter で取得した scenarioMeta / beginnerRatio / playedCountByScenario などを struct field に保持して Aggregate / PostRerank で参照する設計ができる (QuickMatchStrategy / SimilarStrategy がこの形)。
| Surface | Strategy | PreFilter | Aggregate | PostRerank |
|---|---|---|---|---|
| FOR_YOU | ForYouStrategy | Candidates + ExcludeAlreadyPlayed | Identity (matrix[0]) | no-op |
| QUICK_MATCH | QuickMatchStrategy | Candidates + 人数一致 / 全員未プレイ / 無料 / 90分 / release / locale / category | Least Misery + beginner bonus + PerUserScores | MMR |
| SIMILAR | SimilarStrategy | Candidates + self-exclusion / CharacterCount ±2 / 1 人用除外 / locale 積集合 (viewer 0 で skip) / 2 人以上プレイ済み除外 | context scenario × candidate の embedding 内積 (ScenarioSimilarityBatch)、未学習時は viewer 集団 mean にフォールバック | half-half mix (全員未プレイ / 1 人プレイ済みを交互に) |
| EVENT_DETAIL / AUTHOR / COLLECTION | 各 Aggregate 実装 issue で追加 | — | max / topK_mean / mean (spec §3.3) | — |
Surface 固有のフィールド (EventID / AuthorID / GroupUserIDs / ViewerIDs 等) を使う実装は req を concrete 型 (recommendation.QuickMatchRequest / recommendation.SimilarRequest 等) に type assertion してから取り出す。未実装 Surface は Registry.Build で error を返す (panic ではない)。
AlreadyPlayedOracle / QuickMatchMetaStore / QuickMatchConfig は Registry 構築時に注入する依存で、pipeline.Recommender が起動時に 1 回だけ解決する。Played は nil 許容で、nil のとき ForYouStrategy の ExcludeAlreadyPlayed は no-op、QuickMatchStrategy の PreFilter はエラーを返す、SimilarStrategy は played ベースのフィルタを skip する fail-safe 設計。
内部で透過的に扱う事項(インターフェースに出さない)
- Item cold-start(新規シナリオ、ALS
p_s未学習):ScoreMatrixが内部で content embedding にフォールバック(base-scoring §2.3、未実装) - User 部分 cold-start(既存ユーザーだがプレイ数少): fold-in
q̂_uを内部で合成(base-scoring §2.2、未実装) - これらは呼び出し側が判定しない。Scorer インターフェースの一貫性のため実装に閉じ込める
この契約で Base Scoring 実装を差し替える(iALS → hybrid → two-tower 等)時も、Aggregate / PreFilter / PostRerank / resolver / GraphQL は変更不要。
7.3 外部 API (GraphQL)
現状: admin preview 3 本 (FOR_YOU / QuickMatch / SIMILAR) の計 3 query。汎用 recommendedScenarios (client 向け) は MOBL-4275 で削除、quickMatchScenarios (production) は MOBL-4285 で削除済み。admin preview 用 query は MOBL-4277 で Surface 別 input に分割、MOBL-4289 で Quick Match の戻り値型を Recommendation に統合済み (items[].perUserScores に集約前スコア内訳が入る)。SIMILAR は MOBL-4293 で Recommendation を返す query として追加。
extend type Query {
# admin preview 専用 (FOR_YOU)。assignment ログには記録しない。surface は
# 型で FOR_YOU 固定のため入力フィールドに含めない。
adminForYouScenarios(input: AdminForYouInput!): Recommendation!
# admin preview 専用 (Quick Match)。参加者 N 人 (N ≥ 1) の Least Misery 集約
# 結果を返す。SuppressLogging=true + candidates 指定で叩く admin 専用 query。
# items[i].perUserScores に集約前の各参加者スコア内訳が入る。
adminQuickMatchScenarios(input: AdminQuickMatchScenariosInput!): Recommendation!
# admin preview 専用 (SIMILAR)。MOBL-4293 で追加。
adminSimilarScenarios(input: AdminSimilarInput!): Recommendation!
}
input AdminForYouInput {
userId: Int!
k: Int! = 10
candidates: [Int!]
excludeAlreadyPlayed: Boolean! = true
}
type Recommendation {
recommendationId: String!
modelVersion: String!
items: [RecommendedScenario!]!
}
type RecommendedScenario {
scenario: Scenario!
score: Float!
reasons: [String!]!
# 単一 viewer Surface (FOR_YOU 等) では null。複数 viewer Surface (QuickMatch) でのみ
# 集約前の各参加者スコア内訳が入る。
perUserScores: [PerUserScore!]
}
type PerUserScore {
userId: Int!
score: Float!
}他 surface (EventDetail / Author / Collection) の admin query は各 Surface 実装 issue で追加する想定:
adminEventDetailScenarios(input: AdminEventDetailInput!)— EventDetail Surface 実装 issueadminAuthorScenarios/adminCollectionScenarios— 各 Surface 実装 issue
client 向け recommendedScenarios (FOR_YOU / EVENT_DETAIL / AUTHOR / COLLECTION / SIMILAR 汎用) を保留している理由:
- Surface Strategy が整う前に wrapper 型・
recommendationId返却方法・reasons 露出・click ログ送信規約を議論してもズレるだけ - 実需(具体的なモバイル画面)が出てから決める方が、expose の形を誤って固定化しない
adminForYouScenariosは usecase / Base Scoring / Surface Strategy / assignment ログの end-to-end 動作確認 に必要なので admin だけ先に開けている
Quick Match の production client expose は MOBL-4285 で quickMatchScenarios(production) を削除済み: production の集団推薦呼び出しはマッチング成立時に backend が内部で実行し、結果は room state に同期する経路に切り替わった。client が直接 quickMatchScenarios を叩く GraphQL は現状開いていない (admin preview のみ)。
7.4 他 Surface (FOR_YOU / EVENT_DETAIL / AUTHOR / COLLECTION) の client 展開(将来タスク)
各 Surface Strategy が整い reasons の中身が充実した段階で、既存 topRankedScenarios / scenarioscreen/* の 内部実装を recommendation usecase 呼び出しに差し替える 形で client に展開する。Quick Match のように新 query を新設する方針はこれら surface には適用しない (返り値型が既存 [Scenario!]! で済むため)。
| 既存 API | 将来の内部実装差し替え先 |
|---|---|
topRankedScenarios(limit) | recommendation usecase の SurfaceForYou 呼び出し |
scenarioscreen/recommended_for_you.go の各セクション | 各 surface に対応する usecase 呼び出し |
SIMILAR は MOBL-4294 で
Scenario.similarScenarios/Query.similarScenarios(limit, scenarioId, targetUserIDs)の 両 production resolver を pipeline 経由に差し替え済み (旧 Redis cache + タグ類似度ベースは削除)。本節は SIMILAR を対象から外す。
この差し替えは本 issue のスコープ外で、Surface Strategy 整備完了後に別途 issue を切る。
7.5 SLO(base-scoring §3.5 の再掲)
API レイヤーの契約として再掲:
| 対象 | p50 | p95 | p99 |
|---|---|---|---|
| 各 surface の GraphQL Query | < 50 ms | < 150 ms | < 300 ms |
SLO を超える surface は surface 別の in-memory キャッシュや pre-computed top-N 等を検討する(base-scoring §3.5 の escalation path 参照)。
8. 評価指標・A/B 基盤
Base Scoring 導入効果を定量評価できなければ、アルゴリズム改善も継続できない。リリース前に計測枠組みを固定するのが本章の目的。具体値(K 値・劣化閾値・最低サンプルサイズ)は §6 D2 で合意する。
本章のオンライン KPI を「いつでも見られる場所」に落とした観測 surface として、admin の
/recommendation/metricsページを提供している。1 位選択率・MRR・個人 vs グループ一致率・プレイ後 Good 率・assignment 流量・model_version 別 1 位選択率の 6 パネルを期間 / surface / model_version filter 付きで表示する。詳細・saved query は observation.md を参照。quick_match_rooms.recommendation_id(UUID NULL) を経由して BQrecommendation_assignmentsと DB を join するので、「どの 3 件推薦のうちどれが選ばれたか」を後追いで集計できる。
8.1 オフライン評価
学習ジョブ完了時にホールドアウトで自動計測し、前回モデルと比較する。
| メトリクス | K | 用途 |
|---|---|---|
| NDCG@10 | 10 | Primary。順位考慮の相関度(0〜1)。UI 上位(モバイル ForYou 画面のファーストビュー相当)を測る業界標準 |
| Recall@50 | 50 | 候補生成の網羅性。後段 re-ranking の余地を測る |
| HitRate@10 | 10 | 上位 10 件に 1 件でも正解が入るユーザー比率。解釈しやすい補助指標 |
| Coverage | - | 推薦に登場する scenario のユニーク数 / 全 scenario 数。ロングテール対応 |
K=10 は「ForYou 画面のファーストビュー〜1 スクロール目」相当。モバイル UI の実表示件数に合わせた。
Holdout 方針:
- 時間分割: 直近 14 日を test、それ以前を train(リアルな利用シナリオと整合)
- ユーザー分割(cold-start 評価用): 全ユーザーの 5% をランダム選定、学習から除外して別途 NDCG/Recall を算出
- 評価対象: train 期間にプレイ履歴 ≥ 5 件のユーザー(cold-start ノイズを避ける)
8.2 オンライン KPI
| 層 | KPI | 算出式 | ローンチ条件 | 即ロールバック条件 |
|---|---|---|---|---|
| Primary | 推薦経由プレイ完了率 | 推薦 click 後 7 日以内にプレイ完了した数 / assignment 数 | p < 0.05 改善、または CI 下限 > -2%(非劣化容認) | p < 0.05 で -5% 以上劣化 |
| Primary | 推薦経由 Excellent 評価率 | 推薦 click 後 14 日以内に Excellent 評価した数 / assignment 数 | 非劣化(CI 下限 > -5%) | -10% 以上劣化 |
| Secondary | 推薦 CTR | click 数 / assignment 数 | 非劣化目標 | -10% 以上で警告 |
| Secondary | 7 日リテンション | 実験 arm ユーザーの 7 日後再訪率 | 非劣化 | -5% 以上劣化 |
| Guardrail | 推薦経由 Poor 評価率 | Poor 評価した数 / assignment 数 | 悪化 +10% 未満 | +10% 以上悪化 |
| Guardrail | Recommend() p95 レイテンシ | recommend() GraphQL の p95 | < 150ms | 超過 |
| Guardrail | エラー率 | recommend() のエラーレスポンス率 | < baseline × 1.5 | baseline × 2 超 |
運用ルール:
- ローンチ条件はすべて満たす必要あり
- 即ロールバック条件はいずれか 1 つでも満たしたら停止
- Guardrail の劣化時は Primary の改善があってもローンチしない
8.3 A/B テスト方針
Split 戦略:
- ユーザー ID ハッシュで固定割当(セッション間で一貫、離脱時に arm が変わらない)
- surface 別独立 split: FOR_YOU / QUICK_MATCH / SIMILAR_SCENARIO は独立に実験(相互汚染回避)
- 割当ロジック: §8.7 の
AssignExperimentArm()参照
実験期間とサンプルサイズ:
| 項目 | 値 |
|---|---|
| 有意水準 α | 0.05(両側) |
| 検出力 1-β | 0.80 |
| 最低実験期間 | 14 日(曜日変動 7 日 + attribution window 7 日) |
| 最大実験期間 | 56 日(8 週間)超えたら設計見直し |
| 検出効果(MDE) | Primary KPI で +10% relative(ベースライン 5% なら +0.5% absolute) |
| 最低サンプルサイズ | Power Analysis で都度逆算 |
サンプルサイズ逆算式:
n_per_arm = 2 × (z_α/2 + z_β)² × p(1-p) / Δ²
z_α/2 = 1.96 (α=0.05 両側)
z_β = 0.84 (1-β=0.80)
p = ベースライン率(例: 0.05)
Δ = 検出したい絶対効果(例: 0.005)
例: p=0.05、Δ=0.005 → n ≈ 29,800/arm、2 arm で約 60,000 ユーザー段階的 rollout:
| Phase | トラフィック | 期間 | 判定 |
|---|---|---|---|
| Dogfood | 内部 5 人 | 任意 | 手動確認で破綻なし |
| Canary | 5% | 3 日 | Guardrail 監視、異常なければ次へ |
| Expansion | 50% | 14 日 | KPI 比較、ローンチ条件チェック |
| Full rollout | 100% | 14 日観察 | 旧モデル停止、実験クローズ |
P1 期間の基盤: 既存 config 機構を使った簡易 Feature Flag で arm 切替。専用 A/B プラットフォーム(GrowthBook 等)は §4 P3 で導入検討(§8.6)。
ベースライン測定:
- P1 実装の最初の 2 週間で
recommendation_assignments/clicksログ基盤を先行リリースし、現行ranking_rateの CTR / CVR を 1 週間測定してベースラインを確定 - 代替として既存
mobile_events.scenario_viewsから「詳細閲覧 → プレイ完了」の近似 CTR / CVR も参考値として併用
8.4 ロールバック基準
以下のいずれかを満たしたら新モデル・新アルゴを即 revert(§8.2 の「即ロールバック条件」と整合):
| 条件 | 閾値 | 判定タイミング |
|---|---|---|
| オフライン NDCG@10 が前回比劣化 | 5% 以上低下 | 学習ジョブ完了時(swap 前) |
| Primary KPI が旧版比で統計的有意に劣化 | p < 0.05 かつ 5% 以上 relative 低下 | A/B 実験中・終了後 |
| 推薦経由 Poor 評価率の悪化 | +10% 以上 relative | 監視ダッシュボード |
| Recommend() p95 レイテンシ SLO 違反 | 150ms 超 | Cloud Monitoring アラート |
| エラー率 | baseline × 2 超 | Cloud Monitoring アラート |
| モデル出力異常 | NaN / 極端な score 分布 | 学習ジョブ完了時のバリデーション |
8.4.1 P2 以降のモデル間比較
iALS vs BPR / LogMF の切替判定(technology-choices §6.2 と整合):
| 条件 | 行動 |
|---|---|
| オフライン NDCG@10 が +5% 以上改善 | A/B テスト実施 |
| A/B で Primary KPI 有意改善 かつ Guardrail 範囲内 | 切替 |
| それ以外 | 現行モデル継続、ハイパラチューニング優先 |
8.5 計測に必要なデータ
オンライン KPI を計測するには、推薦結果をどこで何位に出したかのログが必須になる。
設計の基本方針
サーバーサイド impression(返却時点で記録)は position bias を持つため採用しない。代わりに クライアント側で VisibilityDetector を使って実際に画面表示された scenario を追跡し、クリック時に「クリックされた scenario + クリック直前まで visible だった scenario 一覧」をセットで送る方式を採る。
これにより:
- Position bias 排除: impression = 「本当に見られたもの」に限定される
- 学習データの質向上: visible-but-not-clicked は「見た上で選ばなかった」弱い negative として将来活用可能(P2 以降)
- 送信コスト削減: スクロール中は送らず、click 時に 1 回だけ
- backend 側 impression ログが不要 or A/B 追跡用の軽量 assignment ログだけで済む
確定スキーマ
既存の mobile_events.scenario_views と同じパターンで以下 2 テーブルを追加。Terraform は infra/terraform/modules/bigquery/ の既存モジュールに追加。
-- A/B 追跡用: backend が Recommend() 実行時に goroutine で非同期 insert(軽量)
CREATE TABLE mobile_events.recommendation_assignments (
recommendation_id STRING NOT NULL, -- UUID、1 Recommend() 呼び出しで 1 行
user_id STRING NOT NULL,
surface STRING NOT NULL,
model_version STRING NOT NULL, -- "ials-v1" 等
experiment_id STRING, -- 実験対象外なら NULL
experiment_arm STRING,
returned_scenario_ids ARRAY<STRING>, -- 返した scenario 一覧(debug / 後追い分析用)
reasons ARRAY<STRUCT<type STRING, value STRING>>, -- scenario_id 順と対応
assigned_at TIMESTAMP NOT NULL
)
PARTITION BY DATE(assigned_at)
CLUSTER BY user_id, surface;
-- クリックログ: クライアントが click 時に visible list 同梱で送る
CREATE TABLE mobile_events.recommendation_clicks (
recommendation_id STRING NOT NULL,
user_id STRING NOT NULL,
clicked_scenario_id STRING NOT NULL,
clicked_position INT64 NOT NULL,
visible_scenarios ARRAY<STRUCT<
scenario_id STRING,
position INT64,
first_visible_at TIMESTAMP
>>, -- click 直前までに画面に出た全 scenario
clicked_at TIMESTAMP NOT NULL
)
PARTITION BY DATE(clicked_at)
CLUSTER BY user_id, clicked_scenario_id;設計判断メモ
| 項目 | 決定 | 理由 |
|---|---|---|
| assignment の生成元 | Go backend の Recommend() | どの arm / model_version でどのユーザーに何を返したかを記録。クリックが無くても残る |
| click + visibility の生成元 | モバイル / Web クライアント | 実 visibility と click を取れるのはクライアントだけ |
| 送信経路 | BigQuery Streaming Insert API (tabledata.insertAll) を直接呼ぶ | 既存 mobile_events.scenario_views と同じ。pub/sub / Dataflow は未導入 |
| backend 側の実装 | goroutine で非同期 insert、Recommend() の応答には待たない | SLO (p95 < 150ms) を守る |
| client 側の実装 | 既存 dart/apps/mobile/lib/helpers/bigquery.dart を拡張。VisibilityDetector で buffer、click 時に flush | アプリ動作には影響させない |
| visibility 判定 | visibleFraction > 0.5 で「見られた」扱い | 一般的な閾値、スクロール中の瞬間的露出を除外 |
| visible buffer のリセット | 画面遷移 or recommendation_id が変わったタイミング | 別の Recommend() 結果が混ざらないようにする |
recommendation_id の粒度 | 1 Recommend() 呼び出しで 1 UUID | 同一リクエストで返す scenario 群は同 ID、position で識別 |
| attribution window | click → play_completed: 7 日、click → Excellent 評価: 14 日 | §8.2 の KPI 算出で使う |
| conversion 紐付け | recommendation_clicks × participants / user_scenario_status を時間窓 JOIN | 冗長テーブルを作らず既存テーブル活用 |
| 保存期間 | 当面無期限、GDPR 退会要請時は個別削除 | 別途データ保持ポリシー策定(本章のスコープ外) |
Flutter 実装スケッチ
class RecommendationImpressionTracker {
final String recommendationId;
final Map<String, VisibleScenarioEntry> _visibleBuffer = {};
void onVisible(String scenarioId, int position) {
_visibleBuffer.putIfAbsent(
scenarioId,
() => VisibleScenarioEntry(
scenarioId: scenarioId,
position: position,
firstVisibleAt: DateTime.now().toUtc(),
),
);
}
Future<void> onClick(String clickedScenarioId, int clickedPosition) async {
await BigQueryHelper.logRecommendationClick(
recommendationId: recommendationId,
clickedScenarioId: clickedScenarioId,
clickedPosition: clickedPosition,
visibleScenarios: _visibleBuffer.values.toList(),
);
}
}
// UI 側
VisibilityDetector(
key: Key('rec_${tracker.recommendationId}_$position'),
onVisibilityChanged: (info) {
if (info.visibleFraction > 0.5) {
tracker.onVisible(scenarioId, position);
}
},
child: ScenarioCard(
onTap: () async {
await tracker.onClick(scenarioId, position);
// navigation
},
),
);学習への活用(段階的)
| Phase | visible-but-not-clicked の扱い |
|---|---|
| P1 | 学習には使わない(未観測扱い)。ただしログは取る |
| P2 以降 | 弱い negative confidence(例: -5〜-10)として iALS に投入。position bias 補正済みの negative として機能 |
P1 から visibility ログを取っておくことで、P2 以降に学習投入の判断ができる。
Streaming Insert 失敗時の扱い
分析用のため失敗は logger に記録するのみで本推薦動作への影響は出さない(既存 BigQueryHelper と同方針)。失敗率 > 0.1% が継続したら監視アラート → pub/sub バッファリング導入を検討。
conversion 集計クエリ例(参考)
-- 推薦経由プレイ完了率(Primary KPI)
WITH assignments AS (
SELECT * FROM mobile_events.recommendation_assignments
WHERE DATE(assigned_at) BETWEEN @start AND @end
),
clicks AS (
SELECT * FROM mobile_events.recommendation_clicks
WHERE DATE(clicked_at) BETWEEN @start AND DATE_ADD(@end, INTERVAL 1 DAY)
),
clicked_with_arm AS (
SELECT a.experiment_arm, a.model_version, c.user_id, c.clicked_scenario_id, c.clicked_at
FROM assignments a
JOIN clicks c USING (recommendation_id)
),
completed AS (
SELECT cw.experiment_arm, cw.model_version, cw.user_id, cw.clicked_scenario_id
FROM clicked_with_arm cw
JOIN app_public.participants p
ON p.user_id = cw.user_id AND p.scenario_id = cw.clicked_scenario_id
WHERE p.finished_at BETWEEN cw.clicked_at AND TIMESTAMP_ADD(cw.clicked_at, INTERVAL 7 DAY)
)
SELECT
experiment_arm,
model_version,
COUNT(DISTINCT (user_id, clicked_scenario_id)) AS completions,
(SELECT COUNT(DISTINCT recommendation_id) FROM assignments) AS total_assignments,
SAFE_DIVIDE(COUNT(DISTINCT (user_id, clicked_scenario_id)), (SELECT COUNT(DISTINCT recommendation_id) FROM assignments)) AS completion_rate
FROM completed
GROUP BY experiment_arm, model_version;スキーマ変更は後方互換を維持(カラム追加のみ、NULL 許容)。
8.6 プロダクト組み込み vs ワンショット分析
A/B 関連要素のうち、「プロダクトに組み込む = バックエンドの恒常機能」と「ワンショット分析 = 実験ごとの人手作業(BigQuery SQL + ダッシュボード)」を明確に切り分ける。GrowthBook / Optimizely 等の専用 A/B プラットフォーム導入は当面不要。
プロダクト組み込み(恒常機能)
継続的に必要なもの・推論時に決定する必要があるもの。
| 要素 | 実装場所 |
|---|---|
| impression / click ログ記録 | backend → BigQuery(mobile_events 相乗り) |
experiment_arm 割当関数(deterministic hash) | usecase/recommendation/experiment.go(AssignExperimentBucket) |
model_version / experiment_id の付与 | 本番経路(FOR_YOU は recommended_for_you.go)→ CommonOptions |
| Feature Flag による arm 切替・ロールバック | AppSetting key recommendation_foryou_experiment(jsonb、60s TTL キャッシュ。再デプロイ不要) |
| オフライン評価指標(NDCG@K / Recall@K)の計算 | 学習ジョブ(Go)内で自動実行、異常検知連動 |
ワンショット分析(実験ごとの人手)
実験のたびに人が判断するもの。BigQuery SQL + 簡易ダッシュボード(Looker Studio / Data Studio)で運用。
| 要素 | 手段 | 頻度 |
|---|---|---|
| Power Analysis(必要サンプルサイズ計算) | Python notebook / スプレッドシート | 実験設計時に 1 回 |
| A/B 結果の集計・可視化 | BigQuery SQL + Looker Studio | 実験期間中 daily |
| 統計検定(t-test / 信頼区間) | notebook or SQL | 実験終了時 |
| 多重検定補正(Bonferroni / BH) | 人手で適用 | 実験終了時 |
| ロールバック最終判定 | Guardrail ダッシュボードを見て SRE / 担当が判断 | 異常時のみ |
進化段階
| Phase | プロダクト組み込み | ワンショット分析 |
|---|---|---|
| P1 | assignment ログ(backend)/ click+visibility ログ(client)/ arm 割当 / Feature Flag / オフライン NDCG 計算 | Power Analysis / 結果集計 SQL / Looker Studio ダッシュボード |
| P3 | 自動 Guardrail 監視・アラート(Cloud Monitoring)/ 自動ロールバック(異常時) | 引き続き実験ごとの検定 |
| P4+ | バンディット基盤(自動最適化)/ 専用 A/B プラットフォーム導入検討 | - |
8.7 P1 で組み込む最小セット
P1 着手時点で backend に実装すべき項目と想定工数:
| 項目 | 行数目安 | 工数 | 実装場所 |
|---|---|---|---|
AssignExperimentArm(userID, experimentID, surface) int | ~30 行 | 1h | usecase/recommendation/experiment.go |
| impression / click ログ記録(gRPC or GraphQL mutation) | ~100 行 | 半日 | usecase/recommendation/log.go + resolver |
BigQuery 永続化(mobile_events パイプライン相乗り) | 設定のみ | 半日 | Terraform / pub-sub |
| Feature Flag による arm 切替 | 既存 config 機構に追加 | 半日 | adapters/config/ |
| 学習ジョブ内の NDCG / Recall 評価と Slack 通知 | ~50 行 Go | 半日 | backend/internal/usecase/recommendation/training/eval.go |
| 合計 | 2〜3 日 |
Power Analysis / 集計 SQL / ダッシュボード構築はプロダクトコード外の作業(データ分析担当の仕事)。
実装 (UZU-4268 で着地)
実コードの user PK は int なので、bucket 割当は recommendation.AssignExperimentBucket として実装した (usecase/recommendation/experiment.go)。
func AssignExperimentBucket(userID int, experimentID string, surface Surface) int {
h := fnv.New32a()
_, _ = h.Write([]byte(experimentID))
_, _ = h.Write([]byte(surface))
_, _ = h.Write([]byte(strconv.Itoa(userID)))
return int(h.Sum32() % 100)
}arm への変換 + ロールアウト比率 + override は ExperimentConfig.ResolveForYouRecipe が担い、可変設定は AppSetting key recommendation_foryou_experiment (jsonb) から 60s TTL キャッシュで読む (再デプロイ無しで段階ロールアウト可)。pipeline の Scorer 選択は CommonOptions.Recipe (baseline / pop_content) 駆動で、surface 特殊ケースを持たない。運用とクエリは observation.md「FOR_YOU A/B 実験」を参照。
重要な特性:
- deterministic(同じ入力なら同じ arm、セッション間で一貫。比率固定中は安定)
experimentIDとsurfaceを hash に混ぜる → 実験ごと・surface ごとに独立 split(過去実験の汚染回避)