Building a filterable map with {leaflet} and {crosstalk} for hospital readmissions

Series on runtime-free content delivery

Want to make this map widget? Read On! 👇👇👇

Hospital Readmissions Reduction Program (HRRP) FY 2022 - WI Map

Cohort-specific penalty:



So you run R locally but want to be able to deliver engaging, interactive analytic content to your stakeholders. Is there a way to do that? Lately, I’ve spent a lot of time learning and exploring tools to find the answer to that question. Luckily, the answer is yes, because there are many amazing packages that have been developed to help with this, including reactable, plotly, DT, leaflet, and crosstalk, among others. This article focuses on the latter two, and how we can build map widgets that are changeable by the user through filters, all in a self-contained HTML document.

The Use-Case: Hospital Readmissions

A readmission occurs when a patient returns to a hospital for acute care within 30 calendar days of previously being discharged. Each year the Centers for Medicare & Medicaid Services (CMS) penalizes hospitals across the United States that have excess readmissions in a select set of high-risk patient populations. This is determined by:

  1. Adjusting for the acuity of your hospital’s patient population by a collection of clinical risk factors
  2. Comparing the expected readmission rate of those patients had they been treated at your hospital versus the national average hospital. The difference between those rates yields the penalty amount (with some additional steps and caveats).

For the stats gurus, it is a mixed-effects logistic regression model that uses the hospital as a random-intercept term, and clinical factors as fixed effects. You can read more about the methodology here.

The goal of this exercise is to build a map widget to identify and explore payment penalties for Wisconsin hospitals participating in the FY 2022 Hospital Readmissions Reduction Program (HRRP).

Constructing the Map

To render the map, we’ll assume you are working in an R Markdown notebook that is being rendered to HTML (as we are in this very article). The following collection of R packages will be used to do most of the heavy lifting:

### Load packages
require(tidyverse)
require(leaflet)
require(crosstalk)

With those loaded, we can carry out these steps:

1. Build the dataset

We want to build a data set consisting of one row per hospital, with payment penalty information and coordinates for plotting the hospital on the map. The data needed to do this comes from a variety of sources. We can break these up into components and then put them all together into our final data set.

i. Payment penalties

The penalty amounts for each hospital are documented in the CMS FY2022 Final Rule. The file we need is contained within a zip file; we can use this solution for motivation of our approach. First, we can download the file:

# Set location of zip file (publicly available on CMS website)
hrrp_zip <- "https://www.cms.gov/files/zip/fy2022-hrrp-final-rule-supplemental-file.zip"

# Create a temporary file (Credits: rpubs.com/otienodominic/398952)
temp_file <- tempfile()

# Download into the temporary file
download.file(hrrp_zip, temp_file)

Next, we can pluck out the data file we need, save it to our current working directory, and then import it into our session.

# Name of file needed within zip
hrrp_file <- "FY2022_Final_Rule_Supplemental_File.xlsx"

# Unzip, and place the file in the current working directory
unzip(temp_file, hrrp_file, exdir = ".")

# Import the data file into a data frame
hrrp_results <-
  readxl::read_xlsx(
    path = hrrp_file,
    sheet = "FR FY 2022",
    skip = 3,
    na = c("", " ", ".", "-", "NA", "N/A")
  )

# Delete the downloaded file
file.remove(hrrp_file)
unlink(temp_file)
print(hrrp_results, n = 5)
## # A tibble: 3,048 × 36
##   `Hospital CCN` Payme…¹ Payme…² Dual …³ Peer …⁴ Neutr…⁵ Numbe…⁶ ERR f…⁷ Peer …⁸
##   <chr>            <dbl>   <dbl>   <dbl>   <dbl>   <dbl>   <dbl>   <dbl>   <dbl>
## 1 010001           0.990    0.97   0.163       2   0.964     424   1.03    0.996
## 2 010005           0.998    0.15   0.141       2   0.964      36   0.955   0.996
## 3 010006           0.984    1.6    0.107       1   0.964     363   0.961   0.994
## 4 010007           0.995    0.51   0.235       4   0.964       2   0.996   0.998
## 5 010008           1        0      0.227       3   0.964       6   0.998   0.996
## # … with 3,043 more rows, 27 more variables: `Penalty indicator for AMI` <chr>,
## #   `DRG payment ratio for AMI` <dbl>,
## #   `Number of eligible discharges for COPD` <dbl>, `ERR for COPD` <dbl>,
## #   `Peer group median ERR for COPD` <dbl>, `Penalty indicator for COPD` <chr>,
## #   `DRG payment ratio for COPD` <dbl>,
## #   `Number of eligible discharges for HF` <dbl>, `ERR for HF` <dbl>,
## #   `Peer group median ERR for HF` <dbl>, `Penalty indicator for HF` <chr>, …

