Logistic regression converts team features into win probabilities: log(p/(1-p)) = β₀ + β₁X₁ + … + βₙXₙ. Use Ridge or Elastic Net when features are correlated. Compare model output against closing lines — if your model consistently beats the close, you have edge.

Why This Matters for Agents

Regression is the workhorse of Layer 4 — Intelligence. Every autonomous betting agent needs a function that takes observable features (team ratings, recent form, injuries, weather) and outputs a probability. Regression models are that function.

An agent’s decision pipeline works like this: pull odds from The Odds API and sportsbook feeds, compute features from historical data, run those features through a regression model to get a probability estimate, compare that estimate against the market’s implied probability, and — if edge exists — size the bet via Kelly criterion. The regression model is the core of the intelligence layer. Without it, the agent has no opinion on what anything is worth. Elo ratings and power rankings generate the features; regression turns those features into money.

The Math

Linear Regression for Point Spreads

Linear regression models a continuous outcome as a weighted sum of input features:

Y = β₀ + β₁X₁ + β₂X₂ + ... + βₙXₙ + ε

where Y is the outcome variable (e.g., point differential), X₁…Xₙ are predictor features, β₀ is the intercept, β₁…βₙ are coefficients, and ε is the error term (assumed normally distributed with mean 0 and variance σ²).

For NFL point spread modeling:

PointDiff = β₀ + β₁(HomeOffRating) + β₂(AwayDefRating) + β₃(HomeAdv) + β₄(RestDays) + ε

OLS (Ordinary Least Squares) estimates β by minimizing the sum of squared residuals:

β̂ = argmin_β Σᵢ (yᵢ - Xᵢβ)²

The closed-form solution:

β̂ = (XᵀX)⁻¹Xᵀy

R² (coefficient of determination) measures the fraction of variance explained:

R² = 1 - (SS_res / SS_tot)
   = 1 - Σ(yᵢ - ŷᵢ)² / Σ(yᵢ - ȳ)²

In sports modeling, R² values of 0.15-0.30 are typical for game-level predictions. That sounds low, but sports outcomes have massive irreducible variance. An R² of 0.25 means you explain 25% of point differential variance — the other 75% is luck, injuries, and chaos. That 25% is where all the edge lives.

Multicollinearity: The Silent Killer

Sports features are correlated. Offensive yards correlate with points scored. Turnover differential correlates with win rate. When features are highly correlated, OLS coefficient estimates become unstable — small changes in training data cause large swings in β̂.

Detect multicollinearity with the Variance Inflation Factor (VIF):

VIF_j = 1 / (1 - R²_j)

where R²_j is the R² from regressing feature j on all other features. Rules of thumb: VIF > 5 is concerning, VIF > 10 is a serious problem. The fix is regularization (covered below) or feature elimination.

Logistic Regression for Win Probability

Binary outcomes (win/loss) violate linear regression assumptions — the output must be bounded between 0 and 1. Logistic regression solves this by modeling the log-odds (logit) as a linear function:

log(p / (1 - p)) = β₀ + β₁X₁ + β₂X₂ + ... + βₙXₙ

where p is the probability of the outcome (e.g., home team wins), and p/(1-p) is the odds ratio.

Solving for p:

p = 1 / (1 + exp(-(β₀ + β₁X₁ + ... + βₙXₙ)))

This is the sigmoid function. It maps any real-valued input to [0, 1].

Sigmoid Output vs Linear Predictor:

p
1.0 ┤                                    ╭────────
    │                                 ╭──╯
0.8 ┤                              ╭──╯
    │                           ╭──╯
0.6 ┤                        ╭──╯
    │                     ╭──╯
0.4 ┤                  ╭──╯
    │               ╭──╯
0.2 ┤            ╭──╯
    │         ╭──╯
0.0 ┤─────────╯
    └──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──→ z = Xβ
      -6 -5 -4 -3 -2 -1  0  1  2  3  4  5

Logistic regression estimates β via maximum likelihood estimation (MLE), not OLS. The log-likelihood function:

ℓ(β) = Σᵢ [yᵢ log(pᵢ) + (1 - yᵢ) log(1 - pᵢ)]

