Fatal Bicycle Crash Analysis: Travel Lane versus Crossing Lane
#Analysis roadmap
This is the main expandable R Markdown file for the fatal bicycle crash study comparing Travel Lane and Crossing Lane crashes.
The file will later be extended with:
- Random Forest variable selection
- Final predictive Random Forest
- SHAP analysis
- Self-Organizing Map analysis
- SOM grid evaluation
- Ward hierarchical clustering
- Cluster interpretation
- Decision-tree explanation
- Spatial and hotspot analysis
- Shiny application development
The present version performs the Random Forest variable-selection stage only.
1 Project settings
PROJECT_DIR <- paste0(
"C:/Users/baitu/OneDrive - Texas State University/",
"Das, Subasish's files - Hackathon_Anika/",
"Papers/TRBAM 2027/",
"6. bike_Travel lane vs crossing_FARS/",
"02_Code"
)
DATA_FILE <- file.path(
PROJECT_DIR,
"Bike01_copy_no duplicate.xlsx"
)
SHEET_NAME <- "Lump"
TARGET_VARIABLE <- "Bicyclist Path Type"
RF_SEED <- 2027
RF_TREES <- 2000
RF_NODE_SIZE <- 1
# Used only to create a separate selected-variable table.
# All non-target variables are still included in the Random Forest.
N_SELECTED_VARIABLES <- 13
FONT_FAMILY <- "serif"
RF_PALETTE <- c(
"#CBB4A7",
"#A56A6C",
"#7C2A35",
"#4B1D24"
)
OUTPUT_DIR <- file.path(
PROJECT_DIR,
"Analysis_Output",
"01_RF_Variable_Selection"
)
dir.create(
OUTPUT_DIR,
recursive = TRUE,
showWarnings = FALSE
)2 Load main Random Forest data
# ============================================================
# LOAD MAIN RANDOM FOREST DATA
#
# This chunk restores bike_data before verify-variables.
# It uses the existing DATA_FILE and SHEET_NAME from Project settings.
# It does NOT use the annual distribution sheet and does NOT change
# the Random Forest analysis logic.
# ============================================================
required_packages_core <- c(
"readxl",
"dplyr",
"stringr",
"tibble",
"randomForest",
"ggplot2",
"scales",
"writexl"
)
packages_to_install_core <- required_packages_core[
!required_packages_core %in% rownames(installed.packages())
]
if (length(packages_to_install_core) > 0) {
install.packages(
packages_to_install_core,
repos = "https://cloud.r-project.org",
dependencies = TRUE
)
}
invisible(
lapply(
required_packages_core,
library,
character.only = TRUE
)
)
if (!file.exists(DATA_FILE)) {
stop(
paste0(
"Main Random Forest data file not found:\n",
DATA_FILE
)
)
}
available_sheets_main <- readxl::excel_sheets(DATA_FILE)
if (!SHEET_NAME %in% available_sheets_main) {
stop(
paste0(
"The main Random Forest sheet was not found: ",
SHEET_NAME,
"\nAvailable sheets are:\n",
paste(available_sheets_main, collapse = "\n")
)
)
}
bike_data <- readxl::read_excel(
path = DATA_FILE,
sheet = SHEET_NAME,
guess_max = 100000
)
cat("Main Random Forest data loaded successfully.\n")## Main Random Forest data loaded successfully.
## File: C:/Users/baitu/OneDrive - Texas State University/Das, Subasish's files - Hackathon_Anika/Papers/TRBAM 2027/6. bike_Travel lane vs crossing_FARS/02_Code/Bike01_copy_no duplicate.xlsx
## Sheet: Lump
## Rows: 8461
## Columns: 17
3 Verify the Excel variables
EXPECTED_VARIABLES <- c(
"Hit and Run",
"Road Relation",
"Weather",
"Lighting Condition",
"Rural/Urban",
"Bicyclist Path Type",
"Intersection Related",
"Trafficway Configuration",
"Road Alignment",
"Surface Condition",
"Vehicle Movement",
"Bicyclist age",
"Driver Age",
"Daytime",
"Vehicle Type",
"Speed Limit",
"Contributing Factors"
)
missing_variables <- setdiff(
EXPECTED_VARIABLES,
names(bike_data)
)
if (length(missing_variables) > 0) {
stop(
paste0(
"The following required Excel variable(s) are missing:\n",
paste(
missing_variables,
collapse = ", "
)
)
)
}
if (!identical(names(bike_data), EXPECTED_VARIABLES)) {
stop(
paste0(
"The variables or their order do not exactly match the Lump worksheet ",
"structure used to prepare this code."
)
)
}
knitr::kable(
tibble::tibble(
`Excel Variable Name` = names(bike_data)
),
caption = "Variables imported exactly as written in the Lump worksheet"
)| Excel Variable Name |
|---|
| Hit and Run |
| Road Relation |
| Weather |
| Lighting Condition |
| Rural/Urban |
| Bicyclist Path Type |
| Intersection Related |
| Trafficway Configuration |
| Road Alignment |
| Surface Condition |
| Vehicle Movement |
| Bicyclist age |
| Driver Age |
| Daytime |
| Vehicle Type |
| Speed Limit |
| Contributing Factors |
3.1 Annual Fatal Bicycle Crashes by Bicyclist Path Type
# ============================================================
# ANNUAL DISTRIBUTION FIGURE 1A: TRAVEL LANE
# This chunk is fully independent from Random Forest.
# It does NOT use bike_data, DATA_FILE, or SHEET_NAME.
# ============================================================
required_packages_annual <- c(
"readxl",
"dplyr",
"ggplot2",
"stringr"
)
packages_to_install_annual <- required_packages_annual[
!required_packages_annual %in% rownames(installed.packages())
]
if (length(packages_to_install_annual) > 0) {
install.packages(
packages_to_install_annual,
repos = "https://cloud.r-project.org",
dependencies = TRUE
)
}
invisible(
lapply(
required_packages_annual,
library,
character.only = TRUE
)
)
annual_file <- paste0(
"C:/Users/baitu/OneDrive - Texas State University/",
"Das, Subasish's files - Hackathon_Anika/",
"Papers/TRBAM 2027/",
"6. bike_Travel lane vs crossing_FARS/",
"02_Code/",
"Bike01_copy_no duplicate.xlsx"
)
annual_sheet <- "Sheet 1 (2)"
annual_data <- readxl::read_excel(
path = annual_file,
sheet = annual_sheet,
guess_max = 100000
)
travel_lane_summary <- annual_data |>
dplyr::mutate(
Annual_Year = as.integer(Year),
Annual_Path_Type = stringr::str_squish(
as.character(`Bicyclist Path Type`)
)
) |>
dplyr::filter(
!is.na(Annual_Year),
Annual_Path_Type == "Travel Lane"
) |>
dplyr::count(
Annual_Year,
name = "Crash_Count"
) |>
dplyr::arrange(
Annual_Year
) |>
dplyr::mutate(
Annual_Year = factor(
Annual_Year,
levels = sort(unique(Annual_Year))
)
)
travel_lane_plot <- ggplot2::ggplot(
travel_lane_summary,
ggplot2::aes(
x = Annual_Year,
y = Crash_Count,
group = 1
)
) +
ggplot2::geom_col(
width = 0.62,
fill = "#CBB4A7",
color = "black",
linewidth = 0.35
) +
ggplot2::geom_line(
color = "#8C6F62",
linewidth = 1.05
) +
ggplot2::geom_point(
color = "black",
fill = "#CBB4A7",
shape = 21,
size = 3.2,
stroke = 0.7
) +
ggplot2::geom_text(
ggplot2::aes(
label = Crash_Count
),
vjust = -0.45,
family = "serif",
fontface = "bold",
size = 4.0,
color = "black"
) +
ggplot2::scale_y_continuous(
limits = c(
0,
max(travel_lane_summary$Crash_Count, na.rm = TRUE) * 1.15
),
expand = c(0, 0)
) +
ggplot2::labs(
x = "Crash year",
y = "Number of fatal bicycle crashes"
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 14
) +
ggplot2::theme(
axis.title.x = ggplot2::element_text(
face = "bold",
size = 14,
color = "black"
),
axis.title.y = ggplot2::element_text(
face = "bold",
size = 14,
color = "black"
),
axis.text.x = ggplot2::element_text(
face = "bold",
size = 12,
color = "black"
),
axis.text.y = ggplot2::element_text(
size = 11,
color = "black"
),
panel.grid.major.x = ggplot2::element_blank(),
panel.grid.minor = ggplot2::element_blank(),
panel.grid.major.y = ggplot2::element_line(
color = "gray85",
linewidth = 0.3
)
)
print(travel_lane_plot)Figure 1a. Annual fatal bicycle crash counts in the Travel Lane group. Bars show yearly fatal crash counts, and the connected line shows the annual pattern across the study period.
annual_out_dir <- file.path(
dirname(annual_file),
"Analysis_Output",
"00_Descriptive_Year_Path_Type"
)
dir.create(
annual_out_dir,
recursive = TRUE,
showWarnings = FALSE
)
ggplot2::ggsave(
filename = file.path(
annual_out_dir,
"Figure_1a_Annual_Travel_Lane_Fatal_Bicycle_Crashes.png"
),
plot = travel_lane_plot,
width = 10.5,
height = 6.5,
dpi = 600,
bg = "white"
)# ============================================================
# ANNUAL DISTRIBUTION FIGURE 1B: CROSSING LANE
# This chunk is fully independent from Random Forest.
# It does NOT use bike_data, DATA_FILE, or SHEET_NAME.
# ============================================================
required_packages_annual <- c(
"readxl",
"dplyr",
"ggplot2",
"stringr"
)
invisible(
lapply(
required_packages_annual,
library,
character.only = TRUE
)
)
annual_file <- paste0(
"C:/Users/baitu/OneDrive - Texas State University/",
"Das, Subasish's files - Hackathon_Anika/",
"Papers/TRBAM 2027/",
"6. bike_Travel lane vs crossing_FARS/",
"02_Code/",
"Bike01_copy_no duplicate.xlsx"
)
annual_sheet <- "Sheet 1 (2)"
annual_data <- readxl::read_excel(
path = annual_file,
sheet = annual_sheet,
guess_max = 100000
)
crossing_lane_summary <- annual_data |>
dplyr::mutate(
Annual_Year = as.integer(Year),
Annual_Path_Type = stringr::str_squish(
as.character(`Bicyclist Path Type`)
)
) |>
dplyr::filter(
!is.na(Annual_Year),
Annual_Path_Type == "Crossing Lane"
) |>
dplyr::count(
Annual_Year,
name = "Crash_Count"
) |>
dplyr::arrange(
Annual_Year
) |>
dplyr::mutate(
Annual_Year = factor(
Annual_Year,
levels = sort(unique(Annual_Year))
)
)
crossing_lane_plot <- ggplot2::ggplot(
crossing_lane_summary,
ggplot2::aes(
x = Annual_Year,
y = Crash_Count,
group = 1
)
) +
ggplot2::geom_col(
width = 0.62,
fill = "#7C2A35",
color = "black",
linewidth = 0.35
) +
ggplot2::geom_line(
color = "#7C2A35",
linewidth = 1.05
) +
ggplot2::geom_point(
color = "black",
fill = "#7C2A35",
shape = 21,
size = 3.2,
stroke = 0.7
) +
ggplot2::geom_text(
ggplot2::aes(
label = Crash_Count
),
vjust = -0.45,
family = "serif",
fontface = "bold",
size = 4.0,
color = "black"
) +
ggplot2::scale_y_continuous(
limits = c(
0,
max(crossing_lane_summary$Crash_Count, na.rm = TRUE) * 1.25
),
expand = c(0, 0)
) +
ggplot2::labs(
x = "Crash year",
y = "Number of fatal bicycle crashes"
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 14
) +
ggplot2::theme(
axis.title.x = ggplot2::element_text(
face = "bold",
size = 14,
color = "black"
),
axis.title.y = ggplot2::element_text(
face = "bold",
size = 14,
color = "black"
),
axis.text.x = ggplot2::element_text(
face = "bold",
size = 12,
color = "black"
),
axis.text.y = ggplot2::element_text(
size = 11,
color = "black"
),
panel.grid.major.x = ggplot2::element_blank(),
panel.grid.minor = ggplot2::element_blank(),
panel.grid.major.y = ggplot2::element_line(
color = "gray85",
linewidth = 0.3
)
)
print(crossing_lane_plot)Figure 1b. Annual fatal bicycle crash counts in the Crossing Lane group. Bars show yearly fatal crash counts, and the connected line shows the annual pattern across the study period.
annual_out_dir <- file.path(
dirname(annual_file),
"Analysis_Output",
"00_Descriptive_Year_Path_Type"
)
dir.create(
annual_out_dir,
recursive = TRUE,
showWarnings = FALSE
)
ggplot2::ggsave(
filename = file.path(
annual_out_dir,
"Figure_1b_Annual_Crossing_Lane_Fatal_Bicycle_Crashes.png"
),
plot = crossing_lane_plot,
width = 10.5,
height = 6.5,
dpi = 600,
bg = "white"
)4 Prepare the data
No variable is excluded except the target variable. No predictor is assigned a manual importance value.
clean_text <- function(x) {
x <- as.character(x)
x <- stringr::str_squish(x)
x[
is.na(x) |
x == ""
] <- "Not Reported"
x
}
# Clean text spacing only. No categories are merged or renamed.
bike_data <- bike_data |>
dplyr::mutate(
dplyr::across(
where(is.character),
clean_text
)
)
approved_target_categories <- c(
"Travel Lane",
"Crossing Lane"
)
unexpected_target_categories <- setdiff(
unique(
bike_data[[TARGET_VARIABLE]]
),
approved_target_categories
)
if (length(unexpected_target_categories) > 0) {
stop(
paste0(
"Unexpected target category or categories found: ",
paste(
unexpected_target_categories,
collapse = ", "
)
)
)
}
# Explicit classification outcome.
rf_target <- factor(
bike_data[[TARGET_VARIABLE]],
levels = c(
"Travel Lane",
"Crossing Lane"
)
)
if (!is.factor(rf_target)) {
stop("The Random Forest outcome is not a factor.")
}
if (nlevels(rf_target) != 2) {
stop("The Random Forest outcome must contain exactly two classes.")
}
# Automatically use EVERY Excel variable except the target.
RF_PREDICTORS <- setdiff(
names(bike_data),
TARGET_VARIABLE
)
rf_predictor_data <- bike_data[
,
RF_PREDICTORS,
drop = FALSE
]
# The Lump worksheet contains recoded categorical predictors.
# Convert every predictor to a factor while preserving its original name.
rf_predictor_data[] <- lapply(
rf_predictor_data,
function(x) {
factor(
clean_text(x)
)
}
)
# Confirm that all 16 non-target variables are present.
if (ncol(rf_predictor_data) != ncol(bike_data) - 1) {
stop("Not all non-target variables entered the Random Forest.")
}
if (!setequal(
names(rf_predictor_data),
setdiff(
names(bike_data),
TARGET_VARIABLE
)
)) {
stop("The Random Forest predictor names do not match the Excel variables.")
}
cat(
"Target variable:",
TARGET_VARIABLE,
"\n"
)## Target variable: Bicyclist Path Type
## Number of predictors included: 16
## Predictors included:
## Hit and Run
## Road Relation
## Weather
## Lighting Condition
## Rural/Urban
## Intersection Related
## Trafficway Configuration
## Road Alignment
## Surface Condition
## Vehicle Movement
## Bicyclist age
## Driver Age
## Daytime
## Vehicle Type
## Speed Limit
## Contributing Factors
5 Outcome distribution
outcome_distribution <- tibble::tibble(
`Bicyclist Path Type` = rf_target
) |>
dplyr::count(
`Bicyclist Path Type`,
name = "Count"
) |>
dplyr::mutate(
Percentage = round(
100 * Count / sum(Count),
2
)
)
knitr::kable(
outcome_distribution,
caption = "Travel Lane and Crossing Lane outcome distribution"
)| Bicyclist Path Type | Count | Percentage |
|---|---|---|
| Travel Lane | 7484 | 88.45 |
| Crossing Lane | 977 | 11.55 |
6 Standard Random Forest variable selection
This is a standard classification Random Forest. It uses all observations and all 16 non-target variables. No class balancing, manual zero assignment, importance clipping, or predictor exclusion is applied.
set.seed(RF_SEED)
rf_mtry <- max(
1,
floor(
sqrt(
ncol(
rf_predictor_data
)
)
)
)
rf_model <- randomForest::randomForest(
x = rf_predictor_data,
y = rf_target,
ntree = RF_TREES,
mtry = rf_mtry,
nodesize = RF_NODE_SIZE,
importance = TRUE,
keep.forest = TRUE
)
cat("Model type: Classification\n")## Model type: Classification
## Number of trees: 2000
## mtry: 4
## Predictors used: 16
7 Original Random Forest importance
The original Random Forest importance outputs are retained:
- Mean Decrease Accuracy
- Mean Decrease Gini
The feature-importance figure uses the original Mean Decrease Gini values. No negative value is converted to zero, and no variable is given a manual importance value.
importance_matrix <- randomForest::importance(
rf_model,
scale = FALSE
)
required_importance_columns <- c(
"MeanDecreaseAccuracy",
"MeanDecreaseGini"
)
missing_importance_columns <- setdiff(
required_importance_columns,
colnames(
importance_matrix
)
)
if (length(missing_importance_columns) > 0) {
stop(
paste0(
"The following Random Forest importance column(s) are missing: ",
paste(
missing_importance_columns,
collapse = ", "
)
)
)
}
rf_importance <- tibble::tibble(
`Variable Name` = rownames(
importance_matrix
),
`Mean Decrease Accuracy` = as.numeric(
importance_matrix[
,
"MeanDecreaseAccuracy"
]
),
`Mean Decrease Gini` = as.numeric(
importance_matrix[
,
"MeanDecreaseGini"
]
)
) |>
dplyr::arrange(
dplyr::desc(
`Mean Decrease Gini`
)
) |>
dplyr::mutate(
`Normalized Gini Importance` = round(
100 *
`Mean Decrease Gini` /
max(
`Mean Decrease Gini`,
na.rm = TRUE
),
2
),
Rank = dplyr::row_number()
) |>
dplyr::select(
Rank,
`Variable Name`,
`Mean Decrease Accuracy`,
`Mean Decrease Gini`,
`Normalized Gini Importance`
)
# Confirm that every non-target Excel variable appears in the importance output.
missing_from_importance <- setdiff(
RF_PREDICTORS,
rf_importance$`Variable Name`
)
extra_in_importance <- setdiff(
rf_importance$`Variable Name`,
RF_PREDICTORS
)
if (length(missing_from_importance) > 0) {
stop(
paste0(
"The following predictor(s) are missing from the RF importance output: ",
paste(
missing_from_importance,
collapse = ", "
)
)
)
}
if (length(extra_in_importance) > 0) {
stop(
paste0(
"Unexpected variable(s) appear in the RF importance output: ",
paste(
extra_in_importance,
collapse = ", "
)
)
)
}
if (nrow(rf_importance) != length(RF_PREDICTORS)) {
stop(
"The importance table does not contain all non-target variables."
)
}
knitr::kable(
rf_importance,
digits = 3,
caption = "Original Random Forest variable-importance results"
)| Rank | Variable Name | Mean Decrease Accuracy | Mean Decrease Gini | Normalized Gini Importance |
|---|---|---|---|---|
| 1 | Contributing Factors | 0.004 | 160.908 | 100.00 |
| 2 | Intersection Related | 0.008 | 121.099 | 75.26 |
| 3 | Driver Age | 0.003 | 116.043 | 72.12 |
| 4 | Bicyclist age | 0.002 | 115.737 | 71.93 |
| 5 | Speed Limit | 0.005 | 92.819 | 57.68 |
| 6 | Vehicle Movement | 0.016 | 87.586 | 54.43 |
| 7 | Trafficway Configuration | 0.003 | 82.701 | 51.40 |
| 8 | Vehicle Type | 0.002 | 58.816 | 36.55 |
| 9 | Weather | 0.000 | 49.423 | 30.71 |
| 10 | Lighting Condition | 0.003 | 45.619 | 28.35 |
| 11 | Daytime | 0.000 | 45.303 | 28.15 |
| 12 | Road Alignment | 0.005 | 42.837 | 26.62 |
| 13 | Surface Condition | 0.000 | 35.455 | 22.03 |
| 14 | Rural/Urban | 0.002 | 32.257 | 20.05 |
| 15 | Hit and Run | 0.002 | 29.485 | 18.32 |
| 16 | Road Relation | 0.001 | 27.981 | 17.39 |
8 Original variable-importance figure
All 16 predictors are shown using their exact Excel names.
rf_plot_data <- rf_importance |>
dplyr::mutate(
`Variable Name` = factor(
`Variable Name`,
levels = rev(
`Variable Name`
)
)
)
rf_importance_plot <- ggplot2::ggplot(
rf_plot_data,
ggplot2::aes(
x = `Mean Decrease Gini`,
y = `Variable Name`,
fill = `Mean Decrease Gini`
)
) +
ggplot2::geom_col(
width = 0.68,
color = "black",
linewidth = 0.25
) +
ggplot2::geom_text(
ggplot2::aes(
label = sprintf(
"%.1f",
`Mean Decrease Gini`
)
),
hjust = -0.12,
family = FONT_FAMILY,
fontface = "bold",
size = 3.7,
color = "black"
) +
ggplot2::scale_fill_gradientn(
colours = RF_PALETTE,
guide = "none"
) +
ggplot2::scale_x_continuous(
expand = ggplot2::expansion(
mult = c(
0,
0.12
)
)
) +
ggplot2::labs(
title = "Random Forest Variable Importance",
subtitle = "Travel Lane versus Crossing Lane fatal bicycle crashes",
x = "Mean Decrease Gini",
y = NULL
) +
ggplot2::theme_minimal(
base_family = FONT_FAMILY,
base_size = 12
) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold",
size = 16,
hjust = 0
),
plot.subtitle = ggplot2::element_text(
size = 11,
hjust = 0,
margin = ggplot2::margin(
b = 10
)
),
axis.text.y = ggplot2::element_text(
face = "bold",
size = 10.5,
color = "black"
),
axis.text.x = ggplot2::element_text(
size = 10,
color = "black"
),
axis.title.x = ggplot2::element_text(
face = "bold",
size = 11,
margin = ggplot2::margin(
t = 10
)
),
panel.grid.major.y = ggplot2::element_blank(),
panel.grid.minor = ggplot2::element_blank(),
panel.grid.major.x = ggplot2::element_line(
color = "grey85",
linewidth = 0.35
),
plot.background = ggplot2::element_rect(
fill = "white",
color = NA
),
panel.background = ggplot2::element_rect(
fill = "white",
color = NA
),
plot.margin = ggplot2::margin(
t = 12,
r = 35,
b = 12,
l = 12
)
)
rf_importance_plot9 Selected-variable table
The model uses every non-target variable. This table simply records the highest ranked variables for the next analytical stage.
selected_variable_table <- rf_importance |>
dplyr::slice_head(
n = min(
N_SELECTED_VARIABLES,
nrow(
rf_importance
)
)
)
selected_variables <- selected_variable_table$`Variable Name`
knitr::kable(
selected_variable_table,
digits = 3,
caption = paste0(
"Top ",
length(
selected_variables
),
" variables based on original Mean Decrease Gini importance"
)
)| Rank | Variable Name | Mean Decrease Accuracy | Mean Decrease Gini | Normalized Gini Importance |
|---|---|---|---|---|
| 1 | Contributing Factors | 0.004 | 160.908 | 100.00 |
| 2 | Intersection Related | 0.008 | 121.099 | 75.26 |
| 3 | Driver Age | 0.003 | 116.043 | 72.12 |
| 4 | Bicyclist age | 0.002 | 115.737 | 71.93 |
| 5 | Speed Limit | 0.005 | 92.819 | 57.68 |
| 6 | Vehicle Movement | 0.016 | 87.586 | 54.43 |
| 7 | Trafficway Configuration | 0.003 | 82.701 | 51.40 |
| 8 | Vehicle Type | 0.002 | 58.816 | 36.55 |
| 9 | Weather | 0.000 | 49.423 | 30.71 |
| 10 | Lighting Condition | 0.003 | 45.619 | 28.35 |
| 11 | Daytime | 0.000 | 45.303 | 28.15 |
| 12 | Road Alignment | 0.005 | 42.837 | 26.62 |
| 13 | Surface Condition | 0.000 | 35.455 | 22.03 |
10 Save Random Forest outputs
writexl::write_xlsx(
list(
Outcome_Distribution = outcome_distribution,
All_RF_Importance = rf_importance,
Selected_Variables = selected_variable_table
),
path = file.path(
OUTPUT_DIR,
"RF_Original_Variable_Importance.xlsx"
)
)
utils::write.csv(
rf_importance,
file = file.path(
OUTPUT_DIR,
"RF_Original_Variable_Importance.csv"
),
row.names = FALSE
)
utils::write.csv(
selected_variable_table,
file = file.path(
OUTPUT_DIR,
"RF_Selected_Variables.csv"
),
row.names = FALSE
)
ggplot2::ggsave(
filename = file.path(
OUTPUT_DIR,
"RF_Original_Variable_Importance.png"
),
plot = rf_importance_plot,
width = 10,
height = 9,
units = "in",
dpi = 600,
bg = "white"
)
ggplot2::ggsave(
filename = file.path(
OUTPUT_DIR,
"RF_Original_Variable_Importance.pdf"
),
plot = rf_importance_plot,
width = 10,
height = 9,
units = "in",
device = grDevices::cairo_pdf,
bg = "white"
)
saveRDS(
rf_model,
file = file.path(
OUTPUT_DIR,
"RF_Model_All_Predictors.rds"
)
)
saveRDS(
rf_predictor_data,
file = file.path(
OUTPUT_DIR,
"RF_All_Predictor_Data.rds"
)
)
saveRDS(
selected_variables,
file = file.path(
OUTPUT_DIR,
"RF_Selected_Variable_Names.rds"
)
)
cat(
"Random Forest outputs were saved to:\n",
OUTPUT_DIR,
"\n"
)## Random Forest outputs were saved to:
## C:/Users/baitu/OneDrive - Texas State University/Das, Subasish's files - Hackathon_Anika/Papers/TRBAM 2027/6. bike_Travel lane vs crossing_FARS/02_Code/Analysis_Output/01_RF_Variable_Selection
11 TreeSHAP analysis
# ============================================================
# FAST TREESHAP ANALYSIS
# Sheet: Selected Variables
# Target: Bicyclist Path Type
# Explained outcome: Crossing Lane probability
# Output:
# 1. SHAP matrix for SOM
# 2. Reference-paper-style 4-panel SHAP figure
# 3. SHAP variable-importance plot
# ============================================================
options(pkgType = "binary")
required_packages <- c(
"readxl",
"dplyr",
"stringr",
"ranger",
"treeshap",
"ggplot2",
"gridExtra",
"grid",
"writexl"
)
packages_to_install <- required_packages[
!required_packages %in% rownames(installed.packages())
]
if (length(packages_to_install) > 0) {
install.packages(
packages_to_install,
repos = "https://cloud.r-project.org",
type = "binary",
dependencies = TRUE
)
}
invisible(
lapply(
required_packages,
library,
character.only = TRUE
)
)
# ----------------------------
# Settings
# ----------------------------
PROJECT_DIR <- paste0(
"C:/Users/baitu/OneDrive - Texas State University/",
"Das, Subasish's files - Hackathon_Anika/",
"Papers/TRBAM 2027/",
"6. bike_Travel lane vs crossing_FARS/",
"02_Code"
)
DATA_FILE <- file.path(
PROJECT_DIR,
"Bike01_copy_no duplicate.xlsx"
)
SHEET_NAME <- "Selected Variables"
TARGET_VARIABLE <- "Bicyclist Path Type"
NEGATIVE_CLASS <- "Travel Lane"
POSITIVE_CLASS <- "Crossing Lane"
EXCLUDED_VARIABLES <- c(
"Hit and Run",
"Road Relation"
)
RF_SEED <- 2027
TRAIN_PROPORTION <- 0.80
RF_TREES <- 500
MAROON_POS <- "#7C2A35"
BROWN_NEG <- "#CBB4A7"
MAROON_DARK <- "#4B1D24"
GRID_COLOR <- "grey85"
OUT_DIR <- file.path(
PROJECT_DIR,
"Analysis_Output",
"02_TreeSHAP_Reference_Style"
)
dir.create(
OUT_DIR,
recursive = TRUE,
showWarnings = FALSE
)
clean_text <- function(x) {
x <- as.character(x)
x <- stringr::str_squish(x)
x[is.na(x) | x == ""] <- "Not Reported"
x
}
# ----------------------------
# Read data
# ----------------------------
bike_data <- readxl::read_excel(
path = DATA_FILE,
sheet = SHEET_NAME,
guess_max = 100000
)
bike_data <- bike_data |>
dplyr::mutate(
dplyr::across(
where(is.character),
clean_text
)
)
if (!TARGET_VARIABLE %in% names(bike_data)) {
stop(
paste0(
"Target variable not found: ",
TARGET_VARIABLE
)
)
}
bike_data[[TARGET_VARIABLE]] <- factor(
clean_text(
bike_data[[TARGET_VARIABLE]]
),
levels = c(
NEGATIVE_CLASS,
POSITIVE_CLASS
)
)
bike_data <- bike_data[
!is.na(
bike_data[[TARGET_VARIABLE]]
),
,
drop = FALSE
]
FINAL_PREDICTORS <- setdiff(
names(bike_data),
c(
TARGET_VARIABLE,
EXCLUDED_VARIABLES
)
)
model_data <- bike_data[
,
c(
FINAL_PREDICTORS,
TARGET_VARIABLE
),
drop = FALSE
]
for (v in FINAL_PREDICTORS) {
if (is.character(model_data[[v]])) {
model_data[[v]] <- factor(
clean_text(
model_data[[v]]
)
)
}
}
model_data <- model_data[
complete.cases(model_data),
,
drop = FALSE
]
cat("Rows used:", nrow(model_data), "\n")## Rows used: 8461
## Predictors used: 14
## Weather
## Lighting Condition
## Rural/Urban
## Intersection Related
## Trafficway Configuration
## Road Alignment
## Surface Condition
## Vehicle Movement
## Bicyclist age
## Driver Age
## Daytime
## Vehicle Type
## Speed Limit
## Contributing Factors
# ----------------------------
# Stratified 80/20 split
# ----------------------------
set.seed(RF_SEED)
row_id <- seq_len(
nrow(model_data)
)
split_ids <- split(
row_id,
model_data[[TARGET_VARIABLE]]
)
train_index <- unlist(
lapply(
split_ids,
function(ids) {
sample(
ids,
size = floor(
TRAIN_PROPORTION * length(ids)
)
)
}
)
)
test_index <- setdiff(
row_id,
train_index
)
train_data <- model_data[
train_index,
,
drop = FALSE
]
test_data <- model_data[
test_index,
,
drop = FALSE
]
cat("Training rows:", nrow(train_data), "\n")## Training rows: 6768
## Testing rows: 1693
## Training distribution:
##
## Travel Lane Crossing Lane
## 5987 781
## Testing distribution:
##
## Travel Lane Crossing Lane
## 1497 196
# ----------------------------
# Safe internal names and dummy encoding
# ----------------------------
name_key <- data.frame(
Original = FINAL_PREDICTORS,
Safe = make.names(
FINAL_PREDICTORS,
unique = TRUE
),
stringsAsFactors = FALSE
)
x_all_original <- model_data[
,
FINAL_PREDICTORS,
drop = FALSE
]
x_all_safe <- x_all_original
names(x_all_safe) <- name_key$Safe
x_all_dummy <- model.matrix(
~ . - 1,
data = x_all_safe
)
x_all_dummy <- as.data.frame(
x_all_dummy
)
x_train_dummy <- x_all_dummy[
train_index,
,
drop = FALSE
]
x_test_dummy <- x_all_dummy[
test_index,
,
drop = FALSE
]
y_train_class <- droplevels(
train_data[[TARGET_VARIABLE]]
)
y_test_class <- droplevels(
test_data[[TARGET_VARIABLE]]
)
y_train_numeric <- ifelse(
y_train_class == POSITIVE_CLASS,
1,
0
)
# ----------------------------
# Final Random Forest
# Regression forest on 0/1 outcome gives predicted Crossing Lane probability.
# This avoids treeshap probability-classification error.
# ----------------------------
rf_train_data <- x_train_dummy
rf_train_data$Crossing_Lane_Probability_Target <- y_train_numeric
set.seed(RF_SEED)
rf_final <- ranger::ranger(
dependent.variable.name = "Crossing_Lane_Probability_Target",
data = rf_train_data,
num.trees = RF_TREES,
mtry = max(
1,
floor(
sqrt(
ncol(x_train_dummy)
)
)
),
seed = RF_SEED,
importance = "impurity",
num.threads = max(
1,
parallel::detectCores() - 1
)
)
test_pred_prob <- predict(
rf_final,
data = x_test_dummy
)$predictions
test_pred_prob <- pmin(
pmax(
test_pred_prob,
0
),
1
)
test_pred_class <- ifelse(
test_pred_prob >= 0.5,
POSITIVE_CLASS,
NEGATIVE_CLASS
)
prediction_output <- data.frame(
Original_Row_ID = test_index,
Actual = as.character(
y_test_class
),
Predicted = as.character(
test_pred_class
),
Predicted_Probability_Crossing_Lane = as.numeric(
test_pred_prob
),
stringsAsFactors = FALSE
)
cat("Random Forest completed.\n")## Random Forest completed.
# ----------------------------
# Fast TreeSHAP for all test observations
# ----------------------------
cat("Starting TreeSHAP for all test observations...\n")## Starting TreeSHAP for all test observations...
unified_rf <- treeshap::ranger.unify(
rf_final,
x_train_dummy
)
treeshap_output <- treeshap::treeshap(
unified_rf,
x_test_dummy,
verbose = TRUE
)
shap_encoded <- as.data.frame(
treeshap_output$shaps
)
names(shap_encoded) <- names(
x_test_dummy
)
cat("TreeSHAP completed.\n")## TreeSHAP completed.
## SHAP rows: 1693
## Encoded SHAP columns: 35
# ----------------------------
# Aggregate encoded SHAP back to original variables
# ----------------------------
shap_original <- data.frame(
matrix(
0,
nrow = nrow(shap_encoded),
ncol = length(FINAL_PREDICTORS)
)
)
names(shap_original) <- FINAL_PREDICTORS
for (i in seq_len(nrow(name_key))) {
original_name <- name_key$Original[i]
safe_name <- name_key$Safe[i]
matched_cols <- names(shap_encoded)[
startsWith(
names(shap_encoded),
safe_name
)
]
if (length(matched_cols) > 0) {
shap_original[[original_name]] <- rowSums(
shap_encoded[
,
matched_cols,
drop = FALSE
],
na.rm = TRUE
)
}
}
# ----------------------------
# SHAP matrix for SOM
# ----------------------------
shap_matrix_for_som <- cbind(
prediction_output,
shap_original
)
# ----------------------------
# SHAP importance table
# ----------------------------
shap_importance <- data.frame(
Variable_Name = names(shap_original),
Mean_Absolute_SHAP = colMeans(
abs(
shap_original
),
na.rm = TRUE
),
Mean_SHAP = colMeans(
shap_original,
na.rm = TRUE
),
stringsAsFactors = FALSE
) |>
dplyr::arrange(
dplyr::desc(
Mean_Absolute_SHAP
)
) |>
dplyr::mutate(
Rank = dplyr::row_number(),
Normalized_Mean_Absolute_SHAP = round(
100 *
Mean_Absolute_SHAP /
max(
Mean_Absolute_SHAP,
na.rm = TRUE
),
2
)
) |>
dplyr::select(
Rank,
Variable_Name,
Mean_Absolute_SHAP,
Mean_SHAP,
Normalized_Mean_Absolute_SHAP
)
print(
shap_importance
)## Rank Variable_Name Mean_Absolute_SHAP
## Intersection Related 1 Intersection Related 0.054227206
## Contributing Factors 2 Contributing Factors 0.042170959
## Vehicle Movement 3 Vehicle Movement 0.034121051
## Trafficway Configuration 4 Trafficway Configuration 0.027682446
## Rural/Urban 5 Rural/Urban 0.015414554
## Speed Limit 6 Speed Limit 0.013193835
## Lighting Condition 7 Lighting Condition 0.013158998
## Bicyclist age 8 Bicyclist age 0.007940529
## Driver Age 9 Driver Age 0.006003790
## Weather 10 Weather 0.004924015
## Vehicle Type 11 Vehicle Type 0.004543815
## Road Alignment 12 Road Alignment 0.004069084
## Daytime 13 Daytime 0.003166485
## Surface Condition 14 Surface Condition 0.003071269
## Mean_SHAP Normalized_Mean_Absolute_SHAP
## Intersection Related 0.00277190501 100.00
## Contributing Factors -0.00018821867 77.77
## Vehicle Movement -0.00183405039 62.92
## Trafficway Configuration 0.00005614711 51.05
## Rural/Urban 0.00064173945 28.43
## Speed Limit 0.00150649052 24.33
## Lighting Condition 0.00023379525 24.27
## Bicyclist age 0.00037273015 14.64
## Driver Age 0.00083375368 11.07
## Weather 0.00012462519 9.08
## Vehicle Type 0.00007776587 8.38
## Road Alignment 0.00184289510 7.50
## Daytime 0.00001353539 5.84
## Surface Condition 0.00002142430 5.66
# ----------------------------
# Reference-style SHAP plot data
# ----------------------------
# ============================================================
# CORRECTED SHAP PLOTTING SECTION
# Fixes:
# 1. No NA labels
# 2. Four separate large figures
# 3. Caption/heading below each figure
# 4. Original Excel variable names preserved
# ============================================================
MAROON_POS <- "#7C2A35"
BROWN_NEG <- "#CBB4A7"
GRID_COLOR <- "grey85"
# Keep original Excel variable names exactly
test_predictor_values <- test_data[, FINAL_PREDICTORS, drop = FALSE]
for (v in FINAL_PREDICTORS) {
test_predictor_values[[v]] <- as.character(test_predictor_values[[v]])
test_predictor_values[[v]][is.na(test_predictor_values[[v]]) | test_predictor_values[[v]] == ""] <- "Not Reported"
}
# Prefix SHAP columns, but keep original variable names after prefix
shap_prefixed <- shap_original
names(shap_prefixed) <- paste0("SHAP__", names(shap_original))
plot_data_base <- data.frame(
prediction_output,
test_predictor_values,
shap_prefixed,
check.names = FALSE,
stringsAsFactors = FALSE
)
plot_data_base$CM_Group <- NA_character_
plot_data_base$CM_Group[
plot_data_base$Predicted == NEGATIVE_CLASS &
plot_data_base$Actual == NEGATIVE_CLASS
] <- "TN"
plot_data_base$CM_Group[
plot_data_base$Predicted == NEGATIVE_CLASS &
plot_data_base$Actual == POSITIVE_CLASS
] <- "FN"
plot_data_base$CM_Group[
plot_data_base$Predicted == POSITIVE_CLASS &
plot_data_base$Actual == NEGATIVE_CLASS
] <- "FP"
plot_data_base$CM_Group[
plot_data_base$Predicted == POSITIVE_CLASS &
plot_data_base$Actual == POSITIVE_CLASS
] <- "TP"
group_info <- data.frame(
CM_Group = c("TN", "FN", "FP", "TP"),
Bottom_Heading = c(
"TN: True negative | Predicted Travel Lane, actual Travel Lane",
"FN: False negative | Predicted Travel Lane, actual Crossing Lane",
"FP: False positive | Predicted Crossing Lane, actual Travel Lane",
"TP: True positive | Predicted Crossing Lane, actual Crossing Lane"
),
File_Name = c(
"SHAP_TN_True_Negative.png",
"SHAP_FN_False_Negative.png",
"SHAP_FP_False_Positive.png",
"SHAP_TP_True_Positive.png"
),
stringsAsFactors = FALSE
)
make_feature_summary_label <- function(data_group, variable_name) {
x <- data_group[[variable_name]]
if (is.null(x)) {
return(paste0(variable_name, " = Not Reported: 0%"))
}
x <- as.character(x)
x[is.na(x) | x == "" | x == "NA"] <- "Not Reported"
tab <- sort(
table(x),
decreasing = TRUE
)
if (length(tab) == 0) {
return(paste0(variable_name, " = Not Reported: 0%"))
}
top_level <- names(tab)[1]
if (is.na(top_level) || top_level == "" || top_level == "NA") {
top_level <- "Not Reported"
}
pct <- round(
100 * as.numeric(tab[1]) / length(x),
0
)
if (is.na(pct)) {
pct <- 0
}
paste0(
variable_name,
" = ",
top_level,
": ",
pct,
"%"
)
}
make_single_group_plot <- function(group_name, bottom_heading) {
group_data <- plot_data_base[
plot_data_base$CM_Group == group_name,
,
drop = FALSE
]
n_group <- nrow(group_data)
shap_cols <- paste0("SHAP__", names(shap_original))
shap_means <- data.frame(
Variable_Name = names(shap_original),
Mean_SHAP = colMeans(
group_data[, shap_cols, drop = FALSE],
na.rm = TRUE
),
stringsAsFactors = FALSE
)
shap_means$Label <- sapply(
shap_means$Variable_Name,
function(v) {
make_feature_summary_label(group_data, v)
}
)
shap_means <- shap_means |>
dplyr::arrange(Mean_SHAP)
shap_means$Label <- factor(
shap_means$Label,
levels = shap_means$Label
)
full_bottom_heading <- paste0(
bottom_heading,
" | n = ",
format(n_group, big.mark = ",")
)
p_bar <- ggplot2::ggplot(
shap_means,
ggplot2::aes(
x = Mean_SHAP,
y = Label,
fill = Mean_SHAP > 0
)
) +
ggplot2::geom_col(
color = "black",
linewidth = 0.35,
width = 0.68
) +
ggplot2::scale_fill_manual(
values = c(
"TRUE" = MAROON_POS,
"FALSE" = BROWN_NEG
),
guide = "none"
) +
ggplot2::geom_vline(
xintercept = 0,
color = "black",
linewidth = 0.45
) +
ggplot2::labs(
x = "Mean SHAP value",
y = NULL
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 15
) +
ggplot2::theme(
axis.text.y = ggplot2::element_text(
face = "bold",
color = "black",
size = 13
),
axis.text.x = ggplot2::element_text(
color = "black",
size = 13
),
axis.title.x = ggplot2::element_text(
face = "bold",
size = 15
),
panel.grid.major.y = ggplot2::element_blank(),
panel.grid.minor = ggplot2::element_blank(),
panel.grid.major.x = ggplot2::element_line(
color = GRID_COLOR,
linewidth = 0.35
),
plot.margin = ggplot2::margin(
t = 8,
r = 10,
b = 4,
l = 8
)
)
p_box <- ggplot2::ggplot(
group_data,
ggplot2::aes(
x = "",
y = Predicted_Probability_Crossing_Lane
)
) +
ggplot2::geom_boxplot(
fill = "white",
color = "black",
width = 0.32,
outlier.size = 1.0,
linewidth = 0.45
) +
ggplot2::coord_flip() +
ggplot2::scale_y_continuous(
limits = c(0, 1),
breaks = seq(0, 1, 0.25)
) +
ggplot2::labs(
x = NULL,
y = "Predicted probability of Crossing Lane"
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 13
) +
ggplot2::theme(
axis.text.y = ggplot2::element_blank(),
axis.ticks.y = ggplot2::element_blank(),
axis.text.x = ggplot2::element_text(
color = "black",
size = 12
),
axis.title.x = ggplot2::element_text(
face = "bold",
size = 13
),
panel.grid.major.y = ggplot2::element_blank(),
panel.grid.minor = ggplot2::element_blank(),
panel.grid.major.x = ggplot2::element_line(
color = GRID_COLOR,
linewidth = 0.30
),
plot.margin = ggplot2::margin(
t = 2,
r = 10,
b = 2,
l = 8
)
)
combined_plot <- gridExtra::arrangeGrob(
p_bar,
p_box,
ncol = 1,
heights = c(4.8, 1.0),
bottom = grid::textGrob(
full_bottom_heading,
gp = grid::gpar(
fontfamily = "serif",
fontsize = 18,
fontface = "bold"
)
)
)
combined_plot
}
separate_group_plots <- list()
for (i in seq_len(nrow(group_info))) {
group_name <- group_info$CM_Group[i]
bottom_heading <- group_info$Bottom_Heading[i]
file_name <- group_info$File_Name[i]
this_plot <- make_single_group_plot(
group_name = group_name,
bottom_heading = bottom_heading
)
separate_group_plots[[group_name]] <- this_plot
ggplot2::ggsave(
filename = file.path(
OUT_DIR,
file_name
),
plot = this_plot,
width = 13,
height = 9,
dpi = 600,
bg = "white"
)
}
# Show the four figures one by one inside R Markdown
grid::grid.newpage()
grid::grid.draw(separate_group_plots[["TN"]])## Four separate corrected SHAP figures saved to:
## C:/Users/baitu/OneDrive - Texas State University/Das, Subasish's files - Hackathon_Anika/Papers/TRBAM 2027/6. bike_Travel lane vs crossing_FARS/02_Code/Analysis_Output/02_TreeSHAP_Reference_Style
# ----------------------------
# Save outputs
# ----------------------------
writexl::write_xlsx(
list(
Predictions = prediction_output,
TreeSHAP_Importance = shap_importance,
SHAP_Matrix_for_SOM = shap_matrix_for_som
),
file.path(
OUT_DIR,
"TreeSHAP_Output_All_Test_Observations.xlsx"
)
)
write.csv(
shap_matrix_for_som,
file.path(
OUT_DIR,
"SHAP_Matrix_for_SOM.csv"
),
row.names = FALSE
)
write.csv(
shap_importance,
file.path(
OUT_DIR,
"TreeSHAP_Importance.csv"
),
row.names = FALSE
)
# ----------------------------
# TreeSHAP variable importance plot
# ----------------------------
importance_plot_data <- shap_importance |>
dplyr::mutate(
Variable_Name = factor(
Variable_Name,
levels = rev(Variable_Name)
)
)
importance_plot <- ggplot2::ggplot(
importance_plot_data,
ggplot2::aes(
x = Mean_Absolute_SHAP,
y = Variable_Name
)
) +
ggplot2::geom_col(
fill = "#7C2A35",
color = "black",
linewidth = 0.35,
width = 0.68
) +
ggplot2::labs(
title = "TreeSHAP Variable Importance",
x = "Mean absolute SHAP value",
y = NULL
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 14
) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold",
size = 18,
color = "black"
),
axis.text.y = ggplot2::element_text(
face = "bold",
color = "black",
size = 12
),
axis.text.x = ggplot2::element_text(
color = "black",
size = 11
),
axis.title.x = ggplot2::element_text(
face = "bold",
size = 13
),
panel.grid.major.y = ggplot2::element_blank(),
panel.grid.minor = ggplot2::element_blank()
)
print(importance_plot)ggplot2::ggsave(
file.path(
OUT_DIR,
"TreeSHAP_Variable_Importance.png"
),
importance_plot,
width = 10,
height = 8,
dpi = 600,
bg = "white"
)
saveRDS(
shap_original,
file.path(
OUT_DIR,
"SHAP_Matrix_for_SOM.rds"
)
)
saveRDS(
rf_final,
file.path(
OUT_DIR,
"Ranger_RF_TreeSHAP_Model.rds"
)
)
cat(
"TreeSHAP analysis complete. Outputs saved to:\n",
OUT_DIR,
"\n"
)## TreeSHAP analysis complete. Outputs saved to:
## C:/Users/baitu/OneDrive - Texas State University/Das, Subasish's files - Hackathon_Anika/Papers/TRBAM 2027/6. bike_Travel lane vs crossing_FARS/02_Code/Analysis_Output/02_TreeSHAP_Reference_Style
## Use SHAP_Matrix_for_SOM.csv for SOM grid selection.
12 SOM grid-size selection using SHAP matrix
# ============================================================
# SOM GRID-SIZE SELECTION
# Input: SHAP_Matrix_for_SOM.csv
# Purpose: select SOM grid size before final SOM training
# ============================================================
options(pkgType = "binary")
required_packages <- c(
"kohonen",
"dplyr",
"ggplot2",
"writexl"
)
packages_to_install <- required_packages[
!required_packages %in% rownames(installed.packages())
]
if (length(packages_to_install) > 0) {
install.packages(
packages_to_install,
repos = "https://cloud.r-project.org",
type = "binary",
dependencies = TRUE
)
}
invisible(
lapply(
required_packages,
library,
character.only = TRUE
)
)
# ------------------------------------------------------------
# Output folder
# ------------------------------------------------------------
SOM_OUT_DIR <- file.path(
PROJECT_DIR,
"Analysis_Output",
"03_SOM_Grid_Selection"
)
dir.create(
SOM_OUT_DIR,
recursive = TRUE,
showWarnings = FALSE
)
# ------------------------------------------------------------
# Read SHAP matrix
# ------------------------------------------------------------
SHAP_SOM_FILE <- file.path(
OUT_DIR,
"SHAP_Matrix_for_SOM.csv"
)
if (!file.exists(SHAP_SOM_FILE)) {
stop(
paste0(
"SHAP_Matrix_for_SOM.csv not found here: ",
SHAP_SOM_FILE
)
)
}
shap_som_data <- read.csv(
SHAP_SOM_FILE,
check.names = FALSE,
stringsAsFactors = FALSE
)
cat("Rows in SHAP matrix:", nrow(shap_som_data), "\n")## Rows in SHAP matrix: 1693
## Columns in SHAP matrix: 18
# ------------------------------------------------------------
# Select only SHAP columns for SOM
# Do not use ID, actual class, predicted class, or probability
# ------------------------------------------------------------
metadata_columns <- c(
"Original_Row_ID",
"Actual",
"Predicted",
"Predicted_Probability_Crossing_Lane"
)
shap_columns <- setdiff(
names(shap_som_data),
metadata_columns
)
som_input_raw <- shap_som_data[, shap_columns, drop = FALSE]
# Force all SHAP columns to numeric
for (v in names(som_input_raw)) {
som_input_raw[[v]] <- as.numeric(som_input_raw[[v]])
}
# Remove columns with all missing values
all_missing_columns <- names(som_input_raw)[
sapply(
som_input_raw,
function(x) all(is.na(x))
)
]
if (length(all_missing_columns) > 0) {
som_input_raw <- som_input_raw[
,
!names(som_input_raw) %in% all_missing_columns,
drop = FALSE
]
}
# Replace remaining NA values with 0
som_input_raw[is.na(som_input_raw)] <- 0
# Remove zero-variance SHAP columns
zero_variance_columns <- names(som_input_raw)[
sapply(
som_input_raw,
function(x) {
stats::sd(x, na.rm = TRUE) == 0
}
)
]
if (length(zero_variance_columns) > 0) {
som_input_raw <- som_input_raw[
,
!names(som_input_raw) %in% zero_variance_columns,
drop = FALSE
]
}
cat("SHAP variables used for SOM:\n")## SHAP variables used for SOM:
## Weather
## Lighting Condition
## Rural/Urban
## Intersection Related
## Trafficway Configuration
## Road Alignment
## Surface Condition
## Vehicle Movement
## Bicyclist age
## Driver Age
## Daytime
## Vehicle Type
## Speed Limit
## Contributing Factors
# ------------------------------------------------------------
# Scale SHAP matrix for SOM grid selection
# ------------------------------------------------------------
som_input_scaled <- scale(
as.matrix(som_input_raw)
)
som_input_scaled[is.na(som_input_scaled)] <- 0
# ------------------------------------------------------------
# Candidate grid sizes
# ------------------------------------------------------------
grid_candidates <- data.frame(
xdim = c(3, 4, 4, 5, 5, 6, 6, 7),
ydim = c(3, 3, 4, 4, 5, 5, 6, 6)
)
grid_candidates$Grid_Size <- paste0(
grid_candidates$xdim,
" x ",
grid_candidates$ydim
)
# ------------------------------------------------------------
# Function to calculate SOM quality metrics
# ------------------------------------------------------------
calculate_som_metrics <- function(xdim, ydim, som_data, seed_value = 2027) {
set.seed(seed_value)
som_grid <- kohonen::somgrid(
xdim = xdim,
ydim = ydim,
topo = "hexagonal"
)
som_model <- kohonen::som(
X = som_data,
grid = som_grid,
rlen = 500,
alpha = c(0.05, 0.01),
keep.data = TRUE
)
codebook <- som_model$codes[[1]]
distance_to_units <- sapply(
seq_len(nrow(codebook)),
function(k) {
rowSums(
sweep(
som_data,
2,
codebook[k, ],
"-"
)^2
)
}
)
ordered_units <- t(
apply(
distance_to_units,
1,
order
)
)
bmu_1 <- ordered_units[, 1]
bmu_2 <- ordered_units[, 2]
first_distance <- distance_to_units[
cbind(
seq_len(nrow(som_data)),
bmu_1
)
]
quantization_error <- mean(
sqrt(first_distance)
)
unit_distance_matrix <- kohonen::unit.distances(
som_grid
)
bmu_neighbor_distance <- unit_distance_matrix[
cbind(
bmu_1,
bmu_2
)
]
topographic_error <- mean(
bmu_neighbor_distance > 1
)
kaski_lagus_error <- mean(
sqrt(first_distance) + bmu_neighbor_distance
)
node_counts <- table(
factor(
bmu_1,
levels = seq_len(nrow(codebook))
)
)
empty_nodes <- sum(node_counts == 0)
result <- data.frame(
xdim = xdim,
ydim = ydim,
Grid_Size = paste0(xdim, " x ", ydim),
Total_Nodes = xdim * ydim,
Quantization_Error = quantization_error,
Topographic_Error = topographic_error,
Kaski_Lagus_Error = kaski_lagus_error,
Empty_Nodes = empty_nodes,
Empty_Node_Percent = round(
100 * empty_nodes / (xdim * ydim),
2
),
Minimum_Records_Per_Node = min(node_counts),
Maximum_Records_Per_Node = max(node_counts),
Mean_Records_Per_Node = mean(node_counts),
stringsAsFactors = FALSE
)
result
}
# ------------------------------------------------------------
# Run grid-size evaluation
# ------------------------------------------------------------
som_grid_results <- list()
for (i in seq_len(nrow(grid_candidates))) {
this_xdim <- grid_candidates$xdim[i]
this_ydim <- grid_candidates$ydim[i]
cat(
"Running SOM grid:",
this_xdim,
"x",
this_ydim,
"\n"
)
som_grid_results[[i]] <- calculate_som_metrics(
xdim = this_xdim,
ydim = this_ydim,
som_data = som_input_scaled,
seed_value = 2027
)
}## Running SOM grid: 3 x 3
## Running SOM grid: 4 x 3
## Running SOM grid: 4 x 4
## Running SOM grid: 5 x 4
## Running SOM grid: 5 x 5
## Running SOM grid: 6 x 5
## Running SOM grid: 6 x 6
## Running SOM grid: 7 x 6
som_grid_selection_table <- dplyr::bind_rows(
som_grid_results
)
# ------------------------------------------------------------
# Create normalized ranking score
# Lower score is better
# ------------------------------------------------------------
normalize_metric <- function(x) {
if (max(x, na.rm = TRUE) == min(x, na.rm = TRUE)) {
return(rep(0, length(x)))
}
(x - min(x, na.rm = TRUE)) /
(max(x, na.rm = TRUE) - min(x, na.rm = TRUE))
}
som_grid_selection_table <- som_grid_selection_table |>
dplyr::mutate(
QE_Normalized = normalize_metric(Quantization_Error),
TE_Normalized = normalize_metric(Topographic_Error),
KLE_Normalized = normalize_metric(Kaski_Lagus_Error),
Empty_Normalized = normalize_metric(Empty_Node_Percent),
Balanced_Selection_Score =
0.40 * QE_Normalized +
0.30 * TE_Normalized +
0.20 * KLE_Normalized +
0.10 * Empty_Normalized
) |>
dplyr::arrange(
Balanced_Selection_Score
) |>
dplyr::mutate(
Rank = dplyr::row_number()
) |>
dplyr::select(
Rank,
Grid_Size,
xdim,
ydim,
Total_Nodes,
Quantization_Error,
Topographic_Error,
Kaski_Lagus_Error,
Empty_Nodes,
Empty_Node_Percent,
Minimum_Records_Per_Node,
Maximum_Records_Per_Node,
Mean_Records_Per_Node,
Balanced_Selection_Score
)
print(som_grid_selection_table)## Rank Grid_Size xdim ydim Total_Nodes Quantization_Error Topographic_Error
## 1 1 7 x 6 7 6 42 1.950550 0.5534554
## 2 2 6 x 5 6 5 30 2.080523 0.4565859
## 3 3 5 x 5 5 5 25 2.130712 0.4819846
## 4 4 6 x 6 6 6 36 2.006741 0.5948021
## 5 5 5 x 4 5 4 20 2.209985 0.5280567
## 6 6 4 x 3 4 3 12 2.437375 0.4666273
## 7 7 4 x 4 4 4 16 2.330594 0.5333727
## 8 8 3 x 3 3 3 9 2.588566 0.7985824
## Kaski_Lagus_Error Empty_Nodes Empty_Node_Percent Minimum_Records_Per_Node
## 1 3.490872 0 0 4
## 2 3.692326 0 0 9
## 3 3.710227 0 0 12
## 4 3.646596 0 0 9
## 5 4.034357 0 0 9
## 6 3.742312 0 0 15
## 7 3.881111 0 0 15
## 8 4.216604 0 0 16
## Maximum_Records_Per_Node Mean_Records_Per_Node Balanced_Selection_Score
## 1 159 40.30952 0.08497409
## 2 203 56.43333 0.13700298
## 3 269 67.72000 0.19568179
## 4 201 47.02778 0.19938669
## 5 305 84.65000 0.37512100
## 6 353 141.08333 0.38331251
## 7 314 105.81250 0.41316695
## 8 597 188.11111 0.90000000
##
## Recommended SOM grid based on balanced score:
## Grid size: 7 x 6
## Quantization error: 1.95055
## Topographic error: 0.5534554
## Kaski-Lagus error: 3.490872
## Empty nodes: 0
# ------------------------------------------------------------
# Save grid-selection outputs
# ------------------------------------------------------------
write.csv(
som_grid_selection_table,
file.path(
SOM_OUT_DIR,
"SOM_Grid_Selection_Table.csv"
),
row.names = FALSE
)
writexl::write_xlsx(
list(
SOM_Grid_Selection = som_grid_selection_table
),
file.path(
SOM_OUT_DIR,
"SOM_Grid_Selection_Table.xlsx"
)
)
# ------------------------------------------------------------
# Plot 1: Quantization error by grid size
# ------------------------------------------------------------
som_grid_selection_table$Grid_Size <- factor(
som_grid_selection_table$Grid_Size,
levels = som_grid_selection_table$Grid_Size
)
plot_qe <- ggplot2::ggplot(
som_grid_selection_table,
ggplot2::aes(
x = Grid_Size,
y = Quantization_Error,
group = 1
)
) +
ggplot2::geom_line(
linewidth = 1.0
) +
ggplot2::geom_point(
size = 3.2
) +
ggplot2::labs(
title = "SOM Grid Selection: Quantization Error",
x = "SOM grid size",
y = "Quantization error"
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 14
) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold",
size = 16
),
axis.text.x = ggplot2::element_text(
angle = 45,
hjust = 1,
face = "bold",
color = "black"
),
axis.text.y = ggplot2::element_text(
color = "black"
),
axis.title = ggplot2::element_text(
face = "bold"
)
)
plot_qeggplot2::ggsave(
filename = file.path(
SOM_OUT_DIR,
"SOM_Grid_Selection_Quantization_Error.png"
),
plot = plot_qe,
width = 9,
height = 6,
dpi = 600,
bg = "white"
)
# ------------------------------------------------------------
# Plot 2: Topographic error by grid size
# ------------------------------------------------------------
plot_te <- ggplot2::ggplot(
som_grid_selection_table,
ggplot2::aes(
x = Grid_Size,
y = Topographic_Error,
group = 1
)
) +
ggplot2::geom_line(
linewidth = 1.0
) +
ggplot2::geom_point(
size = 3.2
) +
ggplot2::labs(
title = "SOM Grid Selection: Topographic Error",
x = "SOM grid size",
y = "Topographic error"
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 14
) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold",
size = 16
),
axis.text.x = ggplot2::element_text(
angle = 45,
hjust = 1,
face = "bold",
color = "black"
),
axis.text.y = ggplot2::element_text(
color = "black"
),
axis.title = ggplot2::element_text(
face = "bold"
)
)
plot_teggplot2::ggsave(
filename = file.path(
SOM_OUT_DIR,
"SOM_Grid_Selection_Topographic_Error.png"
),
plot = plot_te,
width = 9,
height = 6,
dpi = 600,
bg = "white"
)
# ------------------------------------------------------------
# Plot 3: Balanced selection score
# ------------------------------------------------------------
plot_score <- ggplot2::ggplot(
som_grid_selection_table,
ggplot2::aes(
x = Grid_Size,
y = Balanced_Selection_Score
)
) +
ggplot2::geom_col(
fill = "#7C2A35",
color = "black",
width = 0.65
) +
ggplot2::labs(
title = "SOM Grid Selection: Balanced Score",
subtitle = "Lower score indicates better balance across SOM quality metrics",
x = "SOM grid size",
y = "Balanced selection score"
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 14
) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold",
size = 16
),
plot.subtitle = ggplot2::element_text(
size = 12
),
axis.text.x = ggplot2::element_text(
angle = 45,
hjust = 1,
face = "bold",
color = "black"
),
axis.text.y = ggplot2::element_text(
color = "black"
),
axis.title = ggplot2::element_text(
face = "bold"
)
)
plot_scoreggplot2::ggsave(
filename = file.path(
SOM_OUT_DIR,
"SOM_Grid_Selection_Balanced_Score.png"
),
plot = plot_score,
width = 9,
height = 6,
dpi = 600,
bg = "white"
)
cat("\nSOM grid-selection outputs saved to:\n")##
## SOM grid-selection outputs saved to:
## C:/Users/baitu/OneDrive - Texas State University/Das, Subasish's files - Hackathon_Anika/Papers/TRBAM 2027/6. bike_Travel lane vs crossing_FARS/02_Code/Analysis_Output/03_SOM_Grid_Selection
13 Final SOM training, elbow method, and cluster outputs
# ============================================================
# FINAL SOM TRAINING, ELBOW METHOD, AND OUTPUTS
#
# This section:
# 1. Uses SHAP_Matrix_for_SOM.csv
# 2. Excludes Hit and Run and Road Relation
# 3. Trains final SOM using 6 x 5 grid
# 4. Applies Ward clustering to SOM codebook vectors
# 5. Uses the elbow method and selects k = 5 clusters
# 6. Labels nodes/clusters as Crossing Lane or Travel Lane
# 7. Generates:
# - Elbow method figure
# - SOM nodes grouped by Ward cluster
# - Observed Crossing Lane percentage by SOM node
# - Cluster-level reference-style SHAP plots
# ============================================================
options(pkgType = "binary")
required_packages <- c(
"kohonen",
"readxl",
"dplyr",
"ggplot2",
"writexl",
"gridExtra",
"grid",
"stringr"
)
packages_to_install <- required_packages[
!required_packages %in% rownames(installed.packages())
]
if (length(packages_to_install) > 0) {
install.packages(
packages_to_install,
repos = "https://cloud.r-project.org",
type = "binary",
dependencies = TRUE
)
}
invisible(
lapply(
required_packages,
library,
character.only = TRUE
)
)
# ------------------------------------------------------------
# Folder settings
# ------------------------------------------------------------
if (!exists("PROJECT_DIR")) {
PROJECT_DIR <- paste0(
"C:/Users/baitu/OneDrive - Texas State University/",
"Das, Subasish's files - Hackathon_Anika/",
"Papers/TRBAM 2027/",
"6. bike_Travel lane vs crossing_FARS/",
"02_Code"
)
}
if (!exists("OUT_DIR")) {
OUT_DIR <- file.path(
PROJECT_DIR,
"Analysis_Output",
"02_TreeSHAP_Reference_Style"
)
}
RUN_ID <- format(
Sys.time(),
"%Y%m%d_%H%M%S"
)
FINAL_SOM_OUT_DIR <- file.path(
"C:/Users/baitu/Desktop",
"Final_SOM_Elbow_Reference_Style_Outputs",
paste0("Run_", RUN_ID)
)
dir.create(
FINAL_SOM_OUT_DIR,
recursive = TRUE,
showWarnings = FALSE
)
if (!dir.exists(FINAL_SOM_OUT_DIR)) {
stop(
paste0(
"Could not create output folder: ",
FINAL_SOM_OUT_DIR
)
)
}
cat("Outputs will be saved to:\n")## Outputs will be saved to:
## C:/Users/baitu/Desktop/Final_SOM_Elbow_Reference_Style_Outputs/Run_20260715_195723
# ------------------------------------------------------------
# Class and data settings
# ------------------------------------------------------------
POSITIVE_CLASS <- "Crossing Lane"
NEGATIVE_CLASS <- "Travel Lane"
TARGET_VARIABLE <- "Bicyclist Path Type"
SHEET_NAME <- "Selected Variables"
EXCLUDED_VARIABLES <- c(
"Hit and Run",
"Road Relation"
)
DATA_FILE <- file.path(
PROJECT_DIR,
"Bike01_copy_no duplicate.xlsx"
)
# ------------------------------------------------------------
# Read SHAP matrix
# ------------------------------------------------------------
SHAP_SOM_FILE <- file.path(
OUT_DIR,
"SHAP_Matrix_for_SOM.csv"
)
if (!file.exists(SHAP_SOM_FILE)) {
stop(
paste0(
"SHAP_Matrix_for_SOM.csv was not found here: ",
SHAP_SOM_FILE
)
)
}
shap_som_data <- read.csv(
SHAP_SOM_FILE,
check.names = FALSE,
stringsAsFactors = FALSE
)
cat("Rows in SHAP matrix:", nrow(shap_som_data), "\n")## Rows in SHAP matrix: 1693
## Columns in SHAP matrix: 18
# ------------------------------------------------------------
# Select SHAP columns only
# Explicitly exclude Hit and Run and Road Relation
# ------------------------------------------------------------
metadata_columns <- c(
"Original_Row_ID",
"Actual",
"Predicted",
"Predicted_Probability_Crossing_Lane"
)
shap_variables <- setdiff(
names(shap_som_data),
c(
metadata_columns,
EXCLUDED_VARIABLES
)
)
som_input_raw <- shap_som_data[
,
shap_variables,
drop = FALSE
]
for (v in names(som_input_raw)) {
som_input_raw[[v]] <- as.numeric(som_input_raw[[v]])
}
som_input_raw[is.na(som_input_raw)] <- 0
zero_variance_columns <- names(som_input_raw)[
sapply(
som_input_raw,
function(x) {
stats::sd(x, na.rm = TRUE) == 0
}
)
]
if (length(zero_variance_columns) > 0) {
som_input_raw <- som_input_raw[
,
!names(som_input_raw) %in% zero_variance_columns,
drop = FALSE
]
}
shap_variables <- names(som_input_raw)
cat("SHAP variables used in SOM after exclusion:\n")## SHAP variables used in SOM after exclusion:
## Weather
## Lighting Condition
## Rural/Urban
## Intersection Related
## Trafficway Configuration
## Road Alignment
## Surface Condition
## Vehicle Movement
## Bicyclist age
## Driver Age
## Daytime
## Vehicle Type
## Speed Limit
## Contributing Factors
if ("Hit and Run" %in% shap_variables | "Road Relation" %in% shap_variables) {
stop("Hit and Run or Road Relation is still present in SOM input. Check the exclusion step.")
}
# ------------------------------------------------------------
# Scale SHAP matrix for SOM
# ------------------------------------------------------------
som_input_scaled <- scale(
as.matrix(som_input_raw)
)
som_input_scaled[is.na(som_input_scaled)] <- 0
# ------------------------------------------------------------
# Train final SOM using selected 6 x 5 grid
# ------------------------------------------------------------
FINAL_XDIM <- 6
FINAL_YDIM <- 5
FINAL_SOM_SEED <- 2027
set.seed(FINAL_SOM_SEED)
final_som_grid <- kohonen::somgrid(
xdim = FINAL_XDIM,
ydim = FINAL_YDIM,
topo = "hexagonal"
)
cat("Starting final SOM training using 6 x 5 grid.\n")## Starting final SOM training using 6 x 5 grid.
final_som_model <- kohonen::som(
X = som_input_scaled,
grid = final_som_grid,
rlen = 300,
alpha = c(0.05, 0.01),
radius = c(max(FINAL_XDIM, FINAL_YDIM) / 2, 1),
keep.data = TRUE
)
cat("Final SOM training completed.\n")## Final SOM training completed.
## Final SOM grid: 6 x 5
## Total SOM nodes: 30
# ------------------------------------------------------------
# Ward hierarchical clustering of SOM codebook vectors
# ------------------------------------------------------------
som_codebook <- as.data.frame(
final_som_model$codes[[1]]
)
names(som_codebook) <- shap_variables
som_codebook$SOM_Node <- seq_len(
nrow(som_codebook)
)
som_codebook_matrix <- as.matrix(
som_codebook[
,
shap_variables,
drop = FALSE
]
)
codebook_distance <- dist(
som_codebook_matrix,
method = "euclidean"
)
ward_tree <- hclust(
codebook_distance,
method = "ward.D2"
)
# ------------------------------------------------------------
# Elbow method for cluster-number selection
# ------------------------------------------------------------
calculate_wss <- function(data_matrix, cluster_vector) {
total_wss <- 0
for (cl in sort(unique(cluster_vector))) {
cluster_data <- data_matrix[
cluster_vector == cl,
,
drop = FALSE
]
cluster_center <- colMeans(
cluster_data
)
centered_data <- sweep(
cluster_data,
2,
cluster_center,
"-"
)
total_wss <- total_wss + sum(
centered_data^2
)
}
total_wss
}
cluster_k_values <- 2:10
cluster_selection_results <- list()
for (k in cluster_k_values) {
node_cluster <- cutree(
ward_tree,
k = k
)
wss_value <- calculate_wss(
som_codebook_matrix,
node_cluster
)
cluster_sizes <- table(
node_cluster
)
cluster_selection_results[[as.character(k)]] <- data.frame(
Number_of_Clusters = k,
Total_Within_Cluster_SS = wss_value,
Minimum_Nodes_Per_Cluster = min(cluster_sizes),
Maximum_Nodes_Per_Cluster = max(cluster_sizes),
stringsAsFactors = FALSE
)
}
cluster_selection_table <- dplyr::bind_rows(
cluster_selection_results
) |>
dplyr::arrange(
Number_of_Clusters
) |>
dplyr::mutate(
WSS_Decrease = dplyr::lag(Total_Within_Cluster_SS) -
Total_Within_Cluster_SS,
WSS_Decrease_Percent = round(
100 * WSS_Decrease / dplyr::lag(Total_Within_Cluster_SS),
2
)
)
# Maximum-distance elbow diagnostic
x_norm <- (
cluster_selection_table$Number_of_Clusters -
min(cluster_selection_table$Number_of_Clusters)
) /
(
max(cluster_selection_table$Number_of_Clusters) -
min(cluster_selection_table$Number_of_Clusters)
)
y_norm <- (
cluster_selection_table$Total_Within_Cluster_SS -
min(cluster_selection_table$Total_Within_Cluster_SS)
) /
(
max(cluster_selection_table$Total_Within_Cluster_SS) -
min(cluster_selection_table$Total_Within_Cluster_SS)
)
x1 <- x_norm[1]
y1 <- y_norm[1]
x2 <- x_norm[length(x_norm)]
y2 <- y_norm[length(y_norm)]
cluster_selection_table$Elbow_Distance <- abs(
(y2 - y1) * x_norm -
(x2 - x1) * y_norm +
x2 * y1 -
y2 * x1
) /
sqrt(
(y2 - y1)^2 +
(x2 - x1)^2
)
# Final selected cluster number for this study
# This keeps the final output as a 5-cluster SOM solution.
FINAL_CLUSTER_K <- 5
cat("\nSelected number of clusters from elbow interpretation:", FINAL_CLUSTER_K, "\n")##
## Selected number of clusters from elbow interpretation: 5
## Number_of_Clusters Total_Within_Cluster_SS Minimum_Nodes_Per_Cluster
## 1 2 70.71246 8
## 2 3 54.70034 4
## 3 4 42.11893 4
## 4 5 30.66268 3
## 5 6 24.25571 3
## 6 7 20.29639 2
## 7 8 17.65024 2
## 8 9 15.11273 2
## 9 10 12.88923 1
## Maximum_Nodes_Per_Cluster WSS_Decrease WSS_Decrease_Percent Elbow_Distance
## 1 22 NA NA 0.00000000
## 2 22 16.012124 22.64 0.10742017
## 3 13 12.581404 23.00 0.17288687
## 4 10 11.456249 27.20 0.22459431
## 5 10 6.406970 20.90 0.21455530
## 6 10 3.959324 16.32 0.17458460
## 7 10 2.646153 13.04 0.11855544
## 8 7 2.537508 14.38 0.06119768
## 9 7 2.223500 14.71 0.00000000
# ------------------------------------------------------------
# Plot elbow method with selected k = 5
# ------------------------------------------------------------
selected_elbow_row <- cluster_selection_table[
cluster_selection_table$Number_of_Clusters == FINAL_CLUSTER_K,
,
drop = FALSE
]
plot_elbow <- ggplot2::ggplot(
cluster_selection_table,
ggplot2::aes(
x = Number_of_Clusters,
y = Total_Within_Cluster_SS
)
) +
ggplot2::geom_line(
linewidth = 1.2,
color = "black"
) +
ggplot2::geom_point(
size = 3.8,
color = "black"
) +
ggplot2::geom_vline(
xintercept = FINAL_CLUSTER_K,
linetype = "dashed",
linewidth = 0.9,
color = "#7C2A35"
) +
ggplot2::geom_point(
data = selected_elbow_row,
ggplot2::aes(
x = Number_of_Clusters,
y = Total_Within_Cluster_SS
),
size = 5.5,
color = "#7C2A35"
) +
ggplot2::annotate(
"text",
x = FINAL_CLUSTER_K,
y = selected_elbow_row$Total_Within_Cluster_SS,
label = paste0("Selected k = ", FINAL_CLUSTER_K),
hjust = -0.08,
vjust = -0.8,
family = "serif",
fontface = "bold",
size = 5,
color = "#7C2A35"
) +
ggplot2::scale_x_continuous(
breaks = cluster_k_values
) +
ggplot2::labs(
title = "Elbow Method for Ward Clustering of SOM Nodes",
subtitle = "Final SOM solution selected as five clusters",
x = "Number of clusters",
y = "Total within-cluster sum of squares"
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 15
) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold",
size = 18
),
plot.subtitle = ggplot2::element_text(
size = 12
),
axis.title = ggplot2::element_text(
face = "bold"
),
axis.text = ggplot2::element_text(
color = "black"
)
)
plot_elbowggplot2::ggsave(
filename = file.path(
FINAL_SOM_OUT_DIR,
"Elbow_Method_Selected_5_Clusters.png"
),
plot = plot_elbow,
width = 10,
height = 6.5,
dpi = 600,
bg = "white"
)
# ------------------------------------------------------------
# Final Ward cluster assignment using k = 5
# ------------------------------------------------------------
final_node_cluster <- cutree(
ward_tree,
k = FINAL_CLUSTER_K
)
node_cluster_table <- data.frame(
SOM_Node = seq_len(length(final_node_cluster)),
SOM_Cluster = paste0(
"Cluster ",
final_node_cluster
),
stringsAsFactors = FALSE
)
# ------------------------------------------------------------
# Assign each crash to SOM node and SOM cluster
# ------------------------------------------------------------
crash_node_assignment <- data.frame(
Original_Row_ID = shap_som_data$Original_Row_ID,
Actual = shap_som_data$Actual,
Predicted = shap_som_data$Predicted,
Predicted_Probability_Crossing_Lane =
shap_som_data$Predicted_Probability_Crossing_Lane,
SOM_Node = final_som_model$unit.classif,
stringsAsFactors = FALSE
)
crash_node_assignment <- crash_node_assignment |>
dplyr::left_join(
node_cluster_table,
by = "SOM_Node"
)
shap_with_cluster <- cbind(
crash_node_assignment,
som_input_raw
)
# ------------------------------------------------------------
# Reconstruct original test-set variable values for SHAP labels
# ------------------------------------------------------------
clean_text <- function(x) {
x <- as.character(x)
x <- stringr::str_squish(x)
x[is.na(x) | x == ""] <- "Not Reported"
x
}
bike_data <- readxl::read_excel(
path = DATA_FILE,
sheet = SHEET_NAME,
guess_max = 100000
)
bike_data <- bike_data |>
dplyr::mutate(
dplyr::across(
where(is.character),
clean_text
)
)
if (!TARGET_VARIABLE %in% names(bike_data)) {
stop(
paste0(
"Target variable not found: ",
TARGET_VARIABLE
)
)
}
bike_data[[TARGET_VARIABLE]] <- factor(
clean_text(bike_data[[TARGET_VARIABLE]]),
levels = c(
NEGATIVE_CLASS,
POSITIVE_CLASS
)
)
bike_data <- bike_data[
!is.na(bike_data[[TARGET_VARIABLE]]),
,
drop = FALSE
]
FINAL_PREDICTORS <- setdiff(
names(bike_data),
c(
TARGET_VARIABLE,
EXCLUDED_VARIABLES
)
)
model_data <- bike_data[
,
c(
FINAL_PREDICTORS,
TARGET_VARIABLE
),
drop = FALSE
]
for (v in FINAL_PREDICTORS) {
if (is.character(model_data[[v]])) {
model_data[[v]] <- clean_text(model_data[[v]])
}
}
model_data <- model_data[
complete.cases(model_data),
,
drop = FALSE
]
raw_predictor_names <- intersect(
shap_variables,
names(model_data)
)
shap_som_data$Original_Row_ID <- as.integer(
shap_som_data$Original_Row_ID
)
if (max(shap_som_data$Original_Row_ID, na.rm = TRUE) > nrow(model_data)) {
stop("Original_Row_ID is larger than model_data rows. Cannot reconstruct raw labels safely.")
}
raw_test_values <- model_data[
shap_som_data$Original_Row_ID,
raw_predictor_names,
drop = FALSE
]
for (v in names(raw_test_values)) {
raw_test_values[[v]] <- as.character(raw_test_values[[v]])
raw_test_values[[v]][
is.na(raw_test_values[[v]]) |
raw_test_values[[v]] == "" |
raw_test_values[[v]] == "NA"
] <- "Not Reported"
}
names(raw_test_values) <- paste0(
"RAW__",
names(raw_test_values)
)
shap_with_cluster <- cbind(
shap_with_cluster,
raw_test_values
)
# ------------------------------------------------------------
# Confusion-matrix group
# ------------------------------------------------------------
shap_with_cluster$CM_Group <- NA_character_
shap_with_cluster$CM_Group[
shap_with_cluster$Predicted == NEGATIVE_CLASS &
shap_with_cluster$Actual == NEGATIVE_CLASS
] <- "TN"
shap_with_cluster$CM_Group[
shap_with_cluster$Predicted == NEGATIVE_CLASS &
shap_with_cluster$Actual == POSITIVE_CLASS
] <- "FN"
shap_with_cluster$CM_Group[
shap_with_cluster$Predicted == POSITIVE_CLASS &
shap_with_cluster$Actual == NEGATIVE_CLASS
] <- "FP"
shap_with_cluster$CM_Group[
shap_with_cluster$Predicted == POSITIVE_CLASS &
shap_with_cluster$Actual == POSITIVE_CLASS
] <- "TP"
# ------------------------------------------------------------
# Overall Crossing Lane baseline
# ------------------------------------------------------------
overall_crossing_percent <- round(
100 * mean(shap_with_cluster$Actual == POSITIVE_CLASS),
2
)
cat("Overall Crossing Lane percentage in test data:", overall_crossing_percent, "%\n")## Overall Crossing Lane percentage in test data: 11.58 %
# ------------------------------------------------------------
# Cluster ordering helper
# ------------------------------------------------------------
get_cluster_number <- function(x) {
as.integer(
gsub(
"Cluster ",
"",
as.character(x)
)
)
}
cluster_order <- paste0(
"Cluster ",
sort(
unique(
get_cluster_number(
shap_with_cluster$SOM_Cluster
)
)
)
)
# ------------------------------------------------------------
# Cluster-level profile table
# ------------------------------------------------------------
get_percent <- function(x, value) {
round(
100 * mean(x == value, na.rm = TRUE),
1
)
}
get_cm_percent <- function(x, value) {
round(
100 * mean(x == value, na.rm = TRUE),
1
)
}
cluster_profile_list <- list()
for (cl in cluster_order) {
cluster_data <- shap_with_cluster[
shap_with_cluster$SOM_Cluster == cl,
,
drop = FALSE
]
if (nrow(cluster_data) == 0) {
next
}
crossing_percent <- get_percent(
cluster_data$Actual,
POSITIVE_CLASS
)
cluster_orientation <- ifelse(
crossing_percent > overall_crossing_percent,
"Crossing Lane",
"Travel Lane"
)
cluster_profile_list[[cl]] <- data.frame(
SOM_Cluster = cl,
Number_of_Crashes = nrow(cluster_data),
Actual_Travel_Lane_Percent = get_percent(
cluster_data$Actual,
NEGATIVE_CLASS
),
Actual_Crossing_Lane_Percent = crossing_percent,
Predicted_Travel_Lane_Percent = get_percent(
cluster_data$Predicted,
NEGATIVE_CLASS
),
Predicted_Crossing_Lane_Percent = get_percent(
cluster_data$Predicted,
POSITIVE_CLASS
),
TP_Percent = get_cm_percent(
cluster_data$CM_Group,
"TP"
),
FP_Percent = get_cm_percent(
cluster_data$CM_Group,
"FP"
),
TN_Percent = get_cm_percent(
cluster_data$CM_Group,
"TN"
),
FN_Percent = get_cm_percent(
cluster_data$CM_Group,
"FN"
),
Mean_Predicted_Probability_Crossing_Lane = round(
mean(
cluster_data$Predicted_Probability_Crossing_Lane,
na.rm = TRUE
),
4
),
Overall_Crossing_Lane_Percent_Test_Set = overall_crossing_percent,
Cluster_Orientation = cluster_orientation,
stringsAsFactors = FALSE
)
}
cluster_profile_table <- dplyr::bind_rows(
cluster_profile_list
) |>
dplyr::mutate(
Cluster_Number = get_cluster_number(SOM_Cluster)
) |>
dplyr::arrange(
Cluster_Number
) |>
dplyr::select(
-Cluster_Number
)
print(cluster_profile_table)## SOM_Cluster Number_of_Crashes Actual_Travel_Lane_Percent
## 1 Cluster 1 141 61.0
## 2 Cluster 2 474 85.0
## 3 Cluster 3 681 98.2
## 4 Cluster 4 184 71.7
## 5 Cluster 5 213 97.2
## Actual_Crossing_Lane_Percent Predicted_Travel_Lane_Percent
## 1 39.0 83.7
## 2 15.0 100.0
## 3 1.8 100.0
## 4 28.3 89.7
## 5 2.8 100.0
## Predicted_Crossing_Lane_Percent TP_Percent FP_Percent TN_Percent FN_Percent
## 1 16.3 8.5 7.8 53.2 30.5
## 2 0.0 0.0 0.0 85.0 15.0
## 3 0.0 0.0 0.0 98.2 1.8
## 4 10.3 3.3 7.1 64.7 25.0
## 5 0.0 0.0 0.0 97.2 2.8
## Mean_Predicted_Probability_Crossing_Lane
## 1 0.3809
## 2 0.1295
## 3 0.0287
## 4 0.3474
## 5 0.0445
## Overall_Crossing_Lane_Percent_Test_Set Cluster_Orientation
## 1 11.58 Crossing Lane
## 2 11.58 Crossing Lane
## 3 11.58 Travel Lane
## 4 11.58 Crossing Lane
## 5 11.58 Travel Lane
# ------------------------------------------------------------
# Cluster-level SHAP summary
# ------------------------------------------------------------
cluster_level_shap_long <- list()
for (cl in cluster_order) {
cluster_data <- shap_with_cluster[
shap_with_cluster$SOM_Cluster == cl,
,
drop = FALSE
]
if (nrow(cluster_data) == 0) {
next
}
cluster_level_shap_long[[cl]] <- data.frame(
SOM_Cluster = cl,
Variable_Name = shap_variables,
Mean_SHAP = colMeans(
cluster_data[
,
shap_variables,
drop = FALSE
],
na.rm = TRUE
),
Mean_Absolute_SHAP = colMeans(
abs(
cluster_data[
,
shap_variables,
drop = FALSE
]
),
na.rm = TRUE
),
stringsAsFactors = FALSE
)
}
cluster_level_shap_long <- dplyr::bind_rows(
cluster_level_shap_long
) |>
dplyr::mutate(
Cluster_Number = get_cluster_number(SOM_Cluster)
) |>
dplyr::arrange(
Cluster_Number,
dplyr::desc(Mean_Absolute_SHAP)
) |>
dplyr::select(
-Cluster_Number
)
# ------------------------------------------------------------
# Node-level table for SOM maps
# ------------------------------------------------------------
node_count_table <- shap_with_cluster |>
dplyr::group_by(
SOM_Node,
SOM_Cluster
) |>
dplyr::summarise(
Number_of_Crashes = dplyr::n(),
Actual_Crossing_Lane_Percent_Node = round(
100 * mean(Actual == POSITIVE_CLASS),
2
),
Mean_Predicted_Probability_Crossing_Lane = round(
mean(
Predicted_Probability_Crossing_Lane,
na.rm = TRUE
),
4
),
.groups = "drop"
)
som_node_map_data <- data.frame(
SOM_Node = seq_len(nrow(final_som_model$grid$pts)),
X = final_som_model$grid$pts[, 1],
Y = final_som_model$grid$pts[, 2],
stringsAsFactors = FALSE
) |>
dplyr::left_join(
node_cluster_table,
by = "SOM_Node"
) |>
dplyr::left_join(
node_count_table,
by = c(
"SOM_Node",
"SOM_Cluster"
)
) |>
dplyr::left_join(
cluster_profile_table[
,
c(
"SOM_Cluster",
"Cluster_Orientation",
"Actual_Crossing_Lane_Percent"
)
],
by = "SOM_Cluster"
)
som_node_map_data$Number_of_Crashes[
is.na(som_node_map_data$Number_of_Crashes)
] <- 0
som_node_map_data$Actual_Crossing_Lane_Percent_Node[
is.na(som_node_map_data$Actual_Crossing_Lane_Percent_Node)
] <- 0
som_node_map_data$Node_Orientation <- ifelse(
som_node_map_data$Actual_Crossing_Lane_Percent_Node >
overall_crossing_percent,
"Crossing Lane-oriented",
"Travel Lane-oriented"
)
som_node_map_data$Node_Cluster_Label <- paste0(
"C",
gsub(
"Cluster ",
"",
som_node_map_data$SOM_Cluster
)
)
som_node_map_data$Node_Label <- paste0(
som_node_map_data$Node_Cluster_Label,
"\n",
som_node_map_data$Actual_Crossing_Lane_Percent_Node,
"%"
)
som_node_map_data$Node_Text_Color <- ifelse(
som_node_map_data$Node_Orientation == "Crossing Lane-oriented",
"white",
"black"
)
# ------------------------------------------------------------
# Hexagon helper
# Larger radius makes compact hexagons
# ------------------------------------------------------------
make_hexagon <- function(x, y, id, radius = 0.56) {
angle <- seq(
0,
2 * pi,
length.out = 7
)[-7] + pi / 6
data.frame(
SOM_Node = id,
X_hex = x + radius * cos(angle),
Y_hex = y + radius * sin(angle),
stringsAsFactors = FALSE
)
}
hex_data <- dplyr::bind_rows(
lapply(
seq_len(nrow(som_node_map_data)),
function(i) {
make_hexagon(
x = som_node_map_data$X[i],
y = som_node_map_data$Y[i],
id = som_node_map_data$SOM_Node[i]
)
}
)
) |>
dplyr::left_join(
som_node_map_data,
by = "SOM_Node"
)
# ------------------------------------------------------------
# Cluster colors
# ------------------------------------------------------------
unique_clusters <- cluster_order
cluster_colors_base <- c(
"#7C2A35",
"#9B3F49",
"#B56A6D",
"#CBB4A7",
"#8A6E63",
"#4B1D24",
"#D6C6BC",
"#A77C76",
"#5C2D33",
"#E0D3CA"
)
cluster_colors <- setNames(
rep(
cluster_colors_base,
length.out = length(unique_clusters)
),
unique_clusters
)
# ------------------------------------------------------------
# SOM map 1: Ward cluster map
# ------------------------------------------------------------
plot_som_top <- ggplot2::ggplot(
hex_data,
ggplot2::aes(
x = X_hex,
y = Y_hex,
group = SOM_Node,
fill = SOM_Cluster
)
) +
ggplot2::geom_polygon(
color = "black",
linewidth = 0.45
) +
ggplot2::geom_text(
data = som_node_map_data,
ggplot2::aes(
x = X,
y = Y,
label = paste0(
"C",
gsub("Cluster ", "", SOM_Cluster),
"\n",
Number_of_Crashes
)
),
inherit.aes = FALSE,
family = "serif",
fontface = "bold",
size = 4.6
) +
ggplot2::scale_fill_manual(
values = cluster_colors
) +
ggplot2::coord_equal() +
ggplot2::labs(
title = "SOM Nodes Grouped by Ward Clusters",
fill = "SOM cluster"
) +
ggplot2::theme_void(
base_family = "serif",
base_size = 15
) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold",
size = 20
),
legend.title = ggplot2::element_text(
face = "bold",
size = 16
),
legend.text = ggplot2::element_text(
color = "black",
size = 14
)
)
plot_som_topggplot2::ggsave(
filename = file.path(
FINAL_SOM_OUT_DIR,
"SOM_Map_Ward_Clusters.png"
),
plot = plot_som_top,
width = 10,
height = 7.5,
dpi = 600,
bg = "white"
)
# ------------------------------------------------------------
# SOM map 2: Crossing Lane / Travel Lane node map
# ------------------------------------------------------------
# ------------------------------------------------------------
# SOM map 2: Crossing Lane percentage by SOM node
# ------------------------------------------------------------
plot_som_bottom <- ggplot2::ggplot(
hex_data,
ggplot2::aes(
x = X_hex,
y = Y_hex,
group = SOM_Node,
fill = Node_Orientation
)
) +
ggplot2::geom_polygon(
color = "black",
linewidth = 0.45
) +
ggplot2::geom_text(
data = som_node_map_data,
ggplot2::aes(
x = X,
y = Y,
label = Node_Label,
color = Node_Text_Color
),
inherit.aes = FALSE,
family = "serif",
fontface = "bold",
size = 3.7,
lineheight = 0.85
) +
ggplot2::scale_color_identity() +
ggplot2::scale_fill_manual(
values = c(
"Crossing Lane-oriented" = "#7C2A35",
"Travel Lane-oriented" = "#CBB4A7"
)
) +
ggplot2::coord_equal() +
ggplot2::labs(
title = paste0(
"Observed Crossing Lane Percentage by SOM Node; Baseline = ",
overall_crossing_percent,
"%"
),
fill = "Node orientation"
) +
ggplot2::theme_void(
base_family = "serif",
base_size = 15
) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold",
size = 20
),
legend.title = ggplot2::element_text(
face = "bold",
size = 16
),
legend.text = ggplot2::element_text(
color = "black",
size = 14
)
)
plot_som_bottomggplot2::ggsave(
filename = file.path(
FINAL_SOM_OUT_DIR,
"SOM_Map_Crossing_Lane_Percentage_With_Cluster_Label.png"
),
plot = plot_som_bottom,
width = 10,
height = 7.5,
dpi = 600,
bg = "white"
)
# ------------------------------------------------------------
# Reference-style cluster SHAP plots
# ------------------------------------------------------------
TOP_N_SHAP_VARIABLES <- 12
make_feature_label <- function(cluster_data, variable_name) {
raw_col <- paste0(
"RAW__",
variable_name
)
if (!raw_col %in% names(cluster_data)) {
return(variable_name)
}
x <- as.character(
cluster_data[[raw_col]]
)
x[
is.na(x) |
x == "" |
x == "NA"
] <- "Not Reported"
tab <- sort(
table(x),
decreasing = TRUE
)
if (length(tab) == 0) {
return(
paste0(
variable_name,
" = Not Reported: 0%"
)
)
}
top_level <- names(tab)[1]
pct <- round(
100 * as.numeric(tab[1]) / length(x),
0
)
paste0(
variable_name,
" = ",
top_level,
": ",
pct,
"%"
)
}
make_cluster_reference_plot <- function(cluster_name) {
cluster_data <- shap_with_cluster[
shap_with_cluster$SOM_Cluster == cluster_name,
,
drop = FALSE
]
cluster_profile <- cluster_profile_table[
cluster_profile_table$SOM_Cluster == cluster_name,
,
drop = FALSE
]
shap_summary <- cluster_level_shap_long[
cluster_level_shap_long$SOM_Cluster == cluster_name,
,
drop = FALSE
]
shap_summary <- shap_summary[
order(
abs(shap_summary$Mean_SHAP),
decreasing = TRUE
),
,
drop = FALSE
]
shap_summary <- head(
shap_summary,
TOP_N_SHAP_VARIABLES
)
shap_summary$Label <- sapply(
shap_summary$Variable_Name,
function(v) {
make_feature_label(
cluster_data,
v
)
}
)
shap_summary <- shap_summary[
order(
shap_summary$Mean_SHAP
),
,
drop = FALSE
]
shap_summary$Label <- factor(
shap_summary$Label,
levels = shap_summary$Label
)
bottom_heading <- paste0(
cluster_name,
" (TP = ",
cluster_profile$TP_Percent,
"%; FP = ",
cluster_profile$FP_Percent,
"%; TN = ",
cluster_profile$TN_Percent,
"%; FN = ",
cluster_profile$FN_Percent,
"%; n = ",
format(
cluster_profile$Number_of_Crashes,
big.mark = ","
),
")"
)
bottom_subheading <- paste0(
"Cluster type: ",
cluster_profile$Cluster_Orientation,
"; observed Crossing Lane = ",
cluster_profile$Actual_Crossing_Lane_Percent,
"%; test-set baseline = ",
overall_crossing_percent,
"%"
)
p_bar <- ggplot2::ggplot(
shap_summary,
ggplot2::aes(
x = Mean_SHAP,
y = Label,
fill = Mean_SHAP > 0
)
) +
ggplot2::geom_col(
color = "black",
linewidth = 0.35,
width = 0.68
) +
ggplot2::scale_fill_manual(
values = c(
"TRUE" = "#7C2A35",
"FALSE" = "#CBB4A7"
),
guide = "none"
) +
ggplot2::geom_vline(
xintercept = 0,
color = "black",
linewidth = 0.45
) +
ggplot2::labs(
x = "Mean SHAP value",
y = NULL
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 15
) +
ggplot2::theme(
axis.text.y = ggplot2::element_text(
face = "bold",
color = "black",
size = 15
),
axis.text.x = ggplot2::element_text(
face = "bold",
color = "black",
size = 13
),
axis.title.x = ggplot2::element_text(
face = "bold",
size = 16
),
panel.grid.major.y = ggplot2::element_blank(),
panel.grid.minor = ggplot2::element_blank()
)
p_box <- ggplot2::ggplot(
cluster_data,
ggplot2::aes(
x = "",
y = Predicted_Probability_Crossing_Lane
)
) +
ggplot2::geom_boxplot(
fill = "white",
color = "black",
width = 0.32,
outlier.size = 0.95,
linewidth = 0.50
) +
ggplot2::coord_flip() +
ggplot2::scale_y_continuous(
limits = c(0, 1),
breaks = seq(0, 1, 0.25)
) +
ggplot2::labs(
x = NULL,
y = "Predicted probability of Crossing Lane"
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 13
) +
ggplot2::theme(
axis.text.y = ggplot2::element_blank(),
axis.ticks.y = ggplot2::element_blank(),
axis.text.x = ggplot2::element_text(
face = "bold",
color = "black",
size = 12
),
axis.title.x = ggplot2::element_text(
face = "bold",
size = 13
),
panel.grid.major.y = ggplot2::element_blank(),
panel.grid.minor = ggplot2::element_blank()
)
combined_plot <- gridExtra::arrangeGrob(
p_bar,
p_box,
ncol = 1,
heights = c(4.5, 1.0),
bottom = grid::textGrob(
paste0(
bottom_heading,
"\n",
bottom_subheading
),
gp = grid::gpar(
fontfamily = "serif",
fontsize = 16,
fontface = "bold",
lineheight = 1.15
)
)
)
combined_plot
}
for (cl in cluster_order) {
this_plot <- make_cluster_reference_plot(
cl
)
safe_cluster_name <- gsub(
" ",
"_",
cl
)
grid::grid.newpage()
grid::grid.draw(this_plot)
ggplot2::ggsave(
filename = file.path(
FINAL_SOM_OUT_DIR,
paste0(
safe_cluster_name,
"_Reference_Style_SHAP_Profile.png"
)
),
plot = this_plot,
width = 14,
height = 9.5,
dpi = 600,
bg = "white"
)
}# ------------------------------------------------------------
# Save output files
# ------------------------------------------------------------
write.csv(
cluster_selection_table,
file.path(
FINAL_SOM_OUT_DIR,
"Elbow_Cluster_Number_Selection_Table.csv"
),
row.names = FALSE
)
write.csv(
cluster_profile_table,
file.path(
FINAL_SOM_OUT_DIR,
"Cluster_Profile_Target_Orientation_Table.csv"
),
row.names = FALSE
)
write.csv(
node_cluster_table,
file.path(
FINAL_SOM_OUT_DIR,
"SOM_Node_Cluster_Assignment.csv"
),
row.names = FALSE
)
write.csv(
shap_with_cluster,
file.path(
FINAL_SOM_OUT_DIR,
"Crash_Level_SOM_Cluster_Assignment_With_Raw_Labels.csv"
),
row.names = FALSE
)
write.csv(
cluster_level_shap_long,
file.path(
FINAL_SOM_OUT_DIR,
"Cluster_Level_SHAP_Long_Table.csv"
),
row.names = FALSE
)
writexl::write_xlsx(
list(
Elbow_Cluster_Selection = cluster_selection_table,
Cluster_Profile_Target_Orientation = cluster_profile_table,
SOM_Node_Cluster_Assignment = node_cluster_table,
Crash_Level_Assignment = shap_with_cluster,
Cluster_Level_SHAP_Long = cluster_level_shap_long
),
file.path(
FINAL_SOM_OUT_DIR,
"Final_SOM_Elbow_Reference_Style_Output.xlsx"
)
)
saveRDS(
final_som_model,
file.path(
FINAL_SOM_OUT_DIR,
"Final_SOM_Model_6x5.rds"
)
)
saveRDS(
ward_tree,
file.path(
FINAL_SOM_OUT_DIR,
"Ward_Clustering_Tree_SOM_Codebook.rds"
)
)
cat("\nFinal SOM, elbow, and reference-style output generation completed.\n")##
## Final SOM, elbow, and reference-style output generation completed.
## Final SOM grid: 6 x 5
## Selected number of clusters: 5
## Overall Crossing Lane percentage in test data: 11.58 %
## Variables excluded from SHAP/SOM: Hit and Run, Road Relation
## Outputs saved to:
## C:/Users/baitu/Desktop/Final_SOM_Elbow_Reference_Style_Outputs/Run_20260715_195723
14 Medium-pruned decision-tree rule extraction for SHAP-SOM clusters
# ============================================================
# MEDIUM-PRUNED DECISION TREE FOR SHAP-SOM CLUSTER RULES
#
# Purpose:
# - Explain the 5 SHAP-SOM clusters using original retained variables.
# - This is NOT another Travel Lane vs Crossing Lane prediction model.
# - Target variable = SOM_Cluster
# - Predictors = original retained crash variables
# - Excluded variables = Hit and Run, Road Relation
#
# This version:
# 1. Uses medium pruning, not strict pruning.
# 2. Removes confusing yes/no branch labels from the figure.
# 3. Saves SVG and PNG versions.
# 4. Saves one compact tree with correct/total node values.
# 5. Saves one detailed tree with C1-C5 percentage distributions.
# 6. Saves a paper-ready rule table with C1-C5 percentages.
# ============================================================
options(pkgType = "binary")
required_packages <- c(
"dplyr",
"stringr",
"ggplot2",
"rpart",
"rpart.plot",
"writexl",
"gridExtra",
"grid",
"svglite"
)
packages_to_install <- required_packages[
!required_packages %in% rownames(installed.packages())
]
if (length(packages_to_install) > 0) {
install.packages(
packages_to_install,
repos = "https://cloud.r-project.org",
type = "binary",
dependencies = TRUE
)
}
invisible(
lapply(
required_packages,
library,
character.only = TRUE
)
)
# ------------------------------------------------------------
# Locate final SOM output folder
# ------------------------------------------------------------
if (!exists("FINAL_SOM_OUT_DIR")) {
possible_dirs <- list.dirs(
path = file.path(
"C:/Users/baitu/Desktop",
"Final_SOM_Elbow_Reference_Style_Outputs"
),
recursive = FALSE,
full.names = TRUE
)
if (length(possible_dirs) == 0) {
stop(
paste0(
"No SOM output folder found on Desktop.\n",
"Please run the final SOM section first."
)
)
}
FINAL_SOM_OUT_DIR <- possible_dirs[
which.max(
file.info(possible_dirs)$mtime
)
]
}
DECISION_TREE_OUT_DIR <- file.path(
FINAL_SOM_OUT_DIR,
"Medium_Pruned_Decision_Tree_Cluster_Rules_SVG"
)
dir.create(
DECISION_TREE_OUT_DIR,
recursive = TRUE,
showWarnings = FALSE
)
cat("Medium-pruned decision-tree outputs will be saved to:\n")## Medium-pruned decision-tree outputs will be saved to:
## C:/Users/baitu/Desktop/Final_SOM_Elbow_Reference_Style_Outputs/Run_20260715_195723/Medium_Pruned_Decision_Tree_Cluster_Rules_SVG
# ------------------------------------------------------------
# Load final SOM cluster assignment
# ------------------------------------------------------------
if (!exists("shap_with_cluster")) {
cluster_assignment_file <- file.path(
FINAL_SOM_OUT_DIR,
"Crash_Level_SOM_Cluster_Assignment_With_Raw_Labels.csv"
)
if (!file.exists(cluster_assignment_file)) {
stop(
paste0(
"Cluster assignment file not found:\n",
cluster_assignment_file,
"\nPlease run the final SOM section first."
)
)
}
shap_with_cluster <- read.csv(
cluster_assignment_file,
check.names = FALSE,
stringsAsFactors = FALSE
)
}
if (!"SOM_Cluster" %in% names(shap_with_cluster)) {
stop("SOM_Cluster column was not found.")
}
# ------------------------------------------------------------
# Load cluster profile table
# ------------------------------------------------------------
cluster_profile_file <- file.path(
FINAL_SOM_OUT_DIR,
"Cluster_Profile_Target_Orientation_Table.csv"
)
if (file.exists(cluster_profile_file)) {
cluster_profile_table <- read.csv(
cluster_profile_file,
check.names = FALSE,
stringsAsFactors = FALSE
)
} else if (!exists("cluster_profile_table")) {
stop(
paste0(
"Cluster profile table was not found:\n",
cluster_profile_file,
"\nPlease run the final SOM section first."
)
)
}
# ------------------------------------------------------------
# Settings
# ------------------------------------------------------------
EXCLUDED_VARIABLES <- c(
"Hit and Run",
"Road Relation"
)
# ------------------------------------------------------------
# Use original retained variables from RAW__ columns
# ------------------------------------------------------------
raw_variable_columns <- names(shap_with_cluster)[
startsWith(
names(shap_with_cluster),
"RAW__"
)
]
if (length(raw_variable_columns) == 0) {
stop(
paste0(
"No RAW__ columns were found.\n",
"Please rerun the final SOM section that reconstructs original variable labels."
)
)
}
raw_variable_names <- gsub(
"^RAW__",
"",
raw_variable_columns
)
keep_raw_columns <- raw_variable_columns[
!raw_variable_names %in% EXCLUDED_VARIABLES
]
kept_variable_names <- gsub(
"^RAW__",
"",
keep_raw_columns
)
if ("Hit and Run" %in% kept_variable_names | "Road Relation" %in% kept_variable_names) {
stop("Hit and Run or Road Relation is still present. Check exclusion step.")
}
cat("Variables used in medium-pruned decision tree:\n")## Variables used in medium-pruned decision tree:
## Weather
## Lighting Condition
## Rural/Urban
## Intersection Related
## Trafficway Configuration
## Road Alignment
## Surface Condition
## Vehicle Movement
## Bicyclist age
## Driver Age
## Daytime
## Vehicle Type
## Speed Limit
## Contributing Factors
# ------------------------------------------------------------
# Create original data for decision tree
# ------------------------------------------------------------
dt_data_original <- shap_with_cluster[
,
c(
"SOM_Cluster",
keep_raw_columns
),
drop = FALSE
]
names(dt_data_original) <- c(
"SOM_Cluster",
kept_variable_names
)
dt_data_original <- dt_data_original[
complete.cases(dt_data_original),
,
drop = FALSE
]
# ------------------------------------------------------------
# Short variable names for plotting only
# ------------------------------------------------------------
short_variable_name <- function(x) {
dplyr::case_when(
x == "Lighting Condition" ~ "Lighting",
x == "Rural/Urban" ~ "Area",
x == "Intersection Related" ~ "Intersection",
x == "Vehicle Movement" ~ "Movement",
x == "Contributing Factors" ~ "Factor",
x == "Trafficway Configuration" ~ "Trafficway",
x == "Road Alignment" ~ "Alignment",
x == "Surface Condition" ~ "Surface",
x == "Vehicle Type" ~ "Vehicle",
x == "Speed Limit" ~ "Speed",
x == "Bicyclist age" ~ "Bike Age",
x == "Driver Age" ~ "Driver Age",
x == "Daytime" ~ "Day",
TRUE ~ x
)
}
shorten_level <- function(x) {
x <- as.character(x)
x <- stringr::str_squish(x)
x <- dplyr::case_when(
x %in% c("Daylight") ~ "Daylight",
stringr::str_detect(x, regex("dark", ignore_case = TRUE)) ~ "Dark",
x %in% c("At Intersection") ~ "At Int.",
x %in% c("Not At Intersection") ~ "Not at Int.",
x %in% c("Urban") ~ "Urban",
x %in% c("Rural") ~ "Rural",
x %in% c("Others") ~ "Other",
x %in% c("Turning Left/Right") ~ "Turning",
x %in% c("Going Straight") ~ "Straight",
x %in% c("Turning / Merging") ~ "Turn/Merge",
x %in% c("Crossing / Entering") ~ "Cross/Enter",
x %in% c("Overtaking") ~ "Overtake",
x %in% c("Loss of Control / Direction Error") ~ "LOC/Dir.Err",
x %in% c("Two-Way Undivided") ~ "2W Undiv.",
x %in% c("Two-Way, Divided") ~ "2W Div.",
x %in% c("One-Way Trafficway") ~ "1W",
x %in% c("Straight") ~ "Straight",
x %in% c("Curve") ~ "Curve",
x %in% c("Dry") ~ "Dry",
x %in% c("Wet") ~ "Wet",
x %in% c("Cloudy") ~ "Cloudy",
x %in% c("Clear") ~ "Clear",
x %in% c("Weekday") ~ "Weekday",
x %in% c("Weekend") ~ "Weekend",
x %in% c("Car/ Motorcycle") ~ "Car/Moto",
x %in% c("Bus/Truck") ~ "Bus/Truck",
TRUE ~ x
)
x <- stringr::str_replace_all(x, " Years Age", "")
x <- stringr::str_replace_all(x, " MPH", "")
x <- stringr::str_replace_all(x, " or Less", "≤")
x <- stringr::str_replace_all(x, "Not Reported", "NR")
x
}
dt_data_plot <- dt_data_original
names(dt_data_plot) <- c(
"SOM_Cluster",
sapply(
kept_variable_names,
short_variable_name
)
)
dt_data_plot$SOM_Cluster <- factor(
dt_data_plot$SOM_Cluster,
levels = paste0(
"Cluster ",
sort(
as.integer(
gsub(
"Cluster ",
"",
unique(dt_data_plot$SOM_Cluster)
)
)
)
)
)
for (v in setdiff(names(dt_data_plot), "SOM_Cluster")) {
dt_data_plot[[v]] <- shorten_level(
dt_data_plot[[v]]
)
dt_data_plot[[v]][
is.na(dt_data_plot[[v]]) |
dt_data_plot[[v]] == "" |
dt_data_plot[[v]] == "NA"
] <- "NR"
dt_data_plot[[v]] <- factor(
dt_data_plot[[v]]
)
}
cat("Rows used in medium-pruned decision-tree rule extraction:", nrow(dt_data_plot), "\n")## Rows used in medium-pruned decision-tree rule extraction: 1693
## Number of predictors: 14
# ------------------------------------------------------------
# Build formula
# ------------------------------------------------------------
predictor_terms <- paste0(
"`",
setdiff(names(dt_data_plot), "SOM_Cluster"),
"`",
collapse = " + "
)
dt_formula <- as.formula(
paste(
"SOM_Cluster ~",
predictor_terms
)
)
# ------------------------------------------------------------
# Fit medium-pruned decision tree
# ------------------------------------------------------------
set.seed(2027)
cluster_tree_full <- rpart::rpart(
formula = dt_formula,
data = dt_data_plot,
method = "class",
parms = list(
split = "gini"
),
control = rpart::rpart.control(
minsplit = 70,
minbucket = 30,
cp = 0.0005,
xval = 10,
maxdepth = 4
)
)
cp_table <- as.data.frame(
cluster_tree_full$cptable
)
cp_table$Row_ID <- seq_len(
nrow(cp_table)
)
# ------------------------------------------------------------
# Select medium-complexity tree
# Prefer 8 to 12 terminal rules when available.
# ------------------------------------------------------------
cp_table$Terminal_Rules <- cp_table$nsplit + 1
medium_candidates <- cp_table[
cp_table$Terminal_Rules >= 8 &
cp_table$Terminal_Rules <= 12,
,
drop = FALSE
]
if (nrow(medium_candidates) > 0) {
selected_row <- medium_candidates[
which.min(
medium_candidates$xerror
),
,
drop = FALSE
]
} else {
selected_row <- cp_table[
which.min(
cp_table$xerror
),
,
drop = FALSE
]
}
selected_cp <- selected_row$CP[1]
cluster_tree_pruned <- rpart::prune(
cluster_tree_full,
cp = selected_cp
)
number_of_terminal_nodes <- sum(
cluster_tree_pruned$frame$var == "<leaf>"
)
cat("Selected CP:", selected_cp, "\n")## Selected CP: 0.0005
## Number of terminal rules: 9
# ------------------------------------------------------------
# Decision-tree fidelity to SOM clusters
# ------------------------------------------------------------
dt_predicted_cluster <- predict(
cluster_tree_pruned,
newdata = dt_data_plot,
type = "class"
)
cluster_rule_fidelity_accuracy <- round(
100 * mean(
dt_predicted_cluster == dt_data_plot$SOM_Cluster
),
2
)
cat("Decision-tree fidelity to SOM clusters:", cluster_rule_fidelity_accuracy, "%\n")## Decision-tree fidelity to SOM clusters: 81.39 %
cluster_rule_fidelity_table <- as.data.frame.matrix(
table(
Actual_SOM_Cluster = dt_data_plot$SOM_Cluster,
Decision_Tree_Cluster = dt_predicted_cluster
)
)
cluster_rule_fidelity_table <- cbind(
Actual_SOM_Cluster = rownames(cluster_rule_fidelity_table),
cluster_rule_fidelity_table
)
rownames(cluster_rule_fidelity_table) <- NULL
# ------------------------------------------------------------
# Function to draw decision tree
# ------------------------------------------------------------
draw_tree <- function(extra_value, plot_title, cex_value, split_cex_value) {
rpart.plot::rpart.plot(
cluster_tree_pruned,
type = 4,
extra = extra_value,
under = TRUE,
fallen.leaves = FALSE,
yesno = 0,
faclen = 10,
varlen = 0,
cex = cex_value,
tweak = 1.05,
box.palette = "Reds",
shadow.col = "gray85",
branch.lwd = 1.35,
split.cex = split_cex_value,
split.font = 2,
nn.cex = 0.85,
main = plot_title
)
}
# ------------------------------------------------------------
# Save compact tree
# extra = 2 shows correct / total
# ------------------------------------------------------------
compact_svg_file <- file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_Compact_Correct_Total.svg"
)
svglite::svglite(
file = compact_svg_file,
width = 13,
height = 7.5
)
draw_tree(
extra_value = 2,
plot_title = "Decision-Tree Rules Explaining SHAP-SOM Clusters",
cex_value = 1.05,
split_cex_value = 0.95
)
dev.off()## png
## 2
compact_png_file <- file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_Compact_Correct_Total.png"
)
png(
filename = compact_png_file,
width = 5200,
height = 3000,
res = 400
)
draw_tree(
extra_value = 2,
plot_title = "Decision-Tree Rules Explaining SHAP-SOM Clusters",
cex_value = 1.05,
split_cex_value = 0.95
)
dev.off()## png
## 2
# ------------------------------------------------------------
# Save detailed tree
# extra = 104 shows C1-C5 distribution and node percentage
# ------------------------------------------------------------
detailed_svg_file <- file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_Detailed_C1_to_C5_Distribution.svg"
)
svglite::svglite(
file = detailed_svg_file,
width = 15,
height = 8.5
)
draw_tree(
extra_value = 104,
plot_title = "Decision-Tree Rules with Cluster Distribution",
cex_value = 0.82,
split_cex_value = 0.78
)
dev.off()## png
## 2
detailed_png_file <- file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_Detailed_C1_to_C5_Distribution.png"
)
png(
filename = detailed_png_file,
width = 6500,
height = 3700,
res = 400
)
draw_tree(
extra_value = 104,
plot_title = "Decision-Tree Rules with Cluster Distribution",
cex_value = 0.82,
split_cex_value = 0.78
)
dev.off()## png
## 2
## Compact SVG saved:
## C:/Users/baitu/Desktop/Final_SOM_Elbow_Reference_Style_Outputs/Run_20260715_195723/Medium_Pruned_Decision_Tree_Cluster_Rules_SVG/Medium_Pruned_Decision_Tree_Compact_Correct_Total.svg
## Detailed SVG saved:
## C:/Users/baitu/Desktop/Final_SOM_Elbow_Reference_Style_Outputs/Run_20260715_195723/Medium_Pruned_Decision_Tree_Cluster_Rules_SVG/Medium_Pruned_Decision_Tree_Detailed_C1_to_C5_Distribution.svg
# ------------------------------------------------------------
# Extract terminal-node rules
# ------------------------------------------------------------
tree_frame <- cluster_tree_pruned$frame
leaf_frame_rows <- which(
tree_frame$var == "<leaf>"
)
leaf_node_numbers <- as.integer(
rownames(tree_frame)[leaf_frame_rows]
)
leaf_paths <- path.rpart(
cluster_tree_pruned,
nodes = leaf_node_numbers,
print.it = FALSE
)
extract_clean_path <- function(path_vector) {
path_vector <- path_vector[
!grepl(
"^root$",
path_vector
)
]
if (length(path_vector) == 0) {
return("All observations")
}
path_vector <- gsub(
"`",
"",
path_vector
)
path_vector <- gsub(
"\\s+",
" ",
path_vector
)
path_vector <- stringr::str_replace_all(
path_vector,
" = ",
"="
)
paste(
path_vector,
collapse = "; "
)
}
cluster_levels <- levels(
dt_data_plot$SOM_Cluster
)
cluster_class_lookup <- cluster_profile_table[
,
c(
"SOM_Cluster",
"Cluster_Orientation",
"Actual_Crossing_Lane_Percent"
)
]
rule_list <- list()
for (i in seq_along(leaf_frame_rows)) {
leaf_frame_row <- leaf_frame_rows[i]
leaf_node <- leaf_node_numbers[i]
leaf_data <- dt_data_plot[
cluster_tree_pruned$where == leaf_frame_row,
,
drop = FALSE
]
if (nrow(leaf_data) == 0) {
next
}
cluster_counts <- table(
factor(
leaf_data$SOM_Cluster,
levels = cluster_levels
)
)
cluster_percents <- round(
100 * as.numeric(cluster_counts) / nrow(leaf_data),
1
)
predicted_cluster <- names(
cluster_counts
)[
which.max(
cluster_counts
)
]
correct_n <- max(
as.numeric(cluster_counts)
)
total_n <- nrow(
leaf_data
)
rule_purity <- round(
100 * correct_n / total_n,
1
)
rule_support_percent <- round(
100 * total_n / nrow(dt_data_plot),
1
)
predicted_cluster_profile <- cluster_class_lookup[
cluster_class_lookup$SOM_Cluster == predicted_cluster,
,
drop = FALSE
]
cluster_class <- predicted_cluster_profile$Cluster_Orientation[1]
observed_crossing <- predicted_cluster_profile$Actual_Crossing_Lane_Percent[1]
cluster_percent_columns <- as.data.frame(
t(
cluster_percents
)
)
names(cluster_percent_columns) <- paste0(
gsub(
" ",
"_",
cluster_levels
),
"_Percent"
)
rule_list[[i]] <- cbind(
data.frame(
Rule_ID = paste0(
"R",
i
),
Rule_Path = extract_clean_path(
leaf_paths[[i]]
),
Predicted_SOM_Cluster = predicted_cluster,
Cluster_Class = cluster_class,
Observed_Crossing_Lane_Percent_in_Cluster = observed_crossing,
Correct_N = correct_n,
Total_N = total_n,
Correct_Total = paste0(
correct_n,
"/",
total_n
),
Rule_Purity_Percent = rule_purity,
Support_Percent = rule_support_percent,
stringsAsFactors = FALSE
),
cluster_percent_columns
)
}
medium_rule_table <- dplyr::bind_rows(
rule_list
) |>
dplyr::mutate(
Cluster_Number = as.integer(
gsub(
"Cluster ",
"",
Predicted_SOM_Cluster
)
)
) |>
dplyr::arrange(
Cluster_Number,
dplyr::desc(Rule_Purity_Percent),
dplyr::desc(Total_N)
) |>
dplyr::select(
Rule_ID,
Predicted_SOM_Cluster,
Cluster_Class,
Observed_Crossing_Lane_Percent_in_Cluster,
Rule_Path,
Correct_Total,
Rule_Purity_Percent,
Support_Percent,
dplyr::matches("^Cluster_[0-9]+_Percent$")
)
print(medium_rule_table)## Rule_ID Predicted_SOM_Cluster Cluster_Class
## 1 R1 Cluster 1 Crossing Lane
## 2 R5 Cluster 1 Crossing Lane
## 3 R3 Cluster 2 Crossing Lane
## 4 R7 Cluster 2 Crossing Lane
## 5 R2 Cluster 2 Crossing Lane
## 6 R9 Cluster 3 Travel Lane
## 7 R6 Cluster 3 Travel Lane
## 8 R8 Cluster 4 Crossing Lane
## 9 R4 Cluster 5 Travel Lane
## Observed_Crossing_Lane_Percent_in_Cluster
## 1 39.0
## 2 39.0
## 3 15.0
## 4 15.0
## 5 15.0
## 6 1.8
## 7 1.8
## 8 28.3
## 9 2.8
## Rule_Path
## 1 Lighting=Daylight; Area=Other,Urban; Movement=Turning; Alignment=Straight
## 2 Lighting=Dark,Other; Intersection=At Int.; Factor=Loss of Control/ Direction Error,Overtake,Turn/Merge; Movement=Turning
## 3 Lighting=Daylight; Area=Other,Urban; Movement=Other,Straight
## 4 Lighting=Dark,Other; Intersection=At Int.; Factor=Cross/Enter,Other; Trafficway=1W,2W Undiv.
## 5 Lighting=Daylight; Area=Other,Urban; Movement=Turning; Alignment=Curve,Other
## 6 Lighting=Dark,Other; Intersection=Not at Int.
## 7 Lighting=Dark,Other; Intersection=At Int.; Factor=Loss of Control/ Direction Error,Overtake,Turn/Merge; Movement=Other,Straight
## 8 Lighting=Dark,Other; Intersection=At Int.; Factor=Cross/Enter,Other; Trafficway=2W Div.,Other
## 9 Lighting=Daylight; Area=Rural
## Correct_Total Rule_Purity_Percent Support_Percent Cluster_1_Percent
## 1 95/106 89.6 6.3 89.6
## 2 25/34 73.5 2.0 73.5
## 3 338/460 73.5 27.2 0.9
## 4 70/118 59.3 7.0 5.9
## 5 16/35 45.7 2.1 11.4
## 6 569/617 92.2 36.4 0.5
## 7 43/64 67.2 3.8 1.6
## 8 67/93 72.0 5.5 1.1
## 9 155/166 93.4 9.8 0.6
## Cluster_2_Percent Cluster_3_Percent Cluster_4_Percent Cluster_5_Percent
## 1 7.5 1.9 0.9 0.0
## 2 14.7 0.0 2.9 8.8
## 3 73.5 9.6 16.1 0.0
## 4 59.3 0.8 26.3 7.6
## 5 45.7 40.0 2.9 0.0
## 6 0.8 92.2 0.5 6.0
## 7 18.8 67.2 4.7 7.8
## 8 21.5 1.1 72.0 4.3
## 9 0.0 4.2 1.8 93.4
# ------------------------------------------------------------
# Paper-ready rule table
# ------------------------------------------------------------
paper_rule_table <- medium_rule_table |>
dplyr::mutate(
Rule = Rule_ID,
Cluster = Predicted_SOM_Cluster,
Class = Cluster_Class,
Crossing_Percent = paste0(
Observed_Crossing_Lane_Percent_in_Cluster,
"%"
),
Main_Rule = Rule_Path,
Classification_Rate = Correct_Total,
Purity = paste0(
Rule_Purity_Percent,
"%"
),
Support = paste0(
Support_Percent,
"%"
)
) |>
dplyr::select(
Rule,
Main_Rule,
Cluster,
Class,
Crossing_Percent,
Classification_Rate,
Purity,
Support,
dplyr::matches("^Cluster_[0-9]+_Percent$")
)
# ------------------------------------------------------------
# Save paper-ready rule table as image
# ------------------------------------------------------------
paper_rule_table_for_png <- paper_rule_table
paper_rule_table_for_png$Main_Rule <- stringr::str_wrap(
paper_rule_table_for_png$Main_Rule,
width = 65
)
table_theme <- gridExtra::ttheme_minimal(
base_size = 11,
base_family = "serif",
core = list(
fg_params = list(
fontface = "plain",
col = "black"
),
bg_params = list(
fill = c(
"white",
"#F7F0EC"
),
col = "gray80"
)
),
colhead = list(
fg_params = list(
fontface = "bold",
col = "black"
),
bg_params = list(
fill = "#CBB4A7",
col = "gray60"
)
)
)
table_grob <- gridExtra::tableGrob(
paper_rule_table_for_png,
rows = NULL,
theme = table_theme
)
table_png_file <- file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_Rule_Table_With_C1_to_C5_Percent.png"
)
png(
filename = table_png_file,
width = 7200,
height = max(
2600,
480 + 280 * nrow(paper_rule_table_for_png)
),
res = 400
)
grid::grid.newpage()
grid::grid.draw(table_grob)
dev.off()## png
## 2
## Medium-pruned decision-tree rule table image saved:
## C:/Users/baitu/Desktop/Final_SOM_Elbow_Reference_Style_Outputs/Run_20260715_195723/Medium_Pruned_Decision_Tree_Cluster_Rules_SVG/Medium_Pruned_Decision_Tree_Rule_Table_With_C1_to_C5_Percent.png
# ------------------------------------------------------------
# Variable importance from the medium-pruned decision tree
# ------------------------------------------------------------
if (!is.null(cluster_tree_pruned$variable.importance)) {
decision_tree_variable_importance <- data.frame(
Variable = names(
cluster_tree_pruned$variable.importance
),
Importance = as.numeric(
cluster_tree_pruned$variable.importance
),
stringsAsFactors = FALSE
) |>
dplyr::arrange(
dplyr::desc(Importance)
)
} else {
decision_tree_variable_importance <- data.frame(
Variable = character(),
Importance = numeric(),
stringsAsFactors = FALSE
)
}
# ------------------------------------------------------------
# Plot variable importance
# ------------------------------------------------------------
if (nrow(decision_tree_variable_importance) > 0) {
plot_dt_importance <- ggplot2::ggplot(
decision_tree_variable_importance,
ggplot2::aes(
x = reorder(
Variable,
Importance
),
y = Importance
)
) +
ggplot2::geom_col(
fill = "#7C2A35",
color = "black",
linewidth = 0.35
) +
ggplot2::coord_flip() +
ggplot2::labs(
title = "Variable Importance in Medium-Pruned Decision Tree",
x = NULL,
y = "Importance"
) +
ggplot2::theme_minimal(
base_family = "serif",
base_size = 15
) +
ggplot2::theme(
plot.title = ggplot2::element_text(
face = "bold",
size = 18
),
axis.text = ggplot2::element_text(
face = "bold",
color = "black"
),
axis.title.x = ggplot2::element_text(
face = "bold"
),
panel.grid.minor = ggplot2::element_blank()
)
print(plot_dt_importance)
ggplot2::ggsave(
filename = file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_Variable_Importance.png"
),
plot = plot_dt_importance,
width = 10,
height = 7,
dpi = 600,
bg = "white"
)
}# ------------------------------------------------------------
# Save outputs
# ------------------------------------------------------------
write.csv(
medium_rule_table,
file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_Rule_Table_Full_With_C1_to_C5_Percent.csv"
),
row.names = FALSE
)
write.csv(
paper_rule_table,
file.path(
DECISION_TREE_OUT_DIR,
"Paper_Ready_Medium_Pruned_Decision_Tree_Rule_Table_With_C1_to_C5_Percent.csv"
),
row.names = FALSE
)
write.csv(
cluster_rule_fidelity_table,
file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_Fidelity_Table.csv"
),
row.names = FALSE
)
write.csv(
cp_table,
file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_CP_Table.csv"
),
row.names = FALSE
)
write.csv(
decision_tree_variable_importance,
file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_Variable_Importance.csv"
),
row.names = FALSE
)
summary_table <- data.frame(
Item = c(
"Decision tree purpose",
"Target variable",
"Predictors",
"Excluded variables",
"Tree type",
"Pruning method",
"Maximum depth",
"Minimum split",
"Minimum terminal node size",
"Complexity parameter used for initial tree",
"Selected CP",
"Target terminal rule range",
"Final number of terminal rules",
"Number of observations",
"Decision-tree fidelity to SOM clusters (%)",
"Compact tree value format",
"Detailed tree value format"
),
Value = c(
"Rule extraction for SHAP-SOM cluster interpretation",
"SOM_Cluster",
paste(kept_variable_names, collapse = "; "),
paste(EXCLUDED_VARIABLES, collapse = "; "),
"Classification tree using Gini impurity",
"Medium cost-complexity pruning with preferred 8-12 terminal rules",
"4",
"70",
"30",
"0.0005",
selected_cp,
"8-12",
number_of_terminal_nodes,
nrow(dt_data_plot),
cluster_rule_fidelity_accuracy,
"correct / total",
"C1-C5 cluster proportions and node percentage"
),
stringsAsFactors = FALSE
)
writexl::write_xlsx(
list(
Summary = summary_table,
Paper_Ready_Rules = paper_rule_table,
Full_Rule_Table = medium_rule_table,
Fidelity_Table = cluster_rule_fidelity_table,
CP_Table = cp_table,
Variable_Importance = decision_tree_variable_importance
),
file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_SOM_Cluster_Rules_Output_With_SVG.xlsx"
)
)
saveRDS(
cluster_tree_pruned,
file.path(
DECISION_TREE_OUT_DIR,
"Medium_Pruned_Decision_Tree_SOM_Cluster_Rules.rds"
)
)
cat("\nMedium-pruned decision-tree rule extraction completed.\n")##
## Medium-pruned decision-tree rule extraction completed.
## Purpose: explain the five SHAP-SOM clusters using original retained variables.
## Excluded variables: Hit and Run, Road Relation
## Pruning method: medium cost-complexity pruning.
## Maximum depth: 4
## Minimum split: 70
## Minimum terminal node size: 30
## Target terminal rules: 8 to 12
## Final terminal rules: 9
## Decision-tree fidelity to SOM cluster labels: 81.39 %
## Compact SVG: C:/Users/baitu/Desktop/Final_SOM_Elbow_Reference_Style_Outputs/Run_20260715_195723/Medium_Pruned_Decision_Tree_Cluster_Rules_SVG/Medium_Pruned_Decision_Tree_Compact_Correct_Total.svg
## Detailed SVG: C:/Users/baitu/Desktop/Final_SOM_Elbow_Reference_Style_Outputs/Run_20260715_195723/Medium_Pruned_Decision_Tree_Cluster_Rules_SVG/Medium_Pruned_Decision_Tree_Detailed_C1_to_C5_Distribution.svg
## Outputs saved to:
## C:/Users/baitu/Desktop/Final_SOM_Elbow_Reference_Style_Outputs/Run_20260715_195723/Medium_Pruned_Decision_Tree_Cluster_Rules_SVG
Figure X. Medium-pruned decision-tree rules explaining SHAP-SOM cluster membership. The response variable is the SOM cluster label, and the predictors are the original retained crash variables.
| Rule | Main_Rule | Cluster | Class | Crossing_Percent | Classification_Rate | Purity | Support | Cluster_1_Percent | Cluster_2_Percent | Cluster_3_Percent | Cluster_4_Percent | Cluster_5_Percent |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| R1 | Lighting=Daylight; Area=Other,Urban; Movement=Turning; Alignment=Straight | Cluster 1 | Crossing Lane | 39% | 95/106 | 89.6% | 6.3% | 89.6 | 7.5 | 1.9 | 0.9 | 0.0 |
| R5 | Lighting=Dark,Other; Intersection=At Int.; Factor=Loss of Control/ Direction Error,Overtake,Turn/Merge; Movement=Turning | Cluster 1 | Crossing Lane | 39% | 25/34 | 73.5% | 2% | 73.5 | 14.7 | 0.0 | 2.9 | 8.8 |
| R3 | Lighting=Daylight; Area=Other,Urban; Movement=Other,Straight | Cluster 2 | Crossing Lane | 15% | 338/460 | 73.5% | 27.2% | 0.9 | 73.5 | 9.6 | 16.1 | 0.0 |
| R7 | Lighting=Dark,Other; Intersection=At Int.; Factor=Cross/Enter,Other; Trafficway=1W,2W Undiv. | Cluster 2 | Crossing Lane | 15% | 70/118 | 59.3% | 7% | 5.9 | 59.3 | 0.8 | 26.3 | 7.6 |
| R2 | Lighting=Daylight; Area=Other,Urban; Movement=Turning; Alignment=Curve,Other | Cluster 2 | Crossing Lane | 15% | 16/35 | 45.7% | 2.1% | 11.4 | 45.7 | 40.0 | 2.9 | 0.0 |
| R9 | Lighting=Dark,Other; Intersection=Not at Int. | Cluster 3 | Travel Lane | 1.8% | 569/617 | 92.2% | 36.4% | 0.5 | 0.8 | 92.2 | 0.5 | 6.0 |
| R6 | Lighting=Dark,Other; Intersection=At Int.; Factor=Loss of Control/ Direction Error,Overtake,Turn/Merge; Movement=Other,Straight | Cluster 3 | Travel Lane | 1.8% | 43/64 | 67.2% | 3.8% | 1.6 | 18.8 | 67.2 | 4.7 | 7.8 |
| R8 | Lighting=Dark,Other; Intersection=At Int.; Factor=Cross/Enter,Other; Trafficway=2W Div.,Other | Cluster 4 | Crossing Lane | 28.3% | 67/93 | 72% | 5.5% | 1.1 | 21.5 | 1.1 | 72.0 | 4.3 |
| R4 | Lighting=Daylight; Area=Rural | Cluster 5 | Travel Lane | 2.8% | 155/166 | 93.4% | 9.8% | 0.6 | 0.0 | 4.2 | 1.8 | 93.4 |
15 Interpretation Guide for the Modeling Workflow
This section clarifies how each modeling step contributes to the Travel Lane versus Crossing Lane fatal bicycle crash analysis.
15.1 What the Random Forest model shows
The Random Forest model was used as the supervised learning step to identify the variables that help separate fatal bicycle crashes occurring in the Travel Lane group from those occurring in the Crossing Lane group. The model was not used as the final interpretation tool by itself. Instead, it was used to learn the classification structure between the two path-type groups and to support later interpretation using TreeSHAP and SOM.
In this study, the target variable is Bicyclist Path Type, with Crossing Lane treated as the positive class and Travel Lane treated as the reference class. Therefore, the Random Forest results show which variables are most useful for distinguishing these two fatal crash contexts.
15.2 Why TreeSHAP was used after Random Forest
TreeSHAP was used to explain the Random Forest predictions at the crash-observation level. While Random Forest variable importance shows which variables are generally important, TreeSHAP shows how each variable contributes to the predicted direction for each individual crash record.
Positive SHAP values indicate that a variable pushes the model prediction toward Crossing Lane. Negative SHAP values indicate that a variable pushes the model prediction toward Travel Lane. Therefore, the TreeSHAP matrix provides a crash-level explanation profile that is more detailed than global Random Forest importance alone.
15.3 Why SOM was applied to the TreeSHAP matrix
The Self-Organizing Map was applied to the TreeSHAP-value matrix to group crashes with similar explanation patterns. This means that the SOM did not cluster crashes based only on raw variables. Instead, it clustered crashes based on how the Random Forest model explained each crash.
This step helps identify mechanism-based crash patterns. Crashes assigned to the same SOM region have similar prediction-driving characteristics, even if they are not identical in every original variable.
15.4 Why the 11.58% baseline was used
The 11.58% value represents the overall observed percentage of Crossing Lane fatal crashes in the test data. This value was used as a baseline to interpret whether each SOM node was more Crossing Lane-oriented or more Travel Lane-oriented.
A SOM node with an observed Crossing Lane percentage above 11.58% was interpreted as Crossing Lane-oriented because Crossing Lane crashes were more concentrated in that node than in the overall test data. A node with an observed Crossing Lane percentage below 11.58% was interpreted as Travel Lane-oriented.
This baseline does not mean that there are only two clusters. It is only used to describe the orientation of each SOM node relative to the overall Crossing Lane share.
15.5 Why some SOM nodes show 0% Crossing Lane
Some SOM nodes show 0% Crossing Lane because all crashes assigned to those nodes were Travel Lane crashes. This does not indicate a plotting error. Instead, it means that those nodes represent strongly Travel Lane-oriented explanation patterns.
These 0% nodes are useful because they show that some parts of the SOM space are dominated entirely by Travel Lane crash profiles.
15.6 Why there are five final SOM clusters
The SOM contains multiple nodes, and the nodes were grouped into five final clusters using hierarchical clustering of the SOM codebook vectors. Therefore, the hexagons in the SOM map are nodes, not final clusters by themselves.
The final clusters are labeled as C1 through C5. Each node belongs to one of these five clusters. The revised SOM percentage map therefore shows both pieces of information inside each hexagon: the final cluster label and the observed Crossing Lane percentage.
15.7 What the decision tree shows
The decision tree was used after SHAP-SOM clustering to summarize the final SOM clusters using readable rules from the original retained crash variables. The decision tree is not another Travel Lane versus Crossing Lane prediction model.
The response variable in the decision tree is the SOM cluster label. The predictors are the original retained crash variables. Therefore, the decision tree explains how the final SHAP-SOM clusters can be described using simple crash-condition rules.
In summary, the workflow should be interpreted as follows: Random Forest identifies the supervised separation between Travel Lane and Crossing Lane crashes; TreeSHAP explains the Random Forest prediction at the crash level; SOM groups crashes with similar SHAP explanation patterns; the 11.58% baseline is used only to describe node orientation; and the decision tree translates the final SOM clusters into readable rule-based descriptions.