Skip to content

Commit

Permalink
Merge pull request #680 from alan-turing-institute/679-transfers-changes
Browse files Browse the repository at this point in the history
Update max free transfers to 5
  • Loading branch information
nbarlowATI authored Sep 18, 2024
2 parents 857525c + baa1039 commit 35c136a
Show file tree
Hide file tree
Showing 6 changed files with 378 additions and 127 deletions.
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

0 comments on commit 35c136a

Please sign in to comment.