This analysis examines the U.S. National Oceanic and Atmospheric Administration (NOAA) Storm Database to determine which types of severe weather event are most harmful to population health and which carry the greatest economic consequences across the United States. Population health impact is measured as the combined number of fatalities and injuries; economic impact is measured as the sum of reported property and crop damage, after applying the damage exponent codes stored in the database. The raw event type field contains close to a thousand free-text variants, so near-duplicate labels were collapsed into a smaller set of consistent categories before aggregation. Events were then ranked by total impact and the ten largest contributors in each domain were plotted. Tornadoes emerge as by far the most harmful event type for population health, causing more combined casualties than the next several categories together. Flooding produces the largest economic losses, followed by hurricanes and tornadoes. A small number of extreme damage records exert a strong influence on the economic ranking, and this is discussed as a limitation.
library(dplyr)
library(ggplot2)
library(knitr)
The NOAA Storm Database is read directly from the original compressed
StormData.csv.bz2 archive. The file is only downloaded if
it is not already present in the working directory, so that repeated
knitting does not re-fetch roughly 47 MB each time. No preprocessing is
carried out outside R.
if (!file.exists("StormData.csv.bz2")) {
download.file(
"https://d396qusza40orc.cloudfront.net/repdata%2Fdata%2FStormData.csv.bz2",
destfile = "StormData.csv.bz2",
method = "auto",
mode = "wb"
)
}
storm_data <- read.csv("StormData.csv.bz2", stringsAsFactors = FALSE)
dim(storm_data)
## [1] 902297 37
Only seven of the 37 variables are required for this analysis: the event type, the two casualty counts, and the two damage amounts with their accompanying exponent codes.
str(storm_data[, c("EVTYPE", "FATALITIES", "INJURIES",
"PROPDMG", "PROPDMGEXP", "CROPDMG", "CROPDMGEXP")])
## 'data.frame': 902297 obs. of 7 variables:
## $ EVTYPE : chr "TORNADO" "TORNADO" "TORNADO" "TORNADO" ...
## $ FATALITIES: num 0 0 0 0 0 0 0 0 1 0 ...
## $ INJURIES : num 15 0 2 2 2 6 1 0 14 0 ...
## $ PROPDMG : num 25 2.5 25 2.5 2.5 2.5 2.5 2.5 25 25 ...
## $ PROPDMGEXP: chr "K" "K" "K" "K" ...
## $ CROPDMG : num 0 0 0 0 0 0 0 0 0 0 ...
## $ CROPDMGEXP: chr "" "" "" "" ...
length(unique(storm_data$EVTYPE))
## [1] 985
EVTYPE is free text and contains a large number of
near-duplicate labels. Left untreated, this splits single phenomena
classifications across several rows and distorts the ranking: for
example TSTM WIND and THUNDERSTORM WIND are
recorded separately, as are HEAT and
EXCESSIVE HEAT, and HURRICANE and
HURRICANE/TYPHOON.This apply approaches introduced in this
and other course modules.
The function below normalises case and whitespace, then assigns each
record to the first matching category in an ordered list of rules.
Ordering matters: more specific patterns are tested before more general
ones, so that FLASH FLOOD is not subsequently absorbed by
the broader FLOOD rule. Any record matching no rule keeps
its original (uppercased) label.
clean_evtype <- function(x) {
ev <- gsub("[[:space:]]+", " ", toupper(trimws(x)))
out <- rep(NA_character_, length(ev))
rule <- function(out, pattern, label) {
hit <- is.na(out) & grepl(pattern, ev)
out[hit] <- label
out
}
# Most specific patterns first
out <- rule(out, "FLASH FLOOD", "FLASH FLOOD")
out <- rule(out, "STORM SURGE|COASTAL FLOOD|TIDAL FLOOD", "STORM SURGE/COASTAL FLOOD")
out <- rule(out, "TSTM|THUNDERSTORM", "THUNDERSTORM WIND")
out <- rule(out, "HURRICANE|TYPHOON", "HURRICANE/TYPHOON")
out <- rule(out, "TROPICAL STORM", "TROPICAL STORM")
out <- rule(out, "TORNADO|TORNDAO", "TORNADO")
out <- rule(out, "HEAT|WARM|HIGH TEMPERATURE", "EXCESSIVE HEAT")
out <- rule(out, "COLD|WIND CHILL|LOW TEMPERATURE|HYPOTHERMIA", "EXTREME COLD")
out <- rule(out, "FROST|FREEZE", "FROST/FREEZE")
out <- rule(out, "BLIZZARD|SNOW|WINTER|ICE STORM|WINTRY|FREEZING RAIN|SLEET", "WINTER WEATHER")
out <- rule(out, "FLOOD|FLD", "FLOOD")
out <- rule(out, "LIGHTNING|LIGHTING|LIGNTNING", "LIGHTNING")
out <- rule(out, "HAIL", "HAIL")
out <- rule(out, "RIP CURRENT", "RIP CURRENT")
out <- rule(out, "FIRE", "WILDFIRE")
out <- rule(out, "DROUGHT", "DROUGHT")
out <- rule(out, "HIGH WIND|STRONG WIND|GUSTY WIND", "HIGH WIND")
out <- rule(out, "FOG", "FOG")
out <- rule(out, "AVALANCE|AVALANCHE", "AVALANCHE")
out <- rule(out, "HEAVY RAIN|EXCESSIVE RAIN|RAINFALL", "HEAVY RAIN")
out[is.na(out)] <- ev[is.na(out)]
out
}
storm_data$EVENT <- clean_evtype(storm_data$EVTYPE)
length(unique(storm_data$EVENT))
## [1] 307
Fatalities and injuries are summed for each cleaned event category and combined into a single measure of total harm.
health_data <- storm_data %>%
group_by(EVENT) %>%
summarise(
Fatalities = sum(FATALITIES, na.rm = TRUE),
Injuries = sum(INJURIES, na.rm = TRUE),
TotalHarm = Fatalities + Injuries,
.groups = "drop"
) %>%
arrange(desc(TotalHarm))
top_health <- head(health_data, 10)
kable(top_health,
format.args = list(big.mark = ","),
caption = "Table 1. Ten event types with the greatest population health impact.")
| EVENT | Fatalities | Injuries | TotalHarm |
|---|---|---|---|
| TORNADO | 5,636 | 91,407 | 97,043 |
| EXCESSIVE HEAT | 3,178 | 9,243 | 12,421 |
| THUNDERSTORM WIND | 754 | 9,544 | 10,298 |
| FLOOD | 512 | 6,873 | 7,385 |
| WINTER WEATHER | 632 | 5,952 | 6,584 |
| LIGHTNING | 817 | 5,231 | 6,048 |
| FLASH FLOOD | 1,035 | 1,802 | 2,837 |
| HIGH WIND | 421 | 1,796 | 2,217 |
| WILDFIRE | 90 | 1,608 | 1,698 |
| HURRICANE/TYPHOON | 135 | 1,333 | 1,468 |
Damage amounts are split across two columns. PROPDMG and CROPDMG hold the number, and PROPDMGEXP and CROPDMGEXP hold a letter or digit saying what scale that number is on. A value of 25 with a code of K means 25 thousand dollars, so the two have to be combined before anything can be added up.
sort(unique(storm_data$PROPDMGEXP))
## [1] "" "-" "?" "+" "0" "1" "2" "3" "4" "5" "6" "7" "8" "B" "h" "H" "K" "m" "M"
sort(unique(storm_data$CROPDMGEXP))
## [1] "" "?" "0" "2" "B" "k" "K" "m" "M"
A few records carry codes that aren’t documented anywhere — blanks, -, + and ? — and these hold so little damage between them that treating them as a multiplier of one makes no practical difference to the totals.
mult_map <- c("H" = 1e2, "K" = 1e3, "M" = 1e6, "B" = 1e9,
"0" = 1, "1" = 1e1, "2" = 1e2, "3" = 1e3, "4" = 1e4,
"5" = 1e5, "6" = 1e6, "7" = 1e7, "8" = 1e8, "9" = 1e9)
get_multiplier <- function(x) {
m <- unname(mult_map[toupper(trimws(as.character(x)))])
m[is.na(m)] <- 1
m
}
storm_data$PROP_DAMAGE <- storm_data$PROPDMG * get_multiplier(storm_data$PROPDMGEXP)
storm_data$CROP_DAMAGE <- storm_data$CROPDMG * get_multiplier(storm_data$CROPDMGEXP)
# Validation: no missing values introduced, and magnitudes are plausible
sum(is.na(storm_data$PROP_DAMAGE))
## [1] 0
sum(is.na(storm_data$CROP_DAMAGE))
## [1] 0
summary(storm_data$PROP_DAMAGE)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.000e+00 0.000e+00 0.000e+00 4.746e+05 5.000e+02 1.150e+11
summary(storm_data$CROP_DAMAGE)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.000e+00 0.000e+00 0.000e+00 5.442e+04 0.000e+00 5.000e+09
economic_data <- storm_data %>%
group_by(EVENT) %>%
summarise(
PropertyDamage = sum(PROP_DAMAGE, na.rm = TRUE),
CropDamage = sum(CROP_DAMAGE, na.rm = TRUE),
TotalDamage = PropertyDamage + CropDamage,
.groups = "drop"
) %>%
arrange(desc(TotalDamage))
top_economic <- head(economic_data, 10)
kable(
top_economic %>%
mutate(across(PropertyDamage:TotalDamage, ~ round(.x / 1e9, 2))),
col.names = c("Event", "Property damage (bn USD)", "Crop damage (bn USD)", "Total damage (bn USD)"),
caption = "Table 2. Ten event types with the greatest economic impact, in billions of dollars."
)
| Event | Property damage (bn USD) | Crop damage (bn USD) | Total damage (bn USD) |
|---|---|---|---|
| FLOOD | 150.24 | 10.86 | 161.10 |
| HURRICANE/TYPHOON | 85.36 | 5.52 | 90.87 |
| TORNADO | 57.00 | 0.41 | 57.42 |
| STORM SURGE/COASTAL FLOOD | 48.40 | 0.00 | 48.40 |
| FLASH FLOOD | 17.59 | 1.53 | 19.12 |
| HAIL | 15.98 | 3.05 | 19.02 |
| WINTER WEATHER | 12.42 | 5.32 | 17.74 |
| DROUGHT | 1.05 | 13.97 | 15.02 |
| THUNDERSTORM WIND | 12.78 | 1.27 | 14.06 |
| WILDFIRE | 8.50 | 0.40 | 8.90 |
Across the full record, TORNADO is the event type most harmful to population health, accounting for 97,043 combined fatalities and injuries — more than the next 9 categories put together. Once the near-duplicate labels are collapsed, excessive heat and thunderstorm wind rank second and third; both are substantially larger than they appear in the uncleaned data, where each is split across two or more event codes. Fatalities and injuries behave differently: tornadoes and thunderstorm wind generate very high injury counts relative to deaths, whereas excessive heat has a markedly higher fatality share.
ggplot(top_health,
aes(x = reorder(EVENT, TotalHarm), y = TotalHarm)) +
geom_col(fill = "steelblue") +
coord_flip() +
scale_y_continuous(labels = function(x) format(x, big.mark = ",")) +
labs(
title = "Top 10 weather events by impact on population health",
subtitle = "Combined fatalities and injuries, NOAA Storm Database 1950-2011",
x = "Event type",
y = "Total fatalities and injuries"
) +
theme_minimal()
FLOOD produces the greatest overall economic loss, at approximately $161.1 billion in combined property and crop damage. Hurricanes and typhoons, tornadoes and storm surge follow. The composition of the loss varies considerably by event type: flooding, hurricanes and tornadoes are overwhelmingly property-damage events, whereas drought is almost entirely a crop-damage event and would not appear in a property-only ranking.
ggplot(top_economic,
aes(x = reorder(EVENT, TotalDamage), y = TotalDamage / 1e9)) +
geom_col(fill = "darkgreen") +
coord_flip() +
labs(
title = "Top 10 weather events by economic damage",
subtitle = "Property and crop damage combined, NOAA Storm Database 1950-2011",
x = "Event type",
y = "Total damage (billions of dollars)"
) +
theme_minimal()
Three caveats are made in relation to the results above.
First, one record makes a lot of difference to the economic ranking. The biggest property damage entry in the whole database is a Napa County flood in 2006, recorded as $115 billion. The remarks field for that record describes much smaller losses, and the exponent code looks like a typo: B where M was meant. That single row is $115bn of the $150bn flood total. Correct it and flooding drops to around $46bn, which puts hurricanes and typhoons top instead. I have left it in so the analysis matches the database as published, but flooding only comes first because of it.
storm_data %>%
filter(PROP_DAMAGE == max(PROP_DAMAGE)) %>%
select(BGN_DATE, STATE, EVTYPE, PROPDMG, PROPDMGEXP, PROP_DAMAGE)
## BGN_DATE STATE EVTYPE PROPDMG PROPDMGEXP PROP_DAMAGE
## 1 1/1/2006 0:00:00 CA FLOOD 115 B 1.15e+11
Second, recording practice changes over time. Only tornadoes were recorded in the earliest years of the database, with the full range of event types captured from 1996 onwards. Totals accumulated over the whole period therefore overstate tornadoes relative to categories that entered the record later.
Third, damage figures are nominal and have not been adjusted for inflation, so losses from recent decades carry disproportionate weight in a series running from 1950 to 2011.