Published Agent Skill package. Follow the instructions in SKILL.md to complete the user's task.

dota2-analyzer

Analyze Dota 2 matches for single-map and series (Bo1/Bo2/Bo3/Bo5) win probabilities using rating-based logistic models strengthened by recent form, H2H, patch meta and optional Poisson kill or net-worth lead simulation. Extract team rankings, form and stats from DLTV, Dotabuff, OpenDota, STRATZ and Liquipedia via browse_page. Supports pre-draft strength estimates and post-draft adjustments. Use for pre-match predictions, series score distributions, value spotting vs bookmaker or Polymarket odds

This page contains 4 files from the original skill zip. Supporting markdown and scripts are included below so you do not need extra downloads.

Files in this package

SKILL.md

Dota 2 Win Probability Analyzer (Strengthened)

Use for any professional Dota 2 match analysis involving win rates or probabilities. Trigger words include Dota 2, dota2, TI, The International, DreamLeague, ESL One, PGL, Bo3, Bo5, map win, series prediction, Radiant vs Dire, etc.

Core Model (Why This Is Stronger)

Typical calibrated range: even match map_p ≈ 0.48–0.53; clear favorite (large ranking + form gap) map_p ≈ 0.58–0.68. Extreme gaps rarely exceed 0.75 without roster collapse or patch mismatch.

Step-by-Step Workflow (Follow Strictly)

1. Clarify the Query

2. Locate Data Pages

3. Extract Data with browse_page

Use the ready prompts in references/data-extraction-prompts.md.

4. Estimate Inputs (see references/strength-estimation.md)

5. Run the Calculation Script

# Preferred — direct map win probability
python3 /home/workdir/.grok/skills/dota2-analyzer/scripts/calculate_dota2_probs.py \
  --map_p 0.58 --format bo3

# From rating difference (Elo-style logistic)
python3 ... --rating_diff 120 --format bo3

# Strengthened Poisson kill-lead path
python3 ... --kills_a 28.5 --kills_b 24.0 --format map --poisson_lead

# Series with different per-map probabilities (post-draft or scenarios)
python3 ... --map_ps "0.62,0.55,0.48" --format bo3

# Bo5 example
python3 ... --map_p 0.57 --format bo5 --n_sims 100000

6. Present Results Clearly

  1. Match context: teams, format, event, patch, data timestamp, key extracted stats (ranking points, recent form, standout numbers).
  2. Estimated inputs: map_p (or rating_diff / expected kill lead) with one-sentence justification; note any draft adjustments.
  3. Probability table (from script):
    • Single-map win % for A / B (Radiant/Dire if relevant)
    • Series win % for A / B
    • Score distribution (2-0, 2-1, 1-2, 0-2 for Bo3; fuller for Bo5)
  4. Key insights: biggest edges, upset potential, important maps or draft priorities if known.
  5. Market comparison (if odds available): model vs implied probability, rough EV flags.
  6. Caveats & confidence: sample sizes, roster uncertainty, unmodeled draft variance, High/Med/Low confidence.

7. Iteration & Extra Context

Important Rules & Limitations

Bundled Resources

Load these via read_file when needed. Re-run the script whenever inputs change. This converts public Dota 2 data into transparent, defensible win probabilities.

Supporting file: references/data-extraction-prompts.md

This file is part of the dota2-analyzer skill package. Use it when SKILL.md references references/data-extraction-prompts.md.

Ready-to-use browse_page Instructions for Dota 2 Data

Copy-paste or adapt these instructions when calling the browse_page tool. Keep them focused so the summarizer returns structured, usable numbers.

