Skip to content

Projections

The projection layer converts weekly NFL stats into per-game points projections (mean + stdev) for every fantasy-relevant player.

ProjectionEngineV2

The current production engine. Vegas-backed matchup factors with walk-forward weight fitting.

fantasyfb.projections.engine_v2.ProjectionEngineV2

ProjectionEngineV2(time_decay: float = _DEFAULT_TIME_DECAY, shrinkage_n: Optional[Dict[str, int]] = None)

Volume x efficiency projection engine.

Returns the same points_rate/points_stdev/num_games schema as V1 so the rest of fantasyfb (lineup optimizer, season simulator, move analyzer) keeps working unchanged. Per-week matchup adjustment happens downstream in lineup_optimizer, same as V1.

Parameters:

Name Type Description Default
time_decay float

Per-week exponential decay rate applied when aggregating a player's volume and efficiency over their game history. 0 = no decay (treat all games equally), higher = trust recent games more.

_DEFAULT_TIME_DECAY
shrinkage_n Optional[Dict[str, int]]

Bayesian shrinkage strength per position, expressed as equivalent prior games. Defaults to _DEFAULT_SHRINKAGE.

None

calculate_projections

calculate_projections(stats_df: DataFrame, earliest_weeks: Dict[str, int], current_week: int, nfl_schedule: Optional[DataFrame] = None) -> pd.DataFrame

Project per-game fantasy points for every player in stats_df.

Parameters:

Name Type Description Default
stats_df DataFrame

Historical game stats, must include points (added upstream by FantasyScorer) plus the position-specific opportunity columns referenced in _OPPORTUNITY_DEF.

required
earliest_weeks Dict[str, int]

Per-position YYYYWW cutoff -- games older than this are excluded.

required
current_week int

Training cutoff in YYYYWW. Games on or after this week are excluded (no look-ahead).

required
nfl_schedule Optional[DataFrame]

Accepted for V1 API parity; unused in Stage 1.

None

Returns:

Name Type Description
DataFrame

DataFrame with one row per player plus one avg_<POS> row

DataFrame

per position (used as the rookie fallback by fantasyfb.py).

Columns DataFrame

player_id_sr, position, points_rate, points_stdev,

DataFrame

num_games, volume_rate, efficiency_rate.

MatchupModel

Applies opponent/Vegas factors on top of base rates.

fantasyfb.scoring.matchup_model.MatchupModel

MatchupModel(weights: Optional[Dict[str, _PositionWeights]] = None)

Per-game multiplicative matchup factor.

Build via :meth:from_history, which precomputes the league-wide distributions of implied total and allowed-vs-position used to z-score each input. Then call :meth:apply_factors to attach a matchup_factor column to a players DataFrame for a given week.

from_history classmethod

from_history(stats_df: DataFrame, schedule_df: DataFrame, weights: Optional[Dict[str, _PositionWeights]] = None) -> 'MatchupModel'

Fit the league-average baselines from historical data.

Parameters:

Name Type Description Default
stats_df DataFrame

per-game player stats with points, position, opponent, season, week columns. Used to compute "fantasy points allowed by team X to position P per game" via the player's opponent field.

required
schedule_df DataFrame

per-team-game schedule with implied_total. Used for the league mean/std of implied totals.

required
weights Optional[Dict[str, _PositionWeights]]

optional per-position coefficient overrides.

None

factor

factor(position: str, team_implied_total: float, opp_implied_total: float, opp_team: str, string: float = 1.0) -> float

Per-player matchup multiplier centered at 1.0.

apply_factors

apply_factors(players: DataFrame, schedule: DataFrame, as_of: int) -> pd.DataFrame

Attach a matchup_factor column to a players-shaped DataFrame.

Parameters:

Name Type Description Default
players DataFrame

must have current_team, position, and string.

required
schedule DataFrame

provider schedule output (one row per team-week).

required
as_of int

YYYYWW of the week we're projecting.

required

Returns:

Type Description
DataFrame

A copy of players with matchup_factor and the underlying

DataFrame

inputs (team implied total, opp_team, opp allowed) joined on

DataFrame

for debuggability.

Model fitter

Walk-forward fitting of the projection-engine weights.

fantasyfb.projections.model_fitter

Walk-forward least-squares fitting of MatchupModel weights.

Stage 3 of the V2 rebuild. The matchup factor is multiplicative around 1.0:

factor = 1 + alpha * z_implied_total + beta * z_allowed_vs_pos
         - gamma * (string - 1)

