Files
Obsidian/X-Articles/WHEN DATA OUTWEIGHS INTUITION AN AI SYSTEM FOR NBA PREDICTIONS.md
2026-07-21 14:53:24 +08:00

75 KiB
Raw Permalink Blame History

title, source, author, published, created, description, tags
title source author published created description tags
WHEN DATA OUTWEIGHS INTUITION: AN AI SYSTEM FOR NBA PREDICTIONS https://x.com/zostaff/status/2046228556234072547
@zostaff
2026-04-20 2026-04-26 Complete Technical Guide: From Data Collection to a Working ModelEvery night the NBA generates a data stream no human analyst can process ma...
clippings

图像

Complete Technical Guide: From Data Collection to a Working Model

Every night the NBA generates a data stream no human analyst can process manually: ~100 possessions per team, tracking of 10 players 25 times per second, hundreds of micro-decisions per game. The human brain perceives games through narrative - "LeBron was on fire," "the team fell apart in the fourth quarter." A machine sees field goal percentage, defensive rebounds, turnovers - and finds patterns hidden behind the noise. In this article we build a full prediction system combining three probability layers: sportsbook lines (DraftKings/FanDuel), Polymarket prediction market data (crowd intelligence on the blockchain), and a custom ML model with Claude API as the interpreter. The entire pipeline is in Python: nba_api, pandas, scikit-learn, XGBoost, matplotlib.

System Architecture

The system consists of several layers, each serving its own role:

┌─────────────────────────────────────────────────────────────┐
│                      DATA LAYER                              │
│  nba_api │ Basketball-Reference │ balldontlie │ Kaggle       │
│                                                              │
│  ┌──────────────────────────────────────────────────┐        │
│  │    Polymarket Gamma API (prediction market)     │        │
│  │  Crowd-sourced probabilities on Polygon blockchain  │        │
│  └──────────────────────────────────────────────────┘        │
└──────────────────────────┬───────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                   PROCESSING LAYER                           │
│  pandas │ numpy │ data cleaning │ feature engineering        │
│                                                              │
│  ┌──────────────────────────────────────────────────┐        │
│  │  Claude API: feature generation,                    │        │
│  │  context analysis, stats interpretation             │        │
│  └──────────────────────────────────────────────────┘        │
│                                                              │
│  ┌──────────────────────────────────────────────────┐        │
│  │  Merging 3 probability layers:                      │        │
│  │  Sportsbook lines + Polymarket prices + ML model  │        │
│  └──────────────────────────────────────────────────┘        │
└──────────────────────────┬───────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                     MODEL LAYER                              │
│  Logistic Regression │ Random Forest │ XGBoost               │
│  Ensemble (Voting / Stacking)                                │
└──────────────────────────┬───────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                 INTERPRETATION LAYER                          │
│  Claude API: natural language prediction explanation          │
│  + confidence assessment + divergence analysis                │
│    between sportsbook / Polymarket / ML                       │
└──────────────────────────┬───────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│                    OUTPUT LAYER                               │
│  matplotlib visualizations │ JSON reports │ Telegram bot       │
└─────────────────────────────────────────────────────────────┘

Why the NBA Is the Best Playground for Predictive ML

82 regular season games for each of 30 teams - that's 1,230 games, each with detailed statistics. A binary outcome (Win/Loss, no draws) simplifies the task to pure classification. The best team wins 68–72% of games - patterns are stable and modelable. And the free nba_api provides access to box scores, play-by-play, and shot charts without spending a dollar.

| Parameter | NBA |
|---|---|
| Events per game | ~200 possessions, 80+ shots |
| Typical score | 105–115 |
| Outcomes | 2 (W/L) - pure binary classification |
| Predictability | ~68–72% top team wins |
| API data | Free (nba_api, balldontlie) |
| Tracking data | Second Spectrum (for teams), basic tracking via NBA.com |
| Games/season | 82 (sufficient for rolling averages) |

Required Dependencies

# requirements.txt
anthropic>=0.40.0
nba_api>=1.4.1
pandas>=2.1.0
numpy>=1.24.0
scikit-learn>=1.3.0
xgboost>=2.0.0
matplotlib>=3.8.0
seaborn>=0.13.0
requests>=2.31.0
python-dotenv>=1.0.0
schedule>=1.2.0           # pipeline automation

Installation:

pip install anthropic nba_api pandas numpy scikit-learn xgboost matplotlib seaborn requests python-dotenv schedule

nba_api is a free open-source client for the NBA . com API that requires no authentication. The Polymarket Gamma API is also public.

Data Collection & Preparation

The primary data source is nba_api, which provides access to NBA . com data: box scores, play-by-play, player tracking, shot charts spanning 40+ years. The data includes all standard and advanced statistics.

Data Loading

import pandas as pd
import numpy as np
from nba_api.stats.endpoints import (
    LeagueGameFinder,
    TeamGameLog,
    BoxScoreTraditionalV2,
    LeagueStandings,
)
from nba_api.stats.static import teams
import time

class NBADataLoader:
    """
    Historical NBA game data loader.
    Source: nba_api (wrapper over NBA.com API)
    """

    # NBA.com API requires delays between requests
    REQUEST_DELAY = 0.6  # seconds

    def __init__(self, seasons: list[str]):
        """
        Args:
            seasons: list of seasons in format ["2025-26", "2024-25", ...]
        """
        self.seasons = seasons
        self.team_map = {
            t["id"]: t["abbreviation"]
            for t in teams.get_teams()
        }

    def load_season_games(self, season: str) -> pd.DataFrame:
        """
        Load all NBA regular season games.
        
        nba_api returns one row per
        team per game. We need to merge
        the home and away rows into a single record.
        """
        try:
            finder = LeagueGameFinder(
                season_nullable=season,
                league_id_nullable="00",  # NBA
                season_type_nullable="Regular Season",
            )
            time.sleep(self.REQUEST_DELAY)
            
            df = finder.get_data_frames()[0]
            
            if df.empty:
                print(f"  ⚠ No data for season {season}")
                return pd.DataFrame()

            # Split into home and away
            # MATCHUP contains "vs." for home and "@" for away
            df["IS_HOME"] = df["MATCHUP"].str.contains("vs.").astype(int)
            
            home = df[df["IS_HOME"] == 1].copy()
            away = df[df["IS_HOME"] == 0].copy()

            # Rename columns
            home_cols = {
                "TEAM_ID": "HOME_TEAM_ID",
                "TEAM_ABBREVIATION": "HOME_TEAM",
                "PTS": "HOME_PTS",
                "FGM": "HOME_FGM", "FGA": "HOME_FGA",
                "FG_PCT": "HOME_FG_PCT",
                "FG3M": "HOME_FG3M", "FG3A": "HOME_FG3A",
                "FG3_PCT": "HOME_FG3_PCT",
                "FTM": "HOME_FTM", "FTA": "HOME_FTA",
                "FT_PCT": "HOME_FT_PCT",
                "OREB": "HOME_OREB", "DREB": "HOME_DREB",
                "REB": "HOME_REB",
                "AST": "HOME_AST", "STL": "HOME_STL",
                "BLK": "HOME_BLK", "TOV": "HOME_TOV",
                "PF": "HOME_PF",
                "PLUS_MINUS": "HOME_PLUS_MINUS",
            }
            away_cols = {
                "TEAM_ID": "AWAY_TEAM_ID",
                "TEAM_ABBREVIATION": "AWAY_TEAM",
                "PTS": "AWAY_PTS",
                "FGM": "AWAY_FGM", "FGA": "AWAY_FGA",
                "FG_PCT": "AWAY_FG_PCT",
                "FG3M": "AWAY_FG3M", "FG3A": "AWAY_FG3A",
                "FG3_PCT": "AWAY_FG3_PCT",
                "FTM": "AWAY_FTM", "FTA": "AWAY_FTA",
                "FT_PCT": "AWAY_FT_PCT",
                "OREB": "AWAY_OREB", "DREB": "AWAY_DREB",
                "REB": "AWAY_REB",
                "AST": "AWAY_AST", "STL": "AWAY_STL",
                "BLK": "AWAY_BLK", "TOV": "AWAY_TOV",
                "PF": "AWAY_PF",
                "PLUS_MINUS": "AWAY_PLUS_MINUS",
            }

            home = home.rename(columns=home_cols)
            away = away.rename(columns=away_cols)

            # Merge by GAME_ID
            merged = home.merge(
                away[list(away_cols.values()) + ["GAME_ID"]],
                on="GAME_ID",
                how="inner",
            )

            # Result: 1 = home win, 0 = away win
            merged["HOME_WIN"] = (
                merged["HOME_PTS"] > merged["AWAY_PTS"]
            ).astype(int)
            
            merged["GAME_DATE"] = pd.to_datetime(
                merged["GAME_DATE"]
            )
            merged["Season"] = season
            
            return merged

        except Exception as e:
            print(f"  ⚠ Loading error {season}: {e}")
            return pd.DataFrame()

    def load_all(self) -> pd.DataFrame:
        """Load all seasons."""
        frames = []
        for season in self.seasons:
            df = self.load_season_games(season)
            if not df.empty:
                frames.append(df)
                print(f"  ✓ Season {season}: {len(df)} games")
        
        result = pd.concat(frames, ignore_index=True)
        print(f"\nTotal loaded: {len(result)} games")
        return result

