Skip to content

pippen.data.possession_coefficient

Measures what fraction of free throw attempts actually consume a possession, per season, instead of assuming the conventional 0.44.

Measured across all 25 seasons hoopR publishes, every one sits below 0.44, and the spread across a quarter century is smaller than the average distance from the convention. See the errata for the full table and for why the exclusion rule decides the answer.

pippen.data.possession_coefficient

Measured fraction of free throw attempts that end a possession.

Basketball analytics estimates possessions as FGA + 0.44 * FTA + TOV. The 0.44 is not an arbitrary tuning constant. It estimates the fraction of free throw attempts that consume a possession, because not every free throw ends one:

  • On a two-shot common foul, only the second shot ends the trip.
  • On an and-one, the field goal already ended the possession; the free throw adds nothing further to count.
  • On a three-shot foul, only the third shot ends the trip.
  • Technical free throws are awarded on a dead ball with no possession change at all; the team that had the ball keeps it.
  • Flagrant and clear-path fouls award free throws and let the fouled team keep the ball afterwards (a throw-in, not a change of possession), win or miss the last shot.
  • A missed final free throw followed by an offensive rebound does not end the possession either; the shooting team just keeps playing.

docs/methodology/errata.md (section 3) records that this project's own research documents mischaracterised 0.44 as a PER-specific flaw, when the same constant serves the same purpose in Pace, turnover rate, true shooting and usage rate. The legitimate version of that complaint is that the true fraction is empirical and can drift with rule changes. For the 2023-24 season alone the NBA introduced "transition take foul" and "away from play foul", single-shot fouls that let the offense keep the ball. This module measures the coefficient per season from play-by-play instead of assuming a fixed value.

How a free throw is identified hoopR's type_text column carries values like "Free Throw - 1 of 2", "Free Throw - Technical", "Free Throw - Flagrant 2 of 3" and "Free Throw - Clear Path 1 of 2". Verified against the real 2023-24 play-by-play file (614,447 rows): every free throw row's type_text starts with "Free Throw", and every non-technical one ends in a "<k> of <n>" pattern giving its position in the trip and the trip size. Only the last shot of a trip (k == n) can possibly end a possession; every earlier shot in a multi-shot trip is attempted with the trip guaranteed to continue regardless of make or miss.

What this module cannot determine A missed final free throw is followed, almost always, directly by a "Defensive Rebound" or "Offensive Rebound" event, and hoopR's own rebound labels are already possession-relative (an "offensive" rebound is taken by the team that just shot, whichever team that is), so the labels alone settle the question without needing to compare team identifiers. A substitution is sometimes logged between the miss and the rebound, so the lookahead skips up to :data:_MAX_LOOKAHEAD consecutive substitution rows before giving up. Verified on the real 2023-24 file: this resolves all but 5 of 57,076 free throw attempts (0.009%). Each of those five is a missed final free throw followed immediately by another shot with no rebound row logged between them. The shooting team plainly kept the ball, but no rebound label says so, and inferring one from the shooter's team would be a guess rather than a reading. Rather than guess, those attempts are excluded from both the numerator and the denominator and reported separately as :attr:SeasonPossessionCoefficient.excluded_unresolved, so a wrong assumption never gets dressed up as a measurement.

Why this is vectorised rather than a per-row loop A single season's play-by-play is hundreds of thousands of rows (614,447 for 2023-24 alone). Everything below operates on whole columns with pandas string, comparison and groupby().shift() operations, which run as compiled loops inside pandas/NumPy. A Python for loop over rows would still finish, but 25 seasons of it is the difference between a command that returns and one nobody waits for.

CONVENTIONAL_COEFFICIENT module-attribute

CONVENTIONAL_COEFFICIENT = 0.44

The value basketball analytics conventionally assumes.

Kept as a comparison baseline rather than as a default. Every season this project has measured sits below it, so it is the number to beat rather than the number to use.

SeasonPossessionCoefficient dataclass

The measured possession coefficient for one season.

Attributes:

Name Type Description
season int

Season end year, e.g. 2024 for the 2023-24 season.

coefficient float

Possession-ending trips divided by free throw attempts, restricted to attempts whose outcome could be determined. nan if no attempt in the season could be classified.

diff_from_conventional float

