hospital-ed / README.md
testingaccc's picture
Upload folder using huggingface_hub
0fe00d1 verified
|
Raw
History Blame Contribute Delete
19.1 kB
metadata
title: Hospital ED Resource Allocator
emoji: πŸ₯
colorFrom: red
colorTo: blue
sdk: docker
pinned: false
app_port: 8000
tags:
  - openenv
  - reinforcement-learning
  - hospital
  - healthcare
  - triage
base_path: /web

Hospital Emergency Department Resource Allocator

An OpenEnv reinforcement-learning environment for triaging patients and allocating beds, ICU slots, and ventilators in a public hospital emergency department.

Built for the Meta PyTorch OpenEnv Hackathon (Round 1, April 2026).

  • OpenEnv-compliant: subclasses openenv.core.Environment, ships Pydantic Action / Observation / State, FastAPI app at app:app, drivable via openenv.core.GenericEnvClient over WebSocket.
  • Gymnasium-native core: same simulation backs the Gymnasium env (HospitalEnv) so all SB3 / sb3-contrib agents work unchanged.
  • MaskablePPO agent that beats the heuristic on every scenario, trained in ~90 s of CPU.
  • 5 stress scenarios: normal_day, surge, mass_casualty, night_shift, ventilator_crisis.
  • 47 tests including a real WebSocket round-trip via GenericEnvClient against the FastAPI app.

TL;DR results

Agent Overall Survival Crit save Wait Invalid
Random 33.36 30.9% 11.2% 6.54 57.5%
Heuristic 68.43 65.0% 70.5% 4.62 0.0%
MaskablePPO (50k) 78.85 80.6% 76.3% 2.27 0.0%
            normal_day   surge   mass_casualty  night_shift  vent_crisis
random          35.02   33.86       30.10          35.06        32.75
heuristic       83.46   54.84       50.23          90.25        63.39
ppo             89.54   68.38       61.67          90.81        83.88

PPO trained for 50,000 timesteps in β‰ˆ90 seconds on a CPU MacBook, using sb3-contrib.MaskablePPO with the env's action mask.


Why this matters

Indian public hospitals routinely run at 150%+ capacity during surge events (COVID waves, heat waves, mass-casualty incidents). The decision of which patient gets the next ICU bed or ventilator is, literally, a life-or-death scheduling problem β€” one that rewards both fast triage and disciplined long-horizon planning. This environment turns that problem into a reproducible RL benchmark.


How it works

Observation (gym.spaces.Dict)

Key Shape Meaning
bed_occupancy (20,) Severity of patient in each general bed (0 = empty)
icu_occupancy (5,) Severity of patient in each ICU bed
ventilator_status (3,) 0 = free, 1 = in use
waiting_queue (10, 3) [severity, condition_id, waiting_time] per slot
time_step (1,) Current timestep (0–100)
stats (3,) [total_treated, total_deaths, total_waiting]

Action (gym.spaces.Discrete(39))

 0       No-op
 1-10    Assign waiting patient [idx] to a general bed
 11-20   Assign waiting patient [idx] to an ICU bed
 21-23   Use ventilator slot [idx] on the most severe ICU patient without one
 24-28   Transfer general bed [idx] patient to an ICU bed
 29-33   Early-discharge general bed [idx] patient
 34-38   Discharge ICU bed [idx] patient

Invalid actions (e.g. assigning from an empty queue slot) are handled gracefully: they incur a small -1 penalty and never crash.

Action masking

The env exposes a boolean validity mask via two channels:

  • info["action_mask"] β€” present in every reset / step info dict.
  • env.action_masks() β€” direct method, named to be auto-detected by sb3-contrib.MaskablePPO through wrapper chains.

The mask never marks an invalid action as valid, and it never marks a clearly-valid action as invalid. Tested by running 400 masked-random steps and asserting zero invalid actions (tests/test_env.py).

Reward (dense)

Event Reward
Successful discharge +5
Successful discharge of a critical patient +10
Patient death -15
Per-timestep per waiting critical patient -0.5
Per-timestep per waiting non-critical patient -0.1
Invalid action -1
Timestep with ICU utilization > 60% +0.1
End-of-episode (+20 * survival_rate) ≀ +20