# === Usage ===
loader = NBADataLoader(
    seasons=["2025-26", "2024-25", "2023-24", "2022-23", "2021-22"]
)
raw_data = loader.load_all()

Cleaning & Transformation

class DataCleaner:
    """NBA data cleaning and standardization."""

    @staticmethod
    def clean(df: pd.DataFrame) -> pd.DataFrame:
        df = df.copy()

        # Sort by date
        df["GAME_DATE"] = pd.to_datetime(
            df["GAME_DATE"], errors="coerce"
        )
        df = df.dropna(subset=["GAME_DATE"])
        df = df.sort_values("GAME_DATE").reset_index(drop=True)

        # Numeric columns
        numeric_cols = [
            c for c in df.columns
            if c.startswith(("HOME_", "AWAY_"))
            and c not in ["HOME_TEAM", "AWAY_TEAM",
                          "HOME_TEAM_ID", "AWAY_TEAM_ID"]
        ]
        for col in numeric_cols:
            df[col] = pd.to_numeric(df[col], errors="coerce")

        # Remove games with no score
        df = df.dropna(subset=["HOME_PTS", "AWAY_PTS"])

        # Compute basic derived metrics
        # Pace proxy: sum of FGA + 0.44*FTA - OREB + TOV
        for prefix in ["HOME", "AWAY"]:
            df[f"{prefix}_POSS"] = (
                df[f"{prefix}_FGA"]
                + 0.44 * df[f"{prefix}_FTA"]
                - df[f"{prefix}_OREB"]
                + df[f"{prefix}_TOV"]
            )

        # Pace: average possessions for both teams
        df["PACE"] = (df["HOME_POSS"] + df["AWAY_POSS"]) / 2

        # Offensive / Defensive Rating (points per 100 possessions)
        for prefix, opp in [("HOME", "AWAY"), ("AWAY", "HOME")]:
            poss = df[f"{prefix}_POSS"].replace(0, np.nan)
            df[f"{prefix}_ORTG"] = df[f"{prefix}_PTS"] / poss * 100
            df[f"{prefix}_DRTG"] = df[f"{opp}_PTS"] / poss * 100
            df[f"{prefix}_NET_RTG"] = (
                df[f"{prefix}_ORTG"] - df[f"{prefix}_DRTG"]
            )

        return df

clean_data = DataCleaner.clean(raw_data)
print(f"After cleaning: {len(clean_data)} games")
print(f"Distribution: Home Win={clean_data['HOME_WIN'].mean():.1%}, "
      f"Away Win={1-clean_data['HOME_WIN'].mean():.1%}")

Feature Engineering with Claude

The key stage where we create features for the model. The NBA provides an extremely rich set of metrics - Four Factors, plus/minus, pace-adjusted stats, and more.

Statistical Features (Rolling Averages)

class NBAFeatureEngineer:
    """
    Feature generation based on historical team statistics.
    Key idea: for each game we use ONLY data
    available BEFORE that game starts.
    """

    def __init__(self, window: int = 10):
        self.window = window

    def compute_team_stats(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Compute rolling averages for each team
        over the last N games.
        
        In the NBA we use window=10 — sufficient data
        because 82 games per season provide more data.
        """
        df = df.sort_values("GAME_DATE").copy()

        # Create separate records for each team
        home_records = []
        away_records = []

        stat_cols = [
            "PTS", "FG_PCT", "FG3_PCT", "FT_PCT",
            "OREB", "DREB", "REB", "AST", "STL",
            "BLK", "TOV", "PF", "POSS", "ORTG", "DRTG",
            "NET_RTG",
        ]

        for _, row in df.iterrows():
            home_row = {
                "Date": row["GAME_DATE"],
                "Team": row["HOME_TEAM"],
                "IsHome": 1,
                "Win": row["HOME_WIN"],
                "PointDiff": row["HOME_PTS"] - row["AWAY_PTS"],
            }
            away_row = {
                "Date": row["GAME_DATE"],
                "Team": row["AWAY_TEAM"],
                "IsHome": 0,
                "Win": 1 - row["HOME_WIN"],
                "PointDiff": row["AWAY_PTS"] - row["HOME_PTS"],
            }

            for col in stat_cols:
                home_row[col] = row.get(f"HOME_{col}", np.nan)
                away_row[col] = row.get(f"AWAY_{col}", np.nan)

            # Opponent stats (for defensive metrics)
            for col in stat_cols:
                home_row[f"OPP_{col}"] = row.get(f"AWAY_{col}", np.nan)
                away_row[f"OPP_{col}"] = row.get(f"HOME_{col}", np.nan)

            home_records.append(home_row)
            away_records.append(away_row)

        all_records = pd.DataFrame(home_records + away_records)
        all_records = all_records.sort_values("Date")

        # Compute rolling averages
        rolling_cols = stat_cols + [f"OPP_{c}" for c in stat_cols]
        rolling_cols += ["Win", "PointDiff"]

        rolling_stats = {}
        for team in all_records["Team"].unique():
            team_data = all_records[
                all_records["Team"] == team
            ].copy()

            for col in rolling_cols:
                # shift(1) — exclude current game
                team_data[f"avg_{col}"] = (
                    team_data[col]
                    .shift(1)
                    .rolling(window=self.window, min_periods=5)
                    .mean()
                )

            # Form: win percentage over last N games
            team_data["Form"] = (
                team_data["Win"]
                .shift(1)
                .rolling(window=self.window, min_periods=5)
                .mean()
            )

            # Streak: current win/loss streak
            team_data["Streak"] = self._compute_streak(
                team_data["Win"].shift(1)
            )

            rolling_stats[team] = team_data

        return pd.concat(rolling_stats.values())

    @staticmethod
    def _compute_streak(wins: pd.Series) -> pd.Series:
        """Compute current winning (+) / losing (-) streak."""
        streak = []
        current = 0
        for w in wins:
            if pd.isna(w):
                streak.append(0)
                continue
            if w == 1:
                current = max(1, current + 1)
            else:
                current = min(-1, current - 1)
            streak.append(current)
        return pd.Series(streak, index=wins.index)

    def build_match_features(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Join home and away team statistics
        for each game.
        """
        team_stats = self.compute_team_stats(df)

        stat_features = [
            c for c in team_stats.columns if c.startswith("avg_")
        ]
        stat_features += ["Form", "Streak"]

        features_list = []

        for idx, match in df.iterrows():
            home = match["HOME_TEAM"]
            away = match["AWAY_TEAM"]
            date = match["GAME_DATE"]

            home_stats = team_stats[
                (team_stats["Team"] == home)
                & (team_stats["Date"] == date)
                & (team_stats["IsHome"] == 1)
            ]
            away_stats = team_stats[
                (team_stats["Team"] == away)
                & (team_stats["Date"] == date)
                & (team_stats["IsHome"] == 0)
            ]

            if home_stats.empty or away_stats.empty:
                continue

            row = {"match_idx": idx}
            for feat in stat_features:
                h_val = home_stats[feat].values[0]
                a_val = away_stats[feat].values[0]
                row[f"home_{feat}"] = h_val
                row[f"away_{feat}"] = a_val
                # Difference — one of the strongest features
                row[f"diff_{feat}"] = h_val - a_val

            features_list.append(row)

        features_df = pd.DataFrame(features_list).set_index("match_idx")
        result = df.join(features_df, how="inner")
        return result.dropna(
            subset=[c for c in features_df.columns]
        )

# === Usage ===
engineer = NBAFeatureEngineer(window=10)
featured_data = engineer.build_match_features(clean_data)
print(f"Games with features: {len(featured_data)}")
print(f"Number of features: "
      f"{len([c for c in featured_data.columns if c.startswith(('home_', 'away_', 'diff_'))])}")

Claude for Contextual Feature Generation

import anthropic
import json
from dotenv import load_dotenv

load_dotenv()

client = anthropic.Anthropic()  # key from ANTHROPIC_API_KEY

def claude_analyze_nba_matchup(
    home_team: str,
    away_team: str,
    home_form: dict,
    away_form: dict,
) -> dict:
    """
    Claude evaluates contextual factors of an NBA game
    that are hard to extract from numerical data.
    """
    prompt = f"""You are an expert NBA analyst. Analyze the upcoming game
and return ONLY JSON (no markdown, no comments) with the following scores
on a scale from 0.0 to 1.0:

Game: {home_team} (home) vs {away_team} (away)

{home_team} stats over last 10 games:
- Avg points: {home_form.get('avg_PTS', 'N/A'):.1f}
- Avg points allowed: {home_form.get('avg_OPP_PTS', 'N/A'):.1f}
- Net Rating: {home_form.get('avg_NET_RTG', 'N/A'):.1f}
- FG%: {home_form.get('avg_FG_PCT', 'N/A'):.1%}
- 3PT%: {home_form.get('avg_FG3_PCT', 'N/A'):.1%}
- Form (Win%): {home_form.get('Form', 'N/A'):.1%}
- Streak: {home_form.get('Streak', 'N/A')}

{away_team} stats over last 10 games:
- Avg points: {away_form.get('avg_PTS', 'N/A'):.1f}
- Avg points allowed: {away_form.get('avg_OPP_PTS', 'N/A'):.1f}
- Net Rating: {away_form.get('avg_NET_RTG', 'N/A'):.1f}
- FG%: {away_form.get('avg_FG_PCT', 'N/A'):.1%}
- 3PT%: {away_form.get('avg_FG3_PCT', 'N/A'):.1%}
- Form (Win%): {away_form.get('Form', 'N/A'):.1%}
- Streak: {away_form.get('Streak', 'N/A')}

Return JSON strictly in this format:
{{
    "home_offense_strength": <float>,
    "home_defense_strength": <float>,
    "away_offense_strength": <float>,
    "away_defense_strength": <float>,
    "home_momentum": <float>,
    "away_momentum": <float>,
    "pace_mismatch": <float>,
    "upset_probability": <float>,
    "home_win_confidence": <float>,
    "blowout_likelihood": <float>,
    "reasoning": "<brief explanation in 1-2 sentences>"
}}"""

    message = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=500,
        messages=[{"role": "user", "content": prompt}],
    )

    response_text = message.content[0].text.strip()
    try:
        return json.loads(response_text)
    except json.JSONDecodeError:
        start = response_text.find("{")
        end = response_text.rfind("}") + 1
        if start != -1 and end > start:
            return json.loads(response_text[start:end])
        return {}