where yᵢ ∈ {0, 1} and pᵢ = sigmoid(Xᵢβ). No closed-form solution exists — optimization uses iteratively reweighted least squares (IRLS) or gradient descent.

Coefficient interpretation: In logistic regression, exp(βⱼ) is the multiplicative change in odds for a one-unit increase in Xⱼ. If β₁ = 0.35 for home advantage, then exp(0.35) = 1.42 — home teams have 1.42x the odds of winning, all else equal.

Regularized Regression: Ridge, Lasso, Elastic Net

When you have 50+ features with correlations among them, unregularized regression overfits. Regularization adds a penalty term to the loss function that shrinks coefficients toward zero.

Ridge Regression (L2 penalty):

Loss = Σ(yᵢ - Xᵢβ)² + λ Σⱼ βⱼ²

Ridge shrinks all coefficients proportionally. It never zeroes them out. Use Ridge when you believe all features have some predictive value but want to control variance.

Lasso Regression (L1 penalty):

Loss = Σ(yᵢ - Xᵢβ)² + λ Σⱼ |βⱼ|

Lasso drives some coefficients exactly to zero, performing automatic feature selection. Use Lasso when you suspect many features are irrelevant.

Elastic Net (combined L1 + L2):

Loss = Σ(yᵢ - Xᵢβ)² + λ [α Σⱼ |βⱼ| + (1 - α)/2 Σⱼ βⱼ²]

where α ∈ [0, 1] controls the L1/L2 mix. Elastic Net handles groups of correlated features better than Lasso alone. For sports models, Elastic Net with α = 0.5 is a strong default.

Choosing λ (regularization strength): Use k-fold cross-validation. Split training data into k folds (k = 5 is standard), train on k-1 folds, evaluate on the held-out fold, average the error across folds, and select λ that minimizes mean cross-validated error.

MethodPenaltyFeature SelectionBest For
OLSNoneNoSmall feature sets, no collinearity
RidgeL2 (β²)No (shrinks all)Correlated features, all relevant
LassoL1 (|β|)Yes (zeros some)Many irrelevant features
Elastic NetL1 + L2Yes (partial)Correlated groups + irrelevant features

Poisson Regression for Score Modeling

Goals in soccer, runs in baseball, and touchdowns in football are count data. Linear regression is wrong for counts — it can predict negative values. Poisson regression models count outcomes directly:

log(μ) = β₀ + β₁X₁ + β₂X₂ + ... + βₙXₙ

where μ = E[Y] is the expected count (e.g., expected goals). The Poisson distribution:

P(Y = k) = (μᵏ × e⁻ᵘ) / k!

Each coefficient βⱼ in Poisson regression has the interpretation: exp(βⱼ) is the multiplicative change in the expected count for a one-unit increase in Xⱼ.

When Poisson fails: The Poisson distribution assumes mean equals variance (E[Y] = Var[Y]). In real sports data, variance often exceeds the mean — this is overdispersion. Use the negative binomial model when the overdispersion test is significant. The Poisson Distribution guide covers the Poisson model in depth, including the Dixon-Coles adjustment for soccer.

Worked Examples

NFL Win Probability: Logistic Regression from Scratch

We build a logistic regression model for NFL game outcomes using team efficiency metrics. The features: home team offensive DVOA, away team defensive DVOA, home advantage indicator, and rest differential (days since last game, home minus away).

Suppose the 2025 NFL Week 12 matchup is Kansas City Chiefs at home vs. Buffalo Bills. From Football Outsiders:

Feature values (hypothetical but realistic):
  KC Offensive DVOA:    +18.2%
  BUF Defensive DVOA:   -12.4% (negative is good for defense)
  Home advantage:        1
  Rest differential:     0 (both on standard rest)

With fitted coefficients from a 5-season training set:

β₀ (intercept)      = -0.12
β₁ (home_off_dvoa)  = +0.043
β₂ (away_def_dvoa)  = -0.038
β₃ (home_adv)       = +0.35
β₄ (rest_diff)      = +0.15

z = -0.12 + 0.043(18.2) + (-0.038)(-12.4) + 0.35(1) + 0.15(0)
z = -0.12 + 0.783 + 0.471 + 0.35 + 0
z = 1.484

