"""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'' for x, y in bgpts) # 즐긴 게임(별) + 취향 자리 O = (155, 130) star_svg = "" if has_l: sp = [(95, 86), (82, 150)] for i, nm in enumerate(liked_names[:2]): x, y = sp[i] star_svg += (f'' f'⭐ {e(nm)}' f'') if len(liked_names) > 2: star_svg += f'+ 외 {len(liked_names) - 2}개' star_svg += ('' f'' f'취향 자리') else: O = (100, 116) star_svg = (f'' f'✍️ 의도') # 화살표 → 쿼리 지점 + 검색 링 Q = (300, 95) ql = f'✍️ “{e(itxt)}” 지점' if has_i else '🧭 취향 지점' arrow = (f'' f'' f'' f'' f'' f'{ql}') # 추천 점(쿼리 근처) rp = [(332, 70), (322, 124), (356, 104)] recdots = ('' + "".join(f'' for x, y in rp) + '') svg = (f'' f'추천이 만들어지는 과정' f'{bg}{star_svg}{arrow}{recdots}') # 단계 설명(순차 등장) steps = ["🎮 즐긴 게임을 ‘게임 지도’에 콕 찍어요" if has_l else "✍️ 의도를 ‘게임 지도’의 한 지점으로 바꿔요"] if has_l: steps.append("🧭 그 위치들의 평균 = 당신의 취향 자리") if has_l and has_i: steps.append(f"✍️ 의도 ‘{e(itxt)}’가 취향을 그쪽으로 옮겨요 " f"(취향 {wt}%·의도 {wi}%)") elif has_l and not has_i: steps.append("취향 자리 그대로 검색해요") steps.append("🎯 그 자리 근처의 게임을 골라 추천!") steps_html = "".join(f'
  • {s}
  • ' for i, s in enumerate(steps)) rd = 0.7 + len(steps) * 0.5 recs = "".join(f'{e(n)}' for i, n in enumerate(rec_names[:5])) return (f'{FLOW_CSS}
    {svg}' f'
      {steps_html}
    ' f'
    {recs}
    ') # 한국어 게임 외래어 → 영어 Steam 태그/용어 사전. # 진단: 게임은 보편적 영어 태그(Souls-like 등)를 가져 영어 검색은 정확하나, # 한국어 외래어("소울라이크")가 그 태그에 안 붙음. 쿼리에 영어 표준어를 병기해 # 검증된 영어 경로로 결정론적 라우팅(무재학습). GAMING_GLOSSARY = { # 세부 장르 외래어 (영어 태그로 직행) "소울라이크": "Souls-like", "소울라이트": "Souls-like", "소울류": "Souls-like", "메트로배니아": "Metroidvania", "메트로바니아": "Metroidvania", "로그라이크": "Roguelike", "로그라이트": "Roguelite", "로그라잇": "Roguelike", "핵앤슬래시": "Hack and Slash", "핵슬": "Hack and Slash", "디아블로류": "Action RPG Hack and Slash", "타워디펜스": "Tower Defense", "디펜스": "Tower Defense", "방치형": "Idle", "방치": "Idle", "클리커": "Clicker", "클릭커": "Clicker", "비주얼노벨": "Visual Novel", "비주얼 노벨": "Visual Novel", "미연시": "Dating Sim", "연애시뮬": "Dating Sim", "덱빌딩": "Deckbuilding", "덱빌더": "Deckbuilding", "카드게임": "Card Game", "탑다운": "Top-Down", "쿼터뷰": "Isometric", "아이소메트릭": "Isometric", "턴제": "Turn-Based", "실시간전략": "Real-Time Strategy", "알티에스": "Real-Time Strategy", "도트": "Pixel Graphics", "도트그래픽": "Pixel Graphics", "픽셀": "Pixel Graphics", "픽셀아트": "Pixel Graphics", "샌드박스": "Sandbox", "오픈월드": "Open World", "오픈 월드": "Open World", "생존공포": "Survival Horror", "생존": "Survival", "서바이벌": "Survival", "크래프팅": "Crafting", "제작": "Crafting", "도시건설": "City Builder", "건설": "Building", "공포": "Horror", "호러": "Horror", "좀비": "Zombies", "협동": "Co-op", "코옵": "Co-op", "코업": "Co-op", "멀티플레이": "Multiplayer", "싱글플레이": "Singleplayer", "배틀로얄": "Battle Royale", "배틀로열": "Battle Royale", "모바": "MOBA", "에이오에스": "MOBA", "에프피에스": "FPS", "1인칭슈팅": "FPS", "3인칭": "Third Person", "삼인칭": "Third Person", "슈팅": "Shooter", "슈터": "Shooter", "탄막": "Bullet Hell", "불릿헬": "Bullet Hell", "플랫포머": "Platformer", "플랫폼게임": "Platformer", "격투": "Fighting", "대전격투": "Fighting", "레이싱": "Racing", "리듬게임": "Rhythm", "리듬": "Rhythm", "퍼즐": "Puzzle", "방탈출": "Escape Room", "추리": "Detective Mystery", "잠입": "Stealth", "스텔스": "Stealth", "경영시뮬": "Management Simulation", "경영": "Management", "농장": "Farming Sim", "농사": "Farming Sim", "수집형": "Collectathon", "가챠": "Gacha", "오토배틀러": "Auto Battler", "오토체스": "Auto Battler", "자동전투": "Auto Battler", "비대칭": "Asymmetric", "제이알피지": "JRPG", "알피지": "RPG", "시뮬레이션": "Simulation", "시뮬": "Simulation", "어드벤처": "Adventure", "액션": "Action", "전략": "Strategy", "인디": "Indie", "캐주얼": "Casual", # 커뮤니티 표현 "명작": "critically acclaimed", "띵작": "critically acclaimed", "갓겜": "critically acclaimed", "노가다": "Grinding", "그라인딩": "Grinding", "하드코어": "Hardcore Difficult", "고난도": "Difficult", } def _expand_intent(text): """의도 텍스트에 매칭된 외래어의 영어 표준어를 병기(중복 제거). 원문은 보존.""" hits = [] for ko, en in GAMING_GLOSSARY.items(): if ko in text and en not in hits: hits.append(en) return f"{text} {' '.join(hits)}" if hits else text def recommend(liked, intent, w_intent, topn): liked = liked or [] intent = (intent or "").strip() if not liked and not intent: return ("

    게임을 선택하거나 의도를 입력하세요.

    ", "

    추천을 실행하면 추론 과정이 애니메이션으로 재생됩니다.

    ", _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'
    {i}' f'
    ' f'{NAME.get(a,a)}' f'
    {reasons}
    ' f'{score[r]:.3f}
    ') html = f"
    " + "".join(out) + "
    " liked_in = [a for a in liked if a in cand_idx] flow = _flow_html([NAME.get(a, a) for a in liked_in], intent, float(w_intent), [NAME.get(a, a) for a in rec_ap]) fig = _build_fig(liked_in, rec_ap) return html, flow, fig def _traj_fig(frame, colors, names, title, bnd=(-1, 1, -1, 1)): fig = go.Figure(go.Scattergl( x=[p[0] for p in frame], y=[p[1] for p in frame], mode="markers", marker=dict(size=6, color=colors, line=dict(width=0)), text=names, hoverinfo="text", showlegend=False)) fig.update_layout(template="plotly_white", paper_bgcolor=BG, plot_bgcolor=PARCH, height=440, margin=dict(l=10, r=10, t=40, b=10), title=dict(text=title, font=dict(color=TEXT, size=12)), xaxis=dict(visible=False, range=[bnd[0], bnd[1]]), yaxis=dict(visible=False, range=[bnd[2], bnd[3]])) return fig _TB = (TRAIN["bounds"]["xlo"], TRAIN["bounds"]["xhi"], TRAIN["bounds"]["ylo"], TRAIN["bounds"]["yhi"]) REAL_PARAMS = "차원 32 · 학습률 2e-3 · 배치 1024 · InfoNCE(temp 0.07) · Adam" def epoch_fig(ep): """학습 진행 슬라이더 — 선별 스냅샷의 실제 궤적(게임이 '이웃'으로 뭉치는 과정).""" ep = int(ep) loss = TRAIN["losses"][ep] if ep < len(TRAIN["losses"]) else None sub = "🎲 랜덤 초기화 (학습 전)" if loss is None else f"대조손실 {loss}" return _traj_fig(TRAIN["frames"][ep], _gcolor, TRAIN["names"], f"학습 진행 {ep}/{N_EPOCHS} · {sub} — '이웃 그룹'으로 뭉치는 실제 궤적 (색=이웃)", _TB) def autoplay_real(): """실제 파라미터로 학습된 궤적을 튜토리얼처럼 자동재생(Gradio 제너레이터).""" import time n = len(TRAIN["frames"]) for i in range(n): loss = TRAIN["losses"][i] sub = "🎲 랜덤 초기화" if loss is None else f"대조손실 {loss}" yield _traj_fig(TRAIN["frames"][i], _gcolor, TRAIN["names"], f"▶ 자동재생 {i}/{n - 1} · {sub} ⟨{REAL_PARAMS}⟩", _TB) time.sleep(0.5) def train_live(dim, lr, epochs, temp): """직접 지정한 하이퍼파라미터로 Space(CPU)에서 실시간 소형 학습 → 자동재생(제너레이터).""" import time import torch import torch.nn as nn import torch.nn.functional as F dim, epochs, lr, temp = int(dim), int(epochs), float(lr), float(temp) yield _empty_fig(f"⏳ 학습 중… (상위 400게임 · 차원 {dim} · {epochs}에폭, 약 2초)") torch.set_num_threads(2) P = torch.tensor(PG_PAIRS.astype("int64")) emb = nn.Embedding(PG_N, dim) nn.init.normal_(emb.weight, std=0.35) opt = torch.optim.Adam(emb.parameters(), lr=lr) snaps = [emb.weight.detach().numpy().copy()] for _ in range(epochs): perm = torch.randperm(P.size(0)) for i in range(0, P.size(0), 512): ix = perm[i:i + 512] ea = F.normalize(emb(P[ix, 0]), dim=1) eb = F.normalize(emb(P[ix, 1]), dim=1) lo = ea @ eb.t() / temp lab = torch.arange(ea.size(0)) loss = 0.5 * (F.cross_entropy(lo, lab) + F.cross_entropy(lo.t(), lab)) opt.zero_grad(); loss.backward(); opt.step() snaps.append(emb.weight.detach().numpy().copy()) def nrm(x): return x / (np.linalg.norm(x, axis=1, keepdims=True) + 1e-9) Ff = nrm(snaps[-1]); mu = Ff.mean(0) _, _, Vt = np.linalg.svd(Ff - mu, full_matrices=False) comp = Vt[:2].T proj = [(nrm(s) - mu) @ comp for s in snaps] allp = np.concatenate(proj); lo2, hi2 = allp.min(0), allp.max(0) proj = [2 * (p - lo2) / (hi2 - lo2 + 1e-9) - 1 for p in proj] cols = [GENRE_PAL[c % len(GENRE_PAL)] for c in PG_CLUSTERS] tag = f"차원 {dim} · 학습률 {lr:g} · temp {temp:g}" n = len(proj) for i in range(n): stage = "🎲 랜덤 초기화" if i == 0 else f"{i}/{epochs} 에폭" yield _traj_fig(proj[i].tolist(), cols, PG_NAMES, f"🎛 {stage} ⟨{tag}⟩ — 파라미터가 군집에 주는 영향", (-1, 1, -1, 1)) time.sleep(0.45) ARCH_HTML = """
    게임 A
    (앵커)
    게임 B
    (양성쌍)
    ↓ 임베딩 룩업
    임베딩 테이블 · 게임 N × 차원 D
    ↓ L2 정규화 → 유사도 행렬(배치 B×B)
    InfoNCE · 양성쌍 가깝게 / 배치 내 음성 멀게
    · 협업 임베딩(item2vec식): 게임ID→벡터, 공동플레이 쌍 학습 (위 그림)
    · 콘텐츠 임베딩: 텍스트(이름·태그·설명)→다국어 트랜스포머→벡터
    · 추천 = 협업(취향) + 콘텐츠(의도) 가중 결합 → 하이브리드
    """ # 비전공자용 자동재생 애니메이션 — "AI가 비슷한 게임을 배우는 과정"(용어 없이 비유). # 순수 CSS 키프레임(JS 불필요, 모바일 자동재생). 클래스는 la- 로 네임스페이스(Gradio 충돌 방지). LEARN_ANIM = """

    AI가 게임 추천을 학습하는 과정: 같은 사람이 함께 즐긴 게임을 서로 가까이 끌어당겨 '동네'(군집)를 만들고, 내가 좋아한 게임의 동네에서 이웃 게임을 추천합니다.

    🧠 AI는 어떻게 “비슷한 게임”을 배울까?
    AI가 게임을 군집으로 학습하는 과정 🎮 액션 동네 👻 공포 동네 🌱 농장 동네 👥 같이 즐긴 사람 내가 좋아한 게임 추천 ✓
    ① 처음엔 AI도 몰라요 — 어떤 게임이 비슷한지 몰라 뒤죽박죽 흩어져 있죠.
    ② “같은 사람이 둘 다 즐겼네?” — 이런 게임 쌍을 발견하면…
    ③ 그 두 게임을 서로 가까이 끌어당깁니다. (상관없는 건 멀어지고요)
    ④ 수백만 번 반복하면 — 비슷한 게임끼리 ‘동네’가 생겨요.
    ⑤ 추천 = 내 게임의 ‘동네’에서 이웃을 골라주는 것! 🎯
    ▶ 자동 반복 재생 — 사람이 함께 즐긴 게임을 모아 ‘동네’를 만드는 게 학습의 전부예요
    """ # 입력칸/드롭다운 가시성 (밝은 배경 + 다크 글자) GLOBAL_CSS = """ input, textarea { color:#3a332c !important; background:#ffffff !important; } input::placeholder, textarea::placeholder { color:#9a8d7c !important; } ul.options { background:#ffffff !important; border:1px solid #c9b8a8 !important; } ul.options li, li.item, .options .item { background:#ffffff !important; color:#3a332c !important; } ul.options li:hover, li.item:hover, .item.selected, .item.active { background:#e3d5ca !important; color:#3a332c !important; } .token, span.token { background:#d5bdaf !important; color:#3a332c !important; } .token .token-remove, .token svg { color:#3a332c !important; fill:#3a332c !important; } """ with gr.Blocks(title="SteamFit", theme=_theme(), css=GLOBAL_CSS) as demo: gr.Markdown("# 🎮 SteamFit — Steam 게임 추천\n" "**취향**(즐긴 게임) + **의도**(원하는 특징 텍스트)를 결합한 하이브리드 추천. " "Steam 상점이 못 하는 *의도 반영*이 핵심. **한국어·영어 의도 모두 지원** 🇰🇷🇺🇸") with gr.Tabs(): with gr.Tab("🎮 추천"): liked = gr.Dropdown(CHOICES, multiselect=True, label="🎮 즐겨한 게임 (검색해서 선택)", filterable=True) intent = gr.Textbox(label="✍️ 원하는 특징 / 의도", placeholder="예: relaxing open world crafting / 협동 호러 / competitive multiplayer") with gr.Row(): w_intent = gr.Slider(0, 1, value=0.4, step=0.1, label="의도 반영 비중 (0=취향만 · 1=의도만)") topn = gr.Slider(5, 20, value=10, step=1, label="추천 개수") btn = gr.Button("추천 받기", variant="primary") with gr.Row(): with gr.Column(scale=1): out = gr.HTML(label="추천 결과") with gr.Column(scale=1): gr.Markdown("##### 🎬 추론 과정 (자동 재생)") out_flow = gr.HTML() with gr.Accordion("🧭 임베딩 공간 2D 맵 (취향→추천)", open=False): out_plot = gr.Plot() btn.click(recommend, [liked, intent, w_intent, topn], [out, out_flow, out_plot]) with gr.Tab("🎬 학습 과정"): gr.Markdown("### AI가 ‘비슷한 게임’을 배우는 과정\n" "용어 없이 — 아래 애니메이션만 보면 학습 원리가 한눈에 들어옵니다. (자동 반복)") gr.HTML(LEARN_ANIM) with gr.Accordion("🔬 실제 학습 데이터로 보기 (자세히)", open=False): gr.Markdown("위 비유가 **실제로** 일어난 궤적입니다. 900개 게임이 학습을 거치며 " "**‘이웃 그룹’(색)으로 뭉치는 과정**을 자동재생하거나, 직접 파라미터를 바꿔 학습시켜 보세요. " "(협업 임베딩을 2D로 투영 · 점 위에 마우스=게임명)") with gr.Row(): btn_auto = gr.Button("▶ 실제 학습 과정 자동재생", variant="primary") btn_play = gr.Button("🎛 직접 파라미터 지정해보기") train_plot = gr.Plot(value=epoch_fig(0)) ep_slider = gr.Slider(0, N_EPOCHS, value=0, step=1, label="또는 직접 드래그 — 학습 진행 (0=랜덤 → 끝=군집)") with gr.Group(visible=False) as pg_group: gr.Markdown("#### 🎛 파라미터 플레이그라운드 — 상위 400게임 실시간 학습(~2초)\n" "값을 바꿔 **실행**하면 하이퍼파라미터가 군집 형성에 주는 영향을 직접 볼 수 있어요.") with gr.Row(): dim_s = gr.Slider(8, 64, value=32, step=8, label="임베딩 차원 (dim)") temp_s = gr.Slider(0.05, 0.5, value=0.1, step=0.05, label="Temperature (↓일수록 뭉침↑)") with gr.Row(): lr_s = gr.Dropdown(["0.001", "0.003", "0.01", "0.03"], value="0.003", label="학습률 (lr)") ep_s = gr.Slider(4, 20, value=12, step=1, label="에폭 (epochs)") run_btn = gr.Button("🚀 이 설정으로 학습 실행", variant="primary") gr.Markdown("#### 🧩 모델 구조 (대조학습)") gr.HTML(ARCH_HTML) ep_slider.change(epoch_fig, ep_slider, train_plot) btn_auto.click(autoplay_real, None, train_plot) btn_play.click(lambda: gr.update(visible=True), None, pg_group) run_btn.click(train_live, [dim_s, lr_s, ep_s, temp_s], train_plot) gr.Markdown("협업 임베딩(item2vec식 직접 학습) + 콘텐츠 임베딩 · 공식 Steam API 데이터 1,021만 리뷰") if __name__ == "__main__": demo.launch()