coefficient - CONVENTIONAL_COEFFICIENT. Positive means this season's free throws ended possessions more often than the conventional constant assumes.

possession_ending_trips int

Numerator, the count of free throw trips whose last shot ended the shooting team's possession.

free_throw_attempts int

Denominator, the count of free throw attempts whose outcome could be classified. Every attempt in a resolved trip counts, not just the last one, matching how FTA is counted in the conventional formula.

total_free_throw_attempts int

All free throw attempts seen, including the ones excluded as unresolved. Reported so a reader can see how much of the season's free throws the coefficient is actually built from.

excluded_unresolved int

Free throw attempts excluded from both the numerator and the denominator because a missed final free throw was not followed, within :data:_MAX_LOOKAHEAD events, by a clearly labelled rebound. See the module docstring.

Source code in src/pippen/data/possession_coefficient.py
@dataclass(frozen=True)
class SeasonPossessionCoefficient:
    """The measured possession coefficient for one season.

    Attributes:
        season: Season end year, e.g. 2024 for the 2023-24 season.
        coefficient: Possession-ending trips divided by free throw attempts,
            restricted to attempts whose outcome could be determined. ``nan``
            if no attempt in the season could be classified.
        diff_from_conventional: ``coefficient - CONVENTIONAL_COEFFICIENT``.
            Positive means this season's free throws ended possessions more
            often than the conventional constant assumes.
        possession_ending_trips: Numerator, the count of free throw trips
            whose last shot ended the shooting team's possession.
        free_throw_attempts: Denominator, the count of free throw attempts
            whose outcome could be classified. Every attempt in a resolved
            trip counts, not just the last one, matching how ``FTA`` is
            counted in the conventional formula.
        total_free_throw_attempts: All free throw attempts seen, including
            the ones excluded as unresolved. Reported so a reader can see how
            much of the season's free throws the coefficient is actually
            built from.
        excluded_unresolved: Free throw attempts excluded from both the
            numerator and the denominator because a missed final free throw
            was not followed, within :data:`_MAX_LOOKAHEAD` events, by a
            clearly labelled rebound. See the module docstring.
    """

    season: int
    coefficient: float
    diff_from_conventional: float
    possession_ending_trips: int
    free_throw_attempts: int
    total_free_throw_attempts: int
    excluded_unresolved: int

coefficient_for_season

coefficient_for_season(season)

Return the possession coefficient to use for one season.

This is the value the rest of the pipeline should use wherever the conventional 0.44 would otherwise appear. Prefer it to the constant: every season measured here sits below 0.44, so the convention is wrong in the same direction every time rather than being noisy around the truth.

Parameters:

Name Type Description Default
season int

Season end year, so 2024 means the 2023-24 season.

required

Returns:

Type Description
float

The measured coefficient for that season, or

float

data:DEFAULT_COEFFICIENT for a season with no measurement, which

float

is the value pooled across every season that does have one.

Source code in src/pippen/data/possession_coefficient.py
def coefficient_for_season(season: int) -> float:
    """Return the possession coefficient to use for one season.

    This is the value the rest of the pipeline should use wherever the
    conventional 0.44 would otherwise appear. Prefer it to the constant: every
    season measured here sits below 0.44, so the convention is wrong in the
    same direction every time rather than being noisy around the truth.

    Args:
        season: Season end year, so 2024 means the 2023-24 season.

    Returns:
        The measured coefficient for that season, or
        :data:`DEFAULT_COEFFICIENT` for a season with no measurement, which
        is the value pooled across every season that does have one.
    """
    return MEASURED_COEFFICIENTS.get(season, DEFAULT_COEFFICIENT)

estimate_season_coefficient

estimate_season_coefficient(play_by_play, season)

Estimate the possession coefficient for one season.

Parameters:

Name Type Description Default
play_by_play DataFrame

One season's play-by-play rows. Must carry game_id, game_play_number, type_text and scoring_play (all present in hoopR's published play-by-play). Extra columns are ignored.

required
season int

Season end year, e.g. 2024 for the 2023-24 season. Used only to label the result; not cross-checked against the frame's own season column, since that check belongs to :mod:pippen.data.validate, not here.

required

Returns:

Type Description
SeasonPossessionCoefficient

The measured coefficient and the counts it was computed from. If the

SeasonPossessionCoefficient

season has no classifiable free throw attempts at all, coefficient