p(KC wins) = 1 / (1 + exp(-1.484))
p(KC wins) = 1 / (1 + 0.227)
p(KC wins) = 0.815 = 81.5%

Now check the market. BetOnline has Chiefs -7.5 at -110, which implies roughly a 75% win probability (from the point spread, using the historical relationship between spread and moneyline). The model says 81.5% vs. the market’s 75%. That’s a 6.5-percentage-point edge — large enough to be actionable.

Expected CLV: if the line closes at -8.5 (corresponding to ~78% win probability), the model’s 81.5% estimate was closer to the closing line than the opening line. Consistent closing line value is the hallmark of a sharp model.

Point Spread Prediction: Ridge Regression

For a Bovada point spread on Lakers vs. Celtics (NBA):

Features (standardized, zero mean, unit variance):
  Home offensive rating:   +1.2σ
  Away defensive rating:   -0.8σ
  Pace differential:       +0.5σ
  Back-to-back flag:        0 (neither team)
  Rest advantage:          +0.3σ

Ridge coefficients (λ = 1.0, fitted on 3 NBA seasons):
  β₀ = +2.1 (home court baseline)
  β₁ = +3.8 (off rating)
  β₂ = -2.9 (def rating, negative means good defense hurts opponent)
  β₃ = +1.2 (pace)
  β₄ = -4.1 (back-to-back penalty)
  β₅ = +1.5 (rest)

Predicted spread = 2.1 + 3.8(1.2) + (-2.9)(-0.8) + 1.2(0.5) + (-4.1)(0) + 1.5(0.3)
                 = 2.1 + 4.56 + 2.32 + 0.6 + 0 + 0.45
                 = 10.03

Model says: Home team (Lakers) by ~10 points.
Bovada line: Lakers -7.5 at -110
Edge: Model sees 2.5 points of value on Lakers -7.5.

The sharp betting approach is to bet when your model’s prediction diverges significantly from the market.

Implementation

"""
Regression models for sports betting agents.
Covers: OLS, Logistic, Ridge, Poisson regression with
full training, evaluation, and prediction pipelines.
"""

import numpy as np
import pandas as pd
from sklearn.linear_model import (
    LinearRegression,
    LogisticRegressionCV,
    Ridge,
    Lasso,
    ElasticNet,
    PoissonRegressor,
)
from sklearn.model_selection import cross_val_score, TimeSeriesSplit
from sklearn.metrics import (
    brier_score_loss,
    log_loss,
    mean_squared_error,
    r2_score,
)
from sklearn.preprocessing import StandardScaler
from dataclasses import dataclass
from typing import Optional


@dataclass
class RegressionPrediction:
    """Output from a sports regression model."""
    predicted_value: float       # point spread, win prob, or expected goals
    model_type: str              # "ols", "logistic", "ridge", "poisson"
    confidence_interval: tuple[float, float]
    features_used: list[str]
    r2_or_accuracy: float


def compute_vif(X: pd.DataFrame) -> pd.DataFrame:
    """
    Compute Variance Inflation Factor for each feature.
    VIF > 5 indicates concerning multicollinearity.
    VIF > 10 indicates severe multicollinearity.
    """
    from sklearn.linear_model import LinearRegression

    vif_data = []
    for i, col in enumerate(X.columns):
        y_vif = X[col].values
        X_vif = X.drop(columns=[col]).values

        reg = LinearRegression().fit(X_vif, y_vif)
        r2 = reg.score(X_vif, y_vif)

        vif = 1.0 / (1.0 - r2) if r2 < 1.0 else np.inf
        vif_data.append({"feature": col, "VIF": round(vif, 2)})

    return pd.DataFrame(vif_data).sort_values("VIF", ascending=False)


