Spaces:
Sleeping
Sleeping
feat: ํ์ต ์๋์ฌ์ + ํ๋ผ๋ฏธํฐ ํ๋ ์ด๊ทธ๋ผ์ด๋(์ค์๊ฐ ์ํํ์ต)
92b2015 verified | """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'<span class="rb play">๐ฎ {NAME.get(src[0], src[0])} ํ๋ ์ด์ด๊ฐ ํจ๊ป ์ฆ๊น</span>') | |
| rg = _genre_list(a) | |
| shared = [g for g in rg if g in liked_genres][:2] | |
| if shared: | |
| bs.append(f'<span class="rb">๐ท {", ".join(shared)} ์ทจํฅ ์ผ์น</span>') | |
| elif not liked_genres and rg: | |
| bs.append(f'<span class="rb">๐ท {", ".join(rg[:2])}</span>') | |
| if intent and intent_s is not None and intent_hi is not None and intent_s[r] >= intent_hi: | |
| bs.append('<span class="rb intent">๐งญ ์๋ ๋ถํฉ</span>') | |
| if not bs: | |
| g = _genres(a) | |
| bs.append(f'<span class="rb">๐ท {g}</span>' if g else '<span class="rb">์ถ์ฒ</span>') | |
| 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 = """ | |
| <style> | |
| @keyframes flIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}} | |
| @keyframes flFade{0%,100%{opacity:.3}50%{opacity:1}} | |
| .fl{font-family:-apple-system,BlinkMacSystemFont,'Noto Sans KR',sans-serif;color:#3a332c;max-width:460px;margin:0 auto} | |
| .fl .map{background:#edede9;border:1px solid #d6ccc2;border-radius:12px;display:block;width:100%;height:auto} | |
| .fl .s{opacity:0;animation:flIn .55s ease forwards} | |
| .fl text{font-family:inherit} | |
| .fl .starlab{font-size:11px;fill:#5a4632;font-weight:700} | |
| .fl .qlab{font-size:11px;fill:#bc6c25;font-weight:800} | |
| .fl .cap{font-size:10px;fill:#8a7d6c} | |
| .fl .ring{animation:flFade 1.5s ease-in-out infinite} | |
| .fl ol.steps{list-style:none;padding:0;margin:8px 0 0} | |
| .fl ol.steps li{opacity:0;animation:flIn .5s ease forwards;font-size:.9rem;line-height:1.5;margin:3px 0} | |
| .fl ol.steps b{color:#bc6c25} | |
| .fl .recs{margin-top:8px;display:flex;flex-wrap:wrap;gap:5px} | |
| .fl .rchip{opacity:0;animation:flIn .45s ease forwards;background:#bc6c25;color:#fff;font-weight:700;border-radius:999px;padding:3px 11px;font-size:.78rem} | |
| .fl .mut{color:#8a7d6c;font-weight:400} | |
| </style> | |
| """ | |
| 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'<circle cx="{x}" cy="{y}" r="4" fill="#c9b8a8"/>' 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'<g class="s" style="animation-delay:{0.55 + i * 0.2}s">' | |
| f'<text x="{x + 11}" y="{y + 4}" class="starlab">โญ {e(nm)}</text>' | |
| f'<circle cx="{x}" cy="{y}" r="6" fill="#5a4632"/></g>') | |
| if len(liked_names) > 2: | |
| star_svg += f'<text x="95" y="178" class="cap">+ ์ธ {len(liked_names) - 2}๊ฐ</text>' | |
| star_svg += ('<g class="s" style="animation-delay:1.2s">' | |
| f'<circle cx="{O[0]}" cy="{O[1]}" r="9" fill="none" stroke="#5a4632" stroke-width="2.5"/>' | |
| f'<text x="{O[0]}" y="{O[1] + 22}" class="cap" text-anchor="middle">์ทจํฅ ์๋ฆฌ</text></g>') | |
| else: | |
| O = (100, 116) | |
| star_svg = (f'<g class="s" style="animation-delay:0.55s">' | |
| f'<text x="{O[0]}" y="{O[1]}" class="starlab" text-anchor="middle">โ๏ธ ์๋</text></g>') | |
| # ํ์ดํ โ ์ฟผ๋ฆฌ ์ง์ + ๊ฒ์ ๋ง | |
| Q = (300, 95) | |
| ql = f'โ๏ธ “{e(itxt)}” ์ง์ ' if has_i else '๐งญ ์ทจํฅ ์ง์ ' | |
| arrow = (f'<g class="s" style="animation-delay:1.75s">' | |
| f'<line x1="{O[0] + 14}" y1="{O[1]}" x2="{Q[0] - 16}" y2="{Q[1]}" stroke="#bc6c25" stroke-width="2.5" stroke-dasharray="5 4"/>' | |
| f'<path d="M{Q[0] - 14},{Q[1]} l-10,-5 l3,5 l-3,5 z" fill="#bc6c25"/>' | |
| f'<circle class="ring" cx="{Q[0]}" cy="{Q[1]}" r="26" fill="none" stroke="#bc6c25" stroke-width="1.5"/>' | |
| f'<circle cx="{Q[0]}" cy="{Q[1]}" r="7" fill="#bc6c25"/>' | |
| f'<text x="{Q[0]}" y="{Q[1] - 30}" class="qlab" text-anchor="middle">{ql}</text></g>') | |
| # ์ถ์ฒ ์ (์ฟผ๋ฆฌ ๊ทผ์ฒ) | |
| rp = [(332, 70), (322, 124), (356, 104)] | |
| recdots = ('<g class="s" style="animation-delay:2.3s">' | |
| + "".join(f'<circle cx="{x}" cy="{y}" r="7" fill="#606c38" stroke="#fff" stroke-width="1.5"/>' | |
| for x, y in rp) + '</g>') | |
| svg = (f'<svg class="map" viewBox="0 0 440 210" role="img" xmlns="http://www.w3.org/2000/svg">' | |
| f'<title>์ถ์ฒ์ด ๋ง๋ค์ด์ง๋ ๊ณผ์ </title>' | |
| f'<g class="s" style="animation-delay:.15s">{bg}</g>{star_svg}{arrow}{recdots}</svg>') | |
| # ๋จ๊ณ ์ค๋ช (์์ฐจ ๋ฑ์ฅ) | |
| steps = ["๐ฎ <b>์ฆ๊ธด ๊ฒ์</b>์ ‘๊ฒ์ ์ง๋’์ ์ฝ ์ฐ์ด์" if has_l | |
| else "โ๏ธ <b>์๋</b>๋ฅผ ‘๊ฒ์ ์ง๋’์ ํ ์ง์ ์ผ๋ก ๋ฐ๊ฟ์"] | |
| if has_l: | |
| steps.append("๐งญ ๊ทธ ์์น๋ค์ ํ๊ท = <b>๋น์ ์ ์ทจํฅ ์๋ฆฌ</b>") | |
| if has_l and has_i: | |
| steps.append(f"โ๏ธ ์๋ ‘<b>{e(itxt)}</b>’๊ฐ ์ทจํฅ์ ๊ทธ์ชฝ์ผ๋ก ์ฎ๊ฒจ์ " | |
| f"<span class='mut'>(์ทจํฅ {wt}%ยท์๋ {wi}%)</span>") | |
| elif has_l and not has_i: | |
| steps.append("์ทจํฅ ์๋ฆฌ <b>๊ทธ๋๋ก</b> ๊ฒ์ํด์") | |
| steps.append("๐ฏ ๊ทธ ์๋ฆฌ <b>๊ทผ์ฒ์ ๊ฒ์</b>์ ๊ณจ๋ผ ์ถ์ฒ!") | |
| steps_html = "".join(f'<li style="animation-delay:{0.7 + i * 0.5:.2f}s">{s}</li>' | |
| for i, s in enumerate(steps)) | |
| rd = 0.7 + len(steps) * 0.5 | |
| recs = "".join(f'<span class="rchip" style="animation-delay:{rd + i * 0.12:.2f}s">{e(n)}</span>' | |
| for i, n in enumerate(rec_names[:5])) | |
| return (f'{FLOW_CSS}<div class="fl">{svg}' | |
| f'<ol class="steps">{steps_html}</ol>' | |
| f'<div class="recs">{recs}</div></div>') | |
| # ํ๊ตญ์ด ๊ฒ์ ์ธ๋์ด โ ์์ด 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 ("<p style='color:#6f6253'>๊ฒ์์ ์ ํํ๊ฑฐ๋ ์๋๋ฅผ ์ ๋ ฅํ์ธ์.</p>", | |
| "<p style='color:#6f6253'>์ถ์ฒ์ ์คํํ๋ฉด ์ถ๋ก ๊ณผ์ ์ด ์ ๋๋ฉ์ด์ ์ผ๋ก ์ฌ์๋ฉ๋๋ค.</p>", | |
| _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'<div class="rc"><span class="rk">{i}</span>' | |
| f'<div class="rc-main">' | |
| f'<a class="rn" href="{url}" target="_blank">{NAME.get(a,a)}</a>' | |
| f'<div class="rr">{reasons}</div></div>' | |
| f'<span class="rs">{score[r]:.3f}</span></div>') | |
| html = f"<style>{CSS}</style><div class='reclist'>" + "".join(out) + "</div>" | |
| 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 = """ | |
| <style>.arch{color:#3a332c;font-size:.86rem}.arch .row{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin:5px 0} | |
| .arch .nd{background:#edede9;border:1px solid #c9b8a8;border-radius:8px;padding:7px 11px;font-size:.8rem;text-align:center} | |
| .arch .nd b{color:#3a332c}.arch .ar{color:#9c6644;font-weight:700}.arch .nt{color:#6f6253;font-size:.76rem;margin-top:8px}</style> | |
| <div class="arch"> | |
| <div class="row"><div class="nd">๊ฒ์ A<br><b>(์ต์ปค)</b></div><div class="nd">๊ฒ์ B<br><b>(์์ฑ์)</b></div></div> | |
| <div class="row"><span class="ar">โ ์๋ฒ ๋ฉ ๋ฃฉ์ </span></div> | |
| <div class="row"><div class="nd"><b>์๋ฒ ๋ฉ ํ ์ด๋ธ</b> ยท ๊ฒ์ N ร ์ฐจ์ D</div></div> | |
| <div class="row"><span class="ar">โ L2 ์ ๊ทํ โ ์ ์ฌ๋ ํ๋ ฌ(๋ฐฐ์น BรB)</span></div> | |
| <div class="row"><div class="nd"><b>InfoNCE</b> ยท ์์ฑ์ ๊ฐ๊น๊ฒ / ๋ฐฐ์น ๋ด ์์ฑ ๋ฉ๊ฒ</div></div> | |
| <div class="nt">ยท ํ์ ์๋ฒ ๋ฉ(item2vec์): ๊ฒ์IDโ๋ฒกํฐ, ๊ณต๋ํ๋ ์ด ์ ํ์ต (์ ๊ทธ๋ฆผ)<br> | |
| ยท ์ฝํ ์ธ ์๋ฒ ๋ฉ: ํ ์คํธ(์ด๋ฆยทํ๊ทธยท์ค๋ช )โ๋ค๊ตญ์ด ํธ๋์คํฌ๋จธโ๋ฒกํฐ<br> | |
| ยท ์ถ์ฒ = ํ์ (์ทจํฅ) + ์ฝํ ์ธ (์๋) ๊ฐ์ค ๊ฒฐํฉ โ ํ์ด๋ธ๋ฆฌ๋</div></div> | |
| """ | |
| # ๋น์ ๊ณต์์ฉ ์๋์ฌ์ ์ ๋๋ฉ์ด์ โ "AI๊ฐ ๋น์ทํ ๊ฒ์์ ๋ฐฐ์ฐ๋ ๊ณผ์ "(์ฉ์ด ์์ด ๋น์ ). | |
| # ์์ CSS ํคํ๋ ์(JS ๋ถํ์, ๋ชจ๋ฐ์ผ ์๋์ฌ์). ํด๋์ค๋ la- ๋ก ๋ค์์คํ์ด์ค(Gradio ์ถฉ๋ ๋ฐฉ์ง). | |
| LEARN_ANIM = """ | |
| <div class="la-wrap"> | |
| <h2 class="la-sr">AI๊ฐ ๊ฒ์ ์ถ์ฒ์ ํ์ตํ๋ ๊ณผ์ : ๊ฐ์ ์ฌ๋์ด ํจ๊ป ์ฆ๊ธด ๊ฒ์์ ์๋ก ๊ฐ๊น์ด ๋์ด๋น๊ฒจ '๋๋ค'(๊ตฐ์ง)๋ฅผ ๋ง๋ค๊ณ , ๋ด๊ฐ ์ข์ํ ๊ฒ์์ ๋๋ค์์ ์ด์ ๊ฒ์์ ์ถ์ฒํฉ๋๋ค.</h2> | |
| <div class="la-title">๐ง AI๋ ์ด๋ป๊ฒ <b>“๋น์ทํ ๊ฒ์”</b>์ ๋ฐฐ์ธ๊น?</div> | |
| <div class="la-scene"> | |
| <svg viewBox="0 0 640 320" role="img" xmlns="http://www.w3.org/2000/svg"> | |
| <title>AI๊ฐ ๊ฒ์์ ๊ตฐ์ง์ผ๋ก ํ์ตํ๋ ๊ณผ์ </title> | |
| <g class="la-labels"> | |
| <rect x="70" y="278" width="118" height="26" rx="13" fill="#9c6644"/> | |
| <text x="129" y="295" class="la-lbl">๐ฎ ์ก์ ๋๋ค</text> | |
| <rect x="270" y="30" width="120" height="26" rx="13" fill="#5c6b73"/> | |
| <text x="330" y="47" class="la-lbl">๐ป ๊ณตํฌ ๋๋ค</text> | |
| <rect x="442" y="278" width="118" height="26" rx="13" fill="#606c38"/> | |
| <text x="501" y="295" class="la-lbl">๐ฑ ๋์ฅ ๋๋ค</text> | |
| </g> | |
| <g class="la-dot" style="--dx:-350px;--dy:160px"><circle cx="480" cy="90" r="10" fill="#9c6644"/></g> | |
| <g class="la-dot" style="--dx:-140px;--dy:10px"><circle cx="300" cy="250" r="10" fill="#9c6644"/></g> | |
| <g class="la-dot" style="--dx:40px;--dy:180px"><circle cx="90" cy="60" r="10" fill="#9c6644"/></g> | |
| <g class="la-dot" style="--dx:-390px;--dy:-60px"><circle cx="560" cy="290" r="10" fill="#9c6644"/></g> | |
| <g class="la-dot" style="--dx:200px;--dy:-90px"><circle cx="120" cy="210" r="10" fill="#5c6b73"/></g> | |
| <g class="la-dot" style="--dx:-195px;--dy:-95px"><circle cx="540" cy="210" r="10" fill="#5c6b73"/></g> | |
| <g class="la-dot" style="--dx:75px;--dy:-190px"><circle cx="250" cy="300" r="10" fill="#5c6b73"/></g> | |
| <g class="la-dot" style="--dx:-80px;--dy:95px"><circle cx="420" cy="40" r="10" fill="#5c6b73"/></g> | |
| <g class="la-dot" style="--dx:410px;--dy:-45px"><circle cx="80" cy="290" r="10" fill="#606c38"/></g> | |
| <g class="la-dot" style="--dx:165px;--dy:190px"><circle cx="350" cy="70" r="10" fill="#606c38"/></g> | |
| <g class="la-dot" style="--dx:300px;--dy:140px"><circle cx="200" cy="130" r="10" fill="#606c38"/></g> | |
| <g class="la-dot" style="--dx:-80px;--dy:125px"><circle cx="600" cy="110" r="10" fill="#606c38"/></g> | |
| <g class="la-pull"> | |
| <line x1="480" y1="90" x2="300" y2="250" stroke="#bc6c25" stroke-width="2.5" stroke-dasharray="6 5"/> | |
| <rect x="330" y="150" width="150" height="28" rx="14" fill="#bc6c25"/> | |
| <text x="405" y="169" class="la-badge">๐ฅ ๊ฐ์ด ์ฆ๊ธด ์ฌ๋</text> | |
| </g> | |
| <g class="la-reco"> | |
| <circle class="la-ring" cx="501" cy="250" r="18" fill="none" stroke="#bc6c25" stroke-width="2.5"/> | |
| <text x="501" y="256" class="la-star">โญ</text> | |
| <rect x="446" y="205" width="110" height="24" rx="12" fill="#3a332c"/> | |
| <text x="501" y="222" class="la-mine">๋ด๊ฐ ์ข์ํ ๊ฒ์</text> | |
| <line x1="501" y1="250" x2="470" y2="245" stroke="#bc6c25" stroke-width="3"/> | |
| <line x1="501" y1="250" x2="515" y2="260" stroke="#bc6c25" stroke-width="3"/> | |
| <rect x="548" y="238" width="78" height="24" rx="12" fill="#606c38"/> | |
| <text x="587" y="255" class="la-pick">์ถ์ฒ ✓</text> | |
| </g> | |
| </svg> | |
| </div> | |
| <div class="la-caps"> | |
| <div class="la-cap la-c1">โ ์ฒ์์ AI๋ ๋ชฐ๋ผ์ โ ์ด๋ค ๊ฒ์์ด ๋น์ทํ์ง ๋ชฐ๋ผ ๋ค์ฃฝ๋ฐ์ฃฝ ํฉ์ด์ ธ ์์ฃ .</div> | |
| <div class="la-cap la-c2">โก “๊ฐ์ ์ฌ๋์ด <b>๋ ๋ค</b> ์ฆ๊ฒผ๋ค?” โ ์ด๋ฐ ๊ฒ์ ์์ ๋ฐ๊ฒฌํ๋ฉด…</div> | |
| <div class="la-cap la-c3">โข ๊ทธ ๋ ๊ฒ์์ <b>์๋ก ๊ฐ๊น์ด ๋์ด๋น๊น๋๋ค.</b> (์๊ด์๋ ๊ฑด ๋ฉ์ด์ง๊ณ ์)</div> | |
| <div class="la-cap la-c4">โฃ ์๋ฐฑ๋ง ๋ฒ ๋ฐ๋ณตํ๋ฉด โ ๋น์ทํ ๊ฒ์๋ผ๋ฆฌ <b>‘๋๋ค’</b>๊ฐ ์๊ฒจ์.</div> | |
| <div class="la-cap la-c5">โค ์ถ์ฒ = <b>๋ด ๊ฒ์์ ‘๋๋ค’</b>์์ ์ด์์ ๊ณจ๋ผ์ฃผ๋ ๊ฒ! ๐ฏ</div> | |
| </div> | |
| <div class="la-hint">โถ ์๋ ๋ฐ๋ณต ์ฌ์ โ ์ฌ๋์ด ํจ๊ป ์ฆ๊ธด ๊ฒ์์ ๋ชจ์ ‘๋๋ค’๋ฅผ ๋ง๋๋ ๊ฒ ํ์ต์ ์ ๋ถ์์</div> | |
| </div> | |
| <style> | |
| .la-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} | |
| .la-wrap{background:#f5ebe0;border:1px solid #d6ccc2;border-radius:16px;padding:16px 14px 12px;max-width:660px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Noto Sans KR',sans-serif;color:#3a332c} | |
| .la-title{text-align:center;font-size:1.12rem;font-weight:800;margin-bottom:6px;color:#3a332c} | |
| .la-title b{color:#bc6c25} | |
| .la-scene{background:#edede9;border:1px solid #d6ccc2;border-radius:12px} | |
| .la-scene svg{display:block;width:100%;height:auto;animation:la_scene 16s linear infinite} | |
| .la-dot{animation:la_gather 16s ease-in-out infinite} | |
| .la-dot circle{filter:drop-shadow(0 1px 1px rgba(58,51,44,.18))} | |
| .la-labels{opacity:0;animation:la_labels 16s ease infinite} | |
| .la-lbl{fill:#fff;font-size:14px;font-weight:800;text-anchor:middle} | |
| .la-pull{opacity:0;animation:la_pull 16s ease infinite} | |
| .la-badge{fill:#fff;font-size:13px;font-weight:800;text-anchor:middle} | |
| .la-reco{opacity:0;animation:la_reco 16s ease infinite} | |
| .la-star{font-size:22px;text-anchor:middle} | |
| .la-mine{fill:#fff;font-size:12px;font-weight:700;text-anchor:middle} | |
| .la-pick{fill:#fff;font-size:13px;font-weight:800;text-anchor:middle} | |
| .la-ring{animation:la_ring 1.3s ease-in-out infinite} | |
| .la-caps{position:relative;height:54px;margin-top:10px} | |
| .la-cap{position:absolute;left:0;right:0;text-align:center;font-size:.98rem;line-height:1.35;opacity:0;padding:0 6px;color:#3a332c} | |
| .la-cap b{color:#bc6c25} | |
| .la-c1{animation:la_cap1 16s ease infinite} | |
| .la-c2{animation:la_cap2 16s ease infinite} | |
| .la-c3{animation:la_cap3 16s ease infinite} | |
| .la-c4{animation:la_cap4 16s ease infinite} | |
| .la-c5{animation:la_cap5 16s ease infinite} | |
| .la-hint{text-align:center;font-size:.76rem;color:#6f6253;margin-top:8px} | |
| @keyframes la_scene{0%{opacity:0}3%{opacity:1}94%{opacity:1}99%{opacity:0}100%{opacity:0}} | |
| @keyframes la_gather{0%,30%{transform:translate(0,0)}48%,100%{transform:translate(var(--dx),var(--dy))}} | |
| @keyframes la_pull{0%,12%{opacity:0}16%,29%{opacity:1}33%,100%{opacity:0}} | |
| @keyframes la_labels{0%,46%{opacity:0}54%,100%{opacity:1}} | |
| @keyframes la_reco{0%,68%{opacity:0}75%,94%{opacity:1}99%,100%{opacity:0}} | |
| @keyframes la_ring{0%,100%{opacity:.3;transform:scale(.9);transform-origin:501px 250px}50%{opacity:1;transform:scale(1.15);transform-origin:501px 250px}} | |
| @keyframes la_cap1{0%,11%{opacity:1}15%,100%{opacity:0}} | |
| @keyframes la_cap2{0%,13%{opacity:0}17%,28%{opacity:1}31%,100%{opacity:0}} | |
| @keyframes la_cap3{0%,29%{opacity:0}34%,46%{opacity:1}49%,100%{opacity:0}} | |
| @keyframes la_cap4{0%,47%{opacity:0}55%,67%{opacity:1}71%,100%{opacity:0}} | |
| @keyframes la_cap5{0%,69%{opacity:0}76%,93%{opacity:1}97%,100%{opacity:0}} | |
| </style> | |
| """ | |
| # ์ ๋ ฅ์นธ/๋๋กญ๋ค์ด ๊ฐ์์ฑ (๋ฐ์ ๋ฐฐ๊ฒฝ + ๋คํฌ ๊ธ์) | |
| 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("<small>ํ์ ์๋ฒ ๋ฉ(item2vec์ ์ง์ ํ์ต) + ์ฝํ ์ธ ์๋ฒ ๋ฉ ยท ๊ณต์ Steam API ๋ฐ์ดํฐ 1,021๋ง ๋ฆฌ๋ทฐ</small>") | |
| if __name__ == "__main__": | |
| demo.launch() | |