# === Example usage ===
home_form_example = {
    "avg_PTS": 115.2, "avg_OPP_PTS": 108.5,
    "avg_NET_RTG": 6.7, "avg_FG_PCT": 0.478,
    "avg_FG3_PCT": 0.372, "Form": 0.7, "Streak": 3,
}
away_form_example = {
    "avg_PTS": 108.8, "avg_OPP_PTS": 112.1,
    "avg_NET_RTG": -3.3, "avg_FG_PCT": 0.451,
    "avg_FG3_PCT": 0.341, "Form": 0.4, "Streak": -2,
}

analysis = claude_analyze_nba_matchup(
    home_team="BOS",
    away_team="MIA",
    home_form=home_form_example,
    away_form=away_form_example,
)
print(json.dumps(analysis, indent=2, ensure_ascii=False))

Adding Sportsbook Lines as Features

In the NBA sportsbooks set spread (handicap) and moneyline (winner). Spread one of the strongest predictors. because он already содержит агрегированную expertизу рынка.

def add_spread_features(df: pd.DataFrame) -> pd.DataFrame:
    """
    Convert sportsbook lines to probabilities.
    
    In the NBA the line format is specific:
    - Spread: home team -5.5 means they 
      must win by more than 5.5 points
    - Moneyline: -200 (favorite), +170 (underdog)
    """
    df = df.copy()

    # If spread data is available (from external source)
    if "SPREAD" in df.columns:
        # Spread → implied win probability
        # Empirical formula: P(win) ≈ Φ(spread / 13.5)
        # where 13.5 is the standard deviation of margin of victory in the NBA
        from scipy.stats import norm
        df["spread_prob_home"] = norm.cdf(
            -df["SPREAD"] / 13.5
        )
        df["spread_prob_away"] = 1 - df["spread_prob_home"]

    # If moneyline is available
    if "ML_HOME" in df.columns and "ML_AWAY" in df.columns:
        def ml_to_prob(ml):
            """Convert American odds to probability."""
            if ml < 0:
                return abs(ml) / (abs(ml) + 100)
            else:
                return 100 / (ml + 100)

        df["ml_prob_home"] = df["ML_HOME"].apply(ml_to_prob)
        df["ml_prob_away"] = df["ML_AWAY"].apply(ml_to_prob)

        # Normalization (remove margin)
        total = df["ml_prob_home"] + df["ml_prob_away"]
        df["norm_prob_home"] = df["ml_prob_home"] / total
        df["norm_prob_away"] = df["ml_prob_away"] / total

        # Probability difference
        df["odds_spread"] = df["norm_prob_home"] - df["norm_prob_away"]

    return df

featured_data = add_spread_features(featured_data)

Advanced Feature Engineering: Four Factors, ELO & Fatigue

Dean Oliver's Four Factors

Four Factors are the foundation of basketball analytics. They explain ~95% of the variance in team outcomes.

def compute_four_factors(df: pd.DataFrame) -> pd.DataFrame:
    """
    Dean Oliver's Four Factors - key metrics
    of basketball team efficiency:
    
    1. eFG% (Effective FG%) = (FGM + 0.5 * FG3M) / FGA
       Accounts for three-pointers being worth more.
       
    2. TOV% (Turnover Rate) = TOV / (FGA + 0.44 * FTA + TOV)
       Share of possessions ending in turnovers.
       
    3. ORB% (Offensive Rebound Rate) = OREB / (OREB + OPP_DREB)
       Share of offensive rebounds.
       
    4. FT Rate = FTM / FGA
       Free throw rate efficiency.
    """
    df = df.copy()

    for prefix, opp_prefix in [("HOME", "AWAY"), ("AWAY", "HOME")]:
        # 1. eFG%
        df[f"{prefix}_eFG_PCT"] = (
            (df[f"{prefix}_FGM"] + 0.5 * df[f"{prefix}_FG3M"])
            / df[f"{prefix}_FGA"].replace(0, np.nan)
        )

        # 2. TOV%
        df[f"{prefix}_TOV_PCT"] = (
            df[f"{prefix}_TOV"]
            / (
                df[f"{prefix}_FGA"]
                + 0.44 * df[f"{prefix}_FTA"]
                + df[f"{prefix}_TOV"]
            ).replace(0, np.nan)
        )

        # 3. ORB%
        df[f"{prefix}_ORB_PCT"] = (
            df[f"{prefix}_OREB"]
            / (
                df[f"{prefix}_OREB"] + df[f"{opp_prefix}_DREB"]
            ).replace(0, np.nan)
        )

        # 4. FT Rate
        df[f"{prefix}_FT_RATE"] = (
            df[f"{prefix}_FTM"]
            / df[f"{prefix}_FGA"].replace(0, np.nan)
        )

        # True Shooting %
        df[f"{prefix}_TS_PCT"] = (
            df[f"{prefix}_PTS"]
            / (2 * (
                df[f"{prefix}_FGA"]
                + 0.44 * df[f"{prefix}_FTA"]
            )).replace(0, np.nan)
        )

        # Assist Ratio
        df[f"{prefix}_AST_RATIO"] = (
            df[f"{prefix}_AST"]
            / df[f"{prefix}_FGM"].replace(0, np.nan)
        )

    return df

featured_data = compute_four_factors(featured_data)

ELO Ratings for the NBA

