← Notebook

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:

data/ingestion/fetch_official_injuries.pylines 40–41
STATUSES = {"Out", "Doubtful", "Questionable", "Probable", "Available"}
UNAVAILABLE = ("Out", "Doubtful")
model/predict.pylines 1628–1642
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.

web/scripts/notebook_figures.pylines 38–74
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.

Share of listed players who played 0%25%50%75%100% Probable: 89.7% of 2,971 listings Probable 89.7% n = 2,971 Questionable: 49.1% of 8,525 listings Questionable 49.1% n = 8,525 Doubtful: 1.9% of 1,132 listings Doubtful 1.9% n = 1,132 Out: 0.2% of 48,786 listings Out 0.2% n = 48,786 Available: 76.8% of 4,637 listings Available 76.8% n = 4,637
Data table
Sharelistings
Probable89.7%2,971
Questionable49.1%8,525
Doubtful1.9%1,132
Out0.2%48,786
Available76.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.

Questionable players who played, by scoring 0%25%50%75%100% Under 8 ppg: 38.5% of 2,850 listings Under 8 ppg 38.5% n = 2,850 8 to 15 ppg: 55% of 2,553 listings 8 to 15 ppg 55% n = 2,553 15 to 20 ppg: 52.9% of 1,067 listings 15 to 20 ppg 52.9% n = 1,067 20 or more ppg: 65.3% of 1,516 listings 20 or more ppg 65.3% n = 1,516
Data table
Sharelistings
Under 8 ppg38.5%2,850
8 to 15 ppg55%2,553
15 to 20 ppg52.9%1,067
20 or more ppg65.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.

SeasonQuestionable listingsPlayed
2021-221,63254.6%
2022-231,57749.7%
2023-241,86247.7%
2024-251,80645.2%
2025-261,50249.1%
2026-2714645.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:

  1. 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).
  2. Separate input. Give the model a questionable_pts input next to missing_pts and 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.