def build_nfl_logistic_model(
    df: pd.DataFrame,
    features: list[str],
    target: str = "home_win",
    regularization: str = "elasticnet",
    n_splits: int = 5,
) -> dict:
    """
    Train a logistic regression model for NFL win probability.

    Args:
        df: DataFrame with game-level features and outcomes.
            Must have columns matching `features` plus `target`.
        features: List of feature column names.
        target: Binary target column (1 = home win, 0 = away win).
        regularization: One of 'l1', 'l2', 'elasticnet'.
        n_splits: Number of cross-validation folds.

    Returns:
        Dict with model, scaler, metrics, and coefficients.
    """
    X = df[features].copy()
    y = df[target].values

    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)

    # TimeSeriesSplit respects temporal ordering — critical for sports
    tscv = TimeSeriesSplit(n_splits=n_splits)

    if regularization == "elasticnet":
        solver = "saga"
        penalty = "elasticnet"
        l1_ratios = [0.1, 0.3, 0.5, 0.7, 0.9]
    elif regularization == "l1":
        solver = "saga"
        penalty = "l1"
        l1_ratios = None
    else:
        solver = "lbfgs"
        penalty = "l2"
        l1_ratios = None

    model = LogisticRegressionCV(
        penalty=penalty,
        solver=solver,
        cv=tscv,
        Cs=20,
        l1_ratios=l1_ratios,
        max_iter=5000,
        scoring="neg_log_loss",
        random_state=42,
    )
    model.fit(X_scaled, y)

    # Cross-validated metrics
    cv_logloss = -cross_val_score(
        model, X_scaled, y, cv=tscv, scoring="neg_log_loss"
    )
    cv_brier = -cross_val_score(
        model, X_scaled, y, cv=tscv, scoring="neg_brier_score"
    )
    cv_accuracy = cross_val_score(
        model, X_scaled, y, cv=tscv, scoring="accuracy"
    )

    # Coefficient table
    coef_df = pd.DataFrame({
        "feature": features,
        "coefficient": model.coef_[0],
        "odds_ratio": np.exp(model.coef_[0]),
    }).sort_values("coefficient", ascending=False)

    return {
        "model": model,
        "scaler": scaler,
        "best_C": model.C_[0],
        "coefficients": coef_df,
        "cv_logloss_mean": cv_logloss.mean(),
        "cv_logloss_std": cv_logloss.std(),
        "cv_brier_mean": cv_brier.mean(),
        "cv_accuracy_mean": cv_accuracy.mean(),
    }


def predict_win_probability(
    model_dict: dict,
    game_features: dict[str, float],
) -> RegressionPrediction:
    """
    Predict win probability for a single game.

    Args:
        model_dict: Output from build_nfl_logistic_model.
        game_features: Dict of feature_name -> value for one game.

    Returns:
        RegressionPrediction with win probability and metadata.
    """
    model = model_dict["model"]
    scaler = model_dict["scaler"]
    features = model_dict["coefficients"]["feature"].tolist()

    X = np.array([[game_features[f] for f in features]])
    X_scaled = scaler.transform(X)

    prob = model.predict_proba(X_scaled)[0, 1]

    # Bootstrap-style confidence interval approximation
    # using coefficient standard errors
    z = model.decision_function(X_scaled)[0]
    se_approx = 0.15  # typical SE for NFL logistic models
    ci_low = 1 / (1 + np.exp(-(z - 1.96 * se_approx)))
    ci_high = 1 / (1 + np.exp(-(z + 1.96 * se_approx)))

    return RegressionPrediction(
        predicted_value=prob,
        model_type="logistic",
        confidence_interval=(round(ci_low, 4), round(ci_high, 4)),
        features_used=features,
        r2_or_accuracy=model_dict["cv_accuracy_mean"],
    )


