6
\$\begingroup\$

My program applies stacking penalty rule in Daily Fantasy Football by choosing which players to be penalized by which amount of points.

The rule is about penalizing teams with two or more defensive players when the team conceded no goals.

Here is the description of the rule:

For the 2019/20 football season, there is a new, important rule in place for daily fantasy tournaments: If you pick more than one defensive player (included keeper) from a club, the points awarded for clean sheet will decrease by 1 point for each additional defensive player from the same club. The stacking penalty has a maximum of minus 3 points.

1st defensive player: 0 points
2nd defensive player: -1 points
3rd defensive player: -2 points
4th-6th defensive player: -3 points

It is actually quite easy; if you have three defensive players from the same club, one will have no stacking penalty (he gets 4 points for clean sheet), one will get minus 1 (so he gets 3 points for a clean sheet), and one will get minus 2 (he gets 2 points for a clean sheet).

How to choose players to be penalized:

We rank the players that are on your team. When ranking defensive players from the same club, the priority is: Captain > Vice Captain > Price > Last name. For price, the higher price will rank highest. For last name, early position in the alphabet will rank highest. So, if you have a defensive player as captain he will never get a stacking penalty. If you don’t have a defensive player from this club as captain, but instead as vice captain, he will not get a stacking penalty. If there is no captain or vice captain in defense, the highest priced player will be 1st defensive player, and won’t get a stacking penalty.

Please note, that I didn't handle the case of players having the same pursuit points and tie-breaker being last name since it doesn't come up in practice.

I would like to get feedback on my way of decomposing this problem and how to reduce highly repetitive functions that work with particular amount of defensive players to more general one.

Here is the code:

"""
Applying stacking penalty rule to a team in Fantasy Football.
"""
from dataclasses import dataclass
from typing import List, Tuple

@dataclass
class Player:
    """
    Represents a player in Fantasy Football with his id, name, club,
    playing position, pursuit points and whether he is the captain
    or vice-captain.
    """
    p_id: int
    name: str
    club: str
    position: str
    pursuit: float
    is_captain: bool = False
    is_vice_captain: bool = False

PlayerId = int
PenalizedPlayer = Tuple[PlayerId, int]
PenalizedPlayers = List[PenalizedPlayer]
Team = List[Player]

players = [
    Player(88472, "Leno", "ARS", "goalkeeper", -0.88),
    Player(160699, "Mari", "ARS", "defender", -0.52),
    Player(139989, "Tierney", "ARS", "defender", -0.72),
    Player(24075, "Holding", "ARS", "defender", -0.60),
    Player(23332, "Bellerin", "ARS", "defender", -0.76),
    Player(197966, "Smith Rowe", "ARS", "midfielder", -1.24),
    Player(137793, "Ceballos", "ARS", "midfielder", -1.08),
    Player(106430, "Saka", "ARS", "midfielder", -1.72),
    Player(23337, "Xhaka", "ARS", "midfielder", -0.84),
    Player(77663, "Aubameyang", "ARS", "forward", -1.88),
    Player(45079, "Lacazette", "ARS", "forward", -2.00),
    ]

team1 = [
    Player(88472, "Leno", "ARS", "goalkeeper", -0.88, is_captain=True),
    Player(139989, "Tierney", "ARS", "defender", -0.72),
    Player(24075, "Holding", "ARS", "defender", -0.60),
    Player(77663, "Aubameyang", "ARS", "forward", -1.88),
    Player(45079, "Lacazette", "ARS", "forward", -2.00, is_vice_captain=True)
    ]

team2 = [
    Player(88472, "Leno", "ARS", "goalkeeper", -0.88),
    Player(139989, "Tierney", "ARS", "defender", -0.72),
    Player(24075, "Holding", "ARS", "defender", -0.60, is_captain=True),
    Player(77663, "Aubameyang", "ARS", "forward", -1.88),
    Player(45079, "Lacazette", "ARS", "forward", -2.00, is_vice_captain=True)
    ]

team3 = [
    Player(88472, "Leno", "ARS", "goalkeeper", -0.88),
    Player(139989, "Tierney", "ARS", "defender", -0.72, is_vice_captain=True),
    Player(24075, "Holding", "ARS", "defender", -0.60, is_captain=True),
    Player(77663, "Aubameyang", "ARS", "forward", -1.88),
    Player(45079, "Lacazette", "ARS", "forward", -2.00)
    ]