Episode

 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 β”‚  24-hour ED shift ─── 100 timesteps                         β”‚
 β”‚                                                             β”‚
 β”‚   arrivals (Poisson)                                        β”‚
 β”‚        β”‚                                                    β”‚
 β”‚        β–Ό                                                    β”‚
 β”‚   [waiting queue] ──►  agent action  ──►  [beds / ICU]      β”‚
 β”‚        β”‚                                         β”‚          β”‚
 β”‚        β–Ό                                         β–Ό          β”‚
 β”‚   deterioration                        tick_treatment       β”‚
 β”‚        β”‚                                         β”‚          β”‚
 β”‚        β–Ό                                         β–Ό          β”‚
 β”‚     death?                                  discharged      β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Episode ends at step 100 (truncated) or when total_deaths >= max_deaths (terminated).


Setup

# 1. Clone and enter the repo
git clone <this-repo>
cd hospital-resource-allocator

# 2. Create a virtual environment and install dependencies
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Or with Docker:

docker build -t hospital-env .
docker run --rm hospital-env                    # runs evaluate.py
docker run --rm hospital-env python demo.py     # runs visual demo

Running it

Visual demo

python demo.py                             # heuristic agent, normal day
python demo.py --agent random               # random baseline
python demo.py --scenario surge             # COVID-like surge
python demo.py --scenario mass_casualty     # trauma burst

Grade an agent

python evaluate.py --agent heuristic        # prints JSON score to stdout
python evaluate.py --agent random --episodes 10
python evaluate.py --agent ppo --output ppo_score.json

Side-by-side comparison

python compare.py                           # random vs heuristic vs ppo
python compare.py --agents random heuristic --episodes 10
python compare.py --output comparison.json

Sample output: ```

Hospital ED Resource Allocator β€” agent comparison

Agent Overall Survive Crit Wait Util Inval bar

random 33.36 30.9% 11.2% 6.54 10.6% 57.5% β–ˆβ–ˆβ–ˆβ–ˆΒ·Β·Β·Β·Β·Β·Β·Β· heuristic 68.43 65.0% 70.5% 4.62 29.3% 0.0% β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆΒ·Β·Β·Β· ppo 78.85 80.6% 76.3% 2.27 36.3% 0.0% β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆΒ·Β·Β·


### Train MaskablePPO
```bash
pip install -r requirements.txt   # pulls torch + sb3-contrib
python -m agents.train_ppo --timesteps 50000 --save-path ppo_hospital
python evaluate.py --agent ppo

50k timesteps takes β‰ˆ90 s on a CPU MacBook and reliably beats the heuristic. Use --no-mask to compare against vanilla PPO without action masking.

OpenEnv-compliant FastAPI server (the hackathon entry point)

The environment ships with a real OpenEnv wrapper at app:app, built via openenv.core.create_app(...). This is the file the hackathon judges' OpenEnv test client (and any other EnvClient consumer) will talk to.

uvicorn app:app --host 0.0.0.0 --port 8000

Routes registered by the framework:

Method Path Purpose
GET /health liveness probe
GET /metadata environment name / description / version
GET /schema JSON Schema for action / observation / state
GET /state current episode state (Pydantic HospitalState)
POST /reset start a new episode ({"seed":0})
POST /step apply an action ({"action":{"action":0}})
WS /ws full stateful session (used by EnvClient)
WS /mcp MCP JSON-RPC tool-calling protocol
GET /docs auto-generated FastAPI/OpenAPI docs

Drive it with the official OpenEnv client:

import asyncio
from openenv.core import GenericEnvClient

async def main():
    client = GenericEnvClient(base_url="http://localhost:8000")
    await client.connect()

    result = await client.reset(seed=0)
    print("initial obs time_step:", result.observation["time_step"])

    for _ in range(10):
        result = await client.step({"action": 0})
        print("reward:", result.reward, "done:", result.done)

    state = await client.state()
    print("step_count:", state["step_count"], "queue_len:", state["queue_len"])

    await client.disconnect()

asyncio.run(main())

Or via Docker (the default CMD):

docker build -t hospital-env .
docker run --rm -p 8000:8000 hospital-env
docker run --rm -p 8000:8000 -e HOSPITAL_SCENARIO=surge hospital-env  # surge config