ii. Hospital information

We need some identifying information for the hospitals such as name and zip code (this is what we’ll use for plotting). This is found on data.cms.gov, which we can import from directly:

# Set path to file (from CMS)
hosp_file <- "https://data.cms.gov/provider-data/sites/default/files/resources/092256becd267d9eeccf73bf7d16c46b_1689206722/Hospital_General_Information.csv"

# Import the file
hosp_info <-
  read_csv(
    file = hosp_file
  )
print(hosp_info, n = 5)
## # A tibble: 5,446 × 38
##   Facili…¹ Facil…² Address City/…³ State ZIP C…⁴ Count…⁵ Telep…⁶ Hospi…⁷ Hospi…⁸
##   <chr>    <chr>   <chr>   <chr>   <chr> <chr>   <chr>   <chr>   <chr>   <chr>  
## 1 010001   SOUTHE… 1108 R… DOTHAN  AL    36301   HOUSTON (334) … Acute … Govern…
## 2 010005   MARSHA… 2505 U… BOAZ    AL    35957   MARSHA… (256) … Acute … Govern…
## 3 010006   NORTH … 1701 V… FLOREN… AL    35630   LAUDER… (256) … Acute … Propri…
## 4 010007   MIZELL… 702 N … OPP     AL    36467   COVING… (334) … Acute … Volunt…
## 5 010008   CRENSH… 101 HO… LUVERNE AL    36049   CRENSH… (334) … Acute … Propri…
## # … with 5,441 more rows, 28 more variables: `Emergency Services` <chr>,
## #   `Meets criteria for promoting interoperability of EHRs` <chr>,
## #   `Hospital overall rating` <chr>, `Hospital overall rating footnote` <dbl>,
## #   `MORT Group Measure Count` <chr>, `Count of Facility MORT Measures` <chr>,
## #   `Count of MORT Measures Better` <chr>,
## #   `Count of MORT Measures No Different` <chr>,
## #   `Count of MORT Measures Worse` <chr>, `MORT Group Footnote` <dbl>, …

iii. Zip code centroids

For simplicity, we are going to use the hospital’s zip code for plotting them on the map. The tigris package provides access to the geometries we need:

# Get set of zip codes for Wisconsin
zips_wi <- tigris::zctas(year = 2010, state = "WI")

A zip code is a region, so we’ll need to reduce those down to a single set of coordinates. To do this, we can use the centroid of the region. The sf package has readily-available functions for this.

# Gather centroids of corrdinates for each zip
zips_wi_centroids <-
  zips_wi %>%
  
  # Get the centroid
  sf::st_centroid() %>%
  
  # Pluck the coordinates
  sf::st_coordinates() %>%
  
  # Make a tibble
  as_tibble() %>%
  
  # Add identifying column
  add_column(
    Zip = zips_wi$ZCTA5CE10
  ) %>%
  
  # Rename columns
  rename(
    lon = X,
    lat = Y
  )
print(zips_wi_centroids, n = 5)
## # A tibble: 774 × 3
##     lon   lat Zip  
##   <dbl> <dbl> <chr>
## 1 -89.8  43.7 53965
## 2 -89.6  44.1 54943
## 3 -89.5  44.2 54966
## 4 -90.7  46.3 54546
## 5 -90.5  46.5 54559
## # … with 769 more rows

iv. Build map data set

Finally, we can clean and combine the previously loaded datasets, extracting the fields we need. First, we’ll filter to Wisconsin hospitals:

map_data <-
  hosp_info %>%
  
  # Filter to Wisconsin hospitals
  filter(
    State == "WI"
  ) %>%
  
  # Keep a few pieces of information
  select(
    FacilityID = `Facility ID`,
    FacilityName = `Facility Name`,
    Zip = `ZIP Code`
  )

Next, we can add the zip code centroid coordinates for the hospital. One additional step we’ll take is to add a small amount of random noise to the coordinates. This will prevent hospitals that share the same zip code from being plotted directly on top of one another.

map_data <-
  map_data %>%
  
  # Join to get the centroid for the hospital's zip code
  inner_join(
    y = zips_wi_centroids,
    by = "Zip"
  ) %>%
  
  # Add random jitter to coordinates
  mutate(
    across(
      c(
        lat,
        lon
      ),
      jitter,
      amount = 0.05
    )
  )

The penalty information can be added next. We’ll add the:

  • Penalty amount: The percentage in which reimbursements are reduced by due to excess readmissions
  • Dual-eligibility proportion: The percent of discharges in which the patient was dually-eligible for both Medicare and Medicaid benefits
  • Peer group: The group that the hospital was compared against to determine penalty status. This is based on the dual-eligibility proportion.
  • Cohort-specific penalty indicators: A flag of whether or not the hospital was penalized in each of the six (6) focus cohorts

