This study analyzes the National Weather Service (NWS) Storm Data set to examine the relationships between weather events and their annual impact on health and economic damage across the United States. Annual fatalities, injury counts, and property damage are investigated via box-plots. A non-parametrics Kruskal-Walis test is applied to examine the relative impact of the worst weather event types. Our results show that Tornadoes pose a statistically significant higher risk to human health compared to the second leading cause of health-related impacts, namely heat (p-value < 0.05). Additionally, floods and thunderstorm winds are identified as the primary contributors to economic damage.
Raw data from the National Weather Service is used for the analysis (access date: 2025-06-02) and is stored in the “ProjectData” directory. Documentation of the data set is also available (access date: 2025-06-02).
# Create data directory
if (!dir.exists("ProjectData")) {
dir.create("ProjectData")
}
# Load the data
download.file("https://d396qusza40orc.cloudfront.net/repdata%2Fdata%2FStormData.csv.bz2",
"storm_data.csv.bz2")
data_set_raw <- read.csv(bzfile("ProjectData/storm_data.csv.bz2"))
The possible weather event types according to the documentation are manually retrieved.
doc_event_types <- c(
"ASTRONOMICAL LOW TIDE", "AVALANCHE", "BLIZZARD", "COASTAL FLOOD",
"COLD/WIND CHILL", "DEBRIS FLOW", "DENSE FOG", "DENSE SMOKE", "DROUGHT",
"DUST DEVIL", "DUST STORM", "EXCESSIVE HEAT", "EXTREME COLD/WIND CHILL",
"FLASH FLOOD", "FLOOD", "FROST/FREEZE", "FUNNEL CLOUD", "FREEZING FOG",
"HAIL", "HEAT", "HEAVY RAIN", "HEAVY SNOW", "HIGH SURF", "HIGH WIND",
"HURRICANE (TYPHOON)", "ICE STORM", "LAKE-EFFECT SNOW", "LAKESHORE FLOOD",
"LIGHTNING", "MARINE HAIL", "MARINE HIGH WIND", "MARINE STRONG WIND",
"MARINE THUNDERSTORM WIND", "RIP CURRENT", "SEICHE", "SLEET",
"STORM SURGE/TIDE", "STRONG WIND", "THUNDERSTORM WIND", "TORNADO",
"TROPICAL DEPRESSION", "TROPICAL STORM", "TSUNAMI", "VOLCANIC ASH",
"WATERSPOUT", "WILDFIRE", "WINTER STORM", "WINTER WEATHER"
)
doc_event_types_ordered <- doc_event_types[order(nchar(doc_event_types), decreasing = TRUE)]
A check is performed on the data set to confirm conformity to the documentation. According to the documentation, 48 types of weather events exists.
unique_event_types_raw <- unique(data_set_raw$EVTYPE)
However, 985 unique weather event types are detected in the data set. This is because the documentation allows event types to deviate from the standards. For example, measurements can be appended to the event type. Weather events may also consists of multiple events.
Before we process the data events, we first look at the important variables of this study. For this, we focus on the date of the weather event, fatalities and injuries that occurred and property damage.
Variable event_date is constructed and represents the date when a weather event ends. If this information is missing from the data set, we use the start date of the event. If the start date is missing or invalid, the data sample is discarded.
# Create the EVENT_DATE column
data_set_raw <- data_set_raw %>%
mutate(
# Clean the input
END_DATE_CLEAN = ifelse(END_DATE == "", NA, END_DATE),
BGN_DATE_CLEAN = ifelse(BGN_DATE == "", NA, BGN_DATE),
EVENT_DATE_RAW = ifelse(!is.na(END_DATE_CLEAN),
END_DATE_CLEAN, BGN_DATE_CLEAN),
# Convert to date
EVENT_DATE = as.Date(sub(" .*", "", EVENT_DATE_RAW),
format = "%m/%d/%Y")
)
# Only keep valid rows
data_set_clean_1 <- data_set_raw %>%
filter(!is.na(EVENT_DATE))
We exclude samples where the event type is missing. For records where fatalities, injuries, or property damage are ivnalid or missing, we assume that the intended value was zero.
# Clean the variables or interest
data_set_clean_2 <- data_set_clean_1 %>%
mutate(
# Convert fatalities to numeric if possible
# Set "" to NA, then set missing values to 0
FATALITIES = as.numeric(ifelse(FATALITIES == "", NA, FATALITIES)),
FATALITIES = ifelse(is.na(FATALITIES), 0, FATALITIES),
# Convert injuries to numeric if possible
# Set "" to NA, then set missing values to 0
INJURIES = as.numeric(ifelse(INJURIES == "", NA, INJURIES)),
INJURIES = ifelse(is.na(INJURIES), 0, INJURIES),
# Convert property damage to numeric if possible
# Set "" to NA, then set missing values to 0
PROPDMG = as.numeric(ifelse(PROPDMG == "", NA, PROPDMG)),
PROPDMG = ifelse(is.na(PROPDMG), 0, PROPDMG)
) %>%
# Remove rows with missing EVTYPE (NA or "")
filter(!is.na(EVTYPE) & EVTYPE != "")
# Pattern match or set to "Other" if no match is found.
matches_list <- lapply(data_set_clean_2$EVTYPE, function(x) {
# Pattern check
matches <- doc_event_types_ordered[
str_detect(x, fixed(doc_event_types_ordered, ignore_case = TRUE))
]
if (length(matches) > 0) {
matches
} else {
"Other"
}
})
# Store the results
data_set_clean_2$EVTYPE_MATCHES <- matches_list
The data samples are distributed as follows:
# Count how many times each doc_event_type occurs
long_df <- data_set_clean_2 %>%
mutate(row_id = row_number()) %>%
select(row_id, EVTYPE, EVTYPE_MATCHES) %>%
unnest(EVTYPE_MATCHES)
evtype_group_counts <- long_df %>%
group_by(EVTYPE_MATCHES) %>%
summarise(count = n()) %>%
arrange(desc(count))
print(evtype_group_counts)
## # A tibble: 47 × 2
## EVTYPE_MATCHES count
## <chr> <int>
## 1 HAIL 290401
## 2 Other 238109
## 3 THUNDERSTORM WIND 109446
## 4 FLOOD 82731
## 5 TORNADO 60700
## 6 FLASH FLOOD 55668
## 7 HIGH WIND 21953
## 8 HEAVY SNOW 15802
## 9 LIGHTNING 15776
## 10 HEAVY RAIN 11817
## # ℹ 37 more rows
Within the “Other” group, the distribution is as follows:
# Display the distribution
other_counts <- long_df %>%
filter(EVTYPE_MATCHES == "Other") %>%
count(EVTYPE) %>%
arrange(desc(n))
head(other_counts)
## # A tibble: 6 × 2
## EVTYPE n
## <chr> <int>
## 1 TSTM WIND 219940
## 2 MARINE TSTM WIND 6175
## 3 URBAN/SML STREAM FLD 3392
## 4 WILD/FOREST FIRE 1457
## 5 EXTREME COLD 655
## 6 LANDSLIDE 600
TSTM is by far the largest occurrence and has many counts. For this reason, we make TSTM a separate category and keep the rest in “Other”.
# Copy the data
data_set_clean <- data_set_clean_2
# Add a new category
doc_event_types_mod <- c(doc_event_types, "TSTM WIND")
doc_event_types_ordered_mod <- doc_event_types_mod[order(nchar(doc_event_types_mod), decreasing = TRUE)]
# Pattern match or set to "Other" if no match is found.
matches_list_clean <- lapply(data_set_clean$EVTYPE, function(x) {
# Pattern match
matches <- doc_event_types_ordered_mod[
str_detect(x, fixed(doc_event_types_ordered_mod, ignore_case = TRUE))
]
if (length(matches) > 0) {
matches
} else {
"Other"
}
})
# Store the results
data_set_clean$EVTYPE_MATCHES <- matches_list_clean
# Group by event
long_df_clean <- data_set_clean %>%
mutate(row_id = row_number()) %>%
select(row_id, EVTYPE, EVTYPE_MATCHES) %>%
unnest(EVTYPE_MATCHES)
# Count how many times each doc_event_type occurs
evtype_group_counts_clean <- long_df_clean %>%
group_by(EVTYPE_MATCHES) %>%
summarise(count = n()) %>%
arrange(desc(count))
head(evtype_group_counts_clean)
## # A tibble: 6 × 2
## EVTYPE_MATCHES count
## <chr> <int>
## 1 HAIL 290401
## 2 TSTM WIND 227230
## 3 THUNDERSTORM WIND 109446
## 4 FLOOD 82731
## 5 TORNADO 60700
## 6 FLASH FLOOD 55668
Finally, we construct the yearly injury counts and economic damage.
# Create the sum of injuries
data_set_clean$SUM_INJURIES <- data_set_clean$FATALITIES + data_set_clean$INJURIES
# Group by year
annual_summary <- data_set_clean %>%
mutate(YEAR = year(EVENT_DATE)) %>%
group_by(YEAR) %>%
summarise(
TOTAL_SUM_INJURIES = sum(SUM_INJURIES, na.rm = TRUE),
TOTAL_PROPDMG = sum(PROPDMG, na.rm = TRUE)
)
The figures below show box plots of the sum of the injuries and property damage for the top 10 weather events.
# Unpack samples with multiple events
df_long <- data_set_clean %>%
unnest(EVTYPE_MATCHES)
# Determine the annual sums
annual_sums_by_type <- df_long %>%
mutate(YEAR = year(EVENT_DATE)) %>%
group_by(EVTYPE_MATCHES, YEAR) %>%
summarise(
ANNUAL_SUM_INJURIES = sum(SUM_INJURIES, na.rm = TRUE),
.groups = "drop"
)
# Determine the top n-events
top_events <- annual_sums_by_type %>%
group_by(EVTYPE_MATCHES) %>%
summarise(MEDIAN_INJURIES = median(ANNUAL_SUM_INJURIES, na.rm = TRUE)) %>%
arrange(desc(MEDIAN_INJURIES)) %>%
slice_head(n = 10) %>%
pull(EVTYPE_MATCHES)
annual_sums_top_events <- annual_sums_by_type %>%
filter(EVTYPE_MATCHES %in% top_events)
# Plot the top n-events
ggplot(
annual_sums_top_events,
aes(
x = reorder(EVTYPE_MATCHES, ANNUAL_SUM_INJURIES, FUN = function(x) -median(x, na.rm = TRUE)),
y = ANNUAL_SUM_INJURIES
)
) +
geom_boxplot(outlier.size = 1) +
scale_y_log10() +
theme_bw() +
theme(
axis.text.x = element_text(angle = 45, hjust = 1),
panel.grid.major.x = element_blank()
) +
labs(
x = "Event Type",
y = "Annual Total Injuries",
title = "Boxplot of Annual Injuries by Top 10 Weather Events"
)
The boxplot above shows that Tornado, Heat, Exessive heat are the top 3 leading causes of health-related issues. A Kruskal-Wallis test is done below with alpha 0.05 and a Bonferroni correction of 2 to show statistical significance between Tornado and Heat:
top_two_events <- top_events[1:2]
# Filter for the top 2 events
top_two_data <- annual_sums_by_type %>%
filter(EVTYPE_MATCHES %in% top_two_events)
# Apply the Kruskal-Wallis test
kw_result <- kruskal.test(
ANNUAL_SUM_INJURIES ~ EVTYPE_MATCHES,
data = top_two_data
)
# Apply Bonferroni correction
p_value_corrected_1 <- min(kw_result$p.value * 2, 1)
The Bonferroni-corrected p-value is 8.6069185^{-5} indicating statistical signficance.
The figure below show a box plot of the sum of property damage of the top 10 weather events.
# Unpack samples with multiple events
df_long <- data_set_clean %>%
unnest(EVTYPE_MATCHES)
# Summarize annual property damage totals by event type
annual_sums_by_type <- df_long %>%
mutate(YEAR = year(EVENT_DATE)) %>%
group_by(EVTYPE_MATCHES, YEAR) %>%
summarise(
ANNUAL_SUM_PROPDMG = sum(PROPDMG, na.rm = TRUE),
.groups = "drop"
)
# Determine the top n-events
top_events <- annual_sums_by_type %>%
group_by(EVTYPE_MATCHES) %>%
summarise(MEDIAN_PROPDMG = median(ANNUAL_SUM_PROPDMG, na.rm = TRUE)) %>%
arrange(desc(MEDIAN_PROPDMG)) %>%
slice_head(n = 10) %>%
pull(EVTYPE_MATCHES)
annual_sums_top_events <- annual_sums_by_type %>%
filter(EVTYPE_MATCHES %in% top_events)
# Plot the top n-events
ggplot(
annual_sums_top_events,
aes(
x = reorder(EVTYPE_MATCHES, ANNUAL_SUM_PROPDMG, FUN = function(x) -median(x, na.rm = TRUE)),
y = ANNUAL_SUM_PROPDMG
)
) +
geom_boxplot(outlier.size = 1) +
scale_y_log10() +
theme_bw() +
theme(
axis.text.x = element_text(angle = 45, hjust = 1),
panel.grid.major.x = element_blank()
) +
labs(
x = "Weather Event Type",
y = "Annual Total Property Damage",
title = "Boxplot of Annual Property Damage by Top 10 Weather Events"
)
The boxplot above shows that floods, thunderstorm wind and flash floods are the top 3 leading causes to economic damage. A Kruskal-Wallis test is done below with alpha 0.05 and a Bonferroni correction of 2 to show statistical significance between floods and thunderstorm winds:
top_two_events <- top_events[1:2]
# Filter annual_sums_by_type for the top 2 events
top_two_data <- annual_sums_by_type %>%
filter(EVTYPE_MATCHES %in% top_two_events)
# Kruskal-Wallis test
kw_result <- kruskal.test(
ANNUAL_SUM_PROPDMG ~ EVTYPE_MATCHES,
data = top_two_data
)
# Apply Bonferroni correction
p_value_corrected_2 <- min(kw_result$p.value * 2, 1)
The Bonferroni-corrected p-value is 1, which does not indicate a statistical difference between floods and thunderstorm winds.
In this document, a data processing method was described that uses pattern matching to reclassify event types in the data set to events defined in the Documentation. Note that some weather event types listed in the documentation are substrings of other event types, e.g., “COLD/WIND CHILL” is a substring of “EXTREME COLD/WIND CHILL”. Other overlapping weather event types include:
# Create all valid pair combinations
pairs <- expand.grid(i = seq_along(doc_event_types),
j = seq_along(doc_event_types)) %>%
filter(i != j)
# Check if doc_event_types[i] is a substring of doc_event_types[j]
overlaps <- pairs %>%
rowwise() %>%
mutate(
pattern = doc_event_types[i],
text = doc_event_types[j],
is_match = str_detect(text, fixed(pattern))
) %>%
ungroup() %>%
filter(is_match) %>%
transmute(
shorter = pattern,
longer = text
) %>%
distinct() %>%
arrange(shorter, longer)
# Display all overlapping pairs
print(overlaps)
## # A tibble: 9 × 2
## shorter longer
## <chr> <chr>
## 1 COLD/WIND CHILL EXTREME COLD/WIND CHILL
## 2 FLOOD COASTAL FLOOD
## 3 FLOOD FLASH FLOOD
## 4 FLOOD LAKESHORE FLOOD
## 5 HAIL MARINE HAIL
## 6 HEAT EXCESSIVE HEAT
## 7 HIGH WIND MARINE HIGH WIND
## 8 STRONG WIND MARINE STRONG WIND
## 9 THUNDERSTORM WIND MARINE THUNDERSTORM WIND