steamfit / app.py
mininiming's picture
feat: ํ•™์Šต ์ž๋™์žฌ์ƒ + ํŒŒ๋ผ๋ฏธํ„ฐ ํ”Œ๋ ˆ์ด๊ทธ๋ผ์šด๋“œ(์‹ค์‹œ๊ฐ„ ์†Œํ˜•ํ•™์Šต)
92b2015 verified
Raw
History Blame Contribute Delete
39 kB
"""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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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'โœ๏ธ &ldquo;{e(itxt)}&rdquo; ์ง€์ ' 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>์„ &lsquo;๊ฒŒ์ž„ ์ง€๋„&rsquo;์— ์ฝ• ์ฐ์–ด์š”" if has_l
else "โœ๏ธ <b>์˜๋„</b>๋ฅผ &lsquo;๊ฒŒ์ž„ ์ง€๋„&rsquo;์˜ ํ•œ ์ง€์ ์œผ๋กœ ๋ฐ”๊ฟ”์š”"]
if has_l:
steps.append("๐Ÿงญ ๊ทธ ์œ„์น˜๋“ค์˜ ํ‰๊ท  = <b>๋‹น์‹ ์˜ ์ทจํ–ฅ ์ž๋ฆฌ</b>")
if has_l and has_i:
steps.append(f"โœ๏ธ ์˜๋„ &lsquo;<b>{e(itxt)}</b>&rsquo;๊ฐ€ ์ทจํ–ฅ์„ ๊ทธ์ชฝ์œผ๋กœ ์˜ฎ๊ฒจ์š” "
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>&ldquo;๋น„์Šทํ•œ ๊ฒŒ์ž„&rdquo;</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">์ถ”์ฒœ &check;</text>
</g>
</svg>
</div>
<div class="la-caps">
<div class="la-cap la-c1">โ‘  ์ฒ˜์Œ์—” AI๋„ ๋ชฐ๋ผ์š” โ€” ์–ด๋–ค ๊ฒŒ์ž„์ด ๋น„์Šทํ•œ์ง€ ๋ชฐ๋ผ ๋’ค์ฃฝ๋ฐ•์ฃฝ ํฉ์–ด์ ธ ์žˆ์ฃ .</div>
<div class="la-cap la-c2">โ‘ก &ldquo;๊ฐ™์€ ์‚ฌ๋žŒ์ด <b>๋‘˜ ๋‹ค</b> ์ฆ๊ฒผ๋„ค?&rdquo; โ€” ์ด๋Ÿฐ ๊ฒŒ์ž„ ์Œ์„ ๋ฐœ๊ฒฌํ•˜๋ฉด&hellip;</div>
<div class="la-cap la-c3">โ‘ข ๊ทธ ๋‘ ๊ฒŒ์ž„์„ <b>์„œ๋กœ ๊ฐ€๊นŒ์ด ๋Œ์–ด๋‹น๊น๋‹ˆ๋‹ค.</b> (์ƒ๊ด€์—†๋Š” ๊ฑด ๋ฉ€์–ด์ง€๊ณ ์š”)</div>
<div class="la-cap la-c4">โ‘ฃ ์ˆ˜๋ฐฑ๋งŒ ๋ฒˆ ๋ฐ˜๋ณตํ•˜๋ฉด โ€” ๋น„์Šทํ•œ ๊ฒŒ์ž„๋ผ๋ฆฌ <b>&lsquo;๋™๋„ค&rsquo;</b>๊ฐ€ ์ƒ๊ฒจ์š”.</div>
<div class="la-cap la-c5">โ‘ค ์ถ”์ฒœ = <b>๋‚ด ๊ฒŒ์ž„์˜ &lsquo;๋™๋„ค&rsquo;</b>์—์„œ ์ด์›ƒ์„ ๊ณจ๋ผ์ฃผ๋Š” ๊ฒƒ! ๐ŸŽฏ</div>
</div>
<div class="la-hint">โ–ถ ์ž๋™ ๋ฐ˜๋ณต ์žฌ์ƒ โ€” ์‚ฌ๋žŒ์ด ํ•จ๊ป˜ ์ฆ๊ธด ๊ฒŒ์ž„์„ ๋ชจ์•„ &lsquo;๋™๋„ค&rsquo;๋ฅผ ๋งŒ๋“œ๋Š” ๊ฒŒ ํ•™์Šต์˜ ์ „๋ถ€์˜ˆ์š”</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()