"""SteamFit — 취향+의도 하이브리드 게임 추천 (HF Spaces). 라이트 테마.
탭: 추천(취향+의도 + 추론 흐름 + 2D맵) / 학습 과정(에폭 슬라이더 + 구조도).
모델 인코더는 별도 Hub 저장소(mininiming/steamfit-encoder)에서 로드.
"""
import json
from collections import Counter
from pathlib import Path
import gradio as gr
import numpy as np
import pandas as pd
import plotly.graph_objects as go
ROOT = Path(__file__).resolve().parent
# ── 색상 팔레트 (warm neutrals + 가독용 다크 텍스트) ───────────────
BG = "#f5ebe0" # linen (페이지 배경)
CARD = "#e3d5ca" # powder-petal (카드/표면)
PARCH = "#edede9" # parchment (입력칸/보조 표면)
ACCENT = "#d5bdaf" # almond-silk (버튼/강조)
MUTE = "#d6ccc2" # dust-grey (테두리/구름)
TEXT = "#3a332c" # 다크 에스프레소 (주요 글자)
TEXT2 = "#6f6253" # 미디엄 브라운 (보조 글자)
HILITE = "#bc6c25" # 강조 포인트(추천/칩) — 밝은 배경 대비용
LIKED = "#5a4632" # 즐긴 게임 마커(다크 브라운)
# 데이터 시각화용 장르 색(밝은 배경에서 구분되는 어스톤)
GENRE_PAL = ["#9c6644", "#6b705c", "#bc6c25", "#7f5539", "#a5a58d", "#5c6b73",
"#8a5a44", "#606c38", "#9d8189", "#7d6b5d", "#937341"]
games = pd.read_parquet(ROOT / "games_lookup.parquet")
NAME = dict(zip(games["appid"], games["name"]))
GENRE = dict(zip(games["appid"], games["genres"]))
POP = dict(zip(games["appid"], games["recommendations_total"]))
_map = pd.read_parquet(ROOT / "map2d.parquet")
MX = dict(zip(_map["appid"], _map["x"]))
MY = dict(zip(_map["appid"], _map["y"]))
collab = np.load(ROOT / "item2vec_emb.npy")
cids = pd.read_csv(ROOT / "item2vec_appids.csv")["appid"].tolist()
c_row = {a: i for i, a in enumerate(cids)}
content = np.load(ROOT / "game_emb.npy")
tids = pd.read_csv(ROOT / "game_emb_appids.csv").iloc[:, 0].tolist()
t_row = {a: i for i, a in enumerate(tids)}
cand = [a for a in cids if a in t_row]
collab_c = np.stack([collab[c_row[a]] for a in cand])
collab_n = collab_c / (np.linalg.norm(collab_c, axis=1, keepdims=True) + 1e-9) # 코사인용 정규화
content_c = np.stack([content[t_row[a]] for a in cand])
cand_idx = {a: i for i, a in enumerate(cand)}
# 공동플레이(co-occurrence) top-K 이웃 — 하이브리드 취향(RRF)용
# eval_taste.py 측정: RRF(item2vec+cooc) 취향 R@10 0.223 vs item2vec 단독 0.172
_ck = np.load(ROOT / "cooc_topk.npz")
_ck_appids = _ck["appids"].astype(np.int64) # cooc-row → appid
_ck_nb_appid = _ck_appids[_ck["nb"]] # [rows × K] 이웃 appid
_ck_wt = _ck["wt"] # [rows × K] 공동플레이 가중치
_cooc_row = {int(a): i for i, a in enumerate(_ck_appids)}
def _ranks(s):
"""점수 내림차순 순위(1=최고). RRF 융합용."""
o = np.argsort(-s)
r = np.empty(len(s), np.float32)
r[o] = np.arange(1, len(s) + 1, dtype=np.float32)
return r
# 학습 과정 데이터(에폭별 임베딩 스냅샷)
TRAIN = json.loads((ROOT / "training_frames.json").read_text(encoding="utf-8"))
N_EPOCHS = len(TRAIN["frames"]) - 1
_clusters = TRAIN.get("clusters") or [0] * len(TRAIN["names"])
_gcolor = [GENRE_PAL[c % len(GENRE_PAL)] for c in _clusters] # 색 = 협업 '이웃 그룹'(KMeans)
# 파라미터 플레이그라운드용 소형 학습 데이터(상위 400게임 공동플레이 쌍) — 실시간 CPU 학습용
_pg = np.load(ROOT / "playground.npz", allow_pickle=False)
PG_PAIRS = _pg["pairs"]
PG_NAMES = [str(x) for x in _pg["names"]]
PG_CLUSTERS = _pg["clusters"].tolist()
PG_N = len(PG_NAMES)
_encoder = None
def encoder():
global _encoder
if _encoder is None:
from sentence_transformers import SentenceTransformer
_encoder = SentenceTransformer("mininiming/steamfit-encoder")
return _encoder
def _norm(v):
return (v - v.min()) / (v.max() - v.min() + 1e-9)
def _genre_list(a):
try:
return json.loads(GENRE.get(a) or "[]")
except Exception:
return []
def _genres(a):
return ", ".join(_genre_list(a)[:3])
def _reason_badges(a, r, cooc_src, liked_genres, intent, intent_s, intent_hi):
"""추천 카드의 '왜 이 게임인지' 근거 칩 — 공동플레이·공유장르·의도부합."""
bs = []
src = cooc_src.get(r)
if src:
bs.append(f'🎮 {NAME.get(src[0], src[0])} 플레이어가 함께 즐김')
rg = _genre_list(a)
shared = [g for g in rg if g in liked_genres][:2]
if shared:
bs.append(f'🏷 {", ".join(shared)} 취향 일치')
elif not liked_genres and rg:
bs.append(f'🏷 {", ".join(rg[:2])}')
if intent and intent_s is not None and intent_hi is not None and intent_s[r] >= intent_hi:
bs.append('🧭 의도 부합')
if not bs:
g = _genres(a)
bs.append(f'🏷 {g}' if g else '추천')
return "".join(bs[:3])
_namecount = Counter(NAME.get(a, "?") for a in cand)
_sorted = sorted(cand, key=lambda a: -(POP.get(a) or 0))
CHOICES = [
(f"{NAME.get(a,a)} · #{a}" if _namecount[NAME.get(a, '?')] > 1 else NAME.get(a, str(a)), a)
for a in _sorted[:6000]
]
def _theme():
t = gr.themes.Base()
kw = dict(
body_background_fill=BG, body_background_fill_dark=BG,
block_background_fill=CARD, block_background_fill_dark=CARD,
block_border_color=MUTE, block_border_color_dark=MUTE,
border_color_primary="#c9b8a8", border_color_primary_dark="#c9b8a8",
body_text_color=TEXT, body_text_color_dark=TEXT,
body_text_color_subdued=TEXT2, body_text_color_subdued_dark=TEXT2,
block_label_text_color=TEXT, block_label_text_color_dark=TEXT,
block_title_text_color=TEXT, block_title_text_color_dark=TEXT,
button_primary_background_fill=ACCENT, button_primary_background_fill_dark=ACCENT,
button_primary_background_fill_hover="#c9a78f", button_primary_background_fill_hover_dark="#c9a78f",
button_primary_text_color=TEXT, button_primary_text_color_dark=TEXT,
input_background_fill=PARCH, input_background_fill_dark=PARCH,
)
try:
return t.set(**kw)
except Exception:
return t
CSS = """
.reclist{display:flex;flex-direction:column;gap:8px;margin-top:6px;color:#3a332c}
.rc{display:flex;align-items:center;gap:12px;background:#e3d5ca;border:1px solid #c9b8a8;border-radius:10px;padding:11px 14px}
.rk{color:#6f6253;font-size:.82rem;width:22px;text-align:right;font-weight:700}
.rc-main{flex:1;display:flex;flex-direction:column;gap:5px;min-width:0}
.rn{color:#3a332c;font-weight:700;text-decoration:none;font-size:.95rem}
.rn:hover{text-decoration:underline;color:#bc6c25}
.rr{display:flex;flex-wrap:wrap;gap:5px}
.rb{font-size:.72rem;color:#6f6253;background:#edede9;border:1px solid #d6ccc2;border-radius:999px;padding:1px 8px;white-space:nowrap}
.rb.play{color:#7d5a3c;background:#f0e6dc;border-color:#d8c3ae}
.rb.intent{color:#9c5410;background:#f3e6d6;border-color:#e0c39c}
.rs{color:#bc6c25;font-size:.82rem;font-weight:700}
"""
def _empty_fig(msg="게임/의도를 입력하면 추론 과정이 여기 그려집니다"):
f = go.Figure()
f.update_layout(template="plotly_white", paper_bgcolor=BG, plot_bgcolor=PARCH,
height=460, margin=dict(l=10, r=10, t=10, b=10),
xaxis=dict(visible=False), yaxis=dict(visible=False),
annotations=[dict(text=msg, showarrow=False, font=dict(color=TEXT2))])
return f
def _build_fig(liked_ap, rec_ap):
fig = go.Figure()
fig.add_trace(go.Scattergl(x=_map["x"], y=_map["y"], mode="markers",
marker=dict(size=3, color="rgba(140,120,100,0.20)"),
hoverinfo="skip", showlegend=False))
rx = [(MX[a], MY[a], NAME.get(a, a)) for a in rec_ap if a in MX]
if rx:
fig.add_trace(go.Scattergl(x=[p[0] for p in rx], y=[p[1] for p in rx],
mode="markers+text", text=[p[2] for p in rx], textposition="top center",
marker=dict(size=11, color=HILITE, line=dict(width=1, color="#fff")),
textfont=dict(size=9, color=HILITE), name="추천"))
lx = [(MX[a], MY[a], NAME.get(a, a)) for a in liked_ap if a in MX]
if lx:
fig.add_trace(go.Scattergl(x=[p[0] for p in lx], y=[p[1] for p in lx],
mode="markers+text", text=[p[2] for p in lx], textposition="bottom center",
marker=dict(size=16, color=LIKED, symbol="star", line=dict(width=1, color="#fff")),
textfont=dict(size=10, color=LIKED), name="즐긴 게임"))
fig.update_layout(template="plotly_white", paper_bgcolor=BG, plot_bgcolor=PARCH,
height=460, margin=dict(l=10, r=10, t=34, b=10),
title=dict(text="🧭 임베딩 공간 — 취향(별)에서 추천이 나오는 과정",
font=dict(size=13, color=TEXT)),
xaxis=dict(visible=False), yaxis=dict(visible=False),
legend=dict(orientation="h", y=1.02, x=0, font=dict(color=TEXT)))
return fig
FLOW_CSS = """
"""
def _flow_html(liked_names, intent, w_intent, rec_names):
"""추론 과정 — 튜토리얼 스타일 '게임 지도' 애니메이션(자동 단계 재생, 배경은 일부 점만)."""
intent = (intent or "").strip()
has_l, has_i = bool(liked_names), bool(intent)
wt, wi = round((1 - w_intent) * 100), round(w_intent * 100)
def e(s):
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
itxt = (intent[:16] + "…") if len(intent) > 16 else intent
# 배경 '게임 지도' — 전체가 아니라 일부 점만
bgpts = [(205, 45), (255, 172), (180, 192), (392, 150), (120, 34), (60, 198),
(416, 55), (345, 182), (270, 60), (150, 104), (232, 118)]
bg = "".join(f'
게임을 선택하거나 의도를 입력하세요.
", "추천을 실행하면 추론 과정이 애니메이션으로 재생됩니다.
", _empty_fig()) n = len(cand) score = np.zeros(n, np.float32) rows = [cand_idx[a] for a in liked if a in cand_idx] w = float(w_intent) cooc_src = {} # 후보idx → (공동플레이 기여 1위 즐긴게임 appid, 가중치) liked_genres = set() for a in liked: if a in cand_idx: liked_genres |= set(_genre_list(a)) if rows: # 취향 = item2vec 코사인 + 공동플레이(cooc)를 RRF(랭크 융합)로 결합 → 스케일에 강건 emb_s = collab_n @ (collab_n[rows].mean(0) / (np.linalg.norm(collab_n[rows].mean(0)) + 1e-9)) cooc_s = np.zeros(n, np.float32) for a in liked: ri = _cooc_row.get(int(a)) if ri is None: continue for nbr, wv in zip(_ck_nb_appid[ri], _ck_wt[ri]): ci = cand_idx.get(int(nbr)) if ci is not None: cooc_s[ci] += wv if wv > cooc_src.get(ci, (0, 0.0))[1]: cooc_src[ci] = (int(a), float(wv)) # 근거 표시용 출처 추적 taste = 1.0 / (60 + _ranks(emb_s)) + 1.0 / (60 + _ranks(cooc_s)) score += (1 - w) * _norm(taste) intent_s = None if intent: qi = encoder().encode(_expand_intent(intent), normalize_embeddings=True) intent_s = content_c @ qi score += w * _norm(intent_s) intent_hi = float(np.quantile(intent_s, 0.75)) if intent_s is not None else None # 상위25% 의도매칭만 '의도 부합' for r in rows: score[r] = -np.inf top = np.argsort(-score)[: int(topn)] rec_ap = [cand[r] for r in top] out = [] for i, r in enumerate(top, 1): a = cand[r]; url = f"https://store.steampowered.com/app/{a}" reasons = _reason_badges(a, int(r), cooc_src, liked_genres, intent, intent_s, intent_hi) out.append(f'') html = f"