team4 = [
    Player(88472, "Leno", "ARS", "goalkeeper", -0.88),
    Player(139989, "Tierney", "ARS", "defender", -0.72, is_captain=True),
    Player(24075, "Holding", "ARS", "defender", -0.60),
    Player(160699, "Mari", "ARS", "defender", -0.52),
    Player(106430, "Saka", "ARS", "midfielder", -1.72, is_vice_captain=True)
    ]

team5 = [
    Player(88472, "Leno", "ARS", "goalkeeper", -0.88, is_captain=True),
    Player(160699, "Mari", "ARS", "defender", -0.52, is_vice_captain=True),
    Player(139989, "Tierney", "ARS", "defender", -0.72),
    Player(24075, "Holding", "ARS", "defender", -0.60),
    Player(23332, "Bellerin", "ARS", "defender", -0.76)
    ]

team6 = [
    Player(139989, "Tierney", "ARS", "defender", -0.72),
    Player(137793, "Ceballos", "ARS", "midfielder", -1.08),
    Player(106430, "Saka", "ARS", "midfielder", -1.72),
    Player(23337, "Xhaka", "ARS", "midfielder", -0.84),
    Player(77663, "Aubameyang", "ARS", "forward", -1.88, is_captain=True),
    Player(45079, "Lacazette", "ARS", "forward", -2.00, is_vice_captain=True)
    ]

team7 = [
    Player(24075, "Holding", "ARS", "defender", -0.60),
    Player(23332, "Bellerin", "ARS", "defender", -0.76),    
    Player(197966, "Smith Rowe", "ARS", "midfielder", -1.24, is_vice_captain=True),
    Player(137793, "Ceballos", "ARS", "midfielder", -1.08, is_captain=True),
    Player(106430, "Saka", "ARS", "midfielder", -1.72),    
    ]

team8 = [
    Player(24075, "Holding", "ARS", "defender", -0.60, is_vice_captain=True),
    Player(23332, "Bellerin", "ARS", "defender", -0.76),    
    Player(197966, "Smith Rowe", "ARS", "midfielder", -1.24),
    Player(137793, "Ceballos", "ARS", "midfielder", -1.08),
    Player(106430, "Saka", "ARS", "midfielder", -1.72, is_captain=True),    
    ]

def choose_players_to_penalize(team: Team) -> PenalizedPlayers:
    """
    Returns list of tuples with players ids to be penalised and amount of
    points to be taken away from each of them.

    Raises ValueError if defensive players count is greater than 5.
    """
    defensive_players = get_defensive_players(team)
    if len(defensive_players) < 2:
        return []
    if len(defensive_players) == 2:
        return penalize_two_players(defensive_players)
    if len(defensive_players) == 3:
        return penalize_three_players(defensive_players)
    if len(defensive_players) == 4:
        return penalize_four_players(defensive_players)
    if len(defensive_players) == 5:
        return penalize_five_players(defensive_players)    
    raise ValueError("There can't be more than 5 defensive players")

def penalize_two_players(players: Team) -> PenalizedPlayers:
    """
    Chooses players ids to be penalised and amount of points to be taken away
    from a team with two defensive players.
    """
    sorted_players = sort_player_ids_for_penalty(players)
    return [(sorted_players[1], -1)]

def penalize_three_players(players: Team) -> PenalizedPlayers:
    """
    Chooses players ids to be penalised and amount of points to be taken away
    from a team with three defensive players.
    """
    sorted_players = sort_player_ids_for_penalty(players)
    return [(sorted_players[-1], -2),
            (sorted_players[-2], -1)]

def penalize_four_players(players: Team) -> PenalizedPlayers:
    """
    Chooses players ids to be penalised and amount of points to be taken away
    for a team with four defensive players.
    """
    sorted_players = sort_player_ids_for_penalty(players)
    return [(sorted_players[-1], -3),
            (sorted_players[-2], -2),
            (sorted_players[-3], -1)]

def penalize_five_players(players: Team) -> PenalizedPlayers:
    """
    Chooses players ids to be penalised and amount of points to be taken away
    for a team with five defensive players.
    """
    sorted_players = sort_player_ids_for_penalty(players)
    return [(sorted_players[-1], -3),
            (sorted_players[-2], -3),
            (sorted_players[-3], -2),
            (sorted_players[-4], -1)]

