Spaces:
Sleeping
Sleeping
| # app.py | |
| # Gradio Space: formulario numérico + GIF embebido (base64) + créditos | |
| import json | |
| import base64 | |
| import joblib | |
| import pandas as pd | |
| import gradio as gr | |
| MODEL_PKL = "rf_win_model.pkl" | |
| SCHEMA_JSON = "input_schema.json" | |
| META_JSON = "model_metadata.json" | |
| GIF_PATH = "faker.gif" | |
| CLIP_TO_OBSERVED = True | |
| # ------------------------- | |
| # Helpers | |
| # ------------------------- | |
| def gif_data_uri(path: str) -> str: | |
| """ | |
| Devuelve un data URI base64 para un GIF local. | |
| Si el archivo no existe, devuelve string vacío. | |
| """ | |
| try: | |
| with open(path, "rb") as f: | |
| b64 = base64.b64encode(f.read()).decode("utf-8") | |
| return f"data:image/gif;base64,{b64}" | |
| except FileNotFoundError: | |
| return "" | |
| def fmt_int(x: float) -> str: | |
| return f"{int(round(x))}" | |
| # ------------------------- | |
| # Load artifacts | |
| # ------------------------- | |
| clf = joblib.load(MODEL_PKL) | |
| with open(SCHEMA_JSON, "r", encoding="utf-8") as f: | |
| schema = json.load(f) | |
| with open(META_JSON, "r", encoding="utf-8") as f: | |
| meta = json.load(f) | |
| FEATURES = meta["features_order"] | |
| feat_map = {f["name"]: f for f in schema["features"]} | |
| UI_LABELS = { | |
| "TeamTotalGold": "Oro total del equipo", | |
| "TeamXp": "Experiencia total del equipo", | |
| "TeamTotalKills": "Kills del equipo", | |
| "TeamDragonKills": "Dragones conseguidos", | |
| "TeamHeraldKills": "Heraldos conseguidos", | |
| "TeamTurretPlatesDestroyed": "Placas de torre destruidas", | |
| "TeamWardsPlaced": "Wards colocados", | |
| "TeamControlWardsPlaced": "Wards de control colocados", | |
| } | |
| GROUPS = { | |
| "Economía y pelea": ["TeamTotalGold", "TeamXp", "TeamTotalKills"], | |
| "Objetivos": ["TeamDragonKills", "TeamHeraldKills", "TeamTurretPlatesDestroyed"], | |
| "Visión": ["TeamWardsPlaced", "TeamControlWardsPlaced"], | |
| } | |
| def reset_values(): | |
| return [float(feat_map[c]["ui_default"]) for c in FEATURES] | |
| def predict(*vals): | |
| user_inputs = dict(zip(FEATURES, vals)) | |
| warnings = [] | |
| cleaned = {} | |
| for k, v in user_inputs.items(): | |
| info = feat_map[k] | |
| if v is None: | |
| v = float(info["ui_default"]) | |
| warnings.append(f"- {UI_LABELS.get(k,k)} estaba vacío; se usó un valor típico.") | |
| try: | |
| v = float(v) | |
| except Exception: | |
| v = float(info["ui_default"]) | |
| warnings.append(f"- {UI_LABELS.get(k,k)} no era numérico; se usó un valor típico.") | |
| rec_min, rec_max = float(info["p05"]), float(info["p95"]) | |
| obs_min, obs_max = float(info["min"]), float(info["max"]) | |
| if v < rec_min or v > rec_max: | |
| warnings.append( | |
| f"- {UI_LABELS.get(k,k)} fuera de rango típico (típico {fmt_int(rec_min)}–{fmt_int(rec_max)})." | |
| ) | |
| if v < obs_min or v > obs_max: | |
| warnings.append( | |
| f"- {UI_LABELS.get(k,k)} fuera del rango observado (obs {fmt_int(obs_min)}–{fmt_int(obs_max)})." | |
| ) | |
| if CLIP_TO_OBSERVED: | |
| v_clipped = min(max(v, obs_min), obs_max) | |
| if v_clipped != v: | |
| warnings.append( | |
| f"- {UI_LABELS.get(k,k)} se ajustó a {fmt_int(v_clipped)} para mantenerlo dentro del rango observado." | |
| ) | |
| v = v_clipped | |
| cleaned[k] = v | |
| X_in = pd.DataFrame([cleaned], columns=FEATURES) | |
| proba = float(clf.predict_proba(X_in)[:, 1][0]) | |
| if proba >= 0.70: | |
| verdict = "Alta probabilidad de victoria" | |
| elif proba >= 0.55: | |
| verdict = "Probabilidad moderada de victoria" | |
| else: | |
| verdict = "Baja probabilidad de victoria" | |
| warn_text = "Sin advertencias." if not warnings else "\n".join(warnings) | |
| return proba, verdict, warn_text | |
| # ------------------------- | |
| # Build inputs (Number) con rangos en label (sin min/max) | |
| # ------------------------- | |
| input_components_by_col = {} | |
| for col in FEATURES: | |
| info = feat_map[col] | |
| rec_min = float(info["p05"]) | |
| rec_max = float(info["p95"]) | |
| obs_min = float(info["min"]) | |
| obs_max = float(info["max"]) | |
| default = float(info["ui_default"]) | |
| step = float(info.get("ui_step", 1.0)) | |
| if col in ("TeamTotalGold", "TeamXp"): | |
| step = max(step, 50.0) | |
| label = ( | |
| f"{UI_LABELS.get(col, col)}\n" | |
| f"(típico {fmt_int(rec_min)}–{fmt_int(rec_max)}; observado {fmt_int(obs_min)}–{fmt_int(obs_max)})" | |
| ) | |
| input_components_by_col[col] = gr.Number( | |
| label=label, | |
| value=default, | |
| step=step, | |
| precision=0, | |
| ) | |
| inputs_in_order = [input_components_by_col[c] for c in FEATURES] | |
| # ------------------------- | |
| # UI | |
| # ------------------------- | |
| CSS = """ | |
| #wrap { max-width: 1040px; margin: 0 auto; } | |
| .card { | |
| border: 1px solid rgba(255,255,255,0.12); | |
| border-radius: 16px; | |
| background: rgba(255,255,255,0.06); | |
| padding: 16px; | |
| } | |
| .small { opacity: 0.85; font-size: 0.95rem; line-height: 1.3rem; } | |
| .muted { opacity: 0.80; font-size: 0.9rem; } | |
| .gifbox { | |
| border: 1px solid rgba(255,255,255,0.12); | |
| border-radius: 16px; | |
| background: rgba(255,255,255,0.06); | |
| padding: 12px; | |
| } | |
| .gifbox img { | |
| width: 100%; | |
| border-radius: 12px; | |
| display: block; | |
| } | |
| """ | |
| theme = gr.themes.Soft() | |
| gif_uri = gif_data_uri(GIF_PATH) | |
| gif_html = f""" | |
| <div class="gifbox"> | |
| {"<img src='" + gif_uri + "' alt='Faker GIF'>" if gif_uri else "<div class='muted'>No se encontró faker.gif en el repo.</div>"} | |
| <div class="muted" style="margin-top:8px;"> | |
| Fuente del GIF: | |
| <a href="https://thegamehaus.com/league-of-legends/league-of-legends-faker-history-of-success/2019/04/14/" | |
| target="_blank" rel="noopener noreferrer">TheGameHaus (2019)</a> | |
| </div> | |
| </div> | |
| """.strip() | |
| with gr.Blocks(title="Predicción de victoria (demo)") as demo: | |
| with gr.Column(elem_id="wrap"): | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| gr.Markdown( | |
| """ | |
| # Predicción de victoria (demo) | |
| Formulario para estimar probabilidad de ganar a partir de métricas del equipo en early game (primeros 15 minutos). | |
| """.strip() | |
| ) | |
| gr.Markdown( | |
| "<div class='small'>Ingresa valores y presiona Predecir. Si te sales de rangos típicos, la app te avisa (y puede ajustar al rango observado).</div>" | |
| ) | |
| with gr.Column(scale=2): | |
| gr.HTML(gif_html) | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| with gr.Column(elem_classes="card"): | |
| gr.Markdown("## Ingresa los valores de cada variable") | |
| for group_name, cols in GROUPS.items(): | |
| with gr.Accordion(label=group_name, open=True): | |
| for c in cols: | |
| input_components_by_col[c].render() | |
| with gr.Row(): | |
| btn_predict = gr.Button("Predecir", variant="primary") | |
| btn_reset = gr.Button("Restaurar valores típicos", variant="secondary") | |
| with gr.Column(scale=2): | |
| with gr.Column(elem_classes="card"): | |
| gr.Markdown("## Resultado") | |
| out_proba = gr.Number(label="Probabilidad de victoria", precision=4) | |
| out_verdict = gr.Textbox(label="Interpretación", lines=1, interactive=False) | |
| out_warn = gr.Textbox(label="Advertencias", lines=10, interactive=False) | |
| with gr.Column(elem_classes="card"): | |
| gr.Markdown("## Datos y créditos") | |
| gr.Markdown( | |
| """ | |
| <div class="small"> | |
| El modelo fue entrenado con el dataset de Kaggle: | |
| <a href="https://www.kaggle.com/datasets/karlorusovan/league-of-legends-soloq-matches-at-10-minutes-2024/data" | |
| target="_blank" rel="noopener noreferrer">League of Legends SoloQ Matches at 10 Minutes (2024)</a>. | |
| </div> | |
| """.strip() | |
| ) | |
| btn_predict.click( | |
| fn=predict, | |
| inputs=inputs_in_order, | |
| outputs=[out_proba, out_verdict, out_warn], | |
| ) | |
| btn_reset.click( | |
| fn=reset_values, | |
| inputs=[], | |
| outputs=inputs_in_order, | |
| ) | |
| demo.launch(css=CSS, theme=theme, ssr_mode=False) | |