Spaces:
Paused
Paused
replace hardcoded demo with live World Cup model
Browse files- README.md +17 -11
- app.py +239 -98
- drama.py +212 -126
- feed.py +83 -0
- requirements.txt +2 -0
- tests/test_drama.py +71 -18
README.md
CHANGED
|
@@ -9,20 +9,23 @@ python_version: "3.12"
|
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
| 12 |
-
short_description:
|
| 13 |
---
|
| 14 |
|
| 15 |
# DramaMeter 2026
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
## What it
|
| 20 |
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
-
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
| 26 |
|
| 27 |
## Run locally
|
| 28 |
|
|
@@ -33,10 +36,13 @@ pip install -r requirements.txt
|
|
| 33 |
python app.py
|
| 34 |
```
|
| 35 |
|
| 36 |
-
Run
|
| 37 |
|
| 38 |
-
##
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
-
|
|
|
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
| 12 |
+
short_description: Live, explainable World Cup drama analytics
|
| 13 |
---
|
| 14 |
|
| 15 |
# DramaMeter 2026
|
| 16 |
|
| 17 |
+
A live football analytics product for the 2026 World Cup. Pick a current, upcoming or recent fixture and get a pre-match forecast, a live drama index, the match events behind it and a post-ready take.
|
| 18 |
|
| 19 |
+
## What makes it real
|
| 20 |
|
| 21 |
+
- Loads the current 2026 World Cup schedule, scores and match statistics from ESPN Match Center's public JSON feed.
|
| 22 |
+
- Retrains a gradient-boosted tree ensemble on every completed tournament match at startup.
|
| 23 |
+
- Builds rolling pre-match features without using future match statistics.
|
| 24 |
+
- Reports held-out MAE and improvement over a naive mean baseline instead of hiding model quality.
|
| 25 |
+
- Replaces the forecast with an observed live index once a match starts.
|
| 26 |
+
- Shows fetch time, data source, model inputs and feature importance in the UI.
|
| 27 |
|
| 28 |
+
The observed target is an editorial proxy derived from fouls, cards, goals, score closeness, late goals, stage and extra time. It is not a claim about social-media sentiment and it is not betting advice.
|
| 29 |
|
| 30 |
## Run locally
|
| 31 |
|
|
|
|
| 36 |
python app.py
|
| 37 |
```
|
| 38 |
|
| 39 |
+
Run tests with `python -m pytest`.
|
| 40 |
|
| 41 |
+
## Architecture
|
| 42 |
|
| 43 |
+
- `feed.py` — live data client, 30–45 second cache and fixture selection
|
| 44 |
+
- `drama.py` — rolling features, observed target and gradient-boosted model training
|
| 45 |
+
- `app.py` — responsive Gradio product UI and data-driven match report
|
| 46 |
+
- `tests/` — deterministic model and feed-contract tests
|
| 47 |
|
| 48 |
+
Data availability depends on ESPN's public Match Center endpoints. Cached data is used if a refresh fails after at least one successful request.
|
app.py
CHANGED
|
@@ -1,129 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from html import escape
|
| 2 |
|
| 3 |
import gradio as gr
|
| 4 |
import spaces
|
| 5 |
|
| 6 |
-
from drama import
|
|
|
|
| 7 |
|
| 8 |
|
| 9 |
-
# The existing Space is pinned to ZeroGPU and refuses CPU-only apps without a marker.
|
| 10 |
@spaces.GPU(duration=1)
|
| 11 |
def zero_gpu_marker():
|
| 12 |
return True
|
| 13 |
|
| 14 |
|
| 15 |
CSS = """
|
| 16 |
-
:root
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
.
|
| 28 |
-
.
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
"""
|
| 33 |
|
| 34 |
|
| 35 |
-
def
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
"""
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
<div class="moment-card">
|
| 56 |
-
<span>CONTROVERSY RATING</span>
|
| 57 |
-
<strong>{result['score']}/100</strong>
|
| 58 |
-
<p>{escape(result['reaction'])}</p>
|
| 59 |
</div>
|
| 60 |
"""
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
-
def recap(home, away, home_goals, away_goals, key_moment, fan_heat):
|
| 66 |
-
result = post_match(home, away, home_goals, away_goals, key_moment, fan_heat)
|
| 67 |
-
return result["report"], result["share"]
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
team_choices = sorted(TEAMS)
|
| 71 |
|
| 72 |
with gr.Blocks(title="DramaMeter 2026") as demo:
|
| 73 |
gr.HTML("""
|
| 74 |
-
<
|
| 75 |
-
<
|
| 76 |
-
<
|
| 77 |
-
</
|
| 78 |
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
with gr.Column(scale=4):
|
| 84 |
-
team_a = gr.Dropdown(team_choices, value="England", allow_custom_value=True, label="Team one")
|
| 85 |
-
team_b = gr.Dropdown(team_choices, value="Argentina", allow_custom_value=True, label="Team two")
|
| 86 |
-
stage = gr.Radio(list(("Group stage", "Round of 16", "Quarter-final", "Semi-final", "Final")), value="Quarter-final", label="Stage")
|
| 87 |
-
var_heat = gr.Slider(0, 100, value=72, step=1, label="Tournament VAR heat", info="Your read on how angry the tournament feels")
|
| 88 |
-
forecast_btn = gr.Button("Calculate the drama", variant="primary")
|
| 89 |
-
with gr.Column(scale=5):
|
| 90 |
-
forecast_card = gr.HTML()
|
| 91 |
-
forecast_notes = gr.Markdown()
|
| 92 |
-
forecast_share = gr.Textbox(label="Copy this post", buttons=["copy"], interactive=False)
|
| 93 |
-
|
| 94 |
-
forecast_btn.click(forecast, [team_a, team_b, stage, var_heat], [forecast_card, forecast_notes, forecast_share])
|
| 95 |
-
demo.load(forecast, [team_a, team_b, stage, var_heat], [forecast_card, forecast_notes, forecast_share])
|
| 96 |
-
|
| 97 |
-
with gr.Tab("VAR Moment"):
|
| 98 |
-
with gr.Row():
|
| 99 |
-
with gr.Column():
|
| 100 |
-
moment = gr.Textbox(lines=5, label="What happened?", placeholder="88th minute. The ball hits a defender's arm in the box. VAR says no penalty.")
|
| 101 |
-
minute = gr.Slider(1, 120, value=88, step=1, label="Minute")
|
| 102 |
-
tension = gr.Slider(0, 100, value=78, step=1, label="Match tension")
|
| 103 |
-
moment_btn = gr.Button("Rate the controversy", variant="primary")
|
| 104 |
-
with gr.Column():
|
| 105 |
-
moment_card = gr.HTML()
|
| 106 |
-
moment_notes = gr.Markdown()
|
| 107 |
-
moment_btn.click(controversy, [moment, minute, tension], [moment_card, moment_notes])
|
| 108 |
-
|
| 109 |
-
with gr.Tab("Full-time Recap"):
|
| 110 |
-
with gr.Row():
|
| 111 |
-
with gr.Column():
|
| 112 |
-
home = gr.Dropdown(team_choices, value="England", allow_custom_value=True, label="Home team")
|
| 113 |
-
away = gr.Dropdown(team_choices, value="Argentina", allow_custom_value=True, label="Away team")
|
| 114 |
-
with gr.Row():
|
| 115 |
-
home_goals = gr.Number(value=1, minimum=0, precision=0, label="Home goals")
|
| 116 |
-
away_goals = gr.Number(value=2, minimum=0, precision=0, label="Away goals")
|
| 117 |
-
key_moment = gr.Textbox(lines=4, label="The moment everyone is arguing about")
|
| 118 |
-
fan_heat = gr.Slider(0, 100, value=85, step=1, label="Fan heat")
|
| 119 |
-
recap_btn = gr.Button("Write the recap", variant="primary")
|
| 120 |
-
with gr.Column():
|
| 121 |
-
report = gr.Markdown()
|
| 122 |
-
recap_share = gr.Textbox(label="Copy this post", lines=3, buttons=["copy"], interactive=False)
|
| 123 |
-
recap_btn.click(recap, [home, away, home_goals, away_goals, key_moment, fan_heat], [report, recap_share])
|
| 124 |
-
|
| 125 |
-
gr.HTML("<div class='disclaimer'>Fan-made entertainment. No live data. No betting advice. Referees may disagree.</div>")
|
| 126 |
|
| 127 |
|
| 128 |
if __name__ == "__main__":
|
| 129 |
-
demo.launch(css=CSS)
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
from html import escape
|
| 5 |
|
| 6 |
import gradio as gr
|
| 7 |
import spaces
|
| 8 |
|
| 9 |
+
from drama import card_counts, competitors, h2h, observed_index, stat, train_and_score
|
| 10 |
+
from feed import FeedError, MATCH_PAGE, find_event, fixture_choices, summary, tournament
|
| 11 |
|
| 12 |
|
|
|
|
| 13 |
@spaces.GPU(duration=1)
|
| 14 |
def zero_gpu_marker():
|
| 15 |
return True
|
| 16 |
|
| 17 |
|
| 18 |
CSS = """
|
| 19 |
+
:root, .dark {
|
| 20 |
+
--body-background-fill: #090b0d !important;
|
| 21 |
+
--body-text-color: #f5f3ed !important;
|
| 22 |
+
--block-background-fill: #12161a !important;
|
| 23 |
+
--block-border-color: #2b3238 !important;
|
| 24 |
+
--input-background-fill: #0d1013 !important;
|
| 25 |
+
--input-border-color: #384149 !important;
|
| 26 |
+
--input-placeholder-color: #7f898f !important;
|
| 27 |
+
--button-secondary-background-fill: #171c20 !important;
|
| 28 |
+
--button-secondary-text-color: #f5f3ed !important;
|
| 29 |
+
}
|
| 30 |
+
html, body, .gradio-container { background: #090b0d !important; color: #f5f3ed !important; }
|
| 31 |
+
.gradio-container { max-width: 1180px !important; padding: 0 22px 60px !important; }
|
| 32 |
+
.gradio-container label, .gradio-container .label-wrap span, .gradio-container .prose,
|
| 33 |
+
.gradio-container p, .gradio-container h1, .gradio-container h2, .gradio-container h3 { color: #f5f3ed !important; }
|
| 34 |
+
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 34px 0 26px; border-bottom: 1px solid #2b3238; }
|
| 35 |
+
.brand { font: 900 clamp(2rem, 5vw, 4.4rem)/.85 Arial Black, sans-serif; letter-spacing: -.075em; text-transform: uppercase; }
|
| 36 |
+
.brand span { color: #c7ff2f; }
|
| 37 |
+
.tagline { max-width: 340px; color: #aab3b8; font-size: .9rem; line-height: 1.45; text-align: right; }
|
| 38 |
+
.live-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #ff503f; margin-right: 8px; box-shadow: 0 0 0 5px #ff503f22; }
|
| 39 |
+
.control-row { margin: 24px 0 18px; }
|
| 40 |
+
#fixture { flex: 1; }
|
| 41 |
+
#refresh { max-width: 180px; align-self: end; }
|
| 42 |
+
button#refresh { min-height: 48px; background: #c7ff2f !important; color: #090b0d !important; border: 0 !important; font-weight: 900 !important; }
|
| 43 |
+
.match-shell { border: 1px solid #30373d; background: #101418; overflow: hidden; }
|
| 44 |
+
.match-head { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 15px 20px; border-bottom: 1px solid #30373d; color: #aab3b8; font-size: .76rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
| 45 |
+
.status-live { color: #ff6557; }
|
| 46 |
+
.scoreboard { display: grid; grid-template-columns: 1fr minmax(170px, .6fr) 1fr; align-items: center; gap: 24px; padding: 34px 28px 30px; }
|
| 47 |
+
.team { display: flex; align-items: center; gap: 16px; min-width: 0; }
|
| 48 |
+
.team.away { flex-direction: row-reverse; text-align: right; }
|
| 49 |
+
.team img { width: 66px; height: 66px; object-fit: contain; }
|
| 50 |
+
.team-name { font-size: clamp(1.1rem, 2.5vw, 1.85rem); font-weight: 900; }
|
| 51 |
+
.team-form { color: #8c979d; font-size: .78rem; margin-top: 5px; }
|
| 52 |
+
.score { text-align: center; font: 900 clamp(3rem, 8vw, 6.2rem)/.8 Arial Black, sans-serif; letter-spacing: -.08em; white-space: nowrap; }
|
| 53 |
+
.clock { color: #c7ff2f; font-size: .78rem; font-weight: 900; letter-spacing: .08em; margin-top: 14px; }
|
| 54 |
+
.index-band { display: grid; grid-template-columns: 230px 1fr; gap: 28px; padding: 26px 28px; background: #c7ff2f; color: #090b0d; }
|
| 55 |
+
.index-band, .index-band * { color: #090b0d !important; }
|
| 56 |
+
.index-number { font: 900 5rem/.8 Arial Black, sans-serif; letter-spacing: -.08em; }
|
| 57 |
+
.index-label { font-weight: 900; font-size: .78rem; letter-spacing: .08em; margin-top: 14px; }
|
| 58 |
+
.meter-track { height: 13px; background: #090b0d33; margin: 14px 0 12px; }
|
| 59 |
+
.meter-fill { height: 100%; background: #090b0d; }
|
| 60 |
+
.index-copy { font-weight: 750; line-height: 1.45; max-width: 630px; }
|
| 61 |
+
.stat-grid { display: grid; grid-template-columns: repeat(6, 1fr); border-top: 1px solid #30373d; border-bottom: 1px solid #30373d; }
|
| 62 |
+
.stat { padding: 19px 16px; border-right: 1px solid #30373d; }
|
| 63 |
+
.stat:last-child { border-right: 0; }
|
| 64 |
+
.stat b { display: block; font-size: 1.45rem; }
|
| 65 |
+
.stat span { color: #879198; font-size: .7rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
|
| 66 |
+
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; }
|
| 67 |
+
.detail { padding: 26px 28px 30px; }
|
| 68 |
+
.detail + .detail { border-left: 1px solid #30373d; }
|
| 69 |
+
.detail h3 { margin: 0 0 18px; font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; }
|
| 70 |
+
.signal { display: flex; justify-content: space-between; gap: 20px; padding: 11px 0; border-bottom: 1px solid #262d32; color: #aeb7bc; }
|
| 71 |
+
.signal b { color: #f5f3ed; }
|
| 72 |
+
.event { display: grid; grid-template-columns: 58px 1fr; gap: 12px; padding: 10px 0; border-bottom: 1px solid #262d32; }
|
| 73 |
+
.event time { color: #c7ff2f; font-weight: 900; }
|
| 74 |
+
.event p { color: #c8ced1 !important; margin: 0; font-size: .86rem; line-height: 1.4; }
|
| 75 |
+
.source-line { padding: 16px 28px; color: #778187; font-size: .72rem; border-top: 1px solid #30373d; }
|
| 76 |
+
.source-line a { color: #c7ff2f !important; }
|
| 77 |
+
.model-card { color: #bdc5c9; line-height: 1.55; }
|
| 78 |
+
.model-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: #30373d; border: 1px solid #30373d; margin: 16px 0 20px; }
|
| 79 |
+
.model-metric { background: #101418; padding: 18px; }
|
| 80 |
+
.model-metric b { display: block; color: #f5f3ed; font-size: 1.55rem; }
|
| 81 |
+
.model-metric span { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; }
|
| 82 |
+
.error-card { border: 1px solid #ff6557; padding: 24px; color: #ffb0a9; background: #281411; }
|
| 83 |
+
.footer-note { color: #6f797f; font-size: .72rem; padding: 18px 0; }
|
| 84 |
+
@media (max-width: 760px) {
|
| 85 |
+
.topbar { align-items: flex-start; padding-top: 24px; }
|
| 86 |
+
.tagline { display: none; }
|
| 87 |
+
.scoreboard { grid-template-columns: 1fr 120px 1fr; padding: 26px 16px; gap: 8px; }
|
| 88 |
+
.team, .team.away { flex-direction: column; text-align: center; gap: 8px; }
|
| 89 |
+
.team img { width: 48px; height: 48px; }
|
| 90 |
+
.team-form { display: none; }
|
| 91 |
+
.index-band { grid-template-columns: 1fr; gap: 18px; }
|
| 92 |
+
.stat-grid { grid-template-columns: repeat(3, 1fr); }
|
| 93 |
+
.stat:nth-child(3) { border-right: 0; }
|
| 94 |
+
.detail-grid { grid-template-columns: 1fr; }
|
| 95 |
+
.detail + .detail { border-left: 0; border-top: 1px solid #30373d; }
|
| 96 |
+
.model-grid { grid-template-columns: repeat(2, 1fr); }
|
| 97 |
+
}
|
| 98 |
"""
|
| 99 |
|
| 100 |
|
| 101 |
+
def logo(team: dict) -> str:
|
| 102 |
+
return escape(team.get("team", {}).get("logo", ""), quote=True)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def timeline(summary_data: dict) -> str:
|
| 106 |
+
wanted = {"goal", "goal---header", "penalty---scored", "yellow-card", "red-card", "own-goal"}
|
| 107 |
+
events = [item for item in summary_data.get("keyEvents", []) if item.get("type", {}).get("type") in wanted]
|
| 108 |
+
rows = []
|
| 109 |
+
for item in events[-8:]:
|
| 110 |
+
minute = escape(item.get("clock", {}).get("displayValue", "—"))
|
| 111 |
+
text = escape(item.get("shortText") or item.get("text") or item.get("type", {}).get("text", "Match event"))
|
| 112 |
+
rows.append(f'<div class="event"><time>{minute}</time><p>{text}</p></div>')
|
| 113 |
+
return "".join(rows) or '<div class="signal"><span>No key events yet</span><b>Pre-match</b></div>'
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def match_html(event: dict, summary_data: dict, model: dict, meta: dict) -> tuple[str, str, str]:
|
| 117 |
+
home, away = competitors(event)
|
| 118 |
+
state = event["status"]["type"]["state"]
|
| 119 |
+
actual = observed_index(event) if state != "pre" else None
|
| 120 |
+
index = actual if actual is not None else model["forecast"]
|
| 121 |
+
index_kind = "LIVE DRAMA INDEX" if state == "in" else "FINAL DRAMA INDEX" if state == "post" else "PRE-MATCH FORECAST"
|
| 122 |
+
status = event["status"]["type"]["description"]
|
| 123 |
+
kickoff = datetime.fromisoformat(event["date"].replace("Z", "+00:00")).strftime("%b %d · %H:%M UTC")
|
| 124 |
+
clock = kickoff if state == "pre" else event["status"].get("displayClock", "FT")
|
| 125 |
+
scoreline = "VS" if state == "pre" else f"{home.get('score', '–')}–{away.get('score', '–')}"
|
| 126 |
+
yellow, red = card_counts(event)
|
| 127 |
+
fouls = round(stat(home, "foulsCommitted") + stat(away, "foulsCommitted"))
|
| 128 |
+
shots = round(stat(home, "totalShots") + stat(away, "totalShots"))
|
| 129 |
+
on_target = round(stat(home, "shotsOnTarget") + stat(away, "shotsOnTarget"))
|
| 130 |
+
h2h_data = h2h(summary_data)
|
| 131 |
+
venue = summary_data.get("gameInfo", {}).get("venue", {})
|
| 132 |
+
officials = summary_data.get("gameInfo", {}).get("officials", [])
|
| 133 |
+
referee = officials[0].get("displayName", "Not listed") if officials else "Not listed"
|
| 134 |
+
stage = model["features"]["stage"]
|
| 135 |
+
stale = " · cached fallback" if meta.get("stale") else ""
|
| 136 |
+
|
| 137 |
+
if state == "pre":
|
| 138 |
+
lead = f"The model expects {stage.lower()} pressure, based on both teams’ tournament form before kickoff."
|
| 139 |
+
else:
|
| 140 |
+
lead = f"{fouls} fouls, {yellow + red} cards and {shots} shots are driving the live index. Pre-match model: {model['forecast']}/100."
|
| 141 |
+
|
| 142 |
+
top_signals = "".join(
|
| 143 |
+
f'<div class="signal"><span>{escape(name.title())}</span><b>{weight:.1f}%</b></div>'
|
| 144 |
+
for name, weight in model["importances"][:5]
|
| 145 |
+
)
|
| 146 |
+
dashboard = f"""
|
| 147 |
+
<section class="match-shell">
|
| 148 |
+
<div class="match-head">
|
| 149 |
+
<span class="{'status-live' if state == 'in' else ''}">{escape(status)} · {escape(clock)}</span>
|
| 150 |
+
<span>{escape(stage)} · {escape(venue.get('fullName', 'Venue TBC'))}</span>
|
| 151 |
+
</div>
|
| 152 |
+
<div class="scoreboard">
|
| 153 |
+
<div class="team">
|
| 154 |
+
<img src="{logo(home)}" alt="{escape(home['team']['displayName'])} badge">
|
| 155 |
+
<div><div class="team-name">{escape(home['team']['displayName'])}</div><div class="team-form">FORM {escape(home.get('form') or '—')}</div></div>
|
| 156 |
+
</div>
|
| 157 |
+
<div class="score">{escape(scoreline)}<div class="clock">{escape(clock)}</div></div>
|
| 158 |
+
<div class="team away">
|
| 159 |
+
<img src="{logo(away)}" alt="{escape(away['team']['displayName'])} badge">
|
| 160 |
+
<div><div class="team-name">{escape(away['team']['displayName'])}</div><div class="team-form">FORM {escape(away.get('form') or '—')}</div></div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
<div class="index-band">
|
| 164 |
+
<div><div class="index-number">{index}</div><div class="index-label">{index_kind}</div></div>
|
| 165 |
+
<div class="index-copy"><div class="meter-track"><div class="meter-fill" style="width:{index}%"></div></div>{escape(lead)}</div>
|
| 166 |
+
</div>
|
| 167 |
+
<div class="stat-grid">
|
| 168 |
+
<div class="stat"><b>{model['forecast']}</b><span>Model forecast</span></div>
|
| 169 |
+
<div class="stat"><b>{model['confidence']}%</b><span>Confidence</span></div>
|
| 170 |
+
<div class="stat"><b>{fouls or '—'}</b><span>Fouls</span></div>
|
| 171 |
+
<div class="stat"><b>{yellow + red or '—'}</b><span>Cards</span></div>
|
| 172 |
+
<div class="stat"><b>{shots or '—'}</b><span>Shots</span></div>
|
| 173 |
+
<div class="stat"><b>{on_target or '—'}</b><span>On target</span></div>
|
| 174 |
+
</div>
|
| 175 |
+
<div class="detail-grid">
|
| 176 |
+
<div class="detail"><h3>What the model sees</h3>{top_signals}</div>
|
| 177 |
+
<div class="detail"><h3>Match timeline</h3>{timeline(summary_data)}</div>
|
| 178 |
+
</div>
|
| 179 |
+
<div class="stat-grid">
|
| 180 |
+
<div class="stat"><b>{h2h_data['games']}</b><span>Recent H2H</span></div>
|
| 181 |
+
<div class="stat"><b>{h2h_data['world_cups']}</b><span>World Cup H2H</span></div>
|
| 182 |
+
<div class="stat"><b>{h2h_data['shootouts']}</b><span>H2H shootouts</span></div>
|
| 183 |
+
<div class="stat"><b>{escape(referee)}</b><span>Referee</span></div>
|
| 184 |
+
<div class="stat"><b>{model['features']['home_ppg']}</b><span>Home PPG</span></div>
|
| 185 |
+
<div class="stat"><b>{model['features']['away_ppg']}</b><span>Away PPG</span></div>
|
| 186 |
+
</div>
|
| 187 |
+
<div class="source-line">Live match data from <a href="{MATCH_PAGE.format(event['id'])}" target="_blank">ESPN Match Center</a> · fetched {escape(meta['fetched_at'])}{stale} · refresh cache 30–45 seconds</div>
|
| 188 |
+
</section>
|
| 189 |
"""
|
| 190 |
+
model_card = f"""
|
| 191 |
+
<div class="model-card">
|
| 192 |
+
<p>This is a live, explainable model — not an LLM guess. A gradient-boosted tree ensemble is retrained from scratch on completed 2026 World Cup matches using only information available before each kickoff.</p>
|
| 193 |
+
<div class="model-grid">
|
| 194 |
+
<div class="model-metric"><b>{model['samples']}</b><span>Training matches</span></div>
|
| 195 |
+
<div class="model-metric"><b>{model['mae']}</b><span>Held-out MAE</span></div>
|
| 196 |
+
<div class="model-metric"><b>{model['baseline_lift']}%</b><span>MAE lift vs baseline</span></div>
|
| 197 |
+
<div class="model-metric"><b>{model['features']['prior_games']}</b><span>Team games seen</span></div>
|
| 198 |
+
</div>
|
| 199 |
+
<p><b>Target:</b> an observed match-drama proxy derived from fouls, cards, goals, score closeness, late goals, knockout stage and extra time. <b>Forecast inputs:</b> rolling team PPG, goals, fouls, cards, shots, stage and tournament experience. The target is an editorial index, not social-media ground truth or betting advice.</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
</div>
|
| 201 |
"""
|
| 202 |
+
if state == "pre":
|
| 203 |
+
share = f"{home['team']['displayName']} vs {away['team']['displayName']}: {model['forecast']}/100 pre-match DramaMeter forecast. Trained on {model['samples']} World Cup matches. #DramaMeter2026"
|
| 204 |
+
else:
|
| 205 |
+
phase = "live" if state == "in" else "final"
|
| 206 |
+
share = f"{home['team']['displayName']} {home['score']}–{away['score']} {away['team']['displayName']}: {phase} DramaMeter {index}/100. {fouls} fouls, {yellow + red} cards, {shots} shots. #DramaMeter2026"
|
| 207 |
+
return dashboard, share, model_card
|
| 208 |
+
|
| 209 |
|
| 210 |
+
def render(event_id: str, force: bool = False):
|
| 211 |
+
try:
|
| 212 |
+
events, feed_meta = tournament(force)
|
| 213 |
+
event = find_event(events, event_id)
|
| 214 |
+
summary_data, summary_meta = summary(event_id, force)
|
| 215 |
+
model = train_and_score(events, event)
|
| 216 |
+
meta = {
|
| 217 |
+
"fetched_at": max(feed_meta["fetched_at"], summary_meta["fetched_at"]),
|
| 218 |
+
"stale": feed_meta["stale"] or summary_meta["stale"],
|
| 219 |
+
}
|
| 220 |
+
return match_html(event, summary_data, model, meta)
|
| 221 |
+
except FeedError as exc:
|
| 222 |
+
return f'<div class="error-card"><b>Live feed error</b><br>{escape(str(exc))}</div>', "", ""
|
| 223 |
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
+
def load_dashboard():
|
| 226 |
+
try:
|
| 227 |
+
events, _ = tournament()
|
| 228 |
+
choices, default = fixture_choices(events)
|
| 229 |
+
dashboard, share, model_card = render(default)
|
| 230 |
+
return gr.update(choices=choices, value=default), dashboard, share, model_card
|
| 231 |
+
except FeedError as exc:
|
| 232 |
+
error = f'<div class="error-card"><b>Live feed error</b><br>{escape(str(exc))}</div>'
|
| 233 |
+
return gr.update(choices=[], value=None), error, "", ""
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def refresh_dashboard(event_id: str):
|
| 237 |
+
try:
|
| 238 |
+
events, _ = tournament(force=True)
|
| 239 |
+
choices, default = fixture_choices(events)
|
| 240 |
+
selected = event_id if any(value == event_id for _, value in choices) else default
|
| 241 |
+
dashboard, share, model_card = render(selected, force=True)
|
| 242 |
+
return gr.update(choices=choices, value=selected), dashboard, share, model_card
|
| 243 |
+
except FeedError as exc:
|
| 244 |
+
error = f'<div class="error-card"><b>Live feed error</b><br>{escape(str(exc))}</div>'
|
| 245 |
+
return gr.update(), error, "", ""
|
| 246 |
|
|
|
|
| 247 |
|
| 248 |
with gr.Blocks(title="DramaMeter 2026") as demo:
|
| 249 |
gr.HTML("""
|
| 250 |
+
<header class="topbar">
|
| 251 |
+
<div class="brand">DramaMeter <span>2026</span></div>
|
| 252 |
+
<div class="tagline"><span class="live-dot"></span>Live World Cup match data. A model you can inspect. A score you can argue with.</div>
|
| 253 |
+
</header>
|
| 254 |
""")
|
| 255 |
+
with gr.Row(elem_classes="control-row"):
|
| 256 |
+
fixture = gr.Dropdown([], label="Match radar", info="Live, upcoming and latest World Cup fixtures", elem_id="fixture")
|
| 257 |
+
refresh = gr.Button("Refresh live data", elem_id="refresh")
|
| 258 |
+
dashboard = gr.HTML('<div class="match-shell"><div class="source-line">Loading the live tournament feed…</div></div>')
|
| 259 |
+
share = gr.Textbox(label="Post-ready take", buttons=["copy"], interactive=False)
|
| 260 |
+
with gr.Accordion("Model & data card", open=False):
|
| 261 |
+
model_card = gr.HTML()
|
| 262 |
+
gr.HTML('<div class="footer-note">Independent fan analytics project. Match data is attributed to ESPN Match Center. No betting advice.</div>')
|
| 263 |
|
| 264 |
+
fixture.change(render, fixture, [dashboard, share, model_card], show_progress="minimal")
|
| 265 |
+
refresh.click(refresh_dashboard, fixture, [fixture, dashboard, share, model_card], show_progress="minimal")
|
| 266 |
+
demo.load(load_dashboard, outputs=[fixture, dashboard, share, model_card], show_progress="minimal")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
|
| 269 |
if __name__ == "__main__":
|
| 270 |
+
demo.launch(theme=gr.themes.Base(), css=CSS)
|
drama.py
CHANGED
|
@@ -1,137 +1,223 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
"
|
| 13 |
-
"
|
| 14 |
-
"
|
| 15 |
-
"
|
| 16 |
-
"
|
| 17 |
-
"
|
| 18 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
frozenset(("Portugal", "Spain")): (82, "neighbours, stars and an Iberian point to prove"),
|
| 30 |
-
frozenset(("England", "Scotland")): (88, "the oldest fixture in football needs no warm-up"),
|
| 31 |
}
|
| 32 |
|
| 33 |
-
STAGE_BONUS = {
|
| 34 |
-
"Group stage": 0,
|
| 35 |
-
"Round of 16": 3,
|
| 36 |
-
"Quarter-final": 6,
|
| 37 |
-
"Semi-final": 9,
|
| 38 |
-
"Final": 12,
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
def clamp(value: float, low: int = 0, high: int = 100) -> int:
|
| 43 |
-
return max(low, min(high, round(value)))
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def clean_team(name: str) -> str:
|
| 47 |
-
name = re.sub(r"\s+", " ", (name or "").strip())
|
| 48 |
-
return name[:40] or "Unknown XI"
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def stable_number(*parts: str, low: int = 58, span: int = 24) -> int:
|
| 52 |
-
key = "|".join(part.casefold() for part in parts).encode()
|
| 53 |
-
return low + int(hashlib.sha256(key).hexdigest()[:8], 16) % span
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
]
|
| 81 |
-
|
| 82 |
-
"
|
| 83 |
-
"
|
| 84 |
-
"
|
| 85 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
-
return {"teams": (a, b), "score": score, "verdict": verdict, "narratives": narratives, "factors": factors}
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
MOMENT_RULES = {
|
| 91 |
-
"handball": (24, "Hand of God, 1986 — one touch can live for generations"),
|
| 92 |
-
"penalty": (19, "Robben vs Mexico, 2014 — the penalty argument never really ended"),
|
| 93 |
-
"red card": (17, "Beckham vs Argentina, 1998 — one red card can own the whole story"),
|
| 94 |
-
"offside": (15, "tight tournament offside calls — one freeze-frame splits the room"),
|
| 95 |
-
"goal line": (25, "Lampard vs Germany, 2010 — the goal everybody saw except the officials"),
|
| 96 |
-
"var": (13, "modern VAR delays — the wait often makes the anger worse"),
|
| 97 |
-
"dive": (16, "big-stage simulation rows — replay rarely ends the argument"),
|
| 98 |
-
"foul": (9, "classic knockout flashpoints — the second tackle changes the mood"),
|
| 99 |
-
}
|
| 100 |
|
| 101 |
|
| 102 |
-
def
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
reaction = "Heavy outrage. Expect referee clips, conspiracy posts and rival fans piling in."
|
| 115 |
-
elif score >= 55:
|
| 116 |
-
reaction = "Proper argument territory. The replay will be posted all night."
|
| 117 |
-
else:
|
| 118 |
-
reaction = "Some noise, but the next goal probably kills the story."
|
| 119 |
-
|
| 120 |
-
return {"score": score, "reaction": reaction, "echo": echo, "moment": moment or "No moment entered"}
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def post_match(home: str, away: str, home_goals: float, away_goals: float, key_moment: str, fan_heat: float) -> dict:
|
| 124 |
-
a, b = clean_team(home), clean_team(away)
|
| 125 |
-
ga, gb = max(0, int(home_goals)), max(0, int(away_goals))
|
| 126 |
-
moment = re.sub(r"\s+", " ", (key_moment or "").strip())
|
| 127 |
-
if ga == gb:
|
| 128 |
-
lead = f"{a} and {b} finished {ga}-{gb}, but nobody left with the same version of the match."
|
| 129 |
-
else:
|
| 130 |
-
winner = a if ga > gb else b
|
| 131 |
-
lead = f"{winner} took it {ga}-{gb}. The score is clean; the night was not."
|
| 132 |
-
|
| 133 |
-
mood = "furious" if fan_heat >= 80 else "boiling" if fan_heat >= 60 else "split" if fan_heat >= 40 else "surprisingly calm"
|
| 134 |
-
detail = moment or "The biggest argument arrived after the final whistle"
|
| 135 |
-
report = f"{lead}\n\n{detail.rstrip('.')} became the clip everyone replayed. Fan mood: {mood}. Tomorrow the result stays; the argument gets louder."
|
| 136 |
-
share = f"FT: {a} {ga}-{gb} {b}. {detail.rstrip('.')}. Drama never needs extra time. #DramaMeter2026"
|
| 137 |
-
return {"report": report, "share": share, "mood": mood}
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from collections import defaultdict
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from sklearn.ensemble import HistGradientBoostingRegressor
|
| 7 |
+
from sklearn.inspection import permutation_importance
|
| 8 |
+
from sklearn.metrics import mean_absolute_error, r2_score
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
FEATURES = (
|
| 12 |
+
"stage pressure",
|
| 13 |
+
"combined points per game",
|
| 14 |
+
"form gap",
|
| 15 |
+
"goals scored per game",
|
| 16 |
+
"goals allowed per game",
|
| 17 |
+
"fouls per game",
|
| 18 |
+
"cards per game",
|
| 19 |
+
"shots per game",
|
| 20 |
+
"tournament experience",
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
STAGES = {
|
| 24 |
+
"group-stage": 0.15,
|
| 25 |
+
"round-of-32": 0.3,
|
| 26 |
+
"round-of-16": 0.48,
|
| 27 |
+
"quarterfinals": 0.68,
|
| 28 |
+
"semifinals": 0.86,
|
| 29 |
+
"3rd-place-match": 0.5,
|
| 30 |
+
"final": 1.0,
|
| 31 |
}
|
| 32 |
|
| 33 |
+
STAGE_NAMES = {
|
| 34 |
+
"group-stage": "Group stage",
|
| 35 |
+
"round-of-32": "Round of 32",
|
| 36 |
+
"round-of-16": "Round of 16",
|
| 37 |
+
"quarterfinals": "Quarter-final",
|
| 38 |
+
"semifinals": "Semi-final",
|
| 39 |
+
"3rd-place-match": "Third-place match",
|
| 40 |
+
"final": "Final",
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
+
def clamp(value: float) -> int:
|
| 45 |
+
return max(0, min(100, round(value)))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def competitors(event: dict) -> tuple[dict, dict]:
|
| 49 |
+
teams = event["competitions"][0]["competitors"]
|
| 50 |
+
ordered = sorted(teams, key=lambda item: item["homeAway"] != "home")
|
| 51 |
+
return ordered[0], ordered[1]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def stat(team: dict, name: str) -> float:
|
| 55 |
+
for item in team.get("statistics", []):
|
| 56 |
+
if item.get("name") == name:
|
| 57 |
+
try:
|
| 58 |
+
return float(str(item.get("displayValue", 0)).replace("%", ""))
|
| 59 |
+
except ValueError:
|
| 60 |
+
return 0.0
|
| 61 |
+
return 0.0
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def card_counts(event: dict) -> tuple[int, int]:
|
| 65 |
+
yellow = red = 0
|
| 66 |
+
for detail in event["competitions"][0].get("details", []):
|
| 67 |
+
kind = detail.get("type", {}).get("text", "").lower()
|
| 68 |
+
yellow += "yellow card" in kind
|
| 69 |
+
red += "red card" in kind
|
| 70 |
+
return yellow, red
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def team_cards(event: dict, team_id: str) -> int:
|
| 74 |
+
total = 0
|
| 75 |
+
for detail in event["competitions"][0].get("details", []):
|
| 76 |
+
if str(detail.get("team", {}).get("id")) != str(team_id):
|
| 77 |
+
continue
|
| 78 |
+
kind = detail.get("type", {}).get("text", "").lower()
|
| 79 |
+
total += 1 if "yellow card" in kind else 2 if "red card" in kind else 0
|
| 80 |
+
return total
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def observed_index(event: dict) -> int:
|
| 84 |
+
home, away = competitors(event)
|
| 85 |
+
yellow, red = card_counts(event)
|
| 86 |
+
fouls = stat(home, "foulsCommitted") + stat(away, "foulsCommitted")
|
| 87 |
+
goals = int(float(home.get("score", 0))) + int(float(away.get("score", 0)))
|
| 88 |
+
score_gap = abs(int(float(home.get("score", 0))) - int(float(away.get("score", 0))))
|
| 89 |
+
details = event["competitions"][0].get("details", [])
|
| 90 |
+
late_goal = any(
|
| 91 |
+
"goal" in detail.get("type", {}).get("text", "").lower()
|
| 92 |
+
and float(detail.get("clock", {}).get("value", 0)) >= 75 * 60
|
| 93 |
+
for detail in details
|
| 94 |
+
)
|
| 95 |
+
status = event["status"]["type"].get("description", "").lower()
|
| 96 |
+
knockout = STAGES.get(event.get("season", {}).get("slug", ""), 0.15)
|
| 97 |
+
raw = 10 + fouls * 0.78 + yellow * 4.8 + red * 12 + min(goals, 6) * 3.2
|
| 98 |
+
raw += (10 if score_gap <= 1 else 0) + (9 if late_goal else 0) + knockout * 12
|
| 99 |
+
raw += 10 if "extra time" in status or "penalties" in status else 0
|
| 100 |
+
return clamp(raw)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _empty_form() -> dict:
|
| 104 |
+
return {"games": 0, "points": 0, "gf": 0, "ga": 0, "fouls": 0.0, "cards": 0, "shots": 0.0}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _rate(form: dict, key: str, fallback: float) -> float:
|
| 108 |
+
return form[key] / form["games"] if form["games"] else fallback
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _features(event: dict, forms: dict[str, dict]) -> tuple[list[float], dict]:
|
| 112 |
+
home, away = competitors(event)
|
| 113 |
+
a = forms[home["team"]["id"]]
|
| 114 |
+
b = forms[away["team"]["id"]]
|
| 115 |
+
ppg_a, ppg_b = _rate(a, "points", 1.5), _rate(b, "points", 1.5)
|
| 116 |
+
values = [
|
| 117 |
+
STAGES.get(event.get("season", {}).get("slug", ""), 0.15),
|
| 118 |
+
(ppg_a + ppg_b) / 6,
|
| 119 |
+
abs(ppg_a - ppg_b) / 3,
|
| 120 |
+
(_rate(a, "gf", 1.25) + _rate(b, "gf", 1.25)) / 6,
|
| 121 |
+
(_rate(a, "ga", 1.25) + _rate(b, "ga", 1.25)) / 6,
|
| 122 |
+
(_rate(a, "fouls", 11.5) + _rate(b, "fouls", 11.5)) / 35,
|
| 123 |
+
(_rate(a, "cards", 1.8) + _rate(b, "cards", 1.8)) / 8,
|
| 124 |
+
(_rate(a, "shots", 10) + _rate(b, "shots", 10)) / 35,
|
| 125 |
+
min((a["games"] + b["games"]) / 10, 1),
|
| 126 |
]
|
| 127 |
+
raw = {
|
| 128 |
+
"stage": STAGE_NAMES.get(event.get("season", {}).get("slug", ""), "Tournament match"),
|
| 129 |
+
"home_ppg": round(ppg_a, 2),
|
| 130 |
+
"away_ppg": round(ppg_b, 2),
|
| 131 |
+
"combined_fouls_pg": round(_rate(a, "fouls", 11.5) + _rate(b, "fouls", 11.5), 1),
|
| 132 |
+
"combined_cards_pg": round(_rate(a, "cards", 1.8) + _rate(b, "cards", 1.8), 1),
|
| 133 |
+
"combined_shots_pg": round(_rate(a, "shots", 10) + _rate(b, "shots", 10), 1),
|
| 134 |
+
"prior_games": a["games"] + b["games"],
|
| 135 |
+
}
|
| 136 |
+
return values, raw
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _update(forms: dict[str, dict], event: dict) -> None:
|
| 140 |
+
home, away = competitors(event)
|
| 141 |
+
home_score, away_score = int(float(home["score"])), int(float(away["score"]))
|
| 142 |
+
for team, scored, allowed in (
|
| 143 |
+
(home, home_score, away_score),
|
| 144 |
+
(away, away_score, home_score),
|
| 145 |
+
):
|
| 146 |
+
form = forms[team["team"]["id"]]
|
| 147 |
+
form["games"] += 1
|
| 148 |
+
form["points"] += 3 if scored > allowed else 1 if scored == allowed else 0
|
| 149 |
+
form["gf"] += scored
|
| 150 |
+
form["ga"] += allowed
|
| 151 |
+
form["fouls"] += stat(team, "foulsCommitted")
|
| 152 |
+
form["cards"] += team_cards(event, team["team"]["id"])
|
| 153 |
+
form["shots"] += stat(team, "totalShots")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def train_and_score(events: list[dict], target: dict) -> dict:
|
| 157 |
+
ordered = sorted(events, key=lambda item: item["date"])
|
| 158 |
+
forms: dict[str, dict] = defaultdict(_empty_form)
|
| 159 |
+
x: list[list[float]] = []
|
| 160 |
+
y: list[int] = []
|
| 161 |
+
target_x = target_raw = None
|
| 162 |
+
|
| 163 |
+
for event in ordered:
|
| 164 |
+
features, raw = _features(event, forms)
|
| 165 |
+
if str(event["id"]) == str(target["id"]):
|
| 166 |
+
target_x, target_raw = features, raw
|
| 167 |
+
continue
|
| 168 |
+
if event["date"] > target["date"]:
|
| 169 |
+
continue
|
| 170 |
+
if event["status"]["type"].get("completed"):
|
| 171 |
+
x.append(features)
|
| 172 |
+
y.append(observed_index(event))
|
| 173 |
+
_update(forms, event)
|
| 174 |
+
|
| 175 |
+
if target_x is None:
|
| 176 |
+
target_x, target_raw = _features(target, forms)
|
| 177 |
+
|
| 178 |
+
x_data, y_data = np.asarray(x), np.asarray(y)
|
| 179 |
+
split = max(30, int(len(x_data) * 0.8))
|
| 180 |
+
eval_model = HistGradientBoostingRegressor(
|
| 181 |
+
max_iter=120, max_leaf_nodes=8, l2_regularization=2, random_state=26
|
| 182 |
+
)
|
| 183 |
+
eval_model.fit(x_data[:split], y_data[:split])
|
| 184 |
+
held_out = eval_model.predict(x_data[split:])
|
| 185 |
+
mae = mean_absolute_error(y_data[split:], held_out)
|
| 186 |
+
r2 = r2_score(y_data[split:], held_out)
|
| 187 |
+
baseline_mae = mean_absolute_error(y_data[split:], np.full(len(y_data[split:]), y_data[:split].mean()))
|
| 188 |
+
baseline_lift = (baseline_mae - mae) / baseline_mae * 100
|
| 189 |
+
|
| 190 |
+
model = HistGradientBoostingRegressor(
|
| 191 |
+
max_iter=160, max_leaf_nodes=8, l2_regularization=2, random_state=26
|
| 192 |
+
)
|
| 193 |
+
model.fit(x_data, y_data)
|
| 194 |
+
forecast = clamp(model.predict(np.asarray([target_x]))[0])
|
| 195 |
+
permuted = permutation_importance(model, x_data, y_data, n_repeats=8, random_state=26)
|
| 196 |
+
weights = np.maximum(permuted.importances_mean, 0)
|
| 197 |
+
weights = weights / weights.sum() if weights.sum() else np.ones(len(FEATURES)) / len(FEATURES)
|
| 198 |
+
importances = sorted(zip(FEATURES, weights), key=lambda item: item[1], reverse=True)
|
| 199 |
+
confidence = clamp(88 - mae * 1.6 + min(target_raw["prior_games"], 10))
|
| 200 |
+
return {
|
| 201 |
+
"forecast": forecast,
|
| 202 |
+
"confidence": confidence,
|
| 203 |
+
"samples": len(x),
|
| 204 |
+
"mae": round(float(mae), 1),
|
| 205 |
+
"r2": round(float(r2), 2),
|
| 206 |
+
"baseline_lift": round(float(baseline_lift), 1),
|
| 207 |
+
"features": target_raw,
|
| 208 |
+
"importances": [(name, round(float(weight) * 100, 1)) for name, weight in importances],
|
| 209 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
|
| 212 |
+
def h2h(summary: dict) -> dict:
|
| 213 |
+
groups = summary.get("headToHeadGames", [])
|
| 214 |
+
games = groups[0].get("events", []) if groups else []
|
| 215 |
+
world_cups = [game for game in games if "World Cup" in game.get("leagueName", "")]
|
| 216 |
+
shootouts = [game for game in games if int(game.get("homeShootoutScore", 0)) or int(game.get("awayShootoutScore", 0))]
|
| 217 |
+
latest = games[0] if games else None
|
| 218 |
+
return {
|
| 219 |
+
"games": len(games),
|
| 220 |
+
"world_cups": len(world_cups),
|
| 221 |
+
"shootouts": len(shootouts),
|
| 222 |
+
"latest": f"{latest['competitionName']} · {latest['score']}" if latest else "No H2H record returned",
|
| 223 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
feed.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
|
| 6 |
+
import requests
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
SCOREBOARD = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard?dates=2026&limit=200"
|
| 10 |
+
SUMMARY = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/summary?event={}"
|
| 11 |
+
MATCH_PAGE = "https://www.espn.com/soccer/match/_/gameId/{}"
|
| 12 |
+
|
| 13 |
+
_session = requests.Session()
|
| 14 |
+
_session.headers["User-Agent"] = "DramaMeter2026/2.0 (+https://github.com/yava-code/DramaMeter-2026)"
|
| 15 |
+
_cache: dict[str, tuple[float, dict, str]] = {}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FeedError(RuntimeError):
|
| 19 |
+
pass
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _get(url: str, ttl: int, force: bool = False) -> tuple[dict, str, bool]:
|
| 23 |
+
now = time.monotonic()
|
| 24 |
+
cached = _cache.get(url)
|
| 25 |
+
if cached and not force and now - cached[0] < ttl:
|
| 26 |
+
return cached[1], cached[2], False
|
| 27 |
+
|
| 28 |
+
try:
|
| 29 |
+
response = _session.get(url, timeout=12)
|
| 30 |
+
response.raise_for_status()
|
| 31 |
+
fetched_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 32 |
+
data = response.json()
|
| 33 |
+
_cache[url] = (now, data, fetched_at)
|
| 34 |
+
return data, fetched_at, False
|
| 35 |
+
except (requests.RequestException, ValueError) as exc:
|
| 36 |
+
if cached:
|
| 37 |
+
return cached[1], cached[2], True
|
| 38 |
+
raise FeedError("The live football feed is unavailable. Try refresh in a moment.") from exc
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def tournament(force: bool = False) -> tuple[list[dict], dict]:
|
| 42 |
+
data, fetched_at, stale = _get(SCOREBOARD, 45, force)
|
| 43 |
+
return data.get("events", []), {"fetched_at": fetched_at, "stale": stale}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def summary(event_id: str, force: bool = False) -> tuple[dict, dict]:
|
| 47 |
+
data, fetched_at, stale = _get(SUMMARY.format(event_id), 30, force)
|
| 48 |
+
return data, {"fetched_at": fetched_at, "stale": stale}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def find_event(events: list[dict], event_id: str) -> dict:
|
| 52 |
+
for event in events:
|
| 53 |
+
if str(event.get("id")) == str(event_id):
|
| 54 |
+
return event
|
| 55 |
+
raise FeedError("That fixture is no longer in the tournament feed.")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def fixture_choices(events: list[dict]) -> tuple[list[tuple[str, str]], str | None]:
|
| 59 |
+
live = [event for event in events if event["status"]["type"]["state"] == "in"]
|
| 60 |
+
upcoming = [event for event in events if event["status"]["type"]["state"] == "pre"]
|
| 61 |
+
finished = [event for event in events if event["status"]["type"]["state"] == "post"][-10:]
|
| 62 |
+
visible = live + upcoming + list(reversed(finished))
|
| 63 |
+
choices = [(fixture_label(event), str(event["id"])) for event in visible]
|
| 64 |
+
latest = finished[-1] if finished else None
|
| 65 |
+
recent = latest and (
|
| 66 |
+
datetime.now(timezone.utc) - datetime.fromisoformat(latest["date"].replace("Z", "+00:00"))
|
| 67 |
+
).total_seconds() < 8 * 60 * 60
|
| 68 |
+
preferred = live or ([latest] if recent else []) or upcoming or list(reversed(finished))
|
| 69 |
+
default = str(preferred[0]["id"]) if preferred else None
|
| 70 |
+
return choices, default
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def fixture_label(event: dict) -> str:
|
| 74 |
+
teams = sorted(event["competitions"][0]["competitors"], key=lambda item: item["homeAway"] != "home")
|
| 75 |
+
home, away = teams
|
| 76 |
+
home_name, away_name = home["team"]["displayName"], away["team"]["displayName"]
|
| 77 |
+
state = event["status"]["type"]["state"]
|
| 78 |
+
if state == "in":
|
| 79 |
+
return f"LIVE {event['status']['displayClock']} · {home_name} {home['score']}–{away['score']} {away_name}"
|
| 80 |
+
if state == "post":
|
| 81 |
+
return f"FT · {home_name} {home['score']}–{away['score']} {away_name}"
|
| 82 |
+
kickoff = datetime.fromisoformat(event["date"].replace("Z", "+00:00"))
|
| 83 |
+
return f"{kickoff:%b %d · %H:%M UTC} · {home_name} vs {away_name}"
|
requirements.txt
CHANGED
|
@@ -1,3 +1,5 @@
|
|
| 1 |
gradio==6.20.0
|
| 2 |
pytest==8.4.1
|
|
|
|
|
|
|
| 3 |
spaces==0.51.0
|
|
|
|
| 1 |
gradio==6.20.0
|
| 2 |
pytest==8.4.1
|
| 3 |
+
requests==2.34.2
|
| 4 |
+
scikit-learn==1.9.0
|
| 5 |
spaces==0.51.0
|
tests/test_drama.py
CHANGED
|
@@ -1,30 +1,83 @@
|
|
| 1 |
-
from
|
| 2 |
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
def test_england_argentina_is_box_office():
|
| 5 |
-
result = match_forecast("England", "Argentina", "Quarter-final", 72)
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
-
def
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
assert first == second
|
| 16 |
-
assert 0 <= first["score"] <= 100
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
late = analyze_moment("VAR checks a handball and possible penalty", 91, 80)
|
| 21 |
-
early = analyze_moment("A foul in midfield", 12, 40)
|
| 22 |
|
| 23 |
-
assert late["score"] > early["score"]
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from copy import deepcopy
|
| 2 |
|
| 3 |
+
from drama import observed_index, train_and_score
|
| 4 |
+
from feed import fixture_choices
|
| 5 |
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
def event(event_id, date, home_id, away_id, home_score, away_score, state="post", stage="group-stage", cards=1):
|
| 8 |
+
details = [
|
| 9 |
+
{"type": {"text": "Yellow Card"}, "clock": {"value": 1800}}
|
| 10 |
+
for _ in range(cards)
|
| 11 |
+
]
|
| 12 |
+
details.append({"type": {"text": "Goal"}, "clock": {"value": 5100}})
|
| 13 |
+
return {
|
| 14 |
+
"id": str(event_id),
|
| 15 |
+
"date": date,
|
| 16 |
+
"season": {"slug": stage},
|
| 17 |
+
"status": {
|
| 18 |
+
"displayClock": "90'",
|
| 19 |
+
"type": {"state": state, "completed": state == "post", "description": "Full Time"},
|
| 20 |
+
},
|
| 21 |
+
"competitions": [{
|
| 22 |
+
"details": details,
|
| 23 |
+
"competitors": [
|
| 24 |
+
{
|
| 25 |
+
"homeAway": "home",
|
| 26 |
+
"score": str(home_score),
|
| 27 |
+
"team": {"id": str(home_id), "displayName": f"Team {home_id}"},
|
| 28 |
+
"statistics": [
|
| 29 |
+
{"name": "foulsCommitted", "displayValue": "12"},
|
| 30 |
+
{"name": "totalShots", "displayValue": "11"},
|
| 31 |
+
],
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"homeAway": "away",
|
| 35 |
+
"score": str(away_score),
|
| 36 |
+
"team": {"id": str(away_id), "displayName": f"Team {away_id}"},
|
| 37 |
+
"statistics": [
|
| 38 |
+
{"name": "foulsCommitted", "displayValue": "10"},
|
| 39 |
+
{"name": "totalShots", "displayValue": "9"},
|
| 40 |
+
],
|
| 41 |
+
},
|
| 42 |
+
],
|
| 43 |
+
}],
|
| 44 |
+
}
|
| 45 |
|
| 46 |
|
| 47 |
+
def tournament_sample(count=40):
|
| 48 |
+
games = []
|
| 49 |
+
for i in range(count):
|
| 50 |
+
games.append(event(i, f"2026-06-{i % 28 + 1:02d}T12:00Z", i % 8, (i + 1) % 8, i % 4, (i + 2) % 3, cards=i % 5))
|
| 51 |
+
return games
|
| 52 |
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
def test_cards_raise_observed_index():
|
| 55 |
+
calm = event(1, "2026-06-01T12:00Z", 1, 2, 1, 0, cards=0)
|
| 56 |
+
heated = deepcopy(calm)
|
| 57 |
+
heated["competitions"][0]["details"] += [
|
| 58 |
+
{"type": {"text": "Yellow Card"}, "clock": {"value": 4000}}
|
| 59 |
+
for _ in range(5)
|
| 60 |
+
]
|
| 61 |
|
| 62 |
+
assert observed_index(heated) > observed_index(calm)
|
|
|
|
|
|
|
| 63 |
|
|
|
|
| 64 |
|
| 65 |
+
def test_model_trains_on_completed_tournament_matches():
|
| 66 |
+
games = tournament_sample()
|
| 67 |
+
target = event(99, "2026-07-15T19:00Z", 1, 4, 0, 0, state="pre", stage="semifinals")
|
| 68 |
+
result = train_and_score(games + [target], target)
|
| 69 |
|
| 70 |
+
assert result["samples"] == 40
|
| 71 |
+
assert 0 <= result["forecast"] <= 100
|
| 72 |
+
assert len(result["importances"]) == 9
|
| 73 |
|
| 74 |
+
|
| 75 |
+
def test_fixture_choices_put_live_match_first():
|
| 76 |
+
finished = event(1, "2026-07-14T19:00Z", 1, 2, 2, 1)
|
| 77 |
+
live = event(2, "2026-07-15T19:00Z", 3, 4, 1, 1, state="in")
|
| 78 |
+
upcoming = event(3, "2026-07-16T19:00Z", 5, 6, 0, 0, state="pre")
|
| 79 |
+
|
| 80 |
+
choices, default = fixture_choices([finished, upcoming, live])
|
| 81 |
+
|
| 82 |
+
assert default == "2"
|
| 83 |
+
assert choices[0][1] == "2"
|