def sort_player_ids_for_penalty(players: Team) -> List[PlayerId]:
    """
    Returns list of players ids sorted in reverse order by the following criterias:       
    - is_captain
    - is_vice_captain
    - more expensive players by pursuit points   
    (more "expensive" means smaller value, for example, a player with
     pursuit points -0.72 is more expensive than a player with pursuit
     points +1.24, so that when sorting pursuits are taken multiplied by -1)
    """
    return [p.p_id for p in sorted(players, key=lambda x:
                                   (x.is_captain, x.is_vice_captain,
                                    -x.pursuit), reverse=True)]

def get_defensive_players(players: Team) -> Team:
    """
    Returns each player in the team whose position is either defender
    or goalkeeper.
    """
    return [p for p in players if p.position in ['goalkeeper', 'defender']]

def tests():
    """
    Tests for choose_players_to_penalize()
    """
    assert sorted(choose_players_to_penalize(team1)) == sorted([(24075, -2),
                                                                (139989, -1)])
    assert sorted(choose_players_to_penalize(team2)) == sorted([(139989, -2),
                                                                (88472, -1)])
    assert sorted(choose_players_to_penalize(team3)) == sorted([(88472, -2),
                                                                (139989, -1)])
    assert sorted(choose_players_to_penalize(team4)) == sorted([(160699, -3),
                                                                (24075, -2),
                                                                (88472, -1)])
    assert sorted(choose_players_to_penalize(team5)) == sorted([(24075, -3),
                                                                (139989, -3),
                                                                (23332, -2),
                                                                (160699, -1)])
    assert choose_players_to_penalize(team6) == []
    assert choose_players_to_penalize(team7) == [(24075, -1)]
    assert choose_players_to_penalize(team8) == [(23332, -1)]
    
    print("Tests pass.")
    
if __name__ == '__main__':
    tests()    
\$\endgroup\$
4
  • \$\begingroup\$ What if a fantasy team has 2 defensive players from team A and 2 defensive players from team B? \$\endgroup\$
    – RootTwo
    Jan 20, 2021 at 21:21
  • \$\begingroup\$ Each team is penalized separately: a player from team A will get -1 as well as player from team B. \$\endgroup\$ Jan 20, 2021 at 21:22
  • \$\begingroup\$ That's what I thought, but I don't think your code handles that case, nor are there tests for it. Aghast's answer should. \$\endgroup\$
    – RootTwo
    Jan 20, 2021 at 21:32
  • \$\begingroup\$ I posted this to handle the case with players from one team and then made a version handling players from arbitrary amount of teams. I'm afraid it is not appropriate to change the code. \$\endgroup\$ Jan 20, 2021 at 21:37

3 Answers 3

3
+50
\$\begingroup\$

my_first_c_program's answer is good and directly addresses your question. I'm just adding minor points and details about the rest of your implementation. (like doing the daily crossword ☺)

  • Rather than a lambda, make the ordering key a method of the Player class. I don't suggest making it the innate ordering of Player though.
  • It's not clear why you skipped the last-name tie-breaker, except possibly for the fact that parsing names can be tricky in the real world.
  • In general, dataclasses should be frozen (immutable) unless you need mutability; it's just a good habit that will help you catch mistakes. (I guess it's only fair to warn that if you change it later to make it mutable, you'll have problems with equality/hashes/sets.)
  • It seems like the captain, vice-captain, and number of players are strict requirements for a Team. Probably good to make Team another data-class, with "who's the captain" stored there instead of in each Player.
    • Of course this will mean that Players can't generate their own ordering anymore; that's ok it can be a Team method.
    • Actually just leave the ordering as a stand-alone method, but enforce the "captains first" part structurally instead of as part of the ordering-key.
  • Depending on your python-version Player.position could be a Literal[...] (like an enum).
  • Make the defensiveness test a Player method/property.
  • Inline sanity checks can use assert, just be aware that asserts can be bypassed.
  • Get your test data out of the top-level namespace. You may also want to restructure the tests somewhat:
    • You're not using the variable players, but in fact you could and it would save you some verbosity if it were a dict of some kind.
    • Just use a loop over team-result pairs for the assertions.
    • Catch-and-rethrow to give the context for a failed test.
  • The logic for the possible penalty values is pretty trivial, but it's also suspiciously arbitrary; for this reason I'd suggest making it its own function.
  • It seems weird for the function to return player identifiers instead of just players. On the other hand, it's not perfectly clear to me what identifier means what; if p_id is really the ID, then Player equality should be based on just that. I don't have enough context to say exactly how that should work...
  • On the other hand, where are these penalties going? Are they properties of the respective players? Of the team? Should we bake them into the type system? That could get tricky, but fun...
  • There's always room for better names.