def build_ridge_spread_model(
    df: pd.DataFrame,
    features: list[str],
    target: str = "point_diff",
    alphas: Optional[list[float]] = None,
) -> dict:
    """
    Train a Ridge regression model for point spread prediction.

    Args:
        df: DataFrame with game features and point differential.
        features: Feature column names.
        target: Continuous target (home points - away points).
        alphas: List of regularization strengths to try.

    Returns:
        Dict with model, scaler, and evaluation metrics.
    """
    from sklearn.linear_model import RidgeCV

    if alphas is None:
        alphas = np.logspace(-2, 4, 50)

    X = df[features].copy()
    y = df[target].values

    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)

    tscv = TimeSeriesSplit(n_splits=5)

    model = RidgeCV(alphas=alphas, cv=tscv, scoring="neg_mean_squared_error")
    model.fit(X_scaled, y)

    y_pred = model.predict(X_scaled)
    r2 = r2_score(y, y_pred)
    rmse = np.sqrt(mean_squared_error(y, y_pred))

    cv_rmse = np.sqrt(-cross_val_score(
        model, X_scaled, y, cv=tscv, scoring="neg_mean_squared_error"
    ))

    coef_df = pd.DataFrame({
        "feature": features,
        "coefficient": model.coef_,
    }).sort_values("coefficient", ascending=False)

    return {
        "model": model,
        "scaler": scaler,
        "best_alpha": model.alpha_,
        "r2": r2,
        "rmse": rmse,
        "cv_rmse_mean": cv_rmse.mean(),
        "cv_rmse_std": cv_rmse.std(),
        "coefficients": coef_df,
    }


def build_poisson_model(
    df: pd.DataFrame,
    features: list[str],
    target: str = "goals_scored",
) -> dict:
    """
    Train a Poisson regression model for count outcomes
    (goals, runs, touchdowns).

    Args:
        df: DataFrame with features and count target.
        features: Feature column names.
        target: Count target column.

    Returns:
        Dict with model, scaler, and metrics.
    """
    X = df[features].copy()
    y = df[target].values

    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)

    model = PoissonRegressor(alpha=1.0, max_iter=1000)
    model.fit(X_scaled, y)

    y_pred = model.predict(X_scaled)

    # Overdispersion test: compare variance to mean
    residuals = y - y_pred
    pearson_chi2 = np.sum(residuals**2 / y_pred)
    dispersion = pearson_chi2 / (len(y) - len(features) - 1)

    coef_df = pd.DataFrame({
        "feature": features,
        "coefficient": model.coef_,
        "rate_ratio": np.exp(model.coef_),
    }).sort_values("coefficient", ascending=False)

    return {
        "model": model,
        "scaler": scaler,
        "coefficients": coef_df,
        "dispersion": dispersion,
        "overdispersed": dispersion > 1.5,
        "mean_predicted": y_pred.mean(),
        "mean_actual": y.mean(),
    }


def calibration_table(
    y_true: np.ndarray,
    y_prob: np.ndarray,
    n_bins: int = 10,
) -> pd.DataFrame:
    """
    Build a reliability diagram table for model calibration assessment.

    A well-calibrated model has observed frequency ≈ predicted probability
    in each bin. Use this to diagnose systematic over/under-confidence.

    Args:
        y_true: Binary outcomes (0 or 1).
        y_prob: Predicted probabilities.
        n_bins: Number of bins.

    Returns:
        DataFrame with bin_center, mean_predicted, observed_frequency, count.
    """
    bins = np.linspace(0, 1, n_bins + 1)
    table = []

    for i in range(n_bins):
        mask = (y_prob >= bins[i]) & (y_prob < bins[i + 1])
        if mask.sum() == 0:
            continue

        table.append({
            "bin_center": (bins[i] + bins[i + 1]) / 2,
            "mean_predicted": y_prob[mask].mean(),
            "observed_frequency": y_true[mask].mean(),
            "count": mask.sum(),
        })

    return pd.DataFrame(table)


def calculate_clv(
    model_prob: float,
    opening_implied: float,
    closing_implied: float,
) -> dict:
    """
    Calculate Closing Line Value.

    CLV measures whether your model's edge was real by comparing
    against the closing line (the sharpest estimate of true probability).

    Args:
        model_prob: Your model's predicted probability.
        opening_implied: Sportsbook opening implied probability.
        closing_implied: Sportsbook closing implied probability.

    Returns:
        Dict with CLV metrics.
    """
    # Model edge vs opening
    edge_vs_open = model_prob - opening_implied

    # Model edge vs closing
    edge_vs_close = model_prob - closing_implied

    # Did model beat the closing line?
    beat_close = model_prob > closing_implied

    # CLV in percentage points
    clv = closing_implied - opening_implied

    return {
        "model_prob": model_prob,
        "opening_implied": opening_implied,
        "closing_implied": closing_implied,
        "edge_vs_open": round(edge_vs_open, 4),
        "edge_vs_close": round(edge_vs_close, 4),
        "clv_points": round(clv, 4),
        "beat_closing_line": beat_close,
    }


