Skip to content

optimizer

optimizer

Squad optimization: two-level ILP, mean-variance, LP relaxation.

OptimizationResult dataclass

OptimizationResult(
    full_squad: FullSquad,
    objective_value: float = 0.0,
    solve_time: float = 0.0,
    lp_objective: Optional[float] = None,
    integrality_gap: Optional[float] = None,
    shadow_prices: dict = dict(),
    binding_constraints: list = list(),
)

Container for optimization outputs including duality analysis.

TwoLevelILPOptimizer

TwoLevelILPOptimizer(
    budget: float = 100.0,
    max_from_team: int = 3,
    risk_aversion: float = 0.0,
)

Bases: BaseOptimizer

Two-level ILP: select 15-player squad then 11-player lineup jointly.

Supports risk-neutral and risk-averse (mean-variance) objectives. Also exposes LP relaxation for shadow price extraction.

PARAMETER DESCRIPTION
budget

Maximum total squad budget (applied to 15 players).

TYPE: float DEFAULT: 100.0

max_from_team

Maximum players from same club.

TYPE: int DEFAULT: 3

risk_aversion

Lambda for mean-variance penalty. 0 = risk-neutral.

TYPE: float DEFAULT: 0.0

Source code in fplx/selection/optimizer.py
def __init__(
    self,
    budget: float = 100.0,
    max_from_team: int = 3,
    risk_aversion: float = 0.0,
):
    self.budget = budget
    self.max_from_team = max_from_team
    self.risk_aversion = risk_aversion

    try:
        import pulp

        self.pulp = pulp
    except ImportError:
        raise ImportError("pulp required for ILP optimization. Install with: pip install pulp")

solve

solve(players, **kwargs)

Solve the optimization problem.

Source code in fplx/selection/optimizer.py
def solve(self, players, **kwargs):
    """Solve the optimization problem."""
    return self.optimize(players, **kwargs)

optimize

optimize(
    players: list[Player],
    expected_points: dict[int, float],
    expected_variance: Optional[dict[int, float]] = None,
    downside_risk: Optional[dict[int, float]] = None,
    formation: Optional[str] = None,
) -> FullSquad

Solve the two-level ILP.

PARAMETER DESCRIPTION
players

Available player pool.

TYPE: list[Player]

expected_points

E[P_i] per player.

TYPE: dict[int, float]

expected_variance

Var[P_i] per player.

TYPE: dict[int, float] DEFAULT: None

downside_risk

Downside spread per player. If provided, risk penalty uses this directly (instead of sqrt(variance)).

TYPE: dict[int, float] DEFAULT: None

formation

Not used (formation is optimized automatically).

TYPE: Optional[str] DEFAULT: None

RETURNS DESCRIPTION
FullSquad
Source code in fplx/selection/optimizer.py
def optimize(
    self,
    players: list[Player],
    expected_points: dict[int, float],
    expected_variance: Optional[dict[int, float]] = None,
    downside_risk: Optional[dict[int, float]] = None,
    formation: Optional[str] = None,
) -> FullSquad:
    """
    Solve the two-level ILP.

    Parameters
    ----------
    players : list[Player]
        Available player pool.
    expected_points : dict[int, float]
        E[P_i] per player.
    expected_variance : dict[int, float], optional
        Var[P_i] per player.
    downside_risk : dict[int, float], optional
        Downside spread per player. If provided, risk penalty uses this
        directly (instead of sqrt(variance)).
    formation : Optional[str]
        Not used (formation is optimized automatically).

    Returns
    -------
    FullSquad
    """
    import time

    start = time.perf_counter()
    prob, s_vars, x_vars = self._build_problem(
        players,
        expected_points,
        expected_variance,
        downside_risk,
        relax=False,
    )
    prob.solve(self.pulp.PULP_CBC_CMD(msg=0))
    elapsed = time.perf_counter() - start

    if prob.status != 1:
        logger.error("ILP solver did not find optimal solution (status=%d).", prob.status)

    # Extract solution
    squad_players = [p for p in players if s_vars[p.id].varValue and s_vars[p.id].varValue > 0.5]
    lineup_players = [p for p in players if x_vars[p.id].varValue and x_vars[p.id].varValue > 0.5]

    # Determine formation
    pos_counts = {"DEF": 0, "MID": 0, "FWD": 0}
    for p in lineup_players:
        if p.position in pos_counts:
            pos_counts[p.position] += 1
    formation_str = f"{pos_counts['DEF']}-{pos_counts['MID']}-{pos_counts['FWD']}"

    # Captain = highest expected points
    for p in lineup_players:
        p.expected_points = expected_points.get(p.id, 0.0)
    captain = (
        max(lineup_players, key=lambda p: expected_points.get(p.id, 0.0)) if lineup_players else None
    )

    total_ep = sum(expected_points.get(p.id, 0.0) for p in lineup_players)
    lineup_cost = sum(p.price for p in lineup_players)

    lineup = Squad(
        players=lineup_players,
        formation=formation_str,
        total_cost=lineup_cost,
        expected_points=total_ep,
        captain=captain,
    )
    full_squad = FullSquad(squad_players=squad_players, lineup=lineup)

    logger.info("ILP solved in %.3fs. Formation: %s. EP: %.2f", elapsed, formation_str, total_ep)
    return full_squad