"""
Applying stacking penalty rule to a team in Fantasy Football.
"""
from dataclasses import asdict, dataclass
from typing import Iterable, Literal, Mapping, Tuple
from itertools import chain

PlayerId = int
# Literal is new in 3.8:
Position = Literal["goalkeeper", "defender", "midfielder", "forward"]

@dataclass(frozen=True)
class Player:
    p_id: PlayerId
    name: str
    club: str
    position: Position
    pursuit: float

    def is_defensive(self):
        return self.position in ['goalkeeper', 'defender'] 

@dataclass(frozen=True)
class PenalizedPlayer(Player):
    penalty: int

def defence_stacking_penalty(index: int) -> int:
    return min(0, max(-3, -index))

def stacking_penalty_order(
    captain: Player, vice: Player, *others: Player
) -> Iterable[Player]:
    yield captain
    yield vice
    def key(player):
        last_name = player.name.split(maxsplit=1)[-1]
        return (-player.pursuit, last_name)
    yield from sorted(others, key=key, reverse=True)

def assign_defence_stacking_penalties(
    captain: Player, vice: Player, *others: Player
)-> Mapping[Player, PenalizedPlayer]:
    sorted_defence = [player
                      for player
                      in stacking_penalty_order(captain, vice, *others)
                      if player.is_defensive()]
    assert len(sorted_defence) <= 5, "There can't be more than 5 defensive players."
    non_defence_pairs = ((0, player)
                         for player
                         in (captain, vice, *others)
                         if not player.is_defensive())
    return {
        player: PenalizedPlayer(penalty=defence_stacking_penalty(i),
                                **asdict(player))
        for (i, player)  # This isn't great, in that i could be the index from enumerate or the literal 0 from non_defence_pairs.
        in chain(enumerate(sorted_defence), non_defence_pairs)
    }

@dataclass(frozen=True)
class Team:
    captain: PenalizedPlayer
    vice_captain: PenalizedPlayer
    others: Tuple[PenalizedPlayer, ...]
    
    def __iter__(self):
        yield self.captain
        yield self.vice_captain
        yield from self.others

    def __post_init__(self):  # enforce uniqueness and sufficiency
        unique = len(set(self))
        assert len(tuple(self)) == unique
        assert 5 <= unique

    @staticmethod
    def make(*others: Player, captain: Player, vice: Player) -> "Team":
        """Wrap the init for convienence. Force kwargs for explicitness."""
        penalized = assign_defence_stacking_penalties(captain, vice, *others)
        return Team(captain=penalized[captain],
                    vice_captain=penalized[vice],
                    others=tuple(penalized[player] for player in others))

def tests():
    players = {
        player.p_id: player  # the below team declarations might read better if we used name as the key...
        for player in (
            Player(88472, "Leno", "ARS", "goalkeeper", -0.88),
            Player(160699, "Mari", "ARS", "defender", -0.52),
            Player(139989, "Tierney", "ARS", "defender", -0.72),
            Player(24075, "Holding", "ARS", "defender", -0.60),
            Player(23332, "Bellerin", "ARS", "defender", -0.76),
            Player(197966, "Smith Rowe", "ARS", "midfielder", -1.24),
            Player(137793, "Ceballos", "ARS", "midfielder", -1.08),
            Player(106430, "Saka", "ARS", "midfielder", -1.72),
            Player(23337, "Xhaka", "ARS", "midfielder", -0.84),
            Player(77663, "Aubameyang", "ARS", "forward", -1.88),
            Player(45079, "Lacazette", "ARS", "forward", -2.00),
        )
    }

    tests = [
        (Team.make(players[139989], players[24075], players[77663],
                   captain=players[88472], vice=players[45079]),
         [(24075, -2), (139989, -1)]),
        (Team.make(players[88472], players[139989], players[77663],
                   captain=players[24075], vice=players[45079]),
         [(139989, -2), (88472, -1)]),
        (Team.make(players[88472], players[77663], players[45079],
                      captain=players[24075], vice=players[139989]),
         [(88472, -2), (139989, -1)]),
        (Team.make(players[88472], players[24075], players[160699],
                      captain=players[139989], vice=players[106430]),
         [(160699, -3), (24075, -2), (88472, -1)]),
        (Team.make(players[139989], players[24075], players[23332],
                      captain=players[88472], vice=players[160699]),
         [(24075, -3), (139989, -3), (23332, -2), (160699, -1)]),
        (Team.make(players[139989], players[137793], players[106430], players[23337],
                      captain=players[77663], vice=players[45079]),
         []),
        (Team.make(players[24075], players[23332], players[106430], 
                      captain=players[137793], vice=players[197966]),
         [(24075, -1)]),
        (Team.make(players[23332], players[197966], players[137793], 
                      captain=players[106430], vice=players[24075]),
         [(23332, -1)]),
    ]

    for (team, known_result) in tests:
        try:
            result = sorted((player.p_id, player.penalty)
                            for player in team
                            if player.penalty)
            assert result == sorted(known_result)
        except:
            print(team)
            print(known_result)
            print(result)  # concievable that some errors might cause this to presist from a prior loop iteration
            raise
    
    print("Tests pass.")
    