# ------------------------------------------------------------------
# Example: End-to-end NFL model pipeline
# ------------------------------------------------------------------
if __name__ == "__main__":
    np.random.seed(42)

    # Simulate 500 NFL games with realistic feature distributions
    n_games = 500
    df = pd.DataFrame({
        "home_off_dvoa": np.random.normal(0, 15, n_games),
        "away_off_dvoa": np.random.normal(0, 15, n_games),
        "home_def_dvoa": np.random.normal(0, 12, n_games),
        "away_def_dvoa": np.random.normal(0, 12, n_games),
        "home_adv": np.ones(n_games),
        "rest_diff": np.random.choice([-3, 0, 3, 7], n_games, p=[0.1, 0.6, 0.2, 0.1]),
    })

    # Generate outcomes from a known logistic model
    true_z = (
        0.35 * df["home_adv"]
        + 0.04 * df["home_off_dvoa"]
        - 0.035 * df["away_off_dvoa"]
        - 0.03 * df["home_def_dvoa"]
        + 0.03 * df["away_def_dvoa"]
        + 0.08 * df["rest_diff"]
    )
    true_prob = 1 / (1 + np.exp(-true_z))
    df["home_win"] = (np.random.rand(n_games) < true_prob).astype(int)

    # Point differential (correlated with win outcome)
    df["point_diff"] = (
        true_z * 7
        + np.random.normal(0, 10, n_games)
    ).round(0)

    features = [
        "home_off_dvoa", "away_off_dvoa",
        "home_def_dvoa", "away_def_dvoa",
        "home_adv", "rest_diff",
    ]

    # 1. Check multicollinearity
    print("=== VIF Analysis ===")
    vif = compute_vif(df[features])
    print(vif.to_string(index=False))

    # 2. Train logistic model
    print("\n=== Logistic Regression (Elastic Net) ===")
    result = build_nfl_logistic_model(df, features, regularization="elasticnet")
    print(f"Best C (inverse regularization): {result['best_C']:.4f}")
    print(f"CV Log-Loss: {result['cv_logloss_mean']:.4f} +/- {result['cv_logloss_std']:.4f}")
    print(f"CV Brier: {result['cv_brier_mean']:.4f}")
    print(f"CV Accuracy: {result['cv_accuracy_mean']:.1%}")
    print("\nCoefficients:")
    print(result["coefficients"].to_string(index=False))

    # 3. Predict a specific game
    game = {
        "home_off_dvoa": 18.2,
        "away_off_dvoa": 12.5,
        "home_def_dvoa": -8.3,
        "away_def_dvoa": -12.4,
        "home_adv": 1.0,
        "rest_diff": 0.0,
    }
    pred = predict_win_probability(result, game)
    print(f"\nPredicted win prob: {pred.predicted_value:.1%}")
    print(f"95% CI: ({pred.confidence_interval[0]:.1%}, {pred.confidence_interval[1]:.1%})")

    # 4. Calibration check
    print("\n=== Calibration Table ===")
    model = result["model"]
    scaler = result["scaler"]
    X_scaled = scaler.transform(df[features])
    probs = model.predict_proba(X_scaled)[:, 1]
    cal = calibration_table(df["home_win"].values, probs)
    print(cal.to_string(index=False))

    # 5. CLV calculation
    print("\n=== CLV Example ===")
    clv = calculate_clv(
        model_prob=0.815,
        opening_implied=0.75,
        closing_implied=0.78,
    )
    for k, v in clv.items():
        print(f"  {k}: {v}")

    # 6. Ridge spread model
    print("\n=== Ridge Regression (Point Spread) ===")
    spread_result = build_ridge_spread_model(df, features, target="point_diff")
    print(f"Best alpha: {spread_result['best_alpha']:.2f}")
    print(f"R²: {spread_result['r2']:.3f}")
    print(f"RMSE: {spread_result['rmse']:.1f}")
    print(f"CV RMSE: {spread_result['cv_rmse_mean']:.1f} +/- {spread_result['cv_rmse_std']:.1f}")