class NBAELO:
    """
    ELO ratings for NBA teams.
    
    Adapted for basketball:
    - K=20 (basketball is more predictable,
      82 games per season → slower adaptation is needed)
    - Home advantage = 100 ELO points (~3.5 points on the court)
    - Margin of victory multiplier accounts for point differential
    - Regression to mean between seasons
    """

    def __init__(self, k: int = 20, home_advantage: int = 100):
        self.k = k
        self.home_advantage = home_advantage
        self.ratings: dict[str, float] = {}

    def get_rating(self, team: str) -> float:
        return self.ratings.setdefault(team, 1500.0)

    def expected_score(self, rating_a: float, rating_b: float) -> float:
        return 1.0 / (1.0 + 10 ** ((rating_b - rating_a) / 400.0))

    def margin_multiplier(self, point_diff: int,
                           elo_diff: float) -> float:
        """
        Multiplier for difference in points.
        FiveThirtyEight formula:
        M = ((abs(MOV) + 3)^0.8) / (7.5 + 0.006 * elo_diff)
        """
        mov = abs(point_diff)
        return ((mov + 3) ** 0.8) / (7.5 + 0.006 * abs(elo_diff))

    def update(self, home: str, away: str,
               home_pts: int, away_pts: int) -> tuple[float, float]:
        """Update ratings after a game."""
        r_home = self.get_rating(home) + self.home_advantage
        r_away = self.get_rating(away)

        e_home = self.expected_score(r_home, r_away)

        # Actual result
        s_home = 1.0 if home_pts > away_pts else 0.0

        # Multiplier for difference in points
        elo_diff = r_home - r_away
        m = self.margin_multiplier(home_pts - away_pts, elo_diff)

        self.ratings[home] += self.k * m * (s_home - e_home)
        self.ratings[away] += self.k * m * ((1 - s_home) - (1 - e_home))

        return self.ratings[home], self.ratings[away]

    def season_reset(self, regression_factor: float = 0.75):
        """
        Regression to mean between seasons.
        NBA: ~25% regression (factor=0.75).
        """
        mean_elo = np.mean(list(self.ratings.values()))
        for team in self.ratings:
            self.ratings[team] = (
                regression_factor * self.ratings[team]
                + (1 - regression_factor) * mean_elo
            )

    def compute_elo_features(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Iterate through all games chronologically,
        updating ELO after each one.
        """
        df = df.sort_values("GAME_DATE").copy()
        elo_features = []
        current_season = None

        for _, row in df.iterrows():
            # Regression between seasons
            season = row.get("Season", "")
            if current_season and season != current_season:
                self.season_reset()
            current_season = season

            home = row["HOME_TEAM"]
            away = row["AWAY_TEAM"]

            r_home = self.get_rating(home)
            r_away = self.get_rating(away)

            e_home = self.expected_score(
                r_home + self.home_advantage, r_away
            )

            elo_features.append({
                "elo_home": r_home,
                "elo_away": r_away,
                "elo_diff": r_home - r_away,
                "elo_expected_home": e_home,
                "elo_expected_away": 1 - e_home,
            })

            # Update AFTER saving pre-match ratings
            if (pd.notna(row.get("HOME_PTS"))
                    and pd.notna(row.get("AWAY_PTS"))):
                self.update(home, away,
                            int(row["HOME_PTS"]),
                            int(row["AWAY_PTS"]))

        return pd.concat(
            [df.reset_index(drop=True),
             pd.DataFrame(elo_features)],
            axis=1,
        )

# === Usage ===
elo_system = NBAELO(k=20, home_advantage=100)
featured_data = elo_system.compute_elo_features(featured_data)
print(f"Top 5 teams by ELO:")
top_teams = sorted(elo_system.ratings.items(),
                   key=lambda x: -x[1])[:5]
for team, rating in top_teams:
    print(f"  {team:5s}  {rating:.0f}")

Fatigue Factor & Back-to-Back

Back-to-back (two games in two days) - one of the most significant contextual factors in the NBA. Statistically, teams lose ~5% more often in the second game of a back-to-back.

def compute_nba_fatigue_features(df: pd.DataFrame) -> pd.DataFrame:
    """
    Fatigue features for NBA:
    - Back-to-back (second game over 2 days)
    - 3-in-4 (three games over 4 days)
    - Days rest
    - Travel distance (proxy via time zones)
    """
    df = df.sort_values("GAME_DATE").copy()

    rest_days_home = []
    rest_days_away = []
    last_match: dict[str, pd.Timestamp] = {}

    for _, row in df.iterrows():
        home = row["HOME_TEAM"]
        away = row["AWAY_TEAM"]
        date = row["GAME_DATE"]

        for team, rest_list in [(home, rest_days_home),
                                 (away, rest_days_away)]:
            if team in last_match:
                delta = (date - last_match[team]).days
                rest_list.append(min(delta, 14))
            else:
                rest_list.append(3)  # default for first games

        last_match[home] = date
        last_match[away] = date

    df["home_rest_days"] = rest_days_home
    df["away_rest_days"] = rest_days_away
    df["rest_advantage"] = df["home_rest_days"] - df["away_rest_days"]

    # Back-to-back: 1 day rest
    df["home_b2b"] = (df["home_rest_days"] == 1).astype(int)
    df["away_b2b"] = (df["away_rest_days"] == 1).astype(int)

    # 3-in-4: ≤1 day rest with high load
    df["home_fatigued"] = (df["home_rest_days"] <= 1).astype(int)
    df["away_fatigued"] = (df["away_rest_days"] <= 1).astype(int)

    # B2B advantage: one team on B2B, the other — not
    df["b2b_advantage_home"] = (
        df["away_b2b"] - df["home_b2b"]
    )

    return df

featured_data = compute_nba_fatigue_features(featured_data)

Head-to-Head

def compute_h2h_features(df: pd.DataFrame,
                          n_last: int = 5) -> pd.DataFrame:
    """
    Head-to-head statistics between teams.
    In the NBA some matchups have persistent patterns
    (e.g., matchup advantages based on play style).
    """
    df = df.sort_values("GAME_DATE").copy()
    h2h_features = []

    for idx, row in df.iterrows():
        home = row["HOME_TEAM"]
        away = row["AWAY_TEAM"]
        date = row["GAME_DATE"]

        prev = df[
            (df["GAME_DATE"] < date)
            & (
                ((df["HOME_TEAM"] == home) & (df["AWAY_TEAM"] == away))
                | ((df["HOME_TEAM"] == away) & (df["AWAY_TEAM"] == home))
            )
        ].tail(n_last)

        if len(prev) < 2:
            h2h_features.append({
                "h2h_home_wins": np.nan,
                "h2h_avg_point_diff": np.nan,
                "h2h_avg_total_pts": np.nan,
            })
            continue

        home_wins = 0
        total_diff = 0
        total_pts = 0

        for _, p in prev.iterrows():
            game_total = p["HOME_PTS"] + p["AWAY_PTS"]
            total_pts += game_total

            if p["HOME_TEAM"] == home:
                if p["HOME_WIN"] == 1:
                    home_wins += 1
                total_diff += p["HOME_PTS"] - p["AWAY_PTS"]
            else:
                if p["HOME_WIN"] == 0:
                    home_wins += 1
                total_diff += p["AWAY_PTS"] - p["HOME_PTS"]

        n = len(prev)
        h2h_features.append({
            "h2h_home_wins": home_wins / n,
            "h2h_avg_point_diff": total_diff / n,
            "h2h_avg_total_pts": total_pts / n,
        })

    h2h_df = pd.DataFrame(h2h_features, index=df.index)
    return pd.concat([df, h2h_df], axis=1)

featured_data = compute_h2h_features(featured_data)
print(f"Total features: "
      f"{len([c for c in featured_data.columns if c not in ['GAME_DATE', 'HOME_TEAM', 'AWAY_TEAM', 'HOME_WIN', 'Season', 'GAME_ID']])}")

Polymarket Integration: Prediction Market as Signal Source

Polymarket is a decentralized prediction market on the Polygon blockchain. For the NBA, markets feature high liquidity and active trading volumes. Key differences from sportsbooks:

| Parameter | Sportsbook (DraftKings) | Polymarket |
|---|---|---|
| Pricing mechanism | Algorithm + traders | Free market (CLOB) |
| Margin | 4-8% vig/juice | ~1-2% (exchange spreads) |
| Participants | Mass audience | Crypto traders, quants |
| Reaction to news | Minutes | Seconds |
| Signal | Aggregated expertise + margin | Pure crowd intelligence |

When Polymarket and the sportsbook diverge - that's a potential edge. In the NBA this is especially valuable because lines move quickly due to injury reports (for example, a star player decides to rest an hour before the game).

Connecting to the Polymarket Gamma API

import requests
import json
import time
from dataclasses import dataclass

GAMMA_API = "https://gamma-api.polymarket.com"

@dataclass
class PolymarketOdds:
    """Structure for storing Polymarket probabilities."""
    home_win: float
    away_win: float
    liquidity: float
    volume_24h: float
    market_slug: str
    last_updated: str

class PolymarketClient:
    """
    Client for fetching NBA markets from Polymarket.
    Gamma API — public REST API, no auth required.
    """

    HEADERS = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 Chrome/118.0.0.0 Safari/537.36"
        )
    }

    NBA_KEYWORDS = [
        "nba", "basketball", "lakers", "celtics", "warriors",
        "bucks", "nuggets", "76ers", "knicks", "heat",
        "cavaliers", "thunder", "timberwolves", "mavericks",
        "suns", "nets", "clippers", "hawks", "bulls",
        "nba finals", "nba playoffs", "nba champion",
    ]

    def search_nba_markets(self, limit: int = 200) -> list[dict]:
        """Search for NBA markets on Polymarket."""
        all_markets = []
        offset = 0

        while offset < limit:
            try:
                resp = requests.get(
                    f"{GAMMA_API}/markets",
                    params={
                        "active": "true",
                        "closed": "false",
                        "limit": 50,
                        "offset": offset,
                    },
                    headers=self.HEADERS,
                    timeout=15,
                )
                resp.raise_for_status()
                markets = resp.json()

                if not markets:
                    break

                for market in markets:
                    question = market.get("question", "").lower()
                    description = market.get("description", "").lower()
                    text = question + " " + description

                    if any(kw in text for kw in self.NBA_KEYWORDS):
                        all_markets.append(market)

                offset += 50
                time.sleep(0.5)

            except requests.RequestException as e:
                print(f"  ⚠ Request error: {e}")
                break

        print(f"  ✓ Found {len(all_markets)} NBA markets")
        return all_markets

    def extract_match_odds(self, market: dict) -> PolymarketOdds | None:
        """
        Extract probability from market data.
        NBA markets are usually binary (Win/Lose, no draw).
        """
        try:
            outcomes = market.get("outcomes", [])
            prices_raw = market.get("outcomePrices", "[]")

            if isinstance(prices_raw, str):
                prices = json.loads(prices_raw)
            else:
                prices = prices_raw

            if len(prices) < 2:
                return None

            prices = [float(p) for p in prices]

            return PolymarketOdds(
                home_win=prices[0],
                away_win=prices[1],
                liquidity=float(market.get("liquidity", 0) or 0),
                volume_24h=float(market.get("volume24hr", 0) or 0),
                market_slug=market.get("slug", ""),
                last_updated=market.get("updatedAt", ""),
            )

        except (ValueError, IndexError, KeyError) as e:
            print(f"  ⚠ Could not extract prices: {e}")
            return None

# === Usage ===
poly_client = PolymarketClient()
nba_markets = poly_client.search_nba_markets(limit=200)

for market in nba_markets[:5]:
    odds = poly_client.extract_match_odds(market)
    if odds:
        print(f"\n  📊 {market['question']}")
        print(f"     Home: {odds.home_win:.1%} | "
              f"Away: {odds.away_win:.1%}")
        print(f"     Liquidity: ${odds.liquidity:,.0f} | "
              f"24h Vol: ${odds.volume_24h:,.0f}")

Retrieving Historical Prices

class PolymarketHistorical:
    """
    Retrieve historical prices for backtesting.
    """

    CLOB_API = "https://clob.polymarket.com"

    def get_price_history(
        self, token_id: str, interval: str = "1d",
        fidelity: int = 60,
    ) -> pd.DataFrame:
        try:
            resp = requests.get(
                f"{self.CLOB_API}/prices-history",
                params={
                    "market": token_id,
                    "interval": interval,
                    "fidelity": fidelity,
                },
                timeout=15,
            )
            resp.raise_for_status()
            data = resp.json()

            if not data or "history" not in data:
                return pd.DataFrame()

            df = pd.DataFrame(data["history"])
            df["timestamp"] = pd.to_datetime(df["t"], unit="s")
            df["price"] = df["p"].astype(float)
            return df[["timestamp", "price"]].sort_values("timestamp")

        except requests.RequestException as e:
            print(f"  ⚠ Error: {e}")
            return pd.DataFrame()

    def get_orderbook_snapshot(self, token_id: str) -> dict:
        """
        Snapshot order book — liquidity depth.
        NBA markets usually have high liquidity,
        → more reliable signal.
        """
        try:
            resp = requests.get(
                f"{self.CLOB_API}/book",
                params={"token_id": token_id},
                timeout=15,
            )
            resp.raise_for_status()
            book = resp.json()

            bids = book.get("bids", [])
            asks = book.get("asks", [])

            total_bid = sum(float(b.get("size", 0)) for b in bids)
            total_ask = sum(float(a.get("size", 0)) for a in asks)

            best_bid = float(bids[0]["price"]) if bids else 0
            best_ask = float(asks[0]["price"]) if asks else 1
            spread = best_ask - best_bid
            midpoint = (best_bid + best_ask) / 2

            return {
                "midpoint": midpoint,
                "spread": spread,
                "spread_pct": spread / midpoint if midpoint > 0 else 0,
                "bid_depth_usd": total_bid,
                "ask_depth_usd": total_ask,
                "total_depth": total_bid + total_ask,
                "imbalance": (
                    (total_bid - total_ask) / (total_bid + total_ask)
                    if (total_bid + total_ask) > 0 else 0
                ),
            }

        except (requests.RequestException, IndexError, ValueError):
            return {}

Combining Three Probability Layers

class TripleLayerFeatures:
    """
    Combining three layers for NBA:
    1. Sportsbook (DraftKings/FanDuel) — moneyline/spread
    2. Polymarket — crowd intelligence
    3. ML model — our own score
    
    NBA-specific: binary market (Win/Lose),
    so we work with two outcomes instead of three.
    """

    @staticmethod
    def compute_divergence_features(
        sportsbook_probs: dict,
        polymarket_probs: dict,
        ml_probs: dict | None = None,
    ) -> dict:
        features = {}

        # Raw probability
        for prefix, probs in [("sb", sportsbook_probs),
                               ("poly", polymarket_probs)]:
            features[f"{prefix}_prob_home"] = probs.get("home", 0)
            features[f"{prefix}_prob_away"] = probs.get("away", 0)

        # KL divergence
        epsilon = 1e-6
        kl_div = 0
        for key in ["home", "away"]:
            p = max(sportsbook_probs.get(key, epsilon), epsilon)
            q = max(polymarket_probs.get(key, epsilon), epsilon)
            kl_div += p * np.log(p / q)
        features["kl_div_sb_poly"] = kl_div

        # Absolute divergences
        for key in ["home", "away"]:
            sb = sportsbook_probs.get(key, 0)
            poly = polymarket_probs.get(key, 0)
            features[f"divergence_{key}"] = sb - poly
            features[f"abs_divergence_{key}"] = abs(sb - poly)

        features["max_divergence"] = max(
            features["abs_divergence_home"],
            features["abs_divergence_away"],
        )

        # Source consensus
        sb_fav = max(sportsbook_probs, key=sportsbook_probs.get)
        poly_fav = max(polymarket_probs, key=polymarket_probs.get)
        features["sources_agree"] = int(sb_fav == poly_fav)

        # Blended probabilities
        for key in ["home", "away"]:
            features[f"blended_prob_{key}"] = (
                0.5 * sportsbook_probs.get(key, 0)
                + 0.5 * polymarket_probs.get(key, 0)
            )

        # Triple layer
        if ml_probs:
            for key in ["home", "away"]:
                ml = ml_probs.get(key, 0)
                sb = sportsbook_probs.get(key, 0)
                poly = polymarket_probs.get(key, 0)

                features[f"ml_prob_{key}"] = ml
                features[f"ml_vs_sb_{key}"] = ml - sb
                features[f"ml_vs_poly_{key}"] = ml - poly
                features[f"triple_blend_{key}"] = (
                    0.40 * ml + 0.35 * poly + 0.25 * sb
                )

            ml_fav = max(ml_probs, key=ml_probs.get)
            features["all_three_agree"] = int(
                sb_fav == poly_fav == ml_fav
            )

        return features

# === Example ===
sportsbook = {"home": 0.62, "away": 0.38}
polymarket = {"home": 0.58, "away": 0.42}
ml_probs = {"home": 0.65, "away": 0.35}

triple_features = TripleLayerFeatures.compute_divergence_features(
    sportsbook_probs=sportsbook,
    polymarket_probs=polymarket,
    ml_probs=ml_probs,
)
print("=== Triple Layer Features (NBA) ===")
for k, v in triple_features.items():
    print(f"  {k:30s} = {v:.4f}")

Visualize divergences

def plot_nba_divergence(matches: list[dict], figsize=(12, 6)):
    """
    Scatter plot: sportsbook vs Polymarket for NBA.
    Binary market → one chart instead of three.
    """
    fig, ax = plt.subplots(figsize=figsize)

    sb_probs = [m["sb_home"] for m in matches]
    poly_probs = [m["poly_home"] for m in matches]

    ax.scatter(sb_probs, poly_probs, alpha=0.6,
               color="#e67e22", edgecolors="white", s=80)

    ax.plot([0, 1], [0, 1], "k--", alpha=0.3, linewidth=1)

    ax.fill_between([0, 1], [0.03, 1.03], [0, 1],
                    alpha=0.05, color="blue",
                    label="Polymarket above")
    ax.fill_between([0, 1], [0, 1], [-0.03, 0.97],
                    alpha=0.05, color="red",
                    label="Sportsbook above")

    ax.set_xlabel("Sportsbook P(Home Win)", fontsize=12)
    ax.set_ylabel("Polymarket P(Home Win)", fontsize=12)
    ax.set_title(
        "NBA: Sportsbook vs Polymarket\n"
        "Points far from diagonal → potential value",
        fontsize=14,
    )
    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    ax.set_aspect("equal")
    ax.legend(fontsize=10)

    plt.tight_layout()
    plt.savefig("nba_divergence.png", bbox_inches="tight")
    plt.show()

def plot_triple_layer_bar(
    match_name: str,
    sportsbook: dict,
    polymarket: dict,
    ml_model: dict,
):
    """
    Grouped bar chart: three sources side by side for NBA.
    Simpler radar chart for binary market.
    """
    fig, ax = plt.subplots(figsize=(10, 6))

    x = np.arange(2)
    width = 0.25
    labels = ["Home Win", "Away Win"]
    keys = ["home", "away"]

    bars1 = ax.bar(x - width, [sportsbook[k] for k in keys],
                    width, label="Sportsbook", color="#3498db")
    bars2 = ax.bar(x, [polymarket[k] for k in keys],
                    width, label="Polymarket", color="#e74c3c")
    bars3 = ax.bar(x + width, [ml_model[k] for k in keys],
                    width, label="ML Model", color="#2ecc71")

    ax.set_ylabel("Probability")
    ax.set_title(f"Triple Layer: {match_name}", fontsize=14)
    ax.set_xticks(x)
    ax.set_xticklabels(labels)
    ax.legend()

    # Annotations
    for bars in [bars1, bars2, bars3]:
        for bar in bars:
            height = bar.get_height()
            ax.annotate(f"{height:.0%}",
                        xy=(bar.get_x() + bar.get_width() / 2, height),
                        xytext=(0, 3), textcoords="offset points",
                        ha="center", va="bottom", fontsize=9)

    plt.tight_layout()
    plt.savefig("nba_triple_bar.png", bbox_inches="tight")
    plt.show()

Claude Analyzes Divergences

def claude_analyze_nba_divergence(
    match: str,
    sportsbook: dict,
    polymarket: dict,
    ml_model: dict,
    poly_liquidity: float,
    poly_volume_24h: float,
) -> str:
    """
    Claude analyzes divergences between three sources
    in the NBA context.
    """
    prompt = f"""You are a senior NBA analyst. You have three sources of
probabilities for games. Analyze divergences.

**Game:** {match}

| Source | Home Win | Away Win |
|---|---|---|
| Sportsbook | {sportsbook['home']:.1%} | {sportsbook['away']:.1%} |
| Polymarket | {polymarket['home']:.1%} | {polymarket['away']:.1%} |
| ML model | {ml_model['home']:.1%} | {ml_model['away']:.1%} |

**Metadata Polymarket:**
- Liquidity: ${poly_liquidity:,.0f}
- 24h volume: ${poly_volume_24h:,.0f}

**Task:**
1. Where are the main divergences and what could they mean?
   (injuries, load management, lineup changes, matchup advantages)
2. Which source should be trusted more and why?
3. Are there signs of insider activity?
   (NBA injury reports come out ~30 min before games)
4. Forecast with confidence level.

Be specific, 5-8 sentences.""""""

    message = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=600,
        messages=[{"role": "user", "content": prompt}],
    )
    return message.content[0].text

