Business question
Compare charges with payments
How do submitted hospital charges compare with recorded Medicare payments across DRGs? The pipeline makes these differences queryable for analytical investigation.
RAW TO RELIABLE / ENGINEERING CASE STUDY / CMS MEDICARE
Kafka and Spark Structured Streaming process public Medicare hospital charge records into Snowflake snapshots and dbt models for DRG charge-to-payment analysis.
Streaming and dbt code implemented; Airflow orchestration documented separately. Periodically published public records are replayed through a stateful processing pipeline.
Focus areas
Technology stack
01 / Executive Overview
Make public hospital charge and payment differences easier to investigate.
Business question
How do submitted hospital charges compare with recorded Medicare payments across DRGs? The pipeline makes these differences queryable for analytical investigation.
Source grain
CMS publishes provider/service summaries for a reporting year. They describe public hospital-level aggregates, not patient-level claims or a live event feed.
Processing
Python pages through the API, Kafka carries DRG-keyed records, and Spark produces complete aggregate snapshots through foreachBatch.
Analytical output
dbt filters and renames aggregate fields, adds a statistical outlier flag and separate severity bands, then ranks ratios within each band.
The trigger is a configured processing cadence, not a measured end-to-end latency. CMS refreshes this public dataset annually; the project simulates streaming by sending retrieved records through Kafka.
02 / Streaming Architecture
An implemented streaming data path, with documented orchestration kept visibly separate.
Airflow / README-described control workflow / DAG not published
01 / Documented task
Invocation unverified
02 / Documented task
Invocation unverified
03 / Documented task
Invocation unverified
04 / Documented task
Invocation unverified
Ingestion and aggregation / source-defined operations
01 / Source
Periodic public records
02 / Acquisition
size / offset pages
03 / Messages
cms-inpatient-stream
04 / Processing
DRG code + description
Snapshot sink / continues from Spark aggregates
01 / State
All current DRG groups
02 / Callback
collect() to driver
03 / Replace
TRUNCATE → INSERT
04 / Snapshot
DRG_CHARGE_ANALYSIS
dbt analytical path / separately invoked model build
01 / Source
Latest stored aggregates
02 / Normalize
Rename / filter
03 / Classify
Outlier / severity
04 / Analyze
Rank within severity
Spark groups by DRG code and description, emits complete aggregate state, and calls a Python Snowflake writer. The sink replaces its analytical snapshot; it does not retain an append-only event history.
Staging and intermediate views depend on the current snapshot. The mart is a separate table build. Airflow is shown above the data flow as documented control, rather than as a downstream store.
03 / Kafka Ingestion & Partitioning
Page through a public dataset, then publish full rows with a DRG routing key.
Use size and offset; request up to 1,000 rows per page, stopping at the 5,000-record target or an empty response.
Publish each complete source row. DRG_Cd becomes the key; an absent field defaults to UNKNOWN.
Send the page of messages and flush the producer. A half-second sleep between pages simulates arrival gaps.
Read the topic from earliest offsets for a new query, then parse the payload into the selected schema.
BATCH_SIZE = 1000 # How many rows importing from API
TOTAL_ROWS = 5000 # Verify after first 5000 rows, full importing 145000 later on key = row.get("DRG_Cd", "UNKNOWN")
producer.send(TOPIC, key=key, value=row)
producer.flush()Producer key
DRG_CdUTF-8 key bytes
Illustrative target
Actual assigned keys unverified
Illustrative target
Actual assigned keys unverified
Illustrative target
Actual assigned keys unverified
Source GUID: 690ddc6c-2767-4618-b277-420ffb2bf27c. A read-only API audit on October 14, 2026 reported 145,879 available rows; catalog metadata identifies an annual release covering 2024. This source count is separate from the producer target and does not prove how many records reached Kafka.
04 / Spark Structured Streaming
Stateful DRG aggregation with complete output every configured micro-batch.
Spark 3.5.1 Kafka connector / local[*] execution
01 / Read
startingOffsets: earliest
02 / Normalize
11 JSON fields
03 / Aggregate
DRG_Cd / DRG_Desc
04 / Snapshot
30-second trigger
hospital_countCOUNT(*)Counts input messages in each DRG code/description group; not distinct provider CCNs.
avg_charge / avg_medicare_pymt / avg_total_pymtROUND(AVG(field), 2)Unweighted averages of the provider/DRG summary records received by Spark.
avg_charge_ratioROUND(AVG(charge / medicare_payment), 2)Average of individual source-row ratios, not AVG(charge) / AVG(payment). Discharges are cast but not used as weights.
spark_round(
avg(col("charge") / col("medicare_payment")), 2
).alias("avg_charge_ratio"), drg_agg.writeStream
.outputMode("complete")
.foreachBatch(write_to_snowflake)
.trigger(processingTime="30 seconds")
.start()There is no configured durable checkpoint location, deduplication key, event-time window, or watermark. Replayed records can change counts and means. The code does not establish exactly-once writes or bounded state across changing DRG descriptions.
The stream has no explicit positive-payment filter, zero-denominator guard, or quarantine before averaging. Invalid casts and malformed records need validation; their actual runtime behavior is not recorded. count(*) also counts messages rather than distinct providers.
05 / Snowflake & dbt Data Modeling
Replace an aggregate snapshot, then build three explicit analytical layers.
The foreachBatch callback collects aggregate rows to the driver and uses snowflake-connector-python. For a non-empty batch it truncates DRG_CHARGE_ANALYSIS, inserts the current aggregate rows, commits, and closes the connection. Empty batches leave the stored snapshot untouched.
cursor.execute("TRUNCATE TABLE STREAMING.DRG_CHARGE_ANALYSIS")
# ... INSERT statement and row tuples omitted ...
cursor.executemany(insert_sql, data)
conn.commit()No protective staging/swap, batch ledger, rollback strategy, or explicit transaction configuration is present. A failure between truncate and reload can affect availability or expose an incomplete snapshot. This is a design risk, not a reported outage or a transactional guarantee.
A staging table plus atomic replacement, key-based MERGE, versioned snapshots, or a checkpointed incremental sink would address different replay and history requirements. These are future alternatives, not implemented features.
Streaming snapshot → staging → intermediate → mart
01 / Snowflake
Streaming aggregate snapshot
02 / View
Normalize / positive means
03 / View
Global statistics / flags
04 / Table
Severity-specific ranks
Staging / view
stg_ drg_ charge_ analysisRenames fields; retains non-null DRG codes and positive aggregate submitted-charge/payment means. It does not regroup, deduplicate, or validate each original input row.
Intermediate / view
int_ drg_ outlier_ flaggedCROSS JOINs one global statistics row, calculates the charge gap, and attaches outlier and severity classifications without changing the underlying group grain.
Mart / table
mart_ drg_ spending_ qualityPreserves the classified rows and RANKs charge ratios within severity. Ties share a rank and can leave gaps; the stored table refreshes only when dbt is run.
The snapshot table supplies updated_at through a Snowflake default, renamed batch_updated_at in staging. The mart preserves that snapshot timestamp; no historical version key or time-series accumulation is implemented.
06 / Data Quality & Anomaly Rules
Keep statistical outliers, rule-based severity, and test evidence distinct.
Statistical flag
The intermediate model calculates AVG and STDDEV across staged DRG-group ratios. A strict greater-than comparison marks a high-side analytical outlier; it is not a causal or fraud finding.
Rule-based severity
Critical, High, Medium, and Normal are project-defined labels. They are separate from the statistical flag and are not CMS-endorsed regulatory classifications. The charge gap is a charge/payment difference, not profit.
Critical
First CASE branch
High
Second CASE branch
Medium
Third CASE branch
Normal
NULL also falls through to ELSE
CASE
WHEN b.avg_charge_ratio > (s.mean_ratio + 2 * s.stddev_ratio)
THEN TRUE
ELSE FALSE
END AS is_charge_outlier,
CASE
WHEN b.avg_charge_ratio >= 15 THEN 'Critical'
WHEN b.avg_charge_ratio >= 10 THEN 'High'
WHEN b.avg_charge_ratio >= 5 THEN 'Medium'
ELSE 'Normal'
END AS charge_severitynot_null
Staging: DRG code, submitted-charge mean, Medicare-payment mean, and ratio. Mart: DRG code and severity.
unique
DRG code in staging and mart. These test the expected code grain; they do not prove it without execution results.
accepted_values
Mart severity must be Critical, High, Medium, or Normal. No relationship tests or intermediate-model tests are declared.
Nine tests are defined: five on staging and four on the mart. The README reports nine tests with zero failures, but no machine-readable result or execution log independently verifies that result. No fake pass indicator or sample financial table is reproduced.
07 / Apache Airflow Orchestration
Present the documented task chain alongside the orchestration code actually published.
cms_medicare_pipeline / README-described DAG, code not published
01 / README task
Retrieve API records and publish Kafka messages.
02 / README task
Start Spark processing and Snowflake snapshot writes.
03 / README task
Build the staging/intermediate views and mart table.
04 / README task
Run the nine declared model checks.
01 / Documented task
run_cms_producerRetrieve API records and publish Kafka messages.
02 / Documented task
run_spark_streamingStart Spark processing and Snowflake snapshot writes.
03 / Documented task
run_dbt_modelsBuild the staging/intermediate views and mart table.
04 / Documented task
run_dbt_testsRun the nine declared model checks.
The checked-in Spark job calls awaitTermination() and has no available-now trigger, timeout, or programmed stop. A synchronous Airflow invocation of that job would block downstream dbt tasks until the stream stops or fails. The missing DAG prevents verifying how the documented workflow handles this boundary.
The published Airflow stack defines LocalExecutor, PostgreSQL metadata storage, a scheduler/webserver, an external CMS Docker network, and producer/dbt mounts. It also mounts a Docker socket. Historical mount paths, the missing DAG/profile, and the absent Kafka/Spark Compose file prevent a direct replay of the documented environment.
08 / Engineering Decisions & Troubleshooting
Explain routing, runtime, sink, and container contracts with implementation and author-reported lessons separated.
Source + documentation
Source evidence
Source + documentation
Source evidence
Source + documentation
Source evidence
Documented decision
Source evidence
Documented decision
Source evidence
kafka:29092Spark’s configured client target; same-network broker advertisement is documented.
localhost:9092Producer’s default host context. Inside a container, localhost refers to that container.
kafka:39092Author-documented cross-project target; current broker listener and DAG invocation are unverified.
09 / Evidence, Limitations & Source Code
A code-grounded streaming walkthrough with an explicit boundary around execution claims.
API pagination and producer limits; DRG-keyed JSON messages; Spark schema, grouping, complete output, trigger and callback; snapshot replacement; three dbt layers; nine test definitions; and the Airflow Compose/Dockerfile. No broker, Spark, Snowflake, dbt, or Airflow service was executed during this audit.
The public CMS API and catalog were checked separately for schema, available source count, annual refresh, and reporting-year context. These observations verify public source availability, not successful ingestion, exact provider/DRG uniqueness, or end-to-end pipeline execution.
Three topic partitions, three broker listeners, a daily Airflow schedule, the named DAG chain, the nine-tests-passed statement, and the related troubleshooting narratives. No DAG, Kafka/Spark Compose, physical plan, checkpoint configuration, test result file, or historical snapshot output is published.
The repository uses Kafka/, Spark/, Models/, and dbt/, while Compose and README reference historical CMS_project/cms_dbt and DOT_project layouts. The dbt configuration expects models/ under its project; model SQL is actually in top-level Models/. No successful clean-checkout replay is established.
Unverified sample financial results and the README graphic’s full-run/pass labels were omitted. No patient data, source account identifiers, passwords, or private endpoints were copied. The snapshot sink does not establish exactly-once writes, complete history, observed availability, measured latency, or production operation.
Source reviewed at 5143bdc. The Kafka partition figure is illustrative; the other figures explain source-defined code or clearly labeled documented workflows. Charge severity and statistical outliers require business context before any operational decision.