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:

  1. Random Forest variable selection
  2. Final predictive Random Forest
  3. SHAP analysis
  4. Self-Organizing Map analysis
  5. SOM grid evaluation
  6. Ward hierarchical clustering
  7. Cluster interpretation
  8. Decision-tree explanation
  9. Spatial and hotspot analysis
  10. 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.
cat("File:", DATA_FILE, "\n")
## 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
cat("Sheet:", SHEET_NAME, "\n")
## Sheet: Lump
cat("Rows:", nrow(bike_data), "\n")
## Rows: 8461
cat("Columns:", ncol(bike_data), "\n")
## 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"
)
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.

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.

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
cat(
  "Number of predictors included:",
  ncol(rf_predictor_data),
  "\n"
)
## Number of predictors included: 16
cat(
  "Predictors included:\n",
  paste(
    names(rf_predictor_data),
    collapse = "\n"
  ),
  "\n"
)
## 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"
)
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
cat("Number of trees:", RF_TREES, "\n")
## Number of trees: 2000
cat("mtry:", rf_mtry, "\n")
## mtry: 4
cat("Predictors used:", ncol(rf_predictor_data), "\n")
## 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"
)
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_plot

9 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"
  )
)
Top 13 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
cat("Predictors used:", length(FINAL_PREDICTORS), "\n")
## Predictors used: 14
cat(
  paste(
    FINAL_PREDICTORS,
    collapse = "\n"
  )
)
## 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
cat("\n")
# ----------------------------
# 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
cat("Testing rows:", nrow(test_data), "\n")
## Testing rows: 1693
cat("Training distribution:\n")
## Training distribution:
print(table(train_data[[TARGET_VARIABLE]]))
## 
##   Travel Lane Crossing Lane 
##          5987           781
cat("Testing distribution:\n")
## Testing distribution:
print(table(test_data[[TARGET_VARIABLE]]))
## 
##   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.
cat("SHAP rows:", nrow(shap_encoded), "\n")
## SHAP rows: 1693
cat("Encoded SHAP columns:", ncol(shap_encoded), "\n")
## 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"]])

grid::grid.newpage()
grid::grid.draw(separate_group_plots[["FN"]])

grid::grid.newpage()
grid::grid.draw(separate_group_plots[["FP"]])

grid::grid.newpage()
grid::grid.draw(separate_group_plots[["TP"]])

cat("Four separate corrected SHAP figures saved to:\n")
## Four separate corrected SHAP figures saved to:
cat(OUT_DIR, "\n")
## 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
cat(
  "Use SHAP_Matrix_for_SOM.csv for SOM grid selection.\n"
)
## 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
cat("Columns in SHAP matrix:", ncol(shap_som_data), "\n")
## 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:
cat(paste(names(som_input_raw), collapse = "\n"))
## 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
cat("\n")
# ------------------------------------------------------------
# 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
best_grid <- som_grid_selection_table[1, ]

cat("\nRecommended SOM grid based on balanced score:\n")
## 
## Recommended SOM grid based on balanced score:
cat("Grid size:", best_grid$Grid_Size, "\n")
## Grid size: 7 x 6
cat("Quantization error:", best_grid$Quantization_Error, "\n")
## Quantization error: 1.95055
cat("Topographic error:", best_grid$Topographic_Error, "\n")
## Topographic error: 0.5534554
cat("Kaski-Lagus error:", best_grid$Kaski_Lagus_Error, "\n")
## Kaski-Lagus error: 3.490872
cat("Empty nodes:", best_grid$Empty_Nodes, "\n")
## 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_qe

ggplot2::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_te

ggplot2::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_score

ggplot2::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:
cat(SOM_OUT_DIR, "\n")
## 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:
cat(FINAL_SOM_OUT_DIR, "\n")
## 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
cat("Columns in SHAP matrix:", ncol(shap_som_data), "\n")
## 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:
cat(paste(shap_variables, collapse = "\n"))
## 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
cat("\n")
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.
cat("Final SOM grid:", FINAL_XDIM, "x", FINAL_YDIM, "\n")
## Final SOM grid: 6 x 5
cat("Total SOM nodes:", FINAL_XDIM * FINAL_YDIM, "\n")
## 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
print(cluster_selection_table)
##   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_elbow

ggplot2::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_top

ggplot2::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_bottom

ggplot2::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.
cat("Final SOM grid:", FINAL_XDIM, "x", FINAL_YDIM, "\n")
## Final SOM grid: 6 x 5
cat("Selected number of clusters:", FINAL_CLUSTER_K, "\n")
## Selected number of clusters: 5
cat("Overall Crossing Lane percentage in test data:", overall_crossing_percent, "%\n")
## Overall Crossing Lane percentage in test data: 11.58 %
cat("Variables excluded from SHAP/SOM:", paste(EXCLUDED_VARIABLES, collapse = ", "), "\n")
## Variables excluded from SHAP/SOM: Hit and Run, Road Relation
cat("Outputs saved to:\n")
## Outputs saved to:
cat(FINAL_SOM_OUT_DIR, "\n")
## 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:
cat(DECISION_TREE_OUT_DIR, "\n")
## 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:
cat(paste(kept_variable_names, collapse = "\n"))
## 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
cat("\n\n")
# ------------------------------------------------------------
# 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
cat("Number of predictors:", ncol(dt_data_plot) - 1, "\n")
## 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
cat("Number of terminal rules:", number_of_terminal_nodes, "\n")
## 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
cat("Compact SVG saved:\n")
## Compact SVG saved:
cat(compact_svg_file, "\n")
## 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
cat("Detailed SVG saved:\n")
## Detailed SVG saved:
cat(detailed_svg_file, "\n")
## 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
cat("Medium-pruned decision-tree rule table image saved:\n")
## Medium-pruned decision-tree rule table image saved:
cat(table_png_file, "\n")
## 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.
cat("Purpose: explain the five SHAP-SOM clusters using original retained variables.\n")
## Purpose: explain the five SHAP-SOM clusters using original retained variables.
cat("Excluded variables:", paste(EXCLUDED_VARIABLES, collapse = ", "), "\n")
## Excluded variables: Hit and Run, Road Relation
cat("Pruning method: medium cost-complexity pruning.\n")
## Pruning method: medium cost-complexity pruning.
cat("Maximum depth: 4\n")
## Maximum depth: 4
cat("Minimum split: 70\n")
## Minimum split: 70
cat("Minimum terminal node size: 30\n")
## Minimum terminal node size: 30
cat("Target terminal rules: 8 to 12\n")
## Target terminal rules: 8 to 12
cat("Final terminal rules:", number_of_terminal_nodes, "\n")
## Final terminal rules: 9
cat("Decision-tree fidelity to SOM cluster labels:", cluster_rule_fidelity_accuracy, "%\n")
## Decision-tree fidelity to SOM cluster labels: 81.39 %
cat("Compact SVG:", compact_svg_file, "\n")
## 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
cat("Detailed SVG:", detailed_svg_file, "\n")
## 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
cat("Outputs saved to:\n")
## Outputs saved to:
cat(DECISION_TREE_OUT_DIR, "\n")
## 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.

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.

Table X. Paper-ready medium-pruned decision-tree rules explaining SHAP-SOM cluster membership.
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.