if __name__ == '__main__':
    tests()
\$\endgroup\$
4
\$\begingroup\$

I will start by saying that I have no understanding of Daily Fantasy Football. Forgive me if I miss some important point.

If I understand your rules quote correctly, individual players are subjected to the "stacking penalties". So this doesn't just reduce some arbitrary total score, but affects how each player is scored individually.

Thus, you need to write a sorting function that matches the ordering they describe, so that you will identify the same players in the same relative order for the penalties.

Also, the question of which defenders to penalize also requires attention to the club the defenders play for. If you have 4 defensive players, from clubs A, B, C, and D, then none of the players will be penalized. If you have 4 defensive players all from club ARS, then the first one gets no penalty, the second one gets 4-1=3 points, the third one gets 4-2=2 points, and the last one gets 4-3=1 point. Finally, if you have 4 defensive players, 2 from club A and 2 from club B, then the second player from each club will get 4-1=3 points.

As I see it, you can solve this problem by sorting the players on the club as a first key. Then within the club, sort using the criteria described in the rule citation.

Once you have the players in this order, use the itertools.groupby function to bundle players with the same club together, and process the bundles.

I think this function works as your key:

def defensive_player_key(p):
    """ Group players by club. Within the same club, 
        order according to "stacking penalty" rule. 
    """
    return (p.club, 
            not p.is_captain, # NOTE: not() to reverse sort order
            not p.is_vice_captain, # NOTE: not() to reverse sort order
           -p.pursuit, # NOTE: negative to reverse sort order
           p.name)

Assuming that your "pursuit points" is the same as "price" in the rule citation. So you can write something like:

defenders = sorted([p for p in team where p.position in ("defender", "goalkeeper")], key=defensive_player_key)

for club, mates in itertools.groupby(defenders, key=lambda p: p.club):
    for player, order in enumerate(mates):
        penalty = min(order, 3) # 0, 1, 2, 3, 3, 3

        # TODO: do something with player, penalty

I understand this happens in a "clean sheet" scenario, but have no idea what that is. Presumably you would award 4 - penalty points to each of the defenders, somehow?

\$\endgroup\$
3
  • \$\begingroup\$ Apparently you get the rules right. Clean sheet means that a team conceded no goals. Assume that the function is called only in this scenario. Does your solution pass my tests? \$\endgroup\$ Jan 20, 2021 at 15:38
  • 1
    \$\begingroup\$ False sorts before True, so I think you need to change the sort to use not p.is_captain and not p.is_vice_captain. Otherwise, they will come out last when you enumerate(mates) . \$\endgroup\$
    – RootTwo
    Jan 20, 2021 at 21:18
  • \$\begingroup\$ @RootTwo updated. \$\endgroup\$
    – aghast
    Jan 21, 2021 at 1:07
2
\$\begingroup\$

You’re right that you can shorten the penalization functions into a single general function. You can do it like this:

def choose_players_to_penalize(team: Team) -> PenalizedPlayers:
    """
    Returns list of tuples with players ids to be penalised and amount of
    points to be taken away from each of them.

    Raises ValueError if defensive players count is greater than 5.
    """
    defensive_players = get_defensive_players(team)
    return penalize_players(defensive_players)

def penalize_players(players: Team) -> PenalizedPlayers:
    """
    Chooses players ids to be penalised and amount of points to be taken away
    from a team with n defensive players.
    """
    sorted_players = sort_player_ids_for_penalty(players)
    list_len = len(sorted_players)
    if list_len > 5:
        raise ValueError("There can't be more than 5 defensive players")
    return [
        (sorted_players[-n], max(-3, -list_len + n))
        for n in range(1, list_len)
        ]

The choose_players_to_penalize function might not be needed.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.