Building the ML Model

Data Preparation

from sklearn.model_selection import TimeSeriesSplit
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import (
    accuracy_score, classification_report,
    confusion_matrix, log_loss,
)

def prepare_model_data(df: pd.DataFrame) -> tuple:
    """
    Prepare data. NBA = binary classification (Win/Loss).
    """
    feature_cols = [
        c for c in df.columns
        if c.startswith(("home_", "away_", "diff_",
                         "elo_", "h2h_", "rest_",
                         "b2b_", "norm_prob_", "odds_spread"))
        and "TEAM" not in c
    ]

    X = df[feature_cols].copy()
    y = df["HOME_WIN"].copy()

    X = X.fillna(X.median())

    print(f"Features: {X.shape[1]}")
    print(f"Games: {X.shape[0]}")
    print(f"Balance: Home Win={y.mean():.1%}, "
          f"Away Win={1-y.mean():.1%}")

    return X, y, feature_cols

X, y, feature_names = prepare_model_data(featured_data)

Model training

from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import (
    RandomForestClassifier, GradientBoostingClassifier,
    VotingClassifier,
)
from xgboost import XGBClassifier

def train_and_evaluate(X, y):
    """
    Train models with TimeSeriesSplit.
    NBA: binary classification → accuracy ~65-72%.
    """
    tscv = TimeSeriesSplit(n_splits=5)
    scaler = StandardScaler()

    models = {
        "Logistic Regression": LogisticRegression(
            max_iter=1000, C=0.5,
        ),
        "Random Forest": RandomForestClassifier(
            n_estimators=200, max_depth=8,
            min_samples_leaf=10, random_state=42,
        ),
        "XGBoost": XGBClassifier(
            n_estimators=300, max_depth=5,
            learning_rate=0.05, subsample=0.8,
            colsample_bytree=0.8, random_state=42,
            eval_metric="logloss",
        ),
        "Gradient Boosting": GradientBoostingClassifier(
            n_estimators=200, max_depth=4,
            learning_rate=0.08, random_state=42,
        ),
    }

    results = {}

    for name, model in models.items():
        fold_accs = []
        fold_lls = []

        for train_idx, test_idx in tscv.split(X):
            X_train = scaler.fit_transform(X.iloc[train_idx])
            X_test = scaler.transform(X.iloc[test_idx])
            y_train = y.iloc[train_idx]
            y_test = y.iloc[test_idx]

            model.fit(X_train, y_train)
            preds = model.predict(X_test)
            proba = model.predict_proba(X_test)

            fold_accs.append(accuracy_score(y_test, preds))
            fold_lls.append(log_loss(y_test, proba))

        results[name] = {
            "accuracy_mean": np.mean(fold_accs),
            "accuracy_std": np.std(fold_accs),
            "log_loss_mean": np.mean(fold_lls),
            "log_loss_std": np.std(fold_lls),
        }

        print(f"\n{'='*50}")
        print(f"  {name}")
        print(f"  Accuracy:  {results[name]['accuracy_mean']:.4f} "
              f"± {results[name]['accuracy_std']:.4f}")
        print(f"  Log Loss:  {results[name]['log_loss_mean']:.4f} "
              f"± {results[name]['log_loss_std']:.4f}")

    return results, models

