Improving Workplace Safety with HR Analytics: A Tutorial

Written by Adam McKinnon
14 minutes read

This article has been co-authored by Adam McKinnon and Martha Curioni.

Workplace safety is a top priority for employers around the world. Not only does a safe workplace protect employees from injury, but it also saves companies injury-related costs. In the US, work injury costs exceed $170 billion every year. Most of these incidents are preventable. HR teams can harness the power of analytics to prevent accidents in the workplace.

In this article, we will demonstrate how we use an unsupervised ML algorithm—Association Rules in the context of workplace safety. Specifically, we will focus on how Association Rules can assist in the synthesis of large amounts of safety data to identify intervention points to prevent future incidents, and new ways of visualizing Association Rule outputs to make the interpretation, and more importantly, the use of the findings, easier for HR leaders.

We have included some practical code examples written in both Python and R. Let’s get started!

Contents
The business challenge
Why Association Rules
Workflow
1. Ingest and prepare the data
2. Analyze the data
3. Visualize results

The business challenge

A large manufacturing company is committed to providing the safest working conditions for their employees. There are many initiatives to track and prevent injuries across their facilities, but safety incidents are still happening more frequently than they’d like. They capture very detailed data for each incident and at a recent meeting, the VP of Manufacturing asked HR if they can analyze the safety data to determine whether there are conditions commonly associated with incidents, which if addressed, could prevent future incidents. HR turned to their HR Analytics team to analyze the safety data and identify preventable conditions.  

Note: Workplace safety datasets are not readily available, so we’ve used a dataset of UK vehicle incidents to demonstrate how this analysis could be done.

How HR Analytics Helps Workplace Safety

Why Association Rules

There are multiple approaches you could use to identify conditions associated with safety incidents. However, Association Rules provide a means of rapidly identifying conditions that frequently occur together–a key priority for safety and incident prevention.

First, let’s do a quick review of the key components of Association Rules: 

1. Support

Support represents the probability of two conditions appearing together (i.e., number of incidents containing both Condition A and Condition B relative to the size of the dataset). 

Support = Probability of Condition A and Condition B happening

2. Confidence

Confidence represents the probability of Condition B happening given Condition A happened. 

Confidence = Probability of Condition A and Condition B divided by the probability of Condition A 

3. Lift

Lift represents how certain we are that we can predict Condition B will happen if Condition A happens. It is generally agreed that a lift value greater than 1 reflects stronger associations.

Lift = Probability of Condition B given Condition A divided by the probability of Condition B


Workflow

The workflow for addressing this business challenge is as follows: 

  1. Ingest and prepare the data. We will not spend too much time discussing the steps taken and instead focus on the following two steps:
  2. Analyze the data. Under normal conditions, we would spend considerable time performing exploratory data analysis prior to performing more sophisticated analyses, such as Association Rules. While this exploratory analysis was performed, it will not be discussed, and instead, we will focus on the use of Association Rules in this article.
  3. Visualise the results to inform preventive action. We provided two non-traditional ways of visualizing Association Rules for two reasons:
    1. The key tenets of Association Rules (i.e., Support, Confidence and Lift), do not always resonate with business stakeholders, resulting in confusion and a subsequent lack of action. Thus, we sought more intuitive forms of visualization that better facilitate understanding and the call to action.
    2. We believed that addressing preventable incident conditions is unlikely to have an adverse impact, and were, therefore, less rigid in our interpretation and visualization than may be acceptable in other contexts.

The workflow provides a mix of R and Python, reflecting the background of the two authors.

If you want to improve your HR analytics skills and learn how to perform data analyses in R or other statistical programs, check out our People Analytics Certificate Program!

1. Ingest and prepare the data

First, we downloaded the necessary libraries for both coding languages. Next, we loaded the UK Road Safety Data (available on the Kaggle website). There are two data sets, accident information and vehicle information. Both sets are quite large, so we focused the analysis on 2015 data only before we merged the two datasets to combine accident and vehicle conditions.

#import necessary libraries
import pandas as pd
from mlxtend.frequent_patterns import apriori
from mlxtend.frequent_patterns import association_rules
"""
Load the accident and vehicle data as a pandas dataframe
Narrow to 2015 data and needed columns
Merge the two dataframes

"""