There is also a minimal stdlib HTTP server at server.py (zero dependencies, custom JSON shape) β€” useful as a fallback when openenv-core isn't installed, but not the contract the judges will speak. Use app:app for grading.

Run the test suite

pytest tests/ -v

34 tests covering Gymnasium API compliance, action mask correctness, reward-hack regression, grader reproducibility, all 5 scenarios Γ— 2 agents, and the HTTP server endpoints.


Grader output

python evaluate.py --agent ppo produces:

{
  "overall_score": 78.85,
  "survival_rate": 0.8060,
  "avg_wait_time": 2.27,
  "resource_utilization": 0.3631,
  "critical_patient_survival": 0.7625,
  "invalid_action_rate": 0.0,
  "scenario_scores": {
    "normal_day":         89.54,
    "surge":              68.38,
    "mass_casualty":      61.67,
    "night_shift":        90.81,
    "ventilator_crisis":  83.88
  }
}

Scoring formula

overall = 40 * survival_rate
        + 20 * critical_patient_survival
        + 20 * (1 - min(avg_wait_time / 20, 1))
        + 10 * resource_utilization
        + 10 * (1 - invalid_action_rate)

Range: 0 – 100. Reference scores on a fresh machine (5 episodes / scenario):

Agent overall survival crit survival invalid rate
Random 33.36 0.31 0.11 0.58
Heuristic 68.43 0.65 0.71 0.00
MaskablePPO (50k) 78.85 0.81 0.76 0.00

PPO trained for 50,000 timesteps in β‰ˆ90 seconds beats the heuristic on every scenario, with the biggest gains on the hardest ones: surge (+13), mass_casualty (+11), ventilator_crisis (+20).

Per-scenario notes

Scenario What it tests
normal_day Steady-state triage, mild-leaning mix
surge COVID-style respiratory wave, sustained pressure
mass_casualty Sudden trauma burst, rapid-triage stress test
night_shift Sparse arrivals β€” penalises wasted resources / panic vent use
ventilator_crisis 80% respiratory at sev β‰₯ 3, 3 vents are the binding constraint

Example mid-episode render

python demo.py --agent heuristic will print a frame like this each timestep:

========================================================================
HOSPITAL ED  |  Step  35 / 100  |  Treated:  17   Deaths:   6   Queue: 10
========================================================================
Gen Beds  [. . . . . . . . . . . . . . . . . . . .]  occ= 0.0%
ICU Beds  [4v .  4v 4  . ]  occ=60.0%
Vents     [# # .]  util=66.7%
------------------------------------------------------------------------
Waiting queue (10):
  #0  sev=2  cond=trauma     wait=29  vent_needed=
  #1  sev=2  cond=infection  wait=20  vent_needed=
  #2  sev=3  cond=trauma     wait=17  vent_needed=
  #3  sev=2  cond=respiratory wait=14 vent_needed=
  #4  sev=3  cond=infection  wait=13  vent_needed=
  ...
------------------------------------------------------------------------
Crit saved: 6/12   Invalid rate: 0.00%   Ep reward:   +40.40
========================================================================

Read it as: 0/20 general beds occupied, 3/5 ICU beds full (two on ventilators), 2/3 ventilators in use, queue of 10 with the longest waiter at 29 timesteps. This is a mid-surge moment where the agent has run out of ICU room and the queue is backing up β€” exactly the kind of state where the reward function is pressuring the agent to discharge or transfer.


Architecture

hospital-resource-allocator/
β”œβ”€β”€ hospital_env/                # Simulation core + both interfaces
β”‚   β”œβ”€β”€ patient.py               #   Patient & PatientGenerator (Poisson arrivals)
β”‚   β”œβ”€β”€ hospital.py              #   Hospital state (beds / ICU / vents / queue)
β”‚   β”œβ”€β”€ env.py                   #   HospitalEnv(gym.Env) + action_masks()
β”‚   β”œβ”€β”€ renderer.py              #   ASCII rendering for demo.py
β”‚   β”œβ”€β”€ openenv_types.py         #   β˜… Pydantic Action / Observation / State
β”‚   └── openenv_env.py           #   β˜… HospitalOpenEnv(openenv.core.Environment)
β”‚
β”œβ”€β”€ grader/                      # Programmatic scoring across scenarios
β”‚   β”œβ”€β”€ scenarios.py             #   5 scenario configs
β”‚   └── grader.py                #   Per-episode rollout + composite score
β”‚
β”œβ”€β”€ agents/                      # Baseline and trainable agents
β”‚   β”œβ”€β”€ random_agent.py          #   Uniform-random baseline (~33)
β”‚   β”œβ”€β”€ heuristic_agent.py       #   Rule-based triage (~68)
β”‚   └── train_ppo.py             #   MaskablePPO trainer + DictObs flattener
β”‚
β”œβ”€β”€ tests/                       # pytest test suite (47 tests)
β”‚   β”œβ”€β”€ test_env.py              #   Gym API + mechanics + mask + reward-hack regression
β”‚   β”œβ”€β”€ test_grader.py           #   Grader correctness + reproducibility
β”‚   β”œβ”€β”€ test_scenarios.py        #   Smoke each scenario Γ— random/heuristic
β”‚   β”œβ”€β”€ test_server.py           #   stdlib HTTP server endpoint smoke
β”‚   └── test_openenv.py          #   β˜… HospitalOpenEnv unit + FastAPI TestClient round-trip
β”‚
β”œβ”€β”€ app.py                       # β˜… OpenEnv FastAPI entry point (uvicorn app:app)
β”œβ”€β”€ server.py                    # Minimal stdlib HTTP server (fallback, non-OpenEnv shape)
β”œβ”€β”€ compare.py                   # Side-by-side agent comparison CLI
β”œβ”€β”€ demo.py                      # Visual episode with ASCII rendering
β”œβ”€β”€ evaluate.py                  # Grade an agent, output JSON score
β”œβ”€β”€ Dockerfile                   # python:3.11-slim, default CMD = uvicorn app:app
β”œβ”€β”€ requirements.txt             # openenv-core, gymnasium, sb3, sb3-contrib, torch, pytest
└── README.md                    # (this file)

Key design choices

  1. OpenEnv compliance via a thin adapter, not a rewrite. The simulation core is a vanilla Gymnasium HospitalEnv. A separate HospitalOpenEnv(openenv.core.Environment[…]) adapter wraps it, converts numpy obs to a Pydantic HospitalObservation, exposes a state property of type HospitalState, and is mounted as app:app via openenv.core.create_app(...). This means:

    • The judges' OpenEnv test client speaks to a real Environment subclass, with proper Pydantic validation, JSON schemas at /schema, and stateful WebSocket sessions at /ws.
    • All the existing agents, grader, tests, demo and CLI keep using the Gymnasium env unchanged.
    • Verified end-to-end: a real GenericEnvClient round-trip (reset β†’ 5 Γ— step β†’ state) returns correct episode_id, step_count, queue_len, cumulative reward.
  2. Dense reward shaping. A death costs -15 but a successful discharge only +5, so the agent cannot afford to lose patients; the per-timestep waiting penalty nudges it to act rather than idle.

  3. Early-discharge reward hack is closed. Action 29-38 only awards +5 / +10 if treatment_time_remaining ≀ 0 β€” i.e. the patient is actually fully treated. Discharging early ("against medical advice") incurs a -3 penalty scaled by remaining severity and does not count as a successful treatment. Without this, a trained agent could learn the loop admit β†’ immediately discharge β†’ +5 free reward (verified: a 40-step admit-then-discharge policy went from ~+200 reward to βˆ’958).

  4. Ventilator action is slot-indexed. Action 21+v "uses ventilator slot v on the most severe ICU patient who doesn't already have one." This is strictly more agent-friendly than a per-ICU-bed action because the observation exposes ventilator availability but not the ventilator-to-ICU-bed mapping.

  5. Critical-survivor tracking is monotone. A patient is counted as "critical" the first tick their severity hits 4 (either on arrival or after deteriorating in the queue). This guarantees critical_saved ≀ critical_total even though patients can walk up the severity ladder.

  6. Per-episode deterministic seeding. Both HospitalEnv and Grader derive episode seeds from a user-supplied base seed so that evaluate.py gives bit-identical output across runs.


License

MIT.