results, models = train_and_evaluate(X, y)

Ensemble

def build_ensemble(X, y):
    """Ensemble with soft voting for NBA."""
    scaler = StandardScaler()

    ensemble = VotingClassifier(
        estimators=[
            ("lr", LogisticRegression(max_iter=1000, C=0.5)),
            ("rf", RandomForestClassifier(
                n_estimators=200, max_depth=8, random_state=42)),
            ("xgb", XGBClassifier(
                n_estimators=300, max_depth=5, learning_rate=0.05,
                random_state=42, eval_metric="logloss")),
        ],
        voting="soft",
        weights=[1, 1, 2],  # XGBoost gets higher weight
    )

    split_idx = int(len(X) * 0.8)
    X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:]
    y_train, y_test = y.iloc[:split_idx], y.iloc[split_idx:]

    X_train_scaled = scaler.fit_transform(X_train)
    X_test_scaled = scaler.transform(X_test)

    ensemble.fit(X_train_scaled, y_train)
    preds = ensemble.predict(X_test_scaled)
    proba = ensemble.predict_proba(X_test_scaled)

    print(f"\n{'='*60}")
    print(f"  ENSEMBLE (Soft Voting)")
    print(f"  Accuracy:  {accuracy_score(y_test, preds):.4f}")
    print(f"  Log Loss:  {log_loss(y_test, proba):.4f}")
    print(f"\n{classification_report(y_test, preds, "
          f"target_names=['Away Win', 'Home Win'])}")

    return ensemble, scaler

ensemble_model, scaler = build_ensemble(X, y)

Claude API Integration for Interpretation

Generating a Detailed Prediction

def generate_nba_prediction_report(
    home_team: str,
    away_team: str,
    model_proba: dict,
    stats: dict,
) -> str:
    """
    Analytical report on an upcoming NBA game.
    """
    prompt = f"""You are a professional NBA analyst. Based on the data from
ML models and statistics, compose a brief analytical report.

## Data models

Game: **{home_team}** vs **{away_team}**

Probabilities (ML Ensemble):
- Win {home_team}: {model_proba['home_win']:.1%}
- Win {away_team}: {model_proba['away_win']:.1%}

Stats for {home_team} (last 10 games):
- Points (avg): {stats['home_avg_PTS']:.1f}
- Net Rating: {stats['home_avg_NET_RTG']:.1f}
- eFG%: {stats['home_eFG']:.1%}
- TOV%: {stats['home_TOV_PCT']:.1%}
- Form (Win%): {stats['home_Form']:.1%}
- Streak: {stats['home_Streak']}

Stats for {away_team} (last 10 games):
- Points (avg): {stats['away_avg_PTS']:.1f}
- Net Rating: {stats['away_avg_NET_RTG']:.1f}
- eFG%: {stats['away_eFG']:.1%}
- TOV%: {stats['away_TOV_PCT']:.1%}
- Form (Win%): {stats['away_Form']:.1%}
- Streak: {stats['away_Streak']}

## Task
1. Key prediction factors (Four Factors, matchup advantages)
2. Result forecast + expected margin
3. Confidence score (high / medium / low)
4. Potential upset scenarios

Concise and professional.""""""

    message = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=1000,
        messages=[{"role": "user", "content": prompt}],
    )
    return message.content[0].text

Batch Analysis of NBA Slate

def analyze_nba_slate(games: list[dict]) -> str:
    """
    Analyze all games of the day in a single Claude call.
    A typical NBA slate = 5-15 games.
    """
    games_text = ""
    for i, g in enumerate(games, 1):
        games_text += f"""
{i}. {g['home']} vs {g['away']}
   ML: Home={g['prob_H']:.0%} | Away={g['prob_A']:.0%}
   ELO: {g['elo_home']:.0f} vs {g['elo_away']:.0f}
   B2B: Home={'Yes' if g.get('home_b2b') else 'No'} | Away={'Yes' if g.get('away_b2b') else 'No'}
"""

    prompt = f"""Analyze the NBA slate. For each game:
- Forecast (winner)
- Confidence (⭐ / ⭐⭐ / ⭐⭐⭐)
- Expected margin
- Brief comment (1 sentence)

Games:
{games_text}

At the end: top 3 best bets from the slate (highest confidence).""""""

    message = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=1500,
        messages=[{"role": "user", "content": prompt}],
    )
    return message.content[0].text

Results Visualization

Model Comparison

import matplotlib.pyplot as plt
import matplotlib
import seaborn as sns

matplotlib.rcParams["figure.dpi"] = 120
matplotlib.rcParams["font.size"] = 11
sns.set_style("whitegrid")