#Create dataframe of accident data 
df=pd.read_csv('/content/drive/MyDrive/Kaggle/Accident_Information.csv',low_memory=False) #set low_memory to false due to size of data
#Create dataframe of vehicle data
df2=pd.read_csv('/content/drive/MyDrive/Kaggle/Vehicle_Information.csv', low_memory=False, encoding='windows-1252') #set low_memory to false; encoding to addresses characters that flag errors


#Narrow dataframes to 2015 data only
accident_data = df[df['Year'] == 2015]
vehicle_data = df2[df2['Year'] == 2015]

#Reset the index and drop new column that is created with old index
accident_data = accident_data.reset_index(drop=True)
vehicle_data = vehicle_data.reset_index(drop=True)

#drop the unused columns 
accident_data.drop(['1st_Road_Number', '2nd_Road_Number', 'Did_Police_Officer_Attend_Scene_of_Accident', 'Latitude', 'Location_Easting_OSGR','Location_Northing_OSGR',\
                    'Longitude', 'LSOA_of_Accident_Location', 'Pedestrian_Crossing-Human_Control', 'Special_Conditions_at_Site', 'Year', 'InScotland'], axis=1, inplace=True)
vehicle_data.drop(['Junction_Location', 'model', 'Vehicle_Location.Restricted_Lane', 'Year'], axis=1, inplace=True)

#merge two dataframes using the 'Accident_Index' column
merged = pd.merge(accident_data, vehicle_data, how='inner', on=['Accident_Index', 'Accident_Index'])

Using the Apriori Algorithm to find associations between different accident conditions requires categorical data, so we prepared and binned some of the data before we ran the analyses. 

"""
Convert data types to prepare for binning
Bin data that is not categorical
Clean up unused columns

"""

#extract month from 'Date' column
merged['Month'] = pd.DatetimeIndex(merged['Date']).month

#convert time to integer
merged['Hour'] = pd.to_datetime(merged['Time']).dt.hour

#make the accident_index column the index for the dataframe since it will not be an association rules variable
merged.set_index('Accident_Index')

#bin time into time of day
merged['Time_of_Day'] = pd.cut(merged['Hour'], 
                    bins=[0,5,12,17,24], 
                    include_lowest=True, 
                    labels=['Early Morning','Morning','Afternoon','Evening'])

#drop rows with 0 or 10 speed limit as there are only 2 and 3 respectively - left with 6 values so no need to bin
merged.drop(merged.loc[merged['Speed_limit'] == 0.0].index, inplace=True)
merged.drop(merged.loc[merged['Speed_limit'] == 10.0].index, inplace=True)

#delete rows with age of vehicle over 50
merged.drop(merged.loc[merged['Age_of_Vehicle'] > 50.0].index, inplace=True)

#bin age of vehicle
merged['Grouped_Vehicle_Age'] = pd.cut(merged['Age_of_Vehicle'], 
                    bins=[1,10,20,30,40,50], 
                    include_lowest=True, labels=['0-10','10-21','21-30','31-40', '41-50'],
                    precision=0) 

#drop columns that we no longer need and/or converted to bins
merged.drop(['Date', 'Time', 'Hour', 'Age_of_Vehicle'], axis=1, inplace=True)

#convert month integer to month name
import calendar
merged['Month'] = merged['Month'].apply(lambda x: calendar.month_abbr[x])

#create a dataframe with only variables we want to use - drop columns no longer necessary
accidents = merged[['Accident_Severity', 'Carriageway_Hazards', 'Day_of_Week', \
                   'Junction_Control', 'Junction_Detail','Light_Conditions', 'Road_Surface_Conditions',\
                   'Road_Type', 'Speed_limit', 'Urban_or_Rural_Area', 'Weather_Conditions', 'Age_Band_of_Driver',\
                   'Journey_Purpose_of_Driver', 'Sex_of_Driver', 'Vehicle_Manoeuvre', 'Month',\
                   'Time_of_Day', 'Grouped_Vehicle_Age']].copy()

#convert speed limit to string so we can one hot encode
accidents['Speed_limit'] = accidents['Speed_limit'].astype(str)

Finally, we focused on the variables that represented pre-accident conditions. These variables were then one-hot encoded, a method that allows analysts to identify which conditions were and were not present during each incident (represented as a ‘1’ and ‘0’ respectively).  

"""
One hot encode all variables 
Drop columns created for missing values

"""

#get dummies for all columns (one hot encode)
accidents = pd.get_dummies(accidents)

