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
¶
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. |
diff_from_conventional |
float
|
|
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 |
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: |
Source code in src/pippen/data/possession_coefficient.py
coefficient_for_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: |
float
|
is the value pooled across every season that does have one. |
Source code in src/pippen/data/possession_coefficient.py
estimate_season_coefficient ¶
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 |
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
|
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, |
SeasonPossessionCoefficient
|
and |
SeasonPossessionCoefficient
|
an empty season is a legitimate (if useless) input. |
Source code in src/pippen/data/possession_coefficient.py
estimate_coefficients_by_season ¶
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.
|
required |
Returns:
| Type | Description |
|---|---|
DataFrame
|
One row per season, sorted by season ascending, with columns |
DataFrame
|
|
DataFrame
|
|
DataFrame
|
|
DataFrame
|
fields as :class: |
DataFrame
|
the sample size behind every coefficient rather than just the number. |
DataFrame
|
Empty (zero rows, with those columns) if |