def plot_model_comparison(results: dict):
    """Model comparison visualization for NBA."""
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    names = list(results.keys())
    accs = [results[n]["accuracy_mean"] for n in names]
    acc_stds = [results[n]["accuracy_std"] for n in names]
    lls = [results[n]["log_loss_mean"] for n in names]
    ll_stds = [results[n]["log_loss_std"] for n in names]

    colors = ["#2ecc71", "#3498db", "#e74c3c", "#f39c12"]

    bars = axes[0].barh(names, accs, xerr=acc_stds,
                         color=colors, edgecolor="white", linewidth=1.5)
    axes[0].set_xlabel("Accuracy")
    axes[0].set_title("Model Accuracy (NBA, TimeSeriesSplit CV)")
    axes[0].set_xlim(0.5, 0.75)
    for bar, val in zip(bars, accs):
        axes[0].text(val + 0.005, bar.get_y() + bar.get_height()/2,
                     f"{val:.3f}", va="center", fontweight="bold")

    bars = axes[1].barh(names, lls, xerr=ll_stds,
                         color=colors, edgecolor="white", linewidth=1.5)
    axes[1].set_xlabel("Log Loss")
    axes[1].set_title("Log Loss (lower = better)")
    for bar, val in zip(bars, lls):
        axes[1].text(val + 0.005, bar.get_y() + bar.get_height()/2,
                     f"{val:.3f}", va="center", fontweight="bold")

    plt.tight_layout()
    plt.savefig("nba_model_comparison.png", bbox_inches="tight")
    plt.show()

Confusion Matrix

def plot_nba_confusion_matrix(y_true, y_pred):
    """Confusion matrix for binary NBA classification."""
    cm = confusion_matrix(y_true, y_pred)
    labels = ["Away Win", "Home Win"]

    fig, ax = plt.subplots(figsize=(7, 5))
    sns.heatmap(
        cm, annot=True, fmt="d", cmap="Oranges",
        xticklabels=labels, yticklabels=labels,
        ax=ax, linewidths=0.5, linecolor="white",
        annot_kws={"size": 16, "weight": "bold"},
    )
    ax.set_xlabel("Predicted result", fontsize=12)
    ax.set_ylabel("Actual result", fontsize=12)
    ax.set_title("Confusion Matrix — NBA Ensemble", fontsize=14)

    cm_pct = cm / cm.sum(axis=1, keepdims=True)
    for i in range(2):
        for j in range(2):
            ax.text(j + 0.5, i + 0.75,
                    f"({cm_pct[i, j]:.0%})",
                    ha="center", va="center",
                    fontsize=10, color="gray")

    plt.tight_layout()
    plt.savefig("nba_confusion_matrix.png", bbox_inches="tight")
    plt.show()

Feature Importance

def plot_feature_importance(model, feature_names, top_n=15):
    if hasattr(model, "feature_importances_"):
        importances = model.feature_importances_
    else:
        return

    indices = np.argsort(importances)[-top_n:]

    fig, ax = plt.subplots(figsize=(10, 8))
    colors = plt.cm.YlOrRd(np.linspace(0.3, 0.9, top_n))
    ax.barh(range(top_n), importances[indices],
            color=colors, edgecolor="white", linewidth=0.8)
    ax.set_yticks(range(top_n))
    ax.set_yticklabels([feature_names[i] for i in indices])
    ax.set_xlabel("Feature Importance")
    ax.set_title(f"NBA: Top-{top_n} Important Features", fontsize=14)

    plt.tight_layout()
    plt.savefig("nba_feature_importance.png", bbox_inches="tight")
    plt.show()

Backtesting & Model Evaluation

Walk-Forward Backtest

class WalkForwardBacktest:
    """
    Walk-forward for NBA: train on past games,
    predict the next slate.
    
    step_size=15 ≈ one NBA game day
    (on an average day 5-15 games).
    """

    def __init__(self, model, scaler,
                 initial_train_size: int = 1000,
                 step_size: int = 15):
        self.model = model
        self.scaler = scaler
        self.initial_train_size = initial_train_size
        self.step_size = step_size

    def run(self, X: pd.DataFrame, y: pd.Series) -> dict:
        all_preds = []
        all_proba = []
        all_true = []

        for start in range(self.initial_train_size,
                           len(X) - self.step_size,
                           self.step_size):
            end = start + self.step_size

            X_train = X.iloc[:start]
            y_train = y.iloc[:start]
            X_test = X.iloc[start:end]
            y_test = y.iloc[start:end]

            X_train_s = self.scaler.fit_transform(X_train)
            X_test_s = self.scaler.transform(X_test)

            self.model.fit(X_train_s, y_train)
            preds = self.model.predict(X_test_s)
            proba = self.model.predict_proba(X_test_s)

            all_preds.extend(preds)
            all_proba.extend(proba)
            all_true.extend(y_test.values)

        all_preds = np.array(all_preds)
        all_proba = np.array(all_proba)
        all_true = np.array(all_true)

        acc = accuracy_score(all_true, all_preds)
        ll = log_loss(all_true, all_proba)

        print(f"Walk-Forward NBA Backtest:")
        print(f"  Total predictions: {len(all_preds)}")
        print(f"  Accuracy: {acc:.4f}")
        print(f"  Log Loss: {ll:.4f}")
        print(f"\n{classification_report(all_true, all_preds, "
              f"target_names=['Away Win', 'Home Win'])}")

        return {
            "predictions": all_preds,
            "probabilities": all_proba,
            "actuals": all_true,
            "accuracy": acc,
            "log_loss": ll,
        }

# backtester = WalkForwardBacktest(
#     model=XGBClassifier(n_estimators=300, max_depth=5,
#                         learning_rate=0.05, random_state=42),
#     scaler=StandardScaler(),
#     initial_train_size=1000,
#     step_size=15,
# )
# backtest_results = backtester.run(X, y)

Calibration probabilities

def plot_nba_calibration(y_true, y_proba):
    """
    Calibration plot for NBA.
    Binary task → one class (Home Win).
    """
    from sklearn.calibration import calibration_curve

    prob_true, prob_pred = calibration_curve(
        y_true, y_proba[:, 1],
        n_bins=10, strategy="uniform",
    )

    fig, ax = plt.subplots(figsize=(8, 8))
    ax.plot([0, 1], [0, 1], "k--", label="Perfectly calibrated")
    ax.plot(prob_pred, prob_true, "s-", color="#e67e22",
            label="NBA Model", linewidth=2, markersize=8)

    ax.fill_between(prob_pred, prob_true, prob_pred,
                     alpha=0.1, color="#e67e22")

    ax.set_xlabel("Predicted P(Home Win)")
    ax.set_ylabel("Actual home win fraction")
    ax.set_title("NBA Calibration Curve")
    ax.legend(loc="lower right")
    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)

    plt.tight_layout()
    plt.savefig("nba_calibration.png", bbox_inches="tight")
    plt.show()

Advanced Architecture: Hybrid System

Hybrid ML + Claude + Polymarket for NBA