Limitations and Edge Cases

Small sample sizes. NFL seasons have 272 regular-season games. With 6 features, that’s roughly 45 observations per parameter — borderline for stable logistic regression. NBA (1,230 games) and MLB (2,430 games) provide more data, but even then, regime changes (rule changes, new shot clock rules) can invalidate historical coefficients. Regularization helps, but it does not fix fundamentally insufficient data.

Non-stationarity. Team quality changes throughout a season. A model trained on early-season data may not reflect late-season performance. Solutions: use rolling windows (train on the last N games only), exponentially weight recent observations, or build in-season Elo features that capture form changes. The Elo ratings guide covers dynamic rating systems that address this.

Feature engineering matters more than model complexity. A logistic regression with well-engineered features (opponent-adjusted efficiency, rest advantage, travel distance) beats a random forest with raw box score stats. The Feature Engineering guide covers this in detail.

Calibration drift. A model calibrated on 2020-2024 data may be miscalibrated on 2025 data if the game has changed (three-point revolution in the NBA, increased passing in the NFL). Recalibrate quarterly using Platt scaling or isotonic regression.

The closing line is the benchmark, not the outcome. A model that beats the closing line by 2% is profitable even if individual game predictions look wrong half the time. Evaluate against closing line value, not against outcomes. Outcomes have variance; CLV does not.

Poisson overdispersion. Real goal/run distributions often have more variance than Poisson predicts. If the dispersion statistic exceeds 1.5, switch to negative binomial. Ignoring overdispersion produces artificially narrow confidence intervals, which leads to oversized bets.

FAQ

What is the best regression model for predicting sports outcomes?

Logistic regression is the standard starting point for binary outcomes (win/loss). It outputs calibrated probabilities directly, handles mixed feature types, and resists overfitting with regularization. For point spread prediction, use OLS linear regression. For score/goal totals, use Poisson regression. Ridge or Elastic Net variants are preferred when you have many correlated features.

How do you use logistic regression for sports betting?

Fit a logistic regression model on historical game data with features like offensive rating, defensive rating, and home advantage. The model outputs win probabilities via the sigmoid function. Compare those probabilities against implied odds from sportsbooks — if your model says 62% and the market implies 55%, you have a 7-percentage-point edge. Size the bet using the Kelly Criterion.

Why is regularization important for sports prediction models?

Sports datasets have many correlated features (offensive yards correlate with points scored, for example). Without regularization, OLS overfits to noise in the training data and produces unstable coefficients. Ridge regression (L2 penalty) shrinks correlated coefficients toward zero, reducing variance. Lasso (L1) performs feature selection by zeroing out irrelevant predictors entirely.

How do you evaluate if a sports betting model is well-calibrated?

Use a reliability diagram: bin predictions into deciles (50-55%, 55-60%, etc.) and plot predicted probability vs. actual win rate. A calibrated model falls on the diagonal. Quantify miscalibration with the Hosmer-Lemeshow test or Brier score. Then validate against closing lines — a model that consistently beats the closing line has real predictive edge.

How does regression connect to Elo ratings and power rankings for agents?

Elo ratings and power rankings are features that feed into regression models. An agent computes Elo ratings for each team, then uses those ratings as input variables in a logistic regression to predict win probability. The Elo Ratings guide covers how to build the rating system; this guide covers how to turn those ratings into actionable probabilities and bet decisions.

What’s Next

This guide covers the regression toolkit for turning features into probabilities. The natural next steps:

  • NFL-specific modeling: The NFL Mathematical Modeling guide applies these regression techniques to point spreads, totals, and player props with NFL-specific feature engineering.
  • Feature engineering: Regression models are only as good as their inputs. The Feature Engineering guide covers how to build the features that make regression models profitable.
  • Where to get the data: The Agent Betting Stack maps the full pipeline from data ingestion through model deployment.
  • Evaluating your edge: Closing Line Value is how you know your regression model is working — compare model output against closing lines from BetOnline and Bookmaker.
  • Sizing the bets: Once you have a probability from regression, feed it to the Kelly Criterion to determine optimal stake.