knitr::opts_chunk$set(echo = TRUE)
library(dplyr)
library(ggplot2)
library(tidyr)

Synopsis

This analysis uses the NOAA Storm Database to identify which types of severe weather are most harmful to population health and which carry the greatest economic cost in the United States. Records before 1996 are excluded because NWS Directive 10-1605 expanded reporting to all 48 event types only in that year; earlier records contain almost exclusively tornado, hail, and thunderstorm wind events and would bias any comparison across event types. Damage figures were converted to dollars, and one 2006 flood record coded in billions rather than millions was corrected. The free-text event labels were consolidated into 21 categories using pattern matching, with unmatched records verified to account for less than 0.1% of casualties and damage. Tornadoes produce the most casualties overall, at roughly 22,000 killed or injured. Excessive heat is nonetheless the deadliest single event type, causing 2,037 fatalities compared with 1,513 for tornadoes. Hurricanes cause the greatest economic damage at approximately 87 billion, followed by storm surge and flooding. Drought is an outlier in kind rather than scale: it causes negligible property damage but is the largest single source of crop loss at $13.4 billion

Data Processing

First we load the data into R

storm <- read.csv("repdata_data_StormData.csv.bz2")

Next we subset the data in order to not work with so many unneeded columns:

storm <- storm %>% select(REFNUM, BGN_DATE, STATE, EVTYPE, FATALITIES, INJURIES, PROPDMG, PROPDMGEXP, CROPDMG, CROPDMGEXP)

Because we didn’t have all the events types pre-1996, I decided to only analyze based on data we have all event types for

storm$year <- as.integer(format(as.Date(storm$BGN_DATE, format = "%m/%d/%Y"), "%Y"))
storm <- storm %>% filter(year >= 1996)
nrow(storm)
## [1] 653530

Next we want to convert the damage numbers to dollars

Damage is recorded across two columns per category: a numeric coefficient in PROPDMG and CROPDMG, and a separate magnitude character in PROPDMGEXP and CROPDMGEXP, where K, M, and B denote thousands, millions, and billions. The coefficient on its own is not interpretable — a value of 5 means five thousand dollars or five million depending on the accompanying character — so the two must be combined before any totals are computed.

Within the 1996 onward subset the magnitude columns are almost entirely clean:

table(storm$PROPDMGEXP)
## 
##             0      B      K      M 
## 276185      1     32 369938   7374
table(storm$CROPDMGEXP)
## 
##             B      K      M 
## 373069      4 278686   1771

Only K, M, B, and blanks appear, apart from a single property record carrying a literal “0”, which is treated as tens and is too small to affect any total. Blank entries correspond to rows reporting no damage at all, so mapping them to zero discards nothing:

sum(storm$PROPDMGEXP == "" & storm$PROPDMG > 0)
## [1] 0
sum(storm$CROPDMGEXP == "" & storm$CROPDMG > 0)
## [1] 0

Both are zero, confirming that a blank magnitude always accompanies a zero coefficient.

The conversion below also handles H (hundreds), lower-case entries, and the undocumented symbols that appear in the pre-1996 portion of the database. None of those occur in the filtered data, but retaining the branches means the function gives correct results if applied to the full record.

exp_mult <- function(e) {
  e <- toupper(trimws(as.character(e)))
  case_when(
    e == "B" ~ 1e9,
    e == "M" ~ 1e6,
    e == "K" ~ 1e3,
    e == "H" ~ 1e2,
    e %in% as.character(0:8) ~ 1e1,
    TRUE ~ 0
  )
}

storm <- storm %>%
  mutate(propcost  = PROPDMG * exp_mult(PROPDMGEXP),
         cropcost  = CROPDMG * exp_mult(CROPDMGEXP),
         totalcost = propcost + cropcost)

With the damage converted to dollars, the next step is to remove records with no casualties and no damage.

impact <- storm %>%
  filter(FATALITIES > 0 | INJURIES > 0 | totalcost > 0)

