Pre-submission claim scrubbing and denial-risk engine for hospital revenue cycle teams. Catches billing edits and predicts denial risk before a claim ever reaches the payer.
- Reworking a denied hospital claim costs an estimated $25 to $118 per claim, and industry initial denial rates run 10 to 15%. Most of those denials are preventable at submission time: expired filing windows, missing prior authorization, deleted CPT codes, unit caps, registration errors.
- DenialGuard sits in front of claim submission. Deterministic edits hard-stop structurally invalid claims, a calibrated risk model routes suspicious-but-valid claims to a prioritized review workqueue, and everything else auto-releases untouched.
- On a 50,000-claim synthetic corpus, the engine intercepted 48.6% of would-be-denied claims before submission while auto-releasing 75.5% of all traffic, keeping analyst workqueue load at under a quarter of volume. All numbers below are reproducible from scripts in this repo.
Data disclosure: every claim in this repository is synthetic, produced by a seeded generator in
datagen/. No real patient, provider, or payer data is used anywhere. Effectiveness numbers are simulation results against that labeled corpus, not production outcomes.
flowchart LR
A[Claim feed<br/>837-style JSON] --> B{Pydantic<br/>intake validation}
B -- malformed --> DL[(dead_letter)]
B -- valid --> C[Rule engine<br/>11 edit types from YAML]
C --> D[Denial risk model<br/>logistic regression]
D --> E{Disposition router}
E -- hard stop finding --> F[Blocked:<br/>fix before submission]
E -- risk >= 0.18 or warnings --> G[Review workqueue<br/>ranked by expected loss]
E -- clean and low risk --> H[Auto-released<br/>to payer]
F & G & H --> P[(PostgreSQL)]
P --> V[SQL views] --> BI[Power BI<br/>denial prevention dashboard]
| Technology | Why it is here |
|---|---|
| Python 3.12 + FastAPI | Async-capable intake API with request validation for free via Pydantic; scrubbing adds milliseconds, not a batch cycle |
| PostgreSQL (SQLAlchemy) | Claims and findings must commit atomically; analytics layer is join-and-aggregate SQL. See ADR-0001 |
| YAML rule catalog | Payer rules change with contract cycles; analysts edit configuration, not code. See ADR-0002 |
| scikit-learn logistic regression | Calibrated, explainable probabilities; "the model said no" is not an auditable answer. See ADR-0003 |
| structlog | Every event is one JSON line with claim_id, disposition, and latency; ingestible by Splunk or ELK with zero parsing |
| Docker Compose + GitHub Actions | One-command local infra; CI lints, retrains a small artifact, and runs the full test suite on every push |
git clone <repo> && cd denialguard
pip install -r requirements.txt -r requirements-dev.txt
# generate synthetic corpus and train the risk model
python datagen/generate_claims.py --count 50000
PYTHONPATH=. python app/scoring/train.py
# run the API
PYTHONPATH=. uvicorn app.main:app --port 8000Or with real Postgres:
docker compose up --buildScrub a claim:
curl -X POST localhost:8000/claims -H "Content-Type: application/json" -d '{
"external_claim_id": "DEMO-1", "patient_account": "ACC100200",
"payer_id": "BCBS01", "provider_npi": "1234567893",
"provider_specialty": "Radiology", "patient_dob": "1961-02-14",
"patient_sex": "F", "service_date": "2025-01-05",
"submission_date": "2025-06-01", "diagnosis_codes": ["Z12.31"],
"procedure_lines": [{"cpt_code": "70553", "modifiers": [], "units": 1, "charge_amount": 1480.0}]
}'That claim comes back hard_stopped with two findings: TF-001 (147 days against a 90-day BCBS filing window) and AUTH-001 (MRI brain requires prior auth for this payer, none on file). Run the tests with PYTHONPATH=. pytest tests/ -v.
Reproduce with PYTHONPATH=. python benchmark/effectiveness_replay.py. Raw output: benchmark/effectiveness_report.json.
| Metric | Value |
|---|---|
| Labeled would-be-denied claims in corpus | 7,814 (15.6%) |
| Denials intercepted pre-submission | 3,800 (48.6%) |
| Charges on intercepted denials | $51.2M |
| Auto-release rate (no analyst touch) | 75.5% |
| Hard-stopped (blocked with cited rule) | 4.1% |
| Risk model ROC-AUC / Brier (held-out) | 0.727 / 0.114 |
The model routes claims to analysts, and analysts are the scarce resource. Sweeping the threshold produced this trade-off (from the operating-curve script):
| Threshold | Review load | Denied claims captured by model | Precision |
|---|---|---|---|
| 0.10 | 60.6% | 82.0% | 21.2% |
| 0.15 | 28.0% | 54.7% | 30.6% |
| 0.18 | 19.4% | 45.2% | 36.4% |
| 0.25 | 11.0% | 34.1% | 48.5% |
| 0.35 | 7.7% | 27.8% | 56.3% |
A threshold of 0.10 catches the most denials but drowns the workqueue in false positives, which in practice means analysts stop trusting the queue. 0.18 was chosen as the default because it holds review load near what a staffed revenue integrity team absorbs (about one claim in five) while the workqueue view ranks entries by denial_risk x total_charge, so the highest expected-loss claims are worked first. The threshold is an environment variable (DG_REVIEW_THRESHOLD), because the right answer depends on staffing, not on this repo.
Staged load test, single uvicorn worker, SQLite backend, 4 vCPU container. Reproduce with benchmark/load_test.py; raw output in benchmark/results.json.
xychart-beta
title "Latency vs concurrency (ms, 1500 requests per stage)"
x-axis "concurrent clients" [1, 10, 25, 50]
y-axis "latency (ms)" 0 --> 3200
line "p50" [13.0, 29.8, 152.8, 430.9]
line "p95" [17.6, 453.5, 1157.5, 1363.7]
line "p99" [36.6, 1358.7, 2562.7, 3086.9]
| Concurrency | Throughput | p50 | p95 | p99 | Errors |
|---|---|---|---|---|---|
| 1 | 67.7 rps | 13.0 ms | 17.6 ms | 36.6 ms | 0 |
| 10 | 98.2 rps | 29.8 ms | 453.5 ms | 1358.7 ms | 0 |
| 25 | 82.1 rps | 152.8 ms | 1157.5 ms | 2562.7 ms | 1 |
| 50 | 86.8 rps | 430.9 ms | 1363.7 ms | 3086.9 ms | 0 |
Honest reading of that chart: throughput plateaus near 100 rps and tail latency grows with concurrency because SQLite serializes writes behind a single lock. That is a benchmarking artifact of the zero-dependency local setup, not the design ceiling; the Postgres deployment in docker-compose removes the single-writer constraint. At 100 rps sustained, one worker clears 8.6M claims/day, roughly 300x the daily claim volume of a large hospital, so a single modest instance is deliberately the deployment story.
analytics/sql/views.sql defines five views consumed by a Power BI dashboard: daily disposition mix, rule effectiveness by payer, a payer scorecard, the expected-loss-ranked review workqueue, and dead-letter aging. The workqueue view mirrors how Epic WQs are prioritized, except ranked by financial exposure instead of age.
- No PHI exists in this repository; the corpus is synthetic by construction. In a real deployment the service would fall under HIPAA: encrypt Postgres at rest, TLS on the wire, and row access through the views rather than base tables.
- Secrets are environment variables (
DG_prefix) loaded via pydantic-settings. The compose file carries a marked local-only password; deployed environments inject credentials from a secrets manager (AWS Secrets Manager or Vault) at the platform layer, never from committed files. - Structured logs carry claim IDs and rule IDs but never patient names or DOBs, so log aggregation does not become a shadow PHI store.
| Failure | Behavior |
|---|---|
| Malformed claim payload | Persisted to dead_letter with the raw body and validation errors; nothing is silently dropped. vw_dead_letter_aging surfaces unresolved entries |
| Model artifact missing or corrupt | Service starts, logs a warning, and scores every claim 1.0, forcing review. Rules still hard-stop invalid claims. Fail-safe, not fail-open |
| Unknown rule type in YAML | Engine raises at first evaluation. A typo must not silently disable an edit |
| Database down | Intake returns 5xx and the upstream feed retries; claims are never marked scrubbed without a committed row |
| Payer config missing for a claim | Falls back to the default payer profile (365-day window, no auth list) and still scrubs everything else |
- NCCI procedure-to-procedure bundling edits. The full NCCI table is 400k+ code pairs updated quarterly; correct handling is a data-refresh pipeline of its own. The checker seam in the rule engine is where it plugs in.
- Real-time 835 remittance ingestion to close the loop between predicted and actual denials. That is the operationalization path below, not v1.
- Caching and horizontal scaling. At 300x measured headroom over realistic volume, added moving parts are cost without benefit today.
- Feed actual 835 remittance outcomes back as training labels, replacing synthetic labels with observed denials.
- Move duplicate detection to a Postgres unique partial index for multi-replica correctness (documented in ADR-0001).
- Add NCCI bundling and payer-specific medical-necessity (LCD/NCD) checkers.
- Ship the Power BI dashboard from the five views with weekly denial-prevention reporting to revenue cycle leadership.
app/ FastAPI service, rule engine, scoring
config/ rules.yaml, payers.yaml (the analyst-editable surface)
datagen/ synthetic claim generator (seeded, labeled)
analytics/sql/ Power BI-facing views
benchmark/ load test + effectiveness replay, raw results committed
docs/adr/ three architecture decision records
tests/ 18 unit + integration tests