#drop identified columns with missing values
accidents.drop(['Carriageway_Hazards_Data missing or out of range', 'Junction_Control_Data missing or out of range',\
                'Light_Conditions_Darkness - lighting unknown', 'Road_Surface_Conditions_Data missing or out of range',\
                'Road_Type_Unknown', 'Weather_Conditions_Unknown', 'Age_Band_of_Driver_Data missing or out of range',\
                'Journey_Purpose_of_Driver_Data missing or out of range', 'Journey_Purpose_of_Driver_Not known', 'Sex_of_Driver_Not known',\
                'Sex_of_Driver_Data missing or out of range', 'Vehicle_Manoeuvre_Data missing or out of range'], axis=1, inplace=True)

2. Analyze the data

Our goal was to synthesize the large safety dataset to rapidly identify the pre-incident conditions (known as antecedents in Association Rules) that often result in different incident severity outcomes (known as the consequents). Therefore, we decided to run the analysis three times, once for each accident severity (i.e., slight, serious, and fatal). Our rationale was twofold: 

  1. The different incident severity outcomes occurred at different rates and thus needed different Support and consequent parameters applied to identify preventable conditions, and
  2. To potentially enable prioritization of results obtained from the analyses.

Associations Rules for slight incidents

Descriptive analyses revealed the majority of incidents to be ‘slight’. Therefore, we decided upon a higher Support parameter of 0.1, meaning that the combination of conditions occurs at least 10 times in 100 incidents. We chose combinations of only 2 antecedents due to the visualization methods we elected to use and held the Lift parameter at 1 to identify stronger associations. 

#Apriori min support for slight - highest support
min_support = 0.1

#Max lenght of apriori n-grams
max_len = 2

frequent_items = apriori(accidents, use_colnames=True, min_support=min_support, max_len=max_len + 1)
rules = association_rules(frequent_items, metric='lift', min_threshold=1)

target = '{\'Accident_Severity_Slight\'}'

results_attrition_slight = rules[rules['consequents'].astype(str).str.contains(target)].sort_values(by='confidence', ascending=False)

#add a 'length' column to capture the length of antecedents for futher filtering later
results_attrition_slight['length'] = results_attrition_slight['antecedents'].apply(lambda x: len(x))

print(results_attrition_slight.shape)
results_attrition_slight

144 rows × 10 columns

The analysis identified 144 association rules, or 144 combinations of two conditions that are associated with a slight incident, at least 10 times out of 100 incidents. These rules are visualized below.


Associations for serious incidents

There were fewer serious accidents than slight. Therefore, we decided to set the Support parameter at 0.05, meaning the combination of conditions identified had to occur at least 5 out of 100 times across the data set. The remaining parameters were held constant (i.e., antecedent combinations was 2, and Lift was 1).

#Apriori min support for serious accidents - medium support
min_support = 0.05

#Max lenght of apriori n-grams
max_len = 2

frequent_items = apriori(accidents, use_colnames=True, min_support=min_support, max_len=max_len + 1)
rules = association_rules(frequent_items, metric='lift', min_threshold=1)

target = '{\'Accident_Severity_Serious\'}'

results_attrition_serious = rules[rules['consequents'].astype(str).str.contains(target)].sort_values(by='confidence', ascending=False)

#add a 'length' column to capture the length of antecedents for futher filtering later
results_attrition_serious['length'] = results_attrition_serious['antecedents'].apply(lambda x: len(x))

print(results_attrition_serious.shape)
results_attrition_serious

Twenty-two associations, or 22 combinations of conditions that are associated with a serious accident, at least 5 times out of 100 incidents (total) were identified. These associations are visualized below. 

Associations for fatal incidents

Descriptive analyses revealed far fewer fatal incidents compared to serious and slight ones. Despite a lower Support parameter of 0.01, no associations among antecedents were identified for fatal incidents, perhaps suggestive of very random events.

3. Visualize results

#Apriori min support for fatal accidents - lowest support
min_support = 0.01

#Max lenght of apriori n-grams
max_len = 2

frequent_items = apriori(accidents, use_colnames=True, min_support=min_support, max_len=max_len + 1)
rules = association_rules(frequent_items, metric='lift', min_threshold=1)

target = '{\'Accident_Severity_Fatal\'}'

results_attrition_fatal = rules[rules['consequents'].astype(str).str.contains(target)].sort_values(by='confidence', ascending=False)

#add a 'length' column to capture the length of antecedents for futher filtering later
results_attrition_fatal['length'] = results_attrition_fatal['antecedents'].apply(lambda x: len(x))

results_attrition_fatal