nrow(impact)
## [1] 201318
length(unique(impact$EVTYPE))
## [1] 222

Next we need to clean up the EVTYPE column, as the values aren’t consistent.

normalize <- function(x) {
  x <- toupper(trimws(x))
  x <- gsub("[^A-Z ]", " ", x)
  x <- gsub("\\s+", " ", x)
  trimws(x)
}

impact <- impact %>%
  mutate(ev = normalize(EVTYPE),
         event = case_when(
           grepl("TORNADO|TORNDAO|FUNNEL|WATERSPOUT", ev) ~ "Tornado",
           grepl("HURRICANE|TYPHOON",                 ev) ~ "Hurricane",
           grepl("TROPICAL STORM|TROPICAL DEPRESSION",ev) ~ "Tropical Storm",
           grepl("FLASH",                             ev) ~ "Flash Flood",
           grepl("FLOOD|FLD|HIGH WATER|RISING WATER", ev) ~ "Flood",
           grepl("TSTM|THUNDERSTORM|THUDERSTORM",     ev) ~ "Thunderstorm Wind",
           grepl("HAIL",                              ev) ~ "Hail",
           grepl("LIGHTNING|LIGHTING|LIGNTNING",      ev) ~ "Lightning",
           grepl("HEAT|WARM|RECORD HIGH|HYPERTHERMIA",ev) ~ "Excessive Heat",
           grepl("SNOW|BLIZZARD|ICE|ICY|SLEET|WINTER|WINTRY|GLAZE|FREEZING RAIN", ev) ~ "Winter Weather",
           grepl("COLD|CHILL|FROST|FREEZE|HYPOTHERMIA", ev) ~ "Cold",
           grepl("AVALANCH|AVALANCE",                 ev) ~ "Avalanche",
           grepl("WIND",                              ev) ~ "High Wind",
           grepl("FIRE",                              ev) ~ "Wildfire",
           grepl("DROUGHT|DRY|DRIEST",                ev) ~ "Drought",
           grepl("STORM SURGE|SURGE", ev)              ~ "Storm Surge",
           grepl("HEAVY RAIN|^RAIN|PRECIPITATION", ev) ~ "Heavy Rain",
           grepl("RIP CURRENT|SURF|SEAS|SWELL|TIDE|COASTAL|TSUNAMI|MARINE", ev) ~ "Surf & Coastal",
           grepl("FOG",                               ev) ~ "Fog",
           grepl("DUST",                              ev) ~ "Dust",
           grepl("SLIDE|SLUMP|EROSION",               ev) ~ "Landslide",
           TRUE ~ "Other"
         ))

Then ran this to confirm unmatched rows were insignificant:

impact %>% summarise(across(c(FATALITIES, INJURIES, totalcost), ~ sum(.x[event == "Other"]) / sum(.x)))
##     FATALITIES     INJURIES    totalcost
## 1 0.0006871278 0.0008451919 9.565891e-06

All three proportions are very low, confirming that events which matched no pattern contribute negligibly to casualties and damage and can be left grouped as “Other.”

I think one record was coded incorrectly. It is coded with a magnitude B, giving property damage of $115 billion, which is larger than every other flood in the database combined. The accompanying NWS narrative describes damage in the tens of millions, indicating M was intended. This shows the outlier:

impact %>% arrange(desc(propcost)) %>%
  select(REFNUM, year, STATE, EVTYPE, PROPDMG, PROPDMGEXP, propcost) %>%
  head(3)
##   REFNUM year STATE            EVTYPE PROPDMG PROPDMGEXP  propcost
## 1 605943 2006    CA             FLOOD  115.00          B 1.150e+11
## 2 577616 2005    LA       STORM SURGE   31.30          B 3.130e+10
## 3 577615 2005    LA HURRICANE/TYPHOON   16.93          B 1.693e+10

And this corrects it:

impact <- impact %>%
  mutate(propcost  = ifelse(REFNUM == 605943, propcost / 1000, propcost),
         totalcost = propcost + cropcost)

Results

Events most harmful to population health

health <- impact %>%
  group_by(event) %>%
  summarise(fatalities = sum(FATALITIES),
            injuries   = sum(INJURIES),
            casualties = fatalities + injuries) %>%
  arrange(desc(casualties))

head(health, 10)
## # A tibble: 10 × 4
##    event             fatalities injuries casualties
##    <chr>                  <dbl>    <dbl>      <dbl>
##  1 Tornado                 1513    20670      22183
##  2 Excessive Heat          2037     7702       9739
##  3 Flood                    453     6846       7299
##  4 Thunderstorm Wind        398     5164       5562
##  5 Lightning                651     4141       4792
##  6 Winter Weather           548     3569       4117
##  7 Flash Flood              887     1674       2561
##  8 High Wind                387     1508       1895
##  9 Surf & Coastal           735      888       1623
## 10 Wildfire                  87     1458       1545
health %>%
  slice_max(casualties, n = 10) %>%
  pivot_longer(c(fatalities, injuries), names_to = "type", values_to = "n") %>%
  ggplot(aes(reorder(event, n), n, fill = type)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~type, scales = "free_x") +
  coord_flip() +
  labs(x = NULL, y = "Persons affected",
       title = "US storm casualties by event type, 1996-2011",
       caption = "Note differing x-axis scales") +
  theme_minimal()

Tornadoes account for the largest number of casualties overall, with roughly 22,000 people killed or injured between 1996 and 2011, which is more than twice the next category. The ranking changes if deaths alone are considered: excessive heat caused 2,037 fatalities against 1,513 for tornadoes, making it the single deadliest event type despite producing about a third as many injuries. Flooding, thunderstorm wind, and lightning are the next three largest groups, each contributing several thousand casualties.

Events with the greatest economic consequences

econ <- impact %>%
  group_by(event) %>%
  summarise(property = sum(propcost),
            crop     = sum(cropcost),
            total    = property + crop) %>%
  arrange(desc(total))

head(econ, 10)
## # A tibble: 10 × 4
##    event                property        crop       total
##    <chr>                   <dbl>       <dbl>       <dbl>
##  1 Hurricane         81718889010  5350107800 87068996810
##  2 Storm Surge       47834724000      855000 47835579000
##  3 Flood             29658573760  5013161500 34671735260
##  4 Tornado           24622810010   283425010 24906235020
##  5 Hail              14595237420  2496822450 17092059870
##  6 Flash Flood       15222268910  1334901700 16557170610
##  7 Drought            1047833600 13367581000 14415414600
##  8 Thunderstorm Wind  7919453280  1016992600  8936445880
##  9 Tropical Storm     7644212550   677711000  8321923550
## 10 Wildfire           7760449500   402255130  8162704630
econ %>%
  slice_max(total, n = 10) %>%
  pivot_longer(c(property, crop), names_to = "type", values_to = "usd") %>%
  ggplot(aes(reorder(event, usd), usd / 1e9, fill = type)) +
  geom_col(show.legend = FALSE) +
  facet_wrap(~type, scales = "free_x") +
  coord_flip() +
  labs(x = NULL, y = "Damage (billions USD)",
       title = "US storm damage by event type, 1996-2011",
       caption = "Note differing x-axis scales") +
  theme_minimal()

Hurricanes are the costliest event type, at approximately 87 billion in combined property and crop damage, followed by storm surge at 47.8 billion and flooding at 34.7 billion. Because storm surge and tropical storms are themselves consequences of tropical cyclones, the true cost attributable to tropical systems is closer to 143 billion once those categories are combined. Drought is the clearest case for reporting property and crop damage separately: it ranks only seventh on total damage and causes almost no structural loss, yet at 13.4 billion it is by a wide margin the largest source of crop damage in the dataset.