We’ll do a little clean-up on the cohort indicators for nicer names and presentation.

map_data <-
  map_data %>%
  
  # Join to get HRRP program results
  inner_join(
    y = 
      hrrp_results %>%
      
      # Make cleaner levels for penalty indicators
      mutate(
        across(
          starts_with("Penalty indicator"),
          ~
            case_when(
              .x == "Y" ~ "Yes",
              .x == "N" ~ "No",
              TRUE ~ NA_character_
            )
        )
      ) %>%
      
      # Keep a few columns
      select(
        FacilityID = `Hospital CCN`,
        PeerGroup = `Peer group assignment`,
        DualProportion = `Dual proportion`,
        Penalty = `Payment Reduction Percentage`,
        starts_with("Penalty indicator")
      ) %>%
      
      # Remove the prefix from the cohort columns
      rename_with(
        ~str_remove(.x, "^Penalty indicator for "),
        starts_with("Penalty indicator")
      ),
    by = "FacilityID"
  )

Finally, we’ll add a string that concatenates all of the cohorts that the hospital received penalty for. This will be used as a display for the tooltip in the map.

map_data <-
  map_data %>%
  
  # Join to get list of penalized cohorts (for labeling)
  left_join(
    y = 
      hrrp_results %>%
      
      # Send down the rows
      pivot_longer(
        cols = starts_with("Penalty indicator"),
        names_prefix = "Penalty indicator for "
      ) %>%
      
      # Filter to penalty cohorts
      filter(
        value == "Y"
      ) %>%
      
      # For each hospital
      group_by(`Hospital CCN`) %>%
      
      # Concatenate the list of penalty cohorts
      summarise(
        PenalizedCohortCount = n(),
        PenalizedCohorts = paste(sort(unique(name)), collapse = ", "),
        .groups = "drop"
      ) %>%
      
      # Rename the column
      rename(
        FacilityID = `Hospital CCN`
      ),
    by = "FacilityID"
  ) %>%
  
  # Fill in non-penalized hospitals
  mutate(
    PenalizedCohortCount = coalesce(PenalizedCohortCount, 0),
    PenalizedCohorts = coalesce(PenalizedCohorts, "")
  ) 
print(map_data, n = 5)
## # A tibble: 61 × 16
##   FacilityID Facil…¹ Zip     lon   lat PeerG…² DualP…³ Penalty AMI   COPD  HF   
##   <chr>      <chr>   <chr> <dbl> <dbl>   <dbl>   <dbl>   <dbl> <chr> <chr> <chr>
## 1 520002     ASPIRU… 54481 -89.7  44.6       4   0.262    0.12 No    Yes   Yes  
## 2 520004     MAYO C… 54601 -91.2  43.8       4   0.236    0.81 No    No    No   
## 3 520008     WAUKES… 53188 -88.3  43.0       2   0.139    0.31 Yes   No    Yes  
## 4 520009     ASCENS… 54915 -88.3  44.2       3   0.201    0.08 Yes   No    No   
## 5 520011     MARSHF… 54868 -91.8  45.5       4   0.259    0.37 No    No    Yes  
## # … with 56 more rows, 5 more variables: pneumonia <chr>, CABG <chr>,
## #   `THA/TKA` <chr>, PenalizedCohortCount <dbl>, PenalizedCohorts <chr>, and
## #   abbreviated variable names ¹​FacilityName, ²​PeerGroup, ³​DualProportion

2. Create a sharable dataset

In order to enable the map to be controlled and interacted through filters, we need to create a SharedData object using the crosstalk package. This allows the rendered HTML document to hold a connection between the data that feeds the map and the filters.

# Make the shared data set
sd <- SharedData$new(data = map_data)

3. Create the base layer

The first layer of the map will consist of making a focal point on the state of Wisconsin. We will use the maps package to obtain the geometries for this region.

state_outline <-
  maps::map(
    database = "state",
    regions = "wisconsin",
    fill = TRUE,
    plot = FALSE
  )

Now, we can initiate the map with the leaflet toolkit and add the first layer with a simple gray shading.

my_map <- 
  leaflet() %>%
  
  # Add geographic tiles
  addTiles() %>%
  
  # Add WI state outline
  addPolygons(
    data = state_outline,
    fillColor = "gray",
    stroke = FALSE
  ) 

my_map

4. Add county lines

Next, we want to fill in the map with the Wisconsin county lines. The tigris package has a function called counties that enable us to extract these geometries.

county_outlines <- 
  tigris::counties(cb = TRUE) %>%
  filter(
    STATE_NAME == "Wisconsin"
  )