class NBAHybridPredictor:
    """
    NBA prediction hybrid system (Triple Layer):
      ML model (quantitative analysis)
    + Polymarket (crowd intelligence)
    + Claude (qualitative analysis + synthesis)
    
    NBA-specific:
    - Binary classification (no draws)
    - Spread prediction (not only Win/Lose)
    - Injury reports as a critical factor
    """

    def __init__(self, ml_model, scaler, feature_names):
        self.ml_model = ml_model
        self.scaler = scaler
        self.feature_names = feature_names
        self.client = anthropic.Anthropic()
        self.poly_client = PolymarketClient()
        self.triple_layer = TripleLayerFeatures()

    def predict(self, match_features: pd.DataFrame,
                home_team: str, away_team: str,
                polymarket_odds: PolymarketOdds | None = None,
                sportsbook_probs: dict | None = None) -> dict:
        """Full pipeline with three layers."""

        # Step 1: ML prediction
        X_scaled = self.scaler.transform(
            match_features[self.feature_names]
        )
        ml_proba = self.ml_model.predict_proba(X_scaled)[0]
        ml_result = {
            "away_win": float(ml_proba[0]),
            "home_win": float(ml_proba[1]),
        }

        # Step 2: Polymarket
        poly_probs = None
        if polymarket_odds:
            poly_probs = {
                "home": polymarket_odds.home_win,
                "away": polymarket_odds.away_win,
            }

        # Step 3: Sportsbook
        sb_probs = sportsbook_probs or {
            "home": ml_result["home_win"],
            "away": ml_result["away_win"],
        }

        # Step 4: Divergence features
        divergence = {}
        if poly_probs:
            divergence = self.triple_layer.compute_divergence_features(
                sportsbook_probs=sb_probs,
                polymarket_probs=poly_probs,
                ml_probs={
                    "home": ml_result["home_win"],
                    "away": ml_result["away_win"],
                },
            )

        # Step 5: Claude synthesis
        claude_analysis = self._get_claude_synthesis(
            home_team, away_team,
            ml_result, sb_probs, poly_probs,
            divergence, polymarket_odds,
        )

        # Step 6: Combine
        final = self._triple_combine(
            ml_result, sb_probs, poly_probs, claude_analysis,
            poly_liquidity=(
                polymarket_odds.liquidity if polymarket_odds else 0
            ),
        )

        return {
            "match": f"{home_team} vs {away_team}",
            "layers": {
                "ml_model": ml_result,
                "sportsbook": sb_probs,
                "polymarket": poly_probs,
            },
            "divergence_features": divergence,
            "claude_analysis": claude_analysis,
            "final_prediction": final,
        }

    def _get_claude_synthesis(
        self, home, away, ml_proba, sb_probs, poly_probs,
        divergence, poly_odds,
    ) -> dict:
        poly_section = ""
        if poly_probs:
            poly_section = f"""
Polymarket (crowd intelligence):
- Win {home}: {poly_probs['home']:.1%}
- Win {away}: {poly_probs['away']:.1%}
- Liquidity: ${poly_odds.liquidity:,.0f}
- 24h volume: ${poly_odds.volume_24h:,.0f}

KL-divergence (SB vs Poly): {divergence.get('kl_div_sb_poly', 0):.4f}
Source consensus: {'Yes' if divergence.get('all_three_agree') else 'No'}"""

        prompt = f"""You are a senior NBA analyst. Synthesize three sources.

Game: {home} (home) vs {away} (away)

ML model:
- Win {home}: {ml_proba['home_win']:.1%}
- Win {away}: {ml_proba['away_win']:.1%}

Sportsbook:
- Win {home}: {sb_probs['home']:.1%}
- Win {away}: {sb_probs['away']:.1%}
{poly_section}

Return ONLY JSON:
{{
    "confidence": <"high"|"medium"|"low">,
    "adjusted_home_win": <float 0-1>,
    "adjusted_away_win": <float 0-1>,
    "polymarket_trust_level": <"high"|"medium"|"low">,
    "divergence_interpretation": "<what the divergence means>",
    "key_insight": "<main takeaway>",
    "expected_margin": <int, expected point difference>,
    "risk_factor": "<main risk>"
}}"""

        message = self.client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=400,
            messages=[{"role": "user", "content": prompt}],
        )

        text = message.content[0].text.strip()
        try:
            start = text.find("{")
            end = text.rfind("}") + 1
            return json.loads(text[start:end])
        except (json.JSONDecodeError, ValueError):
            return {"error": "Failed to parse"}

    def _triple_combine(self, ml_result, sb_probs, poly_probs,
                         claude_result, poly_liquidity) -> dict:
        """Adaptive merging for NBA."""
        if "error" in claude_result:
            return {
                "predicted_result": max(ml_result, key=ml_result.get),
                "probabilities": ml_result,
                "source": "ml_only",
            }

        if poly_probs and poly_liquidity > 10_000:
            weights = {"ml": 0.35, "poly": 0.35,
                       "sb": 0.15, "claude": 0.15}
        elif poly_probs and poly_liquidity > 1_000:
            weights = {"ml": 0.40, "poly": 0.20,
                       "sb": 0.20, "claude": 0.20}
        else:
            weights = {"ml": 0.50, "poly": 0.0,
                       "sb": 0.25, "claude": 0.25}

        combined = {}
        for key in ["home_win", "away_win"]:
            ml_key = "home" if key == "home_win" else "away"
            claude_key = f"adjusted_{key}"

            ml_val = ml_result[key]
            sb_val = sb_probs.get(ml_key, ml_val)
            poly_val = poly_probs.get(ml_key, ml_val) if poly_probs else ml_val
            claude_val = claude_result.get(claude_key, ml_val)

            combined[key] = (
                weights["ml"] * ml_val
                + weights["poly"] * poly_val
                + weights["sb"] * sb_val
                + weights["claude"] * claude_val
            )

        total = sum(combined.values())
        combined = {k: v / total for k, v in combined.items()}

        return {
            "predicted_result": max(combined, key=combined.get),
            "probabilities": combined,
            "weights_used": weights,
            "confidence": claude_result.get("confidence", "unknown"),
            "expected_margin": claude_result.get("expected_margin", 0),
            "insight": claude_result.get("key_insight", ""),
            "risk": claude_result.get("risk_factor", ""),
            "source": "triple_hybrid",
        }

Ablation Study

class AblationStudy:
    """
    Ablation Study for NBA:
      A) ML model only
      B) ML + sportsbook lines
      C) ML + sportsbook + Polymarket
      D) ML + sportsbook + Polymarket + Claude features
    """

    def __init__(self, base_model, scaler, feature_sets: dict):
        self.base_model = base_model
        self.scaler = scaler
        self.feature_sets = feature_sets

    def run(self, df, y, claude_features=None) -> dict:
        from copy import deepcopy
        tscv = TimeSeriesSplit(n_splits=5)
        results = {}

        for config_name, features in self.feature_sets.items():
            X = df[features].fillna(df[features].median())
            fold_acc, fold_ll = [], []

            for train_idx, test_idx in tscv.split(X):
                model = deepcopy(self.base_model)
                sc = deepcopy(self.scaler)

                X_tr = sc.fit_transform(X.iloc[train_idx])
                X_te = sc.transform(X.iloc[test_idx])

                model.fit(X_tr, y.iloc[train_idx])
                proba = model.predict_proba(X_te)
                preds = model.predict(X_te)

                fold_acc.append(accuracy_score(y.iloc[test_idx], preds))
                fold_ll.append(log_loss(y.iloc[test_idx], proba))

            results[config_name] = {
                "accuracy": np.mean(fold_acc),
                "accuracy_std": np.std(fold_acc),
                "log_loss": np.mean(fold_ll),
                "log_loss_std": np.std(fold_ll),
            }

        return results

    @staticmethod
    def print_results(results: dict):
        print(f"\n{'='*65}")
        print(f"  {'Config':<25s} {'Accuracy':>10s} {'Log Loss':>10s}")
        print(f"{'='*65}")
        baseline_acc = None
        for name, m in sorted(results.items()):
            acc, ll = m["accuracy"], m["log_loss"]
            if baseline_acc is None:
                baseline_acc = acc
                delta = ""
            else:
                diff = acc - baseline_acc
                delta = f"  ({'+' if diff > 0 else ''}{diff:.2%})"
            print(f"  {name:<25s} {acc:>8.4f}±{m['accuracy_std']:.3f}"
                  f"  {ll:>8.4f}{delta}")
        print(f"{'='*65}")

Deployment & Automation

Automated Pipeline

import schedule
import time
from datetime import datetime

class NBAPredictionPipeline:
    """
    Automated pipeline for NBA:
    data loading → model update → today's predictions.
    
    NBA slate is usually 7:00–10:00 PM ET,
    so running at 5:00 PM ET yields fresh predictions.
    """

    def __init__(self):
        self.loader = NBADataLoader(
            seasons=["2025-26", "2024-25", "2023-24"]
        )
        self.engineer = NBAFeatureEngineer(window=10)
        self.model = XGBClassifier(
            n_estimators=300, max_depth=5,
            learning_rate=0.05, random_state=42,
            eval_metric="logloss",
        )
        self.scaler = StandardScaler()

    def run_daily(self):
        print(f"\n{'='*60}")
        print(f"  NBA Pipeline: {datetime.now()}")
        print(f"{'='*60}\n")

        # 1. Load data
        raw = self.loader.load_all()
        clean = DataCleaner.clean(raw)
        featured = self.engineer.build_match_features(clean)
        featured = compute_four_factors(featured)
        
        elo = NBAELO(k=20, home_advantage=100)
        featured = elo.compute_elo_features(featured)
        featured = compute_nba_fatigue_features(featured)

        # 2. Training
        X, y, fnames = prepare_model_data(featured)
        X_scaled = self.scaler.fit_transform(X)
        self.model.fit(X_scaled, y)

        acc = accuracy_score(y, self.model.predict(X_scaled))
        print(f"  Training accuracy: {acc:.4f}")

        # 3. Today's predictions (add via NBA API)
        print(f"  Pipeline completed.")
        return True

# pipeline = NBAPredictionPipeline()
# schedule.every().day.at("17:00").do(pipeline.run_daily)
# while True:
#     schedule.run_pending()
#     time.sleep(60)

Project Structure

nba-ai-predictor/
├── config/
│   └── settings.py              # API keys, parameters
├── data/
│   ├── loader.py                 # NBADataLoader
│   ├── cleaner.py                # DataCleaner
│   └── polymarket.py             # PolymarketClient + Historical
├── features/
│   ├── engineering.py            # NBAFeatureEngineer
│   ├── four_factors.py           # compute_four_factors
│   ├── elo.py                    # NBAELO
│   ├── fatigue.py                # compute_nba_fatigue_features
│   ├── claude_features.py        # Claude-based features
│   ├── spread_features.py        # add_spread_features
│   └── triple_layer.py           # TripleLayerFeatures
├── models/
│   ├── train.py                  # Model training
│   ├── ensemble.py               # Ensemble
│   └── hybrid.py                 # NBAHybridPredictor
├── evaluation/
│   ├── backtest.py               # WalkForwardBacktest
│   ├── ablation.py               # AblationStudy
│   └── metrics.py                # Metrics and charts
├── visualization/
│   ├── plots.py                  # Basic plots
│   └── divergence.py             # Divergence charts
├── pipeline.py                   # NBAPredictionPipeline
├── requirements.txt
└── README.md

Key Takeaways

XGBoost + SHAP is the gold standard (Ouyang et al., 2024: ~70% accuracy). Four Factors explain ~95% of outcomes. The sportsbook baseline (~65–67%) is the bar to beat. GNNs are promising (71.54%) but resource-intensive. Walk-forward validation is mandatory - k-fold allows data leakage from the future.