SeasonPossessionCoefficient

and diff_from_conventional are nan rather than raising, since

SeasonPossessionCoefficient

an empty season is a legitimate (if useless) input.

Source code in src/pippen/data/possession_coefficient.py
def estimate_season_coefficient(
    play_by_play: pd.DataFrame, season: int
) -> SeasonPossessionCoefficient:
    """Estimate the possession coefficient for one season.

    Args:
        play_by_play: One season's play-by-play rows. Must carry ``game_id``,
            ``game_play_number``, ``type_text`` and ``scoring_play`` (all
            present in hoopR's published play-by-play). Extra columns are
            ignored.
        season: Season end year, e.g. 2024 for the 2023-24 season. Used only
            to label the result; not cross-checked against the frame's own
            ``season`` column, since that check belongs to
            :mod:`pippen.data.validate`, not here.

    Returns:
        The measured coefficient and the counts it was computed from. If the
        season has no classifiable free throw attempts at all, ``coefficient``
        and ``diff_from_conventional`` are ``nan`` rather than raising, since
        an empty season is a legitimate (if useless) input.
    """
    classified = _classify_free_throws(play_by_play)

    is_free_throw = classified["is_free_throw"]
    is_last_of_trip = classified["is_last_of_trip"]
    ends_possession = classified["ends_possession"]

    total_attempts = int(is_free_throw.sum())
    unresolved_attempts = int((is_last_of_trip & ends_possession.isna()).sum())
    used_attempts = total_attempts - unresolved_attempts
    possession_ending_trips = int(ends_possession.loc[is_last_of_trip].fillna(False).sum())

    coefficient = possession_ending_trips / used_attempts if used_attempts else float("nan")

    return SeasonPossessionCoefficient(
        season=season,
        coefficient=coefficient,
        diff_from_conventional=coefficient - CONVENTIONAL_COEFFICIENT,
        possession_ending_trips=possession_ending_trips,
        free_throw_attempts=used_attempts,
        total_free_throw_attempts=total_attempts,
        excluded_unresolved=unresolved_attempts,
    )

estimate_coefficients_by_season

estimate_coefficients_by_season(seasons)

Estimate the possession coefficient across several seasons.

Parameters:

Name Type Description Default
seasons Mapping[int, DataFrame]

Play-by-play frames keyed by season end year, e.g. {2024: play_by_play_2024}. Each frame is passed to :func:estimate_season_coefficient unchanged.

required

Returns:

Type Description
DataFrame

One row per season, sorted by season ascending, with columns

DataFrame

season, coefficient, diff_from_conventional,

DataFrame

possession_ending_trips, free_throw_attempts,

DataFrame

total_free_throw_attempts and excluded_unresolved, the same

DataFrame

fields as :class:SeasonPossessionCoefficient, so a reader can see

DataFrame

the sample size behind every coefficient rather than just the number.

DataFrame

Empty (zero rows, with those columns) if seasons is empty.

Source code in src/pippen/data/possession_coefficient.py
def estimate_coefficients_by_season(seasons: Mapping[int, pd.DataFrame]) -> pd.DataFrame:
    """Estimate the possession coefficient across several seasons.

    Args:
        seasons: Play-by-play frames keyed by season end year, e.g.
            ``{2024: play_by_play_2024}``. Each frame is passed to
            :func:`estimate_season_coefficient` unchanged.

    Returns:
        One row per season, sorted by season ascending, with columns
        ``season``, ``coefficient``, ``diff_from_conventional``,
        ``possession_ending_trips``, ``free_throw_attempts``,
        ``total_free_throw_attempts`` and ``excluded_unresolved``, the same
        fields as :class:`SeasonPossessionCoefficient`, so a reader can see
        the sample size behind every coefficient rather than just the number.
        Empty (zero rows, with those columns) if ``seasons`` is empty.
    """
    results = [
        estimate_season_coefficient(play_by_play, season)
        for season, play_by_play in sorted(seasons.items())
    ]
    columns = [
        "season",
        "coefficient",
        "diff_from_conventional",
        "possession_ending_trips",
        "free_throw_attempts",
        "total_free_throw_attempts",
        "excluded_unresolved",
    ]
    if not results:
        return pd.DataFrame(columns=columns)
    return pd.DataFrame([vars(result) for result in results], columns=columns)