We can add the county lines to the current map by supplying another layer with addPolygons. The highlightOptions argument allows us to control how a county appears when hovered over, and the label argument builds strings for tooltip display with column references to the input dataset.

my_map <-
  my_map %>%
  
  # Add county outlines
  addPolygons(
    data = county_outlines,
    color = "black",
    fillColor = "#ff59f7",
    weight = 1,
    opacity = .5,
    fillOpacity = .35,
    highlightOptions = 
      highlightOptions(
        color = "black",
        weight = 3,
        bringToFront = FALSE
      ),
    label = ~NAME
  )

my_map

5. Show data points

The data points representing the hospitals can be added as yet another layer to the map. We want the color of the data points to reflect the amount of payment penalty the hospital received, so we’ll first create a color palette ranging from 0% (no penalty) to 3% (maximum penalty).

# Create a color pallete
pal <- 
  colorNumeric(
    palette = "RdYlGn",
    domain = -1*seq(0,3,.1)
  )

Then we will use the shared data that was created in a prior section to add the map layer with addCircleMarkers.

# Add shared data to the map
my_map %>%
  
  addCircleMarkers(
    data = sd, 
    lng = ~lon,
    lat = ~lat,
    label = ~paste0(FacilityName, " (click for info)"),
    popup = 
      ~paste0(
        "Hospital: ", FacilityName, 
        "<br>Zip Code: ", Zip,
        "<br>Peer Group: ", PeerGroup,
        "<br>Dual-Eligibility: ", round(DualProportion*100, 2), "%",
        "<br>Penalties: ", PenalizedCohorts,
        "<br>Payment Reduction: ", Penalty, "%"
      ),
    color = ~pal(-1*Penalty),
    fillOpacity = .75
  )

6. Make filters

A row of filters (and/or other objects) can be built from a call to the bscols function in the crosstalk package. This enables us to align objects in a grid across columns within rows. The first row is the widget title/header, which is just styled HTML text:

# Header
bscols(
  widths = c(1, 11),
  "",
  htmltools::h3("Hospital Readmissions Reduction Program (HRRP) FY 2022 - WI Map", style = "font-style:italic;")
)

Hospital Readmissions Reduction Program (HRRP) FY 2022 - WI Map

The next row is the slider selector for filtering the penalty amount. This is built with the filter_slider function. We feed it the SharedData object previously created so that changes to the filter trigger changes to the map, which has also been supplied with the same object.

# Penalty filter
bscols(
  widths = c(1, 10, 1),
  "",
  filter_slider(
    id = "penalty",
    label = "Payment Reduction",
    sharedData = sd,
    column = ~Penalty,
    step = 0.1,
    min = 0,
    max = 3,
    post = "%"
  ),
  ""
)

Similarly, the next set of rows consist of a sub-header to title the cluster of filters, and two (2) rows of dropdown selectors for each of the six (6) cohort indicators, which are analogously built with calls to filter_select:

# Sub-header for cohort filters
bscols(
  widths = c(1, 11),
  "",
  "Cohort-specific penalty:"
)
Cohort-specific penalty:
# Cohort filters: Row 1
bscols(
  widths = c(1, 3, 3, 3, 1),
  "",
  filter_select(
    id = "ami",
    label = "AMI",
    sharedData = sd,
    group = ~AMI
  ),
  filter_select(
    id = "cabg",
    label = "CABG",
    sharedData = sd,
    group = ~CABG
  ),
  filter_select(
    id = "copd",
    label = "COPD",
    sharedData = sd,
    group = ~COPD
  ),
  ""
)
# Cohort filters: Row 2
bscols(
  widths = c(1, 3, 3, 3, 1),
  "",
  filter_select(
    id = "hf",
    label = "HF",
    sharedData = sd,
    group = ~HF
  ),
  filter_select(
    id = "pneu",
    label = "PNEU",
    sharedData = sd,
    group = ~pneumonia
  ),
  filter_select(
    id = "tja",
    label = "THA/TKA",
    sharedData = sd,
    group = ~`THA/TKA`
  ),
  ""
)

7. Format order of appearance

Notice that when you adjust the filters in the last section, they change the map from step 5. This is because they are all referencing the same SharedData object. So, our final step is that we simply need to rearrange the execution order of the code so that the objects appear in the relative positions we would like. The code chunk creating the following output is a copy-paste of what we executed in steps 2-6, just with some rearranging. We will set echo = FALSE in the code chunk options so that only the output renders together. Note that we created a different SharedData object here so that the filters in the prior steps don’t impact this output.

Hospital Readmissions Reduction Program (HRRP) FY 2022 - WI Map

Cohort-specific penalty:
Alex Zajichek
Alex Zajichek
Statistician & Data Scientist
comments powered by Disqus

Related