Data Quality · Open
How often does a Questionable player actually play?
About half the time. Players listed Questionable on the 5:30 PM ET injury report played in 49% of cases, yet the model currently counts them as fully available.
Why it came up
On November 6 a player listed Questionable was ruled out shortly before tip-off. The forecast still picked the right winner, but it raised a fair question: what does the model do with a Questionable tag?
What the model does today
Only two statuses count as missing:
STATUSES = {"Out", "Doubtful", "Questionable", "Probable", "Available"}
UNAVAILABLE = ("Out", "Doubtful") def add_live_availability(row_dict, game, injury_report):
"""Fill the team model's availability features from the injury report."""
for side in ('home', 'away'):
out_names = [
p['name'] for p in (injury_report or {}).get(game[f'{side}_team'], [])
if p['status'] in ['Out', 'Doubtful']
]
missing_min, missing_pts, names = compute_live_availability(
game[f'{side}_team_id'], game['game_date'], out_names
)
row_dict[f'{side}_missing_min'] = missing_min
row_dict[f'{side}_missing_pts'] = missing_pts
if names:
print(f" 📉 {game[f'{side}_team']} missing {missing_pts:.1f} pts/g "
f"of rotation scoring: {', '.join(names)}") Live forecasts: only Out and Doubtful players are added to a team's missing points. Training uses the same rule.
So a Questionable player counts as fully available. Treating him as fully out would be just as wrong in the other direction. The real question is how often he plays.
Method
Every player listing on the 5:30 PM ET official injury report since 2021-22 was matched to that night’s box score. “Played” means logged minutes. Each player’s scoring tier is his season points per game before that night, so the tier uses no information from the game itself.
def questionable(q):
"""Did players listed on the 5:30 PM ET official injury report play that night?"""
rep = q("""SELECT e.game_date, e.player_id, e.team_id, e.status
FROM injury_report_entries e JOIN injury_report_fetches f USING (fetch_id)
WHERE e.game_date = f.report_date AND e.player_id IS NOT NULL
AND f.http_status = 200 AND f.report_time = '17:30'""")
box = q("""SELECT g.game_id, g.game_date, g.season, b.player_id,
b.minutes_played::float mp, b.points
FROM player_box_scores b JOIN games g USING (game_id) WHERE g.is_final""")
gm = q("SELECT game_id, game_date, season, home_team_id, away_team_id FROM games WHERE is_final")
for x in (rep, box, gm):
x["game_date"] = pd.to_datetime(x.game_date)
rep = rep.drop_duplicates(["game_date", "player_id"])
# each listing -> that team's game that night -> did the player log minutes?
sides = [gm.rename(columns={c: "team_id"})[["game_id", "game_date", "season", "team_id"]]
for c in ("home_team_id", "away_team_id")]
r = rep.merge(pd.concat(sides), on=["game_date", "team_id"])
r = r.merge(box[["game_id", "player_id", "mp"]], on=["game_id", "player_id"], how="left")
r["played"] = r.mp.fillna(0) > 0
# scoring tier = the player's season points per game before that night
pl = box[box.mp > 0].sort_values("game_date").copy()
pl["ppg"] = pl.groupby(["player_id", "season"]).points.transform(lambda s: s.expanding().mean())
last = pl[["player_id", "season", "game_date", "ppg"]].rename(columns={"game_date": "gd"}).sort_values("gd")
r = pd.merge_asof(r.sort_values("game_date"), last, left_on="game_date", right_on="gd",
by=["player_id", "season"], allow_exact_matches=False)
by_status = r.groupby("status").played.agg(["count", "mean"])
qs = r[r.status == "Questionable"].copy()
qs["tier"] = pd.cut(qs.ppg, [-1, 8, 15, 20, 99], labels=["Under 8", "8 to 15", "15 to 20", "20 or more"])
by_tier = qs.groupby("tier", observed=True).played.agg(["count", "mean"])
by_season = qs.groupby("season").played.agg(["count", "mean"])
rows = lambda d: [dict(label=str(k), n=int(v["count"]), played=r1(100 * v["mean"])) for k, v in d.iterrows()]
order = ["Probable", "Questionable", "Doubtful", "Out"]
return dict(status=sorted(rows(by_status), key=lambda x: order.index(x["label"]) if x["label"] in order else 9),
tier=rows(by_tier), season=rows(by_season)) Findings
Share of listed players who played
By status on the 5:30 PM ET report, 2021-22 to date.
Data table
| Share | listings | |
|---|---|---|
| Probable | 89.7% | 2,971 |
| Questionable | 49.1% | 8,525 |
| Doubtful | 1.9% | 1,132 |
| Out | 0.2% | 48,786 |
| Available | 76.8% | 4,637 |
Questionable means about a coin flip: 49.1% of 8,525 listings played. The other statuses behave as their names suggest. Doubtful players almost never play, and Probable players almost always do.
Questionable players who played, by scoring
Season points per game before that night.
Data table
| Share | listings | |
|---|---|---|
| Under 8 ppg | 38.5% | 2,850 |
| 8 to 15 ppg | 55% | 2,553 |
| 15 to 20 ppg | 52.9% | 1,067 |
| 20 or more ppg | 65.3% | 1,516 |
Stars play through a Questionable tag more often than bench players: 65% for 20-point scorers against 39% for players under 8. That matters because the missing-points input is weighted by scoring, so the players who most affect a forecast are also the most likely to play.
| Season | Questionable listings | Played |
|---|---|---|
| 2021-22 | 1,632 | 54.6% |
| 2022-23 | 1,577 | 49.7% |
| 2023-24 | 1,862 | 47.7% |
| 2024-25 | 1,806 | 45.2% |
| 2025-26 | 1,502 | 49.1% |
| 2026-27 | 146 | 45.2% |
The rate is steady across seasons, between about 45% and 55%, so a single historical estimate is reasonable.
Next
Two options to test against each other in walk-forward cross-validation:
- Partial absence. Count a Questionable player as partly missing, weighted by how often players in his scoring tier sit (about 35% for 20-point scorers, about 60% for players under 8).
- Separate input. Give the model a
questionable_ptsinput next tomissing_ptsand let it learn the weight.
A third idea depends on timing rather than modeling: rerun each forecast closer to tip-off, after most Questionable players have been confirmed in or out.