Following some experimentation, we chose two methods to visualize the Association Rules for slight and serious Incidents. These methods were:

  1. Expanding Trees; and 
  2. Rosetype Diagram. 

Below are Expanding Tree Diagrams for both slight and serious Incidents. The diagrams start from the left-hand side with the incident type and can then be expanded to the right, showing the conditions that contribute. The size of the circles is based on the number of times the condition occurred in the full set of Association Rules identified. The key benefit of the Expanding Tree is the ease with which it can be both explained and understood by non-technical audiences.

# Libraries
library(tidyverse)
## ── Attaching packages ─────────────────────────────────────── tidyverse 1.3.1 ──
## ✓ ggplot2 3.3.5     ✓ purrr   0.3.4
## ✓ tibble  3.1.5     ✓ dplyr   1.0.7
## ✓ tidyr   1.1.4     ✓ stringr 1.4.0
## ✓ readr   2.0.1     ✓ forcats 0.5.1
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## x dplyr::filter() masks stats::filter()
## x dplyr::lag()    masks stats::lag()
library(collapsibleTree)
library(echarts4r)
# clean the antecedent columns from the outputs
aa_cleaning <- function(x){
    
    x = stringr::str_remove_all(string = x, pattern = "frozenset")
    x = stringr::str_remove_all(string = x, pattern = "\\(|\\)|\\{|\\}|\\'")
    x = stringr::str_replace_all(string = x, pattern = "_", replacement = " ")
    
}


# clean the rules for collapsible tree visualisation
duplex_cleaning <- function(df){

    df %>% 
        dplyr::mutate(antecedents = aa_cleaning(antecedents),
                      consequents = aa_cleaning(consequents)) %>%
        tidyr::separate(antecedents, into = c("antecedent_1", "antecedent_2"), sep = ",") %>% 
        dplyr::mutate_at(vars(contains("antecedent")), ~stringr::str_trim(.))
}


# clean the rules for Rosetype visualisation
simplex_cleaning <- function(df){
    
    df %>% 
        dplyr::mutate(antecedents = aa_cleaning(antecedents),
                      consequents = aa_cleaning(consequents)) %>%
        tidyr::separate_rows(antecedents, sep = ",") %>% 
        dplyr::mutate_at(vars(contains("antecedent")), ~stringr::str_trim(.))
}


# create a collapsible tree diagram
aa_collapsibleTree_vis <- function(df, label_name){
    
    collapsibleTree(
        df,
        hierarchy = c("antecedent_1", 
                      "antecedent_2"),
        width = 700,
        height = 700,
        nodeSize = "leafCount",
        collapsed = TRUE,
        zoomable = FALSE,
        root = label_name
    )    
    
}


# create a Rosetype visualisation
clean_vis_simple_conditions <- function(df){
    df %>% 
        mutate(
            antecedents = str_to_title(antecedents),
            antecedents = str_trim(antecedents)
        ) %>% 
        group_by(antecedents) %>% 
        summarise(counter = n()) %>% 
        ungroup() %>% 
        arrange(counter) %>% 
        mutate(antecedents = forcats::as_factor(antecedents)) %>% 
        
        echarts4r::e_chart(antecedents) %>% 
        e_pie(counter, name = "Event Condition & Count", roseType = "radius") %>% 
        echarts4r::e_title("") %>% 
        echarts4r::e_legend(FALSE) %>% 
        echarts4r::e_tooltip() 
    
}
# Load and visualise - serious injuries
serious_results_tbl <- readr::read_csv(file = "../02_outputs/serious_results.csv") %>% janitor::clean_names()
## New names:
## * `` -> ...1
## Rows: 22 Columns: 11
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (2): antecedents, consequents
## dbl (9): ...1, antecedent support, consequent support, support, confidence, ...
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
serious_cleaned_tbl <- duplex_cleaning(serious_results_tbl)
## Warning: Expected 2 pieces. Missing pieces filled with `NA` in 6 rows [1, 4, 10,
## 11, 16, 21].
serious_injuries_tree <- aa_collapsibleTree_vis(serious_cleaned_tbl, "Serious Injuries")
serious_cleaned_simple_tbl <- simplex_cleaning(serious_results_tbl)
serious_injuries_rose <- clean_vis_simple_conditions(serious_cleaned_simple_tbl)


htmlwidgets::saveWidget(serious_injuries_tree, file = "serious_injuries_tree.html")
htmlwidgets::saveWidget(serious_injuries_rose, file = "serious_injuries_rose.html")


