ML · Data

A fantasy cricket team optimiser is a constraint solver

Fantasy cricket looks like a prediction problem. Who will score the most points this match? But when I sat down to build a team builder for Dream11-style contests, I realised that most of the work is not prediction at all. It is constraint satisfaction. You have a pool of players, a set of lineup rules, a budget, and a scoring input. The hard part is enumerating every lineup that satisfies the rules and then picking one. That is a solver, not a model.

This post is about dream11-cricket-team-builder, a small Python tool I wrote to do exactly that, and why I think framing it as a constraint problem is the right call even if you later bolt a proper prediction model on top.

What I built

The tool reads four JSON files and produces a valid eleven:

From those it builds a candidate pool, enumerates every lineup that satisfies the role rules, validates each one, and then selects a team either by total points or at random from the valid set. Random selection sounds odd, but it is useful when you are entering several teams into a large contest and want diversity rather than eleven copies of the same "best" lineup.

How it works

Shrink the pool first

Two squads of fifteen is thirty players. Choosing eleven from thirty is about 54 million combinations before any rules. That is too many to enumerate naively in Python, and most of those combinations include players nobody would pick. So the first step is to cut the pool to the top 18 players by pick frequency, the share of contest entrants who selected each player. Eighteen choose eleven is 31,824 lineups, which is trivial to enumerate and validate in memory.

Pick frequency is a crowd signal rather than a prediction, and that is fine for pruning. The players the crowd ignores are almost never the ones a points-based selector would choose anyway. The point is to make the search space small enough that the rest of the pipeline can be dumb and exhaustive.

Encode the rules

Fantasy cricket lineups have role composition rules. A typical format is a count per role across wicket-keeper, batter, all-rounder and bowler, with patterns such as 3-3-3-2 or 4-4-2-1 being common valid shapes. There is also usually a cap on how many players may come from one side. I keep the allowed patterns as data rather than code so that a different contest format is a JSON edit, not a rewrite.

contest.json
{
  "team_size": 11,
  "max_from_one_side": 7,
  "roles": ["WK", "BAT", "AR", "BOWL"],
  "allowed_patterns": [
    [1, 3, 3, 4],
    [1, 4, 2, 4],
    [1, 4, 3, 3],
    [2, 3, 3, 3]
  ],
  "pool_size": 18
}

The exact patterns above are illustrative. Whatever the contest allows goes in that list, and the validator only checks membership.

Enumerate and validate

With a pool of 18 and the rules as data, enumeration is a single itertools.combinations call followed by a filter. I count roles and sides for each candidate and keep the ones whose role vector appears in allowed_patterns and whose side split is within the cap.

builder.py
from itertools import combinations
from collections import Counter

def valid_lineups(pool, contest):
    roles = contest["roles"]
    allowed = {tuple(p) for p in contest["allowed_patterns"]}
    cap = contest["max_from_one_side"]

    for team in combinations(pool, contest["team_size"]):
        by_role = Counter(p["role"] for p in team)
        by_side = Counter(p["side"] for p in team)
        shape = tuple(by_role[r] for r in roles)
        if shape in allowed and max(by_side.values()) <= cap:
            yield team

Because the generator yields lazily, I can stop early if I only need one random team, or collect everything if I want to rank. On a pool of 18 the full enumeration finishes well under a second, so I never bothered with anything cleverer than brute force.

Choose a team

Selection is the only place where "points" enter. The tool supports two modes:

select.py
import random

def choose(lineups, points, mode="points"):
    lineups = list(lineups)
    if mode == "random":
        return random.choice(lineups)
    # highest total points; captain/vice-captain multipliers applied here
    def score(team):
        s = sorted((points[p["id"]] for p in team), reverse=True)
        return s[0] * 2 + s[1] * 1.5 + sum(s[2:])
    return max(lineups, key=score)

Points mode picks the lineup with the highest total, with the top two scorers treated as captain and vice-captain. Random mode draws uniformly from the valid set. Both produce lineups that are guaranteed to pass the contest's rules, which is the property I actually cared about. Whether the points input is any good is a separate question, and deliberately outside the tool's scope.

Why "constraint solver" is the right frame

Separating the feasible set from the objective does three things for you. First, validation is exhaustive and provable: if a lineup comes out, it is legal. Second, the objective is swappable. Today it is a points file; tomorrow it could be the output of a real model, or a blend with ownership data to find low-owned upside. Third, you get diversity for free. If you want five different teams for a big contest, sample five from the feasible set instead of perturbing one "optimal" answer and hoping it stays legal.

If the pool were larger, or there were a salary budget to respect, I would move from brute-force enumeration to integer programming with a library like PuLP or OR-Tools. The formulation is the same: binary variable per player, linear constraints for roles, sides and budget, linear objective for points. The brute-force version here is just the smallest thing that works for an 18-player pool.

What I learned

Source on GitHub >