Once a player's neutral_rate (volume * efficiency) is fixed, the alpha and beta terms are linear in their parameters per position, so OLS does the work. Gamma (depth-chart penalty) is left at its hand-tuned default because we don't have reliable historical week-by-week depth charts to fit against.

Walk-forward construction: for each historical game, we compute features using only data from before that game. That isolates the matchup signal from the player's own future performance and prevents the fit from memorizing rather than generalizing.

_DEFAULT_WEIGHTS module-attribute

_DEFAULT_WEIGHTS: Dict[str, _PositionWeights] = {'QB': _PositionWeights(alpha=0.2, beta=0.15, gamma=0.5), 'RB': _PositionWeights(alpha=0.15, beta=0.2, gamma=0.55), 'WR': _PositionWeights(alpha=0.15, beta=0.2, gamma=0.4), 'TE': _PositionWeights(alpha=0.1, beta=0.2, gamma=0.4), 'K': _PositionWeights(alpha=0.1, beta=0.0, gamma=0.0), 'DEF': _PositionWeights(alpha=-0.3, beta=0.15, gamma=0.0)}

_FANTASY_POSITIONS module-attribute

_FANTASY_POSITIONS = ['QB', 'RB', 'WR', 'TE', 'K', 'DEF']

MatchupModel

MatchupModel(weights: Optional[Dict[str, _PositionWeights]] = None)

Per-game multiplicative matchup factor.

Build via :meth:from_history, which precomputes the league-wide distributions of implied total and allowed-vs-position used to z-score each input. Then call :meth:apply_factors to attach a matchup_factor column to a players DataFrame for a given week.

from_history classmethod

from_history(stats_df: DataFrame, schedule_df: DataFrame, weights: Optional[Dict[str, _PositionWeights]] = None) -> 'MatchupModel'

Fit the league-average baselines from historical data.

Parameters:

Name Type Description Default
stats_df DataFrame

per-game player stats with points, position, opponent, season, week columns. Used to compute "fantasy points allowed by team X to position P per game" via the player's opponent field.

required
schedule_df DataFrame

per-team-game schedule with implied_total. Used for the league mean/std of implied totals.

required
weights Optional[Dict[str, _PositionWeights]]

optional per-position coefficient overrides.

None

factor

factor(position: str, team_implied_total: float, opp_implied_total: float, opp_team: str, string: float = 1.0) -> float

Per-player matchup multiplier centered at 1.0.

apply_factors

apply_factors(players: DataFrame, schedule: DataFrame, as_of: int) -> pd.DataFrame

Attach a matchup_factor column to a players-shaped DataFrame.

Parameters:

Name Type Description Default
players DataFrame

must have current_team, position, and string.

required
schedule DataFrame

provider schedule output (one row per team-week).

required
as_of int

YYYYWW of the week we're projecting.

required

Returns:

Type Description
DataFrame

A copy of players with matchup_factor and the underlying

DataFrame

inputs (team implied total, opp_team, opp allowed) joined on

DataFrame

for debuggability.

_PositionWeights dataclass

_PositionWeights(alpha: float, beta: float, gamma: float)

Coefficient bundle for one position's matchup factor.

factor = 1 + alpha * z(team_implied_total) + beta * z(opp_allowed_vs_position) - gamma * string_penalty(string)

where z(x) = (x - league_avg) / league_std, and string_penalty is a position-aware function of depth-chart string (see _string_penalty_slots) that is 0 for genuinely-starting depth and only grows once a player is beyond the slots a position actually plays.

ProjectionEngineV2

ProjectionEngineV2(time_decay: float = _DEFAULT_TIME_DECAY, shrinkage_n: Optional[Dict[str, int]] = None)

Volume x efficiency projection engine.

Returns the same points_rate/points_stdev/num_games schema as V1 so the rest of fantasyfb (lineup optimizer, season simulator, move analyzer) keeps working unchanged. Per-week matchup adjustment happens downstream in lineup_optimizer, same as V1.

Parameters:

Name Type Description Default
time_decay float

Per-week exponential decay rate applied when aggregating a player's volume and efficiency over their game history. 0 = no decay (treat all games equally), higher = trust recent games more.

_DEFAULT_TIME_DECAY
shrinkage_n Optional[Dict[str, int]]

Bayesian shrinkage strength per position, expressed as equivalent prior games. Defaults to _DEFAULT_SHRINKAGE.

None

calculate_projections

calculate_projections(stats_df: DataFrame, earliest_weeks: Dict[str, int], current_week: int, nfl_schedule: Optional[DataFrame] = None) -> pd.DataFrame