1. DLTV Ranking Page (https://dltv.org/ranking)

Extract the current Dota 2 world ranking. For the two teams [TEAM_A] and [TEAM_B] (or the top 20 if general): list rank, team name, points, and any noted roster core players. Note the ranking date or last update if shown. Summarize how points are calculated if briefly described. Return clean structured list.

2. Dotabuff Team / Esports Page

Search first: site:dotabuff.com/esports [team name] or similar.

From this Dotabuff page extract for the team:
- Overall recent win rate and record (last 20–40 games or time window shown)
- Average match duration
- Average kills / deaths if available
- Recent series results (especially Bo3/Bo5)
- Current or most-played heroes / notable hero pool if listed
- Roster and any stand-in or recent changes mentioned
- Head-to-head notes if present
Focus on numbers with sample sizes. Ignore pure pub data unless pro section is empty.

3. OpenDota Team Page

Extract team performance metrics: win rate over recent matches, average duration, average kills, GPM/XPM if shown, ranking or MMR proxy, recent form (last 10–20 games), and roster. Note any league or pro filter that is active. Provide sample sizes.

4. STRATZ Team or Match Pages

Summarize team or match statistics relevant to win probability: recent win rate, hero performance, average net worth or kill differentials if available, and any AI or model predictions shown. Extract key numerical edges and sample sizes. Note current patch if indicated.

5. Liquipedia Match / Series / Team Page

Extract match or series details: format (Bo1/Bo3/Bo5), date, event, patch if listed, final or ongoing score, roster for both sides (note stand-ins), head-to-head history summary, previous results between these teams on the current or recent patches, and any preview notes about form or absences. List map/game results if series is finished. Keep structured and concise.

6. Tournament Bracket or Group Page (Liquipedia or official)

Identify the relevant matchup between [TEAM_A] and [TEAM_B]. Extract stage, format, scheduled time, current standings impact (must-win etc.), and any listed odds or power rankings. Note nearby results that affect motivation.

7. Meta / Patch Context (Spectral Stats, STRATZ meta, Dotabuff trends)

Summarize the current patch meta relevant to pro play: dominant strategies (push, late-game, specific cores), Radiant win rate if shown, average game length and kill totals, and any major hero win-rate outliers. Keep to 5–8 key bullet points with numbers.

Usage Tips

Supporting file: references/strength-estimation.md

This file is part of the dota2-analyzer skill package. Use it when SKILL.md references references/strength-estimation.md.

Estimating Team Strength and Map Win Probabilities for Dota 2

Translate public rankings and form into inputs for calculate_dota2_probs.py. Prefer the logistic / rating_diff path for most work. Use the Poisson kill-lead path as a second opinion when average kills or GPM are stable.

1. Baseline from Rankings (Primary Signal)

Primary source: DLTV World Ranking (https://dltv.org/ranking).

base_map_p_A = 0.50 + (points_A - points_B) / 2500

Clamp later. For teams far outside top 20 or with brand-new cores, prefer recent form win-rate conversion instead.

Alternative ranking sources (cross-check): Dotabuff team rankings, OpenDota, community Elo lists.

2. Recent Form & Win-Rate Adjustment

From team pages (Dotabuff / OpenDota / STRATZ last 20–40 games or ~3 months):

form_adj = (winrate_A - winrate_B) * 0.20   # conservative

Typical total map_p after ranking + form: 0.47–0.58 for most competitive matches. Rarely go outside 0.40–0.70 without extreme evidence.

3. Head-to-Head, Motivation & Context Tweaks

Small additive adjustments (±0.01–0.04):

4. Draft / Post-Ban Adjustment (High Value When Available)

Pre-draft estimates ignore the largest source of variance. Once heroes are known:

If only partial draft (bans only), use smaller adjustments.

5. Optional Poisson Kill / Net-Worth Lead Path

When team average kills or GPM are reliable:

  1. Extract recent average kills per game (or GPM) for both teams.
  2. Feed into script with --kills_a --kills_b --poisson_lead.
  3. The script models kills ~ Poisson(λ), computes lead distribution, then maps lead → win probability via logistic.
  4. Use the resulting map_p as a cross-check or average it with the ranking-based estimate when both are high quality.

Gold-lead version (manual): if you have expected net-worth difference at ~20–25 min, convert roughly:

6. Radiant / Dire Side

Radiant historically holds a small edge (patch-dependent, often 50.5–52.5%). When side is known or can be assigned:

final_map_p = clamp(map_p + radiant_adv, 0.01, 0.99)

Typical radiant_adv = +0.01 to +0.025. Script supports --radiant_adv.

7. Series Conversion

Once you have a map_p (or list of map_ps):

Always report score distribution (2-0 / 2-1 etc.) and run sensitivity (±0.03–0.05 on map_p).

8. Calibration & Shrinkage Rules

Limitations

Supporting file: scripts/calculate_dota2_probs.py

This file is part of the dota2-analyzer skill package. Use it when SKILL.md references scripts/calculate_dota2_probs.py.

#!/usr/bin/env python3
"""
Dota 2 Map and Series Win Probability Calculator (Strengthened)

Core models:
- Direct map_p or rating-difference logistic (Elo-style) conversion to single-map win probability.
- Exact dynamic programming for Bo1 / Bo2 / Bo3 / Bo5 series score distributions
  (maps treated independent — practical approximation).
- Optional Poisson kill-lead path: model expected kills as Poisson, derive lead
  distribution, then map lead → win probability via calibrated logistic.
  Use as sensitivity / cross-check only.

Examples:
  # Direct map probability
  python3 calculate_dota2_probs.py --map_p 0.58 --format bo3

  # From rating difference (positive = team A stronger)
  python3 calculate_dota2_probs.py --rating_diff 150 --format bo3

  # Series with per-map probabilities
  python3 calculate_dota2_probs.py --map_ps "0.62,0.55,0.48" --format bo3

  # Poisson kill-lead strengthened path
  python3 calculate_dota2_probs.py --kills_a 29.0 --kills_b 24.5 --format map --poisson_lead

  # Bo5 with more sims
  python3 calculate_dota2_probs.py --map_p 0.57 --format bo5 --n_sims 100000
"""

from __future__ import annotations

import argparse
import math
import sys
from collections import defaultdict
from typing import Dict, List, Optional, Tuple

import numpy as np
from scipy.stats import poisson


def logistic_from_rating_diff(rating_diff: float, scale: float = 400.0) -> float:
    """Classic Elo-style win probability. rating_diff > 0 means team A stronger."""
    return 1.0 / (1.0 + 10.0 ** (-rating_diff / scale))


def logistic_from_lead(lead: float, midpoint: float = 0.0, steepness: float = 0.12) -> float:
    """
    Empirical mapping from kill (or gold/10k) lead to win probability.
    Calibrated roughly on pro data: ~+8–10 kill lead or +8–10k net worth mid-game
    corresponds to ~70–75% win probability. Adjust steepness if needed.
    """
    return 1.0 / (1.0 + math.exp(-steepness * (lead - midpoint)))


def poisson_lead_win_prob(
    kills_a: float,
    kills_b: float,
    max_k: int = 60,
    steepness: float = 0.11,
) -> float:
    """
    Model team kills as independent Poisson, compute P(A wins) by summing
    over possible kill outcomes weighted by the lead→win logistic.
    Returns expected win probability for team A.
    """
    if kills_a <= 0 or kills_b <= 0:
        raise ValueError("kills_a and kills_b must be positive")
    p_win = 0.0
    # Truncate Poisson tails
    for ka in range(0, max_k + 1):
        pa = poisson.pmf(ka, kills_a)
        if pa < 1e-9:
            continue
        for kb in range(0, max_k + 1):
            pb = poisson.pmf(kb, kills_b)
            if pb < 1e-9:
                continue
            lead = ka - kb
            p_win += pa * pb * logistic_from_lead(lead, steepness=steepness)
    return float(p_win)


def series_dp(map_ps: List[float], best_of: int) -> Tuple[float, Dict[str, float]]:
    """
    Exact dynamic programming for series win probability and score distribution.
    map_ps: list of P(A wins map i) for i = 0 .. best_of-1 (or shorter; cycles last if needed).
    Returns (P(A wins series), score_dist) where score_diff keys are like "2-0", "2-1".
    """
    if best_of not in (1, 2, 3, 5):
        raise ValueError("best_of must be 1, 2, 3 or 5")
    wins_needed = (best_of // 2) + 1
    # state: (maps_played, wins_a, wins_b) → probability
    # We only need up to wins_needed
    from functools import lru_cache

    @lru_cache(maxsize=None)
    def prob(wa: int, wb: int, idx: int) -> float:
        if wa >= wins_needed:
            return 1.0
        if wb >= wins_needed:
            return 0.0
        if idx >= len(map_ps) and len(map_ps) > 0:
            # reuse last map_p if more maps than provided
            p = map_ps[-1]
        else:
            p = map_ps[idx] if idx < len(map_ps) else map_ps[-1]
        return p * prob(wa + 1, wb, idx + 1) + (1.0 - p) * prob(wa, wb + 1, idx + 1)

    series_p = prob(0, 0, 0)

    # Score distribution via enumeration of paths (small state space)
    score_dist: Dict[str, float] = defaultdict(float)
    # BFS / recursive count of terminating scores
    def collect(wa: int, wb: int, idx: int, path_p: float):
        if wa >= wins_needed or wb >= wins_needed:
            key = f"{wa}-{wb}"
            score_dist[key] += path_p
            return
        p = map_ps[idx] if idx < len(map_ps) else map_ps[-1]
        collect(wa + 1, wb, idx + 1, path_p * p)
        collect(wa, wb + 1, idx + 1, path_p * (1.0 - p))

    collect(0, 0, 0, 1.0)
    # Normalize just in case of floating error
    total = sum(score_dist.values())
    if total > 0:
        for k in score_dist:
            score_dist[k] /= total
    return series_p, dict(score_dist)


def print_results(
    map_p: float,
    format_str: str,
    series_p: Optional[float] = None,
    score_dist: Optional[Dict[str, float]] = None,
    extra: str = "",
):
    print("=" * 60)
    print("Dota 2 Win Probability Results")
    print("=" * 60)
    if extra:
        print(extra)
    print(f"Single-map P(A wins) : {map_p*100:.1f}%")
    print(f"Single-map P(B wins) : {(1-map_p)*100:.1f}%")
    if series_p is not None and score_dist is not None:
        print(f"\nSeries format       : {format_str.upper()}")
        print(f"Series P(A wins)    : {series_p*100:.1f}%")
        print(f"Series P(B wins)    : {(1-series_p)*100:.1f}%")
        print("\nScore distribution:")
        # Sort nicely: higher A scores first
        def sort_key(s: str):
            a, b = map(int, s.split("-"))
            return (-a, b)
        for score in sorted(score_dist.keys(), key=sort_key):
            print(f"  {score:>5} : {score_dist[score]*100:5.1f}%")
    print("=" * 60)


def main():
    parser = argparse.ArgumentParser(
        description="Dota 2 map & series win probability calculator"
    )
    parser.add_argument("--map_p", type=float, help="Direct P(A wins a single map)")
    parser.add_argument(
        "--rating_diff",
        type=float,
        help="Rating difference (A - B). Converted via Elo logistic (scale=400)",
    )
    parser.add_argument(
        "--map_ps",
        type=str,
        help='Comma-separated per-map probs e.g. "0.62,0.55,0.48"',
    )
    parser.add_argument(
        "--kills_a", type=float, help="Expected kills for team A (Poisson path)"
    )
    parser.add_argument(
        "--kills_b", type=float, help="Expected kills for team B (Poisson path)"
    )
    parser.add_argument(
        "--poisson_lead",
        action="store_true",
        help="Use Poisson kill-lead model (requires --kills_a --kills_b)",
    )
    parser.add_argument(
        "--format",
        type=str,
        default="map",
        choices=["map", "bo1", "bo2", "bo3", "bo5"],
        help="Output format: map only or series BoN",
    )
    parser.add_argument(
        "--n_sims",
        type=int,
        default=50000,
        help="(Reserved for future Monte-Carlo extensions)",
    )
    parser.add_argument(
        "--radiant_adv",
        type=float,
        default=0.0,
        help="Additive Radiant side advantage to apply to map_p (e.g. 0.015)",
    )
    args = parser.parse_args()

    map_p: Optional[float] = None
    extra_notes = []

    if args.poisson_lead:
        if args.kills_a is None or args.kills_b is None:
            print("ERROR: --poisson_lead requires --kills_a and --kills_b", file=sys.stderr)
            sys.exit(1)
        map_p = poisson_lead_win_prob(args.kills_a, args.kills_b)
        extra_notes.append(
            f"Poisson kill-lead model: λA={args.kills_a:.1f}, λB={args.kills_b:.1f}"
        )
    elif args.map_ps:
        try:
            map_ps = [float(x.strip()) for x in args.map_ps.split(",")]
            if not map_ps:
                raise ValueError
            for p in map_ps:
                if not (0.0 < p < 1.0):
                    raise ValueError("each map_p must be in (0,1)")
        except Exception as e:
            print(f"ERROR: invalid --map_ps: {e}", file=sys.stderr)
            sys.exit(1)
        # For series we will use the list; for single map report average
        map_p = float(np.mean(map_ps))
        extra_notes.append(f"Per-map probabilities: {map_ps}")
    elif args.rating_diff is not None:
        map_p = logistic_from_rating_diff(args.rating_diff)
        extra_notes.append(f"From rating_diff={args.rating_diff:.1f} (Elo scale=400)")
    elif args.map_p is not None:
        map_p = args.map_p
        if not (0.0 < map_p < 1.0):
            print("ERROR: --map_p must be in (0,1)", file=sys.stderr)
            sys.exit(1)
    else:
        print(
            "ERROR: provide one of --map_p, --rating_diff, --map_ps or --poisson_lead",
            file=sys.stderr,
        )
        sys.exit(1)

    # Optional Radiant advantage
    if args.radiant_adv != 0.0:
        map_p = min(max(map_p + args.radiant_adv, 0.01), 0.99)
        extra_notes.append(f"Applied Radiant advantage {args.radiant_adv:+.3f}")

    fmt = args.format.lower()
    if fmt == "map" or fmt == "bo1":
        print_results(map_p, "map", extra=" | ".join(extra_notes))
        return

    # Series
    best_of = {"bo2": 2, "bo3": 3, "bo5": 5}[fmt]
    if args.map_ps:
        map_ps_list = [float(x.strip()) for x in args.map_ps.split(",")]
        # Pad or trim to reasonable length
        while len(map_ps_list) < best_of:
            map_ps_list.append(map_ps_list[-1])
        map_ps_list = map_ps_list[:best_of]
    else:
        map_ps_list = [map_p] * best_of

    series_p, score_dist = series_dp(map_ps_list, best_of)
    print_results(
        map_p if not args.map_ps else float(np.mean(map_ps_list)),
        fmt,
        series_p=series_p,
        score_dist=score_dist,
        extra=" | ".join(extra_notes),
    )


if __name__ == "__main__":
    main()