In this assignment, we are given a typical e-commerce dataset from an appearal seller, and tasked with predicting whether an item will be returned. We will attempt to train a predictive model to best predict the likelihood of an order being returned, based on the information available about the order.
We will start with data cleanup. It is generally accepted that 50=80% of the time spent on data science projects is devoted to data cleanup.
We will go variable by variable in this exercise. Alongside this markup, I have also submitted the full data cleanup script I am using. Please note you will need to change the WD where relevant.
After cursory initial investigation, we start with the basic step of merging the user and orders datasets. This is possible, as they both contain the same unique order identifier (order_item_id). We also load all packages we will use in data cleanup.
#Load the necessary packages
library(lubridate) # date management
## Warning: package 'lubridate' was built under R version 4.4.3
##
## Attaching package: 'lubridate'
## The following objects are masked from 'package:base':
##
## date, intersect, setdiff, union
library(ggplot2) # charts
## Warning: package 'ggplot2' was built under R version 4.4.3
library(dplyr) # data handling
## Warning: package 'dplyr' was built under R version 4.4.3
##
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
library(glmnet) # implementation of the methods
## Warning: package 'glmnet' was built under R version 4.4.3
## Loading required package: Matrix
## Loaded glmnet 4.1-10
library(caret) # machine-learning workflow
## Warning: package 'caret' was built under R version 4.4.3
## Loading required package: lattice
# set working directory:
setwd("C:/Users/jedel/OneDrive/Desktop/Data for ML class/Individual Assignment")
#Load data
users <- read.csv("users.csv", sep = ",")
orders <- read.csv("orders.csv", sep = ",")
# Merge data into a single source
orders_full <- merge(orders, users, by = "user_id")
In addition to looking through the table, we will also compute simple summary statistics. This is an early step when examining a new dataset. Not all details of preparatory data examination for this project are included in this markup.
summary(orders_full)
## user_id order_item_id order_date delivery_date
## Min. : 9 Min. : 1 Length:75000 Length:75000
## 1st Qu.:14674 1st Qu.:18751 Class :character Class :character
## Median :30479 Median :37501 Mode :character Mode :character
## Mean :25739 Mean :37501
## 3rd Qu.:36477 3rd Qu.:56250
## Max. :43801 Max. :75000
## item_id item_size item_color brand_id
## Min. : 1.0 Length:75000 Length:75000 Min. : 1.00
## 1st Qu.: 175.0 Class :character Class :character 1st Qu.: 5.00
## Median : 479.0 Mode :character Mode :character Median : 24.00
## Mean : 784.7 Mean : 28.83
## 3rd Qu.:1516.0 3rd Qu.: 43.00
## Max. :2224.0 Max. :137.00
## item_price return user_title user_dob
## Min. : 0.00 Min. :0.0000 Length:75000 Length:75000
## 1st Qu.: 29.90 1st Qu.:0.0000 Class :character Class :character
## Median : 49.90 Median :0.0000 Mode :character Mode :character
## Mean : 60.67 Mean :0.4501
## 3rd Qu.: 74.90 3rd Qu.:1.0000
## Max. :999.00 Max. :1.0000
## user_state user_reg_date
## Length:75000 Length:75000
## Class :character Class :character
## Mode :character Mode :character
##
##
##
Next, we will convert returns to a factor, and set all dates to ymd format. This is directly from the assignment. We will calculate delivery time once we get to order date and delivery date.
# Convert all dates into proper proper date format
orders_full$order_date <- ymd(orders_full$order_date)
orders_full$delivery_date <- ymd(orders_full$delivery_date)
orders_full$user_dob <- ymd(orders_full$user_dob)
orders_full$user_reg_date <- ymd(orders_full$user_reg_date)
#convert returns to "Yes" or "No"
orders_full$return <- factor(orders_full$return,
levels = c(0, 1),
labels = c("No", "Yes"))
Now, we will start looking at individual variables. These are in no particular order. Let’s start with item price.
We first pull summary statistics, and view as a table.
# Item price
summary(orders_full$item_price)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.00 29.90 49.90 60.67 74.90 999.00
Prices seem to fall within a reasonable range for apearal, with some extremes at both ends.
There are also two outliers with a price of 999. Appear can be expensive, so we will assume these are correct.
We can also see that 324 observations where the price is 0. All of these observations are unsized. There seem to be a mix of returned and unreturned items. This could indicate missing data, a promotion, or a return. Because these observations account for only .00432% of the dataset, we will exclude them:
orders_full <- orders_full %>%
filter(item_price > 0)
Next, let’s look at brand. We have no information on what these brands are beyond a numeric ID. First, we’ll pull some summary information - I’d like to know how many unique brands there are, and if any of them account for a particularly large portion of sales.
# Check brand ID counts and how much of the data they make up
brand_counts <- orders_full %>%
count(brand_id, sort = TRUE)
n_unique_brands <- nrow(brand_counts)
n_unique_brands
## [1] 129
head(brand_counts, 25) # preview top 10 brands
## brand_id n
## 1 3 8399
## 2 1 6546
## 3 5 4388
## 4 37 3168
## 5 11 2812
## 6 20 2205
## 7 17 1886
## 8 6 1875
## 9 38 1678
## 10 14 1631
## 11 40 1473
## 12 46 1466
## 13 43 1448
## 14 28 1403
## 15 49 1370
## 16 42 1360
## 17 29 1229
## 18 25 1082
## 19 33 982
## 20 21 976
## 21 19 962
## 22 30 962
## 23 47 931
## 24 48 900
## 25 53 884
Here we can see that only three brands (IDs 3, 1, and 5) account for >5% of observations (computations were done outside of R, but can be easily reproduced). For simplicity and time, we will keep these, and merge all others into an “other” category.
We will also need to how brand data is stored. Currently, brand is stored as an integer. Because some models may multiply this by a coefficient, we will change brands to a string.
# Create buckets and set to string
orders_full <- orders_full %>%
mutate(
brand_id = as.character(brand_id), # ensure string type
brand_id = if_else(brand_id %in% c("1", "3", "5"),
brand_id, "Other")
)
We can now see counts per brand over 2/3 of observations are “other”. That’s not perfect, but with our limited information, its what we will go for.
# See a table of the above
table(orders_full$brand_id)
##
## 1 3 5 Other
## 6546 8399 4388 55339
Next, we will look at which Bundesland a given user is from.
# Show observation counts per state
unique(orders_full$user_state)
## [1] "Rhineland-Palatinate" "Lower Saxony"
## [3] "Berlin" "Saxony"
## [5] "Hesse" "Schleswig-Holstein"
## [7] "North Rhine-Westphalia" "Baden-Wuerttemberg"
## [9] "Bavaria" "Mecklenburg-Western Pomerania"
## [11] "Saarland" "Bremen"
## [13] "Hamburg" "Brandenburg"
## [15] "Saxony-Anhalt" "Thuringia"
table(orders_full$user_state)
##
## Baden-Wuerttemberg Bavaria
## 9162 10443
## Berlin Brandenburg
## 2948 1676
## Bremen Hamburg
## 923 1934
## Hesse Lower Saxony
## 5506 11251
## Mecklenburg-Western Pomerania North Rhine-Westphalia
## 1159 16930
## Rhineland-Palatinate Saarland
## 3621 656
## Saxony Saxony-Anhalt
## 2335 868
## Schleswig-Holstein Thuringia
## 4164 1096
This seems reasonable. All 16 federal subjects are represented, and distribution seems believable. Bremen having low total orders while Lower Saxony has high total orders seem odd, but we don’t know much about this company beyond this dataset. No cleanup action will be taken here.
Next, we will look at delivery date, order date, and the calculated variable delivery time.
Authors note: This was the first section I prepared, and served as my Feuertaufe for R. Please accept my apologies for any inefficiencies and redundancies in this section.
We will start by calculating delivery time, and creating a new variable.
#Calcualte delivery time
orders_full$delivery_time <- orders_full$delivery_date - orders_full$order_date
Next, we will ID possible issues by counting NA values. Note that we also did this for all observations in the initial data checks.
# Count NA values in observations
colSums(is.na(orders_full))
## user_id order_item_id order_date delivery_date item_id
## 0 0 0 7900 0
## item_size item_color brand_id item_price return
## 0 0 0 0 0
## user_title user_dob user_state user_reg_date delivery_time
## 0 6351 0 0 7900
We see ~6000 NA values in user_dob, and ~8000 in both delivery date and time.
On further investigation, we can see that NA values in delivery_date are only present when an order has been returned. This could be due to deletion of delivery data upon return, or a similar operational issue. We would like to avoid unnecessarily discarding these observations if possible.
# Conditional NA values
sum(is.na(orders_full$delivery_date[orders_full$return == "Yes"]))
## [1] 0
sum(is.na(orders_full$delivery_date[orders_full$return == "No"]))
## [1] 7900
In addition, we see that there are illogical delivery times in the 1990s. We will remove them, as these account for ~1% of the observations. Imputation could lead to inaccurate predictions as it may distort relationships between delivery date/time and other predictors.
# Summarize delivery date entries
summary(orders_full$delivery_date)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## "1994-12-31" "2016-07-04" "2016-07-26" "2016-04-13" "2016-08-11" "2017-01-23"
## NA's
## "7900"
# Remove observations with impossible delivery dates
orders_full <- orders_full %>%
filter(delivery_date >= as.Date("2016-01-01") | is.na(delivery_date))
We can create a histogram demonstrating that delivery dates are within a believable time-frame. Remember, all order dates are in June-August 2016.
# Create a year-month variable
orders_full$year_month <- floor_date(orders_full$delivery_date, unit = "month")
# update data for histogram to exclude the removed observations
monthly_counts_delivery_date <- orders_full %>%
group_by(year_month) %>%
summarize(count = n(), .groups = "drop")
#Plot histogram
ggplot(monthly_counts_delivery_date, aes(x = year_month, y = count)) +
geom_col(fill = "steelblue") +
scale_x_date(date_labels = "%Y-%m", date_breaks = "1 month") +
labs(title = "Number of Observations by Year-Month of Delivery",
x = "Year-Month",
y = "Number of Orders") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
## Warning: Removed 1 row containing missing values or values outside the scale range
## (`geom_col()`).
We now create a similar visualization to better understand delivery time. For readability, we will only chart observations where delivery >100 days.
#
ggplot(orders_full, aes(x = delivery_time)) +
geom_histogram(binwidth = 1, fill = "steelblue", color = "black") +
labs(title = "Histogram of Delivery Time",
x = "Delivery Time (days)",
y = "Number of Observations") +
coord_cartesian(xlim = c(0, 50)) + # limit x-axis to 0-100
theme_minimal()
## Don't know how to automatically pick scale for object of type <difftime>.
## Defaulting to continuous.
## Warning: Removed 7900 rows containing non-finite outside the scale range
## (`stat_bin()`).
We can see that there the majority of orders are fulfilled within a week. Because high but non-outlier delivery times may disproportionately impact a model, we will bucket these. Because ~10% of our dataset has a value of NA for delivery time, we will also include an “unknown” bucket, as this category may have explanatory power. We choose roughly even buckets based on total delivery time.
# Create buckets
orders_full <- orders_full %>%
mutate(
delivery_days = as.numeric(delivery_time, units = "days"), # convert difftime → numeric days
delivery_time_bucket = case_when(
is.na(delivery_days) ~ "unknown",
delivery_days >= 0 & delivery_days <= 1 ~ "0-1 days",
delivery_days >= 1 & delivery_days <= 2 ~ "0-2 days",
delivery_days >= 2 & delivery_days <= 3 ~ "2-3 days",
delivery_days >= 3 & delivery_days <= 7 ~ "3-7 days",
delivery_days > 7 ~ ">7 days"
)
)
# Display table
table(orders_full$delivery_time_bucket)
##
## >7 days 0-1 days 0-2 days 2-3 days 3-7 days unknown
## 12536 11570 13226 12161 16395 7900
We see that order date can be one of three months:
orders_month_count <- orders_full %>%
mutate(order_month = floor_date(order_date, "month")) # sets all dates to first day of month
# Plot histogram of orders per month
ggplot(orders_month_count, aes(x = order_month)) +
geom_histogram(stat = "count", fill = "steelblue", color = "black") +
labs(title = "Number of Orders per Month",
x = "Month",
y = "Number of Orders") +
scale_x_date(date_labels = "%b %Y", date_breaks = "1 month") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
## Warning in geom_histogram(stat = "count", fill = "steelblue", color = "black"):
## Ignoring unknown parameters: `binwidth`, `bins`, and `pad`
Seasonality can be significant, but this data is noisy. We will instead use three buckets for each month.
orders_full <- orders_full %>%
mutate(order_date_bucket = case_when(
format(order_date, "%Y-%m") == "2016-06" ~ "June",
format(order_date, "%Y-%m") == "2016-07" ~ "July",
format(order_date, "%Y-%m") == "2016-08" ~ "August",
TRUE ~ "Other"
))
# table
table(orders_full$order_date_bucket)
##
## August July June
## 27855 25504 20429
We will look at an additional calculated variable: account age is the difference between order date and user registration date. First we calculate this value:
# Add account age
orders_full <- orders_full %>%
mutate(
# account age in days
account_age_days = as.numeric(order_date - user_reg_date)
)
Numerous observations have a value of -1. This may be because the account was created for a new user. While ‘-1’ is not an intuitive value for for an account’s age, I believe this is realistic. Account creation systems may lag order placement and tracking systems for operational reasons.
As with delivery time, we will create buckets. An age of ‘-1’ will be represented as ‘New’:
# Bucket account age
orders_full <- orders_full %>%
mutate(
account_age_bucket = case_when(
account_age_days == -1 ~ "New",
account_age_days >= 0 & account_age_days <= 365 ~ "0-1 years",
account_age_days > 365 ~ "> 1 year",
TRUE ~ NA_character_ # catch-all for unexpected cases
)
)
table(orders_full$account_age_bucket)
##
## > 1 year 0-1 years New
## 32158 27696 13934
We see here a reasonably even split between buckets, with the exception ‘New’, which has fewer observations.
To get us started, we will use a histogram to visualize user birth dates:
#create counts for each unique string entry
counts_dob <- orders_full %>%
count(user_dob) %>%
arrange(desc(n))
#create chart
ggplot(counts_dob, aes(x = user_dob, y = n)) +
geom_col(fill = "steelblue", colour = "black", width = 0.8) +
labs(title = "Frequency of DOB",
x = "DOB",
y = "Count") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 90, vjust = 0.5, hjust = 1))
## Warning: Removed 1 row containing missing values or values outside the scale range
## (`geom_col()`).
Looks like we have some outliers, and some unrealistic birth dates. This may be because default dates are used when users are creating some accounts. There may be varying default dates on different platforms. There are also a significant number of NA values.
Let’s take a look at some of the outliers:
# Count DOB frequencies
dob_counts <- as.data.frame(table(orders_full$user_dob))
# Sort in decreasing order of frequency
dob_counts <- dob_counts[order(-dob_counts$Freq), ]
# Rename columns for clarity
colnames(dob_counts) <- c("user_dob", "count")
# Show the result
head(dob_counts)
## user_dob count
## 1 1900-11-21 712
## 576 1949-11-21 344
## 4259 1965-06-10 182
## 7581 1980-12-11 107
## 869 1951-12-30 97
## 1278 1954-04-07 88
It looks like some of the outliers occur pretty frequently. To prevent these from interfering with our predictive models, let’s convert them to NA. Specifically, we will convert all dates before 1930 and after 200 to NA. We also convert all values which occur in over 75 observations to NA - this is to remove default dates which could be realistic birth dates. We convert them to NA as we will bucket these values as ‘unknown’ at a later step.
Note on the histogram - we will convert DoB back to ymd at a later step. This histogram is only intended to visualize distribution.
# Convert DOBs that occur > 100 times to NA
orders_full <- orders_full %>%
add_count(user_dob, name = "dob_freq") %>%
mutate(user_dob = if_else(dob_freq > 75, NA_Date_, user_dob)) %>%
select(-dob_freq)
# Convert DOBs before 1930 or after 2000 to NA.
orders_full$user_dob <- ifelse(
year(orders_full$user_dob) < 1930 | year(orders_full$user_dob) > 2000,
NA,
orders_full$user_dob
)
#Re-plot the histogram to see practical results
#update counts for each unique string entry
counts_dob <- orders_full %>%
count(user_dob) %>%
arrange(desc(n))
#create chart
ggplot(counts_dob, aes(x = user_dob, y = n)) +
geom_col(fill = "steelblue", colour = "black", width = 0.8) +
labs(title = "Frequency of DOB",
x = "DOB",
y = "Count") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 90, vjust = 0.5, hjust = 1))
## Warning: Removed 1 row containing missing values or values outside the scale range
## (`geom_col()`).
While there are still some outliers here, this looks like a much more realistic distribution. As before, we will create buckets. Because we don’t know much about user behavior outside this dataset, we will create 5 buckets: 4 quartiles representing the highest 25% of ages, the next highest 25%, etc, and unknown:
# 1. Compute quartiles of user_dob (ignoring NAs)
dob_quartiles <- quantile(orders_full$user_dob, probs = c(0.25, 0.5, 0.75), na.rm = TRUE)
# 2. Create DOB bins based on quartiles, plus "Unknown"
orders_full <- orders_full %>%
mutate(dob_bin = case_when(
is.na(user_dob) ~ "Unknown",
user_dob <= dob_quartiles[1] ~ "Q1", # oldest
user_dob <= dob_quartiles[2] ~ "Q2",
user_dob <= dob_quartiles[3] ~ "Q3",
user_dob > dob_quartiles[3] ~ "Q4"
))
# Make it a factor in quartile order
orders_full$dob_bin <- factor(orders_full$dob_bin,
levels = c("Q1", "Q2", "Q3", "Q4", "Unknown"))
# Convert dates back to ymd
orders_full$user_dob <- as.Date(orders_full$user_dob, origin = "1970-01-01")
We take a quick look at the data on user title:
# Summarize possible values and counts for user_title
unique(orders_full$user_title)
## [1] "Mrs" "Company" "Mr" "Family" "not reported"
table(orders_full$user_title)
##
## Company Family Mr Mrs not reported
## 90 297 2755 70549 97
The outputs here seem reasonable. While some bins are small, they appear distint, and intuitively may have predictive power. We move on without taking action.
Let’s move on to item color. It is odd that this online retailer does not have some identifier for item category (pants, shirts, jackets, etc), beyond just color, but we will work with the data we have available.
# Summarize possible values and counts for item_color
unique(orders_full$item_color)
## [1] "black" "ancient" "grey" "turquoise"
## [5] "green" "ash" "red" "denim"
## [9] "petrol" "brown" "ocher" "berry"
## [13] "aquamarine" "mahagoni" "blue" "white"
## [17] "khaki" "silver" "purple" "dark oliv"
## [21] "pink" "anthracite" "aubergine" "orange"
## [25] "navy" "mint" "olive" "curry"
## [29] "stained" "mocca" "magenta" "darkblue"
## [33] "basalt" "?" "nature" "pallid"
## [37] "bordeaux" "fuchsia" "dark denim" "blau"
## [41] "azure" "hibiscus" "beige" "striped"
## [45] "yellow" "curled" "ecru" "dark garnet"
## [49] "dark navy" "floral" "gold" "aqua"
## [53] "champagner" "jade" "coral" "cognac"
## [57] "dark grey" "ebony" "baltic blue" "cobalt blue"
## [61] "antique pink" "creme" "habana" "mango"
## [65] "currant purple" "kanel" "almond" "terracotta"
## [69] "ivory" "brwon" "apricot" "caramel"
## [73] "aviator" "avocado" "amethyst" "copper coin"
## [77] "opal"
table(orders_full$item_color)
##
## ? almond amethyst ancient anthracite
## 25 13 5 121 1723
## antique pink apricot aqua aquamarine ash
## 10 10 35 254 1191
## aubergine aviator avocado azure baltic blue
## 699 8 4 397 19
## basalt beige berry black blau
## 189 609 1465 13297 178
## blue bordeaux brown brwon caramel
## 8228 481 6328 10 9
## champagner cobalt blue cognac copper coin coral
## 42 25 58 6 133
## creme curled currant purple curry dark denim
## 3 11 6 244 561
## dark garnet dark grey dark navy dark oliv darkblue
## 11 32 21 30 59
## denim ebony ecru floral fuchsia
## 1543 11 230 57 92
## gold green grey habana hibiscus
## 37 3814 6623 14 157
## ivory jade kanel khaki magenta
## 37 46 31 338 537
## mahagoni mango mint mocca nature
## 92 62 125 1366 649
## navy ocher olive opal orange
## 236 1859 1616 1 723
## pallid petrol pink purple red
## 229 2495 788 2936 5406
## silver stained striped terracotta turquoise
## 102 1227 83 7 727
## white yellow
## 2693 249
This seems noisy. We will manually combine some obvious pairs, based on intuition. This was done manually, by using intuition to check and modify groups suggested by an LLM.
It is worth noting that an approach like factor analysis may be applied to similar problems where there is too much noise/too many variables. Because colors are relatively intuitive, we took a simple approach in this case.
orders_full <- orders_full %>%
mutate(item_color_simple = case_when(
item_color %in% c("black", "ebony", "anthracite", "basalt") ~ "black",
item_color %in% c("grey", "ash", "dark grey") ~ "grey",
item_color %in% c("white", "ecru", "creme", "ivory") ~ "white",
item_color %in% c("red", "dark garnet", "bordeaux", "terracotta") ~ "red",
item_color %in% c("blue", "navy", "darkblue", "blau", "denim", "dark denim", "cobalt blue", "baltic blue", "azure") ~ "blue",
item_color %in% c("green", "mint", "olive", "avocado", "petrol", "turquoise", "aqua", "aquamarine") ~ "green",
item_color %in% c("yellow", "gold", "mango", "apricot", "curry", "champagner", "ocher", "orange") ~ "yellow",
item_color %in% c("pink", "magenta", "hibiscus", "antique pink", "fuchsia", "purple", "aubergine", "currant purple") ~ "magenta",
item_color %in% c("brown", "brwon", "mocca", "cognac", "caramel", "mahagoni", "terracotta") ~ "brown",
TRUE ~ "other"
))
Item size presents us with a challenge. There is no unified system for sizing, and sizes are represented numerically, semi-numerically (46+, etc), and non-numerically. Within the numerical sizes, there is no obvious sizing schema.
Let’s take a quick look:
# Summarize possible values and counts for item_size
unique(orders_full$item_size)
## [1] "40" "37" "41" "l" "m" "42" "xl"
## [8] "unsized" "33" "44" "11" "39" "152" "xxl"
## [15] "38" "41+" "43" "28" "34" "1" "13"
## [22] "30" "32" "2" "35" "3" "29" "31"
## [29] "27" "10" "36+" "36" "39+" "26" "38+"
## [36] "s" "18" "7" "7+" "42+" "24" "21"
## [43] "22" "46" "3332" "8" "9" "5+" "5"
## [50] "4+" "8+" "40+" "4" "48" "6+" "10+"
## [57] "37+" "104" "6" "50" "45" "23" "12"
## [64] "116" "128" "14" "47" "xs" "9+" "164"
## [71] "25" "140" "20" "3634" "xxxl" "4032" "45+"
## [78] "19" "3632" "44+" "80" "46+" "54" "2+"
## [85] "100" "56" "3+" "52" "3832" "3132" "3432"
## [92] "58" "11+" "43+" "176" "90" "49" "4034"
table(orders_full$item_size)
##
## 1 10 10+ 100 104 11 11+ 116 12 128
## 28 192 136 11 44 137 15 70 56 87
## 13 14 140 152 164 176 18 19 2 2+
## 28 6 92 77 21 6 73 78 6 2
## 20 21 22 23 24 25 26 27 28 29
## 84 111 99 77 86 50 79 82 62 117
## 3 3+ 30 31 3132 32 33 3332 34 3432
## 23 10 101 143 1 121 106 24 132 8
## 35 36 36+ 3632 3634 37 37+ 38 38+ 3832
## 176 1039 18 15 1 1719 55 4217 110 7
## 39 39+ 4 4+ 40 40+ 4032 4034 41 41+
## 4926 151 118 76 5591 122 9 1 3642 93
## 42 42+ 43 43+ 44 44+ 45 45+ 46 46+
## 3666 92 1200 8 1493 8 165 7 934 7
## 47 48 49 5 5+ 50 52 54 56 58
## 31 663 1 190 196 116 18 18 15 2
## 6 6+ 7 7+ 8 8+ 80 9 9+ 90
## 342 326 308 264 382 184 2 297 149 7
## l m s unsized xl xs xxl xxxl
## 9221 7502 2841 3257 8289 28 6723 69
# Since the table command is difficult to intuitively read, we create a separate dataset to interpret frequencies of sizes
item_size_freq <- orders_full %>%
group_by(item_size) %>%
summarize(count = n(), .groups = "drop") %>%
arrange(desc(count))
item_size_freq$percent <- item_size_freq$count / sum(item_size_freq$count)
item_size_freq$percent <- paste0(round(item_size_freq$percent * 100, 1), "%")
head(item_size_freq)
## # A tibble: 6 × 3
## item_size count percent
## <chr> <int> <chr>
## 1 l 9221 12.5%
## 2 xl 8289 11.2%
## 3 m 7502 10.2%
## 4 xxl 6723 9.1%
## 5 40 5591 7.6%
## 6 39 4926 6.7%
From this, we can identify several patterns:
Numerous observations fit into expected “s, m, l, xl…” categories
A meaningful subset of sizes have a “+” symbol next to them - we will make an assumption and consider all of these “plus sizes”
The majority of non-‘+’ numeric sizes appear to have a numeric value <50.
While they may follow different sizing schemas across observations, the numeric values may nonetheless have explanatory power.
We will take several actions here:
We will merge the letter sizes into: S, M, L, and XL, and XXL
We remove all 536 observations in which numeric size is >50
We merge all remaining numeric sizes into quartiles.
Plus non-numeric, and we will merge these into a separate ‘plus’ category
# Merge the letter sizes into: S, M, L, and XL, and XXL:
orders_full <- orders_full %>%
mutate(
item_size = dplyr::recode(item_size,
"XS" = "S",
"XXXL" = "XXL")
)%>%
# remove observations in which numeric size is >50
mutate(size_numeric = suppressWarnings(as.numeric(item_size))) %>%
filter(is.na(size_numeric) | size_numeric <= 50)
# Merge numeric into quartiles
orders_full <- orders_full %>%
# Standardize letter sizes and trim whitespace
mutate(item_size = toupper(trimws(item_size))) %>%
# Compute quartiles for numeric sizes only
mutate(size_quartiles = ifelse(!is.na(size_numeric),
ntile(size_numeric, 4),
NA)) %>%
# Create unified clean size column
mutate(size_category = case_when(
item_size %in% c("S","M","L","XL","XXL") ~ item_size, # keep standard letters
!is.na(size_numeric) & size_quartiles == 1 ~ "numeric Q1", # numeric quartiles
!is.na(size_numeric) & size_quartiles == 2 ~ "numeric Q2",
!is.na(size_numeric) & size_quartiles == 3 ~ "numeric Q3",
!is.na(size_numeric) & size_quartiles == 4 ~ "numeric Q4",
TRUE ~ "plus" # everything else → plus
)) %>%
# convert to factor with sensible order
mutate(size_category = factor(size_category,
levels = c("S","M","L","XL","XXL",
"numeric Q1","numeric Q2","numeric Q3","numeric Q4",
"plus"))) %>%
select(-size_numeric, -size_quartiles) # remove helper columns
# Let's take a look at the new Size category variable
item_size_category_freq <- orders_full %>%
group_by(size_category) %>%
summarize(count = n(), .groups = "drop") %>%
arrange(desc(count))
item_size_category_freq$percent <- item_size_category_freq$count / sum(item_size_category_freq$count)
item_size_category_freq$percent <- paste0(round(item_size_category_freq$percent * 100, 1), "%")
table(orders_full$size_category) # optional to view above
##
## S M L XL XXL numeric Q1 numeric Q2
## 2841 7502 9221 8289 6723 8324 8323
## numeric Q3 numeric Q4 plus
## 8323 8323 5383
Now that we have completed data cleaning, we will save a CSV of our cleaned data locally. In case you encounter errors if following along with this markup, I have attached the unmodified R Script I use to create orders_clean.csv. Code included as text to enable R markup knitting.
#write.csv(orders_full, "orders_clean.csv", row.names = FALSE)
Now that our data is cleaned, we will try to fit a number of classification models, with the goal of predicting whether an order will be returned. Results are mixed - the lowest error rate we are able to achieve is: 0.3582545.
We will start with a logistic regression. Before we get started, let’s double check that we have the right packages:
library(lubridate) # date management
library(ggplot2) # charts
library(dplyr) # data handling
library(glmnet) # implementation of the methods
library(caret) # machine-learning workflow
library(car) # multicolinearity check
## Warning: package 'car' was built under R version 4.4.3
## Loading required package: carData
## Warning: package 'carData' was built under R version 4.4.3
##
## Attaching package: 'car'
## The following object is masked from 'package:dplyr':
##
## recode
Next, we will set directory, and load our clean data that we saved in the last step:
# set working directory:
setwd("C:/Users/jedel/OneDrive/Desktop/Data for ML class/Individual Assignment")
#Load data
orders_clean <- read.csv("orders_clean.csv", sep = ",")
Next, we will prepare our training and test data. Seed is set to 8888 for reporduceability. We will do 70/30 split:
# Split the sample into training and test set. .7/1 is used as training data.
# Set seed
set.seed(8888) # for reproducibility. I use seed "8888"
# Determine sample size
train_size <- floor(0.7 * nrow(orders_clean))
# Generate random row indices for training set
train_indices <- sample(seq_len(nrow(orders_clean)), size = train_size)
# Split the dataset
orders_train <- orders_clean[train_indices, ]
orders_test <- orders_clean[-train_indices, ]
Next, we start fitting our logistic regression:
# First, Double-check "return" is coded as a factor
orders_train$return <- factor(orders_train$return, levels = c("No", "Yes"))
# Fit logistic regression
base_model <- glm(return ~
size_category + # cleaned size
item_color_simple + # simplified color
brand_id + # brand, note that there are now 4 possible options
item_price + # price (cleaned)
user_title + # title
user_state + # state
dob_bin + # bucket of age ranges + unknown
delivery_time_bucket + # bucket of delivery times + unknown
order_date_bucket + # order date bucket (month)
account_age_bucket, # account age bucket
data = orders_train,
family = binomial)
summary(base_model)
##
## Call:
## glm(formula = return ~ size_category + item_color_simple + brand_id +
## item_price + user_title + user_state + dob_bin + delivery_time_bucket +
## order_date_bucket + account_age_bucket, family = binomial,
## data = orders_train)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 1.297e-01 2.921e-01 0.444 0.657041
## size_categoryM 3.713e-02 3.936e-02 0.943 0.345460
## size_categorynumeric Q1 -6.600e-02 4.054e-02 -1.628 0.103493
## size_categorynumeric Q2 -1.147e-01 4.148e-02 -2.765 0.005700
## size_categorynumeric Q3 -6.141e-02 4.126e-02 -1.488 0.136641
## size_categorynumeric Q4 3.473e-02 4.068e-02 0.854 0.393278
## size_categoryplus -3.964e-01 4.574e-02 -8.666 < 2e-16
## size_categoryS -1.034e-02 5.461e-02 -0.189 0.849821
## size_categoryXL 2.534e-02 3.891e-02 0.651 0.514926
## size_categoryXXL -5.643e-02 4.130e-02 -1.366 0.171798
## item_color_simpleblue 1.819e-01 3.266e-02 5.568 2.57e-08
## item_color_simplebrown 1.188e-01 3.578e-02 3.321 0.000897
## item_color_simplegreen 1.397e-01 3.476e-02 4.019 5.84e-05
## item_color_simplegrey 1.547e-01 3.629e-02 4.264 2.01e-05
## item_color_simplemagenta 8.146e-02 4.199e-02 1.940 0.052371
## item_color_simpleother 3.777e-02 4.216e-02 0.896 0.370306
## item_color_simplered 1.158e-01 4.023e-02 2.878 0.004007
## item_color_simplewhite 1.902e-01 5.333e-02 3.565 0.000363
## item_color_simpleyellow 1.110e-01 5.141e-02 2.159 0.030877
## brand_id3 -2.149e-01 5.172e-02 -4.156 3.24e-05
## brand_id5 -6.065e-02 5.733e-02 -1.058 0.290172
## brand_idOther 1.517e-01 3.672e-02 4.133 3.59e-05
## item_price 6.471e-03 2.714e-04 23.847 < 2e-16
## user_titleFamily -1.034e+00 3.270e-01 -3.161 0.001571
## user_titleMr -1.069e+00 2.874e-01 -3.718 0.000201
## user_titleMrs -9.065e-01 2.832e-01 -3.201 0.001368
## user_titlenot reported -1.118e+00 3.924e-01 -2.849 0.004388
## user_stateBavaria 1.371e-02 3.721e-02 0.368 0.712612
## user_stateBerlin -1.570e-02 5.537e-02 -0.283 0.776806
## user_stateBrandenburg -6.652e-03 6.848e-02 -0.097 0.922625
## user_stateBremen -2.365e-01 8.937e-02 -2.647 0.008126
## user_stateHamburg -5.719e-02 6.547e-02 -0.874 0.382337
## user_stateHesse -3.211e-03 4.408e-02 -0.073 0.941927
## user_stateLower Saxony 1.535e-02 3.669e-02 0.419 0.675577
## user_stateMecklenburg-Western Pomerania 1.201e-01 8.211e-02 1.463 0.143416
## user_stateNorth Rhine-Westphalia -5.863e-02 3.374e-02 -1.738 0.082248
## user_stateRhineland-Palatinate -5.424e-02 5.106e-02 -1.062 0.288103
## user_stateSaarland 1.677e-01 1.046e-01 1.604 0.108726
## user_stateSaxony 4.150e-03 5.979e-02 0.069 0.944660
## user_stateSaxony-Anhalt -6.938e-03 9.046e-02 -0.077 0.938869
## user_stateSchleswig-Holstein 4.117e-02 4.859e-02 0.847 0.396821
## user_stateThuringia -1.707e-01 8.297e-02 -2.057 0.039681
## dob_binQ2 8.375e-02 2.856e-02 2.933 0.003359
## dob_binQ3 1.679e-01 2.852e-02 5.885 3.97e-09
## dob_binQ4 3.028e-01 2.912e-02 10.399 < 2e-16
## dob_binUnknown 9.276e-02 3.535e-02 2.624 0.008699
## delivery_time_bucket0-1 days 2.492e-01 3.214e-02 7.753 8.95e-15
## delivery_time_bucket0-2 days 2.039e-01 3.075e-02 6.630 3.36e-11
## delivery_time_bucket2-3 days 3.303e-01 3.149e-02 10.490 < 2e-16
## delivery_time_bucket3-7 days 3.030e-01 2.953e-02 10.264 < 2e-16
## delivery_time_bucketunknown -1.741e+01 5.254e+01 -0.331 0.740344
## order_date_bucketJuly 4.247e-02 2.317e-02 1.833 0.066771
## order_date_bucketJune 4.160e-02 2.603e-02 1.598 0.110046
## account_age_bucket0-1 years -1.743e-01 2.135e-02 -8.161 3.33e-16
## account_age_bucketNew -7.269e-02 2.745e-02 -2.648 0.008094
##
## (Intercept)
## size_categoryM
## size_categorynumeric Q1
## size_categorynumeric Q2 **
## size_categorynumeric Q3
## size_categorynumeric Q4
## size_categoryplus ***
## size_categoryS
## size_categoryXL
## size_categoryXXL
## item_color_simpleblue ***
## item_color_simplebrown ***
## item_color_simplegreen ***
## item_color_simplegrey ***
## item_color_simplemagenta .
## item_color_simpleother
## item_color_simplered **
## item_color_simplewhite ***
## item_color_simpleyellow *
## brand_id3 ***
## brand_id5
## brand_idOther ***
## item_price ***
## user_titleFamily **
## user_titleMr ***
## user_titleMrs **
## user_titlenot reported **
## user_stateBavaria
## user_stateBerlin
## user_stateBrandenburg
## user_stateBremen **
## user_stateHamburg
## user_stateHesse
## user_stateLower Saxony
## user_stateMecklenburg-Western Pomerania
## user_stateNorth Rhine-Westphalia .
## user_stateRhineland-Palatinate
## user_stateSaarland
## user_stateSaxony
## user_stateSaxony-Anhalt
## user_stateSchleswig-Holstein
## user_stateThuringia *
## dob_binQ2 **
## dob_binQ3 ***
## dob_binQ4 ***
## dob_binUnknown **
## delivery_time_bucket0-1 days ***
## delivery_time_bucket0-2 days ***
## delivery_time_bucket2-3 days ***
## delivery_time_bucket3-7 days ***
## delivery_time_bucketunknown
## order_date_bucketJuly .
## order_date_bucketJune
## account_age_bucket0-1 years ***
## account_age_bucketNew **
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 70675 on 51275 degrees of freedom
## Residual deviance: 61978 on 51221 degrees of freedom
## AIC: 62088
##
## Number of Fisher Scoring iterations: 16
The summary data gives us insight into the predictive power of different variables, as well an overview of the regression function. We can see that order_date_bucket and user_state are not very predictive, and that only some states and size categories are predictive.
Before we move on to our next steps, let’s also check multicolinearity:
# Check multidisciplinary. 1=non, 5=moderate, 10=high
vif(base_model)
## GVIF Df GVIF^(1/(2*Df))
## size_category 1.586985 9 1.025990
## item_color_simple 1.188109 9 1.009622
## brand_id 1.661080 3 1.088258
## item_price 1.442735 1 1.201139
## user_title 1.052868 4 1.006461
## user_state 1.076232 15 1.002452
## dob_bin 1.088840 4 1.010696
## delivery_time_bucket 1.098420 5 1.009432
## order_date_bucket 1.215751 2 1.050053
## account_age_bucket 1.107194 2 1.025784
Everything looks good on multicolinearity - let’s test our model’s accuracy, and see if we can find any improvements.
#Run logistic regression on test data
# Make sure "return" is a factor with the same levels as in training
orders_test$return <- factor(orders_test$return, levels = levels(orders_train$return))
# Make predictions (predicted probabilities that "return" = "Yes")
# Predicted probabilities
pred_probs <- predict(base_model, newdata = orders_test, type = "response")
#Convert probabilities to class preditctions (.5 is cuttoff)
pred_class <- ifelse(pred_probs > 0.5, "Yes", "No")
pred_class <- factor(pred_class, levels = levels(orders_test$return))
#Compare the error rate
# Compare predicted vs actual
error_rate <- mean(pred_class != orders_test$return)
error_rate
## [1] 0.3814161
Our base model is getting an error rate of 0.3813161. Let’s try to improve this. Based on the summary and error rate, we will test a model with fewer variables, removing order date, size, and state:
# Fit new logistic regression
reduced_model <- glm(return ~
item_color_simple + # simplified color
brand_id + # brand, note that there are now 4 possible options
item_price + # price (cleaned)
user_title + # title
dob_bin + # bucket of age ranges + unknown
delivery_time_bucket + # bucket of delivery times + unknown
account_age_bucket, # account age bucket
data = orders_train,
family = binomial)
summary(reduced_model)
##
## Call:
## glm(formula = return ~ item_color_simple + brand_id + item_price +
## user_title + dob_bin + delivery_time_bucket + account_age_bucket,
## family = binomial, data = orders_train)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 7.761e-02 2.876e-01 0.270 0.787296
## item_color_simpleblue 2.038e-01 3.253e-02 6.264 3.75e-10 ***
## item_color_simplebrown 1.143e-01 3.567e-02 3.204 0.001354 **
## item_color_simplegreen 1.328e-01 3.452e-02 3.846 0.000120 ***
## item_color_simplegrey 1.526e-01 3.611e-02 4.226 2.38e-05 ***
## item_color_simplemagenta 8.743e-02 4.188e-02 2.088 0.036827 *
## item_color_simpleother 1.256e-02 4.179e-02 0.301 0.763793
## item_color_simplered 1.183e-01 4.005e-02 2.953 0.003151 **
## item_color_simplewhite 2.244e-01 5.290e-02 4.242 2.22e-05 ***
## item_color_simpleyellow 1.255e-01 5.112e-02 2.455 0.014069 *
## brand_id3 -1.303e-01 4.678e-02 -2.785 0.005352 **
## brand_id5 2.367e-02 5.341e-02 0.443 0.657721
## brand_idOther 1.812e-01 3.530e-02 5.134 2.84e-07 ***
## item_price 6.463e-03 2.534e-04 25.511 < 2e-16 ***
## user_titleFamily -1.112e+00 3.256e-01 -3.415 0.000637 ***
## user_titleMr -1.103e+00 2.868e-01 -3.847 0.000119 ***
## user_titleMrs -9.335e-01 2.825e-01 -3.305 0.000951 ***
## user_titlenot reported -1.137e+00 3.906e-01 -2.910 0.003617 **
## dob_binQ2 8.614e-02 2.846e-02 3.027 0.002469 **
## dob_binQ3 1.706e-01 2.841e-02 6.006 1.90e-09 ***
## dob_binQ4 3.005e-01 2.877e-02 10.444 < 2e-16 ***
## dob_binUnknown 9.483e-02 3.523e-02 2.691 0.007114 **
## delivery_time_bucket0-1 days 2.515e-01 3.199e-02 7.863 3.75e-15 ***
## delivery_time_bucket0-2 days 2.032e-01 3.061e-02 6.639 3.17e-11 ***
## delivery_time_bucket2-3 days 3.275e-01 3.127e-02 10.472 < 2e-16 ***
## delivery_time_bucket3-7 days 3.045e-01 2.930e-02 10.394 < 2e-16 ***
## delivery_time_bucketunknown -1.743e+01 5.266e+01 -0.331 0.740567
## account_age_bucket0-1 years -1.721e-01 2.125e-02 -8.100 5.50e-16 ***
## account_age_bucketNew -7.175e-02 2.734e-02 -2.625 0.008668 **
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 70675 on 51275 degrees of freedom
## Residual deviance: 62136 on 51247 degrees of freedom
## AIC: 62194
##
## Number of Fisher Scoring iterations: 16
# Make sure "return" is a factor with the same levels as in training
orders_test$return <- factor(orders_test$return, levels = levels(orders_train$return))
# Make predictions (predicted probabilities that "return" = "Yes")
# Predicted probabilities
pred_probs <- predict(reduced_model, newdata = orders_test, type = "response")
#Convert probabilities to class preditctions (.5 is cuttoff)
pred_class <- ifelse(pred_probs > 0.5, "Yes", "No")
pred_class <- factor(pred_class, levels = levels(orders_test$return))
#Compare the error rate
# Compare predicted vs actual
error_rate <- mean(pred_class != orders_test$return)
error_rate
## [1] 0.3833728
We see that this has a marginally higher error rate than before. We will try adding size back:
refined_model <- glm(return ~
size_category + # cleaned size
item_color_simple + # simplified color
brand_id + # brand, note that there are now 4 possible options
item_price + # price (cleaned)
user_title + # title
dob_bin + # bucket of age ranges + unknown
delivery_time_bucket + # bucket of delivery times + unknown
account_age_bucket, # account age bucket
data = orders_train,
family = binomial)
summary(refined_model)
##
## Call:
## glm(formula = return ~ size_category + item_color_simple + brand_id +
## item_price + user_title + dob_bin + delivery_time_bucket +
## account_age_bucket, family = binomial, data = orders_train)
##
## Coefficients:
## Estimate Std. Error z value Pr(>|z|)
## (Intercept) 1.600e-01 2.894e-01 0.553 0.580460
## size_categoryM 3.790e-02 3.932e-02 0.964 0.335078
## size_categorynumeric Q1 -5.850e-02 4.043e-02 -1.447 0.147881
## size_categorynumeric Q2 -1.094e-01 4.134e-02 -2.646 0.008137 **
## size_categorynumeric Q3 -5.554e-02 4.111e-02 -1.351 0.176735
## size_categorynumeric Q4 3.633e-02 4.064e-02 0.894 0.371301
## size_categoryplus -3.983e-01 4.564e-02 -8.727 < 2e-16 ***
## size_categoryS -9.631e-03 5.454e-02 -0.177 0.859849
## size_categoryXL 2.682e-02 3.884e-02 0.690 0.489944
## size_categoryXXL -5.701e-02 4.124e-02 -1.383 0.166782
## item_color_simpleblue 1.824e-01 3.263e-02 5.591 2.25e-08 ***
## item_color_simplebrown 1.206e-01 3.576e-02 3.372 0.000745 ***
## item_color_simplegreen 1.362e-01 3.470e-02 3.925 8.69e-05 ***
## item_color_simplegrey 1.588e-01 3.621e-02 4.386 1.16e-05 ***
## item_color_simplemagenta 8.057e-02 4.196e-02 1.920 0.054822 .
## item_color_simpleother 3.972e-02 4.212e-02 0.943 0.345649
## item_color_simplered 1.178e-01 4.020e-02 2.930 0.003387 **
## item_color_simplewhite 1.972e-01 5.315e-02 3.711 0.000207 ***
## item_color_simpleyellow 1.163e-01 5.133e-02 2.265 0.023501 *
## brand_id3 -2.292e-01 5.125e-02 -4.472 7.73e-06 ***
## brand_id5 -5.943e-02 5.720e-02 -1.039 0.298827
## brand_idOther 1.420e-01 3.650e-02 3.890 0.000100 ***
## item_price 6.299e-03 2.569e-04 24.517 < 2e-16 ***
## user_titleFamily -1.072e+00 3.260e-01 -3.290 0.001001 **
## user_titleMr -1.075e+00 2.869e-01 -3.745 0.000180 ***
## user_titleMrs -9.081e-01 2.827e-01 -3.212 0.001316 **
## user_titlenot reported -1.087e+00 3.917e-01 -2.776 0.005510 **
## dob_binQ2 8.567e-02 2.850e-02 3.006 0.002649 **
## dob_binQ3 1.707e-01 2.846e-02 5.998 2.00e-09 ***
## dob_binQ4 3.050e-01 2.891e-02 10.548 < 2e-16 ***
## dob_binUnknown 9.302e-02 3.529e-02 2.636 0.008400 **
## delivery_time_bucket0-1 days 2.480e-01 3.206e-02 7.736 1.03e-14 ***
## delivery_time_bucket0-2 days 2.004e-01 3.067e-02 6.534 6.40e-11 ***
## delivery_time_bucket2-3 days 3.265e-01 3.134e-02 10.416 < 2e-16 ***
## delivery_time_bucket3-7 days 3.006e-01 2.936e-02 10.239 < 2e-16 ***
## delivery_time_bucketunknown -1.740e+01 5.256e+01 -0.331 0.740599
## account_age_bucket0-1 years -1.747e-01 2.129e-02 -8.205 2.30e-16 ***
## account_age_bucketNew -7.424e-02 2.741e-02 -2.708 0.006762 **
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## (Dispersion parameter for binomial family taken to be 1)
##
## Null deviance: 70675 on 51275 degrees of freedom
## Residual deviance: 62011 on 51238 degrees of freedom
## AIC: 62087
##
## Number of Fisher Scoring iterations: 16
#Run logistic regression on test data
# Make sure "return" is a factor with the same levels as in training
orders_test$return <- factor(orders_test$return, levels = levels(orders_train$return))
# Make predictions (predicted probabilities that "return" = "Yes")
# Predicted probabilities
pred_probs <- predict(refined_model, newdata = orders_test, type = "response")
#Convert probabilities to class preditctions (.5 is cuttoff)
pred_class <- ifelse(pred_probs > 0.5, "Yes", "No")
pred_class <- factor(pred_class, levels = levels(orders_test$return))
#Compare the error rate
# Compare predicted vs actual
error_rate <- mean(pred_class != orders_test$return)
error_rate
## [1] 0.3802785
We see that this is marginally higher success rate than the base model. We can visualize this in a confusion matrix:
#Visualize above via a confusion matrix
cm <- confusionMatrix(pred_class, orders_test$return)
cm_df <- as.data.frame(cm$table)
colnames(cm_df) <- c("Predicted", "Actual", "Count")
cm_df$Type <- with(cm_df, ifelse(
(Predicted == "Yes" & Actual == "Yes") | (Predicted == "No" & Actual == "No"),
"Correct", "Incorrect"
))
ggplot(cm_df, aes(x = Actual, y = Predicted, fill = Type)) +
geom_tile(color = "white", size = 0.5) +
geom_text(aes(label = Count), size = 6) +
scale_fill_manual(values = c("Correct" = "lightgreen", "Incorrect" = "lightcoral")) +
labs(title = "Confusion Matrix",
x = "Actual Class",
y = "Predicted Class") +
theme_minimal(base_size = 14) +
theme(legend.position = "none")
## Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
## ℹ Please use `linewidth` instead.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.
We’ve tried a number of of optimization and have only reached ~62% accuracy. Let’s try some alternative models.
Next, we will try running a classification tree. This tree splits data on predictors to predict whether and order will be returned or not.
As always, we’ll start off by loading our packages and data:
# Packages
library(rpart) # for building decision trees
## Warning: package 'rpart' was built under R version 4.4.3
library(rpart.plot)# for visualizing trees
## Warning: package 'rpart.plot' was built under R version 4.4.3
library(lubridate) # date management
library(ggplot2) # charts
library(dplyr) # data handling
library(glmnet) # implementation of the methods
library(caret) # machine-learning workflow
library(car) # multicolinearity check
# set working directory:
setwd("C:/Users/jedel/OneDrive/Desktop/Data for ML class/Individual Assignment")
#Load data
orders_clean <- read.csv("orders_clean.csv", sep = ",")
Next, we’ll split our data into training and test sets:
# Split the sample into training and test set. .7/1 is used as training data.
# Set seed
set.seed(8888) # for reproducibility. I use seed "8888"
# Determine sample size
train_size <- floor(0.7 * nrow(orders_clean))
# Generate random row indices for training set
train_indices <- sample(seq_len(nrow(orders_clean)), size = train_size)
# Split the dataset
orders_train <- orders_clean[train_indices, ]
orders_test <- orders_clean[-train_indices, ]
Next, we’ll user user our data to build our baseline tree
# Build a classification tree
tree_model <- rpart(
return ~
size_category + # cleaned size
item_color_simple + # simplified color
brand_id + # brand, note that there are now 4 possible options
item_price + # price (cleaned)
user_title + # title
dob_bin + # bucket of age ranges + unknown
delivery_time_bucket + # bucket of delivery times + unknown
account_age_bucket, # account age bucket
data = orders_train,
method = "class", # classification tree
control = rpart.control(cp = 0.01, minsplit = 20) # tuning parameters
)
# Visualize the tree
rpart.plot(tree_model, type = 2, extra = 104, fallen.leaves = TRUE)
I have included a visualization of our first tree above. Before testing our tree on the test data, let’s take a look at variable importance: It looks like item color does not provide much explanatory power.
#Variable importance
tree_model$variable.importance
## delivery_time_bucket item_price brand_id
## 2583.266571 593.999012 105.171679
## size_category item_color_simple
## 10.177667 2.008834
Let’s take a look at how the model performs:
# Predict class labels on the training set
pred_class <- predict(tree_model, newdata = orders_train, type = "class")
# Actual values
actual <- orders_train$return
# Create a confusion table
conf_matrix <- table(Predicted = pred_class, Actual = actual)
conf_matrix
## Actual
## Predicted No Yes
## No 13079 4612
## Yes 14845 18740
error_rate <- sum(pred_class != actual) / length(actual)
error_rate
## [1] 0.3794563
Error rate is not meaningfully below our results from logistic regression. Let’s try optimizing some the complexity parameter, which controls how many branches and hence how precise (to the training data) the tree can be. Below, we test a grid of possible values to find an optimal complexity parameter:
#optimize model
# Define parameter grid
grid <- expand.grid(cp = seq(0.0001, 0.05, length.out = 20))
grid
## cp
## 1 0.000100000
## 2 0.002726316
## 3 0.005352632
## 4 0.007978947
## 5 0.010605263
## 6 0.013231579
## 7 0.015857895
## 8 0.018484211
## 9 0.021110526
## 10 0.023736842
## 11 0.026363158
## 12 0.028989474
## 13 0.031615789
## 14 0.034242105
## 15 0.036868421
## 16 0.039494737
## 17 0.042121053
## 18 0.044747368
## 19 0.047373684
## 20 0.050000000
# 5-fold cross validation
train_control <- trainControl(method = "cv", number = 5)
# Train a tuned tree
tree_tuned <- train(
return ~ size_category + item_color_simple + brand_id + item_price +
user_title + user_state + dob_bin + delivery_time_bucket +
order_date_bucket + account_age_bucket,
data = orders_train,
method = "rpart",
trControl = train_control,
tuneGrid = grid
)
#Show best CP. This step takes some time
tree_tuned$bestTune # shows best cp
## cp
## 2 0.002726316
# We see an optimal cp of 0.002726316
We can see that the optimal CP is ~0.0027. Let’s plug that into our model and see what kind of error rate we get:
# We now use this CP for our optimized tree
final_tree <- rpart(
return ~ size_category + item_color_simple + brand_id + item_price +
user_title + user_state + dob_bin + delivery_time_bucket +
order_date_bucket + account_age_bucket,
data = orders_train,
method = "class",
control = rpart.control(cp = tree_tuned$bestTune$cp)
)
# Visualize the tree
rpart.plot(final_tree, type = 2, extra = 104, fallen.leaves = TRUE)
#Variable importance
final_tree$variable.importance
## delivery_time_bucket item_price brand_id
## 2583.266571 593.999012 130.967684
## size_category order_date_bucket item_color_simple
## 10.177667 7.634840 2.008834
# Predict class labels on the training set
pred_class <- predict(final_tree, newdata = orders_train, type = "class")
# Actual values
actual <- orders_train$return
# Create a confusion table
conf_matrix <- table(Predicted = pred_class, Actual = actual)
conf_matrix
## Actual
## Predicted No Yes
## No 14651 5982
## Yes 13273 17370
#Show error rate
error_rate <- sum(pred_class != actual) / length(actual)
error_rate
## [1] 0.3755168
We’ve reached our highest accuracy yet, but there still isn’t a meaningful improvement from logistic regression. Let’s see if we can do better.
Random forests models create a series of classification trees. These trees are artificially limited in the number of predictors they can ‘choose’ from, meaning that predictive value can be drawn from less dominant predictors. For example, in our single classification tree, delivery time and item price had outsize importance.
As always we start with packages and data:
library(rpart) # for building decision trees
library(rpart.plot) # for visualizing trees
library(lubridate) # date management
library(ggplot2) # charts
library(dplyr) # data handling
library(glmnet) # implementation of the methods
library(caret) # machine-learning workflow
library(car) # multicolinearity check
library(randomForest) # Makes random forests
## Warning: package 'randomForest' was built under R version 4.4.3
## randomForest 4.7-1.2
## Type rfNews() to see new features/changes/bug fixes.
##
## Attaching package: 'randomForest'
## The following object is masked from 'package:dplyr':
##
## combine
## The following object is masked from 'package:ggplot2':
##
## margin
# set working directory:
setwd("C:/Users/jedel/OneDrive/Desktop/Data for ML class/Individual Assignment")
#Load data
orders_clean <- read.csv("orders_clean.csv", sep = ",")
Next, we set our seed and split our data into test and training:
# Set seed
set.seed(8888) # for reproducibility. I use seed "8888"
# Determine sample size
train_size <- floor(0.7 * nrow(orders_clean))
# Generate random row indices for training set
train_indices <- sample(seq_len(nrow(orders_clean)), size = train_size)
# Split the dataset
orders_train <- orders_clean[train_indices, ]
orders_test <- orders_clean[-train_indices, ]
# Ensure return is a factor
orders_train$return <- as.factor(orders_train$return)
orders_test$return <- as.factor(orders_test$return)
Next, we’ll train our baseline model. I have an off-the-shelf non-gaming laptop, so I will be limited by computing power. I limit initial tree count to 500:
# Fit base model
rf_model <- randomForest(
return ~ size_category + item_color_simple + brand_id + item_price +
user_title + user_state + dob_bin + delivery_time_bucket +
order_date_bucket + account_age_bucket,
data = orders_train,
ntree = 500, # number of trees
mtry = 3, # number of variables considered at each split
importance = TRUE # to check variable importance later
)
# Predict on test set
rf_preds <- predict(rf_model, orders_test, type = "class")
# Error rate
rf_error_rate <- mean(rf_preds != orders_test$return)
rf_error_rate
## [1] 0.3633964
Before optimizing the random forest model, we will see if bagging has an impact. Bagging sets the number of predictors a available to a given tree to equal the total number of predictors. In our case, we are going fro 3 to 10 predictors.
Bagging follows a similar set of operations:
# Bagging (all predictors at each split)
rf_bag <- randomForest(
return ~ size_category + item_color_simple + brand_id + item_price +
user_title + user_state + dob_bin + delivery_time_bucket +
order_date_bucket + account_age_bucket,
data = orders_train,
ntree = 500,
mtry = 10, # use all predictors at each split
importance = TRUE
)
# Check on test data
bag_preds <- predict(rf_bag, orders_test, type = "class")
bag_error_rate <- mean(bag_preds != orders_test$return)
bag_error_rate
## [1] 0.374636
This is a little bit worse than our previous model, so we will proceed with optimizing the original model. We will test several variables to first attempt to optimize the mtry and ntree parameters, then see if we can reduce the number of variables used.
Let’s start by increasing the prdictors a tree can split into to 4:
# Build random forest with 4 variables considered per split
rf_4split <- randomForest(
return ~ size_category + item_color_simple + brand_id + item_price +
user_title + user_state + dob_bin + delivery_time_bucket +
order_date_bucket + account_age_bucket,
data = orders_train,
ntree = 500, # number of trees (default is 500)
mtry = 4, # number of variables considered at each split
importance = TRUE # to check variable importance later
)
# Predict on test set
rf_preds <- predict(rf_4split, orders_test, type = "class")
# Error rate
rf_error_rate <- mean(rf_preds != orders_test$return)
rf_error_rate
## [1] 0.3684929
That’s a bit worse than our base model. Let’s try 2 instead of 4:
# Build random forest with 2 variables considered per split
rf_2split <- randomForest(
return ~ size_category + item_color_simple + brand_id + item_price +
user_title + user_state + dob_bin + delivery_time_bucket +
order_date_bucket + account_age_bucket,
data = orders_train,
ntree = 500, # number of trees (default is 500)
mtry = 2, # number of variables considered at each split
importance = TRUE # to check variable importance later
)
# Predict on test set
rf_preds <- predict(rf_2split, orders_test, type = "class")
# Error rate
rf_error_rate <- mean(rf_preds != orders_test$return)
rf_error_rate
## [1] 0.358755
This is our best yet! Now let’s test out a variation where we grow 1000 rather than 500 trees:
# Build random forest with mtry = 2 and ntree = 1000
rf_2split1000 <- randomForest(
return ~ size_category + item_color_simple + brand_id + item_price +
user_title + user_state + dob_bin + delivery_time_bucket +
order_date_bucket + account_age_bucket,
data = orders_train,
ntree = 1000, # number of trees (default is 500)
mtry = 2, # number of variables considered at each split
importance = TRUE # to check variable importance later
)
# Predict on test set
rf_preds <- predict(rf_2split1000, orders_test, type = "class")
# Error rate
rf_error_rate <- mean(rf_preds != orders_test$return)
rf_error_rate
## [1] 0.3604842
We see that this is slightly worse, so we’ll stay at 500. This is inconsistent, and I experienced different results in testing with a marginally different dataset. In the case of this project, we’ll keep it at 500, and my CPUs will be relieved.
Now let’s see if we can optimize any of the predictors. We start by looking at variable importance. Higher mean indicates predictive importance, and higher gini indicates relative importance of a variable for splitting/partitioning data:
# Since it's the best we've gotten so far, let's look at variable importance for the rf_2split1000 model
varImpPlot(rf_2split)
Looking at the relative impact of all variables across both accuracy and gini, we see that user_title is relatively unimportant. Let’s try excluding it:
# Build random forest with mtry = 2 and without user_title
rf_2splitnotitle <- randomForest(
return ~ size_category + item_color_simple + brand_id + item_price + user_state + dob_bin + delivery_time_bucket +
order_date_bucket + account_age_bucket,
data = orders_train,
ntree = 500, # number of trees (default is 500)
mtry = 2, # number of variables considered at each split
importance = TRUE # to check variable importance later
)
# Predict on test set
rf_preds <- predict(rf_2splitnotitle, orders_test, type = "class")
# Error rate
rf_error_rate <- mean(rf_preds != orders_test$return)
rf_error_rate
## [1] 0.3586185
This is our best yet! Before optimizing further, let’s try a neural network.
As a final step, we attempted to fit a neural network. Due to time limitations, we were not able to explore this approach fully, but were able to fit a limited model with 5 hidden layers. In 50 iterations, we were able to reduce loss from 41412 to 30906. I am personally interested in exploring further, but this is outside the scope of this project.
Based on the above, a random forest is the best model we were able to identify over the course of this project.