Base Scoring(コアモデル)
UZU 推薦の コアモデル(全 Surface 共通の score(u, s) → float を返す学習モデル本体)の仕様。学習データ・iALS 実装・Cold-start・実装インフラをここに集約する。スカラー契約の外部インターフェース、および Surface ごとの集約 (Aggregate)・フィルタ (PreFilter / PostRerank)・API 境界・A/B 計測は spec §3.2 / §3.3 / §3.4 を参照。
INFO
コード詳細は backend/internal/usecase/recommendation/basescoring/ および .../training/ 配下を参照。
1. Base Scoring(コアモデル)
Base Scoring は「user × scenario の関係をスコア化する抽象」であり、内部は複数のサブモデルで構成しうる。重要なのは「外から見て user × scenario のスコアを返す」という抽象を増やさないことであって、内部のサブモデル数ではない。
Base Scoring の外部インターフェース(契約)
score(u, s) : (User, Scenario) → R # スカラー- 内部では
user_vec(u), scenario_vec(s) ∈ R^Dを持ち、スコアは内積<user_vec(u), scenario_vec(s)>として計算される - しかし Aggregate から見えるのは scalar のスコアのみ。ベクトルを持ち回さない
- この契約を固定することで、内部実装(iALS / LightFM / content-embedding / hybrid 等)を入れ替えても Aggregate は書き換え不要になる
サブモデル構成
| サブモデル | 役割 | 入力 | 技術例 |
|---|---|---|---|
| 行動ベース(Behavior) | 既存ユーザー・既存シナリオのスコア。主軸 | プレイ・評価・tipping 等の implicit feedback | iALS / BPR / Matrix Factorization |
| 内容ベース(Content) | コールドスタート(新規シナリオ・新規ユーザー)の補完 | シナリオ説明文・タグ・メタデータ(人数・プレイ時間・価格) | テキスト Embedding、content-aware MF |
行動ベースの入力シグナルと重み
implicit feedback として重み差をつける。値は相対順序であり、iALS なら confidence、BPR なら preference pair のソースとして扱う。初期値として提示し、オフライン指標(NDCG / Recall@K)でチューニングする。
| Signal | Source | 相対重み | 層 | 備考 |
|---|---|---|---|---|
| tipping | tipping | 5.0 | post-play | 金銭コストを払った最強 positive |
| Excellent 評価 | participant_locale_evaluation=Excellent | 3.0 | post-play | プレイ後の明示的高評価 |
| プレイ完了 | participant / solo_events | 2.0 | post-play | 完了した体験(時間コスト) |
| 気になる | user_scenario_status=気になる | 1.5 | pre-play | 能動タップ。約 10 件/人の密度があり medium-strong |
| favorite_review | favorite_review | 1.0 | pre/post | レビューへの共感 |
| QuickMatch 選択 | quick_match_participants=voted | 1.0 | pre-play | QM 候補3つから選んだ作品。プレイ前選好 |
| 詳細閲覧(P1 以降) | 行動ログ | 0.3 | pre-play | 受動的関心。行動ログ基盤の整備後に追加 |
| QuickMatch 非選択 | quick_match_scenario_candidates(自票時の非選択) | -0.3 | pre-play | 提示されたのに選ばなかった候補。露出で自己正規化し人気の単純増幅を避ける |
| Poor 評価 | participant_locale_evaluation=Poor | -2.0 | post-play | 不満の明示 |
Negative signal(Poor 評価)の扱い
Poor 評価は「プレイ完了した上で合わなかった」という最も情報量の多い negative signal。
UZU では Go 自前実装の iALS に negative confidence として直接入力する。標準 Hu 2008 iALS を dislike 対応に拡張する修正(Poor を p_ui=0 + 高 confidence で学習、もしくは negative confidence を直接受け入れる形)は数十行のコード修正で実装可能で、重み表(下記)の負値をそのまま confidence 行列のセルに入れる設計を取る。
| Phase | 本番モデル | 並行評価 |
|---|---|---|
| P1 (MVP) | iALS + negative confidence(Poor を負の confidence として学習) | - |
| P2 | iALS 継続 | BPR / LogMF をオフライン学習、NDCG@K で比較 |
| P2 後半以降 | 有意差で勝ったモデルに切替 | - |
素の Hu 2008 iALS は positive-only 前提だが、dislike 対応の拡張はよく知られた派生。Netflix の thumbs down 活用や Spotify の LogMF など、明示的 negative がある領域での業界標準は iALS 系 or LogMF(BPR ではない)。選定経緯・Go 自前実装を選んだ理由・検証戦略は technology-choices 参照。
2 つのサブモデルの統合方式
行動ベースと内容ベースを 1 つの Base Scoring 抽象として束ねる方式として以下を検討する。どちらを採っても Base Scoring は 1 本のまま。
| 統合方式 | 内容 |
|---|---|
| 同一空間に射影 | hybrid MF で 1 モデルに統合(例: LightFM) |
| 独立学習+スコア合成 | 別々に学習し、出力段階で α × behavior + (1-α) × content を合成 |
UZU では独立学習+スコア合成を採用する(technology-choices 参照)。
運用
- 保存: Redis にベクトル格納、推論時は内積スコア
- 更新: 日次バッチ(既存の nightly_batch_process に乗せる)
- 内容ベース側は新作シナリオ公開時に即時 embedding 生成(プレイデータが 0 でもスコアが出る状態を担保)
2. Cold-start 戦略
Base Scoring (iALS) は行動履歴から潜在ベクトルを学習するため、履歴が無いユーザー・シナリオではスコアが縮退する。この Cold-start 期の扱いを明示する。
2.1 2 種類の Cold-start
| 種類 | 問題 | Base Scoring が効かない理由 |
|---|---|---|
| User cold-start | 新規ユーザーに何を出すか | q_u が学習できない |
| Item cold-start | 新作シナリオがおすすめに出ない | p_s / b_s が学習できない |
2.2 User Cold-start
ALS の予測式から user 情報を「平均ユーザー」で代替する。これが新規ユーザー向けの非パーソナライズランキング(= ranking_rate の後継)となる:
score(new_user, s) = μ + b_s + <q̄, p_s>μ: グローバル平均b_s: scenario bias(自動学習される scenario-only スコア)q̄: 全アクティブユーザーのq_u平均p_s: scenario 潜在ベクトル
b_s と q̄ は日次バッチで事前計算されるため、scenario 全件のスコアは定数時間で計算可能(= 現行 ranking_rate と同等の軽さ)。
セグメント別 q̄
q̄ を locale ごとに計算しておけば、新規ユーザーでもセグメントベースの個別化が可能:
| セグメント | q̄ の計算対象 |
|---|---|
| locale (ja / ko / zh-CN / zh-TW / en) | 当該 locale ユーザーの q_u 平均 |
年齢層・地域を別軸で切る案も検討したが、プライバシー/属性収集負担と効果の割に合わないため locale のみで開始する。オーバーヘッドは scenario 数 × locale 数の配列だけで軽量。
段階的 fold-in
プレイや「気になる」が数件たまったら、ALS 再学習を待たず即時にパーソナライズ:
q̂_u = mean({ p_s | s ∈ ユーザー u のプレイ済み or 気になる })オンボーディング時に「好きなタグを 3 つ選んで」→ 選ばれたタグの代表シナリオ群から q̂_u を合成する手法も同じ機構。
2.3 Item Cold-start
新作シナリオは行動履歴がないため p_s / b_s が学習できない。§1 の内容ベースサブモデルで対応する:
p_s_content= シナリオの title / description / author_comment / タグを LLM embedding- 行動データが閾値(例: 20 プレイ)に達するまでは
p_s_contentをそのままp_sとして使用 - 閾値超え後は
α × p_s_ALS + (1 - α) × p_s_content、αはプレイ数に応じて 0 → 1 に遷移
2.4 ranking_rate の移行マッピング
Base Scoring / Aggregate / Context Gate 完成後、現行 ranking_rate は廃止する。各要素の移行先は以下:
| 旧要素 | 実態 | 移行先 |
|---|---|---|
ratingFactor | 評価の集計 | Base Scoring: ALS の b_s に吸収 |
playCountFactor | プレイ数集計 | Base Scoring: ALS の b_s に吸収 |
playTimeFactor | 平均プレイ時間 | Base Scoring: ALS の入力重みに反映 + b_s に吸収 |
daysBonus | 直近 21 日ブースト | Aggregate: recency decay として合成段階で乗算 |
priceBonus | 価格ブースト | PostRerank: ビジネスブースト(有料誘導ルール) |
廃止後の非パーソナライズランキング(一覧の初期表示・新規ユーザー向けフォールバック)は §2.2 の scenario-only スコアで代替する。利点:
- 係数の恣意性が消える(学習ベース)
- 言語・地域・年齢セグメント別の出し分けが可能
- プレイ数が蓄積した時点で自動的にパーソナライズに切り替わる(fold-in)
2.5 Explore / Exploit
Cold-start 期は多様性ブースト(PostRerank の MMR 再ランキング)を強めに効かせ、嗜好探索を促す。プレイ履歴が蓄積するにつれて多様性重みを下げる。
3. 実装インフラ
Base Scoring (iALS) を含む本設計を実運用に落とし込むためのインフラ構成を定める。UZU 規模(scenarios 16.8K / users 356K / D=128)を前提にした決定であり、将来スケールが変わったタイミングで各選択肢を再評価する。
3.1 全体構成
┌──────────────────────────────────────────────────────────────┐
│ BigQuery (app_public) │
│ participants / solo_events / reviews / user_scenario_status│
│ / tipping / favorite_reviews ... │
└──────────────┬───────────────────────────────────────────────┘
│ 日次抽出
▼
┌──────────────────────────────────────────────────────────────┐
│ GKE CronJob (Go + 自前 iALS 実装) │
│ weighted confidence 行列構築 → iALS 学習 │
│ → user_embeddings / scenario_embeddings / b_s / q̄ を出力 │
└──────────────┬───────────────────────────────────────────────┘
│ UPSERT (トランザクション)
▼
┌──────────────────────────────────────────────────────────────┐
│ AlloyDB (pgvector 拡張) │
│ scenario_embeddings (vector(128)) │
│ user_embeddings (vector(128)) │
│ scenario_bias / segment_q_bar │
└──────────────┬───────────────────────────────────────────────┘
│ 起動時に scenarios 全件ロード / user は on-demand
▼
┌──────────────────────────────────────────────────────────────┐
│ Go バックエンド (GraphQL / gRPC) │
│ Recommend() usecase │
│ in-memory scenarios + user vector fetch + 内積計算 │
│ Aggregate 合成 + PreFilter / PostRerank フィルタ │
└──────────────────────────────────────────────────────────────┘3.2 学習ジョブ
| 項目 | 決定 | 理由 |
|---|---|---|
| 実行環境 | GKE CronJob | 既存 nightly_batch_process と同一運用パターン。VPC 内で AlloyDB / BigQuery に高速アクセス |
| 言語 | Go | UZU バックエンドと統一。Python toolchain / Docker image を増やさない |
| 学習ライブラリ | 自前実装(gonum/mat 利用) | ~500 LOC。negative confidence・per-feedback 重み・fold-in を仕様通り完全制御 |
| 実行頻度 | 日次(既存 nightly batch の枠で実行) | 行動の反映ラグは 1 日で十分。cadence を上げるコストに見合う効果は Base Scoring 導入初期では期待薄 |
| 想定所要時間 | ~30-60 分(CPU 4 コア / メモリ 1GB) | 実測 (2026-04、270K users × 3,587 scenarios × 128 factors × 15 iter) で 33 分。gonum 純 Go LAPACK のため CGO なしで single-thread Cholesky が支配項 |
| 実行ノード | 非 spot(eviction 耐性) | 途中停止で学習結果破棄 → 翌日まで embedding が古いまま動く事故を避ける |
| 同時実行 | concurrencyPolicy: Forbid | 前日 job が翌日まで残った場合の二重起動防止 |
| データ取得 | Go から BigQuery クライアントで直接クエリ | 既存 Go 基盤と統一、中間ストレージ不要 |
| BQ 読み取り元 | 全環境で uzu-prd.app_public(cross-project read) | dev/stg は prd 匿名化 dump を毎日 import するため ID が一致。環境別に Datastream を持つと daily DB reset で replication slot が毎日失われ運用破綻。詳細は 環境戦略 |
| BQ 書き込み先 | 各環境の mobile_events.recommendation_assignments | 行動ログは環境別。A/B 割付や click ログが混ざらないように |
| pgvector 書き込み先 | 各環境の AlloyDB/CloudSQL | 学習成果物は環境別に持つ |
3.3 ベクトル保存
AlloyDB に pgvector 拡張を有効化(CREATE EXTENSION vector;)し、scenario / user の双方を同一基盤で管理する。
scenario_id / user_id は ent の int PK と直結させる。複数 version を同時保有できるように (id, version) 複合 PK を採用し、rollback を embedding の再学習なしで実行可能にする。
CREATE TABLE scenario_embeddings (
scenario_id INTEGER NOT NULL,
version TEXT NOT NULL,
vector VECTOR(128) NOT NULL,
bias REAL NOT NULL DEFAULT 0, -- b_s
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (scenario_id, version)
);
CREATE INDEX ON scenario_embeddings (version);
CREATE TABLE user_embeddings (
user_id INTEGER NOT NULL,
version TEXT NOT NULL,
vector VECTOR(128) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, version)
);
CREATE INDEX ON user_embeddings (version);
CREATE TABLE segment_q_bar (
segment_key TEXT PRIMARY KEY, -- 例: "locale:ja", "locale:ko", "locale:zh-CN"
vector VECTOR(128) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);vector(128)型で保持。将来 ANN に切り替える場合もCREATE INDEX ... USING hnswを追加するだけでスキーマ変更不要- 初期は ANN インデックスを張らない(線形スキャンで十分、scenarios 100 万件超えた時点で HNSW / ScaNN 導入を再検討。判断経緯は technology-choices 参照)
- グローバル平均
μと全ユーザーq̄はアプリ設定テーブルまたは同様のシンプルな KV に保持
3.4 推論
Go バックエンドに Recommend() usecase を実装する。
- scenario ベクトル: Go プロセス起動時に
scenario_embeddings全件をロード(16.8K × 128 × 4byte ≈ 8.6MB)、日次で reload - user ベクトル: リクエスト時に AlloyDB から 1 件取得(
WHERE user_id = $1の primary key lookup、1〜3 ms) - 内積計算: SIMD 対応の float 演算(
gonumor 手書き AVX)で 16.8K 件を 2 ms 程度で走査 - Aggregate 合成・Context Gate フィルタ: 内積計算で得た top-N に対して適用
冗長化として、Go 側が異常(embedding ロード失敗等)の場合は pgvector SQL 経由(ORDER BY user_vec <#> scenario_vec LIMIT N)に fallback できるようにしておく。
3.5 レイテンシ予算・SLO
| 対象 | p50 | p95 | p99 |
|---|---|---|---|
| Base Scoring 内部処理(user fetch + 内積 + top-K) | < 10 ms | < 20 ms | < 50 ms |
| PreFilter(既プレイ除外等の DB lookup 含む) | < 5 ms | < 15 ms | < 30 ms |
| Recommend() GraphQL エンドポイント全体 | < 50 ms | < 150 ms | < 300 ms |
SLO を満たせなくなった場合の escalation path:
- 既プレイ除外の bitmap/bloom filter キャッシュ導入(最もありうるボトルネック)
- pgvector + HNSW/ScaNN の導入
- scenario 全件 in-memory を surface 別に絞る(例: 同言語のみロード)
3.6 運用
学習成果物の hot-swap とロールバック
- 学習ジョブは新 version 行を
scenario_embeddings/user_embeddingsに 追加 で INSERT (既存 version と同居)、同時にrecommendation_model_versions.activated_atに NOW() を入れる。トランザクション内で完結 - Recommender は読み取り時に
WHERE version = (SELECT version FROM recommendation_model_versions WHERE activated_at IS NOT NULL ORDER BY activated_at DESC LIMIT 1)で 常に最新 activated version のみ 参照 - Go バックエンドは
ScenarioCacheが 30 分周期で reload、activated 切替が反映される - ロールバック:
UPDATE recommendation_model_versions SET activated_at = NULL WHERE version = '<対象>'で 1 世代前の activated が最新とみなされ、再学習なしで数十秒以内に反映 - GC: 日次学習の最後に
training.PruneOldVersionsが走り、activated な直近training.KeepVersions(5) 世代だけを残してscenario_embeddings/user_embeddingsから古い version を削除する。ロールバックは 1 世代ずつ遡る操作なので、5 世代あれば 4 回連鎖できる- 削除対象は activated な version に限定する。推論は
activated_at IS NOT NULLの最新行しか読まないため、activated でない version は serving からもロールバックからも到達できない - 削除は世代ごとに 1 文ずつ、古い順に実行する。1 トランザクションのサイズを 1 世代 (ユーザー数ぶん) に抑え、時間予算を使い切っても新しい世代側が必ず残るようにするため
- GC の失敗は学習ジョブを落とさない。その時点で当日のモデルは commit 済みで、GC は次回実行が引き継げる
scenario_content_embeddingsは対象外。version が日付ではなく「embedding モデル名 + recipe 名」で、content_hash差分更新により世代が増えない
- 削除対象は activated な version に限定する。推論は
recipe(手法軸)による A/B 整理
version(学習時刻のタイムスタンプ)は「どのデータ世代か」しか表さない。手法(特徴量セット)を変えても次の nightly が別タイムスタンプを吐くだけなので、offline NDCG の時系列を見たとき「データ変化による段差」と「手法変化による段差」を切り分けられない。
これを分離するため recommendation_model_versions に recipe TEXT NOT NULL DEFAULT 'baseline' 軸を持つ。
- recipe は「今このコードに encode されている学習手法」の識別ラベル。中身(
signalQuery等)はコードにハードコードされているため、ラベルも env ではなく コード内定数training.CurrentRecipeとして持ち、手法を変える PR ではsignalQueryと同じコミットでこの値を bump する。これで recipe が常にコードの実態と同期し、version-controlled かつ review 可能になる - 命名規約:
baseline/qm_choice/content_v2のような短い snake_case。過去 recipe は行として残るので使い回さず単調に増やす - これにより offline metrics 履歴を recipe で grouping して手法効果を時系列分離できる(
SELECT recipe, version, metrics->>'ndcg_at_10' FROM recommendation_model_versions ORDER BY trained_at) - スコープ: 本軸は ラベル付けのみ。swap 判定(
DecideSwap)は従来どおり「直近 activated との NDCG 比較」で recipe 非依存。recipe 単位の同一 holdout 並行評価(shadow A/B)や online の arm 振り分けは後続。RunConfig.Recipeはその shadow run / test 用の override seam として残してある
新規シナリオの即時対応
- 行動ベース学習は日次バッチなので、新規シナリオは翌日まで Base Scoring に登場しない
- §2.3 の内容ベースサブモデル(Phase 2)導入後は、公開時点で
p_s_contentを生成して即座にスコアが出せる状態を担保する - Phase 1 期間中は、新規シナリオは既存の新着枠(
scenarioscreen/recommended_for_you.go等)で露出を担保する
モデル異常検知
- 学習ジョブの完了時に オフライン指標(NDCG@10 / Recall@10) をホールドアウトで計算し、前回比で X% 以上劣化していたら:
- 新 embedding の swap を中止
- Slack 通知(
composite-actions/failure-slack-notificationを流用) - 人手で原因調査
セグメント q̄ の更新
- 日次バッチで locale ごとに
q_u平均を計算し、segment_q_barテーブルに UPSERT - Go バックエンドは user の locale から該当 segment を選び、cold-start スコア
μ + b_s + <q̄_seg, p_s>を計算
3.7 コスト見積もり
| 項目 | 想定 | 備考 |
|---|---|---|
| GKE CronJob 追加リソース | 実質ゼロ増 | 既存クラスタの空きノードで実行、数分で完了 |
| AlloyDB ストレージ追加 | 約 1.5 GB (5 世代保持時) | user_embeddings が支配的で 1 世代あたり約 30 万行 / 約 300 MB (pg_dump のテキスト表現で実測)。scenario_embeddings は 1 世代 4K 行、segment_q_bar は数 KB。世代を GC しないと日次でこの分だけ積み上がる (§3.6 GC) |
| AlloyDB pgvector 拡張 | 無料 | 拡張有効化に追加料金なし |
| BigQuery クエリ課金 | 月数百〜数千円 | 日次 1 回の学習データ抽出クエリ |
Phase 1 完成時点で追加インフラコストは実質ネグリジブル。
4. 学習データ定義
iALS 学習用の implicit feedback 行列をどう作るかの方針。完全な SQL は実装時に詰めるが、何をソースにするか・どう重み付けするかはここで合意する。
4.1 データソース
BigQuery uzu-prd.app_public.* を唯一のソースとする。app_public は本番 Postgres のレプリカなので常に最新性が担保される。
| テーブル | 用途 | 重み |
|---|---|---|
participants + participant_locale_evaluation | プレイ完了・評価 | プレイ=2.0、Excellent=3.0、Poor=-2.0 |
solo_events | ソロプレイ完了 | プレイ=2.0 |
reviews | レビュー投稿(書く行為) | 2.0(プレイ完了と同等扱い) |
favorite_reviews | レビューへの共感 | 1.0 |
user_scenario_status | 気になる・プレイ済み | 気になる=1.5 |
tipping | 投げ銭 | 5.0(最強 positive) |
quick_match_participants(voted_scenario_id) | QM で選んだ候補(positive)。user は quick_match_waiting_users 経由で解決 | 1.0 |
quick_match_scenario_candidates(自票時の非選択候補) | QM で提示されたのに選ばなかった候補(弱 negative)。投票者のみ・自分が選んだ候補は除外 | -0.3 |
scenario_views(mobile_events) | 詳細画面閲覧 | 0.3(P1 以降) |
重み値は §1 の表と整合。チューニングは spec §6 D8。
4.2 学習データ構築の擬似コード(iALS + negative confidence)
iALS は (user, item, confidence) のスパース行列を入力とする。UZU では Poor を負の confidence として入れることで dislike を学習させる。
// シグナル重み(§1 と対応)
var weights = map[SignalType]float32{
SignalTipping: 50,
SignalExcellent: 30,
SignalPlay: 20,
SignalFavoriteReview: 10,
SignalFavoriteStatus: 15, // 気になる
SignalQuickMatchVoted: 10, // QM で選んだ候補
SignalView: 3,
SignalQuickMatchShown: -3, // QM で提示されたが選ばなかった候補(弱 negative)
SignalPoor: -30, // 明示的 negative(負の値)
}
// (userIdx, scenarioIdx) → confidence を集計
type Key struct{ User, Scenario int32 }
confidence := make(map[Key]float32)
for signalType, weight := range weights {
rows, _ := loadSignal(ctx, signalType) // BigQuery から stream
for _, r := range rows {
key := Key{r.UserIdx, r.ScenarioIdx}
confidence[key] += weight * float32(r.Count) * timeDecay(r.Timestamp)
}
}
// CSR sparse matrix を構築(負の値も含む)
var rows, cols []int32
var data []float32
for k, c := range confidence {
if c == 0 {
continue // Poor と positive が相殺して 0 になった pair は除外
}
rows = append(rows, k.User)
cols = append(cols, k.Scenario)
data = append(data, c)
}
matrix := sparse.NewCSR(nUsers, nScenarios, rows, cols, data)
// iALS 学習(自前実装)
model := ials.New(ials.Config{
Factors: 128,
Regularization: 0.01,
Iterations: 50,
Jobs: 8, // goroutine 並列度
})
model.Fit(ctx, matrix)実装方針:
- 標準 Hu 2008 iALS を拡張し、confidence が負のセルは
p_ui = 0+ 高 confidence(= 「この user はこの item を好まないと強く確信」)として解釈 - gonum/mat で Cholesky 解、goroutine で user/item 因子更新を並列化
- 重みの具体値・相対倍率・α は spec §6 D8 でチューニング
4.3 プレイ完了の定義
| ソース | 条件 |
|---|---|
participants | finished_at IS NOT NULL かつ finished_at - started_at > 30 分 |
solo_events | completed_at IS NOT NULL |
30 分閾値の根拠: マーダーミステリーの最短シナリオが 30-60 分で、これ以下で終了しているのは「途中離脱」の可能性が高い。spec §6 で見直し候補。
INFO
この定義は 学習・集計用 (confidence 計算等)。 PreFilter で使う「プレイ済み除外 (ネタバレ回避)」は user_scenario_status.PLAYED を単一ソースとする別軸の定義 (spec §3.4 参照)。finished_at ベースは進行中放置や canceled_at 入りレコードを取りこぼすため、ネタバレ除外には使わない。
4.4 Time decay
P1 から exponential decay + 5 年 cutoff を採用する。
def time_decay(event_time, now, tau_days=365):
age_days = (now - event_time).days
if age_days > 365 * 5:
return 0.0 # 5 年超は除外
return math.exp(-age_days / tau_days)
# §4.2 の confidence 計算時に掛ける
confidence[(user, scenario)] += WEIGHTS[signal] * count * time_decay(event_time, now)パラメータ:
- τ = 365 日(half-life ≈ 253 日)。直近の嗜好に追随しつつ、古い強シグナルも部分的に残す
- 5 年 cutoff: それ以上古い行動は除外。計算量削減とノイズ排除を兼ねる
採用理由:
- マダミスはユーザーの趣味が進化するジャンル(初心者→中級者→上級者)で、直近の嗜好に重みを置く価値が大きい
- 現行
ranking_rateは直近 21 日の極端な decay で新作偏重だった。τ=365 日は大幅緩和で、中長期の嗜好も捉える - 3 年前の Excellent 評価は 0.05 倍程度の重みで残るので、強シグナルなら生きる
BigQuery 実装例:
SUM(weight * EXP(-DATE_DIFF(CURRENT_DATE(), event_date, DAY) / 365.0))
WHERE event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 5 YEAR)τ のチューニングは spec §6 D8(重みテーブルチューニング)の一部として扱う。
4.5 退会ユーザー・削除シナリオの扱い
| 対象 | 学習入力 | 推論時 |
|---|---|---|
退会ユーザー(users.deleted_at IS NOT NULL) | 残す(他ユーザーとの関係性に寄与) | 推薦結果から除外 |
削除シナリオ(scenarios.deleted_at IS NOT NULL) | 除外 | 推薦対象から除外(PreFilter) |
| ブロックユーザー | 通常扱い | 推薦結果から除外(既存 user_blocking_authors / user_blocking_scenarios) |
| 非公開シナリオ | 除外 | 推薦対象から除外 |
4.6 Negative feedback (Poor 評価) の扱い
P1 は iALS + negative confidence を本番採用し、Poor を負の confidence として学習する。
iALS における negative confidence の仕組み
UZU の自前 iALS 実装は sparse matrix に負の値を入れることで dislike を表現する:
- sparse matrix のセル値
c_{ui}が正 → 「ユーザー u はアイテム i を好む」と学習(通常の positive、p_ui = 1) c_{ui}が負 → 「ユーザー u はアイテム i を好まない」と学習(dislike、p_ui = 0+ 高 confidence|c_{ui}|で解釈)- セルが空(未観測) → 低 confidence の 0(通常の implicit feedback、
p_ui = 0+ confidence 1)
これは Hu 2008 の iALS 論文を dislike 対応に拡張したもので、§4.2 の擬似コード通り tipping=+50, Excellent=+30, play=+20, ..., Poor=-30 のように重みづけて合成すれば、Poor の情報が学習にそのまま入る。
なぜ BPR ではなく iALS か
- iALS (= 重み付き MF) は (preference, confidence) の枠組みで正例・弱正例・弱負例・強負例・未観測を統一表現できる
- BPR は素の形では「正例 > 未観測」しか表現できず、明示的 negative を扱うには pairwise 設計の拡張が必要
- UZU のように明示的 negative(Poor / block)を持つサービスでは iALS 系が自然
参考: Netflix の thumbs down は iALS 系の重み付き MF で直接反映、Spotify は LogMF(確率モデル)で skip を扱う。業界の明示的 negative 対応は iALS 系 or LogMF が主流。
P2 での比較評価
P1 運用しながら、BPR / LogMF をオフラインで並行学習し、NDCG@K / Recall@K / Coverage を iALS と比較する。有意差で勝ったモデルがあれば切替検討(spec §6 D5)。
4.7 学習データのボリューム感
2026-04 時点の BigQuery 実測ベース:
| シグナル | 件数 | 密度(1 ユーザー平均) |
|---|---|---|
participants | 318 万 | 8.9 |
solo_events | 20.7 万 | 0.58 |
reviews | 25.3 万 | 0.71 |
favorite_reviews | 20.9 万 | 0.59 |
user_scenario_status(気になる) | 370 万 | 10.4 |
tipping | (要確認) | - |
合計で 1 ユーザーあたり平均 20 件以上のシグナルがあり、iALS 学習に十分な密度。scenarios 16.8K × users 356K の行列は疎(sparsity ≈ 99.6%)だが、重み付き MF にとっては典型的なサイズ感。