# Load and Visualise - Slight Injuries
slight_results_tbl <- readr::read_csv(file = "../02_outputs/results_attrition_slight.csv") %>% janitor::clean_names()
## New names:
## * `` -> ...1
## Rows: 144 Columns: 11
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ","
## chr (2): antecedents, consequents
## dbl (9): ...1, antecedent support, consequent support, support, confidence, ...
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
slight_cleaned_tbl <- duplex_cleaning(slight_results_tbl)
## Warning: Expected 2 pieces. Missing pieces filled with `NA` in 21 rows [14,
## 28, 33, 64, 68, 70, 71, 73, 79, 82, 99, 102, 105, 106, 117, 125, 126, 129, 135,
## 137, ...].
slight_injuries_tree <- aa_collapsibleTree_vis(slight_cleaned_tbl, "Slight Injuries")
slight_cleaned_simple_tbl <- simplex_cleaning(slight_results_tbl)
slight_injuries_rose <- clean_vis_simple_conditions(slight_cleaned_simple_tbl)

htmlwidgets::saveWidget(slight_injuries_tree, file = "slight_injuries_tree.html")
htmlwidgets::saveWidget(slight_injuries_rose, file = "slight_injuries_rose.html")

Slight injuries as an Expanding Tree Diagram:

Serious injuries as an Expanding Tree Diagram:

The Rosetype Diagram (part of the echarts4r library) is a spin on the classic Pie Chart. In our context, it represents a further simplification of our generated Association Rules. Two conditions were identified in each rule (i.e., think two columns in a dataset), then each rule was reduced to a single column (i.e., one put on top of the other). Finally, a count of each unique incident condition was calculated. This count of incident conditions was visualized. 

The overwhelmingly clear benefit of the Rosetype Diagram is the speed and clarity with which you can depict and understand the underlying rules. In a matter of minutes, we were able to take over 250,000 recorded incidents and determine with confidence the co-occurring conditions most commonly associated with those incidents. Arguably a powerful example of rapid insight generation.

Slight injuries as a Rosetype Diagram:

Serious injuries as a Rosetype Diagram:

In the case of both the Expanding Tree Diagrams and the Rosetype Diagram, the downside is that the visualizations are a simplistic interpretation of the underlying rule identified, and a loss of the Support, Confidence and Lift detail from the algorithm. Given this reality, we used the core metrics of the Association Rules to generate a short list of rules, which we then visualized. While these visualizations may not appeal to statistical purists, they have the potential to invite participation from non-technical audiences, particularly business leaders, at minimal cost to the “truth” of the underlying algorithm.


Closing

We used Association Rules to analyze a large road-traffic incident dataset to identify preventable incident conditions. We found the approach to be helpful in rapidly synthesizing safety data to identify intervention points, thereby getting to that “tell me what I need to know” point. While we used road-traffic incidents, the approach readily lends itself to organizational safety data, and one of the authors has used it to analyze military injury datasets to inform prevention.

Additionally, we sought to explore novel ways of visualizing the output of Association Rules. Both Expanding Tree’s and Rosetype diagrams provided a clear and more readily accessible interpretation of Association Rules output than traditional methods. The visualization methods used have the advantage of being readily interpretable by non-technical audiences, which may be of benefit when socializing results with safety and/or organizational leaders. However, they both overlooked technical metrics generated by the Association Rules, which may disenfranchise statistical purists.  

If you are interested in using Association Rules, the method may be applied for other HR use cases, including: 

  • Attrition – Identification of conditions that lead to employees staying or leaving an organization. You can find an example of this analysis here.  
  • Performance – Identification of conditions that lead to low or high performance; and 
  • Learning – Identifying training courses/modules that employees often complete together, to feed a recommendation engine (i.e., people that completed this course also did X, Y, and Z courses).
Subscribe to our weekly newsletter to stay up-to-date with the latest HR news, trends, and resources.

Adam McKinnon

Adam is the People Data & Analytics Lead at Reece Group in Melbourne, Australia. Drawing upon a multi-disciplinary academic background in Psychology, IT, Epidemiology and Finance, Adam is an advocate of asking two questions in his work: 1. So What? and 2. Now What? He is responsible for synthesising the scale of people-related topics through statistics and machine learning, enabling organisations to embrace employee centricity in their decision-making.

Are you ready for the future of HR?

Learn modern and relevant HR skills, online

Browse courses Enroll now