Skip to content

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 feedbackiALS / BPR / Matrix Factorization
内容ベース(Content)コールドスタート(新規シナリオ・新規ユーザー)の補完シナリオ説明文・タグ・メタデータ(人数・プレイ時間・価格)テキスト Embedding、content-aware MF

行動ベースの入力シグナルと重み ​

implicit feedback として重み差をつける。値は相対順序であり、iALS なら confidence、BPR なら preference pair のソースとして扱う。初期値として提示し、オフライン指標(NDCG / Recall@K)でチューニングする。

SignalSource相対重み層備考
tippingtipping5.0post-play金銭コストを払った最強 positive
Excellent 評価participant_locale_evaluation=Excellent3.0post-playプレイ後の明示的高評価
プレイ完了participant / solo_events2.0post-play完了した体験(時間コスト)
気になるuser_scenario_status=気になる1.5pre-play能動タップ。約 10 件/人の密度があり medium-strong
favorite_reviewfavorite_review1.0pre/postレビューへの共感
QuickMatch 選択quick_match_participants=voted1.0pre-playQM 候補3つから選んだ作品。プレイ前選好
詳細閲覧(P1 以降)行動ログ0.3pre-play受動的関心。行動ログ基盤の整備後に追加
QuickMatch 非選択quick_match_scenario_candidates(自票時の非選択)-0.3pre-play提示されたのに選ばなかった候補。露出で自己正規化し人気の単純増幅を避ける
Poor 評価participant_locale_evaluation=Poor-2.0post-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 として学習)-
P2iALS 継続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 に高速アクセス
言語GoUZU バックエンドと統一。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 の再学習なしで実行可能にする。

sql
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 演算(gonum or 手書き 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 ​

対象p50p95p99
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:

  1. 既プレイ除外の bitmap/bloom filter キャッシュ導入(最もありうるボトルネック)
  2. pgvector + HNSW/ScaNN の導入
  3. 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 差分更新により世代が増えない

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% 以上劣化していたら:
    1. 新 embedding の swap を中止
    2. Slack 通知(composite-actions/failure-slack-notification を流用)
    3. 人手で原因調査

セグメント 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 を学習させる。

go
// シグナル重み(§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 プレイ完了の定義 ​

ソース条件
participantsfinished_at IS NOT NULL かつ finished_at - started_at > 30 分
solo_eventscompleted_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 を採用する。

python
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 実装例:

sql
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 ユーザー平均)
participants318 万8.9
solo_events20.7 万0.58
reviews25.3 万0.71
favorite_reviews20.9 万0.59
user_scenario_status(気になる)370 万10.4
tipping(要確認)-

合計で 1 ユーザーあたり平均 20 件以上のシグナルがあり、iALS 学習に十分な密度。scenarios 16.8K × users 356K の行列は疎(sparsity ≈ 99.6%)だが、重み付き MF にとっては典型的なサイズ感。