Project per-game fantasy points for every player in stats_df.

Parameters:

Name Type Description Default
stats_df DataFrame

Historical game stats, must include points (added upstream by FantasyScorer) plus the position-specific opportunity columns referenced in _OPPORTUNITY_DEF.

required
earliest_weeks Dict[str, int]

Per-position YYYYWW cutoff -- games older than this are excluded.

required
current_week int

Training cutoff in YYYYWW. Games on or after this week are excluded (no look-ahead).

required
nfl_schedule Optional[DataFrame]

Accepted for V1 API parity; unused in Stage 1.

None

Returns:

Name Type Description
DataFrame

DataFrame with one row per player plus one avg_<POS> row

DataFrame

per position (used as the rookie fallback by fantasyfb.py).

Columns DataFrame

player_id_sr, position, points_rate, points_stdev,

DataFrame

num_games, volume_rate, efficiency_rate.

build_training_set

build_training_set(stats_df: DataFrame, schedule_df: DataFrame, training_seasons: List[int], earliest_history: Optional[int] = None, min_neutral_rate: float = 0.5) -> pd.DataFrame

Walk-forward feature/target table for matchup weight fitting.

For each game in training_seasons, we compute: neutral_rate -- ProjectionEngineV2 output as of the start of the target week, using all stats from the earliest history up to but not including the target week. z_implied_total -- z-score of the team's implied total against the league baseline for that cutoff. z_allowed -- z-score of opponent's allowed-fantasy-points- vs-position against league baseline. actual_points -- the actual fantasy points the player scored in the target week (the regression target).

Parameters:

Name Type Description Default
stats_df DataFrame

per-game stats with points already populated.

required
schedule_df DataFrame

provider schedule with implied_total / opp_implied_total populated.

required
training_seasons List[int]

seasons to use as targets (we still pull from earlier seasons for history).

required
earliest_history Optional[int]

oldest YYYYWW to include in history. Defaults to two full seasons before the earliest training season.

None
min_neutral_rate float

drop rows whose neutral_rate falls below this threshold; a tiny denominator turns into a huge meaningless residual that would dominate the fit.

0.5

Returns:

Type Description
DataFrame

Long-format DataFrame; one row per (player, week) actually played.

fit_matchup_weights

fit_matchup_weights(training: DataFrame, default_weights: Optional[Dict[str, _PositionWeights]] = None, min_samples: int = 50, coefficient_clip: float = 1.0, ridge_lambda: float = 5.0) -> Dict[str, _PositionWeights]

Per-position OLS fit for alpha (implied total) and beta (allowed).

Solves for each position the Bayesian-ridge problem

minimize ||y - X*beta||^2 + ridge_lambda * ||beta - beta_default||^2

where y = actual / neutral - 1 and X = [z_implied, z_allowed]. Pulling toward the hand-tuned default rather than toward zero matters for the offensive positions: V2's neutral_rate already absorbs most of the "team scoring environment" signal through historical efficiency, so the residual signal that matchup adds is small. A vanilla ridge would collapse alphas to 0 in low-signal positions; ridge-to-defaults preserves the hand-tuned prior unless data overrides it.

Parameters:

Name Type Description Default
training DataFrame

output of :func:build_training_set.

required
default_weights Optional[Dict[str, _PositionWeights]]

prior weights to ridge-shrink toward. Below the min_samples threshold we return them unchanged.

None
min_samples int

minimum row count to attempt a fit; below this, fall through to defaults.

50
coefficient_clip float

upper bound for |alpha| and |beta| post-fit.

1.0
ridge_lambda float

L2 strength on deviations from defaults. Larger = trust defaults more relative to data. 5.0 is calibrated to yield meaningful movement on positions with hundreds of samples while keeping K/DEF (where data is noisy) close to their priors.

5.0

Returns:

Type Description
Dict[str, _PositionWeights]

Dict mapping position to fitted _PositionWeights. gamma

Dict[str, _PositionWeights]

is carried over from defaults; we don't have reliable historical

Dict[str, _PositionWeights]

depth-chart data to fit it against.

fit_from_history

fit_from_history(stats_df: DataFrame, schedule_df: DataFrame, training_seasons: List[int], **fit_kwargs) -> Dict[str, _PositionWeights]

Convenience: build the training set, run the fit, return weights.

Heavy compute is in :func:build_training_set (one V2 projection call per training week). Cache the training DataFrame if you're going to refit with different hyperparameters.