solve_lp_relaxation

solve_lp_relaxation(
    players: list[Player],
    expected_points: dict[int, float],
    expected_variance: Optional[dict[int, float]] = None,
    downside_risk: Optional[dict[int, float]] = None,
) -> OptimizationResult

Solve the LP relaxation and extract shadow prices.

RETURNS DESCRIPTION
OptimizationResult

Contains LP objective, shadow prices, binding constraints.

Source code in fplx/selection/optimizer.py
def solve_lp_relaxation(
    self,
    players: list[Player],
    expected_points: dict[int, float],
    expected_variance: Optional[dict[int, float]] = None,
    downside_risk: Optional[dict[int, float]] = None,
) -> OptimizationResult:
    """
    Solve the LP relaxation and extract shadow prices.

    Returns
    -------
    OptimizationResult
        Contains LP objective, shadow prices, binding constraints.
    """
    import time

    start = time.perf_counter()
    prob, s_vars, x_vars = self._build_problem(
        players,
        expected_points,
        expected_variance,
        downside_risk,
        relax=True,
    )
    prob.solve(self.pulp.PULP_CBC_CMD(msg=0))
    elapsed = time.perf_counter() - start

    lp_obj = self.pulp.value(prob.objective)

    # Extract shadow prices from constraints
    shadow_prices = {}
    binding = []
    for name, constraint in prob.constraints.items():
        slack = constraint.slack
        # PuLP: pi attribute gives the dual value for LP
        dual = constraint.pi if constraint.pi is not None else 0.0
        shadow_prices[name] = {
            "dual_value": dual,
            "slack": slack,
            "binding": abs(slack) < 1e-6,
        }
        if abs(slack) < 1e-6:
            binding.append(name)

    # Also solve ILP to compute integrality gap
    full_squad = self.optimize(players, expected_points, expected_variance, downside_risk)
    ilp_obj = full_squad.lineup.expected_points
    gap = (lp_obj - ilp_obj) / lp_obj if lp_obj > 0 else 0.0

    return OptimizationResult(
        full_squad=full_squad,
        objective_value=ilp_obj,
        solve_time=elapsed,
        lp_objective=lp_obj,
        integrality_gap=gap,
        shadow_prices=shadow_prices,
        binding_constraints=binding,
    )

GreedyOptimizer

GreedyOptimizer(
    budget: float = 100.0, max_from_team: int = 3
)

Bases: BaseOptimizer

Greedy baseline: select best-value players per position.

Fast heuristic for comparison. Selects 15-player squad, then picks best 11 as lineup.

Source code in fplx/selection/optimizer.py
def __init__(self, budget: float = 100.0, max_from_team: int = 3):
    self.budget = budget
    self.max_from_team = max_from_team

optimize

optimize(
    players: list[Player],
    expected_points: dict[int, float],
    expected_variance: Optional[dict[int, float]] = None,
    formation: Optional[str] = None,
) -> FullSquad

Greedy squad + lineup selection.

Source code in fplx/selection/optimizer.py
def optimize(
    self,
    players: list[Player],
    expected_points: dict[int, float],
    expected_variance: Optional[dict[int, float]] = None,
    formation: Optional[str] = None,
) -> FullSquad:
    """Greedy squad + lineup selection."""
    # Compute value = EP / price for each player
    for p in players:
        ep = expected_points.get(p.id, 0.0)
        p.expected_points = ep
        p._value = ep / max(p.price, 0.1)

    # Sort by value within each position
    by_pos: dict[str, list[Player]] = {"GK": [], "DEF": [], "MID": [], "FWD": []}
    for p in players:
        by_pos[p.position].append(p)
    for pos in by_pos:
        by_pos[pos].sort(key=lambda p: p._value, reverse=True)

    # Greedily fill squad (15 players)
    squad_quotas = {"GK": 2, "DEF": 5, "MID": 5, "FWD": 3}
    selected_squad: list[Player] = []
    team_counts: dict[str, int] = {}
    remaining = self.budget

    for pos in ["GK", "DEF", "MID", "FWD"]:
        count = 0
        for p in by_pos[pos]:
            if count >= squad_quotas[pos]:
                break
            if team_counts.get(p.team, 0) >= self.max_from_team:
                continue
            if p.price > remaining:
                continue
            selected_squad.append(p)
            team_counts[p.team] = team_counts.get(p.team, 0) + 1
            remaining -= p.price
            count += 1

    if len(selected_squad) != 15:
        logger.warning("Greedy only picked %d squad players.", len(selected_squad))
        # Pad if needed (shouldn't happen with 600+ players)
        return self._fallback(selected_squad, expected_points)

    # Select best 11 from the 15
    lineup = self._select_lineup(selected_squad, expected_points, formation)
    return FullSquad(squad_players=selected_squad, lineup=lineup)