Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update max free transfers to 5 #680

Merged
merged 10 commits into from
Sep 18, 2024
15 changes: 15 additions & 0 deletions airsenal/framework/optimization_transfers.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,21 @@ def make_best_transfers(
players_out = [p for p in _out if p not in _in] # remove duplicates
transfer_dict = {"in": players_in, "out": players_out}

elif isinstance(num_transfers, int) and num_transfers > 2:
new_squad, players_out, players_in = make_random_transfers(
squad,
tag,
nsubs=num_transfers,
gw_range=gameweeks,
root_gw=root_gw,
num_iter=num_iter,
update_func_and_args=update_func_and_args,
season=season,
bench_boost_gw=bench_boost_gw,
triple_captain_gw=triple_captain_gw,
)
transfer_dict = {"in": players_in, "out": players_out}

else:
raise RuntimeError(f"Unrecognized value for num_transfers: {num_transfers}")

Expand Down
128 changes: 79 additions & 49 deletions airsenal/framework/optimization_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
positions = ["FWD", "MID", "DEF", "GK"] # front-to-back

DEFAULT_SUB_WEIGHTS = {"GK": 0.03, "Outfield": (0.65, 0.3, 0.1)}
MAX_FREE_TRANSFERS = 5 # changed in 24/25 season (not accounted for in replay season)


def check_tag_valid(pred_tag, gameweek_range, season=CURRENT_SEASON, dbsession=session):
Expand Down Expand Up @@ -63,35 +64,41 @@ def calc_points_hit(num_transfers, free_transfers):
"""
if num_transfers in ["W", "F"]:
return 0
elif isinstance(num_transfers, int):
return max(0, 4 * (num_transfers - free_transfers))
elif (num_transfers.startswith("B") or num_transfers.startswith("T")) and len(
num_transfers
) == 2:
if (
isinstance(num_transfers, str)
and num_transfers.startswith(("B", "T"))
and len(num_transfers) == 2
):
num_transfers = int(num_transfers[-1])
return max(0, 4 * (num_transfers - free_transfers))
else:
if not isinstance(num_transfers, int):
raise RuntimeError(f"Unexpected argument for num_transfers {num_transfers}")

return max(0, 4 * (num_transfers - free_transfers))


def calc_free_transfers(num_transfers, prev_free_transfers):
def calc_free_transfers(
num_transfers, prev_free_transfers, max_free_transfers=MAX_FREE_TRANSFERS
):
"""
We get one extra free transfer per week, unless we use a wildcard or
free hit, but we can't have more than 2. So we should only be able
to return 1 or 2.
free hit, but we can't have more than 5. So we should only be able
to return 1 to 5.
"""
if num_transfers in ["W", "F"]:
return 1
elif isinstance(num_transfers, int):
return max(1, min(2, 1 + prev_free_transfers - num_transfers))
elif (num_transfers.startswith("B") or num_transfers.startswith("T")) and len(
num_transfers
) == 2:
# take the 'x' out of Bx or Tx
return prev_free_transfers # changed in 24/25 season, previously 1

if (
isinstance(num_transfers, str)
and num_transfers.startswith(("B", "T"))
and len(num_transfers) == 2
):
# take the 'x' out of Bx or Tx (bench boost or triple captain with x transfers)
num_transfers = int(num_transfers[-1])
return max(1, min(2, 1 + prev_free_transfers - num_transfers))
else:
raise RuntimeError(f"Unexpected argument for num_transfers {num_transfers}")

if not isinstance(num_transfers, int):
raise ValueError(f"Unexpected input for num_transfers {num_transfers}")

return max(1, min(max_free_transfers, 1 + prev_free_transfers - num_transfers))


def get_starting_squad(
Expand Down Expand Up @@ -429,15 +436,24 @@ def next_week_transfers(
strat,
max_total_hit=None,
allow_unused_transfers=True,
max_transfers=2,
max_opt_transfers=2,
chips={"chips_allowed": [], "chip_to_play": None},
max_free_transfers=MAX_FREE_TRANSFERS,
):
"""Given a previous strategy and some optimisation constraints, determine the valid
options for the number of transfers (or chip played) in the following gameweek.

strat is a tuple (free_transfers, hit_so_far, strat_dict)
strat_dict must have key chips_played, which is a dict indexed by gameweek with
possible values None, "wildcard", "free_hit", "bench_boost" or triple_captain"

max_opt_transfers - maximum number of transfers to play each week as part of
strategy in optimisation

max_free_transfers - maximum number of free transfers saved in the game rules
(2 before 2024/25, 5 from 2024/25 season)

Returns (new_transfers, new_ft_available, new_points_hits) tuples.
"""
# check that the 'chips' dict we are given makes sense:
if (
Expand All @@ -453,13 +469,13 @@ def next_week_transfers(
ft_available, hit_so_far, strat_dict = strat
chip_history = strat_dict["chips_played"]

if not allow_unused_transfers and ft_available == 2:
# Force at least 1 free transfer.
# NOTE: This will exclude the baseline strategy when allow_unused_transfers
# is False. Re-add it outside this function in that case.
ft_choices = list(range(1, max_transfers + 1))
if not allow_unused_transfers and ft_available == max_free_transfers:
# Force at least 1 free transfer if a free transfer will be lost otherwise.
# NOTE: This can cause the baseline strategy to be excluded. Re-add it outside
# this function in that case.
ft_choices = list(range(1, max_opt_transfers + 1))
else:
ft_choices = list(range(max_transfers + 1))
ft_choices = list(range(max_opt_transfers + 1))

if max_total_hit is not None:
ft_choices = [
Expand Down Expand Up @@ -515,22 +531,26 @@ def next_week_transfers(
new_points_hits = [
hit_so_far + calc_points_hit(nt, ft_available) for nt in new_transfers
]
new_ft_available = [calc_free_transfers(nt, ft_available) for nt in new_transfers]
new_ft_available = [
calc_free_transfers(nt, ft_available, max_free_transfers)
for nt in new_transfers
]

# return list of (num_transfers, free_transfers, hit_so_far) tuples for each new
# strategy
return list(zip(new_transfers, new_ft_available, new_points_hits))


def count_expected_outputs(
gw_ahead,
next_gw=NEXT_GAMEWEEK,
free_transfers=1,
max_total_hit=None,
allow_unused_transfers=True,
max_transfers=2,
chip_gw_dict={},
):
gw_ahead: int,
next_gw: int = NEXT_GAMEWEEK,
free_transfers: int = 1,
max_total_hit: Optional[int] = None,
allow_unused_transfers: bool = True,
max_opt_transfers: int = 2,
chip_gw_dict: dict = {},
max_free_transfers: int = MAX_FREE_TRANSFERS,
) -> tuple[int, bool]:
"""
Count the number of possible transfer and chip strategies for gw_ahead gameweeks
ahead, subject to:
Expand All @@ -540,8 +560,15 @@ def count_expected_outputs(
* Allow playing the chips which have their allow_xxx argument set True
* Exclude strategies that waste free transfers (make 0 transfers if 2 free tramsfers
are available), if allow_unused_transfers is False.
* Make a maximum of max_transfers transfers each gameweek.
* Make a maximum of max_opt_transfers transfers each gameweek.
* Each chip only allowed once.

Returns
-------
Tuple of int: number of strategies that will be computed, and bool: whether the
baseline strategy will be excluded from the main optimization tree and will need
to be computed separately (this can be the case if allow_unused_transfers is
False). Either way, the total count of strategies will include the baseline.
"""

init_strat_dict = {
Expand All @@ -559,9 +586,10 @@ def count_expected_outputs(
possibilities = next_week_transfers(
s,
max_total_hit=max_total_hit,
max_transfers=max_transfers,
max_opt_transfers=max_opt_transfers,
allow_unused_transfers=allow_unused_transfers,
chips=chips_for_gw,
max_free_transfers=max_free_transfers,
)

for n_transfers, new_free_transfers, new_hit in possibilities:
Expand Down Expand Up @@ -593,18 +621,20 @@ def count_expected_outputs(

strategies = new_strategies

# if allow_unused_transfers is False baseline of no transfers will be removed above,
# add it back in here, apart from edge cases where it's already included.
if not allow_unused_transfers and (
gw_ahead > 1 or (gw_ahead == 1 and init_free_transfers == 2)
):
baseline_strat_dict = {
"players_in": {gw: [] for gw in range(next_gw, next_gw + gw_ahead)},
"chips_played": {},
}
baseline_dict = (2, 0, baseline_strat_dict)
# if allow_unused_transfers is False baseline of no transfers can be removed above.
# Check whether 1st strategy is the baseline and if not add it back in here
baseline_strat_dict = {
"players_in": {gw: [] for gw in range(next_gw, next_gw + gw_ahead)},
"chips_played": {},
}
if strategies[0][2] != baseline_strat_dict:
baseline_dict = (max_free_transfers, 0, baseline_strat_dict)
strategies.insert(0, baseline_dict)
return len(strategies)
baseline_excluded = True
else:
baseline_excluded = False

return len(strategies), baseline_excluded


def get_discount_factor(next_gw, pred_gw, discount_type="exp", discount=14 / 15):
Expand Down
9 changes: 6 additions & 3 deletions airsenal/scripts/airsenal_run_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,11 @@
)
@click.option(
"--max_transfers",
help="specify maximum number of transfers to be made each gameweek (defaults to 2)",
type=click.IntRange(min=0, max=2),
help=(
"specify maximum number of transfers to consider each gameweek [EXPERIMENTAL: "
"increasing this value above 2 may make the optimisation very slow!]"
),
type=click.IntRange(min=0, max=5),
default=2,
)
@click.option(
Expand Down Expand Up @@ -361,7 +364,7 @@ def run_optimize_squad(
fpl_team_id=fpl_team_id,
num_thread=num_thread,
chip_gameweeks=chips_played,
max_transfers=max_transfers,
max_opt_transfers=max_transfers,
max_total_hit=max_hit,
allow_unused_transfers=allow_unused,
)
Expand Down
38 changes: 24 additions & 14 deletions airsenal/scripts/fill_transfersuggestion_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
)
from airsenal.framework.optimization_transfers import make_best_transfers
from airsenal.framework.optimization_utils import (
calc_free_transfers,
MAX_FREE_TRANSFERS,
calc_points_hit,
check_tag_valid,
count_expected_outputs,
Expand Down Expand Up @@ -95,6 +95,7 @@ def optimize(
updater: Optional[Callable] = None,
resetter: Optional[Callable] = None,
profile: bool = False,
max_free_transfers: int = MAX_FREE_TRANSFERS,
) -> None:
"""
Queue is the multiprocessing queue,
Expand Down Expand Up @@ -213,7 +214,6 @@ def optimize(
strat_dict["discount_factor"][gw] = discount_factor
strat_dict["players_in"][gw] = transfers["in"]
strat_dict["players_out"][gw] = transfers["out"]
free_transfers = calc_free_transfers(num_transfers, free_transfers)

depth += 1

Expand All @@ -235,8 +235,9 @@ def optimize(
(free_transfers, hit_so_far, strat_dict),
max_total_hit=max_total_hit,
allow_unused_transfers=allow_unused_transfers,
max_transfers=max_transfers,
max_opt_transfers=max_transfers,
chips=chips_gw_dict[gw + 1],
max_free_transfers=max_free_transfers,
)

for strat in strategies:
Expand Down Expand Up @@ -407,11 +408,12 @@ def run_optimization(
num_free_transfers: Optional[int] = None,
max_total_hit: Optional[int] = None,
allow_unused_transfers: bool = False,
max_transfers: int = 2,
max_opt_transfers: int = 2,
num_iterations: int = 100,
num_thread: int = 4,
profile: bool = False,
is_replay: bool = False, # for replaying seasons
max_free_transfers: int = MAX_FREE_TRANSFERS,
):
"""
This is the actual main function that sets up the multiprocessing
Expand Down Expand Up @@ -467,7 +469,7 @@ def run_optimization(
# if we got to here, we can assume we are optimizing an existing squad.

# How many free transfers are we starting with?
if not num_free_transfers:
if num_free_transfers is None:
num_free_transfers = get_free_transfers(
fpl_team_id,
gameweeks[0],
Expand Down Expand Up @@ -505,14 +507,15 @@ def run_optimization(
# number of nodes in tree will be something like 3^num_weeks unless we allow
# a "chip" such as wildcard or free hit, in which case it gets complicated
num_weeks = len(gameweeks)
num_expected_outputs = count_expected_outputs(
num_expected_outputs, baseline_excluded = count_expected_outputs(
num_weeks,
next_gw=gameweeks[0],
free_transfers=num_free_transfers,
max_total_hit=max_total_hit,
allow_unused_transfers=allow_unused_transfers,
max_transfers=max_transfers,
max_opt_transfers=max_opt_transfers,
chip_gw_dict=chip_gw_dict,
max_free_transfers=max_free_transfers,
)
total_progress = tqdm(total=num_expected_outputs, desc="Total progress")

Expand All @@ -539,9 +542,7 @@ def update_progress(increment=1, index=None):
progress_bars[index].update(increment)
progress_bars[index].refresh()

if not allow_unused_transfers and (
num_weeks > 1 or (num_weeks == 1 and num_free_transfers == 2)
):
if baseline_excluded:
# if we are excluding unused transfers the tree may not include the baseline
# strategy. In those cases quickly calculate and save it here first.
save_baseline_score(starting_squad, gameweeks, tag)
Expand All @@ -568,7 +569,7 @@ def update_progress(increment=1, index=None):
chip_gw_dict,
max_total_hit,
allow_unused_transfers,
max_transfers,
max_opt_transfers,
num_iterations,
update_progress,
reset_progress,
Expand Down Expand Up @@ -697,8 +698,8 @@ def sanity_check_args(args: argparse.Namespace) -> bool:
args.gameweek_end and not args.gameweek_start
):
raise RuntimeError("Need to specify both gameweek_start and gameweek_end")
if args.num_free_transfers and args.num_free_transfers not in range(1, 3):
raise RuntimeError("Number of free transfers must be 1 or 2")
if args.num_free_transfers and args.num_free_transfers not in range(6):
raise RuntimeError("Number of free transfers must be 0 to 5")
return True


Expand Down Expand Up @@ -751,6 +752,15 @@ def main():
help="if set, include strategies that waste free transfers",
action="store_true",
)
parser.add_argument(
"--max_transfers",
help=(
"maximum number of transfers to consider each gameweek [EXPERIMENTAL: "
"increasing this value above 2 make the optimisation very slow!]"
),
type=int,
default=2,
)
parser.add_argument(
"--num_iterations",
help="how many iterations to use for Wildcard/Free Hit optimization",
Expand Down Expand Up @@ -835,7 +845,7 @@ def main():
num_free_transfers,
max_total_hit,
allow_unused_transfers,
2,
args.max_transfers,
num_iterations,
num_thread,
profile,
Expand Down
Loading
Loading