CoolFace
Apppublic

thenuke02/cs2-analyzer

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
veto_optimizer.py713 linesDownload Raw Back to ai
1"""2Map veto optimizer for CS2 best-of-1 and best-of-3 formats.3 4Combines map win rates with recency weighting and opponent data5to generate optimal ban/pick sequences.6 7Entirely heuristic — no LLM needed. Pure computation.8"""9 10import logging11import math12from dataclasses import dataclass, field13from typing import Any14 15logger = logging.getLogger(__name__)16 17ACTIVE_MAP_POOL = [18    "de_mirage",19    "de_inferno",20    "de_nuke",21    "de_ancient",22    "de_anubis",23    "de_dust2",24    "de_vertigo",25]26 27 28# =============================================================================29# Dataclasses30# =============================================================================31 32 33@dataclass34class MapStrength:35    """Team's performance on a specific map."""36 37    map_name: str38    matches_played: int = 039    win_rate: float = 0.5  # 0.0-1.040    ct_win_rate: float = 0.541    t_win_rate: float = 0.542    avg_rounds_won: float = 0.043    recency_weighted_win_rate: float = 0.544    confidence: float = 0.0  # higher with more matches played45 46    def to_dict(self) -> dict[str, Any]:47        """Convert to dictionary for JSON serialization."""48        return {49            "map_name": self.map_name,50            "matches_played": self.matches_played,51            "win_rate": round(self.win_rate, 3),52            "ct_win_rate": round(self.ct_win_rate, 3),53            "t_win_rate": round(self.t_win_rate, 3),54            "avg_rounds_won": round(self.avg_rounds_won, 1),55            "recency_weighted_win_rate": round(self.recency_weighted_win_rate, 3),56            "confidence": round(self.confidence, 3),57        }58 59 60@dataclass61class VetoRecommendation:62    """Recommended ban/pick for one step of the veto."""63 64    action: str  # "ban" or "pick"65    map_name: str66    reason: str67    your_win_rate: float = 0.568    opponent_win_rate: float = 0.569    net_advantage: float = 0.0  # your WR - opponent WR70 71    def to_dict(self) -> dict[str, Any]:72        """Convert to dictionary for JSON serialization."""73        return {74            "action": self.action,75            "map_name": self.map_name,76            "reason": self.reason,77            "your_win_rate": round(self.your_win_rate, 3),78            "opponent_win_rate": round(self.opponent_win_rate, 3),79            "net_advantage": round(self.net_advantage, 3),80        }81 82 83@dataclass84class VetoAnalysis:85    """Complete veto analysis for a matchup."""86 87    your_map_pool: list[MapStrength] = field(default_factory=list)88    opponent_map_pool: list[MapStrength] = field(default_factory=list)89    recommended_veto_sequence: list[VetoRecommendation] = field(default_factory=list)90    best_map_for_you: str = ""91    worst_map_for_you: str = ""92    predicted_decider_map: str = ""93    confidence: str = "low"94    format: str = "bo1"95 96    def to_dict(self) -> dict[str, Any]:97        """Convert to dictionary for JSON serialization."""98        return {99            "your_map_pool": [ms.to_dict() for ms in self.your_map_pool],100            "opponent_map_pool": [ms.to_dict() for ms in self.opponent_map_pool],101            "recommended_veto_sequence": [vr.to_dict() for vr in self.recommended_veto_sequence],102            "best_map_for_you": self.best_map_for_you,103            "worst_map_for_you": self.worst_map_for_you,104            "predicted_decider_map": self.predicted_decider_map,105            "confidence": self.confidence,106            "format": self.format,107        }108 109 110# =============================================================================111# VetoOptimizer112# =============================================================================113 114 115class VetoOptimizer:116    """117    Generates optimal map veto sequences for CS2 matches.118 119    Supports BO1 (6 bans, 1 remaining) and BO3 (2 bans, 2 picks, 2 bans, 1 decider).120    """121 122    def analyze(123        self,124        your_demos: list[dict],125        opponent_data: list[dict] | dict,126        format: str = "bo1",127    ) -> VetoAnalysis:128        """129        Generate veto recommendations for an upcoming match.130 131        Args:132            your_demos: List of orchestrator results from your team's matches133            opponent_data: Either a list of orchestrator results OR a scouting134                dict (TeamScoutReport.to_dict() format)135            format: "bo1" or "bo3"136 137        Returns:138            VetoAnalysis with map strengths and recommended veto sequence139        """140        # Compute your map strengths141        your_maps = self._compute_map_strengths(your_demos)142 143        # Compute opponent map strengths144        if isinstance(opponent_data, dict):145            opp_maps = self._map_strengths_from_scouting(opponent_data)146        else:147            opp_maps = self._compute_map_strengths(opponent_data)148 149        # Fill in any missing maps from the active pool with defaults150        your_maps = self._fill_pool(your_maps)151        opp_maps = self._fill_pool(opp_maps)152 153        # Sort by win rate descending for readability154        your_maps.sort(key=lambda m: m.recency_weighted_win_rate, reverse=True)155        opp_maps.sort(key=lambda m: m.recency_weighted_win_rate, reverse=True)156 157        # Generate veto sequence158        if format == "bo3":159            sequence = self._generate_bo3_veto(your_maps, opp_maps)160        else:161            sequence = self._generate_bo1_veto(your_maps, opp_maps)162 163        # Determine best/worst maps164        best = max(your_maps, key=lambda m: m.recency_weighted_win_rate)165        worst = min(your_maps, key=lambda m: m.recency_weighted_win_rate)166 167        # Predict decider (last map in sequence or the non-banned map)168        decider = self._predict_decider(your_maps, opp_maps, format)169 170        # Overall confidence171        total_your_matches = sum(m.matches_played for m in your_maps)172        total_opp_matches = sum(m.matches_played for m in opp_maps)173        if total_your_matches >= 10 and total_opp_matches >= 10:174            confidence = "high"175        elif total_your_matches >= 5 or total_opp_matches >= 5:176            confidence = "medium"177        else:178            confidence = "low"179 180        return VetoAnalysis(181            your_map_pool=your_maps,182            opponent_map_pool=opp_maps,183            recommended_veto_sequence=sequence,184            best_map_for_you=best.map_name,185            worst_map_for_you=worst.map_name,186            predicted_decider_map=decider,187            confidence=confidence,188            format=format,189        )190 191    # =========================================================================192    # Map Strength Computation193    # =========================================================================194 195    def _compute_map_strengths(self, demos: list[dict]) -> list[MapStrength]:196        """197        Compute win rates per map from orchestrator result data.198 199        Assumes team1 (CT first half, score_ct) perspective.200        """201        # Group demos by map202        map_demos: dict[str, list[dict]] = {}203        for demo in demos:204            demo_info = demo.get("demo_info") or {}205            map_name = demo_info.get("map", "")206            if not map_name:207                continue208            map_demos.setdefault(map_name, []).append(demo)209 210        strengths: list[MapStrength] = []211 212        for map_name, demos_list in map_demos.items():213            wins = 0214            ct_round_wins = 0215            ct_round_total = 0216            t_round_wins = 0217            t_round_total = 0218            total_rounds_won = 0219 220            for demo in demos_list:221                demo_info = demo.get("demo_info") or {}222                score_ct = demo_info.get("score_ct", 0)223                score_t = demo_info.get("score_t", 0)224 225                # Team1 (CT first half) is "our" team226                total_rounds_won += score_ct227                if score_ct > score_t:228                    wins += 1229 230                # CT/T side split from round_timeline231                timeline = demo.get("round_timeline") or []232                for rdata in timeline:233                    winner = rdata.get("winner", "")234                    round_num = rdata.get("round_num", 0)235                    is_first_half = round_num <= 12236 237                    if is_first_half:238                        # Our team is CT in first half239                        ct_round_total += 1240                        if winner == "CT":241                            ct_round_wins += 1242                    else:243                        # Our team is T in second half244                        t_round_total += 1245                        if winner == "T":246                            t_round_wins += 1247 248            n = len(demos_list)249            win_rate = wins / n if n > 0 else 0.5250            ct_wr = ct_round_wins / ct_round_total if ct_round_total > 0 else 0.5251            t_wr = t_round_wins / t_round_total if t_round_total > 0 else 0.5252            avg_rounds = total_rounds_won / n if n > 0 else 0.0253 254            recency_wr = self._recency_weight(demos_list)255            confidence = 1 - math.exp(-n / 3)256 257            strengths.append(258                MapStrength(259                    map_name=map_name,260                    matches_played=n,261                    win_rate=win_rate,262                    ct_win_rate=ct_wr,263                    t_win_rate=t_wr,264                    avg_rounds_won=avg_rounds,265                    recency_weighted_win_rate=recency_wr,266                    confidence=confidence,267                )268            )269 270        return strengths271 272    def _map_strengths_from_scouting(self, scouting: dict) -> list[MapStrength]:273        """274        Estimate map strengths from scouting data (TeamScoutReport.to_dict()).275 276        Since scouting data doesn't contain explicit win rates, we estimate277        based on which maps the opponent plays on (maps in their map_tendencies278        are assumed to be their stronger maps).279        """280        map_tendencies = scouting.get("map_tendencies") or []281 282        # Demos analyzed gives us confidence283        demos_analyzed = scouting.get("demos_analyzed", 0)284        base_confidence = 1 - math.exp(-demos_analyzed / 3) if demos_analyzed > 0 else 0.0285 286        strengths: list[MapStrength] = []287        for mt in map_tendencies:288            map_name = mt.get("map_name", "")289            if not map_name:290                continue291 292            # Estimate strength from tendencies293            t_side = mt.get("t_side") or {}294            ct_side = mt.get("ct_side") or {}295 296            t_aggression = t_side.get("aggression", 50)297            ct_aggression = ct_side.get("aggression", 50)298 299            # Higher aggression on T-side + low CT aggression = T-sided team300            # Estimate a moderate win rate for maps they play on301            estimated_wr = 0.55  # They chose to play this map302 303            strengths.append(304                MapStrength(305                    map_name=map_name,306                    matches_played=demos_analyzed,307                    win_rate=estimated_wr,308                    ct_win_rate=0.5 + (ct_aggression - 50) * 0.002,309                    t_win_rate=0.5 + (t_aggression - 50) * 0.002,310                    avg_rounds_won=12.0,  # approximate311                    recency_weighted_win_rate=estimated_wr,312                    confidence=base_confidence * 0.5,  # lower confidence from scouting313                )314            )315 316        return strengths317 318    def _fill_pool(self, strengths: list[MapStrength]) -> list[MapStrength]:319        """320        Ensure all active pool maps are represented.321        Missing maps get 50% win rate with 0 confidence.322        """323        existing = {ms.map_name for ms in strengths}324        for map_name in ACTIVE_MAP_POOL:325            if map_name not in existing:326                strengths.append(MapStrength(map_name=map_name))327        return strengths328 329    def _recency_weight(self, demos: list[dict], half_life: int = 5) -> float:330        """331        Apply exponential decay weighting — recent matches matter more.332 333        Args:334            demos: List of demo dicts (assumed roughly chronological)335            half_life: Number of matches for weight to halve336 337        Returns:338            Recency-weighted win rate (0.0-1.0)339        """340        if not demos:341            return 0.5342 343        total_weight = 0.0344        weighted_wins = 0.0345 346        # Iterate from most recent (end of list) to oldest347        for i, demo in enumerate(reversed(demos)):348            weight = math.exp(-i / half_life)349            total_weight += weight350 351            demo_info = demo.get("demo_info") or {}352            score_ct = demo_info.get("score_ct", 0)353            score_t = demo_info.get("score_t", 0)354 355            if score_ct > score_t:356                weighted_wins += weight357 358        return weighted_wins / total_weight if total_weight > 0 else 0.5359 360    # =========================================================================361    # BO1 Veto Generation362    # =========================================================================363 364    def _generate_bo1_veto(365        self,366        your_maps: list[MapStrength],367        opp_maps: list[MapStrength],368    ) -> list[VetoRecommendation]:369        """370        BO1 veto: each team bans 3 maps, remaining map is played.371 372        Standard CS2 sequence: A ban, B ban, A ban, B ban, A ban, B ban, remaining.373        Strategy: ban opponent's best maps that give them the biggest advantage.374        """375        your_lookup = {ms.map_name: ms for ms in your_maps}376        opp_lookup = {ms.map_name: ms for ms in opp_maps}377 378        remaining = set(ACTIVE_MAP_POOL)379        sequence: list[VetoRecommendation] = []380 381        for step in range(6):382            is_our_ban = step % 2 == 0  # We ban on even steps383 384            if is_our_ban:385                # Our ban: ban the map where opponent has biggest advantage386                best_ban = self._pick_ban_target(387                    remaining, your_lookup, opp_lookup, perspective="ours"388                )389            else:390                # Simulate opponent ban: they'd ban our best map391                best_ban = self._pick_ban_target(392                    remaining, your_lookup, opp_lookup, perspective="theirs"393                )394 395            if best_ban is None:396                break397 398            your_ms = your_lookup.get(best_ban, MapStrength(map_name=best_ban))399            opp_ms = opp_lookup.get(best_ban, MapStrength(map_name=best_ban))400            net = your_ms.recency_weighted_win_rate - opp_ms.recency_weighted_win_rate401 402            if is_our_ban:403                reason = (404                    f"Ban {best_ban} — opponent "405                    f"{opp_ms.recency_weighted_win_rate:.0%} WR"406                    f" ({opp_ms.matches_played} matches)"407                )408            else:409                reason = (410                    f"Opponent bans {best_ban} — your "411                    f"{your_ms.recency_weighted_win_rate:.0%} WR"412                    f" ({your_ms.matches_played} matches)"413                )414 415            sequence.append(416                VetoRecommendation(417                    action="ban",418                    map_name=best_ban,419                    reason=reason,420                    your_win_rate=your_ms.recency_weighted_win_rate,421                    opponent_win_rate=opp_ms.recency_weighted_win_rate,422                    net_advantage=net,423                )424            )425            remaining.discard(best_ban)426 427        # Remaining map is the decider428        if remaining:429            decider = remaining.pop()430            your_ms = your_lookup.get(decider, MapStrength(map_name=decider))431            opp_ms = opp_lookup.get(decider, MapStrength(map_name=decider))432            net = your_ms.recency_weighted_win_rate - opp_ms.recency_weighted_win_rate433 434            sequence.append(435                VetoRecommendation(436                    action="pick",437                    map_name=decider,438                    reason=f"Remaining map — your {your_ms.recency_weighted_win_rate:.0%} "439                    f"vs their {opp_ms.recency_weighted_win_rate:.0%}",440                    your_win_rate=your_ms.recency_weighted_win_rate,441                    opponent_win_rate=opp_ms.recency_weighted_win_rate,442                    net_advantage=net,443                )444            )445 446        return sequence447 448    # =========================================================================449    # BO3 Veto Generation450    # =========================================================================451 452    def _generate_bo3_veto(453        self,454        your_maps: list[MapStrength],455        opp_maps: list[MapStrength],456    ) -> list[VetoRecommendation]:457        """458        BO3 veto: ban-ban-pick-pick-ban-ban-decider.459 460        Standard CS2 sequence:461          1. Team A ban462          2. Team B ban463          3. Team A pick464          4. Team B pick465          5. Team A ban466          6. Team B ban467          7. Remaining map is decider468        """469        your_lookup = {ms.map_name: ms for ms in your_maps}470        opp_lookup = {ms.map_name: ms for ms in opp_maps}471 472        remaining = set(ACTIVE_MAP_POOL)473        sequence: list[VetoRecommendation] = []474 475        # Step 1: Our ban — ban opponent's best map476        target = self._pick_ban_target(remaining, your_lookup, opp_lookup, "ours")477        if target:478            self._add_step(479                sequence, "ban", target, your_lookup, opp_lookup, "Ban opponent's strongest map"480            )481            remaining.discard(target)482 483        # Step 2: Opponent ban — they ban our best map484        target = self._pick_ban_target(remaining, your_lookup, opp_lookup, "theirs")485        if target:486            self._add_step(487                sequence, "ban", target, your_lookup, opp_lookup, "Opponent bans your strongest map"488            )489            remaining.discard(target)490 491        # Step 3: Our pick — pick our best remaining map492        target = self._pick_best_map(remaining, your_lookup, opp_lookup, "ours")493        if target:494            self._add_step(495                sequence,496                "pick",497                target,498                your_lookup,499                opp_lookup,500                "Pick your strongest remaining map",501            )502            remaining.discard(target)503 504        # Step 4: Opponent pick — they pick their best remaining map505        target = self._pick_best_map(remaining, your_lookup, opp_lookup, "theirs")506        if target:507            self._add_step(508                sequence,509                "pick",510                target,511                your_lookup,512                opp_lookup,513                "Opponent picks their strongest remaining map",514            )515            remaining.discard(target)516 517        # Step 5: Our ban518        target = self._pick_ban_target(remaining, your_lookup, opp_lookup, "ours")519        if target:520            self._add_step(521                sequence, "ban", target, your_lookup, opp_lookup, "Ban opponent's best remaining"522            )523            remaining.discard(target)524 525        # Step 6: Opponent ban526        target = self._pick_ban_target(remaining, your_lookup, opp_lookup, "theirs")527        if target:528            self._add_step(529                sequence,530                "ban",531                target,532                your_lookup,533                opp_lookup,534                "Opponent bans your best remaining",535            )536            remaining.discard(target)537 538        # Step 7: Remaining map is decider539        if remaining:540            decider = remaining.pop()541            self._add_step(sequence, "pick", decider, your_lookup, opp_lookup, "Decider map")542 543        return sequence544 545    # =========================================================================546    # Veto Helpers547    # =========================================================================548 549    def _pick_ban_target(550        self,551        remaining: set[str],552        your_lookup: dict[str, MapStrength],553        opp_lookup: dict[str, MapStrength],554        perspective: str,555    ) -> str | None:556        """557        Pick the best map to ban from remaining pool.558 559        perspective="ours": ban the map where opponent has biggest advantage560        perspective="theirs": simulate opponent banning our best map561        """562        if not remaining:563            return None564 565        if perspective == "ours":566            # Ban the map where opponent advantage is biggest567            # i.e., opp_wr - your_wr is maximized568            return max(569                remaining,570                key=lambda m: (571                    opp_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate572                    - your_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate573                ),574            )575        else:576            # Opponent bans our best map577            # i.e., your_wr - opp_wr is maximized578            return max(579                remaining,580                key=lambda m: (581                    your_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate582                    - opp_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate583                ),584            )585 586    def _pick_best_map(587        self,588        remaining: set[str],589        your_lookup: dict[str, MapStrength],590        opp_lookup: dict[str, MapStrength],591        perspective: str,592    ) -> str | None:593        """594        Pick the best map to play from remaining pool.595 596        perspective="ours": pick map with highest net advantage for us597        perspective="theirs": pick map with highest net advantage for opponent598        """599        if not remaining:600            return None601 602        if perspective == "ours":603            return max(604                remaining,605                key=lambda m: (606                    your_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate607                    - opp_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate608                ),609            )610        else:611            return max(612                remaining,613                key=lambda m: (614                    opp_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate615                    - your_lookup.get(m, MapStrength(map_name=m)).recency_weighted_win_rate616                ),617            )618 619    def _add_step(620        self,621        sequence: list[VetoRecommendation],622        action: str,623        map_name: str,624        your_lookup: dict[str, MapStrength],625        opp_lookup: dict[str, MapStrength],626        reason_prefix: str,627    ) -> None:628        """Add a veto step to the sequence."""629        your_ms = your_lookup.get(map_name, MapStrength(map_name=map_name))630        opp_ms = opp_lookup.get(map_name, MapStrength(map_name=map_name))631        net = your_ms.recency_weighted_win_rate - opp_ms.recency_weighted_win_rate632 633        reason = (634            f"{reason_prefix}: {map_name} — "635            f"your {your_ms.recency_weighted_win_rate:.0%} vs "636            f"their {opp_ms.recency_weighted_win_rate:.0%}"637        )638 639        sequence.append(640            VetoRecommendation(641                action=action,642                map_name=map_name,643                reason=reason,644                your_win_rate=your_ms.recency_weighted_win_rate,645                opponent_win_rate=opp_ms.recency_weighted_win_rate,646                net_advantage=net,647            )648        )649 650    def _predict_decider(651        self,652        your_maps: list[MapStrength],653        opp_maps: list[MapStrength],654        format: str,655    ) -> str:656        """657        Predict which map will be the decider after optimal vetoes.658        """659        your_lookup = {ms.map_name: ms for ms in your_maps}660        opp_lookup = {ms.map_name: ms for ms in opp_maps}661 662        remaining = set(ACTIVE_MAP_POOL)663 664        if format == "bo3":665            # Simulate: ban-ban-pick-pick-ban-ban-decider666            steps = [667                ("ours", "ban"),668                ("theirs", "ban"),669                ("ours", "pick"),670                ("theirs", "pick"),671                ("ours", "ban"),672                ("theirs", "ban"),673            ]674        else:675            # BO1: 3 bans each676            steps = [677                ("ours", "ban"),678                ("theirs", "ban"),679                ("ours", "ban"),680                ("theirs", "ban"),681                ("ours", "ban"),682                ("theirs", "ban"),683            ]684 685        for perspective, action in steps:686            if not remaining:687                break688            if action == "ban":689                target = self._pick_ban_target(remaining, your_lookup, opp_lookup, perspective)690            else:691                target = self._pick_best_map(remaining, your_lookup, opp_lookup, perspective)692            if target:693                remaining.discard(target)694 695        if remaining:696            return remaining.pop()697        return ""698 699 700# =============================================================================701# Module-level convenience702# =============================================================================703 704_optimizer_instance: VetoOptimizer | None = None705 706 707def get_veto_optimizer() -> VetoOptimizer:708    """Get or create singleton VetoOptimizer instance."""709    global _optimizer_instance710    if _optimizer_instance is None:711        _optimizer_instance = VetoOptimizer()712    return _optimizer_instance713